Laravel AT Protocol Client (alpha & unstable)

Compare changes

Choose any two refs to compare.

Changed files
+7990 -855
config
docs
src
Attributes
Auth
Builders
Client
Concerns
Console
Contracts
Data
Responses
Enums
Events
Exceptions
Facades
Http
Providers
RichText
Session
Storage
+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
··· 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
··· 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
···
··· 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
···
··· 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 + ```
+16 -4
src/AtpClient.php
··· 7 use SocialDept\AtpClient\Client\ChatClient; 8 use SocialDept\AtpClient\Client\Client; 9 use SocialDept\AtpClient\Client\OzoneClient; 10 use SocialDept\AtpClient\Session\SessionManager; 11 12 class AtpClient 13 { 14 /** 15 * Raw API communication/networking class 16 */ ··· 37 public OzoneClient $ozone; 38 39 public function __construct( 40 - SessionManager $sessions, 41 - string $identifier, 42 ) { 43 - // Load the network client 44 - $this->client = new Client($this, $sessions, $identifier); 45 46 // Load all function collections 47 $this->bsky = new BskyClient($this); 48 $this->atproto = new AtprotoClient($this); 49 $this->chat = new ChatClient($this); 50 $this->ozone = new OzoneClient($this); 51 } 52 }
··· 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 }
+54 -10
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; ··· 42 43 // Register core services 44 $this->app->singleton(ClientMetadataManager::class); 45 $this->app->singleton(DPoPKeyManager::class); 46 $this->app->singleton(DPoPNonceManager::class); 47 $this->app->singleton(DPoPClient::class); ··· 56 ); 57 }); 58 $this->app->singleton(OAuthEngine::class); 59 60 // Register main client facade accessor 61 $this->app->bind('atp-client', function ($app) { ··· 70 $this->app = $app; 71 } 72 73 - public function as(string $identifier): AtpClient 74 { 75 return new AtpClient( 76 $this->app->make(SessionManager::class), 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; ··· 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); ··· 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
···
··· 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
···
··· 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
···
··· 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
··· 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 }
+44 -32
src/Auth/OAuthEngine.php
··· 6 use SocialDept\AtpClient\Data\AccessToken; 7 use SocialDept\AtpClient\Data\AuthorizationRequest; 8 use SocialDept\AtpClient\Data\DPoPKey; 9 use SocialDept\AtpClient\Exceptions\AuthenticationException; 10 use SocialDept\AtpClient\Http\DPoPClient; 11 use SocialDept\AtpResolver\Facades\Resolver; ··· 16 protected DPoPKeyManager $dpopManager, 17 protected ClientMetadataManager $metadata, 18 protected DPoPClient $dpopClient, 19 ) {} 20 21 /** ··· 23 */ 24 public function authorize( 25 string $identifier, 26 - array $scopes = ['atproto', 'transition:generic'], 27 ?string $pdsEndpoint = null 28 ): AuthorizationRequest { 29 // Resolve PDS endpoint 30 if (! $pdsEndpoint) { 31 $pdsEndpoint = Resolver::resolvePds($identifier); ··· 46 $pdsEndpoint, 47 $scopes, 48 $codeChallenge, 49 $dpopKey 50 ); 51 ··· 61 codeVerifier: $codeVerifier, 62 dpopKey: $dpopKey, 63 requestUri: $parResponse['request_uri'], 64 ); 65 } 66 ··· 76 throw new AuthenticationException('State mismatch'); 77 } 78 79 - // Get PDS endpoint from request 80 - $pdsEndpoint = $this->extractPdsFromRequestUri($request->requestUri); 81 - $tokenUrl = $pdsEndpoint.'/oauth/token'; 82 83 - $response = $this->dpopClient->request($pdsEndpoint, $tokenUrl, 'POST', $request->dpopKey) 84 ->asForm() 85 - ->post($tokenUrl, [ 86 - 'grant_type' => 'authorization_code', 87 - 'code' => $code, 88 - 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 89 - 'client_id' => $this->metadata->getClientId(), 90 - 'code_verifier' => $request->codeVerifier, 91 - ]); 92 93 if ($response->failed()) { 94 throw new AuthenticationException('Token exchange failed: '.$response->body()); 95 } 96 97 - return AccessToken::fromResponse($response->json()); 98 } 99 100 /** ··· 104 string $pdsEndpoint, 105 array $scopes, 106 string $codeChallenge, 107 DPoPKey $dpopKey 108 ): array { 109 $parUrl = $pdsEndpoint.'/oauth/par'; 110 111 $response = $this->dpopClient->request($pdsEndpoint, $parUrl, 'POST', $dpopKey) 112 ->asForm() 113 - ->post($parUrl, [ 114 - 'client_id' => $this->metadata->getClientId(), 115 - 'redirect_uri' => $this->metadata->getRedirectUris()[0] ?? null, 116 - 'response_type' => 'code', 117 - 'scope' => implode(' ', $scopes), 118 - 'code_challenge' => $codeChallenge, 119 - 'code_challenge_method' => 'S256', 120 - 'state' => Str::random(32), 121 - ]); 122 123 if ($response->failed()) { 124 throw new AuthenticationException('PAR failed: '.$response->body()); ··· 133 protected function generatePkceChallenge(string $verifier): string 134 { 135 return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); 136 - } 137 - 138 - /** 139 - * Extract PDS endpoint from request URI 140 - */ 141 - protected function extractPdsFromRequestUri(string $requestUri): string 142 - { 143 - $parts = parse_url($requestUri); 144 - 145 - return ($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? ''); 146 } 147 }
··· 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; ··· 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
···
··· 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
···
··· 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 + }
+48 -7
src/Auth/TokenRefresher.php
··· 2 3 namespace SocialDept\AtpClient\Auth; 4 5 use SocialDept\AtpClient\Data\AccessToken; 6 use SocialDept\AtpClient\Data\DPoPKey; 7 use SocialDept\AtpClient\Exceptions\AuthenticationException; 8 use SocialDept\AtpClient\Http\DPoPClient; 9 ··· 11 { 12 public function __construct( 13 protected DPoPClient $dpopClient, 14 ) {} 15 16 /** 17 - * Refresh access token using refresh token 18 * NOTE: Refresh tokens are single-use! 19 */ 20 public function refresh( 21 string $refreshToken, 22 string $pdsEndpoint, 23 - DPoPKey $dpopKey 24 ): AccessToken { 25 $tokenUrl = $pdsEndpoint.'/oauth/token'; 26 27 $response = $this->dpopClient->request($pdsEndpoint, $tokenUrl, 'POST', $dpopKey) 28 ->asForm() 29 - ->post($tokenUrl, [ 30 - 'grant_type' => 'refresh_token', 31 - 'refresh_token' => $refreshToken, 32 - ]); 33 34 if ($response->failed()) { 35 throw new AuthenticationException('Token refresh failed: '.$response->body()); 36 } 37 38 - return AccessToken::fromResponse($response->json()); 39 } 40 }
··· 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 ··· 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
···
··· 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
···
··· 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
···
··· 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
··· 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
··· 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
··· 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 }
+104 -6
src/Client/Client.php
··· 2 3 namespace SocialDept\AtpClient\Client; 4 5 use SocialDept\AtpClient\AtpClient; 6 use SocialDept\AtpClient\Http\DPoPClient; 7 use SocialDept\AtpClient\Http\HasHttp; 8 use SocialDept\AtpClient\Session\SessionManager; 9 10 class Client 11 { 12 - use HasHttp; 13 14 /** 15 * The parent AtpClient instance we belong to 16 */ 17 - public AtpClient $atp; 18 19 public function __construct( 20 AtpClient $parent, 21 - SessionManager $sessions, 22 - string $identifier, 23 ) { 24 $this->atp = $parent; 25 $this->sessions = $sessions; 26 - $this->identifier = $identifier; 27 - $this->dpopClient = app(DPoPClient::class); 28 } 29 }
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
···
··· 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
···
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 9 /** 10 * The parent AtpClient instance we belong to 11 */ 12 - public AtpClient $atp; 13 14 public function __construct($parent) 15 { 16 - $this->atp = $parent->atp; 17 } 18 }
··· 9 /** 10 * The parent AtpClient instance we belong to 11 */ 12 + protected AtpClient $atp; 13 14 public function __construct($parent) 15 { 16 + $this->atp = $parent->root(); 17 } 18 }
+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
···
··· 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
···
··· 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
···
··· 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
··· 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
···
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Contracts; 4 + 5 + interface HasAtpSession 6 + { 7 + /** 8 + * Get the ATP DID associated with this model. 9 + */ 10 + public function getAtpDid(): ?string; 11 + }
+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
··· 10 public readonly string $codeVerifier, 11 public readonly DPoPKey $dpopKey, 12 public readonly string $requestUri, 13 ) {} 14 }
··· 10 public readonly string $codeVerifier, 11 public readonly DPoPKey $dpopKey, 12 public readonly string $requestUri, 13 + public readonly string $pdsEndpoint, 14 + public readonly ?string $handle = null, 15 ) {} 16 }
+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
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 2 3 namespace SocialDept\AtpClient\Data; 4 5 - class StrongRef 6 { 7 public function __construct( 8 public readonly string $uri,
··· 2 3 namespace SocialDept\AtpClient\Data; 4 5 + use Illuminate\Contracts\Support\Arrayable; 6 + 7 + /** 8 + * @implements Arrayable<string, string> 9 + */ 10 + class StrongRef implements Arrayable 11 { 12 public function __construct( 13 public readonly string $uri,
+9
src/Enums/AuthType.php
···
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums; 4 + 5 + enum AuthType: string 6 + { 7 + case OAuth = 'oauth'; 8 + case Legacy = 'legacy'; 9 + }
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums\Nsid; 4 + 5 + use SocialDept\AtpClient\Enums\Nsid\Concerns\HasScopeHelpers; 6 + 7 + enum BskyLabeler: string 8 + { 9 + use HasScopeHelpers; 10 + case GetServices = 'app.bsky.labeler.getServices'; 11 + }
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums; 4 + 5 + enum ScopeAuthorizationFailure: string 6 + { 7 + case Abort = 'abort'; 8 + case Redirect = 'redirect'; 9 + case Exception = 'exception'; 10 + }
+9
src/Enums/ScopeEnforcementLevel.php
···
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Enums; 4 + 5 + enum ScopeEnforcementLevel: string 6 + { 7 + case Strict = 'strict'; 8 + case Permissive = 'permissive'; 9 + }
+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
···
··· 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
···
··· 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
··· 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
··· 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
···
··· 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
···
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Exceptions; 4 + 5 + class HandleResolutionException extends \Exception 6 + { 7 + public function __construct(string $handle) 8 + { 9 + parent::__construct("Unable to resolve handle '{$handle}' to a DID"); 10 + } 11 + }
+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
···
··· 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
··· 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
···
··· 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 + }
+1 -1
src/Http/DPoPClient.php
··· 67 $request = $request->withHeader('DPoP', $dpopProof); 68 69 if ($accessToken) { 70 - $request = $request->withHeader('Authorization', 'Bearer '.$accessToken); 71 } 72 73 return $request;
··· 67 $request = $request->withHeader('DPoP', $dpopProof); 68 69 if ($accessToken) { 70 + $request = $request->withHeader('Authorization', 'DPoP '.$accessToken); 71 } 72 73 return $request;
+79 -14
src/Http/HasHttp.php
··· 2 3 namespace SocialDept\AtpClient\Http; 4 5 use Illuminate\Http\Client\Response as LaravelResponse; 6 use InvalidArgumentException; 7 use SocialDept\AtpClient\Exceptions\ValidationException; 8 use SocialDept\AtpClient\Session\Session; 9 use SocialDept\AtpClient\Session\SessionManager; ··· 11 12 trait HasHttp 13 { 14 - protected SessionManager $sessions; 15 16 - protected string $identifier; 17 18 - protected DPoPClient $dpopClient; 19 20 /** 21 * Make XRPC call 22 */ 23 protected function call( 24 - string $endpoint, 25 string $method, 26 ?array $params = null, 27 ?array $body = null 28 ): Response { 29 - $session = $this->sessions->ensureValid($this->identifier); 30 $url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint; 31 32 $params = array_filter($params ?? [], fn ($v) => ! is_null($v)); ··· 40 default => throw new InvalidArgumentException("Unsupported method: {$method}"), 41 }; 42 43 - if (Schema::exists($endpoint)) { 44 $this->validateResponse($endpoint, $response); 45 } 46 ··· 48 } 49 50 /** 51 - * Build authenticated request with DPoP proof and automatic nonce retry 52 */ 53 protected function buildAuthenticatedRequest( 54 Session $session, 55 string $url, 56 string $method 57 ): \Illuminate\Http\Client\PendingRequest { 58 return $this->dpopClient->request( 59 pdsEndpoint: $session->pdsEndpoint(), 60 url: $url, ··· 75 76 $data = $response->json(); 77 78 - if (! Schema::validate($endpoint, $data)) { 79 - $errors = Schema::getErrors($endpoint, $data); 80 throw new ValidationException($errors); 81 } 82 } ··· 84 /** 85 * Make GET request 86 */ 87 - protected function get(string $endpoint, array $params = []): Response 88 { 89 return $this->call($endpoint, 'GET', $params); 90 } ··· 92 /** 93 * Make POST request 94 */ 95 - protected function post(string $endpoint, array $body = []): Response 96 { 97 return $this->call($endpoint, 'POST', null, $body); 98 } ··· 100 /** 101 * Make DELETE request 102 */ 103 - protected function delete(string $endpoint, array $params = []): Response 104 { 105 return $this->call($endpoint, 'DELETE', $params); 106 } ··· 108 /** 109 * Make POST request with raw binary body (for blob uploads) 110 */ 111 - protected function postBlob(string $endpoint, string $data, string $mimeType): Response 112 { 113 - $session = $this->sessions->ensureValid($this->identifier); 114 $url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint; 115 116 $response = $this->buildAuthenticatedRequest($session, $url, 'POST') 117 ->withBody($data, $mimeType) 118 ->post($url); 119 120 return new Response($response); 121 } 122 }
··· 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; ··· 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)); ··· 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, ··· 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
···
··· 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
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 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
··· 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
+62 -31
src/Session/SessionManager.php
··· 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 { ··· 27 ) {} 28 29 /** 30 - * Get or create session for identifier 31 */ 32 - public function session(string $identifier): Session 33 { 34 - if (! isset($this->sessions[$identifier])) { 35 - $this->sessions[$identifier] = $this->createSession($identifier); 36 } 37 38 - return $this->sessions[$identifier]; 39 } 40 41 /** 42 - * Ensure session is valid, refresh if needed 43 */ 44 - public function ensureValid(string $identifier): Session 45 { 46 - $session = $this->session($identifier); 47 48 // Check if token needs refresh 49 if ($session->expiresIn() < $this->refreshThreshold) { ··· 54 } 55 56 /** 57 - * Create session from app password 58 */ 59 public function fromAppPassword( 60 - string $identifier, 61 string $password 62 ): Session { 63 - $pdsEndpoint = Resolver::resolvePds($identifier); 64 65 $response = Http::post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [ 66 - 'identifier' => $identifier, 67 'password' => $password, 68 ]); 69 ··· 71 throw new AuthenticationException('Login failed'); 72 } 73 74 - $token = AccessToken::fromResponse($response->json()); 75 76 - // Store credentials 77 - $this->credentials->storeCredentials($identifier, $token); 78 79 - return $this->createSession($identifier); 80 } 81 82 /** 83 * Create session from credentials 84 */ 85 - protected function createSession(string $identifier): Session 86 { 87 - $creds = $this->credentials->getCredentials($identifier); 88 89 if (! $creds) { 90 - throw new SessionExpiredException("No credentials found for {$identifier}"); 91 } 92 93 // Get or create DPoP key ··· 98 $dpopKey = $this->dpopManager->generateKey($sessionId); 99 } 100 101 - // Resolve PDS endpoint 102 - $pdsEndpoint = Resolver::resolvePds($creds->did); 103 104 return new Session($creds, $dpopKey, $pdsEndpoint); 105 } ··· 109 */ 110 protected function refreshSession(Session $session): Session 111 { 112 // Fire event before refresh (allows developers to invalidate old token) 113 - event(new TokenRefreshing($session->identifier(), $session->refreshToken())); 114 115 $newToken = $this->refresher->refresh( 116 refreshToken: $session->refreshToken(), 117 pdsEndpoint: $session->pdsEndpoint(), 118 dpopKey: $session->dpopKey(), 119 ); 120 121 // Update credentials (CRITICAL: refresh tokens are single-use) 122 - $this->credentials->updateCredentials( 123 - $session->identifier(), 124 - $newToken 125 - ); 126 127 // Fire event after successful refresh 128 - event(new TokenRefreshed($session->identifier(), $newToken)); 129 130 // Update session 131 - $newCreds = $this->credentials->getCredentials($session->identifier()); 132 $newSession = $session->withCredentials($newCreds); 133 134 // Update cached session 135 - $this->sessions[$session->identifier()] = $newSession; 136 137 return $newSession; 138 }
··· 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 { ··· 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
··· 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 }