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)}")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Admin API for the FAQ manager + help center, split by concern
|
||||
(settings/branding, FAQ content, generation jobs; the custom-domain router
|
||||
joins in the domain-verification unit). Mounted at /api/v1/help-center.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.help_center.branding import router as branding_router
|
||||
from app.api.help_center.domain import router as domain_router
|
||||
from app.api.help_center.faqs import router as faqs_router
|
||||
from app.api.help_center.generation import router as generation_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(branding_router)
|
||||
router.include_router(faqs_router)
|
||||
router.include_router(generation_router)
|
||||
router.include_router(domain_router)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Help-center settings + branding endpoints (settings get/put, logo upload).
|
||||
"""
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.auth import require_permissions
|
||||
from app.core.config import settings as app_settings
|
||||
from app.core.cors import update_cors_middleware
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.models.help_center import HelpCenterSettings
|
||||
from app.models.schemas.help_center import (
|
||||
DnsRecord,
|
||||
DomainStatusResponse,
|
||||
HelpCenterAgentOption,
|
||||
HelpCenterSettingsResponse,
|
||||
HelpCenterSettingsUpdate,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.repositories.help_center import HelpCenterRepository
|
||||
from app.services.file_storage import resolve_public_url, store_upload
|
||||
from app.services.help_center_access import check_help_center_access, help_center_allowed
|
||||
from app.services.help_center_settings import get_or_create_settings, live_url
|
||||
from app.services.help_center_images import absolute_upload_url
|
||||
from app.services.image_security import sanitize_image
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_LOGO_BYTES = 2 * 1024 * 1024
|
||||
# Raster only — SVG is active markup and would be stored XSS on the public help
|
||||
# center, so it is not accepted; the admin cropper rasterises to PNG client-side.
|
||||
_LOGO_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
# Reject anything larger than this on a side up front (decompression-bomb guard);
|
||||
# the stored logo is then downscaled to LOGO_FIT_DIM (header renders it ~30px).
|
||||
MAX_LOGO_DIM = 4000
|
||||
LOGO_FIT_DIM = 512
|
||||
|
||||
# Favicon: same raster-only policy (SVG/ICO rejected — stored XSS / unsupported by
|
||||
# sanitize_image). Stored small and square-ish; the browser scales the PNG.
|
||||
MAX_FAVICON_BYTES = 1 * 1024 * 1024
|
||||
_FAVICON_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
MAX_FAVICON_DIM = 2000
|
||||
FAVICON_FIT_DIM = 128
|
||||
|
||||
|
||||
def domain_status_response(row: HelpCenterSettings) -> DomainStatusResponse:
|
||||
"""DNS-records table for the admin UI, shaped from stored state."""
|
||||
records = []
|
||||
if row.custom_domain:
|
||||
records = [
|
||||
DnsRecord(
|
||||
type="CNAME",
|
||||
host=row.custom_domain,
|
||||
value=app_settings.HELP_CENTER_CNAME_TARGET,
|
||||
verified=row.cname_record_verified,
|
||||
),
|
||||
DnsRecord(
|
||||
type="TXT",
|
||||
host=f"_replypier.{row.custom_domain}",
|
||||
value=f"rp-verify={row.domain_verification_token}",
|
||||
verified=row.txt_record_verified,
|
||||
),
|
||||
]
|
||||
return DomainStatusResponse(
|
||||
custom_domain=row.custom_domain,
|
||||
domain_status=row.domain_status,
|
||||
ssl_status=row.ssl_status,
|
||||
records=records,
|
||||
domain_verified_at=row.domain_verified_at,
|
||||
)
|
||||
|
||||
|
||||
async def settings_response(
|
||||
db: Session, row: HelpCenterSettings, organization_id: UUID
|
||||
) -> HelpCenterSettingsResponse:
|
||||
agents = (
|
||||
db.query(Agent)
|
||||
.options(selectinload(Agent.widgets)) # avoid a lazy-load query per agent
|
||||
.filter(Agent.organization_id == organization_id)
|
||||
.order_by(Agent.name)
|
||||
.all()
|
||||
)
|
||||
response = HelpCenterSettingsResponse.model_validate(row)
|
||||
# Absolute (api-origin) URL: the admin runs on app.replypier.site, whose
|
||||
# nginx serves any *.png path as a static asset — a host-relative
|
||||
# /api/v1/uploads/*.png would 404 there. Anchor to the backend origin.
|
||||
response.logo_url = (
|
||||
absolute_upload_url(await resolve_public_url(row.logo_url)) if row.logo_url else None
|
||||
)
|
||||
response.favicon_url = (
|
||||
absolute_upload_url(await resolve_public_url(row.favicon_url)) if row.favicon_url else None
|
||||
)
|
||||
response.live_url = live_url(row)
|
||||
response.published_count = FAQRepository(db).count_published(organization_id)
|
||||
response.plan_allowed = help_center_allowed(db, organization_id)
|
||||
response.agents = [
|
||||
HelpCenterAgentOption(
|
||||
id=agent.id,
|
||||
name=agent.display_name or agent.name,
|
||||
has_widget=bool(agent.widgets),
|
||||
)
|
||||
for agent in agents
|
||||
]
|
||||
response.domain = domain_status_response(row)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/settings", response_model=HelpCenterSettingsResponse)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get-or-create the org's help center settings. Ungated read: the response
|
||||
carries plan_allowed so the UI can render the upgrade lock."""
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=HelpCenterSettingsResponse)
|
||||
async def update_settings(
|
||||
payload: HelpCenterSettingsUpdate,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if updates.get("agent_id") is not None:
|
||||
agent = db.query(Agent).filter(
|
||||
Agent.id == updates["agent_id"],
|
||||
Agent.organization_id == current_user.organization_id,
|
||||
).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
was_enabled = row.enabled
|
||||
for field, value in updates.items():
|
||||
setattr(row, field, value)
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
# Toggling `enabled` changes which {slug}.{base} origins the widget may call,
|
||||
# so refresh the CORS allowlist (and propagate to other workers via Redis).
|
||||
if "enabled" in updates and updates["enabled"] != was_enabled:
|
||||
update_cors_middleware(request.app)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.post("/logo", response_model=HelpCenterSettingsResponse)
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
|
||||
content = await file.read()
|
||||
# Validate, bomb-guard, downscale and re-encode to a clean PNG/JPEG — the
|
||||
# stored bytes carry no metadata or non-image payload.
|
||||
safe_bytes, content_type, ext = sanitize_image(
|
||||
content,
|
||||
allowed_content_types=_LOGO_TYPES,
|
||||
max_bytes=MAX_LOGO_BYTES,
|
||||
max_dim=MAX_LOGO_DIM,
|
||||
fit=LOGO_FIT_DIM,
|
||||
)
|
||||
file_name = f"{uuid4()}{ext}"
|
||||
row.logo_url = await store_upload(
|
||||
safe_bytes,
|
||||
f"help_center/{current_user.organization_id}",
|
||||
file_name,
|
||||
content_type=content_type,
|
||||
)
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.delete("/logo", response_model=HelpCenterSettingsResponse)
|
||||
async def remove_logo(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
row.logo_url = None
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.post("/favicon", response_model=HelpCenterSettingsResponse)
|
||||
async def upload_favicon(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
|
||||
content = await file.read()
|
||||
safe_bytes, content_type, ext = sanitize_image(
|
||||
content,
|
||||
allowed_content_types=_FAVICON_TYPES,
|
||||
max_bytes=MAX_FAVICON_BYTES,
|
||||
max_dim=MAX_FAVICON_DIM,
|
||||
fit=FAVICON_FIT_DIM,
|
||||
)
|
||||
file_name = f"{uuid4()}{ext}"
|
||||
row.favicon_url = await store_upload(
|
||||
safe_bytes,
|
||||
f"help_center/{current_user.organization_id}",
|
||||
file_name,
|
||||
content_type=content_type,
|
||||
)
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
|
||||
|
||||
@router.delete("/favicon", response_model=HelpCenterSettingsResponse)
|
||||
async def remove_favicon(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
row.favicon_url = None
|
||||
row = HelpCenterRepository(db).update(row)
|
||||
return await settings_response(db, row, current_user.organization_id)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Custom-domain lifecycle for the help center: claim, verify (DNS + SSL),
|
||||
inspect status, remove.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_permissions
|
||||
from app.core.cors import update_cors_middleware
|
||||
from app.database import get_db
|
||||
from app.models.schemas.help_center import DomainRequest, DomainStatusResponse
|
||||
from app.models.user import User
|
||||
from app.services.domain_verification import (
|
||||
clear_custom_domain,
|
||||
set_custom_domain,
|
||||
verify_custom_domain,
|
||||
)
|
||||
from app.services.help_center_access import check_help_center_access
|
||||
from app.services.help_center_settings import get_or_create_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _domain_response(row) -> DomainStatusResponse:
|
||||
from app.api.help_center.branding import domain_status_response
|
||||
return domain_status_response(row)
|
||||
|
||||
|
||||
@router.post("/domain", response_model=DomainStatusResponse)
|
||||
async def set_domain(
|
||||
payload: DomainRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
try:
|
||||
row = set_custom_domain(db, row, payload.domain)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="That domain is already in use.")
|
||||
return _domain_response(row)
|
||||
|
||||
|
||||
@router.delete("/domain", response_model=DomainStatusResponse)
|
||||
async def remove_domain(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
return _domain_response(clear_custom_domain(db, row))
|
||||
|
||||
|
||||
@router.post("/domain/verify", response_model=DomainStatusResponse)
|
||||
async def verify_domain(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
if not row.custom_domain:
|
||||
raise HTTPException(status_code=400, detail="Set a custom domain first.")
|
||||
# DNS + HTTPS probes block; keep them off the event loop.
|
||||
row = await asyncio.to_thread(verify_custom_domain, db, row)
|
||||
# Once verified, the custom domain becomes a widget origin — allow it in CORS.
|
||||
if row.domain_verified:
|
||||
update_cors_middleware(request.app)
|
||||
return _domain_response(row)
|
||||
|
||||
|
||||
@router.get("/domain/status", response_model=DomainStatusResponse)
|
||||
async def domain_status(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Stored state only — cheap enough for the UI's pending-domain polling.
|
||||
POST /domain/verify is the endpoint that re-runs live DNS checks."""
|
||||
row = get_or_create_settings(db, current_user.organization)
|
||||
return _domain_response(row)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
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, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_permissions
|
||||
from app.database import get_db
|
||||
from app.models.faq import FAQ, FAQStatus
|
||||
from app.models.schemas.faq import (
|
||||
FAQBulkDeleteRequest,
|
||||
FAQBulkStatusRequest,
|
||||
FAQCreate,
|
||||
FAQListResponse,
|
||||
FAQResponse,
|
||||
FAQUpdate,
|
||||
)
|
||||
from app.models.schemas.pagination import Pagination
|
||||
from app.models.user import User
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.services.help_center_access import check_help_center_access
|
||||
from app.services.image_security import sanitize_image
|
||||
from app.core.params import PageNumber
|
||||
from app.services.help_center_images import (
|
||||
FAQ_IMAGE_FIT_DIM,
|
||||
FAQ_IMAGE_MAX_DIM,
|
||||
FAQ_IMAGE_TYPES,
|
||||
MAX_FAQ_IMAGE_BYTES,
|
||||
store_article_image,
|
||||
)
|
||||
from app.services.help_center_settings import (
|
||||
generate_faq_slug,
|
||||
normalize_url_path,
|
||||
resolve_faq_slug,
|
||||
resolve_faq_url_path,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_owned_faq(faq_id: UUID, current_user: User, db: Session) -> FAQ:
|
||||
faq = FAQRepository(db).get_by_id(faq_id, current_user.organization_id)
|
||||
# 404 for both missing and cross-org ids (don't reveal existence).
|
||||
if not faq:
|
||||
raise HTTPException(status_code=404, detail="FAQ not found")
|
||||
return faq
|
||||
|
||||
|
||||
@router.get("/faqs", response_model=FAQListResponse)
|
||||
async def list_faqs(
|
||||
status: Optional[FAQStatus] = Query(default=None),
|
||||
category: Optional[str] = Query(default=None),
|
||||
q: Optional[str] = Query(default=None, max_length=200),
|
||||
page: PageNumber = 1,
|
||||
page_size: int = Query(default=100, ge=1, le=200),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
items, total = FAQRepository(db).list_for_org(
|
||||
current_user.organization_id,
|
||||
status=status,
|
||||
category=category,
|
||||
search=q,
|
||||
skip=(page - 1) * page_size,
|
||||
limit=page_size,
|
||||
)
|
||||
return FAQListResponse(
|
||||
faqs=[FAQResponse.model_validate(item) for item in items],
|
||||
pagination=Pagination.build(total=total, page=page, page_size=page_size),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faqs/categories", response_model=List[str])
|
||||
async def list_categories(
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return FAQRepository(db).get_categories(current_user.organization_id)
|
||||
|
||||
|
||||
@router.post("/faqs", response_model=FAQResponse, status_code=201)
|
||||
async def create_faq(
|
||||
payload: FAQCreate,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
org_id = current_user.organization_id
|
||||
faq = FAQ(
|
||||
organization_id=org_id,
|
||||
question=payload.question,
|
||||
answer=payload.answer,
|
||||
category=payload.category,
|
||||
status=payload.status,
|
||||
# A hand-typed slug is normalized and de-duplicated the same way a
|
||||
# generated one is; omitting it derives the slug from the question.
|
||||
slug=(
|
||||
resolve_faq_slug(db, org_id, payload.slug)
|
||||
if payload.slug
|
||||
else generate_faq_slug(db, org_id, payload.question)
|
||||
),
|
||||
url_path=_resolve_url_path(db, org_id, payload.url_path),
|
||||
meta_title=payload.meta_title,
|
||||
meta_description=payload.meta_description,
|
||||
source_label="Added manually",
|
||||
created_by=current_user.id,
|
||||
)
|
||||
return FAQResponse.model_validate(FAQRepository(db).create(faq))
|
||||
|
||||
|
||||
def _resolve_url_path(
|
||||
db: Session, org_id: UUID, requested: Optional[str], exclude_id: Optional[UUID] = None
|
||||
) -> Optional[str]:
|
||||
"""Validate a hand-typed preserved URL path, or raise. None passes through
|
||||
(no path requested / cleared). Unlike the slug this is never auto-corrected:
|
||||
an altered path is no longer the URL the org migrated in, so a bad one is
|
||||
surfaced instead of silently changed."""
|
||||
if not requested:
|
||||
return None
|
||||
if normalize_url_path(requested) is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="That isn't a usable URL path — it can't be empty, or start with a "
|
||||
"reserved prefix like /a/, /ask or /api.",
|
||||
)
|
||||
resolved = resolve_faq_url_path(db, org_id, requested, exclude_id=exclude_id)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=409, detail="Another article already uses that URL path.")
|
||||
return resolved
|
||||
|
||||
|
||||
@router.post("/faqs/image")
|
||||
async def upload_faq_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Upload an image for embedding in an article's Markdown. Returns a stable
|
||||
absolute URL the editor inserts as ."""
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
content = await file.read()
|
||||
# Validate, bomb-guard, downscale and re-encode from decoded pixels — strips
|
||||
# metadata/payloads and normalises to PNG/JPEG (content type is derived here,
|
||||
# never from the client filename).
|
||||
safe_bytes, content_type, _ext = sanitize_image(
|
||||
content,
|
||||
allowed_content_types=set(FAQ_IMAGE_TYPES),
|
||||
max_bytes=MAX_FAQ_IMAGE_BYTES,
|
||||
max_dim=FAQ_IMAGE_MAX_DIM,
|
||||
fit=FAQ_IMAGE_FIT_DIM,
|
||||
)
|
||||
return {"url": await store_article_image(safe_bytes, content_type)}
|
||||
|
||||
|
||||
@router.put("/faqs/{faq_id}", response_model=FAQResponse)
|
||||
async def update_faq(
|
||||
faq_id: UUID,
|
||||
payload: FAQUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
faq = _get_owned_faq(faq_id, current_user, db)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
# The slug is never taken verbatim from the client — normalize and dedupe it
|
||||
# (excluding this row, so re-saving an unchanged slug isn't a self-collision).
|
||||
requested_slug = updates.pop("slug", None)
|
||||
# Note the asymmetry with the slug: a blank slug is a no-op (an article must
|
||||
# always have one), but a blank url_path CLEARS it, moving the article back
|
||||
# to /a/{slug} — so this branches on presence, not truthiness.
|
||||
has_url_path = "url_path" in updates
|
||||
requested_url_path = updates.pop("url_path", None)
|
||||
for field, value in updates.items():
|
||||
setattr(faq, field, value)
|
||||
if requested_slug:
|
||||
faq.slug = resolve_faq_slug(
|
||||
db, current_user.organization_id, requested_slug, exclude_id=faq.id
|
||||
)
|
||||
if has_url_path:
|
||||
faq.url_path = _resolve_url_path(
|
||||
db, current_user.organization_id, requested_url_path, exclude_id=faq.id
|
||||
)
|
||||
# Backfill a slug for legacy/generated FAQs the first time they're edited;
|
||||
# otherwise keep it stable so published article URLs never change on their own.
|
||||
if not faq.slug:
|
||||
faq.slug = generate_faq_slug(db, current_user.organization_id, faq.question)
|
||||
return FAQResponse.model_validate(FAQRepository(db).update(faq))
|
||||
|
||||
|
||||
@router.delete("/faqs/{faq_id}", status_code=204)
|
||||
async def delete_faq(
|
||||
faq_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
faq = _get_owned_faq(faq_id, current_user, db)
|
||||
FAQRepository(db).delete(faq)
|
||||
|
||||
|
||||
@router.post("/faqs/bulk-status")
|
||||
async def bulk_set_status(
|
||||
payload: FAQBulkStatusRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Publish/unpublish one or many FAQs in a single call."""
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
updated = FAQRepository(db).bulk_set_status(
|
||||
current_user.organization_id, payload.faq_ids, payload.status
|
||||
)
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
@router.post("/faqs/bulk-delete")
|
||||
async def bulk_delete(
|
||||
payload: FAQBulkDeleteRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete many FAQs in one call (org-scoped; foreign ids are ignored)."""
|
||||
check_help_center_access(db, current_user.organization_id)
|
||||
deleted = FAQRepository(db).bulk_delete(current_user.organization_id, payload.faq_ids)
|
||||
return {"deleted": deleted}
|
||||
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
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 Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import require_permissions
|
||||
from app.core.config import settings
|
||||
from app.core.file_validation import PDF_MAGIC, read_validated, safe_filename
|
||||
from app.database import get_db
|
||||
from app.knowledge.url_safety import resolves_to_blocked_host
|
||||
from app.models.faq_generation_job import FAQGenerationJob, FAQJobType
|
||||
from app.models.knowledge import Knowledge
|
||||
from app.models.schemas.faq import (
|
||||
GenerateRequest,
|
||||
GenerationEstimateResponse,
|
||||
GenerationJobResponse,
|
||||
GenerationSourceResponse,
|
||||
ImportRequest,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.repositories.ai_config import AIConfigRepository
|
||||
from app.repositories.faq_generation_job import FAQGenerationJobRepository
|
||||
from app.services.faq_usage import (
|
||||
ensure_generation_budget,
|
||||
estimate_generation_calls,
|
||||
generation_is_metered,
|
||||
remaining_message_credits,
|
||||
)
|
||||
from app.services.help_center_access import check_help_center_access
|
||||
from app.core.params import DbIntId
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Budget floor for imports whose call count isn't known up front — blocks only
|
||||
# a fully exhausted plan; the per-call metering happens in the worker.
|
||||
MIN_IMPORT_CALL_ESTIMATE = 1
|
||||
|
||||
MAX_IMPORT_PDF_BYTES = 25 * 1024 * 1024
|
||||
# Stored under its own folder so import buffers never mix with article images.
|
||||
_PDF_IMPORT_FOLDER = "help_center_imports"
|
||||
|
||||
|
||||
def _require_ai_config(db: Session, organization_id) -> None:
|
||||
if not AIConfigRepository(db).get_active_config(organization_id):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No active AI configuration. Set up an AI model in AI Configuration first.",
|
||||
)
|
||||
|
||||
|
||||
def _enqueue(
|
||||
db: Session,
|
||||
current_user: User,
|
||||
job_type: FAQJobType,
|
||||
estimated_calls: int = MIN_IMPORT_CALL_ESTIMATE,
|
||||
requires_llm: bool = True,
|
||||
**fields,
|
||||
) -> FAQGenerationJob:
|
||||
"""Shared enqueue guard: one active job per type per org (409 on dupes),
|
||||
fail fast when no AI model is configured, 402 when a metered run exceeds
|
||||
the remaining message credits. Stamps the job's `metered` flag from the
|
||||
org's current AI config. requires_llm=False (article import) skips the
|
||||
AI-config and credit checks entirely."""
|
||||
org_id = current_user.organization_id
|
||||
check_help_center_access(db, org_id)
|
||||
if requires_llm:
|
||||
_require_ai_config(db, org_id)
|
||||
ensure_generation_budget(db, org_id, estimated_calls)
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
# Clear jobs a dead worker left in 'processing' so they don't block this
|
||||
# enqueue (the one-active-per-type guard + partial unique index).
|
||||
job_repo.fail_orphaned_processing(
|
||||
"Worker stopped before the job finished — please try again.",
|
||||
older_than_seconds=settings.FAQ_JOB_STALE_SECONDS,
|
||||
organization_id=org_id,
|
||||
)
|
||||
if job_repo.get_active_for_org(org_id, job_type):
|
||||
raise HTTPException(status_code=409, detail="A job of this type is already running.")
|
||||
try:
|
||||
return job_repo.create(
|
||||
FAQGenerationJob(
|
||||
organization_id=org_id,
|
||||
user_id=current_user.id,
|
||||
job_type=job_type.value,
|
||||
metered=requires_llm and generation_is_metered(db, org_id),
|
||||
**fields,
|
||||
)
|
||||
)
|
||||
except IntegrityError:
|
||||
# Concurrent enqueue lost the race against the partial unique index.
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="A job of this type is already running.")
|
||||
|
||||
|
||||
def _validate_org_knowledge_ids(db: Session, organization_id, knowledge_ids) -> None:
|
||||
if not knowledge_ids:
|
||||
return
|
||||
owned = {
|
||||
row[0]
|
||||
for row in db.query(Knowledge.id)
|
||||
.filter(Knowledge.organization_id == organization_id, Knowledge.id.in_(knowledge_ids))
|
||||
.all()
|
||||
}
|
||||
if set(knowledge_ids) - owned:
|
||||
raise HTTPException(status_code=400, detail="Unknown knowledge source in selection.")
|
||||
|
||||
|
||||
@router.get("/generate/estimate", response_model=GenerationEstimateResponse)
|
||||
async def generation_estimate(
|
||||
include_pages: bool = Query(
|
||||
default=True,
|
||||
description="False skips the per-source page scan — cheap counts for button labels",
|
||||
),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Pre-generation numbers for the confirm dialog: new (ungenerated)
|
||||
sources, page count, estimated LLM calls and remaining credits."""
|
||||
org_id = current_user.organization_id
|
||||
check_help_center_access(db, org_id)
|
||||
estimate = estimate_generation_calls(db, org_id, count_pages=include_pages)
|
||||
metered = generation_is_metered(db, org_id)
|
||||
return GenerationEstimateResponse(
|
||||
total_sources=estimate.total_sources,
|
||||
new_sources=estimate.new_sources,
|
||||
pages=estimate.pages,
|
||||
estimated_calls=estimate.estimated_calls,
|
||||
metered=metered,
|
||||
remaining_credits=remaining_message_credits(db, org_id) if metered else None,
|
||||
sources=[
|
||||
GenerationSourceResponse(
|
||||
id=s.id, name=s.name, source_type=s.source_type,
|
||||
has_faqs=s.has_faqs, pages=s.pages, estimated_calls=s.estimated_calls,
|
||||
)
|
||||
for s in estimate.sources
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerationJobResponse, status_code=202)
|
||||
async def start_generation(
|
||||
payload: Optional[GenerateRequest] = None,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Draft FAQs from the knowledge base (additive; the worker dedups against
|
||||
existing questions). By default only sources without FAQs are read;
|
||||
knowledge_ids explicitly targets sources instead."""
|
||||
org_id = current_user.organization_id
|
||||
# Plan gate and AI-config checks first: a locked plan must see 403 and a
|
||||
# missing model its setup hint, not source-count errors.
|
||||
check_help_center_access(db, org_id)
|
||||
_require_ai_config(db, org_id)
|
||||
knowledge_ids = payload.knowledge_ids if payload else None
|
||||
_validate_org_knowledge_ids(db, org_id, knowledge_ids)
|
||||
estimate = estimate_generation_calls(db, org_id, knowledge_ids)
|
||||
if estimate.total_sources == 0:
|
||||
raise HTTPException(status_code=400, detail="No knowledge sources to generate from. Add knowledge first.")
|
||||
if estimate.new_sources == 0:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="All knowledge sources already have FAQs. Delete a source's FAQs to regenerate it.",
|
||||
)
|
||||
job = _enqueue(
|
||||
db,
|
||||
current_user,
|
||||
FAQJobType.GENERATE_ALL,
|
||||
estimated_calls=estimate.estimated_calls,
|
||||
knowledge_ids=knowledge_ids,
|
||||
)
|
||||
return GenerationJobResponse.model_validate(job)
|
||||
|
||||
|
||||
@router.post("/import", response_model=GenerationJobResponse, status_code=202)
|
||||
async def start_import(
|
||||
payload: ImportRequest,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Migrate an existing help center. mode=qa extracts Q&A pairs from the
|
||||
page with the org's model (uses credits); mode=articles crawls the page's
|
||||
linked article pages and imports each one as-is (Markdown, no LLM)."""
|
||||
# Early SSRF rejection for immediate feedback; the worker re-validates at
|
||||
# fetch time (including every redirect hop).
|
||||
if resolves_to_blocked_host(payload.url):
|
||||
raise HTTPException(status_code=400, detail="That URL points to a blocked or internal host.")
|
||||
if payload.mode == "articles":
|
||||
# No LLM → no AI-config requirement, no credit checks.
|
||||
job = _enqueue(
|
||||
db, current_user, FAQJobType.IMPORT_ARTICLES,
|
||||
requires_llm=False, source_url=payload.url,
|
||||
preserve_source_urls=payload.preserve_urls,
|
||||
)
|
||||
else:
|
||||
job = _enqueue(db, current_user, FAQJobType.IMPORT_URL, source_url=payload.url)
|
||||
return GenerationJobResponse.model_validate(job)
|
||||
|
||||
|
||||
@router.post("/import/pdf", response_model=GenerationJobResponse, status_code=202)
|
||||
async def start_pdf_import(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Extract Q&A pairs from an uploaded PDF with the org's model. The file
|
||||
is persisted via shared storage (the worker runs in another container)
|
||||
and deleted after processing."""
|
||||
from app.services.file_storage import store_upload
|
||||
|
||||
content = await read_validated(
|
||||
file,
|
||||
max_size=MAX_IMPORT_PDF_BYTES,
|
||||
allowed_content_types=("application/pdf",),
|
||||
magic_prefix=PDF_MAGIC,
|
||||
label="PDF",
|
||||
)
|
||||
stored = await store_upload(
|
||||
content=content,
|
||||
folder=_PDF_IMPORT_FOLDER,
|
||||
file_name=f"{uuid4().hex}.pdf",
|
||||
content_type="application/pdf",
|
||||
)
|
||||
# safe_filename strips traversal; keep only the readable tail for the label.
|
||||
original = safe_filename(file.filename or "document.pdf").split("_", 1)[-1][:255]
|
||||
try:
|
||||
job = _enqueue(
|
||||
db, current_user, FAQJobType.IMPORT_PDF,
|
||||
source_url=stored, source_file_name=original,
|
||||
)
|
||||
except HTTPException:
|
||||
# Enqueue refused (plan/config/duplicate/budget) — don't leak the buffer.
|
||||
from app.services.file_storage import delete_upload
|
||||
await delete_upload(stored)
|
||||
raise
|
||||
return GenerationJobResponse.model_validate(job)
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=Optional[GenerationJobResponse])
|
||||
async def get_job(
|
||||
active: bool = Query(default=True, description="Active job only; false = most recent job"),
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""The UI's polling endpoint: the running job if any (active=true), or the
|
||||
most recent job of any status (active=false) for showing the last outcome."""
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
if active:
|
||||
job = job_repo.get_active_for_org(current_user.organization_id)
|
||||
else:
|
||||
job = job_repo.get_latest_for_org(current_user.organization_id)
|
||||
return GenerationJobResponse.model_validate(job) if job else None
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=GenerationJobResponse)
|
||||
async def get_job_by_id(
|
||||
job_id: DbIntId,
|
||||
current_user: User = Depends(require_permissions("manage_knowledge")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
job = FAQGenerationJobRepository(db).get_by_id_for_org(job_id, current_user.organization_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return GenerationJobResponse.model_validate(job)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Public delivery of help-center article images.
|
||||
|
||||
Article Markdown bakes an image URL in permanently, so it cannot hold a signed
|
||||
S3 URL (those expire) and it cannot hold a raw S3 URL either (the bucket is
|
||||
private). Instead store_article_image bakes this stable app path, and this
|
||||
route resolves it per request — redirecting to a freshly signed URL on S3, or
|
||||
serving the file directly on local storage.
|
||||
|
||||
Registered on BOTH the main app (path mode) and public_app (subdomain/custom
|
||||
domain host mode), because article pages are served by whichever one owns the
|
||||
origin and the baked path is host-relative.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.s3 import sign_s3_key
|
||||
from app.services.help_center_images import ARTICLE_IMAGE_NAME_RE, UPLOAD_FOLDER
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# The object is immutable — a fresh uuid per upload — so let clients and CDNs
|
||||
# keep it for a while.
|
||||
_MAX_CACHE_SECONDS = 300
|
||||
|
||||
# Allow-list of redirect targets. S3 presigns come back either virtual-hosted
|
||||
# (bucket.s3.<region>.amazonaws.com) or path-style against the regional or
|
||||
# legacy global endpoint, depending on bucket name and region; local storage
|
||||
# goes to our own static mount. Nothing else is ever a valid destination.
|
||||
_ALLOWED_REDIRECT_PREFIXES = (
|
||||
f"https://{settings.S3_BUCKET}.s3.",
|
||||
"https://s3.",
|
||||
f"{settings.API_V1_STR}/uploads/",
|
||||
)
|
||||
|
||||
_NOT_FOUND = HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
def _cache_control(max_age: int) -> str:
|
||||
return f"public, max-age={max_age}"
|
||||
|
||||
|
||||
@router.get("/images/{file_name}", include_in_schema=False)
|
||||
async def get_article_image(file_name: str):
|
||||
"""Resolve a baked article-image path to the actual image.
|
||||
|
||||
Both branches redirect rather than serve bytes: S3 to a freshly signed URL,
|
||||
local storage to the /api/v1/uploads mount that already serves that
|
||||
directory on both the main app and public_app.
|
||||
"""
|
||||
if not ARTICLE_IMAGE_NAME_RE.match(file_name):
|
||||
raise _NOT_FOUND
|
||||
|
||||
if settings.S3_FILE_STORAGE:
|
||||
target = sign_s3_key(f"{UPLOAD_FOLDER}/{file_name}")
|
||||
# Never cache the redirect longer than the signature it points at, or a
|
||||
# cached target outlives its own presign and starts 403ing.
|
||||
max_age = min(_MAX_CACHE_SECONDS, settings.S3_PRESIGN_EXPIRY_SECONDS // 2)
|
||||
else:
|
||||
target = f"{settings.API_V1_STR}/uploads/{UPLOAD_FOLDER}/{file_name}"
|
||||
max_age = _MAX_CACHE_SECONDS
|
||||
|
||||
if not target.startswith(_ALLOWED_REDIRECT_PREFIXES):
|
||||
# sign_s3_url hands back its input unchanged when it cannot sign; never
|
||||
# bounce a visitor anywhere outside the allow-list.
|
||||
raise _NOT_FOUND
|
||||
|
||||
return RedirectResponse(
|
||||
target, status_code=307, headers={"Cache-Control": _cache_control(max_age)}
|
||||
)
|
||||
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
The public help-center site: a standalone FastAPI app the host-dispatch layer
|
||||
routes {slug}.<base-domain> and verified custom domains to. Server-rendered
|
||||
Jinja2 HTML for SEO (landing list + per-article pages); the only JSON endpoint
|
||||
is the rate-limited "Ask AI".
|
||||
"""
|
||||
|
||||
from html import escape
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.utils.client_ip import forwarded_client_ip
|
||||
from app.api.help_center_images import router as help_center_images_router
|
||||
from app.core.config import settings
|
||||
from app.core.error_handlers import ClientSafeHTTPException, install_error_handlers
|
||||
from app.database import get_db
|
||||
from app.models.faq import FAQ
|
||||
from app.models.help_center import HelpCenterSettings
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.services.file_storage import resolve_public_url
|
||||
from app.services.help_center_content import (
|
||||
excerpt,
|
||||
read_time_label,
|
||||
render_article_html,
|
||||
to_plain_text,
|
||||
)
|
||||
from app.services.help_center_public import (
|
||||
MAX_QUESTION_CHARS,
|
||||
answer_question,
|
||||
ask_available,
|
||||
category_colors,
|
||||
contrast_ink,
|
||||
get_published_article,
|
||||
get_published_article_by_path,
|
||||
normalize_host,
|
||||
published_faq_groups,
|
||||
related_articles,
|
||||
resolve_help_center,
|
||||
widget_id_for,
|
||||
)
|
||||
from app.services.help_center_seo import (
|
||||
absolute_asset_url,
|
||||
article_description,
|
||||
article_href,
|
||||
article_json_ld,
|
||||
article_path,
|
||||
article_title,
|
||||
article_url,
|
||||
breadcrumbs,
|
||||
index_description,
|
||||
index_json_ld,
|
||||
index_title,
|
||||
site_url,
|
||||
)
|
||||
from app.services.public_rate_limit import allow_request
|
||||
|
||||
public_app = FastAPI(title="ReplyPier Help Center", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
install_error_handlers(public_app)
|
||||
# Serve ONLY help-center images on this app, so relative logo/article-image paths
|
||||
# resolve on a subdomain/custom-domain origin (host dispatch). Scoped to the
|
||||
# help_center/ subtree — the help center must never expose chat_attachments,
|
||||
# knowledge, avatars, etc. (in path mode these paths are served by the main app
|
||||
# instead). check_dir=False: the dir may not exist until the first upload; a
|
||||
# missing file 404s. Local storage only — S3 URLs never hit this mount.
|
||||
public_app.mount(
|
||||
"/api/v1/uploads/help_center",
|
||||
StaticFiles(directory="uploads/help_center", check_dir=False),
|
||||
name="help_center_uploads",
|
||||
)
|
||||
# Article images uploaded from this release on are baked as
|
||||
# {API_V1_STR}/help-center/images/<file> instead, which resolves on either
|
||||
# storage backend. The mount above stays for URLs baked before that.
|
||||
public_app.include_router(
|
||||
help_center_images_router,
|
||||
prefix=f"{settings.API_V1_STR}/help-center",
|
||||
)
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
PAGE_CACHE_CONTROL = "public, max-age=60"
|
||||
ASK_LIMIT_PER_MINUTE = 10
|
||||
ASK_LIMIT_PER_DAY = 100
|
||||
|
||||
# Paths robots.txt keeps crawlers off. The articles themselves stay fully
|
||||
# crawlable ("Allow: /"); these are the non-content paths only:
|
||||
# /feedback, /search Not navigable pages. The catch-all resolves them as
|
||||
# article slugs and 404s, which is how they reached
|
||||
# Search Console as "Not found (404)".
|
||||
# /*?q=, /*?topic= The landing page re-renders itself filtered for both:
|
||||
# one URL per category, and one per search phrase — an
|
||||
# unbounded set of thin near-duplicates, none of them in
|
||||
# sitemap.xml. ("/search" is not a route; it is listed so
|
||||
# a future one cannot leak before anyone thinks of this.)
|
||||
CRAWLER_DISALLOWED_PATHS = ("/feedback", "/search", "/*?q=", "/*?topic=")
|
||||
|
||||
|
||||
class AskRequest(BaseModel):
|
||||
question: str = Field(min_length=3, max_length=MAX_QUESTION_CHARS)
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
helpful: bool
|
||||
|
||||
|
||||
def _resolve_or_404(request: Request, db: Session):
|
||||
hc = request.scope.get("help_center")
|
||||
if hc: # path dispatch (/help/{slug}) — org from the path slug, not Host
|
||||
row = resolve_help_center(db, slug=hc["slug"])
|
||||
else:
|
||||
row = resolve_help_center(db, host=normalize_host(request.headers.get("host")))
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Help center not found")
|
||||
return row
|
||||
|
||||
|
||||
def _base_path(request: Request) -> str:
|
||||
"""URL prefix for internal links: "/help/{slug}" under path dispatch, "" otherwise."""
|
||||
return request.scope.get("help_center", {}).get("base_path", "")
|
||||
|
||||
|
||||
async def _chrome_context(row: HelpCenterSettings, base_path: str = "") -> dict:
|
||||
"""Shared header/footer/widget context used by every rendered page.
|
||||
|
||||
`base_path` is the URL prefix templates prepend to internal links: "" for
|
||||
host dispatch (site at origin root) and "/help/{slug}" for path dispatch."""
|
||||
return {
|
||||
"row": row,
|
||||
"base_path": base_path,
|
||||
"brand_color": row.brand_color,
|
||||
"brand_ink": contrast_ink(row.brand_color),
|
||||
# Relative (/api/v1/uploads/...) so the logo resolves against whichever
|
||||
# origin serves the page — public_app now mounts uploads for host mode,
|
||||
# and path mode is same-origin as the API. S3 stays absolute (signed).
|
||||
"logo_url": await resolve_public_url(row.logo_url) if row.logo_url else None,
|
||||
"favicon_url": await resolve_public_url(row.favicon_url) if row.favicon_url else None,
|
||||
"header_links": row.header_links or [],
|
||||
"widget_id": widget_id_for(row),
|
||||
# The widget LOADER (replypier.min.js) is served by the frontend, while
|
||||
# the widget APP it pulls in (/assets/widget.js) is served from
|
||||
# VITE_WIDGET_URL by the backend. In prod both resolve to the app domain.
|
||||
"widget_script_url": f"{settings.FRONTEND_URL}/webclient/replypier.min.js",
|
||||
# This install's API root, handed to the loader as window.replypierBaseUrl
|
||||
# (same contract as the dashboard's embed snippet). Without it the loader
|
||||
# uses its build-time default — the vendor cloud — which no self-hosted
|
||||
# deployment can talk to.
|
||||
"widget_api_url": f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}",
|
||||
}
|
||||
|
||||
|
||||
def _social_context(row: HelpCenterSettings, site: str) -> dict:
|
||||
"""Open Graph / Twitter card values shared by both page types.
|
||||
|
||||
og:image must be absolute (scrapers don't resolve relative URLs), so a
|
||||
relative logo is anchored to the serving origin. Deliberately NOT signed
|
||||
like the rendered logo: social scrapers cache the URL and re-fetch it long
|
||||
after a signature would expire, so this uses the stored URL — the same
|
||||
unsigned form article images already bake in, which S3 setups serve via a
|
||||
public-read help_center/ prefix.
|
||||
"""
|
||||
logo_url = absolute_asset_url(row.logo_url, site)
|
||||
return {
|
||||
"og_site_name": index_title(row),
|
||||
"og_image": logo_url,
|
||||
"seo_logo_url": logo_url,
|
||||
}
|
||||
|
||||
|
||||
def _card_view(faq: FAQ) -> dict:
|
||||
"""List-card view model: question links to the article, with a plain-text
|
||||
preview and read time computed from the Markdown answer. Search fields are
|
||||
split so the client can rank title hits above body hits, and the body is
|
||||
plain text (raw Markdown would make URLs/syntax searchable noise).
|
||||
|
||||
`href` is base_path-less; the template adds the serving prefix."""
|
||||
return {
|
||||
"question": faq.question,
|
||||
"href": article_href(faq),
|
||||
"preview": excerpt(faq.answer),
|
||||
"read_time": read_time_label(faq.answer),
|
||||
"search_title": faq.question.lower(),
|
||||
"search_text": f"{to_plain_text(faq.answer)} {faq.category}".lower(),
|
||||
}
|
||||
|
||||
|
||||
@public_app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, q: str = "", db: Session = Depends(get_db)):
|
||||
row = _resolve_or_404(request, db)
|
||||
search = q.strip()[:200] or None
|
||||
groups = published_faq_groups(db, row, search=search)
|
||||
colors = category_colors([category for category, _faqs in groups])
|
||||
card_groups = [(category, [_card_view(faq) for faq in faqs]) for category, faqs in groups]
|
||||
site = site_url(row)
|
||||
social = _social_context(row, site)
|
||||
published = [faq for _category, faqs in groups for faq in faqs]
|
||||
context = {
|
||||
**await _chrome_context(row, _base_path(request)),
|
||||
**social,
|
||||
"request": request,
|
||||
"groups": card_groups,
|
||||
"colors": colors,
|
||||
"search": search or "",
|
||||
"title": index_title(row),
|
||||
"description": index_description(row),
|
||||
"canonical_url": f"{site}/",
|
||||
"og_type": "website",
|
||||
"json_ld": index_json_ld(row, published, site, social["seo_logo_url"]),
|
||||
"ask_enabled": ask_available(row),
|
||||
}
|
||||
response = templates.TemplateResponse(request, "help_center/index.html", context)
|
||||
response.headers["Cache-Control"] = PAGE_CACHE_CONTROL
|
||||
return response
|
||||
|
||||
|
||||
@public_app.get("/a/{slug}", response_class=HTMLResponse)
|
||||
async def article(slug: str, request: Request, db: Session = Depends(get_db)):
|
||||
row = _resolve_or_404(request, db)
|
||||
faq = get_published_article(db, row, slug)
|
||||
if not faq:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
if faq.url_path:
|
||||
# The article kept its original URL from a help-center migration, so
|
||||
# that is the canonical one and this is the alias. Cache-Control bounds
|
||||
# how long a browser pins the redirect: without it a 301 is cached
|
||||
# effectively forever, stranding return visitors if the path is cleared.
|
||||
return RedirectResponse(
|
||||
f"{_base_path(request)}{faq.url_path}",
|
||||
status_code=301,
|
||||
headers={"Cache-Control": PAGE_CACHE_CONTROL},
|
||||
)
|
||||
return await _render_article(faq, row, request, db)
|
||||
|
||||
|
||||
async def _render_article(
|
||||
faq: FAQ, row: HelpCenterSettings, request: Request, db: Session
|
||||
) -> Response:
|
||||
"""Render one article page. Shared by /a/{slug} and the preserved-path
|
||||
route below, which reach the same FAQ by different URLs."""
|
||||
# All published categories (unfiltered) drive the sidebar + stable colors.
|
||||
all_groups = published_faq_groups(db, row)
|
||||
colors = category_colors([category for category, _faqs in all_groups])
|
||||
default_color = colors.get(faq.category, "#6d5bd0")
|
||||
topics = [
|
||||
{
|
||||
"category": category,
|
||||
"count": len(faqs),
|
||||
"color": colors.get(category, default_color),
|
||||
"active": category == faq.category,
|
||||
}
|
||||
for category, faqs in all_groups
|
||||
]
|
||||
related = [
|
||||
{
|
||||
"question": rel.question,
|
||||
"href": article_href(rel),
|
||||
"read_time": read_time_label(rel.answer),
|
||||
"color": colors.get(rel.category, default_color),
|
||||
}
|
||||
for rel in related_articles(db, row, faq)
|
||||
]
|
||||
base_path = _base_path(request)
|
||||
# Cross-article links are stored as /a/{slug}; point the ones whose target
|
||||
# kept an original URL straight at it, so a reader never takes the 301 hop.
|
||||
# Built from all_groups, which is already loaded above — no extra query.
|
||||
link_paths = {
|
||||
other.slug: quote(article_path(other), safe="/")
|
||||
for _category, faqs in all_groups
|
||||
for other in faqs
|
||||
if other.slug and other.url_path
|
||||
}
|
||||
article_view = {
|
||||
"question": faq.question,
|
||||
"category": faq.category,
|
||||
"color": default_color,
|
||||
"read_time": read_time_label(faq.answer),
|
||||
"body_html": render_article_html(faq.answer, base_path, link_paths),
|
||||
# Feedback always posts to the slug route, whatever URL the page itself
|
||||
# is served at — location.pathname would miss on a preserved path. None
|
||||
# for a slug-less legacy row reached by its preserved path: there's no
|
||||
# route to post to, so the widget is dropped rather than rendered broken.
|
||||
"feedback_url": f"{base_path}/a/{quote(faq.slug)}/feedback" if faq.slug else None,
|
||||
"related": related,
|
||||
}
|
||||
site = site_url(row)
|
||||
social = _social_context(row, site)
|
||||
crumbs = breadcrumbs(row, faq, base_path, site)
|
||||
context = {
|
||||
**await _chrome_context(row, base_path),
|
||||
**social,
|
||||
"request": request,
|
||||
"article": article_view,
|
||||
"breadcrumbs": crumbs,
|
||||
"topics": topics,
|
||||
"title": article_title(row, faq),
|
||||
"description": article_description(faq),
|
||||
"canonical_url": article_url(site, faq),
|
||||
"og_type": "article",
|
||||
"json_ld": article_json_ld(
|
||||
row, faq, site, crumbs, social["seo_logo_url"], social["og_image"]
|
||||
),
|
||||
}
|
||||
response = templates.TemplateResponse(request, "help_center/article.html", context)
|
||||
response.headers["Cache-Control"] = PAGE_CACHE_CONTROL
|
||||
return response
|
||||
|
||||
|
||||
@public_app.post("/ask")
|
||||
async def ask(payload: AskRequest, request: Request, db: Session = Depends(get_db)):
|
||||
row = _resolve_or_404(request, db)
|
||||
if not ask_available(row):
|
||||
raise HTTPException(status_code=404, detail="AI answers are not enabled")
|
||||
ip = forwarded_client_ip(request)
|
||||
if not allow_request(f"ask:{ip}:1m", ASK_LIMIT_PER_MINUTE, 60) or not allow_request(
|
||||
f"ask:{ip}:1d", ASK_LIMIT_PER_DAY, 86400
|
||||
):
|
||||
raise HTTPException(status_code=429, detail="Too many questions — please try again later.")
|
||||
# answer_question manages its own short sessions around the slow LLM call.
|
||||
answer = await answer_question(row.organization_id, row.agent_id, payload.question)
|
||||
if not answer:
|
||||
raise ClientSafeHTTPException(status_code=503, detail="Could not answer right now — please try again.")
|
||||
return {"answer": answer}
|
||||
|
||||
|
||||
@public_app.post("/a/{slug}/feedback")
|
||||
async def article_feedback(
|
||||
slug: str, payload: FeedbackRequest, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""Record a 'Was this helpful?' vote. One vote per article per IP per day
|
||||
(deduped via the rate limiter; fail-open, idempotent — a repeat vote is a
|
||||
silent no-op rather than an error)."""
|
||||
row = _resolve_or_404(request, db)
|
||||
faq = get_published_article(db, row, slug)
|
||||
if not faq:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
ip = forwarded_client_ip(request)
|
||||
if allow_request(f"faqfb:{faq.id}:{ip}", 1, 86400):
|
||||
FAQRepository(db).record_feedback(faq.id, payload.helpful)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@public_app.get("/sitemap.xml")
|
||||
async def sitemap(request: Request, db: Session = Depends(get_db)):
|
||||
row = _resolve_or_404(request, db)
|
||||
base = site_url(row)
|
||||
groups = published_faq_groups(db, row)
|
||||
urls = [f" <url><loc>{base}/</loc></url>"]
|
||||
for _category, faqs in groups:
|
||||
for faq in faqs:
|
||||
if faq.slug or faq.url_path:
|
||||
urls.append(f" <url><loc>{escape(article_url(base, faq))}</loc></url>")
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
+ "\n".join(urls)
|
||||
+ "\n</urlset>\n"
|
||||
)
|
||||
return Response(content=xml, media_type="application/xml", headers={"Cache-Control": PAGE_CACHE_CONTROL})
|
||||
|
||||
|
||||
@public_app.get("/robots.txt", response_class=PlainTextResponse)
|
||||
async def robots(request: Request, db: Session = Depends(get_db)):
|
||||
row = _resolve_or_404(request, db)
|
||||
base_path = _base_path(request)
|
||||
lines = ["User-agent: *", "Allow: /"]
|
||||
lines += [f"Disallow: {base_path}{path}" for path in CRAWLER_DISALLOWED_PATHS]
|
||||
lines.append(f"Sitemap: {site_url(row)}/sitemap.xml")
|
||||
return PlainTextResponse("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
@public_app.get("/healthz")
|
||||
async def healthz():
|
||||
"""Liveness for the SSL-provisioning probe; host-level only, no org data."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# KEEP THIS LAST. Starlette serves the first route that fully matches, so every
|
||||
# route above — and the uploads mount — still wins; moving this up would shadow
|
||||
# all of them. It only sees URLs nothing else claimed: articles that kept their
|
||||
# original path through a help-center migration, plus the trailing-slash forms
|
||||
# Starlette's redirect_slashes used to handle (that fallback only fires when
|
||||
# NOTHING matched, and a catch-all always matches).
|
||||
@public_app.get("/{full_path:path}", response_class=HTMLResponse)
|
||||
async def preserved_article(full_path: str, request: Request, db: Session = Depends(get_db)):
|
||||
# Already percent-decoded by the ASGI server, which is why url_path is
|
||||
# stored decoded too.
|
||||
raw = f"/{full_path}"
|
||||
path = raw.rstrip("/") or "/"
|
||||
row = _resolve_or_404(request, db)
|
||||
base_path = _base_path(request)
|
||||
|
||||
faq = get_published_article_by_path(db, row, path) if path != "/" else None
|
||||
if faq is None and path.startswith("/a/"):
|
||||
# "/a/{slug}/" — keep the trailing-slash redirect we'd otherwise lose.
|
||||
faq = get_published_article(db, row, path[len("/a/"):])
|
||||
if faq:
|
||||
return RedirectResponse(f"{base_path}{article_path(faq)}", status_code=301)
|
||||
if faq is None:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
if raw != path: # trailing slash on a preserved path — canonicalise it
|
||||
# article_path(faq), not `path`: identical here (the row was found BY
|
||||
# path), but the Location is then built purely from stored, validated
|
||||
# data instead of from the request — nothing user-supplied reaches it.
|
||||
return RedirectResponse(f"{base_path}{article_path(faq)}", status_code=301)
|
||||
return await _render_article(faq, row, request, db)
|
||||
@@ -0,0 +1,558 @@
|
||||
"""
|
||||
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 traceback
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import String
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.jira import JiraService
|
||||
from app.models.jira import JiraToken, AgentJiraConfig
|
||||
from app.models.agent import Agent
|
||||
from app.core.auth import get_current_organization, require_permissions
|
||||
from app.models.organization import Organization
|
||||
from app.models.user import User
|
||||
from app.core.logger import get_logger
|
||||
from app.core.config import settings
|
||||
from app.core.oauth_state import pop_state, store_state
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from app.core.exceptions import JiraAuthError
|
||||
|
||||
router = APIRouter()
|
||||
jira_service = JiraService()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _require_jira_agent_in_org(db, agent_id, org_id) -> None:
|
||||
"""The agent must belong to the caller's org, else 404.
|
||||
|
||||
A malformed agent_id is treated as not-found rather than a 500.
|
||||
"""
|
||||
from app.repositories.agent import AgentRepository
|
||||
try:
|
||||
agent_uuid = uuid.UUID(str(agent_id))
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if not AgentRepository(db).get_agent_in_org(agent_uuid, org_id):
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
class JiraProject(BaseModel):
|
||||
id: str
|
||||
key: str
|
||||
name: str
|
||||
|
||||
class JiraIssueType(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class JiraPriority(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
iconUrl: Optional[str] = None
|
||||
|
||||
class AgentJiraConfigModel(BaseModel):
|
||||
enabled: bool
|
||||
projectKey: Optional[str] = None
|
||||
issueTypeId: Optional[str] = None
|
||||
|
||||
class CreateJiraIssueModel(BaseModel):
|
||||
projectKey: str
|
||||
issueTypeId: str
|
||||
summary: str
|
||||
description: str
|
||||
priority: Optional[str] = None
|
||||
chatId: Optional[str] = None
|
||||
|
||||
@router.get("/status")
|
||||
async def jira_status(
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Check if Jira is connected for the current organization.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
return {"connected": False}
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.refresh_token(token.refresh_token)
|
||||
|
||||
# Update token in database
|
||||
for key, value in token_data.items():
|
||||
setattr(token, key, value)
|
||||
|
||||
db.commit()
|
||||
is_valid = True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Jira token: {e}")
|
||||
is_valid = False
|
||||
|
||||
return {
|
||||
"connected": is_valid,
|
||||
"site_url": token.site_url if is_valid else None
|
||||
}
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_jira(
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Disconnect Jira integration for the current organization.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Delete all agent Jira configurations for this organization
|
||||
try:
|
||||
agent_configs = db.query(AgentJiraConfig).join(
|
||||
Agent, AgentJiraConfig.agent_id == Agent.id.cast(String)
|
||||
).filter(
|
||||
Agent.organization_id == organization.id
|
||||
).all()
|
||||
|
||||
config_count = len(agent_configs)
|
||||
for config in agent_configs:
|
||||
db.delete(config)
|
||||
|
||||
# Delete the Jira token
|
||||
db.delete(token)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Jira disconnected for organization {organization.id} by user {current_user.id}. Deleted {config_count} agent configurations.")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error disconnecting Jira: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error disconnecting Jira: {str(e)}")
|
||||
|
||||
return {"message": "Jira disconnected successfully"}
|
||||
|
||||
@router.get("/authorize")
|
||||
async def authorize_jira(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Start the Jira OAuth flow by redirecting to Jira's authorization page.
|
||||
"""
|
||||
logger.info(f"Authorizing Jira for organization: {organization.id}")
|
||||
|
||||
# Generate a random state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
store_state("jira", state, str(organization.id))
|
||||
|
||||
# Get authorization URL
|
||||
auth_url = jira_service.get_authorization_url(state)
|
||||
logger.info(f"Redirecting to Jira authorization URL: {auth_url}")
|
||||
return RedirectResponse(url=auth_url)
|
||||
|
||||
@router.get("/callback")
|
||||
async def jira_oauth_callback(
|
||||
code: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
error_description: Optional[str] = None,
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Handle the OAuth callback from Jira.
|
||||
This endpoint handles both successful authentication and cancellation/errors.
|
||||
"""
|
||||
# Handle cancellation or error from Jira
|
||||
if error or not code or not state:
|
||||
logger.warning(f"Jira OAuth flow cancelled or error: {error} - {error_description}")
|
||||
pop_state("jira", state or "")
|
||||
# Redirect to failure page
|
||||
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason={error or 'cancelled'}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
org_id = pop_state("jira", state)
|
||||
|
||||
if not org_id:
|
||||
logger.error(f"Invalid or expired state parameter: {state}")
|
||||
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason=invalid_state"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
try:
|
||||
org_id = uuid.UUID(org_id)
|
||||
# Exchange code for token
|
||||
token_data = await jira_service.exchange_code_for_token(code)
|
||||
|
||||
# Get Jira Cloud ID and site URL
|
||||
cloud_data = await jira_service.get_cloud_id(token_data["access_token"])
|
||||
logger.info(f"Cloud data: {cloud_data}")
|
||||
# Store token in database
|
||||
jira_token = JiraToken(
|
||||
organization_id=org_id,
|
||||
access_token=token_data["access_token"],
|
||||
refresh_token=token_data["refresh_token"],
|
||||
token_type=token_data["token_type"],
|
||||
expires_at=token_data["expires_at"],
|
||||
cloud_id=cloud_data["cloud_id"],
|
||||
site_url=cloud_data["site_url"]
|
||||
)
|
||||
|
||||
# Check if token already exists for this organization
|
||||
existing_token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == org_id
|
||||
).first()
|
||||
|
||||
if existing_token:
|
||||
# Update existing token
|
||||
for key, value in token_data.items():
|
||||
setattr(existing_token, key, value)
|
||||
existing_token.cloud_id = cloud_data["cloud_id"]
|
||||
existing_token.site_url = cloud_data["site_url"]
|
||||
else:
|
||||
db.add(jira_token)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Redirect to success page using FRONTEND_URL from settings
|
||||
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=success"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during Jira OAuth callback: {e}")
|
||||
traceback.print_exc()
|
||||
# Redirect to failure page with error message
|
||||
error_message = str(e).replace(" ", "_")[:100] # Limit length and replace spaces
|
||||
redirect_url = f"{settings.FRONTEND_URL}/settings/integrations?status=failure&reason={error_message}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
@router.get("/refresh")
|
||||
async def refresh_jira_token(
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Refresh the Jira access token using the refresh token.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira token found")
|
||||
|
||||
try:
|
||||
token_data = await jira_service.refresh_token(token.refresh_token)
|
||||
|
||||
# Update token in database
|
||||
for key, value in token_data.items():
|
||||
setattr(token, key, value)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Token refreshed successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/projects", response_model=List[JiraProject])
|
||||
async def get_jira_projects(
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all Jira projects for the current organization.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.refresh_token(token.refresh_token)
|
||||
|
||||
# Update token in database
|
||||
for key, value in token_data.items():
|
||||
setattr(token, key, value)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Jira token: {e}")
|
||||
raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
||||
|
||||
try:
|
||||
projects = await jira_service.get_projects(token.access_token, token.cloud_id)
|
||||
return projects
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Jira projects: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get Jira projects")
|
||||
|
||||
@router.get("/projects/{project_key}/issue-types", response_model=List[JiraIssueType])
|
||||
async def get_jira_issue_types(
|
||||
project_key: str,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all issue types for a Jira project.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.refresh_token(token.refresh_token)
|
||||
|
||||
# Update token in database
|
||||
for key, value in token_data.items():
|
||||
setattr(token, key, value)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Jira token: {e}")
|
||||
raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
||||
|
||||
try:
|
||||
issue_types = await jira_service.get_issue_types(token.access_token, token.cloud_id, project_key)
|
||||
return issue_types
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Jira issue types: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get Jira issue types")
|
||||
|
||||
@router.get("/priorities", response_model=List[JiraPriority])
|
||||
async def get_jira_priorities(
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get all Jira priorities.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.refresh_token(token.refresh_token)
|
||||
|
||||
# Update token in database
|
||||
for key, value in token_data.items():
|
||||
setattr(token, key, value)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Jira token: {e}")
|
||||
raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
||||
|
||||
try:
|
||||
priorities = await jira_service.get_priorities(token.access_token, token.cloud_id)
|
||||
return priorities
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Jira priorities: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get Jira priorities")
|
||||
|
||||
@router.get("/projects/{project_key}/issue-types/{issue_type_id}/has-priority")
|
||||
async def check_priority_availability(
|
||||
project_key: str,
|
||||
issue_type_id: str,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Check if priority field is available for a specific project and issue type.
|
||||
"""
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=404, detail="No Jira connection found")
|
||||
|
||||
# Check if token is valid
|
||||
is_valid = jira_service.validate_token(token)
|
||||
|
||||
# If token is expired, try to refresh it
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await jira_service.refresh_token(token.refresh_token)
|
||||
|
||||
# Update token in database
|
||||
for key, value in token_data.items():
|
||||
setattr(token, key, value)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Jira token: {e}")
|
||||
raise HTTPException(status_code=401, detail="Jira token expired and could not be refreshed")
|
||||
|
||||
try:
|
||||
has_priority = await jira_service.is_priority_available(
|
||||
token.access_token,
|
||||
token.cloud_id,
|
||||
project_key,
|
||||
issue_type_id
|
||||
)
|
||||
return {"hasPriority": has_priority}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check priority availability: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to check priority availability")
|
||||
|
||||
@router.post("/issues")
|
||||
async def create_jira_issue(
|
||||
issue_data: CreateJiraIssueModel,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new Jira issue.
|
||||
"""
|
||||
try:
|
||||
# Create the issue using the wrapper method
|
||||
result = await jira_service.create_issue(
|
||||
organization=organization,
|
||||
db=db,
|
||||
issue_data=issue_data
|
||||
)
|
||||
|
||||
# Return the issue key and URL
|
||||
return {
|
||||
"key": result.get("key"),
|
||||
"id": result.get("id"),
|
||||
"self": result.get("self")
|
||||
}
|
||||
except JiraAuthError as e:
|
||||
logger.error(f"Jira authentication error: {e}")
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Jira issue: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to create Jira issue")
|
||||
|
||||
@router.post("/agent-config/{agent_id}")
|
||||
async def save_agent_jira_config(
|
||||
agent_id: str,
|
||||
config: AgentJiraConfigModel,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
current_user: User = Depends(require_permissions("manage_organization")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Save Jira configuration for an agent.
|
||||
"""
|
||||
# The agent must belong to the caller's org — otherwise any tenant could
|
||||
# flip another tenant's project_key / issue_type and hijack escalation.
|
||||
_require_jira_agent_in_org(db, agent_id, organization.id)
|
||||
|
||||
# Check if Jira is connected
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token and config.enabled:
|
||||
raise HTTPException(status_code=400, detail="Cannot enable Jira integration: Jira is not connected")
|
||||
|
||||
# Check if config already exists
|
||||
existing_config = db.query(AgentJiraConfig).filter(
|
||||
AgentJiraConfig.agent_id == agent_id
|
||||
).first()
|
||||
|
||||
if existing_config:
|
||||
# Update existing config
|
||||
existing_config.enabled = config.enabled
|
||||
existing_config.project_key = config.projectKey
|
||||
existing_config.issue_type_id = config.issueTypeId
|
||||
else:
|
||||
# Create new config
|
||||
new_config = AgentJiraConfig(
|
||||
agent_id=agent_id,
|
||||
enabled=config.enabled,
|
||||
project_key=config.projectKey,
|
||||
issue_type_id=config.issueTypeId
|
||||
)
|
||||
db.add(new_config)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": "Agent Jira configuration saved successfully"}
|
||||
|
||||
@router.get("/agent-config/{agent_id}")
|
||||
async def get_agent_jira_config(
|
||||
agent_id: str,
|
||||
organization: Organization = Depends(get_current_organization),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get Jira configuration for an agent.
|
||||
"""
|
||||
# The agent must belong to the caller's org before its config is exposed.
|
||||
_require_jira_agent_in_org(db, agent_id, organization.id)
|
||||
|
||||
config = db.query(AgentJiraConfig).filter(
|
||||
AgentJiraConfig.agent_id == agent_id
|
||||
).first()
|
||||
|
||||
if not config:
|
||||
return {
|
||||
"enabled": False,
|
||||
"projectKey": None,
|
||||
"issueTypeId": None
|
||||
}
|
||||
|
||||
return {
|
||||
"enabled": config.enabled,
|
||||
"projectKey": config.project_key,
|
||||
"issueTypeId": config.issue_type_id
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
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, Depends, HTTPException, status
|
||||
from typing import List
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import get_current_user, require_permissions
|
||||
from app.repositories.mcp_tool import MCPToolRepository
|
||||
from app.repositories.agent import AgentRepository
|
||||
|
||||
# Enterprise feature check
|
||||
try:
|
||||
from app.enterprise.repositories.plan import PlanRepository
|
||||
from app.enterprise.services.feature_access import require_accessible_subscription
|
||||
HAS_ENTERPRISE = True
|
||||
except ImportError:
|
||||
HAS_ENTERPRISE = False
|
||||
from app.models.schemas.mcp_tool import (
|
||||
MCPToolCreate, MCPToolUpdate, MCPToolResponse,
|
||||
MCPToolToAgentCreate, MCPToolToAgentResponse,
|
||||
AgentMCPToolsResponse, MCPToolTestResponse, MCPToolReferencesResponse,
|
||||
MCPTransportTypeEnum, unmask_secret_values
|
||||
)
|
||||
from app.core.outbound_policy import require_public_target
|
||||
from app.core.params import DbIntId
|
||||
from app.tools.mcp_manager import MCP_URL_LABEL, TEST_CONNECT_BUDGET_SECONDS, MCPToolsManager
|
||||
from app.core.config import settings
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import UUID
|
||||
|
||||
def require_stdio_enabled() -> None:
|
||||
"""Refuse a STDIO connector where running admin-typed commands is off."""
|
||||
if not settings.MCP_STDIO_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Local (STDIO) MCP servers are not available here. Connect a remote server over HTTP or SSE.",
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def check_mcp_feature_access(current_user: User, db: Session):
|
||||
"""Check if user has access to MCP tools feature"""
|
||||
if not HAS_ENTERPRISE:
|
||||
return # Allow access in non-enterprise mode
|
||||
|
||||
# Accessible = active/trial/past-due-in-period OR cancelled-but-still-in-
|
||||
# paid-period; raises 403 when the org has no accessible plan.
|
||||
subscription = require_accessible_subscription(db, current_user.organization_id)
|
||||
plan_repo = PlanRepository(db)
|
||||
|
||||
# Check if MCP tools feature is available in the plan
|
||||
if not plan_repo.check_feature_availability(str(subscription.plan_id), 'mcp_tools'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="MCP Tools feature is not available in your current plan. Please upgrade to access this feature."
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MCPToolResponse)
|
||||
async def create_mcp_tool(
|
||||
mcp_tool_data: MCPToolCreate,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new MCP tool"""
|
||||
try:
|
||||
# Check MCP tools feature access
|
||||
check_mcp_feature_access(current_user, db)
|
||||
if mcp_tool_data.transport_type == MCPTransportTypeEnum.STDIO:
|
||||
require_stdio_enabled()
|
||||
else:
|
||||
require_public_target(mcp_tool_data.url, MCP_URL_LABEL)
|
||||
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
|
||||
# Set organization_id from current user
|
||||
create_data = mcp_tool_data.model_dump()
|
||||
create_data['organization_id'] = current_user.organization_id
|
||||
|
||||
# Check if MCP tool with same name already exists
|
||||
existing = mcp_tool_repo.get_by_name(
|
||||
mcp_tool_data.name,
|
||||
current_user.organization_id
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="MCP tool with this name already exists"
|
||||
)
|
||||
|
||||
mcp_tool = mcp_tool_repo.create_mcp_tool(**create_data)
|
||||
return mcp_tool
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"MCP tool creation error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[MCPToolResponse])
|
||||
async def get_organization_mcp_tools(
|
||||
enabled_only: bool = True,
|
||||
current_user: User = Depends(require_permissions("view_all", "manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all MCP tools for the current user's organization"""
|
||||
try:
|
||||
# Check MCP tools feature access
|
||||
check_mcp_feature_access(current_user, db)
|
||||
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
mcp_tools = mcp_tool_repo.get_org_mcp_tools(
|
||||
current_user.organization_id,
|
||||
enabled_only=enabled_only
|
||||
)
|
||||
return mcp_tools
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching MCP tools: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{mcp_tool_id}", response_model=MCPToolResponse)
|
||||
async def get_mcp_tool(
|
||||
mcp_tool_id: DbIntId,
|
||||
current_user: User = Depends(require_permissions("view_all", "manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific MCP tool"""
|
||||
try:
|
||||
check_mcp_feature_access(current_user, db)
|
||||
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
||||
|
||||
if not mcp_tool:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="MCP tool not found"
|
||||
)
|
||||
|
||||
# Check if user has access to this MCP tool's organization
|
||||
if mcp_tool.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to access this MCP tool"
|
||||
)
|
||||
|
||||
return mcp_tool
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching MCP tool: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{mcp_tool_id}/test", response_model=MCPToolTestResponse)
|
||||
async def test_mcp_tool(
|
||||
mcp_tool_id: DbIntId,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Connect to a configured MCP tool once and report whether it comes up
|
||||
and which functions it exposes — surfaces environment problems (e.g. a
|
||||
missing npx) that otherwise only show as a silent 0-tool run."""
|
||||
try:
|
||||
check_mcp_feature_access(current_user, db)
|
||||
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
||||
|
||||
if not mcp_tool:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="MCP tool not found"
|
||||
)
|
||||
|
||||
if mcp_tool.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to access this MCP tool"
|
||||
)
|
||||
|
||||
# A request is waiting on this, so cap it no matter how generous the
|
||||
# tool's own timeout is — a hung server must not hold the worker and
|
||||
# its DB session for minutes.
|
||||
manager = MCPToolsManager(connect_budget=TEST_CONNECT_BUDGET_SECONDS)
|
||||
return await manager.test_tool_config(mcp_tool)
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"MCP tool test error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{mcp_tool_id}/references", response_model=MCPToolReferencesResponse)
|
||||
async def get_mcp_tool_references(
|
||||
mcp_tool_id: DbIntId,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""What currently points at this connector, so a delete confirmation can
|
||||
name what it is about to break. Deliberately ungated, like the delete it
|
||||
precedes."""
|
||||
try:
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
||||
|
||||
if not mcp_tool:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="MCP tool not found"
|
||||
)
|
||||
|
||||
if mcp_tool.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to access this MCP tool"
|
||||
)
|
||||
|
||||
return MCPToolReferencesResponse(
|
||||
agents=mcp_tool_repo.get_mcp_tool_agent_names(mcp_tool_id),
|
||||
used_in_investigations=mcp_tool_repo.is_used_in_investigations(
|
||||
mcp_tool_id, current_user.organization_id
|
||||
),
|
||||
)
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching MCP tool references: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{mcp_tool_id}", response_model=MCPToolResponse)
|
||||
async def update_mcp_tool(
|
||||
mcp_tool_id: DbIntId,
|
||||
update_data: MCPToolUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an existing MCP tool"""
|
||||
try:
|
||||
check_mcp_feature_access(current_user, db)
|
||||
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
||||
|
||||
if not mcp_tool:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="MCP tool not found"
|
||||
)
|
||||
|
||||
# Check if user has access to this MCP tool's organization
|
||||
if mcp_tool.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to update this MCP tool"
|
||||
)
|
||||
|
||||
# Update MCP tool with provided fields
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Secrets are masked on the way out, so a form that round-trips them
|
||||
# untouched must leave the stored credential alone.
|
||||
if 'env_vars' in update_dict:
|
||||
update_dict['env_vars'] = unmask_secret_values(
|
||||
update_dict['env_vars'], mcp_tool.env_vars
|
||||
)
|
||||
if 'headers' in update_dict:
|
||||
update_dict['headers'] = unmask_secret_values(
|
||||
update_dict['headers'], mcp_tool.headers
|
||||
)
|
||||
|
||||
# transport_type is editable, and MCPToolUpdate carries none of the
|
||||
# create-time validators, so check the state the update would leave
|
||||
# behind — otherwise a connector can be saved with a transport it has
|
||||
# no command or URL for and fails only mid-investigation.
|
||||
transport = update_dict.get('transport_type', mcp_tool.transport_type)
|
||||
transport = getattr(transport, 'value', transport)
|
||||
if transport == MCPTransportTypeEnum.STDIO.value:
|
||||
require_stdio_enabled()
|
||||
if not update_dict.get('command', mcp_tool.command):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Command is required for STDIO transport"
|
||||
)
|
||||
elif not update_dict.get('url', mcp_tool.url):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="URL is required for SSE/HTTP transport"
|
||||
)
|
||||
else:
|
||||
require_public_target(update_dict.get('url', mcp_tool.url), MCP_URL_LABEL)
|
||||
|
||||
# Check for name conflicts if name is being updated
|
||||
if 'name' in update_dict:
|
||||
existing = mcp_tool_repo.get_by_name(
|
||||
update_dict['name'],
|
||||
current_user.organization_id
|
||||
)
|
||||
if existing and existing.id != mcp_tool_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="MCP tool with this name already exists"
|
||||
)
|
||||
|
||||
updated_mcp_tool = mcp_tool_repo.update_mcp_tool(mcp_tool_id, **update_dict)
|
||||
return updated_mcp_tool
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"MCP tool update error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{mcp_tool_id}")
|
||||
async def delete_mcp_tool(
|
||||
mcp_tool_id: DbIntId,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete an MCP tool.
|
||||
|
||||
Deliberately not behind check_mcp_feature_access: an org whose plan no
|
||||
longer carries mcp_tools must still be able to remove its connectors,
|
||||
otherwise a downgrade would strand their credentials in env_vars for good.
|
||||
"""
|
||||
try:
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
||||
|
||||
if not mcp_tool:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="MCP tool not found"
|
||||
)
|
||||
|
||||
# Check if user has access to this MCP tool's organization
|
||||
if mcp_tool.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to delete this MCP tool"
|
||||
)
|
||||
|
||||
success = mcp_tool_repo.delete_mcp_tool(mcp_tool_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to delete MCP tool"
|
||||
)
|
||||
|
||||
return {"message": "MCP tool deleted successfully"}
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"MCP tool deletion error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# Agent-MCPTool association endpoints
|
||||
@router.post("/agent-association", response_model=MCPToolToAgentResponse)
|
||||
async def add_mcp_tool_to_agent(
|
||||
association_data: MCPToolToAgentCreate,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add MCP tool to agent"""
|
||||
try:
|
||||
# Check MCP tools feature access
|
||||
check_mcp_feature_access(current_user, db)
|
||||
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
agent_repo = AgentRepository(db)
|
||||
|
||||
# Verify MCP tool exists and belongs to user's organization
|
||||
mcp_tool = mcp_tool_repo.get_mcp_tool(association_data.mcp_tool_id)
|
||||
if not mcp_tool:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="MCP tool not found"
|
||||
)
|
||||
|
||||
if mcp_tool.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to access this MCP tool"
|
||||
)
|
||||
|
||||
# Verify agent exists and belongs to user's organization
|
||||
agent = agent_repo.get_agent(association_data.agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Agent not found"
|
||||
)
|
||||
|
||||
if agent.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to access this agent"
|
||||
)
|
||||
|
||||
# Create association
|
||||
association = mcp_tool_repo.add_mcp_tool_to_agent(
|
||||
association_data.mcp_tool_id,
|
||||
association_data.agent_id
|
||||
)
|
||||
|
||||
return association
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding MCP tool to agent: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/agent-association/{mcp_tool_id}/{agent_id}")
|
||||
async def remove_mcp_tool_from_agent(
|
||||
mcp_tool_id: DbIntId,
|
||||
agent_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove MCP tool from agent"""
|
||||
try:
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
agent_repo = AgentRepository(db)
|
||||
|
||||
# Verify MCP tool exists and belongs to user's organization
|
||||
mcp_tool = mcp_tool_repo.get_mcp_tool(mcp_tool_id)
|
||||
if not mcp_tool:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="MCP tool not found"
|
||||
)
|
||||
|
||||
if mcp_tool.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to access this MCP tool"
|
||||
)
|
||||
|
||||
# Verify agent exists and belongs to user's organization
|
||||
agent = agent_repo.get_agent(agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Agent not found"
|
||||
)
|
||||
|
||||
if agent.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to access this agent"
|
||||
)
|
||||
|
||||
# Remove association
|
||||
success = mcp_tool_repo.remove_mcp_tool_from_agent(mcp_tool_id, agent_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Association not found"
|
||||
)
|
||||
|
||||
return {"message": "MCP tool removed from agent successfully"}
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing MCP tool from agent: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/agent/{agent_id}", response_model=AgentMCPToolsResponse)
|
||||
async def get_agent_mcp_tools(
|
||||
agent_id: UUID,
|
||||
current_user: User = Depends(require_permissions("view_all", "manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all MCP tools associated with an agent"""
|
||||
try:
|
||||
mcp_tool_repo = MCPToolRepository(db)
|
||||
agent_repo = AgentRepository(db)
|
||||
|
||||
# Verify agent exists and belongs to user's organization
|
||||
agent = agent_repo.get_agent(agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Agent not found"
|
||||
)
|
||||
|
||||
if agent.organization_id != current_user.organization_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not authorized to access this agent"
|
||||
)
|
||||
|
||||
# Get agent's MCP tools
|
||||
mcp_tools = mcp_tool_repo.get_agent_mcp_tools(agent_id)
|
||||
|
||||
return AgentMCPToolsResponse(
|
||||
id=agent.id,
|
||||
name=agent.name,
|
||||
mcp_tools=mcp_tools
|
||||
)
|
||||
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching agent MCP tools: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
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, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import get_current_user
|
||||
from app.models.notification import Notification
|
||||
from app.models.schemas.notification import (
|
||||
NotificationResponse,
|
||||
NotificationSettingsOut,
|
||||
NotificationSettingsUpdate,
|
||||
)
|
||||
from app.repositories.notification_settings import UserNotificationSettingsRepository
|
||||
from app.core.logger import get_logger
|
||||
from app.services.user import send_fcm_notification
|
||||
from app.core.params import MAX_DB_INT, DbIntId
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.get("", response_model=List[NotificationResponse])
|
||||
async def list_notifications(
|
||||
skip: int = Query(0, ge=0, le=MAX_DB_INT),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's notifications with pagination"""
|
||||
try:
|
||||
notifications = db.query(Notification)\
|
||||
.filter(Notification.user_id == current_user.id)\
|
||||
.order_by(Notification.created_at.desc())\
|
||||
.offset(skip)\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
return notifications
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching notifications: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to fetch notifications"
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read")
|
||||
async def mark_as_read(
|
||||
notification_id: DbIntId,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark a notification as read"""
|
||||
try:
|
||||
notification = db.query(Notification)\
|
||||
.filter(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not notification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
notification.is_read = True
|
||||
db.commit()
|
||||
|
||||
return {"message": "Notification marked as read"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking notification as read: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update notification"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
async def mark_all_as_read(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark every unread notification as read, in one statement.
|
||||
|
||||
The client used to loop over the notifications it had fetched and PATCH each
|
||||
one. That page is 50 rows, so anyone with more unread than that could never
|
||||
clear the badge however often they tapped "Mark all read" — and it cost 50
|
||||
round trips to not fix it.
|
||||
"""
|
||||
try:
|
||||
updated = db.query(Notification)\
|
||||
.filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False
|
||||
).update({Notification.is_read: True}, synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Notifications marked as read", "updated": updated}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error marking all notifications as read: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update notifications"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
async def delete_notification(
|
||||
notification_id: DbIntId,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete one notification.
|
||||
|
||||
Scoped to the caller: another user's id must 404 rather than delete.
|
||||
"""
|
||||
try:
|
||||
notification = db.query(Notification)\
|
||||
.filter(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not notification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found"
|
||||
)
|
||||
|
||||
db.delete(notification)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Notification deleted"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting notification: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to delete notification"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("")
|
||||
async def clear_notifications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete all of the caller's notifications."""
|
||||
try:
|
||||
deleted = db.query(Notification)\
|
||||
.filter(Notification.user_id == current_user.id)\
|
||||
.delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Notifications cleared", "deleted": deleted}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error clearing notifications: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to clear notifications"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def get_unread_count(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get count of unread notifications"""
|
||||
try:
|
||||
count = db.query(Notification)\
|
||||
.filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False
|
||||
).count()
|
||||
return {"count": count}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting unread count: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get unread count"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=NotificationSettingsOut)
|
||||
async def get_notification_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get the current user's chat notification preferences"""
|
||||
try:
|
||||
return UserNotificationSettingsRepository(db).get_or_create(current_user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching notification settings: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to fetch notification settings"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=NotificationSettingsOut)
|
||||
async def update_notification_settings(
|
||||
payload: NotificationSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update the current user's chat notification preferences.
|
||||
|
||||
Always scoped to the caller — there is no way to address another user's
|
||||
settings, so no permission check beyond authentication is needed.
|
||||
"""
|
||||
try:
|
||||
settings = UserNotificationSettingsRepository(db).get_or_create(current_user.id)
|
||||
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating notification settings: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update notification settings"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def send_test_notification(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Send a test notification to the current user"""
|
||||
try:
|
||||
# Create test notification
|
||||
notification = Notification(
|
||||
user_id=current_user.id,
|
||||
type="CHAT",
|
||||
title="Test Notification",
|
||||
message="This is a test notification from ReplyPier",
|
||||
notification_metadata={"test": True}
|
||||
)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
# Send FCM notification
|
||||
await send_fcm_notification(current_user.id, notification, db)
|
||||
|
||||
return {"message": "Test notification sent successfully"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending test notification: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to send test notification"
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
CRUD + connection testing for guardrailed ticket DB connectors. Credentials
|
||||
are encrypted at rest and never returned by any endpoint.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.core.outbound_policy import require_public_target
|
||||
from app.core.security import encrypt_api_key
|
||||
from app.database import get_db
|
||||
from app.models.schemas.ticket_db_connector import (
|
||||
DBConnectorCreate,
|
||||
DBConnectorDiscoverRequest,
|
||||
DBConnectorDiscoverResponse,
|
||||
DBConnectorOut,
|
||||
DBConnectorUpdate,
|
||||
)
|
||||
from app.models.ticket_db_connector import TicketDBConnector
|
||||
from app.models.user import User
|
||||
from app.api.tickets import require_any_permission
|
||||
from app.services.db_connector_service import DBConnectorConfig, first_hop, test_connection
|
||||
from app.services.ticket_access import check_ticketing_access
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
manage_connectors = require_any_permission("manage_ticket_connectors", "manage_organization")
|
||||
|
||||
|
||||
# Map API secret field -> encrypted model column. Omitting a secret on update
|
||||
# keeps the stored value; the plaintext never round-trips to the client.
|
||||
_SSH_SECRET_COLUMNS = {
|
||||
"ssh_password": "encrypted_ssh_password",
|
||||
"ssh_private_key": "encrypted_ssh_private_key",
|
||||
"ssh_private_key_passphrase": "encrypted_ssh_key_passphrase",
|
||||
}
|
||||
|
||||
|
||||
def _config_from_discover(payload, organization_id) -> DBConnectorConfig:
|
||||
return DBConnectorConfig(
|
||||
id=None,
|
||||
organization_id=organization_id,
|
||||
name="draft",
|
||||
engine=payload.engine,
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
database=payload.database,
|
||||
username=payload.username,
|
||||
password=payload.password,
|
||||
ssh_enabled=payload.ssh_enabled,
|
||||
ssh_host=payload.ssh_host,
|
||||
ssh_port=payload.ssh_port,
|
||||
ssh_username=payload.ssh_username,
|
||||
ssh_password=payload.ssh_password,
|
||||
ssh_private_key=payload.ssh_private_key,
|
||||
ssh_private_key_passphrase=payload.ssh_private_key_passphrase,
|
||||
)
|
||||
|
||||
|
||||
def _get_connector_or_404(db: Session, connector_id: UUID, user: User) -> TicketDBConnector:
|
||||
connector = (
|
||||
db.query(TicketDBConnector)
|
||||
.filter(
|
||||
TicketDBConnector.id == connector_id,
|
||||
TicketDBConnector.organization_id == user.organization_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if connector is None:
|
||||
raise HTTPException(status_code=404, detail="Connector not found")
|
||||
return connector
|
||||
|
||||
|
||||
async def _discover(config: DBConnectorConfig) -> DBConnectorDiscoverResponse:
|
||||
try:
|
||||
result = await asyncio.to_thread(test_connection, config)
|
||||
return DBConnectorDiscoverResponse(ok=True, tables=result["tables"])
|
||||
except Exception as e:
|
||||
return DBConnectorDiscoverResponse(ok=False, error=str(e)[:500])
|
||||
|
||||
|
||||
@router.get("", response_model=List[DBConnectorOut])
|
||||
async def list_connectors(
|
||||
current_user: User = Depends(manage_connectors),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
return (
|
||||
db.query(TicketDBConnector)
|
||||
.filter(TicketDBConnector.organization_id == current_user.organization_id)
|
||||
.order_by(TicketDBConnector.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/discover", response_model=DBConnectorDiscoverResponse)
|
||||
async def discover_tables(
|
||||
payload: DBConnectorDiscoverRequest,
|
||||
current_user: User = Depends(manage_connectors),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Pre-save connection test: connect with the given credentials and
|
||||
return the discoverable tables/columns for the allowlist picker."""
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
require_public_target(*first_hop(payload.ssh_enabled, payload.ssh_host, payload.host))
|
||||
return await _discover(_config_from_discover(payload, current_user.organization_id))
|
||||
|
||||
|
||||
@router.post("", response_model=DBConnectorOut, status_code=201)
|
||||
async def create_connector(
|
||||
payload: DBConnectorCreate,
|
||||
current_user: User = Depends(manage_connectors),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
require_public_target(*first_hop(payload.ssh_enabled, payload.ssh_host, payload.host))
|
||||
connector = TicketDBConnector(
|
||||
organization_id=current_user.organization_id,
|
||||
name=payload.name,
|
||||
engine=payload.engine,
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
database=payload.database,
|
||||
username=payload.username,
|
||||
encrypted_password=encrypt_api_key(payload.password),
|
||||
enabled=payload.enabled,
|
||||
allowed_tables=payload.allowed_tables,
|
||||
masked_columns=payload.masked_columns,
|
||||
row_scope=payload.row_scope,
|
||||
row_scope_key=payload.row_scope_key,
|
||||
max_rows=payload.max_rows,
|
||||
statement_timeout_ms=payload.statement_timeout_ms,
|
||||
ssh_enabled=payload.ssh_enabled,
|
||||
ssh_host=payload.ssh_host,
|
||||
ssh_port=payload.ssh_port,
|
||||
ssh_username=payload.ssh_username,
|
||||
)
|
||||
for field_name, column in _SSH_SECRET_COLUMNS.items():
|
||||
value = getattr(payload, field_name, None)
|
||||
if value:
|
||||
setattr(connector, column, encrypt_api_key(value))
|
||||
db.add(connector)
|
||||
db.commit()
|
||||
db.refresh(connector)
|
||||
return connector
|
||||
|
||||
|
||||
@router.patch("/{connector_id}", response_model=DBConnectorOut)
|
||||
async def update_connector(
|
||||
connector_id: UUID,
|
||||
payload: DBConnectorUpdate,
|
||||
current_user: User = Depends(manage_connectors),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
connector = _get_connector_or_404(db, connector_id, current_user)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
require_public_target(*first_hop(
|
||||
data.get("ssh_enabled", connector.ssh_enabled),
|
||||
data.get("ssh_host", connector.ssh_host),
|
||||
data.get("host", connector.host),
|
||||
))
|
||||
password = data.pop("password", None)
|
||||
if password:
|
||||
connector.encrypted_password = encrypt_api_key(password)
|
||||
# SSH secrets map to encrypted_* columns; omitting one keeps the stored value.
|
||||
for field_name, column in _SSH_SECRET_COLUMNS.items():
|
||||
if field_name in data:
|
||||
value = data.pop(field_name)
|
||||
if value:
|
||||
setattr(connector, column, encrypt_api_key(value))
|
||||
for key, value in data.items():
|
||||
setattr(connector, key, value)
|
||||
db.commit()
|
||||
db.refresh(connector)
|
||||
return connector
|
||||
|
||||
|
||||
@router.delete("/{connector_id}", status_code=204)
|
||||
async def delete_connector(
|
||||
connector_id: UUID,
|
||||
current_user: User = Depends(manage_connectors),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
connector = _get_connector_or_404(db, connector_id, current_user)
|
||||
db.delete(connector)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{connector_id}/test", response_model=DBConnectorDiscoverResponse)
|
||||
async def test_saved_connector(
|
||||
connector_id: UUID,
|
||||
current_user: User = Depends(manage_connectors),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Test a saved connector with its stored credentials; refreshes the
|
||||
discoverable table list and records the outcome."""
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
connector = _get_connector_or_404(db, connector_id, current_user)
|
||||
result = await _discover(DBConnectorConfig.from_model(connector))
|
||||
connector.last_test_at = datetime.now(timezone.utc)
|
||||
connector.last_test_ok = result.ok
|
||||
db.commit()
|
||||
return result
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Alert-webhook intake: Grafana/Datadog/CloudWatch (or any) alerts POST here
|
||||
and become tickets (source=api) — investigated proactively, before a customer
|
||||
reports the issue. Authenticated by a per-org URL secret; near-duplicate open
|
||||
alerts are appended to the existing ticket instead of opening a new one.
|
||||
"""
|
||||
|
||||
import secrets as py_secrets
|
||||
from typing import Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.ticket import TicketPriority, TicketSource
|
||||
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
||||
from app.repositories.ticket import TicketSettingsRepository
|
||||
from app.services.ticket import TicketService, embed_ticket_text
|
||||
from app.services.ticket_access import ticketing_allowed
|
||||
from app.services.ticket_events import emit_ticket_update
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# An open ticket at least this similar means the alert is a re-fire.
|
||||
ALERT_DEDUP_SIMILARITY = 0.95
|
||||
|
||||
_SEVERITY_TO_PRIORITY = {
|
||||
"critical": TicketPriority.URGENT,
|
||||
"disaster": TicketPriority.URGENT,
|
||||
"error": TicketPriority.HIGH,
|
||||
"warning": TicketPriority.MEDIUM,
|
||||
"info": TicketPriority.LOW,
|
||||
}
|
||||
|
||||
|
||||
def _parse_alert(payload: dict) -> Tuple[str, str, Optional[str]]:
|
||||
"""Best-effort extraction across alerting platforms (Grafana webhook,
|
||||
Alertmanager, Datadog, generic {title, description, severity})."""
|
||||
title = (
|
||||
payload.get("title")
|
||||
or payload.get("ruleName")
|
||||
or payload.get("alert_name")
|
||||
or payload.get("commonAnnotations", {}).get("summary")
|
||||
or payload.get("commonLabels", {}).get("alertname")
|
||||
or "Infrastructure alert"
|
||||
)
|
||||
description = (
|
||||
payload.get("description")
|
||||
or payload.get("message")
|
||||
or payload.get("body")
|
||||
or payload.get("commonAnnotations", {}).get("description")
|
||||
or ""
|
||||
)
|
||||
if not isinstance(description, str):
|
||||
description = str(description)
|
||||
severity = (
|
||||
payload.get("severity")
|
||||
or payload.get("priority")
|
||||
or payload.get("commonLabels", {}).get("severity")
|
||||
)
|
||||
return str(title)[:500], description[:20000], (str(severity).lower() if severity else None)
|
||||
|
||||
|
||||
@router.post("/alerts/{org_id}/{secret}", status_code=202)
|
||||
async def alert_intake(
|
||||
org_id: UUID,
|
||||
secret: str,
|
||||
payload: dict = Body(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
settings_row = TicketSettingsRepository(db).get(org_id)
|
||||
# Uniform 404 for unknown org / disabled intake / wrong secret — the
|
||||
# endpoint must not confirm which part failed.
|
||||
if (
|
||||
settings_row is None
|
||||
or not settings_row.alert_webhook_enabled
|
||||
or not settings_row.alert_webhook_secret
|
||||
or not py_secrets.compare_digest(settings_row.alert_webhook_secret, secret)
|
||||
or not ticketing_allowed(db, org_id)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
title, description, severity = _parse_alert(payload)
|
||||
priority = _SEVERITY_TO_PRIORITY.get(severity or "", TicketPriority.HIGH)
|
||||
service = TicketService(db)
|
||||
|
||||
# Re-fired alert → append to the open ticket instead of a duplicate.
|
||||
embedding = await run_in_threadpool(embed_ticket_text, f"[ALERT] {title}", description)
|
||||
if embedding is not None:
|
||||
duplicates = service.repo.find_similar(
|
||||
org_id, embedding, limit=1, only_open=True,
|
||||
min_similarity=ALERT_DEDUP_SIMILARITY,
|
||||
)
|
||||
if duplicates:
|
||||
existing, similarity = duplicates[0]
|
||||
service._add_activity(
|
||||
existing,
|
||||
TicketActivityType.COMMENT,
|
||||
actor_type=TicketActorType.SYSTEM,
|
||||
body=f"Alert fired again: {title}",
|
||||
metadata={"source": "alert_webhook", "similarity": round(similarity, 3)},
|
||||
)
|
||||
db.commit()
|
||||
await emit_ticket_update(org_id, existing.id, "comment")
|
||||
return {"deduplicated": True, "ticket": existing.display_number}
|
||||
|
||||
try:
|
||||
ticket, _duplicates = await run_in_threadpool(
|
||||
service.create_ticket,
|
||||
organization_id=org_id,
|
||||
title=f"[ALERT] {title}",
|
||||
description=description or None,
|
||||
priority=priority,
|
||||
source=TicketSource.API,
|
||||
)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Alert intake failed for org {org_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to create ticket")
|
||||
|
||||
await emit_ticket_update(org_id, ticket.id, "created")
|
||||
return {"deduplicated": False, "ticket": ticket.display_number}
|
||||
@@ -0,0 +1,746 @@
|
||||
"""
|
||||
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 datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import check_permissions, get_current_user
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.investigation import InvestigationRunType, InvestigationTrigger
|
||||
from app.models.schemas.pagination import Pagination
|
||||
from app.models.schemas.ticket import (
|
||||
HypothesisOut,
|
||||
InvestigateRequest,
|
||||
InvestigationDetailResponse,
|
||||
InvestigationEventOut,
|
||||
InvestigationRunOut,
|
||||
ProposalOut,
|
||||
ProposalRejectRequest,
|
||||
RCADocumentOut,
|
||||
RcaUpdate,
|
||||
TicketCommentCreate,
|
||||
TicketCreate,
|
||||
TicketDetailResponse,
|
||||
TicketListItem,
|
||||
TicketListResponse,
|
||||
TicketOut,
|
||||
TicketReopen,
|
||||
TicketResolve,
|
||||
TicketSettingsOut,
|
||||
TicketSettingsUpdate,
|
||||
TicketStats,
|
||||
TicketUpdate,
|
||||
TicketActivityOut,
|
||||
)
|
||||
from app.models.ticket import Ticket, TicketSource, TicketStatus
|
||||
from app.models.ticket_activity import TicketActorType
|
||||
from app.models.user import User
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.services.ticket import TicketService
|
||||
from app.services.ticket_access import check_ticketing_access
|
||||
from app.services.ticket_events import emit_ticket_update
|
||||
from app.core.params import PageNumber
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
# A ticket the team has already settled. It can be reopened, but nothing may
|
||||
# push it forward — notably no new AI run, which would otherwise churn a closed
|
||||
# ticket and re-notify the customer. Mirrored by isReopenable in
|
||||
# TicketDetailView.vue, which disables the Investigate button on these.
|
||||
SETTLED_STATUSES = (
|
||||
TicketStatus.RESOLVED.value,
|
||||
TicketStatus.CLOSED.value,
|
||||
TicketStatus.RESOLVED_PENDING_CONFIRMATION.value,
|
||||
)
|
||||
|
||||
|
||||
def require_any_permission(*permissions: str):
|
||||
"""Any-of permission dependency (require_permissions is all-of)."""
|
||||
async def checker(current_user: User = Depends(get_current_user)) -> User:
|
||||
for permission in permissions:
|
||||
if check_permissions(current_user, [permission]):
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
|
||||
)
|
||||
return checker
|
||||
|
||||
|
||||
view_tickets = require_any_permission("view_tickets", "manage_tickets")
|
||||
manage_tickets = require_any_permission("manage_tickets")
|
||||
# Approving/rejecting AI-proposed resolutions is a separate trust boundary
|
||||
# from day-to-day ticket management.
|
||||
approve_actions = require_any_permission("approve_ticket_actions")
|
||||
|
||||
|
||||
def _service(db: Session) -> TicketService:
|
||||
return TicketService(db)
|
||||
|
||||
|
||||
def _list_item(service: TicketService, ticket: Ticket, has_active_run: bool, settings=None) -> TicketListItem:
|
||||
return TicketListItem(
|
||||
id=ticket.id,
|
||||
ticket_number=ticket.ticket_number,
|
||||
display_number=ticket.display_number,
|
||||
title=ticket.title,
|
||||
status=ticket.status,
|
||||
priority=ticket.priority,
|
||||
tags=ticket.tags,
|
||||
assignee_user_id=ticket.assignee_user_id,
|
||||
assignee_name=ticket.assignee.full_name if ticket.assignee else None,
|
||||
ai_state=service.ai_state(ticket, has_active_run=has_active_run),
|
||||
sla_due_at=service.sla_due_at(ticket, settings),
|
||||
resolved_at=ticket.resolved_at,
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _ticket_out(service: TicketService, ticket: Ticket) -> TicketOut:
|
||||
out = TicketOut.model_validate(ticket)
|
||||
out.display_number = ticket.display_number
|
||||
out.ai_state = service.ai_state(ticket)
|
||||
out.sla_due_at = service.sla_due_at(ticket)
|
||||
return out
|
||||
|
||||
|
||||
def _activity_out(activity) -> TicketActivityOut:
|
||||
out = TicketActivityOut.model_validate(activity)
|
||||
if activity.actor_user is not None:
|
||||
out.actor_name = activity.actor_user.full_name
|
||||
elif str(activity.actor_type) == TicketActorType.AI.value:
|
||||
out.actor_name = "ReplyPier AI"
|
||||
return out
|
||||
|
||||
|
||||
def _get_ticket_or_404(
|
||||
db: Session, ticket_id: UUID, user: User, for_update: bool = False
|
||||
) -> Ticket:
|
||||
from app.repositories.ticket import TicketRepository
|
||||
ticket = TicketRepository(db).get_by_id(
|
||||
ticket_id, user.organization_id, for_update=for_update
|
||||
)
|
||||
if ticket is None:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
return ticket
|
||||
|
||||
|
||||
@router.get("", response_model=TicketListResponse)
|
||||
async def list_tickets(
|
||||
status_filter: Optional[str] = Query(default=None, alias="status"),
|
||||
priority: Optional[str] = None,
|
||||
assignee_id: Optional[UUID] = None,
|
||||
unassigned: bool = False,
|
||||
ai_state: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
sort: str = "updated",
|
||||
page: PageNumber = 1,
|
||||
page_size: int = Query(default=25, ge=1, le=MAX_PAGE_SIZE),
|
||||
current_user: User = Depends(view_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
statuses = [s.strip() for s in status_filter.split(",") if s.strip()] if status_filter else None
|
||||
tickets, total = service.repo.list(
|
||||
current_user.organization_id,
|
||||
status=statuses,
|
||||
priority=priority,
|
||||
assignee_user_id=assignee_id,
|
||||
unassigned=unassigned,
|
||||
ai_state=ai_state,
|
||||
search=search,
|
||||
sort=sort,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
settings = service.settings_repo.get_or_create(current_user.organization_id)
|
||||
active_ids = service.repo.active_run_ticket_ids([t.id for t in tickets])
|
||||
items = [_list_item(service, t, t.id in active_ids, settings) for t in tickets]
|
||||
return TicketListResponse(
|
||||
tickets=items, pagination=Pagination.build(total, page, page_size)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=TicketStats)
|
||||
async def ticket_stats(
|
||||
current_user: User = Depends(view_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
return TicketStats(**_service(db).stats(current_user.organization_id))
|
||||
|
||||
|
||||
@router.get("/settings", response_model=TicketSettingsOut)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(require_any_permission("manage_organization", "manage_tickets")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
from app.repositories.ticket import TicketSettingsRepository
|
||||
settings = TicketSettingsRepository(db).get_or_create(current_user.organization_id)
|
||||
out = TicketSettingsOut.model_validate(settings)
|
||||
# The alert-webhook secret is the sole credential for the intake endpoint —
|
||||
# only settings managers (who can rotate it) may read it back.
|
||||
if not check_permissions(current_user, ["manage_organization"]):
|
||||
out.alert_webhook_secret = None
|
||||
return out
|
||||
|
||||
|
||||
@router.put("/settings", response_model=TicketSettingsOut)
|
||||
async def update_settings(
|
||||
payload: TicketSettingsUpdate,
|
||||
current_user: User = Depends(require_any_permission("manage_organization")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
from app.repositories.ticket import TicketSettingsRepository
|
||||
settings = TicketSettingsRepository(db).get_or_create(current_user.organization_id)
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(settings, key, value.value if hasattr(value, "value") else value)
|
||||
# First enable mints the intake secret (re-enabling keeps it stable).
|
||||
if settings.alert_webhook_enabled and not settings.alert_webhook_secret:
|
||||
import secrets as py_secrets
|
||||
settings.alert_webhook_secret = py_secrets.token_urlsafe(24)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
@router.get("/by-session/{session_id}", response_model=Optional[TicketOut])
|
||||
async def get_ticket_by_session(
|
||||
session_id: UUID,
|
||||
current_user: User = Depends(view_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = service.repo.get_by_session(session_id)
|
||||
if ticket is None or ticket.organization_id != current_user.organization_id:
|
||||
return None
|
||||
return _ticket_out(service, ticket)
|
||||
|
||||
|
||||
@router.get("/draft-from-session/{session_id}")
|
||||
async def draft_from_session(
|
||||
session_id: UUID,
|
||||
current_user: User = Depends(view_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Non-LLM prefill for the create-ticket modal: title from the customer's
|
||||
last message, description from the recent transcript. AI triage rewrites
|
||||
the title after creation."""
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
# Cross-org guard: the transcript is customer data — the session must
|
||||
# belong to the caller's organization.
|
||||
session_record = SessionToAgentRepository(db).get_session(session_id)
|
||||
if session_record is None or str(session_record.organization_id) != str(current_user.organization_id):
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
service = _service(db)
|
||||
messages = (await ChatRepository(db).get_session_history(session_id))[-20:]
|
||||
customer_messages = [m for m in messages if m.message_type == "user"]
|
||||
title = ""
|
||||
if customer_messages:
|
||||
title = (customer_messages[-1].message or "").strip().split("\n")[0][:200]
|
||||
transcript = await service.build_session_transcript(session_id, max_messages=10, line_chars=300)
|
||||
return {
|
||||
"title": title or "Support issue from conversation",
|
||||
"description": (transcript or "")[:5000],
|
||||
}
|
||||
|
||||
|
||||
def _validate_assignment_references(db: Session, org_id, assignee_user_id, group_id) -> None:
|
||||
"""A ticket may only be assigned to a user or group in the caller's org.
|
||||
|
||||
Both create and update run this, so a ticket (and its assignment activity
|
||||
log) can never point at a user or group from another tenant.
|
||||
"""
|
||||
if assignee_user_id is not None:
|
||||
assignee = db.query(User).filter(User.id == assignee_user_id).first()
|
||||
if assignee is None or str(assignee.organization_id) != str(org_id):
|
||||
raise HTTPException(status_code=404, detail="Assignee not found")
|
||||
if group_id is not None:
|
||||
from app.models.user import UserGroup
|
||||
group = db.query(UserGroup).filter(UserGroup.id == group_id).first()
|
||||
if group is None or str(group.organization_id) != str(org_id):
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
|
||||
def _validate_create_references(db: Session, payload: TicketCreate, org_id) -> None:
|
||||
"""Every referenced entity must belong to the caller's organization."""
|
||||
if payload.customer_id is not None:
|
||||
from app.models.customer import Customer
|
||||
customer = db.query(Customer).filter(Customer.id == payload.customer_id).first()
|
||||
if customer is None or str(customer.organization_id) != str(org_id):
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
_validate_assignment_references(
|
||||
db, org_id, payload.assignee_user_id, payload.group_id)
|
||||
|
||||
|
||||
@router.post("", response_model=TicketDetailResponse, status_code=201)
|
||||
async def create_ticket(
|
||||
payload: TicketCreate,
|
||||
current_user: User = Depends(manage_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
_validate_create_references(db, payload, current_user.organization_id)
|
||||
service = _service(db)
|
||||
settings = service.settings_repo.get_or_create(current_user.organization_id)
|
||||
try:
|
||||
# Threadpool: create_ticket embeds the title/description with a local
|
||||
# CPU-bound model — must not block the event loop.
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
ticket, duplicates = await run_in_threadpool(
|
||||
service.create_ticket,
|
||||
organization_id=current_user.organization_id,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
priority=payload.priority,
|
||||
severity=payload.severity,
|
||||
tags=payload.tags,
|
||||
source=TicketSource.HUMAN_AGENT if payload.session_id else TicketSource.MANUAL,
|
||||
customer_id=payload.customer_id,
|
||||
customer_email=payload.customer_email,
|
||||
customer_name=payload.customer_name,
|
||||
session_id=payload.session_id,
|
||||
assignee_user_id=payload.assignee_user_id,
|
||||
group_id=payload.group_id,
|
||||
created_by_user_id=current_user.id,
|
||||
)
|
||||
db.commit()
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Ticket creation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to create ticket")
|
||||
|
||||
db.refresh(ticket)
|
||||
await service.notify_customer_created(ticket, settings)
|
||||
db.commit()
|
||||
await emit_ticket_update(current_user.organization_id, ticket.id, "created")
|
||||
return TicketDetailResponse(
|
||||
ticket=_ticket_out(service, ticket),
|
||||
activities=[_activity_out(a) for a in service.activity_repo.list_for_ticket(ticket.id)],
|
||||
runs=[InvestigationRunOut.model_validate(r) for r in service.run_repo.list_for_ticket(ticket.id)],
|
||||
linked_session_ids=service.repo.get_session_ids(ticket.id),
|
||||
possible_duplicates=[
|
||||
_list_item(service, t, False, settings) for t, _score in duplicates
|
||||
],
|
||||
can_notify_customer=service.can_notify_customer(ticket),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketDetailResponse)
|
||||
async def get_ticket(
|
||||
ticket_id: UUID,
|
||||
current_user: User = Depends(view_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
return TicketDetailResponse(
|
||||
ticket=_ticket_out(service, ticket),
|
||||
activities=[_activity_out(a) for a in service.activity_repo.list_for_ticket(ticket.id)],
|
||||
runs=[InvestigationRunOut.model_validate(r) for r in service.run_repo.list_for_ticket(ticket.id)],
|
||||
linked_session_ids=service.repo.get_session_ids(ticket.id),
|
||||
can_notify_customer=service.can_notify_customer(ticket),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{ticket_id}", response_model=TicketOut)
|
||||
async def update_ticket(
|
||||
ticket_id: UUID,
|
||||
payload: TicketUpdate,
|
||||
current_user: User = Depends(manage_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
try:
|
||||
if "status" in data:
|
||||
service.transition_status(
|
||||
ticket, data.pop("status"),
|
||||
actor_type=TicketActorType.USER, actor_user_id=current_user.id,
|
||||
)
|
||||
if "priority" in data:
|
||||
service.set_priority(
|
||||
ticket, data.pop("priority"),
|
||||
actor_type=TicketActorType.USER, actor_user_id=current_user.id,
|
||||
)
|
||||
if "assignee_user_id" in data or "group_id" in data:
|
||||
new_assignee = data.pop("assignee_user_id", ticket.assignee_user_id)
|
||||
new_group = data.pop("group_id", ticket.group_id)
|
||||
# Same org guard as create — the update path bypassed it entirely.
|
||||
_validate_assignment_references(
|
||||
db, current_user.organization_id, new_assignee, new_group)
|
||||
service.assign(
|
||||
ticket,
|
||||
new_assignee,
|
||||
new_group,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
customer_email = data.pop("customer_email", None)
|
||||
customer_name = data.pop("customer_name", None)
|
||||
if customer_email:
|
||||
service.set_customer(
|
||||
ticket, customer_email, customer_name, actor_user_id=current_user.id
|
||||
)
|
||||
for key, value in data.items():
|
||||
setattr(ticket, key, value.value if hasattr(value, "value") else value)
|
||||
from app.services.ticket_jira import maybe_escalate_to_jira
|
||||
await maybe_escalate_to_jira(
|
||||
db, service, ticket, service.settings_repo.get_or_create(current_user.organization_id)
|
||||
)
|
||||
db.commit()
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Ticket update failed: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to update ticket")
|
||||
db.refresh(ticket)
|
||||
await emit_ticket_update(
|
||||
current_user.organization_id, ticket.id, "updated",
|
||||
{"status": str(ticket.status), "priority": str(ticket.priority)},
|
||||
)
|
||||
return _ticket_out(service, ticket)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/comments", response_model=TicketActivityOut, status_code=201)
|
||||
async def add_comment(
|
||||
ticket_id: UUID,
|
||||
payload: TicketCommentCreate,
|
||||
current_user: User = Depends(view_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
# Customer-visible replies are a customer-facing action, not a read:
|
||||
# view-only roles may add internal notes but never message customers.
|
||||
if not payload.is_internal and not check_permissions(current_user, ["manage_tickets"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Sending customer-visible replies requires manage_tickets",
|
||||
)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
activity = service.add_comment(
|
||||
ticket,
|
||||
payload.body,
|
||||
is_internal=payload.is_internal,
|
||||
actor_type=TicketActorType.USER,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
if not payload.is_internal:
|
||||
# The comment row above is the timeline entry; skip the extra
|
||||
# CUSTOMER_NOTIFIED activity so the reply isn't duplicated.
|
||||
await service.send_customer_message(ticket, payload.body, record_activity=False)
|
||||
db.commit()
|
||||
db.refresh(activity)
|
||||
await emit_ticket_update(current_user.organization_id, ticket.id, "comment")
|
||||
return _activity_out(activity)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/resolve", response_model=TicketOut)
|
||||
async def resolve_ticket(
|
||||
ticket_id: UUID,
|
||||
payload: TicketResolve,
|
||||
current_user: User = Depends(manage_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
# Lock the row so a double-submitted resolve serializes: the second request
|
||||
# blocks until the first commits, then sees RESOLVED_PENDING_CONFIRMATION
|
||||
# and skips re-notifying the customer.
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user, for_update=True)
|
||||
try:
|
||||
await service.resolve(
|
||||
ticket,
|
||||
outcome=payload.outcome,
|
||||
resolution_summary=payload.resolution_summary,
|
||||
customer_message=payload.customer_message,
|
||||
actor_type=TicketActorType.USER,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
db.commit()
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
db.refresh(ticket)
|
||||
await emit_ticket_update(
|
||||
current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)}
|
||||
)
|
||||
return _ticket_out(service, ticket)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/reopen", response_model=TicketOut)
|
||||
async def reopen_ticket(
|
||||
ticket_id: UUID,
|
||||
payload: TicketReopen,
|
||||
current_user: User = Depends(manage_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
if str(ticket.status) not in SETTLED_STATUSES:
|
||||
raise HTTPException(status_code=400, detail="Only resolved or closed tickets can be reopened")
|
||||
service.reopen(
|
||||
ticket, reason=payload.reason,
|
||||
actor_type=TicketActorType.USER, actor_user_id=current_user.id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(ticket)
|
||||
await emit_ticket_update(
|
||||
current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)}
|
||||
)
|
||||
return _ticket_out(service, ticket)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/investigate", response_model=InvestigationRunOut, status_code=201)
|
||||
async def investigate_ticket(
|
||||
ticket_id: UUID,
|
||||
payload: Optional[InvestigateRequest] = None,
|
||||
current_user: User = Depends(manage_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Manually (re-)enqueue an AI run — a full investigation by default."""
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
if str(ticket.status) in SETTLED_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This ticket is resolved — reopen it before running another investigation",
|
||||
)
|
||||
payload = payload or InvestigateRequest()
|
||||
# Distinguish "out of credits" (402, actionable — upgrade) from the generic
|
||||
# 409 below, for a human clicking Investigate.
|
||||
if payload.run_type == InvestigationRunType.INVESTIGATION and service._investigation_over_budget(ticket):
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=(
|
||||
"Not enough message credits left in your plan to run an AI "
|
||||
"investigation. Upgrade your plan or switch to your own AI model key."
|
||||
),
|
||||
)
|
||||
run = service.enqueue_run(
|
||||
ticket,
|
||||
run_type=payload.run_type,
|
||||
trigger=InvestigationTrigger.MANUAL,
|
||||
requested_by_user_id=current_user.id,
|
||||
context_note=payload.context_note,
|
||||
)
|
||||
if run is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="An AI run is already active for this ticket, or its run limit was reached",
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(run)
|
||||
await emit_ticket_update(current_user.organization_id, ticket.id, "run", {"status": "pending"})
|
||||
return InvestigationRunOut.model_validate(run)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/investigation", response_model=InvestigationDetailResponse)
|
||||
async def get_investigation(
|
||||
ticket_id: UUID,
|
||||
run_id: Optional[UUID] = None,
|
||||
current_user: User = Depends(view_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Glass-box payload: the latest (or requested) investigation run with its
|
||||
hypotheses, evidence events and the ticket's current RCA document."""
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
|
||||
run = None
|
||||
if run_id is not None:
|
||||
run = service.run_repo.get_by_id(run_id)
|
||||
# The run must belong to this ticket — run_id alone is not an
|
||||
# authorization boundary.
|
||||
if run is None or run.ticket_id != ticket.id:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
else:
|
||||
run = service.run_repo.latest_run_of_type(
|
||||
ticket.id, InvestigationRunType.INVESTIGATION.value
|
||||
)
|
||||
|
||||
rca = service.run_repo.latest_rca(ticket.id)
|
||||
return InvestigationDetailResponse(
|
||||
run=InvestigationRunOut.model_validate(run) if run else None,
|
||||
hypotheses=[
|
||||
HypothesisOut.model_validate(h) for h in service.run_repo.list_hypotheses(run.id)
|
||||
] if run else [],
|
||||
events=[
|
||||
InvestigationEventOut.model_validate(e) for e in service.run_repo.list_events(run.id)
|
||||
] if run else [],
|
||||
rca=_rca_out(rca),
|
||||
proposal=_proposal_out(service.latest_proposal(ticket.id)),
|
||||
)
|
||||
|
||||
|
||||
def _proposal_out(proposal) -> Optional[ProposalOut]:
|
||||
if proposal is None:
|
||||
return None
|
||||
out = ProposalOut.model_validate(proposal)
|
||||
if proposal.decided_by is not None:
|
||||
out.decided_by_name = proposal.decided_by.full_name
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/proposal/approve", response_model=ProposalOut)
|
||||
async def approve_proposal(
|
||||
ticket_id: UUID,
|
||||
current_user: User = Depends(approve_actions),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Approve the pending AI-proposed resolution: the ticket resolves and
|
||||
the customer is notified with the proposal's message."""
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
proposal = service.pending_proposal(ticket.id)
|
||||
if proposal is None:
|
||||
raise HTTPException(status_code=404, detail="No pending proposal for this ticket")
|
||||
try:
|
||||
await service.approve_proposal(ticket, proposal, current_user.id)
|
||||
db.commit()
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
db.refresh(proposal)
|
||||
await emit_ticket_update(
|
||||
current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)}
|
||||
)
|
||||
return _proposal_out(proposal)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/proposal/reject", response_model=ProposalOut)
|
||||
async def reject_proposal(
|
||||
ticket_id: UUID,
|
||||
payload: ProposalRejectRequest,
|
||||
current_user: User = Depends(approve_actions),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Reject the pending proposal; optionally re-run the investigation with
|
||||
the rejection reason as context."""
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
proposal = service.pending_proposal(ticket.id)
|
||||
if proposal is None:
|
||||
raise HTTPException(status_code=404, detail="No pending proposal for this ticket")
|
||||
try:
|
||||
service.reject_proposal(
|
||||
ticket, proposal, current_user.id, payload.reason,
|
||||
reinvestigate=payload.reinvestigate,
|
||||
)
|
||||
db.commit()
|
||||
except ValueError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
db.refresh(proposal)
|
||||
await emit_ticket_update(
|
||||
current_user.organization_id, ticket.id, "status", {"status": str(ticket.status)}
|
||||
)
|
||||
return _proposal_out(proposal)
|
||||
|
||||
|
||||
def _rca_out(rca) -> Optional[RCADocumentOut]:
|
||||
if rca is None:
|
||||
return None
|
||||
out = RCADocumentOut.model_validate(rca)
|
||||
if rca.reviewed_by is not None:
|
||||
out.reviewed_by_name = rca.reviewed_by.full_name
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/{ticket_id}/rca", response_model=RCADocumentOut)
|
||||
async def update_rca(
|
||||
ticket_id: UUID,
|
||||
payload: RcaUpdate,
|
||||
current_user: User = Depends(manage_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Edit the RCA's customer summary and/or stamp the reviewer byline."""
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
rca = service.run_repo.latest_rca(ticket.id)
|
||||
if rca is None:
|
||||
raise HTTPException(status_code=404, detail="No RCA document for this ticket")
|
||||
if payload.customer_summary is not None:
|
||||
rca.customer_summary = payload.customer_summary
|
||||
if payload.mark_reviewed:
|
||||
rca.reviewed_by_user_id = current_user.id
|
||||
rca.reviewed_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(rca)
|
||||
return _rca_out(rca)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/rca/send-customer", response_model=TicketActivityOut)
|
||||
async def send_rca_to_customer(
|
||||
ticket_id: UUID,
|
||||
current_user: User = Depends(manage_tickets),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Send the RCA's customer summary to the customer (a manual human action
|
||||
at autonomy L1/L2). Stamps the sender as reviewer."""
|
||||
check_ticketing_access(db, current_user.organization_id)
|
||||
service = _service(db)
|
||||
ticket = _get_ticket_or_404(db, ticket_id, current_user)
|
||||
rca = service.run_repo.latest_rca(ticket.id)
|
||||
if rca is None or not (rca.customer_summary or "").strip():
|
||||
raise HTTPException(status_code=404, detail="No customer summary to send")
|
||||
if not service.can_notify_customer(ticket):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No customer channel — link a conversation or set a customer email first",
|
||||
)
|
||||
rca.reviewed_by_user_id = current_user.id
|
||||
rca.reviewed_at = datetime.now(timezone.utc)
|
||||
activity = service.add_comment(
|
||||
ticket,
|
||||
rca.customer_summary,
|
||||
is_internal=False,
|
||||
actor_type=TicketActorType.USER,
|
||||
actor_user_id=current_user.id,
|
||||
)
|
||||
await service.send_customer_message(ticket, rca.customer_summary, record_activity=False)
|
||||
db.commit()
|
||||
db.refresh(activity)
|
||||
await emit_ticket_update(current_user.organization_id, ticket.id, "comment")
|
||||
return _activity_out(activity)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
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.webhooks.telegram import router as telegram_router
|
||||
from app.api.webhooks.slack import router as slack_router
|
||||
from app.api.webhooks.email import router as email_router
|
||||
from app.api.webhooks.sms import router as sms_router
|
||||
from app.api.webhooks.line import router as line_router
|
||||
from app.api.webhooks.meta import router as meta_router
|
||||
|
||||
# Aggregates every channel's webhook routes under /webhooks
|
||||
router = APIRouter()
|
||||
router.include_router(telegram_router, prefix="/telegram", tags=["webhooks"])
|
||||
router.include_router(slack_router, prefix="/slack", tags=["webhooks"])
|
||||
router.include_router(email_router, prefix="/email", tags=["webhooks"])
|
||||
router.include_router(sms_router, prefix="/sms", tags=["webhooks"])
|
||||
router.include_router(line_router, prefix="/line", tags=["webhooks"])
|
||||
# One endpoint for all three Meta products (WhatsApp / Messenger / Instagram)
|
||||
router.include_router(meta_router, prefix="/meta", tags=["webhooks"])
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
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 app.core.redis import get_redis
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Platforms redeliver webhooks on slow acks/timeouts; remember processed
|
||||
# message ids long enough to absorb every retry cycle.
|
||||
DEDUPE_TTL_SECONDS = 60 * 60
|
||||
|
||||
|
||||
def is_duplicate_message(channel_type: str, external_message_id: str) -> bool:
|
||||
"""Atomically claim an inbound message id (Redis SETNX). Returns True when
|
||||
the message was already processed. Without Redis, dedupe is skipped —
|
||||
single-process deployments rarely see retries fast enough to matter."""
|
||||
if not external_message_id:
|
||||
return False
|
||||
try:
|
||||
redis_client = get_redis()
|
||||
if redis_client is None:
|
||||
return False
|
||||
key = f"channel_dedupe:{channel_type}:{external_message_id}"
|
||||
claimed = redis_client.set(key, "1", nx=True, ex=DEDUPE_TTL_SECONDS)
|
||||
return not claimed
|
||||
except Exception as e:
|
||||
logger.error(f"Dedupe check failed (processing anyway): {e}")
|
||||
return False
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
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, BackgroundTasks, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.webhooks.common import is_duplicate_message
|
||||
from app.channels import get_adapter
|
||||
from app.channels.email import verify_webhook_token
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.channels import ChannelType
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.services.channel_chat import process_channel_message
|
||||
from app.services.ticket_email_reply import find_ticket_for_reply, record_ticket_email_reply
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("/{account_id}")
|
||||
async def email_webhook(
|
||||
account_id: UUID,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
token: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Inbound-parse webhook (point SendGrid/Brevo/your forwarder here with
|
||||
the account's ?token=). Accepts JSON or multipart/urlencoded form."""
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
if account is None or account.channel_type != ChannelType.EMAIL.value:
|
||||
raise HTTPException(status_code=404, detail="Unknown account")
|
||||
if not verify_webhook_token(account, token):
|
||||
raise HTTPException(status_code=403, detail="Invalid token")
|
||||
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
payload = await request.json()
|
||||
else:
|
||||
form = await request.form()
|
||||
payload = {key: str(value) for key, value in form.items()}
|
||||
|
||||
adapter = get_adapter(ChannelType.EMAIL.value)
|
||||
for inbound in adapter.parse_inbound(payload):
|
||||
# A forward-all inbox can echo our own outbound reply back — never
|
||||
# answer mail from the connected address itself.
|
||||
if inbound.external_user_id == account.external_account_id:
|
||||
logger.info(f"Ignoring self-addressed email on {account.external_account_id}")
|
||||
continue
|
||||
if is_duplicate_message(ChannelType.EMAIL.value,
|
||||
f"{account.id}:{inbound.external_message_id}"):
|
||||
continue
|
||||
# A reply to a ticket notification belongs on the ticket, not in a
|
||||
# chat session — the customer is answering the support team.
|
||||
ticket = find_ticket_for_reply(db, account.organization_id, inbound.profile)
|
||||
if ticket is not None:
|
||||
background_tasks.add_task(
|
||||
record_ticket_email_reply, account.organization_id, ticket.id, inbound)
|
||||
continue
|
||||
background_tasks.add_task(process_channel_message, account.id, inbound)
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
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, BackgroundTasks, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.webhooks.common import is_duplicate_message
|
||||
from app.channels import get_adapter
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.channels import ChannelType
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.services.channel_chat import process_channel_message
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("/{account_id}")
|
||||
async def line_webhook(
|
||||
account_id: UUID,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""LINE Messaging API webhook, verified via X-Line-Signature."""
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
if account is None or account.channel_type != ChannelType.LINE.value:
|
||||
raise HTTPException(status_code=404, detail="Unknown account")
|
||||
|
||||
adapter = get_adapter(ChannelType.LINE.value)
|
||||
raw_body = await request.body()
|
||||
if not await adapter.verify_webhook(dict(request.headers), raw_body, account):
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
payload = await request.json()
|
||||
for inbound in adapter.parse_inbound(payload):
|
||||
if is_duplicate_message(ChannelType.LINE.value,
|
||||
f"{account.id}:{inbound.external_message_id}"):
|
||||
continue
|
||||
background_tasks.add_task(process_channel_message, account.id, inbound)
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
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 hmac
|
||||
from typing import Callable, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.webhooks.common import is_duplicate_message
|
||||
from app.channels import get_adapter
|
||||
from app.channels.meta_base import signed_by_any, verify_meta_signature, verify_challenge
|
||||
from app.services.meta_own_app import is_own_app, own_app_secret, same_app_accounts
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.channels import ChannelAccount, ChannelType
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.services.channel_chat import process_channel_message
|
||||
from app.services.channel_status import process_status_event
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# One Meta app subscribes WhatsApp + Messenger + Instagram to this single
|
||||
# callback; the payload's top-level `object` says which product it is.
|
||||
_OBJECT_TO_CHANNEL = {
|
||||
"whatsapp_business_account": ChannelType.WHATSAPP.value,
|
||||
"page": ChannelType.MESSENGER.value,
|
||||
"instagram": ChannelType.INSTAGRAM.value,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def meta_webhook_verify(
|
||||
hub_mode: str = Query(None, alias="hub.mode"),
|
||||
hub_verify_token: str = Query(None, alias="hub.verify_token"),
|
||||
hub_challenge: str = Query(None, alias="hub.challenge"),
|
||||
):
|
||||
"""Webhook verification handshake (App Dashboard → Webhooks → Verify)."""
|
||||
challenge = verify_challenge(hub_mode, hub_verify_token, hub_challenge)
|
||||
if challenge is None:
|
||||
raise HTTPException(status_code=403, detail="Verification failed")
|
||||
# Meta expects the raw challenge echoed as text/plain
|
||||
return Response(content=challenge, media_type="text/plain")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def meta_webhook(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Receive a Meta webhook for any of WhatsApp / Messenger / Instagram.
|
||||
|
||||
Verifies the app-level signature, routes on the `object` field, resolves
|
||||
the connected account per message, acks immediately, and processes in the
|
||||
background.
|
||||
"""
|
||||
raw_body = await request.body()
|
||||
if not verify_meta_signature(raw_body, request.headers.get("x-hub-signature-256", "")):
|
||||
# Logged so a rejected delivery is distinguishable from one that never
|
||||
# arrived; the body is untrusted and unlogged.
|
||||
logger.warning(f"Meta webhook rejected: bad signature ({len(raw_body)} bytes)")
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
payload = await request.json()
|
||||
channel_type = _OBJECT_TO_CHANNEL.get(payload.get("object"))
|
||||
if channel_type is None:
|
||||
# Unknown product subscription — ack so Meta doesn't retry forever
|
||||
return {"status": "ignored"}
|
||||
|
||||
account_repo = ChannelAccountRepository(db)
|
||||
_queue_events(background_tasks, channel_type, payload,
|
||||
lambda external_id: account_repo.get_by_external_id(channel_type, external_id))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/{account_id}")
|
||||
async def own_app_webhook_verify(
|
||||
account_id: UUID,
|
||||
hub_mode: str = Query(None, alias="hub.mode"),
|
||||
hub_verify_token: str = Query(None, alias="hub.verify_token"),
|
||||
hub_challenge: str = Query(None, alias="hub.challenge"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Verification handshake for an account connected from the customer's
|
||||
own Meta app: its verify token is the account's webhook secret."""
|
||||
account = _own_app_account(db, account_id)
|
||||
if (account is None or hub_mode != "subscribe" or not hub_verify_token
|
||||
or not hmac.compare_digest(hub_verify_token.encode(), account.webhook_secret.encode())):
|
||||
raise HTTPException(status_code=403, detail="Verification failed")
|
||||
return Response(content=hub_challenge or "", media_type="text/plain")
|
||||
|
||||
|
||||
@router.post("/{account_id}")
|
||||
async def own_app_webhook(
|
||||
account_id: UUID,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""A webhook for an account connected from the customer's own Meta app.
|
||||
|
||||
Checked with that app's secret only, and only taken for this
|
||||
organization's accounts from the same app: the secret is one the customer
|
||||
chose, so it must never vouch for anyone else's.
|
||||
"""
|
||||
raw_body = await request.body()
|
||||
account = _own_app_account(db, account_id)
|
||||
secret = _readable_secret(account)
|
||||
if secret is None:
|
||||
# Disconnected, never ours, or its secret can't be read: ack, so Meta
|
||||
# doesn't disable the customer's webhook over retries — nothing is acted on
|
||||
return {"status": "ignored"}
|
||||
if not signed_by_any(raw_body, request.headers.get("x-hub-signature-256", ""), (secret,)):
|
||||
logger.warning(f"Meta webhook for account {account_id} rejected: bad signature ({len(raw_body)} bytes)")
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
payload = await request.json()
|
||||
if _OBJECT_TO_CHANNEL.get(payload.get("object")) != account.channel_type:
|
||||
return {"status": "ignored"}
|
||||
_queue_events(background_tasks, account.channel_type, payload, same_app_accounts(db, account))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _own_app_account(db: Session, account_id: UUID) -> Optional[ChannelAccount]:
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
return account if account is not None and account.is_active and is_own_app(account) else None
|
||||
|
||||
|
||||
def _readable_secret(account: Optional[ChannelAccount]) -> Optional[str]:
|
||||
if account is None:
|
||||
return None
|
||||
try:
|
||||
return own_app_secret(account)
|
||||
except Exception:
|
||||
logger.exception(f"Could not read the app secret of Meta account {account.id}")
|
||||
return None
|
||||
|
||||
|
||||
def _queue_events(background_tasks: BackgroundTasks, channel_type: str, payload: dict,
|
||||
resolve: Callable[[str], Optional[ChannelAccount]]) -> None:
|
||||
"""Hand each message and receipt in a webhook to its account's processor;
|
||||
`resolve` names the account an external id belongs to, if any."""
|
||||
adapter = get_adapter(channel_type)
|
||||
# Meta batches many events per number; resolve each account once
|
||||
accounts = {}
|
||||
|
||||
def account_for(external_id: str) -> Optional[ChannelAccount]:
|
||||
if external_id not in accounts:
|
||||
accounts[external_id] = resolve(external_id)
|
||||
return accounts[external_id]
|
||||
|
||||
for inbound in adapter.parse_inbound(payload):
|
||||
account = account_for(inbound.external_account_id)
|
||||
if account is None or not account.is_active:
|
||||
logger.info(f"No active {channel_type} account for {inbound.external_account_id}")
|
||||
continue
|
||||
if is_duplicate_message(channel_type,
|
||||
f"{account.id}:{inbound.external_message_id}"):
|
||||
logger.info(f"Skipping duplicate {channel_type} message {inbound.external_message_id}")
|
||||
continue
|
||||
background_tasks.add_task(process_channel_message, account.id, inbound)
|
||||
|
||||
for event in adapter.parse_status_events(payload):
|
||||
account = account_for(event.external_account_id)
|
||||
if account is None or not account.is_active:
|
||||
continue
|
||||
# Meta redelivers receipts; one per message and state is enough.
|
||||
if is_duplicate_message(channel_type, f"{account.id}:{event.external_message_id}:"
|
||||
f"{event.status.value}"):
|
||||
continue
|
||||
background_tasks.add_task(process_status_event, account.id, event)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
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, BackgroundTasks, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.webhooks.common import is_duplicate_message
|
||||
from app.channels import get_adapter
|
||||
from app.channels.slack import verify_slack_signature
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.channels import ChannelType
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.services.channel_chat import process_channel_message
|
||||
from app.services.slack_events import (
|
||||
deliver_home_welcome,
|
||||
handle_assistant_thread_started,
|
||||
publish_agent_home,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Workspace-level events that end our access: the app was removed, or its
|
||||
# tokens were revoked. Both mean the stored credentials are dead.
|
||||
LIFECYCLE_EVENT_TYPES = frozenset({"app_uninstalled", "tokens_revoked"})
|
||||
|
||||
# App Home / Assistant surfaces — not messages, so parse_inbound drops them.
|
||||
# Handled off the envelope and dispatched to background tasks.
|
||||
APP_HOME_OPENED = "app_home_opened"
|
||||
ASSISTANT_THREAD_STARTED = "assistant_thread_started"
|
||||
ASSISTANT_CONTEXT_CHANGED = "assistant_thread_context_changed"
|
||||
APP_SURFACE_EVENT_TYPES = frozenset(
|
||||
{APP_HOME_OPENED, ASSISTANT_THREAD_STARTED, ASSISTANT_CONTEXT_CHANGED}
|
||||
)
|
||||
|
||||
|
||||
def _revokes_bot_access(event: dict) -> bool:
|
||||
"""Whether the event actually kills the credentials we hold.
|
||||
|
||||
An uninstall always does. tokens_revoked also fires when a *user* token
|
||||
is revoked, which leaves the bot working — we only ever store a bot
|
||||
token, so acting on those would disconnect a healthy workspace.
|
||||
"""
|
||||
if event.get("type") == "app_uninstalled":
|
||||
return True
|
||||
return bool((event.get("tokens") or {}).get("bot"))
|
||||
|
||||
|
||||
def _handle_lifecycle_event(payload: dict, account_repo: ChannelAccountRepository) -> None:
|
||||
"""Drop the workspace's stored credentials after an uninstall or revoke.
|
||||
|
||||
Deleting the account closes any open sessions and cascades to its
|
||||
conversations and agent config, so no agent is left holding a
|
||||
conversation on a channel we can no longer reach.
|
||||
"""
|
||||
if not _revokes_bot_access(payload.get("event") or {}):
|
||||
logger.info("Slack tokens_revoked without a bot token; keeping the account")
|
||||
return
|
||||
|
||||
team_id = payload.get("team_id", "")
|
||||
if not team_id:
|
||||
logger.warning("Slack lifecycle event without a team_id; ignoring")
|
||||
return
|
||||
|
||||
account = account_repo.get_by_external_id(ChannelType.SLACK.value, team_id)
|
||||
if account is None:
|
||||
# Already disconnected our side, or a workspace we never stored.
|
||||
logger.info(f"Slack lifecycle event for unknown team {team_id}")
|
||||
return
|
||||
|
||||
# delete() reports failure by returning False rather than raising.
|
||||
if account_repo.delete(account):
|
||||
logger.info(f"Removed Slack account for team {team_id} after lifecycle event")
|
||||
else:
|
||||
logger.error(f"Failed to remove Slack account for team {team_id}")
|
||||
|
||||
|
||||
def _dispatch_app_surface_event(event: dict, team_id: str,
|
||||
account_repo: ChannelAccountRepository,
|
||||
background_tasks: BackgroundTasks) -> None:
|
||||
"""Fan an App Home / Assistant event out to the right background handler.
|
||||
|
||||
The Home tab publishes agent cards; the Messages tab and the Assistant
|
||||
sidebar both send a welcome — this is what lets the app keep the Messages
|
||||
tab enabled under the Marketplace guidelines.
|
||||
"""
|
||||
if not team_id:
|
||||
return
|
||||
account = account_repo.get_by_external_id(ChannelType.SLACK.value, team_id)
|
||||
if account is None or not account.is_active:
|
||||
logger.info(f"Slack app-surface event for inactive/unknown team {team_id}")
|
||||
return
|
||||
|
||||
event_type = event.get("type")
|
||||
if event_type == APP_HOME_OPENED:
|
||||
user = event.get("user", "")
|
||||
if event.get("tab", "messages") == "home":
|
||||
background_tasks.add_task(publish_agent_home, account.id, user)
|
||||
else: # Messages tab (or unspecified) — welcome on first open.
|
||||
background_tasks.add_task(deliver_home_welcome, account.id, user, event.get("channel", ""))
|
||||
elif event_type == ASSISTANT_THREAD_STARTED:
|
||||
thread = event.get("assistant_thread") or {}
|
||||
background_tasks.add_task(handle_assistant_thread_started, account.id,
|
||||
thread.get("channel_id", ""), thread.get("thread_ts", ""))
|
||||
# assistant_thread_context_changed: nothing to do — prompts are set on start.
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def slack_webhook(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Slack Events API endpoint: URL verification handshake plus @mention and
|
||||
DM events, verified against the app signing secret, acked immediately and
|
||||
processed in the background."""
|
||||
raw_body = await request.body()
|
||||
|
||||
# Verify before parsing — unsigned garbage gets a clean 403, not a 500.
|
||||
if not verify_slack_signature(dict(request.headers), raw_body):
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
payload = await request.json()
|
||||
|
||||
if payload.get("type") == "url_verification":
|
||||
return {"challenge": payload.get("challenge")}
|
||||
|
||||
if payload.get("type") != "event_callback":
|
||||
return {"status": "ignored"}
|
||||
|
||||
account_repo = ChannelAccountRepository(db)
|
||||
|
||||
event = payload.get("event") or {}
|
||||
event_type = event.get("type")
|
||||
|
||||
# Lifecycle events carry no message, so parse_inbound drops them — resolve
|
||||
# the workspace off the envelope instead, before the message loop.
|
||||
if event_type in LIFECYCLE_EVENT_TYPES:
|
||||
_handle_lifecycle_event(payload, account_repo)
|
||||
return {"status": "ok"}
|
||||
|
||||
# App Home / Assistant surfaces (welcome, home cards) — also not messages.
|
||||
if event_type in APP_SURFACE_EVENT_TYPES:
|
||||
_dispatch_app_surface_event(event, payload.get("team_id", ""), account_repo, background_tasks)
|
||||
return {"status": "ok"}
|
||||
|
||||
adapter = get_adapter(ChannelType.SLACK.value)
|
||||
|
||||
for inbound in adapter.parse_inbound(payload):
|
||||
account = account_repo.get_by_external_id(ChannelType.SLACK.value, inbound.external_account_id)
|
||||
if account is None or not account.is_active:
|
||||
logger.info(f"No active Slack account for team {inbound.external_account_id}")
|
||||
continue
|
||||
# event_id is globally unique; Slack redelivers with X-Slack-Retry-Num
|
||||
if is_duplicate_message(ChannelType.SLACK.value,
|
||||
f"{account.id}:{inbound.external_message_id}"):
|
||||
logger.info(f"Skipping duplicate Slack event {inbound.external_message_id}")
|
||||
continue
|
||||
background_tasks.add_task(process_channel_message, account.id, inbound)
|
||||
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
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 as jsonlib
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.webhooks.common import is_duplicate_message
|
||||
from app.channels.email import verify_webhook_token
|
||||
from app.channels.sms import get_provider, SmsWebhookRequest
|
||||
from app.channels.sms.adapter import account_provider_name
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.channels import ChannelType
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.services.channel_chat import process_channel_message
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Twilio expects TwiML back; empty means "no auto-reply"
|
||||
EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>'
|
||||
|
||||
|
||||
def _public_url(provider: str, account_id: UUID) -> str:
|
||||
return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/sms/{provider}/{account_id}"
|
||||
|
||||
|
||||
async def _build_request(request: Request, provider: str, account_id: UUID) -> SmsWebhookRequest:
|
||||
raw_body = await request.body()
|
||||
content_type = request.headers.get("content-type", "")
|
||||
json_body = None
|
||||
param_pairs = []
|
||||
params = {}
|
||||
if "application/json" in content_type or (raw_body[:1] in (b"{", b"[")):
|
||||
try:
|
||||
json_body = jsonlib.loads(raw_body or b"{}")
|
||||
except ValueError:
|
||||
json_body = None
|
||||
if json_body is None:
|
||||
form = await request.form()
|
||||
param_pairs = [(k, str(v)) for k, v in form.multi_items()]
|
||||
params = dict(param_pairs)
|
||||
return SmsWebhookRequest(
|
||||
headers={k.lower(): v for k, v in request.headers.items()},
|
||||
params=params, param_pairs=param_pairs, json_body=json_body,
|
||||
raw_body=raw_body, url=_public_url(provider, account_id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{provider}/{account_id}")
|
||||
async def sms_webhook(
|
||||
provider: str,
|
||||
account_id: UUID,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
token: str = Query(""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Inbound SMS for any provider. The provider in the path selects the
|
||||
parser/verifier; it must match the account's configured provider."""
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
if account is None or account.channel_type != ChannelType.SMS.value:
|
||||
raise HTTPException(status_code=404, detail="Unknown account")
|
||||
if account_provider_name(account) != provider:
|
||||
raise HTTPException(status_code=404, detail="Provider mismatch")
|
||||
|
||||
sms_provider = get_provider(provider)
|
||||
if sms_provider is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown SMS provider")
|
||||
|
||||
# Providers that do not sign their webhooks (Brevo, or Vonage with no signature
|
||||
# secret) carry no proof of origin, so the route enforces the per-account URL
|
||||
# token - the same protection the email webhook uses. The account id in the path
|
||||
# is not a secret: it is part of the URL handed to the provider.
|
||||
if not sms_provider.signs_webhook and not verify_webhook_token(account, token):
|
||||
raise HTTPException(status_code=403, detail="Invalid token")
|
||||
|
||||
sms_req = await _build_request(request, provider, account_id)
|
||||
if not await sms_provider.verify_webhook(sms_req, account):
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
# Control callbacks (e.g. SNS subscription confirmation) are handled here
|
||||
if await sms_provider.handle_control(sms_req, account):
|
||||
return {"status": "ok"}
|
||||
|
||||
for inbound in sms_provider.parse_inbound(sms_req, account):
|
||||
# Only dedupe when the provider supplied a real message id — an empty
|
||||
# id means "unknown", and deduping on text would drop repeat replies.
|
||||
if inbound.external_message_id and is_duplicate_message(
|
||||
ChannelType.SMS.value, f"{account.id}:{inbound.external_message_id}"):
|
||||
continue
|
||||
background_tasks.add_task(process_channel_message, account.id, inbound)
|
||||
|
||||
# Twilio wants TwiML; others accept any 200
|
||||
if provider == "twilio":
|
||||
return Response(content=EMPTY_TWIML, media_type="text/xml")
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
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, BackgroundTasks, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.webhooks.common import is_duplicate_message
|
||||
from app.channels import get_adapter
|
||||
from app.channels import telegram as telegram_channel
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.channels import ChannelType
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.services.channel_chat import process_channel_message, process_channel_interaction
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@router.post("/{account_id}")
|
||||
async def telegram_webhook(
|
||||
account_id: UUID,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Receive a Telegram Bot API update. Verifies the per-account secret
|
||||
token, acks immediately, and processes the message in the background."""
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
if account is None or account.channel_type != ChannelType.TELEGRAM.value:
|
||||
raise HTTPException(status_code=404, detail="Unknown account")
|
||||
|
||||
adapter = get_adapter(ChannelType.TELEGRAM.value)
|
||||
raw_body = await request.body()
|
||||
if not await adapter.verify_webhook(dict(request.headers), raw_body, account):
|
||||
raise HTTPException(status_code=403, detail="Invalid webhook secret")
|
||||
|
||||
payload = await request.json()
|
||||
|
||||
# A shared contact (phone) is an interaction, not a customer text turn —
|
||||
# dispatch it separately.
|
||||
interaction = adapter.parse_interaction(payload)
|
||||
if interaction is not None:
|
||||
background_tasks.add_task(process_channel_interaction, account.id, interaction)
|
||||
return {"ok": True}
|
||||
|
||||
tap_taken = False
|
||||
for inbound in adapter.parse_inbound(payload):
|
||||
# Telegram message_id is only unique within a chat — key by conversation
|
||||
# too. A tap is keyed by the keyboard it was on: only the first tap on
|
||||
# a keyboard counts, so a double tap isn't answered twice.
|
||||
if is_duplicate_message(ChannelType.TELEGRAM.value,
|
||||
f"{account.id}:{inbound.external_conversation_id}:{telegram_channel.dedupe_id(inbound)}"):
|
||||
logger.info(f"Skipping duplicate Telegram message {inbound.external_message_id}")
|
||||
continue
|
||||
background_tasks.add_task(process_channel_message, account.id, inbound)
|
||||
if inbound.interactive is not None:
|
||||
tap_taken = True
|
||||
# Take the keyboard off the message: it has been answered. After
|
||||
# the reply — background tasks run in turn, and a slow edit must
|
||||
# not hold it up (a second tap meanwhile is refused as a duplicate).
|
||||
background_tasks.add_task(adapter.clear_buttons, account, inbound.external_conversation_id,
|
||||
inbound.reply_to_message_id)
|
||||
|
||||
# A tap is answered in the reply, so its button stops spinning
|
||||
return telegram_channel.callback_answer(payload, taken=tap_taken) or {"ok": True}
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
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, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import require_permissions
|
||||
from app.services.workflow import WorkflowService
|
||||
from app.models.schemas.workflow import WorkflowCreate, WorkflowResponse, WorkflowUpdate
|
||||
|
||||
# Enterprise feature check
|
||||
try:
|
||||
from app.enterprise.repositories.plan import PlanRepository
|
||||
from app.enterprise.services.feature_access import require_accessible_subscription
|
||||
HAS_ENTERPRISE = True
|
||||
except ImportError:
|
||||
HAS_ENTERPRISE = False
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def check_workflow_feature_access(current_user: User, db: Session):
|
||||
"""Check if user has access to workflow feature"""
|
||||
if not HAS_ENTERPRISE:
|
||||
return # Allow access in non-enterprise mode
|
||||
|
||||
# Accessible = active/trial/past-due-in-period OR cancelled-but-still-in-
|
||||
# paid-period; raises 403 when the org has no accessible plan.
|
||||
subscription = require_accessible_subscription(db, current_user.organization_id)
|
||||
plan_repo = PlanRepository(db)
|
||||
|
||||
# Check if workflow feature is available in the plan
|
||||
if not plan_repo.check_feature_availability(str(subscription.plan_id), 'workflow'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Workflow feature is not available in your current plan. Please upgrade to access this feature."
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=WorkflowResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workflow(
|
||||
workflow_data: WorkflowCreate,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new workflow"""
|
||||
try:
|
||||
# Check workflow feature access
|
||||
check_workflow_feature_access(current_user, db)
|
||||
|
||||
workflow_service = WorkflowService(db)
|
||||
|
||||
# Create workflow with organization ID from current user
|
||||
workflow = workflow_service.create_workflow(
|
||||
workflow_data=workflow_data,
|
||||
created_by=current_user.id,
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
# Prepare response
|
||||
response = WorkflowResponse(
|
||||
id=workflow.id,
|
||||
name=workflow.name,
|
||||
description=workflow.description,
|
||||
status=workflow.status,
|
||||
version=workflow.version,
|
||||
is_template=workflow.is_template,
|
||||
default_language=workflow.default_language,
|
||||
canvas_data=workflow.canvas_data,
|
||||
settings=workflow.settings,
|
||||
organization_id=workflow.organization_id,
|
||||
agent_id=workflow.agent_id,
|
||||
created_by=workflow.created_by,
|
||||
created_at=workflow.created_at,
|
||||
updated_at=workflow.updated_at
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error creating workflow: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
# Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate
|
||||
# instead of being masked as a generic 500.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating workflow: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail="Failed to create workflow")
|
||||
|
||||
|
||||
@router.get("/agent/{agent_id}", response_model=Optional[WorkflowResponse])
|
||||
async def get_workflow_by_agent_id(
|
||||
agent_id: UUID,
|
||||
current_user: User = Depends(require_permissions("view_all", "manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get workflow by agent ID"""
|
||||
try:
|
||||
# Check workflow feature access
|
||||
check_workflow_feature_access(current_user, db)
|
||||
|
||||
workflow_service = WorkflowService(db)
|
||||
|
||||
# Get workflow for the agent
|
||||
workflow = workflow_service.get_workflow_by_agent_id(
|
||||
agent_id=agent_id,
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
if not workflow:
|
||||
return None
|
||||
|
||||
# Prepare response
|
||||
response = WorkflowResponse(
|
||||
id=workflow.id,
|
||||
name=workflow.name,
|
||||
description=workflow.description,
|
||||
status=workflow.status,
|
||||
version=workflow.version,
|
||||
is_template=workflow.is_template,
|
||||
default_language=workflow.default_language,
|
||||
canvas_data=workflow.canvas_data,
|
||||
settings=workflow.settings,
|
||||
organization_id=workflow.organization_id,
|
||||
agent_id=workflow.agent_id,
|
||||
created_by=workflow.created_by,
|
||||
created_at=workflow.created_at,
|
||||
updated_at=workflow.updated_at
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error getting workflow by agent ID: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
# Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate
|
||||
# instead of being masked as a generic 500.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting workflow by agent ID: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get workflow")
|
||||
|
||||
|
||||
@router.put("/{workflow_id}", response_model=WorkflowResponse)
|
||||
async def update_workflow(
|
||||
workflow_id: UUID,
|
||||
workflow_data: WorkflowUpdate,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update workflow"""
|
||||
try:
|
||||
# Check workflow feature access
|
||||
check_workflow_feature_access(current_user, db)
|
||||
|
||||
workflow_service = WorkflowService(db)
|
||||
|
||||
# Update workflow
|
||||
workflow = workflow_service.update_workflow(
|
||||
workflow_id=workflow_id,
|
||||
workflow_data=workflow_data.model_dump(exclude_unset=True),
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
|
||||
# Prepare response
|
||||
response = WorkflowResponse(
|
||||
id=workflow.id,
|
||||
name=workflow.name,
|
||||
description=workflow.description,
|
||||
status=workflow.status,
|
||||
version=workflow.version,
|
||||
is_template=workflow.is_template,
|
||||
default_language=workflow.default_language,
|
||||
canvas_data=workflow.canvas_data,
|
||||
settings=workflow.settings,
|
||||
organization_id=workflow.organization_id,
|
||||
agent_id=workflow.agent_id,
|
||||
created_by=workflow.created_by,
|
||||
created_at=workflow.created_at,
|
||||
updated_at=workflow.updated_at
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error updating workflow: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
# Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate
|
||||
# instead of being masked as a generic 500.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating workflow: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail="Failed to update workflow")
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_workflow(
|
||||
workflow_id: UUID,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete workflow"""
|
||||
try:
|
||||
# Check workflow feature access
|
||||
check_workflow_feature_access(current_user, db)
|
||||
|
||||
workflow_service = WorkflowService(db)
|
||||
|
||||
# Delete workflow
|
||||
success = workflow_service.delete_workflow(
|
||||
workflow_id=workflow_id,
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
|
||||
return None # 204 No Content
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error deleting workflow: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
# Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate
|
||||
# instead of being masked as a generic 500.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting workflow: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail="Failed to delete workflow")
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
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, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.auth import require_permissions, get_current_user
|
||||
from app.services.workflow_node import WorkflowNodeService
|
||||
from app.models.schemas.workflow import WorkflowNodeResponse, WorkflowConnectionResponse, WorkflowNodeUpdate
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WorkflowNodesUpdateRequest(BaseModel):
|
||||
nodes: List[Dict[str, Any]]
|
||||
connections: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class WorkflowNodesResponse(BaseModel):
|
||||
nodes: List[WorkflowNodeResponse]
|
||||
connections: List[WorkflowConnectionResponse]
|
||||
|
||||
|
||||
@router.put("/{workflow_id}/nodes", response_model=WorkflowNodesResponse)
|
||||
async def replace_workflow_nodes(
|
||||
workflow_id: UUID,
|
||||
request_data: WorkflowNodesUpdateRequest,
|
||||
current_user: User = Depends(require_permissions("manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Replace all workflow nodes and connections with cached data"""
|
||||
try:
|
||||
workflow_node_service = WorkflowNodeService(db)
|
||||
|
||||
# Replace all nodes and connections (delete existing, save new ones)
|
||||
result = workflow_node_service.replace_workflow_nodes_and_connections(
|
||||
workflow_id=workflow_id,
|
||||
nodes_data=request_data.nodes,
|
||||
connections_data=request_data.connections,
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
# Convert to response format
|
||||
nodes_response = [
|
||||
WorkflowNodeResponse(
|
||||
id=node.id,
|
||||
workflow_id=node.workflow_id,
|
||||
node_type=node.node_type,
|
||||
name=node.name,
|
||||
description=node.description,
|
||||
position_x=node.position_x,
|
||||
position_y=node.position_y,
|
||||
config=node.config,
|
||||
created_at=node.created_at,
|
||||
updated_at=node.updated_at
|
||||
)
|
||||
for node in result["nodes"]
|
||||
]
|
||||
|
||||
connections_response = [
|
||||
WorkflowConnectionResponse(
|
||||
id=conn.id,
|
||||
workflow_id=conn.workflow_id,
|
||||
source_node_id=conn.source_node_id,
|
||||
target_node_id=conn.target_node_id,
|
||||
label=conn.label,
|
||||
condition=conn.condition,
|
||||
priority=conn.priority,
|
||||
connection_metadata=conn.connection_metadata,
|
||||
created_at=conn.created_at,
|
||||
updated_at=conn.updated_at
|
||||
)
|
||||
for conn in result["connections"]
|
||||
]
|
||||
|
||||
return WorkflowNodesResponse(
|
||||
nodes=nodes_response,
|
||||
connections=connections_response
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error updating workflow nodes: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
# Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate
|
||||
# instead of being masked as a generic 500.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error replacing workflow nodes: {str(e)}")
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail="Failed to replace workflow nodes")
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/nodes", response_model=WorkflowNodesResponse)
|
||||
async def get_workflow_nodes(
|
||||
workflow_id: UUID,
|
||||
current_user: User = Depends(require_permissions("view_all", "manage_agents")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all nodes and connections for a workflow"""
|
||||
try:
|
||||
workflow_node_service = WorkflowNodeService(db)
|
||||
|
||||
# Get nodes and connections
|
||||
result = workflow_node_service.get_workflow_nodes_and_connections(
|
||||
workflow_id=workflow_id,
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
# Convert to response format
|
||||
nodes_response = [
|
||||
WorkflowNodeResponse(
|
||||
id=node.id,
|
||||
workflow_id=node.workflow_id,
|
||||
node_type=node.node_type,
|
||||
name=node.name,
|
||||
description=node.description,
|
||||
position_x=node.position_x,
|
||||
position_y=node.position_y,
|
||||
config=node.config,
|
||||
created_at=node.created_at,
|
||||
updated_at=node.updated_at
|
||||
)
|
||||
for node in result["nodes"]
|
||||
]
|
||||
|
||||
connections_response = [
|
||||
WorkflowConnectionResponse(
|
||||
id=conn.id,
|
||||
workflow_id=conn.workflow_id,
|
||||
source_node_id=conn.source_node_id,
|
||||
target_node_id=conn.target_node_id,
|
||||
label=conn.label,
|
||||
condition=conn.condition,
|
||||
priority=conn.priority,
|
||||
connection_metadata=conn.connection_metadata,
|
||||
created_at=conn.created_at,
|
||||
updated_at=conn.updated_at
|
||||
)
|
||||
for conn in result["connections"]
|
||||
]
|
||||
|
||||
return WorkflowNodesResponse(
|
||||
nodes=nodes_response,
|
||||
connections=connections_response
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error getting workflow nodes: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
# Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate
|
||||
# instead of being masked as a generic 500.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting workflow nodes: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to get workflow nodes")
|
||||
|
||||
|
||||
@router.put("/{workflow_id}/nodes/{node_id}", response_model=WorkflowNodeResponse)
|
||||
async def update_workflow_node(
|
||||
workflow_id: UUID,
|
||||
node_id: UUID,
|
||||
node_data: WorkflowNodeUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a single workflow node with its properties"""
|
||||
try:
|
||||
# Get the workflow node service
|
||||
workflow_node_service = WorkflowNodeService(db)
|
||||
logger.debug(f"Node data: {node_data}")
|
||||
# Update the node
|
||||
updated_node = workflow_node_service.update_single_node(
|
||||
workflow_id=workflow_id,
|
||||
node_id=node_id,
|
||||
node_data=node_data.model_dump(),
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
if not updated_node:
|
||||
raise HTTPException(status_code=400, detail="Node not found or could not be updated")
|
||||
|
||||
return updated_node
|
||||
|
||||
except ValueError as e:
|
||||
# An unknown or another organization's workflow or node, as the routes above
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
# Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate
|
||||
# instead of being masked as a generic 500.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating workflow node: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update workflow node: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/nodes/{node_id}", response_model=WorkflowNodeResponse)
|
||||
async def get_workflow_node(
|
||||
workflow_id: UUID,
|
||||
node_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a single workflow node"""
|
||||
try:
|
||||
# Get the workflow node service
|
||||
workflow_node_service = WorkflowNodeService(db)
|
||||
|
||||
# Get the node
|
||||
node = workflow_node_service.get_single_node(
|
||||
workflow_id=workflow_id,
|
||||
node_id=node_id,
|
||||
organization_id=current_user.organization_id
|
||||
)
|
||||
|
||||
if not node:
|
||||
raise HTTPException(status_code=400, detail="Node not found")
|
||||
|
||||
return node
|
||||
|
||||
except ValueError as e:
|
||||
# An unknown or another organization's workflow or node, as the routes above
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
# Let intended HTTP errors (e.g. 403 plan feature-gate, 400, 404) propagate
|
||||
# instead of being masked as a generic 500.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting workflow node: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get workflow node: {str(e)}")
|
||||
Reference in new issue
Block a user