grocery-bot

Log | Files | Refs | README

commit 6939121b7a20d994abe76e7d72c25ef34a6ce83c
parent c4e3cd3c37ee163f0be48a8290ef8cbcf334f313
Author: Chris Roberts <chris.roberts@learningunix.net>
Date:   Fri,  3 Jul 2026 10:38:35 -0500

Add recipe detail views, tagging, and grocery-list grouping

- Redesign /addrecipe as a two-step conversation (name, then paste) so
  the recipe name can never collide with pasted ingredient text
- Make parse_recipe_text tolerant of real copy-pasted recipes: no
  required "Name:" line, colon-optional section headers, freeform
  ingredient lines alongside the structured "qty | unit | name |
  category" format
- Add /recipe, /instructions (show/add/delete), and make /recipes fall
  back from exact name -> keto -> tag -> substring search
- Add a tags/groups system (/tag, /tags) backed by new tags and
  recipe_tags tables, so recipes can be manually classified independent
  of their name
- Group /grocerylist output by ingredient category, with uncategorized
  items landing in one plain leftover section instead of guessed at
- Fix a crash where any command sent via message edit (update.message
  is None, update.edited_message is set) took down the handler with no
  reply; switch to update.effective_message throughout and register a
  global error handler so future bugs are visible instead of silent
- Add install.sh: stages a full copy, runs the test suite against it,
  and only touches the live deployment/service if tests pass
- Bump python-telegram-bot to v22+ (v20.x is broken on Python 3.14)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Diffstat:
M.gitignore | 1+
Manton-bot.service | 18++++++++++++++++--
Mbot.py | 496++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mdb.py | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainstall.sh | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmodels.py | 15+++++++++++++++
Mparsing.py | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mtests/test_parsing.py | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
8 files changed, 821 insertions(+), 87 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .venv/ venv/ .pytest_cache/ +bot.log diff --git a/anton-bot.service b/anton-bot.service @@ -1,3 +1,19 @@ +# Setup steps on the target LXC/VM (per SPEC.md), before enabling this unit: +# 1. Create a dedicated user to run the bot, e.g.: useradd -r -s /usr/sbin/nologin anton +# 2. Copy this repo to the path used below (default assumes /opt/anton). +# 3. Inside that path: python3 -m venv venv && venv/bin/pip install -r requirements.txt +# 4. Copy .env there (TELEGRAM_BOT_TOKEN, ANTON_DB_PATH) -- chmod 600, owned by the +# user above. Never commit .env to git (it's in .gitignore). +# 5. chown -R the deploy user over the whole directory (code, venv, .env, and the +# SQLite db file once it's created) so the service can read/write everything. +# 6. Copy this file to /etc/systemd/system/anton-bot.service, then: +# systemctl daemon-reload && systemctl enable --now anton-bot +# 7. Check it came up: systemctl status anton-bot && journalctl -u anton-bot -f +# +# NOTE: User/WorkingDirectory/EnvironmentFile/ExecStart below are placeholders -- +# this hasn't been matched against your existing homelab unit conventions +# (e.g. the MakeMKV LXC setup CLAUDE.md references). Adjust paths/user to match. + [Unit] Description=Anton Recipe & Grocery Telegram Bot After=network-online.target @@ -5,8 +21,6 @@ Wants=network-online.target [Service] Type=simple -# Placeholders below -- adjust to match your actual LXC layout. Not -# verified against your existing homelab systemd unit conventions. User=anton WorkingDirectory=/opt/anton EnvironmentFile=/opt/anton/.env diff --git a/bot.py b/bot.py @@ -7,7 +7,14 @@ import random from dotenv import load_dotenv from telegram import Update -from telegram.ext import Application, CommandHandler, ContextTypes +from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) import db import parsing @@ -20,18 +27,26 @@ TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -ADDRECIPE_HELP = ( - "Paste a recipe after /addrecipe, e.g.:\n\n" - "Name: Beef Chili\n" - "Servings: 4\n" - "Source: https://example.com\n" - "Keto: yes\n" - "Ingredients:\n" - "1 | lb | ground beef | meat\n" - "1 | cup | diced tomatoes | pantry\n" - "Instructions:\n" - "Brown the beef, add tomatoes, simmer.\n\n" - "Keto: is optional -- omit it to auto-suggest from ingredients." +ADDRECIPE_NAME, ADDRECIPE_BODY = range(2) +INSTRUCTIONS_ADD_WAITING = 1 + +ADDRECIPE_BODY_PROMPT = ( + "Got it. Now paste the ingredients as one message -- straight from a " + "recipe site/blog is fine, e.g.:\n\n" + "2 medium yellow onions, finely chopped\n" + "1 lb ground beef\n\n" + "For precise quantities you can also use " + "'<quantity> | <unit> | <name> | <category>' per ingredient line.\n" + "Optional lines anywhere: 'Servings: 4', 'Source: ...', 'Keto: yes'.\n" + "Add instructions afterward with /instructions add <name>.\n" + "/cancel to stop." +) + +INSTRUCTIONS_ADD_PROMPT = ( + "Paste the instructions now. Long instructions can be split across " + "multiple messages -- just keep sending them, then send /done when " + "you've sent it all. This replaces any instructions already saved for " + "this recipe. /cancel to stop." ) @@ -41,17 +56,27 @@ def _conn(): return conn -async def addrecipe_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - _, _, body = update.message.text.partition("\n") - if not body.strip(): - await update.message.reply_text(ADDRECIPE_HELP) - return +async def addrecipe_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + await update.effective_message.reply_text( + "What's the recipe called? (/cancel to stop)" + ) + return ADDRECIPE_NAME + + +async def addrecipe_receive_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["addrecipe_name"] = update.effective_message.text.strip() + await update.effective_message.reply_text(ADDRECIPE_BODY_PROMPT) + return ADDRECIPE_BODY + + +async def addrecipe_receive_body(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + name = context.user_data["addrecipe_name"] try: - parsed = parsing.parse_recipe_text(body) + parsed = parsing.parse_recipe_text(name, update.effective_message.text) except ValueError as exc: - await update.message.reply_text(f"Couldn't parse that recipe: {exc}") - return + await update.effective_message.reply_text(f"Couldn't parse that: {exc}. Try again, or /cancel.") + return ADDRECIPE_BODY conn = _conn() try: @@ -75,27 +100,335 @@ async def addrecipe_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N finally: conn.close() + context.user_data.pop("addrecipe_name", None) keto_note = "keto" if parsed["is_keto"] else "not flagged keto" - await update.message.reply_text( + await update.effective_message.reply_text( f"Added '{parsed['name']}' with {len(parsed['ingredients'])} " f"ingredient(s) ({keto_note})." ) + return ConversationHandler.END + + +async def addrecipe_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data.pop("addrecipe_name", None) + await update.effective_message.reply_text("Cancelled.") + return ConversationHandler.END + + +def _format_recipe_ingredients(recipe, ingredients) -> str: + lines = [f"{recipe['name']} ({'keto' if recipe['is_keto'] else 'not keto'})"] + if recipe["servings"]: + lines.append(f"Servings: {recipe['servings']}") + if recipe["source"]: + lines.append(f"Source: {recipe['source']}") + + lines.append("") + lines.append("Ingredients:") + for ing in ingredients: + parts = [p for p in (ing["quantity"], ing["unit"], ing["name"]) if p] + lines.append(f"- {' '.join(parts)}") + + return "\n".join(lines) + + +async def _reply_recipe_ingredients(update: Update, conn, recipe) -> None: + ingredients = db.list_ingredients_for_recipe(conn, recipe["id"]) + await update.effective_message.reply_text(_format_recipe_ingredients(recipe, ingredients)) async def recipes_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - tag = context.args[0] if context.args else None + _, _, rest = update.effective_message.text.partition(" ") + rest = rest.strip() + conn = _conn() try: - rows = db.list_recipes(conn, tag=tag) + if not rest: + rows = db.list_recipes(conn) + else: + # /recipes and /recipe are one letter apart and easy to mix up -- + # if the argument exactly matches a recipe name, show that + # recipe's ingredients rather than silently treating it as an + # unrecognized tag. + recipe = db.get_recipe_by_name(conn, rest) + if recipe: + await _reply_recipe_ingredients(update, conn, recipe) + return + + if rest.lower() == "keto": + rows = db.list_recipes(conn, tag="keto") + else: + # Not an exact name or "keto" -- try a manually-assigned + # group next (e.g. "/recipes slowcooker"), then fall back to + # a substring search over recipe names (e.g. "/recipes + # chicken" finds anything with "chicken" in the name). + rows = db.list_recipes_by_tag(conn, rest) + if not rows: + rows = db.search_recipes_by_name(conn, rest) finally: conn.close() if not rows: - await update.message.reply_text("No recipes found.") + message = f"No recipes found matching '{rest}'." if rest else "No recipes found." + await update.effective_message.reply_text(message) return lines = [f"- {r['name']} ({'keto' if r['is_keto'] else 'not keto'})" for r in rows] - await update.message.reply_text("\n".join(lines)) + await update.effective_message.reply_text("\n".join(lines)) + + +async def recipe_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + _, _, name = update.effective_message.text.partition(" ") + name = name.strip() + if not name: + await update.effective_message.reply_text("Usage: /recipe <name>") + return + + conn = _conn() + try: + recipe = db.get_recipe_by_name(conn, name) + if not recipe: + await update.effective_message.reply_text(f"No recipe found named '{name}'.") + return + await _reply_recipe_ingredients(update, conn, recipe) + finally: + conn.close() + + +TAG_USAGE = ( + "Usage:\n" + "/tag <group> - list recipes tagged with a group\n" + "/tag add <group> - create a group\n" + "/tag add <recipe name>, <group> - tag a recipe with a group\n" + "/tag remove <group> - delete a group entirely (untags everything)\n" + "/tag remove <recipe name>, <group> - untag one recipe" +) + + +async def _tag_add(update: Update, arg: str) -> None: + if not arg: + await update.effective_message.reply_text(TAG_USAGE) + return + + conn = _conn() + try: + if "," in arg: + recipe_name, _, tag_name = arg.partition(",") + recipe_name = recipe_name.strip() + tag_name = tag_name.strip() + if not recipe_name or not tag_name: + await update.effective_message.reply_text(TAG_USAGE) + return + recipe = db.get_recipe_by_name(conn, recipe_name) + if not recipe: + await update.effective_message.reply_text(f"No recipe found named '{recipe_name}'.") + return + tag = db.get_or_create_tag(conn, tag_name) + db.assign_tag_to_recipe(conn, recipe["id"], tag["id"]) + await update.effective_message.reply_text(f"Tagged '{recipe['name']}' with '{tag['name']}'.") + else: + tag_name = arg + already_existed = db.get_tag_by_name(conn, tag_name) is not None + tag = db.get_or_create_tag(conn, tag_name) + if already_existed: + await update.effective_message.reply_text(f"Group '{tag['name']}' already exists.") + else: + await update.effective_message.reply_text(f"Created group '{tag['name']}'.") + finally: + conn.close() + + +async def _tag_remove(update: Update, arg: str) -> None: + if not arg: + await update.effective_message.reply_text(TAG_USAGE) + return + + conn = _conn() + try: + if "," in arg: + recipe_name, _, tag_name = arg.partition(",") + recipe_name = recipe_name.strip() + tag_name = tag_name.strip() + if not recipe_name or not tag_name: + await update.effective_message.reply_text(TAG_USAGE) + return + recipe = db.get_recipe_by_name(conn, recipe_name) + if not recipe: + await update.effective_message.reply_text(f"No recipe found named '{recipe_name}'.") + return + tag = db.get_tag_by_name(conn, tag_name) + if not tag: + await update.effective_message.reply_text(f"No group named '{tag_name}'.") + return + db.remove_tag_from_recipe(conn, recipe["id"], tag["id"]) + await update.effective_message.reply_text(f"Untagged '{recipe['name']}' from '{tag['name']}'.") + else: + tag_name = arg + tag = db.get_tag_by_name(conn, tag_name) + if not tag: + await update.effective_message.reply_text(f"No group named '{tag_name}'.") + return + count = db.count_recipes_for_tag(conn, tag["id"]) + db.delete_tag(conn, tag["id"]) + await update.effective_message.reply_text( + f"Deleted group '{tag['name']}' (was tagged on {count} recipe(s))." + ) + finally: + conn.close() + + +async def _tag_show(update: Update, tag_name: str) -> None: + conn = _conn() + try: + tag = db.get_tag_by_name(conn, tag_name) + if not tag: + await update.effective_message.reply_text(f"No group named '{tag_name}'.") + return + rows = db.list_recipes_by_tag(conn, tag_name) + finally: + conn.close() + + if not rows: + await update.effective_message.reply_text(f"No recipes tagged '{tag['name']}' yet.") + return + + lines = [f"- {r['name']} ({'keto' if r['is_keto'] else 'not keto'})" for r in rows] + await update.effective_message.reply_text("\n".join(lines)) + + +async def tag_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + _, _, rest = update.effective_message.text.partition(" ") + rest = rest.strip() + action, _, arg = rest.partition(" ") + arg = arg.strip() + + if action.lower() == "add": + await _tag_add(update, arg) + elif action.lower() == "remove": + await _tag_remove(update, arg) + elif rest: + await _tag_show(update, rest) + else: + await update.effective_message.reply_text(TAG_USAGE) + + +async def tags_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + conn = _conn() + try: + rows = db.list_tags(conn) + finally: + conn.close() + + if not rows: + await update.effective_message.reply_text("No groups yet. Create one with /tag add <group>.") + return + + lines = [f"- {r['name']} ({r['recipe_count']} recipe(s))" for r in rows] + await update.effective_message.reply_text("\n".join(lines)) + + +async def _show_instructions(update: Update, name: str) -> None: + if not name: + await update.effective_message.reply_text( + "Usage: /instructions <name> | /instructions add <name> | " + "/instructions delete <name>" + ) + return + + conn = _conn() + try: + recipe = db.get_recipe_by_name(conn, name) + finally: + conn.close() + + if not recipe: + await update.effective_message.reply_text(f"No recipe found named '{name}'.") + return + + if not recipe["instructions"]: + await update.effective_message.reply_text(f"No instructions saved for '{recipe['name']}'.") + return + + await update.effective_message.reply_text(f"{recipe['name']}\n\n{recipe['instructions']}") + + +async def _delete_instructions(update: Update, name: str) -> None: + if not name: + await update.effective_message.reply_text("Usage: /instructions delete <name>") + return + + conn = _conn() + try: + recipe = db.get_recipe_by_name(conn, name) + if not recipe: + await update.effective_message.reply_text(f"No recipe found named '{name}'.") + return + db.update_recipe_instructions(conn, recipe["id"], "") + finally: + conn.close() + + await update.effective_message.reply_text(f"Cleared instructions for '{recipe['name']}'.") + + +async def instructions_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + _, _, rest = update.effective_message.text.partition(" ") + rest = rest.strip() + subcommand, _, name = rest.partition(" ") + name = name.strip() + + if subcommand.lower() == "add": + if not name: + await update.effective_message.reply_text("Usage: /instructions add <name>") + return ConversationHandler.END + context.user_data["instructions_name"] = name + context.user_data["instructions_parts"] = [] + await update.effective_message.reply_text(INSTRUCTIONS_ADD_PROMPT) + return INSTRUCTIONS_ADD_WAITING + + if subcommand.lower() in ("delete", "remove"): + await _delete_instructions(update, name) + return ConversationHandler.END + + await _show_instructions(update, rest) + return ConversationHandler.END + + +async def instructions_add_chunk(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data.setdefault("instructions_parts", []).append(update.effective_message.text) + await update.effective_message.reply_text("Got that part. Send more, or /done when finished.") + return INSTRUCTIONS_ADD_WAITING + + +async def instructions_add_finish(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + name = context.user_data["instructions_name"] + parts = context.user_data.get("instructions_parts", []) + if not parts: + await update.effective_message.reply_text( + "Nothing pasted yet -- send the instructions text first, or /cancel." + ) + return INSTRUCTIONS_ADD_WAITING + + text = "\n".join(parts) + conn = _conn() + try: + recipe = db.get_recipe_by_name(conn, name) + if not recipe: + await update.effective_message.reply_text(f"No recipe found named '{name}'.") + else: + db.update_recipe_instructions(conn, recipe["id"], text) + await update.effective_message.reply_text(f"Instructions saved for '{recipe['name']}'.") + finally: + conn.close() + + context.user_data.pop("instructions_name", None) + context.user_data.pop("instructions_parts", None) + return ConversationHandler.END + + +async def instructions_add_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data.pop("instructions_name", None) + context.user_data.pop("instructions_parts", None) + await update.effective_message.reply_text("Cancelled.") + return ConversationHandler.END async def staples_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -104,19 +437,19 @@ async def staples_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non if context.args and context.args[0].lower() == "add": name = " ".join(context.args[1:]).strip() if not name: - await update.message.reply_text("Usage: /staples add <item>") + await update.effective_message.reply_text("Usage: /staples add <item>") return db.add_staple(conn, name=name, default_quantity=None, unit=None, category=None) - await update.message.reply_text(f"Added staple: {name}") + await update.effective_message.reply_text(f"Added staple: {name}") return if context.args and context.args[0].lower() == "remove": name = " ".join(context.args[1:]).strip() if not name: - await update.message.reply_text("Usage: /staples remove <item>") + await update.effective_message.reply_text("Usage: /staples remove <item>") return db.remove_staple(conn, name) - await update.message.reply_text(f"Removed staple: {name}") + await update.effective_message.reply_text(f"Removed staple: {name}") return rows = db.list_staples(conn) @@ -124,15 +457,15 @@ async def staples_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non conn.close() if not rows: - await update.message.reply_text("No staples set.") + await update.effective_message.reply_text("No staples set.") return lines = [f"- {r['name']}" for r in rows] - await update.message.reply_text("\n".join(lines)) + await update.effective_message.reply_text("\n".join(lines)) async def grocerylist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - _, _, rest = update.message.text.partition(" ") + _, _, rest = update.effective_message.text.partition(" ") recipe_names = [n.strip() for n in rest.split(",") if n.strip()] conn = _conn() @@ -168,18 +501,26 @@ async def grocerylist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> finally: conn.close() - lines = [f"- {(item.quantity or '')} {(item.unit or '')} {item.name}".strip() for item in merged] - reply = "\n".join(lines) if lines else "Nothing to list." + if not merged: + reply = "Nothing to list." + else: + sections = [] + for category, group_items in parsing.group_by_category(merged): + header = category if category else "Uncategorized" + lines = [f"- {(item.quantity or '')} {(item.unit or '')} {item.name}".strip() for item in group_items] + sections.append(f"{header}:\n" + "\n".join(lines)) + reply = "\n\n".join(sections) + if not_found: reply += f"\n\n(Recipe(s) not found: {', '.join(not_found)})" - await update.message.reply_text(reply) + await update.effective_message.reply_text(reply) async def add_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - _, _, item_name = update.message.text.partition(" ") + _, _, item_name = update.effective_message.text.partition(" ") item_name = item_name.strip() if not item_name: - await update.message.reply_text("Usage: /add <item>") + await update.effective_message.reply_text("Usage: /add <item>") return conn = _conn() @@ -190,7 +531,7 @@ async def add_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: ) finally: conn.close() - await update.message.reply_text(f"Added '{item_name}' to the grocery list.") + await update.effective_message.reply_text(f"Added '{item_name}' to the grocery list.") async def clearlist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -199,7 +540,7 @@ async def clearlist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N db.clear_grocery_list(conn) finally: conn.close() - await update.message.reply_text("Grocery list cleared.") + await update.effective_message.reply_text("Grocery list cleared.") async def random_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -211,11 +552,53 @@ async def random_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None conn.close() if not rows: - await update.message.reply_text("No recipes to choose from.") + await update.effective_message.reply_text("No recipes to choose from.") return choice = random.choice(rows) - await update.message.reply_text(f"How about: {choice['name']}?") + await update.effective_message.reply_text(f"How about: {choice['name']}?") + + +HELP_TEXT = ( + "Commands:\n" + "/addrecipe - add a recipe (asks for a name, then the ingredients)\n" + "/recipes [tag/group/keyword] - list recipes; 'keto' filters by tag, a\n" + " manually-created group matches next (e.g. /recipes slowcooker), then\n" + " any other word searches recipe names (e.g. /recipes chicken); an exact\n" + " name shows that recipe's ingredients\n" + "/recipe <name> - show one recipe's ingredients\n" + "/tag <group> - list recipes tagged with a group (e.g. /tag beef)\n" + "/tag add <group> - create a group (e.g. /tag add slowcooker)\n" + "/tag add <recipe name>, <group> - tag a recipe with a group\n" + "/tag remove <group> - delete a group entirely (untags everything)\n" + "/tag remove <recipe name>, <group> - untag one recipe\n" + "/tags - list all groups and how many recipes are in each\n" + "/instructions <name> - show one recipe's instructions\n" + "/instructions add <name> - paste instructions for a recipe (replaces any\n" + " existing instructions); long text can span multiple messages, send\n" + " /done when finished\n" + "/instructions delete <name> - clear a recipe's instructions\n" + "/staples - show the staples list\n" + "/staples add <item> - add an item to staples\n" + "/staples remove <item> - remove an item from staples\n" + "/grocerylist [recipe1, recipe2, ...] - build a merged grocery list\n" + "/add <item> - freeform add to the current grocery list\n" + "/clearlist - reset the working grocery list\n" + "/random [tag] - suggest a random recipe, optionally filtered\n" + "/help - show this message" +) + + +async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + await update.effective_message.reply_text(HELP_TEXT) + + +async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + logger.error("Unhandled exception while processing an update", exc_info=context.error) + if isinstance(update, Update) and update.effective_message: + await update.effective_message.reply_text( + "Something went wrong handling that -- check the bot's logs." + ) def main() -> None: @@ -223,13 +606,40 @@ def main() -> None: conn.close() app = Application.builder().token(TOKEN).build() - app.add_handler(CommandHandler("addrecipe", addrecipe_cmd)) + + addrecipe_conv = ConversationHandler( + entry_points=[CommandHandler("addrecipe", addrecipe_start)], + states={ + ADDRECIPE_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, addrecipe_receive_name)], + ADDRECIPE_BODY: [MessageHandler(filters.TEXT & ~filters.COMMAND, addrecipe_receive_body)], + }, + fallbacks=[CommandHandler("cancel", addrecipe_cancel)], + ) + + instructions_conv = ConversationHandler( + entry_points=[CommandHandler("instructions", instructions_cmd)], + states={ + INSTRUCTIONS_ADD_WAITING: [ + CommandHandler("done", instructions_add_finish), + MessageHandler(filters.TEXT & ~filters.COMMAND, instructions_add_chunk), + ], + }, + fallbacks=[CommandHandler("cancel", instructions_add_cancel)], + ) + + app.add_handler(addrecipe_conv) + app.add_handler(instructions_conv) app.add_handler(CommandHandler("recipes", recipes_cmd)) + app.add_handler(CommandHandler("recipe", recipe_cmd)) + app.add_handler(CommandHandler("tag", tag_cmd)) + app.add_handler(CommandHandler("tags", tags_cmd)) app.add_handler(CommandHandler("staples", staples_cmd)) app.add_handler(CommandHandler("grocerylist", grocerylist_cmd)) app.add_handler(CommandHandler("add", add_cmd)) app.add_handler(CommandHandler("clearlist", clearlist_cmd)) app.add_handler(CommandHandler("random", random_cmd)) + app.add_handler(CommandHandler("help", help_cmd)) + app.add_error_handler(error_handler) logger.info("Starting bot (long polling)...") app.run_polling() diff --git a/db.py b/db.py @@ -26,18 +26,94 @@ def get_recipe_by_name(conn, name: str) -> sqlite3.Row | None: ).fetchone() +def update_recipe_instructions(conn, recipe_id: int, instructions: str) -> None: + conn.execute( + "UPDATE recipes SET instructions = ? WHERE id = ?", (instructions, recipe_id) + ) + conn.commit() + + def list_recipes(conn, tag: str | None = None) -> list[sqlite3.Row]: if tag == "keto": return conn.execute("SELECT * FROM recipes WHERE is_keto = 1").fetchall() return conn.execute("SELECT * FROM recipes").fetchall() +def search_recipes_by_name(conn, term: str) -> list[sqlite3.Row]: + return conn.execute( + "SELECT * FROM recipes WHERE name LIKE ? COLLATE NOCASE", (f"%{term}%",) + ).fetchall() + + def delete_recipe(conn, recipe_id: int) -> None: # Ingredients cascade-delete automatically (see models.py schema / PRAGMA). conn.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,)) conn.commit() +# --- tags / groups --- + +def get_tag_by_name(conn, name: str) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM tags WHERE name = ? COLLATE NOCASE", (name,) + ).fetchone() + + +def get_or_create_tag(conn, name: str) -> sqlite3.Row: + conn.execute( + "INSERT INTO tags (name) VALUES (?) ON CONFLICT(name) DO NOTHING", (name,) + ) + conn.commit() + return get_tag_by_name(conn, name) + + +def delete_tag(conn, tag_id: int) -> None: + # recipe_tags rows cascade-delete automatically (see models.py schema). + conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,)) + conn.commit() + + +def count_recipes_for_tag(conn, tag_id: int) -> int: + row = conn.execute( + "SELECT COUNT(*) AS n FROM recipe_tags WHERE tag_id = ?", (tag_id,) + ).fetchone() + return row["n"] + + +def assign_tag_to_recipe(conn, recipe_id: int, tag_id: int) -> None: + conn.execute( + "INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)", + (recipe_id, tag_id), + ) + conn.commit() + + +def remove_tag_from_recipe(conn, recipe_id: int, tag_id: int) -> None: + conn.execute( + "DELETE FROM recipe_tags WHERE recipe_id = ? AND tag_id = ?", + (recipe_id, tag_id), + ) + conn.commit() + + +def list_recipes_by_tag(conn, tag_name: str) -> list[sqlite3.Row]: + return conn.execute( + "SELECT recipes.* FROM recipes " + "JOIN recipe_tags ON recipe_tags.recipe_id = recipes.id " + "JOIN tags ON tags.id = recipe_tags.tag_id " + "WHERE tags.name = ? COLLATE NOCASE", + (tag_name,), + ).fetchall() + + +def list_tags(conn) -> list[sqlite3.Row]: + return conn.execute( + "SELECT tags.*, COUNT(recipe_tags.recipe_id) AS recipe_count " + "FROM tags LEFT JOIN recipe_tags ON recipe_tags.tag_id = tags.id " + "GROUP BY tags.id ORDER BY tags.name COLLATE NOCASE" + ).fetchall() + + # --- ingredients --- def add_ingredient(conn, recipe_id, name, quantity, unit, category) -> int: diff --git a/install.sh b/install.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs or updates the Anton bot as a systemd service. Safe to re-run: +# each run stages a full copy of this repo, installs dependencies and runs +# the test suite against that staged copy, and only touches the live +# deployment (and the running service) if those tests pass. An update +# whose tests fail aborts before anything live is touched, so the +# previous working install is never overwritten by a broken one. +# +# Run as root on the target LXC/VM, from within a checkout of this repo: +# sudo ./install.sh +# +# Override defaults via env vars if needed: +# ANTON_DEPLOY_USER=anton ANTON_DEPLOY_DIR=/opt/anton sudo -E ./install.sh + +DEPLOY_USER="${ANTON_DEPLOY_USER:-anton}" +DEPLOY_DIR="${ANTON_DEPLOY_DIR:-/opt/anton}" +SERVICE_NAME="anton-bot" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $EUID -ne 0 ]]; then + echo "Run this as root: sudo $0" >&2 + exit 1 +fi + +# Applied to both the repo->staging copy and the staging->deploy copy. +# .env and *.db are only relevant for the second copy (they don't exist +# in the repo checkout), but excluding them everywhere is harmless and +# keeps this one list authoritative. venv is excluded both times too -- +# virtualenvs embed absolute paths in their shebangs/activate scripts, so +# a venv is never safe to relocate between directories; the deploy venv +# is always built fresh in place instead (see below). +RSYNC_EXCLUDES=( + --exclude='.env' + --exclude='*.db' + --exclude='.git' + --exclude='.venv' + --exclude='venv' + --exclude='__pycache__' + --exclude='.pytest_cache' + --exclude='.claude' + --exclude='bot.log' +) + +echo "==> Staging a copy to test before touching the live install" +STAGE_DIR="$(mktemp -d)" +trap 'rm -rf "$STAGE_DIR"' EXIT + +rsync -a "${RSYNC_EXCLUDES[@]}" "$SCRIPT_DIR"/ "$STAGE_DIR"/ + +echo "==> Installing dependencies into a throwaway staging virtualenv" +python3 -m venv "$STAGE_DIR/venv" +"$STAGE_DIR/venv/bin/pip" install --upgrade -q -r "$STAGE_DIR/requirements.txt" + +echo "==> Running the test suite against the staged code" +if ! (cd "$STAGE_DIR" && venv/bin/pytest -q); then + echo "==> Tests failed -- aborting. The live install (if any) is untouched." >&2 + exit 1 +fi + +echo "==> Tests passed. Deploying to ${DEPLOY_DIR}" + +if ! id "$DEPLOY_USER" &>/dev/null; then + echo "==> Creating system user '${DEPLOY_USER}'" + useradd --system --shell /usr/sbin/nologin --home-dir "$DEPLOY_DIR" --create-home "$DEPLOY_USER" +else + echo "==> System user '${DEPLOY_USER}' already exists" +fi + +if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + echo "==> Stopping running ${SERVICE_NAME} service for the update" + systemctl stop "$SERVICE_NAME" +fi + +mkdir -p "$DEPLOY_DIR" +echo "==> Copying tested code into ${DEPLOY_DIR} (.env and *.db left untouched)" +rsync -a --delete "${RSYNC_EXCLUDES[@]}" "$STAGE_DIR"/ "$DEPLOY_DIR"/ + +if [[ ! -f "$DEPLOY_DIR/.env" ]]; then + echo "==> No .env found -- creating one from .env.example" + cp "$DEPLOY_DIR/.env.example" "$DEPLOY_DIR/.env" + chmod 600 "$DEPLOY_DIR/.env" +fi + +if [[ ! -d "$DEPLOY_DIR/venv" ]]; then + echo "==> Creating the deploy virtualenv" + python3 -m venv "$DEPLOY_DIR/venv" +fi +echo "==> Installing/upgrading dependencies in the deploy virtualenv" +"$DEPLOY_DIR/venv/bin/pip" install --upgrade -q -r "$DEPLOY_DIR/requirements.txt" + +echo "==> Setting ownership to ${DEPLOY_USER}" +chown -R "$DEPLOY_USER":"$DEPLOY_USER" "$DEPLOY_DIR" + +echo "==> Installing systemd unit" +sed \ + -e "s|^User=.*|User=${DEPLOY_USER}|" \ + -e "s|^WorkingDirectory=.*|WorkingDirectory=${DEPLOY_DIR}|" \ + -e "s|^EnvironmentFile=.*|EnvironmentFile=${DEPLOY_DIR}/.env|" \ + -e "s|^ExecStart=.*|ExecStart=${DEPLOY_DIR}/venv/bin/python ${DEPLOY_DIR}/bot.py|" \ + "$DEPLOY_DIR/anton-bot.service" > "/etc/systemd/system/${SERVICE_NAME}.service" +systemctl daemon-reload +systemctl enable "$SERVICE_NAME" >/dev/null + +TOKEN_VALUE="$(grep -E '^TELEGRAM_BOT_TOKEN=' "$DEPLOY_DIR/.env" | cut -d '=' -f2-)" +if [[ -z "$TOKEN_VALUE" ]]; then + echo + echo "==> ${DEPLOY_DIR}/.env has no TELEGRAM_BOT_TOKEN set." + echo " Edit it, then run: systemctl start ${SERVICE_NAME}" +else + echo "==> Starting ${SERVICE_NAME}" + systemctl restart "$SERVICE_NAME" + sleep 1 + systemctl status "$SERVICE_NAME" --no-pager -l | head -10 || true +fi diff --git a/models.py b/models.py @@ -39,6 +39,21 @@ CREATE TABLE IF NOT EXISTS grocery_list_items ( category TEXT, source TEXT NOT NULL CHECK (source IN ('staple', 'recipe', 'freeform')) ); + +-- "Groups" for manually classifying recipes (e.g. "slowcooker"), separate +-- from the automatic is_keto tag. A tag can exist with zero recipes +-- attached (created ahead of time via /tag add <group>), which is why +-- this is its own table rather than just a column on recipe_tags. +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE +); + +CREATE TABLE IF NOT EXISTS recipe_tags ( + recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (recipe_id, tag_id) +); """ diff --git a/parsing.py b/parsing.py @@ -98,6 +98,24 @@ def merge_ingredients(items: list[Item]) -> list[Item]: return list(merged.values()) + passthrough +def group_by_category(items: list[Item]) -> list[tuple[str | None, list[Item]]]: + """Group items by category, preserving first-seen category order. + + Items with no category (None) are grouped together and always listed + last -- no attempt is made to guess a category from the ingredient + name, so anything not explicitly categorized just lands in one + leftover group instead of being sorted (or mis-sorted) automatically. + """ + groups: dict[str | None, list[Item]] = {} + for item in items: + groups.setdefault(item.category, []).append(item) + + ordered = [(cat, group) for cat, group in groups.items() if cat is not None] + if None in groups: + ordered.append((None, groups[None])) + return ordered + + def suggest_keto(ingredient_names: list[str]) -> bool: """Flag False if any ingredient name contains a high-carb keyword.""" for name in ingredient_names: @@ -107,64 +125,95 @@ def suggest_keto(ingredient_names: list[str]) -> bool: return True -def parse_recipe_text(text: str) -> dict: - """Parse the structured text format expected after /addrecipe: +SECTION_HEADERS = {"ingredients", "instructions", "nutrition"} + + +def parse_recipe_text(name: str, body: str) -> dict: + """Parse a recipe body pasted as free-form text (e.g. copied straight + from a recipe blog) into ingredients/instructions. + + The recipe name is passed in separately rather than parsed out of the + body -- real copy-pasted recipes usually don't include the page title + in the copied text, so there's nothing reliable to extract it from. + + Section headers ("Ingredients", "Instructions", "Nutrition") are + matched case-insensitively with or without a trailing colon. Before + the first recognized header, lines default to the "ingredients" + section, since most pasted recipes list ingredients first, unlabeled. + A "Nutrition" section, if present, is recognized and discarded (out + of scope per CLAUDE.md). + + Ingredient lines containing "|" are parsed as + "<quantity> | <unit> | <name> | <category>" for precise manual entry. + Lines without "|" are kept whole as the ingredient name (quantity/ + unit/category left unset) -- there's no reliable way to split "2 + medium yellow onions, very finely chopped" into quantity/unit/name + without a real NLP ingredient parser, so that's not attempted; those + items just won't auto-merge by quantity later. - Name: <name> + Optional metadata lines, matched anywhere in the body: Servings: <n> Source: <text or url> - Keto: yes|no (optional -- omit to auto-suggest) - Ingredients: - <quantity> | <unit> | <name> | <category> - ... - Instructions: - <free text, one or more lines> + Keto: yes|no (omit to auto-suggest from ingredient names) """ - fields = {"name": None, "servings": None, "source": None, "is_keto": None} + name = name.strip() + if not name: + raise ValueError("a recipe name is required") + + fields = {"servings": None, "source": None, "is_keto": None} ingredients: list[dict] = [] instructions_lines: list[str] = [] - section = None + section = "ingredients" - for line in text.splitlines(): + for line in body.splitlines(): stripped = line.strip() - lower = stripped.lower() + if not stripped: + continue - if lower.startswith("name:"): - fields["name"] = stripped.split(":", 1)[1].strip() - elif lower.startswith("servings:"): + header = stripped.rstrip(":").lower() + if header in SECTION_HEADERS: + section = header + continue + + lower = stripped.lower() + if lower.startswith("servings:"): value = stripped.split(":", 1)[1].strip() fields["servings"] = int(value) if value.isdigit() else None - elif lower.startswith("source:"): + continue + if lower.startswith("source:"): fields["source"] = stripped.split(":", 1)[1].strip() - elif lower.startswith("keto:"): + continue + if lower.startswith("keto:"): value = stripped.split(":", 1)[1].strip().lower() fields["is_keto"] = value in ("yes", "true", "1") - elif lower.startswith("ingredients:"): - section = "ingredients" - elif lower.startswith("instructions:"): - section = "instructions" - elif section == "ingredients" and stripped: - parts = [p.strip() for p in stripped.split("|")] - parts += [None] * (4 - len(parts)) - quantity, unit, name, category = parts[:4] - ingredients.append({ - "quantity": quantity or None, - "unit": unit or None, - "name": name, - "category": category or None, - }) - elif section == "instructions" and stripped: - instructions_lines.append(stripped) + continue - if not fields["name"]: - raise ValueError("recipe text must include a 'Name:' line") + if section == "ingredients": + if "|" in stripped: + parts = [p.strip() for p in stripped.split("|")] + parts += [None] * (4 - len(parts)) + quantity, unit, ing_name, category = parts[:4] + ingredients.append({ + "quantity": quantity or None, + "unit": unit or None, + "name": ing_name, + "category": category or None, + }) + else: + ingredients.append({ + "quantity": None, "unit": None, + "name": stripped, "category": None, + }) + elif section == "instructions": + instructions_lines.append(stripped) + # section == "nutrition": intentionally discarded is_keto = fields["is_keto"] if is_keto is None: is_keto = suggest_keto([i["name"] for i in ingredients if i["name"]]) return { - "name": fields["name"], + "name": name, "servings": fields["servings"], "source": fields["source"], "is_keto": is_keto, diff --git a/tests/test_parsing.py b/tests/test_parsing.py @@ -1,6 +1,13 @@ from fractions import Fraction -from parsing import Item, merge_ingredients, parse_quantity, parse_recipe_text, suggest_keto +from parsing import ( + Item, + group_by_category, + merge_ingredients, + parse_quantity, + parse_recipe_text, + suggest_keto, +) def test_parse_quantity_whole_number(): @@ -48,8 +55,7 @@ def test_merge_keeps_unparseable_quantities_separate(): def test_parse_recipe_text_basic(): - text = ( - "Name: Test Chili\n" + body = ( "Servings: 4\n" "Source: test\n" "Keto: yes\n" @@ -60,7 +66,7 @@ def test_parse_recipe_text_basic(): "Brown the beef.\n" "Simmer.\n" ) - parsed = parse_recipe_text(text) + parsed = parse_recipe_text("Test Chili", body) assert parsed["name"] == "Test Chili" assert parsed["servings"] == 4 assert parsed["is_keto"] is True @@ -70,15 +76,62 @@ def test_parse_recipe_text_basic(): def test_parse_recipe_text_missing_name_raises(): try: - parse_recipe_text("Servings: 2\n") + parse_recipe_text(" ", "Instructions:\nDo stuff.\n") assert False, "expected ValueError" except ValueError: pass +def test_parse_recipe_text_freeform_real_world_paste(): + # Shape of a recipe copy-pasted straight from a blog: no header before + # ingredients, freeform ingredient lines (no "|"), a bare "INSTRUCTIONS" + # header with no colon, and a trailing "NUTRITION" section to discard. + body = ( + "2 tablespoons pork lard, or butter\n" + "3 pounds chicken pieces, bone-in and skin-on\n" + "2 medium yellow onions, very finely chopped\n" + "\n" + "INSTRUCTIONS\n" + "\n" + "Heat the lard in a large Dutch oven and brown the chicken.\n" + "Add the onions and fry until golden brown.\n" + "\n" + "NUTRITION\n" + "Calories: 500\n" + ) + parsed = parse_recipe_text("Chicken Paprikash", body) + assert parsed["name"] == "Chicken Paprikash" + assert len(parsed["ingredients"]) == 3 + assert parsed["ingredients"][0]["name"] == "2 tablespoons pork lard, or butter" + assert parsed["ingredients"][0]["quantity"] is None + assert "brown the chicken" in parsed["instructions"] + assert "Calories" not in parsed["instructions"] + + def test_suggest_keto_flags_high_carb_ingredient(): assert suggest_keto(["ground beef", "flour"]) is False def test_suggest_keto_true_when_no_high_carb_keywords(): assert suggest_keto(["ground beef", "cheese"]) is True + + +def test_group_by_category_groups_and_puts_uncategorized_last(): + items = [ + Item(name="Milk", quantity="1", unit="cup", category="dairy", source="staple"), + Item(name="Onion", quantity="1", unit=None, category=None, source="recipe"), + Item(name="Cheese", quantity="1", unit="cup", category="dairy", source="staple"), + Item(name="Flour", quantity="1", unit="cup", category="pantry", source="staple"), + ] + grouped = group_by_category(items) + categories = [cat for cat, _ in grouped] + assert categories == ["dairy", "pantry", None] + assert [i.name for i in dict(grouped)["dairy"]] == ["Milk", "Cheese"] + + +def test_group_by_category_no_uncategorized_bucket_when_none_missing(): + items = [ + Item(name="Milk", quantity="1", unit="cup", category="dairy", source="staple"), + ] + grouped = group_by_category(items) + assert grouped == [("dairy", items)]