feat(bot,wizard): smart message filtering, encrypted standalone wizard, and file persistence
This commit is contained in:
@@ -135,3 +135,16 @@ class ErpClient:
|
||||
return (await self._request(
|
||||
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
|
||||
)).get("data") or {}
|
||||
|
||||
async def upload_quote_file(self, quote_id: str, kind: str, file_name: str, content: bytes,
|
||||
mime_type: str = "application/pdf", actor_id: str | None = None) -> dict:
|
||||
url = f"{self._base}/quotes/{quote_id}/{kind}"
|
||||
files = {"file": (file_name, content, mime_type)}
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
resp = await client.post(url, files=files, headers=self._actor_headers(actor_id, None))
|
||||
if resp.status_code in (200, 201):
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to upload quote attachment to ERP: %s", e)
|
||||
return {}
|
||||
|
||||
@@ -31,6 +31,13 @@ class Settings(BaseSettings):
|
||||
# Frontend URL (for opening Wizard from Bale bot)
|
||||
FRONTEND_URL: str = "http://localhost:8081"
|
||||
|
||||
# Security token for standalone wizard review URL
|
||||
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
|
||||
DRAFT_TOKEN_EXPIRE_SECONDS: int = 7200 # 2 hours
|
||||
|
||||
# Persistent storage directory for draft attachments (proformas)
|
||||
ATTACHMENTS_DIR: str = "data/attachments/proformas"
|
||||
|
||||
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
|
||||
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
|
||||
AI_API_KEY: str = ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Security and token generation for standalone draft review."""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
padding = 4 - (len(s) % 4)
|
||||
if padding != 4:
|
||||
s += "=" * padding
|
||||
return base64.urlsafe_b64decode(s.encode("ascii"))
|
||||
|
||||
|
||||
def create_draft_token(
|
||||
draft_id: str | UUID,
|
||||
chat_id: int | None = None,
|
||||
user_id: str | None = None,
|
||||
secret: str = "procurement-draft-secret-change-in-prod-v7",
|
||||
expires_in: int = 7200,
|
||||
) -> str:
|
||||
"""Generate an HMAC-SHA256 signed token for secure draft access."""
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"draft_id": str(draft_id),
|
||||
"chat_id": chat_id,
|
||||
"user_id": str(user_id) if user_id else None,
|
||||
"iat": now,
|
||||
"exp": now + expires_in,
|
||||
}
|
||||
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
payload_b64 = _b64url_encode(payload_bytes)
|
||||
|
||||
sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
sig_b64 = _b64url_encode(sig)
|
||||
|
||||
return f"{payload_b64}.{sig_b64}"
|
||||
|
||||
|
||||
def verify_draft_token(
|
||||
token: str,
|
||||
secret: str = "procurement-draft-secret-change-in-prod-v7",
|
||||
) -> dict[str, Any] | None:
|
||||
"""Verify signature and expiration of a draft token. Return payload or None."""
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
try:
|
||||
payload_b64, sig_b64 = token.split(".", 1)
|
||||
expected_sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||
actual_sig = _b64url_decode(sig_b64)
|
||||
|
||||
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||
return None
|
||||
|
||||
payload_bytes = _b64url_decode(payload_b64)
|
||||
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||
|
||||
# Check expiration
|
||||
exp = payload.get("exp")
|
||||
if exp is not None and int(exp) < int(time.time()):
|
||||
return None
|
||||
|
||||
return payload
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Persistent storage for draft attachments (proforma files)."""
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
logger = logging.getLogger("bot.storage")
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize filename preserving Persian/Arabic/English letters, numbers, and extensions."""
|
||||
raw = (filename or "proforma.pdf").strip()
|
||||
name = Path(raw).name
|
||||
# Remove control characters and path traversal
|
||||
clean = re.sub(r'[\/\\:\*\?"<>\|\r\n\t]', "_", name)
|
||||
clean = clean.strip(". ")
|
||||
return clean or f"proforma_{uuid4().hex[:8]}.pdf"
|
||||
|
||||
|
||||
async def save_proforma_file(
|
||||
draft_id: str | UUID,
|
||||
file_name: str,
|
||||
content: bytes,
|
||||
mime_type: str | None = None,
|
||||
storage_dir: str = "data/attachments/proformas",
|
||||
) -> dict:
|
||||
"""Save proforma bytes permanently to disk.
|
||||
|
||||
Returns dict metadata for persisting in draft payload.
|
||||
"""
|
||||
clean_name = sanitize_filename(file_name)
|
||||
target_dir = Path(storage_dir) / str(draft_id)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
unique_prefix = uuid4().hex[:8]
|
||||
saved_filename = f"{unique_prefix}_{clean_name}"
|
||||
target_path = target_dir / saved_filename
|
||||
|
||||
# Write bytes to disk
|
||||
target_path.write_bytes(content)
|
||||
logger.info("Persisted proforma (%d bytes) to %s", len(content), target_path)
|
||||
|
||||
return {
|
||||
"file_name": clean_name,
|
||||
"saved_filename": saved_filename,
|
||||
"saved_path": str(target_path.resolve()),
|
||||
"relative_path": str(target_path.as_posix()),
|
||||
"size_bytes": len(content),
|
||||
"mime_type": mime_type or "application/octet-stream",
|
||||
"saved_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
Reference in New Issue
Block a user