Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
2 changes: 1 addition & 1 deletion .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions bot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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");

Expand Down
157 changes: 157 additions & 0 deletions integration/NewsletterFeed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php

declare(strict_types=1);

namespace BridgeBot\Integration;

use Bridge\Bot;
use Bridge\Message\Incoming;
use Bridge\Modules\Module;

/** An opt-in, bounded export of the owner's messages in approved public rooms. */
final class NewsletterFeed implements Module
{
private array $messages = [];
private readonly \Closure $save;
private readonly \Closure $clock;
private string $coverageStart;

/**
* @param array<string, list<string>> $authors Stable platform account IDs, never display names.
* @param array<string, list<string>> $rooms Operator-approved public room IDs.
* @param callable(array): mixed $save Injected persistence, no chat or network effects.
* @param list<array> $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;
}
}
1 change: 1 addition & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<source ignoreIndirectDeprecations="true">
<include>
<file>bot.php</file>
<directory>integration</directory>
<directory>tests</directory>
</include>
</source>
Expand Down
84 changes: 84 additions & 0 deletions tests/NewsletterFeedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace BridgeBot\Tests;

use Bridge\Message\Incoming;
use BridgeBot\Integration\NewsletterFeed;
use PHPUnit\Framework\TestCase;

require_once __DIR__.'/../integration/NewsletterFeed.php';

final class NewsletterFeedTest extends TestCase
{
public function testOnlyApprovedAccountsAndRoomsReachTheFeed(): void
{
$snapshot = [];
$feed = $this->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);
}
}
Loading