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,22 @@
|
|
|
1
|
+
"""Channel adapters for LollmsBot.
|
|
2
|
+
|
|
3
|
+
All channels use the shared Agent for business logic.
|
|
4
|
+
This package provides channel implementations for various messaging platforms.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from lollmsbot.channels.discord import DiscordChannel
|
|
8
|
+
from lollmsbot.channels.http_api import HttpApiChannel
|
|
9
|
+
|
|
10
|
+
# Telegram is optional - import if available
|
|
11
|
+
try:
|
|
12
|
+
from lollmsbot.channels.telegram import TelegramChannel
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DiscordChannel",
|
|
15
|
+
"TelegramChannel",
|
|
16
|
+
"HttpApiChannel",
|
|
17
|
+
]
|
|
18
|
+
except ImportError:
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DiscordChannel",
|
|
21
|
+
"HttpApiChannel",
|
|
22
|
+
]
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import platform
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Optional, Set, Dict, Any, List
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
import discord
|
|
10
|
+
from discord import Embed, Color, File
|
|
11
|
+
|
|
12
|
+
from lollmsbot.agent import Agent, PermissionLevel
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DiscordUserSession:
|
|
20
|
+
"""Per-user session data for maintaining state."""
|
|
21
|
+
user_id: str
|
|
22
|
+
history: List[Dict[str, str]] = field(default_factory=list)
|
|
23
|
+
max_history: int = 10
|
|
24
|
+
personality: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
def add_exchange(self, user_msg: str, bot_response: str):
|
|
27
|
+
"""Add a conversation exchange to history."""
|
|
28
|
+
self.history.append({"role": "user", "content": user_msg})
|
|
29
|
+
self.history.append({"role": "assistant", "content": bot_response})
|
|
30
|
+
# Trim to max history
|
|
31
|
+
while len(self.history) > self.max_history * 2:
|
|
32
|
+
self.history.pop(0)
|
|
33
|
+
|
|
34
|
+
def get_context_prompt(self, current_message: str) -> str:
|
|
35
|
+
"""Build a prompt with conversation context."""
|
|
36
|
+
if not self.history:
|
|
37
|
+
return current_message
|
|
38
|
+
|
|
39
|
+
context_parts = []
|
|
40
|
+
# Add personality if set
|
|
41
|
+
if self.personality:
|
|
42
|
+
context_parts.append(f"[System: You are {self.personality}]")
|
|
43
|
+
|
|
44
|
+
# Add recent history
|
|
45
|
+
for entry in self.history[-6:]: # Last 3 exchanges
|
|
46
|
+
prefix = "User" if entry["role"] == "user" else "Assistant"
|
|
47
|
+
context_parts.append(f"{prefix}: {entry['content']}")
|
|
48
|
+
|
|
49
|
+
context_parts.append(f"User: {current_message}")
|
|
50
|
+
context_parts.append("Assistant:")
|
|
51
|
+
|
|
52
|
+
return "\n".join(context_parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DiscordChannel:
|
|
56
|
+
"""Enhanced Discord bot channel with full LoLLMS Agent capabilities and file delivery."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
agent: Agent,
|
|
61
|
+
bot_token: Optional[str] = None,
|
|
62
|
+
allowed_guilds: Optional[Set[int]] = None,
|
|
63
|
+
allowed_users: Optional[Set[int]] = None,
|
|
64
|
+
blocked_users: Optional[Set[int]] = None,
|
|
65
|
+
require_mention_in_guild: bool = True,
|
|
66
|
+
require_mention_in_dm: bool = False,
|
|
67
|
+
):
|
|
68
|
+
self.agent = agent
|
|
69
|
+
self.bot_token = bot_token
|
|
70
|
+
self.allowed_guilds = allowed_guilds
|
|
71
|
+
self.allowed_users = allowed_users
|
|
72
|
+
self.blocked_users = blocked_users or set()
|
|
73
|
+
self.require_mention_in_guild = require_mention_in_guild
|
|
74
|
+
self.require_mention_in_dm = require_mention_in_dm
|
|
75
|
+
|
|
76
|
+
self._is_running = False
|
|
77
|
+
self._ready_event = asyncio.Event()
|
|
78
|
+
|
|
79
|
+
self.intents = discord.Intents.default()
|
|
80
|
+
self.intents.message_content = True
|
|
81
|
+
self.intents.guilds = True
|
|
82
|
+
self.intents.guild_messages = True
|
|
83
|
+
self.intents.dm_messages = True
|
|
84
|
+
|
|
85
|
+
self.bot = discord.Client(intents=self.intents)
|
|
86
|
+
self._setup_handlers()
|
|
87
|
+
|
|
88
|
+
# Register file delivery callback with agent
|
|
89
|
+
self.agent.set_file_delivery_callback(self._deliver_files)
|
|
90
|
+
|
|
91
|
+
def _setup_handlers(self) -> None:
|
|
92
|
+
"""Set up Discord.py event handlers."""
|
|
93
|
+
|
|
94
|
+
@self.bot.event
|
|
95
|
+
async def on_ready():
|
|
96
|
+
self._is_running = True
|
|
97
|
+
self._ready_event.set()
|
|
98
|
+
logger.info(f"š¤ Discord bot '{self.bot.user}' ready!")
|
|
99
|
+
logger.info(f" Servers: {len(self.bot.guilds)}")
|
|
100
|
+
for guild in self.bot.guilds:
|
|
101
|
+
logger.info(f" ⢠{guild.name}")
|
|
102
|
+
print(f"š¤ Discord ready with full agent capabilities!")
|
|
103
|
+
|
|
104
|
+
@self.bot.event
|
|
105
|
+
async def on_message(message: discord.Message):
|
|
106
|
+
await self._handle_message(message)
|
|
107
|
+
|
|
108
|
+
def _can_interact(self, message: discord.Message) -> tuple[bool, str]:
|
|
109
|
+
"""Check if we should process this message."""
|
|
110
|
+
if message.author == self.bot.user:
|
|
111
|
+
return False, "own message"
|
|
112
|
+
if message.author.bot:
|
|
113
|
+
return False, "bot message"
|
|
114
|
+
if message.author.id in self.blocked_users:
|
|
115
|
+
return False, "user blocked"
|
|
116
|
+
if self.allowed_users is not None:
|
|
117
|
+
if message.author.id not in self.allowed_users:
|
|
118
|
+
return False, "not in allowed users"
|
|
119
|
+
if message.guild:
|
|
120
|
+
if self.allowed_guilds is not None:
|
|
121
|
+
if message.guild.id not in self.allowed_guilds:
|
|
122
|
+
return False, "guild not allowed"
|
|
123
|
+
if self.require_mention_in_guild:
|
|
124
|
+
is_mentioned = self.bot.user in message.mentions
|
|
125
|
+
if not is_mentioned and not self._content_mentions_bot(message):
|
|
126
|
+
return False, "not mentioned in guild"
|
|
127
|
+
else:
|
|
128
|
+
if self.require_mention_in_dm:
|
|
129
|
+
if self.bot.user not in message.mentions:
|
|
130
|
+
return False, "not mentioned in DM"
|
|
131
|
+
return True, ""
|
|
132
|
+
|
|
133
|
+
def _content_mentions_bot(self, message: discord.Message) -> bool:
|
|
134
|
+
"""Check if message content contains bot mention."""
|
|
135
|
+
if not self.bot.user:
|
|
136
|
+
return False
|
|
137
|
+
patterns = [
|
|
138
|
+
f"<@{self.bot.user.id}>",
|
|
139
|
+
f"<@!{self.bot.user.id}>",
|
|
140
|
+
]
|
|
141
|
+
return any(p in message.content for p in patterns)
|
|
142
|
+
|
|
143
|
+
def _extract_clean_content(self, message: discord.Message) -> str:
|
|
144
|
+
"""Extract message content, removing bot mentions."""
|
|
145
|
+
if not self.bot.user:
|
|
146
|
+
return message.content.strip()
|
|
147
|
+
|
|
148
|
+
content = message.content
|
|
149
|
+
patterns = [
|
|
150
|
+
f"<@{self.bot.user.id}>",
|
|
151
|
+
f"<@!{self.bot.user.id}>",
|
|
152
|
+
f"@{self.bot.user.name}",
|
|
153
|
+
]
|
|
154
|
+
if self.bot.user.discriminator != "0":
|
|
155
|
+
patterns.append(f"@{self.bot.user.name}#{self.bot.user.discriminator}")
|
|
156
|
+
|
|
157
|
+
for pattern in patterns:
|
|
158
|
+
content = content.replace(pattern, "")
|
|
159
|
+
|
|
160
|
+
return content.strip()
|
|
161
|
+
|
|
162
|
+
def _get_user_id(self, message: discord.Message) -> str:
|
|
163
|
+
"""Generate consistent user ID."""
|
|
164
|
+
return f"discord:{message.author.id}"
|
|
165
|
+
|
|
166
|
+
def _get_discord_user_id(self, agent_user_id: str) -> Optional[int]:
|
|
167
|
+
"""Extract Discord user ID from agent user ID."""
|
|
168
|
+
if agent_user_id.startswith("discord:"):
|
|
169
|
+
try:
|
|
170
|
+
return int(agent_user_id.split(":", 1)[1])
|
|
171
|
+
except ValueError:
|
|
172
|
+
pass
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
async def _deliver_files(self, user_id: str, files: List[Dict[str, Any]]) -> bool:
|
|
176
|
+
"""Deliver files to a Discord user via DM.
|
|
177
|
+
|
|
178
|
+
This is the callback registered with the Agent for file delivery.
|
|
179
|
+
Files are always sent via DM to avoid cluttering guild channels.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
user_id: Agent-format user ID (e.g., "discord:123456").
|
|
183
|
+
files: List of file dicts with 'path', 'filename', 'description' keys.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
True if all files were delivered successfully.
|
|
187
|
+
"""
|
|
188
|
+
discord_id = self._get_discord_user_id(user_id)
|
|
189
|
+
if not discord_id:
|
|
190
|
+
logger.warning(f"Cannot deliver files: unknown user format {user_id}")
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
if not files:
|
|
194
|
+
logger.debug(f"No files to deliver for {user_id}")
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
# Get or create DM channel
|
|
199
|
+
user = await self.bot.fetch_user(discord_id)
|
|
200
|
+
if not user:
|
|
201
|
+
logger.warning(f"Cannot deliver files: user {discord_id} not found")
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
dm_channel = await user.create_dm()
|
|
205
|
+
|
|
206
|
+
logger.info(f"š¤ Delivering {len(files)} file(s) to user {discord_id}")
|
|
207
|
+
|
|
208
|
+
# Send intro message
|
|
209
|
+
file_list = ", ".join([f.get("filename", "unnamed") for f in files])
|
|
210
|
+
if len(files) == 1:
|
|
211
|
+
await dm_channel.send(f"š Here's your file: **{file_list}**")
|
|
212
|
+
else:
|
|
213
|
+
await dm_channel.send(f"š Here are your {len(files)} files: **{file_list}**")
|
|
214
|
+
|
|
215
|
+
# Send each file
|
|
216
|
+
success_count = 0
|
|
217
|
+
for file_info in files:
|
|
218
|
+
file_path = file_info.get("path")
|
|
219
|
+
filename = file_info.get("filename") or Path(file_path).name
|
|
220
|
+
description = file_info.get("description", "")
|
|
221
|
+
|
|
222
|
+
if not file_path or not Path(file_path).exists():
|
|
223
|
+
logger.warning(f"File not found for delivery: {file_path}")
|
|
224
|
+
await dm_channel.send(f"ā ļø Could not find file: {filename}")
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
# Send with optional description
|
|
229
|
+
if description:
|
|
230
|
+
await dm_channel.send(description[:1900]) # Discord limit
|
|
231
|
+
|
|
232
|
+
# Send file
|
|
233
|
+
discord_file = File(file_path, filename=filename)
|
|
234
|
+
await dm_channel.send(file=discord_file)
|
|
235
|
+
success_count += 1
|
|
236
|
+
logger.info(f"ā
Delivered file {filename} to user {discord_id}")
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.error(f"Failed to send file {filename}: {e}")
|
|
239
|
+
await dm_channel.send(f"ā Failed to send {filename}: {str(e)[:100]}")
|
|
240
|
+
|
|
241
|
+
return success_count == len(files)
|
|
242
|
+
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.error(f"Failed to deliver files to {user_id}: {e}")
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
async def _handle_message(self, message: discord.Message) -> None:
|
|
248
|
+
"""Process an incoming Discord message with full agent capabilities."""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
location = "DM" if message.guild is None else f"#{message.channel.name}"
|
|
252
|
+
logger.info(f"Message from {message.author} in {location}: '{message.content[:100]}'")
|
|
253
|
+
|
|
254
|
+
can_interact, reason = self._can_interact(message)
|
|
255
|
+
if not can_interact:
|
|
256
|
+
logger.debug(f"Ignoring: {reason}")
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
clean_content = self._extract_clean_content(message)
|
|
260
|
+
if not clean_content:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
user_id = self._get_user_id(message)
|
|
264
|
+
is_dm = message.guild is None
|
|
265
|
+
|
|
266
|
+
async with message.channel.typing():
|
|
267
|
+
try:
|
|
268
|
+
# Use the shared Agent for processing
|
|
269
|
+
result = await self.agent.chat(
|
|
270
|
+
user_id=user_id,
|
|
271
|
+
message=clean_content,
|
|
272
|
+
context={
|
|
273
|
+
"channel": "discord",
|
|
274
|
+
"discord_guild_id": message.guild.id if message.guild else None,
|
|
275
|
+
"discord_channel_id": message.channel.id,
|
|
276
|
+
"discord_is_dm": is_dm,
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Handle permission denied
|
|
281
|
+
if result.get("permission_denied"):
|
|
282
|
+
await message.reply("ā You don't have permission to use this bot.")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# Handle error
|
|
286
|
+
if not result.get("success"):
|
|
287
|
+
error_msg = result.get("error", "Unknown error")
|
|
288
|
+
await message.reply(f"ā Error: {error_msg[:500]}")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# Get response
|
|
292
|
+
response = result.get("response", "No response")
|
|
293
|
+
|
|
294
|
+
# Get files info
|
|
295
|
+
files_to_send = result.get("files_to_send", [])
|
|
296
|
+
tools_used = result.get("tools_used", [])
|
|
297
|
+
|
|
298
|
+
# Build response with file info
|
|
299
|
+
final_response = response
|
|
300
|
+
|
|
301
|
+
# If files were generated, mention them
|
|
302
|
+
if files_to_send:
|
|
303
|
+
file_count = len(files_to_send)
|
|
304
|
+
file_list = ", ".join([f.get("filename", "unnamed") for f in files_to_send[:3]])
|
|
305
|
+
if file_count > 3:
|
|
306
|
+
file_list += f" and {file_count - 3} more"
|
|
307
|
+
|
|
308
|
+
# Add file delivery notice
|
|
309
|
+
if is_dm:
|
|
310
|
+
final_response += f"\n\nš I've sent {file_count} file(s): {file_list}"
|
|
311
|
+
else:
|
|
312
|
+
final_response += f"\n\nš Check your DMs! I've sent you {file_count} file(s): {file_list}"
|
|
313
|
+
|
|
314
|
+
# Send text response (respecting Discord's 2000 char limit)
|
|
315
|
+
await self._send_response(message, final_response)
|
|
316
|
+
|
|
317
|
+
# Log tool usage
|
|
318
|
+
if tools_used:
|
|
319
|
+
logger.info(f"š§ Tools used for {user_id}: {', '.join(tools_used)}")
|
|
320
|
+
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
logger.exception(f"Error processing message: {exc}")
|
|
323
|
+
try:
|
|
324
|
+
await message.reply("šØ An error occurred. Please try again.")
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
async def _send_response(self, message: discord.Message, response: str) -> None:
|
|
329
|
+
"""Send response with appropriate formatting."""
|
|
330
|
+
# Discord has 2000 char limit for regular messages
|
|
331
|
+
MAX_LEN = 1950
|
|
332
|
+
|
|
333
|
+
if len(response) <= MAX_LEN:
|
|
334
|
+
await message.reply(response)
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
# For long responses, split intelligently
|
|
338
|
+
chunks = self._split_message(response, MAX_LEN)
|
|
339
|
+
|
|
340
|
+
# Send first chunk as reply, rest as follow-ups
|
|
341
|
+
for i, chunk in enumerate(chunks):
|
|
342
|
+
if i == 0:
|
|
343
|
+
await message.reply(chunk)
|
|
344
|
+
else:
|
|
345
|
+
await message.channel.send(chunk)
|
|
346
|
+
|
|
347
|
+
# Small delay to avoid rate limiting
|
|
348
|
+
if i < len(chunks) - 1:
|
|
349
|
+
await asyncio.sleep(0.5)
|
|
350
|
+
|
|
351
|
+
def _split_message(self, text: str, max_len: int = 1950) -> List[str]:
|
|
352
|
+
"""Split long message into Discord-compatible chunks."""
|
|
353
|
+
if len(text) <= max_len:
|
|
354
|
+
return [text]
|
|
355
|
+
|
|
356
|
+
chunks = []
|
|
357
|
+
remaining = text
|
|
358
|
+
|
|
359
|
+
while remaining:
|
|
360
|
+
if len(remaining) <= max_len:
|
|
361
|
+
chunks.append(remaining)
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
# Find good break point
|
|
365
|
+
split_at = remaining.rfind('\n\n', 0, max_len)
|
|
366
|
+
if split_at == -1:
|
|
367
|
+
split_at = remaining.rfind('\n', 0, max_len)
|
|
368
|
+
if split_at == -1:
|
|
369
|
+
split_at = remaining.rfind('. ', 0, max_len)
|
|
370
|
+
if split_at == -1:
|
|
371
|
+
split_at = remaining.rfind(' ', 0, max_len)
|
|
372
|
+
if split_at == -1:
|
|
373
|
+
split_at = max_len
|
|
374
|
+
|
|
375
|
+
chunks.append(remaining[:split_at])
|
|
376
|
+
remaining = remaining[split_at:].lstrip()
|
|
377
|
+
|
|
378
|
+
return chunks
|
|
379
|
+
|
|
380
|
+
async def start(self):
|
|
381
|
+
"""Start Discord bot."""
|
|
382
|
+
if not self.bot_token:
|
|
383
|
+
raise ValueError("Discord bot token required")
|
|
384
|
+
|
|
385
|
+
logger.info("Starting Discord bot with full agent capabilities...")
|
|
386
|
+
logging.getLogger('discord').setLevel(logging.INFO)
|
|
387
|
+
await self.bot.start(self.bot_token)
|
|
388
|
+
|
|
389
|
+
async def stop(self):
|
|
390
|
+
"""Graceful shutdown."""
|
|
391
|
+
self._is_running = False
|
|
392
|
+
await self.bot.close()
|
|
393
|
+
logger.info("Discord bot stopped")
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def is_running(self) -> bool:
|
|
397
|
+
return self._is_running and self._ready_event.is_set()
|
|
398
|
+
|
|
399
|
+
async def wait_for_ready(self, timeout: float = 30.0) -> bool:
|
|
400
|
+
try:
|
|
401
|
+
await asyncio.wait_for(self._ready_event.wait(), timeout=timeout)
|
|
402
|
+
return True
|
|
403
|
+
except asyncio.TimeoutError:
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
def __repr__(self) -> str:
|
|
407
|
+
status = "ready" if self.is_running else "connecting" if self._is_running else "stopped"
|
|
408
|
+
return f"DiscordChannel({status}, agent={self.agent.name})"
|