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

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

No files matched your search

+532
View File
@@ -0,0 +1,532 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from app.agents.chat_agent import ChatAgent
from app.channels import InboundMessage, ChannelInteraction, get_adapter
from app.channels.app_hooks import HandledBy
from app.channels.base import InteractiveMessage
from app.channels.constants import MessageSource
from app.channels.interactive import from_options
from app.channels.interactive_limits import InteractiveLimits
from app.core.security import decrypt_api_key
from app.core.logger import get_logger
from app.database import SessionLocal
from app.models.ai_config import AIModelType
from app.models.channels import ChannelAccount
from app.models.session_to_agent import SessionStatus
from app.repositories.agent import AgentRepository
from app.repositories.ai_config import AIConfigRepository
from app.repositories.chat import ChatRepository
from app.repositories.customer import CustomerRepository
from app.utils.phone import normalize_msisdn
from app.repositories.channels import (
ChannelAccountRepository,
ChannelConversationRepository,
AgentChannelConfigRepository,
)
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services.app_first_routing import announce_inbound, app_claims
from app.services.chat_notifications import notify_new_channel_chat_once, team_not_yet_notified
from app.services.human_routing import HUMAN_ONLY_ACK, notify_customer
from app.services.lead_capture import record_lead_from_response
from app.services.conversation_token import VERIFIED_SCOPE
from app.services.agent_rooms import USER_MESSAGE, relay_stored_message
from app.services.business_message import business_message_context
from app.services.channel_replies import inbound_attributes, reply_text
from app.services.message_delivery import DeliveryResult, deliver_to_customer
# Optional enterprise message-limit check (same seam as the widget path)
try:
from app.enterprise.services.message_limit import check_message_limit
HAS_ENTERPRISE = True
except ImportError:
HAS_ENTERPRISE = False
logger = get_logger(__name__)
async def process_channel_message(account_id, inbound: InboundMessage) -> None:
"""Process one inbound customer message from an external channel.
Channel-agnostic core shared by every webhook route: resolves the
customer and session, stores the message, runs the AI agent (or relays
to the handling human), and delivers the reply through the channel's
adapter. Runs from a BackgroundTask with its own DB session.
"""
db = SessionLocal()
try:
account_repo = ChannelAccountRepository(db)
account = account_repo.get_by_id(account_id)
if account is None or not account.is_active:
logger.warning(f"Ignoring message for missing/inactive channel account {account_id}")
return
adapter = get_adapter(account.channel_type)
agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id)
if agent_id is None:
logger.warning(f"No agent configured for {account.channel_type} account {account.id}")
return
org_id = str(account.organization_id)
customer_id = _get_or_create_customer(db, account, inbound, org_id)
# If the customer still has a synthesized placeholder name, resolve the
# real one via the platform API — only then, not on every message.
if adapter is not None:
await _enrich_customer_name(db, adapter, account, inbound, customer_id)
session_record, conversation, is_new_session = _get_or_create_session(
db, account, inbound, agent_id, customer_id, org_id
)
session_id = str(session_record.session_id)
# Merge channel-specific per-conversation state (e.g. email threading
# headers) so outbound replies can use it.
if adapter is not None:
state = adapter.conversation_state(inbound)
if state:
ChannelConversationRepository(db).merge_extra(conversation, state)
# A human-only agent answers nothing — the chat waits in the inbox for
# someone to pick it up, so it takes the same path as one a human
# already holds.
agent = AgentRepository(db).get_agent(agent_id)
ai_replies_enabled = agent.ai_replies_enabled if agent else True
# Buttons are the agent's choice (off unless turned on), where the
# channel can show them
choice_limits = (adapter.choice_limits_for(conversation)
if adapter is not None and agent is not None and agent.channel_quick_replies else None)
# An installed extension (an external app) may answer first. Its reply
# can land while we wait, so the customer's message keeps the time it
# arrived and still sorts before it.
received_at = datetime.now(timezone.utc)
if await app_claims(db, account, session_record, customer_id, inbound, received_at):
stored = _store_customer_message(db, inbound, session_record, agent_id,
customer_id, org_id, account, created_at=received_at)
await relay_stored_message(session_record, stored, USER_MESSAGE)
if adapter is not None:
await adapter.mark_read(account, inbound.external_message_id)
await announce_inbound(account, session_record, customer_id, inbound,
HandledBy.APP, received_at)
return
# From here the team (AI or inbox) is answering
await notify_new_channel_chat_once(db, session_record, conversation)
# Human is handling (or a transfer is pending, or this agent never uses
# AI): store + relay to the agent dashboard, never run the bot.
if (session_record.user_id is not None
or session_record.status == SessionStatus.TRANSFERRED
or not ai_replies_enabled):
stored = _store_customer_message(db, inbound, session_record, agent_id,
customer_id, org_id, account)
await relay_stored_message(session_record, stored, USER_MESSAGE)
await announce_inbound(account, session_record, customer_id, inbound,
HandledBy.HUMAN, received_at)
# Nothing will answer this on its own, so acknowledge once — on a
# channel an unanswered message reads as a dead number. Only on the
# opening message of a brand-new conversation.
if is_new_session and not ai_replies_enabled:
await notify_customer(db, session_record, HUMAN_ONLY_ACK,
channels_only=False)
return
await announce_inbound(account, session_record, customer_id, inbound,
HandledBy.AI, received_at)
ai_config = AIConfigRepository(db).get_active_config(account.organization_id)
if ai_config is None:
logger.error(f"No AI config for org {org_id}; dropping {account.channel_type} message")
return
if not await _within_message_limit(db, org_id, ai_config):
await _send_via_adapter(
db, session_record,
"Sorry, this assistant has reached its message limit. Please try again later."
)
return
# Show a typing indicator while the agent composes its reply
if adapter is not None:
await adapter.send_typing(account, conversation)
response, bot_message_id = await _run_chat_agent(
db, account, ai_config, inbound,
org_id=org_id, agent_id=str(agent_id),
customer_id=customer_id, session_id=session_id,
conversation=conversation,
choice_limits=choice_limits,
)
if response is None:
# The agent hard-failed. Still send a reply so a typing indicator /
# placeholder (Slack, LINE) resolves instead of dangling.
await _deliver(db, session_record, {
'message': "Sorry, I hit an error handling that — please try again.",
'type': 'chat_response',
}, source=MessageSource.SYSTEM)
return
record_lead_from_response(
db, response,
organization_id=account.organization_id,
agent_id=agent_id,
customer_id=customer_id,
session_id=session_id,
channel=account.channel_type,
# The platform vouches for who is messaging.
scope=VERIFIED_SCOPE,
)
delivery = await _deliver(db, session_record, {
'message': response.message,
'type': 'chat_response',
'transfer_to_human': getattr(response, 'transfer_to_human', False),
'end_chat': getattr(response, 'end_chat', False),
}, source=MessageSource.AI, message_id=bot_message_id,
interactive=_offered_choices(response, choice_limits))
if not delivery.ok and bot_message_id is not None:
ChatRepository(db).mark_delivery_failed(bot_message_id, delivery.reason)
await _send_channel_prompts(db, adapter, account, conversation, response)
except Exception as e:
logger.error(f"Error processing channel message: {e}", exc_info=True)
finally:
db.close()
async def _send_channel_prompts(db, adapter, account, conversation, response) -> None:
"""After the reply, surface channel-native prompts. Rating is deliberately
NOT asked on external channels — it is a web-widget-only feature; here we
only request a phone number when the agent asks for contact."""
if adapter is None:
return
try:
if getattr(response, 'request_contact', False):
await adapter.request_phone(
account, conversation,
"To help us follow up, tap below to share your phone number.")
except Exception as e:
logger.error(f"Failed sending channel prompt: {e}")
async def process_channel_interaction(account_id, interaction: ChannelInteraction) -> None:
"""Handle a non-message interaction (a shared phone number) from a channel.
Runs from a BackgroundTask with its own DB session."""
db = SessionLocal()
try:
account = ChannelAccountRepository(db).get_by_id(account_id)
if account is None or not account.is_active:
return
conversation = ChannelConversationRepository(db).get_latest(
account.id, interaction.external_conversation_id)
if conversation is None:
return
if interaction.type == "contact":
_handle_contact(db, conversation, interaction)
except Exception as e:
logger.error(f"Error processing channel interaction: {e}", exc_info=True)
finally:
db.close()
def _handle_contact(db, conversation, interaction: ChannelInteraction) -> None:
"""Store a shared phone number on the customer record."""
if not interaction.phone or conversation.customer_id is None:
return
from app.models.customer import Customer
customer = db.query(Customer).filter(Customer.id == conversation.customer_id).first()
if customer is None:
return
meta = dict(customer.meta_data or {})
meta["phone"] = interaction.phone
customer.meta_data = meta
# Also promote to the identity column (set-if-absent, conflict-guarded) so
# the person becomes phone-addressable; meta_data keeps the raw value for
# anything already reading it there. msisdn-lenient because the number is
# platform-relayed (Telegram sends it with or without '+'), and one commit
# for both writes — separate transactions left a crash window where the
# two stores disagreed.
phone = normalize_msisdn(interaction.phone)
if phone:
CustomerRepository(db).set_phone_if_absent(customer, phone)
db.commit()
logger.info(f"Stored shared phone for customer {conversation.customer_id}")
def _get_or_create_customer(db: Session, account: ChannelAccount,
inbound: InboundMessage, org_id: str) -> str:
"""Resolve the platform user to a Customer.
When the channel already gives us the customer's real email (e.g. the
email channel), use it verbatim so the same person is unified across
channels and the widget. Otherwise synthesize a stable per-channel address.
Phone is the second identity key: adapters that truthfully know the
customer's number (WhatsApp, SMS) declare it in profile['phone'], and the
repository resolves by phone before email — which is what unifies one human
across phone-bearing channels and outbound sends.
"""
real_email = (inbound.profile or {}).get('email')
real_name = (inbound.profile or {}).get('name')
if real_email and '@' in real_email:
channel_email = real_email.lower()
else:
channel_email = f"{inbound.external_user_id}@{account.channel_type}.channel"
customer = CustomerRepository(db).get_or_create_customer(
email=channel_email,
organization_id=uuid.UUID(org_id),
full_name=real_name or placeholder_name(account.channel_type,
inbound.external_user_id),
# msisdn-lenient: adapters supply platform ids (wa_id, SMS sender),
# which are E.164-without-plus — trusted in a way typed digits aren't.
phone=normalize_msisdn((inbound.profile or {}).get('phone')),
)
# Upgrade an existing placeholder name once we've resolved the real one
# (get_or_create doesn't update an existing customer's name).
if real_name and _is_placeholder_name(customer.full_name, account.channel_type):
try:
customer.full_name = real_name
db.commit()
except Exception as e:
db.rollback()
logger.error(f"Failed to update customer name: {e}")
return str(customer.id)
def placeholder_name(channel_type: str, external_user_id: str) -> str:
"""The stand-in name for a channel person we don't know yet.
Paired with _is_placeholder_name deliberately: one builds the string, the
other recognises it, and the two only work if they agree exactly. Spelling
either out a second time somewhere else (an outbound send, say) makes them
agree by coincidence — and the day the format changes, the detector
silently stops matching and every real name upgrade stops happening.
"""
return f"{channel_type.capitalize()} user {external_user_id[:8]}"
def _is_placeholder_name(name: str, channel_type: str) -> bool:
"""True when the stored name is the synthesized '<Channel> user xxxx' or empty."""
if not name:
return True
return name.startswith(f"{channel_type.capitalize()} user ")
async def _enrich_customer_name(db: Session, adapter, account: ChannelAccount,
inbound: InboundMessage, customer_id: str) -> None:
"""Resolve the customer's real name via the platform API, but only while
the stored name is still a placeholder.
Channels like Slack send just a user id in the webhook, so we look the name
up via e.g. users.info. Gating on the placeholder means we make that network
call once — for a new/unresolved customer — not on every inbound message.
"""
customer = CustomerRepository(db).get_by_id(uuid.UUID(customer_id))
if customer is None or not _is_placeholder_name(customer.full_name, account.channel_type):
return
try:
enrichment = await adapter.fetch_profile(account, inbound.external_user_id)
except Exception as e:
logger.debug(f"Profile enrichment failed (non-critical): {e}")
return
name = (enrichment or {}).get('name')
if not name:
return
try:
customer.full_name = name
db.commit()
except Exception as e:
db.rollback()
logger.error(f"Failed to update customer name: {e}")
def _agent_changed(session_record, agent_id) -> bool:
"""Whether this channel now answers with a different agent than the one the
open session was started with.
A session is bound to one agent: its stored history, and the AI runtime's
own memory for that session id, belong to that agent. Carrying it over to a
newly configured agent makes the new agent answer out of the previous one's
retrieved knowledge, and splits the inbox list in two — the conversations
query groups by agent *and* session, while the detail view keys on session
alone, so the same thread appears twice and neither row opens the other.
A missing session likewise needs a new one. A human-handled or
transferred chat is left alone: rotating it would drop the agent out of a
live conversation mid-reply.
"""
if session_record is None:
return True
if session_record.user_id is not None:
return False
if session_record.status == SessionStatus.TRANSFERRED:
return False
return str(session_record.agent_id) != str(agent_id)
def _get_or_create_session(db: Session, account: ChannelAccount, inbound: InboundMessage,
agent_id, customer_id: str, org_id: str):
"""Find the open session for this platform conversation or start a new one.
Returns (session, conversation, is_new) — callers use is_new to notify the
team only when a conversation actually starts.
"""
conv_repo = ChannelConversationRepository(db)
session_repo = SessionToAgentRepository(db)
conversation = conv_repo.get_active(account.id, inbound.external_conversation_id)
if conversation is not None:
session_record = session_repo.get_session(conversation.session_id)
if not _agent_changed(session_record, agent_id):
conv_repo.touch_inbound(conversation)
return session_record, conversation, False
# Retire the thread so the newly configured agent starts clean. Closing
# the session is what makes get_active skip it, so the create below
# opens a fresh conversation for the same platform thread.
logger.info(
f"Agent changed for {account.channel_type} account {account.id}; "
f"starting a new session for conversation {inbound.external_conversation_id}")
session_repo.close_session(conversation.session_id)
session_id = str(uuid.uuid4())
session_record = session_repo.create_session(
session_id=session_id,
agent_id=str(agent_id),
customer_id=customer_id,
organization_id=org_id,
channel=account.channel_type,
)
conversation = conv_repo.create(
channel_account_id=account.id,
channel_type=account.channel_type,
external_conversation_id=inbound.external_conversation_id,
external_user_id=inbound.external_user_id,
session_id=uuid.UUID(session_id),
organization_id=account.organization_id,
agent_id=agent_id,
customer_id=uuid.UUID(customer_id),
extra=team_not_yet_notified(),
)
return session_record, conversation, True
def _store_customer_message(db: Session, inbound: InboundMessage, session_record,
agent_id, customer_id: str, org_id: str,
account: ChannelAccount, created_at: Optional[datetime] = None):
"""Persist a customer message on the paths the AI doesn't answer (the AI
path persists inside ChatAgent.get_response). Returns the stored row for
the relay."""
message = {
"message": reply_text(db, account.id, inbound),
"message_type": "user",
"session_id": str(session_record.session_id),
"organization_id": org_id,
"agent_id": str(agent_id),
"customer_id": customer_id,
"attributes": inbound_attributes(inbound, account.channel_type),
}
if created_at is not None:
message["created_at"] = created_at
return ChatRepository(db).create_message(message)
async def _within_message_limit(db: Session, org_id: str, ai_config) -> bool:
"""Enterprise subscription limit; permissive on any failure so the
community edition and enterprise errors never block conversations."""
if not (HAS_ENTERPRISE and ai_config.model_type == AIModelType.REPLYPIER):
return True
try:
return await check_message_limit(db, org_id, None, None)
except Exception as e:
logger.error(f"Message limit check failed (allowing message): {e}")
return True
async def _run_chat_agent(db: Session, account: ChannelAccount, ai_config,
inbound: InboundMessage, org_id: str, agent_id: str,
customer_id: str, session_id: str, conversation=None,
choice_limits: Optional[InteractiveLimits] = None):
"""Run the AI agent for one turn. Workflow-enabled agents fall back to the
plain agent on external channels (workflow forms are widget-only).
Returns (response, id of the stored reply row); response is None when the
agent hard-failed."""
chat_agent = await ChatAgent.create_async(
api_key=decrypt_api_key(ai_config.encrypted_api_key),
model_name=ai_config.model_name,
model_type=ai_config.model_type.value if hasattr(ai_config.model_type, 'value') else ai_config.model_type,
base_url=(ai_config.settings or {}).get('base_url'),
org_id=org_id,
agent_id=agent_id,
customer_id=customer_id,
session_id=session_id,
channel=account.channel_type,
extra_context=business_message_context(conversation),
choice_limits=choice_limits,
)
try:
response = await chat_agent.get_response(
message=reply_text(db, account.id, inbound),
session_id=session_id,
org_id=org_id,
agent_id=agent_id,
customer_id=customer_id,
user_attributes=inbound_attributes(inbound, account.channel_type),
)
return response, chat_agent.last_bot_message_id
except Exception as e:
logger.error(f"Chat agent error on {account.channel_type} session {session_id}: {e}")
return None, None
finally:
await chat_agent.safe_cleanup_mcp_tools()
def _offered_choices(response, limits: Optional[InteractiveLimits]) -> Optional[InteractiveMessage]:
"""The agent's choices as the interactive message to send with its reply."""
options = getattr(response, 'quick_replies', None)
return from_options(response.message, options, limits) if options and limits is not None else None
async def _deliver(db: Session, session_record, payload: dict, *, source: MessageSource,
message_id: Optional[int] = None,
interactive: Optional[InteractiveMessage] = None) -> DeliveryResult:
"""Deliver a reply and log any failure.
Unlike the widget path there is no agent socket to notify here, so a failed
send would otherwise be silent: the customer gets nothing and nothing says
so. Callers that have a stored message act further on the result.
"""
result = await deliver_to_customer(db, session_record, payload, message_id=message_id,
interactive=interactive, source=source)
if not result.ok:
logger.warning(
f"Undelivered reply on {getattr(session_record, 'channel', 'unknown')} "
f"session {session_record.session_id}: {result.reason}")
return result
async def _send_via_adapter(db: Session, session_record, text: str) -> None:
"""Send a plain service message to the customer's channel."""
await _deliver(db, session_record, {'message': text, 'type': 'chat_response'},
source=MessageSource.SYSTEM)
+103
View File
@@ -0,0 +1,103 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
What a channel customer's message carries beyond its text — a tapped
button, a shared location — and how a tap on one of the agent's own choices
reads back as the full option.
"""
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.channels.base import InboundMessage
from app.channels.sent_choices import (
SENT_CHOICES_ATTRIBUTE, decode_sent_choices, sent_choice_text, sent_numbered_choice,
)
from app.repositories.chat import ChatRepository
# chat_history.attributes key for a tap: only its type and id. The visible
# text is already the (encrypted) message, and a shared location's details
# live there too — nothing customer-identifying goes in plaintext attributes.
REPLY_ATTRIBUTE = "interactive_reply"
# How far back a tap without reply context is matched to the reply that offered it
RECENT_OFFERS = 10
def inbound_attributes(inbound: InboundMessage, channel_type: str) -> dict:
"""The chat_history attributes for a customer's channel message."""
attributes = {
"channel": channel_type,
"external_message_id": inbound.external_message_id,
}
if inbound.interactive is not None:
attributes[REPLY_ATTRIBUTE] = {
"type": inbound.interactive.type.value,
"id": inbound.interactive.id,
}
if inbound.reply_to_message_id:
attributes["reply_to_message_id"] = inbound.reply_to_message_id
return attributes
def reply_text(db: Session, channel_account_id: UUID, inbound: InboundMessage) -> str:
"""The text to store and answer for this message.
A tap arrives as the button's title, which may have been cut to fit (or,
on Telegram, as just the button's id); a reply to choices shown as a
numbered list may be just the number. The full choice is in the record
of what the offering message sent, looked up within this customer's chat
on this account — by the tap's reply context where the platform gives
one, otherwise as the latest reply that offered the choice.
"""
choice = _tapped_choice(db, channel_account_id, inbound) or _numbered_choice(db, channel_account_id, inbound)
if choice is not None:
return choice
return inbound.text or (inbound.interactive.title if inbound.interactive else "") or ""
def _tapped_choice(db: Session, channel_account_id: UUID, inbound: InboundMessage) -> Optional[str]:
if inbound.interactive is None:
return None
repo = ChatRepository(db)
if inbound.reply_to_message_id:
offered = repo.in_channel_chat_by_external_message_id(
channel_account_id, inbound.external_conversation_id, inbound.reply_to_message_id)
candidates = [offered] if offered is not None else []
else:
candidates = repo.latest_replies_in_channel_chat(
channel_account_id, inbound.external_conversation_id, RECENT_OFFERS)
for offered in candidates:
text = sent_choice_text(_record(offered), inbound.interactive.id)
if text is not None:
return text
return None
def _numbered_choice(db: Session, channel_account_id: UUID, inbound: InboundMessage) -> Optional[str]:
"""A bare number answering our latest reply — the AI's or a person's —
when that reply showed its choices numbered (as text, or alongside
buttons a desktop app hides)."""
typed = (inbound.text or "").strip().rstrip(".")
if inbound.interactive is not None or not typed.isdecimal():
return None
latest = ChatRepository(db).latest_replies_in_channel_chat(
channel_account_id, inbound.external_conversation_id, 1)
return sent_numbered_choice(_record(latest[0]), typed) if latest else None
def _record(message) -> Optional[dict]:
return decode_sent_choices((message.attributes or {}).get(SENT_CHOICES_ATTRIBUTE))
+186
View File
@@ -0,0 +1,186 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Channel delivery receipts (sent / delivered / read / failed) applied to the
stored messages they belong to.
"""
import json
from datetime import datetime, timezone
from typing import List, Set
from uuid import UUID
from sqlalchemy.orm import Session
from app.channels.app_hooks import ChannelEvent, EventKind, emit_channel_event, status_event_id
from app.channels.base import DeliveryState, StatusEvent
from app.core.logger import get_logger
from app.core.redis import get_redis
from app.database import SessionLocal
from app.models.chat_history import ChatHistory
from app.repositories.channels import ChannelAccountRepository
from app.repositories.chat import ChatRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services.agent_rooms import emit_to_org_inbox
logger = get_logger(__name__)
# A receipt can arrive before the send that produced it has stored the
# message id (the Graph response and the webhook race). It waits here until
# record_sent picks it up; long enough to cover the race, short enough that a
# receipt for a message we never stored doesn't linger.
PENDING_KEY = "channel_status_pending:{account_id}:{message_id}"
PENDING_TTL_SECONDS = 600
# Receipts can arrive out of order and more than once, so a status only
# replaces one it outranks. FAILED replaces sent/delivered but never READ:
# a message the customer read was delivered, whatever arrives after.
# Keyed by the stored value (chat_history.channel_status).
_RANK = {DeliveryState.SENT.value: 0, DeliveryState.DELIVERED.value: 1, DeliveryState.READ.value: 2}
def replaceable_by(new: DeliveryState) -> Set[str]:
"""The stored statuses receipt `new` may overwrite (no status at all is
always overwritten)."""
if new == DeliveryState.FAILED:
return {DeliveryState.SENT.value, DeliveryState.DELIVERED.value}
return {state for state, rank in _RANK.items() if rank < _RANK[new.value]}
async def process_status_event(account_id: UUID, event: StatusEvent) -> None:
"""Apply one receipt from a channel webhook. Runs from a BackgroundTask
with its own DB session."""
db = SessionLocal()
try:
account = ChannelAccountRepository(db).get_by_id(account_id)
if account is None:
return
# Announced before matching: extensions count every receipt, including
# ones for messages ReplyPier never stored.
await emit_channel_event(ChannelEvent(
kind=EventKind.STATUS,
event_id=status_event_id(account.id, event.external_message_id, event.status.value),
organization_id=account.organization_id,
channel_account_id=account.id,
occurred_at=event.timestamp or datetime.now(timezone.utc),
status=event,
))
chat_repo = ChatRepository(db)
message = chat_repo.get_by_external_message_id(
account.organization_id, event.external_message_id)
if message is None:
_park(account_id, event)
# record_sent may have stored the id and drained the queue between
# the lookup and the park; look again so the receipt isn't stranded.
message = chat_repo.get_by_external_message_id(
account.organization_id, event.external_message_id)
if message is None:
return
for parked in _take_parked(account_id, event.external_message_id):
await _apply(db, message, parked)
return
await _apply(db, message, event)
except Exception as e:
logger.error(f"Error processing channel status event: {e}", exc_info=True)
finally:
db.close()
async def record_sent(db: Session, message_id: int, account_id: UUID,
external_message_id: str) -> None:
"""Store the platform id of a message we just sent, then apply any
receipts that arrived before it."""
message = ChatRepository(db).set_external_message_id(message_id, external_message_id)
if message is None:
return
for event in _take_parked(account_id, external_message_id):
await _apply(db, message, event)
async def _apply(db: Session, message: ChatHistory, event: StatusEvent) -> None:
chat_repo = ChatRepository(db)
advanced = chat_repo.advance_channel_status(
message.id, event.status.value, event.timestamp or datetime.now(timezone.utc),
replaceable_by(event.status))
if not advanced:
return
db.refresh(message)
if event.status == DeliveryState.FAILED:
# Meta can accept a send and fail it later; this is the only place
# that failure becomes visible.
chat_repo.mark_delivery_failed(message.id, event.error)
session = SessionToAgentRepository(db).get_session(message.session_id)
await emit_to_org_inbox('message_status', {
'session_id': str(message.session_id),
'message_id': message.id,
'external_message_id': message.external_message_id,
'channel_status': message.channel_status,
'delivery_status': (message.attributes or {}).get('delivery_status'),
}, message.organization_id, session.user_id if session else None)
def _pending_key(account_id: UUID, external_message_id: str) -> str:
return PENDING_KEY.format(account_id=account_id, message_id=external_message_id)
def _park(account_id: UUID, event: StatusEvent) -> None:
redis = get_redis()
if redis is None:
return
key = _pending_key(account_id, event.external_message_id)
try:
pipe = redis.pipeline()
pipe.rpush(key, _serialize(event))
pipe.expire(key, PENDING_TTL_SECONDS)
pipe.execute()
except Exception as e:
logger.warning(f"Could not park channel status event: {e}")
def _take_parked(account_id: UUID, external_message_id: str) -> List[StatusEvent]:
redis = get_redis()
if redis is None:
return []
key = _pending_key(account_id, external_message_id)
try:
pipe = redis.pipeline()
pipe.lrange(key, 0, -1)
pipe.delete(key)
raw_events, _ = pipe.execute()
except Exception as e:
logger.warning(f"Could not read parked channel status events: {e}")
return []
return [_deserialize(raw, external_message_id) for raw in raw_events or []]
def _serialize(event: StatusEvent) -> str:
# Only what _apply uses — no recipient number or pricing sits in Redis
return json.dumps({
'status': event.status.value,
'timestamp': event.timestamp.isoformat() if event.timestamp else None,
'error': event.error,
})
def _deserialize(raw: str, external_message_id: str) -> StatusEvent:
data = json.loads(raw)
return StatusEvent(
external_account_id="",
external_message_id=external_message_id,
recipient_id="",
status=DeliveryState(data['status']),
timestamp=datetime.fromisoformat(data['timestamp']) if data['timestamp'] else None,
error=data.get('error'),
)
@@ -0,0 +1,319 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Execution layer for guardrailed ticket DB connectors. All functions here are
synchronous (drivers are sync) — callers run them via asyncio.to_thread.
The read-only guarantee is enforced at the SESSION level, independently of
the SQL guardrails: default_transaction_read_only for Postgres, SESSION
TRANSACTION READ ONLY for MySQL, plus statement timeouts on both.
"""
import io
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from uuid import UUID
from app.core.logger import get_logger
from app.core.outbound_policy import pin_outbound_target
from app.core.security import decrypt_api_key
logger = get_logger(__name__)
CONNECT_TIMEOUT_SECONDS = 8
SSH_CONNECT_TIMEOUT_SECONDS = 10
# Schemas that are never discovered or queried.
SYSTEM_SCHEMAS = (
"pg_catalog", "information_schema", "pg_toast",
"mysql", "performance_schema", "sys",
)
@dataclass
class DBConnectorConfig:
"""Plain snapshot of a TicketDBConnector row (secrets decrypted) so the
executor never holds ORM objects across sessions/threads."""
id: Optional[UUID]
organization_id: Optional[UUID]
name: str
engine: str
host: str
port: int
database: str
username: str
password: str
allowed_tables: List[str] = field(default_factory=list)
masked_columns: List[str] = field(default_factory=list)
# {"table": "customer_column"} — tables restricted to the ticket
# customer's own rows. See sql_guardrails._apply_row_scope.
row_scope: dict = field(default_factory=dict)
row_scope_key: str = "email"
max_rows: int = 100
statement_timeout_ms: int = 5000
# SSH tunnel (bastion/jump host) — the common production topology where
# the database isn't directly reachable.
ssh_enabled: bool = False
ssh_host: Optional[str] = None
ssh_port: int = 22
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_private_key: Optional[str] = None
ssh_private_key_passphrase: Optional[str] = None
@classmethod
def from_model(cls, connector) -> "DBConnectorConfig":
return cls(
id=connector.id,
organization_id=connector.organization_id,
name=connector.name,
engine=str(connector.engine),
host=connector.host,
port=connector.port,
database=connector.database,
username=connector.username,
password=decrypt_api_key(connector.encrypted_password),
allowed_tables=list(connector.allowed_tables or []),
masked_columns=list(connector.masked_columns or []),
row_scope=dict(connector.row_scope or {}),
row_scope_key=connector.row_scope_key or "email",
max_rows=connector.max_rows or 100,
statement_timeout_ms=connector.statement_timeout_ms or 5000,
ssh_enabled=bool(connector.ssh_enabled),
ssh_host=connector.ssh_host,
ssh_port=connector.ssh_port or 22,
ssh_username=connector.ssh_username,
ssh_password=(
decrypt_api_key(connector.encrypted_ssh_password)
if connector.encrypted_ssh_password else None
),
ssh_private_key=(
decrypt_api_key(connector.encrypted_ssh_private_key)
if connector.encrypted_ssh_private_key else None
),
ssh_private_key_passphrase=(
decrypt_api_key(connector.encrypted_ssh_key_passphrase)
if connector.encrypted_ssh_key_passphrase else None
),
)
def _load_ssh_key(key_text: str, passphrase: Optional[str]):
"""Parse a PEM/OpenSSH private key of any common type from a string,
without writing it to disk."""
import paramiko
last_error = None
# DSA is obsolete — Ed25519/RSA/ECDSA cover every key type a modern host
# issues.
for key_class in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey):
try:
return key_class.from_private_key(
io.StringIO(key_text), password=passphrase or None
)
except Exception as e: # wrong type or bad passphrase — try the next
last_error = e
raise ValueError(f"Unsupported or unreadable SSH private key: {last_error}")
@contextmanager
def _ssh_tunnel(config: DBConnectorConfig, ssh_address: Optional[str] = None):
"""Open an SSH local-forward tunnel to the database and yield the local
(host, port) to connect through. ``ssh_address`` is the bastion's vetted
IP when the outbound policy pinned one. Always torn down on exit."""
from sshtunnel import SSHTunnelForwarder
kwargs = {
"ssh_username": config.ssh_username,
"remote_bind_address": (config.host, config.port),
# 127.0.0.1:0 — the OS assigns a free local port per tunnel.
"local_bind_address": ("127.0.0.1", 0),
"set_keepalive": 15.0,
}
if config.ssh_private_key:
kwargs["ssh_pkey"] = _load_ssh_key(
config.ssh_private_key, config.ssh_private_key_passphrase
)
elif config.ssh_password:
kwargs["ssh_password"] = config.ssh_password
else:
raise ValueError("SSH tunnel needs a private key or password")
tunnel = SSHTunnelForwarder((ssh_address or config.ssh_host, config.ssh_port), **kwargs)
tunnel.start()
try:
yield "127.0.0.1", tunnel.local_bind_port
finally:
try:
tunnel.stop()
except Exception as e:
logger.warning(f"SSH tunnel teardown warning (non-critical): {e}")
def _driver_connect(config: DBConnectorConfig, host: str, port: int,
hostaddr: Optional[str] = None):
"""Open a read-only DB session with a statement timeout, to (host, port)
— which is the tunnel's local endpoint when tunneling. ``hostaddr`` is
the vetted IP to dial instead of resolving ``host`` again."""
if config.engine == "mysql":
import pymysql
conn = pymysql.connect(
# pymysql has no separate address field; no TLS is configured
# here, so the name isn't needed for verification.
host=hostaddr or host,
port=port,
user=config.username,
password=config.password,
database=config.database,
connect_timeout=CONNECT_TIMEOUT_SECONDS,
read_timeout=max(1, config.statement_timeout_ms // 1000 + 1),
)
with conn.cursor() as cursor:
cursor.execute("SET SESSION TRANSACTION READ ONLY")
# max_execution_time bounds SELECTs server-side (milliseconds).
cursor.execute(f"SET SESSION max_execution_time = {int(config.statement_timeout_ms)}")
return conn
import psycopg
# libpq dials hostaddr and keeps host for TLS verification.
pinned = {"hostaddr": hostaddr} if hostaddr else {}
return psycopg.connect(
host=host,
**pinned,
port=port,
dbname=config.database,
user=config.username,
password=config.password,
connect_timeout=CONNECT_TIMEOUT_SECONDS,
options=(
f"-c statement_timeout={int(config.statement_timeout_ms)} "
"-c default_transaction_read_only=on"
),
)
def first_hop(ssh_enabled: bool, ssh_host: Optional[str], host: Optional[str]) -> Tuple[Optional[str], str]:
"""The host this server dials first, with its field label, for the
outbound policy: the SSH bastion when tunnelling, else the database. A
database behind a bastion is reached from the bastion, and may be private."""
if ssh_enabled:
return ssh_host, "SSH host"
return host, "Database host"
@contextmanager
def _connection(config: DBConnectorConfig):
"""Yield a read-only DB connection, opening an SSH tunnel first when the
connector is configured for one. Tunnel and connection are both closed on
exit — a fresh pair per call keeps the (short-lived) investigation queries
isolated."""
# Vetted on every connect, not only on save, and the vetted address is
# the one dialled: DNS can change, even between two lookups.
pinned = pin_outbound_target(*first_hop(config.ssh_enabled, config.ssh_host, config.host))
if config.ssh_enabled:
if not config.ssh_host or not config.ssh_username:
raise ValueError("SSH tunnel is enabled but host/username are missing")
with _ssh_tunnel(config, pinned) as (host, port):
conn = _driver_connect(config, host, port)
try:
yield conn
finally:
try:
conn.close()
except Exception:
pass
else:
conn = _driver_connect(config, config.host, config.port, pinned)
try:
yield conn
finally:
try:
conn.close()
except Exception:
pass
def run_readonly_query(
config: DBConnectorConfig, sql: str
) -> Tuple[List[str], List[tuple]]:
"""Execute validated SQL in a read-only session; fetches at most
max_rows + 1 (the validator already forced a LIMIT — this is belt and
braces)."""
with _connection(config) as conn:
with conn.cursor() as cursor:
cursor.execute(sql)
columns = [d[0] for d in (cursor.description or [])]
rows = cursor.fetchmany(config.max_rows + 1)
return columns, list(rows)
def test_connection(config: DBConnectorConfig) -> Dict:
"""Connect and discover user tables + columns (for the allowlist picker).
Returns {"tables": [{"schema", "table", "columns": [{"name", "type"}]}]}."""
placeholders = ", ".join(["%s"] * len(SYSTEM_SCHEMAS))
sql = (
"SELECT table_schema, table_name, column_name, data_type "
"FROM information_schema.columns "
f"WHERE table_schema NOT IN ({placeholders}) "
"ORDER BY table_schema, table_name, ordinal_position"
)
with _connection(config) as conn:
with conn.cursor() as cursor:
cursor.execute(sql, SYSTEM_SCHEMAS)
rows = cursor.fetchall()
tables: Dict[Tuple[str, str], List[Dict]] = {}
for schema, table, column, data_type in rows:
tables.setdefault((schema, table), []).append({"name": column, "type": data_type})
return {
"tables": [
{"schema": schema, "table": table, "columns": columns}
for (schema, table), columns in tables.items()
]
}
def describe_columns(config: DBConnectorConfig, schema: str, table: str) -> List[Dict]:
"""Column names/types of one table via information_schema."""
with _connection(config) as conn:
with conn.cursor() as cursor:
cursor.execute(
"SELECT column_name, data_type FROM information_schema.columns "
"WHERE table_schema = %s AND table_name = %s ORDER BY ordinal_position",
(schema, table),
)
return [{"name": name, "type": data_type} for name, data_type in cursor.fetchall()]
def format_result_table(
columns: List[str], rows: List[tuple], max_rows: int, max_value_chars: int = 200
) -> str:
"""Compact text rendering of a result set for the model."""
if not columns:
return "(no result)"
shown = rows[:max_rows]
lines = [" | ".join(columns)]
for row in shown:
lines.append(
" | ".join(
(str(value)[:max_value_chars] if value is not None else "NULL")
for value in row
)
)
suffix = f"({len(shown)} row{'s' if len(shown) != 1 else ''}"
if len(rows) > max_rows:
suffix += ", truncated"
suffix += ")"
lines.append(suffix)
return "\n".join(lines)
+581
View File
@@ -0,0 +1,581 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Article-mode help-center import: crawl the linked article pages of an existing
help-center/FAQ index and import each one AS-IS — HTML converted straight to
Markdown (no LLM, no credits), images downloaded and re-hosted on our storage,
links absolutized. One draft FAQ per article: question = page title, answer =
the article body in Markdown.
"""
import asyncio
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from urllib.parse import urldefrag, urljoin, urlparse
import httpx
from bs4 import BeautifulSoup, Tag
from markdownify import MarkdownConverter
from app.core.config import settings
from app.core.logger import get_logger
from app.knowledge.main_content import select_main_node
from app.knowledge.sitemap_parser import _registrable_domain
from app.knowledge.url_safety import safe_get
from app.models.faq import FAQ, FAQ_SOURCE_URL_MAX_LENGTH, FAQStatus
from app.models.faq_generation_job import FAQGenerationJob, FAQJobStage
from app.models.schemas.faq import MAX_ANSWER_LENGTH, MAX_QUESTION_LENGTH
from app.repositories.faq import FAQRepository
from app.repositories.faq_generation_job import FAQGenerationJobRepository
from app.services.faq_generation import CategoryMerger, insert_draft_rows, normalize_question
from app.services.faq_import import _FETCH_HEADERS, MIN_STATIC_TEXT_CHARS
from app.services.help_center_images import (
FAQ_IMAGE_TYPES,
MAX_FAQ_IMAGE_BYTES,
store_article_image,
)
from app.services.help_center_settings import assign_faq_url_paths
logger = get_logger(__name__)
DEFAULT_ARTICLE_CATEGORY = "Imported"
# Link targets that can never be article pages.
_SKIP_EXTENSIONS = (
".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".zip", ".mp4",
".css", ".js", ".xml", ".ico", ".woff", ".woff2", ".txt", ".json",
)
# In-article chrome that shouldn't survive into the imported Markdown.
# Word-boundary anchored: matches "docs-toc"/"breadcrumbs" but never innocent
# substrings like "stock-levels" or "autocomplete".
_TOC_RE = re.compile(r"\b(toc|table-of-contents|on-this-page|breadcrumbs?)\b", re.IGNORECASE)
# Help-center chrome text (platform branding / article metadata) to drop.
# Platform-specific so it never strips legitimate prose like "Powered by our
# AI engine" — only footer branding for known help-center platforms and the
# "Last updated on …" article-metadata line.
_CHROME_TEXT_RE = re.compile(
r"^last updated on\b"
r"|^(made with|powered by)\b.*\b(chatwoot|intercom|zendesk|help ?scout|gitbook|freshdesk|document360)\b",
re.IGNORECASE,
)
_CHROME_HREFS = ("chatwoot.com", "chatwoot.help")
# Path markers separating leaf article pages from category/section listings
# across Chatwoot / Zendesk / Intercom-style help centers. Only /articles/
# pages hold real content; the listings are followed for their article links.
_ARTICLE_MARKER = "/articles/"
_CATEGORY_MARKERS = ("/categories/", "/sections/", "/collections/")
_HEADING_TAGS = ("h1", "h2", "h3", "h4")
_LEADING_SYMBOLS_RE = re.compile(r"^[^\w]+", re.UNICODE)
# Homepage section headings that are curated cross-cuts, not real categories —
# their articles' true category comes from the category listing pages instead.
_GENERIC_SECTIONS = frozenset({
"featured articles", "featured", "popular", "popular articles", "all articles",
"browse", "recent articles", "recently updated", "top articles",
})
# Internal scheme marking images collected during conversion, replaced with the
# re-hosted URL afterwards (conversion is sync; storage is async). The trailing
# ".img" delimits the index so "…//1.img" is never a prefix of "…//10.img".
_IMG_PLACEHOLDER = "rp-pending-image://{index}.img"
@dataclass
class Article:
url: str
title: str
markdown: str
category_hint: str
# index -> (bytes, content_type) awaiting re-host.
pending_images: Dict[int, Tuple[bytes, str]] = field(default_factory=dict)
class _ArticleConverter(MarkdownConverter):
"""HTML→Markdown converter that absolutizes links and swaps images for
placeholders after downloading them (SSRF-guarded, size/type-capped)."""
def __init__(self, base_url: str, client: httpx.Client, **options):
super().__init__(**options)
self._base_url = base_url
self._client = client
self.images: Dict[int, Tuple[bytes, str]] = {}
def convert_a(self, el, text, parent_tags):
# Absolutize every link; same-site source links are remapped to the
# destination help center in a second pass (run_article_import_job),
# once every article's new FAQ slug is known.
href = el.get("href")
if href:
el["href"] = urljoin(self._base_url, href)
return super().convert_a(el, text, parent_tags)
def convert_img(self, el, text, parent_tags):
alt = (el.get("alt") or "").strip()
src = (el.get("src") or "").strip()
if not src or src.startswith("data:"):
return alt # inline/base64 images are stripped by the renderer anyway
if len(self.images) >= settings.FAQ_ARTICLE_IMPORT_MAX_IMAGES:
return alt
image = self._download(urljoin(self._base_url, src))
if not image:
return alt
index = len(self.images)
self.images[index] = image
return f"![{alt}]({_IMG_PLACEHOLDER.format(index=index)})"
def _download(self, url: str) -> Optional[Tuple[bytes, str]]:
try:
response = safe_get(self._client, url)
response.raise_for_status()
content_type = (response.headers.get("content-type") or "").split(";")[0].strip()
if content_type not in FAQ_IMAGE_TYPES or len(response.content) > MAX_FAQ_IMAGE_BYTES:
return None
return response.content, content_type
except Exception as e:
logger.warning(f"Article image skipped ({url}): {e}")
return None
def _fetch_soup(client: httpx.Client, url: str) -> Tuple[Optional[BeautifulSoup], str]:
"""Static SSRF-guarded fetch → (soup, final hop-validated URL)."""
try:
response = safe_get(client, url)
response.raise_for_status()
return BeautifulSoup(response.text, "html.parser"), str(response.url)
except httpx.HTTPError as e:
logger.warning(f"Article fetch failed for {url}: {e}")
return None, url
def _fetch_index_soup(client: httpx.Client, url: str) -> Tuple[Optional[BeautifulSoup], str]:
"""Index fetch with the headless-browser fallback for JS-rendered indexes
(article pages themselves are static-only — one browser per crawl, not 30)."""
soup, final_url = _fetch_soup(client, url)
if soup and len(soup.get_text(" ", strip=True)) >= MIN_STATIC_TEXT_CHARS:
return soup, final_url
try:
from app.knowledge.crawl4ai_fallback import get_crawl4ai_fallback
fallback = get_crawl4ai_fallback(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT)
if fallback.is_available:
html, browser_soup, _ = fallback.fetch_with_browser(final_url)
if browser_soup is not None:
return browser_soup, final_url
if isinstance(html, str) and html:
return BeautifulSoup(html, "html.parser"), final_url
except Exception as e:
logger.warning(f"Article index browser fetch failed for {url}: {e}")
return soup, final_url
def _clean_category(text: str) -> str:
"""Strip a leading emoji/symbol (Chatwoot section headings are '📞 Help
and support') and cap length."""
cleaned = _LEADING_SYMBOLS_RE.sub("", (text or "").strip()).strip()
return cleaned[:100] or DEFAULT_ARTICLE_CATEGORY
def _same_site_href(anchor: Tag, base_url: str, root_domain: str) -> Optional[str]:
"""Absolute same-site content URL for an anchor, or None if it's off-site,
a fragment/query, a bare root, or a binary asset."""
href = anchor.get("href")
if not href:
return None
url = urldefrag(urljoin(base_url, href))[0].rstrip("/")
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or parsed.query:
return None
if parsed.path in ("", "/"):
return None
if _registrable_domain(parsed.hostname or "") != root_domain:
return None
if parsed.path.lower().endswith(_SKIP_EXTENSIONS):
return None
return url
def _is_article_url(url: str) -> bool:
return _ARTICLE_MARKER in urlparse(url).path.lower()
def _is_category_url(url: str) -> bool:
path = urlparse(url).path.lower()
return any(marker in path for marker in _CATEGORY_MARKERS)
def _sectioned_article_links(node: Tag, base_url: str, root_domain: str) -> List[Tuple[str, Optional[str]]]:
"""Article links in document order, each tagged with the nearest preceding
section heading (a help-center homepage's category cards). Headings nested
inside a link are article titles, not sections, so they don't reset it."""
results: List[Tuple[str, Optional[str]]] = []
current: Optional[str] = None
for el in node.find_all([*_HEADING_TAGS, "a"]):
if el.name in _HEADING_TAGS:
if not el.find_parent("a"):
cat = _clean_category(el.get_text(" ", strip=True))
# Curated cross-cut sections ("Featured Articles") aren't real
# categories — leave those articles for the category pages.
current = None if cat.lower() in _GENERIC_SECTIONS else cat
continue
url = _same_site_href(el, base_url, root_domain)
if url and _is_article_url(url):
results.append((url, current))
return results
def discover_article_links(
client: httpx.Client, index_url: str, limit: int
) -> List[Tuple[str, Optional[str]]]:
"""Discover (article_url, category) pairs from a help-center index.
Standard help centers (Chatwoot/Zendesk/Intercom) nest articles under
/articles/ and group them into /categories/ (or /sections//collections/).
We follow the category listing pages FIRST for the complete per-category
list (homepages truncate each section to a few articles), so hitting the
page cap yields whole categories rather than a thin slice of every one.
Homepage section links then fill any remainder (e.g. featured-only
articles). A non-standard/flat page with no /articles/ links falls back to
importing every same-site content link.
"""
soup, final_url = _fetch_index_soup(client, index_url)
if soup is None:
raise ValueError("Could not read that page.")
root_domain = _registrable_domain(urlparse(final_url).hostname or "")
index_clean = urldefrag(final_url)[0].rstrip("/")
main = select_main_node(soup) or soup
ordered: List[str] = []
category_of: Dict[str, Optional[str]] = {}
def record(url: str, category: Optional[str]) -> None:
if url == index_clean:
return
if url not in category_of:
category_of[url] = category
ordered.append(url)
elif category and not category_of[url]:
# Upgrade a still-uncategorised article (seen first under a generic
# homepage section) with the category page's real name.
category_of[url] = category
# 1. Follow category/section listing pages first — the complete per-category
# lists (a homepage shows only ~5 of a category's articles).
category_links: List[str] = []
seen_cat = set()
for anchor in main.find_all("a", href=True):
url = _same_site_href(anchor, final_url, root_domain)
if url and _is_category_url(url) and url not in seen_cat:
seen_cat.add(url)
category_links.append(url)
for cat_url in category_links[: settings.FAQ_ARTICLE_IMPORT_MAX_CATEGORIES]:
if len(ordered) >= limit:
break
cat_soup, cat_final = _fetch_soup(client, cat_url)
if cat_soup is None:
continue
og_title, doc_title = _head_titles(cat_soup)
cat_node = select_main_node(cat_soup) or cat_soup
cat_name = _clean_category(_page_title(og_title, doc_title, cat_node, cat_soup))
for anchor in cat_node.find_all("a", href=True):
url = _same_site_href(anchor, cat_final, root_domain)
if url and _is_article_url(url):
record(url, cat_name)
# 2. Homepage section links — fills any remainder (featured-only articles,
# or the index itself being a single category/flat listing page).
for url, category in _sectioned_article_links(main, final_url, root_domain):
record(url, category)
# 3. Fallback: no standard article URLs — import every same-site link,
# resolving each article's category from its own breadcrumb/URL.
if not ordered:
for anchor in main.find_all("a", href=True):
url = _same_site_href(anchor, final_url, root_domain)
if url:
record(url, None)
ordered = ordered[:limit]
if not ordered:
raise ValueError("No linked article pages found on that page.")
return [(url, category_of[url]) for url in ordered]
def _head_titles(soup: BeautifulSoup) -> Tuple[str, str]:
"""(og:title, <title>) — must run BEFORE select_main_node, which strips
<head> from the soup."""
og = soup.find("meta", property="og:title")
og_title = og["content"].strip() if og and og.get("content", "").strip() else ""
doc_title = ""
if soup.title and soup.title.get_text(strip=True):
# Drop the " | Site Name" tail commonly appended to <title>.
doc_title = soup.title.get_text(strip=True).split("|")[0].strip()
return og_title, doc_title
def _page_title(og_title: str, doc_title: str, node: Tag, soup: BeautifulSoup) -> str:
if og_title:
return og_title
h1 = node.find("h1") or soup.find("h1")
if h1 and h1.get_text(strip=True):
return h1.get_text(" ", strip=True)
return doc_title
def _category_hint(soup: BeautifulSoup, url: str) -> str:
"""Breadcrumb (second-to-last crumb = the article's section) or a URL path
segment, else the generic import bucket."""
crumbs = soup.select('[aria-label*="readcrumb"] a, nav.breadcrumb a, .breadcrumbs a, .breadcrumb a')
texts = [c.get_text(" ", strip=True) for c in crumbs if c.get_text(strip=True)]
if len(texts) >= 2:
# Last crumb is the page itself; the one before is its section.
return texts[-2][:100]
segments = [s for s in urlparse(url).path.split("/") if s]
if len(segments) >= 2:
return segments[-2].replace("-", " ").replace("_", " ").title()[:100]
return DEFAULT_ARTICLE_CATEGORY
def _strip_chrome(node: Tag) -> None:
"""Remove in-article chrome that shouldn't become FAQ content: nav bars,
TOCs, footers, help-center platform branding ('Made with Chatwoot') and
'Last updated' metadata lines. Best-effort — a detached element mid-loop
just skips. (Breadcrumb "Home › Category" nav links are dropped later, in
_remap_source_links, since they often share a container with the title.)"""
for chrome in node.find_all("nav") + node.find_all("footer"):
chrome.decompose()
for chrome in node.find_all(class_=_TOC_RE) + node.find_all(id=_TOC_RE):
try:
chrome.decompose()
except Exception:
pass
# Platform-branding link ("Made with Chatwoot" logo): remove only a SMALL
# enclosing block (the footer badge), never a large content div that merely
# links out to the platform.
for anchor in list(node.find_all("a", href=True)):
try:
href = (anchor.get("href") or "").lower()
if any(brand in href for brand in _CHROME_HREFS):
container = anchor.find_parent(["div", "footer", "section"])
target = container if container and len(container.get_text(" ", strip=True)) <= 40 else anchor
target.decompose()
except Exception:
pass
for el in list(node.find_all(["p", "div", "span", "small", "time"])):
try:
text = el.get_text(" ", strip=True)
if text and len(text) <= 60 and _CHROME_TEXT_RE.search(text):
el.decompose()
except Exception:
pass
def fetch_article(
client: httpx.Client, url: str, category_override: Optional[str] = None,
) -> Optional[Article]:
"""One article page → title + Markdown body (+ images pending re-host).
category_override (from the category listing that linked here) wins over the
per-page breadcrumb/URL guess. None when the page can't be read or is empty."""
soup, final_url = _fetch_soup(client, url)
if soup is None:
return None
og_title, doc_title = _head_titles(soup) # before <head> is stripped below
node = select_main_node(soup)
if node is None:
return None
title = _page_title(og_title, doc_title, node, soup)
if not title:
return None
category = category_override or _category_hint(soup, final_url)
# The page's own h1 would duplicate the FAQ question.
first_h1 = node.find("h1")
if first_h1:
first_h1.extract()
_strip_chrome(node)
converter = _ArticleConverter(
base_url=final_url, client=client, heading_style="ATX", bullets="-"
)
markdown = converter.convert_soup(node).strip()
if not markdown:
return None
return Article(
url=final_url,
title=title[:MAX_QUESTION_LENGTH],
markdown=markdown,
category_hint=category,
pending_images=converter.images,
)
async def _rehost_images(article: Article) -> str:
markdown = article.markdown
for index, (content, content_type) in article.pending_images.items():
placeholder = _IMG_PLACEHOLDER.format(index=index)
try:
markdown = markdown.replace(placeholder, await store_article_image(content, content_type))
except Exception as e:
logger.warning(f"Article image re-host failed ({article.url}): {e}")
markdown = markdown.replace(f"({placeholder})", "()")
return markdown
# Markdown links, excluding image embeds (`![alt](src)`). Group 1 = label,
# group 2 = the http(s) URL; an optional markdown title (`[x](url "title")`, which
# markdownify emits for anchors with a title attribute) is tolerated and dropped.
_MD_LINK_RE = re.compile(r'(?<!!)\[([^\]]*)\]\((https?://[^)\s]+)(?:\s+"[^"]*")?\)')
def _article_key(path: str) -> Optional[str]:
"""Stable identifier for an article from its URL path — the numeric id in
`/articles/{id}-{slug}` (falling back to the whole segment). Lets a cross-link
resolve to the same article regardless of the slug suffix or a missing slug."""
lower = path.lower()
idx = lower.find(_ARTICLE_MARKER)
if idx == -1:
return None
segment = path[idx + len(_ARTICLE_MARKER):].split("/")[0].split("?")[0]
numeric = re.match(r"\d+", segment)
return numeric.group(0) if numeric else (segment.lower() or None)
# Leading breadcrumb residue after "Home › Account" links are dropped: chevron /
# middot separators (never markdown-structural chars like - > | so hr/blockquote/
# tables are safe).
_BREADCRUMB_RESIDUE_RE = re.compile(r"^[\s›»·]+")
def _remap_source_links(markdown: str, source_hosts: set, key_to_slug: dict) -> str:
"""Rewrite links to the SOURCE HELP CENTER as ROOT-RELATIVE help-center paths:
- article link → /a/{new-slug} (or / when the article wasn't imported),
kept as a link;
- home/category link (non-article) → DROPPED — these are breadcrumb/nav
chrome ("Home › Account") that shouldn't appear in article bodies.
Links are stored WITHOUT an origin or /help/{slug} prefix so they survive a
move to a custom domain or subdomain — the serving prefix is added at render
time (help_center_content.render_article_html). Matching is by EXACT host
against the set of hosts the articles were actually fetched from (handles
redirects and main-site→help-subdomain splits), so links to the main product
site and every other external link are left untouched."""
def repl(match):
label, url = match.group(1), match.group(2)
parsed = urlparse(url)
if (parsed.hostname or "").lower() not in source_hosts:
return match.group(0) # off-site (incl. the main product site) — keep
key = _article_key(parsed.path)
if key is None:
return "" # home / category breadcrumb link — drop entirely
slug = key_to_slug.get(key)
return f"[{label}](/a/{slug})" if slug else f"[{label}](/)"
result = _MD_LINK_RE.sub(repl, markdown)
# Tidy up after dropped breadcrumbs: strip leading separator residue and
# collapse the blank lines those drops leave behind.
result = _BREADCRUMB_RESIDUE_RE.sub("", result)
return re.sub(r"\n{3,}", "\n\n", result)
async def run_article_import_job(db, job: FAQGenerationJob) -> int:
"""Execute an IMPORT_ARTICLES job. Returns FAQs created. No LLM involved —
articles are imported verbatim as Markdown drafts, then a second pass remaps
intra-help-center links to the org's new help center."""
job_repo = FAQGenerationJobRepository(db)
job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0)
client = httpx.Client(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT, headers=_FETCH_HEADERS)
try:
links = await asyncio.to_thread(
discover_article_links, client, job.source_url, settings.FAQ_ARTICLE_IMPORT_MAX_PAGES
)
job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=10.0)
existing = {normalize_question(q) for q in FAQRepository(db).get_existing_questions(job.organization_id)}
categories = CategoryMerger(db, job.organization_id)
source_label = f"Imported from {urlparse(job.source_url).netloc}"
rows: List[FAQ] = []
row_sources: List[str] = [] # each row's source article URL, for link remapping
for index, (link, category) in enumerate(links):
article = await asyncio.to_thread(fetch_article, client, link, category)
job_repo.update_progress(
job.id, progress_percentage=10.0 + 78.0 * (index + 1) / len(links)
)
if not article:
continue
key = normalize_question(article.title)
if not key or key in existing:
continue
existing.add(key)
answer = (await _rehost_images(article))[:MAX_ANSWER_LENGTH]
rows.append(
FAQ(
organization_id=job.organization_id,
question=article.title,
answer=answer,
category=categories.merge(article.category_hint),
status=FAQStatus.DRAFT,
knowledge_id=None,
source_label=source_label,
# Provenance on every import, whether or not the original
# URLs are being preserved — so a later "apply preserved
# paths" pass is a pure data migration.
source_url=article.url[:FAQ_SOURCE_URL_MAX_LENGTH],
generation_job_id=job.id,
created_by=job.user_id,
)
)
row_sources.append(article.url)
finally:
client.close()
if not rows:
return 0
if job.preserve_source_urls:
# Before the insert, so the per-org unique index can never reject the
# batch. Rows that can't keep their path just fall back to /a/{slug} —
# one unusable URL must not cost the org the whole import.
applied = assign_faq_url_paths(db, rows, row_sources)
logger.info(f"Article import job {job.id}: preserved {applied}/{len(rows)} original URLs")
created = insert_draft_rows(db, job, rows) # assigns each row a unique slug in place
_remap_imported_links(db, rows, row_sources)
return created
def _remap_imported_links(db, rows, row_sources) -> None:
"""Second pass: rewrite intra-help-center links in the just-imported answers to
root-relative help-center paths (article → /a/{slug}, else → home)."""
# Match against the hosts the articles were ACTUALLY fetched from (post-redirect,
# possibly a help subdomain) — NOT the typed index URL, which may redirect or be
# main-site-hosted. Using the real article hosts fixes redirect/subdomain splits
# without risking a match on main-site content links.
source_hosts = {(urlparse(src).hostname or "").lower() for src in row_sources}
source_hosts.discard("")
key_to_slug = {}
for src, faq in zip(row_sources, rows):
key = _article_key(urlparse(src).path)
if key:
key_to_slug[key] = faq.slug
dirty = False
for faq in rows:
remapped = _remap_source_links(faq.answer, source_hosts, key_to_slug)
if remapped != faq.answer:
faq.answer = remapped[:MAX_ANSWER_LENGTH]
dirty = True
if dirty:
db.commit()
+431
View File
@@ -0,0 +1,431 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
FAQ generation pipeline: read the org's stored knowledge text (no re-crawl),
extract grounded FAQs with the org's configured model, dedup against existing
questions and insert drafts for review. Additive only — regeneration never
edits or deletes existing FAQs. The drafting loop and dedup/category helpers
here are shared with the import service (faq_import.py).
"""
import re
from collections import deque
from difflib import SequenceMatcher
from typing import Awaitable, Callable, List, Optional, Tuple, TypeVar
from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.agents.faq_generator import FAQGeneratorAgent, GeneratedFAQ
from app.core.config import settings
from app.core.logger import get_logger
from app.core.security import decrypt_api_key
from app.knowledge.page_editor import NATURAL_ID_ORDER, PAGE_ID_EXPR
from app.models.faq import FAQ, DEFAULT_FAQ_CATEGORY, FAQStatus
from app.models.faq_generation_job import FAQGenerationJob, FAQJobStage, FAQJobType
from app.models.knowledge import Knowledge
from app.repositories.ai_config import AIConfigRepository
from app.repositories.faq import FAQRepository
from app.repositories.faq_generation_job import FAQGenerationJobRepository
logger = get_logger(__name__)
# Dedup lists injected into prompts are a small recent window for intra-run
# steering only — the in-Python DedupState below (exact + fuzzy) is the real
# guard covering every existing question, so the prompt stays cheap.
MAX_PROMPT_QUESTIONS = 30
# Fuzzy-dedup tuning: token-set Jaccard on content words; borderline scores are
# confirmed with difflib on the normalized strings. Very short questions skip
# fuzzy matching entirely (too collision-prone).
FUZZY_JACCARD_DUP = 0.8
FUZZY_JACCARD_MAYBE = 0.65
FUZZY_SEQUENCE_DUP = 0.87
FUZZY_MIN_CONTENT_TOKENS = 3
# Tiny inline stopword set — enough to keep filler words from inflating
# question similarity, without an NLP dependency.
_STOPWORDS = frozenset(
"a an and are can could do does for from how i in is it my of on or the "
"there to we what when where which who why will with you your".split()
)
_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE)
BatchT = TypeVar("BatchT")
def normalize_question(question: str) -> str:
"""Casefold + strip punctuation/whitespace, so trivial rephrasings of the
same question dedup against each other."""
return " ".join(_PUNCT_RE.sub(" ", question.casefold()).split())
class NoAIConfigError(Exception):
"""The org has no active AI configuration to generate with."""
def build_generator(db: Session, organization_id, job: Optional[FAQGenerationJob] = None) -> FAQGeneratorAgent:
"""The org's configured extraction agent. When a job is given, every actual
provider call (retries and the plain-JSON fallback's second call included)
increments the job's llm_calls — the metering source of truth."""
config = AIConfigRepository(db).get_active_config(organization_id)
if not config:
raise NoAIConfigError(
"No active AI configuration. Set up an AI model in AI Configuration first."
)
generator = FAQGeneratorAgent(
api_key=decrypt_api_key(config.encrypted_api_key),
model_name=config.model_name,
model_type=config.model_type.value if hasattr(config.model_type, "value") else str(config.model_type),
base_url=(config.settings or {}).get('base_url'),
)
if job is not None:
job_repo = FAQGenerationJobRepository(db)
generator.on_llm_call = lambda: job_repo.increment_llm_calls(job.id)
return generator
def load_source_pages(db: Session, knowledge: Knowledge, max_chars: Optional[int] = None) -> List[str]:
"""One string of aggregated text per crawled page/sub-page of a source,
read straight from the org's vector table (content is stored alongside the
embeddings — no re-crawl needed). Pages kept in crawl order when the cap
truncates; chunk text within a page is naturally ordered. max_chars caps
each page and defaults to the model-agnostic floor."""
if not knowledge.schema or not knowledge.table_name:
# Source rows without a vector table (failed/partial ingestion).
return []
# Chunks written by app.knowledge.chunking (`::n` ids) are verbatim slices
# and join back with nothing between them; legacy agno `_n` chunks were
# cut on whitespace they dropped, so those keep a newline.
query = text(
f"SELECT {PAGE_ID_EXPR} AS page_id, "
"string_agg(content, CASE WHEN id ~ '::[0-9]+$' THEN '' ELSE E'\n' END "
f"ORDER BY {NATURAL_ID_ORDER}) AS body "
f'FROM {knowledge.schema}."{knowledge.table_name}" '
"WHERE name = :source GROUP BY 1 ORDER BY min(created_at) LIMIT :max_pages"
)
rows = db.execute(
query, {"source": knowledge.source, "max_pages": settings.FAQ_MAX_PAGES_PER_SOURCE}
).fetchall()
max_chars = max_chars or settings.FAQ_MAX_BATCH_CHARS
return [row.body[:max_chars] for row in rows if row.body and row.body.strip()]
def pack_batches(items: List[str], max_chars: Optional[int] = None, sep: str = "\n\n---\n\n") -> List[str]:
"""Greedy-pack whole items (pages, lines) into batches of ~max_chars.
Oversized single items are truncated to max_chars, never split across
batches. Shared by generation (page items) and import (line items)."""
max_chars = max_chars or settings.FAQ_MAX_BATCH_CHARS
batches: List[str] = []
current: List[str] = []
size = 0
for item in items:
item = item[:max_chars]
if current and size + len(item) + len(sep) > max_chars:
batches.append(sep.join(current))
current, size = [], 0
current.append(item)
size += len(item) + len(sep)
if current:
batches.append(sep.join(current))
return batches
def _content_tokens(normalized: str) -> frozenset:
return frozenset(t for t in normalized.split() if t not in _STOPWORDS)
class DedupState:
"""Tracks every question seen (existing + accepted this run).
Two layers: an exact normalized-set match, plus fuzzy similarity (token-set
Jaccard via an inverted index, difflib confirmation on borderline scores)
so rephrasings are caught without shipping the whole question list to the
LLM. A bounded recent-questions window is still exposed for prompts."""
def __init__(self, existing_questions: List[str]):
self.seen: set = set()
self._token_index: dict = {} # content token -> set of seen keys
self._key_tokens: dict = {} # seen key -> its content-token frozenset
self._recent = deque(existing_questions, maxlen=MAX_PROMPT_QUESTIONS)
for q in existing_questions:
self._remember(normalize_question(q))
def _remember(self, key: str) -> None:
if not key or key in self.seen:
return
self.seen.add(key)
tokens = _content_tokens(key)
self._key_tokens[key] = tokens
for token in tokens:
self._token_index.setdefault(token, set()).add(key)
def is_similar(self, key: str) -> bool:
"""True when key fuzzily matches a seen question. Exact hits are the
caller's job (cheap set test); this covers rephrasings."""
tokens = _content_tokens(key)
if len(tokens) < FUZZY_MIN_CONTENT_TOKENS:
return False
candidates: set = set()
for token in tokens:
candidates |= self._token_index.get(token, set())
for candidate in candidates:
other = self._key_tokens[candidate]
union = len(tokens | other)
if not union:
continue
jaccard = len(tokens & other) / union
if jaccard >= FUZZY_JACCARD_DUP:
return True
if jaccard >= FUZZY_JACCARD_MAYBE and (
SequenceMatcher(None, key, candidate).ratio() >= FUZZY_SEQUENCE_DUP
):
return True
return False
def accept_new(self, faqs: List[GeneratedFAQ]) -> List[GeneratedFAQ]:
accepted = []
for faq in faqs:
key = normalize_question(faq.question)
if not key or key in self.seen or self.is_similar(key):
continue
self._remember(key)
self._recent.append(faq.question)
accepted.append(faq)
return accepted
@property
def for_prompt(self) -> List[str]:
return list(self._recent)
class CategoryMerger:
"""Case-insensitive merge of model-emitted categories into the org's
existing spellings."""
def __init__(self, db: Session, organization_id):
self._known = {c.casefold(): c for c in FAQRepository(db).get_categories(organization_id)}
def merge(self, category: str) -> str:
cleaned = (category or "").strip() or DEFAULT_FAQ_CATEGORY
return self._known.setdefault(cleaned.casefold(), cleaned)
@property
def names(self) -> List[str]:
return list(self._known.values())
async def draft_batches(
db: Session,
job: FAQGenerationJob,
batches: List[BatchT],
extract: Callable[[BatchT, DedupState], Awaitable[List[GeneratedFAQ]]],
retries_per_batch: int = 1,
) -> List[Tuple[BatchT, GeneratedFAQ]]:
"""Shared DRAFTING stage: run `extract` per batch with retry, dedup as we
go, report progress 30→90%. Raises only when every batch failed."""
job_repo = FAQGenerationJobRepository(db)
job_repo.update_progress(job.id, stage=FAQJobStage.DRAFTING, progress_percentage=30.0)
dedup = DedupState(FAQRepository(db).get_existing_questions(job.organization_id))
accepted: List[Tuple[BatchT, GeneratedFAQ]] = []
failures = 0
last_error: Optional[Exception] = None
for index, batch in enumerate(batches):
faqs = None
for attempt in range(1 + retries_per_batch):
try:
faqs = await extract(batch, dedup)
break
except Exception as e:
last_error = e
logger.warning(f"FAQ batch {index + 1}/{len(batches)} attempt {attempt + 1} failed: {e}")
if faqs is None:
failures += 1
else:
accepted.extend((batch, faq) for faq in dedup.accept_new(faqs))
job_repo.update_progress(
job.id, progress_percentage=30.0 + 60.0 * (index + 1) / len(batches)
)
if batches and failures == len(batches):
# Surface the underlying provider error (e.g. an unsupported/decommissioned
# model) instead of a generic message, so the UI can tell the user what to fix.
detail = f" — {last_error}" if last_error else ""
raise RuntimeError(f"The AI model could not generate FAQs from this content{detail}")
return accepted
def insert_draft_rows(db: Session, job: FAQGenerationJob, rows: List[FAQ]) -> int:
"""Shared GROUPING stage tail: bulk-insert the drafted rows."""
FAQGenerationJobRepository(db).update_progress(
job.id, stage=FAQJobStage.GROUPING, progress_percentage=92.0
)
if rows:
FAQRepository(db).bulk_create(rows)
return len(rows)
async def run_generation_job(db: Session, job: FAQGenerationJob) -> int:
"""Execute a GENERATE_ALL / GENERATE_SOURCE job. Returns FAQs created.
Status transitions and notifications are the worker's responsibility."""
job_repo = FAQGenerationJobRepository(db)
# Stage 1: resolve sources + model config.
job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0)
generator = build_generator(db, job.organization_id, job=job)
knowledge_query = db.query(Knowledge).filter(Knowledge.organization_id == job.organization_id)
if job.job_type == FAQJobType.GENERATE_SOURCE.value:
knowledge_query = knowledge_query.filter(Knowledge.id == job.knowledge_id)
elif job.knowledge_ids:
# Explicit selection overrides the skip-already-generated default.
knowledge_query = knowledge_query.filter(Knowledge.id.in_(job.knowledge_ids))
sources = knowledge_query.all()
if not sources:
raise ValueError("No knowledge sources to generate from. Add knowledge first.")
if job.job_type == FAQJobType.GENERATE_ALL.value and not job.knowledge_ids:
# Regenerate touches only sources that haven't produced FAQs yet —
# deleting a source's FAQs makes it eligible again.
generated = FAQRepository(db).knowledge_ids_with_faqs(job.organization_id)
sources = [s for s in sources if s.id not in generated]
if not sources:
raise ValueError(
"All knowledge sources already have FAQs. "
"Delete a source's FAQs to regenerate it."
)
# Stage 2: read stored page text per source. A broken source (missing
# vector table etc.) is skipped, not fatal — unless nothing is readable.
job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=10.0)
source_batches: List[Tuple[Knowledge, str]] = []
for source in sources:
try:
# Batch size follows the org's model context window — big-context
# models get fewer, larger LLM calls.
pages = load_source_pages(db, source, max_chars=generator.batch_chars)
except Exception as e:
logger.warning(f"Skipping unreadable knowledge source {source.id} ({source.source}): {e}")
db.rollback()
continue
for batch in pack_batches(pages, max_chars=generator.batch_chars):
source_batches.append((source, batch))
if not source_batches:
raise ValueError("The selected knowledge sources contain no readable content.")
# Stage 3: draft per batch (shared loop).
categories = CategoryMerger(db, job.organization_id)
async def extract(batch: Tuple[Knowledge, str], dedup: DedupState) -> List[GeneratedFAQ]:
return await generator.generate_from_text(
batch[1],
existing_questions=dedup.for_prompt,
existing_categories=categories.names,
)
accepted = await draft_batches(db, job, source_batches, extract)
# Stage 4: group + insert drafts.
rows = [
FAQ(
organization_id=job.organization_id,
question=faq.question,
answer=faq.answer,
category=categories.merge(faq.category),
status=FAQStatus.DRAFT,
knowledge_id=source.id,
source_label=source.source,
generation_job_id=job.id,
created_by=job.user_id,
)
for (source, _batch_text), faq in accepted
]
return insert_draft_rows(db, job, rows)
def maybe_enqueue_auto_faq_job(db: Session, queue_item) -> Optional[FAQGenerationJob]:
"""Auto-generate draft FAQs when a new knowledge source finishes processing.
Only fires for orgs that have adopted the feature (a help-center settings
row or any FAQ exists), have auto-generate on, are allowed by their plan
(cloud), and don't already have an active job for this source. Never raises
and always leaves the shared session usable — a hook failure must not fail
knowledge processing.
"""
try:
from app.repositories.help_center import HelpCenterRepository
from app.services.help_center_access import help_center_allowed
org_id = queue_item.organization_id
settings_row = HelpCenterRepository(db).get_by_org(org_id)
faq_repo = FAQRepository(db)
job_repo_gate = FAQGenerationJobRepository(db)
# Adoption gate: auto-generation only after the org explicitly ran a
# generate/import (or added an FAQ). A settings row alone is NOT
# opt-in — merely opening the admin page creates one.
if not job_repo_gate.has_user_initiated_job(org_id) and not faq_repo.exists_for_org(org_id):
return None
if settings_row and not settings_row.auto_generate:
return None
if not help_center_allowed(db, org_id):
return None
knowledge = (
db.query(Knowledge)
.filter(Knowledge.organization_id == org_id, Knowledge.source == queue_item.source)
.order_by(Knowledge.id.desc()) # deterministic: newest row wins on duplicate sources
.first()
)
if not knowledge:
return None
job_repo = FAQGenerationJobRepository(db)
if job_repo.get_active_for_org(org_id, FAQJobType.GENERATE_SOURCE, knowledge_id=knowledge.id):
return None
from app.services.faq_usage import (
count_source_pages,
ensure_generation_budget,
generation_is_metered,
PAGES_PER_CALL,
)
from math import ceil
try:
pages = count_source_pages(db, knowledge)
ensure_generation_budget(db, org_id, max(1, ceil((pages or 1) / PAGES_PER_CALL)))
except HTTPException:
logger.info(f"Auto FAQ generation skipped for org {org_id}: message budget exhausted")
return None
job = job_repo.create(
FAQGenerationJob(
organization_id=org_id,
user_id=queue_item.user_id,
job_type=FAQJobType.GENERATE_SOURCE.value,
knowledge_id=knowledge.id,
metered=generation_is_metered(db, org_id),
)
)
logger.info(f"Auto-enqueued FAQ generation job {job.id} for knowledge {knowledge.id}")
return job
except Exception as e:
logger.error(f"Auto FAQ enqueue failed (non-fatal): {e}")
# A failed flush would otherwise poison the caller's session and turn
# an already-successful knowledge run into a FAILED one.
try:
db.rollback()
except Exception:
pass
return None
+243
View File
@@ -0,0 +1,243 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
FAQ page migration: fetch the customer's existing FAQ/help-center page
(SSRF-guarded) or read an uploaded PDF, extract Q&A pairs with the org's model
and insert them as drafts. URL mode is single page only — for linked article
pages use the article importer (faq_article_import.py).
"""
import asyncio
from io import BytesIO
from typing import List, Optional
from urllib.parse import urlparse
import httpx
from bs4 import BeautifulSoup
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logger import get_logger
from app.knowledge.url_safety import guard_url, safe_get
from app.models.faq import FAQ, FAQStatus
from app.models.faq_generation_job import FAQGenerationJob, FAQJobStage
from app.repositories.faq_generation_job import FAQGenerationJobRepository
from app.services.faq_generation import (
CategoryMerger,
DedupState,
build_generator,
draft_batches,
insert_draft_rows,
pack_batches,
)
logger = get_logger(__name__)
# Below this, the static HTML likely lacks the real content (JS-rendered page).
MIN_STATIC_TEXT_CHARS = 500
# Block-level elements get blank-line separators so a question heading stays
# attached to its answer text when batches are split on blocks.
_BLOCK_TAGS = (
"p", "div", "section", "article", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"dt", "dd", "summary", "details", "tr",
)
_BOILERPLATE_TAGS = ("script", "style", "noscript", "nav", "footer", "header", "form")
_FETCH_HEADERS = {"User-Agent": "ReplyPier-FAQ-Import/1.0 (+https://replypier.site)"}
def _soup_to_text(soup: BeautifulSoup) -> str:
"""Clean text with one BLANK line between block elements, so downstream
blank-line batching keeps each Q&A block intact."""
for tag in soup.find_all(_BOILERPLATE_TAGS):
tag.decompose()
for tag in soup.find_all(_BLOCK_TAGS):
tag.append("\n\n")
lines = [line.strip() for line in soup.get_text("\n").split("\n")]
text = "\n".join(lines)
# Collapse 3+ newlines to exactly one blank line.
while "\n\n\n" in text:
text = text.replace("\n\n\n", "\n\n")
return text.strip()
def fetch_page_text(url: str) -> str:
"""Fetch the page and reduce it to clean text. Static fetch first (every
redirect hop SSRF-checked by safe_get); crawl4ai's headless browser as a
fallback for JS-rendered pages. Blocking — callers run it in a thread."""
text = ""
final_url = url
try:
with httpx.Client(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT, headers=_FETCH_HEADERS) as client:
response = safe_get(client, url)
response.raise_for_status()
# The hop-validated final URL — the browser fallback fetches this,
# not the original, so a redirect chain can't smuggle it inward.
final_url = str(response.url)
text = _soup_to_text(BeautifulSoup(response.text, "html.parser"))
except httpx.HTTPError as e:
logger.warning(f"FAQ import static fetch failed for {url}: {e}")
if len(text) < MIN_STATIC_TEXT_CHARS:
rendered = _fetch_rendered_text(final_url)
if rendered and len(rendered) > len(text):
text = rendered
if not text.strip():
raise ValueError("Could not read any text from that page.")
return text[: settings.FAQ_IMPORT_MAX_PAGE_CHARS]
def _fetch_rendered_text(url: str) -> Optional[str]:
"""Headless-browser fallback for JS-rendered pages. The URL is re-checked
against the SSRF guard, but a browser follows redirects/JS navigation
unguarded — same residual exposure as the knowledge crawler's fallback."""
try:
guard_url(url)
from app.knowledge.crawl4ai_fallback import get_crawl4ai_fallback
fallback = get_crawl4ai_fallback(timeout=settings.FAQ_IMPORT_FETCH_TIMEOUT)
if not fallback.is_available:
return None
_, soup, _ = fallback.fetch_with_browser(url)
return _soup_to_text(soup) if soup else None
except Exception as e:
logger.warning(f"FAQ import browser fetch failed for {url}: {e}")
return None
def _split_blocks(text: str) -> List[str]:
"""Blank-line-separated blocks (see _soup_to_text) — the batching unit."""
return [block for block in text.split("\n\n") if block.strip()]
async def _extract_and_insert(
db: Session, job: FAQGenerationJob, generator, text: str, source_label: str,
mode: str = "extract",
) -> int:
"""Shared LLM tail for URL/PDF imports: batch → extract → dedup → insert.
mode="extract" migrates a page that already contains Q&A pairs (verbatim).
mode="generate" turns free-form documentation (a product guide, manual…)
into FAQs — an existing-FAQ extraction prompt would find almost nothing in
prose that isn't already a Q&A list."""
batches = pack_batches(_split_blocks(text), max_chars=generator.batch_chars, sep="\n\n")
categories = CategoryMerger(db, job.organization_id)
if mode == "generate":
async def extract(batch: str, dedup: DedupState):
return await generator.generate_from_text(
batch, existing_questions=dedup.for_prompt, existing_categories=categories.names
)
else:
async def extract(batch: str, dedup: DedupState):
faqs = await generator.extract_from_faq_page(batch, existing_questions=dedup.for_prompt)
if not faqs:
# The page had no verbatim Q&A — an index/category listing or free-form
# prose. Rather than import nothing, draft FAQs from the same content.
faqs = await generator.generate_from_text(
batch, existing_questions=dedup.for_prompt, existing_categories=categories.names
)
return faqs
accepted = await draft_batches(db, job, batches, extract, retries_per_batch=0)
rows = [
FAQ(
organization_id=job.organization_id,
question=faq.question,
answer=faq.answer,
category=categories.merge(faq.category),
status=FAQStatus.DRAFT,
knowledge_id=None,
source_label=source_label,
generation_job_id=job.id,
created_by=job.user_id,
)
for _batch, faq in accepted
]
return insert_draft_rows(db, job, rows)
async def run_import_job(db: Session, job: FAQGenerationJob) -> int:
"""Execute an IMPORT_URL job. Returns FAQs created. Status transitions and
notifications are the worker's responsibility."""
job_repo = FAQGenerationJobRepository(db)
# ANALYZING = fail fast on a missing model config, before any external I/O.
job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0)
generator = build_generator(db, job.organization_id, job=job)
job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=15.0)
# Re-validated at fetch time (not only at the API layer) so a job forged or
# replayed against the queue can't reach internal hosts; run in a thread so
# the slow fetch doesn't block the worker's event loop.
page_text = await asyncio.to_thread(fetch_page_text, job.source_url)
return await _extract_and_insert(
db, job, generator, page_text,
source_label=f"Imported from {urlparse(job.source_url).netloc}",
)
def _pdf_to_text(content: bytes) -> str:
"""Page-by-page text extraction with pypdf directly — agno's PDFReader
chunks/summarizes for the vector KB, which we don't want here. Blank-line
separators between pages keep block batching intact."""
from pypdf import PdfReader
reader = PdfReader(BytesIO(content))
pages = []
for page in reader.pages:
try:
text = page.extract_text() or ""
except Exception as e: # a single corrupt page shouldn't sink the file
logger.warning(f"PDF page extraction failed: {e}")
continue
if text.strip():
pages.append(text.strip())
return "\n\n".join(pages)[: settings.FAQ_IMPORT_MAX_PAGE_CHARS]
async def run_pdf_import_job(db: Session, job: FAQGenerationJob) -> int:
"""Execute an IMPORT_PDF job: read the stored upload (job.source_url),
extract text and run the shared Q&A extraction. The stored file is
best-effort deleted afterwards — it's an import buffer, not a document
library."""
from app.services.file_storage import delete_upload, load_upload
job_repo = FAQGenerationJobRepository(db)
# Captured before the try: after a DB-originated failure the ORM instance
# is expired and reading job attributes from the finally would raise,
# masking the real error and leaking the buffer.
stored = job.source_url
source_label = f"Imported from {job.source_file_name or 'PDF'}"
# ANALYZING = fail fast on a missing model config, before file I/O.
job_repo.update_progress(job.id, stage=FAQJobStage.ANALYZING_SOURCES, progress_percentage=5.0)
try:
generator = build_generator(db, job.organization_id, job=job)
content = await load_upload(stored)
job_repo.update_progress(job.id, stage=FAQJobStage.EXTRACTING, progress_percentage=15.0)
text = await asyncio.to_thread(_pdf_to_text, content)
if not text.strip():
raise ValueError(
"Could not read any text from that PDF (it may be scanned images)."
)
# A PDF is usually documentation/marketing prose, not an existing Q&A
# list — generate FAQs from it rather than extracting verbatim pairs.
return await _extract_and_insert(
db, job, generator, text, source_label=source_label, mode="generate"
)
finally:
await delete_upload(stored)
+180
View File
@@ -0,0 +1,180 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
FAQ generation usage/credits: estimate how many LLM calls a run will make,
decide whether those calls are metered (hosted model), and enforce the org's
message budget before enqueueing. OSS/self-hosted (no enterprise module) is
never metered and never blocked.
"""
import math
from dataclasses import dataclass
from typing import List, Optional
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logger import get_logger
from app.knowledge.page_editor import PAGE_ID_EXPR
from app.models.knowledge import Knowledge
from app.repositories.faq import FAQRepository
# Shared hosted-model metering helpers; re-exported here so existing FAQ
# callers keep importing them from faq_usage.
from app.services.usage_metering import ( # noqa: F401
HOSTED_MODEL_TYPE,
is_hosted_model,
remaining_message_credits,
)
logger = get_logger(__name__)
# Rough pages-per-LLM-call heuristic for the confirm-dialog estimate (matches
# today's floor batch of ~3 average pages). An estimate, not billing.
PAGES_PER_CALL = 3
OVER_BUDGET_MESSAGE = (
"Not enough message credits left in your plan for this generation "
"(~{estimated} needed, {remaining} remaining). Upgrade your plan or "
"switch to your own AI model key."
)
@dataclass
class SourceEstimate:
id: int
name: str
source_type: str
has_faqs: bool
pages: int
estimated_calls: int
@dataclass
class GenerationEstimate:
total_sources: int
new_sources: int
pages: int
estimated_calls: int
sources: List[SourceEstimate]
def generation_is_metered(db: Session, organization_id: UUID) -> bool:
"""Generation costs credits only on the hosted model — own-key orgs pay
their provider directly. FAQ_METER_OWN_KEY flips to always-meter."""
return settings.FAQ_METER_OWN_KEY or is_hosted_model(db, organization_id)
def count_source_pages(db: Session, knowledge: Knowledge) -> Optional[int]:
"""Distinct stored pages for a source, capped like generation itself.
None when the vector table is missing/unreadable."""
if not knowledge.schema or not knowledge.table_name:
return None
query = text(
f"SELECT COUNT(*) FROM (SELECT {PAGE_ID_EXPR} "
f'FROM {knowledge.schema}."{knowledge.table_name}" '
"WHERE name = :source GROUP BY 1 LIMIT :max_pages) pages"
)
try:
count = db.execute(
query, {"source": knowledge.source, "max_pages": settings.FAQ_MAX_PAGES_PER_SOURCE}
).scalar()
return int(count or 0)
except Exception as e:
logger.warning(f"Page count failed for knowledge {knowledge.id}: {e}")
db.rollback()
return None
def estimate_generation_calls(
db: Session,
organization_id: UUID,
knowledge_ids: Optional[List[int]] = None,
count_pages: bool = True,
) -> GenerationEstimate:
"""Confirm-dialog numbers for a GENERATE_ALL run: which sources it would
read (new-only unless explicitly targeted) and roughly how many LLM calls
that costs. Unreadable sources count as one call each.
Always returns the FULL per-source list (for the generation source picker),
each with its own page/call estimate and whether it already has FAQs. The
aggregate counts (new_sources/pages/estimated_calls) cover the run's TARGET
sources: the explicit `knowledge_ids` selection, or — when none is given —
every source that doesn't yet have FAQs.
count_pages=False skips the per-source page COUNT over the vector tables
(cheap — for surfaces that only need counts, like the Generate button
label); per-source estimated_calls is then a 1-per-source lower bound."""
all_sources = (
db.query(Knowledge).filter(Knowledge.organization_id == organization_id).all()
)
generated = FAQRepository(db).knowledge_ids_with_faqs(organization_id)
existing_ids = {s.id for s in all_sources}
target_ids = (
(set(knowledge_ids) & existing_ids)
if knowledge_ids
else {s.id for s in all_sources if s.id not in generated}
)
source_list: List[SourceEstimate] = []
agg_pages = 0
agg_calls = 0
for source in all_sources:
source_pages = count_source_pages(db, source) if count_pages else None
pages = source_pages or 0
if source_pages is None:
# Unreadable now, or page-count skipped (count_pages=False) — assume
# one call so the estimate isn't zero.
calls = 1
else:
# A readable but empty source produces nothing → costs no call.
calls = max(1, math.ceil(pages / PAGES_PER_CALL)) if pages else 0
source_list.append(
SourceEstimate(
id=source.id,
name=source.source,
source_type=source.source_type.value if hasattr(source.source_type, "value") else str(source.source_type),
has_faqs=source.id in generated,
pages=pages,
estimated_calls=calls,
)
)
if source.id in target_ids:
agg_pages += pages
agg_calls += calls
return GenerationEstimate(
total_sources=len(all_sources),
new_sources=len(target_ids),
pages=agg_pages,
estimated_calls=agg_calls,
sources=source_list,
)
def ensure_generation_budget(db: Session, organization_id: UUID, estimated_calls: int) -> None:
"""402 when a metered run would exceed the org's remaining credits."""
if estimated_calls <= 0 or not generation_is_metered(db, organization_id):
return
remaining = remaining_message_credits(db, organization_id)
if remaining is None:
return
if estimated_calls > remaining:
raise HTTPException(
status_code=402,
detail=OVER_BUDGET_MESSAGE.format(estimated=estimated_calls, remaining=remaining),
)
+63
View File
@@ -0,0 +1,63 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from firebase_admin import credentials, initialize_app, get_app
from app.core.config import settings
from app.core.logger import get_logger
import os
logger = get_logger(__name__)
def initialize_firebase():
"""Initialize Firebase Admin SDK"""
try:
# Check if already initialized
try:
get_app()
logger.info("Firebase already initialized")
return
except ValueError:
logger.info("Initializing Firebase...")
# Get credentials file path
cred_path = settings.FIREBASE_CREDENTIALS
if not cred_path or not os.path.isfile(cred_path):
logger.warning(f"Firebase credentials not found at {cred_path}. Running in development mode.")
# Use a default configuration for development
cred = credentials.Certificate({
"type": "service_account",
"project_id": "demo-project",
"private_key_id": "demo123456789",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDK/eDH0MDgBNHs\nYuXwHMnMYGz+86YJjYoXVbKdUGHQZXYVEVlc8rYA+VXbU1AqhD+c7yT9V9EaEjPO\nXGVWS6dWQh4Kh5g7y1O5SHIwQhDHK+AAqMgBfZhzEHJBtO8CXG8TDyxhfHXxjWpD\nGPz/RJ7GhW9cHYQOvP4yZAp3ZNGYQJwUlhNLGr80XEhGVlkZ5WPL0YFzwOxvqGQs\nQVVxGGj5h7YHJt1YmZwF6cBGGX8AUetJkMTgQEJSNsaK4Pl+MZUGQOhQEynK3qO2\nWf+7XZl4XPx9Qw+WPYEoHHlGgGQR4YUpL+qzVxYwNAXhVxd9k+YPp5JZHtGQAXeO\nZhxDo1QLAgMBAAECggEABBj/H/JGXuVGoCjR6J0XF+kQTOuTgZBvgapkpPTYwfXQ\nPRKzGWEpGUPVnVb3gMDqbqQe7EVJZ8Nj/zHmXf9c4qKxpXwNl7kYmPpuQOHmA8Nq\nXPE3QZH3YSRz1YXF/pA9RQqG5F4JBJjz5LcUR+ax9aSxPu26xwDHOpR4EIR9Aovx\nJfcg7MTAY4YuGBe0oLQbEWoUQqXgUIVEzWcqiDGXF0uuMZzpW6YVk5ZvWHXz7SVK\nE7e1XQN6sMTYqrmnFGkBhuvXiBkPZHsOEHBVxXVqytE8FJAqXwPwFqxWj4RhNS/w\nkMUv5ZyH6UOFuBXtSe1XGGEqktNVGPBhAkEo4BbhAQKBgQD1+fdC9zzDwA4KoNnk\nVxQbCpC7Zr5F8Mycer0SV/3Gg6okzo9vXl+vqVHXqYNGnAEFkLLhEzwpZB0cnqWk\nQswrGvZ5FvUSKd7rMfI0Uh5FxhYCuUwwZW6gVKEZDCXF6N7KBbMPaYVZuGwD7oQk\nUQKBgQDTKj+RKqFrwhZM4cVJVgqHgQmMvHXC7Hy3Sj8Yl9ZHxgsnrkoNXXXRDNwp\nEWVoTy4Xr1BXuBxFEgEBpuPGBlHVXLZRzaP1pCZVqftxiHDqGlAh4y3yZtKHSTGj\n1B6OZlYXxvD+9K5Ki1ZwM3JxpY4YE5zASL7cUrV+gUUE2VoengKBgQDxIrXXFR8l\nAgMBAAECggEABBj/H/JGXuVGoCjR6J0XF+kQTOuTgZBvgapkpPTYwfXQ\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-demo@demo-project.iam.gserviceaccount.com",
"client_id": "123456789",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-demo%40demo-project.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
})
else:
logger.info("Loading Firebase credentials from file " + cred_path)
cred = credentials.Certificate(cred_path)
initialize_app(cred)
logger.info("Firebase initialized successfully")
except Exception as e:
logger.error(f"Error initializing Firebase: {str(e)}")
logger.warning("Continuing without Firebase initialization")
@@ -0,0 +1,42 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Help-center plan gating: Pro-only on cloud, unrestricted for OSS/self-hosted.
Thin wrappers over the generic feature gate.
"""
from uuid import UUID
from sqlalchemy.orm import Session
from app.services.feature_gate import check_feature_access, feature_allowed
HELP_CENTER_FEATURE = "help_center"
UPGRADE_MESSAGE = (
"The Help Center is not available in your current plan. "
"Please upgrade to Pro to access this feature."
)
def help_center_allowed(db: Session, organization_id: UUID) -> bool:
"""Non-raising check — used by the public site (which 404s instead of
403ing) and the auto-generation hook. Fails closed on gating errors."""
return feature_allowed(db, organization_id, HELP_CENTER_FEATURE)
def check_help_center_access(db: Session, organization_id: UUID) -> None:
"""Raising check for admin endpoints (403 with the upgrade message)."""
check_feature_access(db, organization_id, HELP_CENTER_FEATURE, UPGRADE_MESSAGE)
+131
View File
@@ -0,0 +1,131 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
FAQ answers are authored and stored as Markdown so a single Text column can hold
rich help-center articles (headings, images, links, ordered steps, callouts).
This module turns that Markdown into HTML for the public page, and into plain
text for previews, meta descriptions and FAQPage structured data.
Security: the rendered HTML is dropped onto the *public* help center, so an org
admin's Markdown is untrusted input for visitors. Everything is run through an
nh3 (ammonia) allowlist clean — the template must mark this output `| safe`, so
this is the ONLY place org content is ever trusted as HTML.
"""
import re
from typing import Mapping, Optional
import markdown as _markdown
import nh3
from bs4 import BeautifulSoup
# `extra` enables tables/fenced code/etc; `sane_lists` fixes mixed list quirks;
# `nl2br` treats single newlines as <br> so plain-text FAQ answers (the common
# case) keep their line breaks without the author needing blank lines.
_MD_EXTENSIONS = ["extra", "sane_lists", "nl2br"]
# Allowlist of tags a help-center article may contain. Anything else (script,
# style, iframe, form, event handlers, …) is stripped by nh3.
_ALLOWED_TAGS = {
"p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6",
"strong", "b", "em", "i", "u", "s", "del", "code", "pre", "blockquote",
"ul", "ol", "li", "a", "img",
"table", "thead", "tbody", "tr", "th", "td",
"figure", "figcaption", "span",
}
_ALLOWED_ATTRS = {
"a": {"href", "title"},
"img": {"src", "alt", "title"},
}
# Block javascript:/data: URLs; images and links may only point at real hosts.
_URL_SCHEMES = {"http", "https", "mailto"}
_WHITESPACE_RE = re.compile(r"\s+")
# Intra-help-center links are stored ROOT-RELATIVE (/a/{slug}, or / for the home)
# so they survive a move between path mode and a subdomain/custom domain. The
# serving prefix is added here at render time. `/a/` (with the trailing slash)
# never matches an image path like /api/v1/uploads/...
_INTERNAL_HREF_RE = re.compile(r'(href=")(/a/[^"]*|/)(")')
def render_article_html(
text: str, base_path: str = "", link_paths: Optional[Mapping[str, str]] = None
) -> str:
"""Markdown -> sanitized HTML for the public article body. Safe to render
with Jinja's `| safe` because nh3 strips every tag/attr/scheme off-allowlist
and rewrites link rel to neutralise tab-nabbing.
`base_path` is the serving prefix ("" at a domain root, "/help/{slug}" in path
mode) prepended to root-relative intra-help-center links so cross-article links
resolve under whichever mode/domain serves the page.
`link_paths` maps slug -> the path that article is actually served at, for
the articles whose original URL was preserved on a help-center migration.
Cross-article links stay STORED as /a/{slug} (stable when a path is later
edited, and the only form the regex above can prefix) and are resolved here,
so a link lands directly on the preserved URL instead of via its 301."""
if not text or not text.strip():
return ""
raw = _markdown.markdown(text, extensions=_MD_EXTENSIONS, output_format="html")
html = nh3.clean(
raw,
tags=_ALLOWED_TAGS,
attributes=_ALLOWED_ATTRS,
url_schemes=_URL_SCHEMES,
link_rel="noopener noreferrer nofollow",
)
if base_path or link_paths:
html = _INTERNAL_HREF_RE.sub(
lambda m: f"{m.group(1)}{base_path}{_resolve_internal_href(m.group(2), link_paths)}{m.group(3)}",
html,
)
return html
def _resolve_internal_href(href: str, link_paths: Optional[Mapping[str, str]]) -> str:
"""A stored /a/{slug} link swapped for that article's preserved path, if it
has one. Unknown slugs (and the "/" home link) pass straight through."""
if not link_paths or not href.startswith("/a/"):
return href
return link_paths.get(href[len("/a/"):], href)
def to_plain_text(text: str) -> str:
"""Strip Markdown/HTML to a single line of readable text (previews, meta
descriptions, JSON-LD answer text)."""
if not text or not text.strip():
return ""
html = _markdown.markdown(text, extensions=_MD_EXTENSIONS, output_format="html")
plain = BeautifulSoup(html, "html.parser").get_text(" ")
return _WHITESPACE_RE.sub(" ", plain).strip()
def excerpt(text: str, length: int = 150) -> str:
"""A short one-line preview of an answer, cut on a word boundary."""
plain = to_plain_text(text)
if len(plain) <= length:
return plain
return plain[:length].rsplit(" ", 1)[0].rstrip() + "…"
def read_time_minutes(text: str) -> int:
"""Estimated read time in whole minutes (>= 1), ~200 words/min."""
words = len(to_plain_text(text).split())
return max(1, round(words / 200))
def read_time_label(text: str) -> str:
return f"{read_time_minutes(text)} min read"
+124
View File
@@ -0,0 +1,124 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Help-center article images. Images embedded in an article's Markdown must
resolve to a STABLE URL that is baked inline in the answer forever — so no
signed/expiring URLs, and no raw S3 URLs either now the bucket is private.
Both storage backends therefore return the same host-RELATIVE path, which
app/api/help_center_images.py resolves per request (redirect to a freshly
signed URL on S3, direct file on local). Host-relative so it resolves against
whichever origin serves the page: the main app in path mode, public_app on a
help-center subdomain / custom domain. Shared by the admin upload endpoint and
the article importer's re-hosting path.
"""
import re
from uuid import uuid4
from app.core.config import settings
from app.services.file_storage import store_upload
def strip_upload_host(text: str) -> str:
"""Convert the app's OWN absolute upload URLs to relative /api/v1/uploads/...
paths, leaving everything else untouched. Only strips the configured
BACKEND_URL origin or a loopback host (localhost/127.0.0.1/0.0.0.0) — a
third-party URL that merely contains /api/v1/uploads/ (e.g. a link pasted
into an FAQ answer) is NOT rewritten. S3 and already-relative URLs never
match. Used by the one-time backfill migration."""
if not text:
return text
backend = settings.BACKEND_URL.rstrip("/")
# Match our own backend origin OR any loopback host (with optional port),
# only when it sits directly in front of an /api/v1/uploads/ path.
pattern = re.compile(
r'(?:' + re.escape(backend)
+ r'|https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?)'
+ r'(?=/api/v1/uploads/)'
)
return pattern.sub("", text)
MAX_FAQ_IMAGE_BYTES = 5 * 1024 * 1024
# Reject larger-than-this on a side up front (bomb guard); downscale the stored
# image to fit FAQ_IMAGE_FIT_DIM so article pages stay light.
FAQ_IMAGE_MAX_DIM = 6000
FAQ_IMAGE_FIT_DIM = 1600
FAQ_IMAGE_TYPES = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
}
UPLOAD_FOLDER = "help_center"
# store_article_image names files "<uuid4><ext>". The delivery route matches
# against this before interpolating the name into an S3 key or a filesystem
# path, so it is what stops a traversal ("../agents/x.png") being served.
_UUID4_PATTERN = (
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)
_EXTENSION_PATTERN = "|".join(
re.escape(ext.lstrip(".")) for ext in sorted(set(FAQ_IMAGE_TYPES.values()))
)
ARTICLE_IMAGE_NAME_RE = re.compile(rf"^{_UUID4_PATTERN}\.(?:{_EXTENSION_PATTERN})$")
# Absolute S3 URLs baked into article Markdown before delivery moved behind
# article_image_path(). Both URL shapes (virtual-hosted and path-style) end in
# ".../help_center/<name>", so anchor on that.
_BAKED_S3_IMAGE_RE = re.compile(
r"https?://[^\s\"'()]*amazonaws\.com/[^\s\"'()]*"
+ re.escape(UPLOAD_FOLDER)
+ rf"/({_UUID4_PATTERN}\.(?:{_EXTENSION_PATTERN}))"
)
def article_image_path(file_name: str) -> str:
"""The stable, host-relative URL baked into article Markdown.
Identical for local and S3 storage — app/api/help_center_images.py resolves
it per request. Host-relative so it works both in path mode (main app) and
on a help-center subdomain / custom domain (public_app)."""
return f"{settings.API_V1_STR}/help-center/images/{file_name}"
def absolute_upload_url(stored: str) -> str:
"""Make a stored upload path absolute. S3 returns an absolute http(s) URL
already; local mode returns a path under the /api/v1/uploads mount, which we
anchor to the backend's public origin so it loads cross-domain."""
if stored.startswith("http"):
return stored
return f"{settings.BACKEND_URL.rstrip('/')}{stored}"
def rewrite_baked_s3_image_urls(text: str) -> str:
"""Point previously-baked absolute S3 article-image URLs at article_image_path.
Those URLs 403 now the bucket is private, and would have needed re-signing
anyway. Used by the one-time backfill migration."""
if not text:
return text
return _BAKED_S3_IMAGE_RE.sub(lambda m: article_image_path(m.group(1)), text)
async def store_article_image(content: bytes, content_type: str) -> str:
"""Persist validated image bytes and return the stable URL to bake into the
answer markdown. Caller is responsible for size/content-type validation
against MAX_FAQ_IMAGE_BYTES / FAQ_IMAGE_TYPES.
The returned path is storage-agnostic and never expires. It must not be a
signed S3 URL (those expire) nor a raw S3 URL (the bucket is private, so
those 403) — the delivery route resolves it per request instead."""
file_name = f"{uuid4()}{FAQ_IMAGE_TYPES[content_type]}"
await store_upload(content, folder=UPLOAD_FOLDER, file_name=file_name, content_type=content_type)
return article_image_path(file_name)
+238
View File
@@ -0,0 +1,238 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Public help-center domain logic: resolve the Host header to an org's
published help center, group its FAQs, and answer "Ask AI" questions grounded
in the mapped agent's knowledge. No admin auth here — everything must stay
read-only against published data.
"""
import asyncio
from typing import List, Optional, Tuple
from sqlalchemy.orm import Session
from app.agents.guardrail_policy import (
GuardrailContext,
INJECTION_CLAUSE,
visitor_data_block,
)
from app.core.help_center_host import normalize_host, slug_for_host # noqa: F401 — canonical host helpers re-exported for the public app
from app.core.logger import get_logger
from app.utils.guardrail_runtime import Surface, check_inbound
from app.database import SessionLocal
from app.models.faq import FAQ
from app.models.help_center import HelpCenterSettings
from app.repositories.faq import FAQRepository
from app.repositories.help_center import HelpCenterQueryRepository, HelpCenterRepository
from app.services.help_center_access import help_center_allowed
logger = get_logger(__name__)
MAX_QUESTION_CHARS = 500
ANSWER_MAX_TOKENS = 600
# Unauthenticated LLM calls are expensive: bound how many run at once per
# worker so /ask can't drain the DB pool or the provider budget.
_ask_concurrency = asyncio.Semaphore(4)
_ASK_INSTRUCTIONS = """You are the support assistant on a company's public help center. A visitor has asked a question.
You have tools to look up the answer:
- search_faqs: search the published help-center FAQs.
- search_knowledge_base: search the company's wider knowledge base (only available when the help center is mapped to an agent).
ALWAYS use the tools before answering — start with search_faqs, and also try
search_knowledge_base for anything the FAQs don't cover. You may call a tool more
than once with different wording if the first result isn't relevant.
Answer ONLY from what the tools return. When they surface a relevant answer, reply
in 1-4 short sentences of plain text (no markdown), in a friendly second-person
voice. If, after genuinely searching, you find nothing relevant, briefly say you
couldn't find that in the help center and suggest starting a chat or contacting
support. Never invent facts, prices, policies, or URLs.
""" + INJECTION_CLAUSE
def resolve_help_center(
db: Session, host: Optional[str] = None, slug: Optional[str] = None
) -> Optional[HelpCenterSettings]:
"""Enabled help center row, or None (renders as 404). Resolves by explicit
`slug` (path dispatch) when given, else by Host header (subdomain slug or a
verified custom domain). Custom domains only match once verified; plan
lapses hide the site."""
repo = HelpCenterRepository(db)
if slug is not None:
row = repo.get_by_slug(slug)
else:
host_slug = slug_for_host(host or "")
if host_slug:
row = repo.get_by_slug(host_slug)
else:
row = repo.get_by_verified_domain(host or "")
if not row or not row.enabled:
return None
if not help_center_allowed(db, row.organization_id):
return None
return row
# Topic accent colors, applied by category display order (matches the index and
# article templates). Kept here so list cards, sidebar topics, the article tag
# and related cards all resolve the SAME color for a given category.
CATEGORY_PALETTE = [
"#6d5bd0", "#0e8c8c", "#cf5b38", "#2a6fdb", "#1f8a5b", "#b0468a", "#c98a1e", "#3a6f8f",
]
def published_faq_groups(
db: Session, row: HelpCenterSettings, search: Optional[str] = None
) -> List[Tuple[str, List[FAQ]]]:
"""Published FAQs grouped by category, in display order."""
faqs = FAQRepository(db).get_published_for_org(row.organization_id, search=search)
groups: dict = {}
for faq in faqs:
groups.setdefault(faq.category, []).append(faq)
return list(groups.items())
def category_colors(categories) -> dict:
"""category name -> accent hex, by display order."""
return {c: CATEGORY_PALETTE[i % len(CATEGORY_PALETTE)] for i, c in enumerate(categories)}
def get_published_article(db: Session, row: HelpCenterSettings, slug: str) -> Optional[FAQ]:
"""A single published FAQ by slug for the /a/{slug} article page."""
return FAQRepository(db).get_published_by_slug(row.organization_id, slug)
def get_published_article_by_path(
db: Session, row: HelpCenterSettings, url_path: str
) -> Optional[FAQ]:
"""A single published FAQ by its preserved original path — the lookup for a
URL the org carried over from the help center it migrated from."""
return FAQRepository(db).get_published_by_url_path(row.organization_id, url_path)
def related_articles(db: Session, row: HelpCenterSettings, faq: FAQ, limit: int = 4) -> List[FAQ]:
"""Other published FAQs in the same category — the article's related list."""
return FAQRepository(db).get_published_related(
row.organization_id, faq.category, faq.id, limit=limit
)
def contrast_ink(hex_color: str) -> str:
"""Dark or light text over the brand color (WCAG relative luminance)."""
try:
value = hex_color.lstrip("#")
if len(value) == 3:
value = "".join(ch * 2 for ch in value)
r, g, b = (int(value[i:i + 2], 16) / 255 for i in (0, 2, 4))
def linear(c: float) -> float:
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
return "#12131A" if luminance > 0.45 else "#FFFFFF"
except (ValueError, IndexError):
return "#FFFFFF"
def widget_id_for(row: HelpCenterSettings) -> Optional[str]:
"""The chat widget embedded on the page: the mapped agent's widget. Gated by
its own toggle, independent of the AI quick-summary in search."""
if not (row.chat_widget_enabled and row.agent and row.agent.widgets):
return None
return row.agent.widgets[0].id
def ask_available(row: HelpCenterSettings) -> bool:
return bool(row.ai_search_enabled and row.agent_id)
async def answer_question(organization_id, agent_id, question: str) -> Optional[str]:
"""Grounded answer for a public help-center question. The agent is given
FAQ + knowledge search tools and decides what to look up, so it can find and
combine answers instead of relying on a single pre-stuffed context. Returns
None when no confident answer could be produced. Writes the ask-log row
(metering/analytics); never touches chat history or the KB.
The whole tool-using run happens in a worker thread: the tools do synchronous
DB work, and holding a pooled connection through a multi-second model call
would let this unauthenticated endpoint drain the pool for the backend.
"""
from app.core.security import decrypt_api_key
from app.repositories.ai_config import AIConfigRepository
question = question.strip()[:MAX_QUESTION_CHARS]
# Pre-LLM guardrail on the only unauthenticated LLM entry point: a
# blocked question never reaches the semaphore, the DB pool, or the model.
# None renders as the standard "couldn't find that" fallback.
guardrail_ctx = GuardrailContext(
org_id=str(organization_id), agent_id=str(agent_id) if agent_id else None
)
if check_inbound(question, ctx=guardrail_ctx, surface=Surface.HELP_CENTER).block:
return None
async with _ask_concurrency:
with SessionLocal() as db:
config = AIConfigRepository(db).get_active_config(organization_id)
if not config:
return None
model_type = config.model_type.value if hasattr(config.model_type, "value") else str(config.model_type)
model_name = config.model_name
api_key = decrypt_api_key(config.encrypted_api_key)
base_url = (config.settings or {}).get('base_url')
def _run() -> Optional[str]:
from agno.agent import Agent
from app.tools.faq_search import FAQSearchTool
from app.tools.knowledge_search_byagent import KnowledgeSearchByAgent
from app.utils.agno_utils import create_model
tools = [FAQSearchTool(organization_id=organization_id)]
if agent_id:
tools.append(KnowledgeSearchByAgent(agent_id=str(agent_id), org_id=organization_id))
model = create_model(
model_type=model_type, api_key=api_key, model_name=model_name, max_tokens=ANSWER_MAX_TOKENS, base_url=base_url
)
agent = Agent(
name="Help Center Answers",
model=model,
tools=tools,
instructions=_ASK_INSTRUCTIONS,
markdown=False,
)
response = agent.run(
message=visitor_data_block("VISITOR QUESTION", question),
stream=False,
)
content = getattr(response, "content", None)
return content.strip() if isinstance(content, str) and content.strip() else None
answer = None
try:
answer = await asyncio.to_thread(_run)
except Exception as e:
logger.error(f"Help center ask failed for org {organization_id}: {e}")
finally:
try:
with SessionLocal() as log_db:
HelpCenterQueryRepository(log_db).log(organization_id, question, answered=bool(answer))
except Exception as log_err:
logger.error(f"Help center ask log failed: {log_err}")
return answer
+267
View File
@@ -0,0 +1,267 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SEO surface of the public help center: the <head> values (title, description,
canonical, Open Graph) and the schema.org JSON-LD graph. Kept out of the route
module so every page derives its metadata by the same rules.
Two URL spaces are in play and must not be mixed up:
- ABSOLUTE (live_url-based) for canonical, og:*, sitemap and every JSON-LD
@id/url — crawlers and social scrapers need a fetchable address.
- RELATIVE (base_path-prefixed) for links the page actually renders, so the
same markup works on a subdomain, a custom domain and /help/{slug}.
Breadcrumbs carry both, which is how the BreadcrumbList markup is guaranteed to
match the visible trail (a Google requirement).
"""
from datetime import datetime
from typing import List, Optional, Sequence
from urllib.parse import quote, urlsplit
from app.models.faq import FAQ
from app.models.help_center import HelpCenterSettings
from app.services.help_center_content import excerpt, to_plain_text
from app.services.help_center_settings import live_url
HOME_CRUMB = "Home"
# Meta descriptions get truncated by search engines around here; the excerpt
# helper adds an ellipsis rather than cutting mid-word.
META_DESCRIPTION_CHARS = 200
def site_url(row: HelpCenterSettings) -> str:
"""The help center's public base URL, never trailing-slashed."""
return live_url(row).rstrip("/")
def asset_origin(site: str) -> str:
"""Scheme + host the site's uploads are served from. Same origin as the
site in every mode — but NOT the same prefix in path mode, where the site
is {backend}/help/{slug} while uploads stay at {backend}/api/v1/uploads/…"""
parts = urlsplit(site)
return f"{parts.scheme}://{parts.netloc}"
def absolute_asset_url(stored_url: Optional[str], site: str) -> Optional[str]:
"""A stored logo/image URL made absolute for og:image and JSON-LD. S3 URLs
are already absolute and pass through; local uploads are relative and get
anchored to the serving origin."""
if not stored_url:
return None
if stored_url.startswith("http://") or stored_url.startswith("https://"):
return stored_url
return f"{asset_origin(site)}{stored_url}"
def _isoformat(value: Optional[datetime]) -> Optional[str]:
return value.isoformat() if value else None
def article_title(row: HelpCenterSettings, faq: FAQ) -> str:
"""The article's <title>: the org's override, else question + site name."""
return faq.meta_title or f"{faq.question} · {row.organization.name} Help Center"
def article_description(faq: FAQ) -> str:
"""The article's meta description: the org's override, else an excerpt of
the answer (falling back to the question for answers with no prose)."""
return faq.meta_description or excerpt(faq.answer, META_DESCRIPTION_CHARS) or faq.question
def index_title(row: HelpCenterSettings) -> str:
return row.title or f"{row.organization.name} Help Center"
def index_description(row: HelpCenterSettings) -> str:
return row.description or f"Answers to common questions about {row.organization.name}."
def category_url(site: str, category: str) -> str:
"""Absolute URL of the index filtered to one category — the middle
breadcrumb. Mirrors the ?topic= link the templates render."""
return f"{site}/?topic={quote(category)}"
def article_path(faq: FAQ) -> str:
"""The root-relative path an article is served at, percent-DECODED.
An article migrated in with "preserve original URLs" keeps the path it had
on the old help center; everything else lives at /a/{slug}. Exactly one of
the two is canonical — /a/{slug} 301s to the preserved path when there is
one — so this is the single answer to "where does this article live?" and
every URL the site emits derives from it."""
return faq.url_path or f"/a/{faq.slug}"
def article_href(faq: FAQ, base_path: str = "") -> str:
"""What a page renders in href=: the serving prefix plus the ENCODED path."""
return f"{base_path}{quote(article_path(faq), safe='/')}"
def article_url(site: str, faq: FAQ) -> str:
"""The article's ABSOLUTE URL, for canonical/og:url/sitemap/JSON-LD."""
return f"{site}{quote(article_path(faq), safe='/')}"
def breadcrumbs(
row: HelpCenterSettings, faq: FAQ, base_path: str, site: str
) -> List[dict]:
"""Home › Category › Article. Each crumb carries `href` (what the page
links to) and `url` (what BreadcrumbList advertises); the last crumb is the
current page and is rendered as text, so it has no href."""
return [
{"label": HOME_CRUMB, "href": f"{base_path}/", "url": f"{site}/"},
{
"label": faq.category,
"href": f"{base_path}/?topic={quote(faq.category)}",
"url": category_url(site, faq.category),
},
{"label": faq.question, "href": None, "url": article_url(site, faq)},
]
def _organization_node(row: HelpCenterSettings, site: str, logo_url: Optional[str]) -> dict:
node = {
"@type": "Organization",
"@id": f"{site}/#organization",
"name": row.organization.name,
"url": f"{site}/",
}
if logo_url:
node["logo"] = {"@type": "ImageObject", "url": logo_url}
return node
def _website_node(row: HelpCenterSettings, site: str) -> dict:
return {
"@type": "WebSite",
"@id": f"{site}/#website",
"url": f"{site}/",
"name": index_title(row),
"publisher": {"@id": f"{site}/#organization"},
}
def _question_entity(faq: FAQ) -> dict:
"""One schema.org Question + acceptedAnswer, from the plain-text form of the
(Markdown) answer."""
return {
"@type": "Question",
"name": faq.question,
"acceptedAnswer": {"@type": "Answer", "text": to_plain_text(faq.answer)},
}
def _breadcrumb_node(crumbs: Sequence[dict], page_url: str) -> dict:
return {
"@type": "BreadcrumbList",
"@id": f"{page_url}#breadcrumb",
"itemListElement": [
{"@type": "ListItem", "position": i, "name": crumb["label"], "item": crumb["url"]}
for i, crumb in enumerate(crumbs, start=1)
],
}
def index_json_ld(
row: HelpCenterSettings, faqs: Sequence[FAQ], site: str, logo_url: Optional[str]
) -> dict:
"""The landing page graph: Organization + WebSite + a CollectionPage that is
also the FAQPage carrying every published Q&A.
FAQPage - not QAPage - is the correct type for these owner-authored answers.
QAPage models community/forum pages where users submit competing answers and
requires answerCount/upvoteCount, which don't apply here. Google retired FAQ
rich results, but the markup still describes the page for other consumers.
"""
page_url = f"{site}/"
page: dict = {
"@type": ["CollectionPage", "FAQPage"],
"@id": f"{page_url}#webpage",
"url": page_url,
"name": index_title(row),
"description": index_description(row),
"isPartOf": {"@id": f"{site}/#website"},
"about": {"@id": f"{site}/#organization"},
}
if faqs:
page["mainEntity"] = [_question_entity(faq) for faq in faqs]
return {
"@context": "https://schema.org",
"@graph": [
_organization_node(row, site, logo_url),
_website_node(row, site),
page,
],
}
def article_json_ld(
row: HelpCenterSettings,
faq: FAQ,
site: str,
crumbs: Sequence[dict],
logo_url: Optional[str],
image_url: Optional[str],
) -> dict:
"""The article page graph: Organization + WebSite + WebPage + BreadcrumbList
+ TechArticle, wired together by @id.
TechArticle (not FAQPage) is what a single how-to/help article is: Google
dropped FAQ rich results, while BreadcrumbList still renders in search.
"""
page_url = article_url(site, faq)
description = article_description(faq)
article: dict = {
"@type": "TechArticle",
"@id": f"{page_url}#article",
"headline": faq.question,
"description": description,
"articleSection": faq.category,
"inLanguage": "en",
"mainEntityOfPage": {"@id": f"{page_url}#webpage"},
"isPartOf": {"@id": f"{page_url}#webpage"},
"author": {"@id": f"{site}/#organization"},
"publisher": {"@id": f"{site}/#organization"},
}
published_at = _isoformat(faq.created_at)
# updated_at stays NULL until the first edit, so fall back to the creation
# date rather than claiming a modification date older than publication.
modified_at = _isoformat(faq.updated_at) or published_at
if published_at:
article["datePublished"] = published_at
if modified_at:
article["dateModified"] = modified_at
if image_url:
article["image"] = image_url
web_page = {
"@type": "WebPage",
"@id": f"{page_url}#webpage",
"url": page_url,
"name": article_title(row, faq),
"description": description,
"isPartOf": {"@id": f"{site}/#website"},
"breadcrumb": {"@id": f"{page_url}#breadcrumb"},
}
return {
"@context": "https://schema.org",
"@graph": [
_organization_node(row, site, logo_url),
_website_node(row, site),
web_page,
_breadcrumb_node(crumbs, page_url),
article,
],
}
@@ -0,0 +1,252 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Help-center settings lifecycle: lazy get-or-create (which is also the
"org has adopted the feature" marker), slug generation and the agent
auto-default for AI search.
"""
import re
from typing import Iterable, Optional, Sequence
from urllib.parse import unquote, urlsplit
from uuid import UUID
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logger import get_logger
from app.models.agent import Agent
from app.models.faq import FAQ, FAQ_SLUG_MAX_LENGTH, FAQ_URL_PATH_MAX_LENGTH
from app.models.help_center import HelpCenterSettings
from app.models.knowledge_to_agent import KnowledgeToAgent
from app.repositories.faq import FAQRepository
from app.repositories.help_center import HelpCenterRepository
logger = get_logger(__name__)
SLUG_MAX_LENGTH = 63
_SLUG_CLEAN_RE = re.compile(r"[^a-z0-9]+")
# First path segments the public help center already owns (app.api
# help_center_public routes, its uploads/images mounts, the /help/{slug}
# dispatch prefix, and /.well-known, which ACME needs for TLS issuance). A
# preserved path may never start with one of these or it would shadow the real
# route. Matching on the FIRST SEGMENT rather than a string prefix keeps
# "/apix/..." usable while still rejecting "/api/...".
FAQ_RESERVED_FIRST_SEGMENTS = frozenset(
{"a", "ask", "api", "help", "healthz", "robots.txt", "sitemap.xml", ".well-known", "static", "uploads"}
)
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
def slugify_org_name(name: str) -> str:
slug = _SLUG_CLEAN_RE.sub("-", (name or "").casefold()).strip("-")
return slug[:SLUG_MAX_LENGTH].strip("-") or "help"
def _faq_base_slug(question: str) -> str:
base = _SLUG_CLEAN_RE.sub("-", (question or "").casefold()).strip("-")
return base[:FAQ_SLUG_MAX_LENGTH].strip("-") or "article"
def _dedupe_slug(base: str, is_taken) -> str:
"""First free slug from `base`, appending -2, -3, … while is_taken(candidate)."""
candidate = base
suffix = 2
while is_taken(candidate):
tail = f"-{suffix}"
candidate = f"{base[:FAQ_SLUG_MAX_LENGTH - len(tail)]}{tail}"
suffix += 1
return candidate
def generate_faq_slug(db: Session, organization_id: UUID, question: str) -> str:
"""A per-org-unique URL slug from an FAQ question. Collisions get -2, -3, …
Assigned once at creation and kept stable afterwards so article URLs persist."""
repo = FAQRepository(db)
return _dedupe_slug(_faq_base_slug(question), lambda c: repo.slug_exists(organization_id, c))
def resolve_faq_slug(
db: Session, organization_id: UUID, requested: str, exclude_id: Optional[UUID] = None
) -> str:
"""A hand-typed slug, reduced to the same shape generation produces
(lowercase, a-z0-9 and single hyphens, length-capped) and made unique within
the org. `exclude_id` is the FAQ being edited, so keeping its own slug is a
no-op rather than a collision that appends -2."""
repo = FAQRepository(db)
return _dedupe_slug(
_faq_base_slug(requested),
lambda c: repo.slug_exists(organization_id, c, exclude_id=exclude_id),
)
def assign_faq_slugs(db: Session, faqs) -> None:
"""Give every FAQ in a batch a unique slug in place, deduping against both the
DB and the other rows in the same batch. Rows that already have a slug are
left untouched (and reserved so batch-mates don't collide with them)."""
repo = FAQRepository(db)
taken: set = set()
for faq in faqs:
if faq.slug:
taken.add(faq.slug)
continue
slug = _dedupe_slug(
_faq_base_slug(faq.question),
lambda c: c in taken or repo.slug_exists(faq.organization_id, c),
)
faq.slug = slug
taken.add(slug)
def normalize_url_path(raw: Optional[str]) -> Optional[str]:
"""A full URL or hand-typed path reduced to the root-relative path an article
can be served at — or None when it can't be used at all, in which case the
caller falls back to /a/{slug}.
Returned percent-DECODED, because that is the form the ASGI scope hands the
router to match against; every emitted URL re-encodes it (see
help_center_seo.article_url). Getting that direction backwards silently 404s
every non-ASCII path."""
if not raw:
return None
candidate = raw.strip()
if "://" in candidate or candidate.startswith("//"):
candidate = urlsplit(candidate).path # drop scheme/host/port
candidate = candidate.split("#", 1)[0].split("?", 1)[0] # drop fragment/query
candidate = unquote(candidate)
if _CONTROL_CHARS_RE.search(candidate):
return None
if not candidate.startswith("/"):
candidate = f"/{candidate}"
candidate = re.sub(r"/{2,}", "/", candidate).rstrip("/")
if len(candidate) > FAQ_URL_PATH_MAX_LENGTH:
return None
segments = candidate.split("/")[1:]
if not segments or not segments[0]:
return None # "" or "/" — that's the index, not an article
if "." in segments or ".." in segments:
return None # no traversal, and nothing that normalises to another path
if segments[0].lower() in FAQ_RESERVED_FIRST_SEGMENTS:
return None
return candidate
def resolve_faq_url_path(
db: Session, organization_id: UUID, requested: str, exclude_id: Optional[UUID] = None
) -> Optional[str]:
"""A validated preserved path that is free within the org, else None.
Deliberately does NOT dedupe with -2/-3 the way slugs do: a mangled path is
no longer the URL the org already ranks for, so silently altering it would
defeat the whole point. The caller decides what to do instead — fall back to
/a/{slug} on import, or reject the edit in the admin API."""
path = normalize_url_path(requested)
if path is None:
return None
repo = FAQRepository(db)
return None if repo.url_path_exists(organization_id, path, exclude_id=exclude_id) else path
def assign_faq_url_paths(db: Session, faqs: Sequence[FAQ], sources: Iterable[str]) -> int:
"""Give each FAQ in a batch its source URL's path, in place, deduping against
both the DB and the other rows in the batch. Returns how many were applied.
Rows whose path is unusable or taken keep url_path=None and are served at
/a/{slug} — a partial result still imports every article, which beats
failing the whole job over one bad URL.
MUST run before the batch insert, so the unique index can never reject it."""
repo = FAQRepository(db)
taken: set = set()
applied = 0
for faq, source in zip(faqs, sources):
path = normalize_url_path(source)
if path is None:
continue
if path in taken or repo.url_path_exists(faq.organization_id, path):
logger.warning(f"Preserved URL path already taken, using the slug instead: {path}")
continue
faq.url_path = path
taken.add(path)
applied += 1
return applied
def generate_unique_slug(db: Session, name: str) -> str:
"""Slug from the org name; reserved labels and collisions get -2, -3, …"""
repo = HelpCenterRepository(db)
base = slugify_org_name(name)
if base in settings.HELP_CENTER_RESERVED_SLUGS:
base = f"{base}-help"[:SLUG_MAX_LENGTH]
candidate = base
suffix = 2
while repo.slug_exists(candidate) or candidate in settings.HELP_CENTER_RESERVED_SLUGS:
tail = f"-{suffix}"
candidate = f"{base[:SLUG_MAX_LENGTH - len(tail)]}{tail}"
suffix += 1
return candidate
def default_agent_id(db: Session, organization_id: UUID) -> Optional[UUID]:
"""The org's only active agent; else the first agent with linked knowledge;
else None (user picks manually)."""
active_agents = (
db.query(Agent)
.filter(Agent.organization_id == organization_id, Agent.is_active.is_(True))
.order_by(Agent.id)
.all()
)
if len(active_agents) == 1:
return active_agents[0].id
with_knowledge = (
db.query(Agent)
.join(KnowledgeToAgent, KnowledgeToAgent.agent_id == Agent.id)
.filter(Agent.organization_id == organization_id)
.order_by(Agent.id)
.first()
)
return with_knowledge.id if with_knowledge else None
def get_or_create_settings(db: Session, organization) -> HelpCenterSettings:
"""Fetch the org's help-center settings, creating the row (slug + agent
auto-default) on first access."""
repo = HelpCenterRepository(db)
row = repo.get_by_org(organization.id)
if row:
return row
row = HelpCenterSettings(
organization_id=organization.id,
slug=generate_unique_slug(db, organization.name),
agent_id=default_agent_id(db, organization.id),
)
created = repo.create(row)
logger.info(f"Created help center settings for org {organization.id} (slug={created.slug})")
return created
def live_url(row: HelpCenterSettings) -> str:
"""The public URL the help center is (or will be) served at.
A verified custom domain always wins. Otherwise the URL shape follows
HELP_CENTER_PUBLIC_MODE: "subdomain" advertises {slug}.<base> (cloud); the
default "path" mode serves same-origin as the API at {BACKEND_URL}/help/{slug}
(self-host — no DNS/TLS/proxy needed, and correct scheme+port via BACKEND_URL)."""
if row.domain_verified:
return f"https://{row.custom_domain}"
if settings.HELP_CENTER_PUBLIC_MODE == "subdomain":
return f"https://{row.slug}.{settings.HELP_CENTER_BASE_DOMAIN}"
return f"{settings.BACKEND_URL.rstrip('/')}/help/{row.slug}"
+362
View File
@@ -0,0 +1,362 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
import requests
from urllib.parse import urlencode
from app.models.jira import JiraToken
from app.core.config import settings
from app.core.exceptions import JiraAuthError
from app.core.external_urls import external_base
from app.core.logger import get_logger
logger = get_logger(__name__)
# Atlassian hosts. Deployments never set these; the e2e suite points them at its
# recording fake. The authorize URL is browser-facing, the rest are server calls.
JIRA_AUTH_BASE = external_base("JIRA_AUTH_BASE", "https://auth.atlassian.com")
JIRA_API_BASE = external_base("JIRA_API_BASE", "https://api.atlassian.com")
def jira_authorize_url() -> str:
return f"{JIRA_AUTH_BASE}/authorize"
def jira_token_url() -> str:
return f"{JIRA_AUTH_BASE}/oauth/token"
def jira_accessible_resources_url() -> str:
return f"{JIRA_API_BASE}/oauth/token/accessible-resources"
def jira_rest_url(cloud_id: str, path: str) -> str:
"""Jira Cloud REST v3 URL, e.g. `jira_rest_url(cloud_id, "issue")`."""
return f"{JIRA_API_BASE}/ex/jira/{cloud_id}/rest/api/3/{path}"
class JiraService:
"""Service for handling Jira OAuth 2.0 flow and API interactions."""
def __init__(self):
self.client_id = settings.JIRA_CLIENT_ID
self.client_secret = settings.JIRA_CLIENT_SECRET
self.redirect_uri = settings.JIRA_REDIRECT_URI
self.auth_url = jira_authorize_url()
self.token_url = jira_token_url()
self.scope = "read:jira-work write:jira-work read:jira-user offline_access"
def get_authorization_url(self, state: str) -> str:
"""Generate the authorization URL for Jira OAuth flow."""
params = {
"audience": "api.atlassian.com",
"client_id": self.client_id,
"scope": self.scope,
"redirect_uri": self.redirect_uri,
"state": state,
"response_type": "code",
"prompt": "consent"
}
return f"{self.auth_url}?{urlencode(params)}"
def validate_token(self, token: JiraToken) -> bool:
"""Check if the token is valid and not expired."""
if token is None:
return False
return token.expires_at > datetime.utcnow() + timedelta(minutes=5)
async def exchange_code_for_token(self, code: str) -> Dict[str, Any]:
"""Exchange authorization code for access token."""
data = {
"grant_type": "authorization_code",
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": self.redirect_uri
}
response = requests.post(self.token_url, data=data)
if response.status_code != 200:
raise JiraAuthError("Failed to exchange code for token")
token_data = response.json()
return {
"access_token": token_data["access_token"],
"refresh_token": token_data["refresh_token"],
"token_type": token_data["token_type"],
"expires_at": datetime.utcnow() + timedelta(seconds=token_data["expires_in"])
}
async def refresh_token(self, refresh_token: str) -> Dict[str, Any]:
"""Refresh an expired access token."""
data = {
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": refresh_token
}
response = requests.post(self.token_url, data=data)
if response.status_code != 200:
raise JiraAuthError("Failed to refresh token")
token_data = response.json()
return {
"access_token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token", refresh_token),
"token_type": token_data["token_type"],
"expires_at": datetime.utcnow() + timedelta(seconds=token_data["expires_in"])
}
async def get_cloud_id(self, access_token: str) -> Dict[str, str]:
"""Get Jira Cloud ID and site URL for the authenticated user."""
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json"
}
response = requests.get(jira_accessible_resources_url(), headers=headers)
if response.status_code != 200:
raise JiraAuthError("Failed to get Jira Cloud ID")
resources = response.json()
if not resources:
raise JiraAuthError("No Jira Cloud instances found")
# Use the first available cloud instance
return {
"cloud_id": resources[0]["id"],
"site_url": resources[0]["url"]
}
async def get_projects(self, access_token: str, cloud_id: str) -> List[Dict[str, Any]]:
"""Get all projects from Jira."""
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"Content-Type": "application/json"
}
url = jira_rest_url(cloud_id, "project")
response = requests.get(url, headers=headers)
if response.status_code != 200:
raise JiraAuthError(f"Failed to get Jira projects: {response.text}")
projects = response.json()
return [
{
"id": project["id"],
"key": project["key"],
"name": project["name"]
}
for project in projects
]
async def get_issue_types(self, access_token: str, cloud_id: str, project_key: str) -> List[Dict[str, Any]]:
"""Get all issue types for a project from Jira."""
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"Content-Type": "application/json"
}
# First, get the project to find its ID
url = jira_rest_url(cloud_id, f"project/{project_key}")
response = requests.get(url, headers=headers)
if response.status_code != 200:
raise JiraAuthError(f"Failed to get Jira project: {response.text}")
project = response.json()
# Get issue types for the project, filtering out sub-tasks
issue_types = []
for issue_type in project.get("issueTypes", []):
# Skip sub-tasks as they require a parent issue
if issue_type.get("subtask", False):
logger.info(f"Skipping sub-task issue type: {issue_type['name']} (ID: {issue_type['id']})")
continue
issue_types.append({
"id": issue_type["id"],
"name": issue_type["name"],
"description": issue_type.get("description", ""),
"iconUrl": issue_type.get("iconUrl", "")
})
return issue_types
async def create_issue(self, organization, db, issue_data):
"""Wrapper method for create_issue that takes organization, db, and issue_data parameters."""
from app.models.jira import JiraToken
token = db.query(JiraToken).filter(
JiraToken.organization_id == organization.id
).first()
if not token:
raise JiraAuthError("No Jira connection found")
# Check if token is valid and refresh if needed
is_valid = self.validate_token(token)
if not is_valid:
try:
token_data = await self.refresh_token(token.refresh_token)
# Update token in database
for key, value in token_data.items():
setattr(token, key, value)
db.commit()
except Exception as e:
logger.error(f"Failed to refresh Jira token: {e}")
raise JiraAuthError("Jira token expired and could not be refreshed")
# Create the issue
result = await self.create_issue_internal(
token.access_token,
token.cloud_id,
issue_data.projectKey,
issue_data.issueTypeId,
issue_data.summary,
issue_data.description,
issue_data.priority
)
return result
async def create_issue_internal(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str,
summary: str, description: str, priority_id: Optional[str] = None) -> Dict[str, Any]:
"""Create a new issue in Jira."""
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"Content-Type": "application/json"
}
url = jira_rest_url(cloud_id, "issue")
data = {
"fields": {
"project": {
"key": project_key
},
"issuetype": {
"id": issue_type_id
},
"summary": summary,
"description": {
"type": "doc",
"version": 1,
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": description
}
]
}
]
}
}
}
# Add priority if provided and available
if priority_id:
# Check if priority field is available for this project/issue type
priority_available = await self.is_priority_available(access_token, cloud_id, project_key, issue_type_id)
if priority_available:
data["fields"]["priority"] = {
"id": priority_id
}
else:
logger.warning(f"Priority field not available for project {project_key} and issue type {issue_type_id}. Skipping priority.")
response = requests.post(url, headers=headers, json=data)
if response.status_code not in (200, 201):
raise JiraAuthError(f"Failed to create Jira issue: {response.text}")
return response.json()
async def get_priorities(self, access_token: str, cloud_id: str) -> List[Dict[str, Any]]:
"""Get all priorities from Jira."""
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"Content-Type": "application/json"
}
url = jira_rest_url(cloud_id, "priority")
response = requests.get(url, headers=headers)
if response.status_code != 200:
raise JiraAuthError(f"Failed to get Jira priorities: {response.text}")
priorities = response.json()
return [
{
"id": priority["id"],
"name": priority["name"],
"description": priority.get("description", ""),
"iconUrl": priority.get("iconUrl", "")
}
for priority in priorities
]
async def check_field_availability(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str, field_name: str) -> bool:
"""Check if a field is available for a project/issue type combination."""
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"Content-Type": "application/json"
}
# Get the create metadata for the project/issue type
url = jira_rest_url(cloud_id, f"issue/createmeta?projectKeys={project_key}&issuetypeIds={issue_type_id}&expand=projects.issuetypes.fields")
response = requests.get(url, headers=headers)
if response.status_code != 200:
logger.error(f"Failed to get create metadata: {response.text}")
return False
metadata = response.json()
# Navigate through the response structure to find the field
try:
projects = metadata.get("projects", [])
if not projects:
return False
issue_types = projects[0].get("issuetypes", [])
if not issue_types:
return False
fields = issue_types[0].get("fields", {})
# Check if the field exists
return field_name in fields
except Exception as e:
logger.error(f"Error checking field availability: {e}")
return False
async def is_priority_available(self, access_token: str, cloud_id: str, project_key: str, issue_type_id: str) -> bool:
"""Check if the priority field is available for a project/issue type combination."""
return await self.check_field_availability(access_token, cloud_id, project_key, issue_type_id, "priority")
+189
View File
@@ -0,0 +1,189 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
WhatsApp numbers and Messenger Pages that live in the customer's own Meta
app (connected with manual credentials) rather than in ReplyPier's.
Meta signs their webhooks with the customer's app secret, which the shared
/webhooks/meta endpoint can't check. So each such account gets its own
webhook address, checked with its app's secret only. That address takes
events for the account and for the same organization's other accounts from
the same app (one Meta app has one Messenger callback, and possibly several
numbers), never for anyone else's: a payload signed with a secret someone
chose themselves can't reach another organization.
A WhatsApp number is pointed at its address with a per-number callback
override; Messenger has no override, so the customer pastes the address and
verify token into their app.
"""
import hashlib
import hmac
from dataclasses import dataclass
from typing import Callable, Optional
from sqlalchemy.orm import Session
from app.channels.meta_base import graph_detail, graph_get, graph_post_json
from app.core.config import settings, webhooks_base_url
from app.core.logger import get_logger
from app.models.channels import ChannelAccount, ChannelType
from app.core.security import decrypt_credentials
from app.repositories.channels.accounts import ChannelAccountRepository
logger = get_logger(__name__)
# In the account's encrypted credentials
APP_SECRET_KEY = "app_secret"
# In its (non-secret) settings: which app it's from, and whether Meta
# accepted our per-number callback for it
APP_ID_SETTING = "meta_app_id"
AUTO_WEBHOOK_SETTING = "webhook_auto_configured"
OWN_APP_CHANNELS = (ChannelType.WHATSAPP.value, ChannelType.MESSENGER.value)
# Meta's answer to an appsecret_proof made with another app's secret
_PROOF_ERROR = "appsecret_proof"
class AppSecretMismatch(Exception):
"""The secret isn't the one of the app that issued the token."""
class AppCheckUnavailable(Exception):
"""Meta couldn't be asked (network, rate limit, an expired token…)."""
@dataclass
class OwnApp:
app_id: str
secret: str
def is_own_app(account: Optional[ChannelAccount]) -> bool:
return (account is not None and account.channel_type in OWN_APP_CHANNELS
and bool((account.settings or {}).get(APP_ID_SETTING)))
def own_app_secret(account: ChannelAccount) -> Optional[str]:
return decrypt_credentials(account.encrypted_credentials).get(APP_SECRET_KEY) if is_own_app(account) else None
def own_app_token(account: Optional[ChannelAccount]) -> Optional[str]:
"""The customer's token, read before a reconnect replaces it: only it can
take back a callback override made with it."""
return decrypt_credentials(account.encrypted_credentials).get("access_token") if is_own_app(account) else None
def own_app_webhook_url(account: ChannelAccount) -> str:
return f"{webhooks_base_url()}/meta/{account.id}"
async def resolve_own_app(access_token: str, entered_secret: Optional[str],
previous: Optional[ChannelAccount]) -> Optional[OwnApp]:
"""The customer's own app behind this token, proven with its secret —
the one entered, or else the one kept from the last connect (checked
again: the new token may be from another app). None without a secret, or
for ReplyPier's own app, whose webhooks the shared address already gets.
Raises AppSecretMismatch or AppCheckUnavailable."""
secret = entered_secret or (own_app_secret(previous) if previous is not None else None)
if not secret:
return None
proof = hmac.new(secret.encode(), access_token.encode(), hashlib.sha256).hexdigest()
# /app (the token's own app) needs no permission on the Page or number
ok, data = await graph_get("app", access_token, params={"fields": "id", "appsecret_proof": proof})
if not ok:
error = data.get("error") if isinstance(data.get("error"), dict) else {}
if _PROOF_ERROR in str(error.get("message", "")):
raise AppSecretMismatch()
# Meta's own errors carry a code; anything else is ours (a timeout,
# a socket error) and stays in the log
logger.warning(f"Could not check an app secret with Meta: {error.get('message')}")
raise AppCheckUnavailable(graph_detail(data, "") if "code" in error else "Meta did not answer")
app_id = str(data.get("id") or "")
if not app_id or app_id == settings.META_APP_ID:
return None
return OwnApp(app_id=app_id, secret=secret)
async def sync_own_app(db: Session, account: ChannelAccount, own: Optional[OwnApp],
access_token: str, previous_token: Optional[str]) -> None:
"""After a connect: route the account's webhooks to its own address when
it's from the customer's app, or hand a number that used to be back."""
repo = ChannelAccountRepository(db)
is_whatsapp = account.channel_type == ChannelType.WHATSAPP.value
if own is not None:
# Moved to another app: the old app's override is still the old app's
if is_whatsapp and previous_token and (account.settings or {}).get(APP_ID_SETTING) != own.app_id:
await _set_number_callback(account, previous_token, "", None)
# Saved first: Meta checks the address during the callback call, and
# the address only answers for an account marked as from its own app
repo.update_settings(account, {APP_ID_SETTING: own.app_id})
if is_whatsapp:
pointed = await _set_number_callback(
account, access_token, own_app_webhook_url(account), account.webhook_secret)
repo.update_settings(account, {AUTO_WEBHOOK_SETTING: pointed})
return
if previous_token is not None:
if is_whatsapp:
await _set_number_callback(account, previous_token, "", None)
repo.update_settings(account, {}, remove=(APP_ID_SETTING, AUTO_WEBHOOK_SETTING))
async def leave_own_app(account: ChannelAccount) -> None:
"""Before a disconnect: send the number's webhooks back to the customer's
app. Best effort — a disconnect must never fail over it."""
if account.channel_type != ChannelType.WHATSAPP.value:
return
try:
token = own_app_token(account)
except Exception:
logger.exception(f"Could not read {account.id}'s credentials to release its callback")
return
if token:
await _set_number_callback(account, token, "", None)
def same_app_accounts(db: Session, account: ChannelAccount) -> Callable[[str], Optional[ChannelAccount]]:
"""Resolve an external id named in a webhook to one of this organization's
accounts from the same app (and channel), or to nothing."""
repo = ChannelAccountRepository(db)
app_id = account.settings[APP_ID_SETTING]
def resolve(external_id: str) -> Optional[ChannelAccount]:
if external_id == account.external_account_id:
return account
other = repo.get_by_external_id(account.channel_type, external_id)
if (other is not None and other.organization_id == account.organization_id
and (other.settings or {}).get(APP_ID_SETTING) == app_id):
return other
return None
return resolve
async def _set_number_callback(account: ChannelAccount, access_token: str, url: str,
verify_token: Optional[str]) -> bool:
"""Point a WhatsApp number's webhooks at `url` ("" sends them back to the
app's own callback). Meta checks the address during the call."""
configuration = {"override_callback_uri": url}
if verify_token:
configuration["verify_token"] = verify_token
ok, data = await graph_post_json(account.external_account_id, access_token,
{"webhook_configuration": configuration})
if not ok:
action = "point" if url else "release"
logger.warning(f"Could not {action} the webhook callback of {account.external_account_id}: "
f"{graph_detail(data, 'no reason given')}")
return ok
+114
View File
@@ -0,0 +1,114 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import enum
from typing import Iterable, Optional
from sqlalchemy.orm import Session
from app.core.logger import get_logger
from app.models.notification import Notification, NotificationType
from app.models.notification_settings import (
NOTIFY_CHAT_ASSIGNED,
NOTIFY_CHAT_TRANSFER,
NOTIFY_NEW_CHAT,
)
from app.repositories.notification_settings import UserNotificationSettingsRepository
from app.services.user import send_fcm_notification
logger = get_logger(__name__)
class ChatNotificationEvent(str, enum.Enum):
"""Chat events a user can mute. The value is both the preference column
and the `event` discriminator carried in the push metadata.
"""
NEW_CHAT = NOTIFY_NEW_CHAT
CHAT_TRANSFER = NOTIFY_CHAT_TRANSFER
CHAT_ASSIGNED = NOTIFY_CHAT_ASSIGNED
async def notify_user(
db: Session,
user_id,
type_: NotificationType,
title: str,
message: str,
metadata: Optional[dict] = None,
) -> None:
"""Persist an in-app notification and send its FCM push.
Never raises and no-ops without a user (background jobs may have none) —
a notification failure must not fail the work it reports on.
NOTE: this commits (and, on error, rolls back) the caller's `db` session,
so call it only once any work you want persisted is already committed.
Every current caller either commits first or has nothing pending.
"""
if not user_id:
return
try:
notification = Notification(
user_id=user_id,
type=type_,
title=title,
message=message,
notification_metadata=metadata,
)
db.add(notification)
db.commit()
await send_fcm_notification(user_id, notification, db)
except Exception as e:
logger.error(f"Error sending notification '{title}' to user {user_id}: {e}")
try:
db.rollback()
except Exception:
pass
async def notify_chat_event(
db: Session,
user_ids: Iterable,
event: ChatNotificationEvent,
title: str,
message: str,
metadata: Optional[dict] = None,
) -> None:
"""Notify every user in `user_ids` who hasn't muted `event`.
Recipients are resolved in a single query, so fanning out to a whole group
or org costs one lookup rather than one per user. Like notify_user, this
never raises — a notification must not fail the chat it reports on.
"""
try:
recipients = UserNotificationSettingsRepository(db).filter_enabled(
user_ids, event.value
)
if not recipients:
return
payload = {**(metadata or {}), "event": event.value}
for user_id in recipients:
await notify_user(
db=db,
user_id=user_id,
type_=NotificationType.CHAT,
title=title,
message=message,
metadata=payload,
)
except Exception as e:
logger.error(f"Error sending '{event.value}' notification: {e}")
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,82 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from sqlalchemy.orm import Session
from typing import Optional, Tuple, List, Dict, Any
from app.core.logger import get_logger
from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository
from app.repositories.widget import WidgetRepository
from app.services.shopify import ShopifyService
from app.repositories.agent import AgentRepository
from fastapi import Request
from fastapi.responses import RedirectResponse, HTMLResponse
from app.models.organization import Organization
from app.core.config import settings
from urllib.parse import quote
import hmac
import hashlib
import base64
logger = get_logger(__name__)
class ShopifyAuthService:
"""Service for handling Shopify authentication and HTML generation"""
@staticmethod
async def get_shop_success_data(db: Session, shop_id: str) -> Tuple[int, Optional[str]]:
"""
Get success page data for a shop (agent count and widget ID).
Args:
db: Database session
shop_id: Shop ID
Returns:
Tuple of (agents_connected, widget_id)
"""
agent_config_repository = AgentShopifyConfigRepository(db)
configs = agent_config_repository.get_configs_by_shop(shop_id, enabled_only=True)
agents_connected = len(configs) if configs else 0
# Get widget ID from the first connected agent
widget_id = None
if configs and len(configs) > 0:
widget_repo = WidgetRepository(db)
widgets = widget_repo.get_widgets_by_agent(configs[0].agent_id)
if widgets and len(widgets) > 0:
widget_id = str(widgets[0].id)
return agents_connected, widget_id
@staticmethod
async def verify_shop_token(db: Session, db_shop) -> bool:
"""
Verify if a shop's access token is valid.
Args:
db: Database session
db_shop: Shop database record
Returns:
True if token is valid, False otherwise
"""
shopify_service = ShopifyService(db)
validation_query = "query { shop { name } }"
validation_result = await shopify_service._execute_graphql_async(db_shop, validation_query)
return validation_result.get("success", False)
@@ -0,0 +1,209 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from sqlalchemy.orm import Session
from typing import Optional
from fastapi import Request
from fastapi.responses import RedirectResponse, HTMLResponse
from app.core.logger import get_logger
from app.core.config import settings
from app.models.organization import Organization
from app.repositories.widget import WidgetRepository
from app.services.shopify_auth_service import ShopifyAuthService
from app.services.shopify_urls import shopify_oauth_token_url
from urllib.parse import quote
import hmac
import hashlib
import base64
logger = get_logger(__name__)
class ShopifyHelperService:
"""Service for Shopify helper functions and utilities"""
@staticmethod
def get_shop_agents_list(db: Session, organization_id: str) -> list[dict]:
"""
Get all agents for a shop's organization and convert to dict format.
Args:
db: Database session
organization_id: Organization ID (string UUID)
Returns:
List of agent dictionaries with id, name, display_name, description, is_active
"""
from app.repositories.agent import AgentRepository
agent_repository = AgentRepository(db)
agents = agent_repository.get_active_agents(organization_id)
# Convert agents to dict format
agents_list = [
{
"id": str(agent.id),
"name": agent.name,
"display_name": agent.display_name,
"description": agent.description,
"is_active": agent.is_active
}
for agent in agents
]
return agents_list
@staticmethod
def get_shop_widget_id(db: Session, configs: list, default: str = None) -> Optional[str]:
"""
Get the widget ID for the first configured agent.
Args:
db: Database session
configs: List of agent Shopify configs
default: Default value if no widget found
Returns:
Widget ID as string or None
"""
if configs and len(configs) > 0:
widget_repo = WidgetRepository(db)
widgets = widget_repo.get_widgets_by_agent(configs[0].agent_id)
if widgets and len(widgets) > 0:
return str(widgets[0].id)
return default
@staticmethod
def exchange_oauth_code_for_token(shop: str, code: str) -> tuple[str, str]:
"""
Exchange OAuth authorization code for access token.
Args:
shop: Shop domain
code: OAuth authorization code
Returns:
Tuple of (access_token, scope)
Raises:
HTTPException: If token exchange fails
"""
import requests
from fastapi import HTTPException
token_url = shopify_oauth_token_url(shop)
payload = {
"client_id": settings.SHOPIFY_API_KEY,
"client_secret": settings.SHOPIFY_API_SECRET,
"code": code
}
# Check if we should verify SSL certificates (default to True for production)
verify_ssl = settings.VERIFY_SSL_CERTIFICATES
logger.info(f"Exchanging OAuth code for token - Shop: {shop}")
logger.info(f"Verify SSL Certificates: {verify_ssl}")
logger.info(f"Token URL: {token_url}")
try:
response = requests.post(token_url, json=payload, verify=verify_ssl)
response.raise_for_status()
token_data = response.json()
access_token = token_data.get("access_token")
scope = token_data.get("scope")
if not access_token:
logger.error(f"Failed to get access token for shop: {shop}")
raise HTTPException(status_code=400, detail="Failed to obtain access token")
logger.info(f"Successfully obtained access token for shop: {shop}")
return access_token, scope
except requests.exceptions.RequestException as e:
logger.error(f"Error exchanging OAuth code for token: {str(e)}")
if isinstance(e, requests.exceptions.SSLError):
logger.error("SSL Certificate verification failed. If this is a development environment, consider setting VERIFY_SSL_CERTIFICATES=False in settings.")
raise HTTPException(status_code=500, detail=f"Failed to exchange OAuth code: {str(e)}")
@staticmethod
def verify_shopify_webhook(request_headers, request_body) -> bool:
"""
Validate Shopify HMAC signature for webhooks.
Args:
request_headers: Request headers containing X-Shopify-Hmac-Sha256
request_body: Raw request body bytes
Returns:
True if signature is valid, False otherwise
"""
shopify_hmac = request_headers.get('X-Shopify-Hmac-Sha256')
if not shopify_hmac:
return False
digest = hmac.new(
settings.SHOPIFY_API_SECRET.encode('utf-8'),
request_body,
hashlib.sha256
).digest()
computed_hmac = base64.b64encode(digest).decode('utf-8')
return hmac.compare_digest(computed_hmac, shopify_hmac)
@staticmethod
def validate_shop_request(request: Request, shop: str, hmac_param: str) -> bool:
"""
Validate the OAuth request from Shopify using HMAC validation.
Args:
request: FastAPI Request object
shop: Shop domain
hmac_param: HMAC parameter from query string
Returns:
True if request is valid, False otherwise
"""
# Check if the shop URL is a valid Shopify domain
if not shop.endswith('.myshopify.com'):
logger.warning(f"Invalid shop domain: {shop}")
return False
# Get query parameters as dictionary
query_params = dict(request.query_params)
# Remove hmac from params for validation
if 'hmac' in query_params:
query_params.pop('hmac')
logger.info(f"Query params: {query_params}")
logger.info(f"HMAC param: {hmac_param}")
# Sort and encode parameters
sorted_params = "&".join([f"{key}={quote(value)}" for key, value in sorted(query_params.items())])
# Generate HMAC
digest = hmac.new(
settings.SHOPIFY_API_SECRET.encode('utf-8'),
sorted_params.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare with provided HMAC
is_valid = hmac.compare_digest(digest, hmac_param)
if not is_valid:
logger.warning(f"HMAC validation failed for shop: {shop}")
return is_valid
+241
View File
@@ -0,0 +1,241 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import jwt
import time
from typing import Optional, Dict, Any
from fastapi import Request, HTTPException, Depends
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.logger import get_logger
from app.database import get_db
logger = get_logger(__name__)
class ShopifySessionService:
"""Service for handling Shopify session token validation"""
@staticmethod
def get_session_token_from_request(request: Request) -> Optional[str]:
"""
Get session token from request header or URL parameter.
Per Shopify docs, session token can be in:
1. Authorization header: Bearer <token>
2. URL query parameter: id_token
"""
# Try to get from Authorization header first
auth_header = request.headers.get('authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header.replace('Bearer ', '').strip()
if token:
return token
# Try to get from URL query parameter
id_token = request.query_params.get('id_token')
if id_token:
return id_token
return None
@staticmethod
def validate_session_token(token: str) -> Dict[str, Any]:
"""
Validate and decode Shopify session token.
Returns decoded token payload if valid.
Raises HTTPException if invalid.
"""
try:
# Decode without verification first to get the shop
unverified = jwt.decode(token, options={"verify_signature": False})
# Verify the token with Shopify's public key
# Note: In production, you should fetch and cache Shopify's public keys
# For now, we'll do basic validation
decoded = jwt.decode(
token,
settings.SHOPIFY_API_SECRET,
algorithms=["HS256"],
audience=settings.SHOPIFY_API_KEY
)
# Check expiration
exp = decoded.get('exp')
if exp and exp < time.time():
raise HTTPException(status_code=401, detail="Session token expired")
# Check nbf (not before)
nbf = decoded.get('nbf')
if nbf and nbf > time.time():
raise HTTPException(status_code=401, detail="Session token not yet valid")
logger.info(f"Session token validated for shop: {decoded.get('dest')}")
return decoded
except jwt.ExpiredSignatureError:
logger.warning("Session token expired")
raise HTTPException(
status_code=401,
detail="Session token expired",
headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}
)
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid session token: {str(e)}")
raise HTTPException(
status_code=401,
detail="Invalid session token",
headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}
)
@staticmethod
def is_document_request(request: Request) -> bool:
"""
Check if this is a document request (initial page load) vs API request.
Document requests typically don't have Authorization header.
"""
return not request.headers.get("authorization")
# FastAPI dependency for session token validation
async def require_shopify_session(
request: Request,
db: Session = Depends(get_db)
) -> Dict[str, Any]:
"""
FastAPI dependency to validate Shopify session token.
Extracts shop from token and looks up organization.
Returns decoded token with db_shop and organization_id.
Usage:
@router.get("/some-endpoint")
async def some_endpoint(
shopify_session: dict = Depends(require_shopify_session),
db: Session = Depends(get_db)
):
shop_id = shopify_session['shop_id']
org_id = shopify_session['organization_id']
db_shop = shopify_session['db_shop']
# ... use the shop info
"""
from app.repositories.shopify_shop_repository import ShopifyShopRepository
session_token = ShopifySessionService.get_session_token_from_request(request)
if not session_token:
raise HTTPException(
status_code=401,
detail="Session token required for embedded app",
headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}
)
# Validate and decode token
decoded = ShopifySessionService.validate_session_token(session_token)
# Extract shop domain from token
shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '')
# Look up shop in database
shop_repository = ShopifyShopRepository(db)
db_shop = shop_repository.get_shop_by_domain(shop_domain)
if not db_shop or not db_shop.is_installed:
raise HTTPException(status_code=403, detail="Shop not installed or not found")
# Add shop info to decoded token for easy access
decoded['db_shop'] = db_shop
decoded['shop_id'] = str(db_shop.id)
decoded['shop_domain'] = shop_domain
decoded['organization_id'] = db_shop.organization_id if db_shop.organization_id else None
logger.info(f"Session token validated for shop: {shop_domain}, org: {decoded['organization_id']}")
return decoded
async def require_shopify_or_jwt_auth(
request: Request,
db: Session = Depends(get_db)
) -> Dict[str, Any]:
"""
Hybrid authentication dependency that tries Shopify session token first,
then falls back to JWT authentication.
Returns authentication info with type indicator.
Usage:
@router.get("/some-endpoint")
async def some_endpoint(
auth_info: dict = Depends(require_shopify_or_jwt_auth),
db: Session = Depends(get_db)
):
if auth_info['auth_type'] == 'shopify_session':
org_id = auth_info['organization_id']
shop_id = auth_info['shop_id']
user_id = None # No user for session token auth
else: # jwt
org_id = auth_info['organization_id']
user_id = auth_info['user_id']
current_user = auth_info['current_user']
"""
from app.core.auth import get_current_user
from app.repositories.shopify_shop_repository import ShopifyShopRepository
# First try Shopify session token
session_token = ShopifySessionService.get_session_token_from_request(request)
if session_token:
try:
# Validate and decode the session token
decoded = ShopifySessionService.validate_session_token(session_token)
# Extract shop domain from token
shop_domain = decoded.get('dest', '').replace('https://', '').replace('http://', '')
if shop_domain:
# Look up shop in database
shop_repository = ShopifyShopRepository(db)
db_shop = shop_repository.get_shop_by_domain(shop_domain)
if db_shop and db_shop.is_installed and db_shop.organization_id:
logger.info(f"Using Shopify session token auth for shop: {shop_domain}")
return {
'auth_type': 'shopify_session',
'shop_domain': shop_domain,
'shop_id': str(db_shop.id),
'organization_id': db_shop.organization_id, # Keep as UUID for consistency
'db_shop': db_shop,
'decoded_token': decoded,
'user_id': None # No user_id for session token auth
}
except Exception as e:
logger.debug(f"Shopify session token validation failed, trying JWT: {str(e)}")
# Fall back to JWT authentication
try:
current_user = await get_current_user(request=request, db=db)
logger.info(f"Using JWT auth for user: {current_user.id}")
return {
'auth_type': 'jwt',
'organization_id': current_user.organization_id, # Keep as UUID for consistency
'user_id': current_user.id,
'current_user': current_user
}
except Exception as e:
logger.error(f"Both Shopify session token and JWT authentication failed: {str(e)}")
raise HTTPException(
status_code=401,
detail="Authentication required"
)
+42
View File
@@ -0,0 +1,42 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
# Shop-scoped Shopify URLs. Every Admin API and OAuth call goes to the merchant's
# own host (`{shop}.myshopify.com`), so there is no single base to swap. When
# SHOPIFY_ADMIN_BASE is set (only the e2e suite does) the shop domain becomes a
# path segment under it instead: `{base}/{shop_domain}/admin/...`.
from app.core.external_urls import external_base
SHOPIFY_API_VERSION = "2025-10"
SHOPIFY_ADMIN_BASE = external_base("SHOPIFY_ADMIN_BASE", "")
def shopify_shop_url(shop_domain: str, path: str) -> str:
"""URL of `path` (no leading slash) on the shop's own host."""
if SHOPIFY_ADMIN_BASE:
return f"{SHOPIFY_ADMIN_BASE}/{shop_domain}/{path}"
return f"https://{shop_domain}/{path}"
def shopify_admin_url(shop_domain: str, path: str) -> str:
"""Admin API URL, e.g. `shopify_admin_url(shop, "graphql.json")`."""
return shopify_shop_url(shop_domain, f"admin/api/{SHOPIFY_API_VERSION}/{path}")
def shopify_oauth_token_url(shop_domain: str) -> str:
"""The shop's OAuth access-token endpoint (code and token exchange)."""
return shopify_shop_url(shop_domain, "admin/oauth/access_token")
+170
View File
@@ -0,0 +1,170 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Slack App Home / Assistant lifecycle handlers.
These run as FastAPI background tasks (the webhook acks within 3s and hands off
here), so each opens its own DB session. They cover the non-message surfaces:
the Messages-tab welcome, the Home tab view, and the Assistant sidebar — all of
which the Slack Marketplace requires an app with the Messages tab enabled to use.
"""
from uuid import UUID
from app.channels.slack import (
SUGGESTED_PROMPTS_TITLE,
STARTER_PROMPTS,
WELCOME_NO_AGENT,
WELCOME_WITH_AGENT,
ASSISTANT_READY_STATUS,
SlackAdapter,
build_home_view,
publish_home_view,
set_assistant_status,
set_suggested_prompts,
slack_api,
)
from app.core.config import settings
from app.core.logger import get_logger
from app.core.redis import get_redis
from app.core.s3 import get_s3_signed_url
from app.database import SessionLocal
from app.repositories.agent import AgentRepository
from app.repositories.channels import ChannelAccountRepository
from app.repositories.channels.agent_config import AgentChannelConfigRepository
logger = get_logger(__name__)
# One welcome per user, forever — this flag is never meant to expire (a re-welcome
# on a Redis flush is harmless). SETNX; TTL only as a backstop against orphans.
_WELCOME_FLAG_TTL_SECONDS = 60 * 60 * 24 * 365
def _assigned_agent(db, account):
"""The single agent answering this Slack workspace, or None if unassigned."""
agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id)
if not agent_id:
return None
return AgentRepository(db).get_agent(agent_id)
def _welcome_text(agent) -> str:
if agent is None:
return WELCOME_NO_AGENT
return WELCOME_WITH_AGENT.format(agent=agent.display_name or agent.name)
def _already_welcomed(account_id, user_id: str) -> bool:
"""Claim the first-welcome slot for (account, user). True if already welcomed.
Without Redis we can't dedupe, so we welcome (once per process start is the
worst case) rather than stay silent — the review requires a welcome to be sent.
"""
redis_client = get_redis()
if redis_client is None:
return False
try:
key = f"slack_home_welcomed:{account_id}:{user_id}"
claimed = redis_client.set(key, "1", nx=True, ex=_WELCOME_FLAG_TTL_SECONDS)
return not claimed
except Exception as e:
logger.error(f"Slack welcome dedupe failed (welcoming anyway): {e}")
return False
async def deliver_home_welcome(account_id: UUID, user_id: str, channel: str) -> None:
"""Send the one-time welcome DM when a user first opens the Messages tab."""
if not user_id or not channel:
return
if _already_welcomed(account_id, user_id):
return
db = SessionLocal()
try:
account = ChannelAccountRepository(db).get_by_id(account_id)
if account is None or not account.is_active:
return
text = _welcome_text(_assigned_agent(db, account))
await slack_api("chat.postMessage", SlackAdapter._access_token(account),
{"channel": channel, "text": text})
except Exception as e:
logger.error(f"Slack Messages-tab welcome failed: {e}")
finally:
db.close()
async def handle_assistant_thread_started(account_id: UUID, channel_id: str, thread_ts: str) -> None:
"""On assistant-sidebar open: show a status, offer starter prompts, welcome."""
if not channel_id or not thread_ts:
return
db = SessionLocal()
try:
account = ChannelAccountRepository(db).get_by_id(account_id)
if account is None or not account.is_active:
return
token = SlackAdapter._access_token(account)
await set_assistant_status(token, channel_id, thread_ts, ASSISTANT_READY_STATUS)
await set_suggested_prompts(token, channel_id, thread_ts, STARTER_PROMPTS, SUGGESTED_PROMPTS_TITLE)
text = _welcome_text(_assigned_agent(db, account))
await slack_api("chat.postMessage", token,
{"channel": channel_id, "text": text, "thread_ts": thread_ts})
except Exception as e:
logger.error(f"Slack assistant welcome failed: {e}")
finally:
db.close()
async def _agent_card(agent) -> dict:
"""Flatten an Agent into the resolved shape build_home_view expects."""
photo_url = None
customization = getattr(agent, "customization", None)
if customization and customization.photo_url:
photo_url = customization.photo_url
if settings.S3_FILE_STORAGE:
try:
photo_url = await get_s3_signed_url(customization.photo_url)
except Exception as e:
logger.debug(f"Could not sign agent photo URL: {e}")
instructions = agent.instructions or []
return {
"name": agent.display_name or agent.name,
"is_active": agent.is_active,
"instruction": instructions[0] if instructions else "",
"photo_url": photo_url,
}
def _dashboard_url() -> str:
return settings.FRONTEND_URL.rstrip("/")
async def publish_agent_home(account_id: UUID, user_id: str) -> None:
"""Publish the Home tab: what ReplyPier does, the agent connected to this
workspace, how to use it, and a link to the dashboard."""
if not user_id:
return
db = SessionLocal()
try:
account = ChannelAccountRepository(db).get_by_id(account_id)
if account is None or not account.is_active:
return
agent = _assigned_agent(db, account)
card = await _agent_card(agent) if agent is not None else None
view = build_home_view(card, _dashboard_url())
await publish_home_view(SlackAdapter._access_token(account), user_id, view)
except Exception as e:
logger.error(f"Slack Home tab publish failed: {e}")
finally:
db.close()
+369
View File
@@ -0,0 +1,369 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
SQL guardrails for the AI ticket investigator's database connector.
The model's SQL is untrusted input. Every guarantee here is enforced OUTSIDE
the model, on the parsed AST (sqlglot):
- exactly one statement, and it must be a plain SELECT (no INTO, no locking)
- every referenced table — including inside CTEs, subqueries, joins and
unions — must be on the connector's explicit allowlist
- dangerous functions (sleep/file/network/admin) are denied
- a LIMIT is always enforced (added or clamped to the connector's max_rows)
- row-scoped tables are rewritten into a subquery pre-filtered to the ticket
customer's own rows, so the agent cannot read across customers
- the statement is re-rendered from the AST, so only canonical SQL executes
The executor adds the second, independent barrier: a read-only transaction
with a statement timeout. Column masking happens on the result rows.
"""
from dataclasses import dataclass
from typing import Iterable, List, Optional, Sequence
import sqlglot
from sqlglot import exp
MASK_VALUE = "***MASKED***"
# Time-wasting, file, network and admin functions. The read-only transaction
# already blocks writes — this denylist is defense-in-depth against DoS and
# data exfiltration via server-side functions.
BLOCKED_FUNCTIONS = {
# postgres
"pg_sleep", "pg_sleep_for", "pg_sleep_until",
"pg_read_file", "pg_read_binary_file", "pg_ls_dir", "pg_stat_file",
"pg_terminate_backend", "pg_cancel_backend", "pg_reload_conf",
"pg_rotate_logfile", "set_config", "pg_promote",
"dblink", "dblink_exec", "dblink_connect", "dblink_connect_u",
"lo_import", "lo_export",
"query_to_xml", "database_to_xml", "table_to_xml",
"pg_logical_slot_get_changes", "pg_logical_slot_peek_changes",
"pg_create_logical_replication_slot", "pg_drop_replication_slot",
# mysql
"sleep", "benchmark", "load_file", "get_lock", "release_lock",
"master_pos_wait", "sys_eval", "sys_exec",
}
# Statement/expression node types that must never appear anywhere in the tree.
BLOCKED_NODE_TYPES = (
exp.Insert, exp.Update, exp.Delete, exp.Merge,
exp.Create, exp.Drop, exp.Alter, exp.TruncateTable,
exp.Grant, exp.Command, exp.Transaction, exp.Set,
exp.Into, exp.Lock,
)
# Default schema names: a bare table reference resolves to these, so an
# allowlist entry "public.orders" also authorizes "orders".
DEFAULT_SCHEMAS = {"public", "dbo"}
_DIALECTS = {"postgresql": "postgres", "postgres": "postgres", "mysql": "mysql"}
@dataclass
class SqlValidationResult:
ok: bool
sql: Optional[str] = None # canonical SQL, only when ok
reason: Optional[str] = None # block reason, only when not ok
def _normalize_allowlist(allowed_tables: Iterable[str]) -> set:
"""Expand allowlist entries so both qualified and bare references match
when the schema is a default one."""
allowed = set()
for entry in allowed_tables or []:
entry = (entry or "").strip().lower()
if not entry:
continue
allowed.add(entry)
if "." in entry:
schema, _, name = entry.rpartition(".")
if schema in DEFAULT_SCHEMAS:
allowed.add(name)
return allowed
def _table_reference(table: exp.Table) -> str:
name = (table.name or "").lower()
if table.db:
return f"{table.db.lower()}.{name}"
return name
def _cte_names(statement: exp.Expression) -> set:
return {cte.alias_or_name.lower() for cte in statement.find_all(exp.CTE)}
def _blocked_function(statement: exp.Expression) -> Optional[str]:
for func in statement.find_all(exp.Func):
name = (func.sql_name() or "").lower()
if name in BLOCKED_FUNCTIONS:
return name
if isinstance(func, exp.Anonymous):
anon = (func.name or "").lower()
if anon in BLOCKED_FUNCTIONS:
return anon
return None
def normalize_scope_map(row_scope: Optional[dict]) -> dict:
"""Expand row-scope entries the same way as the allowlist, so a rule on
"public.orders" also binds a bare "orders" reference."""
scoped = {}
for raw_table, raw_column in (row_scope or {}).items():
table = (raw_table or "").strip().lower()
column = (raw_column or "").strip()
if not table or not column:
continue
scoped[table] = column
if "." in table:
schema, _, name = table.rpartition(".")
if schema in DEFAULT_SCHEMAS:
scoped.setdefault(name, column)
return scoped
def _apply_row_scope(
statement: exp.Select,
scoped: dict,
scope_value: Optional[str],
cte_names: set,
case_insensitive: bool = False,
) -> tuple:
"""Rewrite every scoped base table into a pre-filtered subquery:
FROM orders o -> FROM (SELECT * FROM orders WHERE email = 'x') AS o
Filtering the *relation* rather than checking the model's WHERE clause is
what makes this unbypassable. Whatever the model writes outside — OR 1=1,
a UNION arm, a correlated subquery — it can only ever see rows the wrapper
already returned. Validating a model-supplied predicate instead would mean
proving that no OR, no join and no outer reference widens it, which is not
a thing you can check reliably.
Returns (statement, error). Fails closed: a scoped table with no value to
scope by is refused, never silently unscoped.
"""
targets = []
for table in statement.find_all(exp.Table):
reference = _table_reference(table)
if not reference:
continue
if not table.db and reference in cte_names:
continue
column = scoped.get(reference)
if column is None and table.name:
column = scoped.get(table.name.lower())
if column:
targets.append((table, column))
if not targets:
return statement, None
if not (scope_value or "").strip():
return None, (
"This table is restricted to the rows of the ticket's own customer, "
"and this ticket has no customer identity to scope by. Set the "
"ticket's customer, or query a table that is not customer-scoped."
)
value = scope_value.strip()
for table, column in targets:
alias_name = table.alias or table.name
inner_table = table.copy()
inner_table.set("alias", None)
column_ref = exp.Column(this=exp.to_identifier(column))
# Emails are compared case-insensitively — the same person may be
# stored as Bob@x.com here and bob@x.com there, and an exact match
# would silently return nothing rather than their rows. Costs an index
# on the raw column unless one exists on LOWER(col).
predicate = exp.EQ(
this=exp.Lower(this=column_ref) if case_insensitive else column_ref,
# Literal.string escapes on render — never string-format a value
# into SQL.
expression=exp.Literal.string(value.lower() if case_insensitive else value),
)
inner = (
exp.Select().select(exp.Star()).from_(inner_table).where(predicate)
)
table.replace(
exp.Subquery(this=inner, alias=exp.TableAlias(this=exp.to_identifier(alias_name)))
)
return statement, None
def _force_limit(statement: exp.Select, max_rows: int) -> exp.Select:
"""Add a LIMIT, or clamp an existing numeric one, to max_rows."""
limit_node = statement.args.get("limit")
if limit_node is not None:
literal = limit_node.expression
if isinstance(literal, exp.Literal) and literal.is_int:
if int(literal.this) <= max_rows:
return statement
return statement.limit(max_rows, copy=False)
def validate_sql(
raw_sql: str,
allowed_tables: Sequence[str],
max_rows: int,
engine: str = "postgresql",
masked_columns: Optional[Iterable[str]] = None,
row_scope: Optional[dict] = None,
scope_value: Optional[str] = None,
scope_case_insensitive: bool = False,
) -> SqlValidationResult:
"""Validate untrusted SQL against the connector policy. Returns canonical
SQL to execute, or the reason it was blocked.
row_scope maps a table to the column identifying the customer who owns the
row ({"orders": "customer_email"}); scope_value is that customer's value
for the ticket being investigated. Listed tables are rewritten so only
their rows are visible."""
dialect = _DIALECTS.get((engine or "").lower(), "postgres")
if not (raw_sql or "").strip():
return SqlValidationResult(ok=False, reason="Empty query")
try:
statements = sqlglot.parse(raw_sql, read=dialect)
except sqlglot.errors.ParseError as e:
return SqlValidationResult(ok=False, reason=f"SQL could not be parsed: {e}")
statements = [s for s in statements if s is not None]
if len(statements) != 1:
return SqlValidationResult(
ok=False, reason="Exactly one SQL statement is allowed"
)
statement = statements[0]
if not isinstance(statement, exp.Select):
return SqlValidationResult(
ok=False,
reason="Only plain SELECT statements are allowed "
"(wrap set operations like UNION in a subquery)",
)
for node_type in BLOCKED_NODE_TYPES:
found = statement.find(node_type)
if found is not None:
return SqlValidationResult(
ok=False, reason=f"Disallowed SQL construct: {found.key.upper()}"
)
blocked_func = _blocked_function(statement)
if blocked_func is not None:
return SqlValidationResult(ok=False, reason=f"Disallowed function: {blocked_func}")
allowed = _normalize_allowlist(allowed_tables)
if not allowed:
return SqlValidationResult(ok=False, reason="No tables are allowlisted for this connector")
cte_names = _cte_names(statement)
for table in statement.find_all(exp.Table):
reference = _table_reference(table)
if not reference:
continue
# References to CTE names defined in this query are fine — the CTE
# bodies themselves are walked by this same loop.
if not table.db and reference in cte_names:
continue
if reference not in allowed:
return SqlValidationResult(
ok=False, reason=f"Table not on the allowlist: {reference}"
)
# Masked columns may not be referenced at all — output-name masking alone
# is alias-bypassable (SELECT email AS x), and blocking references also
# prevents value probing via WHERE clauses. SELECT * and t.* stay allowed:
# they expand to real column names that mask_rows redacts by name.
masked = {c.strip().lower() for c in masked_columns or [] if c and c.strip()}
if masked:
# Names that denote a whole row when used as a bare value.
scope_names = {t.name.lower() for t in statement.find_all(exp.Table) if t.name}
scope_names |= {
t.alias.lower() for t in statement.find_all(exp.Table) if t.alias
}
scope_names |= cte_names
for column in statement.find_all(exp.Column):
# A qualified/bare star (t.* or *) nested inside a function or cast
# collapses the row into one value (to_jsonb(c.*), (c.*)::text),
# defeating name-based masking — only a top-level projection star
# is safe. Same for a bare Star node.
if isinstance(column.this, exp.Star) and not isinstance(column.parent, exp.Select):
return SqlValidationResult(
ok=False,
reason="Row expansion (t.*) inside an expression is not allowed with masked columns",
)
if (column.name or "").lower() in masked and not isinstance(column.this, exp.Star):
return SqlValidationResult(
ok=False,
reason=f"Column '{column.name}' is masked and cannot be referenced",
)
# A bare (unqualified) reference to a table/alias/CTE name is a
# whole-row value — SELECT customers, to_jsonb(c), c::text, row(c).
# These serialize masked fields past name-based masking.
if not column.table and (column.name or "").lower() in scope_names:
return SqlValidationResult(
ok=False,
reason=(
f"Whole-row reference to '{column.name}' is not allowed with "
"masked columns — select individual columns instead"
),
)
for star in statement.find_all(exp.Star):
# Safe: top-level projection (SELECT *), the star of a t.* Column
# node (handled above), and count(*) — a cardinality marker that
# never exposes row values.
if not isinstance(star.parent, (exp.Select, exp.Column, exp.Count)):
return SqlValidationResult(
ok=False,
reason="Row expansion (*) inside an expression is not allowed with masked columns",
)
# Last, so the rewrite can't perturb the checks above: they inspect what
# the model actually wrote, not the wrappers added on its behalf.
scoped = normalize_scope_map(row_scope)
if scoped:
statement, scope_error = _apply_row_scope(
statement, scoped, scope_value, cte_names,
case_insensitive=scope_case_insensitive,
)
if scope_error is not None:
return SqlValidationResult(ok=False, reason=scope_error)
statement = _force_limit(statement, max_rows)
# comments=False: comments are dropped from the canonical SQL — MySQL
# executable comments (/*! ... */) must never reach the server.
return SqlValidationResult(
ok=True, sql=statement.sql(dialect=dialect, comments=False)
)
def mask_rows(
columns: List[str], rows: List[tuple], masked_columns: Iterable[str]
) -> List[tuple]:
"""Replace values of masked columns (matched by column name, case
insensitive) so PII never reaches the model."""
masked = {c.strip().lower() for c in masked_columns or [] if c and c.strip()}
if not masked:
return rows
mask_indexes = {i for i, col in enumerate(columns) if col.lower() in masked}
if not mask_indexes:
return rows
return [
tuple(MASK_VALUE if i in mask_indexes else value for i, value in enumerate(row))
for row in rows
]
+83
View File
@@ -0,0 +1,83 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Registering ReplyPier's webhook with a Telegram bot, with the updates it
must receive. Done on connect, and again for an agent's bots when its reply
buttons are turned on: a bot connected before buttons existed was registered
without button taps, and Telegram keeps a bot's last registration.
"""
import asyncio
import json
from typing import Optional, Tuple
from uuid import UUID
from sqlalchemy.orm import Session
from app.channels import telegram as telegram_api
from app.core.config import settings
from app.core.security import decrypt_api_key
from app.core.logger import get_logger
from app.models.channels import ChannelAccount, ChannelType
from app.repositories.channels import AgentChannelConfigRepository, ChannelAccountRepository
logger = get_logger(__name__)
def webhook_url(account_id) -> str:
return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/telegram/{account_id}"
async def register(account: ChannelAccount, bot_token: str) -> Tuple[bool, str]:
return await telegram_api.set_webhook(bot_token, webhook_url(account.id), account.webhook_secret)
async def reregister_for_agent(db: Session, agent_id: UUID) -> None:
"""Register again, at once, every active Telegram bot routed to this agent
— its buttons were just turned on."""
try:
accounts = ChannelAccountRepository(db)
bots = [account for account in (accounts.get_by_id(config.channel_account_id)
for config in AgentChannelConfigRepository(db).list_by_agent(agent_id))
if _is_active_bot(account)]
except Exception:
# The agent is already saved; only the re-registration is lost
logger.exception(f"Could not list agent {agent_id}'s Telegram bots to re-register")
return
await asyncio.gather(*(_reregister(account) for account in bots))
async def reregister_if_buttons_on(account: ChannelAccount, agent) -> None:
"""A bot just routed to an agent whose buttons are already on."""
if _is_active_bot(account) and agent.channel_quick_replies:
await _reregister(account)
async def _reregister(account: ChannelAccount) -> None:
"""Best effort: a bot that can't be registered keeps working — only its
button taps don't arrive until it is (turn the agent's buttons off and on
again, or reconnect the bot)."""
try:
bot_token = json.loads(decrypt_api_key(account.encrypted_credentials))["bot_token"]
ok, error = await register(account, bot_token)
except Exception as e:
ok, error = False, f"{type(e).__name__}: {e}"
if not ok:
logger.warning(f"Could not re-register Telegram bot {account.id} for button taps: {error}")
def _is_active_bot(account: Optional[ChannelAccount]) -> bool:
return (account is not None and account.is_active
and account.channel_type == ChannelType.TELEGRAM.value)
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
AI-ticketing plan gating: paid-plan feature on cloud, unrestricted for
OSS/self-hosted. Thin wrappers over the generic feature gate (the
help-center pattern).
"""
from uuid import UUID
from sqlalchemy.orm import Session
from app.services.feature_gate import check_feature_access, feature_allowed
AI_TICKETING_FEATURE = "ai_ticketing"
UPGRADE_MESSAGE = (
"AI Ticketing is not available in your current plan. "
"Please upgrade to access this feature."
)
def ticketing_allowed(db: Session, organization_id: UUID) -> bool:
"""Non-raising check — used by the chat-agent tool assembly and workers.
Fails closed on gating errors."""
return feature_allowed(db, organization_id, AI_TICKETING_FEATURE)
def check_ticketing_access(db: Session, organization_id: UUID) -> None:
"""Raising check for admin endpoints (403 with the upgrade message)."""
check_feature_access(db, organization_id, AI_TICKETING_FEATURE, UPGRADE_MESSAGE)
+67
View File
@@ -0,0 +1,67 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Capture side of the ticket CSAT loop.
TicketService.close() asks the customer for a rating through the widget's
existing conversation rating prompt. This module carries the answer back: every
path that creates a Rating calls record_conversation_rating(), which attaches
the score to the ticket linked to that session, if there is one.
Deliberately conversation-only. ratings.session_id is NOT NULL, so a rating
cannot exist without a chat session — a manual or email-only ticket has nothing
to rate. Supporting those would mean a public tokenised rating page and a
session-less Rating; until that exists, such tickets are never asked and their
CSAT card reads "not requested".
Best-effort throughout: a rating is a customer-facing success the moment it is
stored, so a failure to link it must never fail the submission.
"""
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.core.logger import get_logger
from app.models.rating import Rating
from app.services.ticket import TicketService
logger = get_logger(__name__)
def record_conversation_rating(db: Session, rating: Rating) -> Optional[UUID]:
"""Link a just-submitted conversation rating to its ticket.
Returns the ticket id it was attached to, or None when the session has no
native ticket (the common case for plain chats) or the linkage failed.
"""
try:
service = TicketService(db)
ticket = service.repo.get_by_session(rating.session_id)
if ticket is None:
return None
service.record_csat(ticket, rating.id, rating.rating, rating.feedback)
db.commit()
return ticket.id
except Exception as e:
# The rating row itself is already committed by the caller, so rolling
# back here only discards the half-applied ticket update.
logger.error(f"Failed to link rating {rating.id} to its ticket: {e}")
try:
db.rollback()
except Exception:
pass
return None
+181
View File
@@ -0,0 +1,181 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Direct email delivery for ticket notifications — the fallback when a ticket
has no linked chat conversation to deliver through (manual tickets created
from the dashboard). Reuses the email channel's SMTP plumbing: the org's
connected email inbox when one exists (correct SPF/DKIM from their own
domain), platform SMTP otherwise.
"""
import asyncio
import re
import secrets
from email.message import EmailMessage
from email.utils import make_msgid
from typing import List, Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.channels.email import open_smtp, platform_smtp_config, smtp_config
from app.core.config import settings
from app.core.logger import get_logger
from app.models.channels import ChannelAccount
from app.models.ticket import TICKET_NUMBER_PREFIX
from app.repositories.channels.accounts import ChannelAccountRepository
logger = get_logger(__name__)
EMAIL_CHANNEL_TYPE = "email"
# Message-ID local part for ticket mail: 'ticket-<uuid hex>.<unique>'. The
# ticket id lives in the address itself, so a reply that quotes any of our
# Message-IDs back in References identifies its ticket with no lookup table.
# Every sent message still gets its own unique id — reusing one id across
# messages makes Gmail collapse them into a single copy.
_TICKET_MSGID_RE = re.compile(r"ticket-([0-9a-f]{32})(?:\.[^@\s>]+)?@", re.I)
# The phantom id every ticket mail references. Never sent as a message itself:
# it is the shared thread root that ties separate notifications together.
_ROOT_SUFFIX = "root"
_SUBJECT_TOKEN_RE = re.compile(rf"\[{TICKET_NUMBER_PREFIX}-(\d+)\]", re.I)
DEFAULT_MSGID_DOMAIN = "replypier.site"
def _msgid_domain(from_email: Optional[str] = None) -> str:
"""Right-hand side of generated Message-IDs. Cosmetic only — the parser
matches on the local part, so mail sent from a customer's own inbox domain
still threads back to its ticket."""
candidate = from_email or settings.FROM_EMAIL or ""
_, _, domain = candidate.rpartition("@")
return domain.strip("> ").lower() or DEFAULT_MSGID_DOMAIN
def ticket_root_message_id(ticket_id, from_email: Optional[str] = None) -> str:
"""The stable per-ticket thread root, carried in References on every mail."""
return f"<ticket-{UUID(str(ticket_id)).hex}.{_ROOT_SUFFIX}@{_msgid_domain(from_email)}>"
def new_ticket_message_id(ticket_id, from_email: Optional[str] = None) -> str:
"""A fresh Message-ID for one outbound ticket mail."""
return (
f"<ticket-{UUID(str(ticket_id)).hex}.{secrets.token_hex(8)}"
f"@{_msgid_domain(from_email)}>"
)
def build_thread_headers(
ticket_id, in_reply_to: Optional[str] = None, from_email: Optional[str] = None
) -> dict:
"""Threading headers for one ticket notification.
References always starts at the ticket root so every notification for a
ticket lands in one client-side thread; In-Reply-To points at the
customer's most recent reply when we have seen one, so their client keeps
the conversation in order.
"""
root = ticket_root_message_id(ticket_id, from_email)
references: List[str] = [root]
if in_reply_to and in_reply_to != root:
references.append(in_reply_to)
return {
"Message-ID": new_ticket_message_id(ticket_id, from_email),
"In-Reply-To": in_reply_to or root,
"References": " ".join(references),
}
def ticket_ids_from_references(*values: Optional[str]) -> List[UUID]:
"""Ticket ids encoded in In-Reply-To/References/Message-ID headers, most
recently referenced first — References lists ancestors oldest-first, and
the nearest ancestor is the better match."""
found: List[UUID] = []
for value in values:
if not value:
continue
for hex_id in _TICKET_MSGID_RE.findall(str(value)):
try:
ticket_id = UUID(hex_id)
except ValueError:
continue
if ticket_id not in found:
found.append(ticket_id)
found.reverse()
return found
def ticket_number_from_subject(subject: Optional[str]) -> Optional[int]:
"""The [TKT-12] token a customer's client keeps in the reply subject —
the fallback when a mail client drops the threading headers."""
if not subject:
return None
match = _SUBJECT_TOKEN_RE.search(str(subject))
return int(match.group(1)) if match else None
def _org_email_account(db: Session, organization_id) -> Optional[ChannelAccount]:
accounts = ChannelAccountRepository(db).list_by_org(organization_id, EMAIL_CHANNEL_TYPE)
for account in accounts:
if account.is_active:
return account
return None
def _resolve_smtp(db: Session, organization_id) -> dict:
account = _org_email_account(db, organization_id)
if account is not None:
return smtp_config(account)
return platform_smtp_config(settings.FROM_EMAIL)
def _smtp_send(cfg: dict, message: EmailMessage) -> None:
with open_smtp(cfg) as smtp:
smtp.send_message(message)
async def send_ticket_email(
db: Session,
organization_id,
to_email: str,
subject: str,
body: str,
ticket_id=None,
in_reply_to: Optional[str] = None,
) -> bool:
"""Send a plain-text ticket notification email. Returns False (and logs)
on failure — ticket mutations must never fail because SMTP hiccuped.
With a ticket_id the mail carries ticket-scoped threading headers, so the
customer's reply comes back identifiable as a reply to that ticket."""
try:
cfg = _resolve_smtp(db, organization_id)
message = EmailMessage()
message["From"] = cfg["from_email"]
message["To"] = to_email
message["Subject"] = subject
if ticket_id is not None:
for header, value in build_thread_headers(
ticket_id, in_reply_to, cfg["from_email"]
).items():
message[header] = value
else:
message["Message-ID"] = make_msgid()
message.set_content(body)
await asyncio.to_thread(_smtp_send, cfg, message)
return True
except Exception as e:
logger.error(f"Ticket email to {to_email} failed: {e}")
return False
+114
View File
@@ -0,0 +1,114 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Inbound side of ticket email threading: decide whether a mail arriving on the
email channel is a reply to one of our ticket notifications, and append it to
that ticket instead of feeding it to the chat agent.
"""
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.channels.base import InboundMessage
from app.core.logger import get_logger
from app.database import SessionLocal
from app.models.ticket import Ticket
from app.repositories.ticket import TicketRepository
from app.services.ticket_email import (
ticket_ids_from_references,
ticket_number_from_subject,
)
logger = get_logger(__name__)
def find_ticket_for_reply(
db: Session, organization_id: UUID, profile: Optional[dict]
) -> Optional[Ticket]:
"""The ticket an inbound email is replying to, or None.
Headers first: In-Reply-To/References carry a Message-ID we minted, and
the ticket id is inside it — unguessable, so a match is proof the mail
descends from a notification we sent.
The [TKT-n] subject token is the fallback for clients that drop threading
headers. It is guessable, and a ticket's feed is read by agents and by the
investigator AI, so that path additionally requires the sender to be the
ticket's own customer; anything else falls through to the chat pipeline.
"""
profile = profile or {}
repo = TicketRepository(db)
for ticket_id in ticket_ids_from_references(
profile.get("in_reply_to"),
profile.get("references"),
):
ticket = repo.get_by_id(ticket_id, organization_id)
if ticket is not None:
return ticket
number = ticket_number_from_subject(profile.get("subject"))
if number is None:
return None
ticket = repo.get_by_number(organization_id, number)
if ticket is None:
return None
sender = (profile.get("email") or "").strip().lower()
customer_email = (getattr(ticket.customer, "email", "") or "").strip().lower()
if sender and sender == customer_email:
return ticket
logger.info(
f"Subject token named {ticket.display_number} but {sender or 'an unknown sender'} "
"is not its customer — handling as a normal message"
)
return None
async def record_ticket_email_reply(
organization_id: UUID, ticket_id: UUID, inbound: InboundMessage
) -> None:
"""Append an inbound email to its ticket. Runs from a BackgroundTask with
its own DB session, mirroring process_channel_message."""
db = SessionLocal()
try:
ticket = TicketRepository(db).get_by_id(ticket_id, organization_id)
if ticket is None:
logger.warning(f"Email reply for unknown ticket {ticket_id}")
return
from app.services.ticket import TicketService
service = TicketService(db)
profile = inbound.profile or {}
service.record_customer_email_reply(
ticket,
body=inbound.text or "",
from_email=inbound.external_user_id,
message_id=profile.get("inbound_message_id") or None,
)
db.commit()
db.refresh(ticket)
from app.services.ticket_events import emit_ticket_update
await emit_ticket_update(
organization_id, ticket.id, "comment", {"status": str(ticket.status)}
)
logger.info(f"Email reply appended to {ticket.display_number}")
except Exception as e:
db.rollback()
logger.error(f"Failed to record email reply on ticket {ticket_id}: {e}")
finally:
db.close()
+55
View File
@@ -0,0 +1,55 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Live ticket updates to the dashboard. Emitted into the per-org ticket room on
the /agent namespace after a mutation commits; the frontend keeps a poll
fallback, so a missed frame is never fatal.
"""
from typing import Optional
from uuid import UUID
from app.core.logger import get_logger
from app.core.socketio import sio
logger = get_logger(__name__)
ORG_TICKET_ROOM_PREFIX = "org_tickets_"
def org_ticket_room(organization_id) -> str:
return f"{ORG_TICKET_ROOM_PREFIX}{organization_id}"
async def emit_ticket_update(
organization_id: UUID,
ticket_id: UUID,
kind: str,
payload: Optional[dict] = None,
) -> None:
"""kind: 'created' | 'status' | 'triage' | 'comment' | 'run' | 'updated'."""
try:
await sio.emit(
"ticket_update",
{
"ticket_id": str(ticket_id),
"kind": kind,
"payload": payload or {},
},
room=org_ticket_room(organization_id),
namespace="/agent",
)
except Exception as e:
logger.error(f"ticket_update emit failed for {ticket_id}: {e}")
@@ -0,0 +1,349 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Phase 3 investigation engine: hypothesis-driven root-cause analysis with every
tool call captured as a first-class InvestigationEvent row (the glass box).
Orchestrated here, executed by the ticket_investigator worker.
"""
import asyncio
import json
import time
from typing import List, Optional, Tuple
from uuid import UUID
from app.core.logger import get_logger
from app.database import SessionLocal
from app.models.investigation import (
HypothesisStatus,
InvestigationEvent,
InvestigationEventType,
InvestigationHypothesis,
InvestigationRun,
InvestigationRunStatus,
RCADocument,
)
from app.models.ticket import Ticket
from app.utils.guardrails import GuardrailAction, PIIDetector
logger = get_logger(__name__)
# Per-hypothesis tool budget; the run-level max_tool_calls caps the total.
HYPOTHESIS_TOOL_CALL_LIMIT = 6
# Truncation applied at capture time — raw payloads never land in the DB.
MAX_EVENT_INPUT_CHARS = 2000
MAX_EVENT_RESULT_CHARS = 4000
# Bounded time for the final RCA synthesis (outside the main wall clock so a
# timed-out investigation can still produce a partial RCA).
RCA_SYNTHESIS_TIMEOUT_SECONDS = 180
VALID_VERDICT_STATUSES = {
HypothesisStatus.VALIDATED.value,
HypothesisStatus.INVALIDATED.value,
HypothesisStatus.INCONCLUSIVE.value,
}
def redact_snippet(text: str, max_chars: int) -> str:
"""PII-redact and truncate a tool payload before it is persisted."""
if not text:
return ""
result = PIIDetector.detect(text[: max_chars * 2], action=GuardrailAction.REDACT)
redacted = result.redacted_text if result.redacted_text is not None else text
redacted = redacted[:max_chars]
return redacted
class EvidenceRecorder:
"""Writes InvestigationEvent rows as the investigator works.
Each write uses its own short-lived session so evidence is visible live
(the glass-box UI polls during active runs) and survives a failed run.
Also the run's tool-call meter: the budget check reads self.tool_calls.
"""
def __init__(self, run_id: UUID, ticket_id: UUID):
self.run_id = run_id
self.ticket_id = ticket_id
self.seq = 0
self.tool_calls = 0
self.hypothesis_id: Optional[UUID] = None
# Per-hypothesis tool-call outcomes: {hypothesis_id: [total, errored]}.
# A verdict is only worth as much as the evidence under it, and a run
# where every query failed must not be able to assert a fact (#318).
self.calls_by_hypothesis: dict = {}
# Run-level totals, for the dashboard's connector banner.
self.failed_tool_calls = 0
# tool function name -> configured connector (MCP server) name.
self.connector_by_function: dict = {}
# In-memory digest for RCA synthesis, mirroring the persisted rows.
self.evidence_lines: List[str] = []
def map_connectors(self, mcp_tools: list) -> None:
for tool in mcp_tools or []:
connector = getattr(tool, "_connector_name", None) or "MCP"
for function_name in getattr(tool, "functions", {}) or {}:
self.connector_by_function[function_name] = connector
def _write(self, **fields) -> None:
self.seq += 1
try:
with SessionLocal() as db:
db.add(
InvestigationEvent(
run_id=self.run_id,
ticket_id=self.ticket_id,
hypothesis_id=self.hypothesis_id,
seq=self.seq,
**fields,
)
)
db.commit()
except Exception as e:
logger.error(f"Failed to record investigation event: {e}")
def record_phase(self, label: str) -> None:
"""Phase marker — powers the live ticker line in the UI."""
self._write(event_type=InvestigationEventType.PHASE, label=label[:300])
async def tool_hook(self, function_name: str, function, arguments: dict):
"""agno tool hook: times the call and captures redacted input/result.
Registered on the hypothesis-testing agents only."""
# The Groq structured-output capture tool is plumbing, not evidence.
if function_name == "json":
return await function(**arguments)
started = time.monotonic()
error: Optional[str] = None
result = None
try:
result = await function(**arguments)
return result
except Exception as e:
error = str(e)
raise
finally:
self.tool_calls += 1
if error:
self.failed_tool_calls += 1
if self.hypothesis_id is not None:
tally = self.calls_by_hypothesis.setdefault(self.hypothesis_id, [0, 0])
tally[0] += 1
if error:
tally[1] += 1
duration_ms = int((time.monotonic() - started) * 1000)
try:
input_text = json.dumps(arguments, default=str)
except Exception:
input_text = str(arguments)
input_snippet = redact_snippet(input_text, MAX_EVENT_INPUT_CHARS)
result_snippet = redact_snippet(str(result), MAX_EVENT_RESULT_CHARS) if result is not None else None
self._write(
event_type=InvestigationEventType.TOOL_CALL,
tool_name=function_name[:200],
connector_name=self.connector_by_function.get(function_name, "MCP")[:200],
tool_input=input_snippet,
tool_result=result_snippet,
duration_ms=duration_ms,
error=error[:2000] if error else None,
)
self.evidence_lines.append(
f"[E{self.seq}] {function_name}({input_snippet[:300]}) -> "
+ (f"ERROR: {error[:200]}" if error else (result_snippet or "")[:500])
)
def tool_outcomes(self, hypothesis_id: UUID) -> tuple:
"""(total, errored) tool calls made while testing this hypothesis."""
total, errored = self.calls_by_hypothesis.get(hypothesis_id, [0, 0])
return total, errored
def enforce_evidence_backed_verdict(
hypothesis: InvestigationHypothesis, recorder: "EvidenceRecorder"
) -> bool:
"""Downgrade a definite verdict that no successful query supports.
The model returns status and confidence as free text, and nothing else
checks them against what actually happened. A run whose every search
errored — a missing index, a rejected client version — can still come back
"the record does not exist, validated, 0.90", which a support agent would
reasonably act on. Tool errors are the absence of evidence, never evidence
of absence.
Only fires when the phase made tool calls and every one of them failed;
a hypothesis reasoned from ticket context alone is left as the model
returned it. Returns True when it changed the verdict.
"""
total, errored = recorder.tool_outcomes(hypothesis.id)
if not total or errored < total:
return False
if str(hypothesis.status) == HypothesisStatus.INCONCLUSIVE.value:
return False
original = str(hypothesis.status)
confidence = hypothesis.confidence
hypothesis.status = HypothesisStatus.INCONCLUSIVE
hypothesis.confidence = 0.0
# The model's own conclusion is dropped rather than kept: it reads as a
# finding, and the RCA writer would quote it as one.
hypothesis.conclusion = (
f"Not tested — all {total} tool call{'' if total == 1 else 's'} in this phase "
f"failed, so no evidence was gathered. The tester reported "
f"\"{original}\"{f' at {confidence:.2f} confidence' if confidence is not None else ''}, "
"which nothing supports. Check the connector errors in the evidence log."
)
return True
def hypotheses_digest(hypotheses: List[InvestigationHypothesis]) -> str:
lines = []
for h in hypotheses:
lines.append(
f"H{h.idx}: {h.title} — {h.status} (confidence {h.confidence if h.confidence is not None else 'n/a'})\n"
f" {h.conclusion or h.rationale or ''}"
)
return "\n".join(lines)
async def run_investigation_phases(
db,
run: InvestigationRun,
ticket: Ticket,
agent,
context_message: str,
mcp_tools: list,
recorder: EvidenceRecorder,
) -> Tuple[List[InvestigationHypothesis], bool]:
"""Generate hypotheses, then test each within the run's tool budget.
Mutates hypothesis rows (committed per step so the UI streams progress).
Returns (hypotheses, budget_exhausted)."""
recorder.record_phase("Generating hypotheses")
plan = await agent.generate_hypotheses(context_message)
if plan is None or not plan.hypotheses:
raise RuntimeError("Hypothesis generation produced no result")
hypotheses: List[InvestigationHypothesis] = []
for i, spec in enumerate(plan.hypotheses, start=1):
hypothesis = InvestigationHypothesis(
run_id=run.id,
ticket_id=ticket.id,
idx=i,
title=spec.title[:300],
rationale=spec.rationale,
status=HypothesisStatus.PENDING,
)
db.add(hypothesis)
hypotheses.append(hypothesis)
db.commit()
budget_exhausted = False
max_tool_calls = run.max_tool_calls if run.max_tool_calls is not None else 25
for hypothesis in hypotheses:
remaining_budget = max(0, max_tool_calls - recorder.tool_calls)
if remaining_budget <= 0 and mcp_tools:
budget_exhausted = True
hypothesis.status = HypothesisStatus.INCONCLUSIVE
hypothesis.confidence = 0.0
hypothesis.conclusion = "Not tested — the run's tool-call budget was exhausted."
db.commit()
continue
recorder.hypothesis_id = hypothesis.id
recorder.record_phase(f"Testing H{hypothesis.idx}: {hypothesis.title}")
hypothesis.status = HypothesisStatus.TESTING
db.commit()
verdict = await agent.test_hypothesis(
context_message,
hypothesis.title,
hypothesis.rationale,
mcp_tools=mcp_tools,
tool_call_limit=min(HYPOTHESIS_TOOL_CALL_LIMIT, remaining_budget) if mcp_tools else None,
tool_hooks=[recorder.tool_hook],
)
if verdict is not None and verdict.status in VALID_VERDICT_STATUSES:
hypothesis.status = verdict.status
hypothesis.confidence = max(0.0, min(1.0, verdict.confidence))
hypothesis.conclusion = verdict.conclusion
if enforce_evidence_backed_verdict(hypothesis, recorder):
logger.warning(
f"H{hypothesis.idx} claimed '{verdict.status}' with every tool "
f"call failing; forced to inconclusive"
)
else:
hypothesis.status = HypothesisStatus.INCONCLUSIVE
hypothesis.confidence = 0.0
# A provider that refused the request is a hard, deterministic
# failure — saying "no parseable verdict" for it reads as a model
# quality problem and hides a fixable connector issue (#303).
hypothesis.conclusion = (
getattr(agent, "last_provider_error", None)
or "The tester produced no parseable verdict."
)
db.commit()
recorder.hypothesis_id = None
return hypotheses, budget_exhausted
async def synthesize_and_store_rca(
db,
run: InvestigationRun,
ticket: Ticket,
agent,
context_message: str,
hypotheses: List[InvestigationHypothesis],
recorder: EvidenceRecorder,
partial: bool,
) -> Optional[RCADocument]:
"""Write the versioned RCA document from whatever evidence exists."""
from app.repositories.ticket import InvestigationRepository
recorder.record_phase("Writing root-cause analysis")
try:
result = await asyncio.wait_for(
agent.synthesize_rca(
context_message,
hypotheses_digest(hypotheses),
"\n".join(recorder.evidence_lines[-60:]),
partial=partial,
),
timeout=RCA_SYNTHESIS_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.error(f"RCA synthesis timed out for {ticket.display_number}")
return None
if result is None:
return None
rca = RCADocument(
ticket_id=ticket.id,
run_id=run.id,
version=InvestigationRepository(db).next_rca_version(ticket.id),
summary=result.summary,
impact=result.impact,
timeline=[entry.model_dump() for entry in result.timeline],
investigation_log=result.investigation_log,
contributing_factors=result.contributing_factors,
conclusion=result.conclusion,
remediation=result.remediation,
prevention=result.prevention,
customer_summary=result.customer_summary,
confidence=max(0.0, min(1.0, result.confidence)),
is_partial=partial,
generated_by="ai",
)
db.add(rca)
db.commit()
return rca
+134
View File
@@ -0,0 +1,134 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
One-way Jira escalation for native tickets: when enabled, tickets at or
above the configured priority get a mirrored Jira issue (external_ref_*).
Native ReplyPier stays the source of truth — nothing syncs back.
"""
from typing import Optional
from app.core.logger import get_logger
from app.models.jira import AgentJiraConfig, JiraToken
from app.models.organization import Organization
from app.models.ticket import Ticket
from app.models.ticket_activity import TicketActivityType, TicketActorType
logger = get_logger(__name__)
_PRIORITY_ORDER = {"urgent": 0, "high": 1, "medium": 2, "low": 3}
def _find_project_config(db, ticket: Ticket) -> Optional[AgentJiraConfig]:
"""Project/issue-type to escalate into: the ticket's agent's Jira config
when enabled, else any enabled agent config in the organization."""
from app.models.agent import Agent
if ticket.agent_id is not None:
config = (
db.query(AgentJiraConfig)
.filter(
AgentJiraConfig.agent_id == str(ticket.agent_id),
AgentJiraConfig.enabled == True, # noqa: E712
AgentJiraConfig.project_key.isnot(None),
)
.first()
)
if config is not None:
return config
return (
db.query(AgentJiraConfig)
.join(Agent, Agent.id == AgentJiraConfig.agent_id.cast(Agent.id.type))
.filter(
Agent.organization_id == ticket.organization_id,
AgentJiraConfig.enabled == True, # noqa: E712
AgentJiraConfig.project_key.isnot(None),
)
.first()
)
def should_escalate(ticket: Ticket, settings_row) -> bool:
if not settings_row.jira_escalation_enabled:
return False
if ticket.external_ref_id: # already escalated — one-way, never re-synced
return False
minimum = str(settings_row.jira_escalation_priority or "urgent")
return _PRIORITY_ORDER.get(str(ticket.priority), 99) <= _PRIORITY_ORDER.get(minimum, 0)
async def maybe_escalate_to_jira(db, service, ticket: Ticket, settings_row) -> bool:
"""Escalate when policy matches and the org has Jira connected. Failures
are logged, never raised — escalation must not break ticket flows."""
if not should_escalate(ticket, settings_row):
return False
try:
token = (
db.query(JiraToken)
.filter(JiraToken.organization_id == ticket.organization_id)
.first()
)
if token is None:
return False
config = _find_project_config(db, ticket)
if config is None:
logger.info(
f"{ticket.display_number}: Jira escalation enabled but no agent "
"has a Jira project configured"
)
return False
from app.api.jira import CreateJiraIssueModel
from app.services.jira import JiraService
description_parts = [
f"Escalated from ReplyPier ticket {ticket.display_number} "
f"(priority: {ticket.priority}, severity: {ticket.severity or 'n/a'}).",
]
if ticket.ai_summary:
description_parts.append(f"AI summary: {ticket.ai_summary}")
if ticket.description:
description_parts.append(ticket.description[:4000])
organization = db.query(Organization).filter(
Organization.id == ticket.organization_id
).first()
result = await JiraService().create_issue(
organization,
db,
CreateJiraIssueModel(
projectKey=config.project_key,
issueTypeId=config.issue_type_id,
summary=f"[{ticket.display_number}] {ticket.title}"[:250],
description="\n\n".join(description_parts),
),
)
issue_key = result.get("key")
if not issue_key:
return False
ticket.external_ref_type = "jira"
ticket.external_ref_id = issue_key
ticket.external_ref_url = f"{token.site_url}/browse/{issue_key}"
service._add_activity(
ticket,
TicketActivityType.JIRA_ESCALATED,
actor_type=TicketActorType.SYSTEM,
body=f"Escalated to Jira as {issue_key}",
metadata={"issue_key": issue_key, "url": ticket.external_ref_url},
)
return True
except Exception as e:
logger.error(f"Jira escalation failed for {ticket.display_number}: {e}")
return False
+117
View File
@@ -0,0 +1,117 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Cross-customer privacy guards for AI ticketing.
The investigator reasons over material that belongs to people other than the
person it is writing to: similar past tickets from the same org, and rows from
a connected database that has no row-level scoping. The same LLM call that
reads all of that also writes `customer_summary`, which is delivered to the
customer — at autonomy L3 with no human in the loop. Prompt wording alone can't
be the control on that, so these are deterministic, applied at the boundary.
"""
import re
from typing import Iterable, Optional, Set
from app.core.logger import get_logger
from app.utils.guardrails import GuardrailAction, PIIDetector
logger = get_logger(__name__)
# Identifiers that must never appear in a message we send out, no matter whose
# they are — a support summary has no legitimate reason to restate them.
ALWAYS_REDACT = ("ssn", "credit_card", "date_of_birth")
# Identifiers that are fine to echo back to their *owner* but not to anyone
# else. Kept when they match the recipient, redacted otherwise.
OWNER_ONLY = ("email", "phone")
THIRD_PARTY_MARKER = "[redacted]"
def _digits(value: str) -> str:
return re.sub(r"\D", "", value or "")
def _owner_keys(identifiers: Iterable[Optional[str]]) -> Set[str]:
"""Normalized forms of the recipient's own identifiers, so a match is not
defeated by case or phone formatting."""
keys: Set[str] = set()
for raw in identifiers:
value = (raw or "").strip()
if not value:
continue
keys.add(value.lower())
digits = _digits(value)
# 7 digits filters out short numerics (order counts, amounts) that
# would otherwise whitelist themselves as if they were the phone.
if len(digits) >= 7:
keys.add(digits)
return keys
def scrub_outbound(text: Optional[str], owner_identifiers: Iterable[Optional[str]] = ()) -> Optional[str]:
"""Strip third-party identifiers from a message bound for the customer.
The recipient's own email/phone survive — telling someone their own address
is not a leak, and blanking it makes legitimate summaries read as broken.
Everything else that looks like an identifier is redacted, because in a
ticket summary it can only have come from another customer's record.
"""
if not text:
return text
owners = _owner_keys(owner_identifiers)
scrubbed = text
for pii_type in ALWAYS_REDACT:
pattern = PIIDetector.PATTERNS.get(pii_type)
if pattern:
scrubbed = re.sub(pattern, THIRD_PARTY_MARKER, scrubbed)
for pii_type in OWNER_ONLY:
pattern = PIIDetector.PATTERNS.get(pii_type)
if not pattern:
continue
def _replace(match: re.Match) -> str:
value = match.group()
if value.lower() in owners or (_digits(value) and _digits(value) in owners):
return value
return THIRD_PARTY_MARKER
scrubbed = re.sub(pattern, _replace, scrubbed)
if scrubbed != text:
logger.warning(
"Redacted third-party identifiers from an outbound ticket message"
)
return scrubbed
def redact_reference_text(text: Optional[str]) -> Optional[str]:
"""Strip identifiers from another customer's material before it becomes
model context.
Similar past tickets are included to convey *what broke and how it was
fixed*; the names, addresses and numbers in their titles carry none of that
signal and are the most likely thing to be echoed into a summary written
for a different person.
"""
if not text:
return text
result = PIIDetector.detect(text, action=GuardrailAction.REDACT)
return result.redacted_text or text
+319
View File
@@ -0,0 +1,319 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import re
import uuid
from dataclasses import dataclass
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.channels import get_adapter
from app.channels.app_hooks import OutboundRecord, emit_channel_event, outbound_event
from app.channels.constants import DEFAULT_TEMPLATE_LANGUAGE, MessageSource
from app.services.channel_chat import placeholder_name
from app.services.business_message import remember_business_message
from app.services.channel_status import record_sent
from app.services.chat_notifications import team_not_yet_notified
from app.channels.meta_base import fetch_message_templates, graph_auth_refused, graph_detail
from app.core.logger import get_logger
from app.models.channels import ChannelAccount
from app.repositories.channels import (
AgentChannelConfigRepository,
ChannelAccountRepository,
ChannelConversationRepository,
)
from app.repositories.chat import ChatRepository
from app.repositories.customer import CustomerRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.utils.phone import normalize_phone, to_wa_id
logger = get_logger(__name__)
# Business-initiated conversations are Utility/Authentication only. Marketing
# is deliberately unsupported outbound: it carries opt-in obligations and
# per-user caps (Graph error 131049) that a support product should not walk
# its customers into. The reopen flow (an existing conversation) is unaffected.
OUTBOUND_CATEGORIES = {"UTILITY", "AUTHENTICATION"}
_PLACEHOLDER = re.compile(r"\{\{(\d+)\}\}")
@dataclass
class OutboundSent:
"""A started (or rejoined) conversation and the template that opened it."""
session_id: UUID
message_id: int
external_message_id: Optional[str]
class OutboundError(Exception):
"""A refused or failed outbound send. Carries the HTTP status the API
route should answer with, so the Phase-2 scheduler can reuse the service
without importing anything web-shaped."""
def __init__(self, status_code: int, detail: str):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
def _render_body(template: dict, components: Optional[list]) -> str:
"""The message text as the customer will read it.
Graph matches body parameters to {{n}} placeholders positionally in
ascending order (the same rule the frontend's previewTemplate applies —
keep the two honest with each other). Falls back to the raw body when a
parameter is missing rather than failing the send: Meta is the authority
on completeness and will reject a genuinely short send itself.
"""
body = next((c.get("text", "") for c in template.get("components") or []
if str(c.get("type", "")).upper() == "BODY"), "")
values = [
str(parameter.get("text", ""))
for component in components or []
if str(component.get("type", "")).lower() == "body"
for parameter in component.get("parameters") or []
]
return _PLACEHOLDER.sub(
lambda match: values[int(match.group(1)) - 1]
if 0 < int(match.group(1)) <= len(values) else match.group(0),
body,
)
async def _approved_outbound_template(db: Session, account: ChannelAccount,
name: str, language: str,
*, starts_conversation: bool) -> dict:
"""The template this send is allowed to use, or the reason it isn't.
`starts_conversation` decides whether the Utility/Authentication rule
applies. It is a rule about business-INITIATED contact, so it belongs only
to a send that opens a thread with someone who never wrote to us. Reaching
an existing conversation is the reopen case: the customer did message us,
which is why Meta accepts Marketing there — and why the inbox picker has
never been category-gated.
"""
repo = ChannelAccountRepository(db)
credentials = repo.get_credentials(account)
waba_id = credentials.get("waba_id")
if not waba_id:
raise OutboundError(400, "Reconnect this number with its WhatsApp "
"Business Account ID to send templates")
ok, data = await fetch_message_templates(waba_id, credentials["access_token"])
if not ok:
if graph_auth_refused(data):
repo.record_auth_failure(account, graph_detail(data, "Authentication Error"))
raise OutboundError(502, "Could not read the account's templates from Meta")
template = next(
(t for t in data if t.get("name") == name and t.get("language") == language),
None,
)
if template is None:
raise OutboundError(404, f"No template named {name} in {language} on this account")
if str(template.get("status", "")).upper() != "APPROVED":
raise OutboundError(400, f"Template {name} is not approved yet")
if starts_conversation and \
str(template.get("category", "")).upper() not in OUTBOUND_CATEGORIES:
raise OutboundError(
400,
"Only Utility and Authentication templates can start a conversation. "
"Marketing templates need the customer to have messaged you first.",
)
return template
def _resolve_customer(db: Session, account: ChannelAccount, phone: str,
customer_id: Optional[UUID], customer_name: Optional[str]):
"""The person this conversation belongs to.
A picked person must actually OWN the number being messaged — the two
arrive as independent fields, and binding a conversation to one person at
another person's number is unrecoverable: the reply resolves by phone to
somebody else, so history splits across two customers and the thread never
appears on the profile of the person actually in it.
set_phone_if_absent's skip-and-log is right for the automatic inbound
paths, where there is nobody to tell. Here there is a human waiting on a
response, so a conflict is refused instead of silently ignored.
"""
repo = CustomerRepository(db)
if customer_id is not None:
customer = repo.get_by_id(customer_id)
if customer is None or customer.organization_id != account.organization_id:
raise OutboundError(404, "Customer not found")
if customer.phone and customer.phone != phone:
raise OutboundError(
400,
f"{customer.full_name or 'That person'} has a different number on "
f"file. Send to their number, or update it on their profile first.",
)
owner = repo.get_customer_by_phone(phone, account.organization_id)
if owner is not None and owner.id != customer.id:
raise OutboundError(
400,
f"{phone} already belongs to {owner.full_name or 'another person'}. "
f"Pick them instead, or clear the number from their profile.",
)
repo.set_phone_if_absent(customer, phone)
return customer
wa_id = to_wa_id(phone)
customer = repo.get_or_create_customer(
email=f"{wa_id}@whatsapp.channel",
organization_id=account.organization_id,
full_name=customer_name or placeholder_name(account.channel_type, wa_id),
phone=phone,
)
# First-touch source for People's Source column; setdefault semantics so a
# person who arrived some other way keeps their original story.
if customer.lead_source is None:
customer.lead_source = {"channel": "whatsapp", "via": "outbound"}
return customer
async def start_outbound_conversation(
db: Session,
account: ChannelAccount,
*,
to: str,
template_name: str,
language: str = DEFAULT_TEMPLATE_LANGUAGE,
components: Optional[list] = None,
customer_id: Optional[UUID] = None,
customer_name: Optional[str] = None,
source: MessageSource,
) -> OutboundSent:
"""Start (or rejoin) a WhatsApp conversation by phone number.
Returns the session — everything downstream (the inbox thread, template
resends, the AI answering the reply) already keys off one, which is why
this hands one back instead of growing a parallel outbound code path —
and the stored template message. `source` is who sent it (an inbox user,
or an external app). The team is told about the thread only once the
customer's reply needs them (notify_new_channel_chat_once), so a bulk
send doesn't page anyone.
The conversation is created with last_inbound_at=None: no customer message
means no 24-hour window, and pretending otherwise would let the AI send
free-form text that Meta rejects.
"""
phone = normalize_phone(to)
if phone is None:
raise OutboundError(400, "Enter the number in international format, e.g. +91 12345 67890")
wa_id = to_wa_id(phone)
agent_id = AgentChannelConfigRepository(db).get_active_agent_id(account.id)
if agent_id is None:
raise OutboundError(400, "Route an agent to this number first — "
"otherwise nobody would answer the reply")
# Resolved BEFORE the template is validated: whether this opens a thread or
# rejoins one decides which rules the template must satisfy.
conv_repo = ChannelConversationRepository(db)
existing = conv_repo.get_active(account.id, wa_id)
template = await _approved_outbound_template(
db, account, template_name, language, starts_conversation=existing is None)
if existing is not None:
# An open conversation already carries this number; send there instead
# of splitting the thread. The window may even be open, but a template
# send inside the window is legal and simpler than branching.
conversation, session_id = existing, existing.session_id
customer = None
else:
customer = _resolve_customer(db, account, phone, customer_id, customer_name)
session_id = uuid.uuid4()
SessionToAgentRepository(db).create_session(
session_id=session_id,
agent_id=str(agent_id),
customer_id=str(customer.id),
organization_id=str(account.organization_id),
channel=account.channel_type,
)
conversation = conv_repo.create(
channel_account_id=account.id,
channel_type=account.channel_type,
external_conversation_id=wa_id,
external_user_id=wa_id,
session_id=session_id,
organization_id=account.organization_id,
agent_id=agent_id,
customer_id=customer.id,
last_inbound_at=None,
extra=team_not_yet_notified(),
)
adapter = get_adapter("whatsapp")
result = await adapter.send_template(account, conversation, template_name,
language, components)
ChannelAccountRepository(db).track_send_result(account, result)
if not result.ok:
if existing is None:
# A failed send must not leave an empty thread in the inbox: drop
# the conversation, then the session it points at. The customer
# row stays — harmless, and reused on the next attempt.
#
# Guarded, because an exception escaping here would replace the 502
# (which names Meta's actual reason) with an opaque 500 AND leave
# the empty thread this block exists to remove. Failing to clean up
# is worth a log; losing the reason the send failed is not.
from app.models.session_to_agent import SessionToAgent
try:
db.delete(conversation)
db.query(SessionToAgent).filter(
SessionToAgent.session_id == session_id).delete()
db.commit()
except Exception as e:
db.rollback()
logger.error(f"Could not roll back the empty outbound thread for "
f"session {session_id}: {e}")
raise OutboundError(502, result.error or "WhatsApp did not accept the message")
rendered = _render_body(template, components)
stored = ChatRepository(db).create_message({
"message": rendered,
"message_type": "bot",
"session_id": str(session_id),
"organization_id": str(account.organization_id),
"agent_id": str(agent_id),
# A reused conversation row predating this feature can lack a customer.
"customer_id": str(conversation.customer_id) if conversation.customer_id else None,
"attributes": {
"channel": account.channel_type,
"external_message_id": result.external_message_id,
"outbound_template": template_name,
"source": source.value,
},
})
if result.external_message_id:
await record_sent(db, stored.id, account.id, result.external_message_id)
# The AI reads this when the customer replies: a bare "yes" is meaningless
# without knowing what we asked.
remember_business_message(db, conversation, rendered, result.external_message_id)
await emit_channel_event(outbound_event(
organization_id=account.organization_id, channel_account_id=account.id,
session_id=str(session_id),
customer_id=str(conversation.customer_id) if conversation.customer_id else None,
record=OutboundRecord(message_id=stored.id, external_message_id=result.external_message_id,
source=source, text=rendered),
))
return OutboundSent(session_id=session_id, message_id=stored.id,
external_message_id=result.external_message_id)
+185
View File
@@ -0,0 +1,185 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from app.repositories.workflow import WorkflowRepository
from app.repositories.agent import AgentRepository
from app.models.workflow import Workflow
from app.models.schemas.workflow import WorkflowCreate
from app.core.logger import get_logger
logger = get_logger(__name__)
class WorkflowService:
def __init__(self, db: Session):
self.db = db
self.workflow_repo = WorkflowRepository(db)
self.agent_repo = AgentRepository(db)
def create_workflow(self, workflow_data: WorkflowCreate, created_by: UUID, organization_id: UUID) -> Workflow:
"""Create a new workflow with validation"""
# Validate agent exists and belongs to organization
agent = self.agent_repo.get_by_id(workflow_data.agent_id)
if not agent:
raise ValueError("Agent not found")
if agent.organization_id != organization_id:
raise ValueError("Agent does not belong to your organization")
# Check if workflow already exists for this agent
existing_workflow = self.workflow_repo.get_workflows_by_agent(workflow_data.agent_id)
if existing_workflow:
raise ValueError("Agent already has a workflow. Only one workflow per agent is allowed.")
# Check if workflow name already exists in organization
existing_workflow_name = self.workflow_repo.get_by_name(
workflow_data.name,
organization_id
)
if existing_workflow_name:
raise ValueError(f"Workflow with name '{workflow_data.name}' already exists")
# Create workflow data
workflow_dict = workflow_data.model_dump()
workflow_dict.update({
"created_by": created_by,
"organization_id": organization_id
})
# Create workflow
workflow = self.workflow_repo.create_workflow(**workflow_dict)
logger.info(f"Created workflow {workflow.id} for agent {workflow_data.agent_id}")
return workflow
def get_workflow_by_agent_id(self, agent_id: UUID, organization_id: UUID) -> Optional[Workflow]:
"""Get workflow by agent ID with organization validation"""
# Validate agent exists and belongs to organization
agent = self.agent_repo.get_by_id(agent_id)
if not agent:
raise ValueError("Agent not found")
if agent.organization_id != organization_id:
raise ValueError("Agent does not belong to your organization")
# Get workflows for this agent
workflows = self.workflow_repo.get_workflows_by_agent(agent_id)
# Return the first workflow (should be only one due to our constraint)
return workflows[0] if workflows else None
def update_workflow(self, workflow_id: UUID, workflow_data: dict, organization_id: UUID) -> Optional[Workflow]:
"""Update workflow with validation"""
# Get existing workflow
workflow = self.workflow_repo.get_by_id(workflow_id)
if not workflow:
raise ValueError("Workflow not found")
# Verify organization access
if workflow.organization_id != organization_id:
raise ValueError("Workflow does not belong to your organization")
# If name is being updated, check for duplicates
if 'name' in workflow_data and workflow_data['name'] != workflow.name:
existing_workflow = self.workflow_repo.get_by_name(
workflow_data['name'],
organization_id
)
if existing_workflow and existing_workflow.id != workflow_id:
raise ValueError(f"Workflow with name '{workflow_data['name']}' already exists")
# If agent_id is being updated, validate the new agent and check constraint
if 'agent_id' in workflow_data and workflow_data['agent_id'] != workflow.agent_id:
# Validate new agent exists and belongs to organization
agent = self.agent_repo.get_by_id(workflow_data['agent_id'])
if not agent:
raise ValueError("Agent not found")
if agent.organization_id != organization_id:
raise ValueError("Agent does not belong to your organization")
# Check if new agent already has a workflow
existing_workflows = self.workflow_repo.get_workflows_by_agent(workflow_data['agent_id'])
if existing_workflows:
raise ValueError("Agent already has a workflow. Only one workflow per agent is allowed.")
# Check if status is being updated to published
if 'status' in workflow_data and workflow_data['status'] == 'published':
# When publishing, update the agent's active_workflow_id and use_workflow
agent = self.agent_repo.get_by_id(workflow.agent_id)
if agent:
self.agent_repo.update_agent(
workflow.agent_id,
active_workflow_id=workflow_id,
use_workflow=True
)
logger.info(f"Updated agent {workflow.agent_id} active_workflow_id to {workflow_id}")
# Check if status is being updated to draft (unpublished)
if 'status' in workflow_data and workflow_data['status'] == 'draft':
# When unpublishing, clear the agent's active_workflow_id and disable workflow
agent = self.agent_repo.get_by_id(workflow.agent_id)
if agent and agent.active_workflow_id == workflow_id:
self.agent_repo.update_agent(
workflow.agent_id,
active_workflow_id=None,
use_workflow=False
)
logger.info(f"Cleared agent {workflow.agent_id} active_workflow_id")
# Update workflow
updated_workflow = self.workflow_repo.update_workflow(workflow_id, **workflow_data)
logger.info(f"Updated workflow {workflow_id}")
return updated_workflow
def delete_workflow(self, workflow_id: UUID, organization_id: UUID) -> bool:
"""Delete workflow with validation"""
# Get existing workflow
workflow = self.workflow_repo.get_by_id(workflow_id)
if not workflow:
raise ValueError("Workflow not found")
# Verify organization access
if workflow.organization_id != organization_id:
raise ValueError("Workflow does not belong to your organization")
# Clear agent's active_workflow_id if this workflow is active
agent = self.agent_repo.get_by_id(workflow.agent_id)
if agent and agent.active_workflow_id == workflow_id:
self.agent_repo.update_agent(
workflow.agent_id,
active_workflow_id=None,
use_workflow=False
)
logger.info(f"Cleared agent {workflow.agent_id} active_workflow_id before deleting workflow")
# Delete workflow
success = self.workflow_repo.delete_workflow(workflow_id)
if success:
logger.info(f"Deleted workflow {workflow_id}")
return success
+393
View File
@@ -0,0 +1,393 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import asyncio
from app.core.logger import get_logger
from app.agents.chat_agent import ChatAgent, ChatResponse
from app.repositories.chat import ChatRepository
from app.repositories.session_to_agent import SessionToAgentRepository
from app.services.workflow_execution import WorkflowExecutionService
from app.core.security import decrypt_api_key
from app.models.schemas.chat import TransferReasonType
logger = get_logger(__name__)
class WorkflowChatService:
"""Service for handling workflow-based chat processing"""
def __init__(self, db):
self.db = db
self.chat_repo = ChatRepository(db)
self.session_repo = SessionToAgentRepository(db)
self.workflow_service = WorkflowExecutionService(db)
async def handle_workflow_chat(
self,
active_session,
message: str,
session_id: str,
org_id: str,
customer_id: str,
session: dict,
sio,
namespace: str = '/widget',
source: str = None
) -> ChatResponse:
"""
Handle chat processing for workflow-enabled agents
Args:
active_session: The active session object
message: User message
session_id: Session ID
org_id: Organization ID
customer_id: Customer ID
session: Session data containing agent and AI config
sio: SocketIO instance for emitting events
namespace: SocketIO namespace
Returns:
ChatResponse: The response from workflow execution
"""
# Store user message in chat history
self.chat_repo.create_message({
"message": message,
"message_type": "user",
"session_id": session_id,
"organization_id": org_id,
"agent_id": session['agent_id'],
"customer_id": customer_id,
})
# Execute workflow
workflow_result = await self.workflow_service.execute_workflow(
session_id=session_id,
user_message=message,
workflow_id=active_session.workflow_id,
current_node_id=active_session.current_node_id,
workflow_state=active_session.workflow_state,
api_key=decrypt_api_key(session['ai_config'].encrypted_api_key),
model_name=session['ai_config'].model_name,
model_type=session['ai_config'].model_type,
base_url=(session['ai_config'].settings or {}).get('base_url'),
org_id=org_id,
agent_id=session['agent_id'],
customer_id=customer_id,
source=source
)
if workflow_result.success:
return await self._handle_successful_workflow(
workflow_result, active_session, session_id, org_id,
customer_id, session, sio, namespace
)
else:
return await self._handle_failed_workflow(
workflow_result, session_id, org_id, session, customer_id
)
async def _handle_successful_workflow(
self,
workflow_result,
active_session,
session_id: str,
org_id: str,
customer_id: str,
session: dict,
sio,
namespace: str
) -> ChatResponse:
"""Handle successful workflow execution"""
# Handle intermediate messages from MESSAGE nodes during automatic execution
if workflow_result.intermediate_messages:
await self._handle_intermediate_messages(
workflow_result.intermediate_messages, session_id, org_id,
session, customer_id, active_session, sio, namespace
)
# Check if this is a form node that needs to display a form
if workflow_result.form_data:
await self._handle_form_display(workflow_result, session_id, sio, namespace)
return None # Don't send a regular chat response for form display
# Create ChatResponse object from workflow result
response = ChatResponse(
message=workflow_result.message,
transfer_to_human=workflow_result.transfer_to_human,
transfer_reason=None,
transfer_description=None,
end_chat=workflow_result.end_chat,
request_rating=workflow_result.request_rating,
create_ticket=False
)
# Handle transfer to human if requested by workflow LLM
if workflow_result.transfer_to_human:
transfer_response = await self._handle_workflow_transfer(
workflow_result, session_id, org_id, session, customer_id, response
)
if transfer_response:
return transfer_response
# Handle end chat if requested by workflow
if workflow_result.end_chat:
response = await self._handle_workflow_end_chat(
response, session_id, org_id, session, customer_id
)
# Only store message if there's actual content
if workflow_result.message:
self._store_workflow_response(
response, session_id, org_id, session, customer_id,
active_session, workflow_result
)
return response
async def _handle_intermediate_messages(
self,
intermediate_messages,
session_id: str,
org_id: str,
session: dict,
customer_id: str,
active_session,
sio,
namespace: str
):
"""Handle intermediate messages from MESSAGE nodes"""
logger.info(f"Found {len(intermediate_messages)} intermediate messages from MESSAGE nodes")
for intermediate_message in intermediate_messages:
logger.debug(f"Emitting intermediate message: {intermediate_message}")
# Store intermediate message in chat history
self.chat_repo.create_message({
"message": intermediate_message,
"message_type": "bot",
"session_id": session_id,
"organization_id": org_id,
"agent_id": session['agent_id'],
"customer_id": customer_id,
"attributes": {
"workflow_execution": True,
"workflow_id": str(active_session.workflow_id),
"intermediate_message": True,
"message_node": True
}
})
# Emit intermediate message to client immediately
await sio.emit('chat_response', {
'message': intermediate_message,
'type': 'chat_response',
'transfer_to_human': False,
'end_chat': False,
'request_rating': False,
'shopify_output': None
}, room=session_id, namespace=namespace)
async def _handle_form_display(self, workflow_result, session_id: str, sio, namespace: str):
"""Handle form display for form nodes"""
logger.info(f"Form data detected: {workflow_result.form_data}")
logger.info("Emitting display_form event")
# Emit form display to client
await sio.emit('display_form', {
'form_data': workflow_result.form_data,
'session_id': session_id
}, room=session_id, namespace=namespace)
logger.info("Form display event emitted successfully")
async def _handle_workflow_transfer(
self,
workflow_result,
session_id: str,
org_id: str,
session: dict,
customer_id: str,
response: ChatResponse
) -> ChatResponse:
"""Handle transfer to human requested by workflow"""
logger.info(f"Workflow LLM requested transfer to human for session {session_id}")
if not workflow_result.transfer_group_id:
logger.warning(
f"No transfer_group_id provided for workflow transfer in session {session_id}; "
"falling back to the agent's default group")
chat_agent = await ChatAgent.create_async(
api_key=decrypt_api_key(session['ai_config'].encrypted_api_key),
model_name=session['ai_config'].model_name,
model_type=session['ai_config'].model_type,
base_url=(session['ai_config'].settings or {}).get('base_url'),
org_id=org_id,
agent_id=session['agent_id'],
customer_id=customer_id,
session_id=session_id
)
try:
# Create ChatResponse object with transfer details from workflow
llm_response = ChatResponse(
message=workflow_result.message,
transfer_to_human=True,
transfer_reason=TransferReasonType(workflow_result.transfer_reason) if workflow_result.transfer_reason else None,
transfer_description=workflow_result.transfer_description,
end_chat=False,
request_rating=False,
create_ticket=False
)
# A missing group id is passed through rather than short-circuited:
# the transfer path then falls back to the agent's own default
# group, as a plain agent transfer does. Setting the status alone
# left group_id NULL, and an inbox matches on user_id or group_id —
# so the handover was invisible to every agent it was meant for.
transfer_response = await chat_agent.handle_workflow_transfer(
session_id=session_id,
org_id=org_id,
agent_id=session['agent_id'],
customer_id=customer_id,
transfer_group_id=workflow_result.transfer_group_id,
db=self.db,
chat_repo=self.chat_repo,
llm_response=llm_response
)
logger.info(f"Transfer completed for session {session_id} to group {workflow_result.transfer_group_id}")
# Mark this as a transfer response so widget_chat.py can handle it properly
transfer_response._is_transfer_response = True
return transfer_response
except ValueError as e:
# The agent has no groups at all, so there is nobody to route to.
# Mark it transferred anyway — an admin can still see and claim it.
logger.error(
f"Workflow transfer for session {session_id} could not be routed to a group: {e}")
self.session_repo.update_session_status(session_id, "TRANSFERRED")
return response
finally:
await chat_agent.safe_cleanup_mcp_tools()
async def _handle_workflow_end_chat(
self,
response: ChatResponse,
session_id: str,
org_id: str,
session: dict,
customer_id: str
) -> ChatResponse:
"""Handle end chat requested by workflow"""
logger.info(f"Workflow requested end chat for session {session_id}")
# Create a ChatAgent instance to use the _handle_end_chat method
chat_agent = await ChatAgent.create_async(
api_key=decrypt_api_key(session['ai_config'].encrypted_api_key),
model_name=session['ai_config'].model_name,
model_type=session['ai_config'].model_type,
base_url=(session['ai_config'].settings or {}).get('base_url'),
org_id=org_id,
agent_id=session['agent_id'],
customer_id=customer_id,
session_id=session_id
)
try:
# Handle end chat using the agent's method with rating from response
# The response.request_rating value comes from the workflow execution config
return await chat_agent._handle_end_chat(response, session_id, self.db, response.request_rating)
finally:
await chat_agent.safe_cleanup_mcp_tools()
def _store_workflow_response(
self,
response: ChatResponse,
session_id: str,
org_id: str,
session: dict,
customer_id: str,
active_session,
workflow_result
):
"""Store workflow response in chat history"""
self.chat_repo.create_message({
"message": response.message,
"message_type": "bot",
"session_id": session_id,
"organization_id": org_id,
"agent_id": session['agent_id'],
"customer_id": customer_id,
"attributes": {
"workflow_execution": True,
"workflow_id": str(active_session.workflow_id),
"current_node_id": str(workflow_result.next_node_id) if workflow_result.next_node_id else None,
"transfer_to_human": response.transfer_to_human,
"end_chat": response.end_chat,
"request_rating": response.request_rating,
}
})
async def _handle_failed_workflow(
self,
workflow_result,
session_id: str,
org_id: str,
session: dict,
customer_id: str
) -> ChatResponse:
"""Handle failed workflow execution"""
logger.error(f"Workflow execution failed: {workflow_result.error}")
response = ChatResponse(
message=workflow_result.message or "I apologize, but I'm having trouble processing your request right now.",
transfer_to_human=False,
transfer_reason=None,
transfer_description=None,
end_chat=False,
request_rating=False,
create_ticket=False
)
# Store error response in chat history
self.chat_repo.create_message({
"message": response.message,
"message_type": "bot",
"session_id": session_id,
"organization_id": org_id,
"agent_id": session['agent_id'],
"customer_id": customer_id,
"attributes": {
"workflow_execution": True,
"workflow_error": workflow_result.error,
"transfer_to_human": response.transfer_to_human,
"end_chat": response.end_chat,
"request_rating": response.request_rating,
}
})
return response
+291
View File
@@ -0,0 +1,291 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
# Evaluates workflow condition nodes.
#
# The structure of a condition is fixed before any variable is looked up, and
# variable values are only ever compared, never parsed. A visitor's answer such
# as `x" OR "a" === "a` is therefore just text and cannot steer routing.
import re
from typing import Any, Dict, List, Optional, Tuple
from app.core.logger import get_logger
logger = get_logger(__name__)
# Returned by resolve_variable when no value is stored under the name.
MISSING = object()
OPERATORS = frozenset({"===", "!==", ">", "<", ">=", "<=", "includes", "startsWith", "endsWith"})
_ALIASES = {"==": "===", "!=": "!==", "contains": "includes"}
_NUMERIC = {
">": lambda a, b: a > b,
"<": lambda a, b: a < b,
">=": lambda a, b: a >= b,
"<=": lambda a, b: a <= b,
}
_NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")
def resolve_variable(name: str, workflow_state: Optional[Dict[str, Any]]) -> Any:
"""Look up a `{{name}}` value: `node_id.field`, a top-level variable such as
user_message, or a field from any node (latest wins). MISSING if absent."""
variables = (workflow_state or {}).get("variables") or {}
node_variables = {k: v for k, v in variables.items() if isinstance(v, dict)}
name = name.strip()
if "." in name:
node_id, field_name = (part.strip() for part in name.split(".", 1))
scoped = node_variables.get(node_id, {})
return scoped[field_name] if field_name in scoped else MISSING
if name in variables and name not in node_variables:
return variables[name]
for node_id in reversed(list(node_variables)):
if name in node_variables[node_id]:
return node_variables[node_id][name]
return MISSING
def _as_text(value: Any) -> Optional[str]:
if value is MISSING or value is None:
return None
if isinstance(value, bool):
return "true" if value else "false"
return str(value).strip()
def _as_number(text: str) -> Optional[float]:
return float(text) if _NUMBER.fullmatch(text) else None
def compare(left: Any, operator: str, right: Any) -> bool:
"""Apply one operator to two values. Case-sensitive; surrounding whitespace
is ignored. A missing value only satisfies `!==`."""
operator = _ALIASES.get(operator, operator)
if operator not in OPERATORS:
logger.warning(f"Unknown condition operator '{operator}', treating as false")
return False
left_text, right_text = _as_text(left), _as_text(right)
if operator == "!==":
return left_text != right_text
if left_text is None or right_text is None:
return False
if operator == "===":
return left_text == right_text
if operator == "includes":
return right_text in left_text
if operator == "startsWith":
return left_text.startswith(right_text)
if operator == "endsWith":
return left_text.endswith(right_text)
left_num, right_num = _as_number(left_text), _as_number(right_text)
if left_num is None or right_num is None:
logger.debug(f"Non-numeric '{operator}' comparison is false: {left_text!r} vs {right_text!r}")
return False
return _NUMERIC[operator](left_num, right_num)
# --- Builder groups -------------------------------------------------------
def _rule_is_complete(rule: Any) -> bool:
# Mirrors the dashboard, which leaves half-filled rules out of the expression.
if not isinstance(rule, dict):
return False
value = rule.get("value")
return bool(rule.get("variable")) and bool(rule.get("operator")) and value is not None and str(value).strip() != ""
def _evaluate_rule(rule: Dict[str, Any], workflow_state: Dict[str, Any]) -> bool:
name = str(rule["variable"]).strip().removeprefix("{{").removesuffix("}}")
left = resolve_variable(name, workflow_state)
if left is MISSING:
logger.warning(f"Condition variable '{name}' has no value")
return compare(left, str(rule["operator"]), rule["value"])
def evaluate_groups(groups: List[Any], workflow_state: Dict[str, Any]) -> Optional[bool]:
"""Groups are OR'ed; rules within a group use its logicalOperator (AND by
default). None when no group holds a complete rule."""
results = []
for group in groups:
if not isinstance(group, dict):
continue
rules = [rule for rule in (group.get("rules") or []) if _rule_is_complete(rule)]
if not rules:
continue
outcomes = [_evaluate_rule(rule, workflow_state) for rule in rules]
any_rule = str(group.get("logicalOperator", "AND")).upper() == "OR"
results.append(any(outcomes) if any_rule else all(outcomes))
return any(results) if results else None
# --- Legacy expression strings ---------------------------------------------
class ConditionSyntaxError(ValueError):
pass
_TOKEN = re.compile(r"""
\s*(?:
(?P<var>\{\{[^{}]+\}\})
| (?P<str>"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')
| (?P<method>\.(?:includes|startsWith|endsWith)\()
| (?P<op>===|!==|==|!=|>=|<=|>|<|&&|\|\|)
| (?P<paren>[()])
| (?P<word>[^\s(){}"'=!<>&|.]+(?:\.(?!(?:includes|startsWith|endsWith)\()[^\s(){}"'=!<>&|.]+)*)
)\s*""", re.VERBOSE)
_METHODS = frozenset({"includes", "startsWith", "endsWith"})
_WORD_KEYWORDS = {"AND": ("and", None), "OR": ("or", None), "contains": ("op", "contains")}
Token = Tuple[str, Optional[str]]
def _tokenize(expression: str) -> List[Token]:
tokens: List[Token] = []
pos = 0
while pos < len(expression):
match = _TOKEN.match(expression, pos)
if not match or match.end() == pos:
if expression[pos:].strip():
raise ConditionSyntaxError(f"unexpected text at position {pos}")
break
pos = match.end()
kind = match.lastgroup
text = match.group(kind)
if kind == "var":
tokens.append(("var", text[2:-2].strip()))
elif kind == "str":
tokens.append(("lit", re.sub(r"\\(.)", r"\1", text[1:-1])))
elif kind == "method":
tokens.append(("op", text[1:-1]))
elif kind == "op" and text in ("&&", "||"):
tokens.append(("and" if text == "&&" else "or", None))
elif kind == "paren":
tokens.append((text, None))
elif kind == "word":
tokens.append(_WORD_KEYWORDS.get(text, ("lit", text)))
else:
tokens.append((kind, text))
return tokens
class _Parser:
"""or := and (OR and)* ; and := primary (AND primary)* ;
primary := '(' or ')' | operand op operand | operand .method( operand )"""
def __init__(self, tokens: List[Token]):
self.tokens = tokens
self.pos = 0
def parse(self):
if not self.tokens:
raise ConditionSyntaxError("empty condition")
node = self._or()
if self.pos != len(self.tokens):
raise ConditionSyntaxError("unexpected trailing tokens")
return node
def _peek(self) -> Optional[str]:
return self.tokens[self.pos][0] if self.pos < len(self.tokens) else None
def _take(self, kind: str) -> Token:
if self._peek() != kind:
raise ConditionSyntaxError(f"expected {kind}")
token = self.tokens[self.pos]
self.pos += 1
return token
def _or(self):
items = [self._and()]
while self._peek() == "or":
self.pos += 1
items.append(self._and())
return ("or", items) if len(items) > 1 else items[0]
def _and(self):
items = [self._primary()]
while self._peek() == "and":
self.pos += 1
items.append(self._primary())
return ("and", items) if len(items) > 1 else items[0]
def _primary(self):
if self._peek() == "(":
self.pos += 1
node = self._or()
self._take(")")
return node
left = self._operand()
_, operator = self._take("op")
right = self._operand()
if operator in _METHODS:
self._take(")") # closes `.includes(`
return ("cmp", left, operator, right)
def _operand(self) -> Token:
if self._peek() in ("var", "lit"):
token = self.tokens[self.pos]
self.pos += 1
return token
raise ConditionSyntaxError("expected a value")
def _evaluate_node(node, workflow_state: Dict[str, Any]) -> bool:
kind = node[0]
if kind == "or":
return any(_evaluate_node(item, workflow_state) for item in node[1])
if kind == "and":
return all(_evaluate_node(item, workflow_state) for item in node[1])
_, left, operator, right = node
return compare(_operand_value(left, workflow_state), operator, _operand_value(right, workflow_state))
def _operand_value(token: Token, workflow_state: Dict[str, Any]) -> Any:
kind, text = token
if kind == "lit":
return text
value = resolve_variable(text, workflow_state)
if value is MISSING:
logger.warning(f"Condition variable '{text}' has no value")
return value
def evaluate_expression(expression: str, workflow_state: Dict[str, Any]) -> bool:
"""Evaluate a stored `condition_expression`. Unreadable conditions are false."""
try:
tree = _Parser(_tokenize(expression)).parse()
except (ConditionSyntaxError, RecursionError) as e:
logger.warning(f"Unreadable condition {expression!r} ({e}), treating as false")
return False
return _evaluate_node(tree, workflow_state)
def evaluate_condition_config(config: Dict[str, Any], workflow_state: Dict[str, Any]) -> Optional[bool]:
"""Evaluate a condition node's config, preferring the builder's groups over
the generated expression. None when neither holds a usable condition."""
groups = config.get("condition_groups")
if isinstance(groups, list):
result = evaluate_groups(groups, workflow_state)
if result is not None:
return result
expression = config.get("condition_expression")
if isinstance(expression, str) and expression.strip():
return evaluate_expression(expression, workflow_state)
return None
File diff suppressed because it is too large. Load diff
+463
View File
@@ -0,0 +1,463 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from typing import List, Dict, Any, Optional
from uuid import UUID
import uuid
import re
from sqlalchemy.orm import Session
from app.repositories.workflow_node import WorkflowNodeRepository
from app.repositories.workflow import WorkflowRepository
from app.core.logger import get_logger
logger = get_logger(__name__)
def sanitize_utf8_text(text: str) -> str:
"""
Sanitize text to remove invalid UTF-8 characters and surrogates
"""
if not text:
return text
# Remove surrogate characters (like \udcac, \udd16)
text = re.sub(r'[\ud800-\udfff]', '', text)
# Encode to UTF-8 and decode to handle any remaining issues
try:
text = text.encode('utf-8', errors='ignore').decode('utf-8')
except UnicodeError:
# If still failing, remove all non-ASCII characters
text = re.sub(r'[^\x00-\x7F]', '', text)
return text.strip()
class WorkflowNodeService:
def __init__(self, db: Session):
self.db = db
self.node_repo = WorkflowNodeRepository(db)
self.workflow_repo = WorkflowRepository(db)
def _generate_unique_node_name(self, workflow_id: UUID, base_name: str, exclude_node_id: UUID = None) -> str:
"""
Generate a unique node name by appending _001, _002, etc. if needed
Args:
workflow_id: The workflow ID to check within
base_name: The base name to make unique
exclude_node_id: Node ID to exclude from uniqueness check (for updates)
Returns:
Unique node name
"""
try:
# Get all existing nodes in the workflow
workflow = self.workflow_repo.get_by_id(workflow_id)
if not workflow:
return base_name
existing_names = set()
for node in workflow.nodes:
if exclude_node_id and node.id == exclude_node_id:
continue # Skip the node being updated
if node.name:
existing_names.add(node.name.lower())
# Check if base name is already unique
if base_name.lower() not in existing_names:
return base_name
# Generate unique name with suffix
counter = 1
while True:
candidate_name = f"{base_name}_{counter:03d}"
if candidate_name.lower() not in existing_names:
return candidate_name
counter += 1
# Safety check to prevent infinite loop
if counter > 999:
# Fallback to UUID suffix
return f"{base_name}_{str(uuid.uuid4())[:8]}"
except Exception as e:
logger.error(f"Error generating unique node name: {str(e)}")
# Fallback to original name with UUID suffix
return f"{base_name}_{str(uuid.uuid4())[:8]}"
def _validate_node_name_uniqueness(self, workflow_id: UUID, node_name: str, exclude_node_id: UUID = None) -> bool:
"""
Validate that a node name is unique within the workflow
Args:
workflow_id: The workflow ID to check within
node_name: The name to validate
exclude_node_id: Node ID to exclude from uniqueness check (for updates)
Returns:
True if name is unique, False otherwise
"""
try:
workflow = self.workflow_repo.get_by_id(workflow_id)
if not workflow:
return True
for node in workflow.nodes:
if exclude_node_id and node.id == exclude_node_id:
continue # Skip the node being updated
if node.name and node.name.lower() == node_name.lower():
return False
return True
except Exception as e:
logger.error(f"Error validating node name uniqueness: {str(e)}")
return False
def _ensure_unicode_support_in_config(self, config: dict) -> dict:
"""
Ensure Unicode characters are properly preserved in config data
by applying JSON serialization with ensure_ascii=False
"""
import json
import copy
if not config:
return config
try:
# Create a deep copy to avoid modifying the original
config_copy = copy.deepcopy(config)
# Convert to JSON with ensure_ascii=False to preserve Unicode characters
# Then parse it back to ensure it's properly formatted for database storage
json_str = json.dumps(config_copy, ensure_ascii=False, separators=(',', ':'))
unicode_safe_config = json.loads(json_str)
logger.debug(f"Applied Unicode-safe processing to config: {len(str(config_copy))} chars -> {len(str(unicode_safe_config))} chars")
return unicode_safe_config
except Exception as e:
logger.error(f"Error applying Unicode-safe processing to config: {str(e)}")
return config
def replace_workflow_nodes_and_connections(
self,
workflow_id: UUID,
nodes_data: List[Dict[str, Any]],
connections_data: List[Dict[str, Any]],
organization_id: UUID
) -> Dict[str, List]:
"""Replace all workflow nodes and connections with cached data (delete existing, create new)"""
# Validate workflow exists and belongs to organization
workflow = self.workflow_repo.get_by_id(workflow_id)
if not workflow:
raise ValueError("Workflow not found")
if workflow.organization_id != organization_id:
raise ValueError("Workflow does not belong to your organization")
try:
# Delete all existing connections first (due to foreign key constraints)
existing_connections = self.node_repo.get_connections_by_workflow(workflow_id)
for connection in existing_connections:
self.db.delete(connection)
# Delete all existing nodes
existing_nodes = self.node_repo.get_nodes_by_workflow(workflow_id)
for node in existing_nodes:
self.db.delete(node)
# Commit deletions
self.db.commit()
logger.info(f"Deleted {len(existing_nodes)} existing nodes and {len(existing_connections)} existing connections for workflow {workflow_id}")
# Create ID mapping for text IDs to UUIDs
id_mapping = {}
def is_text_id(node_id: str) -> bool:
"""Check if ID is a text ID (like message-1, llm-2) instead of UUID"""
try:
UUID(node_id)
return False
except ValueError:
return True
# First pass: Create ID mappings for text IDs
for node_data in nodes_data:
node_id = str(node_data.get('id', ''))
if node_id and is_text_id(node_id):
if node_id not in id_mapping:
id_mapping[node_id] = str(uuid.uuid4())
logger.info(f"Mapped text ID '{node_id}' to UUID '{id_mapping[node_id]}'")
# Also create mappings for connection IDs if they are text IDs
for conn_data in connections_data:
conn_id = str(conn_data.get('id', ''))
if conn_id and is_text_id(conn_id):
if conn_id not in id_mapping:
id_mapping[conn_id] = str(uuid.uuid4())
logger.info(f"Mapped connection text ID '{conn_id}' to UUID '{id_mapping[conn_id]}'")
# Create all new nodes
created_nodes = []
for node_data in nodes_data:
node_data_copy = node_data.copy()
node_data_copy['workflow_id'] = workflow_id
# Convert workflow_id to UUID if it's a string
if isinstance(node_data_copy['workflow_id'], str):
node_data_copy['workflow_id'] = UUID(node_data_copy['workflow_id'])
# Extract and clean node-specific fields into config
config_fields = [
'message_text', 'system_prompt', 'temperature', 'model_id',
'condition_expression', 'action_type', 'action_url', 'action_config',
'transfer_department', 'transfer_message', 'transfer_rules',
'wait_duration', 'wait_unit', 'wait_until_condition',
'final_message', 'form_fields', 'form_title', 'form_description',
'submit_button_text', 'form_full_screen',
'landing_page_heading', 'landing_page_content',
'enabled_guardrails', 'pii_action', 'jailbreak_sensitivity',
'text_source', 'block_message'
]
# Initialize config if not present
if 'config' not in node_data_copy:
node_data_copy['config'] = {}
# Move config fields from root to config object and filter out blank values
for field in config_fields:
if field in node_data_copy:
value = node_data_copy[field]
# Only add non-blank values to config
if value is not None and value != "" and value != []:
node_data_copy['config'][field] = value
# Remove from root level
del node_data_copy[field]
# Apply Unicode-safe processing to the entire config to handle multi-language content
node_data_copy['config'] = self._ensure_unicode_support_in_config(node_data_copy['config'])
# Sanitize text fields to prevent UTF-8 encoding errors
base_text_fields = ['name', 'description']
for field in base_text_fields:
if field in node_data_copy and isinstance(node_data_copy[field], str):
node_data_copy[field] = sanitize_utf8_text(node_data_copy[field])
# Sanitize config text fields
config_text_fields = ['message_text', 'system_prompt', 'condition_expression',
'action_type', 'action_url', 'transfer_department', 'transfer_message',
'final_message', 'form_title', 'form_description', 'submit_button_text',
'landing_page_heading', 'landing_page_content', 'text_source', 'block_message']
for field in config_text_fields:
if field in node_data_copy['config'] and isinstance(node_data_copy['config'][field], str):
node_data_copy['config'][field] = sanitize_utf8_text(node_data_copy['config'][field])
# Replace text ID with UUID if mapped
original_id = str(node_data_copy.get('id', ''))
if original_id in id_mapping:
node_data_copy['id'] = id_mapping[original_id]
elif 'id' not in node_data_copy or not node_data_copy['id']:
# Generate new ID if not provided
node_data_copy['id'] = uuid.uuid4()
else:
# Convert string ID to UUID
node_data_copy['id'] = UUID(str(node_data_copy['id']))
# Filter out any remaining None or empty values from base fields
filtered_data = {}
for key, value in node_data_copy.items():
if value is not None and value != "":
filtered_data[key] = value
new_node = self.node_repo.create_node(**filtered_data)
created_nodes.append(new_node)
# Commit node creations before processing connections
self.db.commit()
# Create all new connections
created_connections = []
for conn_data in connections_data:
conn_data_copy = conn_data.copy()
conn_data_copy['workflow_id'] = workflow_id
# Convert workflow_id to UUID if it's a string
if isinstance(conn_data_copy['workflow_id'], str):
conn_data_copy['workflow_id'] = UUID(conn_data_copy['workflow_id'])
# Sanitize text fields to prevent UTF-8 encoding errors
text_fields = ['label', 'condition']
for field in text_fields:
if field in conn_data_copy and isinstance(conn_data_copy[field], str):
conn_data_copy[field] = sanitize_utf8_text(conn_data_copy[field])
# Replace text ID with UUID if mapped
original_id = str(conn_data_copy.get('id', ''))
if original_id in id_mapping:
conn_data_copy['id'] = id_mapping[original_id]
elif 'id' not in conn_data_copy or not conn_data_copy['id']:
# Generate new ID if not provided
conn_data_copy['id'] = uuid.uuid4()
else:
# Convert string ID to UUID
conn_data_copy['id'] = UUID(str(conn_data_copy['id']))
# Map source and target node IDs if they are text IDs
source_node_id = str(conn_data_copy.get('source_node_id', ''))
if source_node_id in id_mapping:
conn_data_copy['source_node_id'] = id_mapping[source_node_id]
target_node_id = str(conn_data_copy.get('target_node_id', ''))
if target_node_id in id_mapping:
conn_data_copy['target_node_id'] = id_mapping[target_node_id]
# Convert source and target node IDs to UUIDs
if 'source_node_id' in conn_data_copy:
conn_data_copy['source_node_id'] = UUID(str(conn_data_copy['source_node_id']))
if 'target_node_id' in conn_data_copy:
conn_data_copy['target_node_id'] = UUID(str(conn_data_copy['target_node_id']))
new_conn = self.node_repo.create_connection(**conn_data_copy)
created_connections.append(new_conn)
# Final commit for connections
self.db.commit()
logger.info(f"Created {len(created_nodes)} new nodes and {len(created_connections)} new connections for workflow {workflow_id}")
# Return all current nodes and connections
final_nodes = self.node_repo.get_nodes_by_workflow(workflow_id)
final_connections = self.node_repo.get_connections_by_workflow(workflow_id)
return {
"nodes": final_nodes,
"connections": final_connections
}
except Exception as e:
logger.error(f"Error replacing workflow nodes and connections: {str(e)}")
self.db.rollback()
raise
def get_workflow_nodes_and_connections(self, workflow_id: UUID, organization_id: UUID) -> Dict[str, List]:
"""Get all nodes and connections for a workflow"""
# Validate workflow exists and belongs to organization
workflow = self.workflow_repo.get_by_id(workflow_id)
if not workflow:
raise ValueError("Workflow not found")
if workflow.organization_id != organization_id:
raise ValueError("Workflow does not belong to your organization")
nodes = self.node_repo.get_nodes_by_workflow(workflow_id)
connections = self.node_repo.get_connections_by_workflow(workflow_id)
return {
"nodes": nodes,
"connections": connections
}
def update_single_node(
self,
workflow_id: UUID,
node_id: UUID,
node_data: Dict[str, Any],
organization_id: UUID = None
) -> Dict[str, Any]:
"""Update a single workflow node with its properties"""
# Validate workflow exists and belongs to organization
workflow = self.workflow_repo.get_by_id(workflow_id)
if not workflow:
raise ValueError("Workflow not found")
if organization_id and workflow.organization_id != organization_id:
raise ValueError("Workflow does not belong to your organization")
# Validate node exists and belongs to workflow
node = self.node_repo.get_node_by_id(node_id)
if not node:
raise ValueError("Node not found")
if node.workflow_id != workflow_id:
raise ValueError("Node does not belong to this workflow")
try:
logger.debug(f"Node data: {node_data}")
# Sanitize text fields to prevent UTF-8 encoding errors
node_data_copy = node_data.copy()
text_fields = ['name', 'description', 'message_text', 'system_prompt',
'condition_expression', 'action_type']
for field in text_fields:
if field in node_data_copy and isinstance(node_data_copy[field], str):
node_data_copy[field] = sanitize_utf8_text(node_data_copy[field])
# Validate and ensure unique node name
if 'name' in node_data_copy and node_data_copy['name']:
if not self._validate_node_name_uniqueness(workflow_id, node_data_copy['name'], node_id):
# Generate unique name if not unique
node_data_copy['name'] = self._generate_unique_node_name(workflow_id, node_data_copy['name'], node_id)
logger.info(f"Generated unique node name: {node_data_copy['name']}")
# Apply Unicode-safe processing to config if present
if 'config' in node_data_copy and isinstance(node_data_copy['config'], dict):
node_data_copy['config'] = self._ensure_unicode_support_in_config(node_data_copy['config'])
logger.debug(f"Node data copy: {node_data_copy}")
# Update node properties
updated_node = self.node_repo.update_node(node_id, **node_data_copy)
if not updated_node:
raise ValueError("Failed to update node")
# Commit changes
self.db.commit()
logger.info(f"Updated node {node_id}")
return updated_node
except Exception as e:
logger.error(f"Error updating node {node_id}: {str(e)}")
self.db.rollback()
raise
def get_single_node(self, workflow_id: UUID, node_id: UUID, organization_id: UUID = None):
"""Get a single node"""
# Validate workflow exists and belongs to organization
workflow = self.workflow_repo.get_by_id(workflow_id)
if not workflow:
raise ValueError("Workflow not found")
if organization_id and workflow.organization_id != organization_id:
raise ValueError("Workflow does not belong to your organization")
# Validate node exists and belongs to workflow
node = self.node_repo.get_node_by_id(node_id)
if not node:
raise ValueError("Node not found")
if node.workflow_id != workflow_id:
raise ValueError("Node does not belong to this workflow")
return node