233 lines
13 KiB
Python
233 lines
13 KiB
Python
"""Persistent staff availability, group membership and serialized conversation assignment."""
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
import sqlite3
|
||
import time
|
||
|
||
from .db import connect
|
||
|
||
PRESENCE_SECONDS = 90
|
||
CHINA_TIME = timezone(timedelta(hours=8))
|
||
|
||
|
||
def init_routing_tables(connection):
|
||
connection.execute('''CREATE TABLE IF NOT EXISTS support_groups (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE
|
||
)''')
|
||
connection.execute("INSERT OR IGNORE INTO support_groups(id,name) VALUES(1,'综合客服')")
|
||
# Separate profiles retain compatibility with legacy three-column agents tables.
|
||
connection.execute('''CREATE TABLE IF NOT EXISTS staff_routing (
|
||
username TEXT PRIMARY KEY, group_id INTEGER NOT NULL DEFAULT 1,
|
||
last_assigned REAL NOT NULL DEFAULT 0
|
||
)''')
|
||
connection.execute('''CREATE TABLE IF NOT EXISTS staff_presence (
|
||
token_hash TEXT PRIMARY KEY, username TEXT NOT NULL,
|
||
online INTEGER NOT NULL DEFAULT 0, last_seen REAL NOT NULL
|
||
)''')
|
||
connection.execute('CREATE INDEX IF NOT EXISTS idx_presence_username ON staff_presence(username)')
|
||
columns = {row[1] for row in connection.execute('PRAGMA table_info(human_sessions)')}
|
||
for name, definition in (
|
||
('group_id', 'INTEGER NOT NULL DEFAULT 1'), ('owner_username', 'TEXT'),
|
||
('assigned_at', 'TEXT'), ('routing_version', 'INTEGER NOT NULL DEFAULT 0'),
|
||
('previous_owner', 'TEXT')):
|
||
if name not in columns:
|
||
connection.execute(f'ALTER TABLE human_sessions ADD COLUMN {name} {definition}')
|
||
connection.execute('CREATE INDEX IF NOT EXISTS idx_human_routing ON human_sessions(status,group_id,owner_username,id)')
|
||
connection.execute('CREATE INDEX IF NOT EXISTS idx_human_owner ON human_sessions(owner_username,status)')
|
||
connection.execute('''CREATE TABLE IF NOT EXISTS routing_events (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL,
|
||
action TEXT NOT NULL, actor TEXT NOT NULL, group_id INTEGER NOT NULL,
|
||
owner_username TEXT, ctime TEXT NOT NULL
|
||
)''')
|
||
connection.execute('CREATE INDEX IF NOT EXISTS idx_routing_events_session ON routing_events(session_id,id)')
|
||
connection.execute('''CREATE TRIGGER IF NOT EXISTS routing_delete_agent AFTER DELETE ON agents BEGIN
|
||
DELETE FROM staff_routing WHERE username=OLD.username;
|
||
DELETE FROM staff_presence WHERE username=OLD.username;
|
||
UPDATE human_sessions SET status='waiting', owner_username=NULL, assigned_at=NULL,
|
||
previous_owner=NULL, routing_version=routing_version+1
|
||
WHERE owner_username=OLD.username AND status='active';
|
||
END''')
|
||
|
||
|
||
def timestamp():
|
||
return datetime.now(CHINA_TIME).strftime('%Y-%m-%d %H:%M:%S')
|
||
|
||
|
||
def record_event(connection, sid, action, actor):
|
||
connection.execute('''INSERT INTO routing_events(session_id,action,actor,group_id,owner_username,ctime)
|
||
SELECT id,?,?,group_id,owner_username,? FROM human_sessions WHERE id=?''',
|
||
(action, actor, timestamp(), sid))
|
||
|
||
|
||
def _online_agents(connection, instant):
|
||
return {row[0] for row in connection.execute('''SELECT DISTINCT p.username
|
||
FROM staff_presence p JOIN staff_sessions s ON s.token_hash=p.token_hash AND s.username=p.username
|
||
JOIN agents a ON a.username=p.username
|
||
WHERE p.online=1 AND p.last_seen>? AND s.expires_at>?''',
|
||
(instant - PRESENCE_SECONDS, instant))}
|
||
|
||
|
||
def _agents(connection, instant):
|
||
online = _online_agents(connection, instant)
|
||
return [dict(username=row[0], name=row[1], group_id=row[2], group_name=row[3],
|
||
active_count=row[4], online=row[0] in online, last_assigned=row[5])
|
||
for row in connection.execute('''SELECT a.username,a.name,COALESCE(r.group_id,1),g.name,
|
||
(SELECT COUNT(*) FROM human_sessions h WHERE h.owner_username=a.username AND h.status='active'),
|
||
COALESCE(r.last_assigned,0)
|
||
FROM agents a LEFT JOIN staff_routing r ON r.username=a.username
|
||
JOIN support_groups g ON g.id=COALESCE(r.group_id,1) ORDER BY a.username''')]
|
||
|
||
|
||
def assign_owner(connection, sid, username, group_id, action, actor):
|
||
connection.execute('''UPDATE human_sessions SET owner_username=?,group_id=?,status='active',
|
||
assigned_at=?,previous_owner=NULL,routing_version=routing_version+1 WHERE id=?''',
|
||
(username, group_id, timestamp(), sid))
|
||
connection.execute('''INSERT INTO staff_routing(username,group_id,last_assigned) VALUES(?,1,?)
|
||
ON CONFLICT(username) DO UPDATE SET last_assigned=excluded.last_assigned''',
|
||
(username, time.time()))
|
||
record_event(connection, sid, action, actor)
|
||
|
||
|
||
def assign_new_session(connection, sid):
|
||
"""Try once at creation under BEGIN IMMEDIATE; existing queue needs manual acceptance."""
|
||
row = connection.execute('''SELECT group_id FROM human_sessions
|
||
WHERE id=? AND status='waiting' AND owner_username IS NULL
|
||
AND EXISTS(SELECT 1 FROM chat_history c WHERE c.id=human_sessions.conv_id)''', (sid,)).fetchone()
|
||
if not row:
|
||
return
|
||
eligible = [agent for agent in _agents(connection, time.time())
|
||
if agent['online'] and agent['group_id'] == row[0] and agent['active_count'] == 0]
|
||
if eligible:
|
||
selected = min(eligible, key=lambda agent: (agent['last_assigned'], agent['username']))
|
||
assign_owner(connection, sid, selected['username'], row[0], 'auto_assign', 'system')
|
||
|
||
|
||
def _presence(connection, token, instant):
|
||
row = connection.execute('''SELECT online,last_seen FROM staff_presence WHERE token_hash=?''', (token,)).fetchone()
|
||
return {'online': bool(row and row[0] and row[1] > instant - PRESENCE_SECONDS)}
|
||
|
||
|
||
def routing_snapshot(username, token):
|
||
connection = connect()
|
||
try:
|
||
connection.execute('BEGIN')
|
||
groups = [dict(id=row[0], name=row[1]) for row in connection.execute('SELECT id,name FROM support_groups ORDER BY id')]
|
||
sessions = [dict(id=row[0], status=row[1], group_id=row[2], group_name=row[3],
|
||
owner_username=row[4], owner_name=row[5], version=row[6], ctime=row[7])
|
||
for row in connection.execute('''SELECT h.id,h.status,h.group_id,g.name,h.owner_username,
|
||
a.name,h.routing_version,h.ctime FROM human_sessions h
|
||
JOIN support_groups g ON g.id=h.group_id LEFT JOIN agents a ON a.username=h.owner_username
|
||
WHERE h.status IN ('waiting','active') ORDER BY h.id''')]
|
||
events = [dict(id=row[0], session_id=row[1], action=row[2], actor=row[3], group_name=row[4],
|
||
owner_username=row[5], ctime=row[6]) for row in connection.execute('''SELECT e.id,e.session_id,
|
||
e.action,e.actor,g.name,e.owner_username,e.ctime FROM routing_events e
|
||
JOIN support_groups g ON g.id=e.group_id ORDER BY e.id DESC LIMIT 50''')]
|
||
return {'ok': True, 'username': username, 'presence': _presence(connection, token, time.time()),
|
||
'groups': groups, 'agents': _agents(connection, time.time()), 'sessions': sessions, 'events': events}
|
||
finally:
|
||
connection.close()
|
||
|
||
|
||
def update_presence(username, token, data):
|
||
if not isinstance(data, dict) or ('online' in data and type(data['online']) is not bool):
|
||
return {'ok': False, 'error': '请选择在线或离线。'}, 400
|
||
instant = time.time()
|
||
connection = connect()
|
||
try:
|
||
connection.execute('BEGIN IMMEDIATE')
|
||
if 'online' in data:
|
||
# Going offline applies across this account's logged-in browsers.
|
||
if not data['online']:
|
||
connection.execute('UPDATE staff_presence SET online=0 WHERE username=?', (username,))
|
||
connection.execute('''INSERT INTO staff_presence VALUES(?,?,?,?)
|
||
ON CONFLICT(token_hash) DO UPDATE SET online=excluded.online,last_seen=excluded.last_seen''',
|
||
(token, username, int(data['online']), instant))
|
||
else:
|
||
# Heartbeats never turn an offline/expired presence back on implicitly.
|
||
connection.execute('''UPDATE staff_presence SET online=CASE WHEN last_seen>? THEN online ELSE 0 END,
|
||
last_seen=? WHERE token_hash=?''', (instant - PRESENCE_SECONDS, instant, token))
|
||
connection.execute('DELETE FROM staff_presence WHERE NOT EXISTS '
|
||
'(SELECT 1 FROM staff_sessions s WHERE s.token_hash=staff_presence.token_hash AND s.expires_at>?)', (instant,))
|
||
result = {'ok': True, **_presence(connection, token, instant)}
|
||
connection.commit()
|
||
return result
|
||
finally:
|
||
connection.close()
|
||
|
||
|
||
def create_group(data):
|
||
name = data.get('name') if isinstance(data, dict) else None
|
||
if not isinstance(name, str) or not 1 <= len(name.strip()) <= 40:
|
||
return {'ok': False, 'error': '分组名称需为 1–40 字。'}, 400
|
||
connection = connect()
|
||
try:
|
||
try:
|
||
cursor = connection.execute('INSERT INTO support_groups(name) VALUES(?)', (name.strip(),))
|
||
connection.commit()
|
||
except sqlite3.IntegrityError:
|
||
return {'ok': False, 'error': '已存在同名分组。'}, 409
|
||
return {'ok': True, 'group': {'id': cursor.lastrowid, 'name': name.strip()}}
|
||
finally:
|
||
connection.close()
|
||
|
||
|
||
def _positive_id(value):
|
||
return type(value) is int and 0 < value <= 9223372036854775807
|
||
|
||
|
||
def move_member(data):
|
||
if not isinstance(data, dict) or not isinstance(data.get('username'), str) or not _positive_id(data.get('group_id')):
|
||
return {'ok': False, 'error': '请选择客服与分组。'}, 400
|
||
connection = connect()
|
||
try:
|
||
connection.execute('BEGIN IMMEDIATE')
|
||
if not connection.execute('SELECT 1 FROM agents WHERE username=?', (data['username'],)).fetchone() or not connection.execute(
|
||
'SELECT 1 FROM support_groups WHERE id=?', (data['group_id'],)).fetchone():
|
||
return {'ok': False, 'error': '客服或分组已不存在,请刷新。'}, 404
|
||
connection.execute('''INSERT INTO staff_routing(username,group_id) VALUES(?,?)
|
||
ON CONFLICT(username) DO UPDATE SET group_id=excluded.group_id''', (data['username'], data['group_id']))
|
||
connection.commit()
|
||
return {'ok': True}
|
||
finally:
|
||
connection.close()
|
||
|
||
|
||
def reassign_session(data, actor):
|
||
if not isinstance(data, dict) or not _positive_id(data.get('sid')) or not _positive_id(data.get('group_id')) or (
|
||
type(data.get('version')) is not int or data['version'] < 0 or data['version'] > 9223372036854775807) or (
|
||
data.get('username') is not None and not isinstance(data['username'], str)):
|
||
return {'ok': False, 'error': '请选择会话、目标分组与负责人。'}, 400
|
||
connection = connect()
|
||
try:
|
||
connection.execute('BEGIN IMMEDIATE')
|
||
row = connection.execute('SELECT status,routing_version FROM human_sessions WHERE id=?', (data['sid'],)).fetchone()
|
||
if not row or row[0] == 'closed' or row[1] != data['version']:
|
||
return {'ok': False, 'error': '会话已更新或结束,请刷新后重新选择。'}, 409
|
||
if not connection.execute('SELECT 1 FROM support_groups WHERE id=?', (data['group_id'],)).fetchone():
|
||
return {'ok': False, 'error': '目标分组已不存在,请刷新。'}, 404
|
||
username = data.get('username') or None
|
||
if username:
|
||
selected = next((agent for agent in _agents(connection, time.time()) if agent['username'] == username), None)
|
||
if not selected or not selected['online'] or selected['group_id'] != data['group_id']:
|
||
return {'ok': False, 'error': '目标客服需在所选组内且处于在线状态。'}, 409
|
||
assign_owner(connection, data['sid'], username, data['group_id'], 'reassign', actor)
|
||
else:
|
||
connection.execute('''UPDATE human_sessions SET group_id=?,owner_username=NULL,status='waiting',
|
||
assigned_at=NULL,previous_owner=NULL,routing_version=routing_version+1 WHERE id=?''',
|
||
(data['group_id'], data['sid']))
|
||
record_event(connection, data['sid'], 'queue', actor)
|
||
connection.commit()
|
||
return {'ok': True}
|
||
finally:
|
||
connection.close()
|
||
|
||
|
||
def owner_error(connection, sid, username, allow_unassigned=False):
|
||
"""Call inside a write transaction; assigned sessions cannot be operated by another staff member."""
|
||
row = connection.execute('SELECT owner_username FROM human_sessions WHERE id=?', (sid,)).fetchone()
|
||
if row and not row[0] and not allow_unassigned:
|
||
return {'ok': False, 'error': '请先接入会话,再进行操作。'}, 409
|
||
if row and row[0] and row[0] != username:
|
||
return {'ok': False, 'error': '会话已由其他客服负责,请刷新会话列表。'}, 409
|
||
return None
|