Files
call-center/services/whatsapp_adapter_service/app.py
T
Yera AllandClaude Opus 4.6 6798320209 fix: remove dead code duplicates, add SQL LIMIT across all services
- 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>
2026-04-13 12:04:26 +05:00

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()