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

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

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from fastapi import APIRouter
from app.api.channels.accounts import router as accounts_router
from app.api.channels.telegram import router as telegram_router
from app.api.channels.meta import router as meta_router
from app.api.channels.whatsapp_messaging import router as whatsapp_messaging_router
from app.api.channels.slack import router as slack_router
from app.api.channels.email import router as email_router
from app.api.channels.sms import router as sms_router
from app.api.channels.line import router as line_router
from app.api.channels.agent_config import router as agent_config_router
# Aggregates channel onboarding/management routes under /channels
router = APIRouter()
router.include_router(accounts_router, tags=["channels"])
router.include_router(telegram_router, prefix="/telegram", tags=["channels"])
router.include_router(meta_router, prefix="/meta", tags=["channels"])
# Same prefix: onboarding and messaging are separate modules, one URL space.
router.include_router(whatsapp_messaging_router, prefix="/meta", tags=["channels"])
router.include_router(slack_router, prefix="/slack", tags=["channels"])
router.include_router(email_router, prefix="/email", tags=["channels"])
router.include_router(sms_router, prefix="/sms", tags=["channels"])
router.include_router(line_router, prefix="/line", tags=["channels"])
router.include_router(agent_config_router, prefix="/agent-config", tags=["channels"])
+113
View File
@@ -0,0 +1,113 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth import (
INBOX_PERMISSIONS,
check_permissions,
get_current_organization,
require_any_permission,
require_permissions,
)
from app.services.meta_own_app import AUTO_WEBHOOK_SETTING, is_own_app, own_app_webhook_url
from app.core.config import webhooks_base_url
from app.database import get_db
from app.models.channels import ChannelType
from app.models.organization import Organization
from app.models.schemas.channel import ChannelAccountOut
from app.models.user import User
from app.repositories.channels import ChannelAccountRepository, AgentChannelConfigRepository
from app.repositories.channels.accounts import needs_reconnect
from app.core.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
# Sentinel: distinguishes "config not passed, look it up" from "no config"
_UNRESOLVED = object()
def channel_webhook_url(account) -> str | None:
"""The webhook URL the customer must configure on their provider, for the
channels where that's a manual step. Auto-managed channels return None."""
base = webhooks_base_url()
if account.channel_type == ChannelType.EMAIL.value:
return f"{base}/email/{account.id}?token={account.webhook_secret}"
if account.channel_type == ChannelType.SMS.value:
provider = (account.settings or {}).get("provider", "twilio")
return f"{base}/sms/{provider}/{account.id}?token={account.webhook_secret}"
if is_own_app(account):
return own_app_webhook_url(account)
return None
def get_org_account_or_404(db: Session, account_id: UUID, organization: Organization):
"""Load a channel account and enforce org ownership."""
account = ChannelAccountRepository(db).get_by_id(account_id)
if account is None or account.organization_id != organization.id:
raise HTTPException(status_code=404, detail="Channel account not found")
return account
def to_account_out(
db: Session, account, config=_UNRESOLVED, include_webhook_url: bool = True
) -> ChannelAccountOut:
"""Serialise a channel account.
Pass include_webhook_url=False for callers who are not org admins: the URL
embeds `webhook_secret`, which is the ONLY authentication on the inbound
email and SMS webhooks (api/webhooks/email.py, api/webhooks/sms.py).
Handing it to an agent would let them post forged inbound messages into any
of the org's conversations.
"""
if config is _UNRESOLVED:
config = AgentChannelConfigRepository(db).get_by_account(account.id)
out = ChannelAccountOut.model_validate(account)
out.agent_id = config.agent_id if config and config.is_active else None
out.webhook_url = channel_webhook_url(account) if include_webhook_url else None
out.needs_reconnect = needs_reconnect(account)
# Meta asks for this token when the customer points their app at us
if include_webhook_url and is_own_app(account):
out.webhook_verify_token = account.webhook_secret
out.webhook_auto_configured = (account.settings or {}).get(AUTO_WEBHOOK_SETTING)
return out
@router.get("/accounts", response_model=List[ChannelAccountOut])
async def list_channel_accounts(
# Org admins (Integrations settings) OR inbox agents: the inbox reads this
# to know whether there is a WhatsApp number to start a conversation from,
# so gating it admin-only hid the feature from the people it is for.
current_user: User = Depends(
require_any_permission("manage_organization", *INBOX_PERMISSIONS)),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""All connected channel accounts for the organization."""
accounts = ChannelAccountRepository(db).list_by_org(organization.id)
configs = AgentChannelConfigRepository(db).map_by_accounts([a.id for a in accounts])
# Only the Integrations settings screen needs the webhook URL, and only an
# org admin can act on it — see to_account_out.
include_webhook_url = check_permissions(current_user, ["manage_organization"])
return [
to_account_out(db, account, configs.get(account.id), include_webhook_url)
for account in accounts
]
+68
View File
@@ -0,0 +1,68 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.channels.accounts import get_org_account_or_404, to_account_out
from app.core.auth import get_current_organization, require_permissions
from app.database import get_db
from app.models.organization import Organization
from app.models.schemas.channel import AgentChannelConfigRequest, ChannelAccountOut
from app.models.user import User
from app.repositories.agent import AgentRepository
from app.repositories.channels import AgentChannelConfigRepository
from app.services import telegram_webhooks
from app.core.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
@router.post("/{account_id}", response_model=ChannelAccountOut)
async def set_account_agent(
account_id: UUID,
request: AgentChannelConfigRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Route a connected channel account to an AI agent."""
account = get_org_account_or_404(db, account_id, organization)
agent = AgentRepository(db).get_agent(request.agent_id)
if agent is None or agent.organization_id != organization.id:
raise HTTPException(status_code=404, detail="Agent not found")
AgentChannelConfigRepository(db).set_agent(account.id, request.agent_id, request.is_active)
# A bot connected before buttons existed doesn't receive taps yet
await telegram_webhooks.reregister_if_buttons_on(account, agent)
return to_account_out(db, account)
@router.delete("/{account_id}")
async def clear_account_agent(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Stop routing a channel account to any agent (messages will be ignored)."""
account = get_org_account_or_404(db, account_id, organization)
AgentChannelConfigRepository(db).delete_by_account(account.id)
return {"status": "cleared"}
+149
View File
@@ -0,0 +1,149 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import asyncio
import smtplib
from email.utils import parseaddr
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.channels.accounts import get_org_account_or_404, to_account_out
from app.channels.email import inbox_smtp_config, validate_smtp
from app.core.auth import get_current_organization, require_permissions
from app.core.config import settings
from app.database import get_db
from app.models.channels import ChannelType
from app.models.organization import Organization
from app.models.schemas.channel import ChannelAccountOut, EmailConnectRequest
from app.models.user import User
from app.repositories.channels import ChannelAccountRepository
from app.core.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
def _webhook_url(account) -> str:
return (f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}"
f"/webhooks/email/{account.id}?token={account.webhook_secret}")
async def _build_smtp_credentials(request: EmailConnectRequest, address: str) -> dict:
"""Assemble (and validate) per-inbox SMTP credentials, or {} to use the
platform SMTP settings. Raises HTTPException on invalid/unreachable SMTP."""
if not request.smtp_host:
return {}
if not (request.smtp_username and request.smtp_password):
raise HTTPException(status_code=400,
detail="SMTP username and password are required when an SMTP host is set")
creds = {
"smtp_host": request.smtp_host.strip(),
"smtp_port": request.smtp_port or 587,
"smtp_username": request.smtp_username,
"smtp_password": request.smtp_password,
"from_email": (request.from_email or address).lower(),
"smtp_use_ssl": request.smtp_use_ssl,
}
cfg = inbox_smtp_config(creds, address)
try:
await asyncio.to_thread(validate_smtp, cfg)
except smtplib.SMTPAuthenticationError as e:
raise HTTPException(status_code=400, detail=_auth_error_detail(cfg["host"], e))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Could not connect to SMTP server: {e}")
return creds
def _auth_error_detail(host: str, exc: Exception) -> str:
"""Friendlier message for an SMTP login rejection — Gmail in particular
needs an App Password, not the account password."""
host = (host or "").lower()
if "gmail" in host or "google" in host:
return ("Gmail rejected the login. Gmail requires a 16-character App Password "
"(not your normal account password), and 2-Step Verification must be "
"enabled — create one at myaccount.google.com/apppasswords, then paste "
"it here with the spaces removed.")
return f"SMTP authentication failed — check the username and password ({exc})"
@router.post("", response_model=ChannelAccountOut)
async def connect_email(
request: EmailConnectRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Connect a support inbox. The response's webhook_url must be configured
as the inbound-parse/forwarding target on the email provider. Outbound
replies use the inbox's own SMTP when provided, else the platform SMTP."""
_, address = parseaddr(request.inbound_address)
address = (address or "").lower()
if "@" not in address:
raise HTTPException(status_code=400, detail="Enter a valid email address")
credentials = await _build_smtp_credentials(request, address)
repo = ChannelAccountRepository(db)
existing = repo.get_by_external_id(ChannelType.EMAIL.value, address)
if existing is not None:
if existing.organization_id != organization.id:
raise HTTPException(status_code=409, detail="This address is already connected to another organization")
# Only overwrite SMTP creds when new ones were supplied — reconnecting
# to update the agent/webhook must not silently wipe stored SMTP.
if credentials:
repo.update_credentials(existing, credentials)
account = repo.set_active(existing, True)
else:
account = repo.create_account(
organization_id=organization.id,
channel_type=ChannelType.EMAIL.value,
external_account_id=address,
credentials=credentials, # {} => platform SMTP fallback
display_name=request.display_name or address,
)
out = to_account_out(db, account)
out.webhook_url = _webhook_url(account)
return out
@router.get("/{account_id}/webhook-url")
async def get_email_webhook_url(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Re-show the inbound webhook URL for an existing email account."""
account = get_org_account_or_404(db, account_id, organization)
if account.channel_type != ChannelType.EMAIL.value:
raise HTTPException(status_code=404, detail="Channel account not found")
return {"webhook_url": _webhook_url(account)}
@router.delete("/{account_id}")
async def disconnect_email(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
account = get_org_account_or_404(db, account_id, organization)
if account.channel_type != ChannelType.EMAIL.value:
raise HTTPException(status_code=404, detail="Channel account not found")
ChannelAccountRepository(db).delete(account)
return {"status": "disconnected"}
+95
View File
@@ -0,0 +1,95 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.channels.accounts import get_org_account_or_404, to_account_out
from app.channels import line as line_api
from app.core.auth import get_current_organization, require_permissions
from app.core.config import settings
from app.database import get_db
from app.models.channels import ChannelType
from app.models.organization import Organization
from app.models.schemas.channel import ChannelAccountOut, LineConnectRequest
from app.models.user import User
from app.repositories.channels import ChannelAccountRepository
from app.core.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
def _webhook_url(account_id) -> str:
return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/line/{account_id}"
@router.post("", response_model=ChannelAccountOut)
async def connect_line(
request: LineConnectRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Connect a LINE Official Account (channel secret + access token from the
LINE Developers console). The webhook endpoint is set automatically."""
bot = await line_api.get_bot_info(request.channel_access_token.strip())
if bot is None:
raise HTTPException(status_code=400, detail="Invalid LINE channel access token")
bot_user_id = str(bot.get("userId", ""))
credentials = {
"channel_access_token": request.channel_access_token.strip(),
"channel_secret": request.channel_secret.strip(),
}
repo = ChannelAccountRepository(db)
existing = repo.get_by_external_id(ChannelType.LINE.value, bot_user_id)
if existing is not None:
if existing.organization_id != organization.id:
raise HTTPException(status_code=409, detail="This LINE account is already connected to another organization")
repo.update_credentials(existing, credentials)
account = repo.set_active(existing, True)
else:
account = repo.create_account(
organization_id=organization.id,
channel_type=ChannelType.LINE.value,
external_account_id=bot_user_id,
credentials=credentials,
display_name=bot.get("displayName") or bot.get("basicId") or bot_user_id,
)
if not await line_api.set_webhook_endpoint(credentials["channel_access_token"], _webhook_url(account.id)):
logger.warning(f"LINE webhook auto-set failed for {account.id}; set it manually in the console")
out = to_account_out(db, account)
out.webhook_url = _webhook_url(account.id)
return out
@router.delete("/{account_id}")
async def disconnect_line(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
account = get_org_account_or_404(db, account_id, organization)
if account.channel_type != ChannelType.LINE.value:
raise HTTPException(status_code=404, detail="Channel account not found")
ChannelAccountRepository(db).delete(account)
return {"status": "disconnected"}
+690
View File
@@ -0,0 +1,690 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import json
import secrets
import time
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.channels.accounts import get_org_account_or_404, to_account_out
from app.channels.meta_base import (
debug_token,
exchange_for_long_lived_token,
exchange_instagram_code,
exchange_instagram_long_lived,
exchange_signup_code,
graph_get,
graph_detail,
graph_list_all,
instagram_token_payload,
register_phone_number,
subscribe_app,
subscribe_instagram_app,
GRAPH_INSTAGRAM_BASE,
)
from app.services.meta_own_app import (
APP_SECRET_KEY,
AppCheckUnavailable,
AppSecretMismatch,
OwnApp,
leave_own_app,
own_app_token,
resolve_own_app,
sync_own_app,
)
from app.core.security import decrypt_api_key, encrypt_api_key
from app.core.auth import (
get_current_organization,
require_permissions,
)
from app.core.config import settings
from app.core.error_handlers import ClientSafeHTTPException
from app.database import get_db
from app.models.channels import ChannelType
from app.models.organization import Organization
from app.models.schemas.channel import (
ChannelAccountOut,
EmbeddedSignupConfigOut,
EmbeddedSignupRequest,
WhatsAppConnectRequest,
MessengerConnectRequest,
MessengerSignupConnectRequest,
MessengerSignupPagesOut,
MessengerSignupRequest,
MessengerPageOut,
InstagramConnectRequest,
InstagramLoginRequest,
)
from app.models.user import User
from app.repositories.channels import ChannelAccountRepository
from app.core.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
def _whatsapp_display_name(profile: dict, phone_number_id: str) -> str:
"""How a connected number is labelled in the UI, from its Graph profile."""
return (f"{profile.get('verified_name', 'WhatsApp')} "
f"({profile.get('display_phone_number', phone_number_id)})")
async def _page_name(page_id: str, access_token: str) -> str:
"""The Page's name for the UI label, falling back to its id.
Reading it needs pages_read_engagement, which messaging does not require —
so a token without it still connects and simply shows the id.
"""
ok, data = await graph_get(page_id, access_token, params={"fields": "name"})
if not ok or not data.get("name"):
logger.info(f"No name for page {page_id} (pages_read_engagement not granted); "
f"labelling with the id")
return page_id
return data["name"]
async def _verify_signup_assets(waba_id: str, phone_number_id: str, access_token: str) -> dict:
"""Prove the exchanged token really owns the number being claimed, and
return that number's profile.
The code only proves the caller completed *a* signup — waba_id and
phone_number_id arrive in the request body and are entirely caller
controlled. Without this, an org could pair a genuine code from a throwaway
WABA with someone else's phone number id: webhook routing resolves accounts
by phone number id alone, with no org scoping, so their inbound messages
would land in the attacker's inbox. Listing the WABA's numbers with the
token checks both ids at once.
"""
ok, data = await graph_get(f"{waba_id}/phone_numbers", access_token,
params={"fields": "id,display_phone_number,verified_name"})
if not ok:
raise HTTPException(
status_code=400,
detail=graph_detail(data, "Could not read that WhatsApp Business Account"))
numbers = data.get("data")
if not isinstance(numbers, list):
raise HTTPException(status_code=400, detail="Unexpected response from Meta")
for number in numbers:
if str(number.get("id")) == phone_number_id:
return number
raise HTTPException(status_code=400,
detail="That number is not on this WhatsApp Business Account")
def _generate_verification_pin() -> str:
"""A fresh two-step-verification PIN for a number we register.
The customer never types it — it exists only so the number can be
registered — so it is random rather than chosen, and kept for re-registers.
"""
return f"{secrets.randbelow(1_000_000):06d}"
# Channels that can be onboarded through a Meta login popup, each with the Meta
# configuration that drives it. WhatsApp and Messenger run Facebook logins;
# Instagram runs Instagram Login, which needs no Facebook Page at all.
SIGNUP_CHANNELS = (
ChannelType.WHATSAPP.value,
ChannelType.MESSENGER.value,
ChannelType.INSTAGRAM.value,
)
# Webhook fields a Page must subscribe our app to for Messenger to work.
MESSENGER_SUBSCRIBED_FIELDS = "messages,messaging_postbacks"
def _signup_config_id(channel: str) -> str:
"""The Facebook Login-for-Business configuration that onboards this channel,
read at call time so a settings override (tests, reloads) is always seen.
Instagram Login has no configuration — it is gated on its own app id.
"""
return {
ChannelType.WHATSAPP.value: settings.META_CONFIG_ID,
ChannelType.MESSENGER.value: settings.META_MESSENGER_CONFIG_ID,
}.get(channel, "")
def _signup_app_id(channel: str) -> str:
"""The app the login popup authenticates against. Instagram Login uses the
Instagram app; the Facebook flows use the Meta app."""
if channel == ChannelType.INSTAGRAM.value:
return settings.INSTAGRAM_APP_ID
return settings.META_APP_ID
def _signup_available(channel: str) -> bool:
"""Whether this deployment can offer the login flow for this channel.
Onboarding happens under *our* app, so without its credentials it
structurally cannot work — a self-hoster has none and gets the manual
credentials form instead. That is the whole gate: like every other
integration, signup is not restricted by plan.
"""
if channel == ChannelType.INSTAGRAM.value:
return bool(settings.INSTAGRAM_APP_ID and settings.INSTAGRAM_APP_SECRET)
return bool(_signup_config_id(channel))
def _signup_allowed_for(user: User) -> bool:
"""Whether this account may use one-click signup.
An empty allowlist means everyone, which is the end state. While the Meta
app is in App Review the login only succeeds for people with a role on that
app, so the allowlist keeps the button off everyone it would only fail for.
Read at call time so the list can be changed without a code deploy.
"""
allowed = {email.strip().lower()
for email in settings.SIGNUP_ALLOWED_EMAILS.split(",")
if email.strip()}
return not allowed or (user.email or "").strip().lower() in allowed
def check_signup_access(channel: str, user: User) -> None:
if not _signup_available(channel):
raise HTTPException(
status_code=403,
detail="Signup is not configured on this deployment for this channel. "
"Connect with credentials instead.",
)
# Separate from the deployment gate so the reason is honest: the flow exists
# here, this account just isn't one of the ones it is open to yet.
if not _signup_allowed_for(user):
raise HTTPException(
status_code=403,
detail="One-click connect is limited to selected accounts on this "
"deployment. Connect with credentials instead.",
)
# The signup code is single-use and lives ~10 minutes; the sealed page list it
# produces should not outlive it, so a stale one fails cleanly with a retry.
SIGNUP_TOKEN_TTL_SECONDS = 600
# 500 Pages; an agency ceiling, not a limit a real customer reaches.
PAGE_LIST_MAX_PAGES = 5
def _seal_signup_pages(organization_id, pages: list[dict]) -> str:
"""Carry a signup's Page tokens through the picker without the browser ever
holding them in the clear.
The tokens go back to the client as ciphertext only we can open, so step 2
needs no server-side session — which matters because the two requests are
not guaranteed to hit the same worker. Binding the org and an expiry inside
means a leaked blob cannot be replayed by another tenant, or after the
signup's own code would already have died.
"""
payload = {
"org": str(organization_id),
"exp": int(time.time()) + SIGNUP_TOKEN_TTL_SECONDS,
"pages": pages,
}
return encrypt_api_key(json.dumps(payload))
def _open_signup_pages(signup_token: str, organization: Organization) -> list[dict]:
"""The sealed page list, or 400 if it is forged, expired or another org's.
One generic message across every reject path, so it is not an oracle for
which check tripped.
"""
invalid = HTTPException(status_code=400,
detail="That signup has expired — please connect again")
try:
# decrypt raises ValueError on a bad token; json.loads raises
# JSONDecodeError, a ValueError subclass — one except covers both.
payload = json.loads(decrypt_api_key(signup_token))
except ValueError:
raise invalid
if not isinstance(payload, dict) or payload.get("org") != str(organization.id):
raise invalid
if float(payload.get("exp", 0)) <= time.time():
raise invalid
pages = payload.get("pages")
if not isinstance(pages, list):
raise invalid
return pages
async def _list_manageable_pages(user_token: str, fields: str) -> list[dict]:
"""The Pages this signup granted us, each with its own Page access token."""
ok, data = await graph_list_all(
"me/accounts", user_token, {"fields": fields, "limit": 100},
max_pages=PAGE_LIST_MAX_PAGES)
if not ok:
raise HTTPException(
status_code=400,
detail=graph_detail(data, "Could not list your Facebook Pages"))
return data
async def _signup_user_token(request: MessengerSignupRequest) -> str:
"""Trade a Facebook Login-for-Business code for a long-lived user token.
Page tokens inherit the user token's lifetime, so it is extended first
(best-effort; a failure only risks earlier expiry). Instagram does not come
through here — Instagram Login is its own OAuth against its own host.
"""
ok, data = await exchange_signup_code(request.code, redirect_uri=request.redirect_uri)
user_token = data.get("access_token") if ok else None
if not user_token:
# The code is short-lived (~10 minutes) and single-use, so a stale or
# replayed one lands here rather than on a Graph error later.
raise HTTPException(status_code=400, detail=graph_detail(
data, "Could not complete signup — please try connecting again"))
ok, extended = await exchange_for_long_lived_token(user_token)
if ok and extended.get("access_token"):
return extended["access_token"]
logger.warning("Could not extend the signup token; page tokens may expire early")
return user_token
def _upsert_account(db: Session, organization: Organization, channel_type: str,
external_account_id: str, credentials: dict, display_name: str,
forget: tuple = ()):
"""Create the account or refresh credentials on reconnect (same org only).
`forget` names stored credentials that no longer apply (an app secret,
once the account is connected through ReplyPier's own app)."""
repo = ChannelAccountRepository(db)
existing = repo.get_by_external_id(channel_type, external_account_id)
if existing is not None:
if existing.organization_id != organization.id:
raise HTTPException(status_code=409,
detail="This account is already connected to another organization")
# Merge rather than replace: a reconnect that omits an optional field
# must not silently drop it. Someone reconnecting without re-entering
# their WABA id would otherwise lose template management, and the
# signup PIN cannot be read back from Meta at all.
merged = {**repo.get_credentials(existing),
**{k: v for k, v in credentials.items() if v is not None}}
for key in forget:
merged.pop(key, None)
repo.update_credentials(existing, merged)
return repo.set_active(existing, True)
return repo.create_account(
organization_id=organization.id,
channel_type=channel_type,
external_account_id=external_account_id,
credentials=credentials,
display_name=display_name,
)
def _previous_account(db: Session, organization: Organization, channel_type: str, external_account_id: str):
"""This organization's earlier connection of the account, if any (another
organization's is a 409, before anything is asked of Meta)."""
previous = ChannelAccountRepository(db).get_by_external_id(channel_type, external_account_id)
if previous is not None and previous.organization_id != organization.id:
raise HTTPException(status_code=409, detail="This account is already connected to another organization")
return previous
def _own_app_credentials(own: OwnApp | None) -> dict:
return {APP_SECRET_KEY: own.secret} if own else {}
async def _own_app(access_token: str, entered_secret: str | None, previous) -> OwnApp | None:
"""resolve_own_app, with its failures as answers the form can show."""
try:
return await resolve_own_app(access_token, entered_secret, previous)
except AppSecretMismatch:
raise HTTPException(status_code=400, detail=(
"That app secret is not the one for the Meta app this token comes from. "
"Copy it from your app's App settings → Basic." if entered_secret else
"The app secret saved before doesn't match this token. Enter the app secret again.")) from None
except AppCheckUnavailable as e:
raise ClientSafeHTTPException(
status_code=502, detail=f"Could not check the app secret with Meta, try again ({e})") from e
@router.get("/embedded-signup-config", response_model=EmbeddedSignupConfigOut)
async def get_embedded_signup_config(
channel: str = ChannelType.WHATSAPP.value,
current_user: User = Depends(require_permissions("manage_organization")),
):
"""What the connect UI needs to decide between a Meta login flow and the
manual credentials form, for the given channel. The app id is returned here
rather than baked into the frontend build, so a self-hoster can point at
their own app. A disabled channel returns no ids, so one channel's config
never leaks on another's request."""
if channel not in SIGNUP_CHANNELS:
raise HTTPException(status_code=404, detail="No signup flow for this channel")
# Both halves of the same gate the connect endpoints enforce, so an account
# outside the allowlist is shown the manual form rather than a button that
# would 403 on click.
enabled = _signup_available(channel) and _signup_allowed_for(current_user)
return EmbeddedSignupConfigOut(
enabled=enabled,
# Instagram Login has no configuration id, only an app id.
config_id=(_signup_config_id(channel) or None) if enabled else None,
app_id=_signup_app_id(channel) if enabled else None,
graph_version=settings.META_GRAPH_VERSION,
)
@router.post("/whatsapp", response_model=ChannelAccountOut)
async def connect_whatsapp(
request: WhatsAppConnectRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Connect a WhatsApp Cloud API number with manual credentials
(phone_number_id + permanent token from the customer's own Meta app)."""
ok, data = await graph_get(request.phone_number_id, request.access_token,
params={"fields": "display_phone_number,verified_name"})
if not ok:
raise HTTPException(status_code=400,
detail=f"Could not verify WhatsApp credentials: {data.get('error', {}).get('message', 'invalid token or phone_number_id')}")
previous = _previous_account(db, organization, ChannelType.WHATSAPP.value, request.phone_number_id)
own = await _own_app(request.access_token, request.app_secret, previous)
previous_token = own_app_token(previous)
display = request.display_name or _whatsapp_display_name(data, request.phone_number_id)
account = _upsert_account(
db, organization, ChannelType.WHATSAPP.value,
external_account_id=request.phone_number_id,
credentials={"access_token": request.access_token, "waba_id": request.waba_id,
**_own_app_credentials(own)},
display_name=display,
forget=() if own else (APP_SECRET_KEY,),
)
# Route WABA events to our webhook (best-effort; needs the WABA id)
if request.waba_id:
if not await subscribe_app(request.waba_id, request.access_token):
logger.warning(f"WABA subscribe failed for {request.waba_id}; webhook may need manual subscription")
await sync_own_app(db, account, own, request.access_token, previous_token)
return to_account_out(db, account)
@router.post("/whatsapp/embedded-signup", response_model=ChannelAccountOut)
async def connect_whatsapp_embedded_signup(
request: EmbeddedSignupRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Finish an Embedded Signup flow: trade the code for the customer's
business token and connect the number it created.
Only the way the credentials are obtained differs from connect_whatsapp —
everything after that is the same upsert and webhook subscribe.
"""
check_signup_access(ChannelType.WHATSAPP.value, current_user)
ok, data = await exchange_signup_code(request.code)
access_token = data.get("access_token") if ok else None
if not access_token:
# The code is short-lived (~10 minutes) and single-use, so a stale or
# replayed one lands here rather than on a Graph error later.
raise HTTPException(
status_code=400,
detail=graph_detail(data, "Could not complete signup — please try connecting again"),
)
profile = await _verify_signup_assets(
request.waba_id, request.phone_number_id, access_token)
# A number from Embedded Signup is attached to the WABA but cannot send
# until it is registered for Cloud API use.
pin = _generate_verification_pin()
registered, register_data = await register_phone_number(
request.phone_number_id, access_token, pin)
if not registered:
logger.warning(
f"Phone {request.phone_number_id} not registered after signup: "
f"{graph_detail(register_data, 'unknown error')}")
credentials = {"access_token": access_token, "waba_id": request.waba_id}
# Only keep the PIN we actually set on the number. Storing one from a failed
# register would overwrite a working PIN with a value Meta never accepted —
# and Meta cannot read it back, so the real one would be unrecoverable.
if registered:
credentials["verification_pin"] = pin
# Connected from the customer's own app before: its token is the one that
# can hand the number's webhooks back
previous_token = own_app_token(_previous_account(
db, organization, ChannelType.WHATSAPP.value, request.phone_number_id))
account = _upsert_account(
db, organization, ChannelType.WHATSAPP.value,
external_account_id=request.phone_number_id,
credentials=credentials,
display_name=request.display_name or _whatsapp_display_name(
profile, request.phone_number_id),
forget=(APP_SECRET_KEY,),
)
if not await subscribe_app(request.waba_id, access_token):
logger.warning(f"WABA subscribe failed for {request.waba_id} after Embedded Signup")
await sync_own_app(db, account, None, access_token, previous_token)
return to_account_out(db, account)
@router.post("/messenger", response_model=ChannelAccountOut)
async def connect_messenger(
request: MessengerConnectRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Connect a Facebook Page for Messenger with a page access token."""
# Inspect the token rather than reading the Page node: `me?fields=id,name`
# needs pages_read_engagement, which a messaging token has no reason to
# carry — it would reject a token that sends perfectly well.
ok, info = await debug_token(request.page_access_token)
if not ok or not info.get("is_valid"):
raise HTTPException(status_code=400, detail=graph_detail(
info, "That token is not valid — generate a fresh Page access token"))
if info.get("type") != "PAGE":
raise HTTPException(status_code=400, detail=(
f"That is a {str(info.get('type', 'user')).lower()} access token, not a Page "
f"access token. In the Meta app dashboard go to Messenger → Settings and "
f"click Generate on the row for your Page."))
if str(info.get("profile_id")) != request.page_id:
raise HTTPException(status_code=400, detail=(
f"That token is for Page {info.get('profile_id')}, not the Page ID you "
f"entered. Webhooks are routed by Page ID, so the two must match."))
previous = _previous_account(db, organization, ChannelType.MESSENGER.value, request.page_id)
own = await _own_app(request.page_access_token, request.app_secret, previous)
previous_token = own_app_token(previous)
display_name = request.display_name or await _page_name(
request.page_id, request.page_access_token)
account = _upsert_account(
db, organization, ChannelType.MESSENGER.value,
external_account_id=request.page_id,
credentials={"access_token": request.page_access_token, **_own_app_credentials(own)},
display_name=display_name,
forget=() if own else (APP_SECRET_KEY,),
)
if not await subscribe_app(request.page_id, request.page_access_token,
subscribed_fields=MESSENGER_SUBSCRIBED_FIELDS):
logger.warning(f"Page subscribe failed for {request.page_id}; webhook may need manual subscription")
await sync_own_app(db, account, own, request.page_access_token, previous_token)
return to_account_out(db, account)
@router.post("/messenger/signup/pages", response_model=MessengerSignupPagesOut)
async def list_messenger_signup_pages(
request: MessengerSignupRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
):
"""Step 1 of Facebook Login for Business: trade the code for the customer's
Pages so they can pick which to connect.
The Page tokens are sealed into signup_token and never reach the browser;
only the ids and names come back for the picker.
"""
check_signup_access(ChannelType.MESSENGER.value, current_user)
user_token = await _signup_user_token(request)
pages = await _list_manageable_pages(user_token, "id,name,access_token")
if not pages:
raise HTTPException(status_code=400, detail=(
"No Facebook Pages were shared with ReplyPier. Run the connect "
"again and tick the Page you want to use."))
return MessengerSignupPagesOut(
signup_token=_seal_signup_pages(organization.id, pages),
pages=[MessengerPageOut(id=p["id"], name=p.get("name") or p["id"]) for p in pages],
)
@router.post("/messenger/signup/connect", response_model=ChannelAccountOut)
async def connect_messenger_signup(
request: MessengerSignupConnectRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Step 2: connect the Page the customer picked in step 1.
page_id is a selector into the list we fetched ourselves, never a caller
claim: the token stored is the one /me/accounts returned for that Page, so a
caller cannot connect a Page they do not administer. This is the Messenger
equivalent of _verify_signup_assets, and matters for the same reason —
webhooks resolve accounts by external id with no org scoping.
"""
check_signup_access(ChannelType.MESSENGER.value, current_user)
pages = _open_signup_pages(request.signup_token, organization)
page = next((p for p in pages if p.get("id") == request.page_id), None)
if page is None or not page.get("access_token"):
raise HTTPException(status_code=400, detail="That Page was not part of this signup")
previous_token = own_app_token(_previous_account(db, organization, ChannelType.MESSENGER.value, page["id"]))
account = _upsert_account(
db, organization, ChannelType.MESSENGER.value,
external_account_id=page["id"],
credentials={"access_token": page["access_token"]},
display_name=request.display_name or page.get("name") or page["id"],
forget=(APP_SECRET_KEY,),
)
if not await subscribe_app(page["id"], page["access_token"],
subscribed_fields=MESSENGER_SUBSCRIBED_FIELDS):
logger.warning(f"Page subscribe failed for {page['id']} after signup")
await sync_own_app(db, account, None, page["access_token"], previous_token)
return to_account_out(db, account)
@router.post("/instagram/login/connect", response_model=ChannelAccountOut)
async def connect_instagram_login(
request: InstagramLoginRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Connect an Instagram professional account through Instagram Login.
Unlike Messenger this needs no Facebook Page: the business signs in with
Instagram itself, so the login returns exactly one account and there is
nothing to pick. The token is an Instagram user token, used against
graph.instagram.com rather than the Facebook graph.
"""
check_signup_access(ChannelType.INSTAGRAM.value, current_user)
ok, data = await exchange_instagram_code(request.code, request.redirect_uri)
token_data = instagram_token_payload(data) if ok else {}
access_token = token_data.get("access_token")
user_id = token_data.get("user_id")
if not access_token or not user_id:
# The code is single-use and short-lived, so a stale or replayed one
# lands here rather than on a Graph error later.
raise HTTPException(status_code=400, detail=graph_detail(
data, "Could not complete Instagram login — please try connecting again"))
# The login token lasts about an hour; without extending it the connection
# dies silently mid-conversation. Best-effort, like the Page flow.
ok, extended = await exchange_instagram_long_lived(access_token)
if ok and extended.get("access_token"):
access_token = extended["access_token"]
else:
logger.warning("Could not extend the Instagram token; it may expire within the hour")
ok, profile = await graph_get("me", access_token,
params={"fields": "user_id,username"},
base=GRAPH_INSTAGRAM_BASE)
username = profile.get("username") if ok else None
# Webhooks route Instagram events by the professional account id, which /me
# reports as user_id. The login response also carries a user_id but it can
# be app-scoped, and keying on the wrong one means inbound DMs never match
# any account — silently, with no error. Prefer the authoritative one.
account_id = str((profile.get("user_id") if ok else None) or user_id)
account = _upsert_account(
db, organization, ChannelType.INSTAGRAM.value,
external_account_id=account_id,
credentials={"access_token": access_token},
display_name=request.display_name or ("@" + (username or account_id)),
)
if not await subscribe_instagram_app(access_token):
logger.warning(f"Instagram webhook subscribe failed for {account_id}; DMs may not arrive")
return to_account_out(db, account)
@router.post("/instagram", response_model=ChannelAccountOut)
async def connect_instagram(
request: InstagramConnectRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Connect an Instagram professional account (via its linked page token)."""
ok, data = await graph_get(request.ig_id, request.page_access_token,
params={"fields": "id,username"})
if not ok:
raise HTTPException(status_code=400,
detail=f"Could not verify Instagram credentials: {data.get('error', {}).get('message', 'invalid token or account id')}")
# Webhook routing matches entry.id against this value, so reject anything
# that isn't the actual IG business-account id (e.g. a pasted Page id —
# the page token can read that node too, so ok=True alone isn't enough).
if str(data.get("id")) != request.ig_id:
raise HTTPException(status_code=400,
detail="The ID entered is not an Instagram professional account ID")
account = _upsert_account(
db, organization, ChannelType.INSTAGRAM.value,
external_account_id=request.ig_id,
credentials={"access_token": request.page_access_token},
display_name=request.display_name or f"@{data.get('username', request.ig_id)}",
)
return to_account_out(db, account)
@router.delete("/{account_id}")
async def disconnect_meta_account(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Disconnect a WhatsApp/Messenger/Instagram account."""
account = get_org_account_or_404(db, account_id, organization)
if account.channel_type not in (ChannelType.WHATSAPP.value,
ChannelType.MESSENGER.value,
ChannelType.INSTAGRAM.value):
raise HTTPException(status_code=404, detail="Channel account not found")
await leave_own_app(account)
ChannelAccountRepository(db).delete(account)
return {"status": "disconnected"}
+162
View File
@@ -0,0 +1,162 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import secrets
from urllib.parse import urlencode
from uuid import UUID
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.api.channels.accounts import get_org_account_or_404
from app.channels.slack import SLACK_API_BASE, slack_api
from app.core.auth import get_current_organization, require_permissions
from app.core.config import settings
from app.core.oauth_state import pop_state, store_state
from app.database import get_db
from app.models.channels import ChannelAccount, ChannelType
from app.models.organization import Organization
from app.models.user import User
from app.repositories.channels import ChannelAccountRepository
from app.core.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
# Bot scopes for the customer-chat use case. Keep this minimal — the
# Marketplace review asks us to justify each one, and once listed the app can
# only use approved scopes:
# app_mentions:read receive app_mention, so the agent answers when @mentioned
# chat:write post the reply and the "typing…" placeholder
# im:history receive message.im, so the agent answers DMs
# users:read users.info, so a customer shows as a name not a raw U0… id
OAUTH_SCOPES = "app_mentions:read,chat:write,im:history,users:read"
def _redirect_uri() -> str:
return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/channels/slack/callback"
def _settings_redirect(status: str, reason: str = None) -> RedirectResponse:
params = {"status": status, "integration": "slack"}
if reason:
params["reason"] = reason
return RedirectResponse(f"{settings.FRONTEND_URL}/settings/integrations?{urlencode(params)}")
@router.get("/install")
async def install_slack(
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
):
"""Start the Slack OAuth install: redirect the browser to Slack's consent
page with a CSRF state bound to the organization."""
if not settings.SLACK_CLIENT_ID or not settings.SLACK_CLIENT_SECRET:
raise HTTPException(status_code=400, detail="Slack app credentials are not configured")
state = secrets.token_urlsafe(24)
store_state("slack", state, str(organization.id))
params = urlencode({
"client_id": settings.SLACK_CLIENT_ID,
"scope": OAUTH_SCOPES,
"redirect_uri": _redirect_uri(),
"state": state,
})
return RedirectResponse(f"https://slack.com/oauth/v2/authorize?{params}")
@router.get("/callback")
async def slack_oauth_callback(
code: str = None,
state: str = None,
error: str = None,
db: Session = Depends(get_db),
):
"""Complete the OAuth install: exchange the code, store the workspace as a
channel account, and bounce back to the settings page."""
if error:
return _settings_redirect("failure", "cancelled" if error == "access_denied" else error)
org_id = pop_state("slack", state or "")
if org_id is None or not code:
return _settings_redirect("failure", "invalid_state")
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(f"{SLACK_API_BASE}/oauth.v2.access", data={
"client_id": settings.SLACK_CLIENT_ID,
"client_secret": settings.SLACK_CLIENT_SECRET,
"code": code,
"redirect_uri": _redirect_uri(),
})
data = response.json()
if not data.get("ok"):
logger.error(f"Slack OAuth exchange failed: {data.get('error')}")
return _settings_redirect("failure", str(data.get("error", "oauth_failed")))
team = data.get("team") or {}
team_id = team.get("id", "")
repo = ChannelAccountRepository(db)
existing = repo.get_by_external_id(ChannelType.SLACK.value, team_id)
credentials = {
"access_token": data.get("access_token"),
"bot_user_id": data.get("bot_user_id"),
}
if existing is not None:
if str(existing.organization_id) != org_id:
return _settings_redirect("failure", "workspace_connected_elsewhere")
repo.update_credentials(existing, credentials)
repo.set_active(existing, True)
else:
repo.create_account(
organization_id=UUID(org_id),
channel_type=ChannelType.SLACK.value,
external_account_id=team_id,
credentials=credentials,
display_name=team.get("name", team_id),
)
logger.info(f"Connected Slack workspace {team.get('name')} for org {org_id}")
return _settings_redirect("success")
async def _revoke_token(repo: ChannelAccountRepository, account: ChannelAccount) -> None:
"""Best-effort: tell Slack to invalidate the bot token before we drop it.
Never blocks the disconnect — an already-revoked token, an undecryptable
blob, or an unreachable Slack must still leave the workspace disconnected
on our side.
"""
try:
token = repo.get_credentials(account).get("access_token")
if not token:
return
await slack_api("auth.revoke", token, {}, form=True)
except Exception as e:
logger.warning(f"Slack auth.revoke failed, disconnecting anyway: {e}")
@router.delete("/{account_id}")
async def disconnect_slack(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
account = get_org_account_or_404(db, account_id, organization)
if account.channel_type != ChannelType.SLACK.value:
raise HTTPException(status_code=404, detail="Channel account not found")
repo = ChannelAccountRepository(db)
await _revoke_token(repo, account)
repo.delete(account)
return {"status": "disconnected"}
+108
View File
@@ -0,0 +1,108 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.channels.accounts import get_org_account_or_404, to_account_out
from app.channels.sms import get_provider, list_providers
from app.core.auth import get_current_organization, require_permissions
from app.core.config import settings
from app.database import get_db
from app.models.channels import ChannelType
from app.models.organization import Organization
from app.models.schemas.channel import ChannelAccountOut, SmsConnectRequest, SmsProviderInfo
from app.models.user import User
from app.repositories.channels import ChannelAccountRepository
from app.core.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
@router.get("/providers", response_model=List[SmsProviderInfo])
async def list_sms_providers(
current_user: User = Depends(require_permissions("manage_organization")),
):
"""Available SMS providers and the credential fields each needs — drives
the connect form's provider dropdown."""
return [
SmsProviderInfo(
name=p.name, label=p.label,
fields=[{"key": f.key, "label": f.label, "secret": f.secret, "optional": f.optional}
for f in p.credential_fields],
)
for p in list_providers()
]
@router.post("", response_model=ChannelAccountOut)
async def connect_sms(
request: SmsConnectRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Connect an SMS number through one of the supported providers. Validates
the credentials with the provider before saving."""
provider = get_provider(request.provider)
if provider is None:
raise HTTPException(status_code=400, detail=f"Unknown SMS provider '{request.provider}'")
phone = request.phone_number.strip()
try:
await provider.validate_credentials(phone, request.credentials)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
repo = ChannelAccountRepository(db)
existing = repo.get_by_external_id(ChannelType.SMS.value, phone)
if existing is not None:
if existing.organization_id != organization.id:
raise HTTPException(status_code=409, detail="This number is already connected to another organization")
repo.update_credentials(existing, request.credentials)
existing.settings = {**(existing.settings or {}), "provider": request.provider}
db.commit()
account = repo.set_active(existing, True)
else:
account = repo.create_account(
organization_id=organization.id,
channel_type=ChannelType.SMS.value,
external_account_id=phone,
credentials=request.credentials,
display_name=f"SMS {phone} ({provider.label})",
settings={"provider": request.provider},
)
out = to_account_out(db, account)
out.webhook_url = f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/sms/{request.provider}/{account.id}"
return out
@router.delete("/{account_id}")
async def disconnect_sms(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
account = get_org_account_or_404(db, account_id, organization)
if account.channel_type != ChannelType.SMS.value:
raise HTTPException(status_code=404, detail="Channel account not found")
ChannelAccountRepository(db).delete(account)
return {"status": "disconnected"}
+104
View File
@@ -0,0 +1,104 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.channels.accounts import get_org_account_or_404, to_account_out
from app.channels import telegram as telegram_api
from app.core.auth import get_current_organization, require_permissions
from app.core.error_handlers import ClientSafeHTTPException
from app.database import get_db
from app.models.channels import ChannelType
from app.models.organization import Organization
from app.models.schemas.channel import ChannelAccountOut, TelegramConnectRequest
from app.models.user import User
from app.repositories.channels import ChannelAccountRepository
from app.services import telegram_webhooks
from app.core.logger import get_logger
router = APIRouter()
logger = get_logger(__name__)
@router.post("", response_model=ChannelAccountOut)
async def connect_telegram(
request: TelegramConnectRequest,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Connect a Telegram bot: validate the token with getMe, store the
encrypted credentials, and register our webhook with a secret token."""
bot_token = request.bot_token.strip()
bot = await telegram_api.get_me(bot_token)
if bot is None:
raise HTTPException(status_code=400, detail="Invalid bot token")
repo = ChannelAccountRepository(db)
existing = repo.get_by_external_id(ChannelType.TELEGRAM.value, str(bot["id"]))
if existing is not None:
if existing.organization_id != organization.id:
raise HTTPException(status_code=409, detail="This bot is already connected to another organization")
repo.update_credentials(existing, {"bot_token": bot_token})
account = repo.set_active(existing, True)
created = False
else:
account = repo.create_account(
organization_id=organization.id,
channel_type=ChannelType.TELEGRAM.value,
external_account_id=str(bot["id"]),
credentials={"bot_token": bot_token},
display_name=f"@{bot.get('username', bot['id'])}",
)
created = True
webhook_ok, webhook_error = await telegram_webhooks.register(account, bot_token)
if not webhook_ok:
# Roll back only a freshly created account; keep an existing one (and
# its conversation history) intact on a transient webhook failure.
if created:
repo.delete(account)
detail = f"Failed to register Telegram webhook: {webhook_error}"
if "https" in webhook_error.lower():
detail += " — set BACKEND_URL to a public HTTPS URL (e.g. your ngrok domain)"
raise ClientSafeHTTPException(status_code=502, detail=detail)
logger.info(f"Connected Telegram bot @{bot.get('username')} for org {organization.id}")
return to_account_out(db, account)
@router.delete("/{account_id}")
async def disconnect_telegram(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Disconnect a Telegram bot: remove the webhook and delete the account."""
account = get_org_account_or_404(db, account_id, organization)
if account.channel_type != ChannelType.TELEGRAM.value:
raise HTTPException(status_code=404, detail="Channel account not found")
repo = ChannelAccountRepository(db)
try:
await telegram_api.delete_webhook(repo.get_credentials(account)["bot_token"])
except Exception as e:
logger.warning(f"Failed to remove Telegram webhook during disconnect: {e}")
repo.delete(account)
return {"status": "disconnected"}
@@ -0,0 +1,217 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Sending on an already-connected WhatsApp number: templates and outbound
conversations.
Separate from meta.py, which onboards accounts. These routes are inbox work
done by support agents on a live number, not org-admin onboarding, and they are
the only ones that care about WhatsApp Business Accounts and templates. They
mount under the same /meta prefix, so the URLs are unchanged.
"""
from urllib.parse import urlencode
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.channels.accounts import get_org_account_or_404
from app.channels import get_adapter
from app.channels.meta_base import fetch_message_templates, graph_auth_refused, graph_detail, graph_get
from app.channels.constants import MessageSource
from app.core.error_handlers import ClientSafeHTTPException
from app.core.auth import (
CHAT_MANAGE_PERMISSIONS,
INBOX_PERMISSIONS,
get_current_organization,
require_any_permission,
require_permissions,
)
from app.database import get_db
from app.models.channels import ChannelType
from app.models.organization import Organization
from app.models.schemas.channel import (
OutboundConversationOut,
OutboundConversationRequest,
TemplateLibraryOut,
TemplateOut,
TemplateSendRequest,
)
from app.models.user import User
from app.repositories.channels import ChannelAccountRepository, ChannelConversationRepository
from app.services.whatsapp_outbound import OutboundError, start_outbound_conversation
router = APIRouter()
# Meta's own template authoring UI, which is where we send people to write one.
TEMPLATE_LIBRARY_URL = "https://business.facebook.com/latest/whatsapp_manager/template_library"
# Reading the template list is inbox work — the picker needs it — so any role
# that works the inbox qualifies. INBOX_PERMISSIONS is shared with the People
# page so the two surfaces can't disagree about who that is.
require_inbox_agent = require_any_permission(*INBOX_PERMISSIONS)
# Sending is not: an outbound template is a billable Meta message to a phone
# number, with opt-in obligations attached. It takes the same grant as taking
# over or reassigning a chat, so a view-only role can browse templates without
# being able to fire one.
require_inbox_sender = require_any_permission(*CHAT_MANAGE_PERMISSIONS)
def _whatsapp_account_or_404(db: Session, account_id: UUID, organization: Organization):
account = get_org_account_or_404(db, account_id, organization)
if account.channel_type != ChannelType.WHATSAPP.value:
raise HTTPException(status_code=404, detail="Channel account not found")
return account
async def _fetch_all_templates(db: Session, account) -> list[TemplateOut]:
"""Every template on the account's WABA (transport lives in meta_base; this
wrapper owns the HTTP error surface)."""
waba_id, access_token = _waba_credentials(db, account)
ok, data = await fetch_message_templates(waba_id, access_token)
if not ok:
if graph_auth_refused(data):
ChannelAccountRepository(db).record_auth_failure(
account, graph_detail(data, "Authentication Error"))
raise ClientSafeHTTPException(status_code=502,
detail=graph_detail(data, "Could not list templates"))
return [TemplateOut(**template) for template in data]
def _waba_credentials(db: Session, account) -> tuple[str, str]:
"""(waba_id, access_token) for a WhatsApp account.
Templates live on the WhatsApp Business Account, not the phone number, and
the WABA id is optional in the credential blob — accounts connected without
it can send and receive but cannot manage templates.
"""
credentials = ChannelAccountRepository(db).get_credentials(account)
waba_id = credentials.get("waba_id")
if not waba_id:
raise HTTPException(
status_code=400,
detail="Reconnect this number with its WhatsApp Business Account ID to manage templates",
)
return waba_id, credentials["access_token"]
@router.post("/whatsapp/{account_id}/conversations", response_model=OutboundConversationOut)
async def start_whatsapp_conversation(
account_id: UUID,
request: OutboundConversationRequest,
current_user: User = Depends(require_inbox_sender),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Start a WhatsApp conversation with a phone number via an approved
Utility/Authentication template. The thin route: policy lives in the
service so the Phase-2 scheduler can call the same function."""
account = _whatsapp_account_or_404(db, account_id, organization)
try:
sent = await start_outbound_conversation(
db, account,
to=request.to,
template_name=request.template_name,
language=request.language,
components=request.components,
customer_id=request.customer_id,
customer_name=request.customer_name,
source=MessageSource.HUMAN,
)
except OutboundError as e:
# OutboundError details are written for the operator ("Recipient not on
# WhatsApp"), so they survive the 5xx sanitizer.
raise ClientSafeHTTPException(status_code=e.status_code, detail=e.detail)
return OutboundConversationOut(session_id=sent.session_id)
@router.post("/whatsapp/{account_id}/send-template")
async def send_whatsapp_template(
account_id: UUID,
request: TemplateSendRequest,
current_user: User = Depends(require_inbox_sender),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Send an approved template message to reopen an expired 24h window."""
account = _whatsapp_account_or_404(db, account_id, organization)
conversation = ChannelConversationRepository(db).get_by_session(request.session_id)
if conversation is None or conversation.channel_account_id != account.id:
raise HTTPException(status_code=404, detail="Conversation not found for this account")
adapter = get_adapter(ChannelType.WHATSAPP.value)
result = await adapter.send_template(
account, conversation,
template_name=request.template_name,
language=request.language,
components=request.components,
)
ChannelAccountRepository(db).track_send_result(account, result)
if not result.ok:
raise ClientSafeHTTPException(status_code=502, detail=result.error or "Template send failed")
return {"status": "sent", "external_message_id": result.external_message_id}
@router.get("/whatsapp/{account_id}/templates", response_model=list[TemplateOut])
async def list_whatsapp_templates(
account_id: UUID,
# Inbox-agent, not org-admin: this is what the send-template picker and the
# New-conversation modal read. Gating it tighter than the send it feeds made
# the loosening of those endpoints a no-op — the agent could send a template
# but never see one to pick.
current_user: User = Depends(require_inbox_agent),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""List the message templates on this number's WhatsApp Business Account."""
account = _whatsapp_account_or_404(db, account_id, organization)
return await _fetch_all_templates(db, account)
@router.get("/whatsapp/{account_id}/template-library", response_model=TemplateLibraryOut)
async def get_whatsapp_template_library(
account_id: UUID,
current_user: User = Depends(require_permissions("manage_organization")),
organization: Organization = Depends(get_current_organization),
db: Session = Depends(get_db),
):
"""Where to go to write a template: Meta's own Template Library, opened on
this number's WhatsApp Business Account.
Templates are not authored here. Meta's library holds ~150 pre-written,
pre-localised utility templates plus its authentication ones, all shaped to
pass its own review — a form of ours could only ever be a worse version of
that, and every rule it encodes (categories, variable numbering, which
buttons may be mixed) is Meta's to change without telling us.
The link is built per account rather than hardcoded: business_id scopes the
page to the right portfolio. It is best-effort — a business we cannot read
gives a link that lands less precisely, not a dead button.
"""
account = _whatsapp_account_or_404(db, account_id, organization)
waba_id, access_token = _waba_credentials(db, account)
params = {"asset_id": waba_id}
ok, data = await graph_get(waba_id, access_token, params={"fields": "owner_business_info"})
if ok:
business_id = (data.get("owner_business_info") or {}).get("id")
if business_id:
params["business_id"] = business_id
return TemplateLibraryOut(url=f"{TEMPLATE_LIBRARY_URL}?{urlencode(params)}")
+32
View File
@@ -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)
+245
View File
@@ -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)
+101
View File
@@ -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)
+240
View File
@@ -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 ![](url)."""
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}
+281
View File
@@ -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)
+86
View File
@@ -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)}
)
+422
View File
@@ -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)
+558
View File
@@ -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
}
+538
View File
@@ -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))
+279
View File
@@ -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
+226
View File
@@ -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
+141
View File
@@ -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}
+746
View File
@@ -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)
+34
View File
@@ -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"])
+42
View File
@@ -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
+78
View File
@@ -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"}
+58
View File
@@ -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"}
+187
View File
@@ -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)
+175
View File
@@ -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"}
+114
View File
@@ -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"}
+81
View File
@@ -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}
+256
View File
@@ -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")
+251
View File
@@ -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)}")
+30
View File
@@ -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",
]
+201
View File
@@ -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,
)
+267
View File
@@ -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")
+67
View File
@@ -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
+266
View File
@@ -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())
+66
View File
@@ -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())
+200
View File
@@ -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)
+172
View File
@@ -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())
+186
View File
@@ -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())
+457
View File
@@ -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
+53
View File
@@ -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
+66
View File
@@ -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
+366
View File
@@ -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())
+38
View File
@@ -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",
]
+60
View File
@@ -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())
+136
View File
@@ -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},
)
+74
View File
@@ -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())
+88
View File
@@ -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())
+95
View File
@@ -0,0 +1,95 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
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())
+173
View File
@@ -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())
+104
View File
@@ -0,0 +1,104 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
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())
+104
View File
@@ -0,0 +1,104 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
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())
+309
View File
@@ -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())
+308
View File
@@ -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())
+532
View File
@@ -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)
+103
View File
@@ -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))
+186
View File
@@ -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)
+581
View File
@@ -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"![{alt}]({_IMG_PLACEHOLDER.format(index=index)})"
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 (`![alt](src)`). 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()
+431
View File
@@ -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
+243
View File
@@ -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)
+180
View File
@@ -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),
)
+63
View File
@@ -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)
+131
View File
@@ -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"
+124
View File
@@ -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)
+238
View File
@@ -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
+267
View File
@@ -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}"
+362
View File
@@ -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")
+189
View File
@@ -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
+114
View File
@@ -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
+241
View File
@@ -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"
)
+42
View File
@@ -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")
+170
View File
@@ -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()
+369
View File
@@ -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
]
+83
View File
@@ -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
+43
View File
@@ -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)
+67
View File
@@ -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
+181
View File
@@ -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
+114
View File
@@ -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()
+55
View File
@@ -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
+134
View File
@@ -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
+117
View File
@@ -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
+319
View File
@@ -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)
+185
View File
@@ -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
+393
View File
@@ -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
+291
View File
@@ -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
+463
View File
@@ -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