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 | + |
| M | anton-bot.service | | | 18 | ++++++++++++++++-- |
| M | bot.py | | | 496 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- |
| M | db.py | | | 76 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | install.sh | | | 116 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | models.py | | | 15 | +++++++++++++++ |
| M | parsing.py | | | 123 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------ |
| M | tests/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)]