π§ 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_listtable - [ ] 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. π§