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.
- lightclawbot/__init__.py +88 -0
- lightclawbot/plugin.yaml +10 -0
- lightclawbot/src/__init__.py +37 -0
- lightclawbot/src/adapter.py +391 -0
- lightclawbot/src/config.py +149 -0
- lightclawbot/src/download_handler.py +167 -0
- lightclawbot/src/file_storage.py +236 -0
- lightclawbot/src/history.py +396 -0
- lightclawbot/src/inbound.py +453 -0
- lightclawbot/src/media.py +174 -0
- lightclawbot/src/outbound.py +443 -0
- lightclawbot/src/socket/__init__.py +9 -0
- lightclawbot/src/socket/native_socket.py +264 -0
- lightclawbot/src/socket/reliable_emitter.py +268 -0
- lightclawbot/src/tenancy.py +170 -0
- lightclawbot-0.0.2.dist-info/METADATA +177 -0
- lightclawbot-0.0.2.dist-info/RECORD +20 -0
- lightclawbot-0.0.2.dist-info/WHEEL +5 -0
- lightclawbot-0.0.2.dist-info/entry_points.txt +2 -0
- lightclawbot-0.0.2.dist-info/top_level.txt +1 -0
lightclawbot/__init__.py
ADDED
|
@@ -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__"]
|
lightclawbot/plugin.yaml
ADDED
|
@@ -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
|