The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.
- widget loader is now webclient/replypier.min.js; both bundles rebuilt
from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
rewritten rather than string-swapped; the demo config no longer ships
the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
documents carrying the old local data over
320 lines
14 KiB
Python
320 lines
14 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import re
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.channels import get_adapter
|
|
from app.channels.app_hooks import OutboundRecord, emit_channel_event, outbound_event
|
|
from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE, MessageSource
|
|
from app.services.channel_chat import placeholder_name
|
|
from app.services.business_message import remember_business_message
|
|
from app.services.channel_status import record_sent
|
|
from app.services.chat_notifications import team_not_yet_notified
|
|
from app.channels.meta_base import fetch_message_templates, graph_auth_refused, graph_detail
|
|
from app.core.logger import get_logger
|
|
from app.models.channels import ChannelAccount
|
|
from app.repositories.channels import (
|
|
AgentChannelConfigRepository,
|
|
ChannelAccountRepository,
|
|
ChannelConversationRepository,
|
|
)
|
|
from app.repositories.chat import ChatRepository
|
|
from app.repositories.customer import CustomerRepository
|
|
from app.repositories.session_to_agent import SessionToAgentRepository
|
|
from app.utils.phone import normalize_phone, to_wa_id
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Business-initiated conversations are Utility/Authentication only. Marketing
|
|
# is deliberately unsupported outbound: it carries opt-in obligations and
|
|
# per-user caps (Graph error 131049) that a support product should not walk
|
|
# its customers into. The reopen flow (an existing conversation) is unaffected.
|
|
OUTBOUND_CATEGORIES = {"UTILITY", "AUTHENTICATION"}
|
|
|
|
_PLACEHOLDER = re.compile(r"\{\{(\d+)\}\}")
|
|
|
|
|
|
@dataclass
|
|
class OutboundSent:
|
|
"""A started (or rejoined) conversation and the template that opened it."""
|
|
session_id: UUID
|
|
message_id: int
|
|
external_message_id: Optional[str]
|
|
|
|
|
|
class OutboundError(Exception):
|
|
"""A refused or failed outbound send. Carries the HTTP status the API
|
|
route should answer with, so the Phase-2 scheduler can reuse the service
|
|
without importing anything web-shaped."""
|
|
|
|
def __init__(self, status_code: int, detail: str):
|
|
super().__init__(detail)
|
|
self.status_code = status_code
|
|
self.detail = detail
|
|
|
|
|
|
def _render_body(template: dict, components: Optional[list]) -> str:
|
|
"""The message text as the customer will read it.
|
|
|
|
Graph matches body parameters to {{n}} placeholders positionally in
|
|
ascending order (the same rule the frontend's previewTemplate applies —
|
|
keep the two honest with each other). Falls back to the raw body when a
|
|
parameter is missing rather than failing the send: Meta is the authority
|
|
on completeness and will reject a genuinely short send itself.
|
|
"""
|
|
body = next((c.get("text", "") for c in template.get("components") or []
|
|
if str(c.get("type", "")).upper() == "BODY"), "")
|
|
values = [
|
|
str(parameter.get("text", ""))
|
|
for component in components or []
|
|
if str(component.get("type", "")).lower() == "body"
|
|
for parameter in component.get("parameters") or []
|
|
]
|
|
return _PLACEHOLDER.sub(
|
|
lambda match: values[int(match.group(1)) - 1]
|
|
if 0 < int(match.group(1)) <= len(values) else match.group(0),
|
|
body,
|
|
)
|
|
|
|
|
|
async def _approved_outbound_template(db: Session, account: ChannelAccount,
|
|
name: str, language: str,
|
|
*, starts_conversation: bool) -> dict:
|
|
"""The template this send is allowed to use, or the reason it isn't.
|
|
|
|
`starts_conversation` decides whether the Utility/Authentication rule
|
|
applies. It is a rule about business-INITIATED contact, so it belongs only
|
|
to a send that opens a thread with someone who never wrote to us. Reaching
|
|
an existing conversation is the reopen case: the customer did message us,
|
|
which is why Meta accepts Marketing there — and why the inbox picker has
|
|
never been category-gated.
|
|
"""
|
|
repo = ChannelAccountRepository(db)
|
|
credentials = repo.get_credentials(account)
|
|
waba_id = credentials.get("waba_id")
|
|
if not waba_id:
|
|
raise OutboundError(400, "Reconnect this number with its WhatsApp "
|
|
"Business Account ID to send templates")
|
|
|
|
ok, data = await fetch_message_templates(waba_id, credentials["access_token"])
|
|
if not ok:
|
|
if graph_auth_refused(data):
|
|
repo.record_auth_failure(account, graph_detail(data, "Authentication Error"))
|
|
raise OutboundError(502, "Could not read the account's templates from Meta")
|
|
|
|
template = next(
|
|
(t for t in data if t.get("name") == name and t.get("language") == language),
|
|
None,
|
|
)
|
|
if template is None:
|
|
raise OutboundError(404, f"No template named {name} in {language} on this account")
|
|
if str(template.get("status", "")).upper() != "APPROVED":
|
|
raise OutboundError(400, f"Template {name} is not approved yet")
|
|
if starts_conversation and \
|
|
str(template.get("category", "")).upper() not in OUTBOUND_CATEGORIES:
|
|
raise OutboundError(
|
|
400,
|
|
"Only Utility and Authentication templates can start a conversation. "
|
|
"Marketing templates need the customer to have messaged you first.",
|
|
)
|
|
return template
|
|
|
|
|
|
def _resolve_customer(db: Session, account: ChannelAccount, phone: str,
|
|
customer_id: Optional[UUID], customer_name: Optional[str]):
|
|
"""The person this conversation belongs to.
|
|
|
|
A picked person must actually OWN the number being messaged — the two
|
|
arrive as independent fields, and binding a conversation to one person at
|
|
another person's number is unrecoverable: the reply resolves by phone to
|
|
somebody else, so history splits across two customers and the thread never
|
|
appears on the profile of the person actually in it.
|
|
|
|
set_phone_if_absent's skip-and-log is right for the automatic inbound
|
|
paths, where there is nobody to tell. Here there is a human waiting on a
|
|
response, so a conflict is refused instead of silently ignored.
|
|
"""
|
|
repo = CustomerRepository(db)
|
|
if customer_id is not None:
|
|
customer = repo.get_by_id(customer_id)
|
|
if customer is None or customer.organization_id != account.organization_id:
|
|
raise OutboundError(404, "Customer not found")
|
|
if customer.phone and customer.phone != phone:
|
|
raise OutboundError(
|
|
400,
|
|
f"{customer.full_name or 'That person'} has a different number on "
|
|
f"file. Send to their number, or update it on their profile first.",
|
|
)
|
|
owner = repo.get_customer_by_phone(phone, account.organization_id)
|
|
if owner is not None and owner.id != customer.id:
|
|
raise OutboundError(
|
|
400,
|
|
f"{phone} already belongs to {owner.full_name or 'another person'}. "
|
|
f"Pick them instead, or clear the number from their profile.",
|
|
)
|
|
repo.set_phone_if_absent(customer, phone)
|
|
return customer
|
|
|
|
wa_id = to_wa_id(phone)
|
|
customer = repo.get_or_create_customer(
|
|
email=f"{wa_id}@whatsapp.channel",
|
|
organization_id=account.organization_id,
|
|
full_name=customer_name or placeholder_name(account.channel_type, wa_id),
|
|
phone=phone,
|
|
)
|
|
# First-touch source for People's Source column; setdefault semantics so a
|
|
# person who arrived some other way keeps their original story.
|
|
if customer.lead_source is None:
|
|
customer.lead_source = {"channel": "whatsapp", "via": "outbound"}
|
|
return customer
|
|
|
|
|
|
async def start_outbound_conversation(
|
|
db: Session,
|
|
account: ChannelAccount,
|
|
*,
|
|
to: str,
|
|
template_name: str,
|
|
language: str = DEFAULT_TEMPLATE_LANGUAGE,
|
|
components: Optional[list] = None,
|
|
customer_id: Optional[UUID] = None,
|
|
customer_name: Optional[str] = None,
|
|
source: MessageSource,
|
|
) -> OutboundSent:
|
|
"""Start (or rejoin) a WhatsApp conversation by phone number.
|
|
|
|
Returns the session — everything downstream (the inbox thread, template
|
|
resends, the AI answering the reply) already keys off one, which is why
|
|
this hands one back instead of growing a parallel outbound code path —
|
|
and the stored template message. `source` is who sent it (an inbox user,
|
|
or an external app). The team is told about the thread only once the
|
|
customer's reply needs them (notify_new_channel_chat_once), so a bulk
|
|
send doesn't page anyone.
|
|
|
|
The conversation is created with last_inbound_at=None: no customer message
|
|
means no 24-hour window, and pretending otherwise would let the AI send
|
|
free-form text that Meta rejects.
|
|
"""
|
|
phone = normalize_phone(to)
|
|
if phone is None:
|
|
raise OutboundError(400, "Enter the number in international format, e.g. +91 12345 67890")
|
|
wa_id = to_wa_id(phone)
|
|
|
|
agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id)
|
|
if agent_id is None:
|
|
raise OutboundError(400, "Route an agent to this number first — "
|
|
"otherwise nobody would answer the reply")
|
|
|
|
# Resolved BEFORE the template is validated: whether this opens a thread or
|
|
# rejoins one decides which rules the template must satisfy.
|
|
conv_repo = ChannelConversationRepository(db)
|
|
existing = conv_repo.get_active(account.id, wa_id)
|
|
|
|
template = await _approved_outbound_template(
|
|
db, account, template_name, language, starts_conversation=existing is None)
|
|
|
|
if existing is not None:
|
|
# An open conversation already carries this number; send there instead
|
|
# of splitting the thread. The window may even be open, but a template
|
|
# send inside the window is legal and simpler than branching.
|
|
conversation, session_id = existing, existing.session_id
|
|
customer = None
|
|
else:
|
|
customer = _resolve_customer(db, account, phone, customer_id, customer_name)
|
|
session_id = uuid.uuid4()
|
|
SessionToAgentRepository(db).create_session(
|
|
session_id=session_id,
|
|
agent_id=str(agent_id),
|
|
customer_id=str(customer.id),
|
|
organization_id=str(account.organization_id),
|
|
channel=account.channel_type,
|
|
)
|
|
conversation = conv_repo.create(
|
|
channel_account_id=account.id,
|
|
channel_type=account.channel_type,
|
|
external_conversation_id=wa_id,
|
|
external_user_id=wa_id,
|
|
session_id=session_id,
|
|
organization_id=account.organization_id,
|
|
agent_id=agent_id,
|
|
customer_id=customer.id,
|
|
last_inbound_at=None,
|
|
extra=team_not_yet_notified(),
|
|
)
|
|
|
|
adapter = get_adapter("whatsapp")
|
|
result = await adapter.send_template(account, conversation, template_name,
|
|
language, components)
|
|
ChannelAccountRepository(db).track_send_result(account, result)
|
|
if not result.ok:
|
|
if existing is None:
|
|
# A failed send must not leave an empty thread in the inbox: drop
|
|
# the conversation, then the session it points at. The customer
|
|
# row stays — harmless, and reused on the next attempt.
|
|
#
|
|
# Guarded, because an exception escaping here would replace the 502
|
|
# (which names Meta's actual reason) with an opaque 500 AND leave
|
|
# the empty thread this block exists to remove. Failing to clean up
|
|
# is worth a log; losing the reason the send failed is not.
|
|
from app.models.session_to_agent import SessionToAgent
|
|
try:
|
|
db.delete(conversation)
|
|
db.query(SessionToAgent).filter(
|
|
SessionToAgent.session_id == session_id).delete()
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Could not roll back the empty outbound thread for "
|
|
f"session {session_id}: {e}")
|
|
raise OutboundError(502, result.error or "WhatsApp did not accept the message")
|
|
|
|
rendered = _render_body(template, components)
|
|
stored = ChatRepository(db).create_message({
|
|
"message": rendered,
|
|
"message_type": "bot",
|
|
"session_id": str(session_id),
|
|
"organization_id": str(account.organization_id),
|
|
"agent_id": str(agent_id),
|
|
# A reused conversation row predating this feature can lack a customer.
|
|
"customer_id": str(conversation.customer_id) if conversation.customer_id else None,
|
|
"attributes": {
|
|
"channel": account.channel_type,
|
|
"external_message_id": result.external_message_id,
|
|
"outbound_template": template_name,
|
|
"source": source.value,
|
|
},
|
|
})
|
|
if result.external_message_id:
|
|
await record_sent(db, stored.id, account.id, result.external_message_id)
|
|
# The AI reads this when the customer replies: a bare "yes" is meaningless
|
|
# without knowing what we asked.
|
|
remember_business_message(db, conversation, rendered, result.external_message_id)
|
|
await emit_channel_event(outbound_event(
|
|
organization_id=account.organization_id, channel_account_id=account.id,
|
|
session_id=str(session_id),
|
|
customer_id=str(conversation.customer_id) if conversation.customer_id else None,
|
|
record=OutboundRecord(message_id=stored.id, external_message_id=result.external_message_id,
|
|
source=source, text=rendered),
|
|
))
|
|
return OutboundSent(session_id=session_id, message_id=stored.id,
|
|
external_message_id=result.external_message_id)
|