505 lines
22 KiB
Python
505 lines
22 KiB
Python
"""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 import observability as obs
|
|
from app.config import settings
|
|
from app.fallback_matcher import MatchingFailureError
|
|
from app.nlu import NLUFailureError, 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")
|
|
|
|
# Observability (phase 1): open a trace for every incoming message.
|
|
_doc = message.get("document") or {}
|
|
_audio_like = bool(
|
|
message.get("voice") or message.get("audio")
|
|
or (_doc.get("mime_type") or "").lower().startswith("audio/")
|
|
)
|
|
trace_id = await obs.trace_start(chat_id, message_id, "voice" if _audio_like else "text")
|
|
if text:
|
|
await obs.event(trace_id, "input.text", payload={"text": text[:500]})
|
|
|
|
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)
|
|
voice_path = obs.save_voice(trace_id, message_id or 0, audio_bytes)
|
|
await obs.event(trace_id, "input.voice", payload={
|
|
"path": voice_path,
|
|
"size": len(audio_bytes),
|
|
"mime": audio_media.get("mime_type"),
|
|
"file_id": file_id,
|
|
"duration": audio_media.get("duration"),
|
|
})
|
|
transcripts = await transcribe_multi(
|
|
settings,
|
|
audio_bytes,
|
|
mime_type=audio_media.get("mime_type"),
|
|
filename=audio_media.get("file_name"),
|
|
trace_id=trace_id,
|
|
message_id=message_id,
|
|
)
|
|
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
|
|
try:
|
|
result = await understand(settings, input_payload, draft_summary, trace_id=trace_id)
|
|
except (MatchingFailureError, NLUFailureError):
|
|
logger.error("NLU processing failed completely (Universal Fail-Stop Guard)")
|
|
await ctx.send(
|
|
chat_id,
|
|
"⚠️ سامانه در پردازش هوشمند موجودیتها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
|
|
reply_to=message_id,
|
|
)
|
|
return
|
|
|
|
intent, patch = result.get("intent"), result.get("patch") or {}
|
|
consensus_text = result.get("consensus_text") or (text if isinstance(text, str) else "")
|
|
mentions = result.get("entity_mentions") or []
|
|
await obs.event(trace_id, "entities.extracted",
|
|
payload={"patch": patch, "intent": intent, "entity_mentions": mentions})
|
|
|
|
# Entity matching (Two-Stage: Jev scorer + DeepSeek final arbiter; DeepSeek crisis fallback if Jev down)
|
|
jev_map: dict = {}
|
|
if mentions and intent in ("register_quote", "update_draft", "add_material"):
|
|
try:
|
|
from app import matching
|
|
jev_map = await matching.resolve_mentions(
|
|
ctx, patch, mentions, trace_id=trace_id, raw_input=input_payload,
|
|
consensus_text=consensus_text,
|
|
)
|
|
except matching.MatchingFailureError:
|
|
logger.error("entity matching failed completely (Universal Fail-Stop Guard)")
|
|
await ctx.send(
|
|
chat_id,
|
|
"⚠️ سامانه در پردازش هوشمند موجودیتها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
|
|
reply_to=message_id,
|
|
)
|
|
return
|
|
except Exception:
|
|
logger.exception("unexpected matching failure")
|
|
await ctx.send(
|
|
chat_id,
|
|
"⚠️ سامانه در پردازش هوشمند موجودیتها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
|
|
reply_to=message_id,
|
|
)
|
|
return
|
|
|
|
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,
|
|
trace_id=trace_id,
|
|
jev_map=jev_map,
|
|
)
|
|
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, trace_id=trace_id)
|
|
elif active.kind == draft_store.KIND_SUPPLIER:
|
|
await supplier.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
|
elif active.kind == draft_store.KIND_MATERIAL:
|
|
await material.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
|
elif active.kind == draft_store.KIND_PROJECT:
|
|
await project.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
|
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")
|
|
await obs.event(trace_id, "draft.cancel",
|
|
payload={"draft_id": str(active.id), "kind": active.kind})
|
|
await obs.set_trace_status(trace_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, trace_id=trace_id)
|
|
elif intent == "add_material":
|
|
await material.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id, jev_map=jev_map)
|
|
elif intent == "add_project":
|
|
await project.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id)
|
|
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)."""
|
|
trace_id = await obs.latest_trace_for_draft(draft.id)
|
|
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"])
|
|
obs.save_voice(trace_id, incoming_msg_id or 0, audio_bytes)
|
|
text = await transcribe(
|
|
settings, audio_bytes, trace_id=trace_id, message_id=incoming_msg_id
|
|
)
|
|
except Exception:
|
|
text = ""
|
|
|
|
if text:
|
|
await obs.event(trace_id, "ambiguity.user_choice", payload={
|
|
"amb_id": amb.get("id"), "action": "reply", "kind": amb.get("kind"),
|
|
"reply_text": text[:500],
|
|
})
|
|
draft_summary = _draft_summary(draft)
|
|
result = await understand(settings, text, draft_summary, trace_id=trace_id)
|
|
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, trace_id)
|
|
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, trace_id=trace_id)
|
|
|
|
# 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
|
|
|