Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/crm_chat_service.py
2026-05-15 13:42:50 +03:30

1278 lines
39 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# noqa: D100
"""منطق چت وب CRM: ویجت، مکالمه، پیام، تریگر ورک‌فلو."""
from __future__ import annotations
import hashlib
import logging
import re
import secrets
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from uuid import UUID
from sqlalchemy import and_, delete, desc, func, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from fastapi import HTTPException, UploadFile
from adapters.db.models.business import Business
from adapters.db.models.business_crm_settings import BusinessCrmSettings
from adapters.db.models.crm_chat import CrmChatConversation, CrmChatMessage, CrmChatWidget
from adapters.db.models.messenger_operator_session import MessengerOperatorSession
from adapters.db.models.file_storage import FileStorage
from adapters.db.models.user import User
from app.core.responses import ApiError
from app.services.crm_chat_realtime import crm_chat_realtime_manager
from app.services.file_storage_service import FileStorageService
from app.services.workflow.workflow_trigger_enrichment import build_crm_chat_visitor_message_trigger_enrichment
from app.services.workflow.workflow_trigger_service import trigger_workflows
logger = logging.getLogger(__name__)
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_MAX_BODY = 8000
def _hash_visitor_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def agent_display_name(user: User) -> str:
"""نام نمایشی عامل برای ویجت و رویدادهای بلادرنگ."""
parts = [p for p in (user.first_name or "", user.last_name or "") if p]
s = " ".join(parts).strip()
if s:
return s
if user.email:
return user.email.strip()
if user.mobile:
return user.mobile.strip()
return "پشتیبان"
def _validate_email(email: str) -> None:
if not _EMAIL_RE.match((email or "").strip()):
raise ApiError("CRM_CHAT_INVALID_EMAIL", "Invalid email address", http_status=422)
def _normalize_host_from_origin(origin: Optional[str]) -> Optional[str]:
if not origin:
return None
try:
p = urlparse(origin.strip())
return (p.hostname or "").lower() or None
except Exception:
return None
def origin_allowed(widget: CrmChatWidget, origin_header: Optional[str]) -> bool:
raw = widget.allowed_origins
if not raw or not isinstance(raw, list):
return True
hosts = [str(x).strip().lower() for x in raw if str(x).strip()]
if not hosts:
return True
oh = _normalize_host_from_origin(origin_header)
if not oh:
return False
return oh in hosts
def _fire(
db: Session,
business_id: int,
trigger_type: str,
trigger_data: Dict[str, Any],
user_id: Optional[int] = None,
) -> None:
try:
trigger_workflows(db, business_id, trigger_type, trigger_data, user_id)
except Exception:
logger.exception("crm chat workflow trigger failed type=%s business_id=%s", trigger_type, business_id)
def _visitor_message_workflow_payload(
db: Session,
business_id: int,
c: CrmChatConversation,
msg: CrmChatMessage,
text: str,
file_storage_id: Optional[str] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"conversation_id": c.id,
"widget_id": c.widget_id,
"message_id": msg.id,
"body": text,
"sender_role": "visitor",
}
if file_storage_id:
payload["file_storage_id"] = file_storage_id
# Compatibility alias for actions expecting attachment_file_id.
payload["attachment_file_id"] = file_storage_id
payload.update(build_crm_chat_visitor_message_trigger_enrichment(db, business_id, c.id))
return payload
def widget_to_dict(w: CrmChatWidget) -> Dict[str, Any]:
return {
"id": w.id,
"business_id": w.business_id,
"name": w.name,
"public_key": w.public_key,
"allowed_origins": w.allowed_origins or [],
"settings": w.settings or {},
"is_active": bool(w.is_active),
"created_at": w.created_at,
"updated_at": w.updated_at,
}
def visitor_file_upload_effective_for_widget(db: Session, w: CrmChatWidget) -> bool:
"""
ارسال فایل توسط بازدیدکننده: ابتدا تنظیم کسب‌وکار، سپس settings ویجت
(allow_visitor_file_upload == False برای غیرفعال کردن فقط این ویجت).
"""
s = get_or_create_crm_settings(db, w.business_id)
if not s.allow_web_chat_file_upload:
return False
st = w.settings or {}
if st.get("allow_visitor_file_upload") is False:
return False
return True
def visitor_voice_effective_for_widget(db: Session, w: CrmChatWidget) -> bool:
"""پس از فعال بودن سطح کسب‌وکار، می‌توان با settings ویجت قطع کرد."""
s = get_or_create_crm_settings(db, w.business_id)
if not bool(getattr(s, "allow_web_chat_voice", False)):
return False
st = w.settings or {}
if st.get("allow_visitor_voice") is False:
return False
return True
def _upload_likely_audio(*, content_type: Optional[str], filename: Optional[str]) -> bool:
ct = (content_type or "").strip().lower()
if ct.startswith("audio/"):
return True
fn = (filename or "").lower()
for ext in (".webm", ".weba", ".ogg", ".oga", ".opus", ".mp3", ".m4a", ".aac", ".wav", ".flac"):
if fn.endswith(ext):
return True
return False
def conversation_to_dict(c: CrmChatConversation) -> Dict[str, Any]:
return {
"id": c.id,
"business_id": c.business_id,
"widget_id": c.widget_id,
"status": c.status,
"visitor_first_name": c.visitor_first_name,
"visitor_last_name": c.visitor_last_name,
"visitor_email": c.visitor_email,
"visitor_phone": c.visitor_phone,
"page_url": c.page_url,
"extra_metadata": c.extra_metadata if c.extra_metadata else {},
"lead_id": c.lead_id,
"person_id": c.person_id,
"assigned_to_user_id": c.assigned_to_user_id,
"last_message_at": c.last_message_at,
"created_at": c.created_at,
"updated_at": c.updated_at,
}
def message_to_dict(m: CrmChatMessage) -> Dict[str, Any]:
is_del = m.deleted_at is not None
return {
"id": m.id,
"conversation_id": m.conversation_id,
"sender_role": m.sender_role,
"body": "" if is_del else (m.body or ""),
"user_id": m.user_id,
"file_storage_id": None if is_del else m.file_storage_id,
"created_at": m.created_at,
"edited_at": m.edited_at,
"read_at": m.read_at,
"deleted_at": m.deleted_at,
"is_deleted": is_del,
}
def _message_to_dict_enriched(db: Session, m: CrmChatMessage) -> Dict[str, Any]:
d = message_to_dict(m)
if m.deleted_at is not None:
d["file"] = None
return d
if m.file_storage_id:
fs = db.get(FileStorage, m.file_storage_id)
if fs:
d["file"] = {
"id": str(fs.id),
"original_name": fs.original_name,
"file_size": fs.file_size,
"mime_type": fs.mime_type,
}
else:
d["file"] = None
else:
d["file"] = None
if m.sender_role == "agent" and m.user_id:
u = db.get(User, m.user_id)
if u:
d["sender_name"] = agent_display_name(u)
return d
def _message_dict_for_realtime_ws(db: Session, m: CrmChatMessage) -> Dict[str, Any]:
"""همان enriched اما زمان‌ها به صورت ISO با آفست منطقهٔ نمایش سیستم (برای WebSocket)."""
from app.services.system_settings_service import datetime_to_system_tz_iso_string
d = _message_to_dict_enriched(db, m)
out = dict(d)
for k in ("created_at", "edited_at", "read_at", "deleted_at"):
v = out.get(k)
if isinstance(v, datetime):
out[k] = datetime_to_system_tz_iso_string(v)
return out
def get_or_create_crm_settings(db: Session, business_id: int) -> BusinessCrmSettings:
row = db.get(BusinessCrmSettings, business_id)
if not row:
row = BusinessCrmSettings(
business_id=business_id,
allow_web_chat_file_upload=False,
allow_web_chat_voice=False,
)
db.add(row)
db.commit()
db.refresh(row)
return row
def business_crm_settings_to_dict(s: BusinessCrmSettings) -> Dict[str, Any]:
return {
"business_id": s.business_id,
"allow_web_chat_file_upload": bool(s.allow_web_chat_file_upload),
"allow_web_chat_voice": bool(getattr(s, "allow_web_chat_voice", False)),
"updated_at": s.updated_at,
}
def update_crm_business_settings(
db: Session,
business_id: int,
*,
allow_web_chat_file_upload: Optional[bool] = None,
allow_web_chat_voice: Optional[bool] = None,
) -> BusinessCrmSettings:
row = get_or_create_crm_settings(db, business_id)
if allow_web_chat_file_upload is not None:
row.allow_web_chat_file_upload = allow_web_chat_file_upload
if allow_web_chat_voice is not None:
row.allow_web_chat_voice = allow_web_chat_voice
row.updated_at = datetime.utcnow()
db.commit()
db.refresh(row)
return row
def _notify_business_crm_storage(db: Session, business_id: int, *, title: str, message: str) -> None:
b = db.get(Business, business_id)
if not b or b.owner_id is None:
return
try:
from app.services.notification_service import NotificationService
NotificationService(db).send(
user_id=int(b.owner_id),
event_key="system.generic",
context={"subject": title, "message": message},
preferred_channels=["inapp", "email"],
)
except Exception:
logger.exception("notify business %s for crm file storage failed", business_id)
def _assert_crm_file_for_conversation(
db: Session, *, business_id: int, conversation_id: int, file_id: str
) -> FileStorage:
fs = db.get(FileStorage, file_id)
if not fs or fs.business_id != business_id:
raise ApiError("CRM_CHAT_FILE_NOT_FOUND", "File not found", http_status=404)
if (fs.module_context or "") != "crm_web_chat":
raise ApiError("CRM_CHAT_FILE_NOT_FOR_CHAT", "This file is not used for chat", http_status=422)
if str(fs.context_id or "") != str(conversation_id):
raise ApiError("CRM_CHAT_FILE_WRONG_CONVERSATION", "The file does not belong to this conversation", http_status=422)
return fs
def get_widget_by_public_key(db: Session, public_key: str) -> CrmChatWidget:
w = db.scalar(select(CrmChatWidget).where(CrmChatWidget.public_key == public_key.strip()))
if not w or not w.is_active:
raise ApiError("CRM_CHAT_WIDGET_INACTIVE", "Chat widget not found or inactive", http_status=404)
return w
def list_widgets(db: Session, business_id: int) -> List[CrmChatWidget]:
return list(
db.scalars(
select(CrmChatWidget).where(CrmChatWidget.business_id == business_id).order_by(CrmChatWidget.id.desc())
).all()
)
def create_widget(
db: Session,
business_id: int,
name: str,
allowed_origins: Optional[List[str]],
settings: Optional[dict],
is_active: bool,
) -> CrmChatWidget:
pk = secrets.token_urlsafe(24).replace("-", "")[:48]
w = CrmChatWidget(
business_id=business_id,
name=name.strip(),
public_key=pk,
allowed_origins=allowed_origins,
settings=settings,
is_active=is_active,
)
db.add(w)
db.commit()
db.refresh(w)
return w
def update_widget(
db: Session,
business_id: int,
widget_id: int,
*,
name: Optional[str] = None,
allowed_origins: Optional[List[str]] = None,
settings: Optional[dict] = None,
is_active: Optional[bool] = None,
) -> CrmChatWidget:
w = db.get(CrmChatWidget, widget_id)
if not w or w.business_id != business_id:
raise ApiError("CRM_CHAT_WIDGET_NOT_FOUND", "Widget not found", http_status=404)
if name is not None:
w.name = name.strip()
if allowed_origins is not None:
w.allowed_origins = allowed_origins
if settings is not None:
w.settings = settings
if is_active is not None:
w.is_active = is_active
w.updated_at = datetime.utcnow()
db.commit()
db.refresh(w)
return w
async def start_conversation_public(
db: Session,
*,
public_key: str,
first_name: str,
last_name: str,
email: str,
phone: str,
page_url: Optional[str],
origin_header: Optional[str],
client_ip: Optional[str] = None,
device_type: Optional[str] = None,
) -> Dict[str, Any]:
w = get_widget_by_public_key(db, public_key)
if not origin_allowed(w, origin_header):
raise ApiError("CRM_CHAT_ORIGIN_NOT_ALLOWED", "The request origin is not allowed for this widget", http_status=403)
email_st = (email or "").strip().lower()
if email_st:
_validate_email(email_st)
else:
email_st = ""
fn = first_name.strip()
ln = last_name.strip()
ph = phone.strip()
if len(fn) < 1 or len(ln) < 1:
raise ApiError("CRM_CHAT_VISITOR_NAME_REQUIRED", "First and last name are required", http_status=422)
if len(ph) < 5:
raise ApiError("CRM_CHAT_PHONE_INVALID", "Enter a valid phone number", http_status=422)
visitor_token = secrets.token_urlsafe(32)
th = _hash_visitor_token(visitor_token)
meta: Dict[str, Any] = {}
raw_ip = (client_ip or "").strip().split("%")[0].strip()
if raw_ip:
meta["visitor_ip"] = raw_ip[:45]
dt = (device_type or "").strip().lower()
if dt in ("mobile", "tablet", "desktop"):
meta["device_type"] = dt
c = CrmChatConversation(
business_id=w.business_id,
widget_id=w.id,
status="open",
visitor_first_name=fn,
visitor_last_name=ln,
visitor_email=email_st,
visitor_phone=ph,
visitor_token_hash=th,
page_url=(page_url or None) if page_url else None,
last_message_at=None,
extra_metadata=meta if meta else None,
)
db.add(c)
db.commit()
db.refresh(c)
start_trigger_payload: Dict[str, Any] = {
"conversation_id": c.id,
"widget_id": w.id,
"visitor_first_name": fn,
"visitor_last_name": ln,
"visitor_email": c.visitor_email,
"visitor_phone": ph,
"page_url": c.page_url,
}
start_trigger_payload.update(
build_crm_chat_visitor_message_trigger_enrichment(db, w.business_id, c.id)
)
_fire(db, w.business_id, "crm.chat.conversation.started", start_trigger_payload)
await crm_chat_realtime_manager.broadcast_business(
w.business_id,
{
"type": "crm_chat.event",
"event": "conversation.started",
"conversation": conversation_to_dict(c),
},
)
return {
"conversation_id": c.id,
"visitor_token": visitor_token,
"widget_id": w.id,
}
def _get_conversation_by_visitor(db: Session, visitor_token: str, conversation_id: int) -> CrmChatConversation:
th = _hash_visitor_token(visitor_token)
c = db.scalar(
select(CrmChatConversation).where(
CrmChatConversation.id == conversation_id,
CrmChatConversation.visitor_token_hash == th,
)
)
if not c:
raise ApiError("CRM_CHAT_CONVERSATION_NOT_FOUND", "Conversation not found", http_status=404)
return c
_MAX_PAGE_URL_LEN = 2048
async def update_visitor_current_page_url(
db: Session,
*,
visitor_token: str,
conversation_id: int,
page_url: str,
origin_header: Optional[str],
) -> Dict[str, Any]:
"""نشانی فعلی صفحهٔ مرورگر بازدیدکننده؛ پس از ذخیره، رویداد conversation.updated منتشر می‌شود."""
c = _get_conversation_by_visitor(db, visitor_token, conversation_id)
_assert_widget_origin(db, c, origin_header)
raw = (page_url or "").strip()
if not raw:
raise ApiError("CRM_CHAT_PAGE_URL_INVALID", "page_url is required", http_status=422)
if len(raw) > _MAX_PAGE_URL_LEN:
raw = raw[:_MAX_PAGE_URL_LEN]
if c.page_url == raw:
return conversation_to_dict(c)
c.page_url = raw
c.updated_at = datetime.utcnow()
db.commit()
db.refresh(c)
ev = {
"type": "crm_chat.event",
"event": "conversation.updated",
"conversation": conversation_to_dict(c),
}
await crm_chat_realtime_manager.broadcast_conversation(c.id, ev)
await crm_chat_realtime_manager.broadcast_business(c.business_id, ev)
return conversation_to_dict(c)
def _assert_widget_origin(db: Session, c: CrmChatConversation, origin_header: Optional[str]) -> None:
"""مبدأ درخواست باید با allowed_origins ویجت سازگار باشد (مثل ارسال پیام)."""
w = db.get(CrmChatWidget, c.widget_id)
if not w:
raise ApiError("CRM_CHAT_WIDGET_NOT_FOUND", "Widget not found", http_status=404)
if not origin_allowed(w, origin_header):
raise ApiError("CRM_CHAT_ORIGIN_NOT_ALLOWED", "The request origin is not allowed for this widget", http_status=403)
def _get_conversation_business(db: Session, business_id: int, conversation_id: int) -> CrmChatConversation:
c = db.get(CrmChatConversation, conversation_id)
if not c or c.business_id != business_id:
raise ApiError("CRM_CHAT_CONVERSATION_NOT_FOUND", "Conversation not found", http_status=404)
return c
async def post_visitor_message(
db: Session,
*,
visitor_token: str,
conversation_id: int,
body: str,
origin_header: Optional[str],
) -> Dict[str, Any]:
c = _get_conversation_by_visitor(db, visitor_token, conversation_id)
_assert_widget_origin(db, c, origin_header)
text = (body or "").strip()
if not text or len(text) > _MAX_BODY:
raise ApiError("CRM_CHAT_MESSAGE_BODY_INVALID", "Invalid message text", http_status=422)
msg = CrmChatMessage(conversation_id=c.id, sender_role="visitor", body=text, user_id=None, file_storage_id=None)
db.add(msg)
c.last_message_at = datetime.utcnow()
c.updated_at = datetime.utcnow()
db.commit()
db.refresh(msg)
payload = {"type": "crm_chat.event", "event": "message.created", "message": _message_dict_for_realtime_ws(db, msg)}
await crm_chat_realtime_manager.broadcast_conversation(c.id, payload)
await crm_chat_realtime_manager.broadcast_business(c.business_id, {**payload, "conversation_id": c.id})
_fire(
db,
c.business_id,
"crm.chat.message.received",
_visitor_message_workflow_payload(db, c.business_id, c, msg, text),
)
return _message_to_dict_enriched(db, msg)
async def post_visitor_file(
db: Session,
*,
visitor_token: str,
conversation_id: int,
caption: Optional[str],
upload: UploadFile,
origin_header: Optional[str],
) -> Dict[str, Any]:
c = _get_conversation_by_visitor(db, visitor_token, conversation_id)
_assert_widget_origin(db, c, origin_header)
wgt = db.get(CrmChatWidget, c.widget_id)
if not wgt:
raise ApiError("CRM_CHAT_WIDGET_NOT_FOUND", "Widget not found", http_status=404)
is_aud = _upload_likely_audio(content_type=getattr(upload, "content_type", None), filename=upload.filename)
if is_aud:
if not visitor_voice_effective_for_widget(db, wgt):
bsettings = get_or_create_crm_settings(db, c.business_id)
if not bool(getattr(bsettings, "allow_web_chat_voice", False)):
raise ApiError(
"CRM_VOICE_UPLOAD_DISABLED",
"Voice messages are not enabled for web chat for this business.",
http_status=403,
)
raise ApiError(
"WIDGET_VOICE_UPLOAD_DISABLED",
"Voice messages by visitors are disabled for this widget.",
http_status=403,
)
elif not visitor_file_upload_effective_for_widget(db, wgt):
bsettings = get_or_create_crm_settings(db, c.business_id)
if not bsettings.allow_web_chat_file_upload:
raise ApiError(
"CRM_FILE_UPLOAD_DISABLED",
"File upload is not enabled for web chat for this business.",
http_status=403,
)
raise ApiError(
"WIDGET_FILE_UPLOAD_DISABLED",
"File upload by visitors is disabled for this widget.",
http_status=403,
)
biz = db.get(Business, c.business_id)
if not biz or biz.owner_id is None:
raise ApiError("CRM_CHAT_BUSINESS_NOT_FOUND", "Business not found", http_status=404)
owner_id = int(biz.owner_id)
storage = FileStorageService(db)
try:
saved = await storage.upload_file(
file=upload,
user_id=owner_id,
module_context="crm_web_chat",
context_id=str(c.id),
developer_data={
"business_id": c.business_id,
"conversation_id": c.id,
"widget_id": wgt.id,
"visitor": True,
},
is_temporary=False,
expires_in_days=3650,
business_id=c.business_id,
check_storage_limit=True,
)
except HTTPException as e:
detail = e.detail
err_code = detail.get("error") if isinstance(detail, dict) else None
if err_code in ("NO_ACTIVE_STORAGE_PLAN", "STORAGE_LIMIT_EXCEEDED"):
_notify_business_crm_storage(
db,
c.business_id,
title="CRM web chat: storage limit",
message=(
"A visitor tried to upload a file but the storage plan or quota does not allow it. "
"Please review or upgrade your storage package."
),
)
# پاسخ قابل فهم به بازدیدکننده
if err_code == "NO_ACTIVE_STORAGE_PLAN":
raise ApiError(
"CRM_CHAT_FILE_NOT_AVAILABLE_GENERIC",
"File upload is not available right now. Please try again later.",
http_status=400,
details={"storage_error": "no_plan"},
) from None
if err_code == "STORAGE_LIMIT_EXCEEDED":
raise ApiError(
"CRM_CHAT_FILE_NOT_AVAILABLE_QUOTA",
"Not enough storage space; file upload is not available right now.",
http_status=400,
details={"storage_error": "quota"},
) from None
if err_code == "FILE_SIZE_EXCEEDED":
msg_s = str(detail.get("message") if isinstance(detail, dict) else "File size is not allowed")
raise ApiError("CRM_FILE_TOO_LARGE", msg_s, http_status=400) from None
raise ApiError("CRM_FILE_UPLOAD_FAILED", "File upload failed.", http_status=400) from None
fid = str(saved.get("file_id", ""))
orig_name = saved.get("original_name") or "file"
cap = (caption or "").strip()
text = cap if cap else f"📎 {orig_name}"
if len(text) > _MAX_BODY:
raise ApiError("CRM_CHAT_CAPTION_TOO_LONG", "Caption is too long", http_status=422)
msg = CrmChatMessage(
conversation_id=c.id,
sender_role="visitor",
body=text,
user_id=None,
file_storage_id=fid,
)
db.add(msg)
c.last_message_at = datetime.utcnow()
c.updated_at = datetime.utcnow()
db.commit()
db.refresh(msg)
payload = {"type": "crm_chat.event", "event": "message.created", "message": _message_dict_for_realtime_ws(db, msg)}
await crm_chat_realtime_manager.broadcast_conversation(c.id, payload)
await crm_chat_realtime_manager.broadcast_business(c.business_id, {**payload, "conversation_id": c.id})
_fire(
db,
c.business_id,
"crm.chat.message.received",
_visitor_message_workflow_payload(db, c.business_id, c, msg, text, file_storage_id=fid),
)
return _message_to_dict_enriched(db, msg)
async def post_agent_message(
db: Session,
*,
business_id: int,
conversation_id: int,
body: Optional[str],
user_id: int,
file_storage_id: Optional[str] = None,
fire_workflow_trigger_message_sent: bool = True,
automation_context: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
c = _get_conversation_business(db, business_id, conversation_id)
fid: Optional[str] = None
text_combined: str
if file_storage_id:
fs = _assert_crm_file_for_conversation(
db, business_id=business_id, conversation_id=conversation_id, file_id=str(file_storage_id).strip()
)
mime_ag = (fs.mime_type or "").strip().lower()
if mime_ag.startswith("audio/"):
sv = get_or_create_crm_settings(db, business_id)
if not bool(getattr(sv, "allow_web_chat_voice", False)):
raise ApiError(
"CRM_VOICE_UPLOAD_DISABLED",
"Voice messages are not enabled for web chat for this business.",
http_status=403,
)
fid = str(fs.id)
cap = (body or "").strip()
bname = cap if cap else f"📎 {fs.original_name or 'file'}"
if len(bname) > _MAX_BODY:
raise ApiError("CRM_CHAT_MESSAGE_BODY_INVALID", "Invalid message text", http_status=422)
text_combined = bname
else:
text = (body or "").strip()
if not text or len(text) > _MAX_BODY:
raise ApiError("CRM_CHAT_MESSAGE_BODY_INVALID", "Invalid message text", http_status=422)
text_combined = text
msg = CrmChatMessage(
conversation_id=c.id,
sender_role="agent",
body=text_combined,
user_id=user_id,
file_storage_id=fid,
)
db.add(msg)
c.last_message_at = datetime.utcnow()
c.updated_at = datetime.utcnow()
db.commit()
db.refresh(msg)
payload = {"type": "crm_chat.event", "event": "message.created", "message": _message_dict_for_realtime_ws(db, msg)}
await crm_chat_realtime_manager.broadcast_conversation(c.id, payload)
await crm_chat_realtime_manager.broadcast_business(c.business_id, {**payload, "conversation_id": c.id})
if fire_workflow_trigger_message_sent:
sent_payload: Dict[str, Any] = {
"conversation_id": c.id,
"widget_id": c.widget_id,
"message_id": msg.id,
"body": text_combined,
"sender_role": "agent",
"agent_user_id": user_id,
}
if fid:
sent_payload["file_storage_id"] = fid
# Compatibility alias for actions expecting attachment_file_id.
sent_payload["attachment_file_id"] = fid
if automation_context:
sent_payload.update(automation_context)
_fire(db, business_id, "crm.chat.message.sent", sent_payload, user_id)
return _message_to_dict_enriched(db, msg)
async def download_visitor_crm_file(
db: Session,
*,
visitor_token: str,
conversation_id: int,
file_id: str,
origin_header: Optional[str] = None,
) -> Dict[str, Any]:
c = _get_conversation_by_visitor(db, visitor_token, conversation_id)
_assert_widget_origin(db, c, origin_header)
m = db.scalar(
select(CrmChatMessage)
.where(
CrmChatMessage.conversation_id == c.id,
CrmChatMessage.file_storage_id == file_id,
)
.limit(1)
)
if m is None:
raise ApiError("CRM_CHAT_FILE_NOT_IN_CONVERSATION", "File not found in this conversation", http_status=404)
storage = FileStorageService(db)
return await storage.download_file(UUID(str(file_id)))
def list_messages_public(
db: Session,
visitor_token: str,
conversation_id: int,
limit: int = 100,
origin_header: Optional[str] = None,
before_message_id: Optional[int] = None,
) -> List[Dict[str, Any]]:
c = _get_conversation_by_visitor(db, visitor_token, conversation_id)
_assert_widget_origin(db, c, origin_header)
lim = min(max(limit, 1), 500)
base = and_(
CrmChatMessage.conversation_id == c.id,
CrmChatMessage.deleted_at.is_(None),
)
if before_message_id is not None:
base = and_(base, CrmChatMessage.id < before_message_id)
q = (
select(CrmChatMessage)
.where(base)
.order_by(CrmChatMessage.id.desc())
.limit(lim)
)
rows = list(db.scalars(q).all())
rows.reverse()
return [_message_to_dict_enriched(db, m) for m in rows]
def list_messages_agent(
db: Session,
business_id: int,
conversation_id: int,
limit: int = 80,
before_message_id: Optional[int] = None,
) -> tuple[List[Dict[str, Any]], bool]:
"""آخرین پیام‌ها (نزدیک‌تر به حال)؛ با before_message_id بارگذاری قدیمی‌تر. has_more_older اگر پیام قدیمی‌تر وجود داشته باشد."""
c = _get_conversation_business(db, business_id, conversation_id)
lim = min(max(limit, 1), 1000)
base = CrmChatMessage.conversation_id == c.id
if before_message_id is not None:
base = and_(base, CrmChatMessage.id < before_message_id)
q = (
select(CrmChatMessage)
.where(base)
.order_by(CrmChatMessage.id.desc())
.limit(lim + 1)
)
rows = list(db.scalars(q).all())
has_more_older = len(rows) > lim
rows = rows[:lim]
rows.reverse()
return ([_message_to_dict_enriched(db, m) for m in rows], has_more_older)
def list_conversations_agent(
db: Session,
business_id: int,
*,
status: Optional[str] = None,
limit: int = 50,
offset: int = 0,
search: Optional[str] = None,
) -> tuple[List[Dict[str, Any]], bool]:
sort_ts = func.coalesce(CrmChatConversation.last_message_at, CrmChatConversation.created_at)
lim = min(max(limit, 1), 200)
q = select(CrmChatConversation).where(CrmChatConversation.business_id == business_id)
if status:
q = q.where(CrmChatConversation.status == status)
term2 = (search or "").strip()
if term2:
pat2 = f"%{term2}%"
id_m = int(term2) if term2.isdigit() else None
conds: List[Any] = [
CrmChatConversation.visitor_first_name.ilike(pat2),
CrmChatConversation.visitor_last_name.ilike(pat2),
CrmChatConversation.visitor_email.ilike(pat2),
CrmChatConversation.visitor_phone.ilike(pat2),
CrmChatConversation.page_url.ilike(pat2),
]
if id_m is not None:
conds.append(CrmChatConversation.id == id_m)
q = q.where(or_(*conds))
q = q.order_by(desc(sort_ts), desc(CrmChatConversation.id)).limit(lim + 1).offset(max(offset, 0))
rows = list(db.scalars(q).all())
has_more = len(rows) > lim
rows = rows[:lim]
return ([conversation_to_dict(c) for c in rows], has_more)
async def delete_message_agent(
db: Session,
business_id: int,
conversation_id: int,
message_id: int,
) -> Dict[str, Any]:
c = _get_conversation_business(db, business_id, conversation_id)
m = db.get(CrmChatMessage, message_id)
if not m or m.conversation_id != c.id:
raise ApiError("CRM_CHAT_MESSAGE_NOT_FOUND", "Message not found", http_status=404)
if m.deleted_at is not None:
return {"id": m.id, "deleted": True}
m.deleted_at = datetime.utcnow()
m.body = ""
m.file_storage_id = None
db.commit()
db.refresh(m)
payload = {
"type": "crm_chat.event",
"event": "message.deleted",
"conversation_id": c.id,
"message_id": m.id,
}
await crm_chat_realtime_manager.broadcast_conversation(c.id, payload)
await crm_chat_realtime_manager.broadcast_business(c.business_id, {**payload, "conversation_id": c.id})
return {"id": m.id, "deleted": True}
async def patch_agent_message(
db: Session,
business_id: int,
conversation_id: int,
message_id: int,
*,
body: str,
) -> Dict[str, Any]:
c = _get_conversation_business(db, business_id, conversation_id)
m = db.get(CrmChatMessage, message_id)
if not m or m.conversation_id != c.id:
raise ApiError("CRM_CHAT_MESSAGE_NOT_FOUND", "Message not found", http_status=404)
if m.deleted_at is not None:
raise ApiError("CRM_CHAT_MESSAGE_NOT_FOUND", "Message not found", http_status=404)
if m.sender_role != "agent":
raise ApiError("CRM_CHAT_MESSAGE_EDIT_DENIED", "Only agent messages can be edited", http_status=422)
text = (body or "").strip()
if not text and not m.file_storage_id:
raise ApiError("CRM_CHAT_MESSAGE_BODY_INVALID", "Invalid message text", http_status=422)
if len(text) > _MAX_BODY:
raise ApiError("CRM_CHAT_MESSAGE_BODY_INVALID", "Invalid message text", http_status=422)
m.body = text
m.edited_at = datetime.utcnow()
db.commit()
db.refresh(m)
payload = {"type": "crm_chat.event", "event": "message.updated", "message": _message_dict_for_realtime_ws(db, m)}
await crm_chat_realtime_manager.broadcast_conversation(c.id, payload)
await crm_chat_realtime_manager.broadcast_business(c.business_id, {**payload, "conversation_id": c.id})
return _message_to_dict_enriched(db, m)
def _purge_conversation_rows(db: Session, *, filters) -> tuple[int, int]:
"""
مرتب‌سازی قطعی حذف برای جلوگیری از IntegrityError:
اول نشست پیامرسان برای این گفتگوها؛ سپس پیام‌ها؛ سپس خود مکالمه (بدون اعتماد تنها به CASCADE/ترتیب flush).
filterها روی همان مجموعهٔ سطرهای `crm_chat_conversations` اعمال می‌شود.
return: (پیش‌شمار قبل از حذف، حذف‌شدهٔ واقعی مکالمه طبق rowcount؛ اگر نامشخص → کل پیش‌شمار).
"""
conv_ids_sq = select(CrmChatConversation.id).where(filters)
cnt_q = select(func.count()).select_from(CrmChatConversation).where(filters)
prev_total = int(db.scalar(cnt_q) or 0)
if prev_total == 0:
return prev_total, 0
db.execute(
update(MessengerOperatorSession)
.where(MessengerOperatorSession.active_conversation_id.in_(conv_ids_sq))
.values(active_conversation_id=None),
)
db.flush()
db.execute(delete(CrmChatMessage).where(CrmChatMessage.conversation_id.in_(conv_ids_sq)))
db.flush()
res = db.execute(delete(CrmChatConversation).where(filters))
try:
deleted = int(res.rowcount or 0)
except (TypeError, ValueError):
deleted = prev_total
if deleted < 0:
deleted = prev_total
return prev_total, deleted
async def delete_conversation_agent(
db: Session,
business_id: int,
conversation_id: int,
) -> Dict[str, Any]:
c = _get_conversation_business(db, business_id, conversation_id)
cid = c.id
bid = c.business_id
filters_one = and_(
CrmChatConversation.id == cid,
CrmChatConversation.business_id == business_id,
)
try:
_prev_total, _deleted_conv = _purge_conversation_rows(db, filters=filters_one)
db.commit()
except IntegrityError:
db.rollback()
logger.exception(
"crm chat delete conversation failed integrity business_id=%s conversation_id=%s",
business_id,
conversation_id,
)
raise ApiError(
"CRM_CHAT_CONVERSATION_DELETE_FAILED",
"Could not delete conversation due to database constraints.",
http_status=409,
) from None
ev = {
"type": "crm_chat.event",
"event": "conversation.deleted",
"conversation_id": cid,
"business_id": bid,
}
await crm_chat_realtime_manager.broadcast_conversation(cid, ev)
await crm_chat_realtime_manager.broadcast_business(bid, ev)
return {"id": cid, "deleted": True}
async def delete_conversations_bulk_agent(
db: Session,
business_id: int,
*,
status: Optional[str] = None,
) -> Dict[str, Any]:
"""حذف دسته‌جمعی مکالمه‌ها؛ در صورت ارسال status فقط همان وضعیت. پیام‌ها و نشست‌ها قبل از مکالمه به‌ترتیب امن پاک می‌شوند."""
if status is not None and status not in ("open", "pending", "resolved"):
raise ApiError(
"CRM_CHAT_CONVERSATION_STATUS_INVALID",
"Invalid status",
http_status=422,
)
filters = [CrmChatConversation.business_id == business_id]
if status:
filters.append(CrmChatConversation.status == status)
base = and_(*filters)
try:
prev_total, deleted_n = _purge_conversation_rows(db, filters=base)
db.commit()
except IntegrityError:
db.rollback()
logger.exception(
"crm chat bulk delete conversations failed integrity business_id=%s status=%s",
business_id,
status,
)
raise ApiError(
"CRM_CHAT_CONVERSATIONS_BULK_DELETE_FAILED",
"Could not delete conversations due to database constraints.",
http_status=409,
) from None
if deleted_n <= 0 and prev_total <= 0:
return {"deleted": 0}
report_n = deleted_n if deleted_n > 0 else prev_total
ev = {
"type": "crm_chat.event",
"event": "conversations.bulk_deleted",
"business_id": business_id,
"count": report_n,
"status_filter": status,
}
await crm_chat_realtime_manager.broadcast_business(business_id, ev)
return {"deleted": report_n}
async def patch_conversation_agent(
db: Session,
business_id: int,
conversation_id: int,
*,
status: Optional[str] = None,
assigned_to_user_id: Optional[int] = None,
lead_id: Optional[int] = None,
person_id: Optional[int] = None,
acting_user_id: Optional[int] = None,
) -> Dict[str, Any]:
c = _get_conversation_business(db, business_id, conversation_id)
old_assignee = c.assigned_to_user_id
old_status = c.status
if status is not None:
if status not in ("open", "pending", "resolved"):
raise ApiError("CRM_CHAT_CONVERSATION_STATUS_INVALID", "Invalid status", http_status=422)
c.status = status
if assigned_to_user_id is not None:
c.assigned_to_user_id = assigned_to_user_id
if lead_id is not None:
c.lead_id = lead_id
if person_id is not None:
c.person_id = person_id
c.updated_at = datetime.utcnow()
db.commit()
db.refresh(c)
if assigned_to_user_id is not None and old_assignee != c.assigned_to_user_id:
_fire(
db,
business_id,
"crm.chat.conversation.assigned",
{
"conversation_id": c.id,
"widget_id": c.widget_id,
"old_assigned_to_user_id": old_assignee,
"new_assigned_to_user_id": c.assigned_to_user_id,
},
acting_user_id,
)
if status is not None and old_status != c.status:
if c.status == "resolved":
_fire(
db,
business_id,
"crm.chat.conversation.resolved",
{"conversation_id": c.id, "widget_id": c.widget_id},
acting_user_id,
)
elif old_status == "resolved" and c.status != "resolved":
_fire(
db,
business_id,
"crm.chat.conversation.reopened",
{
"conversation_id": c.id,
"widget_id": c.widget_id,
"old_status": old_status,
"new_status": c.status,
},
acting_user_id,
)
ev = {
"type": "crm_chat.event",
"event": "conversation.updated",
"conversation": conversation_to_dict(c),
}
await crm_chat_realtime_manager.broadcast_conversation(c.id, ev)
await crm_chat_realtime_manager.broadcast_business(c.business_id, ev)
return conversation_to_dict(c)
async def broadcast_typing(
conversation_id: int,
business_id: int,
*,
from_role: str,
active: bool,
actor_name: Optional[str] = None,
) -> None:
payload: Dict[str, Any] = {
"type": "crm_chat.event",
"event": "typing",
"conversation_id": conversation_id,
"from_role": from_role,
"active": bool(active),
}
if actor_name:
payload["actor_name"] = actor_name
await crm_chat_realtime_manager.broadcast_conversation(conversation_id, payload)
await crm_chat_realtime_manager.broadcast_business(business_id, {**payload, "conversation_id": conversation_id})
async def broadcast_agent_joined(
conversation_id: int,
business_id: int,
*,
agent_user_id: int,
agent_name: str,
) -> None:
payload: Dict[str, Any] = {
"type": "crm_chat.event",
"event": "agent.joined",
"conversation_id": conversation_id,
"agent": {"id": agent_user_id, "name": agent_name},
}
await crm_chat_realtime_manager.broadcast_conversation(conversation_id, payload)
await crm_chat_realtime_manager.broadcast_business(business_id, {**payload, "conversation_id": conversation_id})
async def _broadcast_messages_read(
conversation_id: int,
business_id: int,
*,
message_ids: List[int],
read_at: datetime,
reader_role: str,
reader_display_name: Optional[str] = None,
) -> None:
if not message_ids:
return
payload: Dict[str, Any] = {
"type": "crm_chat.event",
"event": "messages.read",
"conversation_id": conversation_id,
"message_ids": message_ids,
"read_at": read_at,
"reader_role": reader_role,
}
if reader_display_name and str(reader_display_name).strip():
payload["reader_display_name"] = str(reader_display_name).strip()
await crm_chat_realtime_manager.broadcast_conversation(conversation_id, payload)
await crm_chat_realtime_manager.broadcast_business(
business_id, {**payload, "conversation_id": conversation_id}
)
async def mark_messages_read_by_visitor(
db: Session,
*,
visitor_token: str,
conversation_id: int,
up_to_message_id: int,
origin_header: Optional[str],
) -> Dict[str, Any]:
"""بازدیدکننده پیام‌های عامل را تا شناسه داده‌شده «خوانده» علامت می‌زند."""
c = _get_conversation_by_visitor(db, visitor_token, conversation_id)
_assert_widget_origin(db, c, origin_header)
if up_to_message_id < 1:
raise ApiError("VALIDATION_ERROR", "Invalid message id", http_status=422)
msg_ids = list(
db.scalars(
select(CrmChatMessage.id)
.where(
CrmChatMessage.conversation_id == c.id,
CrmChatMessage.id <= up_to_message_id,
CrmChatMessage.sender_role == "agent",
CrmChatMessage.read_at.is_(None),
)
)
.all()
)
if not msg_ids:
return {"updated": 0, "message_ids": [], "read_at": None}
now = datetime.utcnow()
db.execute(update(CrmChatMessage).where(CrmChatMessage.id.in_(msg_ids)).values(read_at=now))
db.commit()
await _broadcast_messages_read(
c.id, c.business_id, message_ids=msg_ids, read_at=now, reader_role="visitor"
)
return {"updated": len(msg_ids), "message_ids": msg_ids, "read_at": now}
async def mark_messages_read_by_agent(
db: Session,
*,
business_id: int,
conversation_id: int,
up_to_message_id: int,
reading_user: Optional[User] = None,
) -> Dict[str, Any]:
"""عامل CRM پیام‌های بازدیدکننده را تا شناسه داده‌شده «خوانده» علامت می‌زند."""
c = _get_conversation_business(db, business_id, conversation_id)
if up_to_message_id < 1:
raise ApiError("VALIDATION_ERROR", "Invalid message id", http_status=422)
msg_ids = list(
db.scalars(
select(CrmChatMessage.id)
.where(
CrmChatMessage.conversation_id == c.id,
CrmChatMessage.id <= up_to_message_id,
CrmChatMessage.sender_role == "visitor",
CrmChatMessage.read_at.is_(None),
)
)
.all()
)
if not msg_ids:
return {"updated": 0, "message_ids": [], "read_at": None}
now = datetime.utcnow()
db.execute(update(CrmChatMessage).where(CrmChatMessage.id.in_(msg_ids)).values(read_at=now))
db.commit()
rdn = agent_display_name(reading_user) if reading_user is not None else None
await _broadcast_messages_read(
c.id, c.business_id, message_ids=msg_ids, read_at=now, reader_role="agent", reader_display_name=rdn
)
return {"updated": len(msg_ids), "message_ids": msg_ids, "read_at": now}