256 lines
10 KiB
Python
256 lines
10 KiB
Python
"""Human conversation lifecycle; typing state is supplied by the app."""
|
|
|
|
import time
|
|
from datetime import datetime, datetime as DateTime, timedelta, timezone
|
|
from math import ceil
|
|
|
|
from flask import g
|
|
|
|
from .db import connect
|
|
from . import routing_service
|
|
|
|
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
|
|
QUEUE_TIMEOUT_SECONDS = 5 * 60
|
|
|
|
|
|
def human_request(data):
|
|
conv_id = data.get('conv_id')
|
|
if not conv_id:
|
|
return ({'ok': False, 'error': 'no conv'}, 400)
|
|
service_time = datetime.now(SERVICE_TIMEZONE)
|
|
if not 8 <= service_time.hour < 22:
|
|
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天北京时间8:00-22:00,当前为非工作时间,请留言'}
|
|
now = service_time.strftime('%Y-%m-%d %H:%M:%S')
|
|
conn = connect()
|
|
conn.execute('BEGIN IMMEDIATE')
|
|
if not conn.execute('SELECT 1 FROM chat_history WHERE id=?', (conv_id,)).fetchone():
|
|
conn.close()
|
|
return ({'ok':False, 'error':'conversation_not_found'}, 404)
|
|
row = conn.execute("SELECT id FROM human_sessions WHERE conv_id=? AND status!='closed' ORDER BY id DESC LIMIT 1", (conv_id,)).fetchone()
|
|
if row:
|
|
sid = row[0]
|
|
else:
|
|
cur = conn.execute('INSERT INTO human_sessions(conv_id, status, ctime) VALUES(?,?,?)', (conv_id, 'waiting', now))
|
|
sid = cur.lastrowid
|
|
routing_service.assign_new_session(conn, sid)
|
|
status = conn.execute('SELECT status FROM human_sessions WHERE id=?', (sid,)).fetchone()[0]
|
|
conn.commit()
|
|
conn.close()
|
|
return {'ok': True, 'session_id': sid, 'status': status}
|
|
|
|
|
|
def human_poll(sid, after, typing_map):
|
|
conn = connect()
|
|
msgs = conn.execute('SELECT id, side, content, ctime, read_at, recalled_id FROM human_messages WHERE session_id=? AND id>? ORDER BY id', (sid, after)).fetchall()
|
|
st = conn.execute("SELECT status, COALESCE(note, ''),owner_username FROM human_sessions WHERE id=?", (sid,)).fetchone()
|
|
conn.close()
|
|
is_typing = False
|
|
ts = typing_map.get(str(sid))
|
|
if ts and time.time() - ts < 5:
|
|
is_typing = True
|
|
return {'status': st[0] if st else 'closed', 'note': st[1] if st else '',
|
|
'owner_username': st[2] if st else None,
|
|
'typing': is_typing, 'messages': [dict(id=m[0], side=m[1], content=m[2], ctime=m[3],
|
|
read=bool(m[4]), **({'recalled_id': m[5]} if m[5] else {})) for m in msgs]}
|
|
|
|
|
|
def human_send(data):
|
|
sid = data.get('sid')
|
|
side = data.get('side', 'customer')
|
|
content = data.get('content') or ''
|
|
if not sid or not isinstance(content, str) or not content.strip() or side not in ('customer', 'agent'):
|
|
return ({'ok': False}, 400)
|
|
content = content.strip()
|
|
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
conn = connect()
|
|
conn.execute('BEGIN IMMEDIATE')
|
|
row = conn.execute('SELECT conv_id, status FROM human_sessions WHERE id=?', (sid,)).fetchone()
|
|
if not row:
|
|
conn.close()
|
|
return ({'ok': False, 'error': 'session_not_found'}, 404)
|
|
if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]):
|
|
conn.close()
|
|
return ({'ok': False, 'error': 'conversation_changed'}, 409)
|
|
if getattr(g, 'staff', None):
|
|
error = routing_service.owner_error(conn, sid, g.staff['username'])
|
|
if error:
|
|
conn.close()
|
|
return error
|
|
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now))
|
|
conn.commit()
|
|
conn.close()
|
|
return {'ok': True}
|
|
|
|
|
|
def human_queue():
|
|
now = datetime.now(SERVICE_TIMEZONE).replace(tzinfo=None)
|
|
conn = connect()
|
|
rows = conn.execute("SELECT h.id,h.conv_id,h.status,h.ctime,h.group_id,g.name FROM human_sessions h "
|
|
"JOIN support_groups g ON g.id=h.group_id WHERE h.status='waiting' "
|
|
"OR (h.status='active' AND h.owner_username IS NULL) ORDER BY h.id").fetchall()
|
|
conn.close()
|
|
queue = []
|
|
for row in rows:
|
|
try:
|
|
created = DateTime.fromisoformat(row[3])
|
|
if created.strftime('%Y-%m-%d %H:%M:%S') != row[3]:
|
|
raise ValueError('Invalid queue timestamp')
|
|
elapsed = max(0, int((now-created).total_seconds()))
|
|
except (ValueError, TypeError):
|
|
elapsed = 0
|
|
overdue = elapsed > QUEUE_TIMEOUT_SECONDS
|
|
queue.append({'id':row[0], 'conv_id':row[1], 'ctime':row[3],
|
|
'group_id':row[4], 'group_name':row[5],
|
|
'waiting_minutes':elapsed//60, 'overdue':overdue,
|
|
'overdue_minutes':max(1,ceil((elapsed-QUEUE_TIMEOUT_SECONDS)/60)) if overdue else 0})
|
|
return queue
|
|
|
|
|
|
def human_active():
|
|
conn = connect()
|
|
rows = conn.execute("SELECT h.id,h.conv_id,h.owner_username,h.group_id,g.name FROM human_sessions h "
|
|
"JOIN support_groups g ON g.id=h.group_id WHERE h.status='active' "
|
|
"AND h.owner_username=? ORDER BY h.id", (g.staff['username'],)).fetchall()
|
|
conn.close()
|
|
return [{'id': r[0], 'conv_id': r[1], 'owner_username':r[2], 'group_id':r[3], 'group_name':r[4]} for r in rows]
|
|
|
|
|
|
def human_accept(data):
|
|
sid = data.get('sid')
|
|
conn = connect()
|
|
conn.execute('BEGIN IMMEDIATE')
|
|
row = conn.execute("SELECT group_id,owner_username,status FROM human_sessions WHERE id=? "
|
|
"AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)", (sid,)).fetchone()
|
|
if not row or row[2] == 'closed' or (row[1] and row[1] != g.staff['username']):
|
|
conn.close()
|
|
return {'ok': False, 'error': '会话已由其他客服接入或已结束,请刷新。'}, 409
|
|
if not row[1]:
|
|
# Explicit manual claims remain available when staff are offline.
|
|
routing_service.assign_owner(conn, sid, g.staff['username'], row[0], 'claim', g.staff['username'])
|
|
conn.commit()
|
|
conn.close()
|
|
return {'ok': True}
|
|
|
|
|
|
def human_close(data):
|
|
sid = data.get('sid')
|
|
conn = connect()
|
|
conn.execute('BEGIN IMMEDIATE')
|
|
if getattr(g, 'staff', None):
|
|
error = routing_service.owner_error(conn, sid, g.staff['username'], allow_unassigned=True)
|
|
if error:
|
|
conn.close()
|
|
return error
|
|
changed = conn.execute("UPDATE human_sessions SET status='closed',routing_version=routing_version+1 "
|
|
"WHERE id=? AND status!='closed'", (sid,)).rowcount
|
|
if changed:
|
|
routing_service.record_event(conn, sid, 'close', g.staff['username'] if getattr(g, 'staff', None) else 'customer')
|
|
conn.commit()
|
|
conn.close()
|
|
return {'ok': True}
|
|
|
|
|
|
def human_transfer(data, typing_map):
|
|
sid = data.get('sid')
|
|
conn = connect()
|
|
conn.execute('BEGIN IMMEDIATE')
|
|
error = routing_service.owner_error(conn, sid, g.staff['username'])
|
|
if error:
|
|
conn.close()
|
|
return error
|
|
changed = conn.execute("UPDATE human_sessions SET status='waiting',previous_owner=owner_username,owner_username=NULL,"
|
|
"assigned_at=NULL,routing_version=routing_version+1 WHERE id=? AND status='active'", (sid,)).rowcount
|
|
if not changed:
|
|
conn.close()
|
|
return ({'ok': False, 'error': '会话当前无法转接'}, 409)
|
|
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)',
|
|
(sid, 'system', '客服已转接,请其他客服接入', datetime.now().strftime('%Y-%m-%d %H:%M:%S')))
|
|
routing_service.record_event(conn, sid, 'transfer', g.staff['username'])
|
|
conn.commit()
|
|
conn.close()
|
|
typing_map.pop(str(sid), None)
|
|
return {'ok': True}
|
|
|
|
|
|
def human_note(data):
|
|
note = data.get('note', '')
|
|
if not isinstance(note, str):
|
|
return ({'ok': False}, 400)
|
|
conn = connect()
|
|
conn.execute('BEGIN IMMEDIATE')
|
|
error = routing_service.owner_error(conn, data.get('sid'), g.staff['username'])
|
|
if error:
|
|
conn.close()
|
|
return error
|
|
changed = conn.execute('UPDATE human_sessions SET note=? WHERE id=?', (note, data.get('sid'))).rowcount
|
|
conn.commit()
|
|
conn.close()
|
|
return {'ok': True} if changed else ({'ok': False}, 404)
|
|
|
|
|
|
def human_recall(data):
|
|
conn = connect()
|
|
mid = data.get('mid')
|
|
# Only the first request can change the original message; retries emit no duplicate event.
|
|
conn.execute('BEGIN IMMEDIATE')
|
|
row = conn.execute('SELECT session_id, side, content FROM human_messages WHERE id=?', (mid,)).fetchone()
|
|
if not row:
|
|
conn.close()
|
|
return ({'ok': False}, 404)
|
|
error = routing_service.owner_error(conn, row[0], g.staff['username'])
|
|
if error:
|
|
conn.close()
|
|
return error
|
|
if row[1] == 'system' and row[2] == '该消息已撤回':
|
|
conn.close()
|
|
return {'ok': True}
|
|
if row[1] != 'agent':
|
|
conn.close()
|
|
return ({'ok': False}, 400)
|
|
conn.execute("UPDATE human_messages SET side='system', content='该消息已撤回' WHERE id=?", (mid,))
|
|
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime, recalled_id) VALUES(?,?,?,?,?)',
|
|
(row[0], 'system', '该消息已撤回', datetime.now().strftime('%Y-%m-%d %H:%M:%S'), mid))
|
|
conn.commit()
|
|
conn.close()
|
|
return {'ok': True}
|
|
|
|
|
|
def human_typing(data, typing_map):
|
|
sid = data.get('sid')
|
|
error = _typing_owner_error(sid)
|
|
if error:
|
|
return error
|
|
typing_map[str(sid)] = time.time()
|
|
return {'ok': True}
|
|
|
|
|
|
def human_stop_typing(data, typing_map):
|
|
sid = data.get('sid')
|
|
error = _typing_owner_error(sid)
|
|
if error:
|
|
return error
|
|
typing_map.pop(str(sid), None)
|
|
return {'ok': True}
|
|
|
|
|
|
def human_read(data):
|
|
sid = data.get('sid')
|
|
conn = connect()
|
|
conn.execute('BEGIN IMMEDIATE')
|
|
error = routing_service.owner_error(conn, sid, g.staff['username'])
|
|
if error:
|
|
conn.close()
|
|
return error
|
|
conn.execute("UPDATE human_messages SET read_at=? WHERE session_id=? AND side='customer' AND read_at IS NULL", (datetime.now().strftime('%Y-%m-%d %H:%M:%S'), sid))
|
|
conn.commit()
|
|
conn.close()
|
|
return {'ok': True}
|
|
|
|
|
|
def _typing_owner_error(sid):
|
|
connection = connect()
|
|
try:
|
|
return routing_service.owner_error(connection, sid, g.staff['username'])
|
|
finally:
|
|
connection.close()
|