zeroclaw/python/zeroclaw_tools/integrations/discord_bot.py

177 lines
5 KiB
Python

"""
Discord bot integration for ZeroClaw.
"""
import os
from typing import Optional, Set
try:
import discord
DISCORD_AVAILABLE = True
except ImportError:
DISCORD_AVAILABLE = False
discord = None
from langchain_core.messages import HumanMessage
from ..agent import create_agent
from ..tools import shell, file_read, file_write, web_search
class DiscordBot:
"""
Discord bot powered by ZeroClaw agent with LangGraph tool calling.
Example:
```python
import os
from zeroclaw_tools.integrations import DiscordBot
bot = DiscordBot(
token=os.environ["DISCORD_TOKEN"],
guild_id=123456789,
allowed_users=["123456789"],
api_key=os.environ["API_KEY"]
)
bot.run()
```
"""
def __init__(
self,
token: str,
guild_id: int,
allowed_users: list[str],
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model: str = "glm-5",
prefix: str = "",
):
if not DISCORD_AVAILABLE:
raise ImportError(
"discord.py is required for Discord integration. "
"Install with: pip install zeroclaw-tools[discord]"
)
self.token = token
self.guild_id = guild_id
self.allowed_users: Set[str] = set(allowed_users)
self.api_key = api_key or os.environ.get("API_KEY")
self.base_url = base_url or os.environ.get("API_BASE")
self.model = model
self.prefix = prefix
if not self.api_key:
raise ValueError(
"API key required. Set API_KEY environment variable or pass api_key parameter."
)
self.agent = create_agent(
tools=[shell, file_read, file_write, web_search],
model=self.model,
api_key=self.api_key,
base_url=self.base_url,
)
self._histories: dict[str, list] = {}
self._max_history = 20
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
self.client = discord.Client(intents=intents)
self._setup_events()
def _setup_events(self):
@self.client.event
async def on_ready():
print(f"ZeroClaw Discord Bot ready: {self.client.user}")
print(f"Guild: {self.guild_id}")
print(f"Allowed users: {self.allowed_users}")
@self.client.event
async def on_message(message):
if message.author == self.client.user:
return
if message.guild and message.guild.id != self.guild_id:
return
user_id = str(message.author.id)
if user_id not in self.allowed_users:
return
content = message.content.strip()
if not content:
return
if self.prefix and not content.startswith(self.prefix):
return
if self.prefix:
content = content[len(self.prefix) :].strip()
print(f"[{message.author}] {content[:50]}...")
async with message.channel.typing():
try:
response = await self._process_message(content, user_id)
for chunk in self._split_message(response):
await message.reply(chunk)
except Exception as e:
print(f"Error: {e}")
await message.reply(f"Error: {e}")
async def _process_message(self, content: str, user_id: str) -> str:
"""Process a message and return the response."""
messages = []
if user_id in self._histories:
for msg in self._histories[user_id][-10:]:
messages.append(msg)
messages.append(HumanMessage(content=content))
result = await self.agent.ainvoke({"messages": messages})
if user_id not in self._histories:
self._histories[user_id] = []
self._histories[user_id].append(HumanMessage(content=content))
for msg in result["messages"][len(messages) :]:
self._histories[user_id].append(msg)
self._histories[user_id] = self._histories[user_id][-self._max_history * 2 :]
final = result["messages"][-1]
return final.content or "Done."
@staticmethod
def _split_message(text: str, max_len: int = 1900) -> list[str]:
"""Split long messages for Discord's character limit."""
if len(text) <= max_len:
return [text]
chunks = []
while text:
if len(text) <= max_len:
chunks.append(text)
break
pos = text.rfind("\n", 0, max_len)
if pos == -1:
pos = text.rfind(" ", 0, max_len)
if pos == -1:
pos = max_len
chunks.append(text[:pos].strip())
text = text[pos:].strip()
return chunks
def run(self):
"""Start the Discord bot."""
self.client.run(self.token)