feat(bot,wizard): smart message filtering, encrypted standalone wizard, and file persistence
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user