feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
ba91abc4b2
commit
83a16ed07d
225 files changed
+59337
No files matched your search
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
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 hmac
|
||||
from typing import Callable, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.webhooks.common import is_duplicate_message
|
||||
from app.channels import get_adapter
|
||||
from app.channels.meta_base import signed_by_any, verify_meta_signature, verify_challenge
|
||||
from app.services.meta_own_app import is_own_app, own_app_secret, same_app_accounts
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.channels import ChannelAccount, ChannelType
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.services.channel_chat import process_channel_message
|
||||
from app.services.channel_status import process_status_event
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# One Meta app subscribes WhatsApp + Messenger + Instagram to this single
|
||||
# callback; the payload's top-level `object` says which product it is.
|
||||
_OBJECT_TO_CHANNEL = {
|
||||
"whatsapp_business_account": ChannelType.WHATSAPP.value,
|
||||
"page": ChannelType.MESSENGER.value,
|
||||
"instagram": ChannelType.INSTAGRAM.value,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def meta_webhook_verify(
|
||||
hub_mode: str = Query(None, alias="hub.mode"),
|
||||
hub_verify_token: str = Query(None, alias="hub.verify_token"),
|
||||
hub_challenge: str = Query(None, alias="hub.challenge"),
|
||||
):
|
||||
"""Webhook verification handshake (App Dashboard → Webhooks → Verify)."""
|
||||
challenge = verify_challenge(hub_mode, hub_verify_token, hub_challenge)
|
||||
if challenge is None:
|
||||
raise HTTPException(status_code=403, detail="Verification failed")
|
||||
# Meta expects the raw challenge echoed as text/plain
|
||||
return Response(content=challenge, media_type="text/plain")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def meta_webhook(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Receive a Meta webhook for any of WhatsApp / Messenger / Instagram.
|
||||
|
||||
Verifies the app-level signature, routes on the `object` field, resolves
|
||||
the connected account per message, acks immediately, and processes in the
|
||||
background.
|
||||
"""
|
||||
raw_body = await request.body()
|
||||
if not verify_meta_signature(raw_body, request.headers.get("x-hub-signature-256", "")):
|
||||
# Logged so a rejected delivery is distinguishable from one that never
|
||||
# arrived; the body is untrusted and unlogged.
|
||||
logger.warning(f"Meta webhook rejected: bad signature ({len(raw_body)} bytes)")
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
payload = await request.json()
|
||||
channel_type = _OBJECT_TO_CHANNEL.get(payload.get("object"))
|
||||
if channel_type is None:
|
||||
# Unknown product subscription — ack so Meta doesn't retry forever
|
||||
return {"status": "ignored"}
|
||||
|
||||
account_repo = ChannelAccountRepository(db)
|
||||
_queue_events(background_tasks, channel_type, payload,
|
||||
lambda external_id: account_repo.get_by_external_id(channel_type, external_id))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/{account_id}")
|
||||
async def own_app_webhook_verify(
|
||||
account_id: UUID,
|
||||
hub_mode: str = Query(None, alias="hub.mode"),
|
||||
hub_verify_token: str = Query(None, alias="hub.verify_token"),
|
||||
hub_challenge: str = Query(None, alias="hub.challenge"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Verification handshake for an account connected from the customer's
|
||||
own Meta app: its verify token is the account's webhook secret."""
|
||||
account = _own_app_account(db, account_id)
|
||||
if (account is None or hub_mode != "subscribe" or not hub_verify_token
|
||||
or not hmac.compare_digest(hub_verify_token.encode(), account.webhook_secret.encode())):
|
||||
raise HTTPException(status_code=403, detail="Verification failed")
|
||||
return Response(content=hub_challenge or "", media_type="text/plain")
|
||||
|
||||
|
||||
@router.post("/{account_id}")
|
||||
async def own_app_webhook(
|
||||
account_id: UUID,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""A webhook for an account connected from the customer's own Meta app.
|
||||
|
||||
Checked with that app's secret only, and only taken for this
|
||||
organization's accounts from the same app: the secret is one the customer
|
||||
chose, so it must never vouch for anyone else's.
|
||||
"""
|
||||
raw_body = await request.body()
|
||||
account = _own_app_account(db, account_id)
|
||||
secret = _readable_secret(account)
|
||||
if secret is None:
|
||||
# Disconnected, never ours, or its secret can't be read: ack, so Meta
|
||||
# doesn't disable the customer's webhook over retries — nothing is acted on
|
||||
return {"status": "ignored"}
|
||||
if not signed_by_any(raw_body, request.headers.get("x-hub-signature-256", ""), (secret,)):
|
||||
logger.warning(f"Meta webhook for account {account_id} rejected: bad signature ({len(raw_body)} bytes)")
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
payload = await request.json()
|
||||
if _OBJECT_TO_CHANNEL.get(payload.get("object")) != account.channel_type:
|
||||
return {"status": "ignored"}
|
||||
_queue_events(background_tasks, account.channel_type, payload, same_app_accounts(db, account))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _own_app_account(db: Session, account_id: UUID) -> Optional[ChannelAccount]:
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
return account if account is not None and account.is_active and is_own_app(account) else None
|
||||
|
||||
|
||||
def _readable_secret(account: Optional[ChannelAccount]) -> Optional[str]:
|
||||
if account is None:
|
||||
return None
|
||||
try:
|
||||
return own_app_secret(account)
|
||||
except Exception:
|
||||
logger.exception(f"Could not read the app secret of Meta account {account.id}")
|
||||
return None
|
||||
|
||||
|
||||
def _queue_events(background_tasks: BackgroundTasks, channel_type: str, payload: dict,
|
||||
resolve: Callable[[str], Optional[ChannelAccount]]) -> None:
|
||||
"""Hand each message and receipt in a webhook to its account's processor;
|
||||
`resolve` names the account an external id belongs to, if any."""
|
||||
adapter = get_adapter(channel_type)
|
||||
# Meta batches many events per number; resolve each account once
|
||||
accounts = {}
|
||||
|
||||
def account_for(external_id: str) -> Optional[ChannelAccount]:
|
||||
if external_id not in accounts:
|
||||
accounts[external_id] = resolve(external_id)
|
||||
return accounts[external_id]
|
||||
|
||||
for inbound in adapter.parse_inbound(payload):
|
||||
account = account_for(inbound.external_account_id)
|
||||
if account is None or not account.is_active:
|
||||
logger.info(f"No active {channel_type} account for {inbound.external_account_id}")
|
||||
continue
|
||||
if is_duplicate_message(channel_type,
|
||||
f"{account.id}:{inbound.external_message_id}"):
|
||||
logger.info(f"Skipping duplicate {channel_type} message {inbound.external_message_id}")
|
||||
continue
|
||||
background_tasks.add_task(process_channel_message, account.id, inbound)
|
||||
|
||||
for event in adapter.parse_status_events(payload):
|
||||
account = account_for(event.external_account_id)
|
||||
if account is None or not account.is_active:
|
||||
continue
|
||||
# Meta redelivers receipts; one per message and state is enough.
|
||||
if is_duplicate_message(channel_type, f"{account.id}:{event.external_message_id}:"
|
||||
f"{event.status.value}"):
|
||||
continue
|
||||
background_tasks.add_task(process_status_event, account.id, event)
|
||||
Reference in new issue
Block a user