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,40 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.channels.accounts import router as accounts_router
|
||||
from app.api.channels.telegram import router as telegram_router
|
||||
from app.api.channels.meta import router as meta_router
|
||||
from app.api.channels.whatsapp_messaging import router as whatsapp_messaging_router
|
||||
from app.api.channels.slack import router as slack_router
|
||||
from app.api.channels.email import router as email_router
|
||||
from app.api.channels.sms import router as sms_router
|
||||
from app.api.channels.line import router as line_router
|
||||
from app.api.channels.agent_config import router as agent_config_router
|
||||
|
||||
# Aggregates channel onboarding/management routes under /channels
|
||||
router = APIRouter()
|
||||
router.include_router(accounts_router, tags=["channels"])
|
||||
router.include_router(telegram_router, prefix="/telegram", tags=["channels"])
|
||||
router.include_router(meta_router, prefix="/meta", tags=["channels"])
|
||||
# Same prefix: onboarding and messaging are separate modules, one URL space.
|
||||
router.include_router(whatsapp_messaging_router, prefix="/meta", tags=["channels"])
|
||||
router.include_router(slack_router, prefix="/slack", tags=["channels"])
|
||||
router.include_router(email_router, prefix="/email", tags=["channels"])
|
||||
router.include_router(sms_router, prefix="/sms", tags=["channels"])
|
||||
router.include_router(line_router, prefix="/line", tags=["channels"])
|
||||
router.include_router(agent_config_router, prefix="/agent-config", tags=["channels"])
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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, to_account_out
|
||||
from app.core.auth import get_current_organization, require_permissions
|
||||
from app.database import get_db
|
||||
from app.models.organization import Organization
|
||||
from app.models.schemas.channel import AgentChannelConfigRequest, ChannelAccountOut
|
||||
from app.models.user import User
|
||||
from app.repositories.agent import AgentRepository
|
||||
from app.repositories.channels import AgentChannelConfigRepository
|
||||
from app.services import telegram_webhooks
|
||||
from app.core.logger import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("/{account_id}", response_model=ChannelAccountOut)
|
||||
async def set_account_agent(
|
||||
account_id: UUID,
|
||||
request: AgentChannelConfigRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Route a connected channel account to an AI agent."""
|
||||
account = get_org_account_or_404(db, account_id, organization)
|
||||
|
||||
agent = AgentRepository(db).get_agent(request.agent_id)
|
||||
if agent is None or agent.organization_id != organization.id:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
AgentChannelConfigRepository(db).set_agent(account.id, request.agent_id, request.is_active)
|
||||
# A bot connected before buttons existed doesn't receive taps yet
|
||||
await telegram_webhooks.reregister_if_buttons_on(account, agent)
|
||||
return to_account_out(db, account)
|
||||
|
||||
|
||||
@router.delete("/{account_id}")
|
||||
async def clear_account_agent(
|
||||
account_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Stop routing a channel account to any agent (messages will be ignored)."""
|
||||
account = get_org_account_or_404(db, account_id, organization)
|
||||
AgentChannelConfigRepository(db).delete_by_account(account.id)
|
||||
return {"status": "cleared"}
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
from email.utils import parseaddr
|
||||
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, to_account_out
|
||||
from app.channels.email import inbox_smtp_config, validate_smtp
|
||||
from app.core.auth import get_current_organization, require_permissions
|
||||
from app.core.config import settings
|
||||
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, EmailConnectRequest
|
||||
from app.models.user import User
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.core.logger import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _webhook_url(account) -> str:
|
||||
return (f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}"
|
||||
f"/webhooks/email/{account.id}?token={account.webhook_secret}")
|
||||
|
||||
|
||||
async def _build_smtp_credentials(request: EmailConnectRequest, address: str) -> dict:
|
||||
"""Assemble (and validate) per-inbox SMTP credentials, or {} to use the
|
||||
platform SMTP settings. Raises HTTPException on invalid/unreachable SMTP."""
|
||||
if not request.smtp_host:
|
||||
return {}
|
||||
if not (request.smtp_username and request.smtp_password):
|
||||
raise HTTPException(status_code=400,
|
||||
detail="SMTP username and password are required when an SMTP host is set")
|
||||
creds = {
|
||||
"smtp_host": request.smtp_host.strip(),
|
||||
"smtp_port": request.smtp_port or 587,
|
||||
"smtp_username": request.smtp_username,
|
||||
"smtp_password": request.smtp_password,
|
||||
"from_email": (request.from_email or address).lower(),
|
||||
"smtp_use_ssl": request.smtp_use_ssl,
|
||||
}
|
||||
cfg = inbox_smtp_config(creds, address)
|
||||
try:
|
||||
await asyncio.to_thread(validate_smtp, cfg)
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
raise HTTPException(status_code=400, detail=_auth_error_detail(cfg["host"], e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Could not connect to SMTP server: {e}")
|
||||
return creds
|
||||
|
||||
|
||||
def _auth_error_detail(host: str, exc: Exception) -> str:
|
||||
"""Friendlier message for an SMTP login rejection — Gmail in particular
|
||||
needs an App Password, not the account password."""
|
||||
host = (host or "").lower()
|
||||
if "gmail" in host or "google" in host:
|
||||
return ("Gmail rejected the login. Gmail requires a 16-character App Password "
|
||||
"(not your normal account password), and 2-Step Verification must be "
|
||||
"enabled — create one at myaccount.google.com/apppasswords, then paste "
|
||||
"it here with the spaces removed.")
|
||||
return f"SMTP authentication failed — check the username and password ({exc})"
|
||||
|
||||
|
||||
@router.post("", response_model=ChannelAccountOut)
|
||||
async def connect_email(
|
||||
request: EmailConnectRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Connect a support inbox. The response's webhook_url must be configured
|
||||
as the inbound-parse/forwarding target on the email provider. Outbound
|
||||
replies use the inbox's own SMTP when provided, else the platform SMTP."""
|
||||
_, address = parseaddr(request.inbound_address)
|
||||
address = (address or "").lower()
|
||||
if "@" not in address:
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address")
|
||||
|
||||
credentials = await _build_smtp_credentials(request, address)
|
||||
|
||||
repo = ChannelAccountRepository(db)
|
||||
existing = repo.get_by_external_id(ChannelType.EMAIL.value, address)
|
||||
if existing is not None:
|
||||
if existing.organization_id != organization.id:
|
||||
raise HTTPException(status_code=409, detail="This address is already connected to another organization")
|
||||
# Only overwrite SMTP creds when new ones were supplied — reconnecting
|
||||
# to update the agent/webhook must not silently wipe stored SMTP.
|
||||
if credentials:
|
||||
repo.update_credentials(existing, credentials)
|
||||
account = repo.set_active(existing, True)
|
||||
else:
|
||||
account = repo.create_account(
|
||||
organization_id=organization.id,
|
||||
channel_type=ChannelType.EMAIL.value,
|
||||
external_account_id=address,
|
||||
credentials=credentials, # {} => platform SMTP fallback
|
||||
display_name=request.display_name or address,
|
||||
)
|
||||
out = to_account_out(db, account)
|
||||
out.webhook_url = _webhook_url(account)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{account_id}/webhook-url")
|
||||
async def get_email_webhook_url(
|
||||
account_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Re-show the inbound webhook URL for an existing email account."""
|
||||
account = get_org_account_or_404(db, account_id, organization)
|
||||
if account.channel_type != ChannelType.EMAIL.value:
|
||||
raise HTTPException(status_code=404, detail="Channel account not found")
|
||||
return {"webhook_url": _webhook_url(account)}
|
||||
|
||||
|
||||
@router.delete("/{account_id}")
|
||||
async def disconnect_email(
|
||||
account_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = get_org_account_or_404(db, account_id, organization)
|
||||
if account.channel_type != ChannelType.EMAIL.value:
|
||||
raise HTTPException(status_code=404, detail="Channel account not found")
|
||||
ChannelAccountRepository(db).delete(account)
|
||||
return {"status": "disconnected"}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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, to_account_out
|
||||
from app.channels import line as line_api
|
||||
from app.core.auth import get_current_organization, require_permissions
|
||||
from app.core.config import settings
|
||||
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, LineConnectRequest
|
||||
from app.models.user import User
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.core.logger import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _webhook_url(account_id) -> str:
|
||||
return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/line/{account_id}"
|
||||
|
||||
|
||||
@router.post("", response_model=ChannelAccountOut)
|
||||
async def connect_line(
|
||||
request: LineConnectRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Connect a LINE Official Account (channel secret + access token from the
|
||||
LINE Developers console). The webhook endpoint is set automatically."""
|
||||
bot = await line_api.get_bot_info(request.channel_access_token.strip())
|
||||
if bot is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid LINE channel access token")
|
||||
|
||||
bot_user_id = str(bot.get("userId", ""))
|
||||
credentials = {
|
||||
"channel_access_token": request.channel_access_token.strip(),
|
||||
"channel_secret": request.channel_secret.strip(),
|
||||
}
|
||||
repo = ChannelAccountRepository(db)
|
||||
existing = repo.get_by_external_id(ChannelType.LINE.value, bot_user_id)
|
||||
if existing is not None:
|
||||
if existing.organization_id != organization.id:
|
||||
raise HTTPException(status_code=409, detail="This LINE account is already connected to another organization")
|
||||
repo.update_credentials(existing, credentials)
|
||||
account = repo.set_active(existing, True)
|
||||
else:
|
||||
account = repo.create_account(
|
||||
organization_id=organization.id,
|
||||
channel_type=ChannelType.LINE.value,
|
||||
external_account_id=bot_user_id,
|
||||
credentials=credentials,
|
||||
display_name=bot.get("displayName") or bot.get("basicId") or bot_user_id,
|
||||
)
|
||||
|
||||
if not await line_api.set_webhook_endpoint(credentials["channel_access_token"], _webhook_url(account.id)):
|
||||
logger.warning(f"LINE webhook auto-set failed for {account.id}; set it manually in the console")
|
||||
|
||||
out = to_account_out(db, account)
|
||||
out.webhook_url = _webhook_url(account.id)
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/{account_id}")
|
||||
async def disconnect_line(
|
||||
account_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = get_org_account_or_404(db, account_id, organization)
|
||||
if account.channel_type != ChannelType.LINE.value:
|
||||
raise HTTPException(status_code=404, detail="Channel account not found")
|
||||
ChannelAccountRepository(db).delete(account)
|
||||
return {"status": "disconnected"}
|
||||
@@ -0,0 +1,690 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
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, to_account_out
|
||||
from app.channels.meta_base import (
|
||||
debug_token,
|
||||
exchange_for_long_lived_token,
|
||||
exchange_instagram_code,
|
||||
exchange_instagram_long_lived,
|
||||
exchange_signup_code,
|
||||
graph_get,
|
||||
graph_detail,
|
||||
graph_list_all,
|
||||
instagram_token_payload,
|
||||
register_phone_number,
|
||||
subscribe_app,
|
||||
subscribe_instagram_app,
|
||||
GRAPH_INSTAGRAM_BASE,
|
||||
)
|
||||
from app.services.meta_own_app import (
|
||||
APP_SECRET_KEY,
|
||||
AppCheckUnavailable,
|
||||
AppSecretMismatch,
|
||||
OwnApp,
|
||||
leave_own_app,
|
||||
own_app_token,
|
||||
resolve_own_app,
|
||||
sync_own_app,
|
||||
)
|
||||
from app.core.security import decrypt_api_key, encrypt_api_key
|
||||
from app.core.auth import (
|
||||
get_current_organization,
|
||||
require_permissions,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.error_handlers import ClientSafeHTTPException
|
||||
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,
|
||||
EmbeddedSignupConfigOut,
|
||||
EmbeddedSignupRequest,
|
||||
WhatsAppConnectRequest,
|
||||
MessengerConnectRequest,
|
||||
MessengerSignupConnectRequest,
|
||||
MessengerSignupPagesOut,
|
||||
MessengerSignupRequest,
|
||||
MessengerPageOut,
|
||||
InstagramConnectRequest,
|
||||
InstagramLoginRequest,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.core.logger import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def _whatsapp_display_name(profile: dict, phone_number_id: str) -> str:
|
||||
"""How a connected number is labelled in the UI, from its Graph profile."""
|
||||
return (f"{profile.get('verified_name', 'WhatsApp')} "
|
||||
f"({profile.get('display_phone_number', phone_number_id)})")
|
||||
|
||||
|
||||
async def _page_name(page_id: str, access_token: str) -> str:
|
||||
"""The Page's name for the UI label, falling back to its id.
|
||||
|
||||
Reading it needs pages_read_engagement, which messaging does not require —
|
||||
so a token without it still connects and simply shows the id.
|
||||
"""
|
||||
ok, data = await graph_get(page_id, access_token, params={"fields": "name"})
|
||||
if not ok or not data.get("name"):
|
||||
logger.info(f"No name for page {page_id} (pages_read_engagement not granted); "
|
||||
f"labelling with the id")
|
||||
return page_id
|
||||
return data["name"]
|
||||
|
||||
|
||||
async def _verify_signup_assets(waba_id: str, phone_number_id: str, access_token: str) -> dict:
|
||||
"""Prove the exchanged token really owns the number being claimed, and
|
||||
return that number's profile.
|
||||
|
||||
The code only proves the caller completed *a* signup — waba_id and
|
||||
phone_number_id arrive in the request body and are entirely caller
|
||||
controlled. Without this, an org could pair a genuine code from a throwaway
|
||||
WABA with someone else's phone number id: webhook routing resolves accounts
|
||||
by phone number id alone, with no org scoping, so their inbound messages
|
||||
would land in the attacker's inbox. Listing the WABA's numbers with the
|
||||
token checks both ids at once.
|
||||
"""
|
||||
ok, data = await graph_get(f"{waba_id}/phone_numbers", access_token,
|
||||
params={"fields": "id,display_phone_number,verified_name"})
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=graph_detail(data, "Could not read that WhatsApp Business Account"))
|
||||
|
||||
numbers = data.get("data")
|
||||
if not isinstance(numbers, list):
|
||||
raise HTTPException(status_code=400, detail="Unexpected response from Meta")
|
||||
|
||||
for number in numbers:
|
||||
if str(number.get("id")) == phone_number_id:
|
||||
return number
|
||||
raise HTTPException(status_code=400,
|
||||
detail="That number is not on this WhatsApp Business Account")
|
||||
|
||||
|
||||
def _generate_verification_pin() -> str:
|
||||
"""A fresh two-step-verification PIN for a number we register.
|
||||
|
||||
The customer never types it — it exists only so the number can be
|
||||
registered — so it is random rather than chosen, and kept for re-registers.
|
||||
"""
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
# Channels that can be onboarded through a Meta login popup, each with the Meta
|
||||
# configuration that drives it. WhatsApp and Messenger run Facebook logins;
|
||||
# Instagram runs Instagram Login, which needs no Facebook Page at all.
|
||||
SIGNUP_CHANNELS = (
|
||||
ChannelType.WHATSAPP.value,
|
||||
ChannelType.MESSENGER.value,
|
||||
ChannelType.INSTAGRAM.value,
|
||||
)
|
||||
|
||||
# Webhook fields a Page must subscribe our app to for Messenger to work.
|
||||
MESSENGER_SUBSCRIBED_FIELDS = "messages,messaging_postbacks"
|
||||
|
||||
|
||||
def _signup_config_id(channel: str) -> str:
|
||||
"""The Facebook Login-for-Business configuration that onboards this channel,
|
||||
read at call time so a settings override (tests, reloads) is always seen.
|
||||
|
||||
Instagram Login has no configuration — it is gated on its own app id.
|
||||
"""
|
||||
return {
|
||||
ChannelType.WHATSAPP.value: settings.META_CONFIG_ID,
|
||||
ChannelType.MESSENGER.value: settings.META_MESSENGER_CONFIG_ID,
|
||||
}.get(channel, "")
|
||||
|
||||
|
||||
def _signup_app_id(channel: str) -> str:
|
||||
"""The app the login popup authenticates against. Instagram Login uses the
|
||||
Instagram app; the Facebook flows use the Meta app."""
|
||||
if channel == ChannelType.INSTAGRAM.value:
|
||||
return settings.INSTAGRAM_APP_ID
|
||||
return settings.META_APP_ID
|
||||
|
||||
|
||||
def _signup_available(channel: str) -> bool:
|
||||
"""Whether this deployment can offer the login flow for this channel.
|
||||
|
||||
Onboarding happens under *our* app, so without its credentials it
|
||||
structurally cannot work — a self-hoster has none and gets the manual
|
||||
credentials form instead. That is the whole gate: like every other
|
||||
integration, signup is not restricted by plan.
|
||||
"""
|
||||
if channel == ChannelType.INSTAGRAM.value:
|
||||
return bool(settings.INSTAGRAM_APP_ID and settings.INSTAGRAM_APP_SECRET)
|
||||
return bool(_signup_config_id(channel))
|
||||
|
||||
|
||||
def _signup_allowed_for(user: User) -> bool:
|
||||
"""Whether this account may use one-click signup.
|
||||
|
||||
An empty allowlist means everyone, which is the end state. While the Meta
|
||||
app is in App Review the login only succeeds for people with a role on that
|
||||
app, so the allowlist keeps the button off everyone it would only fail for.
|
||||
Read at call time so the list can be changed without a code deploy.
|
||||
"""
|
||||
allowed = {email.strip().lower()
|
||||
for email in settings.SIGNUP_ALLOWED_EMAILS.split(",")
|
||||
if email.strip()}
|
||||
return not allowed or (user.email or "").strip().lower() in allowed
|
||||
|
||||
|
||||
def check_signup_access(channel: str, user: User) -> None:
|
||||
if not _signup_available(channel):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Signup is not configured on this deployment for this channel. "
|
||||
"Connect with credentials instead.",
|
||||
)
|
||||
# Separate from the deployment gate so the reason is honest: the flow exists
|
||||
# here, this account just isn't one of the ones it is open to yet.
|
||||
if not _signup_allowed_for(user):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="One-click connect is limited to selected accounts on this "
|
||||
"deployment. Connect with credentials instead.",
|
||||
)
|
||||
|
||||
|
||||
# The signup code is single-use and lives ~10 minutes; the sealed page list it
|
||||
# produces should not outlive it, so a stale one fails cleanly with a retry.
|
||||
SIGNUP_TOKEN_TTL_SECONDS = 600
|
||||
# 500 Pages; an agency ceiling, not a limit a real customer reaches.
|
||||
PAGE_LIST_MAX_PAGES = 5
|
||||
|
||||
|
||||
def _seal_signup_pages(organization_id, pages: list[dict]) -> str:
|
||||
"""Carry a signup's Page tokens through the picker without the browser ever
|
||||
holding them in the clear.
|
||||
|
||||
The tokens go back to the client as ciphertext only we can open, so step 2
|
||||
needs no server-side session — which matters because the two requests are
|
||||
not guaranteed to hit the same worker. Binding the org and an expiry inside
|
||||
means a leaked blob cannot be replayed by another tenant, or after the
|
||||
signup's own code would already have died.
|
||||
"""
|
||||
payload = {
|
||||
"org": str(organization_id),
|
||||
"exp": int(time.time()) + SIGNUP_TOKEN_TTL_SECONDS,
|
||||
"pages": pages,
|
||||
}
|
||||
return encrypt_api_key(json.dumps(payload))
|
||||
|
||||
|
||||
def _open_signup_pages(signup_token: str, organization: Organization) -> list[dict]:
|
||||
"""The sealed page list, or 400 if it is forged, expired or another org's.
|
||||
|
||||
One generic message across every reject path, so it is not an oracle for
|
||||
which check tripped.
|
||||
"""
|
||||
invalid = HTTPException(status_code=400,
|
||||
detail="That signup has expired — please connect again")
|
||||
try:
|
||||
# decrypt raises ValueError on a bad token; json.loads raises
|
||||
# JSONDecodeError, a ValueError subclass — one except covers both.
|
||||
payload = json.loads(decrypt_api_key(signup_token))
|
||||
except ValueError:
|
||||
raise invalid
|
||||
if not isinstance(payload, dict) or payload.get("org") != str(organization.id):
|
||||
raise invalid
|
||||
if float(payload.get("exp", 0)) <= time.time():
|
||||
raise invalid
|
||||
pages = payload.get("pages")
|
||||
if not isinstance(pages, list):
|
||||
raise invalid
|
||||
return pages
|
||||
|
||||
|
||||
async def _list_manageable_pages(user_token: str, fields: str) -> list[dict]:
|
||||
"""The Pages this signup granted us, each with its own Page access token."""
|
||||
ok, data = await graph_list_all(
|
||||
"me/accounts", user_token, {"fields": fields, "limit": 100},
|
||||
max_pages=PAGE_LIST_MAX_PAGES)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=graph_detail(data, "Could not list your Facebook Pages"))
|
||||
return data
|
||||
|
||||
|
||||
async def _signup_user_token(request: MessengerSignupRequest) -> str:
|
||||
"""Trade a Facebook Login-for-Business code for a long-lived user token.
|
||||
|
||||
Page tokens inherit the user token's lifetime, so it is extended first
|
||||
(best-effort; a failure only risks earlier expiry). Instagram does not come
|
||||
through here — Instagram Login is its own OAuth against its own host.
|
||||
"""
|
||||
ok, data = await exchange_signup_code(request.code, redirect_uri=request.redirect_uri)
|
||||
user_token = data.get("access_token") if ok else None
|
||||
if not user_token:
|
||||
# The code is short-lived (~10 minutes) and single-use, so a stale or
|
||||
# replayed one lands here rather than on a Graph error later.
|
||||
raise HTTPException(status_code=400, detail=graph_detail(
|
||||
data, "Could not complete signup — please try connecting again"))
|
||||
|
||||
ok, extended = await exchange_for_long_lived_token(user_token)
|
||||
if ok and extended.get("access_token"):
|
||||
return extended["access_token"]
|
||||
logger.warning("Could not extend the signup token; page tokens may expire early")
|
||||
return user_token
|
||||
|
||||
|
||||
def _upsert_account(db: Session, organization: Organization, channel_type: str,
|
||||
external_account_id: str, credentials: dict, display_name: str,
|
||||
forget: tuple = ()):
|
||||
"""Create the account or refresh credentials on reconnect (same org only).
|
||||
`forget` names stored credentials that no longer apply (an app secret,
|
||||
once the account is connected through ReplyPier's own app)."""
|
||||
repo = ChannelAccountRepository(db)
|
||||
existing = repo.get_by_external_id(channel_type, external_account_id)
|
||||
if existing is not None:
|
||||
if existing.organization_id != organization.id:
|
||||
raise HTTPException(status_code=409,
|
||||
detail="This account is already connected to another organization")
|
||||
# Merge rather than replace: a reconnect that omits an optional field
|
||||
# must not silently drop it. Someone reconnecting without re-entering
|
||||
# their WABA id would otherwise lose template management, and the
|
||||
# signup PIN cannot be read back from Meta at all.
|
||||
merged = {**repo.get_credentials(existing),
|
||||
**{k: v for k, v in credentials.items() if v is not None}}
|
||||
for key in forget:
|
||||
merged.pop(key, None)
|
||||
repo.update_credentials(existing, merged)
|
||||
return repo.set_active(existing, True)
|
||||
return repo.create_account(
|
||||
organization_id=organization.id,
|
||||
channel_type=channel_type,
|
||||
external_account_id=external_account_id,
|
||||
credentials=credentials,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
def _previous_account(db: Session, organization: Organization, channel_type: str, external_account_id: str):
|
||||
"""This organization's earlier connection of the account, if any (another
|
||||
organization's is a 409, before anything is asked of Meta)."""
|
||||
previous = ChannelAccountRepository(db).get_by_external_id(channel_type, external_account_id)
|
||||
if previous is not None and previous.organization_id != organization.id:
|
||||
raise HTTPException(status_code=409, detail="This account is already connected to another organization")
|
||||
return previous
|
||||
|
||||
|
||||
def _own_app_credentials(own: OwnApp | None) -> dict:
|
||||
return {APP_SECRET_KEY: own.secret} if own else {}
|
||||
|
||||
|
||||
async def _own_app(access_token: str, entered_secret: str | None, previous) -> OwnApp | None:
|
||||
"""resolve_own_app, with its failures as answers the form can show."""
|
||||
try:
|
||||
return await resolve_own_app(access_token, entered_secret, previous)
|
||||
except AppSecretMismatch:
|
||||
raise HTTPException(status_code=400, detail=(
|
||||
"That app secret is not the one for the Meta app this token comes from. "
|
||||
"Copy it from your app's App settings → Basic." if entered_secret else
|
||||
"The app secret saved before doesn't match this token. Enter the app secret again.")) from None
|
||||
except AppCheckUnavailable as e:
|
||||
raise ClientSafeHTTPException(
|
||||
status_code=502, detail=f"Could not check the app secret with Meta, try again ({e})") from e
|
||||
|
||||
|
||||
@router.get("/embedded-signup-config", response_model=EmbeddedSignupConfigOut)
|
||||
async def get_embedded_signup_config(
|
||||
channel: str = ChannelType.WHATSAPP.value,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
):
|
||||
"""What the connect UI needs to decide between a Meta login flow and the
|
||||
manual credentials form, for the given channel. The app id is returned here
|
||||
rather than baked into the frontend build, so a self-hoster can point at
|
||||
their own app. A disabled channel returns no ids, so one channel's config
|
||||
never leaks on another's request."""
|
||||
if channel not in SIGNUP_CHANNELS:
|
||||
raise HTTPException(status_code=404, detail="No signup flow for this channel")
|
||||
# Both halves of the same gate the connect endpoints enforce, so an account
|
||||
# outside the allowlist is shown the manual form rather than a button that
|
||||
# would 403 on click.
|
||||
enabled = _signup_available(channel) and _signup_allowed_for(current_user)
|
||||
return EmbeddedSignupConfigOut(
|
||||
enabled=enabled,
|
||||
# Instagram Login has no configuration id, only an app id.
|
||||
config_id=(_signup_config_id(channel) or None) if enabled else None,
|
||||
app_id=_signup_app_id(channel) if enabled else None,
|
||||
graph_version=settings.META_GRAPH_VERSION,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/whatsapp", response_model=ChannelAccountOut)
|
||||
async def connect_whatsapp(
|
||||
request: WhatsAppConnectRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Connect a WhatsApp Cloud API number with manual credentials
|
||||
(phone_number_id + permanent token from the customer's own Meta app)."""
|
||||
ok, data = await graph_get(request.phone_number_id, request.access_token,
|
||||
params={"fields": "display_phone_number,verified_name"})
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"Could not verify WhatsApp credentials: {data.get('error', {}).get('message', 'invalid token or phone_number_id')}")
|
||||
|
||||
previous = _previous_account(db, organization, ChannelType.WHATSAPP.value, request.phone_number_id)
|
||||
own = await _own_app(request.access_token, request.app_secret, previous)
|
||||
previous_token = own_app_token(previous)
|
||||
|
||||
display = request.display_name or _whatsapp_display_name(data, request.phone_number_id)
|
||||
account = _upsert_account(
|
||||
db, organization, ChannelType.WHATSAPP.value,
|
||||
external_account_id=request.phone_number_id,
|
||||
credentials={"access_token": request.access_token, "waba_id": request.waba_id,
|
||||
**_own_app_credentials(own)},
|
||||
display_name=display,
|
||||
forget=() if own else (APP_SECRET_KEY,),
|
||||
)
|
||||
# Route WABA events to our webhook (best-effort; needs the WABA id)
|
||||
if request.waba_id:
|
||||
if not await subscribe_app(request.waba_id, request.access_token):
|
||||
logger.warning(f"WABA subscribe failed for {request.waba_id}; webhook may need manual subscription")
|
||||
await sync_own_app(db, account, own, request.access_token, previous_token)
|
||||
return to_account_out(db, account)
|
||||
|
||||
|
||||
@router.post("/whatsapp/embedded-signup", response_model=ChannelAccountOut)
|
||||
async def connect_whatsapp_embedded_signup(
|
||||
request: EmbeddedSignupRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Finish an Embedded Signup flow: trade the code for the customer's
|
||||
business token and connect the number it created.
|
||||
|
||||
Only the way the credentials are obtained differs from connect_whatsapp —
|
||||
everything after that is the same upsert and webhook subscribe.
|
||||
"""
|
||||
check_signup_access(ChannelType.WHATSAPP.value, current_user)
|
||||
|
||||
ok, data = await exchange_signup_code(request.code)
|
||||
access_token = data.get("access_token") if ok else None
|
||||
if not access_token:
|
||||
# The code is short-lived (~10 minutes) and single-use, so a stale or
|
||||
# replayed one lands here rather than on a Graph error later.
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=graph_detail(data, "Could not complete signup — please try connecting again"),
|
||||
)
|
||||
|
||||
profile = await _verify_signup_assets(
|
||||
request.waba_id, request.phone_number_id, access_token)
|
||||
|
||||
# A number from Embedded Signup is attached to the WABA but cannot send
|
||||
# until it is registered for Cloud API use.
|
||||
pin = _generate_verification_pin()
|
||||
registered, register_data = await register_phone_number(
|
||||
request.phone_number_id, access_token, pin)
|
||||
if not registered:
|
||||
logger.warning(
|
||||
f"Phone {request.phone_number_id} not registered after signup: "
|
||||
f"{graph_detail(register_data, 'unknown error')}")
|
||||
|
||||
credentials = {"access_token": access_token, "waba_id": request.waba_id}
|
||||
# Only keep the PIN we actually set on the number. Storing one from a failed
|
||||
# register would overwrite a working PIN with a value Meta never accepted —
|
||||
# and Meta cannot read it back, so the real one would be unrecoverable.
|
||||
if registered:
|
||||
credentials["verification_pin"] = pin
|
||||
|
||||
# Connected from the customer's own app before: its token is the one that
|
||||
# can hand the number's webhooks back
|
||||
previous_token = own_app_token(_previous_account(
|
||||
db, organization, ChannelType.WHATSAPP.value, request.phone_number_id))
|
||||
account = _upsert_account(
|
||||
db, organization, ChannelType.WHATSAPP.value,
|
||||
external_account_id=request.phone_number_id,
|
||||
credentials=credentials,
|
||||
display_name=request.display_name or _whatsapp_display_name(
|
||||
profile, request.phone_number_id),
|
||||
forget=(APP_SECRET_KEY,),
|
||||
)
|
||||
if not await subscribe_app(request.waba_id, access_token):
|
||||
logger.warning(f"WABA subscribe failed for {request.waba_id} after Embedded Signup")
|
||||
await sync_own_app(db, account, None, access_token, previous_token)
|
||||
return to_account_out(db, account)
|
||||
|
||||
|
||||
@router.post("/messenger", response_model=ChannelAccountOut)
|
||||
async def connect_messenger(
|
||||
request: MessengerConnectRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Connect a Facebook Page for Messenger with a page access token."""
|
||||
# Inspect the token rather than reading the Page node: `me?fields=id,name`
|
||||
# needs pages_read_engagement, which a messaging token has no reason to
|
||||
# carry — it would reject a token that sends perfectly well.
|
||||
ok, info = await debug_token(request.page_access_token)
|
||||
if not ok or not info.get("is_valid"):
|
||||
raise HTTPException(status_code=400, detail=graph_detail(
|
||||
info, "That token is not valid — generate a fresh Page access token"))
|
||||
if info.get("type") != "PAGE":
|
||||
raise HTTPException(status_code=400, detail=(
|
||||
f"That is a {str(info.get('type', 'user')).lower()} access token, not a Page "
|
||||
f"access token. In the Meta app dashboard go to Messenger → Settings and "
|
||||
f"click Generate on the row for your Page."))
|
||||
if str(info.get("profile_id")) != request.page_id:
|
||||
raise HTTPException(status_code=400, detail=(
|
||||
f"That token is for Page {info.get('profile_id')}, not the Page ID you "
|
||||
f"entered. Webhooks are routed by Page ID, so the two must match."))
|
||||
|
||||
previous = _previous_account(db, organization, ChannelType.MESSENGER.value, request.page_id)
|
||||
own = await _own_app(request.page_access_token, request.app_secret, previous)
|
||||
previous_token = own_app_token(previous)
|
||||
|
||||
display_name = request.display_name or await _page_name(
|
||||
request.page_id, request.page_access_token)
|
||||
account = _upsert_account(
|
||||
db, organization, ChannelType.MESSENGER.value,
|
||||
external_account_id=request.page_id,
|
||||
credentials={"access_token": request.page_access_token, **_own_app_credentials(own)},
|
||||
display_name=display_name,
|
||||
forget=() if own else (APP_SECRET_KEY,),
|
||||
)
|
||||
if not await subscribe_app(request.page_id, request.page_access_token,
|
||||
subscribed_fields=MESSENGER_SUBSCRIBED_FIELDS):
|
||||
logger.warning(f"Page subscribe failed for {request.page_id}; webhook may need manual subscription")
|
||||
await sync_own_app(db, account, own, request.page_access_token, previous_token)
|
||||
return to_account_out(db, account)
|
||||
|
||||
|
||||
@router.post("/messenger/signup/pages", response_model=MessengerSignupPagesOut)
|
||||
async def list_messenger_signup_pages(
|
||||
request: MessengerSignupRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
):
|
||||
"""Step 1 of Facebook Login for Business: trade the code for the customer's
|
||||
Pages so they can pick which to connect.
|
||||
|
||||
The Page tokens are sealed into signup_token and never reach the browser;
|
||||
only the ids and names come back for the picker.
|
||||
"""
|
||||
check_signup_access(ChannelType.MESSENGER.value, current_user)
|
||||
user_token = await _signup_user_token(request)
|
||||
|
||||
pages = await _list_manageable_pages(user_token, "id,name,access_token")
|
||||
if not pages:
|
||||
raise HTTPException(status_code=400, detail=(
|
||||
"No Facebook Pages were shared with ReplyPier. Run the connect "
|
||||
"again and tick the Page you want to use."))
|
||||
|
||||
return MessengerSignupPagesOut(
|
||||
signup_token=_seal_signup_pages(organization.id, pages),
|
||||
pages=[MessengerPageOut(id=p["id"], name=p.get("name") or p["id"]) for p in pages],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/messenger/signup/connect", response_model=ChannelAccountOut)
|
||||
async def connect_messenger_signup(
|
||||
request: MessengerSignupConnectRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Step 2: connect the Page the customer picked in step 1.
|
||||
|
||||
page_id is a selector into the list we fetched ourselves, never a caller
|
||||
claim: the token stored is the one /me/accounts returned for that Page, so a
|
||||
caller cannot connect a Page they do not administer. This is the Messenger
|
||||
equivalent of _verify_signup_assets, and matters for the same reason —
|
||||
webhooks resolve accounts by external id with no org scoping.
|
||||
"""
|
||||
check_signup_access(ChannelType.MESSENGER.value, current_user)
|
||||
pages = _open_signup_pages(request.signup_token, organization)
|
||||
|
||||
page = next((p for p in pages if p.get("id") == request.page_id), None)
|
||||
if page is None or not page.get("access_token"):
|
||||
raise HTTPException(status_code=400, detail="That Page was not part of this signup")
|
||||
|
||||
previous_token = own_app_token(_previous_account(db, organization, ChannelType.MESSENGER.value, page["id"]))
|
||||
account = _upsert_account(
|
||||
db, organization, ChannelType.MESSENGER.value,
|
||||
external_account_id=page["id"],
|
||||
credentials={"access_token": page["access_token"]},
|
||||
display_name=request.display_name or page.get("name") or page["id"],
|
||||
forget=(APP_SECRET_KEY,),
|
||||
)
|
||||
if not await subscribe_app(page["id"], page["access_token"],
|
||||
subscribed_fields=MESSENGER_SUBSCRIBED_FIELDS):
|
||||
logger.warning(f"Page subscribe failed for {page['id']} after signup")
|
||||
await sync_own_app(db, account, None, page["access_token"], previous_token)
|
||||
return to_account_out(db, account)
|
||||
|
||||
|
||||
@router.post("/instagram/login/connect", response_model=ChannelAccountOut)
|
||||
async def connect_instagram_login(
|
||||
request: InstagramLoginRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Connect an Instagram professional account through Instagram Login.
|
||||
|
||||
Unlike Messenger this needs no Facebook Page: the business signs in with
|
||||
Instagram itself, so the login returns exactly one account and there is
|
||||
nothing to pick. The token is an Instagram user token, used against
|
||||
graph.instagram.com rather than the Facebook graph.
|
||||
"""
|
||||
check_signup_access(ChannelType.INSTAGRAM.value, current_user)
|
||||
|
||||
ok, data = await exchange_instagram_code(request.code, request.redirect_uri)
|
||||
token_data = instagram_token_payload(data) if ok else {}
|
||||
access_token = token_data.get("access_token")
|
||||
user_id = token_data.get("user_id")
|
||||
if not access_token or not user_id:
|
||||
# The code is single-use and short-lived, so a stale or replayed one
|
||||
# lands here rather than on a Graph error later.
|
||||
raise HTTPException(status_code=400, detail=graph_detail(
|
||||
data, "Could not complete Instagram login — please try connecting again"))
|
||||
|
||||
# The login token lasts about an hour; without extending it the connection
|
||||
# dies silently mid-conversation. Best-effort, like the Page flow.
|
||||
ok, extended = await exchange_instagram_long_lived(access_token)
|
||||
if ok and extended.get("access_token"):
|
||||
access_token = extended["access_token"]
|
||||
else:
|
||||
logger.warning("Could not extend the Instagram token; it may expire within the hour")
|
||||
|
||||
ok, profile = await graph_get("me", access_token,
|
||||
params={"fields": "user_id,username"},
|
||||
base=GRAPH_INSTAGRAM_BASE)
|
||||
username = profile.get("username") if ok else None
|
||||
# Webhooks route Instagram events by the professional account id, which /me
|
||||
# reports as user_id. The login response also carries a user_id but it can
|
||||
# be app-scoped, and keying on the wrong one means inbound DMs never match
|
||||
# any account — silently, with no error. Prefer the authoritative one.
|
||||
account_id = str((profile.get("user_id") if ok else None) or user_id)
|
||||
|
||||
account = _upsert_account(
|
||||
db, organization, ChannelType.INSTAGRAM.value,
|
||||
external_account_id=account_id,
|
||||
credentials={"access_token": access_token},
|
||||
display_name=request.display_name or ("@" + (username or account_id)),
|
||||
)
|
||||
if not await subscribe_instagram_app(access_token):
|
||||
logger.warning(f"Instagram webhook subscribe failed for {account_id}; DMs may not arrive")
|
||||
return to_account_out(db, account)
|
||||
|
||||
|
||||
@router.post("/instagram", response_model=ChannelAccountOut)
|
||||
async def connect_instagram(
|
||||
request: InstagramConnectRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Connect an Instagram professional account (via its linked page token)."""
|
||||
ok, data = await graph_get(request.ig_id, request.page_access_token,
|
||||
params={"fields": "id,username"})
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"Could not verify Instagram credentials: {data.get('error', {}).get('message', 'invalid token or account id')}")
|
||||
# Webhook routing matches entry.id against this value, so reject anything
|
||||
# that isn't the actual IG business-account id (e.g. a pasted Page id —
|
||||
# the page token can read that node too, so ok=True alone isn't enough).
|
||||
if str(data.get("id")) != request.ig_id:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="The ID entered is not an Instagram professional account ID")
|
||||
|
||||
account = _upsert_account(
|
||||
db, organization, ChannelType.INSTAGRAM.value,
|
||||
external_account_id=request.ig_id,
|
||||
credentials={"access_token": request.page_access_token},
|
||||
display_name=request.display_name or f"@{data.get('username', request.ig_id)}",
|
||||
)
|
||||
return to_account_out(db, account)
|
||||
|
||||
|
||||
@router.delete("/{account_id}")
|
||||
async def disconnect_meta_account(
|
||||
account_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Disconnect a WhatsApp/Messenger/Instagram account."""
|
||||
account = get_org_account_or_404(db, account_id, organization)
|
||||
if account.channel_type not in (ChannelType.WHATSAPP.value,
|
||||
ChannelType.MESSENGER.value,
|
||||
ChannelType.INSTAGRAM.value):
|
||||
raise HTTPException(status_code=404, detail="Channel account not found")
|
||||
await leave_own_app(account)
|
||||
ChannelAccountRepository(db).delete(account)
|
||||
return {"status": "disconnected"}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.channels.accounts import get_org_account_or_404
|
||||
from app.channels.slack import SLACK_API_BASE, slack_api
|
||||
from app.core.auth import get_current_organization, require_permissions
|
||||
from app.core.config import settings
|
||||
from app.core.oauth_state import pop_state, store_state
|
||||
from app.database import get_db
|
||||
from app.models.channels import ChannelAccount, ChannelType
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.core.logger import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Bot scopes for the customer-chat use case. Keep this minimal — the
|
||||
# Marketplace review asks us to justify each one, and once listed the app can
|
||||
# only use approved scopes:
|
||||
# app_mentions:read receive app_mention, so the agent answers when @mentioned
|
||||
# chat:write post the reply and the "typing…" placeholder
|
||||
# im:history receive message.im, so the agent answers DMs
|
||||
# users:read users.info, so a customer shows as a name not a raw U0… id
|
||||
OAUTH_SCOPES = "app_mentions:read,chat:write,im:history,users:read"
|
||||
|
||||
def _redirect_uri() -> str:
|
||||
return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/channels/slack/callback"
|
||||
|
||||
|
||||
def _settings_redirect(status: str, reason: str = None) -> RedirectResponse:
|
||||
params = {"status": status, "integration": "slack"}
|
||||
if reason:
|
||||
params["reason"] = reason
|
||||
return RedirectResponse(f"{settings.FRONTEND_URL}/settings/integrations?{urlencode(params)}")
|
||||
|
||||
|
||||
@router.get("/install")
|
||||
async def install_slack(
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
):
|
||||
"""Start the Slack OAuth install: redirect the browser to Slack's consent
|
||||
page with a CSRF state bound to the organization."""
|
||||
if not settings.SLACK_CLIENT_ID or not settings.SLACK_CLIENT_SECRET:
|
||||
raise HTTPException(status_code=400, detail="Slack app credentials are not configured")
|
||||
state = secrets.token_urlsafe(24)
|
||||
store_state("slack", state, str(organization.id))
|
||||
params = urlencode({
|
||||
"client_id": settings.SLACK_CLIENT_ID,
|
||||
"scope": OAUTH_SCOPES,
|
||||
"redirect_uri": _redirect_uri(),
|
||||
"state": state,
|
||||
})
|
||||
return RedirectResponse(f"https://slack.com/oauth/v2/authorize?{params}")
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def slack_oauth_callback(
|
||||
code: str = None,
|
||||
state: str = None,
|
||||
error: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Complete the OAuth install: exchange the code, store the workspace as a
|
||||
channel account, and bounce back to the settings page."""
|
||||
if error:
|
||||
return _settings_redirect("failure", "cancelled" if error == "access_denied" else error)
|
||||
org_id = pop_state("slack", state or "")
|
||||
if org_id is None or not code:
|
||||
return _settings_redirect("failure", "invalid_state")
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(f"{SLACK_API_BASE}/oauth.v2.access", data={
|
||||
"client_id": settings.SLACK_CLIENT_ID,
|
||||
"client_secret": settings.SLACK_CLIENT_SECRET,
|
||||
"code": code,
|
||||
"redirect_uri": _redirect_uri(),
|
||||
})
|
||||
data = response.json()
|
||||
if not data.get("ok"):
|
||||
logger.error(f"Slack OAuth exchange failed: {data.get('error')}")
|
||||
return _settings_redirect("failure", str(data.get("error", "oauth_failed")))
|
||||
|
||||
team = data.get("team") or {}
|
||||
team_id = team.get("id", "")
|
||||
repo = ChannelAccountRepository(db)
|
||||
existing = repo.get_by_external_id(ChannelType.SLACK.value, team_id)
|
||||
credentials = {
|
||||
"access_token": data.get("access_token"),
|
||||
"bot_user_id": data.get("bot_user_id"),
|
||||
}
|
||||
if existing is not None:
|
||||
if str(existing.organization_id) != org_id:
|
||||
return _settings_redirect("failure", "workspace_connected_elsewhere")
|
||||
repo.update_credentials(existing, credentials)
|
||||
repo.set_active(existing, True)
|
||||
else:
|
||||
repo.create_account(
|
||||
organization_id=UUID(org_id),
|
||||
channel_type=ChannelType.SLACK.value,
|
||||
external_account_id=team_id,
|
||||
credentials=credentials,
|
||||
display_name=team.get("name", team_id),
|
||||
)
|
||||
logger.info(f"Connected Slack workspace {team.get('name')} for org {org_id}")
|
||||
return _settings_redirect("success")
|
||||
|
||||
|
||||
async def _revoke_token(repo: ChannelAccountRepository, account: ChannelAccount) -> None:
|
||||
"""Best-effort: tell Slack to invalidate the bot token before we drop it.
|
||||
|
||||
Never blocks the disconnect — an already-revoked token, an undecryptable
|
||||
blob, or an unreachable Slack must still leave the workspace disconnected
|
||||
on our side.
|
||||
"""
|
||||
try:
|
||||
token = repo.get_credentials(account).get("access_token")
|
||||
if not token:
|
||||
return
|
||||
await slack_api("auth.revoke", token, {}, form=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Slack auth.revoke failed, disconnecting anyway: {e}")
|
||||
|
||||
|
||||
@router.delete("/{account_id}")
|
||||
async def disconnect_slack(
|
||||
account_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = get_org_account_or_404(db, account_id, organization)
|
||||
if account.channel_type != ChannelType.SLACK.value:
|
||||
raise HTTPException(status_code=404, detail="Channel account not found")
|
||||
repo = ChannelAccountRepository(db)
|
||||
await _revoke_token(repo, account)
|
||||
repo.delete(account)
|
||||
return {"status": "disconnected"}
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
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, to_account_out
|
||||
from app.channels.sms import get_provider, list_providers
|
||||
from app.core.auth import get_current_organization, require_permissions
|
||||
from app.core.config import settings
|
||||
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, SmsConnectRequest, SmsProviderInfo
|
||||
from app.models.user import User
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.core.logger import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("/providers", response_model=List[SmsProviderInfo])
|
||||
async def list_sms_providers(
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
):
|
||||
"""Available SMS providers and the credential fields each needs — drives
|
||||
the connect form's provider dropdown."""
|
||||
return [
|
||||
SmsProviderInfo(
|
||||
name=p.name, label=p.label,
|
||||
fields=[{"key": f.key, "label": f.label, "secret": f.secret, "optional": f.optional}
|
||||
for f in p.credential_fields],
|
||||
)
|
||||
for p in list_providers()
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=ChannelAccountOut)
|
||||
async def connect_sms(
|
||||
request: SmsConnectRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Connect an SMS number through one of the supported providers. Validates
|
||||
the credentials with the provider before saving."""
|
||||
provider = get_provider(request.provider)
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown SMS provider '{request.provider}'")
|
||||
|
||||
phone = request.phone_number.strip()
|
||||
try:
|
||||
await provider.validate_credentials(phone, request.credentials)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
repo = ChannelAccountRepository(db)
|
||||
existing = repo.get_by_external_id(ChannelType.SMS.value, phone)
|
||||
if existing is not None:
|
||||
if existing.organization_id != organization.id:
|
||||
raise HTTPException(status_code=409, detail="This number is already connected to another organization")
|
||||
repo.update_credentials(existing, request.credentials)
|
||||
existing.settings = {**(existing.settings or {}), "provider": request.provider}
|
||||
db.commit()
|
||||
account = repo.set_active(existing, True)
|
||||
else:
|
||||
account = repo.create_account(
|
||||
organization_id=organization.id,
|
||||
channel_type=ChannelType.SMS.value,
|
||||
external_account_id=phone,
|
||||
credentials=request.credentials,
|
||||
display_name=f"SMS {phone} ({provider.label})",
|
||||
settings={"provider": request.provider},
|
||||
)
|
||||
out = to_account_out(db, account)
|
||||
out.webhook_url = f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/sms/{request.provider}/{account.id}"
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/{account_id}")
|
||||
async def disconnect_sms(
|
||||
account_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
account = get_org_account_or_404(db, account_id, organization)
|
||||
if account.channel_type != ChannelType.SMS.value:
|
||||
raise HTTPException(status_code=404, detail="Channel account not found")
|
||||
ChannelAccountRepository(db).delete(account)
|
||||
return {"status": "disconnected"}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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, to_account_out
|
||||
from app.channels import telegram as telegram_api
|
||||
from app.core.auth import get_current_organization, require_permissions
|
||||
from app.core.error_handlers import ClientSafeHTTPException
|
||||
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, TelegramConnectRequest
|
||||
from app.models.user import User
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.services import telegram_webhooks
|
||||
from app.core.logger import get_logger
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("", response_model=ChannelAccountOut)
|
||||
async def connect_telegram(
|
||||
request: TelegramConnectRequest,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Connect a Telegram bot: validate the token with getMe, store the
|
||||
encrypted credentials, and register our webhook with a secret token."""
|
||||
bot_token = request.bot_token.strip()
|
||||
bot = await telegram_api.get_me(bot_token)
|
||||
if bot is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid bot token")
|
||||
|
||||
repo = ChannelAccountRepository(db)
|
||||
existing = repo.get_by_external_id(ChannelType.TELEGRAM.value, str(bot["id"]))
|
||||
if existing is not None:
|
||||
if existing.organization_id != organization.id:
|
||||
raise HTTPException(status_code=409, detail="This bot is already connected to another organization")
|
||||
repo.update_credentials(existing, {"bot_token": bot_token})
|
||||
account = repo.set_active(existing, True)
|
||||
created = False
|
||||
else:
|
||||
account = repo.create_account(
|
||||
organization_id=organization.id,
|
||||
channel_type=ChannelType.TELEGRAM.value,
|
||||
external_account_id=str(bot["id"]),
|
||||
credentials={"bot_token": bot_token},
|
||||
display_name=f"@{bot.get('username', bot['id'])}",
|
||||
)
|
||||
created = True
|
||||
|
||||
webhook_ok, webhook_error = await telegram_webhooks.register(account, bot_token)
|
||||
if not webhook_ok:
|
||||
# Roll back only a freshly created account; keep an existing one (and
|
||||
# its conversation history) intact on a transient webhook failure.
|
||||
if created:
|
||||
repo.delete(account)
|
||||
detail = f"Failed to register Telegram webhook: {webhook_error}"
|
||||
if "https" in webhook_error.lower():
|
||||
detail += " — set BACKEND_URL to a public HTTPS URL (e.g. your ngrok domain)"
|
||||
raise ClientSafeHTTPException(status_code=502, detail=detail)
|
||||
|
||||
logger.info(f"Connected Telegram bot @{bot.get('username')} for org {organization.id}")
|
||||
return to_account_out(db, account)
|
||||
|
||||
|
||||
@router.delete("/{account_id}")
|
||||
async def disconnect_telegram(
|
||||
account_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Disconnect a Telegram bot: remove the webhook and delete the account."""
|
||||
account = get_org_account_or_404(db, account_id, organization)
|
||||
if account.channel_type != ChannelType.TELEGRAM.value:
|
||||
raise HTTPException(status_code=404, detail="Channel account not found")
|
||||
|
||||
repo = ChannelAccountRepository(db)
|
||||
try:
|
||||
await telegram_api.delete_webhook(repo.get_credentials(account)["bot_token"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove Telegram webhook during disconnect: {e}")
|
||||
repo.delete(account)
|
||||
return {"status": "disconnected"}
|
||||
@@ -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)}")
|
||||
Reference in new issue
Block a user