hivemind-admin-panel 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ # hivemind-admin-panel
2
+ # Copyright (C) 2026 Casimiro Ferreira
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """HiveMind Admin - Web-based management UI for HiveMind-core.
5
+
6
+ This module provides a web-based administration interface for HiveMind-core,
7
+ allowing management of clients, permissions, and server configuration via
8
+ a REST API and web UI.
9
+
10
+ When launched with the in-process hivemind-core (the default), this module gets
11
+ direct access to internal HiveMind-core objects for real-time monitoring.
12
+ """
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ from ovos_utils import create_daemon
17
+ from ovos_utils.log import LOG
18
+
19
+ if TYPE_CHECKING:
20
+ from hivemind_core.service import HiveMindService
21
+ from hivemind_core.database import ClientDatabase
22
+ from hivemind_core.protocol import HiveMindListenerProtocol
23
+
24
+ __version__ = "0.2.0"
25
+ __all__ = ["start_admin_server", "init_injected_objects", "get_admin_app"]
26
+
27
+
28
+ def init_injected_objects(
29
+ service: "HiveMindService" = None,
30
+ db: "ClientDatabase" = None,
31
+ protocol: "HiveMindListenerProtocol" = None,
32
+ startup_error: Exception = None
33
+ ) -> None:
34
+ """Initialize admin with direct access to core objects.
35
+
36
+ Args:
37
+ service: HiveMindService instance.
38
+ db: ClientDatabase instance.
39
+ protocol: HiveMindListenerProtocol instance.
40
+ startup_error: Exception if core failed to start.
41
+ """
42
+ from hivemind_admin_panel.api import init_injected_objects as _init
43
+ _init(service=service, db=db, protocol=protocol, logger=LOG, startup_error=startup_error)
44
+
45
+
46
+ def start_admin_server(
47
+ host: str = "127.0.0.1",
48
+ port: int = 8000,
49
+ reload: bool = False,
50
+ ) -> None:
51
+ """Start the HiveMind Admin web server.
52
+
53
+ This function starts a uvicorn server hosting the FastAPI admin interface.
54
+ It should be called after hivemind-core service is running.
55
+
56
+ Args:
57
+ host: Host to bind the server (default: 127.0.0.1).
58
+ port: Port to bind the server (default: 8000).
59
+ reload: Enable auto-reload for development (default: False).
60
+
61
+ Note:
62
+ This function runs the server in a daemon thread and returns
63
+ immediately. The server will shut down when the main process exits.
64
+ """
65
+ import uvicorn
66
+ from hivemind_admin_panel.__main__ import app
67
+
68
+ def _run_server():
69
+ LOG.info(f"Starting HiveMind Admin UI at http://{host}:{port}")
70
+ LOG.info("Change admin credentials in ~/.config/hivemind-core/server.json (admin_user, admin_pass)")
71
+ uvicorn.run(
72
+ app,
73
+ host=host,
74
+ port=port,
75
+ reload=reload,
76
+ log_level="info",
77
+ )
78
+
79
+ # Run server in daemon thread
80
+ create_daemon(_run_server)
81
+ LOG.info("HiveMind Admin server thread started")
82
+
83
+
84
+ def get_admin_app():
85
+ """Get the FastAPI app instance for admin UI.
86
+
87
+ Returns:
88
+ FastAPI app configured with all admin routes.
89
+ """
90
+ from hivemind_admin_panel.api import get_admin_app
91
+ return get_admin_app()
@@ -0,0 +1,267 @@
1
+ # hivemind-admin-panel
2
+ # Copyright (C) 2026 Casimiro Ferreira
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Main entry point for the HiveMind Admin Panel.
5
+
6
+ This module provides the FastAPI application that serves both the REST API and
7
+ the static web UI, and is the single launcher for a HiveMind deployment: by
8
+ default it starts an in-process ``hivemind-core`` hivemind-core and keeps a live reference
9
+ to it, so operators run ``hivemind-admin-panel`` only — there is no separate
10
+ ``hivemind-core`` process to launch.
11
+
12
+ Example:
13
+ ```bash
14
+ # Launch hivemind-core + admin panel together (default)
15
+ hivemind-admin-panel --host 0.0.0.0 --port 8100
16
+
17
+ # Admin panel only, no in-process hivemind-core (manage on-disk state, or attach to a
18
+ # hivemind-core managed elsewhere on the host)
19
+ hivemind-admin-panel --no-core
20
+ ```
21
+ """
22
+
23
+ import argparse
24
+ from pathlib import Path
25
+
26
+ from fastapi import FastAPI
27
+ from fastapi.staticfiles import StaticFiles
28
+ from fastapi.responses import FileResponse
29
+
30
+ from hivemind_admin_panel.api import app as api_app
31
+ from hivemind_admin_panel.version import __version__
32
+
33
+ __all__ = ["app", "main"]
34
+
35
+ #: Directory containing static files (SPA web UI)
36
+ static_dir = Path(__file__).parent / "static"
37
+
38
+ #: Main FastAPI application instance
39
+ app = FastAPI(title="HiveMind Admin Panel")
40
+
41
+ # Mount the API app under /api prefix
42
+ app.mount("/api", api_app)
43
+
44
+ # Mount static files for direct access (CSS, JS, images)
45
+ if static_dir.exists():
46
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
47
+
48
+
49
+ @app.get("/")
50
+ async def root() -> FileResponse:
51
+ """Serve the main index.html file for the SPA.
52
+
53
+ Returns:
54
+ FileResponse: The index.html file from static directory.
55
+
56
+ Raises:
57
+ HTTPException: 404 if index.html is not present (e.g. package installed without static assets).
58
+ """
59
+ from fastapi import HTTPException
60
+ index_path = static_dir / "index.html"
61
+ if not index_path.exists():
62
+ raise HTTPException(status_code=404, detail="Admin UI static assets not found. "
63
+ "Reinstall with: pip install hivemind-admin-panel")
64
+ return FileResponse(index_path)
65
+
66
+
67
+ @app.get("/index.html")
68
+ async def index() -> FileResponse:
69
+ """Serve the main index.html file for the SPA.
70
+
71
+ Returns:
72
+ FileResponse: The index.html file from static directory.
73
+
74
+ Raises:
75
+ HTTPException: 404 if index.html is not present (e.g. package installed without static assets).
76
+ """
77
+ from fastapi import HTTPException
78
+ index_path = static_dir / "index.html"
79
+ if not index_path.exists():
80
+ raise HTTPException(status_code=404, detail="Admin UI static assets not found. "
81
+ "Reinstall with: pip install hivemind-admin-panel")
82
+ return FileResponse(index_path)
83
+
84
+
85
+ def _tracked_protocol(base, service):
86
+ """Subclass hivemind-core's listener protocol to feed the admin panel live state.
87
+
88
+ Captures the live protocol instance (so ``/connections``, ``/stats`` and the
89
+ topology become authoritative) and taps connection + message handlers to feed
90
+ the metrics event/message buffers — entirely panel-side, with no core change.
91
+ """
92
+ from hivemind_admin_panel.api import init_injected_objects
93
+ from hivemind_admin_panel._metrics import METRICS
94
+
95
+ class _Tracked(base):
96
+ def __init__(self, *a, **kw):
97
+ super().__init__(*a, **kw)
98
+ init_injected_objects(service=service, db=service.db, protocol=self)
99
+ METRICS.event("core.ready", "hivemind-core listener protocol online")
100
+
101
+ def handle_new_client(self, client):
102
+ METRICS.event("client.connected", f"{getattr(client, 'peer', '?')} connected")
103
+ return super().handle_new_client(client)
104
+
105
+ def handle_client_disconnected(self, client):
106
+ METRICS.event("client.disconnected", f"{getattr(client, 'peer', '?')} disconnected")
107
+ return super().handle_client_disconnected(client)
108
+
109
+ def handle_message(self, message, client):
110
+ try:
111
+ METRICS.message(str(getattr(message, "msg_type", "?")),
112
+ str(getattr(client, "peer", "?")))
113
+ except Exception:
114
+ pass
115
+ return super().handle_message(message, client)
116
+
117
+ def handle_invalid_key_connected(self, client):
118
+ METRICS.event("auth.rejected", f"invalid key from {getattr(client, 'peer', '?')}")
119
+ return super().handle_invalid_key_connected(client)
120
+
121
+ return _Tracked
122
+
123
+
124
+ def launch_core():
125
+ """Construct an in-process hivemind-core and inject it into the admin API.
126
+
127
+ Builds a ``HiveMindService`` and hands its live ``service``/``db`` objects to
128
+ the admin API via ``init_injected_objects``. It does **not** run hivemind-core —
129
+ ``HiveMindService.run()`` blocks on signal handlers and must execute on the
130
+ main thread (see :func:`main`). If construction fails, the error is injected
131
+ for diagnostics (surfaced at ``GET /api/startup-error``) and ``None`` is
132
+ returned so the panel can still come up.
133
+
134
+ Returns:
135
+ The ``HiveMindService`` instance, or ``None`` if construction failed.
136
+ """
137
+ from ovos_utils.log import LOG
138
+ from hivemind_admin_panel.api import init_injected_objects
139
+
140
+ try:
141
+ from hivemind_core.service import HiveMindService
142
+
143
+ service = HiveMindService()
144
+ init_injected_objects(service=service, db=service.db, protocol=None)
145
+ # Wrap the listener protocol so the panel gets the LIVE protocol instance
146
+ # (authoritative connections) and a tap on every HiveMessage — no core change.
147
+ service.hm_protocol = _tracked_protocol(service.hm_protocol, service)
148
+ LOG.info("hivemind-core constructed; will run in-process")
149
+ return service
150
+ except Exception as error:
151
+ LOG.exception("hivemind-core failed to start; admin panel running in diagnostics mode")
152
+ init_injected_objects(service=None, db=None, protocol=None, startup_error=error)
153
+ return None
154
+
155
+
156
+ def main() -> None:
157
+ """Launch the hivemind-core (in-process) and the admin panel.
158
+
159
+ By default this starts ``hivemind-core`` inside this process and then serves
160
+ the admin panel. Pass ``--no-core`` to serve the panel only.
161
+
162
+ Note:
163
+ Default credentials are admin/admin. Change them in
164
+ ~/.config/hivemind-core/server.json (admin_user, admin_pass).
165
+ """
166
+ parser = argparse.ArgumentParser(description="HiveMind Admin Panel (launches hivemind-core + admin UI)")
167
+ parser.add_argument(
168
+ "--host",
169
+ type=str,
170
+ default="127.0.0.1",
171
+ help="Admin panel host (default: 127.0.0.1)",
172
+ )
173
+ parser.add_argument(
174
+ "--port",
175
+ type=int,
176
+ default=8100,
177
+ help="Admin panel port (default: 8100)",
178
+ )
179
+ parser.add_argument(
180
+ "--no-core",
181
+ action="store_true",
182
+ help="Do not start an in-process hivemind-core; serve the admin panel only.",
183
+ )
184
+ parser.add_argument(
185
+ "--reload",
186
+ action="store_true",
187
+ help="Enable auto-reload for development (implies --no-core).",
188
+ )
189
+ parser.add_argument(
190
+ "--log-level",
191
+ type=str,
192
+ default="INFO",
193
+ help="Log level for the in-process hivemind-core (e.g. DEBUG, INFO, ERROR).",
194
+ )
195
+ parser.add_argument(
196
+ "--version",
197
+ action="version",
198
+ version=f"%(prog)s {__version__}",
199
+ help="Show version and exit",
200
+ )
201
+
202
+ args = parser.parse_args()
203
+
204
+ print(f"HiveMind Admin Panel v{__version__}")
205
+
206
+ # --reload runs uvicorn in a child process, which would not carry hivemind-core —
207
+ # so reload (a dev affordance) forces panel-only mode.
208
+ run_core = not args.no_core and not args.reload
209
+
210
+ from hivemind_admin_panel.api import set_runtime_info
211
+ set_runtime_info(run_mode="in-process" if run_core else "panel-only",
212
+ host=args.host)
213
+
214
+ if not run_core:
215
+ import uvicorn
216
+ print("hivemind-core: not started (--no-core)")
217
+ print(f"Admin panel: http://{args.host}:{args.port}")
218
+ print("Change admin credentials in server.json: admin_user, admin_pass")
219
+ uvicorn.run(
220
+ "hivemind_admin_panel.__main__:app",
221
+ host=args.host,
222
+ port=args.port,
223
+ reload=args.reload,
224
+ )
225
+ return
226
+
227
+ # Run-core mode. hivemind-core's run() installs SIGINT/SIGTERM handlers, which only
228
+ # work on the main thread — so hivemind-core runs on the main thread and the admin
229
+ # panel (uvicorn, which skips signal handlers off the main thread) runs in a
230
+ # daemon thread.
231
+ from ovos_utils import wait_for_exit_signal
232
+ from ovos_utils.log import init_service_logger, LOG
233
+ from hivemind_admin_panel import start_admin_server
234
+
235
+ init_service_logger("core")
236
+ LOG.set_level(args.log_level)
237
+
238
+ service = launch_core()
239
+ start_admin_server(host=args.host, port=args.port) # panel in a daemon thread
240
+ print("hivemind-core: running in-process")
241
+ print(f"Admin panel: http://{args.host}:{args.port}")
242
+ print("Change admin credentials in server.json: admin_user, admin_pass")
243
+
244
+ if service is not None:
245
+ try:
246
+ service.run() # main thread; blocks until SIGINT/SIGTERM
247
+ except KeyboardInterrupt:
248
+ pass
249
+ except Exception as error:
250
+ # The in-process core failed to start — e.g. its agent backend (an OVOS
251
+ # messagebus) is unreachable and the agent protocol now fails fast
252
+ # instead of hanging. Keep the admin UI up in diagnostics mode so the
253
+ # failure is visible at GET /api/startup-error and on the dashboard,
254
+ # rather than taking the whole panel down with it.
255
+ from hivemind_admin_panel.api import init_injected_objects
256
+ LOG.exception("hivemind-core stopped; admin panel staying up in diagnostics mode")
257
+ init_injected_objects(service=None, db=getattr(service, "db", None),
258
+ protocol=None, startup_error=error)
259
+ print(f"hivemind-core failed to start: {error}")
260
+ print("Admin panel staying up in diagnostics mode — see /api/startup-error")
261
+ wait_for_exit_signal()
262
+ else:
263
+ wait_for_exit_signal() # diagnostics mode: keep the panel up
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()
@@ -0,0 +1,162 @@
1
+ # hivemind-admin-panel
2
+ # Copyright (C) 2026 Casimiro Ferreira
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Authentication, roles and audit log for the admin panel.
5
+
6
+ Auth model
7
+ ----------
8
+ Credentials live in ``server.json``. The legacy ``admin_user`` / ``admin_pass``
9
+ remain the primary full-admin account; an optional ``users`` list adds extra
10
+ accounts with roles:
11
+
12
+ "users": [{"username": "ops", "password": "...", "role": "operator"}]
13
+
14
+ Roles: ``admin`` (full) and ``operator`` (read + non-destructive writes; barred
15
+ from destructive actions guarded by :func:`require_admin` at the API layer).
16
+
17
+ Sessions are stateless HMAC-signed bearer tokens, so the API accepts either HTTP
18
+ Basic (username/password) or ``Authorization: Bearer <token>``.
19
+
20
+ Passwords are compared in plaintext for parity with hivemind-core's existing
21
+ ``server.json`` model; hashing them is tracked as a hardening follow-up.
22
+ """
23
+ import base64
24
+ import hashlib
25
+ import hmac
26
+ import json
27
+ import os
28
+ import time
29
+ from typing import Any, Dict, List, Optional, Tuple
30
+
31
+ from ovos_utils.log import LOG
32
+ from ovos_utils.xdg_utils import xdg_data_home
33
+
34
+ ADMIN = "admin"
35
+ OPERATOR = "operator"
36
+ TOKEN_TTL = 12 * 3600 # 12h
37
+
38
+
39
+ # --------------------------------------------------------------------------- users
40
+
41
+ def _users(config: Dict[str, Any]) -> List[Dict[str, str]]:
42
+ """All admin accounts: the primary admin_user plus any extra `users`."""
43
+ users = [{
44
+ "username": config.get("admin_user", "admin"),
45
+ "password": config.get("admin_pass", "admin"),
46
+ "role": ADMIN,
47
+ }]
48
+ for u in config.get("users", []) or []:
49
+ if u.get("username"):
50
+ users.append({
51
+ "username": u["username"],
52
+ "password": u.get("password", ""),
53
+ "role": u.get("role", OPERATOR),
54
+ })
55
+ return users
56
+
57
+
58
+ def hash_password(password: str, iterations: int = 200_000) -> str:
59
+ """Hash a password with PBKDF2-SHA256 (stored format ``pbkdf2_sha256$...``)."""
60
+ salt = os.urandom(16)
61
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
62
+ return f"pbkdf2_sha256${iterations}${salt.hex()}${dk.hex()}"
63
+
64
+
65
+ def verify_password(stored: str, password: str) -> bool:
66
+ """Verify a password against a stored PBKDF2 hash or legacy plaintext value."""
67
+ if stored.startswith("pbkdf2_sha256$"):
68
+ try:
69
+ _, iters, salt_hex, hash_hex = stored.split("$")
70
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode(),
71
+ bytes.fromhex(salt_hex), int(iters))
72
+ return hmac.compare_digest(dk.hex(), hash_hex)
73
+ except Exception:
74
+ return False
75
+ return hmac.compare_digest(stored, password) # legacy plaintext
76
+
77
+
78
+ def authenticate(config: Dict[str, Any], username: str, password: str) -> Optional[str]:
79
+ """Return the user's role if credentials match, else None.
80
+
81
+ Passwords may be PBKDF2 hashes or legacy plaintext (see :func:`verify_password`).
82
+ """
83
+ role = None
84
+ for u in _users(config):
85
+ if hmac.compare_digest(u["username"], username) and verify_password(u["password"], password):
86
+ role = u["role"]
87
+ return role
88
+
89
+
90
+ # --------------------------------------------------------------------------- tokens
91
+
92
+ def _secret(config: Dict[str, Any]) -> bytes:
93
+ """Persistent signing secret, generated into server.json on first use."""
94
+ secret = config.get("admin_token_secret")
95
+ if not secret:
96
+ secret = os.urandom(32).hex()
97
+ try:
98
+ config["admin_token_secret"] = secret
99
+ config.store()
100
+ except Exception as e: # config may be a plain dict in tests
101
+ LOG.debug(f"could not persist token secret: {e}")
102
+ return secret.encode()
103
+
104
+
105
+ def create_token(config: Dict[str, Any], username: str, role: str, ttl: int = TOKEN_TTL) -> Dict[str, Any]:
106
+ payload = {"sub": username, "role": role, "exp": int(time.time()) + ttl}
107
+ raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
108
+ sig = hmac.new(_secret(config), raw.encode(), hashlib.sha256).hexdigest()
109
+ return {"token": f"{raw}.{sig}", "role": role, "expires": payload["exp"]}
110
+
111
+
112
+ def verify_token(config: Dict[str, Any], token: str) -> Optional[Dict[str, Any]]:
113
+ try:
114
+ raw, sig = token.split(".", 1)
115
+ except ValueError:
116
+ return None
117
+ expected = hmac.new(_secret(config), raw.encode(), hashlib.sha256).hexdigest()
118
+ if not hmac.compare_digest(sig, expected):
119
+ return None
120
+ try:
121
+ pad = "=" * (-len(raw) % 4)
122
+ payload = json.loads(base64.urlsafe_b64decode(raw + pad))
123
+ except Exception:
124
+ return None
125
+ if payload.get("exp", 0) < time.time():
126
+ return None
127
+ return payload
128
+
129
+
130
+ # --------------------------------------------------------------------------- audit
131
+
132
+ def _audit_path() -> str:
133
+ base = os.path.join(xdg_data_home(), "hivemind-admin")
134
+ os.makedirs(base, exist_ok=True)
135
+ return os.path.join(base, "audit.log")
136
+
137
+
138
+ def audit(user: str, action: str, **data: Any) -> None:
139
+ entry = {"ts": time.time(), "user": user, "action": action, **data}
140
+ try:
141
+ with open(_audit_path(), "a") as f:
142
+ f.write(json.dumps(entry) + "\n")
143
+ except Exception as e:
144
+ LOG.debug(f"audit write failed: {e}")
145
+
146
+
147
+ def read_audit(limit: int = 200) -> List[Dict[str, Any]]:
148
+ path = _audit_path()
149
+ if not os.path.isfile(path):
150
+ return []
151
+ try:
152
+ with open(path, "r", errors="replace") as f:
153
+ lines = f.readlines()[-limit:]
154
+ except Exception:
155
+ return []
156
+ out = []
157
+ for ln in lines:
158
+ try:
159
+ out.append(json.loads(ln))
160
+ except Exception:
161
+ continue
162
+ return out
@@ -0,0 +1,146 @@
1
+ # hivemind-admin-panel
2
+ # Copyright (C) 2026 Casimiro Ferreira
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ """Server-side client impersonation: chat through the hub *as* a registered client.
5
+
6
+ The admin picks a client; the panel opens a real ``HiveMessageBusClient`` with that
7
+ client's credentials, connects to the hub like any satellite would, sends typed
8
+ utterances, and collects the agent's ``speak`` replies. This exercises the genuine
9
+ path — ACL enforcement, routing, the agent backend — not a simulation.
10
+ """
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+
17
+ class ImpersonationSession:
18
+ """One live impersonated client connection and its chat transcript."""
19
+
20
+ def __init__(self, client_id: int, name: str, key: str, password: str,
21
+ crypto_key: Optional[str], host: str, port: int):
22
+ self.id = uuid.uuid4().hex
23
+ self.client_id = client_id
24
+ self.name = name
25
+ self.error: Optional[str] = None
26
+ self.created = time.time()
27
+ self.last_used = time.time()
28
+ self._transcript: List[Dict[str, Any]] = []
29
+ self._lock = threading.Lock()
30
+ self.bus = None
31
+ self._connect(key, password, crypto_key, host, port)
32
+
33
+ def _connect(self, key, password, crypto_key, host, port):
34
+ from ovos_utils.fakebus import FakeBus
35
+ from hivemind_bus_client import HiveMessageBusClient
36
+
37
+ self.bus = HiveMessageBusClient(
38
+ key=key, password=password, crypto_key=crypto_key,
39
+ host=host, port=port, self_signed=True, useragent="HiveMindAdminChat",
40
+ )
41
+ self.bus.on_mycroft("speak", self._on_speak)
42
+ self.bus.on_mycroft("hive.complete_intent_failure", self._on_fail)
43
+
44
+ def _do():
45
+ try:
46
+ self.bus.connect(FakeBus())
47
+ except Exception as e: # noqa: BLE001 - surfaced to the caller
48
+ self.error = str(e)
49
+
50
+ threading.Thread(target=_do, daemon=True).start()
51
+ if not self.bus.handshake_event.wait(15):
52
+ self.error = self.error or (
53
+ "handshake timed out — is the hub running and does this client have "
54
+ "a crypto key?")
55
+
56
+ # --- bus callbacks (run on the websocket thread) ---------------------------
57
+ def _on_speak(self, message):
58
+ utt = ""
59
+ try:
60
+ utt = message.data.get("utterance") or message.data.get("text") or ""
61
+ except Exception:
62
+ pass
63
+ if utt:
64
+ self._append("assistant", utt)
65
+
66
+ def _on_fail(self, message):
67
+ self._append("system", "the hub reported no skill/agent handled that utterance")
68
+
69
+ def _append(self, role: str, text: str):
70
+ with self._lock:
71
+ self._transcript.append({"role": role, "text": text, "ts": time.time()})
72
+
73
+ # --- public API ------------------------------------------------------------
74
+ def say(self, utterance: str, lang: str = "en-us"):
75
+ from hivemind_bus_client.message import HiveMessage, HiveMessageType
76
+ from ovos_bus_client.message import Message
77
+
78
+ self.last_used = time.time()
79
+ self._append("user", utterance)
80
+ self.bus.emit(HiveMessage(
81
+ HiveMessageType.BUS,
82
+ Message("recognizer_loop:utterance", {"utterances": [utterance], "lang": lang}),
83
+ ))
84
+
85
+ def messages(self, since: int = 0) -> Tuple[List[Dict[str, Any]], int]:
86
+ with self._lock:
87
+ self.last_used = time.time()
88
+ return list(self._transcript[since:]), len(self._transcript)
89
+
90
+ def close(self):
91
+ try:
92
+ self.bus.close()
93
+ except Exception:
94
+ pass
95
+
96
+
97
+ class ChatSessions:
98
+ """Process-wide registry of impersonation sessions (bounded, idle-reaped)."""
99
+
100
+ def __init__(self, max_sessions: int = 12, max_idle: float = 900.0):
101
+ self._sessions: Dict[str, ImpersonationSession] = {}
102
+ self._lock = threading.Lock()
103
+ self._max = max_sessions
104
+ self._max_idle = max_idle
105
+
106
+ def _reap(self):
107
+ now = time.time()
108
+ for sid, s in list(self._sessions.items()):
109
+ if now - s.last_used > self._max_idle:
110
+ s.close()
111
+ self._sessions.pop(sid, None)
112
+
113
+ def create(self, client_id, name, key, password, crypto_key, host, port
114
+ ) -> ImpersonationSession:
115
+ with self._lock:
116
+ self._reap()
117
+ # one live session per client: replace any existing
118
+ for sid, s in list(self._sessions.items()):
119
+ if s.client_id == client_id:
120
+ s.close()
121
+ self._sessions.pop(sid, None)
122
+ if len(self._sessions) >= self._max:
123
+ oldest = min(self._sessions.values(), key=lambda s: s.last_used)
124
+ oldest.close()
125
+ self._sessions.pop(oldest.id, None)
126
+ sess = ImpersonationSession(client_id, name, key, password, crypto_key, host, port)
127
+ with self._lock:
128
+ self._sessions[sess.id] = sess
129
+ return sess
130
+
131
+ def get(self, sid: str) -> Optional[ImpersonationSession]:
132
+ return self._sessions.get(sid)
133
+
134
+ def close(self, sid: str):
135
+ with self._lock:
136
+ s = self._sessions.pop(sid, None)
137
+ if s:
138
+ s.close()
139
+
140
+ def list_active(self) -> List[Dict[str, Any]]:
141
+ return [{"session_id": s.id, "client_id": s.client_id, "name": s.name}
142
+ for s in self._sessions.values()]
143
+
144
+
145
+ # process-wide singleton
146
+ CHAT = ChatSessions()