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

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

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
"""
Copyright 2024-2026 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate'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 ChatterMate. 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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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"_chattermate.{row.custom_domain}",
value=f"cm-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.chattermate.chat, 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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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="ChatterMate 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 (chattermate.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/chattermate.min.js",
# This install's API root, handed to the loader as window.chattermateBaseUrl
# (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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate",
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 = "ChatterMate 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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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 ChatterMate
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)}")