"""Update dispatch — the heart of the bot. Pipeline per update: callback_query → callbacks router (link-gated) message text starting "/" → command router voice/audio → download → STT → treated as text (source=voice) text → pending-flow interception (linking / accept-arrive confirmations) → auth gate (link or admin) → NLU → intent router """ import logging from app import drafts as draft_store from app.config import settings from app.nlu import understand from app.runtime import BotContext, LinkedUser from app.storage import save_proforma_file from app.stt import transcribe, transcribe_multi from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc from . import commands, callbacks, quote, supplier, material, queries, decisions logger = logging.getLogger("bot.handlers") async def handle_update(ctx: BotContext, update: dict) -> None: if update.get("callback_query"): await callbacks.handle_callback(ctx, update["callback_query"]) return message = update.get("message") or update.get("edited_message") if not message: return chat_id = message["chat"]["id"] try: await handle_message(ctx, chat_id, message) except Exception: logger.exception("message handling failed for chat %s", chat_id) await ctx.send(chat_id, "خطای غیرمنتظره رخ داد؛ دوباره تلاش کن.") async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: text = (message.get("text") or message.get("caption") or "").strip() source = "text" voice_transcripts: list[dict] | None = None message_id = message.get("message_id") if text.startswith("/"): await commands.handle_command(ctx, chat_id, text) return # Pending multi-step interactions take the message first. flow = ctx.pending.get(chat_id) if flow: handled = await _route_to_flow(ctx, chat_id, flow, text) if handled: return ctx.pending.pop(chat_id) # stale/irrelevant flow → fall through user = await _gate(ctx, chat_id, message) if user is None: return # gate either started linking or is mid-linking reply_to = message.get("reply_to_message") reply_to_msg_id = reply_to.get("message_id") if reply_to else None def _is_audio(msg: dict) -> bool: if msg.get("voice") or msg.get("audio"): return True doc = msg.get("document") if doc and (doc.get("mime_type") or "").lower().startswith("audio/"): return True return False async def _extract_voice() -> list[dict] | None: nonlocal source source = "voice" await ctx.bale.send_chat_action(chat_id, "typing") audio_media = message.get("voice") or message.get("audio") or message.get("document") file_id = audio_media["file_id"] try: audio_bytes = await ctx.bale.download_file(file_id) transcripts = await transcribe_multi( settings, audio_bytes, mime_type=audio_media.get("mime_type"), filename=audio_media.get("file_name"), ) except Exception as exc: detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200] await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)), reply_to=message_id) return None if not transcripts: await ctx.send(chat_id, VOICE_EMPTY, reply_to=message_id) return None return transcripts # ========================================================================= # BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft # ========================================================================= if reply_to_msg_id is not None: target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id) if target_draft is None: await ctx.send( chat_id, "⚠️ پیش‌نویس فعال مربوط به این پیام یافت نشد (ممکن است ثبت نهایی، منقضی یا لغو شده باشد).\nبرای ثبت استعلام جدید، پیام خود را بدون ریپلای ارسال فرمایید.", reply_to=message_id, ) return # Check for proforma attachment (document or photo) proforma_attached = False saved_meta = None doc = message.get("document") photos = message.get("photo") if doc and not (doc.get("mime_type") or "").lower().startswith("audio/"): doc_file_id = doc["file_id"] doc_file_name = doc.get("file_name") or f"proforma_{message_id}.pdf" doc_mime = doc.get("mime_type") or "application/pdf" elif photos: largest = photos[-1] if isinstance(photos, list) else photos doc_file_id = largest["file_id"] doc_file_name = f"proforma_{message_id}.jpg" doc_mime = "image/jpeg" else: doc_file_id = None doc_file_name = None doc_mime = None if doc_file_id: await ctx.bale.send_chat_action(chat_id, "upload_document") try: content = await ctx.bale.download_file(doc_file_id) saved_meta = await save_proforma_file( target_draft.id, doc_file_name, content, mime_type=doc_mime, storage_dir=settings.ATTACHMENTS_DIR, ) payload = dict(target_draft.payload or {}) payload["proforma_file"] = saved_meta["file_name"] payload["proforma"] = saved_meta target_draft.payload = payload await draft_store.save_payload(target_draft, mark_bale_synced=True) proforma_attached = True except Exception as exc: logger.exception("Failed to persist proforma") await ctx.send(chat_id, f"❌ خطا در ذخیره فایل پیش‌فاکتور: {esc(str(exc)[:100])}", reply_to=message_id) return if _is_audio(message): voice_transcripts = await _extract_voice() if not voice_transcripts: return text = voice_transcripts[0]["text"] if proforma_attached and not text and not voice_transcripts: await quote.render(ctx, chat_id, target_draft, fresh=False) await ctx.send( chat_id, f"📎 پیش‌فاکتور «{saved_meta['file_name']}» با موفقیت ذخیره و به استعلام ضمیمه شد ✅", reply_to=message_id, ) return if not proforma_attached and not text and not voice_transcripts: await ctx.send( chat_id, "⚠️ برای ویرایش پیش‌نویس، لطفاً تغییرات را به صورت متن یا پیام صوتی (ویس) ارسال کنید، یا فایل پیش‌فاکتور را بفرستید.", reply_to=message_id, ) return # ========================================================================= # BRANCH 2: NEW QUOTE MODE — Messages without reply # ========================================================================= else: target_draft = None if _is_audio(message): voice_transcripts = await _extract_voice() if not voice_transcripts: return text = voice_transcripts[0]["text"] elif text: source = "text" else: await ctx.send( chat_id, "⚠️ برای ثبت استعلام خرید جدید، لطفاً اقلام و مشخصات را به صورت پیام متنی یا پیام صوتی (ویس) ارسال فرمایید.", reply_to=message_id, ) return draft_summary = _draft_summary(target_draft) if target_draft else None input_payload = voice_transcripts if voice_transcripts else text result = await understand(settings, input_payload, draft_summary) intent, patch = result.get("intent"), result.get("patch") or {} consensus_text = result.get("consensus_text") or text if source == "voice": await ctx._log(chat_id, "in", "voice", consensus_text) logger.info("chat %s intent=%s conf=%s consensus=%r (is_edit=%s)", chat_id, intent, result.get("confidence"), consensus_text[:40], target_draft is not None) if intent in ("register_quote", "update_draft"): await quote.handle_entities( ctx, chat_id, user, patch, source, incoming_msg_id=message_id, target_draft=target_draft, ) elif intent == "commit_draft": active = target_draft or await draft_store.get_active(chat_id) if active is None: await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id) elif active.kind == draft_store.KIND_QUOTE: await quote.commit(ctx, chat_id, user, active) elif active.kind == draft_store.KIND_SUPPLIER: await supplier.commit(ctx, chat_id, user, active) elif active.kind == draft_store.KIND_MATERIAL: await material.commit(ctx, chat_id, user, active) elif intent == "discard_draft": active = target_draft or await draft_store.get_active(chat_id) if active: await draft_store.set_status(active.id, "cancelled") if active.review_message_id: try: await ctx.delete(chat_id, active.review_message_id) except Exception: pass ctx.pending.pop(chat_id, None) await ctx.send(chat_id, CANCELLED, reply_to=message_id) elif intent == "show_draft": active = target_draft or await draft_store.get_active(chat_id) if active is None: await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id) elif active.kind == draft_store.KIND_QUOTE: await quote.render(ctx, chat_id, active, fresh=False) elif active.kind == draft_store.KIND_SUPPLIER: await supplier.render(ctx, chat_id, active) elif active.kind == draft_store.KIND_MATERIAL: await material.render(ctx, chat_id, active) elif intent == "add_supplier": await supplier.handle_entities(ctx, chat_id, user, patch, source) elif intent == "add_material": await material.handle_entities(ctx, chat_id, user, patch, source) elif intent == "status_query": await queries.handle_query(ctx, chat_id, user, patch) elif intent == "accept_quote": await decisions.start_accept(ctx, chat_id, user, patch) elif intent == "arrive_items": await decisions.start_arrive(ctx, chat_id, user, patch) else: await ctx.send(chat_id, UNKNOWN_HINT) # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None: """Link or admin gate. Returns None when the message was consumed by linking.""" from . import links link = await ctx.links.get(chat_id) if link and link.user_id: return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False) if chat_id in ctx.admin_chat_ids: return LinkedUser(chat_id, None, None, None, is_admin=True) flow = ctx.pending.get(chat_id) if flow and flow.kind == "link_password": await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "") return None if flow and flow.kind == "link_username": await links.finish_username_step(ctx, chat_id, flow, message.get("text") or "") return None await ctx.send(chat_id, LINK_START) ctx.pending.set(chat_id, "link_username", {}) return None async def _route_to_flow(ctx: BotContext, chat_id: int, flow, text: str) -> bool: from . import links, decisions if flow.kind == "link_username": await links.finish_username_step(ctx, chat_id, flow, text) return True if flow.kind == "link_password": await links.finish_link(ctx, chat_id, flow, text=text, password=text) return True if flow.kind == "quote_pick": await decisions.handle_quote_pick_text(ctx, chat_id, flow, text) return True if flow.kind in ("accept", "arrive_qty", "arrive_confirm"): return await decisions.handle_flow_text(ctx, chat_id, flow, text) return False def _draft_summary(draft) -> str | None: if draft is None: return None import json payload = draft.payload or {} if draft.kind == draft_store.KIND_QUOTE: items = ", ".join( f"{it.get('material_name') or '?'}" + (f" {it.get('quantity')}" if it.get("quantity") else "") + (f" {it.get('unit_name')}" if it.get("unit_name") else "") for it in payload.get("items") or [] ) summary = ( f"kind=quote; supplier={payload.get('supplier_name') or '?'}; project={payload.get('project_name') or '?'}; " f"items=[{items}]; terms={[t.get('label') for t in payload.get('termsheets') or []]}" ) elif draft.kind == draft_store.KIND_SUPPLIER: summary = f"kind=add_supplier; name={payload.get('name') or '?'}; category={payload.get('category_name') or '?'}" elif draft.kind == draft_store.KIND_MATERIAL: summary = f"kind=add_material; name={payload.get('name') or '?'}; unit={payload.get('unit_name') or '?'}" else: summary = json.dumps(payload, ensure_ascii=False)[:500] return summary