A self-hosted FastAPI service that generates a coherent themed daily message from live APIs and can deliver it to WhatsApp groups through Evolution API.
Each daily message contains:
- 1 poem
- 1 quote
- 1 joke
- 1 historical event
- 1 curious fact
All items are matched to one controlled theme. The system is API-first, keeps only minimal local state, and uses deterministic date-based generation so the same day returns the same saved result.
- Dynamic content from PoetryDB, Quotable/Wikiquote, JokeAPI, Wikidata SPARQL, and Useless Facts.
- Small local fallback fortune file for resilience when external APIs fail.
- Fixed theme vocabulary with local keyword and synonym scoring. No LLM is used for theme extraction or item matching.
- Date-seeded generation with persisted daily outputs in JSON.
- WhatsApp text and mobile-friendly server-rendered HTML output.
- Evolution API integration with randomized delivery order, retry handling, optional group skips, and a hard 120 second minimum delay between sends.
- FastAPI endpoints for health, current daily message, and generation history.
- Docker and Docker Compose setup, including an Evolution API service stub.
The system only selects from this fixed set:
love, death, war, nature, time, religion, politics, technology, science,
philosophy, humor, absurdity, friendship, family, power, freedom, suffering,
hope, fear
Theme extraction is handled in app/theme.py by tokenizing text and scoring it against local keyword sets. This keeps matching deterministic and avoids using an LLM for classification.
- The generator receives a date in
YYYY-MM-DDformat. - It creates a deterministic RNG seed from
sha256(YYYY-MM-DD). - It picks a leader item type, fetches a leader item, and extracts the theme from that item.
- It fetches candidate items for the other content types using theme keywords where the upstream API supports it.
- It ranks candidates locally by theme score and deterministic hash ordering.
- It assembles WhatsApp text and HTML.
- It stores the result in
daily_outputs.json. Future requests for the same date return the stored result.
app/
clients/ API clients and fallback data client
config.py Environment-based settings
delivery.py WhatsApp group delivery engine
generator.py Daily generation pipeline
matching.py Theme-aware item matching and ranking
models.py Pydantic schemas
rendering.py WhatsApp and HTML rendering
scheduler.py Optional internal daily scheduler
storage.py JSON persistence
theme.py Theme vocabulary, tokenizer, scorer
web.py FastAPI application
whatsapp.py Evolution API client
data/
fortunes.json Small local fallback dataset
tests/
... Unit and route tests
- Python 3.12+
- Docker and Docker Compose, if running containerized
- Network access to the configured public APIs
- Evolution API, only if WhatsApp delivery is enabled
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env
uvicorn app.web:app --reload --env-file .envThen open:
- JSON:
http://127.0.0.1:8000/today - HTML:
http://127.0.0.1:8000/today?format=html - WhatsApp text:
http://127.0.0.1:8000/today?format=text - Health:
http://127.0.0.1:8000/health
The root path, http://127.0.0.1:8000/, redirects to the HTML daily view.
To save the generated outputs locally:
mkdir -p out
curl -fsS http://127.0.0.1:8000/today -o out/today.json
curl -fsS "http://127.0.0.1:8000/today?format=html" -o out/today.html
curl -fsS "http://127.0.0.1:8000/today?format=text" -o out/today-whatsapp.txtOpen out/today.html in your browser and use out/today-whatsapp.txt as the message body for a manual WhatsApp send.
If the browser appears to load forever, test the plain text endpoint first:
curl -v -m 45 "http://127.0.0.1:8000/today?format=text"On first generation the app may wait briefly for upstream APIs. If a source is slow, it is abandoned after SOURCE_TIMEOUT_SECONDS and the local fallback is used. The storage files are created at startup, so their presence alone does not mean daily generation completed; daily_outputs.json must contain an entry for today's date.
PoetryDB can be slow or intermittently unavailable. The poetry client intentionally uses the smallest random endpoint and lets the local fallback fill the poem slot if PoetryDB does not respond before the source timeout.
cp .env.example .env
docker compose up --buildThe application listens on http://127.0.0.1:8000 by default.
The Compose file defines:
app: the FastAPI generator and API serviceevolution-api: an Evolution API container placeholderfortune_data: persisted generated outputs and group metadataevolution_instances: Evolution API instance state
The Compose file uses environment defaults, so docker compose config works even before .env exists. For real deployments, create .env and replace placeholder secrets.
Build and publish the image to a registry reachable by your Swarm nodes:
docker build -t your-registry/fortune-motd:latest .
docker push your-registry/fortune-motd:latestUpdate the app.image value in docker-compose.yml, then deploy:
docker stack deploy -c docker-compose.yml fortune-motdUse Swarm secrets or your platform's secret manager for EVO_API_KEY in production.
Configuration is read from environment variables. .env.example contains working defaults for local use.
| Variable | Default | Purpose |
|---|---|---|
POETRYDB_BASE_URL |
https://poetrydb.org |
Poetry API base URL |
QUOTABLE_BASE_URL |
https://api.quotable.io |
Quotable API base URL |
WIKIQUOTE_API_URL |
https://en.wikiquote.org/w/api.php |
Wikiquote MediaWiki API URL |
JOKEAPI_BASE_URL |
https://v2.jokeapi.dev |
JokeAPI base URL |
WIKIDATA_SPARQL_URL |
https://query.wikidata.org/sparql |
Wikidata SPARQL endpoint |
USELESS_FACTS_BASE_URL |
https://uselessfacts.jsph.pl |
Useless Facts API base URL |
REQUEST_TIMEOUT_SECONDS |
5 |
Timeout per external API request |
REQUEST_RETRIES |
1 |
Retry count for external API calls |
SOURCE_TIMEOUT_SECONDS |
8 |
Hard maximum time spent on one content source before fallback |
LANGUAGES |
en,fr,es |
Preferred language list; current upstream support is source-dependent |
TIMEZONE |
Europe/Madrid |
Time zone used by /today and the scheduler |
SCHEDULE_TIME |
08:00 |
Daily scheduler run time |
ENABLE_SCHEDULER |
false |
Enables the internal daily scheduler |
STORAGE_DIR |
data/storage locally; Docker forces /data |
Runtime storage directory |
FALLBACK_FORTUNES_PATH |
data/fortunes.json |
Small local fallback dataset |
WHATSAPP_ENABLED |
false |
Enables WhatsApp delivery |
WHATSAPP_SKIP_PROBABILITY |
0.05 |
Optional behavioral skip probability per group |
WHATSAPP_MIN_DELAY_SECONDS |
120 |
Hard minimum delay between group sends |
WHATSAPP_MAX_DELAY_SECONDS |
300 |
Maximum randomized delay between group sends |
WHATSAPP_MAX_ATTEMPTS |
2 |
Send attempts per group |
MAX_ITEM_CHARS |
1200 |
Global maximum stored text length for any item |
MAX_POEM_CHARS |
900 |
Maximum poem text length |
MAX_QUOTE_CHARS |
350 |
Maximum quote text length |
MAX_JOKE_CHARS |
500 |
Maximum joke text length |
MAX_EVENT_CHARS |
450 |
Maximum historical event text length |
MAX_FACT_CHARS |
320 |
Maximum curious fact text length |
MAX_POEM_LINES |
16 |
Maximum poem lines kept from PoetryDB responses |
MAX_WHATSAPP_CHARS |
3000 |
Final maximum WhatsApp message length |
EVO_BASE_URL |
empty locally, http://evolution-api:8080 in Compose |
Evolution API base URL |
EVO_API_KEY |
empty locally, change-me in Compose |
Evolution API key |
EVO_INSTANCE |
empty locally, default in Compose |
Evolution API instance name |
WHATSAPP_MIN_DELAY_SECONDS must be at least 120. The app will fail startup if a lower value is configured.
The app uses JSON files for minimal persistence:
STORAGE_DIR/
daily_outputs.json
group_store.json
history.json
daily_outputs.json stores generated daily messages keyed by date. history.json stores seed hashes used for history metadata. group_store.json stores WhatsApp group delivery state.
Runtime storage is ignored by Git. In Docker, it is mounted through the fortune_data volume.
Returns service status and basic configuration flags:
{
"ok": true,
"storage_dir": "data/storage",
"whatsapp_enabled": false,
"scheduler_enabled": false
}Returns the generated daily output as JSON. The response includes the normalized item schema for each type, the rendered WhatsApp text, and the rendered HTML string.
Returns the same daily output as a server-rendered HTML page.
Returns the final WhatsApp-ready text message as text/plain.
Returns compact history metadata for generated days. Use ?limit=30 to adjust the result count up to 365.
All API clients normalize upstream responses into this shape:
{
"id": "string",
"type": "poem|quote|joke|event|fact",
"language": "en|fr|es|unknown",
"title": "string|null",
"author": "string|null",
"year": "integer|null",
"date": "string|null",
"content": "string",
"tags": ["string"],
"source": "string"
}WhatsApp delivery is disabled by default. To enable it:
- Configure and authenticate Evolution API.
- Set
WHATSAPP_ENABLED=true. - Set
EVO_BASE_URL,EVO_API_KEY, andEVO_INSTANCE. - Add group entries to
${STORAGE_DIR}/group_store.json.
Example group_store.json:
{
"groups": [
{
"id": "120363XXXX@g.us",
"name": "Group Name",
"timezone": "Europe/Madrid",
"enabled": true,
"last_sent": null
}
]
}The sender posts to:
POST /message/sendText/{instance}
with:
{
"number": "<group_id>",
"text": "<message>"
}Delivery behavior:
- Enabled groups are shuffled deterministically per day.
- Each group may set an IANA timezone such as
Europe/MadridorAmerica/New_York. - A group is sent only when its local time is at or after
SCHEDULE_TIME. - Groups already sent for the same day are skipped.
- About 5% of groups are skipped by default through
WHATSAPP_SKIP_PROBABILITY. - Failed sends are retried up to
WHATSAPP_MAX_ATTEMPTS. - Failures are logged and do not stop the loop.
- The engine waits a random delay between
WHATSAPP_MIN_DELAY_SECONDSandWHATSAPP_MAX_DELAY_SECONDSafter each attempted send.
Important: This project implements conservative pacing controls, but no automation can guarantee avoidance of platform enforcement. Use responsibly and comply with WhatsApp and Evolution API policies.
You can run generation with either external cron or the internal scheduler.
Internal scheduler:
ENABLE_SCHEDULER=true
SCHEDULE_TIME=08:00
TIMEZONE=Europe/MadridWhen enabled, the scheduler generates the daily output at the configured local time and runs WhatsApp delivery if WHATSAPP_ENABLED=true.
External cron example:
0 8 * * * curl -fsS http://127.0.0.1:8000/today >/dev/nullIf you use external cron for WhatsApp delivery, wire it through application code or add a small command entrypoint that calls DailyGenerator and DeliveryEngine.
The WhatsApp text follows this format:
Theme of the Day: Hope
π Poem
...
π¬ Quote
...
π Joke
...
π
On this day
...
π§ Fact
...
The HTML page uses the same stored output, so JSON, WhatsApp text, and HTML stay consistent for a given date.
Install development dependencies:
pip install -e ".[dev]"Run tests:
pytestRun a syntax/import check:
python -m compileall appValidate Compose:
docker compose configBuild the image:
docker compose build appSmoke test the built image:
docker run --rm -d -p 8001:8000 --name fortune-motd-smoke fortune-motd:latest
curl -fsS http://127.0.0.1:8001/health
docker stop fortune-motd-smoke- Poetry: PoetryDB
- Quotes: Quotable, with Wikiquote fallback
- Jokes: JokeAPI
- Historical events: Wikidata SPARQL
- Curious facts: Useless Facts API
- Fallback: small local
data/fortunes.json
Respect each upstream service's terms, rate limits, and attribution requirements for your deployment.
The fallback dataset intentionally contains one small item for every supported theme and content type. If an upstream source fails or times out, the generator can still produce a complete WhatsApp-ready message without keeping a large local corpus.
- Keep
STORAGE_DIRon persistent storage. - Keep
.envout of version control. - Replace
EVO_API_KEY=change-mebefore enabling WhatsApp. - Prefer platform secrets for credentials in Swarm or hosted deployments.
- Monitor logs for API failures and WhatsApp send failures.
- Consider adding a reverse proxy and TLS if exposing the web service publicly.
- Keep scheduler replicas to
1, or use an external scheduler, to avoid duplicate sends.
This project is licensed under the Apache License 2.0. See LICENSE.