nandi/freeqsay-nimpublic Fork 0
fa53aa2
Commits
Clone
git clone https://git.rickub.com/nandi/freeqsay-nim.git
git clone ssh://git@rickub.com/nandi/freeqsay-nim.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

freeqsay: Nim port of the Clojure implementation

Ports every namespace: SHA-256/HMAC/HKDF, the avatar-v1 derivation (sfc32 on
uint32 lanes, matching the JavaScript original), the 16x24 sprite, truecolor
half-block ANSI, the escape-aware cowsay balloon, the stored-deflate PNG
encoder, handle resolution and the CLI.

The avatar conformance fixture is replayed as-is, so the port stays
byte-identical to the Clojure and TypeScript implementations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T09:30:43-07:00 Browse files
fa53aa2
added .gitignore +5 -0
new file mode 100644
@@ -0,0 +1,5 @@
1+/freeqsay
2+/freeqsay.png
3+nimcache/
4+*.o
5+/tests/test_avatar
new file mode 100644
@@ -0,0 +1,5 @@
1+/freeqsay
2+/freeqsay.png
3+nimcache/
4+*.o
5+/tests/test_avatar
added README.md +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+# freeqsay
2+
3+Cowsay with your FreeqWorld face. Written in Nim — a port of the
4+[Clojure/Jolt implementation](https://gitlab.com/nandithebull/freeqsay), which
5+is itself a port of the original TypeScript.
6+
7+```bash
8+freeqsay <handle> <message>
9+```
10+
11+Resolves a Bluesky handle → DID, derives the deterministic `avatar-v1` character,
12+and prints a cowsay balloon above truecolor ANSI art.
13+
14+## Usage
15+
16+```bash
17+freeqsay jay.bsky.team hello from the timeline
18+freeqsay @pfrazee.com "ships free software"
19+echo "piped in" | freeqsay jay.bsky.team
20+
21+# wrap width, thought bubble, scale
22+freeqsay someone.bsky.social -W 20 short lines
23+freeqsay jay.bsky.team --think hmm
24+freeqsay jay.bsky.team --scale 3 big face says hi
25+
26+# raw DID (no network)
27+freeqsay did:plc:ewvi7nmzuoqusbcablsm7c4h offline ok
28+```
29+
30+Without a built binary, run it straight from the source tree:
31+
32+```bash
33+nim r -d:ssl src/freeqsay.nim jay.bsky.team hello world
34+```
35+
36+## Library
37+
38+```nim
39+import freeqsay
40+
41+echo freeqsay("jay.bsky.team", "hello").output
42+```
43+
44+Modules: `freeqsay/say` (balloon + face), `freeqsay/avatar` (traits and the
45+16×24 sprite), `freeqsay/ansi` (truecolor half-block rendering), `freeqsay/png`
46+(RGBA → PNG), `freeqsay/resolve` (handle → DID), `freeqsay/balloon`,
47+`freeqsay/sha256` (SHA-256 / HMAC / HKDF). Importing `freeqsay` re-exports all
48+of them.
49+
50+## Development
51+
52+```bash
53+just build # optimized binary → ./freeqsay
54+just test # test suite
55+just run <handle> <message> # run from source, no build
56+just shot <handle> <message> # gapless termshot png
57+just --list # everything else
58+```
59+
60+`nimble build` and `nimble test` work too. `-d:ssl` is set in `config.nims`,
61+since handle resolution goes over HTTPS through the stdlib client.
62+
63+Everything but the HTTP call and JSON parsing is pure Nim with no dependencies:
64+SHA-256, HMAC and HKDF live in `freeqsay/sha256`, and the PNG encoder writes
65+stored deflate blocks.
66+
67+## How it works
68+
69+```
70+handle → resolveHandle → did
71+did → HKDF-SHA256(avatar-v1) → traits → 16×24 sprite → ANSI
72+message → cowsay balloon
73+```
74+
75+`fixtures/avatar-conformance.json` pins the cross-implementation contract:
76+canonical seed, traits, and a sprite hash over all four facings. The suite
77+replays it, so this port derives byte-identical characters to the Clojure and
78+TypeScript implementations — including the sfc32 PRNG, reproduced on `uint32`
79+lanes so trait selection matches the JavaScript original bit for bit.
new file mode 100644
@@ -0,0 +1,79 @@
1+# freeqsay
2+
3+Cowsay with your FreeqWorld face. Written in Nim — a port of the
4+[Clojure/Jolt implementation](https://gitlab.com/nandithebull/freeqsay), which
5+is itself a port of the original TypeScript.
6+
7+```bash
8+freeqsay <handle> <message>
9+```
10+
11+Resolves a Bluesky handle → DID, derives the deterministic `avatar-v1` character,
12+and prints a cowsay balloon above truecolor ANSI art.
13+
14+## Usage
15+
16+```bash
17+freeqsay jay.bsky.team hello from the timeline
18+freeqsay @pfrazee.com "ships free software"
19+echo "piped in" | freeqsay jay.bsky.team
20+
21+# wrap width, thought bubble, scale
22+freeqsay someone.bsky.social -W 20 short lines
23+freeqsay jay.bsky.team --think hmm
24+freeqsay jay.bsky.team --scale 3 big face says hi
25+
26+# raw DID (no network)
27+freeqsay did:plc:ewvi7nmzuoqusbcablsm7c4h offline ok
28+```
29+
30+Without a built binary, run it straight from the source tree:
31+
32+```bash
33+nim r -d:ssl src/freeqsay.nim jay.bsky.team hello world
34+```
35+
36+## Library
37+
38+```nim
39+import freeqsay
40+
41+echo freeqsay("jay.bsky.team", "hello").output
42+```
43+
44+Modules: `freeqsay/say` (balloon + face), `freeqsay/avatar` (traits and the
45+16×24 sprite), `freeqsay/ansi` (truecolor half-block rendering), `freeqsay/png`
46+(RGBA → PNG), `freeqsay/resolve` (handle → DID), `freeqsay/balloon`,
47+`freeqsay/sha256` (SHA-256 / HMAC / HKDF). Importing `freeqsay` re-exports all
48+of them.
49+
50+## Development
51+
52+```bash
53+just build # optimized binary → ./freeqsay
54+just test # test suite
55+just run <handle> <message> # run from source, no build
56+just shot <handle> <message> # gapless termshot png
57+just --list # everything else
58+```
59+
60+`nimble build` and `nimble test` work too. `-d:ssl` is set in `config.nims`,
61+since handle resolution goes over HTTPS through the stdlib client.
62+
63+Everything but the HTTP call and JSON parsing is pure Nim with no dependencies:
64+SHA-256, HMAC and HKDF live in `freeqsay/sha256`, and the PNG encoder writes
65+stored deflate blocks.
66+
67+## How it works
68+
69+```
70+handle → resolveHandle → did
71+did → HKDF-SHA256(avatar-v1) → traits → 16×24 sprite → ANSI
72+message → cowsay balloon
73+```
74+
75+`fixtures/avatar-conformance.json` pins the cross-implementation contract:
76+canonical seed, traits, and a sprite hash over all four facings. The suite
77+replays it, so this port derives byte-identical characters to the Clojure and
78+TypeScript implementations — including the sfc32 PRNG, reproduced on `uint32`
79+lanes so trait selection matches the JavaScript original bit for bit.
added config.nims +3 -0
new file mode 100644
@@ -0,0 +1,3 @@
1+# Handle resolution goes over HTTPS, so the stdlib client needs TLS everywhere:
2+# building without it turns every resolve into a transport failure.
3+switch("define", "ssl")
new file mode 100644
@@ -0,0 +1,3 @@
1+# Handle resolution goes over HTTPS, so the stdlib client needs TLS everywhere:
2+# building without it turns every resolve into a transport failure.
3+switch("define", "ssl")
added fixtures/avatar-conformance.json +195 -0
new file mode 100644
@@ -0,0 +1,195 @@
1+[
2+ {
3+ "did": "did:plc:ewvi7nmzuoqusbcablsm7c4h",
4+ "avatar_version": "avatar-v1",
5+ "canonical_seed": "f900fed08848617755407ef6c78207e5d89feb6587914df4d625e7ca62eee7c7",
6+ "expected_traits": {
7+ "body_silhouette": "broad",
8+ "head_shape": "round",
9+ "skin_palette": "#f4d6b8",
10+ "hair_shape": "mohawk",
11+ "hair_palette": "#b8434e",
12+ "eye_pixels": "visor",
13+ "shirt_jacket": "#3f7cac",
14+ "pants_skirt": "#5a3825",
15+ "shoes": "#5a3825",
16+ "accessory": "flower",
17+ "idle_movement": "sway",
18+ "walk_cadence": "steady",
19+ "speech_sound": "burble",
20+ "accent_palette": "#118ab2",
21+ "arrival_effect": "drop",
22+ "musical_leitmotif": "motif-v1"
23+ },
24+ "sprite_hash": "377964bc49c221304968a363581201706ad893f5a5953db82fb7ec8e75e1b9fb",
25+ "leitmotif": {
26+ "notes": [
27+ 60,
28+ 55,
29+ 60
30+ ],
31+ "rhythmic_cell": [
32+ 0.5,
33+ 0.5,
34+ 1
35+ ],
36+ "instrument": "square25"
37+ }
38+ },
39+ {
40+ "did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
41+ "avatar_version": "avatar-v1",
42+ "canonical_seed": "dad7d85259fa1af47ffe00744c596a2053ed7d4ea3a3be4d2b9271dc3efbe8c4",
43+ "expected_traits": {
44+ "body_silhouette": "round",
45+ "head_shape": "round",
46+ "skin_palette": "#c68863",
47+ "hair_shape": "curls",
48+ "hair_palette": "#2b2b3a",
49+ "eye_pixels": "wide",
50+ "shirt_jacket": "#8447ad",
51+ "pants_skirt": "#6b3a55",
52+ "shoes": "#b8434e",
53+ "accessory": "hat",
54+ "idle_movement": "tap",
55+ "walk_cadence": "ambling",
56+ "speech_sound": "hum",
57+ "accent_palette": "#06d6a0",
58+ "arrival_effect": "drop",
59+ "musical_leitmotif": "motif-v1"
60+ },
61+ "sprite_hash": "0cab593c012acfc4e57f3d97a18213c97376aa9facc405a0510e35e49bcdacfa",
62+ "leitmotif": {
63+ "notes": [
64+ 70,
65+ 72,
66+ 68,
67+ 70
68+ ],
69+ "rhythmic_cell": [
70+ 0.5,
71+ 0.5,
72+ 0.5,
73+ 1.5
74+ ],
75+ "instrument": "fmbell"
76+ }
77+ },
78+ {
79+ "did": "did:plc:z72i7hdynmk6r22z27h6tvur",
80+ "avatar_version": "avatar-v1",
81+ "canonical_seed": "bdd2a104bb08f6bc314b51e3202102b04994f16a552f042e34bdfd7870f8d566",
82+ "expected_traits": {
83+ "body_silhouette": "round",
84+ "head_shape": "square",
85+ "skin_palette": "#8d5a3b",
86+ "hair_shape": "curls",
87+ "hair_palette": "#b8434e",
88+ "eye_pixels": "dot",
89+ "shirt_jacket": "#c9a227",
90+ "pants_skirt": "#5a3825",
91+ "shoes": "#5a3825",
92+ "accessory": "none",
93+ "idle_movement": "blink",
94+ "walk_cadence": "bouncy",
95+ "speech_sound": "hum",
96+ "accent_palette": "#9b5de5",
97+ "arrival_effect": "sparkle",
98+ "musical_leitmotif": "motif-v1"
99+ },
100+ "sprite_hash": "e17cb5dd867f68c6c77831a902a11d2d5627749e6ef323393e473d5d5a7ab1ec",
101+ "leitmotif": {
102+ "notes": [
103+ 62,
104+ 60,
105+ 62,
106+ 58
107+ ],
108+ "rhythmic_cell": [
109+ 0.5,
110+ 0.5,
111+ 0.5,
112+ 1.5
113+ ],
114+ "instrument": "fmbell"
115+ }
116+ },
117+ {
118+ "did": "did:key:z6Mkfriq1MqLBoPWecGoDLjguo1sB9brj6wT3qZ5BxkKpuP6",
119+ "avatar_version": "avatar-v1",
120+ "canonical_seed": "18e770b6fa926ae36528fe4e65be9376cda9c66d068902fed010fed98fef8541",
121+ "expected_traits": {
122+ "body_silhouette": "slim",
123+ "head_shape": "round",
124+ "skin_palette": "#8d5a3b",
125+ "hair_shape": "bun",
126+ "hair_palette": "#3f7cac",
127+ "eye_pixels": "wide",
128+ "shirt_jacket": "#8447ad",
129+ "pants_skirt": "#5a3825",
130+ "shoes": "#b8434e",
131+ "accessory": "antenna",
132+ "idle_movement": "bob",
133+ "walk_cadence": "bouncy",
134+ "speech_sound": "hum",
135+ "accent_palette": "#ffd166",
136+ "arrival_effect": "sparkle",
137+ "musical_leitmotif": "motif-v1"
138+ },
139+ "sprite_hash": "845bba278bad03a40100828895e11fc0b09fa8c74872942113627d7a7f731865",
140+ "leitmotif": {
141+ "notes": [
142+ 63,
143+ 65,
144+ 72
145+ ],
146+ "rhythmic_cell": [
147+ 0.5,
148+ 1,
149+ 0.5
150+ ],
151+ "instrument": "fmbell"
152+ }
153+ },
154+ {
155+ "did": "did:example:freeqworld-conformance-0001",
156+ "avatar_version": "avatar-v1",
157+ "canonical_seed": "8b9cea2f3098a8d3e512dc07330e773528fa09da51435ddf799c9a16c2b6d0b7",
158+ "expected_traits": {
159+ "body_silhouette": "broad",
160+ "head_shape": "square",
161+ "skin_palette": "#9fb8ad",
162+ "hair_shape": "curls",
163+ "hair_palette": "#b8434e",
164+ "eye_pixels": "dot",
165+ "shirt_jacket": "#8447ad",
166+ "pants_skirt": "#5a3825",
167+ "shoes": "#1e1e28",
168+ "accessory": "headphones",
169+ "idle_movement": "blink",
170+ "walk_cadence": "steady",
171+ "speech_sound": "chirp",
172+ "accent_palette": "#ef476f",
173+ "arrival_effect": "teleport-rings",
174+ "musical_leitmotif": "motif-v1"
175+ },
176+ "sprite_hash": "89403562c10ca4a58ec5c9eb534e391276a05354a56adaf1032254a907b75c6a",
177+ "leitmotif": {
178+ "notes": [
179+ 62,
180+ 66,
181+ 68,
182+ 65,
183+ 62
184+ ],
185+ "rhythmic_cell": [
186+ 1,
187+ 0.5,
188+ 0.5,
189+ 1,
190+ 1
191+ ],
192+ "instrument": "fmbell"
193+ }
194+ }
195+]
new file mode 100644
@@ -0,0 +1,195 @@
1+[
2+ {
3+ "did": "did:plc:ewvi7nmzuoqusbcablsm7c4h",
4+ "avatar_version": "avatar-v1",
5+ "canonical_seed": "f900fed08848617755407ef6c78207e5d89feb6587914df4d625e7ca62eee7c7",
6+ "expected_traits": {
7+ "body_silhouette": "broad",
8+ "head_shape": "round",
9+ "skin_palette": "#f4d6b8",
10+ "hair_shape": "mohawk",
11+ "hair_palette": "#b8434e",
12+ "eye_pixels": "visor",
13+ "shirt_jacket": "#3f7cac",
14+ "pants_skirt": "#5a3825",
15+ "shoes": "#5a3825",
16+ "accessory": "flower",
17+ "idle_movement": "sway",
18+ "walk_cadence": "steady",
19+ "speech_sound": "burble",
20+ "accent_palette": "#118ab2",
21+ "arrival_effect": "drop",
22+ "musical_leitmotif": "motif-v1"
23+ },
24+ "sprite_hash": "377964bc49c221304968a363581201706ad893f5a5953db82fb7ec8e75e1b9fb",
25+ "leitmotif": {
26+ "notes": [
27+ 60,
28+ 55,
29+ 60
30+ ],
31+ "rhythmic_cell": [
32+ 0.5,
33+ 0.5,
34+ 1
35+ ],
36+ "instrument": "square25"
37+ }
38+ },
39+ {
40+ "did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
41+ "avatar_version": "avatar-v1",
42+ "canonical_seed": "dad7d85259fa1af47ffe00744c596a2053ed7d4ea3a3be4d2b9271dc3efbe8c4",
43+ "expected_traits": {
44+ "body_silhouette": "round",
45+ "head_shape": "round",
46+ "skin_palette": "#c68863",
47+ "hair_shape": "curls",
48+ "hair_palette": "#2b2b3a",
49+ "eye_pixels": "wide",
50+ "shirt_jacket": "#8447ad",
51+ "pants_skirt": "#6b3a55",
52+ "shoes": "#b8434e",
53+ "accessory": "hat",
54+ "idle_movement": "tap",
55+ "walk_cadence": "ambling",
56+ "speech_sound": "hum",
57+ "accent_palette": "#06d6a0",
58+ "arrival_effect": "drop",
59+ "musical_leitmotif": "motif-v1"
60+ },
61+ "sprite_hash": "0cab593c012acfc4e57f3d97a18213c97376aa9facc405a0510e35e49bcdacfa",
62+ "leitmotif": {
63+ "notes": [
64+ 70,
65+ 72,
66+ 68,
67+ 70
68+ ],
69+ "rhythmic_cell": [
70+ 0.5,
71+ 0.5,
72+ 0.5,
73+ 1.5
74+ ],
75+ "instrument": "fmbell"
76+ }
77+ },
78+ {
79+ "did": "did:plc:z72i7hdynmk6r22z27h6tvur",
80+ "avatar_version": "avatar-v1",
81+ "canonical_seed": "bdd2a104bb08f6bc314b51e3202102b04994f16a552f042e34bdfd7870f8d566",
82+ "expected_traits": {
83+ "body_silhouette": "round",
84+ "head_shape": "square",
85+ "skin_palette": "#8d5a3b",
86+ "hair_shape": "curls",
87+ "hair_palette": "#b8434e",
88+ "eye_pixels": "dot",
89+ "shirt_jacket": "#c9a227",
90+ "pants_skirt": "#5a3825",
91+ "shoes": "#5a3825",
92+ "accessory": "none",
93+ "idle_movement": "blink",
94+ "walk_cadence": "bouncy",
95+ "speech_sound": "hum",
96+ "accent_palette": "#9b5de5",
97+ "arrival_effect": "sparkle",
98+ "musical_leitmotif": "motif-v1"
99+ },
100+ "sprite_hash": "e17cb5dd867f68c6c77831a902a11d2d5627749e6ef323393e473d5d5a7ab1ec",
101+ "leitmotif": {
102+ "notes": [
103+ 62,
104+ 60,
105+ 62,
106+ 58
107+ ],
108+ "rhythmic_cell": [
109+ 0.5,
110+ 0.5,
111+ 0.5,
112+ 1.5
113+ ],
114+ "instrument": "fmbell"
115+ }
116+ },
117+ {
118+ "did": "did:key:z6Mkfriq1MqLBoPWecGoDLjguo1sB9brj6wT3qZ5BxkKpuP6",
119+ "avatar_version": "avatar-v1",
120+ "canonical_seed": "18e770b6fa926ae36528fe4e65be9376cda9c66d068902fed010fed98fef8541",
121+ "expected_traits": {
122+ "body_silhouette": "slim",
123+ "head_shape": "round",
124+ "skin_palette": "#8d5a3b",
125+ "hair_shape": "bun",
126+ "hair_palette": "#3f7cac",
127+ "eye_pixels": "wide",
128+ "shirt_jacket": "#8447ad",
129+ "pants_skirt": "#5a3825",
130+ "shoes": "#b8434e",
131+ "accessory": "antenna",
132+ "idle_movement": "bob",
133+ "walk_cadence": "bouncy",
134+ "speech_sound": "hum",
135+ "accent_palette": "#ffd166",
136+ "arrival_effect": "sparkle",
137+ "musical_leitmotif": "motif-v1"
138+ },
139+ "sprite_hash": "845bba278bad03a40100828895e11fc0b09fa8c74872942113627d7a7f731865",
140+ "leitmotif": {
141+ "notes": [
142+ 63,
143+ 65,
144+ 72
145+ ],
146+ "rhythmic_cell": [
147+ 0.5,
148+ 1,
149+ 0.5
150+ ],
151+ "instrument": "fmbell"
152+ }
153+ },
154+ {
155+ "did": "did:example:freeqworld-conformance-0001",
156+ "avatar_version": "avatar-v1",
157+ "canonical_seed": "8b9cea2f3098a8d3e512dc07330e773528fa09da51435ddf799c9a16c2b6d0b7",
158+ "expected_traits": {
159+ "body_silhouette": "broad",
160+ "head_shape": "square",
161+ "skin_palette": "#9fb8ad",
162+ "hair_shape": "curls",
163+ "hair_palette": "#b8434e",
164+ "eye_pixels": "dot",
165+ "shirt_jacket": "#8447ad",
166+ "pants_skirt": "#5a3825",
167+ "shoes": "#1e1e28",
168+ "accessory": "headphones",
169+ "idle_movement": "blink",
170+ "walk_cadence": "steady",
171+ "speech_sound": "chirp",
172+ "accent_palette": "#ef476f",
173+ "arrival_effect": "teleport-rings",
174+ "musical_leitmotif": "motif-v1"
175+ },
176+ "sprite_hash": "89403562c10ca4a58ec5c9eb534e391276a05354a56adaf1032254a907b75c6a",
177+ "leitmotif": {
178+ "notes": [
179+ 62,
180+ 66,
181+ 68,
182+ 65,
183+ 62
184+ ],
185+ "rhythmic_cell": [
186+ 1,
187+ 0.5,
188+ 0.5,
189+ 1,
190+ 1
191+ ],
192+ "instrument": "fmbell"
193+ }
194+ }
195+]
added freeqsay.nimble +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+version = "0.1.0"
2+author = "nandi"
3+description = "Cowsay with your FreeqWorld face: freeqsay <handle> <message>"
4+license = "MIT"
5+srcDir = "src"
6+bin = @["freeqsay"]
7+installExt = @["nim"]
8+
9+requires "nim >= 2.0.0"
10+
11+task test, "Run the test suite":
12+ exec "nim c -d:ssl --hints:off -r tests/test_avatar.nim"
new file mode 100644
@@ -0,0 +1,12 @@
1+version = "0.1.0"
2+author = "nandi"
3+description = "Cowsay with your FreeqWorld face: freeqsay <handle> <message>"
4+license = "MIT"
5+srcDir = "src"
6+bin = @["freeqsay"]
7+installExt = @["nim"]
8+
9+requires "nim >= 2.0.0"
10+
11+task test, "Run the test suite":
12+ exec "nim c -d:ssl --hints:off -r tests/test_avatar.nim"
added justfile +26 -0
new file mode 100644
@@ -0,0 +1,26 @@
1+# freeqsay recipes. Everything here assumes nim on PATH.
2+
3+# Show the recipes.
4+default:
5+ @just --list
6+
7+# Compile the standalone binary (optimized).
8+build:
9+ nim c -d:release -d:ssl --hints:off -o:freeqsay src/freeqsay.nim
10+
11+# Run the test suite.
12+test:
13+ nim c -d:ssl --hints:off -r tests/test_avatar.nim
14+
15+# Run from source, without an optimized build: `just run jay.bsky.team hello`.
16+run *args:
17+ nim r -d:ssl --hints:off src/freeqsay.nim {{args}}
18+
19+# Screenshot the output, gapless (termshot-tight sets lineSpacing 1.0):
20+# `just shot jay.bsky.team hello`. Writes freeqsay.png.
21+shot *args:
22+ termshot -c -C 80 -f freeqsay.png -- nim r -d:ssl --hints:off src/freeqsay.nim {{args}}
23+
24+# Remove build output and caches.
25+clean:
26+ rm -rf freeqsay tests/test_avatar nimcache tests/nimcache
new file mode 100644
@@ -0,0 +1,26 @@
1+# freeqsay recipes. Everything here assumes nim on PATH.
2+
3+# Show the recipes.
4+default:
5+ @just --list
6+
7+# Compile the standalone binary (optimized).
8+build:
9+ nim c -d:release -d:ssl --hints:off -o:freeqsay src/freeqsay.nim
10+
11+# Run the test suite.
12+test:
13+ nim c -d:ssl --hints:off -r tests/test_avatar.nim
14+
15+# Run from source, without an optimized build: `just run jay.bsky.team hello`.
16+run *args:
17+ nim r -d:ssl --hints:off src/freeqsay.nim {{args}}
18+
19+# Screenshot the output, gapless (termshot-tight sets lineSpacing 1.0):
20+# `just shot jay.bsky.team hello`. Writes freeqsay.png.
21+shot *args:
22+ termshot -c -C 80 -f freeqsay.png -- nim r -d:ssl --hints:off src/freeqsay.nim {{args}}
23+
24+# Remove build output and caches.
25+clean:
26+ rm -rf freeqsay tests/test_avatar nimcache tests/nimcache
added src/freeqsay.nim +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+## freeqsay — cowsay with your FreeqWorld face.
2+##
3+## freeqsay <handle> <message...>
4+## echo "hi" | freeqsay <handle>
5+## freeqsay did:plc:… "offline, no resolve"
6+##
7+## As a library, this module re-exports the whole port: `freeqsay.say` (balloon
8+## + face), `avatar` (traits and the 16×24 sprite), `ansi` (truecolor
9+## half-block rendering), `png` (RGBA → PNG), `resolve` (handle → DID),
10+## `balloon`, `sha256`.
11+
12+import freeqsay/[ansi, avatar, balloon, png, resolve, say, sha256]
13+export ansi, avatar, balloon, png, resolve, say, sha256
14+
15+when isMainModule:
16+ import std/[os, strutils]
17+
18+ const usageText = """
19+freeqsay — cowsay with a FreeqWorld character
20+
21+Usage:
22+ freeqsay <handle> <message...>
23+ freeqsay <handle> # message from stdin
24+ echo "hello" | freeqsay <handle>
25+
26+ Resolves a Bluesky handle to a DID, derives the deterministic avatar,
27+ and prints a cowsay balloon above the character. A raw did:… works too.
28+
29+Options:
30+ -W, --width <n> Balloon wrap width (default 40)
31+ --scale <n> Character pixel scale (default 2)
32+ --facing <dir> south | north | east | west (default south)
33+ --think Thought bubble instead of speech
34+ -o, --output <path> Write to file (else stdout)
35+ -h, --help Show help
36+
37+Examples:
38+ freeqsay jay.bsky.team hello from the timeline
39+ freeqsay @pfrazee.com "ships free software"
40+ freeqsay someone.bsky.social -W 20 short lines work
41+ echo "piped in" | freeqsay jay.bsky.team
42+"""
43+
44+ proc usage(code = 1) =
45+ stderr.writeLine usageText
46+ quit(code)
47+
48+ proc positiveInt(s: string): int =
49+ try:
50+ result = parseInt(s.strip())
51+ except ValueError:
52+ usage()
53+ if result < 1: usage()
54+
55+ type Args = object
56+ handle, output: string
57+ messageParts: seq[string]
58+ width, scale: int
59+ facing: Facing
60+ think: bool
61+
62+ proc parseArgs(argv: seq[string]): Args =
63+ result = Args(width: 40, scale: 2, facing: fSouth)
64+ var i = 0
65+ while i < argv.len:
66+ let a = argv[i]
67+ proc value(): string =
68+ # A flag with no value left is a usage error, not an index crash.
69+ if i + 1 >= argv.len: usage()
70+ inc i
71+ argv[i]
72+ case a
73+ of "-h", "--help": usage(0)
74+ of "--think": result.think = true
75+ of "-W", "--width": result.width = positiveInt(value())
76+ of "--scale": result.scale = positiveInt(value())
77+ of "--facing":
78+ let f = value()
79+ if f notin ["south", "north", "east", "west"]: usage()
80+ result.facing = parseEnum[Facing](f)
81+ of "-o", "--output": result.output = value()
82+ else:
83+ if a.startsWith("-"):
84+ stderr.writeLine "unknown flag: " & a
85+ usage()
86+ elif result.handle.len == 0:
87+ result.handle = a
88+ else:
89+ result.messageParts.add a
90+ inc i
91+
92+ proc main() =
93+ let args = parseArgs(commandLineParams())
94+ if args.handle.len == 0: usage()
95+ var message = args.messageParts.join(" ").strip()
96+ if message.len == 0:
97+ message = readAll(stdin).strip()
98+ if message.len == 0: usage()
99+
100+ let result = freeqsay(args.handle, message, width = args.width,
101+ think = args.think, scale = args.scale,
102+ facing = args.facing)
103+ let text = result.output & "\n"
104+ if args.output.len > 0:
105+ writeFile(args.output, text)
106+ else:
107+ stdout.write text
108+ stdout.flushFile()
109+
110+ try:
111+ main()
112+ except CatchableError as e:
113+ stderr.writeLine e.msg
114+ quit(1)
new file mode 100644
@@ -0,0 +1,114 @@
1+## freeqsay — cowsay with your FreeqWorld face.
2+##
3+## freeqsay <handle> <message...>
4+## echo "hi" | freeqsay <handle>
5+## freeqsay did:plc:… "offline, no resolve"
6+##
7+## As a library, this module re-exports the whole port: `freeqsay.say` (balloon
8+## + face), `avatar` (traits and the 16×24 sprite), `ansi` (truecolor
9+## half-block rendering), `png` (RGBA → PNG), `resolve` (handle → DID),
10+## `balloon`, `sha256`.
11+
12+import freeqsay/[ansi, avatar, balloon, png, resolve, say, sha256]
13+export ansi, avatar, balloon, png, resolve, say, sha256
14+
15+when isMainModule:
16+ import std/[os, strutils]
17+
18+ const usageText = """
19+freeqsay — cowsay with a FreeqWorld character
20+
21+Usage:
22+ freeqsay <handle> <message...>
23+ freeqsay <handle> # message from stdin
24+ echo "hello" | freeqsay <handle>
25+
26+ Resolves a Bluesky handle to a DID, derives the deterministic avatar,
27+ and prints a cowsay balloon above the character. A raw did:… works too.
28+
29+Options:
30+ -W, --width <n> Balloon wrap width (default 40)
31+ --scale <n> Character pixel scale (default 2)
32+ --facing <dir> south | north | east | west (default south)
33+ --think Thought bubble instead of speech
34+ -o, --output <path> Write to file (else stdout)
35+ -h, --help Show help
36+
37+Examples:
38+ freeqsay jay.bsky.team hello from the timeline
39+ freeqsay @pfrazee.com "ships free software"
40+ freeqsay someone.bsky.social -W 20 short lines work
41+ echo "piped in" | freeqsay jay.bsky.team
42+"""
43+
44+ proc usage(code = 1) =
45+ stderr.writeLine usageText
46+ quit(code)
47+
48+ proc positiveInt(s: string): int =
49+ try:
50+ result = parseInt(s.strip())
51+ except ValueError:
52+ usage()
53+ if result < 1: usage()
54+
55+ type Args = object
56+ handle, output: string
57+ messageParts: seq[string]
58+ width, scale: int
59+ facing: Facing
60+ think: bool
61+
62+ proc parseArgs(argv: seq[string]): Args =
63+ result = Args(width: 40, scale: 2, facing: fSouth)
64+ var i = 0
65+ while i < argv.len:
66+ let a = argv[i]
67+ proc value(): string =
68+ # A flag with no value left is a usage error, not an index crash.
69+ if i + 1 >= argv.len: usage()
70+ inc i
71+ argv[i]
72+ case a
73+ of "-h", "--help": usage(0)
74+ of "--think": result.think = true
75+ of "-W", "--width": result.width = positiveInt(value())
76+ of "--scale": result.scale = positiveInt(value())
77+ of "--facing":
78+ let f = value()
79+ if f notin ["south", "north", "east", "west"]: usage()
80+ result.facing = parseEnum[Facing](f)
81+ of "-o", "--output": result.output = value()
82+ else:
83+ if a.startsWith("-"):
84+ stderr.writeLine "unknown flag: " & a
85+ usage()
86+ elif result.handle.len == 0:
87+ result.handle = a
88+ else:
89+ result.messageParts.add a
90+ inc i
91+
92+ proc main() =
93+ let args = parseArgs(commandLineParams())
94+ if args.handle.len == 0: usage()
95+ var message = args.messageParts.join(" ").strip()
96+ if message.len == 0:
97+ message = readAll(stdin).strip()
98+ if message.len == 0: usage()
99+
100+ let result = freeqsay(args.handle, message, width = args.width,
101+ think = args.think, scale = args.scale,
102+ facing = args.facing)
103+ let text = result.output & "\n"
104+ if args.output.len > 0:
105+ writeFile(args.output, text)
106+ else:
107+ stdout.write text
108+ stdout.flushFile()
109+
110+ try:
111+ main()
112+ except CatchableError as e:
113+ stderr.writeLine e.msg
114+ quit(1)
added src/freeqsay/ansi.nim +124 -0
new file mode 100644
@@ -0,0 +1,124 @@
1+## Sprite → truecolor ANSI for terminals (half-block packing).
2+## Each terminal cell covers 2 vertical pixels: bg = upper, fg = lower.
3+## Sequences are kept compact (single CSI, reset once per line) so tools like
4+## termshot/bunt and narrow PTYs do not choke mid-escape.
5+
6+import std/[strutils, strformat]
7+import ./avatar
8+
9+type Rgba* = object
10+ width*, height*: int
11+ data*: seq[int] ## w*h*4 bytes
12+
13+func hexToRgb*(hex: string): (int, int, int) =
14+ (parseHexInt(hex[1 .. 2]), parseHexInt(hex[3 .. 4]), parseHexInt(hex[5 .. 6]))
15+
16+func spriteToRgba*(s: Sprite): Rgba =
17+ ## Expand a palette-indexed sprite to RGBA (alpha 0 for transparent).
18+ result = Rgba(width: s.width, height: s.height,
19+ data: newSeq[int](s.width * s.height * 4))
20+ for i, idx in s.pixels:
21+ let color = s.palette[idx]
22+ if color != "transparent":
23+ let (r, g, b) = hexToRgb(color)
24+ let o = i * 4
25+ result.data[o] = r
26+ result.data[o + 1] = g
27+ result.data[o + 2] = b
28+ result.data[o + 3] = 255
29+
30+func scaleRgba*(src: Rgba, scale: int): Rgba =
31+ ## Nearest-neighbor upscale.
32+ if scale <= 1:
33+ return src
34+ let
35+ w = src.width * scale
36+ h = src.height * scale
37+ result = Rgba(width: w, height: h, data: newSeq[int](w * h * 4))
38+ for y in 0 ..< h:
39+ let sy = y div scale
40+ for x in 0 ..< w:
41+ let
42+ sx = x div scale
43+ si = (sy * src.width + sx) * 4
44+ di = (y * w + x) * 4
45+ for k in 0 ..< 4:
46+ result.data[di + k] = src.data[si + k]
47+
48+const
49+ ESC = "\x1b"
50+ RESET = ESC & "[0m"
51+ LOWER = ""
52+ UPPER = ""
53+
54+func fg(r, g, b: int): string = &"{ESC}[38;2;{r};{g};{b}m"
55+
56+func bgFg(ur, ug, ub, lr, lg, lb: int): string =
57+ &"{ESC}[48;2;{ur};{ug};{ub};38;2;{lr};{lg};{lb}m"
58+
59+type Mode = enum mNone, mSpace, mLower, mUpper, mBoth
60+
61+func rgbaToAnsi*(img: Rgba): string =
62+ ## Convert an RGBA raster to a multi-line truecolor ANSI string.
63+ let
64+ w = img.width
65+ h = img.height
66+ func px(y, x, k: int): int = img.data[(y * w + x) * 4 + k]
67+
68+ var lines: seq[string]
69+ var y = 0
70+ while y < h:
71+ let y2 = y + 1
72+ var
73+ line = ""
74+ mode = mNone
75+ fr, fgc, fb, br, bg, bb = -1
76+ for x in 0 ..< w:
77+ let
78+ ur = px(y, x, 0)
79+ ug = px(y, x, 1)
80+ ub = px(y, x, 2)
81+ ua = px(y, x, 3)
82+ lr = if y2 < h: px(y2, x, 0) else: 0
83+ lg = if y2 < h: px(y2, x, 1) else: 0
84+ lb = if y2 < h: px(y2, x, 2) else: 0
85+ la = if y2 < h: px(y2, x, 3) else: 0
86+ if ua == 0 and la == 0:
87+ if mode notin {mNone, mSpace}: line.add RESET
88+ line.add " "
89+ mode = mSpace
90+ fr = -1; fgc = -1; fb = -1; br = -1; bg = -1; bb = -1
91+ elif ua == 0:
92+ if mode != mLower or fr != lr or fgc != lg or fb != lb:
93+ line.add fg(lr, lg, lb) & LOWER
94+ mode = mLower
95+ fr = lr; fgc = lg; fb = lb; br = -1; bg = -1; bb = -1
96+ else:
97+ line.add LOWER
98+ elif la == 0:
99+ if mode != mUpper or fr != ur or fgc != ug or fb != ub:
100+ line.add fg(ur, ug, ub) & UPPER
101+ mode = mUpper
102+ fr = ur; fgc = ug; fb = ub; br = -1; bg = -1; bb = -1
103+ else:
104+ line.add UPPER
105+ else:
106+ # both: single CSI with bg = upper, fg = lower
107+ if mode != mBoth or fr != lr or fgc != lg or fb != lb or
108+ br != ur or bg != ug or bb != ub:
109+ line.add bgFg(ur, ug, ub, lr, lg, lb) & LOWER
110+ mode = mBoth
111+ fr = lr; fgc = lg; fb = lb; br = ur; bg = ug; bb = ub
112+ else:
113+ line.add LOWER
114+ if mode notin {mNone, mSpace}: line.add RESET
115+ lines.add line
116+ y += 2
117+ lines.join("\n")
118+
119+func avatarToAnsi*(av: Avatar, scale = 2, facing = fSouth, frame = 0): string =
120+ rgbaToAnsi(scaleRgba(spriteToRgba(renderSpritePixels(av, facing, frame)), scale))
121+
122+func didToAnsi*(did: string, scale = 2, facing = fSouth, frame = 0): string =
123+ ## DID → truecolor ANSI art (no network).
124+ avatarToAnsi(deriveAvatar(did), scale, facing, frame)
new file mode 100644
@@ -0,0 +1,124 @@
1+## Sprite → truecolor ANSI for terminals (half-block packing).
2+## Each terminal cell covers 2 vertical pixels: bg = upper, fg = lower.
3+## Sequences are kept compact (single CSI, reset once per line) so tools like
4+## termshot/bunt and narrow PTYs do not choke mid-escape.
5+
6+import std/[strutils, strformat]
7+import ./avatar
8+
9+type Rgba* = object
10+ width*, height*: int
11+ data*: seq[int] ## w*h*4 bytes
12+
13+func hexToRgb*(hex: string): (int, int, int) =
14+ (parseHexInt(hex[1 .. 2]), parseHexInt(hex[3 .. 4]), parseHexInt(hex[5 .. 6]))
15+
16+func spriteToRgba*(s: Sprite): Rgba =
17+ ## Expand a palette-indexed sprite to RGBA (alpha 0 for transparent).
18+ result = Rgba(width: s.width, height: s.height,
19+ data: newSeq[int](s.width * s.height * 4))
20+ for i, idx in s.pixels:
21+ let color = s.palette[idx]
22+ if color != "transparent":
23+ let (r, g, b) = hexToRgb(color)
24+ let o = i * 4
25+ result.data[o] = r
26+ result.data[o + 1] = g
27+ result.data[o + 2] = b
28+ result.data[o + 3] = 255
29+
30+func scaleRgba*(src: Rgba, scale: int): Rgba =
31+ ## Nearest-neighbor upscale.
32+ if scale <= 1:
33+ return src
34+ let
35+ w = src.width * scale
36+ h = src.height * scale
37+ result = Rgba(width: w, height: h, data: newSeq[int](w * h * 4))
38+ for y in 0 ..< h:
39+ let sy = y div scale
40+ for x in 0 ..< w:
41+ let
42+ sx = x div scale
43+ si = (sy * src.width + sx) * 4
44+ di = (y * w + x) * 4
45+ for k in 0 ..< 4:
46+ result.data[di + k] = src.data[si + k]
47+
48+const
49+ ESC = "\x1b"
50+ RESET = ESC & "[0m"
51+ LOWER = ""
52+ UPPER = ""
53+
54+func fg(r, g, b: int): string = &"{ESC}[38;2;{r};{g};{b}m"
55+
56+func bgFg(ur, ug, ub, lr, lg, lb: int): string =
57+ &"{ESC}[48;2;{ur};{ug};{ub};38;2;{lr};{lg};{lb}m"
58+
59+type Mode = enum mNone, mSpace, mLower, mUpper, mBoth
60+
61+func rgbaToAnsi*(img: Rgba): string =
62+ ## Convert an RGBA raster to a multi-line truecolor ANSI string.
63+ let
64+ w = img.width
65+ h = img.height
66+ func px(y, x, k: int): int = img.data[(y * w + x) * 4 + k]
67+
68+ var lines: seq[string]
69+ var y = 0
70+ while y < h:
71+ let y2 = y + 1
72+ var
73+ line = ""
74+ mode = mNone
75+ fr, fgc, fb, br, bg, bb = -1
76+ for x in 0 ..< w:
77+ let
78+ ur = px(y, x, 0)
79+ ug = px(y, x, 1)
80+ ub = px(y, x, 2)
81+ ua = px(y, x, 3)
82+ lr = if y2 < h: px(y2, x, 0) else: 0
83+ lg = if y2 < h: px(y2, x, 1) else: 0
84+ lb = if y2 < h: px(y2, x, 2) else: 0
85+ la = if y2 < h: px(y2, x, 3) else: 0
86+ if ua == 0 and la == 0:
87+ if mode notin {mNone, mSpace}: line.add RESET
88+ line.add " "
89+ mode = mSpace
90+ fr = -1; fgc = -1; fb = -1; br = -1; bg = -1; bb = -1
91+ elif ua == 0:
92+ if mode != mLower or fr != lr or fgc != lg or fb != lb:
93+ line.add fg(lr, lg, lb) & LOWER
94+ mode = mLower
95+ fr = lr; fgc = lg; fb = lb; br = -1; bg = -1; bb = -1
96+ else:
97+ line.add LOWER
98+ elif la == 0:
99+ if mode != mUpper or fr != ur or fgc != ug or fb != ub:
100+ line.add fg(ur, ug, ub) & UPPER
101+ mode = mUpper
102+ fr = ur; fgc = ug; fb = ub; br = -1; bg = -1; bb = -1
103+ else:
104+ line.add UPPER
105+ else:
106+ # both: single CSI with bg = upper, fg = lower
107+ if mode != mBoth or fr != lr or fgc != lg or fb != lb or
108+ br != ur or bg != ug or bb != ub:
109+ line.add bgFg(ur, ug, ub, lr, lg, lb) & LOWER
110+ mode = mBoth
111+ fr = lr; fgc = lg; fb = lb; br = ur; bg = ug; bb = ub
112+ else:
113+ line.add LOWER
114+ if mode notin {mNone, mSpace}: line.add RESET
115+ lines.add line
116+ y += 2
117+ lines.join("\n")
118+
119+func avatarToAnsi*(av: Avatar, scale = 2, facing = fSouth, frame = 0): string =
120+ rgbaToAnsi(scaleRgba(spriteToRgba(renderSpritePixels(av, facing, frame)), scale))
121+
122+func didToAnsi*(did: string, scale = 2, facing = fSouth, frame = 0): string =
123+ ## DID → truecolor ANSI art (no network).
124+ avatarToAnsi(deriveAvatar(did), scale, facing, frame)
added src/freeqsay/avatar.nim +279 -0
new file mode 100644
@@ -0,0 +1,279 @@
1+## Deterministic avatar system (avatar-v1).
2+##
3+## canonical seed = HKDF-SHA256(ikm=DID, salt="freeq-world-avatar", info="avatar-v1")
4+## All traits are abstract — nothing is inferred from profile data.
5+
6+import std/[math, tables]
7+import ./sha256
8+
9+type
10+ Traits* = OrderedTable[string, string]
11+
12+ Avatar* = object
13+ schema*: string
14+ did*: string
15+ baseGenerator*: string
16+ canonicalSeedHex*: string
17+ traits*: Traits
18+
19+ Facing* = enum
20+ fSouth = "south", fNorth = "north", fEast = "east", fWest = "west"
21+
22+ Sprite* = object
23+ width*, height*: int
24+ pixels*: seq[int] ## palette indices
25+ palette*: seq[string]
26+
27+const traitKeys* = [
28+ "body_silhouette", "head_shape", "skin_palette", "hair_shape", "hair_palette",
29+ "eye_pixels", "shirt_jacket", "pants_skirt", "shoes", "accessory",
30+ "idle_movement", "walk_cadence", "speech_sound", "accent_palette",
31+ "arrival_effect", "musical_leitmotif"]
32+
33+const options*: array[15, (string, seq[string])] = [
34+ ("body_silhouette", @["slim", "broad", "round", "tall", "compact", "angular"]),
35+ ("head_shape", @["round", "square", "oval", "wide"]),
36+ # stylized fictional palette — deliberately includes non-naturalistic hues
37+ ("skin_palette", @["#e8b48c", "#c68863", "#8d5a3b", "#5c3a26", "#f4d6b8",
38+ "#9fb8ad", "#b7a6d9", "#7d9c6f"]),
39+ ("hair_shape", @["crop", "spikes", "bob", "long", "curls", "mohawk", "bald", "bun"]),
40+ ("hair_palette", @["#2b2b3a", "#5a3825", "#c9a227", "#b8434e", "#3f7cac",
41+ "#67c26b", "#e8e6df", "#8447ad"]),
42+ ("eye_pixels", @["dot", "wide", "wink", "visor"]),
43+ ("shirt_jacket", @["#3f7cac", "#b8434e", "#67c26b", "#c9a227", "#8447ad",
44+ "#3a9188", "#d97b29", "#6d7680"]),
45+ ("pants_skirt", @["#31394d", "#5a3825", "#3a5a40", "#6b3a55", "#444444", "#7a5c99"]),
46+ ("shoes", @["#1e1e28", "#5a3825", "#b8434e", "#e8e6df"]),
47+ ("accessory", @["none", "hat", "glasses", "scarf", "antenna", "flower",
48+ "headphones", "badge"]),
49+ ("idle_movement", @["bob", "sway", "blink", "tap"]),
50+ ("walk_cadence", @["steady", "bouncy", "brisk", "ambling"]),
51+ ("speech_sound", @["beep", "blip", "burble", "chirp", "hum"]),
52+ ("accent_palette", @["#ffd166", "#06d6a0", "#ef476f", "#118ab2", "#f78c6b", "#9b5de5"]),
53+ ("arrival_effect", @["sparkle", "dissolve", "drop", "teleport-rings"])]
54+
55+func optionsFor*(key: string): seq[string] =
56+ for (k, v) in options:
57+ if k == key: return v
58+ raise newException(KeyError, "no options for trait " & key)
59+
60+type Prng* = object
61+ a, b, c, d: uint32
62+
63+func u32le(seed: openArray[byte], i: int): uint32 =
64+ for k in 0 ..< 4:
65+ result = result or (uint32(seed[i + k]) shl (8 * k))
66+
67+func initPrng*(seed: openArray[byte]): Prng =
68+ ## sfc32 seeded from the first 16 bytes of `seed` — bit-identical to the
69+ ## JavaScript original the fixture was generated with.
70+ Prng(a: u32le(seed, 0), b: u32le(seed, 4), c: u32le(seed, 8), d: u32le(seed, 12))
71+
72+func next*(p: var Prng): float =
73+ ## Next double in [0, 1).
74+ let t = p.a + p.b + p.d
75+ let d = p.d + 1
76+ let a = p.b xor (p.b shr 9)
77+ let b = p.c + (p.c shl 3)
78+ let c = ((p.c shl 21) or (p.c shr 11)) + t
79+ p = Prng(a: a, b: b, c: c, d: d)
80+ float(t) / 4294967296.0
81+
82+func pick*(p: var Prng, opts: openArray[string]): string =
83+ let i = int(floor(p.next() * float(opts.len)))
84+ opts[min(i, opts.len - 1)]
85+
86+func canonicalSeed*(did: string): seq[byte] =
87+ ## 32-byte canonical seed for a DID.
88+ hkdf(did, "freeq-world-avatar", "avatar-v1", 32)
89+
90+func deriveAvatar*(did: string): Avatar =
91+ let seed = canonicalSeed(did)
92+ var rng = initPrng(seed)
93+ var traits: Traits
94+ for key in traitKeys:
95+ traits[key] =
96+ if key == "musical_leitmotif":
97+ # pointer to the leitmotif derivation (own HKDF domain)
98+ "motif-v1"
99+ else:
100+ pick(rng, optionsFor(key))
101+ Avatar(schema: "freeq.at/profile/avatar/v1",
102+ did: did,
103+ baseGenerator: "avatar-v1",
104+ canonicalSeedHex: toHex(seed),
105+ traits: traits)
106+
107+const
108+ W* = 16
109+ H* = 24
110+
111+const
112+ SKIN = 1
113+ HAIR = 2
114+ SHIRT = 3
115+ PANTS = 4
116+ SHOE = 5
117+ INK = 6
118+ ACCENT = 7
119+ WHITE = 8
120+
121+func renderSpritePixels*(av: Avatar, facing = fSouth, frame = 0): Sprite =
122+ ## Procedural 16x24 pixel body. Rows: hair/head 0-9, torso 10-17, legs 18-21,
123+ ## shoes 22-23. Facing changes the face; walk frames swing arms and legs.
124+ let t = av.traits
125+ var px = newSeq[int](W * H)
126+
127+ proc put(x, y, c: int) =
128+ if x >= 0 and x < W and y >= 0 and y < H:
129+ px[y * W + x] = c
130+
131+ proc hspan(x0, x1, y, c: int) =
132+ for x in x0 .. x1:
133+ put(x, y, c)
134+
135+ let
136+ silhouette = t["body_silhouette"]
137+ bodyW = if silhouette in ["broad", "round"]: 10
138+ elif silhouette == "slim": 6
139+ else: 8
140+ bodyX0 = (W - bodyW) div 2
141+ bodyX1 = bodyX0 + bodyW - 1
142+
143+ headShape = t["head_shape"]
144+ headW = if headShape == "wide": 10 else: 8
145+ headX0 = (W - headW) div 2
146+ headX1 = headX0 + headW - 1
147+ headTop = if silhouette == "tall": 1 else: 2
148+ hair = t["hair_shape"]
149+ acc = t["accessory"]
150+
151+ # head
152+ for y in headTop ..< 10:
153+ let shrink = if headShape in ["round", "oval"] and (y == headTop or y == 9): 1 else: 0
154+ hspan(headX0 + shrink, headX1 - shrink, y, SKIN)
155+
156+ # hair
157+ if hair != "bald":
158+ hspan(headX0, headX1, headTop, HAIR)
159+ hspan(headX0, headX1, headTop + 1, HAIR)
160+ if hair in ["spikes", "mohawk"]:
161+ let
162+ mohawk = hair == "mohawk"
163+ start = headX0 + (if mohawk: headW div 2 - 1 else: 0)
164+ stop = if mohawk: headX0 + headW div 2 else: headX1
165+ step = if mohawk: 1 else: 2
166+ var x = start
167+ while x <= stop:
168+ put(x, headTop - 1, HAIR)
169+ x += step
170+ if hair in ["bob", "long", "curls"]:
171+ put(headX0, headTop + 2, HAIR)
172+ put(headX1, headTop + 2, HAIR)
173+ put(headX0, headTop + 3, HAIR)
174+ put(headX1, headTop + 3, HAIR)
175+ if hair == "long":
176+ put(headX0, headTop + 4, HAIR)
177+ put(headX1, headTop + 4, HAIR)
178+ if hair == "bun":
179+ put(W div 2, headTop - 1, HAIR)
180+
181+ # face (not shown when facing north)
182+ if facing != fNorth:
183+ let
184+ eyeY = headTop + 4
185+ eyes = t["eye_pixels"]
186+ mid = W div 2
187+ eyeL = case facing
188+ of fEast: mid
189+ of fWest: headX0 + 1
190+ else: headX0 + 2
191+ eyeR = case facing
192+ of fEast: headX1 - 1
193+ of fWest: mid - 1
194+ else: headX1 - 2
195+ if eyes == "visor":
196+ hspan(eyeL, eyeR, eyeY, ACCENT)
197+ else:
198+ put(eyeL, eyeY, INK)
199+ if eyes != "wink":
200+ put(eyeR, eyeY, INK)
201+ if eyes == "wide":
202+ put(eyeL, eyeY - 1, WHITE)
203+ put(eyeR, eyeY - 1, WHITE)
204+ elif hair != "bald":
205+ # back of head is hair
206+ for y in headTop ..< 9:
207+ hspan(headX0, headX1, y, HAIR)
208+
209+ # torso
210+ for y in 10 ..< 18:
211+ hspan(bodyX0, bodyX1, y, SHIRT)
212+ # accent stripe
213+ hspan(bodyX0, bodyX1, 13, ACCENT)
214+
215+ # arms (swing with walk frame)
216+ let swing = case frame
217+ of 0: 0
218+ of 1: 1
219+ else: -1
220+ put(bodyX0 - 1, 11 + swing, SKIN)
221+ put(bodyX0 - 1, 12 + swing, SKIN)
222+ put(bodyX1 + 1, 11 - swing, SKIN)
223+ put(bodyX1 + 1, 12 - swing, SKIN)
224+
225+ # legs
226+ let
227+ legW = max(2, bodyW div 2 - 1)
228+ gap = case frame
229+ of 0: 2
230+ of 1: 4
231+ else: 0
232+ legL0 = W div 2 - gap div 2 - legW
233+ legR0 = W div 2 + int(ceil(float(gap) / 2.0))
234+ for y in 18 ..< 22:
235+ hspan(legL0, legL0 + legW - 1, y, PANTS)
236+ hspan(legR0, legR0 + legW - 1, y, PANTS)
237+ for y in 22 ..< 24:
238+ hspan(legL0, legL0 + legW - 1, y, SHOE)
239+ hspan(legR0, legR0 + legW - 1, y, SHOE)
240+
241+ # accessory
242+ case acc
243+ of "hat":
244+ hspan(headX0 - 1, headX1 + 1, headTop, ACCENT)
245+ hspan(headX0, headX1, headTop - 1, ACCENT)
246+ of "antenna":
247+ put(W div 2, headTop - 1, INK)
248+ put(W div 2, headTop - 2, ACCENT)
249+ of "scarf":
250+ hspan(bodyX0, bodyX1, 10, ACCENT)
251+ of "glasses":
252+ if facing != fNorth:
253+ hspan(headX0 + 1, headX1 - 1, headTop + 4, INK)
254+ of "headphones":
255+ put(headX0 - 1, headTop + 3, ACCENT)
256+ put(headX1 + 1, headTop + 3, ACCENT)
257+ of "flower":
258+ put(headX1, headTop + 1, ACCENT)
259+ of "badge":
260+ put(bodyX0 + 1, 11, ACCENT)
261+ else: discard
262+
263+ Sprite(width: W, height: H, pixels: px,
264+ palette: @["transparent", t["skin_palette"], t["hair_palette"],
265+ t["shirt_jacket"], t["pants_skirt"], t["shoes"],
266+ "#14141c", # outline/eyes
267+ t["accent_palette"], "#ffffff"])
268+
269+func spriteHash*(av: Avatar): string =
270+ ## Deterministic content hash over all four facings — conformance fixture value.
271+ var bs: seq[byte]
272+ for facing in [fSouth, fNorth, fEast, fWest]:
273+ let s = renderSpritePixels(av, facing, 0)
274+ for p in s.pixels:
275+ bs.add byte(p and 255)
276+ for color in s.palette:
277+ for ch in color:
278+ bs.add byte(ch)
279+ toHex(digest(bs))
new file mode 100644
@@ -0,0 +1,279 @@
1+## Deterministic avatar system (avatar-v1).
2+##
3+## canonical seed = HKDF-SHA256(ikm=DID, salt="freeq-world-avatar", info="avatar-v1")
4+## All traits are abstract — nothing is inferred from profile data.
5+
6+import std/[math, tables]
7+import ./sha256
8+
9+type
10+ Traits* = OrderedTable[string, string]
11+
12+ Avatar* = object
13+ schema*: string
14+ did*: string
15+ baseGenerator*: string
16+ canonicalSeedHex*: string
17+ traits*: Traits
18+
19+ Facing* = enum
20+ fSouth = "south", fNorth = "north", fEast = "east", fWest = "west"
21+
22+ Sprite* = object
23+ width*, height*: int
24+ pixels*: seq[int] ## palette indices
25+ palette*: seq[string]
26+
27+const traitKeys* = [
28+ "body_silhouette", "head_shape", "skin_palette", "hair_shape", "hair_palette",
29+ "eye_pixels", "shirt_jacket", "pants_skirt", "shoes", "accessory",
30+ "idle_movement", "walk_cadence", "speech_sound", "accent_palette",
31+ "arrival_effect", "musical_leitmotif"]
32+
33+const options*: array[15, (string, seq[string])] = [
34+ ("body_silhouette", @["slim", "broad", "round", "tall", "compact", "angular"]),
35+ ("head_shape", @["round", "square", "oval", "wide"]),
36+ # stylized fictional palette — deliberately includes non-naturalistic hues
37+ ("skin_palette", @["#e8b48c", "#c68863", "#8d5a3b", "#5c3a26", "#f4d6b8",
38+ "#9fb8ad", "#b7a6d9", "#7d9c6f"]),
39+ ("hair_shape", @["crop", "spikes", "bob", "long", "curls", "mohawk", "bald", "bun"]),
40+ ("hair_palette", @["#2b2b3a", "#5a3825", "#c9a227", "#b8434e", "#3f7cac",
41+ "#67c26b", "#e8e6df", "#8447ad"]),
42+ ("eye_pixels", @["dot", "wide", "wink", "visor"]),
43+ ("shirt_jacket", @["#3f7cac", "#b8434e", "#67c26b", "#c9a227", "#8447ad",
44+ "#3a9188", "#d97b29", "#6d7680"]),
45+ ("pants_skirt", @["#31394d", "#5a3825", "#3a5a40", "#6b3a55", "#444444", "#7a5c99"]),
46+ ("shoes", @["#1e1e28", "#5a3825", "#b8434e", "#e8e6df"]),
47+ ("accessory", @["none", "hat", "glasses", "scarf", "antenna", "flower",
48+ "headphones", "badge"]),
49+ ("idle_movement", @["bob", "sway", "blink", "tap"]),
50+ ("walk_cadence", @["steady", "bouncy", "brisk", "ambling"]),
51+ ("speech_sound", @["beep", "blip", "burble", "chirp", "hum"]),
52+ ("accent_palette", @["#ffd166", "#06d6a0", "#ef476f", "#118ab2", "#f78c6b", "#9b5de5"]),
53+ ("arrival_effect", @["sparkle", "dissolve", "drop", "teleport-rings"])]
54+
55+func optionsFor*(key: string): seq[string] =
56+ for (k, v) in options:
57+ if k == key: return v
58+ raise newException(KeyError, "no options for trait " & key)
59+
60+type Prng* = object
61+ a, b, c, d: uint32
62+
63+func u32le(seed: openArray[byte], i: int): uint32 =
64+ for k in 0 ..< 4:
65+ result = result or (uint32(seed[i + k]) shl (8 * k))
66+
67+func initPrng*(seed: openArray[byte]): Prng =
68+ ## sfc32 seeded from the first 16 bytes of `seed` — bit-identical to the
69+ ## JavaScript original the fixture was generated with.
70+ Prng(a: u32le(seed, 0), b: u32le(seed, 4), c: u32le(seed, 8), d: u32le(seed, 12))
71+
72+func next*(p: var Prng): float =
73+ ## Next double in [0, 1).
74+ let t = p.a + p.b + p.d
75+ let d = p.d + 1
76+ let a = p.b xor (p.b shr 9)
77+ let b = p.c + (p.c shl 3)
78+ let c = ((p.c shl 21) or (p.c shr 11)) + t
79+ p = Prng(a: a, b: b, c: c, d: d)
80+ float(t) / 4294967296.0
81+
82+func pick*(p: var Prng, opts: openArray[string]): string =
83+ let i = int(floor(p.next() * float(opts.len)))
84+ opts[min(i, opts.len - 1)]
85+
86+func canonicalSeed*(did: string): seq[byte] =
87+ ## 32-byte canonical seed for a DID.
88+ hkdf(did, "freeq-world-avatar", "avatar-v1", 32)
89+
90+func deriveAvatar*(did: string): Avatar =
91+ let seed = canonicalSeed(did)
92+ var rng = initPrng(seed)
93+ var traits: Traits
94+ for key in traitKeys:
95+ traits[key] =
96+ if key == "musical_leitmotif":
97+ # pointer to the leitmotif derivation (own HKDF domain)
98+ "motif-v1"
99+ else:
100+ pick(rng, optionsFor(key))
101+ Avatar(schema: "freeq.at/profile/avatar/v1",
102+ did: did,
103+ baseGenerator: "avatar-v1",
104+ canonicalSeedHex: toHex(seed),
105+ traits: traits)
106+
107+const
108+ W* = 16
109+ H* = 24
110+
111+const
112+ SKIN = 1
113+ HAIR = 2
114+ SHIRT = 3
115+ PANTS = 4
116+ SHOE = 5
117+ INK = 6
118+ ACCENT = 7
119+ WHITE = 8
120+
121+func renderSpritePixels*(av: Avatar, facing = fSouth, frame = 0): Sprite =
122+ ## Procedural 16x24 pixel body. Rows: hair/head 0-9, torso 10-17, legs 18-21,
123+ ## shoes 22-23. Facing changes the face; walk frames swing arms and legs.
124+ let t = av.traits
125+ var px = newSeq[int](W * H)
126+
127+ proc put(x, y, c: int) =
128+ if x >= 0 and x < W and y >= 0 and y < H:
129+ px[y * W + x] = c
130+
131+ proc hspan(x0, x1, y, c: int) =
132+ for x in x0 .. x1:
133+ put(x, y, c)
134+
135+ let
136+ silhouette = t["body_silhouette"]
137+ bodyW = if silhouette in ["broad", "round"]: 10
138+ elif silhouette == "slim": 6
139+ else: 8
140+ bodyX0 = (W - bodyW) div 2
141+ bodyX1 = bodyX0 + bodyW - 1
142+
143+ headShape = t["head_shape"]
144+ headW = if headShape == "wide": 10 else: 8
145+ headX0 = (W - headW) div 2
146+ headX1 = headX0 + headW - 1
147+ headTop = if silhouette == "tall": 1 else: 2
148+ hair = t["hair_shape"]
149+ acc = t["accessory"]
150+
151+ # head
152+ for y in headTop ..< 10:
153+ let shrink = if headShape in ["round", "oval"] and (y == headTop or y == 9): 1 else: 0
154+ hspan(headX0 + shrink, headX1 - shrink, y, SKIN)
155+
156+ # hair
157+ if hair != "bald":
158+ hspan(headX0, headX1, headTop, HAIR)
159+ hspan(headX0, headX1, headTop + 1, HAIR)
160+ if hair in ["spikes", "mohawk"]:
161+ let
162+ mohawk = hair == "mohawk"
163+ start = headX0 + (if mohawk: headW div 2 - 1 else: 0)
164+ stop = if mohawk: headX0 + headW div 2 else: headX1
165+ step = if mohawk: 1 else: 2
166+ var x = start
167+ while x <= stop:
168+ put(x, headTop - 1, HAIR)
169+ x += step
170+ if hair in ["bob", "long", "curls"]:
171+ put(headX0, headTop + 2, HAIR)
172+ put(headX1, headTop + 2, HAIR)
173+ put(headX0, headTop + 3, HAIR)
174+ put(headX1, headTop + 3, HAIR)
175+ if hair == "long":
176+ put(headX0, headTop + 4, HAIR)
177+ put(headX1, headTop + 4, HAIR)
178+ if hair == "bun":
179+ put(W div 2, headTop - 1, HAIR)
180+
181+ # face (not shown when facing north)
182+ if facing != fNorth:
183+ let
184+ eyeY = headTop + 4
185+ eyes = t["eye_pixels"]
186+ mid = W div 2
187+ eyeL = case facing
188+ of fEast: mid
189+ of fWest: headX0 + 1
190+ else: headX0 + 2
191+ eyeR = case facing
192+ of fEast: headX1 - 1
193+ of fWest: mid - 1
194+ else: headX1 - 2
195+ if eyes == "visor":
196+ hspan(eyeL, eyeR, eyeY, ACCENT)
197+ else:
198+ put(eyeL, eyeY, INK)
199+ if eyes != "wink":
200+ put(eyeR, eyeY, INK)
201+ if eyes == "wide":
202+ put(eyeL, eyeY - 1, WHITE)
203+ put(eyeR, eyeY - 1, WHITE)
204+ elif hair != "bald":
205+ # back of head is hair
206+ for y in headTop ..< 9:
207+ hspan(headX0, headX1, y, HAIR)
208+
209+ # torso
210+ for y in 10 ..< 18:
211+ hspan(bodyX0, bodyX1, y, SHIRT)
212+ # accent stripe
213+ hspan(bodyX0, bodyX1, 13, ACCENT)
214+
215+ # arms (swing with walk frame)
216+ let swing = case frame
217+ of 0: 0
218+ of 1: 1
219+ else: -1
220+ put(bodyX0 - 1, 11 + swing, SKIN)
221+ put(bodyX0 - 1, 12 + swing, SKIN)
222+ put(bodyX1 + 1, 11 - swing, SKIN)
223+ put(bodyX1 + 1, 12 - swing, SKIN)
224+
225+ # legs
226+ let
227+ legW = max(2, bodyW div 2 - 1)
228+ gap = case frame
229+ of 0: 2
230+ of 1: 4
231+ else: 0
232+ legL0 = W div 2 - gap div 2 - legW
233+ legR0 = W div 2 + int(ceil(float(gap) / 2.0))
234+ for y in 18 ..< 22:
235+ hspan(legL0, legL0 + legW - 1, y, PANTS)
236+ hspan(legR0, legR0 + legW - 1, y, PANTS)
237+ for y in 22 ..< 24:
238+ hspan(legL0, legL0 + legW - 1, y, SHOE)
239+ hspan(legR0, legR0 + legW - 1, y, SHOE)
240+
241+ # accessory
242+ case acc
243+ of "hat":
244+ hspan(headX0 - 1, headX1 + 1, headTop, ACCENT)
245+ hspan(headX0, headX1, headTop - 1, ACCENT)
246+ of "antenna":
247+ put(W div 2, headTop - 1, INK)
248+ put(W div 2, headTop - 2, ACCENT)
249+ of "scarf":
250+ hspan(bodyX0, bodyX1, 10, ACCENT)
251+ of "glasses":
252+ if facing != fNorth:
253+ hspan(headX0 + 1, headX1 - 1, headTop + 4, INK)
254+ of "headphones":
255+ put(headX0 - 1, headTop + 3, ACCENT)
256+ put(headX1 + 1, headTop + 3, ACCENT)
257+ of "flower":
258+ put(headX1, headTop + 1, ACCENT)
259+ of "badge":
260+ put(bodyX0 + 1, 11, ACCENT)
261+ else: discard
262+
263+ Sprite(width: W, height: H, pixels: px,
264+ palette: @["transparent", t["skin_palette"], t["hair_palette"],
265+ t["shirt_jacket"], t["pants_skirt"], t["shoes"],
266+ "#14141c", # outline/eyes
267+ t["accent_palette"], "#ffffff"])
268+
269+func spriteHash*(av: Avatar): string =
270+ ## Deterministic content hash over all four facings — conformance fixture value.
271+ var bs: seq[byte]
272+ for facing in [fSouth, fNorth, fEast, fWest]:
273+ let s = renderSpritePixels(av, facing, 0)
274+ for p in s.pixels:
275+ bs.add byte(p and 255)
276+ for color in s.palette:
277+ for ch in color:
278+ bs.add byte(ch)
279+ toHex(digest(bs))
added src/freeqsay/balloon.nim +177 -0
new file mode 100644
@@ -0,0 +1,177 @@
1+## Cowsay-style speech / thought balloons.
2+##
3+## The balloon measures in visible columns, not bytes: escape sequences cost
4+## nothing and are never split, so ANSI art fed through a balloon keeps its
5+## shape — `freeqsay a … | freeqsay b` puts one character in the other's mouth.
6+
7+import std/[strutils, sequtils, unicode]
8+
9+const
10+ ESC = '\x1b'
11+ RESET = "\x1b[0m"
12+
13+type Token* = tuple[esc: bool, text: string]
14+
15+func finalByte(c: char): bool =
16+ ## Last byte of a CSI sequence: @ through ~.
17+ 0x40 <= int(c) and int(c) <= 0x7e
18+
19+func escapeEnd*(s: string, i: int): int =
20+ ## Index just past the escape sequence starting at `i`, or -1 if `i` is not one.
21+ if i >= s.len or s[i] != ESC:
22+ return -1
23+ let n = s.len
24+ let c = if i + 1 < n: s[i + 1] else: '\0'
25+ case c
26+ of '[':
27+ # CSI: ESC [ params… final
28+ var j = i + 2
29+ while j < n:
30+ if finalByte(s[j]): return j + 1
31+ inc j
32+ n
33+ of ']':
34+ # OSC: ESC ] … BEL or ESC \
35+ var j = i + 2
36+ while j < n:
37+ if s[j] == '\a': return j + 1
38+ if s[j] == ESC and j + 1 < n and s[j + 1] == '\\': return j + 2
39+ inc j
40+ n
41+ else:
42+ # two-character escape
43+ min(n, i + 2)
44+
45+func tokens*(s: string): seq[Token] =
46+ ## Split a string into printable characters and whole escape sequences.
47+ var i = 0
48+ while i < s.len:
49+ let e = escapeEnd(s, i)
50+ if e >= 0:
51+ result.add (true, s[i ..< e])
52+ i = e
53+ else:
54+ let r = runeLenAt(s, i)
55+ result.add (false, s[i ..< i + r])
56+ i += r
57+
58+func visibleWidth*(s: string): int =
59+ ## Width of `s` in terminal columns — escape sequences are free.
60+ for t in tokens(s):
61+ if not t.esc: inc result
62+
63+func hasEscape(s: string): bool =
64+ for t in tokens(s):
65+ if t.esc: return true
66+
67+func detok(toks: openArray[Token]): string =
68+ for t in toks: result.add t.text
69+
70+func visCount(toks: openArray[Token]): int =
71+ for t in toks:
72+ if not t.esc: inc result
73+
74+func isBlank(t: Token): bool =
75+ not t.esc and t.text.strip().len == 0
76+
77+func splitWords(toks: seq[Token]): seq[seq[Token]] =
78+ ## Split a token seq on visible whitespace, dropping the whitespace.
79+ var cur: seq[Token]
80+ for t in toks:
81+ if isBlank(t):
82+ if cur.len > 0:
83+ result.add cur
84+ cur = @[]
85+ else:
86+ cur.add t
87+ if cur.len > 0:
88+ result.add cur
89+
90+func chunks(toks: seq[Token], width: int): seq[seq[Token]] =
91+ ## Split a word into token runs of at most `width` visible columns.
92+ var i = 0
93+ while i < toks.len:
94+ var
95+ taken: seq[Token]
96+ n = 0
97+ while i < toks.len:
98+ if toks[i].esc:
99+ taken.add toks[i]
100+ inc i
101+ elif n == width:
102+ break
103+ else:
104+ taken.add toks[i]
105+ inc i
106+ inc n
107+ result.add taken
108+
109+func wrapText*(text: string, width: int): seq[string] =
110+ ## Wrap text to `width` visible columns.
111+ ##
112+ ## A line that already fits is kept exactly as it is — leading indentation,
113+ ## runs of spaces and escape sequences included — so pixel art survives.
114+ ## Longer lines are reflowed on word boundaries, hard-breaking overlong tokens.
115+ let normalized = text.replace("\r\n", "\n").replace("\t", " ")
116+ for para in normalized.split('\n'):
117+ if visibleWidth(para) <= width:
118+ result.add para
119+ else:
120+ var line = ""
121+ for word in splitWords(tokens(para)):
122+ if visCount(word) > width:
123+ if line.len > 0:
124+ result.add line
125+ for c in chunks(word, width):
126+ result.add detok(c)
127+ line = ""
128+ else:
129+ let w = detok(word)
130+ if line.len == 0:
131+ line = w
132+ elif visibleWidth(line) + 1 + visCount(word) <= width:
133+ line = line & " " & w
134+ else:
135+ result.add line
136+ line = w
137+ if line.len > 0:
138+ result.add line
139+ if result.len == 0:
140+ result = @[""]
141+
142+func fit(s: string, n: int): string =
143+ ## Pad `s` out to `n` visible columns, resetting color first so the balloon's
144+ ## own border never inherits the content's background.
145+ s & (if hasEscape(s): RESET else: "") & " ".repeat(max(0, n - visibleWidth(s)))
146+
147+func makeBalloon*(text: string, width = 40, think = false): string =
148+ ## Classic cowsay balloon around `text`.
149+ let
150+ width = max(1, width)
151+ lines = wrapText(text, width)
152+ mx = max(1, lines.mapIt(visibleWidth(it)).max)
153+ top = " " & "_".repeat(mx + 2)
154+ bot = " " & "-".repeat(mx + 2)
155+ n = lines.len
156+ var body: seq[string]
157+ if n == 1:
158+ let p = fit(lines[0], mx)
159+ body.add (if think: "( " & p & " )" else: "< " & p & " >")
160+ else:
161+ for i, l in lines:
162+ let p = fit(l, mx)
163+ body.add:
164+ if think: "( " & p & " )"
165+ elif i == 0: "/ " & p & " \\"
166+ elif i == n - 1: "\\ " & p & " /"
167+ else: "| " & p & " |"
168+ (@[top] & body & @[bot]).join("\n")
169+
170+func makeTail*(think = false, indent = 8): string =
171+ ## Speech/thought tail that sits under the balloon and points toward the face.
172+ ## Indent is chosen so the tail sits near the left of a typical sprite.
173+ let pad = " ".repeat(indent)
174+ if think:
175+ pad & "o\n" & pad & " o"
176+ else:
177+ pad & "\\\n" & " ".repeat(indent + 1) & "\\"
new file mode 100644
@@ -0,0 +1,177 @@
1+## Cowsay-style speech / thought balloons.
2+##
3+## The balloon measures in visible columns, not bytes: escape sequences cost
4+## nothing and are never split, so ANSI art fed through a balloon keeps its
5+## shape — `freeqsay a … | freeqsay b` puts one character in the other's mouth.
6+
7+import std/[strutils, sequtils, unicode]
8+
9+const
10+ ESC = '\x1b'
11+ RESET = "\x1b[0m"
12+
13+type Token* = tuple[esc: bool, text: string]
14+
15+func finalByte(c: char): bool =
16+ ## Last byte of a CSI sequence: @ through ~.
17+ 0x40 <= int(c) and int(c) <= 0x7e
18+
19+func escapeEnd*(s: string, i: int): int =
20+ ## Index just past the escape sequence starting at `i`, or -1 if `i` is not one.
21+ if i >= s.len or s[i] != ESC:
22+ return -1
23+ let n = s.len
24+ let c = if i + 1 < n: s[i + 1] else: '\0'
25+ case c
26+ of '[':
27+ # CSI: ESC [ params… final
28+ var j = i + 2
29+ while j < n:
30+ if finalByte(s[j]): return j + 1
31+ inc j
32+ n
33+ of ']':
34+ # OSC: ESC ] … BEL or ESC \
35+ var j = i + 2
36+ while j < n:
37+ if s[j] == '\a': return j + 1
38+ if s[j] == ESC and j + 1 < n and s[j + 1] == '\\': return j + 2
39+ inc j
40+ n
41+ else:
42+ # two-character escape
43+ min(n, i + 2)
44+
45+func tokens*(s: string): seq[Token] =
46+ ## Split a string into printable characters and whole escape sequences.
47+ var i = 0
48+ while i < s.len:
49+ let e = escapeEnd(s, i)
50+ if e >= 0:
51+ result.add (true, s[i ..< e])
52+ i = e
53+ else:
54+ let r = runeLenAt(s, i)
55+ result.add (false, s[i ..< i + r])
56+ i += r
57+
58+func visibleWidth*(s: string): int =
59+ ## Width of `s` in terminal columns — escape sequences are free.
60+ for t in tokens(s):
61+ if not t.esc: inc result
62+
63+func hasEscape(s: string): bool =
64+ for t in tokens(s):
65+ if t.esc: return true
66+
67+func detok(toks: openArray[Token]): string =
68+ for t in toks: result.add t.text
69+
70+func visCount(toks: openArray[Token]): int =
71+ for t in toks:
72+ if not t.esc: inc result
73+
74+func isBlank(t: Token): bool =
75+ not t.esc and t.text.strip().len == 0
76+
77+func splitWords(toks: seq[Token]): seq[seq[Token]] =
78+ ## Split a token seq on visible whitespace, dropping the whitespace.
79+ var cur: seq[Token]
80+ for t in toks:
81+ if isBlank(t):
82+ if cur.len > 0:
83+ result.add cur
84+ cur = @[]
85+ else:
86+ cur.add t
87+ if cur.len > 0:
88+ result.add cur
89+
90+func chunks(toks: seq[Token], width: int): seq[seq[Token]] =
91+ ## Split a word into token runs of at most `width` visible columns.
92+ var i = 0
93+ while i < toks.len:
94+ var
95+ taken: seq[Token]
96+ n = 0
97+ while i < toks.len:
98+ if toks[i].esc:
99+ taken.add toks[i]
100+ inc i
101+ elif n == width:
102+ break
103+ else:
104+ taken.add toks[i]
105+ inc i
106+ inc n
107+ result.add taken
108+
109+func wrapText*(text: string, width: int): seq[string] =
110+ ## Wrap text to `width` visible columns.
111+ ##
112+ ## A line that already fits is kept exactly as it is — leading indentation,
113+ ## runs of spaces and escape sequences included — so pixel art survives.
114+ ## Longer lines are reflowed on word boundaries, hard-breaking overlong tokens.
115+ let normalized = text.replace("\r\n", "\n").replace("\t", " ")
116+ for para in normalized.split('\n'):
117+ if visibleWidth(para) <= width:
118+ result.add para
119+ else:
120+ var line = ""
121+ for word in splitWords(tokens(para)):
122+ if visCount(word) > width:
123+ if line.len > 0:
124+ result.add line
125+ for c in chunks(word, width):
126+ result.add detok(c)
127+ line = ""
128+ else:
129+ let w = detok(word)
130+ if line.len == 0:
131+ line = w
132+ elif visibleWidth(line) + 1 + visCount(word) <= width:
133+ line = line & " " & w
134+ else:
135+ result.add line
136+ line = w
137+ if line.len > 0:
138+ result.add line
139+ if result.len == 0:
140+ result = @[""]
141+
142+func fit(s: string, n: int): string =
143+ ## Pad `s` out to `n` visible columns, resetting color first so the balloon's
144+ ## own border never inherits the content's background.
145+ s & (if hasEscape(s): RESET else: "") & " ".repeat(max(0, n - visibleWidth(s)))
146+
147+func makeBalloon*(text: string, width = 40, think = false): string =
148+ ## Classic cowsay balloon around `text`.
149+ let
150+ width = max(1, width)
151+ lines = wrapText(text, width)
152+ mx = max(1, lines.mapIt(visibleWidth(it)).max)
153+ top = " " & "_".repeat(mx + 2)
154+ bot = " " & "-".repeat(mx + 2)
155+ n = lines.len
156+ var body: seq[string]
157+ if n == 1:
158+ let p = fit(lines[0], mx)
159+ body.add (if think: "( " & p & " )" else: "< " & p & " >")
160+ else:
161+ for i, l in lines:
162+ let p = fit(l, mx)
163+ body.add:
164+ if think: "( " & p & " )"
165+ elif i == 0: "/ " & p & " \\"
166+ elif i == n - 1: "\\ " & p & " /"
167+ else: "| " & p & " |"
168+ (@[top] & body & @[bot]).join("\n")
169+
170+func makeTail*(think = false, indent = 8): string =
171+ ## Speech/thought tail that sits under the balloon and points toward the face.
172+ ## Indent is chosen so the tail sits near the left of a typical sprite.
173+ let pad = " ".repeat(indent)
174+ if think:
175+ pad & "o\n" & pad & " o"
176+ else:
177+ pad & "\\\n" & " ".repeat(indent + 1) & "\\"
added src/freeqsay/png.nim +86 -0
new file mode 100644
@@ -0,0 +1,86 @@
1+## Minimal RGBA → PNG encoder. Optional path; ANSI is the default.
2+##
3+## Uses stored (uncompressed) deflate blocks so the encoder stays
4+## dependency-free — the result is a valid zlib stream every decoder accepts.
5+
6+import ./ansi
7+import ./avatar
8+
9+const crcTable = block:
10+ var t: array[256, uint32]
11+ for n in 0 ..< 256:
12+ var c = uint32(n)
13+ for _ in 0 ..< 8:
14+ c = if (c and 1) != 0: 0xedb88320'u32 xor (c shr 1) else: c shr 1
15+ t[n] = c
16+ t
17+
18+func crc32(bs: openArray[byte]): uint32 =
19+ var c = 0xffffffff'u32
20+ for b in bs:
21+ c = crcTable[(c xor uint32(b)) and 0xff] xor (c shr 8)
22+ c xor 0xffffffff'u32
23+
24+func adler32(bs: openArray[byte]): uint32 =
25+ var a = 1'u32
26+ var b = 0'u32
27+ for x in bs:
28+ a = (a + uint32(x)) mod 65521
29+ b = (b + a) mod 65521
30+ (b shl 16) or a
31+
32+func u32(n: uint32): seq[byte] =
33+ @[byte((n shr 24) and 255), byte((n shr 16) and 255),
34+ byte((n shr 8) and 255), byte(n and 255)]
35+
36+func pngChunk(typ: string, data: openArray[byte]): seq[byte] =
37+ var body: seq[byte]
38+ for c in typ: body.add byte(c)
39+ body.add @data
40+ u32(uint32(data.len)) & body & u32(crc32(body))
41+
42+func deflateStored(bs: openArray[byte]): seq[byte] =
43+ ## zlib stream wrapping `bs` in stored deflate blocks (no compression).
44+ result = @[0x78'u8, 0x01'u8] # zlib header, no preset dict
45+ var i = 0
46+ while true:
47+ let
48+ len = min(65535, bs.len - i)
49+ last = i + len >= bs.len
50+ nlen = not uint16(len)
51+ result.add byte(if last: 1 else: 0)
52+ result.add byte(len and 255)
53+ result.add byte((len shr 8) and 255)
54+ result.add byte(nlen and 255)
55+ result.add byte((nlen shr 8) and 255)
56+ for k in 0 ..< len:
57+ result.add bs[i + k]
58+ i += len
59+ if last: break
60+ result.add u32(adler32(bs))
61+
62+func rgbaToPng*(img: Rgba): seq[byte] =
63+ ## Encode RGBA into PNG bytes (color type 6, 8-bit).
64+ let stride = img.width * 4
65+ var raw: seq[byte]
66+ for y in 0 ..< img.height:
67+ raw.add 0'u8 # filter None
68+ for x in 0 ..< stride:
69+ raw.add byte(img.data[y * stride + x])
70+ let ihdr = u32(uint32(img.width)) & u32(uint32(img.height)) &
71+ @[8'u8, 6'u8, 0'u8, 0'u8, 0'u8]
72+ @[137'u8, 80, 78, 71, 13, 10, 26, 10] &
73+ pngChunk("IHDR", ihdr) &
74+ pngChunk("IDAT", deflateStored(raw)) &
75+ pngChunk("IEND", @[])
76+
77+proc writePng*(bs: openArray[byte], path: string) =
78+ ## Write PNG bytes to `path`.
79+ var f = open(path, fmWrite)
80+ defer: f.close()
81+ if bs.len > 0:
82+ discard f.writeBuffer(unsafeAddr bs[0], bs.len)
83+
84+func didToPng*(did: string, scale = 16, facing = fSouth, frame = 0): seq[byte] =
85+ ## DID → PNG bytes (no network).
86+ rgbaToPng(scaleRgba(spriteToRgba(renderSpritePixels(deriveAvatar(did), facing, frame)), scale))
new file mode 100644
@@ -0,0 +1,86 @@
1+## Minimal RGBA → PNG encoder. Optional path; ANSI is the default.
2+##
3+## Uses stored (uncompressed) deflate blocks so the encoder stays
4+## dependency-free — the result is a valid zlib stream every decoder accepts.
5+
6+import ./ansi
7+import ./avatar
8+
9+const crcTable = block:
10+ var t: array[256, uint32]
11+ for n in 0 ..< 256:
12+ var c = uint32(n)
13+ for _ in 0 ..< 8:
14+ c = if (c and 1) != 0: 0xedb88320'u32 xor (c shr 1) else: c shr 1
15+ t[n] = c
16+ t
17+
18+func crc32(bs: openArray[byte]): uint32 =
19+ var c = 0xffffffff'u32
20+ for b in bs:
21+ c = crcTable[(c xor uint32(b)) and 0xff] xor (c shr 8)
22+ c xor 0xffffffff'u32
23+
24+func adler32(bs: openArray[byte]): uint32 =
25+ var a = 1'u32
26+ var b = 0'u32
27+ for x in bs:
28+ a = (a + uint32(x)) mod 65521
29+ b = (b + a) mod 65521
30+ (b shl 16) or a
31+
32+func u32(n: uint32): seq[byte] =
33+ @[byte((n shr 24) and 255), byte((n shr 16) and 255),
34+ byte((n shr 8) and 255), byte(n and 255)]
35+
36+func pngChunk(typ: string, data: openArray[byte]): seq[byte] =
37+ var body: seq[byte]
38+ for c in typ: body.add byte(c)
39+ body.add @data
40+ u32(uint32(data.len)) & body & u32(crc32(body))
41+
42+func deflateStored(bs: openArray[byte]): seq[byte] =
43+ ## zlib stream wrapping `bs` in stored deflate blocks (no compression).
44+ result = @[0x78'u8, 0x01'u8] # zlib header, no preset dict
45+ var i = 0
46+ while true:
47+ let
48+ len = min(65535, bs.len - i)
49+ last = i + len >= bs.len
50+ nlen = not uint16(len)
51+ result.add byte(if last: 1 else: 0)
52+ result.add byte(len and 255)
53+ result.add byte((len shr 8) and 255)
54+ result.add byte(nlen and 255)
55+ result.add byte((nlen shr 8) and 255)
56+ for k in 0 ..< len:
57+ result.add bs[i + k]
58+ i += len
59+ if last: break
60+ result.add u32(adler32(bs))
61+
62+func rgbaToPng*(img: Rgba): seq[byte] =
63+ ## Encode RGBA into PNG bytes (color type 6, 8-bit).
64+ let stride = img.width * 4
65+ var raw: seq[byte]
66+ for y in 0 ..< img.height:
67+ raw.add 0'u8 # filter None
68+ for x in 0 ..< stride:
69+ raw.add byte(img.data[y * stride + x])
70+ let ihdr = u32(uint32(img.width)) & u32(uint32(img.height)) &
71+ @[8'u8, 6'u8, 0'u8, 0'u8, 0'u8]
72+ @[137'u8, 80, 78, 71, 13, 10, 26, 10] &
73+ pngChunk("IHDR", ihdr) &
74+ pngChunk("IDAT", deflateStored(raw)) &
75+ pngChunk("IEND", @[])
76+
77+proc writePng*(bs: openArray[byte], path: string) =
78+ ## Write PNG bytes to `path`.
79+ var f = open(path, fmWrite)
80+ defer: f.close()
81+ if bs.len > 0:
82+ discard f.writeBuffer(unsafeAddr bs[0], bs.len)
83+
84+func didToPng*(did: string, scale = 16, facing = fSouth, frame = 0): seq[byte] =
85+ ## DID → PNG bytes (no network).
86+ rgbaToPng(scaleRgba(spriteToRgba(renderSpritePixels(deriveAvatar(did), facing, frame)), scale))
added src/freeqsay/resolve.nim +102 -0
new file mode 100644
@@ -0,0 +1,102 @@
1+## Resolve Bluesky / ATProto handles to DIDs via the public AppView.
2+## Avatar traits are still a pure function of the DID after resolution.
3+
4+import std/[httpclient, json, strutils]
5+
6+const defaultEndpoint* =
7+ "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"
8+
9+type
10+ HttpResponse* = object
11+ status*: int ## 0 means the request never reached the server
12+ body*: JsonNode
13+ error*: string
14+
15+ HttpGet* = proc (url: string): HttpResponse {.gcsafe.}
16+
17+ Identity* = object
18+ did*: string
19+ handle*: string
20+
21+ ResolveOpts* = object
22+ endpoint*: string
23+ httpGet*: HttpGet ## test hook; nil uses the real transport
24+
25+func normalizeHandle*(handle: string): string =
26+ ## Strip @ and whitespace.
27+ handle.strip().strip(trailing = false, chars = {'@'}).toLowerAscii
28+
29+func isDid*(s: string): bool =
30+ ## True if the string is already a DID: `did:<method>:…`, method case-insensitive.
31+ let t = s.strip()
32+ if not t.toLowerAscii.startsWith("did:"):
33+ return false
34+ var i = 4
35+ while i < t.len and t[i] in {'a'..'z', 'A'..'Z', '0'..'9'}:
36+ inc i
37+ i > 4 and i < t.len and t[i] == ':'
38+
39+func urlEncode(s: string): string =
40+ for c in s:
41+ if c in {'A'..'Z', 'a'..'z', '0'..'9', '_', '.', '~', '-'}:
42+ result.add c
43+ else:
44+ result.add '%' & toHex(int(uint8(c)), 2)
45+
46+proc httpGetJson(url: string): HttpResponse {.gcsafe.} =
47+ ## GET `url` and parse the body as JSON. `status: 0` means the request never
48+ ## reached the server — the transport itself failed — and `error` says why.
49+ {.cast(gcsafe).}:
50+ try:
51+ let client = newHttpClient(timeout = 15_000)
52+ defer: client.close()
53+ let resp = client.get(url)
54+ # `status` is "200 OK". Body parsing gets its own try: an error page from
55+ # the AppView must stay an HTTP failure, not look like a dead transport.
56+ let status = parseInt(resp.status.split(' ')[0])
57+ var body: JsonNode
58+ try: body = parseJson(resp.body) except CatchableError: discard
59+ HttpResponse(status: status, body: body)
60+ except CatchableError as e:
61+ HttpResponse(status: 0, error: e.msg)
62+
63+proc resolveHandle*(handle: string, opts = ResolveOpts()): string =
64+ ## Resolve a handle to its DID. Raises if the handle cannot be resolved.
65+ let clean = normalizeHandle(handle)
66+ if clean.len == 0:
67+ raise newException(ValueError, "empty handle")
68+ if isDid(clean):
69+ return clean.strip()
70+
71+ let
72+ endpoint = if opts.endpoint.len > 0: opts.endpoint else: defaultEndpoint
73+ url = endpoint & "?handle=" & urlEncode(clean)
74+ get = if opts.httpGet != nil: opts.httpGet else: HttpGet(httpGetJson)
75+ resp = get(url)
76+
77+ if resp.status == 0:
78+ # No HTTP status at all: the request never left the machine. Saying "is
79+ # that a real handle?" here blames the user for our own missing transport.
80+ # Name it, and point at the offline path.
81+ raise newException(IOError,
82+ "couldn't reach the Bluesky API to resolve @" & clean &
83+ (if resp.error.len > 0: "" & resp.error else: "") &
84+ "\nPass the DID directly (freeqsay did:plc:… …) to skip resolution.")
85+ if resp.status != 200:
86+ raise newException(IOError,
87+ "couldn't resolve @" & clean & " — is that a real Bluesky handle? (" &
88+ $resp.status & ")")
89+
90+ let did = if resp.body != nil and resp.body.kind == JObject and
91+ resp.body.hasKey("did"): resp.body["did"].getStr else: ""
92+ if did.len == 0 or not isDid(did):
93+ raise newException(IOError, "resolveHandle returned no DID for @" & clean)
94+ did
95+
96+proc resolveIdentity*(handleOrDid: string, opts = ResolveOpts()): Identity =
97+ ## Accept either a handle or a DID; resolve handles, pass DIDs through.
98+ let raw = handleOrDid.strip()
99+ if isDid(raw):
100+ Identity(did: raw)
101+ else:
102+ Identity(did: resolveHandle(raw, opts), handle: normalizeHandle(raw))
new file mode 100644
@@ -0,0 +1,102 @@
1+## Resolve Bluesky / ATProto handles to DIDs via the public AppView.
2+## Avatar traits are still a pure function of the DID after resolution.
3+
4+import std/[httpclient, json, strutils]
5+
6+const defaultEndpoint* =
7+ "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"
8+
9+type
10+ HttpResponse* = object
11+ status*: int ## 0 means the request never reached the server
12+ body*: JsonNode
13+ error*: string
14+
15+ HttpGet* = proc (url: string): HttpResponse {.gcsafe.}
16+
17+ Identity* = object
18+ did*: string
19+ handle*: string
20+
21+ ResolveOpts* = object
22+ endpoint*: string
23+ httpGet*: HttpGet ## test hook; nil uses the real transport
24+
25+func normalizeHandle*(handle: string): string =
26+ ## Strip @ and whitespace.
27+ handle.strip().strip(trailing = false, chars = {'@'}).toLowerAscii
28+
29+func isDid*(s: string): bool =
30+ ## True if the string is already a DID: `did:<method>:…`, method case-insensitive.
31+ let t = s.strip()
32+ if not t.toLowerAscii.startsWith("did:"):
33+ return false
34+ var i = 4
35+ while i < t.len and t[i] in {'a'..'z', 'A'..'Z', '0'..'9'}:
36+ inc i
37+ i > 4 and i < t.len and t[i] == ':'
38+
39+func urlEncode(s: string): string =
40+ for c in s:
41+ if c in {'A'..'Z', 'a'..'z', '0'..'9', '_', '.', '~', '-'}:
42+ result.add c
43+ else:
44+ result.add '%' & toHex(int(uint8(c)), 2)
45+
46+proc httpGetJson(url: string): HttpResponse {.gcsafe.} =
47+ ## GET `url` and parse the body as JSON. `status: 0` means the request never
48+ ## reached the server — the transport itself failed — and `error` says why.
49+ {.cast(gcsafe).}:
50+ try:
51+ let client = newHttpClient(timeout = 15_000)
52+ defer: client.close()
53+ let resp = client.get(url)
54+ # `status` is "200 OK". Body parsing gets its own try: an error page from
55+ # the AppView must stay an HTTP failure, not look like a dead transport.
56+ let status = parseInt(resp.status.split(' ')[0])
57+ var body: JsonNode
58+ try: body = parseJson(resp.body) except CatchableError: discard
59+ HttpResponse(status: status, body: body)
60+ except CatchableError as e:
61+ HttpResponse(status: 0, error: e.msg)
62+
63+proc resolveHandle*(handle: string, opts = ResolveOpts()): string =
64+ ## Resolve a handle to its DID. Raises if the handle cannot be resolved.
65+ let clean = normalizeHandle(handle)
66+ if clean.len == 0:
67+ raise newException(ValueError, "empty handle")
68+ if isDid(clean):
69+ return clean.strip()
70+
71+ let
72+ endpoint = if opts.endpoint.len > 0: opts.endpoint else: defaultEndpoint
73+ url = endpoint & "?handle=" & urlEncode(clean)
74+ get = if opts.httpGet != nil: opts.httpGet else: HttpGet(httpGetJson)
75+ resp = get(url)
76+
77+ if resp.status == 0:
78+ # No HTTP status at all: the request never left the machine. Saying "is
79+ # that a real handle?" here blames the user for our own missing transport.
80+ # Name it, and point at the offline path.
81+ raise newException(IOError,
82+ "couldn't reach the Bluesky API to resolve @" & clean &
83+ (if resp.error.len > 0: "" & resp.error else: "") &
84+ "\nPass the DID directly (freeqsay did:plc:… …) to skip resolution.")
85+ if resp.status != 200:
86+ raise newException(IOError,
87+ "couldn't resolve @" & clean & " — is that a real Bluesky handle? (" &
88+ $resp.status & ")")
89+
90+ let did = if resp.body != nil and resp.body.kind == JObject and
91+ resp.body.hasKey("did"): resp.body["did"].getStr else: ""
92+ if did.len == 0 or not isDid(did):
93+ raise newException(IOError, "resolveHandle returned no DID for @" & clean)
94+ did
95+
96+proc resolveIdentity*(handleOrDid: string, opts = ResolveOpts()): Identity =
97+ ## Accept either a handle or a DID; resolve handles, pass DIDs through.
98+ let raw = handleOrDid.strip()
99+ if isDid(raw):
100+ Identity(did: raw)
101+ else:
102+ Identity(did: resolveHandle(raw, opts), handle: normalizeHandle(raw))
added src/freeqsay/say.nim +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+## freeqsay: handle + message → cowsay balloon over a FreeqWorld ANSI avatar.
2+
3+import std/strutils
4+import ./ansi, ./avatar, ./balloon, ./resolve
5+
6+type Said* = object
7+ did*, handle*, text*, output*: string
8+
9+proc freeqsay*(handleOrDid, message: string, width = 40, think = false,
10+ tailIndent = 8, scale = 2, facing = fSouth, frame = 0,
11+ resolveOpts = ResolveOpts()): Said =
12+ ## Compose a cowsay balloon + FreeqWorld character for a handle and message.
13+ let
14+ id = resolveIdentity(handleOrDid, resolveOpts)
15+ av = deriveAvatar(id.did)
16+ balloon = makeBalloon(message, width, think)
17+ tail = makeTail(think, tailIndent)
18+ face = avatarToAnsi(av, scale, facing, frame)
19+ Said(did: id.did, handle: id.handle, text: message,
20+ output: [balloon, tail, face].join("\n"))
new file mode 100644
@@ -0,0 +1,20 @@
1+## freeqsay: handle + message → cowsay balloon over a FreeqWorld ANSI avatar.
2+
3+import std/strutils
4+import ./ansi, ./avatar, ./balloon, ./resolve
5+
6+type Said* = object
7+ did*, handle*, text*, output*: string
8+
9+proc freeqsay*(handleOrDid, message: string, width = 40, think = false,
10+ tailIndent = 8, scale = 2, facing = fSouth, frame = 0,
11+ resolveOpts = ResolveOpts()): Said =
12+ ## Compose a cowsay balloon + FreeqWorld character for a handle and message.
13+ let
14+ id = resolveIdentity(handleOrDid, resolveOpts)
15+ av = deriveAvatar(id.did)
16+ balloon = makeBalloon(message, width, think)
17+ tail = makeTail(think, tailIndent)
18+ face = avatarToAnsi(av, scale, facing, frame)
19+ Said(did: id.did, handle: id.handle, text: message,
20+ output: [balloon, tail, face].join("\n"))
added src/freeqsay/sha256.nim +123 -0
new file mode 100644
@@ -0,0 +1,123 @@
1+## Pure-Nim SHA-256, HMAC-SHA256 and HKDF-SHA256.
2+##
3+## No dependencies: the avatar seed has to be derivable anywhere the port runs,
4+## including builds with no OpenSSL to bind against.
5+
6+import std/strutils
7+
8+type Sha256Digest* = array[32, byte]
9+
10+const K: array[64, uint32] = [
11+ 0x428a2f98'u32, 0x71374491'u32, 0xb5c0fbcf'u32, 0xe9b5dba5'u32,
12+ 0x3956c25b'u32, 0x59f111f1'u32, 0x923f82a4'u32, 0xab1c5ed5'u32,
13+ 0xd807aa98'u32, 0x12835b01'u32, 0x243185be'u32, 0x550c7dc3'u32,
14+ 0x72be5d74'u32, 0x80deb1fe'u32, 0x9bdc06a7'u32, 0xc19bf174'u32,
15+ 0xe49b69c1'u32, 0xefbe4786'u32, 0x0fc19dc6'u32, 0x240ca1cc'u32,
16+ 0x2de92c6f'u32, 0x4a7484aa'u32, 0x5cb0a9dc'u32, 0x76f988da'u32,
17+ 0x983e5152'u32, 0xa831c66d'u32, 0xb00327c8'u32, 0xbf597fc7'u32,
18+ 0xc6e00bf3'u32, 0xd5a79147'u32, 0x06ca6351'u32, 0x14292967'u32,
19+ 0x27b70a85'u32, 0x2e1b2138'u32, 0x4d2c6dfc'u32, 0x53380d13'u32,
20+ 0x650a7354'u32, 0x766a0abb'u32, 0x81c2c92e'u32, 0x92722c85'u32,
21+ 0xa2bfe8a1'u32, 0xa81a664b'u32, 0xc24b8b70'u32, 0xc76c51a3'u32,
22+ 0xd192e819'u32, 0xd6990624'u32, 0xf40e3585'u32, 0x106aa070'u32,
23+ 0x19a4c116'u32, 0x1e376c08'u32, 0x2748774c'u32, 0x34b0bcb5'u32,
24+ 0x391c0cb3'u32, 0x4ed8aa4a'u32, 0x5b9cca4f'u32, 0x682e6ff3'u32,
25+ 0x748f82ee'u32, 0x78a5636f'u32, 0x84c87814'u32, 0x8cc70208'u32,
26+ 0x90befffa'u32, 0xa4506ceb'u32, 0xbef9a3f7'u32, 0xc67178f2'u32]
27+
28+const H0: array[8, uint32] = [
29+ 0x6a09e667'u32, 0xbb67ae85'u32, 0x3c6ef372'u32, 0xa54ff53a'u32,
30+ 0x510e527f'u32, 0x9b05688c'u32, 0x1f83d9ab'u32, 0x5be0cd19'u32]
31+
32+func rotr(x: uint32, n: int): uint32 {.inline.} =
33+ (x shr n) or (x shl (32 - n))
34+
35+func toBytes*(s: string): seq[byte] =
36+ ## UTF-8 bytes of a string.
37+ result = newSeq[byte](s.len)
38+ for i, c in s:
39+ result[i] = byte(c)
40+
41+func digest*(input: openArray[byte]): Sha256Digest =
42+ let
43+ n = input.len
44+ bitlen = uint64(n) * 8
45+ blocks = (n + 9 + 63) div 64
46+ var padded = newSeq[byte](blocks * 64)
47+ for i, b in input:
48+ padded[i] = b
49+ padded[n] = 0x80
50+ for i in 0 ..< 8:
51+ padded[padded.len - 1 - i] = byte((bitlen shr (8 * i)) and 0xff)
52+
53+ var h = H0
54+ var w: array[64, uint32]
55+ for blk in 0 ..< blocks:
56+ let off = blk * 64
57+ for i in 0 ..< 16:
58+ w[i] = (uint32(padded[off + i*4]) shl 24) or
59+ (uint32(padded[off + i*4 + 1]) shl 16) or
60+ (uint32(padded[off + i*4 + 2]) shl 8) or
61+ uint32(padded[off + i*4 + 3])
62+ for i in 16 ..< 64:
63+ let
64+ w15 = w[i - 15]
65+ w2 = w[i - 2]
66+ s0 = rotr(w15, 7) xor rotr(w15, 18) xor (w15 shr 3)
67+ s1 = rotr(w2, 17) xor rotr(w2, 19) xor (w2 shr 10)
68+ w[i] = w[i - 16] + s0 + w[i - 7] + s1
69+
70+ var (a, b, c, d, e, f, g, hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7])
71+ for i in 0 ..< 64:
72+ let
73+ s1 = rotr(e, 6) xor rotr(e, 11) xor rotr(e, 25)
74+ ch = (e and f) xor ((not e) and g)
75+ t1 = hh + s1 + ch + K[i] + w[i]
76+ s0 = rotr(a, 2) xor rotr(a, 13) xor rotr(a, 22)
77+ maj = (a and b) xor (a and c) xor (b and c)
78+ t2 = s0 + maj
79+ hh = g; g = f; f = e; e = d + t1
80+ d = c; c = b; b = a; a = t1 + t2
81+
82+ for i, v in [a, b, c, d, e, f, g, hh]:
83+ h[i] = h[i] + v
84+
85+ for i, v in h:
86+ result[i*4] = byte((v shr 24) and 0xff)
87+ result[i*4 + 1] = byte((v shr 16) and 0xff)
88+ result[i*4 + 2] = byte((v shr 8) and 0xff)
89+ result[i*4 + 3] = byte(v and 0xff)
90+
91+func digest*(s: string): Sha256Digest = digest(toBytes(s))
92+
93+func hmac*(key, msg: openArray[byte]): Sha256Digest =
94+ ## HMAC-SHA256.
95+ var k = @key
96+ if k.len > 64:
97+ k = @(digest(k))
98+ var ipad, opad = newSeq[byte](64)
99+ for i in 0 ..< 64:
100+ let kb = if i < k.len: k[i] else: 0'u8
101+ ipad[i] = kb xor 0x36
102+ opad[i] = kb xor 0x5c
103+ let inner = digest(ipad & @msg)
104+ digest(opad & @inner)
105+
106+func hmac*(key, msg: string): Sha256Digest = hmac(toBytes(key), toBytes(msg))
107+
108+func hkdf*(ikm, salt, info: openArray[byte], length = 32): seq[byte] =
109+ ## HKDF-SHA256 (extract + expand). One expand block, so `length` <= 32.
110+ doAssert length <= 32, "hkdf: only one expand block is implemented"
111+ let prk = hmac(salt, ikm)
112+ let t1 = hmac(prk, @info & @[1'u8])
113+ result = @(t1)
114+ result.setLen(length)
115+
116+func hkdf*(ikm, salt, info: string, length = 32): seq[byte] =
117+ hkdf(toBytes(ikm), toBytes(salt), toBytes(info), length)
118+
119+func toHex*(bs: openArray[byte]): string =
120+ ## Lowercase hex.
121+ result = newStringOfCap(bs.len * 2)
122+ for b in bs:
123+ result.add toHex(int(b), 2).toLowerAscii
new file mode 100644
@@ -0,0 +1,123 @@
1+## Pure-Nim SHA-256, HMAC-SHA256 and HKDF-SHA256.
2+##
3+## No dependencies: the avatar seed has to be derivable anywhere the port runs,
4+## including builds with no OpenSSL to bind against.
5+
6+import std/strutils
7+
8+type Sha256Digest* = array[32, byte]
9+
10+const K: array[64, uint32] = [
11+ 0x428a2f98'u32, 0x71374491'u32, 0xb5c0fbcf'u32, 0xe9b5dba5'u32,
12+ 0x3956c25b'u32, 0x59f111f1'u32, 0x923f82a4'u32, 0xab1c5ed5'u32,
13+ 0xd807aa98'u32, 0x12835b01'u32, 0x243185be'u32, 0x550c7dc3'u32,
14+ 0x72be5d74'u32, 0x80deb1fe'u32, 0x9bdc06a7'u32, 0xc19bf174'u32,
15+ 0xe49b69c1'u32, 0xefbe4786'u32, 0x0fc19dc6'u32, 0x240ca1cc'u32,
16+ 0x2de92c6f'u32, 0x4a7484aa'u32, 0x5cb0a9dc'u32, 0x76f988da'u32,
17+ 0x983e5152'u32, 0xa831c66d'u32, 0xb00327c8'u32, 0xbf597fc7'u32,
18+ 0xc6e00bf3'u32, 0xd5a79147'u32, 0x06ca6351'u32, 0x14292967'u32,
19+ 0x27b70a85'u32, 0x2e1b2138'u32, 0x4d2c6dfc'u32, 0x53380d13'u32,
20+ 0x650a7354'u32, 0x766a0abb'u32, 0x81c2c92e'u32, 0x92722c85'u32,
21+ 0xa2bfe8a1'u32, 0xa81a664b'u32, 0xc24b8b70'u32, 0xc76c51a3'u32,
22+ 0xd192e819'u32, 0xd6990624'u32, 0xf40e3585'u32, 0x106aa070'u32,
23+ 0x19a4c116'u32, 0x1e376c08'u32, 0x2748774c'u32, 0x34b0bcb5'u32,
24+ 0x391c0cb3'u32, 0x4ed8aa4a'u32, 0x5b9cca4f'u32, 0x682e6ff3'u32,
25+ 0x748f82ee'u32, 0x78a5636f'u32, 0x84c87814'u32, 0x8cc70208'u32,
26+ 0x90befffa'u32, 0xa4506ceb'u32, 0xbef9a3f7'u32, 0xc67178f2'u32]
27+
28+const H0: array[8, uint32] = [
29+ 0x6a09e667'u32, 0xbb67ae85'u32, 0x3c6ef372'u32, 0xa54ff53a'u32,
30+ 0x510e527f'u32, 0x9b05688c'u32, 0x1f83d9ab'u32, 0x5be0cd19'u32]
31+
32+func rotr(x: uint32, n: int): uint32 {.inline.} =
33+ (x shr n) or (x shl (32 - n))
34+
35+func toBytes*(s: string): seq[byte] =
36+ ## UTF-8 bytes of a string.
37+ result = newSeq[byte](s.len)
38+ for i, c in s:
39+ result[i] = byte(c)
40+
41+func digest*(input: openArray[byte]): Sha256Digest =
42+ let
43+ n = input.len
44+ bitlen = uint64(n) * 8
45+ blocks = (n + 9 + 63) div 64
46+ var padded = newSeq[byte](blocks * 64)
47+ for i, b in input:
48+ padded[i] = b
49+ padded[n] = 0x80
50+ for i in 0 ..< 8:
51+ padded[padded.len - 1 - i] = byte((bitlen shr (8 * i)) and 0xff)
52+
53+ var h = H0
54+ var w: array[64, uint32]
55+ for blk in 0 ..< blocks:
56+ let off = blk * 64
57+ for i in 0 ..< 16:
58+ w[i] = (uint32(padded[off + i*4]) shl 24) or
59+ (uint32(padded[off + i*4 + 1]) shl 16) or
60+ (uint32(padded[off + i*4 + 2]) shl 8) or
61+ uint32(padded[off + i*4 + 3])
62+ for i in 16 ..< 64:
63+ let
64+ w15 = w[i - 15]
65+ w2 = w[i - 2]
66+ s0 = rotr(w15, 7) xor rotr(w15, 18) xor (w15 shr 3)
67+ s1 = rotr(w2, 17) xor rotr(w2, 19) xor (w2 shr 10)
68+ w[i] = w[i - 16] + s0 + w[i - 7] + s1
69+
70+ var (a, b, c, d, e, f, g, hh) = (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7])
71+ for i in 0 ..< 64:
72+ let
73+ s1 = rotr(e, 6) xor rotr(e, 11) xor rotr(e, 25)
74+ ch = (e and f) xor ((not e) and g)
75+ t1 = hh + s1 + ch + K[i] + w[i]
76+ s0 = rotr(a, 2) xor rotr(a, 13) xor rotr(a, 22)
77+ maj = (a and b) xor (a and c) xor (b and c)
78+ t2 = s0 + maj
79+ hh = g; g = f; f = e; e = d + t1
80+ d = c; c = b; b = a; a = t1 + t2
81+
82+ for i, v in [a, b, c, d, e, f, g, hh]:
83+ h[i] = h[i] + v
84+
85+ for i, v in h:
86+ result[i*4] = byte((v shr 24) and 0xff)
87+ result[i*4 + 1] = byte((v shr 16) and 0xff)
88+ result[i*4 + 2] = byte((v shr 8) and 0xff)
89+ result[i*4 + 3] = byte(v and 0xff)
90+
91+func digest*(s: string): Sha256Digest = digest(toBytes(s))
92+
93+func hmac*(key, msg: openArray[byte]): Sha256Digest =
94+ ## HMAC-SHA256.
95+ var k = @key
96+ if k.len > 64:
97+ k = @(digest(k))
98+ var ipad, opad = newSeq[byte](64)
99+ for i in 0 ..< 64:
100+ let kb = if i < k.len: k[i] else: 0'u8
101+ ipad[i] = kb xor 0x36
102+ opad[i] = kb xor 0x5c
103+ let inner = digest(ipad & @msg)
104+ digest(opad & @inner)
105+
106+func hmac*(key, msg: string): Sha256Digest = hmac(toBytes(key), toBytes(msg))
107+
108+func hkdf*(ikm, salt, info: openArray[byte], length = 32): seq[byte] =
109+ ## HKDF-SHA256 (extract + expand). One expand block, so `length` <= 32.
110+ doAssert length <= 32, "hkdf: only one expand block is implemented"
111+ let prk = hmac(salt, ikm)
112+ let t1 = hmac(prk, @info & @[1'u8])
113+ result = @(t1)
114+ result.setLen(length)
115+
116+func hkdf*(ikm, salt, info: string, length = 32): seq[byte] =
117+ hkdf(toBytes(ikm), toBytes(salt), toBytes(info), length)
118+
119+func toHex*(bs: openArray[byte]): string =
120+ ## Lowercase hex.
121+ result = newStringOfCap(bs.len * 2)
122+ for b in bs:
123+ result.add toHex(int(b), 2).toLowerAscii
added tests/test_avatar.nim +130 -0
new file mode 100644
@@ -0,0 +1,130 @@
1+import std/[json, os, sequtils, strutils, tables, unittest]
2+import ../src/freeqsay
3+
4+const didA = "did:plc:ewvi7nmzuoqusbcablsm7c4h"
5+
6+let fixtures = parseFile(currentSourcePath().parentDir / ".." /
7+ "fixtures" / "avatar-conformance.json")
8+
9+proc stubGet(did: string): HttpGet =
10+ ## Test hook standing in for the network: always answers with `did`.
11+ result = proc (url: string): HttpResponse {.gcsafe.} =
12+ HttpResponse(status: 200, body: %*{"did": did})
13+
14+suite "sha256":
15+ test "vectors":
16+ check toHex(digest("")) ==
17+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
18+ check toHex(digest("abc")) ==
19+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
20+ # RFC 4231 test case 1
21+ check toHex(hmac(repeat(0x0b'u8, 20), toBytes("Hi There"))) ==
22+ "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
23+
24+suite "avatar-v1 conformance":
25+ test "fixture replays":
26+ check fixtures.len > 0
27+ for f in fixtures:
28+ let
29+ did = f["did"].getStr
30+ av = deriveAvatar(did)
31+ checkpoint did
32+ check f["canonical_seed"].getStr == toHex(canonicalSeed(did))
33+ for key, want in f["expected_traits"]:
34+ check av.traits[key] == want.getStr
35+ check f["sprite_hash"].getStr == spriteHash(av)
36+
37+suite "balloons":
38+ test "wraps long lines":
39+ let lines = wrapText("one two three four five", 10)
40+ check lines.allIt(it.len <= 10)
41+ check "one" in lines.join(" ")
42+
43+ test "single-line speech balloon":
44+ let b = makeBalloon("hi")
45+ check "< hi" in b
46+ let top = b.splitLines[0]
47+ check top.startsWith(" ") and top[1 .. ^1].allIt(it == '_')
48+
49+ test "multi-line balloon":
50+ let b = makeBalloon("hello there friend", width = 6)
51+ check "/ " in b
52+ check "\\ " in b
53+
54+ test "thought bubble":
55+ check "( hmm )" in makeBalloon("hmm", think = true)
56+
57+suite "balloons preserve ANSI shape":
58+ let
59+ esc = "\x1b"
60+ art = didToAnsi(didA, scale = 1)
61+ lines = art.splitLines
62+
63+ test "escape sequences cost no columns":
64+ check visibleWidth(esc & "[38;2;1;2;3mabc" & esc & "[0m") == 3
65+ check lines.allIt(visibleWidth(it) == 16)
66+
67+ test "art lines survive a balloon verbatim":
68+ let
69+ b = makeBalloon(art, width = 40)
70+ body = b.splitLines[1 .. ^2]
71+ # every row is padded to one width, so the right border lines up
72+ let borders = body.mapIt(it.filterIt(it in {'|', '/', '\\'}).len)
73+ check borders.deduplicate.len == 1
74+ check body.len == lines.len
75+ for i, l in lines:
76+ check l in body[i]
77+
78+ test "indentation is not collapsed":
79+ check wrapText(" indented", 40) == @[" indented"]
80+
81+suite "say":
82+ test "balloon + character for handle + message":
83+ let said = freeqsay("alice.test", "hello world", scale = 1,
84+ resolveOpts = ResolveOpts(httpGet: stubGet(didA)))
85+ check said.did == didA
86+ check said.text == "hello world"
87+ check "< hello world >" in said.output
88+ check "\\" in said.output
89+ check "\x1b[" in said.output
90+
91+ test "raw DID needs no network":
92+ check "< yo >" in freeqsay(didA, "yo", scale = 1).output
93+
94+suite "resolving":
95+ test "handle normalization":
96+ check normalizeHandle("@Jay.Bsky.Team") == "jay.bsky.team"
97+ check isDid(didA)
98+ check not isDid("jay.bsky.team")
99+
100+ test "DIDs pass through without a request":
101+ check resolveIdentity(didA).did == didA
102+
103+ test "handles resolve through the endpoint":
104+ var seen = ""
105+ let get = proc (url: string): HttpResponse {.gcsafe.} =
106+ {.cast(gcsafe).}: seen = url
107+ HttpResponse(status: 200, body: %*{"did": didA})
108+ check resolveHandle("@example.bsky.social", ResolveOpts(httpGet: get)) == didA
109+ check "handle=example.bsky.social" in seen
110+
111+ test "a dead transport says so instead of blaming the handle":
112+ let get = proc (url: string): HttpResponse {.gcsafe.} =
113+ HttpResponse(status: 0, error: "no TLS here")
114+ expect IOError:
115+ discard resolveHandle("alice.test", ResolveOpts(httpGet: get))
116+
117+suite "ansi":
118+ test "truecolor escapes":
119+ check "\x1b[" in didToAnsi(didA, scale = 1)
120+
121+ test "handle->ansi matches did->ansi after resolve":
122+ let id = resolveIdentity("alice.test", ResolveOpts(httpGet: stubGet(didA)))
123+ check didToAnsi(didA, scale = 1) == didToAnsi(id.did, scale = 1)
124+
125+suite "png":
126+ test "signature":
127+ let bs = didToPng(didA, scale = 2)
128+ check bs[0 .. 1] == @[137'u8, 80'u8]
129+ check bs[12 .. 15] == toBytes("IHDR")
130+ check bs[^4 .. ^1] == @[0xae'u8, 0x42, 0x60, 0x82] # IEND crc
new file mode 100644
@@ -0,0 +1,130 @@
1+import std/[json, os, sequtils, strutils, tables, unittest]
2+import ../src/freeqsay
3+
4+const didA = "did:plc:ewvi7nmzuoqusbcablsm7c4h"
5+
6+let fixtures = parseFile(currentSourcePath().parentDir / ".." /
7+ "fixtures" / "avatar-conformance.json")
8+
9+proc stubGet(did: string): HttpGet =
10+ ## Test hook standing in for the network: always answers with `did`.
11+ result = proc (url: string): HttpResponse {.gcsafe.} =
12+ HttpResponse(status: 200, body: %*{"did": did})
13+
14+suite "sha256":
15+ test "vectors":
16+ check toHex(digest("")) ==
17+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
18+ check toHex(digest("abc")) ==
19+ "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
20+ # RFC 4231 test case 1
21+ check toHex(hmac(repeat(0x0b'u8, 20), toBytes("Hi There"))) ==
22+ "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
23+
24+suite "avatar-v1 conformance":
25+ test "fixture replays":
26+ check fixtures.len > 0
27+ for f in fixtures:
28+ let
29+ did = f["did"].getStr
30+ av = deriveAvatar(did)
31+ checkpoint did
32+ check f["canonical_seed"].getStr == toHex(canonicalSeed(did))
33+ for key, want in f["expected_traits"]:
34+ check av.traits[key] == want.getStr
35+ check f["sprite_hash"].getStr == spriteHash(av)
36+
37+suite "balloons":
38+ test "wraps long lines":
39+ let lines = wrapText("one two three four five", 10)
40+ check lines.allIt(it.len <= 10)
41+ check "one" in lines.join(" ")
42+
43+ test "single-line speech balloon":
44+ let b = makeBalloon("hi")
45+ check "< hi" in b
46+ let top = b.splitLines[0]
47+ check top.startsWith(" ") and top[1 .. ^1].allIt(it == '_')
48+
49+ test "multi-line balloon":
50+ let b = makeBalloon("hello there friend", width = 6)
51+ check "/ " in b
52+ check "\\ " in b
53+
54+ test "thought bubble":
55+ check "( hmm )" in makeBalloon("hmm", think = true)
56+
57+suite "balloons preserve ANSI shape":
58+ let
59+ esc = "\x1b"
60+ art = didToAnsi(didA, scale = 1)
61+ lines = art.splitLines
62+
63+ test "escape sequences cost no columns":
64+ check visibleWidth(esc & "[38;2;1;2;3mabc" & esc & "[0m") == 3
65+ check lines.allIt(visibleWidth(it) == 16)
66+
67+ test "art lines survive a balloon verbatim":
68+ let
69+ b = makeBalloon(art, width = 40)
70+ body = b.splitLines[1 .. ^2]
71+ # every row is padded to one width, so the right border lines up
72+ let borders = body.mapIt(it.filterIt(it in {'|', '/', '\\'}).len)
73+ check borders.deduplicate.len == 1
74+ check body.len == lines.len
75+ for i, l in lines:
76+ check l in body[i]
77+
78+ test "indentation is not collapsed":
79+ check wrapText(" indented", 40) == @[" indented"]
80+
81+suite "say":
82+ test "balloon + character for handle + message":
83+ let said = freeqsay("alice.test", "hello world", scale = 1,
84+ resolveOpts = ResolveOpts(httpGet: stubGet(didA)))
85+ check said.did == didA
86+ check said.text == "hello world"
87+ check "< hello world >" in said.output
88+ check "\\" in said.output
89+ check "\x1b[" in said.output
90+
91+ test "raw DID needs no network":
92+ check "< yo >" in freeqsay(didA, "yo", scale = 1).output
93+
94+suite "resolving":
95+ test "handle normalization":
96+ check normalizeHandle("@Jay.Bsky.Team") == "jay.bsky.team"
97+ check isDid(didA)
98+ check not isDid("jay.bsky.team")
99+
100+ test "DIDs pass through without a request":
101+ check resolveIdentity(didA).did == didA
102+
103+ test "handles resolve through the endpoint":
104+ var seen = ""
105+ let get = proc (url: string): HttpResponse {.gcsafe.} =
106+ {.cast(gcsafe).}: seen = url
107+ HttpResponse(status: 200, body: %*{"did": didA})
108+ check resolveHandle("@example.bsky.social", ResolveOpts(httpGet: get)) == didA
109+ check "handle=example.bsky.social" in seen
110+
111+ test "a dead transport says so instead of blaming the handle":
112+ let get = proc (url: string): HttpResponse {.gcsafe.} =
113+ HttpResponse(status: 0, error: "no TLS here")
114+ expect IOError:
115+ discard resolveHandle("alice.test", ResolveOpts(httpGet: get))
116+
117+suite "ansi":
118+ test "truecolor escapes":
119+ check "\x1b[" in didToAnsi(didA, scale = 1)
120+
121+ test "handle->ansi matches did->ansi after resolve":
122+ let id = resolveIdentity("alice.test", ResolveOpts(httpGet: stubGet(didA)))
123+ check didToAnsi(didA, scale = 1) == didToAnsi(id.did, scale = 1)
124+
125+suite "png":
126+ test "signature":
127+ let bs = didToPng(didA, scale = 2)
128+ check bs[0 .. 1] == @[137'u8, 80'u8]
129+ check bs[12 .. 15] == toBytes("IHDR")
130+ check bs[^4 .. ^1] == @[0xae'u8, 0x42, 0x60, 0x82] # IEND crc