+3
-5
composer.json
+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
+197
src/Builders/Concerns/BuildsRichText.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Builders\Concerns;
4
+
5
+
use SocialDept\AtpClient\RichText\FacetDetector;
6
+
use SocialDept\AtpResolver\Facades\Resolver;
7
+
8
+
trait BuildsRichText
9
+
{
10
+
protected string $text = '';
11
+
12
+
protected array $facets = [];
13
+
14
+
/**
15
+
* Add plain text
16
+
*/
17
+
public function text(string $text): self
18
+
{
19
+
$this->text .= $text;
20
+
21
+
return $this;
22
+
}
23
+
24
+
/**
25
+
* Add one or more new lines
26
+
*/
27
+
public function newLine(int $count = 1): self
28
+
{
29
+
$this->text .= str_repeat("\n", $count);
30
+
31
+
return $this;
32
+
}
33
+
34
+
/**
35
+
* Add mention (@handle)
36
+
*/
37
+
public function mention(string $handle, ?string $did = null): self
38
+
{
39
+
$handle = ltrim($handle, '@');
40
+
$start = $this->getBytePosition();
41
+
$this->text .= '@'.$handle;
42
+
$end = $this->getBytePosition();
43
+
44
+
if (! $did) {
45
+
try {
46
+
$did = Resolver::handleToDid($handle);
47
+
} catch (\Exception $e) {
48
+
return $this;
49
+
}
50
+
}
51
+
52
+
$this->facets[] = [
53
+
'index' => [
54
+
'byteStart' => $start,
55
+
'byteEnd' => $end,
56
+
],
57
+
'features' => [
58
+
[
59
+
'$type' => 'app.bsky.richtext.facet#mention',
60
+
'did' => $did,
61
+
],
62
+
],
63
+
];
64
+
65
+
return $this;
66
+
}
67
+
68
+
/**
69
+
* Add link with custom display text
70
+
*/
71
+
public function link(string $text, string $uri): self
72
+
{
73
+
$start = $this->getBytePosition();
74
+
$this->text .= $text;
75
+
$end = $this->getBytePosition();
76
+
77
+
$this->facets[] = [
78
+
'index' => [
79
+
'byteStart' => $start,
80
+
'byteEnd' => $end,
81
+
],
82
+
'features' => [
83
+
[
84
+
'$type' => 'app.bsky.richtext.facet#link',
85
+
'uri' => $uri,
86
+
],
87
+
],
88
+
];
89
+
90
+
return $this;
91
+
}
92
+
93
+
/**
94
+
* Add a URL (displayed as-is)
95
+
*/
96
+
public function url(string $url): self
97
+
{
98
+
return $this->link($url, $url);
99
+
}
100
+
101
+
/**
102
+
* Add hashtag
103
+
*/
104
+
public function tag(string $tag): self
105
+
{
106
+
$tag = ltrim($tag, '#');
107
+
108
+
$start = $this->getBytePosition();
109
+
$this->text .= '#'.$tag;
110
+
$end = $this->getBytePosition();
111
+
112
+
$this->facets[] = [
113
+
'index' => [
114
+
'byteStart' => $start,
115
+
'byteEnd' => $end,
116
+
],
117
+
'features' => [
118
+
[
119
+
'$type' => 'app.bsky.richtext.facet#tag',
120
+
'tag' => $tag,
121
+
],
122
+
],
123
+
];
124
+
125
+
return $this;
126
+
}
127
+
128
+
/**
129
+
* Auto-detect and add facets from plain text
130
+
*/
131
+
public function autoDetect(string $text): self
132
+
{
133
+
$start = $this->getBytePosition();
134
+
$this->text .= $text;
135
+
136
+
$detected = FacetDetector::detect($text);
137
+
138
+
foreach ($detected as $facet) {
139
+
$facet['index']['byteStart'] += $start;
140
+
$facet['index']['byteEnd'] += $start;
141
+
$this->facets[] = $facet;
142
+
}
143
+
144
+
return $this;
145
+
}
146
+
147
+
/**
148
+
* Get current byte position (UTF-8 byte offset)
149
+
*/
150
+
protected function getBytePosition(): int
151
+
{
152
+
return strlen($this->text);
153
+
}
154
+
155
+
/**
156
+
* Get the text content
157
+
*/
158
+
public function getText(): string
159
+
{
160
+
return $this->text;
161
+
}
162
+
163
+
/**
164
+
* Get the facets
165
+
*/
166
+
public function getFacets(): array
167
+
{
168
+
return $this->facets;
169
+
}
170
+
171
+
/**
172
+
* Get text and facets as array
173
+
*/
174
+
protected function getTextAndFacets(): array
175
+
{
176
+
return [
177
+
'text' => $this->text,
178
+
'facets' => $this->facets,
179
+
];
180
+
}
181
+
182
+
/**
183
+
* Get grapheme count (closest to what AT Protocol uses for limits)
184
+
*/
185
+
public function getGraphemeCount(): int
186
+
{
187
+
return grapheme_strlen($this->text);
188
+
}
189
+
190
+
/**
191
+
* Check if text exceeds AT Protocol post limit (300 graphemes)
192
+
*/
193
+
public function exceedsLimit(int $limit = 300): bool
194
+
{
195
+
return $this->getGraphemeCount() > $limit;
196
+
}
197
+
}
+93
src/Builders/Embeds/ImagesBuilder.php
+93
src/Builders/Embeds/ImagesBuilder.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Builders\Embeds;
4
+
5
+
class ImagesBuilder
6
+
{
7
+
protected array $images = [];
8
+
9
+
/**
10
+
* Create a new images builder instance
11
+
*/
12
+
public static function make(): self
13
+
{
14
+
return new self;
15
+
}
16
+
17
+
/**
18
+
* Add an image to the embed
19
+
*
20
+
* @param mixed $blob BlobReference or blob array
21
+
* @param string $alt Alt text for the image
22
+
* @param array|null $aspectRatio [width, height] aspect ratio
23
+
*/
24
+
public function add(mixed $blob, string $alt, ?array $aspectRatio = null): self
25
+
{
26
+
$image = [
27
+
'image' => $this->normalizeBlob($blob),
28
+
'alt' => $alt,
29
+
];
30
+
31
+
if ($aspectRatio !== null) {
32
+
$image['aspectRatio'] = [
33
+
'width' => $aspectRatio[0],
34
+
'height' => $aspectRatio[1],
35
+
];
36
+
}
37
+
38
+
$this->images[] = $image;
39
+
40
+
return $this;
41
+
}
42
+
43
+
/**
44
+
* Get all images
45
+
*/
46
+
public function getImages(): array
47
+
{
48
+
return $this->images;
49
+
}
50
+
51
+
/**
52
+
* Check if builder has images
53
+
*/
54
+
public function hasImages(): bool
55
+
{
56
+
return ! empty($this->images);
57
+
}
58
+
59
+
/**
60
+
* Get the count of images
61
+
*/
62
+
public function count(): int
63
+
{
64
+
return count($this->images);
65
+
}
66
+
67
+
/**
68
+
* Convert to embed array format
69
+
*/
70
+
public function toArray(): array
71
+
{
72
+
return [
73
+
'$type' => 'app.bsky.embed.images',
74
+
'images' => $this->images,
75
+
];
76
+
}
77
+
78
+
/**
79
+
* Normalize blob to array format
80
+
*/
81
+
protected function normalizeBlob(mixed $blob): array
82
+
{
83
+
if (is_array($blob)) {
84
+
return $blob;
85
+
}
86
+
87
+
if (method_exists($blob, 'toArray')) {
88
+
return $blob->toArray();
89
+
}
90
+
91
+
return (array) $blob;
92
+
}
93
+
}
+257
src/Builders/PostBuilder.php
+257
src/Builders/PostBuilder.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\AtpClient\Builders;
4
+
5
+
use Closure;
6
+
use DateTimeInterface;
7
+
use SocialDept\AtpClient\Builders\Concerns\BuildsRichText;
8
+
use SocialDept\AtpClient\Builders\Embeds\ImagesBuilder;
9
+
use SocialDept\AtpClient\Client\Records\PostRecordClient;
10
+
use SocialDept\AtpClient\Contracts\Recordable;
11
+
use SocialDept\AtpClient\Data\StrongRef;
12
+
use SocialDept\AtpClient\Enums\Nsid\BskyFeed;
13
+
14
+
class PostBuilder implements Recordable
15
+
{
16
+
use BuildsRichText;
17
+
18
+
protected ?array $embed = null;
19
+
20
+
protected ?array $reply = null;
21
+
22
+
protected ?array $langs = null;
23
+
24
+
protected ?DateTimeInterface $createdAt = null;
25
+
26
+
protected ?PostRecordClient $client = null;
27
+
28
+
/**
29
+
* Create a new post builder instance
30
+
*/
31
+
public static function make(): self
32
+
{
33
+
return new self;
34
+
}
35
+
36
+
/**
37
+
* Add images embed
38
+
*
39
+
* @param Closure|array $images Closure receiving ImagesBuilder, or array of image data
40
+
*/
41
+
public function images(Closure|array $images): self
42
+
{
43
+
if ($images instanceof Closure) {
44
+
$builder = ImagesBuilder::make();
45
+
$images($builder);
46
+
$this->embed = $builder->toArray();
47
+
} else {
48
+
$this->embed = [
49
+
'$type' => 'app.bsky.embed.images',
50
+
'images' => array_map(fn ($img) => $this->normalizeImageData($img), $images),
51
+
];
52
+
}
53
+
54
+
return $this;
55
+
}
56
+
57
+
/**
58
+
* Add external link embed (link card)
59
+
*
60
+
* @param string $uri URL of the external content
61
+
* @param string $title Title of the link card
62
+
* @param string $description Description text
63
+
* @param mixed|null $thumb Optional thumbnail blob
64
+
*/
65
+
public function external(string $uri, string $title, string $description, mixed $thumb = null): self
66
+
{
67
+
$external = [
68
+
'uri' => $uri,
69
+
'title' => $title,
70
+
'description' => $description,
71
+
];
72
+
73
+
if ($thumb !== null) {
74
+
$external['thumb'] = $this->normalizeBlob($thumb);
75
+
}
76
+
77
+
$this->embed = [
78
+
'$type' => 'app.bsky.embed.external',
79
+
'external' => $external,
80
+
];
81
+
82
+
return $this;
83
+
}
84
+
85
+
/**
86
+
* Add video embed
87
+
*
88
+
* @param mixed $blob Video blob reference
89
+
* @param string|null $alt Alt text for the video
90
+
* @param array|null $captions Optional captions array
91
+
*/
92
+
public function video(mixed $blob, ?string $alt = null, ?array $captions = null): self
93
+
{
94
+
$video = [
95
+
'$type' => 'app.bsky.embed.video',
96
+
'video' => $this->normalizeBlob($blob),
97
+
];
98
+
99
+
if ($alt !== null) {
100
+
$video['alt'] = $alt;
101
+
}
102
+
103
+
if ($captions !== null) {
104
+
$video['captions'] = $captions;
105
+
}
106
+
107
+
$this->embed = $video;
108
+
109
+
return $this;
110
+
}
111
+
112
+
/**
113
+
* Add quote embed (embed another post)
114
+
*/
115
+
public function quote(StrongRef $post): self
116
+
{
117
+
$this->embed = [
118
+
'$type' => 'app.bsky.embed.record',
119
+
'record' => $post->toArray(),
120
+
];
121
+
122
+
return $this;
123
+
}
124
+
125
+
/**
126
+
* Set as a reply to another post
127
+
*
128
+
* @param StrongRef $parent The post being replied to
129
+
* @param StrongRef|null $root The root post of the thread (defaults to parent if not provided)
130
+
*/
131
+
public function replyTo(StrongRef $parent, ?StrongRef $root = null): self
132
+
{
133
+
$this->reply = [
134
+
'parent' => $parent->toArray(),
135
+
'root' => ($root ?? $parent)->toArray(),
136
+
];
137
+
138
+
return $this;
139
+
}
140
+
141
+
/**
142
+
* Set the post languages
143
+
*
144
+
* @param array $langs Array of BCP-47 language codes
145
+
*/
146
+
public function langs(array $langs): self
147
+
{
148
+
$this->langs = $langs;
149
+
150
+
return $this;
151
+
}
152
+
153
+
/**
154
+
* Set the creation timestamp
155
+
*/
156
+
public function createdAt(DateTimeInterface $date): self
157
+
{
158
+
$this->createdAt = $date;
159
+
160
+
return $this;
161
+
}
162
+
163
+
/**
164
+
* Bind to a PostRecordClient for creating the post
165
+
*/
166
+
public function for(PostRecordClient $client): self
167
+
{
168
+
$this->client = $client;
169
+
170
+
return $this;
171
+
}
172
+
173
+
/**
174
+
* Create the post (requires client binding via for() or build())
175
+
*
176
+
* @throws \RuntimeException If no client is bound
177
+
*/
178
+
public function create(): StrongRef
179
+
{
180
+
if ($this->client === null) {
181
+
throw new \RuntimeException(
182
+
'No client bound. Use ->for($client) or create via $client->bsky->post->build()'
183
+
);
184
+
}
185
+
186
+
return $this->client->create($this);
187
+
}
188
+
189
+
/**
190
+
* Convert to array for XRPC (implements Recordable)
191
+
*/
192
+
public function toArray(): array
193
+
{
194
+
$record = $this->getTextAndFacets();
195
+
196
+
if ($this->embed !== null) {
197
+
$record['embed'] = $this->embed;
198
+
}
199
+
200
+
if ($this->reply !== null) {
201
+
$record['reply'] = $this->reply;
202
+
}
203
+
204
+
if ($this->langs !== null) {
205
+
$record['langs'] = $this->langs;
206
+
}
207
+
208
+
$record['createdAt'] = ($this->createdAt ?? now())->format('c');
209
+
$record['$type'] = $this->getType();
210
+
211
+
return $record;
212
+
}
213
+
214
+
/**
215
+
* Get the record type (implements Recordable)
216
+
*/
217
+
public function getType(): string
218
+
{
219
+
return BskyFeed::Post->value;
220
+
}
221
+
222
+
/**
223
+
* Normalize image data from array format
224
+
*/
225
+
protected function normalizeImageData(array $data): array
226
+
{
227
+
$image = [
228
+
'image' => $this->normalizeBlob($data['blob'] ?? $data['image']),
229
+
'alt' => $data['alt'] ?? '',
230
+
];
231
+
232
+
if (isset($data['aspectRatio'])) {
233
+
$ratio = $data['aspectRatio'];
234
+
$image['aspectRatio'] = is_array($ratio) && isset($ratio['width'])
235
+
? $ratio
236
+
: ['width' => $ratio[0], 'height' => $ratio[1]];
237
+
}
238
+
239
+
return $image;
240
+
}
241
+
242
+
/**
243
+
* Normalize blob to array format
244
+
*/
245
+
protected function normalizeBlob(mixed $blob): array
246
+
{
247
+
if (is_array($blob)) {
248
+
return $blob;
249
+
}
250
+
251
+
if (method_exists($blob, 'toArray')) {
252
+
return $blob->toArray();
253
+
}
254
+
255
+
return (array) $blob;
256
+
}
257
+
}
+9
-11
src/Client/Records/FollowRecordClient.php
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
/**