···11+MIT License
22+33+Copyright (c) 2025 Social Dept.
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+618
README.md
···11+# Signal
22+33+**Laravel package for building Signals that respond to AT Protocol Jetstream events**
44+55+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.
66+77+---
88+99+## Features
1010+1111+- 🔌 **WebSocket Connection** - Connect to AT Protocol Jetstream with automatic reconnection
1212+- 🎯 **Signal-based Architecture** - Clean, testable event handlers (avoiding Laravel's "listener" naming collision)
1313+- ⭐ **Wildcard Collection Filtering** - Match multiple collections with patterns like `app.bsky.feed.*`
1414+- 💾 **Cursor Management** - Resume from last position after disconnections (Database, Redis, or File storage)
1515+- ⚡ **Queue Integration** - Process events asynchronously with Laravel queues
1616+- 🔍 **Auto-Discovery** - Automatically find and register Signals in `app/Signals`
1717+- 🧪 **Testing Tools** - Test your Signals with sample data
1818+- 🛠️ **Artisan Commands** - Full CLI support for managing and testing Signals
1919+2020+---
2121+2222+## Table of Contents
2323+2424+- [Installation](#installation)
2525+- [Quick Start](#quick-start)
2626+- [Creating Signals](#creating-signals)
2727+- [Filtering Events](#filtering-events)
2828+- [Queue Integration](#queue-integration)
2929+- [Configuration](#configuration)
3030+- [Available Commands](#available-commands)
3131+- [Testing](#testing)
3232+- [Documentation](#documentation)
3333+- [License](#license)
3434+3535+---
3636+3737+## Installation
3838+3939+Install the package via Composer:
4040+4141+```bash
4242+composer require social-dept/signal
4343+```
4444+4545+Run the installation command:
4646+4747+```bash
4848+php artisan signal:install
4949+```
5050+5151+This will:
5252+- Publish the configuration file to `config/signal.php`
5353+- Publish the database migration
5454+- Run migrations (with confirmation)
5555+- Display next steps
5656+5757+### Manual Installation
5858+5959+If you prefer manual installation:
6060+6161+```bash
6262+php artisan vendor:publish --tag=signal-config
6363+php artisan vendor:publish --tag=signal-migrations
6464+php artisan migrate
6565+```
6666+6767+---
6868+6969+## Quick Start
7070+7171+### 1. Create Your First Signal
7272+7373+```bash
7474+php artisan make:signal NewPostSignal
7575+```
7676+7777+This creates `app/Signals/NewPostSignal.php`:
7878+7979+```php
8080+<?php
8181+8282+namespace App\Signals;
8383+8484+use SocialDept\Signal\Events\JetstreamEvent;
8585+use SocialDept\Signal\Signals\Signal;
8686+8787+class NewPostSignal extends Signal
8888+{
8989+ public function eventTypes(): array
9090+ {
9191+ return ['commit'];
9292+ }
9393+9494+ public function collections(): ?array
9595+ {
9696+ return ['app.bsky.feed.post'];
9797+ }
9898+9999+ public function handle(JetstreamEvent $event): void
100100+ {
101101+ $record = $event->getRecord();
102102+103103+ logger()->info('New post created', [
104104+ 'did' => $event->did,
105105+ 'text' => $record->text ?? null,
106106+ ]);
107107+ }
108108+}
109109+```
110110+111111+### 2. Start Consuming Events
112112+113113+```bash
114114+php artisan signal:consume
115115+```
116116+117117+Your Signal will now respond to new posts on the AT Protocol network in real-time!
118118+119119+---
120120+121121+## Creating Signals
122122+123123+### Basic Signal Structure
124124+125125+Every Signal extends the base `Signal` class and must implement:
126126+127127+```php
128128+use SocialDept\Signal\Events\JetstreamEvent;
129129+use SocialDept\Signal\Signals\Signal;
130130+131131+class MySignal extends Signal
132132+{
133133+ // Required: Define which event types to listen for
134134+ public function eventTypes(): array
135135+ {
136136+ return ['commit']; // 'commit', 'identity', or 'account'
137137+ }
138138+139139+ // Required: Handle the event
140140+ public function handle(JetstreamEvent $event): void
141141+ {
142142+ // Your logic here
143143+ }
144144+}
145145+```
146146+147147+### Event Types
148148+149149+Three event types are available:
150150+151151+| Type | Description | Use Cases |
152152+|------|-------------|-----------|
153153+| `commit` | Repository commits (posts, likes, follows, etc.) | Content creation, social interactions |
154154+| `identity` | Identity changes (handle updates) | User profile tracking |
155155+| `account` | Account status changes | Account monitoring |
156156+157157+### Accessing Event Data
158158+159159+```php
160160+public function handle(JetstreamEvent $event): void
161161+{
162162+ // Common properties
163163+ $did = $event->did; // User's DID
164164+ $kind = $event->kind; // Event type
165165+ $timestamp = $event->timeUs; // Microsecond timestamp
166166+167167+ // Commit events
168168+ if ($event->isCommit()) {
169169+ $collection = $event->getCollection(); // e.g., 'app.bsky.feed.post'
170170+ $operation = $event->getOperation(); // 'create', 'update', or 'delete'
171171+ $record = $event->getRecord(); // The actual record data
172172+ $rkey = $event->commit->rkey; // Record key
173173+ }
174174+175175+ // Identity events
176176+ if ($event->isIdentity()) {
177177+ $handle = $event->identity->handle;
178178+ }
179179+180180+ // Account events
181181+ if ($event->isAccount()) {
182182+ $active = $event->account->active;
183183+ $status = $event->account->status;
184184+ }
185185+}
186186+```
187187+188188+---
189189+190190+## Filtering Events
191191+192192+### Collection Filtering (with Wildcards!)
193193+194194+Filter events by AT Protocol collection:
195195+196196+```php
197197+// Exact match - only posts
198198+public function collections(): ?array
199199+{
200200+ return ['app.bsky.feed.post'];
201201+}
202202+203203+// Wildcard - all feed events
204204+public function collections(): ?array
205205+{
206206+ return ['app.bsky.feed.*'];
207207+}
208208+209209+// Multiple patterns
210210+public function collections(): ?array
211211+{
212212+ return [
213213+ 'app.bsky.feed.post',
214214+ 'app.bsky.feed.repost',
215215+ 'app.bsky.graph.*', // All graph collections
216216+ ];
217217+}
218218+219219+// No filter - all collections
220220+public function collections(): ?array
221221+{
222222+ return null;
223223+}
224224+```
225225+226226+### Common Collection Patterns
227227+228228+| Pattern | Matches |
229229+|---------|---------|
230230+| `app.bsky.feed.*` | Posts, likes, reposts, etc. |
231231+| `app.bsky.graph.*` | Follows, blocks, mutes |
232232+| `app.bsky.actor.*` | Profile updates |
233233+| `app.bsky.*` | All Bluesky collections |
234234+235235+### DID Filtering
236236+237237+Filter events by specific users:
238238+239239+```php
240240+public function dids(): ?array
241241+{
242242+ return [
243243+ 'did:plc:z72i7hdynmk6r22z27h6tvur', // Specific user
244244+ 'did:plc:ragtjsm2j2vknwkz3zp4oxrd', // Another user
245245+ ];
246246+}
247247+```
248248+249249+### Custom Filtering
250250+251251+Add complex filtering logic:
252252+253253+```php
254254+public function shouldHandle(JetstreamEvent $event): bool
255255+{
256256+ // Only handle posts with images
257257+ if ($event->isCommit() && $event->commit->collection === 'app.bsky.feed.post') {
258258+ $record = $event->getRecord();
259259+ return isset($record->embed);
260260+ }
261261+262262+ return true;
263263+}
264264+```
265265+266266+---
267267+268268+## Queue Integration
269269+270270+Process events asynchronously using Laravel queues:
271271+272272+```php
273273+class HeavyProcessingSignal extends Signal
274274+{
275275+ public function eventTypes(): array
276276+ {
277277+ return ['commit'];
278278+ }
279279+280280+ // Enable queueing
281281+ public function shouldQueue(): bool
282282+ {
283283+ return true;
284284+ }
285285+286286+ // Optional: Customize queue
287287+ public function queue(): string
288288+ {
289289+ return 'high-priority';
290290+ }
291291+292292+ // Optional: Customize connection
293293+ public function queueConnection(): string
294294+ {
295295+ return 'redis';
296296+ }
297297+298298+ public function handle(JetstreamEvent $event): void
299299+ {
300300+ // This runs in a queue job
301301+ $this->performExpensiveOperation($event);
302302+ }
303303+304304+ // Handle failures
305305+ public function failed(JetstreamEvent $event, \Throwable $exception): void
306306+ {
307307+ Log::error('Signal failed', [
308308+ 'event' => $event->toArray(),
309309+ 'error' => $exception->getMessage(),
310310+ ]);
311311+ }
312312+}
313313+```
314314+315315+---
316316+317317+## Configuration
318318+319319+Configuration is stored in `config/signal.php`:
320320+321321+### Jetstream URL
322322+323323+```php
324324+'websocket_url' => env('SIGNAL_JETSTREAM_URL', 'wss://jetstream2.us-east.bsky.network'),
325325+```
326326+327327+Available endpoints:
328328+- **US East**: `wss://jetstream2.us-east.bsky.network`
329329+- **US West**: `wss://jetstream1.us-west.bsky.network`
330330+331331+### Cursor Storage
332332+333333+Choose how to store cursor positions:
334334+335335+```php
336336+'cursor_storage' => env('SIGNAL_CURSOR_STORAGE', 'database'),
337337+```
338338+339339+| Driver | Best For | Configuration |
340340+|--------|----------|---------------|
341341+| `database` | Production, multi-server | Default connection |
342342+| `redis` | High performance, distributed | Redis connection |
343343+| `file` | Development, single server | Storage path |
344344+345345+### Environment Variables
346346+347347+Add to your `.env`:
348348+349349+```env
350350+# Required
351351+SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network
352352+353353+# Optional
354354+SIGNAL_CURSOR_STORAGE=database
355355+SIGNAL_QUEUE_CONNECTION=redis
356356+SIGNAL_QUEUE=signal
357357+SIGNAL_BATCH_SIZE=100
358358+SIGNAL_RATE_LIMIT=1000
359359+```
360360+361361+### Auto-Discovery
362362+363363+Signals are automatically discovered from `app/Signals`. Disable if needed:
364364+365365+```php
366366+'auto_discovery' => [
367367+ 'enabled' => true,
368368+ 'path' => app_path('Signals'),
369369+ 'namespace' => 'App\\Signals',
370370+],
371371+```
372372+373373+Or manually register Signals:
374374+375375+```php
376376+'signals' => [
377377+ \App\Signals\NewPostSignal::class,
378378+ \App\Signals\NewFollowSignal::class,
379379+],
380380+```
381381+382382+---
383383+384384+## Available Commands
385385+386386+### `signal:install`
387387+Install the package (publish config, migrations, run migrations)
388388+389389+```bash
390390+php artisan signal:install
391391+```
392392+393393+### `signal:consume`
394394+Start consuming events from Jetstream
395395+396396+```bash
397397+php artisan signal:consume
398398+399399+# Start from specific cursor
400400+php artisan signal:consume --cursor=123456789
401401+402402+# Start fresh (ignore stored cursor)
403403+php artisan signal:consume --fresh
404404+```
405405+406406+### `signal:list`
407407+List all registered Signals
408408+409409+```bash
410410+php artisan signal:list
411411+```
412412+413413+### `signal:make`
414414+Create a new Signal class
415415+416416+```bash
417417+php artisan make:signal NewPostSignal
418418+419419+# With options
420420+php artisan make:signal FollowSignal --type=commit --collection=app.bsky.graph.follow
421421+```
422422+423423+### `signal:test`
424424+Test a Signal with sample data
425425+426426+```bash
427427+php artisan signal:test NewPostSignal
428428+```
429429+430430+---
431431+432432+## Testing
433433+434434+Signal includes a comprehensive test suite. Test your Signals:
435435+436436+### Unit Testing
437437+438438+```php
439439+use SocialDept\Signal\Events\CommitEvent;
440440+use SocialDept\Signal\Events\JetstreamEvent;
441441+442442+class NewPostSignalTest extends TestCase
443443+{
444444+ /** @test */
445445+ public function it_handles_new_posts()
446446+ {
447447+ $signal = new NewPostSignal();
448448+449449+ $event = new JetstreamEvent(
450450+ did: 'did:plc:test',
451451+ timeUs: time() * 1000000,
452452+ kind: 'commit',
453453+ commit: new CommitEvent(
454454+ rev: 'test',
455455+ operation: 'create',
456456+ collection: 'app.bsky.feed.post',
457457+ rkey: 'test',
458458+ record: (object) [
459459+ 'text' => 'Hello World!',
460460+ 'createdAt' => now()->toIso8601String(),
461461+ ],
462462+ ),
463463+ );
464464+465465+ $signal->handle($event);
466466+467467+ // Assert your expected behavior
468468+ }
469469+}
470470+```
471471+472472+### Testing with Artisan
473473+474474+```bash
475475+php artisan signal:test NewPostSignal
476476+```
477477+478478+---
479479+480480+## Documentation
481481+482482+For detailed documentation, see:
483483+484484+- **[INSTALLATION.md](./INSTALLATION.md)** - Complete installation guide with troubleshooting
485485+- **[PACKAGE_SUMMARY.md](./PACKAGE_SUMMARY.md)** - Quick reference for package components
486486+- **[WILDCARD_EXAMPLES.md](./WILDCARD_EXAMPLES.md)** - Comprehensive wildcard pattern guide
487487+- **[IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md)** - Full architecture and implementation details
488488+489489+### External Resources
490490+491491+- [AT Protocol Documentation](https://atproto.com/)
492492+- [Jetstream Documentation](https://docs.bsky.app/docs/advanced-guides/jetstream)
493493+- [Bluesky Lexicon](https://atproto.com/lexicons)
494494+495495+---
496496+497497+## Examples
498498+499499+### Monitor All Feed Activity
500500+501501+```php
502502+class FeedMonitorSignal extends Signal
503503+{
504504+ public function eventTypes(): array
505505+ {
506506+ return ['commit'];
507507+ }
508508+509509+ public function collections(): ?array
510510+ {
511511+ return ['app.bsky.feed.*'];
512512+ }
513513+514514+ public function handle(JetstreamEvent $event): void
515515+ {
516516+ // Handles posts, likes, reposts, etc.
517517+ Log::info('Feed activity', [
518518+ 'collection' => $event->getCollection(),
519519+ 'operation' => $event->getOperation(),
520520+ 'did' => $event->did,
521521+ ]);
522522+ }
523523+}
524524+```
525525+526526+### Track New Follows
527527+528528+```php
529529+class NewFollowSignal extends Signal
530530+{
531531+ public function eventTypes(): array
532532+ {
533533+ return ['commit'];
534534+ }
535535+536536+ public function collections(): ?array
537537+ {
538538+ return ['app.bsky.graph.follow'];
539539+ }
540540+541541+ public function handle(JetstreamEvent $event): void
542542+ {
543543+ if ($event->commit->isCreate()) {
544544+ $record = $event->getRecord();
545545+546546+ // Store follow relationship
547547+ Follow::create([
548548+ 'follower_did' => $event->did,
549549+ 'following_did' => $record->subject,
550550+ ]);
551551+ }
552552+ }
553553+}
554554+```
555555+556556+### Content Moderation
557557+558558+```php
559559+class ModerationSignal extends Signal
560560+{
561561+ public function eventTypes(): array
562562+ {
563563+ return ['commit'];
564564+ }
565565+566566+ public function collections(): ?array
567567+ {
568568+ return ['app.bsky.feed.post'];
569569+ }
570570+571571+ public function shouldQueue(): bool
572572+ {
573573+ return true;
574574+ }
575575+576576+ public function handle(JetstreamEvent $event): void
577577+ {
578578+ $record = $event->getRecord();
579579+580580+ if ($this->containsProhibitedContent($record->text)) {
581581+ $this->flagForModeration($event->did, $record);
582582+ }
583583+ }
584584+}
585585+```
586586+587587+---
588588+589589+## Requirements
590590+591591+- PHP 8.2 or higher
592592+- Laravel 11.0 or higher
593593+- WebSocket support (enabled by default in most environments)
594594+595595+---
596596+597597+## License
598598+599599+The MIT License (MIT). Please see [LICENSE](LICENSE) for more information.
600600+601601+---
602602+603603+## Contributing
604604+605605+Contributions are welcome! Please see [CONTRIBUTING.md](contributing.md) for details.
606606+607607+---
608608+609609+## Support
610610+611611+For issues, questions, or feature requests:
612612+- Open an issue on GitHub
613613+- Check the [documentation files](#documentation)
614614+- Review the [implementation plan](./IMPLEMENTATION_PLAN.md)
615615+616616+---
617617+618618+**Built for the AT Protocol ecosystem** • Made with ❤️ by Social Dept
-8
changelog.md
···11-# Changelog
22-33-All notable changes to `Signal` will be documented in this file.
44-55-## Version 1.0
66-77-### Added
88-- Everything
···11-# Contributing
22-33-Contributions are welcome and will be fully credited.
44-55-Contributions are accepted via Pull Requests on [Github](https://github.com/social-dept/signal).
66-77-# Things you could do
88-If you want to contribute but do not know where to start, this list provides some starting points.
99-- Add license text
1010-- Remove rewriteRules.php
1111-- Set up TravisCI, StyleCI, ScrutinizerCI
1212-- Write a comprehensive ReadMe
1313-1414-## Pull Requests
1515-1616-- **Add tests!** - Your patch won't be accepted if it doesn't have tests.
1717-1818-- **Document any change in behaviour** - Make sure the `readme.md` and any other relevant documentation are kept up-to-date.
1919-2020-- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option.
2121-2222-- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
2323-2424-- **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.
2525-2626-2727-**Happy coding**!
···11-# Signal
22-33-[![Latest Version on Packagist][ico-version]][link-packagist]
44-[![Total Downloads][ico-downloads]][link-downloads]
55-[![Build Status][ico-travis]][link-travis]
66-[![StyleCI][ico-styleci]][link-styleci]
77-88-This is where your description should go. Take a look at [contributing.md](contributing.md) to see a to do list.
99-1010-## Installation
1111-1212-Via Composer
1313-1414-```bash
1515-composer require social-dept/signal
1616-```
1717-1818-## Usage
1919-2020-## Change log
2121-2222-Please see the [changelog](changelog.md) for more information on what has changed recently.
2323-2424-## Testing
2525-2626-```bash
2727-composer test
2828-```
2929-3030-## Contributing
3131-3232-Please see [contributing.md](contributing.md) for details and a todolist.
3333-3434-## Security
3535-3636-If you discover any security related issues, please email author@email.com instead of using the issue tracker.
3737-3838-## Credits
3939-4040-- [Author Name][link-author]
4141-- [All Contributors][link-contributors]
4242-4343-## License
4444-4545-MIT. Please see the [license file](license.md) for more information.
4646-4747-[ico-version]: https://img.shields.io/packagist/v/social-dept/signal.svg?style=flat-square
4848-[ico-downloads]: https://img.shields.io/packagist/dt/social-dept/signal.svg?style=flat-square
4949-[ico-travis]: https://img.shields.io/travis/social-dept/signal/master.svg?style=flat-square
5050-[ico-styleci]: https://styleci.io/repos/12345678/shield
5151-5252-[link-packagist]: https://packagist.org/packages/social-dept/signal
5353-[link-downloads]: https://packagist.org/packages/social-dept/signal
5454-[link-travis]: https://travis-ci.org/social-dept/signal
5555-[link-styleci]: https://styleci.io/repos/12345678
5656-[link-author]: https://github.com/social-dept
5757-[link-contributors]: ../../contributors
+67
src/Commands/ConsumeCommand.php
···11+<?php
22+33+namespace SocialDept\Signal\Commands;
44+55+use Illuminate\Console\Command;
66+use SocialDept\Signal\Services\JetstreamConsumer;
77+use SocialDept\Signal\Services\SignalRegistry;
88+99+class ConsumeCommand extends Command
1010+{
1111+ protected $signature = 'signal:consume
1212+ {--cursor= : Start from a specific cursor position}
1313+ {--fresh : Start from the beginning, ignoring stored cursor}';
1414+1515+ protected $description = 'Start consuming events from the AT Protocol Jetstream';
1616+1717+ public function handle(JetstreamConsumer $consumer, SignalRegistry $registry): int
1818+ {
1919+ $this->info('Signal: Initializing Jetstream consumer...');
2020+2121+ // Discover signals
2222+ $registry->discover();
2323+2424+ $signalCount = $registry->all()->count();
2525+ $this->info("Registered {$signalCount} signal(s)");
2626+2727+ if ($signalCount === 0) {
2828+ $this->warn('No signals registered. Create signals in app/Signals or register them in config/signal.php');
2929+ return self::FAILURE;
3030+ }
3131+3232+ // List registered signals
3333+ $this->table(
3434+ ['Signal', 'Event Types', 'Collections'],
3535+ $registry->all()->map(function ($signal) {
3636+ return [
3737+ get_class($signal),
3838+ implode(', ', $signal->eventTypes()),
3939+ $signal->collections() ? implode(', ', $signal->collections()) : 'All',
4040+ ];
4141+ })
4242+ );
4343+4444+ // Determine cursor
4545+ $cursor = null;
4646+ if ($this->option('fresh')) {
4747+ $this->info('Starting fresh from the beginning');
4848+ } elseif ($this->option('cursor')) {
4949+ $cursor = (int) $this->option('cursor');
5050+ $this->info("Starting from cursor: {$cursor}");
5151+ } else {
5252+ $this->info('Resuming from stored cursor position');
5353+ }
5454+5555+ // Start consuming
5656+ $this->info('Starting Jetstream consumer... Press Ctrl+C to stop.');
5757+5858+ try {
5959+ $consumer->start($cursor);
6060+ } catch (\Exception $e) {
6161+ $this->error('Error: ' . $e->getMessage());
6262+ return self::FAILURE;
6363+ }
6464+6565+ return self::SUCCESS;
6666+ }
6767+}
+57
src/Commands/InstallCommand.php
···11+<?php
22+33+namespace SocialDept\Signal\Commands;
44+55+use Illuminate\Console\Command;
66+77+class InstallCommand extends Command
88+{
99+ protected $signature = 'signal:install';
1010+1111+ protected $description = 'Install the Signal package (publish config, migrations, and run migrations)';
1212+1313+ public function handle(): int
1414+ {
1515+ $this->info('Installing Signal package...');
1616+ $this->newLine();
1717+1818+ // Publish config
1919+ $this->comment('Publishing configuration...');
2020+ $this->callSilently('vendor:publish', [
2121+ '--tag' => 'signal-config',
2222+ '--force' => $this->option('force', false),
2323+ ]);
2424+ $this->info('✓ Configuration published');
2525+2626+ // Publish migrations
2727+ $this->comment('Publishing migrations...');
2828+ $this->callSilently('vendor:publish', [
2929+ '--tag' => 'signal-migrations',
3030+ '--force' => $this->option('force', false),
3131+ ]);
3232+ $this->info('✓ Migrations published');
3333+3434+ // Run migrations
3535+ $this->newLine();
3636+ $this->comment('Running migrations...');
3737+3838+ if ($this->confirm('Do you want to run the migrations now?', true)) {
3939+ $this->call('migrate');
4040+ $this->info('✓ Migrations completed');
4141+ } else {
4242+ $this->warn('⚠ Skipped migrations. Run "php artisan migrate" manually when ready.');
4343+ }
4444+4545+ $this->newLine();
4646+ $this->info('Signal package installed successfully!');
4747+ $this->newLine();
4848+4949+ // Show next steps
5050+ $this->line('Next steps:');
5151+ $this->line('1. Review the config file: config/signal.php');
5252+ $this->line('2. Create your first signal: php artisan make:signal NewPostSignal');
5353+ $this->line('3. Start consuming events: php artisan signal:consume');
5454+5555+ return self::SUCCESS;
5656+ }
5757+}
···11+<?php
22+33+namespace SocialDept\Signal\Commands;
44+55+use Illuminate\Console\Command;
66+use SocialDept\Signal\Events\JetstreamEvent;
77+use SocialDept\Signal\Events\CommitEvent;
88+99+class TestSignalCommand extends Command
1010+{
1111+ protected $signature = 'signal:test
1212+ {signal : The Signal class name}
1313+ {--sample=commit : The type of sample event to use}';
1414+1515+ protected $description = 'Test a Signal with sample data';
1616+1717+ public function handle(): int
1818+ {
1919+ $signalClass = $this->argument('signal');
2020+2121+ // Try to resolve the class
2222+ if (!class_exists($signalClass)) {
2323+ $signalClass = 'App\\Signals\\' . $signalClass;
2424+ }
2525+2626+ if (!class_exists($signalClass)) {
2727+ $this->error("Signal class not found: {$signalClass}");
2828+ return self::FAILURE;
2929+ }
3030+3131+ $signal = app($signalClass);
3232+3333+ $this->info("Testing signal: {$signalClass}");
3434+ $this->newLine();
3535+3636+ // Create sample event
3737+ $event = $this->createSampleEvent($this->option('sample'));
3838+3939+ $this->info('Sample event created:');
4040+ $this->line(json_encode($event->toArray(), JSON_PRETTY_PRINT));
4141+ $this->newLine();
4242+4343+ try {
4444+ if ($signal->shouldHandle($event)) {
4545+ $this->info('Calling signal->handle()...');
4646+ $signal->handle($event);
4747+ $this->info('✓ Signal executed successfully');
4848+ } else {
4949+ $this->warn('Signal->shouldHandle() returned false');
5050+ }
5151+ } catch (\Exception $e) {
5252+ $this->error('Error executing signal: ' . $e->getMessage());
5353+ return self::FAILURE;
5454+ }
5555+5656+ return self::SUCCESS;
5757+ }
5858+5959+ protected function createSampleEvent(string $type): JetstreamEvent
6060+ {
6161+ switch ($type) {
6262+ case 'commit':
6363+ return new JetstreamEvent(
6464+ did: 'did:plc:sample123456789',
6565+ timeUs: time() * 1000000,
6666+ kind: 'commit',
6767+ commit: new CommitEvent(
6868+ rev: 'sample-rev',
6969+ operation: 'create',
7070+ collection: 'app.bsky.feed.post',
7171+ rkey: 'sample-rkey',
7272+ record: (object) [
7373+ 'text' => 'This is a sample post for testing',
7474+ 'createdAt' => now()->toIso8601String(),
7575+ ],
7676+ cid: 'sample-cid',
7777+ ),
7878+ );
7979+8080+ default:
8181+ throw new \InvalidArgumentException("Unknown sample type: {$type}");
8282+ }
8383+ }
8484+}
+21
src/Contracts/CursorStore.php
···11+<?php
22+33+namespace SocialDept\Signal\Contracts;
44+55+interface CursorStore
66+{
77+ /**
88+ * Get the current cursor position.
99+ */
1010+ public function get(): ?int;
1111+1212+ /**
1313+ * Set the cursor position.
1414+ */
1515+ public function set(int $cursor): void;
1616+1717+ /**
1818+ * Clear the cursor position.
1919+ */
2020+ public function clear(): void;
2121+}
+36
src/Events/AccountEvent.php
···11+<?php
22+33+namespace SocialDept\Signal\Events;
44+55+class AccountEvent
66+{
77+ public function __construct(
88+ public string $did,
99+ public bool $active,
1010+ public ?string $status = null,
1111+ public int $seq = 0,
1212+ public ?string $time = null,
1313+ ) {}
1414+1515+ public static function fromArray(array $data): self
1616+ {
1717+ return new self(
1818+ did: $data['did'],
1919+ active: $data['active'],
2020+ status: $data['status'] ?? null,
2121+ seq: $data['seq'] ?? 0,
2222+ time: $data['time'] ?? null,
2323+ );
2424+ }
2525+2626+ public function toArray(): array
2727+ {
2828+ return [
2929+ 'did' => $this->did,
3030+ 'active' => $this->active,
3131+ 'status' => $this->status,
3232+ 'seq' => $this->seq,
3333+ 'time' => $this->time,
3434+ ];
3535+ }
3636+}
+59
src/Events/CommitEvent.php
···11+<?php
22+33+namespace SocialDept\Signal\Events;
44+55+class CommitEvent
66+{
77+ public function __construct(
88+ public string $rev,
99+ public string $operation, // 'create', 'update', 'delete'
1010+ public string $collection,
1111+ public string $rkey,
1212+ public ?object $record = null,
1313+ public ?string $cid = null,
1414+ ) {}
1515+1616+ public function isCreate(): bool
1717+ {
1818+ return $this->operation === 'create';
1919+ }
2020+2121+ public function isUpdate(): bool
2222+ {
2323+ return $this->operation === 'update';
2424+ }
2525+2626+ public function isDelete(): bool
2727+ {
2828+ return $this->operation === 'delete';
2929+ }
3030+3131+ public function uri(): string
3232+ {
3333+ return "at://{$this->collection}/{$this->rkey}";
3434+ }
3535+3636+ public static function fromArray(array $data): self
3737+ {
3838+ return new self(
3939+ rev: $data['rev'],
4040+ operation: $data['operation'],
4141+ collection: $data['collection'],
4242+ rkey: $data['rkey'],
4343+ record: isset($data['record']) ? (object) $data['record'] : null,
4444+ cid: $data['cid'] ?? null,
4545+ );
4646+ }
4747+4848+ public function toArray(): array
4949+ {
5050+ return [
5151+ 'rev' => $this->rev,
5252+ 'operation' => $this->operation,
5353+ 'collection' => $this->collection,
5454+ 'rkey' => $this->rkey,
5555+ 'record' => $this->record,
5656+ 'cid' => $this->cid,
5757+ ];
5858+ }
5959+}
+33
src/Events/IdentityEvent.php
···11+<?php
22+33+namespace SocialDept\Signal\Events;
44+55+class IdentityEvent
66+{
77+ public function __construct(
88+ public string $did,
99+ public ?string $handle = null,
1010+ public int $seq = 0,
1111+ public ?string $time = null,
1212+ ) {}
1313+1414+ public static function fromArray(array $data): self
1515+ {
1616+ return new self(
1717+ did: $data['did'],
1818+ handle: $data['handle'] ?? null,
1919+ seq: $data['seq'] ?? 0,
2020+ time: $data['time'] ?? null,
2121+ );
2222+ }
2323+2424+ public function toArray(): array
2525+ {
2626+ return [
2727+ 'did' => $this->did,
2828+ 'handle' => $this->handle,
2929+ 'seq' => $this->seq,
3030+ 'time' => $this->time,
3131+ ];
3232+ }
3333+}
+81
src/Events/JetstreamEvent.php
···11+<?php
22+33+namespace SocialDept\Signal\Events;
44+55+class JetstreamEvent
66+{
77+ public function __construct(
88+ public string $did,
99+ public int $timeUs,
1010+ public string $kind, // 'commit', 'identity', 'account'
1111+ public ?CommitEvent $commit = null,
1212+ public ?IdentityEvent $identity = null,
1313+ public ?AccountEvent $account = null,
1414+ ) {}
1515+1616+ public function isCommit(): bool
1717+ {
1818+ return $this->kind === 'commit';
1919+ }
2020+2121+ public function isIdentity(): bool
2222+ {
2323+ return $this->kind === 'identity';
2424+ }
2525+2626+ public function isAccount(): bool
2727+ {
2828+ return $this->kind === 'account';
2929+ }
3030+3131+ public function getCollection(): ?string
3232+ {
3333+ return $this->commit?->collection;
3434+ }
3535+3636+ public function getRecord(): ?object
3737+ {
3838+ return $this->commit?->record;
3939+ }
4040+4141+ public function getOperation(): ?string
4242+ {
4343+ return $this->commit?->operation;
4444+ }
4545+4646+ public static function fromArray(array $data): self
4747+ {
4848+ $commit = isset($data['commit'])
4949+ ? CommitEvent::fromArray($data['commit'])
5050+ : null;
5151+5252+ $identity = isset($data['identity'])
5353+ ? IdentityEvent::fromArray($data['identity'])
5454+ : null;
5555+5656+ $account = isset($data['account'])
5757+ ? AccountEvent::fromArray($data['account'])
5858+ : null;
5959+6060+ return new self(
6161+ did: $data['did'],
6262+ timeUs: $data['time_us'],
6363+ kind: $data['kind'],
6464+ commit: $commit,
6565+ identity: $identity,
6666+ account: $account,
6767+ );
6868+ }
6969+7070+ public function toArray(): array
7171+ {
7272+ return [
7373+ 'did' => $this->did,
7474+ 'time_us' => $this->timeUs,
7575+ 'kind' => $this->kind,
7676+ 'commit' => $this->commit?->toArray(),
7777+ 'identity' => $this->identity?->toArray(),
7878+ 'account' => $this->account?->toArray(),
7979+ ];
8080+ }
8181+}