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)}")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
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.channels.base import (
|
||||
ChannelAdapter, InboundMessage, SendResult, WindowStatus, ChannelInteraction,
|
||||
)
|
||||
from app.channels.registry import get_adapter, register_adapter
|
||||
|
||||
__all__ = [
|
||||
"ChannelAdapter",
|
||||
"InboundMessage",
|
||||
"SendResult",
|
||||
"WindowStatus",
|
||||
"ChannelInteraction",
|
||||
"get_adapter",
|
||||
"register_adapter",
|
||||
]
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Extension points on the channel pipeline.
|
||||
|
||||
An extension can see every channel message in and out and every delivery
|
||||
receipt (event listeners), and can claim an inbound message before the AI
|
||||
answers it (the inbound handler). Nothing here knows who the extension is:
|
||||
load_extensions imports whatever is installed, and the extension registers
|
||||
itself. Without one, every hook is a no-op.
|
||||
|
||||
Hooks receive plain ids and values, never ORM objects or credentials.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import importlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable, List, Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.channels.base import InboundMessage, InteractiveMessage, StatusEvent
|
||||
from app.channels.constants import MessageSource
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The installed extension's hook module, imported (if present) by
|
||||
# load_extensions. Importing it registers its hooks.
|
||||
EXTENSION_HOOKS_MODULE = "app.enterprise.hooks"
|
||||
_EXTENSION_PACKAGE = EXTENSION_HOOKS_MODULE.rsplit(".", 1)[0]
|
||||
|
||||
# Listeners run inline on the message, delivery and receipt paths, so each
|
||||
# gets this long; they are meant to enqueue work, not do it.
|
||||
LISTENER_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
|
||||
class EventKind(str, enum.Enum):
|
||||
INBOUND = "inbound"
|
||||
STATUS = "status"
|
||||
OUTBOUND = "outbound"
|
||||
|
||||
|
||||
class HandledBy(str, enum.Enum):
|
||||
APP = "app"
|
||||
AI = "ai"
|
||||
HUMAN = "human"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InboundContext:
|
||||
"""An inbound message an extension may claim. `event_id` is the id of the
|
||||
`inbound` event that follows for the same message, so a synchronous call
|
||||
and that event can be told apart from two different messages."""
|
||||
event_id: str
|
||||
organization_id: UUID
|
||||
channel_account_id: UUID
|
||||
channel_type: str
|
||||
session_id: str
|
||||
customer_id: str
|
||||
inbound: InboundMessage
|
||||
received_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutboundRecord:
|
||||
"""A message the business sent on a channel."""
|
||||
message_id: Optional[int]
|
||||
external_message_id: Optional[str]
|
||||
source: MessageSource
|
||||
text: str
|
||||
interactive: Optional[InteractiveMessage] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelEvent:
|
||||
"""Something that happened on a channel conversation. `event_id` is
|
||||
deterministic, so a retried webhook or send yields the same id."""
|
||||
kind: EventKind
|
||||
event_id: str
|
||||
organization_id: UUID
|
||||
channel_account_id: UUID
|
||||
occurred_at: datetime
|
||||
session_id: Optional[str] = None
|
||||
customer_id: Optional[str] = None
|
||||
inbound: Optional[InboundMessage] = None
|
||||
handled_by: Optional[HandledBy] = None
|
||||
status: Optional[StatusEvent] = None
|
||||
outbound: Optional[OutboundRecord] = None
|
||||
|
||||
|
||||
InboundHandler = Callable[[InboundContext], Awaitable[bool]]
|
||||
EventListener = Callable[[ChannelEvent], Awaitable[None]]
|
||||
|
||||
_inbound_handler: Optional[InboundHandler] = None
|
||||
_event_listeners: List[EventListener] = []
|
||||
_extensions_loaded = False
|
||||
|
||||
|
||||
def register_inbound_handler(handler: InboundHandler) -> None:
|
||||
"""Let an extension claim inbound messages first. One slot: the handler
|
||||
returns True (exactly) when it has dealt with the message and the AI
|
||||
must not.
|
||||
|
||||
The contract a handler has to live with:
|
||||
- it is cancelled after app_first_routing.APP_HANDLER_TIMEOUT_SECONDS
|
||||
and the AI answers instead, so its own outbound calls must time out
|
||||
sooner — or the customer can get both answers;
|
||||
- it must only do async I/O (blocking work can't be cancelled);
|
||||
- it can see the same message twice (webhook redelivery when dedupe is
|
||||
unavailable) — dedupe on InboundContext.event_id;
|
||||
- it is not the durable record: every inbound message is also announced
|
||||
as an `inbound` event (same event_id), whatever the handler did."""
|
||||
global _inbound_handler
|
||||
_inbound_handler = handler
|
||||
|
||||
|
||||
def register_event_listener(listener: EventListener) -> None:
|
||||
if listener not in _event_listeners:
|
||||
_event_listeners.append(listener)
|
||||
|
||||
|
||||
def inbound_handler() -> Optional[InboundHandler]:
|
||||
return _inbound_handler
|
||||
|
||||
|
||||
async def emit_channel_event(event: ChannelEvent) -> None:
|
||||
"""Tell every listener. A failing or slow listener is logged and skipped —
|
||||
an extension can never break or stall message handling."""
|
||||
for listener in list(_event_listeners):
|
||||
try:
|
||||
await asyncio.wait_for(listener(event), LISTENER_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"Channel event listener timed out on {event.kind.value} {event.event_id}")
|
||||
except Exception:
|
||||
logger.exception(f"Channel event listener failed on {event.kind.value} {event.event_id}")
|
||||
|
||||
|
||||
def load_extensions() -> None:
|
||||
"""Import the installed extension's hooks, once. Called at startup by the
|
||||
API and by every worker that sends on channels — explicitly, so a process
|
||||
that never calls it (a migration, a script) never loads an extension."""
|
||||
global _extensions_loaded
|
||||
if _extensions_loaded:
|
||||
return
|
||||
_extensions_loaded = True
|
||||
try:
|
||||
importlib.import_module(EXTENSION_HOOKS_MODULE)
|
||||
except ModuleNotFoundError as e:
|
||||
if e.name not in (EXTENSION_HOOKS_MODULE, _EXTENSION_PACKAGE):
|
||||
logger.exception("Channel extension failed to load") # installed but broken
|
||||
return
|
||||
logger.debug("No channel extension installed")
|
||||
except Exception:
|
||||
logger.exception("Channel extension failed to load")
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Forget every registration (tests)."""
|
||||
global _inbound_handler, _extensions_loaded
|
||||
_inbound_handler = None
|
||||
_event_listeners.clear()
|
||||
_extensions_loaded = False
|
||||
|
||||
|
||||
def inbound_event_id(channel_account_id, external_message_id: str) -> str:
|
||||
return f"{channel_account_id}:{external_message_id}"
|
||||
|
||||
|
||||
def status_event_id(channel_account_id, external_message_id: str, status: str) -> str:
|
||||
return f"{channel_account_id}:{external_message_id}:{status}"
|
||||
|
||||
|
||||
def outbound_event(*, organization_id, channel_account_id, session_id: Optional[str],
|
||||
customer_id: Optional[str], record: OutboundRecord) -> ChannelEvent:
|
||||
# Keyed by the platform's id when there is one, else the stored row; a
|
||||
# send with neither is unrepeatable anyway.
|
||||
key = record.external_message_id or (f"row{record.message_id}" if record.message_id else None)
|
||||
return ChannelEvent(
|
||||
kind=EventKind.OUTBOUND,
|
||||
event_id=f"{channel_account_id}:out:{key}" if key else str(uuid4()),
|
||||
organization_id=organization_id,
|
||||
channel_account_id=channel_account_id,
|
||||
occurred_at=datetime.now(timezone.utc),
|
||||
session_id=session_id,
|
||||
customer_id=customer_id,
|
||||
outbound=record,
|
||||
)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
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 enum
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from app.channels.interactive_limits import InteractiveLimits
|
||||
from app.models.channels import ChannelAccount, ChannelConversation
|
||||
|
||||
|
||||
class InteractiveReplyType(str, enum.Enum):
|
||||
BUTTON_REPLY = "button_reply" # tap on a reply button we sent
|
||||
LIST_REPLY = "list_reply" # pick from a list message we sent
|
||||
TEMPLATE_BUTTON = "template_button" # tap on an approved template's quick-reply button
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveReply:
|
||||
"""The customer tapped a button or list row. `id` is the payload we set
|
||||
when sending; `title` is the text they saw."""
|
||||
type: InteractiveReplyType
|
||||
id: str
|
||||
title: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedLocation:
|
||||
latitude: float
|
||||
longitude: float
|
||||
name: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InboundMessage:
|
||||
"""A customer message normalized from a channel webhook payload."""
|
||||
external_account_id: str # which connected account received it
|
||||
external_conversation_id: str # platform conversation id (chat_id / wa_id / PSID ...)
|
||||
external_user_id: str # platform id of the customer
|
||||
external_message_id: str # platform message id, used for dedupe
|
||||
text: Optional[str] = None
|
||||
attachments: List[dict] = field(default_factory=list)
|
||||
profile: dict = field(default_factory=dict) # name / phone / username when available
|
||||
timestamp: Optional[datetime] = None
|
||||
interactive: Optional[InteractiveReply] = None
|
||||
location: Optional[SharedLocation] = None
|
||||
# The platform message this one replies to (WhatsApp context.id) — ties a
|
||||
# button tap back to the message that offered the button.
|
||||
reply_to_message_id: Optional[str] = None
|
||||
|
||||
|
||||
class InteractiveKind(str, enum.Enum):
|
||||
BUTTONS = "buttons"
|
||||
LIST = "list"
|
||||
CTA_URL = "cta_url"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveButton:
|
||||
id: str
|
||||
title: str
|
||||
# The choice's full text when the title had to be cut to fit (not sent)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveRow:
|
||||
id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveSection:
|
||||
rows: List[InteractiveRow]
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InteractiveMessage:
|
||||
"""A channel-native choice prompt: reply buttons, a list, or a link button.
|
||||
Limits are enforced by app.channels.interactive.validate_interactive."""
|
||||
kind: InteractiveKind
|
||||
body: str
|
||||
header: Optional[str] = None
|
||||
footer: Optional[str] = None
|
||||
buttons: List[InteractiveButton] = field(default_factory=list)
|
||||
list_button_label: Optional[str] = None
|
||||
sections: List[InteractiveSection] = field(default_factory=list)
|
||||
url: Optional[str] = None
|
||||
display_text: Optional[str] = None
|
||||
|
||||
|
||||
class DeliveryState(str, enum.Enum):
|
||||
SENT = "sent"
|
||||
DELIVERED = "delivered"
|
||||
READ = "read"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusEvent:
|
||||
"""A delivery receipt for a message we sent."""
|
||||
external_account_id: str
|
||||
external_message_id: str
|
||||
recipient_id: str
|
||||
status: DeliveryState
|
||||
timestamp: Optional[datetime] = None
|
||||
error: Optional[str] = None
|
||||
error_code: Optional[str] = None # the platform's code for `error`
|
||||
pricing_category: Optional[str] = None # e.g. marketing, utility, service
|
||||
# Per-message pricing: whether this message is charged ("regular") or
|
||||
# free ("free_customer_service", "free_entry_point"); `billable` is the
|
||||
# older flag the platform is retiring in its favour
|
||||
pricing_type: Optional[str] = None
|
||||
pricing_model: Optional[str] = None
|
||||
billable: Optional[bool] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendResult:
|
||||
ok: bool
|
||||
external_message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
# True when the failure is a closed messaging window that an approved
|
||||
# template message could reopen (WhatsApp)
|
||||
can_template: bool = False
|
||||
# True when the platform rejected the account's credentials (expired or
|
||||
# revoked token): nothing will send until the account is reconnected
|
||||
auth_failed: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelInteraction:
|
||||
"""A non-message interaction from a channel (a shared contact) — distinct
|
||||
from a customer text turn, so it bypasses the AI pipeline and is handled
|
||||
directly."""
|
||||
type: str # 'contact'
|
||||
external_account_id: str
|
||||
external_conversation_id: str
|
||||
external_user_id: str
|
||||
phone: Optional[str] = None # for type == 'contact'
|
||||
profile: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class WindowStatus(str, enum.Enum):
|
||||
OK = "ok"
|
||||
# Outside the customer-service window but a template message can reopen it
|
||||
TEMPLATE_REQUIRED = "template_required"
|
||||
# Outside the window with no recovery path
|
||||
UNDELIVERABLE = "undeliverable"
|
||||
|
||||
|
||||
class ChannelAdapter(ABC):
|
||||
"""Per-channel behavior behind a uniform interface.
|
||||
|
||||
Adapters are stateless singletons: every method receives the
|
||||
ChannelAccount (with credentials accessed via the account repository)
|
||||
and, where relevant, the ChannelConversation.
|
||||
"""
|
||||
|
||||
channel_type: ClassVar[str]
|
||||
# What send_interactive can carry on this channel; None when it can't
|
||||
# send choices at all
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = None
|
||||
# Whether the platform sends delivery receipts (parse_status_events) for
|
||||
# the messages we send, so their ids are worth recording
|
||||
reports_delivery_status: ClassVar[bool] = False
|
||||
# The platform's cap on one text message, when it has one
|
||||
max_text_length: ClassVar[Optional[int]] = None
|
||||
|
||||
@property
|
||||
def supports_interactive(self) -> bool:
|
||||
return self.interactive_limits is not None
|
||||
|
||||
def choice_limits_for(self, conversation: ChannelConversation) -> Optional[InteractiveLimits]:
|
||||
"""What choices can be offered in this conversation; None where a tap
|
||||
couldn't come back to us. Default: the channel's limits."""
|
||||
return self.interactive_limits
|
||||
|
||||
@abstractmethod
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
"""Authenticate an inbound webhook request before parsing it."""
|
||||
|
||||
@abstractmethod
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Extract customer messages from a webhook payload.
|
||||
Non-message events (delivery receipts, edits, joins) yield []."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
"""Deliver a text reply to the customer."""
|
||||
|
||||
async def send_media(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
url: str, content_type: str) -> SendResult:
|
||||
"""Deliver a media attachment. Default: unsupported."""
|
||||
return SendResult(ok=False, error=f"{self.channel_type} does not support media yet")
|
||||
|
||||
async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: InteractiveMessage) -> SendResult:
|
||||
"""Deliver buttons / a list / a link button. Default: unsupported —
|
||||
callers check supports_interactive and fall back to text."""
|
||||
return SendResult(ok=False, error=f"{self.channel_type} does not support interactive messages")
|
||||
|
||||
def parse_status_events(self, payload: dict) -> List[StatusEvent]:
|
||||
"""Extract delivery receipts (sent/delivered/read/failed) from a
|
||||
webhook payload. Default: the channel reports none."""
|
||||
return []
|
||||
|
||||
def check_delivery_window(self, conversation: ChannelConversation) -> WindowStatus:
|
||||
"""Whether the platform still accepts free-form outbound messages.
|
||||
Default: no window restrictions (Telegram, Slack, email...)."""
|
||||
return WindowStatus.OK
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
"""Convert the agent's markdown to the channel's markup and apply
|
||||
length caps. Default: pass through unchanged."""
|
||||
return markdown
|
||||
|
||||
def conversation_state(self, inbound: InboundMessage) -> dict:
|
||||
"""Channel-specific state to merge into the conversation's extra JSON
|
||||
on each inbound message (e.g. email subject + Message-ID for reply
|
||||
threading). Default: nothing."""
|
||||
return {}
|
||||
|
||||
# --- Optional interactive capabilities (default: unsupported no-ops so
|
||||
# channels without them are simply skipped) ---
|
||||
|
||||
def parse_interaction(self, payload: dict) -> Optional["ChannelInteraction"]:
|
||||
"""Extract a button tap / shared contact from a webhook payload, or
|
||||
None if the payload isn't such an interaction."""
|
||||
return None
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
"""Show a 'typing…' indicator while the agent composes a reply."""
|
||||
return None
|
||||
|
||||
async def mark_read(self, account: ChannelAccount, external_message_id: str) -> None:
|
||||
"""Mark a customer's message read without a typing indicator — for a
|
||||
message something other than the AI answers."""
|
||||
return None
|
||||
|
||||
async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict:
|
||||
"""Look up the customer's real name/email from the platform API when the
|
||||
webhook payload doesn't include it. Returns {} by default."""
|
||||
return {}
|
||||
|
||||
async def request_phone(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
text: str) -> SendResult:
|
||||
"""Ask the customer to share their phone number natively."""
|
||||
return SendResult(ok=False, error=f"{self.channel_type} does not support phone requests")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Channel constants that describe the platforms rather than how we talk to them.
|
||||
|
||||
Separate from meta_base because these are shared by layers that have no
|
||||
business knowing about a transport — request schemas state one as a field
|
||||
default. (It buys no import weight: app/channels/__init__ loads the registry
|
||||
regardless. It is about where the value belongs, not what it drags in.)
|
||||
"""
|
||||
|
||||
import enum
|
||||
from typing import Optional
|
||||
|
||||
# The language assumed when a caller doesn't name one. Meta has no default —
|
||||
# name and language together identify a template, so `order_update` in five
|
||||
# languages is five templates — which makes this our choice, and one worth
|
||||
# keeping in a single place: four copies of the literal would silently disagree
|
||||
# the day it changes.
|
||||
#
|
||||
# The frontend's DEFAULT_LANGUAGE (utils/whatsappLanguages.ts) is the
|
||||
# unavoidable second copy, on the other side of the wire. Keep the two equal.
|
||||
DEFAULT_TEMPLATE_LANGUAGE = "en_US"
|
||||
|
||||
|
||||
class MessageSource(str, enum.Enum):
|
||||
"""Who wrote a message the business sent (chat_history.attributes.source)."""
|
||||
AI = "ai"
|
||||
HUMAN = "human"
|
||||
SYSTEM = "system" # canned notices: handover, limits, errors, tickets
|
||||
APP = "app" # an external app, through a channel extension
|
||||
|
||||
|
||||
# The most choices one reply can offer the customer: WhatsApp's list-message
|
||||
# row cap, the largest interactive form any channel supports.
|
||||
MAX_CHOICE_OPTIONS = 10
|
||||
# The longest one choice can be: a list row's description, which is where a
|
||||
# choice too long for a button or row title is shown in full.
|
||||
MAX_CHOICE_LENGTH = 72
|
||||
|
||||
|
||||
# Channels served by our own chat widget rather than an external messaging
|
||||
# platform. They render our UI, so widget-only features (the rating prompt) work
|
||||
# there; on Telegram or WhatsApp the same prompt is a dead end.
|
||||
#
|
||||
# 'shopify' is that widget embedded in a Shopify storefront — a distinct origin
|
||||
# worth reporting on, but the same chat surface. Every "is this the widget?"
|
||||
# test has to accept both, or Shopify conversations quietly lose widget
|
||||
# features the moment they stop being labelled 'web'.
|
||||
WIDGET_CHANNELS = frozenset({"web", "shopify"})
|
||||
|
||||
|
||||
def is_widget_channel(channel: Optional[str]) -> bool:
|
||||
"""True for our own widget surfaces. A missing channel means the widget."""
|
||||
return not channel or channel in WIDGET_CHANNELS
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
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 hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import parseaddr, make_msgid
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
|
||||
from app.channels.registry import register_adapter
|
||||
from app.core.config import settings
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def smtp_config(account: ChannelAccount) -> dict:
|
||||
"""Resolve the outbound SMTP settings for an inbox.
|
||||
|
||||
Uses the account's own encrypted SMTP credentials when present (so replies
|
||||
go out from the inbox's own domain with correct SPF/DKIM), otherwise falls
|
||||
back to the platform SMTP settings.
|
||||
"""
|
||||
creds = {}
|
||||
if account.encrypted_credentials:
|
||||
try:
|
||||
creds = json.loads(decrypt_api_key(account.encrypted_credentials))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read email SMTP credentials for {account.id}: {e}")
|
||||
|
||||
if creds.get("smtp_host"):
|
||||
return inbox_smtp_config(creds, account.external_account_id)
|
||||
return platform_smtp_config(account.external_account_id)
|
||||
|
||||
|
||||
def _use_ssl(port: int, use_ssl: Optional[bool]) -> bool:
|
||||
"""Implicit TLS on 465 unless set explicitly; other ports try STARTTLS."""
|
||||
return port == 465 if use_ssl is None else bool(use_ssl)
|
||||
|
||||
|
||||
def inbox_smtp_config(creds: dict, default_from: str) -> dict:
|
||||
"""Connection settings from an inbox's own SMTP credentials (the stored
|
||||
smtp_* keys). `default_from` is used when no from_email was given."""
|
||||
port = int(creds.get("smtp_port") or 587)
|
||||
return {
|
||||
"host": creds["smtp_host"],
|
||||
"port": port,
|
||||
"username": creds.get("smtp_username"),
|
||||
"password": creds.get("smtp_password"),
|
||||
"from_email": creds.get("from_email") or default_from,
|
||||
"use_ssl": _use_ssl(port, creds.get("smtp_use_ssl")),
|
||||
}
|
||||
|
||||
|
||||
def platform_smtp_config(from_email: str) -> dict:
|
||||
"""Connection settings for the platform's own account (SMTP_* settings).
|
||||
With no SMTP_USERNAME there is no login, which is how local Mailpit runs."""
|
||||
port = int(settings.SMTP_PORT)
|
||||
return {
|
||||
"host": settings.SMTP_SERVER,
|
||||
"port": port,
|
||||
"username": settings.SMTP_USERNAME,
|
||||
"password": settings.SMTP_PASSWORD,
|
||||
"from_email": from_email,
|
||||
"use_ssl": _use_ssl(port, None),
|
||||
}
|
||||
|
||||
|
||||
def open_smtp(cfg: dict) -> smtplib.SMTP:
|
||||
"""A connected, logged-in SMTP session for a config built above."""
|
||||
if cfg["use_ssl"]:
|
||||
smtp = smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=SMTP_TIMEOUT_SECONDS)
|
||||
else:
|
||||
smtp = smtplib.SMTP(cfg["host"], cfg["port"], timeout=SMTP_TIMEOUT_SECONDS)
|
||||
try:
|
||||
smtp.starttls()
|
||||
except smtplib.SMTPNotSupportedError:
|
||||
logger.warning("SMTP server does not support STARTTLS; sending unencrypted")
|
||||
if cfg.get("username"):
|
||||
smtp.login(cfg["username"], cfg["password"])
|
||||
return smtp
|
||||
|
||||
|
||||
def validate_smtp(cfg: dict) -> None:
|
||||
"""Open + authenticate an SMTP connection to verify per-inbox credentials.
|
||||
Raises on failure. Runs in a worker thread (smtplib is blocking)."""
|
||||
with open_smtp(cfg) as smtp:
|
||||
smtp.noop()
|
||||
|
||||
SMTP_TIMEOUT_SECONDS = 20.0
|
||||
# Strip quoted history so the agent sees only the new content
|
||||
_REPLY_MARKERS = (
|
||||
re.compile(r"^On .{5,80} wrote:\s*$", re.M),
|
||||
re.compile(r"^-{2,}\s*Original Message\s*-{2,}", re.M | re.I),
|
||||
re.compile(r"^_{10,}\s*$", re.M),
|
||||
)
|
||||
|
||||
|
||||
def strip_quoted_reply(text: str) -> str:
|
||||
"""Cut the email body at the first quoted-history marker."""
|
||||
cut = len(text)
|
||||
for marker in _REPLY_MARKERS:
|
||||
match = marker.search(text)
|
||||
if match:
|
||||
cut = min(cut, match.start())
|
||||
lines = [line for line in text[:cut].splitlines() if not line.startswith(">")]
|
||||
stripped = "\n".join(lines).strip()
|
||||
# Never lose a message to over-eager stripping — fall back to the raw body
|
||||
return stripped or text.strip()
|
||||
|
||||
|
||||
class EmailAdapter(ChannelAdapter):
|
||||
"""Email as a messaging channel.
|
||||
|
||||
Inbound: an inbound-parse webhook (SendGrid/Brevo-style JSON or form post
|
||||
forwarded to /webhooks/email/{account_id}?token=...). Conversations are
|
||||
keyed by the customer's address — one continuous conversation per sender
|
||||
per inbox. Outbound: platform SMTP, with In-Reply-To/References threading
|
||||
from the last inbound Message-ID kept on the conversation."""
|
||||
|
||||
channel_type: ClassVar[str] = ChannelType.EMAIL.value
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
# The per-account secret rides on the URL (?token=), checked in the
|
||||
# route because generic parse providers can't sign requests.
|
||||
return account is not None
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize a parsed-email payload. Accepts the common field names of
|
||||
SendGrid Inbound Parse (from/to/subject/text/headers) and Brevo
|
||||
(From/To/Subject/RawTextBody/MessageId). Machine-generated mail
|
||||
(autoresponders, bounces) is dropped to prevent reply loops."""
|
||||
if self._is_auto_generated(payload):
|
||||
return []
|
||||
sender = payload.get("from") or payload.get("From") or ""
|
||||
if isinstance(sender, dict): # Brevo: {"Address": ..., "Name": ...}
|
||||
sender_name, sender_email = sender.get("Name"), sender.get("Address", "")
|
||||
else:
|
||||
sender_name, sender_email = parseaddr(str(sender))
|
||||
sender_email = (sender_email or "").lower()
|
||||
if not sender_email:
|
||||
return []
|
||||
|
||||
text = payload.get("text") or payload.get("RawTextBody") or payload.get("body") or ""
|
||||
text = strip_quoted_reply(str(text))
|
||||
if not text:
|
||||
return []
|
||||
|
||||
message_id = str(payload.get("MessageId") or payload.get("message_id")
|
||||
or self._header(payload, "Message-ID") or "")
|
||||
subject = str(payload.get("subject") or payload.get("Subject") or "")
|
||||
|
||||
return [InboundMessage(
|
||||
external_account_id="", # resolved from the webhook path
|
||||
external_conversation_id=sender_email,
|
||||
external_user_id=sender_email,
|
||||
external_message_id=message_id or
|
||||
f"{sender_email}:{hashlib.sha256(text.encode()).hexdigest()[:32]}",
|
||||
text=text,
|
||||
profile={"name": sender_name or None, "email": sender_email,
|
||||
"subject": subject, "inbound_message_id": message_id,
|
||||
# Threading headers, kept raw — ticket reply matching
|
||||
# reads the Message-IDs we minted back out of them.
|
||||
"in_reply_to": self._threading_header(payload, "In-Reply-To"),
|
||||
"references": self._threading_header(payload, "References")},
|
||||
)]
|
||||
|
||||
def conversation_state(self, inbound: InboundMessage) -> dict:
|
||||
state = {}
|
||||
if inbound.profile.get("inbound_message_id"):
|
||||
state["last_message_id"] = inbound.profile["inbound_message_id"]
|
||||
if inbound.profile.get("subject"):
|
||||
state["subject"] = inbound.profile["subject"]
|
||||
return state
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
extra = conversation.extra or {}
|
||||
subject = extra.get("subject") or "Re: your message"
|
||||
if subject and not subject.lower().startswith("re:"):
|
||||
subject = f"Re: {subject}"
|
||||
|
||||
cfg = smtp_config(account)
|
||||
message = EmailMessage()
|
||||
message["From"] = cfg["from_email"]
|
||||
message["To"] = conversation.external_conversation_id
|
||||
message["Subject"] = subject
|
||||
message["Message-ID"] = make_msgid()
|
||||
last_inbound_id = extra.get("last_message_id")
|
||||
if last_inbound_id:
|
||||
message["In-Reply-To"] = last_inbound_id
|
||||
message["References"] = last_inbound_id
|
||||
message.set_content(text)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(self._smtp_send, cfg, message)
|
||||
return SendResult(ok=True, external_message_id=message["Message-ID"])
|
||||
except Exception as e:
|
||||
logger.error(f"Email send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
@staticmethod
|
||||
def _smtp_send(cfg: dict, message: EmailMessage) -> None:
|
||||
with open_smtp(cfg) as smtp:
|
||||
smtp.send_message(message)
|
||||
|
||||
@staticmethod
|
||||
def _is_auto_generated(payload: dict) -> bool:
|
||||
"""True for autoresponders/bounces (Auto-Submitted, Precedence bulk,
|
||||
X-Auto-Response-Suppress) — answering those creates mail loops."""
|
||||
headers = payload.get("headers")
|
||||
text = headers if isinstance(headers, str) else ""
|
||||
if isinstance(headers, dict):
|
||||
text = "\n".join(f"{k}: {v}" for k, v in headers.items())
|
||||
lowered = text.lower()
|
||||
if re.search(r"^auto-submitted:\s*(?!no)", lowered, re.M):
|
||||
return True
|
||||
if re.search(r"^precedence:\s*(bulk|junk|auto_reply|list)", lowered, re.M):
|
||||
return True
|
||||
if "x-auto-response-suppress:" in lowered:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _threading_header(cls, payload: dict, name: str) -> str:
|
||||
"""In-Reply-To/References, read from the raw headers the provider
|
||||
forwards (dict or string, both handled by _header). Also accepts a
|
||||
top-level lowercased field for providers that flatten headers onto the
|
||||
payload."""
|
||||
return payload.get(name.lower()) or cls._header(payload, name) or ""
|
||||
|
||||
@staticmethod
|
||||
def _header(payload: dict, name: str) -> Optional[str]:
|
||||
headers = payload.get("headers")
|
||||
if isinstance(headers, dict):
|
||||
return headers.get(name) or headers.get(name.lower())
|
||||
if isinstance(headers, str):
|
||||
match = re.search(rf"^{name}:\s*(.+)$", headers, re.M | re.I)
|
||||
return match.group(1).strip() if match else None
|
||||
return None
|
||||
|
||||
|
||||
def verify_webhook_token(account: ChannelAccount, token: str) -> bool:
|
||||
return bool(token) and hmac.compare_digest(token, account.webhook_secret or "")
|
||||
|
||||
|
||||
register_adapter(EmailAdapter())
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
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 ClassVar, List, Optional
|
||||
|
||||
from app.channels.interactive_limits import INSTAGRAM_LIMITS, InteractiveLimits
|
||||
from app.channels.messenger import MessengerAdapter
|
||||
from app.channels.meta_base import GRAPH_INSTAGRAM_BASE
|
||||
from app.channels.registry import register_adapter
|
||||
from app.models.channels import ChannelType
|
||||
|
||||
# Instagram DM uses the same Messenger Platform envelope (entry[].messaging[],
|
||||
# sender.id = IGSID) and the same `me/messages` send shape, so it subclasses
|
||||
# Messenger. What differs is the host and the credential: accounts connect
|
||||
# through Instagram Login, which yields an Instagram user token rather than a
|
||||
# Facebook Page token.
|
||||
|
||||
|
||||
class InstagramAdapter(MessengerAdapter):
|
||||
channel_type: ClassVar[str] = ChannelType.INSTAGRAM.value
|
||||
# An Instagram user token is only accepted by graph.instagram.com; the
|
||||
# Facebook graph rejects it, so sends and profile lookups go here instead.
|
||||
graph_base: ClassVar[str] = GRAPH_INSTAGRAM_BASE
|
||||
# An IG user node exposes name/username, not the Messenger first/last name.
|
||||
profile_fields: ClassVar[str] = "name,username"
|
||||
# Instagram's send body is just {recipient, message} — messaging_type is a
|
||||
# Messenger-only parameter and has no meaning here.
|
||||
send_extras: ClassVar[dict] = {}
|
||||
# Quick replies, but Instagram doesn't show them on desktop or the web,
|
||||
# so the choices are written into the text too
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = INSTAGRAM_LIMITS
|
||||
# Instagram's own cap on a message's text
|
||||
max_text_length: ClassVar[Optional[int]] = INSTAGRAM_LIMITS.body_max
|
||||
|
||||
def _events(self, entry: dict) -> List[dict]:
|
||||
"""Instagram delivers a DM either as messaging[] or, depending on the
|
||||
API version, as changes[] with field=messages and the same event under
|
||||
`value`. Reading only one shape drops every message on the other, and
|
||||
silently — the payload parses to nothing and the webhook still acks.
|
||||
"""
|
||||
events = super()._events(entry)
|
||||
if events:
|
||||
return events
|
||||
return [change.get("value") or {}
|
||||
for change in entry.get("changes") or []
|
||||
if change.get("field") == "messages"]
|
||||
|
||||
@staticmethod
|
||||
def _display_name(data: dict) -> str:
|
||||
return (data.get("name") or data.get("username") or "").strip()
|
||||
|
||||
|
||||
register_adapter(InstagramAdapter())
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Interactive (choice) messages: validation against a channel's limits, and
|
||||
building one from the agent's plain list of options.
|
||||
|
||||
Checked before sending, so a bad message fails here with a readable reason
|
||||
instead of as an opaque platform 400. Each channel's limits are in
|
||||
channels/interactive_limits.py; the header, footer, section, list-button and
|
||||
link-button limits below are WhatsApp's, the only channel with those parts.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from app.channels.base import (
|
||||
InteractiveButton, InteractiveKind, InteractiveMessage, InteractiveRow,
|
||||
InteractiveSection,
|
||||
)
|
||||
from app.channels.constants import MAX_CHOICE_OPTIONS
|
||||
from app.channels.interactive_limits import WHATSAPP_LIMITS, InteractiveLimits
|
||||
|
||||
SECTION_TITLE_MAX = 24
|
||||
LIST_BUTTON_LABEL_MAX = 20
|
||||
CTA_DISPLAY_TEXT_MAX = 20
|
||||
HEADER_MAX = 60
|
||||
FOOTER_MAX = 60
|
||||
|
||||
DEFAULT_LIST_BUTTON_LABEL = "Choose"
|
||||
# The body of the choices when the reply itself was too long to carry them
|
||||
DEFAULT_CHOICES_BODY = "Choose an option:"
|
||||
ELLIPSIS = "…"
|
||||
|
||||
# Ids we give the agent's options
|
||||
_QUICK_REPLY_ID_PREFIX = "qr_"
|
||||
|
||||
|
||||
def _quick_reply_id(index: int) -> str:
|
||||
return f"{_QUICK_REPLY_ID_PREFIX}{index}"
|
||||
|
||||
|
||||
def truncate(text: str, limit: int) -> str:
|
||||
"""Cut to `limit` characters, marking the cut with an ellipsis."""
|
||||
text = text.strip()
|
||||
return text if len(text) <= limit else text[:limit - len(ELLIPSIS)].rstrip() + ELLIPSIS
|
||||
|
||||
|
||||
def from_options(body: str, options: List[str],
|
||||
limits: InteractiveLimits = WHATSAPP_LIMITS) -> InteractiveMessage:
|
||||
"""Build the message for the agent's options on a channel.
|
||||
|
||||
Buttons when there are few enough options and every one fits a button
|
||||
title, or when the channel has no lists (titles are cut to fit; the full
|
||||
text is kept with each); otherwise a list, whose rows allow longer titles
|
||||
and carry the full text in the description when the title had to be cut.
|
||||
"""
|
||||
options = options[:MAX_CHOICE_OPTIONS]
|
||||
fits = len(options) <= limits.max_buttons and all(len(o) <= limits.button_title_max for o in options)
|
||||
if fits or not limits.supports_list:
|
||||
return InteractiveMessage(
|
||||
kind=InteractiveKind.BUTTONS,
|
||||
body=body,
|
||||
buttons=[InteractiveButton(id=_quick_reply_id(i), title=truncate(o, limits.button_title_max),
|
||||
description=o if len(o) > limits.button_title_max else None)
|
||||
for i, o in enumerate(options[:limits.max_buttons])],
|
||||
)
|
||||
rows = [
|
||||
InteractiveRow(
|
||||
id=_quick_reply_id(i),
|
||||
title=truncate(o, limits.row_title_max),
|
||||
description=truncate(o, limits.row_description_max) if len(o) > limits.row_title_max else None,
|
||||
)
|
||||
for i, o in enumerate(options[:limits.max_list_rows])
|
||||
]
|
||||
return InteractiveMessage(
|
||||
kind=InteractiveKind.LIST,
|
||||
body=body,
|
||||
list_button_label=DEFAULT_LIST_BUTTON_LABEL,
|
||||
sections=[InteractiveSection(rows=rows)],
|
||||
)
|
||||
|
||||
|
||||
def validate_interactive(message: InteractiveMessage,
|
||||
limits: InteractiveLimits = WHATSAPP_LIMITS) -> List[str]:
|
||||
"""Every reason the channel would reject this message; empty when valid."""
|
||||
errors: List[str] = []
|
||||
_check_text(errors, "body", message.body, limits.body_max, required=True)
|
||||
_check_text(errors, "header", message.header, HEADER_MAX)
|
||||
_check_text(errors, "footer", message.footer, FOOTER_MAX)
|
||||
|
||||
if message.kind == InteractiveKind.BUTTONS:
|
||||
_validate_buttons(errors, message.buttons, limits)
|
||||
elif message.kind == InteractiveKind.LIST:
|
||||
if limits.supports_list:
|
||||
_validate_list(errors, message, limits)
|
||||
else:
|
||||
errors.append("this channel can't show a list")
|
||||
elif message.kind == InteractiveKind.CTA_URL:
|
||||
if limits.supports_cta:
|
||||
_validate_cta(errors, message)
|
||||
else:
|
||||
errors.append("this channel can't show a link button")
|
||||
return errors
|
||||
|
||||
|
||||
def to_text_fallback(message: InteractiveMessage, max_length: Optional[int] = None) -> str:
|
||||
"""The same choices as plain text, for when the interactive form can't be
|
||||
sent: the customer can still answer by typing the option. When the
|
||||
channel caps message length, the body is shortened — never the choices."""
|
||||
if message.kind == InteractiveKind.CTA_URL:
|
||||
tail = f"{message.display_text}: {message.url}" if message.display_text else message.url
|
||||
suffix = f"\n{tail}"
|
||||
else:
|
||||
lines = [f"{i}. {_fallback_line(c)}" for i, c in enumerate(all_choices(message), start=1)]
|
||||
suffix = "\n\n" + "\n".join(lines) if lines else ""
|
||||
body = message.body
|
||||
if max_length is not None and len(body) + len(suffix) > max_length:
|
||||
body = truncate(body, max(max_length - len(suffix), len(ELLIPSIS)))
|
||||
return body + suffix
|
||||
|
||||
|
||||
def choice_text(choice) -> str:
|
||||
"""The full text of one choice: its title, or for a button or row whose
|
||||
title had to be cut, its description (see from_options)."""
|
||||
description = getattr(choice, "description", None)
|
||||
if description and choice.title.endswith(ELLIPSIS):
|
||||
return description
|
||||
return choice.title
|
||||
|
||||
|
||||
def all_choices(message: InteractiveMessage) -> list:
|
||||
"""Every button and list row, in order."""
|
||||
return list(message.buttons) + [r for s in message.sections for r in s.rows]
|
||||
|
||||
|
||||
def _fallback_line(choice) -> str:
|
||||
text = choice_text(choice)
|
||||
description = getattr(choice, "description", None)
|
||||
# A real description (not the cut title's full text) is worth showing too
|
||||
return f"{text} — {description}" if description and description != text else text
|
||||
|
||||
|
||||
def _check_text(errors: List[str], name: str, value: Optional[str], limit: int,
|
||||
required: bool = False) -> None:
|
||||
if not value or not value.strip():
|
||||
if required:
|
||||
errors.append(f"{name} is required")
|
||||
return
|
||||
if len(value) > limit:
|
||||
errors.append(f"{name} must be at most {limit} characters")
|
||||
|
||||
|
||||
def _check_ids(errors: List[str], name: str, ids: List[str], limits: InteractiveLimits) -> None:
|
||||
if any(not i or len(i.encode()) > limits.id_max_bytes for i in ids):
|
||||
errors.append(f"every {name} id must be 1-{limits.id_max_bytes} bytes")
|
||||
if len(set(ids)) != len(ids):
|
||||
errors.append(f"{name} ids must be unique")
|
||||
|
||||
|
||||
def _validate_buttons(errors: List[str], buttons: List[InteractiveButton], limits: InteractiveLimits) -> None:
|
||||
if not 1 <= len(buttons) <= limits.max_buttons:
|
||||
errors.append(f"buttons needs 1-{limits.max_buttons} buttons")
|
||||
for button in buttons:
|
||||
_check_text(errors, "button title", button.title, limits.button_title_max, required=True)
|
||||
_check_ids(errors, "button", [b.id for b in buttons], limits)
|
||||
|
||||
|
||||
def _validate_list(errors: List[str], message: InteractiveMessage, limits: InteractiveLimits) -> None:
|
||||
_check_text(errors, "list button label", message.list_button_label,
|
||||
LIST_BUTTON_LABEL_MAX, required=True)
|
||||
rows = [r for s in message.sections for r in s.rows]
|
||||
if not message.sections or any(not s.rows for s in message.sections):
|
||||
errors.append("every list section needs at least one row")
|
||||
if not 1 <= len(rows) <= limits.max_list_rows:
|
||||
errors.append(f"a list needs 1-{limits.max_list_rows} rows in total")
|
||||
if len(message.sections) > 1 and any(not s.title for s in message.sections):
|
||||
errors.append("section titles are required when there is more than one section")
|
||||
for section in message.sections:
|
||||
_check_text(errors, "section title", section.title, SECTION_TITLE_MAX)
|
||||
for row in rows:
|
||||
_check_text(errors, "row title", row.title, limits.row_title_max, required=True)
|
||||
_check_text(errors, "row description", row.description, limits.row_description_max)
|
||||
_check_ids(errors, "row", [r.id for r in rows], limits)
|
||||
|
||||
|
||||
def _validate_cta(errors: List[str], message: InteractiveMessage) -> None:
|
||||
_check_text(errors, "display text", message.display_text, CTA_DISPLAY_TEXT_MAX, required=True)
|
||||
if not (message.url or "").lower().startswith("https://"):
|
||||
errors.append("url must be an https:// link")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
What each channel's tappable choices can hold. Dependency-free so adapters
|
||||
(channels/base.py) and the builders (channels/interactive.py) can share it.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.channels.constants import MAX_CHOICE_LENGTH, MAX_CHOICE_OPTIONS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InteractiveLimits:
|
||||
max_buttons: int
|
||||
button_title_max: int
|
||||
body_max: int
|
||||
# Choice ids are checked in bytes: Telegram counts bytes, and so does
|
||||
# every other platform's limit once an id isn't ASCII
|
||||
id_max_bytes: int
|
||||
supports_list: bool = False
|
||||
max_list_rows: int = 0
|
||||
row_title_max: int = 0
|
||||
row_description_max: int = 0
|
||||
supports_cta: bool = False
|
||||
# Also write the choices into the text: where some of the platform's
|
||||
# apps don't show the buttons (Instagram on desktop and the web)
|
||||
options_in_text: bool = False
|
||||
|
||||
|
||||
# https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-reply-buttons-messages
|
||||
WHATSAPP_LIMITS = InteractiveLimits(
|
||||
max_buttons=3, button_title_max=20, body_max=1024, id_max_bytes=256,
|
||||
supports_list=True, max_list_rows=MAX_CHOICE_OPTIONS, row_title_max=24,
|
||||
row_description_max=MAX_CHOICE_LENGTH, supports_cta=True,
|
||||
)
|
||||
|
||||
# Quick replies: https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies
|
||||
# body_max is each channel's cap on a message's text, which the adapters use too
|
||||
MESSENGER_LIMITS = InteractiveLimits(max_buttons=13, button_title_max=20, body_max=2000, id_max_bytes=1000)
|
||||
|
||||
# Same quick replies, but Instagram doesn't show them on desktop or the web
|
||||
INSTAGRAM_LIMITS = InteractiveLimits(max_buttons=13, button_title_max=20, body_max=1000, id_max_bytes=1000,
|
||||
options_in_text=True)
|
||||
|
||||
# Inline keyboard: https://core.telegram.org/bots/api#inlinekeyboardbutton
|
||||
TELEGRAM_LIMITS = InteractiveLimits(max_buttons=MAX_CHOICE_OPTIONS, button_title_max=64, body_max=4096,
|
||||
id_max_bytes=64)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
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 base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
|
||||
from app.channels.registry import register_adapter
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
LINE_API_BASE = external_base("LINE_API_BASE", "https://api.line.me/v2/bot")
|
||||
REQUEST_TIMEOUT_SECONDS = 15.0
|
||||
# https://developers.line.biz/en/reference/messaging-api/#text-message
|
||||
MAX_MESSAGE_LENGTH = 5000
|
||||
# Loading-animation duration: 5–60, in multiples of 5 (auto-dismissed on reply)
|
||||
LOADING_ANIMATION_SECONDS = 20
|
||||
|
||||
_http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
||||
def _get_http_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None or _http_client.is_closed:
|
||||
_http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
return _http_client
|
||||
|
||||
|
||||
def credentials(account: ChannelAccount) -> dict:
|
||||
return json.loads(decrypt_api_key(account.encrypted_credentials))
|
||||
|
||||
|
||||
async def get_bot_info(channel_access_token: str) -> Optional[dict]:
|
||||
"""Validate a channel access token; returns the bot info or None."""
|
||||
try:
|
||||
response = await _get_http_client().get(
|
||||
f"{LINE_API_BASE}/info",
|
||||
headers={"Authorization": f"Bearer {channel_access_token}"},
|
||||
)
|
||||
return response.json() if response.status_code < 300 else None
|
||||
except Exception as e:
|
||||
logger.error(f"LINE bot info failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def set_webhook_endpoint(channel_access_token: str, url: str) -> bool:
|
||||
"""Point the LINE channel's webhook at us (best-effort; can also be set in
|
||||
the LINE Developers console)."""
|
||||
try:
|
||||
response = await _get_http_client().put(
|
||||
f"{LINE_API_BASE}/channel/webhook/endpoint",
|
||||
headers={"Authorization": f"Bearer {channel_access_token}"},
|
||||
json={"endpoint": url},
|
||||
)
|
||||
return response.status_code < 300
|
||||
except Exception as e:
|
||||
logger.error(f"LINE set webhook failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class LineAdapter(ChannelAdapter):
|
||||
"""LINE Messaging API. Conversations are keyed by the LINE userId; replies
|
||||
go out as push messages (reply tokens expire too quickly for AI latency)."""
|
||||
|
||||
channel_type: ClassVar[str] = ChannelType.LINE.value
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
"""X-Line-Signature: base64(HMAC-SHA256(channel_secret, body))."""
|
||||
if account is None:
|
||||
return False
|
||||
signature = headers.get("x-line-signature", "")
|
||||
try:
|
||||
secret = credentials(account).get("channel_secret", "")
|
||||
except Exception as e:
|
||||
logger.error(f"LINE credential decrypt failed for {account.id}: {e}")
|
||||
return False
|
||||
if not signature or not secret:
|
||||
return False
|
||||
expected = base64.b64encode(
|
||||
hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
|
||||
).decode()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize webhook events; only one-on-one text messages become
|
||||
InboundMessages."""
|
||||
messages: List[InboundMessage] = []
|
||||
for event in payload.get("events", []):
|
||||
message = event.get("message") or {}
|
||||
source = event.get("source") or {}
|
||||
if event.get("type") != "message" or message.get("type") != "text":
|
||||
continue
|
||||
user_id = source.get("userId")
|
||||
if not user_id:
|
||||
continue
|
||||
timestamp = None
|
||||
if event.get("timestamp"):
|
||||
timestamp = datetime.fromtimestamp(event["timestamp"] / 1000, tz=timezone.utc)
|
||||
messages.append(InboundMessage(
|
||||
external_account_id=str(payload.get("destination", "")),
|
||||
external_conversation_id=str(user_id),
|
||||
external_user_id=str(user_id),
|
||||
external_message_id=str(message.get("id", "")),
|
||||
text=message.get("text", ""),
|
||||
timestamp=timestamp,
|
||||
))
|
||||
return messages
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
try:
|
||||
response = await _get_http_client().post(
|
||||
f"{LINE_API_BASE}/message/push",
|
||||
headers={"Authorization": f"Bearer {credentials(account)['channel_access_token']}"},
|
||||
json={
|
||||
"to": conversation.external_conversation_id,
|
||||
"messages": [{"type": "text", "text": text}],
|
||||
},
|
||||
)
|
||||
if response.status_code >= 300:
|
||||
try:
|
||||
error = response.json().get("message", f"HTTP {response.status_code}")
|
||||
except Exception:
|
||||
error = f"HTTP {response.status_code}"
|
||||
return SendResult(ok=False, error=str(error))
|
||||
return SendResult(ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"LINE send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
# LINE's loading animation (1:1 chats only); auto-dismisses when the
|
||||
# reply is sent or after loadingSeconds.
|
||||
try:
|
||||
await _get_http_client().post(
|
||||
f"{LINE_API_BASE}/chat/loading/start",
|
||||
headers={"Authorization": f"Bearer {credentials(account)['channel_access_token']}"},
|
||||
json={
|
||||
"chatId": conversation.external_conversation_id,
|
||||
"loadingSeconds": LOADING_ANIMATION_SECONDS,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"LINE loading animation failed (non-critical): {e}")
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
return markdown[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
|
||||
register_adapter(LineAdapter())
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
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 ClassVar, List, Optional
|
||||
|
||||
from app.channels.base import (
|
||||
InboundMessage, InteractiveKind, InteractiveMessage, InteractiveReply, InteractiveReplyType, SendResult,
|
||||
)
|
||||
from app.channels.interactive import to_text_fallback
|
||||
from app.channels.interactive_limits import MESSENGER_LIMITS, InteractiveLimits
|
||||
from app.channels.meta_base import GRAPH_BASE, MetaBaseAdapter, graph_get, graph_post
|
||||
from app.channels.registry import register_adapter
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Messenger send API limit
|
||||
MAX_MESSAGE_LENGTH = MESSENGER_LIMITS.body_max
|
||||
|
||||
|
||||
class MessengerAdapter(MetaBaseAdapter):
|
||||
"""Facebook Messenger. Instagram DM shares the same entry[].messaging[]
|
||||
envelope and send shape, so InstagramAdapter subclasses this — overriding
|
||||
only the host it talks to and the fields that name a sender."""
|
||||
|
||||
channel_type: ClassVar[str] = ChannelType.MESSENGER.value
|
||||
# Instagram Login accounts hold an Instagram user token, which only
|
||||
# graph.instagram.com accepts; Page tokens only work on the Facebook graph.
|
||||
graph_base: ClassVar[str] = GRAPH_BASE
|
||||
# Graph fields on the sender node that carry a display name. Instagram user
|
||||
# nodes expose different ones, so it overrides this and _display_name.
|
||||
profile_fields: ClassVar[str] = "first_name,last_name"
|
||||
# Extra keys on a send. messaging_type is a Messenger Platform parameter and
|
||||
# is not part of Instagram's documented send body, so Instagram sends none.
|
||||
send_extras: ClassVar[dict] = {"messaging_type": "RESPONSE"}
|
||||
# Choices go as quick replies (buttons only: no lists or link buttons)
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = MESSENGER_LIMITS
|
||||
max_text_length: ClassVar[Optional[int]] = MAX_MESSAGE_LENGTH
|
||||
|
||||
@staticmethod
|
||||
def _event_seconds(raw) -> Optional[int]:
|
||||
"""Event time in seconds, or None if absent/unparseable.
|
||||
|
||||
Messenger sends milliseconds as a number; Instagram's changes payload
|
||||
sends seconds as a string, which the old `// 1000` raised a TypeError on
|
||||
— crashing the whole webhook rather than dropping one timestamp.
|
||||
"""
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
# 13-digit values are milliseconds; 10-digit ones are already seconds.
|
||||
return value // 1000 if value > 10_000_000_000 else value
|
||||
|
||||
def _events(self, entry: dict) -> List[dict]:
|
||||
"""The messaging events on one entry. Messenger always uses messaging[];
|
||||
Instagram overrides this because it can deliver the same event under
|
||||
changes[] instead."""
|
||||
return entry.get("messaging") or []
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize per-entry messaging events. Echoes, delivery/read receipts
|
||||
and attachment-only messages yield nothing."""
|
||||
messages: List[InboundMessage] = []
|
||||
for entry in payload.get("entry", []):
|
||||
account_id = str(entry.get("id", ""))
|
||||
for event in self._events(entry):
|
||||
message = event.get("message")
|
||||
if not message or message.get("is_echo"):
|
||||
continue
|
||||
text = message.get("text")
|
||||
if not text:
|
||||
continue
|
||||
sender_id = str((event.get("sender") or {}).get("id", ""))
|
||||
if not sender_id:
|
||||
continue
|
||||
messages.append(InboundMessage(
|
||||
external_account_id=account_id,
|
||||
external_conversation_id=sender_id,
|
||||
external_user_id=sender_id,
|
||||
external_message_id=str(message.get("mid", "")),
|
||||
text=text,
|
||||
profile={},
|
||||
timestamp=self._timestamp(self._event_seconds(event.get("timestamp"))),
|
||||
interactive=_quick_reply(message),
|
||||
# Only a swipe-reply carries this; a quick-reply tap doesn't
|
||||
reply_to_message_id=(message.get("reply_to") or {}).get("mid"),
|
||||
))
|
||||
return messages
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
return await self._send(account, conversation, {"text": text})
|
||||
|
||||
async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: InteractiveMessage) -> SendResult:
|
||||
"""The choices as quick replies under the text — and, where some of the
|
||||
platform's apps don't show quick replies, written into it as well."""
|
||||
if message.kind != InteractiveKind.BUTTONS:
|
||||
return SendResult(ok=False, error=f"{self.channel_type} only sends choices as quick replies")
|
||||
limits = self.interactive_limits
|
||||
text = to_text_fallback(message, limits.body_max) if limits.options_in_text else message.body
|
||||
return await self._send(account, conversation, {
|
||||
"text": text,
|
||||
"quick_replies": [{"content_type": "text", "title": b.title, "payload": b.id}
|
||||
for b in message.buttons],
|
||||
})
|
||||
|
||||
async def _send(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: dict) -> SendResult:
|
||||
# `me/messages` resolves to the page/account owning the access token
|
||||
return await graph_post(
|
||||
"me/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"recipient": {"id": conversation.external_conversation_id},
|
||||
**self.send_extras,
|
||||
"message": message,
|
||||
},
|
||||
base=self.graph_base,
|
||||
)
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
"""Dismissed automatically when the reply is delivered, or after ~20s."""
|
||||
try:
|
||||
result = await graph_post(
|
||||
"me/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"recipient": {"id": conversation.external_conversation_id},
|
||||
"sender_action": "typing_on",
|
||||
},
|
||||
base=self.graph_base,
|
||||
)
|
||||
if not result.ok:
|
||||
logger.debug(f"{self.channel_type} typing_on failed (non-critical): {result.error}")
|
||||
except Exception as e:
|
||||
logger.debug(f"{self.channel_type} typing_on failed (non-critical): {e}")
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
return markdown[:self.max_text_length]
|
||||
|
||||
async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict:
|
||||
"""The customer's name, which the webhook never carries — Messenger
|
||||
sends only a PSID, so without this every customer shows as
|
||||
"Messenger user 2742…" in People.
|
||||
|
||||
Best-effort: a lookup failure must not cost us the message, so it
|
||||
degrades to {} (the placeholder stays) rather than raising. Reading a
|
||||
sender's name needs no permission beyond messaging them.
|
||||
"""
|
||||
ok, data = await graph_get(external_user_id, self.access_token(account),
|
||||
params={"fields": self.profile_fields},
|
||||
base=self.graph_base)
|
||||
if not ok:
|
||||
return {}
|
||||
name = self._display_name(data)
|
||||
return {"name": name} if name else {}
|
||||
|
||||
@staticmethod
|
||||
def _display_name(data: dict) -> str:
|
||||
return " ".join(p for p in (data.get("first_name"), data.get("last_name")) if p).strip()
|
||||
|
||||
|
||||
def _quick_reply(message: dict) -> Optional[InteractiveReply]:
|
||||
"""A tapped quick reply: its payload is the choice's id, the text its title."""
|
||||
payload = (message.get("quick_reply") or {}).get("payload")
|
||||
if not payload:
|
||||
return None
|
||||
return InteractiveReply(type=InteractiveReplyType.BUTTON_REPLY, id=str(payload), title=message.get("text") or "")
|
||||
|
||||
|
||||
register_adapter(MessengerAdapter())
|
||||
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
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 hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar, Iterable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.channels.base import ChannelAdapter, SendResult, WindowStatus
|
||||
from app.core.config import settings
|
||||
from app.core.security import decrypt_credentials
|
||||
from app.models.channels import ChannelAccount, ChannelConversation
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
GRAPH_BASE = external_base("META_GRAPH_BASE", "https://graph.facebook.com")
|
||||
# Instagram API with Instagram Login speaks to its own host with an Instagram
|
||||
# user token; the Facebook graph rejects those. Page-based channels keep using
|
||||
# GRAPH_BASE, so every helper below takes the base rather than assuming one.
|
||||
GRAPH_INSTAGRAM_BASE = external_base("META_GRAPH_INSTAGRAM_BASE", "https://graph.instagram.com")
|
||||
INSTAGRAM_OAUTH_BASE = "https://api.instagram.com"
|
||||
REQUEST_TIMEOUT_SECONDS = 15.0
|
||||
SEND_RETRIES = 2
|
||||
# Graph error codes meaning the access token is unusable: 190 expired or
|
||||
# invalidated token, 102 expired session, 0 WhatsApp's "Authentication Error"
|
||||
GRAPH_AUTH_ERROR_CODES = frozenset({0, 102, 190})
|
||||
# Meta customer-service window: free-form replies allowed for 24h after the
|
||||
# customer's last inbound message (applies to WhatsApp, Messenger and Instagram).
|
||||
WINDOW_HOURS = 24
|
||||
|
||||
# Shared keep-alive client to graph.facebook.com (process-lifetime, like the
|
||||
# Telegram adapter's) — avoids a TCP+TLS handshake per outbound message.
|
||||
_http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
||||
def _get_http_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None or _http_client.is_closed:
|
||||
_http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
return _http_client
|
||||
|
||||
|
||||
def graph_url(path: str, base: str = GRAPH_BASE) -> str:
|
||||
return f"{base}/{settings.META_GRAPH_VERSION}/{path}"
|
||||
|
||||
|
||||
def verify_meta_signature(raw_body: bytes, signature_header: str) -> bool:
|
||||
"""Validate the X-Hub-Signature-256 header against our app secrets.
|
||||
|
||||
The signature covers the raw request body; it must be checked before the
|
||||
body is parsed. Uses a constant-time comparison.
|
||||
|
||||
Instagram Login webhooks are signed with the Instagram app secret rather
|
||||
than the Meta one, and the only thing that would say which product sent this
|
||||
is inside the body we have not authenticated yet. So this tries each secret
|
||||
we own instead of trusting unverified content to choose one; an attacker
|
||||
still has to forge one of them.
|
||||
"""
|
||||
return signed_by_any(raw_body, signature_header,
|
||||
(settings.META_APP_SECRET, settings.INSTAGRAM_APP_SECRET))
|
||||
|
||||
|
||||
def signed_by_any(raw_body: bytes, signature_header: str, app_secrets: Iterable[Optional[str]]) -> bool:
|
||||
"""Whether X-Hub-Signature-256 was made with one of these app secrets
|
||||
(constant-time; empty secrets are skipped)."""
|
||||
if not signature_header or not signature_header.startswith("sha256="):
|
||||
return False
|
||||
provided = signature_header[len("sha256="):]
|
||||
for app_secret in app_secrets:
|
||||
if not app_secret:
|
||||
continue
|
||||
expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(expected, provided):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_challenge(mode: Optional[str], token: Optional[str], challenge: Optional[str]) -> Optional[str]:
|
||||
"""Answer a webhook GET verification handshake.
|
||||
|
||||
Returns the challenge string to echo when the subscribe token matches, else
|
||||
None (caller should 403).
|
||||
"""
|
||||
if mode == "subscribe" and token and token == settings.META_WEBHOOK_VERIFY_TOKEN:
|
||||
return challenge
|
||||
return None
|
||||
|
||||
|
||||
async def graph_post(path: str, access_token: str, payload: dict,
|
||||
base: str = GRAPH_BASE) -> SendResult:
|
||||
"""POST to the Graph API with small exponential backoff on transient errors.
|
||||
|
||||
Returns a SendResult; 4xx errors are terminal (no retry), network/5xx are
|
||||
retried up to SEND_RETRIES times. `base` selects the host — Instagram Login
|
||||
accounts send via graph.instagram.com, everything else via graph.facebook.com.
|
||||
"""
|
||||
url = graph_url(path, base)
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
last_error = "unknown error"
|
||||
for attempt in range(SEND_RETRIES + 1):
|
||||
try:
|
||||
response = await _get_http_client().post(url, json=payload, headers=headers)
|
||||
if response.status_code < 300:
|
||||
data = response.json()
|
||||
message_id = _extract_message_id(data)
|
||||
return SendResult(ok=True, external_message_id=message_id)
|
||||
# 4xx are our fault (bad token, closed window) — don't retry
|
||||
if response.status_code < 500:
|
||||
return SendResult(ok=False, error=_graph_error(response),
|
||||
auth_failed=_is_auth_error(response))
|
||||
last_error = _graph_error(response)
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
if attempt < SEND_RETRIES:
|
||||
await asyncio.sleep(0.5 * (2 ** attempt))
|
||||
return SendResult(ok=False, error=last_error)
|
||||
|
||||
|
||||
async def _graph_request(method: str, path: str, access_token: Optional[str],
|
||||
params: Optional[dict] = None,
|
||||
json_body: Optional[dict] = None,
|
||||
base: str = GRAPH_BASE) -> tuple[bool, dict]:
|
||||
"""Issue a Graph call and return (ok, decoded-json).
|
||||
|
||||
Unlike graph_post this keeps the whole response body, for calls where the
|
||||
body is the result rather than just a message id. The token goes in the
|
||||
Authorization header so it never appears in URLs or proxy logs; pass None
|
||||
for the one endpoint that authenticates with app credentials instead. These
|
||||
are management calls, not sends, so a failure surfaces to the caller
|
||||
instead of being retried.
|
||||
"""
|
||||
try:
|
||||
response = await _get_http_client().request(
|
||||
method,
|
||||
graph_url(path, base),
|
||||
params=params or None,
|
||||
json=json_body,
|
||||
headers={"Authorization": f"Bearer {access_token}"} if access_token else {},
|
||||
)
|
||||
return (response.status_code < 300, response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Graph {method} {path} failed: {e}")
|
||||
return (False, {"error": {"message": str(e)}})
|
||||
|
||||
|
||||
async def graph_get(path: str, access_token: str, params: Optional[dict] = None,
|
||||
base: str = GRAPH_BASE) -> tuple[bool, dict]:
|
||||
"""GET a Graph node (validating tokens during onboarding, listing templates)."""
|
||||
return await _graph_request("GET", path, access_token, params=params, base=base)
|
||||
|
||||
|
||||
def graph_detail(data: dict, fallback: str) -> str:
|
||||
"""Meta's own error text where it has one, so the UI can show the real
|
||||
reason (e.g. a template that cannot be deleted) rather than a generic
|
||||
failure.
|
||||
|
||||
error_user_msg first: `message` is often a generic OAuth string that says
|
||||
nothing actionable — Graph will pair a `message` of "Application does not
|
||||
have permission for this action" with an error_user_msg that names the
|
||||
actual asset and rule. Only one of those is a clue.
|
||||
"""
|
||||
error = data.get("error", {})
|
||||
if not isinstance(error, dict):
|
||||
return fallback
|
||||
return error.get("error_user_msg") or error.get("message") or fallback
|
||||
|
||||
|
||||
async def graph_post_json(path: str, access_token: str, payload: dict) -> tuple[bool, dict]:
|
||||
"""POST to a Graph node, keeping the response body (e.g. creating a message
|
||||
template returns its id, status and category)."""
|
||||
return await _graph_request("POST", path, access_token, json_body=payload)
|
||||
|
||||
|
||||
async def debug_token(input_token: str) -> tuple[bool, dict]:
|
||||
"""Inspect a token: what it is, who it belongs to, and whether it is ours.
|
||||
|
||||
Returns Meta's `data` object, which for a Page token carries
|
||||
`type: "PAGE"`, `profile_id` (the page id), `app_id`, `is_valid` and the
|
||||
granted `scopes`.
|
||||
|
||||
This authenticates with app credentials rather than the token being
|
||||
inspected, so it needs no permission on the asset. Reading the Page node
|
||||
directly (`me?fields=id,name`) would instead demand `pages_read_engagement`
|
||||
— unrelated to messaging, and absent from a token that can nonetheless send
|
||||
perfectly well.
|
||||
"""
|
||||
app_token = f"{settings.META_APP_ID}|{settings.META_APP_SECRET}"
|
||||
ok, data = await _graph_request("GET", "debug_token", app_token,
|
||||
params={"input_token": input_token})
|
||||
if not ok:
|
||||
return False, data
|
||||
return True, data.get("data", {})
|
||||
|
||||
|
||||
async def exchange_signup_code(code: str, redirect_uri: Optional[str] = None) -> tuple[bool, dict]:
|
||||
"""Trade a login code for the customer's access token.
|
||||
|
||||
The app credentials authenticate this call, so it is the one Graph request
|
||||
that carries no bearer token.
|
||||
|
||||
redirect_uri must match whatever the login dialog used, or Graph rejects the
|
||||
code as a mismatch:
|
||||
- WhatsApp Embedded Signup issues its code through the JS SDK with no
|
||||
redirect_uri, so it passes None and we omit the param.
|
||||
- Facebook Login for Business runs as a first-party OAuth popup bound to
|
||||
our own callback URL, so that flow passes that exact callback URL.
|
||||
"""
|
||||
params = {
|
||||
"client_id": settings.META_APP_ID,
|
||||
"client_secret": settings.META_APP_SECRET,
|
||||
"code": code,
|
||||
}
|
||||
if redirect_uri is not None:
|
||||
params["redirect_uri"] = redirect_uri
|
||||
return await _graph_request("GET", "oauth/access_token", None, params=params)
|
||||
|
||||
|
||||
async def exchange_for_long_lived_token(short_lived_token: str) -> tuple[bool, dict]:
|
||||
"""Trade a user token for a 60-day one.
|
||||
|
||||
Page tokens inherit their lifetime from the user token they were read with:
|
||||
from a short-lived one they die in about an hour, from a long-lived one they
|
||||
never expire. We store the Page token and send with it for months, so this
|
||||
runs before /me/accounts. An already-long-lived token comes back unchanged,
|
||||
so it is safe to call unconditionally. App credentials authenticate it, so
|
||||
like exchange_signup_code it carries no bearer token.
|
||||
"""
|
||||
return await _graph_request("GET", "oauth/access_token", None, params={
|
||||
"grant_type": "fb_exchange_token",
|
||||
"client_id": settings.META_APP_ID,
|
||||
"client_secret": settings.META_APP_SECRET,
|
||||
"fb_exchange_token": short_lived_token,
|
||||
})
|
||||
|
||||
|
||||
def instagram_token_payload(data: dict) -> dict:
|
||||
"""The token object out of an Instagram Login exchange response.
|
||||
|
||||
Instagram has returned this both flat and wrapped in a single-element `data`
|
||||
list depending on the API version, and the difference is invisible until it
|
||||
silently yields no token — so accept either shape.
|
||||
"""
|
||||
entries = data.get("data")
|
||||
if isinstance(entries, list) and entries and isinstance(entries[0], dict):
|
||||
return entries[0]
|
||||
return data
|
||||
|
||||
|
||||
async def exchange_instagram_code(code: str, redirect_uri: str) -> tuple[bool, dict]:
|
||||
"""Trade an Instagram Business Login code for a short-lived user token.
|
||||
|
||||
Instagram Login is a separate OAuth from the Facebook graph: its own host,
|
||||
its own app credentials, form-encoded rather than query params, and it
|
||||
returns an Instagram user token plus the account's user_id — no Page and no
|
||||
Page token anywhere in the flow.
|
||||
"""
|
||||
try:
|
||||
response = await _get_http_client().request(
|
||||
"POST",
|
||||
f"{INSTAGRAM_OAUTH_BASE}/oauth/access_token",
|
||||
data={
|
||||
"client_id": settings.INSTAGRAM_APP_ID,
|
||||
"client_secret": settings.INSTAGRAM_APP_SECRET,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri,
|
||||
"code": code,
|
||||
},
|
||||
)
|
||||
return (response.status_code < 300, response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Instagram code exchange failed: {e}")
|
||||
return (False, {"error": {"message": str(e)}})
|
||||
|
||||
|
||||
async def exchange_instagram_long_lived(short_lived_token: str) -> tuple[bool, dict]:
|
||||
"""Trade a short-lived Instagram token (~1 hour) for a 60-day one.
|
||||
|
||||
Without this the connection dies about an hour after onboarding, with no
|
||||
signal beyond messages quietly failing.
|
||||
"""
|
||||
try:
|
||||
response = await _get_http_client().request(
|
||||
"GET",
|
||||
f"{GRAPH_INSTAGRAM_BASE}/access_token",
|
||||
params={
|
||||
"grant_type": "ig_exchange_token",
|
||||
"client_secret": settings.INSTAGRAM_APP_SECRET,
|
||||
"access_token": short_lived_token,
|
||||
},
|
||||
)
|
||||
return (response.status_code < 300, response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Instagram long-lived exchange failed: {e}")
|
||||
return (False, {"error": {"message": str(e)}})
|
||||
|
||||
|
||||
async def subscribe_instagram_app(access_token: str) -> bool:
|
||||
"""Subscribe our app to this Instagram account's message webhooks.
|
||||
|
||||
Best-effort, like subscribe_app: onboarding still succeeds if it fails, and
|
||||
it can be retried by reconnecting.
|
||||
"""
|
||||
ok, _ = await _graph_request("POST", "me/subscribed_apps", access_token,
|
||||
params={"subscribed_fields": "messages"},
|
||||
base=GRAPH_INSTAGRAM_BASE)
|
||||
return ok
|
||||
|
||||
|
||||
async def register_phone_number(phone_number_id: str, access_token: str, pin: str) -> tuple[bool, dict]:
|
||||
"""Enable a number for Cloud API sending.
|
||||
|
||||
A number onboarded through Embedded Signup is attached to the WABA but not
|
||||
yet usable; registering it with a two-step-verification PIN is what makes it
|
||||
able to send.
|
||||
"""
|
||||
return await _graph_request("POST", f"{phone_number_id}/register", access_token,
|
||||
json_body={"messaging_product": "whatsapp", "pin": pin})
|
||||
|
||||
|
||||
# Template fields worth reading back from Graph; components carries the body
|
||||
# text and any {{n}} variables the UI has to prompt for.
|
||||
TEMPLATE_FIELDS = "name,status,category,language,components"
|
||||
TEMPLATE_PAGE_LIMIT = 100
|
||||
# Backstop against paging forever on a malformed cursor. Well above Meta's own
|
||||
# per-WABA template ceiling, so a real account never reaches it.
|
||||
TEMPLATE_MAX_PAGES = 10
|
||||
|
||||
|
||||
async def graph_list_all(path: str, access_token: str, params: dict,
|
||||
max_pages: int = TEMPLATE_MAX_PAGES) -> tuple[bool, list | dict]:
|
||||
"""Every node on a Graph edge, following its cursor.
|
||||
|
||||
A node that exists but isn't listed is an invisible failure — a Page the
|
||||
customer administers but never sees offered, a template an agent cannot pick
|
||||
— so this pages rather than stopping at the first page. Returns
|
||||
(True, [node dicts]) or (False, graph error body), matching the other
|
||||
helpers here. A `limit` in params lets a short final page end the walk one
|
||||
round-trip early; without one it stops when Graph offers no `after` cursor.
|
||||
"""
|
||||
items: list = []
|
||||
limit = params.get("limit")
|
||||
for _ in range(max_pages):
|
||||
ok, data = await graph_get(path, access_token, params=params)
|
||||
if not ok:
|
||||
return False, data
|
||||
page = data.get("data")
|
||||
if not isinstance(page, list):
|
||||
return False, data
|
||||
items.extend(page)
|
||||
|
||||
after = (data.get("paging") or {}).get("cursors", {}).get("after")
|
||||
if not after or (limit is not None and len(page) < limit):
|
||||
return True, items
|
||||
params = {**params, "after": after}
|
||||
|
||||
logger.warning(f"Stopped paging {path} at {max_pages} pages")
|
||||
return True, items
|
||||
|
||||
|
||||
async def fetch_message_templates(waba_id: str, access_token: str) -> tuple[bool, list | dict]:
|
||||
"""Every template on the WABA (see graph_list_all for the paging rationale)."""
|
||||
return await graph_list_all(
|
||||
f"{waba_id}/message_templates", access_token,
|
||||
{"fields": TEMPLATE_FIELDS, "limit": TEMPLATE_PAGE_LIMIT})
|
||||
|
||||
|
||||
async def subscribe_app(node_id: str, access_token: str, subscribed_fields: Optional[str] = None) -> bool:
|
||||
"""Subscribe our app to a Page/WABA so its messages reach our webhook.
|
||||
Best-effort: onboarding still succeeds if this fails (can be retried)."""
|
||||
payload = {}
|
||||
if subscribed_fields:
|
||||
payload["subscribed_fields"] = subscribed_fields
|
||||
result = await graph_post(f"{node_id}/subscribed_apps", access_token, payload)
|
||||
return result.ok
|
||||
|
||||
|
||||
def _extract_message_id(data: dict) -> Optional[str]:
|
||||
# WhatsApp: {"messages":[{"id":...}]}; Messenger/IG: {"message_id":...}
|
||||
if isinstance(data.get("messages"), list) and data["messages"]:
|
||||
return str(data["messages"][0].get("id", "")) or None
|
||||
return data.get("message_id")
|
||||
|
||||
|
||||
def graph_auth_refused(body) -> bool:
|
||||
"""Whether a Graph error body says the access token itself is unusable
|
||||
(expired, revoked, or never valid), as opposed to refusing this one call."""
|
||||
error = body.get("error") if isinstance(body, dict) else None
|
||||
return isinstance(error, dict) and error.get("code") in GRAPH_AUTH_ERROR_CODES
|
||||
|
||||
|
||||
def _is_auth_error(response: httpx.Response) -> bool:
|
||||
if response.status_code == 401:
|
||||
return True
|
||||
try:
|
||||
return graph_auth_refused(response.json())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _graph_error(response: httpx.Response) -> str:
|
||||
try:
|
||||
body = response.json()
|
||||
return str(body.get("error", {}).get("message") or body)
|
||||
except Exception:
|
||||
return f"HTTP {response.status_code}"
|
||||
|
||||
|
||||
class MetaBaseAdapter(ChannelAdapter):
|
||||
"""Shared behavior for the Meta messaging channels (WhatsApp, Messenger,
|
||||
Instagram): one webhook signature scheme, one Graph client, one 24h
|
||||
customer-service window. Subclasses implement parse_inbound and send_text."""
|
||||
|
||||
# What an expired window means for this channel: WhatsApp can reopen with a
|
||||
# template, Messenger/Instagram simply cannot deliver.
|
||||
expired_status: ClassVar[WindowStatus] = WindowStatus.UNDELIVERABLE
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
return verify_meta_signature(raw_body, headers.get("x-hub-signature-256", ""))
|
||||
|
||||
def check_delivery_window(self, conversation: ChannelConversation) -> WindowStatus:
|
||||
last_inbound = conversation.last_inbound_at
|
||||
if last_inbound is None:
|
||||
return self.expired_status
|
||||
if last_inbound.tzinfo is None:
|
||||
last_inbound = last_inbound.replace(tzinfo=timezone.utc)
|
||||
age_hours = (datetime.now(timezone.utc) - last_inbound).total_seconds() / 3600
|
||||
return WindowStatus.OK if age_hours < WINDOW_HOURS else self.expired_status
|
||||
|
||||
@staticmethod
|
||||
def access_token(account: ChannelAccount) -> str:
|
||||
"""Decrypt the account's Graph access token (page or WABA token)."""
|
||||
return decrypt_credentials(account.encrypted_credentials)["access_token"]
|
||||
|
||||
@staticmethod
|
||||
def _timestamp(value) -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.fromtimestamp(int(value), tz=timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
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 Dict, Optional
|
||||
|
||||
from app.channels.base import ChannelAdapter
|
||||
|
||||
_adapters: Dict[str, ChannelAdapter] = {}
|
||||
|
||||
|
||||
def register_adapter(adapter: ChannelAdapter) -> ChannelAdapter:
|
||||
"""Register a stateless adapter singleton for its channel_type."""
|
||||
_adapters[adapter.channel_type] = adapter
|
||||
return adapter
|
||||
|
||||
|
||||
def get_adapter(channel_type: str) -> Optional[ChannelAdapter]:
|
||||
"""Adapter for a channel, or None for channels without one ('web')."""
|
||||
_ensure_loaded()
|
||||
return _adapters.get(channel_type)
|
||||
|
||||
|
||||
_loaded = False
|
||||
|
||||
|
||||
def _ensure_loaded() -> None:
|
||||
"""Import adapter modules on first use (each registers itself).
|
||||
Lazy so importing app.channels never pulls every adapter's deps."""
|
||||
global _loaded
|
||||
if _loaded:
|
||||
return
|
||||
_loaded = True
|
||||
from app.channels import telegram # noqa: F401
|
||||
from app.channels import whatsapp # noqa: F401
|
||||
from app.channels import messenger # noqa: F401
|
||||
from app.channels import instagram # noqa: F401
|
||||
from app.channels import slack # noqa: F401
|
||||
from app.channels import email # noqa: F401
|
||||
from app.channels import sms # noqa: F401
|
||||
from app.channels import line # noqa: F401
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
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 stored record of the choices a message actually went out with — what the
|
||||
inbox shows under it, and what a later tap on one of them is read against.
|
||||
Encrypted: the choices can echo what the customer said.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.channels.base import InteractiveMessage
|
||||
from app.channels.interactive import all_choices, choice_text
|
||||
from app.core.encryption import decrypt_json, encrypt_json
|
||||
|
||||
# chat_history.attributes key the record is stored under
|
||||
SENT_CHOICES_ATTRIBUTE = "interactive_sent"
|
||||
|
||||
|
||||
def encode_sent_choices(message: InteractiveMessage, as_text: bool, numbered: bool = False) -> dict:
|
||||
"""The record for one sent message; `as_text` when the choices had to go
|
||||
as a numbered list instead of buttons; `numbered` when the customer saw
|
||||
them numbered either way (so a bare "2" picks the second)."""
|
||||
return encrypt_json({
|
||||
"kind": message.kind.value,
|
||||
"as_text": as_text,
|
||||
"numbered": as_text or numbered,
|
||||
"choices": [{"id": c.id, "text": choice_text(c)} for c in all_choices(message)],
|
||||
})
|
||||
|
||||
|
||||
def decode_sent_choices(value) -> Optional[dict]:
|
||||
record = decrypt_json(value)
|
||||
return record if isinstance(record, dict) else None
|
||||
|
||||
|
||||
def sent_numbered_choice(record: Optional[dict], typed: str) -> Optional[str]:
|
||||
"""The full text of the choice the customer typed the number of (1-based),
|
||||
when the record's choices were shown numbered. A number that is itself
|
||||
one of the choices ("2" guests) is that choice, not a position."""
|
||||
choices = (record or {}).get("choices") or []
|
||||
if not (record or {}).get("numbered") or not typed.isdecimal():
|
||||
return None
|
||||
if any((choice.get("text") or "").strip() == typed for choice in choices):
|
||||
return None
|
||||
number = int(typed)
|
||||
return choices[number - 1].get("text") if 1 <= number <= len(choices) else None
|
||||
|
||||
|
||||
def sent_choice_text(record: Optional[dict], choice_id: str) -> Optional[str]:
|
||||
"""The full text of the choice with this id in a sent-choices record."""
|
||||
for choice in (record or {}).get("choices") or []:
|
||||
if choice.get("id") == choice_id:
|
||||
return choice.get("text")
|
||||
return None
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
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 hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
|
||||
from app.channels.registry import register_adapter
|
||||
from app.core.config import settings
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
SLACK_API_BASE = external_base("SLACK_API_BASE", "https://slack.com/api")
|
||||
REQUEST_TIMEOUT_SECONDS = 15.0
|
||||
# Reject webhook timestamps older than this (replay protection, per Slack docs)
|
||||
MAX_SIGNATURE_AGE_SECONDS = 60 * 5
|
||||
# Slack recommends keeping messages well under the 40k hard limit
|
||||
MAX_MESSAGE_LENGTH = 12000
|
||||
|
||||
# App Home / Assistant (Agents & AI Apps) copy — kept in one place so the tone
|
||||
# stays consistent across the Messages tab, the Home tab, and the sidebar.
|
||||
MAX_SUGGESTED_PROMPTS = 4 # Slack caps setSuggestedPrompts at 4
|
||||
ASSISTANT_READY_STATUS = "is getting ready…"
|
||||
WELCOME_WITH_AGENT = (
|
||||
"Hi! I'm {agent}. 👋\n\n"
|
||||
"Ask me anything and I'll help right away — I answer from your team's "
|
||||
"knowledge and can bring in a human when you need one."
|
||||
)
|
||||
WELCOME_NO_AGENT = (
|
||||
"Hi! I'm ReplyPier. 👋\n\n"
|
||||
"No agent is connected to this workspace yet. Set one up in ReplyPier and "
|
||||
"I'll start answering here."
|
||||
)
|
||||
SUGGESTED_PROMPTS_TITLE = "Try asking:"
|
||||
STARTER_PROMPTS = [
|
||||
{"title": "What can you help with?", "message": "What can you help me with?"},
|
||||
{"title": "Talk to a human", "message": "I'd like to talk to a human."},
|
||||
]
|
||||
|
||||
_MENTION_RE = re.compile(r"<@[A-Z0-9]+>")
|
||||
|
||||
_http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
||||
def _get_http_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None or _http_client.is_closed:
|
||||
_http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
return _http_client
|
||||
|
||||
|
||||
async def slack_api(method: str, access_token: str, payload: dict, form: bool = False) -> dict:
|
||||
# Write methods (chat.postMessage/update) accept JSON; read/query methods
|
||||
# like users.info only read args from form-encoded params.
|
||||
kwargs = {"data": payload} if form else {"json": payload}
|
||||
response = await _get_http_client().post(
|
||||
f"{SLACK_API_BASE}/{method}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
**kwargs,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
|
||||
# --- App Home / Assistant API wrappers (Agents & AI Apps) -------------------
|
||||
# All take JSON, so they go through slack_api directly. Status and prompts are
|
||||
# cosmetic — failures are logged, never raised, so a hiccup can't break the flow.
|
||||
|
||||
async def set_assistant_status(token: str, channel_id: str, thread_ts: str, status: str) -> dict:
|
||||
"""Show a transient status line (e.g. 'is thinking…') in an assistant thread."""
|
||||
data = await slack_api("assistant.threads.setStatus", token,
|
||||
{"channel_id": channel_id, "thread_ts": thread_ts, "status": status})
|
||||
if not data.get("ok"):
|
||||
logger.debug(f"Slack assistant.threads.setStatus: {data.get('error')}")
|
||||
return data
|
||||
|
||||
|
||||
async def set_suggested_prompts(token: str, channel_id: str, thread_ts: str,
|
||||
prompts: List[dict], title: Optional[str] = None) -> dict:
|
||||
"""Offer starter prompts in an assistant thread. `prompts` is capped at 4."""
|
||||
payload = {"channel_id": channel_id, "thread_ts": thread_ts,
|
||||
"prompts": prompts[:MAX_SUGGESTED_PROMPTS]}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
data = await slack_api("assistant.threads.setSuggestedPrompts", token, payload)
|
||||
if not data.get("ok"):
|
||||
logger.debug(f"Slack assistant.threads.setSuggestedPrompts: {data.get('error')}")
|
||||
return data
|
||||
|
||||
|
||||
async def publish_home_view(token: str, user_id: str, view: dict) -> dict:
|
||||
"""Publish the App Home (Home tab) view for one user."""
|
||||
data = await slack_api("views.publish", token, {"user_id": user_id, "view": view})
|
||||
if not data.get("ok"):
|
||||
logger.error(f"Slack views.publish failed: {data.get('error')}")
|
||||
return data
|
||||
|
||||
|
||||
# Home-tab presentation. The card is pre-resolved by the caller (photo URL
|
||||
# signed) so this stays a pure, testable Block Kit builder. Per Slack's App Home
|
||||
# guidance: lead with what the app does, surface the most relevant thing (the
|
||||
# connected agent), keep call-to-actions minimal, link out to settings.
|
||||
HOME_INSTRUCTION_MAX = 250
|
||||
_STATUS_ONLINE = ":large_green_circle: Online"
|
||||
_STATUS_OFFLINE = ":white_circle: Offline"
|
||||
HOME_INTRO = (
|
||||
"AI customer support, right inside Slack. @mention me in a channel and I'll "
|
||||
"answer in the thread, or send me a direct message. My answers come from your "
|
||||
"team's own knowledge, and I hand off to a human when a conversation needs one."
|
||||
)
|
||||
HOME_HOW_TO = (
|
||||
"*How to use me*\n"
|
||||
"• @mention *ReplyPier* in any channel — I reply in the thread\n"
|
||||
"• Send me a *direct message* for a private conversation\n"
|
||||
"• Open me from the *assistant* in the top bar for a side-by-side chat"
|
||||
)
|
||||
HOME_NO_AGENT = (
|
||||
"*No agent connected yet*\nConnect an agent to this workspace from the "
|
||||
"ReplyPier dashboard and I'll start answering here."
|
||||
)
|
||||
DASHBOARD_LINK_LABEL = "Open the ReplyPier dashboard →"
|
||||
|
||||
|
||||
def build_home_view(agent_card: Optional[dict], dashboard_url: str) -> dict:
|
||||
"""Assemble the App Home view: what the app does, the connected agent, how to
|
||||
use it, and a link to the dashboard.
|
||||
|
||||
`agent_card` (or None if no agent is assigned): {"name", "is_active",
|
||||
"instruction", "photo_url" | None}.
|
||||
"""
|
||||
blocks: List[dict] = [
|
||||
{"type": "header", "text": {"type": "plain_text", "text": "ReplyPier", "emoji": True}},
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": HOME_INTRO}},
|
||||
{"type": "divider"},
|
||||
]
|
||||
|
||||
if agent_card:
|
||||
status = _STATUS_ONLINE if agent_card.get("is_active") else _STATUS_OFFLINE
|
||||
header: List[dict] = []
|
||||
if agent_card.get("photo_url"):
|
||||
header.append({"type": "image", "image_url": agent_card["photo_url"],
|
||||
"alt_text": agent_card["name"]})
|
||||
header.append({"type": "mrkdwn", "text": f"*{agent_card['name']}* · {status}"})
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "*Connected agent*"}})
|
||||
blocks.append({"type": "context", "elements": header})
|
||||
|
||||
instruction = (agent_card.get("instruction") or "").strip()
|
||||
if instruction:
|
||||
if len(instruction) > HOME_INSTRUCTION_MAX:
|
||||
instruction = instruction[:HOME_INSTRUCTION_MAX - 1].rstrip() + "…"
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": instruction}})
|
||||
else:
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": HOME_NO_AGENT}})
|
||||
|
||||
blocks.append({"type": "divider"})
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": HOME_HOW_TO}})
|
||||
# A markdown link, not a Block Kit button — link buttons still require
|
||||
# Interactivity to be enabled, which this app intentionally leaves off.
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": f"<{dashboard_url}|{DASHBOARD_LINK_LABEL}>"}})
|
||||
|
||||
return {"type": "home", "blocks": blocks}
|
||||
|
||||
|
||||
# Placeholder "typing…" message ts per (account, conversation) — set by
|
||||
# send_typing, consumed by send_text which edits it into the real reply.
|
||||
# In-memory is fine: a message's send_typing + send_text run in the same
|
||||
# background task / worker. Each entry is (ts, created_at); stale entries are
|
||||
# pruned so a bypassed send_text (e.g. AI error, empty reply) can't leak them.
|
||||
_typing_placeholders: dict = {}
|
||||
# A placeholder is only useful for the seconds between typing and the reply;
|
||||
# anything older is a leak from a path that never called send_text.
|
||||
_PLACEHOLDER_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _prune_placeholders(now: float) -> None:
|
||||
stale = [key for key, (_, created) in _typing_placeholders.items()
|
||||
if now - created > _PLACEHOLDER_TTL_SECONDS]
|
||||
for key in stale:
|
||||
_typing_placeholders.pop(key, None)
|
||||
|
||||
|
||||
def verify_slack_signature(headers: dict, raw_body: bytes) -> bool:
|
||||
"""Validate X-Slack-Signature (v0 HMAC-SHA256 over 'v0:{ts}:{body}') with
|
||||
replay protection. Fails closed when SLACK_SIGNING_SECRET is unset."""
|
||||
signing_secret = settings.SLACK_SIGNING_SECRET
|
||||
timestamp = headers.get("x-slack-request-timestamp", "")
|
||||
signature = headers.get("x-slack-signature", "")
|
||||
if not signing_secret or not timestamp or not signature:
|
||||
return False
|
||||
try:
|
||||
if abs(time.time() - int(timestamp)) > MAX_SIGNATURE_AGE_SECONDS:
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
base = f"v0:{timestamp}:".encode() + raw_body
|
||||
expected = "v0=" + hmac.new(signing_secret.encode(), base, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
class SlackAdapter(ChannelAdapter):
|
||||
"""Slack as a customer messaging channel on the unified stack.
|
||||
|
||||
Conversation keying: DMs to the bot use the IM channel id (one continuous
|
||||
conversation, like Telegram); @mentions in channels use `{channel}:{thread_ts}`
|
||||
so each thread is its own conversation and replies stay in-thread.
|
||||
"""
|
||||
|
||||
channel_type: ClassVar[str] = ChannelType.SLACK.value
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
return verify_slack_signature(headers, raw_body)
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize an Events API callback. Handles app_mention and direct
|
||||
messages; bot echoes, edits, and other subtypes yield nothing."""
|
||||
event = payload.get("event") or {}
|
||||
event_type = event.get("type")
|
||||
team_id = payload.get("team_id", "")
|
||||
|
||||
is_mention = event_type == "app_mention"
|
||||
is_dm = event_type == "message" and event.get("channel_type") == "im"
|
||||
if not (is_mention or is_dm):
|
||||
return []
|
||||
# Ignore bot messages and subtypes (edits, joins, message_changed...)
|
||||
if event.get("bot_id") or event.get("subtype"):
|
||||
return []
|
||||
|
||||
text = _MENTION_RE.sub("", event.get("text") or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
channel = event.get("channel", "")
|
||||
ts = event.get("ts", "")
|
||||
if is_mention:
|
||||
thread_ts = event.get("thread_ts") or ts
|
||||
conversation_id = f"{channel}:{thread_ts}"
|
||||
elif event.get("thread_ts"):
|
||||
# Assistant-sidebar messages are DMs that carry the assistant thread's
|
||||
# ts; key by thread so the reply posts back into that thread. Plain
|
||||
# DMs have no thread_ts and stay one continuous conversation.
|
||||
conversation_id = f"{channel}:{event['thread_ts']}"
|
||||
else:
|
||||
conversation_id = channel
|
||||
|
||||
timestamp = None
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(float(ts), tz=timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return [InboundMessage(
|
||||
external_account_id=team_id,
|
||||
external_conversation_id=conversation_id,
|
||||
external_user_id=str(event.get("user", "")),
|
||||
external_message_id=payload.get("event_id") or ts,
|
||||
text=text,
|
||||
timestamp=timestamp,
|
||||
)]
|
||||
|
||||
async def fetch_profile(self, account: ChannelAccount, external_user_id: str) -> dict:
|
||||
"""Resolve the Slack user's real name (and email, if the scope allows)
|
||||
via users.info so customers show as a name, not a raw U0… id."""
|
||||
try:
|
||||
data = await slack_api("users.info", self._access_token(account),
|
||||
{"user": external_user_id}, form=True)
|
||||
if not data.get("ok"):
|
||||
return {}
|
||||
user = data.get("user") or {}
|
||||
profile = user.get("profile") or {}
|
||||
name = (user.get("real_name") or profile.get("display_name")
|
||||
or profile.get("real_name"))
|
||||
return {"name": name or None, "email": profile.get("email")}
|
||||
except Exception as e:
|
||||
logger.error(f"Slack users.info failed: {e}")
|
||||
return {}
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
# Slack has no bot typing indicator; post a placeholder we later edit
|
||||
# into the real reply so the customer sees an immediate "typing…".
|
||||
channel, thread_ts = self._split_conversation(conversation.external_conversation_id)
|
||||
payload = {"channel": channel, "text": "_typing…_", "mrkdwn": True}
|
||||
if thread_ts:
|
||||
payload["thread_ts"] = thread_ts
|
||||
try:
|
||||
_prune_placeholders(time.time())
|
||||
data = await slack_api("chat.postMessage", self._access_token(account), payload)
|
||||
if data.get("ok"):
|
||||
_typing_placeholders[self._placeholder_key(account, conversation)] = (data["ts"], time.time())
|
||||
except Exception as e:
|
||||
logger.debug(f"Slack typing placeholder failed (non-critical): {e}")
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
channel, thread_ts = self._split_conversation(conversation.external_conversation_id)
|
||||
token = self._access_token(account)
|
||||
|
||||
# If we posted a "typing…" placeholder, edit it into the reply
|
||||
entry = _typing_placeholders.pop(self._placeholder_key(account, conversation), None)
|
||||
placeholder_ts = entry[0] if entry else None
|
||||
if placeholder_ts:
|
||||
try:
|
||||
data = await slack_api("chat.update", token,
|
||||
{"channel": channel, "ts": placeholder_ts, "text": text})
|
||||
if data.get("ok"):
|
||||
return SendResult(ok=True, external_message_id=str(data.get("ts", "")))
|
||||
# Update failed (e.g. placeholder deleted) — remove the stale
|
||||
# "typing…" so we don't leave it beside the fresh reply.
|
||||
await slack_api("chat.delete", token, {"channel": channel, "ts": placeholder_ts})
|
||||
except Exception as e:
|
||||
logger.debug(f"Slack chat.update failed, posting new message: {e}")
|
||||
|
||||
payload = {"channel": channel, "text": text}
|
||||
if thread_ts:
|
||||
payload["thread_ts"] = thread_ts
|
||||
try:
|
||||
data = await slack_api("chat.postMessage", token, payload)
|
||||
if not data.get("ok"):
|
||||
return SendResult(ok=False, error=str(data.get("error") or "chat.postMessage failed"))
|
||||
return SendResult(ok=True, external_message_id=str(data.get("ts", "")))
|
||||
except Exception as e:
|
||||
logger.error(f"Slack send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
# Slack mrkdwn uses single markers: **bold** -> *bold*
|
||||
return markdown.replace("**", "*")[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
@staticmethod
|
||||
def _placeholder_key(account: ChannelAccount, conversation: ChannelConversation) -> tuple:
|
||||
return (str(account.id), conversation.external_conversation_id)
|
||||
|
||||
@staticmethod
|
||||
def _split_conversation(conversation_id: str) -> tuple[str, Optional[str]]:
|
||||
if ":" in conversation_id:
|
||||
channel, thread_ts = conversation_id.split(":", 1)
|
||||
return channel, thread_ts
|
||||
return conversation_id, None
|
||||
|
||||
@staticmethod
|
||||
def _access_token(account: ChannelAccount) -> str:
|
||||
return json.loads(decrypt_api_key(account.encrypted_credentials))["access_token"]
|
||||
|
||||
|
||||
register_adapter(SlackAdapter())
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
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.channels.sms.base import (
|
||||
SmsProvider, SmsWebhookRequest, CredentialField,
|
||||
get_provider, list_providers, register_provider,
|
||||
)
|
||||
# Import provider modules so they self-register
|
||||
from app.channels.sms import twilio # noqa: F401
|
||||
from app.channels.sms import vonage # noqa: F401
|
||||
from app.channels.sms import messagebird # noqa: F401
|
||||
from app.channels.sms import plivo # noqa: F401
|
||||
from app.channels.sms import brevo # noqa: F401
|
||||
from app.channels.sms import sns # noqa: F401
|
||||
# The channel adapter (registers itself for channel_type 'sms')
|
||||
from app.channels.sms import adapter # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"SmsProvider",
|
||||
"SmsWebhookRequest",
|
||||
"CredentialField",
|
||||
"get_provider",
|
||||
"list_providers",
|
||||
"register_provider",
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
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 ClassVar, List, Optional
|
||||
|
||||
from app.channels.base import ChannelAdapter, InboundMessage, SendResult
|
||||
from app.channels.registry import register_adapter
|
||||
from app.channels.sms.base import MAX_MESSAGE_LENGTH, get_provider
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def account_provider_name(account: ChannelAccount) -> str:
|
||||
"""The SMS provider chosen for this account (stored non-secret in settings;
|
||||
defaults to twilio for accounts connected before providers existed)."""
|
||||
return (account.settings or {}).get("provider", "twilio")
|
||||
|
||||
|
||||
class SmsAdapter(ChannelAdapter):
|
||||
"""The 'sms' channel. Inbound parsing/verification is provider-specific and
|
||||
handled by the /webhooks/sms route; this adapter only dispatches outbound
|
||||
sends to the account's provider."""
|
||||
|
||||
channel_type: ClassVar[str] = ChannelType.SMS.value
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
# SMS inbound is verified per-provider inside the webhook route.
|
||||
return account is not None
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
# Inbound is parsed per-provider in the webhook route, not here.
|
||||
return []
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
provider = get_provider(account_provider_name(account))
|
||||
if provider is None:
|
||||
return SendResult(ok=False, error=f"Unknown SMS provider '{account_provider_name(account)}'")
|
||||
return await provider.send(account, conversation.external_conversation_id, text)
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
plain = markdown.replace("**", "").replace("__", "")
|
||||
return plain[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
|
||||
register_adapter(SmsAdapter())
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
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
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.channels.base import InboundMessage, SendResult
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
REQUEST_TIMEOUT_SECONDS = 15.0
|
||||
# Carriers segment long messages; keep replies to a sane cap
|
||||
MAX_MESSAGE_LENGTH = 1600
|
||||
|
||||
_http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
||||
def get_http_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None or _http_client.is_closed:
|
||||
_http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
return _http_client
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmsWebhookRequest:
|
||||
"""Everything a provider needs to verify and parse an inbound webhook,
|
||||
normalized by the /webhooks/sms route so providers stay transport-agnostic."""
|
||||
headers: dict
|
||||
params: dict # decoded form params (repeated keys → last)
|
||||
param_pairs: List[tuple] # form params preserving order/duplicates
|
||||
json_body: Optional[dict] # parsed JSON body (JSON providers)
|
||||
raw_body: bytes
|
||||
url: str # the public webhook URL (for signatures)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CredentialField:
|
||||
key: str
|
||||
label: str
|
||||
secret: bool = False
|
||||
optional: bool = False
|
||||
|
||||
|
||||
class SmsProvider(ABC):
|
||||
"""A pluggable SMS backend (Twilio, Vonage, ...). One SmsProvider per
|
||||
vendor; all share the single 'sms' channel so conversations, badges and
|
||||
agent routing stay uniform."""
|
||||
|
||||
name: ClassVar[str]
|
||||
label: ClassVar[str]
|
||||
# Credential fields the connect UI collects (besides the phone number)
|
||||
credential_fields: ClassVar[List[CredentialField]] = []
|
||||
# True when the provider signs inbound webhooks and we need the raw body
|
||||
signs_webhook: ClassVar[bool] = False
|
||||
|
||||
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
|
||||
"""Authenticate an inbound webhook. Default: accept - providers that leave
|
||||
signs_webhook False have no signature to check, and the route authenticates
|
||||
them with the per-account URL token instead."""
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
|
||||
"""Normalize an inbound SMS into InboundMessage(s)."""
|
||||
|
||||
async def handle_control(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
|
||||
"""Handle non-message control callbacks (e.g. SNS subscription
|
||||
confirmation). Return True if fully handled — the route then stops."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
|
||||
"""Send an SMS from the account's number to `to`."""
|
||||
|
||||
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
|
||||
"""Optionally verify credentials at connect time. Raise ValueError with
|
||||
a user-facing message on failure. Default: no check."""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def credentials(account: ChannelAccount) -> dict:
|
||||
return json.loads(decrypt_api_key(account.encrypted_credentials))
|
||||
|
||||
|
||||
_providers: Dict[str, SmsProvider] = {}
|
||||
|
||||
|
||||
def register_provider(provider: SmsProvider) -> SmsProvider:
|
||||
_providers[provider.name] = provider
|
||||
return provider
|
||||
|
||||
|
||||
def get_provider(name: str) -> Optional[SmsProvider]:
|
||||
return _providers.get(name)
|
||||
|
||||
|
||||
def list_providers() -> List[SmsProvider]:
|
||||
return list(_providers.values())
|
||||
|
||||
|
||||
def inbound(to: str, sender: str, text: str, message_id: str) -> InboundMessage:
|
||||
"""Build a normalized InboundMessage for an SMS (conversation keyed by the
|
||||
customer's number). external_message_id is left empty when the provider
|
||||
gives none — the webhook skips dedupe rather than risk collapsing two
|
||||
legitimate identical replies (e.g. "Yes", "Yes") into one."""
|
||||
return InboundMessage(
|
||||
external_account_id=to,
|
||||
external_conversation_id=sender,
|
||||
external_user_id=sender,
|
||||
external_message_id=message_id or "",
|
||||
text=text,
|
||||
# The sender *is* the customer's number; providers vary on the leading
|
||||
# '+' (Twilio sends it, Vonage doesn't) — normalize_phone accepts both.
|
||||
profile={"phone": sender},
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
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 ClassVar, List
|
||||
|
||||
from app.channels.base import InboundMessage, SendResult
|
||||
from app.channels.sms.base import (
|
||||
CredentialField, SmsProvider, SmsWebhookRequest,
|
||||
get_http_client, inbound, register_provider,
|
||||
)
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
BREVO_SMS_API = "https://api.brevo.com/v3/transactionalSMS/sms"
|
||||
|
||||
|
||||
class BrevoProvider(SmsProvider):
|
||||
"""Brevo transactional SMS. Outbound only in most plans — inbound replies
|
||||
depend on Brevo's inbound SMS support; when available they post to the
|
||||
per-account webhook URL (authenticated by its token, not a signature)."""
|
||||
|
||||
name: ClassVar[str] = "brevo"
|
||||
label: ClassVar[str] = "Brevo"
|
||||
credential_fields: ClassVar[List[CredentialField]] = [
|
||||
CredentialField("api_key", "API key", secret=True),
|
||||
]
|
||||
|
||||
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
|
||||
# Brevo inbound payloads vary; accept the common field names.
|
||||
p = req.json_body or req.params
|
||||
sender = p.get("from") or p.get("sender") or p.get("msisdn") or ""
|
||||
text = (p.get("text") or p.get("message") or p.get("content") or "").strip()
|
||||
if not sender or not text:
|
||||
return []
|
||||
recipient = str(p.get("to") or account.external_account_id)
|
||||
return [inbound(recipient, sender, text, str(p.get("id") or p.get("messageId") or ""))]
|
||||
|
||||
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
|
||||
creds = self.credentials(account)
|
||||
try:
|
||||
response = await get_http_client().post(
|
||||
BREVO_SMS_API,
|
||||
headers={"api-key": creds["api_key"], "content-type": "application/json"},
|
||||
json={"sender": account.external_account_id, "recipient": to, "content": text},
|
||||
)
|
||||
data = response.json()
|
||||
if response.status_code >= 300:
|
||||
return SendResult(ok=False, error=str(data.get("message") or f"HTTP {response.status_code}"))
|
||||
return SendResult(ok=True, external_message_id=str(data.get("messageId", "")))
|
||||
except Exception as e:
|
||||
logger.error(f"Brevo send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
|
||||
if not credentials.get("api_key"):
|
||||
raise ValueError("API key is required")
|
||||
|
||||
|
||||
register_provider(BrevoProvider())
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
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 base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import ClassVar, List
|
||||
|
||||
from app.channels.base import InboundMessage, SendResult
|
||||
from app.channels.sms.base import (
|
||||
CredentialField, SmsProvider, SmsWebhookRequest,
|
||||
get_http_client, inbound, register_provider,
|
||||
)
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
MESSAGEBIRD_API = "https://rest.messagebird.com/messages"
|
||||
|
||||
|
||||
class MessageBirdProvider(SmsProvider):
|
||||
name: ClassVar[str] = "messagebird"
|
||||
label: ClassVar[str] = "MessageBird (Bird)"
|
||||
credential_fields: ClassVar[List[CredentialField]] = [
|
||||
CredentialField("access_key", "Access key", secret=True),
|
||||
CredentialField("signing_key", "Signing key (optional)", secret=True, optional=True),
|
||||
]
|
||||
|
||||
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
|
||||
signing_key = self.credentials(account).get("signing_key")
|
||||
if not signing_key:
|
||||
return True
|
||||
timestamp = req.headers.get("messagebird-request-timestamp", "")
|
||||
signature = req.headers.get("messagebird-signature", "")
|
||||
if not (timestamp and signature):
|
||||
return False
|
||||
# v1: base64(HMAC-SHA256(key, "{ts}\n{url}\n{sha256(body)}"))
|
||||
body_hash = hashlib.sha256(req.raw_body).digest()
|
||||
parts = timestamp.encode() + b"\n" + req.url.encode() + b"\n" + body_hash
|
||||
expected = base64.b64encode(hmac.new(signing_key.encode(), parts, hashlib.sha256).digest()).decode()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
|
||||
p = req.json_body or req.params
|
||||
sender = p.get("originator") or p.get("sender") or ""
|
||||
text = (p.get("payload") or p.get("body") or "").strip()
|
||||
if not sender or not text:
|
||||
return []
|
||||
recipient = str(p.get("recipient") or account.external_account_id)
|
||||
return [inbound(recipient, sender, text, str(p.get("id", "")))]
|
||||
|
||||
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
|
||||
creds = self.credentials(account)
|
||||
try:
|
||||
response = await get_http_client().post(
|
||||
MESSAGEBIRD_API,
|
||||
headers={"Authorization": f"AccessKey {creds['access_key']}"},
|
||||
json={"originator": account.external_account_id, "recipients": [to], "body": text},
|
||||
)
|
||||
data = response.json()
|
||||
if response.status_code >= 300:
|
||||
errors = data.get("errors") or [{}]
|
||||
return SendResult(ok=False, error=str(errors[0].get("description") or f"HTTP {response.status_code}"))
|
||||
return SendResult(ok=True, external_message_id=str(data.get("id", "")))
|
||||
except Exception as e:
|
||||
logger.error(f"MessageBird send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
|
||||
if not credentials.get("access_key"):
|
||||
raise ValueError("Access key is required")
|
||||
|
||||
|
||||
register_provider(MessageBirdProvider())
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import ClassVar, List
|
||||
|
||||
from app.channels.base import InboundMessage, SendResult
|
||||
from app.channels.sms.base import (
|
||||
CredentialField, SmsProvider, SmsWebhookRequest,
|
||||
get_http_client, inbound, register_provider,
|
||||
)
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
PLIVO_API_BASE = external_base("PLIVO_API_BASE", "https://api.plivo.com/v1")
|
||||
|
||||
|
||||
class PlivoProvider(SmsProvider):
|
||||
name: ClassVar[str] = "plivo"
|
||||
label: ClassVar[str] = "Plivo"
|
||||
signs_webhook: ClassVar[bool] = True
|
||||
credential_fields: ClassVar[List[CredentialField]] = [
|
||||
CredentialField("auth_id", "Auth ID"),
|
||||
CredentialField("auth_token", "Auth token", secret=True),
|
||||
]
|
||||
|
||||
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
|
||||
"""X-Plivo-Signature-V3: base64(HMAC-SHA256(auth_token, url + nonce))."""
|
||||
auth_token = self.credentials(account).get("auth_token", "")
|
||||
signature = req.headers.get("x-plivo-signature-v3", "")
|
||||
nonce = req.headers.get("x-plivo-signature-v3-nonce", "")
|
||||
if not (auth_token and signature and nonce):
|
||||
return False
|
||||
expected = base64.b64encode(
|
||||
hmac.new(auth_token.encode(), (req.url + nonce).encode(), hashlib.sha256).digest()
|
||||
).decode()
|
||||
# Plivo may send multiple comma-separated signatures
|
||||
return any(hmac.compare_digest(expected, s) for s in signature.split(","))
|
||||
|
||||
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
|
||||
p = req.params
|
||||
sender, text = p.get("From", ""), (p.get("Text") or "").strip()
|
||||
if not sender or not text:
|
||||
return []
|
||||
return [inbound(p.get("To", ""), sender, text, p.get("MessageUUID", ""))]
|
||||
|
||||
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
|
||||
creds = self.credentials(account)
|
||||
try:
|
||||
response = await get_http_client().post(
|
||||
f"{PLIVO_API_BASE}/Account/{creds['auth_id']}/Message/",
|
||||
auth=(creds["auth_id"], creds["auth_token"]),
|
||||
json={"src": account.external_account_id, "dst": to, "text": text},
|
||||
)
|
||||
data = response.json()
|
||||
if response.status_code >= 300:
|
||||
return SendResult(ok=False, error=str(data.get("error") or f"HTTP {response.status_code}"))
|
||||
ids = data.get("message_uuid") or []
|
||||
return SendResult(ok=True, external_message_id=ids[0] if ids else None)
|
||||
except Exception as e:
|
||||
logger.error(f"Plivo send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
|
||||
auth_id, token = credentials.get("auth_id"), credentials.get("auth_token")
|
||||
if not (auth_id and token):
|
||||
raise ValueError("Auth ID and auth token are required")
|
||||
try:
|
||||
response = await get_http_client().get(
|
||||
f"{PLIVO_API_BASE}/Account/{auth_id}/", auth=(auth_id, token))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Could not reach Plivo: {e}")
|
||||
if response.status_code >= 300:
|
||||
raise ValueError("Invalid Plivo Auth ID or auth token")
|
||||
|
||||
|
||||
register_provider(PlivoProvider())
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
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 base64
|
||||
import json
|
||||
import re
|
||||
from typing import ClassVar, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.x509 import load_pem_x509_certificate
|
||||
|
||||
from app.channels.base import InboundMessage, SendResult
|
||||
from app.channels.sms.base import (
|
||||
CredentialField, SmsProvider, SmsWebhookRequest,
|
||||
get_http_client, inbound, register_provider,
|
||||
)
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Signed-string field order per SNS message type
|
||||
_SIGN_FIELDS = {
|
||||
"Notification": ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"],
|
||||
"SubscriptionConfirmation": ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"],
|
||||
"UnsubscribeConfirmation": ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"],
|
||||
}
|
||||
_cert_cache: dict = {}
|
||||
|
||||
|
||||
def _string_to_sign(body: dict) -> Optional[str]:
|
||||
fields = _SIGN_FIELDS.get(body.get("Type"))
|
||||
if not fields:
|
||||
return None
|
||||
parts = []
|
||||
for key in fields:
|
||||
if key in body and body[key] is not None:
|
||||
parts.append(f"{key}\n{body[key]}\n")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# Only genuine SNS endpoints — NOT any *.amazonaws.com (e.g. an attacker could
|
||||
# host a spoofed cert on *.s3.amazonaws.com). AWS SNS uses sns.<region>.amazonaws.com.
|
||||
_SNS_HOST_RE = re.compile(r"^sns\.[a-z0-9-]+\.amazonaws\.com$")
|
||||
|
||||
|
||||
def _valid_sns_host(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
return parsed.scheme == "https" and parsed.hostname is not None and \
|
||||
bool(_SNS_HOST_RE.match(parsed.hostname))
|
||||
|
||||
|
||||
class SnsProvider(SmsProvider):
|
||||
"""AWS SNS / End User Messaging. Outbound via SNS Publish; inbound arrives
|
||||
as SNS HTTPS-subscription notifications (two-way SMS → SNS topic)."""
|
||||
|
||||
name: ClassVar[str] = "sns"
|
||||
label: ClassVar[str] = "AWS SNS"
|
||||
signs_webhook: ClassVar[bool] = True
|
||||
credential_fields: ClassVar[List[CredentialField]] = [
|
||||
CredentialField("aws_access_key_id", "AWS access key ID"),
|
||||
CredentialField("aws_secret_access_key", "AWS secret access key", secret=True),
|
||||
CredentialField("region", "AWS region (e.g. us-east-1)"),
|
||||
CredentialField("topic_arn", "SNS topic ARN (optional)", optional=True),
|
||||
]
|
||||
|
||||
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
|
||||
body = req.json_body or {}
|
||||
configured_topic = self.credentials(account).get("topic_arn")
|
||||
if configured_topic and body.get("TopicArn") != configured_topic:
|
||||
return False
|
||||
cert_url = body.get("SigningCertURL", "")
|
||||
if not _valid_sns_host(cert_url):
|
||||
return False
|
||||
string_to_sign = _string_to_sign(body)
|
||||
signature = body.get("Signature")
|
||||
if not string_to_sign or not signature:
|
||||
return False
|
||||
try:
|
||||
cert_pem = await self._fetch_cert(cert_url)
|
||||
algorithm = hashes.SHA256() if body.get("SignatureVersion") == "2" else hashes.SHA1()
|
||||
public_key = load_pem_x509_certificate(cert_pem).public_key()
|
||||
public_key.verify(base64.b64decode(signature), string_to_sign.encode(),
|
||||
padding.PKCS1v15(), algorithm)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"SNS signature verification failed: {e}")
|
||||
return False
|
||||
|
||||
async def handle_control(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
|
||||
"""Auto-confirm the SNS HTTPS subscription by visiting SubscribeURL."""
|
||||
body = req.json_body or {}
|
||||
if body.get("Type") == "SubscriptionConfirmation" and body.get("SubscribeURL"):
|
||||
# Only fetch AWS-hosted SubscribeURLs — verify_webhook already ran,
|
||||
# but restrict here too to avoid any SSRF to an attacker URL.
|
||||
if not _valid_sns_host(body["SubscribeURL"]):
|
||||
logger.warning(f"Refusing non-SNS SubscribeURL for account {account.id}")
|
||||
return True
|
||||
try:
|
||||
await get_http_client().get(body["SubscribeURL"])
|
||||
logger.info(f"Confirmed SNS subscription for account {account.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"SNS subscription confirmation failed: {e}")
|
||||
return True
|
||||
return body.get("Type") == "UnsubscribeConfirmation"
|
||||
|
||||
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
|
||||
body = req.json_body or {}
|
||||
if body.get("Type") != "Notification":
|
||||
return []
|
||||
message = body.get("Message")
|
||||
try:
|
||||
sms = json.loads(message) if isinstance(message, str) else (message or {})
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
sender = sms.get("originationNumber", "")
|
||||
text = (sms.get("messageBody") or "").strip()
|
||||
if not sender or not text:
|
||||
return []
|
||||
return [inbound(sms.get("destinationNumber", ""), sender, text, body.get("MessageId", ""))]
|
||||
|
||||
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
|
||||
creds = self.credentials(account)
|
||||
try:
|
||||
message_id = await asyncio.to_thread(self._publish, creds, to, text)
|
||||
return SendResult(ok=True, external_message_id=message_id)
|
||||
except Exception as e:
|
||||
logger.error(f"SNS send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
@staticmethod
|
||||
def _publish(creds: dict, to: str, text: str) -> str:
|
||||
import boto3
|
||||
client = boto3.client(
|
||||
"sns",
|
||||
aws_access_key_id=creds["aws_access_key_id"],
|
||||
aws_secret_access_key=creds["aws_secret_access_key"],
|
||||
region_name=creds.get("region"),
|
||||
)
|
||||
result = client.publish(PhoneNumber=to, Message=text)
|
||||
return result.get("MessageId", "")
|
||||
|
||||
async def _fetch_cert(self, url: str) -> bytes:
|
||||
if url in _cert_cache:
|
||||
return _cert_cache[url]
|
||||
response = await get_http_client().get(url)
|
||||
cert = response.content
|
||||
_cert_cache[url] = cert
|
||||
return cert
|
||||
|
||||
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
|
||||
for key in ("aws_access_key_id", "aws_secret_access_key", "region"):
|
||||
if not credentials.get(key):
|
||||
raise ValueError("AWS access key, secret, and region are required")
|
||||
|
||||
|
||||
register_provider(SnsProvider())
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import ClassVar, List
|
||||
|
||||
from app.channels.base import InboundMessage, SendResult
|
||||
from app.channels.sms.base import (
|
||||
CredentialField, SmsProvider, SmsWebhookRequest,
|
||||
get_http_client, inbound, register_provider,
|
||||
)
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
TWILIO_API_BASE = external_base("TWILIO_API_BASE", "https://api.twilio.com/2010-04-01")
|
||||
|
||||
|
||||
def _verify_signature(auth_token: str, url: str, param_pairs, signature: str) -> bool:
|
||||
"""X-Twilio-Signature: base64(HMAC-SHA1(auth_token, url + sorted concatenated
|
||||
key/values)). param_pairs preserves repeated keys.
|
||||
|
||||
HMAC-SHA1 is mandated by Twilio's signature spec (not a free choice); HMAC
|
||||
with SHA1 remains secure for message authentication.
|
||||
"""
|
||||
if not auth_token or not signature:
|
||||
return False
|
||||
payload = url + "".join(f"{k}{v}" for k, v in sorted(param_pairs, key=lambda kv: kv[0]))
|
||||
expected = base64.b64encode(
|
||||
hmac.new(auth_token.encode(), payload.encode(), hashlib.sha1).digest() # noqa: S324 - Twilio protocol
|
||||
).decode()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
class TwilioProvider(SmsProvider):
|
||||
name: ClassVar[str] = "twilio"
|
||||
label: ClassVar[str] = "Twilio"
|
||||
signs_webhook: ClassVar[bool] = True
|
||||
credential_fields: ClassVar[List[CredentialField]] = [
|
||||
CredentialField("account_sid", "Account SID"),
|
||||
CredentialField("auth_token", "Auth token", secret=True),
|
||||
]
|
||||
|
||||
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
|
||||
return _verify_signature(
|
||||
self.credentials(account).get("auth_token", ""),
|
||||
req.url, req.param_pairs,
|
||||
req.headers.get("x-twilio-signature", ""),
|
||||
)
|
||||
|
||||
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
|
||||
p = req.params
|
||||
sender, body = p.get("From", ""), (p.get("Body") or "").strip()
|
||||
if not sender or not body:
|
||||
return []
|
||||
return [inbound(p.get("To", ""), sender, body, p.get("MessageSid", ""))]
|
||||
|
||||
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
|
||||
creds = self.credentials(account)
|
||||
try:
|
||||
response = await get_http_client().post(
|
||||
f"{TWILIO_API_BASE}/Accounts/{creds['account_sid']}/Messages.json",
|
||||
auth=(creds["account_sid"], creds["auth_token"]),
|
||||
data={"From": account.external_account_id, "To": to, "Body": text},
|
||||
)
|
||||
data = response.json()
|
||||
if response.status_code >= 300:
|
||||
return SendResult(ok=False, error=str(data.get("message") or f"HTTP {response.status_code}"))
|
||||
return SendResult(ok=True, external_message_id=data.get("sid"))
|
||||
except Exception as e:
|
||||
logger.error(f"Twilio send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
|
||||
sid, token = credentials.get("account_sid"), credentials.get("auth_token")
|
||||
if not (sid and token):
|
||||
raise ValueError("Account SID and auth token are required")
|
||||
try:
|
||||
response = await get_http_client().get(
|
||||
f"{TWILIO_API_BASE}/Accounts/{sid}.json", auth=(sid, token))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Could not reach Twilio: {e}")
|
||||
if response.status_code >= 300:
|
||||
raise ValueError("Invalid Twilio Account SID or auth token")
|
||||
|
||||
|
||||
register_provider(TwilioProvider())
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import ClassVar, List
|
||||
|
||||
from app.channels.base import InboundMessage, SendResult
|
||||
from app.channels.sms.base import (
|
||||
CredentialField, SmsProvider, SmsWebhookRequest,
|
||||
get_http_client, inbound, register_provider,
|
||||
)
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
VONAGE_SMS_URL = "https://rest.nexmo.com/sms/json"
|
||||
|
||||
|
||||
def _verify_hmac_signature(params: dict, signature_secret: str) -> bool:
|
||||
"""Vonage HMAC-SHA256 signed-webhook check: HMAC-SHA256(secret) over the
|
||||
sorted params (excluding `sig`, with `&`/`=` in values replaced by `_`).
|
||||
|
||||
Configure the Vonage account's signature method as "SHA-256 HMAC". The
|
||||
legacy MD5 method is intentionally not supported (weak hash).
|
||||
"""
|
||||
provided = params.get("sig", "")
|
||||
if not provided:
|
||||
return False
|
||||
parts = []
|
||||
for key in sorted(k for k in params if k != "sig"):
|
||||
value = str(params[key]).replace("&", "_").replace("=", "_")
|
||||
parts.append(f"&{key}={value}")
|
||||
digest = hmac.new(
|
||||
signature_secret.encode(), "".join(parts).encode(), hashlib.sha256
|
||||
).hexdigest().upper()
|
||||
return hmac.compare_digest(digest, provided.upper())
|
||||
|
||||
|
||||
class VonageProvider(SmsProvider):
|
||||
name: ClassVar[str] = "vonage"
|
||||
label: ClassVar[str] = "Vonage (Nexmo)"
|
||||
credential_fields: ClassVar[List[CredentialField]] = [
|
||||
CredentialField("api_key", "API key"),
|
||||
CredentialField("api_secret", "API secret", secret=True),
|
||||
CredentialField("signature_secret", "Signature secret (optional)", secret=True, optional=True),
|
||||
]
|
||||
|
||||
async def verify_webhook(self, req: SmsWebhookRequest, account: ChannelAccount) -> bool:
|
||||
secret = self.credentials(account).get("signature_secret")
|
||||
if not secret:
|
||||
# No signature configured - the route authenticates this account with the
|
||||
# per-account webhook token instead (signs_webhook is False).
|
||||
return True
|
||||
source = req.json_body or req.params
|
||||
return _verify_hmac_signature(source, secret)
|
||||
|
||||
def parse_inbound(self, req: SmsWebhookRequest, account: ChannelAccount) -> List[InboundMessage]:
|
||||
p = req.json_body or req.params
|
||||
sender = p.get("msisdn", "")
|
||||
text = (p.get("text") or "").strip()
|
||||
if not sender or not text:
|
||||
return []
|
||||
return [inbound(p.get("to", ""), sender, text, p.get("messageId", ""))]
|
||||
|
||||
async def send(self, account: ChannelAccount, to: str, text: str) -> SendResult:
|
||||
creds = self.credentials(account)
|
||||
try:
|
||||
response = await get_http_client().post(VONAGE_SMS_URL, data={
|
||||
"api_key": creds["api_key"],
|
||||
"api_secret": creds["api_secret"],
|
||||
"from": account.external_account_id,
|
||||
"to": to,
|
||||
"text": text,
|
||||
})
|
||||
data = response.json()
|
||||
message = (data.get("messages") or [{}])[0]
|
||||
if message.get("status") != "0":
|
||||
return SendResult(ok=False, error=str(message.get("error-text") or "Vonage send failed"))
|
||||
return SendResult(ok=True, external_message_id=message.get("message-id"))
|
||||
except Exception as e:
|
||||
logger.error(f"Vonage send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
async def validate_credentials(self, phone_number: str, credentials: dict) -> None:
|
||||
if not (credentials.get("api_key") and credentials.get("api_secret")):
|
||||
raise ValueError("API key and API secret are required")
|
||||
|
||||
|
||||
register_provider(VonageProvider())
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
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
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.channels.base import (
|
||||
ChannelAdapter, ChannelInteraction, InboundMessage, InteractiveKind, InteractiveMessage,
|
||||
InteractiveReply, InteractiveReplyType, SendResult,
|
||||
)
|
||||
from app.channels.interactive_limits import TELEGRAM_LIMITS, InteractiveLimits
|
||||
from app.channels.registry import register_adapter
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.core.logger import get_logger
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
TELEGRAM_API_BASE = external_base("TELEGRAM_API_BASE", "https://api.telegram.org")
|
||||
# https://core.telegram.org/bots/api#sendmessage
|
||||
MAX_MESSAGE_LENGTH = TELEGRAM_LIMITS.body_max
|
||||
REQUEST_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
# Shared client: keep-alive connection pooling to api.telegram.org instead of
|
||||
# a TCP+TLS handshake per call (process-lifetime, like the DB engine).
|
||||
_http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
|
||||
def _get_http_client() -> httpx.AsyncClient:
|
||||
global _http_client
|
||||
if _http_client is None or _http_client.is_closed:
|
||||
_http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
return _http_client
|
||||
|
||||
|
||||
async def _call_api(bot_token: str, method: str, payload: Optional[dict] = None) -> dict:
|
||||
"""Call a Telegram Bot API method; returns the decoded JSON envelope."""
|
||||
url = f"{TELEGRAM_API_BASE}/bot{bot_token}/{method}"
|
||||
response = await _get_http_client().post(url, json=payload or {})
|
||||
return response.json()
|
||||
|
||||
|
||||
async def get_me(bot_token: str) -> Optional[dict]:
|
||||
"""Validate a bot token; returns the bot's user object or None."""
|
||||
try:
|
||||
data = await _call_api(bot_token, "getMe")
|
||||
return data.get("result") if data.get("ok") else None
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram getMe failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def set_webhook(bot_token: str, url: str, secret_token: str) -> tuple[bool, str]:
|
||||
"""Register our webhook with Telegram. Returns (ok, error_description) —
|
||||
the description surfaces Telegram's reason (e.g. 'an HTTPS URL must be
|
||||
provided for webhook' when BACKEND_URL points at localhost)."""
|
||||
try:
|
||||
data = await _call_api(bot_token, "setWebhook", {
|
||||
"url": url,
|
||||
"secret_token": secret_token,
|
||||
# message covers customer texts and shared contacts;
|
||||
# callback_query, taps on the choices we send
|
||||
"allowed_updates": ["message", "callback_query"],
|
||||
})
|
||||
if data.get("ok"):
|
||||
return True, ""
|
||||
description = str(data.get("description") or "setWebhook failed")
|
||||
logger.error(f"Telegram setWebhook rejected for {url}: {description}")
|
||||
return False, description
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram setWebhook failed: {e}")
|
||||
return False, str(e)
|
||||
|
||||
|
||||
async def delete_webhook(bot_token: str) -> bool:
|
||||
try:
|
||||
data = await _call_api(bot_token, "deleteWebhook")
|
||||
return bool(data.get("ok"))
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram deleteWebhook failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# A tap's message id, kept apart from message ids (which only count up per chat)
|
||||
CALLBACK_ID_PREFIX = "cbq:"
|
||||
PRIVATE_CHAT = "private"
|
||||
TAP_NOT_TAKEN = "This choice is no longer available"
|
||||
|
||||
|
||||
def callback_answer(payload: dict, taken: bool) -> Optional[dict]:
|
||||
"""The webhook response that answers a tap — Telegram keeps the button
|
||||
spinning until a tap is answered, and accepts the answer as the reply to
|
||||
its webhook request. A tap we didn't act on (a second tap, a group chat,
|
||||
an old message) says so. None for any other update."""
|
||||
callback = payload.get("callback_query") or {}
|
||||
if not callback.get("id"):
|
||||
return None
|
||||
answer = {"method": "answerCallbackQuery", "callback_query_id": callback["id"]}
|
||||
if not taken:
|
||||
answer["text"] = TAP_NOT_TAKEN
|
||||
return answer
|
||||
|
||||
|
||||
def dedupe_id(inbound: InboundMessage) -> str:
|
||||
"""What makes an update a repeat: a tap's keyboard (one answer per
|
||||
keyboard), otherwise the message itself."""
|
||||
if inbound.interactive is not None and inbound.reply_to_message_id:
|
||||
return f"keyboard:{inbound.reply_to_message_id}"
|
||||
return inbound.external_message_id
|
||||
|
||||
|
||||
class TelegramAdapter(ChannelAdapter):
|
||||
channel_type: ClassVar[str] = ChannelType.TELEGRAM.value
|
||||
# Choices go as an inline keyboard (buttons only)
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = TELEGRAM_LIMITS
|
||||
max_text_length: ClassVar[Optional[int]] = MAX_MESSAGE_LENGTH
|
||||
|
||||
async def verify_webhook(self, headers: dict, raw_body: bytes, account: Optional[ChannelAccount]) -> bool:
|
||||
"""Telegram echoes the secret_token given to setWebhook on every request."""
|
||||
if account is None:
|
||||
return False
|
||||
provided = headers.get("x-telegram-bot-api-secret-token", "")
|
||||
return hmac.compare_digest(provided, account.webhook_secret or "")
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize a Telegram Update. Plain user text messages and taps on
|
||||
our buttons become InboundMessages; bot echoes, shared contacts
|
||||
(handled as interactions) and service updates are ignored."""
|
||||
if payload.get("callback_query"):
|
||||
tap = _tap(payload["callback_query"])
|
||||
return [tap] if tap is not None else []
|
||||
message = payload.get("message") or {}
|
||||
sender = message.get("from") or {}
|
||||
chat = message.get("chat") or {}
|
||||
if not message or sender.get("is_bot") or "id" not in chat:
|
||||
return []
|
||||
|
||||
text = message.get("text") or message.get("caption")
|
||||
if not text:
|
||||
return [] # media / contact-only messages aren't customer text turns
|
||||
|
||||
name = " ".join(filter(None, [sender.get("first_name"), sender.get("last_name")])) or sender.get("username")
|
||||
timestamp = None
|
||||
if message.get("date"):
|
||||
timestamp = datetime.fromtimestamp(message["date"], tz=timezone.utc)
|
||||
|
||||
return [InboundMessage(
|
||||
external_account_id="", # resolved from the webhook path, not the payload
|
||||
external_conversation_id=str(chat["id"]),
|
||||
external_user_id=str(sender.get("id", chat["id"])),
|
||||
external_message_id=str(message.get("message_id", "")),
|
||||
text=text,
|
||||
profile={"name": name, "username": sender.get("username")},
|
||||
timestamp=timestamp,
|
||||
)]
|
||||
|
||||
def parse_interaction(self, payload: dict):
|
||||
"""Detect a shared contact (message.contact) — the share-phone flow.
|
||||
Bypasses the AI pipeline."""
|
||||
message = payload.get("message") or {}
|
||||
contact = message.get("contact")
|
||||
chat = message.get("chat") or {}
|
||||
if contact and "id" in chat:
|
||||
sender = message.get("from") or {}
|
||||
# Only accept the sender's OWN contact (the share-phone button flow);
|
||||
# a forwarded third-party contact card has a different/absent user_id.
|
||||
if contact.get("user_id") != sender.get("id"):
|
||||
return None
|
||||
return ChannelInteraction(
|
||||
type="contact",
|
||||
external_account_id="",
|
||||
external_conversation_id=str(chat["id"]),
|
||||
external_user_id=str(sender.get("id", chat["id"])),
|
||||
phone=contact.get("phone_number"),
|
||||
profile={"name": " ".join(filter(None, [contact.get("first_name"), contact.get("last_name")]))},
|
||||
)
|
||||
return None
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
return await self._send_message(account, conversation.external_conversation_id, text)
|
||||
|
||||
def choice_limits_for(self, conversation: ChannelConversation) -> Optional[InteractiveLimits]:
|
||||
"""Buttons only in a private chat with the bot: in a group anyone could
|
||||
tap them, so taps there are ignored (Telegram's group ids are negative)."""
|
||||
if str(conversation.external_conversation_id).startswith("-"):
|
||||
return None
|
||||
return self.interactive_limits
|
||||
|
||||
async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: InteractiveMessage) -> SendResult:
|
||||
"""The choices as an inline keyboard under the text, one per row."""
|
||||
if message.kind != InteractiveKind.BUTTONS:
|
||||
return SendResult(ok=False, error="Telegram only sends choices as buttons")
|
||||
keyboard = {"inline_keyboard": [[{"text": b.title, "callback_data": b.id}] for b in message.buttons]}
|
||||
return await self._send_message(account, conversation.external_conversation_id, message.body,
|
||||
reply_markup=keyboard)
|
||||
|
||||
async def clear_buttons(self, account: ChannelAccount, chat_id: str, message_id: Optional[str]) -> None:
|
||||
"""Take the keyboard off a message once one of its buttons was tapped.
|
||||
Best effort: a keyboard left in place is only a stale one."""
|
||||
if not message_id:
|
||||
return
|
||||
try:
|
||||
await _call_api(self._bot_token(account), "editMessageReplyMarkup", {
|
||||
"chat_id": chat_id, "message_id": int(message_id),
|
||||
"reply_markup": {"inline_keyboard": []},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Telegram editMessageReplyMarkup failed (non-critical): {e}")
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
try:
|
||||
await _call_api(self._bot_token(account), "sendChatAction", {
|
||||
"chat_id": conversation.external_conversation_id,
|
||||
"action": "typing",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Telegram sendChatAction failed (non-critical): {e}")
|
||||
|
||||
async def request_phone(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
text: str) -> SendResult:
|
||||
# One-time reply keyboard with a share-contact button
|
||||
keyboard = {
|
||||
"keyboard": [[{"text": "📱 Share my phone number", "request_contact": True}]],
|
||||
"resize_keyboard": True,
|
||||
"one_time_keyboard": True,
|
||||
}
|
||||
return await self._send_message(account, conversation.external_conversation_id, text,
|
||||
reply_markup=keyboard)
|
||||
|
||||
async def _send_message(self, account: ChannelAccount, chat_id: str, text: str,
|
||||
reply_markup: dict = None) -> SendResult:
|
||||
try:
|
||||
payload = {"chat_id": chat_id, "text": text}
|
||||
if reply_markup is not None:
|
||||
payload["reply_markup"] = reply_markup
|
||||
data = await _call_api(self._bot_token(account), "sendMessage", payload)
|
||||
if not data.get("ok"):
|
||||
return SendResult(ok=False, error=str(data.get("description") or "sendMessage failed"))
|
||||
return SendResult(ok=True, external_message_id=str(data["result"].get("message_id", "")))
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram send failed: {e}")
|
||||
return SendResult(ok=False, error=str(e))
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
"""Telegram is sent as plain text (no parse_mode) — markdown renders
|
||||
readably and escaping pitfalls are avoided. Enforce the length cap."""
|
||||
return markdown[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
@staticmethod
|
||||
def _bot_token(account: ChannelAccount) -> str:
|
||||
return json.loads(decrypt_api_key(account.encrypted_credentials))["bot_token"]
|
||||
|
||||
|
||||
def _tap(callback: dict) -> Optional[InboundMessage]:
|
||||
"""A tap on one of our buttons, in a private chat with the bot (in a
|
||||
group anyone could tap it). The tap carries only the button's id; its
|
||||
title is read from the keyboard it was on. Dropped when that message is
|
||||
gone or no longer accessible."""
|
||||
message = callback.get("message") or {}
|
||||
chat = message.get("chat") or {}
|
||||
sender = callback.get("from") or {}
|
||||
data = callback.get("data")
|
||||
if not data or not message.get("date") or chat.get("type") != PRIVATE_CHAT or "id" not in chat:
|
||||
return None
|
||||
title = _button_title(message.get("reply_markup") or {}, data)
|
||||
if title is None:
|
||||
return None
|
||||
name = " ".join(filter(None, [sender.get("first_name"), sender.get("last_name")])) or sender.get("username")
|
||||
return InboundMessage(
|
||||
external_account_id="", # resolved from the webhook path, not the payload
|
||||
external_conversation_id=str(chat["id"]),
|
||||
external_user_id=str(sender.get("id", chat["id"])),
|
||||
external_message_id=f"{CALLBACK_ID_PREFIX}{callback['id']}",
|
||||
text=title,
|
||||
profile={"name": name, "username": sender.get("username")},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
interactive=InteractiveReply(type=InteractiveReplyType.BUTTON_REPLY, id=str(data), title=title),
|
||||
reply_to_message_id=str(message.get("message_id", "")) or None,
|
||||
)
|
||||
|
||||
|
||||
def _button_title(reply_markup: dict, data: str) -> Optional[str]:
|
||||
for row in reply_markup.get("inline_keyboard") or []:
|
||||
for button in row:
|
||||
if button.get("callback_data") == data:
|
||||
return button.get("text")
|
||||
return None
|
||||
|
||||
|
||||
register_adapter(TelegramAdapter())
|
||||
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
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 ClassVar, List, Optional
|
||||
|
||||
from app.channels.base import (
|
||||
DeliveryState, InboundMessage, InteractiveKind, InteractiveMessage,
|
||||
InteractiveReply, InteractiveReplyType, InteractiveSection, SendResult, SharedLocation,
|
||||
StatusEvent, WindowStatus,
|
||||
)
|
||||
from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE
|
||||
from app.channels.interactive_limits import WHATSAPP_LIMITS, InteractiveLimits
|
||||
from app.channels.meta_base import MetaBaseAdapter, graph_post
|
||||
from app.channels.registry import register_adapter
|
||||
from app.models.channels import ChannelAccount, ChannelConversation, ChannelType
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
|
||||
class WhatsAppAdapter(MetaBaseAdapter):
|
||||
channel_type: ClassVar[str] = ChannelType.WHATSAPP.value
|
||||
# Outside the 24h window WhatsApp allows re-opening with an approved template
|
||||
expired_status: ClassVar[WindowStatus] = WindowStatus.TEMPLATE_REQUIRED
|
||||
interactive_limits: ClassVar[Optional[InteractiveLimits]] = WHATSAPP_LIMITS
|
||||
reports_delivery_status: ClassVar[bool] = True
|
||||
max_text_length: ClassVar[Optional[int]] = MAX_MESSAGE_LENGTH
|
||||
|
||||
def parse_inbound(self, payload: dict) -> List[InboundMessage]:
|
||||
"""Normalize a WhatsApp Cloud API webhook.
|
||||
|
||||
Shape: entry[].changes[].value.{metadata,contacts,messages}. Status
|
||||
callbacks (delivered/read) carry no `messages` and yield nothing.
|
||||
"""
|
||||
messages: List[InboundMessage] = []
|
||||
for entry in payload.get("entry", []):
|
||||
for change in entry.get("changes", []):
|
||||
value = change.get("value", {})
|
||||
metadata = value.get("metadata", {})
|
||||
phone_number_id = metadata.get("phone_number_id")
|
||||
if not phone_number_id or not value.get("messages"):
|
||||
continue
|
||||
name_by_wa_id = {
|
||||
c.get("wa_id"): (c.get("profile") or {}).get("name")
|
||||
for c in value.get("contacts", [])
|
||||
}
|
||||
for message in value["messages"]:
|
||||
interactive = self._extract_interactive(message)
|
||||
location = self._extract_location(message)
|
||||
text = self._extract_text(message, interactive, location)
|
||||
if text is None:
|
||||
continue # media types until media support lands
|
||||
wa_id = message.get("from")
|
||||
messages.append(InboundMessage(
|
||||
external_account_id=str(phone_number_id),
|
||||
external_conversation_id=str(wa_id),
|
||||
external_user_id=str(wa_id),
|
||||
external_message_id=str(message.get("id", "")),
|
||||
text=text,
|
||||
# wa_id is the customer's number in E.164-without-plus,
|
||||
# declared verbatim (like the SMS adapters) so the one
|
||||
# normalize_msisdn boundary owns all '+' handling.
|
||||
profile={"name": name_by_wa_id.get(wa_id), "phone": str(wa_id)},
|
||||
timestamp=self._timestamp(message.get("timestamp")),
|
||||
interactive=interactive,
|
||||
location=location,
|
||||
reply_to_message_id=(message.get("context") or {}).get("id"),
|
||||
))
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _extract_text(message: dict, interactive: Optional[InteractiveReply],
|
||||
location: Optional[SharedLocation]) -> Optional[str]:
|
||||
if message.get("type") == "text":
|
||||
return (message.get("text") or {}).get("body")
|
||||
# A tap reads as the text the customer saw, so the agent needs no
|
||||
# special handling; the id travels separately on `interactive`. A
|
||||
# tap without a title (never expected) falls back to its id rather
|
||||
# than running a turn on nothing.
|
||||
if interactive is not None:
|
||||
return interactive.title or interactive.id or None
|
||||
if location is not None:
|
||||
return _describe_location(location)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_interactive(message: dict) -> Optional[InteractiveReply]:
|
||||
if message.get("type") == "button":
|
||||
# Template quick-reply button: `payload` is what the template set
|
||||
button = message.get("button") or {}
|
||||
return InteractiveReply(type=InteractiveReplyType.TEMPLATE_BUTTON,
|
||||
id=str(button.get("payload") or ""),
|
||||
title=str(button.get("text") or ""))
|
||||
if message.get("type") == "interactive":
|
||||
interactive = message.get("interactive") or {}
|
||||
for reply_type in (InteractiveReplyType.BUTTON_REPLY, InteractiveReplyType.LIST_REPLY):
|
||||
reply = interactive.get(reply_type.value)
|
||||
if reply:
|
||||
return InteractiveReply(type=reply_type,
|
||||
id=str(reply.get("id") or ""),
|
||||
title=str(reply.get("title") or ""))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_location(message: dict) -> Optional[SharedLocation]:
|
||||
if message.get("type") != "location":
|
||||
return None
|
||||
location = message.get("location") or {}
|
||||
try:
|
||||
return SharedLocation(latitude=float(location["latitude"]),
|
||||
longitude=float(location["longitude"]),
|
||||
name=location.get("name"),
|
||||
address=location.get("address"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def parse_status_events(self, payload: dict) -> List[StatusEvent]:
|
||||
"""Delivery receipts: entry[].changes[].value.statuses[]."""
|
||||
events: List[StatusEvent] = []
|
||||
for entry in payload.get("entry", []):
|
||||
for change in entry.get("changes", []):
|
||||
value = change.get("value", {})
|
||||
phone_number_id = (value.get("metadata") or {}).get("phone_number_id")
|
||||
if not phone_number_id:
|
||||
continue
|
||||
for status in value.get("statuses") or []:
|
||||
event = self._status_event(str(phone_number_id), status)
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
def _status_event(self, phone_number_id: str, status: dict) -> Optional[StatusEvent]:
|
||||
try:
|
||||
state = DeliveryState(status.get("status"))
|
||||
except ValueError:
|
||||
return None # e.g. 'deleted' — not a delivery state we track
|
||||
message_id = status.get("id")
|
||||
if not message_id:
|
||||
return None
|
||||
pricing = status.get("pricing") or {}
|
||||
errors = status.get("errors") or []
|
||||
return StatusEvent(
|
||||
external_account_id=phone_number_id,
|
||||
external_message_id=str(message_id),
|
||||
recipient_id=str(status.get("recipient_id") or ""),
|
||||
status=state,
|
||||
timestamp=self._timestamp(status.get("timestamp")),
|
||||
error=_status_error(errors[0]) if errors else None,
|
||||
error_code=str(errors[0]["code"]) if errors and errors[0].get("code") is not None else None,
|
||||
pricing_category=pricing.get("category"),
|
||||
pricing_type=pricing.get("type"),
|
||||
pricing_model=pricing.get("pricing_model"),
|
||||
billable=pricing.get("billable"),
|
||||
)
|
||||
|
||||
def conversation_state(self, inbound: InboundMessage) -> dict:
|
||||
# send_typing marks a specific message as read, so it needs the wamid of
|
||||
# the message it is replying to.
|
||||
if not inbound.external_message_id:
|
||||
return {}
|
||||
return {"last_inbound_message_id": inbound.external_message_id}
|
||||
|
||||
async def send_typing(self, account: ChannelAccount, conversation: ChannelConversation) -> None:
|
||||
"""Show a typing indicator. WhatsApp only offers this as part of marking
|
||||
an inbound message read, so this also blue-ticks that message. Expires
|
||||
after 25s or when the reply is delivered."""
|
||||
message_id = (conversation.extra or {}).get("last_inbound_message_id")
|
||||
if message_id:
|
||||
await self._read_receipt(account, message_id, typing=True)
|
||||
|
||||
async def mark_read(self, account: ChannelAccount, external_message_id: str) -> None:
|
||||
if external_message_id:
|
||||
await self._read_receipt(account, external_message_id, typing=False)
|
||||
|
||||
async def _read_receipt(self, account: ChannelAccount, message_id: str, typing: bool) -> None:
|
||||
payload = {"messaging_product": "whatsapp", "status": "read", "message_id": message_id}
|
||||
if typing:
|
||||
payload["typing_indicator"] = {"type": "text"}
|
||||
try:
|
||||
result = await graph_post(f"{account.external_account_id}/messages",
|
||||
self.access_token(account), payload)
|
||||
if not result.ok:
|
||||
logger.debug(f"WhatsApp read receipt failed (non-critical): {result.error}")
|
||||
except Exception as e:
|
||||
logger.debug(f"WhatsApp read receipt failed (non-critical): {e}")
|
||||
|
||||
async def send_text(self, account: ChannelAccount, conversation: ChannelConversation, text: str) -> SendResult:
|
||||
return await graph_post(
|
||||
f"{account.external_account_id}/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": conversation.external_conversation_id,
|
||||
"type": "text",
|
||||
"text": {"preview_url": False, "body": text},
|
||||
},
|
||||
)
|
||||
|
||||
async def send_template(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
template_name: str, language: str = DEFAULT_TEMPLATE_LANGUAGE,
|
||||
components: list = None) -> SendResult:
|
||||
"""Send an approved template message — the only way to reach a customer
|
||||
outside the 24h window."""
|
||||
template: dict = {"name": template_name, "language": {"code": language}}
|
||||
if components:
|
||||
template["components"] = components
|
||||
return await graph_post(
|
||||
f"{account.external_account_id}/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": conversation.external_conversation_id,
|
||||
"type": "template",
|
||||
"template": template,
|
||||
},
|
||||
)
|
||||
|
||||
async def send_interactive(self, account: ChannelAccount, conversation: ChannelConversation,
|
||||
message: InteractiveMessage) -> SendResult:
|
||||
return await graph_post(
|
||||
f"{account.external_account_id}/messages",
|
||||
self.access_token(account),
|
||||
{
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": conversation.external_conversation_id,
|
||||
"type": "interactive",
|
||||
"interactive": _interactive_payload(message),
|
||||
},
|
||||
)
|
||||
|
||||
def format_outbound(self, markdown: str) -> str:
|
||||
# WhatsApp renders *bold* / _italic_ but agent markdown (**bold**) doesn't
|
||||
# map cleanly; send as-is with the length cap to avoid mangled markup.
|
||||
return markdown[:MAX_MESSAGE_LENGTH]
|
||||
|
||||
|
||||
def _describe_location(location: SharedLocation) -> str:
|
||||
"""A shared pin as text the agent (and the inbox) can read."""
|
||||
place = ", ".join(p for p in (location.name, location.address) if p)
|
||||
coordinates = f"{location.latitude}, {location.longitude}"
|
||||
return f"Shared location: {place} ({coordinates})" if place else f"Shared location: {coordinates}"
|
||||
|
||||
|
||||
def _status_error(error: dict) -> str:
|
||||
details = (error.get("error_data") or {}).get("details")
|
||||
return str(details or error.get("message") or error.get("title") or error.get("code") or "failed")
|
||||
|
||||
|
||||
def _interactive_payload(message: InteractiveMessage) -> dict:
|
||||
"""The Graph `interactive` object for a validated InteractiveMessage."""
|
||||
payload: dict = {"body": {"text": message.body}}
|
||||
if message.header:
|
||||
payload["header"] = {"type": "text", "text": message.header}
|
||||
if message.footer:
|
||||
payload["footer"] = {"text": message.footer}
|
||||
|
||||
if message.kind == InteractiveKind.BUTTONS:
|
||||
payload["type"] = "button"
|
||||
payload["action"] = {"buttons": [
|
||||
{"type": "reply", "reply": {"id": b.id, "title": b.title}} for b in message.buttons
|
||||
]}
|
||||
elif message.kind == InteractiveKind.LIST:
|
||||
payload["type"] = "list"
|
||||
payload["action"] = {
|
||||
"button": message.list_button_label,
|
||||
"sections": [_list_section(s) for s in message.sections],
|
||||
}
|
||||
else:
|
||||
payload["type"] = "cta_url"
|
||||
payload["action"] = {"name": "cta_url", "parameters": {
|
||||
"display_text": message.display_text, "url": message.url,
|
||||
}}
|
||||
return payload
|
||||
|
||||
|
||||
def _list_section(section: InteractiveSection) -> dict:
|
||||
rows = []
|
||||
for row in section.rows:
|
||||
item = {"id": row.id, "title": row.title}
|
||||
if row.description:
|
||||
item["description"] = row.description
|
||||
rows.append(item)
|
||||
out: dict = {"rows": rows}
|
||||
if section.title:
|
||||
out["title"] = section.title
|
||||
return out
|
||||
|
||||
|
||||
register_adapter(WhatsAppAdapter())
|
||||
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
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 uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.agents.chat_agent import ChatAgent
|
||||
from app.channels import InboundMessage, ChannelInteraction, get_adapter
|
||||
from app.channels.app_hooks import HandledBy
|
||||
from app.channels.base import InteractiveMessage
|
||||
from app.channels.constants import MessageSource
|
||||
from app.channels.interactive import from_options
|
||||
from app.channels.interactive_limits import InteractiveLimits
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.core.logger import get_logger
|
||||
from app.database import SessionLocal
|
||||
from app.models.ai_config import AIModelType
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.models.session_to_agent import SessionStatus
|
||||
from app.repositories.agent import AgentRepository
|
||||
from app.repositories.ai_config import AIConfigRepository
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.customer import CustomerRepository
|
||||
from app.utils.phone import normalize_msisdn
|
||||
from app.repositories.channels import (
|
||||
ChannelAccountRepository,
|
||||
ChannelConversationRepository,
|
||||
AgentChannelConfigRepository,
|
||||
)
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.services.app_first_routing import announce_inbound, app_claims
|
||||
from app.services.chat_notifications import notify_new_channel_chat_once, team_not_yet_notified
|
||||
from app.services.human_routing import HUMAN_ONLY_ACK, notify_customer
|
||||
from app.services.lead_capture import record_lead_from_response
|
||||
from app.services.conversation_token import VERIFIED_SCOPE
|
||||
from app.services.agent_rooms import USER_MESSAGE, relay_stored_message
|
||||
from app.services.business_message import business_message_context
|
||||
from app.services.channel_replies import inbound_attributes, reply_text
|
||||
from app.services.message_delivery import DeliveryResult, deliver_to_customer
|
||||
|
||||
# Optional enterprise message-limit check (same seam as the widget path)
|
||||
try:
|
||||
from app.enterprise.services.message_limit import check_message_limit
|
||||
HAS_ENTERPRISE = True
|
||||
except ImportError:
|
||||
HAS_ENTERPRISE = False
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def process_channel_message(account_id, inbound: InboundMessage) -> None:
|
||||
"""Process one inbound customer message from an external channel.
|
||||
|
||||
Channel-agnostic core shared by every webhook route: resolves the
|
||||
customer and session, stores the message, runs the AI agent (or relays
|
||||
to the handling human), and delivers the reply through the channel's
|
||||
adapter. Runs from a BackgroundTask with its own DB session.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
account_repo = ChannelAccountRepository(db)
|
||||
account = account_repo.get_by_id(account_id)
|
||||
if account is None or not account.is_active:
|
||||
logger.warning(f"Ignoring message for missing/inactive channel account {account_id}")
|
||||
return
|
||||
|
||||
adapter = get_adapter(account.channel_type)
|
||||
|
||||
agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id)
|
||||
if agent_id is None:
|
||||
logger.warning(f"No agent configured for {account.channel_type} account {account.id}")
|
||||
return
|
||||
|
||||
org_id = str(account.organization_id)
|
||||
customer_id = _get_or_create_customer(db, account, inbound, org_id)
|
||||
# If the customer still has a synthesized placeholder name, resolve the
|
||||
# real one via the platform API — only then, not on every message.
|
||||
if adapter is not None:
|
||||
await _enrich_customer_name(db, adapter, account, inbound, customer_id)
|
||||
session_record, conversation, is_new_session = _get_or_create_session(
|
||||
db, account, inbound, agent_id, customer_id, org_id
|
||||
)
|
||||
session_id = str(session_record.session_id)
|
||||
|
||||
# Merge channel-specific per-conversation state (e.g. email threading
|
||||
# headers) so outbound replies can use it.
|
||||
if adapter is not None:
|
||||
state = adapter.conversation_state(inbound)
|
||||
if state:
|
||||
ChannelConversationRepository(db).merge_extra(conversation, state)
|
||||
|
||||
# A human-only agent answers nothing — the chat waits in the inbox for
|
||||
# someone to pick it up, so it takes the same path as one a human
|
||||
# already holds.
|
||||
agent = AgentRepository(db).get_agent(agent_id)
|
||||
ai_replies_enabled = agent.ai_replies_enabled if agent else True
|
||||
# Buttons are the agent's choice (off unless turned on), where the
|
||||
# channel can show them
|
||||
choice_limits = (adapter.choice_limits_for(conversation)
|
||||
if adapter is not None and agent is not None and agent.channel_quick_replies else None)
|
||||
|
||||
# An installed extension (an external app) may answer first. Its reply
|
||||
# can land while we wait, so the customer's message keeps the time it
|
||||
# arrived and still sorts before it.
|
||||
received_at = datetime.now(timezone.utc)
|
||||
if await app_claims(db, account, session_record, customer_id, inbound, received_at):
|
||||
stored = _store_customer_message(db, inbound, session_record, agent_id,
|
||||
customer_id, org_id, account, created_at=received_at)
|
||||
await relay_stored_message(session_record, stored, USER_MESSAGE)
|
||||
if adapter is not None:
|
||||
await adapter.mark_read(account, inbound.external_message_id)
|
||||
await announce_inbound(account, session_record, customer_id, inbound,
|
||||
HandledBy.APP, received_at)
|
||||
return
|
||||
|
||||
# From here the team (AI or inbox) is answering
|
||||
await notify_new_channel_chat_once(db, session_record, conversation)
|
||||
|
||||
# Human is handling (or a transfer is pending, or this agent never uses
|
||||
# AI): store + relay to the agent dashboard, never run the bot.
|
||||
if (session_record.user_id is not None
|
||||
or session_record.status == SessionStatus.TRANSFERRED
|
||||
or not ai_replies_enabled):
|
||||
stored = _store_customer_message(db, inbound, session_record, agent_id,
|
||||
customer_id, org_id, account)
|
||||
await relay_stored_message(session_record, stored, USER_MESSAGE)
|
||||
await announce_inbound(account, session_record, customer_id, inbound,
|
||||
HandledBy.HUMAN, received_at)
|
||||
|
||||
# Nothing will answer this on its own, so acknowledge once — on a
|
||||
# channel an unanswered message reads as a dead number. Only on the
|
||||
# opening message of a brand-new conversation.
|
||||
if is_new_session and not ai_replies_enabled:
|
||||
await notify_customer(db, session_record, HUMAN_ONLY_ACK,
|
||||
channels_only=False)
|
||||
return
|
||||
|
||||
await announce_inbound(account, session_record, customer_id, inbound,
|
||||
HandledBy.AI, received_at)
|
||||
|
||||
ai_config = AIConfigRepository(db).get_active_config(account.organization_id)
|
||||
if ai_config is None:
|
||||
logger.error(f"No AI config for org {org_id}; dropping {account.channel_type} message")
|
||||
return
|
||||
|
||||
if not await _within_message_limit(db, org_id, ai_config):
|
||||
await _send_via_adapter(
|
||||
db, session_record,
|
||||
"Sorry, this assistant has reached its message limit. Please try again later."
|
||||
)
|
||||
return
|
||||
|
||||
# Show a typing indicator while the agent composes its reply
|
||||
if adapter is not None:
|
||||
await adapter.send_typing(account, conversation)
|
||||
|
||||
response, bot_message_id = await _run_chat_agent(
|
||||
db, account, ai_config, inbound,
|
||||
org_id=org_id, agent_id=str(agent_id),
|
||||
customer_id=customer_id, session_id=session_id,
|
||||
conversation=conversation,
|
||||
choice_limits=choice_limits,
|
||||
)
|
||||
if response is None:
|
||||
# The agent hard-failed. Still send a reply so a typing indicator /
|
||||
# placeholder (Slack, LINE) resolves instead of dangling.
|
||||
await _deliver(db, session_record, {
|
||||
'message': "Sorry, I hit an error handling that — please try again.",
|
||||
'type': 'chat_response',
|
||||
}, source=MessageSource.SYSTEM)
|
||||
return
|
||||
|
||||
record_lead_from_response(
|
||||
db, response,
|
||||
organization_id=account.organization_id,
|
||||
agent_id=agent_id,
|
||||
customer_id=customer_id,
|
||||
session_id=session_id,
|
||||
channel=account.channel_type,
|
||||
# The platform vouches for who is messaging.
|
||||
scope=VERIFIED_SCOPE,
|
||||
)
|
||||
|
||||
delivery = await _deliver(db, session_record, {
|
||||
'message': response.message,
|
||||
'type': 'chat_response',
|
||||
'transfer_to_human': getattr(response, 'transfer_to_human', False),
|
||||
'end_chat': getattr(response, 'end_chat', False),
|
||||
}, source=MessageSource.AI, message_id=bot_message_id,
|
||||
interactive=_offered_choices(response, choice_limits))
|
||||
if not delivery.ok and bot_message_id is not None:
|
||||
ChatRepository(db).mark_delivery_failed(bot_message_id, delivery.reason)
|
||||
|
||||
await _send_channel_prompts(db, adapter, account, conversation, response)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing channel message: {e}", exc_info=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _send_channel_prompts(db, adapter, account, conversation, response) -> None:
|
||||
"""After the reply, surface channel-native prompts. Rating is deliberately
|
||||
NOT asked on external channels — it is a web-widget-only feature; here we
|
||||
only request a phone number when the agent asks for contact."""
|
||||
if adapter is None:
|
||||
return
|
||||
try:
|
||||
if getattr(response, 'request_contact', False):
|
||||
await adapter.request_phone(
|
||||
account, conversation,
|
||||
"To help us follow up, tap below to share your phone number.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed sending channel prompt: {e}")
|
||||
|
||||
|
||||
async def process_channel_interaction(account_id, interaction: ChannelInteraction) -> None:
|
||||
"""Handle a non-message interaction (a shared phone number) from a channel.
|
||||
Runs from a BackgroundTask with its own DB session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
if account is None or not account.is_active:
|
||||
return
|
||||
conversation = ChannelConversationRepository(db).get_latest(
|
||||
account.id, interaction.external_conversation_id)
|
||||
if conversation is None:
|
||||
return
|
||||
|
||||
if interaction.type == "contact":
|
||||
_handle_contact(db, conversation, interaction)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing channel interaction: {e}", exc_info=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _handle_contact(db, conversation, interaction: ChannelInteraction) -> None:
|
||||
"""Store a shared phone number on the customer record."""
|
||||
if not interaction.phone or conversation.customer_id is None:
|
||||
return
|
||||
from app.models.customer import Customer
|
||||
customer = db.query(Customer).filter(Customer.id == conversation.customer_id).first()
|
||||
if customer is None:
|
||||
return
|
||||
meta = dict(customer.meta_data or {})
|
||||
meta["phone"] = interaction.phone
|
||||
customer.meta_data = meta
|
||||
# Also promote to the identity column (set-if-absent, conflict-guarded) so
|
||||
# the person becomes phone-addressable; meta_data keeps the raw value for
|
||||
# anything already reading it there. msisdn-lenient because the number is
|
||||
# platform-relayed (Telegram sends it with or without '+'), and one commit
|
||||
# for both writes — separate transactions left a crash window where the
|
||||
# two stores disagreed.
|
||||
phone = normalize_msisdn(interaction.phone)
|
||||
if phone:
|
||||
CustomerRepository(db).set_phone_if_absent(customer, phone)
|
||||
db.commit()
|
||||
logger.info(f"Stored shared phone for customer {conversation.customer_id}")
|
||||
|
||||
|
||||
def _get_or_create_customer(db: Session, account: ChannelAccount,
|
||||
inbound: InboundMessage, org_id: str) -> str:
|
||||
"""Resolve the platform user to a Customer.
|
||||
|
||||
When the channel already gives us the customer's real email (e.g. the
|
||||
email channel), use it verbatim so the same person is unified across
|
||||
channels and the widget. Otherwise synthesize a stable per-channel address.
|
||||
|
||||
Phone is the second identity key: adapters that truthfully know the
|
||||
customer's number (WhatsApp, SMS) declare it in profile['phone'], and the
|
||||
repository resolves by phone before email — which is what unifies one human
|
||||
across phone-bearing channels and outbound sends.
|
||||
"""
|
||||
real_email = (inbound.profile or {}).get('email')
|
||||
real_name = (inbound.profile or {}).get('name')
|
||||
if real_email and '@' in real_email:
|
||||
channel_email = real_email.lower()
|
||||
else:
|
||||
channel_email = f"{inbound.external_user_id}@{account.channel_type}.channel"
|
||||
customer = CustomerRepository(db).get_or_create_customer(
|
||||
email=channel_email,
|
||||
organization_id=uuid.UUID(org_id),
|
||||
full_name=real_name or placeholder_name(account.channel_type,
|
||||
inbound.external_user_id),
|
||||
# msisdn-lenient: adapters supply platform ids (wa_id, SMS sender),
|
||||
# which are E.164-without-plus — trusted in a way typed digits aren't.
|
||||
phone=normalize_msisdn((inbound.profile or {}).get('phone')),
|
||||
)
|
||||
# Upgrade an existing placeholder name once we've resolved the real one
|
||||
# (get_or_create doesn't update an existing customer's name).
|
||||
if real_name and _is_placeholder_name(customer.full_name, account.channel_type):
|
||||
try:
|
||||
customer.full_name = real_name
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update customer name: {e}")
|
||||
return str(customer.id)
|
||||
|
||||
|
||||
def placeholder_name(channel_type: str, external_user_id: str) -> str:
|
||||
"""The stand-in name for a channel person we don't know yet.
|
||||
|
||||
Paired with _is_placeholder_name deliberately: one builds the string, the
|
||||
other recognises it, and the two only work if they agree exactly. Spelling
|
||||
either out a second time somewhere else (an outbound send, say) makes them
|
||||
agree by coincidence — and the day the format changes, the detector
|
||||
silently stops matching and every real name upgrade stops happening.
|
||||
"""
|
||||
return f"{channel_type.capitalize()} user {external_user_id[:8]}"
|
||||
|
||||
|
||||
def _is_placeholder_name(name: str, channel_type: str) -> bool:
|
||||
"""True when the stored name is the synthesized '<Channel> user xxxx' or empty."""
|
||||
if not name:
|
||||
return True
|
||||
return name.startswith(f"{channel_type.capitalize()} user ")
|
||||
|
||||
|
||||
async def _enrich_customer_name(db: Session, adapter, account: ChannelAccount,
|
||||
inbound: InboundMessage, customer_id: str) -> None:
|
||||
"""Resolve the customer's real name via the platform API, but only while
|
||||
the stored name is still a placeholder.
|
||||
|
||||
Channels like Slack send just a user id in the webhook, so we look the name
|
||||
up via e.g. users.info. Gating on the placeholder means we make that network
|
||||
call once — for a new/unresolved customer — not on every inbound message.
|
||||
"""
|
||||
customer = CustomerRepository(db).get_by_id(uuid.UUID(customer_id))
|
||||
if customer is None or not _is_placeholder_name(customer.full_name, account.channel_type):
|
||||
return
|
||||
try:
|
||||
enrichment = await adapter.fetch_profile(account, inbound.external_user_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"Profile enrichment failed (non-critical): {e}")
|
||||
return
|
||||
name = (enrichment or {}).get('name')
|
||||
if not name:
|
||||
return
|
||||
try:
|
||||
customer.full_name = name
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to update customer name: {e}")
|
||||
|
||||
|
||||
def _agent_changed(session_record, agent_id) -> bool:
|
||||
"""Whether this channel now answers with a different agent than the one the
|
||||
open session was started with.
|
||||
|
||||
A session is bound to one agent: its stored history, and the AI runtime's
|
||||
own memory for that session id, belong to that agent. Carrying it over to a
|
||||
newly configured agent makes the new agent answer out of the previous one's
|
||||
retrieved knowledge, and splits the inbox list in two — the conversations
|
||||
query groups by agent *and* session, while the detail view keys on session
|
||||
alone, so the same thread appears twice and neither row opens the other.
|
||||
|
||||
A missing session likewise needs a new one. A human-handled or
|
||||
transferred chat is left alone: rotating it would drop the agent out of a
|
||||
live conversation mid-reply.
|
||||
"""
|
||||
if session_record is None:
|
||||
return True
|
||||
if session_record.user_id is not None:
|
||||
return False
|
||||
if session_record.status == SessionStatus.TRANSFERRED:
|
||||
return False
|
||||
return str(session_record.agent_id) != str(agent_id)
|
||||
|
||||
|
||||
def _get_or_create_session(db: Session, account: ChannelAccount, inbound: InboundMessage,
|
||||
agent_id, customer_id: str, org_id: str):
|
||||
"""Find the open session for this platform conversation or start a new one.
|
||||
|
||||
Returns (session, conversation, is_new) — callers use is_new to notify the
|
||||
team only when a conversation actually starts.
|
||||
"""
|
||||
conv_repo = ChannelConversationRepository(db)
|
||||
session_repo = SessionToAgentRepository(db)
|
||||
|
||||
conversation = conv_repo.get_active(account.id, inbound.external_conversation_id)
|
||||
if conversation is not None:
|
||||
session_record = session_repo.get_session(conversation.session_id)
|
||||
if not _agent_changed(session_record, agent_id):
|
||||
conv_repo.touch_inbound(conversation)
|
||||
return session_record, conversation, False
|
||||
# Retire the thread so the newly configured agent starts clean. Closing
|
||||
# the session is what makes get_active skip it, so the create below
|
||||
# opens a fresh conversation for the same platform thread.
|
||||
logger.info(
|
||||
f"Agent changed for {account.channel_type} account {account.id}; "
|
||||
f"starting a new session for conversation {inbound.external_conversation_id}")
|
||||
session_repo.close_session(conversation.session_id)
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
session_record = session_repo.create_session(
|
||||
session_id=session_id,
|
||||
agent_id=str(agent_id),
|
||||
customer_id=customer_id,
|
||||
organization_id=org_id,
|
||||
channel=account.channel_type,
|
||||
)
|
||||
conversation = conv_repo.create(
|
||||
channel_account_id=account.id,
|
||||
channel_type=account.channel_type,
|
||||
external_conversation_id=inbound.external_conversation_id,
|
||||
external_user_id=inbound.external_user_id,
|
||||
session_id=uuid.UUID(session_id),
|
||||
organization_id=account.organization_id,
|
||||
agent_id=agent_id,
|
||||
customer_id=uuid.UUID(customer_id),
|
||||
extra=team_not_yet_notified(),
|
||||
)
|
||||
return session_record, conversation, True
|
||||
|
||||
|
||||
def _store_customer_message(db: Session, inbound: InboundMessage, session_record,
|
||||
agent_id, customer_id: str, org_id: str,
|
||||
account: ChannelAccount, created_at: Optional[datetime] = None):
|
||||
"""Persist a customer message on the paths the AI doesn't answer (the AI
|
||||
path persists inside ChatAgent.get_response). Returns the stored row for
|
||||
the relay."""
|
||||
message = {
|
||||
"message": reply_text(db, account.id, inbound),
|
||||
"message_type": "user",
|
||||
"session_id": str(session_record.session_id),
|
||||
"organization_id": org_id,
|
||||
"agent_id": str(agent_id),
|
||||
"customer_id": customer_id,
|
||||
"attributes": inbound_attributes(inbound, account.channel_type),
|
||||
}
|
||||
if created_at is not None:
|
||||
message["created_at"] = created_at
|
||||
return ChatRepository(db).create_message(message)
|
||||
|
||||
|
||||
async def _within_message_limit(db: Session, org_id: str, ai_config) -> bool:
|
||||
"""Enterprise subscription limit; permissive on any failure so the
|
||||
community edition and enterprise errors never block conversations."""
|
||||
if not (HAS_ENTERPRISE and ai_config.model_type == AIModelType.REPLYPIER):
|
||||
return True
|
||||
try:
|
||||
return await check_message_limit(db, org_id, None, None)
|
||||
except Exception as e:
|
||||
logger.error(f"Message limit check failed (allowing message): {e}")
|
||||
return True
|
||||
|
||||
|
||||
async def _run_chat_agent(db: Session, account: ChannelAccount, ai_config,
|
||||
inbound: InboundMessage, org_id: str, agent_id: str,
|
||||
customer_id: str, session_id: str, conversation=None,
|
||||
choice_limits: Optional[InteractiveLimits] = None):
|
||||
"""Run the AI agent for one turn. Workflow-enabled agents fall back to the
|
||||
plain agent on external channels (workflow forms are widget-only).
|
||||
|
||||
Returns (response, id of the stored reply row); response is None when the
|
||||
agent hard-failed."""
|
||||
chat_agent = await ChatAgent.create_async(
|
||||
api_key=decrypt_api_key(ai_config.encrypted_api_key),
|
||||
model_name=ai_config.model_name,
|
||||
model_type=ai_config.model_type.value if hasattr(ai_config.model_type, 'value') else ai_config.model_type,
|
||||
base_url=(ai_config.settings or {}).get('base_url'),
|
||||
org_id=org_id,
|
||||
agent_id=agent_id,
|
||||
customer_id=customer_id,
|
||||
session_id=session_id,
|
||||
channel=account.channel_type,
|
||||
extra_context=business_message_context(conversation),
|
||||
choice_limits=choice_limits,
|
||||
)
|
||||
try:
|
||||
response = await chat_agent.get_response(
|
||||
message=reply_text(db, account.id, inbound),
|
||||
session_id=session_id,
|
||||
org_id=org_id,
|
||||
agent_id=agent_id,
|
||||
customer_id=customer_id,
|
||||
user_attributes=inbound_attributes(inbound, account.channel_type),
|
||||
)
|
||||
return response, chat_agent.last_bot_message_id
|
||||
except Exception as e:
|
||||
logger.error(f"Chat agent error on {account.channel_type} session {session_id}: {e}")
|
||||
return None, None
|
||||
finally:
|
||||
await chat_agent.safe_cleanup_mcp_tools()
|
||||
|
||||
|
||||
def _offered_choices(response, limits: Optional[InteractiveLimits]) -> Optional[InteractiveMessage]:
|
||||
"""The agent's choices as the interactive message to send with its reply."""
|
||||
options = getattr(response, 'quick_replies', None)
|
||||
return from_options(response.message, options, limits) if options and limits is not None else None
|
||||
|
||||
|
||||
async def _deliver(db: Session, session_record, payload: dict, *, source: MessageSource,
|
||||
message_id: Optional[int] = None,
|
||||
interactive: Optional[InteractiveMessage] = None) -> DeliveryResult:
|
||||
"""Deliver a reply and log any failure.
|
||||
|
||||
Unlike the widget path there is no agent socket to notify here, so a failed
|
||||
send would otherwise be silent: the customer gets nothing and nothing says
|
||||
so. Callers that have a stored message act further on the result.
|
||||
"""
|
||||
result = await deliver_to_customer(db, session_record, payload, message_id=message_id,
|
||||
interactive=interactive, source=source)
|
||||
if not result.ok:
|
||||
logger.warning(
|
||||
f"Undelivered reply on {getattr(session_record, 'channel', 'unknown')} "
|
||||
f"session {session_record.session_id}: {result.reason}")
|
||||
return result
|
||||
|
||||
|
||||
async def _send_via_adapter(db: Session, session_record, text: str) -> None:
|
||||
"""Send a plain service message to the customer's channel."""
|
||||
await _deliver(db, session_record, {'message': text, 'type': 'chat_response'},
|
||||
source=MessageSource.SYSTEM)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
What a channel customer's message carries beyond its text — a tapped
|
||||
button, a shared location — and how a tap on one of the agent's own choices
|
||||
reads back as the full option.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels.base import InboundMessage
|
||||
from app.channels.sent_choices import (
|
||||
SENT_CHOICES_ATTRIBUTE, decode_sent_choices, sent_choice_text, sent_numbered_choice,
|
||||
)
|
||||
from app.repositories.chat import ChatRepository
|
||||
|
||||
# chat_history.attributes key for a tap: only its type and id. The visible
|
||||
# text is already the (encrypted) message, and a shared location's details
|
||||
# live there too — nothing customer-identifying goes in plaintext attributes.
|
||||
REPLY_ATTRIBUTE = "interactive_reply"
|
||||
# How far back a tap without reply context is matched to the reply that offered it
|
||||
RECENT_OFFERS = 10
|
||||
|
||||
|
||||
def inbound_attributes(inbound: InboundMessage, channel_type: str) -> dict:
|
||||
"""The chat_history attributes for a customer's channel message."""
|
||||
attributes = {
|
||||
"channel": channel_type,
|
||||
"external_message_id": inbound.external_message_id,
|
||||
}
|
||||
if inbound.interactive is not None:
|
||||
attributes[REPLY_ATTRIBUTE] = {
|
||||
"type": inbound.interactive.type.value,
|
||||
"id": inbound.interactive.id,
|
||||
}
|
||||
if inbound.reply_to_message_id:
|
||||
attributes["reply_to_message_id"] = inbound.reply_to_message_id
|
||||
return attributes
|
||||
|
||||
|
||||
def reply_text(db: Session, channel_account_id: UUID, inbound: InboundMessage) -> str:
|
||||
"""The text to store and answer for this message.
|
||||
|
||||
A tap arrives as the button's title, which may have been cut to fit (or,
|
||||
on Telegram, as just the button's id); a reply to choices shown as a
|
||||
numbered list may be just the number. The full choice is in the record
|
||||
of what the offering message sent, looked up within this customer's chat
|
||||
on this account — by the tap's reply context where the platform gives
|
||||
one, otherwise as the latest reply that offered the choice.
|
||||
"""
|
||||
choice = _tapped_choice(db, channel_account_id, inbound) or _numbered_choice(db, channel_account_id, inbound)
|
||||
if choice is not None:
|
||||
return choice
|
||||
return inbound.text or (inbound.interactive.title if inbound.interactive else "") or ""
|
||||
|
||||
|
||||
def _tapped_choice(db: Session, channel_account_id: UUID, inbound: InboundMessage) -> Optional[str]:
|
||||
if inbound.interactive is None:
|
||||
return None
|
||||
repo = ChatRepository(db)
|
||||
if inbound.reply_to_message_id:
|
||||
offered = repo.in_channel_chat_by_external_message_id(
|
||||
channel_account_id, inbound.external_conversation_id, inbound.reply_to_message_id)
|
||||
candidates = [offered] if offered is not None else []
|
||||
else:
|
||||
candidates = repo.latest_replies_in_channel_chat(
|
||||
channel_account_id, inbound.external_conversation_id, RECENT_OFFERS)
|
||||
for offered in candidates:
|
||||
text = sent_choice_text(_record(offered), inbound.interactive.id)
|
||||
if text is not None:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _numbered_choice(db: Session, channel_account_id: UUID, inbound: InboundMessage) -> Optional[str]:
|
||||
"""A bare number answering our latest reply — the AI's or a person's —
|
||||
when that reply showed its choices numbered (as text, or alongside
|
||||
buttons a desktop app hides)."""
|
||||
typed = (inbound.text or "").strip().rstrip(".")
|
||||
if inbound.interactive is not None or not typed.isdecimal():
|
||||
return None
|
||||
latest = ChatRepository(db).latest_replies_in_channel_chat(
|
||||
channel_account_id, inbound.external_conversation_id, 1)
|
||||
return sent_numbered_choice(_record(latest[0]), typed) if latest else None
|
||||
|
||||
|
||||
def _record(message) -> Optional[dict]:
|
||||
return decode_sent_choices((message.attributes or {}).get(SENT_CHOICES_ATTRIBUTE))
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Channel delivery receipts (sent / delivered / read / failed) applied to the
|
||||
stored messages they belong to.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Set
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels.app_hooks import ChannelEvent, EventKind, emit_channel_event, status_event_id
|
||||
from app.channels.base import DeliveryState, StatusEvent
|
||||
from app.core.logger import get_logger
|
||||
from app.core.redis import get_redis
|
||||
from app.database import SessionLocal
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.services.agent_rooms import emit_to_org_inbox
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# A receipt can arrive before the send that produced it has stored the
|
||||
# message id (the Graph response and the webhook race). It waits here until
|
||||
# record_sent picks it up; long enough to cover the race, short enough that a
|
||||
# receipt for a message we never stored doesn't linger.
|
||||
PENDING_KEY = "channel_status_pending:{account_id}:{message_id}"
|
||||
PENDING_TTL_SECONDS = 600
|
||||
|
||||
# Receipts can arrive out of order and more than once, so a status only
|
||||
# replaces one it outranks. FAILED replaces sent/delivered but never READ:
|
||||
# a message the customer read was delivered, whatever arrives after.
|
||||
# Keyed by the stored value (chat_history.channel_status).
|
||||
_RANK = {DeliveryState.SENT.value: 0, DeliveryState.DELIVERED.value: 1, DeliveryState.READ.value: 2}
|
||||
|
||||
|
||||
def replaceable_by(new: DeliveryState) -> Set[str]:
|
||||
"""The stored statuses receipt `new` may overwrite (no status at all is
|
||||
always overwritten)."""
|
||||
if new == DeliveryState.FAILED:
|
||||
return {DeliveryState.SENT.value, DeliveryState.DELIVERED.value}
|
||||
return {state for state, rank in _RANK.items() if rank < _RANK[new.value]}
|
||||
|
||||
|
||||
async def process_status_event(account_id: UUID, event: StatusEvent) -> None:
|
||||
"""Apply one receipt from a channel webhook. Runs from a BackgroundTask
|
||||
with its own DB session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
if account is None:
|
||||
return
|
||||
# Announced before matching: extensions count every receipt, including
|
||||
# ones for messages ReplyPier never stored.
|
||||
await emit_channel_event(ChannelEvent(
|
||||
kind=EventKind.STATUS,
|
||||
event_id=status_event_id(account.id, event.external_message_id, event.status.value),
|
||||
organization_id=account.organization_id,
|
||||
channel_account_id=account.id,
|
||||
occurred_at=event.timestamp or datetime.now(timezone.utc),
|
||||
status=event,
|
||||
))
|
||||
chat_repo = ChatRepository(db)
|
||||
message = chat_repo.get_by_external_message_id(
|
||||
account.organization_id, event.external_message_id)
|
||||
if message is None:
|
||||
_park(account_id, event)
|
||||
# record_sent may have stored the id and drained the queue between
|
||||
# the lookup and the park; look again so the receipt isn't stranded.
|
||||
message = chat_repo.get_by_external_message_id(
|
||||
account.organization_id, event.external_message_id)
|
||||
if message is None:
|
||||
return
|
||||
for parked in _take_parked(account_id, event.external_message_id):
|
||||
await _apply(db, message, parked)
|
||||
return
|
||||
await _apply(db, message, event)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing channel status event: {e}", exc_info=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def record_sent(db: Session, message_id: int, account_id: UUID,
|
||||
external_message_id: str) -> None:
|
||||
"""Store the platform id of a message we just sent, then apply any
|
||||
receipts that arrived before it."""
|
||||
message = ChatRepository(db).set_external_message_id(message_id, external_message_id)
|
||||
if message is None:
|
||||
return
|
||||
for event in _take_parked(account_id, external_message_id):
|
||||
await _apply(db, message, event)
|
||||
|
||||
|
||||
async def _apply(db: Session, message: ChatHistory, event: StatusEvent) -> None:
|
||||
chat_repo = ChatRepository(db)
|
||||
advanced = chat_repo.advance_channel_status(
|
||||
message.id, event.status.value, event.timestamp or datetime.now(timezone.utc),
|
||||
replaceable_by(event.status))
|
||||
if not advanced:
|
||||
return
|
||||
db.refresh(message)
|
||||
if event.status == DeliveryState.FAILED:
|
||||
# Meta can accept a send and fail it later; this is the only place
|
||||
# that failure becomes visible.
|
||||
chat_repo.mark_delivery_failed(message.id, event.error)
|
||||
session = SessionToAgentRepository(db).get_session(message.session_id)
|
||||
await emit_to_org_inbox('message_status', {
|
||||
'session_id': str(message.session_id),
|
||||
'message_id': message.id,
|
||||
'external_message_id': message.external_message_id,
|
||||
'channel_status': message.channel_status,
|
||||
'delivery_status': (message.attributes or {}).get('delivery_status'),
|
||||
}, message.organization_id, session.user_id if session else None)
|
||||
|
||||
|
||||
def _pending_key(account_id: UUID, external_message_id: str) -> str:
|
||||
return PENDING_KEY.format(account_id=account_id, message_id=external_message_id)
|
||||
|
||||
|
||||
def _park(account_id: UUID, event: StatusEvent) -> None:
|
||||
redis = get_redis()
|
||||
if redis is None:
|
||||
return
|
||||
key = _pending_key(account_id, event.external_message_id)
|
||||
try:
|
||||
pipe = redis.pipeline()
|
||||
pipe.rpush(key, _serialize(event))
|
||||
pipe.expire(key, PENDING_TTL_SECONDS)
|
||||
pipe.execute()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not park channel status event: {e}")
|
||||
|
||||
|
||||
def _take_parked(account_id: UUID, external_message_id: str) -> List[StatusEvent]:
|
||||
redis = get_redis()
|
||||
if redis is None:
|
||||
return []
|
||||
key = _pending_key(account_id, external_message_id)
|
||||
try:
|
||||
pipe = redis.pipeline()
|
||||
pipe.lrange(key, 0, -1)
|
||||
pipe.delete(key)
|
||||
raw_events, _ = pipe.execute()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read parked channel status events: {e}")
|
||||
return []
|
||||
return [_deserialize(raw, external_message_id) for raw in raw_events or []]
|
||||
|
||||
|
||||
def _serialize(event: StatusEvent) -> str:
|
||||
# Only what _apply uses — no recipient number or pricing sits in Redis
|
||||
return json.dumps({
|
||||
'status': event.status.value,
|
||||
'timestamp': event.timestamp.isoformat() if event.timestamp else None,
|
||||
'error': event.error,
|
||||
})
|
||||
|
||||
|
||||
def _deserialize(raw: str, external_message_id: str) -> StatusEvent:
|
||||
data = json.loads(raw)
|
||||
return StatusEvent(
|
||||
external_account_id="",
|
||||
external_message_id=external_message_id,
|
||||
recipient_id="",
|
||||
status=DeliveryState(data['status']),
|
||||
timestamp=datetime.fromisoformat(data['timestamp']) if data['timestamp'] else None,
|
||||
error=data.get('error'),
|
||||
)
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Execution layer for guardrailed ticket DB connectors. All functions here are
|
||||
synchronous (drivers are sync) — callers run them via asyncio.to_thread.
|
||||
|
||||
The read-only guarantee is enforced at the SESSION level, independently of
|
||||
the SQL guardrails: default_transaction_read_only for Postgres, SESSION
|
||||
TRANSACTION READ ONLY for MySQL, plus statement timeouts on both.
|
||||
"""
|
||||
|
||||
import io
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.core.outbound_policy import pin_outbound_target
|
||||
from app.core.security import decrypt_api_key
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CONNECT_TIMEOUT_SECONDS = 8
|
||||
SSH_CONNECT_TIMEOUT_SECONDS = 10
|
||||
# Schemas that are never discovered or queried.
|
||||
SYSTEM_SCHEMAS = (
|
||||
"pg_catalog", "information_schema", "pg_toast",
|
||||
"mysql", "performance_schema", "sys",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DBConnectorConfig:
|
||||
"""Plain snapshot of a TicketDBConnector row (secrets decrypted) so the
|
||||
executor never holds ORM objects across sessions/threads."""
|
||||
id: Optional[UUID]
|
||||
organization_id: Optional[UUID]
|
||||
name: str
|
||||
engine: str
|
||||
host: str
|
||||
port: int
|
||||
database: str
|
||||
username: str
|
||||
password: str
|
||||
allowed_tables: List[str] = field(default_factory=list)
|
||||
masked_columns: List[str] = field(default_factory=list)
|
||||
# {"table": "customer_column"} — tables restricted to the ticket
|
||||
# customer's own rows. See sql_guardrails._apply_row_scope.
|
||||
row_scope: dict = field(default_factory=dict)
|
||||
row_scope_key: str = "email"
|
||||
max_rows: int = 100
|
||||
statement_timeout_ms: int = 5000
|
||||
# SSH tunnel (bastion/jump host) — the common production topology where
|
||||
# the database isn't directly reachable.
|
||||
ssh_enabled: bool = False
|
||||
ssh_host: Optional[str] = None
|
||||
ssh_port: int = 22
|
||||
ssh_username: Optional[str] = None
|
||||
ssh_password: Optional[str] = None
|
||||
ssh_private_key: Optional[str] = None
|
||||
ssh_private_key_passphrase: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, connector) -> "DBConnectorConfig":
|
||||
return cls(
|
||||
id=connector.id,
|
||||
organization_id=connector.organization_id,
|
||||
name=connector.name,
|
||||
engine=str(connector.engine),
|
||||
host=connector.host,
|
||||
port=connector.port,
|
||||
database=connector.database,
|
||||
username=connector.username,
|
||||
password=decrypt_api_key(connector.encrypted_password),
|
||||
allowed_tables=list(connector.allowed_tables or []),
|
||||
masked_columns=list(connector.masked_columns or []),
|
||||
row_scope=dict(connector.row_scope or {}),
|
||||
row_scope_key=connector.row_scope_key or "email",
|
||||
max_rows=connector.max_rows or 100,
|
||||
statement_timeout_ms=connector.statement_timeout_ms or 5000,
|
||||
ssh_enabled=bool(connector.ssh_enabled),
|
||||
ssh_host=connector.ssh_host,
|
||||
ssh_port=connector.ssh_port or 22,
|
||||
ssh_username=connector.ssh_username,
|
||||
ssh_password=(
|
||||
decrypt_api_key(connector.encrypted_ssh_password)
|
||||
if connector.encrypted_ssh_password else None
|
||||
),
|
||||
ssh_private_key=(
|
||||
decrypt_api_key(connector.encrypted_ssh_private_key)
|
||||
if connector.encrypted_ssh_private_key else None
|
||||
),
|
||||
ssh_private_key_passphrase=(
|
||||
decrypt_api_key(connector.encrypted_ssh_key_passphrase)
|
||||
if connector.encrypted_ssh_key_passphrase else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _load_ssh_key(key_text: str, passphrase: Optional[str]):
|
||||
"""Parse a PEM/OpenSSH private key of any common type from a string,
|
||||
without writing it to disk."""
|
||||
import paramiko
|
||||
|
||||
last_error = None
|
||||
# DSA is obsolete — Ed25519/RSA/ECDSA cover every key type a modern host
|
||||
# issues.
|
||||
for key_class in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey):
|
||||
try:
|
||||
return key_class.from_private_key(
|
||||
io.StringIO(key_text), password=passphrase or None
|
||||
)
|
||||
except Exception as e: # wrong type or bad passphrase — try the next
|
||||
last_error = e
|
||||
raise ValueError(f"Unsupported or unreadable SSH private key: {last_error}")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _ssh_tunnel(config: DBConnectorConfig, ssh_address: Optional[str] = None):
|
||||
"""Open an SSH local-forward tunnel to the database and yield the local
|
||||
(host, port) to connect through. ``ssh_address`` is the bastion's vetted
|
||||
IP when the outbound policy pinned one. Always torn down on exit."""
|
||||
from sshtunnel import SSHTunnelForwarder
|
||||
|
||||
kwargs = {
|
||||
"ssh_username": config.ssh_username,
|
||||
"remote_bind_address": (config.host, config.port),
|
||||
# 127.0.0.1:0 — the OS assigns a free local port per tunnel.
|
||||
"local_bind_address": ("127.0.0.1", 0),
|
||||
"set_keepalive": 15.0,
|
||||
}
|
||||
if config.ssh_private_key:
|
||||
kwargs["ssh_pkey"] = _load_ssh_key(
|
||||
config.ssh_private_key, config.ssh_private_key_passphrase
|
||||
)
|
||||
elif config.ssh_password:
|
||||
kwargs["ssh_password"] = config.ssh_password
|
||||
else:
|
||||
raise ValueError("SSH tunnel needs a private key or password")
|
||||
|
||||
tunnel = SSHTunnelForwarder((ssh_address or config.ssh_host, config.ssh_port), **kwargs)
|
||||
tunnel.start()
|
||||
try:
|
||||
yield "127.0.0.1", tunnel.local_bind_port
|
||||
finally:
|
||||
try:
|
||||
tunnel.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"SSH tunnel teardown warning (non-critical): {e}")
|
||||
|
||||
|
||||
def _driver_connect(config: DBConnectorConfig, host: str, port: int,
|
||||
hostaddr: Optional[str] = None):
|
||||
"""Open a read-only DB session with a statement timeout, to (host, port)
|
||||
— which is the tunnel's local endpoint when tunneling. ``hostaddr`` is
|
||||
the vetted IP to dial instead of resolving ``host`` again."""
|
||||
if config.engine == "mysql":
|
||||
import pymysql
|
||||
conn = pymysql.connect(
|
||||
# pymysql has no separate address field; no TLS is configured
|
||||
# here, so the name isn't needed for verification.
|
||||
host=hostaddr or host,
|
||||
port=port,
|
||||
user=config.username,
|
||||
password=config.password,
|
||||
database=config.database,
|
||||
connect_timeout=CONNECT_TIMEOUT_SECONDS,
|
||||
read_timeout=max(1, config.statement_timeout_ms // 1000 + 1),
|
||||
)
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SET SESSION TRANSACTION READ ONLY")
|
||||
# max_execution_time bounds SELECTs server-side (milliseconds).
|
||||
cursor.execute(f"SET SESSION max_execution_time = {int(config.statement_timeout_ms)}")
|
||||
return conn
|
||||
import psycopg
|
||||
# libpq dials hostaddr and keeps host for TLS verification.
|
||||
pinned = {"hostaddr": hostaddr} if hostaddr else {}
|
||||
return psycopg.connect(
|
||||
host=host,
|
||||
**pinned,
|
||||
port=port,
|
||||
dbname=config.database,
|
||||
user=config.username,
|
||||
password=config.password,
|
||||
connect_timeout=CONNECT_TIMEOUT_SECONDS,
|
||||
options=(
|
||||
f"-c statement_timeout={int(config.statement_timeout_ms)} "
|
||||
"-c default_transaction_read_only=on"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def first_hop(ssh_enabled: bool, ssh_host: Optional[str], host: Optional[str]) -> Tuple[Optional[str], str]:
|
||||
"""The host this server dials first, with its field label, for the
|
||||
outbound policy: the SSH bastion when tunnelling, else the database. A
|
||||
database behind a bastion is reached from the bastion, and may be private."""
|
||||
if ssh_enabled:
|
||||
return ssh_host, "SSH host"
|
||||
return host, "Database host"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _connection(config: DBConnectorConfig):
|
||||
"""Yield a read-only DB connection, opening an SSH tunnel first when the
|
||||
connector is configured for one. Tunnel and connection are both closed on
|
||||
exit — a fresh pair per call keeps the (short-lived) investigation queries
|
||||
isolated."""
|
||||
# Vetted on every connect, not only on save, and the vetted address is
|
||||
# the one dialled: DNS can change, even between two lookups.
|
||||
pinned = pin_outbound_target(*first_hop(config.ssh_enabled, config.ssh_host, config.host))
|
||||
if config.ssh_enabled:
|
||||
if not config.ssh_host or not config.ssh_username:
|
||||
raise ValueError("SSH tunnel is enabled but host/username are missing")
|
||||
with _ssh_tunnel(config, pinned) as (host, port):
|
||||
conn = _driver_connect(config, host, port)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
conn = _driver_connect(config, config.host, config.port, pinned)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_readonly_query(
|
||||
config: DBConnectorConfig, sql: str
|
||||
) -> Tuple[List[str], List[tuple]]:
|
||||
"""Execute validated SQL in a read-only session; fetches at most
|
||||
max_rows + 1 (the validator already forced a LIMIT — this is belt and
|
||||
braces)."""
|
||||
with _connection(config) as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(sql)
|
||||
columns = [d[0] for d in (cursor.description or [])]
|
||||
rows = cursor.fetchmany(config.max_rows + 1)
|
||||
return columns, list(rows)
|
||||
|
||||
|
||||
def test_connection(config: DBConnectorConfig) -> Dict:
|
||||
"""Connect and discover user tables + columns (for the allowlist picker).
|
||||
Returns {"tables": [{"schema", "table", "columns": [{"name", "type"}]}]}."""
|
||||
placeholders = ", ".join(["%s"] * len(SYSTEM_SCHEMAS))
|
||||
sql = (
|
||||
"SELECT table_schema, table_name, column_name, data_type "
|
||||
"FROM information_schema.columns "
|
||||
f"WHERE table_schema NOT IN ({placeholders}) "
|
||||
"ORDER BY table_schema, table_name, ordinal_position"
|
||||
)
|
||||
with _connection(config) as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(sql, SYSTEM_SCHEMAS)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
tables: Dict[Tuple[str, str], List[Dict]] = {}
|
||||
for schema, table, column, data_type in rows:
|
||||
tables.setdefault((schema, table), []).append({"name": column, "type": data_type})
|
||||
return {
|
||||
"tables": [
|
||||
{"schema": schema, "table": table, "columns": columns}
|
||||
for (schema, table), columns in tables.items()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def describe_columns(config: DBConnectorConfig, schema: str, table: str) -> List[Dict]:
|
||||
"""Column names/types of one table via information_schema."""
|
||||
with _connection(config) as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT column_name, data_type FROM information_schema.columns "
|
||||
"WHERE table_schema = %s AND table_name = %s ORDER BY ordinal_position",
|
||||
(schema, table),
|
||||
)
|
||||
return [{"name": name, "type": data_type} for name, data_type in cursor.fetchall()]
|
||||
|
||||
|
||||
def format_result_table(
|
||||
columns: List[str], rows: List[tuple], max_rows: int, max_value_chars: int = 200
|
||||
) -> str:
|
||||
"""Compact text rendering of a result set for the model."""
|
||||
if not columns:
|
||||
return "(no result)"
|
||||
shown = rows[:max_rows]
|
||||
lines = [" | ".join(columns)]
|
||||
for row in shown:
|
||||
lines.append(
|
||||
" | ".join(
|
||||
(str(value)[:max_value_chars] if value is not None else "NULL")
|
||||
for value in row
|
||||
)
|
||||
)
|
||||
suffix = f"({len(shown)} row{'s' if len(shown) != 1 else ''}"
|
||||
if len(rows) > max_rows:
|
||||
suffix += ", truncated"
|
||||
suffix += ")"
|
||||
lines.append(suffix)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,581 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Article-mode help-center import: crawl the linked article pages of an existing
|
||||
help-center/FAQ index and import each one AS-IS — HTML converted straight to
|
||||
Markdown (no LLM, no credits), images downloaded and re-hosted on our storage,
|
||||
links absolutized. One draft FAQ per article: question = page title, answer =
|
||||
the article body in Markdown.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import urldefrag, urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from markdownify import MarkdownConverter
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.knowledge.main_content import select_main_node
|
||||
from app.knowledge.sitemap_parser import _registrable_domain
|
||||
from app.knowledge.url_safety import safe_get
|
||||
from app.models.faq import FAQ, FAQ_SOURCE_URL_MAX_LENGTH, FAQStatus
|
||||
from app.models.faq_generation_job import FAQGenerationJob, FAQJobStage
|
||||
from app.models.schemas.faq import MAX_ANSWER_LENGTH, MAX_QUESTION_LENGTH
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.repositories.faq_generation_job import FAQGenerationJobRepository
|
||||
from app.services.faq_generation import CategoryMerger, insert_draft_rows, normalize_question
|
||||
from app.services.faq_import import _FETCH_HEADERS, MIN_STATIC_TEXT_CHARS
|
||||
from app.services.help_center_images import (
|
||||
FAQ_IMAGE_TYPES,
|
||||
MAX_FAQ_IMAGE_BYTES,
|
||||
store_article_image,
|
||||
)
|
||||
from app.services.help_center_settings import assign_faq_url_paths
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_ARTICLE_CATEGORY = "Imported"
|
||||
|
||||
# Link targets that can never be article pages.
|
||||
_SKIP_EXTENSIONS = (
|
||||
".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".zip", ".mp4",
|
||||
".css", ".js", ".xml", ".ico", ".woff", ".woff2", ".txt", ".json",
|
||||
)
|
||||
|
||||
# In-article chrome that shouldn't survive into the imported Markdown.
|
||||
# Word-boundary anchored: matches "docs-toc"/"breadcrumbs" but never innocent
|
||||
# substrings like "stock-levels" or "autocomplete".
|
||||
_TOC_RE = re.compile(r"\b(toc|table-of-contents|on-this-page|breadcrumbs?)\b", re.IGNORECASE)
|
||||
|
||||
# Help-center chrome text (platform branding / article metadata) to drop.
|
||||
# Platform-specific so it never strips legitimate prose like "Powered by our
|
||||
# AI engine" — only footer branding for known help-center platforms and the
|
||||
# "Last updated on …" article-metadata line.
|
||||
_CHROME_TEXT_RE = re.compile(
|
||||
r"^last updated on\b"
|
||||
r"|^(made with|powered by)\b.*\b(chatwoot|intercom|zendesk|help ?scout|gitbook|freshdesk|document360)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CHROME_HREFS = ("chatwoot.com", "chatwoot.help")
|
||||
|
||||
# Path markers separating leaf article pages from category/section listings
|
||||
# across Chatwoot / Zendesk / Intercom-style help centers. Only /articles/
|
||||
# pages hold real content; the listings are followed for their article links.
|
||||
_ARTICLE_MARKER = "/articles/"
|
||||
_CATEGORY_MARKERS = ("/categories/", "/sections/", "/collections/")
|
||||
_HEADING_TAGS = ("h1", "h2", "h3", "h4")
|
||||
_LEADING_SYMBOLS_RE = re.compile(r"^[^\w]+", re.UNICODE)
|
||||
|
||||
# Homepage section headings that are curated cross-cuts, not real categories —
|
||||
# their articles' true category comes from the category listing pages instead.
|
||||
_GENERIC_SECTIONS = frozenset({
|
||||
"featured articles", "featured", "popular", "popular articles", "all articles",
|
||||
"browse", "recent articles", "recently updated", "top articles",
|
||||
})
|
||||
|
||||
# Internal scheme marking images collected during conversion, replaced with the
|
||||
# re-hosted URL afterwards (conversion is sync; storage is async). The trailing
|
||||
# ".img" delimits the index so "…//1.img" is never a prefix of "…//10.img".
|
||||
_IMG_PLACEHOLDER = "rp-pending-image://{index}.img"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Article:
|
||||
url: str
|
||||
title: str
|
||||
markdown: str
|
||||
category_hint: str
|
||||
# index -> (bytes, content_type) awaiting re-host.
|
||||
pending_images: Dict[int, Tuple[bytes, str]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class _ArticleConverter(MarkdownConverter):
|
||||
"""HTML→Markdown converter that absolutizes links and swaps images for
|
||||
placeholders after downloading them (SSRF-guarded, size/type-capped)."""
|
||||
|
||||
def __init__(self, base_url: str, client: httpx.Client, **options):
|
||||
super().__init__(**options)
|
||||
self._base_url = base_url
|
||||
self._client = client
|
||||
self.images: Dict[int, Tuple[bytes, str]] = {}
|
||||
|
||||
def convert_a(self, el, text, parent_tags):
|
||||
# Absolutize every link; same-site source links are remapped to the
|
||||
# destination help center in a second pass (run_article_import_job),
|
||||
# once every article's new FAQ slug is known.
|
||||
href = el.get("href")
|
||||
if href:
|
||||
el["href"] = urljoin(self._base_url, href)
|
||||
return super().convert_a(el, text, parent_tags)
|
||||
|
||||
def convert_img(self, el, text, parent_tags):
|
||||
alt = (el.get("alt") or "").strip()
|
||||
src = (el.get("src") or "").strip()
|
||||
if not src or src.startswith("data:"):
|
||||
return alt # inline/base64 images are stripped by the renderer anyway
|
||||
if len(self.images) >= settings.FAQ_ARTICLE_IMPORT_MAX_IMAGES:
|
||||
return alt
|
||||
image = self._download(urljoin(self._base_url, src))
|
||||
if not image:
|
||||
return alt
|
||||
index = len(self.images)
|
||||
self.images[index] = image
|
||||
return f"})"
|
||||
|
||||
def _download(self, url: str) -> Optional[Tuple[bytes, str]]:
|
||||
try:
|
||||
response = safe_get(self._client, url)
|
||||
response.raise_for_status()
|
||||
content_type = (response.headers.get("content-type") or "").split(";")[0].strip()
|
||||
if content_type not in FAQ_IMAGE_TYPES or len(response.content) > MAX_FAQ_IMAGE_BYTES:
|
||||
return None
|
||||
return response.content, content_type
|
||||
except Exception as e:
|
||||
logger.warning(f"Article image skipped ({url}): {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_soup(client: httpx.Client, url: str) -> Tuple[Optional[BeautifulSoup], str]:
|
||||
"""Static SSRF-guarded fetch → (soup, final hop-validated URL)."""
|
||||
try:
|
||||
response = safe_get(client, url)
|
||||
response.raise_for_status()
|
||||
return BeautifulSoup(response.text, "html.parser"), str(response.url)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"Article fetch failed for {url}: {e}")
|
||||
return None, url
|
||||
|
||||
|
||||
def _fetch_index_soup(client: httpx.Client, url: str) -> Tuple[Optional[BeautifulSoup], str]:
|
||||
"""Index fetch with the headless-browser fallback for JS-rendered indexes
|
||||
(article pages themselves are static-only — one browser per crawl, not 30)."""
|
||||
soup, final_url = _fetch_soup(client, url)
|
||||
if soup and len(soup.get_text(" ", strip=True)) >= MIN_STATIC_TEXT_CHARS:
|
||||
return soup, final_url
|
||||
try:
|
||||
from app.knowledge.crawl4ai_fallback import get_crawl4ai_fallback
|
||||
|
||||
fallback = get_crawl4ai_fallback(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT)
|
||||
if fallback.is_available:
|
||||
html, browser_soup, _ = fallback.fetch_with_browser(final_url)
|
||||
if browser_soup is not None:
|
||||
return browser_soup, final_url
|
||||
if isinstance(html, str) and html:
|
||||
return BeautifulSoup(html, "html.parser"), final_url
|
||||
except Exception as e:
|
||||
logger.warning(f"Article index browser fetch failed for {url}: {e}")
|
||||
return soup, final_url
|
||||
|
||||
|
||||
def _clean_category(text: str) -> str:
|
||||
"""Strip a leading emoji/symbol (Chatwoot section headings are '📞 Help
|
||||
and support') and cap length."""
|
||||
cleaned = _LEADING_SYMBOLS_RE.sub("", (text or "").strip()).strip()
|
||||
return cleaned[:100] or DEFAULT_ARTICLE_CATEGORY
|
||||
|
||||
|
||||
def _same_site_href(anchor: Tag, base_url: str, root_domain: str) -> Optional[str]:
|
||||
"""Absolute same-site content URL for an anchor, or None if it's off-site,
|
||||
a fragment/query, a bare root, or a binary asset."""
|
||||
href = anchor.get("href")
|
||||
if not href:
|
||||
return None
|
||||
url = urldefrag(urljoin(base_url, href))[0].rstrip("/")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or parsed.query:
|
||||
return None
|
||||
if parsed.path in ("", "/"):
|
||||
return None
|
||||
if _registrable_domain(parsed.hostname or "") != root_domain:
|
||||
return None
|
||||
if parsed.path.lower().endswith(_SKIP_EXTENSIONS):
|
||||
return None
|
||||
return url
|
||||
|
||||
|
||||
def _is_article_url(url: str) -> bool:
|
||||
return _ARTICLE_MARKER in urlparse(url).path.lower()
|
||||
|
||||
|
||||
def _is_category_url(url: str) -> bool:
|
||||
path = urlparse(url).path.lower()
|
||||
return any(marker in path for marker in _CATEGORY_MARKERS)
|
||||
|
||||
|
||||
def _sectioned_article_links(node: Tag, base_url: str, root_domain: str) -> List[Tuple[str, Optional[str]]]:
|
||||
"""Article links in document order, each tagged with the nearest preceding
|
||||
section heading (a help-center homepage's category cards). Headings nested
|
||||
inside a link are article titles, not sections, so they don't reset it."""
|
||||
results: List[Tuple[str, Optional[str]]] = []
|
||||
current: Optional[str] = None
|
||||
for el in node.find_all([*_HEADING_TAGS, "a"]):
|
||||
if el.name in _HEADING_TAGS:
|
||||
if not el.find_parent("a"):
|
||||
cat = _clean_category(el.get_text(" ", strip=True))
|
||||
# Curated cross-cut sections ("Featured Articles") aren't real
|
||||
# categories — leave those articles for the category pages.
|
||||
current = None if cat.lower() in _GENERIC_SECTIONS else cat
|
||||
continue
|
||||
url = _same_site_href(el, base_url, root_domain)
|
||||
if url and _is_article_url(url):
|
||||
results.append((url, current))
|
||||
return results
|
||||
|
||||
|
||||
def discover_article_links(
|
||||
client: httpx.Client, index_url: str, limit: int
|
||||
) -> List[Tuple[str, Optional[str]]]:
|
||||
"""Discover (article_url, category) pairs from a help-center index.
|
||||
|
||||
Standard help centers (Chatwoot/Zendesk/Intercom) nest articles under
|
||||
/articles/ and group them into /categories/ (or /sections//collections/).
|
||||
We follow the category listing pages FIRST for the complete per-category
|
||||
list (homepages truncate each section to a few articles), so hitting the
|
||||
page cap yields whole categories rather than a thin slice of every one.
|
||||
Homepage section links then fill any remainder (e.g. featured-only
|
||||
articles). A non-standard/flat page with no /articles/ links falls back to
|
||||
importing every same-site content link.
|
||||
"""
|
||||
soup, final_url = _fetch_index_soup(client, index_url)
|
||||
if soup is None:
|
||||
raise ValueError("Could not read that page.")
|
||||
root_domain = _registrable_domain(urlparse(final_url).hostname or "")
|
||||
index_clean = urldefrag(final_url)[0].rstrip("/")
|
||||
main = select_main_node(soup) or soup
|
||||
|
||||
ordered: List[str] = []
|
||||
category_of: Dict[str, Optional[str]] = {}
|
||||
|
||||
def record(url: str, category: Optional[str]) -> None:
|
||||
if url == index_clean:
|
||||
return
|
||||
if url not in category_of:
|
||||
category_of[url] = category
|
||||
ordered.append(url)
|
||||
elif category and not category_of[url]:
|
||||
# Upgrade a still-uncategorised article (seen first under a generic
|
||||
# homepage section) with the category page's real name.
|
||||
category_of[url] = category
|
||||
|
||||
# 1. Follow category/section listing pages first — the complete per-category
|
||||
# lists (a homepage shows only ~5 of a category's articles).
|
||||
category_links: List[str] = []
|
||||
seen_cat = set()
|
||||
for anchor in main.find_all("a", href=True):
|
||||
url = _same_site_href(anchor, final_url, root_domain)
|
||||
if url and _is_category_url(url) and url not in seen_cat:
|
||||
seen_cat.add(url)
|
||||
category_links.append(url)
|
||||
for cat_url in category_links[: settings.FAQ_ARTICLE_IMPORT_MAX_CATEGORIES]:
|
||||
if len(ordered) >= limit:
|
||||
break
|
||||
cat_soup, cat_final = _fetch_soup(client, cat_url)
|
||||
if cat_soup is None:
|
||||
continue
|
||||
og_title, doc_title = _head_titles(cat_soup)
|
||||
cat_node = select_main_node(cat_soup) or cat_soup
|
||||
cat_name = _clean_category(_page_title(og_title, doc_title, cat_node, cat_soup))
|
||||
for anchor in cat_node.find_all("a", href=True):
|
||||
url = _same_site_href(anchor, cat_final, root_domain)
|
||||
if url and _is_article_url(url):
|
||||
record(url, cat_name)
|
||||
|
||||
# 2. Homepage section links — fills any remainder (featured-only articles,
|
||||
# or the index itself being a single category/flat listing page).
|
||||
for url, category in _sectioned_article_links(main, final_url, root_domain):
|
||||
record(url, category)
|
||||
|
||||
# 3. Fallback: no standard article URLs — import every same-site link,
|
||||
# resolving each article's category from its own breadcrumb/URL.
|
||||
if not ordered:
|
||||
for anchor in main.find_all("a", href=True):
|
||||
url = _same_site_href(anchor, final_url, root_domain)
|
||||
if url:
|
||||
record(url, None)
|
||||
|
||||
ordered = ordered[:limit]
|
||||
if not ordered:
|
||||
raise ValueError("No linked article pages found on that page.")
|
||||
return [(url, category_of[url]) for url in ordered]
|
||||
|
||||
|
||||
def _head_titles(soup: BeautifulSoup) -> Tuple[str, str]:
|
||||
"""(og:title, <title>) — must run BEFORE select_main_node, which strips
|
||||
<head> from the soup."""
|
||||
og = soup.find("meta", property="og:title")
|
||||
og_title = og["content"].strip() if og and og.get("content", "").strip() else ""
|
||||
doc_title = ""
|
||||
if soup.title and soup.title.get_text(strip=True):
|
||||
# Drop the " | Site Name" tail commonly appended to <title>.
|
||||
doc_title = soup.title.get_text(strip=True).split("|")[0].strip()
|
||||
return og_title, doc_title
|
||||
|
||||
|
||||
def _page_title(og_title: str, doc_title: str, node: Tag, soup: BeautifulSoup) -> str:
|
||||
if og_title:
|
||||
return og_title
|
||||
h1 = node.find("h1") or soup.find("h1")
|
||||
if h1 and h1.get_text(strip=True):
|
||||
return h1.get_text(" ", strip=True)
|
||||
return doc_title
|
||||
|
||||
|
||||
def _category_hint(soup: BeautifulSoup, url: str) -> str:
|
||||
"""Breadcrumb (second-to-last crumb = the article's section) or a URL path
|
||||
segment, else the generic import bucket."""
|
||||
crumbs = soup.select('[aria-label*="readcrumb"] a, nav.breadcrumb a, .breadcrumbs a, .breadcrumb a')
|
||||
texts = [c.get_text(" ", strip=True) for c in crumbs if c.get_text(strip=True)]
|
||||
if len(texts) >= 2:
|
||||
# Last crumb is the page itself; the one before is its section.
|
||||
return texts[-2][:100]
|
||||
segments = [s for s in urlparse(url).path.split("/") if s]
|
||||
if len(segments) >= 2:
|
||||
return segments[-2].replace("-", " ").replace("_", " ").title()[:100]
|
||||
return DEFAULT_ARTICLE_CATEGORY
|
||||
|
||||
|
||||
def _strip_chrome(node: Tag) -> None:
|
||||
"""Remove in-article chrome that shouldn't become FAQ content: nav bars,
|
||||
TOCs, footers, help-center platform branding ('Made with Chatwoot') and
|
||||
'Last updated' metadata lines. Best-effort — a detached element mid-loop
|
||||
just skips. (Breadcrumb "Home › Category" nav links are dropped later, in
|
||||
_remap_source_links, since they often share a container with the title.)"""
|
||||
for chrome in node.find_all("nav") + node.find_all("footer"):
|
||||
chrome.decompose()
|
||||
for chrome in node.find_all(class_=_TOC_RE) + node.find_all(id=_TOC_RE):
|
||||
try:
|
||||
chrome.decompose()
|
||||
except Exception:
|
||||
pass
|
||||
# Platform-branding link ("Made with Chatwoot" logo): remove only a SMALL
|
||||
# enclosing block (the footer badge), never a large content div that merely
|
||||
# links out to the platform.
|
||||
for anchor in list(node.find_all("a", href=True)):
|
||||
try:
|
||||
href = (anchor.get("href") or "").lower()
|
||||
if any(brand in href for brand in _CHROME_HREFS):
|
||||
container = anchor.find_parent(["div", "footer", "section"])
|
||||
target = container if container and len(container.get_text(" ", strip=True)) <= 40 else anchor
|
||||
target.decompose()
|
||||
except Exception:
|
||||
pass
|
||||
for el in list(node.find_all(["p", "div", "span", "small", "time"])):
|
||||
try:
|
||||
text = el.get_text(" ", strip=True)
|
||||
if text and len(text) <= 60 and _CHROME_TEXT_RE.search(text):
|
||||
el.decompose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def fetch_article(
|
||||
client: httpx.Client, url: str, category_override: Optional[str] = None,
|
||||
) -> Optional[Article]:
|
||||
"""One article page → title + Markdown body (+ images pending re-host).
|
||||
category_override (from the category listing that linked here) wins over the
|
||||
per-page breadcrumb/URL guess. None when the page can't be read or is empty."""
|
||||
soup, final_url = _fetch_soup(client, url)
|
||||
if soup is None:
|
||||
return None
|
||||
og_title, doc_title = _head_titles(soup) # before <head> is stripped below
|
||||
node = select_main_node(soup)
|
||||
if node is None:
|
||||
return None
|
||||
title = _page_title(og_title, doc_title, node, soup)
|
||||
if not title:
|
||||
return None
|
||||
category = category_override or _category_hint(soup, final_url)
|
||||
# The page's own h1 would duplicate the FAQ question.
|
||||
first_h1 = node.find("h1")
|
||||
if first_h1:
|
||||
first_h1.extract()
|
||||
_strip_chrome(node)
|
||||
converter = _ArticleConverter(
|
||||
base_url=final_url, client=client, heading_style="ATX", bullets="-"
|
||||
)
|
||||
markdown = converter.convert_soup(node).strip()
|
||||
if not markdown:
|
||||
return None
|
||||
return Article(
|
||||
url=final_url,
|
||||
title=title[:MAX_QUESTION_LENGTH],
|
||||
markdown=markdown,
|
||||
category_hint=category,
|
||||
pending_images=converter.images,
|
||||
)
|
||||
|
||||
|
||||
async def _rehost_images(article: Article) -> str:
|
||||
markdown = article.markdown
|
||||
for index, (content, content_type) in article.pending_images.items():
|
||||
placeholder = _IMG_PLACEHOLDER.format(index=index)
|
||||
try:
|
||||
markdown = markdown.replace(placeholder, await store_article_image(content, content_type))
|
||||
except Exception as e:
|
||||
logger.warning(f"Article image re-host failed ({article.url}): {e}")
|
||||
markdown = markdown.replace(f"({placeholder})", "()")
|
||||
return markdown
|
||||
|
||||
|
||||
# Markdown links, excluding image embeds (``). Group 1 = label,
|
||||
# group 2 = the http(s) URL; an optional markdown title (`[x](url "title")`, which
|
||||
# markdownify emits for anchors with a title attribute) is tolerated and dropped.
|
||||
_MD_LINK_RE = re.compile(r'(?<!!)\[([^\]]*)\]\((https?://[^)\s]+)(?:\s+"[^"]*")?\)')
|
||||
|
||||
|
||||
def _article_key(path: str) -> Optional[str]:
|
||||
"""Stable identifier for an article from its URL path — the numeric id in
|
||||
`/articles/{id}-{slug}` (falling back to the whole segment). Lets a cross-link
|
||||
resolve to the same article regardless of the slug suffix or a missing slug."""
|
||||
lower = path.lower()
|
||||
idx = lower.find(_ARTICLE_MARKER)
|
||||
if idx == -1:
|
||||
return None
|
||||
segment = path[idx + len(_ARTICLE_MARKER):].split("/")[0].split("?")[0]
|
||||
numeric = re.match(r"\d+", segment)
|
||||
return numeric.group(0) if numeric else (segment.lower() or None)
|
||||
|
||||
|
||||
# Leading breadcrumb residue after "Home › Account" links are dropped: chevron /
|
||||
# middot separators (never markdown-structural chars like - > | so hr/blockquote/
|
||||
# tables are safe).
|
||||
_BREADCRUMB_RESIDUE_RE = re.compile(r"^[\s›»·]+")
|
||||
|
||||
|
||||
def _remap_source_links(markdown: str, source_hosts: set, key_to_slug: dict) -> str:
|
||||
"""Rewrite links to the SOURCE HELP CENTER as ROOT-RELATIVE help-center paths:
|
||||
- article link → /a/{new-slug} (or / when the article wasn't imported),
|
||||
kept as a link;
|
||||
- home/category link (non-article) → DROPPED — these are breadcrumb/nav
|
||||
chrome ("Home › Account") that shouldn't appear in article bodies.
|
||||
|
||||
Links are stored WITHOUT an origin or /help/{slug} prefix so they survive a
|
||||
move to a custom domain or subdomain — the serving prefix is added at render
|
||||
time (help_center_content.render_article_html). Matching is by EXACT host
|
||||
against the set of hosts the articles were actually fetched from (handles
|
||||
redirects and main-site→help-subdomain splits), so links to the main product
|
||||
site and every other external link are left untouched."""
|
||||
def repl(match):
|
||||
label, url = match.group(1), match.group(2)
|
||||
parsed = urlparse(url)
|
||||
if (parsed.hostname or "").lower() not in source_hosts:
|
||||
return match.group(0) # off-site (incl. the main product site) — keep
|
||||
key = _article_key(parsed.path)
|
||||
if key is None:
|
||||
return "" # home / category breadcrumb link — drop entirely
|
||||
slug = key_to_slug.get(key)
|
||||
return f"[{label}](/a/{slug})" if slug else f"[{label}](/)"
|
||||
|
||||
result = _MD_LINK_RE.sub(repl, markdown)
|
||||
# Tidy up after dropped breadcrumbs: strip leading separator residue and
|
||||
# collapse the blank lines those drops leave behind.
|
||||
result = _BREADCRUMB_RESIDUE_RE.sub("", result)
|
||||
return re.sub(r"\n{3,}", "\n\n", result)
|
||||
|
||||
|
||||
async def run_article_import_job(db, job: FAQGenerationJob) -> int:
|
||||
"""Execute an IMPORT_ARTICLES job. Returns FAQs created. No LLM involved —
|
||||
articles are imported verbatim as Markdown drafts, then a second pass remaps
|
||||
intra-help-center links to the org's new help center."""
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0)
|
||||
|
||||
client = httpx.Client(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT, headers=_FETCH_HEADERS)
|
||||
try:
|
||||
links = await asyncio.to_thread(
|
||||
discover_article_links, client, job.source_url, settings.FAQ_ARTICLE_IMPORT_MAX_PAGES
|
||||
)
|
||||
job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=10.0)
|
||||
|
||||
existing = {normalize_question(q) for q in FAQRepository(db).get_existing_questions(job.organization_id)}
|
||||
categories = CategoryMerger(db, job.organization_id)
|
||||
source_label = f"Imported from {urlparse(job.source_url).netloc}"
|
||||
|
||||
rows: List[FAQ] = []
|
||||
row_sources: List[str] = [] # each row's source article URL, for link remapping
|
||||
for index, (link, category) in enumerate(links):
|
||||
article = await asyncio.to_thread(fetch_article, client, link, category)
|
||||
job_repo.update_progress(
|
||||
job.id, progress_percentage=10.0 + 78.0 * (index + 1) / len(links)
|
||||
)
|
||||
if not article:
|
||||
continue
|
||||
key = normalize_question(article.title)
|
||||
if not key or key in existing:
|
||||
continue
|
||||
existing.add(key)
|
||||
answer = (await _rehost_images(article))[:MAX_ANSWER_LENGTH]
|
||||
rows.append(
|
||||
FAQ(
|
||||
organization_id=job.organization_id,
|
||||
question=article.title,
|
||||
answer=answer,
|
||||
category=categories.merge(article.category_hint),
|
||||
status=FAQStatus.DRAFT,
|
||||
knowledge_id=None,
|
||||
source_label=source_label,
|
||||
# Provenance on every import, whether or not the original
|
||||
# URLs are being preserved — so a later "apply preserved
|
||||
# paths" pass is a pure data migration.
|
||||
source_url=article.url[:FAQ_SOURCE_URL_MAX_LENGTH],
|
||||
generation_job_id=job.id,
|
||||
created_by=job.user_id,
|
||||
)
|
||||
)
|
||||
row_sources.append(article.url)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
if job.preserve_source_urls:
|
||||
# Before the insert, so the per-org unique index can never reject the
|
||||
# batch. Rows that can't keep their path just fall back to /a/{slug} —
|
||||
# one unusable URL must not cost the org the whole import.
|
||||
applied = assign_faq_url_paths(db, rows, row_sources)
|
||||
logger.info(f"Article import job {job.id}: preserved {applied}/{len(rows)} original URLs")
|
||||
created = insert_draft_rows(db, job, rows) # assigns each row a unique slug in place
|
||||
_remap_imported_links(db, rows, row_sources)
|
||||
return created
|
||||
|
||||
|
||||
def _remap_imported_links(db, rows, row_sources) -> None:
|
||||
"""Second pass: rewrite intra-help-center links in the just-imported answers to
|
||||
root-relative help-center paths (article → /a/{slug}, else → home)."""
|
||||
# Match against the hosts the articles were ACTUALLY fetched from (post-redirect,
|
||||
# possibly a help subdomain) — NOT the typed index URL, which may redirect or be
|
||||
# main-site-hosted. Using the real article hosts fixes redirect/subdomain splits
|
||||
# without risking a match on main-site content links.
|
||||
source_hosts = {(urlparse(src).hostname or "").lower() for src in row_sources}
|
||||
source_hosts.discard("")
|
||||
key_to_slug = {}
|
||||
for src, faq in zip(row_sources, rows):
|
||||
key = _article_key(urlparse(src).path)
|
||||
if key:
|
||||
key_to_slug[key] = faq.slug
|
||||
|
||||
dirty = False
|
||||
for faq in rows:
|
||||
remapped = _remap_source_links(faq.answer, source_hosts, key_to_slug)
|
||||
if remapped != faq.answer:
|
||||
faq.answer = remapped[:MAX_ANSWER_LENGTH]
|
||||
dirty = True
|
||||
if dirty:
|
||||
db.commit()
|
||||
@@ -0,0 +1,431 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
FAQ generation pipeline: read the org's stored knowledge text (no re-crawl),
|
||||
extract grounded FAQs with the org's configured model, dedup against existing
|
||||
questions and insert drafts for review. Additive only — regeneration never
|
||||
edits or deletes existing FAQs. The drafting loop and dedup/category helpers
|
||||
here are shared with the import service (faq_import.py).
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections import deque
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Awaitable, Callable, List, Optional, Tuple, TypeVar
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.agents.faq_generator import FAQGeneratorAgent, GeneratedFAQ
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.knowledge.page_editor import NATURAL_ID_ORDER, PAGE_ID_EXPR
|
||||
from app.models.faq import FAQ, DEFAULT_FAQ_CATEGORY, FAQStatus
|
||||
from app.models.faq_generation_job import FAQGenerationJob, FAQJobStage, FAQJobType
|
||||
from app.models.knowledge import Knowledge
|
||||
from app.repositories.ai_config import AIConfigRepository
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.repositories.faq_generation_job import FAQGenerationJobRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Dedup lists injected into prompts are a small recent window for intra-run
|
||||
# steering only — the in-Python DedupState below (exact + fuzzy) is the real
|
||||
# guard covering every existing question, so the prompt stays cheap.
|
||||
MAX_PROMPT_QUESTIONS = 30
|
||||
|
||||
# Fuzzy-dedup tuning: token-set Jaccard on content words; borderline scores are
|
||||
# confirmed with difflib on the normalized strings. Very short questions skip
|
||||
# fuzzy matching entirely (too collision-prone).
|
||||
FUZZY_JACCARD_DUP = 0.8
|
||||
FUZZY_JACCARD_MAYBE = 0.65
|
||||
FUZZY_SEQUENCE_DUP = 0.87
|
||||
FUZZY_MIN_CONTENT_TOKENS = 3
|
||||
|
||||
# Tiny inline stopword set — enough to keep filler words from inflating
|
||||
# question similarity, without an NLP dependency.
|
||||
_STOPWORDS = frozenset(
|
||||
"a an and are can could do does for from how i in is it my of on or the "
|
||||
"there to we what when where which who why will with you your".split()
|
||||
)
|
||||
|
||||
|
||||
_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)
|
||||
|
||||
BatchT = TypeVar("BatchT")
|
||||
|
||||
|
||||
def normalize_question(question: str) -> str:
|
||||
"""Casefold + strip punctuation/whitespace, so trivial rephrasings of the
|
||||
same question dedup against each other."""
|
||||
return " ".join(_PUNCT_RE.sub(" ", question.casefold()).split())
|
||||
|
||||
|
||||
class NoAIConfigError(Exception):
|
||||
"""The org has no active AI configuration to generate with."""
|
||||
|
||||
|
||||
def build_generator(db: Session, organization_id, job: Optional[FAQGenerationJob] = None) -> FAQGeneratorAgent:
|
||||
"""The org's configured extraction agent. When a job is given, every actual
|
||||
provider call (retries and the plain-JSON fallback's second call included)
|
||||
increments the job's llm_calls — the metering source of truth."""
|
||||
config = AIConfigRepository(db).get_active_config(organization_id)
|
||||
if not config:
|
||||
raise NoAIConfigError(
|
||||
"No active AI configuration. Set up an AI model in AI Configuration first."
|
||||
)
|
||||
generator = FAQGeneratorAgent(
|
||||
api_key=decrypt_api_key(config.encrypted_api_key),
|
||||
model_name=config.model_name,
|
||||
model_type=config.model_type.value if hasattr(config.model_type, "value") else str(config.model_type),
|
||||
base_url=(config.settings or {}).get('base_url'),
|
||||
)
|
||||
if job is not None:
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
generator.on_llm_call = lambda: job_repo.increment_llm_calls(job.id)
|
||||
return generator
|
||||
|
||||
|
||||
def load_source_pages(db: Session, knowledge: Knowledge, max_chars: Optional[int] = None) -> List[str]:
|
||||
"""One string of aggregated text per crawled page/sub-page of a source,
|
||||
read straight from the org's vector table (content is stored alongside the
|
||||
embeddings — no re-crawl needed). Pages kept in crawl order when the cap
|
||||
truncates; chunk text within a page is naturally ordered. max_chars caps
|
||||
each page and defaults to the model-agnostic floor."""
|
||||
if not knowledge.schema or not knowledge.table_name:
|
||||
# Source rows without a vector table (failed/partial ingestion).
|
||||
return []
|
||||
# Chunks written by app.knowledge.chunking (`::n` ids) are verbatim slices
|
||||
# and join back with nothing between them; legacy agno `_n` chunks were
|
||||
# cut on whitespace they dropped, so those keep a newline.
|
||||
query = text(
|
||||
f"SELECT {PAGE_ID_EXPR} AS page_id, "
|
||||
"string_agg(content, CASE WHEN id ~ '::[0-9]+$' THEN '' ELSE E'\n' END "
|
||||
f"ORDER BY {NATURAL_ID_ORDER}) AS body "
|
||||
f'FROM {knowledge.schema}."{knowledge.table_name}" '
|
||||
"WHERE name = :source GROUP BY 1 ORDER BY min(created_at) LIMIT :max_pages"
|
||||
)
|
||||
rows = db.execute(
|
||||
query, {"source": knowledge.source, "max_pages": settings.FAQ_MAX_PAGES_PER_SOURCE}
|
||||
).fetchall()
|
||||
max_chars = max_chars or settings.FAQ_MAX_BATCH_CHARS
|
||||
return [row.body[:max_chars] for row in rows if row.body and row.body.strip()]
|
||||
|
||||
|
||||
def pack_batches(items: List[str], max_chars: Optional[int] = None, sep: str = "\n\n---\n\n") -> List[str]:
|
||||
"""Greedy-pack whole items (pages, lines) into batches of ~max_chars.
|
||||
Oversized single items are truncated to max_chars, never split across
|
||||
batches. Shared by generation (page items) and import (line items)."""
|
||||
max_chars = max_chars or settings.FAQ_MAX_BATCH_CHARS
|
||||
batches: List[str] = []
|
||||
current: List[str] = []
|
||||
size = 0
|
||||
for item in items:
|
||||
item = item[:max_chars]
|
||||
if current and size + len(item) + len(sep) > max_chars:
|
||||
batches.append(sep.join(current))
|
||||
current, size = [], 0
|
||||
current.append(item)
|
||||
size += len(item) + len(sep)
|
||||
if current:
|
||||
batches.append(sep.join(current))
|
||||
return batches
|
||||
|
||||
|
||||
def _content_tokens(normalized: str) -> frozenset:
|
||||
return frozenset(t for t in normalized.split() if t not in _STOPWORDS)
|
||||
|
||||
|
||||
class DedupState:
|
||||
"""Tracks every question seen (existing + accepted this run).
|
||||
|
||||
Two layers: an exact normalized-set match, plus fuzzy similarity (token-set
|
||||
Jaccard via an inverted index, difflib confirmation on borderline scores)
|
||||
so rephrasings are caught without shipping the whole question list to the
|
||||
LLM. A bounded recent-questions window is still exposed for prompts."""
|
||||
|
||||
def __init__(self, existing_questions: List[str]):
|
||||
self.seen: set = set()
|
||||
self._token_index: dict = {} # content token -> set of seen keys
|
||||
self._key_tokens: dict = {} # seen key -> its content-token frozenset
|
||||
self._recent = deque(existing_questions, maxlen=MAX_PROMPT_QUESTIONS)
|
||||
for q in existing_questions:
|
||||
self._remember(normalize_question(q))
|
||||
|
||||
def _remember(self, key: str) -> None:
|
||||
if not key or key in self.seen:
|
||||
return
|
||||
self.seen.add(key)
|
||||
tokens = _content_tokens(key)
|
||||
self._key_tokens[key] = tokens
|
||||
for token in tokens:
|
||||
self._token_index.setdefault(token, set()).add(key)
|
||||
|
||||
def is_similar(self, key: str) -> bool:
|
||||
"""True when key fuzzily matches a seen question. Exact hits are the
|
||||
caller's job (cheap set test); this covers rephrasings."""
|
||||
tokens = _content_tokens(key)
|
||||
if len(tokens) < FUZZY_MIN_CONTENT_TOKENS:
|
||||
return False
|
||||
candidates: set = set()
|
||||
for token in tokens:
|
||||
candidates |= self._token_index.get(token, set())
|
||||
for candidate in candidates:
|
||||
other = self._key_tokens[candidate]
|
||||
union = len(tokens | other)
|
||||
if not union:
|
||||
continue
|
||||
jaccard = len(tokens & other) / union
|
||||
if jaccard >= FUZZY_JACCARD_DUP:
|
||||
return True
|
||||
if jaccard >= FUZZY_JACCARD_MAYBE and (
|
||||
SequenceMatcher(None, key, candidate).ratio() >= FUZZY_SEQUENCE_DUP
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def accept_new(self, faqs: List[GeneratedFAQ]) -> List[GeneratedFAQ]:
|
||||
accepted = []
|
||||
for faq in faqs:
|
||||
key = normalize_question(faq.question)
|
||||
if not key or key in self.seen or self.is_similar(key):
|
||||
continue
|
||||
self._remember(key)
|
||||
self._recent.append(faq.question)
|
||||
accepted.append(faq)
|
||||
return accepted
|
||||
|
||||
@property
|
||||
def for_prompt(self) -> List[str]:
|
||||
return list(self._recent)
|
||||
|
||||
|
||||
class CategoryMerger:
|
||||
"""Case-insensitive merge of model-emitted categories into the org's
|
||||
existing spellings."""
|
||||
|
||||
def __init__(self, db: Session, organization_id):
|
||||
self._known = {c.casefold(): c for c in FAQRepository(db).get_categories(organization_id)}
|
||||
|
||||
def merge(self, category: str) -> str:
|
||||
cleaned = (category or "").strip() or DEFAULT_FAQ_CATEGORY
|
||||
return self._known.setdefault(cleaned.casefold(), cleaned)
|
||||
|
||||
@property
|
||||
def names(self) -> List[str]:
|
||||
return list(self._known.values())
|
||||
|
||||
|
||||
async def draft_batches(
|
||||
db: Session,
|
||||
job: FAQGenerationJob,
|
||||
batches: List[BatchT],
|
||||
extract: Callable[[BatchT, DedupState], Awaitable[List[GeneratedFAQ]]],
|
||||
retries_per_batch: int = 1,
|
||||
) -> List[Tuple[BatchT, GeneratedFAQ]]:
|
||||
"""Shared DRAFTING stage: run `extract` per batch with retry, dedup as we
|
||||
go, report progress 30→90%. Raises only when every batch failed."""
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
job_repo.update_progress(job.id, stage=FAQJobStage.DRAFTING, progress_percentage=30.0)
|
||||
dedup = DedupState(FAQRepository(db).get_existing_questions(job.organization_id))
|
||||
|
||||
accepted: List[Tuple[BatchT, GeneratedFAQ]] = []
|
||||
failures = 0
|
||||
last_error: Optional[Exception] = None
|
||||
for index, batch in enumerate(batches):
|
||||
faqs = None
|
||||
for attempt in range(1 + retries_per_batch):
|
||||
try:
|
||||
faqs = await extract(batch, dedup)
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"FAQ batch {index + 1}/{len(batches)} attempt {attempt + 1} failed: {e}")
|
||||
if faqs is None:
|
||||
failures += 1
|
||||
else:
|
||||
accepted.extend((batch, faq) for faq in dedup.accept_new(faqs))
|
||||
job_repo.update_progress(
|
||||
job.id, progress_percentage=30.0 + 60.0 * (index + 1) / len(batches)
|
||||
)
|
||||
if batches and failures == len(batches):
|
||||
# Surface the underlying provider error (e.g. an unsupported/decommissioned
|
||||
# model) instead of a generic message, so the UI can tell the user what to fix.
|
||||
detail = f" — {last_error}" if last_error else ""
|
||||
raise RuntimeError(f"The AI model could not generate FAQs from this content{detail}")
|
||||
return accepted
|
||||
|
||||
|
||||
def insert_draft_rows(db: Session, job: FAQGenerationJob, rows: List[FAQ]) -> int:
|
||||
"""Shared GROUPING stage tail: bulk-insert the drafted rows."""
|
||||
FAQGenerationJobRepository(db).update_progress(
|
||||
job.id, stage=FAQJobStage.GROUPING, progress_percentage=92.0
|
||||
)
|
||||
if rows:
|
||||
FAQRepository(db).bulk_create(rows)
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def run_generation_job(db: Session, job: FAQGenerationJob) -> int:
|
||||
"""Execute a GENERATE_ALL / GENERATE_SOURCE job. Returns FAQs created.
|
||||
Status transitions and notifications are the worker's responsibility."""
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
|
||||
# Stage 1: resolve sources + model config.
|
||||
job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0)
|
||||
generator = build_generator(db, job.organization_id, job=job)
|
||||
|
||||
knowledge_query = db.query(Knowledge).filter(Knowledge.organization_id == job.organization_id)
|
||||
if job.job_type == FAQJobType.GENERATE_SOURCE.value:
|
||||
knowledge_query = knowledge_query.filter(Knowledge.id == job.knowledge_id)
|
||||
elif job.knowledge_ids:
|
||||
# Explicit selection overrides the skip-already-generated default.
|
||||
knowledge_query = knowledge_query.filter(Knowledge.id.in_(job.knowledge_ids))
|
||||
sources = knowledge_query.all()
|
||||
if not sources:
|
||||
raise ValueError("No knowledge sources to generate from. Add knowledge first.")
|
||||
|
||||
if job.job_type == FAQJobType.GENERATE_ALL.value and not job.knowledge_ids:
|
||||
# Regenerate touches only sources that haven't produced FAQs yet —
|
||||
# deleting a source's FAQs makes it eligible again.
|
||||
generated = FAQRepository(db).knowledge_ids_with_faqs(job.organization_id)
|
||||
sources = [s for s in sources if s.id not in generated]
|
||||
if not sources:
|
||||
raise ValueError(
|
||||
"All knowledge sources already have FAQs. "
|
||||
"Delete a source's FAQs to regenerate it."
|
||||
)
|
||||
|
||||
# Stage 2: read stored page text per source. A broken source (missing
|
||||
# vector table etc.) is skipped, not fatal — unless nothing is readable.
|
||||
job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=10.0)
|
||||
source_batches: List[Tuple[Knowledge, str]] = []
|
||||
for source in sources:
|
||||
try:
|
||||
# Batch size follows the org's model context window — big-context
|
||||
# models get fewer, larger LLM calls.
|
||||
pages = load_source_pages(db, source, max_chars=generator.batch_chars)
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping unreadable knowledge source {source.id} ({source.source}): {e}")
|
||||
db.rollback()
|
||||
continue
|
||||
for batch in pack_batches(pages, max_chars=generator.batch_chars):
|
||||
source_batches.append((source, batch))
|
||||
if not source_batches:
|
||||
raise ValueError("The selected knowledge sources contain no readable content.")
|
||||
|
||||
# Stage 3: draft per batch (shared loop).
|
||||
categories = CategoryMerger(db, job.organization_id)
|
||||
|
||||
async def extract(batch: Tuple[Knowledge, str], dedup: DedupState) -> List[GeneratedFAQ]:
|
||||
return await generator.generate_from_text(
|
||||
batch[1],
|
||||
existing_questions=dedup.for_prompt,
|
||||
existing_categories=categories.names,
|
||||
)
|
||||
|
||||
accepted = await draft_batches(db, job, source_batches, extract)
|
||||
|
||||
# Stage 4: group + insert drafts.
|
||||
rows = [
|
||||
FAQ(
|
||||
organization_id=job.organization_id,
|
||||
question=faq.question,
|
||||
answer=faq.answer,
|
||||
category=categories.merge(faq.category),
|
||||
status=FAQStatus.DRAFT,
|
||||
knowledge_id=source.id,
|
||||
source_label=source.source,
|
||||
generation_job_id=job.id,
|
||||
created_by=job.user_id,
|
||||
)
|
||||
for (source, _batch_text), faq in accepted
|
||||
]
|
||||
return insert_draft_rows(db, job, rows)
|
||||
|
||||
|
||||
def maybe_enqueue_auto_faq_job(db: Session, queue_item) -> Optional[FAQGenerationJob]:
|
||||
"""Auto-generate draft FAQs when a new knowledge source finishes processing.
|
||||
|
||||
Only fires for orgs that have adopted the feature (a help-center settings
|
||||
row or any FAQ exists), have auto-generate on, are allowed by their plan
|
||||
(cloud), and don't already have an active job for this source. Never raises
|
||||
and always leaves the shared session usable — a hook failure must not fail
|
||||
knowledge processing.
|
||||
"""
|
||||
try:
|
||||
from app.repositories.help_center import HelpCenterRepository
|
||||
from app.services.help_center_access import help_center_allowed
|
||||
|
||||
org_id = queue_item.organization_id
|
||||
settings_row = HelpCenterRepository(db).get_by_org(org_id)
|
||||
faq_repo = FAQRepository(db)
|
||||
job_repo_gate = FAQGenerationJobRepository(db)
|
||||
# Adoption gate: auto-generation only after the org explicitly ran a
|
||||
# generate/import (or added an FAQ). A settings row alone is NOT
|
||||
# opt-in — merely opening the admin page creates one.
|
||||
if not job_repo_gate.has_user_initiated_job(org_id) and not faq_repo.exists_for_org(org_id):
|
||||
return None
|
||||
if settings_row and not settings_row.auto_generate:
|
||||
return None
|
||||
if not help_center_allowed(db, org_id):
|
||||
return None
|
||||
knowledge = (
|
||||
db.query(Knowledge)
|
||||
.filter(Knowledge.organization_id == org_id, Knowledge.source == queue_item.source)
|
||||
.order_by(Knowledge.id.desc()) # deterministic: newest row wins on duplicate sources
|
||||
.first()
|
||||
)
|
||||
if not knowledge:
|
||||
return None
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
if job_repo.get_active_for_org(org_id, FAQJobType.GENERATE_SOURCE, knowledge_id=knowledge.id):
|
||||
return None
|
||||
from app.services.faq_usage import (
|
||||
count_source_pages,
|
||||
ensure_generation_budget,
|
||||
generation_is_metered,
|
||||
PAGES_PER_CALL,
|
||||
)
|
||||
from math import ceil
|
||||
|
||||
try:
|
||||
pages = count_source_pages(db, knowledge)
|
||||
ensure_generation_budget(db, org_id, max(1, ceil((pages or 1) / PAGES_PER_CALL)))
|
||||
except HTTPException:
|
||||
logger.info(f"Auto FAQ generation skipped for org {org_id}: message budget exhausted")
|
||||
return None
|
||||
job = job_repo.create(
|
||||
FAQGenerationJob(
|
||||
organization_id=org_id,
|
||||
user_id=queue_item.user_id,
|
||||
job_type=FAQJobType.GENERATE_SOURCE.value,
|
||||
knowledge_id=knowledge.id,
|
||||
metered=generation_is_metered(db, org_id),
|
||||
)
|
||||
)
|
||||
logger.info(f"Auto-enqueued FAQ generation job {job.id} for knowledge {knowledge.id}")
|
||||
return job
|
||||
except Exception as e:
|
||||
logger.error(f"Auto FAQ enqueue failed (non-fatal): {e}")
|
||||
# A failed flush would otherwise poison the caller's session and turn
|
||||
# an already-successful knowledge run into a FAILED one.
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
FAQ page migration: fetch the customer's existing FAQ/help-center page
|
||||
(SSRF-guarded) or read an uploaded PDF, extract Q&A pairs with the org's model
|
||||
and insert them as drafts. URL mode is single page only — for linked article
|
||||
pages use the article importer (faq_article_import.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.knowledge.url_safety import guard_url, safe_get
|
||||
from app.models.faq import FAQ, FAQStatus
|
||||
from app.models.faq_generation_job import FAQGenerationJob, FAQJobStage
|
||||
from app.repositories.faq_generation_job import FAQGenerationJobRepository
|
||||
from app.services.faq_generation import (
|
||||
CategoryMerger,
|
||||
DedupState,
|
||||
build_generator,
|
||||
draft_batches,
|
||||
insert_draft_rows,
|
||||
pack_batches,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Below this, the static HTML likely lacks the real content (JS-rendered page).
|
||||
MIN_STATIC_TEXT_CHARS = 500
|
||||
# Block-level elements get blank-line separators so a question heading stays
|
||||
# attached to its answer text when batches are split on blocks.
|
||||
_BLOCK_TAGS = (
|
||||
"p", "div", "section", "article", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"dt", "dd", "summary", "details", "tr",
|
||||
)
|
||||
_BOILERPLATE_TAGS = ("script", "style", "noscript", "nav", "footer", "header", "form")
|
||||
|
||||
_FETCH_HEADERS = {"User-Agent": "ReplyPier-FAQ-Import/1.0 (+https://replypier.site)"}
|
||||
|
||||
|
||||
def _soup_to_text(soup: BeautifulSoup) -> str:
|
||||
"""Clean text with one BLANK line between block elements, so downstream
|
||||
blank-line batching keeps each Q&A block intact."""
|
||||
for tag in soup.find_all(_BOILERPLATE_TAGS):
|
||||
tag.decompose()
|
||||
for tag in soup.find_all(_BLOCK_TAGS):
|
||||
tag.append("\n\n")
|
||||
lines = [line.strip() for line in soup.get_text("\n").split("\n")]
|
||||
text = "\n".join(lines)
|
||||
# Collapse 3+ newlines to exactly one blank line.
|
||||
while "\n\n\n" in text:
|
||||
text = text.replace("\n\n\n", "\n\n")
|
||||
return text.strip()
|
||||
|
||||
|
||||
def fetch_page_text(url: str) -> str:
|
||||
"""Fetch the page and reduce it to clean text. Static fetch first (every
|
||||
redirect hop SSRF-checked by safe_get); crawl4ai's headless browser as a
|
||||
fallback for JS-rendered pages. Blocking — callers run it in a thread."""
|
||||
text = ""
|
||||
final_url = url
|
||||
try:
|
||||
with httpx.Client(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT, headers=_FETCH_HEADERS) as client:
|
||||
response = safe_get(client, url)
|
||||
response.raise_for_status()
|
||||
# The hop-validated final URL — the browser fallback fetches this,
|
||||
# not the original, so a redirect chain can't smuggle it inward.
|
||||
final_url = str(response.url)
|
||||
text = _soup_to_text(BeautifulSoup(response.text, "html.parser"))
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"FAQ import static fetch failed for {url}: {e}")
|
||||
|
||||
if len(text) < MIN_STATIC_TEXT_CHARS:
|
||||
rendered = _fetch_rendered_text(final_url)
|
||||
if rendered and len(rendered) > len(text):
|
||||
text = rendered
|
||||
|
||||
if not text.strip():
|
||||
raise ValueError("Could not read any text from that page.")
|
||||
return text[: settings.FAQ_IMPORT_MAX_PAGE_CHARS]
|
||||
|
||||
|
||||
def _fetch_rendered_text(url: str) -> Optional[str]:
|
||||
"""Headless-browser fallback for JS-rendered pages. The URL is re-checked
|
||||
against the SSRF guard, but a browser follows redirects/JS navigation
|
||||
unguarded — same residual exposure as the knowledge crawler's fallback."""
|
||||
try:
|
||||
guard_url(url)
|
||||
from app.knowledge.crawl4ai_fallback import get_crawl4ai_fallback
|
||||
|
||||
fallback = get_crawl4ai_fallback(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT)
|
||||
if not fallback.is_available:
|
||||
return None
|
||||
_, soup, _ = fallback.fetch_with_browser(url)
|
||||
return _soup_to_text(soup) if soup else None
|
||||
except Exception as e:
|
||||
logger.warning(f"FAQ import browser fetch failed for {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _split_blocks(text: str) -> List[str]:
|
||||
"""Blank-line-separated blocks (see _soup_to_text) — the batching unit."""
|
||||
return [block for block in text.split("\n\n") if block.strip()]
|
||||
|
||||
|
||||
async def _extract_and_insert(
|
||||
db: Session, job: FAQGenerationJob, generator, text: str, source_label: str,
|
||||
mode: str = "extract",
|
||||
) -> int:
|
||||
"""Shared LLM tail for URL/PDF imports: batch → extract → dedup → insert.
|
||||
|
||||
mode="extract" migrates a page that already contains Q&A pairs (verbatim).
|
||||
mode="generate" turns free-form documentation (a product guide, manual…)
|
||||
into FAQs — an existing-FAQ extraction prompt would find almost nothing in
|
||||
prose that isn't already a Q&A list."""
|
||||
batches = pack_batches(_split_blocks(text), max_chars=generator.batch_chars, sep="\n\n")
|
||||
categories = CategoryMerger(db, job.organization_id)
|
||||
|
||||
if mode == "generate":
|
||||
async def extract(batch: str, dedup: DedupState):
|
||||
return await generator.generate_from_text(
|
||||
batch, existing_questions=dedup.for_prompt, existing_categories=categories.names
|
||||
)
|
||||
else:
|
||||
async def extract(batch: str, dedup: DedupState):
|
||||
faqs = await generator.extract_from_faq_page(batch, existing_questions=dedup.for_prompt)
|
||||
if not faqs:
|
||||
# The page had no verbatim Q&A — an index/category listing or free-form
|
||||
# prose. Rather than import nothing, draft FAQs from the same content.
|
||||
faqs = await generator.generate_from_text(
|
||||
batch, existing_questions=dedup.for_prompt, existing_categories=categories.names
|
||||
)
|
||||
return faqs
|
||||
|
||||
accepted = await draft_batches(db, job, batches, extract, retries_per_batch=0)
|
||||
|
||||
rows = [
|
||||
FAQ(
|
||||
organization_id=job.organization_id,
|
||||
question=faq.question,
|
||||
answer=faq.answer,
|
||||
category=categories.merge(faq.category),
|
||||
status=FAQStatus.DRAFT,
|
||||
knowledge_id=None,
|
||||
source_label=source_label,
|
||||
generation_job_id=job.id,
|
||||
created_by=job.user_id,
|
||||
)
|
||||
for _batch, faq in accepted
|
||||
]
|
||||
return insert_draft_rows(db, job, rows)
|
||||
|
||||
|
||||
async def run_import_job(db: Session, job: FAQGenerationJob) -> int:
|
||||
"""Execute an IMPORT_URL job. Returns FAQs created. Status transitions and
|
||||
notifications are the worker's responsibility."""
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
# ANALYZING = fail fast on a missing model config, before any external I/O.
|
||||
job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0)
|
||||
generator = build_generator(db, job.organization_id, job=job)
|
||||
|
||||
job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=15.0)
|
||||
# Re-validated at fetch time (not only at the API layer) so a job forged or
|
||||
# replayed against the queue can't reach internal hosts; run in a thread so
|
||||
# the slow fetch doesn't block the worker's event loop.
|
||||
page_text = await asyncio.to_thread(fetch_page_text, job.source_url)
|
||||
return await _extract_and_insert(
|
||||
db, job, generator, page_text,
|
||||
source_label=f"Imported from {urlparse(job.source_url).netloc}",
|
||||
)
|
||||
|
||||
|
||||
def _pdf_to_text(content: bytes) -> str:
|
||||
"""Page-by-page text extraction with pypdf directly — agno's PDFReader
|
||||
chunks/summarizes for the vector KB, which we don't want here. Blank-line
|
||||
separators between pages keep block batching intact."""
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(BytesIO(content))
|
||||
pages = []
|
||||
for page in reader.pages:
|
||||
try:
|
||||
text = page.extract_text() or ""
|
||||
except Exception as e: # a single corrupt page shouldn't sink the file
|
||||
logger.warning(f"PDF page extraction failed: {e}")
|
||||
continue
|
||||
if text.strip():
|
||||
pages.append(text.strip())
|
||||
return "\n\n".join(pages)[: settings.FAQ_IMPORT_MAX_PAGE_CHARS]
|
||||
|
||||
|
||||
async def run_pdf_import_job(db: Session, job: FAQGenerationJob) -> int:
|
||||
"""Execute an IMPORT_PDF job: read the stored upload (job.source_url),
|
||||
extract text and run the shared Q&A extraction. The stored file is
|
||||
best-effort deleted afterwards — it's an import buffer, not a document
|
||||
library."""
|
||||
from app.services.file_storage import delete_upload, load_upload
|
||||
|
||||
job_repo = FAQGenerationJobRepository(db)
|
||||
# Captured before the try: after a DB-originated failure the ORM instance
|
||||
# is expired and reading job attributes from the finally would raise,
|
||||
# masking the real error and leaking the buffer.
|
||||
stored = job.source_url
|
||||
source_label = f"Imported from {job.source_file_name or 'PDF'}"
|
||||
# ANALYZING = fail fast on a missing model config, before file I/O.
|
||||
job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0)
|
||||
|
||||
try:
|
||||
generator = build_generator(db, job.organization_id, job=job)
|
||||
content = await load_upload(stored)
|
||||
job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=15.0)
|
||||
text = await asyncio.to_thread(_pdf_to_text, content)
|
||||
if not text.strip():
|
||||
raise ValueError(
|
||||
"Could not read any text from that PDF (it may be scanned images)."
|
||||
)
|
||||
# A PDF is usually documentation/marketing prose, not an existing Q&A
|
||||
# list — generate FAQs from it rather than extracting verbatim pairs.
|
||||
return await _extract_and_insert(
|
||||
db, job, generator, text, source_label=source_label, mode="generate"
|
||||
)
|
||||
finally:
|
||||
await delete_upload(stored)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
FAQ generation usage/credits: estimate how many LLM calls a run will make,
|
||||
decide whether those calls are metered (hosted model), and enforce the org's
|
||||
message budget before enqueueing. OSS/self-hosted (no enterprise module) is
|
||||
never metered and never blocked.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.knowledge.page_editor import PAGE_ID_EXPR
|
||||
from app.models.knowledge import Knowledge
|
||||
from app.repositories.faq import FAQRepository
|
||||
# Shared hosted-model metering helpers; re-exported here so existing FAQ
|
||||
# callers keep importing them from faq_usage.
|
||||
from app.services.usage_metering import ( # noqa: F401
|
||||
HOSTED_MODEL_TYPE,
|
||||
is_hosted_model,
|
||||
remaining_message_credits,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Rough pages-per-LLM-call heuristic for the confirm-dialog estimate (matches
|
||||
# today's floor batch of ~3 average pages). An estimate, not billing.
|
||||
PAGES_PER_CALL = 3
|
||||
|
||||
OVER_BUDGET_MESSAGE = (
|
||||
"Not enough message credits left in your plan for this generation "
|
||||
"(~{estimated} needed, {remaining} remaining). Upgrade your plan or "
|
||||
"switch to your own AI model key."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceEstimate:
|
||||
id: int
|
||||
name: str
|
||||
source_type: str
|
||||
has_faqs: bool
|
||||
pages: int
|
||||
estimated_calls: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationEstimate:
|
||||
total_sources: int
|
||||
new_sources: int
|
||||
pages: int
|
||||
estimated_calls: int
|
||||
sources: List[SourceEstimate]
|
||||
|
||||
|
||||
def generation_is_metered(db: Session, organization_id: UUID) -> bool:
|
||||
"""Generation costs credits only on the hosted model — own-key orgs pay
|
||||
their provider directly. FAQ_METER_OWN_KEY flips to always-meter."""
|
||||
return settings.FAQ_METER_OWN_KEY or is_hosted_model(db, organization_id)
|
||||
|
||||
|
||||
def count_source_pages(db: Session, knowledge: Knowledge) -> Optional[int]:
|
||||
"""Distinct stored pages for a source, capped like generation itself.
|
||||
None when the vector table is missing/unreadable."""
|
||||
if not knowledge.schema or not knowledge.table_name:
|
||||
return None
|
||||
query = text(
|
||||
f"SELECT COUNT(*) FROM (SELECT {PAGE_ID_EXPR} "
|
||||
f'FROM {knowledge.schema}."{knowledge.table_name}" '
|
||||
"WHERE name = :source GROUP BY 1 LIMIT :max_pages) pages"
|
||||
)
|
||||
try:
|
||||
count = db.execute(
|
||||
query, {"source": knowledge.source, "max_pages": settings.FAQ_MAX_PAGES_PER_SOURCE}
|
||||
).scalar()
|
||||
return int(count or 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"Page count failed for knowledge {knowledge.id}: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
|
||||
|
||||
def estimate_generation_calls(
|
||||
db: Session,
|
||||
organization_id: UUID,
|
||||
knowledge_ids: Optional[List[int]] = None,
|
||||
count_pages: bool = True,
|
||||
) -> GenerationEstimate:
|
||||
"""Confirm-dialog numbers for a GENERATE_ALL run: which sources it would
|
||||
read (new-only unless explicitly targeted) and roughly how many LLM calls
|
||||
that costs. Unreadable sources count as one call each.
|
||||
|
||||
Always returns the FULL per-source list (for the generation source picker),
|
||||
each with its own page/call estimate and whether it already has FAQs. The
|
||||
aggregate counts (new_sources/pages/estimated_calls) cover the run's TARGET
|
||||
sources: the explicit `knowledge_ids` selection, or — when none is given —
|
||||
every source that doesn't yet have FAQs.
|
||||
|
||||
count_pages=False skips the per-source page COUNT over the vector tables
|
||||
(cheap — for surfaces that only need counts, like the Generate button
|
||||
label); per-source estimated_calls is then a 1-per-source lower bound."""
|
||||
all_sources = (
|
||||
db.query(Knowledge).filter(Knowledge.organization_id == organization_id).all()
|
||||
)
|
||||
generated = FAQRepository(db).knowledge_ids_with_faqs(organization_id)
|
||||
existing_ids = {s.id for s in all_sources}
|
||||
target_ids = (
|
||||
(set(knowledge_ids) & existing_ids)
|
||||
if knowledge_ids
|
||||
else {s.id for s in all_sources if s.id not in generated}
|
||||
)
|
||||
|
||||
source_list: List[SourceEstimate] = []
|
||||
agg_pages = 0
|
||||
agg_calls = 0
|
||||
for source in all_sources:
|
||||
source_pages = count_source_pages(db, source) if count_pages else None
|
||||
pages = source_pages or 0
|
||||
if source_pages is None:
|
||||
# Unreadable now, or page-count skipped (count_pages=False) — assume
|
||||
# one call so the estimate isn't zero.
|
||||
calls = 1
|
||||
else:
|
||||
# A readable but empty source produces nothing → costs no call.
|
||||
calls = max(1, math.ceil(pages / PAGES_PER_CALL)) if pages else 0
|
||||
source_list.append(
|
||||
SourceEstimate(
|
||||
id=source.id,
|
||||
name=source.source,
|
||||
source_type=source.source_type.value if hasattr(source.source_type, "value") else str(source.source_type),
|
||||
has_faqs=source.id in generated,
|
||||
pages=pages,
|
||||
estimated_calls=calls,
|
||||
)
|
||||
)
|
||||
if source.id in target_ids:
|
||||
agg_pages += pages
|
||||
agg_calls += calls
|
||||
|
||||
return GenerationEstimate(
|
||||
total_sources=len(all_sources),
|
||||
new_sources=len(target_ids),
|
||||
pages=agg_pages,
|
||||
estimated_calls=agg_calls,
|
||||
sources=source_list,
|
||||
)
|
||||
|
||||
|
||||
def ensure_generation_budget(db: Session, organization_id: UUID, estimated_calls: int) -> None:
|
||||
"""402 when a metered run would exceed the org's remaining credits."""
|
||||
if estimated_calls <= 0 or not generation_is_metered(db, organization_id):
|
||||
return
|
||||
remaining = remaining_message_credits(db, organization_id)
|
||||
if remaining is None:
|
||||
return
|
||||
if estimated_calls > remaining:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail=OVER_BUDGET_MESSAGE.format(estimated=estimated_calls, remaining=remaining),
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
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 firebase_admin import credentials, initialize_app, get_app
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
import os
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def initialize_firebase():
|
||||
"""Initialize Firebase Admin SDK"""
|
||||
try:
|
||||
# Check if already initialized
|
||||
try:
|
||||
get_app()
|
||||
logger.info("Firebase already initialized")
|
||||
return
|
||||
except ValueError:
|
||||
logger.info("Initializing Firebase...")
|
||||
|
||||
# Get credentials file path
|
||||
cred_path = settings.FIREBASE_CREDENTIALS
|
||||
|
||||
if not cred_path or not os.path.isfile(cred_path):
|
||||
logger.warning(f"Firebase credentials not found at {cred_path}. Running in development mode.")
|
||||
# Use a default configuration for development
|
||||
cred = credentials.Certificate({
|
||||
"type": "service_account",
|
||||
"project_id": "demo-project",
|
||||
"private_key_id": "demo123456789",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDK/eDH0MDgBNHs\nYuXwHMnMYGz+86YJjYoXVbKdUGHQZXYVEVlc8rYA+VXbU1AqhD+c7yT9V9EaEjPO\nXGVWS6dWQh4Kh5g7y1O5SHIwQhDHK+AAqMgBfZhzEHJBtO8CXG8TDyxhfHXxjWpD\nGPz/RJ7GhW9cHYQOvP4yZAp3ZNGYQJwUlhNLGr80XEhGVlkZ5WPL0YFzwOxvqGQs\nQVVxGGj5h7YHJt1YmZwF6cBGGX8AUetJkMTgQEJSNsaK4Pl+MZUGQOhQEynK3qO2\nWf+7XZl4XPx9Qw+WPYEoHHlGgGQR4YUpL+qzVxYwNAXhVxd9k+YPp5JZHtGQAXeO\nZhxDo1QLAgMBAAECggEABBj/H/JGXuVGoCjR6J0XF+kQTOuTgZBvgapkpPTYwfXQ\nPRKzGWEpGUPVnVb3gMDqbqQe7EVJZ8Nj/zHmXf9c4qKxpXwNl7kYmPpuQOHmA8Nq\nXPE3QZH3YSRz1YXF/pA9RQqG5F4JBJjz5LcUR+ax9aSxPu26xwDHOpR4EIR9Aovx\nJfcg7MTAY4YuGBe0oLQbEWoUQqXgUIVEzWcqiDGXF0uuMZzpW6YVk5ZvWHXz7SVK\nE7e1XQN6sMTYqrmnFGkBhuvXiBkPZHsOEHBVxXVqytE8FJAqXwPwFqxWj4RhNS/w\nkMUv5ZyH6UOFuBXtSe1XGGEqktNVGPBhAkEo4BbhAQKBgQD1+fdC9zzDwA4KoNnk\nVxQbCpC7Zr5F8Mycer0SV/3Gg6okzo9vXl+vqVHXqYNGnAEFkLLhEzwpZB0cnqWk\nQswrGvZ5FvUSKd7rMfI0Uh5FxhYCuUwwZW6gVKEZDCXF6N7KBbMPaYVZuGwD7oQk\nUQKBgQDTKj+RKqFrwhZM4cVJVgqHgQmMvHXC7Hy3Sj8Yl9ZHxgsnrkoNXXXRDNwp\nEWVoTy4Xr1BXuBxFEgEBpuPGBlHVXLZRzaP1pCZVqftxiHDqGlAh4y3yZtKHSTGj\n1B6OZlYXxvD+9K5Ki1ZwM3JxpY4YE5zASL7cUrV+gUUE2VoengKBgQDxIrXXFR8l\nAgMBAAECggEABBj/H/JGXuVGoCjR6J0XF+kQTOuTgZBvgapkpPTYwfXQ\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "firebase-adminsdk-demo@demo-project.iam.gserviceaccount.com",
|
||||
"client_id": "123456789",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-demo%40demo-project.iam.gserviceaccount.com",
|
||||
"universe_domain": "googleapis.com"
|
||||
})
|
||||
else:
|
||||
logger.info("Loading Firebase credentials from file " + cred_path)
|
||||
cred = credentials.Certificate(cred_path)
|
||||
|
||||
initialize_app(cred)
|
||||
logger.info("Firebase initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing Firebase: {str(e)}")
|
||||
logger.warning("Continuing without Firebase initialization")
|
||||
@@ -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.
|
||||
|
||||
Help-center plan gating: Pro-only on cloud, unrestricted for OSS/self-hosted.
|
||||
Thin wrappers over the generic feature gate.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.feature_gate import check_feature_access, feature_allowed
|
||||
|
||||
HELP_CENTER_FEATURE = "help_center"
|
||||
|
||||
UPGRADE_MESSAGE = (
|
||||
"The Help Center is not available in your current plan. "
|
||||
"Please upgrade to Pro to access this feature."
|
||||
)
|
||||
|
||||
|
||||
def help_center_allowed(db: Session, organization_id: UUID) -> bool:
|
||||
"""Non-raising check — used by the public site (which 404s instead of
|
||||
403ing) and the auto-generation hook. Fails closed on gating errors."""
|
||||
return feature_allowed(db, organization_id, HELP_CENTER_FEATURE)
|
||||
|
||||
|
||||
def check_help_center_access(db: Session, organization_id: UUID) -> None:
|
||||
"""Raising check for admin endpoints (403 with the upgrade message)."""
|
||||
check_feature_access(db, organization_id, HELP_CENTER_FEATURE, UPGRADE_MESSAGE)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
FAQ answers are authored and stored as Markdown so a single Text column can hold
|
||||
rich help-center articles (headings, images, links, ordered steps, callouts).
|
||||
This module turns that Markdown into HTML for the public page, and into plain
|
||||
text for previews, meta descriptions and FAQPage structured data.
|
||||
|
||||
Security: the rendered HTML is dropped onto the *public* help center, so an org
|
||||
admin's Markdown is untrusted input for visitors. Everything is run through an
|
||||
nh3 (ammonia) allowlist clean — the template must mark this output `| safe`, so
|
||||
this is the ONLY place org content is ever trusted as HTML.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Mapping, Optional
|
||||
|
||||
import markdown as _markdown
|
||||
import nh3
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# `extra` enables tables/fenced code/etc; `sane_lists` fixes mixed list quirks;
|
||||
# `nl2br` treats single newlines as <br> so plain-text FAQ answers (the common
|
||||
# case) keep their line breaks without the author needing blank lines.
|
||||
_MD_EXTENSIONS = ["extra", "sane_lists", "nl2br"]
|
||||
|
||||
# Allowlist of tags a help-center article may contain. Anything else (script,
|
||||
# style, iframe, form, event handlers, …) is stripped by nh3.
|
||||
_ALLOWED_TAGS = {
|
||||
"p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"strong", "b", "em", "i", "u", "s", "del", "code", "pre", "blockquote",
|
||||
"ul", "ol", "li", "a", "img",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"figure", "figcaption", "span",
|
||||
}
|
||||
_ALLOWED_ATTRS = {
|
||||
"a": {"href", "title"},
|
||||
"img": {"src", "alt", "title"},
|
||||
}
|
||||
# Block javascript:/data: URLs; images and links may only point at real hosts.
|
||||
_URL_SCHEMES = {"http", "https", "mailto"}
|
||||
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
|
||||
# Intra-help-center links are stored ROOT-RELATIVE (/a/{slug}, or / for the home)
|
||||
# so they survive a move between path mode and a subdomain/custom domain. The
|
||||
# serving prefix is added here at render time. `/a/` (with the trailing slash)
|
||||
# never matches an image path like /api/v1/uploads/...
|
||||
_INTERNAL_HREF_RE = re.compile(r'(href=")(/a/[^"]*|/)(")')
|
||||
|
||||
|
||||
def render_article_html(
|
||||
text: str, base_path: str = "", link_paths: Optional[Mapping[str, str]] = None
|
||||
) -> str:
|
||||
"""Markdown -> sanitized HTML for the public article body. Safe to render
|
||||
with Jinja's `| safe` because nh3 strips every tag/attr/scheme off-allowlist
|
||||
and rewrites link rel to neutralise tab-nabbing.
|
||||
|
||||
`base_path` is the serving prefix ("" at a domain root, "/help/{slug}" in path
|
||||
mode) prepended to root-relative intra-help-center links so cross-article links
|
||||
resolve under whichever mode/domain serves the page.
|
||||
|
||||
`link_paths` maps slug -> the path that article is actually served at, for
|
||||
the articles whose original URL was preserved on a help-center migration.
|
||||
Cross-article links stay STORED as /a/{slug} (stable when a path is later
|
||||
edited, and the only form the regex above can prefix) and are resolved here,
|
||||
so a link lands directly on the preserved URL instead of via its 301."""
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
raw = _markdown.markdown(text, extensions=_MD_EXTENSIONS, output_format="html")
|
||||
html = nh3.clean(
|
||||
raw,
|
||||
tags=_ALLOWED_TAGS,
|
||||
attributes=_ALLOWED_ATTRS,
|
||||
url_schemes=_URL_SCHEMES,
|
||||
link_rel="noopener noreferrer nofollow",
|
||||
)
|
||||
if base_path or link_paths:
|
||||
html = _INTERNAL_HREF_RE.sub(
|
||||
lambda m: f"{m.group(1)}{base_path}{_resolve_internal_href(m.group(2), link_paths)}{m.group(3)}",
|
||||
html,
|
||||
)
|
||||
return html
|
||||
|
||||
|
||||
def _resolve_internal_href(href: str, link_paths: Optional[Mapping[str, str]]) -> str:
|
||||
"""A stored /a/{slug} link swapped for that article's preserved path, if it
|
||||
has one. Unknown slugs (and the "/" home link) pass straight through."""
|
||||
if not link_paths or not href.startswith("/a/"):
|
||||
return href
|
||||
return link_paths.get(href[len("/a/"):], href)
|
||||
|
||||
|
||||
def to_plain_text(text: str) -> str:
|
||||
"""Strip Markdown/HTML to a single line of readable text (previews, meta
|
||||
descriptions, JSON-LD answer text)."""
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
html = _markdown.markdown(text, extensions=_MD_EXTENSIONS, output_format="html")
|
||||
plain = BeautifulSoup(html, "html.parser").get_text(" ")
|
||||
return _WHITESPACE_RE.sub(" ", plain).strip()
|
||||
|
||||
|
||||
def excerpt(text: str, length: int = 150) -> str:
|
||||
"""A short one-line preview of an answer, cut on a word boundary."""
|
||||
plain = to_plain_text(text)
|
||||
if len(plain) <= length:
|
||||
return plain
|
||||
return plain[:length].rsplit(" ", 1)[0].rstrip() + "…"
|
||||
|
||||
|
||||
def read_time_minutes(text: str) -> int:
|
||||
"""Estimated read time in whole minutes (>= 1), ~200 words/min."""
|
||||
words = len(to_plain_text(text).split())
|
||||
return max(1, round(words / 200))
|
||||
|
||||
|
||||
def read_time_label(text: str) -> str:
|
||||
return f"{read_time_minutes(text)} min read"
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
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 article images. Images embedded in an article's Markdown must
|
||||
resolve to a STABLE URL that is baked inline in the answer forever — so no
|
||||
signed/expiring URLs, and no raw S3 URLs either now the bucket is private.
|
||||
Both storage backends therefore return the same host-RELATIVE path, which
|
||||
app/api/help_center_images.py resolves per request (redirect to a freshly
|
||||
signed URL on S3, direct file on local). Host-relative so it resolves against
|
||||
whichever origin serves the page: the main app in path mode, public_app on a
|
||||
help-center subdomain / custom domain. Shared by the admin upload endpoint and
|
||||
the article importer's re-hosting path.
|
||||
"""
|
||||
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.file_storage import store_upload
|
||||
|
||||
def strip_upload_host(text: str) -> str:
|
||||
"""Convert the app's OWN absolute upload URLs to relative /api/v1/uploads/...
|
||||
paths, leaving everything else untouched. Only strips the configured
|
||||
BACKEND_URL origin or a loopback host (localhost/127.0.0.1/0.0.0.0) — a
|
||||
third-party URL that merely contains /api/v1/uploads/ (e.g. a link pasted
|
||||
into an FAQ answer) is NOT rewritten. S3 and already-relative URLs never
|
||||
match. Used by the one-time backfill migration."""
|
||||
if not text:
|
||||
return text
|
||||
backend = settings.BACKEND_URL.rstrip("/")
|
||||
# Match our own backend origin OR any loopback host (with optional port),
|
||||
# only when it sits directly in front of an /api/v1/uploads/ path.
|
||||
pattern = re.compile(
|
||||
r'(?:' + re.escape(backend)
|
||||
+ r'|https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?)'
|
||||
+ r'(?=/api/v1/uploads/)'
|
||||
)
|
||||
return pattern.sub("", text)
|
||||
|
||||
MAX_FAQ_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
# Reject larger-than-this on a side up front (bomb guard); downscale the stored
|
||||
# image to fit FAQ_IMAGE_FIT_DIM so article pages stay light.
|
||||
FAQ_IMAGE_MAX_DIM = 6000
|
||||
FAQ_IMAGE_FIT_DIM = 1600
|
||||
FAQ_IMAGE_TYPES = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
UPLOAD_FOLDER = "help_center"
|
||||
|
||||
# store_article_image names files "<uuid4><ext>". The delivery route matches
|
||||
# against this before interpolating the name into an S3 key or a filesystem
|
||||
# path, so it is what stops a traversal ("../agents/x.png") being served.
|
||||
_UUID4_PATTERN = (
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
||||
)
|
||||
_EXTENSION_PATTERN = "|".join(
|
||||
re.escape(ext.lstrip(".")) for ext in sorted(set(FAQ_IMAGE_TYPES.values()))
|
||||
)
|
||||
ARTICLE_IMAGE_NAME_RE = re.compile(rf"^{_UUID4_PATTERN}\.(?:{_EXTENSION_PATTERN})$")
|
||||
|
||||
# Absolute S3 URLs baked into article Markdown before delivery moved behind
|
||||
# article_image_path(). Both URL shapes (virtual-hosted and path-style) end in
|
||||
# ".../help_center/<name>", so anchor on that.
|
||||
_BAKED_S3_IMAGE_RE = re.compile(
|
||||
r"https?://[^\s\"'()]*amazonaws\.com/[^\s\"'()]*"
|
||||
+ re.escape(UPLOAD_FOLDER)
|
||||
+ rf"/({_UUID4_PATTERN}\.(?:{_EXTENSION_PATTERN}))"
|
||||
)
|
||||
|
||||
|
||||
def article_image_path(file_name: str) -> str:
|
||||
"""The stable, host-relative URL baked into article Markdown.
|
||||
|
||||
Identical for local and S3 storage — app/api/help_center_images.py resolves
|
||||
it per request. Host-relative so it works both in path mode (main app) and
|
||||
on a help-center subdomain / custom domain (public_app)."""
|
||||
return f"{settings.API_V1_STR}/help-center/images/{file_name}"
|
||||
|
||||
|
||||
def absolute_upload_url(stored: str) -> str:
|
||||
"""Make a stored upload path absolute. S3 returns an absolute http(s) URL
|
||||
already; local mode returns a path under the /api/v1/uploads mount, which we
|
||||
anchor to the backend's public origin so it loads cross-domain."""
|
||||
if stored.startswith("http"):
|
||||
return stored
|
||||
return f"{settings.BACKEND_URL.rstrip('/')}{stored}"
|
||||
|
||||
|
||||
def rewrite_baked_s3_image_urls(text: str) -> str:
|
||||
"""Point previously-baked absolute S3 article-image URLs at article_image_path.
|
||||
|
||||
Those URLs 403 now the bucket is private, and would have needed re-signing
|
||||
anyway. Used by the one-time backfill migration."""
|
||||
if not text:
|
||||
return text
|
||||
return _BAKED_S3_IMAGE_RE.sub(lambda m: article_image_path(m.group(1)), text)
|
||||
|
||||
|
||||
async def store_article_image(content: bytes, content_type: str) -> str:
|
||||
"""Persist validated image bytes and return the stable URL to bake into the
|
||||
answer markdown. Caller is responsible for size/content-type validation
|
||||
against MAX_FAQ_IMAGE_BYTES / FAQ_IMAGE_TYPES.
|
||||
|
||||
The returned path is storage-agnostic and never expires. It must not be a
|
||||
signed S3 URL (those expire) nor a raw S3 URL (the bucket is private, so
|
||||
those 403) — the delivery route resolves it per request instead."""
|
||||
file_name = f"{uuid4()}{FAQ_IMAGE_TYPES[content_type]}"
|
||||
await store_upload(content, folder=UPLOAD_FOLDER, file_name=file_name, content_type=content_type)
|
||||
return article_image_path(file_name)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
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 help-center domain logic: resolve the Host header to an org's
|
||||
published help center, group its FAQs, and answer "Ask AI" questions grounded
|
||||
in the mapped agent's knowledge. No admin auth here — everything must stay
|
||||
read-only against published data.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.agents.guardrail_policy import (
|
||||
GuardrailContext,
|
||||
INJECTION_CLAUSE,
|
||||
visitor_data_block,
|
||||
)
|
||||
from app.core.help_center_host import normalize_host, slug_for_host # noqa: F401 — canonical host helpers re-exported for the public app
|
||||
from app.core.logger import get_logger
|
||||
from app.utils.guardrail_runtime import Surface, check_inbound
|
||||
from app.database import SessionLocal
|
||||
from app.models.faq import FAQ
|
||||
from app.models.help_center import HelpCenterSettings
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.repositories.help_center import HelpCenterQueryRepository, HelpCenterRepository
|
||||
from app.services.help_center_access import help_center_allowed
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
MAX_QUESTION_CHARS = 500
|
||||
ANSWER_MAX_TOKENS = 600
|
||||
# Unauthenticated LLM calls are expensive: bound how many run at once per
|
||||
# worker so /ask can't drain the DB pool or the provider budget.
|
||||
_ask_concurrency = asyncio.Semaphore(4)
|
||||
|
||||
_ASK_INSTRUCTIONS = """You are the support assistant on a company's public help center. A visitor has asked a question.
|
||||
|
||||
You have tools to look up the answer:
|
||||
- search_faqs: search the published help-center FAQs.
|
||||
- search_knowledge_base: search the company's wider knowledge base (only available when the help center is mapped to an agent).
|
||||
|
||||
ALWAYS use the tools before answering — start with search_faqs, and also try
|
||||
search_knowledge_base for anything the FAQs don't cover. You may call a tool more
|
||||
than once with different wording if the first result isn't relevant.
|
||||
|
||||
Answer ONLY from what the tools return. When they surface a relevant answer, reply
|
||||
in 1-4 short sentences of plain text (no markdown), in a friendly second-person
|
||||
voice. If, after genuinely searching, you find nothing relevant, briefly say you
|
||||
couldn't find that in the help center and suggest starting a chat or contacting
|
||||
support. Never invent facts, prices, policies, or URLs.
|
||||
|
||||
""" + INJECTION_CLAUSE
|
||||
|
||||
|
||||
def resolve_help_center(
|
||||
db: Session, host: Optional[str] = None, slug: Optional[str] = None
|
||||
) -> Optional[HelpCenterSettings]:
|
||||
"""Enabled help center row, or None (renders as 404). Resolves by explicit
|
||||
`slug` (path dispatch) when given, else by Host header (subdomain slug or a
|
||||
verified custom domain). Custom domains only match once verified; plan
|
||||
lapses hide the site."""
|
||||
repo = HelpCenterRepository(db)
|
||||
if slug is not None:
|
||||
row = repo.get_by_slug(slug)
|
||||
else:
|
||||
host_slug = slug_for_host(host or "")
|
||||
if host_slug:
|
||||
row = repo.get_by_slug(host_slug)
|
||||
else:
|
||||
row = repo.get_by_verified_domain(host or "")
|
||||
if not row or not row.enabled:
|
||||
return None
|
||||
if not help_center_allowed(db, row.organization_id):
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
# Topic accent colors, applied by category display order (matches the index and
|
||||
# article templates). Kept here so list cards, sidebar topics, the article tag
|
||||
# and related cards all resolve the SAME color for a given category.
|
||||
CATEGORY_PALETTE = [
|
||||
"#6d5bd0", "#0e8c8c", "#cf5b38", "#2a6fdb", "#1f8a5b", "#b0468a", "#c98a1e", "#3a6f8f",
|
||||
]
|
||||
|
||||
|
||||
def published_faq_groups(
|
||||
db: Session, row: HelpCenterSettings, search: Optional[str] = None
|
||||
) -> List[Tuple[str, List[FAQ]]]:
|
||||
"""Published FAQs grouped by category, in display order."""
|
||||
faqs = FAQRepository(db).get_published_for_org(row.organization_id, search=search)
|
||||
groups: dict = {}
|
||||
for faq in faqs:
|
||||
groups.setdefault(faq.category, []).append(faq)
|
||||
return list(groups.items())
|
||||
|
||||
|
||||
def category_colors(categories) -> dict:
|
||||
"""category name -> accent hex, by display order."""
|
||||
return {c: CATEGORY_PALETTE[i % len(CATEGORY_PALETTE)] for i, c in enumerate(categories)}
|
||||
|
||||
|
||||
def get_published_article(db: Session, row: HelpCenterSettings, slug: str) -> Optional[FAQ]:
|
||||
"""A single published FAQ by slug for the /a/{slug} article page."""
|
||||
return FAQRepository(db).get_published_by_slug(row.organization_id, slug)
|
||||
|
||||
|
||||
def get_published_article_by_path(
|
||||
db: Session, row: HelpCenterSettings, url_path: str
|
||||
) -> Optional[FAQ]:
|
||||
"""A single published FAQ by its preserved original path — the lookup for a
|
||||
URL the org carried over from the help center it migrated from."""
|
||||
return FAQRepository(db).get_published_by_url_path(row.organization_id, url_path)
|
||||
|
||||
|
||||
def related_articles(db: Session, row: HelpCenterSettings, faq: FAQ, limit: int = 4) -> List[FAQ]:
|
||||
"""Other published FAQs in the same category — the article's related list."""
|
||||
return FAQRepository(db).get_published_related(
|
||||
row.organization_id, faq.category, faq.id, limit=limit
|
||||
)
|
||||
|
||||
|
||||
def contrast_ink(hex_color: str) -> str:
|
||||
"""Dark or light text over the brand color (WCAG relative luminance)."""
|
||||
try:
|
||||
value = hex_color.lstrip("#")
|
||||
if len(value) == 3:
|
||||
value = "".join(ch * 2 for ch in value)
|
||||
r, g, b = (int(value[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
||||
|
||||
def linear(c: float) -> float:
|
||||
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||
|
||||
luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
|
||||
return "#12131A" if luminance > 0.45 else "#FFFFFF"
|
||||
except (ValueError, IndexError):
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
def widget_id_for(row: HelpCenterSettings) -> Optional[str]:
|
||||
"""The chat widget embedded on the page: the mapped agent's widget. Gated by
|
||||
its own toggle, independent of the AI quick-summary in search."""
|
||||
if not (row.chat_widget_enabled and row.agent and row.agent.widgets):
|
||||
return None
|
||||
return row.agent.widgets[0].id
|
||||
|
||||
|
||||
def ask_available(row: HelpCenterSettings) -> bool:
|
||||
return bool(row.ai_search_enabled and row.agent_id)
|
||||
|
||||
|
||||
async def answer_question(organization_id, agent_id, question: str) -> Optional[str]:
|
||||
"""Grounded answer for a public help-center question. The agent is given
|
||||
FAQ + knowledge search tools and decides what to look up, so it can find and
|
||||
combine answers instead of relying on a single pre-stuffed context. Returns
|
||||
None when no confident answer could be produced. Writes the ask-log row
|
||||
(metering/analytics); never touches chat history or the KB.
|
||||
|
||||
The whole tool-using run happens in a worker thread: the tools do synchronous
|
||||
DB work, and holding a pooled connection through a multi-second model call
|
||||
would let this unauthenticated endpoint drain the pool for the backend.
|
||||
"""
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.repositories.ai_config import AIConfigRepository
|
||||
|
||||
question = question.strip()[:MAX_QUESTION_CHARS]
|
||||
|
||||
# Pre-LLM guardrail on the only unauthenticated LLM entry point: a
|
||||
# blocked question never reaches the semaphore, the DB pool, or the model.
|
||||
# None renders as the standard "couldn't find that" fallback.
|
||||
guardrail_ctx = GuardrailContext(
|
||||
org_id=str(organization_id), agent_id=str(agent_id) if agent_id else None
|
||||
)
|
||||
if check_inbound(question, ctx=guardrail_ctx, surface=Surface.HELP_CENTER).block:
|
||||
return None
|
||||
|
||||
async with _ask_concurrency:
|
||||
with SessionLocal() as db:
|
||||
config = AIConfigRepository(db).get_active_config(organization_id)
|
||||
if not config:
|
||||
return None
|
||||
model_type = config.model_type.value if hasattr(config.model_type, "value") else str(config.model_type)
|
||||
model_name = config.model_name
|
||||
api_key = decrypt_api_key(config.encrypted_api_key)
|
||||
base_url = (config.settings or {}).get('base_url')
|
||||
|
||||
def _run() -> Optional[str]:
|
||||
from agno.agent import Agent
|
||||
|
||||
from app.tools.faq_search import FAQSearchTool
|
||||
from app.tools.knowledge_search_byagent import KnowledgeSearchByAgent
|
||||
from app.utils.agno_utils import create_model
|
||||
|
||||
tools = [FAQSearchTool(organization_id=organization_id)]
|
||||
if agent_id:
|
||||
tools.append(KnowledgeSearchByAgent(agent_id=str(agent_id), org_id=organization_id))
|
||||
model = create_model(
|
||||
model_type=model_type, api_key=api_key, model_name=model_name, max_tokens=ANSWER_MAX_TOKENS, base_url=base_url
|
||||
)
|
||||
agent = Agent(
|
||||
name="Help Center Answers",
|
||||
model=model,
|
||||
tools=tools,
|
||||
instructions=_ASK_INSTRUCTIONS,
|
||||
markdown=False,
|
||||
)
|
||||
response = agent.run(
|
||||
message=visitor_data_block("VISITOR QUESTION", question),
|
||||
stream=False,
|
||||
)
|
||||
content = getattr(response, "content", None)
|
||||
return content.strip() if isinstance(content, str) and content.strip() else None
|
||||
|
||||
answer = None
|
||||
try:
|
||||
answer = await asyncio.to_thread(_run)
|
||||
except Exception as e:
|
||||
logger.error(f"Help center ask failed for org {organization_id}: {e}")
|
||||
finally:
|
||||
try:
|
||||
with SessionLocal() as log_db:
|
||||
HelpCenterQueryRepository(log_db).log(organization_id, question, answered=bool(answer))
|
||||
except Exception as log_err:
|
||||
logger.error(f"Help center ask log failed: {log_err}")
|
||||
return answer
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
SEO surface of the public help center: the <head> values (title, description,
|
||||
canonical, Open Graph) and the schema.org JSON-LD graph. Kept out of the route
|
||||
module so every page derives its metadata by the same rules.
|
||||
|
||||
Two URL spaces are in play and must not be mixed up:
|
||||
- ABSOLUTE (live_url-based) for canonical, og:*, sitemap and every JSON-LD
|
||||
@id/url — crawlers and social scrapers need a fetchable address.
|
||||
- RELATIVE (base_path-prefixed) for links the page actually renders, so the
|
||||
same markup works on a subdomain, a custom domain and /help/{slug}.
|
||||
Breadcrumbs carry both, which is how the BreadcrumbList markup is guaranteed to
|
||||
match the visible trail (a Google requirement).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Sequence
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
from app.models.faq import FAQ
|
||||
from app.models.help_center import HelpCenterSettings
|
||||
from app.services.help_center_content import excerpt, to_plain_text
|
||||
from app.services.help_center_settings import live_url
|
||||
|
||||
HOME_CRUMB = "Home"
|
||||
# Meta descriptions get truncated by search engines around here; the excerpt
|
||||
# helper adds an ellipsis rather than cutting mid-word.
|
||||
META_DESCRIPTION_CHARS = 200
|
||||
|
||||
|
||||
def site_url(row: HelpCenterSettings) -> str:
|
||||
"""The help center's public base URL, never trailing-slashed."""
|
||||
return live_url(row).rstrip("/")
|
||||
|
||||
|
||||
def asset_origin(site: str) -> str:
|
||||
"""Scheme + host the site's uploads are served from. Same origin as the
|
||||
site in every mode — but NOT the same prefix in path mode, where the site
|
||||
is {backend}/help/{slug} while uploads stay at {backend}/api/v1/uploads/…"""
|
||||
parts = urlsplit(site)
|
||||
return f"{parts.scheme}://{parts.netloc}"
|
||||
|
||||
|
||||
def absolute_asset_url(stored_url: Optional[str], site: str) -> Optional[str]:
|
||||
"""A stored logo/image URL made absolute for og:image and JSON-LD. S3 URLs
|
||||
are already absolute and pass through; local uploads are relative and get
|
||||
anchored to the serving origin."""
|
||||
if not stored_url:
|
||||
return None
|
||||
if stored_url.startswith("http://") or stored_url.startswith("https://"):
|
||||
return stored_url
|
||||
return f"{asset_origin(site)}{stored_url}"
|
||||
|
||||
|
||||
def _isoformat(value: Optional[datetime]) -> Optional[str]:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def article_title(row: HelpCenterSettings, faq: FAQ) -> str:
|
||||
"""The article's <title>: the org's override, else question + site name."""
|
||||
return faq.meta_title or f"{faq.question} · {row.organization.name} Help Center"
|
||||
|
||||
|
||||
def article_description(faq: FAQ) -> str:
|
||||
"""The article's meta description: the org's override, else an excerpt of
|
||||
the answer (falling back to the question for answers with no prose)."""
|
||||
return faq.meta_description or excerpt(faq.answer, META_DESCRIPTION_CHARS) or faq.question
|
||||
|
||||
|
||||
def index_title(row: HelpCenterSettings) -> str:
|
||||
return row.title or f"{row.organization.name} Help Center"
|
||||
|
||||
|
||||
def index_description(row: HelpCenterSettings) -> str:
|
||||
return row.description or f"Answers to common questions about {row.organization.name}."
|
||||
|
||||
|
||||
def category_url(site: str, category: str) -> str:
|
||||
"""Absolute URL of the index filtered to one category — the middle
|
||||
breadcrumb. Mirrors the ?topic= link the templates render."""
|
||||
return f"{site}/?topic={quote(category)}"
|
||||
|
||||
|
||||
def article_path(faq: FAQ) -> str:
|
||||
"""The root-relative path an article is served at, percent-DECODED.
|
||||
|
||||
An article migrated in with "preserve original URLs" keeps the path it had
|
||||
on the old help center; everything else lives at /a/{slug}. Exactly one of
|
||||
the two is canonical — /a/{slug} 301s to the preserved path when there is
|
||||
one — so this is the single answer to "where does this article live?" and
|
||||
every URL the site emits derives from it."""
|
||||
return faq.url_path or f"/a/{faq.slug}"
|
||||
|
||||
|
||||
def article_href(faq: FAQ, base_path: str = "") -> str:
|
||||
"""What a page renders in href=: the serving prefix plus the ENCODED path."""
|
||||
return f"{base_path}{quote(article_path(faq), safe='/')}"
|
||||
|
||||
|
||||
def article_url(site: str, faq: FAQ) -> str:
|
||||
"""The article's ABSOLUTE URL, for canonical/og:url/sitemap/JSON-LD."""
|
||||
return f"{site}{quote(article_path(faq), safe='/')}"
|
||||
|
||||
|
||||
def breadcrumbs(
|
||||
row: HelpCenterSettings, faq: FAQ, base_path: str, site: str
|
||||
) -> List[dict]:
|
||||
"""Home › Category › Article. Each crumb carries `href` (what the page
|
||||
links to) and `url` (what BreadcrumbList advertises); the last crumb is the
|
||||
current page and is rendered as text, so it has no href."""
|
||||
return [
|
||||
{"label": HOME_CRUMB, "href": f"{base_path}/", "url": f"{site}/"},
|
||||
{
|
||||
"label": faq.category,
|
||||
"href": f"{base_path}/?topic={quote(faq.category)}",
|
||||
"url": category_url(site, faq.category),
|
||||
},
|
||||
{"label": faq.question, "href": None, "url": article_url(site, faq)},
|
||||
]
|
||||
|
||||
|
||||
def _organization_node(row: HelpCenterSettings, site: str, logo_url: Optional[str]) -> dict:
|
||||
node = {
|
||||
"@type": "Organization",
|
||||
"@id": f"{site}/#organization",
|
||||
"name": row.organization.name,
|
||||
"url": f"{site}/",
|
||||
}
|
||||
if logo_url:
|
||||
node["logo"] = {"@type": "ImageObject", "url": logo_url}
|
||||
return node
|
||||
|
||||
|
||||
def _website_node(row: HelpCenterSettings, site: str) -> dict:
|
||||
return {
|
||||
"@type": "WebSite",
|
||||
"@id": f"{site}/#website",
|
||||
"url": f"{site}/",
|
||||
"name": index_title(row),
|
||||
"publisher": {"@id": f"{site}/#organization"},
|
||||
}
|
||||
|
||||
|
||||
def _question_entity(faq: FAQ) -> dict:
|
||||
"""One schema.org Question + acceptedAnswer, from the plain-text form of the
|
||||
(Markdown) answer."""
|
||||
return {
|
||||
"@type": "Question",
|
||||
"name": faq.question,
|
||||
"acceptedAnswer": {"@type": "Answer", "text": to_plain_text(faq.answer)},
|
||||
}
|
||||
|
||||
|
||||
def _breadcrumb_node(crumbs: Sequence[dict], page_url: str) -> dict:
|
||||
return {
|
||||
"@type": "BreadcrumbList",
|
||||
"@id": f"{page_url}#breadcrumb",
|
||||
"itemListElement": [
|
||||
{"@type": "ListItem", "position": i, "name": crumb["label"], "item": crumb["url"]}
|
||||
for i, crumb in enumerate(crumbs, start=1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def index_json_ld(
|
||||
row: HelpCenterSettings, faqs: Sequence[FAQ], site: str, logo_url: Optional[str]
|
||||
) -> dict:
|
||||
"""The landing page graph: Organization + WebSite + a CollectionPage that is
|
||||
also the FAQPage carrying every published Q&A.
|
||||
|
||||
FAQPage - not QAPage - is the correct type for these owner-authored answers.
|
||||
QAPage models community/forum pages where users submit competing answers and
|
||||
requires answerCount/upvoteCount, which don't apply here. Google retired FAQ
|
||||
rich results, but the markup still describes the page for other consumers.
|
||||
"""
|
||||
page_url = f"{site}/"
|
||||
page: dict = {
|
||||
"@type": ["CollectionPage", "FAQPage"],
|
||||
"@id": f"{page_url}#webpage",
|
||||
"url": page_url,
|
||||
"name": index_title(row),
|
||||
"description": index_description(row),
|
||||
"isPartOf": {"@id": f"{site}/#website"},
|
||||
"about": {"@id": f"{site}/#organization"},
|
||||
}
|
||||
if faqs:
|
||||
page["mainEntity"] = [_question_entity(faq) for faq in faqs]
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
_organization_node(row, site, logo_url),
|
||||
_website_node(row, site),
|
||||
page,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def article_json_ld(
|
||||
row: HelpCenterSettings,
|
||||
faq: FAQ,
|
||||
site: str,
|
||||
crumbs: Sequence[dict],
|
||||
logo_url: Optional[str],
|
||||
image_url: Optional[str],
|
||||
) -> dict:
|
||||
"""The article page graph: Organization + WebSite + WebPage + BreadcrumbList
|
||||
+ TechArticle, wired together by @id.
|
||||
|
||||
TechArticle (not FAQPage) is what a single how-to/help article is: Google
|
||||
dropped FAQ rich results, while BreadcrumbList still renders in search.
|
||||
"""
|
||||
page_url = article_url(site, faq)
|
||||
description = article_description(faq)
|
||||
article: dict = {
|
||||
"@type": "TechArticle",
|
||||
"@id": f"{page_url}#article",
|
||||
"headline": faq.question,
|
||||
"description": description,
|
||||
"articleSection": faq.category,
|
||||
"inLanguage": "en",
|
||||
"mainEntityOfPage": {"@id": f"{page_url}#webpage"},
|
||||
"isPartOf": {"@id": f"{page_url}#webpage"},
|
||||
"author": {"@id": f"{site}/#organization"},
|
||||
"publisher": {"@id": f"{site}/#organization"},
|
||||
}
|
||||
published_at = _isoformat(faq.created_at)
|
||||
# updated_at stays NULL until the first edit, so fall back to the creation
|
||||
# date rather than claiming a modification date older than publication.
|
||||
modified_at = _isoformat(faq.updated_at) or published_at
|
||||
if published_at:
|
||||
article["datePublished"] = published_at
|
||||
if modified_at:
|
||||
article["dateModified"] = modified_at
|
||||
if image_url:
|
||||
article["image"] = image_url
|
||||
web_page = {
|
||||
"@type": "WebPage",
|
||||
"@id": f"{page_url}#webpage",
|
||||
"url": page_url,
|
||||
"name": article_title(row, faq),
|
||||
"description": description,
|
||||
"isPartOf": {"@id": f"{site}/#website"},
|
||||
"breadcrumb": {"@id": f"{page_url}#breadcrumb"},
|
||||
}
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
_organization_node(row, site, logo_url),
|
||||
_website_node(row, site),
|
||||
web_page,
|
||||
_breadcrumb_node(crumbs, page_url),
|
||||
article,
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
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 lifecycle: lazy get-or-create (which is also the
|
||||
"org has adopted the feature" marker), slug generation and the agent
|
||||
auto-default for AI search.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Iterable, Optional, Sequence
|
||||
from urllib.parse import unquote, urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.models.agent import Agent
|
||||
from app.models.faq import FAQ, FAQ_SLUG_MAX_LENGTH, FAQ_URL_PATH_MAX_LENGTH
|
||||
from app.models.help_center import HelpCenterSettings
|
||||
from app.models.knowledge_to_agent import KnowledgeToAgent
|
||||
from app.repositories.faq import FAQRepository
|
||||
from app.repositories.help_center import HelpCenterRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
SLUG_MAX_LENGTH = 63
|
||||
_SLUG_CLEAN_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
# First path segments the public help center already owns (app.api
|
||||
# help_center_public routes, its uploads/images mounts, the /help/{slug}
|
||||
# dispatch prefix, and /.well-known, which ACME needs for TLS issuance). A
|
||||
# preserved path may never start with one of these or it would shadow the real
|
||||
# route. Matching on the FIRST SEGMENT rather than a string prefix keeps
|
||||
# "/apix/..." usable while still rejecting "/api/...".
|
||||
FAQ_RESERVED_FIRST_SEGMENTS = frozenset(
|
||||
{"a", "ask", "api", "help", "healthz", "robots.txt", "sitemap.xml", ".well-known", "static", "uploads"}
|
||||
)
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def slugify_org_name(name: str) -> str:
|
||||
slug = _SLUG_CLEAN_RE.sub("-", (name or "").casefold()).strip("-")
|
||||
return slug[:SLUG_MAX_LENGTH].strip("-") or "help"
|
||||
|
||||
|
||||
def _faq_base_slug(question: str) -> str:
|
||||
base = _SLUG_CLEAN_RE.sub("-", (question or "").casefold()).strip("-")
|
||||
return base[:FAQ_SLUG_MAX_LENGTH].strip("-") or "article"
|
||||
|
||||
|
||||
def _dedupe_slug(base: str, is_taken) -> str:
|
||||
"""First free slug from `base`, appending -2, -3, … while is_taken(candidate)."""
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while is_taken(candidate):
|
||||
tail = f"-{suffix}"
|
||||
candidate = f"{base[:FAQ_SLUG_MAX_LENGTH - len(tail)]}{tail}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def generate_faq_slug(db: Session, organization_id: UUID, question: str) -> str:
|
||||
"""A per-org-unique URL slug from an FAQ question. Collisions get -2, -3, …
|
||||
Assigned once at creation and kept stable afterwards so article URLs persist."""
|
||||
repo = FAQRepository(db)
|
||||
return _dedupe_slug(_faq_base_slug(question), lambda c: repo.slug_exists(organization_id, c))
|
||||
|
||||
|
||||
def resolve_faq_slug(
|
||||
db: Session, organization_id: UUID, requested: str, exclude_id: Optional[UUID] = None
|
||||
) -> str:
|
||||
"""A hand-typed slug, reduced to the same shape generation produces
|
||||
(lowercase, a-z0-9 and single hyphens, length-capped) and made unique within
|
||||
the org. `exclude_id` is the FAQ being edited, so keeping its own slug is a
|
||||
no-op rather than a collision that appends -2."""
|
||||
repo = FAQRepository(db)
|
||||
return _dedupe_slug(
|
||||
_faq_base_slug(requested),
|
||||
lambda c: repo.slug_exists(organization_id, c, exclude_id=exclude_id),
|
||||
)
|
||||
|
||||
|
||||
def assign_faq_slugs(db: Session, faqs) -> None:
|
||||
"""Give every FAQ in a batch a unique slug in place, deduping against both the
|
||||
DB and the other rows in the same batch. Rows that already have a slug are
|
||||
left untouched (and reserved so batch-mates don't collide with them)."""
|
||||
repo = FAQRepository(db)
|
||||
taken: set = set()
|
||||
for faq in faqs:
|
||||
if faq.slug:
|
||||
taken.add(faq.slug)
|
||||
continue
|
||||
slug = _dedupe_slug(
|
||||
_faq_base_slug(faq.question),
|
||||
lambda c: c in taken or repo.slug_exists(faq.organization_id, c),
|
||||
)
|
||||
faq.slug = slug
|
||||
taken.add(slug)
|
||||
|
||||
|
||||
def normalize_url_path(raw: Optional[str]) -> Optional[str]:
|
||||
"""A full URL or hand-typed path reduced to the root-relative path an article
|
||||
can be served at — or None when it can't be used at all, in which case the
|
||||
caller falls back to /a/{slug}.
|
||||
|
||||
Returned percent-DECODED, because that is the form the ASGI scope hands the
|
||||
router to match against; every emitted URL re-encodes it (see
|
||||
help_center_seo.article_url). Getting that direction backwards silently 404s
|
||||
every non-ASCII path."""
|
||||
if not raw:
|
||||
return None
|
||||
candidate = raw.strip()
|
||||
if "://" in candidate or candidate.startswith("//"):
|
||||
candidate = urlsplit(candidate).path # drop scheme/host/port
|
||||
candidate = candidate.split("#", 1)[0].split("?", 1)[0] # drop fragment/query
|
||||
candidate = unquote(candidate)
|
||||
if _CONTROL_CHARS_RE.search(candidate):
|
||||
return None
|
||||
if not candidate.startswith("/"):
|
||||
candidate = f"/{candidate}"
|
||||
candidate = re.sub(r"/{2,}", "/", candidate).rstrip("/")
|
||||
if len(candidate) > FAQ_URL_PATH_MAX_LENGTH:
|
||||
return None
|
||||
segments = candidate.split("/")[1:]
|
||||
if not segments or not segments[0]:
|
||||
return None # "" or "/" — that's the index, not an article
|
||||
if "." in segments or ".." in segments:
|
||||
return None # no traversal, and nothing that normalises to another path
|
||||
if segments[0].lower() in FAQ_RESERVED_FIRST_SEGMENTS:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def resolve_faq_url_path(
|
||||
db: Session, organization_id: UUID, requested: str, exclude_id: Optional[UUID] = None
|
||||
) -> Optional[str]:
|
||||
"""A validated preserved path that is free within the org, else None.
|
||||
|
||||
Deliberately does NOT dedupe with -2/-3 the way slugs do: a mangled path is
|
||||
no longer the URL the org already ranks for, so silently altering it would
|
||||
defeat the whole point. The caller decides what to do instead — fall back to
|
||||
/a/{slug} on import, or reject the edit in the admin API."""
|
||||
path = normalize_url_path(requested)
|
||||
if path is None:
|
||||
return None
|
||||
repo = FAQRepository(db)
|
||||
return None if repo.url_path_exists(organization_id, path, exclude_id=exclude_id) else path
|
||||
|
||||
|
||||
def assign_faq_url_paths(db: Session, faqs: Sequence[FAQ], sources: Iterable[str]) -> int:
|
||||
"""Give each FAQ in a batch its source URL's path, in place, deduping against
|
||||
both the DB and the other rows in the batch. Returns how many were applied.
|
||||
|
||||
Rows whose path is unusable or taken keep url_path=None and are served at
|
||||
/a/{slug} — a partial result still imports every article, which beats
|
||||
failing the whole job over one bad URL.
|
||||
|
||||
MUST run before the batch insert, so the unique index can never reject it."""
|
||||
repo = FAQRepository(db)
|
||||
taken: set = set()
|
||||
applied = 0
|
||||
for faq, source in zip(faqs, sources):
|
||||
path = normalize_url_path(source)
|
||||
if path is None:
|
||||
continue
|
||||
if path in taken or repo.url_path_exists(faq.organization_id, path):
|
||||
logger.warning(f"Preserved URL path already taken, using the slug instead: {path}")
|
||||
continue
|
||||
faq.url_path = path
|
||||
taken.add(path)
|
||||
applied += 1
|
||||
return applied
|
||||
|
||||
|
||||
def generate_unique_slug(db: Session, name: str) -> str:
|
||||
"""Slug from the org name; reserved labels and collisions get -2, -3, …"""
|
||||
repo = HelpCenterRepository(db)
|
||||
base = slugify_org_name(name)
|
||||
if base in settings.HELP_CENTER_RESERVED_SLUGS:
|
||||
base = f"{base}-help"[:SLUG_MAX_LENGTH]
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while repo.slug_exists(candidate) or candidate in settings.HELP_CENTER_RESERVED_SLUGS:
|
||||
tail = f"-{suffix}"
|
||||
candidate = f"{base[:SLUG_MAX_LENGTH - len(tail)]}{tail}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def default_agent_id(db: Session, organization_id: UUID) -> Optional[UUID]:
|
||||
"""The org's only active agent; else the first agent with linked knowledge;
|
||||
else None (user picks manually)."""
|
||||
active_agents = (
|
||||
db.query(Agent)
|
||||
.filter(Agent.organization_id == organization_id, Agent.is_active.is_(True))
|
||||
.order_by(Agent.id)
|
||||
.all()
|
||||
)
|
||||
if len(active_agents) == 1:
|
||||
return active_agents[0].id
|
||||
with_knowledge = (
|
||||
db.query(Agent)
|
||||
.join(KnowledgeToAgent, KnowledgeToAgent.agent_id == Agent.id)
|
||||
.filter(Agent.organization_id == organization_id)
|
||||
.order_by(Agent.id)
|
||||
.first()
|
||||
)
|
||||
return with_knowledge.id if with_knowledge else None
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session, organization) -> HelpCenterSettings:
|
||||
"""Fetch the org's help-center settings, creating the row (slug + agent
|
||||
auto-default) on first access."""
|
||||
repo = HelpCenterRepository(db)
|
||||
row = repo.get_by_org(organization.id)
|
||||
if row:
|
||||
return row
|
||||
row = HelpCenterSettings(
|
||||
organization_id=organization.id,
|
||||
slug=generate_unique_slug(db, organization.name),
|
||||
agent_id=default_agent_id(db, organization.id),
|
||||
)
|
||||
created = repo.create(row)
|
||||
logger.info(f"Created help center settings for org {organization.id} (slug={created.slug})")
|
||||
return created
|
||||
|
||||
|
||||
def live_url(row: HelpCenterSettings) -> str:
|
||||
"""The public URL the help center is (or will be) served at.
|
||||
|
||||
A verified custom domain always wins. Otherwise the URL shape follows
|
||||
HELP_CENTER_PUBLIC_MODE: "subdomain" advertises {slug}.<base> (cloud); the
|
||||
default "path" mode serves same-origin as the API at {BACKEND_URL}/help/{slug}
|
||||
(self-host — no DNS/TLS/proxy needed, and correct scheme+port via BACKEND_URL)."""
|
||||
if row.domain_verified:
|
||||
return f"https://{row.custom_domain}"
|
||||
if settings.HELP_CENTER_PUBLIC_MODE == "subdomain":
|
||||
return f"https://{row.slug}.{settings.HELP_CENTER_BASE_DOMAIN}"
|
||||
return f"{settings.BACKEND_URL.rstrip('/')}/help/{row.slug}"
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
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, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.models.jira import JiraToken
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import JiraAuthError
|
||||
from app.core.external_urls import external_base
|
||||
from app.core.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Atlassian hosts. Deployments never set these; the e2e suite points them at its
|
||||
# recording fake. The authorize URL is browser-facing, the rest are server calls.
|
||||
JIRA_AUTH_BASE = external_base("JIRA_AUTH_BASE", "https://auth.atlassian.com")
|
||||
JIRA_API_BASE = external_base("JIRA_API_BASE", "https://api.atlassian.com")
|
||||
|
||||
|
||||
def jira_authorize_url() -> str:
|
||||
return f"{JIRA_AUTH_BASE}/authorize"
|
||||
|
||||
|
||||
def jira_token_url() -> str:
|
||||
return f"{JIRA_AUTH_BASE}/oauth/token"
|
||||
|
||||
|
||||
def jira_accessible_resources_url() -> str:
|
||||
return f"{JIRA_API_BASE}/oauth/token/accessible-resources"
|
||||
|
||||
|
||||
def jira_rest_url(cloud_id: str, path: str) -> str:
|
||||
"""Jira Cloud REST v3 URL, e.g. `jira_rest_url(cloud_id, "issue")`."""
|
||||
return f"{JIRA_API_BASE}/ex/jira/{cloud_id}/rest/api/3/{path}"
|
||||
|
||||
|
||||
class JiraService:
|
||||
"""Service for handling Jira OAuth 2.0 flow and API interactions."""
|
||||
|
||||
def __init__(self):
|
||||
self.client_id = settings.JIRA_CLIENT_ID
|
||||
self.client_secret = settings.JIRA_CLIENT_SECRET
|
||||
self.redirect_uri = settings.JIRA_REDIRECT_URI
|
||||
self.auth_url = jira_authorize_url()
|
||||
self.token_url = jira_token_url()
|
||||
self.scope = "read:jira-work write:jira-work read:jira-user offline_access"
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Generate the authorization URL for Jira OAuth flow."""
|
||||
params = {
|
||||
"audience": "api.atlassian.com",
|
||||
"client_id": self.client_id,
|
||||
"scope": self.scope,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"state": state,
|
||||
"response_type": "code",
|
||||
"prompt": "consent"
|
||||
}
|
||||
return f"{self.auth_url}?{urlencode(params)}"
|
||||
|
||||
def validate_token(self, token: JiraToken) -> bool:
|
||||
"""Check if the token is valid and not expired."""
|
||||
if token is None:
|
||||
return False
|
||||
return token.expires_at > datetime.utcnow() + timedelta(minutes=5)
|
||||
|
||||
async def exchange_code_for_token(self, code: str) -> Dict[str, Any]:
|
||||
"""Exchange authorization code for access token."""
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri
|
||||
}
|
||||
|
||||
response = requests.post(self.token_url, data=data)
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError("Failed to exchange code for token")
|
||||
|
||||
token_data = response.json()
|
||||
return {
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"token_type": token_data["token_type"],
|
||||
"expires_at": datetime.utcnow() + timedelta(seconds=token_data["expires_in"])
|
||||
}
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
|
||||
"""Refresh an expired access token."""
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": refresh_token
|
||||
}
|
||||
|
||||
response = requests.post(self.token_url, data=data)
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError("Failed to refresh token")
|
||||
|
||||
token_data = response.json()
|
||||
return {
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data.get("refresh_token", refresh_token),
|
||||
"token_type": token_data["token_type"],
|
||||
"expires_at": datetime.utcnow() + timedelta(seconds=token_data["expires_in"])
|
||||
}
|
||||
|
||||
async def get_cloud_id(self, access_token: str) -> Dict[str, str]:
|
||||
"""Get Jira Cloud ID and site URL for the authenticated user."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
response = requests.get(jira_accessible_resources_url(), headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError("Failed to get Jira Cloud ID")
|
||||
|
||||
resources = response.json()
|
||||
if not resources:
|
||||
raise JiraAuthError("No Jira Cloud instances found")
|
||||
|
||||
# Use the first available cloud instance
|
||||
return {
|
||||
"cloud_id": resources[0]["id"],
|
||||
"site_url": resources[0]["url"]
|
||||
}
|
||||
|
||||
async def get_projects(self, access_token: str, cloud_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all projects from Jira."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
url = jira_rest_url(cloud_id, "project")
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError(f"Failed to get Jira projects: {response.text}")
|
||||
|
||||
projects = response.json()
|
||||
return [
|
||||
{
|
||||
"id": project["id"],
|
||||
"key": project["key"],
|
||||
"name": project["name"]
|
||||
}
|
||||
for project in projects
|
||||
]
|
||||
|
||||
async def get_issue_types(self, access_token: str, cloud_id: str, project_key: str) -> List[Dict[str, Any]]:
|
||||
"""Get all issue types for a project from Jira."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# First, get the project to find its ID
|
||||
url = jira_rest_url(cloud_id, f"project/{project_key}")
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError(f"Failed to get Jira project: {response.text}")
|
||||
|
||||
project = response.json()
|
||||
|
||||
# Get issue types for the project, filtering out sub-tasks
|
||||
issue_types = []
|
||||
for issue_type in project.get("issueTypes", []):
|
||||
# Skip sub-tasks as they require a parent issue
|
||||
if issue_type.get("subtask", False):
|
||||
logger.info(f"Skipping sub-task issue type: {issue_type['name']} (ID: {issue_type['id']})")
|
||||
continue
|
||||
|
||||
issue_types.append({
|
||||
"id": issue_type["id"],
|
||||
"name": issue_type["name"],
|
||||
"description": issue_type.get("description", ""),
|
||||
"iconUrl": issue_type.get("iconUrl", "")
|
||||
})
|
||||
|
||||
return issue_types
|
||||
|
||||
async def create_issue(self, organization, db, issue_data):
|
||||
"""Wrapper method for create_issue that takes organization, db, and issue_data parameters."""
|
||||
from app.models.jira import JiraToken
|
||||
|
||||
token = db.query(JiraToken).filter(
|
||||
JiraToken.organization_id == organization.id
|
||||
).first()
|
||||
|
||||
if not token:
|
||||
raise JiraAuthError("No Jira connection found")
|
||||
|
||||
# Check if token is valid and refresh if needed
|
||||
is_valid = self.validate_token(token)
|
||||
if not is_valid:
|
||||
try:
|
||||
token_data = await self.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 JiraAuthError("Jira token expired and could not be refreshed")
|
||||
|
||||
# Create the issue
|
||||
result = await self.create_issue_internal(
|
||||
token.access_token,
|
||||
token.cloud_id,
|
||||
issue_data.projectKey,
|
||||
issue_data.issueTypeId,
|
||||
issue_data.summary,
|
||||
issue_data.description,
|
||||
issue_data.priority
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def create_issue_internal(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str,
|
||||
summary: str, description: str, priority_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Create a new issue in Jira."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
url = jira_rest_url(cloud_id, "issue")
|
||||
|
||||
data = {
|
||||
"fields": {
|
||||
"project": {
|
||||
"key": project_key
|
||||
},
|
||||
"issuetype": {
|
||||
"id": issue_type_id
|
||||
},
|
||||
"summary": summary,
|
||||
"description": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": description
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Add priority if provided and available
|
||||
if priority_id:
|
||||
# Check if priority field is available for this project/issue type
|
||||
priority_available = await self.is_priority_available(access_token, cloud_id, project_key, issue_type_id)
|
||||
if priority_available:
|
||||
data["fields"]["priority"] = {
|
||||
"id": priority_id
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Priority field not available for project {project_key} and issue type {issue_type_id}. Skipping priority.")
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise JiraAuthError(f"Failed to create Jira issue: {response.text}")
|
||||
|
||||
return response.json()
|
||||
|
||||
async def get_priorities(self, access_token: str, cloud_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all priorities from Jira."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
url = jira_rest_url(cloud_id, "priority")
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise JiraAuthError(f"Failed to get Jira priorities: {response.text}")
|
||||
|
||||
priorities = response.json()
|
||||
return [
|
||||
{
|
||||
"id": priority["id"],
|
||||
"name": priority["name"],
|
||||
"description": priority.get("description", ""),
|
||||
"iconUrl": priority.get("iconUrl", "")
|
||||
}
|
||||
for priority in priorities
|
||||
]
|
||||
|
||||
async def check_field_availability(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str, field_name: str) -> bool:
|
||||
"""Check if a field is available for a project/issue type combination."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# Get the create metadata for the project/issue type
|
||||
url = jira_rest_url(cloud_id, f"issue/createmeta?projectKeys={project_key}&issuetypeIds={issue_type_id}&expand=projects.issuetypes.fields")
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to get create metadata: {response.text}")
|
||||
return False
|
||||
|
||||
metadata = response.json()
|
||||
|
||||
# Navigate through the response structure to find the field
|
||||
try:
|
||||
projects = metadata.get("projects", [])
|
||||
if not projects:
|
||||
return False
|
||||
|
||||
issue_types = projects[0].get("issuetypes", [])
|
||||
if not issue_types:
|
||||
return False
|
||||
|
||||
fields = issue_types[0].get("fields", {})
|
||||
|
||||
# Check if the field exists
|
||||
return field_name in fields
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking field availability: {e}")
|
||||
return False
|
||||
|
||||
async def is_priority_available(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str) -> bool:
|
||||
"""Check if the priority field is available for a project/issue type combination."""
|
||||
return await self.check_field_availability(access_token, cloud_id, project_key, issue_type_id, "priority")
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
WhatsApp numbers and Messenger Pages that live in the customer's own Meta
|
||||
app (connected with manual credentials) rather than in ReplyPier's.
|
||||
|
||||
Meta signs their webhooks with the customer's app secret, which the shared
|
||||
/webhooks/meta endpoint can't check. So each such account gets its own
|
||||
webhook address, checked with its app's secret only. That address takes
|
||||
events for the account and for the same organization's other accounts from
|
||||
the same app (one Meta app has one Messenger callback, and possibly several
|
||||
numbers), never for anyone else's: a payload signed with a secret someone
|
||||
chose themselves can't reach another organization.
|
||||
|
||||
A WhatsApp number is pointed at its address with a per-number callback
|
||||
override; Messenger has no override, so the customer pastes the address and
|
||||
verify token into their app.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels.meta_base import graph_detail, graph_get, graph_post_json
|
||||
from app.core.config import settings, webhooks_base_url
|
||||
from app.core.logger import get_logger
|
||||
from app.models.channels import ChannelAccount, ChannelType
|
||||
from app.core.security import decrypt_credentials
|
||||
from app.repositories.channels.accounts import ChannelAccountRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# In the account's encrypted credentials
|
||||
APP_SECRET_KEY = "app_secret"
|
||||
# In its (non-secret) settings: which app it's from, and whether Meta
|
||||
# accepted our per-number callback for it
|
||||
APP_ID_SETTING = "meta_app_id"
|
||||
AUTO_WEBHOOK_SETTING = "webhook_auto_configured"
|
||||
OWN_APP_CHANNELS = (ChannelType.WHATSAPP.value, ChannelType.MESSENGER.value)
|
||||
|
||||
# Meta's answer to an appsecret_proof made with another app's secret
|
||||
_PROOF_ERROR = "appsecret_proof"
|
||||
|
||||
|
||||
class AppSecretMismatch(Exception):
|
||||
"""The secret isn't the one of the app that issued the token."""
|
||||
|
||||
|
||||
class AppCheckUnavailable(Exception):
|
||||
"""Meta couldn't be asked (network, rate limit, an expired token…)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OwnApp:
|
||||
app_id: str
|
||||
secret: str
|
||||
|
||||
|
||||
def is_own_app(account: Optional[ChannelAccount]) -> bool:
|
||||
return (account is not None and account.channel_type in OWN_APP_CHANNELS
|
||||
and bool((account.settings or {}).get(APP_ID_SETTING)))
|
||||
|
||||
|
||||
def own_app_secret(account: ChannelAccount) -> Optional[str]:
|
||||
return decrypt_credentials(account.encrypted_credentials).get(APP_SECRET_KEY) if is_own_app(account) else None
|
||||
|
||||
|
||||
def own_app_token(account: Optional[ChannelAccount]) -> Optional[str]:
|
||||
"""The customer's token, read before a reconnect replaces it: only it can
|
||||
take back a callback override made with it."""
|
||||
return decrypt_credentials(account.encrypted_credentials).get("access_token") if is_own_app(account) else None
|
||||
|
||||
|
||||
def own_app_webhook_url(account: ChannelAccount) -> str:
|
||||
return f"{webhooks_base_url()}/meta/{account.id}"
|
||||
|
||||
|
||||
async def resolve_own_app(access_token: str, entered_secret: Optional[str],
|
||||
previous: Optional[ChannelAccount]) -> Optional[OwnApp]:
|
||||
"""The customer's own app behind this token, proven with its secret —
|
||||
the one entered, or else the one kept from the last connect (checked
|
||||
again: the new token may be from another app). None without a secret, or
|
||||
for ReplyPier's own app, whose webhooks the shared address already gets.
|
||||
|
||||
Raises AppSecretMismatch or AppCheckUnavailable."""
|
||||
secret = entered_secret or (own_app_secret(previous) if previous is not None else None)
|
||||
if not secret:
|
||||
return None
|
||||
proof = hmac.new(secret.encode(), access_token.encode(), hashlib.sha256).hexdigest()
|
||||
# /app (the token's own app) needs no permission on the Page or number
|
||||
ok, data = await graph_get("app", access_token, params={"fields": "id", "appsecret_proof": proof})
|
||||
if not ok:
|
||||
error = data.get("error") if isinstance(data.get("error"), dict) else {}
|
||||
if _PROOF_ERROR in str(error.get("message", "")):
|
||||
raise AppSecretMismatch()
|
||||
# Meta's own errors carry a code; anything else is ours (a timeout,
|
||||
# a socket error) and stays in the log
|
||||
logger.warning(f"Could not check an app secret with Meta: {error.get('message')}")
|
||||
raise AppCheckUnavailable(graph_detail(data, "") if "code" in error else "Meta did not answer")
|
||||
app_id = str(data.get("id") or "")
|
||||
if not app_id or app_id == settings.META_APP_ID:
|
||||
return None
|
||||
return OwnApp(app_id=app_id, secret=secret)
|
||||
|
||||
|
||||
async def sync_own_app(db: Session, account: ChannelAccount, own: Optional[OwnApp],
|
||||
access_token: str, previous_token: Optional[str]) -> None:
|
||||
"""After a connect: route the account's webhooks to its own address when
|
||||
it's from the customer's app, or hand a number that used to be back."""
|
||||
repo = ChannelAccountRepository(db)
|
||||
is_whatsapp = account.channel_type == ChannelType.WHATSAPP.value
|
||||
if own is not None:
|
||||
# Moved to another app: the old app's override is still the old app's
|
||||
if is_whatsapp and previous_token and (account.settings or {}).get(APP_ID_SETTING) != own.app_id:
|
||||
await _set_number_callback(account, previous_token, "", None)
|
||||
# Saved first: Meta checks the address during the callback call, and
|
||||
# the address only answers for an account marked as from its own app
|
||||
repo.update_settings(account, {APP_ID_SETTING: own.app_id})
|
||||
if is_whatsapp:
|
||||
pointed = await _set_number_callback(
|
||||
account, access_token, own_app_webhook_url(account), account.webhook_secret)
|
||||
repo.update_settings(account, {AUTO_WEBHOOK_SETTING: pointed})
|
||||
return
|
||||
if previous_token is not None:
|
||||
if is_whatsapp:
|
||||
await _set_number_callback(account, previous_token, "", None)
|
||||
repo.update_settings(account, {}, remove=(APP_ID_SETTING, AUTO_WEBHOOK_SETTING))
|
||||
|
||||
|
||||
async def leave_own_app(account: ChannelAccount) -> None:
|
||||
"""Before a disconnect: send the number's webhooks back to the customer's
|
||||
app. Best effort — a disconnect must never fail over it."""
|
||||
if account.channel_type != ChannelType.WHATSAPP.value:
|
||||
return
|
||||
try:
|
||||
token = own_app_token(account)
|
||||
except Exception:
|
||||
logger.exception(f"Could not read {account.id}'s credentials to release its callback")
|
||||
return
|
||||
if token:
|
||||
await _set_number_callback(account, token, "", None)
|
||||
|
||||
|
||||
def same_app_accounts(db: Session, account: ChannelAccount) -> Callable[[str], Optional[ChannelAccount]]:
|
||||
"""Resolve an external id named in a webhook to one of this organization's
|
||||
accounts from the same app (and channel), or to nothing."""
|
||||
repo = ChannelAccountRepository(db)
|
||||
app_id = account.settings[APP_ID_SETTING]
|
||||
|
||||
def resolve(external_id: str) -> Optional[ChannelAccount]:
|
||||
if external_id == account.external_account_id:
|
||||
return account
|
||||
other = repo.get_by_external_id(account.channel_type, external_id)
|
||||
if (other is not None and other.organization_id == account.organization_id
|
||||
and (other.settings or {}).get(APP_ID_SETTING) == app_id):
|
||||
return other
|
||||
return None
|
||||
return resolve
|
||||
|
||||
|
||||
async def _set_number_callback(account: ChannelAccount, access_token: str, url: str,
|
||||
verify_token: Optional[str]) -> bool:
|
||||
"""Point a WhatsApp number's webhooks at `url` ("" sends them back to the
|
||||
app's own callback). Meta checks the address during the call."""
|
||||
configuration = {"override_callback_uri": url}
|
||||
if verify_token:
|
||||
configuration["verify_token"] = verify_token
|
||||
ok, data = await graph_post_json(account.external_account_id, access_token,
|
||||
{"webhook_configuration": configuration})
|
||||
if not ok:
|
||||
action = "point" if url else "release"
|
||||
logger.warning(f"Could not {action} the webhook callback of {account.external_account_id}: "
|
||||
f"{graph_detail(data, 'no reason given')}")
|
||||
return ok
|
||||
@@ -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 enum
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.models.notification import Notification, NotificationType
|
||||
from app.models.notification_settings import (
|
||||
NOTIFY_CHAT_ASSIGNED,
|
||||
NOTIFY_CHAT_TRANSFER,
|
||||
NOTIFY_NEW_CHAT,
|
||||
)
|
||||
from app.repositories.notification_settings import UserNotificationSettingsRepository
|
||||
from app.services.user import send_fcm_notification
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChatNotificationEvent(str, enum.Enum):
|
||||
"""Chat events a user can mute. The value is both the preference column
|
||||
and the `event` discriminator carried in the push metadata.
|
||||
"""
|
||||
NEW_CHAT = NOTIFY_NEW_CHAT
|
||||
CHAT_TRANSFER = NOTIFY_CHAT_TRANSFER
|
||||
CHAT_ASSIGNED = NOTIFY_CHAT_ASSIGNED
|
||||
|
||||
|
||||
async def notify_user(
|
||||
db: Session,
|
||||
user_id,
|
||||
type_: NotificationType,
|
||||
title: str,
|
||||
message: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""Persist an in-app notification and send its FCM push.
|
||||
|
||||
Never raises and no-ops without a user (background jobs may have none) —
|
||||
a notification failure must not fail the work it reports on.
|
||||
|
||||
NOTE: this commits (and, on error, rolls back) the caller's `db` session,
|
||||
so call it only once any work you want persisted is already committed.
|
||||
Every current caller either commits first or has nothing pending.
|
||||
"""
|
||||
if not user_id:
|
||||
return
|
||||
try:
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
type=type_,
|
||||
title=title,
|
||||
message=message,
|
||||
notification_metadata=metadata,
|
||||
)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
await send_fcm_notification(user_id, notification, db)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending notification '{title}' to user {user_id}: {e}")
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def notify_chat_event(
|
||||
db: Session,
|
||||
user_ids: Iterable,
|
||||
event: ChatNotificationEvent,
|
||||
title: str,
|
||||
message: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""Notify every user in `user_ids` who hasn't muted `event`.
|
||||
|
||||
Recipients are resolved in a single query, so fanning out to a whole group
|
||||
or org costs one lookup rather than one per user. Like notify_user, this
|
||||
never raises — a notification must not fail the chat it reports on.
|
||||
"""
|
||||
try:
|
||||
recipients = UserNotificationSettingsRepository(db).filter_enabled(
|
||||
user_ids, event.value
|
||||
)
|
||||
if not recipients:
|
||||
return
|
||||
|
||||
payload = {**(metadata or {}), "event": event.value}
|
||||
for user_id in recipients:
|
||||
await notify_user(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type_=NotificationType.CHAT,
|
||||
title=title,
|
||||
message=message,
|
||||
metadata=payload,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending '{event.value}' notification: {e}")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
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 sqlalchemy.orm import Session
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
from app.core.logger import get_logger
|
||||
from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository
|
||||
from app.repositories.widget import WidgetRepository
|
||||
from app.services.shopify import ShopifyService
|
||||
from app.repositories.agent import AgentRepository
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from app.models.organization import Organization
|
||||
from app.core.config import settings
|
||||
from urllib.parse import quote
|
||||
import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ShopifyAuthService:
|
||||
"""Service for handling Shopify authentication and HTML generation"""
|
||||
|
||||
@staticmethod
|
||||
async def get_shop_success_data(db: Session, shop_id: str) -> Tuple[int, Optional[str]]:
|
||||
"""
|
||||
Get success page data for a shop (agent count and widget ID).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
shop_id: Shop ID
|
||||
|
||||
Returns:
|
||||
Tuple of (agents_connected, widget_id)
|
||||
"""
|
||||
agent_config_repository = AgentShopifyConfigRepository(db)
|
||||
configs = agent_config_repository.get_configs_by_shop(shop_id, enabled_only=True)
|
||||
agents_connected = len(configs) if configs else 0
|
||||
|
||||
# Get widget ID from the first connected agent
|
||||
widget_id = None
|
||||
if configs and len(configs) > 0:
|
||||
widget_repo = WidgetRepository(db)
|
||||
widgets = widget_repo.get_widgets_by_agent(configs[0].agent_id)
|
||||
if widgets and len(widgets) > 0:
|
||||
widget_id = str(widgets[0].id)
|
||||
|
||||
return agents_connected, widget_id
|
||||
|
||||
@staticmethod
|
||||
async def verify_shop_token(db: Session, db_shop) -> bool:
|
||||
"""
|
||||
Verify if a shop's access token is valid.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_shop: Shop database record
|
||||
|
||||
Returns:
|
||||
True if token is valid, False otherwise
|
||||
"""
|
||||
shopify_service = ShopifyService(db)
|
||||
validation_query = "query { shop { name } }"
|
||||
validation_result = await shopify_service._execute_graphql_async(db_shop, validation_query)
|
||||
return validation_result.get("success", False)
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
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 sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from app.core.logger import get_logger
|
||||
from app.core.config import settings
|
||||
from app.models.organization import Organization
|
||||
from app.repositories.widget import WidgetRepository
|
||||
from app.services.shopify_auth_service import ShopifyAuthService
|
||||
from app.services.shopify_urls import shopify_oauth_token_url
|
||||
from urllib.parse import quote
|
||||
import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ShopifyHelperService:
|
||||
"""Service for Shopify helper functions and utilities"""
|
||||
|
||||
@staticmethod
|
||||
def get_shop_agents_list(db: Session, organization_id: str) -> list[dict]:
|
||||
"""
|
||||
Get all agents for a shop's organization and convert to dict format.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
organization_id: Organization ID (string UUID)
|
||||
|
||||
Returns:
|
||||
List of agent dictionaries with id, name, display_name, description, is_active
|
||||
"""
|
||||
from app.repositories.agent import AgentRepository
|
||||
agent_repository = AgentRepository(db)
|
||||
agents = agent_repository.get_active_agents(organization_id)
|
||||
|
||||
# Convert agents to dict format
|
||||
agents_list = [
|
||||
{
|
||||
"id": str(agent.id),
|
||||
"name": agent.name,
|
||||
"display_name": agent.display_name,
|
||||
"description": agent.description,
|
||||
"is_active": agent.is_active
|
||||
}
|
||||
for agent in agents
|
||||
]
|
||||
|
||||
return agents_list
|
||||
|
||||
@staticmethod
|
||||
def get_shop_widget_id(db: Session, configs: list, default: str = None) -> Optional[str]:
|
||||
"""
|
||||
Get the widget ID for the first configured agent.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
configs: List of agent Shopify configs
|
||||
default: Default value if no widget found
|
||||
|
||||
Returns:
|
||||
Widget ID as string or None
|
||||
"""
|
||||
if configs and len(configs) > 0:
|
||||
widget_repo = WidgetRepository(db)
|
||||
widgets = widget_repo.get_widgets_by_agent(configs[0].agent_id)
|
||||
if widgets and len(widgets) > 0:
|
||||
return str(widgets[0].id)
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def exchange_oauth_code_for_token(shop: str, code: str) -> tuple[str, str]:
|
||||
"""
|
||||
Exchange OAuth authorization code for access token.
|
||||
|
||||
Args:
|
||||
shop: Shop domain
|
||||
code: OAuth authorization code
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, scope)
|
||||
|
||||
Raises:
|
||||
HTTPException: If token exchange fails
|
||||
"""
|
||||
import requests
|
||||
from fastapi import HTTPException
|
||||
|
||||
token_url = shopify_oauth_token_url(shop)
|
||||
payload = {
|
||||
"client_id": settings.SHOPIFY_API_KEY,
|
||||
"client_secret": settings.SHOPIFY_API_SECRET,
|
||||
"code": code
|
||||
}
|
||||
|
||||
# Check if we should verify SSL certificates (default to True for production)
|
||||
verify_ssl = settings.VERIFY_SSL_CERTIFICATES
|
||||
logger.info(f"Exchanging OAuth code for token - Shop: {shop}")
|
||||
logger.info(f"Verify SSL Certificates: {verify_ssl}")
|
||||
logger.info(f"Token URL: {token_url}")
|
||||
|
||||
try:
|
||||
response = requests.post(token_url, json=payload, verify=verify_ssl)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
scope = token_data.get("scope")
|
||||
|
||||
if not access_token:
|
||||
logger.error(f"Failed to get access token for shop: {shop}")
|
||||
raise HTTPException(status_code=400, detail="Failed to obtain access token")
|
||||
|
||||
logger.info(f"Successfully obtained access token for shop: {shop}")
|
||||
return access_token, scope
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error exchanging OAuth code for token: {str(e)}")
|
||||
if isinstance(e, requests.exceptions.SSLError):
|
||||
logger.error("SSL Certificate verification failed. If this is a development environment, consider setting VERIFY_SSL_CERTIFICATES=False in settings.")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to exchange OAuth code: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def verify_shopify_webhook(request_headers, request_body) -> bool:
|
||||
"""
|
||||
Validate Shopify HMAC signature for webhooks.
|
||||
|
||||
Args:
|
||||
request_headers: Request headers containing X-Shopify-Hmac-Sha256
|
||||
request_body: Raw request body bytes
|
||||
|
||||
Returns:
|
||||
True if signature is valid, False otherwise
|
||||
"""
|
||||
shopify_hmac = request_headers.get('X-Shopify-Hmac-Sha256')
|
||||
if not shopify_hmac:
|
||||
return False
|
||||
|
||||
digest = hmac.new(
|
||||
settings.SHOPIFY_API_SECRET.encode('utf-8'),
|
||||
request_body,
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
|
||||
computed_hmac = base64.b64encode(digest).decode('utf-8')
|
||||
return hmac.compare_digest(computed_hmac, shopify_hmac)
|
||||
|
||||
@staticmethod
|
||||
def validate_shop_request(request: Request, shop: str, hmac_param: str) -> bool:
|
||||
"""
|
||||
Validate the OAuth request from Shopify using HMAC validation.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
shop: Shop domain
|
||||
hmac_param: HMAC parameter from query string
|
||||
|
||||
Returns:
|
||||
True if request is valid, False otherwise
|
||||
"""
|
||||
# Check if the shop URL is a valid Shopify domain
|
||||
if not shop.endswith('.myshopify.com'):
|
||||
logger.warning(f"Invalid shop domain: {shop}")
|
||||
return False
|
||||
|
||||
# Get query parameters as dictionary
|
||||
query_params = dict(request.query_params)
|
||||
|
||||
# Remove hmac from params for validation
|
||||
if 'hmac' in query_params:
|
||||
query_params.pop('hmac')
|
||||
|
||||
logger.info(f"Query params: {query_params}")
|
||||
logger.info(f"HMAC param: {hmac_param}")
|
||||
|
||||
# Sort and encode parameters
|
||||
sorted_params = "&".join([f"{key}={quote(value)}" for key, value in sorted(query_params.items())])
|
||||
|
||||
# Generate HMAC
|
||||
digest = hmac.new(
|
||||
settings.SHOPIFY_API_SECRET.encode('utf-8'),
|
||||
sorted_params.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Compare with provided HMAC
|
||||
is_valid = hmac.compare_digest(digest, hmac_param)
|
||||
if not is_valid:
|
||||
logger.warning(f"HMAC validation failed for shop: {shop}")
|
||||
|
||||
return is_valid
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
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 jwt
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import Request, HTTPException, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.database import get_db
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ShopifySessionService:
|
||||
"""Service for handling Shopify session token validation"""
|
||||
|
||||
@staticmethod
|
||||
def get_session_token_from_request(request: Request) -> Optional[str]:
|
||||
"""
|
||||
Get session token from request header or URL parameter.
|
||||
Per Shopify docs, session token can be in:
|
||||
1. Authorization header: Bearer <token>
|
||||
2. URL query parameter: id_token
|
||||
"""
|
||||
# Try to get from Authorization header first
|
||||
auth_header = request.headers.get('authorization', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header.replace('Bearer ', '').strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
# Try to get from URL query parameter
|
||||
id_token = request.query_params.get('id_token')
|
||||
if id_token:
|
||||
return id_token
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def validate_session_token(token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate and decode Shopify session token.
|
||||
Returns decoded token payload if valid.
|
||||
Raises HTTPException if invalid.
|
||||
"""
|
||||
try:
|
||||
# Decode without verification first to get the shop
|
||||
unverified = jwt.decode(token, options={"verify_signature": False})
|
||||
|
||||
# Verify the token with Shopify's public key
|
||||
# Note: In production, you should fetch and cache Shopify's public keys
|
||||
# For now, we'll do basic validation
|
||||
decoded = jwt.decode(
|
||||
token,
|
||||
settings.SHOPIFY_API_SECRET,
|
||||
algorithms=["HS256"],
|
||||
audience=settings.SHOPIFY_API_KEY
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
exp = decoded.get('exp')
|
||||
if exp and exp < time.time():
|
||||
raise HTTPException(status_code=401, detail="Session token expired")
|
||||
|
||||
# Check nbf (not before)
|
||||
nbf = decoded.get('nbf')
|
||||
if nbf and nbf > time.time():
|
||||
raise HTTPException(status_code=401, detail="Session token not yet valid")
|
||||
|
||||
logger.info(f"Session token validated for shop: {decoded.get('dest')}")
|
||||
return decoded
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Session token expired")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Session token expired",
|
||||
headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}
|
||||
)
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid session token: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Invalid session token",
|
||||
headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_document_request(request: Request) -> bool:
|
||||
"""
|
||||
Check if this is a document request (initial page load) vs API request.
|
||||
Document requests typically don't have Authorization header.
|
||||
"""
|
||||
return not request.headers.get("authorization")
|
||||
|
||||
|
||||
# FastAPI dependency for session token validation
|
||||
async def require_shopify_session(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
FastAPI dependency to validate Shopify session token.
|
||||
Extracts shop from token and looks up organization.
|
||||
Returns decoded token with db_shop and organization_id.
|
||||
|
||||
Usage:
|
||||
@router.get("/some-endpoint")
|
||||
async def some_endpoint(
|
||||
shopify_session: dict = Depends(require_shopify_session),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
shop_id = shopify_session['shop_id']
|
||||
org_id = shopify_session['organization_id']
|
||||
db_shop = shopify_session['db_shop']
|
||||
# ... use the shop info
|
||||
"""
|
||||
from app.repositories.shopify_shop_repository import ShopifyShopRepository
|
||||
|
||||
session_token = ShopifySessionService.get_session_token_from_request(request)
|
||||
|
||||
if not session_token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Session token required for embedded app",
|
||||
headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}
|
||||
)
|
||||
|
||||
# Validate and decode token
|
||||
decoded = ShopifySessionService.validate_session_token(session_token)
|
||||
|
||||
# Extract shop domain from token
|
||||
shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '')
|
||||
|
||||
# Look up shop in database
|
||||
shop_repository = ShopifyShopRepository(db)
|
||||
db_shop = shop_repository.get_shop_by_domain(shop_domain)
|
||||
|
||||
if not db_shop or not db_shop.is_installed:
|
||||
raise HTTPException(status_code=403, detail="Shop not installed or not found")
|
||||
|
||||
# Add shop info to decoded token for easy access
|
||||
decoded['db_shop'] = db_shop
|
||||
decoded['shop_id'] = str(db_shop.id)
|
||||
decoded['shop_domain'] = shop_domain
|
||||
decoded['organization_id'] = db_shop.organization_id if db_shop.organization_id else None
|
||||
|
||||
logger.info(f"Session token validated for shop: {shop_domain}, org: {decoded['organization_id']}")
|
||||
|
||||
return decoded
|
||||
|
||||
|
||||
async def require_shopify_or_jwt_auth(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Hybrid authentication dependency that tries Shopify session token first,
|
||||
then falls back to JWT authentication.
|
||||
Returns authentication info with type indicator.
|
||||
|
||||
Usage:
|
||||
@router.get("/some-endpoint")
|
||||
async def some_endpoint(
|
||||
auth_info: dict = Depends(require_shopify_or_jwt_auth),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if auth_info['auth_type'] == 'shopify_session':
|
||||
org_id = auth_info['organization_id']
|
||||
shop_id = auth_info['shop_id']
|
||||
user_id = None # No user for session token auth
|
||||
else: # jwt
|
||||
org_id = auth_info['organization_id']
|
||||
user_id = auth_info['user_id']
|
||||
current_user = auth_info['current_user']
|
||||
"""
|
||||
from app.core.auth import get_current_user
|
||||
from app.repositories.shopify_shop_repository import ShopifyShopRepository
|
||||
|
||||
# First try Shopify session token
|
||||
session_token = ShopifySessionService.get_session_token_from_request(request)
|
||||
|
||||
if session_token:
|
||||
try:
|
||||
# Validate and decode the session token
|
||||
decoded = ShopifySessionService.validate_session_token(session_token)
|
||||
|
||||
# Extract shop domain from token
|
||||
shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '')
|
||||
|
||||
if shop_domain:
|
||||
# Look up shop in database
|
||||
shop_repository = ShopifyShopRepository(db)
|
||||
db_shop = shop_repository.get_shop_by_domain(shop_domain)
|
||||
|
||||
if db_shop and db_shop.is_installed and db_shop.organization_id:
|
||||
logger.info(f"Using Shopify session token auth for shop: {shop_domain}")
|
||||
return {
|
||||
'auth_type': 'shopify_session',
|
||||
'shop_domain': shop_domain,
|
||||
'shop_id': str(db_shop.id),
|
||||
'organization_id': db_shop.organization_id, # Keep as UUID for consistency
|
||||
'db_shop': db_shop,
|
||||
'decoded_token': decoded,
|
||||
'user_id': None # No user_id for session token auth
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Shopify session token validation failed, trying JWT: {str(e)}")
|
||||
|
||||
# Fall back to JWT authentication
|
||||
try:
|
||||
current_user = await get_current_user(request=request, db=db)
|
||||
logger.info(f"Using JWT auth for user: {current_user.id}")
|
||||
return {
|
||||
'auth_type': 'jwt',
|
||||
'organization_id': current_user.organization_id, # Keep as UUID for consistency
|
||||
'user_id': current_user.id,
|
||||
'current_user': current_user
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Both Shopify session token and JWT authentication failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
# Shop-scoped Shopify URLs. Every Admin API and OAuth call goes to the merchant's
|
||||
# own host (`{shop}.myshopify.com`), so there is no single base to swap. When
|
||||
# SHOPIFY_ADMIN_BASE is set (only the e2e suite does) the shop domain becomes a
|
||||
# path segment under it instead: `{base}/{shop_domain}/admin/...`.
|
||||
|
||||
from app.core.external_urls import external_base
|
||||
|
||||
SHOPIFY_API_VERSION = "2025-10"
|
||||
SHOPIFY_ADMIN_BASE = external_base("SHOPIFY_ADMIN_BASE", "")
|
||||
|
||||
|
||||
def shopify_shop_url(shop_domain: str, path: str) -> str:
|
||||
"""URL of `path` (no leading slash) on the shop's own host."""
|
||||
if SHOPIFY_ADMIN_BASE:
|
||||
return f"{SHOPIFY_ADMIN_BASE}/{shop_domain}/{path}"
|
||||
return f"https://{shop_domain}/{path}"
|
||||
|
||||
|
||||
def shopify_admin_url(shop_domain: str, path: str) -> str:
|
||||
"""Admin API URL, e.g. `shopify_admin_url(shop, "graphql.json")`."""
|
||||
return shopify_shop_url(shop_domain, f"admin/api/{SHOPIFY_API_VERSION}/{path}")
|
||||
|
||||
|
||||
def shopify_oauth_token_url(shop_domain: str) -> str:
|
||||
"""The shop's OAuth access-token endpoint (code and token exchange)."""
|
||||
return shopify_shop_url(shop_domain, "admin/oauth/access_token")
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
"""Slack App Home / Assistant lifecycle handlers.
|
||||
|
||||
These run as FastAPI background tasks (the webhook acks within 3s and hands off
|
||||
here), so each opens its own DB session. They cover the non-message surfaces:
|
||||
the Messages-tab welcome, the Home tab view, and the Assistant sidebar — all of
|
||||
which the Slack Marketplace requires an app with the Messages tab enabled to use.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from app.channels.slack import (
|
||||
SUGGESTED_PROMPTS_TITLE,
|
||||
STARTER_PROMPTS,
|
||||
WELCOME_NO_AGENT,
|
||||
WELCOME_WITH_AGENT,
|
||||
ASSISTANT_READY_STATUS,
|
||||
SlackAdapter,
|
||||
build_home_view,
|
||||
publish_home_view,
|
||||
set_assistant_status,
|
||||
set_suggested_prompts,
|
||||
slack_api,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.core.redis import get_redis
|
||||
from app.core.s3 import get_s3_signed_url
|
||||
from app.database import SessionLocal
|
||||
from app.repositories.agent import AgentRepository
|
||||
from app.repositories.channels import ChannelAccountRepository
|
||||
from app.repositories.channels.agent_config import AgentChannelConfigRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# One welcome per user, forever — this flag is never meant to expire (a re-welcome
|
||||
# on a Redis flush is harmless). SETNX; TTL only as a backstop against orphans.
|
||||
_WELCOME_FLAG_TTL_SECONDS = 60 * 60 * 24 * 365
|
||||
|
||||
|
||||
def _assigned_agent(db, account):
|
||||
"""The single agent answering this Slack workspace, or None if unassigned."""
|
||||
agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id)
|
||||
if not agent_id:
|
||||
return None
|
||||
return AgentRepository(db).get_agent(agent_id)
|
||||
|
||||
|
||||
def _welcome_text(agent) -> str:
|
||||
if agent is None:
|
||||
return WELCOME_NO_AGENT
|
||||
return WELCOME_WITH_AGENT.format(agent=agent.display_name or agent.name)
|
||||
|
||||
|
||||
def _already_welcomed(account_id, user_id: str) -> bool:
|
||||
"""Claim the first-welcome slot for (account, user). True if already welcomed.
|
||||
|
||||
Without Redis we can't dedupe, so we welcome (once per process start is the
|
||||
worst case) rather than stay silent — the review requires a welcome to be sent.
|
||||
"""
|
||||
redis_client = get_redis()
|
||||
if redis_client is None:
|
||||
return False
|
||||
try:
|
||||
key = f"slack_home_welcomed:{account_id}:{user_id}"
|
||||
claimed = redis_client.set(key, "1", nx=True, ex=_WELCOME_FLAG_TTL_SECONDS)
|
||||
return not claimed
|
||||
except Exception as e:
|
||||
logger.error(f"Slack welcome dedupe failed (welcoming anyway): {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def deliver_home_welcome(account_id: UUID, user_id: str, channel: str) -> None:
|
||||
"""Send the one-time welcome DM when a user first opens the Messages tab."""
|
||||
if not user_id or not channel:
|
||||
return
|
||||
if _already_welcomed(account_id, user_id):
|
||||
return
|
||||
db = SessionLocal()
|
||||
try:
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
if account is None or not account.is_active:
|
||||
return
|
||||
text = _welcome_text(_assigned_agent(db, account))
|
||||
await slack_api("chat.postMessage", SlackAdapter._access_token(account),
|
||||
{"channel": channel, "text": text})
|
||||
except Exception as e:
|
||||
logger.error(f"Slack Messages-tab welcome failed: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def handle_assistant_thread_started(account_id: UUID, channel_id: str, thread_ts: str) -> None:
|
||||
"""On assistant-sidebar open: show a status, offer starter prompts, welcome."""
|
||||
if not channel_id or not thread_ts:
|
||||
return
|
||||
db = SessionLocal()
|
||||
try:
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
if account is None or not account.is_active:
|
||||
return
|
||||
token = SlackAdapter._access_token(account)
|
||||
await set_assistant_status(token, channel_id, thread_ts, ASSISTANT_READY_STATUS)
|
||||
await set_suggested_prompts(token, channel_id, thread_ts, STARTER_PROMPTS, SUGGESTED_PROMPTS_TITLE)
|
||||
text = _welcome_text(_assigned_agent(db, account))
|
||||
await slack_api("chat.postMessage", token,
|
||||
{"channel": channel_id, "text": text, "thread_ts": thread_ts})
|
||||
except Exception as e:
|
||||
logger.error(f"Slack assistant welcome failed: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def _agent_card(agent) -> dict:
|
||||
"""Flatten an Agent into the resolved shape build_home_view expects."""
|
||||
photo_url = None
|
||||
customization = getattr(agent, "customization", None)
|
||||
if customization and customization.photo_url:
|
||||
photo_url = customization.photo_url
|
||||
if settings.S3_FILE_STORAGE:
|
||||
try:
|
||||
photo_url = await get_s3_signed_url(customization.photo_url)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not sign agent photo URL: {e}")
|
||||
instructions = agent.instructions or []
|
||||
return {
|
||||
"name": agent.display_name or agent.name,
|
||||
"is_active": agent.is_active,
|
||||
"instruction": instructions[0] if instructions else "",
|
||||
"photo_url": photo_url,
|
||||
}
|
||||
|
||||
|
||||
def _dashboard_url() -> str:
|
||||
return settings.FRONTEND_URL.rstrip("/")
|
||||
|
||||
|
||||
async def publish_agent_home(account_id: UUID, user_id: str) -> None:
|
||||
"""Publish the Home tab: what ReplyPier does, the agent connected to this
|
||||
workspace, how to use it, and a link to the dashboard."""
|
||||
if not user_id:
|
||||
return
|
||||
db = SessionLocal()
|
||||
try:
|
||||
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||||
if account is None or not account.is_active:
|
||||
return
|
||||
agent = _assigned_agent(db, account)
|
||||
card = await _agent_card(agent) if agent is not None else None
|
||||
view = build_home_view(card, _dashboard_url())
|
||||
await publish_home_view(SlackAdapter._access_token(account), user_id, view)
|
||||
except Exception as e:
|
||||
logger.error(f"Slack Home tab publish failed: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
SQL guardrails for the AI ticket investigator's database connector.
|
||||
|
||||
The model's SQL is untrusted input. Every guarantee here is enforced OUTSIDE
|
||||
the model, on the parsed AST (sqlglot):
|
||||
|
||||
- exactly one statement, and it must be a plain SELECT (no INTO, no locking)
|
||||
- every referenced table — including inside CTEs, subqueries, joins and
|
||||
unions — must be on the connector's explicit allowlist
|
||||
- dangerous functions (sleep/file/network/admin) are denied
|
||||
- a LIMIT is always enforced (added or clamped to the connector's max_rows)
|
||||
- row-scoped tables are rewritten into a subquery pre-filtered to the ticket
|
||||
customer's own rows, so the agent cannot read across customers
|
||||
- the statement is re-rendered from the AST, so only canonical SQL executes
|
||||
|
||||
The executor adds the second, independent barrier: a read-only transaction
|
||||
with a statement timeout. Column masking happens on the result rows.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, List, Optional, Sequence
|
||||
|
||||
import sqlglot
|
||||
from sqlglot import exp
|
||||
|
||||
MASK_VALUE = "***MASKED***"
|
||||
|
||||
# Time-wasting, file, network and admin functions. The read-only transaction
|
||||
# already blocks writes — this denylist is defense-in-depth against DoS and
|
||||
# data exfiltration via server-side functions.
|
||||
BLOCKED_FUNCTIONS = {
|
||||
# postgres
|
||||
"pg_sleep", "pg_sleep_for", "pg_sleep_until",
|
||||
"pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
|
||||
"pg_terminate_backend", "pg_cancel_backend", "pg_reload_conf",
|
||||
"pg_rotate_logfile", "set_config", "pg_promote",
|
||||
"dblink", "dblink_exec", "dblink_connect", "dblink_connect_u",
|
||||
"lo_import", "lo_export",
|
||||
"query_to_xml", "database_to_xml", "table_to_xml",
|
||||
"pg_logical_slot_get_changes", "pg_logical_slot_peek_changes",
|
||||
"pg_create_logical_replication_slot", "pg_drop_replication_slot",
|
||||
# mysql
|
||||
"sleep", "benchmark", "load_file", "get_lock", "release_lock",
|
||||
"master_pos_wait", "sys_eval", "sys_exec",
|
||||
}
|
||||
|
||||
# Statement/expression node types that must never appear anywhere in the tree.
|
||||
BLOCKED_NODE_TYPES = (
|
||||
exp.Insert, exp.Update, exp.Delete, exp.Merge,
|
||||
exp.Create, exp.Drop, exp.Alter, exp.TruncateTable,
|
||||
exp.Grant, exp.Command, exp.Transaction, exp.Set,
|
||||
exp.Into, exp.Lock,
|
||||
)
|
||||
|
||||
# Default schema names: a bare table reference resolves to these, so an
|
||||
# allowlist entry "public.orders" also authorizes "orders".
|
||||
DEFAULT_SCHEMAS = {"public", "dbo"}
|
||||
|
||||
_DIALECTS = {"postgresql": "postgres", "postgres": "postgres", "mysql": "mysql"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SqlValidationResult:
|
||||
ok: bool
|
||||
sql: Optional[str] = None # canonical SQL, only when ok
|
||||
reason: Optional[str] = None # block reason, only when not ok
|
||||
|
||||
|
||||
def _normalize_allowlist(allowed_tables: Iterable[str]) -> set:
|
||||
"""Expand allowlist entries so both qualified and bare references match
|
||||
when the schema is a default one."""
|
||||
allowed = set()
|
||||
for entry in allowed_tables or []:
|
||||
entry = (entry or "").strip().lower()
|
||||
if not entry:
|
||||
continue
|
||||
allowed.add(entry)
|
||||
if "." in entry:
|
||||
schema, _, name = entry.rpartition(".")
|
||||
if schema in DEFAULT_SCHEMAS:
|
||||
allowed.add(name)
|
||||
return allowed
|
||||
|
||||
|
||||
def _table_reference(table: exp.Table) -> str:
|
||||
name = (table.name or "").lower()
|
||||
if table.db:
|
||||
return f"{table.db.lower()}.{name}"
|
||||
return name
|
||||
|
||||
|
||||
def _cte_names(statement: exp.Expression) -> set:
|
||||
return {cte.alias_or_name.lower() for cte in statement.find_all(exp.CTE)}
|
||||
|
||||
|
||||
def _blocked_function(statement: exp.Expression) -> Optional[str]:
|
||||
for func in statement.find_all(exp.Func):
|
||||
name = (func.sql_name() or "").lower()
|
||||
if name in BLOCKED_FUNCTIONS:
|
||||
return name
|
||||
if isinstance(func, exp.Anonymous):
|
||||
anon = (func.name or "").lower()
|
||||
if anon in BLOCKED_FUNCTIONS:
|
||||
return anon
|
||||
return None
|
||||
|
||||
|
||||
def normalize_scope_map(row_scope: Optional[dict]) -> dict:
|
||||
"""Expand row-scope entries the same way as the allowlist, so a rule on
|
||||
"public.orders" also binds a bare "orders" reference."""
|
||||
scoped = {}
|
||||
for raw_table, raw_column in (row_scope or {}).items():
|
||||
table = (raw_table or "").strip().lower()
|
||||
column = (raw_column or "").strip()
|
||||
if not table or not column:
|
||||
continue
|
||||
scoped[table] = column
|
||||
if "." in table:
|
||||
schema, _, name = table.rpartition(".")
|
||||
if schema in DEFAULT_SCHEMAS:
|
||||
scoped.setdefault(name, column)
|
||||
return scoped
|
||||
|
||||
|
||||
def _apply_row_scope(
|
||||
statement: exp.Select,
|
||||
scoped: dict,
|
||||
scope_value: Optional[str],
|
||||
cte_names: set,
|
||||
case_insensitive: bool = False,
|
||||
) -> tuple:
|
||||
"""Rewrite every scoped base table into a pre-filtered subquery:
|
||||
|
||||
FROM orders o -> FROM (SELECT * FROM orders WHERE email = 'x') AS o
|
||||
|
||||
Filtering the *relation* rather than checking the model's WHERE clause is
|
||||
what makes this unbypassable. Whatever the model writes outside — OR 1=1,
|
||||
a UNION arm, a correlated subquery — it can only ever see rows the wrapper
|
||||
already returned. Validating a model-supplied predicate instead would mean
|
||||
proving that no OR, no join and no outer reference widens it, which is not
|
||||
a thing you can check reliably.
|
||||
|
||||
Returns (statement, error). Fails closed: a scoped table with no value to
|
||||
scope by is refused, never silently unscoped.
|
||||
"""
|
||||
targets = []
|
||||
for table in statement.find_all(exp.Table):
|
||||
reference = _table_reference(table)
|
||||
if not reference:
|
||||
continue
|
||||
if not table.db and reference in cte_names:
|
||||
continue
|
||||
column = scoped.get(reference)
|
||||
if column is None and table.name:
|
||||
column = scoped.get(table.name.lower())
|
||||
if column:
|
||||
targets.append((table, column))
|
||||
|
||||
if not targets:
|
||||
return statement, None
|
||||
|
||||
if not (scope_value or "").strip():
|
||||
return None, (
|
||||
"This table is restricted to the rows of the ticket's own customer, "
|
||||
"and this ticket has no customer identity to scope by. Set the "
|
||||
"ticket's customer, or query a table that is not customer-scoped."
|
||||
)
|
||||
|
||||
value = scope_value.strip()
|
||||
for table, column in targets:
|
||||
alias_name = table.alias or table.name
|
||||
inner_table = table.copy()
|
||||
inner_table.set("alias", None)
|
||||
column_ref = exp.Column(this=exp.to_identifier(column))
|
||||
# Emails are compared case-insensitively — the same person may be
|
||||
# stored as Bob@x.com here and bob@x.com there, and an exact match
|
||||
# would silently return nothing rather than their rows. Costs an index
|
||||
# on the raw column unless one exists on LOWER(col).
|
||||
predicate = exp.EQ(
|
||||
this=exp.Lower(this=column_ref) if case_insensitive else column_ref,
|
||||
# Literal.string escapes on render — never string-format a value
|
||||
# into SQL.
|
||||
expression=exp.Literal.string(value.lower() if case_insensitive else value),
|
||||
)
|
||||
inner = (
|
||||
exp.Select().select(exp.Star()).from_(inner_table).where(predicate)
|
||||
)
|
||||
table.replace(
|
||||
exp.Subquery(this=inner, alias=exp.TableAlias(this=exp.to_identifier(alias_name)))
|
||||
)
|
||||
return statement, None
|
||||
|
||||
|
||||
def _force_limit(statement: exp.Select, max_rows: int) -> exp.Select:
|
||||
"""Add a LIMIT, or clamp an existing numeric one, to max_rows."""
|
||||
limit_node = statement.args.get("limit")
|
||||
if limit_node is not None:
|
||||
literal = limit_node.expression
|
||||
if isinstance(literal, exp.Literal) and literal.is_int:
|
||||
if int(literal.this) <= max_rows:
|
||||
return statement
|
||||
return statement.limit(max_rows, copy=False)
|
||||
|
||||
|
||||
def validate_sql(
|
||||
raw_sql: str,
|
||||
allowed_tables: Sequence[str],
|
||||
max_rows: int,
|
||||
engine: str = "postgresql",
|
||||
masked_columns: Optional[Iterable[str]] = None,
|
||||
row_scope: Optional[dict] = None,
|
||||
scope_value: Optional[str] = None,
|
||||
scope_case_insensitive: bool = False,
|
||||
) -> SqlValidationResult:
|
||||
"""Validate untrusted SQL against the connector policy. Returns canonical
|
||||
SQL to execute, or the reason it was blocked.
|
||||
|
||||
row_scope maps a table to the column identifying the customer who owns the
|
||||
row ({"orders": "customer_email"}); scope_value is that customer's value
|
||||
for the ticket being investigated. Listed tables are rewritten so only
|
||||
their rows are visible."""
|
||||
dialect = _DIALECTS.get((engine or "").lower(), "postgres")
|
||||
if not (raw_sql or "").strip():
|
||||
return SqlValidationResult(ok=False, reason="Empty query")
|
||||
|
||||
try:
|
||||
statements = sqlglot.parse(raw_sql, read=dialect)
|
||||
except sqlglot.errors.ParseError as e:
|
||||
return SqlValidationResult(ok=False, reason=f"SQL could not be parsed: {e}")
|
||||
|
||||
statements = [s for s in statements if s is not None]
|
||||
if len(statements) != 1:
|
||||
return SqlValidationResult(
|
||||
ok=False, reason="Exactly one SQL statement is allowed"
|
||||
)
|
||||
statement = statements[0]
|
||||
|
||||
if not isinstance(statement, exp.Select):
|
||||
return SqlValidationResult(
|
||||
ok=False,
|
||||
reason="Only plain SELECT statements are allowed "
|
||||
"(wrap set operations like UNION in a subquery)",
|
||||
)
|
||||
|
||||
for node_type in BLOCKED_NODE_TYPES:
|
||||
found = statement.find(node_type)
|
||||
if found is not None:
|
||||
return SqlValidationResult(
|
||||
ok=False, reason=f"Disallowed SQL construct: {found.key.upper()}"
|
||||
)
|
||||
|
||||
blocked_func = _blocked_function(statement)
|
||||
if blocked_func is not None:
|
||||
return SqlValidationResult(ok=False, reason=f"Disallowed function: {blocked_func}")
|
||||
|
||||
allowed = _normalize_allowlist(allowed_tables)
|
||||
if not allowed:
|
||||
return SqlValidationResult(ok=False, reason="No tables are allowlisted for this connector")
|
||||
cte_names = _cte_names(statement)
|
||||
for table in statement.find_all(exp.Table):
|
||||
reference = _table_reference(table)
|
||||
if not reference:
|
||||
continue
|
||||
# References to CTE names defined in this query are fine — the CTE
|
||||
# bodies themselves are walked by this same loop.
|
||||
if not table.db and reference in cte_names:
|
||||
continue
|
||||
if reference not in allowed:
|
||||
return SqlValidationResult(
|
||||
ok=False, reason=f"Table not on the allowlist: {reference}"
|
||||
)
|
||||
|
||||
# Masked columns may not be referenced at all — output-name masking alone
|
||||
# is alias-bypassable (SELECT email AS x), and blocking references also
|
||||
# prevents value probing via WHERE clauses. SELECT * and t.* stay allowed:
|
||||
# they expand to real column names that mask_rows redacts by name.
|
||||
masked = {c.strip().lower() for c in masked_columns or [] if c and c.strip()}
|
||||
if masked:
|
||||
# Names that denote a whole row when used as a bare value.
|
||||
scope_names = {t.name.lower() for t in statement.find_all(exp.Table) if t.name}
|
||||
scope_names |= {
|
||||
t.alias.lower() for t in statement.find_all(exp.Table) if t.alias
|
||||
}
|
||||
scope_names |= cte_names
|
||||
|
||||
for column in statement.find_all(exp.Column):
|
||||
# A qualified/bare star (t.* or *) nested inside a function or cast
|
||||
# collapses the row into one value (to_jsonb(c.*), (c.*)::text),
|
||||
# defeating name-based masking — only a top-level projection star
|
||||
# is safe. Same for a bare Star node.
|
||||
if isinstance(column.this, exp.Star) and not isinstance(column.parent, exp.Select):
|
||||
return SqlValidationResult(
|
||||
ok=False,
|
||||
reason="Row expansion (t.*) inside an expression is not allowed with masked columns",
|
||||
)
|
||||
if (column.name or "").lower() in masked and not isinstance(column.this, exp.Star):
|
||||
return SqlValidationResult(
|
||||
ok=False,
|
||||
reason=f"Column '{column.name}' is masked and cannot be referenced",
|
||||
)
|
||||
# A bare (unqualified) reference to a table/alias/CTE name is a
|
||||
# whole-row value — SELECT customers, to_jsonb(c), c::text, row(c).
|
||||
# These serialize masked fields past name-based masking.
|
||||
if not column.table and (column.name or "").lower() in scope_names:
|
||||
return SqlValidationResult(
|
||||
ok=False,
|
||||
reason=(
|
||||
f"Whole-row reference to '{column.name}' is not allowed with "
|
||||
"masked columns — select individual columns instead"
|
||||
),
|
||||
)
|
||||
for star in statement.find_all(exp.Star):
|
||||
# Safe: top-level projection (SELECT *), the star of a t.* Column
|
||||
# node (handled above), and count(*) — a cardinality marker that
|
||||
# never exposes row values.
|
||||
if not isinstance(star.parent, (exp.Select, exp.Column, exp.Count)):
|
||||
return SqlValidationResult(
|
||||
ok=False,
|
||||
reason="Row expansion (*) inside an expression is not allowed with masked columns",
|
||||
)
|
||||
|
||||
# Last, so the rewrite can't perturb the checks above: they inspect what
|
||||
# the model actually wrote, not the wrappers added on its behalf.
|
||||
scoped = normalize_scope_map(row_scope)
|
||||
if scoped:
|
||||
statement, scope_error = _apply_row_scope(
|
||||
statement, scoped, scope_value, cte_names,
|
||||
case_insensitive=scope_case_insensitive,
|
||||
)
|
||||
if scope_error is not None:
|
||||
return SqlValidationResult(ok=False, reason=scope_error)
|
||||
|
||||
statement = _force_limit(statement, max_rows)
|
||||
# comments=False: comments are dropped from the canonical SQL — MySQL
|
||||
# executable comments (/*! ... */) must never reach the server.
|
||||
return SqlValidationResult(
|
||||
ok=True, sql=statement.sql(dialect=dialect, comments=False)
|
||||
)
|
||||
|
||||
|
||||
def mask_rows(
|
||||
columns: List[str], rows: List[tuple], masked_columns: Iterable[str]
|
||||
) -> List[tuple]:
|
||||
"""Replace values of masked columns (matched by column name, case
|
||||
insensitive) so PII never reaches the model."""
|
||||
masked = {c.strip().lower() for c in masked_columns or [] if c and c.strip()}
|
||||
if not masked:
|
||||
return rows
|
||||
mask_indexes = {i for i, col in enumerate(columns) if col.lower() in masked}
|
||||
if not mask_indexes:
|
||||
return rows
|
||||
return [
|
||||
tuple(MASK_VALUE if i in mask_indexes else value for i, value in enumerate(row))
|
||||
for row in rows
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Registering ReplyPier's webhook with a Telegram bot, with the updates it
|
||||
must receive. Done on connect, and again for an agent's bots when its reply
|
||||
buttons are turned on: a bot connected before buttons existed was registered
|
||||
without button taps, and Telegram keeps a bot's last registration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels import telegram as telegram_api
|
||||
from app.core.config import settings
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.core.logger import get_logger
|
||||
from app.models.channels import ChannelAccount, ChannelType
|
||||
from app.repositories.channels import AgentChannelConfigRepository, ChannelAccountRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def webhook_url(account_id) -> str:
|
||||
return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/telegram/{account_id}"
|
||||
|
||||
|
||||
async def register(account: ChannelAccount, bot_token: str) -> Tuple[bool, str]:
|
||||
return await telegram_api.set_webhook(bot_token, webhook_url(account.id), account.webhook_secret)
|
||||
|
||||
|
||||
async def reregister_for_agent(db: Session, agent_id: UUID) -> None:
|
||||
"""Register again, at once, every active Telegram bot routed to this agent
|
||||
— its buttons were just turned on."""
|
||||
try:
|
||||
accounts = ChannelAccountRepository(db)
|
||||
bots = [account for account in (accounts.get_by_id(config.channel_account_id)
|
||||
for config in AgentChannelConfigRepository(db).list_by_agent(agent_id))
|
||||
if _is_active_bot(account)]
|
||||
except Exception:
|
||||
# The agent is already saved; only the re-registration is lost
|
||||
logger.exception(f"Could not list agent {agent_id}'s Telegram bots to re-register")
|
||||
return
|
||||
await asyncio.gather(*(_reregister(account) for account in bots))
|
||||
|
||||
|
||||
async def reregister_if_buttons_on(account: ChannelAccount, agent) -> None:
|
||||
"""A bot just routed to an agent whose buttons are already on."""
|
||||
if _is_active_bot(account) and agent.channel_quick_replies:
|
||||
await _reregister(account)
|
||||
|
||||
|
||||
async def _reregister(account: ChannelAccount) -> None:
|
||||
"""Best effort: a bot that can't be registered keeps working — only its
|
||||
button taps don't arrive until it is (turn the agent's buttons off and on
|
||||
again, or reconnect the bot)."""
|
||||
try:
|
||||
bot_token = json.loads(decrypt_api_key(account.encrypted_credentials))["bot_token"]
|
||||
ok, error = await register(account, bot_token)
|
||||
except Exception as e:
|
||||
ok, error = False, f"{type(e).__name__}: {e}"
|
||||
if not ok:
|
||||
logger.warning(f"Could not re-register Telegram bot {account.id} for button taps: {error}")
|
||||
|
||||
|
||||
def _is_active_bot(account: Optional[ChannelAccount]) -> bool:
|
||||
return (account is not None and account.is_active
|
||||
and account.channel_type == ChannelType.TELEGRAM.value)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
AI-ticketing plan gating: paid-plan feature on cloud, unrestricted for
|
||||
OSS/self-hosted. Thin wrappers over the generic feature gate (the
|
||||
help-center pattern).
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.feature_gate import check_feature_access, feature_allowed
|
||||
|
||||
AI_TICKETING_FEATURE = "ai_ticketing"
|
||||
|
||||
UPGRADE_MESSAGE = (
|
||||
"AI Ticketing is not available in your current plan. "
|
||||
"Please upgrade to access this feature."
|
||||
)
|
||||
|
||||
|
||||
def ticketing_allowed(db: Session, organization_id: UUID) -> bool:
|
||||
"""Non-raising check — used by the chat-agent tool assembly and workers.
|
||||
Fails closed on gating errors."""
|
||||
return feature_allowed(db, organization_id, AI_TICKETING_FEATURE)
|
||||
|
||||
|
||||
def check_ticketing_access(db: Session, organization_id: UUID) -> None:
|
||||
"""Raising check for admin endpoints (403 with the upgrade message)."""
|
||||
check_feature_access(db, organization_id, AI_TICKETING_FEATURE, UPGRADE_MESSAGE)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Capture side of the ticket CSAT loop.
|
||||
|
||||
TicketService.close() asks the customer for a rating through the widget's
|
||||
existing conversation rating prompt. This module carries the answer back: every
|
||||
path that creates a Rating calls record_conversation_rating(), which attaches
|
||||
the score to the ticket linked to that session, if there is one.
|
||||
|
||||
Deliberately conversation-only. ratings.session_id is NOT NULL, so a rating
|
||||
cannot exist without a chat session — a manual or email-only ticket has nothing
|
||||
to rate. Supporting those would mean a public tokenised rating page and a
|
||||
session-less Rating; until that exists, such tickets are never asked and their
|
||||
CSAT card reads "not requested".
|
||||
|
||||
Best-effort throughout: a rating is a customer-facing success the moment it is
|
||||
stored, so a failure to link it must never fail the submission.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.models.rating import Rating
|
||||
from app.services.ticket import TicketService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def record_conversation_rating(db: Session, rating: Rating) -> Optional[UUID]:
|
||||
"""Link a just-submitted conversation rating to its ticket.
|
||||
|
||||
Returns the ticket id it was attached to, or None when the session has no
|
||||
native ticket (the common case for plain chats) or the linkage failed.
|
||||
"""
|
||||
try:
|
||||
service = TicketService(db)
|
||||
ticket = service.repo.get_by_session(rating.session_id)
|
||||
if ticket is None:
|
||||
return None
|
||||
service.record_csat(ticket, rating.id, rating.rating, rating.feedback)
|
||||
db.commit()
|
||||
return ticket.id
|
||||
except Exception as e:
|
||||
# The rating row itself is already committed by the caller, so rolling
|
||||
# back here only discards the half-applied ticket update.
|
||||
logger.error(f"Failed to link rating {rating.id} to its ticket: {e}")
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Direct email delivery for ticket notifications — the fallback when a ticket
|
||||
has no linked chat conversation to deliver through (manual tickets created
|
||||
from the dashboard). Reuses the email channel's SMTP plumbing: the org's
|
||||
connected email inbox when one exists (correct SPF/DKIM from their own
|
||||
domain), platform SMTP otherwise.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import secrets
|
||||
from email.message import EmailMessage
|
||||
from email.utils import make_msgid
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels.email import open_smtp, platform_smtp_config, smtp_config
|
||||
from app.core.config import settings
|
||||
from app.core.logger import get_logger
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.models.ticket import TICKET_NUMBER_PREFIX
|
||||
from app.repositories.channels.accounts import ChannelAccountRepository
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
EMAIL_CHANNEL_TYPE = "email"
|
||||
|
||||
# Message-ID local part for ticket mail: 'ticket-<uuid hex>.<unique>'. The
|
||||
# ticket id lives in the address itself, so a reply that quotes any of our
|
||||
# Message-IDs back in References identifies its ticket with no lookup table.
|
||||
# Every sent message still gets its own unique id — reusing one id across
|
||||
# messages makes Gmail collapse them into a single copy.
|
||||
_TICKET_MSGID_RE = re.compile(r"ticket-([0-9a-f]{32})(?:\.[^@\s>]+)?@", re.I)
|
||||
# The phantom id every ticket mail references. Never sent as a message itself:
|
||||
# it is the shared thread root that ties separate notifications together.
|
||||
_ROOT_SUFFIX = "root"
|
||||
_SUBJECT_TOKEN_RE = re.compile(rf"\[{TICKET_NUMBER_PREFIX}-(\d+)\]", re.I)
|
||||
|
||||
DEFAULT_MSGID_DOMAIN = "replypier.site"
|
||||
|
||||
|
||||
def _msgid_domain(from_email: Optional[str] = None) -> str:
|
||||
"""Right-hand side of generated Message-IDs. Cosmetic only — the parser
|
||||
matches on the local part, so mail sent from a customer's own inbox domain
|
||||
still threads back to its ticket."""
|
||||
candidate = from_email or settings.FROM_EMAIL or ""
|
||||
_, _, domain = candidate.rpartition("@")
|
||||
return domain.strip("> ").lower() or DEFAULT_MSGID_DOMAIN
|
||||
|
||||
|
||||
def ticket_root_message_id(ticket_id, from_email: Optional[str] = None) -> str:
|
||||
"""The stable per-ticket thread root, carried in References on every mail."""
|
||||
return f"<ticket-{UUID(str(ticket_id)).hex}.{_ROOT_SUFFIX}@{_msgid_domain(from_email)}>"
|
||||
|
||||
|
||||
def new_ticket_message_id(ticket_id, from_email: Optional[str] = None) -> str:
|
||||
"""A fresh Message-ID for one outbound ticket mail."""
|
||||
return (
|
||||
f"<ticket-{UUID(str(ticket_id)).hex}.{secrets.token_hex(8)}"
|
||||
f"@{_msgid_domain(from_email)}>"
|
||||
)
|
||||
|
||||
|
||||
def build_thread_headers(
|
||||
ticket_id, in_reply_to: Optional[str] = None, from_email: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Threading headers for one ticket notification.
|
||||
|
||||
References always starts at the ticket root so every notification for a
|
||||
ticket lands in one client-side thread; In-Reply-To points at the
|
||||
customer's most recent reply when we have seen one, so their client keeps
|
||||
the conversation in order.
|
||||
"""
|
||||
root = ticket_root_message_id(ticket_id, from_email)
|
||||
references: List[str] = [root]
|
||||
if in_reply_to and in_reply_to != root:
|
||||
references.append(in_reply_to)
|
||||
return {
|
||||
"Message-ID": new_ticket_message_id(ticket_id, from_email),
|
||||
"In-Reply-To": in_reply_to or root,
|
||||
"References": " ".join(references),
|
||||
}
|
||||
|
||||
|
||||
def ticket_ids_from_references(*values: Optional[str]) -> List[UUID]:
|
||||
"""Ticket ids encoded in In-Reply-To/References/Message-ID headers, most
|
||||
recently referenced first — References lists ancestors oldest-first, and
|
||||
the nearest ancestor is the better match."""
|
||||
found: List[UUID] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
for hex_id in _TICKET_MSGID_RE.findall(str(value)):
|
||||
try:
|
||||
ticket_id = UUID(hex_id)
|
||||
except ValueError:
|
||||
continue
|
||||
if ticket_id not in found:
|
||||
found.append(ticket_id)
|
||||
found.reverse()
|
||||
return found
|
||||
|
||||
|
||||
def ticket_number_from_subject(subject: Optional[str]) -> Optional[int]:
|
||||
"""The [TKT-12] token a customer's client keeps in the reply subject —
|
||||
the fallback when a mail client drops the threading headers."""
|
||||
if not subject:
|
||||
return None
|
||||
match = _SUBJECT_TOKEN_RE.search(str(subject))
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _org_email_account(db: Session, organization_id) -> Optional[ChannelAccount]:
|
||||
accounts = ChannelAccountRepository(db).list_by_org(organization_id, EMAIL_CHANNEL_TYPE)
|
||||
for account in accounts:
|
||||
if account.is_active:
|
||||
return account
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_smtp(db: Session, organization_id) -> dict:
|
||||
account = _org_email_account(db, organization_id)
|
||||
if account is not None:
|
||||
return smtp_config(account)
|
||||
return platform_smtp_config(settings.FROM_EMAIL)
|
||||
|
||||
|
||||
def _smtp_send(cfg: dict, message: EmailMessage) -> None:
|
||||
with open_smtp(cfg) as smtp:
|
||||
smtp.send_message(message)
|
||||
|
||||
|
||||
async def send_ticket_email(
|
||||
db: Session,
|
||||
organization_id,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
ticket_id=None,
|
||||
in_reply_to: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Send a plain-text ticket notification email. Returns False (and logs)
|
||||
on failure — ticket mutations must never fail because SMTP hiccuped.
|
||||
|
||||
With a ticket_id the mail carries ticket-scoped threading headers, so the
|
||||
customer's reply comes back identifiable as a reply to that ticket."""
|
||||
try:
|
||||
cfg = _resolve_smtp(db, organization_id)
|
||||
message = EmailMessage()
|
||||
message["From"] = cfg["from_email"]
|
||||
message["To"] = to_email
|
||||
message["Subject"] = subject
|
||||
if ticket_id is not None:
|
||||
for header, value in build_thread_headers(
|
||||
ticket_id, in_reply_to, cfg["from_email"]
|
||||
).items():
|
||||
message[header] = value
|
||||
else:
|
||||
message["Message-ID"] = make_msgid()
|
||||
message.set_content(body)
|
||||
await asyncio.to_thread(_smtp_send, cfg, message)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ticket email to {to_email} failed: {e}")
|
||||
return False
|
||||
@@ -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.
|
||||
|
||||
Inbound side of ticket email threading: decide whether a mail arriving on the
|
||||
email channel is a reply to one of our ticket notifications, and append it to
|
||||
that ticket instead of feeding it to the chat agent.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels.base import InboundMessage
|
||||
from app.core.logger import get_logger
|
||||
from app.database import SessionLocal
|
||||
from app.models.ticket import Ticket
|
||||
from app.repositories.ticket import TicketRepository
|
||||
from app.services.ticket_email import (
|
||||
ticket_ids_from_references,
|
||||
ticket_number_from_subject,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def find_ticket_for_reply(
|
||||
db: Session, organization_id: UUID, profile: Optional[dict]
|
||||
) -> Optional[Ticket]:
|
||||
"""The ticket an inbound email is replying to, or None.
|
||||
|
||||
Headers first: In-Reply-To/References carry a Message-ID we minted, and
|
||||
the ticket id is inside it — unguessable, so a match is proof the mail
|
||||
descends from a notification we sent.
|
||||
|
||||
The [TKT-n] subject token is the fallback for clients that drop threading
|
||||
headers. It is guessable, and a ticket's feed is read by agents and by the
|
||||
investigator AI, so that path additionally requires the sender to be the
|
||||
ticket's own customer; anything else falls through to the chat pipeline.
|
||||
"""
|
||||
profile = profile or {}
|
||||
repo = TicketRepository(db)
|
||||
|
||||
for ticket_id in ticket_ids_from_references(
|
||||
profile.get("in_reply_to"),
|
||||
profile.get("references"),
|
||||
):
|
||||
ticket = repo.get_by_id(ticket_id, organization_id)
|
||||
if ticket is not None:
|
||||
return ticket
|
||||
|
||||
number = ticket_number_from_subject(profile.get("subject"))
|
||||
if number is None:
|
||||
return None
|
||||
ticket = repo.get_by_number(organization_id, number)
|
||||
if ticket is None:
|
||||
return None
|
||||
sender = (profile.get("email") or "").strip().lower()
|
||||
customer_email = (getattr(ticket.customer, "email", "") or "").strip().lower()
|
||||
if sender and sender == customer_email:
|
||||
return ticket
|
||||
logger.info(
|
||||
f"Subject token named {ticket.display_number} but {sender or 'an unknown sender'} "
|
||||
"is not its customer — handling as a normal message"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def record_ticket_email_reply(
|
||||
organization_id: UUID, ticket_id: UUID, inbound: InboundMessage
|
||||
) -> None:
|
||||
"""Append an inbound email to its ticket. Runs from a BackgroundTask with
|
||||
its own DB session, mirroring process_channel_message."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ticket = TicketRepository(db).get_by_id(ticket_id, organization_id)
|
||||
if ticket is None:
|
||||
logger.warning(f"Email reply for unknown ticket {ticket_id}")
|
||||
return
|
||||
|
||||
from app.services.ticket import TicketService
|
||||
service = TicketService(db)
|
||||
profile = inbound.profile or {}
|
||||
service.record_customer_email_reply(
|
||||
ticket,
|
||||
body=inbound.text or "",
|
||||
from_email=inbound.external_user_id,
|
||||
message_id=profile.get("inbound_message_id") or None,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(ticket)
|
||||
|
||||
from app.services.ticket_events import emit_ticket_update
|
||||
await emit_ticket_update(
|
||||
organization_id, ticket.id, "comment", {"status": str(ticket.status)}
|
||||
)
|
||||
logger.info(f"Email reply appended to {ticket.display_number}")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to record email reply on ticket {ticket_id}: {e}")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Live ticket updates to the dashboard. Emitted into the per-org ticket room on
|
||||
the /agent namespace after a mutation commits; the frontend keeps a poll
|
||||
fallback, so a missed frame is never fatal.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.core.socketio import sio
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
ORG_TICKET_ROOM_PREFIX = "org_tickets_"
|
||||
|
||||
|
||||
def org_ticket_room(organization_id) -> str:
|
||||
return f"{ORG_TICKET_ROOM_PREFIX}{organization_id}"
|
||||
|
||||
|
||||
async def emit_ticket_update(
|
||||
organization_id: UUID,
|
||||
ticket_id: UUID,
|
||||
kind: str,
|
||||
payload: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""kind: 'created' | 'status' | 'triage' | 'comment' | 'run' | 'updated'."""
|
||||
try:
|
||||
await sio.emit(
|
||||
"ticket_update",
|
||||
{
|
||||
"ticket_id": str(ticket_id),
|
||||
"kind": kind,
|
||||
"payload": payload or {},
|
||||
},
|
||||
room=org_ticket_room(organization_id),
|
||||
namespace="/agent",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"ticket_update emit failed for {ticket_id}: {e}")
|
||||
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Phase 3 investigation engine: hypothesis-driven root-cause analysis with every
|
||||
tool call captured as a first-class InvestigationEvent row (the glass box).
|
||||
Orchestrated here, executed by the ticket_investigator worker.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.database import SessionLocal
|
||||
from app.models.investigation import (
|
||||
HypothesisStatus,
|
||||
InvestigationEvent,
|
||||
InvestigationEventType,
|
||||
InvestigationHypothesis,
|
||||
InvestigationRun,
|
||||
InvestigationRunStatus,
|
||||
RCADocument,
|
||||
)
|
||||
from app.models.ticket import Ticket
|
||||
from app.utils.guardrails import GuardrailAction, PIIDetector
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Per-hypothesis tool budget; the run-level max_tool_calls caps the total.
|
||||
HYPOTHESIS_TOOL_CALL_LIMIT = 6
|
||||
# Truncation applied at capture time — raw payloads never land in the DB.
|
||||
MAX_EVENT_INPUT_CHARS = 2000
|
||||
MAX_EVENT_RESULT_CHARS = 4000
|
||||
# Bounded time for the final RCA synthesis (outside the main wall clock so a
|
||||
# timed-out investigation can still produce a partial RCA).
|
||||
RCA_SYNTHESIS_TIMEOUT_SECONDS = 180
|
||||
|
||||
VALID_VERDICT_STATUSES = {
|
||||
HypothesisStatus.VALIDATED.value,
|
||||
HypothesisStatus.INVALIDATED.value,
|
||||
HypothesisStatus.INCONCLUSIVE.value,
|
||||
}
|
||||
|
||||
|
||||
def redact_snippet(text: str, max_chars: int) -> str:
|
||||
"""PII-redact and truncate a tool payload before it is persisted."""
|
||||
if not text:
|
||||
return ""
|
||||
result = PIIDetector.detect(text[: max_chars * 2], action=GuardrailAction.REDACT)
|
||||
redacted = result.redacted_text if result.redacted_text is not None else text
|
||||
redacted = redacted[:max_chars]
|
||||
return redacted
|
||||
|
||||
|
||||
class EvidenceRecorder:
|
||||
"""Writes InvestigationEvent rows as the investigator works.
|
||||
|
||||
Each write uses its own short-lived session so evidence is visible live
|
||||
(the glass-box UI polls during active runs) and survives a failed run.
|
||||
Also the run's tool-call meter: the budget check reads self.tool_calls.
|
||||
"""
|
||||
|
||||
def __init__(self, run_id: UUID, ticket_id: UUID):
|
||||
self.run_id = run_id
|
||||
self.ticket_id = ticket_id
|
||||
self.seq = 0
|
||||
self.tool_calls = 0
|
||||
self.hypothesis_id: Optional[UUID] = None
|
||||
# Per-hypothesis tool-call outcomes: {hypothesis_id: [total, errored]}.
|
||||
# A verdict is only worth as much as the evidence under it, and a run
|
||||
# where every query failed must not be able to assert a fact (#318).
|
||||
self.calls_by_hypothesis: dict = {}
|
||||
# Run-level totals, for the dashboard's connector banner.
|
||||
self.failed_tool_calls = 0
|
||||
# tool function name -> configured connector (MCP server) name.
|
||||
self.connector_by_function: dict = {}
|
||||
# In-memory digest for RCA synthesis, mirroring the persisted rows.
|
||||
self.evidence_lines: List[str] = []
|
||||
|
||||
def map_connectors(self, mcp_tools: list) -> None:
|
||||
for tool in mcp_tools or []:
|
||||
connector = getattr(tool, "_connector_name", None) or "MCP"
|
||||
for function_name in getattr(tool, "functions", {}) or {}:
|
||||
self.connector_by_function[function_name] = connector
|
||||
|
||||
def _write(self, **fields) -> None:
|
||||
self.seq += 1
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
InvestigationEvent(
|
||||
run_id=self.run_id,
|
||||
ticket_id=self.ticket_id,
|
||||
hypothesis_id=self.hypothesis_id,
|
||||
seq=self.seq,
|
||||
**fields,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record investigation event: {e}")
|
||||
|
||||
def record_phase(self, label: str) -> None:
|
||||
"""Phase marker — powers the live ticker line in the UI."""
|
||||
self._write(event_type=InvestigationEventType.PHASE, label=label[:300])
|
||||
|
||||
async def tool_hook(self, function_name: str, function, arguments: dict):
|
||||
"""agno tool hook: times the call and captures redacted input/result.
|
||||
Registered on the hypothesis-testing agents only."""
|
||||
# The Groq structured-output capture tool is plumbing, not evidence.
|
||||
if function_name == "json":
|
||||
return await function(**arguments)
|
||||
started = time.monotonic()
|
||||
error: Optional[str] = None
|
||||
result = None
|
||||
try:
|
||||
result = await function(**arguments)
|
||||
return result
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
raise
|
||||
finally:
|
||||
self.tool_calls += 1
|
||||
if error:
|
||||
self.failed_tool_calls += 1
|
||||
if self.hypothesis_id is not None:
|
||||
tally = self.calls_by_hypothesis.setdefault(self.hypothesis_id, [0, 0])
|
||||
tally[0] += 1
|
||||
if error:
|
||||
tally[1] += 1
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
try:
|
||||
input_text = json.dumps(arguments, default=str)
|
||||
except Exception:
|
||||
input_text = str(arguments)
|
||||
input_snippet = redact_snippet(input_text, MAX_EVENT_INPUT_CHARS)
|
||||
result_snippet = redact_snippet(str(result), MAX_EVENT_RESULT_CHARS) if result is not None else None
|
||||
self._write(
|
||||
event_type=InvestigationEventType.TOOL_CALL,
|
||||
tool_name=function_name[:200],
|
||||
connector_name=self.connector_by_function.get(function_name, "MCP")[:200],
|
||||
tool_input=input_snippet,
|
||||
tool_result=result_snippet,
|
||||
duration_ms=duration_ms,
|
||||
error=error[:2000] if error else None,
|
||||
)
|
||||
self.evidence_lines.append(
|
||||
f"[E{self.seq}] {function_name}({input_snippet[:300]}) -> "
|
||||
+ (f"ERROR: {error[:200]}" if error else (result_snippet or "")[:500])
|
||||
)
|
||||
|
||||
|
||||
def tool_outcomes(self, hypothesis_id: UUID) -> tuple:
|
||||
"""(total, errored) tool calls made while testing this hypothesis."""
|
||||
total, errored = self.calls_by_hypothesis.get(hypothesis_id, [0, 0])
|
||||
return total, errored
|
||||
|
||||
|
||||
def enforce_evidence_backed_verdict(
|
||||
hypothesis: InvestigationHypothesis, recorder: "EvidenceRecorder"
|
||||
) -> bool:
|
||||
"""Downgrade a definite verdict that no successful query supports.
|
||||
|
||||
The model returns status and confidence as free text, and nothing else
|
||||
checks them against what actually happened. A run whose every search
|
||||
errored — a missing index, a rejected client version — can still come back
|
||||
"the record does not exist, validated, 0.90", which a support agent would
|
||||
reasonably act on. Tool errors are the absence of evidence, never evidence
|
||||
of absence.
|
||||
|
||||
Only fires when the phase made tool calls and every one of them failed;
|
||||
a hypothesis reasoned from ticket context alone is left as the model
|
||||
returned it. Returns True when it changed the verdict.
|
||||
"""
|
||||
total, errored = recorder.tool_outcomes(hypothesis.id)
|
||||
if not total or errored < total:
|
||||
return False
|
||||
if str(hypothesis.status) == HypothesisStatus.INCONCLUSIVE.value:
|
||||
return False
|
||||
|
||||
original = str(hypothesis.status)
|
||||
confidence = hypothesis.confidence
|
||||
hypothesis.status = HypothesisStatus.INCONCLUSIVE
|
||||
hypothesis.confidence = 0.0
|
||||
# The model's own conclusion is dropped rather than kept: it reads as a
|
||||
# finding, and the RCA writer would quote it as one.
|
||||
hypothesis.conclusion = (
|
||||
f"Not tested — all {total} tool call{'' if total == 1 else 's'} in this phase "
|
||||
f"failed, so no evidence was gathered. The tester reported "
|
||||
f"\"{original}\"{f' at {confidence:.2f} confidence' if confidence is not None else ''}, "
|
||||
"which nothing supports. Check the connector errors in the evidence log."
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def hypotheses_digest(hypotheses: List[InvestigationHypothesis]) -> str:
|
||||
lines = []
|
||||
for h in hypotheses:
|
||||
lines.append(
|
||||
f"H{h.idx}: {h.title} — {h.status} (confidence {h.confidence if h.confidence is not None else 'n/a'})\n"
|
||||
f" {h.conclusion or h.rationale or ''}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def run_investigation_phases(
|
||||
db,
|
||||
run: InvestigationRun,
|
||||
ticket: Ticket,
|
||||
agent,
|
||||
context_message: str,
|
||||
mcp_tools: list,
|
||||
recorder: EvidenceRecorder,
|
||||
) -> Tuple[List[InvestigationHypothesis], bool]:
|
||||
"""Generate hypotheses, then test each within the run's tool budget.
|
||||
Mutates hypothesis rows (committed per step so the UI streams progress).
|
||||
Returns (hypotheses, budget_exhausted)."""
|
||||
recorder.record_phase("Generating hypotheses")
|
||||
plan = await agent.generate_hypotheses(context_message)
|
||||
if plan is None or not plan.hypotheses:
|
||||
raise RuntimeError("Hypothesis generation produced no result")
|
||||
|
||||
hypotheses: List[InvestigationHypothesis] = []
|
||||
for i, spec in enumerate(plan.hypotheses, start=1):
|
||||
hypothesis = InvestigationHypothesis(
|
||||
run_id=run.id,
|
||||
ticket_id=ticket.id,
|
||||
idx=i,
|
||||
title=spec.title[:300],
|
||||
rationale=spec.rationale,
|
||||
status=HypothesisStatus.PENDING,
|
||||
)
|
||||
db.add(hypothesis)
|
||||
hypotheses.append(hypothesis)
|
||||
db.commit()
|
||||
|
||||
budget_exhausted = False
|
||||
max_tool_calls = run.max_tool_calls if run.max_tool_calls is not None else 25
|
||||
for hypothesis in hypotheses:
|
||||
remaining_budget = max(0, max_tool_calls - recorder.tool_calls)
|
||||
if remaining_budget <= 0 and mcp_tools:
|
||||
budget_exhausted = True
|
||||
hypothesis.status = HypothesisStatus.INCONCLUSIVE
|
||||
hypothesis.confidence = 0.0
|
||||
hypothesis.conclusion = "Not tested — the run's tool-call budget was exhausted."
|
||||
db.commit()
|
||||
continue
|
||||
|
||||
recorder.hypothesis_id = hypothesis.id
|
||||
recorder.record_phase(f"Testing H{hypothesis.idx}: {hypothesis.title}")
|
||||
hypothesis.status = HypothesisStatus.TESTING
|
||||
db.commit()
|
||||
|
||||
verdict = await agent.test_hypothesis(
|
||||
context_message,
|
||||
hypothesis.title,
|
||||
hypothesis.rationale,
|
||||
mcp_tools=mcp_tools,
|
||||
tool_call_limit=min(HYPOTHESIS_TOOL_CALL_LIMIT, remaining_budget) if mcp_tools else None,
|
||||
tool_hooks=[recorder.tool_hook],
|
||||
)
|
||||
if verdict is not None and verdict.status in VALID_VERDICT_STATUSES:
|
||||
hypothesis.status = verdict.status
|
||||
hypothesis.confidence = max(0.0, min(1.0, verdict.confidence))
|
||||
hypothesis.conclusion = verdict.conclusion
|
||||
if enforce_evidence_backed_verdict(hypothesis, recorder):
|
||||
logger.warning(
|
||||
f"H{hypothesis.idx} claimed '{verdict.status}' with every tool "
|
||||
f"call failing; forced to inconclusive"
|
||||
)
|
||||
else:
|
||||
hypothesis.status = HypothesisStatus.INCONCLUSIVE
|
||||
hypothesis.confidence = 0.0
|
||||
# A provider that refused the request is a hard, deterministic
|
||||
# failure — saying "no parseable verdict" for it reads as a model
|
||||
# quality problem and hides a fixable connector issue (#303).
|
||||
hypothesis.conclusion = (
|
||||
getattr(agent, "last_provider_error", None)
|
||||
or "The tester produced no parseable verdict."
|
||||
)
|
||||
db.commit()
|
||||
recorder.hypothesis_id = None
|
||||
return hypotheses, budget_exhausted
|
||||
|
||||
|
||||
async def synthesize_and_store_rca(
|
||||
db,
|
||||
run: InvestigationRun,
|
||||
ticket: Ticket,
|
||||
agent,
|
||||
context_message: str,
|
||||
hypotheses: List[InvestigationHypothesis],
|
||||
recorder: EvidenceRecorder,
|
||||
partial: bool,
|
||||
) -> Optional[RCADocument]:
|
||||
"""Write the versioned RCA document from whatever evidence exists."""
|
||||
from app.repositories.ticket import InvestigationRepository
|
||||
|
||||
recorder.record_phase("Writing root-cause analysis")
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
agent.synthesize_rca(
|
||||
context_message,
|
||||
hypotheses_digest(hypotheses),
|
||||
"\n".join(recorder.evidence_lines[-60:]),
|
||||
partial=partial,
|
||||
),
|
||||
timeout=RCA_SYNTHESIS_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"RCA synthesis timed out for {ticket.display_number}")
|
||||
return None
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
rca = RCADocument(
|
||||
ticket_id=ticket.id,
|
||||
run_id=run.id,
|
||||
version=InvestigationRepository(db).next_rca_version(ticket.id),
|
||||
summary=result.summary,
|
||||
impact=result.impact,
|
||||
timeline=[entry.model_dump() for entry in result.timeline],
|
||||
investigation_log=result.investigation_log,
|
||||
contributing_factors=result.contributing_factors,
|
||||
conclusion=result.conclusion,
|
||||
remediation=result.remediation,
|
||||
prevention=result.prevention,
|
||||
customer_summary=result.customer_summary,
|
||||
confidence=max(0.0, min(1.0, result.confidence)),
|
||||
is_partial=partial,
|
||||
generated_by="ai",
|
||||
)
|
||||
db.add(rca)
|
||||
db.commit()
|
||||
return rca
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
One-way Jira escalation for native tickets: when enabled, tickets at or
|
||||
above the configured priority get a mirrored Jira issue (external_ref_*).
|
||||
Native ReplyPier stays the source of truth — nothing syncs back.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.models.jira import AgentJiraConfig, JiraToken
|
||||
from app.models.organization import Organization
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_PRIORITY_ORDER = {"urgent": 0, "high": 1, "medium": 2, "low": 3}
|
||||
|
||||
|
||||
def _find_project_config(db, ticket: Ticket) -> Optional[AgentJiraConfig]:
|
||||
"""Project/issue-type to escalate into: the ticket's agent's Jira config
|
||||
when enabled, else any enabled agent config in the organization."""
|
||||
from app.models.agent import Agent
|
||||
|
||||
if ticket.agent_id is not None:
|
||||
config = (
|
||||
db.query(AgentJiraConfig)
|
||||
.filter(
|
||||
AgentJiraConfig.agent_id == str(ticket.agent_id),
|
||||
AgentJiraConfig.enabled == True, # noqa: E712
|
||||
AgentJiraConfig.project_key.isnot(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if config is not None:
|
||||
return config
|
||||
return (
|
||||
db.query(AgentJiraConfig)
|
||||
.join(Agent, Agent.id == AgentJiraConfig.agent_id.cast(Agent.id.type))
|
||||
.filter(
|
||||
Agent.organization_id == ticket.organization_id,
|
||||
AgentJiraConfig.enabled == True, # noqa: E712
|
||||
AgentJiraConfig.project_key.isnot(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def should_escalate(ticket: Ticket, settings_row) -> bool:
|
||||
if not settings_row.jira_escalation_enabled:
|
||||
return False
|
||||
if ticket.external_ref_id: # already escalated — one-way, never re-synced
|
||||
return False
|
||||
minimum = str(settings_row.jira_escalation_priority or "urgent")
|
||||
return _PRIORITY_ORDER.get(str(ticket.priority), 99) <= _PRIORITY_ORDER.get(minimum, 0)
|
||||
|
||||
|
||||
async def maybe_escalate_to_jira(db, service, ticket: Ticket, settings_row) -> bool:
|
||||
"""Escalate when policy matches and the org has Jira connected. Failures
|
||||
are logged, never raised — escalation must not break ticket flows."""
|
||||
if not should_escalate(ticket, settings_row):
|
||||
return False
|
||||
try:
|
||||
token = (
|
||||
db.query(JiraToken)
|
||||
.filter(JiraToken.organization_id == ticket.organization_id)
|
||||
.first()
|
||||
)
|
||||
if token is None:
|
||||
return False
|
||||
config = _find_project_config(db, ticket)
|
||||
if config is None:
|
||||
logger.info(
|
||||
f"{ticket.display_number}: Jira escalation enabled but no agent "
|
||||
"has a Jira project configured"
|
||||
)
|
||||
return False
|
||||
|
||||
from app.api.jira import CreateJiraIssueModel
|
||||
from app.services.jira import JiraService
|
||||
|
||||
description_parts = [
|
||||
f"Escalated from ReplyPier ticket {ticket.display_number} "
|
||||
f"(priority: {ticket.priority}, severity: {ticket.severity or 'n/a'}).",
|
||||
]
|
||||
if ticket.ai_summary:
|
||||
description_parts.append(f"AI summary: {ticket.ai_summary}")
|
||||
if ticket.description:
|
||||
description_parts.append(ticket.description[:4000])
|
||||
|
||||
organization = db.query(Organization).filter(
|
||||
Organization.id == ticket.organization_id
|
||||
).first()
|
||||
result = await JiraService().create_issue(
|
||||
organization,
|
||||
db,
|
||||
CreateJiraIssueModel(
|
||||
projectKey=config.project_key,
|
||||
issueTypeId=config.issue_type_id,
|
||||
summary=f"[{ticket.display_number}] {ticket.title}"[:250],
|
||||
description="\n\n".join(description_parts),
|
||||
),
|
||||
)
|
||||
issue_key = result.get("key")
|
||||
if not issue_key:
|
||||
return False
|
||||
ticket.external_ref_type = "jira"
|
||||
ticket.external_ref_id = issue_key
|
||||
ticket.external_ref_url = f"{token.site_url}/browse/{issue_key}"
|
||||
service._add_activity(
|
||||
ticket,
|
||||
TicketActivityType.JIRA_ESCALATED,
|
||||
actor_type=TicketActorType.SYSTEM,
|
||||
body=f"Escalated to Jira as {issue_key}",
|
||||
metadata={"issue_key": issue_key, "url": ticket.external_ref_url},
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Jira escalation failed for {ticket.display_number}: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Cross-customer privacy guards for AI ticketing.
|
||||
|
||||
The investigator reasons over material that belongs to people other than the
|
||||
person it is writing to: similar past tickets from the same org, and rows from
|
||||
a connected database that has no row-level scoping. The same LLM call that
|
||||
reads all of that also writes `customer_summary`, which is delivered to the
|
||||
customer — at autonomy L3 with no human in the loop. Prompt wording alone can't
|
||||
be the control on that, so these are deterministic, applied at the boundary.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Iterable, Optional, Set
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.utils.guardrails import GuardrailAction, PIIDetector
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Identifiers that must never appear in a message we send out, no matter whose
|
||||
# they are — a support summary has no legitimate reason to restate them.
|
||||
ALWAYS_REDACT = ("ssn", "credit_card", "date_of_birth")
|
||||
|
||||
# Identifiers that are fine to echo back to their *owner* but not to anyone
|
||||
# else. Kept when they match the recipient, redacted otherwise.
|
||||
OWNER_ONLY = ("email", "phone")
|
||||
|
||||
THIRD_PARTY_MARKER = "[redacted]"
|
||||
|
||||
|
||||
def _digits(value: str) -> str:
|
||||
return re.sub(r"\D", "", value or "")
|
||||
|
||||
|
||||
def _owner_keys(identifiers: Iterable[Optional[str]]) -> Set[str]:
|
||||
"""Normalized forms of the recipient's own identifiers, so a match is not
|
||||
defeated by case or phone formatting."""
|
||||
keys: Set[str] = set()
|
||||
for raw in identifiers:
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
keys.add(value.lower())
|
||||
digits = _digits(value)
|
||||
# 7 digits filters out short numerics (order counts, amounts) that
|
||||
# would otherwise whitelist themselves as if they were the phone.
|
||||
if len(digits) >= 7:
|
||||
keys.add(digits)
|
||||
return keys
|
||||
|
||||
|
||||
def scrub_outbound(text: Optional[str], owner_identifiers: Iterable[Optional[str]] = ()) -> Optional[str]:
|
||||
"""Strip third-party identifiers from a message bound for the customer.
|
||||
|
||||
The recipient's own email/phone survive — telling someone their own address
|
||||
is not a leak, and blanking it makes legitimate summaries read as broken.
|
||||
Everything else that looks like an identifier is redacted, because in a
|
||||
ticket summary it can only have come from another customer's record.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
owners = _owner_keys(owner_identifiers)
|
||||
scrubbed = text
|
||||
|
||||
for pii_type in ALWAYS_REDACT:
|
||||
pattern = PIIDetector.PATTERNS.get(pii_type)
|
||||
if pattern:
|
||||
scrubbed = re.sub(pattern, THIRD_PARTY_MARKER, scrubbed)
|
||||
|
||||
for pii_type in OWNER_ONLY:
|
||||
pattern = PIIDetector.PATTERNS.get(pii_type)
|
||||
if not pattern:
|
||||
continue
|
||||
|
||||
def _replace(match: re.Match) -> str:
|
||||
value = match.group()
|
||||
if value.lower() in owners or (_digits(value) and _digits(value) in owners):
|
||||
return value
|
||||
return THIRD_PARTY_MARKER
|
||||
|
||||
scrubbed = re.sub(pattern, _replace, scrubbed)
|
||||
|
||||
if scrubbed != text:
|
||||
logger.warning(
|
||||
"Redacted third-party identifiers from an outbound ticket message"
|
||||
)
|
||||
return scrubbed
|
||||
|
||||
|
||||
def redact_reference_text(text: Optional[str]) -> Optional[str]:
|
||||
"""Strip identifiers from another customer's material before it becomes
|
||||
model context.
|
||||
|
||||
Similar past tickets are included to convey *what broke and how it was
|
||||
fixed*; the names, addresses and numbers in their titles carry none of that
|
||||
signal and are the most likely thing to be echoed into a summary written
|
||||
for a different person.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
result = PIIDetector.detect(text, action=GuardrailAction.REDACT)
|
||||
return result.redacted_text or text
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
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 re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.channels import get_adapter
|
||||
from app.channels.app_hooks import OutboundRecord, emit_channel_event, outbound_event
|
||||
from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE, MessageSource
|
||||
from app.services.channel_chat import placeholder_name
|
||||
from app.services.business_message import remember_business_message
|
||||
from app.services.channel_status import record_sent
|
||||
from app.services.chat_notifications import team_not_yet_notified
|
||||
from app.channels.meta_base import fetch_message_templates, graph_auth_refused, graph_detail
|
||||
from app.core.logger import get_logger
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.repositories.channels import (
|
||||
AgentChannelConfigRepository,
|
||||
ChannelAccountRepository,
|
||||
ChannelConversationRepository,
|
||||
)
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.customer import CustomerRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.utils.phone import normalize_phone, to_wa_id
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Business-initiated conversations are Utility/Authentication only. Marketing
|
||||
# is deliberately unsupported outbound: it carries opt-in obligations and
|
||||
# per-user caps (Graph error 131049) that a support product should not walk
|
||||
# its customers into. The reopen flow (an existing conversation) is unaffected.
|
||||
OUTBOUND_CATEGORIES = {"UTILITY", "AUTHENTICATION"}
|
||||
|
||||
_PLACEHOLDER = re.compile(r"\{\{(\d+)\}\}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutboundSent:
|
||||
"""A started (or rejoined) conversation and the template that opened it."""
|
||||
session_id: UUID
|
||||
message_id: int
|
||||
external_message_id: Optional[str]
|
||||
|
||||
|
||||
class OutboundError(Exception):
|
||||
"""A refused or failed outbound send. Carries the HTTP status the API
|
||||
route should answer with, so the Phase-2 scheduler can reuse the service
|
||||
without importing anything web-shaped."""
|
||||
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def _render_body(template: dict, components: Optional[list]) -> str:
|
||||
"""The message text as the customer will read it.
|
||||
|
||||
Graph matches body parameters to {{n}} placeholders positionally in
|
||||
ascending order (the same rule the frontend's previewTemplate applies —
|
||||
keep the two honest with each other). Falls back to the raw body when a
|
||||
parameter is missing rather than failing the send: Meta is the authority
|
||||
on completeness and will reject a genuinely short send itself.
|
||||
"""
|
||||
body = next((c.get("text", "") for c in template.get("components") or []
|
||||
if str(c.get("type", "")).upper() == "BODY"), "")
|
||||
values = [
|
||||
str(parameter.get("text", ""))
|
||||
for component in components or []
|
||||
if str(component.get("type", "")).lower() == "body"
|
||||
for parameter in component.get("parameters") or []
|
||||
]
|
||||
return _PLACEHOLDER.sub(
|
||||
lambda match: values[int(match.group(1)) - 1]
|
||||
if 0 < int(match.group(1)) <= len(values) else match.group(0),
|
||||
body,
|
||||
)
|
||||
|
||||
|
||||
async def _approved_outbound_template(db: Session, account: ChannelAccount,
|
||||
name: str, language: str,
|
||||
*, starts_conversation: bool) -> dict:
|
||||
"""The template this send is allowed to use, or the reason it isn't.
|
||||
|
||||
`starts_conversation` decides whether the Utility/Authentication rule
|
||||
applies. It is a rule about business-INITIATED contact, so it belongs only
|
||||
to a send that opens a thread with someone who never wrote to us. Reaching
|
||||
an existing conversation is the reopen case: the customer did message us,
|
||||
which is why Meta accepts Marketing there — and why the inbox picker has
|
||||
never been category-gated.
|
||||
"""
|
||||
repo = ChannelAccountRepository(db)
|
||||
credentials = repo.get_credentials(account)
|
||||
waba_id = credentials.get("waba_id")
|
||||
if not waba_id:
|
||||
raise OutboundError(400, "Reconnect this number with its WhatsApp "
|
||||
"Business Account ID to send templates")
|
||||
|
||||
ok, data = await fetch_message_templates(waba_id, credentials["access_token"])
|
||||
if not ok:
|
||||
if graph_auth_refused(data):
|
||||
repo.record_auth_failure(account, graph_detail(data, "Authentication Error"))
|
||||
raise OutboundError(502, "Could not read the account's templates from Meta")
|
||||
|
||||
template = next(
|
||||
(t for t in data if t.get("name") == name and t.get("language") == language),
|
||||
None,
|
||||
)
|
||||
if template is None:
|
||||
raise OutboundError(404, f"No template named {name} in {language} on this account")
|
||||
if str(template.get("status", "")).upper() != "APPROVED":
|
||||
raise OutboundError(400, f"Template {name} is not approved yet")
|
||||
if starts_conversation and \
|
||||
str(template.get("category", "")).upper() not in OUTBOUND_CATEGORIES:
|
||||
raise OutboundError(
|
||||
400,
|
||||
"Only Utility and Authentication templates can start a conversation. "
|
||||
"Marketing templates need the customer to have messaged you first.",
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
def _resolve_customer(db: Session, account: ChannelAccount, phone: str,
|
||||
customer_id: Optional[UUID], customer_name: Optional[str]):
|
||||
"""The person this conversation belongs to.
|
||||
|
||||
A picked person must actually OWN the number being messaged — the two
|
||||
arrive as independent fields, and binding a conversation to one person at
|
||||
another person's number is unrecoverable: the reply resolves by phone to
|
||||
somebody else, so history splits across two customers and the thread never
|
||||
appears on the profile of the person actually in it.
|
||||
|
||||
set_phone_if_absent's skip-and-log is right for the automatic inbound
|
||||
paths, where there is nobody to tell. Here there is a human waiting on a
|
||||
response, so a conflict is refused instead of silently ignored.
|
||||
"""
|
||||
repo = CustomerRepository(db)
|
||||
if customer_id is not None:
|
||||
customer = repo.get_by_id(customer_id)
|
||||
if customer is None or customer.organization_id != account.organization_id:
|
||||
raise OutboundError(404, "Customer not found")
|
||||
if customer.phone and customer.phone != phone:
|
||||
raise OutboundError(
|
||||
400,
|
||||
f"{customer.full_name or 'That person'} has a different number on "
|
||||
f"file. Send to their number, or update it on their profile first.",
|
||||
)
|
||||
owner = repo.get_customer_by_phone(phone, account.organization_id)
|
||||
if owner is not None and owner.id != customer.id:
|
||||
raise OutboundError(
|
||||
400,
|
||||
f"{phone} already belongs to {owner.full_name or 'another person'}. "
|
||||
f"Pick them instead, or clear the number from their profile.",
|
||||
)
|
||||
repo.set_phone_if_absent(customer, phone)
|
||||
return customer
|
||||
|
||||
wa_id = to_wa_id(phone)
|
||||
customer = repo.get_or_create_customer(
|
||||
email=f"{wa_id}@whatsapp.channel",
|
||||
organization_id=account.organization_id,
|
||||
full_name=customer_name or placeholder_name(account.channel_type, wa_id),
|
||||
phone=phone,
|
||||
)
|
||||
# First-touch source for People's Source column; setdefault semantics so a
|
||||
# person who arrived some other way keeps their original story.
|
||||
if customer.lead_source is None:
|
||||
customer.lead_source = {"channel": "whatsapp", "via": "outbound"}
|
||||
return customer
|
||||
|
||||
|
||||
async def start_outbound_conversation(
|
||||
db: Session,
|
||||
account: ChannelAccount,
|
||||
*,
|
||||
to: str,
|
||||
template_name: str,
|
||||
language: str = DEFAULT_TEMPLATE_LANGUAGE,
|
||||
components: Optional[list] = None,
|
||||
customer_id: Optional[UUID] = None,
|
||||
customer_name: Optional[str] = None,
|
||||
source: MessageSource,
|
||||
) -> OutboundSent:
|
||||
"""Start (or rejoin) a WhatsApp conversation by phone number.
|
||||
|
||||
Returns the session — everything downstream (the inbox thread, template
|
||||
resends, the AI answering the reply) already keys off one, which is why
|
||||
this hands one back instead of growing a parallel outbound code path —
|
||||
and the stored template message. `source` is who sent it (an inbox user,
|
||||
or an external app). The team is told about the thread only once the
|
||||
customer's reply needs them (notify_new_channel_chat_once), so a bulk
|
||||
send doesn't page anyone.
|
||||
|
||||
The conversation is created with last_inbound_at=None: no customer message
|
||||
means no 24-hour window, and pretending otherwise would let the AI send
|
||||
free-form text that Meta rejects.
|
||||
"""
|
||||
phone = normalize_phone(to)
|
||||
if phone is None:
|
||||
raise OutboundError(400, "Enter the number in international format, e.g. +91 12345 67890")
|
||||
wa_id = to_wa_id(phone)
|
||||
|
||||
agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id)
|
||||
if agent_id is None:
|
||||
raise OutboundError(400, "Route an agent to this number first — "
|
||||
"otherwise nobody would answer the reply")
|
||||
|
||||
# Resolved BEFORE the template is validated: whether this opens a thread or
|
||||
# rejoins one decides which rules the template must satisfy.
|
||||
conv_repo = ChannelConversationRepository(db)
|
||||
existing = conv_repo.get_active(account.id, wa_id)
|
||||
|
||||
template = await _approved_outbound_template(
|
||||
db, account, template_name, language, starts_conversation=existing is None)
|
||||
|
||||
if existing is not None:
|
||||
# An open conversation already carries this number; send there instead
|
||||
# of splitting the thread. The window may even be open, but a template
|
||||
# send inside the window is legal and simpler than branching.
|
||||
conversation, session_id = existing, existing.session_id
|
||||
customer = None
|
||||
else:
|
||||
customer = _resolve_customer(db, account, phone, customer_id, customer_name)
|
||||
session_id = uuid.uuid4()
|
||||
SessionToAgentRepository(db).create_session(
|
||||
session_id=session_id,
|
||||
agent_id=str(agent_id),
|
||||
customer_id=str(customer.id),
|
||||
organization_id=str(account.organization_id),
|
||||
channel=account.channel_type,
|
||||
)
|
||||
conversation = conv_repo.create(
|
||||
channel_account_id=account.id,
|
||||
channel_type=account.channel_type,
|
||||
external_conversation_id=wa_id,
|
||||
external_user_id=wa_id,
|
||||
session_id=session_id,
|
||||
organization_id=account.organization_id,
|
||||
agent_id=agent_id,
|
||||
customer_id=customer.id,
|
||||
last_inbound_at=None,
|
||||
extra=team_not_yet_notified(),
|
||||
)
|
||||
|
||||
adapter = get_adapter("whatsapp")
|
||||
result = await adapter.send_template(account, conversation, template_name,
|
||||
language, components)
|
||||
ChannelAccountRepository(db).track_send_result(account, result)
|
||||
if not result.ok:
|
||||
if existing is None:
|
||||
# A failed send must not leave an empty thread in the inbox: drop
|
||||
# the conversation, then the session it points at. The customer
|
||||
# row stays — harmless, and reused on the next attempt.
|
||||
#
|
||||
# Guarded, because an exception escaping here would replace the 502
|
||||
# (which names Meta's actual reason) with an opaque 500 AND leave
|
||||
# the empty thread this block exists to remove. Failing to clean up
|
||||
# is worth a log; losing the reason the send failed is not.
|
||||
from app.models.session_to_agent import SessionToAgent
|
||||
try:
|
||||
db.delete(conversation)
|
||||
db.query(SessionToAgent).filter(
|
||||
SessionToAgent.session_id == session_id).delete()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Could not roll back the empty outbound thread for "
|
||||
f"session {session_id}: {e}")
|
||||
raise OutboundError(502, result.error or "WhatsApp did not accept the message")
|
||||
|
||||
rendered = _render_body(template, components)
|
||||
stored = ChatRepository(db).create_message({
|
||||
"message": rendered,
|
||||
"message_type": "bot",
|
||||
"session_id": str(session_id),
|
||||
"organization_id": str(account.organization_id),
|
||||
"agent_id": str(agent_id),
|
||||
# A reused conversation row predating this feature can lack a customer.
|
||||
"customer_id": str(conversation.customer_id) if conversation.customer_id else None,
|
||||
"attributes": {
|
||||
"channel": account.channel_type,
|
||||
"external_message_id": result.external_message_id,
|
||||
"outbound_template": template_name,
|
||||
"source": source.value,
|
||||
},
|
||||
})
|
||||
if result.external_message_id:
|
||||
await record_sent(db, stored.id, account.id, result.external_message_id)
|
||||
# The AI reads this when the customer replies: a bare "yes" is meaningless
|
||||
# without knowing what we asked.
|
||||
remember_business_message(db, conversation, rendered, result.external_message_id)
|
||||
await emit_channel_event(outbound_event(
|
||||
organization_id=account.organization_id, channel_account_id=account.id,
|
||||
session_id=str(session_id),
|
||||
customer_id=str(conversation.customer_id) if conversation.customer_id else None,
|
||||
record=OutboundRecord(message_id=stored.id, external_message_id=result.external_message_id,
|
||||
source=source, text=rendered),
|
||||
))
|
||||
return OutboundSent(session_id=session_id, message_id=stored.id,
|
||||
external_message_id=result.external_message_id)
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
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 UUID
|
||||
from sqlalchemy.orm import Session
|
||||
from app.repositories.workflow import WorkflowRepository
|
||||
from app.repositories.agent import AgentRepository
|
||||
from app.models.workflow import Workflow
|
||||
from app.models.schemas.workflow import WorkflowCreate
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WorkflowService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.workflow_repo = WorkflowRepository(db)
|
||||
self.agent_repo = AgentRepository(db)
|
||||
|
||||
def create_workflow(self, workflow_data: WorkflowCreate, created_by: UUID, organization_id: UUID) -> Workflow:
|
||||
"""Create a new workflow with validation"""
|
||||
|
||||
# Validate agent exists and belongs to organization
|
||||
agent = self.agent_repo.get_by_id(workflow_data.agent_id)
|
||||
if not agent:
|
||||
raise ValueError("Agent not found")
|
||||
|
||||
if agent.organization_id != organization_id:
|
||||
raise ValueError("Agent does not belong to your organization")
|
||||
|
||||
# Check if workflow already exists for this agent
|
||||
existing_workflow = self.workflow_repo.get_workflows_by_agent(workflow_data.agent_id)
|
||||
if existing_workflow:
|
||||
raise ValueError("Agent already has a workflow. Only one workflow per agent is allowed.")
|
||||
|
||||
# Check if workflow name already exists in organization
|
||||
existing_workflow_name = self.workflow_repo.get_by_name(
|
||||
workflow_data.name,
|
||||
organization_id
|
||||
)
|
||||
if existing_workflow_name:
|
||||
raise ValueError(f"Workflow with name '{workflow_data.name}' already exists")
|
||||
|
||||
# Create workflow data
|
||||
workflow_dict = workflow_data.model_dump()
|
||||
workflow_dict.update({
|
||||
"created_by": created_by,
|
||||
"organization_id": organization_id
|
||||
})
|
||||
|
||||
# Create workflow
|
||||
workflow = self.workflow_repo.create_workflow(**workflow_dict)
|
||||
|
||||
logger.info(f"Created workflow {workflow.id} for agent {workflow_data.agent_id}")
|
||||
return workflow
|
||||
|
||||
def get_workflow_by_agent_id(self, agent_id: UUID, organization_id: UUID) -> Optional[Workflow]:
|
||||
"""Get workflow by agent ID with organization validation"""
|
||||
|
||||
# Validate agent exists and belongs to organization
|
||||
agent = self.agent_repo.get_by_id(agent_id)
|
||||
if not agent:
|
||||
raise ValueError("Agent not found")
|
||||
|
||||
if agent.organization_id != organization_id:
|
||||
raise ValueError("Agent does not belong to your organization")
|
||||
|
||||
# Get workflows for this agent
|
||||
workflows = self.workflow_repo.get_workflows_by_agent(agent_id)
|
||||
|
||||
# Return the first workflow (should be only one due to our constraint)
|
||||
return workflows[0] if workflows else None
|
||||
|
||||
def update_workflow(self, workflow_id: UUID, workflow_data: dict, organization_id: UUID) -> Optional[Workflow]:
|
||||
"""Update workflow with validation"""
|
||||
|
||||
# Get existing workflow
|
||||
workflow = self.workflow_repo.get_by_id(workflow_id)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not found")
|
||||
|
||||
# Verify organization access
|
||||
if workflow.organization_id != organization_id:
|
||||
raise ValueError("Workflow does not belong to your organization")
|
||||
|
||||
# If name is being updated, check for duplicates
|
||||
if 'name' in workflow_data and workflow_data['name'] != workflow.name:
|
||||
existing_workflow = self.workflow_repo.get_by_name(
|
||||
workflow_data['name'],
|
||||
organization_id
|
||||
)
|
||||
if existing_workflow and existing_workflow.id != workflow_id:
|
||||
raise ValueError(f"Workflow with name '{workflow_data['name']}' already exists")
|
||||
|
||||
# If agent_id is being updated, validate the new agent and check constraint
|
||||
if 'agent_id' in workflow_data and workflow_data['agent_id'] != workflow.agent_id:
|
||||
# Validate new agent exists and belongs to organization
|
||||
agent = self.agent_repo.get_by_id(workflow_data['agent_id'])
|
||||
if not agent:
|
||||
raise ValueError("Agent not found")
|
||||
|
||||
if agent.organization_id != organization_id:
|
||||
raise ValueError("Agent does not belong to your organization")
|
||||
|
||||
# Check if new agent already has a workflow
|
||||
existing_workflows = self.workflow_repo.get_workflows_by_agent(workflow_data['agent_id'])
|
||||
if existing_workflows:
|
||||
raise ValueError("Agent already has a workflow. Only one workflow per agent is allowed.")
|
||||
|
||||
# Check if status is being updated to published
|
||||
if 'status' in workflow_data and workflow_data['status'] == 'published':
|
||||
# When publishing, update the agent's active_workflow_id and use_workflow
|
||||
agent = self.agent_repo.get_by_id(workflow.agent_id)
|
||||
if agent:
|
||||
self.agent_repo.update_agent(
|
||||
workflow.agent_id,
|
||||
active_workflow_id=workflow_id,
|
||||
use_workflow=True
|
||||
)
|
||||
logger.info(f"Updated agent {workflow.agent_id} active_workflow_id to {workflow_id}")
|
||||
|
||||
# Check if status is being updated to draft (unpublished)
|
||||
if 'status' in workflow_data and workflow_data['status'] == 'draft':
|
||||
# When unpublishing, clear the agent's active_workflow_id and disable workflow
|
||||
agent = self.agent_repo.get_by_id(workflow.agent_id)
|
||||
if agent and agent.active_workflow_id == workflow_id:
|
||||
self.agent_repo.update_agent(
|
||||
workflow.agent_id,
|
||||
active_workflow_id=None,
|
||||
use_workflow=False
|
||||
)
|
||||
logger.info(f"Cleared agent {workflow.agent_id} active_workflow_id")
|
||||
|
||||
# Update workflow
|
||||
updated_workflow = self.workflow_repo.update_workflow(workflow_id, **workflow_data)
|
||||
|
||||
logger.info(f"Updated workflow {workflow_id}")
|
||||
return updated_workflow
|
||||
|
||||
def delete_workflow(self, workflow_id: UUID, organization_id: UUID) -> bool:
|
||||
"""Delete workflow with validation"""
|
||||
|
||||
# Get existing workflow
|
||||
workflow = self.workflow_repo.get_by_id(workflow_id)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not found")
|
||||
|
||||
# Verify organization access
|
||||
if workflow.organization_id != organization_id:
|
||||
raise ValueError("Workflow does not belong to your organization")
|
||||
|
||||
# Clear agent's active_workflow_id if this workflow is active
|
||||
agent = self.agent_repo.get_by_id(workflow.agent_id)
|
||||
if agent and agent.active_workflow_id == workflow_id:
|
||||
self.agent_repo.update_agent(
|
||||
workflow.agent_id,
|
||||
active_workflow_id=None,
|
||||
use_workflow=False
|
||||
)
|
||||
logger.info(f"Cleared agent {workflow.agent_id} active_workflow_id before deleting workflow")
|
||||
|
||||
# Delete workflow
|
||||
success = self.workflow_repo.delete_workflow(workflow_id)
|
||||
|
||||
if success:
|
||||
logger.info(f"Deleted workflow {workflow_id}")
|
||||
|
||||
return success
|
||||
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
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
|
||||
from app.core.logger import get_logger
|
||||
from app.agents.chat_agent import ChatAgent, ChatResponse
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.services.workflow_execution import WorkflowExecutionService
|
||||
from app.core.security import decrypt_api_key
|
||||
from app.models.schemas.chat import TransferReasonType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WorkflowChatService:
|
||||
"""Service for handling workflow-based chat processing"""
|
||||
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
self.chat_repo = ChatRepository(db)
|
||||
self.session_repo = SessionToAgentRepository(db)
|
||||
self.workflow_service = WorkflowExecutionService(db)
|
||||
|
||||
async def handle_workflow_chat(
|
||||
self,
|
||||
active_session,
|
||||
message: str,
|
||||
session_id: str,
|
||||
org_id: str,
|
||||
customer_id: str,
|
||||
session: dict,
|
||||
sio,
|
||||
namespace: str = '/widget',
|
||||
source: str = None
|
||||
) -> ChatResponse:
|
||||
"""
|
||||
Handle chat processing for workflow-enabled agents
|
||||
|
||||
Args:
|
||||
active_session: The active session object
|
||||
message: User message
|
||||
session_id: Session ID
|
||||
org_id: Organization ID
|
||||
customer_id: Customer ID
|
||||
session: Session data containing agent and AI config
|
||||
sio: SocketIO instance for emitting events
|
||||
namespace: SocketIO namespace
|
||||
|
||||
Returns:
|
||||
ChatResponse: The response from workflow execution
|
||||
"""
|
||||
|
||||
# Store user message in chat history
|
||||
self.chat_repo.create_message({
|
||||
"message": message,
|
||||
"message_type": "user",
|
||||
"session_id": session_id,
|
||||
"organization_id": org_id,
|
||||
"agent_id": session['agent_id'],
|
||||
"customer_id": customer_id,
|
||||
})
|
||||
|
||||
# Execute workflow
|
||||
workflow_result = await self.workflow_service.execute_workflow(
|
||||
session_id=session_id,
|
||||
user_message=message,
|
||||
workflow_id=active_session.workflow_id,
|
||||
current_node_id=active_session.current_node_id,
|
||||
workflow_state=active_session.workflow_state,
|
||||
api_key=decrypt_api_key(session['ai_config'].encrypted_api_key),
|
||||
model_name=session['ai_config'].model_name,
|
||||
model_type=session['ai_config'].model_type,
|
||||
base_url=(session['ai_config'].settings or {}).get('base_url'),
|
||||
org_id=org_id,
|
||||
agent_id=session['agent_id'],
|
||||
customer_id=customer_id,
|
||||
source=source
|
||||
)
|
||||
|
||||
if workflow_result.success:
|
||||
return await self._handle_successful_workflow(
|
||||
workflow_result, active_session, session_id, org_id,
|
||||
customer_id, session, sio, namespace
|
||||
)
|
||||
else:
|
||||
return await self._handle_failed_workflow(
|
||||
workflow_result, session_id, org_id, session, customer_id
|
||||
)
|
||||
|
||||
async def _handle_successful_workflow(
|
||||
self,
|
||||
workflow_result,
|
||||
active_session,
|
||||
session_id: str,
|
||||
org_id: str,
|
||||
customer_id: str,
|
||||
session: dict,
|
||||
sio,
|
||||
namespace: str
|
||||
) -> ChatResponse:
|
||||
"""Handle successful workflow execution"""
|
||||
|
||||
|
||||
|
||||
# Handle intermediate messages from MESSAGE nodes during automatic execution
|
||||
if workflow_result.intermediate_messages:
|
||||
await self._handle_intermediate_messages(
|
||||
workflow_result.intermediate_messages, session_id, org_id,
|
||||
session, customer_id, active_session, sio, namespace
|
||||
)
|
||||
|
||||
# Check if this is a form node that needs to display a form
|
||||
if workflow_result.form_data:
|
||||
await self._handle_form_display(workflow_result, session_id, sio, namespace)
|
||||
return None # Don't send a regular chat response for form display
|
||||
|
||||
# Create ChatResponse object from workflow result
|
||||
response = ChatResponse(
|
||||
message=workflow_result.message,
|
||||
transfer_to_human=workflow_result.transfer_to_human,
|
||||
transfer_reason=None,
|
||||
transfer_description=None,
|
||||
end_chat=workflow_result.end_chat,
|
||||
request_rating=workflow_result.request_rating,
|
||||
create_ticket=False
|
||||
)
|
||||
|
||||
# Handle transfer to human if requested by workflow LLM
|
||||
if workflow_result.transfer_to_human:
|
||||
transfer_response = await self._handle_workflow_transfer(
|
||||
workflow_result, session_id, org_id, session, customer_id, response
|
||||
)
|
||||
if transfer_response:
|
||||
return transfer_response
|
||||
|
||||
# Handle end chat if requested by workflow
|
||||
if workflow_result.end_chat:
|
||||
response = await self._handle_workflow_end_chat(
|
||||
response, session_id, org_id, session, customer_id
|
||||
)
|
||||
|
||||
# Only store message if there's actual content
|
||||
if workflow_result.message:
|
||||
self._store_workflow_response(
|
||||
response, session_id, org_id, session, customer_id,
|
||||
active_session, workflow_result
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
async def _handle_intermediate_messages(
|
||||
self,
|
||||
intermediate_messages,
|
||||
session_id: str,
|
||||
org_id: str,
|
||||
session: dict,
|
||||
customer_id: str,
|
||||
active_session,
|
||||
sio,
|
||||
namespace: str
|
||||
):
|
||||
"""Handle intermediate messages from MESSAGE nodes"""
|
||||
|
||||
logger.info(f"Found {len(intermediate_messages)} intermediate messages from MESSAGE nodes")
|
||||
|
||||
for intermediate_message in intermediate_messages:
|
||||
logger.debug(f"Emitting intermediate message: {intermediate_message}")
|
||||
|
||||
# Store intermediate message in chat history
|
||||
self.chat_repo.create_message({
|
||||
"message": intermediate_message,
|
||||
"message_type": "bot",
|
||||
"session_id": session_id,
|
||||
"organization_id": org_id,
|
||||
"agent_id": session['agent_id'],
|
||||
"customer_id": customer_id,
|
||||
"attributes": {
|
||||
"workflow_execution": True,
|
||||
"workflow_id": str(active_session.workflow_id),
|
||||
"intermediate_message": True,
|
||||
"message_node": True
|
||||
}
|
||||
})
|
||||
|
||||
# Emit intermediate message to client immediately
|
||||
await sio.emit('chat_response', {
|
||||
'message': intermediate_message,
|
||||
'type': 'chat_response',
|
||||
'transfer_to_human': False,
|
||||
'end_chat': False,
|
||||
'request_rating': False,
|
||||
'shopify_output': None
|
||||
}, room=session_id, namespace=namespace)
|
||||
|
||||
async def _handle_form_display(self, workflow_result, session_id: str, sio, namespace: str):
|
||||
"""Handle form display for form nodes"""
|
||||
|
||||
logger.info(f"Form data detected: {workflow_result.form_data}")
|
||||
logger.info("Emitting display_form event")
|
||||
|
||||
# Emit form display to client
|
||||
await sio.emit('display_form', {
|
||||
'form_data': workflow_result.form_data,
|
||||
'session_id': session_id
|
||||
}, room=session_id, namespace=namespace)
|
||||
|
||||
logger.info("Form display event emitted successfully")
|
||||
|
||||
async def _handle_workflow_transfer(
|
||||
self,
|
||||
workflow_result,
|
||||
session_id: str,
|
||||
org_id: str,
|
||||
session: dict,
|
||||
customer_id: str,
|
||||
response: ChatResponse
|
||||
) -> ChatResponse:
|
||||
"""Handle transfer to human requested by workflow"""
|
||||
|
||||
logger.info(f"Workflow LLM requested transfer to human for session {session_id}")
|
||||
|
||||
if not workflow_result.transfer_group_id:
|
||||
logger.warning(
|
||||
f"No transfer_group_id provided for workflow transfer in session {session_id}; "
|
||||
"falling back to the agent's default group")
|
||||
|
||||
chat_agent = await ChatAgent.create_async(
|
||||
api_key=decrypt_api_key(session['ai_config'].encrypted_api_key),
|
||||
model_name=session['ai_config'].model_name,
|
||||
model_type=session['ai_config'].model_type,
|
||||
base_url=(session['ai_config'].settings or {}).get('base_url'),
|
||||
org_id=org_id,
|
||||
agent_id=session['agent_id'],
|
||||
customer_id=customer_id,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
try:
|
||||
# Create ChatResponse object with transfer details from workflow
|
||||
llm_response = ChatResponse(
|
||||
message=workflow_result.message,
|
||||
transfer_to_human=True,
|
||||
transfer_reason=TransferReasonType(workflow_result.transfer_reason) if workflow_result.transfer_reason else None,
|
||||
transfer_description=workflow_result.transfer_description,
|
||||
end_chat=False,
|
||||
request_rating=False,
|
||||
create_ticket=False
|
||||
)
|
||||
|
||||
# A missing group id is passed through rather than short-circuited:
|
||||
# the transfer path then falls back to the agent's own default
|
||||
# group, as a plain agent transfer does. Setting the status alone
|
||||
# left group_id NULL, and an inbox matches on user_id or group_id —
|
||||
# so the handover was invisible to every agent it was meant for.
|
||||
transfer_response = await chat_agent.handle_workflow_transfer(
|
||||
session_id=session_id,
|
||||
org_id=org_id,
|
||||
agent_id=session['agent_id'],
|
||||
customer_id=customer_id,
|
||||
transfer_group_id=workflow_result.transfer_group_id,
|
||||
db=self.db,
|
||||
chat_repo=self.chat_repo,
|
||||
llm_response=llm_response
|
||||
)
|
||||
|
||||
logger.info(f"Transfer completed for session {session_id} to group {workflow_result.transfer_group_id}")
|
||||
# Mark this as a transfer response so widget_chat.py can handle it properly
|
||||
transfer_response._is_transfer_response = True
|
||||
return transfer_response
|
||||
except ValueError as e:
|
||||
# The agent has no groups at all, so there is nobody to route to.
|
||||
# Mark it transferred anyway — an admin can still see and claim it.
|
||||
logger.error(
|
||||
f"Workflow transfer for session {session_id} could not be routed to a group: {e}")
|
||||
self.session_repo.update_session_status(session_id, "TRANSFERRED")
|
||||
return response
|
||||
finally:
|
||||
await chat_agent.safe_cleanup_mcp_tools()
|
||||
|
||||
async def _handle_workflow_end_chat(
|
||||
self,
|
||||
response: ChatResponse,
|
||||
session_id: str,
|
||||
org_id: str,
|
||||
session: dict,
|
||||
customer_id: str
|
||||
) -> ChatResponse:
|
||||
"""Handle end chat requested by workflow"""
|
||||
|
||||
logger.info(f"Workflow requested end chat for session {session_id}")
|
||||
|
||||
# Create a ChatAgent instance to use the _handle_end_chat method
|
||||
chat_agent = await ChatAgent.create_async(
|
||||
api_key=decrypt_api_key(session['ai_config'].encrypted_api_key),
|
||||
model_name=session['ai_config'].model_name,
|
||||
model_type=session['ai_config'].model_type,
|
||||
base_url=(session['ai_config'].settings or {}).get('base_url'),
|
||||
org_id=org_id,
|
||||
agent_id=session['agent_id'],
|
||||
customer_id=customer_id,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
try:
|
||||
# Handle end chat using the agent's method with rating from response
|
||||
# The response.request_rating value comes from the workflow execution config
|
||||
return await chat_agent._handle_end_chat(response, session_id, self.db, response.request_rating)
|
||||
finally:
|
||||
await chat_agent.safe_cleanup_mcp_tools()
|
||||
|
||||
def _store_workflow_response(
|
||||
self,
|
||||
response: ChatResponse,
|
||||
session_id: str,
|
||||
org_id: str,
|
||||
session: dict,
|
||||
customer_id: str,
|
||||
active_session,
|
||||
workflow_result
|
||||
):
|
||||
"""Store workflow response in chat history"""
|
||||
|
||||
self.chat_repo.create_message({
|
||||
"message": response.message,
|
||||
"message_type": "bot",
|
||||
"session_id": session_id,
|
||||
"organization_id": org_id,
|
||||
"agent_id": session['agent_id'],
|
||||
"customer_id": customer_id,
|
||||
"attributes": {
|
||||
"workflow_execution": True,
|
||||
"workflow_id": str(active_session.workflow_id),
|
||||
"current_node_id": str(workflow_result.next_node_id) if workflow_result.next_node_id else None,
|
||||
"transfer_to_human": response.transfer_to_human,
|
||||
"end_chat": response.end_chat,
|
||||
"request_rating": response.request_rating,
|
||||
}
|
||||
})
|
||||
|
||||
async def _handle_failed_workflow(
|
||||
self,
|
||||
workflow_result,
|
||||
session_id: str,
|
||||
org_id: str,
|
||||
session: dict,
|
||||
customer_id: str
|
||||
) -> ChatResponse:
|
||||
"""Handle failed workflow execution"""
|
||||
|
||||
logger.error(f"Workflow execution failed: {workflow_result.error}")
|
||||
|
||||
response = ChatResponse(
|
||||
message=workflow_result.message or "I apologize, but I'm having trouble processing your request right now.",
|
||||
transfer_to_human=False,
|
||||
transfer_reason=None,
|
||||
transfer_description=None,
|
||||
end_chat=False,
|
||||
request_rating=False,
|
||||
create_ticket=False
|
||||
)
|
||||
|
||||
# Store error response in chat history
|
||||
self.chat_repo.create_message({
|
||||
"message": response.message,
|
||||
"message_type": "bot",
|
||||
"session_id": session_id,
|
||||
"organization_id": org_id,
|
||||
"agent_id": session['agent_id'],
|
||||
"customer_id": customer_id,
|
||||
"attributes": {
|
||||
"workflow_execution": True,
|
||||
"workflow_error": workflow_result.error,
|
||||
"transfer_to_human": response.transfer_to_human,
|
||||
"end_chat": response.end_chat,
|
||||
"request_rating": response.request_rating,
|
||||
}
|
||||
})
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
# Evaluates workflow condition nodes.
|
||||
#
|
||||
# The structure of a condition is fixed before any variable is looked up, and
|
||||
# variable values are only ever compared, never parsed. A visitor's answer such
|
||||
# as `x" OR "a" === "a` is therefore just text and cannot steer routing.
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Returned by resolve_variable when no value is stored under the name.
|
||||
MISSING = object()
|
||||
|
||||
OPERATORS = frozenset({"===", "!==", ">", "<", ">=", "<=", "includes", "startsWith", "endsWith"})
|
||||
_ALIASES = {"==": "===", "!=": "!==", "contains": "includes"}
|
||||
_NUMERIC = {
|
||||
">": lambda a, b: a > b,
|
||||
"<": lambda a, b: a < b,
|
||||
">=": lambda a, b: a >= b,
|
||||
"<=": lambda a, b: a <= b,
|
||||
}
|
||||
_NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")
|
||||
|
||||
|
||||
def resolve_variable(name: str, workflow_state: Optional[Dict[str, Any]]) -> Any:
|
||||
"""Look up a `{{name}}` value: `node_id.field`, a top-level variable such as
|
||||
user_message, or a field from any node (latest wins). MISSING if absent."""
|
||||
variables = (workflow_state or {}).get("variables") or {}
|
||||
node_variables = {k: v for k, v in variables.items() if isinstance(v, dict)}
|
||||
name = name.strip()
|
||||
|
||||
if "." in name:
|
||||
node_id, field_name = (part.strip() for part in name.split(".", 1))
|
||||
scoped = node_variables.get(node_id, {})
|
||||
return scoped[field_name] if field_name in scoped else MISSING
|
||||
|
||||
if name in variables and name not in node_variables:
|
||||
return variables[name]
|
||||
for node_id in reversed(list(node_variables)):
|
||||
if name in node_variables[node_id]:
|
||||
return node_variables[node_id][name]
|
||||
return MISSING
|
||||
|
||||
|
||||
def _as_text(value: Any) -> Optional[str]:
|
||||
if value is MISSING or value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _as_number(text: str) -> Optional[float]:
|
||||
return float(text) if _NUMBER.fullmatch(text) else None
|
||||
|
||||
|
||||
def compare(left: Any, operator: str, right: Any) -> bool:
|
||||
"""Apply one operator to two values. Case-sensitive; surrounding whitespace
|
||||
is ignored. A missing value only satisfies `!==`."""
|
||||
operator = _ALIASES.get(operator, operator)
|
||||
if operator not in OPERATORS:
|
||||
logger.warning(f"Unknown condition operator '{operator}', treating as false")
|
||||
return False
|
||||
|
||||
left_text, right_text = _as_text(left), _as_text(right)
|
||||
if operator == "!==":
|
||||
return left_text != right_text
|
||||
if left_text is None or right_text is None:
|
||||
return False
|
||||
if operator == "===":
|
||||
return left_text == right_text
|
||||
if operator == "includes":
|
||||
return right_text in left_text
|
||||
if operator == "startsWith":
|
||||
return left_text.startswith(right_text)
|
||||
if operator == "endsWith":
|
||||
return left_text.endswith(right_text)
|
||||
|
||||
left_num, right_num = _as_number(left_text), _as_number(right_text)
|
||||
if left_num is None or right_num is None:
|
||||
logger.debug(f"Non-numeric '{operator}' comparison is false: {left_text!r} vs {right_text!r}")
|
||||
return False
|
||||
return _NUMERIC[operator](left_num, right_num)
|
||||
|
||||
|
||||
# --- Builder groups -------------------------------------------------------
|
||||
|
||||
def _rule_is_complete(rule: Any) -> bool:
|
||||
# Mirrors the dashboard, which leaves half-filled rules out of the expression.
|
||||
if not isinstance(rule, dict):
|
||||
return False
|
||||
value = rule.get("value")
|
||||
return bool(rule.get("variable")) and bool(rule.get("operator")) and value is not None and str(value).strip() != ""
|
||||
|
||||
|
||||
def _evaluate_rule(rule: Dict[str, Any], workflow_state: Dict[str, Any]) -> bool:
|
||||
name = str(rule["variable"]).strip().removeprefix("{{").removesuffix("}}")
|
||||
left = resolve_variable(name, workflow_state)
|
||||
if left is MISSING:
|
||||
logger.warning(f"Condition variable '{name}' has no value")
|
||||
return compare(left, str(rule["operator"]), rule["value"])
|
||||
|
||||
|
||||
def evaluate_groups(groups: List[Any], workflow_state: Dict[str, Any]) -> Optional[bool]:
|
||||
"""Groups are OR'ed; rules within a group use its logicalOperator (AND by
|
||||
default). None when no group holds a complete rule."""
|
||||
results = []
|
||||
for group in groups:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
rules = [rule for rule in (group.get("rules") or []) if _rule_is_complete(rule)]
|
||||
if not rules:
|
||||
continue
|
||||
outcomes = [_evaluate_rule(rule, workflow_state) for rule in rules]
|
||||
any_rule = str(group.get("logicalOperator", "AND")).upper() == "OR"
|
||||
results.append(any(outcomes) if any_rule else all(outcomes))
|
||||
return any(results) if results else None
|
||||
|
||||
|
||||
# --- Legacy expression strings ---------------------------------------------
|
||||
|
||||
class ConditionSyntaxError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_TOKEN = re.compile(r"""
|
||||
\s*(?:
|
||||
(?P<var>\{\{[^{}]+\}\})
|
||||
| (?P<str>"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')
|
||||
| (?P<method>\.(?:includes|startsWith|endsWith)\()
|
||||
| (?P<op>===|!==|==|!=|>=|<=|>|<|&&|\|\|)
|
||||
| (?P<paren>[()])
|
||||
| (?P<word>[^\s(){}"'=!<>&|.]+(?:\.(?!(?:includes|startsWith|endsWith)\()[^\s(){}"'=!<>&|.]+)*)
|
||||
)\s*""", re.VERBOSE)
|
||||
_METHODS = frozenset({"includes", "startsWith", "endsWith"})
|
||||
_WORD_KEYWORDS = {"AND": ("and", None), "OR": ("or", None), "contains": ("op", "contains")}
|
||||
|
||||
Token = Tuple[str, Optional[str]]
|
||||
|
||||
|
||||
def _tokenize(expression: str) -> List[Token]:
|
||||
tokens: List[Token] = []
|
||||
pos = 0
|
||||
while pos < len(expression):
|
||||
match = _TOKEN.match(expression, pos)
|
||||
if not match or match.end() == pos:
|
||||
if expression[pos:].strip():
|
||||
raise ConditionSyntaxError(f"unexpected text at position {pos}")
|
||||
break
|
||||
pos = match.end()
|
||||
kind = match.lastgroup
|
||||
text = match.group(kind)
|
||||
if kind == "var":
|
||||
tokens.append(("var", text[2:-2].strip()))
|
||||
elif kind == "str":
|
||||
tokens.append(("lit", re.sub(r"\\(.)", r"\1", text[1:-1])))
|
||||
elif kind == "method":
|
||||
tokens.append(("op", text[1:-1]))
|
||||
elif kind == "op" and text in ("&&", "||"):
|
||||
tokens.append(("and" if text == "&&" else "or", None))
|
||||
elif kind == "paren":
|
||||
tokens.append((text, None))
|
||||
elif kind == "word":
|
||||
tokens.append(_WORD_KEYWORDS.get(text, ("lit", text)))
|
||||
else:
|
||||
tokens.append((kind, text))
|
||||
return tokens
|
||||
|
||||
|
||||
class _Parser:
|
||||
"""or := and (OR and)* ; and := primary (AND primary)* ;
|
||||
primary := '(' or ')' | operand op operand | operand .method( operand )"""
|
||||
|
||||
def __init__(self, tokens: List[Token]):
|
||||
self.tokens = tokens
|
||||
self.pos = 0
|
||||
|
||||
def parse(self):
|
||||
if not self.tokens:
|
||||
raise ConditionSyntaxError("empty condition")
|
||||
node = self._or()
|
||||
if self.pos != len(self.tokens):
|
||||
raise ConditionSyntaxError("unexpected trailing tokens")
|
||||
return node
|
||||
|
||||
def _peek(self) -> Optional[str]:
|
||||
return self.tokens[self.pos][0] if self.pos < len(self.tokens) else None
|
||||
|
||||
def _take(self, kind: str) -> Token:
|
||||
if self._peek() != kind:
|
||||
raise ConditionSyntaxError(f"expected {kind}")
|
||||
token = self.tokens[self.pos]
|
||||
self.pos += 1
|
||||
return token
|
||||
|
||||
def _or(self):
|
||||
items = [self._and()]
|
||||
while self._peek() == "or":
|
||||
self.pos += 1
|
||||
items.append(self._and())
|
||||
return ("or", items) if len(items) > 1 else items[0]
|
||||
|
||||
def _and(self):
|
||||
items = [self._primary()]
|
||||
while self._peek() == "and":
|
||||
self.pos += 1
|
||||
items.append(self._primary())
|
||||
return ("and", items) if len(items) > 1 else items[0]
|
||||
|
||||
def _primary(self):
|
||||
if self._peek() == "(":
|
||||
self.pos += 1
|
||||
node = self._or()
|
||||
self._take(")")
|
||||
return node
|
||||
left = self._operand()
|
||||
_, operator = self._take("op")
|
||||
right = self._operand()
|
||||
if operator in _METHODS:
|
||||
self._take(")") # closes `.includes(`
|
||||
return ("cmp", left, operator, right)
|
||||
|
||||
def _operand(self) -> Token:
|
||||
if self._peek() in ("var", "lit"):
|
||||
token = self.tokens[self.pos]
|
||||
self.pos += 1
|
||||
return token
|
||||
raise ConditionSyntaxError("expected a value")
|
||||
|
||||
|
||||
def _evaluate_node(node, workflow_state: Dict[str, Any]) -> bool:
|
||||
kind = node[0]
|
||||
if kind == "or":
|
||||
return any(_evaluate_node(item, workflow_state) for item in node[1])
|
||||
if kind == "and":
|
||||
return all(_evaluate_node(item, workflow_state) for item in node[1])
|
||||
_, left, operator, right = node
|
||||
return compare(_operand_value(left, workflow_state), operator, _operand_value(right, workflow_state))
|
||||
|
||||
|
||||
def _operand_value(token: Token, workflow_state: Dict[str, Any]) -> Any:
|
||||
kind, text = token
|
||||
if kind == "lit":
|
||||
return text
|
||||
value = resolve_variable(text, workflow_state)
|
||||
if value is MISSING:
|
||||
logger.warning(f"Condition variable '{text}' has no value")
|
||||
return value
|
||||
|
||||
|
||||
def evaluate_expression(expression: str, workflow_state: Dict[str, Any]) -> bool:
|
||||
"""Evaluate a stored `condition_expression`. Unreadable conditions are false."""
|
||||
try:
|
||||
tree = _Parser(_tokenize(expression)).parse()
|
||||
except (ConditionSyntaxError, RecursionError) as e:
|
||||
logger.warning(f"Unreadable condition {expression!r} ({e}), treating as false")
|
||||
return False
|
||||
return _evaluate_node(tree, workflow_state)
|
||||
|
||||
|
||||
def evaluate_condition_config(config: Dict[str, Any], workflow_state: Dict[str, Any]) -> Optional[bool]:
|
||||
"""Evaluate a condition node's config, preferring the builder's groups over
|
||||
the generated expression. None when neither holds a usable condition."""
|
||||
groups = config.get("condition_groups")
|
||||
if isinstance(groups, list):
|
||||
result = evaluate_groups(groups, workflow_state)
|
||||
if result is not None:
|
||||
return result
|
||||
expression = config.get("condition_expression")
|
||||
if isinstance(expression, str) and expression.strip():
|
||||
return evaluate_expression(expression, workflow_state)
|
||||
return None
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,463 @@
|
||||
"""
|
||||
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, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
import uuid
|
||||
import re
|
||||
from sqlalchemy.orm import Session
|
||||
from app.repositories.workflow_node import WorkflowNodeRepository
|
||||
from app.repositories.workflow import WorkflowRepository
|
||||
|
||||
from app.core.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def sanitize_utf8_text(text: str) -> str:
|
||||
"""
|
||||
Sanitize text to remove invalid UTF-8 characters and surrogates
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# Remove surrogate characters (like \udcac, \udd16)
|
||||
text = re.sub(r'[\ud800-\udfff]', '', text)
|
||||
|
||||
# Encode to UTF-8 and decode to handle any remaining issues
|
||||
try:
|
||||
text = text.encode('utf-8', errors='ignore').decode('utf-8')
|
||||
except UnicodeError:
|
||||
# If still failing, remove all non-ASCII characters
|
||||
text = re.sub(r'[^\x00-\x7F]', '', text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
class WorkflowNodeService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.node_repo = WorkflowNodeRepository(db)
|
||||
self.workflow_repo = WorkflowRepository(db)
|
||||
|
||||
def _generate_unique_node_name(self, workflow_id: UUID, base_name: str, exclude_node_id: UUID = None) -> str:
|
||||
"""
|
||||
Generate a unique node name by appending _001, _002, etc. if needed
|
||||
|
||||
Args:
|
||||
workflow_id: The workflow ID to check within
|
||||
base_name: The base name to make unique
|
||||
exclude_node_id: Node ID to exclude from uniqueness check (for updates)
|
||||
|
||||
Returns:
|
||||
Unique node name
|
||||
"""
|
||||
try:
|
||||
# Get all existing nodes in the workflow
|
||||
workflow = self.workflow_repo.get_by_id(workflow_id)
|
||||
if not workflow:
|
||||
return base_name
|
||||
|
||||
existing_names = set()
|
||||
for node in workflow.nodes:
|
||||
if exclude_node_id and node.id == exclude_node_id:
|
||||
continue # Skip the node being updated
|
||||
if node.name:
|
||||
existing_names.add(node.name.lower())
|
||||
|
||||
# Check if base name is already unique
|
||||
if base_name.lower() not in existing_names:
|
||||
return base_name
|
||||
|
||||
# Generate unique name with suffix
|
||||
counter = 1
|
||||
while True:
|
||||
candidate_name = f"{base_name}_{counter:03d}"
|
||||
if candidate_name.lower() not in existing_names:
|
||||
return candidate_name
|
||||
counter += 1
|
||||
|
||||
# Safety check to prevent infinite loop
|
||||
if counter > 999:
|
||||
# Fallback to UUID suffix
|
||||
return f"{base_name}_{str(uuid.uuid4())[:8]}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating unique node name: {str(e)}")
|
||||
# Fallback to original name with UUID suffix
|
||||
return f"{base_name}_{str(uuid.uuid4())[:8]}"
|
||||
|
||||
def _validate_node_name_uniqueness(self, workflow_id: UUID, node_name: str, exclude_node_id: UUID = None) -> bool:
|
||||
"""
|
||||
Validate that a node name is unique within the workflow
|
||||
|
||||
Args:
|
||||
workflow_id: The workflow ID to check within
|
||||
node_name: The name to validate
|
||||
exclude_node_id: Node ID to exclude from uniqueness check (for updates)
|
||||
|
||||
Returns:
|
||||
True if name is unique, False otherwise
|
||||
"""
|
||||
try:
|
||||
workflow = self.workflow_repo.get_by_id(workflow_id)
|
||||
if not workflow:
|
||||
return True
|
||||
|
||||
for node in workflow.nodes:
|
||||
if exclude_node_id and node.id == exclude_node_id:
|
||||
continue # Skip the node being updated
|
||||
if node.name and node.name.lower() == node_name.lower():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating node name uniqueness: {str(e)}")
|
||||
return False
|
||||
|
||||
def _ensure_unicode_support_in_config(self, config: dict) -> dict:
|
||||
"""
|
||||
Ensure Unicode characters are properly preserved in config data
|
||||
by applying JSON serialization with ensure_ascii=False
|
||||
"""
|
||||
import json
|
||||
import copy
|
||||
|
||||
if not config:
|
||||
return config
|
||||
|
||||
try:
|
||||
# Create a deep copy to avoid modifying the original
|
||||
config_copy = copy.deepcopy(config)
|
||||
|
||||
# Convert to JSON with ensure_ascii=False to preserve Unicode characters
|
||||
# Then parse it back to ensure it's properly formatted for database storage
|
||||
json_str = json.dumps(config_copy, ensure_ascii=False, separators=(',', ':'))
|
||||
unicode_safe_config = json.loads(json_str)
|
||||
|
||||
logger.debug(f"Applied Unicode-safe processing to config: {len(str(config_copy))} chars -> {len(str(unicode_safe_config))} chars")
|
||||
return unicode_safe_config
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying Unicode-safe processing to config: {str(e)}")
|
||||
return config
|
||||
|
||||
def replace_workflow_nodes_and_connections(
|
||||
self,
|
||||
workflow_id: UUID,
|
||||
nodes_data: List[Dict[str, Any]],
|
||||
connections_data: List[Dict[str, Any]],
|
||||
organization_id: UUID
|
||||
) -> Dict[str, List]:
|
||||
"""Replace all workflow nodes and connections with cached data (delete existing, create new)"""
|
||||
|
||||
# Validate workflow exists and belongs to organization
|
||||
workflow = self.workflow_repo.get_by_id(workflow_id)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not found")
|
||||
|
||||
if workflow.organization_id != organization_id:
|
||||
raise ValueError("Workflow does not belong to your organization")
|
||||
|
||||
try:
|
||||
# Delete all existing connections first (due to foreign key constraints)
|
||||
existing_connections = self.node_repo.get_connections_by_workflow(workflow_id)
|
||||
for connection in existing_connections:
|
||||
self.db.delete(connection)
|
||||
|
||||
# Delete all existing nodes
|
||||
existing_nodes = self.node_repo.get_nodes_by_workflow(workflow_id)
|
||||
for node in existing_nodes:
|
||||
self.db.delete(node)
|
||||
|
||||
# Commit deletions
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Deleted {len(existing_nodes)} existing nodes and {len(existing_connections)} existing connections for workflow {workflow_id}")
|
||||
|
||||
# Create ID mapping for text IDs to UUIDs
|
||||
id_mapping = {}
|
||||
|
||||
def is_text_id(node_id: str) -> bool:
|
||||
"""Check if ID is a text ID (like message-1, llm-2) instead of UUID"""
|
||||
try:
|
||||
UUID(node_id)
|
||||
return False
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
# First pass: Create ID mappings for text IDs
|
||||
for node_data in nodes_data:
|
||||
node_id = str(node_data.get('id', ''))
|
||||
if node_id and is_text_id(node_id):
|
||||
if node_id not in id_mapping:
|
||||
id_mapping[node_id] = str(uuid.uuid4())
|
||||
logger.info(f"Mapped text ID '{node_id}' to UUID '{id_mapping[node_id]}'")
|
||||
|
||||
# Also create mappings for connection IDs if they are text IDs
|
||||
for conn_data in connections_data:
|
||||
conn_id = str(conn_data.get('id', ''))
|
||||
if conn_id and is_text_id(conn_id):
|
||||
if conn_id not in id_mapping:
|
||||
id_mapping[conn_id] = str(uuid.uuid4())
|
||||
logger.info(f"Mapped connection text ID '{conn_id}' to UUID '{id_mapping[conn_id]}'")
|
||||
|
||||
# Create all new nodes
|
||||
created_nodes = []
|
||||
for node_data in nodes_data:
|
||||
node_data_copy = node_data.copy()
|
||||
node_data_copy['workflow_id'] = workflow_id
|
||||
|
||||
# Convert workflow_id to UUID if it's a string
|
||||
if isinstance(node_data_copy['workflow_id'], str):
|
||||
node_data_copy['workflow_id'] = UUID(node_data_copy['workflow_id'])
|
||||
|
||||
# Extract and clean node-specific fields into config
|
||||
config_fields = [
|
||||
'message_text', 'system_prompt', 'temperature', 'model_id',
|
||||
'condition_expression', 'action_type', 'action_url', 'action_config',
|
||||
'transfer_department', 'transfer_message', 'transfer_rules',
|
||||
'wait_duration', 'wait_unit', 'wait_until_condition',
|
||||
'final_message', 'form_fields', 'form_title', 'form_description',
|
||||
'submit_button_text', 'form_full_screen',
|
||||
'landing_page_heading', 'landing_page_content',
|
||||
'enabled_guardrails', 'pii_action', 'jailbreak_sensitivity',
|
||||
'text_source', 'block_message'
|
||||
]
|
||||
|
||||
# Initialize config if not present
|
||||
if 'config' not in node_data_copy:
|
||||
node_data_copy['config'] = {}
|
||||
|
||||
# Move config fields from root to config object and filter out blank values
|
||||
for field in config_fields:
|
||||
if field in node_data_copy:
|
||||
value = node_data_copy[field]
|
||||
# Only add non-blank values to config
|
||||
if value is not None and value != "" and value != []:
|
||||
node_data_copy['config'][field] = value
|
||||
# Remove from root level
|
||||
del node_data_copy[field]
|
||||
|
||||
# Apply Unicode-safe processing to the entire config to handle multi-language content
|
||||
node_data_copy['config'] = self._ensure_unicode_support_in_config(node_data_copy['config'])
|
||||
|
||||
# Sanitize text fields to prevent UTF-8 encoding errors
|
||||
base_text_fields = ['name', 'description']
|
||||
for field in base_text_fields:
|
||||
if field in node_data_copy and isinstance(node_data_copy[field], str):
|
||||
node_data_copy[field] = sanitize_utf8_text(node_data_copy[field])
|
||||
|
||||
# Sanitize config text fields
|
||||
config_text_fields = ['message_text', 'system_prompt', 'condition_expression',
|
||||
'action_type', 'action_url', 'transfer_department', 'transfer_message',
|
||||
'final_message', 'form_title', 'form_description', 'submit_button_text',
|
||||
'landing_page_heading', 'landing_page_content', 'text_source', 'block_message']
|
||||
for field in config_text_fields:
|
||||
if field in node_data_copy['config'] and isinstance(node_data_copy['config'][field], str):
|
||||
node_data_copy['config'][field] = sanitize_utf8_text(node_data_copy['config'][field])
|
||||
|
||||
# Replace text ID with UUID if mapped
|
||||
original_id = str(node_data_copy.get('id', ''))
|
||||
if original_id in id_mapping:
|
||||
node_data_copy['id'] = id_mapping[original_id]
|
||||
elif 'id' not in node_data_copy or not node_data_copy['id']:
|
||||
# Generate new ID if not provided
|
||||
node_data_copy['id'] = uuid.uuid4()
|
||||
else:
|
||||
# Convert string ID to UUID
|
||||
node_data_copy['id'] = UUID(str(node_data_copy['id']))
|
||||
|
||||
# Filter out any remaining None or empty values from base fields
|
||||
filtered_data = {}
|
||||
for key, value in node_data_copy.items():
|
||||
if value is not None and value != "":
|
||||
filtered_data[key] = value
|
||||
|
||||
new_node = self.node_repo.create_node(**filtered_data)
|
||||
created_nodes.append(new_node)
|
||||
|
||||
# Commit node creations before processing connections
|
||||
self.db.commit()
|
||||
|
||||
# Create all new connections
|
||||
created_connections = []
|
||||
for conn_data in connections_data:
|
||||
conn_data_copy = conn_data.copy()
|
||||
conn_data_copy['workflow_id'] = workflow_id
|
||||
|
||||
# Convert workflow_id to UUID if it's a string
|
||||
if isinstance(conn_data_copy['workflow_id'], str):
|
||||
conn_data_copy['workflow_id'] = UUID(conn_data_copy['workflow_id'])
|
||||
|
||||
# Sanitize text fields to prevent UTF-8 encoding errors
|
||||
text_fields = ['label', 'condition']
|
||||
for field in text_fields:
|
||||
if field in conn_data_copy and isinstance(conn_data_copy[field], str):
|
||||
conn_data_copy[field] = sanitize_utf8_text(conn_data_copy[field])
|
||||
|
||||
# Replace text ID with UUID if mapped
|
||||
original_id = str(conn_data_copy.get('id', ''))
|
||||
if original_id in id_mapping:
|
||||
conn_data_copy['id'] = id_mapping[original_id]
|
||||
elif 'id' not in conn_data_copy or not conn_data_copy['id']:
|
||||
# Generate new ID if not provided
|
||||
conn_data_copy['id'] = uuid.uuid4()
|
||||
else:
|
||||
# Convert string ID to UUID
|
||||
conn_data_copy['id'] = UUID(str(conn_data_copy['id']))
|
||||
|
||||
# Map source and target node IDs if they are text IDs
|
||||
source_node_id = str(conn_data_copy.get('source_node_id', ''))
|
||||
if source_node_id in id_mapping:
|
||||
conn_data_copy['source_node_id'] = id_mapping[source_node_id]
|
||||
|
||||
target_node_id = str(conn_data_copy.get('target_node_id', ''))
|
||||
if target_node_id in id_mapping:
|
||||
conn_data_copy['target_node_id'] = id_mapping[target_node_id]
|
||||
|
||||
# Convert source and target node IDs to UUIDs
|
||||
if 'source_node_id' in conn_data_copy:
|
||||
conn_data_copy['source_node_id'] = UUID(str(conn_data_copy['source_node_id']))
|
||||
if 'target_node_id' in conn_data_copy:
|
||||
conn_data_copy['target_node_id'] = UUID(str(conn_data_copy['target_node_id']))
|
||||
|
||||
new_conn = self.node_repo.create_connection(**conn_data_copy)
|
||||
created_connections.append(new_conn)
|
||||
|
||||
# Final commit for connections
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Created {len(created_nodes)} new nodes and {len(created_connections)} new connections for workflow {workflow_id}")
|
||||
|
||||
# Return all current nodes and connections
|
||||
final_nodes = self.node_repo.get_nodes_by_workflow(workflow_id)
|
||||
final_connections = self.node_repo.get_connections_by_workflow(workflow_id)
|
||||
|
||||
return {
|
||||
"nodes": final_nodes,
|
||||
"connections": final_connections
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error replacing workflow nodes and connections: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def get_workflow_nodes_and_connections(self, workflow_id: UUID, organization_id: UUID) -> Dict[str, List]:
|
||||
"""Get all nodes and connections for a workflow"""
|
||||
|
||||
# Validate workflow exists and belongs to organization
|
||||
workflow = self.workflow_repo.get_by_id(workflow_id)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not found")
|
||||
|
||||
if workflow.organization_id != organization_id:
|
||||
raise ValueError("Workflow does not belong to your organization")
|
||||
|
||||
nodes = self.node_repo.get_nodes_by_workflow(workflow_id)
|
||||
connections = self.node_repo.get_connections_by_workflow(workflow_id)
|
||||
|
||||
return {
|
||||
"nodes": nodes,
|
||||
"connections": connections
|
||||
}
|
||||
|
||||
def update_single_node(
|
||||
self,
|
||||
workflow_id: UUID,
|
||||
node_id: UUID,
|
||||
node_data: Dict[str, Any],
|
||||
organization_id: UUID = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Update a single workflow node with its properties"""
|
||||
|
||||
# Validate workflow exists and belongs to organization
|
||||
workflow = self.workflow_repo.get_by_id(workflow_id)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not found")
|
||||
|
||||
if organization_id and workflow.organization_id != organization_id:
|
||||
raise ValueError("Workflow does not belong to your organization")
|
||||
|
||||
# Validate node exists and belongs to workflow
|
||||
node = self.node_repo.get_node_by_id(node_id)
|
||||
if not node:
|
||||
raise ValueError("Node not found")
|
||||
|
||||
if node.workflow_id != workflow_id:
|
||||
raise ValueError("Node does not belong to this workflow")
|
||||
|
||||
try:
|
||||
logger.debug(f"Node data: {node_data}")
|
||||
# Sanitize text fields to prevent UTF-8 encoding errors
|
||||
node_data_copy = node_data.copy()
|
||||
text_fields = ['name', 'description', 'message_text', 'system_prompt',
|
||||
'condition_expression', 'action_type']
|
||||
for field in text_fields:
|
||||
if field in node_data_copy and isinstance(node_data_copy[field], str):
|
||||
node_data_copy[field] = sanitize_utf8_text(node_data_copy[field])
|
||||
|
||||
# Validate and ensure unique node name
|
||||
if 'name' in node_data_copy and node_data_copy['name']:
|
||||
if not self._validate_node_name_uniqueness(workflow_id, node_data_copy['name'], node_id):
|
||||
# Generate unique name if not unique
|
||||
node_data_copy['name'] = self._generate_unique_node_name(workflow_id, node_data_copy['name'], node_id)
|
||||
logger.info(f"Generated unique node name: {node_data_copy['name']}")
|
||||
|
||||
# Apply Unicode-safe processing to config if present
|
||||
if 'config' in node_data_copy and isinstance(node_data_copy['config'], dict):
|
||||
node_data_copy['config'] = self._ensure_unicode_support_in_config(node_data_copy['config'])
|
||||
|
||||
logger.debug(f"Node data copy: {node_data_copy}")
|
||||
# Update node properties
|
||||
updated_node = self.node_repo.update_node(node_id, **node_data_copy)
|
||||
if not updated_node:
|
||||
raise ValueError("Failed to update node")
|
||||
|
||||
# Commit changes
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Updated node {node_id}")
|
||||
|
||||
return updated_node
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating node {node_id}: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def get_single_node(self, workflow_id: UUID, node_id: UUID, organization_id: UUID = None):
|
||||
"""Get a single node"""
|
||||
|
||||
# Validate workflow exists and belongs to organization
|
||||
workflow = self.workflow_repo.get_by_id(workflow_id)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not found")
|
||||
|
||||
if organization_id and workflow.organization_id != organization_id:
|
||||
raise ValueError("Workflow does not belong to your organization")
|
||||
|
||||
# Validate node exists and belongs to workflow
|
||||
node = self.node_repo.get_node_by_id(node_id)
|
||||
if not node:
|
||||
raise ValueError("Node not found")
|
||||
|
||||
if node.workflow_id != workflow_id:
|
||||
raise ValueError("Node does not belong to this workflow")
|
||||
|
||||
return node
|
||||
Reference in new issue
Block a user