diff --git a/.gitignore b/.gitignore
index 5fa9c7a..a3548d1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,8 +15,6 @@ build/
.venv/
venv/
uv.lock
-bridge/node_modules/
-bridge/package-lock.json
# IDE / Tools
.vscode/
@@ -37,5 +35,4 @@ memory/
*.ipynb
*CLAUDE.md
*AGENTS.md
-*meals/
-botpy.log
\ No newline at end of file
+*meals/
\ No newline at end of file
diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py
index c3700df..0b1614d 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, view_image
+from .tools import tavily_search, think_tool, skill_manager
from . import paths as _paths_mod
from .paths import set_active_workspace, set_workspace_root
@@ -110,11 +110,10 @@ 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, view_image]
+BASE_TOOLS = [think_tool, skill_manager]
# 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 29c0520..2a5af27 100644
--- a/EvoScientist/__init__.py
+++ b/EvoScientist/__init__.py
@@ -34,9 +34,6 @@ _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
deleted file mode 100644
index c445bb9..0000000
--- a/EvoScientist/channels/README.md
+++ /dev/null
@@ -1,552 +0,0 @@
-# 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 7085a47..1fac31d 100644
--- a/EvoScientist/channels/__init__.py
+++ b/EvoScientist/channels/__init__.py
@@ -1,44 +1,9 @@
"""Communication channels for EvoScientist.
This module provides an extensible interface for different messaging channels
-(iMessage, Telegram, Discord) to communicate with the EvoScientist agent.
+(iMessage, WeChat, etc.) to communicate with the EvoScientist agent.
"""
-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
+from .base import 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",
-]
+__all__ = ["Channel", "IncomingMessage", "OutgoingMessage"]
diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py
index fbe938d..ecf1260 100644
--- a/EvoScientist/channels/base.py
+++ b/EvoScientist/channels/base.py
@@ -5,358 +5,42 @@ 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 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
+from typing import AsyncIterator
@dataclass
-class RawIncoming:
- """Raw data extracted from a platform-specific message event.
+class IncomingMessage:
+ """Represents a message received from a channel."""
- 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
+ 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
-class Channel(ChannelPlugin, ABC):
+@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):
"""Abstract base class for messaging channels.
Subclasses must implement:
- start(): Initialize the channel (connect, authenticate, etc.)
- - _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").
+ - stop(): Clean up resources
+ - receive(): Async iterator yielding incoming messages
+ - send(): Send a message through the channel
"""
- 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.
@@ -371,649 +55,56 @@ class Channel(ChannelPlugin, ABC):
"""
pass
- async def stop(self) -> None:
- """Stop the channel. Cancels typing tasks, then calls _cleanup()."""
- self._running = False
- await self._typing_manager.stop_all()
- await self._cleanup()
-
- 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.
- """
- 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 _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."""
- ...
+ async def stop(self) -> None:
+ """Stop the channel and clean up resources.
- _format_fallback_patterns: tuple[str, ...] = ("parse", "invalid")
-
- 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.
+ This method should:
+ - Close connections
+ - Cancel background tasks
+ - Release any held resources
"""
- 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
-
- 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:
- 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.
- """
- 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 ──────────────────────────────────────
+ @abstractmethod
+ async def receive(self) -> AsyncIterator[IncomingMessage]:
+ """Async iterator that yields incoming messages.
- 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)
+ Yields:
+ IncomingMessage: Each new message received
- 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.
+ Example:
+ async for msg in channel.receive():
+ print(f"From {msg.sender}: {msg.content}")
"""
- import asyncio
- import concurrent.futures
+ pass
- 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()
+ @abstractmethod
+ async def send(self, message: OutgoingMessage) -> bool:
+ """Send a message through the channel.
- def _raw_to_inbound(self, raw: RawIncoming) -> InboundMessage | None:
- """Convert a RawIncoming to InboundMessage (pure transformation, no filtering).
+ Args:
+ message: The message to send
- Merges text + annotations into content, sets metadata.
- Returns None only if there is no content and no media.
+ Returns:
+ True if sent successfully, False otherwise
"""
- 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
+ pass
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
deleted file mode 100644
index 3510967..0000000
--- a/EvoScientist/channels/bus/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""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
deleted file mode 100644
index 411006f..0000000
--- a/EvoScientist/channels/bus/events.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""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
deleted file mode 100644
index 3e68697..0000000
--- a/EvoScientist/channels/bus/message_bus.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""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
deleted file mode 100644
index 5060dd9..0000000
--- a/EvoScientist/channels/capabilities.py
+++ /dev/null
@@ -1,220 +0,0 @@
-"""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
deleted file mode 100644
index 989cc64..0000000
--- a/EvoScientist/channels/channel_manager.py
+++ /dev/null
@@ -1,995 +0,0 @@
-"""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
deleted file mode 100644
index c6afb77..0000000
--- a/EvoScientist/channels/config.py
+++ /dev/null
@@ -1,126 +0,0 @@
-"""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
deleted file mode 100644
index 62463c9..0000000
--- a/EvoScientist/channels/consumer.py
+++ /dev/null
@@ -1,407 +0,0 @@
-"""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
deleted file mode 100644
index 2026d82..0000000
--- a/EvoScientist/channels/dingtalk/__init__.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""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
deleted file mode 100644
index 1b1bffb..0000000
--- a/EvoScientist/channels/dingtalk/channel.py
+++ /dev/null
@@ -1,354 +0,0 @@
-"""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
deleted file mode 100644
index eb0eeb2..0000000
--- a/EvoScientist/channels/dingtalk/probe.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""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
deleted file mode 100644
index 8fb4abb..0000000
--- a/EvoScientist/channels/dingtalk/serve.py
+++ /dev/null
@@ -1,92 +0,0 @@
-"""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
deleted file mode 100644
index dfc7740..0000000
--- a/EvoScientist/channels/discord/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-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
deleted file mode 100644
index 3ef69c9..0000000
--- a/EvoScientist/channels/discord/channel.py
+++ /dev/null
@@ -1,255 +0,0 @@
-"""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
deleted file mode 100644
index 9fbc4c0..0000000
--- a/EvoScientist/channels/discord/probe.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""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
deleted file mode 100644
index 9e8f000..0000000
--- a/EvoScientist/channels/discord/serve.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""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
deleted file mode 100644
index 56bd5f9..0000000
--- a/EvoScientist/channels/email/__init__.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""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
deleted file mode 100644
index 2c2a2da..0000000
--- a/EvoScientist/channels/email/channel.py
+++ /dev/null
@@ -1,372 +0,0 @@
-"""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 deleted file mode 100644 index 84c6c49..0000000 --- a/EvoScientist/channels/email/probe.py +++ /dev/null @@ -1,67 +0,0 @@ -"""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 deleted file mode 100644 index c014dca..0000000 --- a/EvoScientist/channels/email/serve.py +++ /dev/null @@ -1,124 +0,0 @@ -"""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 deleted file mode 100644 index 4cc68e5..0000000 --- a/EvoScientist/channels/feishu/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index e444be9..0000000 --- a/EvoScientist/channels/feishu/channel.py +++ /dev/null @@ -1,780 +0,0 @@ -"""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 deleted file mode 100644 index 46d962e..0000000 --- a/EvoScientist/channels/feishu/probe.py +++ /dev/null @@ -1,39 +0,0 @@ -"""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 deleted file mode 100644 index c9d0709..0000000 --- a/EvoScientist/channels/feishu/serve.py +++ /dev/null @@ -1,113 +0,0 @@ -"""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 deleted file mode 100644 index c37e854..0000000 --- a/EvoScientist/channels/formatter.py +++ /dev/null @@ -1,287 +0,0 @@ -"""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
deleted file mode 100644
index 9cd8656..0000000
--- a/tests/test_channel_manager.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""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
deleted file mode 100644
index 6d5152d..0000000
--- a/tests/test_discord_channel.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""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
deleted file mode 100644
index 842efc8..0000000
--- a/tests/test_mention_gating.py
+++ /dev/null
@@ -1,541 +0,0 @@
-"""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
deleted file mode 100644
index 700423d..0000000
--- a/tests/test_message_bus.py
+++ /dev/null
@@ -1,111 +0,0 @@
-"""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 29a3d00..c5e142d 100644
--- a/tests/test_stream_state.py
+++ b/tests/test_stream_state.py
@@ -554,5 +554,162 @@ class TestParseTodoItemsAdvanced:
# =============================================================================
-# ChannelState queue mechanism (removed — replaced by bus mode in channel.py)
+# ChannelState queue mechanism
# =============================================================================
+
+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
deleted file mode 100644
index e50f3a5..0000000
--- a/tests/test_telegram_channel.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""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
deleted file mode 100644
index e0ae457..0000000
--- a/tests/test_wechat_channel.py
+++ /dev/null
@@ -1,458 +0,0 @@
-"""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