A work-in-progress chat bot for Streamplace with chat overlay functionality
2
fork

Configure Feed

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

Initial messy commit

timconspicuous 208f9e14

+1963
+12
.gitignore
··· 1 + # dotenv environment variable files 2 + .env 3 + .env.development.local 4 + .env.test.local 5 + .env.production.local 6 + .env.local 7 + 8 + # Fresh build directory 9 + _fresh/ 10 + # npm + other dependencies 11 + node_modules/ 12 + vendor/
+42
deno.json
··· 1 + { 2 + "nodeModulesDir": "auto", 3 + "tasks": { 4 + "check": "deno fmt --check . && deno lint . && deno check", 5 + "dev": "deno run -A --watch=static/,routes/ dev.ts", 6 + "build": "deno run -A dev.ts build", 7 + "start": "deno serve -A _fresh/server.js", 8 + "update": "deno run -A -r jsr:@fresh/update ." 9 + }, 10 + "lint": { "rules": { "tags": ["fresh", "recommended"] } }, 11 + "exclude": ["**/_fresh/*"], 12 + "imports": { 13 + "@atcute/atproto": "npm:@atcute/atproto@^3.1.4", 14 + "@atcute/client": "npm:@atcute/client@^4.0.3", 15 + "@std/dotenv": "jsr:@std/dotenv@^0.225.5", 16 + "fresh": "jsr:@fresh/core@^2.1.1", 17 + "preact": "npm:preact@^10.27.2", 18 + "@preact/signals": "npm:@preact/signals@^2.3.1" 19 + }, 20 + "compilerOptions": { 21 + "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns"], 22 + "jsx": "precompile", 23 + "jsxImportSource": "preact", 24 + "jsxPrecompileSkipElements": [ 25 + "a", 26 + "img", 27 + "source", 28 + "body", 29 + "html", 30 + "head", 31 + "title", 32 + "meta", 33 + "script", 34 + "link", 35 + "style", 36 + "base", 37 + "noscript", 38 + "template" 39 + ] 40 + }, 41 + "fmt": { "useTabs": true, "indentWidth": 4 } 42 + }
+378
deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.0", 5 + "jsr:@deno/loader@~0.3.3": "0.3.5", 6 + "jsr:@fresh/build-id@1": "1.0.1", 7 + "jsr:@fresh/core@^2.1.1": "2.1.1", 8 + "jsr:@std/bytes@^1.0.6": "1.0.6", 9 + "jsr:@std/dotenv@~0.225.5": "0.225.5", 10 + "jsr:@std/encoding@1": "1.0.10", 11 + "jsr:@std/encoding@^1.0.10": "1.0.10", 12 + "jsr:@std/fmt@^1.0.8": "1.0.8", 13 + "jsr:@std/fs@1": "1.0.19", 14 + "jsr:@std/html@1": "1.0.4", 15 + "jsr:@std/http@^1.0.15": "1.0.20", 16 + "jsr:@std/internal@^1.0.10": "1.0.10", 17 + "jsr:@std/json@^1.0.2": "1.0.2", 18 + "jsr:@std/jsonc@1": "1.0.2", 19 + "jsr:@std/media-types@1": "1.1.0", 20 + "jsr:@std/path@1": "1.1.2", 21 + "jsr:@std/path@^1.1.1": "1.1.2", 22 + "jsr:@std/semver@1": "1.0.5", 23 + "jsr:@std/uuid@^1.0.7": "1.0.9", 24 + "npm:@atcute/atproto@^3.1.4": "3.1.4", 25 + "npm:@atcute/client@^4.0.3": "4.0.3", 26 + "npm:@opentelemetry/api@^1.9.0": "1.9.0", 27 + "npm:@preact/signals@^2.2.1": "2.3.1_preact@10.27.2", 28 + "npm:@preact/signals@^2.3.1": "2.3.1_preact@10.27.2", 29 + "npm:@types/node@*": "22.15.15", 30 + "npm:esbuild-wasm@0.25.7": "0.25.7", 31 + "npm:esbuild@0.25.7": "0.25.7", 32 + "npm:esbuild@~0.25.5": "0.25.7", 33 + "npm:preact-render-to-string@^6.5.11": "6.6.1_preact@10.27.2", 34 + "npm:preact@^10.27.0": "10.27.2", 35 + "npm:preact@^10.27.2": "10.27.2" 36 + }, 37 + "jsr": { 38 + "@deno/esbuild-plugin@1.2.0": { 39 + "integrity": "04ddd0fca9416d8a2866263928a53b9d5ed08dfca064d64504a0aaf9800c709e", 40 + "dependencies": [ 41 + "jsr:@deno/loader", 42 + "jsr:@std/path@^1.1.1", 43 + "npm:esbuild@~0.25.5" 44 + ] 45 + }, 46 + "@deno/loader@0.3.5": { 47 + "integrity": "72f6ce9c6e7242c6e070705dbd8a838884dd236d5dd0bd907d08bece92db5722" 48 + }, 49 + "@fresh/build-id@1.0.1": { 50 + "integrity": "12a2ec25fd52ae9ec68c26848a5696cd1c9b537f7c983c7e56e4fb1e7e816c20", 51 + "dependencies": [ 52 + "jsr:@std/encoding@^1.0.10" 53 + ] 54 + }, 55 + "@fresh/core@2.1.1": { 56 + "integrity": "8ec49ee86c54947faf965d67e206383c79333bee2b7e891594e87bbd31b16d52", 57 + "dependencies": [ 58 + "jsr:@deno/esbuild-plugin", 59 + "jsr:@fresh/build-id", 60 + "jsr:@std/encoding@1", 61 + "jsr:@std/fmt", 62 + "jsr:@std/fs", 63 + "jsr:@std/html", 64 + "jsr:@std/http", 65 + "jsr:@std/jsonc", 66 + "jsr:@std/media-types", 67 + "jsr:@std/path@1", 68 + "jsr:@std/semver", 69 + "jsr:@std/uuid", 70 + "npm:@opentelemetry/api", 71 + "npm:@preact/signals@^2.2.1", 72 + "npm:esbuild-wasm", 73 + "npm:esbuild@0.25.7", 74 + "npm:preact-render-to-string", 75 + "npm:preact@^10.27.0" 76 + ] 77 + }, 78 + "@std/bytes@1.0.6": { 79 + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" 80 + }, 81 + "@std/dotenv@0.225.5": { 82 + "integrity": "9ce6f9d0ec3311f74a32535aa1b8c62ed88b1ab91b7f0815797d77a6f60c922f" 83 + }, 84 + "@std/encoding@1.0.10": { 85 + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 86 + }, 87 + "@std/fmt@1.0.8": { 88 + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" 89 + }, 90 + "@std/fs@1.0.19": { 91 + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", 92 + "dependencies": [ 93 + "jsr:@std/path@^1.1.1" 94 + ] 95 + }, 96 + "@std/html@1.0.4": { 97 + "integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e" 98 + }, 99 + "@std/http@1.0.20": { 100 + "integrity": "b5cc33fc001bccce65ed4c51815668c9891c69ccd908295997e983d8f56070a1", 101 + "dependencies": [ 102 + "jsr:@std/encoding@^1.0.10" 103 + ] 104 + }, 105 + "@std/internal@1.0.10": { 106 + "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" 107 + }, 108 + "@std/json@1.0.2": { 109 + "integrity": "d9e5497801c15fb679f55a2c01c7794ad7a5dfda4dd1bebab5e409cb5e0d34d4" 110 + }, 111 + "@std/jsonc@1.0.2": { 112 + "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7", 113 + "dependencies": [ 114 + "jsr:@std/json" 115 + ] 116 + }, 117 + "@std/media-types@1.1.0": { 118 + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" 119 + }, 120 + "@std/path@1.1.2": { 121 + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", 122 + "dependencies": [ 123 + "jsr:@std/internal" 124 + ] 125 + }, 126 + "@std/semver@1.0.5": { 127 + "integrity": "529f79e83705714c105ad0ba55bec0f9da0f24d2f726b6cc1c15e505cc2c0624" 128 + }, 129 + "@std/uuid@1.0.9": { 130 + "integrity": "44b627bf2d372fe1bd099e2ad41b2be41a777fc94e62a3151006895a037f1642", 131 + "dependencies": [ 132 + "jsr:@std/bytes" 133 + ] 134 + } 135 + }, 136 + "npm": { 137 + "@atcute/atproto@3.1.4": { 138 + "integrity": "sha512-v0/ue7mZYtjYw4vWbtda51bLwW88mqsUQB8F/UZNO18ANAQWmKq1HDceVqjvruaLe2QPqE43XM3WkEyZ2FhOrA==", 139 + "dependencies": [ 140 + "@atcute/lexicons" 141 + ] 142 + }, 143 + "@atcute/client@4.0.3": { 144 + "integrity": "sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==", 145 + "dependencies": [ 146 + "@atcute/identity", 147 + "@atcute/lexicons" 148 + ] 149 + }, 150 + "@atcute/identity@1.1.0": { 151 + "integrity": "sha512-6vRvRqJatDB+JUQsb+UswYmtBGQnSZcqC3a2y6H5DB/v5KcIh+6nFFtc17G0+3W9rxdk7k9M4KkgkdKf/YDNoQ==", 152 + "dependencies": [ 153 + "@atcute/lexicons", 154 + "@badrap/valita" 155 + ] 156 + }, 157 + "@atcute/lexicons@1.1.1": { 158 + "integrity": "sha512-k6qy5p3j9fJJ6ekaMPfEfp3ni4TW/XNuH9ZmsuwC0fi0tOjp+Fa8ZQakHwnqOzFt/cVBfGcmYE/lKNAbeTjgUg==", 159 + "dependencies": [ 160 + "esm-env" 161 + ] 162 + }, 163 + "@badrap/valita@0.4.6": { 164 + "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" 165 + }, 166 + "@esbuild/aix-ppc64@0.25.7": { 167 + "integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==", 168 + "os": ["aix"], 169 + "cpu": ["ppc64"] 170 + }, 171 + "@esbuild/android-arm64@0.25.7": { 172 + "integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==", 173 + "os": ["android"], 174 + "cpu": ["arm64"] 175 + }, 176 + "@esbuild/android-arm@0.25.7": { 177 + "integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==", 178 + "os": ["android"], 179 + "cpu": ["arm"] 180 + }, 181 + "@esbuild/android-x64@0.25.7": { 182 + "integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==", 183 + "os": ["android"], 184 + "cpu": ["x64"] 185 + }, 186 + "@esbuild/darwin-arm64@0.25.7": { 187 + "integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==", 188 + "os": ["darwin"], 189 + "cpu": ["arm64"] 190 + }, 191 + "@esbuild/darwin-x64@0.25.7": { 192 + "integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==", 193 + "os": ["darwin"], 194 + "cpu": ["x64"] 195 + }, 196 + "@esbuild/freebsd-arm64@0.25.7": { 197 + "integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==", 198 + "os": ["freebsd"], 199 + "cpu": ["arm64"] 200 + }, 201 + "@esbuild/freebsd-x64@0.25.7": { 202 + "integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==", 203 + "os": ["freebsd"], 204 + "cpu": ["x64"] 205 + }, 206 + "@esbuild/linux-arm64@0.25.7": { 207 + "integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==", 208 + "os": ["linux"], 209 + "cpu": ["arm64"] 210 + }, 211 + "@esbuild/linux-arm@0.25.7": { 212 + "integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==", 213 + "os": ["linux"], 214 + "cpu": ["arm"] 215 + }, 216 + "@esbuild/linux-ia32@0.25.7": { 217 + "integrity": "sha512-9Jex4uVpdeofiDxnwHRgen+j6398JlX4/6SCbbEFEXN7oMO2p0ueLN+e+9DdsdPLUdqns607HmzEFnxwr7+5wQ==", 218 + "os": ["linux"], 219 + "cpu": ["ia32"] 220 + }, 221 + "@esbuild/linux-loong64@0.25.7": { 222 + "integrity": "sha512-TG1KJqjBlN9IHQjKVUYDB0/mUGgokfhhatlay8aZ/MSORMubEvj/J1CL8YGY4EBcln4z7rKFbsH+HeAv0d471w==", 223 + "os": ["linux"], 224 + "cpu": ["loong64"] 225 + }, 226 + "@esbuild/linux-mips64el@0.25.7": { 227 + "integrity": "sha512-Ty9Hj/lx7ikTnhOfaP7ipEm/ICcBv94i/6/WDg0OZ3BPBHhChsUbQancoWYSO0WNkEiSW5Do4febTTy4x1qYQQ==", 228 + "os": ["linux"], 229 + "cpu": ["mips64el"] 230 + }, 231 + "@esbuild/linux-ppc64@0.25.7": { 232 + "integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==", 233 + "os": ["linux"], 234 + "cpu": ["ppc64"] 235 + }, 236 + "@esbuild/linux-riscv64@0.25.7": { 237 + "integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==", 238 + "os": ["linux"], 239 + "cpu": ["riscv64"] 240 + }, 241 + "@esbuild/linux-s390x@0.25.7": { 242 + "integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==", 243 + "os": ["linux"], 244 + "cpu": ["s390x"] 245 + }, 246 + "@esbuild/linux-x64@0.25.7": { 247 + "integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==", 248 + "os": ["linux"], 249 + "cpu": ["x64"] 250 + }, 251 + "@esbuild/netbsd-arm64@0.25.7": { 252 + "integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==", 253 + "os": ["netbsd"], 254 + "cpu": ["arm64"] 255 + }, 256 + "@esbuild/netbsd-x64@0.25.7": { 257 + "integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==", 258 + "os": ["netbsd"], 259 + "cpu": ["x64"] 260 + }, 261 + "@esbuild/openbsd-arm64@0.25.7": { 262 + "integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==", 263 + "os": ["openbsd"], 264 + "cpu": ["arm64"] 265 + }, 266 + "@esbuild/openbsd-x64@0.25.7": { 267 + "integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==", 268 + "os": ["openbsd"], 269 + "cpu": ["x64"] 270 + }, 271 + "@esbuild/openharmony-arm64@0.25.7": { 272 + "integrity": "sha512-5VTtExUrWwHHEUZ/N+rPlHDwVFQ5aME7vRJES8+iQ0xC/bMYckfJ0l2n3yGIfRoXcK/wq4oXSItZAz5wslTKGw==", 273 + "os": ["openharmony"], 274 + "cpu": ["arm64"] 275 + }, 276 + "@esbuild/sunos-x64@0.25.7": { 277 + "integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==", 278 + "os": ["sunos"], 279 + "cpu": ["x64"] 280 + }, 281 + "@esbuild/win32-arm64@0.25.7": { 282 + "integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==", 283 + "os": ["win32"], 284 + "cpu": ["arm64"] 285 + }, 286 + "@esbuild/win32-ia32@0.25.7": { 287 + "integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==", 288 + "os": ["win32"], 289 + "cpu": ["ia32"] 290 + }, 291 + "@esbuild/win32-x64@0.25.7": { 292 + "integrity": "sha512-FaPsAHTwm+1Gfvn37Eg3E5HIpfR3i6x1AIcla/MkqAIupD4BW3MrSeUqfoTzwwJhk3WE2/KqUn4/eenEJC76VA==", 293 + "os": ["win32"], 294 + "cpu": ["x64"] 295 + }, 296 + "@opentelemetry/api@1.9.0": { 297 + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" 298 + }, 299 + "@preact/signals-core@1.12.1": { 300 + "integrity": "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==" 301 + }, 302 + "@preact/signals@2.3.1_preact@10.27.2": { 303 + "integrity": "sha512-nyuRIGmcwM/HjvFHhN2xUWfyla9D4llHt+prWoxjQfD6b5prO7CFPlG/xjJkP31Oic4KQXfH9SIhJFP9cy4lmg==", 304 + "dependencies": [ 305 + "@preact/signals-core", 306 + "preact" 307 + ] 308 + }, 309 + "@types/node@22.15.15": { 310 + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", 311 + "dependencies": [ 312 + "undici-types" 313 + ] 314 + }, 315 + "esbuild-wasm@0.25.7": { 316 + "integrity": "sha512-x3t1BlU59YOMtpwzayHxF4LPVujOvNKqm7y6jPvFKC13J8FmJRCdHPJwHq86er7ik+f7uwGcMbe+6KVzLGmsGw==", 317 + "bin": true 318 + }, 319 + "esbuild@0.25.7": { 320 + "integrity": "sha512-daJB0q2dmTzo90L9NjRaohhRWrCzYxWNFTjEi72/h+p5DcY3yn4MacWfDakHmaBaDzDiuLJsCh0+6LK/iX+c+Q==", 321 + "optionalDependencies": [ 322 + "@esbuild/aix-ppc64", 323 + "@esbuild/android-arm", 324 + "@esbuild/android-arm64", 325 + "@esbuild/android-x64", 326 + "@esbuild/darwin-arm64", 327 + "@esbuild/darwin-x64", 328 + "@esbuild/freebsd-arm64", 329 + "@esbuild/freebsd-x64", 330 + "@esbuild/linux-arm", 331 + "@esbuild/linux-arm64", 332 + "@esbuild/linux-ia32", 333 + "@esbuild/linux-loong64", 334 + "@esbuild/linux-mips64el", 335 + "@esbuild/linux-ppc64", 336 + "@esbuild/linux-riscv64", 337 + "@esbuild/linux-s390x", 338 + "@esbuild/linux-x64", 339 + "@esbuild/netbsd-arm64", 340 + "@esbuild/netbsd-x64", 341 + "@esbuild/openbsd-arm64", 342 + "@esbuild/openbsd-x64", 343 + "@esbuild/openharmony-arm64", 344 + "@esbuild/sunos-x64", 345 + "@esbuild/win32-arm64", 346 + "@esbuild/win32-ia32", 347 + "@esbuild/win32-x64" 348 + ], 349 + "scripts": true, 350 + "bin": true 351 + }, 352 + "esm-env@1.2.2": { 353 + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" 354 + }, 355 + "preact-render-to-string@6.6.1_preact@10.27.2": { 356 + "integrity": "sha512-IIMfXRjmbSP9QmG18WJLQa4Z4yx3J0VC9QN5q9z2XYlWSzFlJ+bSm/AyLyyV/YFwjof1OXFX2Mz6Ao60LXudJg==", 357 + "dependencies": [ 358 + "preact" 359 + ] 360 + }, 361 + "preact@10.27.2": { 362 + "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==" 363 + }, 364 + "undici-types@6.21.0": { 365 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" 366 + } 367 + }, 368 + "workspace": { 369 + "dependencies": [ 370 + "jsr:@fresh/core@^2.1.1", 371 + "jsr:@std/dotenv@~0.225.5", 372 + "npm:@atcute/atproto@^3.1.4", 373 + "npm:@atcute/client@^4.0.3", 374 + "npm:@preact/signals@^2.3.1", 375 + "npm:preact@^10.27.2" 376 + ] 377 + } 378 + }
+11
dev.ts
··· 1 + #!/usr/bin/env -S deno run -A --watch=static/,routes/ 2 + 3 + import { Builder } from "fresh/dev"; 4 + 5 + const builder = new Builder(); 6 + 7 + if (Deno.args.includes("build")) { 8 + await builder.build(); 9 + } else { 10 + await builder.listen(() => import("./main.ts")); 11 + }
+8
env.ts
··· 1 + import { load } from "@std/dotenv"; 2 + 3 + await load({ export: true }); 4 + 5 + export const PDS_HOST_URL = Deno.env.get("PDS_HOST_URL"); 6 + export const ATPROTO_USERNAME = Deno.env.get("ATPROTO_USERNAME"); 7 + export const ATPROTO_PASSWORD = Deno.env.get("ATPROTO_PASSWORD"); 8 + export const JETSTREAM_URL = Deno.env.get("JETSTREAM_URL");
+112
islands/ChatOverlay.tsx
··· 1 + import { useEffect, useRef, useState } from "preact/hooks"; 2 + 3 + interface MessageLabel { 4 + type: string; 5 + value: string; 6 + } 7 + 8 + interface ChatOverlayProps { 9 + wsUrl: string; 10 + streamerDid: string; 11 + maxMessages?: number; 12 + } 13 + 14 + export function ChatOverlay({ 15 + wsUrl, 16 + streamerDid, 17 + maxMessages = 50, 18 + }: ChatOverlayProps) { 19 + const [messages, setMessages] = useState<EnrichedChatMessage[]>([]); 20 + const [connected, setConnected] = useState(false); 21 + const wsRef = useRef<WebSocket | null>(null); 22 + const messagesEndRef = useRef<HTMLDivElement>(null); 23 + 24 + useEffect(() => { 25 + const ws = new WebSocket(wsUrl); 26 + wsRef.current = ws; 27 + 28 + ws.onopen = () => { 29 + console.log("Connected to WebSocket"); 30 + setConnected(true); 31 + 32 + // Subscribe to streamer 33 + ws.send(JSON.stringify({ 34 + type: "subscribe", 35 + streamer: streamerDid, 36 + })); 37 + }; 38 + 39 + ws.onmessage = (event) => { 40 + try { 41 + const data = JSON.parse(event.data); 42 + 43 + if (data.type === "chat_message") { 44 + const message: EnrichedChatMessage = { 45 + ...data.data, 46 + timestamp: new Date(data.data.timestamp), 47 + }; 48 + 49 + setMessages((prev) => { 50 + const newMessages = [...prev, message]; 51 + // Keep only last N messages 52 + return newMessages.slice(-maxMessages); 53 + }); 54 + } 55 + } catch (error) { 56 + console.error("Error parsing message:", error); 57 + } 58 + }; 59 + 60 + ws.onclose = () => { 61 + console.log("Disconnected from WebSocket"); 62 + setConnected(false); 63 + }; 64 + 65 + ws.onerror = (error) => { 66 + console.error("WebSocket error:", error); 67 + }; 68 + 69 + return () => { 70 + if (ws.readyState === WebSocket.OPEN) { 71 + ws.send(JSON.stringify({ 72 + type: "unsubscribe", 73 + streamer: streamerDid, 74 + })); 75 + } 76 + ws.close(); 77 + }; 78 + }, [wsUrl, streamerDid]); 79 + 80 + // Auto-scroll to bottom on new messages 81 + useEffect(() => { 82 + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); 83 + }, [messages]); 84 + 85 + return ( 86 + <div class="chat-overlay"> 87 + <div class="chat-status"> 88 + {connected ? "🟢 Connected" : "🔴 Disconnected"} 89 + </div> 90 + <div class="chat-messages"> 91 + {messages.map((msg) => ( 92 + <div key={msg.id} class="chat-message"> 93 + <span 94 + class="chat-author" 95 + style={{ 96 + color: 97 + `rgb(${msg.color.red}, ${msg.color.green}, ${msg.color.blue})`, 98 + }} 99 + > 100 + {msg.author.displayName || msg.author.handle}: 101 + </span> 102 + <span class="chat-text">{msg.text}</span> 103 + {msg.isReply && ( 104 + <span class="chat-reply-indicator">↩️</span> 105 + )} 106 + </div> 107 + ))} 108 + <div ref={messagesEndRef} /> 109 + </div> 110 + </div> 111 + ); 112 + }
+88
main.ts
··· 1 + import { App, staticFiles } from "fresh"; 2 + import { type State } from "./utils.ts"; 3 + import { StreamplaceWebSocketService } from "./utils/websocket.ts"; 4 + 5 + export const app = new App<State>(); 6 + 7 + app.use(staticFiles()); 8 + 9 + // Start WebSocket service 10 + // const wsService = new StreamplaceWebSocketService(8080); 11 + // wsService.start(); 12 + // console.log("WebSocket service started on port 8080"); 13 + 14 + const clients = new Map<WebSocket, Set<string>>(); 15 + const streamerClients = new Map<string, Set<WebSocket>>(); 16 + let jetstreamWs: WebSocket | null = null; 17 + 18 + function connectToJetstream() { 19 + const jetstreamUrl = "wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=place.stream.chat.message"; 20 + jetstreamWs = new WebSocket(jetstreamUrl); 21 + 22 + jetstreamWs.onopen = () => { 23 + console.log("Connected to Jetstream"); 24 + }; 25 + 26 + jetstreamWs.onmessage = (event) => { 27 + try { 28 + const jetstreamMessage = JSON.parse(event.data); 29 + 30 + if ( 31 + jetstreamMessage.kind === "commit" && 32 + jetstreamMessage.commit?.operation === "create" && 33 + jetstreamMessage.commit.record 34 + ) { 35 + console.log(jetstreamMessage) 36 + const record = jetstreamMessage.commit.record; 37 + const enrichedMessage = { 38 + id: jetstreamMessage.commit.cid, 39 + text: record.text, 40 + author: { 41 + did: jetstreamMessage.did, 42 + handle: jetstreamMessage.did, 43 + }, 44 + timestamp: new Date(record.createdAt), 45 + color: { red: 128, green: 128, blue: 128 }, 46 + facets: record.facets, 47 + isReply: !!record.reply, 48 + streamer: record.streamer, 49 + }; 50 + 51 + const streamer = record.streamer; 52 + const subscribedClients = streamerClients.get(streamer); 53 + 54 + if (subscribedClients && subscribedClients.size > 0) { 55 + const messageJson = JSON.stringify({ 56 + type: "chat_message", 57 + data: enrichedMessage, 58 + }); 59 + 60 + for (const client of subscribedClients) { 61 + if (client.readyState === WebSocket.OPEN) { 62 + client.send(messageJson); 63 + } 64 + } 65 + 66 + console.log( 67 + `Sent message to ${subscribedClients.size} clients for streamer ${streamer}`, 68 + ); 69 + } 70 + } 71 + } catch (error) { 72 + console.error("Error processing jetstream message:", error); 73 + } 74 + }; 75 + 76 + jetstreamWs.onclose = () => { 77 + console.log("Jetstream connection closed, attempting to reconnect..."); 78 + setTimeout(() => connectToJetstream(), 5000); 79 + }; 80 + } 81 + 82 + connectToJetstream(); 83 + 84 + // Export these so the ws route can access them 85 + export { clients, streamerClients }; 86 + 87 + // Include file-system based routes here 88 + app.fsRoutes();
+17
routes/[streamerDid].tsx
··· 1 + import { PageProps } from "fresh"; 2 + import { ChatOverlay } from "../islands/ChatOverlay.tsx"; 3 + 4 + export default function ChatPage(props: PageProps) { 5 + const streamerDid = props.params.streamerDid; 6 + 7 + return ( 8 + <div> 9 + <h1>Chat Overlay</h1> 10 + <ChatOverlay 11 + wsUrl="ws://localhost:8000/ws" 12 + streamerDid={streamerDid} 13 + maxMessages={50} 14 + /> 15 + </div> 16 + ); 17 + }
+20
routes/_app.tsx
··· 1 + import { define } from "../utils.ts"; 2 + 3 + export default define.page(function App({ Component, state }) { 4 + return ( 5 + <html> 6 + <head> 7 + <meta charset="utf-8" /> 8 + <meta 9 + name="viewport" 10 + content="width=device-width, initial-scale=1.0" 11 + /> 12 + <title>{state.title ?? "streamplace"}</title> 13 + <link rel="stylesheet" href="/styles.css" /> 14 + </head> 15 + <body> 16 + <Component /> 17 + </body> 18 + </html> 19 + ); 20 + });
+9
routes/index.tsx
··· 1 + import { define } from "../utils.ts"; 2 + 3 + export default define.page(function Home(ctx) { 4 + return ( 5 + <div> 6 + Hello World 7 + </div> 8 + ); 9 + });
+84
routes/ws.ts
··· 1 + import { define } from "../utils.ts"; 2 + import { clients, streamerClients } from "../main.ts"; 3 + 4 + export const handler = define.handlers({ 5 + GET(ctx) { 6 + const { socket, response } = Deno.upgradeWebSocket(ctx.req); 7 + 8 + socket.onopen = () => { 9 + console.log("New client connected"); 10 + clients.set(socket, new Set()); 11 + }; 12 + 13 + socket.onmessage = (event) => { 14 + try { 15 + const message = JSON.parse(event.data); 16 + const clientStreamers = clients.get(socket); 17 + if (!clientStreamers) return; 18 + 19 + switch (message.type) { 20 + case "subscribe": { 21 + clientStreamers.add(message.streamer); 22 + if (!streamerClients.has(message.streamer)) { 23 + streamerClients.set(message.streamer, new Set()); 24 + } 25 + streamerClients.get(message.streamer)!.add(socket); 26 + console.log( 27 + `Client subscribed to streamer: ${message.streamer}`, 28 + ); 29 + socket.send(JSON.stringify({ 30 + type: "subscribed", 31 + streamer: message.streamer, 32 + })); 33 + break; 34 + } 35 + 36 + case "unsubscribe": { 37 + clientStreamers.delete(message.streamer); 38 + const streamerClientSet = streamerClients.get( 39 + message.streamer, 40 + ); 41 + if (streamerClientSet) { 42 + streamerClientSet.delete(socket); 43 + if (streamerClientSet.size === 0) { 44 + streamerClients.delete(message.streamer); 45 + } 46 + } 47 + console.log( 48 + `Client unsubscribed from streamer: ${message.streamer}`, 49 + ); 50 + socket.send(JSON.stringify({ 51 + type: "unsubscribed", 52 + streamer: message.streamer, 53 + })); 54 + break; 55 + } 56 + } 57 + } catch (error) { 58 + console.error("Error handling client message:", error); 59 + socket.send( 60 + JSON.stringify({ error: "Invalid message format" }), 61 + ); 62 + } 63 + }; 64 + 65 + socket.onclose = () => { 66 + console.log("Client disconnected"); 67 + const clientStreamers = clients.get(socket); 68 + if (clientStreamers) { 69 + for (const streamer of clientStreamers) { 70 + const streamerClientSet = streamerClients.get(streamer); 71 + if (streamerClientSet) { 72 + streamerClientSet.delete(socket); 73 + if (streamerClientSet.size === 0) { 74 + streamerClients.delete(streamer); 75 + } 76 + } 77 + } 78 + } 79 + clients.delete(socket); 80 + }; 81 + 82 + return response; 83 + }, 84 + });
+78
static/chat-overlay.css
··· 1 + * { 2 + margin: 0; 3 + padding: 0; 4 + box-sizing: border-box; 5 + } 6 + 7 + body { 8 + font-family: 9 + system-ui, 10 + -apple-system, 11 + sans-serif; 12 + background: transparent; 13 + } 14 + 15 + .chat-overlay { 16 + width: 400px; 17 + height: 600px; 18 + display: flex; 19 + flex-direction: column; 20 + background: rgba(0, 0, 0, 0.7); 21 + color: white; 22 + padding: 10px; 23 + } 24 + 25 + .chat-status { 26 + font-size: 12px; 27 + padding: 5px; 28 + margin-bottom: 10px; 29 + background: rgba(0, 0, 0, 0.5); 30 + border-radius: 4px; 31 + } 32 + 33 + .chat-messages { 34 + flex: 1; 35 + overflow-y: auto; 36 + display: flex; 37 + flex-direction: column; 38 + gap: 8px; 39 + } 40 + 41 + .chat-message { 42 + padding: 4px 8px; 43 + background: rgba(0, 0, 0, 0.3); 44 + border-radius: 4px; 45 + word-wrap: break-word; 46 + } 47 + 48 + .chat-author { 49 + font-weight: bold; 50 + margin-right: 6px; 51 + } 52 + 53 + .chat-text { 54 + color: white; 55 + } 56 + 57 + .chat-reply-indicator { 58 + margin-left: 6px; 59 + font-size: 12px; 60 + } 61 + 62 + /* Scrollbar styling */ 63 + .chat-messages::-webkit-scrollbar { 64 + width: 6px; 65 + } 66 + 67 + .chat-messages::-webkit-scrollbar-track { 68 + background: rgba(0, 0, 0, 0.2); 69 + } 70 + 71 + .chat-messages::-webkit-scrollbar-thumb { 72 + background: rgba(255, 255, 255, 0.3); 73 + border-radius: 3px; 74 + } 75 + 76 + .chat-messages::-webkit-scrollbar-thumb:hover { 77 + background: rgba(255, 255, 255, 0.5); 78 + }
static/favicon.ico

This is a binary file and will not be displayed.

+19
static/logo.svg
··· 1 + <svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path 3 + d="M34.092 8.845C38.929 20.652 34.092 27 30 30.5c1 3.5-2.986 4.222-4.5 2.5-4.457 1.537-13.512 1.487-20-5C2 24.5 4.73 16.714 14 11.5c8-4.5 16-7 20.092-2.655Z" 4 + fill="#FFDB1E" 5 + /> 6 + <path 7 + d="M14 11.5c6.848-4.497 15.025-6.38 18.368-3.47C37.5 12.5 21.5 22.612 15.5 25c-6.5 2.587-3 8.5-6.5 8.5-3 0-2.5-4-5.183-7.75C2.232 23.535 6.16 16.648 14 11.5Z" 8 + fill="#fff" 9 + stroke="#FFDB1E" 10 + /> 11 + <path 12 + d="M28.535 8.772c4.645 1.25-.365 5.695-4.303 8.536-3.732 2.692-6.606 4.21-7.923 4.83-.366.173-1.617-2.252-1.617-1 0 .417-.7 2.238-.934 2.326-1.365.512-4.223 1.29-5.835 1.29-3.491 0-1.923-4.754 3.014-9.122.892-.789 1.478-.645 2.283-.645-.537-.773-.534-.917.403-1.546C17.79 10.64 23 8.77 25.212 8.42c.366.014.82.35.82.629.41-.14 2.095-.388 2.503-.278Z" 13 + fill="#FFE600" 14 + /> 15 + <path 16 + d="M14.297 16.49c.985-.747 1.644-1.01 2.099-2.526.566.121.841-.08 1.29-.701.324.466 1.657.608 2.453.701-.715.451-1.057.852-1.452 2.106-1.464-.611-3.167-.302-4.39.42Z" 17 + fill="#fff" 18 + /> 19 + </svg>
+178
static/styles.css
··· 1 + @import "chat-overlay.css"; 2 + 3 + *, 4 + *::before, 5 + *::after { 6 + box-sizing: border-box; 7 + } 8 + 9 + * { 10 + margin: 0; 11 + } 12 + 13 + button { 14 + color: inherit; 15 + } 16 + 17 + button, 18 + [role="button"] { 19 + cursor: pointer; 20 + } 21 + 22 + code { 23 + font-family: 24 + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 25 + "Courier New", monospace; 26 + font-size: 1em; 27 + } 28 + 29 + img, 30 + svg { 31 + display: block; 32 + } 33 + 34 + img, 35 + video { 36 + max-width: 100%; 37 + height: auto; 38 + } 39 + 40 + html { 41 + line-height: 1.5; 42 + -webkit-text-size-adjust: 100%; 43 + font-family: 44 + ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", 45 + Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, 46 + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", 47 + "Noto Color Emoji"; 48 + } 49 + 50 + .transition-colors { 51 + transition-property: background-color, border-color, color, fill, stroke; 52 + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 53 + transition-duration: 150ms; 54 + } 55 + 56 + .my-6 { 57 + margin-bottom: 1.5rem; 58 + margin-top: 1.5rem; 59 + } 60 + 61 + .text-4xl { 62 + font-size: 2.25rem; 63 + line-height: 2.5rem; 64 + } 65 + 66 + .mx-2 { 67 + margin-left: 0.5rem; 68 + margin-right: 0.5rem; 69 + } 70 + 71 + .my-4 { 72 + margin-bottom: 1rem; 73 + margin-top: 1rem; 74 + } 75 + 76 + .mx-auto { 77 + margin-left: auto; 78 + margin-right: auto; 79 + } 80 + 81 + .px-4 { 82 + padding-left: 1rem; 83 + padding-right: 1rem; 84 + } 85 + 86 + .py-8 { 87 + padding-bottom: 2rem; 88 + padding-top: 2rem; 89 + } 90 + 91 + .bg-\[\#86efac\] { 92 + background-color: #86efac; 93 + } 94 + 95 + .text-3xl { 96 + font-size: 1.875rem; 97 + line-height: 2.25rem; 98 + } 99 + 100 + .py-6 { 101 + padding-bottom: 1.5rem; 102 + padding-top: 1.5rem; 103 + } 104 + 105 + .px-2 { 106 + padding-left: 0.5rem; 107 + padding-right: 0.5rem; 108 + } 109 + 110 + .py-1 { 111 + padding-bottom: 0.25rem; 112 + padding-top: 0.25rem; 113 + } 114 + 115 + .border-gray-500 { 116 + border-color: #6b7280; 117 + } 118 + 119 + .bg-white { 120 + background-color: #fff; 121 + } 122 + 123 + .flex { 124 + display: flex; 125 + } 126 + 127 + .gap-8 { 128 + grid-gap: 2rem; 129 + gap: 2rem; 130 + } 131 + 132 + .font-bold { 133 + font-weight: 700; 134 + } 135 + 136 + .max-w-screen-md { 137 + max-width: 768px; 138 + } 139 + 140 + .flex-col { 141 + flex-direction: column; 142 + } 143 + 144 + .items-center { 145 + align-items: center; 146 + } 147 + 148 + .justify-center { 149 + justify-content: center; 150 + } 151 + 152 + .border-2 { 153 + border-width: 2px; 154 + } 155 + 156 + .rounded-sm { 157 + border-radius: 0.25rem; 158 + } 159 + 160 + .hover\:bg-gray-200:hover { 161 + background-color: #e5e7eb; 162 + } 163 + 164 + .tabular-nums { 165 + font-variant-numeric: tabular-nums; 166 + } 167 + 168 + .min-h-screen { 169 + min-height: 100vh; 170 + } 171 + 172 + .fresh-gradient { 173 + background-color: rgb(134, 239, 172); 174 + background-image: linear-gradient(to right bottom, 175 + rgb(219, 234, 254), 176 + rgb(187, 247, 208), 177 + rgb(254, 249, 195)); 178 + }
+144
streamplaceBot.ts
··· 1 + import { createStreamplaceMessage } from "./utils/streamplaceUtils.ts"; 2 + 3 + export interface CommandHandler { 4 + (message: ChatMessage, args: string[]): Promise<void> | void; 5 + } 6 + 7 + export interface ChatMessage { 8 + username: string; 9 + text: string; 10 + color?: string; 11 + timestamp: string; 12 + did?: string; 13 + } 14 + 15 + export class StreamplaceBot { 16 + private streamerDid: string; 17 + private commandPrefix: string; 18 + private commands: Map<string, CommandHandler>; 19 + private enabled: boolean; 20 + private botDid: string | null = null; 21 + 22 + /** 23 + * Create a new StreamplaceBot 24 + * @param streamerDid The DID of the streamer whose chat to respond in 25 + * @param commandPrefix The prefix that triggers bot commands (default: "!") 26 + */ 27 + constructor(streamerDid: string, commandPrefix = "!") { 28 + this.streamerDid = streamerDid; 29 + this.commandPrefix = commandPrefix; 30 + this.commands = new Map(); 31 + this.enabled = true; 32 + 33 + // Register default commands 34 + this.registerDefaultCommands(); 35 + } 36 + 37 + /** 38 + * Process an incoming chat message and respond if it's a command 39 + * @param message The chat message to process 40 + */ 41 + async processMessage(message: ChatMessage): Promise<void> { 42 + if (!this.enabled) return; 43 + 44 + const text = message.text.trim(); 45 + 46 + // Check if message starts with command prefix 47 + if (!text.startsWith(this.commandPrefix)) return; 48 + 49 + // Extract command name and arguments 50 + const parts = text.slice(this.commandPrefix.length).split(/\s+/); 51 + const commandName = parts[0].toLowerCase(); 52 + const args = parts.slice(1); 53 + 54 + // Look up and execute command handler 55 + const handler = this.commands.get(commandName); 56 + if (handler) { 57 + console.log( 58 + `Executing command: ${commandName} from user: ${message.username}`, 59 + ); 60 + try { 61 + await handler(message, args); 62 + } catch (error) { 63 + console.error(`Error executing command ${commandName}:`, error); 64 + } 65 + } 66 + } 67 + 68 + /** 69 + * Register a new command 70 + * @param commandName Name of the command (without prefix) 71 + * @param handler Function to handle the command 72 + */ 73 + registerCommand(commandName: string, handler: CommandHandler): void { 74 + this.commands.set(commandName.toLowerCase(), handler); 75 + } 76 + 77 + /** 78 + * Remove a command 79 + * @param commandName Name of the command to remove 80 + */ 81 + unregisterCommand(commandName: string): boolean { 82 + return this.commands.delete(commandName.toLowerCase()); 83 + } 84 + 85 + /** 86 + * Enable or disable the bot 87 + */ 88 + setEnabled(enabled: boolean): void { 89 + this.enabled = enabled; 90 + } 91 + 92 + /** 93 + * Send a message to the chat 94 + * @param text Message text 95 + */ 96 + async sendMessage(text: string): Promise<void> { 97 + try { 98 + const result = await createStreamplaceMessage( 99 + text, 100 + this.streamerDid, 101 + ); 102 + // Store bot's DID if not already known (from the first message) 103 + if (!this.botDid && result?.uri) { 104 + const didMatch = result.uri.match(/at:\/\/(did:[^\/]+)/); 105 + if (didMatch) { 106 + this.botDid = didMatch[1]; 107 + console.log(`Bot DID identified as: ${this.botDid}`); 108 + } 109 + } 110 + console.log(`Bot sent message: ${text}`); 111 + } catch (error) { 112 + console.error("Error sending bot message:", error); 113 + } 114 + } 115 + 116 + /** 117 + * Get the bot's DID (if known) 118 + */ 119 + getBotDid(): string | null { 120 + return this.botDid; 121 + } 122 + 123 + /** 124 + * Register the default set of commands 125 + */ 126 + private registerDefaultCommands(): void { 127 + // Help command 128 + this.registerCommand("commands", async (_message, _args) => { 129 + const commandsList = Array.from(this.commands.keys()) 130 + .map((cmd) => `${this.commandPrefix}${cmd}`) 131 + .join(", "); 132 + 133 + await this.sendMessage(`Available commands: ${commandsList}`); 134 + }); 135 + 136 + // Hug command 137 + this.registerCommand("hug", async (message, args) => { 138 + const hugger = message.username; 139 + const huggee = args[0].replace(/^@+/, ""); 140 + 141 + await this.sendMessage(`@${hugger} gives @${huggee} a big hug!`); 142 + }); 143 + } 144 + }
+7
utils.ts
··· 1 + import { createDefine } from "fresh"; 2 + 3 + export interface State { 4 + title: string; 5 + } 6 + 7 + export const define = createDefine<State>();
+102
utils/globals.d.ts
··· 1 + // Raw chat message as received from stream.place WebSocket 2 + interface RawChatMessage { 3 + $type: "place.stream.chat.defs#messageView"; 4 + author: { 5 + did: string; 6 + handle: string; 7 + }; 8 + chatProfile: { 9 + $type: "place.stream.chat.profile"; 10 + color: { 11 + red: number; 12 + green: number; 13 + blue: number; 14 + }; 15 + }; 16 + cid: string; 17 + indexedAt: string; 18 + record: { 19 + $type: "place.stream.chat.message"; 20 + createdAt: string; 21 + facets?: Array<{ 22 + features: unknown[]; 23 + index: { 24 + byteStart: number; 25 + byteEnd: number; 26 + }; 27 + }>; 28 + streamer: string; 29 + text: string; 30 + reply?: { 31 + root: unknown; // AT Proto strongRef 32 + parent: unknown; // AT Proto strongRef 33 + }; 34 + }; 35 + uri: string; 36 + } 37 + 38 + // Raw chat message as received from Jetstream WebSocket 39 + interface JetstreamMessage { 40 + did: string; 41 + time_us: number; 42 + kind: "commit"; 43 + commit: { 44 + rev: string; 45 + operation: "create" | "delete" | "update"; 46 + collection: string; 47 + rkey: string; 48 + record?: { 49 + $type: "place.stream.chat.message"; 50 + createdAt: string; 51 + streamer: string; // This is a DID 52 + text: string; 53 + facets?: Array<{ 54 + features: unknown[]; 55 + index: { 56 + byteStart: number; 57 + byteEnd: number; 58 + }; 59 + }>; 60 + reply?: { 61 + root: unknown; 62 + parent: unknown; 63 + }; 64 + }; 65 + cid: string; 66 + }; 67 + } 68 + 69 + // Label for display (pronouns, roles, etc.) 70 + interface MessageLabel { 71 + text: string; 72 + icon?: string; // SVG string or icon identifier 73 + color?: string; // Hex color for label styling 74 + type?: "pronoun" | "role" | "badge" | "custom"; 75 + } 76 + 77 + // Enriched message for overlay display 78 + interface EnrichedChatMessage { 79 + id: string; 80 + text: string; 81 + author: { 82 + did: string; 83 + handle: string; 84 + displayName?: string; 85 + }; 86 + streamer: string; 87 + timestamp: Date; // Parsed createdAt 88 + color: { 89 + red: number; 90 + green: number; 91 + blue: number; 92 + }; 93 + labels?: MessageLabel[]; // Pronouns, roles, VIP status, etc. 94 + facets?: Array<{ 95 + features: unknown[]; 96 + index: { 97 + byteStart: number; 98 + byteEnd: number; 99 + }; 100 + }>; // Keep facets for mentions, links, etc. 101 + isReply?: boolean; // Simplified reply indicator 102 + }
+364
utils/labelUtils.ts
··· 1 + // Response types from label APIs 2 + interface Label { 3 + id: number; 4 + src: string; 5 + uri: string; 6 + val: string; 7 + neg: boolean; 8 + cts: string; 9 + sig: { $bytes: string }; 10 + ver: number; 11 + } 12 + 13 + interface LabelApiResponse { 14 + cursor?: string; 15 + labels: Label[]; 16 + } 17 + 18 + interface LabelDefinition { 19 + identifier: string; 20 + locales: Array<{ 21 + lang: string; 22 + name: string; 23 + description: string; 24 + }>; 25 + blurs: string; 26 + severity: string; 27 + adultOnly: boolean; 28 + defaultSetting: string; 29 + } 30 + 31 + interface LabelServiceDefinition { 32 + policies: { 33 + labelValues: string[]; 34 + labelValueDefinitions: LabelDefinition[]; 35 + }; 36 + } 37 + 38 + // Configuration for labeling service 39 + interface LabelingServiceConfig { 40 + apiBase: string; 41 + labelerDid: string; 42 + definitionsEndpoint: string; 43 + longCacheTtl?: number; // For users with labels (default 24 hours) 44 + shortCacheTtl?: number; // For users without labels (default 5 minutes) 45 + } 46 + 47 + export class GenericLabelingService { 48 + private labelDefinitions: Map<string, LabelDefinition> = new Map(); 49 + private cache: Map<string, MessageLabel[]> = new Map(); 50 + private cacheExpiry: Map<string, number> = new Map(); 51 + private readonly config: LabelingServiceConfig; 52 + private readonly longCacheTtl: number; // For users with labels 53 + private readonly shortCacheTtl: number; // For users without labels 54 + 55 + constructor(config: LabelingServiceConfig) { 56 + this.config = config; 57 + this.longCacheTtl = config.longCacheTtl ?? 24 * 60 * 60 * 1000; // Default 24 hours 58 + this.shortCacheTtl = config.shortCacheTtl ?? 5 * 60 * 1000; // Default 5 minutes 59 + this.loadLabelDefinitions(); 60 + } 61 + 62 + private async loadLabelDefinitions(): Promise<void> { 63 + try { 64 + const response = await fetch(this.config.definitionsEndpoint); 65 + 66 + if (!response.ok) { 67 + console.error("Failed to load label definitions"); 68 + return; 69 + } 70 + 71 + const data = await response.json() as { 72 + value: LabelServiceDefinition; 73 + }; 74 + const definitions = data.value.policies.labelValueDefinitions; 75 + 76 + for (const def of definitions) { 77 + this.labelDefinitions.set(def.identifier, def); 78 + } 79 + 80 + console.log( 81 + `Loaded ${definitions.length} label definitions from ${this.config.labelerDid}`, 82 + ); 83 + } catch (error) { 84 + console.error("Error loading label definitions:", error); 85 + } 86 + } 87 + 88 + async fetchLabelsForUsers( 89 + dids: string[], 90 + labelConverter?: ( 91 + labels: Label[], 92 + definitions: Map<string, LabelDefinition>, 93 + ) => MessageLabel[], 94 + ): Promise<Map<string, MessageLabel[]>> { 95 + const results = new Map<string, MessageLabel[]>(); 96 + const uncachedDids: string[] = []; 97 + 98 + // Separate cached from uncached 99 + for (const did of dids) { 100 + const cached = this.getCachedLabels(did); 101 + if (cached !== null) { 102 + results.set(did, cached); 103 + } else { 104 + uncachedDids.push(did); 105 + } 106 + } 107 + 108 + if (uncachedDids.length === 0) { 109 + return results; 110 + } 111 + 112 + try { 113 + // Batch request for uncached DIDs 114 + const uriPatterns = uncachedDids.join(","); 115 + const response = await fetch( 116 + `${this.config.apiBase}/com.atproto.label.queryLabels?uriPatterns=${ 117 + encodeURIComponent(uriPatterns) 118 + }`, 119 + ); 120 + 121 + if (!response.ok) { 122 + console.warn("Failed to batch fetch labels"); 123 + // Return empty arrays for uncached DIDs 124 + for (const did of uncachedDids) { 125 + results.set(did, []); 126 + } 127 + return results; 128 + } 129 + 130 + const data = await response.json() as LabelApiResponse; 131 + 132 + // Group labels by DID 133 + const labelsByDid = new Map<string, Label[]>(); 134 + for (const label of data.labels) { 135 + if (!labelsByDid.has(label.uri)) { 136 + labelsByDid.set(label.uri, []); 137 + } 138 + labelsByDid.get(label.uri)!.push(label); 139 + } 140 + 141 + // Convert and cache results 142 + for (const did of uncachedDids) { 143 + const rawLabels = labelsByDid.get(did) || []; 144 + const messageLabels = labelConverter 145 + ? labelConverter(rawLabels, this.labelDefinitions) 146 + : this.defaultLabelConverter(rawLabels); 147 + 148 + results.set(did, messageLabels); 149 + this.cache.set(did, messageLabels); 150 + 151 + // Use different cache TTL based on whether user has labels 152 + const cacheTtl = messageLabels.length > 0 153 + ? this.longCacheTtl 154 + : this.shortCacheTtl; 155 + this.cacheExpiry.set(did, Date.now() + cacheTtl); 156 + } 157 + } catch (error) { 158 + console.error("Error in batch label fetch:", error); 159 + // Return empty arrays for failed requests 160 + for (const did of uncachedDids) { 161 + results.set(did, []); 162 + } 163 + } 164 + 165 + return results; 166 + } 167 + 168 + // Convenience method for single user 169 + async fetchLabelsForUser( 170 + did: string, 171 + labelConverter?: ( 172 + labels: Label[], 173 + definitions: Map<string, LabelDefinition>, 174 + ) => MessageLabel[], 175 + ): Promise<MessageLabel[]> { 176 + const results = await this.fetchLabelsForUsers([did], labelConverter); 177 + return results.get(did) || []; 178 + } 179 + 180 + private getCachedLabels(did: string): MessageLabel[] | null { 181 + const expiry = this.cacheExpiry.get(did); 182 + if (!expiry || Date.now() > expiry) { 183 + // Cache expired or doesn't exist 184 + this.cache.delete(did); 185 + this.cacheExpiry.delete(did); 186 + return null; 187 + } 188 + return this.cache.get(did) || null; 189 + } 190 + 191 + private defaultLabelConverter(labels: Label[]): MessageLabel[] { 192 + const messageLabels: MessageLabel[] = []; 193 + 194 + for (const label of labels) { 195 + if (label.neg) continue; // Skip negative labels 196 + 197 + const definition = this.labelDefinitions.get(label.val); 198 + if (!definition) { 199 + // Fallback for unknown labels 200 + messageLabels.push({ 201 + text: label.val, 202 + type: "custom", 203 + color: "#6b7280", // Default gray 204 + }); 205 + continue; 206 + } 207 + 208 + const englishLocale = definition.locales.find((l) => 209 + l.lang === "en" 210 + ); 211 + if (!englishLocale) continue; 212 + 213 + messageLabels.push({ 214 + text: englishLocale.name, 215 + type: "custom", 216 + color: "#6b7280", // Default gray 217 + }); 218 + } 219 + 220 + return messageLabels; 221 + } 222 + 223 + // Force refresh specific users (useful for "user just set pronouns" scenario) 224 + forceRefreshUsers( 225 + dids: string[], 226 + labelConverter?: ( 227 + labels: Label[], 228 + definitions: Map<string, LabelDefinition>, 229 + ) => MessageLabel[], 230 + ): Promise<Map<string, MessageLabel[]>> { 231 + // Remove from cache first 232 + for (const did of dids) { 233 + this.cache.delete(did); 234 + this.cacheExpiry.delete(did); 235 + } 236 + 237 + // Fetch fresh data 238 + return this.fetchLabelsForUsers(dids, labelConverter); 239 + } 240 + 241 + // Clear expired cache entries periodically 242 + cleanupCache(): void { 243 + const now = Date.now(); 244 + for (const [did, expiry] of this.cacheExpiry.entries()) { 245 + if (now > expiry) { 246 + this.cache.delete(did); 247 + this.cacheExpiry.delete(did); 248 + } 249 + } 250 + } 251 + 252 + // Clear all cache (useful for new sessions) 253 + clearCache(): void { 254 + this.cache.clear(); 255 + this.cacheExpiry.clear(); 256 + } 257 + 258 + // Get cache statistics 259 + getCacheStats(): { size: number; hitRate?: number } { 260 + return { 261 + size: this.cache.size, 262 + // You could track hit rate if needed by adding counters 263 + }; 264 + } 265 + } 266 + 267 + // Pronoun-specific service wrapper 268 + export class PronounService { 269 + private labelingService: GenericLabelingService; 270 + 271 + constructor() { 272 + const config: LabelingServiceConfig = { 273 + apiBase: "https://api.pronouns.diy/xrpc", 274 + labelerDid: "did:plc:wkoofae5uytcm7bjncmev6n6", 275 + definitionsEndpoint: 276 + "https://pds.juli.ee/xrpc/com.atproto.repo.getRecord?repo=did:plc:wkoofae5uytcm7bjncmev6n6&collection=app.bsky.labeler.service&rkey=self", 277 + longCacheTtl: 24 * 60 * 60 * 1000, // 24 hours for users with pronouns 278 + shortCacheTtl: 5 * 60 * 1000, // 5 minutes for users without pronouns 279 + }; 280 + 281 + this.labelingService = new GenericLabelingService(config); 282 + } 283 + 284 + // Pronoun-specific label converter 285 + private pronounLabelConverter = ( 286 + labels: Label[], 287 + definitions: Map<string, LabelDefinition>, 288 + ): MessageLabel[] => { 289 + const messageLabels: MessageLabel[] = []; 290 + 291 + for (const label of labels) { 292 + if (label.neg) continue; // Skip negative labels 293 + 294 + const definition = definitions.get(label.val); 295 + if (!definition) { 296 + // Fallback for unknown pronoun labels 297 + messageLabels.push({ 298 + text: label.val, 299 + type: "pronoun", 300 + color: "#6b7280", // Gray for pronouns 301 + }); 302 + continue; 303 + } 304 + 305 + const englishLocale = definition.locales.find((l) => 306 + l.lang === "en" 307 + ); 308 + if (!englishLocale) continue; 309 + 310 + messageLabels.push({ 311 + text: englishLocale.name, 312 + type: "pronoun", 313 + color: "#6b7280", // Gray for pronouns 314 + }); 315 + } 316 + 317 + return messageLabels; 318 + }; 319 + 320 + fetchPronounsForUsers( 321 + dids: string[], 322 + ): Promise<Map<string, MessageLabel[]>> { 323 + return this.labelingService.fetchLabelsForUsers( 324 + dids, 325 + this.pronounLabelConverter, 326 + ); 327 + } 328 + 329 + fetchPronounsForUser(did: string): Promise<MessageLabel[]> { 330 + return this.labelingService.fetchLabelsForUser( 331 + did, 332 + this.pronounLabelConverter, 333 + ); 334 + } 335 + 336 + forceRefreshPronounsForUsers( 337 + dids: string[], 338 + ): Promise<Map<string, MessageLabel[]>> { 339 + return this.labelingService.forceRefreshUsers( 340 + dids, 341 + this.pronounLabelConverter, 342 + ); 343 + } 344 + 345 + // Expose cache management methods 346 + cleanupCache(): void { 347 + this.labelingService.cleanupCache(); 348 + } 349 + 350 + clearCache(): void { 351 + this.labelingService.clearCache(); 352 + } 353 + 354 + getCacheStats(): { size: number; hitRate?: number } { 355 + return this.labelingService.getCacheStats(); 356 + } 357 + } 358 + 359 + // testing 360 + const pronounService = new PronounService(); 361 + const pronouns = await pronounService.fetchPronounsForUser( 362 + "did:plc:o6xucog6fghiyrvp7pyqxcs3", 363 + ); 364 + console.log(pronouns);
+69
utils/streamplaceUtils.ts
··· 1 + import { Client, CredentialManager, ok } from "@atcute/client"; 2 + import type {} from "@atcute/atproto"; 3 + import { ATPROTO_PASSWORD, ATPROTO_USERNAME, PDS_HOST_URL } from "../env.ts"; 4 + 5 + // Store credential manager singleton to avoid multiple logins 6 + let credentialManager: CredentialManager | null = null; 7 + let rpcClient: Client | null = null; 8 + 9 + export async function initStreamplaceClient(): Promise<void> { 10 + if (credentialManager !== null) return; 11 + 12 + credentialManager = new CredentialManager({ 13 + service: PDS_HOST_URL!, 14 + }); 15 + rpcClient = new Client({ handler: credentialManager }); 16 + 17 + await credentialManager.login({ 18 + identifier: ATPROTO_USERNAME!, 19 + password: ATPROTO_PASSWORD!, 20 + }); 21 + 22 + console.log("Streamplace client initialized successfully"); 23 + } 24 + 25 + export async function createStreamplaceMessage( 26 + text: string, 27 + streamerDid: string, 28 + replyTo?: { 29 + rootUri: string; 30 + rootCid: string; 31 + parentUri: string; 32 + parentCid: string; 33 + }, 34 + ): Promise<void> { 35 + // Make sure client is initialized 36 + if (!credentialManager || !rpcClient) { 37 + await initStreamplaceClient(); 38 + } 39 + 40 + // Create the message record according to the lexicon 41 + const record = { 42 + text: text, // Plain text - stream.place will handle richtext formatting 43 + streamer: streamerDid, 44 + reply: {}, 45 + createdAt: new Date().toISOString(), 46 + }; 47 + 48 + // Add reply reference if provided 49 + if (replyTo) { 50 + record.reply = { 51 + root: { 52 + uri: replyTo.rootUri, 53 + cid: replyTo.rootCid, 54 + }, 55 + parent: { 56 + uri: replyTo.parentUri, 57 + cid: replyTo.parentCid, 58 + }, 59 + }; 60 + } 61 + 62 + await ok(rpcClient!.post("com.atproto.repo.createRecord", { 63 + input: { 64 + repo: credentialManager!.session!.did, 65 + collection: "place.stream.chat.message", 66 + record: record, 67 + }, 68 + })); 69 + }
+221
utils/websocket.ts
··· 1 + import { JETSTREAM_URL } from "../env.ts"; 2 + 3 + // Client subscription message 4 + interface SubscriptionMessage { 5 + type: "subscribe" | "unsubscribe"; 6 + streamer: string; // DID 7 + } 8 + 9 + // WebSocket service class 10 + export class StreamplaceWebSocketService { 11 + private jetstreamWs: WebSocket | null = null; 12 + private clients = new Map<WebSocket, Set<string>>(); // client -> subscribed streamers 13 + private streamerClients = new Map<string, Set<WebSocket>>(); // streamer -> clients 14 + 15 + constructor(private port: number = 8080) {} 16 + 17 + start() { 18 + console.log(`Starting WebSocket service on port ${this.port}`); 19 + 20 + // Connect to Jetstream 21 + this.connectToJetstream(); 22 + 23 + // Start HTTP server for WebSocket upgrades 24 + this.startWebSocketServer(); 25 + } 26 + 27 + private connectToJetstream() { 28 + const jetstreamUrl = JETSTREAM_URL ?? 29 + "wss://jetstream1.us-east.bsky.network/subscribe?wantedCollections=place.stream.chat.message"; 30 + 31 + this.jetstreamWs = new WebSocket(jetstreamUrl); 32 + 33 + this.jetstreamWs.onopen = () => { 34 + console.log("Connected to Jetstream"); 35 + }; 36 + 37 + this.jetstreamWs.onmessage = (event) => { 38 + try { 39 + const jetstreamMessage: JetstreamMessage = JSON.parse( 40 + event.data, 41 + ); 42 + 43 + // Only process create operations 44 + if ( 45 + jetstreamMessage.kind === "commit" && 46 + jetstreamMessage.commit?.operation === "create" && 47 + jetstreamMessage.commit.record 48 + ) { 49 + console.log(jetstreamMessage); //debug 50 + this.processJetstreamMessage(jetstreamMessage); 51 + } 52 + } catch (error) { 53 + console.error("Error processing jetstream message:", error); 54 + } 55 + }; 56 + 57 + this.jetstreamWs.onclose = () => { 58 + console.log( 59 + "Jetstream connection closed, attempting to reconnect...", 60 + ); 61 + setTimeout(() => this.connectToJetstream(), 5000); 62 + }; 63 + 64 + this.jetstreamWs.onerror = (error) => { 65 + console.error("Jetstream WebSocket error:", error); 66 + }; 67 + } 68 + 69 + private startWebSocketServer() { 70 + Deno.serve({ port: this.port }, (req) => { 71 + const { socket, response } = Deno.upgradeWebSocket(req); 72 + 73 + socket.onopen = () => { 74 + console.log("New client connected"); 75 + this.clients.set(socket, new Set()); 76 + }; 77 + 78 + socket.onmessage = (event) => { 79 + try { 80 + const message: SubscriptionMessage = JSON.parse(event.data); 81 + this.handleClientMessage(socket, message); 82 + } catch (error) { 83 + console.error("Error handling client message:", error); 84 + socket.send( 85 + JSON.stringify({ error: "Invalid message format" }), 86 + ); 87 + } 88 + }; 89 + 90 + socket.onclose = () => { 91 + console.log("Client disconnected"); 92 + this.removeClient(socket); 93 + }; 94 + 95 + return response; 96 + }); 97 + } 98 + 99 + private handleClientMessage( 100 + client: WebSocket, 101 + message: SubscriptionMessage, 102 + ) { 103 + const clientStreamers = this.clients.get(client); 104 + if (!clientStreamers) return; 105 + 106 + switch (message.type) { 107 + case "subscribe": { // Add streamer to client's subscription list 108 + clientStreamers.add(message.streamer); 109 + 110 + // Add client to streamer's client list 111 + if (!this.streamerClients.has(message.streamer)) { 112 + this.streamerClients.set(message.streamer, new Set()); 113 + } 114 + this.streamerClients.get(message.streamer)!.add(client); 115 + 116 + console.log( 117 + `Client subscribed to streamer: ${message.streamer}`, 118 + ); 119 + client.send(JSON.stringify({ 120 + type: "subscribed", 121 + streamer: message.streamer, 122 + })); 123 + break; 124 + } 125 + 126 + case "unsubscribe": { // Remove streamer from client's subscription list 127 + clientStreamers.delete(message.streamer); 128 + 129 + // Remove client from streamer's client list 130 + const streamerClientSet = this.streamerClients.get( 131 + message.streamer, 132 + ); 133 + if (streamerClientSet) { 134 + streamerClientSet.delete(client); 135 + if (streamerClientSet.size === 0) { 136 + this.streamerClients.delete(message.streamer); 137 + } 138 + } 139 + 140 + console.log( 141 + `Client unsubscribed from streamer: ${message.streamer}`, 142 + ); 143 + client.send(JSON.stringify({ 144 + type: "unsubscribed", 145 + streamer: message.streamer, 146 + })); 147 + break; 148 + } 149 + } 150 + } 151 + 152 + private removeClient(client: WebSocket) { 153 + const clientStreamers = this.clients.get(client); 154 + if (clientStreamers) { 155 + // Remove client from all streamer client sets 156 + for (const streamer of clientStreamers) { 157 + const streamerClientSet = this.streamerClients.get(streamer); 158 + if (streamerClientSet) { 159 + streamerClientSet.delete(client); 160 + if (streamerClientSet.size === 0) { 161 + this.streamerClients.delete(streamer); 162 + } 163 + } 164 + } 165 + } 166 + this.clients.delete(client); 167 + } 168 + 169 + private processJetstreamMessage(jetstreamMessage: JetstreamMessage) { 170 + // Extract the record data 171 + const record = jetstreamMessage.commit.record!; 172 + const enrichedMessage = this.enrichMessage(jetstreamMessage, record); 173 + 174 + // Get clients subscribed to this streamer 175 + const streamer = record.streamer; // This is a DID 176 + const subscribedClients = this.streamerClients.get(streamer); 177 + 178 + if (subscribedClients && subscribedClients.size > 0) { 179 + const messageJson = JSON.stringify({ 180 + type: "chat_message", 181 + data: enrichedMessage, 182 + }); 183 + 184 + // Send to all subscribed clients 185 + for (const client of subscribedClients) { 186 + if (client.readyState === WebSocket.OPEN) { 187 + client.send(messageJson); 188 + } 189 + } 190 + 191 + console.log( 192 + `Sent message to ${subscribedClients.size} clients for streamer ${streamer}`, 193 + ); 194 + } 195 + } 196 + 197 + private enrichMessage( 198 + jetstreamMessage: JetstreamMessage, 199 + record: NonNullable<JetstreamMessage["commit"]["record"]>, 200 + ): EnrichedChatMessage { 201 + // Placeholder enrichment - just convert the format for now 202 + return { 203 + id: jetstreamMessage.commit.cid, 204 + text: record.text, 205 + author: { 206 + did: jetstreamMessage.did, // Message author's DID 207 + handle: jetstreamMessage.did, // TODO: Resolve DID to handle 208 + // displayName would be enriched here by looking up profile 209 + }, 210 + timestamp: new Date(record.createdAt), 211 + color: { 212 + red: 128, // TODO: Get from user's chat profile 213 + green: 128, 214 + blue: 128, 215 + }, 216 + facets: record.facets, 217 + isReply: !!record.reply, 218 + streamer: record.streamer, // The streamer's DID 219 + }; 220 + } 221 + }