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
+3
View File
@@ -40,6 +40,9 @@ class Settings(BaseSettings):
MINIO_BUCKET: str = "procurement-files"
MINIO_SECURE: bool = False
# Security token for standalone wizard review URL (must match bot)
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
# Background auto-expiry of offered quotes whose expiry_date has passed.
AUTO_EXPIRE_ENABLED: bool = True
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
+89 -1
View File
@@ -8,7 +8,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.draft import BotDraft
from app.utils.errors import NotFoundError
from app.utils.draft_token import verify_draft_token
from app.utils.errors import NotFoundError, UnauthorizedError
router = APIRouter(prefix="/drafts", tags=["drafts"])
@@ -38,6 +39,93 @@ def _serialize_draft(draft: BotDraft) -> dict:
}
def _verify_token_or_raise(token: str) -> dict:
token_payload = verify_draft_token(token)
if not token_payload or not token_payload.get("draft_id"):
raise UnauthorizedError("لینک بازبینی نامعتبر یا منقضی شده است")
return token_payload
@router.get("/verify")
async def verify_and_get_draft(token: str, db: AsyncSession = Depends(get_db)):
"""Public endpoint for token-based draft review without ERP login."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
return {"data": _serialize_draft(draft), "token_payload": token_payload}
@router.patch("/by-token")
async def update_draft_by_token(token: str, body: DraftUpdateIn, db: AsyncSession = Depends(get_db)):
"""Update draft payload using verified token."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
if body.payload is not None:
merged = dict(draft.payload or {})
merged.update(body.payload)
draft.payload = merged
if body.status is not None:
draft.status = body.status
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/by-token/commit")
async def commit_draft_by_token(token: str, body: DraftCommitIn, db: AsyncSession = Depends(get_db)):
"""Commit draft using verified token."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "committed"
payload = dict(draft.payload or {})
if body.quote_id:
payload["committed_quote_id"] = body.quote_id
draft.payload = payload
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/by-token/cancel")
async def cancel_draft_by_token(token: str, db: AsyncSession = Depends(get_db)):
"""Cancel draft using verified token."""
token_payload = _verify_token_or_raise(token)
draft_id = UUID(token_payload["draft_id"])
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "cancelled"
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.get("/{draft_id}")
async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id)
+81
View File
@@ -0,0 +1,81 @@
"""HMAC-SHA256 token verification 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 _get_secret(secret: str | None = None) -> str:
if secret:
return secret
try:
from app.config import settings
return settings.DRAFT_TOKEN_SECRET
except Exception:
return "procurement-draft-secret-change-in-prod-v7"
def create_draft_token(
draft_id: str | UUID,
chat_id: int | None = None,
user_id: str | None = None,
secret: str | None = None,
expires_in: int = 7200,
) -> str:
sec = _get_secret(secret)
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(sec.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 | None = None,
) -> 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:
sec = _get_secret(secret)
payload_b64, sig_b64 = token.split(".", 1)
expected_sig = hmac.new(sec.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"))
exp = payload.get("exp")
if exp is not None and int(exp) < int(time.time()):
return None
return payload
except Exception:
return None