grocery-bot

Log | Files | Refs | README

bot.py (23695B)


      1 """Telegram bot entrypoint: command handlers wired to db.py / parsing.py."""
      2 from __future__ import annotations
      3 
      4 import logging
      5 import os
      6 import random
      7 
      8 from dotenv import load_dotenv
      9 from telegram import Update
     10 from telegram.ext import (
     11     Application,
     12     CommandHandler,
     13     ContextTypes,
     14     ConversationHandler,
     15     MessageHandler,
     16     filters,
     17 )
     18 
     19 import db
     20 import parsing
     21 from models import get_connection, init_db
     22 
     23 load_dotenv()
     24 
     25 TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
     26 
     27 logging.basicConfig(level=logging.INFO)
     28 logging.getLogger("httpx").setLevel(logging.WARNING)
     29 logger = logging.getLogger(__name__)
     30 
     31 ADDRECIPE_NAME, ADDRECIPE_BODY = range(2)
     32 INSTRUCTIONS_ADD_WAITING = 1
     33 
     34 ADDRECIPE_BODY_PROMPT = (
     35     "Got it. Now paste the ingredients as one message -- straight from a "
     36     "recipe site/blog is fine, e.g.:\n\n"
     37     "2 medium yellow onions, finely chopped\n"
     38     "1 lb ground beef\n\n"
     39     "For precise quantities you can also use "
     40     "'<quantity> | <unit> | <name> | <category>' per ingredient line.\n"
     41     "Optional lines anywhere: 'Servings: 4', 'Source: ...', 'Keto: yes'.\n"
     42     "Add instructions afterward with /instructions add <name>.\n"
     43     "/cancel to stop."
     44 )
     45 
     46 INSTRUCTIONS_ADD_PROMPT = (
     47     "Paste the instructions now. Long instructions can be split across "
     48     "multiple messages -- just keep sending them, then send /done when "
     49     "you've sent it all. This replaces any instructions already saved for "
     50     "this recipe. /cancel to stop."
     51 )
     52 
     53 
     54 def _conn():
     55     conn = get_connection()
     56     init_db(conn)
     57     return conn
     58 
     59 
     60 async def addrecipe_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
     61     await update.effective_message.reply_text(
     62         "What's the recipe called? (/cancel to stop)"
     63     )
     64     return ADDRECIPE_NAME
     65 
     66 
     67 async def addrecipe_receive_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
     68     context.user_data["addrecipe_name"] = update.effective_message.text.strip()
     69     await update.effective_message.reply_text(ADDRECIPE_BODY_PROMPT)
     70     return ADDRECIPE_BODY
     71 
     72 
     73 async def addrecipe_receive_body(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
     74     name = context.user_data["addrecipe_name"]
     75 
     76     try:
     77         parsed = parsing.parse_recipe_text(name, update.effective_message.text)
     78     except ValueError as exc:
     79         await update.effective_message.reply_text(f"Couldn't parse that: {exc}. Try again, or /cancel.")
     80         return ADDRECIPE_BODY
     81 
     82     conn = _conn()
     83     try:
     84         recipe_id = db.add_recipe(
     85             conn,
     86             name=parsed["name"],
     87             servings=parsed["servings"],
     88             instructions=parsed["instructions"],
     89             source=parsed["source"],
     90             is_keto=parsed["is_keto"],
     91         )
     92         for ing in parsed["ingredients"]:
     93             db.add_ingredient(
     94                 conn,
     95                 recipe_id=recipe_id,
     96                 name=ing["name"],
     97                 quantity=ing["quantity"],
     98                 unit=ing["unit"],
     99                 category=ing["category"],
    100             )
    101     finally:
    102         conn.close()
    103 
    104     context.user_data.pop("addrecipe_name", None)
    105     keto_note = "keto" if parsed["is_keto"] else "not flagged keto"
    106     await update.effective_message.reply_text(
    107         f"Added '{parsed['name']}' with {len(parsed['ingredients'])} "
    108         f"ingredient(s) ({keto_note})."
    109     )
    110     return ConversationHandler.END
    111 
    112 
    113 async def addrecipe_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    114     context.user_data.pop("addrecipe_name", None)
    115     await update.effective_message.reply_text("Cancelled.")
    116     return ConversationHandler.END
    117 
    118 
    119 def _format_recipe_ingredients(recipe, ingredients) -> str:
    120     lines = [f"{recipe['name']} ({'keto' if recipe['is_keto'] else 'not keto'})"]
    121     if recipe["servings"]:
    122         lines.append(f"Servings: {recipe['servings']}")
    123     if recipe["source"]:
    124         lines.append(f"Source: {recipe['source']}")
    125 
    126     lines.append("")
    127     lines.append("Ingredients:")
    128     for ing in ingredients:
    129         parts = [p for p in (ing["quantity"], ing["unit"], ing["name"]) if p]
    130         lines.append(f"- {' '.join(parts)}")
    131 
    132     return "\n".join(lines)
    133 
    134 
    135 async def _reply_recipe_ingredients(update: Update, conn, recipe) -> None:
    136     ingredients = db.list_ingredients_for_recipe(conn, recipe["id"])
    137     await update.effective_message.reply_text(_format_recipe_ingredients(recipe, ingredients))
    138 
    139 
    140 async def recipes_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    141     _, _, rest = update.effective_message.text.partition(" ")
    142     rest = rest.strip()
    143 
    144     conn = _conn()
    145     try:
    146         if not rest:
    147             rows = db.list_recipes(conn)
    148         else:
    149             # /recipes and /recipe are one letter apart and easy to mix up --
    150             # if the argument exactly matches a recipe name, show that
    151             # recipe's ingredients rather than silently treating it as an
    152             # unrecognized tag.
    153             recipe = db.get_recipe_by_name(conn, rest)
    154             if recipe:
    155                 await _reply_recipe_ingredients(update, conn, recipe)
    156                 return
    157 
    158             if rest.lower() == "keto":
    159                 rows = db.list_recipes(conn, tag="keto")
    160             else:
    161                 # Not an exact name or "keto" -- try a manually-assigned
    162                 # group next (e.g. "/recipes slowcooker"), then fall back to
    163                 # a substring search over recipe names (e.g. "/recipes
    164                 # chicken" finds anything with "chicken" in the name).
    165                 rows = db.list_recipes_by_tag(conn, rest)
    166                 if not rows:
    167                     rows = db.search_recipes_by_name(conn, rest)
    168     finally:
    169         conn.close()
    170 
    171     if not rows:
    172         message = f"No recipes found matching '{rest}'." if rest else "No recipes found."
    173         await update.effective_message.reply_text(message)
    174         return
    175 
    176     lines = [f"- {r['name']} ({'keto' if r['is_keto'] else 'not keto'})" for r in rows]
    177     await update.effective_message.reply_text("\n".join(lines))
    178 
    179 
    180 async def recipe_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    181     _, _, name = update.effective_message.text.partition(" ")
    182     name = name.strip()
    183     if not name:
    184         await update.effective_message.reply_text("Usage: /recipe <name>")
    185         return
    186 
    187     conn = _conn()
    188     try:
    189         recipe = db.get_recipe_by_name(conn, name)
    190         if not recipe:
    191             await update.effective_message.reply_text(f"No recipe found named '{name}'.")
    192             return
    193         await _reply_recipe_ingredients(update, conn, recipe)
    194     finally:
    195         conn.close()
    196 
    197 
    198 TAG_USAGE = (
    199     "Usage:\n"
    200     "/tag <group> - list recipes tagged with a group\n"
    201     "/tag add <group> - create a group\n"
    202     "/tag add <recipe name>, <group> - tag a recipe with a group\n"
    203     "/tag remove <group> - delete a group entirely (untags everything)\n"
    204     "/tag remove <recipe name>, <group> - untag one recipe"
    205 )
    206 
    207 
    208 async def _tag_add(update: Update, arg: str) -> None:
    209     if not arg:
    210         await update.effective_message.reply_text(TAG_USAGE)
    211         return
    212 
    213     conn = _conn()
    214     try:
    215         if "," in arg:
    216             recipe_name, _, tag_name = arg.partition(",")
    217             recipe_name = recipe_name.strip()
    218             tag_name = tag_name.strip()
    219             if not recipe_name or not tag_name:
    220                 await update.effective_message.reply_text(TAG_USAGE)
    221                 return
    222             recipe = db.get_recipe_by_name(conn, recipe_name)
    223             if not recipe:
    224                 await update.effective_message.reply_text(f"No recipe found named '{recipe_name}'.")
    225                 return
    226             tag = db.get_or_create_tag(conn, tag_name)
    227             db.assign_tag_to_recipe(conn, recipe["id"], tag["id"])
    228             await update.effective_message.reply_text(f"Tagged '{recipe['name']}' with '{tag['name']}'.")
    229         else:
    230             tag_name = arg
    231             already_existed = db.get_tag_by_name(conn, tag_name) is not None
    232             tag = db.get_or_create_tag(conn, tag_name)
    233             if already_existed:
    234                 await update.effective_message.reply_text(f"Group '{tag['name']}' already exists.")
    235             else:
    236                 await update.effective_message.reply_text(f"Created group '{tag['name']}'.")
    237     finally:
    238         conn.close()
    239 
    240 
    241 async def _tag_remove(update: Update, arg: str) -> None:
    242     if not arg:
    243         await update.effective_message.reply_text(TAG_USAGE)
    244         return
    245 
    246     conn = _conn()
    247     try:
    248         if "," in arg:
    249             recipe_name, _, tag_name = arg.partition(",")
    250             recipe_name = recipe_name.strip()
    251             tag_name = tag_name.strip()
    252             if not recipe_name or not tag_name:
    253                 await update.effective_message.reply_text(TAG_USAGE)
    254                 return
    255             recipe = db.get_recipe_by_name(conn, recipe_name)
    256             if not recipe:
    257                 await update.effective_message.reply_text(f"No recipe found named '{recipe_name}'.")
    258                 return
    259             tag = db.get_tag_by_name(conn, tag_name)
    260             if not tag:
    261                 await update.effective_message.reply_text(f"No group named '{tag_name}'.")
    262                 return
    263             db.remove_tag_from_recipe(conn, recipe["id"], tag["id"])
    264             await update.effective_message.reply_text(f"Untagged '{recipe['name']}' from '{tag['name']}'.")
    265         else:
    266             tag_name = arg
    267             tag = db.get_tag_by_name(conn, tag_name)
    268             if not tag:
    269                 await update.effective_message.reply_text(f"No group named '{tag_name}'.")
    270                 return
    271             count = db.count_recipes_for_tag(conn, tag["id"])
    272             db.delete_tag(conn, tag["id"])
    273             await update.effective_message.reply_text(
    274                 f"Deleted group '{tag['name']}' (was tagged on {count} recipe(s))."
    275             )
    276     finally:
    277         conn.close()
    278 
    279 
    280 async def _tag_show(update: Update, tag_name: str) -> None:
    281     conn = _conn()
    282     try:
    283         tag = db.get_tag_by_name(conn, tag_name)
    284         if not tag:
    285             await update.effective_message.reply_text(f"No group named '{tag_name}'.")
    286             return
    287         rows = db.list_recipes_by_tag(conn, tag_name)
    288     finally:
    289         conn.close()
    290 
    291     if not rows:
    292         await update.effective_message.reply_text(f"No recipes tagged '{tag['name']}' yet.")
    293         return
    294 
    295     lines = [f"- {r['name']} ({'keto' if r['is_keto'] else 'not keto'})" for r in rows]
    296     await update.effective_message.reply_text("\n".join(lines))
    297 
    298 
    299 async def tag_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    300     _, _, rest = update.effective_message.text.partition(" ")
    301     rest = rest.strip()
    302     action, _, arg = rest.partition(" ")
    303     arg = arg.strip()
    304 
    305     if action.lower() == "add":
    306         await _tag_add(update, arg)
    307     elif action.lower() == "remove":
    308         await _tag_remove(update, arg)
    309     elif rest:
    310         await _tag_show(update, rest)
    311     else:
    312         await update.effective_message.reply_text(TAG_USAGE)
    313 
    314 
    315 async def tags_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    316     conn = _conn()
    317     try:
    318         rows = db.list_tags(conn)
    319     finally:
    320         conn.close()
    321 
    322     if not rows:
    323         await update.effective_message.reply_text("No groups yet. Create one with /tag add <group>.")
    324         return
    325 
    326     lines = [f"- {r['name']} ({r['recipe_count']} recipe(s))" for r in rows]
    327     await update.effective_message.reply_text("\n".join(lines))
    328 
    329 
    330 async def _show_instructions(update: Update, name: str) -> None:
    331     if not name:
    332         await update.effective_message.reply_text(
    333             "Usage: /instructions <name> | /instructions add <name> | "
    334             "/instructions delete <name>"
    335         )
    336         return
    337 
    338     conn = _conn()
    339     try:
    340         recipe = db.get_recipe_by_name(conn, name)
    341     finally:
    342         conn.close()
    343 
    344     if not recipe:
    345         await update.effective_message.reply_text(f"No recipe found named '{name}'.")
    346         return
    347 
    348     if not recipe["instructions"]:
    349         await update.effective_message.reply_text(f"No instructions saved for '{recipe['name']}'.")
    350         return
    351 
    352     await update.effective_message.reply_text(f"{recipe['name']}\n\n{recipe['instructions']}")
    353 
    354 
    355 async def _delete_instructions(update: Update, name: str) -> None:
    356     if not name:
    357         await update.effective_message.reply_text("Usage: /instructions delete <name>")
    358         return
    359 
    360     conn = _conn()
    361     try:
    362         recipe = db.get_recipe_by_name(conn, name)
    363         if not recipe:
    364             await update.effective_message.reply_text(f"No recipe found named '{name}'.")
    365             return
    366         db.update_recipe_instructions(conn, recipe["id"], "")
    367     finally:
    368         conn.close()
    369 
    370     await update.effective_message.reply_text(f"Cleared instructions for '{recipe['name']}'.")
    371 
    372 
    373 async def instructions_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    374     _, _, rest = update.effective_message.text.partition(" ")
    375     rest = rest.strip()
    376     subcommand, _, name = rest.partition(" ")
    377     name = name.strip()
    378 
    379     if subcommand.lower() == "add":
    380         if not name:
    381             await update.effective_message.reply_text("Usage: /instructions add <name>")
    382             return ConversationHandler.END
    383         context.user_data["instructions_name"] = name
    384         context.user_data["instructions_parts"] = []
    385         await update.effective_message.reply_text(INSTRUCTIONS_ADD_PROMPT)
    386         return INSTRUCTIONS_ADD_WAITING
    387 
    388     if subcommand.lower() in ("delete", "remove"):
    389         await _delete_instructions(update, name)
    390         return ConversationHandler.END
    391 
    392     await _show_instructions(update, rest)
    393     return ConversationHandler.END
    394 
    395 
    396 async def instructions_add_chunk(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    397     context.user_data.setdefault("instructions_parts", []).append(update.effective_message.text)
    398     await update.effective_message.reply_text("Got that part. Send more, or /done when finished.")
    399     return INSTRUCTIONS_ADD_WAITING
    400 
    401 
    402 async def instructions_add_finish(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    403     name = context.user_data["instructions_name"]
    404     parts = context.user_data.get("instructions_parts", [])
    405     if not parts:
    406         await update.effective_message.reply_text(
    407             "Nothing pasted yet -- send the instructions text first, or /cancel."
    408         )
    409         return INSTRUCTIONS_ADD_WAITING
    410 
    411     text = "\n".join(parts)
    412     conn = _conn()
    413     try:
    414         recipe = db.get_recipe_by_name(conn, name)
    415         if not recipe:
    416             await update.effective_message.reply_text(f"No recipe found named '{name}'.")
    417         else:
    418             db.update_recipe_instructions(conn, recipe["id"], text)
    419             await update.effective_message.reply_text(f"Instructions saved for '{recipe['name']}'.")
    420     finally:
    421         conn.close()
    422 
    423     context.user_data.pop("instructions_name", None)
    424     context.user_data.pop("instructions_parts", None)
    425     return ConversationHandler.END
    426 
    427 
    428 async def instructions_add_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    429     context.user_data.pop("instructions_name", None)
    430     context.user_data.pop("instructions_parts", None)
    431     await update.effective_message.reply_text("Cancelled.")
    432     return ConversationHandler.END
    433 
    434 
    435 async def staples_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    436     conn = _conn()
    437     try:
    438         if context.args and context.args[0].lower() == "add":
    439             name = " ".join(context.args[1:]).strip()
    440             if not name:
    441                 await update.effective_message.reply_text("Usage: /staples add <item>")
    442                 return
    443             db.add_staple(conn, name=name, default_quantity=None, unit=None, category=None)
    444             await update.effective_message.reply_text(f"Added staple: {name}")
    445             return
    446 
    447         if context.args and context.args[0].lower() == "remove":
    448             name = " ".join(context.args[1:]).strip()
    449             if not name:
    450                 await update.effective_message.reply_text("Usage: /staples remove <item>")
    451                 return
    452             db.remove_staple(conn, name)
    453             await update.effective_message.reply_text(f"Removed staple: {name}")
    454             return
    455 
    456         rows = db.list_staples(conn)
    457     finally:
    458         conn.close()
    459 
    460     if not rows:
    461         await update.effective_message.reply_text("No staples set.")
    462         return
    463 
    464     lines = [f"- {r['name']}" for r in rows]
    465     await update.effective_message.reply_text("\n".join(lines))
    466 
    467 
    468 async def grocerylist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    469     _, _, rest = update.effective_message.text.partition(" ")
    470     recipe_names = [n.strip() for n in rest.split(",") if n.strip()]
    471 
    472     conn = _conn()
    473     try:
    474         items = [
    475             parsing.Item(
    476                 name=s["name"], quantity=s["default_quantity"],
    477                 unit=s["unit"], category=s["category"], source="staple",
    478             )
    479             for s in db.list_staples(conn)
    480         ]
    481 
    482         not_found = []
    483         for rn in recipe_names:
    484             recipe = db.get_recipe_by_name(conn, rn)
    485             if not recipe:
    486                 not_found.append(rn)
    487                 continue
    488             for ing in db.list_ingredients_for_recipe(conn, recipe["id"]):
    489                 items.append(parsing.Item(
    490                     name=ing["name"], quantity=ing["quantity"],
    491                     unit=ing["unit"], category=ing["category"], source="recipe",
    492                 ))
    493 
    494         merged = parsing.merge_ingredients(items)
    495 
    496         db.clear_grocery_list(conn)
    497         for item in merged:
    498             db.add_grocery_item(
    499                 conn, name=item.name, quantity=item.quantity,
    500                 unit=item.unit, category=item.category, source=item.source,
    501             )
    502     finally:
    503         conn.close()
    504 
    505     if not merged:
    506         reply = "Nothing to list."
    507     else:
    508         sections = []
    509         for category, group_items in parsing.group_by_category(merged):
    510             header = category if category else "Uncategorized"
    511             lines = [f"- {(item.quantity or '')} {(item.unit or '')} {item.name}".strip() for item in group_items]
    512             sections.append(f"{header}:\n" + "\n".join(lines))
    513         reply = "\n\n".join(sections)
    514 
    515     if not_found:
    516         reply += f"\n\n(Recipe(s) not found: {', '.join(not_found)})"
    517     await update.effective_message.reply_text(reply)
    518 
    519 
    520 async def add_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    521     _, _, item_name = update.effective_message.text.partition(" ")
    522     item_name = item_name.strip()
    523     if not item_name:
    524         await update.effective_message.reply_text("Usage: /add <item>")
    525         return
    526 
    527     conn = _conn()
    528     try:
    529         db.add_grocery_item(
    530             conn, name=item_name, quantity=None, unit=None,
    531             category=None, source="freeform",
    532         )
    533     finally:
    534         conn.close()
    535     await update.effective_message.reply_text(f"Added '{item_name}' to the grocery list.")
    536 
    537 
    538 async def clearlist_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    539     conn = _conn()
    540     try:
    541         db.clear_grocery_list(conn)
    542     finally:
    543         conn.close()
    544     await update.effective_message.reply_text("Grocery list cleared.")
    545 
    546 
    547 async def random_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    548     tag = context.args[0] if context.args else None
    549     conn = _conn()
    550     try:
    551         rows = db.list_recipes(conn, tag=tag)
    552     finally:
    553         conn.close()
    554 
    555     if not rows:
    556         await update.effective_message.reply_text("No recipes to choose from.")
    557         return
    558 
    559     choice = random.choice(rows)
    560     await update.effective_message.reply_text(f"How about: {choice['name']}?")
    561 
    562 
    563 HELP_TEXT = (
    564     "Commands:\n"
    565     "/addrecipe - add a recipe (asks for a name, then the ingredients)\n"
    566     "/recipes [tag/group/keyword] - list recipes; 'keto' filters by tag, a\n"
    567     "  manually-created group matches next (e.g. /recipes slowcooker), then\n"
    568     "  any other word searches recipe names (e.g. /recipes chicken); an exact\n"
    569     "  name shows that recipe's ingredients\n"
    570     "/recipe <name> - show one recipe's ingredients\n"
    571     "/tag <group> - list recipes tagged with a group (e.g. /tag beef)\n"
    572     "/tag add <group> - create a group (e.g. /tag add slowcooker)\n"
    573     "/tag add <recipe name>, <group> - tag a recipe with a group\n"
    574     "/tag remove <group> - delete a group entirely (untags everything)\n"
    575     "/tag remove <recipe name>, <group> - untag one recipe\n"
    576     "/tags - list all groups and how many recipes are in each\n"
    577     "/instructions <name> - show one recipe's instructions\n"
    578     "/instructions add <name> - paste instructions for a recipe (replaces any\n"
    579     "  existing instructions); long text can span multiple messages, send\n"
    580     "  /done when finished\n"
    581     "/instructions delete <name> - clear a recipe's instructions\n"
    582     "/staples - show the staples list\n"
    583     "/staples add <item> - add an item to staples\n"
    584     "/staples remove <item> - remove an item from staples\n"
    585     "/grocerylist [recipe1, recipe2, ...] - build a merged grocery list\n"
    586     "/add <item> - freeform add to the current grocery list\n"
    587     "/clearlist - reset the working grocery list\n"
    588     "/random [tag] - suggest a random recipe, optionally filtered\n"
    589     "/help - show this message"
    590 )
    591 
    592 
    593 async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    594     await update.effective_message.reply_text(HELP_TEXT)
    595 
    596 
    597 async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
    598     logger.error("Unhandled exception while processing an update", exc_info=context.error)
    599     if isinstance(update, Update) and update.effective_message:
    600         await update.effective_message.reply_text(
    601             "Something went wrong handling that -- check the bot's logs."
    602         )
    603 
    604 
    605 def main() -> None:
    606     conn = _conn()
    607     conn.close()
    608 
    609     app = Application.builder().token(TOKEN).build()
    610 
    611     addrecipe_conv = ConversationHandler(
    612         entry_points=[CommandHandler("addrecipe", addrecipe_start)],
    613         states={
    614             ADDRECIPE_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, addrecipe_receive_name)],
    615             ADDRECIPE_BODY: [MessageHandler(filters.TEXT & ~filters.COMMAND, addrecipe_receive_body)],
    616         },
    617         fallbacks=[CommandHandler("cancel", addrecipe_cancel)],
    618     )
    619 
    620     instructions_conv = ConversationHandler(
    621         entry_points=[CommandHandler("instructions", instructions_cmd)],
    622         states={
    623             INSTRUCTIONS_ADD_WAITING: [
    624                 CommandHandler("done", instructions_add_finish),
    625                 MessageHandler(filters.TEXT & ~filters.COMMAND, instructions_add_chunk),
    626             ],
    627         },
    628         fallbacks=[CommandHandler("cancel", instructions_add_cancel)],
    629     )
    630 
    631     app.add_handler(addrecipe_conv)
    632     app.add_handler(instructions_conv)
    633     app.add_handler(CommandHandler("recipes", recipes_cmd))
    634     app.add_handler(CommandHandler("recipe", recipe_cmd))
    635     app.add_handler(CommandHandler("tag", tag_cmd))
    636     app.add_handler(CommandHandler("tags", tags_cmd))
    637     app.add_handler(CommandHandler("staples", staples_cmd))
    638     app.add_handler(CommandHandler("grocerylist", grocerylist_cmd))
    639     app.add_handler(CommandHandler("add", add_cmd))
    640     app.add_handler(CommandHandler("clearlist", clearlist_cmd))
    641     app.add_handler(CommandHandler("random", random_cmd))
    642     app.add_handler(CommandHandler("help", help_cmd))
    643     app.add_error_handler(error_handler)
    644 
    645     logger.info("Starting bot (long polling)...")
    646     app.run_polling()
    647 
    648 
    649 if __name__ == "__main__":
    650     main()