2026-10-17 22:05:00 +08:00
|
|
|
"""
|
2026-10-26 00:50:43 +08:00
|
|
|
Copyright 2024-2026 ReplyPier
|
2026-10-17 22:05:00 +08:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from typing import List
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from app.core.auth import (
|
|
|
|
|
INBOX_PERMISSIONS,
|
|
|
|
|
check_permissions,
|
|
|
|
|
get_current_organization,
|
|
|
|
|
require_any_permission,
|
|
|
|
|
require_permissions,
|
|
|
|
|
)
|
|
|
|
|
from app.services.meta_own_app import AUTO_WEBHOOK_SETTING, is_own_app, own_app_webhook_url
|
|
|
|
|
from app.core.config import webhooks_base_url
|
|
|
|
|
from app.database import get_db
|
|
|
|
|
from app.models.channels import ChannelType
|
|
|
|
|
from app.models.organization import Organization
|
|
|
|
|
from app.models.schemas.channel import ChannelAccountOut
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.repositories.channels import ChannelAccountRepository, AgentChannelConfigRepository
|
|
|
|
|
from app.repositories.channels.accounts import needs_reconnect
|
|
|
|
|
from app.core.logger import get_logger
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
# Sentinel: distinguishes "config not passed, look it up" from "no config"
|
|
|
|
|
_UNRESOLVED = object()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def channel_webhook_url(account) -> str | None:
|
|
|
|
|
"""The webhook URL the customer must configure on their provider, for the
|
|
|
|
|
channels where that's a manual step. Auto-managed channels return None."""
|
|
|
|
|
base = webhooks_base_url()
|
|
|
|
|
if account.channel_type == ChannelType.EMAIL.value:
|
|
|
|
|
return f"{base}/email/{account.id}?token={account.webhook_secret}"
|
|
|
|
|
if account.channel_type == ChannelType.SMS.value:
|
|
|
|
|
provider = (account.settings or {}).get("provider", "twilio")
|
|
|
|
|
return f"{base}/sms/{provider}/{account.id}?token={account.webhook_secret}"
|
|
|
|
|
if is_own_app(account):
|
|
|
|
|
return own_app_webhook_url(account)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_org_account_or_404(db: Session, account_id: UUID, organization: Organization):
|
|
|
|
|
"""Load a channel account and enforce org ownership."""
|
|
|
|
|
account = ChannelAccountRepository(db).get_by_id(account_id)
|
|
|
|
|
if account is None or account.organization_id != organization.id:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Channel account not found")
|
|
|
|
|
return account
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_account_out(
|
|
|
|
|
db: Session, account, config=_UNRESOLVED, include_webhook_url: bool = True
|
|
|
|
|
) -> ChannelAccountOut:
|
|
|
|
|
"""Serialise a channel account.
|
|
|
|
|
|
|
|
|
|
Pass include_webhook_url=False for callers who are not org admins: the URL
|
|
|
|
|
embeds `webhook_secret`, which is the ONLY authentication on the inbound
|
|
|
|
|
email and SMS webhooks (api/webhooks/email.py, api/webhooks/sms.py).
|
|
|
|
|
Handing it to an agent would let them post forged inbound messages into any
|
|
|
|
|
of the org's conversations.
|
|
|
|
|
"""
|
|
|
|
|
if config is _UNRESOLVED:
|
|
|
|
|
config = AgentChannelConfigRepository(db).get_by_account(account.id)
|
|
|
|
|
out = ChannelAccountOut.model_validate(account)
|
|
|
|
|
out.agent_id = config.agent_id if config and config.is_active else None
|
|
|
|
|
out.webhook_url = channel_webhook_url(account) if include_webhook_url else None
|
|
|
|
|
out.needs_reconnect = needs_reconnect(account)
|
|
|
|
|
# Meta asks for this token when the customer points their app at us
|
|
|
|
|
if include_webhook_url and is_own_app(account):
|
|
|
|
|
out.webhook_verify_token = account.webhook_secret
|
|
|
|
|
out.webhook_auto_configured = (account.settings or {}).get(AUTO_WEBHOOK_SETTING)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/accounts", response_model=List[ChannelAccountOut])
|
|
|
|
|
async def list_channel_accounts(
|
|
|
|
|
# Org admins (Integrations settings) OR inbox agents: the inbox reads this
|
|
|
|
|
# to know whether there is a WhatsApp number to start a conversation from,
|
|
|
|
|
# so gating it admin-only hid the feature from the people it is for.
|
|
|
|
|
current_user: User = Depends(
|
|
|
|
|
require_any_permission("manage_organization", *INBOX_PERMISSIONS)),
|
|
|
|
|
organization: Organization = Depends(get_current_organization),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
):
|
|
|
|
|
"""All connected channel accounts for the organization."""
|
|
|
|
|
accounts = ChannelAccountRepository(db).list_by_org(organization.id)
|
|
|
|
|
configs = AgentChannelConfigRepository(db).map_by_accounts([a.id for a in accounts])
|
|
|
|
|
# Only the Integrations settings screen needs the webhook URL, and only an
|
|
|
|
|
# org admin can act on it — see to_account_out.
|
|
|
|
|
include_webhook_url = check_permissions(current_user, ["manage_organization"])
|
|
|
|
|
return [
|
|
|
|
|
to_account_out(db, account, configs.get(account.id), include_webhook_url)
|
|
|
|
|
for account in accounts
|
|
|
|
|
]
|