フェディコメ

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

5個のファイル、+159 -13

@@ -39,6 +39,7 @@
3939
4040 use Site\Controller\BlogPost;
4141 use Site\Controller\Mods;
42+use Std\Lib\Activitypub;
4243 use Std\Lib\Auth;
4344 use Std\Lib\Comments;
4445 use Std\Lib\Template;
@@ -74,7 +75,7 @@
7475 if (!$this->csrfOk()) {
7576 $error = 'セッションの有効期限が切れました。もう一度送信して下さい。';
7677 } 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));
7879 if ($res->isSuccess) $this->redirect('/'.$thread.'#c-'.$res->data['id']);
7980 $error = $res->message;
8081 }
@@ -144,6 +145,23 @@
144145 foreach ($this->getPosts("/{$parts[0]}/", $this->user) as $post) {
145146 // 会員限定等で読めない記事にはコメント出来ない
146147 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;
147165 }
148166 return null;
149167 }
@@ -174,7 +174,8 @@
174174
175175 /**
176176 * 投稿への返信のコレクション(Noteの「replies」)
177- * Fediverseから受け取った返信(記事のコメントになった物)だけ。他のサーバーやbrowser.pub等がスレッドを表示するのに使う
177+ * Fediverseにある返信(受け取った返信と、Fediverseのロールを持つユーザーのコメント)だけ。
178+ * 他のサーバーやbrowser.pub等がスレッドを表示するのに使う
178179 *
179180 * @param string $uuid 投稿のUUID
180181 * @param string $section ブログのディレクトリ
@@ -185,7 +186,7 @@
185186 $post = $this->findPublicPost($uuid, $section);
186187 if ($post === null) return '';
187188
188- $items = Comments::remoteReplies(($post['section'] ?? 'blog').'/'.$post['slug']);
189+ $items = Comments::fediReplies(($post['section'] ?? 'blog').'/'.$post['slug']);
189190 $collection = [
190191 '@context' => 'https://www.w3.org/ns/activitystreams',
191192 'id' => "{$this->domain}/ap/objects/".self::postKey($post).'/replies',
@@ -39,7 +39,9 @@
3939
4040 use Std\Lib\Fedi\Fedi;
4141 use Std\Lib\Fedi\Html;
42+use Std\Lib\Fedi\Notes;
4243 use Std\Lib\Fedi\Remote;
44+use Std\Lib\Fedi\Social;
4345 use Result;
4446 use Roles;
4547
@@ -53,6 +55,9 @@
5355 * notifications/{ユーザー名}.json 通知(新しい順)
5456 *
5557 * 新しいコメントは全部の管理者に通知する。ローカルのユーザーには、自分のコメントへの返信といいねを通知する。
58+ * マルチユーザーモードでFediverseのロールを持つユーザーのコメントは、連合する記事なら本人のアカウントの投稿
59+ * (記事又はコメントへの返信)としてもFediverseに送る。コメントを消すと投稿も消え、マイクロブログで編集・削除すると
60+ * コメントも変わる。
5661 */
5762 final class Comments {
5863 /** コメントの最大文字数 */
@@ -159,14 +164,14 @@
159164 }
160165
161166 /**
162- * Fediverseから受け取った、記事又はコメントへの直接の返信のID(古い順、削除済みを除く)
163- * ローカルのユーザーのコメントはActivityPubのオブジェクトではないので含まない
167+ * Fediverseにある、記事又はコメントへの直接の返信のID(古い順、削除済みを除く)
168+ * Fediverseから受け取った返信と、Fediverseのロールを持つユーザーのコメント(本人の投稿)。他のコメントは含まない
164169 *
165170 * @param string $thread スレッド
166171 * @param string|null $parent コメントのID(記事への返信ならnull)
167- * @return array リモートの投稿のID
172+ * @return array 投稿のID
168173 */
169- public static function remoteReplies(string $thread, ?string $parent = null): array {
174+ public static function fediReplies(string $thread, ?string $parent = null): array {
170175 $out = [];
171176 foreach (self::all($thread) as $c) {
172177 if (empty($c['deleted']) && isset($c['apId']) && ($c['parent'] ?? null) === $parent) $out[] = $c['apId'];
@@ -197,20 +202,23 @@
197202 * @param \stdClass $user コメントするユーザー
198203 * @param string $text 本文
199204 * @param string|null $parent 返信先のコメントのID
205+ * @param array|null $postNote 記事のNote(連合する記事の場合。Fediverseのロールを持つユーザーなら投稿としても送る)
200206 * @return Result data:id
201207 */
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 {
203209 if (!self::enabled()) return Result::Error('エラー:コメントは無効です。');
204210 if (!self::canPost($user)) return Result::Error('エラー:コメントする権限がありません。');
205211 $text = trim(str_replace(["\r\n", "\r"], "\n", $text));
206212 if ($text === '') return Result::Error('エラー:コメントをご入力下さい。');
207213 if (mb_strlen($text) > self::MAX_LENGTH) return Result::Error('エラー:コメントは'.self::MAX_LENGTH.'文字以内でご入力下さい。');
208214
209- return self::add($thread, $title, [
215+ $res = self::add($thread, $title, [
210216 'author' => ['username' => $user->username],
211217 'source' => $text,
212218 'html' => Html::render($text, [], false),
213219 ], $parent);
220+ if ($res->isSuccess && $postNote !== null) self::federateLocal($thread, $res->data['id'], $user->username, $text, $postNote);
221+ return $res;
214222 }
215223
216224 /**
@@ -253,6 +261,7 @@
253261 * @return bool 更新したらtrue
254262 */
255263 public static function updateRemote(array $obj, string $actor): bool {
264+ if (!self::enabled()) return false;
256265 $id = Fedi::id($obj['id'] ?? null);
257266 $ref = $id !== null ? self::remoteRef($id) : null;
258267 if ($ref === null || $ref['actor'] !== $actor || Fedi::id($obj['attributedTo'] ?? null) !== $actor) return false;
@@ -274,6 +283,7 @@
274283 * @return bool 消したらtrue
275284 */
276285 public static function deleteRemote(string $apId, string $actor): bool {
286+ if (!self::enabled()) return false;
277287 $ref = self::remoteRef($apId);
278288 if ($ref === null || $ref['actor'] !== $actor) return false;
279289 self::remove($ref['thread'], $ref['id']);
@@ -344,6 +354,9 @@
344354 $mine = ($c['author']['username'] ?? null) === ($user->username ?? '');
345355 if (!$mine && !self::isModerator($user)) return Result::Error('エラー:権限がありません。');
346356 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]);
347360 return Result::Success();
348361 }
349362
@@ -434,6 +447,42 @@
434447 foreach ($to as $u => $type) self::notify($u, $type, $thread, $id, $title, $from);
435448
436449 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+ });
437486 }
438487
439488 /**
@@ -37,6 +37,7 @@
3737 */
3838 namespace Std\Lib\Fedi;
3939
40+use Std\Lib\Comments;
4041 use Result;
4142
4243 /**
@@ -68,11 +69,12 @@
6869 $vis = (string)($opt['visibility'] ?? 'public');
6970 if (!isset(self::VISIBILITIES[$vis])) $vis = 'public';
7071
71- // 返信先
72+ // 返信先(parentObject:取得出来ない返信先。ブログの投稿のNote等、記事のコメントから)
7273 $parent = null;
7374 $inReplyTo = trim((string)($opt['inReplyTo'] ?? ''));
7475 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);
7678 if (!$parent) return Result::Error('返信先の投稿が見つかりません。');
7779 }
7880
@@ -158,6 +160,8 @@
158160 'object' => $obj,
159161 ];
160162 Delivery::enqueue($username, $update, Delivery::audience($username, $update));
163+ // 記事のコメントとしても投稿していれば、コメントも変える
164+ Comments::updateRemote($obj, Fedi::actorUrl($username));
161165 return Result::Success('編集しました。');
162166 }
163167
@@ -187,6 +191,8 @@
187191 if (!empty($note['inReplyTo'])) self::forgetReply($note['inReplyTo'], $url);
188192
189193 Delivery::enqueue($username, $delete, $inboxes);
194+ // 記事のコメントとしても投稿していれば、コメントも消す
195+ Comments::deleteRemote($url, Fedi::actorUrl($username));
190196 return Result::Success('削除しました。');
191197 }
192198
@@ -42,7 +42,7 @@
4242 use Std\Lib\Tester;
4343 use Std\Lib\Activitypub;
4444 use Std\Lib\Comments;
45-use Std\Lib\Fedi\{Fedi, Remote};
45+use Std\Lib\Fedi\{Fedi, Notes, Remote, Social};
4646
4747 $test = new Tester([
4848 'colorOutput' => true,
@@ -236,7 +236,7 @@
236236 $test->assertEquals(['https://1.1.1.1/users/carol/statuses/1'], $json['orderedItems'], 'ローカルのコメントと返信への返信は含まない');
237237 $test->assertEquals(1, $json['totalItems']);
238238 $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))));
240240 });
241241
242242 $test->it('本人の編集と削除だけを反映するはず', function($test) use ($ap, $note, $thread, $carol): void {
@@ -258,6 +258,78 @@
258258 // 返信のあるコメントは「削除されました」として残る
259259 $test->assertEquals([true, false], array_column($view, 'deleted'));
260260 $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));
261333 });
262334 });
263335