190 lines
8.4 KiB
Python
190 lines
8.4 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.
|
|
|
|
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
|