Laravel AT Protocol Client (alpha & unstable)

Compare changes

Choose any two refs to compare.

+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 }
+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 + }
+9 -11
src/Client/Records/FollowRecordClient.php
··· 5 use DateTimeInterface; 6 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 7 use SocialDept\AtpClient\Client\Requests\Request; 8 - use SocialDept\AtpClient\Data\StrongRef; 9 use SocialDept\AtpClient\Enums\Nsid\BskyGraph; 10 use SocialDept\AtpClient\Enums\Scope; 11 ··· 21 public function create( 22 string $subject, 23 ?DateTimeInterface $createdAt = null 24 - ): StrongRef { 25 $record = [ 26 '$type' => BskyGraph::Follow->value, 27 'subject' => $subject, // DID 28 'createdAt' => ($createdAt ?? now())->format('c'), 29 ]; 30 31 - $response = $this->atp->atproto->repo->createRecord( 32 - repo: $this->atp->client->session()->did(), 33 collection: BskyGraph::Follow, 34 record: $record 35 ); 36 - 37 - return StrongRef::fromResponse($response->json()); 38 } 39 40 /** ··· 44 */ 45 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')] 46 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.graph.follow?action=delete')] 47 - public function delete(string $rkey): void 48 { 49 - $this->atp->atproto->repo->deleteRecord( 50 - repo: $this->atp->client->session()->did(), 51 collection: BskyGraph::Follow, 52 rkey: $rkey 53 ); ··· 59 * @requires transition:generic (rpc:com.atproto.repo.getRecord) 60 */ 61 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 62 - public function get(string $rkey, ?string $cid = null): array 63 { 64 $response = $this->atp->atproto->repo->getRecord( 65 repo: $this->atp->client->session()->did(), ··· 68 cid: $cid 69 ); 70 71 - return $response->json('value'); 72 } 73 }
··· 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 ··· 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 /** ··· 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 ); ··· 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(), ··· 66 cid: $cid 67 ); 68 69 + return Record::fromArrayRaw($response->toArray()); 70 } 71 }
+9 -10
src/Client/Records/LikeRecordClient.php
··· 5 use DateTimeInterface; 6 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 7 use SocialDept\AtpClient\Client\Requests\Request; 8 use SocialDept\AtpClient\Data\StrongRef; 9 use SocialDept\AtpClient\Enums\Nsid\BskyFeed; 10 use SocialDept\AtpClient\Enums\Scope; ··· 21 public function create( 22 StrongRef $subject, 23 ?DateTimeInterface $createdAt = null 24 - ): StrongRef { 25 $record = [ 26 '$type' => BskyFeed::Like->value, 27 'subject' => $subject->toArray(), 28 'createdAt' => ($createdAt ?? now())->format('c'), 29 ]; 30 31 - $response = $this->atp->atproto->repo->createRecord( 32 - repo: $this->atp->client->session()->did(), 33 collection: BskyFeed::Like, 34 record: $record 35 ); 36 - 37 - return StrongRef::fromResponse($response->json()); 38 } 39 40 /** ··· 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): void 48 { 49 - $this->atp->atproto->repo->deleteRecord( 50 - repo: $this->atp->client->session()->did(), 51 collection: BskyFeed::Like, 52 rkey: $rkey 53 ); ··· 59 * @requires transition:generic (rpc:com.atproto.repo.getRecord) 60 */ 61 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 62 - public function get(string $rkey, ?string $cid = null): array 63 { 64 $response = $this->atp->atproto->repo->getRecord( 65 repo: $this->atp->client->session()->did(), ··· 68 cid: $cid 69 ); 70 71 - return $response->json('value'); 72 } 73 }
··· 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; ··· 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 /** ··· 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 ); ··· 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(), ··· 67 cid: $cid 68 ); 69 70 + return Record::fromArrayRaw($response->toArray()); 71 } 72 }
+33 -151
src/Client/Records/PostRecordClient.php
··· 4 5 use DateTimeInterface; 6 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 7 use SocialDept\AtpClient\Client\Requests\Request; 8 use SocialDept\AtpClient\Contracts\Recordable; 9 use SocialDept\AtpClient\Data\StrongRef; 10 use SocialDept\AtpClient\Enums\Nsid\BskyFeed; 11 use SocialDept\AtpClient\Enums\Scope; ··· 15 class PostRecordClient extends Request 16 { 17 /** 18 * Create a post 19 * 20 * @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create) ··· 28 ?array $reply = null, 29 ?array $langs = null, 30 ?DateTimeInterface $createdAt = null 31 - ): StrongRef { 32 // Handle different input types 33 if (is_string($content)) { 34 $record = [ ··· 41 $record = $content; 42 } 43 44 - // Add optional fields 45 - if ($embed) { 46 - $record['embed'] = $embed; 47 - } 48 - if ($reply) { 49 - $record['reply'] = $reply; 50 - } 51 - if ($langs) { 52 - $record['langs'] = $langs; 53 } 54 if (! isset($record['createdAt'])) { 55 $record['createdAt'] = ($createdAt ?? now())->format('c'); 56 } ··· 60 $record['$type'] = BskyFeed::Post->value; 61 } 62 63 - $response = $this->atp->atproto->repo->createRecord( 64 - repo: $this->atp->client->session()->did(), 65 collection: BskyFeed::Post, 66 record: $record 67 ); 68 - 69 - return StrongRef::fromResponse($response->json()); 70 } 71 72 /** ··· 76 */ 77 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 78 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=update')] 79 - public function update(string $rkey, array $record): StrongRef 80 { 81 // Ensure $type is set 82 if (! isset($record['$type'])) { 83 $record['$type'] = BskyFeed::Post->value; 84 } 85 86 - $response = $this->atp->atproto->repo->putRecord( 87 - repo: $this->atp->client->session()->did(), 88 collection: BskyFeed::Post, 89 rkey: $rkey, 90 record: $record 91 ); 92 - 93 - return StrongRef::fromResponse($response->toArray()); 94 } 95 96 /** ··· 100 */ 101 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')] 102 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=delete')] 103 - public function delete(string $rkey): void 104 { 105 - $this->atp->atproto->repo->deleteRecord( 106 - repo: $this->atp->client->session()->did(), 107 collection: BskyFeed::Post, 108 rkey: $rkey 109 ); ··· 115 * @requires transition:generic (rpc:com.atproto.repo.getRecord) 116 */ 117 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 118 - public function get(string $rkey, ?string $cid = null): PostView 119 { 120 $response = $this->atp->atproto->repo->getRecord( 121 repo: $this->atp->client->session()->did(), ··· 124 cid: $cid 125 ); 126 127 - return PostView::fromArray($response->value); 128 - } 129 - 130 - /** 131 - * Create a reply to another post 132 - * 133 - * @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create) 134 - */ 135 - #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')] 136 - #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')] 137 - public function reply( 138 - StrongRef $parent, 139 - StrongRef $root, 140 - string|array|Recordable $content, 141 - ?array $facets = null, 142 - ?array $embed = null, 143 - ?array $langs = null, 144 - ?DateTimeInterface $createdAt = null 145 - ): StrongRef { 146 - $reply = [ 147 - 'parent' => $parent->toArray(), 148 - 'root' => $root->toArray(), 149 - ]; 150 - 151 - return $this->create( 152 - content: $content, 153 - facets: $facets, 154 - embed: $embed, 155 - reply: $reply, 156 - langs: $langs, 157 - createdAt: $createdAt 158 - ); 159 - } 160 - 161 - /** 162 - * Create a quote post (post with embedded post) 163 - * 164 - * @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create) 165 - */ 166 - #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')] 167 - #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')] 168 - public function quote( 169 - StrongRef $quotedPost, 170 - string|array|Recordable $content, 171 - ?array $facets = null, 172 - ?array $langs = null, 173 - ?DateTimeInterface $createdAt = null 174 - ): StrongRef { 175 - $embed = [ 176 - '$type' => 'app.bsky.embed.record', 177 - 'record' => $quotedPost->toArray(), 178 - ]; 179 - 180 - return $this->create( 181 - content: $content, 182 - facets: $facets, 183 - embed: $embed, 184 - langs: $langs, 185 - createdAt: $createdAt 186 - ); 187 - } 188 - 189 - /** 190 - * Create a post with images 191 - * 192 - * @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create) 193 - */ 194 - #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')] 195 - #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')] 196 - public function withImages( 197 - string|array|Recordable $content, 198 - array $images, 199 - ?array $facets = null, 200 - ?array $langs = null, 201 - ?DateTimeInterface $createdAt = null 202 - ): StrongRef { 203 - $embed = [ 204 - '$type' => 'app.bsky.embed.images', 205 - 'images' => $images, 206 - ]; 207 - 208 - return $this->create( 209 - content: $content, 210 - facets: $facets, 211 - embed: $embed, 212 - langs: $langs, 213 - createdAt: $createdAt 214 - ); 215 } 216 217 - /** 218 - * Create a post with external link embed 219 - * 220 - * @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create) 221 - */ 222 - #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.createRecord')] 223 - #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=create')] 224 - public function withLink( 225 - string|array|Recordable $content, 226 - string $uri, 227 - string $title, 228 - string $description, 229 - ?string $thumbBlob = null, 230 - ?array $facets = null, 231 - ?array $langs = null, 232 - ?DateTimeInterface $createdAt = null 233 - ): StrongRef { 234 - $external = [ 235 - 'uri' => $uri, 236 - 'title' => $title, 237 - 'description' => $description, 238 - ]; 239 - 240 - if ($thumbBlob) { 241 - $external['thumb'] = $thumbBlob; 242 - } 243 - 244 - $embed = [ 245 - '$type' => 'app.bsky.embed.external', 246 - 'external' => $external, 247 - ]; 248 - 249 - return $this->create( 250 - content: $content, 251 - facets: $facets, 252 - embed: $embed, 253 - langs: $langs, 254 - createdAt: $createdAt 255 - ); 256 - } 257 }
··· 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; ··· 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) ··· 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 } ··· 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 /** ··· 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 /** ··· 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 ); ··· 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(), ··· 133 cid: $cid 134 ); 135 136 + return Record::fromArrayRaw($response->toArray()); 137 } 138 139 }
+11 -13
src/Client/Records/ProfileRecordClient.php
··· 4 5 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 6 use SocialDept\AtpClient\Client\Requests\Request; 7 - use SocialDept\AtpClient\Data\StrongRef; 8 use SocialDept\AtpClient\Enums\Nsid\BskyActor; 9 use SocialDept\AtpClient\Enums\Scope; 10 ··· 17 */ 18 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 19 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 20 - public function update(array $profile): StrongRef 21 { 22 // Ensure $type is set 23 if (! isset($profile['$type'])) { 24 $profile['$type'] = BskyActor::Profile->value; 25 } 26 27 - $response = $this->atp->atproto->repo->putRecord( 28 - repo: $this->atp->client->session()->did(), 29 collection: BskyActor::Profile, 30 rkey: 'self', // Profile records always use 'self' as rkey 31 record: $profile 32 ); 33 - 34 - return StrongRef::fromResponse($response->json()); 35 } 36 37 /** ··· 40 * @requires transition:generic (rpc:com.atproto.repo.getRecord) 41 */ 42 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 43 - public function get(): array 44 { 45 $response = $this->atp->atproto->repo->getRecord( 46 repo: $this->atp->client->session()->did(), ··· 48 rkey: 'self' 49 ); 50 51 - return $response->json('value'); 52 } 53 54 /** ··· 58 */ 59 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 60 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 61 - public function updateDisplayName(string $displayName): StrongRef 62 { 63 $profile = $this->getOrCreateProfile(); 64 $profile['displayName'] = $displayName; ··· 73 */ 74 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 75 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 76 - public function updateDescription(string $description): StrongRef 77 { 78 $profile = $this->getOrCreateProfile(); 79 $profile['description'] = $description; ··· 88 */ 89 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 90 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 91 - public function updateAvatar(array $avatarBlob): StrongRef 92 { 93 $profile = $this->getOrCreateProfile(); 94 $profile['avatar'] = $avatarBlob; ··· 103 */ 104 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 105 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 106 - public function updateBanner(array $bannerBlob): StrongRef 107 { 108 $profile = $this->getOrCreateProfile(); 109 $profile['banner'] = $bannerBlob; ··· 117 protected function getOrCreateProfile(): array 118 { 119 try { 120 - return $this->get(); 121 } catch (\Exception $e) { 122 // Profile doesn't exist, return empty structure 123 return [
··· 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 ··· 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 /** ··· 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(), ··· 46 rkey: 'self' 47 ); 48 49 + return Record::fromArrayRaw($response->toArray()); 50 } 51 52 /** ··· 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; ··· 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; ··· 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; ··· 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 [
+7 -3
src/Client/Requests/Atproto/IdentityRequestClient.php
··· 5 use SocialDept\AtpClient\Attributes\PublicEndpoint; 6 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 7 use SocialDept\AtpClient\Client\Requests\Request; 8 use SocialDept\AtpClient\Enums\Nsid\AtprotoIdentity; 9 use SocialDept\AtpClient\Enums\Scope; 10 ··· 16 * @see https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle 17 */ 18 #[PublicEndpoint] 19 - public function resolveHandle(string $handle): string 20 { 21 $response = $this->atp->client->get( 22 endpoint: AtprotoIdentity::ResolveHandle, 23 params: compact('handle') 24 ); 25 26 - return $response->json()['did']; 27 } 28 29 /** ··· 34 * @see https://docs.bsky.app/docs/api/com-atproto-identity-update-handle 35 */ 36 #[ScopedEndpoint(Scope::Atproto, granular: 'identity:handle')] 37 - public function updateHandle(string $handle): void 38 { 39 $this->atp->client->post( 40 endpoint: AtprotoIdentity::UpdateHandle, 41 body: compact('handle') 42 ); 43 } 44 }
··· 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 ··· 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 /** ··· 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 }
+3 -3
src/Client/Requests/Atproto/RepoRequestClient.php
··· 32 */ 33 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Create records in repository')] 34 public function createRecord( 35 - string $repo, 36 string|BackedEnum $collection, 37 array $record, 38 ?string $rkey = null, 39 bool $validate = true, 40 ?string $swapCommit = null 41 ): CreateRecordResponse { 42 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 43 $this->checkCollectionScope($collection, 'create'); 44 ··· 62 */ 63 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Delete records from repository')] 64 public function deleteRecord( 65 - string $repo, 66 string|BackedEnum $collection, 67 string $rkey, 68 ?string $swapRecord = null, 69 ?string $swapCommit = null 70 ): DeleteRecordResponse { 71 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 72 $this->checkCollectionScope($collection, 'delete'); 73 ··· 91 */ 92 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Update records in repository')] 93 public function putRecord( 94 - string $repo, 95 string|BackedEnum $collection, 96 string $rkey, 97 array $record, ··· 99 ?string $swapRecord = null, 100 ?string $swapCommit = null 101 ): PutRecordResponse { 102 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 103 $this->checkCollectionScope($collection, 'update'); 104
··· 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 ··· 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 ··· 91 */ 92 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Update records in repository')] 93 public function putRecord( 94 string|BackedEnum $collection, 95 string $rkey, 96 array $record, ··· 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
+1 -1
src/Client/Requests/Bsky/ActorRequestClient.php
··· 26 params: compact('actor') 27 ); 28 29 - return ProfileViewDetailed::fromArray($response->json()); 30 } 31 32 /**
··· 26 params: compact('actor') 27 ); 28 29 + return ProfileViewDetailed::fromArray($response->toArray()); 30 } 31 32 /**
+4 -1
src/Client/Requests/Chat/ActorRequestClient.php
··· 4 5 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 6 use SocialDept\AtpClient\Client\Requests\Request; 7 use SocialDept\AtpClient\Enums\Nsid\ChatActor; 8 use SocialDept\AtpClient\Enums\Scope; 9 use SocialDept\AtpClient\Http\Response; ··· 48 * @see https://docs.bsky.app/docs/api/chat-bsky-actor-delete-account 49 */ 50 #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.deleteAccount')] 51 - public function deleteAccount(): void 52 { 53 $this->atp->client->post( 54 endpoint: ChatActor::DeleteAccount 55 ); 56 } 57 }
··· 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; ··· 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 }
+11 -13
src/Client/Requests/Ozone/TeamRequestClient.php
··· 4 5 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 6 use SocialDept\AtpClient\Client\Requests\Request; 7 use SocialDept\AtpClient\Data\Responses\Ozone\Team\ListMembersResponse; 8 use SocialDept\AtpClient\Enums\Nsid\OzoneTeam; 9 use SocialDept\AtpClient\Enums\Scope; 10 ··· 15 * 16 * @requires transition:generic (rpc:tools.ozone.team.getMember) 17 * 18 - * @return array<string, mixed> Team member object 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): array 24 { 25 $response = $this->atp->client->get( 26 endpoint: OzoneTeam::GetMember, 27 params: compact('did') 28 ); 29 30 - return $response->json(); 31 } 32 33 /** ··· 53 * 54 * @requires transition:generic (rpc:tools.ozone.team.addMember) 55 * 56 - * @return array<string, mixed> Team member object 57 - * 58 * @see https://docs.bsky.app/docs/api/tools-ozone-team-add-member 59 */ 60 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.addMember')] 61 - public function addTeamMember(string $did, string $role): array 62 { 63 $response = $this->atp->client->post( 64 endpoint: OzoneTeam::AddMember, 65 body: compact('did', 'role') 66 ); 67 68 - return $response->json(); 69 } 70 71 /** ··· 73 * 74 * @requires transition:generic (rpc:tools.ozone.team.updateMember) 75 * 76 - * @return array<string, mixed> Team member object 77 - * 78 * @see https://docs.bsky.app/docs/api/tools-ozone-team-update-member 79 */ 80 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.updateMember')] ··· 82 string $did, 83 ?bool $disabled = null, 84 ?string $role = null 85 - ): array { 86 $response = $this->atp->client->post( 87 endpoint: OzoneTeam::UpdateMember, 88 body: array_filter( ··· 91 ) 92 ); 93 94 - return $response->json(); 95 } 96 97 /** ··· 102 * @see https://docs.bsky.app/docs/api/tools-ozone-team-delete-member 103 */ 104 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.deleteMember')] 105 - public function deleteTeamMember(string $did): void 106 { 107 $this->atp->client->post( 108 endpoint: OzoneTeam::DeleteMember, 109 body: compact('did') 110 ); 111 } 112 }
··· 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 ··· 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 /** ··· 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 /** ··· 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')] ··· 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( ··· 87 ) 88 ); 89 90 + return MemberResponse::fromArray($response->json()); 91 } 92 93 /** ··· 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 }
+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 + }
+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 + }
+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 + }
+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 /**