diff --git a/.env.example b/.env.example index 33f4765e3..748ffc763 100644 --- a/.env.example +++ b/.env.example @@ -65,11 +65,18 @@ x500_AWS_ENDPOINT=http://192.168.56.4:9600 WEBSOCKET_BROADCAST_HOST=192.168.10.10 +# OLMbot X/Twitter posting — default off; set true only with paid X API credits +TWITTER_ENABLED=false TWITTER_API_CONSUMER_KEY= TWITTER_API_CONSUMER_SECRET= TWITTER_API_ACCESS_TOKEN= TWITTER_API_ACCESS_SECRET= +# OLMbot Bluesky posting — set BLUESKY_ENABLED=true in prod with an app password +BLUESKY_ENABLED=false +BLUESKY_IDENTIFIER=olmbot.bsky.social +BLUESKY_APP_PASSWORD= + # BROWSERSHOT_CHROME_PATH=/snap/bin/chromium LOCATION_API_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index fbdd1150d..44e0b3242 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,6 +218,17 @@ Fully deployed. 1010+ tests passing. Facilitator queue (3-panel admin-like UI fo ## Daily Changelog After every change in a session, append a one-line entry to `readme/changelog/YYYY-MM-DD.md` (create the file if it doesn't exist for today's date). Group entries by session. This is the running record of all work done each day. +### `## Public` block (what OLMbot posts) +A changelog file MAY include a single `## Public` block — curated, plain-language release notes that the `twitter:changelog` bot posts to the social feeds (Bluesky; X gated off). Rules: +- **Audience:** OLM users, educators/schools, the citizen-science community, funders. NOT contributors — they read the PR. No file paths, class/function names, route/throttle internals. If a teacher couldn't follow it, rewrite it. +- **0–3 plain-language points written as tight prose** (not a bullet list). The whole post must fit ONE Bluesky post (300 chars) — write to that ceiling. If it genuinely needs more it threads, but one post under 300 is the default unit. +- **Lead with what matters most to an observer:** privacy/safeguarding and access changes first, usability/speed after. +- **One `## Public` per release, on the day the release lands.** A multi-day feature gets a single public post on its completion day — do NOT fragment it across each day the work spanned (that re-buries the headline change). When the release day arrives, consolidate the user-facing story into one block and leave the earlier days' blocks absent. +- **Silence is correct and expected.** Most days are internal-only — leave the block absent and the bot posts nothing. Only add it when something is genuinely user-facing. The detailed session entries above stay as the internal record regardless. +- The block runs from the `## Public` heading to the next heading; place it directly under the `# YYYY-MM-DD` title. See `readme/changelog/2026-06-27.md`, `2026-06-28.md`, `2026-05-04.md` for worked examples. + +The mobile app (react-native) repo follows the same `## Public` convention in its own changelog; the bot fetches that file and adds a second post on days both have content (mobile after web). Mobile blocks self-label (e.g. "OpenLitterMap app update 📱…"). + ## Versioning - The single source of truth for the app version is `package.json` `"version"` field - Vite exposes it as `__APP_VERSION__` (defined in `vite.config.js`) — Footer.vue reads it from there @@ -232,7 +243,7 @@ After every change in a session, append a one-line entry to `readme/changelog/YY When the user says "BOOP", perform all of the following: 1. Determine if the change is a new feature (minor bump) or a fix/improvement (patch bump). Ask if unsure 2. Bump the appropriate version in `package.json` -3. Append a one-line entry to `readme/changelog/YYYY-MM-DD.md` (today's date) +3. Append a one-line entry to `readme/changelog/YYYY-MM-DD.md` (today's date). If the work is user-facing, also add/extend the day's `## Public` block (see Daily Changelog) — otherwise leave it absent 4. Update any readme docs (`readme/*.md`) affected by the changes 5. Update any skills files affected by the changes 4. Update any skills files affected by the changes diff --git a/app/Console/Commands/Twitter/AnnualImpactReportTweet.php b/app/Console/Commands/Twitter/AnnualImpactReportTweet.php index 9425b7105..9d78ab77b 100644 --- a/app/Console/Commands/Twitter/AnnualImpactReportTweet.php +++ b/app/Console/Commands/Twitter/AnnualImpactReportTweet.php @@ -4,7 +4,7 @@ namespace App\Console\Commands\Twitter; -use App\Helpers\Twitter; +use App\Helpers\Social; use Illuminate\Console\Command; use Spatie\Browsershot\Browsershot; @@ -47,7 +47,7 @@ public function handle(): int $msg = "Annual Impact Report for {$lastYear}." . " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; - Twitter::sendTweetWithImage($msg, $path); + Social::withImage($msg, $path); $this->info('Tweet sent'); diff --git a/app/Console/Commands/Twitter/ChangelogTweet.php b/app/Console/Commands/Twitter/ChangelogTweet.php index f1cccab46..0a272ff47 100644 --- a/app/Console/Commands/Twitter/ChangelogTweet.php +++ b/app/Console/Commands/Twitter/ChangelogTweet.php @@ -4,19 +4,20 @@ namespace App\Console\Commands\Twitter; -use App\Helpers\Twitter; +use App\Helpers\Social; use Illuminate\Console\Command; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Throwable; class ChangelogTweet extends Command { protected $signature = 'twitter:changelog {date? : Date in YYYY-MM-DD format, defaults to yesterday}'; - protected $description = 'Tweet a threaded changelog from the daily changelog file'; + protected $description = 'Post the curated ## Public changelog block(s) to OLMbot social feeds'; - private const MAX_TWEET_LENGTH = 280; + private const MAX_POST_LENGTH = 300; private const MOBILE_CHANGELOG_URL = 'https://raw.githubusercontent.com/OpenLitterMap/react-native/openlittermap/v7/readme/changelog/'; @@ -24,215 +25,160 @@ public function handle(): int { if (! app()->environment('production') && ! app()->runningUnitTests()) { $this->info('Skipping — not production environment.'); + return self::SUCCESS; } $date = $this->argument('date') ?? now()->subDay()->toDateString(); $path = base_path("readme/changelog/{$date}.md"); - if (! File::exists($path)) { - $this->info("No changelog found for {$date} — skipping."); - return self::SUCCESS; - } + // Web and mobile are separate repos on separate cadences — read each + // independently so a mobile-only release still posts when there's no local + // web changelog file for the date. + $webPublic = File::exists($path) ? $this->parsePublicBlock(File::lines($path)) : ''; + $mobilePublic = $this->mobilePublicBlock($date); + + $posts = array_merge($this->buildPosts($webPublic), $this->buildPosts($mobilePublic)); - $parsed = $this->parseEntries($path, $date); + if (empty($posts)) { + $this->info("No public changelog for {$date} — nothing to post."); - if (empty($parsed['web']) && empty($parsed['mobile'])) { - $this->info("No changelog found for {$date} — skipping."); return self::SUCCESS; } - $tweets = $this->buildThread($date, $parsed['web'], $parsed['mobile']); - - foreach ($tweets as $i => $tweet) { - $this->line("[" . ($i + 1) . "/" . count($tweets) . "] " . $tweet); + foreach ($posts as $i => $post) { + $this->line('[' . ($i + 1) . '/' . count($posts) . '] ' . $post); } - $result = Twitter::sendThread($tweets); + $result = Social::thread($posts); if ($result['sent'] === 0) { - $this->info('Thread not sent (non-production or dry run).'); + $this->info('Changelog not sent (non-production or dry run).'); + return self::SUCCESS; } if ($result['sent'] < $result['total']) { - $this->error("Partial thread failure: {$result['sent']}/{$result['total']} tweets posted. First ID: {$result['first_id']}"); + $this->error("Partial post failure: {$result['sent']}/{$result['total']} posts published. First ID: {$result['first_id']}"); + return self::FAILURE; } - $this->info("Thread posted ({$result['sent']} tweets). First tweet ID: {$result['first_id']}"); + $this->info("Changelog posted ({$result['sent']} posts). First post ID: {$result['first_id']}"); + return self::SUCCESS; } /** - * Parse changelog entries into web and mobile arrays. - * - * - Lines starting with `- [Web] ` → web (prefix stripped) - * - Lines starting with `- [Mobile] ` → mobile (prefix stripped) - * - Lines starting with `- ` with no prefix → default to web + * Parse the curated `## Public` block from an iterable of changelog lines and + * return it as a single plain-language post body. Returns '' when the block is + * absent or empty — the silence case, where that source posts nothing. * - * Also fetches mobile changelog from the react-native repo on GitHub. - * All entries from the mobile repo are treated as mobile entries. + * Works on both the local web file (`File::lines($path)`) and the fetched + * mobile body (`explode("\n", $body)`). The block runs from the `## Public` + * heading to the next markdown heading (or EOF). Blank lines are dropped and a + * leading bullet marker is tolerated and stripped; the remaining lines are + * joined into one prose string (the house standard is tight prose under 300 + * chars, not a bullet list). * - * Version prefixes and backticks are cleaned from all entries. - * - * @return array{web: string[], mobile: string[]} + * @param iterable $lines */ - public function parseEntries(string $path, ?string $date = null): array + public function parsePublicBlock(iterable $lines): string { - $web = []; - $mobile = []; + $inBlock = false; + $collected = []; - foreach (File::lines($path) as $line) { - $line = trim($line); + foreach ($lines as $line) { + $trimmed = trim($line); + + if (! $inBlock) { + if ($trimmed === '## Public') { + $inBlock = true; + } - if (! str_starts_with($line, '- ')) { continue; } - $content = substr($line, 2); // strip "- " + if (preg_match('/^#{1,6}\s/', $trimmed)) { + break; + } - if (str_starts_with($content, '[Mobile] ')) { - $mobile[] = $this->cleanChange(substr($content, 9)); - } elseif (str_starts_with($content, '[Web] ')) { - $web[] = $this->cleanChange(substr($content, 6)); - } else { - // No prefix → default to web - $web[] = $this->cleanChange($content); + if ($trimmed === '') { + continue; } - } - // Fetch mobile changelog from react-native repo - if ($date) { - $mobileEntries = $this->fetchMobileChangelog($date); - $mobile = array_merge($mobile, $mobileEntries); + $collected[] = preg_replace('/^[-*]\s+/', '', $trimmed); } - return ['web' => $web, 'mobile' => $mobile]; + return trim(preg_replace('/\s+/', ' ', implode(' ', $collected))); } /** - * Fetch mobile changelog entries from the react-native GitHub repo. - * - * @return string[] + * Fetch the mobile (react-native) changelog for the date and return its + * `## Public` block. Mobile posts require the mobile repo to adopt the same + * `## Public` convention; until it does — or if the fetch fails (non-200, + * network error, timeout, exception) — this returns '' and the bot posts + * web-only. A mobile failure never breaks the command. */ - public function fetchMobileChangelog(string $date): array + public function mobilePublicBlock(string $date): string { - try { - $url = self::MOBILE_CHANGELOG_URL . "{$date}.md"; - $response = Http::timeout(10)->get($url); - - if (! $response->successful()) { - return []; - } + $body = $this->fetchMobileChangelog($date); - $entries = []; - - foreach (explode("\n", $response->body()) as $line) { - $line = trim($line); - - if (! str_starts_with($line, '- ')) { - continue; - } + return $body === '' ? '' : $this->parsePublicBlock(explode("\n", $body)); + } - $entries[] = $this->cleanChange(substr($line, 2)); - } + /** + * Fetch the raw mobile changelog body from the react-native GitHub repo, or '' + * on any failure (logged, never thrown). + */ + public function fetchMobileChangelog(string $date): string + { + try { + $response = Http::timeout(10)->get(self::MOBILE_CHANGELOG_URL . "{$date}.md"); - return $entries; - } catch (\Exception $e) { + return $response->successful() ? $response->body() : ''; + } catch (Throwable $e) { Log::warning("Failed to fetch mobile changelog for {$date}: {$e->getMessage()}"); - return []; + return ''; } } /** - * Build the tweet thread: overview tweet + grouped change tweets. + * Build the post(s) for one public block: one post when it fits a single + * Bluesky post (300 chars), otherwise a thread split on word boundaries with + * every post within the limit. An empty block contributes no posts. * - * @param string[] $web - * @param string[] $mobile * @return string[] */ - public function buildThread(string $date, array $web, array $mobile): array + public function buildPosts(string $text): array { - $tweets = []; - - // ─── Tweet 1: Overview ─────────────────────────────────────── - - $overview = "🔧 OpenLitterMap — Changes for {$date}\n\n"; - - $counts = []; - if (! empty($web)) { - $counts[] = count($web) . " web improvement" . (count($web) !== 1 ? 's' : ''); - } - if (! empty($mobile)) { - $counts[] = count($mobile) . " mobile improvement" . (count($mobile) !== 1 ? 's' : ''); - } - - $overview .= implode(' · ', $counts); - $overview .= "\n\n🧵 Thread ↓"; - - $tweets[] = $overview; - - // ─── Tweet 2+: Grouped changes ─────────────────────────────── - - $footer = "\n\n#openlittermap #changelog"; - $changeLines = []; - - if (! empty($web)) { - $changeLines[] = "🌐 Web"; - foreach ($web as $entry) { - $changeLines[] = "- {$entry}"; - } + if ($text === '') { + return []; } - if (! empty($web) && ! empty($mobile)) { - $changeLines[] = ''; + if (mb_strlen($text) <= self::MAX_POST_LENGTH) { + return [$text]; } - if (! empty($mobile)) { - $changeLines[] = "📱 Mobile"; - foreach ($mobile as $entry) { - $changeLines[] = "- {$entry}"; - } - } - - // Pack change lines into tweets respecting 280 char limit + $posts = []; $current = ''; - foreach ($changeLines as $changeLine) { - // Truncate individual lines that are too long for a single tweet - $maxLineLen = self::MAX_TWEET_LENGTH - mb_strlen($footer) - 2; - if (mb_strlen($changeLine) > $maxLineLen) { - $changeLine = mb_substr($changeLine, 0, $maxLineLen - 1) . '…'; - } - - $candidate = $current === '' ? $changeLine : $current . "\n" . $changeLine; + foreach (explode(' ', $text) as $word) { + $candidate = $current === '' ? $word : $current . ' ' . $word; - // Reserve space for footer on the last tweet (worst case) - if (mb_strlen($candidate . $footer) > self::MAX_TWEET_LENGTH && $current !== '') { - $tweets[] = trim($current); - $current = $changeLine; + if (mb_strlen($candidate) > self::MAX_POST_LENGTH && $current !== '') { + $posts[] = $current; + $current = $word; } else { $current = $candidate; } } if ($current !== '') { - $tweets[] = trim($current) . $footer; + $posts[] = $current; } - return $tweets; - } - - /** - * Strip version prefix like "`v5.0.3` — " and backticks from a change line. - */ - public function cleanChange(string $change): string - { - $clean = preg_replace('/^\*\*`?v?\d+\.\d+\.\d+`?\*\*\s*[—–-]\s*/u', '', $change); - $clean = preg_replace('/^`?v?\d+\.\d+\.\d+`?\s*[—–-]\s*/u', '', $clean); - - return str_replace('`', '', $clean); + return $posts; } } diff --git a/app/Console/Commands/Twitter/DailyReportTweet.php b/app/Console/Commands/Twitter/DailyReportTweet.php index af68f3360..3fe7dfcf4 100644 --- a/app/Console/Commands/Twitter/DailyReportTweet.php +++ b/app/Console/Commands/Twitter/DailyReportTweet.php @@ -6,7 +6,7 @@ use Carbon\Carbon; use App\Enums\LocationType; -use App\Helpers\Twitter; +use App\Helpers\Social; use App\Models\Users\User; use App\Models\Littercoin; use Illuminate\Console\Command; @@ -146,7 +146,7 @@ public function handle(): int // ─── Send ──────────────────────────────────────────────────── - $result = Twitter::sendThread([$tweet1, $tweet2]); + $result = Social::thread([$tweet1, $tweet2]); $this->line("Tweet 1:\n{$tweet1}"); $this->line("Tweet 2:\n{$tweet2}"); diff --git a/app/Console/Commands/Twitter/MonthlyImpactReportTweet.php b/app/Console/Commands/Twitter/MonthlyImpactReportTweet.php index ca0f3b405..3a2fc90cb 100644 --- a/app/Console/Commands/Twitter/MonthlyImpactReportTweet.php +++ b/app/Console/Commands/Twitter/MonthlyImpactReportTweet.php @@ -4,7 +4,7 @@ namespace App\Console\Commands\Twitter; -use App\Helpers\Twitter; +use App\Helpers\Social; use Illuminate\Console\Command; use Spatie\Browsershot\Browsershot; @@ -49,7 +49,7 @@ public function handle(): int $time = $lastMonth->format('F Y'); $msg = "Monthly Impact Report for {$time}. Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; - Twitter::sendTweetWithImage($msg, $path); + Social::withImage($msg, $path); $this->info('Tweet sent'); diff --git a/app/Console/Commands/Twitter/WeeklyImpactReportTweet.php b/app/Console/Commands/Twitter/WeeklyImpactReportTweet.php index 4cb914a57..799c630fb 100644 --- a/app/Console/Commands/Twitter/WeeklyImpactReportTweet.php +++ b/app/Console/Commands/Twitter/WeeklyImpactReportTweet.php @@ -4,7 +4,7 @@ namespace App\Console\Commands\Twitter; -use App\Helpers\Twitter; +use App\Helpers\Social; use Illuminate\Console\Command; use Illuminate\Support\Carbon; use Spatie\Browsershot\Browsershot; @@ -49,7 +49,7 @@ public function handle(): int $msg = "Weekly Impact Report for week {$isoWeek} of {$isoYear}." . " Join us at openlittermap.com #litter #citizenscience #impact #openlittermap"; - Twitter::sendTweetWithImage($msg, $path); + Social::withImage($msg, $path); $this->info('Tweet sent'); diff --git a/app/Helpers/Bluesky.php b/app/Helpers/Bluesky.php new file mode 100644 index 000000000..fc608b3a4 --- /dev/null +++ b/app/Helpers/Bluesky.php @@ -0,0 +1,302 @@ +environment('production')) { + return false; + } + + if (blank(config('services.bluesky.identifier')) || blank(config('services.bluesky.app_password'))) { + Log::warning('Bluesky enabled but BLUESKY_IDENTIFIER / BLUESKY_APP_PASSWORD is missing — posting disabled.'); + + return false; + } + + return true; + } + + public static function post(string $text): void + { + if (! self::isEnabled()) { + return; + } + + if ($session = self::createSession()) { + self::createRecord($session, self::record($text)); + } + } + + /** + * Post a reply chain. Each record after the first carries reply.root + + * reply.parent strongRefs taken from the previous createRecord response. + * + * @param string[] $messages + * @return array{first_id: string|null, sent: int, total: int} + */ + public static function thread(array $messages): array + { + $result = ['first_id' => null, 'sent' => 0, 'total' => count($messages)]; + + if (empty($messages) || ! self::isEnabled()) { + return $result; + } + + $session = self::createSession(); + + if (! $session) { + return $result; + } + + $root = null; + $parent = null; + + foreach ($messages as $text) { + $record = self::record($text); + + if ($root && $parent) { + $record['reply'] = ['root' => $root, 'parent' => $parent]; + } + + $ref = self::createRecord($session, $record); + + if (! $ref) { + break; + } + + $root ??= $ref; + $parent = $ref; + $result['first_id'] ??= $ref['uri']; + $result['sent']++; + } + + return $result; + } + + public static function postWithImage(string $text, string $imagePath): void + { + if (! self::isEnabled()) { + return; + } + + $session = self::createSession(); + + if (! $session) { + return; + } + + $record = self::record($text); + + if ($blob = self::uploadImage($session, $imagePath)) { + $record['embed'] = [ + '$type' => 'app.bsky.embed.images', + 'images' => [['alt' => '', 'image' => $blob]], + ]; + } + + self::createRecord($session, $record); + } + + /** + * @return array{jwt: string, did: string}|null + */ + private static function createSession(): ?array + { + try { + $response = Http::post(self::url('com.atproto.server.createSession'), [ + 'identifier' => config('services.bluesky.identifier'), + 'password' => config('services.bluesky.app_password'), + ]); + + if ($response->failed()) { + Log::error('Bluesky.createSession failed', ['status' => $response->status(), 'body' => $response->body()]); + + return null; + } + + return ['jwt' => $response->json('accessJwt'), 'did' => $response->json('did')]; + } catch (Throwable $e) { + Log::error('Bluesky.createSession', [$e->getMessage()]); + + return null; + } + } + + /** + * Post one record; returns its strongRef {uri, cid} or null on failure. + * + * @param array{jwt: string, did: string} $session + * @param array $record + * @return array{uri: string, cid: string}|null + */ + private static function createRecord(array $session, array $record): ?array + { + try { + $response = Http::withToken($session['jwt'])->post(self::url('com.atproto.repo.createRecord'), [ + 'repo' => $session['did'], + 'collection' => 'app.bsky.feed.post', + 'record' => $record, + ]); + + if ($response->failed()) { + Log::error('Bluesky.createRecord failed', ['status' => $response->status(), 'body' => $response->body()]); + + return null; + } + + return ['uri' => $response->json('uri'), 'cid' => $response->json('cid')]; + } catch (Throwable $e) { + Log::error('Bluesky.createRecord', [$e->getMessage()]); + + return null; + } + } + + /** + * Build a feed-post record with createdAt and link facets. + * + * @return array + */ + private static function record(string $text): array + { + $record = [ + '$type' => 'app.bsky.feed.post', + 'text' => $text, + 'createdAt' => now()->utc()->format('Y-m-d\TH:i:s.v\Z'), + ]; + + if ($facets = self::linkFacets($text)) { + $record['facets'] = $facets; + } + + return $record; + } + + /** + * Build link facets (UTF-8 byte ranges) for bare URLs so they render + * clickable — Bluesky does not auto-link plain text. Hashtags are left + * plain for v1. preg offsets are byte offsets, which is what facets need. + * + * @return array> + */ + private static function linkFacets(string $text): array + { + if (! preg_match_all('/https?:\/\/[^\s]+/', $text, $matches, PREG_OFFSET_CAPTURE)) { + return []; + } + + $facets = []; + + foreach ($matches[0] as [$url, $byteOffset]) { + $url = rtrim($url, '.,;:)]}"\''); + + $facets[] = [ + 'index' => ['byteStart' => $byteOffset, 'byteEnd' => $byteOffset + strlen($url)], + 'features' => [['$type' => 'app.bsky.richtext.facet#link', 'uri' => $url]], + ]; + } + + return $facets; + } + + /** + * Recompress the image under the blob limit, then uploadBlob. Returns the + * blob object for an embed, or null if it can't be uploaded. + * + * @param array{jwt: string, did: string} $session + * @return array|null + */ + private static function uploadImage(array $session, string $imagePath): ?array + { + if (! $imagePath || ! file_exists($imagePath)) { + return null; + } + + try { + $binary = self::compressUnderLimit($imagePath); + + if ($binary === null) { + Log::error('Bluesky.uploadImage: could not bring image under blob limit', ['path' => $imagePath]); + + return null; + } + + $response = Http::withToken($session['jwt']) + ->withBody($binary, 'image/jpeg') + ->post(self::url('com.atproto.repo.uploadBlob')); + + if ($response->failed()) { + Log::error('Bluesky.uploadBlob failed', ['status' => $response->status(), 'body' => $response->body()]); + + return null; + } + + return $response->json('blob'); + } catch (Throwable $e) { + Log::error('Bluesky.uploadImage', [$e->getMessage()]); + + return null; + } + } + + /** + * Encode as JPEG, stepping quality (and downscaling as a last resort) until + * under the blob limit. Returns binary, or null if unachievable. + */ + private static function compressUnderLimit(string $imagePath): ?string + { + $image = (new ImageManager(['driver' => 'gd']))->make($imagePath); + + if ($image->width() > 1600) { + $image->resize(1600, null, function ($constraint) { + $constraint->aspectRatio(); + $constraint->upsize(); + }); + } + + foreach ([85, 70, 55, 40] as $quality) { + $binary = (string) $image->encode('jpg', $quality); + + if (strlen($binary) <= self::MAX_BLOB_BYTES) { + return $binary; + } + } + + $image->resize((int) ($image->width() / 2), null, function ($constraint) { + $constraint->aspectRatio(); + $constraint->upsize(); + }); + + $binary = (string) $image->encode('jpg', 40); + + return strlen($binary) <= self::MAX_BLOB_BYTES ? $binary : null; + } + + private static function url(string $method): string + { + return rtrim(config('services.bluesky.service'), '/') . '/xrpc/' . $method; + } +} diff --git a/app/Helpers/Social.php b/app/Helpers/Social.php new file mode 100644 index 000000000..abb033de7 --- /dev/null +++ b/app/Helpers/Social.php @@ -0,0 +1,53 @@ + null, 'sent' => 0, 'total' => 0]; + + foreach ([Twitter::class, Bluesky::class] as $network) { + if (! $network::isEnabled()) { + continue; + } + + $result = $network === Twitter::class + ? Twitter::sendThread($messages) + : Bluesky::thread($messages); + + $aggregate['first_id'] ??= $result['first_id']; + $aggregate['sent'] += $result['sent']; + $aggregate['total'] += $result['total']; + } + + return $aggregate; + } + + public static function withImage(string $text, string $imagePath): void + { + Twitter::sendTweetWithImage($text, $imagePath); + Bluesky::postWithImage($text, $imagePath); + } +} diff --git a/app/Helpers/Twitter.php b/app/Helpers/Twitter.php index 92c6dfc13..c4f55ef5c 100644 --- a/app/Helpers/Twitter.php +++ b/app/Helpers/Twitter.php @@ -7,6 +7,22 @@ class Twitter { + /** + * Master on/off switch for all X/Twitter posting. Every send method funnels + * through this. Posting requires TWITTER_ENABLED (default false), the + * production environment, and a configured consumer key — so an unset or + * empty key can never misfire a live call. + */ + public static function isEnabled(): bool + { + return (bool) config('services.twitter.enabled') + && app()->environment('production') + && filled(config('services.twitter.consumer_key')) + && filled(config('services.twitter.consumer_secret')) + && filled(config('services.twitter.access_token')) + && filled(config('services.twitter.access_secret')); + } + public static function sendTweet (string $message): void { $consumer_key = config('services.twitter.consumer_key'); @@ -14,7 +30,7 @@ public static function sendTweet (string $message): void $access_token = config('services.twitter.access_token'); $access_token_secret = config('services.twitter.access_secret'); - if (app()->environment() === 'production' && $consumer_key !== null) + if (self::isEnabled()) { $connection = new TwitterOAuth( $consumer_key, @@ -59,8 +75,8 @@ public static function sendThread(array $messages): array $access_token = config('services.twitter.access_token'); $access_token_secret = config('services.twitter.access_secret'); - if (! app()->environment('production') || $consumer_key === null) { - Log::info('Twitter thread skipped (not production or missing keys)', ['count' => count($messages)]); + if (! self::isEnabled()) { + Log::info('Twitter thread skipped (disabled, not production, or missing keys)', ['count' => count($messages)]); return $result; } @@ -105,7 +121,7 @@ public static function sendTweetWithImage (string $message, string $imagePath): $access_token = config('services.twitter.access_token'); $access_token_secret = config('services.twitter.access_secret'); - if (app()->environment() === 'production' && $consumer_key !== null) { + if (self::isEnabled()) { $connection = new TwitterOAuth( $consumer_key, $consumer_secret, diff --git a/app/Listeners/Images/TweetBadgeCreated.php b/app/Listeners/Images/TweetBadgeCreated.php index 72042af9d..4aaddbc03 100644 --- a/app/Listeners/Images/TweetBadgeCreated.php +++ b/app/Listeners/Images/TweetBadgeCreated.php @@ -3,7 +3,7 @@ namespace App\Listeners\Images; use App\Events\Images\BadgeCreated; -use App\Helpers\Twitter; +use App\Helpers\Social; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Facades\Storage; @@ -16,7 +16,7 @@ public function handle(BadgeCreated $event): void $path = Storage::disk('public')->path($badge->filename); - Twitter::sendTweetWithImage("An awesome new #openlittermap badge has been created & unlocked for {$badge->subtype}.", $path); + Social::withImage("An awesome new #openlittermap badge has been created & unlocked for {$badge->subtype}.", $path); } } } diff --git a/app/Listeners/Locations/Twitter/TweetNewCity.php b/app/Listeners/Locations/Twitter/TweetNewCity.php index 09613fb73..24f7f37ed 100644 --- a/app/Listeners/Locations/Twitter/TweetNewCity.php +++ b/app/Listeners/Locations/Twitter/TweetNewCity.php @@ -3,7 +3,7 @@ namespace App\Listeners\Locations\Twitter; use App\Events\NewCityAdded; -use App\Helpers\Twitter; +use App\Helpers\Social; class TweetNewCity { @@ -32,7 +32,7 @@ public function handle (NewCityAdded $event) if (app()->environment() === 'production') { - Twitter::sendTweet( + Social::text( "A new city has been added. Say hello to $event->city, $event->state, $event->country! " . $link ?: '' ); diff --git a/app/Listeners/Locations/Twitter/TweetNewCountry.php b/app/Listeners/Locations/Twitter/TweetNewCountry.php index fd154054f..97f7877ac 100644 --- a/app/Listeners/Locations/Twitter/TweetNewCountry.php +++ b/app/Listeners/Locations/Twitter/TweetNewCountry.php @@ -3,7 +3,7 @@ namespace App\Listeners\Locations\Twitter; use App\Events\NewCountryAdded; -use App\Helpers\Twitter; +use App\Helpers\Social; class TweetNewCountry { @@ -17,7 +17,7 @@ public function handle (NewCountryAdded $event) { if (app()->environment() === 'production') { - Twitter::sendTweet("A new country has been added. Say hello to $event->country!"); + Social::text("A new country has been added. Say hello to $event->country!"); } } } diff --git a/app/Listeners/Locations/Twitter/TweetNewState.php b/app/Listeners/Locations/Twitter/TweetNewState.php index 9e0f1c2a4..67d09fd73 100644 --- a/app/Listeners/Locations/Twitter/TweetNewState.php +++ b/app/Listeners/Locations/Twitter/TweetNewState.php @@ -3,7 +3,7 @@ namespace App\Listeners\Locations\Twitter; use App\Events\NewStateAdded; -use App\Helpers\Twitter; +use App\Helpers\Social; class TweetNewState { @@ -17,7 +17,7 @@ public function handle (NewStateAdded $event) { if (app()->environment() === 'production') { - Twitter::sendTweet("A new state has been added. Say hello to $event->state, $event->country!"); + Social::text("A new state has been added. Say hello to $event->state, $event->country!"); } } } diff --git a/config/services.php b/config/services.php index c40d91b22..0ab0c27c0 100644 --- a/config/services.php +++ b/config/services.php @@ -55,12 +55,20 @@ ], 'twitter' => [ + 'enabled' => env('TWITTER_ENABLED', false), 'consumer_key' => env('TWITTER_API_CONSUMER_KEY'), 'consumer_secret' => env('TWITTER_API_CONSUMER_SECRET'), 'access_token' => env('TWITTER_API_ACCESS_TOKEN'), 'access_secret' => env('TWITTER_API_ACCESS_SECRET'), ], + 'bluesky' => [ + 'enabled' => env('BLUESKY_ENABLED', false), + 'identifier' => env('BLUESKY_IDENTIFIER'), + 'app_password' => env('BLUESKY_APP_PASSWORD'), + 'service' => env('BLUESKY_SERVICE', 'https://bsky.social'), + ], + 'browsershot' => [ 'chrome_path' => env('BROWSERSHOT_CHROME_PATH', '/snap/bin/chromium'), ], diff --git a/docs/superpowers/specs/2026-06-28-bluesky-posting-design.md b/docs/superpowers/specs/2026-06-28-bluesky-posting-design.md new file mode 100644 index 000000000..beeaf6b68 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-bluesky-posting-design.md @@ -0,0 +1,54 @@ +# Bluesky posting for OLMbot — design + +**Date:** 2026-06-28 +**Status:** Approved + +## Goal + +OLMbot currently posts to X/Twitter (now disabled — 0 API credits). Add Bluesky as a posting channel without touching any stat-generation logic. Keep X code in place (gated off) so dual-posting or switching back is a flag. + +## Architecture + +A thin dispatcher over per-network helpers. **No registry/plugin abstraction** — just direct calls; Mastodon/Threads later is one more helper + two lines in the dispatcher. + +``` +commands/listeners ──► App\Helpers\Social ──► App\Helpers\Twitter (gated off) + └─► App\Helpers\Bluesky (new) +``` + +- **`App\Helpers\Bluesky`** mirrors the `Twitter` helper's three shapes: + - `post(string $text): void` — `createSession` → `createRecord` + - `thread(array $messages): array` — auth once, post a reply chain; each record after the first carries `reply.root` + `reply.parent` strongRefs (`{uri,cid}` from `createRecord`). Returns `{first_id, sent, total}` (same shape as `Twitter::sendThread`). + - `postWithImage(string $text, string $path): void` — recompress under the blob limit → `uploadBlob` → `createRecord` with an `app.bsky.embed.images` embed. + - `isEnabled(): bool` = `enabled && production && app_password !== null`. Logs a warning when `enabled && production` but the password is missing (misconfigured prod is visible, not silently dead). + - Built on Laravel `Http` (so tests use `Http::fake()`). Every network call wrapped in try/catch + `Log` — a failure never breaks the calling command. +- **`App\Helpers\Social`** — `text()`, `thread()`, `withImage()` fan out to enabled networks. `thread()` sums `sent`/`total` over **enabled** networks only (a disabled network contributes nothing, so the commands' `sent < total → FAILURE` check stays correct; with no network enabled it returns `sent=0,total=0` → the existing `sent === 0 → SUCCESS` branch fires). + +## Config (`config/services.php`) + +```php +'bluesky' => [ + 'enabled' => env('BLUESKY_ENABLED', false), + 'identifier' => env('BLUESKY_IDENTIFIER'), + 'app_password' => env('BLUESKY_APP_PASSWORD'), + 'service' => env('BLUESKY_SERVICE', 'https://bsky.social'), +], +``` + +## Call sites (9 — one word each) + +`Twitter::sendTweet` → `Social::text`; `Twitter::sendThread` → `Social::thread`; `Twitter::sendTweetWithImage` → `Social::withImage`. In: DailyReportTweet, ChangelogTweet (threads); Weekly/Monthly/AnnualImpactReportTweet, TweetBadgeCreated (images); TweetNewCity/State/Country (text). Stat logic untouched. + +## Refinements (from review) + +1. **<1MB blob limit.** `uploadBlob` rejects images over ~1MB. Before upload, recompress with intervention/image (GD): cap width, step JPEG quality (85→40), downscale as last resort, until `<= 950,000 bytes`. If still over, skip the image and post text-only. Without this, the 4 image paths silently fail. +2. **Map-link facet in scope (v1).** Bluesky does not auto-link plain-text URLs. Build `app.bsky.richtext.facet#link` facets with UTF-8 **byte** ranges for any `https?://` URL in the text (covers the new-city map link). Hashtag facets deferred — plain `#tags` post fine, just unlinked. +3. **300-char limit:** callers already truncate to 280 — no change. + +## Testing + +`Http::fake()` unit tests for `Bluesky` (session→record, thread reply-ref chaining, image uploadBlob+embed, URL facet byte ranges, disabled no-op, error swallow) and `Social` (fan-out, enabled-only summation). Existing 73 Twitter tests and the command tests stay green. + +## Out of scope + +Hashtag/mention facets, link cards (external embeds), session-token caching, multi-account, any config registry. diff --git a/docs/superpowers/specs/2026-06-28-public-changelog-posts-design.md b/docs/superpowers/specs/2026-06-28-public-changelog-posts-design.md new file mode 100644 index 000000000..b84b1b816 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-public-changelog-posts-design.md @@ -0,0 +1,125 @@ +# Public changelog posts for OLMbot — design + +**Date:** 2026-06-28 +**Status:** Approved + +## Goal + +OLMbot's `twitter:changelog` command currently dumps the raw, internal commit-level +changelog bullets to the public feed (an overview-counts post + a thread of every +`- ...` line, plus a mobile changelog fetched from GitHub). That's contributor noise, +not an audience message. Replace it: the command posts a curated `## Public` block — +plain-language release notes written for OLM users, educators, the citizen-science +community, and funders — or posts **nothing** when there's no user-facing news (most +days). The detailed internal changelog file is untouched; it just stops doubling as +the public source. Mobile is kept, but curated the same way (see Mobile, below) — the +raw mobile bullets go too. + +## Model — the `## Public` convention + +A changelog file (`readme/changelog/YYYY-MM-DD.md`) **may** contain one `## Public` +block. Rules: + +- **0–3 plain-language points, written as tight prose**, not a bullet list. The whole + post must fit **one Bluesky post (300 chars)** — that ceiling is the brittle + constraint, so write to it. If it genuinely needs more, it threads, but one post + under 300 is the default unit. +- **Audience:** OLM users, educators/schools, citizen-science community, funders. + **Not contributors** — they read the PR. +- **No internals:** no file paths, class/function names, route/throttle details. If a + teacher couldn't follow it, rewrite it. +- **Order:** lead with what matters most to an observer — privacy/safeguarding and + access changes first, usability/speed after. +- **One block per release, on the release-completion day.** A multi-day feature gets a + single `## Public` post on the day it lands, not one fragment per day it spanned + (fragmenting re-buries the headline change). Consolidate on the release day; leave the + earlier days' blocks absent. +- **Silence is correct.** If nothing is user-facing, the block is absent (or empty) + and the bot posts nothing. Most days are internal-only — that's expected. + +The block runs from the `## Public` heading to the next markdown heading (or EOF). +Authors write prose; a leading `- ` bullet marker is tolerated and stripped. + +## Mobile (curated, not raw) + +The mobile (react-native) changelog is back — but curated via `## Public`, not the raw +bullets. The bot fetches the mobile repo's changelog +(`…/react-native/openlittermap/v7/readme/changelog/{date}.md`) and parses its `## Public` +block with the **same** parser (refactored to take an iterable of lines, so it runs on +both `File::lines($localPath)` and `explode("\n", $body)`). Web and mobile each build +their own post(s) and combine: +`array_merge(buildPosts(webPublic), buildPosts(mobilePublic))` — a day where both have +content becomes a short thread (web post, then mobile post); most days only one or +neither. Mobile posts require the **mobile repo to adopt the same `## Public` +convention**; until it does — or if the fetch fails (non-200, network error, timeout, +exception) — mobile contributes nothing and the bot posts web-only. A mobile failure is +logged, never thrown. + +## Command behaviour — `App\Console\Commands\Twitter\ChangelogTweet` + +| | Old | New | +|---|---|---| +| Source | every `- ...` line in the local file **+** raw mobile bullets fetched from GitHub | the `## Public` block of the local file **+** the `## Public` block of the mobile changelog | +| Post 1 | overview counts (`N web · M mobile improvements 🧵 Thread ↓`) | dropped | +| Post 2+ | raw internal bullets grouped `🌐 Web` / `📱 Mobile`, threaded at 280 | one `## Public` post per source (≤300), else a word-boundary thread | +| Nothing user-facing | still posted the overview + whatever bullets existed | posts nothing, logs "No public changelog", exits SUCCESS | +| Mobile | raw bullets | curated `## Public` only; graceful web-only fallback on fetch failure | + +`handle()`: env guard → resolve date/path → +`webPublic = File::exists($path) ? parsePublicBlock(File::lines($path)) : ''` (no early +return — web and mobile are decoupled), `mobilePublic = mobilePublicBlock($date)` → +`posts = array_merge(buildPosts(webPublic), buildPosts(mobilePublic))` → empty ⇒ "No +public changelog … nothing to post", SUCCESS → else `Social::thread($posts)` with the +existing `sent === 0` / `sent < total` result handling. The mobile fetch always runs, so +a mobile-only release on a date with no local web file still posts. + +Public methods (directly testable): `parsePublicBlock(iterable $lines): string`, +`buildPosts(string $text): array`, `mobilePublicBlock(string $date): string`, +`fetchMobileChangelog(string $date): string` (raw body or '' on any failure). +`cleanChange`, `parseEntries`, `buildThread` and the old overview/raw-bullet path are +removed. + +## Cadence + +Event-driven, enforced by the command, not the scheduler. `twitter:changelog` still runs +`dailyAt('07:00')`, but it now self-silences on internal-only days, so it effectively +fires only per release/milestone that ships a non-empty `## Public` block. The +always-on `twitter:daily-report` stats post is untouched and out of scope. + +## Posting + +Through `App\Helpers\Social::thread` → Bluesky (X gated off). Bluesky limit is 300; a +single ≤300-char post goes as a one-element thread. No change to the Social/Bluesky +helpers. + +## Docs & examples + +- `CLAUDE.md` — document the `## Public` convention in "Daily Changelog" + a BOOP step. +- `readme/Twitter.md` — rewrite the `twitter:changelog` section to the new contract. + **Not renamed** to a network-neutral filename: the prior Bluesky work already folded + Bluesky into `Twitter.md` without a rename; following that precedent keeps the change + minimal and avoids touching the CLAUDE.md index + skill table for no real gain. +- Three first worked `## Public` blocks ship with the convention: + `2026-06-27.md` (newsletter form fixed), `2026-06-28.md` (OLMbot on Bluesky), + `2026-05-04.md` (the export release — login-required + student-privacy fix, on its + completion day). The export work spans `2026-05-02…05-04`; per the one-block-per-release + rule, only `05-04` carries the block and the earlier days stay absent. Every other file + leaves the block absent — demonstrating the silence rule. + +## Testing + +`tests/Feature/Twitter/ChangelogTweetTest.php` (23 tests) to the new contract: +`parsePublicBlock` present/absent/empty/bullet-stripping/stops-at-next-heading/from an +array of lines; `buildPosts` one-post-≤300 / threads->each-≤300 / empty->[]; `handle` +posts present web block, posts nothing + "No public changelog" when both sources absent +(incl. no web file at all), defaults to yesterday, mobile fetch hits the GitHub URL; +mobile combine (mobile post after web), mobile-only when web silent, mobile-only release +with **no web file** -> still posted, both->thread each ≤300, fetch +404/500/exception->web-only, mobile without `## Public`->nothing. Whole +`tests/Feature/Twitter/` dir stays green. + +## Out of scope + +`twitter:daily-report`; renaming `Twitter.md`; any AI/summarisation; new config; the +Social/Bluesky helpers. (The mobile app authoring its own `## Public` blocks is a +mobile-repo task — this command just consumes them when present.) diff --git a/package.json b/package.json index f1aad9a7f..d017a94ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openlittermap-web", - "version": "5.12.9", + "version": "5.12.11", "type": "module", "author": "Seán Lynch", "license": "GPL v3", diff --git a/readme/Twitter.md b/readme/Twitter.md index 1d0c06664..7e1d03a77 100644 --- a/readme/Twitter.md +++ b/readme/Twitter.md @@ -1,12 +1,13 @@ # OLMbot — Automated Twitter Commands -OLMbot is the automated Twitter/X posting system. All commands live in `app/Console/Commands/Twitter/` and use the `App\Helpers\Twitter` helper for API calls. +OLMbot is the automated social posting system. All commands live in `app/Console/Commands/Twitter/` and post via the `App\Helpers\Social` dispatcher, which fans out to every enabled network — `App\Helpers\Twitter` (X) and `App\Helpers\Bluesky` — each self-gated by its own `isEnabled()`. Stat-generation logic is independent of the posting layer. ## Configuration Twitter API credentials are in `config/services.php` under the `twitter` key, backed by env vars: ``` +TWITTER_ENABLED=false # master on/off — default off; requires paid X API credits TWITTER_API_CONSUMER_KEY= TWITTER_API_CONSUMER_SECRET= TWITTER_API_ACCESS_TOKEN= @@ -19,7 +20,29 @@ Browsershot Chromium path is configurable via `config/services.php`: BROWSERSHOT_CHROME_PATH= # defaults to /snap/bin/chromium ``` -The `Twitter` helper has a production guard — all three methods (`sendTweet`, `sendThread`, `sendTweetWithImage`) silently no-op outside `production`. Each command also has its own production guard at the top of `handle()`. +The `Twitter` helper has a master switch — `Twitter::isEnabled()` gates all three methods (`sendTweet`, `sendThread`, `sendTweetWithImage`) and requires `TWITTER_ENABLED=true` **and** the `production` environment **and** a configured consumer key. It **defaults off** (`TWITTER_ENABLED` defaults to `false`), so OLMbot posts nothing until explicitly enabled — a zero-code kill switch for when X API credits are unavailable. An empty/missing key can't misfire a live call. Each command/listener also has its own production guard. + +## Social dispatcher & Bluesky + +Commands/listeners call `App\Helpers\Social` (`text`, `thread`, `withImage`), which posts to every enabled network. `Social::thread` sums `sent`/`total` over **enabled networks only**, so the commands' `sent < total → FAILURE` check stays correct and a no-network environment returns `sent=0` (→ SUCCESS). Adding a network is a new helper + two lines in `Social` — deliberately no registry/plugin layer. + +### Bluesky (`App\Helpers\Bluesky`) + +AT Protocol XRPC via Laravel `Http`. Config under the `bluesky` key: + +``` +BLUESKY_ENABLED=false # master on/off — default off +BLUESKY_IDENTIFIER=olmbot.bsky.social +BLUESKY_APP_PASSWORD= # app password, never the account password +BLUESKY_SERVICE=https://bsky.social # optional override +``` + +`Bluesky::isEnabled()` = `enabled && production && app_password` (logs a warning if enabled in prod with no password). Methods mirror the Twitter helper — `post()`, `thread()`, `postWithImage()`: + +- **Auth:** `createSession` (identifier + app password) → `accessJwt` + `did`, once per send operation. +- **Threads:** each post after the first carries `reply.root` + `reply.parent` strongRefs from the previous `createRecord`. +- **Images:** recompressed under Bluesky's ~1MB blob limit (intervention/image, JPEG, quality-stepped + downscale) before `uploadBlob`; falls back to text-only if it can't get under. Embedded as `app.bsky.embed.images`. +- **Links:** bare `https?://` URLs get `app.bsky.richtext.facet#link` facets (UTF-8 byte ranges) so they're clickable — Bluesky does not auto-link plain text. Hashtag facets are deferred (post as plain `#tags`). ## Schedule (Kernel.php) @@ -31,6 +54,17 @@ The `Twitter` helper has a production guard — all three methods (`sendTweet`, | `twitter:monthly-impact-report-tweet` | `monthlyOn(1, '06:30')` | None | | `twitter:annual-impact-report-tweet` | `yearlyOn(1, 1, '06:30')` (Jan 1) | None | +## Event-driven posts (not scheduled) + +These fire from domain events on user activity, not the cron schedule — volume scales with uploads/badges, so they are the main X API *write* driver. Wired in `app/Providers/EventServiceProvider.php`; all gated by `Twitter::isEnabled()`. + +| Listener | Event | Fires | Posts | +|---|---|---|---| +| `TweetNewCity` | `NewCityAdded` | per new city uploaded | `sendTweet()` (text) | +| `TweetNewState` | `NewStateAdded` | per new state uploaded | `sendTweet()` (text) | +| `TweetNewCountry` | `NewCountryAdded` | per new country uploaded | `sendTweet()` (text) | +| `TweetBadgeCreated` | `BadgeCreated` (queued) | per badge unlocked | `sendTweetWithImage()` | + ## Commands ### twitter:daily-report @@ -107,51 +141,38 @@ The `Twitter` helper has a production guard — all three methods (`sendTweet`, **Class:** `App\Console\Commands\Twitter\ChangelogTweet` **Signature:** `twitter:changelog {date?}` — defaults to yesterday -**Send method:** `Twitter::sendThread()` (overview tweet + grouped change tweets) +**Send method:** `Social::thread()` (one post when ≤300 chars, otherwise a thread) **Image:** No -**Data sources:** -- **Web:** `readme/changelog/{date}.md` (local file). Entries default to web unless prefixed with `[Mobile]` -- **Mobile:** Fetched from `https://raw.githubusercontent.com/OpenLitterMap/react-native/openlittermap/v7/readme/changelog/{date}.md` via HTTP. All entries from this file are treated as mobile. Falls back to web-only if fetch fails (404, network error, timeout) - -**Entry prefixes (local file only):** -- `- [Web] description` → web entry (prefix stripped) -- `- [Mobile] description` → mobile entry (prefix stripped) -- `- description` (no prefix) → defaults to web -- Version prefixes (`v5.0.3 —`) and backticks are cleaned from all entries -- Mobile entries from both local `[Mobile]` prefixes and the GitHub fetch are merged +The command posts the curated **`## Public`** block from `readme/changelog/{date}.md` — plain-language release notes written for OLM users, educators, the citizen-science community, and funders. It does **not** post the raw internal session bullets (those stay as the team's internal record). The `## Public` convention is documented in `CLAUDE.md` → "Daily Changelog". -**Thread structure:** -- **Tweet 1 (overview):** Date, entry counts by platform, thread indicator -- **Tweet 2+ (grouped changes):** Web section first (`🌐 Web`), then mobile (`📱 Mobile`), split across tweets if > 280 chars -- Hashtags on final tweet only +**Two sources, decoupled and combined:** +- **Web:** the `## Public` block in the local `readme/changelog/{date}.md` (or empty if no file exists for the date — `File::exists($path) ? parsePublicBlock(...) : ''`). +- **Mobile:** the `## Public` block in the react-native repo's changelog, fetched from `https://raw.githubusercontent.com/OpenLitterMap/react-native/openlittermap/v7/readme/changelog/{date}.md`. Mobile posts require the **mobile repo to adopt the same `## Public` convention**; until it does (or if the fetch fails), mobile is silently skipped. Mobile blocks self-label in the same house style (e.g. "OpenLitterMap app update 📱…"). +- The two repos run on **separate cadences**, so the mobile fetch always runs — even when there's no local web file. A mobile-only app release on a date with no web changelog still posts its mobile `## Public` block. +- Each source builds its own post(s): `array_merge(buildPosts(webPublic), buildPosts(mobilePublic))`. A day where both have content becomes a short thread — web post, then mobile post; most days only one or neither. -**Example Tweet 1 (Overview):** -``` -🔧 OpenLitterMap — Changes for 2026-03-22 +**Behaviour:** +- Neither source has a `## Public` block (no web file / no block, and no mobile block) → logs "No public changelog", **posts nothing**, exits SUCCESS. The common, correct outcome — most days are internal-only. +- One or both blocks present → each is posted as **one Bluesky post** when ≤300 chars, else threaded on word boundaries (every post ≤300). +- **Mobile fetch is best-effort:** any failure (non-200, network error, timeout, exception) is logged and the bot continues web-only — a mobile failure never breaks the command. -3 web improvements · 2 mobile improvements +**Cadence — event-driven, not daily.** The command is still scheduled `dailyAt('07:00')`, but it self-silences on internal-only days, so it effectively fires only per release/milestone that ships a non-empty `## Public` block. -🧵 Thread ↓ -``` +**`## Public` block format** (see `CLAUDE.md` → "Daily Changelog" for authoring rules): +- Runs from the `## Public` heading to the next markdown heading (or EOF). Parsed identically for web (`File::lines`) and mobile (`explode` of the fetched body). +- 0–3 plain-language points written as tight prose; a leading `- ` bullet marker is tolerated and stripped, then lines are joined into one prose string. +- No internals (file paths, class names, routes). Lead with privacy/safeguarding/access, then usability/speed. +- **One `## Public` per release, on the release-completion day** — a multi-day feature consolidates to a single block, not one per day it spanned. -**Example Tweet 2 (Changes):** +**Example post (single Bluesky post, ≤300 chars):** ``` -🌐 Web -- Fix admin permissions for superadmin role -- Scheduler restored for automated tweets -- Faster cluster rendering at high zoom - -📱 Mobile -- Camera orientation saved correctly -- Upload retry on weak connections - -#openlittermap #changelog +OpenLitterMap update 🔒 Data exports now require a free account, and we fixed a privacy issue that could have exposed school students' names. Exports are also faster, with simpler format options. #openlittermap ``` -**No data:** If no changelog file or empty file, logs "No changelog found" and skips. +**No data:** No `## Public` block on either source (including no web file at all) → "No public changelog", skips silently. -**External dependencies:** GitHub raw content (for mobile changelog fetch, graceful fallback on failure) +**External dependencies:** GitHub raw content (mobile `## Public` block; graceful web-only fallback on failure). --- @@ -255,7 +276,7 @@ All three methods guard on `app()->environment('production')` and `$consumer_key ## Tests - `tests/Feature/Twitter/DailyReportTweetTest.php` — 28 tests: streak (0/1/5/gap), milestone boundaries (100K/1M), season labels (all 6 tiers), lead line (same/new/no-data), mission frames (3), conditional skipping (littercoin/streak/cities), thread output, no-data skip, formatMilestone (k/M), tweet length enforcement -- `tests/Feature/Twitter/ChangelogTweetTest.php` — 26 tests: overview counts, prefix parsing ([Web]/[Mobile]/default), GitHub raw content call verification, web-only/mobile-only, long changelog splits, oversized single line truncation, no-file skip, thread structure, cleanChange, singular/plural, sendThread return shape, mobile fetch from GitHub (success/404/500/merge/URL/thread integration) +- `tests/Feature/Twitter/ChangelogTweetTest.php` — 23 tests: `parsePublicBlock` (present prose / absent / empty / bullet-marker stripping / stops at next heading / from an array of lines), `buildPosts` (empty → none / short → one post / exactly-at-limit / over-limit threads with every post ≤300 and no content lost), `handle` (no web file + no mobile block → "No public changelog" + posts nothing, absent block both sources → "No public changelog", web block → posted, mobile fetch hits the GitHub URL, defaults to yesterday), and mobile (mobile `## Public` posted after web, mobile-only when web silent, mobile-only release with no web file → still posted, both combine into a ≤300 thread, fetch 404/500/exception → web-only, mobile without a `## Public` block contributes nothing) No tests exist for `WeeklyImpactReportTweet`, `MonthlyImpactReportTweet`, or `AnnualImpactReportTweet` (Browsershot dependency). The `GenerateImpactReportController` is tested in `tests/Feature/Reports/GenerateImpactReportTest.php` (8 tests: weekly/monthly/annual rendering, future date, invalid period, v5 brands query, zero data). @@ -264,7 +285,7 @@ No tests exist for `WeeklyImpactReportTweet`, `MonthlyImpactReportTweet`, or `An | Command | Tables | Send Method | Image | No-Data | External Deps | |---|---|---|---|---|---| | `daily-report` | `metrics`, `users`, `countries`, `cities`, `littercoin` | `sendThread()` (2 tweets) | No | Skips | None | -| `changelog` | None (reads local + GitHub changelog files) | `sendThread()` (overview + grouped) | No | Skips | GitHub raw content | +| `changelog` | None (web `## Public` block + mobile `## Public` from GitHub) | `Social::thread()` (one post per source, else thread) | No | Skips (no file / no `## Public` block) | GitHub raw content (mobile, graceful web-only fallback) | | `weekly-impact-report` | None | `sendTweetWithImage()` | Browsershot 1200x800 | Always tweets | Browsershot, Chromium, network | | `monthly-impact-report` | None | `sendTweetWithImage()` | Browsershot 1200x800 fullPage | Always tweets | Browsershot, Chromium, network | | `annual-impact-report` | None | `sendTweetWithImage()` | Browsershot 1200x800 fullPage | Always tweets | Browsershot, Chromium, network | diff --git a/readme/changelog/2026-05-04.md b/readme/changelog/2026-05-04.md index 3ab0fcba5..539d7a4ca 100644 --- a/readme/changelog/2026-05-04.md +++ b/readme/changelog/2026-05-04.md @@ -1,5 +1,8 @@ # 2026-05-04 +## Public +OpenLitterMap update 🔒 Data exports now require a free account, and we fixed a privacy issue that could have exposed school students' names. Exports are also faster, with simpler format options. #openlittermap + ## v5.11.3 — Collapsible export drawer, plain-language format options - New shared `ExportDrawer.vue` (light + dark theme) replaces the inline radio + nested-checkbox format selector that was duplicated across `UploadsHeader.vue`, `TeamPhotosHeader.vue`, and `Locations/Location/Controls/Download.vue`. The Export CSV button on each page now toggles the drawer open/closed below the filter row (or below the Download button on the location page) - Three flat radio options replace the old "Number-based + Separate/Combined checkboxes / Full-detail" matrix: "For Excel or Google Sheets" (wide+split), "For analysis tools (pandas, SQL, R, Tableau)" (long), "For legacy v4 scripts" (wide+joined). Default = Excel diff --git a/readme/changelog/2026-06-27.md b/readme/changelog/2026-06-27.md index f757a61cc..7821d947c 100644 --- a/readme/changelog/2026-06-27.md +++ b/readme/changelog/2026-06-27.md @@ -1,5 +1,8 @@ # 2026-06-27 +## Public +OpenLitterMap update 📬 Our newsletter sign-up form is working again — if it didn't respond before, you can subscribe for project updates straight from the homepage. #openlittermap + ## Session: Subscribe endpoint validation (mass-email malformed address fix) - Restored the mailing-list subscribe entry point that the live `Footer.vue` form still posts to (`POST /subscribe` had been removed, so every real subscribe attempt was 404ing). Recreated `SubscribersController` (invokable) + the `web.php` route, now with strict server-side validation: `required|max:100|email|regex:/@[^@\s]+\.[a-z]{2,}$/i|unique:subscribers`. The dotted-domain regex rejects RFC-valid-but-undeliverable single-label domains like `Estherbarriga@8` (the address that triggered the SES `Invalid domain name: '8'` failure during the Update 28 mass send) — Laravel's default `email` rule alone accepts these. Added 7 tests to `EmailSubscriptionTest.php` (valid insert, dotless-domain rejection across 6 real malformed samples, multi-level/uppercase domain acceptance, duplicate + missing-email rejection). 19 tests passing. (v5.12.7) diff --git a/readme/changelog/2026-06-28.md b/readme/changelog/2026-06-28.md index 7c4e406b8..01196b14a 100644 --- a/readme/changelog/2026-06-28.md +++ b/readme/changelog/2026-06-28.md @@ -1,8 +1,23 @@ # 2026-06-28 +## Public +OpenLitterMap update 🦋 You can now follow OpenLitterMap on Bluesky — our daily stats and project updates post there too, not just on X. #openlittermap + ## Session: Email deliverability docs (Email.md, resend runbook) - **Version bump → v5.12.9** (patch — docs only). - Docs: created `readme/Email.md` — canonical reference for the email subsystem (outbound campaign send + ledger, inbound SNS suppression feedback loop, backfill import, subscribe/unsubscribe, data model, the in-memory-suppression rationale, and Phase-2 verification deferral). Cross-references `API.md` and `ArtisanCommands.md` rather than duplicating them. Added to the `olm-architecture` skill's Domain Documentation index and the `CLAUDE.md` Domain Documentation list. (v5.12.9) - Docs: added a Phase-1 production resend runbook to `readme/ArtisanCommands.md` (migrate → `email:import-suppressions` → `--dry-run` → real send), with the `SES_SNS_TOPIC_ARN` prereq. Explicitly notes email verification (`users.email_verified_at` + `email:backfill-verified-at`) is split out as Phase 2 and is **not** a step in this deploy. (v5.12.9) - Confirmed suppression handling ships as-is: the send command loads `email_suppressions` into an in-memory set and writes a `skipped_suppressed` audit row per excluded address — a query-time `whereNotExists` was evaluated and rejected (it would drop the audit row + zero the operator report and break `EmailSendLedgerTest`). No code change. (v5.12.9) + +## Session: OLMbot — disable X posting (0 credits) + add Bluesky + +- Added Bluesky as a posting channel alongside X (now gated off). New `App\Helpers\Bluesky` — AT Protocol XRPC via Laravel `Http`: `createSession` → `createRecord`, reply-ref threads, `uploadBlob` images recompressed under the ~1MB blob limit (intervention/image), URL link facets with UTF-8 byte ranges. New thin `App\Helpers\Social` dispatcher (`text`/`thread`/`withImage`) fans out to enabled networks (`thread` sums over enabled networks only). All 9 OLMbot call sites (5 commands + 4 listeners) now post via `Social::` instead of `Twitter::` — stat logic untouched. Config `services.bluesky.*` (`BLUESKY_ENABLED` default false + identifier/app-password/service). Added `BlueskyTest` + `SocialTest` (Http::fake); 83 Twitter/Bluesky/Social tests pass. Spec: `docs/superpowers/specs/2026-06-28-bluesky-posting-design.md`; docs in `readme/Twitter.md`. (v5.12.10) +- Code-review hardening (Codex P2): `Twitter::isEnabled()` / `Bluesky::isEnabled()` now treat blank or partial credentials as disabled (`filled()`/`blank()` instead of `!== null`), since a blank env var resolves to `""` not `null` — so a misconfigured `*_ENABLED=true` with empty creds can no longer attempt live calls (and Bluesky's misconfig warning now fires on blank too). Added regression tests. Also hardened Bluesky `createdAt` to the canonical RFC 3339 form (UTC, `Z`, milliseconds) for the AT Protocol datetime lexicon. 85 OLMbot tests pass. (v5.12.11) +- X enabled API billing and the account has 0 credits, so all OLMbot writes would fail. Added a `TWITTER_ENABLED` master switch (`config('services.twitter.enabled')`, **defaults false**) gating `App\Helpers\Twitter::isEnabled()` — the single choke point all 9 posting paths (5 scheduled commands + 4 event listeners) funnel through. Posting now requires the flag ON **and** production **and** a consumer key, so an empty/missing key can't misfire. Effectively disables all X posting until re-enabled. Added `tests/Feature/Twitter/TwitterHelperTest.php` (5 tests); 73 Twitter tests pass. Documented in `readme/Twitter.md`, incl. the previously-undocumented event-driven listeners. (v5.12.10) + +## Session: Public changelog posts for OLMbot (curated `## Public` block) + +- Redesigned `twitter:changelog` to post a curated **`## Public`** changelog block instead of dumping the raw internal session bullets. `App\Console\Commands\Twitter\ChangelogTweet` now: reads the local file's `## Public` block (`parsePublicBlock()` — runs to the next heading, strips leading bullet markers, joins to one prose string), posts it via `Social::thread()` as **one Bluesky post** when ≤300 chars else a word-boundary thread (`buildPosts()`), and **posts nothing** (logs "No public changelog", exits SUCCESS) when the block is absent/empty — the common, correct, silent-on-internal-only-days outcome. Dropped the overview-counts post, the raw-bullet thread, the GitHub mobile-changelog fetch, and `cleanChange`/`parseEntries`/`buildThread`. Cadence is now event-driven (still scheduled `dailyAt('07:00')` but self-silences). Rewrote `tests/Feature/Twitter/ChangelogTweetTest.php` (14 tests) to the new contract; whole `tests/Feature/Twitter/` dir green (73 tests). Documented the `## Public` convention in `CLAUDE.md` (Daily Changelog + BOOP) and `readme/Twitter.md`; added worked `## Public` blocks to `2026-06-27.md`, `2026-06-28.md`, `2026-05-02.md`. Spec: `docs/superpowers/specs/2026-06-28-public-changelog-posts-design.md`. (v5.12.11, no bump) +- Follow-up — curated mobile support + multi-day rule: re-added the mobile (react-native) changelog, but curated via `## Public` (not the raw bullets). Refactored `parsePublicBlock()` to take an iterable of lines so it runs on both `File::lines($localPath)` (web) and `explode("\n", $body)` (mobile); new `fetchMobileChangelog()` (raw body or '' on any failure — non-200/network/timeout/exception, logged not thrown) + `mobilePublicBlock()`. `handle()` now combines `array_merge(buildPosts($webPublic), buildPosts($mobilePublic))` — both-with-content becomes a short web-then-mobile thread; a mobile failure falls back to web-only and never breaks the command. Mobile posts require the mobile repo to adopt the same `## Public` convention (silently skipped until it does). Fixed the export worked example: the login-required + student-privacy fix lands on `2026-05-04`, so moved/consolidated the canonical `## Public` block there (removed the `2026-05-02` block) and codified **one `## Public` per release, on the release-completion day, privacy/access first** in the spec + `CLAUDE.md`. ChangelogTweetTest now 22 tests; whole `tests/Feature/Twitter/` dir green (81 tests). (v5.12.11, no bump) +- Follow-up — decouple web/mobile in `handle()`: removed the early `! File::exists($path)` "No changelog found" return that fired before the mobile fetch, dropping the mobile post on a mobile-only release day with no local web file (separate repos, separate cadences). Now `$webPublic = File::exists($path) ? parsePublicBlock(File::lines($path)) : ''` and the mobile fetch always runs; post if either source is non-empty, else the existing `empty($posts)` → "No public changelog" silent path. Updated tests (the old no-web-file test now expects "No public changelog" with mobile 404; added a no-web-file + mobile-`## Public`-present test asserting the mobile post publishes). ChangelogTweetTest 23 tests; whole `tests/Feature/Twitter/` dir green (82 tests). (v5.12.11, no bump) diff --git a/tests/Feature/Twitter/BlueskyTest.php b/tests/Feature/Twitter/BlueskyTest.php new file mode 100644 index 000000000..98d074ac5 --- /dev/null +++ b/tests/Feature/Twitter/BlueskyTest.php @@ -0,0 +1,178 @@ +app['env'] = 'production'; + config([ + 'services.bluesky.enabled' => true, + 'services.bluesky.identifier' => 'olmbot.bsky.social', + 'services.bluesky.app_password' => 'app-pass', + 'services.bluesky.service' => 'https://bsky.social', + ]); + } + + protected function tearDown(): void + { + $this->app['env'] = 'testing'; + parent::tearDown(); + } + + public function test_posting_is_disabled_by_default(): void + { + $this->assertFalse((bool) config('services.bluesky.enabled')); + $this->assertFalse(Bluesky::isEnabled()); + } + + public function test_post_does_nothing_when_disabled(): void + { + Http::fake(); + + Bluesky::post('hello'); + + Http::assertNothingSent(); + } + + public function test_blank_credentials_stay_disabled(): void + { + $this->app['env'] = 'production'; + config([ + 'services.bluesky.enabled' => true, + 'services.bluesky.identifier' => 'olmbot.bsky.social', + 'services.bluesky.app_password' => '', // blank, not null — must still count as disabled + ]); + + $this->assertFalse(Bluesky::isEnabled()); + } + + public function test_post_creates_session_then_record(): void + { + $this->enableBluesky(); + + Http::fake([ + '*com.atproto.server.createSession' => Http::response(['accessJwt' => 'jwt-123', 'did' => 'did:plc:abc']), + '*com.atproto.repo.createRecord' => Http::response(['uri' => 'at://did/post/1', 'cid' => 'cid1']), + ]); + + Bluesky::post('Hello Bluesky'); + + Http::assertSent(fn ($r) => str_contains($r->url(), 'createSession') + && $r['identifier'] === 'olmbot.bsky.social' + && $r['password'] === 'app-pass'); + + Http::assertSent(fn ($r) => str_contains($r->url(), 'createRecord') + && $r['repo'] === 'did:plc:abc' + && $r['collection'] === 'app.bsky.feed.post' + && $r['record']['text'] === 'Hello Bluesky' + && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $r['record']['createdAt'] ?? '') === 1); + } + + public function test_thread_chains_reply_refs(): void + { + $this->enableBluesky(); + + Http::fake([ + '*createSession' => Http::response(['accessJwt' => 'jwt', 'did' => 'did:plc:abc']), + '*createRecord' => Http::sequence() + ->push(['uri' => 'at://did/post/1', 'cid' => 'cid1']) + ->push(['uri' => 'at://did/post/2', 'cid' => 'cid2']), + ]); + + $result = Bluesky::thread(['First', 'Second']); + + $this->assertEquals(2, $result['sent']); + $this->assertEquals(2, $result['total']); + $this->assertEquals('at://did/post/1', $result['first_id']); + + // The reply must point root + parent at the first post. + Http::assertSent(function ($r) { + if (! str_contains($r->url(), 'createRecord')) { + return false; + } + + $reply = $r['record']['reply'] ?? null; + + return $reply + && $reply['root']['uri'] === 'at://did/post/1' + && $reply['parent']['uri'] === 'at://did/post/1'; + }); + } + + public function test_url_gets_a_link_facet_with_byte_range(): void + { + $this->enableBluesky(); + + Http::fake([ + '*createSession' => Http::response(['accessJwt' => 'jwt', 'did' => 'did:plc:abc']), + '*createRecord' => Http::response(['uri' => 'at://p', 'cid' => 'c']), + ]); + + Bluesky::post('See https://openlittermap.com/global now'); + + Http::assertSent(function ($r) { + if (! str_contains($r->url(), 'createRecord')) { + return false; + } + + $facet = $r['record']['facets'][0] ?? null; + + return $facet + && $facet['features'][0]['$type'] === 'app.bsky.richtext.facet#link' + && $facet['features'][0]['uri'] === 'https://openlittermap.com/global' + && $facet['index']['byteStart'] === 4 + && $facet['index']['byteEnd'] === 4 + strlen('https://openlittermap.com/global'); + }); + } + + public function test_post_with_image_uploads_blob_then_embeds(): void + { + $this->enableBluesky(); + + $path = sys_get_temp_dir() . '/olm-bsky-' . uniqid() . '.jpg'; + $image = imagecreatetruecolor(120, 90); + imagejpeg($image, $path); + imagedestroy($image); + + Http::fake([ + '*createSession' => Http::response(['accessJwt' => 'jwt', 'did' => 'did:plc:abc']), + '*uploadBlob' => Http::response(['blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafy'], 'mimeType' => 'image/jpeg', 'size' => 123]]), + '*createRecord' => Http::response(['uri' => 'at://p', 'cid' => 'c']), + ]); + + Bluesky::postWithImage('With image', $path); + + @unlink($path); + + Http::assertSent(fn ($r) => str_contains($r->url(), 'uploadBlob')); + Http::assertSent(function ($r) { + if (! str_contains($r->url(), 'createRecord')) { + return false; + } + + $embed = $r['record']['embed'] ?? null; + + return $embed + && $embed['$type'] === 'app.bsky.embed.images' + && isset($embed['images'][0]['image']); + }); + } + + public function test_errors_are_swallowed(): void + { + $this->enableBluesky(); + + Http::fake(['*' => Http::response('nope', 500)]); + + Bluesky::post('hi'); // must not throw + $result = Bluesky::thread(['a', 'b']); // must not throw + + $this->assertEquals(0, $result['sent']); + } +} diff --git a/tests/Feature/Twitter/ChangelogTweetTest.php b/tests/Feature/Twitter/ChangelogTweetTest.php index 89c7fe1ce..154838f80 100644 --- a/tests/Feature/Twitter/ChangelogTweetTest.php +++ b/tests/Feature/Twitter/ChangelogTweetTest.php @@ -3,390 +3,300 @@ namespace Tests\Feature\Twitter; use App\Console\Commands\Twitter\ChangelogTweet; -use App\Helpers\Twitter; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Tests\TestCase; class ChangelogTweetTest extends TestCase { - private string $summaryDir; + private string $changelogDir; private ChangelogTweet $command; protected function setUp(): void { parent::setUp(); - $this->summaryDir = base_path('readme/changelog'); + $this->changelogDir = base_path('readme/changelog'); $this->command = new ChangelogTweet(); } - // ─── Overview tweet counts ─────────────────────────────────────── + /** A mobile changelog body carrying a `## Public` block in the house style. */ + private function mobilePublicBody(string $text): string + { + return implode("\n", ['# Mobile Changes', '', '## Public', $text, '', '## Internal', '- did a thing']); + } + + // ─── parsePublicBlock (web file lines) ─────────────────────────── - public function test_overview_counts_web_and_mobile_correctly(): void + public function test_parses_public_block_as_prose(): void { $date = '2099-01-01'; - $path = "{$this->summaryDir}/{$date}.md"; + $path = "{$this->changelogDir}/{$date}.md"; File::put($path, implode("\n", [ '# Changes', '', - '- [Web] Fix admin permissions', - '- [Web] Restore scheduler', - '- [Web] Add clustering config', - '- [Mobile] Camera orientation fix', - '- [Mobile] Upload retry', + '## Public', + 'OpenLitterMap update 🔒 We fixed a privacy issue and exports are faster. #openlittermap', + '', + '## Session: internal notes', + '- `v5.0.1` — refactored MetricsService internals', ])); try { - $parsed = $this->command->parseEntries($path); - $tweets = $this->command->buildThread($date, $parsed['web'], $parsed['mobile']); - - $overview = $tweets[0]; - $this->assertStringContainsString('3 web improvements', $overview); - $this->assertStringContainsString('2 mobile improvements', $overview); - $this->assertStringContainsString('🧵 Thread ↓', $overview); + $this->assertEquals( + 'OpenLitterMap update 🔒 We fixed a privacy issue and exports are faster. #openlittermap', + $this->command->parsePublicBlock(File::lines($path)) + ); } finally { File::delete($path); } } - // ─── Prefix parsing ───────────────────────────────────────────── - - public function test_entries_without_prefix_default_to_web(): void + public function test_absent_public_block_returns_empty_string(): void { $date = '2099-01-02'; - $path = "{$this->summaryDir}/{$date}.md"; + $path = "{$this->changelogDir}/{$date}.md"; File::put($path, implode("\n", [ '# Changes', '', - '- `v5.0.1` — Fix something', - '- `v5.0.2` — Another fix', + '## Session: internal only', + '- `v5.0.2` — tweaked a query', ])); try { - $parsed = $this->command->parseEntries($path); - - $this->assertCount(2, $parsed['web']); - $this->assertCount(0, $parsed['mobile']); - $this->assertEquals('Fix something', $parsed['web'][0]); + $this->assertSame('', $this->command->parsePublicBlock(File::lines($path))); } finally { File::delete($path); } } - public function test_web_prefix_stripped_correctly(): void + public function test_empty_public_block_returns_empty_string(): void { $date = '2099-01-03'; - $path = "{$this->summaryDir}/{$date}.md"; + $path = "{$this->changelogDir}/{$date}.md"; - File::put($path, "# Changes\n\n- [Web] Fix admin permissions\n"); + File::put($path, implode("\n", [ + '# Changes', + '', + '## Public', + '', + '## Session: internal', + '- did a thing', + ])); try { - $parsed = $this->command->parseEntries($path); - - $this->assertCount(1, $parsed['web']); - $this->assertEquals('Fix admin permissions', $parsed['web'][0]); + $this->assertSame('', $this->command->parsePublicBlock(File::lines($path))); } finally { File::delete($path); } } - public function test_mobile_prefix_stripped_correctly(): void + public function test_strips_leading_bullet_markers_and_joins_lines(): void { $date = '2099-01-04'; - $path = "{$this->summaryDir}/{$date}.md"; + $path = "{$this->changelogDir}/{$date}.md"; - File::put($path, "# Changes\n\n- [Mobile] Camera orientation fix\n"); + File::put($path, implode("\n", [ + '# Changes', + '', + '## Public', + '- Newsletter sign-up works again.', + '- The map loads faster.', + ])); try { - $parsed = $this->command->parseEntries($path); - - $this->assertCount(0, $parsed['web']); - $this->assertCount(1, $parsed['mobile']); - $this->assertEquals('Camera orientation fix', $parsed['mobile'][0]); + $this->assertEquals( + 'Newsletter sign-up works again. The map loads faster.', + $this->command->parsePublicBlock(File::lines($path)) + ); } finally { File::delete($path); } } - // ─── GitHub calls only to raw.githubusercontent.com ──────────────── - - public function test_only_github_raw_content_calls_made(): void + public function test_block_stops_at_next_heading(): void { - Http::fake([ - 'raw.githubusercontent.com/*' => Http::response('', 404), - '*' => Http::response('', 200), - ]); - $date = '2099-01-05'; - $path = "{$this->summaryDir}/{$date}.md"; - - File::put($path, "# Changes\n\n- [Web] Fix something\n"); - - try { - $this->artisan("twitter:changelog {$date}") - ->assertSuccessful(); - - Http::assertSentCount(1); - Http::assertSent(fn ($request) => str_contains($request->url(), 'raw.githubusercontent.com')); - } finally { - File::delete($path); - } - } - - // ─── Web only ──────────────────────────────────────────────────── - - public function test_web_only_overview_shows_web_count_no_mobile(): void - { - $date = '2099-01-06'; - $path = "{$this->summaryDir}/{$date}.md"; + $path = "{$this->changelogDir}/{$date}.md"; File::put($path, implode("\n", [ '# Changes', '', - '- [Web] Fix admin permissions', - '- [Web] Restore scheduler', + '## Public', + 'Public line.', + '## Session: internal', + '- Internal bullet that must never be posted', ])); try { - $parsed = $this->command->parseEntries($path); - $tweets = $this->command->buildThread($date, $parsed['web'], $parsed['mobile']); + $public = $this->command->parsePublicBlock(File::lines($path)); - $overview = $tweets[0]; - $this->assertStringContainsString('2 web improvements', $overview); - $this->assertStringNotContainsString('mobile', $overview); + $this->assertEquals('Public line.', $public); + $this->assertStringNotContainsString('Internal bullet', $public); } finally { File::delete($path); } } - // ─── Mobile only ───────────────────────────────────────────────── - - public function test_mobile_only_overview_shows_mobile_count_no_web(): void + public function test_parses_public_block_from_array_of_lines(): void { - $date = '2099-01-07'; - $path = "{$this->summaryDir}/{$date}.md"; + $body = $this->mobilePublicBody('OpenLitterMap app update 📱 Camera orientation is saved correctly now. #openlittermap'); - File::put($path, implode("\n", [ - '# Changes', - '', - '- [Mobile] Camera fix', - '- [Mobile] Upload retry', - '- [Mobile] Haptic feedback', - ])); - - try { - $parsed = $this->command->parseEntries($path); - $tweets = $this->command->buildThread($date, $parsed['web'], $parsed['mobile']); - - $overview = $tweets[0]; - $this->assertStringContainsString('3 mobile improvements', $overview); - $this->assertStringNotContainsString('web', $overview); - } finally { - File::delete($path); - } + $this->assertEquals( + 'OpenLitterMap app update 📱 Camera orientation is saved correctly now. #openlittermap', + $this->command->parsePublicBlock(explode("\n", $body)) + ); } - // ─── Long changelog splits ─────────────────────────────────────── + // ─── buildPosts ────────────────────────────────────────────────── - public function test_long_changelog_splits_across_tweets(): void + public function test_empty_text_builds_no_posts(): void { - $date = '2099-01-08'; - $path = "{$this->summaryDir}/{$date}.md"; + $this->assertSame([], $this->command->buildPosts('')); + } - $lines = "# Changes\n\n"; - for ($i = 1; $i <= 15; $i++) { - $lines .= "- [Web] Implemented a fairly verbose description of change number {$i} that pushes tweet length limits\n"; - } + public function test_short_text_is_a_single_post(): void + { + $text = 'OpenLitterMap update 🦋 We now post to Bluesky too. #openlittermap'; - File::put($path, $lines); + $posts = $this->command->buildPosts($text); - try { - $parsed = $this->command->parseEntries($path); - $tweets = $this->command->buildThread($date, $parsed['web'], $parsed['mobile']); + $this->assertCount(1, $posts); + $this->assertEquals($text, $posts[0]); + } - $this->assertGreaterThan(2, count($tweets), 'Should split into 3+ tweets'); + public function test_text_exactly_at_limit_is_a_single_post(): void + { + $text = str_repeat('a', 300); - // Every tweet must be within 280 chars - foreach ($tweets as $i => $tweet) { - $this->assertLessThanOrEqual( - 280, - mb_strlen($tweet), - "Tweet " . ($i + 1) . " exceeds 280 chars (" . mb_strlen($tweet) . ")" - ); - } - } finally { - File::delete($path); - } + $this->assertCount(1, $this->command->buildPosts($text)); } - public function test_oversized_single_line_truncated_within_280(): void + public function test_long_text_threads_with_every_post_within_limit(): void { - $date = '2099-01-20'; - $path = "{$this->summaryDir}/{$date}.md"; + $text = trim(str_repeat('word ', 120)); // 600 chars, word boundaries - // A single line that is 300+ chars - $longLine = '- [Web] ' . str_repeat('A very long description that keeps going ', 8); + $posts = $this->command->buildPosts($text); - File::put($path, "# Changes\n\n{$longLine}\n"); + $this->assertGreaterThan(1, count($posts), 'Over-limit text should thread'); - try { - $parsed = $this->command->parseEntries($path); - $tweets = $this->command->buildThread($date, $parsed['web'], $parsed['mobile']); - - foreach ($tweets as $i => $tweet) { - $this->assertLessThanOrEqual( - 280, - mb_strlen($tweet), - "Tweet " . ($i + 1) . " exceeds 280 chars (" . mb_strlen($tweet) . ")" - ); - } - } finally { - File::delete($path); + foreach ($posts as $i => $post) { + $this->assertLessThanOrEqual( + 300, + mb_strlen($post), + 'Post ' . ($i + 1) . ' exceeds 300 chars (' . mb_strlen($post) . ')' + ); } + + // No content lost in the split. + $this->assertEquals($text, implode(' ', $posts)); } - // ─── No file: skip silently ────────────────────────────────────── + // ─── handle ────────────────────────────────────────────────────── - public function test_no_file_skips_silently(): void + public function test_no_web_file_and_no_mobile_block_skips_silently(): void { + // No local web file for the date, and the mobile fetch 404s → both sources + // empty → silent. Web and mobile are decoupled, so the mobile fetch still runs. + Http::fake(['raw.githubusercontent.com/*' => Http::response('', 404)]); + $this->artisan('twitter:changelog 2099-12-31') - ->expectsOutputToContain('No changelog found') + ->expectsOutputToContain('No public changelog') ->assertSuccessful(); } - public function test_empty_file_skips_silently(): void + public function test_mobile_only_release_posts_when_no_web_file(): void { - $date = '2099-01-09'; - $path = "{$this->summaryDir}/{$date}.md"; - - File::put($path, "# Changes\n\nNo bullet points here.\n"); + // The decoupling gap: a mobile app release on a date with no local web + // changelog file must still post the mobile `## Public` block. + Http::fake([ + 'raw.githubusercontent.com/*' => Http::response( + $this->mobilePublicBody('OpenLitterMap app update 📱 New offline mode in the app. #openlittermap'), + 200 + ), + ]); - try { - $this->artisan("twitter:changelog {$date}") - ->expectsOutputToContain('No changelog found') - ->assertSuccessful(); - } finally { - File::delete($path); - } + $this->artisan('twitter:changelog 2099-12-30') + ->expectsOutputToContain('[1/1] OpenLitterMap app update 📱 New offline mode in the app.') + ->doesntExpectOutputToContain('No public changelog') + ->assertSuccessful(); } - // ─── Thread structure ──────────────────────────────────────────── - - public function test_first_tweet_is_always_overview(): void + public function test_absent_public_block_on_both_sources_posts_nothing(): void { + Http::fake(['raw.githubusercontent.com/*' => Http::response('', 404)]); + $date = '2099-01-10'; - $path = "{$this->summaryDir}/{$date}.md"; + $path = "{$this->changelogDir}/{$date}.md"; - File::put($path, implode("\n", [ - '# Changes', - '', - '- [Web] Fix something', - '- [Mobile] Camera fix', - ])); + File::put($path, "# Changes\n\n## Session: internal\n- refactored something\n"); try { - $parsed = $this->command->parseEntries($path); - $tweets = $this->command->buildThread($date, $parsed['web'], $parsed['mobile']); - - $this->assertStringContainsString('🔧 OpenLitterMap — Changes for', $tweets[0]); - $this->assertStringContainsString('🧵 Thread ↓', $tweets[0]); - - // Second tweet has actual changes - $this->assertStringContainsString('🌐 Web', $tweets[1]); + $this->artisan("twitter:changelog {$date}") + ->expectsOutputToContain('No public changelog') + ->assertSuccessful(); } finally { File::delete($path); } } - public function test_last_tweet_has_hashtags(): void + public function test_web_public_block_is_posted(): void { + Http::fake(['raw.githubusercontent.com/*' => Http::response('', 404)]); + $date = '2099-01-11'; - $path = "{$this->summaryDir}/{$date}.md"; + $path = "{$this->changelogDir}/{$date}.md"; File::put($path, implode("\n", [ '# Changes', '', - '- [Web] Fix something', - '- [Mobile] Camera fix', + '## Public', + 'OpenLitterMap update 🦋 We now post to Bluesky. #openlittermap', + '', + '## Session: internal', + '- changed an internal thing', ])); try { - $parsed = $this->command->parseEntries($path); - $tweets = $this->command->buildThread($date, $parsed['web'], $parsed['mobile']); - - $lastTweet = end($tweets); - $this->assertStringContainsString('#openlittermap #changelog', $lastTweet); + // Non-production test env → Social posts nothing (sent === 0 → SUCCESS), + // but the command must reach the post path, not the silence path. + $this->artisan("twitter:changelog {$date}") + ->expectsOutputToContain('We now post to Bluesky') + ->doesntExpectOutputToContain('No public changelog') + ->assertSuccessful(); } finally { File::delete($path); } } - public function test_grouped_sections_in_correct_order(): void + public function test_fetches_mobile_changelog_from_github(): void { - $date = '2099-01-12'; - $path = "{$this->summaryDir}/{$date}.md"; - - File::put($path, implode("\n", [ - '# Changes', - '', - '- [Mobile] Camera fix', - '- [Web] Fix something', - ])); - - try { - $parsed = $this->command->parseEntries($path); - $tweets = $this->command->buildThread($date, $parsed['web'], $parsed['mobile']); - - // Changes tweet should have Web before Mobile - $changesTweet = $tweets[1]; - $webPos = mb_strpos($changesTweet, '🌐 Web'); - $mobilePos = mb_strpos($changesTweet, '📱 Mobile'); - - $this->assertNotFalse($webPos); - $this->assertNotFalse($mobilePos); - $this->assertLessThan($mobilePos, $webPos, 'Web section should come before Mobile'); - } finally { - File::delete($path); - } - } + Http::fake(['raw.githubusercontent.com/*' => Http::response('', 404)]); - // ─── cleanChange ───────────────────────────────────────────────── + $date = '2099-01-12'; - public function test_strips_backticks_and_version_prefix(): void - { - $this->assertEquals( - 'Added __APP_VERSION__ define in vite.config.js', - $this->command->cleanChange('`v5.0.3` — Added `__APP_VERSION__` define in `vite.config.js`') - ); - } + $this->command->fetchMobileChangelog($date); - public function test_strips_bold_version_prefix(): void - { - $this->assertEquals( - 'Fix admin permissions', - $this->command->cleanChange('**v5.0.13** — Fix admin permissions') - ); + Http::assertSent(fn ($request) => $request->url() + === "https://raw.githubusercontent.com/OpenLitterMap/react-native/openlittermap/v7/readme/changelog/{$date}.md"); } - // ─── Defaults to yesterday ─────────────────────────────────────── - public function test_defaults_to_yesterday_when_no_date_provided(): void { Http::fake(['raw.githubusercontent.com/*' => Http::response('', 404)]); $yesterday = now()->subDay()->toDateString(); - $path = "{$this->summaryDir}/{$yesterday}.md"; + $path = "{$this->changelogDir}/{$yesterday}.md"; $existed = File::exists($path); if (! $existed) { - File::put($path, "# Changes\n\n- `v1.0.0` — Test change\n"); + File::put($path, "# Changes\n\n## Session: internal\n- internal only\n"); } try { - $this->artisan('twitter:changelog') - ->assertSuccessful(); + $this->artisan('twitter:changelog')->assertSuccessful(); } finally { if (! $existed) { File::delete($path); @@ -394,172 +304,118 @@ public function test_defaults_to_yesterday_when_no_date_provided(): void } } - // ─── sendThread return shape ───────────────────────────────────── - - public function test_send_thread_returns_correct_shape_in_non_production(): void - { - $result = Twitter::sendThread(['Tweet 1', 'Tweet 2']); - - $this->assertArrayHasKey('first_id', $result); - $this->assertArrayHasKey('sent', $result); - $this->assertArrayHasKey('total', $result); - $this->assertNull($result['first_id']); - $this->assertEquals(0, $result['sent']); - $this->assertEquals(2, $result['total']); - } - - public function test_send_thread_with_empty_array_returns_zero_counts(): void - { - $result = Twitter::sendThread([]); - - $this->assertNull($result['first_id']); - $this->assertEquals(0, $result['sent']); - $this->assertEquals(0, $result['total']); - } - - // ─── Singular/plural ───────────────────────────────────────────── - - public function test_singular_improvement_for_single_entry(): void - { - $tweets = $this->command->buildThread('2099-01-13', ['Fix something'], []); - - $this->assertStringContainsString('1 web improvement', $tweets[0]); - $this->assertStringNotContainsString('improvements', $tweets[0]); - } - - // ─── Mobile changelog from GitHub ──────────────────────────────── + // ─── Mobile (curated `## Public` from the react-native repo) ────── - public function test_fetches_mobile_entries_from_github(): void + public function test_mobile_public_block_is_posted_after_web(): void { Http::fake([ - 'raw.githubusercontent.com/*' => Http::response(implode("\n", [ - '# Mobile Changes', - '', - '- Camera orientation saved correctly', - '- Upload retry on weak connections', - ]), 200), + 'raw.githubusercontent.com/*' => Http::response( + $this->mobilePublicBody('OpenLitterMap app update 📱 Camera orientation is fixed. #openlittermap'), + 200 + ), ]); - $date = '2099-01-14'; - $path = "{$this->summaryDir}/{$date}.md"; + $date = '2099-01-13'; + $path = "{$this->changelogDir}/{$date}.md"; - File::put($path, "# Changes\n\n- [Web] Fix admin permissions\n"); + File::put($path, "# Changes\n\n## Public\nWeb public note here. #openlittermap\n"); try { - $parsed = $this->command->parseEntries($path, $date); - - $this->assertCount(1, $parsed['web']); - $this->assertCount(2, $parsed['mobile']); - $this->assertEquals('Camera orientation saved correctly', $parsed['mobile'][0]); - $this->assertEquals('Upload retry on weak connections', $parsed['mobile'][1]); + $this->artisan("twitter:changelog {$date}") + ->expectsOutputToContain('[1/2] Web public note here.') + ->expectsOutputToContain('[2/2] OpenLitterMap app update 📱 Camera orientation is fixed.') + ->assertSuccessful(); } finally { File::delete($path); } } - public function test_mobile_fetch_failure_falls_back_to_web_only(): void + public function test_mobile_only_public_block_posts_when_web_is_silent(): void { Http::fake([ - 'raw.githubusercontent.com/*' => Http::response('', 500), + 'raw.githubusercontent.com/*' => Http::response( + $this->mobilePublicBody('OpenLitterMap app update 📱 Upload retry on weak connections. #openlittermap'), + 200 + ), ]); - $date = '2099-01-15'; - $path = "{$this->summaryDir}/{$date}.md"; + $date = '2099-01-14'; + $path = "{$this->changelogDir}/{$date}.md"; - File::put($path, "# Changes\n\n- [Web] Fix something\n"); + // Web file exists but has no `## Public` block — mobile is the only source. + File::put($path, "# Changes\n\n## Session: internal\n- internal only\n"); try { - $parsed = $this->command->parseEntries($path, $date); - - $this->assertCount(1, $parsed['web']); - $this->assertCount(0, $parsed['mobile']); + $this->artisan("twitter:changelog {$date}") + ->expectsOutputToContain('[1/1] OpenLitterMap app update 📱 Upload retry on weak connections.') + ->doesntExpectOutputToContain('No public changelog') + ->assertSuccessful(); } finally { File::delete($path); } } - public function test_mobile_fetch_404_returns_no_mobile_entries(): void + public function test_both_sources_combine_into_a_thread_each_within_limit(): void { + $mobileText = 'OpenLitterMap app update 📱 ' . str_repeat('mobile detail ', 30) . '#openlittermap'; + Http::fake([ - 'raw.githubusercontent.com/*' => Http::response('', 404), + 'raw.githubusercontent.com/*' => Http::response($this->mobilePublicBody($mobileText), 200), ]); - $date = '2099-01-16'; - $path = "{$this->summaryDir}/{$date}.md"; + $date = '2099-01-15'; + $path = "{$this->changelogDir}/{$date}.md"; - File::put($path, "# Changes\n\n- [Web] Fix something\n"); + File::put($path, "# Changes\n\n## Public\nWeb public note. #openlittermap\n"); try { - $parsed = $this->command->parseEntries($path, $date); + $webPublic = $this->command->parsePublicBlock(File::lines($path)); + $mobilePublic = $this->command->mobilePublicBlock($date); + $posts = array_merge($this->command->buildPosts($webPublic), $this->command->buildPosts($mobilePublic)); - $this->assertCount(1, $parsed['web']); - $this->assertCount(0, $parsed['mobile']); + $this->assertGreaterThan(1, count($posts), 'Web + long mobile should thread'); + $this->assertStringContainsString('Web public note', $posts[0]); + + foreach ($posts as $i => $post) { + $this->assertLessThanOrEqual( + 300, + mb_strlen($post), + 'Post ' . ($i + 1) . ' exceeds 300 chars (' . mb_strlen($post) . ')' + ); + } } finally { File::delete($path); } } - public function test_mobile_entries_merge_with_local_mobile_entries(): void + public function test_mobile_fetch_404_falls_back_to_web_only(): void { - Http::fake([ - 'raw.githubusercontent.com/*' => Http::response("- Camera fix from GitHub\n", 200), - ]); - - $date = '2099-01-17'; - $path = "{$this->summaryDir}/{$date}.md"; + Http::fake(['raw.githubusercontent.com/*' => Http::response('', 404)]); - File::put($path, "# Changes\n\n- [Web] Web fix\n- [Mobile] Local mobile fix\n"); + $this->assertSame('', $this->command->mobilePublicBlock('2099-01-16')); + } - try { - $parsed = $this->command->parseEntries($path, $date); + public function test_mobile_fetch_500_falls_back_to_web_only(): void + { + Http::fake(['raw.githubusercontent.com/*' => Http::response('', 500)]); - $this->assertCount(1, $parsed['web']); - $this->assertCount(2, $parsed['mobile']); - $this->assertEquals('Local mobile fix', $parsed['mobile'][0]); - $this->assertEquals('Camera fix from GitHub', $parsed['mobile'][1]); - } finally { - File::delete($path); - } + $this->assertSame('', $this->command->mobilePublicBlock('2099-01-17')); } - public function test_mobile_fetch_hits_correct_github_url(): void + public function test_mobile_fetch_exception_is_swallowed(): void { - Http::fake([ - 'raw.githubusercontent.com/*' => Http::response('', 404), - ]); + Http::fake(['raw.githubusercontent.com/*' => fn () => throw new ConnectionException('timeout')]); - $date = '2099-01-18'; - $this->command->fetchMobileChangelog($date); - - Http::assertSent(function ($request) use ($date) { - return $request->url() === "https://raw.githubusercontent.com/OpenLitterMap/react-native/openlittermap/v7/readme/changelog/{$date}.md"; - }); + // No exception bubbles up; web-only fallback. + $this->assertSame('', $this->command->mobilePublicBlock('2099-01-18')); } - public function test_mobile_entries_appear_in_tweet_thread(): void + public function test_mobile_without_public_block_contributes_nothing(): void { Http::fake([ - 'raw.githubusercontent.com/*' => Http::response("- Haptic feedback added\n", 200), + 'raw.githubusercontent.com/*' => Http::response("# Mobile Changes\n\n- internal only\n", 200), ]); - $date = '2099-01-19'; - $path = "{$this->summaryDir}/{$date}.md"; - - File::put($path, "# Changes\n\n- [Web] Fix admin permissions\n"); - - try { - $parsed = $this->command->parseEntries($path, $date); - $tweets = $this->command->buildThread($date, $parsed['web'], $parsed['mobile']); - - $overview = $tweets[0]; - $this->assertStringContainsString('1 web improvement', $overview); - $this->assertStringContainsString('1 mobile improvement', $overview); - - $changesTweet = $tweets[1]; - $this->assertStringContainsString('📱 Mobile', $changesTweet); - $this->assertStringContainsString('Haptic feedback added', $changesTweet); - } finally { - File::delete($path); - } + $this->assertSame('', $this->command->mobilePublicBlock('2099-01-19')); } } diff --git a/tests/Feature/Twitter/SocialTest.php b/tests/Feature/Twitter/SocialTest.php new file mode 100644 index 000000000..7b57c40a3 --- /dev/null +++ b/tests/Feature/Twitter/SocialTest.php @@ -0,0 +1,72 @@ +app['env'] = 'production'; + config([ + 'services.bluesky.enabled' => true, + 'services.bluesky.identifier' => 'olmbot.bsky.social', + 'services.bluesky.app_password' => 'pw', + 'services.bluesky.service' => 'https://bsky.social', + 'services.twitter.enabled' => false, // X stays gated off + ]); + } + + protected function tearDown(): void + { + $this->app['env'] = 'testing'; + parent::tearDown(); + } + + public function test_thread_returns_zero_when_no_network_enabled(): void + { + // testing env → neither network enabled; command's `sent === 0` → SUCCESS path + $result = Social::thread(['a', 'b']); + + $this->assertEquals(0, $result['sent']); + $this->assertEquals(0, $result['total']); + $this->assertNull($result['first_id']); + } + + public function test_thread_counts_only_enabled_networks(): void + { + $this->enableBlueskyOnly(); + + Http::fake([ + '*createSession' => Http::response(['accessJwt' => 'jwt', 'did' => 'did:plc:abc']), + '*createRecord' => Http::sequence() + ->push(['uri' => 'at://1', 'cid' => 'c1']) + ->push(['uri' => 'at://2', 'cid' => 'c2']), + ]); + + $result = Social::thread(['one', 'two']); + + // Only Bluesky enabled → total = 2 (not 4 across two networks), sent = 2. + $this->assertEquals(2, $result['sent']); + $this->assertEquals(2, $result['total']); + $this->assertEquals('at://1', $result['first_id']); + } + + public function test_text_fans_out_to_enabled_network(): void + { + $this->enableBlueskyOnly(); + + Http::fake([ + '*createSession' => Http::response(['accessJwt' => 'jwt', 'did' => 'did:plc:abc']), + '*createRecord' => Http::response(['uri' => 'at://1', 'cid' => 'c1']), + ]); + + Social::text('Hello world'); + + Http::assertSent(fn ($r) => str_contains($r->url(), 'createRecord') + && $r['record']['text'] === 'Hello world'); + } +} diff --git a/tests/Feature/Twitter/TwitterHelperTest.php b/tests/Feature/Twitter/TwitterHelperTest.php new file mode 100644 index 000000000..1fdd835e8 --- /dev/null +++ b/tests/Feature/Twitter/TwitterHelperTest.php @@ -0,0 +1,89 @@ +assertFalse((bool) config('services.twitter.enabled')); + $this->assertFalse(Twitter::isEnabled()); + } + + public function test_disabled_in_production_when_flag_is_off(): void + { + $this->app['env'] = 'production'; + config([ + 'services.twitter.enabled' => false, + 'services.twitter.consumer_key' => 'a-key', + ]); + + $this->assertFalse(Twitter::isEnabled()); + + $this->app['env'] = 'testing'; + } + + public function test_enabled_only_with_flag_on_in_production_and_full_credentials(): void + { + $this->app['env'] = 'production'; + config([ + 'services.twitter.enabled' => true, + 'services.twitter.consumer_key' => 'a-key', + 'services.twitter.consumer_secret' => 'a-secret', + 'services.twitter.access_token' => 'a-token', + 'services.twitter.access_secret' => 'a-token-secret', + ]); + + $this->assertTrue(Twitter::isEnabled()); + + $this->app['env'] = 'testing'; + } + + public function test_blank_or_partial_credentials_stay_disabled(): void + { + $this->app['env'] = 'production'; + config([ + 'services.twitter.enabled' => true, + 'services.twitter.consumer_key' => 'a-key', + 'services.twitter.consumer_secret' => '', // blank resolves like an empty env var + 'services.twitter.access_token' => null, + 'services.twitter.access_secret' => '', + ]); + + $this->assertFalse(Twitter::isEnabled()); + + $this->app['env'] = 'testing'; + } + + public function test_flag_on_but_missing_key_stays_disabled(): void + { + $this->app['env'] = 'production'; + config([ + 'services.twitter.enabled' => true, + 'services.twitter.consumer_key' => null, + ]); + + $this->assertFalse(Twitter::isEnabled()); + + $this->app['env'] = 'testing'; + } + + public function test_flag_on_outside_production_stays_disabled(): void + { + config([ + 'services.twitter.enabled' => true, + 'services.twitter.consumer_key' => 'a-key', + ]); + + // Environment is 'testing' here — must never post outside production. + $this->assertFalse(Twitter::isEnabled()); + } +}