Skip to content

Commit 69a4822

Browse files
authored
Merge pull request #179 from wumibals/main
(ADMIN) — returns unassigned escalated queue
2 parents 8570421 + b6e38f0 commit 69a4822

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

src/chat.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ def __init__(self) -> None:
6363
self.message_history: Dict[str, List[ChatMessage]] = {}
6464
self.escalations: List[EscalationEvent] = []
6565
self.user_connections: Dict[str, Set[str]] = {}
66+
# status: "open" | "escalated" | "assigned" | "resolved"
67+
self.conversation_statuses: Dict[str, str] = {}
68+
self.conversation_assignments: Dict[str, Optional[str]] = {}
69+
self.conversation_escalated_at: Dict[str, datetime] = {}
6670
self._lock = asyncio.Lock()
6771

6872
async def connect(
@@ -192,6 +196,9 @@ async def escalate_conversation(
192196

193197
async with self._lock:
194198
self.escalations.append(escalation)
199+
self.conversation_statuses[conversation_id] = "escalated"
200+
self.conversation_assignments[conversation_id] = None
201+
self.conversation_escalated_at[conversation_id] = escalation.timestamp
195202

196203
if conversation_id in self.active_connections:
197204
escalation_notification = {
@@ -225,6 +232,77 @@ async def escalate_conversation(
225232
)
226233
return escalation
227234

235+
async def assign_conversation(
236+
self, conversation_id: str, agent_id: str
237+
) -> None:
238+
"""Assign an escalated conversation to a support agent."""
239+
async with self._lock:
240+
self.conversation_statuses[conversation_id] = "assigned"
241+
self.conversation_assignments[conversation_id] = agent_id
242+
243+
notification = {
244+
"type": "assignment",
245+
"conversation_id": conversation_id,
246+
"agent_id": agent_id,
247+
"timestamp": datetime.utcnow().isoformat(),
248+
"message": "This conversation has been assigned to a support agent.",
249+
}
250+
251+
if conversation_id in self.active_connections:
252+
disconnected: List[WebSocket] = []
253+
for websocket in self.active_connections[conversation_id]:
254+
try:
255+
await websocket.send_text(json.dumps(notification))
256+
except Exception as exc:
257+
log_warning(
258+
"Failed to send assignment notification",
259+
{"conversation_id": conversation_id, "error": str(exc)},
260+
)
261+
disconnected.append(websocket)
262+
263+
if disconnected:
264+
async with self._lock:
265+
for ws in disconnected:
266+
if ws in self.active_connections.get(conversation_id, []):
267+
self.active_connections[conversation_id].remove(ws)
268+
269+
log_info(
270+
"Conversation assigned",
271+
{"conversation_id": conversation_id, "agent_id": agent_id},
272+
)
273+
274+
def get_conversation_status(
275+
self, conversation_id: str
276+
) -> Dict[str, Any]:
277+
"""Return the current status and assignment for a conversation."""
278+
status = self.conversation_statuses.get(conversation_id, "open")
279+
assigned_agent_id = self.conversation_assignments.get(conversation_id)
280+
return {
281+
"conversation_id": conversation_id,
282+
"status": status,
283+
"assigned_agent_id": assigned_agent_id,
284+
}
285+
286+
def get_unassigned_queue(self) -> List[Dict[str, Any]]:
287+
"""Return all escalated conversations with no assigned agent, ordered by escalated_at asc."""
288+
queue = []
289+
for conv_id, status in self.conversation_statuses.items():
290+
if status == "escalated" and self.conversation_assignments.get(conv_id) is None:
291+
escalated_at = self.conversation_escalated_at.get(conv_id)
292+
# Find the most recent escalation reason for this conversation
293+
reason = ""
294+
for esc in reversed(self.escalations):
295+
if esc.conversation_id == conv_id:
296+
reason = esc.reason
297+
break
298+
queue.append({
299+
"conversation_id": conv_id,
300+
"escalated_at": escalated_at,
301+
"reason": reason,
302+
})
303+
queue.sort(key=lambda x: x["escalated_at"] or datetime.min)
304+
return queue
305+
228306
def get_escalations(
229307
self, conversation_id: Optional[str] = None
230308
) -> List[EscalationEvent]:

src/main.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,18 @@
7272
AnalyticsTransfersResponse,
7373
HeatmapQuery,
7474
HeatmapResponse,
75+
ChatAssignRequest,
76+
ChatAssignResponse,
77+
ChatConversationStatusResponse,
7578
ChatEscalateRequest,
7679
ChatEscalateResponse,
7780
ChatEscalationsResponse,
7881
ChatMessageHistoryQuery,
7982
ChatMessageHistoryResponse,
8083
ChatMessageSendRequest,
8184
ChatMessageSendResponse,
85+
ChatQueueItem,
86+
ChatQueueResponse,
8287
ChatTypingRequest,
8388
ChatTypingResponse,
8489
ChatUserConversationsResponse,
@@ -913,6 +918,75 @@ async def get_user_conversations(user_id: str) -> ChatUserConversationsResponse:
913918
raise HTTPException(status_code=500, detail="Failed to get user conversations")
914919

915920

921+
@app.get("/chat/queue", response_model=ChatQueueResponse)
922+
async def get_escalation_queue(
923+
_: str = Depends(require_admin_key),
924+
) -> ChatQueueResponse:
925+
"""Return all unassigned escalated conversations ordered by escalation time (oldest first)."""
926+
try:
927+
raw_queue = chat_manager.get_unassigned_queue()
928+
queue = [
929+
ChatQueueItem(
930+
conversation_id=item["conversation_id"],
931+
escalated_at=item["escalated_at"],
932+
reason=item["reason"],
933+
)
934+
for item in raw_queue
935+
]
936+
return ChatQueueResponse(queue=queue, count=len(queue))
937+
except HTTPException:
938+
raise
939+
except Exception as exc:
940+
logger.error("Error getting escalation queue: %s", exc)
941+
raise HTTPException(status_code=500, detail="Failed to get escalation queue")
942+
943+
944+
@app.post("/chat/{conversation_id}/assign", response_model=ChatAssignResponse)
945+
async def assign_conversation(
946+
conversation_id: str,
947+
body: ChatAssignRequest,
948+
_: str = Depends(require_admin_key),
949+
) -> ChatAssignResponse:
950+
"""Assign an escalated conversation to a support agent."""
951+
try:
952+
status_info = chat_manager.get_conversation_status(conversation_id)
953+
if status_info["status"] not in ("escalated", "assigned"):
954+
raise HTTPException(
955+
status_code=400,
956+
detail=f"Conversation is not escalated (current status: {status_info['status']})",
957+
)
958+
await chat_manager.assign_conversation(conversation_id, body.agent_id)
959+
return ChatAssignResponse(
960+
status="success",
961+
conversation_id=conversation_id,
962+
agent_id=body.agent_id,
963+
)
964+
except HTTPException:
965+
raise
966+
except Exception as exc:
967+
logger.error("Error assigning conversation: %s", exc)
968+
raise HTTPException(status_code=500, detail="Failed to assign conversation")
969+
970+
971+
@app.get("/chat/{conversation_id}/status", response_model=ChatConversationStatusResponse)
972+
async def get_conversation_status(
973+
conversation_id: str,
974+
) -> ChatConversationStatusResponse:
975+
"""Get the current status and agent assignment for a conversation."""
976+
try:
977+
info = chat_manager.get_conversation_status(conversation_id)
978+
return ChatConversationStatusResponse(
979+
conversation_id=info["conversation_id"],
980+
status=info["status"],
981+
assigned_agent_id=info["assigned_agent_id"],
982+
)
983+
except HTTPException:
984+
raise
985+
except Exception as exc:
986+
logger.error("Error getting conversation status: %s", exc)
987+
raise HTTPException(status_code=500, detail="Failed to get conversation status")
988+
989+
916990
# ---------------------------------------------------------------------------
917991
# Trending events
918992
# ---------------------------------------------------------------------------

src/types_custom.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,38 @@ class ChatTypingResponse(BaseModel):
227227
status: Literal["success"]
228228

229229

230+
class ChatAssignRequest(BaseModel):
231+
model_config = ConfigDict(extra="forbid")
232+
agent_id: str = Field(..., min_length=1)
233+
234+
235+
class ChatAssignResponse(BaseModel):
236+
model_config = ConfigDict(extra="forbid")
237+
status: Literal["success"]
238+
conversation_id: str
239+
agent_id: str
240+
241+
242+
class ChatQueueItem(BaseModel):
243+
model_config = ConfigDict(extra="forbid")
244+
conversation_id: str
245+
escalated_at: datetime
246+
reason: str
247+
248+
249+
class ChatQueueResponse(BaseModel):
250+
model_config = ConfigDict(extra="forbid")
251+
queue: List[ChatQueueItem]
252+
count: int
253+
254+
255+
class ChatConversationStatusResponse(BaseModel):
256+
model_config = ConfigDict(extra="forbid")
257+
conversation_id: str
258+
status: Literal["open", "escalated", "assigned", "resolved"]
259+
assigned_agent_id: Optional[str] = None
260+
261+
230262
class AnalyticsStatsQuery(BaseModel):
231263
model_config = ConfigDict(extra="forbid")
232264
event_id: Optional[str] = None

0 commit comments

Comments
 (0)