mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-11-01 18:42:56 +00:00
Plan rule 3c said every module this refactor creates is type-checked from its first commit; web_viewer/tracking.py was on the ignore list because it moved out of app.py with app's existing errors, and mypy skipped the bodies of untyped helpers in all the new modules. tracking.py is off the ignore list, and the 30 new modules get check_untyped_defs. The 25 errors that surfaced were all missing annotations: container annotations (paths_by_key, tracking, server_stats, result, daily_by_role, feed params), casts for page/page_size where pagination guarantees ints, FeedSubscriptionsMixin's two host attributes, and an ignore for Flask-SocketIO's request.sid. No runtime change.
277 lines
13 KiB
Python
277 lines
13 KiB
Python
"""Socket.IO clients for the web viewer: connect/disconnect and stream
|
|
subscription handlers, history replay to a new subscriber, logout disconnects
|
|
and stale-client cleanup."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
from contextlib import closing, suppress
|
|
from typing import Any
|
|
|
|
from flask import request, session
|
|
from flask_socketio import disconnect, emit
|
|
|
|
from modules.utils import resolve_path
|
|
from modules.web_viewer.live_stream import _strip_ansi_codes
|
|
|
|
|
|
class SocketClientsMixin:
|
|
"""Mixed into BotDataViewer."""
|
|
|
|
_clients_lock: Any
|
|
_config_base: Any
|
|
config: Any
|
|
connected_clients: Any
|
|
db_path: Any
|
|
logger: Any
|
|
max_clients: Any
|
|
socketio: Any
|
|
web_viewer_password: Any
|
|
|
|
def _setup_socketio_handlers(self):
|
|
"""Setup SocketIO event handlers using modern patterns"""
|
|
|
|
@self.socketio.on('connect')
|
|
def handle_connect():
|
|
"""Handle client connection"""
|
|
try:
|
|
client_id = request.sid # type: ignore[attr-defined] # Flask-SocketIO sets sid on the request
|
|
if not client_id:
|
|
self.logger.warning("Connect event received but client_id is None")
|
|
return False
|
|
|
|
# Reject unauthenticated SocketIO connections when auth is enabled.
|
|
# Live packet/message/log streams stay admin-only — they can carry
|
|
# decrypted channel traffic and operator logs (deviation from #240).
|
|
if self.web_viewer_password and not session.get('authenticated_admin'):
|
|
self.logger.warning(f"Rejected unauthenticated SocketIO connection from {client_id}")
|
|
with suppress(Exception):
|
|
disconnect()
|
|
return False
|
|
|
|
self.logger.info(f"Client connected: {client_id}")
|
|
|
|
with self._clients_lock:
|
|
# Check client limit
|
|
if len(self.connected_clients) >= self.max_clients:
|
|
self.logger.warning(f"Client limit reached ({self.max_clients}), rejecting connection")
|
|
try:
|
|
disconnect()
|
|
except Exception as e:
|
|
self.logger.error(f"Error disconnecting client: {e}")
|
|
return False
|
|
|
|
# Track client
|
|
self.connected_clients[client_id] = {
|
|
'admin_login_id': session.get('admin_login_id'),
|
|
'connected_at': time.time(),
|
|
'last_activity': time.time(),
|
|
'subscribed_commands': False,
|
|
'subscribed_packets': False,
|
|
'subscribed_messages': False,
|
|
'subscribed_mesh': False,
|
|
'subscribed_logs': False,
|
|
}
|
|
|
|
# Connection status is shown via the green indicator in the navbar, no toast needed
|
|
self.logger.info(f"Client {client_id} connected. Total clients: {len(self.connected_clients)}")
|
|
except Exception as e:
|
|
self.logger.error(f"Error in handle_connect: {e}", exc_info=True)
|
|
return False
|
|
|
|
@self.socketio.on('disconnect')
|
|
def handle_disconnect(data=None):
|
|
"""Handle client disconnection"""
|
|
try:
|
|
# Safely get client_id - it may be None if disconnect happens during error state
|
|
client_id = getattr(request, 'sid', None)
|
|
with self._clients_lock:
|
|
if client_id and client_id in self.connected_clients:
|
|
del self.connected_clients[client_id]
|
|
self.logger.info(f"Client {client_id} disconnected. Total clients: {len(self.connected_clients)}")
|
|
elif client_id:
|
|
# Client disconnected but wasn't in our tracking dict (might have been cleaned up)
|
|
self.logger.debug(f"Client {client_id} disconnected (not in tracking dict)")
|
|
else:
|
|
# No client_id available - this can happen during error states
|
|
self.logger.debug("Disconnect event received but client_id is None")
|
|
except Exception as e:
|
|
# Don't emit errors during disconnect as the connection may be broken
|
|
self.logger.error(f"Error in handle_disconnect: {e}", exc_info=True)
|
|
|
|
@self.socketio.on('subscribe_commands')
|
|
def handle_subscribe_commands():
|
|
"""Handle command stream subscription — also replays recent history to the new subscriber."""
|
|
try:
|
|
client_id = self._mark_subscribed('subscribed_commands')
|
|
# Keep connection/subscription success silent; navbar indicator already shows socket state.
|
|
self.logger.debug(f"Client {client_id} subscribed to commands")
|
|
# Replay recent command history so the page isn't blank on load (BUG-023 fix)
|
|
try:
|
|
self._replay_packet_stream("type = 'command'", lambda _type: 'command_data')
|
|
except Exception as e:
|
|
self.logger.warning(f"Error replaying command history: {e}", exc_info=True)
|
|
except Exception as e:
|
|
self.logger.error(f"Error in handle_subscribe_commands: {e}", exc_info=True)
|
|
|
|
@self.socketio.on('subscribe_packets')
|
|
def handle_subscribe_packets():
|
|
"""Handle packet stream subscription — also replays recent history to the new subscriber."""
|
|
try:
|
|
client_id = self._mark_subscribed('subscribed_packets')
|
|
self.logger.debug(f"Client {client_id} subscribed to packets")
|
|
# Replay recent packet/command/routing history so the page isn't blank on load
|
|
try:
|
|
self._replay_packet_stream(
|
|
"type IN ('packet','command','routing')",
|
|
lambda _type: 'command_data' if _type == 'command' else 'packet_data',
|
|
)
|
|
except Exception as e:
|
|
self.logger.warning(f"Error replaying packet history: {e}", exc_info=True)
|
|
except Exception as e:
|
|
self.logger.error(f"Error in handle_subscribe_packets: {e}", exc_info=True)
|
|
|
|
@self.socketio.on('subscribe_mesh')
|
|
def handle_subscribe_mesh():
|
|
"""Handle mesh graph stream subscription"""
|
|
try:
|
|
client_id = self._mark_subscribed('subscribed_mesh')
|
|
self.logger.debug(f"Client {client_id} subscribed to mesh graph")
|
|
except Exception as e:
|
|
self.logger.error(f"Error in handle_subscribe_mesh: {e}", exc_info=True)
|
|
|
|
@self.socketio.on('subscribe_messages')
|
|
def handle_subscribe_messages():
|
|
"""Handle live channel message stream subscription — also replays recent messages."""
|
|
try:
|
|
client_id = self._mark_subscribed('subscribed_messages')
|
|
self.logger.debug(f"Client {client_id} subscribed to messages")
|
|
# Replay recent channel messages so the page isn't blank on load
|
|
try:
|
|
self._replay_packet_stream("type = 'message'", lambda _type: 'message_data')
|
|
except Exception as e:
|
|
self.logger.warning(f"Error replaying message history: {e}", exc_info=True)
|
|
except Exception as e:
|
|
self.logger.error(f"Error in handle_subscribe_messages: {e}", exc_info=True)
|
|
|
|
@self.socketio.on('subscribe_logs')
|
|
def handle_subscribe_logs():
|
|
"""Handle live log stream subscription — also sends last 200 log lines to the new subscriber."""
|
|
try:
|
|
client_id = self._mark_subscribed('subscribed_logs')
|
|
self.logger.debug(f"Client {client_id} subscribed to logs")
|
|
# Send recent log history so the page isn't blank on load
|
|
log_file = ''
|
|
try:
|
|
log_file = self.config.get('Logging', 'log_file', fallback='').strip()
|
|
if log_file:
|
|
log_file = str(resolve_path(log_file, self._config_base))
|
|
except (configparser.Error, OSError, ValueError): # bad config or inaccessible path
|
|
pass
|
|
if log_file and os.path.exists(log_file):
|
|
try:
|
|
with open(log_file, encoding='utf-8', errors='replace') as _fh:
|
|
recent_lines = _fh.readlines()[-200:]
|
|
for line in recent_lines:
|
|
emit('log_line', {'line': _strip_ansi_codes(line.rstrip())})
|
|
except Exception as e:
|
|
self.logger.debug(f"Error reading log history: {e}")
|
|
except Exception as e:
|
|
self.logger.error(f"Error in handle_subscribe_logs: {e}", exc_info=True)
|
|
|
|
@self.socketio.on('ping')
|
|
def handle_ping():
|
|
"""Handle client ping (modern ping/pong pattern)"""
|
|
try:
|
|
client_id = getattr(request, 'sid', None)
|
|
with self._clients_lock:
|
|
if client_id and client_id in self.connected_clients:
|
|
self.connected_clients[client_id]['last_activity'] = time.time()
|
|
emit('pong') # Server responds with pong (Flask-SocketIO 5.x pattern)
|
|
except Exception as e:
|
|
self.logger.error(f"Error in handle_ping: {e}", exc_info=True)
|
|
|
|
@self.socketio.on_error_default
|
|
def default_error_handler(e):
|
|
"""Handle SocketIO errors gracefully"""
|
|
try:
|
|
self.logger.error(f"SocketIO error: {e}", exc_info=True)
|
|
# Only emit if we have a valid request context
|
|
if hasattr(request, 'sid') and request.sid:
|
|
emit('error', {'message': str(e)})
|
|
except Exception as emit_error:
|
|
# If we can't emit, just log it
|
|
self.logger.error(f"Error emitting error message: {emit_error}")
|
|
|
|
def _mark_subscribed(self, flag: str):
|
|
"""Set a stream flag on the requesting Socket.IO client; returns its sid (may be None)."""
|
|
client_id = getattr(request, 'sid', None)
|
|
with self._clients_lock:
|
|
if client_id and client_id in self.connected_clients:
|
|
self.connected_clients[client_id][flag] = True
|
|
return client_id
|
|
|
|
def _replay_packet_stream(self, where: str, event_for_type) -> None:
|
|
"""Emit the newest 50 packet_stream rows matching *where* to the requesting client, oldest first.
|
|
|
|
``event_for_type`` maps a row's type to its event name; rows whose data
|
|
does not parse are skipped.
|
|
"""
|
|
with closing(sqlite3.connect(self.db_path, timeout=10, check_same_thread=False)) as _conn:
|
|
_conn.row_factory = sqlite3.Row
|
|
_cur = _conn.cursor()
|
|
_cur.execute(
|
|
"SELECT data, type FROM packet_stream"
|
|
f" WHERE {where}"
|
|
" ORDER BY timestamp DESC LIMIT 50"
|
|
)
|
|
rows = list(reversed(_cur.fetchall()))
|
|
for row in rows:
|
|
try:
|
|
data = json.loads(row['data'])
|
|
emit(event_for_type(row['type']), data)
|
|
except (json.JSONDecodeError, KeyError, TypeError):
|
|
pass
|
|
|
|
def _disconnect_login_sockets(self, login_id):
|
|
"""Disconnect every Socket.IO client opened under the given admin login."""
|
|
with self._clients_lock:
|
|
sids = [
|
|
sid for sid, info in self.connected_clients.items()
|
|
if info.get('admin_login_id') == login_id
|
|
]
|
|
for sid in sids:
|
|
try:
|
|
self.socketio.server.disconnect(sid, namespace='/')
|
|
except Exception as e:
|
|
self.logger.warning(f"Could not disconnect socket {sid} on logout: {e}")
|
|
with self._clients_lock:
|
|
self.connected_clients.pop(sid, None)
|
|
if sids:
|
|
self.logger.info(f"Logout disconnected {len(sids)} live socket(s)")
|
|
|
|
def _cleanup_stale_clients(self, max_idle_seconds: int = 300):
|
|
"""Remove clients that haven't had activity in max_idle_seconds"""
|
|
try:
|
|
current_time = time.time()
|
|
stale_clients = []
|
|
|
|
with self._clients_lock:
|
|
for client_id, client_info in self.connected_clients.items():
|
|
last_activity = client_info.get('last_activity', 0)
|
|
if current_time - last_activity > max_idle_seconds:
|
|
stale_clients.append(client_id)
|
|
|
|
for client_id in stale_clients:
|
|
del self.connected_clients[client_id]
|
|
|
|
if stale_clients:
|
|
self.logger.info(f"Cleaned up {len(stale_clients)} stale client(s)")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error cleaning up stale clients: {e}")
|