Grocery Bot: Meal Planning on GitHub Actions
Live
Weekly meal-planning and grocery-list automation with zero hosting cost — the whole
thing is two scheduled GitHub Actions jobs and a Discord channel. Sunday morning
(cron: "0 13 * * 0", 9am ET ignoring DST), one workflow posts the week's
meal options from config/meals.yaml and pre-adds one reaction emoji per
meal; the family votes by reacting during the day; Sunday evening
(0 23 * * 0), a second workflow reads the reactions back and posts the
aggregated grocery list. Both workflows also expose workflow_dispatch
for manual runs, and both authenticate through two repo secrets:
DISCORD_BOT_TOKEN and DISCORD_CHANNEL_ID.
The message ID is the only state
GitHub Actions can't hold a socket open waiting for replies, so there's no live bot
process — reactions on a single Discord message are the persistence layer. The
morning script writes the posted message's ID to
data/last_message_id.json, and a follow-up workflow step commits it back
to the repo as github-actions[bot] (this is the one workflow that needs
permissions: contents: write; the evening one runs read-only). Ordering
is deliberate: the ID is persisted before the reaction-add loop, so if a
reaction fails partway through, the evening run can still find the message. Each
reaction add is individually wrapped in a try/except that logs and
continues — one bad emoji doesn't kill the post.
Votes, aggregation, dedupe
The evening script builds an emoji-to-meal dict from the YAML and walks the
message's reactions array. A meal counts as picked when
reaction["count"] > 1 — the bot's own pre-added reaction is the 1, so
any real vote tips it over. Reactions that don't map to a known meal emoji fall out
of the dict lookup and are silently ignored. Ingredients from all picked meals are
then folded into a dict keyed on ingredient.strip().lower(), using
setdefault so the first-seen casing wins and insertion order is
preserved — pick three rice dishes and "2 cups rice" appears once. It's exact-string
dedupe, not quantity summing: two meals that each need "1 lb ground beef" collapse
to a single line, a known tradeoff of keeping ingredients as plain strings.
Rate limits and failure modes
Discord is driven through a ~40-line REST wrapper (discord_api.py,
API v10) over a requests.Session — no gateway connection, no privileged
intents. On a 429 it sleeps for the retry_after value from the response
body (defaulting to 1s) and retries exactly once before
raise_for_status(); reaction adds additionally sleep 0.3s each, since
Discord rate-limits back-to-back reactions aggressively. Edge cases are handled
explicitly: no votes posts "No meals were picked this week" instead of an empty
list; a deleted prompt message (404 on fetch) posts a heads-up to the channel and
exits cleanly rather than crashing the job; a missing state file exits with a
message pointing at the morning workflow. Meals stay in a hand-maintained YAML pool
(ten batch-cook recipes) rather than a recipe API — exact quantities, no rate
limits, no extra keys.