feat(handoff): 转人工状态、待接管列表与接待时段
同会话内 AI 转人工,待接管列表按分组和在线状态展示,接管后保留上下文。
This commit is contained in:
1 parent
d9fe727c3f
commit
8e3a4d95da
6 files changed
+803
No files matched your search
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import has_any_permission
|
||||
from app.core.socketio import sio
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.chat_notifications import UNASSIGNED_CHAT_VIEWERS
|
||||
|
||||
# Per-organization room every inbox page joins (see handle_join_room), for
|
||||
# updates about any of the org's conversations — including AI-owned ones,
|
||||
# which have no handling agent to address.
|
||||
ORG_INBOX_ROOM_PREFIX = "org_inbox_"
|
||||
|
||||
|
||||
# chat_reply `type`s the inbox appends to a conversation
|
||||
USER_MESSAGE = 'user_message'
|
||||
BOT_MESSAGE = 'bot'
|
||||
|
||||
|
||||
def org_inbox_room(organization_id) -> str:
|
||||
return f"{ORG_INBOX_ROOM_PREFIX}{organization_id}"
|
||||
|
||||
|
||||
def is_org_inbox_room(room: str) -> bool:
|
||||
return room.startswith(ORG_INBOX_ROOM_PREFIX)
|
||||
|
||||
|
||||
def may_join_org_inbox(db: Session, socket_session: dict, room: str) -> bool:
|
||||
"""Whether a dashboard socket may join an org inbox room: its own
|
||||
organization's, and only for people who can see chats nobody owns."""
|
||||
if str(socket_session.get('organization_id')) != room.removeprefix(ORG_INBOX_ROOM_PREFIX):
|
||||
return False
|
||||
user_id = socket_session.get('user_id')
|
||||
user = UserRepository(db).get_user(user_id) if user_id else None
|
||||
return (user is not None and user.is_active
|
||||
and has_any_permission(user, UNASSIGNED_CHAT_VIEWERS))
|
||||
|
||||
|
||||
async def emit_to_session_agents(event: str, payload: dict, session_id,
|
||||
user_id: Optional[object] = None) -> None:
|
||||
"""Push a live update to the dashboard for one conversation.
|
||||
|
||||
The inbox listens on the handling agent's personal room (user_<id>); the
|
||||
session room reaches anyone who joined that conversation explicitly.
|
||||
"""
|
||||
if user_id is not None:
|
||||
await sio.emit(event, payload, room=f"user_{user_id}", namespace='/agent')
|
||||
await sio.emit(event, payload, room=str(session_id), namespace='/agent')
|
||||
|
||||
|
||||
async def emit_to_org_inbox(event: str, payload: dict, organization_id,
|
||||
user_id: Optional[object] = None) -> None:
|
||||
"""Push a live update to every inbox open on the organization, and to the
|
||||
handling agent's personal room (where a chat they own is listening)."""
|
||||
await sio.emit(event, payload, room=org_inbox_room(organization_id), namespace='/agent')
|
||||
if user_id is not None:
|
||||
await sio.emit(event, payload, room=f"user_{user_id}", namespace='/agent')
|
||||
|
||||
|
||||
async def relay_stored_message(session_record, stored, message_type: str) -> None:
|
||||
"""Push a stored message into the dashboard conversation live, the way
|
||||
the widget's chat_reply events do — a customer message, or one sent from
|
||||
outside the inbox (e.g. by an external app) into a chat a human holds."""
|
||||
session_id = str(session_record.session_id)
|
||||
payload = {
|
||||
'message': stored.message,
|
||||
'message_id': stored.id,
|
||||
'type': message_type,
|
||||
'transfer_to_human': False,
|
||||
'session_id': session_id,
|
||||
# The inbox appends the message live off this field; without it the
|
||||
# handler builds an invalid Date and the message only shows on refetch.
|
||||
'created_at': (stored.created_at or datetime.now(timezone.utc)).isoformat(),
|
||||
# Carries e.g. the button-reply marker, so tags show live too
|
||||
'attributes': stored.attributes or {},
|
||||
}
|
||||
await emit_to_session_agents('chat_reply', payload, session_id, session_record.user_id)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
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.channels.constants import MessageSource
|
||||
from app.core.logger import get_logger
|
||||
from app.models.session_to_agent import SessionStatus
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.group import GroupRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.repositories.user import UserRepository
|
||||
from app.services.chat_notifications import UNASSIGNED_CHAT_VIEWERS
|
||||
from app.services.message_delivery import deliver_to_customer
|
||||
from app.services.notifications import ChatNotificationEvent, notify_chat_event
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# The widget shows handover state in its own UI, so only external channels need
|
||||
# a message spelling out what just happened.
|
||||
WEB_CHANNEL = 'web'
|
||||
|
||||
# Sent when a chat is queued for the team but nobody has picked it up yet. The
|
||||
# widget composes an availability-aware line of its own; channels get this.
|
||||
QUEUED_FOR_HUMAN_NOTICE = (
|
||||
"I'm passing this to a member of our team. Someone will reply here shortly."
|
||||
)
|
||||
|
||||
# First reply on an agent whose AI is switched off. Deliberately says nothing
|
||||
# about transferring or passing anything on — there was no AI in the
|
||||
# conversation to hand it over. It exists so the visitor is not left staring at
|
||||
# an empty thread wondering whether the message arrived.
|
||||
HUMAN_ONLY_ACK = (
|
||||
"Thanks for your message. Someone from our team will reply here shortly."
|
||||
)
|
||||
|
||||
|
||||
async def notify_customer(
|
||||
db: Session,
|
||||
session,
|
||||
message: str,
|
||||
user_id: Optional[str] = None,
|
||||
channels_only: bool = True,
|
||||
) -> None:
|
||||
"""Record a message in the thread and push it to the customer.
|
||||
|
||||
Best-effort: a failed notice must never fail the action that triggered it.
|
||||
Skips the widget by default, which renders handover state in its own UI —
|
||||
pass channels_only=False for a message the visitor should actually read.
|
||||
"""
|
||||
if channels_only and getattr(session, 'channel', None) in (None, WEB_CHANNEL):
|
||||
return
|
||||
try:
|
||||
stored = ChatRepository(db).create_message({
|
||||
'message': message,
|
||||
'message_type': 'agent' if user_id else 'bot',
|
||||
'session_id': str(session.session_id),
|
||||
'organization_id': str(session.organization_id),
|
||||
'agent_id': str(session.agent_id) if session.agent_id else None,
|
||||
'customer_id': str(session.customer_id) if session.customer_id else None,
|
||||
'user_id': user_id,
|
||||
'attributes': {'channel': session.channel, 'handover_notice': True},
|
||||
})
|
||||
result = await deliver_to_customer(db, session, {
|
||||
'message': message,
|
||||
'type': 'chat_response',
|
||||
}, message_id=stored.id, source=MessageSource.SYSTEM)
|
||||
if not result.ok:
|
||||
logger.warning(
|
||||
f"Handover notice not delivered on {session.channel}: {result.reason}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed sending handover notice: {str(e)}")
|
||||
|
||||
|
||||
def _transfer_recipients(db: Session, session, group_id) -> list:
|
||||
"""Who to tell that a chat is waiting.
|
||||
|
||||
A group queue notifies that group; without one the chat belongs to nobody,
|
||||
so it goes to whoever could actually open it from the inbox.
|
||||
"""
|
||||
if group_id:
|
||||
group = GroupRepository(db).get_group_with_users(str(group_id))
|
||||
users = group.users if group else []
|
||||
else:
|
||||
users = UserRepository(db).get_users_with_any_permission(
|
||||
session.organization_id, UNASSIGNED_CHAT_VIEWERS
|
||||
)
|
||||
return [user.id for user in users]
|
||||
|
||||
|
||||
async def route_session_to_human(
|
||||
db: Session,
|
||||
session,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
group_id: Optional[str | UUID] = None,
|
||||
notify: bool = True,
|
||||
) -> bool:
|
||||
"""Take the AI out of a conversation and queue it for the team.
|
||||
|
||||
`TRANSFERRED` with no assignee is the state every AI path already checks:
|
||||
the widget stops calling the model and answers with an availability line,
|
||||
and channels relay straight to the dashboard. So this needs no new status —
|
||||
it puts the session into the one the guards already understand.
|
||||
|
||||
Returns False without touching anything when there is nothing to route: a
|
||||
human already holds it, it is closed, or it is queued already. That makes
|
||||
the call idempotent, so a double click cannot double-notify the team or
|
||||
send the customer the holding message twice.
|
||||
"""
|
||||
if session is None:
|
||||
return False
|
||||
if session.user_id is not None:
|
||||
return False
|
||||
if session.status in (SessionStatus.CLOSED, SessionStatus.TRANSFERRED):
|
||||
return False
|
||||
|
||||
updates = {
|
||||
'status': SessionStatus.TRANSFERRED,
|
||||
'transfer_reason': reason,
|
||||
'transfer_description': description,
|
||||
}
|
||||
# Only when a queue was named: passing None would clear a group the AI's
|
||||
# own transfer had already set.
|
||||
if group_id:
|
||||
updates['group_id'] = group_id
|
||||
|
||||
if not SessionToAgentRepository(db).update_session(session.session_id, updates):
|
||||
return False
|
||||
|
||||
if notify:
|
||||
recipients = _transfer_recipients(db, session, group_id or session.group_id)
|
||||
if recipients:
|
||||
await notify_chat_event(
|
||||
db=db,
|
||||
user_ids=recipients,
|
||||
event=ChatNotificationEvent.CHAT_TRANSFER,
|
||||
title="Chat waiting for a human",
|
||||
message=description or "A chat is waiting for someone to take it over.",
|
||||
metadata={
|
||||
"session_id": str(session.session_id),
|
||||
"transfer_reason": reason,
|
||||
"transfer_description": description,
|
||||
},
|
||||
)
|
||||
|
||||
await notify_customer(db, session, QUEUED_FOR_HUMAN_NOTICE)
|
||||
return True
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
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 types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.permission import Permission
|
||||
from app.services.agent_rooms import may_join_org_inbox
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def viewer(db, test_user, test_role):
|
||||
permission = Permission(name="view_unassigned_chats")
|
||||
db.add(permission)
|
||||
test_role.permissions = [*test_role.permissions, permission]
|
||||
db.commit()
|
||||
return test_user
|
||||
|
||||
|
||||
def _socket(user, org=None):
|
||||
return {"user_id": str(user.id), "organization_id": str(org or user.organization_id)}
|
||||
|
||||
|
||||
def test_a_viewer_of_unowned_chats_may_join_their_orgs_room(db, viewer):
|
||||
assert may_join_org_inbox(db, _socket(viewer), f"org_inbox_{viewer.organization_id}")
|
||||
|
||||
|
||||
def test_not_another_orgs_room(db, viewer):
|
||||
assert not may_join_org_inbox(db, _socket(viewer), f"org_inbox_{uuid4()}")
|
||||
|
||||
|
||||
def test_not_without_the_permission(db, test_user):
|
||||
assert not may_join_org_inbox(db, _socket(test_user), f"org_inbox_{test_user.organization_id}")
|
||||
|
||||
|
||||
def test_not_once_deactivated_or_deleted(db, viewer):
|
||||
viewer.is_active = False
|
||||
db.commit()
|
||||
assert not may_join_org_inbox(db, _socket(viewer), f"org_inbox_{viewer.organization_id}")
|
||||
ghost = SimpleNamespace(id=uuid4(), organization_id=viewer.organization_id)
|
||||
assert not may_join_org_inbox(db, _socket(ghost), f"org_inbox_{viewer.organization_id}")
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Taking the AI out of a conversation: the state transition behind the per-chat
|
||||
"hand to my team" action.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
||||
from app.models.chat_history import ChatHistory
|
||||
from app.services.human_routing import notify_customer, route_session_to_human
|
||||
from app.services.message_delivery import DeliveryResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def open_session(db, test_agent, test_customer, test_organization) -> SessionToAgent:
|
||||
session = SessionToAgent(
|
||||
session_id=uuid4(),
|
||||
agent_id=test_agent.id,
|
||||
customer_id=test_customer.id,
|
||||
organization_id=test_organization.id,
|
||||
status=SessionStatus.OPEN,
|
||||
assigned_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
|
||||
|
||||
def _silenced():
|
||||
"""Notifications and channel delivery are covered by their own tests."""
|
||||
return (
|
||||
patch("app.services.human_routing.notify_chat_event", new=AsyncMock()),
|
||||
patch("app.services.human_routing.notify_customer", new=AsyncMock()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_queues_the_chat_without_an_assignee(db, open_session):
|
||||
"""TRANSFERRED with user_id null is the state every AI guard checks."""
|
||||
notify, customer = _silenced()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(
|
||||
db, open_session, reason="DIRECT_REQUEST", description="test") is True
|
||||
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.TRANSFERRED
|
||||
assert open_session.user_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_refuses_a_claimed_or_closed_chat(db, open_session, test_user):
|
||||
"""Nothing to take away from the AI — it is not answering either one."""
|
||||
notify, customer = _silenced()
|
||||
|
||||
open_session.user_id = test_user.id
|
||||
db.commit()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is False
|
||||
|
||||
open_session.user_id = None
|
||||
open_session.status = SessionStatus.CLOSED
|
||||
db.commit()
|
||||
notify, customer = _silenced()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is False
|
||||
|
||||
db.refresh(open_session)
|
||||
assert open_session.status == SessionStatus.CLOSED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_is_idempotent(db, open_session):
|
||||
"""A second call must not re-notify or re-message the customer."""
|
||||
notify, customer = _silenced()
|
||||
with notify, customer:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is True
|
||||
|
||||
with patch("app.services.human_routing.notify_chat_event", new=AsyncMock()) as n, \
|
||||
patch("app.services.human_routing.notify_customer", new=AsyncMock()) as c:
|
||||
assert await route_session_to_human(db, open_session, reason="x") is False
|
||||
assert not n.called
|
||||
assert not c.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handover_notice_is_delivered_against_its_stored_row(db, open_session):
|
||||
"""The notice's channel message id must land on the notice's own row."""
|
||||
open_session.channel = "whatsapp"
|
||||
db.commit()
|
||||
with patch("app.services.human_routing.deliver_to_customer",
|
||||
new=AsyncMock(return_value=DeliveryResult(ok=True))) as deliver:
|
||||
await notify_customer(db, open_session, "You're now connected with our team.")
|
||||
|
||||
notice = db.query(ChatHistory).filter_by(session_id=open_session.session_id).one()
|
||||
assert deliver.await_args.kwargs["message_id"] == notice.id
|
||||
Reference in new issue
Block a user