The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.
- widget loader is now webclient/replypier.min.js; both bundles rebuilt
from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
rewritten rather than string-swapped; the demo config no longer ships
the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
documents carrying the old local data over
266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from typing import Optional
|
|
from uuid import UUID
|
|
|
|
from agno.tools import Toolkit
|
|
|
|
from app.core.logger import get_logger
|
|
from app.database import SessionLocal
|
|
from app.models.session_to_agent import SessionToAgent
|
|
from app.models.ticket import TicketPriority, TicketSource
|
|
from app.repositories.customer import CustomerRepository
|
|
from app.repositories.session_to_agent import SessionToAgentRepository
|
|
from app.services.widget_identity import trusts_identity_in_conversation
|
|
from app.models.ticket_activity import TicketActivityType, TicketActorType
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
VALID_PRIORITIES = {p.value for p in TicketPriority}
|
|
|
|
# Loose sanity check only — a real inbox is not verified here, we just refuse
|
|
# to store obviously-not-an-email junk the model might pass.
|
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
|
|
|
|
def _clean_email(email: Optional[str]) -> Optional[str]:
|
|
if not email:
|
|
return None
|
|
email = email.strip().lower()
|
|
return email if _EMAIL_RE.match(email) else None
|
|
|
|
|
|
def _clean_name(name: Optional[str]) -> Optional[str]:
|
|
name = (name or "").strip()
|
|
return name or None
|
|
|
|
|
|
class TicketTools(Toolkit):
|
|
"""Native ticket tools for the customer-facing chat agent — the AI-first
|
|
replacement for JiraTools. One native ticket per session: a second
|
|
create_ticket call appends the new details as an activity instead of
|
|
opening a duplicate.
|
|
"""
|
|
|
|
def __init__(self, agent_id: str, org_id: str, session_id: str):
|
|
super().__init__(name="ticket_tools")
|
|
self.agent_id = agent_id
|
|
self.org_id = org_id
|
|
self.session_id = session_id
|
|
|
|
self.register(self.create_ticket)
|
|
self.register(self.get_ticket_status)
|
|
self.register(self.check_existing_ticket)
|
|
|
|
def create_ticket(
|
|
self,
|
|
summary: str,
|
|
description: str,
|
|
priority: Optional[str] = "medium",
|
|
customer_email: Optional[str] = None,
|
|
customer_name: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
Create a support ticket for the current conversation, or add the new
|
|
details to the session's existing ticket. Use this when the customer
|
|
reports a technical issue you cannot resolve from the knowledge base —
|
|
the ticket is investigated by the support team's AI and humans.
|
|
|
|
Always pass the customer's account email and registered name when you
|
|
know them (ask the customer first if they haven't been shared) — the
|
|
support AI uses them to look the customer up in the connected systems,
|
|
and without a real email it cannot analyse the customer's own records.
|
|
|
|
Args:
|
|
summary (str): Short title of the issue.
|
|
description (str): Detailed description: what happens, error
|
|
messages, customer impact, and anything needed to reproduce.
|
|
priority (str, optional): urgent, high, medium or low. Defaults to
|
|
"medium".
|
|
customer_email (str, optional): The customer's account email, as
|
|
they gave it. Used to correlate them in the support systems.
|
|
customer_name (str, optional): The customer's registered name.
|
|
|
|
Returns:
|
|
str: JSON string with the ticket number and status.
|
|
"""
|
|
try:
|
|
from app.services.ticket import TicketService
|
|
|
|
priority_value = (priority or "medium").lower()
|
|
if priority_value not in VALID_PRIORITIES:
|
|
priority_value = TicketPriority.MEDIUM.value
|
|
|
|
email = _clean_email(customer_email)
|
|
name = _clean_name(customer_name)
|
|
|
|
with SessionLocal() as db:
|
|
service = TicketService(db)
|
|
session_uuid = UUID(str(self.session_id))
|
|
conversation = SessionToAgentRepository(db).get_session(session_uuid)
|
|
customer = (
|
|
CustomerRepository(db).get_by_id(conversation.customer_id)
|
|
if conversation is not None and conversation.customer_id else None
|
|
)
|
|
trusted = trusts_identity_in_conversation(db, customer, conversation)
|
|
if trusted:
|
|
# Enrich the (usually anonymous) widget customer with the
|
|
# identity the customer stated in chat, so the ticket — and
|
|
# the investigator's DB correlation — carry a real email +
|
|
# name instead of the …@noemail.com placeholder.
|
|
self._capture_customer_identity(db, conversation, email, name)
|
|
else:
|
|
# A visitor who only typed a customer's email is not them:
|
|
# the ticket carries no customer, so the investigator has no
|
|
# one's records to look up or quote back into this chat.
|
|
email = name = None
|
|
existing = service.repo.get_by_session(session_uuid)
|
|
if existing is not None:
|
|
service._add_activity(
|
|
existing,
|
|
TicketActivityType.CUSTOMER_REPLIED,
|
|
actor_type=TicketActorType.AI,
|
|
body=f"Additional details from the conversation:\n{description}",
|
|
)
|
|
db.commit()
|
|
return json.dumps({
|
|
"success": True,
|
|
"updated": True,
|
|
"ticket_id": existing.display_number,
|
|
"status": str(existing.status),
|
|
"message": (
|
|
f"Ticket {existing.display_number} already exists for this "
|
|
"conversation — the new details were added to it."
|
|
),
|
|
})
|
|
|
|
ticket, _duplicates = service.create_ticket(
|
|
organization_id=UUID(str(self.org_id)),
|
|
title=summary[:500],
|
|
description=description,
|
|
priority=priority_value,
|
|
source=TicketSource.CHAT_AI,
|
|
session_id=session_uuid,
|
|
agent_id=UUID(str(self.agent_id)),
|
|
customer_email=email,
|
|
customer_name=name,
|
|
link_session_customer=trusted,
|
|
)
|
|
db.commit()
|
|
db.refresh(ticket)
|
|
ticket_number = ticket.display_number
|
|
status = str(ticket.status)
|
|
|
|
# The acknowledgment message is sent by the chat agent itself in
|
|
# its reply, so no separate customer notification here.
|
|
return json.dumps({
|
|
"success": True,
|
|
"updated": False,
|
|
"ticket_id": ticket_number,
|
|
"status": status,
|
|
"message": f"Ticket {ticket_number} created. The team's AI will start investigating.",
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"create_ticket failed for session {self.session_id}: {e}")
|
|
return json.dumps({"success": False, "message": "Failed to create the ticket."})
|
|
|
|
def _capture_customer_identity(
|
|
self, db, conversation: SessionToAgent, email: Optional[str], name: Optional[str]
|
|
) -> None:
|
|
"""Fold a stated email/name onto the conversation's customer record in place.
|
|
|
|
Widget visitors are anonymous (`…@noemail.com`, no name); the customer
|
|
naming their real account in chat is the only identity the investigator
|
|
gets. update_contact only overwrites a placeholder email and never
|
|
clobbers a real one or a channel identity key. Only for a conversation
|
|
trusted to speak for its customer (see create_ticket).
|
|
"""
|
|
if not email and not name:
|
|
return
|
|
try:
|
|
CustomerRepository(db).update_contact(
|
|
conversation.customer_id, email=email, full_name=name
|
|
)
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(
|
|
f"Failed to capture customer identity for session {conversation.session_id}: {e}"
|
|
)
|
|
|
|
def get_ticket_status(self, ticket_number: Optional[str] = None) -> str:
|
|
"""
|
|
Get the current status of a ticket. Without a ticket number, returns
|
|
the status of this conversation's ticket.
|
|
|
|
Args:
|
|
ticket_number (str, optional): Ticket number like "TKT-123".
|
|
|
|
Returns:
|
|
str: JSON string with the ticket status and priority.
|
|
"""
|
|
try:
|
|
from app.repositories.ticket import TicketRepository
|
|
|
|
with SessionLocal() as db:
|
|
repo = TicketRepository(db)
|
|
ticket = None
|
|
if ticket_number:
|
|
digits = str(ticket_number).strip().upper().removeprefix("TKT-")
|
|
if digits.isdigit():
|
|
ticket = repo.get_by_number(UUID(str(self.org_id)), int(digits))
|
|
else:
|
|
ticket = repo.get_by_session(UUID(str(self.session_id)))
|
|
if ticket is None or str(ticket.organization_id) != str(self.org_id):
|
|
return json.dumps({"exists": False, "message": "No ticket found."})
|
|
return json.dumps({
|
|
"exists": True,
|
|
"ticket_id": ticket.display_number,
|
|
"status": str(ticket.status),
|
|
"priority": str(ticket.priority),
|
|
"title": ticket.title,
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"get_ticket_status failed: {e}")
|
|
return json.dumps({"exists": False, "message": "Could not look up the ticket."})
|
|
|
|
def check_existing_ticket(self) -> str:
|
|
"""
|
|
Check whether this conversation already has a ticket. Always call this
|
|
before creating a ticket.
|
|
|
|
Returns:
|
|
str: JSON string with "exists" and the ticket number if present.
|
|
"""
|
|
try:
|
|
from app.repositories.ticket import TicketRepository
|
|
|
|
with SessionLocal() as db:
|
|
ticket = TicketRepository(db).get_by_session(UUID(str(self.session_id)))
|
|
if ticket is None:
|
|
return json.dumps({"exists": False})
|
|
return json.dumps({
|
|
"exists": True,
|
|
"ticket_id": ticket.display_number,
|
|
"status": str(ticket.status),
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"check_existing_ticket failed: {e}")
|
|
return json.dumps({"exists": False})
|