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>
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 | ||