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.
- lollmsbot/__init__.py +1 -0
- lollmsbot/agent.py +1682 -0
- lollmsbot/channels/__init__.py +22 -0
- lollmsbot/channels/discord.py +408 -0
- lollmsbot/channels/http_api.py +449 -0
- lollmsbot/channels/telegram.py +272 -0
- lollmsbot/cli.py +217 -0
- lollmsbot/config.py +90 -0
- lollmsbot/gateway.py +606 -0
- lollmsbot/guardian.py +692 -0
- lollmsbot/heartbeat.py +826 -0
- lollmsbot/lollms_client.py +37 -0
- lollmsbot/skills.py +1483 -0
- lollmsbot/soul.py +482 -0
- lollmsbot/storage/__init__.py +245 -0
- lollmsbot/storage/sqlite_store.py +332 -0
- lollmsbot/tools/__init__.py +151 -0
- lollmsbot/tools/calendar.py +717 -0
- lollmsbot/tools/filesystem.py +663 -0
- lollmsbot/tools/http.py +498 -0
- lollmsbot/tools/shell.py +519 -0
- lollmsbot/ui/__init__.py +11 -0
- lollmsbot/ui/__main__.py +121 -0
- lollmsbot/ui/app.py +1122 -0
- lollmsbot/ui/routes.py +39 -0
- lollmsbot/wizard.py +1493 -0
- lollmsbot-0.0.1.dist-info/METADATA +25 -0
- lollmsbot-0.0.1.dist-info/RECORD +32 -0
- lollmsbot-0.0.1.dist-info/WHEEL +5 -0
- lollmsbot-0.0.1.dist-info/entry_points.txt +2 -0
- lollmsbot-0.0.1.dist-info/licenses/LICENSE +201 -0
- lollmsbot-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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)"
|