signal-bot/signal_bot/food_command.py
Krzysztof kuhy Rudnicki 77795ee6f8
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Today's food and workout logs, read from where diet_guard and screen_locker keep them
Two commands, food_today and workout_today, each printing the current
Warsaw day out of a log another program owns: every meal with its time,
calories and source plus the day's total and macros; every workout with
every field it carries. Three workout fields are withheld (a reservation
phone number, a screenshot path, the sync record id), and the food budget
is never printed -- diet_guard keeps that number on the machine on purpose.

The spellings the group uses carry a surname, so they are custom aliases in
the state directory; aliases.json ships only plain words. The suite points
both logs at absent temp files so it can never read the operator's own,
and the e2e run writes one invented entry of each under today's date.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 22:07:46 +02:00

115 lines
4.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""!co_jadł: what the operator has logged in diet_guard today.
The group asked to see the day's food log, so the day's food log is what
this prints: every entry, its time, its calories and where the number came
from, and the day's total at the top. What it deliberately does not print
is the daily budget or what is left of it -- diet_guard keeps that number
on the machine on purpose, and a chat is the opposite of on the machine.
The trigger the group actually uses has a surname in it, so it lives in
``aliases_custom.json`` outside this repository; ``aliases.json`` ships
the spellings that are just words.
"""
from __future__ import annotations
import datetime as dt
import logging
from typing import TYPE_CHECKING, Any, Final
from signal_bot.animals import load_aliases
from signal_bot.day_log import DayLogError, path_from_env, read_day
from signal_bot.stats.periods import pl_day_month
from signal_bot.stats.text import plural
if TYPE_CHECKING:
from pathlib import Path
from signal_bot.context import CommandContext
from signal_bot.decorators import Decorator
from signal_bot.registry import CommandRegistry, Handler
logger = logging.getLogger(__name__)
COMMAND_NAME: Final = "food_today"
DESCRIPTION: Final = "co dziś zjadł operator bota, prosto z dziennika diety"
ENV_VAR: Final = "FOOD_LOG_FILE"
DEFAULT_PATH: Final = "~/.local/share/diet_guard/food_log.json"
PLATE: Final = "\U0001f37d\ufe0f"
UNAVAILABLE: Final = f"{PLATE} Dziennik jedzenia jest dziś niedostępny."
# (key, label): the macros in the order a label reads them, grams each.
MACROS: Final = (("protein_g", "B"), ("carbs_g", "W"), ("fat_g", "T"))
def _number(entry: dict[str, Any], key: str) -> float:
"""A numeric field, or zero -- a bool is not a gram count."""
value = entry.get(key)
return float(value) if isinstance(value, int | float) and not isinstance(value, bool) else 0.0
def _clock(entry: dict[str, Any]) -> str:
"""The wall-clock time the entry carries, as written; diet_guard logs local time."""
try:
return dt.datetime.fromisoformat(str(entry.get("time", ""))).strftime("%H:%M")
except ValueError:
return "??:??"
def entry_line(entry: dict[str, Any]) -> str:
"""One meal: "06:37 carbonara — 550 kcal (manual)", grams first when logged."""
parts = []
grams = _number(entry, "grams")
if grams > 0:
parts.append(f"{grams:g} g")
parts.append(f"{_number(entry, 'kcal'):g} kcal")
source = str(entry.get("source") or "").strip()
tail = f" ({source})" if source else ""
return f"{_clock(entry)} {entry.get('desc', '?')} — {', '.join(parts)}{tail}"
def render(day: str, entries: list[dict[str, Any]]) -> str:
"""The day's log as one message: total first, then every entry, then macros."""
if not entries:
return f"{PLATE} {pl_day_month(day)} — nic jeszcze nie zalogowano."
count = len(entries)
kcal = sum(_number(entry, "kcal") for entry in entries)
noun = plural(count, "wpis", "wpisy", "wpisów")
head = f"{PLATE} {pl_day_month(day)} — {count} {noun}, {kcal:g} kcal"
lines = [head, *(entry_line(entry) for entry in entries)]
macros = [(label, sum(_number(entry, key) for entry in entries)) for key, label in MACROS]
if any(total > 0 for _, total in macros):
lines.append("Razem: " + " · ".join(f"{label} {total:g} g" for label, total in macros))
return "\n".join(lines)
def food_handler(path: Path) -> Handler:
"""A handler that reads the log fresh on every call; the file changes all day."""
async def handle(ctx: CommandContext) -> None:
try:
entries = read_day(path, ctx.day)
except DayLogError as error:
logger.warning("food log unavailable: %s", error)
await ctx.reply(UNAVAILABLE)
return
await ctx.reply(render(ctx.day, entries))
return handle
def register_food_command(
registry: CommandRegistry,
*,
decorate: Decorator | None = None,
aliases: dict[str, list[str]] | None = None,
path: Path | None = None,
) -> None:
"""Register !co_jadł under every spelling aliases.json gives it."""
table = load_aliases() if aliases is None else aliases
handler = food_handler(path or path_from_env(ENV_VAR, DEFAULT_PATH))
if decorate is not None:
handler = decorate(handler)
registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION)