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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/Console/Commands/Twitter/AnnualImpactReportTweet.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');

Expand Down
226 changes: 86 additions & 140 deletions app/Console/Commands/Twitter/ChangelogTweet.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,235 +4,181 @@

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/';

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<int, string> $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;
}
}
Loading
Loading