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,189 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
WhatsApp numbers and Messenger Pages that live in the customer's own Meta
|
||||
app (connected with manual credentials) rather than in ReplyPier's.
|
||||
|
||||
Meta signs their webhooks with the customer's app secret, which the shared
|
||||
/webhooks/meta endpoint can't check. So each such account gets its own
|
||||
webhook address, checked with its app's secret only. That address takes
|
||||
events for the account and for the same organization's other accounts from
|
||||
the same app (one Meta app has one Messenger callback, and possibly several
|
||||
numbers), never for anyone else's: a payload signed with a secret someone
|
||||
chose themselves can't reach another organization.
|
||||
|
||||
A WhatsApp number is pointed at its address with a per-number callback
|
||||
override; Messenger has no override, so the customer pastes the address and
|
||||
verify token into their app.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels.meta_base import graph_detail, graph_get, graph_post_json
|
||||
from app.core.config import settings, webhooks_base_url
|
||||
from app.core.logger import get_logger
|
||||
from app.models.channels import ChannelAccount, ChannelType
|
||||
from app.core.security import decrypt_credentials
|
||||
from app.repositories.channels.accounts import ChannelAccountRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# In the account's encrypted credentials
|
||||
APP_SECRET_KEY = "app_secret"
|
||||
# In its (non-secret) settings: which app it's from, and whether Meta
|
||||
# accepted our per-number callback for it
|
||||
APP_ID_SETTING = "meta_app_id"
|
||||
AUTO_WEBHOOK_SETTING = "webhook_auto_configured"
|
||||
OWN_APP_CHANNELS = (ChannelType.WHATSAPP.value, ChannelType.MESSENGER.value)
|
||||
|
||||
# Meta's answer to an appsecret_proof made with another app's secret
|
||||
_PROOF_ERROR = "appsecret_proof"
|
||||
|
||||
|
||||
class AppSecretMismatch(Exception):
|
||||
"""The secret isn't the one of the app that issued the token."""
|
||||
|
||||
|
||||
class AppCheckUnavailable(Exception):
|
||||
"""Meta couldn't be asked (network, rate limit, an expired token…)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OwnApp:
|
||||
app_id: str
|
||||
secret: str
|
||||
|
||||
|
||||
def is_own_app(account: Optional[ChannelAccount]) -> bool:
|
||||
return (account is not None and account.channel_type in OWN_APP_CHANNELS
|
||||
and bool((account.settings or {}).get(APP_ID_SETTING)))
|
||||
|
||||
|
||||
def own_app_secret(account: ChannelAccount) -> Optional[str]:
|
||||
return decrypt_credentials(account.encrypted_credentials).get(APP_SECRET_KEY) if is_own_app(account) else None
|
||||
|
||||
|
||||
def own_app_token(account: Optional[ChannelAccount]) -> Optional[str]:
|
||||
"""The customer's token, read before a reconnect replaces it: only it can
|
||||
take back a callback override made with it."""
|
||||
return decrypt_credentials(account.encrypted_credentials).get("access_token") if is_own_app(account) else None
|
||||
|
||||
|
||||
def own_app_webhook_url(account: ChannelAccount) -> str:
|
||||
return f"{webhooks_base_url()}/meta/{account.id}"
|
||||
|
||||
|
||||
async def resolve_own_app(access_token: str, entered_secret: Optional[str],
|
||||
previous: Optional[ChannelAccount]) -> Optional[OwnApp]:
|
||||
"""The customer's own app behind this token, proven with its secret —
|
||||
the one entered, or else the one kept from the last connect (checked
|
||||
again: the new token may be from another app). None without a secret, or
|
||||
for ReplyPier's own app, whose webhooks the shared address already gets.
|
||||
|
||||
Raises AppSecretMismatch or AppCheckUnavailable."""
|
||||
secret = entered_secret or (own_app_secret(previous) if previous is not None else None)
|
||||
if not secret:
|
||||
return None
|
||||
proof = hmac.new(secret.encode(), access_token.encode(), hashlib.sha256).hexdigest()
|
||||
# /app (the token's own app) needs no permission on the Page or number
|
||||
ok, data = await graph_get("app", access_token, params={"fields": "id", "appsecret_proof": proof})
|
||||
if not ok:
|
||||
error = data.get("error") if isinstance(data.get("error"), dict) else {}
|
||||
if _PROOF_ERROR in str(error.get("message", "")):
|
||||
raise AppSecretMismatch()
|
||||
# Meta's own errors carry a code; anything else is ours (a timeout,
|
||||
# a socket error) and stays in the log
|
||||
logger.warning(f"Could not check an app secret with Meta: {error.get('message')}")
|
||||
raise AppCheckUnavailable(graph_detail(data, "") if "code" in error else "Meta did not answer")
|
||||
app_id = str(data.get("id") or "")
|
||||
if not app_id or app_id == settings.META_APP_ID:
|
||||
return None
|
||||
return OwnApp(app_id=app_id, secret=secret)
|
||||
|
||||
|
||||
async def sync_own_app(db: Session, account: ChannelAccount, own: Optional[OwnApp],
|
||||
access_token: str, previous_token: Optional[str]) -> None:
|
||||
"""After a connect: route the account's webhooks to its own address when
|
||||
it's from the customer's app, or hand a number that used to be back."""
|
||||
repo = ChannelAccountRepository(db)
|
||||
is_whatsapp = account.channel_type == ChannelType.WHATSAPP.value
|
||||
if own is not None:
|
||||
# Moved to another app: the old app's override is still the old app's
|
||||
if is_whatsapp and previous_token and (account.settings or {}).get(APP_ID_SETTING) != own.app_id:
|
||||
await _set_number_callback(account, previous_token, "", None)
|
||||
# Saved first: Meta checks the address during the callback call, and
|
||||
# the address only answers for an account marked as from its own app
|
||||
repo.update_settings(account, {APP_ID_SETTING: own.app_id})
|
||||
if is_whatsapp:
|
||||
pointed = await _set_number_callback(
|
||||
account, access_token, own_app_webhook_url(account), account.webhook_secret)
|
||||
repo.update_settings(account, {AUTO_WEBHOOK_SETTING: pointed})
|
||||
return
|
||||
if previous_token is not None:
|
||||
if is_whatsapp:
|
||||
await _set_number_callback(account, previous_token, "", None)
|
||||
repo.update_settings(account, {}, remove=(APP_ID_SETTING, AUTO_WEBHOOK_SETTING))
|
||||
|
||||
|
||||
async def leave_own_app(account: ChannelAccount) -> None:
|
||||
"""Before a disconnect: send the number's webhooks back to the customer's
|
||||
app. Best effort — a disconnect must never fail over it."""
|
||||
if account.channel_type != ChannelType.WHATSAPP.value:
|
||||
return
|
||||
try:
|
||||
token = own_app_token(account)
|
||||
except Exception:
|
||||
logger.exception(f"Could not read {account.id}'s credentials to release its callback")
|
||||
return
|
||||
if token:
|
||||
await _set_number_callback(account, token, "", None)
|
||||
|
||||
|
||||
def same_app_accounts(db: Session, account: ChannelAccount) -> Callable[[str], Optional[ChannelAccount]]:
|
||||
"""Resolve an external id named in a webhook to one of this organization's
|
||||
accounts from the same app (and channel), or to nothing."""
|
||||
repo = ChannelAccountRepository(db)
|
||||
app_id = account.settings[APP_ID_SETTING]
|
||||
|
||||
def resolve(external_id: str) -> Optional[ChannelAccount]:
|
||||
if external_id == account.external_account_id:
|
||||
return account
|
||||
other = repo.get_by_external_id(account.channel_type, external_id)
|
||||
if (other is not None and other.organization_id == account.organization_id
|
||||
and (other.settings or {}).get(APP_ID_SETTING) == app_id):
|
||||
return other
|
||||
return None
|
||||
return resolve
|
||||
|
||||
|
||||
async def _set_number_callback(account: ChannelAccount, access_token: str, url: str,
|
||||
verify_token: Optional[str]) -> bool:
|
||||
"""Point a WhatsApp number's webhooks at `url` ("" sends them back to the
|
||||
app's own callback). Meta checks the address during the call."""
|
||||
configuration = {"override_callback_uri": url}
|
||||
if verify_token:
|
||||
configuration["verify_token"] = verify_token
|
||||
ok, data = await graph_post_json(account.external_account_id, access_token,
|
||||
{"webhook_configuration": configuration})
|
||||
if not ok:
|
||||
action = "point" if url else "release"
|
||||
logger.warning(f"Could not {action} the webhook callback of {account.external_account_id}: "
|
||||
f"{graph_detail(data, 'no reason given')}")
|
||||
return ok
|
||||
Reference in new issue
Block a user