feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent 69a71aabbb
commit e0baca15a9
225 files changed
+59337

No files matched your search

+40
View File
@@ -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"])
+113
View File
@@ -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
]
+68
View File
@@ -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"}
+149
View File
@@ -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"}
+95
View File
@@ -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"}
+690
View File
@@ -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"}
+162
View File
@@ -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"}
+108
View File
@@ -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"}
+104
View File
@@ -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)}")