zeroclaw 0.1.7

Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.
Documentation
"""
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)