diff --git a/.env.example b/.env.example index 0d9d4c9..c9b506c 100644 --- a/.env.example +++ b/.env.example @@ -106,3 +106,14 @@ TELEGRAM_PREFIX=! # Optional. How long each long poll is held open, 1-50 seconds. Updates arrive # as soon as there are any either way; shorter only means more requests. TELEGRAM_POLL_TIMEOUT=50 + +# --- Optional local Newsletter feed --- +# Absolute output path, shared read-only with DiscordPHP-Newsletter. Empty = disabled. +NEWSLETTER_BRIDGE_FEED= +# JSON objects of platform -> stable account IDs; never display names or handles. +# Example: {"telegram":["123456"],"twitch":["654321"]} +NEWSLETTER_BRIDGE_AUTHOR_IDS= +# Explicitly approved PUBLIC rooms only, using connector-normalized targets. +# Example: {"telegram":["-100123456"],"twitch":["publicchannel"]} +# Do not list DMs, staff rooms, private groups, or anonymous/channel identities. +NEWSLETTER_BRIDGE_PUBLIC_ROOMS= diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index adde370..17855d5 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -3,7 +3,7 @@ // The application is one entrypoint and its tests; there is no src/ here, // because everything it does lives in the packages it requires. $finder = PhpCsFixer\Finder::create() - ->in(__DIR__ . '/tests') + ->in([__DIR__ . '/tests', __DIR__ . '/integration']) ->append([__DIR__ . '/bot.php']); return (new PhpCsFixer\Config()) diff --git a/README.md b/README.md index 5066649..38ffa11 100644 --- a/README.md +++ b/README.md @@ -225,3 +225,26 @@ trust the admins. ## Licence MIT. + +## Optional local Newsletter export + +`NEWSLETTER_BRIDGE_FEED` enables an owner-only JSON snapshot for DiscordPHP-Newsletter. +Set an absolute path and both `NEWSLETTER_BRIDGE_AUTHOR_IDS` and +`NEWSLETTER_BRIDGE_PUBLIC_ROOMS` JSON objects (see `.env.example`). Keys are connector +names, values are arrays of string IDs. Account IDs are the platform's immutable IDs, +not display names or handles. Rooms must be explicitly approved **public** rooms using +connector-normalized targets. An allowlist entry does not prove a room is public; the +operator must check it. Leave all settings empty to disable recording. Restart both bots +after configuring their shared local path. No history scraping or publication occurs. + +The module registers additional incoming listeners without changing relay handlers. +Only the owner's text is exported, capped at 600 characters; no quotes, media, profile +URLs, credentials or other authors' text. Leading `!` and `/` commands are excluded. +It suppresses unchanged duplicate deliveries, replaces edits, and honors deletion/purge +notices received from connectors. Unsupported deletion notifications cannot be inferred. +The snapshot retains at most 500 messages and 14 days, reapplies current policy on restart, +and writes a heartbeat every five minutes through the existing atomic JSON persistence. +Restart/capacity gaps remain visible to the consumer; source timestamps mean observed +time, not original-send time. A recorded plan is not evidence of finished development. +Shutdown flushes queued writes; forced process termination can lose pending observations. +Persisted Newsletter editions/public posts are not retroactively erased by feed deletions. diff --git a/bot.php b/bot.php index d171085..231735c 100644 --- a/bot.php +++ b/bot.php @@ -17,12 +17,14 @@ use Bridge\Store; use Bridge\Support\Filesystem; use Bridge\Support\GatewayDiagnostics; +use Bridge\Support\JsonFile; use Bridge\Telegram\TelegramConfig; use Bridge\Telegram\TelegramConnector; use Bridge\Twitch\TwitchConfig; use Bridge\Twitch\TwitchConnector; use Bridge\YouTube\YouTubeConfig; use Bridge\YouTube\YouTubeConnector; +use BridgeBot\Integration\NewsletterFeed; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; use Monolog\Level; @@ -45,6 +47,7 @@ } require $baseDir . '/vendor/autoload.php'; +require_once $baseDir . '/integration/NewsletterFeed.php'; // One environment for the whole process. Every connector reads its own settings // out of it, so the file is parsed once and nothing disagrees about precedence. @@ -129,6 +132,24 @@ $logger->info('[bridge] no YouTube client — skipping that connector'); } +// Disabled unless the operator explicitly names both their accounts and public rooms. +if ($feedPath = $environment->get('NEWSLETTER_BRIDGE_FEED')) { + try { + $authors = NewsletterFeed::policy($environment->require('NEWSLETTER_BRIDGE_AUTHOR_IDS')); + $rooms = NewsletterFeed::policy($environment->require('NEWSLETTER_BRIDGE_PUBLIC_ROOMS')); + $feedFile = new JsonFile($feedPath, Filesystem::create()); + $saved = $feedFile->load(); + $feed = new NewsletterFeed($authors, $rooms, static fn (array $data) => $feedFile->save($data)->then(null, static function () use ($logger): void { + $logger->warning('[newsletter] could not save the Bridge feed; coverage may be incomplete'); + }), (array) ($saved['messages'] ?? [])); + $bot->addModule($feed); + register_shutdown_function(static fn () => $feedFile->flush()); + $logger->info('[newsletter] public-room owner feed enabled; no historical chat backfill'); + } catch (Throwable) { + $logger->warning('[newsletter] feed disabled: check its path and explicit account/public-room policy'); + } +} + if ($bot->connectors() === []) { fwrite(STDERR, "No connectors are configured — there is nothing to bridge. See .env.example.\n"); diff --git a/integration/NewsletterFeed.php b/integration/NewsletterFeed.php new file mode 100644 index 0000000..5420eca --- /dev/null +++ b/integration/NewsletterFeed.php @@ -0,0 +1,157 @@ +> $authors Stable platform account IDs, never display names. + * @param array> $rooms Operator-approved public room IDs. + * @param callable(array): mixed $save Injected persistence, no chat or network effects. + * @param list $saved Previous messages, re-filtered against current policy. + */ + public function __construct( + private readonly array $authors, + private readonly array $rooms, + callable $save, + array $saved = [], + ?callable $clock = null, + private readonly int $capacity = 500, + ) { + if ($capacity < 1) { + throw new \InvalidArgumentException('Newsletter feed capacity must be positive.'); + } + $this->save = \Closure::fromCallable($save); + $this->clock = $clock === null ? static fn (): \DateTimeImmutable => new \DateTimeImmutable() : \Closure::fromCallable($clock); + $this->coverageStart = ($this->clock)()->format(DATE_ATOM); + foreach ($saved as $message) { + if (is_array($message) && $this->allowed($message['platform'] ?? '', $message['room'] ?? '', $message['author_id'] ?? '') + && is_string($message['id'] ?? null) && is_string($message['text'] ?? null) + && is_string($message['created_at'] ?? null) && is_string($message['updated_at'] ?? null)) { + $this->messages[$this->key($message['platform'], $message['room'], $message['id'])] = $message; + } + } + } + + public function name(): string + { + return 'newsletter-feed'; + } + + public function boot(Bot $bot): void + { + foreach ($bot->connectors() as $platform => $connector) { + $connector->onIncoming(fn (Incoming $message) => $this->record($platform, $message)); + } + $bot->getLoop()->addPeriodicTimer(300, fn () => $this->persist()); + $this->persist(); + } + + /** Records original text only: never quotes, attachments, profiles, or command replies. */ + public function record(string $platform, Incoming $message): void + { + if ($message->own || ! in_array($message->target, $this->rooms[$platform] ?? [], true)) { + return; + } + if ($message->deleted) { + if ($message->id !== null) { + unset($this->messages[$this->key($platform, $message->target, $message->id)]); + } elseif ($message->authorId !== null) { + $this->messages = array_filter($this->messages, static fn (array $m): bool => ! ($m['platform'] === $platform && $m['room'] === $message->target && $m['author_id'] === $message->authorId)); + } + $this->persist(); + + return; + } + if (! $this->allowed($platform, $message->target, $message->authorId ?? '') || $message->id === null) { + return; + } + $key = $this->key($platform, $message->target, $message->id); + // Do not archive commands or empty edits; an edit removes the old excerpt too. + $text = trim((string) preg_replace('/\s+/u', ' ', $message->text ?? '')); + if ($text === '' || preg_match('/^[!\/]/u', $text)) { + unset($this->messages[$key]); + $this->persist(); + + return; + } + $text = mb_substr($text, 0, 600); + if (($this->messages[$key]['text'] ?? null) === $text) { + return; // reconnect duplicates and unchanged edits are not new activity + } + $now = ($this->clock)()->format(DATE_ATOM); + $created = $this->messages[$key]['created_at'] ?? $now; + $revised = $message->edited || isset($this->messages[$key]); + unset($this->messages[$key]); + $this->messages[$key] = ['platform' => $platform, 'room' => $message->target, 'author_id' => $message->authorId, + 'id' => $message->id, 'text' => $text, 'created_at' => $created, 'updated_at' => $now, 'revised' => $revised]; + $this->persist(); + } + + public function persist(): void + { + $now = ($this->clock)(); + foreach ($this->messages as $key => $message) { + try { + $old = new \DateTimeImmutable($message['updated_at']); + } catch (\Exception) { + unset($this->messages[$key]); + + continue; + } + if ($old < $now->modify('-14 days')) { + unset($this->messages[$key]); + } + } + while (count($this->messages) > $this->capacity) { + $removed = array_shift($this->messages); + $this->coverageStart = max($this->coverageStart, $removed['updated_at']); + } + ($this->save)(['version' => 1, 'coverage_start' => $this->coverageStart, 'updated_at' => $now->format(DATE_ATOM), 'messages' => array_values($this->messages)]); + } + + private function allowed(mixed $platform, mixed $room, mixed $author): bool + { + return is_string($platform) && is_string($room) && is_string($author) && $author !== '' + && in_array($author, $this->authors[$platform] ?? [], true) && in_array($room, $this->rooms[$platform] ?? [], true); + } + + private function key(string $platform, string $room, string $id): string + { + return json_encode([$platform, $room, $id], JSON_THROW_ON_ERROR); + } + + /** Validates environment policy; all IDs must be strings to avoid numeric coercion. */ + public static function policy(string $json): array + { + $data = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + if (! is_array($data) || array_is_list($data)) { + throw new \InvalidArgumentException('Newsletter policy must be an object keyed by platform.'); + } + foreach ($data as $platform => $ids) { + if (! is_string($platform) || ! preg_match('/^[a-z][a-z0-9_-]*$/D', $platform) + || ! is_array($ids) || ! array_is_list($ids)) { + throw new \InvalidArgumentException('Newsletter policy contains an invalid platform or ID list.'); + } + foreach ($ids as $id) { + if (! is_string($id) || trim($id) === '') { + throw new \InvalidArgumentException('Newsletter policy IDs must be nonempty strings.'); + } + } + } + + return $data; + } +} diff --git a/phpunit.xml b/phpunit.xml index fc794b2..56f3fcc 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,6 +19,7 @@ bot.php + integration tests diff --git a/tests/NewsletterFeedTest.php b/tests/NewsletterFeedTest.php new file mode 100644 index 0000000..9d4d15e --- /dev/null +++ b/tests/NewsletterFeedTest.php @@ -0,0 +1,84 @@ +feed($snapshot); + $feed->record('telegram', new Incoming('public', 'Spoofed Owner', 'other', 'private conversation', '1')); + $feed->record('telegram', new Incoming('private', 'Owner', 'owner', 'secret', '2')); + $feed->record('telegram', new Incoming('public', 'Owner', null, 'anonymous', '3')); + $feed->record('telegram', new Incoming('public', 'Owner', 'owner', '!api token', '4')); + $feed->record('telegram', new Incoming('public', 'Owner', 'owner', 'bot echo', '5', own: true)); + $feed->record('telegram', new Incoming('public', 'Owner', 'owner', 'Working on the cache.', '6', quoted: 'someone else: secret')); + + self::assertCount(1, $snapshot['messages']); + self::assertSame('Working on the cache.', $snapshot['messages'][0]['text']); + self::assertStringNotContainsString('secret', json_encode($snapshot)); + self::assertArrayNotHasKey('profileUrl', $snapshot['messages'][0]); + } + + public function testEditsDeletesPurgesAndDuplicateDelivery(): void + { + $snapshot = []; + $now = new \DateTimeImmutable('2026-10-11T10:00:00Z'); + $feed = new NewsletterFeed(['telegram' => ['owner']], ['telegram' => ['public']], static function (array $data) use (&$snapshot): void { + $snapshot = $data; + }, clock: static function () use (&$now): \DateTimeImmutable { + return $now; + }); + $feed->record('telegram', new Incoming('public', 'Owner', 'owner', 'original', '1')); + $original = $snapshot['messages'][0]; + $now = $now->modify('+1 hour'); + $feed->record('telegram', new Incoming('public', 'Owner', 'owner', 'original', '1')); + self::assertSame($original, $snapshot['messages'][0]); + $feed->record('telegram', new Incoming('public', 'Owner', 'owner', 'expanded work', '1', edited: true)); + self::assertTrue($snapshot['messages'][0]['revised']); + self::assertSame($original['created_at'], $snapshot['messages'][0]['created_at']); + self::assertSame($now->format(DATE_ATOM), $snapshot['messages'][0]['updated_at']); + $feed->record('telegram', Incoming::deletion('public', '1')); + self::assertSame([], $snapshot['messages']); + $feed->record('telegram', new Incoming('public', 'Owner', 'owner', 'another', '2')); + $feed->record('telegram', Incoming::purge('public', 'owner')); + self::assertSame([], $snapshot['messages']); + } + + public function testRestartReappliesPolicyAndCapacityMarksPartialCoverage(): void + { + $snapshot = []; + $feed = $this->feed($snapshot, 1); + $feed->record('telegram', new Incoming('public', 'Owner', 'owner', 'first', '1')); + $feed->record('telegram', new Incoming('public', 'Owner', 'owner', 'second', '2')); + self::assertCount(1, $snapshot['messages']); + self::assertSame('second', $snapshot['messages'][0]['text']); + $next = new NewsletterFeed(['telegram' => ['different-owner']], ['telegram' => ['public']], static function (array $data) use (&$snapshot): void { + $snapshot = $data; + }, $snapshot['messages']); + $next->persist(); + self::assertSame([], $snapshot['messages']); + } + + public function testNumericPolicyValuesCannotBecomeAccountIdentities(): void + { + $this->expectException(\InvalidArgumentException::class); + NewsletterFeed::policy('{"telegram":[123]}'); + } + + private function feed(array &$snapshot, int $capacity = 500): NewsletterFeed + { + return new NewsletterFeed(['telegram' => ['owner']], ['telegram' => ['public']], static function (array $data) use (&$snapshot): void { + $snapshot = $data; + }, clock: static fn (): \DateTimeImmutable => new \DateTimeImmutable('2026-10-11T10:00:00Z'), capacity: $capacity); + } +}