Build Reactive Signals for Bluesky's AT Protocol Firehose in Laravel

Implement core functionality

+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Social Dept. 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+618
README.md
··· 1 + # Signal 2 + 3 + **Laravel package for building Signals that respond to AT Protocol Jetstream events** 4 + 5 + Signal provides a clean, Laravel-style interface for consuming real-time events from the AT Protocol firehose (Jetstream). Build reactive applications that respond to posts, likes, follows, and other social interactions on the AT Protocol network. 6 + 7 + --- 8 + 9 + ## Features 10 + 11 + - 🔌 **WebSocket Connection** - Connect to AT Protocol Jetstream with automatic reconnection 12 + - 🎯 **Signal-based Architecture** - Clean, testable event handlers (avoiding Laravel's "listener" naming collision) 13 + - ⭐ **Wildcard Collection Filtering** - Match multiple collections with patterns like `app.bsky.feed.*` 14 + - 💾 **Cursor Management** - Resume from last position after disconnections (Database, Redis, or File storage) 15 + - ⚡ **Queue Integration** - Process events asynchronously with Laravel queues 16 + - 🔍 **Auto-Discovery** - Automatically find and register Signals in `app/Signals` 17 + - 🧪 **Testing Tools** - Test your Signals with sample data 18 + - 🛠️ **Artisan Commands** - Full CLI support for managing and testing Signals 19 + 20 + --- 21 + 22 + ## Table of Contents 23 + 24 + - [Installation](#installation) 25 + - [Quick Start](#quick-start) 26 + - [Creating Signals](#creating-signals) 27 + - [Filtering Events](#filtering-events) 28 + - [Queue Integration](#queue-integration) 29 + - [Configuration](#configuration) 30 + - [Available Commands](#available-commands) 31 + - [Testing](#testing) 32 + - [Documentation](#documentation) 33 + - [License](#license) 34 + 35 + --- 36 + 37 + ## Installation 38 + 39 + Install the package via Composer: 40 + 41 + ```bash 42 + composer require social-dept/signal 43 + ``` 44 + 45 + Run the installation command: 46 + 47 + ```bash 48 + php artisan signal:install 49 + ``` 50 + 51 + This will: 52 + - Publish the configuration file to `config/signal.php` 53 + - Publish the database migration 54 + - Run migrations (with confirmation) 55 + - Display next steps 56 + 57 + ### Manual Installation 58 + 59 + If you prefer manual installation: 60 + 61 + ```bash 62 + php artisan vendor:publish --tag=signal-config 63 + php artisan vendor:publish --tag=signal-migrations 64 + php artisan migrate 65 + ``` 66 + 67 + --- 68 + 69 + ## Quick Start 70 + 71 + ### 1. Create Your First Signal 72 + 73 + ```bash 74 + php artisan make:signal NewPostSignal 75 + ``` 76 + 77 + This creates `app/Signals/NewPostSignal.php`: 78 + 79 + ```php 80 + <?php 81 + 82 + namespace App\Signals; 83 + 84 + use SocialDept\Signal\Events\JetstreamEvent; 85 + use SocialDept\Signal\Signals\Signal; 86 + 87 + class NewPostSignal extends Signal 88 + { 89 + public function eventTypes(): array 90 + { 91 + return ['commit']; 92 + } 93 + 94 + public function collections(): ?array 95 + { 96 + return ['app.bsky.feed.post']; 97 + } 98 + 99 + public function handle(JetstreamEvent $event): void 100 + { 101 + $record = $event->getRecord(); 102 + 103 + logger()->info('New post created', [ 104 + 'did' => $event->did, 105 + 'text' => $record->text ?? null, 106 + ]); 107 + } 108 + } 109 + ``` 110 + 111 + ### 2. Start Consuming Events 112 + 113 + ```bash 114 + php artisan signal:consume 115 + ``` 116 + 117 + Your Signal will now respond to new posts on the AT Protocol network in real-time! 118 + 119 + --- 120 + 121 + ## Creating Signals 122 + 123 + ### Basic Signal Structure 124 + 125 + Every Signal extends the base `Signal` class and must implement: 126 + 127 + ```php 128 + use SocialDept\Signal\Events\JetstreamEvent; 129 + use SocialDept\Signal\Signals\Signal; 130 + 131 + class MySignal extends Signal 132 + { 133 + // Required: Define which event types to listen for 134 + public function eventTypes(): array 135 + { 136 + return ['commit']; // 'commit', 'identity', or 'account' 137 + } 138 + 139 + // Required: Handle the event 140 + public function handle(JetstreamEvent $event): void 141 + { 142 + // Your logic here 143 + } 144 + } 145 + ``` 146 + 147 + ### Event Types 148 + 149 + Three event types are available: 150 + 151 + | Type | Description | Use Cases | 152 + |------|-------------|-----------| 153 + | `commit` | Repository commits (posts, likes, follows, etc.) | Content creation, social interactions | 154 + | `identity` | Identity changes (handle updates) | User profile tracking | 155 + | `account` | Account status changes | Account monitoring | 156 + 157 + ### Accessing Event Data 158 + 159 + ```php 160 + public function handle(JetstreamEvent $event): void 161 + { 162 + // Common properties 163 + $did = $event->did; // User's DID 164 + $kind = $event->kind; // Event type 165 + $timestamp = $event->timeUs; // Microsecond timestamp 166 + 167 + // Commit events 168 + if ($event->isCommit()) { 169 + $collection = $event->getCollection(); // e.g., 'app.bsky.feed.post' 170 + $operation = $event->getOperation(); // 'create', 'update', or 'delete' 171 + $record = $event->getRecord(); // The actual record data 172 + $rkey = $event->commit->rkey; // Record key 173 + } 174 + 175 + // Identity events 176 + if ($event->isIdentity()) { 177 + $handle = $event->identity->handle; 178 + } 179 + 180 + // Account events 181 + if ($event->isAccount()) { 182 + $active = $event->account->active; 183 + $status = $event->account->status; 184 + } 185 + } 186 + ``` 187 + 188 + --- 189 + 190 + ## Filtering Events 191 + 192 + ### Collection Filtering (with Wildcards!) 193 + 194 + Filter events by AT Protocol collection: 195 + 196 + ```php 197 + // Exact match - only posts 198 + public function collections(): ?array 199 + { 200 + return ['app.bsky.feed.post']; 201 + } 202 + 203 + // Wildcard - all feed events 204 + public function collections(): ?array 205 + { 206 + return ['app.bsky.feed.*']; 207 + } 208 + 209 + // Multiple patterns 210 + public function collections(): ?array 211 + { 212 + return [ 213 + 'app.bsky.feed.post', 214 + 'app.bsky.feed.repost', 215 + 'app.bsky.graph.*', // All graph collections 216 + ]; 217 + } 218 + 219 + // No filter - all collections 220 + public function collections(): ?array 221 + { 222 + return null; 223 + } 224 + ``` 225 + 226 + ### Common Collection Patterns 227 + 228 + | Pattern | Matches | 229 + |---------|---------| 230 + | `app.bsky.feed.*` | Posts, likes, reposts, etc. | 231 + | `app.bsky.graph.*` | Follows, blocks, mutes | 232 + | `app.bsky.actor.*` | Profile updates | 233 + | `app.bsky.*` | All Bluesky collections | 234 + 235 + ### DID Filtering 236 + 237 + Filter events by specific users: 238 + 239 + ```php 240 + public function dids(): ?array 241 + { 242 + return [ 243 + 'did:plc:z72i7hdynmk6r22z27h6tvur', // Specific user 244 + 'did:plc:ragtjsm2j2vknwkz3zp4oxrd', // Another user 245 + ]; 246 + } 247 + ``` 248 + 249 + ### Custom Filtering 250 + 251 + Add complex filtering logic: 252 + 253 + ```php 254 + public function shouldHandle(JetstreamEvent $event): bool 255 + { 256 + // Only handle posts with images 257 + if ($event->isCommit() && $event->commit->collection === 'app.bsky.feed.post') { 258 + $record = $event->getRecord(); 259 + return isset($record->embed); 260 + } 261 + 262 + return true; 263 + } 264 + ``` 265 + 266 + --- 267 + 268 + ## Queue Integration 269 + 270 + Process events asynchronously using Laravel queues: 271 + 272 + ```php 273 + class HeavyProcessingSignal extends Signal 274 + { 275 + public function eventTypes(): array 276 + { 277 + return ['commit']; 278 + } 279 + 280 + // Enable queueing 281 + public function shouldQueue(): bool 282 + { 283 + return true; 284 + } 285 + 286 + // Optional: Customize queue 287 + public function queue(): string 288 + { 289 + return 'high-priority'; 290 + } 291 + 292 + // Optional: Customize connection 293 + public function queueConnection(): string 294 + { 295 + return 'redis'; 296 + } 297 + 298 + public function handle(JetstreamEvent $event): void 299 + { 300 + // This runs in a queue job 301 + $this->performExpensiveOperation($event); 302 + } 303 + 304 + // Handle failures 305 + public function failed(JetstreamEvent $event, \Throwable $exception): void 306 + { 307 + Log::error('Signal failed', [ 308 + 'event' => $event->toArray(), 309 + 'error' => $exception->getMessage(), 310 + ]); 311 + } 312 + } 313 + ``` 314 + 315 + --- 316 + 317 + ## Configuration 318 + 319 + Configuration is stored in `config/signal.php`: 320 + 321 + ### Jetstream URL 322 + 323 + ```php 324 + 'websocket_url' => env('SIGNAL_JETSTREAM_URL', 'wss://jetstream2.us-east.bsky.network'), 325 + ``` 326 + 327 + Available endpoints: 328 + - **US East**: `wss://jetstream2.us-east.bsky.network` 329 + - **US West**: `wss://jetstream1.us-west.bsky.network` 330 + 331 + ### Cursor Storage 332 + 333 + Choose how to store cursor positions: 334 + 335 + ```php 336 + 'cursor_storage' => env('SIGNAL_CURSOR_STORAGE', 'database'), 337 + ``` 338 + 339 + | Driver | Best For | Configuration | 340 + |--------|----------|---------------| 341 + | `database` | Production, multi-server | Default connection | 342 + | `redis` | High performance, distributed | Redis connection | 343 + | `file` | Development, single server | Storage path | 344 + 345 + ### Environment Variables 346 + 347 + Add to your `.env`: 348 + 349 + ```env 350 + # Required 351 + SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network 352 + 353 + # Optional 354 + SIGNAL_CURSOR_STORAGE=database 355 + SIGNAL_QUEUE_CONNECTION=redis 356 + SIGNAL_QUEUE=signal 357 + SIGNAL_BATCH_SIZE=100 358 + SIGNAL_RATE_LIMIT=1000 359 + ``` 360 + 361 + ### Auto-Discovery 362 + 363 + Signals are automatically discovered from `app/Signals`. Disable if needed: 364 + 365 + ```php 366 + 'auto_discovery' => [ 367 + 'enabled' => true, 368 + 'path' => app_path('Signals'), 369 + 'namespace' => 'App\\Signals', 370 + ], 371 + ``` 372 + 373 + Or manually register Signals: 374 + 375 + ```php 376 + 'signals' => [ 377 + \App\Signals\NewPostSignal::class, 378 + \App\Signals\NewFollowSignal::class, 379 + ], 380 + ``` 381 + 382 + --- 383 + 384 + ## Available Commands 385 + 386 + ### `signal:install` 387 + Install the package (publish config, migrations, run migrations) 388 + 389 + ```bash 390 + php artisan signal:install 391 + ``` 392 + 393 + ### `signal:consume` 394 + Start consuming events from Jetstream 395 + 396 + ```bash 397 + php artisan signal:consume 398 + 399 + # Start from specific cursor 400 + php artisan signal:consume --cursor=123456789 401 + 402 + # Start fresh (ignore stored cursor) 403 + php artisan signal:consume --fresh 404 + ``` 405 + 406 + ### `signal:list` 407 + List all registered Signals 408 + 409 + ```bash 410 + php artisan signal:list 411 + ``` 412 + 413 + ### `signal:make` 414 + Create a new Signal class 415 + 416 + ```bash 417 + php artisan make:signal NewPostSignal 418 + 419 + # With options 420 + php artisan make:signal FollowSignal --type=commit --collection=app.bsky.graph.follow 421 + ``` 422 + 423 + ### `signal:test` 424 + Test a Signal with sample data 425 + 426 + ```bash 427 + php artisan signal:test NewPostSignal 428 + ``` 429 + 430 + --- 431 + 432 + ## Testing 433 + 434 + Signal includes a comprehensive test suite. Test your Signals: 435 + 436 + ### Unit Testing 437 + 438 + ```php 439 + use SocialDept\Signal\Events\CommitEvent; 440 + use SocialDept\Signal\Events\JetstreamEvent; 441 + 442 + class NewPostSignalTest extends TestCase 443 + { 444 + /** @test */ 445 + public function it_handles_new_posts() 446 + { 447 + $signal = new NewPostSignal(); 448 + 449 + $event = new JetstreamEvent( 450 + did: 'did:plc:test', 451 + timeUs: time() * 1000000, 452 + kind: 'commit', 453 + commit: new CommitEvent( 454 + rev: 'test', 455 + operation: 'create', 456 + collection: 'app.bsky.feed.post', 457 + rkey: 'test', 458 + record: (object) [ 459 + 'text' => 'Hello World!', 460 + 'createdAt' => now()->toIso8601String(), 461 + ], 462 + ), 463 + ); 464 + 465 + $signal->handle($event); 466 + 467 + // Assert your expected behavior 468 + } 469 + } 470 + ``` 471 + 472 + ### Testing with Artisan 473 + 474 + ```bash 475 + php artisan signal:test NewPostSignal 476 + ``` 477 + 478 + --- 479 + 480 + ## Documentation 481 + 482 + For detailed documentation, see: 483 + 484 + - **[INSTALLATION.md](./INSTALLATION.md)** - Complete installation guide with troubleshooting 485 + - **[PACKAGE_SUMMARY.md](./PACKAGE_SUMMARY.md)** - Quick reference for package components 486 + - **[WILDCARD_EXAMPLES.md](./WILDCARD_EXAMPLES.md)** - Comprehensive wildcard pattern guide 487 + - **[IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md)** - Full architecture and implementation details 488 + 489 + ### External Resources 490 + 491 + - [AT Protocol Documentation](https://atproto.com/) 492 + - [Jetstream Documentation](https://docs.bsky.app/docs/advanced-guides/jetstream) 493 + - [Bluesky Lexicon](https://atproto.com/lexicons) 494 + 495 + --- 496 + 497 + ## Examples 498 + 499 + ### Monitor All Feed Activity 500 + 501 + ```php 502 + class FeedMonitorSignal extends Signal 503 + { 504 + public function eventTypes(): array 505 + { 506 + return ['commit']; 507 + } 508 + 509 + public function collections(): ?array 510 + { 511 + return ['app.bsky.feed.*']; 512 + } 513 + 514 + public function handle(JetstreamEvent $event): void 515 + { 516 + // Handles posts, likes, reposts, etc. 517 + Log::info('Feed activity', [ 518 + 'collection' => $event->getCollection(), 519 + 'operation' => $event->getOperation(), 520 + 'did' => $event->did, 521 + ]); 522 + } 523 + } 524 + ``` 525 + 526 + ### Track New Follows 527 + 528 + ```php 529 + class NewFollowSignal extends Signal 530 + { 531 + public function eventTypes(): array 532 + { 533 + return ['commit']; 534 + } 535 + 536 + public function collections(): ?array 537 + { 538 + return ['app.bsky.graph.follow']; 539 + } 540 + 541 + public function handle(JetstreamEvent $event): void 542 + { 543 + if ($event->commit->isCreate()) { 544 + $record = $event->getRecord(); 545 + 546 + // Store follow relationship 547 + Follow::create([ 548 + 'follower_did' => $event->did, 549 + 'following_did' => $record->subject, 550 + ]); 551 + } 552 + } 553 + } 554 + ``` 555 + 556 + ### Content Moderation 557 + 558 + ```php 559 + class ModerationSignal extends Signal 560 + { 561 + public function eventTypes(): array 562 + { 563 + return ['commit']; 564 + } 565 + 566 + public function collections(): ?array 567 + { 568 + return ['app.bsky.feed.post']; 569 + } 570 + 571 + public function shouldQueue(): bool 572 + { 573 + return true; 574 + } 575 + 576 + public function handle(JetstreamEvent $event): void 577 + { 578 + $record = $event->getRecord(); 579 + 580 + if ($this->containsProhibitedContent($record->text)) { 581 + $this->flagForModeration($event->did, $record); 582 + } 583 + } 584 + } 585 + ``` 586 + 587 + --- 588 + 589 + ## Requirements 590 + 591 + - PHP 8.2 or higher 592 + - Laravel 11.0 or higher 593 + - WebSocket support (enabled by default in most environments) 594 + 595 + --- 596 + 597 + ## License 598 + 599 + The MIT License (MIT). Please see [LICENSE](LICENSE) for more information. 600 + 601 + --- 602 + 603 + ## Contributing 604 + 605 + Contributions are welcome! Please see [CONTRIBUTING.md](contributing.md) for details. 606 + 607 + --- 608 + 609 + ## Support 610 + 611 + For issues, questions, or feature requests: 612 + - Open an issue on GitHub 613 + - Check the [documentation files](#documentation) 614 + - Review the [implementation plan](./IMPLEMENTATION_PLAN.md) 615 + 616 + --- 617 + 618 + **Built for the AT Protocol ecosystem** • Made with ❤️ by Social Dept
-8
changelog.md
··· 1 - # Changelog 2 - 3 - All notable changes to `Signal` will be documented in this file. 4 - 5 - ## Version 1.0 6 - 7 - ### Added 8 - - Everything
+11 -14
composer.json
··· 1 1 { 2 2 "name": "social-dept/signal", 3 - "description": ":package_description", 3 + "description": "Laravel package for building Signals that respond to AT Protocol Jetstream events", 4 + "type": "library", 4 5 "license": "MIT", 5 - "authors": [ 6 - { 7 - "name": "Author Name", 8 - "email": "author@email.com", 9 - "homepage": "http://author.com" 10 - } 11 - ], 12 - "homepage": "https://github.com/social-dept/signal", 13 - "keywords": ["Laravel", "Signal"], 14 6 "require": { 15 - "illuminate/support": "~9" 7 + "php": "^8.2", 8 + "illuminate/support": "^11.0", 9 + "illuminate/console": "^11.0", 10 + "illuminate/database": "^11.0", 11 + "ratchet/pawl": "^0.4", 12 + "react/event-loop": "^1.5" 16 13 }, 17 14 "require-dev": { 18 - "phpunit/phpunit": "~9.0", 19 - "orchestra/testbench": "~7" 15 + "orchestra/testbench": "^9.0", 16 + "phpunit/phpunit": "^11.0" 20 17 }, 21 18 "autoload": { 22 19 "psr-4": { ··· 25 22 }, 26 23 "autoload-dev": { 27 24 "psr-4": { 28 - "SocialDept\\Signal\\Tests\\": "tests" 25 + "SocialDept\\Signal\\Tests\\": "tests/" 29 26 } 30 27 }, 31 28 "extra": {
+114 -2
config/signal.php
··· 1 1 <?php 2 2 3 3 return [ 4 - // 5 - ]; 4 + /* 5 + |-------------------------------------------------------------------------- 6 + | Jetstream WebSocket URL 7 + |-------------------------------------------------------------------------- 8 + | 9 + | The WebSocket URL for the AT Protocol Jetstream service. 10 + | US East: wss://jetstream2.us-east.bsky.network 11 + | US West: wss://jetstream1.us-west.bsky.network 12 + | 13 + */ 14 + 'websocket_url' => env('SIGNAL_JETSTREAM_URL', 'wss://jetstream2.us-east.bsky.network'), 15 + 16 + /* 17 + |-------------------------------------------------------------------------- 18 + | Cursor Storage Driver 19 + |-------------------------------------------------------------------------- 20 + | 21 + | Determines how Signal stores the cursor position for resuming after 22 + | disconnections. Options: 'database', 'redis', 'file' 23 + | 24 + */ 25 + 'cursor_storage' => env('SIGNAL_CURSOR_STORAGE', 'database'), 26 + 27 + /* 28 + |-------------------------------------------------------------------------- 29 + | Cursor Storage Configuration 30 + |-------------------------------------------------------------------------- 31 + */ 32 + 'cursor_config' => [ 33 + 'database' => [ 34 + 'table' => 'signal_cursors', 35 + 'connection' => null, // null = default connection 36 + ], 37 + 'redis' => [ 38 + 'connection' => 'default', 39 + 'key' => 'signal:cursor', 40 + ], 41 + 'file' => [ 42 + 'path' => storage_path('signal/cursor.json'), 43 + ], 44 + ], 45 + 46 + /* 47 + |-------------------------------------------------------------------------- 48 + | Signals 49 + |-------------------------------------------------------------------------- 50 + | 51 + | Register your Signals here, or use auto-discovery by placing them 52 + | in app/Signals directory. 53 + | 54 + */ 55 + 'signals' => [ 56 + // App\Signals\NewPostSignal::class, 57 + ], 58 + 59 + /* 60 + |-------------------------------------------------------------------------- 61 + | Auto-Discovery 62 + |-------------------------------------------------------------------------- 63 + | 64 + | Automatically discover Signals in the specified directory. 65 + | 66 + */ 67 + 'auto_discovery' => [ 68 + 'enabled' => true, 69 + 'path' => app_path('Signals'), 70 + 'namespace' => 'App\\Signals', 71 + ], 72 + 73 + /* 74 + |-------------------------------------------------------------------------- 75 + | Queue Configuration 76 + |-------------------------------------------------------------------------- 77 + | 78 + | Default queue settings for Signals that should be queued. 79 + | 80 + */ 81 + 'queue' => [ 82 + 'connection' => env('SIGNAL_QUEUE_CONNECTION', 'default'), 83 + 'queue' => env('SIGNAL_QUEUE', 'signal'), 84 + ], 85 + 86 + /* 87 + |-------------------------------------------------------------------------- 88 + | Connection Settings 89 + |-------------------------------------------------------------------------- 90 + */ 91 + 'connection' => [ 92 + 'reconnect_attempts' => 5, 93 + 'reconnect_delay' => 5, // seconds 94 + 'ping_interval' => 30, // seconds 95 + 'timeout' => 60, // seconds 96 + ], 97 + 98 + /* 99 + |-------------------------------------------------------------------------- 100 + | Performance & Rate Limiting 101 + |-------------------------------------------------------------------------- 102 + */ 103 + 'performance' => [ 104 + 'batch_size' => env('SIGNAL_BATCH_SIZE', 100), 105 + 'rate_limit' => env('SIGNAL_RATE_LIMIT', 1000), // events per minute 106 + ], 107 + 108 + /* 109 + |-------------------------------------------------------------------------- 110 + | Logging 111 + |-------------------------------------------------------------------------- 112 + */ 113 + 'logging' => [ 114 + 'channel' => env('SIGNAL_LOG_CHANNEL', 'stack'), 115 + 'level' => env('SIGNAL_LOG_LEVEL', 'info'), 116 + ], 117 + ];
-27
contributing.md
··· 1 - # Contributing 2 - 3 - Contributions are welcome and will be fully credited. 4 - 5 - Contributions are accepted via Pull Requests on [Github](https://github.com/social-dept/signal). 6 - 7 - # Things you could do 8 - If you want to contribute but do not know where to start, this list provides some starting points. 9 - - Add license text 10 - - Remove rewriteRules.php 11 - - Set up TravisCI, StyleCI, ScrutinizerCI 12 - - Write a comprehensive ReadMe 13 - 14 - ## Pull Requests 15 - 16 - - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 17 - 18 - - **Document any change in behaviour** - Make sure the `readme.md` and any other relevant documentation are kept up-to-date. 19 - 20 - - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 21 - 22 - - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 23 - 24 - - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 25 - 26 - 27 - **Happy coding**!
+25
database/migrations/2024_01_01_000000_create_signal_cursors_table.php
··· 1 + <?php 2 + 3 + use Illuminate\Database\Migrations\Migration; 4 + use Illuminate\Database\Schema\Blueprint; 5 + use Illuminate\Support\Facades\Schema; 6 + 7 + return new class extends Migration 8 + { 9 + public function up(): void 10 + { 11 + Schema::create('signal_cursors', function (Blueprint $table) { 12 + $table->id(); 13 + $table->string('key')->unique(); 14 + $table->bigInteger('cursor'); 15 + $table->timestamps(); 16 + 17 + $table->index('key'); 18 + }); 19 + } 20 + 21 + public function down(): void 22 + { 23 + Schema::dropIfExists('signal_cursors'); 24 + } 25 + };
-5
license.md
··· 1 - # The license 2 - 3 - Copyright (c) Author Name <author@email.com> 4 - 5 - ...Add your license text here...
+12 -17
phpunit.xml
··· 1 1 <?xml version="1.0" encoding="UTF-8"?> 2 - <phpunit bootstrap="vendor/autoload.php" 3 - backupGlobals="false" 4 - backupStaticAttributes="false" 5 - colors="true" 6 - verbose="true" 7 - convertErrorsToExceptions="true" 8 - convertNoticesToExceptions="true" 9 - convertWarningsToExceptions="true" 10 - processIsolation="false" 11 - stopOnFailure="false"> 2 + <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 3 + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" 4 + bootstrap="vendor/autoload.php" 5 + colors="true"> 12 6 <testsuites> 13 - <testsuite name="Package"> 14 - <directory suffix=".php">./tests/</directory> 7 + <testsuite name="Signal Test Suite"> 8 + <directory>tests</directory> 15 9 </testsuite> 16 10 </testsuites> 17 - <filter> 18 - <whitelist> 19 - <directory>src/</directory> 20 - </whitelist> 21 - </filter> 11 + <php> 12 + <env name="APP_ENV" value="testing"/> 13 + <env name="CACHE_DRIVER" value="array"/> 14 + <env name="SESSION_DRIVER" value="array"/> 15 + <env name="QUEUE_CONNECTION" value="sync"/> 16 + </php> 22 17 </phpunit>
-57
readme.md
··· 1 - # Signal 2 - 3 - [![Latest Version on Packagist][ico-version]][link-packagist] 4 - [![Total Downloads][ico-downloads]][link-downloads] 5 - [![Build Status][ico-travis]][link-travis] 6 - [![StyleCI][ico-styleci]][link-styleci] 7 - 8 - This is where your description should go. Take a look at [contributing.md](contributing.md) to see a to do list. 9 - 10 - ## Installation 11 - 12 - Via Composer 13 - 14 - ```bash 15 - composer require social-dept/signal 16 - ``` 17 - 18 - ## Usage 19 - 20 - ## Change log 21 - 22 - Please see the [changelog](changelog.md) for more information on what has changed recently. 23 - 24 - ## Testing 25 - 26 - ```bash 27 - composer test 28 - ``` 29 - 30 - ## Contributing 31 - 32 - Please see [contributing.md](contributing.md) for details and a todolist. 33 - 34 - ## Security 35 - 36 - If you discover any security related issues, please email author@email.com instead of using the issue tracker. 37 - 38 - ## Credits 39 - 40 - - [Author Name][link-author] 41 - - [All Contributors][link-contributors] 42 - 43 - ## License 44 - 45 - MIT. Please see the [license file](license.md) for more information. 46 - 47 - [ico-version]: https://img.shields.io/packagist/v/social-dept/signal.svg?style=flat-square 48 - [ico-downloads]: https://img.shields.io/packagist/dt/social-dept/signal.svg?style=flat-square 49 - [ico-travis]: https://img.shields.io/travis/social-dept/signal/master.svg?style=flat-square 50 - [ico-styleci]: https://styleci.io/repos/12345678/shield 51 - 52 - [link-packagist]: https://packagist.org/packages/social-dept/signal 53 - [link-downloads]: https://packagist.org/packages/social-dept/signal 54 - [link-travis]: https://travis-ci.org/social-dept/signal 55 - [link-styleci]: https://styleci.io/repos/12345678 56 - [link-author]: https://github.com/social-dept 57 - [link-contributors]: ../../contributors
+67
src/Commands/ConsumeCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Commands; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\Signal\Services\JetstreamConsumer; 7 + use SocialDept\Signal\Services\SignalRegistry; 8 + 9 + class ConsumeCommand extends Command 10 + { 11 + protected $signature = 'signal:consume 12 + {--cursor= : Start from a specific cursor position} 13 + {--fresh : Start from the beginning, ignoring stored cursor}'; 14 + 15 + protected $description = 'Start consuming events from the AT Protocol Jetstream'; 16 + 17 + public function handle(JetstreamConsumer $consumer, SignalRegistry $registry): int 18 + { 19 + $this->info('Signal: Initializing Jetstream consumer...'); 20 + 21 + // Discover signals 22 + $registry->discover(); 23 + 24 + $signalCount = $registry->all()->count(); 25 + $this->info("Registered {$signalCount} signal(s)"); 26 + 27 + if ($signalCount === 0) { 28 + $this->warn('No signals registered. Create signals in app/Signals or register them in config/signal.php'); 29 + return self::FAILURE; 30 + } 31 + 32 + // List registered signals 33 + $this->table( 34 + ['Signal', 'Event Types', 'Collections'], 35 + $registry->all()->map(function ($signal) { 36 + return [ 37 + get_class($signal), 38 + implode(', ', $signal->eventTypes()), 39 + $signal->collections() ? implode(', ', $signal->collections()) : 'All', 40 + ]; 41 + }) 42 + ); 43 + 44 + // Determine cursor 45 + $cursor = null; 46 + if ($this->option('fresh')) { 47 + $this->info('Starting fresh from the beginning'); 48 + } elseif ($this->option('cursor')) { 49 + $cursor = (int) $this->option('cursor'); 50 + $this->info("Starting from cursor: {$cursor}"); 51 + } else { 52 + $this->info('Resuming from stored cursor position'); 53 + } 54 + 55 + // Start consuming 56 + $this->info('Starting Jetstream consumer... Press Ctrl+C to stop.'); 57 + 58 + try { 59 + $consumer->start($cursor); 60 + } catch (\Exception $e) { 61 + $this->error('Error: ' . $e->getMessage()); 62 + return self::FAILURE; 63 + } 64 + 65 + return self::SUCCESS; 66 + } 67 + }
+57
src/Commands/InstallCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Commands; 4 + 5 + use Illuminate\Console\Command; 6 + 7 + class InstallCommand extends Command 8 + { 9 + protected $signature = 'signal:install'; 10 + 11 + protected $description = 'Install the Signal package (publish config, migrations, and run migrations)'; 12 + 13 + public function handle(): int 14 + { 15 + $this->info('Installing Signal package...'); 16 + $this->newLine(); 17 + 18 + // Publish config 19 + $this->comment('Publishing configuration...'); 20 + $this->callSilently('vendor:publish', [ 21 + '--tag' => 'signal-config', 22 + '--force' => $this->option('force', false), 23 + ]); 24 + $this->info('✓ Configuration published'); 25 + 26 + // Publish migrations 27 + $this->comment('Publishing migrations...'); 28 + $this->callSilently('vendor:publish', [ 29 + '--tag' => 'signal-migrations', 30 + '--force' => $this->option('force', false), 31 + ]); 32 + $this->info('✓ Migrations published'); 33 + 34 + // Run migrations 35 + $this->newLine(); 36 + $this->comment('Running migrations...'); 37 + 38 + if ($this->confirm('Do you want to run the migrations now?', true)) { 39 + $this->call('migrate'); 40 + $this->info('✓ Migrations completed'); 41 + } else { 42 + $this->warn('⚠ Skipped migrations. Run "php artisan migrate" manually when ready.'); 43 + } 44 + 45 + $this->newLine(); 46 + $this->info('Signal package installed successfully!'); 47 + $this->newLine(); 48 + 49 + // Show next steps 50 + $this->line('Next steps:'); 51 + $this->line('1. Review the config file: config/signal.php'); 52 + $this->line('2. Create your first signal: php artisan make:signal NewPostSignal'); 53 + $this->line('3. Start consuming events: php artisan signal:consume'); 54 + 55 + return self::SUCCESS; 56 + } 57 + }
+44
src/Commands/ListSignalsCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Commands; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\Signal\Services\SignalRegistry; 7 + 8 + class ListSignalsCommand extends Command 9 + { 10 + protected $signature = 'signal:list'; 11 + 12 + protected $description = 'List all registered Signals'; 13 + 14 + public function handle(SignalRegistry $registry): int 15 + { 16 + $registry->discover(); 17 + 18 + $signals = $registry->all(); 19 + 20 + if ($signals->isEmpty()) { 21 + $this->warn('No signals registered.'); 22 + $this->info('Create signals in app/Signals or register them in config/signal.php'); 23 + return self::SUCCESS; 24 + } 25 + 26 + $this->info("Found {$signals->count()} signal(s):"); 27 + $this->newLine(); 28 + 29 + $this->table( 30 + ['Signal', 'Event Types', 'Collections', 'DIDs', 'Queued'], 31 + $signals->map(function ($signal) { 32 + return [ 33 + get_class($signal), 34 + implode(', ', $signal->eventTypes()), 35 + $signal->collections() ? implode(', ', $signal->collections()) : 'All', 36 + $signal->dids() ? implode(', ', $signal->dids()) : 'All', 37 + $signal->shouldQueue() ? 'Yes' : 'No', 38 + ]; 39 + }) 40 + ); 41 + 42 + return self::SUCCESS; 43 + } 44 + }
+46
src/Commands/MakeSignalCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Commands; 4 + 5 + use Illuminate\Console\GeneratorCommand; 6 + use Symfony\Component\Console\Input\InputOption; 7 + 8 + class MakeSignalCommand extends GeneratorCommand 9 + { 10 + protected $name = 'make:signal'; 11 + 12 + protected $description = 'Create a new Signal class'; 13 + 14 + protected $type = 'Signal'; 15 + 16 + protected function getStub(): string 17 + { 18 + return __DIR__ . '/../../stubs/signal.stub'; 19 + } 20 + 21 + protected function getDefaultNamespace($rootNamespace): string 22 + { 23 + return $rootNamespace . '\\Signals'; 24 + } 25 + 26 + protected function buildClass($name): string 27 + { 28 + $stub = parent::buildClass($name); 29 + 30 + $eventType = $this->option('type') ?? 'commit'; 31 + $collection = $this->option('collection') ?? 'app.bsky.feed.post'; 32 + 33 + $stub = str_replace('{{ eventType }}', $eventType, $stub); 34 + $stub = str_replace('{{ collection }}', $collection, $stub); 35 + 36 + return $stub; 37 + } 38 + 39 + protected function getOptions(): array 40 + { 41 + return [ 42 + ['type', 't', InputOption::VALUE_OPTIONAL, 'The event type (commit, identity, account)'], 43 + ['collection', 'c', InputOption::VALUE_OPTIONAL, 'The collection to watch'], 44 + ]; 45 + } 46 + }
+84
src/Commands/TestSignalCommand.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Commands; 4 + 5 + use Illuminate\Console\Command; 6 + use SocialDept\Signal\Events\JetstreamEvent; 7 + use SocialDept\Signal\Events\CommitEvent; 8 + 9 + class TestSignalCommand extends Command 10 + { 11 + protected $signature = 'signal:test 12 + {signal : The Signal class name} 13 + {--sample=commit : The type of sample event to use}'; 14 + 15 + protected $description = 'Test a Signal with sample data'; 16 + 17 + public function handle(): int 18 + { 19 + $signalClass = $this->argument('signal'); 20 + 21 + // Try to resolve the class 22 + if (!class_exists($signalClass)) { 23 + $signalClass = 'App\\Signals\\' . $signalClass; 24 + } 25 + 26 + if (!class_exists($signalClass)) { 27 + $this->error("Signal class not found: {$signalClass}"); 28 + return self::FAILURE; 29 + } 30 + 31 + $signal = app($signalClass); 32 + 33 + $this->info("Testing signal: {$signalClass}"); 34 + $this->newLine(); 35 + 36 + // Create sample event 37 + $event = $this->createSampleEvent($this->option('sample')); 38 + 39 + $this->info('Sample event created:'); 40 + $this->line(json_encode($event->toArray(), JSON_PRETTY_PRINT)); 41 + $this->newLine(); 42 + 43 + try { 44 + if ($signal->shouldHandle($event)) { 45 + $this->info('Calling signal->handle()...'); 46 + $signal->handle($event); 47 + $this->info('✓ Signal executed successfully'); 48 + } else { 49 + $this->warn('Signal->shouldHandle() returned false'); 50 + } 51 + } catch (\Exception $e) { 52 + $this->error('Error executing signal: ' . $e->getMessage()); 53 + return self::FAILURE; 54 + } 55 + 56 + return self::SUCCESS; 57 + } 58 + 59 + protected function createSampleEvent(string $type): JetstreamEvent 60 + { 61 + switch ($type) { 62 + case 'commit': 63 + return new JetstreamEvent( 64 + did: 'did:plc:sample123456789', 65 + timeUs: time() * 1000000, 66 + kind: 'commit', 67 + commit: new CommitEvent( 68 + rev: 'sample-rev', 69 + operation: 'create', 70 + collection: 'app.bsky.feed.post', 71 + rkey: 'sample-rkey', 72 + record: (object) [ 73 + 'text' => 'This is a sample post for testing', 74 + 'createdAt' => now()->toIso8601String(), 75 + ], 76 + cid: 'sample-cid', 77 + ), 78 + ); 79 + 80 + default: 81 + throw new \InvalidArgumentException("Unknown sample type: {$type}"); 82 + } 83 + } 84 + }
+21
src/Contracts/CursorStore.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Contracts; 4 + 5 + interface CursorStore 6 + { 7 + /** 8 + * Get the current cursor position. 9 + */ 10 + public function get(): ?int; 11 + 12 + /** 13 + * Set the cursor position. 14 + */ 15 + public function set(int $cursor): void; 16 + 17 + /** 18 + * Clear the cursor position. 19 + */ 20 + public function clear(): void; 21 + }
+36
src/Events/AccountEvent.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Events; 4 + 5 + class AccountEvent 6 + { 7 + public function __construct( 8 + public string $did, 9 + public bool $active, 10 + public ?string $status = null, 11 + public int $seq = 0, 12 + public ?string $time = null, 13 + ) {} 14 + 15 + public static function fromArray(array $data): self 16 + { 17 + return new self( 18 + did: $data['did'], 19 + active: $data['active'], 20 + status: $data['status'] ?? null, 21 + seq: $data['seq'] ?? 0, 22 + time: $data['time'] ?? null, 23 + ); 24 + } 25 + 26 + public function toArray(): array 27 + { 28 + return [ 29 + 'did' => $this->did, 30 + 'active' => $this->active, 31 + 'status' => $this->status, 32 + 'seq' => $this->seq, 33 + 'time' => $this->time, 34 + ]; 35 + } 36 + }
+59
src/Events/CommitEvent.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Events; 4 + 5 + class CommitEvent 6 + { 7 + public function __construct( 8 + public string $rev, 9 + public string $operation, // 'create', 'update', 'delete' 10 + public string $collection, 11 + public string $rkey, 12 + public ?object $record = null, 13 + public ?string $cid = null, 14 + ) {} 15 + 16 + public function isCreate(): bool 17 + { 18 + return $this->operation === 'create'; 19 + } 20 + 21 + public function isUpdate(): bool 22 + { 23 + return $this->operation === 'update'; 24 + } 25 + 26 + public function isDelete(): bool 27 + { 28 + return $this->operation === 'delete'; 29 + } 30 + 31 + public function uri(): string 32 + { 33 + return "at://{$this->collection}/{$this->rkey}"; 34 + } 35 + 36 + public static function fromArray(array $data): self 37 + { 38 + return new self( 39 + rev: $data['rev'], 40 + operation: $data['operation'], 41 + collection: $data['collection'], 42 + rkey: $data['rkey'], 43 + record: isset($data['record']) ? (object) $data['record'] : null, 44 + cid: $data['cid'] ?? null, 45 + ); 46 + } 47 + 48 + public function toArray(): array 49 + { 50 + return [ 51 + 'rev' => $this->rev, 52 + 'operation' => $this->operation, 53 + 'collection' => $this->collection, 54 + 'rkey' => $this->rkey, 55 + 'record' => $this->record, 56 + 'cid' => $this->cid, 57 + ]; 58 + } 59 + }
+33
src/Events/IdentityEvent.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Events; 4 + 5 + class IdentityEvent 6 + { 7 + public function __construct( 8 + public string $did, 9 + public ?string $handle = null, 10 + public int $seq = 0, 11 + public ?string $time = null, 12 + ) {} 13 + 14 + public static function fromArray(array $data): self 15 + { 16 + return new self( 17 + did: $data['did'], 18 + handle: $data['handle'] ?? null, 19 + seq: $data['seq'] ?? 0, 20 + time: $data['time'] ?? null, 21 + ); 22 + } 23 + 24 + public function toArray(): array 25 + { 26 + return [ 27 + 'did' => $this->did, 28 + 'handle' => $this->handle, 29 + 'seq' => $this->seq, 30 + 'time' => $this->time, 31 + ]; 32 + } 33 + }
+81
src/Events/JetstreamEvent.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Events; 4 + 5 + class JetstreamEvent 6 + { 7 + public function __construct( 8 + public string $did, 9 + public int $timeUs, 10 + public string $kind, // 'commit', 'identity', 'account' 11 + public ?CommitEvent $commit = null, 12 + public ?IdentityEvent $identity = null, 13 + public ?AccountEvent $account = null, 14 + ) {} 15 + 16 + public function isCommit(): bool 17 + { 18 + return $this->kind === 'commit'; 19 + } 20 + 21 + public function isIdentity(): bool 22 + { 23 + return $this->kind === 'identity'; 24 + } 25 + 26 + public function isAccount(): bool 27 + { 28 + return $this->kind === 'account'; 29 + } 30 + 31 + public function getCollection(): ?string 32 + { 33 + return $this->commit?->collection; 34 + } 35 + 36 + public function getRecord(): ?object 37 + { 38 + return $this->commit?->record; 39 + } 40 + 41 + public function getOperation(): ?string 42 + { 43 + return $this->commit?->operation; 44 + } 45 + 46 + public static function fromArray(array $data): self 47 + { 48 + $commit = isset($data['commit']) 49 + ? CommitEvent::fromArray($data['commit']) 50 + : null; 51 + 52 + $identity = isset($data['identity']) 53 + ? IdentityEvent::fromArray($data['identity']) 54 + : null; 55 + 56 + $account = isset($data['account']) 57 + ? AccountEvent::fromArray($data['account']) 58 + : null; 59 + 60 + return new self( 61 + did: $data['did'], 62 + timeUs: $data['time_us'], 63 + kind: $data['kind'], 64 + commit: $commit, 65 + identity: $identity, 66 + account: $account, 67 + ); 68 + } 69 + 70 + public function toArray(): array 71 + { 72 + return [ 73 + 'did' => $this->did, 74 + 'time_us' => $this->timeUs, 75 + 'kind' => $this->kind, 76 + 'commit' => $this->commit?->toArray(), 77 + 'identity' => $this->identity?->toArray(), 78 + 'account' => $this->account?->toArray(), 79 + ]; 80 + } 81 + }
+8
src/Exceptions/ConnectionException.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Exceptions; 4 + 5 + class ConnectionException extends \Exception 6 + { 7 + // 8 + }
+8
src/Exceptions/SignalException.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Exceptions; 4 + 5 + class SignalException extends \Exception 6 + { 7 + // 8 + }
+8 -6
src/Facades/Signal.php
··· 3 3 namespace SocialDept\Signal\Facades; 4 4 5 5 use Illuminate\Support\Facades\Facade; 6 + use SocialDept\Signal\Services\JetstreamConsumer; 6 7 8 + /** 9 + * @method static void start(?int $cursor = null) 10 + * @method static void stop() 11 + * 12 + * @see \SocialDept\Signal\Services\JetstreamConsumer 13 + */ 7 14 class Signal extends Facade 8 15 { 9 - /** 10 - * Get the registered name of the component. 11 - * 12 - * @return string 13 - */ 14 16 protected static function getFacadeAccessor(): string 15 17 { 16 - return 'signal'; 18 + return JetstreamConsumer::class; 17 19 } 18 20 }
+31
src/Jobs/ProcessSignalJob.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Jobs; 4 + 5 + use Illuminate\Bus\Queueable; 6 + use Illuminate\Contracts\Queue\ShouldQueue; 7 + use Illuminate\Foundation\Bus\Dispatchable; 8 + use Illuminate\Queue\InteractsWithQueue; 9 + use Illuminate\Queue\SerializesModels; 10 + use SocialDept\Signal\Events\JetstreamEvent; 11 + use SocialDept\Signal\Signals\Signal; 12 + 13 + class ProcessSignalJob implements ShouldQueue 14 + { 15 + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; 16 + 17 + public function __construct( 18 + protected Signal $signal, 19 + protected JetstreamEvent $event, 20 + ) {} 21 + 22 + public function handle(): void 23 + { 24 + $this->signal->handle($this->event); 25 + } 26 + 27 + public function failed(\Throwable $exception): void 28 + { 29 + $this->signal->failed($this->event, $exception); 30 + } 31 + }
+61
src/Services/EventDispatcher.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Services; 4 + 5 + use Illuminate\Support\Facades\Log; 6 + use Illuminate\Support\Facades\Queue; 7 + use SocialDept\Signal\Events\JetstreamEvent; 8 + use SocialDept\Signal\Jobs\ProcessSignalJob; 9 + 10 + class EventDispatcher 11 + { 12 + protected SignalRegistry $signalRegistry; 13 + 14 + public function __construct(SignalRegistry $signalRegistry) 15 + { 16 + $this->signalRegistry = $signalRegistry; 17 + } 18 + 19 + /** 20 + * Dispatch event to matching signals. 21 + */ 22 + public function dispatch(JetstreamEvent $event): void 23 + { 24 + $signals = $this->signalRegistry->getMatchingSignals($event); 25 + 26 + foreach ($signals as $signal) { 27 + try { 28 + if ($signal->shouldQueue()) { 29 + $this->dispatchToQueue($signal, $event); 30 + } else { 31 + $this->dispatchSync($signal, $event); 32 + } 33 + } catch (\Exception $e) { 34 + Log::error('Signal: Error dispatching to signal', [ 35 + 'signal' => get_class($signal), 36 + 'error' => $e->getMessage(), 37 + ]); 38 + 39 + $signal->failed($event, $e); 40 + } 41 + } 42 + } 43 + 44 + /** 45 + * Dispatch signal synchronously. 46 + */ 47 + protected function dispatchSync($signal, JetstreamEvent $event): void 48 + { 49 + $signal->handle($event); 50 + } 51 + 52 + /** 53 + * Dispatch signal to queue. 54 + */ 55 + protected function dispatchToQueue($signal, JetstreamEvent $event): void 56 + { 57 + ProcessSignalJob::dispatch($signal, $event) 58 + ->onConnection($signal->queueConnection()) 59 + ->onQueue($signal->queue()); 60 + } 61 + }
+206
src/Services/JetstreamConsumer.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Services; 4 + 5 + use Illuminate\Support\Facades\Log; 6 + use Ratchet\Client\Connector; 7 + use Ratchet\Client\WebSocket; 8 + use React\EventLoop\Loop; 9 + use SocialDept\Signal\Contracts\CursorStore; 10 + use SocialDept\Signal\Events\JetstreamEvent; 11 + use SocialDept\Signal\Exceptions\ConnectionException; 12 + 13 + class JetstreamConsumer 14 + { 15 + protected CursorStore $cursorStore; 16 + protected SignalRegistry $signalRegistry; 17 + protected EventDispatcher $eventDispatcher; 18 + protected ?WebSocket $connection = null; 19 + protected int $reconnectAttempts = 0; 20 + protected bool $shouldStop = false; 21 + 22 + public function __construct( 23 + CursorStore $cursorStore, 24 + SignalRegistry $signalRegistry, 25 + EventDispatcher $eventDispatcher 26 + ) { 27 + $this->cursorStore = $cursorStore; 28 + $this->signalRegistry = $signalRegistry; 29 + $this->eventDispatcher = $eventDispatcher; 30 + } 31 + 32 + /** 33 + * Start consuming the Jetstream. 34 + */ 35 + public function start(?int $cursor = null): void 36 + { 37 + $this->shouldStop = false; 38 + 39 + // Get cursor from storage if not provided 40 + if ($cursor === null) { 41 + $cursor = $this->cursorStore->get(); 42 + } 43 + 44 + $url = $this->buildWebSocketUrl($cursor); 45 + 46 + Log::info('Signal: Starting Jetstream consumer', [ 47 + 'url' => $url, 48 + 'cursor' => $cursor, 49 + ]); 50 + 51 + $this->connect($url); 52 + } 53 + 54 + /** 55 + * Stop consuming the Jetstream. 56 + */ 57 + public function stop(): void 58 + { 59 + $this->shouldStop = true; 60 + 61 + if ($this->connection) { 62 + $this->connection->close(); 63 + } 64 + 65 + Log::info('Signal: Jetstream consumer stopped'); 66 + } 67 + 68 + /** 69 + * Connect to the Jetstream WebSocket. 70 + */ 71 + protected function connect(string $url): void 72 + { 73 + $loop = Loop::get(); 74 + $connector = new Connector($loop); 75 + 76 + $connector($url)->then( 77 + function (WebSocket $conn) { 78 + $this->connection = $conn; 79 + $this->reconnectAttempts = 0; 80 + 81 + Log::info('Signal: Connected to Jetstream'); 82 + 83 + $conn->on('message', function ($msg) { 84 + $this->handleMessage($msg); 85 + }); 86 + 87 + $conn->on('close', function ($code, $reason) { 88 + Log::warning('Signal: Connection closed', [ 89 + 'code' => $code, 90 + 'reason' => $reason, 91 + ]); 92 + 93 + if (!$this->shouldStop) { 94 + $this->attemptReconnect(); 95 + } 96 + }); 97 + 98 + $conn->on('error', function (\Exception $e) { 99 + Log::error('Signal: Connection error', [ 100 + 'error' => $e->getMessage(), 101 + ]); 102 + }); 103 + 104 + // Setup ping interval to keep connection alive 105 + $this->setupPingInterval($conn, $loop); 106 + }, 107 + function (\Exception $e) { 108 + Log::error('Signal: Could not connect to Jetstream', [ 109 + 'error' => $e->getMessage(), 110 + ]); 111 + 112 + if (!$this->shouldStop) { 113 + $this->attemptReconnect(); 114 + } 115 + } 116 + ); 117 + 118 + $loop->run(); 119 + } 120 + 121 + /** 122 + * Handle incoming WebSocket message. 123 + */ 124 + protected function handleMessage($message): void 125 + { 126 + try { 127 + $data = json_decode($message, true); 128 + 129 + if (!$data) { 130 + Log::warning('Signal: Failed to decode message'); 131 + return; 132 + } 133 + 134 + $event = JetstreamEvent::fromArray($data); 135 + 136 + // Update cursor 137 + $this->cursorStore->set($event->timeUs); 138 + 139 + // Dispatch to matching signals 140 + $this->eventDispatcher->dispatch($event); 141 + 142 + } catch (\Exception $e) { 143 + Log::error('Signal: Error handling message', [ 144 + 'error' => $e->getMessage(), 145 + 'trace' => $e->getTraceAsString(), 146 + ]); 147 + } 148 + } 149 + 150 + /** 151 + * Attempt to reconnect to the Jetstream. 152 + */ 153 + protected function attemptReconnect(): void 154 + { 155 + $maxAttempts = config('signal.connection.reconnect_attempts', 5); 156 + $delay = config('signal.connection.reconnect_delay', 5); 157 + 158 + if ($this->reconnectAttempts >= $maxAttempts) { 159 + Log::error('Signal: Max reconnection attempts reached'); 160 + throw new ConnectionException('Failed to reconnect to Jetstream after ' . $maxAttempts . ' attempts'); 161 + } 162 + 163 + $this->reconnectAttempts++; 164 + 165 + Log::info('Signal: Attempting to reconnect', [ 166 + 'attempt' => $this->reconnectAttempts, 167 + 'max_attempts' => $maxAttempts, 168 + ]); 169 + 170 + sleep($delay); 171 + 172 + $cursor = $this->cursorStore->get(); 173 + $url = $this->buildWebSocketUrl($cursor); 174 + 175 + $this->connect($url); 176 + } 177 + 178 + /** 179 + * Setup ping interval to keep connection alive. 180 + */ 181 + protected function setupPingInterval(WebSocket $conn, $loop): void 182 + { 183 + $interval = config('signal.connection.ping_interval', 30); 184 + 185 + $loop->addPeriodicTimer($interval, function () use ($conn) { 186 + if ($conn->getReadyState() === WebSocket::STATE_OPEN) { 187 + $conn->send(json_encode(['type' => 'ping'])); 188 + } 189 + }); 190 + } 191 + 192 + /** 193 + * Build the WebSocket URL with optional cursor. 194 + */ 195 + protected function buildWebSocketUrl(?int $cursor = null): string 196 + { 197 + $baseUrl = config('signal.websocket_url', 'wss://jetstream2.us-east.bsky.network'); 198 + $url = rtrim($baseUrl, '/') . '/subscribe'; 199 + 200 + if ($cursor !== null) { 201 + $url .= '?cursor=' . $cursor; 202 + } 203 + 204 + return $url; 205 + } 206 + }
+137
src/Services/SignalRegistry.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Services; 4 + 5 + use Illuminate\Support\Collection; 6 + use Illuminate\Support\Facades\File; 7 + use ReflectionClass; 8 + use SocialDept\Signal\Signals\Signal; 9 + 10 + class SignalRegistry 11 + { 12 + protected Collection $signals; 13 + 14 + public function __construct() 15 + { 16 + $this->signals = collect(); 17 + } 18 + 19 + /** 20 + * Register a signal. 21 + */ 22 + public function register(string $signalClass): void 23 + { 24 + if (!is_subclass_of($signalClass, Signal::class)) { 25 + throw new \InvalidArgumentException( 26 + "Signal class must extend " . Signal::class 27 + ); 28 + } 29 + 30 + $this->signals->push($signalClass); 31 + } 32 + 33 + /** 34 + * Get all registered signals. 35 + */ 36 + public function all(): Collection 37 + { 38 + return $this->signals->map(fn($class) => app($class)); 39 + } 40 + 41 + /** 42 + * Auto-discover signals in the configured directory. 43 + */ 44 + public function discover(): void 45 + { 46 + if (!config('signal.auto_discovery.enabled', true)) { 47 + return; 48 + } 49 + 50 + $path = config('signal.auto_discovery.path', app_path('Signals')); 51 + $namespace = config('signal.auto_discovery.namespace', 'App\\Signals'); 52 + 53 + if (!File::exists($path)) { 54 + return; 55 + } 56 + 57 + $files = File::allFiles($path); 58 + 59 + foreach ($files as $file) { 60 + $class = $namespace . '\\' . $file->getFilenameWithoutExtension(); 61 + 62 + if (class_exists($class) && is_subclass_of($class, Signal::class)) { 63 + $this->register($class); 64 + } 65 + } 66 + } 67 + 68 + /** 69 + * Get signals that match the given event. 70 + */ 71 + public function getMatchingSignals($event): Collection 72 + { 73 + return $this->all()->filter(function (Signal $signal) use ($event) { 74 + // Check event type 75 + if (!in_array($event->kind, $signal->eventTypes())) { 76 + return false; 77 + } 78 + 79 + // Check collections filter (with wildcard support) 80 + if ($signal->collections() !== null && $event->isCommit()) { 81 + if (!$this->matchesCollection($event->getCollection(), $signal->collections())) { 82 + return false; 83 + } 84 + } 85 + 86 + // Check DIDs filter 87 + if ($signal->dids() !== null) { 88 + if (!in_array($event->did, $signal->dids())) { 89 + return false; 90 + } 91 + } 92 + 93 + // Check custom shouldHandle logic 94 + if (!$signal->shouldHandle($event)) { 95 + return false; 96 + } 97 + 98 + return true; 99 + }); 100 + } 101 + 102 + /** 103 + * Check if a collection matches any of the patterns (supports wildcards). 104 + * 105 + * @param string|null $collection The actual collection from the event 106 + * @param array $patterns Array of collection patterns (may include wildcards like 'app.bsky.feed.*') 107 + * @return bool 108 + */ 109 + protected function matchesCollection(?string $collection, array $patterns): bool 110 + { 111 + if ($collection === null) { 112 + return false; 113 + } 114 + 115 + foreach ($patterns as $pattern) { 116 + // Exact match 117 + if ($pattern === $collection) { 118 + return true; 119 + } 120 + 121 + // Wildcard match 122 + if (str_contains($pattern, '*')) { 123 + // Convert wildcard pattern to regex 124 + // Escape special regex characters except * 125 + $regex = preg_quote($pattern, '/'); 126 + // Replace escaped \* with .* for regex wildcard 127 + $regex = str_replace('\*', '.*', $regex); 128 + 129 + if (preg_match('/^' . $regex . '$/', $collection)) { 130 + return true; 131 + } 132 + } 133 + } 134 + 135 + return false; 136 + } 137 + }
-8
src/Signal.php
··· 1 - <?php 2 - 3 - namespace SocialDept\Signal; 4 - 5 - class Signal 6 - { 7 - // Build wonderful things 8 - }
+65 -60
src/SignalServiceProvider.php
··· 3 3 namespace SocialDept\Signal; 4 4 5 5 use Illuminate\Support\ServiceProvider; 6 + use SocialDept\Signal\Commands\ConsumeCommand; 7 + use SocialDept\Signal\Commands\InstallCommand; 8 + use SocialDept\Signal\Commands\ListSignalsCommand; 9 + use SocialDept\Signal\Commands\MakeSignalCommand; 10 + use SocialDept\Signal\Commands\TestSignalCommand; 11 + use SocialDept\Signal\Contracts\CursorStore; 12 + use SocialDept\Signal\Services\EventDispatcher; 13 + use SocialDept\Signal\Services\JetstreamConsumer; 14 + use SocialDept\Signal\Services\SignalRegistry; 15 + use SocialDept\Signal\Storage\DatabaseCursorStore; 16 + use SocialDept\Signal\Storage\FileCursorStore; 17 + use SocialDept\Signal\Storage\RedisCursorStore; 6 18 7 19 class SignalServiceProvider extends ServiceProvider 8 20 { 9 - /** 10 - * Perform post-registration booting of services. 11 - * 12 - * @return void 13 - */ 14 - public function boot(): void 21 + public function register(): void 15 22 { 16 - // $this->loadTranslationsFrom(__DIR__.'/../resources/lang', 'social-dept'); 17 - // $this->loadViewsFrom(__DIR__.'/../resources/views', 'social-dept'); 18 - // $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 19 - // $this->loadRoutesFrom(__DIR__.'/routes.php'); 23 + $this->mergeConfigFrom(__DIR__ . '/../config/signal.php', 'signal'); 24 + 25 + // Register cursor store 26 + $this->app->singleton(CursorStore::class, function ($app) { 27 + return match (config('signal.cursor_storage')) { 28 + 'redis' => new RedisCursorStore(), 29 + 'file' => new FileCursorStore(), 30 + default => new DatabaseCursorStore(), 31 + }; 32 + }); 33 + 34 + // Register signal registry 35 + $this->app->singleton(SignalRegistry::class, function ($app) { 36 + $registry = new SignalRegistry(); 20 37 21 - // Publishing is only necessary when using the CLI. 22 - if ($this->app->runningInConsole()) { 23 - $this->bootForConsole(); 24 - } 25 - } 38 + // Register configured signals 39 + foreach (config('signal.signals', []) as $signal) { 40 + $registry->register($signal); 41 + } 26 42 27 - /** 28 - * Register any package services. 29 - * 30 - * @return void 31 - */ 32 - public function register(): void 33 - { 34 - $this->mergeConfigFrom(__DIR__.'/../config/signal.php', 'signal'); 43 + return $registry; 44 + }); 35 45 36 - // Register the service the package provides. 37 - $this->app->singleton('signal', function ($app) { 38 - return new Signal; 46 + // Register event dispatcher 47 + $this->app->singleton(EventDispatcher::class, function ($app) { 48 + return new EventDispatcher($app->make(SignalRegistry::class)); 39 49 }); 40 - } 41 50 42 - /** 43 - * Get the services provided by the provider. 44 - * 45 - * @return array 46 - */ 47 - public function provides() 48 - { 49 - return ['signal']; 51 + // Register Jetstream consumer 52 + $this->app->singleton(JetstreamConsumer::class, function ($app) { 53 + return new JetstreamConsumer( 54 + $app->make(CursorStore::class), 55 + $app->make(SignalRegistry::class), 56 + $app->make(EventDispatcher::class), 57 + ); 58 + }); 50 59 } 51 60 52 - /** 53 - * Console-specific booting. 54 - * 55 - * @return void 56 - */ 57 - protected function bootForConsole(): void 61 + public function boot(): void 58 62 { 59 - // Publishing the configuration file. 60 - $this->publishes([ 61 - __DIR__.'/../config/signal.php' => config_path('signal.php'), 62 - ], 'signal.config'); 63 - 64 - // Publishing the views. 65 - /*$this->publishes([ 66 - __DIR__.'/../resources/views' => base_path('resources/views/vendor/social-dept'), 67 - ], 'signal.views');*/ 63 + if ($this->app->runningInConsole()) { 64 + // Publish config 65 + $this->publishes([ 66 + __DIR__ . '/../config/signal.php' => config_path('signal.php'), 67 + ], 'signal-config'); 68 68 69 - // Publishing assets. 70 - /*$this->publishes([ 71 - __DIR__.'/../resources/assets' => public_path('vendor/social-dept'), 72 - ], 'signal.assets');*/ 69 + // Publish migrations 70 + $this->publishes([ 71 + __DIR__ . '/../database/migrations' => database_path('migrations'), 72 + ], 'signal-migrations'); 73 73 74 - // Publishing the translation files. 75 - /*$this->publishes([ 76 - __DIR__.'/../resources/lang' => resource_path('lang/vendor/social-dept'), 77 - ], 'signal.lang');*/ 74 + // Register commands 75 + $this->commands([ 76 + InstallCommand::class, 77 + ConsumeCommand::class, 78 + ListSignalsCommand::class, 79 + MakeSignalCommand::class, 80 + TestSignalCommand::class, 81 + ]); 82 + } 78 83 79 - // Registering package commands. 80 - // $this->commands([]); 84 + // Load migrations 85 + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); 81 86 } 82 87 }
+97
src/Signals/Signal.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Signals; 4 + 5 + use SocialDept\Signal\Events\JetstreamEvent; 6 + 7 + abstract class Signal 8 + { 9 + /** 10 + * Define which event types to listen for. 11 + * 12 + * @return array<string> ['commit', 'identity', 'account'] 13 + */ 14 + abstract public function eventTypes(): array; 15 + 16 + /** 17 + * Define collections to watch (optional, null = all). 18 + * Supports wildcards using asterisk (*). 19 + * 20 + * Examples: 21 + * - ['app.bsky.feed.post'] - Only posts 22 + * - ['app.bsky.feed.*'] - All feed collections (post, like, repost, etc.) 23 + * - ['app.bsky.graph.*'] - All graph collections (follow, block, etc.) 24 + * - ['app.bsky.*'] - All app.bsky collections 25 + * 26 + * @return array<string>|null 27 + */ 28 + public function collections(): ?array 29 + { 30 + return null; 31 + } 32 + 33 + /** 34 + * Define DIDs to watch (optional, null = all). 35 + * 36 + * @return array<string>|null 37 + */ 38 + public function dids(): ?array 39 + { 40 + return null; 41 + } 42 + 43 + /** 44 + * Handle the Jetstream event. 45 + */ 46 + abstract public function handle(JetstreamEvent $event): void; 47 + 48 + /** 49 + * Determine if this signal should handle the event. 50 + */ 51 + public function shouldHandle(JetstreamEvent $event): bool 52 + { 53 + return true; 54 + } 55 + 56 + /** 57 + * Should this signal be queued? 58 + */ 59 + public function shouldQueue(): bool 60 + { 61 + return false; 62 + } 63 + 64 + /** 65 + * Get the queue connection name. 66 + */ 67 + public function queueConnection(): ?string 68 + { 69 + return config('signal.queue.connection'); 70 + } 71 + 72 + /** 73 + * Get the queue name. 74 + */ 75 + public function queue(): ?string 76 + { 77 + return config('signal.queue.queue'); 78 + } 79 + 80 + /** 81 + * Middleware to run before handling the event. 82 + * 83 + * @return array 84 + */ 85 + public function middleware(): array 86 + { 87 + return []; 88 + } 89 + 90 + /** 91 + * Handle a failed signal execution. 92 + */ 93 + public function failed(JetstreamEvent $event, \Throwable $exception): void 94 + { 95 + // 96 + } 97 + }
+52
src/Storage/DatabaseCursorStore.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Storage; 4 + 5 + use Illuminate\Support\Facades\DB; 6 + use SocialDept\Signal\Contracts\CursorStore; 7 + 8 + class DatabaseCursorStore implements CursorStore 9 + { 10 + protected string $table; 11 + protected ?string $connection; 12 + 13 + public function __construct() 14 + { 15 + $this->table = config('signal.cursor_config.database.table', 'signal_cursors'); 16 + $this->connection = config('signal.cursor_config.database.connection'); 17 + } 18 + 19 + public function get(): ?int 20 + { 21 + $cursor = $this->query() 22 + ->where('key', 'jetstream') 23 + ->value('cursor'); 24 + 25 + return $cursor ? (int) $cursor : null; 26 + } 27 + 28 + public function set(int $cursor): void 29 + { 30 + $this->query() 31 + ->updateOrInsert( 32 + ['key' => 'jetstream'], 33 + [ 34 + 'cursor' => $cursor, 35 + 'updated_at' => now(), 36 + ] 37 + ); 38 + } 39 + 40 + public function clear(): void 41 + { 42 + $this->query() 43 + ->where('key', 'jetstream') 44 + ->delete(); 45 + } 46 + 47 + protected function query() 48 + { 49 + return DB::connection($this->connection) 50 + ->table($this->table); 51 + } 52 + }
+48
src/Storage/FileCursorStore.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Storage; 4 + 5 + use Illuminate\Support\Facades\File; 6 + use SocialDept\Signal\Contracts\CursorStore; 7 + 8 + class FileCursorStore implements CursorStore 9 + { 10 + protected string $path; 11 + 12 + public function __construct() 13 + { 14 + $this->path = config('signal.cursor_config.file.path', storage_path('signal/cursor.json')); 15 + 16 + // Ensure directory exists 17 + $directory = dirname($this->path); 18 + if (!File::exists($directory)) { 19 + File::makeDirectory($directory, 0755, true); 20 + } 21 + } 22 + 23 + public function get(): ?int 24 + { 25 + if (!File::exists($this->path)) { 26 + return null; 27 + } 28 + 29 + $data = json_decode(File::get($this->path), true); 30 + 31 + return $data['cursor'] ?? null; 32 + } 33 + 34 + public function set(int $cursor): void 35 + { 36 + File::put($this->path, json_encode([ 37 + 'cursor' => $cursor, 38 + 'updated_at' => now()->toIso8601String(), 39 + ], JSON_PRETTY_PRINT)); 40 + } 41 + 42 + public function clear(): void 43 + { 44 + if (File::exists($this->path)) { 45 + File::delete($this->path); 46 + } 47 + } 48 + }
+35
src/Storage/RedisCursorStore.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Storage; 4 + 5 + use Illuminate\Support\Facades\Redis; 6 + use SocialDept\Signal\Contracts\CursorStore; 7 + 8 + class RedisCursorStore implements CursorStore 9 + { 10 + protected string $connection; 11 + protected string $key; 12 + 13 + public function __construct() 14 + { 15 + $this->connection = config('signal.cursor_config.redis.connection', 'default'); 16 + $this->key = config('signal.cursor_config.redis.key', 'signal:cursor'); 17 + } 18 + 19 + public function get(): ?int 20 + { 21 + $cursor = Redis::connection($this->connection)->get($this->key); 22 + 23 + return $cursor ? (int) $cursor : null; 24 + } 25 + 26 + public function set(int $cursor): void 27 + { 28 + Redis::connection($this->connection)->set($this->key, $cursor); 29 + } 30 + 31 + public function clear(): void 32 + { 33 + Redis::connection($this->connection)->del($this->key); 34 + } 35 + }
+63
stubs/signal.stub
··· 1 + <?php 2 + 3 + namespace {{ namespace }}; 4 + 5 + use SocialDept\Signal\Events\JetstreamEvent; 6 + use SocialDept\Signal\Signals\Signal; 7 + 8 + class {{ class }} extends Signal 9 + { 10 + /** 11 + * Define which event types to listen for. 12 + */ 13 + public function eventTypes(): array 14 + { 15 + return ['{{ eventType }}']; 16 + } 17 + 18 + /** 19 + * Define collections to watch (optional, null = all). 20 + * 21 + * Supports wildcards: 22 + * - ['app.bsky.feed.post'] - Only posts 23 + * - ['app.bsky.feed.*'] - All feed collections 24 + * - ['app.bsky.graph.*'] - All graph collections 25 + */ 26 + public function collections(): ?array 27 + { 28 + return ['{{ collection }}']; 29 + } 30 + 31 + /** 32 + * Handle the Jetstream event. 33 + */ 34 + public function handle(JetstreamEvent $event): void 35 + { 36 + // Handle the event here 37 + 38 + // Example: Access commit data 39 + if ($event->isCommit()) { 40 + $record = $event->getRecord(); 41 + $operation = $event->getOperation(); 42 + $collection = $event->getCollection(); 43 + 44 + // Your logic here 45 + } 46 + } 47 + 48 + /** 49 + * Determine if this signal should handle the event. 50 + */ 51 + public function shouldHandle(JetstreamEvent $event): bool 52 + { 53 + return true; 54 + } 55 + 56 + /** 57 + * Should this signal be queued? 58 + */ 59 + public function shouldQueue(): bool 60 + { 61 + return false; 62 + } 63 + }
+136
tests/Unit/SignalRegistryTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Tests\Unit; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Signal\Events\CommitEvent; 7 + use SocialDept\Signal\Events\JetstreamEvent; 8 + use SocialDept\Signal\Services\SignalRegistry; 9 + use SocialDept\Signal\Signals\Signal; 10 + 11 + class SignalRegistryTest extends TestCase 12 + { 13 + /** @test */ 14 + public function it_matches_exact_collections() 15 + { 16 + $registry = new SignalRegistry(); 17 + 18 + $event = new JetstreamEvent( 19 + did: 'did:plc:test', 20 + timeUs: time() * 1000000, 21 + kind: 'commit', 22 + commit: new CommitEvent( 23 + rev: 'test', 24 + operation: 'create', 25 + collection: 'app.bsky.feed.post', 26 + rkey: 'test', 27 + ), 28 + ); 29 + 30 + $result = $this->invokeMethod( 31 + $registry, 32 + 'matchesCollection', 33 + ['app.bsky.feed.post', ['app.bsky.feed.post']] 34 + ); 35 + 36 + $this->assertTrue($result); 37 + } 38 + 39 + /** @test */ 40 + public function it_matches_wildcard_collections() 41 + { 42 + $registry = new SignalRegistry(); 43 + 44 + // Test app.bsky.feed.* 45 + $this->assertTrue( 46 + $this->invokeMethod( 47 + $registry, 48 + 'matchesCollection', 49 + ['app.bsky.feed.post', ['app.bsky.feed.*']] 50 + ) 51 + ); 52 + 53 + $this->assertTrue( 54 + $this->invokeMethod( 55 + $registry, 56 + 'matchesCollection', 57 + ['app.bsky.feed.like', ['app.bsky.feed.*']] 58 + ) 59 + ); 60 + 61 + $this->assertFalse( 62 + $this->invokeMethod( 63 + $registry, 64 + 'matchesCollection', 65 + ['app.bsky.graph.follow', ['app.bsky.feed.*']] 66 + ) 67 + ); 68 + 69 + // Test app.bsky.* 70 + $this->assertTrue( 71 + $this->invokeMethod( 72 + $registry, 73 + 'matchesCollection', 74 + ['app.bsky.feed.post', ['app.bsky.*']] 75 + ) 76 + ); 77 + 78 + $this->assertTrue( 79 + $this->invokeMethod( 80 + $registry, 81 + 'matchesCollection', 82 + ['app.bsky.graph.follow', ['app.bsky.*']] 83 + ) 84 + ); 85 + } 86 + 87 + /** @test */ 88 + public function it_matches_multiple_patterns() 89 + { 90 + $registry = new SignalRegistry(); 91 + 92 + $patterns = [ 93 + 'app.bsky.feed.post', 94 + 'app.bsky.graph.*', 95 + ]; 96 + 97 + // Exact match 98 + $this->assertTrue( 99 + $this->invokeMethod( 100 + $registry, 101 + 'matchesCollection', 102 + ['app.bsky.feed.post', $patterns] 103 + ) 104 + ); 105 + 106 + // Wildcard match 107 + $this->assertTrue( 108 + $this->invokeMethod( 109 + $registry, 110 + 'matchesCollection', 111 + ['app.bsky.graph.follow', $patterns] 112 + ) 113 + ); 114 + 115 + // No match 116 + $this->assertFalse( 117 + $this->invokeMethod( 118 + $registry, 119 + 'matchesCollection', 120 + ['app.bsky.feed.like', $patterns] 121 + ) 122 + ); 123 + } 124 + 125 + /** 126 + * Call protected/private method of a class. 127 + */ 128 + protected function invokeMethod(&$object, $methodName, array $parameters = []) 129 + { 130 + $reflection = new \ReflectionClass(get_class($object)); 131 + $method = $reflection->getMethod($methodName); 132 + $method->setAccessible(true); 133 + 134 + return $method->invokeArgs($object, $parameters); 135 + } 136 + }
+131
tests/Unit/SignalTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Signal\Tests\Unit; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Signal\Events\CommitEvent; 7 + use SocialDept\Signal\Events\JetstreamEvent; 8 + use SocialDept\Signal\Signals\Signal; 9 + 10 + class SignalTest extends TestCase 11 + { 12 + /** @test */ 13 + public function it_can_create_a_signal() 14 + { 15 + $signal = new class extends Signal { 16 + public function eventTypes(): array 17 + { 18 + return ['commit']; 19 + } 20 + 21 + public function handle(JetstreamEvent $event): void 22 + { 23 + // 24 + } 25 + }; 26 + 27 + $this->assertInstanceOf(Signal::class, $signal); 28 + $this->assertEquals(['commit'], $signal->eventTypes()); 29 + } 30 + 31 + /** @test */ 32 + public function it_can_filter_by_exact_collection() 33 + { 34 + $signal = new class extends Signal { 35 + public function eventTypes(): array 36 + { 37 + return ['commit']; 38 + } 39 + 40 + public function collections(): ?array 41 + { 42 + return ['app.bsky.feed.post']; 43 + } 44 + 45 + public function handle(JetstreamEvent $event): void 46 + { 47 + // 48 + } 49 + }; 50 + 51 + $event = new JetstreamEvent( 52 + did: 'did:plc:test', 53 + timeUs: time() * 1000000, 54 + kind: 'commit', 55 + commit: new CommitEvent( 56 + rev: 'test', 57 + operation: 'create', 58 + collection: 'app.bsky.feed.post', 59 + rkey: 'test', 60 + ), 61 + ); 62 + 63 + $this->assertTrue($signal->shouldHandle($event)); 64 + } 65 + 66 + /** @test */ 67 + public function it_can_filter_by_wildcard_collection() 68 + { 69 + $signal = new class extends Signal { 70 + public function eventTypes(): array 71 + { 72 + return ['commit']; 73 + } 74 + 75 + public function collections(): ?array 76 + { 77 + return ['app.bsky.feed.*']; 78 + } 79 + 80 + public function handle(JetstreamEvent $event): void 81 + { 82 + // 83 + } 84 + }; 85 + 86 + // Test that it matches app.bsky.feed.post 87 + $postEvent = new JetstreamEvent( 88 + did: 'did:plc:test', 89 + timeUs: time() * 1000000, 90 + kind: 'commit', 91 + commit: new CommitEvent( 92 + rev: 'test', 93 + operation: 'create', 94 + collection: 'app.bsky.feed.post', 95 + rkey: 'test', 96 + ), 97 + ); 98 + 99 + $this->assertTrue($signal->shouldHandle($postEvent)); 100 + 101 + // Test that it matches app.bsky.feed.like 102 + $likeEvent = new JetstreamEvent( 103 + did: 'did:plc:test', 104 + timeUs: time() * 1000000, 105 + kind: 'commit', 106 + commit: new CommitEvent( 107 + rev: 'test', 108 + operation: 'create', 109 + collection: 'app.bsky.feed.like', 110 + rkey: 'test', 111 + ), 112 + ); 113 + 114 + $this->assertTrue($signal->shouldHandle($likeEvent)); 115 + 116 + // Test that it does NOT match app.bsky.graph.follow 117 + $followEvent = new JetstreamEvent( 118 + did: 'did:plc:test', 119 + timeUs: time() * 1000000, 120 + kind: 'commit', 121 + commit: new CommitEvent( 122 + rev: 'test', 123 + operation: 'create', 124 + collection: 'app.bsky.graph.follow', 125 + rkey: 'test', 126 + ), 127 + ); 128 + 129 + $this->assertFalse($signal->shouldHandle($followEvent)); 130 + } 131 + }