"""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, project, 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: user = await _gate(ctx, chat_id, message) if user is None: return 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 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: # Check if replying to an ambiguity message (Requirement 2.b) amb_draft, target_amb = await draft_store.get_by_ambiguity_message_id(chat_id, reply_to_msg_id) if amb_draft is not None and target_amb is not None: await _handle_ambiguity_user_reply(ctx, chat_id, user, amb_draft, target_amb, message, message_id) return 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 active.kind == draft_store.KIND_PROJECT: await project.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 active.kind == draft_store.KIND_PROJECT: await project.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 == "add_project": await project.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 # --------------------------------------------------------------------------- from datetime import datetime, timezone import json from pathlib import Path UNREGISTERED_FILE = Path(__file__).resolve().parent.parent / "unregistered_users.json" def _record_unregistered_user(chat_id: int, message: dict) -> None: from_user = message.get("from") or {} record = { "chat_id": chat_id, "username": from_user.get("username"), "first_name": from_user.get("first_name"), "last_name": from_user.get("last_name"), "timestamp": datetime.now(timezone.utc).isoformat(), "text_preview": (message.get("text") or "")[:100], } records = [] try: if UNREGISTERED_FILE.exists(): content = UNREGISTERED_FILE.read_text(encoding="utf-8").strip() if content: records = json.loads(content) if not isinstance(records, list): records = [] except Exception: records = [] records.append(record) try: UNREGISTERED_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8") except Exception as e: logger.warning("Could not write to unregistered_users.json: %s", e) async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None: """Whitelist gate (Casdoor bypassed). Unregistered users are blocked and logged.""" from app.defaults import AUTHORIZED_USERS # 1. Whitelist check (only registered whitelist IDs) if chat_id in AUTHORIZED_USERS: u = AUTHORIZED_USERS[chat_id] return LinkedUser( chat_id=chat_id, user_id=u.get("user_id"), display_name=u.get("display_name"), role=u.get("role", "procurement_manager"), is_admin=u.get("is_admin", True), ) # 2. Block unregistered user and record log _record_unregistered_user(chat_id, message) await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.") 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 async def _handle_ambiguity_user_reply( ctx: BotContext, chat_id: int, user: LinkedUser, draft, amb: dict, message: dict, incoming_msg_id: int | None ) -> None: """Handle text/audio correction sent directly as a reply to an ambiguity sub-message (Requirement 2.b).""" text = (message.get("text") or message.get("caption") or "").strip() if not text and (message.get("voice") or message.get("audio")): try: audio_media = message.get("voice") or message.get("audio") audio_bytes = await ctx.bale.download_file(audio_media["file_id"]) text = await transcribe(settings, audio_bytes) except Exception: text = "" if text: draft_summary = _draft_summary(draft) result = await understand(settings, text, draft_summary) patch = result.get("patch") or {} # Apply user correction specifically to the target field if amb.get("kind") == "payment_schedule": if patch.get("payment_terms"): draft_store.merge_quote_patch(draft.payload, patch) else: draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {}) if not draft.payload.get("notes"): draft.payload["notes"] = f"نحوه پرداخت: {text}" else: draft.payload["notes"] += f"\nنحوه پرداخت: {text}" elif amb.get("kind") == "project_assignment": if patch.get("items") or patch.get("project_name") or patch.get("project_names"): draft_store.merge_quote_patch(draft.payload, patch) else: draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {}) else: draft_store.merge_quote_patch(draft.payload, patch) amb["status"] = "applied" await quote.resolve_payload(ctx, draft.payload) await draft_store.save_payload(draft, mark_bale_synced=True) # In-place edit main review card (Requirement 2.b) await quote.render(ctx, chat_id, draft, fresh=False) # Delete the ambiguity message (Requirement 2.b) if amb.get("message_id"): try: await ctx.delete(chat_id, amb["message_id"]) except Exception: pass # Delete user's message to keep chat clean (Requirement 2.b) if incoming_msg_id: try: await ctx.delete(chat_id, incoming_msg_id) except Exception: pass