Skip to content
CodeHospitalPublic

About

BBC News in Farsi

Resources

Stars

0 stars

Watchers

1 watching

Forks

Latest commit

 

History

93 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Khabarestan Rails App

A Rails 8 admin web app that fetches international news RSS feeds (BBC, NYT and others), rewrites articles using a local LLM (Ollama), translates them to Persian, and autoposts to Telegram, Bale (bale.ai) and other Telegram-compatible channels.

LLM work is not done inside the web app. The app enqueues tasks in a database-backed queue; a separate worker client — which has access to Ollama — claims tasks over a protected API, runs them, and posts the results back. This decouples the web app from Ollama entirely (the worker can run on a different, GPU-equipped machine).

Tech Stack

  • Ruby 3.3.8 / Rails 8
  • SQLite3 (development/test) / PostgreSQL (production, via the database_url credential) — database
  • DB-backed task queue + standalone worker client — LLM work
  • Solid Cache — Rails.cache store
  • Ollama — local LLM inference, called by the worker (not the app)
  • feedjira + httparty — RSS fetching
  • telegram-bot-ruby — Telegram posting
  • Bootstrap 5 — admin UI
  • Kamal — deployment

Requirements

  • Ruby 3.3.8 (see .ruby-version)
  • Bundler
  • SQLite3
  • Ollama running locally

Setup

1. Install dependencies

bundle install

2. Configure credentials

All app configuration lives in Rails encrypted credentials (config/credentials.yml.enc). It's decrypted with config/master.key locally, or with the RAILS_MASTER_KEY env var in production. The app does not read ENV for any of these settings.

bin/rails credentials:edit     # opens the decrypted YAML in $EDITOR
bin/rails bbc:credentials      # lists which keys are set / MISSING (never prints values)

config/credentials.example.yml is a template with every key. Copy the ones you need into credentials:edit.

Credential key Required? Purpose
admin_username / admin_password / admin_email yes (app won't boot without them, except in test) Bootstrap admin, used once by bin/rails db:seed to create the first admin User row if no users exist yet. After that, manage users at /admin/users.
worker_api_token yes (worker API rejects every call without it) Shared bearer token for the worker API (/api/tasks). Must match the worker's own WORKER_API_TOKEN env.
database_url production only PostgreSQL connection string, e.g. postgres://user:password@host:5432/khabarestan_production. Development/test use SQLite.
app_base_url for llmarkt / Telegram admin bot / mail links Public URL of this app. Also added to production's allowed hosts.
llmarkt_api_url / llmarkt_api_key / llmarkt_model_match optional llmarkt LLM grid (see below)
resend_api_key / sender_email optional Outgoing mail via Resend (see below)
sentry_dsn optional Error tracking (see below)
telegram_admin_bot_token / telegram_admin_chat_id / telegram_admin_webhook_secret optional Telegram admin notification bot (see below)
ollama_url optional Only used for the admin "debug curl" panels (default http://localhost:11434). The app itself never calls Ollama.

Only runtime/boot knobs are still read from the environment: RAILS_MASTER_KEY, RAILS_ENV, PORT, RAILS_MAX_THREADS, WEB_CONCURRENCY, PIDFILE, RAILS_LOG_LEVEL, and CI (tests). The standalone worker (worker/) is a separate program and keeps its own env config (see worker/README.md).

Migrating from .env: each old env var maps to the lower-case credential of the same name (WORKER_API_TOKEN → worker_api_token, DATABASE_URL → database_url, APP_BASE_URL → app_base_url, …). Copy every value into bin/rails credentials:edit, then run bin/rails bbc:credentials to confirm. The old TELEGRAM_BOT_TOKEN / TELEGRAM_CHANNEL vars were only used by the legacy update.rb script. Channel tokens live on /admin/telegram_channels rows.

3. Set up the database

bin/rails db:prepare
bin/rails db:seed

db:seed also creates the first admin account (from the admin_username / admin_password / admin_email credentials), but only if the users table is empty. Once at least one admin exists, manage everyone (including yourself) from /admin/users. The credentials are not read at login time.

4. Start the server

bin/dev

bin/dev just boots Puma (bin/rails server is equivalent). The web app only enqueues tasks — to actually process them you also need to run the worker client somewhere with access to Ollama:

WORKER_API_TOKEN=<same value as the worker_api_token credential> ruby worker/worker.rb

Visit http://localhost:3000/admin and log in with the admin account seeded above.


Ollama Setup

The app uses two models via Ollama:

Purpose Model
Article rewriting qwen3:14b
Persian translation & refinement aya-expanse:32b

Install Ollama

macOS:

brew install ollama

Or download the installer from ollama.com/download.

Linux:

curl -fsSL https://ollama.com/install.sh | sh

Docker:

docker run -d -p 11434:11434 --name ollama ollama/ollama

Pull the required models

ollama pull qwen3:14b
ollama pull aya-expanse:32b

aya-expanse:32b is a large model (~20 GB). Make sure you have sufficient disk space and RAM (or a GPU with enough VRAM).

Start the Ollama server

ollama serve

By default Ollama listens on http://localhost:11434. If you run it on a different host or port (e.g. a remote GPU server), set OLLAMA_URL in the worker's environment. For the admin "debug curl" panels, also set the ollama_url credential:

OLLAMA_URL=http://192.168.1.50:11434          # worker env
ollama_url: http://192.168.1.50:11434         # bin/rails credentials:edit

Verify Ollama is reachable

curl http://localhost:11434/api/tags

You should see a JSON list of your locally available models.


LLM backends: llmarkt (primary) + Ollama worker (fallback)

LLM work always starts as a task in the tasks table (rewrite, translate, refine, feature, tag). There are two ways a task gets executed:

  1. llmarkt (vibeearning) grid — primary, webhook-based. When llmarkt is configured, a task is submitted to the grid the moment it is enqueued, and the result is delivered back via a webhook. No worker process is needed.

     Rails app ──POST /v1/jobs──▶ llmarkt grid ──webhook──▶ POST /api/llm_callbacks ──▶ Rails app
    

    Configure it in Rails credentials:

    Credential key Purpose
    llmarkt_api_url API base incl. version, e.g. https://llmarkt.codehospital.com/api/v1
    llmarkt_api_key Bearer token
    app_base_url Public URL of this app, so llmarkt's webhook can reach it
    llmarkt_model_match family (default) or exact
    bin/rails credentials:edit
    # llmarkt_api_url: https://llmarkt.codehospital.com/api/v1
    # llmarkt_api_key: <token>
    # app_base_url: https://news.example.com

    Each submitted job's webhook_url carries a signed token encoding the task id and request key, so POST /api/llm_callbacks knows where to route the result (not the worker bearer) and needs no job-mapping table. The webhook is also verified against the grid's X-Vibe-Signature header (HMAC-SHA256 of the raw body keyed with the API key, constant-time comparison) before it's trusted — a missing or invalid signature is rejected with 401. Multi-step tasks (e.g. rewrite body → title) are run one job at a time, advancing on each callback.

    When the three required values are absent, llmarkt is disabled and tasks stay pending for the worker below — the app behaves exactly as before.

  2. Ollama worker — fallback / self-hosted. A separate worker client claims pending tasks over a protected API, runs them against Ollama, and posts the results back. Used automatically for any task llmarkt didn't take (e.g. llmarkt disabled or a submission error).

 Rails app (task queue)  <──/api/tasks──>  worker  <──/api/chat──>  Ollama

Task API (all require Authorization: Bearer <worker_api_token>):

Endpoint Purpose
GET /api/tasks/next Claim the next pending task (204 when idle)
POST /api/tasks/:id/complete Submit { "responses": { "<key>": "<text>" } }
POST /api/tasks/:id/fail Report failure { "error": "..." }

Run the worker wherever Ollama is reachable (it uses only the Ruby stdlib):

export WORKER_API_TOKEN=your-shared-secret   # must match the app's worker_api_token credential
export APP_URL=http://localhost:3000
export OLLAMA_URL=http://localhost:11434
ruby worker/worker.rb

See worker/README.md for full configuration.

Browse the queue at /admin/tasks — filter by status/kind, inspect a task's requests/responses, and retry failed tasks.

Feed sources

Every feed has a source, which decides two things: the fetcher used to pull it (FeedIngestor::FETCHER_CLASSES) and the SSRF host allowlist that fetcher enforces. /admin/feeds has a Seed … Feeds button per source that upserts that source's whole catalog (idempotent — it matches on URL, so re-seeding never duplicates or overwrites an edited feed).

Source Catalog Feeds Allowed hosts
bbc Feed::BBC_FEEDS 7 feeds.bbci.co.uk, www.bbc.co.uk, www.bbc.com
nyt Feed::NYT_FEEDS 74 rss.nytimes.com, www.nytimes.com
adhocnews Feed::ADHOCNEWS_FEEDS 12 www.ad-hoc-news.de
heise Feed::HEISE_FEEDS 22 www.heise.de, www.telepolis.de
germannews Feed::GERMANNEWS_FEEDS 50 derived from the catalog (one host per publisher)
australianews Feed::AUSTRALIANEWS_FEEDS 15 derived from the catalog
israelnews Feed::ISRAELNEWS_FEEDS 4 derived from the catalog

germannews, australianews, and israelnews are the odd ones: each is many publishers under one source (germannews alone spans tagesschau, ZEIT, SPIEGEL, FAZ, SZ, taz, 14 regional papers, and the ARD/ZDF-family public broadcasters SWR/BR/WDR/NDR), so their allowlists are computed from the catalog itself rather than hardcoded — a host is reachable only because a shipped feed lives there. For the same reason those feeds carry a site: key (the host their articles live on, which the fetcher's own host often isn't — a broadcaster's several sections all resolve to one publisher:, e.g. every NDR/WDR/tagesschau section attributes back to "NDR"/"WDR"/"tagesschau"), and the public portal attributes each story to its real publisher via Feed#publisher_info (Feed.build_publisher_index) instead of NewsHelper::SOURCE_INFO. israelnews is deliberately small: it's limited to English-language outlets, since Prompt's "translate" prompt is written and tuned specifically for English-to-Farsi (see the catalog's own comment in app/models/feed.rb for what was tried and excluded, and why).

FeedFetcher never follows redirects, so a catalog entry must be the canonical feed URL — a publisher that 301s its old feed path will otherwise come back empty rather than erroring.

Periodic work (RSS fetch + autopost)

These need no Ollama access, so they stay in the app.

RSS fetch can run on a per-feed hourly schedule, on demand, or both.

Each feed has a Scheduled fetch setting (/admin/feeds → Edit): either Disabled (the default — the feed is never auto-fetched) or an hour of the day, 00:00–23:00. Hours are server-local (the sweep compares against Time.now.hour on the machine running the app), so spreading feeds across the 24 slots staggers the load round the clock instead of hammering every source at once. The Feeds index shows each feed's slot and when it was last auto-fetched.

What actually runs the hourly sweep (FeedIngestor.run_scheduled):

  • Built in, no setup needed: BackgroundPollers runs a background thread inside the app server (bin/rails server, or the worker service in a split deployment — see Split domains) that polls every 5 minutes and fetches whatever is due.
  • Optional external cron, if you'd rather not rely on the in-process thread. Safe to run alongside it — each feed is claimed with a conditional UPDATE (feeds.last_scheduled_fetch_at), so a feed is fetched at most once per hour slot no matter how many sweeps race:
0 * * * *  cd /path/to/app && bin/rails bbc:fetch_scheduled >> log/cron.log 2>&1

To fetch everything regardless of schedule, use the admin Fetch now button, the per-feed Fetch button, or bin/rails bbc:fetch (which ignores fetch_hour and pulls every enabled feed):

*/30 * * * *  cd /path/to/app && bin/rails bbc:fetch >> log/cron.log 2>&1

Article lead images are stored on the article (articles.image_url), never looked up while a visitor's page renders. At ingestion the feed's own image (media:thumbnail / media:content / image enclosure) is saved straight away. ArticleImageResolver then upgrades it in the background to the source page's larger og:image (allow-listed hosts only), keeping the feed image when the page has none or can't be fetched, and stamps articles.image_resolved_at. It runs from a built-in poller (BackgroundPollers, every 2 minutes, 30 articles per run, newest first) and, optionally, cron. Both can run together because each article is claimed with a conditional UPDATE. Telegram/Bale posting resolves a not-yet-resolved article on the spot so it goes out with the best image. After deploying the migration, backfill existing articles once (values cached by the old render-time lookup are reused without refetching). RETRY_MISSING=1 re-tries articles that still have no image:

*/10 * * * *  cd /path/to/app && bin/rails bbc:resolve_images >> log/cron.log 2>&1

Channels can be on any Telegram-compatible provider — any messenger whose bot API answers <API base URL>/bot<token>/sendMessage with chat_id + text. At /admin/telegram_channels → New Channel, pick a Provider:

Provider Endpoint Message format
Telegram https://api.telegram.org (or your own, e.g. a self-hosted Telegram Bot API server) HTML (bold title)
Bale (bale.ai) https://tapi.bale.ai Plain text — Bale has no parse_mode
Other Telegram-compatible API required: its API base URL (without /bot<token>) You choose: plain, or HTML if it supports parse_mode: "HTML"

"Other" also needs a Provider name, which is how its channels are labelled in the admin and the Telegram admin bot. Create the bot on that messenger and add it as an admin of the channel. Every provider shares the same queue, pacing, posts log and enable/autopost toggles (all are TelegramChannel rows with a provider). Telegram is sent through telegram-bot-ruby (TelegramPoster); every other provider through plain HTTP (BotApiPoster), whose posts end with "follow @ for more" when the channel id is an @username. No env vars are needed: tokens live on the channel row.

Autoposting is per-feed: each feed has an Autopost to set of channels (/admin/feeds → Edit, or the inline checkboxes on the index row), from any provider — none by default. Once a translation for that feed completes, Autoposter.post_translation queues it for exactly those channels (no fan-out to every autopost-enabled channel). Delivery still requires each channel to be both enabled and have its own Autopost toggle on (/admin/telegram_channels) — a deliberate second gate, so a channel's autoposting can be paused without editing every feed pointed at it (Feed#autopost_channels). The Feeds index flags a checked channel in red when it isn't currently enabled/autoposting, so a misconfiguration doesn't fail silently.

Channel posting is queue-then-deliver, not instant, on every path — autopost, a completed translation task, the web admin's "📤 Post" button, and the Telegram admin bot's one-tap publish button all just queue a post (Publisher.post_to_channel); actual delivery to Telegram is paced to roughly 25-40 minutes apart per channel (Autoposter::MIN_INTERVAL/JITTER) so a channel doesn't get a burst of messages at once. What actually calls Autoposter.run_all to deliver due posts:

  • Built in, no setup needed: BackgroundPollers runs a background thread inside the app server (bin/rails server, or the worker service in a split deployment) that polls every minute. This is the primary delivery mechanism — nothing else to configure.
  • Optional external cron, if you'd rather not rely on the in-process thread (e.g. it also drives the unposted_for sweep that queues new autopost-eligible translations, independent of the task-chain autopost): bin/rails bbc:autopost. Safe to run alongside the in-process poller — delivery is claim-then-atomic-update safe against double-sending.
*/5  * * * *  cd /path/to/app && bin/rails bbc:autopost >> log/cron.log 2>&1

Telegram Admin Notification Bot

A separate Telegram bot (distinct from the per-channel publishing bots managed at /admin/telegram_channels) DMs an admin/editor chat whenever a translation finishes processing and becomes the active version — i.e. news that's ready for a human decision. The message carries inline buttons so the admin can act without opening the web admin at all:

  1. 🔁 Request rewrite
  2. 🌐 Request retranslation
  3. ✨ Request refine
  4. 📤 Publish to a channel on any provider (opens a channel-picker submenu; non-Telegram channels are suffixed with their provider, e.g. "(بله)"; with exactly one enabled channel it's a one-tap button instead)
  5. 🌍 Publish / unpublish on the news portal
  6. ✋ Mark for manual edit by editors
 Task#complete! ──sendMessage──▶ Telegram ──tap──▶ callback_query
                                     │                    │
                                     ▼                    ▼
                          admin's Telegram app   POST /api/telegram_admin/webhook
                                                          │
                                                          ▼
                                              TelegramAdminNotifier (edits the
                                              message in place + performs the
                                              action — same code paths as the
                                              equivalent admin-UI buttons)

Configure it in Rails credentials:

Credential key Purpose
telegram_admin_bot_token The admin bot's token (from @BotFather)
telegram_admin_chat_id The user/group chat id to notify
telegram_admin_webhook_secret Random secret Telegram echoes back on every webhook call, verified before trusting it
bin/rails credentials:edit
# telegram_admin_bot_token: <token>
# telegram_admin_chat_id: <chat id>
# telegram_admin_webhook_secret: <long random string>

Then register the webhook once (needs the app_base_url credential set — the same value used by llmarkt — so Telegram can reach this app):

bin/rails telegram_admin:set_webhook   # POST setWebhook, secret_token included
bin/rails telegram_admin:delete_webhook # to unregister

When telegram_admin_bot_token/telegram_admin_chat_id are absent, the notifier is a no-op — no message is sent and nothing else changes. A flagged "needs manual edit" translation shows a badge on /admin/translations and a banner on its show page (filterable via the "Needs edit" toggle), whether it was set from Telegram or from the web admin.


Public Site: SEO, Social & LLM Discovery

  • SEO: canonical + hreflang (fa/en) on every page, NewsArticle / BreadcrumbList / WebSite JSON-LD, /sitemap.xml (home, categories, stories, Google News tags for the last 2 days), dynamic /robots.txt. Search result pages are noindex.
  • Social: Open Graph + Twitter Card tags (site-icon fallback when a story has no lead image) and share buttons on article pages.
  • LLMs / readers: /llms.txt (site summary + latest articles), a Markdown twin of every story (/news/<slug>.md, /en/news/<slug>.md), and RSS feeds at /feed.xml and /en/feed.xml.

Admin Interface

All admin routes are under /admin, behind a session-based login backed by the User model (/admin/login). When the app is split across domains (admin_base_url set — see Split domains), they live at the root of admin.khabarestan.com instead (/login, /articles, …) and every /admin/... path below maps to that host.

Roles

Role Can access
admin Everything, including infrastructure/config pages and /admin/users
editor The editorial workflow: Dashboard, Articles, Rewrites, Translations, Task queue, Analytics, Telegram Posts (read-only log)

Editors are redirected away from infrastructure/config pages (Feeds, Telegram Channels, Ollama Servers, House Keeping, IP Geolocations, Users, Activity Log) — those stay admin-only. Manage accounts at /admin/users (admin-only): create an editor, change roles, reset passwords, or disable an account. A built-in safeguard blocks removing/demoting the last active admin so nobody can lock everyone out.

Edit approval (restricted editors)

Tick Edits need approval on an editor's account (/admin/users → Edit) to put their manual edits through review. When a restricted editor saves a Translation (title/body) or Rewrite (content), nothing changes on the live record: the change becomes a PendingEdit (only the changed fields, plus a snapshot of what they were editing against). Saving again before review updates the same pending edit; the edit form pre-fills it. The author can withdraw it while it is still pending.

Admins and editors without the flag review the queue at /admin/pending_edits (sidebar Pending Edits, badged with the count). Each edit shows a word-level diff (removed words struck through in red, added words in green) plus a side-by-side view. A warning appears when the live text changed after the edit was submitted. Approve & publish applies the change (the PaperTrail version is attributed to the author). Reject discards it. Both accept an optional note to the author. Restricted editors see their own edits (pending/approved/rejected, with reviewer notes) under My Edits. The flag is ignored for admins. AI actions (rerun/refine/activate/archive, analysis overrides) are not gated.

Magic links (edits from people without an account)

On an article's admin page, the Edit links section creates a secret URL (/edit/:token, valid 1/7/30 days, revocable) for that article. Anyone with the link can propose changes to the article's live Persian title/body after entering their name, email and phone and ticking a consent box. The proposal is a guest PendingEdit: it appears in /admin/pending_edits (with the guest's contact details and the link's note) and only goes live when an admin or unrestricted editor approves it. Creating/revoking links requires being an admin or unrestricted editor. Unknown/expired/revoked links show the same 404 page; the page is noindex, disallowed in robots.txt, rate-limited and has a honeypot. Each link is multiple use (default — several people may each submit until it expires or is revoked) or single use (spent by the first saved submission; it then shows as "used"). The submitter's IP address and the country/city it resolves to (cached ipwho.is lookup, shared with page-view analytics) are recorded and shown to reviewers. Guest contact details and IPs are personal data — delete old rows when no longer needed.

Forgot password?

Every user has an email (used only for this). "Forgot password?" on the login page (/admin/password_resets/new) emails a time-limited reset link (20 minutes, via Rails' built-in generates_token_for — no separate tokens table) using UserMailer. The response is identical whether or not the email matches an account, so the flow can't be used to enumerate registered users. See Outgoing mail below to configure where those emails actually get sent.

Who changed what

Every edit to a Rewrite's or Translation's text (and to Articles, Feeds, Telegram Channels, Ollama Servers, and Users) is tracked via PaperTrail: each Rewrite/ Translation show page has an Edit history panel listing every past version with who made the change and the prior text; /admin/activity_logs (admin-only) is a system-wide "who did what" log across all tracked models, filterable by model type or user. The article page (/admin/articles/:id) also has an Edit history timeline across all of that article's rewrites and translations: word diffs of each manual change plus edit-approval proposals (with reviewer and note); AI/system changes are hidden unless toggled on.

Route Description
/admin Dashboard — counts and recent activity
/admin/feeds Manage RSS feeds (admin-only)
/admin/articles Browse articles, trigger rewrite
/admin/rewrites View/edit rewrites, activate versions, edit history
/admin/translations View/edit translations, post to Telegram, flag for manual edit, edit history
/admin/telegram_channels Manage channels on any Telegram-compatible provider (provider, API URL, token, enable, autopost) (admin-only)
/admin/telegram_posts Channel posts: Scheduled tab (each channel's queue in delivery order with estimated send windows) and History tab (posted/failed posts, filter by status/channel); click a post for details
/admin/ollama_servers Manage Ollama servers and their model lists (rewrite, translate, refine, analysis) (admin-only)
/admin/tasks Task queue — status/kind filters, request/response inspector, retry
/admin/pending_edits Review queue for edits by editors flagged "Edits need approval" — diff, approve, reject (restricted editors see their own as My Edits)
/admin/skip_phrases Phrases (matched against entry title or URL, case-insensitive) that make feed ingestion skip an entry (admin-only)
/admin/users Manage admin/editor accounts (admin-only)
/admin/activity_logs System-wide audit log of who changed what (admin-only)

AI bias / fact analysis

Every newly ingested article also gets an analyze task, running in parallel with its rewrite (nothing waits for it). It scores the original article for political leaning (Left / Center / Right %, summing to 100) and for how much of it reports things that already happened vs. speculation about the future (Fact / Spec %, summing to 100), plus a short reasoning sentence. Display only: the scores never change the pipeline or autoposting.

  • Shown as badges on the articles and translations lists, the translation show/edit pages, and in the Telegram admin-bot notification.
  • Editable on the article page (AI analysis card). The card also has a Re-analyze button. Each split must add up to 100.
  • The prompt is the editable analyze prompt (/admin/prompts). It runs on the first enabled server's Analysis models (set per server at /admin/ollama_servers, like rewrite/translate/refine models). If no server has one, it falls back to the first configured rewrite model.
  • To backfill existing articles, use ⚖ Analyze unanalyzed on the articles page, the bulk Analyze action, or bin/rails bbc:analyze. This needs migration 20261015000001.

Multi-server comparison

Each article's show page has two collapsible panels — Run Rewrites on Targets and Run Translations on Targets — that display every enabled server and its configured models as checkboxes. Selecting multiple server/model combos and clicking submit creates one task per selection. All results appear on the same page so you can compare outputs and activate the best version for posting.


Outgoing Mail (Resend)

Password reset emails are the only mail this app sends, delivered through Resend's SMTP relay (smtp.resend.com) via ActionMailer's built-in :smtp delivery method — no extra gem required.

  1. Create a Resend account, verify a sending domain, and grab an API key.
  2. Set the resend_api_key and sender_email credentials (see app/services/mailer_config.rb).
  3. Set the app_base_url credential (the same value used for llmarkt webhooks) so reset links point at the right host.

Without resend_api_key/sender_email configured, perform_deliveries is off (see config/initializers/action_mailer.rb) — the forgot-password flow still runs end to end, it just doesn't actually send anything. In tests, config/environments/test.rb sets the :test delivery method, so specs assert against ActionMailer::Base.deliveries instead of hitting the network.


Error Tracking (Sentry)

Every rescue in the app — not just exceptions that go unhandled — reports to Sentry via Sentry.capture_exception, in addition to whatever the site already did (log, retry, fall back). This covers both the Rails app and the standalone worker (worker/worker.rb).

  1. Create a Sentry project and grab its DSN.
  2. Set the sentry_dsn credential (see app/services/sentry_config.rb). For the worker, set SENTRY_DSN in its own environment or worker/.env (see worker/README.md) — it does not read Rails credentials.

Without a DSN configured, Sentry.init is never called (config/initializers/sentry.rb / the worker's own inline check), and every Sentry.capture_exception call throughout the app is a documented no-op — the app behaves exactly as it did before Sentry was added.


Docker / Kamal Deployment

Production runs on PostgreSQL. The connection comes entirely from the database_url credential (Solid Cache shares the same database — no separate cache database). On boot the entrypoint runs bin/rails db:prepare, which creates the schema (app tables + solid_cache_entries) on first deploy. On later deploys it runs any pending migrations instead. Note that a fresh db:prepare loads db/schema.rb and marks every migration as run without executing it, so schema.rb must always match the migrations. Migration 20261003000003 repairs databases built while schema.rb was missing the telegram_posts unique index (it does nothing where the index already exists).

Build and run with Docker:

docker build -t khabarestan .
docker run -d -p 80:80 \
  -e RAILS_MASTER_KEY=$(cat config/master.key) \
  --name khabarestan khabarestan

RAILS_MASTER_KEY is the only value the container needs. Everything else (database_url, worker_api_token, admin_*, …) comes from the encrypted credentials baked into the image. Run bin/rails bbc:credentials (with RAILS_ENV=production) to verify nothing is missing.

The admin_* credentials only matter on the very first boot — the entrypoint runs db:seed, which creates that one admin account if the users table is empty. After that, manage accounts at /admin/users.

The web container does not need to reach Ollama. Run the worker client separately (e.g. on the GPU host) with the WORKER_API_TOKEN set to the app's worker_api_token credential and APP_URL pointed at this app.

Split domains (docker compose)

docker-compose.yml runs the same image as four services, chosen by the APP_ROLE env var. It has no reverse proxy: on Coolify, assign each domain to its service (port 80) in Coolify's domain settings and set RAILS_MASTER_KEY in the app's environment variables. On a plain server, add Caddy (automatic TLS) with the override file: docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d --build.

Domain Service (APP_ROLE) Serves
www.khabarestan.com public news portal, sitemap/feed/robots/llms, guest edit links /edit/:token; /admin/* 301-redirects to the admin host
khabarestan.com public 301-redirects to www.khabarestan.com (same path and query)
admin.khabarestan.com admin admin UI at the root (/articles, /feeds, …), /cable
api.khabarestan.com api /api/*: worker queue, llmarkt callbacks, Telegram webhook; / is a public landing page with processed-task stats (also as JSON, format=json)
worker.khabarestan.com worker the background pollers (feed fetch, Telegram autopost, article images) and their status page

It is still one monolith. Every service has every route drawn, so admin pages can link to the portal, but a route only matches on its own host and in a container whose role serves it. Anything else is a 404. /up answers everywhere. Leaving APP_ROLE unset (all) serves everything, as before. The split never applies in development, because a local server answers on localhost.

Worker status page (worker.khabarestan.com, or /worker without worker_base_url; it refreshes every 30s):

  • Anyone sees overall status, each poller's health (OK / Failing / Stalled after missing 3 intervals / Not running) and last run, plus counts: queue pending/in-flight/done/failed, oldest pending, Telegram queued/sent and articles ingested. No error messages, hostnames or record contents.
  • Signed in (any admin/editor account; the worker host has its own sign-in because sessions are per host) adds poller errors, durations, run/error counts and hosts, open tasks by kind, pending by model, stale claims, in-flight and recently failed tasks with errors, Telegram delivery errors, and process info. Task links open on the admin host.
  • status.json returns the same data for uptime monitors (details only when signed in).

Poller heartbeats are kept in Rails.cache (Solid Cache, so they're shared across containers and need no migration). bin/rails pollers:run still runs the pollers headless without a web server.

Setup:

  1. DNS: point khabarestan.com, www.khabarestan.com, admin.khabarestan.com, api.khabarestan.com and worker.khabarestan.com at the server.

  2. Credentials (bin/rails credentials:edit): set app_base_url: https://www.khabarestan.com, plus admin_base_url, api_base_url and worker_base_url (see config/credentials.example.yml). The split switches on when admin_base_url is set; without it the admin UI stays under /admin on one host.

  3. Coolify: assign www.khabarestan.com and khabarestan.com to public (Rails redirects the bare domain), admin.khabarestan.com to admin, api.khabarestan.com to api and worker.khabarestan.com to worker, all on port 80, then deploy. On a plain server, run RAILS_MASTER_KEY=$(cat config/master.key) docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d --build. The admin service boots first and runs db:prepare + db:seed; the others wait for it to be healthy.

    Coolify + a Coolify-managed Postgres: the database_url host is the database's internal container name (e.g. bhphyox7kxvmflxzk0hqw55x), which only resolves on Coolify's shared network. A Docker Compose app gets its own network, so tick Configuration → General → Advanced → Connect To Predefined Network (and keep app and database on the same server). Otherwise admin fails with PG::ConnectionBad: could not translate host name and the deploy reports it as unhealthy.

  4. Move the webhook callers to the API host, then turn the transition flag off:

    • worker client: APP_URL=https://api.khabarestan.com
    • Telegram admin bot: docker compose exec admin bin/rails telegram_admin:set_webhook
    • llmarkt callbacks pick up api_base_url automatically for new tasks (already-submitted tasks still call back to the old URL).
    • Then LEGACY_API_ON_PUBLIC=0 docker compose up -d public, which stops www.khabarestan.com/api/* from answering.

Admin sessions are host-only cookies on the admin domain, so everyone signs in again once after the switch. Domains in Caddy can be overridden with PUBLIC_DOMAIN / PUBLIC_DOMAIN_ALIAS / ADMIN_DOMAIN / API_DOMAIN / WORKER_DOMAIN.

For Kamal deployments, see .kamal/. RAILS_MASTER_KEY is the only app secret wired into config/deploy.yml / .kamal/secrets. database_url, worker_api_token and the rest live in the encrypted credentials. A managed Postgres just needs the database_url credential. To self-host Postgres on a server, see the commented accessories: db: block in config/deploy.yml.


Running Tests

bin/rails test

About

BBC News in Farsi

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages