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,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})"