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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LightClaw — on-demand file download signal handler.
|
|
3
|
+
|
|
4
|
+
Mirrors: src/socket/handlers.ts L306-436 (``handleFileDownloadReq``).
|
|
5
|
+
|
|
6
|
+
Protocol (front-end ↔ adapter)::
|
|
7
|
+
|
|
8
|
+
Client → Adapter (kind=file:download, status=download_req):
|
|
9
|
+
{ msgId, from, to, content:"", timestamp, kind:"file:download",
|
|
10
|
+
extra: { transferData: { transferId, status:"download_req",
|
|
11
|
+
localPath } } }
|
|
12
|
+
|
|
13
|
+
Adapter → Client (kind=file:download, status=download_ready):
|
|
14
|
+
{ ..., extra: { transferData: { transferId, status:"download_ready",
|
|
15
|
+
name, size, contentType } } }
|
|
16
|
+
|
|
17
|
+
Adapter → Client (kind=file:download, status=download_url):
|
|
18
|
+
{ ..., extra: { transferData: { transferId, status:"download_url",
|
|
19
|
+
url, name, size, contentType } } }
|
|
20
|
+
|
|
21
|
+
Adapter → Client (kind=file:download, status=download_error):
|
|
22
|
+
{ ..., extra: { transferData: { transferId, status:"download_error",
|
|
23
|
+
error } } }
|
|
24
|
+
|
|
25
|
+
Flow:
|
|
26
|
+
1. Parse transferId / localPath from ``data.extra.transferData``.
|
|
27
|
+
2. Validate: localPath must be absolute, must exist, must be a regular
|
|
28
|
+
file. On failure → ``download_error`` and stop.
|
|
29
|
+
3. Emit ``download_ready`` with the file metadata (name/size/mime).
|
|
30
|
+
4. Upload the file to ``/drive/save`` (server-side content review).
|
|
31
|
+
5. On success → ``download_url`` with the public URL.
|
|
32
|
+
On failure → ``download_error``.
|
|
33
|
+
|
|
34
|
+
All four reply frames use the reliable emitter (ACK + auto-retry),
|
|
35
|
+
matching TS emitWithAck semantics.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import asyncio
|
|
41
|
+
import logging
|
|
42
|
+
import os
|
|
43
|
+
|
|
44
|
+
from .config import (
|
|
45
|
+
DEFAULT_AGENT_ID,
|
|
46
|
+
EVENT_MESSAGE_PRIVATE,
|
|
47
|
+
FileDownloadStatus,
|
|
48
|
+
KIND_FILE_DOWNLOAD,
|
|
49
|
+
)
|
|
50
|
+
from .file_storage import upload_file_to_server
|
|
51
|
+
from .media import guess_mime_by_ext
|
|
52
|
+
from .tenancy import resolve_effective_api_key
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DownloadHandlerMixin:
|
|
58
|
+
"""Mixin providing the ``file:download`` request handler.
|
|
59
|
+
|
|
60
|
+
Requires (set by LightClawAdapter):
|
|
61
|
+
self._bot_client_id: str
|
|
62
|
+
self._session: aiohttp.ClientSession
|
|
63
|
+
self._reliable: ReliableEmitter
|
|
64
|
+
self._build_message(...)
|
|
65
|
+
self._emit_reliable(event, data)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
async def _handle_file_download_req(self, data: dict) -> None:
|
|
69
|
+
"""Handle one ``file:download`` ``download_req`` frame.
|
|
70
|
+
|
|
71
|
+
Fire-and-forget from ``_handle_raw``; never re-raises. Three
|
|
72
|
+
outcomes: ``download_ready`` + ``download_url`` on success, or
|
|
73
|
+
``download_error`` on any failure.
|
|
74
|
+
"""
|
|
75
|
+
sender = data.get("from") or ""
|
|
76
|
+
agent_id = data.get("agentId") or DEFAULT_AGENT_ID
|
|
77
|
+
|
|
78
|
+
extra = data.get("extra") or {}
|
|
79
|
+
td = extra.get("transferData") if isinstance(extra, dict) else None
|
|
80
|
+
td = td or {}
|
|
81
|
+
|
|
82
|
+
transfer_id = td.get("transferId")
|
|
83
|
+
local_path = td.get("localPath") or ""
|
|
84
|
+
|
|
85
|
+
logger.info(
|
|
86
|
+
"[lightclaw] file:download(req) received: transferId=%s localPath=%s from=%s",
|
|
87
|
+
transfer_id, local_path, sender,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def _reply(status: str, **payload) -> None:
|
|
91
|
+
"""Emit one file:download reply frame (reliable)."""
|
|
92
|
+
transfer_payload = {"transferId": transfer_id, "status": status}
|
|
93
|
+
transfer_payload.update(payload)
|
|
94
|
+
msg = self._build_message(
|
|
95
|
+
sender, "",
|
|
96
|
+
kind=KIND_FILE_DOWNLOAD,
|
|
97
|
+
agent_id=agent_id,
|
|
98
|
+
extra={"transferData": transfer_payload},
|
|
99
|
+
)
|
|
100
|
+
try:
|
|
101
|
+
await self._emit_reliable(EVENT_MESSAGE_PRIVATE, msg)
|
|
102
|
+
except Exception as emit_err:
|
|
103
|
+
logger.warning(
|
|
104
|
+
"[lightclaw] file:download emit(%s) failed for transferId=%s: %s",
|
|
105
|
+
status, transfer_id, emit_err,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def _reply_error(message: str) -> None:
|
|
109
|
+
logger.error(
|
|
110
|
+
"[lightclaw] file:download(error) transferId=%s: %s",
|
|
111
|
+
transfer_id, message,
|
|
112
|
+
)
|
|
113
|
+
await _reply(FileDownloadStatus.ERROR, error=message)
|
|
114
|
+
|
|
115
|
+
# ── ① validation ────────────────────────────────────────────
|
|
116
|
+
if not transfer_id or not local_path:
|
|
117
|
+
await _reply_error("Missing transferId or localPath in extra.transferData")
|
|
118
|
+
return
|
|
119
|
+
if not sender:
|
|
120
|
+
logger.warning("[lightclaw] file:download(req) missing sender, ignoring")
|
|
121
|
+
return
|
|
122
|
+
if not os.path.isabs(local_path):
|
|
123
|
+
await _reply_error(f"localPath must be an absolute path: {local_path}")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
resolved = os.path.realpath(local_path)
|
|
127
|
+
if not os.path.exists(resolved):
|
|
128
|
+
await _reply_error(f"File not found: {resolved}")
|
|
129
|
+
return
|
|
130
|
+
if not os.path.isfile(resolved):
|
|
131
|
+
await _reply_error(f"Not a regular file: {resolved}")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
file_name = os.path.basename(resolved)
|
|
135
|
+
size = os.path.getsize(resolved)
|
|
136
|
+
mime = guess_mime_by_ext(file_name) or "application/octet-stream"
|
|
137
|
+
|
|
138
|
+
# ── ② ready frame ───────────────────────────────────────────
|
|
139
|
+
await _reply(
|
|
140
|
+
FileDownloadStatus.READY,
|
|
141
|
+
name=file_name, size=size, contentType=mime,
|
|
142
|
+
)
|
|
143
|
+
logger.info(
|
|
144
|
+
"[lightclaw] file:download(ready) sent: transferId=%s name=%s size=%d",
|
|
145
|
+
transfer_id, file_name, size,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# ── ③ upload + url frame ────────────────────────────────────
|
|
149
|
+
try:
|
|
150
|
+
api_key = resolve_effective_api_key(sender_id=sender)
|
|
151
|
+
_, public_url = await upload_file_to_server(
|
|
152
|
+
resolved, api_key=api_key, session=self._session,
|
|
153
|
+
)
|
|
154
|
+
except asyncio.CancelledError:
|
|
155
|
+
raise
|
|
156
|
+
except Exception as upload_err:
|
|
157
|
+
await _reply_error(str(upload_err))
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
await _reply(
|
|
161
|
+
FileDownloadStatus.URL,
|
|
162
|
+
url=public_url, name=file_name, size=size, contentType=mime,
|
|
163
|
+
)
|
|
164
|
+
logger.info(
|
|
165
|
+
"[lightclaw] file:download(url) sent: transferId=%s url=%s",
|
|
166
|
+
transfer_id, public_url,
|
|
167
|
+
)
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LightClaw — remote file storage client.
|
|
3
|
+
Mirrors: src/file-storage.ts
|
|
4
|
+
|
|
5
|
+
All binary transfer with ai-server happens here. HTTP contract
|
|
6
|
+
(reverse-engineered from the TS client):
|
|
7
|
+
|
|
8
|
+
Upload : POST /drive/save
|
|
9
|
+
Content-Type: multipart/form-data
|
|
10
|
+
Fields : file (binary) + filePath (string)
|
|
11
|
+
Auth : Bearer <apiKey>, x-product: channel
|
|
12
|
+
Success : { code: 0, data: { uploaded: true, ... } }
|
|
13
|
+
|
|
14
|
+
Download : GET /drive/preview?filePath=<filePath>
|
|
15
|
+
Auth : Bearer <apiKey>, x-product: channel
|
|
16
|
+
Success : HTTP 200, body = raw file bytes
|
|
17
|
+
|
|
18
|
+
The server-side ``filePath`` is constructed client-side as
|
|
19
|
+
``${Date.now()}/${fileName}`` so that concurrent uploads of the same
|
|
20
|
+
name never collide. The returned public URL is constructed locally as
|
|
21
|
+
``${SERVER_UPLOAD_BASE_URL}${API_PATH_DOWNLOAD}?filePath=<filePath>``
|
|
22
|
+
rather than trusting the server response — matches TS behaviour and
|
|
23
|
+
means the public URL is deterministic given a known ``filePath``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import time
|
|
32
|
+
from typing import Optional, Tuple
|
|
33
|
+
from urllib.parse import quote
|
|
34
|
+
|
|
35
|
+
from .config import (
|
|
36
|
+
API_PATH_DOWNLOAD,
|
|
37
|
+
API_PATH_UPLOAD,
|
|
38
|
+
DOWNLOAD_TIMEOUT,
|
|
39
|
+
SERVER_UPLOAD_BASE_URL,
|
|
40
|
+
UPLOAD_TIMEOUT,
|
|
41
|
+
)
|
|
42
|
+
from .media import guess_mime_by_ext
|
|
43
|
+
from .tenancy import build_auth_headers
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Helpers
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def get_file_download_url(file_path: str) -> str:
|
|
53
|
+
"""Build a public ``/drive/preview`` URL for *file_path*.
|
|
54
|
+
|
|
55
|
+
Does not perform any network IO. Mirrors TS ``getFileDownloadUrl``.
|
|
56
|
+
"""
|
|
57
|
+
if not file_path:
|
|
58
|
+
return ""
|
|
59
|
+
# Full http(s) URL passthrough — sometimes the caller already has one.
|
|
60
|
+
if file_path.startswith(("http://", "https://")):
|
|
61
|
+
return file_path
|
|
62
|
+
return f"{SERVER_UPLOAD_BASE_URL}{API_PATH_DOWNLOAD}?filePath={quote(file_path, safe='')}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _make_server_file_path(file_name: str) -> str:
|
|
66
|
+
"""Generate the remote ``filePath`` (millisecond timestamp dir + name)."""
|
|
67
|
+
return f"{int(time.time() * 1000)}/{file_name}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _do_upload(
|
|
71
|
+
*,
|
|
72
|
+
data: bytes,
|
|
73
|
+
file_name: str,
|
|
74
|
+
mime: str,
|
|
75
|
+
api_key: str,
|
|
76
|
+
session,
|
|
77
|
+
) -> Tuple[str, str]:
|
|
78
|
+
"""Internal: POST /drive/save and return ``(file_path, public_url)``.
|
|
79
|
+
|
|
80
|
+
Raises ``RuntimeError`` on any failure (HTTP error, timeout, server
|
|
81
|
+
rejection). The caller decides whether to fall back gracefully.
|
|
82
|
+
"""
|
|
83
|
+
import aiohttp
|
|
84
|
+
|
|
85
|
+
file_path = _make_server_file_path(file_name)
|
|
86
|
+
|
|
87
|
+
form = aiohttp.FormData()
|
|
88
|
+
form.add_field("file", data, filename=file_name, content_type=mime)
|
|
89
|
+
form.add_field("filePath", file_path)
|
|
90
|
+
|
|
91
|
+
url = f"{SERVER_UPLOAD_BASE_URL}{API_PATH_UPLOAD}"
|
|
92
|
+
headers = build_auth_headers(api_key)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
async with session.post(
|
|
96
|
+
url,
|
|
97
|
+
data=form,
|
|
98
|
+
headers=headers,
|
|
99
|
+
timeout=aiohttp.ClientTimeout(total=UPLOAD_TIMEOUT),
|
|
100
|
+
) as resp:
|
|
101
|
+
if resp.status != 200:
|
|
102
|
+
body = (await resp.text())[:200]
|
|
103
|
+
raise RuntimeError(f"Upload HTTP {resp.status}: {body}")
|
|
104
|
+
result = await resp.json(content_type=None)
|
|
105
|
+
except asyncio.TimeoutError as exc:
|
|
106
|
+
raise RuntimeError(f"Upload timeout after {UPLOAD_TIMEOUT}s") from exc
|
|
107
|
+
|
|
108
|
+
if not isinstance(result, dict):
|
|
109
|
+
raise RuntimeError(f"Upload rejected: non-JSON response {result!r}")
|
|
110
|
+
|
|
111
|
+
data_field = result.get("data") or {}
|
|
112
|
+
if result.get("code") == 0 and data_field.get("uploaded") is True:
|
|
113
|
+
return file_path, get_file_download_url(file_path)
|
|
114
|
+
|
|
115
|
+
raise RuntimeError(f"Upload rejected: {result}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Public upload API
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async def upload_file_to_server(
|
|
123
|
+
local_path: str,
|
|
124
|
+
*,
|
|
125
|
+
api_key: str,
|
|
126
|
+
session,
|
|
127
|
+
custom_file_name: Optional[str] = None,
|
|
128
|
+
) -> Tuple[str, str]:
|
|
129
|
+
"""Upload a local file to ai-server.
|
|
130
|
+
|
|
131
|
+
Returns ``(server_file_path, public_url)``.
|
|
132
|
+
|
|
133
|
+
:param local_path: absolute path of the file to upload.
|
|
134
|
+
:param api_key: tenant API key (Bearer auth).
|
|
135
|
+
:param session: an ``aiohttp.ClientSession``.
|
|
136
|
+
:param custom_file_name: override the remote file name.
|
|
137
|
+
:raises FileNotFoundError: when *local_path* does not exist or is
|
|
138
|
+
not a regular file.
|
|
139
|
+
:raises RuntimeError: for any network / server rejection.
|
|
140
|
+
"""
|
|
141
|
+
if not local_path or not os.path.exists(local_path):
|
|
142
|
+
raise FileNotFoundError(f"File not found: {local_path}")
|
|
143
|
+
if not os.path.isfile(local_path):
|
|
144
|
+
raise RuntimeError(f"Not a regular file: {local_path}")
|
|
145
|
+
|
|
146
|
+
file_name = custom_file_name or os.path.basename(local_path)
|
|
147
|
+
mime = guess_mime_by_ext(file_name)
|
|
148
|
+
|
|
149
|
+
with open(local_path, "rb") as fp:
|
|
150
|
+
payload = fp.read()
|
|
151
|
+
|
|
152
|
+
file_path, public_url = await _do_upload(
|
|
153
|
+
data=payload, file_name=file_name, mime=mime,
|
|
154
|
+
api_key=api_key, session=session,
|
|
155
|
+
)
|
|
156
|
+
logger.info("[lightclaw] uploaded %s → %s", local_path, public_url)
|
|
157
|
+
return file_path, public_url
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def upload_buffer_to_server(
|
|
161
|
+
buffer: bytes,
|
|
162
|
+
file_name: str,
|
|
163
|
+
mime: str,
|
|
164
|
+
*,
|
|
165
|
+
api_key: str,
|
|
166
|
+
session,
|
|
167
|
+
) -> Tuple[str, str]:
|
|
168
|
+
"""Upload an in-memory buffer. Same contract as :func:`upload_file_to_server`."""
|
|
169
|
+
if buffer is None:
|
|
170
|
+
raise RuntimeError("buffer must not be None")
|
|
171
|
+
file_path, public_url = await _do_upload(
|
|
172
|
+
data=buffer, file_name=file_name or "file",
|
|
173
|
+
mime=mime or "application/octet-stream",
|
|
174
|
+
api_key=api_key, session=session,
|
|
175
|
+
)
|
|
176
|
+
logger.info("[lightclaw] uploaded buffer (%d bytes) → %s", len(buffer), public_url)
|
|
177
|
+
return file_path, public_url
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def upload_and_get_public_url(
|
|
181
|
+
local_path: str,
|
|
182
|
+
*,
|
|
183
|
+
api_key: str,
|
|
184
|
+
session,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Convenience wrapper: upload and return only the public URL."""
|
|
187
|
+
_, url = await upload_file_to_server(
|
|
188
|
+
local_path, api_key=api_key, session=session,
|
|
189
|
+
)
|
|
190
|
+
return url
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Public download API
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
async def download_file_from_server(
|
|
198
|
+
file_path_or_url: str,
|
|
199
|
+
*,
|
|
200
|
+
api_key: str,
|
|
201
|
+
session,
|
|
202
|
+
) -> Tuple[bytes, str, str]:
|
|
203
|
+
"""GET ``/drive/preview`` for *file_path_or_url*.
|
|
204
|
+
|
|
205
|
+
Returns ``(buffer, file_name, content_type)``. If a full http(s) URL
|
|
206
|
+
is passed, it's used directly — otherwise it's treated as a remote
|
|
207
|
+
``filePath`` and wrapped with :func:`get_file_download_url`.
|
|
208
|
+
"""
|
|
209
|
+
import aiohttp
|
|
210
|
+
|
|
211
|
+
if not file_path_or_url:
|
|
212
|
+
raise RuntimeError("file_path_or_url is required")
|
|
213
|
+
|
|
214
|
+
url = (file_path_or_url
|
|
215
|
+
if file_path_or_url.startswith(("http://", "https://"))
|
|
216
|
+
else get_file_download_url(file_path_or_url))
|
|
217
|
+
headers = build_auth_headers(api_key)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
async with session.get(
|
|
221
|
+
url,
|
|
222
|
+
headers=headers,
|
|
223
|
+
timeout=aiohttp.ClientTimeout(total=DOWNLOAD_TIMEOUT),
|
|
224
|
+
) as resp:
|
|
225
|
+
if resp.status != 200:
|
|
226
|
+
body = (await resp.text())[:200]
|
|
227
|
+
raise RuntimeError(f"Download HTTP {resp.status}: {body}")
|
|
228
|
+
buf = await resp.read()
|
|
229
|
+
content_type = resp.headers.get("content-type", "application/octet-stream")
|
|
230
|
+
except asyncio.TimeoutError as exc:
|
|
231
|
+
raise RuntimeError(f"Download timeout after {DOWNLOAD_TIMEOUT}s") from exc
|
|
232
|
+
|
|
233
|
+
# Derive filename: strip query string + take last path segment.
|
|
234
|
+
tail = file_path_or_url.split("?", 1)[0]
|
|
235
|
+
file_name = tail.rsplit("/", 1)[-1] or "file"
|
|
236
|
+
return buf, file_name, content_type
|