feat(bot,wizard): smart message filtering, encrypted standalone wizard, and file persistence

This commit is contained in:
2026-09-30 00:49:04 +03:30
parent 0a7db5ebe9
commit 07643dbdf5
13 changed files with 750 additions and 56 deletions
+123 -24
View File
@@ -14,6 +14,7 @@ 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
@@ -38,35 +39,15 @@ async def handle_update(ctx: BotContext, update: dict) -> None:
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
text = (message.get("text") or "").strip()
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
if not text and (message.get("voice") or message.get("audio") or message.get("document")):
source = "voice"
await ctx.bale.send_chat_action(chat_id, "typing")
file_id = (message.get("voice") or message.get("audio") or message.get("document"))["file_id"]
try:
audio = await ctx.bale.download_file(file_id)
voice_transcripts = await transcribe_multi(
settings,
audio,
mime_type=(message.get("voice") or {}).get("mime_type"),
filename=(message.get("voice") or {}).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)))
return
if not voice_transcripts:
await ctx.send(chat_id, VOICE_EMPTY)
return
text = voice_transcripts[0]["text"]
# Pending multi-step interactions take the message first.
flow = ctx.pending.get(chat_id)
if flow:
@@ -79,11 +60,43 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
if user is None:
return # gate either started linking or is mid-linking
message_id = message.get("message_id")
reply_to = message.get("reply_to_message")
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
target_draft = 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:
@@ -94,6 +107,92 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
)
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)
+30 -2
View File
@@ -198,9 +198,17 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
reply_to: int | None = None) -> None:
from app.config import settings
from app.security import create_draft_token
payload = draft.payload
missing = compute_quote_missing(payload)
wizard_url = f"{settings.FRONTEND_URL}/wizard?draft={draft.id}"
token = create_draft_token(
draft.id,
chat_id=chat_id,
secret=settings.DRAFT_TOKEN_SECRET,
expires_in=settings.DRAFT_TOKEN_EXPIRE_SECONDS,
)
wizard_url = f"{settings.FRONTEND_URL.rstrip('/')}/review?token={token}"
text = quote_review_text(payload, missing, status_note=note)
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
@@ -302,9 +310,29 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
ctx.pending.pop(chat_id, None)
ctx.resolver.invalidate()
quote_id = (created or {}).get("id") or ""
# If a proforma file was attached, persist it to the created ERP quote
proforma_info = payload.get("proforma")
if quote_id and proforma_info and proforma_info.get("saved_path"):
try:
from pathlib import Path
p_path = Path(proforma_info["saved_path"])
if p_path.exists():
file_bytes = p_path.read_bytes()
await ctx.api.upload_quote_file(
quote_id,
"proforma",
proforma_info.get("file_name") or "proforma.pdf",
file_bytes,
mime_type=proforma_info.get("mime_type") or "application/pdf",
actor_id=user.actor_id,
)
logger.info("Successfully uploaded proforma to quote %s", quote_id)
except Exception as e:
logger.warning("Could not upload draft proforma to quote %s: %s", quote_id, e)
if draft.review_message_id:
try:
quote_id = (created or {}).get("id") or ""
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)