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

Configure Feed

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

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

New Features:
- Introduced a new /social command for Discord servers to manage Fediverse and Bluesky account tracking.
- Enabled adding, removing, listing, and refreshing social media subscriptions with notifications in designated channels.
- Automated periodic checks for new social media posts with timely notifications.
- Added comprehensive localization strings for social media commands and messages.
- Implemented social media integration supporting Bluesky and Fediverse platforms with real-time post fetching and notifications.
- Added database support for storing social media subscriptions and tracking updates.
- Unified conversation and user identification keys for improved context management and scoping of user data and limits.

Chores:
- Updated dependencies and package version for improved stability and feature support.
- Improved environment variable handling in production Docker setup.

Bug Fixes:
- Enhanced validation and error handling for social media account management and notification delivery.

authored by

Scan and committed by
GitHub
029f238e c75ef3cb

+1529 -193
+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": {
+32
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 NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP 13 + ); 14 + 15 + CREATE INDEX IF NOT EXISTS idx_server_social_subscriptions_guild ON server_social_subscriptions(guild_id); 16 + CREATE INDEX IF NOT EXISTS idx_server_social_subscriptions_platform ON server_social_subscriptions(platform); 17 + 18 + CREATE UNIQUE INDEX IF NOT EXISTS uniq_server_social_subscriptions_ci 19 + ON server_social_subscriptions (guild_id, platform, lower(account_handle)); 20 + 21 + CREATE OR REPLACE FUNCTION update_updated_at_column() 22 + RETURNS TRIGGER AS $$ 23 + BEGIN 24 + NEW.updated_at = NOW(); 25 + RETURN NEW; 26 + END; 27 + $$ LANGUAGE plpgsql; 28 + 29 + CREATE TRIGGER update_server_social_subscriptions_updated_at 30 + BEFORE UPDATE ON server_social_subscriptions 31 + FOR EACH ROW 32 + EXECUTE FUNCTION update_updated_at_column();
+9 -8
package.json
··· 1 1 { 2 2 "name": "aethel", 3 - "version": "2.0.0-beta", 3 + "version": "2.0.0-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", 22 23 "axios": "^1.11.0", 23 24 "city-timezones": "^1.3.1", 24 25 "cors": "^2.8.5", 25 26 "discord.js": "^14.21.0", 26 27 "dotenv": "^16.6.1", 27 - "eslint-plugin-prettier": "^5.5.3", 28 + "eslint-plugin-prettier": "^5.5.4", 28 29 "express": "^4.21.2", 29 30 "express-rate-limit": "^7.5.1", 30 31 "express-validator": "^7.2.1", ··· 32 33 "jsonwebtoken": "^9.0.2", 33 34 "moment-timezone": "^0.6.0", 34 35 "node-fetch": "^3.3.2", 35 - "openai": "^5.12.0", 36 + "openai": "^5.12.2", 36 37 "pg": "^8.16.3", 37 38 "uuid": "^11.1.0", 38 39 "validator": "^13.15.15", ··· 40 41 "winston": "^3.17.0" 41 42 }, 42 43 "devDependencies": { 43 - "@eslint/js": "^9.32.0", 44 + "@eslint/js": "^9.33.0", 44 45 "@types/cors": "^2.8.19", 45 46 "@types/express": "^4.17.23", 46 47 "@types/jsonwebtoken": "^9.0.10", 47 - "@types/node": "^24.1.0", 48 + "@types/node": "^24.2.1", 48 49 "@types/pg": "^8.15.5", 49 50 "@types/uuid": "^10.0.0", 50 51 "@types/validator": "^13.15.2", 51 52 "@types/whois-json": "^2.0.4", 52 - "eslint": "^9.32.0", 53 + "eslint": "^9.33.0", 53 54 "eslint-config-prettier": "^10.1.8", 54 55 "globals": "^16.3.0", 55 56 "nodemon": "^3.1.10", ··· 57 58 "tsc-alias": "^1.8.16", 58 59 "tsconfig-paths": "^4.2.0", 59 60 "tsx": "^4.20.3", 60 - "typescript": "^5.8.3", 61 - "typescript-eslint": "^8.38.0" 61 + "typescript": "^5.9.2", 62 + "typescript-eslint": "^8.39.0" 62 63 } 63 64 }
+220 -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 ··· 27 30 specifier: ^16.6.1 28 31 version: 16.6.1 29 32 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) 33 + specifier: ^5.5.4 34 + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.33.0))(eslint@9.33.0)(prettier@3.6.2) 32 35 express: 33 36 specifier: ^4.21.2 34 37 version: 4.21.2 ··· 51 54 specifier: ^3.3.2 52 55 version: 3.3.2 53 56 openai: 54 - specifier: ^5.12.0 55 - version: 5.12.0(ws@8.18.3) 57 + specifier: ^5.12.2 58 + version: 5.12.2(ws@8.18.3)(zod@3.25.76) 56 59 pg: 57 60 specifier: ^8.16.3 58 61 version: 8.16.3 ··· 70 73 version: 3.17.0 71 74 devDependencies: 72 75 '@eslint/js': 73 - specifier: ^9.32.0 74 - version: 9.32.0 76 + specifier: ^9.33.0 77 + version: 9.33.0 75 78 '@types/cors': 76 79 specifier: ^2.8.19 77 80 version: 2.8.19 ··· 82 85 specifier: ^9.0.10 83 86 version: 9.0.10 84 87 '@types/node': 85 - specifier: ^24.1.0 86 - version: 24.1.0 88 + specifier: ^24.2.1 89 + version: 24.2.1 87 90 '@types/pg': 88 91 specifier: ^8.15.5 89 92 version: 8.15.5 ··· 97 100 specifier: ^2.0.4 98 101 version: 2.0.4 99 102 eslint: 100 - specifier: ^9.32.0 101 - version: 9.32.0 103 + specifier: ^9.33.0 104 + version: 9.33.0 102 105 eslint-config-prettier: 103 106 specifier: ^10.1.8 104 - version: 10.1.8(eslint@9.32.0) 107 + version: 10.1.8(eslint@9.33.0) 105 108 globals: 106 109 specifier: ^16.3.0 107 110 version: 16.3.0 ··· 121 124 specifier: ^4.20.3 122 125 version: 4.20.3 123 126 typescript: 124 - specifier: ^5.8.3 125 - version: 5.8.3 127 + specifier: ^5.9.2 128 + version: 5.9.2 126 129 typescript-eslint: 127 - specifier: ^8.38.0 128 - version: 8.38.0(eslint@9.32.0)(typescript@5.8.3) 130 + specifier: ^8.39.0 131 + version: 8.39.0(eslint@9.33.0)(typescript@5.9.2) 129 132 130 133 packages: 134 + 135 + '@atproto/common-web@0.4.2': 136 + resolution: {integrity: sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==} 137 + 138 + '@atproto/crypto@0.4.4': 139 + resolution: {integrity: sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==} 140 + engines: {node: '>=18.7.0'} 141 + 142 + '@atproto/identity@0.4.8': 143 + resolution: {integrity: sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA==} 144 + engines: {node: '>=18.7.0'} 131 145 132 146 '@colors/colors@1.6.0': 133 147 resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} ··· 334 348 resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} 335 349 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 336 350 337 - '@eslint/config-helpers@0.3.0': 338 - resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==} 351 + '@eslint/config-helpers@0.3.1': 352 + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} 339 353 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 340 354 341 - '@eslint/core@0.15.1': 342 - resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==} 355 + '@eslint/core@0.15.2': 356 + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} 343 357 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 344 358 345 359 '@eslint/eslintrc@3.3.1': 346 360 resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} 347 361 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 348 362 349 - '@eslint/js@9.32.0': 350 - resolution: {integrity: sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==} 363 + '@eslint/js@9.33.0': 364 + resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==} 351 365 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 352 366 353 367 '@eslint/object-schema@2.1.6': 354 368 resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} 355 369 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 356 370 357 - '@eslint/plugin-kit@0.3.4': 358 - resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==} 371 + '@eslint/plugin-kit@0.3.5': 372 + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} 359 373 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 360 374 361 375 '@humanfs/core@0.19.1': ··· 377 391 '@humanwhocodes/retry@0.4.3': 378 392 resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} 379 393 engines: {node: '>=18.18'} 394 + 395 + '@noble/curves@1.9.6': 396 + resolution: {integrity: sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==} 397 + engines: {node: ^14.21.3 || >=16} 398 + 399 + '@noble/hashes@1.8.0': 400 + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} 401 + engines: {node: ^14.21.3 || >=16} 380 402 381 403 '@nodelib/fs.scandir@2.1.5': 382 404 resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} ··· 443 465 '@types/ms@2.1.0': 444 466 resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} 445 467 446 - '@types/node@24.1.0': 447 - resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==} 468 + '@types/node@24.2.1': 469 + resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==} 448 470 449 471 '@types/pg@8.15.5': 450 472 resolution: {integrity: sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==} ··· 476 498 '@types/ws@8.18.1': 477 499 resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} 478 500 479 - '@typescript-eslint/eslint-plugin@8.38.0': 480 - resolution: {integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==} 501 + '@typescript-eslint/eslint-plugin@8.39.0': 502 + resolution: {integrity: sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==} 481 503 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 482 504 peerDependencies: 483 - '@typescript-eslint/parser': ^8.38.0 505 + '@typescript-eslint/parser': ^8.39.0 484 506 eslint: ^8.57.0 || ^9.0.0 485 - typescript: '>=4.8.4 <5.9.0' 507 + typescript: '>=4.8.4 <6.0.0' 486 508 487 - '@typescript-eslint/parser@8.38.0': 488 - resolution: {integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==} 509 + '@typescript-eslint/parser@8.39.0': 510 + resolution: {integrity: sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==} 489 511 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 490 512 peerDependencies: 491 513 eslint: ^8.57.0 || ^9.0.0 492 - typescript: '>=4.8.4 <5.9.0' 514 + typescript: '>=4.8.4 <6.0.0' 493 515 494 - '@typescript-eslint/project-service@8.38.0': 495 - resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==} 516 + '@typescript-eslint/project-service@8.39.0': 517 + resolution: {integrity: sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==} 496 518 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 497 519 peerDependencies: 498 - typescript: '>=4.8.4 <5.9.0' 520 + typescript: '>=4.8.4 <6.0.0' 499 521 500 - '@typescript-eslint/scope-manager@8.38.0': 501 - resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} 522 + '@typescript-eslint/scope-manager@8.39.0': 523 + resolution: {integrity: sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==} 502 524 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 503 525 504 - '@typescript-eslint/tsconfig-utils@8.38.0': 505 - resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==} 526 + '@typescript-eslint/tsconfig-utils@8.39.0': 527 + resolution: {integrity: sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==} 506 528 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 507 529 peerDependencies: 508 - typescript: '>=4.8.4 <5.9.0' 530 + typescript: '>=4.8.4 <6.0.0' 509 531 510 - '@typescript-eslint/type-utils@8.38.0': 511 - resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==} 532 + '@typescript-eslint/type-utils@8.39.0': 533 + resolution: {integrity: sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==} 512 534 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 513 535 peerDependencies: 514 536 eslint: ^8.57.0 || ^9.0.0 515 - typescript: '>=4.8.4 <5.9.0' 537 + typescript: '>=4.8.4 <6.0.0' 516 538 517 - '@typescript-eslint/types@8.38.0': 518 - resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} 539 + '@typescript-eslint/types@8.39.0': 540 + resolution: {integrity: sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==} 519 541 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 520 542 521 - '@typescript-eslint/typescript-estree@8.38.0': 522 - resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} 543 + '@typescript-eslint/typescript-estree@8.39.0': 544 + resolution: {integrity: sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==} 523 545 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 524 546 peerDependencies: 525 - typescript: '>=4.8.4 <5.9.0' 547 + typescript: '>=4.8.4 <6.0.0' 526 548 527 - '@typescript-eslint/utils@8.38.0': 528 - resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==} 549 + '@typescript-eslint/utils@8.39.0': 550 + resolution: {integrity: sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==} 529 551 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 530 552 peerDependencies: 531 553 eslint: ^8.57.0 || ^9.0.0 532 - typescript: '>=4.8.4 <5.9.0' 554 + typescript: '>=4.8.4 <6.0.0' 533 555 534 - '@typescript-eslint/visitor-keys@8.38.0': 535 - resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} 556 + '@typescript-eslint/visitor-keys@8.39.0': 557 + resolution: {integrity: sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==} 536 558 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 537 559 538 560 '@vladfrangu/async_event_emitter@2.4.6': ··· 829 851 peerDependencies: 830 852 eslint: '>=7.0.0' 831 853 832 - eslint-plugin-prettier@5.5.3: 833 - resolution: {integrity: sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==} 854 + eslint-plugin-prettier@5.5.4: 855 + resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} 834 856 engines: {node: ^14.18.0 || >=16.0.0} 835 857 peerDependencies: 836 858 '@types/eslint': '>=8.0.0' ··· 855 877 resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} 856 878 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 857 879 858 - eslint@9.32.0: 859 - resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==} 880 + eslint@9.33.0: 881 + resolution: {integrity: sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==} 860 882 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 861 883 hasBin: true 862 884 peerDependencies: ··· 1288 1310 ms@2.1.3: 1289 1311 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 1290 1312 1313 + multiformats@9.9.0: 1314 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 1315 + 1291 1316 mylas@2.1.13: 1292 1317 resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} 1293 1318 engines: {node: '>=12.0.0'} ··· 1335 1360 one-time@1.0.0: 1336 1361 resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} 1337 1362 1338 - openai@5.12.0: 1339 - resolution: {integrity: sha512-vUdt02xiWgOHiYUmW0Hj1Qu9OKAiVQu5Bd547ktVCiMKC1BkB5L3ImeEnCyq3WpRKR6ZTaPgekzqdozwdPs7Lg==} 1363 + openai@5.12.2: 1364 + resolution: {integrity: sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==} 1340 1365 hasBin: true 1341 1366 peerDependencies: 1342 1367 ws: ^8.18.0 ··· 1718 1743 resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} 1719 1744 engines: {node: '>= 0.6'} 1720 1745 1721 - typescript-eslint@8.38.0: 1722 - resolution: {integrity: sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==} 1746 + typescript-eslint@8.39.0: 1747 + resolution: {integrity: sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==} 1723 1748 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 1724 1749 peerDependencies: 1725 1750 eslint: ^8.57.0 || ^9.0.0 1726 - typescript: '>=4.8.4 <5.9.0' 1751 + typescript: '>=4.8.4 <6.0.0' 1727 1752 1728 - typescript@5.8.3: 1729 - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} 1753 + typescript@5.9.2: 1754 + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} 1730 1755 engines: {node: '>=14.17'} 1731 1756 hasBin: true 1757 + 1758 + uint8arrays@3.0.0: 1759 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 1732 1760 1733 1761 undefsafe@2.0.5: 1734 1762 resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} ··· 1736 1764 underscore@1.13.7: 1737 1765 resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} 1738 1766 1739 - undici-types@7.8.0: 1740 - resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} 1767 + undici-types@7.10.0: 1768 + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} 1741 1769 1742 1770 undici@6.21.3: 1743 1771 resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} ··· 1845 1873 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} 1846 1874 engines: {node: '>=10'} 1847 1875 1876 + zod@3.25.76: 1877 + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 1878 + 1848 1879 snapshots: 1849 1880 1881 + '@atproto/common-web@0.4.2': 1882 + dependencies: 1883 + graphemer: 1.4.0 1884 + multiformats: 9.9.0 1885 + uint8arrays: 3.0.0 1886 + zod: 3.25.76 1887 + 1888 + '@atproto/crypto@0.4.4': 1889 + dependencies: 1890 + '@noble/curves': 1.9.6 1891 + '@noble/hashes': 1.8.0 1892 + uint8arrays: 3.0.0 1893 + 1894 + '@atproto/identity@0.4.8': 1895 + dependencies: 1896 + '@atproto/common-web': 0.4.2 1897 + '@atproto/crypto': 0.4.4 1898 + 1850 1899 '@colors/colors@1.6.0': {} 1851 1900 1852 1901 '@dabh/diagnostics@2.0.3': ··· 1980 2029 '@esbuild/win32-x64@0.25.8': 1981 2030 optional: true 1982 2031 1983 - '@eslint-community/eslint-utils@4.7.0(eslint@9.32.0)': 2032 + '@eslint-community/eslint-utils@4.7.0(eslint@9.33.0)': 1984 2033 dependencies: 1985 - eslint: 9.32.0 2034 + eslint: 9.33.0 1986 2035 eslint-visitor-keys: 3.4.3 1987 2036 1988 2037 '@eslint-community/regexpp@4.12.1': {} ··· 1995 2044 transitivePeerDependencies: 1996 2045 - supports-color 1997 2046 1998 - '@eslint/config-helpers@0.3.0': {} 2047 + '@eslint/config-helpers@0.3.1': {} 1999 2048 2000 - '@eslint/core@0.15.1': 2049 + '@eslint/core@0.15.2': 2001 2050 dependencies: 2002 2051 '@types/json-schema': 7.0.15 2003 2052 ··· 2015 2064 transitivePeerDependencies: 2016 2065 - supports-color 2017 2066 2018 - '@eslint/js@9.32.0': {} 2067 + '@eslint/js@9.33.0': {} 2019 2068 2020 2069 '@eslint/object-schema@2.1.6': {} 2021 2070 2022 - '@eslint/plugin-kit@0.3.4': 2071 + '@eslint/plugin-kit@0.3.5': 2023 2072 dependencies: 2024 - '@eslint/core': 0.15.1 2073 + '@eslint/core': 0.15.2 2025 2074 levn: 0.4.1 2026 2075 2027 2076 '@humanfs/core@0.19.1': {} ··· 2037 2086 2038 2087 '@humanwhocodes/retry@0.4.3': {} 2039 2088 2089 + '@noble/curves@1.9.6': 2090 + dependencies: 2091 + '@noble/hashes': 1.8.0 2092 + 2093 + '@noble/hashes@1.8.0': {} 2094 + 2040 2095 '@nodelib/fs.scandir@2.1.5': 2041 2096 dependencies: 2042 2097 '@nodelib/fs.stat': 2.0.5 ··· 2065 2120 '@types/body-parser@1.19.6': 2066 2121 dependencies: 2067 2122 '@types/connect': 3.4.38 2068 - '@types/node': 24.1.0 2123 + '@types/node': 24.2.1 2069 2124 2070 2125 '@types/connect@3.4.38': 2071 2126 dependencies: 2072 - '@types/node': 24.1.0 2127 + '@types/node': 24.2.1 2073 2128 2074 2129 '@types/cors@2.8.19': 2075 2130 dependencies: 2076 - '@types/node': 24.1.0 2131 + '@types/node': 24.2.1 2077 2132 2078 2133 '@types/estree@1.0.8': {} 2079 2134 2080 2135 '@types/express-serve-static-core@4.19.6': 2081 2136 dependencies: 2082 - '@types/node': 24.1.0 2137 + '@types/node': 24.2.1 2083 2138 '@types/qs': 6.14.0 2084 2139 '@types/range-parser': 1.2.7 2085 2140 '@types/send': 0.17.5 ··· 2098 2153 '@types/jsonwebtoken@9.0.10': 2099 2154 dependencies: 2100 2155 '@types/ms': 2.1.0 2101 - '@types/node': 24.1.0 2156 + '@types/node': 24.2.1 2102 2157 2103 2158 '@types/mime@1.3.5': {} 2104 2159 2105 2160 '@types/ms@2.1.0': {} 2106 2161 2107 - '@types/node@24.1.0': 2162 + '@types/node@24.2.1': 2108 2163 dependencies: 2109 - undici-types: 7.8.0 2164 + undici-types: 7.10.0 2110 2165 2111 2166 '@types/pg@8.15.5': 2112 2167 dependencies: 2113 - '@types/node': 24.1.0 2168 + '@types/node': 24.2.1 2114 2169 pg-protocol: 1.10.3 2115 2170 pg-types: 2.2.0 2116 2171 ··· 2121 2176 '@types/send@0.17.5': 2122 2177 dependencies: 2123 2178 '@types/mime': 1.3.5 2124 - '@types/node': 24.1.0 2179 + '@types/node': 24.2.1 2125 2180 2126 2181 '@types/serve-static@1.15.8': 2127 2182 dependencies: 2128 2183 '@types/http-errors': 2.0.5 2129 - '@types/node': 24.1.0 2184 + '@types/node': 24.2.1 2130 2185 '@types/send': 0.17.5 2131 2186 2132 2187 '@types/triple-beam@1.3.5': {} ··· 2139 2194 2140 2195 '@types/ws@8.18.1': 2141 2196 dependencies: 2142 - '@types/node': 24.1.0 2197 + '@types/node': 24.2.1 2143 2198 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)': 2199 + '@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 2200 dependencies: 2146 2201 '@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 2202 + '@typescript-eslint/parser': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 2203 + '@typescript-eslint/scope-manager': 8.39.0 2204 + '@typescript-eslint/type-utils': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 2205 + '@typescript-eslint/utils': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 2206 + '@typescript-eslint/visitor-keys': 8.39.0 2207 + eslint: 9.33.0 2153 2208 graphemer: 1.4.0 2154 2209 ignore: 7.0.5 2155 2210 natural-compare: 1.4.0 2156 - ts-api-utils: 2.1.0(typescript@5.8.3) 2157 - typescript: 5.8.3 2211 + ts-api-utils: 2.1.0(typescript@5.9.2) 2212 + typescript: 5.9.2 2158 2213 transitivePeerDependencies: 2159 2214 - supports-color 2160 2215 2161 - '@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.8.3)': 2216 + '@typescript-eslint/parser@8.39.0(eslint@9.33.0)(typescript@5.9.2)': 2162 2217 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 2218 + '@typescript-eslint/scope-manager': 8.39.0 2219 + '@typescript-eslint/types': 8.39.0 2220 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) 2221 + '@typescript-eslint/visitor-keys': 8.39.0 2167 2222 debug: 4.4.1(supports-color@5.5.0) 2168 - eslint: 9.32.0 2169 - typescript: 5.8.3 2223 + eslint: 9.33.0 2224 + typescript: 5.9.2 2170 2225 transitivePeerDependencies: 2171 2226 - supports-color 2172 2227 2173 - '@typescript-eslint/project-service@8.38.0(typescript@5.8.3)': 2228 + '@typescript-eslint/project-service@8.39.0(typescript@5.9.2)': 2174 2229 dependencies: 2175 - '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) 2176 - '@typescript-eslint/types': 8.38.0 2230 + '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.9.2) 2231 + '@typescript-eslint/types': 8.39.0 2177 2232 debug: 4.4.1(supports-color@5.5.0) 2178 - typescript: 5.8.3 2233 + typescript: 5.9.2 2179 2234 transitivePeerDependencies: 2180 2235 - supports-color 2181 2236 2182 - '@typescript-eslint/scope-manager@8.38.0': 2237 + '@typescript-eslint/scope-manager@8.39.0': 2183 2238 dependencies: 2184 - '@typescript-eslint/types': 8.38.0 2185 - '@typescript-eslint/visitor-keys': 8.38.0 2239 + '@typescript-eslint/types': 8.39.0 2240 + '@typescript-eslint/visitor-keys': 8.39.0 2186 2241 2187 - '@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.8.3)': 2242 + '@typescript-eslint/tsconfig-utils@8.39.0(typescript@5.9.2)': 2188 2243 dependencies: 2189 - typescript: 5.8.3 2244 + typescript: 5.9.2 2190 2245 2191 - '@typescript-eslint/type-utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)': 2246 + '@typescript-eslint/type-utils@8.39.0(eslint@9.33.0)(typescript@5.9.2)': 2192 2247 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) 2248 + '@typescript-eslint/types': 8.39.0 2249 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) 2250 + '@typescript-eslint/utils': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 2196 2251 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 2252 + eslint: 9.33.0 2253 + ts-api-utils: 2.1.0(typescript@5.9.2) 2254 + typescript: 5.9.2 2200 2255 transitivePeerDependencies: 2201 2256 - supports-color 2202 2257 2203 - '@typescript-eslint/types@8.38.0': {} 2258 + '@typescript-eslint/types@8.39.0': {} 2204 2259 2205 - '@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)': 2260 + '@typescript-eslint/typescript-estree@8.39.0(typescript@5.9.2)': 2206 2261 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 2262 + '@typescript-eslint/project-service': 8.39.0(typescript@5.9.2) 2263 + '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.9.2) 2264 + '@typescript-eslint/types': 8.39.0 2265 + '@typescript-eslint/visitor-keys': 8.39.0 2211 2266 debug: 4.4.1(supports-color@5.5.0) 2212 2267 fast-glob: 3.3.3 2213 2268 is-glob: 4.0.3 2214 2269 minimatch: 9.0.5 2215 2270 semver: 7.7.2 2216 - ts-api-utils: 2.1.0(typescript@5.8.3) 2217 - typescript: 5.8.3 2271 + ts-api-utils: 2.1.0(typescript@5.9.2) 2272 + typescript: 5.9.2 2218 2273 transitivePeerDependencies: 2219 2274 - supports-color 2220 2275 2221 - '@typescript-eslint/utils@8.38.0(eslint@9.32.0)(typescript@5.8.3)': 2276 + '@typescript-eslint/utils@8.39.0(eslint@9.33.0)(typescript@5.9.2)': 2222 2277 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 2278 + '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0) 2279 + '@typescript-eslint/scope-manager': 8.39.0 2280 + '@typescript-eslint/types': 8.39.0 2281 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) 2282 + eslint: 9.33.0 2283 + typescript: 5.9.2 2229 2284 transitivePeerDependencies: 2230 2285 - supports-color 2231 2286 2232 - '@typescript-eslint/visitor-keys@8.38.0': 2287 + '@typescript-eslint/visitor-keys@8.39.0': 2233 2288 dependencies: 2234 - '@typescript-eslint/types': 8.38.0 2289 + '@typescript-eslint/types': 8.39.0 2235 2290 eslint-visitor-keys: 4.2.1 2236 2291 2237 2292 '@vladfrangu/async_event_emitter@2.4.6': {} ··· 2572 2627 2573 2628 escape-string-regexp@4.0.0: {} 2574 2629 2575 - eslint-config-prettier@10.1.8(eslint@9.32.0): 2630 + eslint-config-prettier@10.1.8(eslint@9.33.0): 2576 2631 dependencies: 2577 - eslint: 9.32.0 2632 + eslint: 9.33.0 2578 2633 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): 2634 + 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 2635 dependencies: 2581 - eslint: 9.32.0 2636 + eslint: 9.33.0 2582 2637 prettier: 3.6.2 2583 2638 prettier-linter-helpers: 1.0.0 2584 2639 synckit: 0.11.11 2585 2640 optionalDependencies: 2586 - eslint-config-prettier: 10.1.8(eslint@9.32.0) 2641 + eslint-config-prettier: 10.1.8(eslint@9.33.0) 2587 2642 2588 2643 eslint-scope@8.4.0: 2589 2644 dependencies: ··· 2594 2649 2595 2650 eslint-visitor-keys@4.2.1: {} 2596 2651 2597 - eslint@9.32.0: 2652 + eslint@9.33.0: 2598 2653 dependencies: 2599 - '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) 2654 + '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0) 2600 2655 '@eslint-community/regexpp': 4.12.1 2601 2656 '@eslint/config-array': 0.21.0 2602 - '@eslint/config-helpers': 0.3.0 2603 - '@eslint/core': 0.15.1 2657 + '@eslint/config-helpers': 0.3.1 2658 + '@eslint/core': 0.15.2 2604 2659 '@eslint/eslintrc': 3.3.1 2605 - '@eslint/js': 9.32.0 2606 - '@eslint/plugin-kit': 0.3.4 2660 + '@eslint/js': 9.33.0 2661 + '@eslint/plugin-kit': 0.3.5 2607 2662 '@humanfs/node': 0.16.6 2608 2663 '@humanwhocodes/module-importer': 1.0.1 2609 2664 '@humanwhocodes/retry': 0.4.3 ··· 3059 3114 3060 3115 ms@2.1.3: {} 3061 3116 3117 + multiformats@9.9.0: {} 3118 + 3062 3119 mylas@2.1.13: {} 3063 3120 3064 3121 natural-compare@1.4.0: {} ··· 3104 3161 dependencies: 3105 3162 fn.name: 1.1.0 3106 3163 3107 - openai@5.12.0(ws@8.18.3): 3164 + openai@5.12.2(ws@8.18.3)(zod@3.25.76): 3108 3165 optionalDependencies: 3109 3166 ws: 8.18.3 3167 + zod: 3.25.76 3110 3168 3111 3169 optionator@0.9.4: 3112 3170 dependencies: ··· 3432 3490 3433 3491 triple-beam@1.4.1: {} 3434 3492 3435 - ts-api-utils@2.1.0(typescript@5.8.3): 3493 + ts-api-utils@2.1.0(typescript@5.9.2): 3436 3494 dependencies: 3437 - typescript: 5.8.3 3495 + typescript: 5.9.2 3438 3496 3439 3497 ts-mixer@6.0.4: {} 3440 3498 ··· 3472 3530 media-typer: 0.3.0 3473 3531 mime-types: 2.1.35 3474 3532 3475 - typescript-eslint@8.38.0(eslint@9.32.0)(typescript@5.8.3): 3533 + typescript-eslint@8.39.0(eslint@9.33.0)(typescript@5.9.2): 3476 3534 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 3535 + '@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) 3536 + '@typescript-eslint/parser': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 3537 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) 3538 + '@typescript-eslint/utils': 8.39.0(eslint@9.33.0)(typescript@5.9.2) 3539 + eslint: 9.33.0 3540 + typescript: 5.9.2 3483 3541 transitivePeerDependencies: 3484 3542 - supports-color 3485 3543 3486 - typescript@5.8.3: {} 3544 + typescript@5.9.2: {} 3545 + 3546 + uint8arrays@3.0.0: 3547 + dependencies: 3548 + multiformats: 9.9.0 3487 3549 3488 3550 undefsafe@2.0.5: {} 3489 3551 3490 3552 underscore@1.13.7: {} 3491 3553 3492 - undici-types@7.8.0: {} 3554 + undici-types@7.10.0: {} 3493 3555 3494 3556 undici@6.21.3: {} 3495 3557 ··· 3593 3655 yargs-parser: 18.1.3 3594 3656 3595 3657 yocto-queue@0.1.0: {} 3658 + 3659 + zod@3.25.76: {}
+18 -10
src/commands/utilities/ai.ts
··· 442 442 `prompt: "${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}"`, 443 443 ); 444 444 445 - const { apiKey, model, apiUrl } = await getUserCredentials(interaction.user.id); 445 + const invokerId = getInvokerId(interaction); 446 + const { apiKey, model, apiUrl } = await getUserCredentials(invokerId); 446 447 const config = getApiConfiguration(apiKey ?? null, model ?? null, apiUrl ?? null); 447 448 448 449 if (config.usingDefaultKey) { 449 450 const exemptUserId = process.env.AI_EXEMPT_USER_ID; 450 - if (interaction.user.id !== exemptUserId) { 451 - const allowed = await incrementAndCheckDailyLimit(interaction.user.id, 10); 451 + if (invokerId !== exemptUserId) { 452 + const allowed = await incrementAndCheckDailyLimit(invokerId, 10); 452 453 if (!allowed) { 453 454 await interaction.editReply( 454 455 '❌ ' + ··· 464 465 return; 465 466 } 466 467 467 - const existingConversation = userConversations.get(interaction.user.id) || []; 468 + const existingConversation = userConversations.get(invokerId) || []; 468 469 const conversationArray = Array.isArray(existingConversation) ? existingConversation : []; 469 470 const systemPrompt = buildSystemPrompt( 470 471 !!config.usingDefaultKey, ··· 486 487 if (updatedConversation.length > 10) { 487 488 updatedConversation.splice(0, updatedConversation.length - 10); 488 489 } 489 - userConversations.set(interaction.user.id, updatedConversation); 490 + userConversations.set(invokerId, updatedConversation); 490 491 491 492 await sendAIResponse(interaction, aiResponse, client); 492 493 } catch (error) { ··· 494 495 interaction, 495 496 client, 496 497 error: error as Error, 497 - userId: interaction.user.id, 498 + userId: getInvokerId(interaction), 498 499 username: interaction.user.tag, 499 500 }); 500 501 } finally { 501 - pendingRequests.delete(interaction.user.id); 502 + pendingRequests.delete(getInvokerId(interaction)); 502 503 } 503 504 } 504 505 ··· 594 595 ), 595 596 596 597 async execute(client: BotClient, interaction: ChatInputCommandInteraction) { 597 - const userId = interaction.user.id; 598 + const userId = getInvokerId(interaction); 598 599 599 600 if (pendingRequests.has(userId)) { 600 601 const pending = pendingRequests.get(userId); ··· 696 697 if (interaction.customId === 'apiCredentials') { 697 698 await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 698 699 699 - const userId = interaction.user.id; 700 + const userId = getInvokerId(interaction); 700 701 const pendingRequest = pendingRequests.get(userId); 701 702 702 703 if (!pendingRequest) { ··· 758 759 content: await client.getLocaleText('failedrequest', interaction.locale), 759 760 }); 760 761 } finally { 761 - pendingRequests.delete(interaction.user.id); 762 + pendingRequests.delete(getInvokerId(interaction)); 762 763 } 763 764 }, 764 765 } as unknown as SlashCommandProps; 766 + 767 + function getInvokerId(interaction: ChatInputCommandInteraction | ModalSubmitInteraction) { 768 + if (interaction.inGuild()) { 769 + return `${interaction.guildId}-${interaction.user.id}`; 770 + } 771 + return interaction.user.id; 772 + }
+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 || !('isTextBased' in channel) || !channel.isTextBased()) { 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 -18
src/events/messageCreate.ts
··· 14 14 import type { ConversationMessage, AIResponse } from '@/commands/utilities/ai'; 15 15 import { createMemoryManager } from '@/utils/memoryManager'; 16 16 17 - const dmConversations = createMemoryManager<string, ConversationMessage[]>({ 18 - maxSize: 500, 17 + const conversations = createMemoryManager<string, ConversationMessage[]>({ 18 + maxSize: 2000, 19 19 maxAge: 2 * 60 * 60 * 1000, 20 20 cleanupInterval: 10 * 60 * 1000, 21 21 }); 22 22 23 - const serverConversations = createMemoryManager<string, ConversationMessage[]>({ 24 - maxSize: 1000, 25 - maxAge: 1 * 60 * 60 * 1000, 26 - cleanupInterval: 10 * 60 * 1000, 27 - }); 23 + function getConversationKey(message: Message): string { 24 + if (message.channel.type === ChannelType.DM) { 25 + return `dm:${message.author.id}`; 26 + } else if (message.guildId) { 27 + return `guild:${message.guildId}:${message.author.id}`; 28 + } 29 + return `channel:${message.channelId}`; 30 + } 28 31 29 32 export default class MessageCreateEvent { 30 33 constructor(private client: BotClient) { ··· 56 59 try { 57 60 logger.debug(`${isDM ? 'DM' : 'Message'} received (${message.content.length} characters)`); 58 61 59 - const conversationKey = isDM ? message.author.id : message.channel.id; 60 - const conversationManager = isDM ? dmConversations : serverConversations; 61 - const conversation = conversationManager.get(conversationKey) || []; 62 + const conversationKey = getConversationKey(message); 63 + const conversation = conversations.get(conversationKey) || []; 62 64 63 65 const hasImageAttachments = message.attachments.some( 64 66 (att) => ··· 79 81 80 82 logger.debug(`hasImageAttachments: ${hasImageAttachments}, hasImageUrls: ${hasImageUrls}`); 81 83 const hasImages = hasImageAttachments; 82 - 83 - const { model: userCustomModel } = await getUserCredentials(message.author.id); 84 + const { model: userCustomModel } = await getUserCredentials(conversationKey); 84 85 85 86 const selectedModel = hasImages 86 87 ? 'google/gemma-3-4b-it' ··· 167 168 systemPrompt, 168 169 ); 169 170 170 - const { apiKey: userApiKey, apiUrl: userApiUrl } = await getUserCredentials( 171 - message.author.id, 172 - ); 171 + const { apiKey: userApiKey, apiUrl: userApiUrl } = await getUserCredentials(conversationKey); 173 172 const config = getApiConfiguration(userApiKey ?? null, selectedModel, userApiUrl ?? null); 174 173 175 174 if (config.usingDefaultKey) { 176 175 const exemptUserId = process.env.AI_EXEMPT_USER_ID; 177 - if (message.author.id !== exemptUserId) { 178 - const allowed = await incrementAndCheckDailyLimit(message.author.id, 10); 176 + const actorId = message.author.id; 177 + 178 + if (actorId !== exemptUserId) { 179 + const allowed = await incrementAndCheckDailyLimit(actorId, 10); 179 180 if (!allowed) { 180 181 await message.reply( 181 182 "❌ You've reached your daily limit of AI requests. Please try again tomorrow or set up your own API key using the `/ai` command.", ··· 263 264 role: 'assistant', 264 265 content: aiResponse.content, 265 266 }); 266 - conversationManager.set(conversationKey, updatedConversation); 267 + conversations.set(conversationKey, updatedConversation); 267 268 268 269 logger.info(`${isDM ? 'DM' : 'Server'} response sent successfully`); 269 270 } catch (error) {
+56
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 sslMode = (process.env.PGSSLMODE || process.env.DATABASE_SSL || '').toLowerCase(); 102 + let ssl: false | { rejectUnauthorized?: boolean; ca?: string } = false; 103 + const rootCertPath = process.env.PGSSLROOTCERT || process.env.DATABASE_SSL_CA; 104 + 105 + if (sslMode === 'require') { 106 + ssl = { rejectUnauthorized: true }; 107 + } 108 + 109 + if (rootCertPath) { 110 + try { 111 + const ca = await promises.readFile(rootCertPath, 'utf8'); 112 + ssl = { ca, rejectUnauthorized: true }; 113 + } catch (e) { 114 + console.warn('Failed to read CA certificate: unable to access the specified path.', e); 115 + } 116 + } 117 + 118 + const pool = new Pool({ 119 + connectionString: process.env.DATABASE_URL, 120 + ssl, 121 + }); 122 + 123 + pool.on('error', (err) => { 124 + console.error('Unexpected error on idle PostgreSQL client:', err); 125 + }); 126 + 127 + const shutdown = async (signal?: NodeJS.Signals) => { 128 + try { 129 + console.log(`Received ${signal ?? 'shutdown'}: closing services and database pool...`); 130 + await this.socialMediaManager?.cleanup(); 131 + await pool.end(); 132 + console.log('Database pool closed. Exiting.'); 133 + } catch (e) { 134 + console.error('Error during graceful shutdown:', e); 135 + } finally { 136 + process.exit(0); 137 + } 138 + }; 139 + 140 + process.on('SIGINT', () => shutdown('SIGINT')); 141 + process.on('SIGTERM', () => shutdown('SIGTERM')); 142 + 143 + this.socialMediaManager = initializeSocialMediaManager(this, pool); 144 + await this.socialMediaManager.initialize(); 145 + } catch (error) { 146 + console.error('Failed to initialize database and services:', error); 147 + throw error; 148 + } 149 + } 150 + 95 151 public async getLocaleText(key: string, locale: string, replaces = {}): Promise<string> { 96 152 const fallbackLocale = 'en-US'; 97 153
+253
src/services/social/SocialMediaManager.ts
··· 1 + import { Client, 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); 68 + if (!this.isTextBasedAndSendable(channel)) { 69 + console.error( 70 + `Channel ${subscription.channelId} not found or not text-capable. Skipping notification.`, 71 + ); 72 + return; 73 + } 74 + 75 + const embed = this.createEmbed(post); 76 + await channel.send({ embeds: [embed] }); 77 + } catch (error) { 78 + console.error('Error sending notification:', error); 79 + } 80 + } 81 + 82 + private createEmbed(post: SocialMediaPost): EmbedBuilder { 83 + const authorIcon = post.authorAvatarUrl ?? this.getPlatformIcon(post.platform); 84 + const authorName = 85 + post.authorDisplayName && post.authorDisplayName.trim().length > 0 86 + ? `${post.authorDisplayName} (${post.author})` 87 + : post.author; 88 + const cleanText = this.stripHtml(post.text ?? ''); 89 + const embed = new EmbedBuilder() 90 + .setColor(this.getPlatformColor(post.platform)) 91 + .setAuthor({ name: authorName, iconURL: authorIcon }) 92 + .setDescription(this.truncateText(cleanText, 1000)) 93 + .setTimestamp(post.timestamp) 94 + .setFooter({ 95 + text: `New post on ${this.formatPlatformName(post.platform)}`, 96 + iconURL: this.getPlatformIcon(post.platform), 97 + }); 98 + 99 + if (post.mediaUrls && post.mediaUrls.length > 0) embed.setImage(post.mediaUrls[0]); 100 + embed.addFields([ 101 + { name: 'View Post', value: `[Open in Browser](${this.getPostUrl(post)})`, inline: true }, 102 + ]); 103 + return embed; 104 + } 105 + 106 + private getPlatformColor(platform: string): number { 107 + switch (platform) { 108 + case 'bluesky': 109 + return 0x1185fe; 110 + case 'fediverse': 111 + return 0x6364ff; 112 + default: 113 + return 0x7289da; 114 + } 115 + } 116 + 117 + private getPlatformIcon(platform: string): string | undefined { 118 + switch (platform) { 119 + case 'bluesky': 120 + return 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/7a/Bluesky_Logo.svg/24px-Bluesky_Logo.svg.png'; 121 + case 'fediverse': 122 + return 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Fediverse_logo_proposal.svg/64px-Fediverse_logo_proposal.svg.png'; 123 + default: 124 + return undefined; 125 + } 126 + } 127 + 128 + private formatPlatformName(platform: string): string { 129 + return platform.charAt(0).toUpperCase() + platform.slice(1); 130 + } 131 + 132 + private getPostUrl(post: SocialMediaPost): string { 133 + switch (post.platform) { 134 + case 'bluesky': { 135 + const handle = post.author.split('@')[0]; 136 + const postId = post.uri.split('/').pop(); 137 + return `https://bsky.app/profile/${handle}/post/${postId}`; 138 + } 139 + case 'fediverse': 140 + return post.uri; 141 + default: 142 + return post.uri; 143 + } 144 + } 145 + 146 + private truncateText(text: string, maxLength: number): string { 147 + if (text.length <= maxLength) return text; 148 + return text.substring(0, maxLength - 3) + '...'; 149 + } 150 + 151 + private stripHtml(text: string): string { 152 + return text 153 + .replace(/<[^>]*>/g, ' ') 154 + .replace(/\s+/g, ' ') 155 + .trim(); 156 + } 157 + 158 + private isTextBasedAndSendable( 159 + channel: unknown, 160 + ): channel is { send: (options: unknown) => Promise<unknown> } & { isTextBased(): boolean } { 161 + const maybe = channel as { send?: unknown; isTextBased?: unknown }; 162 + return ( 163 + typeof maybe.send === 'function' && 164 + typeof maybe.isTextBased === 'function' && 165 + maybe.isTextBased() 166 + ); 167 + } 168 + } 169 + 170 + class SocialMediaPoller { 171 + private isRunning = false; 172 + private inProgress = false; 173 + private pollInterval: NodeJS.Timeout | null = null; 174 + private readonly POLL_INTERVAL_MS = 60 * 1000; 175 + 176 + constructor( 177 + private readonly client: Client, 178 + private readonly socialService: SocialMediaService, 179 + private readonly notificationService: NotificationService, 180 + ) {} 181 + 182 + public start(): void { 183 + if (this.isRunning) { 184 + logger.warn('Social media poller is already running'); 185 + return; 186 + } 187 + 188 + this.isRunning = true; 189 + logger.info('Starting social media poller...'); 190 + 191 + this.safeCheckForUpdates(); 192 + 193 + this.pollInterval = setInterval(() => { 194 + this.safeCheckForUpdates(); 195 + }, this.POLL_INTERVAL_MS); 196 + } 197 + 198 + public stop(): void { 199 + if (!this.isRunning) return; 200 + logger.info('Stopping social media poller...'); 201 + if (this.pollInterval) { 202 + clearInterval(this.pollInterval); 203 + this.pollInterval = null; 204 + } 205 + this.isRunning = false; 206 + } 207 + 208 + public isActive(): boolean { 209 + return this.isRunning; 210 + } 211 + 212 + private async checkForUpdates(): Promise<void> { 213 + if (!this.isRunning) return; 214 + logger.debug('Checking for social media updates...'); 215 + try { 216 + const newPosts = await this.socialService.checkForUpdates(); 217 + if (newPosts.length > 0) { 218 + for (const { post, subscription } of newPosts) { 219 + try { 220 + await this.notificationService.sendNotification(post, subscription); 221 + } catch (error) { 222 + console.error(`Error sending notification for ${post.platform} post:`, error); 223 + } 224 + } 225 + } 226 + } catch (error) { 227 + console.error('Error checking for social media updates:', error); 228 + } 229 + } 230 + 231 + private async safeCheckForUpdates(): Promise<void> { 232 + if (!this.isRunning || this.inProgress) { 233 + return; 234 + } 235 + this.inProgress = true; 236 + try { 237 + await this.checkForUpdates(); 238 + } catch (error) { 239 + logger.error('Error during guarded social media check:', error); 240 + } finally { 241 + this.inProgress = false; 242 + } 243 + } 244 + } 245 + 246 + export let socialMediaManager: SocialMediaManager; 247 + 248 + export function initializeSocialMediaManager(client: Client, pool: Pool): SocialMediaManager { 249 + if (!socialMediaManager) { 250 + socialMediaManager = new SocialMediaManager(client, pool); 251 + } 252 + return socialMediaManager; 253 + }
+201
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 normalized = this.normalizeAccountHandle(platform, accountHandle); 36 + 37 + const result = await this.pool.query( 38 + `INSERT INTO server_social_subscriptions 39 + (guild_id, platform, account_handle, channel_id) 40 + VALUES ($1, $2::social_platform, $3, $4) 41 + ON CONFLICT (guild_id, platform, lower(account_handle)) 42 + DO UPDATE SET channel_id = $4 43 + RETURNING *`, 44 + [guildId, platform, normalized, channelId], 45 + ); 46 + 47 + return this.mapDbToSubscription(result.rows[0]); 48 + } 49 + 50 + async removeSubscription( 51 + guildId: string, 52 + platform: SocialPlatform, 53 + accountHandle: string, 54 + ): Promise<boolean> { 55 + const normalizedHandle = this.normalizeAccountHandle(platform, accountHandle); 56 + const result = await this.pool.query( 57 + `DELETE FROM server_social_subscriptions 58 + WHERE guild_id = $1 AND platform = $2::social_platform AND account_handle = $3`, 59 + [guildId, platform, normalizedHandle], 60 + ); 61 + 62 + return (result.rowCount || 0) > 0; 63 + } 64 + 65 + async listSubscriptions(guildId: string): Promise<SocialMediaSubscription[]> { 66 + const result = await this.pool.query( 67 + `SELECT * FROM server_social_subscriptions WHERE guild_id = $1`, 68 + [guildId], 69 + ); 70 + 71 + return result.rows.map(this.mapDbToSubscription); 72 + } 73 + 74 + async checkForUpdates(): Promise< 75 + { post: SocialMediaPost; subscription: SocialMediaSubscription }[] 76 + > { 77 + const subscriptions = await this.getAllActiveSubscriptions(); 78 + const newPosts: { post: SocialMediaPost; subscription: SocialMediaSubscription }[] = []; 79 + 80 + for (const sub of subscriptions) { 81 + try { 82 + const fetcher = this.fetchers.get(sub.platform as SocialPlatform); 83 + if (!fetcher) continue; 84 + 85 + const latestPost = await fetcher.fetchLatestPost(sub.accountHandle); 86 + if (latestPost) { 87 + if (!sub.lastPostTimestamp) { 88 + await this.updateLastPost(sub.id, latestPost.uri, latestPost.timestamp); 89 + continue; 90 + } 91 + 92 + if (this.isNewerPost(latestPost, sub)) { 93 + await this.updateLastPost(sub.id, latestPost.uri, latestPost.timestamp); 94 + newPosts.push({ 95 + post: latestPost, 96 + subscription: sub, 97 + }); 98 + } 99 + } 100 + } catch (error) { 101 + console.error( 102 + `Error checking updates for ${sub.platform} account ${sub.accountHandle}:`, 103 + error, 104 + ); 105 + } 106 + } 107 + 108 + return newPosts; 109 + } 110 + 111 + startPolling(): void { 112 + if (this.isPolling) return; 113 + 114 + this.isPolling = true; 115 + const poll = async () => { 116 + if (!this.isPolling) return; 117 + 118 + try { 119 + await this.checkForUpdates(); 120 + } catch (error) { 121 + console.error('Error during social media polling:', error); 122 + } finally { 123 + if (this.isPolling) { 124 + setTimeout(poll, this.pollInterval); 125 + } 126 + } 127 + }; 128 + 129 + poll(); 130 + } 131 + 132 + stopPolling(): void { 133 + this.isPolling = false; 134 + } 135 + 136 + private async getAllActiveSubscriptions(): Promise<SocialMediaSubscription[]> { 137 + const result = await this.pool.query(`SELECT * FROM server_social_subscriptions`); 138 + return result.rows.map(this.mapDbToSubscription); 139 + } 140 + 141 + private async updateLastPost( 142 + subscriptionId: number, 143 + postUri: string, 144 + postTimestamp: Date, 145 + ): Promise<void> { 146 + await this.pool.query( 147 + `UPDATE server_social_subscriptions 148 + SET last_post_uri = $1, last_post_timestamp = $2 149 + WHERE id = $3`, 150 + [postUri, postTimestamp, subscriptionId], 151 + ); 152 + } 153 + 154 + private isNewerPost(post: SocialMediaPost, subscription: SocialMediaSubscription): boolean { 155 + if (!subscription.lastPostTimestamp) return true; 156 + if (post.timestamp > subscription.lastPostTimestamp) return true; 157 + if (post.timestamp < subscription.lastPostTimestamp) return false; 158 + if (!subscription.lastPostUri) return true; 159 + return post.uri !== subscription.lastPostUri; 160 + } 161 + 162 + private mapDbToSubscription(row: { 163 + id: number; 164 + guild_id: string; 165 + platform: SocialPlatform; 166 + account_handle: string; 167 + last_post_uri: string | null; 168 + last_post_timestamp: string | Date | null; 169 + channel_id: string; 170 + created_at: Date; 171 + updated_at: Date; 172 + }): SocialMediaSubscription { 173 + const lastPostTimestamp = row.last_post_timestamp ? new Date(row.last_post_timestamp) : null; 174 + 175 + return { 176 + id: row.id, 177 + guildId: row.guild_id, 178 + platform: row.platform as SocialPlatform, 179 + accountHandle: row.account_handle, 180 + lastPostUri: row.last_post_uri ?? undefined, 181 + lastPostTimestamp: lastPostTimestamp ?? undefined, 182 + channelId: row.channel_id, 183 + createdAt: row.created_at, 184 + updatedAt: row.updated_at, 185 + }; 186 + } 187 + 188 + private normalizeAccountHandle(platform: SocialPlatform, handle: string): string { 189 + let h = handle.trim(); 190 + if (platform === 'bluesky') { 191 + h = h.startsWith('@') ? h.slice(1) : h; 192 + h = h.toLowerCase(); 193 + if (!h.includes('.')) { 194 + h = `${h}.bsky.social`; 195 + } 196 + return h; 197 + } 198 + h = h.startsWith('@') ? h.slice(1) : h; 199 + return h.toLowerCase(); 200 + } 201 + }
+324
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 + const DEFAULT_FETCH_TIMEOUT_MS = 10_000; 31 + async function fetchWithTimeout( 32 + input: string, 33 + init?: RequestInit, 34 + timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS, 35 + ): Promise<Response> { 36 + const controller = new AbortController(); 37 + const timer = setTimeout(() => controller.abort(), timeoutMs); 38 + try { 39 + return await fetch(input, { ...(init || {}), signal: controller.signal }); 40 + } finally { 41 + clearTimeout(timer); 42 + } 43 + } 44 + 45 + export class BlueskyFetcher implements SocialMediaFetcher { 46 + platform: SocialPlatform = 'bluesky'; 47 + private readonly baseUrl = 'https://public.api.bsky.app'; 48 + private readonly handleResolver = new HandleResolver(); 49 + 50 + async fetchLatestPost(account: string): Promise<SocialMediaPost | null> { 51 + const actor = await this.resolveActor(account); 52 + const url = `${this.baseUrl}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(actor)}&limit=1`; 53 + 54 + try { 55 + const response = await fetchWithTimeout(url); 56 + if (!response.ok) { 57 + throw new Error(`HTTP error! status: ${response.status}`); 58 + } 59 + 60 + const data = await response.json(); 61 + const items = (data?.feed as BlueskyFeedItem[]) || []; 62 + if (!Array.isArray(items) || items.length === 0) return null; 63 + 64 + const post = items[0]?.post; 65 + if (!post || !post.record) return null; 66 + 67 + const actorId = post.author?.did || actor; 68 + const profile = await this.fetchBlueskyProfile(actorId); 69 + const avatarUrl = profile?.avatar ?? null; 70 + const displayName = profile?.displayName ?? post.author?.displayName; 71 + 72 + return this.mapToSocialMediaPost(post, avatarUrl || undefined, displayName); 73 + } catch (error: unknown) { 74 + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 75 + console.error('Error fetching Bluesky post:', error); 76 + throw new Error(`Failed to fetch post from Bluesky: ${errorMessage}`); 77 + } 78 + } 79 + 80 + isValidAccount(account: string | null | undefined): boolean { 81 + if (!account) return false; 82 + 83 + if (account.startsWith('did:')) { 84 + return /^did:[a-z0-9]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/.test(account); 85 + } 86 + 87 + const parts = account.split('@').filter(Boolean); 88 + if (parts.length < 2) return false; 89 + 90 + const handle = parts[0]; 91 + const domain = parts.slice(1).join('@'); 92 + 93 + const isValidHandle = /^[a-zA-Z0-9-]+$/.test(handle); 94 + const isValidDomain = /^[a-zA-Z0-9.-]+$/.test(domain); 95 + 96 + return isValidHandle && isValidDomain; 97 + } 98 + 99 + private normalizeHandle(handle: string): string { 100 + if (handle.startsWith('did:')) { 101 + return handle; 102 + } 103 + 104 + handle = handle.startsWith('@') ? handle.slice(1) : handle; 105 + if (!handle.includes('.')) { 106 + return `${handle}.bsky.social`.toLowerCase(); 107 + } 108 + return handle.toLowerCase(); 109 + } 110 + 111 + private async resolveActor(account: string): Promise<string> { 112 + const normalized = this.normalizeHandle(account); 113 + try { 114 + const did = await this.handleResolver.resolve(normalized); 115 + if (typeof did === 'string' && did.startsWith('did:')) { 116 + return did; 117 + } 118 + } catch (_error) { 119 + void 0; 120 + } 121 + return normalized; 122 + } 123 + 124 + private mapToSocialMediaPost( 125 + post: BlueskyPost, 126 + authorAvatarUrl?: string, 127 + authorDisplayName?: string, 128 + ): SocialMediaPost { 129 + const mediaUrls: string[] = []; 130 + 131 + if (post.embed?.$type === 'app.bsky.embed.images#view' && post.embed.images) { 132 + mediaUrls.push(...post.embed.images.map((img) => img.fullsize)); 133 + } 134 + 135 + const text = post.record?.text ?? ''; 136 + const createdAt = post.record?.createdAt ?? new Date().toISOString(); 137 + const author = post.author?.handle ?? 'unknown'; 138 + 139 + return { 140 + uri: post.uri, 141 + text, 142 + author, 143 + timestamp: new Date(createdAt), 144 + platform: 'bluesky', 145 + mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, 146 + authorAvatarUrl, 147 + authorDisplayName, 148 + }; 149 + } 150 + 151 + private async fetchBlueskyProfile( 152 + actor: string, 153 + ): Promise<{ avatar?: string; displayName?: string } | null> { 154 + try { 155 + const url = `${this.baseUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`; 156 + const res = await fetchWithTimeout(url); 157 + if (!res.ok) return null; 158 + const data = await res.json(); 159 + const avatar = typeof data?.avatar === 'string' ? data.avatar : undefined; 160 + const displayName = typeof data?.displayName === 'string' ? data.displayName : undefined; 161 + return { avatar, displayName }; 162 + } catch { 163 + return null; 164 + } 165 + } 166 + } 167 + 168 + interface FediverseAccount { 169 + username: string; 170 + acct: string; 171 + display_name: string; 172 + avatar: string; 173 + } 174 + 175 + interface FediverseAttachment { 176 + id: string; 177 + type: 'image' | 'video' | 'gifv' | 'audio' | 'unknown'; 178 + url: string; 179 + preview_url: string; 180 + description?: string; 181 + } 182 + 183 + interface FediversePost { 184 + id: string; 185 + uri: string; 186 + url: string; 187 + in_reply_to_id: string | null; 188 + in_reply_to_account_id: string | null; 189 + account: FediverseAccount; 190 + content: string; 191 + created_at: string; 192 + reblogs_count: number; 193 + favourites_count: number; 194 + reblogged: boolean; 195 + favourited: boolean; 196 + sensitive: boolean; 197 + spoiler_text: string; 198 + visibility: 'public' | 'unlisted' | 'private' | 'direct'; 199 + media_attachments: FediverseAttachment[]; 200 + mentions: Array<{ 201 + id: string; 202 + username: string; 203 + url: string; 204 + acct: string; 205 + }>; 206 + tags: Array<{ name: string; url: string }>; 207 + application?: { 208 + name: string; 209 + website?: string; 210 + }; 211 + language: string | null; 212 + reblog: FediversePost | null; 213 + } 214 + 215 + export class FediverseFetcher implements SocialMediaFetcher { 216 + platform: SocialPlatform = 'fediverse'; 217 + 218 + private validateFediverseAccount(username: string, domain: string | null): void { 219 + if (!domain) { 220 + throw new Error('Fediverse account must include a domain (e.g., user@instance.social)'); 221 + } 222 + 223 + if (/^https?:\/\//i.test(username) || /^https?:\/\//i.test(domain)) { 224 + throw new Error('URL schemes (http/https) are not allowed in Fediverse accounts'); 225 + } 226 + 227 + if (/[?#]/.test(username) || /[?#]/.test(domain)) { 228 + throw new Error('URL paths and query parameters are not allowed in Fediverse accounts'); 229 + } 230 + 231 + const domainStr = domain as string; 232 + if (!/^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(domainStr)) { 233 + throw new Error('Invalid domain format in Fediverse account'); 234 + } 235 + 236 + if (!/^[a-z0-9_.-]+$/i.test(username)) { 237 + throw new Error('Invalid username format in Fediverse account'); 238 + } 239 + } 240 + 241 + async fetchLatestPost(account: string): Promise<SocialMediaPost | null> { 242 + const [username, domain] = this.parseAccount(account); 243 + this.validateFediverseAccount(username, domain); 244 + 245 + const domainStr = domain as string; 246 + const apiUrl = `https://${domainStr}/api/v1/accounts/lookup?acct=${username}@${domainStr}`; 247 + 248 + try { 249 + const accountResponse = await fetchWithTimeout(apiUrl); 250 + if (!accountResponse.ok) { 251 + throw new Error(`Failed to fetch account: ${accountResponse.statusText}`); 252 + } 253 + 254 + const accountData = await accountResponse.json(); 255 + 256 + if (!accountData?.id) { 257 + throw new Error('Invalid account data: missing account ID in response'); 258 + } 259 + 260 + const accountId = accountData.id; 261 + const statusesUrl = `https://${domainStr}/api/v1/accounts/${accountId}/statuses?limit=1&exclude_replies=true&exclude_reblogs=true`; 262 + const statusResponse = await fetchWithTimeout(statusesUrl); 263 + 264 + if (!statusResponse.ok) { 265 + throw new Error(`Failed to fetch statuses: ${statusResponse.statusText}`); 266 + } 267 + 268 + const statuses = (await statusResponse.json()) as FediversePost[]; 269 + if (!statuses || statuses.length === 0) { 270 + return null; 271 + } 272 + 273 + const post = this.mapToSocialMediaPost(statuses[0], domainStr); 274 + return post; 275 + } catch (error: unknown) { 276 + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 277 + console.error('Error fetching Fediverse post:', error); 278 + throw new Error(`Failed to fetch post from Fediverse: ${errorMessage}`); 279 + } 280 + } 281 + 282 + isValidAccount(account: string): boolean { 283 + if (!account) return false; 284 + const parts = account.split('@').filter(Boolean); 285 + return parts.length >= 2 && parts.every((part) => part.length > 0); 286 + } 287 + 288 + private parseAccount(account: string): [string, string | null] { 289 + const cleanAccount = account.startsWith('@') ? account.slice(1) : account; 290 + const firstAt = cleanAccount.indexOf('@'); 291 + 292 + if (firstAt === -1) { 293 + return [cleanAccount, null]; 294 + } 295 + 296 + const username = cleanAccount.substring(0, firstAt); 297 + const domain = cleanAccount.substring(firstAt + 1); 298 + return [username, domain]; 299 + } 300 + 301 + private mapToSocialMediaPost(post: FediversePost, domain: string): SocialMediaPost { 302 + if (post.reblog) { 303 + return this.mapToSocialMediaPost(post.reblog, domain); 304 + } 305 + 306 + const mediaUrls = post.media_attachments 307 + .filter((media) => media.type === 'image' || media.type === 'gifv') 308 + .map((media) => media.url); 309 + 310 + const acct = post.account.acct; 311 + const authorAcct = acct.includes('@') ? acct : `${acct}@${domain}`; 312 + 313 + return { 314 + uri: post.uri, 315 + text: post.content, 316 + author: authorAcct, 317 + timestamp: new Date(post.created_at), 318 + platform: 'fediverse', 319 + mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, 320 + authorAvatarUrl: post.account.avatar, 321 + authorDisplayName: post.account.display_name, 322 + }; 323 + } 324 + }
+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 + }