フェディ返事

守矢諏訪子 <suwako@076.moe>、2026-11-11 16:26
コミット 8834e446cceb9125325c9c76fa0262121a0d274d ・親 a764d87 ・ファイルを見る

5個のファイル、+79 -0

変更 route.php
@@ -78,6 +78,9 @@
7878 $routes[] = Route::add('GET', 'ap/activities/create/{section}/{uuid}',
7979 Fediverse::class.'@apactivity');
8080 $routes[] = Route::add('GET', 'ap/objects/{uuid}', Fediverse::class.'@apobject');
81+ // 「{section}/{uuid}」より先に(「{uuid}/replies」もそれに一致するので)
82+ $routes[] = Route::add('GET', 'ap/objects/{uuid}/replies', Fediverse::class.'@apreplies');
83+ $routes[] = Route::add('GET', 'ap/objects/{section}/{uuid}/replies', Fediverse::class.'@apreplies');
8184 $routes[] = Route::add('GET', 'ap/objects/{section}/{uuid}', Fediverse::class.'@apobject');
8285 $routes[] = Route::add('POST', 'ap/inbox', Fediverse::class.'@apinbox');
8386 $routes[] = Route::add('GET', 'ap/actor', Fediverse::class.'@apactor');
@@ -379,6 +379,31 @@
379379 }
380380
381381 /**
382+ * 投稿への返信のコレクション(/ap/objects/{uuid}/replies)
383+ *
384+ * @param array $params パラメータ配列
385+ * @return void
386+ */
387+ public function apreplies(array $params): void {
388+ try {
389+ header('Content-Type: application/activity+json');
390+ $ap = new Activitypub($this->federatedPosts());
391+ $json = $ap->getReplies($params['uuid'] ?? '', $params['section'] ?? 'blog');
392+ if ($json === '') {
393+ header('HTTP/1.1 404 Not Found');
394+ echo json_encode(['error' => '見つかりません']);
395+ exit;
396+ }
397+ echo $json;
398+ exit;
399+ } catch (\Exception $e) {
400+ header('Content-Type: text/plain; charset=utf-8');
401+ echo 'フェディバースの作成に失敗: '.$e->getMessage();
402+ exit;
403+ }
404+ }
405+
406+ /**
382407 * @param array $params パラメータ配列
383408 * @return void
384409 */
@@ -173,6 +173,31 @@
173173 }
174174
175175 /**
176+ * 投稿への返信のコレクション(Noteの「replies」)
177+ * Fediverseから受け取った返信(記事のコメントになった物)だけ。他のサーバーやbrowser.pub等がスレッドを表示するのに使う
178+ *
179+ * @param string $uuid 投稿のUUID
180+ * @param string $section ブログのディレクトリ
181+ * @return string JSONエンコードされたOrderedCollection、投稿が無ければ空文字
182+ */
183+ public function getReplies(string $uuid, string $section = 'blog'): string {
184+ if (!ACTIVITYPUB_ENABLED) return '';
185+ $post = $this->findPublicPost($uuid, $section);
186+ if ($post === null) return '';
187+
188+ $items = Comments::remoteReplies(($post['section'] ?? 'blog').'/'.$post['slug']);
189+ $collection = [
190+ '@context' => 'https://www.w3.org/ns/activitystreams',
191+ 'id' => "{$this->domain}/ap/objects/".self::postKey($post).'/replies',
192+ 'type' => 'OrderedCollection',
193+ 'totalItems' => count($items),
194+ 'orderedItems' => $items,
195+ ];
196+
197+ return json_encode($collection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
198+ }
199+
200+ /**
176201 * アウトボックスデータを取得する(公開投稿のみ、新しい順)
177202 *
178203 * @return string JSONエンコードされたアウトボックスデータ
@@ -159,6 +159,22 @@
159159 }
160160
161161 /**
162+ * Fediverseから受け取った、記事又はコメントへの直接の返信のID(古い順、削除済みを除く)
163+ * ローカルのユーザーのコメントはActivityPubのオブジェクトではないので含まない
164+ *
165+ * @param string $thread スレッド
166+ * @param string|null $parent コメントのID(記事への返信ならnull)
167+ * @return array リモートの投稿のID
168+ */
169+ public static function remoteReplies(string $thread, ?string $parent = null): array {
170+ $out = [];
171+ foreach (self::all($thread) as $c) {
172+ if (empty($c['deleted']) && isset($c['apId']) && ($c['parent'] ?? null) === $parent) $out[] = $c['apId'];
173+ }
174+ return $out;
175+ }
176+
177+ /**
162178 * Fediverseの返信のIDからコメントを探す
163179 *
164180 * @param string $apId リモートの投稿のID
@@ -229,6 +229,16 @@
229229 $test->assertEquals(["comment:{$carol}", 'comment:alice1234'], array_slice($types('admin1234'), 0, 2), '管理者には全部のコメント');
230230 });
231231
232+ $test->it('返信のコレクションはFediverseからの記事への直接の返信だけのはず', function($test) use ($ap, $post, $thread, $target): void {
233+ $json = json_decode($ap->getReplies($post['uuid'], 'enblog'), true);
234+ $test->assertEquals("{$target}/replies", $json['id']);
235+ $test->assertEquals('OrderedCollection', $json['type']);
236+ $test->assertEquals(['https://1.1.1.1/users/carol/statuses/1'], $json['orderedItems'], 'ローカルのコメントと返信への返信は含まない');
237+ $test->assertEquals(1, $json['totalItems']);
238+ $test->assertEquals('', $ap->getReplies($post['uuid']), 'blogには無い');
239+ $test->assertEquals(['https://1.1.1.1/users/carol/statuses/7'], Comments::remoteReplies($thread, array_key_first(Comments::all($thread))));
240+ });
241+
232242 $test->it('本人の編集と削除だけを反映するはず', function($test) use ($ap, $note, $thread, $carol): void {
233243 $edited = $note('1', '', ['content' => '<p>編集しました</p>']);
234244 $test->assertFalse($ap->handleComments(['type' => 'Update', 'actor' => 'https://1.1.1.1/users/dave', 'object' => $edited]));