Laravel AT Protocol Client (alpha & unstable)
1<?php
2
3namespace SocialDept\AtpClient\Builders;
4
5use Closure;
6use DateTimeInterface;
7use SocialDept\AtpClient\Builders\Concerns\BuildsRichText;
8use SocialDept\AtpClient\Builders\Embeds\ImagesBuilder;
9use SocialDept\AtpClient\Client\Records\PostRecordClient;
10use SocialDept\AtpClient\Contracts\Recordable;
11use SocialDept\AtpClient\Data\StrongRef;
12use SocialDept\AtpClient\Enums\Nsid\BskyFeed;
13
14class 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}