Implement Discord bot support and update README for messaging platform changes

This commit is contained in:
Alishahryar1
2026-02-16 00:08:09 -08:00
parent b53a1b20c5
commit 6511542bfe
13 changed files with 1065 additions and 67 deletions
+9 -3
View File
@@ -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
+29 -14
View File
@@ -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/).
+2
View File
@@ -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
View File
@@ -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
+375
View File
@@ -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
+374
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+1
View File
@@ -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",
+8
View File
@@ -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",
+28
View File
@@ -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"
+93
View File
@@ -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")
+26 -1
View File
@@ -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):