mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-06-02 06:13:46 +02:00
Implement Discord bot support and update README for messaging platform changes
This commit is contained in:
+9
-3
@@ -20,14 +20,20 @@ OPENROUTER_API_KEY=""
|
||||
LM_STUDIO_BASE_URL="http://localhost:1234/v1"
|
||||
|
||||
|
||||
# Messaging Platform: "telegram" | "discord"
|
||||
MESSAGING_PLATFORM=discord
|
||||
MESSAGING_RATE_LIMIT=40
|
||||
MESSAGING_RATE_WINDOW=1
|
||||
|
||||
|
||||
# Telegram Config
|
||||
TELEGRAM_BOT_TOKEN=""
|
||||
ALLOWED_TELEGRAM_USER_ID=""
|
||||
|
||||
|
||||
# Messaging Rate Limiting
|
||||
MESSAGING_RATE_LIMIT=1
|
||||
MESSAGING_RATE_WINDOW=1
|
||||
# Discord Config
|
||||
DISCORD_BOT_TOKEN=""
|
||||
ALLOWED_DISCORD_CHANNELS=""
|
||||
|
||||
|
||||
# Agent Config
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
A lightweight proxy server that translates Claude Code's Anthropic API calls into **NVIDIA NIM**, **OpenRouter**, or **LM Studio** format.
|
||||
Get **40 free requests/min** on NVIDIA NIM, access **hundreds of models** on OpenRouter, or run **fully local** with LM Studio.
|
||||
|
||||
[Features](#features) · [Quick Start](#quick-start) · [How It Works](#how-it-works) · [Telegram Bot](#telegram-bot) · [Configuration](#configuration)
|
||||
[Features](#features) · [Quick Start](#quick-start) · [How It Works](#how-it-works) · [Discord Bot](#discord-bot) · [Configuration](#configuration)
|
||||
|
||||
---
|
||||
|
||||
@@ -39,7 +39,7 @@ Get **40 free requests/min** on NVIDIA NIM, access **hundreds of models** on Ope
|
||||
| **Thinking Token Support** | Parses `<think>` tags and `reasoning_content` into native Claude thinking blocks |
|
||||
| **Heuristic Tool Parser** | Models outputting tool calls as text are auto-parsed into structured tool use |
|
||||
| **Request Optimization** | 5 categories of trivial API calls intercepted locally — saves quota and latency |
|
||||
| **Telegram Bot** | Remote autonomous coding with tree-based threading, session persistence, and live progress |
|
||||
| **Discord Bot** | Remote autonomous coding with tree-based threading, session persistence, and live progress (Telegram also supported) |
|
||||
| **Smart Rate Limiting** | Proactive rolling-window throttle + reactive 429 exponential backoff across all providers |
|
||||
| **Subagent Control** | Task tool interception forces `run_in_background=False` — no runaway subagents |
|
||||
| **Extensible** | Clean `BaseProvider` and `MessagingPlatform` ABCs — add new providers or platforms easily |
|
||||
@@ -184,9 +184,9 @@ LM Studio runs locally — start the server in LM Studio's Developer tab or via
|
||||
|
||||
---
|
||||
|
||||
## Telegram Bot
|
||||
## Discord Bot
|
||||
|
||||
Control Claude Code remotely from your phone. Send tasks, watch live progress, and manage multiple concurrent sessions.
|
||||
Control Claude Code remotely from Discord. Send tasks, watch live progress, and manage multiple concurrent sessions. Discord is the default messaging platform; Telegram is also supported.
|
||||
|
||||
**Capabilities:**
|
||||
- Tree-based message threading — reply to messages to fork conversations
|
||||
@@ -197,16 +197,17 @@ Control Claude Code remotely from your phone. Send tasks, watch live progress, a
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Get a Bot Token** — Message [@BotFather](https://t.me/BotFather) on Telegram, send `/newbot`, and copy the HTTP API Token.
|
||||
1. **Create a Discord Bot** — Go to [Discord Developer Portal](https://discord.com/developers/applications), create an application, add a bot, and copy the token. Enable **Message Content Intent** under Bot settings.
|
||||
|
||||
2. **Edit `.env`:**
|
||||
|
||||
```dotenv
|
||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
|
||||
ALLOWED_TELEGRAM_USER_ID=your_telegram_user_id
|
||||
MESSAGING_PLATFORM=discord
|
||||
DISCORD_BOT_TOKEN=your_discord_bot_token
|
||||
ALLOWED_DISCORD_CHANNELS=123456789,987654321
|
||||
```
|
||||
|
||||
> To find your Telegram user ID, message [@userinfobot](https://t.me/userinfobot).
|
||||
> Enable Developer Mode in Discord (Settings → Advanced), then right-click a channel and "Copy ID" to get channel IDs. Comma-separate multiple channels. If empty, no channels are allowed.
|
||||
|
||||
3. **Configure the workspace** (where Claude will operate):
|
||||
|
||||
@@ -221,7 +222,18 @@ ALLOWED_DIR=C:/Users/yourname/projects
|
||||
uv run uvicorn server:app --host 0.0.0.0 --port 8082
|
||||
```
|
||||
|
||||
5. **Send a message** to the bot with a task. Claude responds with thinking tokens, tool calls as they execute, and the final result. Reply `/stop` to a running task to cancel it.
|
||||
5. **Invite the bot** to your server (OAuth2 → URL Generator, scopes: `bot`, permissions: Read Messages, Send Messages, Manage Messages, Read Message History). Send a message in an allowed channel with a task. Claude responds with thinking tokens, tool calls as they execute, and the final result. Reply `/stop` to a running task to cancel it.
|
||||
|
||||
### Telegram (Alternative)
|
||||
|
||||
To use Telegram instead, set `MESSAGING_PLATFORM=telegram` and configure:
|
||||
|
||||
```dotenv
|
||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrSTUvwxYZ
|
||||
ALLOWED_TELEGRAM_USER_ID=your_telegram_user_id
|
||||
```
|
||||
|
||||
Get a token from [@BotFather](https://t.me/BotFather); find your user ID via [@userinfobot](https://t.me/userinfobot).
|
||||
|
||||
---
|
||||
|
||||
@@ -295,9 +307,12 @@ Browse: [model.lmstudio.ai](https://model.lmstudio.ai)
|
||||
| `ENABLE_TITLE_GENERATION_SKIP` | Skip title generation | `true` |
|
||||
| `ENABLE_SUGGESTION_MODE_SKIP` | Skip suggestion mode | `true` |
|
||||
| `ENABLE_FILEPATH_EXTRACTION_MOCK` | Enable filepath extraction mock | `true` |
|
||||
| `MESSAGING_PLATFORM` | Messaging platform: `discord` or `telegram` | `discord` |
|
||||
| `DISCORD_BOT_TOKEN` | Discord Bot Token | `""` |
|
||||
| `ALLOWED_DISCORD_CHANNELS` | Comma-separated channel IDs (empty = none allowed) | `""` |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token | `""` |
|
||||
| `ALLOWED_TELEGRAM_USER_ID` | Allowed Telegram User ID | `""` |
|
||||
| `MESSAGING_RATE_LIMIT` | Telegram messages per window | `1` |
|
||||
| `MESSAGING_RATE_LIMIT` | Messaging messages per window | `1` |
|
||||
| `MESSAGING_RATE_WINDOW` | Messaging window (seconds) | `1` |
|
||||
| `CLAUDE_WORKSPACE` | Directory for agent workspace | `./agent_workspace` |
|
||||
| `ALLOWED_DIR` | Allowed directories for agent | `""` |
|
||||
@@ -316,7 +331,7 @@ free-claude-code/
|
||||
├── server.py # Entry point
|
||||
├── api/ # FastAPI routes, request detection, optimization handlers
|
||||
├── providers/ # BaseProvider ABC + NVIDIA NIM, OpenRouter, LM Studio
|
||||
├── messaging/ # MessagingPlatform ABC + Telegram bot, session management
|
||||
├── messaging/ # MessagingPlatform ABC + Discord/Telegram bots, session management
|
||||
├── config/ # Settings, NIM config, logging
|
||||
├── cli/ # CLI session and process management
|
||||
├── utils/ # Text utilities
|
||||
@@ -351,7 +366,7 @@ class MyProvider(BaseProvider):
|
||||
|
||||
### Adding a Messaging Platform
|
||||
|
||||
Extend `MessagingPlatform` in `messaging/` to add Discord, Slack, or other platforms:
|
||||
Extend `MessagingPlatform` in `messaging/` to add Slack or other platforms:
|
||||
|
||||
```python
|
||||
from messaging.base import MessagingPlatform
|
||||
@@ -386,7 +401,7 @@ Contributions are welcome! Here are some ways to help:
|
||||
|
||||
- Report bugs or suggest features via [Issues](https://github.com/Alishahryar1/free-claude-code/issues)
|
||||
- Add new LLM providers (Groq, Together AI, etc.)
|
||||
- Add new messaging platforms (Discord, Slack, etc.)
|
||||
- Add new messaging platforms (Slack, etc.)
|
||||
- Improve test coverage
|
||||
|
||||
```bash
|
||||
@@ -403,4 +418,4 @@ uv run pytest && uv run ty check && uv run ruff check && uv run ruff format --ch
|
||||
|
||||
This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
Built with [FastAPI](https://fastapi.tiangolo.com/), [OpenAI Python SDK](https://github.com/openai/openai-python), and [python-telegram-bot](https://python-telegram-bot.org/).
|
||||
Built with [FastAPI](https://fastapi.tiangolo.com/), [OpenAI Python SDK](https://github.com/openai/openai-python), [discord.py](https://github.com/Rapptz/discord.py), and [python-telegram-bot](https://python-telegram-bot.org/).
|
||||
|
||||
@@ -56,6 +56,8 @@ async def lifespan(app: FastAPI):
|
||||
platform_type=settings.messaging_platform,
|
||||
bot_token=settings.telegram_bot_token,
|
||||
allowed_user_id=settings.allowed_telegram_user_id,
|
||||
discord_bot_token=settings.discord_bot_token,
|
||||
allowed_discord_channels=settings.allowed_discord_channels,
|
||||
)
|
||||
|
||||
if messaging_platform:
|
||||
|
||||
+12
-1
@@ -26,7 +26,10 @@ class Settings(BaseSettings):
|
||||
open_router_api_key: str = Field(default="", validation_alias="OPENROUTER_API_KEY")
|
||||
|
||||
# ==================== Messaging Platform Selection ====================
|
||||
messaging_platform: str = "telegram"
|
||||
# Valid: "telegram" | "discord"
|
||||
messaging_platform: str = Field(
|
||||
default="discord", validation_alias="MESSAGING_PLATFORM"
|
||||
)
|
||||
|
||||
# ==================== NVIDIA NIM Config ====================
|
||||
nvidia_nim_api_key: str = ""
|
||||
@@ -62,6 +65,12 @@ class Settings(BaseSettings):
|
||||
# ==================== Bot Wrapper Config ====================
|
||||
telegram_bot_token: Optional[str] = None
|
||||
allowed_telegram_user_id: Optional[str] = None
|
||||
discord_bot_token: Optional[str] = Field(
|
||||
default=None, validation_alias="DISCORD_BOT_TOKEN"
|
||||
)
|
||||
allowed_discord_channels: Optional[str] = Field(
|
||||
default=None, validation_alias="ALLOWED_DISCORD_CHANNELS"
|
||||
)
|
||||
claude_workspace: str = "./agent_workspace"
|
||||
allowed_dir: str = ""
|
||||
max_cli_sessions: int = 10
|
||||
@@ -75,6 +84,8 @@ class Settings(BaseSettings):
|
||||
@field_validator(
|
||||
"telegram_bot_token",
|
||||
"allowed_telegram_user_id",
|
||||
"discord_bot_token",
|
||||
"allowed_discord_channels",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Discord Platform Adapter
|
||||
|
||||
Implements MessagingPlatform for Discord using discord.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Callable, Awaitable, Optional, Any, Set
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .base import MessagingPlatform
|
||||
from .models import IncomingMessage
|
||||
from .discord_markdown import format_status_discord
|
||||
|
||||
try:
|
||||
import discord
|
||||
|
||||
DISCORD_AVAILABLE = True
|
||||
except ImportError:
|
||||
discord = None # type: ignore
|
||||
DISCORD_AVAILABLE = False
|
||||
|
||||
DISCORD_MESSAGE_LIMIT = 2000
|
||||
|
||||
|
||||
def _parse_allowed_channels(raw: Optional[str]) -> Set[str]:
|
||||
"""Parse comma-separated channel IDs into a set of strings."""
|
||||
if not raw or not raw.strip():
|
||||
return set()
|
||||
return {s.strip() for s in raw.split(",") if s.strip()}
|
||||
|
||||
|
||||
if DISCORD_AVAILABLE and discord is not None:
|
||||
|
||||
class _DiscordClient(discord.Client):
|
||||
"""Internal Discord client that forwards events to DiscordPlatform."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform: "DiscordPlatform",
|
||||
intents: discord.Intents,
|
||||
) -> None:
|
||||
super().__init__(intents=intents)
|
||||
self._platform = platform
|
||||
|
||||
async def on_ready(self) -> None:
|
||||
"""Called when the bot is ready."""
|
||||
self._platform._connected = True
|
||||
logger.info("Discord platform connected")
|
||||
|
||||
async def on_message(self, message: Any) -> None:
|
||||
"""Handle incoming Discord messages."""
|
||||
await self._platform._on_discord_message(message)
|
||||
else:
|
||||
_DiscordClient = None
|
||||
|
||||
|
||||
class DiscordPlatform(MessagingPlatform):
|
||||
"""
|
||||
Discord messaging platform adapter.
|
||||
|
||||
Uses discord.py for Discord access.
|
||||
Requires a Bot Token from Discord Developer Portal and message_content intent.
|
||||
"""
|
||||
|
||||
name = "discord"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_token: Optional[str] = None,
|
||||
allowed_channel_ids: Optional[str] = None,
|
||||
):
|
||||
if not DISCORD_AVAILABLE:
|
||||
raise ImportError(
|
||||
"discord.py is required. Install with: pip install discord.py"
|
||||
)
|
||||
|
||||
self.bot_token = bot_token or os.getenv("DISCORD_BOT_TOKEN")
|
||||
raw_channels = allowed_channel_ids or os.getenv("ALLOWED_DISCORD_CHANNELS")
|
||||
self.allowed_channel_ids = _parse_allowed_channels(raw_channels)
|
||||
|
||||
if not self.bot_token:
|
||||
logger.warning("DISCORD_BOT_TOKEN not set")
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
|
||||
self._client = _DiscordClient(self, intents) # type: ignore[misc]
|
||||
self._message_handler: Optional[
|
||||
Callable[[IncomingMessage], Awaitable[None]]
|
||||
] = None
|
||||
self._connected = False
|
||||
self._limiter: Optional[Any] = None
|
||||
self._start_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def _on_discord_message(self, message: Any) -> None:
|
||||
"""Handle incoming Discord messages."""
|
||||
if message.author.bot:
|
||||
return
|
||||
if not message.content:
|
||||
return
|
||||
|
||||
channel_id = str(message.channel.id)
|
||||
|
||||
if not self.allowed_channel_ids or channel_id not in self.allowed_channel_ids:
|
||||
return
|
||||
|
||||
user_id = str(message.author.id)
|
||||
message_id = str(message.id)
|
||||
reply_to = (
|
||||
str(message.reference.message_id)
|
||||
if message.reference and message.reference.message_id
|
||||
else None
|
||||
)
|
||||
|
||||
text_preview = (message.content or "")[:80]
|
||||
if len(message.content or "") > 80:
|
||||
text_preview += "..."
|
||||
logger.info(
|
||||
"DISCORD_MSG: chat_id=%s message_id=%s reply_to=%s text_preview=%r",
|
||||
channel_id,
|
||||
message_id,
|
||||
reply_to,
|
||||
text_preview,
|
||||
)
|
||||
|
||||
if not self._message_handler:
|
||||
return
|
||||
|
||||
incoming = IncomingMessage(
|
||||
text=message.content,
|
||||
chat_id=channel_id,
|
||||
user_id=user_id,
|
||||
message_id=message_id,
|
||||
platform="discord",
|
||||
reply_to_message_id=reply_to,
|
||||
username=message.author.display_name,
|
||||
raw_event=message,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._message_handler(incoming)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling message: {e}")
|
||||
try:
|
||||
await self.send_message(
|
||||
channel_id,
|
||||
format_status_discord("Error:", str(e)[:200]),
|
||||
reply_to=message_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _truncate(self, text: str, limit: int = DISCORD_MESSAGE_LIMIT) -> str:
|
||||
"""Truncate text to Discord's message limit."""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 3] + "..."
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Initialize and connect to Discord."""
|
||||
if not self.bot_token:
|
||||
raise ValueError("DISCORD_BOT_TOKEN is required")
|
||||
|
||||
from .limiter import MessagingRateLimiter
|
||||
|
||||
self._limiter = await MessagingRateLimiter.get_instance()
|
||||
|
||||
self._start_task = asyncio.create_task(
|
||||
self._client.start(self.bot_token),
|
||||
name="discord-client-start",
|
||||
)
|
||||
|
||||
max_wait = 30
|
||||
waited = 0
|
||||
while not self._connected and waited < max_wait:
|
||||
await asyncio.sleep(0.5)
|
||||
waited += 0.5
|
||||
|
||||
if not self._connected:
|
||||
raise RuntimeError("Discord client failed to connect within timeout")
|
||||
|
||||
logger.info("Discord platform started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the bot."""
|
||||
if self._client.is_closed():
|
||||
self._connected = False
|
||||
return
|
||||
|
||||
await self._client.close()
|
||||
if self._start_task and not self._start_task.done():
|
||||
try:
|
||||
await asyncio.wait_for(self._start_task, timeout=5.0)
|
||||
except asyncio.TimeoutError, asyncio.CancelledError:
|
||||
self._start_task.cancel()
|
||||
try:
|
||||
await self._start_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._connected = False
|
||||
logger.info("Discord platform stopped")
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
reply_to: Optional[str] = None,
|
||||
parse_mode: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Send a message to a channel."""
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if not channel or not hasattr(channel, "send"):
|
||||
raise RuntimeError(f"Channel {chat_id} not found")
|
||||
|
||||
text = self._truncate(text)
|
||||
|
||||
if reply_to:
|
||||
ref = discord.MessageReference(
|
||||
message_id=int(reply_to),
|
||||
channel_id=int(chat_id),
|
||||
)
|
||||
msg = await channel.send(content=text, reference=ref) # type: ignore[union-attr]
|
||||
else:
|
||||
msg = await channel.send(content=text) # type: ignore[union-attr]
|
||||
|
||||
return str(msg.id)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
text: str,
|
||||
parse_mode: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Edit an existing message."""
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if not channel or not hasattr(channel, "fetch_message"):
|
||||
raise RuntimeError(f"Channel {chat_id} not found")
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(int(message_id)) # type: ignore[union-attr]
|
||||
except discord.NotFound:
|
||||
return
|
||||
|
||||
text = self._truncate(text)
|
||||
await msg.edit(content=text)
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
) -> None:
|
||||
"""Delete a message from a channel."""
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if not channel or not hasattr(channel, "fetch_message"):
|
||||
return
|
||||
|
||||
try:
|
||||
msg = await channel.fetch_message(int(message_id)) # type: ignore[union-attr]
|
||||
await msg.delete()
|
||||
except discord.NotFound, discord.Forbidden:
|
||||
pass
|
||||
|
||||
async def delete_messages(self, chat_id: str, message_ids: list[str]) -> None:
|
||||
"""Delete multiple messages (best-effort)."""
|
||||
for mid in message_ids:
|
||||
await self.delete_message(chat_id, mid)
|
||||
|
||||
async def queue_send_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
reply_to: Optional[str] = None,
|
||||
parse_mode: Optional[str] = None,
|
||||
fire_and_forget: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Enqueue a message to be sent."""
|
||||
if not self._limiter:
|
||||
return await self.send_message(chat_id, text, reply_to, parse_mode)
|
||||
|
||||
async def _send():
|
||||
return await self.send_message(chat_id, text, reply_to, parse_mode)
|
||||
|
||||
if fire_and_forget:
|
||||
self._limiter.fire_and_forget(_send)
|
||||
return None
|
||||
return await self._limiter.enqueue(_send)
|
||||
|
||||
async def queue_edit_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
text: str,
|
||||
parse_mode: Optional[str] = None,
|
||||
fire_and_forget: bool = True,
|
||||
) -> None:
|
||||
"""Enqueue a message edit."""
|
||||
if not self._limiter:
|
||||
await self.edit_message(chat_id, message_id, text, parse_mode)
|
||||
return
|
||||
|
||||
async def _edit():
|
||||
await self.edit_message(chat_id, message_id, text, parse_mode)
|
||||
|
||||
dedup_key = f"edit:{chat_id}:{message_id}"
|
||||
if fire_and_forget:
|
||||
self._limiter.fire_and_forget(_edit, dedup_key=dedup_key)
|
||||
else:
|
||||
await self._limiter.enqueue(_edit, dedup_key=dedup_key)
|
||||
|
||||
async def queue_delete_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
fire_and_forget: bool = True,
|
||||
) -> None:
|
||||
"""Enqueue a message delete."""
|
||||
if not self._limiter:
|
||||
await self.delete_message(chat_id, message_id)
|
||||
return
|
||||
|
||||
async def _delete():
|
||||
await self.delete_message(chat_id, message_id)
|
||||
|
||||
dedup_key = f"del:{chat_id}:{message_id}"
|
||||
if fire_and_forget:
|
||||
self._limiter.fire_and_forget(_delete, dedup_key=dedup_key)
|
||||
else:
|
||||
await self._limiter.enqueue(_delete, dedup_key=dedup_key)
|
||||
|
||||
async def queue_delete_messages(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_ids: list[str],
|
||||
fire_and_forget: bool = True,
|
||||
) -> None:
|
||||
"""Enqueue a bulk delete."""
|
||||
if not message_ids:
|
||||
return
|
||||
|
||||
if not self._limiter:
|
||||
await self.delete_messages(chat_id, message_ids)
|
||||
return
|
||||
|
||||
async def _bulk():
|
||||
await self.delete_messages(chat_id, message_ids)
|
||||
|
||||
dedup_key = f"del_bulk:{chat_id}:{hash(tuple(message_ids))}"
|
||||
if fire_and_forget:
|
||||
self._limiter.fire_and_forget(_bulk, dedup_key=dedup_key)
|
||||
else:
|
||||
await self._limiter.enqueue(_bulk, dedup_key=dedup_key)
|
||||
|
||||
def fire_and_forget(self, task: Awaitable[Any]) -> None:
|
||||
"""Execute a coroutine without awaiting it."""
|
||||
if asyncio.iscoroutine(task):
|
||||
asyncio.create_task(task)
|
||||
else:
|
||||
asyncio.ensure_future(task)
|
||||
|
||||
def on_message(
|
||||
self,
|
||||
handler: Callable[[IncomingMessage], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Register a message handler callback."""
|
||||
self._message_handler = handler
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected."""
|
||||
return self._connected
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Discord markdown utilities.
|
||||
|
||||
Discord uses standard markdown: **bold**, *italic*, `code`, ```code block```.
|
||||
Used by the message handler and Discord platform adapter.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from markdown_it import MarkdownIt
|
||||
|
||||
# Discord escapes: \ * _ ` ~ | >
|
||||
DISCORD_SPECIAL = set("\\*_`~|>")
|
||||
|
||||
_MD = MarkdownIt("commonmark", {"html": False, "breaks": False})
|
||||
_MD.enable("strikethrough")
|
||||
_MD.enable("table")
|
||||
|
||||
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
||||
_FENCE_RE = re.compile(r"^\s*```")
|
||||
|
||||
|
||||
def _is_gfm_table_header_line(line: str) -> bool:
|
||||
"""Check if line is a GFM table header."""
|
||||
if "|" not in line:
|
||||
return False
|
||||
if _TABLE_SEP_RE.match(line):
|
||||
return False
|
||||
stripped = line.strip()
|
||||
parts = [p.strip() for p in stripped.strip("|").split("|")]
|
||||
parts = [p for p in parts if p != ""]
|
||||
return len(parts) >= 2
|
||||
|
||||
|
||||
def _normalize_gfm_tables(text: str) -> str:
|
||||
"""Insert blank line before detected tables outside code blocks."""
|
||||
lines = text.splitlines()
|
||||
if len(lines) < 2:
|
||||
return text
|
||||
|
||||
out_lines: List[str] = []
|
||||
in_fence = False
|
||||
|
||||
for idx, line in enumerate(lines):
|
||||
if _FENCE_RE.match(line):
|
||||
in_fence = not in_fence
|
||||
out_lines.append(line)
|
||||
continue
|
||||
|
||||
if (
|
||||
not in_fence
|
||||
and idx + 1 < len(lines)
|
||||
and _is_gfm_table_header_line(line)
|
||||
and _TABLE_SEP_RE.match(lines[idx + 1])
|
||||
):
|
||||
if out_lines and out_lines[-1].strip() != "":
|
||||
m = re.match(r"^(\s*)", line)
|
||||
indent = m.group(1) if m else ""
|
||||
out_lines.append(indent)
|
||||
|
||||
out_lines.append(line)
|
||||
|
||||
return "\n".join(out_lines)
|
||||
|
||||
|
||||
def escape_discord(text: str) -> str:
|
||||
"""Escape text for Discord markdown (bold, italic, etc.)."""
|
||||
return "".join(f"\\{ch}" if ch in DISCORD_SPECIAL else ch for ch in text)
|
||||
|
||||
|
||||
def escape_discord_code(text: str) -> str:
|
||||
"""Escape text for Discord code spans/blocks."""
|
||||
return text.replace("\\", "\\\\").replace("`", "\\`")
|
||||
|
||||
|
||||
def discord_bold(text: str) -> str:
|
||||
"""Format text as bold in Discord (uses **)."""
|
||||
return f"**{escape_discord(text)}**"
|
||||
|
||||
|
||||
def discord_code_inline(text: str) -> str:
|
||||
"""Format text as inline code in Discord."""
|
||||
return f"`{escape_discord_code(text)}`"
|
||||
|
||||
|
||||
def format_status_discord(label: str, suffix: Optional[str] = None) -> str:
|
||||
"""Format a status message for Discord (label in bold, optional suffix)."""
|
||||
base = discord_bold(label)
|
||||
if suffix:
|
||||
return f"{base} {escape_discord(suffix)}"
|
||||
return base
|
||||
|
||||
|
||||
def format_status(emoji: str, label: str, suffix: Optional[str] = None) -> str:
|
||||
"""Format a status message with emoji for Discord (matches Telegram API)."""
|
||||
base = f"{emoji} {discord_bold(label)}"
|
||||
if suffix:
|
||||
return f"{base} {escape_discord(suffix)}"
|
||||
return base
|
||||
|
||||
|
||||
def render_markdown_to_discord(text: str) -> str:
|
||||
"""Render common Markdown into Discord-compatible format."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
text = _normalize_gfm_tables(text)
|
||||
tokens = _MD.parse(text)
|
||||
|
||||
def render_inline_table_plain(children) -> str:
|
||||
out: List[str] = []
|
||||
for tok in children:
|
||||
if tok.type == "text":
|
||||
out.append(tok.content)
|
||||
elif tok.type == "code_inline":
|
||||
out.append(tok.content)
|
||||
elif tok.type in {"softbreak", "hardbreak"}:
|
||||
out.append(" ")
|
||||
elif tok.type == "image":
|
||||
if tok.content:
|
||||
out.append(tok.content)
|
||||
return "".join(out)
|
||||
|
||||
def render_inline(children) -> str:
|
||||
out: List[str] = []
|
||||
i = 0
|
||||
while i < len(children):
|
||||
tok = children[i]
|
||||
t = tok.type
|
||||
if t == "text":
|
||||
out.append(escape_discord(tok.content))
|
||||
elif t in {"softbreak", "hardbreak"}:
|
||||
out.append("\n")
|
||||
elif t == "em_open":
|
||||
out.append("*")
|
||||
elif t == "em_close":
|
||||
out.append("*")
|
||||
elif t == "strong_open":
|
||||
out.append("**")
|
||||
elif t == "strong_close":
|
||||
out.append("**")
|
||||
elif t == "s_open":
|
||||
out.append("~~")
|
||||
elif t == "s_close":
|
||||
out.append("~~")
|
||||
elif t == "code_inline":
|
||||
out.append(f"`{escape_discord_code(tok.content)}`")
|
||||
elif t == "link_open":
|
||||
href = ""
|
||||
if tok.attrs:
|
||||
if isinstance(tok.attrs, dict):
|
||||
href = tok.attrs.get("href", "")
|
||||
else:
|
||||
for key, val in tok.attrs:
|
||||
if key == "href":
|
||||
href = val
|
||||
break
|
||||
inner_tokens = []
|
||||
i += 1
|
||||
while i < len(children) and children[i].type != "link_close":
|
||||
inner_tokens.append(children[i])
|
||||
i += 1
|
||||
link_text = ""
|
||||
for child in inner_tokens:
|
||||
if child.type == "text":
|
||||
link_text += child.content
|
||||
elif child.type == "code_inline":
|
||||
link_text += child.content
|
||||
out.append(f"[{escape_discord(link_text)}]({href})")
|
||||
elif t == "image":
|
||||
href = ""
|
||||
alt = tok.content or ""
|
||||
if tok.attrs:
|
||||
if isinstance(tok.attrs, dict):
|
||||
href = tok.attrs.get("src", "")
|
||||
else:
|
||||
for key, val in tok.attrs:
|
||||
if key == "src":
|
||||
href = val
|
||||
break
|
||||
if alt:
|
||||
out.append(f"{escape_discord(alt)} ({href})")
|
||||
else:
|
||||
out.append(href)
|
||||
else:
|
||||
out.append(escape_discord(tok.content or ""))
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
out: List[str] = []
|
||||
list_stack: List[dict] = []
|
||||
pending_prefix: Optional[str] = None
|
||||
blockquote_level = 0
|
||||
in_heading = False
|
||||
|
||||
def apply_blockquote(val: str) -> str:
|
||||
if blockquote_level <= 0:
|
||||
return val
|
||||
prefix = "> " * blockquote_level
|
||||
return prefix + val.replace("\n", "\n" + prefix)
|
||||
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
tok = tokens[i]
|
||||
t = tok.type
|
||||
if t == "paragraph_open":
|
||||
pass
|
||||
elif t == "paragraph_close":
|
||||
out.append("\n")
|
||||
elif t == "heading_open":
|
||||
in_heading = True
|
||||
elif t == "heading_close":
|
||||
in_heading = False
|
||||
out.append("\n")
|
||||
elif t == "bullet_list_open":
|
||||
list_stack.append({"type": "bullet", "index": 1})
|
||||
elif t == "bullet_list_close":
|
||||
if list_stack:
|
||||
list_stack.pop()
|
||||
out.append("\n")
|
||||
elif t == "ordered_list_open":
|
||||
start = 1
|
||||
if tok.attrs:
|
||||
if isinstance(tok.attrs, dict):
|
||||
val = tok.attrs.get("start")
|
||||
if val is not None:
|
||||
try:
|
||||
start = int(val)
|
||||
except TypeError, ValueError:
|
||||
start = 1
|
||||
else:
|
||||
for key, val in tok.attrs:
|
||||
if key == "start":
|
||||
try:
|
||||
start = int(val)
|
||||
except TypeError, ValueError:
|
||||
start = 1
|
||||
break
|
||||
list_stack.append({"type": "ordered", "index": start})
|
||||
elif t == "ordered_list_close":
|
||||
if list_stack:
|
||||
list_stack.pop()
|
||||
out.append("\n")
|
||||
elif t == "list_item_open":
|
||||
if list_stack:
|
||||
top = list_stack[-1]
|
||||
if top["type"] == "bullet":
|
||||
pending_prefix = "- "
|
||||
else:
|
||||
pending_prefix = f"{top['index']}. "
|
||||
top["index"] += 1
|
||||
elif t == "list_item_close":
|
||||
out.append("\n")
|
||||
elif t == "blockquote_open":
|
||||
blockquote_level += 1
|
||||
elif t == "blockquote_close":
|
||||
blockquote_level = max(0, blockquote_level - 1)
|
||||
out.append("\n")
|
||||
elif t == "table_open":
|
||||
if pending_prefix:
|
||||
out.append(apply_blockquote(pending_prefix.rstrip()))
|
||||
out.append("\n")
|
||||
pending_prefix = None
|
||||
|
||||
rows: List[List[str]] = []
|
||||
row_is_header: List[bool] = []
|
||||
|
||||
j = i + 1
|
||||
in_thead = False
|
||||
in_row = False
|
||||
current_row: List[str] = []
|
||||
current_row_header = False
|
||||
|
||||
in_cell = False
|
||||
cell_parts: List[str] = []
|
||||
|
||||
while j < len(tokens):
|
||||
tt = tokens[j].type
|
||||
if tt == "thead_open":
|
||||
in_thead = True
|
||||
elif tt == "thead_close":
|
||||
in_thead = False
|
||||
elif tt == "tr_open":
|
||||
in_row = True
|
||||
current_row = []
|
||||
current_row_header = in_thead
|
||||
elif tt in {"th_open", "td_open"}:
|
||||
in_cell = True
|
||||
cell_parts = []
|
||||
elif tt == "inline" and in_cell:
|
||||
cell_parts.append(
|
||||
render_inline_table_plain(tokens[j].children or [])
|
||||
)
|
||||
elif tt in {"th_close", "td_close"} and in_cell:
|
||||
cell = " ".join(cell_parts).strip()
|
||||
current_row.append(cell)
|
||||
in_cell = False
|
||||
cell_parts = []
|
||||
elif tt == "tr_close" and in_row:
|
||||
rows.append(current_row)
|
||||
row_is_header.append(bool(current_row_header))
|
||||
in_row = False
|
||||
elif tt == "table_close":
|
||||
break
|
||||
j += 1
|
||||
|
||||
if rows:
|
||||
col_count = max((len(r) for r in rows), default=0)
|
||||
norm_rows: List[List[str]] = []
|
||||
for r in rows:
|
||||
if len(r) < col_count:
|
||||
r = r + [""] * (col_count - len(r))
|
||||
norm_rows.append(r)
|
||||
|
||||
widths: List[int] = []
|
||||
for c in range(col_count):
|
||||
w = max((len(r[c]) for r in norm_rows), default=0)
|
||||
widths.append(max(w, 3))
|
||||
|
||||
def fmt_row(r: List[str]) -> str:
|
||||
cells = [r[c].ljust(widths[c]) for c in range(col_count)]
|
||||
return "| " + " | ".join(cells) + " |"
|
||||
|
||||
def fmt_sep() -> str:
|
||||
cells = ["-" * widths[c] for c in range(col_count)]
|
||||
return "| " + " | ".join(cells) + " |"
|
||||
|
||||
last_header_idx = -1
|
||||
for idx, is_h in enumerate(row_is_header):
|
||||
if is_h:
|
||||
last_header_idx = idx
|
||||
|
||||
lines: List[str] = []
|
||||
for idx, r in enumerate(norm_rows):
|
||||
lines.append(fmt_row(r))
|
||||
if idx == last_header_idx:
|
||||
lines.append(fmt_sep())
|
||||
|
||||
table_text = "\n".join(lines).rstrip()
|
||||
out.append(f"```\n{escape_discord_code(table_text)}\n```")
|
||||
out.append("\n")
|
||||
|
||||
i = j + 1
|
||||
continue
|
||||
elif t in {"code_block", "fence"}:
|
||||
code = escape_discord_code(tok.content.rstrip("\n"))
|
||||
out.append(f"```\n{code}\n```")
|
||||
out.append("\n")
|
||||
elif t == "inline":
|
||||
rendered = render_inline(tok.children or [])
|
||||
if in_heading:
|
||||
rendered = f"**{render_inline(tok.children or [])}**"
|
||||
if pending_prefix:
|
||||
rendered = pending_prefix + rendered
|
||||
pending_prefix = None
|
||||
rendered = apply_blockquote(rendered)
|
||||
out.append(rendered)
|
||||
else:
|
||||
if tok.content:
|
||||
out.append(escape_discord(tok.content))
|
||||
i += 1
|
||||
|
||||
return "".join(out).rstrip()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"escape_discord",
|
||||
"escape_discord_code",
|
||||
"discord_bold",
|
||||
"discord_code_inline",
|
||||
"format_status",
|
||||
"format_status_discord",
|
||||
"render_markdown_to_discord",
|
||||
]
|
||||
+13
-5
@@ -39,12 +39,20 @@ def create_messaging_platform(
|
||||
allowed_user_id=kwargs.get("allowed_user_id"),
|
||||
)
|
||||
|
||||
# Add new platforms here:
|
||||
# elif platform_type == "discord":
|
||||
# from .discord import DiscordPlatform
|
||||
# return DiscordPlatform(...)
|
||||
if platform_type == "discord":
|
||||
bot_token = kwargs.get("discord_bot_token")
|
||||
if not bot_token:
|
||||
logger.info("No Discord bot token configured, skipping platform setup")
|
||||
return None
|
||||
|
||||
from .discord import DiscordPlatform
|
||||
|
||||
return DiscordPlatform(
|
||||
bot_token=bot_token,
|
||||
allowed_channel_ids=kwargs.get("allowed_discord_channels"),
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
f"Unknown messaging platform: '{platform_type}'. Supported: 'telegram'"
|
||||
f"Unknown messaging platform: '{platform_type}'. Supported: 'telegram', 'discord'"
|
||||
)
|
||||
return None
|
||||
|
||||
+95
-43
@@ -22,9 +22,17 @@ from .telegram_markdown import (
|
||||
escape_md_v2_code,
|
||||
mdv2_bold,
|
||||
mdv2_code_inline,
|
||||
format_status,
|
||||
format_status as format_status_telegram,
|
||||
render_markdown_to_mdv2,
|
||||
)
|
||||
from .discord_markdown import (
|
||||
escape_discord,
|
||||
escape_discord_code,
|
||||
discord_bold,
|
||||
discord_code_inline,
|
||||
format_status as format_status_discord, # (emoji, label, suffix)
|
||||
render_markdown_to_discord,
|
||||
)
|
||||
from loguru import logger
|
||||
|
||||
|
||||
@@ -64,16 +72,16 @@ _EVENT_STATUS_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def _get_status_for_event(ptype: str, parsed: dict) -> Optional[str]:
|
||||
def _get_status_for_event(ptype: str, parsed: dict, format_status_fn) -> Optional[str]:
|
||||
"""Return status string for event type, or None if no status update needed."""
|
||||
entry = _EVENT_STATUS_MAP.get(ptype)
|
||||
if entry is not None:
|
||||
emoji, label = entry
|
||||
return format_status(emoji, label)
|
||||
return format_status_fn(emoji, label)
|
||||
if ptype in ("tool_use_start", "tool_use_delta", "tool_use"):
|
||||
if parsed.get("name") == "Task":
|
||||
return format_status("🤖", "Subagent working...")
|
||||
return format_status("⏳", "Executing tools...")
|
||||
return format_status_fn("🤖", "Subagent working...")
|
||||
return format_status_fn("⏳", "Executing tools...")
|
||||
return None
|
||||
|
||||
|
||||
@@ -102,6 +110,44 @@ class ClaudeMessageHandler:
|
||||
node_started_callback=self._mark_node_processing,
|
||||
)
|
||||
|
||||
def _format_status(
|
||||
self, emoji: str, label: str, suffix: Optional[str] = None
|
||||
) -> str:
|
||||
"""Platform-specific status formatting."""
|
||||
if self.platform.name == "discord":
|
||||
return format_status_discord(emoji, label, suffix)
|
||||
return format_status_telegram(emoji, label, suffix)
|
||||
|
||||
def _parse_mode(self) -> Optional[str]:
|
||||
"""Platform-specific parse mode (MarkdownV2 for Telegram, None for Discord)."""
|
||||
if self.platform.name == "discord":
|
||||
return None
|
||||
return "MarkdownV2"
|
||||
|
||||
def _get_render_ctx(self) -> RenderCtx:
|
||||
"""Platform-specific render context for transcript."""
|
||||
if self.platform.name == "discord":
|
||||
return RenderCtx(
|
||||
bold=discord_bold,
|
||||
code_inline=discord_code_inline,
|
||||
escape_code=escape_discord_code,
|
||||
escape_text=escape_discord,
|
||||
render_markdown=render_markdown_to_discord,
|
||||
)
|
||||
return RenderCtx(
|
||||
bold=mdv2_bold,
|
||||
code_inline=mdv2_code_inline,
|
||||
escape_code=escape_md_v2_code,
|
||||
escape_text=escape_md_v2,
|
||||
render_markdown=render_markdown_to_mdv2,
|
||||
)
|
||||
|
||||
def _get_limit_chars(self) -> int:
|
||||
"""Platform-specific message length limit (Discord 2000, Telegram 4096)."""
|
||||
if self.platform.name == "discord":
|
||||
return 1900
|
||||
return 3900
|
||||
|
||||
async def handle_message(self, incoming: IncomingMessage) -> None:
|
||||
"""
|
||||
Main entry point for handling an incoming message.
|
||||
@@ -239,8 +285,10 @@ class ClaudeMessageHandler:
|
||||
await self.platform.queue_edit_message(
|
||||
incoming.chat_id,
|
||||
status_msg_id,
|
||||
format_status("📋", "Queued", f"(position {queue_size}) - waiting..."),
|
||||
parse_mode="MarkdownV2",
|
||||
self._format_status(
|
||||
"📋", "Queued", f"(position {queue_size}) - waiting..."
|
||||
),
|
||||
parse_mode=self._parse_mode(),
|
||||
)
|
||||
|
||||
async def _update_queue_positions(self, tree: MessageTree) -> None:
|
||||
@@ -264,10 +312,10 @@ class ClaudeMessageHandler:
|
||||
self.platform.queue_edit_message(
|
||||
node.incoming.chat_id,
|
||||
node.status_message_id,
|
||||
format_status(
|
||||
self._format_status(
|
||||
"📋", "Queued", f"(position {position}) - waiting..."
|
||||
),
|
||||
parse_mode="MarkdownV2",
|
||||
parse_mode=self._parse_mode(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -280,8 +328,8 @@ class ClaudeMessageHandler:
|
||||
self.platform.queue_edit_message(
|
||||
node.incoming.chat_id,
|
||||
node.status_message_id,
|
||||
format_status("🔄", "Processing..."),
|
||||
parse_mode="MarkdownV2",
|
||||
self._format_status("🔄", "Processing..."),
|
||||
parse_mode=self._parse_mode(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -290,14 +338,7 @@ class ClaudeMessageHandler:
|
||||
) -> Tuple[TranscriptBuffer, RenderCtx]:
|
||||
"""Create transcript buffer and render context for node processing."""
|
||||
transcript = TranscriptBuffer(show_tool_results=False)
|
||||
render_ctx = RenderCtx(
|
||||
bold=mdv2_bold,
|
||||
code_inline=mdv2_code_inline,
|
||||
escape_code=escape_md_v2_code,
|
||||
escape_text=escape_md_v2,
|
||||
render_markdown=render_markdown_to_mdv2,
|
||||
)
|
||||
return transcript, render_ctx
|
||||
return transcript, self._get_render_ctx()
|
||||
|
||||
async def _handle_session_info_event(
|
||||
self,
|
||||
@@ -346,7 +387,7 @@ class ClaudeMessageHandler:
|
||||
transcript.apply(parsed)
|
||||
had_transcript_events = True
|
||||
|
||||
status = _get_status_for_event(ptype, parsed)
|
||||
status = _get_status_for_event(ptype, parsed, self._format_status)
|
||||
if status is not None:
|
||||
await update_ui(status)
|
||||
last_status = status
|
||||
@@ -356,7 +397,7 @@ class ClaudeMessageHandler:
|
||||
if not had_transcript_events:
|
||||
transcript.apply({"type": "text_chunk", "text": "Done."})
|
||||
logger.info("HANDLER: Task complete, updating UI")
|
||||
await update_ui(format_status("✅", "Complete"), force=True)
|
||||
await update_ui(self._format_status("✅", "Complete"), force=True)
|
||||
if tree and captured_session_id:
|
||||
await tree.update_state(
|
||||
node_id,
|
||||
@@ -368,7 +409,7 @@ class ClaudeMessageHandler:
|
||||
error_msg = parsed.get("message", "Unknown error")
|
||||
logger.error(f"HANDLER: Error event received: {error_msg}")
|
||||
logger.info("HANDLER: Updating UI with error status")
|
||||
await update_ui(format_status("❌", "Error"), force=True)
|
||||
await update_ui(self._format_status("❌", "Error"), force=True)
|
||||
if tree:
|
||||
await self._propagate_error_to_children(
|
||||
node_id, error_msg, "Parent task failed"
|
||||
@@ -428,7 +469,11 @@ class ClaudeMessageHandler:
|
||||
if status is not None:
|
||||
last_status = status
|
||||
try:
|
||||
display = transcript.render(render_ctx, limit_chars=3900, status=status)
|
||||
display = transcript.render(
|
||||
render_ctx,
|
||||
limit_chars=self._get_limit_chars(),
|
||||
status=status,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Transcript render failed for node {node_id}: {e}")
|
||||
return
|
||||
@@ -453,7 +498,10 @@ class ClaudeMessageHandler:
|
||||
last_displayed_text = display
|
||||
try:
|
||||
await self.platform.queue_edit_message(
|
||||
chat_id, status_msg_id, display, parse_mode="MarkdownV2"
|
||||
chat_id,
|
||||
status_msg_id,
|
||||
display,
|
||||
parse_mode=self._parse_mode(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update Telegram for node {node_id}: {e}")
|
||||
@@ -472,7 +520,8 @@ class ClaudeMessageHandler:
|
||||
except RuntimeError as e:
|
||||
transcript.apply({"type": "error", "message": str(e)})
|
||||
await update_ui(
|
||||
format_status("⏳", "Session limit reached"), force=True
|
||||
self._format_status("⏳", "Session limit reached"),
|
||||
force=True,
|
||||
)
|
||||
if tree:
|
||||
await tree.update_state(
|
||||
@@ -530,10 +579,10 @@ class ClaudeMessageHandler:
|
||||
cancel_reason = node.context.get("cancel_reason")
|
||||
|
||||
if cancel_reason == "stop":
|
||||
await update_ui(format_status("⏹", "Stopped."), force=True)
|
||||
await update_ui(self._format_status("⏹", "Stopped."), force=True)
|
||||
else:
|
||||
transcript.apply({"type": "error", "message": "Task was cancelled"})
|
||||
await update_ui(format_status("❌", "Cancelled"), force=True)
|
||||
await update_ui(self._format_status("❌", "Cancelled"), force=True)
|
||||
|
||||
# Do not propagate cancellation to children; a reply-scoped "/stop"
|
||||
# should only stop the targeted task.
|
||||
@@ -547,7 +596,7 @@ class ClaudeMessageHandler:
|
||||
)
|
||||
error_msg = str(e)[:200]
|
||||
transcript.apply({"type": "error", "message": error_msg})
|
||||
await update_ui(format_status("💥", "Task Failed"), force=True)
|
||||
await update_ui(self._format_status("💥", "Task Failed"), force=True)
|
||||
if tree:
|
||||
await self._propagate_error_to_children(
|
||||
node_id, error_msg, "Parent task failed"
|
||||
@@ -581,8 +630,8 @@ class ClaudeMessageHandler:
|
||||
self.platform.queue_edit_message(
|
||||
child.incoming.chat_id,
|
||||
child.status_message_id,
|
||||
format_status("❌", "Cancelled:", child_status_text),
|
||||
parse_mode="MarkdownV2",
|
||||
self._format_status("❌", "Cancelled:", child_status_text),
|
||||
parse_mode=self._parse_mode(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -596,20 +645,20 @@ class ClaudeMessageHandler:
|
||||
# Reply to existing tree
|
||||
if self.tree_queue.is_node_tree_busy(parent_node_id):
|
||||
queue_size = self.tree_queue.get_queue_size(parent_node_id) + 1
|
||||
return format_status(
|
||||
return self._format_status(
|
||||
"📋", "Queued", f"(position {queue_size}) - waiting..."
|
||||
)
|
||||
return format_status("🔄", "Continuing conversation...")
|
||||
return self._format_status("🔄", "Continuing conversation...")
|
||||
|
||||
# New conversation
|
||||
stats = self.cli_manager.get_stats()
|
||||
if stats["active_sessions"] >= stats["max_sessions"]:
|
||||
return format_status(
|
||||
return self._format_status(
|
||||
"⏳",
|
||||
"Waiting for slot...",
|
||||
f"({stats['active_sessions']}/{stats['max_sessions']})",
|
||||
)
|
||||
return format_status("⏳", "Launching new Claude CLI instance...")
|
||||
return self._format_status("⏳", "Launching new Claude CLI instance...")
|
||||
|
||||
async def stop_all_tasks(self) -> int:
|
||||
"""
|
||||
@@ -676,8 +725,8 @@ class ClaudeMessageHandler:
|
||||
self.platform.queue_edit_message(
|
||||
node.incoming.chat_id,
|
||||
node.status_message_id,
|
||||
format_status("⏹", "Stopped."),
|
||||
parse_mode="MarkdownV2",
|
||||
self._format_status("⏹", "Stopped."),
|
||||
parse_mode=self._parse_mode(),
|
||||
)
|
||||
)
|
||||
tree = self.tree_queue.get_tree_for_node(node.node_id)
|
||||
@@ -697,7 +746,9 @@ class ClaudeMessageHandler:
|
||||
if not node_id:
|
||||
msg_id = await self.platform.queue_send_message(
|
||||
incoming.chat_id,
|
||||
format_status("⏹", "Stopped.", "Nothing to stop for that message."),
|
||||
self._format_status(
|
||||
"⏹", "Stopped.", "Nothing to stop for that message."
|
||||
),
|
||||
fire_and_forget=False,
|
||||
)
|
||||
self._record_outgoing_message(
|
||||
@@ -709,7 +760,7 @@ class ClaudeMessageHandler:
|
||||
noun = "request" if count == 1 else "requests"
|
||||
msg_id = await self.platform.queue_send_message(
|
||||
incoming.chat_id,
|
||||
format_status("⏹", "Stopped.", f"Cancelled {count} {noun}."),
|
||||
self._format_status("⏹", "Stopped.", f"Cancelled {count} {noun}."),
|
||||
fire_and_forget=False,
|
||||
)
|
||||
self._record_outgoing_message(
|
||||
@@ -721,7 +772,7 @@ class ClaudeMessageHandler:
|
||||
count = await self.stop_all_tasks()
|
||||
msg_id = await self.platform.queue_send_message(
|
||||
incoming.chat_id,
|
||||
format_status(
|
||||
self._format_status(
|
||||
"⏹", "Stopped.", f"Cancelled {count} pending or active requests."
|
||||
),
|
||||
fire_and_forget=False,
|
||||
@@ -734,16 +785,17 @@ class ClaudeMessageHandler:
|
||||
"""Handle /stats command."""
|
||||
stats = self.cli_manager.get_stats()
|
||||
tree_count = self.tree_queue.get_tree_count()
|
||||
ctx = self._get_render_ctx()
|
||||
msg_id = await self.platform.queue_send_message(
|
||||
incoming.chat_id,
|
||||
"📊 "
|
||||
+ mdv2_bold("Stats")
|
||||
+ ctx.bold("Stats")
|
||||
+ "\n"
|
||||
+ escape_md_v2(f"• Active CLI: {stats['active_sessions']}")
|
||||
+ ctx.escape_text(f"• Active CLI: {stats['active_sessions']}")
|
||||
+ "\n"
|
||||
+ escape_md_v2(f"• Max CLI: {stats['max_sessions']}")
|
||||
+ ctx.escape_text(f"• Max CLI: {stats['max_sessions']}")
|
||||
+ "\n"
|
||||
+ escape_md_v2(f"• Message Trees: {tree_count}"),
|
||||
+ ctx.escape_text(f"• Message Trees: {tree_count}"),
|
||||
fire_and_forget=False,
|
||||
)
|
||||
self._record_outgoing_message(
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"python-dotenv>=1.0.0",
|
||||
"tiktoken>=0.7.0",
|
||||
"python-telegram-bot>=21.0",
|
||||
"discord.py>=2.0.0",
|
||||
"pydantic-settings>=2.12.0",
|
||||
"openai>=2.16.0",
|
||||
"loguru>=0.7.0",
|
||||
|
||||
@@ -21,6 +21,8 @@ def test_create_app_provider_error_handler_returns_anthropic_format():
|
||||
messaging_platform="telegram",
|
||||
telegram_bot_token=None,
|
||||
allowed_telegram_user_id=None,
|
||||
discord_bot_token=None,
|
||||
allowed_discord_channels=None,
|
||||
allowed_dir="",
|
||||
claude_workspace="./agent_workspace",
|
||||
host="127.0.0.1",
|
||||
@@ -54,6 +56,8 @@ def test_create_app_general_exception_handler_returns_500():
|
||||
messaging_platform="telegram",
|
||||
telegram_bot_token=None,
|
||||
allowed_telegram_user_id=None,
|
||||
discord_bot_token=None,
|
||||
allowed_discord_channels=None,
|
||||
allowed_dir="",
|
||||
claude_workspace="./agent_workspace",
|
||||
host="127.0.0.1",
|
||||
@@ -85,6 +89,8 @@ def test_app_lifespan_sets_state_and_cleans_up(tmp_path, messaging_enabled):
|
||||
messaging_platform="telegram",
|
||||
telegram_bot_token="token" if messaging_enabled else None,
|
||||
allowed_telegram_user_id="123",
|
||||
discord_bot_token=None,
|
||||
allowed_discord_channels=None,
|
||||
allowed_dir=str(tmp_path / "workspace"),
|
||||
claude_workspace=str(tmp_path / "data"),
|
||||
host="127.0.0.1",
|
||||
@@ -162,6 +168,8 @@ def test_app_lifespan_cleanup_continues_if_platform_stop_raises(tmp_path):
|
||||
messaging_platform="telegram",
|
||||
telegram_bot_token="token",
|
||||
allowed_telegram_user_id="123",
|
||||
discord_bot_token=None,
|
||||
allowed_discord_channels=None,
|
||||
allowed_dir=str(tmp_path / "workspace"),
|
||||
claude_workspace=str(tmp_path / "data"),
|
||||
host="127.0.0.1",
|
||||
|
||||
@@ -242,3 +242,31 @@ class TestSettingsOptionalStr:
|
||||
monkeypatch.setenv("ALLOWED_TELEGRAM_USER_ID", "")
|
||||
s = Settings()
|
||||
assert s.allowed_telegram_user_id is None
|
||||
|
||||
def test_discord_bot_token_from_env(self, monkeypatch):
|
||||
from config.settings import Settings
|
||||
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "discord_token_123")
|
||||
s = Settings()
|
||||
assert s.discord_bot_token == "discord_token_123"
|
||||
|
||||
def test_empty_discord_bot_token_to_none(self, monkeypatch):
|
||||
from config.settings import Settings
|
||||
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "")
|
||||
s = Settings()
|
||||
assert s.discord_bot_token is None
|
||||
|
||||
def test_allowed_discord_channels_from_env(self, monkeypatch):
|
||||
from config.settings import Settings
|
||||
|
||||
monkeypatch.setenv("ALLOWED_DISCORD_CHANNELS", "111,222,333")
|
||||
s = Settings()
|
||||
assert s.allowed_discord_channels == "111,222,333"
|
||||
|
||||
def test_messaging_platform_from_env(self, monkeypatch):
|
||||
from config.settings import Settings
|
||||
|
||||
monkeypatch.setenv("MESSAGING_PLATFORM", "discord")
|
||||
s = Settings()
|
||||
assert s.messaging_platform == "discord"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for Discord platform adapter."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from messaging.discord import (
|
||||
DiscordPlatform,
|
||||
_parse_allowed_channels,
|
||||
DISCORD_AVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
class TestParseAllowedChannels:
|
||||
"""Tests for _parse_allowed_channels helper."""
|
||||
|
||||
def test_empty_string_returns_empty_set(self):
|
||||
assert _parse_allowed_channels("") == set()
|
||||
assert _parse_allowed_channels(None) == set()
|
||||
|
||||
def test_single_channel(self):
|
||||
assert _parse_allowed_channels("123456789") == {"123456789"}
|
||||
|
||||
def test_comma_separated(self):
|
||||
assert _parse_allowed_channels("111,222,333") == {"111", "222", "333"}
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _parse_allowed_channels(" 111 , 222 ") == {"111", "222"}
|
||||
|
||||
def test_empty_parts_ignored(self):
|
||||
assert _parse_allowed_channels("111,,222,") == {"111", "222"}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed")
|
||||
class TestDiscordPlatform:
|
||||
"""Tests for DiscordPlatform (requires discord.py)."""
|
||||
|
||||
def test_init_with_token(self):
|
||||
platform = DiscordPlatform(
|
||||
bot_token="test_token",
|
||||
allowed_channel_ids="123,456",
|
||||
)
|
||||
assert platform.bot_token == "test_token"
|
||||
assert platform.allowed_channel_ids == {"123", "456"}
|
||||
|
||||
def test_init_without_allowed_channels(self):
|
||||
with patch.dict("os.environ", {"ALLOWED_DISCORD_CHANNELS": ""}, clear=False):
|
||||
platform = DiscordPlatform(bot_token="token", allowed_channel_ids="")
|
||||
assert platform.allowed_channel_ids == set()
|
||||
|
||||
def test_empty_allowed_channels_rejects_all_messages(self):
|
||||
"""When allowed_channel_ids is empty, no channels are allowed (secure default)."""
|
||||
platform = DiscordPlatform(
|
||||
bot_token="token", allowed_channel_ids=""
|
||||
)
|
||||
assert platform.allowed_channel_ids == set()
|
||||
# Empty set means: not self.allowed_channel_ids is True -> reject
|
||||
|
||||
def test_truncate_long_message(self):
|
||||
platform = DiscordPlatform(bot_token="token")
|
||||
long_text = "x" * 2500
|
||||
truncated = platform._truncate(long_text)
|
||||
assert len(truncated) == 2000
|
||||
assert truncated.endswith("...")
|
||||
|
||||
def test_truncate_short_message_unchanged(self):
|
||||
platform = DiscordPlatform(bot_token="token")
|
||||
short = "hello"
|
||||
assert platform._truncate(short) == short
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_returns_message_id(self):
|
||||
platform = DiscordPlatform(bot_token="token")
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.id = 999
|
||||
mock_channel = AsyncMock()
|
||||
mock_channel.send = AsyncMock(return_value=mock_msg)
|
||||
platform._client.get_channel = MagicMock(return_value=mock_channel) # type: ignore[method-assign]
|
||||
platform._connected = True
|
||||
|
||||
msg_id = await platform.send_message("123", "Hello")
|
||||
assert msg_id == "999"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message(self):
|
||||
platform = DiscordPlatform(bot_token="token")
|
||||
mock_msg = AsyncMock()
|
||||
mock_channel = AsyncMock()
|
||||
mock_channel.fetch_message = AsyncMock(return_value=mock_msg)
|
||||
platform._client.get_channel = MagicMock(return_value=mock_channel) # type: ignore[method-assign]
|
||||
platform._connected = True
|
||||
|
||||
await platform.edit_message("123", "456", "Updated text")
|
||||
mock_msg.edit.assert_called_once_with(content="Updated text")
|
||||
@@ -33,9 +33,34 @@ class TestCreateMessagingPlatform:
|
||||
result = create_messaging_platform("telegram", bot_token="")
|
||||
assert result is None
|
||||
|
||||
def test_discord_with_token(self):
|
||||
"""Create Discord platform when discord_bot_token is provided."""
|
||||
mock_platform = MagicMock()
|
||||
with patch("messaging.discord.DISCORD_AVAILABLE", True):
|
||||
with patch("messaging.discord.DiscordPlatform", return_value=mock_platform):
|
||||
result = create_messaging_platform(
|
||||
"discord",
|
||||
discord_bot_token="test_token",
|
||||
allowed_discord_channels="123,456",
|
||||
)
|
||||
|
||||
assert result is mock_platform
|
||||
|
||||
def test_discord_without_token(self):
|
||||
"""Return None when no discord_bot_token for Discord."""
|
||||
result = create_messaging_platform("discord")
|
||||
assert result is None
|
||||
|
||||
def test_discord_empty_token(self):
|
||||
"""Return None when discord_bot_token is empty string."""
|
||||
result = create_messaging_platform(
|
||||
"discord", discord_bot_token="", allowed_discord_channels="123"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_unknown_platform(self):
|
||||
"""Return None for unknown platform types."""
|
||||
result = create_messaging_platform("discord")
|
||||
result = create_messaging_platform("slack")
|
||||
assert result is None
|
||||
|
||||
def test_unknown_platform_with_kwargs(self):
|
||||
|
||||
Reference in New Issue
Block a user