フェディコメ
守矢諏訪子 <suwako@076.moe>、2026-11-11 16:49
コミット 2493afc987dc3533ce82909b5a1c9e5ca337bea8
・親 8834e44
・ファイルを見る
5個のファイル、+159 -13
| @@ -39,6 +39,7 @@ | ||
| 39 | 39 | |
| 40 | 40 | use Site\Controller\BlogPost; |
| 41 | 41 | use Site\Controller\Mods; |
| 42 | +use Std\Lib\Activitypub; | |
| 42 | 43 | use Std\Lib\Auth; |
| 43 | 44 | use Std\Lib\Comments; |
| 44 | 45 | use Std\Lib\Template; |
| @@ -74,7 +75,7 @@ | ||
| 74 | 75 | if (!$this->csrfOk()) { |
| 75 | 76 | $error = 'セッションの有効期限が切れました。もう一度送信して下さい。'; |
| 76 | 77 | } else { |
| 77 | - $res = Comments::addLocal($thread, $title, $user, $body, $parent === '' ? null : $parent); | |
| 78 | + $res = Comments::addLocal($thread, $title, $user, $body, $parent === '' ? null : $parent, $this->postNote($thread)); | |
| 78 | 79 | if ($res->isSuccess) $this->redirect('/'.$thread.'#c-'.$res->data['id']); |
| 79 | 80 | $error = $res->message; |
| 80 | 81 | } |
| @@ -144,6 +145,23 @@ | ||
| 144 | 145 | foreach ($this->getPosts("/{$parts[0]}/", $this->user) as $post) { |
| 145 | 146 | // 会員限定等で読めない記事にはコメント出来ない |
| 146 | 147 | if ($post['slug'] === $parts[1]) return $post['preview'] === '未許可' ? null : (string)$post['title']; |
| 148 | + } | |
| 149 | + return null; | |
| 150 | + } | |
| 151 | + | |
| 152 | + /** | |
| 153 | + * 連合するブログの記事のNote(Fediverseのロールを持つユーザーのコメントを、記事への返信として送る為) | |
| 154 | + * | |
| 155 | + * @param string $thread スレッド | |
| 156 | + * @return array|null 連合しない記事(勉強会、会員限定等)ならnull | |
| 157 | + */ | |
| 158 | + private function postNote(string $thread): ?array { | |
| 159 | + [$section, $slug] = array_pad(explode('/', $thread, 2), 2, ''); | |
| 160 | + if (!ACTIVITYPUB_ENABLED || !(blogs()[$section]['federate'] ?? false)) return null; | |
| 161 | + foreach ($this->getPosts("/{$section}/", null) as $post) { | |
| 162 | + if ($post['slug'] !== $slug) continue; | |
| 163 | + $json = (new Activitypub([$post + ['section' => $section]]))->getObject((string)$post['uuid'], $section); | |
| 164 | + return $json !== '' ? json_decode($json, true) : null; | |
| 147 | 165 | } |
| 148 | 166 | return null; |
| 149 | 167 | } |
| @@ -174,7 +174,8 @@ | ||
| 174 | 174 | |
| 175 | 175 | /** |
| 176 | 176 | * 投稿への返信のコレクション(Noteの「replies」) |
| 177 | - * Fediverseから受け取った返信(記事のコメントになった物)だけ。他のサーバーやbrowser.pub等がスレッドを表示するのに使う | |
| 177 | + * Fediverseにある返信(受け取った返信と、Fediverseのロールを持つユーザーのコメント)だけ。 | |
| 178 | + * 他のサーバーやbrowser.pub等がスレッドを表示するのに使う | |
| 178 | 179 | * |
| 179 | 180 | * @param string $uuid 投稿のUUID |
| 180 | 181 | * @param string $section ブログのディレクトリ |
| @@ -185,7 +186,7 @@ | ||
| 185 | 186 | $post = $this->findPublicPost($uuid, $section); |
| 186 | 187 | if ($post === null) return ''; |
| 187 | 188 | |
| 188 | - $items = Comments::remoteReplies(($post['section'] ?? 'blog').'/'.$post['slug']); | |
| 189 | + $items = Comments::fediReplies(($post['section'] ?? 'blog').'/'.$post['slug']); | |
| 189 | 190 | $collection = [ |
| 190 | 191 | '@context' => 'https://www.w3.org/ns/activitystreams', |
| 191 | 192 | 'id' => "{$this->domain}/ap/objects/".self::postKey($post).'/replies', |
| @@ -39,7 +39,9 @@ | ||
| 39 | 39 | |
| 40 | 40 | use Std\Lib\Fedi\Fedi; |
| 41 | 41 | use Std\Lib\Fedi\Html; |
| 42 | +use Std\Lib\Fedi\Notes; | |
| 42 | 43 | use Std\Lib\Fedi\Remote; |
| 44 | +use Std\Lib\Fedi\Social; | |
| 43 | 45 | use Result; |
| 44 | 46 | use Roles; |
| 45 | 47 | |
| @@ -53,6 +55,9 @@ | ||
| 53 | 55 | * notifications/{ユーザー名}.json 通知(新しい順) |
| 54 | 56 | * |
| 55 | 57 | * 新しいコメントは全部の管理者に通知する。ローカルのユーザーには、自分のコメントへの返信といいねを通知する。 |
| 58 | + * マルチユーザーモードでFediverseのロールを持つユーザーのコメントは、連合する記事なら本人のアカウントの投稿 | |
| 59 | + * (記事又はコメントへの返信)としてもFediverseに送る。コメントを消すと投稿も消え、マイクロブログで編集・削除すると | |
| 60 | + * コメントも変わる。 | |
| 56 | 61 | */ |
| 57 | 62 | final class Comments { |
| 58 | 63 | /** コメントの最大文字数 */ |
| @@ -159,14 +164,14 @@ | ||
| 159 | 164 | } |
| 160 | 165 | |
| 161 | 166 | /** |
| 162 | - * Fediverseから受け取った、記事又はコメントへの直接の返信のID(古い順、削除済みを除く) | |
| 163 | - * ローカルのユーザーのコメントはActivityPubのオブジェクトではないので含まない | |
| 167 | + * Fediverseにある、記事又はコメントへの直接の返信のID(古い順、削除済みを除く) | |
| 168 | + * Fediverseから受け取った返信と、Fediverseのロールを持つユーザーのコメント(本人の投稿)。他のコメントは含まない | |
| 164 | 169 | * |
| 165 | 170 | * @param string $thread スレッド |
| 166 | 171 | * @param string|null $parent コメントのID(記事への返信ならnull) |
| 167 | - * @return array リモートの投稿のID | |
| 172 | + * @return array 投稿のID | |
| 168 | 173 | */ |
| 169 | - public static function remoteReplies(string $thread, ?string $parent = null): array { | |
| 174 | + public static function fediReplies(string $thread, ?string $parent = null): array { | |
| 170 | 175 | $out = []; |
| 171 | 176 | foreach (self::all($thread) as $c) { |
| 172 | 177 | if (empty($c['deleted']) && isset($c['apId']) && ($c['parent'] ?? null) === $parent) $out[] = $c['apId']; |
| @@ -197,20 +202,23 @@ | ||
| 197 | 202 | * @param \stdClass $user コメントするユーザー |
| 198 | 203 | * @param string $text 本文 |
| 199 | 204 | * @param string|null $parent 返信先のコメントのID |
| 205 | + * @param array|null $postNote 記事のNote(連合する記事の場合。Fediverseのロールを持つユーザーなら投稿としても送る) | |
| 200 | 206 | * @return Result data:id |
| 201 | 207 | */ |
| 202 | - public static function addLocal(string $thread, string $title, \stdClass $user, string $text, ?string $parent = null): Result { | |
| 208 | + public static function addLocal(string $thread, string $title, \stdClass $user, string $text, ?string $parent = null, ?array $postNote = null): Result { | |
| 203 | 209 | if (!self::enabled()) return Result::Error('エラー:コメントは無効です。'); |
| 204 | 210 | if (!self::canPost($user)) return Result::Error('エラー:コメントする権限がありません。'); |
| 205 | 211 | $text = trim(str_replace(["\r\n", "\r"], "\n", $text)); |
| 206 | 212 | if ($text === '') return Result::Error('エラー:コメントをご入力下さい。'); |
| 207 | 213 | if (mb_strlen($text) > self::MAX_LENGTH) return Result::Error('エラー:コメントは'.self::MAX_LENGTH.'文字以内でご入力下さい。'); |
| 208 | 214 | |
| 209 | - return self::add($thread, $title, [ | |
| 215 | + $res = self::add($thread, $title, [ | |
| 210 | 216 | 'author' => ['username' => $user->username], |
| 211 | 217 | 'source' => $text, |
| 212 | 218 | 'html' => Html::render($text, [], false), |
| 213 | 219 | ], $parent); |
| 220 | + if ($res->isSuccess && $postNote !== null) self::federateLocal($thread, $res->data['id'], $user->username, $text, $postNote); | |
| 221 | + return $res; | |
| 214 | 222 | } |
| 215 | 223 | |
| 216 | 224 | /** |
| @@ -253,6 +261,7 @@ | ||
| 253 | 261 | * @return bool 更新したらtrue |
| 254 | 262 | */ |
| 255 | 263 | public static function updateRemote(array $obj, string $actor): bool { |
| 264 | + if (!self::enabled()) return false; | |
| 256 | 265 | $id = Fedi::id($obj['id'] ?? null); |
| 257 | 266 | $ref = $id !== null ? self::remoteRef($id) : null; |
| 258 | 267 | if ($ref === null || $ref['actor'] !== $actor || Fedi::id($obj['attributedTo'] ?? null) !== $actor) return false; |
| @@ -274,6 +283,7 @@ | ||
| 274 | 283 | * @return bool 消したらtrue |
| 275 | 284 | */ |
| 276 | 285 | public static function deleteRemote(string $apId, string $actor): bool { |
| 286 | + if (!self::enabled()) return false; | |
| 277 | 287 | $ref = self::remoteRef($apId); |
| 278 | 288 | if ($ref === null || $ref['actor'] !== $actor) return false; |
| 279 | 289 | self::remove($ref['thread'], $ref['id']); |
| @@ -344,6 +354,9 @@ | ||
| 344 | 354 | $mine = ($c['author']['username'] ?? null) === ($user->username ?? ''); |
| 345 | 355 | if (!$mine && !self::isModerator($user)) return Result::Error('エラー:権限がありません。'); |
| 346 | 356 | self::remove($thread, $id); |
| 357 | + // Fediverseにも投稿したコメントは、投稿も消す(コメントは先に消したので、Social::deleteからは何もしない) | |
| 358 | + $note = isset($c['apId']) && isset($c['author']['username']) ? Fedi::localNoteId($c['apId']) : null; | |
| 359 | + if ($note !== null && $note[0] === $c['author']['username'] && Fedi::enabled()) Social::delete($note[0], $note[1]); | |
| 347 | 360 | return Result::Success(); |
| 348 | 361 | } |
| 349 | 362 | |
| @@ -434,6 +447,42 @@ | ||
| 434 | 447 | foreach ($to as $u => $type) self::notify($u, $type, $thread, $id, $title, $from); |
| 435 | 448 | |
| 436 | 449 | return Result::Success('', ['id' => $id]); |
| 450 | + } | |
| 451 | + | |
| 452 | + /** | |
| 453 | + * Fediverseのロールを持つユーザーのコメントを、本人の投稿(記事又はFediverseにある一番近い親コメントへの返信)として送る | |
| 454 | + * 失敗してもコメントはそのまま(ログに残す) | |
| 455 | + */ | |
| 456 | + private static function federateLocal(string $thread, string $id, string $username, string $text, array $postNote): void { | |
| 457 | + if (!Fedi::enabled() || Fedi::localActor($username) === null || !is_string($postNote['id'] ?? null)) return; | |
| 458 | + $all = self::all($thread); | |
| 459 | + $inReplyTo = $postNote['id']; | |
| 460 | + for ($x = $all[$id]['parent'] ?? null; $x !== null && isset($all[$x]); $x = $all[$x]['parent'] ?? null) { | |
| 461 | + if (isset($all[$x]['apId'])) { | |
| 462 | + $inReplyTo = $all[$x]['apId']; | |
| 463 | + break; | |
| 464 | + } | |
| 465 | + } | |
| 466 | + | |
| 467 | + try { | |
| 468 | + $res = Social::post($username, $text, ['visibility' => 'public', 'inReplyTo' => $inReplyTo, 'parentObject' => $postNote]); | |
| 469 | + } catch (\Throwable $e) { | |
| 470 | + $res = Result::Error($e->getMessage()); | |
| 471 | + } | |
| 472 | + if (!$res->isSuccess) { | |
| 473 | + logger(\LogType::ActivityPub, "コメント「{$thread}#{$id}」をFediverseに送れませんでした:{$res->message}"); | |
| 474 | + return; | |
| 475 | + } | |
| 476 | + | |
| 477 | + $apId = Notes::url($res->data['note']); | |
| 478 | + self::modify($thread, function(array $t) use ($id, $apId): array { | |
| 479 | + if (isset($t['comments'][$id])) $t['comments'][$id]['apId'] = $apId; | |
| 480 | + return $t; | |
| 481 | + }); | |
| 482 | + Fedi::update(self::path('comments/ap.json'), function(array $l) use ($apId, $thread, $id, $username): array { | |
| 483 | + $l[$apId] = ['thread' => $thread, 'id' => $id, 'actor' => Fedi::actorUrl($username)]; | |
| 484 | + return $l; | |
| 485 | + }); | |
| 437 | 486 | } |
| 438 | 487 | |
| 439 | 488 | /** |
| @@ -37,6 +37,7 @@ | ||
| 37 | 37 | */ |
| 38 | 38 | namespace Std\Lib\Fedi; |
| 39 | 39 | |
| 40 | +use Std\Lib\Comments; | |
| 40 | 41 | use Result; |
| 41 | 42 | |
| 42 | 43 | /** |
| @@ -68,11 +69,12 @@ | ||
| 68 | 69 | $vis = (string)($opt['visibility'] ?? 'public'); |
| 69 | 70 | if (!isset(self::VISIBILITIES[$vis])) $vis = 'public'; |
| 70 | 71 | |
| 71 | - // 返信先 | |
| 72 | + // 返信先(parentObject:取得出来ない返信先。ブログの投稿のNote等、記事のコメントから) | |
| 72 | 73 | $parent = null; |
| 73 | 74 | $inReplyTo = trim((string)($opt['inReplyTo'] ?? '')); |
| 74 | 75 | if ($inReplyTo !== '') { |
| 75 | - $parent = Remote::object($inReplyTo); | |
| 76 | + $parent = is_array($opt['parentObject'] ?? null) && ($opt['parentObject']['id'] ?? null) === $inReplyTo | |
| 77 | + ? $opt['parentObject'] : Remote::object($inReplyTo); | |
| 76 | 78 | if (!$parent) return Result::Error('返信先の投稿が見つかりません。'); |
| 77 | 79 | } |
| 78 | 80 | |
| @@ -158,6 +160,8 @@ | ||
| 158 | 160 | 'object' => $obj, |
| 159 | 161 | ]; |
| 160 | 162 | Delivery::enqueue($username, $update, Delivery::audience($username, $update)); |
| 163 | + // 記事のコメントとしても投稿していれば、コメントも変える | |
| 164 | + Comments::updateRemote($obj, Fedi::actorUrl($username)); | |
| 161 | 165 | return Result::Success('編集しました。'); |
| 162 | 166 | } |
| 163 | 167 | |
| @@ -187,6 +191,8 @@ | ||
| 187 | 191 | if (!empty($note['inReplyTo'])) self::forgetReply($note['inReplyTo'], $url); |
| 188 | 192 | |
| 189 | 193 | Delivery::enqueue($username, $delete, $inboxes); |
| 194 | + // 記事のコメントとしても投稿していれば、コメントも消す | |
| 195 | + Comments::deleteRemote($url, Fedi::actorUrl($username)); | |
| 190 | 196 | return Result::Success('削除しました。'); |
| 191 | 197 | } |
| 192 | 198 |
| @@ -42,7 +42,7 @@ | ||
| 42 | 42 | use Std\Lib\Tester; |
| 43 | 43 | use Std\Lib\Activitypub; |
| 44 | 44 | use Std\Lib\Comments; |
| 45 | -use Std\Lib\Fedi\{Fedi, Remote}; | |
| 45 | +use Std\Lib\Fedi\{Fedi, Notes, Remote, Social}; | |
| 46 | 46 | |
| 47 | 47 | $test = new Tester([ |
| 48 | 48 | 'colorOutput' => true, |
| @@ -236,7 +236,7 @@ | ||
| 236 | 236 | $test->assertEquals(['https://1.1.1.1/users/carol/statuses/1'], $json['orderedItems'], 'ローカルのコメントと返信への返信は含まない'); |
| 237 | 237 | $test->assertEquals(1, $json['totalItems']); |
| 238 | 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)))); | |
| 239 | + $test->assertEquals(['https://1.1.1.1/users/carol/statuses/7'], Comments::fediReplies($thread, array_key_first(Comments::all($thread)))); | |
| 240 | 240 | }); |
| 241 | 241 | |
| 242 | 242 | $test->it('本人の編集と削除だけを反映するはず', function($test) use ($ap, $note, $thread, $carol): void { |
| @@ -258,6 +258,78 @@ | ||
| 258 | 258 | // 返信のあるコメントは「削除されました」として残る |
| 259 | 259 | $test->assertEquals([true, false], array_column($view, 'deleted')); |
| 260 | 260 | $test->assertNull(Comments::findRemote('https://1.1.1.1/users/carol/statuses/1')); |
| 261 | + }); | |
| 262 | +}); | |
| 263 | + | |
| 264 | +$test->describe('Fediverseのロールを持つユーザーのコメント', function($test) use ($admin, $alice, $bob, $tmp): void { | |
| 265 | + if (!Comments::enabled() || !ACTIVITYPUB_MULTIUSER) { | |
| 266 | + $test->skipAll('COMMENTS_ENABLED又はマルチユーザーモードが無効'); | |
| 267 | + } else if (!is_readable((string)FEDIINFO['pubkey'])) { | |
| 268 | + $test->skipAll('ブログのアカウントの公開鍵(FEDIINFOのpubkey)が無い'); | |
| 269 | + } | |
| 270 | + | |
| 271 | + // ロールを外されたユーザー(fediDefaultがあり、FEDIVERSEのビットが無い) | |
| 272 | + file_put_contents("{$tmp}/user/6.carol1234.json", json_encode(['id' => 6, 'username' => 'carol1234', 'displayname' => '', 'namecolor' => '', | |
| 273 | + 'avatar' => '', 'gender' => -1, 'role' => \Roles::MEMBER, 'fediDefault' => true])); | |
| 274 | + $carol = (object)['id' => 6, 'username' => 'carol1234', 'role' => \Roles::MEMBER]; | |
| 275 | + $post = ['title' => '連合する記事', 'date' => '2025-01-01 10:00', 'thumbnail' => '', 'category' => [], 'uuid' => 'c0ffee00-0000-4000-8000-000000000002', | |
| 276 | + 'preview' => '本文', 'slug' => 'fedi-post', 'section' => 'enblog']; | |
| 277 | + $ap = new Activitypub([$post]); | |
| 278 | + $postNote = ACTIVITYPUB_MULTIUSER ? json_decode($ap->getObject($post['uuid'], 'enblog'), true) : []; | |
| 279 | + $thread = 'enblog/fedi-post'; | |
| 280 | + $noteOf = fn(string $thread, string $id) => Notes::get((string)(Fedi::localNoteId((string)(Comments::all($thread)[$id]['apId'] ?? ''))[1] ?? '')); | |
| 281 | + $ids = new \ArrayObject(); | |
| 282 | + | |
| 283 | + $test->it('ロールを持つユーザーのコメントは本人の投稿として記事に返信するはず', function($test) use ($ap, $post, $postNote, $thread, $alice, $noteOf, $ids): void { | |
| 284 | + $res = Comments::addLocal($thread, '連合する記事', $alice, 'こんにちは **世界**', null, $postNote); | |
| 285 | + $test->assertTrue($res->isSuccess, (string)$res->message); | |
| 286 | + $ids['alice'] = $res->data['id']; | |
| 287 | + $c = Comments::all($thread)[$ids['alice']]; | |
| 288 | + $test->assertStringContains(Fedi::actorUrl('alice1234').'/notes/', (string)($c['apId'] ?? '')); | |
| 289 | + $note = $noteOf($thread, $ids['alice']); | |
| 290 | + $test->assertEquals($postNote['id'], $note['inReplyTo']); | |
| 291 | + $test->assertEquals('public', $note['visibility']); | |
| 292 | + $test->assertEquals('こんにちは **世界**', $note['source']); | |
| 293 | + $test->assertEquals([$c['apId']], json_decode($ap->getReplies($post['uuid'], 'enblog'), true)['orderedItems']); | |
| 294 | + $test->assertEquals($ids['alice'], Comments::findRemote($c['apId'])[1], 'Fediverseからの返信を繋げられる'); | |
| 295 | + }); | |
| 296 | + | |
| 297 | + $test->it('ロールが無いユーザーと連合しない記事のコメントは送らないはず', function($test) use ($postNote, $thread, $carol, $alice, $ids): void { | |
| 298 | + $res = Comments::addLocal($thread, '連合する記事', $carol, 'ロール無し', $ids['alice'], $postNote); | |
| 299 | + $test->assertTrue($res->isSuccess, (string)$res->message); | |
| 300 | + $ids['carol'] = $res->data['id']; | |
| 301 | + $test->assertFalse(isset(Comments::all($thread)[$ids['carol']]['apId'])); | |
| 302 | + $id = Comments::addLocal('tutorial/linalg/01-test', '勉強会', $alice, '勉強会のコメント')->data['id']; | |
| 303 | + $test->assertFalse(isset(Comments::all('tutorial/linalg/01-test')[$id]['apId'])); | |
| 304 | + $test->assertEquals(1, count(Comments::fediReplies($thread))); | |
| 305 | + }); | |
| 306 | + | |
| 307 | + $test->it('返信はFediverseにある一番近い親コメントへの返信のはず', function($test) use ($postNote, $thread, $bob, $noteOf, $ids): void { | |
| 308 | + // carolのコメントはFediverseに無いので、その親(alice)への返信にする | |
| 309 | + $ids['bob'] = Comments::addLocal($thread, '連合する記事', $bob, 'carolさんへ', $ids['carol'], $postNote)->data['id']; | |
| 310 | + $alice = Comments::all($thread)[$ids['alice']]['apId']; | |
| 311 | + $test->assertEquals($alice, $noteOf($thread, $ids['bob'])['inReplyTo']); | |
| 312 | + $test->assertEquals([Comments::all($thread)[$ids['bob']]['apId']], Comments::fediReplies($thread, $ids['carol'])); | |
| 313 | + }); | |
| 314 | + | |
| 315 | + $test->it('マイクロブログで編集・削除するとコメントも変わるはず', function($test) use ($postNote, $thread, $bob, $noteOf, $ids): void { | |
| 316 | + $note = $noteOf($thread, $ids['bob']); | |
| 317 | + $test->assertTrue(Social::edit('bob12345', $note['id'], '編集しました')->isSuccess); | |
| 318 | + $c = Comments::all($thread)[$ids['bob']]; | |
| 319 | + $test->assertStringContains('編集しました', $c['html']); | |
| 320 | + $test->assertTrue(isset($c['edited'])); | |
| 321 | + $test->assertTrue(Social::delete('bob12345', $note['id'])->isSuccess); | |
| 322 | + $test->assertFalse(isset(Comments::all($thread)[$ids['bob']])); | |
| 323 | + }); | |
| 324 | + | |
| 325 | + $test->it('コメントを削除すると投稿も消えるはず', function($test) use ($thread, $alice, $admin, $noteOf, $ids): void { | |
| 326 | + $apId = Comments::all($thread)[$ids['alice']]['apId']; | |
| 327 | + $noteId = Fedi::localNoteId($apId)[1]; | |
| 328 | + $test->assertTrue(Comments::delete($thread, $ids['alice'], $admin)->isSuccess, '管理者が消しても本人の投稿を消す'); | |
| 329 | + $test->assertNull(Notes::get($noteId)); | |
| 330 | + $test->assertTrue(Comments::all($thread)[$ids['alice']]['deleted'], 'carolの返信があるので中身だけ消す'); | |
| 331 | + $test->assertNull(Comments::findRemote($apId)); | |
| 332 | + $test->assertEquals([], Comments::fediReplies($thread)); | |
| 261 | 333 | }); |
| 262 | 334 | }); |
| 263 | 335 |