+450
-30
README.md
+450
-30
README.md
···
166
use SocialDept\AtpClient\Events\TokenRefreshed;
167
168
Event::listen(TokenRefreshed::class, function ($event) {
169
-
// $event->identifier - the user identifier
170
// $event->token - the new AccessToken
171
// Update your credential storage here
172
});
173
```
174
···
478
479
## Credential Storage
480
481
-
The package uses a `CredentialProvider` interface for token storage. The default `ArrayCredentialProvider` stores credentials in memory (lost on request end).
482
483
-
### Implementing Custom Storage
484
485
```php
486
use SocialDept\AtpClient\Contracts\CredentialProvider;
487
use SocialDept\AtpClient\Data\AccessToken;
488
use SocialDept\AtpClient\Data\Credentials;
489
490
class DatabaseCredentialProvider implements CredentialProvider
491
{
492
-
public function getCredentials(string $identifier): ?Credentials
493
{
494
-
$record = AtpCredential::where('identifier', $identifier)->first();
495
496
-
if (!$record) {
497
return null;
498
}
499
500
return new Credentials(
501
-
identifier: $record->identifier,
502
did: $record->did,
503
accessToken: $record->access_token,
504
refreshToken: $record->refresh_token,
505
expiresAt: $record->expires_at,
506
);
507
}
508
509
-
public function storeCredentials(string $identifier, AccessToken $token): void
510
{
511
-
AtpCredential::create([
512
-
'identifier' => $identifier,
513
-
'did' => $token->did,
514
-
'access_token' => $token->accessJwt,
515
-
'refresh_token' => $token->refreshJwt,
516
-
'expires_at' => $token->expiresAt,
517
-
]);
518
}
519
520
-
public function updateCredentials(string $identifier, AccessToken $token): void
521
{
522
-
AtpCredential::where('identifier', $identifier)->update([
523
'access_token' => $token->accessJwt,
524
'refresh_token' => $token->refreshJwt,
525
'expires_at' => $token->expiresAt,
526
]);
527
}
528
529
-
public function removeCredentials(string $identifier): void
530
{
531
-
AtpCredential::where('identifier', $identifier)->delete();
532
}
533
}
534
```
535
536
-
Register your provider in the config:
537
538
```php
539
'credential_provider' => App\Providers\DatabaseCredentialProvider::class,
540
```
541
542
## Events
543
544
The package dispatches events you can listen to:
545
546
```php
547
use SocialDept\AtpClient\Events\TokenRefreshing;
548
use SocialDept\AtpClient\Events\TokenRefreshed;
549
550
-
// Before token refresh
551
-
Event::listen(TokenRefreshing::class, function ($event) {
552
-
Log::info('Refreshing token for: ' . $event->identifier);
553
});
554
555
-
// After token refresh
556
-
Event::listen(TokenRefreshed::class, function ($event) {
557
-
// Update your stored credentials
558
-
$this->credentialProvider->updateCredentials(
559
-
$event->identifier,
560
-
$event->token
561
-
);
562
});
563
```
564
565
## Available Commands
566
567
```bash
568
# Generate OAuth private key
569
php artisan atp-client:generate-key
570
```
571
572
## Requirements
···
587
- [AT Protocol Documentation](https://atproto.com/)
588
- [Bluesky API Docs](https://docs.bsky.app/)
589
- [CRYPTO.md](CRYPTO.md) - Cryptographic implementation details
590
591
## Support & Contributing
592
···
166
use SocialDept\AtpClient\Events\TokenRefreshed;
167
168
Event::listen(TokenRefreshed::class, function ($event) {
169
+
// $event->session - the Session being refreshed
170
// $event->token - the new AccessToken
171
// Update your credential storage here
172
+
173
+
// Check auth type if needed
174
+
if ($event->session->isLegacy()) {
175
+
// App password session
176
+
}
177
});
178
```
179
···
483
484
## Credential Storage
485
486
+
The package uses a `CredentialProvider` interface for token storage. The default `ArrayCredentialProvider` stores credentials in memory (lost on request end). For production applications, you need to implement persistent storage.
487
+
488
+
### Why You Need a Credential Provider
489
+
490
+
AT Protocol OAuth uses **single-use refresh tokens**. When a token is refreshed:
491
+
1. The old refresh token is immediately invalidated
492
+
2. A new refresh token is issued
493
+
3. You must store the new token before using it again
494
+
495
+
If you lose the refresh token, the user must re-authenticate. The `CredentialProvider` ensures tokens are safely persisted.
496
+
497
+
### How Handle Resolution Works
498
+
499
+
When you call `Atp::as('user.bsky.social')` or `Atp::login('user.bsky.social', $password)`, the package automatically resolves the handle to a DID (Decentralized Identifier). The DID is then used as the storage key for credentials. This ensures consistency even if a user changes their handle.
500
+
501
+
If resolution fails (invalid handle, network error, etc.), a `HandleResolutionException` is thrown.
502
+
503
+
### The CredentialProvider Interface
504
+
505
+
```php
506
+
interface CredentialProvider
507
+
{
508
+
// Get stored credentials by DID
509
+
public function getCredentials(string $did): ?Credentials;
510
+
511
+
// Store credentials after initial OAuth or app password login
512
+
public function storeCredentials(string $did, AccessToken $token): void;
513
+
514
+
// Update credentials after token refresh (CRITICAL: refresh tokens are single-use!)
515
+
public function updateCredentials(string $did, AccessToken $token): void;
516
+
517
+
// Remove credentials (logout)
518
+
public function removeCredentials(string $did): void;
519
+
}
520
+
```
521
+
522
+
### Built-in Credential Providers
523
+
524
+
The package includes several credential providers for different use cases:
525
+
526
+
| Provider | Persistence | Setup | Best For |
527
+
|----------|-------------|-------|----------|
528
+
| `ArrayCredentialProvider` | None (memory) | None | Testing, single requests |
529
+
| `CacheCredentialProvider` | Cache driver | None | Quick prototyping, APIs |
530
+
| `SessionCredentialProvider` | Session lifetime | None | Web apps with user sessions |
531
+
| `FileCredentialProvider` | Permanent (disk) | None | CLI tools, bots |
532
+
533
+
**CacheCredentialProvider** - Uses Laravel's cache system (file cache by default):
534
+
```php
535
+
// config/client.php
536
+
'credential_provider' => \SocialDept\AtpClient\Providers\CacheCredentialProvider::class,
537
+
```
538
+
539
+
**SessionCredentialProvider** - Credentials cleared when session expires or user logs out:
540
+
```php
541
+
// config/client.php
542
+
'credential_provider' => \SocialDept\AtpClient\Providers\SessionCredentialProvider::class,
543
+
```
544
+
545
+
**FileCredentialProvider** - Stores credentials in `storage/app/atp-credentials/`:
546
+
```php
547
+
// config/client.php
548
+
'credential_provider' => \SocialDept\AtpClient\Providers\FileCredentialProvider::class,
549
+
```
550
+
551
+
For production applications with multiple users, implement a database-backed provider as shown below.
552
+
553
+
### Database Migration
554
+
555
+
Create a migration for storing credentials:
556
557
+
```bash
558
+
php artisan make:migration create_atp_credentials_table
559
+
```
560
561
```php
562
+
Schema::create('atp_credentials', function (Blueprint $table) {
563
+
$table->id();
564
+
$table->string('did')->unique(); // Decentralized identifier (primary key)
565
+
$table->string('handle')->nullable(); // User's handle (e.g., user.bsky.social)
566
+
$table->string('issuer')->nullable(); // PDS endpoint URL
567
+
$table->text('access_token'); // JWT access token
568
+
$table->text('refresh_token'); // Single-use refresh token
569
+
$table->timestamp('expires_at'); // Token expiration time
570
+
$table->json('scope')->nullable(); // Granted OAuth scopes
571
+
$table->string('auth_type')->default('oauth'); // 'oauth' or 'legacy'
572
+
$table->timestamps();
573
+
});
574
+
```
575
+
576
+
### Implementing a Database Provider
577
+
578
+
```php
579
+
<?php
580
+
581
+
namespace App\Providers;
582
+
583
+
use App\Models\AtpCredential;
584
use SocialDept\AtpClient\Contracts\CredentialProvider;
585
use SocialDept\AtpClient\Data\AccessToken;
586
use SocialDept\AtpClient\Data\Credentials;
587
+
use SocialDept\AtpClient\Enums\AuthType;
588
589
class DatabaseCredentialProvider implements CredentialProvider
590
{
591
+
public function getCredentials(string $did): ?Credentials
592
{
593
+
$record = AtpCredential::where('did', $did)->first();
594
595
+
if (! $record) {
596
return null;
597
}
598
599
return new Credentials(
600
did: $record->did,
601
accessToken: $record->access_token,
602
refreshToken: $record->refresh_token,
603
expiresAt: $record->expires_at,
604
+
handle: $record->handle,
605
+
issuer: $record->issuer,
606
+
scope: $record->scope ?? [],
607
+
authType: AuthType::from($record->auth_type),
608
);
609
}
610
611
+
public function storeCredentials(string $did, AccessToken $token): void
612
{
613
+
AtpCredential::updateOrCreate(
614
+
['did' => $did],
615
+
[
616
+
'handle' => $token->handle,
617
+
'issuer' => $token->issuer,
618
+
'access_token' => $token->accessJwt,
619
+
'refresh_token' => $token->refreshJwt,
620
+
'expires_at' => $token->expiresAt,
621
+
'scope' => $token->scope,
622
+
'auth_type' => $token->authType->value,
623
+
]
624
+
);
625
}
626
627
+
public function updateCredentials(string $did, AccessToken $token): void
628
{
629
+
AtpCredential::where('did', $did)->update([
630
'access_token' => $token->accessJwt,
631
'refresh_token' => $token->refreshJwt,
632
'expires_at' => $token->expiresAt,
633
+
'handle' => $token->handle,
634
+
'issuer' => $token->issuer,
635
+
'scope' => $token->scope,
636
+
'auth_type' => $token->authType->value,
637
]);
638
}
639
640
+
public function removeCredentials(string $did): void
641
{
642
+
AtpCredential::where('did', $did)->delete();
643
}
644
}
645
```
646
647
+
### The AtpCredential Model
648
649
```php
650
+
<?php
651
+
652
+
namespace App\Models;
653
+
654
+
use Illuminate\Database\Eloquent\Model;
655
+
656
+
class AtpCredential extends Model
657
+
{
658
+
protected $fillable = [
659
+
'did',
660
+
'handle',
661
+
'issuer',
662
+
'access_token',
663
+
'refresh_token',
664
+
'expires_at',
665
+
'scope',
666
+
'auth_type',
667
+
];
668
+
669
+
protected $casts = [
670
+
'expires_at' => 'datetime',
671
+
'scope' => 'array',
672
+
];
673
+
674
+
protected $hidden = [
675
+
'access_token',
676
+
'refresh_token',
677
+
];
678
+
}
679
+
```
680
+
681
+
### Register Your Provider
682
+
683
+
Update your config file:
684
+
685
+
```php
686
+
// config/client.php
687
+
688
'credential_provider' => App\Providers\DatabaseCredentialProvider::class,
689
```
690
691
+
Or bind it in a service provider:
692
+
693
+
```php
694
+
// app/Providers/AppServiceProvider.php
695
+
696
+
use SocialDept\AtpClient\Contracts\CredentialProvider;
697
+
use App\Providers\DatabaseCredentialProvider;
698
+
699
+
public function register(): void
700
+
{
701
+
$this->app->singleton(CredentialProvider::class, DatabaseCredentialProvider::class);
702
+
}
703
+
```
704
+
705
+
### Linking to Your User Model
706
+
707
+
If you want to associate ATP credentials with your application's users:
708
+
709
+
```php
710
+
// Migration
711
+
Schema::table('atp_credentials', function (Blueprint $table) {
712
+
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
713
+
});
714
+
715
+
// AtpCredential model
716
+
public function user()
717
+
{
718
+
return $this->belongsTo(User::class);
719
+
}
720
+
721
+
// User model
722
+
public function atpCredential()
723
+
{
724
+
return $this->hasOne(AtpCredential::class);
725
+
}
726
+
```
727
+
728
+
Then update your provider to work with the authenticated user:
729
+
730
+
```php
731
+
public function storeCredentials(string $did, AccessToken $token): void
732
+
{
733
+
AtpCredential::updateOrCreate(
734
+
['did' => $did],
735
+
[
736
+
'user_id' => auth()->id(), // Link to current user
737
+
'handle' => $token->handle,
738
+
'issuer' => $token->issuer,
739
+
'access_token' => $token->accessJwt,
740
+
'refresh_token' => $token->refreshJwt,
741
+
'expires_at' => $token->expiresAt,
742
+
]
743
+
);
744
+
}
745
+
```
746
+
747
+
### Understanding the Credential Fields
748
+
749
+
| Field | Description |
750
+
|-------|-------------|
751
+
| `did` | Decentralized Identifier - the stable, permanent user ID (e.g., `did:plc:abc123...`) |
752
+
| `handle` | User's handle (e.g., `user.bsky.social`) - can change |
753
+
| `issuer` | The user's PDS endpoint URL (avoids repeated lookups) |
754
+
| `accessToken` | JWT for API authentication (short-lived) |
755
+
| `refreshToken` | Token to get new access tokens (single-use!) |
756
+
| `expiresAt` | When the access token expires |
757
+
| `scope` | Array of granted scopes (e.g., `['atproto', 'transition:generic']`) |
758
+
| `authType` | Authentication method: `AuthType::OAuth` or `AuthType::Legacy` |
759
+
760
+
### Handling Token Refresh Events
761
+
762
+
When tokens are automatically refreshed, you can listen for events:
763
+
764
+
```php
765
+
use SocialDept\AtpClient\Events\TokenRefreshed;
766
+
767
+
// In EventServiceProvider or via Event::listen()
768
+
Event::listen(TokenRefreshed::class, function (TokenRefreshed $event) {
769
+
// The CredentialProvider.updateCredentials() is already called,
770
+
// but you can do additional logging or notifications here
771
+
Log::info("Token refreshed for: {$event->session->did()}");
772
+
773
+
// Check if this is a legacy (app password) session
774
+
if ($event->session->isLegacy()) {
775
+
// Handle legacy sessions differently if needed
776
+
}
777
+
});
778
+
```
779
+
780
## Events
781
782
The package dispatches events you can listen to:
783
784
+
### OAuthUserAuthenticated
785
+
786
+
Fired after a successful OAuth callback. Use this to create or update users in your application:
787
+
788
+
```php
789
+
use SocialDept\AtpClient\Events\OAuthUserAuthenticated;
790
+
use SocialDept\AtpClient\Facades\Atp;
791
+
792
+
Event::listen(OAuthUserAuthenticated::class, function (OAuthUserAuthenticated $event) {
793
+
// $event->token contains: did, accessJwt, refreshJwt, handle, issuer, expiresAt, scope
794
+
795
+
// Check granted scopes
796
+
if (in_array('atproto', $event->token->scope)) {
797
+
// User granted AT Protocol access
798
+
}
799
+
800
+
// Fetch the user's profile
801
+
$client = Atp::as($event->token->did);
802
+
$profile = $client->bsky->actor->getProfile($event->token->did);
803
+
804
+
// Create or update user in your database
805
+
$user = User::updateOrCreate(
806
+
['did' => $event->token->did],
807
+
[
808
+
'handle' => $event->token->handle,
809
+
'name' => $profile->json('displayName'),
810
+
'avatar' => $profile->json('avatar'),
811
+
]
812
+
);
813
+
814
+
// Log them in
815
+
Auth::login($user);
816
+
});
817
+
```
818
+
819
+
### TokenRefreshing / TokenRefreshed
820
+
821
+
Fired before and after automatic token refresh for both OAuth and legacy sessions. Use `TokenRefreshing` to invalidate your stored refresh token before it's used (refresh tokens are single-use):
822
+
823
```php
824
use SocialDept\AtpClient\Events\TokenRefreshing;
825
use SocialDept\AtpClient\Events\TokenRefreshed;
826
827
+
// Before token refresh - invalidate old refresh token
828
+
Event::listen(TokenRefreshing::class, function (TokenRefreshing $event) {
829
+
// $event->session gives access to did(), handle(), authType(), isLegacy(), etc.
830
+
Log::info('Refreshing token for: ' . $event->session->did());
831
});
832
833
+
// After token refresh - new tokens available
834
+
Event::listen(TokenRefreshed::class, function (TokenRefreshed $event) {
835
+
// $event->session - the session being refreshed
836
+
// $event->token - the new AccessToken with fresh tokens
837
+
// CredentialProvider.updateCredentials() is already called automatically
838
+
Log::info('Token refreshed for: ' . $event->session->did());
839
+
840
+
// Check auth type if needed
841
+
if ($event->session->isLegacy()) {
842
+
// Legacy (app password) session
843
+
}
844
});
845
```
846
847
+
## Scope Authorization
848
+
849
+
The package provides Laravel-native authorization features for checking ATP OAuth scopes, similar to Laravel's Gate/Policy system.
850
+
851
+
### Setup
852
+
853
+
Have your User model implement the `HasAtpSession` interface:
854
+
855
+
```php
856
+
use SocialDept\AtpClient\Contracts\HasAtpSession;
857
+
858
+
class User extends Authenticatable implements HasAtpSession
859
+
{
860
+
public function getAtpDid(): ?string
861
+
{
862
+
return $this->atp_did; // or however you store the DID
863
+
}
864
+
}
865
+
```
866
+
867
+
### Route Middleware
868
+
869
+
Protect routes by requiring specific scopes. Uses AND logic (all listed scopes required):
870
+
871
+
```php
872
+
use Illuminate\Support\Facades\Route;
873
+
874
+
// Requires transition:generic scope
875
+
Route::post('/posts', [PostController::class, 'store'])
876
+
->middleware('atp.scope:transition:generic');
877
+
878
+
// Requires BOTH scopes
879
+
Route::post('/dm', [MessageController::class, 'store'])
880
+
->middleware('atp.scope:transition:generic,transition:chat.bsky');
881
+
```
882
+
883
+
### AtpScope Facade
884
+
885
+
Use the `AtpScope` facade for programmatic scope checks:
886
+
887
+
```php
888
+
use SocialDept\AtpClient\Facades\AtpScope;
889
+
890
+
// Check if user has a scope
891
+
if (AtpScope::can('transition:generic')) {
892
+
// ...
893
+
}
894
+
895
+
// Check if user has any of the scopes
896
+
if (AtpScope::canAny(['transition:generic', 'transition:chat.bsky'])) {
897
+
// ...
898
+
}
899
+
900
+
// Check if user has all scopes
901
+
if (AtpScope::canAll(['atproto', 'transition:generic'])) {
902
+
// ...
903
+
}
904
+
905
+
// Authorize or fail (throws/aborts based on config)
906
+
AtpScope::authorize('transition:generic');
907
+
908
+
// Check for a specific user
909
+
AtpScope::forUser($did)->authorize('transition:generic');
910
+
911
+
// Get all granted scopes
912
+
$scopes = AtpScope::granted();
913
+
```
914
+
915
+
### Session Helper Methods
916
+
917
+
The Session class also has convenience methods:
918
+
919
+
```php
920
+
$session = Atp::as($did)->session();
921
+
922
+
$session->can('transition:generic');
923
+
$session->canAny(['transition:generic', 'transition:chat.bsky']);
924
+
$session->canAll(['atproto', 'transition:generic']);
925
+
$session->cannot('transition:chat.bsky');
926
+
```
927
+
928
+
### Configuration
929
+
930
+
Configure authorization failure behavior in `config/client.php`:
931
+
932
+
```php
933
+
'scope_authorization' => [
934
+
// What happens when scope check fails: 'abort', 'redirect', or 'exception'
935
+
'failure_action' => ScopeAuthorizationFailure::Abort,
936
+
937
+
// Redirect URL when failure_action is 'redirect'
938
+
'redirect_to' => '/login',
939
+
],
940
+
```
941
+
942
+
Or via environment variables:
943
+
944
+
```env
945
+
ATP_SCOPE_FAILURE_ACTION=abort
946
+
ATP_SCOPE_REDIRECT=/login
947
+
```
948
+
949
+
## Extending the Client
950
+
951
+
Add custom functionality to AtpClient by registering your own domain clients or request clients. Extensions are lazily instantiated on first access.
952
+
953
+
### Register Extensions
954
+
955
+
Register extensions in your service provider's `boot()` method:
956
+
957
+
```php
958
+
use SocialDept\AtpClient\AtpClient;
959
+
960
+
// Add a new domain client: $client->analytics
961
+
AtpClient::extend('analytics', fn(AtpClient $atp) => new AnalyticsClient($atp));
962
+
963
+
// Add to an existing domain: $client->bsky->metrics
964
+
AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new MetricsClient($bsky));
965
+
```
966
+
967
+
### Usage
968
+
969
+
```php
970
+
$client = Atp::as('user.bsky.social');
971
+
972
+
$client->analytics->trackEvent('post_created');
973
+
$client->bsky->metrics->getEngagement();
974
+
```
975
+
976
+
For complete documentation including creating custom clients, testing, and advanced patterns, see [docs/extensions.md](docs/extensions.md).
977
+
978
## Available Commands
979
980
```bash
981
# Generate OAuth private key
982
php artisan atp-client:generate-key
983
+
984
+
# Create a domain client extension
985
+
php artisan make:atp-client AnalyticsClient
986
+
987
+
# Create a request client extension for an existing domain
988
+
php artisan make:atp-request MetricsClient --domain=bsky
989
```
990
991
## Requirements
···
1006
- [AT Protocol Documentation](https://atproto.com/)
1007
- [Bluesky API Docs](https://docs.bsky.app/)
1008
- [CRYPTO.md](CRYPTO.md) - Cryptographic implementation details
1009
+
- [docs/extensions.md](docs/extensions.md) - Client extensions guide
1010
1011
## Support & Contributing
1012
+3
-5
composer.json
+3
-5
composer.json
···
45
}
46
},
47
"scripts": {
48
-
"test": "vendor/bin/pest",
49
-
"test-coverage": "vendor/bin/pest --coverage",
50
"format": "vendor/bin/php-cs-fixer fix"
51
},
52
"extra": {
···
62
"minimum-stability": "dev",
63
"prefer-stable": true,
64
"config": {
65
-
"allow-plugins": {
66
-
"pestphp/pest-plugin": false
67
-
}
68
}
69
}
···
45
}
46
},
47
"scripts": {
48
+
"test": "vendor/bin/phpunit",
49
+
"test-coverage": "vendor/bin/phpunit --coverage-html coverage",
50
"format": "vendor/bin/php-cs-fixer fix"
51
},
52
"extra": {
···
62
"minimum-stability": "dev",
63
"prefer-stable": true,
64
"config": {
65
+
"sort-packages": true
66
}
67
}
+102
-7
config/client.php
+102
-7
config/client.php
···
1
<?php
2
3
return [
4
/*
5
|--------------------------------------------------------------------------
6
-
| Client Metadata
7
|--------------------------------------------------------------------------
8
|
9
-
| OAuth client configuration. The metadata URL must be publicly accessible
10
-
| and serve the client-metadata.json file.
11
|
12
*/
13
'client' => [
14
'name' => env('ATP_CLIENT_NAME', config('app.name')),
15
'url' => env('ATP_CLIENT_URL', config('app.url')),
16
-
'metadata_url' => env('ATP_CLIENT_METADATA_URL'),
17
-
'redirect_uris' => [
18
-
env('ATP_CLIENT_REDIRECT_URI', config('app.url').'/auth/atp/callback'),
19
-
],
20
'scopes' => ['atproto', 'transition:generic'],
21
],
22
···
96
'times' => env('ATP_HTTP_RETRY_TIMES', 3),
97
'sleep' => env('ATP_HTTP_RETRY_SLEEP', 100),
98
],
99
],
100
];
···
1
<?php
2
3
+
use SocialDept\AtpClient\Enums\ScopeAuthorizationFailure;
4
+
use SocialDept\AtpClient\Enums\ScopeEnforcementLevel;
5
+
6
return [
7
/*
8
|--------------------------------------------------------------------------
9
+
| Client Configuration
10
|--------------------------------------------------------------------------
11
|
12
+
| OAuth client configuration. The client_id is a URL that serves as the
13
+
| unique identifier for your OAuth client. In production, this must be
14
+
| an HTTPS URL pointing to your publicly accessible client metadata.
15
+
|
16
+
| For local development, use 'http://localhost' (no port) as the client_id.
17
+
| The redirect_uri for localhost must use 127.0.0.1 with a port.
18
+
|
19
+
| @see https://atproto.com/specs/oauth#clients
20
|
21
*/
22
'client' => [
23
'name' => env('ATP_CLIENT_NAME', config('app.name')),
24
'url' => env('ATP_CLIENT_URL', config('app.url')),
25
+
26
+
// The client_id is the URL to your client metadata document.
27
+
// For production: 'https://example.com/oauth/client-metadata.json'
28
+
// For localhost: 'http://localhost' (exactly, no port)
29
+
'client_id' => env('ATP_CLIENT_ID'),
30
+
31
+
// Redirect URIs for OAuth callback.
32
+
// For localhost development, use 'http://127.0.0.1:<port>/callback'
33
+
'redirect_uris' => array_filter([
34
+
env('ATP_CLIENT_REDIRECT_URI'),
35
+
]),
36
+
37
'scopes' => ['atproto', 'transition:generic'],
38
],
39
···
113
'times' => env('ATP_HTTP_RETRY_TIMES', 3),
114
'sleep' => env('ATP_HTTP_RETRY_SLEEP', 100),
115
],
116
+
],
117
+
118
+
/*
119
+
|--------------------------------------------------------------------------
120
+
| Schema Validation
121
+
|--------------------------------------------------------------------------
122
+
|
123
+
| Enable or disable response validation against AT Protocol lexicon schemas.
124
+
| When enabled, responses are validated and ValidationException is thrown
125
+
| if the response doesn't match the expected schema.
126
+
|
127
+
*/
128
+
'schema_validation' => env('ATP_SCHEMA_VALIDATION', false),
129
+
130
+
/*
131
+
|--------------------------------------------------------------------------
132
+
| Public API Configuration
133
+
|--------------------------------------------------------------------------
134
+
|
135
+
| Configuration for unauthenticated public API access. The public API
136
+
| allows reading public data without authentication.
137
+
|
138
+
*/
139
+
'public' => [
140
+
'service_url' => env('ATP_PUBLIC_SERVICE_URL', 'https://public.api.bsky.app'),
141
+
],
142
+
143
+
/*
144
+
|--------------------------------------------------------------------------
145
+
| Scope Enforcement
146
+
|--------------------------------------------------------------------------
147
+
|
148
+
| Configure how scope requirements are enforced. Options:
149
+
| - 'strict': Throws MissingScopeException if required scopes are missing
150
+
| - 'permissive': Logs a warning but attempts the request anyway
151
+
|
152
+
*/
153
+
'scope_enforcement' => ScopeEnforcementLevel::tryFrom(
154
+
env('ATP_SCOPE_ENFORCEMENT', 'permissive')
155
+
) ?? ScopeEnforcementLevel::Permissive,
156
+
157
+
/*
158
+
|--------------------------------------------------------------------------
159
+
| Scope Authorization
160
+
|--------------------------------------------------------------------------
161
+
|
162
+
| Configure behavior for the AtpScope facade and atp.scope middleware.
163
+
|
164
+
| failure_action: What happens when a scope check fails
165
+
| - 'abort': Return a 403 HTTP response
166
+
| - 'redirect': Redirect to the configured URL
167
+
| - 'exception': Throw ScopeAuthorizationException
168
+
|
169
+
| redirect_to: URL to redirect to when failure_action is 'redirect'
170
+
|
171
+
*/
172
+
'scope_authorization' => [
173
+
'failure_action' => ScopeAuthorizationFailure::tryFrom(
174
+
env('ATP_SCOPE_FAILURE_ACTION', 'abort')
175
+
) ?? ScopeAuthorizationFailure::Abort,
176
+
177
+
'redirect_to' => env('ATP_SCOPE_REDIRECT', '/login'),
178
+
],
179
+
180
+
/*
181
+
|--------------------------------------------------------------------------
182
+
| Generator Settings
183
+
|--------------------------------------------------------------------------
184
+
|
185
+
| Configure paths for the make:atp-client and make:atp-request commands.
186
+
| Paths are relative to the application base path.
187
+
|
188
+
*/
189
+
'generators' => [
190
+
'client_path' => 'app/Services/Clients',
191
+
'client_public_path' => 'app/Services/Clients/Public',
192
+
'request_path' => 'app/Services/Clients/Requests',
193
+
'request_public_path' => 'app/Services/Clients/Public/Requests',
194
],
195
];
+467
docs/extensions.md
+467
docs/extensions.md
···
···
1
+
# Client Extensions
2
+
3
+
AtpClient provides an extension system that allows you to add custom functionality. You can register domain clients (like `$client->myDomain`) or request clients on existing domains (like `$client->bsky->myFeature`).
4
+
5
+
## Quick Reference
6
+
7
+
### Available Methods
8
+
9
+
| Method | Description |
10
+
|--------|-------------|
11
+
| `AtpClient::extend($name, $callback)` | Register a domain client extension |
12
+
| `AtpClient::extendDomain($domain, $name, $callback)` | Register a request client on an existing domain |
13
+
| `AtpClient::hasExtension($name)` | Check if a domain extension is registered |
14
+
| `AtpClient::hasDomainExtension($domain, $name)` | Check if a request client extension is registered |
15
+
| `AtpClient::flushExtensions()` | Clear all extensions (useful for testing) |
16
+
17
+
### Extension Types
18
+
19
+
| Type | Access Pattern | Use Case |
20
+
|------|----------------|----------|
21
+
| Domain Client | `$client->myDomain` | Group related functionality under a namespace |
22
+
| Request Client | `$client->bsky->myFeature` | Add methods to an existing domain |
23
+
24
+
### Generator Commands
25
+
26
+
Quickly scaffold extension classes using artisan commands:
27
+
28
+
```bash
29
+
# Create a domain client extension
30
+
php artisan make:atp-client AnalyticsClient
31
+
32
+
# Create a request client extension for an existing domain
33
+
php artisan make:atp-request MetricsClient --domain=bsky
34
+
```
35
+
36
+
The generated files are placed in configurable directories. You can customize these paths in `config/client.php`:
37
+
38
+
```php
39
+
'generators' => [
40
+
'client_path' => 'app/Services/Clients',
41
+
'request_path' => 'app/Services/Clients/Requests',
42
+
],
43
+
```
44
+
45
+
## Understanding Extensions
46
+
47
+
Extensions follow a lazy-loading pattern. When you register an extension, the callback is stored but not executed. The extension is only instantiated when first accessed:
48
+
49
+
```php
50
+
// Registration - callback stored, not executed
51
+
AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp));
52
+
53
+
// First access - callback executed, instance cached
54
+
$client->analytics->trackEvent('login');
55
+
56
+
// Subsequent access - cached instance returned
57
+
$client->analytics->trackEvent('post_created');
58
+
```
59
+
60
+
This ensures extensions don't add overhead unless they're actually used.
61
+
62
+
## Creating a Domain Client
63
+
64
+
A domain client adds a new namespace to AtpClient, accessible as a property.
65
+
66
+
### Step 1: Create Your Client Class
67
+
68
+
```php
69
+
<?php
70
+
71
+
namespace App\Atp;
72
+
73
+
use SocialDept\AtpClient\AtpClient;
74
+
75
+
class AnalyticsClient
76
+
{
77
+
protected AtpClient $atp;
78
+
79
+
public function __construct(AtpClient $parent)
80
+
{
81
+
$this->atp = $parent;
82
+
}
83
+
84
+
public function trackEvent(string $event, array $properties = []): void
85
+
{
86
+
// Your analytics logic here
87
+
// You have full access to the authenticated client via $this->atp
88
+
}
89
+
90
+
public function getEngagementStats(string $actor): array
91
+
{
92
+
$profile = $this->atp->bsky->actor->getProfile($actor);
93
+
94
+
return [
95
+
'followers' => $profile->followersCount,
96
+
'following' => $profile->followsCount,
97
+
'posts' => $profile->postsCount,
98
+
];
99
+
}
100
+
}
101
+
```
102
+
103
+
### Step 2: Register the Extension
104
+
105
+
In your `AppServiceProvider`:
106
+
107
+
```php
108
+
<?php
109
+
110
+
namespace App\Providers;
111
+
112
+
use App\Atp\AnalyticsClient;
113
+
use Illuminate\Support\ServiceProvider;
114
+
use SocialDept\AtpClient\AtpClient;
115
+
116
+
class AppServiceProvider extends ServiceProvider
117
+
{
118
+
public function boot(): void
119
+
{
120
+
AtpClient::extend('analytics', fn(AtpClient $atp) => new AnalyticsClient($atp));
121
+
}
122
+
}
123
+
```
124
+
125
+
### Step 3: Use Your Extension
126
+
127
+
```php
128
+
use SocialDept\AtpClient\Facades\Atp;
129
+
130
+
$client = Atp::as('user.bsky.social');
131
+
132
+
$client->analytics->trackEvent('page_view', ['page' => '/feed']);
133
+
134
+
$stats = $client->analytics->getEngagementStats('someone.bsky.social');
135
+
```
136
+
137
+
## Creating a Request Client
138
+
139
+
A request client extends an existing domain (like `bsky`, `atproto`, `chat`, or `ozone`). This is useful when you want to add methods that logically belong alongside the built-in functionality.
140
+
141
+
### Step 1: Create Your Request Client Class
142
+
143
+
Extend the base `Request` class to get access to the parent AtpClient:
144
+
145
+
```php
146
+
<?php
147
+
148
+
namespace App\Atp;
149
+
150
+
use SocialDept\AtpClient\Client\Requests\Request;
151
+
152
+
class BskyMetricsClient extends Request
153
+
{
154
+
public function getPostEngagement(string $uri): array
155
+
{
156
+
$thread = $this->atp->bsky->feed->getPostThread($uri);
157
+
$post = $thread->thread['post'] ?? null;
158
+
159
+
if (! $post) {
160
+
return [];
161
+
}
162
+
163
+
return [
164
+
'likes' => $post['likeCount'] ?? 0,
165
+
'reposts' => $post['repostCount'] ?? 0,
166
+
'replies' => $post['replyCount'] ?? 0,
167
+
'quotes' => $post['quoteCount'] ?? 0,
168
+
];
169
+
}
170
+
171
+
public function getAuthorMetrics(string $actor): array
172
+
{
173
+
$feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 100);
174
+
$posts = $feed->feed;
175
+
176
+
$totalLikes = 0;
177
+
$totalReposts = 0;
178
+
179
+
foreach ($posts as $item) {
180
+
$totalLikes += $item['post']['likeCount'] ?? 0;
181
+
$totalReposts += $item['post']['repostCount'] ?? 0;
182
+
}
183
+
184
+
return [
185
+
'posts_analyzed' => count($posts),
186
+
'total_likes' => $totalLikes,
187
+
'total_reposts' => $totalReposts,
188
+
'avg_likes' => count($posts) > 0 ? $totalLikes / count($posts) : 0,
189
+
];
190
+
}
191
+
}
192
+
```
193
+
194
+
### Step 2: Register the Extension
195
+
196
+
```php
197
+
use App\Atp\BskyMetricsClient;
198
+
use SocialDept\AtpClient\AtpClient;
199
+
200
+
public function boot(): void
201
+
{
202
+
AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky));
203
+
}
204
+
```
205
+
206
+
The callback receives the domain client instance (`BskyClient` in this case), which is passed to your request client's constructor.
207
+
208
+
### Step 3: Use Your Extension
209
+
210
+
```php
211
+
$client = Atp::as('user.bsky.social');
212
+
213
+
$engagement = $client->bsky->metrics->getPostEngagement('at://did:plc:.../app.bsky.feed.post/...');
214
+
215
+
$authorMetrics = $client->bsky->metrics->getAuthorMetrics('someone.bsky.social');
216
+
```
217
+
218
+
## Public vs Authenticated Mode
219
+
220
+
The `AtpClient` class works in both public and authenticated modes. Both `Atp::public()` and `Atp::as()` return the same `AtpClient` class:
221
+
222
+
```php
223
+
// Public mode - no authentication
224
+
$publicClient = Atp::public('https://public.api.bsky.app');
225
+
$publicClient->bsky->actor->getProfile('someone.bsky.social');
226
+
227
+
// Authenticated mode - with session
228
+
$authClient = Atp::as('did:plc:xxx');
229
+
$authClient->bsky->actor->getProfile('someone.bsky.social');
230
+
```
231
+
232
+
Extensions registered on `AtpClient` work in both modes. The underlying HTTP layer automatically handles authentication based on whether a session is present.
233
+
234
+
## Registering Multiple Extensions
235
+
236
+
You can register multiple extensions in your service provider:
237
+
238
+
```php
239
+
public function boot(): void
240
+
{
241
+
// Domain clients
242
+
AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp));
243
+
AtpClient::extend('moderation', fn($atp) => new ModerationClient($atp));
244
+
245
+
// Request clients
246
+
AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky));
247
+
AtpClient::extendDomain('bsky', 'lists', fn($bsky) => new BskyListsClient($bsky));
248
+
AtpClient::extendDomain('atproto', 'backup', fn($atproto) => new RepoBackupClient($atproto));
249
+
}
250
+
```
251
+
252
+
## Conditional Registration
253
+
254
+
Register extensions conditionally based on environment or configuration:
255
+
256
+
```php
257
+
public function boot(): void
258
+
{
259
+
if (config('services.analytics.enabled')) {
260
+
AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp));
261
+
}
262
+
263
+
if (app()->environment('local')) {
264
+
AtpClient::extend('debug', fn($atp) => new DebugClient($atp));
265
+
}
266
+
}
267
+
```
268
+
269
+
## Testing Extensions
270
+
271
+
### Test Isolation
272
+
273
+
Use `flushExtensions()` to clear registered extensions between tests:
274
+
275
+
```php
276
+
use SocialDept\AtpClient\AtpClient;
277
+
use PHPUnit\Framework\TestCase;
278
+
279
+
class MyExtensionTest extends TestCase
280
+
{
281
+
protected function setUp(): void
282
+
{
283
+
parent::setUp();
284
+
AtpClient::flushExtensions();
285
+
}
286
+
287
+
protected function tearDown(): void
288
+
{
289
+
AtpClient::flushExtensions();
290
+
parent::tearDown();
291
+
}
292
+
293
+
public function test_extension_is_registered(): void
294
+
{
295
+
AtpClient::extend('test', fn($atp) => new TestClient($atp));
296
+
297
+
$this->assertTrue(AtpClient::hasExtension('test'));
298
+
}
299
+
}
300
+
```
301
+
302
+
### Checking Registration
303
+
304
+
Use the static methods to verify extensions are registered:
305
+
306
+
```php
307
+
// Check domain extension
308
+
if (AtpClient::hasExtension('analytics')) {
309
+
$client->analytics->trackEvent('test');
310
+
}
311
+
312
+
// Check request client extension
313
+
if (AtpClient::hasDomainExtension('bsky', 'metrics')) {
314
+
$metrics = $client->bsky->metrics->getAuthorMetrics($actor);
315
+
}
316
+
```
317
+
318
+
## Advanced Patterns
319
+
320
+
### Accessing the HTTP Client
321
+
322
+
Domain client extensions can access the underlying HTTP client for custom API calls:
323
+
324
+
```php
325
+
class CustomApiClient
326
+
{
327
+
protected AtpClient $atp;
328
+
329
+
public function __construct(AtpClient $parent)
330
+
{
331
+
$this->atp = $parent;
332
+
}
333
+
334
+
public function customEndpoint(array $params): array
335
+
{
336
+
// Access the authenticated HTTP client
337
+
$response = $this->atp->client->get('com.example.customEndpoint', $params);
338
+
339
+
return $response->json();
340
+
}
341
+
342
+
public function customProcedure(array $data): array
343
+
{
344
+
$response = $this->atp->client->post('com.example.customProcedure', $data);
345
+
346
+
return $response->json();
347
+
}
348
+
}
349
+
```
350
+
351
+
### Using Typed Responses
352
+
353
+
Return typed response objects for better IDE support:
354
+
355
+
```php
356
+
use SocialDept\AtpClient\Data\Responses\Response;
357
+
358
+
class MetricsResponse extends Response
359
+
{
360
+
public function __construct(
361
+
public readonly int $likes,
362
+
public readonly int $reposts,
363
+
public readonly int $replies,
364
+
) {}
365
+
366
+
public static function fromArray(array $data): static
367
+
{
368
+
return new static(
369
+
likes: $data['likes'] ?? 0,
370
+
reposts: $data['reposts'] ?? 0,
371
+
replies: $data['replies'] ?? 0,
372
+
);
373
+
}
374
+
}
375
+
376
+
class BskyMetricsClient extends Request
377
+
{
378
+
public function getPostMetrics(string $uri): MetricsResponse
379
+
{
380
+
$thread = $this->atp->bsky->feed->getPostThread($uri);
381
+
$post = $thread->thread['post'] ?? [];
382
+
383
+
return MetricsResponse::fromArray([
384
+
'likes' => $post['likeCount'] ?? 0,
385
+
'reposts' => $post['repostCount'] ?? 0,
386
+
'replies' => $post['replyCount'] ?? 0,
387
+
]);
388
+
}
389
+
}
390
+
```
391
+
392
+
### Composing Multiple Clients
393
+
394
+
Extensions can use other extensions or built-in clients:
395
+
396
+
```php
397
+
class DashboardClient
398
+
{
399
+
protected AtpClient $atp;
400
+
401
+
public function __construct(AtpClient $parent)
402
+
{
403
+
$this->atp = $parent;
404
+
}
405
+
406
+
public function getOverview(string $actor): array
407
+
{
408
+
// Use built-in clients
409
+
$profile = $this->atp->bsky->actor->getProfile($actor);
410
+
$feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 10);
411
+
412
+
// Use other extensions (if registered)
413
+
$metrics = AtpClient::hasDomainExtension('bsky', 'metrics')
414
+
? $this->atp->bsky->metrics->getAuthorMetrics($actor)
415
+
: null;
416
+
417
+
return [
418
+
'profile' => $profile,
419
+
'recent_posts' => $feed->feed,
420
+
'metrics' => $metrics,
421
+
];
422
+
}
423
+
}
424
+
```
425
+
426
+
### Documenting Scope Requirements
427
+
428
+
Use the `#[ScopedEndpoint]` and `#[PublicEndpoint]` attributes to document the authentication requirements of your extension methods:
429
+
430
+
```php
431
+
use SocialDept\AtpClient\Attributes\PublicEndpoint;
432
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
433
+
use SocialDept\AtpClient\Client\Requests\Request;
434
+
use SocialDept\AtpClient\Enums\Scope;
435
+
436
+
class BskyMetricsClient extends Request
437
+
{
438
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')]
439
+
public function getTimelineMetrics(): array
440
+
{
441
+
$timeline = $this->atp->bsky->feed->getTimeline();
442
+
// Process and return metrics...
443
+
}
444
+
445
+
#[PublicEndpoint]
446
+
public function getPublicPostMetrics(string $uri): array
447
+
{
448
+
$thread = $this->atp->bsky->feed->getPostThread($uri);
449
+
// Process and return metrics...
450
+
}
451
+
}
452
+
```
453
+
454
+
> **Note:** These attributes currently serve as documentation only. Runtime scope enforcement will be implemented in a future release. Using them correctly now ensures forward compatibility.
455
+
456
+
Methods with `#[ScopedEndpoint]` indicate they require authentication, while methods with `#[PublicEndpoint]` work without authentication. See [scopes.md](scopes.md) for full documentation on scope handling.
457
+
458
+
## Available Domains
459
+
460
+
You can extend these built-in domains with `extendDomain()`:
461
+
462
+
| Domain | Description |
463
+
|--------|-------------|
464
+
| `bsky` | Bluesky-specific operations (app.bsky.*) |
465
+
| `atproto` | AT Protocol core operations (com.atproto.*) |
466
+
| `chat` | Direct messaging operations (chat.bsky.*) |
467
+
| `ozone` | Moderation tools (tools.ozone.*) |
+408
docs/scopes.md
+408
docs/scopes.md
···
···
1
+
# OAuth Scopes
2
+
3
+
The AT Protocol uses OAuth scopes to control what actions an application can perform on behalf of a user. AtpClient provides attributes for documenting scope requirements on endpoints.
4
+
5
+
> **Note:** The `#[ScopedEndpoint]` and `#[PublicEndpoint]` attributes currently serve as documentation only. Runtime scope validation and enforcement will be implemented in a future release. Using these attributes correctly now ensures forward compatibility.
6
+
7
+
## Quick Reference
8
+
9
+
### Scope Enum
10
+
11
+
```php
12
+
use SocialDept\AtpClient\Enums\Scope;
13
+
14
+
// Transition scopes (current AT Protocol scopes)
15
+
Scope::Atproto // 'atproto' - Full access
16
+
Scope::TransitionGeneric // 'transition:generic' - General API access
17
+
Scope::TransitionEmail // 'transition:email' - Email access
18
+
Scope::TransitionChat // 'transition:chat.bsky' - Chat access
19
+
20
+
// Granular scope builders (future AT Protocol scopes)
21
+
Scope::repo('app.bsky.feed.post', ['create', 'delete']) // Record operations
22
+
Scope::rpc('app.bsky.feed.getTimeline') // RPC endpoint access
23
+
Scope::blob('image/*') // Blob upload access
24
+
Scope::account('email') // Account attribute access
25
+
Scope::identity('handle') // Identity attribute access
26
+
```
27
+
28
+
### ScopedEndpoint Attribute
29
+
30
+
```php
31
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
32
+
use SocialDept\AtpClient\Enums\Scope;
33
+
34
+
#[ScopedEndpoint(Scope::TransitionGeneric)]
35
+
public function getTimeline(): GetTimelineResponse
36
+
{
37
+
// Method implementation
38
+
}
39
+
```
40
+
41
+
## Understanding AT Protocol Scopes
42
+
43
+
### Current Transition Scopes
44
+
45
+
The AT Protocol is currently in a transition period where broad "transition scopes" are used:
46
+
47
+
| Scope | Description |
48
+
|-------|-------------|
49
+
| `atproto` | Full access to the AT Protocol |
50
+
| `transition:generic` | General API access for most operations |
51
+
| `transition:email` | Access to email-related operations |
52
+
| `transition:chat.bsky` | Access to Bluesky chat features |
53
+
54
+
### Future Granular Scopes
55
+
56
+
The AT Protocol is moving toward granular scopes that provide fine-grained access control:
57
+
58
+
```php
59
+
// Record operations
60
+
'repo:app.bsky.feed.post' // All operations on posts
61
+
'repo:app.bsky.feed.post?action=create' // Only create posts
62
+
'repo:app.bsky.feed.like?action=create&action=delete' // Create or delete likes
63
+
'repo:*' // All collections, all actions
64
+
65
+
// RPC endpoint access
66
+
'rpc:app.bsky.feed.getTimeline' // Access to timeline endpoint
67
+
'rpc:app.bsky.feed.*' // All feed endpoints
68
+
69
+
// Blob operations
70
+
'blob:image/*' // Upload images
71
+
'blob:*/*' // Upload any blob type
72
+
73
+
// Account and identity
74
+
'account:email' // Access email
75
+
'identity:handle' // Manage handle
76
+
```
77
+
78
+
## The ScopedEndpoint Attribute
79
+
80
+
The `#[ScopedEndpoint]` attribute documents scope requirements on methods that require authentication.
81
+
82
+
### Basic Usage
83
+
84
+
```php
85
+
<?php
86
+
87
+
namespace App\Atp;
88
+
89
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
90
+
use SocialDept\AtpClient\Client\Requests\Request;
91
+
use SocialDept\AtpClient\Enums\Scope;
92
+
93
+
class CustomClient extends Request
94
+
{
95
+
#[ScopedEndpoint(Scope::TransitionGeneric)]
96
+
public function getTimeline(): array
97
+
{
98
+
return $this->atp->client->get('app.bsky.feed.getTimeline')->json();
99
+
}
100
+
}
101
+
```
102
+
103
+
### With Granular Scope
104
+
105
+
Document the future granular scope that will replace the transition scope:
106
+
107
+
```php
108
+
#[ScopedEndpoint(
109
+
Scope::TransitionGeneric,
110
+
granular: 'rpc:app.bsky.feed.getTimeline'
111
+
)]
112
+
public function getTimeline(): GetTimelineResponse
113
+
{
114
+
// ...
115
+
}
116
+
```
117
+
118
+
### With Description
119
+
120
+
Add a human-readable description for documentation:
121
+
122
+
```php
123
+
#[ScopedEndpoint(
124
+
Scope::TransitionGeneric,
125
+
granular: 'rpc:app.bsky.feed.getTimeline',
126
+
description: 'Access to the user\'s home timeline'
127
+
)]
128
+
public function getTimeline(): GetTimelineResponse
129
+
{
130
+
// ...
131
+
}
132
+
```
133
+
134
+
### Multiple Scopes (AND Logic)
135
+
136
+
When a method requires multiple scopes, all must be present:
137
+
138
+
```php
139
+
#[ScopedEndpoint([Scope::TransitionGeneric, Scope::TransitionEmail])]
140
+
public function getEmailPreferences(): array
141
+
{
142
+
// Requires BOTH scopes
143
+
}
144
+
```
145
+
146
+
### Multiple Attributes (OR Logic)
147
+
148
+
Use multiple attributes for alternative scope requirements:
149
+
150
+
```php
151
+
#[ScopedEndpoint(Scope::Atproto)]
152
+
#[ScopedEndpoint(Scope::TransitionGeneric)]
153
+
public function getProfile(string $actor): ProfileViewDetailed
154
+
{
155
+
// Either scope satisfies the requirement
156
+
}
157
+
```
158
+
159
+
## Scope Enforcement (Planned)
160
+
161
+
> **Coming Soon:** Runtime scope enforcement is not yet implemented. The following documentation describes planned functionality for a future release.
162
+
163
+
### Configuration
164
+
165
+
Configure scope enforcement in `config/client.php` or via environment variables:
166
+
167
+
```php
168
+
'scope_enforcement' => ScopeEnforcementLevel::Permissive,
169
+
```
170
+
171
+
| Level | Behavior |
172
+
|-------|----------|
173
+
| `Strict` | Throws `MissingScopeException` if required scopes are missing |
174
+
| `Permissive` | Logs a warning but attempts the request anyway |
175
+
176
+
Set via environment variable:
177
+
178
+
```env
179
+
ATP_SCOPE_ENFORCEMENT=strict
180
+
```
181
+
182
+
### Programmatic Scope Checking
183
+
184
+
Check scopes programmatically using the `ScopeChecker`:
185
+
186
+
```php
187
+
use SocialDept\AtpClient\Auth\ScopeChecker;
188
+
use SocialDept\AtpClient\Facades\Atp;
189
+
190
+
$checker = app(ScopeChecker::class);
191
+
$session = Atp::as($did)->client->session();
192
+
193
+
// Check if session has a scope
194
+
if ($checker->hasScope($session, Scope::TransitionGeneric)) {
195
+
// Session has the scope
196
+
}
197
+
198
+
// Check multiple scopes
199
+
if ($checker->check($session, [Scope::TransitionGeneric, Scope::TransitionEmail])) {
200
+
// Session has ALL required scopes
201
+
}
202
+
203
+
// Check and fail if missing (respects enforcement level)
204
+
$checker->checkOrFail($session, [Scope::TransitionGeneric]);
205
+
206
+
// Check repo scope for specific action
207
+
if ($checker->checkRepoScope($session, 'app.bsky.feed.post', 'create')) {
208
+
// Can create posts
209
+
}
210
+
```
211
+
212
+
### Granular Pattern Matching
213
+
214
+
The scope checker supports wildcard patterns:
215
+
216
+
```php
217
+
// Check if session can access any feed endpoint
218
+
$checker->matchesGranular($session, 'rpc:app.bsky.feed.*');
219
+
220
+
// Check if session can upload images
221
+
$checker->matchesGranular($session, 'blob:image/*');
222
+
223
+
// Check if session has any repo access
224
+
$checker->matchesGranular($session, 'repo:*');
225
+
```
226
+
227
+
## Route Middleware (Planned)
228
+
229
+
> **Coming Soon:** Route middleware is not yet implemented. The following documentation describes planned functionality for a future release.
230
+
231
+
Protect Laravel routes based on ATP session scopes:
232
+
233
+
```php
234
+
use Illuminate\Support\Facades\Route;
235
+
236
+
// Single scope
237
+
Route::get('/timeline', TimelineController::class)
238
+
->middleware('atp.scope:transition:generic');
239
+
240
+
// Multiple scopes (AND logic)
241
+
Route::get('/email-settings', EmailSettingsController::class)
242
+
->middleware('atp.scope:transition:generic,transition:email');
243
+
```
244
+
245
+
### Middleware Configuration
246
+
247
+
Configure middleware behavior in `config/client.php`:
248
+
249
+
```php
250
+
'scope_authorization' => [
251
+
// What to do when scope check fails
252
+
'failure_action' => ScopeAuthorizationFailure::Abort, // abort, redirect, or exception
253
+
254
+
// Where to redirect (when failure_action is 'redirect')
255
+
'redirect_to' => '/login',
256
+
],
257
+
```
258
+
259
+
| Failure Action | Behavior |
260
+
|----------------|----------|
261
+
| `Abort` | Returns 403 Forbidden response |
262
+
| `Redirect` | Redirects to configured URL |
263
+
| `Exception` | Throws `ScopeAuthorizationException` |
264
+
265
+
Set via environment variables:
266
+
267
+
```env
268
+
ATP_SCOPE_FAILURE_ACTION=redirect
269
+
ATP_SCOPE_REDIRECT=/auth/login
270
+
```
271
+
272
+
### User Model Integration
273
+
274
+
For the middleware to work, your User model must implement `HasAtpSession`:
275
+
276
+
```php
277
+
<?php
278
+
279
+
namespace App\Models;
280
+
281
+
use Illuminate\Foundation\Auth\User as Authenticatable;
282
+
use SocialDept\AtpClient\Contracts\HasAtpSession;
283
+
284
+
class User extends Authenticatable implements HasAtpSession
285
+
{
286
+
public function getAtpDid(): ?string
287
+
{
288
+
return $this->atp_did;
289
+
}
290
+
}
291
+
```
292
+
293
+
## Public Mode and Scopes
294
+
295
+
Methods marked with `#[PublicEndpoint]` can be called without authentication using `Atp::public()`:
296
+
297
+
```php
298
+
// Public mode - no authentication required
299
+
$client = Atp::public('https://public.api.bsky.app');
300
+
$client->bsky->actor->getProfile('someone.bsky.social'); // Works without auth
301
+
302
+
// Authenticated mode - for endpoints requiring scopes
303
+
$client = Atp::as($did);
304
+
$client->bsky->feed->getTimeline(); // Requires transition:generic scope
305
+
```
306
+
307
+
Methods with `#[PublicEndpoint]` work in both modes, while methods with `#[ScopedEndpoint]` require authentication.
308
+
309
+
## Exception Handling (Planned)
310
+
311
+
> **Coming Soon:** These exceptions will be thrown when scope enforcement is implemented in a future release.
312
+
313
+
### MissingScopeException
314
+
315
+
Will be thrown when required scopes are missing and enforcement is strict:
316
+
317
+
```php
318
+
use SocialDept\AtpClient\Exceptions\MissingScopeException;
319
+
320
+
try {
321
+
$timeline = $client->bsky->feed->getTimeline();
322
+
} catch (MissingScopeException $e) {
323
+
$missing = $e->getMissingScopes(); // Scopes that are missing
324
+
$granted = $e->getGrantedScopes(); // Scopes the session has
325
+
326
+
// Handle missing scope
327
+
}
328
+
```
329
+
330
+
### ScopeAuthorizationException
331
+
332
+
Will be thrown by middleware when route access is denied:
333
+
334
+
```php
335
+
use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException;
336
+
337
+
try {
338
+
// Route protected by atp.scope middleware
339
+
} catch (ScopeAuthorizationException $e) {
340
+
$required = $e->getRequiredScopes();
341
+
$granted = $e->getGrantedScopes();
342
+
$message = $e->getMessage();
343
+
}
344
+
```
345
+
346
+
## Best Practices
347
+
348
+
### 1. Document All Scope Requirements
349
+
350
+
Always add `#[ScopedEndpoint]` to methods that require authentication:
351
+
352
+
```php
353
+
#[ScopedEndpoint(
354
+
Scope::TransitionGeneric,
355
+
granular: 'rpc:app.bsky.feed.getTimeline',
356
+
description: 'Fetches the authenticated user\'s home timeline'
357
+
)]
358
+
public function getTimeline(): GetTimelineResponse
359
+
```
360
+
361
+
### 2. Use the Scope Enum
362
+
363
+
Prefer the `Scope` enum over string literals for type safety:
364
+
365
+
```php
366
+
// Good
367
+
#[ScopedEndpoint(Scope::TransitionGeneric)]
368
+
369
+
// Avoid
370
+
#[ScopedEndpoint('transition:generic')]
371
+
```
372
+
373
+
### 3. Request Minimal Scopes
374
+
375
+
When implementing OAuth, request only the scopes your application needs:
376
+
377
+
```php
378
+
$authUrl = Atp::oauth()->getAuthorizationUrl([
379
+
'scope' => 'atproto transition:generic',
380
+
]);
381
+
```
382
+
383
+
### 4. Handle Missing Scopes Gracefully
384
+
385
+
Check for scope availability before attempting operations:
386
+
387
+
```php
388
+
$checker = app(ScopeChecker::class);
389
+
$session = $client->client->session();
390
+
391
+
if ($checker->hasScope($session, Scope::TransitionChat)) {
392
+
$conversations = $client->chat->getConversations();
393
+
} else {
394
+
// Inform user they need to re-authorize with chat scope
395
+
}
396
+
```
397
+
398
+
### 5. Use Permissive Mode in Development
399
+
400
+
Start with permissive enforcement during development, then switch to strict for production:
401
+
402
+
```env
403
+
# .env.local
404
+
ATP_SCOPE_ENFORCEMENT=permissive
405
+
406
+
# .env.production
407
+
ATP_SCOPE_ENFORCEMENT=strict
408
+
```
+17
-7
src/AtpClient.php
+17
-7
src/AtpClient.php
···
2
3
namespace SocialDept\AtpClient;
4
5
-
use Illuminate\Http\Client\Factory;
6
-
use SocialDept\AtpClient\Client\Client;
7
use SocialDept\AtpClient\Client\AtprotoClient;
8
use SocialDept\AtpClient\Client\BskyClient;
9
use SocialDept\AtpClient\Client\ChatClient;
10
use SocialDept\AtpClient\Client\OzoneClient;
11
use SocialDept\AtpClient\Session\SessionManager;
12
13
class AtpClient
14
{
15
/**
16
* Raw API communication/networking class
17
*/
···
38
public OzoneClient $ozone;
39
40
public function __construct(
41
-
SessionManager $sessions,
42
-
Factory $http,
43
-
string $identifier,
44
) {
45
-
// Load the network client
46
-
$this->client = new Client($this, $sessions, $http, $identifier);
47
48
// Load all function collections
49
$this->bsky = new BskyClient($this);
50
$this->atproto = new AtprotoClient($this);
51
$this->chat = new ChatClient($this);
52
$this->ozone = new OzoneClient($this);
53
}
54
}
···
2
3
namespace SocialDept\AtpClient;
4
5
use SocialDept\AtpClient\Client\AtprotoClient;
6
use SocialDept\AtpClient\Client\BskyClient;
7
use SocialDept\AtpClient\Client\ChatClient;
8
+
use SocialDept\AtpClient\Client\Client;
9
use SocialDept\AtpClient\Client\OzoneClient;
10
+
use SocialDept\AtpClient\Concerns\HasExtensions;
11
use SocialDept\AtpClient\Session\SessionManager;
12
13
class AtpClient
14
{
15
+
use HasExtensions;
16
+
17
/**
18
* Raw API communication/networking class
19
*/
···
40
public OzoneClient $ozone;
41
42
public function __construct(
43
+
?SessionManager $sessions = null,
44
+
?string $did = null,
45
+
?string $serviceUrl = null,
46
) {
47
+
// Load the network client (supports both public and authenticated modes)
48
+
$this->client = new Client($this, $sessions, $did, $serviceUrl);
49
50
// Load all function collections
51
$this->bsky = new BskyClient($this);
52
$this->atproto = new AtprotoClient($this);
53
$this->chat = new ChatClient($this);
54
$this->ozone = new OzoneClient($this);
55
+
}
56
+
57
+
/**
58
+
* Check if client is in public mode (no authentication).
59
+
*/
60
+
public function isPublicMode(): bool
61
+
{
62
+
return $this->client->isPublicMode();
63
}
64
}
+56
-12
src/AtpClientServiceProvider.php
+56
-12
src/AtpClientServiceProvider.php
···
2
3
namespace SocialDept\AtpClient;
4
5
use Illuminate\Support\Facades\Route;
6
use Illuminate\Support\ServiceProvider;
7
use SocialDept\AtpClient\Auth\ClientMetadataManager;
8
use SocialDept\AtpClient\Auth\DPoPKeyManager;
9
use SocialDept\AtpClient\Auth\DPoPNonceManager;
10
use SocialDept\AtpClient\Auth\OAuthEngine;
11
use SocialDept\AtpClient\Auth\TokenRefresher;
12
use SocialDept\AtpClient\Console\GenerateOAuthKeyCommand;
13
use SocialDept\AtpClient\Contracts\CredentialProvider;
14
use SocialDept\AtpClient\Contracts\KeyStore;
15
use SocialDept\AtpClient\Http\Controllers\ClientMetadataController;
16
use SocialDept\AtpClient\Http\Controllers\JwksController;
17
use SocialDept\AtpClient\Session\SessionManager;
18
use SocialDept\AtpClient\Storage\EncryptedFileKeyStore;
19
···
41
42
// Register core services
43
$this->app->singleton(ClientMetadataManager::class);
44
$this->app->singleton(DPoPKeyManager::class);
45
$this->app->singleton(DPoPNonceManager::class);
46
$this->app->singleton(TokenRefresher::class);
47
$this->app->singleton(SessionManager::class, function ($app) {
48
return new SessionManager(
···
50
refresher: $app->make(TokenRefresher::class),
51
dpopManager: $app->make(DPoPKeyManager::class),
52
keyStore: $app->make(KeyStore::class),
53
-
http: $app->make('http'),
54
refreshThreshold: config('client.session.refresh_threshold', 300),
55
);
56
});
57
$this->app->singleton(OAuthEngine::class);
58
59
// Register main client facade accessor
60
$this->app->bind('atp-client', function ($app) {
···
69
$this->app = $app;
70
}
71
72
-
public function as(string $identifier): AtpClient
73
{
74
return new AtpClient(
75
$this->app->make(SessionManager::class),
76
-
$this->app->make('http'),
77
-
$identifier
78
);
79
}
80
81
-
public function login(string $identifier, string $password): AtpClient
82
{
83
-
$session = $this->app->make(SessionManager::class)
84
-
->fromAppPassword($identifier, $password);
85
86
-
return $this->as($identifier);
87
}
88
89
public function oauth(): OAuthEngine
···
96
$this->defaultProvider = $provider;
97
$this->app->instance(CredentialProvider::class, $provider);
98
}
99
};
100
});
101
}
···
112
113
$this->commands([
114
GenerateOAuthKeyCommand::class,
115
]);
116
}
117
118
$this->registerRoutes();
119
}
120
121
/**
···
137
->name('atp.oauth.jwks');
138
});
139
140
-
// Register standard .well-known endpoint
141
-
Route::get('.well-known/oauth-client-metadata', ClientMetadataController::class)
142
-
->name('atp.oauth.well-known');
143
}
144
145
/**
···
149
*/
150
public function provides(): array
151
{
152
-
return ['atp-client'];
153
}
154
}
···
2
3
namespace SocialDept\AtpClient;
4
5
+
use Illuminate\Routing\Router;
6
use Illuminate\Support\Facades\Route;
7
use Illuminate\Support\ServiceProvider;
8
+
use SocialDept\AtpClient\Auth\ClientAssertionManager;
9
use SocialDept\AtpClient\Auth\ClientMetadataManager;
10
use SocialDept\AtpClient\Auth\DPoPKeyManager;
11
use SocialDept\AtpClient\Auth\DPoPNonceManager;
12
use SocialDept\AtpClient\Auth\OAuthEngine;
13
+
use SocialDept\AtpClient\Auth\ScopeChecker;
14
+
use SocialDept\AtpClient\Auth\ScopeGate;
15
use SocialDept\AtpClient\Auth\TokenRefresher;
16
+
use SocialDept\AtpClient\Enums\ScopeEnforcementLevel;
17
+
use SocialDept\AtpClient\Http\Middleware\RequiresScopeMiddleware;
18
use SocialDept\AtpClient\Console\GenerateOAuthKeyCommand;
19
+
use SocialDept\AtpClient\Console\MakeAtpClientCommand;
20
+
use SocialDept\AtpClient\Console\MakeAtpRequestCommand;
21
use SocialDept\AtpClient\Contracts\CredentialProvider;
22
use SocialDept\AtpClient\Contracts\KeyStore;
23
use SocialDept\AtpClient\Http\Controllers\ClientMetadataController;
24
use SocialDept\AtpClient\Http\Controllers\JwksController;
25
+
use SocialDept\AtpClient\Http\DPoPClient;
26
use SocialDept\AtpClient\Session\SessionManager;
27
use SocialDept\AtpClient\Storage\EncryptedFileKeyStore;
28
···
50
51
// Register core services
52
$this->app->singleton(ClientMetadataManager::class);
53
+
$this->app->singleton(ClientAssertionManager::class);
54
$this->app->singleton(DPoPKeyManager::class);
55
$this->app->singleton(DPoPNonceManager::class);
56
+
$this->app->singleton(DPoPClient::class);
57
$this->app->singleton(TokenRefresher::class);
58
$this->app->singleton(SessionManager::class, function ($app) {
59
return new SessionManager(
···
61
refresher: $app->make(TokenRefresher::class),
62
dpopManager: $app->make(DPoPKeyManager::class),
63
keyStore: $app->make(KeyStore::class),
64
refreshThreshold: config('client.session.refresh_threshold', 300),
65
);
66
});
67
$this->app->singleton(OAuthEngine::class);
68
+
$this->app->singleton(ScopeChecker::class, function ($app) {
69
+
return new ScopeChecker(
70
+
config('atp-client.scope_enforcement', ScopeEnforcementLevel::Permissive)
71
+
);
72
+
});
73
+
74
+
// Register ScopeGate for AtpScope facade
75
+
$this->app->singleton('atp-scope', function ($app) {
76
+
return new ScopeGate(
77
+
$app->make(SessionManager::class),
78
+
$app->make(ScopeChecker::class),
79
+
);
80
+
});
81
82
// Register main client facade accessor
83
$this->app->bind('atp-client', function ($app) {
···
92
$this->app = $app;
93
}
94
95
+
public function as(string $actor): AtpClient
96
{
97
return new AtpClient(
98
$this->app->make(SessionManager::class),
99
+
$actor
100
);
101
}
102
103
+
public function login(string $actor, string $password): AtpClient
104
{
105
+
$this->app->make(SessionManager::class)
106
+
->fromAppPassword($actor, $password);
107
108
+
return $this->as($actor);
109
}
110
111
public function oauth(): OAuthEngine
···
118
$this->defaultProvider = $provider;
119
$this->app->instance(CredentialProvider::class, $provider);
120
}
121
+
122
+
public function public(?string $service = null): AtpClient
123
+
{
124
+
return new AtpClient(
125
+
sessions: null,
126
+
did: null,
127
+
serviceUrl: $service ?? config('atp-client.public.service_url', 'https://public.api.bsky.app')
128
+
);
129
+
}
130
};
131
});
132
}
···
143
144
$this->commands([
145
GenerateOAuthKeyCommand::class,
146
+
MakeAtpClientCommand::class,
147
+
MakeAtpRequestCommand::class,
148
]);
149
}
150
151
$this->registerRoutes();
152
+
$this->registerMiddleware();
153
+
}
154
+
155
+
/**
156
+
* Register middleware aliases
157
+
*/
158
+
protected function registerMiddleware(): void
159
+
{
160
+
/** @var Router $router */
161
+
$router = $this->app->make(Router::class);
162
+
$router->aliasMiddleware('atp.scope', RequiresScopeMiddleware::class);
163
}
164
165
/**
···
181
->name('atp.oauth.jwks');
182
});
183
184
+
// Register recommended client id convention (see: https://atproto.com/guides/oauth#clients)
185
+
Route::get('oauth-client-metadata.json', ClientMetadataController::class)
186
+
->name('atp.oauth.json');
187
}
188
189
/**
···
193
*/
194
public function provides(): array
195
{
196
+
return ['atp-client', 'atp-scope'];
197
}
198
}
+43
src/Attributes/PublicEndpoint.php
+43
src/Attributes/PublicEndpoint.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Attributes;
4
+
5
+
use Attribute;
6
+
7
+
/**
8
+
* Documents that a method is a public endpoint that does not require authentication.
9
+
*
10
+
* This attribute currently serves as documentation to indicate which AT Protocol
11
+
* endpoints can be called without an authenticated session. It helps developers
12
+
* understand which endpoints work with `Atp::public()` against public API endpoints
13
+
* like `https://public.api.bsky.app`.
14
+
*
15
+
* While this attribute does not currently perform runtime enforcement, scope
16
+
* validation will be implemented in a future release. Correctly attributing
17
+
* endpoints now ensures forward compatibility when enforcement is enabled.
18
+
*
19
+
* Public endpoints typically include operations like:
20
+
* - Reading public profiles and posts
21
+
* - Searching actors and content
22
+
* - Resolving handles to DIDs
23
+
* - Accessing repository data (sync endpoints)
24
+
* - Describing servers and feed generators
25
+
*
26
+
* @example Basic usage
27
+
* ```php
28
+
* #[PublicEndpoint]
29
+
* public function getProfile(string $actor): ProfileViewDetailed
30
+
* ```
31
+
*
32
+
* @see \SocialDept\AtpClient\Attributes\ScopedEndpoint For endpoints that require authentication
33
+
*/
34
+
#[Attribute(Attribute::TARGET_METHOD)]
35
+
class PublicEndpoint
36
+
{
37
+
/**
38
+
* @param string $description Human-readable description of the endpoint
39
+
*/
40
+
public function __construct(
41
+
public readonly string $description = '',
42
+
) {}
43
+
}
+67
src/Attributes/ScopedEndpoint.php
+67
src/Attributes/ScopedEndpoint.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Attributes;
4
+
5
+
use Attribute;
6
+
use SocialDept\AtpClient\Enums\Scope;
7
+
8
+
/**
9
+
* Documents that a method requires authentication with specific OAuth scopes.
10
+
*
11
+
* This attribute currently serves as documentation to indicate which AT Protocol
12
+
* endpoints require authentication and what scopes they need. It helps developers
13
+
* understand scope requirements when building applications.
14
+
*
15
+
* While this attribute does not currently perform runtime enforcement, scope
16
+
* validation will be implemented in a future release. Correctly attributing
17
+
* endpoints now ensures forward compatibility when enforcement is enabled.
18
+
*
19
+
* The AT Protocol currently uses "transition scopes" (like `transition:generic`) while
20
+
* moving toward more granular scopes. The `granular` parameter allows documenting the
21
+
* future granular scope that will replace the transition scope.
22
+
*
23
+
* @example Basic usage with a transition scope
24
+
* ```php
25
+
* #[ScopedEndpoint(Scope::TransitionGeneric)]
26
+
* public function getTimeline(): GetTimelineResponse
27
+
* ```
28
+
*
29
+
* @example With future granular scope documented
30
+
* ```php
31
+
* #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')]
32
+
* public function getTimeline(): GetTimelineResponse
33
+
* ```
34
+
*
35
+
* @see \SocialDept\AtpClient\Attributes\PublicEndpoint For endpoints that don't require authentication
36
+
* @see \SocialDept\AtpClient\Enums\Scope For available scope values
37
+
*/
38
+
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
39
+
class ScopedEndpoint
40
+
{
41
+
public array $scopes;
42
+
43
+
/**
44
+
* @param string|Scope|array<string|Scope> $scopes Required scope(s) for this method
45
+
* @param string|null $granular Future granular scope equivalent
46
+
* @param string $description Human-readable description of scope requirement
47
+
*/
48
+
public function __construct(
49
+
string|Scope|array $scopes,
50
+
public readonly ?string $granular = null,
51
+
public readonly string $description = '',
52
+
) {
53
+
$this->scopes = $this->normalizeScopes($scopes);
54
+
}
55
+
56
+
protected function normalizeScopes(string|Scope|array $scopes): array
57
+
{
58
+
if (! is_array($scopes)) {
59
+
$scopes = [$scopes];
60
+
}
61
+
62
+
return array_map(
63
+
fn ($scope) => $scope instanceof Scope ? $scope->value : $scope,
64
+
$scopes
65
+
);
66
+
}
67
+
}
+77
src/Auth/ClientAssertionManager.php
+77
src/Auth/ClientAssertionManager.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Auth;
4
+
5
+
use Firebase\JWT\JWT;
6
+
7
+
class ClientAssertionManager
8
+
{
9
+
public function __construct(
10
+
protected ClientMetadataManager $metadata,
11
+
) {}
12
+
13
+
/**
14
+
* Check if client assertion is required (private key is configured)
15
+
*/
16
+
public function isRequired(): bool
17
+
{
18
+
return ! empty(config('client.oauth.private_key'));
19
+
}
20
+
21
+
/**
22
+
* Create a client assertion JWT for private_key_jwt authentication
23
+
*/
24
+
public function createAssertion(string $audience): string
25
+
{
26
+
$key = OAuthKey::load();
27
+
$now = time();
28
+
29
+
$payload = [
30
+
'iss' => $this->metadata->getClientId(),
31
+
'sub' => $this->metadata->getClientId(),
32
+
'aud' => $audience,
33
+
'jti' => bin2hex(random_bytes(16)),
34
+
'iat' => $now,
35
+
'exp' => $now + 60,
36
+
];
37
+
38
+
$header = [
39
+
'alg' => 'ES256',
40
+
'kid' => config('client.oauth.kid', 'atp-client-key'),
41
+
'typ' => 'JWT',
42
+
];
43
+
44
+
return JWT::encode(
45
+
payload: $payload,
46
+
key: $key->toPEM(),
47
+
alg: 'ES256',
48
+
head: $header
49
+
);
50
+
}
51
+
52
+
/**
53
+
* Get the client assertion type for OAuth requests
54
+
*/
55
+
public function getAssertionType(): string
56
+
{
57
+
return 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
58
+
}
59
+
60
+
/**
61
+
* Get client authentication parameters for OAuth requests
62
+
*/
63
+
public function getAuthParams(string $audience): array
64
+
{
65
+
if (! $this->isRequired()) {
66
+
return [
67
+
'client_id' => $this->metadata->getClientId(),
68
+
];
69
+
}
70
+
71
+
return [
72
+
'client_id' => $this->metadata->getClientId(),
73
+
'client_assertion_type' => $this->getAssertionType(),
74
+
'client_assertion' => $this->createAssertion($audience),
75
+
];
76
+
}
77
+
}
+120
-9
src/Auth/ClientMetadataManager.php
+120
-9
src/Auth/ClientMetadataManager.php
···
2
3
namespace SocialDept\AtpClient\Auth;
4
5
class ClientMetadataManager
6
{
7
/**
8
-
* Get the client ID (typically the client URL)
9
*/
10
public function getClientId(): string
11
{
12
-
return config('client.client.url');
13
}
14
15
/**
16
-
* Get the client metadata URL
17
*/
18
-
public function getMetadataUrl(): ?string
19
{
20
-
return config('client.client.metadata_url');
21
}
22
23
/**
24
-
* Get the redirect URIs
25
*
26
* @return array<string>
27
*/
28
public function getRedirectUris(): array
29
{
30
-
return config('client.client.redirect_uris', []);
31
}
32
33
/**
34
-
* Get the OAuth scopes
35
*
36
* @return array<string>
37
*/
···
41
}
42
43
/**
44
-
* Get the client metadata as an array
45
*
46
* @return array<string, mixed>
47
*/
···
62
'application_type' => 'web',
63
'dpop_bound_access_tokens' => true,
64
];
65
}
66
}
···
2
3
namespace SocialDept\AtpClient\Auth;
4
5
+
/**
6
+
* Manages OAuth client metadata for AT Protocol authentication.
7
+
*
8
+
* The client_id in atproto OAuth is a URL that serves as both the unique
9
+
* identifier and the location of the client metadata document.
10
+
*
11
+
* For production: Use an HTTPS URL pointing to your client metadata.
12
+
* For localhost: Use exactly 'http://localhost' (no port).
13
+
*
14
+
* @see https://atproto.com/specs/oauth#clients
15
+
*/
16
class ClientMetadataManager
17
{
18
/**
19
+
* Get the client ID (URL to client metadata document).
20
+
*
21
+
* For production clients, this is an HTTPS URL like:
22
+
* 'https://example.com/oauth/client-metadata.json'
23
+
*
24
+
* For localhost development, this must be exactly 'http://localhost'
25
+
* (no port number allowed per atproto spec).
26
*/
27
public function getClientId(): string
28
{
29
+
$clientId = config('client.client.client_id');
30
+
31
+
if ($clientId) {
32
+
return $clientId;
33
+
}
34
+
35
+
// Fall back to auto-generated client_id based on app URL
36
+
return $this->generateClientId();
37
}
38
39
/**
40
+
* Check if this is a localhost development client.
41
*/
42
+
public function isLocalhost(): bool
43
{
44
+
return str_starts_with($this->getClientId(), 'http://localhost');
45
}
46
47
/**
48
+
* Get the redirect URIs.
49
+
*
50
+
* For localhost development, redirect URIs must use 127.0.0.1
51
+
* (not localhost) and can include a port number.
52
*
53
* @return array<string>
54
*/
55
public function getRedirectUris(): array
56
{
57
+
$uris = config('client.client.redirect_uris', []);
58
+
59
+
if (! empty($uris)) {
60
+
return $uris;
61
+
}
62
+
63
+
// Default redirect URI based on environment
64
+
if ($this->isLocalhost()) {
65
+
// For localhost, use 127.0.0.1
66
+
return ['http://127.0.0.1'];
67
+
}
68
+
69
+
// For production, use app URL
70
+
return [config('client.client.url').'/auth/atp/callback'];
71
}
72
73
/**
74
+
* Get the OAuth scopes.
75
*
76
* @return array<string>
77
*/
···
81
}
82
83
/**
84
+
* Get the client metadata as an array.
85
+
*
86
+
* This is the structure served at the client_id URL.
87
*
88
* @return array<string, mixed>
89
*/
···
104
'application_type' => 'web',
105
'dpop_bound_access_tokens' => true,
106
];
107
+
}
108
+
109
+
/**
110
+
* Generate client_id from app configuration.
111
+
*
112
+
* In production, points to the package's client-metadata.json endpoint.
113
+
* For localhost detection, checks if app URL contains localhost or .test.
114
+
*
115
+
* For localhost clients, the client_id includes query parameters for
116
+
* redirect_uri and scope since there's no metadata document to fetch.
117
+
*
118
+
* @see https://atproto.com/specs/oauth#clients
119
+
*/
120
+
protected function generateClientId(): string
121
+
{
122
+
$appUrl = config('client.client.url') ?? config('app.url');
123
+
$host = parse_url($appUrl, PHP_URL_HOST);
124
+
125
+
// Detect local development environments
126
+
if ($this->isLocalDevelopment($host)) {
127
+
return $this->buildLocalhostClientId();
128
+
}
129
+
130
+
// Production: point to client metadata endpoint
131
+
$prefix = config('client.oauth.prefix', '/atp/oauth/');
132
+
133
+
return rtrim($appUrl, '/').rtrim($prefix, '/').'/client-metadata.json';
134
+
}
135
+
136
+
/**
137
+
* Build localhost client_id with query parameters.
138
+
*
139
+
* For localhost clients, metadata is passed via query parameters:
140
+
* - redirect_uri: The callback URL (using 127.0.0.1)
141
+
* - scope: Space-separated list of requested scopes
142
+
*/
143
+
protected function buildLocalhostClientId(): string
144
+
{
145
+
$params = [];
146
+
147
+
// Add redirect URI
148
+
$redirectUris = config('client.client.redirect_uris', []);
149
+
if (! empty($redirectUris)) {
150
+
$params['redirect_uri'] = $redirectUris[0];
151
+
} else {
152
+
$params['redirect_uri'] = 'http://127.0.0.1';
153
+
}
154
+
155
+
// Add scopes
156
+
$scopes = config('client.client.scopes', ['atproto']);
157
+
$params['scope'] = implode(' ', $scopes);
158
+
159
+
return 'http://localhost?'.http_build_query($params);
160
+
}
161
+
162
+
/**
163
+
* Check if the host indicates a local development environment.
164
+
*/
165
+
protected function isLocalDevelopment(?string $host): bool
166
+
{
167
+
if (! $host) {
168
+
return false;
169
+
}
170
+
171
+
return $host === 'localhost'
172
+
|| $host === '127.0.0.1'
173
+
|| str_ends_with($host, '.localhost')
174
+
|| str_ends_with($host, '.test')
175
+
|| str_ends_with($host, '.local');
176
}
177
}
+6
-2
src/Auth/DPoPKeyManager.php
+6
-2
src/Auth/DPoPKeyManager.php
···
38
DPoPKey $key,
39
string $method,
40
string $url,
41
-
string $nonce,
42
?string $accessToken = null
43
): string {
44
$now = time();
···
49
'htu' => $url,
50
'iat' => $now,
51
'exp' => $now + 60, // 1 minute validity
52
-
'nonce' => $nonce,
53
];
54
55
if ($accessToken) {
56
$payload['ath'] = $this->hashAccessToken($accessToken);
···
38
DPoPKey $key,
39
string $method,
40
string $url,
41
+
string $nonce = '',
42
?string $accessToken = null
43
): string {
44
$now = time();
···
49
'htu' => $url,
50
'iat' => $now,
51
'exp' => $now + 60, // 1 minute validity
52
];
53
+
54
+
// Only include nonce if provided (first request may not have one)
55
+
if ($nonce !== '') {
56
+
$payload['nonce'] = $nonce;
57
+
}
58
59
if ($accessToken) {
60
$payload['ath'] = $this->hashAccessToken($accessToken);
+7
-29
src/Auth/DPoPNonceManager.php
+7
-29
src/Auth/DPoPNonceManager.php
···
8
{
9
/**
10
* Get DPoP nonce for PDS endpoint
11
*/
12
public function getNonce(string $pdsEndpoint): string
13
{
14
$cacheKey = 'dpop_nonce:'.md5($pdsEndpoint);
15
16
-
// Return cached nonce if available
17
-
if ($nonce = Cache::get($cacheKey)) {
18
-
return $nonce;
19
-
}
20
-
21
-
// Fetch new nonce from server
22
-
$nonce = $this->fetchNonce($pdsEndpoint);
23
-
24
-
// Cache for 5 minutes
25
-
Cache::put($cacheKey, $nonce, now()->addMinutes(5));
26
-
27
-
return $nonce;
28
}
29
30
/**
···
43
{
44
$cacheKey = 'dpop_nonce:'.md5($pdsEndpoint);
45
Cache::forget($cacheKey);
46
-
}
47
-
48
-
/**
49
-
* Fetch nonce from PDS server
50
-
*/
51
-
protected function fetchNonce(string $pdsEndpoint): string
52
-
{
53
-
// Make a HEAD request to get initial nonce
54
-
// The server returns nonce in DPoP-Nonce header
55
-
try {
56
-
$response = app('http')->head($pdsEndpoint.'/xrpc/_health');
57
-
58
-
return $response->header('DPoP-Nonce') ?? 'fallback-nonce-'.time();
59
-
} catch (\Exception $e) {
60
-
// Fallback if health endpoint fails
61
-
return 'fallback-nonce-'.time();
62
-
}
63
}
64
}
···
8
{
9
/**
10
* Get DPoP nonce for PDS endpoint
11
+
*
12
+
* Returns cached nonce if available, otherwise empty string.
13
+
* The first request will fail with use_dpop_nonce error,
14
+
* and the server will provide a valid nonce in the response.
15
*/
16
public function getNonce(string $pdsEndpoint): string
17
{
18
$cacheKey = 'dpop_nonce:'.md5($pdsEndpoint);
19
20
+
// Return cached nonce if available, empty string otherwise
21
+
// Empty nonce triggers use_dpop_nonce error, which is expected
22
+
return Cache::get($cacheKey, '');
23
}
24
25
/**
···
38
{
39
$cacheKey = 'dpop_nonce:'.md5($pdsEndpoint);
40
Cache::forget($cacheKey);
41
}
42
}
+51
-68
src/Auth/OAuthEngine.php
+51
-68
src/Auth/OAuthEngine.php
···
2
3
namespace SocialDept\AtpClient\Auth;
4
5
-
use Illuminate\Http\Client\Factory as HttpClient;
6
use Illuminate\Support\Str;
7
use SocialDept\AtpClient\Data\AccessToken;
8
use SocialDept\AtpClient\Data\AuthorizationRequest;
9
use SocialDept\AtpClient\Exceptions\AuthenticationException;
10
use SocialDept\AtpResolver\Facades\Resolver;
11
12
class OAuthEngine
13
{
14
public function __construct(
15
-
protected HttpClient $http,
16
protected DPoPKeyManager $dpopManager,
17
protected ClientMetadataManager $metadata,
18
) {}
19
20
/**
···
22
*/
23
public function authorize(
24
string $identifier,
25
-
array $scopes = ['atproto', 'transition:generic'],
26
?string $pdsEndpoint = null
27
): AuthorizationRequest {
28
// Resolve PDS endpoint
29
if (! $pdsEndpoint) {
30
$pdsEndpoint = Resolver::resolvePds($identifier);
···
45
$pdsEndpoint,
46
$scopes,
47
$codeChallenge,
48
$dpopKey
49
);
50
···
60
codeVerifier: $codeVerifier,
61
dpopKey: $dpopKey,
62
requestUri: $parResponse['request_uri'],
63
);
64
}
65
···
75
throw new AuthenticationException('State mismatch');
76
}
77
78
-
// Get PDS endpoint from request
79
-
$pdsEndpoint = $this->extractPdsFromRequestUri($request->requestUri);
80
81
-
// Exchange code for token
82
-
$dpopProof = $this->dpopManager->createProof(
83
-
key: $request->dpopKey,
84
-
method: 'POST',
85
-
url: $pdsEndpoint.'/oauth/token',
86
-
nonce: $this->getDpopNonce($pdsEndpoint),
87
-
);
88
-
89
-
$response = $this->http
90
-
->withHeaders([
91
-
'DPoP' => $dpopProof,
92
-
'Content-Type' => 'application/x-www-form-urlencoded',
93
-
])
94
->asForm()
95
-
->post($pdsEndpoint.'/oauth/token', [
96
-
'grant_type' => 'authorization_code',
97
-
'code' => $code,
98
-
'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null,
99
-
'client_id' => $this->metadata->getClientId(),
100
-
'code_verifier' => $request->codeVerifier,
101
-
]);
102
103
if ($response->failed()) {
104
-
throw new AuthenticationException(
105
-
'Token exchange failed: '.$response->body()
106
-
);
107
}
108
109
-
return AccessToken::fromResponse($response->json());
110
}
111
112
/**
···
116
string $pdsEndpoint,
117
array $scopes,
118
string $codeChallenge,
119
-
$dpopKey
120
): array {
121
-
$dpopProof = $this->dpopManager->createProof(
122
-
key: $dpopKey,
123
-
method: 'POST',
124
-
url: $pdsEndpoint.'/oauth/par',
125
-
nonce: $this->getDpopNonce($pdsEndpoint),
126
-
);
127
128
-
$response = $this->http
129
-
->withHeaders(['DPoP' => $dpopProof])
130
->asForm()
131
-
->post($pdsEndpoint.'/oauth/par', [
132
-
'client_id' => $this->metadata->getClientId(),
133
-
'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null,
134
-
'response_type' => 'code',
135
-
'scope' => implode(' ', $scopes),
136
-
'code_challenge' => $codeChallenge,
137
-
'code_challenge_method' => 'S256',
138
-
'state' => Str::random(32),
139
-
]);
140
141
if ($response->failed()) {
142
throw new AuthenticationException('PAR failed: '.$response->body());
···
151
protected function generatePkceChallenge(string $verifier): string
152
{
153
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
154
-
}
155
-
156
-
/**
157
-
* Get DPoP nonce from server
158
-
*/
159
-
protected function getDpopNonce(string $pdsEndpoint): string
160
-
{
161
-
// TODO: Implement proper DPoP nonce fetching and caching
162
-
// This is typically returned in DPoP-Nonce header
163
-
return 'temp-nonce-'.time();
164
-
}
165
-
166
-
/**
167
-
* Extract PDS endpoint from request URI
168
-
*/
169
-
protected function extractPdsFromRequestUri(string $requestUri): string
170
-
{
171
-
// Parse the request URI to extract the base PDS endpoint
172
-
$parts = parse_url($requestUri);
173
-
174
-
return ($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? '');
175
}
176
}
···
2
3
namespace SocialDept\AtpClient\Auth;
4
5
use Illuminate\Support\Str;
6
use SocialDept\AtpClient\Data\AccessToken;
7
use SocialDept\AtpClient\Data\AuthorizationRequest;
8
+
use SocialDept\AtpClient\Data\DPoPKey;
9
+
use SocialDept\AtpClient\Events\SessionAuthenticated;
10
+
use SocialDept\AtpClient\Contracts\KeyStore;
11
use SocialDept\AtpClient\Exceptions\AuthenticationException;
12
+
use SocialDept\AtpClient\Http\DPoPClient;
13
use SocialDept\AtpResolver\Facades\Resolver;
14
15
class OAuthEngine
16
{
17
public function __construct(
18
protected DPoPKeyManager $dpopManager,
19
protected ClientMetadataManager $metadata,
20
+
protected DPoPClient $dpopClient,
21
+
protected ClientAssertionManager $clientAssertion,
22
+
protected KeyStore $keyStore,
23
) {}
24
25
/**
···
27
*/
28
public function authorize(
29
string $identifier,
30
+
?array $scopes = null,
31
?string $pdsEndpoint = null
32
): AuthorizationRequest {
33
+
// Use configured scopes if none provided
34
+
$scopes = $scopes ?? $this->metadata->getScopes();
35
+
36
// Resolve PDS endpoint
37
if (! $pdsEndpoint) {
38
$pdsEndpoint = Resolver::resolvePds($identifier);
···
53
$pdsEndpoint,
54
$scopes,
55
$codeChallenge,
56
+
$state,
57
$dpopKey
58
);
59
···
69
codeVerifier: $codeVerifier,
70
dpopKey: $dpopKey,
71
requestUri: $parResponse['request_uri'],
72
+
pdsEndpoint: $pdsEndpoint,
73
+
handle: $identifier,
74
);
75
}
76
···
86
throw new AuthenticationException('State mismatch');
87
}
88
89
+
$tokenUrl = $request->pdsEndpoint.'/oauth/token';
90
91
+
$response = $this->dpopClient->request($request->pdsEndpoint, $tokenUrl, 'POST', $request->dpopKey)
92
->asForm()
93
+
->post($tokenUrl, array_merge(
94
+
$this->clientAssertion->getAuthParams($request->pdsEndpoint),
95
+
[
96
+
'grant_type' => 'authorization_code',
97
+
'code' => $code,
98
+
'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null,
99
+
'code_verifier' => $request->codeVerifier,
100
+
]
101
+
));
102
103
if ($response->failed()) {
104
+
throw new AuthenticationException('Token exchange failed: '.$response->body());
105
}
106
107
+
$token = AccessToken::fromResponse($response->json(), $request->handle, $request->pdsEndpoint);
108
+
109
+
// Store the DPoP key with the session ID so future requests can use it
110
+
// The token is bound to this key's thumbprint (cnf.jkt claim)
111
+
$sessionId = 'session_'.hash('sha256', $token->did);
112
+
$this->keyStore->store($sessionId, $request->dpopKey);
113
+
114
+
event(new SessionAuthenticated($token));
115
+
116
+
return $token;
117
}
118
119
/**
···
123
string $pdsEndpoint,
124
array $scopes,
125
string $codeChallenge,
126
+
string $state,
127
+
DPoPKey $dpopKey
128
): array {
129
+
$parUrl = $pdsEndpoint.'/oauth/par';
130
131
+
$response = $this->dpopClient->request($pdsEndpoint, $parUrl, 'POST', $dpopKey)
132
->asForm()
133
+
->post($parUrl, array_merge(
134
+
$this->clientAssertion->getAuthParams($pdsEndpoint),
135
+
[
136
+
'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null,
137
+
'response_type' => 'code',
138
+
'scope' => implode(' ', $scopes),
139
+
'code_challenge' => $codeChallenge,
140
+
'code_challenge_method' => 'S256',
141
+
'state' => $state,
142
+
]
143
+
));
144
145
if ($response->failed()) {
146
throw new AuthenticationException('PAR failed: '.$response->body());
···
155
protected function generatePkceChallenge(string $verifier): string
156
{
157
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
158
}
159
}
+271
src/Auth/ScopeChecker.php
+271
src/Auth/ScopeChecker.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Auth;
4
+
5
+
use BackedEnum;
6
+
use Illuminate\Support\Facades\Log;
7
+
use SocialDept\AtpClient\Enums\Scope;
8
+
use SocialDept\AtpClient\Enums\ScopeEnforcementLevel;
9
+
use SocialDept\AtpClient\Exceptions\MissingScopeException;
10
+
use SocialDept\AtpClient\Session\Session;
11
+
12
+
class ScopeChecker
13
+
{
14
+
public function __construct(
15
+
protected ScopeEnforcementLevel $enforcement = ScopeEnforcementLevel::Permissive
16
+
) {}
17
+
18
+
/**
19
+
* Check if the session has all required scopes.
20
+
*
21
+
* @param array<string|Scope> $requiredScopes
22
+
*/
23
+
public function check(Session $session, array $requiredScopes): bool
24
+
{
25
+
$required = $this->normalizeScopes($requiredScopes);
26
+
$granted = $session->scopes();
27
+
28
+
foreach ($required as $scope) {
29
+
if (! $this->sessionHasScope($session, $scope)) {
30
+
return false;
31
+
}
32
+
}
33
+
34
+
return true;
35
+
}
36
+
37
+
/**
38
+
* Check scopes and handle enforcement based on configuration.
39
+
*
40
+
* @param array<string|Scope> $requiredScopes
41
+
*
42
+
* @throws MissingScopeException
43
+
*/
44
+
public function checkOrFail(Session $session, array $requiredScopes): void
45
+
{
46
+
if ($this->check($session, $requiredScopes)) {
47
+
return;
48
+
}
49
+
50
+
$required = $this->normalizeScopes($requiredScopes);
51
+
$granted = $session->scopes();
52
+
$missing = array_diff($required, $granted);
53
+
54
+
if ($this->enforcement === ScopeEnforcementLevel::Strict) {
55
+
throw new MissingScopeException($missing, $granted);
56
+
}
57
+
58
+
Log::warning('ATP Client: Missing required scope(s)', [
59
+
'required' => $required,
60
+
'granted' => $granted,
61
+
'missing' => $missing,
62
+
'did' => $session->did(),
63
+
]);
64
+
}
65
+
66
+
/**
67
+
* Check if the session has a specific scope.
68
+
*/
69
+
public function hasScope(Session $session, string|Scope $scope): bool
70
+
{
71
+
$scope = $scope instanceof Scope ? $scope->value : $scope;
72
+
73
+
return $this->sessionHasScope($session, $scope);
74
+
}
75
+
76
+
/**
77
+
* Check if the session matches a granular scope pattern.
78
+
*
79
+
* Supports patterns like:
80
+
* - repo:app.bsky.feed.post?action=create
81
+
* - repo:app.bsky.feed.*
82
+
* - rpc:app.bsky.feed.*
83
+
* - blob:image/*
84
+
*/
85
+
public function matchesGranular(Session $session, string $pattern): bool
86
+
{
87
+
$granted = $session->scopes();
88
+
89
+
// Check for exact match first
90
+
if (in_array($pattern, $granted, true)) {
91
+
return true;
92
+
}
93
+
94
+
// Handle repo: scopes with action semantics
95
+
if (str_starts_with($pattern, 'repo:')) {
96
+
foreach ($granted as $scope) {
97
+
if (str_starts_with($scope, 'repo:') && $this->matchesRepoScope($pattern, $scope)) {
98
+
return true;
99
+
}
100
+
}
101
+
}
102
+
103
+
// Check for wildcard matches
104
+
$patternRegex = $this->patternToRegex($pattern);
105
+
106
+
foreach ($granted as $scope) {
107
+
if (preg_match($patternRegex, $scope)) {
108
+
return true;
109
+
}
110
+
}
111
+
112
+
// Check if granted scope is a superset (wildcard in granted scope)
113
+
foreach ($granted as $scope) {
114
+
$grantedRegex = $this->patternToRegex($scope);
115
+
if (preg_match($grantedRegex, $pattern)) {
116
+
return true;
117
+
}
118
+
}
119
+
120
+
return false;
121
+
}
122
+
123
+
/**
124
+
* Check if a required repo scope is satisfied by a granted repo scope.
125
+
*
126
+
* Per AT Protocol spec: "If not defined, all operations are allowed."
127
+
* - repo:collection (no action) grants ALL actions
128
+
* - repo:collection?action=create grants only create
129
+
* - repo:* grants all collections with all actions
130
+
*/
131
+
protected function matchesRepoScope(string $required, string $granted): bool
132
+
{
133
+
$requiredParsed = $this->parseRepoScope($required);
134
+
$grantedParsed = $this->parseRepoScope($granted);
135
+
136
+
// Check collection match (with wildcard support)
137
+
if (! $this->collectionsMatch($requiredParsed['collection'], $grantedParsed['collection'])) {
138
+
return false;
139
+
}
140
+
141
+
// If granted has no actions, it grants ALL actions
142
+
if (empty($grantedParsed['actions'])) {
143
+
return true;
144
+
}
145
+
146
+
// If required has no actions, we need all actions granted
147
+
if (empty($requiredParsed['actions'])) {
148
+
// Required needs all actions, but granted is restricted
149
+
return false;
150
+
}
151
+
152
+
// Check if all required actions are in granted actions
153
+
return empty(array_diff($requiredParsed['actions'], $grantedParsed['actions']));
154
+
}
155
+
156
+
/**
157
+
* Parse a repo scope into collection and actions.
158
+
*
159
+
* Handles formats like:
160
+
* - repo:app.bsky.feed.post
161
+
* - repo:app.bsky.feed.post?action=create
162
+
* - repo:app.bsky.feed.post?action=create&action=update&action=delete
163
+
* - repo:*
164
+
* - repo:*?action=delete
165
+
*
166
+
* @return array{collection: string, actions: array<string>}
167
+
*/
168
+
protected function parseRepoScope(string $scope): array
169
+
{
170
+
$parts = explode('?', $scope, 2);
171
+
$collection = substr($parts[0], 5); // Remove 'repo:'
172
+
173
+
$actions = [];
174
+
if (isset($parts[1])) {
175
+
// Parse action=create&action=update&action=delete format
176
+
// PHP's parse_str doesn't handle repeated params well
177
+
preg_match_all('/action=([^&]+)/', $parts[1], $matches);
178
+
if (! empty($matches[1])) {
179
+
$actions = array_map('urldecode', $matches[1]);
180
+
}
181
+
}
182
+
183
+
return ['collection' => $collection, 'actions' => $actions];
184
+
}
185
+
186
+
/**
187
+
* Check if a required collection matches a granted collection.
188
+
*/
189
+
protected function collectionsMatch(string $required, string $granted): bool
190
+
{
191
+
if ($granted === '*') {
192
+
return true;
193
+
}
194
+
195
+
return $required === $granted;
196
+
}
197
+
198
+
/**
199
+
* Check if the session has repo access for a specific collection and action.
200
+
*/
201
+
public function checkRepoScope(Session $session, string|BackedEnum $collection, string $action): bool
202
+
{
203
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
204
+
$required = "repo:{$collection}?action={$action}";
205
+
206
+
return $this->sessionHasScope($session, $required);
207
+
}
208
+
209
+
/**
210
+
* Check repo scope and handle enforcement based on configuration.
211
+
*
212
+
* @throws MissingScopeException
213
+
*/
214
+
public function checkRepoScopeOrFail(Session $session, string|BackedEnum $collection, string $action): void
215
+
{
216
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
217
+
$required = "repo:{$collection}?action={$action}";
218
+
219
+
$this->checkOrFail($session, [$required]);
220
+
}
221
+
222
+
/**
223
+
* Get the current enforcement level.
224
+
*/
225
+
public function enforcement(): ScopeEnforcementLevel
226
+
{
227
+
return $this->enforcement;
228
+
}
229
+
230
+
/**
231
+
* Create a new instance with a different enforcement level.
232
+
*/
233
+
public function withEnforcement(ScopeEnforcementLevel $enforcement): self
234
+
{
235
+
return new self($enforcement);
236
+
}
237
+
238
+
/**
239
+
* @param array<string|Scope> $scopes
240
+
* @return array<string>
241
+
*/
242
+
protected function normalizeScopes(array $scopes): array
243
+
{
244
+
return array_map(
245
+
fn ($scope) => $scope instanceof Scope ? $scope->value : $scope,
246
+
$scopes
247
+
);
248
+
}
249
+
250
+
protected function sessionHasScope(Session $session, string $scope): bool
251
+
{
252
+
// Direct match
253
+
if ($session->hasScope($scope)) {
254
+
return true;
255
+
}
256
+
257
+
// Check granular pattern matching
258
+
return $this->matchesGranular($session, $scope);
259
+
}
260
+
261
+
protected function patternToRegex(string $pattern): string
262
+
{
263
+
// Escape regex special characters except *
264
+
$escaped = preg_quote($pattern, '/');
265
+
266
+
// Replace \* with .* for wildcard matching
267
+
$regex = str_replace('\*', '.*', $escaped);
268
+
269
+
return '/^'.$regex.'$/';
270
+
}
271
+
}
+176
src/Auth/ScopeGate.php
+176
src/Auth/ScopeGate.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Auth;
4
+
5
+
use Illuminate\Contracts\Auth\Authenticatable;
6
+
use SocialDept\AtpClient\Contracts\HasAtpSession;
7
+
use SocialDept\AtpClient\Enums\Scope;
8
+
use SocialDept\AtpClient\Enums\ScopeAuthorizationFailure;
9
+
use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException;
10
+
use SocialDept\AtpClient\Session\Session;
11
+
use SocialDept\AtpClient\Session\SessionManager;
12
+
13
+
class ScopeGate
14
+
{
15
+
protected ?Session $session = null;
16
+
17
+
public function __construct(
18
+
protected SessionManager $sessions,
19
+
protected ScopeChecker $checker,
20
+
) {}
21
+
22
+
/**
23
+
* Set the session context directly.
24
+
*/
25
+
public function forSession(Session $session): self
26
+
{
27
+
$instance = new self($this->sessions, $this->checker);
28
+
$instance->session = $session;
29
+
30
+
return $instance;
31
+
}
32
+
33
+
/**
34
+
* Set the session context via actor (handle or DID).
35
+
*/
36
+
public function forUser(string $actor): self
37
+
{
38
+
$instance = new self($this->sessions, $this->checker);
39
+
$instance->session = $this->sessions->session($actor);
40
+
41
+
return $instance;
42
+
}
43
+
44
+
/**
45
+
* Check if the session has the given scope.
46
+
*/
47
+
public function can(string|Scope $scope): bool
48
+
{
49
+
$session = $this->resolveSession();
50
+
51
+
if (! $session) {
52
+
return false;
53
+
}
54
+
55
+
return $this->checker->hasScope($session, $scope);
56
+
}
57
+
58
+
/**
59
+
* Check if the session has any of the given scopes.
60
+
*
61
+
* @param array<string|Scope> $scopes
62
+
*/
63
+
public function canAny(array $scopes): bool
64
+
{
65
+
$session = $this->resolveSession();
66
+
67
+
if (! $session) {
68
+
return false;
69
+
}
70
+
71
+
foreach ($scopes as $scope) {
72
+
if ($this->checker->hasScope($session, $scope)) {
73
+
return true;
74
+
}
75
+
}
76
+
77
+
return false;
78
+
}
79
+
80
+
/**
81
+
* Check if the session has all of the given scopes.
82
+
*
83
+
* @param array<string|Scope> $scopes
84
+
*/
85
+
public function canAll(array $scopes): bool
86
+
{
87
+
$session = $this->resolveSession();
88
+
89
+
if (! $session) {
90
+
return false;
91
+
}
92
+
93
+
return $this->checker->check($session, $scopes);
94
+
}
95
+
96
+
/**
97
+
* Check if the session does NOT have the given scope.
98
+
*/
99
+
public function cannot(string|Scope $scope): bool
100
+
{
101
+
return ! $this->can($scope);
102
+
}
103
+
104
+
/**
105
+
* Authorize the session has all given scopes, or handle failure.
106
+
*
107
+
* @param string|Scope ...$scopes
108
+
*
109
+
* @throws ScopeAuthorizationException
110
+
*/
111
+
public function authorize(string|Scope ...$scopes): void
112
+
{
113
+
if ($this->canAll($scopes)) {
114
+
return;
115
+
}
116
+
117
+
$session = $this->resolveSession();
118
+
$granted = $session ? $session->scopes() : [];
119
+
$required = array_map(
120
+
fn ($scope) => $scope instanceof Scope ? $scope->value : $scope,
121
+
$scopes
122
+
);
123
+
$missing = array_diff($required, $granted);
124
+
125
+
$exception = new ScopeAuthorizationException($missing, $granted);
126
+
127
+
$action = config('atp-client.scope_authorization.failure_action', ScopeAuthorizationFailure::Abort);
128
+
129
+
if ($action === ScopeAuthorizationFailure::Exception) {
130
+
throw $exception;
131
+
}
132
+
133
+
// For Abort and Redirect, let the exception render itself
134
+
throw $exception;
135
+
}
136
+
137
+
/**
138
+
* Get the granted scopes for the current session.
139
+
*/
140
+
public function granted(): array
141
+
{
142
+
$session = $this->resolveSession();
143
+
144
+
return $session ? $session->scopes() : [];
145
+
}
146
+
147
+
/**
148
+
* Resolve the session from context.
149
+
*/
150
+
protected function resolveSession(): ?Session
151
+
{
152
+
// If session was explicitly set, use it
153
+
if ($this->session) {
154
+
return $this->session;
155
+
}
156
+
157
+
// Try to resolve from authenticated user
158
+
$user = auth()->user();
159
+
160
+
if (! $user instanceof HasAtpSession) {
161
+
return null;
162
+
}
163
+
164
+
$did = $user->getAtpDid();
165
+
166
+
if (! $did) {
167
+
return null;
168
+
}
169
+
170
+
try {
171
+
return $this->sessions->session($did);
172
+
} catch (\Exception) {
173
+
return null;
174
+
}
175
+
}
176
+
}
+51
-29
src/Auth/TokenRefresher.php
+51
-29
src/Auth/TokenRefresher.php
···
2
3
namespace SocialDept\AtpClient\Auth;
4
5
-
use Illuminate\Http\Client\Factory as HttpClient;
6
use SocialDept\AtpClient\Data\AccessToken;
7
use SocialDept\AtpClient\Data\DPoPKey;
8
use SocialDept\AtpClient\Exceptions\AuthenticationException;
9
10
class TokenRefresher
11
{
12
public function __construct(
13
-
protected HttpClient $http,
14
-
protected DPoPKeyManager $dpopManager,
15
) {}
16
17
/**
18
-
* Refresh access token using refresh token
19
* NOTE: Refresh tokens are single-use!
20
*/
21
public function refresh(
22
string $refreshToken,
23
string $pdsEndpoint,
24
-
DPoPKey $dpopKey
25
): AccessToken {
26
-
$dpopProof = $this->dpopManager->createProof(
27
-
key: $dpopKey,
28
-
method: 'POST',
29
-
url: $pdsEndpoint.'/oauth/token',
30
-
nonce: $this->getDpopNonce($pdsEndpoint),
31
-
);
32
33
-
$response = $this->http
34
-
->withHeaders([
35
-
'DPoP' => $dpopProof,
36
-
'Content-Type' => 'application/x-www-form-urlencoded',
37
-
])
38
->asForm()
39
-
->post($pdsEndpoint.'/oauth/token', [
40
-
'grant_type' => 'refresh_token',
41
-
'refresh_token' => $refreshToken,
42
-
]);
43
44
if ($response->failed()) {
45
-
throw new AuthenticationException(
46
-
'Token refresh failed: '.$response->body()
47
-
);
48
}
49
50
-
return AccessToken::fromResponse($response->json());
51
}
52
53
-
protected function getDpopNonce(string $pdsEndpoint): string
54
-
{
55
-
// TODO: Implement proper DPoP nonce fetching and caching
56
-
// For now, return a placeholder that will need to be replaced
57
-
return 'temp-nonce-'.time();
58
}
59
}
···
2
3
namespace SocialDept\AtpClient\Auth;
4
5
+
use Illuminate\Support\Facades\Http;
6
use SocialDept\AtpClient\Data\AccessToken;
7
use SocialDept\AtpClient\Data\DPoPKey;
8
+
use SocialDept\AtpClient\Enums\AuthType;
9
use SocialDept\AtpClient\Exceptions\AuthenticationException;
10
+
use SocialDept\AtpClient\Http\DPoPClient;
11
12
class TokenRefresher
13
{
14
public function __construct(
15
+
protected DPoPClient $dpopClient,
16
+
protected ClientAssertionManager $clientAssertion,
17
) {}
18
19
/**
20
+
* Refresh access token using refresh token.
21
* NOTE: Refresh tokens are single-use!
22
*/
23
public function refresh(
24
string $refreshToken,
25
string $pdsEndpoint,
26
+
DPoPKey $dpopKey,
27
+
?string $handle = null,
28
+
AuthType $authType = AuthType::OAuth,
29
): AccessToken {
30
+
return $authType === AuthType::Legacy
31
+
? $this->refreshLegacy($refreshToken, $pdsEndpoint, $handle)
32
+
: $this->refreshOAuth($refreshToken, $pdsEndpoint, $dpopKey, $handle);
33
+
}
34
35
+
/**
36
+
* Refresh OAuth session using /oauth/token endpoint with DPoP.
37
+
*/
38
+
protected function refreshOAuth(
39
+
string $refreshToken,
40
+
string $pdsEndpoint,
41
+
DPoPKey $dpopKey,
42
+
?string $handle,
43
+
): AccessToken {
44
+
$tokenUrl = $pdsEndpoint.'/oauth/token';
45
+
46
+
$response = $this->dpopClient->request($pdsEndpoint, $tokenUrl, 'POST', $dpopKey)
47
->asForm()
48
+
->post($tokenUrl, array_merge(
49
+
$this->clientAssertion->getAuthParams($pdsEndpoint),
50
+
[
51
+
'grant_type' => 'refresh_token',
52
+
'refresh_token' => $refreshToken,
53
+
]
54
+
));
55
56
if ($response->failed()) {
57
+
throw new AuthenticationException('Token refresh failed: '.$response->body());
58
}
59
60
+
return AccessToken::fromResponse($response->json(), $handle, $pdsEndpoint);
61
}
62
63
+
/**
64
+
* Refresh legacy session using /xrpc/com.atproto.server.refreshSession endpoint.
65
+
*/
66
+
protected function refreshLegacy(
67
+
string $refreshToken,
68
+
string $pdsEndpoint,
69
+
?string $handle,
70
+
): AccessToken {
71
+
$response = Http::withHeader('Authorization', 'Bearer '.$refreshToken)
72
+
->withBody('', 'application/json')
73
+
->post($pdsEndpoint.'/xrpc/com.atproto.server.refreshSession');
74
+
75
+
if ($response->failed()) {
76
+
throw new AuthenticationException('Token refresh failed: '.$response->body());
77
+
}
78
+
79
+
return AccessToken::fromResponse($response->json(), $handle, $pdsEndpoint);
80
}
81
}
+197
src/Builders/Concerns/BuildsRichText.php
+197
src/Builders/Concerns/BuildsRichText.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Builders\Concerns;
4
+
5
+
use SocialDept\AtpClient\RichText\FacetDetector;
6
+
use SocialDept\AtpResolver\Facades\Resolver;
7
+
8
+
trait BuildsRichText
9
+
{
10
+
protected string $text = '';
11
+
12
+
protected array $facets = [];
13
+
14
+
/**
15
+
* Add plain text
16
+
*/
17
+
public function text(string $text): self
18
+
{
19
+
$this->text .= $text;
20
+
21
+
return $this;
22
+
}
23
+
24
+
/**
25
+
* Add one or more new lines
26
+
*/
27
+
public function newLine(int $count = 1): self
28
+
{
29
+
$this->text .= str_repeat("\n", $count);
30
+
31
+
return $this;
32
+
}
33
+
34
+
/**
35
+
* Add mention (@handle)
36
+
*/
37
+
public function mention(string $handle, ?string $did = null): self
38
+
{
39
+
$handle = ltrim($handle, '@');
40
+
$start = $this->getBytePosition();
41
+
$this->text .= '@'.$handle;
42
+
$end = $this->getBytePosition();
43
+
44
+
if (! $did) {
45
+
try {
46
+
$did = Resolver::handleToDid($handle);
47
+
} catch (\Exception $e) {
48
+
return $this;
49
+
}
50
+
}
51
+
52
+
$this->facets[] = [
53
+
'index' => [
54
+
'byteStart' => $start,
55
+
'byteEnd' => $end,
56
+
],
57
+
'features' => [
58
+
[
59
+
'$type' => 'app.bsky.richtext.facet#mention',
60
+
'did' => $did,
61
+
],
62
+
],
63
+
];
64
+
65
+
return $this;
66
+
}
67
+
68
+
/**
69
+
* Add link with custom display text
70
+
*/
71
+
public function link(string $text, string $uri): self
72
+
{
73
+
$start = $this->getBytePosition();
74
+
$this->text .= $text;
75
+
$end = $this->getBytePosition();
76
+
77
+
$this->facets[] = [
78
+
'index' => [
79
+
'byteStart' => $start,
80
+
'byteEnd' => $end,
81
+
],
82
+
'features' => [
83
+
[
84
+
'$type' => 'app.bsky.richtext.facet#link',
85
+
'uri' => $uri,
86
+
],
87
+
],
88
+
];
89
+
90
+
return $this;
91
+
}
92
+
93
+
/**
94
+
* Add a URL (displayed as-is)
95
+
*/
96
+
public function url(string $url): self
97
+
{
98
+
return $this->link($url, $url);
99
+
}
100
+
101
+
/**
102
+
* Add hashtag
103
+
*/
104
+
public function tag(string $tag): self
105
+
{
106
+
$tag = ltrim($tag, '#');
107
+
108
+
$start = $this->getBytePosition();
109
+
$this->text .= '#'.$tag;
110
+
$end = $this->getBytePosition();
111
+
112
+
$this->facets[] = [
113
+
'index' => [
114
+
'byteStart' => $start,
115
+
'byteEnd' => $end,
116
+
],
117
+
'features' => [
118
+
[
119
+
'$type' => 'app.bsky.richtext.facet#tag',
120
+
'tag' => $tag,
121
+
],
122
+
],
123
+
];
124
+
125
+
return $this;
126
+
}
127
+
128
+
/**
129
+
* Auto-detect and add facets from plain text
130
+
*/
131
+
public function autoDetect(string $text): self
132
+
{
133
+
$start = $this->getBytePosition();
134
+
$this->text .= $text;
135
+
136
+
$detected = FacetDetector::detect($text);
137
+
138
+
foreach ($detected as $facet) {
139
+
$facet['index']['byteStart'] += $start;
140
+
$facet['index']['byteEnd'] += $start;
141
+
$this->facets[] = $facet;
142
+
}
143
+
144
+
return $this;
145
+
}
146
+
147
+
/**
148
+
* Get current byte position (UTF-8 byte offset)
149
+
*/
150
+
protected function getBytePosition(): int
151
+
{
152
+
return strlen($this->text);
153
+
}
154
+
155
+
/**
156
+
* Get the text content
157
+
*/
158
+
public function getText(): string
159
+
{
160
+
return $this->text;
161
+
}
162
+
163
+
/**
164
+
* Get the facets
165
+
*/
166
+
public function getFacets(): array
167
+
{
168
+
return $this->facets;
169
+
}
170
+
171
+
/**
172
+
* Get text and facets as array
173
+
*/
174
+
protected function getTextAndFacets(): array
175
+
{
176
+
return [
177
+
'text' => $this->text,
178
+
'facets' => $this->facets,
179
+
];
180
+
}
181
+
182
+
/**
183
+
* Get grapheme count (closest to what AT Protocol uses for limits)
184
+
*/
185
+
public function getGraphemeCount(): int
186
+
{
187
+
return grapheme_strlen($this->text);
188
+
}
189
+
190
+
/**
191
+
* Check if text exceeds AT Protocol post limit (300 graphemes)
192
+
*/
193
+
public function exceedsLimit(int $limit = 300): bool
194
+
{
195
+
return $this->getGraphemeCount() > $limit;
196
+
}
197
+
}
+93
src/Builders/Embeds/ImagesBuilder.php
+93
src/Builders/Embeds/ImagesBuilder.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Builders\Embeds;
4
+
5
+
class ImagesBuilder
6
+
{
7
+
protected array $images = [];
8
+
9
+
/**
10
+
* Create a new images builder instance
11
+
*/
12
+
public static function make(): self
13
+
{
14
+
return new self;
15
+
}
16
+
17
+
/**
18
+
* Add an image to the embed
19
+
*
20
+
* @param mixed $blob BlobReference or blob array
21
+
* @param string $alt Alt text for the image
22
+
* @param array|null $aspectRatio [width, height] aspect ratio
23
+
*/
24
+
public function add(mixed $blob, string $alt, ?array $aspectRatio = null): self
25
+
{
26
+
$image = [
27
+
'image' => $this->normalizeBlob($blob),
28
+
'alt' => $alt,
29
+
];
30
+
31
+
if ($aspectRatio !== null) {
32
+
$image['aspectRatio'] = [
33
+
'width' => $aspectRatio[0],
34
+
'height' => $aspectRatio[1],
35
+
];
36
+
}
37
+
38
+
$this->images[] = $image;
39
+
40
+
return $this;
41
+
}
42
+
43
+
/**
44
+
* Get all images
45
+
*/
46
+
public function getImages(): array
47
+
{
48
+
return $this->images;
49
+
}
50
+
51
+
/**
52
+
* Check if builder has images
53
+
*/
54
+
public function hasImages(): bool
55
+
{
56
+
return ! empty($this->images);
57
+
}
58
+
59
+
/**
60
+
* Get the count of images
61
+
*/
62
+
public function count(): int
63
+
{
64
+
return count($this->images);
65
+
}
66
+
67
+
/**
68
+
* Convert to embed array format
69
+
*/
70
+
public function toArray(): array
71
+
{
72
+
return [
73
+
'$type' => 'app.bsky.embed.images',
74
+
'images' => $this->images,
75
+
];
76
+
}
77
+
78
+
/**
79
+
* Normalize blob to array format
80
+
*/
81
+
protected function normalizeBlob(mixed $blob): array
82
+
{
83
+
if (is_array($blob)) {
84
+
return $blob;
85
+
}
86
+
87
+
if (method_exists($blob, 'toArray')) {
88
+
return $blob->toArray();
89
+
}
90
+
91
+
return (array) $blob;
92
+
}
93
+
}
+257
src/Builders/PostBuilder.php
+257
src/Builders/PostBuilder.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Builders;
4
+
5
+
use Closure;
6
+
use DateTimeInterface;
7
+
use SocialDept\AtpClient\Builders\Concerns\BuildsRichText;
8
+
use SocialDept\AtpClient\Builders\Embeds\ImagesBuilder;
9
+
use SocialDept\AtpClient\Client\Records\PostRecordClient;
10
+
use SocialDept\AtpClient\Contracts\Recordable;
11
+
use SocialDept\AtpClient\Data\StrongRef;
12
+
use SocialDept\AtpClient\Enums\Nsid\BskyFeed;
13
+
14
+
class PostBuilder implements Recordable
15
+
{
16
+
use BuildsRichText;
17
+
18
+
protected ?array $embed = null;
19
+
20
+
protected ?array $reply = null;
21
+
22
+
protected ?array $langs = null;
23
+
24
+
protected ?DateTimeInterface $createdAt = null;
25
+
26
+
protected ?PostRecordClient $client = null;
27
+
28
+
/**
29
+
* Create a new post builder instance
30
+
*/
31
+
public static function make(): self
32
+
{
33
+
return new self;
34
+
}
35
+
36
+
/**
37
+
* Add images embed
38
+
*
39
+
* @param Closure|array $images Closure receiving ImagesBuilder, or array of image data
40
+
*/
41
+
public function images(Closure|array $images): self
42
+
{
43
+
if ($images instanceof Closure) {
44
+
$builder = ImagesBuilder::make();
45
+
$images($builder);
46
+
$this->embed = $builder->toArray();
47
+
} else {
48
+
$this->embed = [
49
+
'$type' => 'app.bsky.embed.images',
50
+
'images' => array_map(fn ($img) => $this->normalizeImageData($img), $images),
51
+
];
52
+
}
53
+
54
+
return $this;
55
+
}
56
+
57
+
/**
58
+
* Add external link embed (link card)
59
+
*
60
+
* @param string $uri URL of the external content
61
+
* @param string $title Title of the link card
62
+
* @param string $description Description text
63
+
* @param mixed|null $thumb Optional thumbnail blob
64
+
*/
65
+
public function external(string $uri, string $title, string $description, mixed $thumb = null): self
66
+
{
67
+
$external = [
68
+
'uri' => $uri,
69
+
'title' => $title,
70
+
'description' => $description,
71
+
];
72
+
73
+
if ($thumb !== null) {
74
+
$external['thumb'] = $this->normalizeBlob($thumb);
75
+
}
76
+
77
+
$this->embed = [
78
+
'$type' => 'app.bsky.embed.external',
79
+
'external' => $external,
80
+
];
81
+
82
+
return $this;
83
+
}
84
+
85
+
/**
86
+
* Add video embed
87
+
*
88
+
* @param mixed $blob Video blob reference
89
+
* @param string|null $alt Alt text for the video
90
+
* @param array|null $captions Optional captions array
91
+
*/
92
+
public function video(mixed $blob, ?string $alt = null, ?array $captions = null): self
93
+
{
94
+
$video = [
95
+
'$type' => 'app.bsky.embed.video',
96
+
'video' => $this->normalizeBlob($blob),
97
+
];
98
+
99
+
if ($alt !== null) {
100
+
$video['alt'] = $alt;
101
+
}
102
+
103
+
if ($captions !== null) {
104
+
$video['captions'] = $captions;
105
+
}
106
+
107
+
$this->embed = $video;
108
+
109
+
return $this;
110
+
}
111
+
112
+
/**
113
+
* Add quote embed (embed another post)
114
+
*/
115
+
public function quote(StrongRef $post): self
116
+
{
117
+
$this->embed = [
118
+
'$type' => 'app.bsky.embed.record',
119
+
'record' => $post->toArray(),
120
+
];
121
+
122
+
return $this;
123
+
}
124
+
125
+
/**
126
+
* Set as a reply to another post
127
+
*
128
+
* @param StrongRef $parent The post being replied to
129
+
* @param StrongRef|null $root The root post of the thread (defaults to parent if not provided)
130
+
*/
131
+
public function replyTo(StrongRef $parent, ?StrongRef $root = null): self
132
+
{
133
+
$this->reply = [
134
+
'parent' => $parent->toArray(),
135
+
'root' => ($root ?? $parent)->toArray(),
136
+
];
137
+
138
+
return $this;
139
+
}
140
+
141
+
/**
142
+
* Set the post languages
143
+
*
144
+
* @param array $langs Array of BCP-47 language codes
145
+
*/
146
+
public function langs(array $langs): self
147
+
{
148
+
$this->langs = $langs;
149
+
150
+
return $this;
151
+
}
152
+
153
+
/**
154
+
* Set the creation timestamp
155
+
*/
156
+
public function createdAt(DateTimeInterface $date): self
157
+
{
158
+
$this->createdAt = $date;
159
+
160
+
return $this;
161
+
}
162
+
163
+
/**
164
+
* Bind to a PostRecordClient for creating the post
165
+
*/
166
+
public function for(PostRecordClient $client): self
167
+
{
168
+
$this->client = $client;
169
+
170
+
return $this;
171
+
}
172
+
173
+
/**
174
+
* Create the post (requires client binding via for() or build())
175
+
*
176
+
* @throws \RuntimeException If no client is bound
177
+
*/
178
+
public function create(): StrongRef
179
+
{
180
+
if ($this->client === null) {
181
+
throw new \RuntimeException(
182
+
'No client bound. Use ->for($client) or create via $client->bsky->post->build()'
183
+
);
184
+
}
185
+
186
+
return $this->client->create($this);
187
+
}
188
+
189
+
/**
190
+
* Convert to array for XRPC (implements Recordable)
191
+
*/
192
+
public function toArray(): array
193
+
{
194
+
$record = $this->getTextAndFacets();
195
+
196
+
if ($this->embed !== null) {
197
+
$record['embed'] = $this->embed;
198
+
}
199
+
200
+
if ($this->reply !== null) {
201
+
$record['reply'] = $this->reply;
202
+
}
203
+
204
+
if ($this->langs !== null) {
205
+
$record['langs'] = $this->langs;
206
+
}
207
+
208
+
$record['createdAt'] = ($this->createdAt ?? now())->format('c');
209
+
$record['$type'] = $this->getType();
210
+
211
+
return $record;
212
+
}
213
+
214
+
/**
215
+
* Get the record type (implements Recordable)
216
+
*/
217
+
public function getType(): string
218
+
{
219
+
return BskyFeed::Post->value;
220
+
}
221
+
222
+
/**
223
+
* Normalize image data from array format
224
+
*/
225
+
protected function normalizeImageData(array $data): array
226
+
{
227
+
$image = [
228
+
'image' => $this->normalizeBlob($data['blob'] ?? $data['image']),
229
+
'alt' => $data['alt'] ?? '',
230
+
];
231
+
232
+
if (isset($data['aspectRatio'])) {
233
+
$ratio = $data['aspectRatio'];
234
+
$image['aspectRatio'] = is_array($ratio) && isset($ratio['width'])
235
+
? $ratio
236
+
: ['width' => $ratio[0], 'height' => $ratio[1]];
237
+
}
238
+
239
+
return $image;
240
+
}
241
+
242
+
/**
243
+
* Normalize blob to array format
244
+
*/
245
+
protected function normalizeBlob(mixed $blob): array
246
+
{
247
+
if (is_array($blob)) {
248
+
return $blob;
249
+
}
250
+
251
+
if (method_exists($blob, 'toArray')) {
252
+
return $blob->toArray();
253
+
}
254
+
255
+
return (array) $blob;
256
+
}
257
+
}
+18
-1
src/Client/AtprotoClient.php
+18
-1
src/Client/AtprotoClient.php
···
4
5
use SocialDept\AtpClient\AtpClient;
6
use SocialDept\AtpClient\Client\Requests\Atproto;
7
8
class AtprotoClient
9
{
10
/**
11
* The parent AtpClient instance
12
*/
13
-
public AtpClient $atp;
14
15
/**
16
* Repository operations (com.atproto.repo.*)
···
39
$this->server = new Atproto\ServerRequestClient($this);
40
$this->identity = new Atproto\IdentityRequestClient($this);
41
$this->sync = new Atproto\SyncRequestClient($this);
42
}
43
}
···
4
5
use SocialDept\AtpClient\AtpClient;
6
use SocialDept\AtpClient\Client\Requests\Atproto;
7
+
use SocialDept\AtpClient\Concerns\HasDomainExtensions;
8
9
class AtprotoClient
10
{
11
+
use HasDomainExtensions;
12
/**
13
* The parent AtpClient instance
14
*/
15
+
protected AtpClient $atp;
16
17
/**
18
* Repository operations (com.atproto.repo.*)
···
41
$this->server = new Atproto\ServerRequestClient($this);
42
$this->identity = new Atproto\IdentityRequestClient($this);
43
$this->sync = new Atproto\SyncRequestClient($this);
44
+
}
45
+
46
+
protected function getDomainName(): string
47
+
{
48
+
return 'atproto';
49
+
}
50
+
51
+
protected function getRootClientClass(): string
52
+
{
53
+
return AtpClient::class;
54
+
}
55
+
56
+
public function root(): AtpClient
57
+
{
58
+
return $this->atp;
59
}
60
}
+31
-1
src/Client/BskyClient.php
+31
-1
src/Client/BskyClient.php
···
8
use SocialDept\AtpClient\Client\Records\PostRecordClient;
9
use SocialDept\AtpClient\Client\Records\ProfileRecordClient;
10
use SocialDept\AtpClient\Client\Requests\Bsky;
11
12
class BskyClient
13
{
14
/**
15
* The parent AtpClient instance
16
*/
17
-
public AtpClient $atp;
18
19
/**
20
* Feed operations (app.bsky.feed.*)
···
27
public Bsky\ActorRequestClient $actor;
28
29
/**
30
* Post record client
31
*/
32
public PostRecordClient $post;
···
51
$this->atp = $parent;
52
$this->feed = new Bsky\FeedRequestClient($this);
53
$this->actor = new Bsky\ActorRequestClient($this);
54
$this->post = new PostRecordClient($this);
55
$this->profile = new ProfileRecordClient($this);
56
$this->like = new LikeRecordClient($this);
57
$this->follow = new FollowRecordClient($this);
58
}
59
}
···
8
use SocialDept\AtpClient\Client\Records\PostRecordClient;
9
use SocialDept\AtpClient\Client\Records\ProfileRecordClient;
10
use SocialDept\AtpClient\Client\Requests\Bsky;
11
+
use SocialDept\AtpClient\Concerns\HasDomainExtensions;
12
13
class BskyClient
14
{
15
+
use HasDomainExtensions;
16
+
17
/**
18
* The parent AtpClient instance
19
*/
20
+
protected AtpClient $atp;
21
22
/**
23
* Feed operations (app.bsky.feed.*)
···
30
public Bsky\ActorRequestClient $actor;
31
32
/**
33
+
* Graph operations (app.bsky.graph.*)
34
+
*/
35
+
public Bsky\GraphRequestClient $graph;
36
+
37
+
/**
38
+
* Labeler operations (app.bsky.labeler.*)
39
+
*/
40
+
public Bsky\LabelerRequestClient $labeler;
41
+
42
+
/**
43
* Post record client
44
*/
45
public PostRecordClient $post;
···
64
$this->atp = $parent;
65
$this->feed = new Bsky\FeedRequestClient($this);
66
$this->actor = new Bsky\ActorRequestClient($this);
67
+
$this->graph = new Bsky\GraphRequestClient($this);
68
+
$this->labeler = new Bsky\LabelerRequestClient($this);
69
$this->post = new PostRecordClient($this);
70
$this->profile = new ProfileRecordClient($this);
71
$this->like = new LikeRecordClient($this);
72
$this->follow = new FollowRecordClient($this);
73
+
}
74
+
75
+
protected function getDomainName(): string
76
+
{
77
+
return 'bsky';
78
+
}
79
+
80
+
protected function getRootClientClass(): string
81
+
{
82
+
return AtpClient::class;
83
+
}
84
+
85
+
public function root(): AtpClient
86
+
{
87
+
return $this->atp;
88
}
89
}
+18
-1
src/Client/ChatClient.php
+18
-1
src/Client/ChatClient.php
···
4
5
use SocialDept\AtpClient\AtpClient;
6
use SocialDept\AtpClient\Client\Requests\Chat;
7
8
class ChatClient
9
{
10
/**
11
* The parent AtpClient instance
12
*/
13
-
public AtpClient $atp;
14
15
/**
16
* Conversation operations (chat.bsky.convo.*)
···
27
$this->atp = $parent;
28
$this->convo = new Chat\ConvoRequestClient($this);
29
$this->actor = new Chat\ActorRequestClient($this);
30
}
31
}
···
4
5
use SocialDept\AtpClient\AtpClient;
6
use SocialDept\AtpClient\Client\Requests\Chat;
7
+
use SocialDept\AtpClient\Concerns\HasDomainExtensions;
8
9
class ChatClient
10
{
11
+
use HasDomainExtensions;
12
/**
13
* The parent AtpClient instance
14
*/
15
+
protected AtpClient $atp;
16
17
/**
18
* Conversation operations (chat.bsky.convo.*)
···
29
$this->atp = $parent;
30
$this->convo = new Chat\ConvoRequestClient($this);
31
$this->actor = new Chat\ActorRequestClient($this);
32
+
}
33
+
34
+
protected function getDomainName(): string
35
+
{
36
+
return 'chat';
37
+
}
38
+
39
+
protected function getRootClientClass(): string
40
+
{
41
+
return AtpClient::class;
42
+
}
43
+
44
+
public function root(): AtpClient
45
+
{
46
+
return $this->atp;
47
}
48
}
+105
-10
src/Client/Client.php
+105
-10
src/Client/Client.php
···
2
3
namespace SocialDept\AtpClient\Client;
4
5
-
use Illuminate\Http\Client\Factory;
6
use SocialDept\AtpClient\AtpClient;
7
-
use SocialDept\AtpClient\Auth\DPoPNonceManager;
8
use SocialDept\AtpClient\Http\HasHttp;
9
use SocialDept\AtpClient\Session\SessionManager;
10
11
class Client
12
{
13
-
use HasHttp;
14
15
/**
16
* The parent AtpClient instance we belong to
17
*/
18
-
public AtpClient $atp;
19
20
public function __construct(
21
AtpClient $parent,
22
-
SessionManager $sessions,
23
-
Factory $http,
24
-
string $identifier,
25
) {
26
$this->atp = $parent;
27
$this->sessions = $sessions;
28
-
$this->http = $http;
29
-
$this->identifier = $identifier;
30
-
$this->nonceManager = app(DPoPNonceManager::class);
31
}
32
}
···
2
3
namespace SocialDept\AtpClient\Client;
4
5
+
use BackedEnum;
6
+
use Illuminate\Support\Facades\Http;
7
use SocialDept\AtpClient\AtpClient;
8
+
use SocialDept\AtpClient\Exceptions\AtpResponseException;
9
+
use SocialDept\AtpClient\Http\DPoPClient;
10
use SocialDept\AtpClient\Http\HasHttp;
11
+
use SocialDept\AtpClient\Http\Response;
12
+
use SocialDept\AtpClient\Session\Session;
13
use SocialDept\AtpClient\Session\SessionManager;
14
15
class Client
16
{
17
+
use HasHttp {
18
+
call as authenticatedCall;
19
+
postBlob as authenticatedPostBlob;
20
+
}
21
22
/**
23
* The parent AtpClient instance we belong to
24
*/
25
+
protected AtpClient $atp;
26
+
27
+
/**
28
+
* Service URL for public mode
29
+
*/
30
+
protected ?string $serviceUrl;
31
32
public function __construct(
33
AtpClient $parent,
34
+
?SessionManager $sessions = null,
35
+
?string $did = null,
36
+
?string $serviceUrl = null,
37
) {
38
$this->atp = $parent;
39
$this->sessions = $sessions;
40
+
$this->did = $did;
41
+
$this->serviceUrl = $serviceUrl;
42
+
43
+
if (! $this->isPublicMode()) {
44
+
$this->dpopClient = app(DPoPClient::class);
45
+
}
46
+
}
47
+
48
+
/**
49
+
* Check if client is in public mode (no authentication).
50
+
*/
51
+
public function isPublicMode(): bool
52
+
{
53
+
return $this->sessions === null || $this->did === null;
54
+
}
55
+
56
+
/**
57
+
* Get the current session.
58
+
*/
59
+
public function session(): Session
60
+
{
61
+
return $this->sessions->session($this->did);
62
+
}
63
+
64
+
/**
65
+
* Get the service URL.
66
+
*/
67
+
public function serviceUrl(): string
68
+
{
69
+
return $this->serviceUrl;
70
+
}
71
+
72
+
/**
73
+
* Make XRPC call - routes to public or authenticated based on mode.
74
+
*/
75
+
protected function call(
76
+
string|BackedEnum $endpoint,
77
+
string $method,
78
+
?array $params = null,
79
+
?array $body = null
80
+
): Response {
81
+
if ($this->isPublicMode()) {
82
+
return $this->publicCall($endpoint, $method, $params, $body);
83
+
}
84
+
85
+
return $this->authenticatedCall($endpoint, $method, $params, $body);
86
+
}
87
+
88
+
/**
89
+
* Make public XRPC call (no authentication).
90
+
*/
91
+
protected function publicCall(
92
+
string|BackedEnum $endpoint,
93
+
string $method,
94
+
?array $params = null,
95
+
?array $body = null
96
+
): Response {
97
+
$endpoint = $endpoint instanceof BackedEnum ? $endpoint->value : $endpoint;
98
+
$url = rtrim($this->serviceUrl, '/') . '/xrpc/' . $endpoint;
99
+
$params = array_filter($params ?? [], fn ($v) => ! is_null($v));
100
+
101
+
$response = match ($method) {
102
+
'GET' => Http::get($url, $params),
103
+
'POST' => Http::post($url, $body ?? $params),
104
+
'DELETE' => Http::delete($url, $params),
105
+
default => throw new \InvalidArgumentException("Unsupported method: {$method}"),
106
+
};
107
+
108
+
if ($response->failed() || isset($response->json()['error'])) {
109
+
throw AtpResponseException::fromResponse($response, $endpoint);
110
+
}
111
+
112
+
return new Response($response);
113
+
}
114
+
115
+
/**
116
+
* Make POST request with raw binary body (for blob uploads).
117
+
* Only works in authenticated mode.
118
+
*/
119
+
public function postBlob(string|BackedEnum $endpoint, string $data, string $mimeType): Response
120
+
{
121
+
if ($this->isPublicMode()) {
122
+
throw new \RuntimeException('Blob uploads require authentication.');
123
+
}
124
+
125
+
return $this->authenticatedPostBlob($endpoint, $data, $mimeType);
126
}
127
}
+18
-1
src/Client/OzoneClient.php
+18
-1
src/Client/OzoneClient.php
···
4
5
use SocialDept\AtpClient\AtpClient;
6
use SocialDept\AtpClient\Client\Requests\Ozone;
7
8
class OzoneClient
9
{
10
/**
11
* The parent AtpClient instance
12
*/
13
-
public AtpClient $atp;
14
15
/**
16
* Moderation operations (tools.ozone.moderation.*)
···
33
$this->moderation = new Ozone\ModerationRequestClient($this);
34
$this->server = new Ozone\ServerRequestClient($this);
35
$this->team = new Ozone\TeamRequestClient($this);
36
}
37
}
···
4
5
use SocialDept\AtpClient\AtpClient;
6
use SocialDept\AtpClient\Client\Requests\Ozone;
7
+
use SocialDept\AtpClient\Concerns\HasDomainExtensions;
8
9
class OzoneClient
10
{
11
+
use HasDomainExtensions;
12
/**
13
* The parent AtpClient instance
14
*/
15
+
protected AtpClient $atp;
16
17
/**
18
* Moderation operations (tools.ozone.moderation.*)
···
35
$this->moderation = new Ozone\ModerationRequestClient($this);
36
$this->server = new Ozone\ServerRequestClient($this);
37
$this->team = new Ozone\TeamRequestClient($this);
38
+
}
39
+
40
+
protected function getDomainName(): string
41
+
{
42
+
return 'ozone';
43
+
}
44
+
45
+
protected function getRootClientClass(): string
46
+
{
47
+
return AtpClient::class;
48
+
}
49
+
50
+
public function root(): AtpClient
51
+
{
52
+
return $this->atp;
53
}
54
}
+33
-30
src/Client/Records/FollowRecordClient.php
+33
-30
src/Client/Records/FollowRecordClient.php
···
3
namespace SocialDept\AtpClient\Client\Records;
4
5
use DateTimeInterface;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
-
use SocialDept\AtpClient\Data\StrongRef;
8
9
class FollowRecordClient extends Request
10
{
11
/**
12
* Follow a user
13
*/
14
public function create(
15
string $subject,
16
?DateTimeInterface $createdAt = null
17
-
): StrongRef {
18
$record = [
19
-
'$type' => 'app.bsky.graph.follow',
20
'subject' => $subject, // DID
21
'createdAt' => ($createdAt ?? now())->format('c'),
22
];
23
24
-
$response = $this->atp->client->post(
25
-
endpoint: 'com.atproto.repo.createRecord',
26
-
body: [
27
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
28
-
'collection' => 'app.bsky.graph.follow',
29
-
'record' => $record,
30
-
]
31
);
32
-
33
-
return StrongRef::fromResponse($response->json());
34
}
35
36
/**
37
* Unfollow a user (delete follow record)
38
*/
39
-
public function delete(string $rkey): void
40
{
41
-
$this->atp->client->post(
42
-
endpoint: 'com.atproto.repo.deleteRecord',
43
-
body: [
44
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
45
-
'collection' => 'app.bsky.graph.follow',
46
-
'rkey' => $rkey,
47
-
]
48
);
49
}
50
51
/**
52
* Get a follow record
53
*/
54
-
public function get(string $rkey, ?string $cid = null): array
55
{
56
-
$response = $this->atp->client->get(
57
-
endpoint: 'com.atproto.repo.getRecord',
58
-
params: array_filter([
59
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
60
-
'collection' => 'app.bsky.graph.follow',
61
-
'rkey' => $rkey,
62
-
'cid' => $cid,
63
-
])
64
);
65
66
-
return $response->json('value');
67
}
68
}
···
3
namespace SocialDept\AtpClient\Client\Records;
4
5
use DateTimeInterface;
6
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
7
use SocialDept\AtpClient\Client\Requests\Request;
8
+
use SocialDept\AtpClient\Data\Record;
9
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse;
10
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse;
11
+
use SocialDept\AtpClient\Enums\Nsid\BskyGraph;
12
+
use SocialDept\AtpClient\Enums\Scope;
13
14
class FollowRecordClient extends Request
15
{
16
/**
17
* Follow a user
18
+
*
19
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.graph.follow?action=create)
20
*/
21
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
22
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.graph.follow?action=create')]
23
public function create(
24
string $subject,
25
?DateTimeInterface $createdAt = null
26
+
): CreateRecordResponse {
27
$record = [
28
+
'$type' => BskyGraph::Follow->value,
29
'subject' => $subject, // DID
30
'createdAt' => ($createdAt ?? now())->format('c'),
31
];
32
33
+
return $this->atp->atproto->repo->createRecord(
34
+
collection: BskyGraph::Follow,
35
+
record: $record
36
);
37
}
38
39
/**
40
* Unfollow a user (delete follow record)
41
+
*
42
+
* @requires transition:generic OR (rpc:com.atproto.repo.deleteRecord AND repo:app.bsky.graph.follow?action=delete)
43
*/
44
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')]
45
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.graph.follow?action=delete')]
46
+
public function delete(string $rkey): DeleteRecordResponse
47
{
48
+
return $this->atp->atproto->repo->deleteRecord(
49
+
collection: BskyGraph::Follow,
50
+
rkey: $rkey
51
);
52
}
53
54
/**
55
* Get a follow record
56
+
*
57
+
* @requires transition:generic (rpc:com.atproto.repo.getRecord)
58
*/
59
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')]
60
+
public function get(string $rkey, ?string $cid = null): Record
61
{
62
+
$response = $this->atp->atproto->repo->getRecord(
63
+
repo: $this->atp->client->session()->did(),
64
+
collection: BskyGraph::Follow,
65
+
rkey: $rkey,
66
+
cid: $cid
67
);
68
69
+
return Record::fromArrayRaw($response->toArray());
70
}
71
}
+33
-29
src/Client/Records/LikeRecordClient.php
+33
-29
src/Client/Records/LikeRecordClient.php
···
3
namespace SocialDept\AtpClient\Client\Records;
4
5
use DateTimeInterface;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
use SocialDept\AtpClient\Data\StrongRef;
8
9
class LikeRecordClient extends Request
10
{
11
/**
12
* Like a post
13
*/
14
public function create(
15
StrongRef $subject,
16
?DateTimeInterface $createdAt = null
17
-
): StrongRef {
18
$record = [
19
-
'$type' => 'app.bsky.feed.like',
20
'subject' => $subject->toArray(),
21
'createdAt' => ($createdAt ?? now())->format('c'),
22
];
23
24
-
$response = $this->atp->client->post(
25
-
endpoint: 'com.atproto.repo.createRecord',
26
-
body: [
27
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
28
-
'collection' => 'app.bsky.feed.like',
29
-
'record' => $record,
30
-
]
31
);
32
-
33
-
return StrongRef::fromResponse($response->json());
34
}
35
36
/**
37
* Unlike a post (delete like record)
38
*/
39
-
public function delete(string $rkey): void
40
{
41
-
$this->atp->client->post(
42
-
endpoint: 'com.atproto.repo.deleteRecord',
43
-
body: [
44
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
45
-
'collection' => 'app.bsky.feed.like',
46
-
'rkey' => $rkey,
47
-
]
48
);
49
}
50
51
/**
52
* Get a like record
53
*/
54
-
public function get(string $rkey, ?string $cid = null): array
55
{
56
-
$response = $this->atp->client->get(
57
-
endpoint: 'com.atproto.repo.getRecord',
58
-
params: array_filter([
59
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
60
-
'collection' => 'app.bsky.feed.like',
61
-
'rkey' => $rkey,
62
-
'cid' => $cid,
63
-
])
64
);
65
66
-
return $response->json('value');
67
}
68
}
···
3
namespace SocialDept\AtpClient\Client\Records;
4
5
use DateTimeInterface;
6
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
7
use SocialDept\AtpClient\Client\Requests\Request;
8
+
use SocialDept\AtpClient\Data\Record;
9
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse;
10
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse;
11
use SocialDept\AtpClient\Data\StrongRef;
12
+
use SocialDept\AtpClient\Enums\Nsid\BskyFeed;
13
+
use SocialDept\AtpClient\Enums\Scope;
14
15
class LikeRecordClient extends Request
16
{
17
/**
18
* Like a post
19
+
*
20
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.like?action=create)
21
*/
22
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
23
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.like?action=create')]
24
public function create(
25
StrongRef $subject,
26
?DateTimeInterface $createdAt = null
27
+
): CreateRecordResponse {
28
$record = [
29
+
'$type' => BskyFeed::Like->value,
30
'subject' => $subject->toArray(),
31
'createdAt' => ($createdAt ?? now())->format('c'),
32
];
33
34
+
return $this->atp->atproto->repo->createRecord(
35
+
collection: BskyFeed::Like,
36
+
record: $record
37
);
38
}
39
40
/**
41
* Unlike a post (delete like record)
42
+
*
43
+
* @requires transition:generic OR (rpc:com.atproto.repo.deleteRecord AND repo:app.bsky.feed.like?action=delete)
44
*/
45
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')]
46
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.like?action=delete')]
47
+
public function delete(string $rkey): DeleteRecordResponse
48
{
49
+
return $this->atp->atproto->repo->deleteRecord(
50
+
collection: BskyFeed::Like,
51
+
rkey: $rkey
52
);
53
}
54
55
/**
56
* Get a like record
57
+
*
58
+
* @requires transition:generic (rpc:com.atproto.repo.getRecord)
59
*/
60
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')]
61
+
public function get(string $rkey, ?string $cid = null): Record
62
{
63
+
$response = $this->atp->atproto->repo->getRecord(
64
+
repo: $this->atp->client->session()->did(),
65
+
collection: BskyFeed::Like,
66
+
rkey: $rkey,
67
+
cid: $cid
68
);
69
70
+
return Record::fromArrayRaw($response->toArray());
71
}
72
}
+66
-163
src/Client/Records/PostRecordClient.php
+66
-163
src/Client/Records/PostRecordClient.php
···
3
namespace SocialDept\AtpClient\Client\Records;
4
5
use DateTimeInterface;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
use SocialDept\AtpClient\Contracts\Recordable;
8
use SocialDept\AtpClient\Data\StrongRef;
9
-
use SocialDept\AtpClient\Http\Response;
10
use SocialDept\AtpClient\RichText\TextBuilder;
11
12
class PostRecordClient extends Request
13
{
14
/**
15
* Create a post
16
*/
17
public function create(
18
string|array|Recordable $content,
19
?array $facets = null,
···
21
?array $reply = null,
22
?array $langs = null,
23
?DateTimeInterface $createdAt = null
24
-
): StrongRef {
25
// Handle different input types
26
if (is_string($content)) {
27
$record = [
···
34
$record = $content;
35
}
36
37
-
// Add optional fields
38
-
if ($embed) {
39
-
$record['embed'] = $embed;
40
-
}
41
-
if ($reply) {
42
-
$record['reply'] = $reply;
43
}
44
-
if ($langs) {
45
-
$record['langs'] = $langs;
46
-
}
47
if (! isset($record['createdAt'])) {
48
$record['createdAt'] = ($createdAt ?? now())->format('c');
49
}
50
51
// Ensure $type is set
52
if (! isset($record['$type'])) {
53
-
$record['$type'] = 'app.bsky.feed.post';
54
}
55
56
-
// Create record via XRPC
57
-
$response = $this->atp->client->post(
58
-
endpoint: 'com.atproto.repo.createRecord',
59
-
body: [
60
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
61
-
'collection' => 'app.bsky.feed.post',
62
-
'record' => $record,
63
-
]
64
);
65
-
66
-
return StrongRef::fromResponse($response->json());
67
}
68
69
/**
70
* Update a post
71
*/
72
-
public function update(string $rkey, array $record): StrongRef
73
{
74
// Ensure $type is set
75
if (! isset($record['$type'])) {
76
-
$record['$type'] = 'app.bsky.feed.post';
77
}
78
79
-
$response = $this->atp->client->post(
80
-
endpoint: 'com.atproto.repo.putRecord',
81
-
body: [
82
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
83
-
'collection' => 'app.bsky.feed.post',
84
-
'rkey' => $rkey,
85
-
'record' => $record,
86
-
]
87
);
88
-
89
-
return StrongRef::fromResponse($response->json());
90
}
91
92
/**
93
* Delete a post
94
*/
95
-
public function delete(string $rkey): void
96
{
97
-
$this->atp->client->post(
98
-
endpoint: 'com.atproto.repo.deleteRecord',
99
-
body: [
100
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
101
-
'collection' => 'app.bsky.feed.post',
102
-
'rkey' => $rkey,
103
-
]
104
);
105
}
106
107
/**
108
* Get a post
109
*/
110
-
public function get(string $rkey, ?string $cid = null): array
111
{
112
-
$response = $this->atp->client->get(
113
-
endpoint: 'com.atproto.repo.getRecord',
114
-
params: array_filter([
115
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
116
-
'collection' => 'app.bsky.feed.post',
117
-
'rkey' => $rkey,
118
-
'cid' => $cid,
119
-
])
120
);
121
122
-
return $response->json('value');
123
-
}
124
-
125
-
/**
126
-
* Create a reply to another post
127
-
*/
128
-
public function reply(
129
-
StrongRef $parent,
130
-
StrongRef $root,
131
-
string|array|Recordable $content,
132
-
?array $facets = null,
133
-
?array $embed = null,
134
-
?array $langs = null,
135
-
?DateTimeInterface $createdAt = null
136
-
): StrongRef {
137
-
$reply = [
138
-
'parent' => $parent->toArray(),
139
-
'root' => $root->toArray(),
140
-
];
141
-
142
-
return $this->create(
143
-
content: $content,
144
-
facets: $facets,
145
-
embed: $embed,
146
-
reply: $reply,
147
-
langs: $langs,
148
-
createdAt: $createdAt
149
-
);
150
-
}
151
-
152
-
/**
153
-
* Create a quote post (post with embedded post)
154
-
*/
155
-
public function quote(
156
-
StrongRef $quotedPost,
157
-
string|array|Recordable $content,
158
-
?array $facets = null,
159
-
?array $langs = null,
160
-
?DateTimeInterface $createdAt = null
161
-
): StrongRef {
162
-
$embed = [
163
-
'$type' => 'app.bsky.embed.record',
164
-
'record' => $quotedPost->toArray(),
165
-
];
166
-
167
-
return $this->create(
168
-
content: $content,
169
-
facets: $facets,
170
-
embed: $embed,
171
-
langs: $langs,
172
-
createdAt: $createdAt
173
-
);
174
-
}
175
-
176
-
/**
177
-
* Create a post with images
178
-
*/
179
-
public function withImages(
180
-
string|array|Recordable $content,
181
-
array $images,
182
-
?array $facets = null,
183
-
?array $langs = null,
184
-
?DateTimeInterface $createdAt = null
185
-
): StrongRef {
186
-
$embed = [
187
-
'$type' => 'app.bsky.embed.images',
188
-
'images' => $images,
189
-
];
190
-
191
-
return $this->create(
192
-
content: $content,
193
-
facets: $facets,
194
-
embed: $embed,
195
-
langs: $langs,
196
-
createdAt: $createdAt
197
-
);
198
}
199
200
-
/**
201
-
* Create a post with external link embed
202
-
*/
203
-
public function withLink(
204
-
string|array|Recordable $content,
205
-
string $uri,
206
-
string $title,
207
-
string $description,
208
-
?string $thumbBlob = null,
209
-
?array $facets = null,
210
-
?array $langs = null,
211
-
?DateTimeInterface $createdAt = null
212
-
): StrongRef {
213
-
$external = [
214
-
'uri' => $uri,
215
-
'title' => $title,
216
-
'description' => $description,
217
-
];
218
-
219
-
if ($thumbBlob) {
220
-
$external['thumb'] = $thumbBlob;
221
-
}
222
-
223
-
$embed = [
224
-
'$type' => 'app.bsky.embed.external',
225
-
'external' => $external,
226
-
];
227
-
228
-
return $this->create(
229
-
content: $content,
230
-
facets: $facets,
231
-
embed: $embed,
232
-
langs: $langs,
233
-
createdAt: $createdAt
234
-
);
235
-
}
236
}
···
3
namespace SocialDept\AtpClient\Client\Records;
4
5
use DateTimeInterface;
6
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
7
+
use SocialDept\AtpClient\Builders\PostBuilder;
8
use SocialDept\AtpClient\Client\Requests\Request;
9
use SocialDept\AtpClient\Contracts\Recordable;
10
+
use SocialDept\AtpClient\Data\Record;
11
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse;
12
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse;
13
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\PutRecordResponse;
14
use SocialDept\AtpClient\Data\StrongRef;
15
+
use SocialDept\AtpClient\Enums\Nsid\BskyFeed;
16
+
use SocialDept\AtpClient\Enums\Scope;
17
use SocialDept\AtpClient\RichText\TextBuilder;
18
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\PostView;
19
20
class PostRecordClient extends Request
21
{
22
/**
23
+
* Create a new post builder bound to this client
24
+
*/
25
+
public function build(): PostBuilder
26
+
{
27
+
return PostBuilder::make()->for($this);
28
+
}
29
+
30
+
/**
31
* Create a post
32
+
*
33
+
* @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create)
34
*/
35
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')]
36
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')]
37
public function create(
38
string|array|Recordable $content,
39
?array $facets = null,
···
41
?array $reply = null,
42
?array $langs = null,
43
?DateTimeInterface $createdAt = null
44
+
): CreateRecordResponse {
45
// Handle different input types
46
if (is_string($content)) {
47
$record = [
···
54
$record = $content;
55
}
56
57
+
// Add optional fields (only for non-Recordable inputs)
58
+
if (! ($content instanceof Recordable)) {
59
+
if ($embed) {
60
+
$record['embed'] = $embed;
61
+
}
62
+
if ($reply) {
63
+
$record['reply'] = $reply;
64
+
}
65
+
if ($langs) {
66
+
$record['langs'] = $langs;
67
+
}
68
}
69
+
70
if (! isset($record['createdAt'])) {
71
$record['createdAt'] = ($createdAt ?? now())->format('c');
72
}
73
74
// Ensure $type is set
75
if (! isset($record['$type'])) {
76
+
$record['$type'] = BskyFeed::Post->value;
77
}
78
79
+
return $this->atp->atproto->repo->createRecord(
80
+
collection: BskyFeed::Post,
81
+
record: $record
82
);
83
}
84
85
/**
86
* Update a post
87
+
*
88
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.feed.post?action=update)
89
*/
90
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
91
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=update')]
92
+
public function update(string $rkey, array $record): PutRecordResponse
93
{
94
// Ensure $type is set
95
if (! isset($record['$type'])) {
96
+
$record['$type'] = BskyFeed::Post->value;
97
}
98
99
+
return $this->atp->atproto->repo->putRecord(
100
+
collection: BskyFeed::Post,
101
+
rkey: $rkey,
102
+
record: $record
103
);
104
}
105
106
/**
107
* Delete a post
108
+
*
109
+
* @requires transition:generic OR (rpc:com.atproto.repo.deleteRecord AND repo:app.bsky.feed.post?action=delete)
110
*/
111
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')]
112
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=delete')]
113
+
public function delete(string $rkey): DeleteRecordResponse
114
{
115
+
return $this->atp->atproto->repo->deleteRecord(
116
+
collection: BskyFeed::Post,
117
+
rkey: $rkey
118
);
119
}
120
121
/**
122
* Get a post
123
+
*
124
+
* @requires transition:generic (rpc:com.atproto.repo.getRecord)
125
*/
126
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')]
127
+
public function get(string $rkey, ?string $cid = null): Record
128
{
129
+
$response = $this->atp->atproto->repo->getRecord(
130
+
repo: $this->atp->client->session()->did(),
131
+
collection: BskyFeed::Post,
132
+
rkey: $rkey,
133
+
cid: $cid
134
);
135
136
+
return Record::fromArrayRaw($response->toArray());
137
}
138
139
}
+46
-28
src/Client/Records/ProfileRecordClient.php
+46
-28
src/Client/Records/ProfileRecordClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Records;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
-
use SocialDept\AtpClient\Data\StrongRef;
7
8
class ProfileRecordClient extends Request
9
{
10
/**
11
* Update profile
12
*/
13
-
public function update(array $profile): StrongRef
14
{
15
// Ensure $type is set
16
if (! isset($profile['$type'])) {
17
-
$profile['$type'] = 'app.bsky.actor.profile';
18
}
19
20
-
$response = $this->atp->client->post(
21
-
endpoint: 'com.atproto.repo.putRecord',
22
-
body: [
23
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
24
-
'collection' => 'app.bsky.actor.profile',
25
-
'rkey' => 'self', // Profile records always use 'self' as rkey
26
-
'record' => $profile,
27
-
]
28
);
29
-
30
-
return StrongRef::fromResponse($response->json());
31
}
32
33
/**
34
* Get current profile
35
*/
36
-
public function get(): array
37
{
38
-
$response = $this->atp->client->get(
39
-
endpoint: 'com.atproto.repo.getRecord',
40
-
params: [
41
-
'repo' => $this->atp->client->sessions->session($this->atp->client->identifier)->did(),
42
-
'collection' => 'app.bsky.actor.profile',
43
-
'rkey' => 'self',
44
-
]
45
);
46
47
-
return $response->json('value');
48
}
49
50
/**
51
* Update display name
52
*/
53
-
public function updateDisplayName(string $displayName): StrongRef
54
{
55
$profile = $this->getOrCreateProfile();
56
$profile['displayName'] = $displayName;
···
60
61
/**
62
* Update description/bio
63
*/
64
-
public function updateDescription(string $description): StrongRef
65
{
66
$profile = $this->getOrCreateProfile();
67
$profile['description'] = $description;
···
71
72
/**
73
* Update avatar
74
*/
75
-
public function updateAvatar(array $avatarBlob): StrongRef
76
{
77
$profile = $this->getOrCreateProfile();
78
$profile['avatar'] = $avatarBlob;
···
82
83
/**
84
* Update banner
85
*/
86
-
public function updateBanner(array $bannerBlob): StrongRef
87
{
88
$profile = $this->getOrCreateProfile();
89
$profile['banner'] = $bannerBlob;
···
97
protected function getOrCreateProfile(): array
98
{
99
try {
100
-
return $this->get();
101
} catch (\Exception $e) {
102
// Profile doesn't exist, return empty structure
103
return [
104
-
'$type' => 'app.bsky.actor.profile',
105
];
106
}
107
}
···
2
3
namespace SocialDept\AtpClient\Client\Records;
4
5
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Data\Record;
8
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\PutRecordResponse;
9
+
use SocialDept\AtpClient\Enums\Nsid\BskyActor;
10
+
use SocialDept\AtpClient\Enums\Scope;
11
12
class ProfileRecordClient extends Request
13
{
14
/**
15
* Update profile
16
+
*
17
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
18
*/
19
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
20
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
21
+
public function update(array $profile): PutRecordResponse
22
{
23
// Ensure $type is set
24
if (! isset($profile['$type'])) {
25
+
$profile['$type'] = BskyActor::Profile->value;
26
}
27
28
+
return $this->atp->atproto->repo->putRecord(
29
+
collection: BskyActor::Profile,
30
+
rkey: 'self', // Profile records always use 'self' as rkey
31
+
record: $profile
32
);
33
}
34
35
/**
36
* Get current profile
37
+
*
38
+
* @requires transition:generic (rpc:com.atproto.repo.getRecord)
39
*/
40
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')]
41
+
public function get(): Record
42
{
43
+
$response = $this->atp->atproto->repo->getRecord(
44
+
repo: $this->atp->client->session()->did(),
45
+
collection: BskyActor::Profile,
46
+
rkey: 'self'
47
);
48
49
+
return Record::fromArrayRaw($response->toArray());
50
}
51
52
/**
53
* Update display name
54
+
*
55
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
56
*/
57
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
58
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
59
+
public function updateDisplayName(string $displayName): PutRecordResponse
60
{
61
$profile = $this->getOrCreateProfile();
62
$profile['displayName'] = $displayName;
···
66
67
/**
68
* Update description/bio
69
+
*
70
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
71
*/
72
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
73
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
74
+
public function updateDescription(string $description): PutRecordResponse
75
{
76
$profile = $this->getOrCreateProfile();
77
$profile['description'] = $description;
···
81
82
/**
83
* Update avatar
84
+
*
85
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
86
*/
87
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
88
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
89
+
public function updateAvatar(array $avatarBlob): PutRecordResponse
90
{
91
$profile = $this->getOrCreateProfile();
92
$profile['avatar'] = $avatarBlob;
···
96
97
/**
98
* Update banner
99
+
*
100
+
* @requires transition:generic OR (rpc:com.atproto.repo.putRecord AND repo:app.bsky.actor.profile?action=update)
101
*/
102
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')]
103
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')]
104
+
public function updateBanner(array $bannerBlob): PutRecordResponse
105
{
106
$profile = $this->getOrCreateProfile();
107
$profile['banner'] = $bannerBlob;
···
115
protected function getOrCreateProfile(): array
116
{
117
try {
118
+
return $this->get()->value;
119
} catch (\Exception $e) {
120
// Profile doesn't exist, return empty structure
121
return [
122
+
'$type' => BskyActor::Profile->value,
123
];
124
}
125
}
+20
-7
src/Client/Requests/Atproto/IdentityRequestClient.php
+20
-7
src/Client/Requests/Atproto/IdentityRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
-
use SocialDept\AtpClient\Http\Response;
7
8
class IdentityRequestClient extends Request
9
{
···
12
*
13
* @see https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle
14
*/
15
-
public function resolveHandle(string $handle): Response
16
{
17
-
return $this->atp->client->get(
18
-
endpoint: 'com.atproto.identity.resolveHandle',
19
params: compact('handle')
20
);
21
}
22
23
/**
24
* Update handle
25
*
26
* @see https://docs.bsky.app/docs/api/com-atproto-identity-update-handle
27
*/
28
-
public function updateHandle(string $handle): Response
29
{
30
-
return $this->atp->client->post(
31
-
endpoint: 'com.atproto.identity.updateHandle',
32
body: compact('handle')
33
);
34
}
35
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
5
+
use SocialDept\AtpClient\Attributes\PublicEndpoint;
6
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
7
use SocialDept\AtpClient\Client\Requests\Request;
8
+
use SocialDept\AtpClient\Data\Responses\Atproto\Identity\ResolveHandleResponse;
9
+
use SocialDept\AtpClient\Data\Responses\EmptyResponse;
10
+
use SocialDept\AtpClient\Enums\Nsid\AtprotoIdentity;
11
+
use SocialDept\AtpClient\Enums\Scope;
12
13
class IdentityRequestClient extends Request
14
{
···
17
*
18
* @see https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle
19
*/
20
+
#[PublicEndpoint]
21
+
public function resolveHandle(string $handle): ResolveHandleResponse
22
{
23
+
$response = $this->atp->client->get(
24
+
endpoint: AtprotoIdentity::ResolveHandle,
25
params: compact('handle')
26
);
27
+
28
+
return ResolveHandleResponse::fromArray($response->json());
29
}
30
31
/**
32
* Update handle
33
+
*
34
+
* @requires atproto (identity:handle)
35
*
36
* @see https://docs.bsky.app/docs/api/com-atproto-identity-update-handle
37
*/
38
+
#[ScopedEndpoint(Scope::Atproto, granular: 'identity:handle')]
39
+
public function updateHandle(string $handle): EmptyResponse
40
{
41
+
$this->atp->client->post(
42
+
endpoint: AtprotoIdentity::UpdateHandle,
43
body: compact('handle')
44
);
45
+
46
+
return new EmptyResponse;
47
}
48
}
+102
-30
src/Client/Requests/Atproto/RepoRequestClient.php
+102
-30
src/Client/Requests/Atproto/RepoRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
5
use Illuminate\Http\UploadedFile;
6
use InvalidArgumentException;
7
use SocialDept\AtpClient\Client\Requests\Request;
8
-
use SocialDept\AtpClient\Http\Response;
9
use SplFileInfo;
10
use Throwable;
11
···
14
/**
15
* Create a record
16
*
17
* @see https://docs.bsky.app/docs/api/com-atproto-repo-create-record
18
*/
19
public function createRecord(
20
-
string $repo,
21
-
string $collection,
22
array $record,
23
?string $rkey = null,
24
bool $validate = true,
25
?string $swapCommit = null
26
-
): Response {
27
-
return $this->atp->client->post(
28
-
endpoint: 'com.atproto.repo.createRecord',
29
body: array_filter(
30
compact('repo', 'collection', 'record', 'rkey', 'validate', 'swapCommit'),
31
fn ($v) => ! is_null($v)
32
)
33
);
34
}
35
36
/**
37
* Delete a record
38
*
39
* @see https://docs.bsky.app/docs/api/com-atproto-repo-delete-record
40
*/
41
public function deleteRecord(
42
-
string $repo,
43
-
string $collection,
44
string $rkey,
45
?string $swapRecord = null,
46
?string $swapCommit = null
47
-
): Response {
48
-
return $this->atp->client->post(
49
-
endpoint: 'com.atproto.repo.deleteRecord',
50
body: array_filter(
51
compact('repo', 'collection', 'rkey', 'swapRecord', 'swapCommit'),
52
fn ($v) => ! is_null($v)
53
)
54
);
55
}
56
57
/**
58
* Put (upsert) a record
59
*
60
* @see https://docs.bsky.app/docs/api/com-atproto-repo-put-record
61
*/
62
public function putRecord(
63
-
string $repo,
64
-
string $collection,
65
string $rkey,
66
array $record,
67
bool $validate = true,
68
?string $swapRecord = null,
69
?string $swapCommit = null
70
-
): Response {
71
-
return $this->atp->client->post(
72
-
endpoint: 'com.atproto.repo.putRecord',
73
body: array_filter(
74
compact('repo', 'collection', 'rkey', 'record', 'validate', 'swapRecord', 'swapCommit'),
75
fn ($v) => ! is_null($v)
76
)
77
);
78
}
79
80
/**
···
82
*
83
* @see https://docs.bsky.app/docs/api/com-atproto-repo-get-record
84
*/
85
public function getRecord(
86
string $repo,
87
-
string $collection,
88
string $rkey,
89
?string $cid = null
90
-
): Response {
91
-
return $this->atp->client->get(
92
-
endpoint: 'com.atproto.repo.getRecord',
93
params: compact('repo', 'collection', 'rkey', 'cid')
94
);
95
}
96
97
/**
···
99
*
100
* @see https://docs.bsky.app/docs/api/com-atproto-repo-list-records
101
*/
102
public function listRecords(
103
string $repo,
104
-
string $collection,
105
int $limit = 50,
106
?string $cursor = null,
107
bool $reverse = false
108
-
): Response {
109
-
return $this->atp->client->get(
110
-
endpoint: 'com.atproto.repo.listRecords',
111
params: compact('repo', 'collection', 'limit', 'cursor', 'reverse')
112
);
113
}
114
115
/**
···
117
*
118
* The blob will be deleted if it is not referenced within a time window.
119
*
120
* @param UploadedFile|SplFileInfo|string $file The file to upload
121
* @param string|null $mimeType MIME type (required for string input, auto-detected for file objects)
122
*
···
124
*
125
* @see https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
126
*/
127
-
public function uploadBlob(UploadedFile|SplFileInfo|string $file, ?string $mimeType = null): Response
128
{
129
// Handle different input types
130
if ($file instanceof UploadedFile) {
···
138
$data = $file;
139
}
140
141
-
return $this->atp->client->postBlob(
142
-
endpoint: 'com.atproto.repo.uploadBlob',
143
data: $data,
144
mimeType: $mimeType
145
);
146
}
147
148
/**
···
150
*
151
* @see https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo
152
*/
153
-
public function describeRepo(string $repo): Response
154
{
155
-
return $this->atp->client->get(
156
-
endpoint: 'com.atproto.repo.describeRepo',
157
params: compact('repo')
158
);
159
}
160
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
5
+
use BackedEnum;
6
use Illuminate\Http\UploadedFile;
7
use InvalidArgumentException;
8
+
use SocialDept\AtpClient\Attributes\PublicEndpoint;
9
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
10
+
use SocialDept\AtpClient\Auth\ScopeChecker;
11
use SocialDept\AtpClient\Client\Requests\Request;
12
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse;
13
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse;
14
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DescribeRepoResponse;
15
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\GetRecordResponse;
16
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\ListRecordsResponse;
17
+
use SocialDept\AtpClient\Data\Responses\Atproto\Repo\PutRecordResponse;
18
+
use SocialDept\AtpClient\Enums\Nsid\AtprotoRepo;
19
+
use SocialDept\AtpClient\Enums\Scope;
20
+
use SocialDept\AtpSchema\Data\BlobReference;
21
use SplFileInfo;
22
use Throwable;
23
···
26
/**
27
* Create a record
28
*
29
+
* @requires transition:generic OR repo:[collection]?action=create
30
+
*
31
* @see https://docs.bsky.app/docs/api/com-atproto-repo-create-record
32
*/
33
+
#[ScopedEndpoint(Scope::TransitionGeneric, description: 'Create records in repository')]
34
public function createRecord(
35
+
string|BackedEnum $collection,
36
array $record,
37
?string $rkey = null,
38
bool $validate = true,
39
?string $swapCommit = null
40
+
): CreateRecordResponse {
41
+
$repo = $this->atp->client->session()->did();
42
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
43
+
$this->checkCollectionScope($collection, 'create');
44
+
45
+
$response = $this->atp->client->post(
46
+
endpoint: AtprotoRepo::CreateRecord,
47
body: array_filter(
48
compact('repo', 'collection', 'record', 'rkey', 'validate', 'swapCommit'),
49
fn ($v) => ! is_null($v)
50
)
51
);
52
+
53
+
return CreateRecordResponse::fromArray($response->json());
54
}
55
56
/**
57
* Delete a record
58
*
59
+
* @requires transition:generic OR repo:[collection]?action=delete
60
+
*
61
* @see https://docs.bsky.app/docs/api/com-atproto-repo-delete-record
62
*/
63
+
#[ScopedEndpoint(Scope::TransitionGeneric, description: 'Delete records from repository')]
64
public function deleteRecord(
65
+
string|BackedEnum $collection,
66
string $rkey,
67
?string $swapRecord = null,
68
?string $swapCommit = null
69
+
): DeleteRecordResponse {
70
+
$repo = $this->atp->client->session()->did();
71
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
72
+
$this->checkCollectionScope($collection, 'delete');
73
+
74
+
$response = $this->atp->client->post(
75
+
endpoint: AtprotoRepo::DeleteRecord,
76
body: array_filter(
77
compact('repo', 'collection', 'rkey', 'swapRecord', 'swapCommit'),
78
fn ($v) => ! is_null($v)
79
)
80
);
81
+
82
+
return DeleteRecordResponse::fromArray($response->json());
83
}
84
85
/**
86
* Put (upsert) a record
87
*
88
+
* @requires transition:generic OR repo:[collection]?action=update
89
+
*
90
* @see https://docs.bsky.app/docs/api/com-atproto-repo-put-record
91
*/
92
+
#[ScopedEndpoint(Scope::TransitionGeneric, description: 'Update records in repository')]
93
public function putRecord(
94
+
string|BackedEnum $collection,
95
string $rkey,
96
array $record,
97
bool $validate = true,
98
?string $swapRecord = null,
99
?string $swapCommit = null
100
+
): PutRecordResponse {
101
+
$repo = $this->atp->client->session()->did();
102
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
103
+
$this->checkCollectionScope($collection, 'update');
104
+
105
+
$response = $this->atp->client->post(
106
+
endpoint: AtprotoRepo::PutRecord,
107
body: array_filter(
108
compact('repo', 'collection', 'rkey', 'record', 'validate', 'swapRecord', 'swapCommit'),
109
fn ($v) => ! is_null($v)
110
)
111
);
112
+
113
+
return PutRecordResponse::fromArray($response->json());
114
}
115
116
/**
···
118
*
119
* @see https://docs.bsky.app/docs/api/com-atproto-repo-get-record
120
*/
121
+
#[PublicEndpoint]
122
public function getRecord(
123
string $repo,
124
+
string|BackedEnum $collection,
125
string $rkey,
126
?string $cid = null
127
+
): GetRecordResponse {
128
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
129
+
$response = $this->atp->client->get(
130
+
endpoint: AtprotoRepo::GetRecord,
131
params: compact('repo', 'collection', 'rkey', 'cid')
132
);
133
+
134
+
return GetRecordResponse::fromArray($response->json());
135
}
136
137
/**
···
139
*
140
* @see https://docs.bsky.app/docs/api/com-atproto-repo-list-records
141
*/
142
+
#[PublicEndpoint]
143
public function listRecords(
144
string $repo,
145
+
string|BackedEnum $collection,
146
int $limit = 50,
147
?string $cursor = null,
148
bool $reverse = false
149
+
): ListRecordsResponse {
150
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
151
+
$response = $this->atp->client->get(
152
+
endpoint: AtprotoRepo::ListRecords,
153
params: compact('repo', 'collection', 'limit', 'cursor', 'reverse')
154
);
155
+
156
+
return ListRecordsResponse::fromArray($response->json());
157
}
158
159
/**
···
161
*
162
* The blob will be deleted if it is not referenced within a time window.
163
*
164
+
* @requires transition:generic (blob:*\/*\)
165
+
*
166
* @param UploadedFile|SplFileInfo|string $file The file to upload
167
* @param string|null $mimeType MIME type (required for string input, auto-detected for file objects)
168
*
···
170
*
171
* @see https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob
172
*/
173
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'blob:*/*')]
174
+
public function uploadBlob(UploadedFile|SplFileInfo|string $file, ?string $mimeType = null): BlobReference
175
{
176
// Handle different input types
177
if ($file instanceof UploadedFile) {
···
185
$data = $file;
186
}
187
188
+
$response = $this->atp->client->postBlob(
189
+
endpoint: AtprotoRepo::UploadBlob,
190
data: $data,
191
mimeType: $mimeType
192
);
193
+
194
+
return BlobReference::fromArray($response->json()['blob']);
195
}
196
197
/**
···
199
*
200
* @see https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo
201
*/
202
+
#[PublicEndpoint]
203
+
public function describeRepo(string $repo): DescribeRepoResponse
204
{
205
+
$response = $this->atp->client->get(
206
+
endpoint: AtprotoRepo::DescribeRepo,
207
params: compact('repo')
208
);
209
+
210
+
return DescribeRepoResponse::fromArray($response->json());
211
+
}
212
+
213
+
/**
214
+
* Check if the session has repo access for a specific collection and action.
215
+
*
216
+
* This check is in addition to the transition:generic scope check.
217
+
* Users need either transition:generic OR the specific repo scope.
218
+
*/
219
+
protected function checkCollectionScope(string $collection, string $action): void
220
+
{
221
+
$session = $this->atp->client->session();
222
+
$checker = app(ScopeChecker::class);
223
+
224
+
// If user has transition:generic, they have broad access
225
+
if ($checker->hasScope($session, Scope::TransitionGeneric)) {
226
+
return;
227
+
}
228
+
229
+
// Otherwise, check for specific repo scope
230
+
$checker->checkRepoScopeOrFail($session, $collection, $action);
231
}
232
}
+20
-7
src/Client/Requests/Atproto/ServerRequestClient.php
+20
-7
src/Client/Requests/Atproto/ServerRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
-
use SocialDept\AtpClient\Http\Response;
7
8
class ServerRequestClient extends Request
9
{
10
/**
11
* Get current session
12
*
13
* @see https://docs.bsky.app/docs/api/com-atproto-server-get-session
14
*/
15
-
public function getSession(): Response
16
{
17
-
return $this->atp->client->get(
18
-
endpoint: 'com.atproto.server.getSession'
19
);
20
}
21
22
/**
···
24
*
25
* @see https://docs.bsky.app/docs/api/com-atproto-server-describe-server
26
*/
27
-
public function describeServer(): Response
28
{
29
-
return $this->atp->client->get(
30
-
endpoint: 'com.atproto.server.describeServer'
31
);
32
}
33
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
5
+
use SocialDept\AtpClient\Attributes\PublicEndpoint;
6
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
7
use SocialDept\AtpClient\Client\Requests\Request;
8
+
use SocialDept\AtpClient\Data\Responses\Atproto\Server\DescribeServerResponse;
9
+
use SocialDept\AtpClient\Data\Responses\Atproto\Server\GetSessionResponse;
10
+
use SocialDept\AtpClient\Enums\Nsid\AtprotoServer;
11
+
use SocialDept\AtpClient\Enums\Scope;
12
13
class ServerRequestClient extends Request
14
{
15
/**
16
* Get current session
17
*
18
+
* @requires atproto (rpc:com.atproto.server.getSession)
19
+
*
20
* @see https://docs.bsky.app/docs/api/com-atproto-server-get-session
21
*/
22
+
#[ScopedEndpoint(Scope::Atproto, granular: 'rpc:com.atproto.server.getSession')]
23
+
public function getSession(): GetSessionResponse
24
{
25
+
$response = $this->atp->client->get(
26
+
endpoint: AtprotoServer::GetSession
27
);
28
+
29
+
return GetSessionResponse::fromArray($response->json());
30
}
31
32
/**
···
34
*
35
* @see https://docs.bsky.app/docs/api/com-atproto-server-describe-server
36
*/
37
+
#[PublicEndpoint]
38
+
public function describeServer(): DescribeServerResponse
39
{
40
+
$response = $this->atp->client->get(
41
+
endpoint: AtprotoServer::DescribeServer
42
);
43
+
44
+
return DescribeServerResponse::fromArray($response->json());
45
}
46
}
+64
-17
src/Client/Requests/Atproto/SyncRequestClient.php
+64
-17
src/Client/Requests/Atproto/SyncRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
use SocialDept\AtpClient\Http\Response;
7
8
class SyncRequestClient extends Request
9
{
···
12
*
13
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
14
*/
15
public function getBlob(string $did, string $cid): Response
16
{
17
return $this->atp->client->get(
18
-
endpoint: 'com.atproto.sync.getBlob',
19
params: compact('did', 'cid')
20
);
21
}
···
25
*
26
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-repo
27
*/
28
public function getRepo(string $did, ?string $since = null): Response
29
{
30
return $this->atp->client->get(
31
-
endpoint: 'com.atproto.sync.getRepo',
32
params: compact('did', 'since')
33
);
34
}
···
38
*
39
* @see https://docs.bsky.app/docs/api/com-atproto-sync-list-repos
40
*/
41
-
public function listRepos(int $limit = 500, ?string $cursor = null): Response
42
{
43
-
return $this->atp->client->get(
44
-
endpoint: 'com.atproto.sync.listRepos',
45
params: compact('limit', 'cursor')
46
);
47
}
48
49
/**
···
51
*
52
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-latest-commit
53
*/
54
-
public function getLatestCommit(string $did): Response
55
{
56
-
return $this->atp->client->get(
57
-
endpoint: 'com.atproto.sync.getLatestCommit',
58
params: compact('did')
59
);
60
}
61
62
/**
···
64
*
65
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-record
66
*/
67
-
public function getRecord(string $did, string $collection, string $rkey): Response
68
{
69
return $this->atp->client->get(
70
-
endpoint: 'com.atproto.sync.getRecord',
71
params: compact('did', 'collection', 'rkey')
72
);
73
}
···
77
*
78
* @see https://docs.bsky.app/docs/api/com-atproto-sync-list-blobs
79
*/
80
public function listBlobs(
81
string $did,
82
?string $since = null,
83
int $limit = 500,
84
?string $cursor = null
85
-
): Response {
86
-
return $this->atp->client->get(
87
-
endpoint: 'com.atproto.sync.listBlobs',
88
params: compact('did', 'since', 'limit', 'cursor')
89
);
90
}
91
92
/**
···
94
*
95
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-blocks
96
*/
97
public function getBlocks(string $did, array $cids): Response
98
{
99
return $this->atp->client->get(
100
-
endpoint: 'com.atproto.sync.getBlocks',
101
params: compact('did', 'cids')
102
);
103
}
···
107
*
108
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status
109
*/
110
-
public function getRepoStatus(string $did): Response
111
{
112
-
return $this->atp->client->get(
113
-
endpoint: 'com.atproto.sync.getRepoStatus',
114
params: compact('did')
115
);
116
}
117
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Atproto;
4
5
+
use BackedEnum;
6
+
use SocialDept\AtpClient\Attributes\PublicEndpoint;
7
use SocialDept\AtpClient\Client\Requests\Request;
8
+
use SocialDept\AtpClient\Data\Responses\Atproto\Sync\GetRepoStatusResponse;
9
+
use SocialDept\AtpClient\Data\Responses\Atproto\Sync\ListBlobsResponse;
10
+
use SocialDept\AtpClient\Data\Responses\Atproto\Sync\ListReposByCollectionResponse;
11
+
use SocialDept\AtpClient\Data\Responses\Atproto\Sync\ListReposResponse;
12
+
use SocialDept\AtpClient\Enums\Nsid\AtprotoSync;
13
use SocialDept\AtpClient\Http\Response;
14
+
use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\Defs\CommitMeta;
15
16
class SyncRequestClient extends Request
17
{
···
20
*
21
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-blob
22
*/
23
+
#[PublicEndpoint]
24
public function getBlob(string $did, string $cid): Response
25
{
26
return $this->atp->client->get(
27
+
endpoint: AtprotoSync::GetBlob,
28
params: compact('did', 'cid')
29
);
30
}
···
34
*
35
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-repo
36
*/
37
+
#[PublicEndpoint]
38
public function getRepo(string $did, ?string $since = null): Response
39
{
40
return $this->atp->client->get(
41
+
endpoint: AtprotoSync::GetRepo,
42
params: compact('did', 'since')
43
);
44
}
···
48
*
49
* @see https://docs.bsky.app/docs/api/com-atproto-sync-list-repos
50
*/
51
+
#[PublicEndpoint]
52
+
public function listRepos(int $limit = 500, ?string $cursor = null): ListReposResponse
53
{
54
+
$response = $this->atp->client->get(
55
+
endpoint: AtprotoSync::ListRepos,
56
params: compact('limit', 'cursor')
57
);
58
+
59
+
return ListReposResponse::fromArray($response->json());
60
+
}
61
+
62
+
/**
63
+
* Enumerates all the DIDs with records in a specific collection
64
+
*
65
+
* @see https://docs.bsky.app/docs/api/com-atproto-sync-list-repos-by-collection
66
+
*/
67
+
#[PublicEndpoint]
68
+
public function listReposByCollection(
69
+
string|BackedEnum $collection,
70
+
int $limit = 500,
71
+
?string $cursor = null
72
+
): ListReposByCollectionResponse {
73
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
74
+
75
+
$response = $this->atp->client->get(
76
+
endpoint: AtprotoSync::ListReposByCollection,
77
+
params: compact('collection', 'limit', 'cursor')
78
+
);
79
+
80
+
return ListReposByCollectionResponse::fromArray($response->json());
81
}
82
83
/**
···
85
*
86
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-latest-commit
87
*/
88
+
#[PublicEndpoint]
89
+
public function getLatestCommit(string $did): CommitMeta
90
{
91
+
$response = $this->atp->client->get(
92
+
endpoint: AtprotoSync::GetLatestCommit,
93
params: compact('did')
94
);
95
+
96
+
return CommitMeta::fromArray($response->json());
97
}
98
99
/**
···
101
*
102
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-record
103
*/
104
+
#[PublicEndpoint]
105
+
public function getRecord(string $did, string|BackedEnum $collection, string $rkey): Response
106
{
107
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
108
+
109
return $this->atp->client->get(
110
+
endpoint: AtprotoSync::GetRecord,
111
params: compact('did', 'collection', 'rkey')
112
);
113
}
···
117
*
118
* @see https://docs.bsky.app/docs/api/com-atproto-sync-list-blobs
119
*/
120
+
#[PublicEndpoint]
121
public function listBlobs(
122
string $did,
123
?string $since = null,
124
int $limit = 500,
125
?string $cursor = null
126
+
): ListBlobsResponse {
127
+
$response = $this->atp->client->get(
128
+
endpoint: AtprotoSync::ListBlobs,
129
params: compact('did', 'since', 'limit', 'cursor')
130
);
131
+
132
+
return ListBlobsResponse::fromArray($response->json());
133
}
134
135
/**
···
137
*
138
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-blocks
139
*/
140
+
#[PublicEndpoint]
141
public function getBlocks(string $did, array $cids): Response
142
{
143
return $this->atp->client->get(
144
+
endpoint: AtprotoSync::GetBlocks,
145
params: compact('did', 'cids')
146
);
147
}
···
151
*
152
* @see https://docs.bsky.app/docs/api/com-atproto-sync-get-repo-status
153
*/
154
+
#[PublicEndpoint]
155
+
public function getRepoStatus(string $did): GetRepoStatusResponse
156
{
157
+
$response = $this->atp->client->get(
158
+
endpoint: AtprotoSync::GetRepoStatus,
159
params: compact('did')
160
);
161
+
162
+
return GetRepoStatusResponse::fromArray($response->json());
163
}
164
}
+77
-4
src/Client/Requests/Bsky/ActorRequestClient.php
+77
-4
src/Client/Requests/Bsky/ActorRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Bsky;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
-
use SocialDept\AtpClient\Http\Response;
7
8
class ActorRequestClient extends Request
9
{
···
12
*
13
* @see https://docs.bsky.app/docs/api/app-bsky-actor-get-profile
14
*/
15
-
public function getProfile(string $actor): Response
16
{
17
-
return $this->atp->client->get(
18
-
endpoint: 'app.bsky.actor.getProfile',
19
params: compact('actor')
20
);
21
}
22
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Bsky;
4
5
+
use SocialDept\AtpClient\Attributes\PublicEndpoint;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Data\Responses\Bsky\Actor\GetProfilesResponse;
8
+
use SocialDept\AtpClient\Data\Responses\Bsky\Actor\GetSuggestionsResponse;
9
+
use SocialDept\AtpClient\Data\Responses\Bsky\Actor\SearchActorsResponse;
10
+
use SocialDept\AtpClient\Data\Responses\Bsky\Actor\SearchActorsTypeaheadResponse;
11
+
use SocialDept\AtpClient\Enums\Nsid\BskyActor;
12
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileViewDetailed;
13
14
class ActorRequestClient extends Request
15
{
···
18
*
19
* @see https://docs.bsky.app/docs/api/app-bsky-actor-get-profile
20
*/
21
+
#[PublicEndpoint]
22
+
public function getProfile(string $actor): ProfileViewDetailed
23
{
24
+
$response = $this->atp->client->get(
25
+
endpoint: BskyActor::GetProfile,
26
params: compact('actor')
27
);
28
+
29
+
return ProfileViewDetailed::fromArray($response->toArray());
30
+
}
31
+
32
+
/**
33
+
* Get multiple actor profiles
34
+
*
35
+
* @see https://docs.bsky.app/docs/api/app-bsky-actor-get-profiles
36
+
*/
37
+
#[PublicEndpoint]
38
+
public function getProfiles(array $actors): GetProfilesResponse
39
+
{
40
+
$response = $this->atp->client->get(
41
+
endpoint: BskyActor::GetProfiles,
42
+
params: compact('actors')
43
+
);
44
+
45
+
return GetProfilesResponse::fromArray($response->json());
46
+
}
47
+
48
+
/**
49
+
* Get suggestions for actors to follow
50
+
*
51
+
* @see https://docs.bsky.app/docs/api/app-bsky-actor-get-suggestions
52
+
*/
53
+
#[PublicEndpoint]
54
+
public function getSuggestions(int $limit = 50, ?string $cursor = null): GetSuggestionsResponse
55
+
{
56
+
$response = $this->atp->client->get(
57
+
endpoint: BskyActor::GetSuggestions,
58
+
params: compact('limit', 'cursor')
59
+
);
60
+
61
+
return GetSuggestionsResponse::fromArray($response->json());
62
+
}
63
+
64
+
/**
65
+
* Search for actors
66
+
*
67
+
* @see https://docs.bsky.app/docs/api/app-bsky-actor-search-actors
68
+
*/
69
+
#[PublicEndpoint]
70
+
public function searchActors(string $q, int $limit = 25, ?string $cursor = null): SearchActorsResponse
71
+
{
72
+
$response = $this->atp->client->get(
73
+
endpoint: BskyActor::SearchActors,
74
+
params: compact('q', 'limit', 'cursor')
75
+
);
76
+
77
+
return SearchActorsResponse::fromArray($response->json());
78
+
}
79
+
80
+
/**
81
+
* Search for actors matching a prefix (typeahead/autocomplete)
82
+
*
83
+
* @see https://docs.bsky.app/docs/api/app-bsky-actor-search-actors-typeahead
84
+
*/
85
+
#[PublicEndpoint]
86
+
public function searchActorsTypeahead(string $q, int $limit = 10): SearchActorsTypeaheadResponse
87
+
{
88
+
$response = $this->atp->client->get(
89
+
endpoint: BskyActor::SearchActorsTypeahead,
90
+
params: compact('q', 'limit')
91
+
);
92
+
93
+
return SearchActorsTypeaheadResponse::fromArray($response->json());
94
}
95
}
+220
-33
src/Client/Requests/Bsky/FeedRequestClient.php
+220
-33
src/Client/Requests/Bsky/FeedRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Bsky;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
-
use SocialDept\AtpClient\Http\Response;
7
8
class FeedRequestClient extends Request
9
{
10
/**
11
-
* Get timeline feed
12
*
13
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-timeline
14
*/
15
-
public function getTimeline(int $limit = 50, ?string $cursor = null): Response
16
{
17
-
return $this->atp->client->get(
18
-
endpoint: 'app.bsky.feed.getTimeline',
19
params: compact('limit', 'cursor')
20
);
21
}
22
23
/**
···
25
*
26
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed
27
*/
28
public function getAuthorFeed(
29
string $actor,
30
int $limit = 50,
31
-
?string $cursor = null
32
-
): Response {
33
-
return $this->atp->client->get(
34
-
endpoint: 'app.bsky.feed.getAuthorFeed',
35
params: compact('actor', 'limit', 'cursor')
36
);
37
}
38
39
/**
···
41
*
42
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread
43
*/
44
-
public function getPostThread(string $uri, int $depth = 6): Response
45
{
46
-
return $this->atp->client->get(
47
-
endpoint: 'app.bsky.feed.getPostThread',
48
-
params: compact('uri', 'depth')
49
);
50
}
51
52
/**
53
-
* Search posts
54
*
55
-
* @see https://docs.bsky.app/docs/api/app-bsky-feed-search-posts
56
*/
57
-
public function searchPosts(
58
-
string $q,
59
-
int $limit = 25,
60
-
?string $cursor = null
61
-
): Response {
62
-
return $this->atp->client->get(
63
-
endpoint: 'app.bsky.feed.searchPosts',
64
-
params: compact('q', 'limit', 'cursor')
65
);
66
}
67
68
/**
···
70
*
71
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-likes
72
*/
73
public function getLikes(
74
string $uri,
75
int $limit = 50,
76
-
?string $cursor = null
77
-
): Response {
78
-
return $this->atp->client->get(
79
-
endpoint: 'app.bsky.feed.getLikes',
80
-
params: compact('uri', 'limit', 'cursor')
81
);
82
}
83
84
/**
···
86
*
87
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-reposted-by
88
*/
89
public function getRepostedBy(
90
string $uri,
91
int $limit = 50,
92
-
?string $cursor = null
93
-
): Response {
94
-
return $this->atp->client->get(
95
-
endpoint: 'app.bsky.feed.getRepostedBy',
96
-
params: compact('uri', 'limit', 'cursor')
97
);
98
}
99
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Bsky;
4
5
+
use SocialDept\AtpClient\Attributes\PublicEndpoint;
6
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
7
use SocialDept\AtpClient\Client\Requests\Request;
8
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\DescribeFeedGeneratorResponse;
9
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetActorFeedsResponse;
10
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetActorLikesResponse;
11
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetAuthorFeedResponse;
12
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetFeedGeneratorResponse;
13
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetFeedGeneratorsResponse;
14
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetFeedResponse;
15
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetLikesResponse;
16
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetPostsResponse;
17
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetPostThreadResponse;
18
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetQuotesResponse;
19
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetRepostedByResponse;
20
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetSuggestedFeedsResponse;
21
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\GetTimelineResponse;
22
+
use SocialDept\AtpClient\Data\Responses\Bsky\Feed\SearchPostsResponse;
23
+
use SocialDept\AtpClient\Enums\Nsid\BskyFeed;
24
+
use SocialDept\AtpClient\Enums\Scope;
25
26
class FeedRequestClient extends Request
27
{
28
/**
29
+
* Describe feed generator
30
+
*
31
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-describe-feed-generator
32
+
*/
33
+
#[PublicEndpoint]
34
+
public function describeFeedGenerator(): DescribeFeedGeneratorResponse
35
+
{
36
+
$response = $this->atp->client->get(
37
+
endpoint: BskyFeed::DescribeFeedGenerator
38
+
);
39
+
40
+
return DescribeFeedGeneratorResponse::fromArray($response->json());
41
+
}
42
+
43
+
/**
44
+
* Get timeline feed (requires authentication)
45
*
46
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-timeline
47
*/
48
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')]
49
+
public function getTimeline(int $limit = 50, ?string $cursor = null): GetTimelineResponse
50
{
51
+
$response = $this->atp->client->get(
52
+
endpoint: BskyFeed::GetTimeline,
53
params: compact('limit', 'cursor')
54
);
55
+
56
+
return GetTimelineResponse::fromArray($response->json());
57
}
58
59
/**
···
61
*
62
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-author-feed
63
*/
64
+
#[PublicEndpoint]
65
public function getAuthorFeed(
66
string $actor,
67
int $limit = 50,
68
+
?string $cursor = null,
69
+
?string $filter = null
70
+
): GetAuthorFeedResponse {
71
+
$response = $this->atp->client->get(
72
+
endpoint: BskyFeed::GetAuthorFeed,
73
+
params: compact('actor', 'limit', 'cursor', 'filter')
74
+
);
75
+
76
+
return GetAuthorFeedResponse::fromArray($response->json());
77
+
}
78
+
79
+
/**
80
+
* Get feeds created by an actor
81
+
*
82
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-actor-feeds
83
+
*/
84
+
#[PublicEndpoint]
85
+
public function getActorFeeds(string $actor, int $limit = 50, ?string $cursor = null): GetActorFeedsResponse
86
+
{
87
+
$response = $this->atp->client->get(
88
+
endpoint: BskyFeed::GetActorFeeds,
89
params: compact('actor', 'limit', 'cursor')
90
);
91
+
92
+
return GetActorFeedsResponse::fromArray($response->json());
93
+
}
94
+
95
+
/**
96
+
* Get posts liked by an actor
97
+
*
98
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-actor-likes
99
+
*/
100
+
#[PublicEndpoint]
101
+
public function getActorLikes(string $actor, int $limit = 50, ?string $cursor = null): GetActorLikesResponse
102
+
{
103
+
$response = $this->atp->client->get(
104
+
endpoint: BskyFeed::GetActorLikes,
105
+
params: compact('actor', 'limit', 'cursor')
106
+
);
107
+
108
+
return GetActorLikesResponse::fromArray($response->json());
109
+
}
110
+
111
+
/**
112
+
* Get a feed
113
+
*
114
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-feed
115
+
*/
116
+
#[PublicEndpoint]
117
+
public function getFeed(string $feed, int $limit = 50, ?string $cursor = null): GetFeedResponse
118
+
{
119
+
$response = $this->atp->client->get(
120
+
endpoint: BskyFeed::GetFeed,
121
+
params: compact('feed', 'limit', 'cursor')
122
+
);
123
+
124
+
return GetFeedResponse::fromArray($response->json());
125
+
}
126
+
127
+
/**
128
+
* Get a feed generator
129
+
*
130
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-feed-generator
131
+
*/
132
+
#[PublicEndpoint]
133
+
public function getFeedGenerator(string $feed): GetFeedGeneratorResponse
134
+
{
135
+
$response = $this->atp->client->get(
136
+
endpoint: BskyFeed::GetFeedGenerator,
137
+
params: compact('feed')
138
+
);
139
+
140
+
return GetFeedGeneratorResponse::fromArray($response->json());
141
+
}
142
+
143
+
/**
144
+
* Get multiple feed generators
145
+
*
146
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-feed-generators
147
+
*/
148
+
#[PublicEndpoint]
149
+
public function getFeedGenerators(array $feeds): GetFeedGeneratorsResponse
150
+
{
151
+
$response = $this->atp->client->get(
152
+
endpoint: BskyFeed::GetFeedGenerators,
153
+
params: compact('feeds')
154
+
);
155
+
156
+
return GetFeedGeneratorsResponse::fromArray($response->json());
157
}
158
159
/**
···
161
*
162
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread
163
*/
164
+
#[PublicEndpoint]
165
+
public function getPostThread(string $uri, int $depth = 6, int $parentHeight = 80): GetPostThreadResponse
166
{
167
+
$response = $this->atp->client->get(
168
+
endpoint: BskyFeed::GetPostThread,
169
+
params: compact('uri', 'depth', 'parentHeight')
170
);
171
+
172
+
return GetPostThreadResponse::fromArray($response->json());
173
}
174
175
/**
176
+
* Get multiple posts by URI
177
*
178
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-posts
179
*/
180
+
#[PublicEndpoint]
181
+
public function getPosts(array $uris): GetPostsResponse
182
+
{
183
+
$response = $this->atp->client->get(
184
+
endpoint: BskyFeed::GetPosts,
185
+
params: compact('uris')
186
);
187
+
188
+
return GetPostsResponse::fromArray($response->json());
189
}
190
191
/**
···
193
*
194
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-likes
195
*/
196
+
#[PublicEndpoint]
197
public function getLikes(
198
string $uri,
199
int $limit = 50,
200
+
?string $cursor = null,
201
+
?string $cid = null
202
+
): GetLikesResponse {
203
+
$response = $this->atp->client->get(
204
+
endpoint: BskyFeed::GetLikes,
205
+
params: compact('uri', 'limit', 'cursor', 'cid')
206
);
207
+
208
+
return GetLikesResponse::fromArray($response->json());
209
+
}
210
+
211
+
/**
212
+
* Get quotes of a post
213
+
*
214
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-quotes
215
+
*/
216
+
#[PublicEndpoint]
217
+
public function getQuotes(
218
+
string $uri,
219
+
int $limit = 50,
220
+
?string $cursor = null,
221
+
?string $cid = null
222
+
): GetQuotesResponse {
223
+
$response = $this->atp->client->get(
224
+
endpoint: BskyFeed::GetQuotes,
225
+
params: compact('uri', 'limit', 'cursor', 'cid')
226
+
);
227
+
228
+
return GetQuotesResponse::fromArray($response->json());
229
}
230
231
/**
···
233
*
234
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-reposted-by
235
*/
236
+
#[PublicEndpoint]
237
public function getRepostedBy(
238
string $uri,
239
int $limit = 50,
240
+
?string $cursor = null,
241
+
?string $cid = null
242
+
): GetRepostedByResponse {
243
+
$response = $this->atp->client->get(
244
+
endpoint: BskyFeed::GetRepostedBy,
245
+
params: compact('uri', 'limit', 'cursor', 'cid')
246
+
);
247
+
248
+
return GetRepostedByResponse::fromArray($response->json());
249
+
}
250
+
251
+
/**
252
+
* Get suggested feeds
253
+
*
254
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-get-suggested-feeds
255
+
*/
256
+
#[PublicEndpoint]
257
+
public function getSuggestedFeeds(int $limit = 50, ?string $cursor = null): GetSuggestedFeedsResponse
258
+
{
259
+
$response = $this->atp->client->get(
260
+
endpoint: BskyFeed::GetSuggestedFeeds,
261
+
params: compact('limit', 'cursor')
262
+
);
263
+
264
+
return GetSuggestedFeedsResponse::fromArray($response->json());
265
+
}
266
+
267
+
/**
268
+
* Search posts
269
+
*
270
+
* @see https://docs.bsky.app/docs/api/app-bsky-feed-search-posts
271
+
*/
272
+
#[PublicEndpoint]
273
+
public function searchPosts(
274
+
string $q,
275
+
int $limit = 25,
276
+
?string $cursor = null,
277
+
?string $sort = null
278
+
): SearchPostsResponse {
279
+
$response = $this->atp->client->get(
280
+
endpoint: BskyFeed::SearchPosts,
281
+
params: compact('q', 'limit', 'cursor', 'sort')
282
);
283
+
284
+
return SearchPostsResponse::fromArray($response->json());
285
}
286
}
+163
src/Client/Requests/Bsky/GraphRequestClient.php
+163
src/Client/Requests/Bsky/GraphRequestClient.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Client\Requests\Bsky;
4
+
5
+
use SocialDept\AtpClient\Attributes\PublicEndpoint;
6
+
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetFollowersResponse;
8
+
use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetFollowsResponse;
9
+
use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetKnownFollowersResponse;
10
+
use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetListResponse;
11
+
use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetListsResponse;
12
+
use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetRelationshipsResponse;
13
+
use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetStarterPacksResponse;
14
+
use SocialDept\AtpClient\Data\Responses\Bsky\Graph\GetSuggestedFollowsByActorResponse;
15
+
use SocialDept\AtpClient\Enums\Nsid\BskyGraph;
16
+
use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\StarterPackView;
17
+
18
+
class GraphRequestClient extends Request
19
+
{
20
+
/**
21
+
* Get followers of an actor
22
+
*
23
+
* @see https://docs.bsky.app/docs/api/app-bsky-graph-get-followers
24
+
*/
25
+
#[PublicEndpoint]
26
+
public function getFollowers(string $actor, int $limit = 50, ?string $cursor = null): GetFollowersResponse
27
+
{
28
+
$response = $this->atp->client->get(
29
+
endpoint: BskyGraph::GetFollowers,
30
+
params: compact('actor', 'limit', 'cursor')
31
+
);
32
+
33
+
return GetFollowersResponse::fromArray($response->json());
34
+
}
35
+
36
+
/**
37
+
* Get accounts that an actor follows
38
+
*
39
+
* @see https://docs.bsky.app/docs/api/app-bsky-graph-get-follows
40
+
*/
41
+
#[PublicEndpoint]
42
+
public function getFollows(string $actor, int $limit = 50, ?string $cursor = null): GetFollowsResponse
43
+
{
44
+
$response = $this->atp->client->get(
45
+
endpoint: BskyGraph::GetFollows,
46
+
params: compact('actor', 'limit', 'cursor')
47
+
);
48
+
49
+
return GetFollowsResponse::fromArray($response->json());
50
+
}
51
+
52
+
/**
53
+
* Get followers of an actor that you also follow
54
+
*
55
+
* @see https://docs.bsky.app/docs/api/app-bsky-graph-get-known-followers
56
+
*/
57
+
#[PublicEndpoint]
58
+
public function getKnownFollowers(string $actor, int $limit = 50, ?string $cursor = null): GetKnownFollowersResponse
59
+
{
60
+
$response = $this->atp->client->get(
61
+
endpoint: BskyGraph::GetKnownFollowers,
62
+
params: compact('actor', 'limit', 'cursor')
63
+
);
64
+
65
+
return GetKnownFollowersResponse::fromArray($response->json());
66
+
}
67
+
68
+
/**
69
+
* Get a list by URI
70
+
*
71
+
* @see https://docs.bsky.app/docs/api/app-bsky-graph-get-list
72
+
*/
73
+
#[PublicEndpoint]
74
+
public function getList(string $list, int $limit = 50, ?string $cursor = null): GetListResponse
75
+
{
76
+
$response = $this->atp->client->get(
77
+
endpoint: BskyGraph::GetList,
78
+
params: compact('list', 'limit', 'cursor')
79
+
);
80
+
81
+
return GetListResponse::fromArray($response->json());
82
+
}
83
+
84
+
/**
85
+
* Get lists created by an actor
86
+
*
87
+
* @see https://docs.bsky.app/docs/api/app-bsky-graph-get-lists
88
+
*/
89
+
#[PublicEndpoint]
90
+
public function getLists(string $actor, int $limit = 50, ?string $cursor = null): GetListsResponse
91
+
{
92
+
$response = $this->atp->client->get(
93
+
endpoint: BskyGraph::GetLists,
94
+
params: compact('actor', 'limit', 'cursor')
95
+
);
96
+
97
+
return GetListsResponse::fromArray($response->json());
98
+
}
99
+
100
+
/**
101
+
* Get relationships between actors
102
+
*
103
+
* @see https://docs.bsky.app/docs/api/app-bsky-graph-get-relationships
104
+
*/
105
+
#[PublicEndpoint]
106
+
public function getRelationships(string $actor, array $others = []): GetRelationshipsResponse
107
+
{
108
+
$response = $this->atp->client->get(
109
+
endpoint: BskyGraph::GetRelationships,
110
+
params: compact('actor', 'others')
111
+
);
112
+
113
+
return GetRelationshipsResponse::fromArray($response->json());
114
+
}
115
+
116
+
/**
117
+
* Get a starter pack by URI
118
+
*
119
+
* @see https://docs.bsky.app/docs/api/app-bsky-graph-get-starter-pack
120
+
*/
121
+
#[PublicEndpoint]
122
+
public function getStarterPack(string $starterPack): StarterPackView
123
+
{
124
+
$response = $this->atp->client->get(
125
+
endpoint: BskyGraph::GetStarterPack,
126
+
params: compact('starterPack')
127
+
);
128
+
129
+
return StarterPackView::fromArray($response->json()['starterPack']);
130
+
}
131
+
132
+
/**
133
+
* Get multiple starter packs
134
+
*
135
+
* @see https://docs.bsky.app/docs/api/app-bsky-graph-get-starter-packs
136
+
*/
137
+
#[PublicEndpoint]
138
+
public function getStarterPacks(array $uris): GetStarterPacksResponse
139
+
{
140
+
$response = $this->atp->client->get(
141
+
endpoint: BskyGraph::GetStarterPacks,
142
+
params: compact('uris')
143
+
);
144
+
145
+
return GetStarterPacksResponse::fromArray($response->json());
146
+
}
147
+
148
+
/**
149
+
* Get suggested follows based on an actor
150
+
*
151
+
* @see https://docs.bsky.app/docs/api/app-bsky-graph-get-suggested-follows-by-actor
152
+
*/
153
+
#[PublicEndpoint]
154
+
public function getSuggestedFollowsByActor(string $actor): GetSuggestedFollowsByActorResponse
155
+
{
156
+
$response = $this->atp->client->get(
157
+
endpoint: BskyGraph::GetSuggestedFollowsByActor,
158
+
params: compact('actor')
159
+
);
160
+
161
+
return GetSuggestedFollowsByActorResponse::fromArray($response->json());
162
+
}
163
+
}
+27
src/Client/Requests/Bsky/LabelerRequestClient.php
+27
src/Client/Requests/Bsky/LabelerRequestClient.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Client\Requests\Bsky;
4
+
5
+
use SocialDept\AtpClient\Attributes\PublicEndpoint;
6
+
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Data\Responses\Bsky\Labeler\GetServicesResponse;
8
+
use SocialDept\AtpClient\Enums\Nsid\BskyLabeler;
9
+
10
+
class LabelerRequestClient extends Request
11
+
{
12
+
/**
13
+
* Get labeler services
14
+
*
15
+
* @see https://docs.bsky.app/docs/api/app-bsky-labeler-get-services
16
+
*/
17
+
#[PublicEndpoint]
18
+
public function getServices(array $dids, bool $detailed = false): GetServicesResponse
19
+
{
20
+
$response = $this->atp->client->get(
21
+
endpoint: BskyLabeler::GetServices,
22
+
params: compact('dids', 'detailed')
23
+
);
24
+
25
+
return GetServicesResponse::fromArray($response->json(), $detailed);
26
+
}
27
+
}
+21
-6
src/Client/Requests/Chat/ActorRequestClient.php
+21
-6
src/Client/Requests/Chat/ActorRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Chat;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
use SocialDept\AtpClient\Http\Response;
7
8
class ActorRequestClient extends Request
···
10
/**
11
* Get actor metadata
12
*
13
* @see https://docs.bsky.app/docs/api/chat-bsky-actor-export-account-data
14
*/
15
public function getActorMetadata(): Response
16
{
17
return $this->atp->client->get(
18
-
endpoint: 'chat.bsky.actor.getActorMetadata'
19
);
20
}
21
22
/**
23
-
* Export account data
24
*
25
* @see https://docs.bsky.app/docs/api/chat-bsky-actor-export-account-data
26
*/
27
public function exportAccountData(): Response
28
{
29
return $this->atp->client->get(
30
-
endpoint: 'chat.bsky.actor.exportAccountData'
31
);
32
}
33
34
/**
35
* Delete account
36
*
37
* @see https://docs.bsky.app/docs/api/chat-bsky-actor-delete-account
38
*/
39
-
public function deleteAccount(): Response
40
{
41
-
return $this->atp->client->post(
42
-
endpoint: 'chat.bsky.actor.deleteAccount'
43
);
44
}
45
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Chat;
4
5
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Data\Responses\EmptyResponse;
8
+
use SocialDept\AtpClient\Enums\Nsid\ChatActor;
9
+
use SocialDept\AtpClient\Enums\Scope;
10
use SocialDept\AtpClient\Http\Response;
11
12
class ActorRequestClient extends Request
···
14
/**
15
* Get actor metadata
16
*
17
+
* @requires transition:chat.bsky (rpc:chat.bsky.actor.getActorMetadata)
18
+
*
19
* @see https://docs.bsky.app/docs/api/chat-bsky-actor-export-account-data
20
*/
21
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.getActorMetadata')]
22
public function getActorMetadata(): Response
23
{
24
return $this->atp->client->get(
25
+
endpoint: ChatActor::GetActorMetadata
26
);
27
}
28
29
/**
30
+
* Export account data (returns JSONL stream)
31
+
*
32
+
* @requires transition:chat.bsky (rpc:chat.bsky.actor.exportAccountData)
33
*
34
* @see https://docs.bsky.app/docs/api/chat-bsky-actor-export-account-data
35
*/
36
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.exportAccountData')]
37
public function exportAccountData(): Response
38
{
39
return $this->atp->client->get(
40
+
endpoint: ChatActor::ExportAccountData
41
);
42
}
43
44
/**
45
* Delete account
46
+
*
47
+
* @requires transition:chat.bsky (rpc:chat.bsky.actor.deleteAccount)
48
*
49
* @see https://docs.bsky.app/docs/api/chat-bsky-actor-delete-account
50
*/
51
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.deleteAccount')]
52
+
public function deleteAccount(): EmptyResponse
53
{
54
+
$this->atp->client->post(
55
+
endpoint: ChatActor::DeleteAccount
56
);
57
+
58
+
return new EmptyResponse;
59
}
60
}
+107
-37
src/Client/Requests/Chat/ConvoRequestClient.php
+107
-37
src/Client/Requests/Chat/ConvoRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Chat;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
-
use SocialDept\AtpClient\Http\Response;
7
8
class ConvoRequestClient extends Request
9
{
10
/**
11
* Get conversation
12
*
13
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-convo
14
*/
15
-
public function getConvo(string $convoId): Response
16
{
17
-
return $this->atp->client->get(
18
-
endpoint: 'chat.bsky.convo.getConvo',
19
params: compact('convoId')
20
);
21
}
22
23
/**
24
* Get conversation for members
25
*
26
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-convo-for-members
27
*/
28
-
public function getConvoForMembers(array $members): Response
29
{
30
-
return $this->atp->client->get(
31
-
endpoint: 'chat.bsky.convo.getConvoForMembers',
32
params: compact('members')
33
);
34
}
35
36
/**
37
* List conversations
38
*
39
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-list-convos
40
*/
41
-
public function listConvos(int $limit = 50, ?string $cursor = null): Response
42
{
43
-
return $this->atp->client->get(
44
-
endpoint: 'chat.bsky.convo.listConvos',
45
params: compact('limit', 'cursor')
46
);
47
}
48
49
/**
50
* Get messages
51
*
52
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-messages
53
*/
54
public function getMessages(
55
string $convoId,
56
int $limit = 50,
57
?string $cursor = null
58
-
): Response {
59
-
return $this->atp->client->get(
60
-
endpoint: 'chat.bsky.convo.getMessages',
61
params: compact('convoId', 'limit', 'cursor')
62
);
63
}
64
65
/**
66
* Send message
67
*
68
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-send-message
69
*/
70
-
public function sendMessage(string $convoId, array $message): Response
71
{
72
-
return $this->atp->client->post(
73
-
endpoint: 'chat.bsky.convo.sendMessage',
74
body: compact('convoId', 'message')
75
);
76
}
77
78
/**
79
* Send message batch
80
*
81
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-send-message-batch
82
*/
83
-
public function sendMessageBatch(array $items): Response
84
{
85
-
return $this->atp->client->post(
86
-
endpoint: 'chat.bsky.convo.sendMessageBatch',
87
body: compact('items')
88
);
89
}
90
91
/**
92
* Delete message for self
93
*
94
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-delete-message-for-self
95
*/
96
-
public function deleteMessageForSelf(string $convoId, string $messageId): Response
97
{
98
-
return $this->atp->client->post(
99
-
endpoint: 'chat.bsky.convo.deleteMessageForSelf',
100
body: compact('convoId', 'messageId')
101
);
102
}
103
104
/**
105
* Update read status
106
*
107
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-update-read
108
*/
109
-
public function updateRead(string $convoId, ?string $messageId = null): Response
110
{
111
-
return $this->atp->client->post(
112
-
endpoint: 'chat.bsky.convo.updateRead',
113
body: compact('convoId', 'messageId')
114
);
115
}
116
117
/**
118
* Mute conversation
119
*
120
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-mute-convo
121
*/
122
-
public function muteConvo(string $convoId): Response
123
{
124
-
return $this->atp->client->post(
125
-
endpoint: 'chat.bsky.convo.muteConvo',
126
body: compact('convoId')
127
);
128
}
129
130
/**
131
* Unmute conversation
132
*
133
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-unmute-convo
134
*/
135
-
public function unmuteConvo(string $convoId): Response
136
{
137
-
return $this->atp->client->post(
138
-
endpoint: 'chat.bsky.convo.unmuteConvo',
139
body: compact('convoId')
140
);
141
}
142
143
/**
144
* Leave conversation
145
*
146
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-leave-convo
147
*/
148
-
public function leaveConvo(string $convoId): Response
149
{
150
-
return $this->atp->client->post(
151
-
endpoint: 'chat.bsky.convo.leaveConvo',
152
body: compact('convoId')
153
);
154
}
155
156
/**
157
* Get log
158
*
159
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-log
160
*/
161
-
public function getLog(?string $cursor = null): Response
162
{
163
-
return $this->atp->client->get(
164
-
endpoint: 'chat.bsky.convo.getLog',
165
params: compact('cursor')
166
);
167
}
168
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Chat;
4
5
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Data\Responses\Chat\Convo\GetLogResponse;
8
+
use SocialDept\AtpClient\Data\Responses\Chat\Convo\GetMessagesResponse;
9
+
use SocialDept\AtpClient\Data\Responses\Chat\Convo\LeaveConvoResponse;
10
+
use SocialDept\AtpClient\Data\Responses\Chat\Convo\ListConvosResponse;
11
+
use SocialDept\AtpClient\Data\Responses\Chat\Convo\SendMessageBatchResponse;
12
+
use SocialDept\AtpClient\Enums\Nsid\ChatConvo;
13
+
use SocialDept\AtpClient\Enums\Scope;
14
+
use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\ConvoView;
15
+
use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\DeletedMessageView;
16
+
use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\MessageView;
17
18
class ConvoRequestClient extends Request
19
{
20
/**
21
* Get conversation
22
+
*
23
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.getConvo)
24
*
25
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-convo
26
*/
27
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getConvo')]
28
+
public function getConvo(string $convoId): ConvoView
29
{
30
+
$response = $this->atp->client->get(
31
+
endpoint: ChatConvo::GetConvo,
32
params: compact('convoId')
33
);
34
+
35
+
return ConvoView::fromArray($response->json()['convo']);
36
}
37
38
/**
39
* Get conversation for members
40
*
41
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.getConvoForMembers)
42
+
*
43
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-convo-for-members
44
*/
45
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getConvoForMembers')]
46
+
public function getConvoForMembers(array $members): ConvoView
47
{
48
+
$response = $this->atp->client->get(
49
+
endpoint: ChatConvo::GetConvoForMembers,
50
params: compact('members')
51
);
52
+
53
+
return ConvoView::fromArray($response->json()['convo']);
54
}
55
56
/**
57
* List conversations
58
+
*
59
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.listConvos)
60
*
61
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-list-convos
62
*/
63
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.listConvos')]
64
+
public function listConvos(int $limit = 50, ?string $cursor = null): ListConvosResponse
65
{
66
+
$response = $this->atp->client->get(
67
+
endpoint: ChatConvo::ListConvos,
68
params: compact('limit', 'cursor')
69
);
70
+
71
+
return ListConvosResponse::fromArray($response->json());
72
}
73
74
/**
75
* Get messages
76
*
77
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.getMessages)
78
+
*
79
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-messages
80
*/
81
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getMessages')]
82
public function getMessages(
83
string $convoId,
84
int $limit = 50,
85
?string $cursor = null
86
+
): GetMessagesResponse {
87
+
$response = $this->atp->client->get(
88
+
endpoint: ChatConvo::GetMessages,
89
params: compact('convoId', 'limit', 'cursor')
90
);
91
+
92
+
return GetMessagesResponse::fromArray($response->json());
93
}
94
95
/**
96
* Send message
97
*
98
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.sendMessage)
99
+
*
100
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-send-message
101
*/
102
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.sendMessage')]
103
+
public function sendMessage(string $convoId, array $message): MessageView
104
{
105
+
$response = $this->atp->client->post(
106
+
endpoint: ChatConvo::SendMessage,
107
body: compact('convoId', 'message')
108
);
109
+
110
+
return MessageView::fromArray($response->json());
111
}
112
113
/**
114
* Send message batch
115
*
116
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.sendMessageBatch)
117
+
*
118
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-send-message-batch
119
*/
120
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.sendMessageBatch')]
121
+
public function sendMessageBatch(array $items): SendMessageBatchResponse
122
{
123
+
$response = $this->atp->client->post(
124
+
endpoint: ChatConvo::SendMessageBatch,
125
body: compact('items')
126
);
127
+
128
+
return SendMessageBatchResponse::fromArray($response->json());
129
}
130
131
/**
132
* Delete message for self
133
*
134
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.deleteMessageForSelf)
135
+
*
136
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-delete-message-for-self
137
*/
138
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.deleteMessageForSelf')]
139
+
public function deleteMessageForSelf(string $convoId, string $messageId): DeletedMessageView
140
{
141
+
$response = $this->atp->client->post(
142
+
endpoint: ChatConvo::DeleteMessageForSelf,
143
body: compact('convoId', 'messageId')
144
);
145
+
146
+
return DeletedMessageView::fromArray($response->json());
147
}
148
149
/**
150
* Update read status
151
*
152
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.updateRead)
153
+
*
154
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-update-read
155
*/
156
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.updateRead')]
157
+
public function updateRead(string $convoId, ?string $messageId = null): ConvoView
158
{
159
+
$response = $this->atp->client->post(
160
+
endpoint: ChatConvo::UpdateRead,
161
body: compact('convoId', 'messageId')
162
);
163
+
164
+
return ConvoView::fromArray($response->json()['convo']);
165
}
166
167
/**
168
* Mute conversation
169
+
*
170
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.muteConvo)
171
*
172
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-mute-convo
173
*/
174
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.muteConvo')]
175
+
public function muteConvo(string $convoId): ConvoView
176
{
177
+
$response = $this->atp->client->post(
178
+
endpoint: ChatConvo::MuteConvo,
179
body: compact('convoId')
180
);
181
+
182
+
return ConvoView::fromArray($response->json()['convo']);
183
}
184
185
/**
186
* Unmute conversation
187
+
*
188
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.unmuteConvo)
189
*
190
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-unmute-convo
191
*/
192
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.unmuteConvo')]
193
+
public function unmuteConvo(string $convoId): ConvoView
194
{
195
+
$response = $this->atp->client->post(
196
+
endpoint: ChatConvo::UnmuteConvo,
197
body: compact('convoId')
198
);
199
+
200
+
return ConvoView::fromArray($response->json()['convo']);
201
}
202
203
/**
204
* Leave conversation
205
*
206
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.leaveConvo)
207
+
*
208
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-leave-convo
209
*/
210
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.leaveConvo')]
211
+
public function leaveConvo(string $convoId): LeaveConvoResponse
212
{
213
+
$response = $this->atp->client->post(
214
+
endpoint: ChatConvo::LeaveConvo,
215
body: compact('convoId')
216
);
217
+
218
+
return LeaveConvoResponse::fromArray($response->json());
219
}
220
221
/**
222
* Get log
223
+
*
224
+
* @requires transition:chat.bsky (rpc:chat.bsky.convo.getLog)
225
*
226
* @see https://docs.bsky.app/docs/api/chat-bsky-convo-get-log
227
*/
228
+
#[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.convo.getLog')]
229
+
public function getLog(?string $cursor = null): GetLogResponse
230
{
231
+
$response = $this->atp->client->get(
232
+
endpoint: ChatConvo::GetLog,
233
params: compact('cursor')
234
);
235
+
236
+
return GetLogResponse::fromArray($response->json());
237
}
238
}
+70
-22
src/Client/Requests/Ozone/ModerationRequestClient.php
+70
-22
src/Client/Requests/Ozone/ModerationRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Ozone;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
use SocialDept\AtpClient\Http\Response;
7
8
class ModerationRequestClient extends Request
9
{
10
/**
11
* Get moderation event
12
*
13
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-event
14
*/
15
-
public function getModerationEvent(int $id): Response
16
{
17
-
return $this->atp->client->get(
18
-
endpoint: 'tools.ozone.moderation.getEvent',
19
params: compact('id')
20
);
21
}
22
23
/**
24
* Get moderation events
25
*
26
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-events
27
*/
28
public function getModerationEvents(
29
?string $subject = null,
30
?array $types = null,
···
33
?string $cursor = null
34
): Response {
35
return $this->atp->client->get(
36
-
endpoint: 'tools.ozone.moderation.getEvents',
37
params: array_filter(
38
compact('subject', 'types', 'createdBy', 'limit', 'cursor'),
39
fn ($v) => ! is_null($v)
···
44
/**
45
* Get record
46
*
47
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-record
48
*/
49
-
public function getRecord(string $uri, ?string $cid = null): Response
50
{
51
-
return $this->atp->client->get(
52
-
endpoint: 'tools.ozone.moderation.getRecord',
53
params: compact('uri', 'cid')
54
);
55
}
56
57
/**
58
* Get repo
59
*
60
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-repo
61
*/
62
-
public function getRepo(string $did): Response
63
{
64
-
return $this->atp->client->get(
65
-
endpoint: 'tools.ozone.moderation.getRepo',
66
params: compact('did')
67
);
68
}
69
70
/**
71
* Query events
72
*
73
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-events
74
*/
75
public function queryEvents(
76
?array $types = null,
77
?string $createdBy = null,
···
79
int $limit = 50,
80
?string $cursor = null,
81
bool $sortDirection = false
82
-
): Response {
83
-
return $this->atp->client->get(
84
-
endpoint: 'tools.ozone.moderation.queryEvents',
85
params: array_filter(
86
compact('types', 'createdBy', 'subject', 'limit', 'cursor', 'sortDirection'),
87
fn ($v) => ! is_null($v)
88
)
89
);
90
}
91
92
/**
93
* Query statuses
94
*
95
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-statuses
96
*/
97
public function queryStatuses(
98
?string $subject = null,
99
?array $tags = null,
100
?string $excludeTags = null,
101
int $limit = 50,
102
?string $cursor = null
103
-
): Response {
104
-
return $this->atp->client->get(
105
-
endpoint: 'tools.ozone.moderation.queryStatuses',
106
params: array_filter(
107
compact('subject', 'tags', 'excludeTags', 'limit', 'cursor'),
108
fn ($v) => ! is_null($v)
109
)
110
);
111
}
112
113
/**
114
* Search repos
115
*
116
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-search-repos
117
*/
118
public function searchRepos(
119
?string $term = null,
120
?string $invitedBy = null,
121
int $limit = 50,
122
?string $cursor = null
123
-
): Response {
124
-
return $this->atp->client->get(
125
-
endpoint: 'tools.ozone.moderation.searchRepos',
126
params: array_filter(
127
compact('term', 'invitedBy', 'limit', 'cursor'),
128
fn ($v) => ! is_null($v)
129
)
130
);
131
}
132
133
/**
134
* Emit moderation event
135
*
136
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-emit-event
137
*/
138
public function emitEvent(
139
array $event,
140
string $subject,
141
array $subjectBlobCids = [],
142
?string $createdBy = null
143
-
): Response {
144
-
return $this->atp->client->post(
145
-
endpoint: 'tools.ozone.moderation.emitEvent',
146
body: compact('event', 'subject', 'subjectBlobCids', 'createdBy')
147
);
148
}
149
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Ozone;
4
5
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Data\Responses\Ozone\Moderation\QueryEventsResponse;
8
+
use SocialDept\AtpClient\Data\Responses\Ozone\Moderation\QueryStatusesResponse;
9
+
use SocialDept\AtpClient\Data\Responses\Ozone\Moderation\SearchReposResponse;
10
+
use SocialDept\AtpClient\Enums\Nsid\OzoneModeration;
11
+
use SocialDept\AtpClient\Enums\Scope;
12
use SocialDept\AtpClient\Http\Response;
13
+
use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\ModEventView;
14
+
use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\ModEventViewDetail;
15
+
use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\RecordViewDetail;
16
+
use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\RepoViewDetail;
17
18
class ModerationRequestClient extends Request
19
{
20
/**
21
* Get moderation event
22
+
*
23
+
* @requires transition:generic (rpc:tools.ozone.moderation.getEvent)
24
*
25
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-event
26
*/
27
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getEvent')]
28
+
public function getModerationEvent(int $id): ModEventViewDetail
29
{
30
+
$response = $this->atp->client->get(
31
+
endpoint: OzoneModeration::GetEvent,
32
params: compact('id')
33
);
34
+
35
+
return ModEventViewDetail::fromArray($response->json());
36
}
37
38
/**
39
* Get moderation events
40
+
*
41
+
* @requires transition:generic (rpc:tools.ozone.moderation.getEvents)
42
*
43
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-events
44
*/
45
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getEvents')]
46
public function getModerationEvents(
47
?string $subject = null,
48
?array $types = null,
···
51
?string $cursor = null
52
): Response {
53
return $this->atp->client->get(
54
+
endpoint: OzoneModeration::GetEvents,
55
params: array_filter(
56
compact('subject', 'types', 'createdBy', 'limit', 'cursor'),
57
fn ($v) => ! is_null($v)
···
62
/**
63
* Get record
64
*
65
+
* @requires transition:generic (rpc:tools.ozone.moderation.getRecord)
66
+
*
67
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-record
68
*/
69
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getRecord')]
70
+
public function getRecord(string $uri, ?string $cid = null): RecordViewDetail
71
{
72
+
$response = $this->atp->client->get(
73
+
endpoint: OzoneModeration::GetRecord,
74
params: compact('uri', 'cid')
75
);
76
+
77
+
return RecordViewDetail::fromArray($response->json());
78
}
79
80
/**
81
* Get repo
82
*
83
+
* @requires transition:generic (rpc:tools.ozone.moderation.getRepo)
84
+
*
85
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-get-repo
86
*/
87
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.getRepo')]
88
+
public function getRepo(string $did): RepoViewDetail
89
{
90
+
$response = $this->atp->client->get(
91
+
endpoint: OzoneModeration::GetRepo,
92
params: compact('did')
93
);
94
+
95
+
return RepoViewDetail::fromArray($response->json());
96
}
97
98
/**
99
* Query events
100
*
101
+
* @requires transition:generic (rpc:tools.ozone.moderation.queryEvents)
102
+
*
103
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-events
104
*/
105
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.queryEvents')]
106
public function queryEvents(
107
?array $types = null,
108
?string $createdBy = null,
···
110
int $limit = 50,
111
?string $cursor = null,
112
bool $sortDirection = false
113
+
): QueryEventsResponse {
114
+
$response = $this->atp->client->get(
115
+
endpoint: OzoneModeration::QueryEvents,
116
params: array_filter(
117
compact('types', 'createdBy', 'subject', 'limit', 'cursor', 'sortDirection'),
118
fn ($v) => ! is_null($v)
119
)
120
);
121
+
122
+
return QueryEventsResponse::fromArray($response->json());
123
}
124
125
/**
126
* Query statuses
127
+
*
128
+
* @requires transition:generic (rpc:tools.ozone.moderation.queryStatuses)
129
*
130
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-query-statuses
131
*/
132
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.queryStatuses')]
133
public function queryStatuses(
134
?string $subject = null,
135
?array $tags = null,
136
?string $excludeTags = null,
137
int $limit = 50,
138
?string $cursor = null
139
+
): QueryStatusesResponse {
140
+
$response = $this->atp->client->get(
141
+
endpoint: OzoneModeration::QueryStatuses,
142
params: array_filter(
143
compact('subject', 'tags', 'excludeTags', 'limit', 'cursor'),
144
fn ($v) => ! is_null($v)
145
)
146
);
147
+
148
+
return QueryStatusesResponse::fromArray($response->json());
149
}
150
151
/**
152
* Search repos
153
+
*
154
+
* @requires transition:generic (rpc:tools.ozone.moderation.searchRepos)
155
*
156
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-search-repos
157
*/
158
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.searchRepos')]
159
public function searchRepos(
160
?string $term = null,
161
?string $invitedBy = null,
162
int $limit = 50,
163
?string $cursor = null
164
+
): SearchReposResponse {
165
+
$response = $this->atp->client->get(
166
+
endpoint: OzoneModeration::SearchRepos,
167
params: array_filter(
168
compact('term', 'invitedBy', 'limit', 'cursor'),
169
fn ($v) => ! is_null($v)
170
)
171
);
172
+
173
+
return SearchReposResponse::fromArray($response->json());
174
}
175
176
/**
177
* Emit moderation event
178
*
179
+
* @requires transition:generic (rpc:tools.ozone.moderation.emitEvent)
180
+
*
181
* @see https://docs.bsky.app/docs/api/tools-ozone-moderation-emit-event
182
*/
183
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.moderation.emitEvent')]
184
public function emitEvent(
185
array $event,
186
string $subject,
187
array $subjectBlobCids = [],
188
?string $createdBy = null
189
+
): ModEventView {
190
+
$response = $this->atp->client->post(
191
+
endpoint: OzoneModeration::EmitEvent,
192
body: compact('event', 'subject', 'subjectBlobCids', 'createdBy')
193
);
194
+
195
+
return ModEventView::fromArray($response->json());
196
}
197
}
+17
-5
src/Client/Requests/Ozone/ServerRequestClient.php
+17
-5
src/Client/Requests/Ozone/ServerRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Ozone;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
use SocialDept\AtpClient\Http\Response;
7
8
class ServerRequestClient extends Request
9
{
10
/**
11
-
* Get blob
12
*
13
* @see https://docs.bsky.app/docs/api/tools-ozone-server-get-config
14
*/
15
public function getBlob(string $did, string $cid): Response
16
{
17
return $this->atp->client->get(
18
-
endpoint: 'tools.ozone.server.getBlob',
19
params: compact('did', 'cid')
20
);
21
}
···
23
/**
24
* Get config
25
*
26
* @see https://docs.bsky.app/docs/api/tools-ozone-server-get-config
27
*/
28
-
public function getConfig(): Response
29
{
30
-
return $this->atp->client->get(
31
-
endpoint: 'tools.ozone.server.getConfig'
32
);
33
}
34
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Ozone;
4
5
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Data\Responses\Ozone\Server\GetConfigResponse;
8
+
use SocialDept\AtpClient\Enums\Nsid\OzoneServer;
9
+
use SocialDept\AtpClient\Enums\Scope;
10
use SocialDept\AtpClient\Http\Response;
11
12
class ServerRequestClient extends Request
13
{
14
/**
15
+
* Get blob (returns binary data)
16
+
*
17
+
* @requires transition:generic (rpc:tools.ozone.server.getBlob)
18
*
19
* @see https://docs.bsky.app/docs/api/tools-ozone-server-get-config
20
*/
21
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.server.getBlob')]
22
public function getBlob(string $did, string $cid): Response
23
{
24
return $this->atp->client->get(
25
+
endpoint: OzoneServer::GetBlob,
26
params: compact('did', 'cid')
27
);
28
}
···
30
/**
31
* Get config
32
*
33
+
* @requires transition:generic (rpc:tools.ozone.server.getConfig)
34
+
*
35
* @see https://docs.bsky.app/docs/api/tools-ozone-server-get-config
36
*/
37
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.server.getConfig')]
38
+
public function getConfig(): GetConfigResponse
39
{
40
+
$response = $this->atp->client->get(
41
+
endpoint: OzoneServer::GetConfig
42
);
43
+
44
+
return GetConfigResponse::fromArray($response->json());
45
}
46
}
+46
-16
src/Client/Requests/Ozone/TeamRequestClient.php
+46
-16
src/Client/Requests/Ozone/TeamRequestClient.php
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Ozone;
4
5
use SocialDept\AtpClient\Client\Requests\Request;
6
-
use SocialDept\AtpClient\Http\Response;
7
8
class TeamRequestClient extends Request
9
{
10
/**
11
* Get team member
12
*
13
* @see https://docs.bsky.app/docs/api/tools-ozone-team-list-members
14
*/
15
-
public function getTeamMember(string $did): Response
16
{
17
-
return $this->atp->client->get(
18
-
endpoint: 'tools.ozone.team.getMember',
19
params: compact('did')
20
);
21
}
22
23
/**
24
* List team members
25
*
26
* @see https://docs.bsky.app/docs/api/tools-ozone-team-list-members
27
*/
28
-
public function listTeamMembers(int $limit = 50, ?string $cursor = null): Response
29
{
30
-
return $this->atp->client->get(
31
-
endpoint: 'tools.ozone.team.listMembers',
32
params: compact('limit', 'cursor')
33
);
34
}
35
36
/**
37
* Add team member
38
*
39
* @see https://docs.bsky.app/docs/api/tools-ozone-team-add-member
40
*/
41
-
public function addTeamMember(string $did, string $role): Response
42
{
43
-
return $this->atp->client->post(
44
-
endpoint: 'tools.ozone.team.addMember',
45
body: compact('did', 'role')
46
);
47
}
48
49
/**
50
* Update team member
51
*
52
* @see https://docs.bsky.app/docs/api/tools-ozone-team-update-member
53
*/
54
public function updateTeamMember(
55
string $did,
56
?bool $disabled = null,
57
?string $role = null
58
-
): Response {
59
-
return $this->atp->client->post(
60
-
endpoint: 'tools.ozone.team.updateMember',
61
body: array_filter(
62
compact('did', 'disabled', 'role'),
63
fn ($v) => ! is_null($v)
64
)
65
);
66
}
67
68
/**
69
* Delete team member
70
*
71
* @see https://docs.bsky.app/docs/api/tools-ozone-team-delete-member
72
*/
73
-
public function deleteTeamMember(string $did): Response
74
{
75
-
return $this->atp->client->post(
76
-
endpoint: 'tools.ozone.team.deleteMember',
77
body: compact('did')
78
);
79
}
80
}
···
2
3
namespace SocialDept\AtpClient\Client\Requests\Ozone;
4
5
+
use SocialDept\AtpClient\Attributes\ScopedEndpoint;
6
use SocialDept\AtpClient\Client\Requests\Request;
7
+
use SocialDept\AtpClient\Data\Responses\EmptyResponse;
8
+
use SocialDept\AtpClient\Data\Responses\Ozone\Team\ListMembersResponse;
9
+
use SocialDept\AtpClient\Data\Responses\Ozone\Team\MemberResponse;
10
+
use SocialDept\AtpClient\Enums\Nsid\OzoneTeam;
11
+
use SocialDept\AtpClient\Enums\Scope;
12
13
class TeamRequestClient extends Request
14
{
15
/**
16
* Get team member
17
*
18
+
* @requires transition:generic (rpc:tools.ozone.team.getMember)
19
+
*
20
* @see https://docs.bsky.app/docs/api/tools-ozone-team-list-members
21
*/
22
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.getMember')]
23
+
public function getTeamMember(string $did): MemberResponse
24
{
25
+
$response = $this->atp->client->get(
26
+
endpoint: OzoneTeam::GetMember,
27
params: compact('did')
28
);
29
+
30
+
return MemberResponse::fromArray($response->json());
31
}
32
33
/**
34
* List team members
35
+
*
36
+
* @requires transition:generic (rpc:tools.ozone.team.listMembers)
37
*
38
* @see https://docs.bsky.app/docs/api/tools-ozone-team-list-members
39
*/
40
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.listMembers')]
41
+
public function listTeamMembers(int $limit = 50, ?string $cursor = null): ListMembersResponse
42
{
43
+
$response = $this->atp->client->get(
44
+
endpoint: OzoneTeam::ListMembers,
45
params: compact('limit', 'cursor')
46
);
47
+
48
+
return ListMembersResponse::fromArray($response->json());
49
}
50
51
/**
52
* Add team member
53
+
*
54
+
* @requires transition:generic (rpc:tools.ozone.team.addMember)
55
*
56
* @see https://docs.bsky.app/docs/api/tools-ozone-team-add-member
57
*/
58
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.addMember')]
59
+
public function addTeamMember(string $did, string $role): MemberResponse
60
{
61
+
$response = $this->atp->client->post(
62
+
endpoint: OzoneTeam::AddMember,
63
body: compact('did', 'role')
64
);
65
+
66
+
return MemberResponse::fromArray($response->json());
67
}
68
69
/**
70
* Update team member
71
*
72
+
* @requires transition:generic (rpc:tools.ozone.team.updateMember)
73
+
*
74
* @see https://docs.bsky.app/docs/api/tools-ozone-team-update-member
75
*/
76
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.updateMember')]
77
public function updateTeamMember(
78
string $did,
79
?bool $disabled = null,
80
?string $role = null
81
+
): MemberResponse {
82
+
$response = $this->atp->client->post(
83
+
endpoint: OzoneTeam::UpdateMember,
84
body: array_filter(
85
compact('did', 'disabled', 'role'),
86
fn ($v) => ! is_null($v)
87
)
88
);
89
+
90
+
return MemberResponse::fromArray($response->json());
91
}
92
93
/**
94
* Delete team member
95
*
96
+
* @requires transition:generic (rpc:tools.ozone.team.deleteMember)
97
+
*
98
* @see https://docs.bsky.app/docs/api/tools-ozone-team-delete-member
99
*/
100
+
#[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.deleteMember')]
101
+
public function deleteTeamMember(string $did): EmptyResponse
102
{
103
+
$this->atp->client->post(
104
+
endpoint: OzoneTeam::DeleteMember,
105
body: compact('did')
106
);
107
+
108
+
return new EmptyResponse;
109
}
110
}
+2
-2
src/Client/Requests/Request.php
+2
-2
src/Client/Requests/Request.php
+76
src/Concerns/HasDomainExtensions.php
+76
src/Concerns/HasDomainExtensions.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Concerns;
4
+
5
+
use BadMethodCallException;
6
+
7
+
trait HasDomainExtensions
8
+
{
9
+
/**
10
+
* Resolved domain extension instances.
11
+
*
12
+
* @var array<string, object>
13
+
*/
14
+
protected array $resolvedDomainExtensions = [];
15
+
16
+
/**
17
+
* Get the domain name for this client.
18
+
*/
19
+
abstract protected function getDomainName(): string;
20
+
21
+
/**
22
+
* Get the root client class for extension lookup.
23
+
*/
24
+
abstract protected function getRootClientClass(): string;
25
+
26
+
/**
27
+
* Get the root client instance.
28
+
*/
29
+
abstract public function root(): object;
30
+
31
+
/**
32
+
* Resolve a domain extension instance.
33
+
*/
34
+
protected function resolveDomainExtension(string $name): object
35
+
{
36
+
if (! isset($this->resolvedDomainExtensions[$name])) {
37
+
$rootClass = $this->getRootClientClass();
38
+
$extensions = $rootClass::getDomainExtensionsFor($this->getDomainName());
39
+
$this->resolvedDomainExtensions[$name] = ($extensions[$name])($this);
40
+
}
41
+
42
+
return $this->resolvedDomainExtensions[$name];
43
+
}
44
+
45
+
/**
46
+
* Check if a domain extension exists.
47
+
*/
48
+
protected function hasDomainExtension(string $name): bool
49
+
{
50
+
$rootClass = $this->getRootClientClass();
51
+
52
+
return $rootClass::hasDomainExtension($this->getDomainName(), $name);
53
+
}
54
+
55
+
/**
56
+
* Magic getter for domain extension access.
57
+
*/
58
+
public function __get(string $name): mixed
59
+
{
60
+
if ($this->hasDomainExtension($name)) {
61
+
return $this->resolveDomainExtension($name);
62
+
}
63
+
64
+
throw new BadMethodCallException(
65
+
sprintf('Property [%s] does not exist on [%s].', $name, static::class)
66
+
);
67
+
}
68
+
69
+
/**
70
+
* Magic isset for domain extension checking.
71
+
*/
72
+
public function __isset(string $name): bool
73
+
{
74
+
return $this->hasDomainExtension($name);
75
+
}
76
+
}
+124
src/Concerns/HasExtensions.php
+124
src/Concerns/HasExtensions.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Concerns;
4
+
5
+
use BadMethodCallException;
6
+
use Closure;
7
+
8
+
trait HasExtensions
9
+
{
10
+
/**
11
+
* Registered domain client extensions.
12
+
*
13
+
* @var array<string, Closure>
14
+
*/
15
+
protected static array $extensions = [];
16
+
17
+
/**
18
+
* Registered request client extensions for existing domains.
19
+
*
20
+
* @var array<string, array<string, Closure>>
21
+
*/
22
+
protected static array $domainExtensions = [];
23
+
24
+
/**
25
+
* Resolved extension instances (lazy loading).
26
+
*
27
+
* @var array<string, object>
28
+
*/
29
+
protected array $resolvedExtensions = [];
30
+
31
+
/**
32
+
* Register a domain client extension.
33
+
*/
34
+
public static function extend(string $name, Closure $callback): void
35
+
{
36
+
static::$extensions[$name] = $callback;
37
+
}
38
+
39
+
/**
40
+
* Register a request client extension for an existing domain.
41
+
*/
42
+
public static function extendDomain(string $domain, string $name, Closure $callback): void
43
+
{
44
+
static::$domainExtensions[$domain][$name] = $callback;
45
+
}
46
+
47
+
/**
48
+
* Check if an extension is registered.
49
+
*/
50
+
public static function hasExtension(string $name): bool
51
+
{
52
+
return isset(static::$extensions[$name]);
53
+
}
54
+
55
+
/**
56
+
* Check if a domain extension is registered.
57
+
*/
58
+
public static function hasDomainExtension(string $domain, string $name): bool
59
+
{
60
+
return isset(static::$domainExtensions[$domain][$name]);
61
+
}
62
+
63
+
/**
64
+
* Get domain extensions for a specific domain.
65
+
*/
66
+
public static function getDomainExtensionsFor(string $domain): array
67
+
{
68
+
return static::$domainExtensions[$domain] ?? [];
69
+
}
70
+
71
+
/**
72
+
* Flush all registered extensions (useful for testing).
73
+
*/
74
+
public static function flushExtensions(): void
75
+
{
76
+
static::$extensions = [];
77
+
static::$domainExtensions = [];
78
+
}
79
+
80
+
/**
81
+
* Resolve an extension instance.
82
+
*/
83
+
protected function resolveExtension(string $name): object
84
+
{
85
+
if (! isset($this->resolvedExtensions[$name])) {
86
+
$this->resolvedExtensions[$name] = (static::$extensions[$name])($this);
87
+
}
88
+
89
+
return $this->resolvedExtensions[$name];
90
+
}
91
+
92
+
/**
93
+
* Magic getter for extension access.
94
+
*/
95
+
public function __get(string $name): mixed
96
+
{
97
+
if (static::hasExtension($name)) {
98
+
return $this->resolveExtension($name);
99
+
}
100
+
101
+
throw new BadMethodCallException(
102
+
sprintf('Property [%s] does not exist on [%s].', $name, static::class)
103
+
);
104
+
}
105
+
106
+
/**
107
+
* Magic isset for extension checking.
108
+
*/
109
+
public function __isset(string $name): bool
110
+
{
111
+
return static::hasExtension($name);
112
+
}
113
+
114
+
/**
115
+
* Get the root client instance.
116
+
*
117
+
* For root clients, this returns itself.
118
+
* Domain clients override this to return their parent.
119
+
*/
120
+
public function root(): static
121
+
{
122
+
return $this;
123
+
}
124
+
}
+123
src/Console/MakeAtpClientCommand.php
+123
src/Console/MakeAtpClientCommand.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Console;
4
+
5
+
use Illuminate\Console\Command;
6
+
use Illuminate\Filesystem\Filesystem;
7
+
use Illuminate\Support\Str;
8
+
9
+
class MakeAtpClientCommand extends Command
10
+
{
11
+
protected $signature = 'make:atp-client
12
+
{name : The name of the client class}
13
+
{--force : Overwrite existing file}';
14
+
15
+
protected $description = 'Create a new ATP domain client extension';
16
+
17
+
public function __construct(protected Filesystem $files)
18
+
{
19
+
parent::__construct();
20
+
}
21
+
22
+
public function handle(): int
23
+
{
24
+
$name = $this->argument('name');
25
+
26
+
if (! Str::endsWith($name, 'Client')) {
27
+
$name .= 'Client';
28
+
}
29
+
30
+
$path = $this->getPath($name);
31
+
32
+
if ($this->files->exists($path) && ! $this->option('force')) {
33
+
$this->components->error("Client [{$name}] already exists!");
34
+
35
+
return self::FAILURE;
36
+
}
37
+
38
+
$this->makeDirectory($path);
39
+
40
+
$content = $this->populateStub($this->getStub(), $name);
41
+
42
+
$this->files->put($path, $content);
43
+
44
+
$this->components->info("Client [{$path}] created successfully.");
45
+
46
+
$this->outputRegistrationHint($name);
47
+
48
+
return self::SUCCESS;
49
+
}
50
+
51
+
protected function getPath(string $name): string
52
+
{
53
+
$basePath = config('client.generators.client_path', 'app/Services/Clients');
54
+
55
+
return base_path($basePath.'/'.$name.'.php');
56
+
}
57
+
58
+
protected function makeDirectory(string $path): void
59
+
{
60
+
if (! $this->files->isDirectory(dirname($path))) {
61
+
$this->files->makeDirectory(dirname($path), 0755, true);
62
+
}
63
+
}
64
+
65
+
protected function getNamespace(): string
66
+
{
67
+
$basePath = config('client.generators.client_path', 'app/Services/Clients');
68
+
69
+
return Str::of($basePath)
70
+
->replace('/', '\\')
71
+
->ucfirst()
72
+
->replace('App', 'App')
73
+
->toString();
74
+
}
75
+
76
+
protected function populateStub(string $stub, string $name): string
77
+
{
78
+
return str_replace(
79
+
['{{ namespace }}', '{{ class }}'],
80
+
[$this->getNamespace(), $name],
81
+
$stub
82
+
);
83
+
}
84
+
85
+
protected function outputRegistrationHint(string $name): void
86
+
{
87
+
$this->newLine();
88
+
$this->components->info('Register the extension in your AppServiceProvider:');
89
+
$this->newLine();
90
+
91
+
$namespace = $this->getNamespace();
92
+
$extensionName = Str::of($name)->before('Client')->camel()->toString();
93
+
94
+
$this->line("use {$namespace}\\{$name};");
95
+
$this->line("use SocialDept\\AtpClient\\AtpClient;");
96
+
$this->newLine();
97
+
$this->line("// In boot() method:");
98
+
$this->line("AtpClient::extend('{$extensionName}', fn(AtpClient \$atp) => new {$name}(\$atp));");
99
+
}
100
+
101
+
protected function getStub(): string
102
+
{
103
+
return <<<'STUB'
104
+
<?php
105
+
106
+
namespace {{ namespace }};
107
+
108
+
use SocialDept\AtpClient\AtpClient;
109
+
110
+
class {{ class }}
111
+
{
112
+
protected AtpClient $atp;
113
+
114
+
public function __construct(AtpClient $parent)
115
+
{
116
+
$this->atp = $parent;
117
+
}
118
+
119
+
//
120
+
}
121
+
STUB;
122
+
}
123
+
}
+126
src/Console/MakeAtpRequestCommand.php
+126
src/Console/MakeAtpRequestCommand.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Console;
4
+
5
+
use Illuminate\Console\Command;
6
+
use Illuminate\Filesystem\Filesystem;
7
+
use Illuminate\Support\Str;
8
+
9
+
class MakeAtpRequestCommand extends Command
10
+
{
11
+
protected $signature = 'make:atp-request
12
+
{name : The name of the request client class}
13
+
{--domain=bsky : The domain to extend (bsky, atproto, chat, ozone)}
14
+
{--force : Overwrite existing file}';
15
+
16
+
protected $description = 'Create a new ATP request client extension for an existing domain';
17
+
18
+
protected array $validDomains = ['bsky', 'atproto', 'chat', 'ozone'];
19
+
20
+
public function __construct(protected Filesystem $files)
21
+
{
22
+
parent::__construct();
23
+
}
24
+
25
+
public function handle(): int
26
+
{
27
+
$name = $this->argument('name');
28
+
$domain = $this->option('domain');
29
+
30
+
if (! in_array($domain, $this->validDomains)) {
31
+
$this->components->error("Invalid domain [{$domain}]. Valid domains: ".implode(', ', $this->validDomains));
32
+
33
+
return self::FAILURE;
34
+
}
35
+
36
+
if (! Str::endsWith($name, 'Client')) {
37
+
$name .= 'Client';
38
+
}
39
+
40
+
$path = $this->getPath($name);
41
+
42
+
if ($this->files->exists($path) && ! $this->option('force')) {
43
+
$this->components->error("Request client [{$name}] already exists!");
44
+
45
+
return self::FAILURE;
46
+
}
47
+
48
+
$this->makeDirectory($path);
49
+
50
+
$content = $this->populateStub($this->getStub(), $name);
51
+
52
+
$this->files->put($path, $content);
53
+
54
+
$this->components->info("Request client [{$path}] created successfully.");
55
+
56
+
$this->outputRegistrationHint($name, $domain);
57
+
58
+
return self::SUCCESS;
59
+
}
60
+
61
+
protected function getPath(string $name): string
62
+
{
63
+
$basePath = config('client.generators.request_path', 'app/Services/Clients/Requests');
64
+
65
+
return base_path($basePath.'/'.$name.'.php');
66
+
}
67
+
68
+
protected function makeDirectory(string $path): void
69
+
{
70
+
if (! $this->files->isDirectory(dirname($path))) {
71
+
$this->files->makeDirectory(dirname($path), 0755, true);
72
+
}
73
+
}
74
+
75
+
protected function getNamespace(): string
76
+
{
77
+
$basePath = config('client.generators.request_path', 'app/Services/Clients/Requests');
78
+
79
+
return Str::of($basePath)
80
+
->replace('/', '\\')
81
+
->ucfirst()
82
+
->replace('App', 'App')
83
+
->toString();
84
+
}
85
+
86
+
protected function populateStub(string $stub, string $name): string
87
+
{
88
+
return str_replace(
89
+
['{{ namespace }}', '{{ class }}'],
90
+
[$this->getNamespace(), $name],
91
+
$stub
92
+
);
93
+
}
94
+
95
+
protected function outputRegistrationHint(string $name, string $domain): void
96
+
{
97
+
$this->newLine();
98
+
$this->components->info('Register the extension in your AppServiceProvider:');
99
+
$this->newLine();
100
+
101
+
$namespace = $this->getNamespace();
102
+
$extensionName = Str::of($name)->before('Client')->camel()->toString();
103
+
104
+
$this->line("use {$namespace}\\{$name};");
105
+
$this->line("use SocialDept\\AtpClient\\AtpClient;");
106
+
$this->newLine();
107
+
$this->line("// In boot() method:");
108
+
$this->line("AtpClient::extendDomain('{$domain}', '{$extensionName}', fn(\$domain) => new {$name}(\$domain));");
109
+
}
110
+
111
+
protected function getStub(): string
112
+
{
113
+
return <<<'STUB'
114
+
<?php
115
+
116
+
namespace {{ namespace }};
117
+
118
+
use SocialDept\AtpClient\Client\Requests\Request;
119
+
120
+
class {{ class }} extends Request
121
+
{
122
+
//
123
+
}
124
+
STUB;
125
+
}
126
+
}
+5
-5
src/Contracts/CredentialProvider.php
+5
-5
src/Contracts/CredentialProvider.php
···
8
interface CredentialProvider
9
{
10
/**
11
-
* Get credentials for the given identifier
12
*/
13
-
public function getCredentials(string $identifier): ?Credentials;
14
15
/**
16
* Store new credentials (initial OAuth or app password login)
17
*/
18
-
public function storeCredentials(string $identifier, AccessToken $token): void;
19
20
/**
21
* Update credentials after token refresh
22
* CRITICAL: Refresh tokens are single-use!
23
*/
24
-
public function updateCredentials(string $identifier, AccessToken $token): void;
25
26
/**
27
* Remove credentials
28
*/
29
-
public function removeCredentials(string $identifier): void;
30
}
···
8
interface CredentialProvider
9
{
10
/**
11
+
* Get credentials for the given DID
12
*/
13
+
public function getCredentials(string $did): ?Credentials;
14
15
/**
16
* Store new credentials (initial OAuth or app password login)
17
*/
18
+
public function storeCredentials(string $did, AccessToken $token): void;
19
20
/**
21
* Update credentials after token refresh
22
* CRITICAL: Refresh tokens are single-use!
23
*/
24
+
public function updateCredentials(string $did, AccessToken $token): void;
25
26
/**
27
* Remove credentials
28
*/
29
+
public function removeCredentials(string $did): void;
30
}
+11
src/Contracts/HasAtpSession.php
+11
src/Contracts/HasAtpSession.php
+56
-3
src/Data/AccessToken.php
+56
-3
src/Data/AccessToken.php
···
2
3
namespace SocialDept\AtpClient\Data;
4
5
class AccessToken
6
{
7
public function __construct(
···
10
public readonly string $did,
11
public readonly \DateTimeInterface $expiresAt,
12
public readonly ?string $handle = null,
13
) {}
14
15
-
public static function fromResponse(array $data): self
16
{
17
return new self(
18
accessJwt: $data['accessJwt'],
19
refreshJwt: $data['refreshJwt'],
20
did: $data['did'],
21
-
expiresAt: now()->addSeconds($data['expiresIn'] ?? 300),
22
-
handle: $data['handle'] ?? null,
23
);
24
}
25
}
···
2
3
namespace SocialDept\AtpClient\Data;
4
5
+
use Carbon\Carbon;
6
+
use SocialDept\AtpClient\Enums\AuthType;
7
+
8
class AccessToken
9
{
10
public function __construct(
···
13
public readonly string $did,
14
public readonly \DateTimeInterface $expiresAt,
15
public readonly ?string $handle = null,
16
+
public readonly ?string $issuer = null,
17
+
public readonly array $scope = [],
18
+
public readonly AuthType $authType = AuthType::OAuth,
19
) {}
20
21
+
/**
22
+
* Create from API response.
23
+
*
24
+
* Handles both legacy createSession format (accessJwt, refreshJwt, did)
25
+
* and OAuth token format (access_token, refresh_token, sub).
26
+
*/
27
+
public static function fromResponse(array $data, ?string $handle = null, ?string $issuer = null): self
28
{
29
+
// OAuth token endpoint format
30
+
if (isset($data['access_token'])) {
31
+
return new self(
32
+
accessJwt: $data['access_token'],
33
+
refreshJwt: $data['refresh_token'] ?? '',
34
+
did: $data['sub'] ?? '',
35
+
expiresAt: now()->addSeconds($data['expires_in'] ?? 300),
36
+
handle: $handle,
37
+
issuer: $issuer,
38
+
scope: isset($data['scope']) ? explode(' ', $data['scope']) : [],
39
+
authType: AuthType::OAuth,
40
+
);
41
+
}
42
+
43
+
// Legacy createSession format (app passwords have full access)
44
+
// Parse expiry from JWT since createSession doesn't return expiresIn
45
+
$expiresAt = self::parseJwtExpiry($data['accessJwt']) ?? now()->addHour();
46
+
47
return new self(
48
accessJwt: $data['accessJwt'],
49
refreshJwt: $data['refreshJwt'],
50
did: $data['did'],
51
+
expiresAt: $expiresAt,
52
+
handle: $data['handle'] ?? $handle,
53
+
issuer: $issuer,
54
+
scope: ['atproto', 'transition:generic', 'transition:email'],
55
+
authType: AuthType::Legacy,
56
);
57
+
}
58
+
59
+
/**
60
+
* Parse the expiry timestamp from a JWT's payload.
61
+
*/
62
+
protected static function parseJwtExpiry(string $jwt): ?\DateTimeInterface
63
+
{
64
+
$parts = explode('.', $jwt);
65
+
66
+
if (count($parts) !== 3) {
67
+
return null;
68
+
}
69
+
70
+
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
71
+
72
+
if (! isset($payload['exp'])) {
73
+
return null;
74
+
}
75
+
76
+
return Carbon::createFromTimestamp($payload['exp']);
77
}
78
}
+2
src/Data/AuthorizationRequest.php
+2
src/Data/AuthorizationRequest.php
+6
-1
src/Data/Credentials.php
+6
-1
src/Data/Credentials.php
···
2
3
namespace SocialDept\AtpClient\Data;
4
5
class Credentials
6
{
7
public function __construct(
8
-
public readonly string $identifier,
9
public readonly string $did,
10
public readonly string $accessToken,
11
public readonly string $refreshToken,
12
public readonly \DateTimeInterface $expiresAt,
13
) {}
14
15
public function isExpired(): bool
···
2
3
namespace SocialDept\AtpClient\Data;
4
5
+
use SocialDept\AtpClient\Enums\AuthType;
6
+
7
class Credentials
8
{
9
public function __construct(
10
public readonly string $did,
11
public readonly string $accessToken,
12
public readonly string $refreshToken,
13
public readonly \DateTimeInterface $expiresAt,
14
+
public readonly ?string $handle = null,
15
+
public readonly ?string $issuer = null,
16
+
public readonly array $scope = [],
17
+
public readonly AuthType $authType = AuthType::OAuth,
18
) {}
19
20
public function isExpired(): bool
+30
-6
src/Data/DPoPKey.php
+30
-6
src/Data/DPoPKey.php
···
4
5
use phpseclib3\Crypt\Common\PrivateKey;
6
use phpseclib3\Crypt\Common\PublicKey;
7
8
class DPoPKey
9
{
10
public function __construct(
11
-
public readonly PrivateKey $privateKey,
12
-
public readonly PublicKey $publicKey,
13
public readonly string $keyId,
14
-
) {}
15
16
public function getPublicJwk(): array
17
{
18
-
$jwks = json_decode($this->publicKey->toString('JWK'), true);
19
20
// phpseclib returns JWKS format {"keys":[...]}, extract the first key
21
$jwk = $jwks['keys'][0] ?? $jwks;
···
32
33
public function getPrivateJwk(): array
34
{
35
-
$jwks = json_decode($this->privateKey->toString('JWK'), true);
36
37
// phpseclib returns JWKS format {"keys":[...]}, extract the first key
38
$jwk = $jwks['keys'][0] ?? $jwks;
···
49
50
public function toPEM(): string
51
{
52
-
return $this->privateKey->toString('PKCS8');
53
}
54
}
···
4
5
use phpseclib3\Crypt\Common\PrivateKey;
6
use phpseclib3\Crypt\Common\PublicKey;
7
+
use phpseclib3\Crypt\PublicKeyLoader;
8
9
class DPoPKey
10
{
11
+
protected string $privateKeyPem;
12
+
13
+
protected string $publicKeyPem;
14
+
15
public function __construct(
16
+
PrivateKey|string $privateKey,
17
+
PublicKey|string $publicKey,
18
public readonly string $keyId,
19
+
) {
20
+
// Store as PEM strings for serialization
21
+
$this->privateKeyPem = $privateKey instanceof PrivateKey
22
+
? $privateKey->toString('PKCS8')
23
+
: $privateKey;
24
+
25
+
$this->publicKeyPem = $publicKey instanceof PublicKey
26
+
? $publicKey->toString('PKCS8')
27
+
: $publicKey;
28
+
}
29
+
30
+
public function getPrivateKey(): PrivateKey
31
+
{
32
+
return PublicKeyLoader::load($this->privateKeyPem);
33
+
}
34
+
35
+
public function getPublicKey(): PublicKey
36
+
{
37
+
return PublicKeyLoader::load($this->publicKeyPem);
38
+
}
39
40
public function getPublicJwk(): array
41
{
42
+
$jwks = json_decode($this->getPublicKey()->toString('JWK'), true);
43
44
// phpseclib returns JWKS format {"keys":[...]}, extract the first key
45
$jwk = $jwks['keys'][0] ?? $jwks;
···
56
57
public function getPrivateJwk(): array
58
{
59
+
$jwks = json_decode($this->getPrivateKey()->toString('JWK'), true);
60
61
// phpseclib returns JWKS format {"keys":[...]}, extract the first key
62
$jwk = $jwks['keys'][0] ?? $jwks;
···
73
74
public function toPEM(): string
75
{
76
+
return $this->privateKeyPem;
77
}
78
}
+61
src/Data/Record.php
+61
src/Data/Record.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* Generic wrapper for AT Protocol records.
9
+
*
10
+
* @template T
11
+
* @implements Arrayable<string, mixed>
12
+
*/
13
+
class Record implements Arrayable
14
+
{
15
+
/**
16
+
* @param T $value
17
+
*/
18
+
public function __construct(
19
+
public readonly string $uri,
20
+
public readonly string $cid,
21
+
public readonly mixed $value,
22
+
) {}
23
+
24
+
/**
25
+
* @template U
26
+
* @param array $data
27
+
* @param callable(array): U $transformer
28
+
* @return self<U>
29
+
*/
30
+
public static function fromArray(array $data, callable $transformer): self
31
+
{
32
+
return new self(
33
+
uri: $data['uri'],
34
+
cid: $data['cid'],
35
+
value: $transformer($data['value']),
36
+
);
37
+
}
38
+
39
+
/**
40
+
* Create without transforming value.
41
+
*/
42
+
public static function fromArrayRaw(array $data): self
43
+
{
44
+
return new self(
45
+
uri: $data['uri'],
46
+
cid: $data['cid'],
47
+
value: $data['value'],
48
+
);
49
+
}
50
+
51
+
public function toArray(): array
52
+
{
53
+
return [
54
+
'uri' => $this->uri,
55
+
'cid' => $this->cid,
56
+
'value' => $this->value instanceof Arrayable
57
+
? $this->value->toArray()
58
+
: $this->value,
59
+
];
60
+
}
61
+
}
+60
src/Data/RecordCollection.php
+60
src/Data/RecordCollection.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
8
+
/**
9
+
* Collection wrapper for paginated AT Protocol records.
10
+
*
11
+
* @template T
12
+
* @implements Arrayable<string, mixed>
13
+
*/
14
+
class RecordCollection implements Arrayable
15
+
{
16
+
/**
17
+
* @param Collection<int, Record<T>> $records
18
+
*/
19
+
public function __construct(
20
+
public readonly Collection $records,
21
+
public readonly ?string $cursor = null,
22
+
) {}
23
+
24
+
/**
25
+
* @template U
26
+
* @param array $data
27
+
* @param callable(array): U $transformer
28
+
* @return self<U>
29
+
*/
30
+
public static function fromArray(array $data, callable $transformer): self
31
+
{
32
+
return new self(
33
+
records: collect($data['records'] ?? [])->map(
34
+
fn (array $record) => Record::fromArray($record, $transformer)
35
+
),
36
+
cursor: $data['cursor'] ?? null,
37
+
);
38
+
}
39
+
40
+
/**
41
+
* Create without transforming values.
42
+
*/
43
+
public static function fromArrayRaw(array $data): self
44
+
{
45
+
return new self(
46
+
records: collect($data['records'] ?? [])->map(
47
+
fn (array $record) => Record::fromArrayRaw($record)
48
+
),
49
+
cursor: $data['cursor'] ?? null,
50
+
);
51
+
}
52
+
53
+
public function toArray(): array
54
+
{
55
+
return [
56
+
'records' => $this->records->map(fn (Record $r) => $r->toArray())->all(),
57
+
'cursor' => $this->cursor,
58
+
];
59
+
}
60
+
}
+29
src/Data/Responses/Atproto/Identity/ResolveHandleResponse.php
+29
src/Data/Responses/Atproto/Identity/ResolveHandleResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Identity;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, string>
9
+
*/
10
+
class ResolveHandleResponse implements Arrayable
11
+
{
12
+
public function __construct(
13
+
public readonly string $did,
14
+
) {}
15
+
16
+
public static function fromArray(array $data): self
17
+
{
18
+
return new self(
19
+
did: $data['did'],
20
+
);
21
+
}
22
+
23
+
public function toArray(): array
24
+
{
25
+
return [
26
+
'did' => $this->did,
27
+
];
28
+
}
29
+
}
+39
src/Data/Responses/Atproto/Repo/CreateRecordResponse.php
+39
src/Data/Responses/Atproto/Repo/CreateRecordResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\Defs\CommitMeta;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class CreateRecordResponse implements Arrayable
12
+
{
13
+
public function __construct(
14
+
public readonly string $uri,
15
+
public readonly string $cid,
16
+
public readonly ?CommitMeta $commit = null,
17
+
public readonly ?string $validationStatus = null,
18
+
) {}
19
+
20
+
public static function fromArray(array $data): self
21
+
{
22
+
return new self(
23
+
uri: $data['uri'],
24
+
cid: $data['cid'],
25
+
commit: isset($data['commit']) ? CommitMeta::fromArray($data['commit']) : null,
26
+
validationStatus: $data['validationStatus'] ?? null,
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return [
33
+
'uri' => $this->uri,
34
+
'cid' => $this->cid,
35
+
'commit' => $this->commit?->toArray(),
36
+
'validationStatus' => $this->validationStatus,
37
+
];
38
+
}
39
+
}
+30
src/Data/Responses/Atproto/Repo/DeleteRecordResponse.php
+30
src/Data/Responses/Atproto/Repo/DeleteRecordResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\Defs\CommitMeta;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class DeleteRecordResponse implements Arrayable
12
+
{
13
+
public function __construct(
14
+
public readonly ?CommitMeta $commit = null,
15
+
) {}
16
+
17
+
public static function fromArray(array $data): self
18
+
{
19
+
return new self(
20
+
commit: isset($data['commit']) ? CommitMeta::fromArray($data['commit']) : null,
21
+
);
22
+
}
23
+
24
+
public function toArray(): array
25
+
{
26
+
return [
27
+
'commit' => $this->commit?->toArray(),
28
+
];
29
+
}
30
+
}
+44
src/Data/Responses/Atproto/Repo/DescribeRepoResponse.php
+44
src/Data/Responses/Atproto/Repo/DescribeRepoResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, mixed>
9
+
*/
10
+
class DescribeRepoResponse implements Arrayable
11
+
{
12
+
/**
13
+
* @param array<string> $collections
14
+
*/
15
+
public function __construct(
16
+
public readonly string $handle,
17
+
public readonly string $did,
18
+
public readonly mixed $didDoc,
19
+
public readonly array $collections,
20
+
public readonly bool $handleIsCorrect,
21
+
) {}
22
+
23
+
public static function fromArray(array $data): self
24
+
{
25
+
return new self(
26
+
handle: $data['handle'],
27
+
did: $data['did'],
28
+
didDoc: $data['didDoc'],
29
+
collections: $data['collections'] ?? [],
30
+
handleIsCorrect: $data['handleIsCorrect'],
31
+
);
32
+
}
33
+
34
+
public function toArray(): array
35
+
{
36
+
return [
37
+
'handle' => $this->handle,
38
+
'did' => $this->did,
39
+
'didDoc' => $this->didDoc,
40
+
'collections' => $this->collections,
41
+
'handleIsCorrect' => $this->handleIsCorrect,
42
+
];
43
+
}
44
+
}
+35
src/Data/Responses/Atproto/Repo/GetRecordResponse.php
+35
src/Data/Responses/Atproto/Repo/GetRecordResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, mixed>
9
+
*/
10
+
class GetRecordResponse implements Arrayable
11
+
{
12
+
public function __construct(
13
+
public readonly string $uri,
14
+
public readonly mixed $value,
15
+
public readonly ?string $cid = null,
16
+
) {}
17
+
18
+
public static function fromArray(array $data): self
19
+
{
20
+
return new self(
21
+
uri: $data['uri'],
22
+
value: $data['value'],
23
+
cid: $data['cid'] ?? null,
24
+
);
25
+
}
26
+
27
+
public function toArray(): array
28
+
{
29
+
return [
30
+
'uri' => $this->uri,
31
+
'value' => $this->value,
32
+
'cid' => $this->cid,
33
+
];
34
+
}
35
+
}
+36
src/Data/Responses/Atproto/Repo/ListRecordsResponse.php
+36
src/Data/Responses/Atproto/Repo/ListRecordsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class ListRecordsResponse implements Arrayable
12
+
{
13
+
/**
14
+
* @param Collection<int, array{uri: string, cid: string, value: mixed}> $records
15
+
*/
16
+
public function __construct(
17
+
public readonly Collection $records,
18
+
public readonly ?string $cursor = null,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
records: collect($data['records'] ?? []),
25
+
cursor: $data['cursor'] ?? null,
26
+
);
27
+
}
28
+
29
+
public function toArray(): array
30
+
{
31
+
return [
32
+
'records' => $this->records->all(),
33
+
'cursor' => $this->cursor,
34
+
];
35
+
}
36
+
}
+39
src/Data/Responses/Atproto/Repo/PutRecordResponse.php
+39
src/Data/Responses/Atproto/Repo/PutRecordResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Repo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use SocialDept\AtpSchema\Generated\Com\Atproto\Repo\Defs\CommitMeta;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class PutRecordResponse implements Arrayable
12
+
{
13
+
public function __construct(
14
+
public readonly string $uri,
15
+
public readonly string $cid,
16
+
public readonly ?CommitMeta $commit = null,
17
+
public readonly ?string $validationStatus = null,
18
+
) {}
19
+
20
+
public static function fromArray(array $data): self
21
+
{
22
+
return new self(
23
+
uri: $data['uri'],
24
+
cid: $data['cid'],
25
+
commit: isset($data['commit']) ? CommitMeta::fromArray($data['commit']) : null,
26
+
validationStatus: $data['validationStatus'] ?? null,
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return [
33
+
'uri' => $this->uri,
34
+
'cid' => $this->cid,
35
+
'commit' => $this->commit?->toArray(),
36
+
'validationStatus' => $this->validationStatus,
37
+
];
38
+
}
39
+
}
+47
src/Data/Responses/Atproto/Server/DescribeServerResponse.php
+47
src/Data/Responses/Atproto/Server/DescribeServerResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Server;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, mixed>
9
+
*/
10
+
class DescribeServerResponse implements Arrayable
11
+
{
12
+
/**
13
+
* @param array<string> $availableUserDomains
14
+
*/
15
+
public function __construct(
16
+
public readonly string $did,
17
+
public readonly array $availableUserDomains,
18
+
public readonly ?bool $inviteCodeRequired = null,
19
+
public readonly ?bool $phoneVerificationRequired = null,
20
+
public readonly ?array $links = null,
21
+
public readonly ?array $contact = null,
22
+
) {}
23
+
24
+
public static function fromArray(array $data): self
25
+
{
26
+
return new self(
27
+
did: $data['did'],
28
+
availableUserDomains: $data['availableUserDomains'] ?? [],
29
+
inviteCodeRequired: $data['inviteCodeRequired'] ?? null,
30
+
phoneVerificationRequired: $data['phoneVerificationRequired'] ?? null,
31
+
links: $data['links'] ?? null,
32
+
contact: $data['contact'] ?? null,
33
+
);
34
+
}
35
+
36
+
public function toArray(): array
37
+
{
38
+
return [
39
+
'did' => $this->did,
40
+
'availableUserDomains' => $this->availableUserDomains,
41
+
'inviteCodeRequired' => $this->inviteCodeRequired,
42
+
'phoneVerificationRequired' => $this->phoneVerificationRequired,
43
+
'links' => $this->links,
44
+
'contact' => $this->contact,
45
+
];
46
+
}
47
+
}
+50
src/Data/Responses/Atproto/Server/GetSessionResponse.php
+50
src/Data/Responses/Atproto/Server/GetSessionResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Server;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, mixed>
9
+
*/
10
+
class GetSessionResponse implements Arrayable
11
+
{
12
+
public function __construct(
13
+
public readonly string $handle,
14
+
public readonly string $did,
15
+
public readonly ?string $email = null,
16
+
public readonly ?bool $emailConfirmed = null,
17
+
public readonly ?bool $emailAuthFactor = null,
18
+
public readonly mixed $didDoc = null,
19
+
public readonly ?bool $active = null,
20
+
public readonly ?string $status = null,
21
+
) {}
22
+
23
+
public static function fromArray(array $data): self
24
+
{
25
+
return new self(
26
+
handle: $data['handle'],
27
+
did: $data['did'],
28
+
email: $data['email'] ?? null,
29
+
emailConfirmed: $data['emailConfirmed'] ?? null,
30
+
emailAuthFactor: $data['emailAuthFactor'] ?? null,
31
+
didDoc: $data['didDoc'] ?? null,
32
+
active: $data['active'] ?? null,
33
+
status: $data['status'] ?? null,
34
+
);
35
+
}
36
+
37
+
public function toArray(): array
38
+
{
39
+
return [
40
+
'handle' => $this->handle,
41
+
'did' => $this->did,
42
+
'email' => $this->email,
43
+
'emailConfirmed' => $this->emailConfirmed,
44
+
'emailAuthFactor' => $this->emailAuthFactor,
45
+
'didDoc' => $this->didDoc,
46
+
'active' => $this->active,
47
+
'status' => $this->status,
48
+
];
49
+
}
50
+
}
+38
src/Data/Responses/Atproto/Sync/GetRepoStatusResponse.php
+38
src/Data/Responses/Atproto/Sync/GetRepoStatusResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Sync;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, mixed>
9
+
*/
10
+
class GetRepoStatusResponse implements Arrayable
11
+
{
12
+
public function __construct(
13
+
public readonly string $did,
14
+
public readonly bool $active,
15
+
public readonly ?string $status = null,
16
+
public readonly ?string $rev = null,
17
+
) {}
18
+
19
+
public static function fromArray(array $data): self
20
+
{
21
+
return new self(
22
+
did: $data['did'],
23
+
active: $data['active'],
24
+
status: $data['status'] ?? null,
25
+
rev: $data['rev'] ?? null,
26
+
);
27
+
}
28
+
29
+
public function toArray(): array
30
+
{
31
+
return [
32
+
'did' => $this->did,
33
+
'active' => $this->active,
34
+
'status' => $this->status,
35
+
'rev' => $this->rev,
36
+
];
37
+
}
38
+
}
+35
src/Data/Responses/Atproto/Sync/ListBlobsResponse.php
+35
src/Data/Responses/Atproto/Sync/ListBlobsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Sync;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, mixed>
9
+
*/
10
+
class ListBlobsResponse implements Arrayable
11
+
{
12
+
/**
13
+
* @param array<string> $cids
14
+
*/
15
+
public function __construct(
16
+
public readonly array $cids,
17
+
public readonly ?string $cursor = null,
18
+
) {}
19
+
20
+
public static function fromArray(array $data): self
21
+
{
22
+
return new self(
23
+
cids: $data['cids'] ?? [],
24
+
cursor: $data['cursor'] ?? null,
25
+
);
26
+
}
27
+
28
+
public function toArray(): array
29
+
{
30
+
return [
31
+
'cids' => $this->cids,
32
+
'cursor' => $this->cursor,
33
+
];
34
+
}
35
+
}
+36
src/Data/Responses/Atproto/Sync/ListReposByCollectionResponse.php
+36
src/Data/Responses/Atproto/Sync/ListReposByCollectionResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Sync;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class ListReposByCollectionResponse implements Arrayable
12
+
{
13
+
/**
14
+
* @param Collection<int, array{did: string, rev: string}> $repos
15
+
*/
16
+
public function __construct(
17
+
public readonly Collection $repos,
18
+
public readonly ?string $cursor = null,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
repos: collect($data['repos'] ?? []),
25
+
cursor: $data['cursor'] ?? null,
26
+
);
27
+
}
28
+
29
+
public function toArray(): array
30
+
{
31
+
return [
32
+
'repos' => $this->repos->all(),
33
+
'cursor' => $this->cursor,
34
+
];
35
+
}
36
+
}
+36
src/Data/Responses/Atproto/Sync/ListReposResponse.php
+36
src/Data/Responses/Atproto/Sync/ListReposResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Atproto\Sync;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class ListReposResponse implements Arrayable
12
+
{
13
+
/**
14
+
* @param Collection<int, array{did: string, head: string, rev: string, active?: bool, status?: string}> $repos
15
+
*/
16
+
public function __construct(
17
+
public readonly Collection $repos,
18
+
public readonly ?string $cursor = null,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
repos: collect($data['repos'] ?? []),
25
+
cursor: $data['cursor'] ?? null,
26
+
);
27
+
}
28
+
29
+
public function toArray(): array
30
+
{
31
+
return [
32
+
'repos' => $this->repos->all(),
33
+
'cursor' => $this->cursor,
34
+
];
35
+
}
36
+
}
+36
src/Data/Responses/Bsky/Actor/GetProfilesResponse.php
+36
src/Data/Responses/Bsky/Actor/GetProfilesResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Actor;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileViewDetailed;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetProfilesResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ProfileViewDetailed> $profiles
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $profiles,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
profiles: collect($data['profiles'] ?? [])->map(
25
+
fn (array $profile) => ProfileViewDetailed::fromArray($profile)
26
+
),
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return [
33
+
'profiles' => $this->profiles->map(fn (ProfileViewDetailed $p) => $p->toArray())->all(),
34
+
];
35
+
}
36
+
}
+39
src/Data/Responses/Bsky/Actor/GetSuggestionsResponse.php
+39
src/Data/Responses/Bsky/Actor/GetSuggestionsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Actor;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetSuggestionsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ProfileView> $actors
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $actors,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
actors: collect($data['actors'] ?? [])->map(
26
+
fn (array $actor) => ProfileView::fromArray($actor)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'actors' => $this->actors->map(fn (ProfileView $a) => $a->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+39
src/Data/Responses/Bsky/Actor/SearchActorsResponse.php
+39
src/Data/Responses/Bsky/Actor/SearchActorsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Actor;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class SearchActorsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ProfileView> $actors
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $actors,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
actors: collect($data['actors'] ?? [])->map(
26
+
fn (array $actor) => ProfileView::fromArray($actor)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'actors' => $this->actors->map(fn (ProfileView $a) => $a->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+36
src/Data/Responses/Bsky/Actor/SearchActorsTypeaheadResponse.php
+36
src/Data/Responses/Bsky/Actor/SearchActorsTypeaheadResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Actor;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileViewBasic;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class SearchActorsTypeaheadResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ProfileViewBasic> $actors
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $actors,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
actors: collect($data['actors'] ?? [])->map(
25
+
fn (array $actor) => ProfileViewBasic::fromArray($actor)
26
+
),
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return [
33
+
'actors' => $this->actors->map(fn (ProfileViewBasic $a) => $a->toArray())->all(),
34
+
];
35
+
}
36
+
}
+38
src/Data/Responses/Bsky/Feed/DescribeFeedGeneratorResponse.php
+38
src/Data/Responses/Bsky/Feed/DescribeFeedGeneratorResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, mixed>
9
+
*/
10
+
class DescribeFeedGeneratorResponse implements Arrayable
11
+
{
12
+
/**
13
+
* @param array<array{uri: string}> $feeds
14
+
*/
15
+
public function __construct(
16
+
public readonly string $did,
17
+
public readonly array $feeds,
18
+
public readonly ?array $links = null,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
did: $data['did'],
25
+
feeds: $data['feeds'] ?? [],
26
+
links: $data['links'] ?? null,
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return [
33
+
'did' => $this->did,
34
+
'feeds' => $this->feeds,
35
+
'links' => $this->links,
36
+
];
37
+
}
38
+
}
+39
src/Data/Responses/Bsky/Feed/GetActorFeedsResponse.php
+39
src/Data/Responses/Bsky/Feed/GetActorFeedsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\GeneratorView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetActorFeedsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, GeneratorView> $feeds
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $feeds,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
feeds: collect($data['feeds'] ?? [])->map(
26
+
fn (array $feed) => GeneratorView::fromArray($feed)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'feeds' => $this->feeds->map(fn (GeneratorView $f) => $f->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+39
src/Data/Responses/Bsky/Feed/GetActorLikesResponse.php
+39
src/Data/Responses/Bsky/Feed/GetActorLikesResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\FeedViewPost;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetActorLikesResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, FeedViewPost> $feed
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $feed,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
feed: collect($data['feed'] ?? [])->map(
26
+
fn (array $post) => FeedViewPost::fromArray($post)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'feed' => $this->feed->map(fn (FeedViewPost $p) => $p->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+39
src/Data/Responses/Bsky/Feed/GetAuthorFeedResponse.php
+39
src/Data/Responses/Bsky/Feed/GetAuthorFeedResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\FeedViewPost;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetAuthorFeedResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, FeedViewPost> $feed
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $feed,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
feed: collect($data['feed'] ?? [])->map(
26
+
fn (array $post) => FeedViewPost::fromArray($post)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'feed' => $this->feed->map(fn (FeedViewPost $p) => $p->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+36
src/Data/Responses/Bsky/Feed/GetFeedGeneratorResponse.php
+36
src/Data/Responses/Bsky/Feed/GetFeedGeneratorResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\GeneratorView;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class GetFeedGeneratorResponse implements Arrayable
12
+
{
13
+
public function __construct(
14
+
public readonly GeneratorView $view,
15
+
public readonly bool $isOnline,
16
+
public readonly bool $isValid,
17
+
) {}
18
+
19
+
public static function fromArray(array $data): self
20
+
{
21
+
return new self(
22
+
view: GeneratorView::fromArray($data['view']),
23
+
isOnline: $data['isOnline'],
24
+
isValid: $data['isValid'],
25
+
);
26
+
}
27
+
28
+
public function toArray(): array
29
+
{
30
+
return [
31
+
'view' => $this->view->toArray(),
32
+
'isOnline' => $this->isOnline,
33
+
'isValid' => $this->isValid,
34
+
];
35
+
}
36
+
}
+36
src/Data/Responses/Bsky/Feed/GetFeedGeneratorsResponse.php
+36
src/Data/Responses/Bsky/Feed/GetFeedGeneratorsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\GeneratorView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetFeedGeneratorsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, GeneratorView> $feeds
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $feeds,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
feeds: collect($data['feeds'] ?? [])->map(
25
+
fn (array $feed) => GeneratorView::fromArray($feed)
26
+
),
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return [
33
+
'feeds' => $this->feeds->map(fn (GeneratorView $f) => $f->toArray())->all(),
34
+
];
35
+
}
36
+
}
+39
src/Data/Responses/Bsky/Feed/GetFeedResponse.php
+39
src/Data/Responses/Bsky/Feed/GetFeedResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\FeedViewPost;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetFeedResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, FeedViewPost> $feed
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $feed,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
feed: collect($data['feed'] ?? [])->map(
26
+
fn (array $post) => FeedViewPost::fromArray($post)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'feed' => $this->feed->map(fn (FeedViewPost $p) => $p->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+45
src/Data/Responses/Bsky/Feed/GetLikesResponse.php
+45
src/Data/Responses/Bsky/Feed/GetLikesResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\GetLikes\Like;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetLikesResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, Like> $likes
16
+
*/
17
+
public function __construct(
18
+
public readonly string $uri,
19
+
public readonly Collection $likes,
20
+
public readonly ?string $cid = null,
21
+
public readonly ?string $cursor = null,
22
+
) {}
23
+
24
+
public static function fromArray(array $data): self
25
+
{
26
+
return new self(
27
+
uri: $data['uri'],
28
+
likes: collect($data['likes'] ?? [])->map(
29
+
fn (array $like) => Like::fromArray($like)
30
+
),
31
+
cid: $data['cid'] ?? null,
32
+
cursor: $data['cursor'] ?? null,
33
+
);
34
+
}
35
+
36
+
public function toArray(): array
37
+
{
38
+
return [
39
+
'uri' => $this->uri,
40
+
'likes' => $this->likes->map(fn (Like $l) => $l->toArray())->all(),
41
+
'cid' => $this->cid,
42
+
'cursor' => $this->cursor,
43
+
];
44
+
}
45
+
}
+33
src/Data/Responses/Bsky/Feed/GetPostThreadResponse.php
+33
src/Data/Responses/Bsky/Feed/GetPostThreadResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\ThreadViewPost;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class GetPostThreadResponse implements Arrayable
12
+
{
13
+
public function __construct(
14
+
public readonly ThreadViewPost $thread,
15
+
public readonly mixed $threadgate = null,
16
+
) {}
17
+
18
+
public static function fromArray(array $data): self
19
+
{
20
+
return new self(
21
+
thread: ThreadViewPost::fromArray($data['thread']),
22
+
threadgate: $data['threadgate'] ?? null,
23
+
);
24
+
}
25
+
26
+
public function toArray(): array
27
+
{
28
+
return [
29
+
'thread' => $this->thread->toArray(),
30
+
'threadgate' => $this->threadgate,
31
+
];
32
+
}
33
+
}
+36
src/Data/Responses/Bsky/Feed/GetPostsResponse.php
+36
src/Data/Responses/Bsky/Feed/GetPostsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\PostView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetPostsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, PostView> $posts
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $posts,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
posts: collect($data['posts'] ?? [])->map(
25
+
fn (array $post) => PostView::fromArray($post)
26
+
),
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return [
33
+
'posts' => $this->posts->map(fn (PostView $p) => $p->toArray())->all(),
34
+
];
35
+
}
36
+
}
+45
src/Data/Responses/Bsky/Feed/GetQuotesResponse.php
+45
src/Data/Responses/Bsky/Feed/GetQuotesResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\PostView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetQuotesResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, PostView> $posts
16
+
*/
17
+
public function __construct(
18
+
public readonly string $uri,
19
+
public readonly Collection $posts,
20
+
public readonly ?string $cid = null,
21
+
public readonly ?string $cursor = null,
22
+
) {}
23
+
24
+
public static function fromArray(array $data): self
25
+
{
26
+
return new self(
27
+
uri: $data['uri'],
28
+
posts: collect($data['posts'] ?? [])->map(
29
+
fn (array $post) => PostView::fromArray($post)
30
+
),
31
+
cid: $data['cid'] ?? null,
32
+
cursor: $data['cursor'] ?? null,
33
+
);
34
+
}
35
+
36
+
public function toArray(): array
37
+
{
38
+
return [
39
+
'uri' => $this->uri,
40
+
'posts' => $this->posts->map(fn (PostView $p) => $p->toArray())->all(),
41
+
'cid' => $this->cid,
42
+
'cursor' => $this->cursor,
43
+
];
44
+
}
45
+
}
+45
src/Data/Responses/Bsky/Feed/GetRepostedByResponse.php
+45
src/Data/Responses/Bsky/Feed/GetRepostedByResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetRepostedByResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ProfileView> $repostedBy
16
+
*/
17
+
public function __construct(
18
+
public readonly string $uri,
19
+
public readonly Collection $repostedBy,
20
+
public readonly ?string $cid = null,
21
+
public readonly ?string $cursor = null,
22
+
) {}
23
+
24
+
public static function fromArray(array $data): self
25
+
{
26
+
return new self(
27
+
uri: $data['uri'],
28
+
repostedBy: collect($data['repostedBy'] ?? [])->map(
29
+
fn (array $profile) => ProfileView::fromArray($profile)
30
+
),
31
+
cid: $data['cid'] ?? null,
32
+
cursor: $data['cursor'] ?? null,
33
+
);
34
+
}
35
+
36
+
public function toArray(): array
37
+
{
38
+
return [
39
+
'uri' => $this->uri,
40
+
'repostedBy' => $this->repostedBy->map(fn (ProfileView $p) => $p->toArray())->all(),
41
+
'cid' => $this->cid,
42
+
'cursor' => $this->cursor,
43
+
];
44
+
}
45
+
}
+39
src/Data/Responses/Bsky/Feed/GetSuggestedFeedsResponse.php
+39
src/Data/Responses/Bsky/Feed/GetSuggestedFeedsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\GeneratorView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetSuggestedFeedsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, GeneratorView> $feeds
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $feeds,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
feeds: collect($data['feeds'] ?? [])->map(
26
+
fn (array $feed) => GeneratorView::fromArray($feed)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'feeds' => $this->feeds->map(fn (GeneratorView $f) => $f->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+39
src/Data/Responses/Bsky/Feed/GetTimelineResponse.php
+39
src/Data/Responses/Bsky/Feed/GetTimelineResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\FeedViewPost;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetTimelineResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, FeedViewPost> $feed
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $feed,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
feed: collect($data['feed'] ?? [])->map(
26
+
fn (array $post) => FeedViewPost::fromArray($post)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'feed' => $this->feed->map(fn (FeedViewPost $p) => $p->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+42
src/Data/Responses/Bsky/Feed/SearchPostsResponse.php
+42
src/Data/Responses/Bsky/Feed/SearchPostsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Feed;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Feed\Defs\PostView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class SearchPostsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, PostView> $posts
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $posts,
19
+
public readonly ?string $cursor = null,
20
+
public readonly ?int $hitsTotal = null,
21
+
) {}
22
+
23
+
public static function fromArray(array $data): self
24
+
{
25
+
return new self(
26
+
posts: collect($data['posts'] ?? [])->map(
27
+
fn (array $post) => PostView::fromArray($post)
28
+
),
29
+
cursor: $data['cursor'] ?? null,
30
+
hitsTotal: $data['hitsTotal'] ?? null,
31
+
);
32
+
}
33
+
34
+
public function toArray(): array
35
+
{
36
+
return [
37
+
'posts' => $this->posts->map(fn (PostView $p) => $p->toArray())->all(),
38
+
'cursor' => $this->cursor,
39
+
'hitsTotal' => $this->hitsTotal,
40
+
];
41
+
}
42
+
}
+42
src/Data/Responses/Bsky/Graph/GetFollowersResponse.php
+42
src/Data/Responses/Bsky/Graph/GetFollowersResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetFollowersResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ProfileView> $followers
16
+
*/
17
+
public function __construct(
18
+
public readonly ProfileView $subject,
19
+
public readonly Collection $followers,
20
+
public readonly ?string $cursor = null,
21
+
) {}
22
+
23
+
public static function fromArray(array $data): self
24
+
{
25
+
return new self(
26
+
subject: ProfileView::fromArray($data['subject']),
27
+
followers: collect($data['followers'] ?? [])->map(
28
+
fn (array $profile) => ProfileView::fromArray($profile)
29
+
),
30
+
cursor: $data['cursor'] ?? null,
31
+
);
32
+
}
33
+
34
+
public function toArray(): array
35
+
{
36
+
return [
37
+
'subject' => $this->subject->toArray(),
38
+
'followers' => $this->followers->map(fn (ProfileView $p) => $p->toArray())->all(),
39
+
'cursor' => $this->cursor,
40
+
];
41
+
}
42
+
}
+42
src/Data/Responses/Bsky/Graph/GetFollowsResponse.php
+42
src/Data/Responses/Bsky/Graph/GetFollowsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetFollowsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ProfileView> $follows
16
+
*/
17
+
public function __construct(
18
+
public readonly ProfileView $subject,
19
+
public readonly Collection $follows,
20
+
public readonly ?string $cursor = null,
21
+
) {}
22
+
23
+
public static function fromArray(array $data): self
24
+
{
25
+
return new self(
26
+
subject: ProfileView::fromArray($data['subject']),
27
+
follows: collect($data['follows'] ?? [])->map(
28
+
fn (array $profile) => ProfileView::fromArray($profile)
29
+
),
30
+
cursor: $data['cursor'] ?? null,
31
+
);
32
+
}
33
+
34
+
public function toArray(): array
35
+
{
36
+
return [
37
+
'subject' => $this->subject->toArray(),
38
+
'follows' => $this->follows->map(fn (ProfileView $p) => $p->toArray())->all(),
39
+
'cursor' => $this->cursor,
40
+
];
41
+
}
42
+
}
+42
src/Data/Responses/Bsky/Graph/GetKnownFollowersResponse.php
+42
src/Data/Responses/Bsky/Graph/GetKnownFollowersResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetKnownFollowersResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ProfileView> $followers
16
+
*/
17
+
public function __construct(
18
+
public readonly ProfileView $subject,
19
+
public readonly Collection $followers,
20
+
public readonly ?string $cursor = null,
21
+
) {}
22
+
23
+
public static function fromArray(array $data): self
24
+
{
25
+
return new self(
26
+
subject: ProfileView::fromArray($data['subject']),
27
+
followers: collect($data['followers'] ?? [])->map(
28
+
fn (array $profile) => ProfileView::fromArray($profile)
29
+
),
30
+
cursor: $data['cursor'] ?? null,
31
+
);
32
+
}
33
+
34
+
public function toArray(): array
35
+
{
36
+
return [
37
+
'subject' => $this->subject->toArray(),
38
+
'followers' => $this->followers->map(fn (ProfileView $p) => $p->toArray())->all(),
39
+
'cursor' => $this->cursor,
40
+
];
41
+
}
42
+
}
+43
src/Data/Responses/Bsky/Graph/GetListResponse.php
+43
src/Data/Responses/Bsky/Graph/GetListResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\ListItemView;
8
+
use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\ListView;
9
+
10
+
/**
11
+
* @implements Arrayable<string, mixed>
12
+
*/
13
+
class GetListResponse implements Arrayable
14
+
{
15
+
/**
16
+
* @param Collection<int, ListItemView> $items
17
+
*/
18
+
public function __construct(
19
+
public readonly ListView $list,
20
+
public readonly Collection $items,
21
+
public readonly ?string $cursor = null,
22
+
) {}
23
+
24
+
public static function fromArray(array $data): self
25
+
{
26
+
return new self(
27
+
list: ListView::fromArray($data['list']),
28
+
items: collect($data['items'] ?? [])->map(
29
+
fn (array $item) => ListItemView::fromArray($item)
30
+
),
31
+
cursor: $data['cursor'] ?? null,
32
+
);
33
+
}
34
+
35
+
public function toArray(): array
36
+
{
37
+
return [
38
+
'list' => $this->list->toArray(),
39
+
'items' => $this->items->map(fn (ListItemView $i) => $i->toArray())->all(),
40
+
'cursor' => $this->cursor,
41
+
];
42
+
}
43
+
}
+39
src/Data/Responses/Bsky/Graph/GetListsResponse.php
+39
src/Data/Responses/Bsky/Graph/GetListsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\ListView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetListsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ListView> $lists
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $lists,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
lists: collect($data['lists'] ?? [])->map(
26
+
fn (array $list) => ListView::fromArray($list)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'lists' => $this->lists->map(fn (ListView $l) => $l->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+36
src/Data/Responses/Bsky/Graph/GetRelationshipsResponse.php
+36
src/Data/Responses/Bsky/Graph/GetRelationshipsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class GetRelationshipsResponse implements Arrayable
12
+
{
13
+
/**
14
+
* @param Collection<int, mixed> $relationships Collection of Relationship or NotFoundActor objects
15
+
*/
16
+
public function __construct(
17
+
public readonly Collection $relationships,
18
+
public readonly ?string $actor = null,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
relationships: collect($data['relationships'] ?? []),
25
+
actor: $data['actor'] ?? null,
26
+
);
27
+
}
28
+
29
+
public function toArray(): array
30
+
{
31
+
return [
32
+
'relationships' => $this->relationships->all(),
33
+
'actor' => $this->actor,
34
+
];
35
+
}
36
+
}
+36
src/Data/Responses/Bsky/Graph/GetStarterPacksResponse.php
+36
src/Data/Responses/Bsky/Graph/GetStarterPacksResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Graph\Defs\StarterPackViewBasic;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetStarterPacksResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, StarterPackViewBasic> $starterPacks
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $starterPacks,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
starterPacks: collect($data['starterPacks'] ?? [])->map(
25
+
fn (array $pack) => StarterPackViewBasic::fromArray($pack)
26
+
),
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return [
33
+
'starterPacks' => $this->starterPacks->map(fn (StarterPackViewBasic $p) => $p->toArray())->all(),
34
+
];
35
+
}
36
+
}
+39
src/Data/Responses/Bsky/Graph/GetSuggestedFollowsByActorResponse.php
+39
src/Data/Responses/Bsky/Graph/GetSuggestedFollowsByActorResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Graph;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Actor\Defs\ProfileView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetSuggestedFollowsByActorResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ProfileView> $suggestions
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $suggestions,
19
+
public readonly ?bool $isFallback = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
suggestions: collect($data['suggestions'] ?? [])->map(
26
+
fn (array $profile) => ProfileView::fromArray($profile)
27
+
),
28
+
isFallback: $data['isFallback'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'suggestions' => $this->suggestions->map(fn (ProfileView $p) => $p->toArray())->all(),
36
+
'isFallback' => $this->isFallback,
37
+
];
38
+
}
39
+
}
+39
src/Data/Responses/Bsky/Labeler/GetServicesResponse.php
+39
src/Data/Responses/Bsky/Labeler/GetServicesResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Bsky\Labeler;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\App\Bsky\Labeler\Defs\LabelerView;
8
+
use SocialDept\AtpSchema\Generated\App\Bsky\Labeler\Defs\LabelerViewDetailed;
9
+
10
+
/**
11
+
* @implements Arrayable<string, mixed>
12
+
*/
13
+
class GetServicesResponse implements Arrayable
14
+
{
15
+
/**
16
+
* @param Collection<int, LabelerView|LabelerViewDetailed> $views
17
+
*/
18
+
public function __construct(
19
+
public readonly Collection $views,
20
+
) {}
21
+
22
+
public static function fromArray(array $data, bool $detailed = false): self
23
+
{
24
+
return new self(
25
+
views: collect($data['views'] ?? [])->map(
26
+
fn (array $view) => $detailed
27
+
? LabelerViewDetailed::fromArray($view)
28
+
: LabelerView::fromArray($view)
29
+
),
30
+
);
31
+
}
32
+
33
+
public function toArray(): array
34
+
{
35
+
return [
36
+
'views' => $this->views->map(fn (LabelerView|LabelerViewDetailed $v) => $v->toArray())->all(),
37
+
];
38
+
}
39
+
}
+36
src/Data/Responses/Chat/Convo/GetLogResponse.php
+36
src/Data/Responses/Chat/Convo/GetLogResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Chat\Convo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class GetLogResponse implements Arrayable
12
+
{
13
+
/**
14
+
* @param Collection<int, mixed> $logs Collection of log event objects (LogBeginConvo, LogCreateMessage, etc.)
15
+
*/
16
+
public function __construct(
17
+
public readonly Collection $logs,
18
+
public readonly ?string $cursor = null,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
logs: collect($data['logs'] ?? []),
25
+
cursor: $data['cursor'] ?? null,
26
+
);
27
+
}
28
+
29
+
public function toArray(): array
30
+
{
31
+
return [
32
+
'logs' => $this->logs->all(),
33
+
'cursor' => $this->cursor,
34
+
];
35
+
}
36
+
}
+46
src/Data/Responses/Chat/Convo/GetMessagesResponse.php
+46
src/Data/Responses/Chat/Convo/GetMessagesResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Chat\Convo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\DeletedMessageView;
8
+
use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\MessageView;
9
+
10
+
/**
11
+
* @implements Arrayable<string, mixed>
12
+
*/
13
+
class GetMessagesResponse implements Arrayable
14
+
{
15
+
/**
16
+
* @param Collection<int, MessageView|DeletedMessageView> $messages
17
+
*/
18
+
public function __construct(
19
+
public readonly Collection $messages,
20
+
public readonly ?string $cursor = null,
21
+
) {}
22
+
23
+
public static function fromArray(array $data): self
24
+
{
25
+
return new self(
26
+
messages: collect($data['messages'] ?? [])->map(
27
+
function (array $message) {
28
+
if (isset($message['$type']) && $message['$type'] === 'chat.bsky.convo.defs#deletedMessageView') {
29
+
return DeletedMessageView::fromArray($message);
30
+
}
31
+
32
+
return MessageView::fromArray($message);
33
+
}
34
+
),
35
+
cursor: $data['cursor'] ?? null,
36
+
);
37
+
}
38
+
39
+
public function toArray(): array
40
+
{
41
+
return [
42
+
'messages' => $this->messages->map(fn (MessageView|DeletedMessageView $m) => $m->toArray())->all(),
43
+
'cursor' => $this->cursor,
44
+
];
45
+
}
46
+
}
+32
src/Data/Responses/Chat/Convo/LeaveConvoResponse.php
+32
src/Data/Responses/Chat/Convo/LeaveConvoResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Chat\Convo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, mixed>
9
+
*/
10
+
class LeaveConvoResponse implements Arrayable
11
+
{
12
+
public function __construct(
13
+
public readonly string $convoId,
14
+
public readonly string $rev,
15
+
) {}
16
+
17
+
public static function fromArray(array $data): self
18
+
{
19
+
return new self(
20
+
convoId: $data['convoId'],
21
+
rev: $data['rev'],
22
+
);
23
+
}
24
+
25
+
public function toArray(): array
26
+
{
27
+
return [
28
+
'convoId' => $this->convoId,
29
+
'rev' => $this->rev,
30
+
];
31
+
}
32
+
}
+39
src/Data/Responses/Chat/Convo/ListConvosResponse.php
+39
src/Data/Responses/Chat/Convo/ListConvosResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Chat\Convo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\ConvoView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class ListConvosResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ConvoView> $convos
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $convos,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
convos: collect($data['convos'] ?? [])->map(
26
+
fn (array $convo) => ConvoView::fromArray($convo)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'convos' => $this->convos->map(fn (ConvoView $c) => $c->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+36
src/Data/Responses/Chat/Convo/SendMessageBatchResponse.php
+36
src/Data/Responses/Chat/Convo/SendMessageBatchResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Chat\Convo;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\Chat\Bsky\Convo\Defs\MessageView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class SendMessageBatchResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, MessageView> $items
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $items,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
items: collect($data['items'] ?? [])->map(
25
+
fn (array $item) => MessageView::fromArray($item)
26
+
),
27
+
);
28
+
}
29
+
30
+
public function toArray(): array
31
+
{
32
+
return [
33
+
'items' => $this->items->map(fn (MessageView $m) => $m->toArray())->all(),
34
+
];
35
+
}
36
+
}
+25
src/Data/Responses/EmptyResponse.php
+25
src/Data/Responses/EmptyResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* Response class for endpoints that return empty objects.
9
+
*
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class EmptyResponse implements Arrayable
13
+
{
14
+
public function __construct() {}
15
+
16
+
public static function fromArray(array $data): self
17
+
{
18
+
return new self;
19
+
}
20
+
21
+
public function toArray(): array
22
+
{
23
+
return [];
24
+
}
25
+
}
+39
src/Data/Responses/Ozone/Moderation/QueryEventsResponse.php
+39
src/Data/Responses/Ozone/Moderation/QueryEventsResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Ozone\Moderation;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\ModEventView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class QueryEventsResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, ModEventView> $events
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $events,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
events: collect($data['events'] ?? [])->map(
26
+
fn (array $event) => ModEventView::fromArray($event)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'events' => $this->events->map(fn (ModEventView $e) => $e->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+39
src/Data/Responses/Ozone/Moderation/QueryStatusesResponse.php
+39
src/Data/Responses/Ozone/Moderation/QueryStatusesResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Ozone\Moderation;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\SubjectStatusView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class QueryStatusesResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, SubjectStatusView> $subjectStatuses
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $subjectStatuses,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
subjectStatuses: collect($data['subjectStatuses'] ?? [])->map(
26
+
fn (array $status) => SubjectStatusView::fromArray($status)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'subjectStatuses' => $this->subjectStatuses->map(fn (SubjectStatusView $s) => $s->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+39
src/Data/Responses/Ozone/Moderation/SearchReposResponse.php
+39
src/Data/Responses/Ozone/Moderation/SearchReposResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Ozone\Moderation;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
use SocialDept\AtpSchema\Generated\Tools\Ozone\Moderation\Defs\RepoView;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class SearchReposResponse implements Arrayable
13
+
{
14
+
/**
15
+
* @param Collection<int, RepoView> $repos
16
+
*/
17
+
public function __construct(
18
+
public readonly Collection $repos,
19
+
public readonly ?string $cursor = null,
20
+
) {}
21
+
22
+
public static function fromArray(array $data): self
23
+
{
24
+
return new self(
25
+
repos: collect($data['repos'] ?? [])->map(
26
+
fn (array $repo) => RepoView::fromArray($repo)
27
+
),
28
+
cursor: $data['cursor'] ?? null,
29
+
);
30
+
}
31
+
32
+
public function toArray(): array
33
+
{
34
+
return [
35
+
'repos' => $this->repos->map(fn (RepoView $r) => $r->toArray())->all(),
36
+
'cursor' => $this->cursor,
37
+
];
38
+
}
39
+
}
+46
src/Data/Responses/Ozone/Server/GetConfigResponse.php
+46
src/Data/Responses/Ozone/Server/GetConfigResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Ozone\Server;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use SocialDept\AtpSchema\Generated\Tools\Ozone\Server\GetConfig\ServiceConfig;
7
+
use SocialDept\AtpSchema\Generated\Tools\Ozone\Server\GetConfig\ViewerConfig;
8
+
9
+
/**
10
+
* @implements Arrayable<string, mixed>
11
+
*/
12
+
class GetConfigResponse implements Arrayable
13
+
{
14
+
public function __construct(
15
+
public readonly ?ServiceConfig $appview = null,
16
+
public readonly ?ServiceConfig $pds = null,
17
+
public readonly ?ServiceConfig $blobDivert = null,
18
+
public readonly ?ServiceConfig $chat = null,
19
+
public readonly ?ViewerConfig $viewer = null,
20
+
public readonly ?string $verifierDid = null,
21
+
) {}
22
+
23
+
public static function fromArray(array $data): self
24
+
{
25
+
return new self(
26
+
appview: isset($data['appview']) ? ServiceConfig::fromArray($data['appview']) : null,
27
+
pds: isset($data['pds']) ? ServiceConfig::fromArray($data['pds']) : null,
28
+
blobDivert: isset($data['blobDivert']) ? ServiceConfig::fromArray($data['blobDivert']) : null,
29
+
chat: isset($data['chat']) ? ServiceConfig::fromArray($data['chat']) : null,
30
+
viewer: isset($data['viewer']) ? ViewerConfig::fromArray($data['viewer']) : null,
31
+
verifierDid: $data['verifierDid'] ?? null,
32
+
);
33
+
}
34
+
35
+
public function toArray(): array
36
+
{
37
+
return [
38
+
'appview' => $this->appview?->toArray(),
39
+
'pds' => $this->pds?->toArray(),
40
+
'blobDivert' => $this->blobDivert?->toArray(),
41
+
'chat' => $this->chat?->toArray(),
42
+
'viewer' => $this->viewer?->toArray(),
43
+
'verifierDid' => $this->verifierDid,
44
+
];
45
+
}
46
+
}
+36
src/Data/Responses/Ozone/Team/ListMembersResponse.php
+36
src/Data/Responses/Ozone/Team/ListMembersResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Ozone\Team;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
use Illuminate\Support\Collection;
7
+
8
+
/**
9
+
* @implements Arrayable<string, mixed>
10
+
*/
11
+
class ListMembersResponse implements Arrayable
12
+
{
13
+
/**
14
+
* @param Collection<int, array<string, mixed>> $members Collection of team member objects
15
+
*/
16
+
public function __construct(
17
+
public readonly Collection $members,
18
+
public readonly ?string $cursor = null,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
members: collect($data['members'] ?? []),
25
+
cursor: $data['cursor'] ?? null,
26
+
);
27
+
}
28
+
29
+
public function toArray(): array
30
+
{
31
+
return [
32
+
'members' => $this->members->all(),
33
+
'cursor' => $this->cursor,
34
+
];
35
+
}
36
+
}
+44
src/Data/Responses/Ozone/Team/MemberResponse.php
+44
src/Data/Responses/Ozone/Team/MemberResponse.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Data\Responses\Ozone\Team;
4
+
5
+
use Illuminate\Contracts\Support\Arrayable;
6
+
7
+
/**
8
+
* @implements Arrayable<string, mixed>
9
+
*/
10
+
class MemberResponse implements Arrayable
11
+
{
12
+
public function __construct(
13
+
public readonly string $did,
14
+
public readonly bool $disabled,
15
+
public readonly ?string $role = null,
16
+
public readonly ?string $createdAt = null,
17
+
public readonly ?string $updatedAt = null,
18
+
public readonly ?string $lastUpdatedBy = null,
19
+
) {}
20
+
21
+
public static function fromArray(array $data): self
22
+
{
23
+
return new self(
24
+
did: $data['did'],
25
+
disabled: $data['disabled'] ?? false,
26
+
role: $data['role'] ?? null,
27
+
createdAt: $data['createdAt'] ?? null,
28
+
updatedAt: $data['updatedAt'] ?? null,
29
+
lastUpdatedBy: $data['lastUpdatedBy'] ?? null,
30
+
);
31
+
}
32
+
33
+
public function toArray(): array
34
+
{
35
+
return array_filter([
36
+
'did' => $this->did,
37
+
'disabled' => $this->disabled,
38
+
'role' => $this->role,
39
+
'createdAt' => $this->createdAt,
40
+
'updatedAt' => $this->updatedAt,
41
+
'lastUpdatedBy' => $this->lastUpdatedBy,
42
+
], fn ($v) => $v !== null);
43
+
}
44
+
}
+6
-1
src/Data/StrongRef.php
+6
-1
src/Data/StrongRef.php
+9
src/Enums/AuthType.php
+9
src/Enums/AuthType.php
+12
src/Enums/Nsid/AtprotoIdentity.php
+12
src/Enums/Nsid/AtprotoIdentity.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum AtprotoIdentity: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case ResolveHandle = 'com.atproto.identity.resolveHandle';
11
+
case UpdateHandle = 'com.atproto.identity.updateHandle';
12
+
}
+17
src/Enums/Nsid/AtprotoRepo.php
+17
src/Enums/Nsid/AtprotoRepo.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum AtprotoRepo: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case CreateRecord = 'com.atproto.repo.createRecord';
11
+
case DeleteRecord = 'com.atproto.repo.deleteRecord';
12
+
case PutRecord = 'com.atproto.repo.putRecord';
13
+
case GetRecord = 'com.atproto.repo.getRecord';
14
+
case ListRecords = 'com.atproto.repo.listRecords';
15
+
case UploadBlob = 'com.atproto.repo.uploadBlob';
16
+
case DescribeRepo = 'com.atproto.repo.describeRepo';
17
+
}
+14
src/Enums/Nsid/AtprotoServer.php
+14
src/Enums/Nsid/AtprotoServer.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum AtprotoServer: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case CreateSession = 'com.atproto.server.createSession';
11
+
case RefreshSession = 'com.atproto.server.refreshSession';
12
+
case GetSession = 'com.atproto.server.getSession';
13
+
case DescribeServer = 'com.atproto.server.describeServer';
14
+
}
+19
src/Enums/Nsid/AtprotoSync.php
+19
src/Enums/Nsid/AtprotoSync.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum AtprotoSync: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case GetBlob = 'com.atproto.sync.getBlob';
11
+
case GetRepo = 'com.atproto.sync.getRepo';
12
+
case ListRepos = 'com.atproto.sync.listRepos';
13
+
case ListReposByCollection = 'com.atproto.sync.listReposByCollection';
14
+
case GetLatestCommit = 'com.atproto.sync.getLatestCommit';
15
+
case GetRecord = 'com.atproto.sync.getRecord';
16
+
case ListBlobs = 'com.atproto.sync.listBlobs';
17
+
case GetBlocks = 'com.atproto.sync.getBlocks';
18
+
case GetRepoStatus = 'com.atproto.sync.getRepoStatus';
19
+
}
+18
src/Enums/Nsid/BskyActor.php
+18
src/Enums/Nsid/BskyActor.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum BskyActor: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case GetProfile = 'app.bsky.actor.getProfile';
11
+
case GetProfiles = 'app.bsky.actor.getProfiles';
12
+
case GetSuggestions = 'app.bsky.actor.getSuggestions';
13
+
case SearchActors = 'app.bsky.actor.searchActors';
14
+
case SearchActorsTypeahead = 'app.bsky.actor.searchActorsTypeahead';
15
+
16
+
// Record type
17
+
case Profile = 'app.bsky.actor.profile';
18
+
}
+29
src/Enums/Nsid/BskyFeed.php
+29
src/Enums/Nsid/BskyFeed.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum BskyFeed: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case DescribeFeedGenerator = 'app.bsky.feed.describeFeedGenerator';
11
+
case GetAuthorFeed = 'app.bsky.feed.getAuthorFeed';
12
+
case GetActorFeeds = 'app.bsky.feed.getActorFeeds';
13
+
case GetActorLikes = 'app.bsky.feed.getActorLikes';
14
+
case GetFeed = 'app.bsky.feed.getFeed';
15
+
case GetFeedGenerator = 'app.bsky.feed.getFeedGenerator';
16
+
case GetFeedGenerators = 'app.bsky.feed.getFeedGenerators';
17
+
case GetLikes = 'app.bsky.feed.getLikes';
18
+
case GetPostThread = 'app.bsky.feed.getPostThread';
19
+
case GetPosts = 'app.bsky.feed.getPosts';
20
+
case GetQuotes = 'app.bsky.feed.getQuotes';
21
+
case GetRepostedBy = 'app.bsky.feed.getRepostedBy';
22
+
case GetSuggestedFeeds = 'app.bsky.feed.getSuggestedFeeds';
23
+
case GetTimeline = 'app.bsky.feed.getTimeline';
24
+
case SearchPosts = 'app.bsky.feed.searchPosts';
25
+
26
+
// Record types
27
+
case Post = 'app.bsky.feed.post';
28
+
case Like = 'app.bsky.feed.like';
29
+
}
+22
src/Enums/Nsid/BskyGraph.php
+22
src/Enums/Nsid/BskyGraph.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum BskyGraph: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case GetFollowers = 'app.bsky.graph.getFollowers';
11
+
case GetFollows = 'app.bsky.graph.getFollows';
12
+
case GetKnownFollowers = 'app.bsky.graph.getKnownFollowers';
13
+
case GetList = 'app.bsky.graph.getList';
14
+
case GetLists = 'app.bsky.graph.getLists';
15
+
case GetRelationships = 'app.bsky.graph.getRelationships';
16
+
case GetStarterPack = 'app.bsky.graph.getStarterPack';
17
+
case GetStarterPacks = 'app.bsky.graph.getStarterPacks';
18
+
case GetSuggestedFollowsByActor = 'app.bsky.graph.getSuggestedFollowsByActor';
19
+
20
+
// Record type
21
+
case Follow = 'app.bsky.graph.follow';
22
+
}
+11
src/Enums/Nsid/BskyLabeler.php
+11
src/Enums/Nsid/BskyLabeler.php
+13
src/Enums/Nsid/ChatActor.php
+13
src/Enums/Nsid/ChatActor.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum ChatActor: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case GetActorMetadata = 'chat.bsky.actor.getActorMetadata';
11
+
case ExportAccountData = 'chat.bsky.actor.exportAccountData';
12
+
case DeleteAccount = 'chat.bsky.actor.deleteAccount';
13
+
}
+22
src/Enums/Nsid/ChatConvo.php
+22
src/Enums/Nsid/ChatConvo.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum ChatConvo: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case GetConvo = 'chat.bsky.convo.getConvo';
11
+
case GetConvoForMembers = 'chat.bsky.convo.getConvoForMembers';
12
+
case ListConvos = 'chat.bsky.convo.listConvos';
13
+
case GetMessages = 'chat.bsky.convo.getMessages';
14
+
case SendMessage = 'chat.bsky.convo.sendMessage';
15
+
case SendMessageBatch = 'chat.bsky.convo.sendMessageBatch';
16
+
case DeleteMessageForSelf = 'chat.bsky.convo.deleteMessageForSelf';
17
+
case UpdateRead = 'chat.bsky.convo.updateRead';
18
+
case MuteConvo = 'chat.bsky.convo.muteConvo';
19
+
case UnmuteConvo = 'chat.bsky.convo.unmuteConvo';
20
+
case LeaveConvo = 'chat.bsky.convo.leaveConvo';
21
+
case GetLog = 'chat.bsky.convo.getLog';
22
+
}
+34
src/Enums/Nsid/Concerns/HasScopeHelpers.php
+34
src/Enums/Nsid/Concerns/HasScopeHelpers.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid\Concerns;
4
+
5
+
trait HasScopeHelpers
6
+
{
7
+
/**
8
+
* Get the RPC scope format for this NSID.
9
+
*
10
+
* @example BskyActor::GetProfile->rpc() // "rpc:app.bsky.actor.getProfile"
11
+
*/
12
+
public function rpc(): string
13
+
{
14
+
return 'rpc:' . $this->value;
15
+
}
16
+
17
+
/**
18
+
* Get the repo scope format for this NSID.
19
+
*
20
+
* @example BskyGraph::Follow->repo(['create']) // "repo:app.bsky.graph.follow?action=create"
21
+
* @example BskyFeed::Post->repo(['create', 'delete']) // "repo:app.bsky.feed.post?action=create&action=delete"
22
+
* @example BskyFeed::Post->repo() // "repo:app.bsky.feed.post"
23
+
*/
24
+
public function repo(array $actions = []): string
25
+
{
26
+
$scope = 'repo:' . $this->value;
27
+
28
+
if (! empty($actions)) {
29
+
$scope .= '?' . implode('&', array_map(fn ($action) => "action={$action}", $actions));
30
+
}
31
+
32
+
return $scope;
33
+
}
34
+
}
+18
src/Enums/Nsid/OzoneModeration.php
+18
src/Enums/Nsid/OzoneModeration.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum OzoneModeration: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case GetEvent = 'tools.ozone.moderation.getEvent';
11
+
case GetEvents = 'tools.ozone.moderation.getEvents';
12
+
case GetRecord = 'tools.ozone.moderation.getRecord';
13
+
case GetRepo = 'tools.ozone.moderation.getRepo';
14
+
case QueryEvents = 'tools.ozone.moderation.queryEvents';
15
+
case QueryStatuses = 'tools.ozone.moderation.queryStatuses';
16
+
case SearchRepos = 'tools.ozone.moderation.searchRepos';
17
+
case EmitEvent = 'tools.ozone.moderation.emitEvent';
18
+
}
+12
src/Enums/Nsid/OzoneServer.php
+12
src/Enums/Nsid/OzoneServer.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum OzoneServer: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case GetBlob = 'tools.ozone.server.getBlob';
11
+
case GetConfig = 'tools.ozone.server.getConfig';
12
+
}
+15
src/Enums/Nsid/OzoneTeam.php
+15
src/Enums/Nsid/OzoneTeam.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums\Nsid;
4
+
5
+
use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers;
6
+
7
+
enum OzoneTeam: string
8
+
{
9
+
use HasScopeHelpers;
10
+
case GetMember = 'tools.ozone.team.getMember';
11
+
case ListMembers = 'tools.ozone.team.listMembers';
12
+
case AddMember = 'tools.ozone.team.addMember';
13
+
case UpdateMember = 'tools.ozone.team.updateMember';
14
+
case DeleteMember = 'tools.ozone.team.deleteMember';
15
+
}
+74
src/Enums/Scope.php
+74
src/Enums/Scope.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Enums;
4
+
5
+
use BackedEnum;
6
+
7
+
enum Scope: string
8
+
{
9
+
// Transition scopes (current)
10
+
case Atproto = 'atproto';
11
+
case TransitionGeneric = 'transition:generic';
12
+
case TransitionEmail = 'transition:email';
13
+
case TransitionChat = 'transition:chat.bsky';
14
+
15
+
/**
16
+
* Build a repo scope string for record operations.
17
+
*
18
+
* @param string|BackedEnum $collection The collection NSID (e.g., 'app.bsky.feed.post')
19
+
* @param array|null $actions The action (create, update, delete)
20
+
*
21
+
* @return string
22
+
*/
23
+
public static function repo(string|BackedEnum $collection, ?array $actions = []): string
24
+
{
25
+
$collection = $collection instanceof BackedEnum ? $collection->value : $collection;
26
+
$scope = "repo:{$collection}";
27
+
28
+
if (!empty($actions)) {
29
+
$scope .= '?' . implode('&', array_map(fn ($action) => "action={$action}", $actions));
30
+
}
31
+
32
+
return $scope;
33
+
}
34
+
35
+
/**
36
+
* Build an RPC scope string for endpoint access.
37
+
*
38
+
* @param string $lxm The lexicon method ID (e.g., 'app.bsky.feed.getTimeline')
39
+
*/
40
+
public static function rpc(string $lxm): string
41
+
{
42
+
return "rpc:{$lxm}";
43
+
}
44
+
45
+
/**
46
+
* Build a blob scope string for uploads.
47
+
*
48
+
* @param string|null $mimeType The mime type pattern (e.g., 'image/*', '*\/*')
49
+
*/
50
+
public static function blob(?string $mimeType = null): string
51
+
{
52
+
return 'blob:'.($mimeType ?? '*/*');
53
+
}
54
+
55
+
/**
56
+
* Build an account scope string.
57
+
*
58
+
* @param string $attr The account attribute (e.g., 'email', 'status')
59
+
*/
60
+
public static function account(string $attr): string
61
+
{
62
+
return "account:{$attr}";
63
+
}
64
+
65
+
/**
66
+
* Build an identity scope string.
67
+
*
68
+
* @param string $attr The identity attribute (e.g., 'handle')
69
+
*/
70
+
public static function identity(string $attr): string
71
+
{
72
+
return "identity:{$attr}";
73
+
}
74
+
}
+10
src/Enums/ScopeAuthorizationFailure.php
+10
src/Enums/ScopeAuthorizationFailure.php
+9
src/Enums/ScopeEnforcementLevel.php
+9
src/Enums/ScopeEnforcementLevel.php
+27
src/Events/SessionAuthenticated.php
+27
src/Events/SessionAuthenticated.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Events;
4
+
5
+
use Illuminate\Foundation\Events\Dispatchable;
6
+
use Illuminate\Queue\SerializesModels;
7
+
use SocialDept\AtpClient\Data\AccessToken;
8
+
use SocialDept\AtpClient\Enums\AuthType;
9
+
10
+
class SessionAuthenticated
11
+
{
12
+
use Dispatchable, SerializesModels;
13
+
14
+
public function __construct(
15
+
public readonly AccessToken $token,
16
+
) {}
17
+
18
+
public function isOAuth(): bool
19
+
{
20
+
return $this->token->authType === AuthType::OAuth;
21
+
}
22
+
23
+
public function isLegacy(): bool
24
+
{
25
+
return $this->token->authType === AuthType::Legacy;
26
+
}
27
+
}
+16
src/Events/SessionRefreshing.php
+16
src/Events/SessionRefreshing.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Events;
4
+
5
+
use Illuminate\Foundation\Events\Dispatchable;
6
+
use Illuminate\Queue\SerializesModels;
7
+
use SocialDept\AtpClient\Session\Session;
8
+
9
+
class SessionRefreshing
10
+
{
11
+
use Dispatchable, SerializesModels;
12
+
13
+
public function __construct(
14
+
public readonly Session $session,
15
+
) {}
16
+
}
+18
src/Events/SessionUpdated.php
+18
src/Events/SessionUpdated.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Events;
4
+
5
+
use Illuminate\Foundation\Events\Dispatchable;
6
+
use Illuminate\Queue\SerializesModels;
7
+
use SocialDept\AtpClient\Data\AccessToken;
8
+
use SocialDept\AtpClient\Session\Session;
9
+
10
+
class SessionUpdated
11
+
{
12
+
use Dispatchable, SerializesModels;
13
+
14
+
public function __construct(
15
+
public readonly Session $session,
16
+
public readonly AccessToken $token,
17
+
) {}
18
+
}
-17
src/Events/TokenRefreshed.php
-17
src/Events/TokenRefreshed.php
···
1
-
<?php
2
-
3
-
namespace SocialDept\AtpClient\Events;
4
-
5
-
use Illuminate\Foundation\Events\Dispatchable;
6
-
use Illuminate\Queue\SerializesModels;
7
-
use SocialDept\AtpClient\Data\AccessToken;
8
-
9
-
class TokenRefreshed
10
-
{
11
-
use Dispatchable, SerializesModels;
12
-
13
-
public function __construct(
14
-
public readonly string $identifier,
15
-
public readonly AccessToken $token,
16
-
) {}
17
-
}
···
-16
src/Events/TokenRefreshing.php
-16
src/Events/TokenRefreshing.php
···
1
-
<?php
2
-
3
-
namespace SocialDept\AtpClient\Events;
4
-
5
-
use Illuminate\Foundation\Events\Dispatchable;
6
-
use Illuminate\Queue\SerializesModels;
7
-
8
-
class TokenRefreshing
9
-
{
10
-
use Dispatchable, SerializesModels;
11
-
12
-
public function __construct(
13
-
public readonly string $identifier,
14
-
public readonly string $refreshToken,
15
-
) {}
16
-
}
···
+31
src/Exceptions/AtpResponseException.php
+31
src/Exceptions/AtpResponseException.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Exceptions;
4
+
5
+
use Illuminate\Http\Client\Response;
6
+
7
+
class AtpResponseException extends \Exception
8
+
{
9
+
public function __construct(
10
+
public readonly string $errorCode,
11
+
public readonly string $errorMessage,
12
+
public readonly int $httpStatus,
13
+
public readonly string $endpoint,
14
+
public readonly array $responseBody,
15
+
) {
16
+
parent::__construct("{$errorCode}: {$errorMessage}", $httpStatus);
17
+
}
18
+
19
+
public static function fromResponse(Response $response, string $endpoint): self
20
+
{
21
+
$body = $response->json() ?? [];
22
+
23
+
return new self(
24
+
errorCode: $body['error'] ?? 'UnknownError',
25
+
errorMessage: $body['message'] ?? $response->body(),
26
+
httpStatus: $response->status(),
27
+
endpoint: $endpoint,
28
+
responseBody: $body,
29
+
);
30
+
}
31
+
}
+11
src/Exceptions/HandleResolutionException.php
+11
src/Exceptions/HandleResolutionException.php
+22
src/Exceptions/MissingScopeException.php
+22
src/Exceptions/MissingScopeException.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Exceptions;
4
+
5
+
class MissingScopeException extends \Exception
6
+
{
7
+
public function __construct(
8
+
public readonly array $required,
9
+
public readonly array $granted,
10
+
?string $message = null,
11
+
) {
12
+
parent::__construct($message ?? $this->buildMessage());
13
+
}
14
+
15
+
protected function buildMessage(): string
16
+
{
17
+
$required = implode(', ', $this->required);
18
+
$granted = empty($this->granted) ? 'none' : implode(', ', $this->granted);
19
+
20
+
return "Missing required scope(s): {$required}. Granted: {$granted}.";
21
+
}
22
+
}
+26
src/Exceptions/ScopeAuthorizationException.php
+26
src/Exceptions/ScopeAuthorizationException.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Exceptions;
4
+
5
+
use Illuminate\Http\Request;
6
+
use SocialDept\AtpClient\Enums\ScopeAuthorizationFailure;
7
+
use Symfony\Component\HttpFoundation\Response;
8
+
9
+
class ScopeAuthorizationException extends MissingScopeException
10
+
{
11
+
/**
12
+
* Render the exception as an HTTP response.
13
+
*/
14
+
public function render(Request $request): Response
15
+
{
16
+
$action = config('atp-client.scope_authorization.failure_action', ScopeAuthorizationFailure::Abort);
17
+
18
+
return match ($action) {
19
+
ScopeAuthorizationFailure::Redirect => redirect(
20
+
config('atp-client.scope_authorization.redirect_to', '/login')
21
+
),
22
+
ScopeAuthorizationFailure::Exception => throw $this,
23
+
default => abort(403, $this->getMessage()),
24
+
};
25
+
}
26
+
}
+4
-3
src/Facades/Atp.php
+4
-3
src/Facades/Atp.php
···
3
namespace SocialDept\AtpClient\Facades;
4
5
use Illuminate\Support\Facades\Facade;
6
use SocialDept\AtpClient\Auth\OAuthEngine;
7
-
use SocialDept\AtpClient\Client\AtpClient;
8
use SocialDept\AtpClient\Contracts\CredentialProvider;
9
10
/**
11
-
* @method static AtpClient as(string $identifier)
12
-
* @method static AtpClient login(string $identifier, string $password)
13
* @method static OAuthEngine oauth()
14
* @method static void setDefaultProvider(CredentialProvider $provider)
15
*
16
* @see \SocialDept\AtpClient\AtpClientServiceProvider
···
3
namespace SocialDept\AtpClient\Facades;
4
5
use Illuminate\Support\Facades\Facade;
6
+
use SocialDept\AtpClient\AtpClient;
7
use SocialDept\AtpClient\Auth\OAuthEngine;
8
use SocialDept\AtpClient\Contracts\CredentialProvider;
9
10
/**
11
+
* @method static AtpClient as(string $actor)
12
+
* @method static AtpClient login(string $actor, string $password)
13
* @method static OAuthEngine oauth()
14
+
* @method static AtpClient public(?string $service = null)
15
* @method static void setDefaultProvider(CredentialProvider $provider)
16
*
17
* @see \SocialDept\AtpClient\AtpClientServiceProvider
+28
src/Facades/AtpScope.php
+28
src/Facades/AtpScope.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Facades;
4
+
5
+
use Illuminate\Support\Facades\Facade;
6
+
use SocialDept\AtpClient\Auth\ScopeGate;
7
+
use SocialDept\AtpClient\Enums\Scope;
8
+
use SocialDept\AtpClient\Session\Session;
9
+
10
+
/**
11
+
* @method static ScopeGate forSession(Session $session)
12
+
* @method static ScopeGate forUser(string $actor)
13
+
* @method static bool can(string|Scope $scope)
14
+
* @method static bool canAny(array $scopes)
15
+
* @method static bool canAll(array $scopes)
16
+
* @method static bool cannot(string|Scope $scope)
17
+
* @method static void authorize(string|Scope ...$scopes)
18
+
* @method static array granted()
19
+
*
20
+
* @see \SocialDept\AtpClient\Auth\ScopeGate
21
+
*/
22
+
class AtpScope extends Facade
23
+
{
24
+
protected static function getFacadeAccessor(): string
25
+
{
26
+
return 'atp-scope';
27
+
}
28
+
}
+89
src/Http/DPoPClient.php
+89
src/Http/DPoPClient.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Http;
4
+
5
+
use Illuminate\Http\Client\PendingRequest;
6
+
use Illuminate\Support\Facades\Http;
7
+
use Psr\Http\Message\RequestInterface;
8
+
use Psr\Http\Message\ResponseInterface;
9
+
use SocialDept\AtpClient\Auth\DPoPKeyManager;
10
+
use SocialDept\AtpClient\Auth\DPoPNonceManager;
11
+
use SocialDept\AtpClient\Data\DPoPKey;
12
+
13
+
class DPoPClient
14
+
{
15
+
public function __construct(
16
+
protected DPoPKeyManager $dpopManager,
17
+
protected DPoPNonceManager $nonceManager,
18
+
) {}
19
+
20
+
/**
21
+
* Build a DPoP-authenticated request with automatic nonce retry
22
+
*/
23
+
public function request(
24
+
string $pdsEndpoint,
25
+
string $url,
26
+
string $method,
27
+
DPoPKey $dpopKey,
28
+
?string $accessToken = null,
29
+
): PendingRequest {
30
+
return Http::retry(times: 2, sleepMilliseconds: 0, throw: false)
31
+
->withRequestMiddleware(
32
+
fn (RequestInterface $request) => $this->addDPoPProof(
33
+
$request,
34
+
$pdsEndpoint,
35
+
$url,
36
+
$method,
37
+
$dpopKey,
38
+
$accessToken,
39
+
)
40
+
)
41
+
->withResponseMiddleware(
42
+
fn (ResponseInterface $response) => $this->captureNonce($response, $pdsEndpoint)
43
+
);
44
+
}
45
+
46
+
/**
47
+
* Add DPoP proof header to request
48
+
*/
49
+
protected function addDPoPProof(
50
+
RequestInterface $request,
51
+
string $pdsEndpoint,
52
+
string $url,
53
+
string $method,
54
+
DPoPKey $dpopKey,
55
+
?string $accessToken,
56
+
): RequestInterface {
57
+
$nonce = $this->nonceManager->getNonce($pdsEndpoint);
58
+
59
+
$dpopProof = $this->dpopManager->createProof(
60
+
key: $dpopKey,
61
+
method: $method,
62
+
url: $url,
63
+
nonce: $nonce,
64
+
accessToken: $accessToken,
65
+
);
66
+
67
+
$request = $request->withHeader('DPoP', $dpopProof);
68
+
69
+
if ($accessToken) {
70
+
$request = $request->withHeader('Authorization', 'DPoP '.$accessToken);
71
+
}
72
+
73
+
return $request;
74
+
}
75
+
76
+
/**
77
+
* Capture DPoP nonce from response for future requests
78
+
*/
79
+
protected function captureNonce(ResponseInterface $response, string $pdsEndpoint): ResponseInterface
80
+
{
81
+
$nonce = $response->getHeaderLine('DPoP-Nonce');
82
+
83
+
if ($nonce !== '') {
84
+
$this->nonceManager->storeNonce($pdsEndpoint, $nonce);
85
+
}
86
+
87
+
return $response;
88
+
}
89
+
}
+94
-69
src/Http/HasHttp.php
+94
-69
src/Http/HasHttp.php
···
2
3
namespace SocialDept\AtpClient\Http;
4
5
-
use Illuminate\Http\Client\Factory;
6
use Illuminate\Http\Client\Response as LaravelResponse;
7
use InvalidArgumentException;
8
-
use SocialDept\AtpClient\Auth\DPoPKeyManager;
9
-
use SocialDept\AtpClient\Auth\DPoPNonceManager;
10
use SocialDept\AtpClient\Exceptions\ValidationException;
11
use SocialDept\AtpClient\Session\SessionManager;
12
use SocialDept\AtpSchema\Facades\Schema;
13
14
trait HasHttp
15
{
16
-
protected SessionManager $sessions;
17
18
-
protected Factory $http;
19
20
-
protected string $identifier;
21
22
-
protected DPoPNonceManager $nonceManager;
23
24
/**
25
* Make XRPC call
26
*/
27
protected function call(
28
-
string $endpoint,
29
string $method,
30
?array $params = null,
31
?array $body = null
32
): Response {
33
-
// Ensure session is valid (auto-refresh)
34
-
$session = $this->sessions->ensureValid($this->identifier);
35
-
36
-
// Build URL
37
$url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint;
38
39
-
// Get DPoP nonce
40
-
$nonce = $this->nonceManager->getNonce($session->pdsEndpoint());
41
-
42
-
// Create DPoP proof using DPoPKeyManager
43
-
$dpopProof = app(DPoPKeyManager::class)->createProof(
44
-
key: $session->dpopKey(),
45
-
method: $method,
46
-
url: $url,
47
-
nonce: $nonce,
48
-
accessToken: $session->accessToken(),
49
-
);
50
-
51
-
// Filter null parameters
52
$params = array_filter($params ?? [], fn ($v) => ! is_null($v));
53
54
-
// Build request
55
-
$request = $this->http
56
-
->withHeaders([
57
-
'Authorization' => 'Bearer '.$session->accessToken(),
58
-
'DPoP' => $dpopProof,
59
-
]);
60
61
-
// Send request
62
$response = match ($method) {
63
'GET' => $request->get($url, $params),
64
'POST' => $request->post($url, $body ?? $params),
···
66
default => throw new InvalidArgumentException("Unsupported method: {$method}"),
67
};
68
69
-
// Store nonce from response if present
70
-
if ($newNonce = $response->header('DPoP-Nonce')) {
71
-
$this->nonceManager->storeNonce($session->pdsEndpoint(), $newNonce);
72
}
73
74
-
// Validate response if schema exists
75
-
if (Schema::exists($endpoint)) {
76
$this->validateResponse($endpoint, $response);
77
}
78
···
80
}
81
82
/**
83
* Validate response against schema
84
*/
85
protected function validateResponse(string $endpoint, LaravelResponse $response): void
86
{
87
if (! $response->successful()) {
88
-
return; // Don't validate error responses
89
}
90
91
$data = $response->json();
92
93
-
if (! Schema::validate($endpoint, $data)) {
94
-
$errors = Schema::getErrors($endpoint, $data);
95
throw new ValidationException($errors);
96
}
97
}
···
99
/**
100
* Make GET request
101
*/
102
-
protected function get(string $endpoint, array $params = []): Response
103
{
104
return $this->call($endpoint, 'GET', $params);
105
}
···
107
/**
108
* Make POST request
109
*/
110
-
protected function post(string $endpoint, array $body = []): Response
111
{
112
return $this->call($endpoint, 'POST', null, $body);
113
}
···
115
/**
116
* Make DELETE request
117
*/
118
-
protected function delete(string $endpoint, array $params = []): Response
119
{
120
return $this->call($endpoint, 'DELETE', $params);
121
}
···
123
/**
124
* Make POST request with raw binary body (for blob uploads)
125
*/
126
-
protected function postBlob(string $endpoint, string $data, string $mimeType): Response
127
{
128
-
// Ensure session is valid (auto-refresh)
129
-
$session = $this->sessions->ensureValid($this->identifier);
130
-
131
-
// Build URL
132
$url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint;
133
134
-
// Get DPoP nonce
135
-
$nonce = $this->nonceManager->getNonce($session->pdsEndpoint());
136
-
137
-
// Create DPoP proof using DPoPKeyManager
138
-
$dpopProof = app(DPoPKeyManager::class)->createProof(
139
-
key: $session->dpopKey(),
140
-
method: 'POST',
141
-
url: $url,
142
-
nonce: $nonce,
143
-
accessToken: $session->accessToken(),
144
-
);
145
-
146
-
// Build and send request with raw binary body
147
-
$response = $this->http
148
-
->withHeaders([
149
-
'Authorization' => 'Bearer '.$session->accessToken(),
150
-
'DPoP' => $dpopProof,
151
-
])
152
->withBody($data, $mimeType)
153
->post($url);
154
155
-
// Store nonce from response if present
156
-
if ($newNonce = $response->header('DPoP-Nonce')) {
157
-
$this->nonceManager->storeNonce($session->pdsEndpoint(), $newNonce);
158
}
159
160
return new Response($response);
161
}
162
}
···
2
3
namespace SocialDept\AtpClient\Http;
4
5
+
use BackedEnum;
6
use Illuminate\Http\Client\Response as LaravelResponse;
7
+
use Illuminate\Support\Facades\Http;
8
use InvalidArgumentException;
9
+
use SocialDept\AtpClient\Auth\ScopeChecker;
10
+
use SocialDept\AtpClient\Enums\Scope;
11
+
use SocialDept\AtpClient\Exceptions\AtpResponseException;
12
use SocialDept\AtpClient\Exceptions\ValidationException;
13
+
use SocialDept\AtpClient\Session\Session;
14
use SocialDept\AtpClient\Session\SessionManager;
15
use SocialDept\AtpSchema\Facades\Schema;
16
17
trait HasHttp
18
{
19
+
protected ?SessionManager $sessions = null;
20
21
+
protected ?string $did = null;
22
23
+
protected ?DPoPClient $dpopClient = null;
24
25
+
protected ?ScopeChecker $scopeChecker = null;
26
27
/**
28
* Make XRPC call
29
*/
30
protected function call(
31
+
string|BackedEnum $endpoint,
32
string $method,
33
?array $params = null,
34
?array $body = null
35
): Response {
36
+
$endpoint = $endpoint instanceof BackedEnum ? $endpoint->value : $endpoint;
37
+
$session = $this->sessions->ensureValid($this->did);
38
$url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint;
39
40
$params = array_filter($params ?? [], fn ($v) => ! is_null($v));
41
42
+
$request = $this->buildAuthenticatedRequest($session, $url, $method);
43
44
$response = match ($method) {
45
'GET' => $request->get($url, $params),
46
'POST' => $request->post($url, $body ?? $params),
···
48
default => throw new InvalidArgumentException("Unsupported method: {$method}"),
49
};
50
51
+
if ($response->failed() || isset($response->json()['error'])) {
52
+
throw AtpResponseException::fromResponse($response, $endpoint);
53
}
54
55
+
if (config('atp-client.schema_validation') && Schema::exists($endpoint)) {
56
$this->validateResponse($endpoint, $response);
57
}
58
···
60
}
61
62
/**
63
+
* Build authenticated request.
64
+
*
65
+
* OAuth sessions use DPoP proof with Bearer token.
66
+
* Legacy sessions use plain Bearer token.
67
+
*/
68
+
protected function buildAuthenticatedRequest(
69
+
Session $session,
70
+
string $url,
71
+
string $method
72
+
): \Illuminate\Http\Client\PendingRequest {
73
+
if ($session->isLegacy()) {
74
+
return Http::withHeader('Authorization', 'Bearer '.$session->accessToken());
75
+
}
76
+
77
+
return $this->dpopClient->request(
78
+
pdsEndpoint: $session->pdsEndpoint(),
79
+
url: $url,
80
+
method: $method,
81
+
dpopKey: $session->dpopKey(),
82
+
accessToken: $session->accessToken(),
83
+
);
84
+
}
85
+
86
+
/**
87
* Validate response against schema
88
*/
89
protected function validateResponse(string $endpoint, LaravelResponse $response): void
90
{
91
if (! $response->successful()) {
92
+
return;
93
}
94
95
$data = $response->json();
96
97
+
$errors = Schema::validateWithErrors($endpoint, $data);
98
+
99
+
if (! empty($errors)) {
100
throw new ValidationException($errors);
101
}
102
}
···
104
/**
105
* Make GET request
106
*/
107
+
public function get(string|BackedEnum $endpoint, array $params = []): Response
108
{
109
return $this->call($endpoint, 'GET', $params);
110
}
···
112
/**
113
* Make POST request
114
*/
115
+
public function post(string|BackedEnum $endpoint, array $body = []): Response
116
{
117
return $this->call($endpoint, 'POST', null, $body);
118
}
···
120
/**
121
* Make DELETE request
122
*/
123
+
public function delete(string|BackedEnum $endpoint, array $params = []): Response
124
{
125
return $this->call($endpoint, 'DELETE', $params);
126
}
···
128
/**
129
* Make POST request with raw binary body (for blob uploads)
130
*/
131
+
public function postBlob(string|BackedEnum $endpoint, string $data, string $mimeType): Response
132
{
133
+
$endpoint = $endpoint instanceof BackedEnum ? $endpoint->value : $endpoint;
134
+
$session = $this->sessions->ensureValid($this->did);
135
$url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint;
136
137
+
$response = $this->buildAuthenticatedRequest($session, $url, 'POST')
138
->withBody($data, $mimeType)
139
->post($url);
140
141
+
if ($response->failed() || isset($response->json()['error'])) {
142
+
throw AtpResponseException::fromResponse($response, $endpoint);
143
}
144
145
return new Response($response);
146
+
}
147
+
148
+
/**
149
+
* Require specific scopes before making a request.
150
+
*
151
+
* Checks if the session has the required scopes. In strict mode, throws
152
+
* MissingScopeException if scopes are missing. In permissive mode, logs
153
+
* a warning but allows the request to proceed.
154
+
*
155
+
* @param string|Scope ...$scopes The required scopes
156
+
*
157
+
* @throws \SocialDept\AtpClient\Exceptions\MissingScopeException
158
+
*/
159
+
protected function requireScopes(string|Scope ...$scopes): void
160
+
{
161
+
$session = $this->sessions->session($this->did);
162
+
163
+
$this->getScopeChecker()->checkOrFail($session, $scopes);
164
+
}
165
+
166
+
/**
167
+
* Check if the session has a specific scope.
168
+
*/
169
+
protected function hasScope(string|Scope $scope): bool
170
+
{
171
+
$session = $this->sessions->session($this->did);
172
+
173
+
return $this->getScopeChecker()->hasScope($session, $scope);
174
+
}
175
+
176
+
/**
177
+
* Get the scope checker instance.
178
+
*/
179
+
protected function getScopeChecker(): ScopeChecker
180
+
{
181
+
if ($this->scopeChecker === null) {
182
+
$this->scopeChecker = app(ScopeChecker::class);
183
+
}
184
+
185
+
return $this->scopeChecker;
186
}
187
}
+85
src/Http/Middleware/RequiresScopeMiddleware.php
+85
src/Http/Middleware/RequiresScopeMiddleware.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Http\Middleware;
4
+
5
+
use Closure;
6
+
use Illuminate\Http\Request;
7
+
use SocialDept\AtpClient\Auth\ScopeChecker;
8
+
use SocialDept\AtpClient\Contracts\HasAtpSession;
9
+
use SocialDept\AtpClient\Enums\ScopeAuthorizationFailure;
10
+
use SocialDept\AtpClient\Exceptions\ScopeAuthorizationException;
11
+
use SocialDept\AtpClient\Session\SessionManager;
12
+
use Symfony\Component\HttpFoundation\Response;
13
+
14
+
class RequiresScopeMiddleware
15
+
{
16
+
public function __construct(
17
+
protected SessionManager $sessions,
18
+
protected ScopeChecker $checker,
19
+
) {}
20
+
21
+
/**
22
+
* Handle an incoming request.
23
+
*
24
+
* @param string ...$scopes
25
+
*/
26
+
public function handle(Request $request, Closure $next, string ...$scopes): Response
27
+
{
28
+
$user = $request->user();
29
+
30
+
// Ensure user is authenticated
31
+
if (! $user) {
32
+
return $this->handleFailure(
33
+
new ScopeAuthorizationException($scopes, [], 'User not authenticated.')
34
+
);
35
+
}
36
+
37
+
// Ensure user implements HasAtpSession
38
+
if (! $user instanceof HasAtpSession) {
39
+
return $this->handleFailure(
40
+
new ScopeAuthorizationException($scopes, [], 'User model must implement HasAtpSession interface.')
41
+
);
42
+
}
43
+
44
+
$did = $user->getAtpDid();
45
+
46
+
if (! $did) {
47
+
return $this->handleFailure(
48
+
new ScopeAuthorizationException($scopes, [], 'User has no ATP session.')
49
+
);
50
+
}
51
+
52
+
try {
53
+
$session = $this->sessions->session($did);
54
+
} catch (\Exception $e) {
55
+
return $this->handleFailure(
56
+
new ScopeAuthorizationException($scopes, [], 'Could not retrieve ATP session: '.$e->getMessage())
57
+
);
58
+
}
59
+
60
+
// Check ALL scopes (AND logic)
61
+
if (! $this->checker->check($session, $scopes)) {
62
+
$granted = $session->scopes();
63
+
$missing = array_diff($scopes, $granted);
64
+
65
+
return $this->handleFailure(
66
+
new ScopeAuthorizationException($missing, $granted)
67
+
);
68
+
}
69
+
70
+
return $next($request);
71
+
}
72
+
73
+
protected function handleFailure(ScopeAuthorizationException $exception): Response
74
+
{
75
+
$action = config('atp-client.scope_authorization.failure_action', ScopeAuthorizationFailure::Abort);
76
+
77
+
return match ($action) {
78
+
ScopeAuthorizationFailure::Redirect => redirect(
79
+
config('atp-client.scope_authorization.redirect_to', '/login')
80
+
),
81
+
ScopeAuthorizationFailure::Exception => throw $exception,
82
+
default => abort(403, $exception->getMessage()),
83
+
};
84
+
}
85
+
}
+12
-9
src/Providers/ArrayCredentialProvider.php
+12
-9
src/Providers/ArrayCredentialProvider.php
···
15
*/
16
protected array $credentials = [];
17
18
-
public function getCredentials(string $identifier): ?Credentials
19
{
20
-
return $this->credentials[$identifier] ?? null;
21
}
22
23
-
public function storeCredentials(string $identifier, AccessToken $token): void
24
{
25
-
$this->credentials[$identifier] = new Credentials(
26
-
identifier: $identifier,
27
did: $token->did,
28
accessToken: $token->accessJwt,
29
refreshToken: $token->refreshJwt,
30
expiresAt: $token->expiresAt,
31
);
32
}
33
34
-
public function updateCredentials(string $identifier, AccessToken $token): void
35
{
36
-
$this->storeCredentials($identifier, $token);
37
}
38
39
-
public function removeCredentials(string $identifier): void
40
{
41
-
unset($this->credentials[$identifier]);
42
}
43
}
···
15
*/
16
protected array $credentials = [];
17
18
+
public function getCredentials(string $did): ?Credentials
19
{
20
+
return $this->credentials[$did] ?? null;
21
}
22
23
+
public function storeCredentials(string $did, AccessToken $token): void
24
{
25
+
$this->credentials[$did] = new Credentials(
26
did: $token->did,
27
accessToken: $token->accessJwt,
28
refreshToken: $token->refreshJwt,
29
expiresAt: $token->expiresAt,
30
+
handle: $token->handle,
31
+
issuer: $token->issuer,
32
+
scope: $token->scope,
33
+
authType: $token->authType,
34
);
35
}
36
37
+
public function updateCredentials(string $did, AccessToken $token): void
38
{
39
+
$this->storeCredentials($did, $token);
40
}
41
42
+
public function removeCredentials(string $did): void
43
{
44
+
unset($this->credentials[$did]);
45
}
46
}
+52
src/Providers/CacheCredentialProvider.php
+52
src/Providers/CacheCredentialProvider.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Providers;
4
+
5
+
use Illuminate\Support\Facades\Cache;
6
+
use SocialDept\AtpClient\Contracts\CredentialProvider;
7
+
use SocialDept\AtpClient\Data\AccessToken;
8
+
use SocialDept\AtpClient\Data\Credentials;
9
+
10
+
class CacheCredentialProvider implements CredentialProvider
11
+
{
12
+
protected string $prefix = 'atp:credentials:';
13
+
14
+
public function getCredentials(string $did): ?Credentials
15
+
{
16
+
return Cache::get($this->key($did));
17
+
}
18
+
19
+
public function storeCredentials(string $did, AccessToken $token): void
20
+
{
21
+
Cache::put($this->key($did), $this->toCredentials($token));
22
+
}
23
+
24
+
public function updateCredentials(string $did, AccessToken $token): void
25
+
{
26
+
$this->storeCredentials($did, $token);
27
+
}
28
+
29
+
public function removeCredentials(string $did): void
30
+
{
31
+
Cache::forget($this->key($did));
32
+
}
33
+
34
+
protected function key(string $did): string
35
+
{
36
+
return $this->prefix.$did;
37
+
}
38
+
39
+
protected function toCredentials(AccessToken $token): Credentials
40
+
{
41
+
return new Credentials(
42
+
did: $token->did,
43
+
accessToken: $token->accessJwt,
44
+
refreshToken: $token->refreshJwt,
45
+
expiresAt: $token->expiresAt,
46
+
handle: $token->handle,
47
+
issuer: $token->issuer,
48
+
scope: $token->scope,
49
+
authType: $token->authType,
50
+
);
51
+
}
52
+
}
+70
src/Providers/FileCredentialProvider.php
+70
src/Providers/FileCredentialProvider.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Providers;
4
+
5
+
use SocialDept\AtpClient\Contracts\CredentialProvider;
6
+
use SocialDept\AtpClient\Data\AccessToken;
7
+
use SocialDept\AtpClient\Data\Credentials;
8
+
9
+
class FileCredentialProvider implements CredentialProvider
10
+
{
11
+
protected string $storagePath;
12
+
13
+
public function __construct(?string $storagePath = null)
14
+
{
15
+
$this->storagePath = $storagePath ?? storage_path('app/atp-credentials');
16
+
17
+
if (! is_dir($this->storagePath)) {
18
+
mkdir($this->storagePath, 0755, true);
19
+
}
20
+
}
21
+
22
+
public function getCredentials(string $did): ?Credentials
23
+
{
24
+
$path = $this->path($did);
25
+
26
+
if (! file_exists($path)) {
27
+
return null;
28
+
}
29
+
30
+
return unserialize(file_get_contents($path));
31
+
}
32
+
33
+
public function storeCredentials(string $did, AccessToken $token): void
34
+
{
35
+
file_put_contents($this->path($did), serialize($this->toCredentials($token)));
36
+
}
37
+
38
+
public function updateCredentials(string $did, AccessToken $token): void
39
+
{
40
+
$this->storeCredentials($did, $token);
41
+
}
42
+
43
+
public function removeCredentials(string $did): void
44
+
{
45
+
$path = $this->path($did);
46
+
47
+
if (file_exists($path)) {
48
+
unlink($path);
49
+
}
50
+
}
51
+
52
+
protected function path(string $did): string
53
+
{
54
+
return $this->storagePath.'/'.hash('sha256', $did).'.cred';
55
+
}
56
+
57
+
protected function toCredentials(AccessToken $token): Credentials
58
+
{
59
+
return new Credentials(
60
+
did: $token->did,
61
+
accessToken: $token->accessJwt,
62
+
refreshToken: $token->refreshJwt,
63
+
expiresAt: $token->expiresAt,
64
+
handle: $token->handle,
65
+
issuer: $token->issuer,
66
+
scope: $token->scope,
67
+
authType: $token->authType,
68
+
);
69
+
}
70
+
}
+52
src/Providers/SessionCredentialProvider.php
+52
src/Providers/SessionCredentialProvider.php
···
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Providers;
4
+
5
+
use Illuminate\Support\Facades\Session;
6
+
use SocialDept\AtpClient\Contracts\CredentialProvider;
7
+
use SocialDept\AtpClient\Data\AccessToken;
8
+
use SocialDept\AtpClient\Data\Credentials;
9
+
10
+
class SessionCredentialProvider implements CredentialProvider
11
+
{
12
+
protected string $prefix = 'atp.credentials.';
13
+
14
+
public function getCredentials(string $did): ?Credentials
15
+
{
16
+
return Session::get($this->key($did));
17
+
}
18
+
19
+
public function storeCredentials(string $did, AccessToken $token): void
20
+
{
21
+
Session::put($this->key($did), $this->toCredentials($token));
22
+
}
23
+
24
+
public function updateCredentials(string $did, AccessToken $token): void
25
+
{
26
+
$this->storeCredentials($did, $token);
27
+
}
28
+
29
+
public function removeCredentials(string $did): void
30
+
{
31
+
Session::forget($this->key($did));
32
+
}
33
+
34
+
protected function key(string $did): string
35
+
{
36
+
return $this->prefix.$did;
37
+
}
38
+
39
+
protected function toCredentials(AccessToken $token): Credentials
40
+
{
41
+
return new Credentials(
42
+
did: $token->did,
43
+
accessToken: $token->accessJwt,
44
+
refreshToken: $token->refreshJwt,
45
+
expiresAt: $token->expiresAt,
46
+
handle: $token->handle,
47
+
issuer: $token->issuer,
48
+
scope: $token->scope,
49
+
authType: $token->authType,
50
+
);
51
+
}
52
+
}
+5
-186
src/RichText/TextBuilder.php
+5
-186
src/RichText/TextBuilder.php
···
2
3
namespace SocialDept\AtpClient\RichText;
4
5
-
use SocialDept\AtpResolver\Facades\Resolver;
6
7
class TextBuilder
8
{
9
-
protected string $text = '';
10
-
protected array $facets = [];
11
12
/**
13
* Create a new text builder instance
14
*/
15
public static function make(): self
16
{
17
-
return new self();
18
}
19
20
/**
···
22
*/
23
public static function build(callable $callback): array
24
{
25
-
$builder = new self();
26
$callback($builder);
27
28
return $builder->toArray();
29
}
30
31
/**
32
-
* Add plain text
33
-
*/
34
-
public function text(string $text): self
35
-
{
36
-
$this->text .= $text;
37
-
38
-
return $this;
39
-
}
40
-
41
-
/**
42
-
* Add a new line
43
-
*/
44
-
public function newLine(): self
45
-
{
46
-
$this->text .= "\n";
47
-
48
-
return $this;
49
-
}
50
-
51
-
/**
52
-
* Add mention (@handle)
53
-
*/
54
-
public function mention(string $handle, ?string $did = null): self
55
-
{
56
-
$handle = ltrim($handle, '@');
57
-
$start = $this->getBytePosition();
58
-
$this->text .= '@'.$handle;
59
-
$end = $this->getBytePosition();
60
-
61
-
// Resolve DID if not provided
62
-
if (! $did) {
63
-
try {
64
-
$did = Resolver::handleToDid($handle);
65
-
} catch (\Exception $e) {
66
-
// If resolution fails, still add the text but skip the facet
67
-
return $this;
68
-
}
69
-
}
70
-
71
-
$this->facets[] = [
72
-
'index' => [
73
-
'byteStart' => $start,
74
-
'byteEnd' => $end,
75
-
],
76
-
'features' => [
77
-
[
78
-
'$type' => 'app.bsky.richtext.facet#mention',
79
-
'did' => $did,
80
-
],
81
-
],
82
-
];
83
-
84
-
return $this;
85
-
}
86
-
87
-
/**
88
-
* Add link with custom display text
89
-
*/
90
-
public function link(string $text, string $uri): self
91
-
{
92
-
$start = $this->getBytePosition();
93
-
$this->text .= $text;
94
-
$end = $this->getBytePosition();
95
-
96
-
$this->facets[] = [
97
-
'index' => [
98
-
'byteStart' => $start,
99
-
'byteEnd' => $end,
100
-
],
101
-
'features' => [
102
-
[
103
-
'$type' => 'app.bsky.richtext.facet#link',
104
-
'uri' => $uri,
105
-
],
106
-
],
107
-
];
108
-
109
-
return $this;
110
-
}
111
-
112
-
/**
113
-
* Add a URL (displayed as-is)
114
-
*/
115
-
public function url(string $url): self
116
-
{
117
-
return $this->link($url, $url);
118
-
}
119
-
120
-
/**
121
-
* Add hashtag
122
-
*/
123
-
public function tag(string $tag): self
124
-
{
125
-
$tag = ltrim($tag, '#');
126
-
127
-
$start = $this->getBytePosition();
128
-
$this->text .= '#'.$tag;
129
-
$end = $this->getBytePosition();
130
-
131
-
$this->facets[] = [
132
-
'index' => [
133
-
'byteStart' => $start,
134
-
'byteEnd' => $end,
135
-
],
136
-
'features' => [
137
-
[
138
-
'$type' => 'app.bsky.richtext.facet#tag',
139
-
'tag' => $tag,
140
-
],
141
-
],
142
-
];
143
-
144
-
return $this;
145
-
}
146
-
147
-
/**
148
-
* Auto-detect and add facets from plain text
149
-
*/
150
-
public function autoDetect(string $text): self
151
-
{
152
-
$start = $this->getBytePosition();
153
-
$this->text .= $text;
154
-
155
-
// Detect facets in the added text
156
-
$detected = FacetDetector::detect($text);
157
-
158
-
// Adjust byte positions to account for existing text
159
-
foreach ($detected as $facet) {
160
-
$facet['index']['byteStart'] += $start;
161
-
$facet['index']['byteEnd'] += $start;
162
-
$this->facets[] = $facet;
163
-
}
164
-
165
-
return $this;
166
-
}
167
-
168
-
/**
169
-
* Get current byte position
170
-
*/
171
-
protected function getBytePosition(): int
172
-
{
173
-
return strlen($this->text);
174
-
}
175
-
176
-
/**
177
-
* Get the text content
178
-
*/
179
-
public function getText(): string
180
-
{
181
-
return $this->text;
182
-
}
183
-
184
-
/**
185
-
* Get the facets
186
-
*/
187
-
public function getFacets(): array
188
-
{
189
-
return $this->facets;
190
-
}
191
-
192
-
/**
193
* Build the final text and facets array
194
*/
195
public function toArray(): array
196
{
197
-
return [
198
-
'text' => $this->text,
199
-
'facets' => $this->facets,
200
-
];
201
}
202
203
/**
···
233
public function getByteCount(): int
234
{
235
return strlen($this->text);
236
-
}
237
-
238
-
/**
239
-
* Check if text exceeds AT Protocol post limit (300 graphemes)
240
-
*/
241
-
public function exceedsLimit(int $limit = 300): bool
242
-
{
243
-
return $this->getGraphemeCount() > $limit;
244
-
}
245
-
246
-
/**
247
-
* Get grapheme count (closest to what AT Protocol uses)
248
-
*/
249
-
public function getGraphemeCount(): int
250
-
{
251
-
return grapheme_strlen($this->text);
252
}
253
254
/**
···
2
3
namespace SocialDept\AtpClient\RichText;
4
5
+
use SocialDept\AtpClient\Builders\Concerns\BuildsRichText;
6
7
class TextBuilder
8
{
9
+
use BuildsRichText;
10
11
/**
12
* Create a new text builder instance
13
*/
14
public static function make(): self
15
{
16
+
return new self;
17
}
18
19
/**
···
21
*/
22
public static function build(callable $callback): array
23
{
24
+
$builder = new self;
25
$callback($builder);
26
27
return $builder->toArray();
28
}
29
30
/**
31
* Build the final text and facets array
32
*/
33
public function toArray(): array
34
{
35
+
return $this->getTextAndFacets();
36
}
37
38
/**
···
68
public function getByteCount(): int
69
{
70
return strlen($this->text);
71
}
72
73
/**
+76
-4
src/Session/Session.php
+76
-4
src/Session/Session.php
···
4
5
use SocialDept\AtpClient\Data\Credentials;
6
use SocialDept\AtpClient\Data\DPoPKey;
7
8
class Session
9
{
···
13
protected string $pdsEndpoint,
14
) {}
15
16
-
public function identifier(): string
17
{
18
-
return $this->credentials->identifier;
19
}
20
21
-
public function did(): string
22
{
23
-
return $this->credentials->did;
24
}
25
26
public function accessToken(): string
···
51
public function expiresIn(): int
52
{
53
return $this->credentials->expiresIn();
54
}
55
56
public function withCredentials(Credentials $credentials): self
···
4
5
use SocialDept\AtpClient\Data\Credentials;
6
use SocialDept\AtpClient\Data\DPoPKey;
7
+
use SocialDept\AtpClient\Enums\AuthType;
8
+
use SocialDept\AtpClient\Enums\Scope;
9
10
class Session
11
{
···
15
protected string $pdsEndpoint,
16
) {}
17
18
+
public function did(): string
19
{
20
+
return $this->credentials->did;
21
}
22
23
+
public function handle(): ?string
24
{
25
+
return $this->credentials->handle;
26
}
27
28
public function accessToken(): string
···
53
public function expiresIn(): int
54
{
55
return $this->credentials->expiresIn();
56
+
}
57
+
58
+
public function scopes(): array
59
+
{
60
+
return $this->credentials->scope;
61
+
}
62
+
63
+
public function hasScope(string $scope): bool
64
+
{
65
+
return in_array($scope, $this->credentials->scope, true);
66
+
}
67
+
68
+
/**
69
+
* Check if the session has the given scope (alias for hasScope with Scope enum support).
70
+
*/
71
+
public function can(string|Scope $scope): bool
72
+
{
73
+
$scopeValue = $scope instanceof Scope ? $scope->value : $scope;
74
+
75
+
return $this->hasScope($scopeValue);
76
+
}
77
+
78
+
/**
79
+
* Check if the session has any of the given scopes.
80
+
*
81
+
* @param array<string|Scope> $scopes
82
+
*/
83
+
public function canAny(array $scopes): bool
84
+
{
85
+
foreach ($scopes as $scope) {
86
+
if ($this->can($scope)) {
87
+
return true;
88
+
}
89
+
}
90
+
91
+
return false;
92
+
}
93
+
94
+
/**
95
+
* Check if the session has all of the given scopes.
96
+
*
97
+
* @param array<string|Scope> $scopes
98
+
*/
99
+
public function canAll(array $scopes): bool
100
+
{
101
+
foreach ($scopes as $scope) {
102
+
if (! $this->can($scope)) {
103
+
return false;
104
+
}
105
+
}
106
+
107
+
return true;
108
+
}
109
+
110
+
/**
111
+
* Check if the session does NOT have the given scope.
112
+
*/
113
+
public function cannot(string|Scope $scope): bool
114
+
{
115
+
return ! $this->can($scope);
116
+
}
117
+
118
+
public function authType(): AuthType
119
+
{
120
+
return $this->credentials->authType;
121
+
}
122
+
123
+
public function isLegacy(): bool
124
+
{
125
+
return $this->credentials->authType === AuthType::Legacy;
126
}
127
128
public function withCredentials(Credentials $credentials): self
+64
-34
src/Session/SessionManager.php
+64
-34
src/Session/SessionManager.php
···
2
3
namespace SocialDept\AtpClient\Session;
4
5
-
use Illuminate\Http\Client\Factory as HttpClient;
6
use SocialDept\AtpClient\Auth\DPoPKeyManager;
7
use SocialDept\AtpClient\Auth\TokenRefresher;
8
use SocialDept\AtpClient\Contracts\CredentialProvider;
9
use SocialDept\AtpClient\Contracts\KeyStore;
10
use SocialDept\AtpClient\Data\AccessToken;
11
-
use SocialDept\AtpClient\Events\TokenRefreshed;
12
-
use SocialDept\AtpClient\Events\TokenRefreshing;
13
use SocialDept\AtpClient\Exceptions\AuthenticationException;
14
use SocialDept\AtpClient\Exceptions\SessionExpiredException;
15
use SocialDept\AtpResolver\Facades\Resolver;
16
17
class SessionManager
18
{
···
23
protected TokenRefresher $refresher,
24
protected DPoPKeyManager $dpopManager,
25
protected KeyStore $keyStore,
26
-
protected HttpClient $http,
27
protected int $refreshThreshold = 300, // 5 minutes
28
) {}
29
30
/**
31
-
* Get or create session for identifier
32
*/
33
-
public function session(string $identifier): Session
34
{
35
-
if (! isset($this->sessions[$identifier])) {
36
-
$this->sessions[$identifier] = $this->createSession($identifier);
37
}
38
39
-
return $this->sessions[$identifier];
40
}
41
42
/**
43
-
* Ensure session is valid, refresh if needed
44
*/
45
-
public function ensureValid(string $identifier): Session
46
{
47
-
$session = $this->session($identifier);
48
49
// Check if token needs refresh
50
if ($session->expiresIn() < $this->refreshThreshold) {
···
55
}
56
57
/**
58
-
* Create session from app password
59
*/
60
public function fromAppPassword(
61
-
string $identifier,
62
string $password
63
): Session {
64
-
$pdsEndpoint = Resolver::resolvePds($identifier);
65
66
-
$response = $this->http->post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [
67
-
'identifier' => $identifier,
68
'password' => $password,
69
]);
70
···
72
throw new AuthenticationException('Login failed');
73
}
74
75
-
$token = AccessToken::fromResponse($response->json());
76
77
-
// Store credentials
78
-
$this->credentials->storeCredentials($identifier, $token);
79
80
-
return $this->createSession($identifier);
81
}
82
83
/**
84
* Create session from credentials
85
*/
86
-
protected function createSession(string $identifier): Session
87
{
88
-
$creds = $this->credentials->getCredentials($identifier);
89
90
if (! $creds) {
91
-
throw new SessionExpiredException("No credentials found for {$identifier}");
92
}
93
94
// Get or create DPoP key
···
99
$dpopKey = $this->dpopManager->generateKey($sessionId);
100
}
101
102
-
// Resolve PDS endpoint
103
-
$pdsEndpoint = Resolver::resolvePds($creds->did);
104
105
return new Session($creds, $dpopKey, $pdsEndpoint);
106
}
···
110
*/
111
protected function refreshSession(Session $session): Session
112
{
113
// Fire event before refresh (allows developers to invalidate old token)
114
-
event(new TokenRefreshing($session->identifier(), $session->refreshToken()));
115
116
$newToken = $this->refresher->refresh(
117
refreshToken: $session->refreshToken(),
118
pdsEndpoint: $session->pdsEndpoint(),
119
dpopKey: $session->dpopKey(),
120
);
121
122
// Update credentials (CRITICAL: refresh tokens are single-use)
123
-
$this->credentials->updateCredentials(
124
-
$session->identifier(),
125
-
$newToken
126
-
);
127
128
// Fire event after successful refresh
129
-
event(new TokenRefreshed($session->identifier(), $newToken));
130
131
// Update session
132
-
$newCreds = $this->credentials->getCredentials($session->identifier());
133
$newSession = $session->withCredentials($newCreds);
134
135
// Update cached session
136
-
$this->sessions[$session->identifier()] = $newSession;
137
138
return $newSession;
139
}
···
2
3
namespace SocialDept\AtpClient\Session;
4
5
+
use Illuminate\Support\Facades\Http;
6
use SocialDept\AtpClient\Auth\DPoPKeyManager;
7
use SocialDept\AtpClient\Auth\TokenRefresher;
8
use SocialDept\AtpClient\Contracts\CredentialProvider;
9
use SocialDept\AtpClient\Contracts\KeyStore;
10
use SocialDept\AtpClient\Data\AccessToken;
11
+
use SocialDept\AtpClient\Events\SessionAuthenticated;
12
+
use SocialDept\AtpClient\Events\SessionRefreshing;
13
+
use SocialDept\AtpClient\Events\SessionUpdated;
14
use SocialDept\AtpClient\Exceptions\AuthenticationException;
15
+
use SocialDept\AtpClient\Exceptions\HandleResolutionException;
16
use SocialDept\AtpClient\Exceptions\SessionExpiredException;
17
use SocialDept\AtpResolver\Facades\Resolver;
18
+
use SocialDept\AtpResolver\Support\Identity;
19
20
class SessionManager
21
{
···
26
protected TokenRefresher $refresher,
27
protected DPoPKeyManager $dpopManager,
28
protected KeyStore $keyStore,
29
protected int $refreshThreshold = 300, // 5 minutes
30
) {}
31
32
/**
33
+
* Resolve an actor (handle or DID) to a DID.
34
+
*
35
+
* @throws HandleResolutionException
36
*/
37
+
protected function resolveToDid(string $actor): string
38
{
39
+
// If already a DID, return as-is
40
+
if (Identity::isDid($actor)) {
41
+
return $actor;
42
+
}
43
+
44
+
// Resolve handle to DID
45
+
$did = Resolver::handleToDid($actor);
46
+
47
+
if (! $did) {
48
+
throw new HandleResolutionException($actor);
49
}
50
51
+
return $did;
52
}
53
54
/**
55
+
* Get or create session for an actor.
56
*/
57
+
public function session(string $actor): Session
58
{
59
+
$did = $this->resolveToDid($actor);
60
+
61
+
if (! isset($this->sessions[$did])) {
62
+
$this->sessions[$did] = $this->createSession($did);
63
+
}
64
+
65
+
return $this->sessions[$did];
66
+
}
67
+
68
+
/**
69
+
* Ensure session is valid, refresh if needed.
70
+
*/
71
+
public function ensureValid(string $actor): Session
72
+
{
73
+
$session = $this->session($actor);
74
75
// Check if token needs refresh
76
if ($session->expiresIn() < $this->refreshThreshold) {
···
81
}
82
83
/**
84
+
* Create session from app password.
85
*/
86
public function fromAppPassword(
87
+
string $actor,
88
string $password
89
): Session {
90
+
$did = $this->resolveToDid($actor);
91
+
$pdsEndpoint = Resolver::resolvePds($did);
92
93
+
$response = Http::post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [
94
+
'identifier' => $actor,
95
'password' => $password,
96
]);
97
···
99
throw new AuthenticationException('Login failed');
100
}
101
102
+
$token = AccessToken::fromResponse($response->json(), $actor, $pdsEndpoint);
103
104
+
// Store credentials using DID as key
105
+
$this->credentials->storeCredentials($did, $token);
106
107
+
event(new SessionAuthenticated($token));
108
+
109
+
return $this->createSession($did);
110
}
111
112
/**
113
* Create session from credentials
114
*/
115
+
protected function createSession(string $did): Session
116
{
117
+
$creds = $this->credentials->getCredentials($did);
118
119
if (! $creds) {
120
+
throw new SessionExpiredException("No credentials found for {$did}");
121
}
122
123
// Get or create DPoP key
···
128
$dpopKey = $this->dpopManager->generateKey($sessionId);
129
}
130
131
+
// Use stored issuer if available, otherwise resolve PDS endpoint
132
+
$pdsEndpoint = $creds->issuer ?? Resolver::resolvePds($creds->did);
133
134
return new Session($creds, $dpopKey, $pdsEndpoint);
135
}
···
139
*/
140
protected function refreshSession(Session $session): Session
141
{
142
+
$did = $session->did();
143
+
144
// Fire event before refresh (allows developers to invalidate old token)
145
+
event(new SessionRefreshing($session));
146
147
$newToken = $this->refresher->refresh(
148
refreshToken: $session->refreshToken(),
149
pdsEndpoint: $session->pdsEndpoint(),
150
dpopKey: $session->dpopKey(),
151
+
handle: $session->handle(),
152
+
authType: $session->authType(),
153
);
154
155
// Update credentials (CRITICAL: refresh tokens are single-use)
156
+
$this->credentials->updateCredentials($did, $newToken);
157
158
// Fire event after successful refresh
159
+
event(new SessionUpdated($session, $newToken));
160
161
// Update session
162
+
$newCreds = $this->credentials->getCredentials($did);
163
$newSession = $session->withCredentials($newCreds);
164
165
// Update cached session
166
+
$this->sessions[$did] = $newSession;
167
168
return $newSession;
169
}
+4
-8
src/Storage/EncryptedFileKeyStore.php
+4
-8
src/Storage/EncryptedFileKeyStore.php
···
3
namespace SocialDept\AtpClient\Storage;
4
5
use Illuminate\Contracts\Encryption\Encrypter;
6
-
use phpseclib3\Crypt\PublicKeyLoader;
7
use SocialDept\AtpClient\Contracts\KeyStore;
8
use SocialDept\AtpClient\Data\DPoPKey;
9
···
23
public function store(string $sessionId, DPoPKey $key): void
24
{
25
$data = [
26
-
'privateKey' => $key->privateKey->toString('PKCS8'),
27
-
'publicKey' => $key->publicKey->toString('PKCS8'),
28
'keyId' => $key->keyId,
29
];
30
···
47
$encrypted = file_get_contents($path);
48
$data = $this->encrypter->decrypt($encrypted);
49
50
-
$privateKey = PublicKeyLoader::load($data['privateKey']);
51
-
$publicKey = PublicKeyLoader::load($data['publicKey']);
52
-
53
return new DPoPKey(
54
-
privateKey: $privateKey,
55
-
publicKey: $publicKey,
56
keyId: $data['keyId'],
57
);
58
}
···
3
namespace SocialDept\AtpClient\Storage;
4
5
use Illuminate\Contracts\Encryption\Encrypter;
6
use SocialDept\AtpClient\Contracts\KeyStore;
7
use SocialDept\AtpClient\Data\DPoPKey;
8
···
22
public function store(string $sessionId, DPoPKey $key): void
23
{
24
$data = [
25
+
'privateKey' => $key->toPEM(),
26
+
'publicKey' => $key->getPublicKey()->toString('PKCS8'),
27
'keyId' => $key->keyId,
28
];
29
···
46
$encrypted = file_get_contents($path);
47
$data = $this->encrypter->decrypt($encrypted);
48
49
return new DPoPKey(
50
+
privateKey: $data['privateKey'],
51
+
publicKey: $data['publicKey'],
52
keyId: $data['keyId'],
53
);
54
}