Aethel Bot OSS repository! aethel.xyz
bot fun ai discord discord-bot aethel

feat: lyra

- New social notifications, track social media posts from the Fediverse and Bluesky, no login or authentication needed.

+1 -1
Dockerfile
··· 55 55 ENV NODE_ENV=production 56 56 ENV VITE_BOT_API_URL=${VITE_BOT_API_URL} 57 57 ENV STATUS_API_KEY=${VITE_STATUS_API_KEY} 58 - ENV VITE_STATUS_API_KEY=${VITE_STATUS_API_KEY} 58 + ENV VITE_STATUS_API_KEY=${STATUS_API_KEY} 59 59 ENV VITE_FRONTEND_URL=${VITE_FRONTEND_URL} 60 60 61 61 RUN addgroup -g 1001 -S nodejs && \
+20
locales/en-US.json
··· 319 319 "fetch_failed": "Failed to fetch trivia questions: {error}", 320 320 "api_error": "Trivia API error: {code}" 321 321 } 322 + }, 323 + "social": { 324 + "name": "social", 325 + "description": "Get notifications of posts from Fediverse and Bluesky accounts", 326 + "invalidChannel": "❌ Invalid channel selected.", 327 + "notInitializedThrow": "Social media features are not properly initialized.", 328 + "addSuccess": "✅ Now tracking {platform} account {account}. New posts will be posted in {channel}.", 329 + "notInitialized": "❌ Social media features are not initialized.", 330 + "refreshSuccess": "🔄 Refresh complete. {count} new post(s) notified.", 331 + "refreshFailed": "❌ Refresh failed: {message}", 332 + "removeSuccess": "✅ Removed {platform} account {account} from tracking.", 333 + "removeNotFound": "❌ Could not find a subscription for {platform} account {account}.", 334 + "listNone": "No social media accounts are being tracked in this server.", 335 + "listTitle": "📱 Tracked Social Media Accounts", 336 + "listDescription": "Here are all the social media accounts being tracked in this server:", 337 + "channelUnset": "No channel set", 338 + "fieldNoAccounts": "No accounts", 339 + "guildOnly": "This command can only be used in a server.", 340 + "unknownSubcommand": "Unknown subcommand.", 341 + "failedAction": "❌ Failed to {action} subscription: {message}" 322 342 } 323 343 }, 324 344 "categories": {
+30
migrations/008_create_social_media_tables.sql
··· 1 + CREATE TYPE social_platform AS ENUM ('bluesky', 'fediverse'); 2 + 3 + CREATE TABLE IF NOT EXISTS server_social_subscriptions ( 4 + id SERIAL PRIMARY KEY, 5 + guild_id TEXT NOT NULL, 6 + platform social_platform NOT NULL, 7 + account_handle TEXT NOT NULL, 8 + last_post_uri TEXT, 9 + last_post_timestamp TIMESTAMPTZ, 10 + channel_id TEXT NOT NULL, 11 + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 12 + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 13 + UNIQUE(guild_id, platform, account_handle) 14 + ); 15 + 16 + CREATE INDEX IF NOT EXISTS idx_server_social_subscriptions_guild ON server_social_subscriptions(guild_id); 17 + CREATE INDEX IF NOT EXISTS idx_server_social_subscriptions_platform ON server_social_subscriptions(platform); 18 + 19 + CREATE OR REPLACE FUNCTION update_updated_at_column() 20 + RETURNS TRIGGER AS $$ 21 + BEGIN 22 + NEW.updated_at = NOW(); 23 + RETURN NEW; 24 + END; 25 + $$ LANGUAGE plpgsql; 26 + 27 + CREATE TRIGGER update_server_social_subscriptions_updated_at 28 + BEFORE UPDATE ON server_social_subscriptions 29 + FOR EACH ROW 30 + EXECUTE FUNCTION update_updated_at_column();
+10 -8
package.json
··· 1 1 { 2 2 "name": "aethel", 3 - "version": "2.0.0-beta", 3 + "version": "lyra", 4 4 "description": "A privacy-conscious, production-ready Discord user bot", 5 5 "type": "module", 6 6 "main": "dist/index.js", ··· 18 18 "check": "pnpm run lint && pnpm run format:check" 19 19 }, 20 20 "dependencies": { 21 + "@atproto/identity": "^0.4.8", 21 22 "@discordjs/rest": "^2.5.1", 23 + "@types/node-fetch": "^2.6.13", 22 24 "axios": "^1.11.0", 23 25 "city-timezones": "^1.3.1", 24 26 "cors": "^2.8.5", 25 27 "discord.js": "^14.21.0", 26 28 "dotenv": "^16.6.1", 27 - "eslint-plugin-prettier": "^5.5.3", 29 + "eslint-plugin-prettier": "^5.5.4", 28 30 "express": "^4.21.2", 29 31 "express-rate-limit": "^7.5.1", 30 32 "express-validator": "^7.2.1", ··· 32 34 "jsonwebtoken": "^9.0.2", 33 35 "moment-timezone": "^0.6.0", 34 36 "node-fetch": "^3.3.2", 35 - "openai": "^5.12.0", 37 + "openai": "^5.12.2", 36 38 "pg": "^8.16.3", 37 39 "uuid": "^11.1.0", 38 40 "validator": "^13.15.15", ··· 40 42 "winston": "^3.17.0" 41 43 }, 42 44 "devDependencies": { 43 - "@eslint/js": "^9.32.0", 45 + "@eslint/js": "^9.33.0", 44 46 "@types/cors": "^2.8.19", 45 47 "@types/express": "^4.17.23", 46 48 "@types/jsonwebtoken": "^9.0.10", 47 - "@types/node": "^24.1.0", 49 + "@types/node": "^24.2.1", 48 50 "@types/pg": "^8.15.5", 49 51 "@types/uuid": "^10.0.0", 50 52 "@types/validator": "^13.15.2", 51 53 "@types/whois-json": "^2.0.4", 52 - "eslint": "^9.32.0", 54 + "eslint": "^9.33.0", 53 55 "eslint-config-prettier": "^10.1.8", 54 56 "globals": "^16.3.0", 55 57 "nodemon": "^3.1.10", ··· 57 59 "tsc-alias": "^1.8.16", 58 60 "tsconfig-paths": "^4.2.0", 59 61 "tsx": "^4.20.3", 60 - "typescript": "^5.8.3", 61 - "typescript-eslint": "^8.38.0" 62 + "typescript": "^5.9.2", 63 + "typescript-eslint": "^8.39.0" 62 64 } 63 65 }
+231 -156
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atproto/identity': 12 + specifier: ^0.4.8 13 + version: 0.4.8 11 14 '@discordjs/rest': 12 15 specifier: ^2.5.1 13 16 version: 2.5.1 17 + '@types/node-fetch': 18 + specifier: ^2.6.13 19 + version: 2.6.13 14 20 axios: 15 21 specifier: ^1.11.0 16 22 version: 1.11.0 ··· 27 33 specifier: ^16.6.1 28 34 version: 16.6.1 29 35 eslint-plugin-prettier: 30 - specifier: ^5.5.3 31 - version: 5.5.3(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2) 36 + specifier: ^5.5.4 37 + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.33.0))(eslint@9.33.0)(prettier@3.6.2) 32 38 express: 33 39 specifier: ^4.21.2 34 40 version: 4.21.2 ··· 51 57 specifier: ^3.3.2 52 58 version: 3.3.2 53 59 openai: 54 - specifier: ^5.12.0 55 - version: 5.12.0(ws@8.18.3) 60 + specifier: ^5.12.2 61 + version: 5.12.2(ws@8.18.3)(zod@3.25.76) 56 62 pg: 57 63 specifier: ^8.16.3 58 64 version: 8.16.3 ··· 70 76 version: 3.17.0 71 77 devDependencies: 72 78 '@eslint/js': 73 - specifier: ^9.32.0 74 - version: 9.32.0 79 + specifier: ^9.33.0 80 + version: 9.33.0 75 81 '@types/cors': 76 82 specifier: ^2.8.19 77 83 version: 2.8.19 ··· 82 88 specifier: ^9.0.10 83 89 version: 9.0.10 84 90 '@types/node': 85 - specifier: ^24.1.0 86 - version: 24.1.0 91 + specifier: ^24.2.1 92 + version: 24.2.1 87 93 '@types/pg': 88 94 specifier: ^8.15.5 89 95 version: 8.15.5 ··· 97 103 specifier: ^2.0.4 98 104 version: 2.0.4 99 105 eslint: 100 - specifier: ^9.32.0 101 - version: 9.32.0 106 + specifier: ^9.33.0 107 + version: 9.33.0 102 108 eslint-config-prettier: 103 109 specifier: ^10.1.8 104 - version: 10.1.8(eslint@9.32.0) 110 + version: 10.1.8(eslint@9.33.0) 105 111 globals: 106 112 specifier: ^16.3.0 107 113 version: 16.3.0 ··· 121 127 specifier: ^4.20.3 122 128 version: 4.20.3 123 129 typescript: 124 - specifier: ^5.8.3 125 - version: 5.8.3 130 + specifier: ^5.9.2 131 + version: 5.9.2 126 132 typescript-eslint: 127 - specifier: ^8.38.0 128 - version: 8.38.0(eslint@9.32.0)(typescript@5.8.3) 133 + specifier: ^8.39.0 134 + version: 8.39.0(eslint@9.33.0)(typescript@5.9.2) 129 135 130 136 packages: 137 + 138 + '@atproto/common-web@0.4.2': 139 + resolution: {integrity: sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==} 140 + 141 + '@atproto/crypto@0.4.4': 142 + resolution: {integrity: sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==} 143 + engines: {node: '>=18.7.0'} 144 + 145 + '@atproto/identity@0.4.8': 146 + resolution: {integrity: sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA==} 147 + engines: {node: '>=18.7.0'} 131 148 132 149 '@colors/colors@1.6.0': 133 150 resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} ··· 334 351 resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} 335 352 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 336 353 337 - '@eslint/config-helpers@0.3.0': 338 - resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==} 354 + '@eslint/config-helpers@0.3.1': 355 + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} 339 356 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 340 357 341 - '@eslint/core@0.15.1': 342 - resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==} 358 + '@eslint/core@0.15.2': 359 + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} 343 360 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 344 361 345 362 '@eslint/eslintrc@3.3.1': 346 363 resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} 347 364 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 348 365 349 - '@eslint/js@9.32.0': 350 - resolution: {integrity: sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==} 366 + '@eslint/js@9.33.0': 367 + resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==} 351 368 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 352 369 353 370 '@eslint/object-schema@2.1.6': 354 371 resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} 355 372 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 356 373 357 - '@eslint/plugin-kit@0.3.4': 358 - resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} 374 + '@eslint/plugin-kit@0.3.5': 375 + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} 359 376 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 360 377 361 378 '@humanfs/core@0.19.1': ··· 377 394 '@humanwhocodes/retry@0.4.3': 378 395 resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} 379 396 engines: {node: '>=18.18'} 397 + 398 + '@noble/curves@1.9.6': 399 + resolution: {integrity: sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==} 400 + engines: {node: ^14.21.3 || >=16} 401 + 402 + '@noble/hashes@1.8.0': 403 + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} 404 + engines: {node: ^14.21.3 || >=16} 380 405 381 406 '@nodelib/fs.scandir@2.1.5': 382 407 resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} ··· 443 468 '@types/ms@2.1.0': 444 469 resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} 445 470 446 - '@types/node@24.1.0': 447 - resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==} 471 + '@types/node-fetch@2.6.13': 472 + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} 473 + 474 + '@types/node@24.2.1': 475 + resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==} 448 476 449 477 '@types/pg@8.15.5': 450 478 resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==} ··· 476 504 '@types/ws@8.18.1': 477 505 resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} 478 506 479 - '@typescript-eslint/eslint-plugin@8.38.0': 480 - resolution: {integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==} 507 + '@typescript-eslint/eslint-plugin@8.39.0': 508 + resolution: {integrity: sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==} 481 509 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 482 510 peerDependencies: 483 - '@typescript-eslint/parser': ^8.38.0 511 + '@typescript-eslint/parser': ^8.39.0 484 512 eslint: ^8.57.0 || ^9.0.0 485 - typescript: '>=4.8.4 <5.9.0' 513 + typescript: '>=4.8.4 <6.0.0' 486 514 487 - '@typescript-eslint/parser@8.38.0': 488 - resolution: {integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==} 515 + '@typescript-eslint/parser@8.39.0': 516 + resolution: {integrity: sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==} 489 517 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 490 518 peerDependencies: 491 519 eslint: ^8.57.0 || ^9.0.0 492 - typescript: '>=4.8.4 <5.9.0' 520 + typescript: '>=4.8.4 <6.0.0' 493 521 494 - '@typescript-eslint/project-service@8.38.0': 495 - resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==} 522 + '@typescript-eslint/project-service@8.39.0': 523 + resolution: {integrity: sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==} 496 524 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 497 525 peerDependencies: 498 - typescript: '>=4.8.4 <5.9.0' 526 + typescript: '>=4.8.4 <6.0.0' 499 527 500 - '@typescript-eslint/scope-manager@8.38.0': 501 - resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} 528 + '@typescript-eslint/scope-manager@8.39.0': 529 + resolution: {integrity: sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==} 502 530 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 503 531 504 - '@typescript-eslint/tsconfig-utils@8.38.0': 505 - resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==} 532 + '@typescript-eslint/tsconfig-utils@8.39.0': 533 + resolution: {integrity: sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==} 506 534 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 507 535 peerDependencies: 508 - typescript: '>=4.8.4 <5.9.0' 536 + typescript: '>=4.8.4 <6.0.0' 509 537 510 - '@typescript-eslint/type-utils@8.38.0': 511 - resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==} 538 + '@typescript-eslint/type-utils@8.39.0': 539 + resolution: {integrity: sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==} 512 540 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 513 541 peerDependencies: 514 542 eslint: ^8.57.0 || ^9.0.0 515 - typescript: '>=4.8.4 <5.9.0' 543 + typescript: '>=4.8.4 <6.0.0' 516 544 517 - '@typescript-eslint/types@8.38.0': 518 - resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} 545 + '@typescript-eslint/types@8.39.0': 546 + resolution: {integrity: sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==} 519 547 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 520 548 521 - '@typescript-eslint/typescript-estree@8.38.0': 522 - resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} 549 + '@typescript-eslint/typescript-estree@8.39.0': 550 + resolution: {integrity: sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==} 523 551 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 524 552 peerDependencies: 525 - typescript: '>=4.8.4 <5.9.0' 553 + typescript: '>=4.8.4 <6.0.0' 526 554 527 - '@typescript-eslint/utils@8.38.0': 528 - resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==} 555 + '@typescript-eslint/utils@8.39.0': 556 + resolution: {integrity: sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==} 529 557 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 530 558 peerDependencies: 531 559 eslint: ^8.57.0 || ^9.0.0 532 - typescript: '>=4.8.4 <5.9.0' 560 + typescript: '>=4.8.4 <6.0.0' 533 561 534 - '@typescript-eslint/visitor-keys@8.38.0': 535 - resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} 562 + '@typescript-eslint/visitor-keys@8.39.0': 563 + resolution: {integrity: sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==} 536 564 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 537 565 538 566 '@vladfrangu/async_event_emitter@2.4.6': ··· 829 857 peerDependencies: 830 858 eslint: '>=7.0.0' 831 859 832 - eslint-plugin-prettier@5.5.3: 833 - resolution: {integrity: sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==} 860 + eslint-plugin-prettier@5.5.4: 861 + resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} 834 862 engines: {node: ^14.18.0 || >=16.0.0} 835 863 peerDependencies: 836 864 '@types/eslint': '>=8.0.0' ··· 855 883 resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} 856 884 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 857 885 858 - eslint@9.32.0: 859 - resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==} 886 + eslint@9.33.0: 887 + resolution: {integrity: sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==} 860 888 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 861 889 hasBin: true 862 890 peerDependencies: ··· 1288 1316 ms@2.1.3: 1289 1317 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 1290 1318 1319 + multiformats@9.9.0: 1320 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 1321 + 1291 1322 mylas@2.1.13: 1292 1323 resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} 1293 1324 engines: {node: '>=12.0.0'} ··· 1335 1366 one-time@1.0.0: 1336 1367 resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} 1337 1368 1338 - openai@5.12.0: 1339 - resolution: {integrity: sha512-vUdt02xiWgOHiYUmW0Hj1Qu9OKAiVQu5Bd547ktVCiMKC1BkB5L3ImeEnCyq3WpRKR6ZTaPgekzqdozwdPs7Lg==} 1369 + openai@5.12.2: 1370 + resolution: {integrity: sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==} 1340 1371 hasBin: true 1341 1372 peerDependencies: 1342 1373 ws: ^8.18.0 ··· 1718 1749 resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} 1719 1750 engines: {node: '>= 0.6'} 1720 1751 1721 - typescript-eslint@8.38.0: 1722 - resolution: {integrity: sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==} 1752 + typescript-eslint@8.39.0: 1753 + resolution: {integrity: sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==} 1723 1754 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 1724 1755 peerDependencies: 1725 1756 eslint: ^8.57.0 || ^9.0.0 1726 - typescript: '>=4.8.4 <5.9.0' 1757 + typescript: '>=4.8.4 <6.0.0' 1727 1758 1728 - typescript@5.8.3: 1729 - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} 1759 + typescript@5.9.2: 1760 + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} 1730 1761 engines: {node: '>=14.17'} 1731 1762 hasBin: true 1763 + 1764 + uint8arrays@3.0.0: 1765 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 1732 1766 1733 1767 undefsafe@2.0.5: 1734 1768 resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} ··· 1736 1770 underscore@1.13.7: 1737 1771 resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} 1738 1772 1739 - undici-types@7.8.0: 1740 - resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} 1773 + undici-types@7.10.0: 1774 + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} 1741 1775 1742 1776 undici@6.21.3: 1743 1777 resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} ··· 1845 1879 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} 1846 1880 engines: {node: '>=10'} 1847 1881 1882 + zod@3.25.76: 1883 + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 1884 + 1848 1885 snapshots: 1849 1886 1887 + '@atproto/common-web@0.4.2': 1888 + dependencies: 1889 + graphemer: 1.4.0 1890 + multiformats: 9.9.0 1891 + uint8arrays: 3.0.0 1892 + zod: 3.25.76 1893 + 1894 + '@atproto/crypto@0.4.4': 1895 + dependencies: 1896 + '@noble/curves': 1.9.6 1897 + '@noble/hashes': 1.8.0 1898 + uint8arrays: 3.0.0 1899 + 1900 + '@atproto/identity@0.4.8': 1901 + dependencies: 1902 + '@atproto/common-web': 0.4.2 1903 + '@atproto/crypto': 0.4.4 1904 + 1850 1905 '@colors/colors@1.6.0': {} 1851 1906 1852 1907 '@dabh/diagnostics@2.0.3': ··· 1980 2035 '@esbuild/win32-x64@0.25.8': 1981 2036 optional: true 1982 2037 1983 - '@eslint-community/eslint-utils@4.7.0(eslint@9.32.0)': 2038 + '@eslint-community/eslint-utils@4.7.0(eslint@9.33.0)': 1984 2039 dependencies: 1985 - eslint: 9.32.0 2040 + eslint: 9.33.0 1986 2041 eslint-visitor-keys: 3.4.3 1987 2042 1988 2043 '@eslint-community/regexpp@4.12.1': {} ··· 1995 2050 transitivePeerDependencies: 1996 2051 - supports-color 1997 2052 1998 - '@eslint/config-helpers@0.3.0': {} 2053 + '@eslint/config-helpers@0.3.1': {} 1999 2054 2000 - '@eslint/core@0.15.1': 2055 + '@eslint/core@0.15.2': 2001 2056 dependencies: 2002 2057 '@types/json-schema': 7.0.15 2003 2058 ··· 2015 2070 transitivePeerDependencies: 2016 2071 - supports-color 2017 2072 2018 - '@eslint/js@9.32.0': {} 2073 + '@eslint/js@9.33.0': {} 2019 2074 2020 2075 '@eslint/object-schema@2.1.6': {} 2021 2076 2022 - '@eslint/plugin-kit@0.3.4': 2077 + '@eslint/plugin-kit@0.3.5': 2023 2078 dependencies: 2024 - '@eslint/core': 0.15.1 2079 + '@eslint/core': 0.15.2 2025 2080 levn: 0.4.1 2026 2081 2027 2082 '@humanfs/core@0.19.1': {} ··· 2036 2091 '@humanwhocodes/retry@0.3.1': {} 2037 2092 2038 2093 '@humanwhocodes/retry@0.4.3': {} 2094 + 2095 + '@noble/curves@1.9.6': 2096 + dependencies: 2097 + '@noble/hashes': 1.8.0 2098 + 2099 + '@noble/hashes@1.8.0': {} 2039 2100 2040 2101 '@nodelib/fs.scandir@2.1.5': 2041 2102 dependencies: ··· 2065 2126 '@types/body-parser@1.19.6': 2066 2127 dependencies: 2067 2128 '@types/connect': 3.4.38 2068 - '@types/node': 24.1.0 2129 + '@types/node': 24.2.1 2069 2130 2070 2131 '@types/connect@3.4.38': 2071 2132 dependencies: 2072 - '@types/node': 24.1.0 2133 + '@types/node': 24.2.1 2073 2134 2074 2135 '@types/cors@2.8.19': 2075 2136 dependencies: 2076 - '@types/node': 24.1.0 2137 + '@types/node': 24.2.1 2077 2138 2078 2139 '@types/estree@1.0.8': {} 2079 2140 2080 2141 '@types/express-serve-static-core@4.19.6': 2081 2142 dependencies: 2082 - '@types/node': 24.1.0 2143 + '@types/node': 24.2.1 2083 2144 '@types/qs': 6.14.0 2084 2145 '@types/range-parser': 1.2.7 2085 2146 '@types/send': 0.17.5 ··· 2098 2159 '@types/jsonwebtoken@9.0.10': 2099 2160 dependencies: 2100 2161 '@types/ms': 2.1.0 2101 - '@types/node': 24.1.0 2162 + '@types/node': 24.2.1 2102 2163 2103 2164 '@types/mime@1.3.5': {} 2104 2165 2105 2166 '@types/ms@2.1.0': {} 2106 2167 2107 - '@types/node@24.1.0': 2168 + '@types/node-fetch@2.6.13': 2169 + dependencies: 2170 + '@types/node': 24.2.1 2171 + form-data: 4.0.4 2172 + 2173 + '@types/node@24.2.1': 2108 2174 dependencies: 2109 - undici-types: 7.8.0 2175 + undici-types: 7.10.0 2110 2176 2111 2177 '@types/pg@8.15.5': 2112 2178 dependencies: 2113 - '@types/node': 24.1.0 2179 + '@types/node': 24.2.1 2114 2180 pg-protocol: 1.10.3 2115 2181 pg-types: 2.2.0 2116 2182 ··· 2121 2187 '@types/send@0.17.5': 2122 2188 dependencies: 2123 2189 '@types/mime': 1.3.5 2124 - '@types/node': 24.1.0 2190 + '@types/node': 24.2.1 2125 2191 2126 2192 '@types/serve-static@1.15.8': 2127 2193 dependencies: 2128 2194 '@types/http-errors': 2.0.5 2129 - '@types/node': 24.1.0 2195 + '@types/node': 24.2.1 2130 2196 '@types/send': 0.17.5 2131 2197 2132 2198 '@types/triple-beam@1.3.5': {} ··· 2139 2205 2140 2206 '@types/ws@8.18.1': 2141 2207 dependencies: 2142 - '@types/node': 24.1.0 2208 + '@types/node': 24.2.1 2143 2209 2144 - '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3)': 2210 + '@typescript-eslint/eslint-plugin@8.39.0(@typescript-eslint/parser@8.39.0(eslint@9.33.0)(typescript@5.9.2))(eslint@9.33.0)(typescript@5.9.2)': 2145 2211 dependencies: 2146 2212 '@eslint-community/regexpp': 4.12.1 2147 - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) 2148 - '@typescript-eslint/scope-manager': 8.38.0 2149 - '@typescript-eslint/type-utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) 2150 - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) 2151 - '@typescript-eslint/visitor-keys': 8.38.0 2152 - eslint: 9.32.0 2213 + '@typescript-eslint/parser': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 2214 + '@typescript-eslint/scope-manager': 8.39.0 2215 + '@typescript-eslint/type-utils': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 2216 + '@typescript-eslint/utils': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 2217 + '@typescript-eslint/visitor-keys': 8.39.0 2218 + eslint: 9.33.0 2153 2219 graphemer: 1.4.0 2154 2220 ignore: 7.0.5 2155 2221 natural-compare: 1.4.0 2156 - ts-api-utils: 2.1.0(typescript@5.8.3) 2157 - typescript: 5.8.3 2222 + ts-api-utils: 2.1.0(typescript@5.9.2) 2223 + typescript: 5.9.2 2158 2224 transitivePeerDependencies: 2159 2225 - supports-color 2160 2226 2161 - '@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3)': 2227 + '@typescript-eslint/parser@8.39.0(eslint@9.33.0)(typescript@5.9.2)': 2162 2228 dependencies: 2163 - '@typescript-eslint/scope-manager': 8.38.0 2164 - '@typescript-eslint/types': 8.38.0 2165 - '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) 2166 - '@typescript-eslint/visitor-keys': 8.38.0 2229 + '@typescript-eslint/scope-manager': 8.39.0 2230 + '@typescript-eslint/types': 8.39.0 2231 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) 2232 + '@typescript-eslint/visitor-keys': 8.39.0 2167 2233 debug: 4.4.1(supports-color@5.5.0) 2168 - eslint: 9.32.0 2169 - typescript: 5.8.3 2234 + eslint: 9.33.0 2235 + typescript: 5.9.2 2170 2236 transitivePeerDependencies: 2171 2237 - supports-color 2172 2238 2173 - '@typescript-eslint/project-service@8.38.0(typescript@5.8.3)': 2239 + '@typescript-eslint/project-service@8.39.0(typescript@5.9.2)': 2174 2240 dependencies: 2175 - '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) 2176 - '@typescript-eslint/types': 8.38.0 2241 + '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.9.2) 2242 + '@typescript-eslint/types': 8.39.0 2177 2243 debug: 4.4.1(supports-color@5.5.0) 2178 - typescript: 5.8.3 2244 + typescript: 5.9.2 2179 2245 transitivePeerDependencies: 2180 2246 - supports-color 2181 2247 2182 - '@typescript-eslint/scope-manager@8.38.0': 2248 + '@typescript-eslint/scope-manager@8.39.0': 2183 2249 dependencies: 2184 - '@typescript-eslint/types': 8.38.0 2185 - '@typescript-eslint/visitor-keys': 8.38.0 2250 + '@typescript-eslint/types': 8.39.0 2251 + '@typescript-eslint/visitor-keys': 8.39.0 2186 2252 2187 - '@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.8.3)': 2253 + '@typescript-eslint/tsconfig-utils@8.39.0(typescript@5.9.2)': 2188 2254 dependencies: 2189 - typescript: 5.8.3 2255 + typescript: 5.9.2 2190 2256 2191 - '@typescript-eslint/type-utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)': 2257 + '@typescript-eslint/type-utils@8.39.0(eslint@9.33.0)(typescript@5.9.2)': 2192 2258 dependencies: 2193 - '@typescript-eslint/types': 8.38.0 2194 - '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) 2195 - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) 2259 + '@typescript-eslint/types': 8.39.0 2260 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) 2261 + '@typescript-eslint/utils': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 2196 2262 debug: 4.4.1(supports-color@5.5.0) 2197 - eslint: 9.32.0 2198 - ts-api-utils: 2.1.0(typescript@5.8.3) 2199 - typescript: 5.8.3 2263 + eslint: 9.33.0 2264 + ts-api-utils: 2.1.0(typescript@5.9.2) 2265 + typescript: 5.9.2 2200 2266 transitivePeerDependencies: 2201 2267 - supports-color 2202 2268 2203 - '@typescript-eslint/types@8.38.0': {} 2269 + '@typescript-eslint/types@8.39.0': {} 2204 2270 2205 - '@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)': 2271 + '@typescript-eslint/typescript-estree@8.39.0(typescript@5.9.2)': 2206 2272 dependencies: 2207 - '@typescript-eslint/project-service': 8.38.0(typescript@5.8.3) 2208 - '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) 2209 - '@typescript-eslint/types': 8.38.0 2210 - '@typescript-eslint/visitor-keys': 8.38.0 2273 + '@typescript-eslint/project-service': 8.39.0(typescript@5.9.2) 2274 + '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.9.2) 2275 + '@typescript-eslint/types': 8.39.0 2276 + '@typescript-eslint/visitor-keys': 8.39.0 2211 2277 debug: 4.4.1(supports-color@5.5.0) 2212 2278 fast-glob: 3.3.3 2213 2279 is-glob: 4.0.3 2214 2280 minimatch: 9.0.5 2215 2281 semver: 7.7.2 2216 - ts-api-utils: 2.1.0(typescript@5.8.3) 2217 - typescript: 5.8.3 2282 + ts-api-utils: 2.1.0(typescript@5.9.2) 2283 + typescript: 5.9.2 2218 2284 transitivePeerDependencies: 2219 2285 - supports-color 2220 2286 2221 - '@typescript-eslint/utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)': 2287 + '@typescript-eslint/utils@8.39.0(eslint@9.33.0)(typescript@5.9.2)': 2222 2288 dependencies: 2223 - '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) 2224 - '@typescript-eslint/scope-manager': 8.38.0 2225 - '@typescript-eslint/types': 8.38.0 2226 - '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) 2227 - eslint: 9.32.0 2228 - typescript: 5.8.3 2289 + '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0) 2290 + '@typescript-eslint/scope-manager': 8.39.0 2291 + '@typescript-eslint/types': 8.39.0 2292 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) 2293 + eslint: 9.33.0 2294 + typescript: 5.9.2 2229 2295 transitivePeerDependencies: 2230 2296 - supports-color 2231 2297 2232 - '@typescript-eslint/visitor-keys@8.38.0': 2298 + '@typescript-eslint/visitor-keys@8.39.0': 2233 2299 dependencies: 2234 - '@typescript-eslint/types': 8.38.0 2300 + '@typescript-eslint/types': 8.39.0 2235 2301 eslint-visitor-keys: 4.2.1 2236 2302 2237 2303 '@vladfrangu/async_event_emitter@2.4.6': {} ··· 2572 2638 2573 2639 escape-string-regexp@4.0.0: {} 2574 2640 2575 - eslint-config-prettier@10.1.8(eslint@9.32.0): 2641 + eslint-config-prettier@10.1.8(eslint@9.33.0): 2576 2642 dependencies: 2577 - eslint: 9.32.0 2643 + eslint: 9.33.0 2578 2644 2579 - eslint-plugin-prettier@5.5.3(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2): 2645 + eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.33.0))(eslint@9.33.0)(prettier@3.6.2): 2580 2646 dependencies: 2581 - eslint: 9.32.0 2647 + eslint: 9.33.0 2582 2648 prettier: 3.6.2 2583 2649 prettier-linter-helpers: 1.0.0 2584 2650 synckit: 0.11.11 2585 2651 optionalDependencies: 2586 - eslint-config-prettier: 10.1.8(eslint@9.32.0) 2652 + eslint-config-prettier: 10.1.8(eslint@9.33.0) 2587 2653 2588 2654 eslint-scope@8.4.0: 2589 2655 dependencies: ··· 2594 2660 2595 2661 eslint-visitor-keys@4.2.1: {} 2596 2662 2597 - eslint@9.32.0: 2663 + eslint@9.33.0: 2598 2664 dependencies: 2599 - '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) 2665 + '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0) 2600 2666 '@eslint-community/regexpp': 4.12.1 2601 2667 '@eslint/config-array': 0.21.0 2602 - '@eslint/config-helpers': 0.3.0 2603 - '@eslint/core': 0.15.1 2668 + '@eslint/config-helpers': 0.3.1 2669 + '@eslint/core': 0.15.2 2604 2670 '@eslint/eslintrc': 3.3.1 2605 - '@eslint/js': 9.32.0 2606 - '@eslint/plugin-kit': 0.3.4 2671 + '@eslint/js': 9.33.0 2672 + '@eslint/plugin-kit': 0.3.5 2607 2673 '@humanfs/node': 0.16.6 2608 2674 '@humanwhocodes/module-importer': 1.0.1 2609 2675 '@humanwhocodes/retry': 0.4.3 ··· 3059 3125 3060 3126 ms@2.1.3: {} 3061 3127 3128 + multiformats@9.9.0: {} 3129 + 3062 3130 mylas@2.1.13: {} 3063 3131 3064 3132 natural-compare@1.4.0: {} ··· 3104 3172 dependencies: 3105 3173 fn.name: 1.1.0 3106 3174 3107 - openai@5.12.0(ws@8.18.3): 3175 + openai@5.12.2(ws@8.18.3)(zod@3.25.76): 3108 3176 optionalDependencies: 3109 3177 ws: 8.18.3 3178 + zod: 3.25.76 3110 3179 3111 3180 optionator@0.9.4: 3112 3181 dependencies: ··· 3432 3501 3433 3502 triple-beam@1.4.1: {} 3434 3503 3435 - ts-api-utils@2.1.0(typescript@5.8.3): 3504 + ts-api-utils@2.1.0(typescript@5.9.2): 3436 3505 dependencies: 3437 - typescript: 5.8.3 3506 + typescript: 5.9.2 3438 3507 3439 3508 ts-mixer@6.0.4: {} 3440 3509 ··· 3472 3541 media-typer: 0.3.0 3473 3542 mime-types: 2.1.35 3474 3543 3475 - typescript-eslint@8.38.0(eslint@9.32.0)(typescript@5.8.3): 3544 + typescript-eslint@8.39.0(eslint@9.33.0)(typescript@5.9.2): 3476 3545 dependencies: 3477 - '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3))(eslint@9.32.0)(typescript@5.8.3) 3478 - '@typescript-eslint/parser': 8.38.0(eslint@9.32.0)(typescript@5.8.3) 3479 - '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) 3480 - '@typescript-eslint/utils': 8.38.0(eslint@9.32.0)(typescript@5.8.3) 3481 - eslint: 9.32.0 3482 - typescript: 5.8.3 3546 + '@typescript-eslint/eslint-plugin': 8.39.0(@typescript-eslint/parser@8.39.0(eslint@9.33.0)(typescript@5.9.2))(eslint@9.33.0)(typescript@5.9.2) 3547 + '@typescript-eslint/parser': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 3548 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) 3549 + '@typescript-eslint/utils': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 3550 + eslint: 9.33.0 3551 + typescript: 5.9.2 3483 3552 transitivePeerDependencies: 3484 3553 - supports-color 3485 3554 3486 - typescript@5.8.3: {} 3555 + typescript@5.9.2: {} 3556 + 3557 + uint8arrays@3.0.0: 3558 + dependencies: 3559 + multiformats: 9.9.0 3487 3560 3488 3561 undefsafe@2.0.5: {} 3489 3562 3490 3563 underscore@1.13.7: {} 3491 3564 3492 - undici-types@7.8.0: {} 3565 + undici-types@7.10.0: {} 3493 3566 3494 3567 undici@6.21.3: {} 3495 3568 ··· 3593 3666 yargs-parser: 18.1.3 3594 3667 3595 3668 yocto-queue@0.1.0: {} 3669 + 3670 + zod@3.25.76: {}
+346
src/commands/utilities/social.ts
··· 1 + import { 2 + SlashCommandBuilder, 3 + PermissionFlagsBits, 4 + EmbedBuilder, 5 + ChatInputCommandInteraction, 6 + MessageFlags, 7 + } from 'discord.js'; 8 + import { SlashCommandProps } from '@/types/command'; 9 + import BotClient from '@/services/Client'; 10 + import logger from '@/utils/logger'; 11 + import { SocialMediaSubscription } from '@/types/social'; 12 + 13 + interface SocialCommand extends SlashCommandProps { 14 + handleAdd: (client: BotClient, interaction: ChatInputCommandInteraction) => Promise<void>; 15 + handleRemove: (client: BotClient, interaction: ChatInputCommandInteraction) => Promise<void>; 16 + handleList: (client: BotClient, interaction: ChatInputCommandInteraction) => Promise<void>; 17 + handleRefresh: (client: BotClient, interaction: ChatInputCommandInteraction) => Promise<void>; 18 + } 19 + 20 + const platforms = [ 21 + { name: 'Bluesky', value: 'bluesky' }, 22 + { name: 'Fediverse (Mastodon, Pleroma, etc.)', value: 'fediverse' }, 23 + ] as const; 24 + 25 + type SocialPlatform = (typeof platforms)[number]['value']; 26 + 27 + const platformNames: Record<string, string> = { 28 + bluesky: 'Bluesky', 29 + fediverse: 'Fediverse', 30 + }; 31 + 32 + const platformEmojis: Record<string, string> = { 33 + bluesky: '🔵', 34 + fediverse: '🐘', 35 + }; 36 + 37 + const command: SocialCommand = { 38 + data: new SlashCommandBuilder() 39 + .setName('social') 40 + .setDescription('Get notifications of posts from Fediverse and Bluesky accounts') 41 + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) 42 + .setDMPermission(false) 43 + .addSubcommand((subcommand) => 44 + subcommand 45 + .setName('add') 46 + .setDescription('Add a social media account to track') 47 + .addStringOption((option) => 48 + option 49 + .setName('platform') 50 + .setDescription('Social media platform') 51 + .setRequired(true) 52 + .addChoices(...platforms), 53 + ) 54 + .addStringOption((option) => 55 + option 56 + .setName('account') 57 + .setDescription( 58 + 'Account handle (e.g., user@instance.social for Fediverse, or user.bsky.social for Bluesky)', 59 + ) 60 + .setRequired(true), 61 + ) 62 + .addChannelOption((option) => 63 + option 64 + .setName('channel') 65 + .setDescription('Channel to post notifications (defaults to current channel)') 66 + .setRequired(false), 67 + ), 68 + ) 69 + .addSubcommand((subcommand) => 70 + subcommand 71 + .setName('remove') 72 + .setDescription('Remove a social media account from tracking') 73 + .addStringOption((option) => 74 + option 75 + .setName('platform') 76 + .setDescription('Social media platform') 77 + .setRequired(true) 78 + .addChoices(...platforms), 79 + ) 80 + .addStringOption((option) => 81 + option 82 + .setName('account') 83 + .setDescription('Account handle to remove from tracking') 84 + .setRequired(true), 85 + ), 86 + ) 87 + .addSubcommand((subcommand) => 88 + subcommand 89 + .setName('list') 90 + .setDescription('List all tracked social media accounts for this server'), 91 + ) 92 + .addSubcommand((subcommand) => 93 + subcommand 94 + .setName('refresh') 95 + .setDescription('Check for new posts and send notifications immediately'), 96 + ) as unknown as SlashCommandBuilder, 97 + 98 + handleAdd: async (client: BotClient, interaction: ChatInputCommandInteraction) => { 99 + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 100 + 101 + const platform = interaction.options.getString('platform', true) as SocialPlatform; 102 + const account = interaction.options.getString('account', true); 103 + const channel = interaction.options.getChannel('channel') || interaction.channel; 104 + 105 + if (!channel || !('send' in channel)) { 106 + const msg = await client.getLocaleText( 107 + 'commands.social.invalidChannel', 108 + interaction.locale || 'en-US', 109 + ); 110 + await interaction.editReply(msg); 111 + return; 112 + } 113 + 114 + if (!client.socialMediaManager) { 115 + throw new Error( 116 + await client.getLocaleText( 117 + 'commands.social.notInitializedThrow', 118 + interaction.locale || 'en-US', 119 + ), 120 + ); 121 + } 122 + 123 + await client.socialMediaManager 124 + .getService() 125 + .addSubscription(interaction.guildId!, platform, account, channel.id); 126 + 127 + const success = await client.getLocaleText( 128 + 'commands.social.addSuccess', 129 + interaction.locale || 'en-US', 130 + { 131 + platform, 132 + account, 133 + channel: String(channel), 134 + }, 135 + ); 136 + await interaction.editReply(success); 137 + 138 + logger.info( 139 + `Added social media subscription for ${platform} account ${account} in guild ${interaction.guildId}`, 140 + ); 141 + }, 142 + 143 + handleRefresh: async (client: BotClient, interaction: ChatInputCommandInteraction) => { 144 + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 145 + if (!client.socialMediaManager) { 146 + const msg = await client.getLocaleText( 147 + 'commands.social.notInitialized', 148 + interaction.locale || 'en-US', 149 + ); 150 + await interaction.editReply(msg); 151 + return; 152 + } 153 + try { 154 + const count = await client.socialMediaManager.refreshOnce(); 155 + const msg = await client.getLocaleText( 156 + 'commands.social.refreshSuccess', 157 + interaction.locale || 'en-US', 158 + { count: String(count) }, 159 + ); 160 + await interaction.editReply(msg); 161 + } catch (err) { 162 + const msg = err instanceof Error ? err.message : 'Unknown error'; 163 + const loc = await client.getLocaleText( 164 + 'commands.social.refreshFailed', 165 + interaction.locale || 'en-US', 166 + { message: msg }, 167 + ); 168 + await interaction.editReply(loc); 169 + } 170 + }, 171 + 172 + handleRemove: async (client: BotClient, interaction: ChatInputCommandInteraction) => { 173 + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 174 + 175 + const platform = interaction.options.getString('platform', true) as SocialPlatform; 176 + const account = interaction.options.getString('account', true); 177 + 178 + if (!client.socialMediaManager) { 179 + throw new Error('Social media features are not properly initialized.'); 180 + } 181 + 182 + const removed = await client.socialMediaManager 183 + .getService() 184 + .removeSubscription(interaction.guildId!, platform, account); 185 + 186 + if (removed) { 187 + const msg = await client.getLocaleText( 188 + 'commands.social.removeSuccess', 189 + interaction.locale || 'en-US', 190 + { platform, account }, 191 + ); 192 + await interaction.editReply(msg); 193 + logger.info( 194 + `Removed social media subscription for ${platform} account ${account} in guild ${interaction.guildId}`, 195 + ); 196 + } else { 197 + const msg = await client.getLocaleText( 198 + 'commands.social.removeNotFound', 199 + interaction.locale || 'en-US', 200 + { platform, account }, 201 + ); 202 + await interaction.editReply(msg); 203 + } 204 + }, 205 + 206 + handleList: async (client: BotClient, interaction: ChatInputCommandInteraction) => { 207 + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 208 + 209 + if (!client.socialMediaManager) { 210 + throw new Error('Social media features are not properly initialized.'); 211 + } 212 + 213 + const subscriptions = await client.socialMediaManager 214 + .getService() 215 + .listSubscriptions(interaction.guildId!); 216 + 217 + if (subscriptions.length === 0) { 218 + const msg = await client.getLocaleText( 219 + 'commands.social.listNone', 220 + interaction.locale || 'en-US', 221 + ); 222 + await interaction.editReply(msg); 223 + return; 224 + } 225 + 226 + const embed = new EmbedBuilder() 227 + .setTitle( 228 + await client.getLocaleText('commands.social.listTitle', interaction.locale || 'en-US'), 229 + ) 230 + .setColor(0x3498db) 231 + .setDescription( 232 + await client.getLocaleText( 233 + 'commands.social.listDescription', 234 + interaction.locale || 'en-US', 235 + ), 236 + ); 237 + 238 + const groupedByPlatform = subscriptions.reduce( 239 + (acc, sub) => { 240 + if (!acc[sub.platform]) { 241 + acc[sub.platform] = []; 242 + } 243 + acc[sub.platform].push(sub); 244 + return acc; 245 + }, 246 + {} as Record<string, SocialMediaSubscription[]>, 247 + ); 248 + 249 + for (const [platform, subs] of Object.entries(groupedByPlatform)) { 250 + const platformName = 251 + platformNames[platform] || platform.charAt(0).toUpperCase() + platform.slice(1); 252 + const emoji = platformEmojis[platform] || '📱'; 253 + 254 + const channelUnset = await client.getLocaleText( 255 + 'commands.social.channelUnset', 256 + interaction.locale || 'en-US', 257 + ); 258 + 259 + const value = subs 260 + .map((sub) => { 261 + const channelMention = sub.channelId ? `<#${sub.channelId}>` : channelUnset; 262 + return `• ${sub.accountHandle} → ${channelMention}`; 263 + }) 264 + .join('\n'); 265 + 266 + embed.addFields({ 267 + name: `${emoji} ${platformName} (${subs.length})`, 268 + value: 269 + value || 270 + (await client.getLocaleText( 271 + 'commands.social.fieldNoAccounts', 272 + interaction.locale || 'en-US', 273 + )), 274 + inline: false, 275 + }); 276 + } 277 + 278 + await interaction.editReply({ embeds: [embed] }); 279 + logger.info( 280 + `Listed ${subscriptions.length} social media subscriptions for guild ${interaction.guildId}`, 281 + ); 282 + }, 283 + 284 + async execute(client: BotClient, interaction: ChatInputCommandInteraction) { 285 + if (!interaction.guildId) { 286 + await interaction.reply({ 287 + content: await client.getLocaleText( 288 + 'commands.social.guildOnly', 289 + interaction.locale || 'en-US', 290 + ), 291 + flags: MessageFlags.Ephemeral, 292 + }); 293 + return; 294 + } 295 + 296 + const subcommand = interaction.options.getSubcommand(); 297 + 298 + try { 299 + switch (subcommand) { 300 + case 'add': 301 + await command.handleAdd(client, interaction); 302 + break; 303 + case 'remove': 304 + await command.handleRemove(client, interaction); 305 + break; 306 + case 'list': 307 + await command.handleList(client, interaction); 308 + break; 309 + case 'refresh': 310 + await command.handleRefresh(client, interaction); 311 + break; 312 + default: 313 + await interaction.reply({ 314 + content: await client.getLocaleText( 315 + 'commands.social.unknownSubcommand', 316 + interaction.locale || 'en-US', 317 + ), 318 + flags: MessageFlags.Ephemeral, 319 + }); 320 + } 321 + } catch (error: unknown) { 322 + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; 323 + logger.error(`Error in social ${subcommand} command:`, error); 324 + 325 + if (interaction.deferred || interaction.replied) { 326 + const msg = await client.getLocaleText( 327 + 'commands.social.failedAction', 328 + interaction.locale || 'en-US', 329 + { action: subcommand, message: errorMessage }, 330 + ); 331 + await interaction.editReply(msg); 332 + } else { 333 + await interaction.reply({ 334 + content: await client.getLocaleText( 335 + 'commands.social.failedAction', 336 + interaction.locale || 'en-US', 337 + { action: subcommand, message: errorMessage }, 338 + ), 339 + flags: MessageFlags.Ephemeral, 340 + }); 341 + } 342 + } 343 + }, 344 + }; 345 + 346 + export default command;
+19
src/services/Client.ts
··· 2 2 import initialzeCommands from '@/handlers/initialzeCommands'; 3 3 import { SlashCommandProps } from '@/types/command'; 4 4 import { Client, Collection, GatewayIntentBits, Partials } from 'discord.js'; 5 + import { Pool } from 'pg'; 6 + import { initializeSocialMediaManager, SocialMediaManager } from './social/SocialMediaManager'; 5 7 import { promises, readdirSync } from 'fs'; 6 8 import path from 'path'; 7 9 import { fileURLToPath } from 'url'; ··· 17 19 public commands = new Collection<string, SlashCommandProps>(); 18 20 // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 21 public t = new Collection<string, any>(); 22 + public socialMediaManager?: SocialMediaManager; 20 23 21 24 constructor() { 22 25 super({ ··· 47 50 await this.setupLocalization(); 48 51 await initialzeCommands(this); 49 52 await this.setupEvents(); 53 + await this.setupDatabase(); 50 54 this.login(config.TOKEN); 51 55 } 52 56 ··· 92 96 throw new Error('Failed to initialize localization'); 93 97 } 94 98 } 99 + private async setupDatabase() { 100 + try { 101 + const pool = new Pool({ 102 + connectionString: process.env.DATABASE_URL, 103 + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, 104 + }); 105 + 106 + this.socialMediaManager = initializeSocialMediaManager(this, pool); 107 + await this.socialMediaManager.initialize(); 108 + } catch (error) { 109 + console.error('Failed to initialize database and services:', error); 110 + throw error; 111 + } 112 + } 113 + 95 114 public async getLocaleText(key: string, locale: string, replaces = {}): Promise<string> { 96 115 const fallbackLocale = 'en-US'; 97 116
+224
src/services/social/SocialMediaManager.ts
··· 1 + import { Client, TextChannel, EmbedBuilder } from 'discord.js'; 2 + import { Pool } from 'pg'; 3 + import { SocialMediaService } from './SocialMediaService'; 4 + import { BlueskyFetcher, FediverseFetcher } from './fetchers/UnifiedFetcher'; 5 + import { SocialMediaFetcher } from '../../types/social'; 6 + import { SocialMediaPost, SocialMediaSubscription } from '../../types/social'; 7 + import logger from '../../utils/logger'; 8 + 9 + export class SocialMediaManager { 10 + private socialService: SocialMediaService; 11 + private notificationService: NotificationService; 12 + private poller: SocialMediaPoller; 13 + private isInitialized = false; 14 + 15 + constructor( 16 + private client: Client, 17 + private pool: Pool, 18 + ) { 19 + const fetchers: SocialMediaFetcher[] = [new BlueskyFetcher(), new FediverseFetcher()]; 20 + 21 + this.socialService = new SocialMediaService(pool, fetchers); 22 + this.notificationService = new NotificationService(client); 23 + this.poller = new SocialMediaPoller(client, this.socialService, this.notificationService); 24 + } 25 + 26 + public async initialize(): Promise<void> { 27 + if (this.isInitialized) return; 28 + 29 + this.poller.start(); 30 + this.isInitialized = true; 31 + } 32 + 33 + public getService(): SocialMediaService { 34 + return this.socialService; 35 + } 36 + 37 + public async refreshOnce(): Promise<number> { 38 + const updates = await this.socialService.checkForUpdates(); 39 + let count = 0; 40 + for (const { post, subscription } of updates) { 41 + try { 42 + await this.notificationService.sendNotification(post, subscription); 43 + count++; 44 + } catch (error) { 45 + console.error('Error sending notification during manual refresh:', error); 46 + } 47 + } 48 + return count; 49 + } 50 + 51 + public async cleanup(): Promise<void> { 52 + if (this.poller) { 53 + this.poller.stop(); 54 + } 55 + this.isInitialized = false; 56 + } 57 + } 58 + 59 + class NotificationService { 60 + constructor(private client: Client) {} 61 + 62 + async sendNotification( 63 + post: SocialMediaPost, 64 + subscription: SocialMediaSubscription, 65 + ): Promise<void> { 66 + try { 67 + const channel = (await this.client.channels.fetch(subscription.channelId)) as TextChannel; 68 + if (!channel) { 69 + console.error(`Channel ${subscription.channelId} not found`); 70 + return; 71 + } 72 + 73 + const embed = this.createEmbed(post); 74 + await channel.send({ embeds: [embed] }); 75 + } catch (error) { 76 + console.error('Error sending notification:', error); 77 + } 78 + } 79 + 80 + private createEmbed(post: SocialMediaPost): EmbedBuilder { 81 + const authorIcon = post.authorAvatarUrl ?? this.getPlatformIcon(post.platform); 82 + const authorName = 83 + post.authorDisplayName && post.authorDisplayName.trim().length > 0 84 + ? `${post.authorDisplayName} (${post.author})` 85 + : post.author; 86 + const embed = new EmbedBuilder() 87 + .setColor(this.getPlatformColor(post.platform)) 88 + .setAuthor({ name: authorName, iconURL: authorIcon }) 89 + .setDescription(this.truncateText(post.text, 1000)) 90 + .setTimestamp(post.timestamp) 91 + .setFooter({ 92 + text: `New post on ${this.formatPlatformName(post.platform)}`, 93 + iconURL: this.getPlatformIcon(post.platform), 94 + }); 95 + 96 + if (post.mediaUrls && post.mediaUrls.length > 0) embed.setImage(post.mediaUrls[0]); 97 + embed.addFields([ 98 + { name: 'View Post', value: `[Open in Browser](${this.getPostUrl(post)})`, inline: true }, 99 + ]); 100 + return embed; 101 + } 102 + 103 + private getPlatformColor(platform: string): number { 104 + switch (platform) { 105 + case 'bluesky': 106 + return 0x1185fe; 107 + case 'fediverse': 108 + return 0x6364ff; 109 + default: 110 + return 0x7289da; 111 + } 112 + } 113 + 114 + private getPlatformIcon(platform: string): string | undefined { 115 + switch (platform) { 116 + case 'bluesky': 117 + return 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/7a/Bluesky_Logo.svg/24px-Bluesky_Logo.svg.png'; 118 + case 'fediverse': 119 + return 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Fediverse_logo_proposal.svg/64px-Fediverse_logo_proposal.svg.png'; 120 + default: 121 + return undefined; 122 + } 123 + } 124 + 125 + private formatPlatformName(platform: string): string { 126 + return platform.charAt(0).toUpperCase() + platform.slice(1); 127 + } 128 + 129 + private getPostUrl(post: SocialMediaPost): string { 130 + switch (post.platform) { 131 + case 'bluesky': { 132 + const handle = post.author.split('@')[0]; 133 + const postId = post.uri.split('/').pop(); 134 + return `https://bsky.app/profile/${handle}/post/${postId}`; 135 + } 136 + case 'fediverse': 137 + return post.uri; 138 + default: 139 + return post.uri; 140 + } 141 + } 142 + 143 + private truncateText(text: string, maxLength: number): string { 144 + if (text.length <= maxLength) return text; 145 + return text.substring(0, maxLength - 3) + '...'; 146 + } 147 + } 148 + class SocialMediaPoller { 149 + private isRunning = false; 150 + private pollInterval: NodeJS.Timeout | null = null; 151 + private readonly POLL_INTERVAL_MS = 60 * 1000; 152 + 153 + constructor( 154 + private readonly client: Client, 155 + private readonly socialService: SocialMediaService, 156 + private readonly notificationService: NotificationService, 157 + ) {} 158 + 159 + public start(): void { 160 + if (this.isRunning) { 161 + logger.warn('Social media poller is already running'); 162 + return; 163 + } 164 + 165 + this.isRunning = true; 166 + logger.info('Starting social media poller...'); 167 + 168 + this.checkForUpdates().catch((error) => { 169 + logger.error('Error in initial social media check:', error); 170 + }); 171 + 172 + this.pollInterval = setInterval(() => { 173 + this.checkForUpdates().catch((error) => { 174 + logger.error('Error in social media polling:', error); 175 + }); 176 + }, this.POLL_INTERVAL_MS); 177 + } 178 + 179 + public stop(): void { 180 + if (!this.isRunning) return; 181 + logger.info('Stopping social media poller...'); 182 + if (this.pollInterval) { 183 + clearInterval(this.pollInterval); 184 + this.pollInterval = null; 185 + } 186 + this.isRunning = false; 187 + } 188 + 189 + public isActive(): boolean { 190 + return this.isRunning; 191 + } 192 + 193 + private async checkForUpdates(): Promise<void> { 194 + if (!this.isRunning) return; 195 + logger.debug('Checking for social media updates...'); 196 + try { 197 + const newPosts = await this.socialService.checkForUpdates(); 198 + if (newPosts.length > 0) { 199 + logger.info(`Found ${newPosts.length} new social media posts`); 200 + for (const { post, subscription } of newPosts) { 201 + try { 202 + await this.notificationService.sendNotification(post, subscription); 203 + logger.debug(`Sent notification for ${post.platform} post by ${post.author}`); 204 + } catch (error) { 205 + logger.error(`Error sending notification for ${post.platform} post:`, error); 206 + } 207 + } 208 + } else { 209 + logger.debug('No new social media posts found'); 210 + } 211 + } catch (error) { 212 + logger.error('Error checking for social media updates:', error); 213 + } 214 + } 215 + } 216 + 217 + export let socialMediaManager: SocialMediaManager; 218 + 219 + export function initializeSocialMediaManager(client: Client, pool: Pool): SocialMediaManager { 220 + if (!socialMediaManager) { 221 + socialMediaManager = new SocialMediaManager(client, pool); 222 + } 223 + return socialMediaManager; 224 + }
+179
src/services/social/SocialMediaService.ts
··· 1 + import { Pool } from 'pg'; 2 + import { 3 + SocialMediaSubscription, 4 + SocialMediaPost, 5 + SocialPlatform, 6 + SocialMediaFetcher, 7 + } from '../../types/social'; 8 + 9 + export class SocialMediaService { 10 + private pool: Pool; 11 + private fetchers: Map<SocialPlatform, SocialMediaFetcher>; 12 + private isPolling = false; 13 + private pollInterval: number = 5 * 60 * 1000; 14 + 15 + constructor(pool: Pool, fetchers: SocialMediaFetcher[]) { 16 + this.pool = pool; 17 + this.fetchers = new Map(fetchers.map((f) => [f.platform, f])); 18 + } 19 + 20 + async addSubscription( 21 + guildId: string, 22 + platform: SocialPlatform, 23 + accountHandle: string, 24 + channelId: string, 25 + ): Promise<SocialMediaSubscription> { 26 + const fetcher = this.fetchers.get(platform); 27 + if (!fetcher) { 28 + throw new Error(`Unsupported platform: ${platform}`); 29 + } 30 + 31 + if (!fetcher.isValidAccount(accountHandle)) { 32 + throw new Error(`Invalid account handle format for ${platform}`); 33 + } 34 + 35 + const result = await this.pool.query( 36 + `INSERT INTO server_social_subscriptions 37 + (guild_id, platform, account_handle, channel_id) 38 + VALUES ($1, $2::social_platform, $3, $4) 39 + ON CONFLICT (guild_id, platform, account_handle) 40 + DO UPDATE SET channel_id = $4 41 + RETURNING *`, 42 + [guildId, platform, accountHandle, channelId], 43 + ); 44 + 45 + return this.mapDbToSubscription(result.rows[0]); 46 + } 47 + 48 + async removeSubscription( 49 + guildId: string, 50 + platform: SocialPlatform, 51 + accountHandle: string, 52 + ): Promise<boolean> { 53 + const result = await this.pool.query( 54 + `DELETE FROM server_social_subscriptions 55 + WHERE guild_id = $1 AND platform = $2::social_platform AND account_handle = $3`, 56 + [guildId, platform, accountHandle], 57 + ); 58 + 59 + return (result.rowCount || 0) > 0; 60 + } 61 + 62 + async listSubscriptions(guildId: string): Promise<SocialMediaSubscription[]> { 63 + const result = await this.pool.query( 64 + `SELECT * FROM server_social_subscriptions WHERE guild_id = $1`, 65 + [guildId], 66 + ); 67 + 68 + return result.rows.map(this.mapDbToSubscription); 69 + } 70 + 71 + async checkForUpdates(): Promise< 72 + { post: SocialMediaPost; subscription: SocialMediaSubscription }[] 73 + > { 74 + const subscriptions = await this.getAllActiveSubscriptions(); 75 + const newPosts: { post: SocialMediaPost; subscription: SocialMediaSubscription }[] = []; 76 + 77 + for (const sub of subscriptions) { 78 + try { 79 + const fetcher = this.fetchers.get(sub.platform as SocialPlatform); 80 + if (!fetcher) continue; 81 + 82 + const latestPost = await fetcher.fetchLatestPost(sub.accountHandle); 83 + if (latestPost) { 84 + if (!sub.lastPostTimestamp) { 85 + await this.updateLastPost(sub.id, latestPost.uri, latestPost.timestamp); 86 + continue; 87 + } 88 + 89 + if (this.isNewerPost(latestPost, sub)) { 90 + await this.updateLastPost(sub.id, latestPost.uri, latestPost.timestamp); 91 + newPosts.push({ 92 + post: latestPost, 93 + subscription: sub, 94 + }); 95 + } 96 + } 97 + } catch (error) { 98 + console.error( 99 + `Error checking updates for ${sub.platform} account ${sub.accountHandle}:`, 100 + error, 101 + ); 102 + } 103 + } 104 + 105 + return newPosts; 106 + } 107 + 108 + startPolling(): void { 109 + if (this.isPolling) return; 110 + 111 + this.isPolling = true; 112 + const poll = async () => { 113 + if (!this.isPolling) return; 114 + 115 + try { 116 + await this.checkForUpdates(); 117 + } catch (error) { 118 + console.error('Error during social media polling:', error); 119 + } finally { 120 + if (this.isPolling) { 121 + setTimeout(poll, this.pollInterval); 122 + } 123 + } 124 + }; 125 + 126 + poll(); 127 + } 128 + 129 + stopPolling(): void { 130 + this.isPolling = false; 131 + } 132 + 133 + private async getAllActiveSubscriptions(): Promise<SocialMediaSubscription[]> { 134 + const result = await this.pool.query(`SELECT * FROM server_social_subscriptions`); 135 + return result.rows.map(this.mapDbToSubscription); 136 + } 137 + 138 + private async updateLastPost( 139 + subscriptionId: number, 140 + postUri: string, 141 + postTimestamp: Date, 142 + ): Promise<void> { 143 + await this.pool.query( 144 + `UPDATE server_social_subscriptions 145 + SET last_post_uri = $1, last_post_timestamp = $2 146 + WHERE id = $3`, 147 + [postUri, postTimestamp, subscriptionId], 148 + ); 149 + } 150 + 151 + private isNewerPost(post: SocialMediaPost, subscription: SocialMediaSubscription): boolean { 152 + if (!subscription.lastPostTimestamp) return true; 153 + return post.timestamp > subscription.lastPostTimestamp; 154 + } 155 + 156 + private mapDbToSubscription(row: { 157 + id: number; 158 + guild_id: string; 159 + platform: SocialPlatform; 160 + account_handle: string; 161 + last_post_uri: string | null; 162 + last_post_timestamp: Date | null; 163 + channel_id: string; 164 + created_at: Date; 165 + updated_at: Date; 166 + }): SocialMediaSubscription { 167 + return { 168 + id: row.id, 169 + guildId: row.guild_id, 170 + platform: row.platform as SocialPlatform, 171 + accountHandle: row.account_handle, 172 + lastPostUri: row.last_post_uri ?? undefined, 173 + lastPostTimestamp: (row.last_post_timestamp as Date | null) ?? undefined, 174 + channelId: row.channel_id, 175 + createdAt: row.created_at, 176 + updatedAt: row.updated_at, 177 + }; 178 + } 179 + }
+262
src/services/social/fetchers/UnifiedFetcher.ts
··· 1 + import { SocialMediaFetcher, SocialMediaPost, SocialPlatform } from '../../../types/social'; 2 + import { HandleResolver } from '@atproto/identity'; 3 + 4 + interface BlueskyPost { 5 + uri: string; 6 + cid: string; 7 + author: { 8 + did: string; 9 + handle: string; 10 + displayName?: string; 11 + }; 12 + record: { 13 + text: string; 14 + createdAt: string; 15 + }; 16 + embed?: { 17 + $type: string; 18 + images?: Array<{ 19 + thumb: string; 20 + fullsize: string; 21 + alt: string; 22 + }>; 23 + }; 24 + } 25 + 26 + interface BlueskyFeedItem { 27 + post?: BlueskyPost; 28 + } 29 + 30 + export class BlueskyFetcher implements SocialMediaFetcher { 31 + platform: SocialPlatform = 'bluesky'; 32 + private readonly baseUrl = 'https://public.api.bsky.app'; 33 + private readonly handleResolver = new HandleResolver(); 34 + 35 + async fetchLatestPost(account: string): Promise<SocialMediaPost | null> { 36 + const actor = await this.resolveActor(account); 37 + const url = `${this.baseUrl}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(actor)}&limit=1`; 38 + 39 + try { 40 + const response = await fetch(url); 41 + if (!response.ok) { 42 + throw new Error(`HTTP error! status: ${response.status}`); 43 + } 44 + 45 + const data = await response.json(); 46 + const items = (data?.feed as BlueskyFeedItem[]) || []; 47 + if (!Array.isArray(items) || items.length === 0) return null; 48 + 49 + const post = items[0]?.post; 50 + if (!post || !post.record) return null; 51 + 52 + const actorId = post.author?.did || actor; 53 + const profile = await this.fetchBlueskyProfile(actorId); 54 + const avatarUrl = profile?.avatar ?? null; 55 + const displayName = profile?.displayName ?? post.author?.displayName; 56 + 57 + return this.mapToSocialMediaPost(post, avatarUrl || undefined, displayName); 58 + } catch (error: unknown) { 59 + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 60 + console.error('Error fetching Bluesky post:', error); 61 + throw new Error(`Failed to fetch post from Bluesky: ${errorMessage}`); 62 + } 63 + } 64 + 65 + isValidAccount(account: string): boolean { 66 + if (!account) return false; 67 + const handle = this.normalizeHandle(account); 68 + return /^[a-zA-Z0-9.-]+(\.[a-zA-Z0-9.-]+)*$/.test(handle); 69 + } 70 + 71 + private normalizeHandle(handle: string): string { 72 + handle = handle.startsWith('@') ? handle.slice(1) : handle; 73 + if (!handle.includes('.')) { 74 + return `${handle}.bsky.social`; 75 + } 76 + return handle; 77 + } 78 + 79 + private async resolveActor(account: string): Promise<string> { 80 + const normalized = this.normalizeHandle(account); 81 + try { 82 + const did = await this.handleResolver.resolve(normalized); 83 + if (typeof did === 'string' && did.startsWith('did:')) { 84 + return did; 85 + } 86 + } catch (_error) { 87 + void 0; 88 + } 89 + return normalized; 90 + } 91 + 92 + private mapToSocialMediaPost( 93 + post: BlueskyPost, 94 + authorAvatarUrl?: string, 95 + authorDisplayName?: string, 96 + ): SocialMediaPost { 97 + const mediaUrls: string[] = []; 98 + 99 + if (post.embed?.$type === 'app.bsky.embed.images#view' && post.embed.images) { 100 + mediaUrls.push(...post.embed.images.map((img) => img.fullsize)); 101 + } 102 + 103 + const text = post.record?.text ?? ''; 104 + const createdAt = post.record?.createdAt ?? new Date().toISOString(); 105 + const author = post.author?.handle ?? 'unknown'; 106 + 107 + return { 108 + uri: post.uri, 109 + text, 110 + author, 111 + timestamp: new Date(createdAt), 112 + platform: 'bluesky', 113 + mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, 114 + authorAvatarUrl, 115 + authorDisplayName, 116 + }; 117 + } 118 + 119 + private async fetchBlueskyProfile( 120 + actor: string, 121 + ): Promise<{ avatar?: string; displayName?: string } | null> { 122 + try { 123 + const url = `${this.baseUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`; 124 + const res = await fetch(url); 125 + if (!res.ok) return null; 126 + const data = await res.json(); 127 + const avatar = typeof data?.avatar === 'string' ? data.avatar : undefined; 128 + const displayName = typeof data?.displayName === 'string' ? data.displayName : undefined; 129 + return { avatar, displayName }; 130 + } catch { 131 + return null; 132 + } 133 + } 134 + } 135 + 136 + interface FediverseAccount { 137 + username: string; 138 + acct: string; 139 + display_name: string; 140 + avatar: string; 141 + } 142 + 143 + interface FediverseAttachment { 144 + id: string; 145 + type: 'image' | 'video' | 'gifv' | 'audio' | 'unknown'; 146 + url: string; 147 + preview_url: string; 148 + description?: string; 149 + } 150 + 151 + interface FediversePost { 152 + id: string; 153 + uri: string; 154 + url: string; 155 + in_reply_to_id: string | null; 156 + in_reply_to_account_id: string | null; 157 + account: FediverseAccount; 158 + content: string; 159 + created_at: string; 160 + reblogs_count: number; 161 + favourites_count: number; 162 + reblogged: boolean; 163 + favourited: boolean; 164 + sensitive: boolean; 165 + spoiler_text: string; 166 + visibility: 'public' | 'unlisted' | 'private' | 'direct'; 167 + media_attachments: FediverseAttachment[]; 168 + mentions: Array<{ 169 + id: string; 170 + username: string; 171 + url: string; 172 + acct: string; 173 + }>; 174 + tags: Array<{ name: string; url: string }>; 175 + application?: { 176 + name: string; 177 + website?: string; 178 + }; 179 + language: string | null; 180 + reblog: FediversePost | null; 181 + } 182 + 183 + export class FediverseFetcher implements SocialMediaFetcher { 184 + platform: SocialPlatform = 'fediverse'; 185 + 186 + async fetchLatestPost(account: string): Promise<SocialMediaPost | null> { 187 + const [username, domain] = this.parseAccount(account); 188 + if (!domain) { 189 + throw new Error('Fediverse account must include a domain (e.g., user@instance.social)'); 190 + } 191 + 192 + const apiUrl = `https://${domain}/api/v1/accounts/lookup?acct=${username}@${domain}`; 193 + 194 + try { 195 + const accountResponse = await fetch(apiUrl); 196 + if (!accountResponse.ok) { 197 + throw new Error(`Failed to fetch account: ${accountResponse.statusText}`); 198 + } 199 + 200 + const accountData = await accountResponse.json(); 201 + const accountId = accountData.id; 202 + 203 + const statusesUrl = `https://${domain}/api/v1/accounts/${accountId}/statuses?limit=1&exclude_replies=true&exclude_reblogs=true`; 204 + const statusResponse = await fetch(statusesUrl); 205 + 206 + if (!statusResponse.ok) { 207 + throw new Error(`Failed to fetch statuses: ${statusResponse.statusText}`); 208 + } 209 + 210 + const statuses = (await statusResponse.json()) as FediversePost[]; 211 + if (!statuses || statuses.length === 0) { 212 + return null; 213 + } 214 + 215 + return this.mapToSocialMediaPost(statuses[0], domain); 216 + } catch (error: unknown) { 217 + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 218 + console.error('Error fetching Fediverse post:', error); 219 + throw new Error(`Failed to fetch post from Fediverse: ${errorMessage}`); 220 + } 221 + } 222 + 223 + isValidAccount(account: string): boolean { 224 + if (!account) return false; 225 + const parts = account.split('@').filter(Boolean); 226 + return parts.length >= 2 && parts.every((part) => part.length > 0); 227 + } 228 + 229 + private parseAccount(account: string): [string, string | null] { 230 + const cleanAccount = account.startsWith('@') ? account.slice(1) : account; 231 + const parts = cleanAccount.split('@'); 232 + 233 + if (parts.length < 2) { 234 + return [cleanAccount, null]; 235 + } 236 + 237 + const username = parts[0]; 238 + const domain = parts[1]; 239 + return [username, domain]; 240 + } 241 + 242 + private mapToSocialMediaPost(post: FediversePost, domain: string): SocialMediaPost { 243 + if (post.reblog) { 244 + return this.mapToSocialMediaPost(post.reblog, domain); 245 + } 246 + 247 + const mediaUrls = post.media_attachments 248 + .filter((media) => media.type === 'image' || media.type === 'gifv') 249 + .map((media) => media.url); 250 + 251 + return { 252 + uri: post.uri, 253 + text: post.content, 254 + author: `${post.account.acct}@${domain}`, 255 + timestamp: new Date(post.created_at), 256 + platform: 'fediverse', 257 + mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, 258 + authorAvatarUrl: post.account.avatar, 259 + authorDisplayName: post.account.display_name, 260 + }; 261 + } 262 + }
+30
src/types/social.ts
··· 1 + export type SocialPlatform = 'bluesky' | 'fediverse'; 2 + 3 + export interface SocialMediaSubscription { 4 + id: number; 5 + guildId: string; 6 + platform: SocialPlatform; 7 + accountHandle: string; 8 + lastPostUri?: string; 9 + lastPostTimestamp?: Date; 10 + channelId: string; 11 + createdAt: Date; 12 + updatedAt: Date; 13 + } 14 + 15 + export interface SocialMediaPost { 16 + uri: string; 17 + text: string; 18 + author: string; 19 + timestamp: Date; 20 + platform: SocialPlatform; 21 + mediaUrls?: string[]; 22 + authorAvatarUrl?: string; 23 + authorDisplayName?: string; 24 + } 25 + 26 + export interface SocialMediaFetcher { 27 + platform: SocialPlatform; 28 + fetchLatestPost(account: string): Promise<SocialMediaPost | null>; 29 + isValidAccount(account: string): boolean; 30 + }