feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent 69a71aabbb
commit e0baca15a9
225 files changed
+59337

No files matched your search

@@ -0,0 +1,217 @@
"""
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.
"""
"""Sending on an already-connected WhatsApp number: templates and outbound
conversations.
Separate from meta.py, which onboards accounts. These routes are inbox work
done by support agents on a live number, not org-admin onboarding, and they are
the only ones that care about WhatsApp Business Accounts and templates. They
mount under the same /meta prefix, so the URLs are unchanged.
"""
from urllib.parse import urlencode
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.channels.accounts import get_org_account_or_404
from app.channels import get_adapter
from app.channels.meta_base import fetch_message_templates, graph_auth_refused, graph_detail, graph_get
from app.channels.constants import MessageSource
from app.core.error_handlers import ClientSafeHTTPException
from app.core.auth import (
CHAT_MANAGE_PERMISSIONS,
INBOX_PERMISSIONS,
get_current_organization,
require_any_permission,
require_permissions,
)
from app.database import get_db
from app.models.channels import ChannelType
from app.models.organization import Organization
from app.models.schemas.channel import (
OutboundConversationOut,
OutboundConversationRequest,
TemplateLibraryOut,
TemplateOut,
TemplateSendRequest,
)
from app.models.user import User
from app.repositories.channels import ChannelAccountRepository, ChannelConversationRepository
from app.services.whatsapp_outbound import OutboundError, start_outbound_conversation
router = APIRouter()
# Meta's own template authoring UI, which is where we send people to write one.
TEMPLATE_LIBRARY_URL = "https://business.facebook.com/latest/whatsapp_manager/template_library"
# Reading the template list is inbox work — the picker needs it — so any role
# that works the inbox qualifies. INBOX_PERMISSIONS is shared with the People
# page so the two surfaces can't disagree about who that is.
require_inbox_agent = require_any_permission(*INBOX_PERMISSIONS)
# Sending is not: an outbound template is a billable Meta message to a phone
# number, with opt-in obligations attached. It takes the same grant as taking
# over or reassigning a chat, so a view-only role can browse templates without
# being able to fire one.
require_inbox_sender = require_any_permission(*CHAT_MANAGE_PERMISSIONS)
def _whatsapp_account_or_404(db: Session, account_id: UUID, organization: Organization):
account = get_org_account_or_404(db, account_id, organization)
if account.channel_type != ChannelType.WHATSAPP.value:
raise HTTPException(status_code=404, detail="Channel account not found")
return account
async def _fetch_all_templates(db: Session, account) -> list[TemplateOut]:
"""Every template on the account's WABA (transport lives in meta_base; this
wrapper owns the HTTP error surface)."""
waba_id, access_token = _waba_credentials(db, account)
ok, data = await fetch_message_templates(waba_id, access_token)
if not ok:
if graph_auth_refused(data):
ChannelAccountRepository(db).record_auth_failure(
account, graph_detail(data, "Authentication Error"))
raise ClientSafeHTTPException(status_code=502,
detail=graph_detail(data, "Could not list templates"))
return [TemplateOut(**template) for template in data]
def _waba_credentials(db: Session, account) -> tuple[str, str]:
"""(waba_id, access_token) for a WhatsApp account.
Templates live on the WhatsApp Business Account, not the phone number, and
the WABA id is optional in the credential blob — accounts connected without
it can send and receive but cannot manage templates.
"""
credentials = ChannelAccountRepository(db).get_credentials(account)
waba_id = credentials.get("waba_id")
if not waba_id:
raise HTTPException(
status_code=400,
detail="Reconnect this number with its WhatsApp Business Account ID to manage templates",
)
return waba_id, credentials["access_token"]
@router.post("/whatsapp/{account_id}/conversations", response_model=OutboundConversationOut)
async def start_whatsapp_conversation(
account_id: UUID,
request: OutboundConversationRequest,
current_user: User = Depends(require_inbox_sender),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Start a WhatsApp conversation with a phone number via an approved
Utility/Authentication template. The thin route: policy lives in the
service so the Phase-2 scheduler can call the same function."""
account = _whatsapp_account_or_404(db, account_id, organization)
try:
sent = await start_outbound_conversation(
db, account,
to=request.to,
template_name=request.template_name,
language=request.language,
components=request.components,
customer_id=request.customer_id,
customer_name=request.customer_name,
source=MessageSource.HUMAN,
)
except OutboundError as e:
# OutboundError details are written for the operator ("Recipient not on
# WhatsApp"), so they survive the 5xx sanitizer.
raise ClientSafeHTTPException(status_code=e.status_code, detail=e.detail)
return OutboundConversationOut(session_id=sent.session_id)
@router.post("/whatsapp/{account_id}/send-template")
async def send_whatsapp_template(
account_id: UUID,
request: TemplateSendRequest,
current_user: User = Depends(require_inbox_sender),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Send an approved template message to reopen an expired 24h window."""
account = _whatsapp_account_or_404(db, account_id, organization)
conversation = ChannelConversationRepository(db).get_by_session(request.session_id)
if conversation is None or conversation.channel_account_id != account.id:
raise HTTPException(status_code=404, detail="Conversation not found for this account")
adapter = get_adapter(ChannelType.WHATSAPP.value)
result = await adapter.send_template(
account, conversation,
template_name=request.template_name,
language=request.language,
components=request.components,
)
ChannelAccountRepository(db).track_send_result(account, result)
if not result.ok:
raise ClientSafeHTTPException(status_code=502, detail=result.error or "Template send failed")
return {"status": "sent", "external_message_id": result.external_message_id}
@router.get("/whatsapp/{account_id}/templates", response_model=list[TemplateOut])
async def list_whatsapp_templates(
account_id: UUID,
# Inbox-agent, not org-admin: this is what the send-template picker and the
# New-conversation modal read. Gating it tighter than the send it feeds made
# the loosening of those endpoints a no-op — the agent could send a template
# but never see one to pick.
current_user: User = Depends(require_inbox_agent),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""List the message templates on this number's WhatsApp Business Account."""
account = _whatsapp_account_or_404(db, account_id, organization)
return await _fetch_all_templates(db, account)
@router.get("/whatsapp/{account_id}/template-library", response_model=TemplateLibraryOut)
async def get_whatsapp_template_library(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Where to go to write a template: Meta's own Template Library, opened on
this number's WhatsApp Business Account.
Templates are not authored here. Meta's library holds ~150 pre-written,
pre-localised utility templates plus its authentication ones, all shaped to
pass its own review — a form of ours could only ever be a worse version of
that, and every rule it encodes (categories, variable numbering, which
buttons may be mixed) is Meta's to change without telling us.
The link is built per account rather than hardcoded: business_id scopes the
page to the right portfolio. It is best-effort — a business we cannot read
gives a link that lands less precisely, not a dead button.
"""
account = _whatsapp_account_or_404(db, account_id, organization)
waba_id, access_token = _waba_credentials(db, account)
params = {"asset_id": waba_id}
ok, data = await graph_get(waba_id, access_token, params={"fields": "owner_business_info"})
if ok:
business_id = (data.get("owner_business_info") or {}).get("id")
if business_id:
params["business_id"] = business_id
return TemplateLibraryOut(url=f"{TEMPLATE_LIBRARY_URL}?{urlencode(params)}")