diff --git a/.gitignore b/.gitignore
index a3548d1..5fa9c7a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,8 @@ build/
.venv/
venv/
uv.lock
+bridge/node_modules/
+bridge/package-lock.json
# IDE / Tools
.vscode/
@@ -35,4 +37,5 @@ memory/
*.ipynb
*CLAUDE.md
*AGENTS.md
-*meals/
\ No newline at end of file
+*meals/
+botpy.log
\ No newline at end of file
diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py
index 0b1614d..c3700df 100644
--- a/EvoScientist/EvoScientist.py
+++ b/EvoScientist/EvoScientist.py
@@ -27,7 +27,7 @@ from .mcp import load_mcp_tools
from .middleware import create_memory_middleware
from .prompts import RESEARCHER_INSTRUCTIONS, get_system_prompt
from .utils import load_subagents
-from .tools import tavily_search, think_tool, skill_manager
+from .tools import tavily_search, think_tool, skill_manager, view_image
from . import paths as _paths_mod
from .paths import set_active_workspace, set_workspace_root
@@ -110,10 +110,11 @@ backend = CompositeBackend(
tool_registry = {
"think_tool": think_tool,
"tavily_search": tavily_search,
+ "view_image": view_image,
}
# Base tools that every agent variant gets (before MCP)
-BASE_TOOLS = [think_tool, skill_manager]
+BASE_TOOLS = [think_tool, skill_manager, view_image]
# Cache MCP tools by the effective config signature to avoid reconnecting
# to MCP servers on every `/new` when config is unchanged.
diff --git a/EvoScientist/__init__.py b/EvoScientist/__init__.py
index 2a5af27..29c0520 100644
--- a/EvoScientist/__init__.py
+++ b/EvoScientist/__init__.py
@@ -34,6 +34,9 @@ _EXPORTS: dict[str, tuple[str, str]] = {
# Tools
"tavily_search": (".tools", "tavily_search"),
"think_tool": (".tools", "think_tool"),
+ "view_image": (".tools", "view_image"),
+ # Middleware
+ "create_skills_middleware": (".middleware", "create_skills_middleware"),
# Sessions
"get_checkpointer": (".sessions", "get_checkpointer"),
"generate_thread_id": (".sessions", "generate_thread_id"),
diff --git a/EvoScientist/channels/README.md b/EvoScientist/channels/README.md
new file mode 100644
index 0000000..c445bb9
--- /dev/null
+++ b/EvoScientist/channels/README.md
@@ -0,0 +1,552 @@
+# Channels
+
+EvoScientist provides unified integration with 11 messaging platforms. This document covers the architecture overview, capability matrix, and detailed deployment guide for each channel.
+
+Configuration file: `~/.config/evoscientist/config.yaml` (or use environment variables with the `EVOSCIENTIST_` prefix).
+
+## Architecture
+
+```
+┌──────────┐ ┌──────────┐ ┌──────────┐
+│ Telegram │ │ Discord │ │ Slack │ ... (×11)
+└────┬─────┘ └────┬─────┘ └────┬─────┘
+ │ │ │
+ └─────────────┼─────────────┘
+ ▼
+ ┌──────────────┐
+ │ MessageBus │ async queue, 5000 cap
+ └──────┬───────┘
+ ▼
+ ┌──────────────┐
+ │InboundConsumer│ → Agent → OutboundMessage
+ └──────┬───────┘
+ ▼
+ ┌──────────────┐
+ │ Dispatcher │ routes replies to origin channel
+ └──────────────┘
+```
+
+**Core modules:**
+
+| Module | Responsibility |
+|--------|---------------|
+| `base.py` | Abstract `Channel` base class — declarative readiness checks, retry strategy, mention stripping, send fallback, media handling |
+| `capabilities.py` | `ChannelCapabilities` frozen dataclass — each channel declares its capabilities, framework adapts automatically |
+| `mixins.py` | Reusable patterns: `WebhookMixin` (aiohttp + httpx), `WebSocketMixin` (connect/reconnect/heartbeat), `PollingMixin` (async polling), `TokenMixin` (OAuth token refresh) |
+| `config.py` | `BaseChannelConfig` — shared config fields (allowed_senders, proxy, text_chunk_limit, etc.) |
+| `bus/` | `MessageBus` async message queue + `InboundMessage`/`OutboundMessage` dataclasses |
+| `channel_manager.py` | Lifecycle management (start/stop), health checks, channel registry |
+| `consumer.py` | `InboundConsumer` — dequeue messages, invoke Agent, publish replies |
+| `retry.py` | Configurable exponential backoff retry (`RetryConfig`: attempts, min/max delay, jitter) |
+| `markdown_utils.py` | Universal Markdown converter with per-platform formatting plugins |
+
+## Capability Matrix
+
+| Channel | Format | Max Len | Media | Voice | Sticker | Location | Video | Typing | Reaction | Thread | Group | @Mention | No Public IP | Token Refresh | Proxy | Allowlist |
+|:--------|:------:|:-------:|:-----:|:-----:|:-------:|:--------:|:-----:|:------:|:--------:|:------:|:-----:|:--------:|:------------:|:-------------:|:-----:|:---------:|
+| Telegram | HTML | 4000 | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ | | ✓ | ✓ | ✓ | | ✓ | ✓ |
+| Discord | Discord | 2000 | ✓ | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ |
+| Slack | Mrkdwn | 4000 | ✓ | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ |
+| Feishu | MD | 4096 | ✓ | ✓ | ✓ | | | | ✓ | | ✓ | ✓ | | ✓ | ✓ | ✓ |
+| WeChat | MD | 4096 | ✓ | ✓ | | ✓ | | | | | ✓ | ✓ | | ✓ | ✓ | ✓ |
+| DingTalk | MD | 4096 | ✓ | ✓ | | | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
+| QQ | Plain | 4096 | ✓ | | | | | | | | ✓ | ✓ | ✓ | | | ✓ |
+| Signal | Plain | 4096 | ✓ | ✓ | | | | ✓ | ✓ | | ✓ | ✓ | ✓ | | | ✓ |
+| iMessage | Plain | ∞ | ✓ | ✓ | | | | | | | ✓ | | ✓ | | | ✓ |
+| Email | HTML | ∞ | ✓ | | | | | | | | | | ✓ | | | ✓ |
+
+### Connection Types
+
+| Channel | Transport | Connection Mode | Default Port |
+|-------------|-----------|----------------------------------------|:------------:|
+| Telegram | HTTPS | Long polling (`getUpdates`) | — |
+| Discord | WebSocket | Gateway events (`discord.py`) | — |
+| Slack | WebSocket | Socket Mode (`slack-sdk`) | — |
+| Feishu | HTTP | Webhook `POST /webhook/event` | 9000 |
+| WeChat | HTTP | Webhook `POST /wechat/callback` | 9001 |
+| DingTalk | WebSocket | Stream Mode (DingTalk gateway) | — |
+| QQ | WebSocket | Bot Gateway (`qq-botpy`) | — |
+| Signal | TCP | JSON-RPC (`signal-cli` daemon) | 7583 |
+| iMessage | stdio | JSON-RPC (`imsg` CLI) | — |
+| Email | TCP | IMAP polling + SMTP send | 993/587 |
+
+> **"—"** means no listening port is required — no public IP or port forwarding needed.
+
+## Quick Start
+
+### 1. Install channel dependencies
+
+```bash
+pip install evoscientist[telegram]
+# Available extras: telegram, discord, slack, feishu, wechat,
+# dingtalk, qq, email, signal
+# iMessage requires no extra Python dependencies
+```
+
+### 2. Configure
+
+```bash
+# Option A: Interactive wizard
+EvoSci onboard
+
+# Option B: CLI commands
+EvoSci config set channel_enabled telegram
+EvoSci config set telegram_bot_token "123456:ABC-xxx"
+
+# Option C: Environment variables (EVOSCIENTIST_ prefix, uppercase)
+export EVOSCIENTIST_CHANNEL_ENABLED=telegram
+export EVOSCIENTIST_TELEGRAM_BOT_TOKEN="123456:ABC-xxx"
+```
+
+### 3. Start
+
+```bash
+EvoSci serve # Start agent + all enabled channels
+# or
+EvoSci channel start # Standalone channel mode (message loop only)
+```
+
+### 4. Health check
+
+```bash
+curl http://localhost:8080/healthz
+```
+
+```json
+{
+ "status": "healthy",
+ "channels": { "enabled": ["telegram"], "running": ["telegram"] }
+}
+```
+
+### Running multiple channels
+
+Comma-separate channel names in the config to enable multiple channels simultaneously:
+
+```yaml
+channel_enabled: "telegram,discord,imessage"
+```
+
+All enabled channels run concurrently via the internal message bus.
+
+---
+
+## Channel Deployment Guides
+
+---
+
+### Telegram
+
+**Install:** `pip install evoscientist[telegram]`
+
+**Prerequisites:**
+
+1. Search for [@BotFather](https://t.me/BotFather) in Telegram, send `/newbot`, and follow the prompts to create a bot.
+2. BotFather will return a Bot Token (format: `123456789:ABCdefGHI...`) — save it securely.
+3. Get your user ID: send any message to [@userinfobot](https://t.me/userinfobot), it will reply with your numeric ID.
+4. (Optional) For group use: add the bot to a group, then in BotFather send `/setprivacy` → `Disable` so the bot can read group messages.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "telegram"
+telegram_bot_token: "123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
+telegram_allowed_senders: "" # Comma-separated user IDs; empty = no restriction
+telegram_proxy: "" # Optional HTTPS proxy (e.g. http://proxy:8080)
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `telegram_bot_token` | `str` | `""` | **Required.** Bot API Token from BotFather |
+| `telegram_allowed_senders` | `str` | `""` | Comma-separated user IDs, empty = allow all |
+| `telegram_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_TELEGRAM_BOT_TOKEN`, `EVOSCIENTIST_TELEGRAM_ALLOWED_SENDERS`, `EVOSCIENTIST_TELEGRAM_PROXY`
+
+**Technical details:** Long polling mode, `drop_pending_updates=True` on startup to skip backlog. Markdown→Telegram HTML auto-conversion (bold, italic, strikethrough, links, code blocks, headings, lists). Falls back to plain text on HTML parse failure. Media routed by extension to `send_photo`/`send_video`/`send_audio`/`send_document`. In groups, only responds when @mentioned; auto-strips @mention. Typing indicator refreshes every 4s. Retry: 3 attempts, min delay 0.4s, parse errors not retried. Text chunk limit: 4000 chars.
+
+---
+
+### Discord
+
+**Install:** `pip install evoscientist[discord]`
+
+**Prerequisites:**
+
+1. Go to [Discord Developer Portal](https://discord.com/developers/applications) → New Application → enter a name.
+2. Left menu **Bot** → Reset Token → copy the Bot Token.
+3. Under **Privileged Gateway Intents**, enable **Message Content Intent** (required to read message content).
+4. Left menu **OAuth2** → URL Generator:
+ - Scopes: check `bot`
+ - Bot Permissions: check `Send Messages`, `Read Message History`, `Attach Files`, `Add Reactions`
+ - Copy the generated URL, open in browser, select a server to invite the bot.
+5. Get user ID: Discord Settings → Advanced → enable Developer Mode → right-click username → Copy User ID.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "discord"
+discord_bot_token: "MTIzNDU2Nzg5.xxxx.xxxxx"
+discord_allowed_senders: "" # Comma-separated user IDs
+discord_allowed_channels: "" # Comma-separated channel IDs
+discord_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `discord_bot_token` | `str` | `""` | **Required.** Bot Token |
+| `discord_allowed_senders` | `str` | `""` | Comma-separated user IDs, empty = allow all |
+| `discord_allowed_channels` | `str` | `""` | Comma-separated channel IDs, empty = allow all |
+| `discord_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_DISCORD_BOT_TOKEN`, `EVOSCIENTIST_DISCORD_ALLOWED_SENDERS`, `EVOSCIENTIST_DISCORD_ALLOWED_CHANNELS`, `EVOSCIENTIST_DISCORD_PROXY`
+
+**Technical details:** WebSocket Gateway (`discord.py`). In server channels, only responds when @mentioned; DMs respond directly. Replies via `MessageReference`. Attachment download (max 20 MB) with safe filename sanitization. Media sent via `discord.File`. Typing indicator refreshes every 8s. Retry: 3 attempts, parses `Retry-After` header for 429s. Text chunk limit: 2000 chars.
+
+---
+
+### Slack
+
+**Install:** `pip install evoscientist[slack]`
+
+**Prerequisites:**
+
+1. Go to [Slack API](https://api.slack.com/apps) → Create New App → From scratch → select workspace.
+2. Left menu **Socket Mode** → enable → Generate App-Level Token, scope `connections:write` → copy App Token (`xapp-...`).
+3. Left menu **OAuth & Permissions** → add Bot Token Scopes:
+ - `chat:write`, `channels:history`, `groups:history`, `im:history`, `files:read`, `files:write`, `reactions:write`
+4. Click **Install to Workspace** → copy Bot User OAuth Token (`xoxb-...`).
+5. Left menu **Event Subscriptions** → enable → Subscribe to bot events: `message.channels`, `message.groups`, `message.im`, `app_mention`.
+6. Get Member ID: click user avatar → profile → **⋮** → Copy member ID.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "slack"
+slack_bot_token: "xoxb-xxxx-xxxx-xxxx"
+slack_app_token: "xapp-1-xxxx-xxxx"
+slack_allowed_senders: "" # Member ID (U...)
+slack_allowed_channels: "" # Channel ID (C...)
+slack_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `slack_bot_token` | `str` | `""` | **Required.** Bot User OAuth Token (`xoxb-`) |
+| `slack_app_token` | `str` | `""` | **Required.** Socket Mode App Token (`xapp-`) |
+| `slack_allowed_senders` | `str` | `""` | Comma-separated Member IDs |
+| `slack_allowed_channels` | `str` | `""` | Comma-separated Channel IDs |
+| `slack_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_SLACK_BOT_TOKEN`, `EVOSCIENTIST_SLACK_APP_TOKEN`, `EVOSCIENTIST_SLACK_ALLOWED_SENDERS`, `EVOSCIENTIST_SLACK_ALLOWED_CHANNELS`, `EVOSCIENTIST_SLACK_PROXY`
+
+**Technical details:** Socket Mode (no public URL needed). Markdown→mrkdwn conversion. DMs respond directly; channels only respond to `app_mention` events. Thread replies via `thread_ts`. Attachments downloaded with Bearer auth. Media sent via `files_upload_v2`. Runs `auth_test()` on startup to verify credentials. Retry: 3 attempts, exponential backoff + jitter. Text chunk limit: 4000 chars.
+
+---
+
+### Feishu (Lark)
+
+**Install:** `pip install evoscientist[feishu]`
+
+**Prerequisites:**
+
+1. Go to [Feishu Open Platform](https://open.feishu.cn/app) (international: [Lark Developer](https://open.larksuite.com/app)) → create a custom app.
+2. Copy the **App ID** and **App Secret**.
+3. Left menu **Event Subscriptions** → set request URL to `http://your-host:9000/webhook/event` → copy **Verification Token** and **Encrypt Key**.
+4. Add event: `im.message.receive_v1` (receive messages).
+5. Left menu **Permissions** → enable `im:message:send_as_bot`.
+6. Create a version and publish.
+
+> Webhook must be publicly reachable. For local dev, use `ngrok http 9000`.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "feishu"
+feishu_app_id: "cli_xxxxxxx"
+feishu_app_secret: "xxxxxxxxxxxxxxxxxx"
+feishu_webhook_port: 9000
+feishu_allowed_senders: "" # open_id
+feishu_domain: "https://open.feishu.cn"
+feishu_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `feishu_app_id` | `str` | `""` | **Required.** App ID |
+| `feishu_app_secret` | `str` | `""` | **Required.** App Secret |
+| `feishu_webhook_port` | `int` | `9000` | Webhook HTTP port |
+| `feishu_allowed_senders` | `str` | `""` | Comma-separated open_ids |
+| `feishu_domain` | `str` | `"https://open.feishu.cn"` | API domain (use `https://open.larksuite.com` for Lark) |
+| `feishu_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_FEISHU_APP_ID`, `EVOSCIENTIST_FEISHU_APP_SECRET`, `EVOSCIENTIST_FEISHU_WEBHOOK_PORT`, `EVOSCIENTIST_FEISHU_DOMAIN`
+
+**Technical details:** Webhook on `POST /webhook/event` with URL verification challenge-response. `tenant_access_token` auto-refresh (2h TTL, refreshes 5 min before expiry). Markdown→Post rich text conversion (code blocks, bold, italic, strikethrough, links, headings, quotes, lists). Plain text fallback. Group @mention filtering. Media: images via `/im/v1/images`, files via `/im/v1/files`. Replies via `/messages/{id}/reply`. Retry: 3 attempts, rate limit delay 2.0s, matches `99991400`/`rate limit`. Text chunk limit: 4096 chars.
+
+---
+
+### WeChat
+
+**Install:** `pip install evoscientist[wechat]`
+
+Two backends supported: **WeCom** (recommended, free, no certification needed) and **WeChat Official Account** (requires verified service account).
+
+#### WeCom
+
+**Prerequisites:**
+
+1. Log in to [WeCom Admin Console](https://work.weixin.qq.com) → App Management → create a custom app.
+2. Copy the **Corp ID**, **AgentId**, and **Secret**.
+3. In app details → Receive Messages → Set API Receive → URL: `http://your-host:9001/wechat/callback` → copy **Token** and **EncodingAESKey**.
+
+```yaml
+channel_enabled: "wechat"
+wechat_backend: "wecom"
+wechat_webhook_port: 9001
+wechat_wecom_corp_id: "ww..."
+wechat_wecom_agent_id: "1000002"
+wechat_wecom_secret: "xxxxxxxxxxxxxxxxxx"
+wechat_wecom_token: "xxxxxxxxxxxxxxxxxx"
+wechat_wecom_encoding_aes_key: "xxxxxxxxxxxxxxxxxx"
+wechat_allowed_senders: ""
+wechat_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `wechat_backend` | `str` | `"wecom"` | `"wecom"` or `"wechatmp"` |
+| `wechat_webhook_port` | `int` | `9001` | Callback HTTP port |
+| `wechat_wecom_corp_id` | `str` | `""` | **Required (WeCom).** Corp ID |
+| `wechat_wecom_agent_id` | `str` | `""` | **Required (WeCom).** App AgentId |
+| `wechat_wecom_secret` | `str` | `""` | **Required (WeCom).** App Secret |
+| `wechat_wecom_token` | `str` | `""` | **Required (WeCom).** Callback Token |
+| `wechat_wecom_encoding_aes_key` | `str` | `""` | **Required (WeCom).** Callback EncodingAESKey |
+
+#### WeChat Official Account
+
+**Prerequisites:**
+
+1. Log in to [WeChat Official Account Platform](https://mp.weixin.qq.com) → Settings & Development → Basic Configuration.
+2. Copy the **AppID** and **AppSecret**.
+3. Server Configuration → URL: `http://your-host:9001/wechat/callback` → set **Token** and **EncodingAESKey**.
+
+```yaml
+wechat_backend: "wechatmp"
+wechat_mp_app_id: "wx..."
+wechat_mp_app_secret: "xxxxxxxxxxxxxxxxxx"
+wechat_mp_token: "xxxxxxxxxxxxxxxxxx"
+wechat_mp_encoding_aes_key: "xxxxxxxxxxxxxxxxxx"
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `wechat_mp_app_id` | `str` | `""` | **Required (MP).** AppID |
+| `wechat_mp_app_secret` | `str` | `""` | **Required (MP).** AppSecret |
+| `wechat_mp_token` | `str` | `""` | **Required (MP).** Server Token |
+| `wechat_mp_encoding_aes_key` | `str` | `""` | **Required (MP).** Server EncodingAESKey |
+
+**Technical details:** Webhook HTTP server. XML message parsing. Signature verification. `access_token` auto-refresh. Optional AES encryption/decryption. WeCom supports Markdown message format; Official Account uses plain text. Media send/receive. Retry + backoff. Text chunk limit: 2048 chars.
+
+---
+
+### DingTalk
+
+**Install:** `pip install evoscientist[dingtalk]`
+
+**Prerequisites:**
+
+1. Go to [DingTalk Open Platform](https://open-dev.dingtalk.com) → App Development → create a bot app.
+2. Copy the **AppKey** (Client ID) and **AppSecret** (Client Secret).
+3. Enable **Stream Mode** in the app configuration — no public IP needed.
+4. Publish the app and add the bot to a group, or test via direct message.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "dingtalk"
+dingtalk_client_id: "ding..."
+dingtalk_client_secret: "xxxxxxxxxxxxxxxxxx"
+dingtalk_allowed_senders: ""
+dingtalk_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `dingtalk_client_id` | `str` | `""` | **Required.** AppKey |
+| `dingtalk_client_secret` | `str` | `""` | **Required.** AppSecret |
+| `dingtalk_allowed_senders` | `str` | `""` | Comma-separated user IDs |
+| `dingtalk_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_DINGTALK_CLIENT_ID`, `EVOSCIENTIST_DINGTALK_CLIENT_SECRET`
+
+**Technical details:** Stream Mode (WebSocket, no public IP needed). Connects via DingTalk gateway with automatic ping/pong heartbeat and message ACK. `access_token` auto-refresh. Group @mention filtering (strips first `@bot` mention). Supports image, file, video, audio attachment download. Sends in Markdown format (`sampleMarkdown`). Auth errors (`invalidauthentication`/`forbidden`/`40014`) not retried. Text chunk limit: 4096 chars.
+
+---
+
+### QQ
+
+**Install:** `pip install evoscientist[qq]`
+
+**Prerequisites:**
+
+1. Go to [QQ Open Platform](https://q.qq.com) → create a bot application.
+2. Complete developer verification, create a sandbox or production bot.
+3. Copy the **AppID** and **AppSecret**.
+4. Search for and add the bot as a friend in QQ, or add it to a group.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "qq"
+qq_app_id: "xxxxxxxxxx"
+qq_app_secret: "xxxxxxxxxxxxxxxxxx"
+qq_allowed_senders: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `qq_app_id` | `str` | `""` | **Required.** AppID |
+| `qq_app_secret` | `str` | `""` | **Required.** AppSecret |
+| `qq_allowed_senders` | `str` | `""` | Comma-separated user IDs |
+
+**Env vars:** `EVOSCIENTIST_QQ_APP_ID`, `EVOSCIENTIST_QQ_APP_SECRET`
+
+**Technical details:** Uses `qq-botpy` SDK via WebSocket to connect to QQ Bot Gateway. Supports C2C (direct) and group messages. Message deduplication (1000-entry LRU cache). Group @mention filtering (strips first `@bot`). Intents: `public_messages=True`, `direct_message=True`. Text chunk limit: 2048 chars.
+
+---
+
+### Signal
+
+**Install:** `pip install evoscientist[signal]` (also requires [signal-cli](https://github.com/AsamK/signal-cli) installed separately)
+
+**Prerequisites:**
+
+1. Install signal-cli: see [signal-cli installation guide](https://github.com/AsamK/signal-cli#installation).
+2. Register or link a phone number:
+ - Register: `signal-cli -u +1234567890 register`, then `signal-cli -u +1234567890 verify CODE`
+ - Link existing device: `signal-cli link -n "EvoScientist"`
+3. EvoScientist will auto-start the signal-cli daemon if it's not already running.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "signal"
+signal_phone_number: "+1234567890"
+signal_cli_path: "signal-cli"
+signal_config_dir: ""
+signal_allowed_senders: ""
+signal_rpc_port: 7583
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `signal_phone_number` | `str` | `""` | **Required.** Signal phone number (E.164 format) |
+| `signal_cli_path` | `str` | `"signal-cli"` | Path to signal-cli binary |
+| `signal_config_dir` | `str` | `""` | signal-cli config directory (optional) |
+| `signal_allowed_senders` | `str` | `""` | Comma-separated phone numbers |
+| `signal_rpc_port` | `int` | `7583` | JSON RPC socket port |
+
+**Env vars:** `EVOSCIENTIST_SIGNAL_PHONE_NUMBER`, `EVOSCIENTIST_SIGNAL_CLI_PATH`, `EVOSCIENTIST_SIGNAL_RPC_PORT`
+
+**Technical details:** JSON RPC over TCP socket to signal-cli daemon. Auto-starts daemon if not running (`signal-cli -u +NUMBER daemon --socket localhost:PORT`). Listens for `receive` notifications. Sends via `send` RPC method. Group detection via `groupInfo`. Mention detection via UUID matching. No public IP needed. Text chunk limit: 4096 chars.
+
+---
+
+### Email
+
+**Install:** `pip install evoscientist[email]` (core dependencies included, no extras needed)
+
+**Prerequisites:**
+
+1. Prepare an email account with IMAP + SMTP support (Gmail, Outlook, self-hosted, etc.).
+2. **Gmail:** Enable 2FA → generate an App Password. IMAP: `imap.gmail.com:993` (SSL), SMTP: `smtp.gmail.com:587` (STARTTLS).
+3. **Outlook/Office 365:** IMAP: `outlook.office365.com:993` (SSL), SMTP: `smtp.office365.com:587` (STARTTLS).
+4. Ensure IMAP access is enabled in your email settings.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "email"
+email_imap_host: "imap.gmail.com"
+email_imap_port: 993
+email_imap_username: "bot@gmail.com"
+email_imap_password: "xxxx-xxxx-xxxx-xxxx"
+email_imap_mailbox: "INBOX"
+email_imap_use_ssl: true
+email_smtp_host: "smtp.gmail.com"
+email_smtp_port: 587
+email_smtp_username: "bot@gmail.com"
+email_smtp_password: "xxxx-xxxx-xxxx-xxxx"
+email_smtp_use_tls: true
+email_from_address: "bot@gmail.com"
+email_poll_interval: 30
+email_mark_seen: true
+email_allowed_senders: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `email_imap_host` | `str` | `""` | **Required.** IMAP server address |
+| `email_imap_port` | `int` | `993` | IMAP port |
+| `email_imap_username` | `str` | `""` | **Required.** IMAP login username |
+| `email_imap_password` | `str` | `""` | **Required.** IMAP login password (or app password) |
+| `email_imap_mailbox` | `str` | `"INBOX"` | Mailbox folder to monitor |
+| `email_imap_use_ssl` | `bool` | `true` | Use SSL for IMAP connection |
+| `email_smtp_host` | `str` | `""` | **Required.** SMTP server address |
+| `email_smtp_port` | `int` | `587` | SMTP port |
+| `email_smtp_username` | `str` | `""` | **Required.** SMTP login username |
+| `email_smtp_password` | `str` | `""` | **Required.** SMTP login password |
+| `email_smtp_use_tls` | `bool` | `true` | Use STARTTLS (`true`) or SSL (`false`) |
+| `email_from_address` | `str` | `""` | Sender address (defaults to smtp_username) |
+| `email_poll_interval` | `int` | `30` | IMAP poll interval in seconds |
+| `email_mark_seen` | `bool` | `true` | Mark emails as read after processing |
+| `email_max_body_chars` | `int` | `12000` | Max email body chars (truncated beyond) |
+| `email_subject_prefix` | `str` | `"Re: "` | Reply subject prefix |
+| `email_allowed_senders` | `str` | `""` | Comma-separated sender email addresses |
+
+**Env vars:** `EVOSCIENTIST_EMAIL_IMAP_HOST`, `EVOSCIENTIST_EMAIL_IMAP_USERNAME`, `EVOSCIENTIST_EMAIL_IMAP_PASSWORD`, `EVOSCIENTIST_EMAIL_SMTP_HOST`, `EVOSCIENTIST_EMAIL_SMTP_USERNAME`, `EVOSCIENTIST_EMAIL_SMTP_PASSWORD`
+
+**Technical details:** IMAP polling mode, checks for UNSEEN emails periodically (max 20 per cycle). Supports SSL and STARTTLS. Auto-parses multipart emails (prefers text/plain, falls back text/html → plain text). Attachments auto-downloaded. Replies set `In-Reply-To` and `References` headers to maintain email threads. Sends HTML + plain text dual format (multipart/alternative), falls back to plain text on HTML failure. IMAP auto-reconnects on disconnect. Auth errors (auth/login/credential) not retried. No public IP needed. Text chunk limit: no limit.
+
+---
+
+### iMessage
+
+**Install:** No extra Python dependencies. Requires the [imsg](https://github.com/anthropics/imsg) CLI tool.
+
+**Requirements:** macOS only (iMessage is Apple-proprietary). Requires a signed-in Apple ID with iMessage and Full Disk Access permission for the terminal app.
+
+**Prerequisites:**
+
+1. Install imsg CLI:
+ ```bash
+ brew install imsg
+ ```
+2. Verify: `imsg --version`
+3. Ensure Messages.app is signed in and working on macOS.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "imessage"
+imessage_cli_path: "imsg"
+imessage_db_path: ""
+imessage_service: "auto"
+imessage_region: "US"
+imessage_allowed_senders: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `imessage_cli_path` | `str` | `"imsg"` | Path to imsg CLI binary |
+| `imessage_db_path` | `str` | `""` | iMessage database path (empty = default) |
+| `imessage_service` | `str` | `"auto"` | Send service: `imessage`, `sms`, or `auto` |
+| `imessage_region` | `str` | `"US"` | Phone number region code |
+| `imessage_allowed_senders` | `str` | `""` | Comma-separated allowlist (see below) |
+
+**Allowlist formats:** phone (`+1234567890`), email (`user@example.com`), `chat_id:123`, `chat_guid:iMessage;-;+1234567890`, wildcard `*`.
+
+**Env vars:** `EVOSCIENTIST_IMESSAGE_CLI_PATH`, `EVOSCIENTIST_IMESSAGE_SERVICE`, `EVOSCIENTIST_IMESSAGE_ALLOWED_SENDERS`
+
+**Technical details:** JSON-RPC over stdio with imsg CLI. Creates `watch.subscribe` on startup for real-time message streaming (not polling). Supports iMessage + SMS dual channel (`service: auto`). Target resolution supports chat_id, chat_guid, chat_identifier, and phone/email. Attachments read from local paths provided by imsg. Group detection via `is_group` field. RPC errors (AppleScript/permission/not found) not retried; only connection timeouts retried. Plain text format (no Markdown). No public IP needed. Text chunk limit: 4000 chars.
diff --git a/EvoScientist/channels/__init__.py b/EvoScientist/channels/__init__.py
index 1fac31d..7085a47 100644
--- a/EvoScientist/channels/__init__.py
+++ b/EvoScientist/channels/__init__.py
@@ -1,9 +1,44 @@
"""Communication channels for EvoScientist.
This module provides an extensible interface for different messaging channels
-(iMessage, WeChat, etc.) to communicate with the EvoScientist agent.
+(iMessage, Telegram, Discord) to communicate with the EvoScientist agent.
"""
-from .base import Channel, IncomingMessage, OutgoingMessage
+from .base import Channel, RawIncoming, IncomingMessage, OutgoingMessage, chunk_text
+from .bus import MessageBus, InboundMessage, OutboundMessage
+from .capabilities import ChannelCapabilities
+from .channel_manager import ChannelManager, register_channel, create_channel, available_channels
+from .consumer import InboundConsumer
+from .formatter import UnifiedFormatter
+from .middleware import TypingManager
+from .plugin import ChannelPlugin, ChannelMeta, ReloadPolicy
+from .standalone import run_standalone
-__all__ = ["Channel", "IncomingMessage", "OutgoingMessage"]
+# Backward compat: ChannelServer is now Channel itself
+ChannelServer = Channel
+
+__all__ = [
+ "Channel",
+ "ChannelServer",
+ "ChannelManager",
+ "MessageBus",
+ "RawIncoming",
+ "IncomingMessage",
+ "OutgoingMessage",
+ "InboundMessage",
+ "OutboundMessage",
+ "InboundConsumer",
+ "run_standalone",
+ "register_channel",
+ "create_channel",
+ "available_channels",
+ # New modules
+ "ChannelCapabilities",
+ "UnifiedFormatter",
+ "TypingManager",
+ "chunk_text",
+ # Plugin architecture
+ "ChannelPlugin",
+ "ChannelMeta",
+ "ReloadPolicy",
+]
diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py
index ecf1260..fbe938d 100644
--- a/EvoScientist/channels/base.py
+++ b/EvoScientist/channels/base.py
@@ -5,42 +5,358 @@ This module defines the Channel interface that all messaging channels
"""
from abc import ABC, abstractmethod
+import asyncio
+import logging
+import re
+from collections import defaultdict
+from collections.abc import Awaitable, Callable as CallableABC
from dataclasses import dataclass, field
from datetime import datetime
-from typing import AsyncIterator
+from pathlib import Path
+from typing import Any, AsyncIterator, Callable
+
+from ..paths import WORKSPACE_ROOT
+
+from .bus.events import InboundMessage, OutboundMessage
+from .capabilities import ChannelCapabilities
+from .formatter import UnifiedFormatter
+from .plugin import ChannelPlugin, ChannelMeta
+
+_logger = logging.getLogger(__name__)
+
+
+# ── Text chunking ────────────────────────────────────────────────────
+
+def chunk_text(text: str, limit: int) -> list[str]:
+ """Split text into chunks that respect logical boundaries.
+
+ Splitting priority (highest to lowest):
+ 1. Code block boundaries (``` fences)
+ 2. Double newlines (paragraph breaks)
+ 3. Single newlines
+ 4. Spaces (word boundaries)
+ 5. Hard cut (last resort)
+
+ Code blocks are never split mid-block when possible. If a single code
+ block exceeds the limit it is sent as its own chunk(s).
+
+ Args:
+ text: The text to split.
+ limit: Maximum characters per chunk.
+
+ Returns:
+ List of text chunks, each <= limit characters.
+ """
+ if not text:
+ return []
+ if len(text) <= limit:
+ return [text]
+
+ chunks: list[str] = []
+ remaining = text
+
+ while remaining:
+ if len(remaining) <= limit:
+ chunks.append(remaining)
+ break
+
+ # Try to find a split point within the limit
+ segment = remaining[:limit]
+
+ # 1. Prefer splitting at code block boundary (``` at line start)
+ best = -1
+ fence_pos = segment.rfind("\n```")
+ if fence_pos > 0:
+ line_end = segment.find("\n", fence_pos + 1)
+ if line_end == -1:
+ line_end = len(segment)
+ best = line_end
+
+ # 2. Double newline (paragraph break)
+ if best == -1:
+ pos = segment.rfind("\n\n")
+ if pos > 0:
+ best = pos
+
+ # 3. Single newline
+ if best == -1:
+ pos = segment.rfind("\n")
+ if pos > 0:
+ best = pos
+
+ # 4. Space (word boundary)
+ if best == -1:
+ pos = segment.rfind(" ")
+ if pos > 0:
+ best = pos
+
+ # 5. Hard cut
+ if best == -1:
+ best = limit
+
+ chunk = remaining[:best].rstrip()
+ if chunk:
+ chunks.append(chunk)
+ remaining = remaining[best:].lstrip("\n")
+
+ return chunks
+
+
+# ── Attachment / media helpers ───────────────────────────────────────
+
+MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20 MB
+MEDIA_DIR = WORKSPACE_ROOT / "media"
+
+IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"})
+VIDEO_EXTS = frozenset({".mp4", ".mov", ".avi", ".webm"})
+AUDIO_EXTS = frozenset({".mp3", ".ogg", ".m4a", ".wav"})
+
+
+def classify_media(ext: str) -> str | None:
+ """Classify a file extension into a media type string.
+
+ Returns ``"image"``, ``"video"``, ``"audio"``, or ``None``.
+ """
+ ext = ext.lower()
+ if ext in IMAGE_EXTS:
+ return "image"
+ if ext in VIDEO_EXTS:
+ return "video"
+ if ext in AUDIO_EXTS:
+ return "audio"
+ return None
+
+
+def media_path(filename: str) -> Path:
+ """Ensure MEDIA_DIR exists and return a path inside it."""
+ MEDIA_DIR.mkdir(parents=True, exist_ok=True)
+ return MEDIA_DIR / filename
+
+
+def check_attachment_size(file_size: int, filename: str) -> str | None:
+ """Return a 'too large' annotation if *file_size* exceeds the limit.
+
+ Returns ``None`` when the file is within the allowed size.
+ """
+ if file_size > MAX_ATTACHMENT_BYTES:
+ return f"[attachment: {filename} - too large ({file_size} bytes)]"
+ return None
+
+
+async def download_attachment(
+ url: str,
+ filename: str,
+ *,
+ channel_name: str = "",
+ headers: dict[str, str] | None = None,
+ file_size: int | None = None,
+ proxy: str | None = None,
+) -> tuple[str | None, str | None]:
+ """Download an attachment via httpx.
+
+ Returns ``(local_path, annotation)``.
+
+ If *file_size* exceeds ``MAX_ATTACHMENT_BYTES``, returns
+ ``(None, too-large-annotation)`` without downloading.
+ On download failure returns ``(None, failure-annotation)``.
+ On success returns ``(local_path_str, success-annotation)``.
+ """
+ if file_size is not None:
+ too_large = check_attachment_size(file_size, filename)
+ if too_large:
+ return None, too_large
+
+ try:
+ import httpx
+
+ safe_name = filename.replace("/", "_")
+ prefix = f"{channel_name}_" if channel_name else ""
+ local_path = media_path(f"{prefix}{safe_name}")
+
+ async with httpx.AsyncClient(proxy=proxy) as client:
+ async with client.stream("GET", url, headers=headers or {}, timeout=30) as resp:
+ if resp.status_code != 200:
+ return None, f"[attachment: {filename} - download failed]"
+
+ # Check Content-Length header before downloading body
+ if file_size is None:
+ cl = resp.headers.get("content-length")
+ if cl:
+ try:
+ too_large = check_attachment_size(int(cl), filename)
+ if too_large:
+ return None, too_large
+ except (ValueError, TypeError):
+ pass
+
+ # Stream body with incremental size check
+ chunks: list[bytes] = []
+ total = 0
+ async for chunk in resp.aiter_bytes():
+ total += len(chunk)
+ if total > MAX_ATTACHMENT_BYTES:
+ return None, check_attachment_size(total, filename)
+ chunks.append(chunk)
+
+ local_path.write_bytes(b"".join(chunks))
+ return str(local_path), f"[attachment: {local_path}]"
+ except Exception as e:
+ _logger.warning(f"Failed to download attachment: {e}")
+ return None, f"[attachment: {filename} - download failed]"
+
+# Deprecated aliases — use InboundMessage / OutboundMessage instead.
+IncomingMessage = InboundMessage
+OutgoingMessage = OutboundMessage
@dataclass
-class IncomingMessage:
- """Represents a message received from a channel."""
+class RawIncoming:
+ """Raw data extracted from a platform-specific message event.
- sender: str # Phone number, email, or unique identifier
- content: str # Message text content
- timestamp: datetime # When the message was sent
- message_id: str # Unique identifier for the message
- metadata: dict = field(default_factory=dict) # Channel-specific metadata
+ Each channel's ``_on_message`` populates this with platform data,
+ then calls ``_enqueue_raw()`` which handles allow-list checks,
+ content merging, and ``InboundMessage`` creation.
+ """
+
+ sender_id: str
+ chat_id: str
+ text: str = ""
+ media_files: list[str] = field(default_factory=list)
+ content_annotations: list[str] = field(default_factory=list)
+ timestamp: datetime = field(default_factory=datetime.now)
+ message_id: str = ""
+ metadata: dict = field(default_factory=dict)
+ is_group: bool = False
+ was_mentioned: bool = True # default True so DMs always pass
-@dataclass
-class OutgoingMessage:
- """Represents a message to be sent through a channel."""
-
- recipient: str # Phone number, email, or unique identifier
- content: str # Message text content
- reply_to: str | None = None # Optional message ID being replied to
- metadata: dict = field(default_factory=dict) # Channel-specific metadata
-
-
-class Channel(ABC):
+class Channel(ChannelPlugin, ABC):
"""Abstract base class for messaging channels.
Subclasses must implement:
- start(): Initialize the channel (connect, authenticate, etc.)
- - stop(): Clean up resources
- - receive(): Async iterator yielding incoming messages
- - send(): Send a message through the channel
+ - _send_chunk(): Send a single text chunk (platform-specific)
+
+ Subclasses may optionally override:
+ - _cleanup(): Channel-specific teardown (called by stop())
+ - _format_chunk(): Convert Markdown to channel format
+ - _is_ready(): Return False if channel cannot send
+ - _resolve_chat_id(): Extract chat_id from message
+ - receive(): Only if custom exit conditions are needed
+
+ Subclasses should set ``name`` to a unique identifier (e.g. "telegram").
"""
+ name: str = "base"
+ capabilities: ChannelCapabilities = ChannelCapabilities()
+ _typing_interval: float = 5.0
+ _ready_attrs: tuple[str, ...] = ()
+
+ def __init__(self, config, *, queue_maxsize: int = 1000):
+ ChannelPlugin.__init__(self)
+ self.id = self.name
+ self.meta = ChannelMeta(id=self.name, label=self.name.title())
+
+ self.config = config
+
+ # Auto-configure formatter from capabilities
+ self._formatter = UnifiedFormatter.for_channel(self.capabilities.format_type)
+ self._queue: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=queue_maxsize)
+ self._running = False
+
+ # Typing indicator — delegated to TypingManager
+ from .middleware import TypingManager
+ self._typing_manager = TypingManager(
+ self._send_typing_action, interval=self._typing_interval,
+ )
+ # Keep legacy dict reference for any subclass that touches it directly
+ self._typing_tasks = self._typing_manager._tasks
+
+ # Bus integration (injected by ChannelManager.register / set_bus)
+ self._bus: Any = None
+ self.send_thinking: bool = False
+ self._on_activity: Callable | None = None
+
+ # Debounce settings
+ self.initial_debounce: float = 2.0
+ self.debounce_step: float = 0.5
+ self.max_debounce: float = 5.0
+
+ # Per-sender message buffers for debouncing
+ self._message_buffers: dict[str, list[str]] = {}
+ self._message_metadata: dict[str, dict] = {}
+ self._message_media: dict[str, list[str]] = {}
+ self._message_ids: dict[str, str] = {}
+ self._debounce_tasks: dict[str, asyncio.Task] = {}
+
+ # Mention gating: "always" | "group" | "off"
+ self.require_mention: str = getattr(config, "require_mention", "group")
+
+ # DM policy: "open" | "allowlist" | "pairing"
+ self.dm_policy: str = getattr(config, "dm_policy", "allowlist")
+
+ # Per-sender is_group / was_mentioned for debounce merge
+ self._message_is_group: dict[str, bool] = {}
+ self._message_was_mentioned: dict[str, bool] = {}
+
+ # Retry configuration (auto-resolved from channel name)
+ from .retry import RetryConfig, DEFAULT_RETRY, RETRY_PRESETS
+ self._retry_config: RetryConfig = RETRY_PRESETS.get(self.name, DEFAULT_RETRY)
+
+ # Per-chat send locks to prevent message reordering
+ self._send_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
+
+ # Build inbound middleware pipeline
+ self._inbound_middlewares = self._build_inbound_middlewares()
+
+ def _build_inbound_middlewares(self) -> list:
+ """Build the inbound middleware chain from config and capabilities.
+
+ Middleware order:
+ 1. DedupMiddleware — drop duplicates early
+ 2. AllowListMiddleware — enforce sender/channel restrictions
+ 3. PairingMiddleware — handle DM pairing (if applicable)
+ 4. GroupHistoryMiddleware — buffer/inject group history
+ 5. MentionGatingMiddleware — filter by mention policy
+ """
+ from .middleware import (
+ DedupMiddleware, AllowListMiddleware,
+ PairingMiddleware, GroupHistoryMiddleware, MentionGatingMiddleware,
+ )
+ middlewares = []
+ middlewares.append(DedupMiddleware())
+ # AllowList
+ allowed_senders = getattr(self.config, "allowed_senders", None)
+ allowed_channels = getattr(self.config, "allowed_channels", None)
+ if allowed_senders and not isinstance(allowed_senders, set):
+ allowed_senders = set(allowed_senders)
+ if allowed_channels and not isinstance(allowed_channels, set):
+ allowed_channels = set(allowed_channels)
+ middlewares.append(AllowListMiddleware(
+ allowed_senders=allowed_senders,
+ allowed_channels=allowed_channels,
+ dm_policy=self.dm_policy,
+ ))
+ # Pairing
+ if self.dm_policy == "pairing":
+ async def _send_pair(chat_id, text):
+ await self._send_chunk(chat_id, text, text, None, {})
+ middlewares.append(PairingMiddleware(
+ channel_name=self.name,
+ send_response_fn=_send_pair,
+ dm_policy=self.dm_policy,
+ ))
+ # GroupHistory
+ if self.capabilities.groups:
+ middlewares.append(GroupHistoryMiddleware())
+ # MentionGating
+ if self.capabilities.mentions:
+ middlewares.append(MentionGatingMiddleware(
+ require_mention=self.require_mention,
+ strip_fn=self._strip_mention,
+ ))
+ return middlewares
+
@abstractmethod
async def start(self) -> None:
"""Initialize and start the channel.
@@ -55,56 +371,649 @@ class Channel(ABC):
"""
pass
- @abstractmethod
async def stop(self) -> None:
- """Stop the channel and clean up resources.
+ """Stop the channel. Cancels typing tasks, then calls _cleanup()."""
+ self._running = False
+ await self._typing_manager.stop_all()
+ await self._cleanup()
- This method should:
- - Close connections
- - Cancel background tasks
- - Release any held resources
+ async def _cleanup(self) -> None:
+ """Channel-specific teardown. Override in subclasses."""
+
+ async def receive(self) -> AsyncIterator[InboundMessage]:
+ """Yield incoming messages from the queue.
+
+ Default implementation polls ``self._queue``. Override only if
+ the channel needs custom exit conditions.
"""
- pass
+ while self._running:
+ try:
+ msg = await asyncio.wait_for(self._queue.get(), timeout=1.0)
+ yield msg
+ except asyncio.TimeoutError:
+ continue
+
+ async def send(self, message: OutboundMessage) -> bool:
+ """Send a message. Handles chunking, retry, and error logging.
+
+ Subclasses override ``_send_chunk()`` for the platform-specific call.
+ Override ``_format_chunk()`` to convert Markdown to channel format.
+
+ A per-chat lock ensures messages to the same chat are serialised,
+ preventing out-of-order delivery when multiple sends overlap.
+
+ If formatting expands a chunk beyond the platform limit (e.g. Markdown
+ → HTML), the chunk is automatically re-split at a smaller size. Per-
+ chunk errors are logged but do not abort delivery of remaining chunks.
+
+ When the channel satisfies ``ThreadingAdapter``, its ``reply_to_mode``
+ controls which chunks carry a ``reply_to`` reference.
+ """
+ if not self._is_ready():
+ return False
+ try:
+ chat_id = self._resolve_chat_id(message)
+ limit = self._get_chunk_limit()
+ async with self._send_locks[chat_id]:
+ pairs = self._prepare_chunks(message.content, limit)
+ had_error = False
+ for i, (formatted, raw) in enumerate(pairs):
+ reply_to = self._resolve_reply_to(message.reply_to, i)
+ try:
+ await self._send_with_retry(
+ lambda _cid=chat_id, _fmt=formatted, _raw=raw, _reply=reply_to, _meta=message.metadata: (
+ self._send_chunk(_cid, _fmt, _raw, _reply, _meta)
+ )
+ )
+ except Exception as chunk_err:
+ _logger.error(
+ f"{self.name} chunk {i} send error: {chunk_err}"
+ )
+ had_error = True
+ return not had_error
+ except Exception as e:
+ _logger.error(f"{self.name} send error: {e}")
+ return False
+
+ def _resolve_reply_to(self, reply_to: str | None, chunk_index: int) -> str | None:
+ """Determine the reply_to value for a given chunk index.
+
+ Legacy: reply_to on first chunk only.
+ """
+ if not reply_to:
+ return None
+ return reply_to if chunk_index == 0 else None
+
+ def _prepare_chunks(
+ self, content: str, limit: int,
+ ) -> list[tuple[str, str]]:
+ """Build ``(formatted, raw)`` pairs, re-splitting when formatting
+ expands a chunk beyond *limit*.
+
+ Returns a list of ``(formatted_text, raw_text)`` tuples ready
+ for ``_send_chunk()``.
+ """
+ raw_chunks = chunk_text(content, limit)
+ pairs: list[tuple[str, str]] = []
+ for raw in raw_chunks:
+ formatted = self._format_chunk(raw)
+ if len(formatted) <= limit:
+ pairs.append((formatted, raw))
+ else:
+ # Re-chunk at half the limit to leave room for format expansion
+ sub_limit = max(limit // 2, 500)
+ for sub_raw in chunk_text(raw, sub_limit):
+ sub_fmt = self._format_chunk(sub_raw)
+ if len(sub_fmt) <= limit:
+ pairs.append((sub_fmt, sub_raw))
+ else:
+ # Still too long — send raw text (guaranteed to fit)
+ pairs.append((sub_raw, sub_raw))
+ return pairs
+
+ def _is_ready(self) -> bool:
+ """Return False if the channel cannot send (e.g. client not connected).
+
+ Default checks that every attribute named in ``_ready_attrs`` is truthy.
+ Override for channels with more complex readiness logic.
+ """
+ if not self._ready_attrs:
+ return True
+ return all(getattr(self, attr, None) for attr in self._ready_attrs)
+
+ def _resolve_chat_id(self, message: OutboundMessage) -> str:
+ """Extract chat_id from metadata or recipient. Override if needed."""
+ return message.metadata.get("chat_id", message.recipient)
+
+ def _get_chunk_limit(self) -> int:
+ config_limit = getattr(self.config, "text_chunk_limit", 0)
+ cap_limit = self.capabilities.max_text_length
+ return config_limit or cap_limit or 4096
+
+ def _format_chunk(self, text: str) -> str:
+ """Convert Markdown to channel format via UnifiedFormatter.
+
+ Uses the formatter auto-configured from ``capabilities.format_type``.
+ Subclasses rarely need to override this — set ``capabilities`` instead.
+ """
+ return self._formatter.format(text)
@abstractmethod
- async def receive(self) -> AsyncIterator[IncomingMessage]:
- """Async iterator that yields incoming messages.
+ async def _send_chunk(
+ self, chat_id: str, formatted_text: str, raw_text: str,
+ reply_to: str | None, metadata: dict,
+ ) -> None:
+ """Send a single text chunk. Platform-specific implementation."""
+ ...
- Yields:
- IncomingMessage: Each new message received
+ _format_fallback_patterns: tuple[str, ...] = ("parse", "invalid")
- Example:
- async for msg in channel.receive():
- print(f"From {msg.sender}: {msg.content}")
+ async def _send_with_format_fallback(
+ self, send_fn: CallableABC[[str], Awaitable], formatted: str, raw: str,
+ ) -> None:
+ """Try *send_fn(formatted)*; on format-related errors retry with *raw*.
+
+ Channels whose ``_send_chunk`` follows the try-formatted / except-fallback
+ pattern can delegate to this helper instead of duplicating the logic.
"""
- pass
+ try:
+ await send_fn(formatted)
+ except Exception as e:
+ if formatted != raw and any(
+ p in str(e).lower() for p in self._format_fallback_patterns
+ ):
+ await send_fn(raw)
+ else:
+ raise
- @abstractmethod
- async def send(self, message: OutgoingMessage) -> bool:
- """Send a message through the channel.
+ async def send_media(
+ self,
+ recipient: str,
+ file_path: str,
+ caption: str = "",
+ metadata: dict | None = None,
+ ) -> bool:
+ """Send a media file through the channel.
+
+ Handles the ready-check guard and error logging. Subclasses
+ override ``_send_media_impl()`` with platform-specific logic.
Args:
- message: The message to send
+ recipient: Target recipient or chat identifier.
+ file_path: Local path to the media file.
+ caption: Optional caption text.
+ metadata: Optional channel-specific metadata.
Returns:
- True if sent successfully, False otherwise
+ True if sent successfully, False otherwise.
"""
+ if not self._is_ready():
+ return False
+ try:
+ return await self._send_media_impl(recipient, file_path, caption, metadata)
+ except Exception as e:
+ _logger.error(f"{self.name} send_media error: {e}")
+ return False
+
+ async def _send_media_impl(
+ self,
+ recipient: str,
+ file_path: str,
+ caption: str = "",
+ metadata: dict | None = None,
+ ) -> bool:
+ """Platform-specific media send. Override in subclasses."""
+ return False
+
+ # ── Attachment / proxy helpers ─────────────────────────────────
+
+ def _media_path(self, filename: str) -> Path:
+ """Ensure MEDIA_DIR exists and return a path inside it."""
+ return media_path(filename)
+
+ def _resolve_media_chat_id(self, recipient: str, metadata: dict | None) -> str:
+ """Extract chat_id from metadata, falling back to recipient."""
+ return (metadata or {}).get("chat_id", recipient)
+
+ def _get_proxy(self) -> str | None:
+ """Return the configured proxy URL, or ``None`` if unset/empty."""
+ return getattr(self.config, "proxy", None) or None
+
+ def _check_attachment_size(self, file_size: int, filename: str) -> str | None:
+ """Return a 'too large' annotation string if *file_size* exceeds the limit."""
+ return check_attachment_size(file_size, filename)
+
+ async def _download_attachment(
+ self,
+ url: str,
+ filename: str,
+ *,
+ headers: dict[str, str] | None = None,
+ file_size: int | None = None,
+ ) -> tuple[str | None, str | None]:
+ """Download an attachment via httpx. Returns ``(local_path, annotation)``.
+
+ Delegates to :func:`download_attachment`.
+ """
+ return await download_attachment(
+ url, filename,
+ channel_name=self.name,
+ headers=headers,
+ file_size=file_size,
+ proxy=self._get_proxy(),
+ )
+
+ # ── Send retry abstraction ──────────────────────────────────────
+
+ _non_retryable_patterns: tuple[str, ...] = ()
+ _rate_limit_patterns: tuple[str, ...] = ("429", "ratelimit")
+ _rate_limit_delay: float = 1.0
+
+ def _extract_retry_after(self, exc: Exception) -> float | None:
+ """Extract retry-wait seconds from an exception.
+
+ Returns ``None`` to signal that the error is **not retryable**.
+
+ Pipeline:
+ 1. SDK-provided ``retry_after`` attribute (Telegram / Slack SDKs).
+ 2. HTTP ``Retry-After`` header via :meth:`_parse_retry_after_header`.
+ 3. Non-retryable pattern match → ``None``.
+ 4. Rate-limit pattern match → ``_rate_limit_delay``.
+ 5. Default ``1.0`` s (generic transient-error retry).
+
+ Channels can customise behaviour declaratively via class attributes
+ ``_non_retryable_patterns``, ``_rate_limit_patterns``, and
+ ``_rate_limit_delay``, or override this method entirely.
+ """
+ # 1. SDK retry_after attribute
+ retry = getattr(exc, "retry_after", None)
+ if retry is not None:
+ return float(retry)
+
+ # 2. HTTP Retry-After header
+ header_val = self._parse_retry_after_header(exc)
+ if header_val is not None:
+ return header_val
+
+ msg = str(exc).lower()
+
+ # 3. Non-retryable patterns
+ if self._non_retryable_patterns and any(
+ p in msg for p in self._non_retryable_patterns
+ ):
+ return None
+
+ # 4. Rate-limit patterns
+ if self._rate_limit_patterns and any(
+ p in msg for p in self._rate_limit_patterns
+ ):
+ return self._rate_limit_delay
+
+ # 5. Default
+ return 1.0
+
+ def _parse_retry_after_header(self, exc: Exception) -> float | None:
+ """Try to extract a ``Retry-After`` value from an HTTP response."""
+ resp = getattr(exc, "response", None)
+ if resp is None:
+ return None
+ headers = getattr(resp, "headers", None)
+ if not headers:
+ return None
+ raw = headers.get("Retry-After") or headers.get("retry-after")
+ if raw is None:
+ return None
+ try:
+ return float(raw)
+ except (ValueError, TypeError):
+ return None
+
+ async def _send_with_retry(
+ self,
+ coro_factory: CallableABC[[], Awaitable],
+ max_retries: int = 3,
+ ) -> Any:
+ """Send helper with automatic exponential-backoff retry.
+
+ *coro_factory* is called on every attempt so that the awaitable is
+ fresh. Uses :func:`retry.retry_async` for backoff, jitter, and
+ server-supplied ``Retry-After`` support.
+
+ The *max_retries* parameter is accepted for backward compatibility
+ but the attempt count is taken from ``self._retry_config``.
+ """
+ from .retry import retry_async
+
+ return await retry_async(
+ coro_factory,
+ config=self._retry_config,
+ should_retry=lambda exc, _: self._extract_retry_after(exc) is not None,
+ retry_after_s=self._extract_retry_after,
+ on_retry=lambda info: _logger.warning(
+ f"{self.name} send retry {info.attempt}/{info.max_attempts} "
+ f"in {info.delay_s:.2f}s: {info.error}"
+ ),
+ label=f"{self.name}.send",
+ )
+
+ # ── Typing indicator abstraction ─────────────────────────────────
+
+ async def _send_typing_action(self, chat_id: str) -> None:
+ """Send a single typing indicator. Override in sub-classes."""
+
+ async def start_typing(self, chat_id: str) -> None:
+ """Start a background typing-indicator loop for *chat_id*."""
+ await self._typing_manager.start(chat_id)
+
+ async def stop_typing(self, chat_id: str) -> None:
+ """Cancel the typing-indicator loop for *chat_id*."""
+ await self._typing_manager.stop(chat_id)
+
+ # ── Mention gating ──────────────────────────────────────────────
+
+ def _should_process(self, raw: RawIncoming) -> bool:
+ """Decide whether to process a message based on mention gating."""
+ if self.require_mention == "off":
+ return True
+ # Both "always" and "group" allow DMs through unconditionally
+ if not raw.is_group:
+ return True
+ if self.require_mention == "always":
+ return raw.was_mentioned
+ # "group" — require mention only in groups
+ return raw.was_mentioned
+
+ _mention_pattern: str | None = None
+ _mention_strip_count: int = 0 # 0 = all occurrences, 1 = first only
+
+ def _get_bot_identifier(self) -> str | None:
+ """Return the bot's identifier for mention pattern substitution.
+
+ Override in subclasses where ``_mention_pattern`` contains
+ ``{bot_id}`` placeholder.
+ """
+ return None
+
+ def _strip_mention(self, text: str) -> str:
+ """Strip bot mention from text using the ``_mention_pattern`` approach."""
+ if not self._mention_pattern:
+ return text
+ pattern = self._mention_pattern
+ if "{bot_id}" in pattern:
+ bot_id = self._get_bot_identifier()
+ if not bot_id:
+ return text
+ pattern = pattern.replace("{bot_id}", re.escape(bot_id))
+ return re.sub(pattern, "", text, count=self._mention_strip_count).strip()
+
+ # ── ACK reaction ─────────────────────────────────────────────────
+
+ async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None:
+ """Send an acknowledgment reaction to a message. Override in subclasses that support reactions."""
+ pass # Default no-op; channels override if they support reactions
+
+ async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None:
+ """Remove the ack reaction after replying. Override in subclasses."""
pass
+ # ── Inbound message pipeline ──────────────────────────────────────
+
+ async def _build_inbound_async(self, raw: RawIncoming) -> InboundMessage | None:
+ """Async version: run *raw* through inbound middlewares and convert."""
+ context: dict = {"channel": self}
+ current: RawIncoming | None = raw
+ for mw in self._inbound_middlewares:
+ if current is None:
+ return None
+ current = await mw.process_inbound(current, context)
+ if current is None:
+ return None
+ return self._raw_to_inbound(current)
+
+ def _build_inbound(self, raw: RawIncoming) -> InboundMessage | None:
+ """Run *raw* through inbound middlewares and convert to InboundMessage.
+
+ Synchronous wrapper around :meth:`_build_inbound_async`. Safe to
+ call from both sync and async contexts.
+ """
+ import asyncio
+ import concurrent.futures
+
+ try:
+ asyncio.get_running_loop()
+ # Inside a running loop — run in a worker thread
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
+ return pool.submit(
+ lambda: asyncio.run(self._build_inbound_async(raw))
+ ).result()
+ except RuntimeError:
+ # No running loop — safe to create one
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(self._build_inbound_async(raw))
+ finally:
+ loop.close()
+
+ def _raw_to_inbound(self, raw: RawIncoming) -> InboundMessage | None:
+ """Convert a RawIncoming to InboundMessage (pure transformation, no filtering).
+
+ Merges text + annotations into content, sets metadata.
+ Returns None only if there is no content and no media.
+ """
+ parts = []
+ if raw.text:
+ parts.append(raw.text)
+ parts.extend(raw.content_annotations)
+ content = "\n".join(p for p in parts if p)
+ if not content and not raw.media_files:
+ return None
+ meta = dict(raw.metadata)
+ meta.setdefault("chat_id", raw.chat_id)
+ return InboundMessage(
+ channel=self.name, sender_id=raw.sender_id, chat_id=raw.chat_id,
+ content=content or "[media only]", timestamp=raw.timestamp,
+ message_id=raw.message_id, media=raw.media_files, metadata=meta,
+ is_group=raw.is_group, was_mentioned=raw.was_mentioned,
+ )
+
+ async def _enqueue_raw(self, raw: RawIncoming) -> None:
+ """Run *raw* through the inbound middleware pipeline, convert to
+ InboundMessage, and put it on the queue.
+
+ Convenience method for subclass ``_on_message`` handlers.
+ """
+ msg = self._build_inbound(raw)
+ if msg is None:
+ return
+ if raw.message_id:
+ try:
+ await self._send_ack_reaction(raw.chat_id, raw.message_id)
+ except Exception:
+ pass
+ await self._queue.put(msg)
+
+ # ── Bus integration ──────────────────────────────────────────────
+
+ def set_bus(self, bus) -> None:
+ """Inject the MessageBus reference (called by ChannelManager)."""
+ self._bus = bus
+
+ async def queue_message(self, msg: InboundMessage) -> None:
+ """Buffer *msg* with debounce, then publish to bus."""
+ sender = msg.sender_id
+
+ if sender not in self._message_buffers:
+ self._message_buffers[sender] = []
+ self._message_metadata[sender] = msg.metadata
+ self._message_media[sender] = []
+ self._message_is_group[sender] = msg.is_group
+ self._message_was_mentioned[sender] = msg.was_mentioned
+ self._message_buffers[sender].append(msg.content)
+ if msg.message_id:
+ self._message_ids[sender] = msg.message_id
+ if msg.media:
+ self._message_media[sender].extend(msg.media)
+
+ if self._on_activity:
+ try:
+ self._on_activity(sender, "received")
+ except Exception:
+ pass
+
+ if sender in self._debounce_tasks:
+ self._debounce_tasks[sender].cancel()
+
+ msg_count = len(self._message_buffers[sender])
+ wait = min(
+ self.initial_debounce + (msg_count - 1) * self.debounce_step,
+ self.max_debounce,
+ )
+ _logger.debug(
+ f"Debounce for {sender}: {wait:.1f}s (message #{msg_count})"
+ )
+
+ async def debounce_callback(_s=sender, _w=wait):
+ await asyncio.sleep(_w)
+ await self._process_buffered_messages(_s)
+
+ self._debounce_tasks[sender] = asyncio.create_task(
+ debounce_callback()
+ )
+
+ async def _process_buffered_messages(self, sender: str) -> None:
+ """Flush buffered messages for *sender* and publish to bus."""
+ if sender not in self._message_buffers:
+ return
+
+ messages = self._message_buffers.pop(sender, [])
+ metadata = self._message_metadata.pop(sender, None)
+ media = self._message_media.pop(sender, [])
+ message_id = self._message_ids.pop(sender, "")
+ is_group = self._message_is_group.pop(sender, False)
+ was_mentioned = self._message_was_mentioned.pop(sender, True)
+ self._debounce_tasks.pop(sender, None)
+ if not messages:
+ return
+
+ merged_content = "\n".join(messages)
+ _logger.info(
+ f"Processing {len(messages)} merged message(s) from {sender}"
+ )
+
+ if self._bus:
+ chat_id = (metadata or {}).get("chat_id", sender)
+ inbound = InboundMessage(
+ channel=self.name,
+ sender_id=sender,
+ chat_id=str(chat_id),
+ content=merged_content,
+ media=media,
+ metadata=metadata or {},
+ message_id=message_id,
+ is_group=is_group,
+ was_mentioned=was_mentioned,
+ )
+ await self._bus.publish_inbound(inbound)
+
+ async def _send_status_message(
+ self, sender: str, content: str, metadata: dict | None = None,
+ ) -> None:
+ """Send a status/intermediate message to the channel."""
+ chat_id = (metadata or {}).get("chat_id", sender)
+ await self.send(OutboundMessage(
+ channel=self.name,
+ chat_id=str(chat_id),
+ content=content,
+ metadata=metadata or {},
+ ))
+
+ async def send_thinking_message(
+ self, sender: str, thinking: str, metadata: dict | None = None,
+ ) -> None:
+ """Send a thinking intermediate message to the channel."""
+ if not self.send_thinking:
+ return
+ await self._send_status_message(sender, f"\U0001f9e0\n{thinking}\n\u23f3", metadata)
+
+ async def send_todo_message(
+ self, sender: str, content: str, metadata: dict | None = None,
+ ) -> None:
+ """Send a todo list intermediate message to the channel."""
+ await self._send_status_message(sender, content, metadata)
+
+ async def run(self) -> None:
+ """Run the channel with auto-reconnect (exponential backoff)."""
+ backoff = 1.0
+ max_backoff = 60.0
+ self._running = True
+ while self._running:
+ try:
+ await self.start()
+ backoff = 1.0
+ async for msg in self.receive():
+ _logger.info(f"From {msg.sender_id}: {msg.content[:50]}...")
+ await self.queue_message(msg)
+ except asyncio.CancelledError:
+ break
+ except ChannelError as e:
+ _logger.error(f"Channel {self.name} fatal error: {e}")
+ self._running = False
+ break
+ except Exception as e:
+ _logger.error(f"Channel {self.name} error: {e}")
+ finally:
+ for task in self._debounce_tasks.values():
+ task.cancel()
+ self._debounce_tasks.clear()
+ # Preserve reconnect intent across stop()
+ should_reconnect = self._running
+ try:
+ await self.stop()
+ except Exception:
+ pass
+ self._running = should_reconnect
+
+ if self._running:
+ _logger.info(
+ f"Reconnecting {self.name} in {backoff:.1f}s..."
+ )
+ await asyncio.sleep(backoff)
+ backoff = min(backoff * 2, max_backoff)
+
+ # ── Channel allow-list check ─────────────────────────────────────
+
+ def is_channel_allowed(self, channel_id: str) -> bool:
+ """Return ``True`` if *channel_id* is permitted by config.
+
+ When the allow-list is empty or absent every channel is allowed.
+ """
+ allowed = getattr(self.config, "allowed_channels", None)
+ return not allowed or str(channel_id) in allowed
+
+ # ── Sender allow-list check ──────────────────────────────────────
+
+ def is_allowed(self, sender: str) -> bool:
+ """Check if *sender* is permitted by ``self.config.allowed_senders``.
+
+ Returns ``True`` when the allow-list is empty / None (open access).
+ Supports ``|``-separated composite IDs (e.g. ``"uid|gid"``).
+ Subclasses with richer filtering (iMessage) may override.
+ """
+ config = getattr(self, "config", None)
+ allowed = getattr(config, "allowed_senders", None) if config else None
+ if not allowed:
+ return True
+ sender_str = str(sender)
+ if sender_str in allowed:
+ return True
+ if "|" in sender_str:
+ for part in sender_str.split("|"):
+ if part and part in allowed:
+ return True
+ return False
+
class ChannelError(Exception):
"""Base exception for channel-related errors."""
pass
-
-
-class ChannelPermissionError(ChannelError):
- """Raised when the channel lacks required permissions."""
-
- pass
-
-
-class ChannelConnectionError(ChannelError):
- """Raised when the channel cannot establish a connection."""
-
- pass
diff --git a/EvoScientist/channels/bus/__init__.py b/EvoScientist/channels/bus/__init__.py
new file mode 100644
index 0000000..3510967
--- /dev/null
+++ b/EvoScientist/channels/bus/__init__.py
@@ -0,0 +1,6 @@
+"""Message bus for decoupled channel-agent communication."""
+
+from .events import InboundMessage, OutboundMessage
+from .message_bus import MessageBus
+
+__all__ = ["MessageBus", "InboundMessage", "OutboundMessage"]
diff --git a/EvoScientist/channels/bus/events.py b/EvoScientist/channels/bus/events.py
new file mode 100644
index 0000000..411006f
--- /dev/null
+++ b/EvoScientist/channels/bus/events.py
@@ -0,0 +1,52 @@
+"""Event types for the message bus."""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any
+
+
+@dataclass
+class InboundMessage:
+ """Message received from a chat channel.
+
+ Carries enough context for the bus to route and for the agent
+ to build a session: which channel, who sent it, which chat.
+ """
+
+ channel: str
+ sender_id: str
+ chat_id: str
+ content: str
+ timestamp: datetime = field(default_factory=datetime.now)
+ message_id: str = ""
+ media: list[str] = field(default_factory=list)
+ metadata: dict[str, Any] = field(default_factory=dict)
+ is_group: bool = False
+ was_mentioned: bool = True
+
+ @property
+ def sender(self) -> str:
+ """Alias for ``sender_id`` (compatibility with IncomingMessage)."""
+ return self.sender_id
+
+ @property
+ def session_key(self) -> str:
+ """Unique key for session identification: ``channel:chat_id``."""
+ return f"{self.channel}:{self.chat_id}"
+
+
+@dataclass
+class OutboundMessage:
+ """Message to send to a chat channel."""
+
+ channel: str
+ chat_id: str
+ content: str
+ reply_to: str | None = None
+ media: list[str] = field(default_factory=list)
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def recipient(self) -> str:
+ """Alias for ``chat_id`` (compatibility with OutgoingMessage)."""
+ return self.chat_id
diff --git a/EvoScientist/channels/bus/message_bus.py b/EvoScientist/channels/bus/message_bus.py
new file mode 100644
index 0000000..3e68697
--- /dev/null
+++ b/EvoScientist/channels/bus/message_bus.py
@@ -0,0 +1,96 @@
+"""Async message bus that decouples chat channels from the agent core.
+
+Channels push messages to the inbound queue; the agent (or any consumer)
+reads from inbound, processes, and pushes responses to the outbound queue.
+A background dispatcher routes outbound messages to the correct channel
+via subscriber callbacks.
+
+Deduplication is handled at the Channel level (single dedup point).
+"""
+
+import asyncio
+import logging
+from typing import Callable, Awaitable
+
+from .events import InboundMessage, OutboundMessage
+
+logger = logging.getLogger(__name__)
+
+OutboundCallback = Callable[[OutboundMessage], Awaitable[None]]
+
+
+class MessageBus:
+ """Async message bus that decouples chat channels from the agent core."""
+
+ def __init__(self):
+ self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=5000)
+ self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue(maxsize=5000)
+ self._outbound_subscribers: dict[str, list[OutboundCallback]] = {}
+ self._running = False
+
+ # ── inbound (channel → agent) ──
+
+ async def publish_inbound(self, msg: InboundMessage) -> None:
+ """Publish a message from a channel to the agent."""
+ await self.inbound.put(msg)
+
+ async def consume_inbound(self) -> InboundMessage:
+ """Consume the next inbound message (blocks until available)."""
+ return await self.inbound.get()
+
+ # ── outbound (agent → channel) ──
+
+ async def publish_outbound(self, msg: OutboundMessage) -> None:
+ """Publish a response from the agent to channels."""
+ await self.outbound.put(msg)
+
+ async def consume_outbound(self) -> OutboundMessage:
+ """Consume the next outbound message (blocks until available)."""
+ return await self.outbound.get()
+
+ # ── subscriber routing ──
+
+ def subscribe_outbound(
+ self, channel: str, callback: OutboundCallback,
+ ) -> None:
+ """Register a callback for outbound messages targeting *channel*."""
+ if channel not in self._outbound_subscribers:
+ self._outbound_subscribers[channel] = []
+ self._outbound_subscribers[channel].append(callback)
+
+ async def dispatch_outbound(self) -> None:
+ """Route outbound messages to subscribed channels.
+
+ Run as a background task — loops until :meth:`stop` is called.
+ """
+ self._running = True
+ while self._running:
+ try:
+ msg = await asyncio.wait_for(
+ self.outbound.get(), timeout=1.0,
+ )
+ except asyncio.TimeoutError:
+ continue
+ subscribers = self._outbound_subscribers.get(msg.channel, [])
+ if not subscribers:
+ logger.warning(f"No subscriber for channel: {msg.channel}")
+ continue
+ for callback in subscribers:
+ try:
+ await callback(msg)
+ except Exception as e:
+ logger.error(
+ f"Error dispatching to {msg.channel}: {e}"
+ )
+
+ def stop(self) -> None:
+ """Stop the dispatcher loop."""
+ self._running = False
+
+ @property
+ def inbound_size(self) -> int:
+ return self.inbound.qsize()
+
+ @property
+ def outbound_size(self) -> int:
+ return self.outbound.qsize()
diff --git a/EvoScientist/channels/capabilities.py b/EvoScientist/channels/capabilities.py
new file mode 100644
index 0000000..5060dd9
--- /dev/null
+++ b/EvoScientist/channels/capabilities.py
@@ -0,0 +1,220 @@
+"""Channel capabilities declaration system.
+
+Each channel declares its capabilities via a ChannelCapabilities dataclass,
+enabling the framework to adapt behavior automatically (formatting, reactions,
+streaming, threading, etc.) without per-channel branching in core logic.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+FormatType = Literal["html", "markdown", "slack_mrkdwn", "discord", "plain"]
+
+
+@dataclass(frozen=True)
+class ChannelCapabilities:
+ """Immutable declaration of what a channel supports.
+
+ Set once as a class attribute on each Channel subclass.
+ The framework inspects these at runtime to auto-configure behavior.
+ """
+
+ # ── Messaging features ──────────────────────────────────────────
+ format_type: FormatType = "plain"
+ max_text_length: int = 4096
+ max_file_size: int = 20 * 1024 * 1024 # 20 MB
+
+ # ── Interaction capabilities ────────────────────────────────────
+ streaming: bool = False # edit-in-place streaming output
+ threading: bool = False # message threads / topics
+ reactions: bool = False # emoji reactions on messages
+ typing: bool = False # typing indicator API
+ inline_buttons: bool = False # inline keyboard / action buttons
+
+ # ── Media capabilities ──────────────────────────────────────────
+ media_send: bool = False # can send files/images
+ media_receive: bool = False # can receive files/images
+ voice: bool = False # platform has voice/audio messages that arrive as downloadable files (receive only, not bot sending)
+ stickers: bool = False # supports sticker receive (not bot sending)
+ location: bool = False # supports location message receive (not bot sending)
+ video: bool = False # video messages
+
+ # ── Group features ──────────────────────────────────────────────
+ groups: bool = False # group chat support
+ mentions: bool = False # @mention detection
+
+ # ── Rich text ───────────────────────────────────────────────────
+ markdown: bool = False # supports Markdown rendering
+ html: bool = False # supports HTML rendering
+
+ # ── Extended capabilities ────────────────────────────────────────
+ chat_types: tuple[str, ...] = () # ("direct", "group", "channel", "thread")
+ edit: bool = False # message editing after send
+ unsend: bool = False # message recall / unsend
+ block_streaming: bool = False # block edit-in-place streaming
+ native_commands: bool = False # platform-native slash commands
+ polls: bool = False # poll / vote messages
+
+ def supports(self, feature: str) -> bool:
+ """Check if a feature is supported by name."""
+ return getattr(self, feature, False)
+
+
+# ═════════════════════════════════════════════════════════════════════
+# Pre-built capability profiles for each channel
+# ═════════════════════════════════════════════════════════════════════
+
+TELEGRAM = ChannelCapabilities(
+ format_type="html",
+ max_text_length=4000,
+ streaming=False, # could edit messages, but not implemented yet
+ threading=False, # topics exist but not used yet
+ reactions=True,
+ typing=True,
+ media_send=True,
+ media_receive=True,
+ voice=True,
+ stickers=True,
+ location=True,
+ groups=True,
+ mentions=True,
+ html=True,
+ chat_types=("direct", "group", "channel"),
+ edit=True,
+ unsend=True,
+ native_commands=True,
+ polls=True,
+)
+
+DISCORD = ChannelCapabilities(
+ format_type="discord",
+ max_text_length=2000,
+ streaming=False,
+ threading=True,
+ reactions=True,
+ typing=True,
+ media_send=True,
+ media_receive=True,
+ voice=False, # no distinct voice message type in Discord bot API
+ groups=True,
+ mentions=True,
+ markdown=True,
+ chat_types=("direct", "group", "thread"),
+ edit=True,
+ unsend=True,
+ native_commands=True,
+ polls=True,
+)
+
+SLACK = ChannelCapabilities(
+ format_type="slack_mrkdwn",
+ max_text_length=4000,
+ streaming=False,
+ threading=True,
+ reactions=True,
+ typing=True,
+ media_send=True,
+ media_receive=True,
+ voice=False, # no distinct voice message type in Slack bot API
+ groups=True,
+ mentions=True,
+ chat_types=("direct", "group", "thread"),
+ edit=True,
+ unsend=True,
+ native_commands=True,
+)
+
+FEISHU = ChannelCapabilities(
+ format_type="markdown",
+ max_text_length=4096,
+ reactions=True,
+ typing=False, # no typing API
+ media_send=True,
+ media_receive=True,
+ voice=True,
+ stickers=True,
+ groups=True,
+ mentions=True,
+ markdown=True,
+ chat_types=("direct", "group"),
+ edit=True,
+ unsend=True,
+)
+
+DINGTALK = ChannelCapabilities(
+ format_type="markdown",
+ max_text_length=4096,
+ typing=False, # no typing API for bots
+ media_send=True,
+ media_receive=True,
+ voice=True,
+ groups=True,
+ mentions=True,
+ markdown=True,
+ chat_types=("direct", "group"),
+)
+
+QQ = ChannelCapabilities(
+ format_type="plain",
+ max_text_length=4096,
+ typing=False, # no typing API for QQ bots
+ media_send=True,
+ media_receive=True,
+ voice=False, # qq-botpy does not expose voice as a distinct message type
+ groups=True,
+ mentions=True,
+ chat_types=("direct", "group", "channel"),
+ unsend=True,
+)
+
+WECHAT = ChannelCapabilities(
+ format_type="markdown", # WeCom supports markdown
+ max_text_length=4096,
+ typing=False, # no typing API
+ media_send=True,
+ media_receive=True,
+ voice=True,
+ location=True,
+ groups=True,
+ mentions=True,
+ markdown=True,
+ chat_types=("direct", "group"),
+ unsend=True,
+)
+
+SIGNAL = ChannelCapabilities(
+ format_type="plain",
+ max_text_length=4096,
+ reactions=True,
+ typing=True,
+ media_send=True,
+ media_receive=True,
+ voice=True,
+ groups=True,
+ mentions=True,
+ chat_types=("direct", "group"),
+)
+
+EMAIL = ChannelCapabilities(
+ format_type="html",
+ max_text_length=999_999, # no practical limit
+ media_send=True,
+ media_receive=True,
+ html=True,
+ chat_types=("direct",),
+)
+
+IMESSAGE = ChannelCapabilities(
+ format_type="plain",
+ max_text_length=999_999,
+ typing=False, # Apple does not expose typing indicator API
+ media_send=True,
+ media_receive=True,
+ voice=True,
+ groups=True,
+ mentions=False, # iMessage has no @mention concept
+ reactions=False, # imsg CLI cannot send tapback reactions
+ chat_types=("direct", "group"),
+)
diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py
new file mode 100644
index 0000000..989cc64
--- /dev/null
+++ b/EvoScientist/channels/channel_manager.py
@@ -0,0 +1,995 @@
+"""Unified channel manager for coordinating chat channels.
+
+Manages channel lifecycle (start/stop), wires each channel to the
+message bus, and routes outbound messages to the correct channel.
+
+Also provides the global channel registry (formerly in ``registry.py``),
+account management (formerly ``account.py``), and pipeline assembly
+(formerly ``pipeline.py``).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import importlib
+import json
+import logging
+import pkgutil
+import time
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Callable
+
+from .base import Channel, OutboundMessage
+from .bus import MessageBus
+from .middleware import OutboundMiddlewareBase
+from .plugin import ChannelPlugin
+
+logger = logging.getLogger(__name__)
+
+
+# ═════════════════════════════════════════════════════════════════════
+# Account management (formerly account.py)
+# ═════════════════════════════════════════════════════════════════════
+
+@dataclass
+class ChannelAccountSnapshot:
+ """Point-in-time snapshot of a single account's connection state."""
+
+ account_id: str
+ channel: str
+ connected: bool = False
+ started_at: float = 0.0
+ last_outbound_at: float = 0.0
+ error: str | None = None
+
+ def mark_connected(self) -> None:
+ self.connected = True
+ self.started_at = time.monotonic()
+ self.error = None
+
+ def mark_disconnected(self, error: str | None = None) -> None:
+ self.connected = False
+ self.error = error
+
+ def mark_outbound(self) -> None:
+ self.last_outbound_at = time.monotonic()
+
+
+@dataclass
+class AccountConfig:
+ """Per-account configuration wrapper."""
+
+ account_id: str
+ channel_id: str # which plugin
+ enabled: bool = True
+ config: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class AccountState:
+ """Runtime state for a single account."""
+
+ account_id: str
+ channel_id: str
+ status: str = "stopped" # stopped | starting | running | error
+ snapshot: ChannelAccountSnapshot | None = None
+ error: str | None = None
+ started_at: float = 0.0
+
+
+class AccountManager:
+ """Manages multiple accounts across channel plugins.
+
+ Works with the ``ConfigAdapter`` protocol on each plugin to discover
+ accounts and manage their lifecycle independently.
+ """
+
+ def __init__(self) -> None:
+ self._plugins: dict[str, ChannelPlugin] = {}
+ self._states: dict[str, AccountState] = {} # key: "{channel_id}:{account_id}"
+
+ @staticmethod
+ def _key(channel_id: str, account_id: str) -> str:
+ return f"{channel_id}:{account_id}"
+
+ def register_plugin(self, plugin: ChannelPlugin) -> None:
+ """Register a plugin that supports multi-account."""
+ self._plugins[plugin.id] = plugin
+ logger.info(f"AccountManager: registered plugin '{plugin.id}'")
+
+ async def start_account(
+ self,
+ channel_id: str,
+ account_id: str,
+ config: Any = None,
+ ) -> None:
+ """Start a specific account on a plugin."""
+ plugin = self._plugins.get(channel_id)
+ if plugin is None:
+ raise ValueError(f"No plugin registered for channel '{channel_id}'")
+
+ key = self._key(channel_id, account_id)
+ state = self._states.get(key)
+ if state is None:
+ state = AccountState(account_id=account_id, channel_id=channel_id)
+ self._states[key] = state
+
+ if state.status == "running":
+ logger.warning(f"Account {key} is already running")
+ return
+
+ state.status = "starting"
+ state.error = None
+ try:
+ account_config = config
+ if plugin.config_adapter is not None and config is not None:
+ account_config = plugin.config_adapter.resolve_account(config, account_id)
+
+ await plugin.start(account_config, account_id=account_id)
+ state.status = "running"
+ state.started_at = time.monotonic()
+ state.snapshot = ChannelAccountSnapshot(
+ account_id=account_id, channel=channel_id,
+ )
+ state.snapshot.mark_connected()
+ logger.info(f"Account {key} started")
+ except Exception as e:
+ state.status = "error"
+ state.error = str(e)
+ logger.error(f"Failed to start account {key}: {e}")
+ raise
+
+ async def stop_account(self, channel_id: str, account_id: str) -> None:
+ """Stop a specific account on a plugin."""
+ plugin = self._plugins.get(channel_id)
+ if plugin is None:
+ raise ValueError(f"No plugin registered for channel '{channel_id}'")
+
+ key = self._key(channel_id, account_id)
+ state = self._states.get(key)
+ if state is None or state.status == "stopped":
+ logger.debug(f"Account {key} is already stopped")
+ return
+
+ try:
+ await plugin.stop(account_id=account_id)
+ state.status = "stopped"
+ if state.snapshot is not None:
+ state.snapshot.mark_disconnected()
+ logger.info(f"Account {key} stopped")
+ except Exception as e:
+ state.status = "error"
+ state.error = str(e)
+ if state.snapshot is not None:
+ state.snapshot.mark_disconnected(error=str(e))
+ logger.error(f"Error stopping account {key}: {e}")
+ raise
+
+ async def restart_account(
+ self,
+ channel_id: str,
+ account_id: str,
+ config: Any = None,
+ ) -> None:
+ """Restart a specific account."""
+ await self.stop_account(channel_id, account_id)
+ await self.start_account(channel_id, account_id, config)
+
+ async def start_all(self, channel_id: str, config: Any = None) -> None:
+ """Start all accounts for a given channel plugin."""
+ plugin = self._plugins.get(channel_id)
+ if plugin is None:
+ raise ValueError(f"No plugin registered for channel '{channel_id}'")
+
+ adapter = plugin.config_adapter
+ if adapter is None:
+ await self.start_account(channel_id, "default", config)
+ return
+
+ if config is None:
+ logger.warning(f"No config provided for start_all on '{channel_id}'")
+ return
+
+ for account_id in adapter.list_account_ids(config):
+ if adapter.is_enabled(
+ adapter.resolve_account(config, account_id), config,
+ ):
+ try:
+ await self.start_account(channel_id, account_id, config)
+ except Exception as e:
+ logger.error(
+ f"Failed to start account {channel_id}:{account_id}: {e}"
+ )
+
+ async def stop_all(self, channel_id: str) -> None:
+ """Stop all accounts for a given channel plugin."""
+ keys_to_stop = [
+ (state.channel_id, state.account_id)
+ for state in self._states.values()
+ if state.channel_id == channel_id and state.status != "stopped"
+ ]
+ for cid, aid in keys_to_stop:
+ try:
+ await self.stop_account(cid, aid)
+ except Exception as e:
+ logger.error(f"Failed to stop account {cid}:{aid}: {e}")
+
+ def get_state(
+ self, channel_id: str, account_id: str,
+ ) -> AccountState | None:
+ """Get the runtime state for a specific account."""
+ return self._states.get(self._key(channel_id, account_id))
+
+ def list_accounts(
+ self, channel_id: str | None = None,
+ ) -> list[AccountState]:
+ """List account states, optionally filtered by channel."""
+ if channel_id is None:
+ return list(self._states.values())
+ return [
+ s for s in self._states.values() if s.channel_id == channel_id
+ ]
+
+ def get_snapshot(
+ self, channel_id: str, account_id: str,
+ ) -> ChannelAccountSnapshot | None:
+ """Get the connection snapshot for a specific account."""
+ state = self._states.get(self._key(channel_id, account_id))
+ return state.snapshot if state else None
+
+
+# ═════════════════════════════════════════════════════════════════════
+# Inbound / outbound pipelines (formerly pipeline.py)
+# ═════════════════════════════════════════════════════════════════════
+
+
+class OutboundPipeline:
+ """Processes outgoing messages through a middleware chain."""
+
+ def __init__(
+ self,
+ plugin: ChannelPlugin,
+ middlewares: list[OutboundMiddlewareBase],
+ ) -> None:
+ self.plugin = plugin
+ self.middlewares = middlewares
+
+ async def process(
+ self,
+ message: OutboundMessage,
+ context: dict[str, Any] | None = None,
+ ) -> OutboundMessage | None:
+ """Run *message* through each middleware. Returns ``None`` if dropped."""
+ ctx = context or {}
+ current: OutboundMessage | None = message
+ for mw in self.middlewares:
+ if current is None:
+ return None
+ current = await mw.process_outbound(current, ctx)
+ return current
+
+
+def build_outbound_pipeline(
+ plugin: ChannelPlugin,
+ config: Any,
+) -> OutboundPipeline:
+ """Auto-assemble outbound pipeline based on plugin capabilities.
+
+ FormattingMiddleware has been removed — Channel.send() handles
+ formatting + chunking via _format_chunk() / _prepare_chunks().
+ """
+ middlewares: list[OutboundMiddlewareBase] = []
+ return OutboundPipeline(plugin, middlewares)
+
+
+# ── Per-channel health tracking ──────────────────────────────────────
+
+@dataclass
+class ChannelHealth:
+ """Tracks send success / failure metrics for a single channel."""
+
+ consecutive_failures: int = 0
+ last_failure_time: float | None = None
+ last_failure_error: str | None = None
+ total_failures: int = 0
+ total_successes: int = 0
+
+
+# ── Minimal HTTP health-check server ────────────────────────────────
+
+class _HealthServer:
+ """Zero-dependency HTTP health-check endpoint using ``asyncio.start_server``.
+
+ Responds to ``GET /healthz`` with a JSON status payload; all other
+ requests receive a 404. A per-connection timeout prevents slow
+ clients from tying up the server.
+ """
+
+ _CONNECTION_TIMEOUT = 5.0 # seconds
+
+ def __init__(self, manager: ChannelManager, port: int) -> None:
+ self._manager = manager
+ self._port = port
+ self._server: asyncio.AbstractServer | None = None
+ self._start_time: float = 0.0
+
+ async def start(self) -> None:
+ self._start_time = time.monotonic()
+ self._server = await asyncio.start_server(
+ self._handle_connection, "0.0.0.0", self._port,
+ )
+ addrs = [s.getsockname() for s in self._server.sockets]
+ logger.info(f"Health server listening on {addrs}")
+
+ async def stop(self) -> None:
+ if self._server is not None:
+ self._server.close()
+ await self._server.wait_closed()
+ self._server = None
+ logger.info("Health server stopped")
+
+ async def _handle_connection(
+ self,
+ reader: asyncio.StreamReader,
+ writer: asyncio.StreamWriter,
+ ) -> None:
+ try:
+ await asyncio.wait_for(
+ self._process_request(reader, writer),
+ timeout=self._CONNECTION_TIMEOUT,
+ )
+ except (asyncio.TimeoutError, ConnectionError, OSError):
+ pass
+ finally:
+ try:
+ writer.close()
+ await writer.wait_closed()
+ except (ConnectionError, OSError):
+ pass
+
+ async def _process_request(
+ self,
+ reader: asyncio.StreamReader,
+ writer: asyncio.StreamWriter,
+ ) -> None:
+ request_line = await reader.readline()
+ # Consume remaining headers
+ while True:
+ line = await reader.readline()
+ if line in (b"\r\n", b"\n", b""):
+ break
+
+ parts = request_line.decode("utf-8", errors="replace").split()
+ if len(parts) >= 2 and parts[0] == "GET" and parts[1] == "/healthz":
+ body = self._build_response()
+ payload = json.dumps(body).encode()
+ header = (
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Type: application/json\r\n"
+ f"Content-Length: {len(payload)}\r\n"
+ "Connection: close\r\n"
+ "\r\n"
+ )
+ else:
+ payload = b'{"error":"not found"}'
+ header = (
+ "HTTP/1.1 404 Not Found\r\n"
+ "Content-Type: application/json\r\n"
+ f"Content-Length: {len(payload)}\r\n"
+ "Connection: close\r\n"
+ "\r\n"
+ )
+ writer.write(header.encode() + payload)
+ await writer.drain()
+
+ def _build_response(self) -> dict[str, Any]:
+ mgr = self._manager
+ health_map: dict[str, Any] = {}
+ for name, h in mgr._health.items():
+ health_map[name] = {
+ "consecutive_failures": h.consecutive_failures,
+ "total_successes": h.total_successes,
+ "total_failures": h.total_failures,
+ }
+ accounts_map: dict[str, Any] = {}
+ for state in mgr._account_manager.list_accounts():
+ key = f"{state.channel_id}:{state.account_id}"
+ accounts_map[key] = {
+ "account_id": state.account_id,
+ "channel": state.channel_id,
+ "status": state.status,
+ "error": state.error,
+ }
+ resp: dict[str, Any] = {
+ "status": "healthy",
+ "uptime_seconds": round(time.monotonic() - self._start_time, 1),
+ "channels": {
+ "enabled": mgr.enabled_channels,
+ "running": mgr.running_channels(),
+ },
+ "queues": {
+ "inbound_size": mgr.bus.inbound_size,
+ "outbound_size": mgr.bus.outbound_size,
+ },
+ "health": health_map,
+ "accounts": accounts_map,
+ }
+ for pname, provider in mgr._health_providers.items():
+ try:
+ resp[pname] = provider()
+ except Exception:
+ resp[pname] = {"error": "provider failed"}
+ return resp
+
+
+# ── Channel registry ──────────────────────────────────────────────────
+
+ChannelFactory = Callable[..., Channel]
+
+_CHANNEL_REGISTRY: dict[str, ChannelFactory] = {}
+
+
+def _parse_csv(value: str) -> set[str] | None:
+ """Parse comma-separated string into a set, or ``None`` if empty."""
+ if not value or not value.strip():
+ return None
+ items = {s.strip() for s in value.split(",") if s.strip()}
+ return items if items else None
+
+
+def register_channel(name: str, factory: ChannelFactory) -> None:
+ """Register a channel factory under *name*."""
+ _CHANNEL_REGISTRY[name] = factory
+
+
+def create_channel(name: str, config) -> Channel:
+ """Create a channel instance using the registered factory for *name*."""
+ factory = _CHANNEL_REGISTRY.get(name)
+ if not factory:
+ raise ValueError(
+ f"Unknown channel type: {name}. "
+ f"Available: {list(_CHANNEL_REGISTRY.keys())}"
+ )
+ return factory(config)
+
+
+def available_channels() -> list[str]:
+ """Return the names of all available channel types.
+
+ Triggers auto-discovery if the registry is empty.
+ """
+ if not _CHANNEL_REGISTRY:
+ _ensure_channels_registered()
+ return list(_CHANNEL_REGISTRY.keys())
+
+
+def _discover_channel_subpackages() -> list[str]:
+ """Discover all channel sub-packages under the channels directory.
+
+ Returns a list of sub-package names (e.g. ["telegram", "discord", ...]).
+ Excludes non-channel directories (bus, __pycache__) and plain modules.
+ """
+ channels_dir = Path(__file__).parent
+ _EXCLUDED = {"bus", "__pycache__"}
+ names = []
+ for info in pkgutil.iter_modules([str(channels_dir)]):
+ if info.ispkg and info.name not in _EXCLUDED:
+ names.append(info.name)
+ return sorted(names)
+
+
+def _ensure_channels_registered(types: list[str] | None = None) -> None:
+ """Lazily import channel sub-packages to trigger registration.
+
+ If *types* is given, only those channels are imported.
+ If *types* is ``None``, all discovered channel sub-packages are imported.
+ """
+ if types is None:
+ targets = _discover_channel_subpackages()
+ else:
+ # Only import the ones that exist as sub-packages
+ available = set(_discover_channel_subpackages())
+ targets = [t for t in types if t in available]
+
+ for t in targets:
+ module_name = f"EvoScientist.channels.{t}"
+ if t not in _CHANNEL_REGISTRY:
+ try:
+ importlib.import_module(module_name)
+ except ImportError as e:
+ logger.debug(f"Could not import channel {t}: {e}")
+
+
+# ── Shared webhook server ─────────────────────────────────────────
+
+class SharedWebhookServer:
+ """Single aiohttp server that hosts routes from multiple HTTP channels.
+
+ When ``shared_webhook_port`` is configured, ``ChannelManager`` collects
+ routes from every channel that exposes ``_webhook_routes()`` and starts
+ one server instead of letting each channel bind its own port.
+ """
+
+ def __init__(self, port: int) -> None:
+ self._port = port
+ self._app: Any = None
+ self._runner: Any = None
+ self._site: Any = None
+
+ async def start(self, routes: list[tuple[str, str, Any]]) -> None:
+ from aiohttp import web
+
+ self._app = web.Application()
+ for method, path, handler in routes:
+ if method.upper() == "GET":
+ self._app.router.add_get(path, handler)
+ else:
+ self._app.router.add_post(path, handler)
+
+ self._runner = web.AppRunner(self._app)
+ await self._runner.setup()
+ self._site = web.TCPSite(self._runner, "0.0.0.0", self._port)
+ await self._site.start()
+ logger.info(
+ f"Shared webhook server started on 0.0.0.0:{self._port} "
+ f"with {len(routes)} route(s)"
+ )
+
+ async def stop(self) -> None:
+ if self._site:
+ await self._site.stop()
+ self._site = None
+ if self._runner:
+ await self._runner.cleanup()
+ self._runner = None
+ logger.info("Shared webhook server stopped")
+
+
+class ChannelManager:
+ """Manages all chat channels and coordinates message routing.
+
+ Responsibilities:
+ - Register channels and inject bus reference
+ - Start / stop all channels
+ - Route outbound messages from the bus to the correct channel
+ """
+
+ def __init__(
+ self,
+ bus: MessageBus,
+ *,
+ health_port: int = 8080,
+ drain_timeout: float = 30.0,
+ shared_webhook_port: int = 0,
+ ):
+ self.bus = bus
+ self._channels: dict[str, Channel] = {}
+ self._tasks: list[asyncio.Task] = []
+ self._dispatch_task: asyncio.Task | None = None
+ self._start_times: dict[str, datetime] = {}
+ self._message_counts: dict[str, dict[str, int]] = {}
+ self._health: dict[str, ChannelHealth] = {}
+ self._is_running: bool = False
+ self._health_port = health_port
+ self._health_server: _HealthServer | None = None
+ self._drain_timeout = drain_timeout
+ self._health_providers: dict[str, Callable[[], dict]] = {}
+ self._account_manager = AccountManager()
+ # Pipelines (built during registration)
+ self._outbound_pipelines: dict[str, OutboundPipeline] = {}
+ # Shared webhook
+ self._shared_webhook_port = shared_webhook_port
+ self._shared_webhook_server: SharedWebhookServer | None = None
+
+ @classmethod
+ def from_config(cls, config, bus: MessageBus | None = None) -> "ChannelManager":
+ """Create a ChannelManager from application config.
+
+ Parses ``config.channel_enabled`` (comma-separated channel types),
+ creates each Channel instance, and registers them.
+
+ Args:
+ config: Application config with channel settings.
+ bus: Optional MessageBus instance. A new one is created if not provided.
+
+ Returns:
+ A fully configured ChannelManager.
+ """
+ if bus is None:
+ bus = MessageBus()
+ shared_webhook_port = getattr(config, "shared_webhook_port", 0) or 0
+ manager = cls(bus, shared_webhook_port=shared_webhook_port)
+ types = [t.strip() for t in (config.channel_enabled or "").split(",") if t.strip()]
+ if not types:
+ raise ValueError("No channels enabled")
+ _ensure_channels_registered(types)
+ for ct in types:
+ channel = create_channel(ct, config)
+ manager.register(channel, config=config)
+ return manager
+
+ # ── registration ──
+
+ def register(
+ self,
+ channel: Channel,
+ *,
+ config: Any = None,
+ **kwargs: Any,
+ ) -> Channel:
+ """Register a channel and inject the bus reference.
+
+ Since Channel IS-A ChannelPlugin, the channel is also registered
+ in the plugin registry. If *config* is provided, inbound/outbound
+ pipelines are built for the channel.
+
+ Args:
+ channel: The channel instance (must have a unique ``name``).
+ config: Optional app config for building pipelines.
+ **kwargs: Extra kwargs applied to the channel
+ (e.g. ``send_thinking=True``, ``initial_debounce=3.0``).
+
+ Returns:
+ The channel instance.
+ """
+ name = channel.name
+ if name in self._channels:
+ raise ValueError(f"Channel '{name}' already registered")
+
+ channel.set_bus(self.bus)
+ for key, value in kwargs.items():
+ if hasattr(channel, key):
+ setattr(channel, key, value)
+ self._channels[name] = channel
+ self._health[name] = ChannelHealth()
+ if channel.config_adapter is not None:
+ self._account_manager.register_plugin(channel)
+ if config is not None:
+ self._outbound_pipelines[name] = build_outbound_pipeline(channel, config)
+ logger.info(f"Registered channel: {name} (slots: {channel.filled_slots()})")
+ return channel
+
+ # ── lifecycle ──
+
+ async def start_all(self) -> None:
+ """Start the outbound dispatcher and all registered channels."""
+ if not self._channels:
+ logger.warning("No channels registered")
+ return
+
+ self._is_running = True
+
+ await self.start_health()
+
+ # Start shared webhook server before individual channels
+ await self._setup_shared_webhook()
+
+ self._dispatch_task = asyncio.create_task(
+ self._dispatch_outbound()
+ )
+
+ now = datetime.now()
+ for name, channel in self._channels.items():
+ logger.info(f"Starting channel: {name}")
+ self._start_times[name] = now
+ if name not in self._message_counts:
+ self._message_counts[name] = {"received": 0, "sent": 0}
+ task = asyncio.create_task(channel.run())
+ self._tasks.append(task)
+
+ await asyncio.gather(*self._tasks, return_exceptions=True)
+
+ async def stop_all(self) -> None:
+ """Stop all channels and the outbound dispatcher.
+
+ Before shutting down channels, attempts to drain the outbound
+ queue so that pending replies are delivered.
+ """
+ logger.info("Stopping all channels...")
+ self._is_running = False
+
+ # Drain outbound queue — try to send pending replies
+ drained = 0
+ deadline = time.monotonic() + self._drain_timeout
+ while time.monotonic() < deadline:
+ try:
+ msg = self.bus.outbound.get_nowait()
+ except asyncio.QueueEmpty:
+ break
+ channel = self._channels.get(msg.channel)
+ if channel and msg.content:
+ try:
+ await asyncio.wait_for(
+ channel.send(msg),
+ timeout=max(1.0, deadline - time.monotonic()),
+ )
+ drained += 1
+ except Exception:
+ pass
+ dropped = self.bus.outbound.qsize()
+ if drained or dropped:
+ logger.info(f"Outbound drain: {drained} sent, {dropped} dropped")
+
+ if self._dispatch_task:
+ self._dispatch_task.cancel()
+ try:
+ await self._dispatch_task
+ except asyncio.CancelledError:
+ pass
+
+ for name, channel in self._channels.items():
+ try:
+ channel._running = False
+ await channel.stop()
+ logger.info(f"Stopped channel: {name}")
+ except Exception as e:
+ logger.error(f"Error stopping {name}: {e}")
+
+ for task in self._tasks:
+ task.cancel()
+ self._tasks.clear()
+
+ # Stop shared webhook server
+ if self._shared_webhook_server is not None:
+ await self._shared_webhook_server.stop()
+ self._shared_webhook_server = None
+
+ await self.stop_health()
+
+ # ── health server ──
+
+ async def start_health(self) -> None:
+ """Start the HTTP health-check endpoint (if configured)."""
+ if self._health_port and self._health_server is None:
+ self._health_server = _HealthServer(self, self._health_port)
+ await self._health_server.start()
+
+ async def stop_health(self) -> None:
+ """Stop the HTTP health-check endpoint."""
+ if self._health_server is not None:
+ await self._health_server.stop()
+ self._health_server = None
+
+ # ── shared webhook ──
+
+ async def _setup_shared_webhook(self) -> None:
+ """Collect routes from HTTP channels and start a shared server.
+
+ Only active when ``shared_webhook_port > 0``. For each channel
+ that exposes ``_webhook_routes()``, the routes are gathered and
+ a sentinel attribute (``_shared_webhook_server``) is set so the
+ channel's own ``start()`` skips creating its own aiohttp server.
+ """
+ if not self._shared_webhook_port:
+ return
+
+ all_routes: list[tuple[str, str, Any]] = []
+ for name, channel in self._channels.items():
+ routes_fn = getattr(channel, "_webhook_routes", None)
+ if routes_fn is None:
+ continue
+ routes = routes_fn()
+ if not routes:
+ continue
+ # Set sentinel so the channel skips its own server
+ channel._shared_webhook_server = True # type: ignore[attr-defined]
+ all_routes.extend(routes)
+ logger.debug(
+ f"Shared webhook: collected {len(routes)} route(s) "
+ f"from '{name}'"
+ )
+
+ if not all_routes:
+ logger.info("Shared webhook: no HTTP channels found, skipping")
+ return
+
+ self._shared_webhook_server = SharedWebhookServer(
+ self._shared_webhook_port,
+ )
+ await self._shared_webhook_server.start(all_routes)
+
+ def register_health_provider(
+ self, name: str, provider: Callable[[], dict],
+ ) -> None:
+ """Register a callable that returns extra data for ``/healthz``."""
+ self._health_providers[name] = provider
+
+ # ── outbound routing ──
+
+ async def _dispatch_outbound(self) -> None:
+ """Route outbound messages from the bus to the correct channel."""
+ logger.info("Outbound dispatcher started")
+ while True:
+ try:
+ msg: OutboundMessage = await asyncio.wait_for(
+ self.bus.consume_outbound(), timeout=1.0,
+ )
+ except asyncio.TimeoutError:
+ continue
+ except asyncio.CancelledError:
+ break
+
+ channel = self._channels.get(msg.channel)
+
+ if not channel:
+ logger.warning(f"Unknown channel: {msg.channel}")
+ continue
+
+ try:
+ # Run outbound pipeline if available (formatting, etc.)
+ if msg.channel in self._outbound_pipelines:
+ processed = await self._outbound_pipelines[msg.channel].process(msg)
+ if processed is None:
+ continue # dropped by pipeline
+ msg = processed
+
+ if msg.content:
+ await channel.send(msg)
+
+ for media_path in msg.media:
+ try:
+ await channel.send_media(
+ recipient=msg.chat_id,
+ file_path=media_path,
+ metadata=msg.metadata,
+ )
+ except Exception as e:
+ logger.error(
+ f"Error sending media to {msg.channel}: {e}"
+ )
+
+ # Success
+ health = self._health.get(msg.channel)
+ if health is not None:
+ health.consecutive_failures = 0
+ health.total_successes += 1
+ except Exception as e:
+ logger.error(
+ f"Error sending to {msg.channel}: {e}"
+ )
+ health = self._health.get(msg.channel)
+ if health is not None:
+ health.consecutive_failures += 1
+ health.total_failures += 1
+ health.last_failure_time = time.monotonic()
+ health.last_failure_error = str(e)
+
+ # ── per-account lifecycle ──
+
+ async def start_account(
+ self,
+ channel_id: str,
+ account_id: str,
+ config: Any = None,
+ ) -> None:
+ """Start a specific account on a registered plugin."""
+ await self._account_manager.start_account(channel_id, account_id, config)
+
+ async def stop_account(
+ self,
+ channel_id: str,
+ account_id: str,
+ ) -> None:
+ """Stop a specific account on a registered plugin."""
+ await self._account_manager.stop_account(channel_id, account_id)
+
+ def list_accounts(
+ self,
+ channel_id: str | None = None,
+ ) -> list[AccountState]:
+ """List account states, optionally filtered by channel."""
+ return self._account_manager.list_accounts(channel_id)
+
+ @property
+ def account_manager(self) -> AccountManager:
+ """Access the underlying AccountManager."""
+ return self._account_manager
+
+ # ── queries ──
+
+ def get_channel(self, name: str) -> Channel | None:
+ """Get a channel by name."""
+ return self._channels.get(name)
+
+ def get_server(self, name: str) -> Channel | None:
+ """Backward compat: returns the Channel (was ChannelServer)."""
+ return self._channels.get(name)
+
+ def get_status(self) -> dict[str, Any]:
+ """Get status of all registered channels."""
+ return {
+ name: {
+ "registered": True,
+ "running": channel._running,
+ "slots": channel.filled_slots(),
+ }
+ for name, channel in self._channels.items()
+ }
+
+ @property
+ def is_running(self) -> bool:
+ """Whether the manager is currently running."""
+ return self._is_running
+
+ @property
+ def enabled_channels(self) -> list[str]:
+ """List of registered channel names."""
+ return list(self._channels.keys())
+
+ def running_channels(self) -> list[str]:
+ """Return names of currently running channels."""
+ return [name for name, ch in self._channels.items() if ch._running]
+
+ def get_stats(self) -> dict:
+ """Return summary stats for all channels."""
+ return {
+ "channels": self.enabled_channels,
+ "running": self.running_channels(),
+ "message_counts": dict(self._message_counts),
+ }
+
+ async def add_channel(self, channel_type: str, config) -> Channel:
+ """Dynamically add and start a channel at runtime."""
+ _ensure_channels_registered([channel_type])
+ channel = create_channel(channel_type, config)
+ self.register(channel)
+ self._start_times[channel_type] = datetime.now()
+ if channel_type not in self._message_counts:
+ self._message_counts[channel_type] = {"received": 0, "sent": 0}
+ task = asyncio.create_task(channel.run())
+ self._tasks.append(task)
+ return channel
+
+ async def remove_channel(self, channel_type: str) -> None:
+ """Stop and remove a channel at runtime."""
+ channel = self._channels.pop(channel_type, None)
+ if channel:
+ channel._running = False
+ await channel.stop()
+ logger.info(f"Removed channel: {channel_type}")
+
+ def record_message(self, channel_name: str, direction: str) -> None:
+ """Record a message for tracking.
+
+ Args:
+ channel_name: Channel name (e.g. "telegram").
+ direction: "received" or "sent".
+ """
+ if channel_name not in self._message_counts:
+ self._message_counts[channel_name] = {"received": 0, "sent": 0}
+ if direction in self._message_counts[channel_name]:
+ self._message_counts[channel_name][direction] += 1
+
+ def get_detailed_status(self) -> dict[str, Any]:
+ """Get detailed status of all registered channels.
+
+ Returns:
+ Dict keyed by channel name with running, start_time, message
+ counts, health, and plugin information.
+ """
+ now = datetime.now()
+ result = {}
+ for name, channel in self._channels.items():
+ start = self._start_times.get(name)
+ counts = self._message_counts.get(name, {"received": 0, "sent": 0})
+ health = self._health.get(name, ChannelHealth())
+ result[name] = {
+ "registered": True,
+ "running": channel._running,
+ "start_time": start,
+ "uptime_seconds": (now - start).total_seconds() if start else 0,
+ "received": counts["received"],
+ "sent": counts["sent"],
+ "health": {
+ "consecutive_failures": health.consecutive_failures,
+ "last_failure_time": health.last_failure_time,
+ "last_failure_error": health.last_failure_error,
+ "total_failures": health.total_failures,
+ "total_successes": health.total_successes,
+ },
+ "plugin_slots": channel.filled_slots(),
+ "has_outbound_pipeline": name in self._outbound_pipelines,
+ }
+ return result
diff --git a/EvoScientist/channels/config.py b/EvoScientist/channels/config.py
new file mode 100644
index 0000000..c6afb77
--- /dev/null
+++ b/EvoScientist/channels/config.py
@@ -0,0 +1,126 @@
+"""Base configuration for all channel implementations.
+
+Provides common fields shared across channels, reducing duplication.
+Channel-specific configs inherit from BaseChannelConfig.
+
+Also provides ready-made ConfigAdapter implementations for the two most
+common account patterns:
+
+- ``SingleAccountConfigAdapter`` — one account per channel (default).
+- ``MultiAccountConfigAdapter`` — multiple accounts from a config dict.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class BaseChannelConfig:
+ """Common configuration fields for all channels.
+
+ Subclass this for channel-specific configs. Only add fields
+ here that are used by 3+ channels.
+ """
+
+ allowed_senders: set[str] | None = None
+ allowed_channels: set[str] | None = None
+ text_chunk_limit: int = 4096
+ proxy: str | None = None
+ include_attachments: bool = True
+ accounts: dict | None = None # multi-account config mapping
+
+
+class SingleAccountConfigAdapter:
+ """For channels that only ever have one account (most channels).
+
+ Returns a single ``"default"`` account whose config is the entire
+ channel config object. This is the zero-change default: existing
+ single-account channels get multi-account support for free.
+ """
+
+ def list_account_ids(self, config: Any) -> list[str]:
+ return ["default"]
+
+ def resolve_account(
+ self, config: Any, account_id: str | None = None,
+ ) -> Any:
+ return config
+
+ def is_enabled(self, account: Any, config: Any) -> bool:
+ return True
+
+ def is_configured(self, account: Any, config: Any) -> bool:
+ """Check that the account has at least some non-None values."""
+ if account is None:
+ return False
+ if isinstance(account, dict):
+ return bool(account)
+ # dataclass / object — check that at least one field is truthy
+ if hasattr(account, "__dataclass_fields__"):
+ return any(
+ getattr(account, f, None)
+ for f in account.__dataclass_fields__
+ )
+ return True
+
+
+class MultiAccountConfigAdapter:
+ """For channels that support multiple accounts.
+
+ Expects the channel config to contain a mapping of accounts under
+ a configurable key (default ``"accounts"``). Each entry is keyed
+ by account id and holds account-specific settings.
+
+ Example config structure::
+
+ {
+ "accounts": {
+ "bot1": {"token": "...", "enabled": true},
+ "bot2": {"token": "...", "enabled": false},
+ }
+ }
+ """
+
+ def __init__(
+ self,
+ accounts_key: str = "accounts",
+ required_fields: list[str] | None = None,
+ ) -> None:
+ self._accounts_key = accounts_key
+ self._required_fields = required_fields or []
+
+ def _get_accounts_map(self, config: Any) -> dict[str, Any]:
+ """Extract the accounts mapping from config."""
+ if isinstance(config, dict):
+ return config.get(self._accounts_key, {})
+ return getattr(config, self._accounts_key, None) or {}
+
+ def list_account_ids(self, config: Any) -> list[str]:
+ return list(self._get_accounts_map(config).keys())
+
+ def resolve_account(
+ self, config: Any, account_id: str | None = None,
+ ) -> Any:
+ accounts = self._get_accounts_map(config)
+ if account_id is None:
+ # Return the first account, or empty dict
+ return next(iter(accounts.values()), {})
+ return accounts.get(account_id, {})
+
+ def is_enabled(self, account: Any, config: Any) -> bool:
+ if isinstance(account, dict):
+ return account.get("enabled", True)
+ return getattr(account, "enabled", True)
+
+ def is_configured(self, account: Any, config: Any) -> bool:
+ if not account:
+ return False
+ for f in self._required_fields:
+ if isinstance(account, dict):
+ if not account.get(f):
+ return False
+ elif not getattr(account, f, None):
+ return False
+ return True
diff --git a/EvoScientist/channels/consumer.py b/EvoScientist/channels/consumer.py
new file mode 100644
index 0000000..62463c9
--- /dev/null
+++ b/EvoScientist/channels/consumer.py
@@ -0,0 +1,407 @@
+"""Unified inbound message consumer.
+
+Provides :class:`InboundConsumer` — a single class that consumes
+inbound messages from the :class:`MessageBus`, runs them through
+the agent, and publishes outbound responses. This replaces the
+inline consumer loops that were duplicated in ``cli.py`` and
+``standalone.py``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import uuid
+from dataclasses import dataclass
+from typing import Any, AsyncIterator, Callable, TypeVar
+
+from .base import Channel
+from .bus import MessageBus
+from .bus.events import InboundMessage, OutboundMessage
+
+logger = logging.getLogger(__name__)
+
+T = TypeVar("T")
+
+_MAX_CHAT_LOCKS = 10_000
+_MAX_SESSIONS = 10_000
+
+
+@dataclass
+class ConsumerMetrics:
+ """Cumulative processing counters for the consumer."""
+
+ total_processed: int = 0
+ total_successes: int = 0
+ total_failures: int = 0
+ total_timeouts: int = 0
+
+
+async def _timeout_aiter(
+ agen: AsyncIterator[T],
+ idle_timeout: float,
+) -> AsyncIterator[T]:
+ """Wrap an async iterator with a per-yield idle timeout.
+
+ If ``__anext__()`` does not produce a value within *idle_timeout*
+ seconds, :class:`asyncio.TimeoutError` is raised. Continuous
+ yielding resets the timer each time, so only a truly stalled
+ generator will trigger the timeout.
+ """
+ ait = agen.__aiter__()
+ try:
+ while True:
+ try:
+ item = await asyncio.wait_for(ait.__anext__(), timeout=idle_timeout)
+ except StopAsyncIteration:
+ return
+ yield item
+ finally:
+ if hasattr(ait, "aclose"):
+ await ait.aclose()
+
+
+def _format_todo_list(todos: list[dict]) -> str:
+ """Format todo items as a numbered list."""
+ lines = ["\U0001f4cb Todo List\n"] # 📋
+ for i, item in enumerate(todos, 1):
+ content = item.get("content", "")
+ lines.append(f"{i}. {content}")
+ lines.append(f"\n\U0001f680 {len(todos)} tasks") # 🚀
+ return "\n".join(lines)
+
+
+class InboundConsumer:
+ """Consume inbound messages from the bus, process via agent, publish outbound.
+
+ Parameters
+ ----------
+ bus:
+ The MessageBus to consume from / publish to.
+ manager:
+ The ChannelManager (used to look up channel instances).
+ agent:
+ The agent object (must support ``stream_agent_events``).
+ thread_id:
+ Default thread ID for agent conversations.
+ send_thinking:
+ Whether to forward thinking messages to the channel.
+ on_message_received:
+ Optional callback ``(msg: InboundMessage) -> None`` invoked when
+ a message is consumed (e.g. for CLI Rich display).
+ on_streaming_event:
+ Optional callback ``(event: dict) -> None`` invoked for each
+ streaming event from the agent.
+ on_message_sent:
+ Optional callback ``(msg: OutboundMessage) -> None`` invoked when
+ the outbound message is published.
+ inference_timeout:
+ Per-yield idle timeout in seconds for the agent stream. If the
+ agent produces no event for this long, the inference is aborted.
+ max_concurrent:
+ Number of worker coroutines (= max parallel inferences).
+ max_pending:
+ Maximum depth of the internal work queue. When full, the
+ consumer loop blocks (back-pressure).
+ drain_timeout:
+ Seconds to wait for in-flight workers to finish during ``stop()``.
+ """
+
+ def __init__(
+ self,
+ bus: MessageBus,
+ manager: Any,
+ agent: Any,
+ thread_id: str,
+ *,
+ send_thinking: bool = False,
+ on_message_received: Callable[[InboundMessage], None] | None = None,
+ on_streaming_event: Callable[[dict], None] | None = None,
+ on_message_sent: Callable[[OutboundMessage], None] | None = None,
+ inference_timeout: float = 300.0,
+ max_concurrent: int = 5,
+ max_pending: int = 50,
+ drain_timeout: float = 30.0,
+ ):
+ self.bus = bus
+ self.manager = manager
+ self.agent = agent
+ self.thread_id = thread_id
+ self.send_thinking = send_thinking
+ self._on_message_received = on_message_received
+ self._on_streaming_event = on_streaming_event
+ self._on_message_sent = on_message_sent
+ self._sessions: dict[str, str] = {} # sender_id -> thread_id
+
+ # Per-chat locks: same chat is processed serially (bounded)
+ self._chat_locks: dict[str, asyncio.Lock] = {}
+
+ # Inference timeout
+ self._inference_timeout = inference_timeout
+
+ # Worker pool
+ self._max_concurrent = max_concurrent
+ self._work_queue: asyncio.Queue[InboundMessage | None] = asyncio.Queue(
+ maxsize=max_pending,
+ )
+ self._workers: list[asyncio.Task] = []
+ self._stopping = False
+ self._drain_timeout = drain_timeout
+
+ # Metrics
+ self._metrics = ConsumerMetrics()
+
+ def _get_thread_id(self, sender_id: str) -> str:
+ """Get or create a thread ID for the given sender."""
+ if sender_id not in self._sessions:
+ if len(self._sessions) >= _MAX_SESSIONS:
+ # Evict oldest entry
+ oldest = next(iter(self._sessions))
+ del self._sessions[oldest]
+ if self.thread_id:
+ self._sessions[sender_id] = f"{self.thread_id}:{sender_id}"
+ else:
+ self._sessions[sender_id] = str(uuid.uuid4())
+ return self._sessions[sender_id]
+
+ def _get_channel(self, channel_name: str) -> Channel | None:
+ """Look up the channel by name from the manager."""
+ return self.manager.get_channel(channel_name)
+
+ # ── lifecycle ──
+
+ async def run(self) -> None:
+ """Main consumer loop — runs until ``stop()`` or cancellation.
+
+ Spawns *max_concurrent* worker coroutines that pull from an
+ internal bounded queue. The loop reads from the bus and feeds
+ the queue; when the queue is full the loop blocks (back-pressure).
+ """
+ self._stopping = False
+ self._workers = [
+ asyncio.create_task(self._worker(i))
+ for i in range(self._max_concurrent)
+ ]
+ try:
+ while not self._stopping:
+ try:
+ msg = await asyncio.wait_for(
+ self.bus.consume_inbound(), timeout=1.0,
+ )
+ except asyncio.TimeoutError:
+ continue
+ except asyncio.CancelledError:
+ break
+ if self._stopping:
+ break
+ await self._work_queue.put(msg) # blocks when full (back-pressure)
+ finally:
+ if not self._stopping:
+ await self.stop()
+
+ async def stop(self) -> None:
+ """Gracefully drain in-flight work and shut down workers."""
+ self._stopping = True
+ logger.info("Consumer stopping: draining in-flight messages...")
+ pending_count = self._work_queue.qsize()
+
+ # Send a None sentinel per worker so each exits its loop
+ for _ in self._workers:
+ try:
+ self._work_queue.put_nowait(None)
+ except asyncio.QueueFull:
+ pass
+
+ # Wait for workers to finish, then force-cancel stragglers
+ if self._workers:
+ done, still_running = await asyncio.wait(
+ self._workers, timeout=self._drain_timeout,
+ )
+ for task in still_running:
+ task.cancel()
+ try:
+ await task
+ except (asyncio.CancelledError, Exception):
+ pass
+ logger.info(
+ f"Consumer drain: {len(done)} finished, "
+ f"{len(still_running)} force-cancelled, "
+ f"{pending_count} were pending"
+ )
+ self._workers.clear()
+
+ # ── workers ──
+
+ async def _worker(self, worker_id: int) -> None:
+ """Pull messages from the work queue and process them."""
+ while True:
+ msg = await self._work_queue.get()
+ if msg is None:
+ break # shutdown sentinel
+ try:
+ await self._handle_message(msg)
+ except Exception:
+ logger.exception(f"Worker {worker_id} unhandled error")
+ finally:
+ self._work_queue.task_done()
+
+ async def _handle_message(self, msg: InboundMessage) -> None:
+ """Process a single inbound message."""
+ from ..stream.events import stream_agent_events
+
+ if self._on_message_received:
+ try:
+ self._on_message_received(msg)
+ except Exception:
+ pass
+
+ channel = self._get_channel(msg.channel)
+ thread_id = self._get_thread_id(msg.sender_id)
+ session_key = msg.session_key # "channel:chat_id"
+
+ # Lazily create per-chat lock; evict stale locks when too many
+ if session_key not in self._chat_locks:
+ self._chat_locks[session_key] = asyncio.Lock()
+ if len(self._chat_locks) > _MAX_CHAT_LOCKS:
+ self._evict_chat_locks()
+
+ self._metrics.total_processed += 1
+
+ async with self._chat_locks[session_key]:
+ try:
+ final_content = ""
+ thinking_buffer: list[str] = []
+ todo_sent = False
+ thinking_sent = False
+
+ if channel:
+ await channel.start_typing(msg.chat_id)
+
+ async for event in _timeout_aiter(
+ stream_agent_events(self.agent, msg.content, thread_id, media=msg.media or None),
+ self._inference_timeout,
+ ):
+ event_type = event.get("type")
+
+ if self._on_streaming_event:
+ try:
+ self._on_streaming_event(event)
+ except Exception:
+ pass
+
+ if event_type == "thinking":
+ thinking_text = event.get("content", "")
+ if thinking_text:
+ thinking_buffer.append(thinking_text)
+
+ elif event_type == "tool_call":
+ if event.get("name") == "write_todos" and not todo_sent:
+ todos = event.get("args", {}).get("todos", [])
+ if todos and channel:
+ if thinking_buffer and not thinking_sent:
+ full_thinking = "".join(thinking_buffer)
+ if full_thinking:
+ await channel.send_thinking_message(
+ msg.sender_id,
+ full_thinking,
+ msg.metadata,
+ )
+ thinking_sent = True
+ thinking_buffer.clear()
+ await channel.send_todo_message(
+ msg.sender_id,
+ _format_todo_list(todos),
+ msg.metadata,
+ )
+ todo_sent = True
+
+ elif event_type == "text":
+ final_content += event.get("content", "")
+
+ elif event_type == "done":
+ final_content = event.get("content", "") or final_content
+
+ if thinking_buffer and not thinking_sent and channel:
+ full_thinking = "".join(thinking_buffer)
+ if full_thinking:
+ await channel.send_thinking_message(
+ msg.sender_id, full_thinking, msg.metadata,
+ )
+
+ outbound = OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=final_content or "No response",
+ reply_to=msg.message_id or None,
+ metadata=msg.metadata,
+ )
+ await self.bus.publish_outbound(outbound)
+
+ self._metrics.total_successes += 1
+
+ if self._on_message_sent:
+ try:
+ self._on_message_sent(outbound)
+ except Exception:
+ pass
+
+ except asyncio.TimeoutError:
+ self._metrics.total_timeouts += 1
+ logger.error(
+ f"Inference timeout ({self._inference_timeout}s idle) "
+ f"for {msg.sender_id} in {session_key}"
+ )
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="Sorry, the response timed out. Please try again.",
+ metadata=msg.metadata,
+ ))
+
+ except Exception as e:
+ self._metrics.total_failures += 1
+ logger.error(f"Agent error: {e}")
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="Sorry, something went wrong. Please try again later.",
+ metadata=msg.metadata,
+ ))
+ finally:
+ if channel:
+ await channel.stop_typing(msg.chat_id)
+
+ # ── observability ──
+
+ @property
+ def pending_count(self) -> int:
+ """Number of messages waiting in the work queue."""
+ return self._work_queue.qsize()
+
+ @property
+ def active_workers(self) -> int:
+ """Number of worker tasks that are still alive."""
+ return sum(1 for w in self._workers if not w.done())
+
+ @property
+ def metrics(self) -> dict[str, int]:
+ """Cumulative processing counters."""
+ m = self._metrics
+ return {
+ "total_processed": m.total_processed,
+ "total_successes": m.total_successes,
+ "total_failures": m.total_failures,
+ "total_timeouts": m.total_timeouts,
+ "pending": self.pending_count,
+ "active_workers": self.active_workers,
+ "chat_locks": len(self._chat_locks),
+ "sessions": len(self._sessions),
+ }
+
+ # ── internal ──
+
+ def _evict_chat_locks(self) -> None:
+ """Remove chat locks that are not currently held."""
+ stale = [k for k, lock in self._chat_locks.items() if not lock.locked()]
+ for k in stale[:max(1, len(stale) // 2)]:
+ del self._chat_locks[k]
diff --git a/EvoScientist/channels/dingtalk/__init__.py b/EvoScientist/channels/dingtalk/__init__.py
new file mode 100644
index 0000000..2026d82
--- /dev/null
+++ b/EvoScientist/channels/dingtalk/__init__.py
@@ -0,0 +1,29 @@
+"""DingTalk (钉钉) channel for EvoScientist.
+
+Uses Stream Mode (WebSocket) for receiving messages — no public IP needed.
+Sends replies via HTTP API.
+
+Usage in config:
+ channel_enabled = "dingtalk"
+ dingtalk_client_id = "your_app_key"
+ dingtalk_client_secret = "your_app_secret"
+"""
+
+from .channel import DingTalkChannel, DingTalkConfig
+from ..channel_manager import register_channel, _parse_csv
+
+__all__ = ["DingTalkChannel", "DingTalkConfig"]
+
+
+def create_from_config(config) -> DingTalkChannel:
+ allowed = _parse_csv(getattr(config, "dingtalk_allowed_senders", ""))
+ proxy = getattr(config, "dingtalk_proxy", "") or None
+ return DingTalkChannel(DingTalkConfig(
+ client_id=getattr(config, "dingtalk_client_id", ""),
+ client_secret=getattr(config, "dingtalk_client_secret", ""),
+ allowed_senders=allowed,
+ proxy=proxy,
+ ))
+
+
+register_channel("dingtalk", create_from_config)
diff --git a/EvoScientist/channels/dingtalk/channel.py b/EvoScientist/channels/dingtalk/channel.py
new file mode 100644
index 0000000..1b1bffb
--- /dev/null
+++ b/EvoScientist/channels/dingtalk/channel.py
@@ -0,0 +1,354 @@
+"""DingTalk channel — refactored with WebSocketMixin + TokenMixin."""
+
+import asyncio
+import json
+import logging
+from urllib.parse import quote_plus
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+
+from ..base import Channel, RawIncoming, ChannelError
+from ..capabilities import DINGTALK as DINGTALK_CAPS
+from ..mixins import WebSocketMixin, TokenMixin
+from ..config import BaseChannelConfig
+
+logger = logging.getLogger(__name__)
+
+GATEWAY_URL = "https://api.dingtalk.com/v1.0/gateway/connections/open"
+TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+SEND_URL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
+MEDIA_SEND_URL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
+MEDIA_UPLOAD_URL = "https://oapi.dingtalk.com/media/upload"
+FILE_DOWNLOAD_URL = "https://api.dingtalk.com/v1.0/robot/messageFiles/download"
+
+
+@dataclass
+class DingTalkConfig(BaseChannelConfig):
+ client_id: str = ""
+ client_secret: str = ""
+ text_chunk_limit: int = 4096
+
+
+class DingTalkChannel(Channel, WebSocketMixin, TokenMixin):
+ capabilities = DINGTALK_CAPS
+ name = "dingtalk"
+ _ready_attrs = ("_http_client", "_access_token")
+ _non_retryable_patterns = ("invalidauthentication", "forbidden", "40014")
+ _mention_pattern = r"@\S+\s*"
+ _mention_strip_count = 1
+
+ def __init__(self, config: DingTalkConfig):
+ super().__init__(config)
+
+ async def start(self) -> None:
+ import httpx
+ if not self.config.client_id or not self.config.client_secret:
+ raise ChannelError("DingTalk client_id and client_secret are required")
+ self._http_client = httpx.AsyncClient(timeout=15, proxy=self.config.proxy)
+ await self._refresh_token()
+ self._running = True
+ logger.info("DingTalk channel starting (Stream Mode)...")
+ self._ws_task = asyncio.create_task(self._ws_loop())
+
+ # ── TokenMixin ────────────────────────────────────────────────
+
+ async def _fetch_token(self) -> tuple[str, int]:
+ data = await self._api_post(TOKEN_URL, {
+ "appKey": self.config.client_id,
+ "appSecret": self.config.client_secret,
+ })
+ token = data.get("accessToken")
+ if not token:
+ raise ChannelError(f"DingTalk auth error: {data}")
+ return token, int(data.get("expireIn", 7200))
+
+ async def _api_post(self, url, body, headers=None):
+ resp = await self._http_client.post(url, json=body, headers=headers)
+ return resp.json()
+
+ async def _resolve_download_code(self, download_code: str) -> str | None:
+ """Exchange a DingTalk downloadCode for a real download URL."""
+ try:
+ token = await self._ensure_token()
+ data = await self._api_post(
+ FILE_DOWNLOAD_URL,
+ {"downloadCode": download_code, "robotCode": self.config.client_id},
+ headers={"x-acs-dingtalk-access-token": token},
+ )
+ url = data.get("downloadUrl") or ""
+ if url:
+ return url
+ logger.warning(f"DingTalk downloadCode resolve failed: {data}")
+ except Exception as e:
+ logger.warning(f"DingTalk downloadCode resolve error: {e}")
+ return None
+
+ # ── WebSocketMixin ────────────────────────────────────────────
+
+ async def _get_ws_url(self) -> str:
+ resp = await self._http_client.post(GATEWAY_URL, json={
+ "clientId": self.config.client_id,
+ "clientSecret": self.config.client_secret,
+ "subscriptions": [{"type": "CALLBACK", "topic": "/v1.0/im/bot/messages/get"}],
+ "ua": "dingtalk-sdk-python/v0.24.3-union",
+ })
+ data = resp.json()
+ endpoint, ticket = data.get("endpoint"), data.get("ticket")
+ if not endpoint or not ticket:
+ raise ChannelError(f"DingTalk gateway failed: {data}")
+ return f"{endpoint}?ticket={quote_plus(ticket)}"
+
+ async def _on_ws_message(self, data) -> None:
+ if not isinstance(data, dict):
+ return
+ headers = data.get("headers", {})
+ msg_id = headers.get("messageId", "")
+
+ # System ping
+ if data.get("type") == "SYSTEM" and headers.get("topic") == "ping":
+ await self._ws_send_json({"code": 200, "headers": headers, "message": "OK", "data": data.get("data", "")})
+ return
+
+ # ACK
+ await self._ws_send_json({"code": 200, "headers": {"contentType": "application/json", "messageId": msg_id}, "message": "OK", "data": "{}"})
+
+ if data.get("type") != "CALLBACK":
+ return
+
+ payload = data.get("data", "{}")
+ payload = json.loads(payload) if isinstance(payload, str) else payload
+ text_obj = payload.get("text", {})
+ content = (text_obj.get("content", "") if isinstance(text_obj, dict) else str(text_obj)).strip()
+ if not content:
+ raw_content = payload.get("content", "")
+ content = raw_content.strip() if isinstance(raw_content, str) else ""
+
+ # Download attachments if present
+ annotations: list[str] = []
+ media_paths: list[str] = []
+
+ # DingTalk file/image messages may put download info in
+ # payload["content"] (as a dict) instead of in a dedicated
+ # "fileContent"/"imageContent" key.
+ raw_content_obj = payload.get("content")
+ if isinstance(raw_content_obj, dict) and raw_content_obj not in [
+ payload.get(k) for k in ("imageContent", "fileContent", "videoContent", "audioContent")
+ ]:
+ msg_type = payload.get("msgtype") or payload.get("msgType") or ""
+ media_label = msg_type or "file"
+ file_size = raw_content_obj.get("fileSize") or raw_content_obj.get("downloadSize") or 0
+ file_name = raw_content_obj.get("fileName") or raw_content_obj.get("name") or f"dingtalk_{msg_type}"
+ download_code = raw_content_obj.get("downloadCode") or ""
+ download_url = raw_content_obj.get("downloadUrl") or ""
+ # downloadCode is NOT a URL — resolve it via DingTalk API first
+ if download_code and not download_code.startswith("http"):
+ resolved = await self._resolve_download_code(download_code)
+ if resolved:
+ download_url = resolved
+ elif download_code:
+ download_url = download_code
+ if download_url:
+ try:
+ dl_token = await self._ensure_token()
+ dl_headers = {"x-acs-dingtalk-access-token": dl_token}
+ except Exception:
+ dl_headers = None
+ local, ann = await self._download_attachment(
+ download_url, f"dingtalk_{file_name}",
+ headers=dl_headers,
+ file_size=int(file_size) if file_size else None,
+ )
+ if local:
+ media_paths.append(local)
+ if ann:
+ ann = ann.replace("[attachment:", f"[{media_label}:")
+ annotations.append(ann)
+ elif file_name:
+ annotations.append(f"[{media_label}: {file_name}]")
+
+ for att_key in ("imageContent", "fileContent", "videoContent", "audioContent"):
+ att = payload.get(att_key)
+ if att and isinstance(att, dict):
+ file_size = att.get("fileSize") or att.get("downloadSize") or 0
+ file_name = att.get("fileName", att_key)
+ download_code = att.get("downloadCode") or ""
+ download_url = att.get("downloadUrl") or ""
+ # Resolve downloadCode via API if it's not a URL
+ if download_code and not download_code.startswith("http"):
+ resolved = await self._resolve_download_code(download_code)
+ if resolved:
+ download_url = resolved
+ elif download_code:
+ download_url = download_code
+ # DingTalk audioContent is voice messages
+ media_label = "voice" if att_key == "audioContent" else att_key
+ if download_url and (self.config.include_attachments if hasattr(self.config, 'include_attachments') else True):
+ # DingTalk download URLs require access token
+ try:
+ dl_token = await self._ensure_token()
+ dl_headers = {"x-acs-dingtalk-access-token": dl_token}
+ except Exception:
+ dl_headers = None
+ local, ann = await self._download_attachment(
+ download_url, f"dingtalk_{file_name}",
+ headers=dl_headers,
+ file_size=int(file_size) if file_size else None,
+ )
+ if local:
+ media_paths.append(local)
+ if ann:
+ ann = ann.replace("[attachment:", f"[{media_label}:")
+ annotations.append(ann)
+ elif file_size:
+ too_large = self._check_attachment_size(int(file_size), file_name)
+ if too_large:
+ annotations.append(too_large)
+ else:
+ annotations.append(f"[{media_label}: {file_name}]")
+
+ if not content and not media_paths and not annotations:
+ return
+
+ sender_id = payload.get("senderStaffId") or payload.get("senderId", "")
+ is_group = payload.get("conversationType") == "2"
+ # For send API (oToMessages/batchSend), userIds needs staffId, not conversationId
+ chat_id = sender_id
+ create_time = payload.get("createAt") or payload.get("createTime", "")
+
+ # Mention gating: DMs always pass; groups require @bot
+ was_mentioned = not is_group
+ if is_group:
+ # isInAtList is set by DingTalk when bot is @mentioned
+ if payload.get("isInAtList"):
+ was_mentioned = True
+ else:
+ # Fallback: check atUsers array
+ at_users = payload.get("atUsers") or []
+ for u in at_users:
+ if u.get("dingtalkId") == self.config.client_id:
+ was_mentioned = True
+ break
+
+ try:
+ ts = datetime.fromtimestamp(int(create_time) / 1000) if create_time else datetime.now()
+ except (ValueError, TypeError, OSError):
+ ts = datetime.now()
+
+ await self._enqueue_raw(RawIncoming(
+ sender_id=sender_id, chat_id=chat_id, text=content, timestamp=ts,
+ message_id=msg_id, is_group=is_group, was_mentioned=was_mentioned,
+ media_files=media_paths,
+ content_annotations=annotations,
+ metadata={"chat_id": chat_id, "sender_nick": payload.get("senderNick", ""), "backend": "dingtalk"},
+ ))
+
+ # _send_typing_action: inherited no-op (DingTalk has no typing API)
+ # _format_chunk: inherited from base (UnifiedFormatter)
+
+ # ── Send ──────────────────────────────────────────────────────
+
+ async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
+ token = await self._ensure_token()
+ data = await self._api_post(SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps({"text": raw_text, "title": "EvoScientist"}),
+ }, headers={"x-acs-dingtalk-access-token": token})
+ return data
+
+ # ── Media send ────────────────────────────────────────────────
+
+ _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
+
+ async def _send_media_impl(
+ self,
+ recipient: str,
+ file_path: str,
+ caption: str = "",
+ metadata: dict | None = None,
+ ) -> bool:
+ """Send a media file through DingTalk.
+
+ For images: uploads via /media/upload to get media_id, then sends
+ as sampleImageMsg. Non-image files are sent as markdown links
+ (DingTalk robot API does not support arbitrary file uploads).
+ """
+ token = await self._ensure_token()
+ chat_id = self._resolve_media_chat_id(recipient, metadata)
+ headers = {"x-acs-dingtalk-access-token": token}
+ ext = Path(file_path).suffix.lower()
+
+ if ext in self._IMAGE_EXTS:
+ # Try uploading image to get media_id for native image message
+ media_id = await self._upload_dingtalk_media(token, file_path, "image")
+ if media_id:
+ await self._api_post(MEDIA_SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleImageMsg",
+ "msgParam": json.dumps({"photoURL": media_id}),
+ }, headers=headers)
+ else:
+ # Fallback to markdown with file path
+ await self._api_post(MEDIA_SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps({
+ "text": f"" + (f"\n{caption}" if caption else ""),
+ "title": caption or "Image",
+ }),
+ }, headers=headers)
+ else:
+ # Non-image: send as markdown with filename
+ name = Path(file_path).name
+ text = f"[文件] {name}" + (f"\n{caption}" if caption else "")
+ await self._api_post(MEDIA_SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps({"text": text, "title": name}),
+ }, headers=headers)
+
+ if caption and ext in self._IMAGE_EXTS:
+ # Send caption separately for image messages
+ await self._api_post(MEDIA_SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps({"text": caption, "title": "Caption"}),
+ }, headers=headers)
+ return True
+
+ async def _upload_dingtalk_media(
+ self, token: str, file_path: str, media_type: str = "image",
+ ) -> str | None:
+ """Upload a file to DingTalk media API and return the media_id."""
+ try:
+ url = f"{MEDIA_UPLOAD_URL}?access_token={token}&type={media_type}"
+ with open(file_path, "rb") as f:
+ resp = await self._http_client.post(
+ url, files={"media": (Path(file_path).name, f)},
+ )
+ data = resp.json()
+ return data.get("media_id")
+ except Exception as e:
+ logger.warning(f"DingTalk media upload failed: {e}")
+ return None
+
+ async def _cleanup(self) -> None:
+ if hasattr(self, "_ws_task") and self._ws_task:
+ self._ws_task.cancel()
+ try:
+ await self._ws_task
+ except (asyncio.CancelledError, Exception):
+ pass
+ self._ws_task = None
+ await self._stop_ws()
+ if self._http_client:
+ await self._http_client.aclose()
+ self._http_client = None
+ self._access_token = None
+ logger.info("DingTalk channel stopped")
diff --git a/EvoScientist/channels/dingtalk/probe.py b/EvoScientist/channels/dingtalk/probe.py
new file mode 100644
index 0000000..eb0eeb2
--- /dev/null
+++ b/EvoScientist/channels/dingtalk/probe.py
@@ -0,0 +1,33 @@
+"""DingTalk credential validation."""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+async def validate_dingtalk(
+ client_id: str,
+ client_secret: str,
+ proxy: str | None = None,
+) -> tuple[bool, str]:
+ """Validate DingTalk credentials by fetching an access token."""
+ if not client_id or not client_secret:
+ return False, "client_id and client_secret are required"
+
+ try:
+ import httpx
+ except ImportError:
+ return False, "httpx not installed"
+
+ url = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+ body = {"appKey": client_id, "appSecret": client_secret}
+
+ try:
+ async with httpx.AsyncClient(proxy=proxy) as client:
+ resp = await client.post(url, json=body, timeout=10)
+ data = resp.json()
+ if data.get("accessToken"):
+ return True, "DingTalk credentials valid"
+ return False, f"Error: {data.get('message', data)}"
+ except Exception as e:
+ return False, f"Error: {e}"
diff --git a/EvoScientist/channels/dingtalk/serve.py b/EvoScientist/channels/dingtalk/serve.py
new file mode 100644
index 0000000..8fb4abb
--- /dev/null
+++ b/EvoScientist/channels/dingtalk/serve.py
@@ -0,0 +1,92 @@
+"""DingTalk channel server.
+
+Standalone script to run the DingTalk channel with CLI options.
+
+Usage:
+ python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET [OPTIONS]
+
+Examples:
+ # Basic usage
+ python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET
+
+ # With proxy and allowed senders
+ python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET --proxy http://proxy:8080 --allow user123
+
+ # With agent and thinking
+ python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET --agent --thinking
+"""
+
+import argparse
+import logging
+
+from .channel import DingTalkChannel, DingTalkConfig
+from ..bus import MessageBus
+from ..standalone import run_standalone
+
+logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger(__name__)
+
+
+def parse_args():
+ """Parse command line arguments."""
+ parser = argparse.ArgumentParser(
+ description="DingTalk channel server",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "--client-id",
+ required=True,
+ help="DingTalk app client ID",
+ )
+ parser.add_argument(
+ "--client-secret",
+ required=True,
+ help="DingTalk app client secret",
+ )
+ parser.add_argument(
+ "--allow",
+ action="append",
+ dest="allowed_senders",
+ help="Allowed sender (DingTalk user ID). Can be used multiple times.",
+ )
+ parser.add_argument(
+ "--proxy",
+ help="HTTP proxy URL",
+ )
+ parser.add_argument(
+ "--agent",
+ action="store_true",
+ help="Use EvoScientist agent as handler (default: echo)",
+ )
+ parser.add_argument(
+ "--thinking",
+ action="store_true",
+ help="Send thinking content as intermediate messages (requires --agent)",
+ )
+ return parser.parse_args()
+
+
+def main():
+ """Entry point."""
+ args = parse_args()
+
+ config = DingTalkConfig(
+ client_id=args.client_id,
+ client_secret=args.client_secret,
+ allowed_senders=set(args.allowed_senders) if args.allowed_senders else None,
+ proxy=args.proxy,
+ )
+
+ send_thinking = args.thinking and args.agent
+ bus = MessageBus()
+ channel = DingTalkChannel(config)
+
+ run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/EvoScientist/channels/discord/__init__.py b/EvoScientist/channels/discord/__init__.py
new file mode 100644
index 0000000..dfc7740
--- /dev/null
+++ b/EvoScientist/channels/discord/__init__.py
@@ -0,0 +1,19 @@
+from .channel import DiscordChannel, DiscordConfig
+from ..channel_manager import register_channel, _parse_csv
+
+__all__ = ["DiscordChannel", "DiscordConfig"]
+
+
+def create_from_config(config) -> DiscordChannel:
+ allowed = _parse_csv(config.discord_allowed_senders)
+ channels = _parse_csv(config.discord_allowed_channels)
+ proxy = config.discord_proxy if config.discord_proxy else None
+ return DiscordChannel(DiscordConfig(
+ bot_token=config.discord_bot_token,
+ allowed_senders=allowed,
+ allowed_channels=channels,
+ proxy=proxy,
+ ))
+
+
+register_channel("discord", create_from_config)
diff --git a/EvoScientist/channels/discord/channel.py b/EvoScientist/channels/discord/channel.py
new file mode 100644
index 0000000..3ef69c9
--- /dev/null
+++ b/EvoScientist/channels/discord/channel.py
@@ -0,0 +1,255 @@
+"""Discord channel implementation using discord.py."""
+
+import asyncio
+import logging
+import os
+from dataclasses import dataclass
+from datetime import datetime
+
+from ..base import Channel, RawIncoming, ChannelError
+from ..capabilities import DISCORD as DISCORD_CAPS
+from ..config import BaseChannelConfig
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class DiscordConfig(BaseChannelConfig):
+ bot_token: str = ""
+ text_chunk_limit: int = 4096
+
+
+class DiscordChannel(Channel):
+ """Discord channel using discord.py."""
+
+ name = "discord"
+
+ capabilities = DISCORD_CAPS
+ _typing_interval: float = 8.0
+ _ready_attrs = ("_client",)
+ _mention_pattern = r"<@!?{bot_id}>\s*"
+
+ def __init__(self, config: DiscordConfig):
+ super().__init__(config)
+ self._client = None
+ self._ready = asyncio.Event()
+ # Cache message objects for ACK reactions
+ self._message_cache: dict[str, object] = {}
+ self._MESSAGE_CACHE_MAX = 200
+
+ async def start(self) -> None:
+ try:
+ import discord
+ except ImportError:
+ raise ChannelError(
+ "discord.py not installed. "
+ "Install with: pip install evoscientist[discord]"
+ )
+
+ if not self.config.bot_token:
+ raise ChannelError("Discord bot token is required")
+
+ proxy = (
+ self.config.proxy
+ or os.environ.get("https_proxy")
+ or os.environ.get("HTTPS_PROXY")
+ or os.environ.get("http_proxy")
+ or os.environ.get("HTTP_PROXY")
+ or None
+ )
+
+ logger.info(
+ "Discord connect: token=%s...%s proxy=%s",
+ self.config.bot_token[:8],
+ self.config.bot_token[-4:],
+ proxy or "(none)",
+ )
+
+ intents = discord.Intents.default()
+ intents.message_content = True
+ client_kwargs = {"intents": intents}
+ if proxy:
+ client_kwargs["proxy"] = proxy
+ self._client = discord.Client(**client_kwargs)
+
+ self._start_task_error: BaseException | None = None
+
+ @self._client.event
+ async def on_ready():
+ logger.info(f"Discord bot ready: {self._client.user}")
+ self._ready.set()
+
+ @self._client.event
+ async def on_message(message):
+ await self._on_message(message)
+
+ async def _guarded_start():
+ try:
+ logger.info("Discord gateway: starting client.start()...")
+ await self._client.start(self.config.bot_token)
+ except Exception as exc:
+ logger.error("Discord gateway error: %s: %s", type(exc).__name__, exc)
+ self._start_task_error = exc
+ self._ready.set() # unblock the waiter so it doesn't hang
+
+ logger.info("Discord connect: launching gateway task")
+ asyncio.create_task(_guarded_start())
+
+ try:
+ await asyncio.wait_for(self._ready.wait(), timeout=60)
+ except asyncio.TimeoutError:
+ raise ChannelError(
+ "Discord bot failed to connect within 60s. "
+ "Check network/proxy connectivity to gateway.discord.gg"
+ )
+
+ if self._start_task_error:
+ raise ChannelError(
+ f"Discord bot failed to connect: {self._start_task_error}"
+ )
+
+ self._running = True
+ logger.info("Discord channel started")
+
+ async def _cleanup(self) -> None:
+ if self._client:
+ await self._client.close()
+ logger.info("Discord channel stopped")
+
+ # ── Typing indicator ────────────────────────────────────────────
+
+ async def _send_typing_action(self, chat_id: str) -> None:
+ if not self._client:
+ return
+ ch = self._client.get_channel(int(chat_id))
+ if ch:
+ await ch.trigger_typing()
+
+ # ── ACK Reactions ───────────────────────────────────────────────
+
+ async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None:
+ msg = self._message_cache.get(message_id)
+ if msg:
+ try:
+ await msg.add_reaction(emoji)
+ except Exception as e:
+ logger.debug(f"Discord ACK reaction failed: {e}")
+
+ async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None:
+ msg = self._message_cache.get(message_id)
+ if msg and self._client and self._client.user:
+ try:
+ await msg.remove_reaction(emoji, self._client.user)
+ except Exception as e:
+ logger.debug(f"Discord remove ACK reaction failed: {e}")
+
+ def _cache_message(self, message) -> None:
+ """Cache a discord message object for later reaction use."""
+ mid = str(message.id)
+ self._message_cache[mid] = message
+ # Evict oldest entries if cache is too large
+ if len(self._message_cache) > self._MESSAGE_CACHE_MAX:
+ oldest = list(self._message_cache.keys())[: self._MESSAGE_CACHE_MAX // 2]
+ for k in oldest:
+ self._message_cache.pop(k, None)
+
+ # ── Send ────────────────────────────────────────────────────────
+
+
+ async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
+ import discord
+
+ thread_id = (metadata or {}).get("thread_id", "")
+ target_id = int(thread_id) if thread_id else int(chat_id)
+ ch = self._client.get_channel(target_id)
+ if not ch:
+ raise RuntimeError(f"Discord channel {target_id} not found")
+ ref = None
+ if reply_to:
+ try:
+ ref = discord.MessageReference(
+ message_id=int(reply_to), channel_id=target_id,
+ )
+ except (ValueError, TypeError):
+ pass
+
+ async def _send(text):
+ await ch.send(text, reference=ref)
+
+ await self._send_with_format_fallback(_send, formatted_text, raw_text)
+
+ async def _send_media_impl(
+ self, recipient: str, file_path: str,
+ caption: str = "", metadata: dict | None = None,
+ ) -> bool:
+ import discord
+
+ channel_id = self._resolve_media_chat_id(recipient, metadata)
+ ch = self._client.get_channel(int(channel_id))
+ if not ch:
+ logger.error(f"Discord channel {channel_id} not found")
+ return False
+ file = discord.File(file_path)
+ await ch.send(content=caption or None, file=file)
+ return True
+
+ def _get_bot_identifier(self) -> str | None:
+ if self._client and self._client.user:
+ return str(self._client.user.id)
+ return None
+
+ # ── Inbound ─────────────────────────────────────────────────────
+
+ async def _on_message(self, message) -> None:
+ import discord
+
+ if message.author == self._client.user:
+ return
+
+ # Cache for ACK reactions
+ self._cache_message(message)
+
+ user_id = str(message.author.id)
+ channel_id = str(message.channel.id)
+
+ is_dm = isinstance(message.channel, discord.DMChannel)
+ was_mentioned = is_dm or (self._client.user in message.mentions)
+
+ text = message.content or ""
+ annotations: list[str] = []
+ media_paths: list[str] = []
+
+ if self.config.include_attachments and message.attachments:
+ for attachment in message.attachments:
+ too_large = self._check_attachment_size(
+ attachment.size or 0, attachment.filename,
+ )
+ if too_large:
+ annotations.append(too_large)
+ continue
+ try:
+ safe_name = attachment.filename.replace("/", "_")
+ file_path = self._media_path(f"{attachment.id}_{safe_name}")
+ await attachment.save(file_path)
+ media_paths.append(str(file_path))
+ annotations.append(f"[attachment: {file_path}]")
+ except Exception as e:
+ logger.warning(f"Failed to download Discord attachment: {e}")
+ annotations.append(f"[attachment: {attachment.filename} - download failed]")
+
+ # Detect thread context
+ thread_id = ""
+ parent_channel_id = channel_id
+ if hasattr(message.channel, "parent") and message.channel.parent:
+ # Message is inside a Thread — store thread info
+ thread_id = channel_id # the thread IS the channel
+ parent_channel_id = str(message.channel.parent.id)
+
+ await self._enqueue_raw(RawIncoming(
+ sender_id=user_id, chat_id=parent_channel_id, text=text,
+ media_files=media_paths, content_annotations=annotations,
+ timestamp=message.created_at or datetime.now(),
+ message_id=str(message.id),
+ metadata={"chat_id": parent_channel_id, "thread_id": thread_id},
+ is_group=not is_dm, was_mentioned=was_mentioned,
+ ))
diff --git a/EvoScientist/channels/discord/probe.py b/EvoScientist/channels/discord/probe.py
new file mode 100644
index 0000000..9fbc4c0
--- /dev/null
+++ b/EvoScientist/channels/discord/probe.py
@@ -0,0 +1,33 @@
+"""Discord bot token validation."""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+async def validate_discord_token(token: str, proxy: str | None = None) -> tuple[bool, str]:
+ """Validate a Discord bot token via the REST API.
+
+ Returns:
+ Tuple of (is_valid, message).
+ """
+ if not token:
+ return False, "No token provided"
+
+ try:
+ import httpx
+ except ImportError:
+ return False, "httpx not installed"
+
+ url = "https://discord.com/api/v10/users/@me"
+ headers = {"Authorization": f"Bot {token}"}
+ try:
+ async with httpx.AsyncClient(proxy=proxy) as client:
+ resp = await client.get(url, headers=headers, timeout=10)
+ if resp.status_code == 200:
+ data = resp.json()
+ username = data.get("username", "unknown")
+ return True, f"Bot: {username}"
+ return False, "Invalid token"
+ except Exception as e:
+ return False, f"Error: {e}"
diff --git a/EvoScientist/channels/discord/serve.py b/EvoScientist/channels/discord/serve.py
new file mode 100644
index 0000000..9e8f000
--- /dev/null
+++ b/EvoScientist/channels/discord/serve.py
@@ -0,0 +1,93 @@
+"""Discord channel server.
+
+Standalone script to run the Discord channel with CLI options.
+
+Usage:
+ python -m EvoScientist.channels.discord.serve --bot-token TOKEN [OPTIONS]
+
+Examples:
+ # Allow all senders (default)
+ python -m EvoScientist.channels.discord.serve --bot-token TOKEN
+
+ # Only allow specific senders and channels
+ python -m EvoScientist.channels.discord.serve --bot-token TOKEN --allow 123 --allow-channel 456
+
+ # With proxy, agent and thinking
+ python -m EvoScientist.channels.discord.serve --bot-token TOKEN --proxy http://proxy:8080 --agent --thinking
+"""
+
+import argparse
+import logging
+
+from .channel import DiscordChannel, DiscordConfig
+from ..bus import MessageBus
+from ..standalone import run_standalone
+
+logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger(__name__)
+
+
+def parse_args():
+ """Parse command line arguments."""
+ parser = argparse.ArgumentParser(
+ description="Discord channel server",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "--bot-token",
+ required=True,
+ help="Discord bot token",
+ )
+ parser.add_argument(
+ "--allow",
+ action="append",
+ dest="allowed_senders",
+ help="Allowed sender (Discord user ID). Can be used multiple times.",
+ )
+ parser.add_argument(
+ "--allow-channel",
+ action="append",
+ dest="allowed_channels",
+ help="Allowed channel ID. Can be used multiple times.",
+ )
+ parser.add_argument(
+ "--proxy",
+ help="HTTP proxy URL for Discord API requests",
+ )
+ parser.add_argument(
+ "--agent",
+ action="store_true",
+ help="Use EvoScientist agent as handler (default: echo)",
+ )
+ parser.add_argument(
+ "--thinking",
+ action="store_true",
+ help="Send thinking content as intermediate messages (requires --agent)",
+ )
+ return parser.parse_args()
+
+
+def main():
+ """Entry point."""
+ args = parse_args()
+
+ config = DiscordConfig(
+ bot_token=args.bot_token,
+ allowed_senders=set(args.allowed_senders) if args.allowed_senders else None,
+ allowed_channels=set(args.allowed_channels) if args.allowed_channels else None,
+ proxy=args.proxy,
+ )
+
+ send_thinking = args.thinking and args.agent
+ bus = MessageBus()
+ channel = DiscordChannel(config)
+
+ run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/EvoScientist/channels/email/__init__.py b/EvoScientist/channels/email/__init__.py
new file mode 100644
index 0000000..56bd5f9
--- /dev/null
+++ b/EvoScientist/channels/email/__init__.py
@@ -0,0 +1,41 @@
+"""Email channel for EvoScientist.
+
+Uses IMAP polling for inbound + SMTP for outbound. Pure Python, no extra deps.
+
+Usage in config:
+ channel_enabled = "email"
+ email_imap_host = "imap.gmail.com"
+ email_smtp_host = "smtp.gmail.com"
+ ...
+"""
+
+from .channel import EmailChannel, EmailConfig
+from ..channel_manager import register_channel, _parse_csv
+
+__all__ = ["EmailChannel", "EmailConfig"]
+
+
+def create_from_config(config) -> EmailChannel:
+ allowed = _parse_csv(getattr(config, "email_allowed_senders", ""))
+ return EmailChannel(EmailConfig(
+ imap_host=getattr(config, "email_imap_host", ""),
+ imap_port=int(getattr(config, "email_imap_port", 993)),
+ imap_username=getattr(config, "email_imap_username", ""),
+ imap_password=getattr(config, "email_imap_password", ""),
+ imap_mailbox=getattr(config, "email_imap_mailbox", "INBOX"),
+ imap_use_ssl=getattr(config, "email_imap_use_ssl", True),
+ smtp_host=getattr(config, "email_smtp_host", ""),
+ smtp_port=int(getattr(config, "email_smtp_port", 587)),
+ smtp_username=getattr(config, "email_smtp_username", ""),
+ smtp_password=getattr(config, "email_smtp_password", ""),
+ smtp_use_tls=getattr(config, "email_smtp_use_tls", True),
+ from_address=getattr(config, "email_from_address", ""),
+ poll_interval=int(getattr(config, "email_poll_interval", 30)),
+ mark_seen=getattr(config, "email_mark_seen", True),
+ max_body_chars=int(getattr(config, "email_max_body_chars", 12000)),
+ subject_prefix=getattr(config, "email_subject_prefix", "Re: "),
+ allowed_senders=allowed,
+ ))
+
+
+register_channel("email", create_from_config)
diff --git a/EvoScientist/channels/email/channel.py b/EvoScientist/channels/email/channel.py
new file mode 100644
index 0000000..2c2a2da
--- /dev/null
+++ b/EvoScientist/channels/email/channel.py
@@ -0,0 +1,372 @@
+"""Email channel — refactored with PollingMixin."""
+
+import asyncio
+import email as email_lib
+import email.utils
+import html
+import imaplib
+import logging
+import re
+import smtplib
+import ssl
+from dataclasses import dataclass
+from datetime import datetime
+from email.header import decode_header, make_header
+from email.message import EmailMessage
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.mime.base import MIMEBase
+from email import encoders
+from email.utils import parseaddr
+from pathlib import Path
+
+from ..base import Channel, RawIncoming, ChannelError
+from ..capabilities import EMAIL as EMAIL_CAPS
+from ..mixins import PollingMixin
+from ..config import BaseChannelConfig
+
+logger = logging.getLogger(__name__)
+
+
+def _decode_hdr(raw: str) -> str:
+ try:
+ return str(make_header(decode_header(raw))) if raw else ""
+ except Exception:
+ return raw or ""
+
+
+def _strip_html(text: str) -> str:
+ text = re.sub(r"
", "\n", text, flags=re.I)
+ text = re.sub(r"
]*>", "\n", text, flags=re.I) + text = re.sub(r"
", "\n", text, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + return html.unescape(text).strip() + + + + + + + +@dataclass +class EmailConfig(BaseChannelConfig): + imap_host: str = "" + imap_port: int = 993 + imap_username: str = "" + imap_password: str = "" + imap_mailbox: str = "INBOX" + imap_use_ssl: bool = True + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + smtp_use_tls: bool = True + from_address: str = "" + poll_interval: int = 30 + mark_seen: bool = True + max_body_chars: int = 12000 + subject_prefix: str = "Re: " + allowed_senders: set[str] | None = None + text_chunk_limit: int = 4096 + + +class EmailChannel(Channel, PollingMixin): + capabilities = EMAIL_CAPS + name = "email" + _non_retryable_patterns = ("auth", "login", "credential") + + def __init__(self, config: EmailConfig): + super().__init__(config) + self._imap: imaplib.IMAP4_SSL | imaplib.IMAP4 | None = None + + async def start(self) -> None: + cfg = self.config + if not cfg.imap_host or not cfg.imap_username: + raise ChannelError("Email imap_host and imap_username are required") + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._connect_imap) + self._running = True + logger.info(f"Email channel started (IMAP: {cfg.imap_host}, poll {cfg.poll_interval}s)") + await self._start_polling() + + def _connect_imap(self) -> None: + cfg = self.config + try: + if cfg.imap_use_ssl: + self._imap = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port, ssl_context=ssl.create_default_context()) + else: + self._imap = imaplib.IMAP4(cfg.imap_host, cfg.imap_port) + self._imap.login(cfg.imap_username, cfg.imap_password) + self._imap.select(cfg.imap_mailbox) + except Exception as e: + raise ChannelError(f"IMAP failed: {e}") + + def _reconnect_imap(self) -> None: + try: + if self._imap: + self._imap.noop() + return + except Exception: + pass + self._connect_imap() + + async def _poll_once(self) -> None: + loop = asyncio.get_event_loop() + messages = await loop.run_in_executor(None, self._fetch_unseen) + for m in messages: + await self._process_email(m) + + def _fetch_unseen(self) -> list[dict]: + self._reconnect_imap() + results = [] + try: + st, data = self._imap.search(None, "UNSEEN") + if st != "OK": + return [] + for mid in data[0].split()[-20:]: + st, msg_data = self._imap.fetch(mid, "(RFC822)") + if st != "OK": + continue + msg = email_lib.message_from_bytes(msg_data[0][1]) + from_name, from_addr = parseaddr(msg.get("From", "")) + body = self._extract_body(msg) + if len(body) > self.config.max_body_chars: + body = body[:self.config.max_body_chars] + "\n[...truncated]" + # Extract attachments and inline images + attachments = [] + if msg.is_multipart(): + for part in msg.walk(): + content_disp = part.get("Content-Disposition") or "" + content_type = part.get_content_type() or "" + is_attachment = "attachment" in content_disp.lower() + is_inline_image = ( + "inline" in content_disp.lower() + and content_type.startswith("image/") + ) + # Also detect non-text parts with a filename but no + # Content-Disposition header (common for PDFs, docs, + # etc. sent by some email clients). + is_named_file = ( + not is_attachment + and not is_inline_image + and part.get_filename() + and not content_type.startswith("multipart/") + and not content_type.startswith("text/") + ) + if is_attachment or is_inline_image or is_named_file: + filename = part.get_filename() or "attachment" + filename = _decode_hdr(filename) + payload_data = part.get_payload(decode=True) + if payload_data: + from ..base import MAX_ATTACHMENT_BYTES, MEDIA_DIR + if len(payload_data) > MAX_ATTACHMENT_BYTES: + attachments.append({"annotation": f"[attachment: {filename} - too large ({len(payload_data)} bytes)]"}) + else: + MEDIA_DIR.mkdir(parents=True, exist_ok=True) + local_path = MEDIA_DIR / f"email_{mid.decode()}_{filename}" + local_path.write_bytes(payload_data) + label = "inline-image" if is_inline_image else "attachment" + attachments.append({"path": str(local_path), "annotation": f"[{label}: {local_path}]"}) + if self.config.mark_seen: + self._imap.store(mid, "+FLAGS", "\\Seen") + results.append({ + "from_addr": from_addr, "from_name": _decode_hdr(from_name), + "subject": _decode_hdr(msg.get("Subject", "")), "body": body, + "message_id": msg.get("Message-ID", ""), "date": msg.get("Date", ""), + "references": msg.get("References", ""), "attachments": attachments, + }) + except Exception as e: + logger.error(f"IMAP fetch: {e}") + return results + + def _extract_body(self, msg) -> str: + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + if ct == "text/plain": + return self._decode_payload(part) + for part in msg.walk(): + if part.get_content_type() == "text/html": + return _strip_html(self._decode_payload(part)) + return "[no text content]" + text = self._decode_payload(msg) + return _strip_html(text) if msg.get_content_type() == "text/html" else text + + @staticmethod + def _decode_payload(part) -> str: + payload = part.get_payload(decode=True) + if not payload: + return "" + charset = part.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + + async def _process_email(self, m: dict) -> None: + subject = m["subject"] + text = f"[邮件] 主题: {subject}\n\n{m['body']}" if subject else m["body"] + try: + ts = email_lib.utils.parsedate_to_datetime(m["date"]) + except Exception: + ts = datetime.now() + # Process attachments + media_paths: list[str] = [] + annotations: list[str] = [] + for att in m.get("attachments", []): + if att.get("path"): + media_paths.append(att["path"]) + if att.get("annotation"): + annotations.append(att["annotation"]) + await self._enqueue_raw(RawIncoming( + sender_id=m["from_addr"], chat_id=m["from_addr"], text=text, timestamp=ts, + message_id=m["message_id"], + media_files=media_paths, + content_annotations=annotations, + metadata={"chat_id": m["from_addr"], "subject": subject, + "original_message_id": m["message_id"], "references": m["references"], "backend": "email"}, + )) + + # ── Send ────────────────────────────────────────────────────── + + def _is_ready(self) -> bool: + return bool(self.config.smtp_host) + + async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): + loop = asyncio.get_event_loop() + try: + await loop.run_in_executor( + None, self._smtp_send_html, chat_id, formatted_text, raw_text, metadata or {}, + ) + except Exception as e: + err_str = str(e).lower() + # Only fall back to plain text for format-related errors, not server rejections + if any(code in err_str for code in ("550", "553", "554", "auth", "rejected")): + raise + logger.warning(f"HTML email failed ({e}), falling back to plain text") + await loop.run_in_executor( + None, self._smtp_send, chat_id, raw_text, metadata or {}, + ) + + def _smtp_send(self, to: str, content: str, meta: dict) -> None: + cfg = self.config + from_addr = cfg.from_address or cfg.smtp_username + logger.debug(f"SMTP plain send: from={from_addr} to={to}") + msg = EmailMessage() + orig_subj = meta.get("subject", "") + msg["Subject"] = f"{cfg.subject_prefix}{orig_subj}" if orig_subj and not orig_subj.lower().startswith("re:") else (orig_subj or "EvoScientist Reply") + msg["From"] = from_addr + msg["To"] = to + orig_id = meta.get("original_message_id", "") + if orig_id: + msg["In-Reply-To"] = orig_id + msg["References"] = f"{meta.get('references', '')} {orig_id}".strip() + msg.set_content(content) + try: + if cfg.smtp_use_tls: + srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) + srv.starttls() + else: + srv = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=ssl.create_default_context(), timeout=30) + srv.login(cfg.smtp_username, cfg.smtp_password) + srv.sendmail(from_addr, [to], msg.as_string()) + srv.quit() + except Exception as e: + logger.error(f"SMTP send failed: from={from_addr} to={to} error={e}") + raise RuntimeError(f"SMTP: {e}") + + def _smtp_send_html(self, to: str, html_content: str, plain_content: str, meta: dict) -> None: + """Send an email with both HTML and plain-text parts.""" + cfg = self.config + from_addr = cfg.from_address or cfg.smtp_username + logger.debug(f"SMTP HTML send: from={from_addr} to={to}") + msg = MIMEMultipart("alternative") + orig_subj = meta.get("subject", "") + msg["Subject"] = f"{cfg.subject_prefix}{orig_subj}" if orig_subj and not orig_subj.lower().startswith("re:") else (orig_subj or "EvoScientist Reply") + msg["From"] = from_addr + msg["To"] = to + orig_id = meta.get("original_message_id", "") + if orig_id: + msg["In-Reply-To"] = orig_id + msg["References"] = f"{meta.get('references', '')} {orig_id}".strip() + msg.attach(MIMEText(plain_content, "plain", "utf-8")) + msg.attach(MIMEText(html_content, "html", "utf-8")) + try: + if cfg.smtp_use_tls: + srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) + srv.starttls() + else: + srv = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=ssl.create_default_context(), timeout=30) + srv.login(cfg.smtp_username, cfg.smtp_password) + srv.sendmail(from_addr, [to], msg.as_string()) + srv.quit() + except Exception as e: + logger.error(f"SMTP HTML send failed: from={from_addr} to={to} error={e}") + raise RuntimeError(f"SMTP HTML: {e}") + + # ── Markdown to HTML formatting ─────────────────────────────── + + + # ── Media send (email attachment) ───────────────────────────── + + async def _send_media_impl( + self, + recipient: str, + file_path: str, + caption: str = "", + metadata: dict | None = None, + ) -> bool: + """Send a file as an email attachment via SMTP.""" + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, self._smtp_send_attachment, recipient, file_path, caption, metadata or {}, + ) + return True + + def _smtp_send_attachment(self, to: str, file_path: str, caption: str, meta: dict) -> None: + """Send an email with a file attachment.""" + cfg = self.config + from_addr = cfg.from_address or cfg.smtp_username + logger.debug(f"SMTP attachment send: from={from_addr} to={to} file={file_path}") + msg = MIMEMultipart() + orig_subj = meta.get("subject", "") + msg["Subject"] = f"{cfg.subject_prefix}{orig_subj}" if orig_subj and not orig_subj.lower().startswith("re:") else (orig_subj or "EvoScientist Reply") + msg["From"] = from_addr + msg["To"] = to + orig_id = meta.get("original_message_id", "") + if orig_id: + msg["In-Reply-To"] = orig_id + msg["References"] = f"{meta.get('references', '')} {orig_id}".strip() + + # Text body + if caption: + msg.attach(MIMEText(caption, "plain", "utf-8")) + + # Attachment + path = Path(file_path) + part = MIMEBase("application", "octet-stream") + part.set_payload(path.read_bytes()) + encoders.encode_base64(part) + part.add_header("Content-Disposition", f"attachment; filename={path.name}") + msg.attach(part) + + try: + if cfg.smtp_use_tls: + srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) + srv.starttls() + else: + srv = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=ssl.create_default_context(), timeout=30) + srv.login(cfg.smtp_username, cfg.smtp_password) + srv.sendmail(from_addr, [to], msg.as_string()) + srv.quit() + except Exception as e: + logger.error(f"SMTP attachment send failed: from={from_addr} to={to} error={e}") + raise RuntimeError(f"SMTP attachment: {e}") + + async def _cleanup(self) -> None: + await self._stop_polling() + if self._imap: + try: + self._imap.close() + self._imap.logout() + except Exception: + pass + self._imap = None + logger.info("Email channel stopped") diff --git a/EvoScientist/channels/email/probe.py b/EvoScientist/channels/email/probe.py new file mode 100644 index 0000000..84c6c49 --- /dev/null +++ b/EvoScientist/channels/email/probe.py @@ -0,0 +1,67 @@ +"""Email credential validation.""" + +import imaplib +import smtplib +import ssl +import logging + +logger = logging.getLogger(__name__) + + +async def validate_email_imap( + host: str, port: int, username: str, password: str, + use_ssl: bool = True, +) -> tuple[bool, str]: + """Validate IMAP credentials.""" + if not host or not username or not password: + return False, "host, username, and password are required" + + import asyncio + loop = asyncio.get_event_loop() + + def _check(): + try: + if use_ssl: + ctx = ssl.create_default_context() + conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) + else: + conn = imaplib.IMAP4(host, port) + conn.login(username, password) + conn.logout() + return True, "IMAP credentials valid" + except imaplib.IMAP4.error as e: + return False, f"IMAP auth failed: {e}" + except Exception as e: + return False, f"IMAP error: {e}" + + return await loop.run_in_executor(None, _check) + + +async def validate_email_smtp( + host: str, port: int, username: str, password: str, + use_tls: bool = True, +) -> tuple[bool, str]: + """Validate SMTP credentials.""" + if not host or not username or not password: + return False, "host, username, and password are required" + + import asyncio + loop = asyncio.get_event_loop() + + def _check(): + try: + if use_tls: + server = smtplib.SMTP(host, port, timeout=10) + server.starttls() + else: + ctx = ssl.create_default_context() + server = smtplib.SMTP_SSL(host, port, context=ctx, timeout=10) + server.login(username, password) + server.quit() + return True, "SMTP credentials valid" + except smtplib.SMTPAuthenticationError as e: + return False, f"SMTP auth failed: {e}" + except Exception as e: + return False, f"SMTP error: {e}" + + return await loop.run_in_executor(None, _check) diff --git a/EvoScientist/channels/email/serve.py b/EvoScientist/channels/email/serve.py new file mode 100644 index 0000000..c014dca --- /dev/null +++ b/EvoScientist/channels/email/serve.py @@ -0,0 +1,124 @@ +"""Email channel server. + +Standalone script to run the Email channel with CLI options. + +Usage: + python -m EvoScientist.channels.email.serve --imap-host HOST --imap-username USER --imap-password PASS --smtp-host HOST --smtp-username USER --smtp-password PASS --from-address ADDR [OPTIONS] + +Examples: + # Basic usage + python -m EvoScientist.channels.email.serve --imap-host imap.gmail.com --imap-username bot@gmail.com --imap-password PASS --smtp-host smtp.gmail.com --smtp-username bot@gmail.com --smtp-password PASS --from-address bot@gmail.com + + # With allowed senders and custom poll interval + python -m EvoScientist.channels.email.serve --imap-host imap.gmail.com --imap-username bot@gmail.com --imap-password PASS --smtp-host smtp.gmail.com --smtp-username bot@gmail.com --smtp-password PASS --from-address bot@gmail.com --allow user@example.com --poll-interval 60 + + # With agent and thinking + python -m EvoScientist.channels.email.serve --imap-host imap.gmail.com --imap-username bot@gmail.com --imap-password PASS --smtp-host smtp.gmail.com --smtp-username bot@gmail.com --smtp-password PASS --from-address bot@gmail.com --agent --thinking +""" + +import argparse +import logging + +from .channel import EmailChannel, EmailConfig +from ..bus import MessageBus +from ..standalone import run_standalone + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger(__name__) + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Email channel server", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--imap-host", + required=True, + help="IMAP server hostname", + ) + parser.add_argument( + "--imap-username", + required=True, + help="IMAP username", + ) + parser.add_argument( + "--imap-password", + required=True, + help="IMAP password", + ) + parser.add_argument( + "--smtp-host", + required=True, + help="SMTP server hostname", + ) + parser.add_argument( + "--smtp-username", + required=True, + help="SMTP username", + ) + parser.add_argument( + "--smtp-password", + required=True, + help="SMTP password", + ) + parser.add_argument( + "--from-address", + required=True, + help="From email address for outgoing messages", + ) + parser.add_argument( + "--allow", + action="append", + dest="allowed_senders", + help="Allowed sender (email address). Can be used multiple times.", + ) + parser.add_argument( + "--poll-interval", + type=int, + default=30, + help="IMAP poll interval in seconds (default: 30)", + ) + parser.add_argument( + "--agent", + action="store_true", + help="Use EvoScientist agent as handler (default: echo)", + ) + parser.add_argument( + "--thinking", + action="store_true", + help="Send thinking content as intermediate messages (requires --agent)", + ) + return parser.parse_args() + + +def main(): + """Entry point.""" + args = parse_args() + + config = EmailConfig( + imap_host=args.imap_host, + imap_username=args.imap_username, + imap_password=args.imap_password, + smtp_host=args.smtp_host, + smtp_username=args.smtp_username, + smtp_password=args.smtp_password, + from_address=args.from_address, + allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, + poll_interval=args.poll_interval, + ) + + send_thinking = args.thinking and args.agent + bus = MessageBus() + channel = EmailChannel(config) + + run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) + + +if __name__ == "__main__": + main() diff --git a/EvoScientist/channels/feishu/__init__.py b/EvoScientist/channels/feishu/__init__.py new file mode 100644 index 0000000..4cc68e5 --- /dev/null +++ b/EvoScientist/channels/feishu/__init__.py @@ -0,0 +1,21 @@ +from .channel import FeishuChannel, FeishuConfig +from ..channel_manager import register_channel, _parse_csv + +__all__ = ["FeishuChannel", "FeishuConfig"] + + +def create_from_config(config) -> FeishuChannel: + allowed = _parse_csv(config.feishu_allowed_senders) + return FeishuChannel(FeishuConfig( + app_id=config.feishu_app_id, + app_secret=config.feishu_app_secret, + verification_token=config.feishu_verification_token, + encrypt_key=config.feishu_encrypt_key, + webhook_port=config.feishu_webhook_port, + allowed_senders=allowed, + feishu_domain=config.feishu_domain, + proxy=getattr(config, 'feishu_proxy', '') or None, + )) + + +register_channel("feishu", create_from_config) diff --git a/EvoScientist/channels/feishu/channel.py b/EvoScientist/channels/feishu/channel.py new file mode 100644 index 0000000..e444be9 --- /dev/null +++ b/EvoScientist/channels/feishu/channel.py @@ -0,0 +1,780 @@ +"""Feishu (飞书/Lark) channel implementation. + +Receives messages via an HTTP event subscription webhook (aiohttp), +sends replies via Feishu Open API REST endpoints. + +Feishu Open API docs: https://open.feishu.cn/document + +Authentication: + - App ID + App Secret → tenant_access_token (2-hour TTL, auto-refreshed) + +Event subscription: + - URL verification challenge on first request + - ``im.message.receive_v1`` events for incoming messages + +Send API: + - ``POST /open-apis/im/v1/messages?receive_id_type=chat_id`` +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any, TYPE_CHECKING +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +if TYPE_CHECKING: + from aiohttp import web + +from ..base import Channel, RawIncoming, ChannelError +from ..capabilities import FEISHU as FEISHU_CAPS +from ..mixins import WebhookMixin, TokenMixin +from ..config import BaseChannelConfig + +logger = logging.getLogger(__name__) + + +# ── Markdown → Feishu Post conversion ──────────────────────────── + + +def _parse_inline_text(text: str) -> list[dict]: + """Parse inline Markdown elements into Feishu post tag dicts. + + Handles: `code`, **bold**, ~~strikethrough~~, [link](url), _italic_. + """ + elements: list[dict] = [] + # Pattern order matters: code first (protect content), then bold, strikethrough, link, italic + pattern = re.compile( + r"`([^`]+)`" # inline code + r"|\*\*(.+?)\*\*" # bold + r"|~~(.+?)~~" # strikethrough + r"|\[([^\]]+)\]\(([^)]+)\)" # link + r"|_(.+?)_" # italic + ) + pos = 0 + for m in pattern.finditer(text): + # Plain text before this match + if m.start() > pos: + elements.append({"tag": "text", "text": text[pos:m.start()]}) + + if m.group(1) is not None: + # inline code → code_block would be block-level; use text with style + elements.append({ + "tag": "text", "text": m.group(1), + "style": ["code_block"], + }) + elif m.group(2) is not None: + elements.append({ + "tag": "text", "text": m.group(2), + "style": ["bold"], + }) + elif m.group(3) is not None: + elements.append({ + "tag": "text", "text": m.group(3), + "style": ["strikethrough"], + }) + elif m.group(4) is not None: + elements.append({ + "tag": "a", "text": m.group(4), "href": m.group(5), + }) + elif m.group(6) is not None: + elements.append({ + "tag": "text", "text": m.group(6), + "style": ["italic"], + }) + pos = m.end() + + # Remaining plain text + if pos < len(text): + elements.append({"tag": "text", "text": text[pos:]}) + return elements + + +def _parse_inline_elements(line: str) -> list[dict]: + """Parse a single Markdown line into a list of Feishu post elements. + + Handles headings (→ bold), blockquotes (→ italic with prefix), + list items (→ bullet prefix), and plain lines. + """ + # Heading: # Title → bold text + heading_match = re.match(r"^(#{1,6})\s+(.+)$", line) + if heading_match: + return [{"tag": "text", "text": heading_match.group(2), "style": ["bold"]}] + + # Blockquote: > text → italic with "▎" prefix + quote_match = re.match(r"^>\s*(.*)$", line) + if quote_match: + inner = quote_match.group(1) + elements = [{"tag": "text", "text": "▎", "style": ["italic"]}] + elements.extend(_parse_inline_text(inner)) + return elements + + # Unordered list: - item or * item → "• " prefix + list_match = re.match(r"^[\-\*]\s+(.+)$", line) + if list_match: + elements = [{"tag": "text", "text": "• "}] + elements.extend(_parse_inline_text(list_match.group(1))) + return elements + + # Ordered list: 1. item → keep number prefix + ol_match = re.match(r"^(\d+)\.\s+(.+)$", line) + if ol_match: + elements = [{"tag": "text", "text": f"{ol_match.group(1)}. "}] + elements.extend(_parse_inline_text(ol_match.group(2))) + return elements + + # Plain line + return _parse_inline_text(line) + + +def _markdown_to_feishu_post(text: str) -> dict | None: + """Convert Markdown text to Feishu post (rich text) JSON structure. + + Returns a dict like {"zh_cn": {"content": [[...]]}} suitable for + Feishu msg_type="post", or None if the text is empty. + """ + if not text or not text.strip(): + return None + + paragraphs: list[list[dict]] = [] + current_paragraph: list[dict] = [] + in_code_block = False + code_lines: list[str] = [] + code_lang = "" + + for line in text.split("\n"): + # Code block fences + if line.startswith("```"): + if not in_code_block: + # Flush any pending paragraph + if current_paragraph: + paragraphs.append(current_paragraph) + current_paragraph = [] + in_code_block = True + code_lang = line[3:].strip() + code_lines = [] + else: + # End of code block + code_text = "\n".join(code_lines) + paragraphs.append([{ + "tag": "code_block", + "language": code_lang or "plain", + "text": code_text, + }]) + in_code_block = False + code_lines = [] + code_lang = "" + continue + + if in_code_block: + code_lines.append(line) + continue + + # Empty line → new paragraph + if not line.strip(): + if current_paragraph: + paragraphs.append(current_paragraph) + current_paragraph = [] + continue + + # Non-empty line + elements = _parse_inline_elements(line) + if elements: + # Each visual line becomes its own paragraph in Feishu post + if current_paragraph: + paragraphs.append(current_paragraph) + current_paragraph = elements + + # Flush remaining + if in_code_block and code_lines: + code_text = "\n".join(code_lines) + paragraphs.append([{ + "tag": "code_block", + "language": code_lang or "plain", + "text": code_text, + }]) + elif current_paragraph: + paragraphs.append(current_paragraph) + + if not paragraphs: + return None + + return {"zh_cn": {"content": paragraphs}} + + +@dataclass +class FeishuConfig(BaseChannelConfig): + app_id: str = "" + app_secret: str = "" + verification_token: str = "" + encrypt_key: str = "" + webhook_port: int = 9000 + text_chunk_limit: int = 4096 + feishu_domain: str = "https://open.feishu.cn" + + +class FeishuChannel(Channel, WebhookMixin, TokenMixin): + capabilities = FEISHU_CAPS + """Feishu channel using Open API + event subscription webhook.""" + + name = "feishu" + _ready_attrs = ("_http_client", "_access_token") + _rate_limit_patterns = ("99991400", "rate limit", "频率限制") + _rate_limit_delay = 2.0 + + def __init__(self, config: FeishuConfig): + super().__init__(config) + self._mention_names: list[str] = [] # bot mention keys from events + + # ── WebhookMixin overrides ──────────────────────────────────── + + def _get_webhook_port(self) -> int: + return self.config.webhook_port + + def _webhook_routes(self) -> list[tuple[str, str, Any]]: + return [("POST", "/webhook/event", self._handle_event)] + + # ── TokenMixin overrides ────────────────────────────────────── + + async def _fetch_token(self) -> tuple[str, int]: + """Fetch Feishu tenant_access_token.""" + url = f"{self.config.feishu_domain}/open-apis/auth/v3/tenant_access_token/internal" + body = { + "app_id": self.config.app_id, + "app_secret": self.config.app_secret, + } + try: + resp = await self._http_client.post(url, json=body) + data = resp.json() + except Exception as e: + raise ChannelError(f"Failed to get Feishu access token: {e}") + + if data.get("code") != 0: + raise ChannelError( + f"Feishu auth error: {data.get('msg', 'unknown')}" + ) + return data["tenant_access_token"], data.get("expire", 7200) + + # ── Lifecycle ───────────────────────────────────────────────── + + async def start(self) -> None: + try: + from aiohttp import web # noqa: F401 + import httpx # noqa: F401 + except ImportError: + raise ChannelError( + "aiohttp or httpx not installed. " + "Install with: pip install aiohttp httpx" + ) + + if not self.config.app_id: + raise ChannelError("Feishu app_id is required") + if not self.config.app_secret: + raise ChannelError("Feishu app_secret is required") + + # Start webhook server (sets up self._http_client) + await self._start_webhook_server() + + # Verify credentials by fetching initial token + await self._refresh_token() + + self._running = True + logger.info( + f"Feishu channel started " + f"(webhook on port {self.config.webhook_port})" + ) + + async def _cleanup(self) -> None: + await self._stop_webhook_server() + self._access_token = None + logger.info("Feishu channel stopped") + + # ── Token helpers (adapt old API to mixin) ──────────────────── + + async def _ensure_token(self) -> str: + """Return a valid access token, refreshing if needed.""" + return await TokenMixin._ensure_token(self) + + # ── Send (template method overrides) ────────────────────────── + + async def _feishu_send(self, url: str, body: dict, headers: dict) -> bool: + """POST to Feishu API and return True if code==0.""" + try: + resp = await self._http_client.post(url, json=body, headers=headers) + return resp.json().get("code") == 0 + except Exception as e: + logger.warning(f"Feishu send error: {e}") + return False + + async def _send_chunk( + self, chat_id, formatted_text, raw_text, reply_to, metadata, + ): + token = await self._ensure_token() + headers = {"Authorization": f"Bearer {token}"} + post_content = _markdown_to_feishu_post(raw_text) + + # If reply_to is set, try the reply API first + if reply_to: + reply_url = ( + f"{self.config.feishu_domain}" + f"/open-apis/im/v1/messages/{reply_to}/reply" + ) + if post_content is not None: + body = {"msg_type": "post", "content": json.dumps(post_content)} + else: + body = {"msg_type": "text", "content": json.dumps({"text": formatted_text})} + if await self._feishu_send(reply_url, body, headers): + return + + # Normal send (non-reply or reply fallback) + url = ( + f"{self.config.feishu_domain}" + f"/open-apis/im/v1/messages?receive_id_type=chat_id" + ) + + # Try post format first + if post_content is not None: + body = { + "receive_id": chat_id, + "msg_type": "post", + "content": json.dumps(post_content), + } + if await self._feishu_send(url, body, headers): + return + + # Fallback: plain text + body = { + "receive_id": chat_id, + "msg_type": "text", + "content": json.dumps({"text": formatted_text}), + } + if not await self._feishu_send(url, body, headers): + raise RuntimeError("Feishu send failed") + + # ── Media helpers ────────────────────────────────────────────── + + _IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} + + async def _download_media( + self, message_id: str, file_key: str, msg_type: str, + ) -> str | None: + """Download an image or file attachment from Feishu. + + Returns the local file path on success, or None on failure. + """ + token = await self._ensure_token() + resource_type = "image" if msg_type == "image" else "file" + url = ( + f"{self.config.feishu_domain}" + f"/open-apis/im/v1/messages/{message_id}" + f"/resources/{file_key}?type={resource_type}" + ) + headers = {"Authorization": f"Bearer {token}"} + try: + resp = await self._http_client.get(url, headers=headers, timeout=30) + if resp.status_code != 200: + logger.warning( + f"Feishu media download failed: HTTP {resp.status_code}" + ) + return None + + # Check attachment size before writing to disk + cl = resp.headers.get("content-length") + if cl: + try: + too_large = self._check_attachment_size(int(cl), file_key) + if too_large: + logger.warning(too_large) + return None + except (ValueError, TypeError): + pass + from ..base import MAX_ATTACHMENT_BYTES + if len(resp.content) > MAX_ATTACHMENT_BYTES: + logger.warning( + f"Feishu media too large: {len(resp.content)} bytes" + ) + return None + + # Determine extension from Content-Type or default + content_type = resp.headers.get("content-type", "") + ext_map = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + } + ext = ext_map.get(content_type, ".bin") + local_path = self._media_path(f"feishu_{message_id}_{file_key}{ext}") + local_path.write_bytes(resp.content) + return str(local_path) + except Exception as e: + logger.warning(f"Failed to download Feishu media: {e}") + return None + + async def _upload_feishu_resource( + self, url: str, headers: dict, file_path: str, + field_name: str, extra_data: dict, + ) -> dict | None: + """Upload a file to Feishu API. Returns response data or None on failure.""" + with open(file_path, "rb") as f: + resp = await self._http_client.post( + url, headers=headers, data=extra_data, + files={field_name: (Path(file_path).name, f)}, + ) + data = resp.json() + if data.get("code") != 0: + logger.error(f"Feishu upload failed: {data.get('msg')}") + return None + return data["data"] + + async def _send_media_impl( + self, + recipient: str, + file_path: str, + caption: str = "", + metadata: dict | None = None, + ) -> bool: + """Send a media file through Feishu.""" + token = await self._ensure_token() + headers = {"Authorization": f"Bearer {token}"} + chat_id = self._resolve_media_chat_id(recipient, metadata) + + path = Path(file_path) + ext = path.suffix.lower() + is_image = ext in self._IMAGE_EXTENSIONS + + send_url = ( + f"{self.config.feishu_domain}" + f"/open-apis/im/v1/messages?receive_id_type=chat_id" + ) + + if is_image: + upload_url = f"{self.config.feishu_domain}/open-apis/im/v1/images" + data = await self._upload_feishu_resource( + upload_url, headers, file_path, "image", {"image_type": "message"}, + ) + if not data: + return False + body = { + "receive_id": chat_id, + "msg_type": "image", + "content": json.dumps({"image_key": data["image_key"]}), + } + else: + upload_url = f"{self.config.feishu_domain}/open-apis/im/v1/files" + data = await self._upload_feishu_resource( + upload_url, headers, file_path, "file", + {"file_type": "stream", "file_name": path.name}, + ) + if not data: + return False + body = { + "receive_id": chat_id, + "msg_type": "file", + "content": json.dumps({"file_key": data["file_key"]}), + } + + if not await self._feishu_send(send_url, body, headers): + return False + + # Send caption as a separate text message if provided + if caption: + cap_body = { + "receive_id": chat_id, + "msg_type": "text", + "content": json.dumps({"text": caption}), + } + await self._feishu_send(send_url, cap_body, headers) + + return True + + # ── ACK reaction ─────────────────────────────────────────────── + + async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: + """Send an acknowledgment reaction via Feishu Open API.""" + try: + token = await self._ensure_token() + url = f"{self.config.feishu_domain}/open-apis/im/v1/messages/{message_id}/reactions" + await self._http_client.post( + url, + json={"reaction_type": {"emoji_type": emoji}}, + headers={"Authorization": f"Bearer {token}"}, + ) + except Exception as e: + logger.debug(f"Feishu ack reaction failed: {e}") + + async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: + """Remove ACK reaction via Feishu Open API. + + Feishu's DELETE /reactions endpoint requires the reaction_id, which + we don't track. No-op for now. + """ + pass + + # ── Format chunk ───────────────────────────────────────────── + + + # ── Typing indicator ────────────────────────────────────────── + + # Feishu Open API has no typing indicator endpoint. + + # ── Mention stripping ───────────────────────────────────────── + + def _strip_mention(self, text: str) -> str: + """Strip bot @mention placeholders from Feishu text. + + In Feishu v2 events the text contains placeholders like ``@_user_1`` + for each mention. ``_mention_names`` caches the placeholder keys that + belong to the bot (identified during ``_on_message``). + """ + result = text + for key in self._mention_names: + result = result.replace(key, "") + # Clean up extra whitespace left behind + return re.sub(r" +", " ", result).strip() + + # ── Webhook event handler ───────────────────────────────────── + + async def _handle_event(self, request) -> "web.Response": + """Handle POST /webhook/event from Feishu.""" + from aiohttp import web + + try: + body = await request.json() + except Exception: + return web.Response(status=400) + + # ── URL verification challenge ── + if body.get("type") == "url_verification": + challenge = body.get("challenge", "") + return web.json_response({"challenge": challenge}) + + # ── v2 event schema ── + schema = body.get("schema") + if schema == "2.0": + header = body.get("header", {}) + + # Verify token if configured + if self.config.verification_token: + token = header.get("token", "") + if token != self.config.verification_token: + logger.warning("Feishu event token mismatch") + return web.Response(status=403) + + event_type = header.get("event_type", "") + if event_type == "im.message.receive_v1": + await self._on_message(body.get("event", {})) + + # ── v1 event schema (legacy) ── + elif "event" in body: + if self.config.verification_token: + token = body.get("token", "") + if token != self.config.verification_token: + logger.warning("Feishu event token mismatch (v1)") + return web.Response(status=403) + + event = body["event"] + msg_type = event.get("type", "") + if msg_type == "message": + await self._on_message_v1(event) + + return web.Response(status=200) + + async def _on_message(self, event: dict) -> None: + """Handle im.message.receive_v1 event (v2 schema).""" + sender_info = event.get("sender", {}) + sender_id_info = sender_info.get("sender_id", {}) + sender_id = ( + sender_id_info.get("open_id") + or sender_id_info.get("user_id") + or "" + ) + sender_type = sender_info.get("sender_type", "") + + # Skip bot's own messages + if sender_type == "app": + return + + message = event.get("message", {}) + chat_id = message.get("chat_id", "") + msg_type = message.get("message_type", "") + message_id = message.get("message_id", "") + + # In group chats, detect mention status for centralized gating + chat_type = message.get("chat_type", "") + is_group = chat_type == "group" + was_mentioned = True + if is_group: + mentions = message.get("mentions", []) + was_mentioned = bool(mentions) + # Cache bot mention keys — bot mentions have empty user IDs + bot_keys = [] + for m in mentions: + m_id = m.get("id", {}) + # Bot/app mentions have no open_id / user_id + if not m_id.get("open_id") and not m_id.get("user_id"): + key = m.get("key", "") + if key: + bot_keys.append(key) + if bot_keys: + self._mention_names = bot_keys + + # Parse content JSON + content_str = message.get("content", "{}") + try: + content_data = json.loads(content_str) + except json.JSONDecodeError: + content_data = {} + + text = "" + annotations: list[str] = [] + media_paths: list[str] = [] + + if msg_type == "text": + text = content_data.get("text", "") + elif msg_type == "post": + text = self._extract_post_text(content_data) + elif msg_type == "image" and self.config.include_attachments: + image_key = content_data.get("image_key", "") + if image_key: + local = await self._download_media(message_id, image_key, "image") + if local: + media_paths.append(local) + annotations.append(f"[attachment: {local}]") + else: + annotations.append("[image message - download failed]") + else: + annotations.append("[image message]") + elif msg_type == "file" and self.config.include_attachments: + file_key = content_data.get("file_key", "") + file_name = content_data.get("file_name", "unknown") + if file_key: + local = await self._download_media(message_id, file_key, "file") + if local: + media_paths.append(local) + annotations.append(f"[attachment: {local}]") + else: + annotations.append(f"[file: {file_name} - download failed]") + else: + annotations.append(f"[file message: {file_name}]") + elif msg_type in ("audio", "media") and self.config.include_attachments: + # Feishu audio messages are voice recordings + media_label = "voice" if msg_type == "audio" else msg_type + file_key = content_data.get("file_key", "") + if file_key: + local = await self._download_media(message_id, file_key, "file") + if local: + media_paths.append(local) + annotations.append(f"[{media_label}: {local}]") + else: + annotations.append(f"[{media_label} message - download failed]") + else: + annotations.append(f"[{media_label} message]") + elif msg_type == "sticker": + sticker_key = content_data.get("file_key", "") + if sticker_key and self.config.include_attachments: + local = await self._download_media(message_id, sticker_key, "image") + if local: + media_paths.append(local) + annotations.append(f"[sticker: {local}]") + else: + annotations.append("[sticker message]") + else: + annotations.append("[sticker message]") + else: + text = f"[{msg_type} message]" + + if not text and not media_paths and not annotations: + return + + # Parse timestamp (milliseconds) + create_time = message.get("create_time", "") + try: + timestamp = datetime.fromtimestamp( + int(create_time) / 1000 + ) if create_time else datetime.now() + except (ValueError, TypeError, OSError): + timestamp = datetime.now() + + await self._enqueue_raw(RawIncoming( + sender_id=sender_id, + chat_id=chat_id, + text=text, + media_files=media_paths, + content_annotations=annotations, + timestamp=timestamp, + message_id=message_id, + metadata={ + "chat_id": chat_id, + "chat_type": message.get("chat_type", ""), + }, + is_group=is_group, + was_mentioned=was_mentioned, + )) + + async def _on_message_v1(self, event: dict) -> None: + """Handle v1 schema message event (legacy).""" + sender_id = event.get("open_id", "") + if not sender_id: + return + + # Detect group and mention status for centralized gating + chat_type = event.get("chat_type", "") + is_group = chat_type == "group" + was_mentioned = True + if is_group: + text_without_at = event.get("text_without_at_bot", "") + was_mentioned = bool(text_without_at) + + text = event.get("text_without_at_bot", "") or event.get("text", "") + if not text: + return + + chat_id = event.get("open_chat_id", "") + message_id = event.get("open_message_id", "") + + await self._enqueue_raw(RawIncoming( + sender_id=sender_id, + chat_id=chat_id, + text=text, + timestamp=datetime.now(), + message_id=message_id, + metadata={ + "chat_id": chat_id, + "chat_type": event.get("chat_type", ""), + }, + is_group=is_group, + was_mentioned=was_mentioned, + )) + + @staticmethod + def _extract_post_text(content: dict) -> str: + """Extract plain text from Feishu post (rich text) content.""" + parts: list[str] = [] + # Post content has locale keys like "zh_cn", "en_us" + for locale_key in ("zh_cn", "en_us", "ja_jp"): + locale_content = content.get(locale_key) + if locale_content: + title = locale_content.get("title", "") + if title: + parts.append(title) + for paragraph in locale_content.get("content", []): + line_parts: list[str] = [] + for element in paragraph: + tag = element.get("tag", "") + if tag == "text": + line_parts.append(element.get("text", "")) + elif tag == "a": + line_parts.append(element.get("text", "")) + elif tag == "at": + # Skip @mentions of the bot + pass + line = "".join(line_parts).strip() + if line: + parts.append(line) + break # Use first available locale + return "\n".join(parts) diff --git a/EvoScientist/channels/feishu/probe.py b/EvoScientist/channels/feishu/probe.py new file mode 100644 index 0000000..46d962e --- /dev/null +++ b/EvoScientist/channels/feishu/probe.py @@ -0,0 +1,39 @@ +"""Feishu (飞书/Lark) app credential validation.""" + +import logging + +logger = logging.getLogger(__name__) + + +async def validate_feishu_credentials( + app_id: str, + app_secret: str, + domain: str = "https://open.feishu.cn", +) -> tuple[bool, str]: + """Validate Feishu app credentials by requesting a tenant_access_token. + + Returns: + Tuple of (is_valid, message). + """ + if not app_id: + return False, "No app_id provided" + if not app_secret: + return False, "No app_secret provided" + + try: + import httpx + except ImportError: + return False, "httpx not installed" + + url = f"{domain}/open-apis/auth/v3/tenant_access_token/internal" + body = {"app_id": app_id, "app_secret": app_secret} + try: + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=body, timeout=10) + data = resp.json() + if data.get("code") == 0: + return True, f"App: {app_id}" + msg = data.get("msg", "unknown error") + return False, f"Auth failed: {msg}" + except Exception as e: + return False, f"Error: {e}" diff --git a/EvoScientist/channels/feishu/serve.py b/EvoScientist/channels/feishu/serve.py new file mode 100644 index 0000000..c9d0709 --- /dev/null +++ b/EvoScientist/channels/feishu/serve.py @@ -0,0 +1,113 @@ +"""Feishu (飞书/Lark) channel server. + +Standalone script to run the Feishu channel with CLI options. + +Usage: + python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET [OPTIONS] + +Examples: + # Basic setup + python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET + + # With verification token and custom port + python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET \\ + --verification-token TOKEN --webhook-port 9000 + + # With agent and thinking + python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET --agent --thinking +""" + +import argparse +import logging + +from .channel import FeishuChannel, FeishuConfig +from ..bus import MessageBus +from ..standalone import run_standalone + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger(__name__) + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Feishu (飞书/Lark) channel server", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--app-id", + required=True, + help="Feishu App ID", + ) + parser.add_argument( + "--app-secret", + required=True, + help="Feishu App Secret", + ) + parser.add_argument( + "--verification-token", + default="", + help="Feishu event verification token", + ) + parser.add_argument( + "--encrypt-key", + default="", + help="Feishu event encrypt key", + ) + parser.add_argument( + "--webhook-port", + type=int, + default=9000, + help="Port for webhook HTTP server (default: 9000)", + ) + parser.add_argument( + "--domain", + default="https://open.feishu.cn", + help="Feishu API domain (use https://open.larksuite.com for Lark)", + ) + parser.add_argument( + "--allow", + action="append", + dest="allowed_senders", + help="Allowed sender (Feishu open_id). Can be used multiple times.", + ) + parser.add_argument( + "--agent", + action="store_true", + help="Use EvoScientist agent as handler (default: echo)", + ) + parser.add_argument( + "--thinking", + action="store_true", + help="Send thinking content as intermediate messages (requires --agent)", + ) + return parser.parse_args() + + +def main(): + """Entry point.""" + args = parse_args() + + config = FeishuConfig( + app_id=args.app_id, + app_secret=args.app_secret, + verification_token=args.verification_token, + encrypt_key=args.encrypt_key, + webhook_port=args.webhook_port, + feishu_domain=args.domain, + allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, + ) + + send_thinking = args.thinking and args.agent + bus = MessageBus() + channel = FeishuChannel(config) + + run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) + + +if __name__ == "__main__": + main() diff --git a/EvoScientist/channels/formatter.py b/EvoScientist/channels/formatter.py new file mode 100644 index 0000000..c37e854 --- /dev/null +++ b/EvoScientist/channels/formatter.py @@ -0,0 +1,287 @@ +"""Unified formatting pipeline for all channels. + +Internal representation is Markdown. This module converts Markdown to +each platform's native format: HTML, Slack mrkdwn, Discord Markdown, +or plain text. + +Channels no longer need per-file format functions — they just declare +``capabilities.format_type`` and the base class auto-configures a +``UnifiedFormatter`` instance. +""" + +from __future__ import annotations + +import re +from typing import Callable + + +# ═════════════════════════════════════════════════════════════════════ +# Markdown conversion engine (formerly markdown_utils.py) +# ═════════════════════════════════════════════════════════════════════ + +_PLACEHOLDER_PREFIX = "\x00BLOCK" +_INLINE_PREFIX = "\x00INLINE" + +# A formatting rule: (regex_pattern, replacement) +InlineRule = tuple[str, str] + + +def convert_markdown( + text: str, + *, + code_block_formatter: Callable[[str, str], str], + inline_code_formatter: Callable[[str], str], + inline_rules: list[InlineRule], + escape_fn: Callable[[str], str] | None = None, +) -> str: + """Convert Markdown to a channel-specific format. + + Parameters + ---------- + text: + Input Markdown text. + code_block_formatter: + ``(language, code) -> str`` — format a fenced code block. + inline_code_formatter: + ``(code) -> str`` — format an inline code span. + inline_rules: + List of ``(pattern, replacement)`` pairs applied in order to the + remaining text (after code extraction and optional escaping). + escape_fn: + Optional function applied to the non-code text *before* inline + rules. Useful for HTML-escaping (Telegram) or other channel- + specific character escaping. + + Returns + ------- + str + The converted text. + """ + # 1. Extract and protect fenced code blocks (```...```) + code_blocks: list[str] = [] + + def _save_code_block(m: re.Match) -> str: + lang = m.group(1) or "" + code = m.group(2) + formatted = code_block_formatter(lang, code) + idx = len(code_blocks) + code_blocks.append(formatted) + return f"{_PLACEHOLDER_PREFIX}{idx}\x00" + + text = re.sub(r"```(\w*)\n?(.*?)```", _save_code_block, text, flags=re.DOTALL) + + # 2. Extract and protect inline code (`...`) + inline_codes: list[str] = [] + + def _save_inline(m: re.Match) -> str: + code = m.group(1) + formatted = inline_code_formatter(code) + idx = len(inline_codes) + inline_codes.append(formatted) + return f"{_INLINE_PREFIX}{idx}\x00" + + text = re.sub(r"`([^`]+)`", _save_inline, text) + + # 3. Optional escaping of remaining text + if escape_fn is not None: + text = escape_fn(text) + + # 4. Apply inline formatting rules + for pattern, replacement in inline_rules: + text = re.sub(pattern, replacement, text, flags=re.MULTILINE) + + # 5. Restore code blocks and inline code + for idx, html in enumerate(code_blocks): + text = text.replace(f"{_PLACEHOLDER_PREFIX}{idx}\x00", html) + for idx, code in enumerate(inline_codes): + text = text.replace(f"{_INLINE_PREFIX}{idx}\x00", code) + + return text + +# ═════════════════════════════════════════════════════════════════════ +# Shared helpers +# ═════════════════════════════════════════════════════════════════════ + +def _escape_html(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def _noop_escape(text: str) -> str: + return text + + +# ═════════════════════════════════════════════════════════════════════ +# HTML profile (Telegram, Email, Teams) +# ═════════════════════════════════════════════════════════════════════ + +def _html_code_block(lang: str, code: str) -> str: + escaped = _escape_html(code) + if lang: + return f'{escaped}'
+ return f"{escaped}"
+
+
+def _html_inline_code(code: str) -> str:
+ return f"{_escape_html(code)}"
+
+
+_HTML_INLINE_RULES: list[InlineRule] = [
+ # Headings → bold
+ (r"^#{1,6}\s+(.+)$", r"\1"),
+ # Blockquote markers (already escaped to >)
+ (r"^>\s?", ""),
+ # Links [text](url) →
+ (r"\[([^\]]+)\]\(([^)]+)\)", r'\1'),
+ # Bold **text** →
+ (r"\*\*(.+?)\*\*", r"\1"),
+ # Italic _text_ →
+ (r"(?\1"),
+ # Strikethrough ~~text~~ → {code}",
+ inline_code_formatter=lambda code: f"{code}",
+ inline_rules=[
+ (r"\*\*(.+?)\*\*", r"\1"),
+ (r"\*(.+?)\*", r"\1"),
+ ],
+ escape_fn=lambda t: t.replace("&", "&").replace("<", "<").replace(">", ">"),
+ )
+
+ def test_basic_bold_italic(self):
+ result = self._html_converter("**bold** and *italic*")
+ assert "bold" in result
+ assert "italic" in result
+
+ def test_code_block_protection(self):
+ """Code inside blocks should NOT have inline rules applied."""
+ text = "```\n**not bold**\n```"
+ result = self._html_converter(text)
+ assert "" not in result
+ assert "**not bold**" in result
+
+ def test_inline_code_protection(self):
+ text = "Use `**literal**` please"
+ result = self._html_converter(text)
+ assert "" in result
+ # The **literal** inside backticks should be literal
+ assert "**literal**" in result
+
+ def test_escape_fn_does_not_corrupt_placeholders(self):
+ """[B-28] escape_fn must not corrupt NUL-byte placeholders."""
+ text = "```\ncode\n```\nNormal "
+
+ def bad_escape(t):
+ # Strips NUL bytes — would break placeholders
+ return t.replace("\x00", "")
+
+ result = convert_markdown(
+ text,
+ code_block_formatter=lambda lang, c: f"[CODE]{c}[/CODE]",
+ inline_code_formatter=lambda c: f"[IC]{c}[/IC]",
+ inline_rules=[],
+ escape_fn=bad_escape,
+ )
+ # If placeholders were corrupted, the code block won't be restored
+ # This test DOCUMENTS the bug — it should fail until the bug is fixed
+ # After fix: assert "[CODE]" in result
+ # Current behavior: placeholder is corrupted
+ if "\x00" in text:
+ pass # Can't easily test without modifying source
+ # At minimum, verify the function doesn't crash
+ assert isinstance(result, str)
+
+ def test_placeholder_collision_with_user_input(self):
+ """[B-28 variant] User input containing placeholder pattern."""
+ text = "Normal text with \x00BLOCK0\x00 in it"
+ result = convert_markdown(
+ text,
+ code_block_formatter=lambda lang, c: f"{c}",
+ inline_code_formatter=lambda c: f"{c}",
+ inline_rules=[],
+ )
+ assert isinstance(result, str)
+
+ def test_empty_inline_code(self):
+ """[B-29] Empty backtick pairs should not crash."""
+ text = "before `` after"
+ result = convert_markdown(
+ text,
+ code_block_formatter=lambda lang, c: c,
+ inline_code_formatter=lambda c: f"[{c}]",
+ inline_rules=[],
+ )
+ assert isinstance(result, str)
+
+ def test_nested_code_fence_on_same_line(self):
+ """[B-30] Opening fence with code on same line."""
+ text = "```pythonprint('hi')```"
+ result = convert_markdown(
+ text,
+ code_block_formatter=lambda lang, code: f"LANG={lang}|CODE={code}",
+ inline_code_formatter=lambda c: c,
+ inline_rules=[],
+ )
+ assert isinstance(result, str)
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 5. Channel base class
+# ═══════════════════════════════════════════════════════════════════
+
+class TestChannelSend:
+
+ def test_send_single_chunk(self):
+ async def _test():
+ ch = StubChannel()
+ msg = OutboundMessage(
+ channel="stub", chat_id="c1", content="hello",
+ metadata={"chat_id": "c1"},
+ )
+ ok = await ch.send(msg)
+ assert ok is True
+ assert len(ch._sent_chunks) == 1
+ assert ch._sent_chunks[0][0] == "c1"
+ assert ch._sent_chunks[0][2] == "hello" # raw
+ _run(_test())
+
+ def test_send_multi_chunk(self):
+ async def _test():
+ cfg = _FakeConfig(text_chunk_limit=10)
+ ch = StubChannel(cfg)
+ msg = OutboundMessage(
+ channel="stub", chat_id="c1",
+ content="hello world this is a long message",
+ metadata={"chat_id": "c1"},
+ )
+ ok = await ch.send(msg)
+ assert ok is True
+ assert len(ch._sent_chunks) > 1
+ _run(_test())
+
+ def test_send_returns_false_when_not_ready(self):
+ async def _test():
+ ch = StubChannel()
+ ch._is_ready = lambda: False
+ msg = OutboundMessage(channel="stub", chat_id="c1", content="hi")
+ ok = await ch.send(msg)
+ assert ok is False
+ _run(_test())
+
+ def test_send_per_chat_lock_serializes(self):
+ """[B-03] Per-chat locks prevent message reordering."""
+ async def _test():
+ ch = StubChannel()
+ order = []
+
+ original_send_chunk = ch._send_chunk
+
+ async def slow_send(chat_id, fmt, raw, reply_to, meta):
+ order.append(raw)
+ await asyncio.sleep(0.05)
+ await original_send_chunk(chat_id, fmt, raw, reply_to, meta)
+
+ ch._send_chunk = slow_send
+
+ msg1 = OutboundMessage(channel="stub", chat_id="c1", content="first", metadata={"chat_id": "c1"})
+ msg2 = OutboundMessage(channel="stub", chat_id="c1", content="second", metadata={"chat_id": "c1"})
+
+ await asyncio.gather(ch.send(msg1), ch.send(msg2))
+ # Both complete; order may vary but no interleaving within a single send
+ assert len(order) == 2
+ _run(_test())
+
+ def test_reply_to_only_on_first_chunk(self):
+ """reply_to should only be passed to the first chunk."""
+ async def _test():
+ cfg = _FakeConfig(text_chunk_limit=10)
+ ch = StubChannel(cfg)
+ msg = OutboundMessage(
+ channel="stub", chat_id="c1",
+ content="a very long message that will be split into multiple parts",
+ reply_to="msg_42",
+ metadata={"chat_id": "c1"},
+ )
+ await ch.send(msg)
+ reply_tos = [c[3] for c in ch._sent_chunks]
+ assert reply_tos[0] == "msg_42"
+ assert all(r is None for r in reply_tos[1:])
+ _run(_test())
+
+
+class TestChannelAllowList:
+
+ def test_open_access_when_no_list(self):
+ ch = StubChannel()
+ assert ch.is_allowed("anyone") is True
+
+ def test_allowed_sender_passes(self):
+ cfg = _FakeConfig(allowed_senders=["alice", "bob"])
+ ch = StubChannel(cfg)
+ assert ch.is_allowed("alice") is True
+ assert ch.is_allowed("bob") is True
+
+ def test_disallowed_sender_blocked(self):
+ cfg = _FakeConfig(allowed_senders=["alice"])
+ ch = StubChannel(cfg)
+ assert ch.is_allowed("eve") is False
+
+ def test_composite_sender_id(self):
+ """Pipe-separated composite IDs should match any component."""
+ cfg = _FakeConfig(allowed_senders=["12345"])
+ ch = StubChannel(cfg)
+ assert ch.is_allowed("12345|alice") is True
+
+ def test_channel_allow_list(self):
+ cfg = _FakeConfig(allowed_channels=["chan_1", "chan_2"])
+ ch = StubChannel(cfg)
+ assert ch.is_channel_allowed("chan_1") is True
+ assert ch.is_channel_allowed("chan_3") is False
+
+ def test_channel_allow_list_empty_allows_all(self):
+ cfg = _FakeConfig(allowed_channels=None)
+ ch = StubChannel(cfg)
+ assert ch.is_channel_allowed("any_channel") is True
+
+
+class TestChannelMentionGating:
+
+ def test_dm_always_passes(self):
+ ch = StubChannel()
+ raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi",
+ is_group=False, was_mentioned=False)
+ assert ch._should_process(raw) is True
+
+ def test_group_mentioned_passes(self):
+ ch = StubChannel()
+ raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi",
+ is_group=True, was_mentioned=True)
+ assert ch._should_process(raw) is True
+
+ def test_group_not_mentioned_blocked(self):
+ ch = StubChannel()
+ ch.require_mention = "group"
+ raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi",
+ is_group=True, was_mentioned=False)
+ assert ch._should_process(raw) is False
+
+ def test_mention_off_passes_all(self):
+ ch = StubChannel()
+ ch.require_mention = "off"
+ raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi",
+ is_group=True, was_mentioned=False)
+ assert ch._should_process(raw) is True
+
+
+class TestChannelBuildInbound:
+
+ def test_builds_valid_inbound(self):
+ ch = StubChannel()
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ message_id="m1", media_files=["/path/img.jpg"],
+ )
+ msg = ch._raw_to_inbound(raw)
+ assert msg is not None
+ assert msg.channel == "stub"
+ assert msg.sender_id == "u1"
+ assert msg.content == "hello"
+ assert msg.media == ["/path/img.jpg"]
+
+ def test_drops_disallowed_sender(self):
+ async def _test():
+ cfg = _FakeConfig(allowed_senders=["alice"])
+ ch = StubChannel(cfg)
+ raw = RawIncoming(sender_id="eve", chat_id="c1", text="hack")
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 0
+ _run(_test())
+
+ def test_drops_disallowed_channel(self):
+ async def _test():
+ cfg = _FakeConfig(allowed_channels=["c1"])
+ ch = StubChannel(cfg)
+ raw = RawIncoming(sender_id="u1", chat_id="c2", text="hello")
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 0
+ _run(_test())
+
+ def test_drops_empty_content_no_media(self):
+ ch = StubChannel()
+ raw = RawIncoming(sender_id="u1", chat_id="c1", text="")
+ assert ch._raw_to_inbound(raw) is None
+
+ def test_media_only_message_passes(self):
+ ch = StubChannel()
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="",
+ media_files=["/path/file.pdf"],
+ )
+ msg = ch._raw_to_inbound(raw)
+ assert msg is not None
+ assert msg.content == "[media only]"
+
+ def test_annotations_merged(self):
+ ch = StubChannel()
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="main text",
+ content_annotations=["[attachment: photo.jpg]"],
+ )
+ msg = ch._raw_to_inbound(raw)
+ assert "[attachment: photo.jpg]" in msg.content
+
+ def test_metadata_preserves_chat_id(self):
+ ch = StubChannel()
+ raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi",
+ metadata={"extra": "data"})
+ msg = ch._raw_to_inbound(raw)
+ assert msg.metadata["chat_id"] == "c1"
+ assert msg.metadata["extra"] == "data"
+
+
+class TestInboundPipeline:
+ """Tests for the new middleware-based inbound pipeline in _enqueue_raw()."""
+
+ def test_pipeline_dedup(self):
+ """Duplicate messages are dropped by the pipeline."""
+ async def _test():
+ ch = StubChannel()
+ raw = RawIncoming(sender_id="u1", chat_id="c1", text="hello", message_id="m1")
+ await ch._enqueue_raw(raw)
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 1
+ _run(_test())
+
+ def test_pipeline_allowlist_blocks(self):
+ """Non-allowed senders are blocked by the pipeline."""
+ async def _test():
+ cfg = _FakeConfig(allowed_senders=["alice"])
+ ch = StubChannel(cfg)
+ raw = RawIncoming(sender_id="eve", chat_id="c1", text="hack")
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 0
+ _run(_test())
+
+ def test_pipeline_allowlist_passes(self):
+ """Allowed senders pass through the pipeline."""
+ async def _test():
+ cfg = _FakeConfig(allowed_senders=["alice"])
+ ch = StubChannel(cfg)
+ raw = RawIncoming(sender_id="alice", chat_id="c1", text="hello")
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 1
+ _run(_test())
+
+ def test_pipeline_channel_allowlist_blocks(self):
+ """Non-allowed channels are blocked by the pipeline."""
+ async def _test():
+ cfg = _FakeConfig(allowed_channels=["c1"])
+ ch = StubChannel(cfg)
+ raw = RawIncoming(sender_id="u1", chat_id="c2", text="hello")
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 0
+ _run(_test())
+
+ def test_pipeline_inbound_has_is_group(self):
+ """InboundMessage carries is_group and was_mentioned from RawIncoming."""
+ async def _test():
+ ch = StubChannel()
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ is_group=True, was_mentioned=True,
+ )
+ await ch._enqueue_raw(raw)
+ msg = await ch._queue.get()
+ assert msg.is_group is True
+ assert msg.was_mentioned is True
+ _run(_test())
+
+
+class TestChannelDebounce:
+
+ def test_single_message_processed(self):
+ """A single message should be published after debounce delay."""
+ async def _test():
+ bus = MessageBus()
+ ch = StubChannel()
+ ch.set_bus(bus)
+ ch.initial_debounce = 0.05
+ ch.max_debounce = 0.1
+
+ msg = InboundMessage(
+ channel="stub", sender_id="u1", chat_id="c1",
+ content="hello", message_id="m1",
+ metadata={"chat_id": "c1"},
+ )
+ await ch.queue_message(msg)
+ await asyncio.sleep(0.2)
+
+ # Check bus received the message
+ assert bus.inbound.qsize() == 1
+ received = await bus.consume_inbound()
+ assert received.content == "hello"
+ _run(_test())
+
+ def test_rapid_messages_merged(self):
+ """[B-05] Multiple rapid messages should be merged."""
+ async def _test():
+ bus = MessageBus()
+ ch = StubChannel()
+ ch.set_bus(bus)
+ ch.initial_debounce = 0.1
+ ch.max_debounce = 0.3
+
+ for i in range(3):
+ msg = InboundMessage(
+ channel="stub", sender_id="u1", chat_id="c1",
+ content=f"part{i}", message_id=f"m{i}",
+ metadata={"chat_id": "c1"},
+ )
+ await ch.queue_message(msg)
+ await asyncio.sleep(0.01)
+
+ await asyncio.sleep(0.5)
+ assert bus.inbound.qsize() == 1
+ received = await bus.consume_inbound()
+ assert "part0" in received.content
+ assert "part1" in received.content
+ assert "part2" in received.content
+ _run(_test())
+
+ def test_dedup_skips_duplicate(self):
+ """Dedup is now handled in _enqueue_raw pipeline, not queue_message."""
+ async def _test():
+ ch = StubChannel()
+
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ message_id="m1",
+ )
+ await ch._enqueue_raw(raw)
+ await ch._enqueue_raw(raw) # duplicate
+
+ # Only one should be enqueued (dedup catches second)
+ assert ch._queue.qsize() == 1
+ _run(_test())
+
+ def test_debounce_metadata_from_first_message(self):
+ """[B-05] Metadata from the first message in a debounce window is kept."""
+ async def _test():
+ bus = MessageBus()
+ ch = StubChannel()
+ ch.set_bus(bus)
+ ch.initial_debounce = 0.1
+
+ msg1 = InboundMessage(
+ channel="stub", sender_id="u1", chat_id="c1",
+ content="first", message_id="m1",
+ metadata={"chat_id": "c1", "key": "val1"},
+ )
+ msg2 = InboundMessage(
+ channel="stub", sender_id="u1", chat_id="c1",
+ content="second", message_id="m2",
+ metadata={"chat_id": "c2", "key": "val2"},
+ )
+ await ch.queue_message(msg1)
+ await asyncio.sleep(0.01)
+ await ch.queue_message(msg2)
+ await asyncio.sleep(0.3)
+
+ received = await bus.consume_inbound()
+ # BUG: metadata is from msg1 only; msg2's metadata is lost
+ assert received.metadata["key"] == "val1"
+ _run(_test())
+
+
+class TestChannelTyping:
+
+ def test_start_and_stop_typing(self):
+ async def _test():
+ ch = StubChannel()
+ await ch.start_typing("c1")
+ assert "c1" in ch._typing_tasks
+ await asyncio.sleep(0.1)
+ await ch.stop_typing("c1")
+ assert "c1" not in ch._typing_tasks
+ _run(_test())
+
+ def test_double_start_cancels_previous(self):
+ async def _test():
+ ch = StubChannel()
+ await ch.start_typing("c1")
+ task1 = ch._typing_tasks["c1"]
+ await ch.start_typing("c1")
+ task2 = ch._typing_tasks["c1"]
+ assert task1 is not task2
+ # Allow the event loop to process the cancellation
+ await asyncio.sleep(0)
+ assert task1.cancelled() or task1.done()
+ await ch.stop_typing("c1")
+ _run(_test())
+
+ def test_stop_typing_idempotent(self):
+ async def _test():
+ ch = StubChannel()
+ # Should not raise even if never started
+ await ch.stop_typing("nonexistent")
+ _run(_test())
+
+
+class TestChannelReconnect:
+
+ def test_run_reconnects_on_error(self):
+ """Channel.run() should reconnect with backoff on transient errors."""
+ async def _test():
+ ch = StubChannel()
+ start_count = 0
+ original_start = ch.start
+
+ async def flaky_start():
+ nonlocal start_count
+ start_count += 1
+ if start_count <= 2:
+ raise ConnectionError("transient")
+ await original_start()
+ # Stop after successful start to end the test
+ ch._running = False
+
+ ch.start = flaky_start
+ await ch.run()
+ assert start_count == 3
+ _run(_test())
+
+ def test_run_stops_on_channel_error(self):
+ """ChannelError should stop the channel permanently."""
+ async def _test():
+ ch = StubChannel()
+
+ async def fatal_start():
+ raise ChannelError("fatal")
+
+ ch.start = fatal_start
+ await ch.run()
+ assert ch._running is False
+ _run(_test())
+
+
+class TestExtractRetryAfter:
+
+ def test_never_returns_none(self):
+ """[B-01] Base _extract_retry_after always returns float, never None."""
+ ch = StubChannel()
+ # Even for a generic exception, it returns 1.0 instead of None
+ result = ch._extract_retry_after(ValueError("bad"))
+ # BUG: This should return None for non-retryable errors
+ # Current behavior: always returns 1.0
+ assert result is not None # Documents the bug
+
+ def test_extracts_retry_after_attribute(self):
+ ch = StubChannel()
+
+ class RateLimitError(Exception):
+ retry_after = 5.0
+
+ result = ch._extract_retry_after(RateLimitError("rate limited"))
+ assert result == 5.0
+
+ def test_detects_429_in_message(self):
+ ch = StubChannel()
+ result = ch._extract_retry_after(RuntimeError("HTTP 429 Too Many Requests"))
+ assert result == 1.0
+
+
+class TestChannelAttachments:
+
+ def test_check_attachment_size_within_limit(self):
+ ch = StubChannel()
+ result = ch._check_attachment_size(1024, "small.txt")
+ assert result is None
+
+ def test_check_attachment_size_too_large(self):
+ ch = StubChannel()
+ result = ch._check_attachment_size(30 * 1024 * 1024, "huge.bin")
+ assert result is not None
+ assert "too large" in result
+
+ def test_send_media_returns_false_when_not_ready(self):
+ async def _test():
+ ch = StubChannel()
+ ch._is_ready = lambda: False
+ ok = await ch.send_media("r1", "/path/file.txt")
+ assert ok is False
+ _run(_test())
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 6. ChannelManager
+# ═══════════════════════════════════════════════════════════════════
+
+class TestChannelManagerRegister:
+
+ def test_register_and_lookup(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ mgr.register(ch)
+ assert mgr.get_channel("stub") is ch
+ assert "stub" in mgr.enabled_channels
+
+ def test_duplicate_raises(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+ with pytest.raises(ValueError, match="already registered"):
+ mgr.register(StubChannel())
+
+ def test_register_injects_bus(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ mgr.register(ch)
+ assert ch._bus is bus
+
+ def test_register_applies_kwargs(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ mgr.register(ch, send_thinking=True, initial_debounce=5.0)
+ assert ch.send_thinking is True
+ assert ch.initial_debounce == 5.0
+
+ def test_health_entry_created(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+ assert "stub" in mgr._health
+
+
+class TestChannelManagerDispatch:
+
+ def test_dispatch_routes_to_channel(self):
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ # Override send to track calls
+ sent = []
+ ch.send = AsyncMock(side_effect=lambda m: sent.append(m) or True)
+ mgr.register(ch)
+
+ task = asyncio.create_task(mgr._dispatch_outbound())
+ await bus.publish_outbound(OutboundMessage(
+ channel="stub", chat_id="c1", content="hello",
+ ))
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+ assert len(sent) == 1
+ assert sent[0].content == "hello"
+ _run(_test())
+
+ def test_dispatch_unknown_channel_logged(self):
+ """Messages to unknown channels should be logged, not crash."""
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+
+ task = asyncio.create_task(mgr._dispatch_outbound())
+ await bus.publish_outbound(OutboundMessage(
+ channel="nonexistent", chat_id="c1", content="hello",
+ ))
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ # Should not raise
+ _run(_test())
+
+ def test_dispatch_ignores_send_return_false(self):
+ """[B-18] _dispatch_outbound ignores send() return value — health is inaccurate."""
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+
+ async def failing_send(msg):
+ return False # Indicates failure
+
+ ch.send = failing_send
+ mgr.register(ch)
+
+ task = asyncio.create_task(mgr._dispatch_outbound())
+ await bus.publish_outbound(OutboundMessage(
+ channel="stub", chat_id="c1", content="hello",
+ ))
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+ health = mgr._health["stub"]
+ # BUG: health shows success even though send returned False
+ assert health.total_successes == 1 # Documents the bug
+ assert health.total_failures == 0 # Should be 1
+ _run(_test())
+
+
+class TestChannelManagerHealth:
+
+ def test_health_tracks_success(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+ health = mgr._health["stub"]
+ health.total_successes = 5
+ health.consecutive_failures = 0
+ assert health.total_successes == 5
+
+ def test_health_tracks_failure(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+ health = mgr._health["stub"]
+ health.consecutive_failures = 3
+ health.total_failures = 10
+ health.last_failure_error = "timeout"
+ assert health.consecutive_failures == 3
+ assert health.last_failure_error == "timeout"
+
+
+class TestChannelManagerDynamicOps:
+
+ def test_add_channel_runtime(self):
+ """[B-15] add_channel uses channel_type as key for start_times
+ but register() uses channel.name — potential mismatch."""
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ # We can't easily test add_channel without registry,
+ # but we can verify the key mismatch concern
+ ch = StubChannel()
+ ch.name = "custom_name"
+ mgr.register(ch)
+ assert "custom_name" in mgr._channels
+ # If add_channel used "other_type" but channel.name is "custom_name",
+ # start_times would be keyed differently
+ _run(_test())
+
+ def test_remove_channel(self):
+ """[B-14] remove_channel removes from dict but doesn't cancel task."""
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ mgr.register(ch)
+ assert "stub" in mgr._channels
+
+ await mgr.remove_channel("stub")
+ assert "stub" not in mgr._channels
+ _run(_test())
+
+ def test_remove_nonexistent_channel(self):
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ await mgr.remove_channel("ghost") # should not raise
+ _run(_test())
+
+
+class TestChannelManagerDrain:
+
+ def test_stop_all_drains_outbound(self):
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus, drain_timeout=1.0)
+ ch = StubChannel()
+ sent = []
+ ch.send = AsyncMock(side_effect=lambda m: sent.append(m) or True)
+ mgr.register(ch)
+
+ # Pre-load an outbound message
+ await bus.publish_outbound(OutboundMessage(
+ channel="stub", chat_id="c1", content="drain me",
+ ))
+
+ await mgr.stop_all()
+ # The drain loop should have sent it
+ assert len(sent) == 1
+ assert sent[0].content == "drain me"
+ _run(_test())
+
+
+class TestChannelManagerStatus:
+
+ def test_get_status(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+ status = mgr.get_status()
+ assert "stub" in status
+ assert status["stub"]["registered"] is True
+
+ def test_running_channels(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ mgr.register(ch)
+ assert mgr.running_channels() == []
+ ch._running = True
+ assert mgr.running_channels() == ["stub"]
+
+ def test_get_stats(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+ stats = mgr.get_stats()
+ assert "channels" in stats
+ assert "running" in stats
+ assert "message_counts" in stats
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 7. InboundConsumer
+# ═══════════════════════════════════════════════════════════════════
+
+class TestInboundConsumer:
+
+ @staticmethod
+ def _make_consumer(bus=None, mgr=None, agent=None, **kw):
+ bus = bus or MessageBus()
+ if mgr is None:
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+ if agent is None:
+ agent = MagicMock()
+ return InboundConsumer(
+ bus=bus, manager=mgr, agent=agent,
+ thread_id="", max_concurrent=2, max_pending=10,
+ inference_timeout=2.0, drain_timeout=1.0, **kw,
+ )
+
+ def test_session_key_format(self):
+ msg = BusInbound(channel="tg", sender_id="u1", chat_id="c1", content="hi")
+ assert msg.session_key == "tg:c1"
+
+ def test_get_thread_id_creates_unique(self):
+ consumer = self._make_consumer()
+ tid1 = consumer._get_thread_id("user_a")
+ tid2 = consumer._get_thread_id("user_b")
+ assert tid1 != tid2
+
+ def test_get_thread_id_returns_same_for_same_sender(self):
+ consumer = self._make_consumer()
+ tid1 = consumer._get_thread_id("user_a")
+ tid2 = consumer._get_thread_id("user_a")
+ assert tid1 == tid2
+
+ def test_shared_thread_id_bug(self):
+ """[B-20] If thread_id is non-empty, senders get unique thread IDs with shared prefix."""
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+ consumer = InboundConsumer(
+ bus=bus, manager=mgr, agent=MagicMock(),
+ thread_id="shared_thread", # Non-empty!
+ )
+ tid1 = consumer._get_thread_id("alice")
+ tid2 = consumer._get_thread_id("bob")
+ # Fixed: Each sender gets a unique thread_id using thread_id as prefix
+ assert tid1 != tid2
+ assert tid1 == "shared_thread:alice"
+ assert tid2 == "shared_thread:bob"
+
+ def test_session_eviction_is_fifo_not_lru(self):
+ """[B-19] Sessions evict oldest by insertion, not by access."""
+ consumer = self._make_consumer()
+ consumer._sessions.clear()
+
+ # Fill up to limit
+ for i in range(10):
+ consumer._sessions[f"user_{i}"] = f"thread_{i}"
+
+ # Access "user_0" (should make it LRU-recent, but dict doesn't)
+ _ = consumer._sessions["user_0"]
+
+ # Force eviction by exceeding limit (simulate)
+ # Note: actual limit is 10_000, we test the logic pattern
+ oldest = next(iter(consumer._sessions))
+ assert oldest == "user_0" # Still first in insertion order
+
+ def test_metrics_initial(self):
+ consumer = self._make_consumer()
+ m = consumer.metrics
+ assert m["total_processed"] == 0
+ assert m["total_successes"] == 0
+ assert m["total_failures"] == 0
+ assert m["total_timeouts"] == 0
+
+ def test_stop_graceful(self):
+ async def _test():
+ consumer = self._make_consumer()
+ # Start and immediately stop
+ asyncio.create_task(consumer.run())
+ await asyncio.sleep(0.1)
+ await consumer.stop()
+ await asyncio.sleep(0.1)
+ assert consumer._stopping is True
+ _run(_test())
+
+
+class TestInboundConsumerErrorHandling:
+
+ def test_error_message_leaks_info(self):
+ """[B-22] Exception messages are sent directly to users."""
+ # This test documents that internal error details are exposed
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ mgr.register(ch)
+
+ _consumer = InboundConsumer(
+ bus=bus, manager=mgr, agent=MagicMock(),
+ thread_id="",
+ )
+
+ # The error message format includes the raw exception
+ # This should be sanitized in production
+ error_msg = f"Error: {RuntimeError('secret internal path /etc/passwd')}"
+ assert "/etc/passwd" in error_msg # Documents the leak
+ _run(_test())
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 8. MessageBus
+# ═══════════════════════════════════════════════════════════════════
+
+class TestMessageBus:
+
+ def test_publish_consume_inbound(self):
+ async def _test():
+ bus = MessageBus()
+ msg = BusInbound(channel="tg", sender_id="u1",
+ chat_id="c1", content="hello")
+ await bus.publish_inbound(msg)
+ assert bus.inbound_size == 1
+ received = await bus.consume_inbound()
+ assert received.content == "hello"
+ assert bus.inbound_size == 0
+ _run(_test())
+
+ def test_publish_consume_outbound(self):
+ async def _test():
+ bus = MessageBus()
+ msg = BusOutbound(channel="tg", chat_id="c1", content="reply")
+ await bus.publish_outbound(msg)
+ assert bus.outbound_size == 1
+ received = await bus.consume_outbound()
+ assert received.content == "reply"
+ _run(_test())
+
+ def test_subscriber_dispatch(self):
+ async def _test():
+ bus = MessageBus()
+ received = []
+ bus.subscribe_outbound("tg", lambda m: received.append(m))
+
+ task = asyncio.create_task(bus.dispatch_outbound())
+ await bus.publish_outbound(BusOutbound(
+ channel="tg", chat_id="c1", content="hello",
+ ))
+ await asyncio.sleep(0.1)
+ bus.stop()
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+ assert len(received) == 1
+ _run(_test())
+
+ def test_no_subscriber_logs_warning(self):
+ """Messages to unsubscribed channels should warn, not crash."""
+ async def _test():
+ bus = MessageBus()
+ task = asyncio.create_task(bus.dispatch_outbound())
+ await bus.publish_outbound(BusOutbound(
+ channel="unknown", chat_id="c1", content="lost",
+ ))
+ await asyncio.sleep(0.1)
+ bus.stop()
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ _run(_test())
+
+ def test_queue_sizes(self):
+ async def _test():
+ bus = MessageBus()
+ assert bus.inbound_size == 0
+ assert bus.outbound_size == 0
+ await bus.publish_inbound(BusInbound(
+ channel="x", sender_id="u", chat_id="c", content="a",
+ ))
+ assert bus.inbound_size == 1
+ _run(_test())
+
+ def test_subscriber_error_does_not_crash_dispatch(self):
+ async def _test():
+ bus = MessageBus()
+
+ async def bad_callback(msg):
+ raise RuntimeError("subscriber crash")
+
+ bus.subscribe_outbound("tg", bad_callback)
+
+ task = asyncio.create_task(bus.dispatch_outbound())
+ await bus.publish_outbound(BusOutbound(
+ channel="tg", chat_id="c1", content="trigger",
+ ))
+ await asyncio.sleep(0.1)
+ bus.stop()
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ # dispatch should survive the error
+ _run(_test())
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 9. Event dataclasses
+# ═══════════════════════════════════════════════════════════════════
+
+class TestEvents:
+
+ def test_inbound_defaults(self):
+ msg = BusInbound(channel="tg", sender_id="u1",
+ chat_id="c1", content="hi")
+ assert msg.media == []
+ assert msg.metadata == {}
+ assert msg.session_key == "tg:c1"
+ assert isinstance(msg.timestamp, datetime)
+
+ def test_outbound_defaults(self):
+ msg = BusOutbound(channel="tg", chat_id="c1", content="reply")
+ assert msg.reply_to is None
+ assert msg.media == []
+ assert msg.metadata == {}
+
+ def test_inbound_sender_alias(self):
+ msg = InboundMessage(channel="x", sender_id="u1",
+ chat_id="c1", content="hi")
+ assert msg.sender == "u1"
+
+ def test_outbound_recipient_alias(self):
+ msg = OutboundMessage(channel="x", chat_id="c1", content="hi")
+ assert msg.recipient == "c1"
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 10. Integration scenarios
+# ═══════════════════════════════════════════════════════════════════
+
+class TestIntegration:
+
+ def test_full_inbound_pipeline(self):
+ """Raw message → build_inbound → queue_message → bus."""
+ async def _test():
+ bus = MessageBus()
+ ch = StubChannel()
+ ch.set_bus(bus)
+ ch.initial_debounce = 0.05
+
+ raw = RawIncoming(
+ sender_id="user1", chat_id="chat1",
+ text="integration test", message_id="int_001",
+ )
+ await ch._enqueue_raw(raw)
+
+ # _enqueue_raw puts on internal queue, not bus
+ assert ch._queue.qsize() == 1
+ inbound = await ch._queue.get()
+ assert inbound.content == "integration test"
+
+ # Now simulate the bus path via queue_message
+ await ch.queue_message(inbound)
+ await asyncio.sleep(0.2)
+ assert bus.inbound_size == 1
+ _run(_test())
+
+ def test_outbound_dispatch_with_media(self):
+ """Dispatch routes media alongside text content."""
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ media_sent = []
+ ch.send_media = AsyncMock(
+ side_effect=lambda **kw: media_sent.append(kw) or True,
+ )
+ ch.send = AsyncMock(return_value=True)
+ mgr.register(ch)
+
+ task = asyncio.create_task(mgr._dispatch_outbound())
+ await bus.publish_outbound(OutboundMessage(
+ channel="stub", chat_id="c1", content="see attached",
+ media=["/path/doc.pdf"],
+ ))
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+ assert len(media_sent) == 1
+ _run(_test())
+
+ def test_debounce_lost_on_stop(self):
+ """[B-06] Buffered messages are lost when channel stops during debounce."""
+ async def _test():
+ bus = MessageBus()
+ ch = StubChannel()
+ ch.set_bus(bus)
+ ch.initial_debounce = 5.0 # Long debounce
+
+ msg = InboundMessage(
+ channel="stub", sender_id="u1", chat_id="c1",
+ content="will be lost", message_id="m1",
+ metadata={"chat_id": "c1"},
+ )
+ await ch.queue_message(msg)
+ # Message is buffered but debounce hasn't fired yet
+
+ assert len(ch._message_buffers) == 1
+
+ # Stop the channel — debounce tasks are cancelled
+ ch._running = True
+ await ch.stop()
+
+ # BUG: The buffered message was never published
+ assert bus.inbound_size == 0 # Documents data loss
+ _run(_test())
+
+ def test_send_locks_unbounded_growth(self):
+ """[B-03] _send_locks grows without bound for unique chat_ids."""
+ async def _test():
+ ch = StubChannel()
+ for i in range(100):
+ msg = OutboundMessage(
+ channel="stub", chat_id=f"chat_{i}",
+ content="hi", metadata={"chat_id": f"chat_{i}"},
+ )
+ await ch.send(msg)
+
+ # All 100 unique chat_ids created a lock
+ assert len(ch._send_locks) == 100
+ # BUG: These are never cleaned up
+ _run(_test())
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 11. Edge cases and boundary conditions
+# ═══════════════════════════════════════════════════════════════════
+
+class TestEdgeCases:
+
+ def test_chunk_text_single_char_limit(self):
+ chunks = chunk_text("abc", 1)
+ assert all(len(c) <= 1 for c in chunks)
+ assert len(chunks) == 3
+
+ def test_chunk_text_unicode(self):
+ text = "你好世界" * 100
+ chunks = chunk_text(text, 50)
+ assert all(len(c) <= 50 for c in chunks)
+
+ def test_dedup_cache_rapid_same_id(self):
+ dc = DedupCache()
+ assert dc.is_duplicate("x") is False
+ for _ in range(100):
+ assert dc.is_duplicate("x") is True
+
+ def test_channel_send_empty_content(self):
+ async def _test():
+ ch = StubChannel()
+ msg = OutboundMessage(channel="stub", chat_id="c1", content="")
+ ok = await ch.send(msg)
+ # Empty content goes through chunk_text which returns []
+ assert ok is True
+ assert len(ch._sent_chunks) == 0
+ _run(_test())
+
+ def test_raw_incoming_defaults(self):
+ raw = RawIncoming(sender_id="u1", chat_id="c1")
+ assert raw.text == ""
+ assert raw.media_files == []
+ assert raw.content_annotations == []
+ assert raw.is_group is False
+ assert raw.was_mentioned is True
+ assert raw.message_id == ""
+
+ def test_outbound_message_no_metadata_chat_id_resolution(self):
+ """resolve_chat_id falls back to recipient when metadata has no chat_id."""
+ ch = StubChannel()
+ msg = OutboundMessage(
+ channel="stub", chat_id="fallback_id", content="hi",
+ metadata={},
+ )
+ resolved = ch._resolve_chat_id(msg)
+ assert resolved == "fallback_id"
+
+ def test_health_server_response_structure(self):
+ """HealthServer builds response with expected keys."""
+ from EvoScientist.channels.channel_manager import _HealthServer
+
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+
+ hs = _HealthServer(mgr, 0)
+ resp = hs._build_response()
+ assert resp["status"] == "healthy"
+ assert "uptime_seconds" in resp
+ assert "channels" in resp
+ assert "queues" in resp
+ assert "health" in resp
diff --git a/tests/test_channel_manager.py b/tests/test_channel_manager.py
new file mode 100644
index 0000000..9cd8656
--- /dev/null
+++ b/tests/test_channel_manager.py
@@ -0,0 +1,166 @@
+"""Tests for ChannelManager."""
+
+import asyncio
+
+import pytest
+
+from EvoScientist.channels.bus.message_bus import MessageBus
+from EvoScientist.channels.channel_manager import ChannelManager
+from EvoScientist.channels.base import Channel, OutboundMessage
+
+
+def _run(coro):
+ """Run an async coroutine safely, creating a fresh event loop."""
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+class _FakeConfig:
+ text_chunk_limit = 4096
+ allowed_senders = None
+
+
+class FakeChannel(Channel):
+ """Minimal channel for testing."""
+
+ name = "fake"
+
+ def __init__(self):
+ super().__init__(_FakeConfig())
+ self._started = False
+ self._stopped = False
+ self._sent: list[OutboundMessage] = []
+
+ async def start(self):
+ self._started = True
+
+ async def stop(self):
+ self._stopped = True
+
+ async def receive(self):
+ while True:
+ try:
+ msg = await asyncio.wait_for(
+ self._queue.get(), timeout=0.5,
+ )
+ yield msg
+ except asyncio.TimeoutError:
+ return
+
+ async def send(self, message: OutboundMessage) -> bool:
+ self._sent.append(message)
+ return True
+
+ async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
+ pass
+
+
+class TestChannelManagerRegister:
+ def test_register_channel(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = FakeChannel()
+ result = mgr.register(ch)
+ assert "fake" in mgr.enabled_channels
+ assert mgr.get_channel("fake") is ch
+ assert result is ch
+
+ def test_duplicate_register_raises(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+ with pytest.raises(ValueError, match="already registered"):
+ mgr.register(FakeChannel())
+
+ def test_get_status(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+ status = mgr.get_status()
+ assert "fake" in status
+ assert status["fake"]["registered"] is True
+
+
+class TestChannelManagerDispatch:
+ def test_outbound_dispatch_routes_to_channel(self):
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = FakeChannel()
+ mgr.register(ch)
+
+ # Start only the dispatcher (not full start_all)
+ dispatch = asyncio.create_task(
+ mgr._dispatch_outbound()
+ )
+
+ # Publish an outbound message
+ await bus.publish_outbound(OutboundMessage(
+ channel="fake", chat_id="u1",
+ content="hello from agent",
+ ))
+
+ await asyncio.sleep(0.1)
+ dispatch.cancel()
+ try:
+ await dispatch
+ except asyncio.CancelledError:
+ pass
+
+ assert len(ch._sent) == 1
+ assert ch._sent[0].content == "hello from agent"
+ assert ch._sent[0].chat_id == "u1"
+
+ _run(_test())
+
+
+class TestChannelManagerTracking:
+ def test_record_message(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+
+ mgr.record_message("fake", "received")
+ mgr.record_message("fake", "received")
+ mgr.record_message("fake", "sent")
+
+ assert mgr._message_counts["fake"]["received"] == 2
+ assert mgr._message_counts["fake"]["sent"] == 1
+
+ def test_record_message_unknown_channel(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+
+ # Should not raise, auto-creates entry
+ mgr.record_message("unknown", "received")
+ assert mgr._message_counts["unknown"]["received"] == 1
+
+ def test_get_detailed_status(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+
+ # Simulate start_all setting start_times
+ from datetime import datetime
+ mgr._start_times["fake"] = datetime.now()
+ mgr._message_counts["fake"] = {"received": 5, "sent": 3}
+
+ status = mgr.get_detailed_status()
+ assert "fake" in status
+ assert status["fake"]["registered"] is True
+ assert status["fake"]["received"] == 5
+ assert status["fake"]["sent"] == 3
+ assert status["fake"]["uptime_seconds"] >= 0
+ assert status["fake"]["start_time"] is not None
+
+ def test_get_detailed_status_no_start_time(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+
+ status = mgr.get_detailed_status()
+ assert status["fake"]["uptime_seconds"] == 0
+ assert status["fake"]["start_time"] is None
diff --git a/tests/test_discord_channel.py b/tests/test_discord_channel.py
new file mode 100644
index 0000000..6d5152d
--- /dev/null
+++ b/tests/test_discord_channel.py
@@ -0,0 +1,71 @@
+"""Tests for Discord channel implementation."""
+
+import asyncio
+
+import pytest
+
+from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig
+from EvoScientist.channels.base import ChannelError
+
+
+def _run(coro):
+ """Run an async coroutine safely, creating a fresh event loop."""
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+class TestDiscordConfig:
+ def test_default_values(self):
+ config = DiscordConfig()
+ assert config.bot_token == ""
+ assert config.allowed_senders is None
+ assert config.allowed_channels is None
+ assert config.text_chunk_limit == 4096
+
+ def test_custom_values(self):
+ config = DiscordConfig(
+ bot_token="test-token",
+ allowed_senders={"111"},
+ allowed_channels={"222"},
+ text_chunk_limit=1000,
+ )
+ assert config.bot_token == "test-token"
+ assert config.allowed_senders == {"111"}
+ assert config.allowed_channels == {"222"}
+ assert config.text_chunk_limit == 1000
+
+
+class TestDiscordChannel:
+ def test_init(self):
+ config = DiscordConfig(bot_token="test")
+ channel = DiscordChannel(config)
+ assert channel.config is config
+ assert channel._running is False
+
+ def test_start_raises_without_token_or_library(self):
+ config = DiscordConfig(bot_token="")
+ channel = DiscordChannel(config)
+ with pytest.raises(ChannelError):
+ _run(channel.start())
+
+ def test_stop_when_not_running(self):
+ config = DiscordConfig(bot_token="test")
+ channel = DiscordChannel(config)
+ _run(channel.stop())
+
+ def test_send_returns_false_without_client(self):
+ from EvoScientist.channels.base import OutboundMessage
+
+ config = DiscordConfig(bot_token="test")
+ channel = DiscordChannel(config)
+ msg = OutboundMessage(
+ channel="discord",
+ chat_id="123",
+ content="hello",
+ metadata={"chat_id": "123"},
+ )
+ result = _run(channel.send(msg))
+ assert result is False
diff --git a/tests/test_mention_gating.py b/tests/test_mention_gating.py
new file mode 100644
index 0000000..842efc8
--- /dev/null
+++ b/tests/test_mention_gating.py
@@ -0,0 +1,541 @@
+"""Tests for unified mention gating in base.py and per-channel _strip_mention."""
+
+import asyncio
+from dataclasses import dataclass
+
+
+from EvoScientist.channels.base import Channel, RawIncoming
+from EvoScientist.channels.capabilities import ChannelCapabilities
+
+
+# ── Minimal concrete channel for testing base-class logic ─────────────
+
+
+@dataclass
+class _StubConfig:
+ allowed_senders: set[str] | None = None
+ require_mention: str = "group"
+ dm_policy: str = "allowlist"
+
+
+class _StubChannel(Channel):
+ name = "stub"
+
+ async def start(self):
+ pass
+
+ async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
+ pass
+
+
+class _MentionStubChannel(_StubChannel):
+ """Stub with mention gating enabled."""
+ capabilities = ChannelCapabilities(mentions=True)
+
+
+def _run(coro):
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+# ── _should_process tests ────────────────────────────────────────────
+
+
+class TestShouldProcess:
+ """Tests for the centralized _should_process gate."""
+
+ def _make(self, require_mention="group"):
+ config = _StubConfig(require_mention=require_mention)
+ return _StubChannel(config)
+
+ def test_dm_always_passes_with_group_mode(self):
+ ch = self._make("group")
+ raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=False, was_mentioned=False)
+ assert ch._should_process(raw) is True
+
+ def test_dm_always_passes_with_always_mode(self):
+ ch = self._make("always")
+ raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=False, was_mentioned=False)
+ assert ch._should_process(raw) is True
+
+ def test_dm_always_passes_with_off_mode(self):
+ ch = self._make("off")
+ raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=False, was_mentioned=False)
+ assert ch._should_process(raw) is True
+
+ def test_group_mentioned_passes_with_group_mode(self):
+ ch = self._make("group")
+ raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=True)
+ assert ch._should_process(raw) is True
+
+ def test_group_not_mentioned_blocked_with_group_mode(self):
+ ch = self._make("group")
+ raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=False)
+ assert ch._should_process(raw) is False
+
+ def test_group_not_mentioned_passes_with_off_mode(self):
+ ch = self._make("off")
+ raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=False)
+ assert ch._should_process(raw) is True
+
+ def test_group_not_mentioned_blocked_with_always_mode(self):
+ ch = self._make("always")
+ raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=False)
+ assert ch._should_process(raw) is False
+
+ def test_group_mentioned_passes_with_always_mode(self):
+ ch = self._make("always")
+ raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=True)
+ assert ch._should_process(raw) is True
+
+
+class TestPipelineGating:
+ """Tests that _enqueue_raw pipeline integrates mention gating and _strip_mention."""
+
+ def test_group_not_mentioned_dropped(self):
+ async def _test():
+ config = _StubConfig(require_mention="group")
+ ch = _MentionStubChannel(config)
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ is_group=True, was_mentioned=False,
+ )
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 0
+ _run(_test())
+
+ def test_group_mentioned_passes(self):
+ async def _test():
+ config = _StubConfig(require_mention="group")
+ ch = _MentionStubChannel(config)
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ is_group=True, was_mentioned=True,
+ )
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 1
+ msg = await ch._queue.get()
+ assert msg.content == "hello"
+ _run(_test())
+
+ def test_dm_passes_even_when_not_mentioned(self):
+ async def _test():
+ config = _StubConfig(require_mention="group")
+ ch = _MentionStubChannel(config)
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ is_group=False, was_mentioned=False,
+ )
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 1
+ _run(_test())
+
+ def test_strip_mention_called_for_group(self):
+ """When is_group=True, _strip_mention should be applied to text."""
+ async def _test():
+ config = _StubConfig(require_mention="group")
+ ch = _MentionStubChannel(config)
+ ch._strip_mention = lambda text: text.replace("@bot ", "").strip()
+ # Rebuild middlewares so MentionGatingMiddleware picks up new strip_fn
+ ch._inbound_middlewares = ch._build_inbound_middlewares()
+
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="@bot hello",
+ is_group=True, was_mentioned=True,
+ )
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 1
+ msg = await ch._queue.get()
+ assert msg.content == "hello"
+ _run(_test())
+
+ def test_strip_mention_not_called_for_dm(self):
+ """When is_group=False, _strip_mention should NOT be applied."""
+ async def _test():
+ config = _StubConfig(require_mention="group")
+ ch = _MentionStubChannel(config)
+ ch._strip_mention = lambda text: text.replace("@bot ", "").strip()
+ ch._inbound_middlewares = ch._build_inbound_middlewares()
+
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="@bot hello",
+ is_group=False, was_mentioned=True,
+ )
+ await ch._enqueue_raw(raw)
+ assert ch._queue.qsize() == 1
+ msg = await ch._queue.get()
+ assert msg.content == "@bot hello"
+ _run(_test())
+
+
+# ── Per-channel _strip_mention tests ─────────────────────────────────
+
+
+class TestTelegramStripMention:
+ def test_strip_username(self):
+ from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+
+ config = TelegramConfig(bot_token="test")
+ ch = TelegramChannel(config)
+ ch._bot_username = "mybot"
+ assert ch._strip_mention("@mybot hello") == "hello"
+ assert ch._strip_mention("@MyBot hello") == "hello"
+ assert ch._strip_mention("hello @mybot world") == "hello world"
+
+ def test_no_username(self):
+ from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+
+ config = TelegramConfig(bot_token="test")
+ ch = TelegramChannel(config)
+ ch._bot_username = ""
+ assert ch._strip_mention("@mybot hello") == "@mybot hello"
+
+
+class TestDiscordStripMention:
+ def test_strip_user_id(self):
+ from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig
+
+ config = DiscordConfig(bot_token="test")
+ ch = DiscordChannel(config)
+
+ # Mock client.user
+ class FakeUser:
+ id = 123456789
+ class FakeClient:
+ user = FakeUser()
+ ch._client = FakeClient()
+
+ assert ch._strip_mention("<@123456789> hello") == "hello"
+ assert ch._strip_mention("<@!123456789> hello") == "hello"
+ assert ch._strip_mention("hello <@123456789> world") == "hello world"
+
+ def test_no_client(self):
+ from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig
+
+ config = DiscordConfig(bot_token="test")
+ ch = DiscordChannel(config)
+ ch._client = None
+ assert ch._strip_mention("<@123> hello") == "<@123> hello"
+
+
+class TestSlackStripMention:
+ def test_strip_user_id(self):
+ from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
+
+ config = SlackConfig(bot_token="test", app_token="xapp-test")
+ ch = SlackChannel(config)
+ ch._bot_user_id = "U123ABC"
+ assert ch._strip_mention("<@U123ABC> hello") == "hello"
+ assert ch._strip_mention("hello <@U123ABC> world") == "hello world"
+
+ def test_no_bot_user_id(self):
+ from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
+
+ config = SlackConfig(bot_token="test", app_token="xapp-test")
+ ch = SlackChannel(config)
+ assert ch._strip_mention("<@U123> hello") == "<@U123> hello"
+
+
+# ── iMessage pipeline integration tests ──────────────────────────
+
+
+class TestIMessageConfig:
+ def test_default_values(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageConfig
+
+ config = IMessageConfig()
+ assert config.allowed_senders is None
+ assert config.include_attachments is True
+ assert config.text_chunk_limit == 4096
+
+ def test_allowed_senders_is_set(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageConfig
+
+ config = IMessageConfig(allowed_senders={"+1234", "chat_id:5"})
+ assert isinstance(config.allowed_senders, set)
+ assert "+1234" in config.allowed_senders
+
+
+class TestIMessageChannel:
+ def test_is_ready_without_client(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ assert ch._is_ready() is False
+
+ def test_is_allowed_always_true(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ assert ch.is_allowed("anyone") is True
+
+ def test_build_inbound_uses_rich_filtering(self):
+ from EvoScientist.channels.imessage.channel_rpc import (
+ IMessageChannelRpc, IMessageConfig,
+ )
+
+ config = IMessageConfig(allowed_senders={"+15551234567"})
+ ch = IMessageChannelRpc(config)
+
+ # Allowed sender passes
+ raw = RawIncoming(
+ sender_id="+15551234567", chat_id="c1", text="hi",
+ metadata={"chat_id": 1, "chat_guid": None},
+ )
+ assert ch._build_inbound(raw) is not None
+
+ # Disallowed sender blocked
+ raw2 = RawIncoming(
+ sender_id="+19999999999", chat_id="c1", text="hi",
+ metadata={"chat_id": 1, "chat_guid": None},
+ )
+ assert ch._build_inbound(raw2) is None
+
+ def test_add_remove_allowed_senders(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ assert ch.config.allowed_senders is None
+
+ ch.add_allowed_sender("+15551234567")
+ assert ch.config.allowed_senders is not None
+ assert len(ch.config.allowed_senders) == 1
+
+ ch.remove_allowed_sender("+15551234567")
+ assert len(ch.config.allowed_senders) == 0
+
+ ch.clear_allowed_senders()
+ assert ch.config.allowed_senders is None
+
+ def test_list_allowed_senders(self):
+ from EvoScientist.channels.imessage.channel_rpc import (
+ IMessageChannelRpc, IMessageConfig,
+ )
+
+ ch = IMessageChannelRpc()
+ assert ch.list_allowed_senders() == []
+
+ config = IMessageConfig(allowed_senders={"a", "b"})
+ ch2 = IMessageChannelRpc(config)
+ result = ch2.list_allowed_senders()
+ assert set(result) == {"a", "b"}
+
+ def test_handle_message_sets_is_group(self):
+ import asyncio
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ # Capture what _build_inbound receives
+ captured = []
+ original = ch._build_inbound
+ def spy(raw):
+ captured.append(raw)
+ return original(raw)
+ ch._build_inbound = spy
+
+ loop = asyncio.new_event_loop()
+ try:
+ loop.run_until_complete(ch._handle_message({
+ "message": {
+ "sender": "+1234",
+ "text": "hello",
+ "is_group": True,
+ "chat_id": 42,
+ "id": "msg1",
+ }
+ }))
+ finally:
+ loop.close()
+
+ assert len(captured) == 1
+ assert captured[0].is_group is True
+ assert captured[0].was_mentioned is True
+
+
+# ── Feishu config tests ──────────────────────────────────────────
+
+
+class TestFeishuConfig:
+ def test_allowed_channels(self):
+ from EvoScientist.channels.feishu.channel import FeishuConfig
+
+ config = FeishuConfig(
+ app_id="test", app_secret="test",
+ allowed_channels={"oc_abc123"},
+ )
+ assert config.allowed_channels == {"oc_abc123"}
+
+
+# ── Slack retry tests ────────────────────────────────────────────
+
+
+class TestSlackRetry:
+ def test_extract_retry_after_from_response(self):
+ from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
+
+ config = SlackConfig(bot_token="test", app_token="xapp-test")
+ ch = SlackChannel(config)
+
+ # Simulate a SlackApiError-like exception with response headers
+ class FakeResponse:
+ headers = {"Retry-After": "30"}
+ class FakeError(Exception):
+ response = FakeResponse()
+
+ result = ch._extract_retry_after(FakeError())
+ assert result == 30.0
+
+ def test_extract_retry_after_fallback(self):
+ from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
+
+ config = SlackConfig(bot_token="test", app_token="xapp-test")
+ ch = SlackChannel(config)
+
+ # Plain exception without response attribute
+ result = ch._extract_retry_after(Exception("some error"))
+ assert result == 1.0 # base class default
+
+
+# ── Attachment size check tests ──────────────────────────────────
+
+
+class TestAttachmentSizeCheck:
+ """Tests for _check_attachment_size and _download_attachment size guard."""
+
+ def test_check_attachment_size_within_limit(self):
+ config = _StubConfig()
+ ch = _StubChannel(config)
+ assert ch._check_attachment_size(100, "small.txt") is None
+
+ def test_check_attachment_size_exceeds_limit(self):
+ from EvoScientist.channels.base import MAX_ATTACHMENT_BYTES
+
+ config = _StubConfig()
+ ch = _StubChannel(config)
+ result = ch._check_attachment_size(MAX_ATTACHMENT_BYTES + 1, "big.zip")
+ assert result is not None
+ assert "too large" in result
+
+
+# ── Feishu _strip_mention tests ──────────────────────────────────
+
+
+class TestFeishuStripMention:
+ def test_strip_bot_mention_placeholder(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ ch._mention_names = ["@_user_1"]
+ assert ch._strip_mention("@_user_1 hello world") == "hello world"
+
+ def test_strip_multiple_mentions(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ ch._mention_names = ["@_user_1", "@_user_2"]
+ result = ch._strip_mention("@_user_1 @_user_2 hello")
+ assert result == "hello"
+
+ def test_no_mention_names(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ assert ch._strip_mention("hello world") == "hello world"
+
+
+# ── Telegram allowed_channels tests ──────────────────────────────
+
+
+class TestTelegramConfig:
+ def test_allowed_channels_field_exists(self):
+ from EvoScientist.channels.telegram.channel import TelegramConfig
+
+ config = TelegramConfig(bot_token="test", allowed_channels={"-100123"})
+ assert config.allowed_channels == {"-100123"}
+
+ def test_allowed_channels_default_none(self):
+ from EvoScientist.channels.telegram.channel import TelegramConfig
+
+ config = TelegramConfig(bot_token="test")
+ assert config.allowed_channels is None
+
+ def test_channel_allow_list_integration(self):
+ from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+
+ config = TelegramConfig(bot_token="test", allowed_channels={"-100123"})
+ ch = TelegramChannel(config)
+ assert ch.is_channel_allowed("-100123") is True
+ assert ch.is_channel_allowed("-100999") is False
+
+ def test_channel_allow_list_empty_allows_all(self):
+ from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+
+ config = TelegramConfig(bot_token="test")
+ ch = TelegramChannel(config)
+ assert ch.is_channel_allowed("-100999") is True
+
+
+# ── Feishu retry tests ───────────────────────────────────────────
+
+
+class TestFeishuRetry:
+ def test_extract_retry_after_rate_limit(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ result = ch._extract_retry_after(Exception("code 99991400: rate limit"))
+ assert result == 2.0
+
+ def test_extract_retry_after_generic(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ result = ch._extract_retry_after(Exception("some error"))
+ assert result == 1.0
+
+
+# ── iMessage reply_to tests ──────────────────────────────────────
+
+
+class TestIMessageReplyTo:
+ def test_send_chunk_includes_reply_to(self):
+ """Verify reply_to is passed through to RPC params."""
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ # Mock the RPC client
+ captured_params = {}
+
+ class FakeClient:
+ async def request(self, method, params):
+ captured_params.update(params)
+ return {}
+
+ ch._client = FakeClient()
+
+ _run(ch._send_chunk("chat123", "hello", "hello", "msg42", {"chat_id": "chat123"}))
+ assert captured_params.get("reply_to") == "msg42"
+
+ def test_send_chunk_omits_reply_to_when_none(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ captured_params = {}
+
+ class FakeClient:
+ async def request(self, method, params):
+ captured_params.update(params)
+ return {}
+
+ ch._client = FakeClient()
+
+ _run(ch._send_chunk("chat123", "hello", "hello", None, {"chat_id": "chat123"}))
+ assert "reply_to" not in captured_params
diff --git a/tests/test_message_bus.py b/tests/test_message_bus.py
new file mode 100644
index 0000000..700423d
--- /dev/null
+++ b/tests/test_message_bus.py
@@ -0,0 +1,111 @@
+"""Tests for the Message Bus decoupling layer."""
+
+import asyncio
+
+
+from EvoScientist.channels.bus.events import InboundMessage, OutboundMessage
+from EvoScientist.channels.bus.message_bus import MessageBus
+
+
+def _run(coro):
+ """Run an async coroutine safely, creating a fresh event loop."""
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+# ── Event tests ──
+
+
+class TestInboundMessage:
+ def test_session_key(self):
+ msg = InboundMessage(
+ channel="telegram", sender_id="u1",
+ chat_id="c1", content="hi",
+ )
+ assert msg.session_key == "telegram:c1"
+
+ def test_defaults(self):
+ msg = InboundMessage(
+ channel="discord", sender_id="u2",
+ chat_id="c2", content="hello",
+ )
+ assert msg.media == []
+ assert msg.metadata == {}
+ assert msg.message_id == ""
+
+
+class TestOutboundMessage:
+ def test_fields(self):
+ msg = OutboundMessage(
+ channel="telegram", chat_id="c1", content="reply",
+ )
+ assert msg.channel == "telegram"
+ assert msg.chat_id == "c1"
+ assert msg.reply_to is None
+ assert msg.media == []
+
+
+# ── MessageBus tests ──
+
+
+class TestMessageBus:
+ def test_inbound_publish_consume(self):
+ async def _test():
+ bus = MessageBus()
+ msg = InboundMessage(
+ channel="telegram", sender_id="u1",
+ chat_id="c1", content="hello",
+ )
+ await bus.publish_inbound(msg)
+ assert bus.inbound_size == 1
+ got = await bus.consume_inbound()
+ assert got is msg
+ assert bus.inbound_size == 0
+ _run(_test())
+
+ def test_outbound_publish_consume(self):
+ async def _test():
+ bus = MessageBus()
+ msg = OutboundMessage(
+ channel="discord", chat_id="c1", content="reply",
+ )
+ await bus.publish_outbound(msg)
+ assert bus.outbound_size == 1
+ got = await bus.consume_outbound()
+ assert got is msg
+ assert bus.outbound_size == 0
+ _run(_test())
+
+ def test_subscribe_and_dispatch(self):
+ async def _test():
+ bus = MessageBus()
+ received = []
+
+ async def callback(msg):
+ received.append(msg)
+
+ bus.subscribe_outbound("telegram", callback)
+
+ msg = OutboundMessage(
+ channel="telegram", chat_id="c1", content="hi",
+ )
+ await bus.publish_outbound(msg)
+
+ dispatch = asyncio.create_task(bus.dispatch_outbound())
+ await asyncio.sleep(0.05)
+ bus.stop()
+ await asyncio.sleep(0.05)
+ dispatch.cancel()
+
+ assert len(received) == 1
+ assert received[0] is msg
+ _run(_test())
+
+ def test_stop(self):
+ bus = MessageBus()
+ assert bus._running is False
+ bus.stop()
+ assert bus._running is False
diff --git a/tests/test_stream_state.py b/tests/test_stream_state.py
index c5e142d..29a3d00 100644
--- a/tests/test_stream_state.py
+++ b/tests/test_stream_state.py
@@ -554,162 +554,5 @@ class TestParseTodoItemsAdvanced:
# =============================================================================
-# ChannelState queue mechanism
+# ChannelState queue mechanism (removed — replaced by bus mode in channel.py)
# =============================================================================
-
-class TestChannelState:
- """Tests for _ChannelState queue-based communication."""
-
- def test_enqueue_creates_message_in_queue(self):
- """enqueue() should add a ChannelMessage to the queue."""
- from EvoScientist.cli import _ChannelState, ChannelMessage
- import queue
-
- # Clear any existing messages
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
-
- msg_id, event = _ChannelState.enqueue("test content", "sender@test.com", "Email")
- assert msg_id is not None
- assert event is not None
-
- # Message should be in queue
- msg = _ChannelState.message_queue.get_nowait()
- assert isinstance(msg, ChannelMessage)
- assert msg.content == "test content"
- assert msg.sender == "sender@test.com"
- assert msg.channel_type == "Email"
- assert msg.msg_id == msg_id
-
- def test_enqueue_creates_pending_response_slot(self):
- """enqueue() should create a response slot for the message."""
- from EvoScientist.cli import _ChannelState
- import queue
-
- # Clear queue and responses
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
- _ChannelState.pending_responses.clear()
-
- msg_id, _ = _ChannelState.enqueue("test", "sender", "iMessage")
- assert msg_id in _ChannelState.pending_responses
- assert _ChannelState.pending_responses[msg_id]["response"] is None
-
- # Cleanup
- _ChannelState.message_queue.get_nowait()
-
- def test_set_response_updates_slot_and_signals(self):
- """set_response() should update response and signal the event."""
- from EvoScientist.cli import _ChannelState
- import queue
-
- # Clear
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
- _ChannelState.pending_responses.clear()
-
- msg_id, event = _ChannelState.enqueue("test", "sender", "iMessage")
- _ChannelState.message_queue.get_nowait() # Remove from queue
-
- assert not event.is_set()
- _ChannelState.set_response(msg_id, "response text")
-
- assert event.is_set()
- assert _ChannelState.pending_responses[msg_id]["response"] == "response text"
-
- # Cleanup
- _ChannelState.pending_responses.clear()
-
- def test_get_response_waits_and_retrieves(self):
- """get_response() should wait for response and return it."""
- from EvoScientist.cli import _ChannelState
- import threading
- import queue
-
- # Clear
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
- _ChannelState.pending_responses.clear()
-
- msg_id, _ = _ChannelState.enqueue("test", "sender", "iMessage")
- _ChannelState.message_queue.get_nowait()
-
- # Set response in another thread
- def set_later():
- import time
- time.sleep(0.05)
- _ChannelState.set_response(msg_id, "async response")
-
- t = threading.Thread(target=set_later)
- t.start()
-
- response = _ChannelState.get_response(msg_id, timeout=1.0)
- t.join()
-
- assert response == "async response"
- assert msg_id not in _ChannelState.pending_responses # Cleaned up
-
- def test_get_response_returns_none_on_timeout(self):
- """get_response() should return None if timeout expires."""
- from EvoScientist.cli import _ChannelState
- import queue
-
- # Clear
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
- _ChannelState.pending_responses.clear()
-
- msg_id, _ = _ChannelState.enqueue("test", "sender", "iMessage")
- _ChannelState.message_queue.get_nowait()
-
- # Don't set response, let it timeout
- response = _ChannelState.get_response(msg_id, timeout=0.01)
- assert response is None
-
- # Cleanup
- _ChannelState.pending_responses.clear()
-
- def test_get_response_returns_none_for_unknown_id(self):
- """get_response() should return None for unknown message ID."""
- from EvoScientist.cli import _ChannelState
- response = _ChannelState.get_response("nonexistent-id", timeout=0.01)
- assert response is None
-
- def test_channel_message_dataclass(self):
- """ChannelMessage should store all fields correctly."""
- from EvoScientist.cli import ChannelMessage
-
- msg = ChannelMessage(
- msg_id="id123",
- content="Hello",
- sender="+1234567890",
- channel_type="iMessage",
- metadata={"key": "value"},
- )
- assert msg.msg_id == "id123"
- assert msg.content == "Hello"
- assert msg.sender == "+1234567890"
- assert msg.channel_type == "iMessage"
- assert msg.metadata == {"key": "value"}
-
- def test_channel_message_default_metadata(self):
- """ChannelMessage metadata should default to None."""
- from EvoScientist.cli import ChannelMessage
-
- msg = ChannelMessage("id", "content", "sender", "type")
- assert msg.metadata is None
diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py
new file mode 100644
index 0000000..e50f3a5
--- /dev/null
+++ b/tests/test_telegram_channel.py
@@ -0,0 +1,68 @@
+"""Tests for Telegram channel implementation."""
+
+import asyncio
+
+import pytest
+
+from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+from EvoScientist.channels.base import ChannelError
+
+
+def _run(coro):
+ """Run an async coroutine safely, creating a fresh event loop."""
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+class TestTelegramConfig:
+ def test_default_values(self):
+ config = TelegramConfig()
+ assert config.bot_token == ""
+ assert config.allowed_senders is None
+ assert config.text_chunk_limit == 4096
+
+ def test_custom_values(self):
+ config = TelegramConfig(
+ bot_token="test-token",
+ allowed_senders={"123", "456"},
+ text_chunk_limit=2000,
+ )
+ assert config.bot_token == "test-token"
+ assert config.allowed_senders == {"123", "456"}
+ assert config.text_chunk_limit == 2000
+
+
+class TestTelegramChannel:
+ def test_init(self):
+ config = TelegramConfig(bot_token="test")
+ channel = TelegramChannel(config)
+ assert channel.config is config
+ assert channel._running is False
+
+ def test_start_raises_without_token(self):
+ config = TelegramConfig(bot_token="")
+ channel = TelegramChannel(config)
+ with pytest.raises(ChannelError, match="bot token"):
+ _run(channel.start())
+
+ def test_stop_when_not_running(self):
+ config = TelegramConfig(bot_token="test")
+ channel = TelegramChannel(config)
+ _run(channel.stop())
+
+ def test_send_returns_false_without_app(self):
+ from EvoScientist.channels.base import OutboundMessage
+
+ config = TelegramConfig(bot_token="test")
+ channel = TelegramChannel(config)
+ msg = OutboundMessage(
+ channel="telegram",
+ chat_id="123",
+ content="hello",
+ metadata={"chat_id": "123"},
+ )
+ result = _run(channel.send(msg))
+ assert result is False
diff --git a/tests/test_wechat_channel.py b/tests/test_wechat_channel.py
new file mode 100644
index 0000000..e0ae457
--- /dev/null
+++ b/tests/test_wechat_channel.py
@@ -0,0 +1,458 @@
+"""Tests for WeChat channel implementation."""
+
+import asyncio
+import hashlib
+import time
+import xml.etree.ElementTree as ET
+
+import pytest
+
+from EvoScientist.channels.wechat.channel import (
+ WeChatChannel,
+ WeComConfig,
+ WeChatMPConfig,
+ _strip_markdown,
+)
+from EvoScientist.channels.wechat.crypto import (
+ WeChatCrypto,
+ parse_xml,
+ _pkcs7_pad,
+ _pkcs7_unpad,
+)
+from EvoScientist.channels.base import ChannelError
+
+
+def _run(coro):
+ """Run an async coroutine safely, creating a fresh event loop."""
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+# ── Config tests ──────────────────────────────────────────────────
+
+class TestWeComConfig:
+ def test_default_values(self):
+ config = WeComConfig()
+ assert config.corp_id == ""
+ assert config.agent_id == ""
+ assert config.secret == ""
+ assert config.webhook_port == 9001
+ assert config.allowed_senders is None
+ assert config.text_chunk_limit == 4096
+
+ def test_custom_values(self):
+ config = WeComConfig(
+ corp_id="corp123",
+ agent_id="1000001",
+ secret="my-secret",
+ token="my-token",
+ encoding_aes_key="a" * 43,
+ webhook_port=8080,
+ allowed_senders={"user1", "user2"},
+ )
+ assert config.corp_id == "corp123"
+ assert config.agent_id == "1000001"
+ assert config.allowed_senders == {"user1", "user2"}
+ assert config.webhook_port == 8080
+
+
+class TestWeChatMPConfig:
+ def test_default_values(self):
+ config = WeChatMPConfig()
+ assert config.app_id == ""
+ assert config.app_secret == ""
+ assert config.webhook_port == 9001
+
+ def test_custom_values(self):
+ config = WeChatMPConfig(
+ app_id="wx1234",
+ app_secret="secret",
+ token="mp-token",
+ )
+ assert config.app_id == "wx1234"
+
+
+# ── Channel init / lifecycle tests ────────────────────────────────
+
+class TestWeChatChannelInit:
+ def test_wecom_init(self):
+ config = WeComConfig(corp_id="corp", agent_id="1", secret="s")
+ channel = WeChatChannel(config, backend="wecom")
+ assert channel.name == "wechat"
+ assert channel._backend == "wecom"
+ assert channel._running is False
+
+ def test_mp_init(self):
+ config = WeChatMPConfig(app_id="wx", app_secret="s")
+ channel = WeChatChannel(config, backend="wechatmp")
+ assert channel._backend == "wechatmp"
+
+ def test_start_raises_without_corp_id(self):
+ config = WeComConfig(corp_id="", agent_id="1", secret="s")
+ channel = WeChatChannel(config, backend="wecom")
+ with pytest.raises(ChannelError, match="corp_id"):
+ _run(channel.start())
+
+ def test_start_raises_without_secret(self):
+ config = WeComConfig(corp_id="corp", agent_id="1", secret="")
+ channel = WeChatChannel(config, backend="wecom")
+ with pytest.raises(ChannelError, match="secret"):
+ _run(channel.start())
+
+ def test_start_raises_without_agent_id(self):
+ config = WeComConfig(corp_id="corp", agent_id="", secret="s")
+ channel = WeChatChannel(config, backend="wecom")
+ with pytest.raises(ChannelError, match="agent_id"):
+ _run(channel.start())
+
+ def test_start_raises_mp_without_app_id(self):
+ config = WeChatMPConfig(app_id="", app_secret="s")
+ channel = WeChatChannel(config, backend="wechatmp")
+ with pytest.raises(ChannelError, match="app_id"):
+ _run(channel.start())
+
+ def test_stop_when_not_running(self):
+ config = WeComConfig(corp_id="c", agent_id="1", secret="s")
+ channel = WeChatChannel(config, backend="wecom")
+ _run(channel.stop()) # Should not raise
+
+ def test_send_returns_false_without_client(self):
+ from EvoScientist.channels.base import OutboundMessage
+
+ config = WeComConfig(corp_id="c", agent_id="1", secret="s")
+ channel = WeChatChannel(config, backend="wecom")
+ msg = OutboundMessage(
+ channel="wechat",
+ chat_id="user1",
+ content="hello",
+ metadata={"chat_id": "user1"},
+ )
+ result = _run(channel.send(msg))
+ assert result is False
+
+
+# ── Markdown stripping tests ──────────────────────────────────────
+
+class TestStripMarkdown:
+ def test_plain_text(self):
+ assert _strip_markdown("hello world") == "hello world"
+
+ def test_bold(self):
+ assert _strip_markdown("**bold**") == "bold"
+
+ def test_italic(self):
+ assert _strip_markdown("_italic_") == "italic"
+
+ def test_code(self):
+ assert _strip_markdown("`code`") == "code"
+
+ def test_link(self):
+ result = _strip_markdown("[text](https://example.com)")
+ assert "text" in result
+ assert "https://example.com" in result
+
+ def test_heading(self):
+ assert _strip_markdown("## Title").strip() == "Title"
+
+ def test_list_items(self):
+ result = _strip_markdown("- item1\n- item2")
+ assert "• item1" in result
+ assert "• item2" in result
+
+ def test_strikethrough(self):
+ assert _strip_markdown("~~deleted~~") == "deleted"
+
+ def test_code_block(self):
+ text = "```python\nprint('hi')\n```"
+ result = _strip_markdown(text)
+ assert "print('hi')" in result
+
+
+# ── XML parsing tests ─────────────────────────────────────────────
+
+class TestParseXml:
+ def test_basic_text_message(self):
+ xml = (
+ ""
+ " "
+ " "
+ " "
+ " "
+ "1234 "
+ "1700000000 "
+ " "
+ )
+ data = parse_xml(xml)
+ assert data["MsgType"] == "text"
+ assert data["Content"] == "hello"
+ assert data["FromUserName"] == "user123"
+ assert data["MsgId"] == "1234"
+
+ def test_image_message(self):
+ xml = (
+ ""
+ " "
+ " "
+ " "
+ " "
+ " "
+ )
+ data = parse_xml(xml)
+ assert data["MsgType"] == "image"
+ assert data["PicUrl"] == "https://example.com/img.jpg"
+
+ def test_event_message(self):
+ xml = (
+ ""
+ " "
+ " "
+ " "
+ " "
+ )
+ data = parse_xml(xml)
+ assert data["MsgType"] == "event"
+ assert data["Event"] == "subscribe"
+
+
+# ── Crypto tests ──────────────────────────────────────────────────
+
+class TestPKCS7:
+ def test_pad_unpad_roundtrip(self):
+ data = b"hello"
+ padded = _pkcs7_pad(data)
+ assert len(padded) % 32 == 0
+ assert _pkcs7_unpad(padded) == data
+
+ def test_pad_block_aligned(self):
+ data = b"x" * 32
+ padded = _pkcs7_pad(data)
+ assert len(padded) == 64 # full padding block added
+ assert _pkcs7_unpad(padded) == data
+
+
+class TestWeChatCrypto:
+ """Test the encryption/decryption roundtrip.
+
+ Uses a deterministic 43-char EncodingAESKey.
+ """
+
+ # Skip encryption tests when no crypto backend is available
+ _has_crypto = False
+ try:
+ from Crypto.Cipher import AES as _aes # noqa: F401
+ _has_crypto = True
+ except ImportError:
+ try:
+ import pyaes as _pyaes # noqa: F401
+ _has_crypto = True
+ except ImportError:
+ pass
+ pytestmark = pytest.mark.skipif(
+ not _has_crypto,
+ reason="pycryptodome or pyaes required for encryption tests",
+ )
+
+ @pytest.fixture
+ def crypto(self):
+ # 43 base64 chars → 32 bytes AES key
+ key = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
+ return WeChatCrypto(
+ token="test_token",
+ encoding_aes_key=key,
+ app_id="wx_test_app",
+ )
+
+ def test_encrypt_decrypt_roundtrip(self, crypto):
+ msg = "Hello WeChat! "
+ encrypted = crypto.encrypt(msg)
+ decrypted, app_id = crypto.decrypt(encrypted)
+ assert decrypted == msg
+ assert app_id == "wx_test_app"
+
+ def test_verify_signature(self, crypto):
+ timestamp = "1609459200"
+ nonce = "abc123"
+ parts = sorted([crypto.token, timestamp, nonce])
+ expected = hashlib.sha1("".join(parts).encode()).hexdigest()
+ assert crypto.verify_signature(expected, timestamp, nonce)
+ assert not crypto.verify_signature("wrong", timestamp, nonce)
+
+ def test_verify_signature_with_encrypt(self, crypto):
+ timestamp = "1609459200"
+ nonce = "abc123"
+ encrypt = "some_encrypted_data"
+ parts = sorted([crypto.token, timestamp, nonce, encrypt])
+ expected = hashlib.sha1("".join(parts).encode()).hexdigest()
+ assert crypto.verify_signature(expected, timestamp, nonce, encrypt)
+
+ def test_generate_signature(self, crypto):
+ encrypt = "test_encrypted"
+ timestamp = "1609459200"
+ nonce = "abc"
+ sig = crypto.generate_signature(encrypt, timestamp, nonce)
+ parts = sorted([crypto.token, timestamp, nonce, encrypt])
+ expected = hashlib.sha1("".join(parts).encode()).hexdigest()
+ assert sig == expected
+
+ def test_wrap_encrypted_reply(self, crypto):
+ msg = "Reply "
+ xml_reply = crypto.wrap_encrypted_reply(msg)
+ assert "" in xml_reply
+ assert "" in xml_reply
+ assert "" in xml_reply
+ assert "" in xml_reply
+
+ # Parse and verify the encrypted content decrypts back
+ root = ET.fromstring(xml_reply)
+ encrypt = root.find("Encrypt").text
+ decrypted, app_id = crypto.decrypt(encrypt)
+ assert decrypted == msg
+
+
+# ── Message processing tests ──────────────────────────────────────
+
+class TestMessageProcessing:
+ """Test the _process_message method with various XML payloads."""
+
+ def _make_channel(self):
+ config = WeComConfig(
+ corp_id="corp", agent_id="1", secret="s",
+ )
+ return WeChatChannel(config, backend="wecom")
+
+ def test_text_message_queued(self):
+ channel = self._make_channel()
+
+ async def _test():
+ await channel._process_message({
+ "MsgType": "text",
+ "Content": "Hello!",
+ "FromUserName": "user1",
+ "ToUserName": "bot",
+ "MsgId": "100",
+ "CreateTime": str(int(time.time())),
+ })
+ # Check message was enqueued
+ assert not channel._queue.empty()
+ msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
+ assert msg.content == "Hello!"
+ assert msg.sender_id == "user1"
+ assert msg.channel == "wechat"
+
+ _run(_test())
+
+ def test_location_message(self):
+ channel = self._make_channel()
+
+ async def _test():
+ await channel._process_message({
+ "MsgType": "location",
+ "Location_X": "39.9",
+ "Location_Y": "116.4",
+ "Label": "Beijing",
+ "FromUserName": "user1",
+ "ToUserName": "bot",
+ "MsgId": "101",
+ "CreateTime": str(int(time.time())),
+ })
+ msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
+ assert "Beijing" in msg.content
+ assert "39.9" in msg.content
+
+ _run(_test())
+
+ def test_voice_recognition(self):
+ channel = self._make_channel()
+
+ async def _test():
+ await channel._process_message({
+ "MsgType": "voice",
+ "Recognition": "你好世界",
+ "FromUserName": "user1",
+ "ToUserName": "bot",
+ "MsgId": "102",
+ "CreateTime": str(int(time.time())),
+ })
+ msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
+ assert "你好世界" in msg.content
+
+ _run(_test())
+
+ def test_link_message(self):
+ channel = self._make_channel()
+
+ async def _test():
+ await channel._process_message({
+ "MsgType": "link",
+ "Title": "Test Link",
+ "Description": "A description",
+ "Url": "https://example.com",
+ "FromUserName": "user1",
+ "ToUserName": "bot",
+ "MsgId": "103",
+ "CreateTime": str(int(time.time())),
+ })
+ msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
+ assert "Test Link" in msg.content
+ assert "https://example.com" in msg.content
+
+ _run(_test())
+
+ def test_subscribe_event(self):
+ channel = self._make_channel()
+
+ async def _test():
+ await channel._process_message({
+ "MsgType": "event",
+ "Event": "subscribe",
+ "FromUserName": "user1",
+ "ToUserName": "bot",
+ "MsgId": "",
+ "CreateTime": str(int(time.time())),
+ })
+ msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0)
+ assert "关注" in msg.content
+
+ _run(_test())
+
+ def test_unsubscribe_ignored(self):
+ channel = self._make_channel()
+
+ async def _test():
+ await channel._process_message({
+ "MsgType": "event",
+ "Event": "unsubscribe",
+ "FromUserName": "user1",
+ "ToUserName": "bot",
+ "MsgId": "",
+ "CreateTime": str(int(time.time())),
+ })
+ assert channel._queue.empty()
+
+ _run(_test())
+
+ def test_empty_message_ignored(self):
+ channel = self._make_channel()
+
+ async def _test():
+ await channel._process_message({
+ "MsgType": "text",
+ "Content": "",
+ "FromUserName": "",
+ "ToUserName": "bot",
+ })
+ assert channel._queue.empty()
+
+ _run(_test())
+
+
+# ── Registration test ─────────────────────────────────────────────
+
+class TestChannelRegistration:
+ def test_wechat_registered(self):
+ from EvoScientist.channels.channel_manager import available_channels
+ channels = available_channels()
+ assert "wechat" in channels