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()