"""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