Laravel AT Protocol Client (alpha & unstable)

Merge branch 'refs/heads/dev'

+3 -5
composer.json
··· 45 45 } 46 46 }, 47 47 "scripts": { 48 - "test": "vendor/bin/pest", 49 - "test-coverage": "vendor/bin/pest --coverage", 48 + "test": "vendor/bin/phpunit", 49 + "test-coverage": "vendor/bin/phpunit --coverage-html coverage", 50 50 "format": "vendor/bin/php-cs-fixer fix" 51 51 }, 52 52 "extra": { ··· 62 62 "minimum-stability": "dev", 63 63 "prefer-stable": true, 64 64 "config": { 65 - "allow-plugins": { 66 - "pestphp/pest-plugin": false 67 - } 65 + "sort-packages": true 68 66 } 69 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 5 use DateTimeInterface; 6 6 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 7 7 use SocialDept\AtpClient\Client\Requests\Request; 8 - use SocialDept\AtpClient\Data\StrongRef; 8 + use SocialDept\AtpClient\Data\Record; 9 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse; 10 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse; 9 11 use SocialDept\AtpClient\Enums\Nsid\BskyGraph; 10 12 use SocialDept\AtpClient\Enums\Scope; 11 13 ··· 21 23 public function create( 22 24 string $subject, 23 25 ?DateTimeInterface $createdAt = null 24 - ): StrongRef { 26 + ): CreateRecordResponse { 25 27 $record = [ 26 28 '$type' => BskyGraph::Follow->value, 27 29 'subject' => $subject, // DID 28 30 'createdAt' => ($createdAt ?? now())->format('c'), 29 31 ]; 30 32 31 - $response = $this->atp->atproto->repo->createRecord( 32 - repo: $this->atp->client->session()->did(), 33 + return $this->atp->atproto->repo->createRecord( 33 34 collection: BskyGraph::Follow, 34 35 record: $record 35 36 ); 36 - 37 - return StrongRef::fromResponse($response->json()); 38 37 } 39 38 40 39 /** ··· 44 43 */ 45 44 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')] 46 45 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.graph.follow?action=delete')] 47 - public function delete(string $rkey): void 46 + public function delete(string $rkey): DeleteRecordResponse 48 47 { 49 - $this->atp->atproto->repo->deleteRecord( 50 - repo: $this->atp->client->session()->did(), 48 + return $this->atp->atproto->repo->deleteRecord( 51 49 collection: BskyGraph::Follow, 52 50 rkey: $rkey 53 51 ); ··· 59 57 * @requires transition:generic (rpc:com.atproto.repo.getRecord) 60 58 */ 61 59 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 62 - public function get(string $rkey, ?string $cid = null): array 60 + public function get(string $rkey, ?string $cid = null): Record 63 61 { 64 62 $response = $this->atp->atproto->repo->getRecord( 65 63 repo: $this->atp->client->session()->did(), ··· 68 66 cid: $cid 69 67 ); 70 68 71 - return $response->json('value'); 69 + return Record::fromArrayRaw($response->toArray()); 72 70 } 73 71 }
+9 -10
src/Client/Records/LikeRecordClient.php
··· 5 5 use DateTimeInterface; 6 6 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 7 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; 8 11 use SocialDept\AtpClient\Data\StrongRef; 9 12 use SocialDept\AtpClient\Enums\Nsid\BskyFeed; 10 13 use SocialDept\AtpClient\Enums\Scope; ··· 21 24 public function create( 22 25 StrongRef $subject, 23 26 ?DateTimeInterface $createdAt = null 24 - ): StrongRef { 27 + ): CreateRecordResponse { 25 28 $record = [ 26 29 '$type' => BskyFeed::Like->value, 27 30 'subject' => $subject->toArray(), 28 31 'createdAt' => ($createdAt ?? now())->format('c'), 29 32 ]; 30 33 31 - $response = $this->atp->atproto->repo->createRecord( 32 - repo: $this->atp->client->session()->did(), 34 + return $this->atp->atproto->repo->createRecord( 33 35 collection: BskyFeed::Like, 34 36 record: $record 35 37 ); 36 - 37 - return StrongRef::fromResponse($response->json()); 38 38 } 39 39 40 40 /** ··· 44 44 */ 45 45 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')] 46 46 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.like?action=delete')] 47 - public function delete(string $rkey): void 47 + public function delete(string $rkey): DeleteRecordResponse 48 48 { 49 - $this->atp->atproto->repo->deleteRecord( 50 - repo: $this->atp->client->session()->did(), 49 + return $this->atp->atproto->repo->deleteRecord( 51 50 collection: BskyFeed::Like, 52 51 rkey: $rkey 53 52 ); ··· 59 58 * @requires transition:generic (rpc:com.atproto.repo.getRecord) 60 59 */ 61 60 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 62 - public function get(string $rkey, ?string $cid = null): array 61 + public function get(string $rkey, ?string $cid = null): Record 63 62 { 64 63 $response = $this->atp->atproto->repo->getRecord( 65 64 repo: $this->atp->client->session()->did(), ··· 68 67 cid: $cid 69 68 ); 70 69 71 - return $response->json('value'); 70 + return Record::fromArrayRaw($response->toArray()); 72 71 } 73 72 }
+33 -151
src/Client/Records/PostRecordClient.php
··· 4 4 5 5 use DateTimeInterface; 6 6 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 7 + use SocialDept\AtpClient\Builders\PostBuilder; 7 8 use SocialDept\AtpClient\Client\Requests\Request; 8 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; 9 14 use SocialDept\AtpClient\Data\StrongRef; 10 15 use SocialDept\AtpClient\Enums\Nsid\BskyFeed; 11 16 use SocialDept\AtpClient\Enums\Scope; ··· 15 20 class PostRecordClient extends Request 16 21 { 17 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 + /** 18 31 * Create a post 19 32 * 20 33 * @requires transition:generic OR (rpc:com.atproto.repo.createRecord AND repo:app.bsky.feed.post?action=create) ··· 28 41 ?array $reply = null, 29 42 ?array $langs = null, 30 43 ?DateTimeInterface $createdAt = null 31 - ): StrongRef { 44 + ): CreateRecordResponse { 32 45 // Handle different input types 33 46 if (is_string($content)) { 34 47 $record = [ ··· 41 54 $record = $content; 42 55 } 43 56 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; 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 + } 53 68 } 69 + 54 70 if (! isset($record['createdAt'])) { 55 71 $record['createdAt'] = ($createdAt ?? now())->format('c'); 56 72 } ··· 60 76 $record['$type'] = BskyFeed::Post->value; 61 77 } 62 78 63 - $response = $this->atp->atproto->repo->createRecord( 64 - repo: $this->atp->client->session()->did(), 79 + return $this->atp->atproto->repo->createRecord( 65 80 collection: BskyFeed::Post, 66 81 record: $record 67 82 ); 68 - 69 - return StrongRef::fromResponse($response->json()); 70 83 } 71 84 72 85 /** ··· 76 89 */ 77 90 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 78 91 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=update')] 79 - public function update(string $rkey, array $record): StrongRef 92 + public function update(string $rkey, array $record): PutRecordResponse 80 93 { 81 94 // Ensure $type is set 82 95 if (! isset($record['$type'])) { 83 96 $record['$type'] = BskyFeed::Post->value; 84 97 } 85 98 86 - $response = $this->atp->atproto->repo->putRecord( 87 - repo: $this->atp->client->session()->did(), 99 + return $this->atp->atproto->repo->putRecord( 88 100 collection: BskyFeed::Post, 89 101 rkey: $rkey, 90 102 record: $record 91 103 ); 92 - 93 - return StrongRef::fromResponse($response->toArray()); 94 104 } 95 105 96 106 /** ··· 100 110 */ 101 111 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.deleteRecord')] 102 112 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.feed.post?action=delete')] 103 - public function delete(string $rkey): void 113 + public function delete(string $rkey): DeleteRecordResponse 104 114 { 105 - $this->atp->atproto->repo->deleteRecord( 106 - repo: $this->atp->client->session()->did(), 115 + return $this->atp->atproto->repo->deleteRecord( 107 116 collection: BskyFeed::Post, 108 117 rkey: $rkey 109 118 ); ··· 115 124 * @requires transition:generic (rpc:com.atproto.repo.getRecord) 116 125 */ 117 126 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 118 - public function get(string $rkey, ?string $cid = null): PostView 127 + public function get(string $rkey, ?string $cid = null): Record 119 128 { 120 129 $response = $this->atp->atproto->repo->getRecord( 121 130 repo: $this->atp->client->session()->did(), ··· 124 133 cid: $cid 125 134 ); 126 135 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 - ); 136 + return Record::fromArrayRaw($response->toArray()); 215 137 } 216 138 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 139 }
+11 -13
src/Client/Records/ProfileRecordClient.php
··· 4 4 5 5 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 6 6 use SocialDept\AtpClient\Client\Requests\Request; 7 - use SocialDept\AtpClient\Data\StrongRef; 7 + use SocialDept\AtpClient\Data\Record; 8 + use SocialDept\AtpClient\Data\Responses\Atproto\Repo\PutRecordResponse; 8 9 use SocialDept\AtpClient\Enums\Nsid\BskyActor; 9 10 use SocialDept\AtpClient\Enums\Scope; 10 11 ··· 17 18 */ 18 19 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 19 20 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 20 - public function update(array $profile): StrongRef 21 + public function update(array $profile): PutRecordResponse 21 22 { 22 23 // Ensure $type is set 23 24 if (! isset($profile['$type'])) { 24 25 $profile['$type'] = BskyActor::Profile->value; 25 26 } 26 27 27 - $response = $this->atp->atproto->repo->putRecord( 28 - repo: $this->atp->client->session()->did(), 28 + return $this->atp->atproto->repo->putRecord( 29 29 collection: BskyActor::Profile, 30 30 rkey: 'self', // Profile records always use 'self' as rkey 31 31 record: $profile 32 32 ); 33 - 34 - return StrongRef::fromResponse($response->json()); 35 33 } 36 34 37 35 /** ··· 40 38 * @requires transition:generic (rpc:com.atproto.repo.getRecord) 41 39 */ 42 40 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.getRecord')] 43 - public function get(): array 41 + public function get(): Record 44 42 { 45 43 $response = $this->atp->atproto->repo->getRecord( 46 44 repo: $this->atp->client->session()->did(), ··· 48 46 rkey: 'self' 49 47 ); 50 48 51 - return $response->json('value'); 49 + return Record::fromArrayRaw($response->toArray()); 52 50 } 53 51 54 52 /** ··· 58 56 */ 59 57 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 60 58 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 61 - public function updateDisplayName(string $displayName): StrongRef 59 + public function updateDisplayName(string $displayName): PutRecordResponse 62 60 { 63 61 $profile = $this->getOrCreateProfile(); 64 62 $profile['displayName'] = $displayName; ··· 73 71 */ 74 72 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 75 73 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 76 - public function updateDescription(string $description): StrongRef 74 + public function updateDescription(string $description): PutRecordResponse 77 75 { 78 76 $profile = $this->getOrCreateProfile(); 79 77 $profile['description'] = $description; ··· 88 86 */ 89 87 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 90 88 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 91 - public function updateAvatar(array $avatarBlob): StrongRef 89 + public function updateAvatar(array $avatarBlob): PutRecordResponse 92 90 { 93 91 $profile = $this->getOrCreateProfile(); 94 92 $profile['avatar'] = $avatarBlob; ··· 103 101 */ 104 102 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:com.atproto.repo.putRecord')] 105 103 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'repo:app.bsky.actor.profile?action=update')] 106 - public function updateBanner(array $bannerBlob): StrongRef 104 + public function updateBanner(array $bannerBlob): PutRecordResponse 107 105 { 108 106 $profile = $this->getOrCreateProfile(); 109 107 $profile['banner'] = $bannerBlob; ··· 117 115 protected function getOrCreateProfile(): array 118 116 { 119 117 try { 120 - return $this->get(); 118 + return $this->get()->value; 121 119 } catch (\Exception $e) { 122 120 // Profile doesn't exist, return empty structure 123 121 return [
+7 -3
src/Client/Requests/Atproto/IdentityRequestClient.php
··· 5 5 use SocialDept\AtpClient\Attributes\PublicEndpoint; 6 6 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 7 7 use SocialDept\AtpClient\Client\Requests\Request; 8 + use SocialDept\AtpClient\Data\Responses\Atproto\Identity\ResolveHandleResponse; 9 + use SocialDept\AtpClient\Data\Responses\EmptyResponse; 8 10 use SocialDept\AtpClient\Enums\Nsid\AtprotoIdentity; 9 11 use SocialDept\AtpClient\Enums\Scope; 10 12 ··· 16 18 * @see https://docs.bsky.app/docs/api/com-atproto-identity-resolve-handle 17 19 */ 18 20 #[PublicEndpoint] 19 - public function resolveHandle(string $handle): string 21 + public function resolveHandle(string $handle): ResolveHandleResponse 20 22 { 21 23 $response = $this->atp->client->get( 22 24 endpoint: AtprotoIdentity::ResolveHandle, 23 25 params: compact('handle') 24 26 ); 25 27 26 - return $response->json()['did']; 28 + return ResolveHandleResponse::fromArray($response->json()); 27 29 } 28 30 29 31 /** ··· 34 36 * @see https://docs.bsky.app/docs/api/com-atproto-identity-update-handle 35 37 */ 36 38 #[ScopedEndpoint(Scope::Atproto, granular: 'identity:handle')] 37 - public function updateHandle(string $handle): void 39 + public function updateHandle(string $handle): EmptyResponse 38 40 { 39 41 $this->atp->client->post( 40 42 endpoint: AtprotoIdentity::UpdateHandle, 41 43 body: compact('handle') 42 44 ); 45 + 46 + return new EmptyResponse; 43 47 } 44 48 }
+3 -3
src/Client/Requests/Atproto/RepoRequestClient.php
··· 32 32 */ 33 33 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Create records in repository')] 34 34 public function createRecord( 35 - string $repo, 36 35 string|BackedEnum $collection, 37 36 array $record, 38 37 ?string $rkey = null, 39 38 bool $validate = true, 40 39 ?string $swapCommit = null 41 40 ): CreateRecordResponse { 41 + $repo = $this->atp->client->session()->did(); 42 42 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 43 43 $this->checkCollectionScope($collection, 'create'); 44 44 ··· 62 62 */ 63 63 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Delete records from repository')] 64 64 public function deleteRecord( 65 - string $repo, 66 65 string|BackedEnum $collection, 67 66 string $rkey, 68 67 ?string $swapRecord = null, 69 68 ?string $swapCommit = null 70 69 ): DeleteRecordResponse { 70 + $repo = $this->atp->client->session()->did(); 71 71 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 72 72 $this->checkCollectionScope($collection, 'delete'); 73 73 ··· 91 91 */ 92 92 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Update records in repository')] 93 93 public function putRecord( 94 - string $repo, 95 94 string|BackedEnum $collection, 96 95 string $rkey, 97 96 array $record, ··· 99 98 ?string $swapRecord = null, 100 99 ?string $swapCommit = null 101 100 ): PutRecordResponse { 101 + $repo = $this->atp->client->session()->did(); 102 102 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 103 103 $this->checkCollectionScope($collection, 'update'); 104 104
+6 -2
src/Client/Requests/Bsky/ActorRequestClient.php
··· 4 4 5 5 use SocialDept\AtpClient\Attributes\PublicEndpoint; 6 6 use SocialDept\AtpClient\Client\Requests\Request; 7 + use SocialDept\AtpClient\Data\Record; 7 8 use SocialDept\AtpClient\Data\Responses\Bsky\Actor\GetProfilesResponse; 8 9 use SocialDept\AtpClient\Data\Responses\Bsky\Actor\GetSuggestionsResponse; 9 10 use SocialDept\AtpClient\Data\Responses\Bsky\Actor\SearchActorsResponse; ··· 19 20 * @see https://docs.bsky.app/docs/api/app-bsky-actor-get-profile 20 21 */ 21 22 #[PublicEndpoint] 22 - public function getProfile(string $actor): ProfileViewDetailed 23 + public function getProfile(string $actor): Record 23 24 { 24 25 $response = $this->atp->client->get( 25 26 endpoint: BskyActor::GetProfile, 26 27 params: compact('actor') 27 28 ); 28 29 29 - return ProfileViewDetailed::fromArray($response->json()); 30 + return Record::fromArray( 31 + data: $response->toArray(), 32 + transformer: fn($value) => ProfileViewDetailed::fromArray($response->json('value')) 33 + ); 30 34 } 31 35 32 36 /**
+4 -1
src/Client/Requests/Chat/ActorRequestClient.php
··· 4 4 5 5 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 6 6 use SocialDept\AtpClient\Client\Requests\Request; 7 + use SocialDept\AtpClient\Data\Responses\EmptyResponse; 7 8 use SocialDept\AtpClient\Enums\Nsid\ChatActor; 8 9 use SocialDept\AtpClient\Enums\Scope; 9 10 use SocialDept\AtpClient\Http\Response; ··· 48 49 * @see https://docs.bsky.app/docs/api/chat-bsky-actor-delete-account 49 50 */ 50 51 #[ScopedEndpoint(Scope::TransitionChat, granular: 'rpc:chat.bsky.actor.deleteAccount')] 51 - public function deleteAccount(): void 52 + public function deleteAccount(): EmptyResponse 52 53 { 53 54 $this->atp->client->post( 54 55 endpoint: ChatActor::DeleteAccount 55 56 ); 57 + 58 + return new EmptyResponse; 56 59 } 57 60 }
+11 -13
src/Client/Requests/Ozone/TeamRequestClient.php
··· 4 4 5 5 use SocialDept\AtpClient\Attributes\ScopedEndpoint; 6 6 use SocialDept\AtpClient\Client\Requests\Request; 7 + use SocialDept\AtpClient\Data\Responses\EmptyResponse; 7 8 use SocialDept\AtpClient\Data\Responses\Ozone\Team\ListMembersResponse; 9 + use SocialDept\AtpClient\Data\Responses\Ozone\Team\MemberResponse; 8 10 use SocialDept\AtpClient\Enums\Nsid\OzoneTeam; 9 11 use SocialDept\AtpClient\Enums\Scope; 10 12 ··· 15 17 * 16 18 * @requires transition:generic (rpc:tools.ozone.team.getMember) 17 19 * 18 - * @return array<string, mixed> Team member object 19 - * 20 20 * @see https://docs.bsky.app/docs/api/tools-ozone-team-list-members 21 21 */ 22 22 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.getMember')] 23 - public function getTeamMember(string $did): array 23 + public function getTeamMember(string $did): MemberResponse 24 24 { 25 25 $response = $this->atp->client->get( 26 26 endpoint: OzoneTeam::GetMember, 27 27 params: compact('did') 28 28 ); 29 29 30 - return $response->json(); 30 + return MemberResponse::fromArray($response->json()); 31 31 } 32 32 33 33 /** ··· 53 53 * 54 54 * @requires transition:generic (rpc:tools.ozone.team.addMember) 55 55 * 56 - * @return array<string, mixed> Team member object 57 - * 58 56 * @see https://docs.bsky.app/docs/api/tools-ozone-team-add-member 59 57 */ 60 58 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.addMember')] 61 - public function addTeamMember(string $did, string $role): array 59 + public function addTeamMember(string $did, string $role): MemberResponse 62 60 { 63 61 $response = $this->atp->client->post( 64 62 endpoint: OzoneTeam::AddMember, 65 63 body: compact('did', 'role') 66 64 ); 67 65 68 - return $response->json(); 66 + return MemberResponse::fromArray($response->json()); 69 67 } 70 68 71 69 /** ··· 73 71 * 74 72 * @requires transition:generic (rpc:tools.ozone.team.updateMember) 75 73 * 76 - * @return array<string, mixed> Team member object 77 - * 78 74 * @see https://docs.bsky.app/docs/api/tools-ozone-team-update-member 79 75 */ 80 76 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.updateMember')] ··· 82 78 string $did, 83 79 ?bool $disabled = null, 84 80 ?string $role = null 85 - ): array { 81 + ): MemberResponse { 86 82 $response = $this->atp->client->post( 87 83 endpoint: OzoneTeam::UpdateMember, 88 84 body: array_filter( ··· 91 87 ) 92 88 ); 93 89 94 - return $response->json(); 90 + return MemberResponse::fromArray($response->json()); 95 91 } 96 92 97 93 /** ··· 102 98 * @see https://docs.bsky.app/docs/api/tools-ozone-team-delete-member 103 99 */ 104 100 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:tools.ozone.team.deleteMember')] 105 - public function deleteTeamMember(string $did): void 101 + public function deleteTeamMember(string $did): EmptyResponse 106 102 { 107 103 $this->atp->client->post( 108 104 endpoint: OzoneTeam::DeleteMember, 109 105 body: compact('did') 110 106 ); 107 + 108 + return new EmptyResponse; 111 109 } 112 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 2 3 3 namespace SocialDept\AtpClient\RichText; 4 4 5 - use SocialDept\AtpResolver\Facades\Resolver; 5 + use SocialDept\AtpClient\Builders\Concerns\BuildsRichText; 6 6 7 7 class TextBuilder 8 8 { 9 - protected string $text = ''; 10 - protected array $facets = []; 9 + use BuildsRichText; 11 10 12 11 /** 13 12 * Create a new text builder instance 14 13 */ 15 14 public static function make(): self 16 15 { 17 - return new self(); 16 + return new self; 18 17 } 19 18 20 19 /** ··· 22 21 */ 23 22 public static function build(callable $callback): array 24 23 { 25 - $builder = new self(); 24 + $builder = new self; 26 25 $callback($builder); 27 26 28 27 return $builder->toArray(); 29 28 } 30 29 31 30 /** 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 31 * Build the final text and facets array 194 32 */ 195 33 public function toArray(): array 196 34 { 197 - return [ 198 - 'text' => $this->text, 199 - 'facets' => $this->facets, 200 - ]; 35 + return $this->getTextAndFacets(); 201 36 } 202 37 203 38 /** ··· 233 68 public function getByteCount(): int 234 69 { 235 70 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 71 } 253 72 254 73 /**