mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
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>
115 lines
4.4 KiB
Python
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)
|