$from, 'inbox' => $inbox, 'activity' => $activity, 'tries' => 0, 'next' => time()]; Fedi::write(Fedi::path('queue/'.Fedi::newId().'.json'), $job); } // ローカルの宛先は1回だけ処理する(共有の受付ボックスなので、受け取る全員に届く) if ($local) Inbox::handle($activity, true); } /** * アクティビティの宛先の受付ボックス(to・cc・フォロワー) * * @param string $from 送るユーザー * @param array $activity アクティビティ * @param array $extra 他に送るアクター(返信先の投稿者等) * @return array 受付ボックス(sharedInboxがあればそちら、重複なし) */ public static function audience(string $from, array $activity, array $extra = []): array { $obj = is_array($activity['object'] ?? null) ? $activity['object'] : []; $targets = array_merge( Fedi::ids($activity['to'] ?? []), Fedi::ids($activity['cc'] ?? []), Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []), $extra ); $followersUrl = Fedi::actorUrl($from).'/followers'; $inboxes = []; foreach (array_unique($targets) as $t) { if ($t === Fedi::PUBLIC || $t === Fedi::actorUrl($from)) continue; if ($t === $followersUrl) { foreach (Actor::followers($from) as $actor => $f) { $inbox = $f['sharedInbox'] ?? $f['inbox'] ?? null; if ($inbox) $inboxes[] = $inbox; } continue; } if (str_ends_with($t, '/followers')) continue; // 他人のフォロワーには送れない $doc = Remote::actor($t); if (!$doc) continue; $inbox = Remote::inbox($doc); if ($inbox) $inboxes[] = $inbox; } return array_values(array_unique($inboxes)); } /** * 送る時刻になったアクティビティを送る(cronのapdeliver) * * @param int $budget この秒数を超えたら次の実行に回す * @return \Result data:sent, failed, dropped */ public static function run(int $budget = 50): \Result { if (!Fedi::enabled()) return \Result::Error('マルチユーザーのFediverseは無効です。'); $lock = fopen(Fedi::path('queue.lock'), 'c'); if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) return \Result::Success('前の配信がまだ終わっていません。'); $start = time(); $sent = $failed = $dropped = $filled = 0; try { $files = glob(Fedi::path('queue/').'*.json') ?: []; sort($files); foreach ($files as $file) { if (time() - $start > $budget) break; $job = Fedi::read($file, null); if (!$job) { @unlink($file); continue; } if (($job['next'] ?? 0) > time()) continue; if (static::send($job['from'], $job['inbox'], $job['activity'])) { @unlink($file); $sent++; continue; } $job['tries']++; if ($job['tries'] >= self::MAX_TRIES) { logger(\LogType::ActivityPub, "配信を諦めました:{$job['activity']['type']} → {$job['inbox']}"); @unlink($file); $dropped++; continue; } $job['next'] = time() + self::BACKOFF[$job['tries'] - 1]; Fedi::write($file, $job); $failed++; } $filled = static::runBackfill($budget - (time() - $start)); } finally { flock($lock, LOCK_UN); fclose($lock); } $data = ['sent' => $sent, 'failed' => $failed, 'dropped' => $dropped, 'backfilled' => $filled]; return \Result::Success("配信:成功{$sent}件、失敗{$failed}件、諦めた{$dropped}件、取得した投稿{$filled}件", $data); } /** * フォローが承認された相手の最近の投稿を、次の配信の時に取得する */ public static function queueBackfill(string $actor): void { if (Fedi::isLocal($actor)) return; Fedi::update(Fedi::path('backfill.json'), fn($l) => $l + [$actor => time()]); } /** * 待っている相手の投稿を取得する(1人1回だけ。失敗しても、新しい投稿はこれから届く) * * @param int $budget 使って良い秒数 * @return int タイムラインに入れた件数 */ protected static function runBackfill(int $budget): int { $start = time(); $n = 0; foreach (array_keys(Fedi::read(Fedi::path('backfill.json'))) as $actor) { if (time() - $start > $budget) break; Fedi::update(Fedi::path('backfill.json'), function($l) use ($actor) { unset($l[$actor]); return $l; }); $n += Social::backfill((string)$actor); } return $n; } /** 待ち行列の件数 */ public static function pending(): int { return count(glob(Fedi::path('queue/').'*.json') ?: []); } /** * 署名して送る * * @param string $from 送るユーザー(「gitler:所有者/名前」はGitlerのリポジトリ) * @param string $inbox 受付ボックス * @param array $activity アクティビティ * @return bool 2xxならtrue */ protected static function send(string $from, string $inbox, array $activity): bool { if (!Remote::isSafeUrl($inbox)) return false; // Gitlerのリポジトリ(「gitler:所有者/名前」)はリポジトリの鍵 [$key, $keyId] = str_starts_with($from, 'gitler:') ? (\Std\Lib\Gitler\Federation::signer(substr($from, 7)) ?? [null, '']) : [Actor::privateKey($from), Actor::keyId($from)]; if ($key === null) return false; $body = json_encode($activity, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $headers = (new Activitypub())->signRequest($inbox, $body, $key, 'post', $keyId); if ($headers === null) return false; $curl = new Curl($inbox); $curl->setMethod('POST') ->setPostRaw($body) ->setHeaders($headers) ->setFollowRedirects(false) ->setTimeout(15) ->setCaInfo('/etc/ssl/cert.pem'); $res = $curl->execute(); $code = $curl->getResponseCode(); $ok = $res->isSuccess && $code >= 200 && $code < 300; // 410(もう無い)は再試行しても無駄 if ($code === 410) return true; if (!$ok) logger(\LogType::ActivityPub, "{$activity['type']}を「{$inbox}」に送れません:HTTP {$code} {$curl->getError()}"); return $ok; } }