πŸ“„ recipe-toggle-backend-brief-socrates.md 8,768 bytes Apr 26, 2026 πŸ“‹ Raw

🧠 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

@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

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)

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

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

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

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:

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. πŸ§