lollmsbot 0.0.1__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,449 @@
1
+ """
2
+ HTTP API channel implementation for LollmsBot.
3
+
4
+ Uses shared Agent for all business logic.
5
+ """
6
+
7
+ import asyncio
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import logging
12
+ import os
13
+ import time
14
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
15
+ from pathlib import Path
16
+ from dataclasses import dataclass
17
+
18
+ import uvicorn
19
+ from fastapi import FastAPI, Header, HTTPException, Request, status, Query
20
+ from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
21
+
22
+ from lollmsbot.agent import Agent, PermissionLevel
23
+
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class FileDelivery:
30
+ """Represents a file ready for delivery to a user."""
31
+ file_id: str
32
+ original_path: str
33
+ filename: str
34
+ description: str
35
+ user_id: str
36
+ created_at: float
37
+ content_type: Optional[str] = None
38
+
39
+
40
+ class HttpApiChannel:
41
+ """HTTP API channel using shared Agent.
42
+
43
+ Provides webhook endpoints and REST API for external integration.
44
+ All business logic is delegated to the Agent.
45
+ Includes file delivery endpoints for generated files.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ agent: Agent,
51
+ host: str = "localhost",
52
+ port: int = 8080,
53
+ webhook_path: str = "/webhook",
54
+ api_key: Optional[str] = None,
55
+ ):
56
+ self.agent = agent
57
+ self.host = host
58
+ self.port = port
59
+ self.webhook_path = webhook_path
60
+ self.api_key = api_key
61
+
62
+ self.app: Optional[FastAPI] = None
63
+ self._server: Optional[uvicorn.Server] = None
64
+ self._server_task: Optional[asyncio.Task] = None
65
+ self._is_running = False
66
+
67
+ # File delivery storage
68
+ self._pending_files: Dict[str, FileDelivery] = {}
69
+ self._file_cleanup_task: Optional[asyncio.Task] = None
70
+ self._file_ttl_seconds: float = 3600.0 # Files expire after 1 hour
71
+
72
+ # Register file delivery callback with agent
73
+ self.agent.set_file_delivery_callback(self._deliver_files)
74
+
75
+ self._setup_app()
76
+
77
+ def _generate_file_id(self, user_id: str, filename: str) -> str:
78
+ """Generate unique file ID for download URL."""
79
+ timestamp = str(time.time())
80
+ hash_input = f"{user_id}:{filename}:{timestamp}:{os.urandom(8).hex()}"
81
+ return hashlib.sha256(hash_input.encode()).hexdigest()[:16]
82
+
83
+ async def _deliver_files(self, user_id: str, files: List[Dict[str, Any]]) -> bool:
84
+ """Store files for HTTP download delivery.
85
+
86
+ This is the callback registered with the Agent for file delivery.
87
+ Files are stored with unique IDs and made available via download endpoint.
88
+
89
+ Args:
90
+ user_id: Agent-format user ID (e.g., "http:anonymous").
91
+ files: List of file dicts with 'path', 'filename', 'description' keys.
92
+
93
+ Returns:
94
+ True if files were registered for delivery successfully.
95
+ """
96
+ if not files:
97
+ logger.debug(f"No files to deliver for {user_id}")
98
+ return True
99
+
100
+ try:
101
+ logger.info(f"📤 Registering {len(files)} file(s) for download delivery to {user_id}")
102
+
103
+ for file_info in files:
104
+ file_path = file_info.get("path")
105
+ filename = file_info.get("filename") or Path(file_path).name if file_path else "unnamed"
106
+ description = file_info.get("description", "")
107
+
108
+ if not file_path or not Path(file_path).exists():
109
+ logger.warning(f"File not found for delivery: {file_path}")
110
+ continue
111
+
112
+ # Generate unique file ID
113
+ file_id = self._generate_file_id(user_id, filename)
114
+
115
+ # Determine content type
116
+ content_type = self._guess_content_type(filename)
117
+
118
+ # Store file delivery info
119
+ delivery = FileDelivery(
120
+ file_id=file_id,
121
+ original_path=file_path,
122
+ filename=filename,
123
+ description=description,
124
+ user_id=user_id,
125
+ created_at=time.time(),
126
+ content_type=content_type,
127
+ )
128
+ self._pending_files[file_id] = delivery
129
+ logger.info(f"✅ Registered file '{filename}' with ID {file_id}")
130
+
131
+ return True
132
+
133
+ except Exception as e:
134
+ logger.error(f"Failed to register files for delivery to {user_id}: {e}")
135
+ return False
136
+
137
+ def _guess_content_type(self, filename: str) -> Optional[str]:
138
+ """Guess MIME type from filename extension."""
139
+ ext = Path(filename).suffix.lower()
140
+ mime_types = {
141
+ ".html": "text/html",
142
+ ".htm": "text/html",
143
+ ".css": "text/css",
144
+ ".js": "application/javascript",
145
+ ".json": "application/json",
146
+ ".py": "text/x-python",
147
+ ".txt": "text/plain",
148
+ ".md": "text/markdown",
149
+ ".csv": "text/csv",
150
+ ".png": "image/png",
151
+ ".jpg": "image/jpeg",
152
+ ".jpeg": "image/jpeg",
153
+ ".gif": "image/gif",
154
+ ".svg": "image/svg+xml",
155
+ ".pdf": "application/pdf",
156
+ ".zip": "application/zip",
157
+ ".tar": "application/x-tar",
158
+ ".gz": "application/gzip",
159
+ }
160
+ return mime_types.get(ext)
161
+
162
+ async def _cleanup_expired_files(self) -> None:
163
+ """Background task to clean up expired file registrations."""
164
+ while self._is_running:
165
+ try:
166
+ now = time.time()
167
+ expired = [
168
+ file_id for file_id, delivery in self._pending_files.items()
169
+ if now - delivery.created_at > self._file_ttl_seconds
170
+ ]
171
+ for file_id in expired:
172
+ del self._pending_files[file_id]
173
+ logger.debug(f"Cleaned up expired file registration: {file_id}")
174
+
175
+ await asyncio.sleep(60.0) # Check every minute
176
+
177
+ except asyncio.CancelledError:
178
+ break
179
+ except Exception as e:
180
+ logger.error(f"Error in file cleanup: {e}")
181
+ await asyncio.sleep(60.0)
182
+
183
+ def _setup_app(self) -> None:
184
+ """Configure FastAPI application."""
185
+ self.app = FastAPI(
186
+ title="LollmsBot HTTP API",
187
+ description="HTTP API for LollmsBot agent with file delivery support",
188
+ version="0.2.0",
189
+ )
190
+
191
+ @self.app.get("/health")
192
+ async def health_check():
193
+ return {
194
+ "status": "healthy",
195
+ "agent": self.agent.name,
196
+ "running": self._is_running,
197
+ "pending_files": len(self._pending_files),
198
+ }
199
+
200
+ @self.app.post(self.webhook_path)
201
+ async def webhook_endpoint(
202
+ request: Request,
203
+ x_api_key: Optional[str] = Header(None),
204
+ ):
205
+ """Handle incoming webhook messages."""
206
+ # Validate API key if configured
207
+ if self.api_key and x_api_key != self.api_key:
208
+ raise HTTPException(
209
+ status_code=status.HTTP_401_UNAUTHORIZED,
210
+ detail="Invalid API key",
211
+ )
212
+
213
+ try:
214
+ body = await request.json()
215
+ except json.JSONDecodeError:
216
+ raise HTTPException(
217
+ status_code=status.HTTP_400_BAD_REQUEST,
218
+ detail="Invalid JSON",
219
+ )
220
+
221
+ user_id = body.get("user_id", "unknown")
222
+ message = body.get("message", "").strip()
223
+
224
+ if not message:
225
+ raise HTTPException(
226
+ status_code=status.HTTP_400_BAD_REQUEST,
227
+ detail="message is required",
228
+ )
229
+
230
+ # Process via Agent
231
+ result = await self.agent.chat(
232
+ user_id=f"http:{user_id}",
233
+ message=message,
234
+ context={"channel": "http", "source": "webhook"},
235
+ )
236
+
237
+ # Build response with file download info
238
+ response_data = {
239
+ "success": result.get("success"),
240
+ "response": result.get("response"),
241
+ "error": result.get("error"),
242
+ "tools_used": result.get("tools_used"),
243
+ }
244
+
245
+ # Add file download URLs if files were generated
246
+ files_generated = result.get("files_to_send", [])
247
+ if files_generated:
248
+ download_urls = []
249
+ for file_info in files_generated:
250
+ # Find the file_id we assigned
251
+ for file_id, delivery in self._pending_files.items():
252
+ if delivery.original_path == file_info.get("path"):
253
+ download_urls.append({
254
+ "filename": delivery.filename,
255
+ "download_url": f"/files/download/{file_id}",
256
+ "description": delivery.description,
257
+ "expires_in_seconds": int(self._file_ttl_seconds - (time.time() - delivery.created_at)),
258
+ })
259
+ break
260
+
261
+ response_data["files"] = {
262
+ "count": len(download_urls),
263
+ "downloads": download_urls,
264
+ }
265
+
266
+ return response_data
267
+
268
+ @self.app.post("/chat")
269
+ async def chat_endpoint(request: Request):
270
+ """Direct chat endpoint with file delivery support."""
271
+ try:
272
+ body = await request.json()
273
+ except json.JSONDecodeError:
274
+ raise HTTPException(
275
+ status_code=status.HTTP_400_BAD_REQUEST,
276
+ detail="Invalid JSON",
277
+ )
278
+
279
+ user_id = body.get("user_id", "anonymous")
280
+ message = body.get("message", "").strip()
281
+
282
+ if not message:
283
+ raise HTTPException(
284
+ status_code=status.HTTP_400_BAD_REQUEST,
285
+ detail="message is required",
286
+ )
287
+
288
+ result = await self.agent.chat(
289
+ user_id=f"http:{user_id}",
290
+ message=message,
291
+ context={"channel": "http", "source": "direct_api"},
292
+ )
293
+
294
+ # Build response with file download info
295
+ response_data = {
296
+ "success": result.get("success"),
297
+ "response": result.get("response"),
298
+ "error": result.get("error"),
299
+ "tools_used": result.get("tools_used"),
300
+ }
301
+
302
+ # Add file download URLs if files were generated
303
+ files_generated = result.get("files_to_send", [])
304
+ if files_generated:
305
+ download_urls = []
306
+ for file_info in files_generated:
307
+ # Find the file_id we assigned
308
+ for file_id, delivery in self._pending_files.items():
309
+ if delivery.original_path == file_info.get("path"):
310
+ download_urls.append({
311
+ "filename": delivery.filename,
312
+ "download_url": f"/files/download/{file_id}",
313
+ "description": delivery.description,
314
+ "expires_in_seconds": int(self._file_ttl_seconds - (time.time() - delivery.created_at)),
315
+ })
316
+ break
317
+
318
+ response_data["files"] = {
319
+ "count": len(download_urls),
320
+ "downloads": download_urls,
321
+ }
322
+
323
+ return response_data
324
+
325
+ @self.app.get("/files/download/{file_id}")
326
+ async def download_file(file_id: str):
327
+ """Download a generated file by its temporary ID."""
328
+ if file_id not in self._pending_files:
329
+ raise HTTPException(
330
+ status_code=status.HTTP_404_NOT_FOUND,
331
+ detail="File not found or expired",
332
+ )
333
+
334
+ delivery = self._pending_files[file_id]
335
+
336
+ # Check if file still exists on disk
337
+ file_path = Path(delivery.original_path)
338
+ if not file_path.exists():
339
+ # Clean up stale registration
340
+ del self._pending_files[file_id]
341
+ raise HTTPException(
342
+ status_code=status.HTTP_404_NOT_FOUND,
343
+ detail="File no longer available",
344
+ )
345
+
346
+ # Check expiration
347
+ if time.time() - delivery.created_at > self._file_ttl_seconds:
348
+ del self._pending_files[file_id]
349
+ raise HTTPException(
350
+ status_code=status.HTTP_410_GONE,
351
+ detail="File download link has expired",
352
+ )
353
+
354
+ # Return file with appropriate content type
355
+ media_type = delivery.content_type or "application/octet-stream"
356
+
357
+ return FileResponse(
358
+ path=str(file_path),
359
+ filename=delivery.filename,
360
+ media_type=media_type,
361
+ )
362
+
363
+ @self.app.get("/files/list")
364
+ async def list_pending_files(user_id: Optional[str] = Query(None)):
365
+ """List pending files for a user (or all if no user specified)."""
366
+ files = []
367
+ for file_id, delivery in self._pending_files.items():
368
+ if user_id is None or delivery.user_id == user_id:
369
+ files.append({
370
+ "file_id": file_id,
371
+ "filename": delivery.filename,
372
+ "description": delivery.description,
373
+ "download_url": f"/files/download/{file_id}",
374
+ "created_at": delivery.created_at,
375
+ "expires_in_seconds": int(self._file_ttl_seconds - (time.time() - delivery.created_at)),
376
+ })
377
+
378
+ return {
379
+ "count": len(files),
380
+ "files": files,
381
+ }
382
+
383
+ async def start(self) -> None:
384
+ """Start the HTTP server."""
385
+ if self._is_running:
386
+ return
387
+
388
+ try:
389
+ config = uvicorn.Config(
390
+ app=self.app,
391
+ host=self.host,
392
+ port=self.port,
393
+ log_level="info",
394
+ )
395
+ self._server = uvicorn.Server(config)
396
+ self._server_task = asyncio.create_task(self._server.serve())
397
+
398
+ # Start file cleanup task
399
+ self._file_cleanup_task = asyncio.create_task(self._cleanup_expired_files())
400
+
401
+ self._is_running = True
402
+
403
+ logger.info(f"HTTP API channel started on http://{self.host}:{self.port}")
404
+ logger.info(f"File download endpoint: http://{self.host}:{self.port}/files/download/<file_id>")
405
+
406
+ except Exception as exc:
407
+ logger.error(f"Failed to start HTTP API channel: {exc}")
408
+ self._is_running = False
409
+ raise
410
+
411
+ async def stop(self) -> None:
412
+ """Stop the HTTP server."""
413
+ if not self._is_running:
414
+ return
415
+
416
+ self._is_running = False
417
+
418
+ # Stop cleanup task
419
+ if self._file_cleanup_task:
420
+ self._file_cleanup_task.cancel()
421
+ try:
422
+ await self._file_cleanup_task
423
+ except asyncio.CancelledError:
424
+ pass
425
+
426
+ if self._server:
427
+ self._server.should_exit = True
428
+
429
+ if self._server_task:
430
+ try:
431
+ await asyncio.wait_for(self._server_task, timeout=5.0)
432
+ except asyncio.TimeoutError:
433
+ self._server_task.cancel()
434
+ try:
435
+ await self._server_task
436
+ except asyncio.CancelledError:
437
+ pass
438
+
439
+ # Clear pending files
440
+ self._pending_files.clear()
441
+
442
+ self._server = None
443
+ self._server_task = None
444
+ self._file_cleanup_task = None
445
+ logger.info("HTTP API channel stopped")
446
+
447
+ def __repr__(self) -> str:
448
+ status = "running" if self._is_running else "stopped"
449
+ return f"HttpApiChannel({self.host}:{self.port}, {status}, {len(self._pending_files)} pending files)"