# 🧠 Brief: Callback Handler + SQLite Commit β€” Recipe to Grocery List **For:** Socrates 🧠 **From:** Wadsworth πŸ“‹ (routing Director's orders) **Project:** Icarus Recipe Integration β€” Phase 1 **Priority:** High **Deadline:** Week 1 Day 4 (tomorrow) --- ## 🎯 Objective Build the backend for recipeβ†’grocery Telegram flow: handle toggle callbacks with immediate UI edits, track temp state, commit selected items to SQLite. --- ## πŸ“‹ Component 1: Callback Handler ### Endpoint ```python @router.post("/telegram/webhook") async def telegram_webhook(update: TelegramUpdate): # Existing message handler... # NEW: Handle callback queries if update.callback_query: await handle_callback_query(update.callback_query) ``` ### Callback Types **Pattern:** `{action}:{recipe_id}:{ingredient_index}` | Action | Format | Handler | |--------|--------|---------| | toggle | `toggle:{recipe_id}:{index}` | `handle_toggle()` | | commit | `commit:{recipe_id}` | `handle_commit()` | | cancel | `cancel:{recipe_id}` | `handle_cancel()` | ### Toggle Handler ```python async def handle_toggle(callback_query: CallbackQuery): """Toggle ingredient selection, edit message in-place.""" # Parse callback data _, recipe_id, idx_str = callback_query.data.split(":") ingredient_idx = int(idx_str) # Retrieve temp state (from memory/redis/sqlite β€” see below) state = await get_temp_state(recipe_id) # Toggle the ingredient state["ingredients"][ingredient_idx]["selected"] = not state["ingredients"][ingredient_idx]["selected"] # Recalculate selected count selected_count = sum(1 for i in state["ingredients"] if i["selected"]) # Build new inline keyboard keyboard = build_toggle_keyboard(state["ingredients"], selected_count) # Edit the message (inline edit, no new message) await bot.edit_message_reply_markup( chat_id=callback_query.message.chat.id, message_id=callback_query.message.message_id, reply_markup=keyboard ) # Ack the callback (stops "loading" spinner) await bot.answer_callback_query(callback_query.id) ``` ### Temp State Storage **Option A: SQLite (recommended)** ```sql CREATE TABLE recipe_temp_state ( recipe_id TEXT PRIMARY KEY, user_id INTEGER, chat_id INTEGER, message_id INTEGER, ingredients JSON, -- [{"index": 0, "text": "...", "selected": true}, ...] created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP -- 5 min from created_at ); ``` **Cleanup:** Cron or periodic task deletes `expires_at < now()` --- ## πŸ“‹ Component 2: SQLite Commit ### Schema ```sql -- Grocery list table CREATE TABLE grocery_list ( id INTEGER PRIMARY KEY AUTOINCREMENT, item TEXT NOT NULL, -- "2 cups penne pasta" normalized_item TEXT, -- "penne pasta" (for future matching) quantity TEXT, -- "2 cups" (parsed if possible) recipe_source TEXT, -- "Creamy Tomato and Spinach Pasta" recipe_url TEXT, -- "https://..." requested_by TEXT, -- "aundrea" added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, status TEXT DEFAULT 'pending' -- 'pending', 'in_cart', 'purchased', 'skipped' ); -- Indexes for performance CREATE INDEX idx_grocery_status ON grocery_list(status); CREATE INDEX idx_grocery_added_by ON grocery_list(requested_by); ``` ### Commit Handler ```python async def handle_commit(callback_query: CallbackQuery): """Commit selected ingredients to grocery list.""" _, recipe_id = callback_query.data.split(":") user_id = callback_query.from_user.id # Get temp state state = await get_temp_state(recipe_id) if not state: await bot.answer_callback_query( callback_query.id, text="Session expired. Send the recipe again." ) return # Filter selected ingredients selected = [i for i in state["ingredients"] if i["selected"]] if not selected: await bot.answer_callback_query( callback_query.id, text="No items selected. Tap ingredients to select them." ) return # Insert into grocery_list async with db.transaction(): for ingredient in selected: await db.execute( """ INSERT INTO grocery_list (item, recipe_source, recipe_url, requested_by) VALUES (?, ?, ?, ?) """, ingredient["text"], state["title"], state.get("source_url"), callback_query.from_user.first_name.lower() ) # Update message to show success await bot.edit_message_text( chat_id=callback_query.message.chat.id, message_id=callback_query.message.message_id, text=f"βœ… Added {len(selected)} items to your grocery list!\n\nView: /groceries", reply_markup=None # Remove buttons ) # Clean up temp state await delete_temp_state(recipe_id) # Ack callback await bot.answer_callback_query(callback_query.id) ``` ### Cancel Handler ```python async def handle_cancel(callback_query: CallbackQuery): """Cancel the operation, clean up.""" _, recipe_id = callback_query.data.split(":") # Delete temp state await delete_temp_state(recipe_id) # Update message await bot.edit_message_text( chat_id=callback_query.message.chat.id, message_id=callback_query.message.message_id, text="❌ Cancelled. No items added.", reply_markup=None ) await bot.answer_callback_query(callback_query.id) ``` --- ## πŸ“‹ Component 3: Recipe Extraction Trigger When user sends a URL, extract and present: ```python async def handle_recipe_url(message: Message, url: str): """Extract recipe and present toggle UI.""" # Extract (re-use costco_route pattern or port to icarus) recipe = await extract_recipe(url) # Generate recipe_id recipe_id = f"{slugify(recipe['title'])}-{uuid4().hex[:8]}" # Build initial state (all selected) ingredients = [ {"index": i, "text": ing, "selected": True} for i, ing in enumerate(recipe["ingredients"]) ] # Store temp state await save_temp_state( recipe_id=recipe_id, user_id=message.from_user.id, chat_id=message.chat.id, ingredients=ingredients, title=recipe["title"], source_url=url ) # Send message with toggle keyboard keyboard = build_toggle_keyboard(ingredients, len(ingredients)) await bot.send_message( chat_id=message.chat.id, text=f"🍝 {recipe['title']}\nFrom: {domain}\n\nTap to deselect items you don't need:", reply_markup=keyboard ) ``` --- ## πŸ“¦ Deliverables | File | Purpose | |------|---------| | `icarus/core/handlers/recipe_toggle.py` | Callback handlers (toggle, commit, cancel) | | `icarus/core/db/grocery_list.py` | Schema + CRUD for grocery_list table | | `icarus/core/db/temp_state.py` | Schema + CRUD for recipe_temp_state table | | `icarus/core/extractors/recipe.py` | Ported recipe extraction (from costco_route) | | `icarus/api/routes/grocery.py` | Optional: REST endpoints for grocery list | | `shared/project-docs/recipe-backend-handoff.md` | Integration notes for Daedalus/Wadsworth | --- ## πŸ”Œ Integration Points | What | Where | |------|-------| | Recipe extraction | Port from `costco_route/recipe_extractor.py` | | Telegram bot | Extend existing `telegram/handler.py` | | Database | SQLite in `/home/hoffmann_admin/.icarus/data/icarus.db` | | Temp state | SQLite table (not Redis, keep it simple) | --- ## ⚠️ Edge Cases & Handling | Case | Behavior | |------|----------| | User taps Commit with 0 items | Show alert: "Select at least 1 item" | | Temp state expired (5 min) | Show: "Session expired. Send recipe again." | | Duplicate recipe URL | New session, new temp state (don't dedup) | | Message edited by user | Ignore, temp state still valid | | Bot restarted mid-session | Temp state persists in SQLite, survives restart | | Database locked | Retry 3x, then show error | --- ## βœ… Success Criteria - [ ] Toggle callback edits message in-place - [ ] Selected count updates on each toggle - [ ] Commit writes to `grocery_list` table - [ ] Cancel cleans up temp state - [ ] 5-minute timeout handled gracefully - [ ] Zero dependencies on costco_route (copy, don't import) --- ## πŸ“š Reference **Source pattern:** `costco_route/recipe_extractor.py` **Port strategy:** Copy logic, rename variables, Icarus-specific schema **Ready to build.** πŸ§