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,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