- Remove duplicate function definitions with hardcoded "AI-оператор" strings (ai_voice_runtime, ai_orchestrator, voice_name_config, voice.py) - Remove unreachable dead code after return in ai_voice_runtime - Add SQL LIMIT to 17 unbounded queries across 12 services to prevent OOM - Move Python-side filtering to SQL WHERE in reporting_service - Downgrade 19 logger.warning to logger.info for normal-flow events in media_runtime Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2329 lines
79 KiB
Python
2329 lines
79 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
|
|
from fastapi.responses import PlainTextResponse
|
|
from sqlalchemy import and_, case, func, inspect, or_, select, text, update
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from services.shared.core import Role, new_id, utc_now_iso
|
|
from services.shared.db import engine, get_session
|
|
from services.shared.models import (
|
|
AIWhatsAppEnqueueIn,
|
|
EscalateRequest,
|
|
HealthResponse,
|
|
InteractionStatus,
|
|
WhatsAppThreadAISummaryOut,
|
|
WhatsAppThreadAIHandoffIn,
|
|
WhatsAppThreadAIReplyIn,
|
|
WhatsAppThreadEscalateIn,
|
|
WhatsAppThreadMessageOut,
|
|
WhatsAppThreadOut,
|
|
WhatsAppThreadReplyIn,
|
|
WhatsAppWebhookIn,
|
|
WhatsAppWebhookOut,
|
|
)
|
|
from services.shared.security import issue_app_token, require_roles
|
|
from services.shared.sql_init import init_sql_schema
|
|
from services.shared.sql_models import (
|
|
AISessionRow,
|
|
Customer,
|
|
CustomerExternalIdentity,
|
|
Interaction,
|
|
InteractionTimeline,
|
|
WhatsAppMessageRow,
|
|
WhatsAppThreadRow,
|
|
)
|
|
|
|
app = FastAPI(title="whatsapp-adapter-service", version="2.0.0")
|
|
|
|
init_sql_schema()
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WHATSAPP_BOT_DEDUP_INDEX = "ix_whatsapp_messages_chat_external_unique"
|
|
WHATSAPP_REPLY_NEXT_ATTEMPT_INDEX = "ix_whatsapp_messages_next_delivery_attempt_at"
|
|
WHATSAPP_REPLY_LOCKED_UNTIL_INDEX = "ix_whatsapp_messages_delivery_locked_until"
|
|
|
|
_reply_delivery_worker_lock = threading.Lock()
|
|
_reply_delivery_worker_thread: threading.Thread | None = None
|
|
_reply_delivery_worker_stop: threading.Event | None = None
|
|
_reply_delivery_worker_wakeup: threading.Event | None = None
|
|
_reply_delivery_worker_clients = 0
|
|
_HANDOFF_REASON_UNSET = object()
|
|
|
|
|
|
def _utc_now() -> datetime:
|
|
return datetime.now(timezone.utc).replace(microsecond=0)
|
|
|
|
|
|
def _utc_after_seconds(seconds: int) -> str:
|
|
return (_utc_now() + timedelta(seconds=max(seconds, 0))).isoformat()
|
|
|
|
|
|
def _text_column_type() -> str:
|
|
return "TEXT"
|
|
|
|
|
|
def _string_column_type(length: int = 64) -> str:
|
|
return f"VARCHAR({length})"
|
|
|
|
|
|
def _integer_env(name: str, default: int) -> int:
|
|
raw = os.getenv(name)
|
|
if raw is None:
|
|
return default
|
|
try:
|
|
return int(raw.strip())
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
def _ensure_whatsapp_message_indexes() -> None:
|
|
# Runtime compatibility for WhatsApp lives in shared.sql_init.
|
|
# Keep the hook so the adapter startup shape matches Telegram without
|
|
# duplicating migration logic in a second place.
|
|
return
|
|
|
|
|
|
_ensure_whatsapp_message_indexes()
|
|
|
|
|
|
def _bool_env(name: str, default: bool) -> bool:
|
|
raw = os.getenv(name)
|
|
if raw is None:
|
|
return default
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _outbound_enabled() -> bool:
|
|
return _bool_env("WHATSAPP_OUTBOUND_ENABLED", False)
|
|
|
|
|
|
def _outbound_auth_token() -> str:
|
|
return os.getenv("WHATSAPP_OUTBOUND_AUTH_TOKEN", "").strip()
|
|
|
|
|
|
def _webhook_secret() -> str:
|
|
return os.getenv("WHATSAPP_WEBHOOK_SECRET", "").strip()
|
|
|
|
|
|
def _meta_verify_token() -> str:
|
|
return (
|
|
os.getenv("WHATSAPP_META_VERIFY_TOKEN", "").strip()
|
|
or os.getenv("WHATSAPP_VERIFY_TOKEN", "").strip()
|
|
)
|
|
|
|
|
|
def _meta_app_secret() -> str:
|
|
return (
|
|
os.getenv("WHATSAPP_META_APP_SECRET", "").strip()
|
|
or os.getenv("WHATSAPP_APP_SECRET", "").strip()
|
|
)
|
|
|
|
|
|
def _meta_access_token() -> str:
|
|
return os.getenv("WHATSAPP_META_ACCESS_TOKEN", "").strip()
|
|
|
|
|
|
def _meta_phone_number_id() -> str:
|
|
return os.getenv("WHATSAPP_META_PHONE_NUMBER_ID", "").strip()
|
|
|
|
|
|
def _meta_graph_api_version() -> str:
|
|
return os.getenv("WHATSAPP_META_GRAPH_API_VERSION", "v22.0").strip() or "v22.0"
|
|
|
|
|
|
def _meta_graph_base_url() -> str:
|
|
return os.getenv("WHATSAPP_META_GRAPH_BASE_URL", "https://graph.facebook.com").rstrip("/")
|
|
|
|
|
|
def _default_queue_id() -> str:
|
|
return os.getenv("WHATSAPP_DEFAULT_QUEUE_ID", "q_whatsapp").strip() or "q_whatsapp"
|
|
|
|
|
|
def _outbound_send_url() -> str:
|
|
return os.getenv("WHATSAPP_OUTBOUND_SEND_URL", "").strip()
|
|
|
|
|
|
def _reply_delivery_worker_enabled() -> bool:
|
|
return _bool_env("WHATSAPP_REPLY_DELIVERY_WORKER_ENABLED", True)
|
|
|
|
|
|
def _reply_delivery_poll_seconds() -> int:
|
|
return max(1, _integer_env("WHATSAPP_REPLY_DELIVERY_POLL_SECONDS", 5))
|
|
|
|
|
|
def _reply_delivery_lease_seconds() -> int:
|
|
return max(5, _integer_env("WHATSAPP_REPLY_DELIVERY_LEASE_SECONDS", 30))
|
|
|
|
|
|
def _reply_delivery_retry_base_seconds() -> int:
|
|
return max(1, _integer_env("WHATSAPP_REPLY_DELIVERY_RETRY_BASE_SECONDS", 5))
|
|
|
|
|
|
def _reply_delivery_retry_cap_seconds() -> int:
|
|
return max(_reply_delivery_retry_base_seconds(), _integer_env("WHATSAPP_REPLY_DELIVERY_RETRY_CAP_SECONDS", 300))
|
|
|
|
|
|
def _reply_delivery_max_attempts() -> int:
|
|
return max(1, _integer_env("WHATSAPP_REPLY_DELIVERY_MAX_ATTEMPTS", 5))
|
|
|
|
|
|
def _interaction_service_url() -> str:
|
|
return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/")
|
|
|
|
|
|
def _ai_service_url() -> str:
|
|
return os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017").rstrip("/")
|
|
|
|
|
|
def _ai_whatsapp_enabled() -> bool:
|
|
return _bool_env("AI_WHATSAPP_ENABLED", False)
|
|
|
|
|
|
def _service_auth_headers() -> dict[str, str]:
|
|
token = issue_app_token(
|
|
subject="svc:whatsapp-adapter",
|
|
username="whatsapp-adapter",
|
|
role="admin",
|
|
auth_source="service",
|
|
provider="whatsapp-adapter",
|
|
ttl_seconds=300,
|
|
)
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def _internal_service_headers(
|
|
*,
|
|
subject: str,
|
|
username: str,
|
|
provider: str,
|
|
) -> dict[str, str]:
|
|
token = issue_app_token(
|
|
subject=subject,
|
|
username=username,
|
|
role="admin",
|
|
auth_source="service",
|
|
provider=provider,
|
|
ttl_seconds=300,
|
|
)
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def _interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict:
|
|
with httpx.Client(timeout=5.0) as client:
|
|
response = client.request(
|
|
method,
|
|
f"{_interaction_service_url()}{path}",
|
|
json=payload,
|
|
headers=_service_auth_headers(),
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
|
|
|
|
def _ai_enqueue_request(thread_id: str, trigger_message_id: str | None) -> None:
|
|
if not _ai_whatsapp_enabled():
|
|
return
|
|
try:
|
|
with httpx.Client(timeout=10.0) as client:
|
|
response = client.post(
|
|
f"{_ai_service_url()}/ai/whatsapp/threads/{thread_id}/enqueue",
|
|
json=AIWhatsAppEnqueueIn(trigger_message_id=trigger_message_id).model_dump(),
|
|
headers=_internal_service_headers(
|
|
subject="svc:whatsapp-adapter",
|
|
username="whatsapp-adapter",
|
|
provider="whatsapp-adapter",
|
|
),
|
|
)
|
|
response.raise_for_status()
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("WhatsApp AI enqueue failed", extra={"thread_id": thread_id})
|
|
|
|
|
|
def _preview_text(text: str, limit: int = 120) -> str:
|
|
value = str(text or "").strip()
|
|
if len(value) <= limit:
|
|
return value
|
|
return f"{value[: limit - 3]}..."
|
|
|
|
|
|
def _normalize_external_subject(value: str | None) -> str | None:
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
return None
|
|
if raw.startswith("whatsapp:"):
|
|
return raw.split(":", 1)[1].strip() or None
|
|
return raw
|
|
|
|
|
|
def _first_external_subject(
|
|
whatsapp_user_id: str | None,
|
|
chat_id: str,
|
|
explicit: str | None = None,
|
|
) -> str:
|
|
return (
|
|
_normalize_external_subject(whatsapp_user_id)
|
|
or _normalize_external_subject(explicit)
|
|
or _normalize_external_subject(chat_id)
|
|
or chat_id
|
|
)
|
|
|
|
|
|
def _customer_external_id(whatsapp_user_id: str | None, chat_id: str, explicit: str | None = None) -> str:
|
|
return f"whatsapp:{_first_external_subject(whatsapp_user_id, chat_id, explicit)}"
|
|
|
|
|
|
def _customer_id_is_real(customer_id: str | None) -> bool:
|
|
return str(customer_id or "").startswith("cus_")
|
|
|
|
|
|
def _whatsapp_identity_subjects(
|
|
whatsapp_user_id: str | None,
|
|
chat_id: str,
|
|
*,
|
|
phone_number: str | None = None,
|
|
external_subject: str | None = None,
|
|
) -> list[str]:
|
|
ordered = [
|
|
_normalize_external_subject(whatsapp_user_id),
|
|
_normalize_external_subject(phone_number),
|
|
_normalize_external_subject(external_subject),
|
|
_normalize_external_subject(chat_id),
|
|
]
|
|
seen: set[str] = set()
|
|
result: list[str] = []
|
|
for item in ordered:
|
|
if not item or item in seen:
|
|
continue
|
|
seen.add(item)
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def _ensure_customer_external_identity(
|
|
session,
|
|
*,
|
|
customer_id: str,
|
|
channel: str,
|
|
external_subject: str,
|
|
display_name_snapshot: str | None,
|
|
now: str,
|
|
) -> None:
|
|
row = session.execute(
|
|
select(CustomerExternalIdentity).where(
|
|
CustomerExternalIdentity.channel == channel,
|
|
CustomerExternalIdentity.external_subject == external_subject,
|
|
)
|
|
).scalar_one_or_none()
|
|
if row:
|
|
row.customer_id = customer_id
|
|
if display_name_snapshot:
|
|
row.display_name_snapshot = display_name_snapshot
|
|
row.updated_at = now
|
|
return
|
|
session.add(
|
|
CustomerExternalIdentity(
|
|
identity_id=new_id("cei"),
|
|
customer_id=customer_id,
|
|
channel=channel,
|
|
external_subject=external_subject,
|
|
display_name_snapshot=display_name_snapshot,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
)
|
|
|
|
|
|
def _resolve_or_create_customer_id(
|
|
session,
|
|
*,
|
|
whatsapp_user_id: str | None,
|
|
chat_id: str,
|
|
display_name: str,
|
|
phone_number: str | None = None,
|
|
explicit_customer_id: str | None = None,
|
|
external_subject: str | None = None,
|
|
) -> str:
|
|
now = utc_now_iso()
|
|
explicit_value = str(explicit_customer_id or "").strip()
|
|
subjects = _whatsapp_identity_subjects(
|
|
whatsapp_user_id,
|
|
chat_id,
|
|
phone_number=phone_number,
|
|
external_subject=external_subject,
|
|
)
|
|
|
|
for subject in subjects:
|
|
identity = session.execute(
|
|
select(CustomerExternalIdentity).where(
|
|
CustomerExternalIdentity.channel == "whatsapp",
|
|
CustomerExternalIdentity.external_subject == subject,
|
|
)
|
|
).scalar_one_or_none()
|
|
if identity:
|
|
if display_name and identity.display_name_snapshot != display_name:
|
|
identity.display_name_snapshot = display_name
|
|
identity.updated_at = now
|
|
return identity.customer_id
|
|
|
|
if _customer_id_is_real(explicit_value):
|
|
customer = session.execute(
|
|
select(Customer).where(Customer.customer_id == explicit_value)
|
|
).scalar_one_or_none()
|
|
if not customer:
|
|
customer = Customer(
|
|
customer_id=explicit_value,
|
|
display_name=display_name,
|
|
phones_json="[]",
|
|
preferred_phone=None,
|
|
tags_json=json.dumps(["whatsapp"], ensure_ascii=False),
|
|
created_at=now,
|
|
)
|
|
session.add(customer)
|
|
elif display_name and customer.display_name != display_name:
|
|
customer.display_name = display_name
|
|
customer_id = customer.customer_id
|
|
else:
|
|
customer = Customer(
|
|
customer_id=new_id("cus"),
|
|
display_name=display_name,
|
|
phones_json="[]",
|
|
preferred_phone=None,
|
|
tags_json=json.dumps(["whatsapp"], ensure_ascii=False),
|
|
created_at=now,
|
|
)
|
|
session.add(customer)
|
|
customer_id = customer.customer_id
|
|
|
|
for subject in subjects or [_normalize_external_subject(chat_id) or chat_id]:
|
|
_ensure_customer_external_identity(
|
|
session,
|
|
customer_id=customer_id,
|
|
channel="whatsapp",
|
|
external_subject=subject,
|
|
display_name_snapshot=display_name,
|
|
now=now,
|
|
)
|
|
return customer_id
|
|
|
|
|
|
def _display_name(
|
|
*,
|
|
username: str | None = None,
|
|
first_name: str | None = None,
|
|
last_name: str | None = None,
|
|
fallback: str | None = None,
|
|
) -> str:
|
|
parts = [str(first_name or "").strip(), str(last_name or "").strip()]
|
|
full_name = " ".join(part for part in parts if part).strip()
|
|
if full_name:
|
|
return full_name
|
|
if username:
|
|
return username
|
|
if fallback:
|
|
return fallback
|
|
return "WhatsApp user"
|
|
|
|
|
|
def _inbound_author_type(direction: str) -> str:
|
|
return "system" if direction == "system" else "customer"
|
|
|
|
|
|
def _message_can_trigger_ai(author_type: str | None) -> bool:
|
|
return _ai_whatsapp_enabled() and author_type == "customer"
|
|
|
|
|
|
def _normalize_phone_number(value: str | None) -> str | None:
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
return None
|
|
digits = "".join(ch for ch in raw if ch.isdigit())
|
|
if not digits:
|
|
return None
|
|
if raw.startswith("+"):
|
|
return f"+{digits}"
|
|
return digits
|
|
|
|
|
|
def _contact_phone_number(contact: dict | None, *, sender_user_id: str | None) -> str | None:
|
|
if not isinstance(contact, dict):
|
|
return None
|
|
phone_number = _normalize_phone_number(contact.get("phone_number"))
|
|
if not phone_number:
|
|
return None
|
|
contact_user_id = str(contact.get("user_id") or "").strip() or None
|
|
if sender_user_id and contact_user_id and contact_user_id != sender_user_id:
|
|
return None
|
|
return phone_number
|
|
|
|
|
|
def _customer_phone_list(raw: str | None) -> list[str]:
|
|
try:
|
|
value = json.loads(raw or "[]")
|
|
except Exception: # noqa: BLE001
|
|
return []
|
|
if not isinstance(value, list):
|
|
return []
|
|
result: list[str] = []
|
|
for item in value:
|
|
normalized = _normalize_phone_number(item)
|
|
if normalized and normalized not in result:
|
|
result.append(normalized)
|
|
return result
|
|
|
|
|
|
def _ensure_customer_phone(session, *, customer_id: str, phone_number: str | None) -> None:
|
|
normalized_phone = _normalize_phone_number(phone_number)
|
|
if not normalized_phone or not _customer_id_is_real(customer_id):
|
|
return
|
|
session.flush()
|
|
customer = session.execute(
|
|
select(Customer).where(Customer.customer_id == customer_id)
|
|
).scalar_one_or_none()
|
|
if not customer:
|
|
return
|
|
phones = _customer_phone_list(customer.phones_json)
|
|
if normalized_phone not in phones:
|
|
phones.append(normalized_phone)
|
|
customer.phones_json = json.dumps(phones, ensure_ascii=False)
|
|
if not customer.preferred_phone:
|
|
customer.preferred_phone = normalized_phone
|
|
|
|
|
|
def _push_timeline(session, interaction_id: str, action: str, metadata: dict | None = None) -> None:
|
|
session.add(
|
|
InteractionTimeline(
|
|
interaction_id=interaction_id,
|
|
timestamp=utc_now_iso(),
|
|
action=action,
|
|
metadata_json=json.dumps(metadata or {}, ensure_ascii=False),
|
|
)
|
|
)
|
|
|
|
|
|
def _get_thread(session, thread_id: str) -> WhatsAppThreadRow:
|
|
row = session.execute(
|
|
select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == thread_id)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="WhatsApp thread not found")
|
|
return row
|
|
|
|
|
|
def _thread_unread_count(session, thread_id: str) -> int:
|
|
last_outbound_at = session.execute(
|
|
select(func.max(WhatsAppMessageRow.created_at)).where(
|
|
WhatsAppMessageRow.thread_id == thread_id,
|
|
WhatsAppMessageRow.direction == "outbound",
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
stmt = select(func.count(WhatsAppMessageRow.id)).where(
|
|
WhatsAppMessageRow.thread_id == thread_id,
|
|
WhatsAppMessageRow.direction == "inbound",
|
|
WhatsAppMessageRow.author_type == "customer",
|
|
)
|
|
if last_outbound_at:
|
|
stmt = stmt.where(WhatsAppMessageRow.created_at > last_outbound_at)
|
|
raw = session.execute(stmt).scalar_one_or_none()
|
|
return int(raw or 0)
|
|
|
|
|
|
def _to_thread_out(row: WhatsAppThreadRow, *, unread_count: int = 0) -> WhatsAppThreadOut:
|
|
return WhatsAppThreadOut(
|
|
thread_id=row.thread_id,
|
|
chat_id=row.chat_id,
|
|
interaction_id=row.interaction_id,
|
|
whatsapp_user_id=row.whatsapp_user_id,
|
|
phone_number=row.phone_number,
|
|
display_name=row.display_name,
|
|
queue_id=row.queue_id,
|
|
is_group=bool(row.is_group),
|
|
status=row.status, # type: ignore[arg-type]
|
|
claimed_by_user=row.claimed_by_user,
|
|
claimed_at=row.claimed_at,
|
|
ai_session_id=row.ai_session_id,
|
|
ai_state=row.ai_state, # type: ignore[arg-type]
|
|
ai_handoff_reason=row.ai_handoff_reason,
|
|
ai_last_model_at=row.ai_last_model_at,
|
|
unread_count=unread_count,
|
|
last_message_at=row.last_message_at,
|
|
last_message_preview=row.last_message_preview,
|
|
created_at=row.created_at,
|
|
updated_at=row.updated_at,
|
|
)
|
|
|
|
|
|
def _to_message_out(row: WhatsAppMessageRow) -> WhatsAppThreadMessageOut:
|
|
return WhatsAppThreadMessageOut(
|
|
message_id=row.message_id,
|
|
thread_id=row.thread_id or "",
|
|
interaction_id=row.interaction_id or "",
|
|
chat_id=row.chat_id,
|
|
direction=row.direction, # type: ignore[arg-type]
|
|
text=row.text,
|
|
whatsapp_message_id_external=row.whatsapp_message_id_external,
|
|
operator_user=row.operator_user,
|
|
author_type=row.author_type, # type: ignore[arg-type]
|
|
author_id=row.author_id,
|
|
customer_id=row.customer_id,
|
|
delivery_status=row.delivery_status,
|
|
payload=json.loads(row.payload_json or "{}"),
|
|
created_at=row.created_at,
|
|
)
|
|
|
|
|
|
def _loads_payload(raw: str | None) -> dict[str, Any]:
|
|
try:
|
|
payload = json.loads(raw or "{}")
|
|
except Exception: # noqa: BLE001
|
|
return {}
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
|
|
def _to_webhook_out(row: WhatsAppMessageRow) -> WhatsAppWebhookOut:
|
|
return WhatsAppWebhookOut(
|
|
message_id=row.message_id,
|
|
chat_id=row.chat_id,
|
|
text=row.text,
|
|
external_message_id=row.whatsapp_message_id_external,
|
|
customer_id=row.customer_id,
|
|
payload=_loads_payload(row.payload_json),
|
|
thread_id=row.thread_id,
|
|
interaction_id=row.interaction_id,
|
|
direction=row.direction, # type: ignore[arg-type]
|
|
created_at=row.created_at,
|
|
)
|
|
|
|
|
|
def _to_bot_webhook_result(row: WhatsAppMessageRow, *, thread_created: bool) -> dict[str, Any]:
|
|
return {
|
|
"ok": True,
|
|
"thread_id": row.thread_id or "",
|
|
"interaction_id": row.interaction_id or "",
|
|
"message_id": row.message_id,
|
|
"thread_created": thread_created,
|
|
}
|
|
|
|
|
|
def _existing_bot_message(
|
|
session,
|
|
*,
|
|
chat_id: str,
|
|
external_message_id: str | None,
|
|
) -> WhatsAppMessageRow | None:
|
|
if not external_message_id:
|
|
return None
|
|
return session.execute(
|
|
select(WhatsAppMessageRow).where(
|
|
WhatsAppMessageRow.chat_id == chat_id,
|
|
WhatsAppMessageRow.whatsapp_message_id_external == external_message_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _latest_ai_session_for_thread(session, thread: WhatsAppThreadRow) -> AISessionRow | None:
|
|
if thread.ai_session_id:
|
|
ai_session = session.execute(
|
|
select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id)
|
|
).scalar_one_or_none()
|
|
if ai_session:
|
|
return ai_session
|
|
return session.execute(
|
|
select(AISessionRow)
|
|
.where(AISessionRow.thread_id == thread.thread_id)
|
|
.order_by(AISessionRow.updated_at.desc(), AISessionRow.id.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _summary_customer_request_text(
|
|
session,
|
|
*,
|
|
thread: WhatsAppThreadRow,
|
|
ai_session: AISessionRow,
|
|
generated_at: str,
|
|
) -> str:
|
|
if ai_session.last_user_message_id:
|
|
customer_message = session.execute(
|
|
select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == ai_session.last_user_message_id)
|
|
).scalar_one_or_none()
|
|
if customer_message and customer_message.text.strip():
|
|
return customer_message.text.strip()
|
|
customer_message = session.execute(
|
|
select(WhatsAppMessageRow)
|
|
.where(
|
|
WhatsAppMessageRow.thread_id == thread.thread_id,
|
|
WhatsAppMessageRow.direction == "inbound",
|
|
WhatsAppMessageRow.author_type == "customer",
|
|
WhatsAppMessageRow.created_at <= generated_at,
|
|
)
|
|
.order_by(WhatsAppMessageRow.created_at.desc(), WhatsAppMessageRow.id.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if customer_message and customer_message.text.strip():
|
|
return customer_message.text.strip()
|
|
return "Последний запрос клиента недоступен."
|
|
|
|
|
|
def _summary_ai_outcome_text(
|
|
session,
|
|
*,
|
|
thread: WhatsAppThreadRow,
|
|
ai_session: AISessionRow,
|
|
generated_at: str,
|
|
) -> str:
|
|
if ai_session.last_ai_message_id:
|
|
ai_message = session.execute(
|
|
select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == ai_session.last_ai_message_id)
|
|
).scalar_one_or_none()
|
|
if ai_message and ai_message.text.strip():
|
|
return ai_message.text.strip()
|
|
ai_message = session.execute(
|
|
select(WhatsAppMessageRow)
|
|
.where(
|
|
WhatsAppMessageRow.thread_id == thread.thread_id,
|
|
WhatsAppMessageRow.direction == "outbound",
|
|
WhatsAppMessageRow.author_type == "ai",
|
|
WhatsAppMessageRow.created_at <= generated_at,
|
|
)
|
|
.order_by(WhatsAppMessageRow.created_at.desc(), WhatsAppMessageRow.id.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if ai_message and ai_message.text.strip():
|
|
return ai_message.text.strip()
|
|
return "AI передал диалог оператору без ответа клиенту."
|
|
|
|
|
|
def _summary_status(
|
|
session,
|
|
*,
|
|
thread: WhatsAppThreadRow,
|
|
ai_session: AISessionRow,
|
|
generated_at: str,
|
|
) -> tuple[str, str]:
|
|
if ai_session.last_ai_message_id:
|
|
ai_message = session.execute(
|
|
select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == ai_session.last_ai_message_id)
|
|
).scalar_one_or_none()
|
|
if ai_message and ai_message.text.strip():
|
|
return ("AI ответил клиенту", "answered")
|
|
ai_message = session.execute(
|
|
select(WhatsAppMessageRow.message_id)
|
|
.where(
|
|
WhatsAppMessageRow.thread_id == thread.thread_id,
|
|
WhatsAppMessageRow.direction == "outbound",
|
|
WhatsAppMessageRow.author_type == "ai",
|
|
WhatsAppMessageRow.created_at <= generated_at,
|
|
)
|
|
.order_by(WhatsAppMessageRow.created_at.desc(), WhatsAppMessageRow.id.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if ai_message:
|
|
return ("AI ответил клиенту", "answered")
|
|
return ("AI передал без ответа", "handoff")
|
|
|
|
|
|
def _summary_recommended_next_step(thread: WhatsAppThreadRow) -> str:
|
|
if thread.ai_state == "handoff_required":
|
|
return "Заберите чат и ответьте клиенту вручную."
|
|
if thread.ai_state == "human_owned":
|
|
return "Продолжайте диалог вручную; AI больше не отвечает в этот thread."
|
|
return ""
|
|
|
|
|
|
def _thread_supports_ai_summary(thread: WhatsAppThreadRow, ai_session: AISessionRow | None) -> bool:
|
|
if not ai_session:
|
|
return False
|
|
if thread.ai_state == "handoff_required":
|
|
return True
|
|
if thread.ai_state == "human_owned":
|
|
return bool(thread.ai_handoff_reason or ai_session.handoff_reason)
|
|
return False
|
|
|
|
|
|
def _build_ai_summary(session, thread: WhatsAppThreadRow) -> WhatsAppThreadAISummaryOut | None:
|
|
ai_session = _latest_ai_session_for_thread(session, thread)
|
|
if not _thread_supports_ai_summary(thread, ai_session):
|
|
return None
|
|
assert ai_session is not None
|
|
generated_at = thread.ai_last_model_at or ai_session.updated_at or thread.updated_at
|
|
handoff_reason = (thread.ai_handoff_reason or ai_session.handoff_reason or "").strip()
|
|
if not handoff_reason:
|
|
return None
|
|
status_label, status_tone = _summary_status(
|
|
session,
|
|
thread=thread,
|
|
ai_session=ai_session,
|
|
generated_at=generated_at,
|
|
)
|
|
return WhatsAppThreadAISummaryOut(
|
|
thread_id=thread.thread_id,
|
|
session_id=ai_session.session_id,
|
|
status_label=status_label,
|
|
status_tone=status_tone, # type: ignore[arg-type]
|
|
customer_request_text=_summary_customer_request_text(
|
|
session,
|
|
thread=thread,
|
|
ai_session=ai_session,
|
|
generated_at=generated_at,
|
|
),
|
|
ai_outcome_text=_summary_ai_outcome_text(
|
|
session,
|
|
thread=thread,
|
|
ai_session=ai_session,
|
|
generated_at=generated_at,
|
|
),
|
|
handoff_reason=handoff_reason,
|
|
recommended_next_step=_summary_recommended_next_step(thread),
|
|
generated_at=generated_at,
|
|
)
|
|
|
|
|
|
def _reactivate_thread_interaction(
|
|
session,
|
|
thread: WhatsAppThreadRow,
|
|
now: str,
|
|
*,
|
|
requeue_ai: bool,
|
|
) -> None:
|
|
interaction = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id == thread.interaction_id)
|
|
).scalar_one_or_none()
|
|
if not interaction:
|
|
return
|
|
interaction.status = "new"
|
|
interaction.assigned_to = None
|
|
interaction.updated_at = now
|
|
thread.status = "new"
|
|
thread.claimed_by_user = None
|
|
thread.claimed_at = None
|
|
thread.ai_handoff_reason = None
|
|
if thread.ai_state != "human_owned":
|
|
thread.ai_state = "queued" if requeue_ai else None
|
|
thread.updated_at = now
|
|
_push_timeline(
|
|
session,
|
|
interaction.interaction_id,
|
|
"interaction.status_changed",
|
|
{"status": "new", "source": "whatsapp-reactivation"},
|
|
)
|
|
_push_timeline(
|
|
session,
|
|
interaction.interaction_id,
|
|
"whatsapp.thread_reactivated",
|
|
{"thread_id": thread.thread_id, "chat_id": thread.chat_id},
|
|
)
|
|
|
|
|
|
def _create_interaction(session, *, subject: str, customer_id: str, queue_id: str, created_at: str) -> str:
|
|
interaction_id = new_id("int")
|
|
session.add(
|
|
Interaction(
|
|
interaction_id=interaction_id,
|
|
channel="whatsapp",
|
|
subject=_preview_text(subject, 120),
|
|
customer_id=customer_id,
|
|
queue_id=queue_id,
|
|
priority=3,
|
|
status="new",
|
|
assigned_to=None,
|
|
created_at=created_at,
|
|
updated_at=created_at,
|
|
)
|
|
)
|
|
_push_timeline(session, interaction_id, "interaction.created", {"channel": "whatsapp"})
|
|
return interaction_id
|
|
|
|
|
|
def _persist_message(
|
|
session,
|
|
*,
|
|
thread: WhatsAppThreadRow,
|
|
text: str,
|
|
payload: dict,
|
|
direction: str,
|
|
created_at: str,
|
|
customer_id: str | None = None,
|
|
external_message_id: str | None = None,
|
|
operator_user: str | None = None,
|
|
author_type: str | None = None,
|
|
author_id: str | None = None,
|
|
delivery_status: str | None = None,
|
|
) -> WhatsAppMessageRow:
|
|
resolved_author_type = author_type or ("human" if direction == "outbound" else ("system" if direction == "system" else "customer"))
|
|
row = WhatsAppMessageRow(
|
|
message_id=new_id("wam"),
|
|
thread_id=thread.thread_id,
|
|
interaction_id=thread.interaction_id,
|
|
chat_id=thread.chat_id,
|
|
text=text,
|
|
customer_id=customer_id,
|
|
direction=direction,
|
|
whatsapp_message_id_external=external_message_id,
|
|
operator_user=operator_user,
|
|
author_type=resolved_author_type,
|
|
author_id=author_id,
|
|
delivery_status=delivery_status,
|
|
payload_json=json.dumps(payload, ensure_ascii=False),
|
|
created_at=created_at,
|
|
)
|
|
session.add(row)
|
|
thread.last_message_at = created_at
|
|
thread.last_message_preview = _preview_text(text, 140)
|
|
thread.updated_at = created_at
|
|
return row
|
|
|
|
|
|
def _mark_thread_ai_state(
|
|
thread: WhatsAppThreadRow,
|
|
*,
|
|
ai_state: str | None,
|
|
ai_handoff_reason: str | None = None,
|
|
ai_last_model_at: str | None = None,
|
|
) -> None:
|
|
thread.ai_state = ai_state
|
|
thread.ai_handoff_reason = ai_handoff_reason
|
|
if ai_last_model_at is not None:
|
|
thread.ai_last_model_at = ai_last_model_at
|
|
|
|
|
|
def _update_ai_session_state(
|
|
session,
|
|
*,
|
|
session_id: str | None,
|
|
status: str,
|
|
updated_at: str,
|
|
handoff_reason: object = _HANDOFF_REASON_UNSET,
|
|
closed: bool = False,
|
|
) -> None:
|
|
if not session_id:
|
|
return
|
|
ai_session = session.execute(
|
|
select(AISessionRow).where(AISessionRow.session_id == session_id)
|
|
).scalar_one_or_none()
|
|
if not ai_session:
|
|
return
|
|
ai_session.status = status
|
|
ai_session.updated_at = updated_at
|
|
if handoff_reason is not _HANDOFF_REASON_UNSET:
|
|
ai_session.handoff_reason = handoff_reason
|
|
if closed:
|
|
ai_session.closed_at = updated_at
|
|
|
|
|
|
def _maybe_enqueue_ai_for_thread(
|
|
thread: WhatsAppThreadRow,
|
|
trigger_message_id: str | None,
|
|
*,
|
|
author_type: str | None = None,
|
|
) -> None:
|
|
if not _ai_whatsapp_enabled():
|
|
return
|
|
if thread.ai_state == "human_owned":
|
|
return
|
|
if author_type and author_type != "customer":
|
|
return
|
|
threading.Thread(
|
|
target=_ai_enqueue_request,
|
|
args=(thread.thread_id, trigger_message_id),
|
|
daemon=True,
|
|
name=f"whatsapp-ai-enqueue-{thread.thread_id}",
|
|
).start()
|
|
|
|
|
|
def _upsert_thread_for_inbound(
|
|
session,
|
|
*,
|
|
chat_id: str,
|
|
whatsapp_user_id: str | None,
|
|
display_name: str,
|
|
text: str,
|
|
payload: dict,
|
|
customer_id: str | None = None,
|
|
external_subject: str | None = None,
|
|
external_message_id: str | None = None,
|
|
phone_number: str | None = None,
|
|
queue_id: str | None = None,
|
|
is_group: bool = False,
|
|
direction: str = "inbound",
|
|
) -> tuple[WhatsAppThreadRow, WhatsAppMessageRow, bool]:
|
|
now = utc_now_iso()
|
|
author_type = _inbound_author_type(direction)
|
|
ai_eligible = _message_can_trigger_ai(author_type)
|
|
thread = session.execute(
|
|
select(WhatsAppThreadRow).where(WhatsAppThreadRow.chat_id == chat_id)
|
|
).scalar_one_or_none()
|
|
created = False
|
|
assigned_customer_id = _resolve_or_create_customer_id(
|
|
session,
|
|
whatsapp_user_id=whatsapp_user_id,
|
|
chat_id=chat_id,
|
|
display_name=display_name,
|
|
phone_number=phone_number,
|
|
explicit_customer_id=customer_id,
|
|
external_subject=external_subject,
|
|
)
|
|
_ensure_customer_phone(session, customer_id=assigned_customer_id, phone_number=phone_number)
|
|
resolved_queue_id = queue_id or (thread.queue_id if thread else None) or _default_queue_id()
|
|
|
|
if not thread:
|
|
interaction_id = _create_interaction(
|
|
session,
|
|
subject=text,
|
|
customer_id=assigned_customer_id,
|
|
queue_id=resolved_queue_id,
|
|
created_at=now,
|
|
)
|
|
thread = WhatsAppThreadRow(
|
|
thread_id=new_id("wht"),
|
|
chat_id=chat_id,
|
|
interaction_id=interaction_id,
|
|
whatsapp_user_id=whatsapp_user_id,
|
|
phone_number=phone_number,
|
|
display_name=display_name,
|
|
queue_id=resolved_queue_id,
|
|
is_group=is_group,
|
|
status="new",
|
|
claimed_by_user=None,
|
|
claimed_at=None,
|
|
ai_session_id=None,
|
|
ai_state="queued" if ai_eligible else None,
|
|
ai_handoff_reason=None,
|
|
ai_last_model_at=None,
|
|
last_message_at=now,
|
|
last_message_preview=_preview_text(text, 140),
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(thread)
|
|
created = True
|
|
else:
|
|
if thread.status == "closed":
|
|
_reactivate_thread_interaction(session, thread, now, requeue_ai=ai_eligible)
|
|
thread.whatsapp_user_id = whatsapp_user_id or thread.whatsapp_user_id
|
|
thread.phone_number = phone_number or thread.phone_number
|
|
thread.display_name = display_name or thread.display_name
|
|
thread.queue_id = thread.queue_id or resolved_queue_id
|
|
thread.is_group = bool(thread.is_group or is_group)
|
|
interaction = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id == thread.interaction_id)
|
|
).scalar_one_or_none()
|
|
if interaction and interaction.customer_id != assigned_customer_id:
|
|
interaction.customer_id = assigned_customer_id
|
|
interaction.updated_at = now
|
|
if ai_eligible and thread.ai_state != "human_owned":
|
|
_mark_thread_ai_state(thread, ai_state="queued", ai_handoff_reason=None)
|
|
|
|
message_row = _persist_message(
|
|
session,
|
|
thread=thread,
|
|
text=text,
|
|
payload=payload,
|
|
direction=direction,
|
|
created_at=now,
|
|
customer_id=assigned_customer_id,
|
|
external_message_id=external_message_id,
|
|
author_type=author_type,
|
|
author_id=_first_external_subject(whatsapp_user_id, chat_id, external_subject or phone_number),
|
|
delivery_status="received",
|
|
)
|
|
_push_timeline(
|
|
session,
|
|
thread.interaction_id,
|
|
"whatsapp.message_received",
|
|
{
|
|
"thread_id": thread.thread_id,
|
|
"chat_id": chat_id,
|
|
"direction": direction,
|
|
"display_name": display_name,
|
|
},
|
|
)
|
|
session.flush()
|
|
return thread, message_row, created
|
|
|
|
|
|
def _manual_webhook_metadata(payload: WhatsAppWebhookIn) -> dict[str, Any]:
|
|
raw_payload = payload.payload if isinstance(payload.payload, dict) else {}
|
|
whatsapp_user_id = (
|
|
str(payload.whatsapp_user_id or raw_payload.get("whatsapp_user_id") or raw_payload.get("from") or "").strip()
|
|
or None
|
|
)
|
|
phone_number = _normalize_phone_number(payload.phone_number) or _normalize_phone_number(raw_payload.get("phone_number"))
|
|
display_name = (
|
|
str(payload.display_name or raw_payload.get("display_name") or raw_payload.get("profile_name") or "").strip()
|
|
or phone_number
|
|
or f"WhatsApp {payload.chat_id}"
|
|
)
|
|
queue_id = str(payload.queue_id or raw_payload.get("queue_id") or "").strip() or None
|
|
external_subject = str(payload.external_subject or raw_payload.get("external_subject") or "").strip() or None
|
|
is_group = bool(payload.is_group or raw_payload.get("is_group"))
|
|
return {
|
|
"whatsapp_user_id": whatsapp_user_id,
|
|
"display_name": display_name,
|
|
"phone_number": phone_number,
|
|
"queue_id": queue_id,
|
|
"external_subject": external_subject,
|
|
"is_group": is_group,
|
|
}
|
|
|
|
|
|
def _parse_provider_update(update: dict) -> dict:
|
|
if not isinstance(update, dict):
|
|
raise HTTPException(status_code=400, detail="Unsupported WhatsApp update")
|
|
|
|
if str(update.get("chat_id") or "").strip():
|
|
text = str(update.get("text") or "").strip()
|
|
message_type = str(update.get("message_type") or "").strip() or "message"
|
|
direction = "inbound"
|
|
if not text:
|
|
text = "[Shared WhatsApp contact]" if message_type == "contacts" else f"[Unsupported WhatsApp content: {message_type}]"
|
|
direction = "system"
|
|
whatsapp_user_id = str(update.get("whatsapp_user_id") or update.get("from") or update.get("chat_id") or "").strip() or None
|
|
phone_number = _normalize_phone_number(update.get("phone_number") or whatsapp_user_id)
|
|
return {
|
|
"chat_id": str(update.get("chat_id")).strip(),
|
|
"whatsapp_user_id": whatsapp_user_id,
|
|
"display_name": str(update.get("display_name") or phone_number or f"WhatsApp {update.get('chat_id')}").strip(),
|
|
"text": text,
|
|
"payload": update,
|
|
"external_message_id": str(update.get("external_message_id") or update.get("message_id") or "").strip() or None,
|
|
"phone_number": phone_number,
|
|
"external_subject": str(update.get("external_subject") or "").strip() or None,
|
|
"queue_id": str(update.get("queue_id") or "").strip() or None,
|
|
"is_group": bool(update.get("is_group")),
|
|
"direction": direction,
|
|
}
|
|
|
|
entry = update.get("entry")
|
|
if not isinstance(entry, list) or not entry:
|
|
raise HTTPException(status_code=400, detail="Unsupported WhatsApp provider payload")
|
|
changes = entry[0].get("changes") if isinstance(entry[0], dict) else None
|
|
if not isinstance(changes, list) or not changes:
|
|
raise HTTPException(status_code=400, detail="WhatsApp provider payload has no changes")
|
|
value = changes[0].get("value") if isinstance(changes[0], dict) else {}
|
|
if not isinstance(value, dict):
|
|
raise HTTPException(status_code=400, detail="WhatsApp provider payload is invalid")
|
|
messages = value.get("messages")
|
|
if not isinstance(messages, list) or not messages:
|
|
raise HTTPException(status_code=400, detail="WhatsApp provider payload has no messages")
|
|
|
|
message = messages[0] if isinstance(messages[0], dict) else {}
|
|
chat_id = str(message.get("from") or "").strip()
|
|
if not chat_id:
|
|
raise HTTPException(status_code=400, detail="WhatsApp chat_id is missing")
|
|
message_type = str(message.get("type") or "text").strip() or "text"
|
|
text = ""
|
|
if isinstance(message.get("text"), dict):
|
|
text = str(message["text"].get("body") or "").strip()
|
|
elif isinstance(message.get("interactive"), dict):
|
|
interactive = message["interactive"]
|
|
for key in ("button_reply", "list_reply"):
|
|
if isinstance(interactive.get(key), dict):
|
|
text = str(interactive[key].get("title") or interactive[key].get("description") or "").strip()
|
|
if text:
|
|
break
|
|
direction = "inbound"
|
|
if not text:
|
|
if message_type == "contacts":
|
|
text = "[Shared WhatsApp contact]"
|
|
direction = "system"
|
|
else:
|
|
text = f"[Unsupported WhatsApp content: {message_type}]"
|
|
direction = "system"
|
|
|
|
contacts = value.get("contacts") if isinstance(value.get("contacts"), list) else []
|
|
contact = contacts[0] if contacts and isinstance(contacts[0], dict) else {}
|
|
profile = contact.get("profile") if isinstance(contact.get("profile"), dict) else {}
|
|
display_name = str(profile.get("name") or contact.get("wa_id") or chat_id or f"WhatsApp {chat_id}").strip()
|
|
phone_number = _normalize_phone_number(contact.get("wa_id") or chat_id)
|
|
return {
|
|
"chat_id": chat_id,
|
|
"whatsapp_user_id": str(contact.get("wa_id") or chat_id).strip() or None,
|
|
"display_name": display_name,
|
|
"text": text,
|
|
"payload": update,
|
|
"external_message_id": str(message.get("id") or "").strip() or None,
|
|
"phone_number": phone_number,
|
|
"external_subject": str(contact.get("wa_id") or "").strip() or None,
|
|
"queue_id": str(update.get("queue_id") or "").strip() or None,
|
|
"is_group": bool(message.get("group_id") or value.get("is_group") or update.get("is_group")),
|
|
"direction": direction,
|
|
}
|
|
|
|
|
|
def _provider_status_to_delivery_status(raw_status: str | None) -> str | None:
|
|
value = str(raw_status or "").strip().lower()
|
|
if not value:
|
|
return None
|
|
if value in {"sent", "delivered", "read", "failed"}:
|
|
return value
|
|
return value[:32]
|
|
|
|
|
|
def _handle_provider_status_update(session, payload: dict) -> dict | None:
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
|
|
entry = payload.get("entry")
|
|
if not isinstance(entry, list) or not entry:
|
|
return None
|
|
changes = entry[0].get("changes") if isinstance(entry[0], dict) else None
|
|
if not isinstance(changes, list) or not changes:
|
|
return None
|
|
value = changes[0].get("value") if isinstance(changes[0], dict) else {}
|
|
if not isinstance(value, dict):
|
|
return None
|
|
|
|
statuses = value.get("statuses")
|
|
if not isinstance(statuses, list) or not statuses:
|
|
return None
|
|
|
|
status = statuses[0] if isinstance(statuses[0], dict) else {}
|
|
external_message_id = str(status.get("id") or "").strip()
|
|
if not external_message_id:
|
|
return {"ok": True, "status_event": True, "updated": False}
|
|
|
|
row = session.execute(
|
|
select(WhatsAppMessageRow).where(
|
|
WhatsAppMessageRow.whatsapp_message_id_external == external_message_id
|
|
)
|
|
).scalar_one_or_none()
|
|
if not row:
|
|
return {"ok": True, "status_event": True, "updated": False}
|
|
|
|
next_status = _provider_status_to_delivery_status(status.get("status"))
|
|
if next_status:
|
|
row.delivery_status = next_status
|
|
if next_status == "failed":
|
|
error_title = ""
|
|
errors = status.get("errors")
|
|
if isinstance(errors, list) and errors and isinstance(errors[0], dict):
|
|
error_title = str(
|
|
errors[0].get("title") or errors[0].get("message") or errors[0].get("code") or ""
|
|
).strip()
|
|
if error_title:
|
|
row.last_delivery_error = error_title[:1000]
|
|
payload_json = _loads_payload(row.payload_json)
|
|
payload_json["provider_status"] = status
|
|
row.payload_json = json.dumps(payload_json, ensure_ascii=False)
|
|
session.commit()
|
|
return {
|
|
"ok": True,
|
|
"status_event": True,
|
|
"updated": True,
|
|
"message_id": row.message_id,
|
|
"delivery_status": row.delivery_status,
|
|
}
|
|
|
|
|
|
def _verify_meta_webhook_signature(body: bytes, signature_header: str | None) -> None:
|
|
app_secret = _meta_app_secret()
|
|
if not app_secret:
|
|
return
|
|
if not signature_header:
|
|
raise HTTPException(status_code=403, detail="Missing Meta webhook signature")
|
|
prefix = "sha256="
|
|
signature_value = str(signature_header or "").strip()
|
|
if not signature_value.startswith(prefix):
|
|
raise HTTPException(status_code=403, detail="Invalid Meta webhook signature")
|
|
expected = hmac.new(
|
|
app_secret.encode("utf-8"),
|
|
body,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
actual = signature_value[len(prefix) :].strip().lower()
|
|
if not hmac.compare_digest(actual, expected):
|
|
raise HTTPException(status_code=403, detail="Invalid Meta webhook signature")
|
|
|
|
|
|
def _validate_provider_webhook_request(
|
|
body: bytes,
|
|
*,
|
|
x_whatsapp_webhook_secret: str | None,
|
|
x_hub_signature_256: str | None,
|
|
) -> None:
|
|
if x_hub_signature_256:
|
|
_verify_meta_webhook_signature(body, x_hub_signature_256)
|
|
return
|
|
expected_secret = _webhook_secret()
|
|
if expected_secret and x_whatsapp_webhook_secret != expected_secret:
|
|
raise HTTPException(status_code=403, detail="Invalid WhatsApp webhook secret")
|
|
|
|
|
|
def _send_whatsapp_message_via_meta(chat_id: str, text: str) -> dict:
|
|
access_token = _meta_access_token()
|
|
phone_number_id = _meta_phone_number_id()
|
|
if not access_token or not phone_number_id:
|
|
raise HTTPException(status_code=503, detail="WhatsApp Meta outbound delivery is not configured")
|
|
|
|
url = f"{_meta_graph_base_url()}/{_meta_graph_api_version()}/{phone_number_id}/messages"
|
|
headers = {
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
payload = {
|
|
"messaging_product": "whatsapp",
|
|
"to": chat_id,
|
|
"type": "text",
|
|
"text": {"body": text},
|
|
}
|
|
with httpx.Client(timeout=10.0) as client:
|
|
response = client.post(url, json=payload, headers=headers)
|
|
if response.status_code >= 400:
|
|
raise HTTPException(status_code=502, detail=f"WhatsApp Meta send failed: {response.text}")
|
|
try:
|
|
response_payload = response.json()
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=502, detail="WhatsApp Meta send returned non-JSON") from exc
|
|
if response_payload.get("error"):
|
|
raise HTTPException(status_code=502, detail=f"WhatsApp Meta send failed: {response_payload}")
|
|
result = (
|
|
response_payload.get("messages", [{}])[0]
|
|
if isinstance(response_payload.get("messages"), list) and response_payload.get("messages")
|
|
else {}
|
|
)
|
|
message_id = str(result.get("id") or response_payload.get("message_id") or "").strip() or None
|
|
return {
|
|
"ok": True,
|
|
"result": {
|
|
"message_id": message_id,
|
|
"chat_id": chat_id,
|
|
},
|
|
"provider": "meta",
|
|
"raw": response_payload,
|
|
}
|
|
|
|
|
|
def _send_whatsapp_message(chat_id: str, text: str) -> dict:
|
|
if _meta_access_token() and _meta_phone_number_id():
|
|
return _send_whatsapp_message_via_meta(chat_id, text)
|
|
if not _outbound_enabled() or not _outbound_send_url():
|
|
raise HTTPException(status_code=503, detail="WhatsApp outbound delivery is not configured")
|
|
headers: dict[str, str] = {}
|
|
if _outbound_auth_token():
|
|
headers["Authorization"] = f"Bearer {_outbound_auth_token()}"
|
|
with httpx.Client(timeout=10.0) as client:
|
|
response = client.post(
|
|
_outbound_send_url(),
|
|
json={"chat_id": chat_id, "text": text},
|
|
headers=headers,
|
|
)
|
|
if response.status_code >= 400:
|
|
raise HTTPException(status_code=502, detail=f"WhatsApp outbound send failed: {response.text}")
|
|
try:
|
|
payload = response.json()
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=502, detail="WhatsApp outbound send returned non-JSON") from exc
|
|
if payload.get("ok") is False:
|
|
raise HTTPException(status_code=502, detail=f"WhatsApp outbound send failed: {payload}")
|
|
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
|
message_id = (
|
|
str(result.get("message_id") or payload.get("message_id") or payload.get("id") or "").strip()
|
|
or (
|
|
str(payload.get("messages", [{}])[0].get("id") or "").strip()
|
|
if isinstance(payload.get("messages"), list) and payload.get("messages")
|
|
else ""
|
|
)
|
|
)
|
|
return {
|
|
"ok": True,
|
|
"result": {
|
|
**(result if isinstance(result, dict) else {}),
|
|
"message_id": message_id or None,
|
|
},
|
|
}
|
|
|
|
|
|
def _whatsapp_reply_delivery_filter(now: str):
|
|
return or_(
|
|
WhatsAppMessageRow.delivery_status == "pending",
|
|
WhatsAppMessageRow.delivery_status == "retrying",
|
|
and_(
|
|
WhatsAppMessageRow.delivery_status == "sending",
|
|
or_(
|
|
WhatsAppMessageRow.delivery_locked_until.is_(None),
|
|
WhatsAppMessageRow.delivery_locked_until < now,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def _whatsapp_reply_delivery_due_filter(now: str):
|
|
return or_(
|
|
WhatsAppMessageRow.next_delivery_attempt_at.is_(None),
|
|
WhatsAppMessageRow.next_delivery_attempt_at <= now,
|
|
)
|
|
|
|
|
|
def _delivery_error_text(error: Exception) -> str:
|
|
if isinstance(error, HTTPException):
|
|
detail = error.detail
|
|
if isinstance(detail, str):
|
|
return detail[:1000]
|
|
return str(detail)[:1000]
|
|
return str(error or error.__class__.__name__)[:1000]
|
|
|
|
|
|
def _whatsapp_reply_backoff_seconds(next_attempt: int) -> int:
|
|
base = _reply_delivery_retry_base_seconds()
|
|
cap = _reply_delivery_retry_cap_seconds()
|
|
return min(cap, base * (2 ** max(next_attempt - 1, 0)))
|
|
|
|
|
|
def _claim_whatsapp_reply_delivery(session, message_id: str) -> WhatsAppMessageRow | None:
|
|
now = utc_now_iso()
|
|
leased_until = _utc_after_seconds(_reply_delivery_lease_seconds())
|
|
claim = session.execute(
|
|
update(WhatsAppMessageRow)
|
|
.where(
|
|
WhatsAppMessageRow.message_id == message_id,
|
|
WhatsAppMessageRow.direction == "outbound",
|
|
WhatsAppMessageRow.thread_id.is_not(None),
|
|
WhatsAppMessageRow.interaction_id.is_not(None),
|
|
_whatsapp_reply_delivery_filter(now),
|
|
_whatsapp_reply_delivery_due_filter(now),
|
|
)
|
|
.values(
|
|
delivery_status="sending",
|
|
delivery_locked_until=leased_until,
|
|
)
|
|
)
|
|
session.commit()
|
|
if claim.rowcount != 1:
|
|
return None
|
|
return session.execute(
|
|
select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == message_id)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _mark_whatsapp_reply_delivery_failure(
|
|
session,
|
|
row: WhatsAppMessageRow,
|
|
error_text: str,
|
|
*,
|
|
allow_retry: bool = True,
|
|
) -> None:
|
|
attempts = int(row.delivery_attempts or 0) + 1
|
|
row.delivery_attempts = attempts
|
|
row.delivery_locked_until = None
|
|
row.last_delivery_error = error_text
|
|
should_retry = allow_retry and attempts < _reply_delivery_max_attempts()
|
|
if should_retry:
|
|
row.delivery_status = "retrying"
|
|
row.next_delivery_attempt_at = _utc_after_seconds(_whatsapp_reply_backoff_seconds(attempts))
|
|
else:
|
|
row.delivery_status = "failed"
|
|
row.next_delivery_attempt_at = None
|
|
if row.interaction_id and row.thread_id:
|
|
_push_timeline(
|
|
session,
|
|
row.interaction_id,
|
|
"whatsapp.message_failed",
|
|
{
|
|
"thread_id": row.thread_id,
|
|
"operator_user": row.operator_user,
|
|
"attempts": attempts,
|
|
"error": error_text,
|
|
},
|
|
)
|
|
session.commit()
|
|
|
|
|
|
def _mark_whatsapp_reply_delivery_success(session, row: WhatsAppMessageRow, whatsapp_payload: dict) -> None:
|
|
row.delivery_attempts = int(row.delivery_attempts or 0) + 1
|
|
row.delivery_status = "sent"
|
|
row.delivery_locked_until = None
|
|
row.next_delivery_attempt_at = None
|
|
row.last_delivery_error = None
|
|
result_message = whatsapp_payload.get("result") if isinstance(whatsapp_payload, dict) else {}
|
|
if isinstance(result_message, dict) and result_message.get("message_id") is not None:
|
|
row.whatsapp_message_id_external = str(result_message.get("message_id"))
|
|
_push_timeline(
|
|
session,
|
|
row.interaction_id,
|
|
"whatsapp.message_sent",
|
|
{"thread_id": row.thread_id, "operator_user": row.operator_user},
|
|
)
|
|
session.commit()
|
|
|
|
|
|
def _deliver_pending_whatsapp_reply(message_id: str) -> bool:
|
|
session = get_session()
|
|
try:
|
|
row = _claim_whatsapp_reply_delivery(session, message_id)
|
|
if not row or not row.thread_id or not row.interaction_id:
|
|
return False
|
|
|
|
thread = session.execute(
|
|
select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == row.thread_id)
|
|
).scalar_one_or_none()
|
|
if not thread:
|
|
_mark_whatsapp_reply_delivery_failure(
|
|
session,
|
|
row,
|
|
"WhatsApp thread not found during reply delivery",
|
|
allow_retry=False,
|
|
)
|
|
return False
|
|
|
|
try:
|
|
whatsapp_payload = _send_whatsapp_message(row.chat_id, row.text)
|
|
except Exception as exc: # noqa: BLE001
|
|
_mark_whatsapp_reply_delivery_failure(session, row, _delivery_error_text(exc))
|
|
return False
|
|
|
|
_mark_whatsapp_reply_delivery_success(session, row, whatsapp_payload)
|
|
return True
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def _claim_next_due_whatsapp_reply_message_id(session) -> str | None:
|
|
now = utc_now_iso()
|
|
return session.execute(
|
|
select(WhatsAppMessageRow.message_id)
|
|
.where(
|
|
WhatsAppMessageRow.direction == "outbound",
|
|
WhatsAppMessageRow.thread_id.is_not(None),
|
|
WhatsAppMessageRow.interaction_id.is_not(None),
|
|
_whatsapp_reply_delivery_filter(now),
|
|
_whatsapp_reply_delivery_due_filter(now),
|
|
)
|
|
.order_by(
|
|
case(
|
|
(WhatsAppMessageRow.delivery_status == "sending", 0),
|
|
(WhatsAppMessageRow.delivery_status == "retrying", 1),
|
|
else_=2,
|
|
),
|
|
WhatsAppMessageRow.created_at.asc(),
|
|
WhatsAppMessageRow.id.asc(),
|
|
)
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _process_due_whatsapp_reply_batch(batch_size: int = 20) -> int:
|
|
processed = 0
|
|
attempts = 0
|
|
while processed < batch_size and attempts < batch_size * 2:
|
|
attempts += 1
|
|
session = get_session()
|
|
try:
|
|
message_id = _claim_next_due_whatsapp_reply_message_id(session)
|
|
finally:
|
|
session.close()
|
|
if not message_id:
|
|
break
|
|
if _deliver_pending_whatsapp_reply(message_id):
|
|
processed += 1
|
|
return processed
|
|
|
|
|
|
def _reply_delivery_worker_loop(stop_event: threading.Event, wakeup_event: threading.Event) -> None:
|
|
while not stop_event.is_set():
|
|
wakeup_event.wait(timeout=_reply_delivery_poll_seconds())
|
|
wakeup_event.clear()
|
|
if stop_event.is_set():
|
|
break
|
|
try:
|
|
_process_due_whatsapp_reply_batch()
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("WhatsApp reply delivery worker cycle failed")
|
|
|
|
|
|
def _wake_whatsapp_reply_delivery_worker() -> None:
|
|
with _reply_delivery_worker_lock:
|
|
wakeup_event = _reply_delivery_worker_wakeup
|
|
if wakeup_event:
|
|
wakeup_event.set()
|
|
|
|
|
|
def _start_reply_delivery_worker() -> None:
|
|
global _reply_delivery_worker_clients
|
|
global _reply_delivery_worker_stop
|
|
global _reply_delivery_worker_thread
|
|
global _reply_delivery_worker_wakeup
|
|
|
|
if not _reply_delivery_worker_enabled():
|
|
return
|
|
|
|
with _reply_delivery_worker_lock:
|
|
_reply_delivery_worker_clients += 1
|
|
if _reply_delivery_worker_thread and _reply_delivery_worker_thread.is_alive():
|
|
wakeup_event = _reply_delivery_worker_wakeup
|
|
else:
|
|
stop_event = threading.Event()
|
|
wakeup_event = threading.Event()
|
|
worker = threading.Thread(
|
|
target=_reply_delivery_worker_loop,
|
|
args=(stop_event, wakeup_event),
|
|
name="whatsapp-reply-delivery-worker",
|
|
daemon=True,
|
|
)
|
|
_reply_delivery_worker_stop = stop_event
|
|
_reply_delivery_worker_wakeup = wakeup_event
|
|
_reply_delivery_worker_thread = worker
|
|
worker.start()
|
|
if wakeup_event:
|
|
wakeup_event.set()
|
|
|
|
|
|
def _stop_reply_delivery_worker() -> None:
|
|
global _reply_delivery_worker_clients
|
|
global _reply_delivery_worker_stop
|
|
global _reply_delivery_worker_thread
|
|
global _reply_delivery_worker_wakeup
|
|
|
|
thread: threading.Thread | None = None
|
|
stop_event: threading.Event | None = None
|
|
wakeup_event: threading.Event | None = None
|
|
|
|
with _reply_delivery_worker_lock:
|
|
if _reply_delivery_worker_clients > 0:
|
|
_reply_delivery_worker_clients -= 1
|
|
if _reply_delivery_worker_clients > 0:
|
|
return
|
|
thread = _reply_delivery_worker_thread
|
|
stop_event = _reply_delivery_worker_stop
|
|
wakeup_event = _reply_delivery_worker_wakeup
|
|
_reply_delivery_worker_thread = None
|
|
_reply_delivery_worker_stop = None
|
|
_reply_delivery_worker_wakeup = None
|
|
|
|
if stop_event:
|
|
stop_event.set()
|
|
if wakeup_event:
|
|
wakeup_event.set()
|
|
if thread and thread.is_alive():
|
|
thread.join(timeout=2)
|
|
|
|
|
|
def _start_whatsapp_reply_delivery(message_id: str) -> None:
|
|
if _reply_delivery_worker_enabled():
|
|
_wake_whatsapp_reply_delivery_worker()
|
|
return
|
|
threading.Thread(
|
|
target=_deliver_pending_whatsapp_reply,
|
|
args=(message_id,),
|
|
daemon=True,
|
|
).start()
|
|
|
|
|
|
@app.on_event("startup")
|
|
def _start_delivery_worker_on_startup() -> None:
|
|
_start_reply_delivery_worker()
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
def _stop_delivery_worker_on_shutdown() -> None:
|
|
_stop_reply_delivery_worker()
|
|
|
|
|
|
def _ensure_operator_can_manage(
|
|
actor: dict,
|
|
thread: WhatsAppThreadRow,
|
|
*,
|
|
for_reply: bool = False,
|
|
require_claim: bool = False,
|
|
) -> None:
|
|
if actor["role"] in {Role.ADMIN.value, Role.SUPERVISOR.value}:
|
|
return
|
|
if actor["role"] != Role.OPERATOR.value:
|
|
raise HTTPException(status_code=403, detail="Insufficient role")
|
|
if for_reply and thread.claimed_by_user != actor["user"]:
|
|
raise HTTPException(status_code=403, detail="Thread must be claimed by the current operator")
|
|
if require_claim and thread.claimed_by_user != actor["user"]:
|
|
raise HTTPException(status_code=403, detail="Thread must be claimed by the current operator")
|
|
if not for_reply and thread.claimed_by_user and thread.claimed_by_user != actor["user"]:
|
|
raise HTTPException(status_code=403, detail="Thread is already claimed by another operator")
|
|
|
|
|
|
def _raise_claim_conflict(thread: WhatsAppThreadRow, actor_user: str) -> None:
|
|
if thread.status == "closed":
|
|
raise HTTPException(status_code=409, detail="WhatsApp thread is closed")
|
|
if thread.claimed_by_user and thread.claimed_by_user != actor_user:
|
|
raise HTTPException(status_code=409, detail="WhatsApp thread is already claimed by another operator")
|
|
raise HTTPException(status_code=409, detail="WhatsApp thread claim conflict")
|
|
|
|
|
|
def _ensure_thread_available_for_ai(thread: WhatsAppThreadRow) -> None:
|
|
if thread.status == "closed":
|
|
raise HTTPException(status_code=409, detail="WhatsApp thread is closed")
|
|
if thread.ai_state == "human_owned" or thread.claimed_by_user:
|
|
raise HTTPException(status_code=409, detail="WhatsApp thread is owned by a human operator")
|
|
|
|
|
|
def _claim_thread_locally(
|
|
session,
|
|
*,
|
|
thread_id: str,
|
|
actor_user: str,
|
|
claimed_at: str,
|
|
) -> tuple[WhatsAppThreadRow, str, bool]:
|
|
thread = _get_thread(session, thread_id)
|
|
if thread.claimed_by_user == actor_user and thread.status == "in_progress":
|
|
return thread, thread.status, False
|
|
|
|
previous_status = thread.status
|
|
result = session.execute(
|
|
update(WhatsAppThreadRow)
|
|
.where(
|
|
WhatsAppThreadRow.thread_id == thread_id,
|
|
WhatsAppThreadRow.status != "closed",
|
|
or_(
|
|
WhatsAppThreadRow.claimed_by_user.is_(None),
|
|
WhatsAppThreadRow.claimed_by_user == actor_user,
|
|
),
|
|
)
|
|
.values(
|
|
claimed_by_user=actor_user,
|
|
claimed_at=claimed_at,
|
|
updated_at=claimed_at,
|
|
)
|
|
)
|
|
if not result.rowcount:
|
|
session.rollback()
|
|
_raise_claim_conflict(_get_thread(session, thread_id), actor_user)
|
|
|
|
session.commit()
|
|
return _get_thread(session, thread_id), previous_status, True
|
|
|
|
|
|
def _rollback_thread_claim(
|
|
session,
|
|
*,
|
|
thread_id: str,
|
|
actor_user: str,
|
|
claimed_at: str,
|
|
previous_status: str,
|
|
) -> None:
|
|
try:
|
|
session.execute(
|
|
update(WhatsAppThreadRow)
|
|
.where(
|
|
WhatsAppThreadRow.thread_id == thread_id,
|
|
WhatsAppThreadRow.claimed_by_user == actor_user,
|
|
WhatsAppThreadRow.claimed_at == claimed_at,
|
|
)
|
|
.values(
|
|
claimed_by_user=None,
|
|
claimed_at=None,
|
|
status=previous_status,
|
|
updated_at=utc_now_iso(),
|
|
)
|
|
)
|
|
session.commit()
|
|
except Exception:
|
|
session.rollback()
|
|
|
|
|
|
@app.get("/health", response_model=HealthResponse)
|
|
def health() -> HealthResponse:
|
|
return HealthResponse(status="ok", service="whatsapp-adapter-service", version="v2")
|
|
|
|
|
|
@app.post("/integrations/whatsapp/webhook", response_model=WhatsAppWebhookOut)
|
|
def webhook(payload: WhatsAppWebhookIn) -> WhatsAppWebhookOut:
|
|
session = get_session()
|
|
try:
|
|
metadata = _manual_webhook_metadata(payload)
|
|
existing = _existing_bot_message(
|
|
session,
|
|
chat_id=payload.chat_id,
|
|
external_message_id=payload.external_message_id,
|
|
)
|
|
if existing:
|
|
return _to_webhook_out(existing)
|
|
text = str(payload.text or "").strip()
|
|
if not text:
|
|
text = "[Unsupported WhatsApp content: message]"
|
|
direction = "system"
|
|
else:
|
|
direction = "inbound"
|
|
thread, row, _ = _upsert_thread_for_inbound(
|
|
session,
|
|
chat_id=payload.chat_id,
|
|
whatsapp_user_id=metadata["whatsapp_user_id"],
|
|
display_name=metadata["display_name"],
|
|
text=text,
|
|
payload=payload.payload,
|
|
customer_id=payload.customer_id,
|
|
external_subject=metadata["external_subject"],
|
|
external_message_id=payload.external_message_id,
|
|
phone_number=metadata["phone_number"],
|
|
queue_id=metadata["queue_id"],
|
|
is_group=metadata["is_group"],
|
|
direction=direction,
|
|
)
|
|
try:
|
|
session.commit()
|
|
except IntegrityError:
|
|
session.rollback()
|
|
existing = _existing_bot_message(
|
|
session,
|
|
chat_id=payload.chat_id,
|
|
external_message_id=payload.external_message_id,
|
|
)
|
|
if existing:
|
|
return _to_webhook_out(existing)
|
|
raise
|
|
session.refresh(row)
|
|
session.refresh(thread)
|
|
_maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
|
|
return _to_webhook_out(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/integrations/whatsapp/provider/webhook", response_class=PlainTextResponse)
|
|
def verify_provider_webhook(
|
|
hub_mode: str | None = Query(default=None, alias="hub.mode"),
|
|
hub_verify_token: str | None = Query(default=None, alias="hub.verify_token"),
|
|
hub_challenge: str | None = Query(default=None, alias="hub.challenge"),
|
|
) -> str:
|
|
expected_token = _meta_verify_token()
|
|
if not expected_token:
|
|
raise HTTPException(status_code=503, detail="WhatsApp Meta verify token is not configured")
|
|
if str(hub_mode or "").strip().lower() != "subscribe":
|
|
raise HTTPException(status_code=400, detail="Unsupported WhatsApp webhook mode")
|
|
if hub_verify_token != expected_token:
|
|
raise HTTPException(status_code=403, detail="Invalid WhatsApp verify token")
|
|
return str(hub_challenge or "")
|
|
|
|
|
|
@app.post("/integrations/whatsapp/provider/webhook")
|
|
async def provider_webhook(
|
|
request: Request,
|
|
x_whatsapp_webhook_secret: str | None = Header(
|
|
default=None,
|
|
alias="X-WhatsApp-Webhook-Secret",
|
|
),
|
|
x_hub_signature_256: str | None = Header(
|
|
default=None,
|
|
alias="X-Hub-Signature-256",
|
|
),
|
|
) -> dict:
|
|
raw_body = await request.body()
|
|
_validate_provider_webhook_request(
|
|
raw_body,
|
|
x_whatsapp_webhook_secret=x_whatsapp_webhook_secret,
|
|
x_hub_signature_256=x_hub_signature_256,
|
|
)
|
|
try:
|
|
payload = json.loads((raw_body or b"{}").decode("utf-8"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="WhatsApp provider payload is not valid JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=400, detail="Unsupported WhatsApp provider payload")
|
|
session = get_session()
|
|
try:
|
|
status_result = _handle_provider_status_update(session, payload)
|
|
if status_result is not None:
|
|
return status_result
|
|
parsed = _parse_provider_update(payload)
|
|
existing = _existing_bot_message(
|
|
session,
|
|
chat_id=parsed["chat_id"],
|
|
external_message_id=parsed["external_message_id"],
|
|
)
|
|
if existing:
|
|
return _to_bot_webhook_result(existing, thread_created=False)
|
|
thread, row, created = _upsert_thread_for_inbound(
|
|
session,
|
|
chat_id=parsed["chat_id"],
|
|
whatsapp_user_id=parsed["whatsapp_user_id"],
|
|
display_name=parsed["display_name"],
|
|
text=parsed["text"],
|
|
payload=parsed["payload"],
|
|
external_subject=parsed["external_subject"],
|
|
external_message_id=parsed["external_message_id"],
|
|
phone_number=parsed["phone_number"],
|
|
queue_id=parsed["queue_id"],
|
|
is_group=parsed["is_group"],
|
|
direction=parsed["direction"],
|
|
)
|
|
try:
|
|
session.commit()
|
|
except IntegrityError:
|
|
session.rollback()
|
|
existing = _existing_bot_message(
|
|
session,
|
|
chat_id=parsed["chat_id"],
|
|
external_message_id=parsed["external_message_id"],
|
|
)
|
|
if existing:
|
|
return _to_bot_webhook_result(existing, thread_created=False)
|
|
raise
|
|
_maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
|
|
return _to_bot_webhook_result(row, thread_created=created)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get(
|
|
"/integrations/whatsapp/messages",
|
|
response_model=list[WhatsAppWebhookOut],
|
|
)
|
|
def list_messages(
|
|
limit: int = 100,
|
|
chat_id: str | None = None,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> list[WhatsAppWebhookOut]:
|
|
session = get_session()
|
|
try:
|
|
stmt = select(WhatsAppMessageRow).order_by(WhatsAppMessageRow.id.desc())
|
|
if chat_id:
|
|
stmt = stmt.where(WhatsAppMessageRow.chat_id == chat_id)
|
|
rows = session.execute(stmt.limit(max(limit, 1))).scalars().all()
|
|
return [_to_webhook_out(r) for r in reversed(rows)]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get(
|
|
"/integrations/whatsapp/threads",
|
|
response_model=list[WhatsAppThreadOut],
|
|
)
|
|
def list_threads(
|
|
limit: int = 100,
|
|
status: InteractionStatus | None = None,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> list[WhatsAppThreadOut]:
|
|
session = get_session()
|
|
try:
|
|
stmt = select(WhatsAppThreadRow).order_by(WhatsAppThreadRow.last_message_at.desc())
|
|
if status:
|
|
stmt = stmt.where(WhatsAppThreadRow.status == status)
|
|
rows = session.execute(stmt.limit(max(limit, 1))).scalars().all()
|
|
return [
|
|
_to_thread_out(row, unread_count=_thread_unread_count(session, row.thread_id))
|
|
for row in rows
|
|
]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get(
|
|
"/integrations/whatsapp/threads/{thread_id}",
|
|
response_model=WhatsAppThreadOut,
|
|
)
|
|
def get_thread(
|
|
thread_id: str,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> WhatsAppThreadOut:
|
|
session = get_session()
|
|
try:
|
|
row = _get_thread(session, thread_id)
|
|
return _to_thread_out(row, unread_count=_thread_unread_count(session, row.thread_id))
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get(
|
|
"/integrations/whatsapp/threads/{thread_id}/ai-summary",
|
|
response_model=WhatsAppThreadAISummaryOut | None,
|
|
)
|
|
def get_thread_ai_summary(
|
|
thread_id: str,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> WhatsAppThreadAISummaryOut | None:
|
|
session = get_session()
|
|
try:
|
|
thread = _get_thread(session, thread_id)
|
|
if actor["role"] == Role.OPERATOR.value and thread.claimed_by_user and thread.claimed_by_user != actor["user"]:
|
|
raise HTTPException(status_code=403, detail="Thread is already claimed by another operator")
|
|
return _build_ai_summary(session, thread)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get(
|
|
"/integrations/whatsapp/threads/{thread_id}/messages",
|
|
response_model=list[WhatsAppThreadMessageOut],
|
|
)
|
|
def get_thread_messages(
|
|
thread_id: str,
|
|
limit: int = 200,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> list[WhatsAppThreadMessageOut]:
|
|
session = get_session()
|
|
try:
|
|
thread = _get_thread(session, thread_id)
|
|
rows = session.execute(
|
|
select(WhatsAppMessageRow)
|
|
.where(WhatsAppMessageRow.thread_id == thread.thread_id)
|
|
.order_by(WhatsAppMessageRow.id.desc())
|
|
.limit(max(limit, 1))
|
|
).scalars().all()
|
|
return [_to_message_out(row) for row in reversed(rows)]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post(
|
|
"/integrations/whatsapp/threads/{thread_id}/claim",
|
|
response_model=WhatsAppThreadOut,
|
|
)
|
|
def claim_thread(
|
|
thread_id: str,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> WhatsAppThreadOut:
|
|
session = get_session()
|
|
try:
|
|
claimed_at = utc_now_iso()
|
|
thread, previous_status, local_claimed = _claim_thread_locally(
|
|
session,
|
|
thread_id=thread_id,
|
|
actor_user=actor["user"],
|
|
claimed_at=claimed_at,
|
|
)
|
|
if not local_claimed:
|
|
return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
|
|
try:
|
|
result = _interaction_request(
|
|
"PATCH",
|
|
f"/interactions/{thread.interaction_id}/assign",
|
|
payload={"assignee": actor["user"]},
|
|
)
|
|
except Exception:
|
|
_rollback_thread_claim(
|
|
session,
|
|
thread_id=thread_id,
|
|
actor_user=actor["user"],
|
|
claimed_at=claimed_at,
|
|
previous_status=previous_status,
|
|
)
|
|
raise
|
|
thread = _get_thread(session, thread_id)
|
|
thread.status = result.get("status", "in_progress")
|
|
thread.updated_at = utc_now_iso()
|
|
_mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None)
|
|
_update_ai_session_state(
|
|
session,
|
|
session_id=thread.ai_session_id,
|
|
status="human_owned",
|
|
updated_at=thread.updated_at,
|
|
)
|
|
_push_timeline(
|
|
session,
|
|
thread.interaction_id,
|
|
"ai.human_takeover",
|
|
{"thread_id": thread.thread_id, "actor_user": actor["user"]},
|
|
)
|
|
session.commit()
|
|
session.refresh(thread)
|
|
return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post(
|
|
"/integrations/whatsapp/threads/{thread_id}/return-to-ai",
|
|
response_model=WhatsAppThreadOut,
|
|
)
|
|
def return_thread_to_ai(
|
|
thread_id: str,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> WhatsAppThreadOut:
|
|
if not _ai_whatsapp_enabled():
|
|
raise HTTPException(status_code=409, detail="WhatsApp AI is disabled")
|
|
|
|
session = get_session()
|
|
try:
|
|
thread = _get_thread(session, thread_id)
|
|
if thread.status == "closed":
|
|
raise HTTPException(status_code=409, detail="WhatsApp thread is closed")
|
|
_ensure_operator_can_manage(actor, thread, require_claim=True)
|
|
|
|
now = utc_now_iso()
|
|
interaction = session.execute(
|
|
select(Interaction).where(Interaction.interaction_id == thread.interaction_id)
|
|
).scalar_one_or_none()
|
|
if interaction:
|
|
interaction.status = "new"
|
|
interaction.assigned_to = None
|
|
interaction.updated_at = now
|
|
_push_timeline(
|
|
session,
|
|
interaction.interaction_id,
|
|
"interaction.status_changed",
|
|
{"status": "new", "source": "whatsapp-return-to-ai"},
|
|
)
|
|
|
|
thread.status = "new"
|
|
thread.claimed_by_user = None
|
|
thread.claimed_at = None
|
|
thread.updated_at = now
|
|
_mark_thread_ai_state(thread, ai_state="queued", ai_handoff_reason=None)
|
|
_update_ai_session_state(
|
|
session,
|
|
session_id=thread.ai_session_id,
|
|
status="active",
|
|
updated_at=now,
|
|
handoff_reason=None,
|
|
)
|
|
_push_timeline(
|
|
session,
|
|
thread.interaction_id,
|
|
"ai.returned_to_ai",
|
|
{"thread_id": thread.thread_id, "actor_user": actor["user"]},
|
|
)
|
|
session.commit()
|
|
session.refresh(thread)
|
|
return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post(
|
|
"/integrations/whatsapp/threads/{thread_id}/messages",
|
|
response_model=WhatsAppThreadMessageOut,
|
|
)
|
|
def reply_thread(
|
|
thread_id: str,
|
|
payload: WhatsAppThreadReplyIn,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> WhatsAppThreadMessageOut:
|
|
session = get_session()
|
|
try:
|
|
thread = _get_thread(session, thread_id)
|
|
if thread.status == "closed":
|
|
raise HTTPException(status_code=409, detail="WhatsApp thread is closed")
|
|
_ensure_operator_can_manage(actor, thread, for_reply=True)
|
|
now = utc_now_iso()
|
|
row = _persist_message(
|
|
session,
|
|
thread=thread,
|
|
text=payload.text,
|
|
payload={"operator_user": actor["user"]},
|
|
direction="outbound",
|
|
created_at=now,
|
|
operator_user=actor["user"],
|
|
author_type="human",
|
|
author_id=actor["user"],
|
|
delivery_status="pending",
|
|
)
|
|
row.delivery_attempts = 0
|
|
row.next_delivery_attempt_at = now
|
|
row.delivery_locked_until = None
|
|
row.last_delivery_error = None
|
|
_mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None)
|
|
_update_ai_session_state(
|
|
session,
|
|
session_id=thread.ai_session_id,
|
|
status="human_owned",
|
|
updated_at=now,
|
|
)
|
|
session.commit()
|
|
session.refresh(row)
|
|
_start_whatsapp_reply_delivery(row.message_id)
|
|
return _to_message_out(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post(
|
|
"/integrations/whatsapp/threads/{thread_id}/ai/reply",
|
|
response_model=WhatsAppThreadMessageOut,
|
|
)
|
|
def ai_reply_thread(
|
|
thread_id: str,
|
|
payload: WhatsAppThreadAIReplyIn,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
|
|
) -> WhatsAppThreadMessageOut:
|
|
session = get_session()
|
|
try:
|
|
thread = _get_thread(session, thread_id)
|
|
_ensure_thread_available_for_ai(thread)
|
|
now = utc_now_iso()
|
|
row = _persist_message(
|
|
session,
|
|
thread=thread,
|
|
text=payload.text,
|
|
payload={
|
|
"agent_profile": payload.agent_profile,
|
|
"model": payload.model,
|
|
"trigger_message_id": payload.trigger_message_id,
|
|
"language": payload.language,
|
|
"confidence": payload.confidence,
|
|
"kb_refs": payload.kb_refs,
|
|
**(payload.payload or {}),
|
|
},
|
|
direction="outbound",
|
|
created_at=now,
|
|
author_type="ai",
|
|
author_id=payload.agent_profile,
|
|
delivery_status="pending",
|
|
)
|
|
row.delivery_attempts = 0
|
|
row.next_delivery_attempt_at = now
|
|
row.delivery_locked_until = None
|
|
row.last_delivery_error = None
|
|
_mark_thread_ai_state(thread, ai_state="active", ai_handoff_reason=None, ai_last_model_at=now)
|
|
_update_ai_session_state(
|
|
session,
|
|
session_id=thread.ai_session_id,
|
|
status="active",
|
|
updated_at=now,
|
|
handoff_reason=None,
|
|
)
|
|
if thread.ai_session_id:
|
|
ai_session = session.execute(
|
|
select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id)
|
|
).scalar_one_or_none()
|
|
if ai_session:
|
|
ai_session.last_ai_message_id = row.message_id
|
|
if payload.language:
|
|
ai_session.language = payload.language
|
|
_push_timeline(
|
|
session,
|
|
thread.interaction_id,
|
|
"ai.reply_generated",
|
|
{
|
|
"thread_id": thread.thread_id,
|
|
"message_id": row.message_id,
|
|
"agent_profile": payload.agent_profile,
|
|
"model": payload.model,
|
|
"confidence": payload.confidence,
|
|
"kb_refs": payload.kb_refs,
|
|
"actor_user": actor["user"],
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(row)
|
|
_start_whatsapp_reply_delivery(row.message_id)
|
|
return _to_message_out(row)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post(
|
|
"/integrations/whatsapp/threads/{thread_id}/ai/handoff",
|
|
response_model=WhatsAppThreadOut,
|
|
)
|
|
def ai_handoff_thread(
|
|
thread_id: str,
|
|
payload: WhatsAppThreadAIHandoffIn,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
|
|
) -> WhatsAppThreadOut:
|
|
session = get_session()
|
|
try:
|
|
thread = _get_thread(session, thread_id)
|
|
_ensure_thread_available_for_ai(thread)
|
|
now = utc_now_iso()
|
|
_mark_thread_ai_state(
|
|
thread,
|
|
ai_state="handoff_required",
|
|
ai_handoff_reason=payload.reason,
|
|
ai_last_model_at=now,
|
|
)
|
|
thread.updated_at = now
|
|
_update_ai_session_state(
|
|
session,
|
|
session_id=thread.ai_session_id,
|
|
status="handoff_required",
|
|
updated_at=now,
|
|
handoff_reason=payload.reason,
|
|
)
|
|
_push_timeline(
|
|
session,
|
|
thread.interaction_id,
|
|
"ai.handoff_requested",
|
|
{
|
|
"thread_id": thread.thread_id,
|
|
"reason": payload.reason,
|
|
"agent_profile": payload.agent_profile,
|
|
"trigger_message_id": payload.trigger_message_id,
|
|
"confidence": payload.confidence,
|
|
"actor_user": actor["user"],
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(thread)
|
|
return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post(
|
|
"/integrations/whatsapp/threads/{thread_id}/close",
|
|
response_model=WhatsAppThreadOut,
|
|
)
|
|
def close_thread(
|
|
thread_id: str,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> WhatsAppThreadOut:
|
|
session = get_session()
|
|
try:
|
|
thread = _get_thread(session, thread_id)
|
|
_ensure_operator_can_manage(actor, thread, require_claim=True)
|
|
_interaction_request(
|
|
"PATCH",
|
|
f"/interactions/{thread.interaction_id}/status",
|
|
payload={"status": "closed"},
|
|
)
|
|
thread.status = "closed"
|
|
thread.claimed_by_user = None
|
|
thread.claimed_at = None
|
|
thread.updated_at = utc_now_iso()
|
|
_mark_thread_ai_state(thread, ai_state="closed", ai_handoff_reason=None, ai_last_model_at=thread.ai_last_model_at)
|
|
_update_ai_session_state(
|
|
session,
|
|
session_id=thread.ai_session_id,
|
|
status="closed",
|
|
updated_at=thread.updated_at,
|
|
closed=True,
|
|
)
|
|
session.commit()
|
|
session.refresh(thread)
|
|
return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post(
|
|
"/integrations/whatsapp/threads/{thread_id}/escalate",
|
|
response_model=WhatsAppThreadOut,
|
|
)
|
|
def escalate_thread(
|
|
thread_id: str,
|
|
payload: WhatsAppThreadEscalateIn,
|
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
|
) -> WhatsAppThreadOut:
|
|
session = get_session()
|
|
try:
|
|
thread = _get_thread(session, thread_id)
|
|
_ensure_operator_can_manage(actor, thread, require_claim=True)
|
|
result = _interaction_request(
|
|
"POST",
|
|
f"/interactions/{thread.interaction_id}/escalate",
|
|
payload=EscalateRequest(target_queue_id=payload.target_queue_id).model_dump(),
|
|
)
|
|
thread.status = result.get("status", "escalated")
|
|
thread.queue_id = result.get("queue_id", payload.target_queue_id)
|
|
thread.updated_at = utc_now_iso()
|
|
_mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None)
|
|
_update_ai_session_state(
|
|
session,
|
|
session_id=thread.ai_session_id,
|
|
status="human_owned",
|
|
updated_at=thread.updated_at,
|
|
)
|
|
session.commit()
|
|
session.refresh(thread)
|
|
return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
|
|
finally:
|
|
session.close()
|