lightclawbot 0.0.2__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,88 @@
1
+ """
2
+ lightclawbot — LightClaw platform plugin for Hermes Agent.
3
+
4
+ Registers the LightClaw adapter via the Hermes plugin discovery system.
5
+ No source-code modifications to lighthouse-hermes are required — the adapter
6
+ is discovered automatically when this package is placed under
7
+ ``~/.hermes/plugins/lightclawbot/`` or installed via pip (entry_points).
8
+ """
9
+
10
+ import os
11
+
12
+
13
+ def _get_version() -> str:
14
+ """Return package version from importlib.metadata, with fallback."""
15
+ try:
16
+ from importlib.metadata import version
17
+
18
+ return version("lightclawbot")
19
+ except Exception:
20
+ return "0.0.0"
21
+
22
+
23
+ __version__ = _get_version()
24
+
25
+
26
+ def _load_platform_hint() -> str:
27
+ """Return the LightClaw-specific LLM capability hint.
28
+
29
+ Only contents that are *unique* to LightClaw belong here. The generic
30
+ ``MEDIA:/absolute/path/to/file`` protocol, supported extensions and
31
+ text post-processing are owned by the framework
32
+ (``BasePlatformAdapter.extract_media`` + ``PLATFORM_HINTS`` in
33
+ ``agent/prompt_builder.py``) and are intentionally NOT re-stated here.
34
+ """
35
+ return (
36
+ "You are on LightClaw, a WebSocket-based messaging platform. "
37
+ "Markdown formatting is supported (code blocks, bold, italic, tables). "
38
+ "You can send files natively by including MEDIA:/absolute/path/to/file "
39
+ "in your reply; the file is delivered to the user as an on-demand "
40
+ "download link (no need to upload or build https URLs yourself). "
41
+ "For cron jobs / reminders / scheduled tasks, always set "
42
+ "deliver='lightclawbot:<chat_id>' so results reach the user instead of "
43
+ "being saved locally."
44
+ )
45
+
46
+
47
+ def _validate_config(config) -> bool:
48
+ """Check whether the platform config has enough info to connect."""
49
+ extra = getattr(config, "extra", {}) or {}
50
+ return bool(extra.get("api_keys") or os.getenv("LIGHTCLAW_API_KEY", ""))
51
+
52
+
53
+ def _is_connected(config) -> bool:
54
+ """Check whether the platform is sufficiently configured (for status display)."""
55
+ extra = getattr(config, "extra", {}) or {}
56
+ return bool(extra.get("api_keys"))
57
+
58
+
59
+ def register(ctx):
60
+ """Called by the Hermes plugin discovery system.
61
+
62
+ Registers the LightClaw platform adapter so that:
63
+ - Platform("lightclawbot") resolves to a dynamic enum member
64
+ - The gateway creates and connects a LightClawAdapter at startup
65
+ - send_message routes to the adapter via _send_via_adapter()
66
+ - Cron delivery to "lightclawbot:<chat_id>" works automatically
67
+ - User authorization respects LIGHTCLAW_ALLOWED_USERS env var
68
+ """
69
+ from .src import LightClawAdapter, check_lightclaw_requirements
70
+
71
+ ctx.register_platform(
72
+ name="lightclawbot",
73
+ label="LightClawBot",
74
+ adapter_factory=lambda cfg: LightClawAdapter(cfg),
75
+ check_fn=check_lightclaw_requirements,
76
+ validate_config=_validate_config,
77
+ is_connected=_is_connected,
78
+ required_env=["LIGHTCLAW_API_KEY"],
79
+ install_hint="See https://pypi.org/project/lightclawbot/",
80
+ allowed_users_env="LIGHTCLAW_ALLOWED_USERS",
81
+ allow_all_env="LIGHTCLAW_ALLOW_ALL_USERS",
82
+ max_message_length=4096,
83
+ emoji="⚡",
84
+ platform_hint=_load_platform_hint(),
85
+ )
86
+
87
+
88
+ __all__ = ["register", "__version__"]
@@ -0,0 +1,10 @@
1
+ name: lightclawbot
2
+ kind: platform
3
+ version: 0.0.2
4
+ description: >
5
+ LightClaw gateway adapter for Hermes Agent.
6
+ Connects to a LightClaw-compatible server via native WebSocket,
7
+ receiving user messages and sending AI responses back through the same protocol.
8
+ author: lhanyun
9
+ requires_env:
10
+ - LIGHTCLAW_API_KEY
@@ -0,0 +1,37 @@
1
+ """
2
+ LightClaw platform adapter for Hermes Agent.
3
+
4
+ Connects to a LightClaw-compatible server via native WebSocket,
5
+ receiving user messages and sending AI responses back through the same protocol.
6
+
7
+ Protocol (post socket-rewrite):
8
+ - Auth: POST /cgi/ticket → { code:0, data:{ client:{ extra:'{"botId":"..."}' }, ticket:"..." } }
9
+ - Transport: Native WebSocket wss://<domain>/ws/agent?ticket=<ticket>&enableMultiLogin=false
10
+ - Framing: Raw JSON { "event": "<name>", "data": {...} }
11
+ - Handshake: server sends { "event": "__handshake__", "data": { "id": "<socket_id>" } }
12
+ - ACK: server sends { "event": "message:ack", "data": { "relatedMsgId": "<msgId>" } }
13
+ - Events: message:private (bidirectional), history/sessions request/response
14
+ - Kinds: text, typing_start, stream_chunk, stream_end, typing_stop
15
+
16
+ Package layout (mirrors lightclaw/src/):
17
+ config.py ← constants + utils (config.ts)
18
+ socket/reliable_emitter.py ← ACK-based send (socket/reliable-emitter.ts)
19
+ socket/native_socket.py ← connection loop (socket/native-socket.ts)
20
+ inbound.py ← inbound handler + media (inbound.ts + media.ts)
21
+ outbound.py ← send API (outbound.ts)
22
+ adapter.py ← LightClawAdapter + singleton (gateway.ts)
23
+ """
24
+
25
+ # Public API — kept stable so existing callers need no changes:
26
+ # gateway/platforms/__init__.py → from .lightclaw import LightClawAdapter
27
+ # gateway/run.py → from gateway.platforms.lightclaw import LightClawAdapter, check_lightclaw_requirements
28
+ # tools/send_message_tool.py → from gateway.platforms.lightclaw import get_active_adapter
29
+
30
+ from .adapter import LightClawAdapter, get_active_adapter
31
+ from .config import check_lightclaw_requirements
32
+
33
+ __all__ = [
34
+ "LightClawAdapter",
35
+ "check_lightclaw_requirements",
36
+ "get_active_adapter",
37
+ ]
@@ -0,0 +1,391 @@
1
+ """
2
+ LightClaw adapter — main coordinator class and module singleton.
3
+ Mirrors: src/gateway.ts
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import os
10
+ from typing import Dict, List, Optional
11
+
12
+ from gateway.platforms.base import BasePlatformAdapter
13
+ from gateway.config import PlatformConfig
14
+
15
+ from .config import (
16
+ API_PATH_TICKET,
17
+ DEFAULT_API_BASE_URL,
18
+ DEFAULT_WS_BASE_URL,
19
+ EVENT_HANDSHAKE,
20
+ EVENT_HISTORY_REQUEST,
21
+ EVENT_MESSAGE_ACK,
22
+ EVENT_MESSAGE_PRIVATE,
23
+ EVENT_SESSIONS_REQUEST,
24
+ FileDownloadStatus,
25
+ KIND_FILE_DOWNLOAD,
26
+ generate_msg_id,
27
+ )
28
+ from .socket.reliable_emitter import ReliableEmitter
29
+ from .socket.native_socket import NativeSocketMixin
30
+ from .inbound import InboundMixin
31
+ from .outbound import OutboundMixin
32
+ from .download_handler import DownloadHandlerMixin
33
+ from .tenancy import set_api_key_map
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class LightClawAdapter(
39
+ NativeSocketMixin,
40
+ InboundMixin,
41
+ OutboundMixin,
42
+ DownloadHandlerMixin,
43
+ BasePlatformAdapter,
44
+ ):
45
+ """
46
+ Hermes gateway adapter for LightClaw-compatible servers.
47
+
48
+ Connects via native WebSocket (aiohttp), receives message:private events,
49
+ dispatches them to Hermes AIAgent, and sends responses back.
50
+
51
+ Protocol:
52
+ - Auth: POST /cgi/ticket → { code:0, data:{ client:{ extra:'{"botId":"..."}' }, ticket:"..." } }
53
+ - Transport: Native WebSocket wss://<domain>/ws/agent?ticket=<ticket>&enableMultiLogin=false
54
+ - Framing: Raw JSON { "event": "<name>", "data": {...} }
55
+ - Handshake: server sends { "event": "__handshake__", "data": { "id": "<socket_id>" } }
56
+ - ACK: server sends { "event": "message:ack", "data": { "relatedMsgId": "<msgId>" } }
57
+ """
58
+
59
+ # LightClaw uses non-streaming mode: edit_message is a no-op.
60
+ # Gateway won't create GatewayStreamConsumer when SUPPORTS_MESSAGE_EDITING=False.
61
+ SUPPORTS_MESSAGE_EDITING = False
62
+
63
+ def __init__(self, config: PlatformConfig):
64
+ from gateway.config import Platform
65
+ super().__init__(config, Platform.LIGHTCLAW)
66
+
67
+ extra = config.extra or {}
68
+
69
+ # API key — single source: env LIGHTCLAW_API_KEY.
70
+ # `extra.api_keys` (yaml list) is kept as a forward-compat hook for
71
+ # future multi-tenant deployments; not used in current deployments.
72
+ if extra.get("api_keys"):
73
+ self._api_keys: List[str] = list(extra["api_keys"])
74
+ else:
75
+ single = os.getenv("LIGHTCLAW_API_KEY", "").strip()
76
+ self._api_keys = [single] if single else []
77
+
78
+ # Server URLs — default to config.py constants; env overrides are kept
79
+ # as an operational escape hatch for private deployments / staging.
80
+ self._ws_base_url: str = os.getenv("LIGHTCLAW_WS_URL", DEFAULT_WS_BASE_URL)
81
+ self._api_base_url: str = os.getenv("LIGHTCLAW_API_BASE_URL", DEFAULT_API_BASE_URL)
82
+
83
+ # Identity (resolved on connect, stable across reconnects)
84
+ self._bot_client_id: str = ""
85
+ self._api_key_map: Dict[str, str] = {} # uin → apiKey (populated during _resolve_identity)
86
+
87
+ # Per-chat round msgId
88
+ self._round_ids: Dict[str, str] = {}
89
+
90
+ # Per-chat flag: whether the current round has already emitted a
91
+ # stream_chunk. Used by OutboundMixin.send() to prepend "\n\n"
92
+ # before non-first chunks so front-end concatenation matches the
93
+ # visual breaks seen in the history view.
94
+ self._round_has_content: Dict[str, bool] = {}
95
+
96
+ # Per-chat agentId received from inbound messages (used by outbound to echo back)
97
+ self._incoming_agent_ids: Dict[str, str] = {}
98
+
99
+ # Per-chat list of attachments seen on inbound messages.
100
+ # Used by history persistence / outbound enrichment. Entries are
101
+ # dicts of shape {"name", "mimeType", "url"} where url is always
102
+ # a `localfile://` URI (mirrors TS `publicMediaUrls`).
103
+ self._inbound_attachments: Dict[str, list] = {}
104
+
105
+ # Sessions directory for history reading
106
+ hermes_home = os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes")
107
+ self._sessions_dir: str = (
108
+ extra.get("sessions_dir")
109
+ or os.getenv("LIGHTCLAW_SESSIONS_DIR")
110
+ or os.path.join(hermes_home, "sessions")
111
+ )
112
+
113
+ # Per-agent system prompts (multi-agent support)
114
+ # Configured in config.yaml → platforms.lightclaw.extra.agent_prompts
115
+ self._agent_prompts: Dict[str, str] = extra.get("agent_prompts") or {}
116
+
117
+ # Connection state
118
+ self._stopped = False
119
+ self._socket_id: str = ""
120
+ self._ws = None # aiohttp.ClientWebSocketResponse
121
+ self._session = None # aiohttp.ClientSession
122
+ self._connection_task: Optional[asyncio.Task] = None
123
+ self._reliable: Optional[ReliableEmitter] = None
124
+ self._first_connect_event: asyncio.Event = asyncio.Event()
125
+
126
+ # Pending ACK table (owned by NativeSocketMixin)
127
+ self._init_pending_acks()
128
+
129
+ # ------------------------------------------------------------------
130
+ # Identity resolution
131
+ # ------------------------------------------------------------------
132
+
133
+ async def _resolve_identity(self) -> None:
134
+ """
135
+ POST /cgi/ticket for each API key to get botClientId and the tenant's uin.
136
+
137
+ Response shape::
138
+
139
+ { code: 0,
140
+ data: {
141
+ id: "<uin>", # tenant user id
142
+ client: { extra: '{"botId":"<botId>"}' }, # JSON string
143
+ ticket: "..."
144
+ }
145
+ }
146
+
147
+ Populates:
148
+ self._bot_client_id — any non-empty botId (first key wins)
149
+ self._api_key_map — { uin: apiKey, ... } (plus apiKey→apiKey
150
+ as a safety fallback when uin missing)
151
+
152
+ And pushes the result into :mod:`.tenancy` so tool handlers and the
153
+ download signal handler can resolve the correct apiKey per session.
154
+ """
155
+ import aiohttp
156
+
157
+ url = f"{self._api_base_url}{API_PATH_TICKET}"
158
+ bot_client_id = ""
159
+ api_key_map: Dict[str, str] = {}
160
+
161
+ async with aiohttp.ClientSession() as session:
162
+ for key in self._api_keys:
163
+ try:
164
+ headers = {
165
+ "authorization": f"Bearer {key}",
166
+ "x-product": "channel",
167
+ }
168
+ async with session.post(
169
+ url, headers=headers,
170
+ timeout=aiohttp.ClientTimeout(total=15),
171
+ ) as resp:
172
+ if resp.status != 200:
173
+ logger.warning(
174
+ "[lightclaw] %s HTTP %d for key ***%s",
175
+ API_PATH_TICKET, resp.status, key[-4:],
176
+ )
177
+ continue
178
+ data = await resp.json()
179
+ if data.get("code") != 0:
180
+ logger.warning(
181
+ "[lightclaw] %s error for key ***%s: %s",
182
+ API_PATH_TICKET, key[-4:], data.get("message"),
183
+ )
184
+ continue
185
+
186
+ payload = data.get("data") or {}
187
+
188
+ # Extract botClientId from data.client.extra (JSON-encoded)
189
+ extra_str = (payload.get("client") or {}).get("extra", "")
190
+ try:
191
+ parsed = json.loads(extra_str) if extra_str else {}
192
+ bid = parsed.get("botId", "")
193
+ except (json.JSONDecodeError, TypeError):
194
+ bid = ""
195
+
196
+ if bid and not bot_client_id:
197
+ bot_client_id = bid
198
+
199
+ # Extract the uin — this is what inbound messages use
200
+ # as their `from` field and what we use to route the
201
+ # per-tenant apiKey.
202
+ uin = str(payload.get("id") or "").strip()
203
+ if uin:
204
+ api_key_map[uin] = key
205
+ logger.info(
206
+ "[lightclaw] Key ***%s mapped to uin=%s (botId=%s)",
207
+ key[-4:], uin, bid or "?",
208
+ )
209
+ else:
210
+ # No uin exposed → fall back to key→key so
211
+ # resolve_effective_api_key() always finds
212
+ # *something* for single-tenant deployments.
213
+ api_key_map[key] = key
214
+ logger.info(
215
+ "[lightclaw] Key ***%s mapped (botId=%s, no uin returned)",
216
+ key[-4:], bid or "?",
217
+ )
218
+ except Exception as exc:
219
+ logger.warning(
220
+ "[lightclaw] Identity resolve failed for key ***%s: %s",
221
+ key[-4:], exc,
222
+ )
223
+
224
+ if not bot_client_id:
225
+ raise RuntimeError(
226
+ "Failed to resolve botClientId from any API key via POST /cgi/ticket"
227
+ )
228
+
229
+ self._bot_client_id = bot_client_id
230
+ self._api_key_map = api_key_map
231
+
232
+ # Publish the map into the tenancy module so inbound/outbound/tool
233
+ # code can resolve the correct apiKey for a given sessionKey/senderId.
234
+ default_key = self._api_keys[0] if self._api_keys else ""
235
+ set_api_key_map(api_key_map, default_key)
236
+
237
+ logger.info(
238
+ "[lightclaw] Bot clientId: %s, %d key(s) mapped",
239
+ bot_client_id, len(api_key_map),
240
+ )
241
+
242
+ # ------------------------------------------------------------------
243
+ # Lifecycle
244
+ # ------------------------------------------------------------------
245
+
246
+ async def connect(self) -> bool:
247
+ if not self._api_keys:
248
+ logger.error("[lightclaw] No API keys configured")
249
+ self._set_fatal_error("no_api_keys", "No API keys configured", retryable=False)
250
+ return False
251
+
252
+ try:
253
+ import aiohttp # noqa: F401
254
+ except ImportError:
255
+ logger.error("[lightclaw] aiohttp not installed")
256
+ self._set_fatal_error("missing_deps", "aiohttp not installed", retryable=False)
257
+ return False
258
+
259
+ try:
260
+ await self._resolve_identity()
261
+ except Exception as exc:
262
+ logger.error("[lightclaw] Identity resolution failed: %s", exc)
263
+ self._set_fatal_error("identity_failed", str(exc), retryable=True)
264
+ return False
265
+
266
+ import aiohttp
267
+ self._stopped = False
268
+ self._first_connect_event.clear()
269
+ self._session = aiohttp.ClientSession()
270
+ self._reliable = ReliableEmitter(
271
+ ws_emit=self._ws_emit,
272
+ ws_emit_with_timeout=self._ws_emit_with_timeout,
273
+ prefix="[lightclaw]",
274
+ )
275
+ self._connection_task = asyncio.create_task(self._connection_loop())
276
+
277
+ # Wait for first successful WS connect
278
+ try:
279
+ await asyncio.wait_for(self._first_connect_event.wait(), timeout=20.0)
280
+ except asyncio.TimeoutError:
281
+ logger.error("[lightclaw] Timed out waiting for initial connection")
282
+ self._set_fatal_error("connect_timeout", "Initial connection timeout", retryable=True)
283
+ return False
284
+
285
+ _set_active_adapter(self)
286
+ return True
287
+
288
+ async def disconnect(self) -> None:
289
+ self._stopped = True
290
+ self._flush_pending_acks(ConnectionError("adapter disconnect"))
291
+ if self._reliable:
292
+ self._reliable.destroy()
293
+ self._reliable = None
294
+ if self._connection_task:
295
+ self._connection_task.cancel()
296
+ try:
297
+ await self._connection_task
298
+ except asyncio.CancelledError:
299
+ pass
300
+ self._connection_task = None
301
+ if self._ws and not self._ws.closed:
302
+ try:
303
+ await self._ws.close()
304
+ except Exception:
305
+ pass
306
+ self._ws = None
307
+ if self._session and not self._session.closed:
308
+ await self._session.close()
309
+ self._session = None
310
+ _set_active_adapter(None)
311
+ self._mark_disconnected()
312
+
313
+ # ------------------------------------------------------------------
314
+ # Raw message dispatch
315
+ # ------------------------------------------------------------------
316
+
317
+ async def _handle_raw(self, raw: str) -> None:
318
+ """
319
+ Dispatch incoming WebSocket frames.
320
+ Mirrors: src/socket/handlers.ts
321
+ """
322
+ try:
323
+ msg = json.loads(raw)
324
+ except (json.JSONDecodeError, TypeError):
325
+ return
326
+
327
+ event = msg.get("event", "")
328
+ data = msg.get("data") or {}
329
+
330
+ if event == EVENT_HANDSHAKE:
331
+ self._socket_id = data.get("id", "")
332
+ logger.info("[lightclaw] Handshake, socket_id=%s", self._socket_id)
333
+ return
334
+
335
+ if event == EVENT_MESSAGE_ACK:
336
+ related = data.get("relatedMsgId", "")
337
+ logger.debug("[lightclaw] ACK received: relatedMsgId=%s", related)
338
+ if related:
339
+ self._on_ws_ack(related)
340
+ return
341
+
342
+ if event == EVENT_MESSAGE_PRIVATE:
343
+ # ── file:download signalling — separate from the AI pipeline ──
344
+ # The front-end issues `kind=file:download, status=download_req`
345
+ # when the user clicks on a `localfile://` markdown link. We
346
+ # handle it inline, never forwarding to handle_message().
347
+ if data.get("kind") == KIND_FILE_DOWNLOAD:
348
+ td = (data.get("extra") or {}).get("transferData") or {}
349
+ if td.get("status") == FileDownloadStatus.REQ:
350
+ asyncio.create_task(self._handle_file_download_req(data))
351
+ # Any other status on a client→adapter frame is ignored:
352
+ # ready / url / error are only ever sent adapter→client.
353
+ return
354
+
355
+ # Spawn as background task: handle_message triggers the full
356
+ # agent pipeline (seconds to minutes), must not block the WS
357
+ # read loop. Matches TS: void (async () => { await handler(msg); })()
358
+ asyncio.create_task(self._handle_incoming_message(data))
359
+ return
360
+
361
+ if event == EVENT_HISTORY_REQUEST:
362
+ await self._handle_history_request(data)
363
+ return
364
+
365
+ if event == EVENT_SESSIONS_REQUEST:
366
+ await self._handle_sessions_request(data)
367
+ return
368
+
369
+ # ------------------------------------------------------------------
370
+ # Misc
371
+ # ------------------------------------------------------------------
372
+
373
+ async def get_chat_info(self, chat_id: str) -> dict:
374
+ return {"name": f"LightClaw DM ({chat_id})", "type": "dm", "chat_id": chat_id}
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # Module-level singleton accessor
379
+ # ---------------------------------------------------------------------------
380
+
381
+ _active_adapter: Optional[LightClawAdapter] = None
382
+
383
+
384
+ def _set_active_adapter(adapter: Optional[LightClawAdapter]) -> None:
385
+ global _active_adapter
386
+ _active_adapter = adapter
387
+
388
+
389
+ def get_active_adapter() -> Optional[LightClawAdapter]:
390
+ """Return the running LightClawAdapter singleton, or None if not started."""
391
+ return _active_adapter
@@ -0,0 +1,149 @@
1
+ """
2
+ LightClaw adapter — constants and utility functions.
3
+ Mirrors: src/config.ts
4
+ """
5
+
6
+ import os
7
+ import time
8
+ import uuid
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Channel key
12
+ # ---------------------------------------------------------------------------
13
+
14
+ CHANNEL_KEY = "lightclaw"
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Event names
18
+ # ---------------------------------------------------------------------------
19
+
20
+ EVENT_MESSAGE_PRIVATE = "message:private"
21
+ EVENT_MESSAGE_ACK = "message:ack"
22
+ EVENT_HANDSHAKE = "__handshake__"
23
+ EVENT_HISTORY_REQUEST = "message:history:request"
24
+ EVENT_HISTORY_RESPONSE = "message:history:response"
25
+ EVENT_SESSIONS_REQUEST = "sessions:request"
26
+ EVENT_SESSIONS_RESPONSE = "sessions:response"
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Server URLs / paths
30
+ # ---------------------------------------------------------------------------
31
+
32
+ DEFAULT_WS_BASE_URL = "wss://lightai.cloud.tencent.com"
33
+ DEFAULT_API_BASE_URL = "https://lightai.cloud.tencent.com"
34
+ SOCKET_PATH = "/ws/agent"
35
+ API_PATH_TICKET = "/cgi/ticket"
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Protocol limits
39
+ # ---------------------------------------------------------------------------
40
+
41
+ MAX_MESSAGE_LENGTH = 4096
42
+
43
+ # Default agentId (mirrors DEFAULT_AGENT_ID in config.ts)
44
+ DEFAULT_AGENT_ID = "main"
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Reconnect strategy (mirrors NativeSocketClient)
48
+ # ---------------------------------------------------------------------------
49
+
50
+ RECONNECT_DELAY_BASE = 1.0 # seconds
51
+ RECONNECT_DELAY_MAX = 30.0 # seconds
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # ReliableEmitter config (mirrors reliable-emitter.ts)
55
+ # ---------------------------------------------------------------------------
56
+
57
+ EMIT_ACK_TIMEOUT = 5.0 # seconds
58
+ EMIT_MAX_RETRIES = 3
59
+ EMIT_RETRY_BASE_DELAY = 2.0 # seconds
60
+ EMIT_RETRY_MAX_DELAY = 15.0 # seconds
61
+ EMIT_PENDING_MAX = 500
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Media / file storage (mirrors src/config.ts media section)
65
+ # ---------------------------------------------------------------------------
66
+
67
+ # Remote file storage service base URL
68
+ SERVER_UPLOAD_BASE_URL = "https://lightai.cloud.tencent.com"
69
+ API_PATH_UPLOAD = "/drive/save" # POST multipart/form-data
70
+ API_PATH_DOWNLOAD = "/drive/preview" # GET ?filePath=...
71
+
72
+ # Single-file hard limit: 100 MB (mirrors MEDIA_MAX_BYTES)
73
+ MEDIA_MAX_BYTES = 100 * 1024 * 1024
74
+ # Upload/download timeout in seconds (mirrors UPLOAD_TIMEOUT=120_000ms)
75
+ UPLOAD_TIMEOUT = 120.0
76
+ DOWNLOAD_TIMEOUT = 60.0
77
+
78
+ # URI scheme for local-file references embedded in AI replies.
79
+ # Front-end recognises this prefix and issues a file:download signal to
80
+ # trigger on-demand upload + download.
81
+ LOCALFILE_SCHEME = "localfile://"
82
+
83
+ # Auth header `x-product` value (mirrors TS X_PRODUCT)
84
+ X_PRODUCT = "channel"
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # file:download signalling (kind field value + status enum)
88
+ # ---------------------------------------------------------------------------
89
+
90
+ KIND_FILE_DOWNLOAD = "file:download"
91
+
92
+
93
+ class FileDownloadStatus:
94
+ """Lifecycle statuses carried inside `extra.transferData.status`.
95
+
96
+ Mirrors TS FILE_DOWNLOAD_STATUS enum.
97
+ """
98
+ REQ = "download_req" # client → bot
99
+ READY = "download_ready" # bot → client (file confirmed, upload starting)
100
+ URL = "download_url" # bot → client (upload done, public URL ready)
101
+ ERROR = "download_error" # bot → client (any failure)
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # MIME type lookup (aligned 1:1 with TS guessMimeByExt)
106
+ # ---------------------------------------------------------------------------
107
+
108
+ _MIME_MAP = {
109
+ # image
110
+ ".png": "image/png",
111
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
112
+ ".gif": "image/gif",
113
+ ".webp": "image/webp",
114
+ ".svg": "image/svg+xml",
115
+ # audio
116
+ ".mp3": "audio/mpeg",
117
+ ".wav": "audio/wav",
118
+ ".ogg": "audio/ogg",
119
+ # video
120
+ ".mp4": "video/mp4",
121
+ ".webm": "video/webm",
122
+ # documents
123
+ ".pdf": "application/pdf",
124
+ ".txt": "text/plain",
125
+ }
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Utility functions
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def generate_msg_id() -> str:
133
+ """Generate a unique message ID."""
134
+ return f"hermes_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}"
135
+
136
+
137
+ def guess_mime(filename: str) -> str:
138
+ """Guess MIME type from file extension."""
139
+ ext = os.path.splitext(filename)[1].lower()
140
+ return _MIME_MAP.get(ext, "application/octet-stream")
141
+
142
+
143
+ def check_lightclaw_requirements() -> bool:
144
+ """Check if aiohttp is available (python-socketio no longer needed)."""
145
+ try:
146
+ import aiohttp # noqa: F401
147
+ return True
148
+ except ImportError:
149
+ return False