feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
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)
|
||||
Reference in new issue
Block a user