From 61cf79e5254e5637375c27ac155d892380c5aa83 Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 02:11:38 +0800
Subject: [PATCH 01/12] for merge
---
.gitignore | 2 +
EvoScientist/EvoScientist.py | 9 +-
EvoScientist/__init__.py | 3 +
EvoScientist/channels/README.md | 552 ++++++
EvoScientist/channels/__init__.py | 44 +-
EvoScientist/channels/base.py | 1004 ++++++++++-
EvoScientist/channels/bus/__init__.py | 6 +
EvoScientist/channels/bus/events.py | 50 +
EvoScientist/channels/bus/message_bus.py | 96 ++
EvoScientist/channels/capabilities.py | 220 +++
EvoScientist/channels/channel_manager.py | 1089 ++++++++++++
EvoScientist/channels/config.py | 126 ++
EvoScientist/channels/consumer.py | 404 +++++
EvoScientist/channels/dingtalk/__init__.py | 29 +
EvoScientist/channels/dingtalk/channel.py | 287 ++++
EvoScientist/channels/dingtalk/probe.py | 33 +
EvoScientist/channels/dingtalk/serve.py | 92 +
EvoScientist/channels/discord/__init__.py | 19 +
EvoScientist/channels/discord/channel.py | 256 +++
EvoScientist/channels/discord/probe.py | 33 +
EvoScientist/channels/discord/serve.py | 93 ++
EvoScientist/channels/email/__init__.py | 41 +
EvoScientist/channels/email/channel.py | 349 ++++
EvoScientist/channels/email/probe.py | 67 +
EvoScientist/channels/email/serve.py | 124 ++
EvoScientist/channels/feishu/__init__.py | 21 +
EvoScientist/channels/feishu/channel.py | 777 +++++++++
EvoScientist/channels/feishu/probe.py | 39 +
EvoScientist/channels/feishu/serve.py | 113 ++
EvoScientist/channels/formatter.py | 287 ++++
EvoScientist/channels/imessage/__init__.py | 9 +
EvoScientist/channels/imessage/channel_rpc.py | 333 ++--
EvoScientist/channels/imessage/serve.py | 355 +---
EvoScientist/channels/middleware.py | 806 +++++++++
EvoScientist/channels/mixins.py | 307 ++++
EvoScientist/channels/plugin.py | 226 +++
EvoScientist/channels/qq/__init__.py | 26 +
EvoScientist/channels/qq/channel.py | 259 +++
EvoScientist/channels/qq/probe.py | 33 +
EvoScientist/channels/qq/serve.py | 87 +
EvoScientist/channels/retry.py | 122 ++
EvoScientist/channels/signal/__init__.py | 27 +
EvoScientist/channels/signal/channel.py | 443 +++++
EvoScientist/channels/signal/probe.py | 32 +
EvoScientist/channels/signal/serve.py | 99 ++
EvoScientist/channels/slack/__init__.py | 19 +
EvoScientist/channels/slack/channel.py | 292 ++++
EvoScientist/channels/slack/probe.py | 47 +
EvoScientist/channels/slack/serve.py | 94 ++
EvoScientist/channels/standalone.py | 142 ++
EvoScientist/channels/telegram/__init__.py | 17 +
EvoScientist/channels/telegram/channel.py | 291 ++++
EvoScientist/channels/telegram/probe.py | 32 +
EvoScientist/channels/telegram/serve.py | 81 +
EvoScientist/channels/wechat/__init__.py | 69 +
EvoScientist/channels/wechat/channel.py | 777 +++++++++
EvoScientist/channels/wechat/crypto.py | 189 +++
EvoScientist/channels/wechat/probe.py | 72 +
EvoScientist/channels/wechat/serve.py | 130 ++
EvoScientist/channels/wechat/verify_server.py | 169 ++
EvoScientist/cli/__init__.py | 2 +-
EvoScientist/cli/_app.py | 4 +
EvoScientist/cli/channel.py | 568 ++++---
EvoScientist/cli/commands.py | 102 +-
EvoScientist/cli/interactive.py | 138 +-
EvoScientist/config/settings.py | 98 +-
EvoScientist/middleware/__init__.py | 23 +
EvoScientist/prompts.py | 4 +
EvoScientist/stream/emitter.py | 2 +-
EvoScientist/stream/formatter.py | 6 +-
EvoScientist/tools/__init__.py | 2 +
EvoScientist/tools/image.py | 74 +
EvoScientist/utils.py | 4 +
pyproject.toml | 11 +
tests/test_bus_integration.py | 287 ++++
tests/test_channel_comprehensive.py | 1485 +++++++++++++++++
tests/test_channel_manager.py | 167 ++
tests/test_discord_channel.py | 71 +
tests/test_mention_gating.py | 521 ++++++
tests/test_message_bus.py | 112 ++
tests/test_stream_state.py | 159 +-
tests/test_telegram_channel.py | 68 +
tests/test_wechat_channel.py | 443 +++++
83 files changed, 15100 insertions(+), 1101 deletions(-)
create mode 100644 EvoScientist/channels/README.md
create mode 100644 EvoScientist/channels/bus/__init__.py
create mode 100644 EvoScientist/channels/bus/events.py
create mode 100644 EvoScientist/channels/bus/message_bus.py
create mode 100644 EvoScientist/channels/capabilities.py
create mode 100644 EvoScientist/channels/channel_manager.py
create mode 100644 EvoScientist/channels/config.py
create mode 100644 EvoScientist/channels/consumer.py
create mode 100644 EvoScientist/channels/dingtalk/__init__.py
create mode 100644 EvoScientist/channels/dingtalk/channel.py
create mode 100644 EvoScientist/channels/dingtalk/probe.py
create mode 100644 EvoScientist/channels/dingtalk/serve.py
create mode 100644 EvoScientist/channels/discord/__init__.py
create mode 100644 EvoScientist/channels/discord/channel.py
create mode 100644 EvoScientist/channels/discord/probe.py
create mode 100644 EvoScientist/channels/discord/serve.py
create mode 100644 EvoScientist/channels/email/__init__.py
create mode 100644 EvoScientist/channels/email/channel.py
create mode 100644 EvoScientist/channels/email/probe.py
create mode 100644 EvoScientist/channels/email/serve.py
create mode 100644 EvoScientist/channels/feishu/__init__.py
create mode 100644 EvoScientist/channels/feishu/channel.py
create mode 100644 EvoScientist/channels/feishu/probe.py
create mode 100644 EvoScientist/channels/feishu/serve.py
create mode 100644 EvoScientist/channels/formatter.py
create mode 100644 EvoScientist/channels/middleware.py
create mode 100644 EvoScientist/channels/mixins.py
create mode 100644 EvoScientist/channels/plugin.py
create mode 100644 EvoScientist/channels/qq/__init__.py
create mode 100644 EvoScientist/channels/qq/channel.py
create mode 100644 EvoScientist/channels/qq/probe.py
create mode 100644 EvoScientist/channels/qq/serve.py
create mode 100644 EvoScientist/channels/retry.py
create mode 100644 EvoScientist/channels/signal/__init__.py
create mode 100644 EvoScientist/channels/signal/channel.py
create mode 100644 EvoScientist/channels/signal/probe.py
create mode 100644 EvoScientist/channels/signal/serve.py
create mode 100644 EvoScientist/channels/slack/__init__.py
create mode 100644 EvoScientist/channels/slack/channel.py
create mode 100644 EvoScientist/channels/slack/probe.py
create mode 100644 EvoScientist/channels/slack/serve.py
create mode 100644 EvoScientist/channels/standalone.py
create mode 100644 EvoScientist/channels/telegram/__init__.py
create mode 100644 EvoScientist/channels/telegram/channel.py
create mode 100644 EvoScientist/channels/telegram/probe.py
create mode 100644 EvoScientist/channels/telegram/serve.py
create mode 100644 EvoScientist/channels/wechat/__init__.py
create mode 100644 EvoScientist/channels/wechat/channel.py
create mode 100644 EvoScientist/channels/wechat/crypto.py
create mode 100644 EvoScientist/channels/wechat/probe.py
create mode 100644 EvoScientist/channels/wechat/serve.py
create mode 100644 EvoScientist/channels/wechat/verify_server.py
create mode 100644 EvoScientist/tools/image.py
create mode 100644 tests/test_bus_integration.py
create mode 100644 tests/test_channel_comprehensive.py
create mode 100644 tests/test_channel_manager.py
create mode 100644 tests/test_discord_channel.py
create mode 100644 tests/test_mention_gating.py
create mode 100644 tests/test_message_bus.py
create mode 100644 tests/test_telegram_channel.py
create mode 100644 tests/test_wechat_channel.py
diff --git a/.gitignore b/.gitignore
index a3548d1..205410b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,8 @@ build/
.venv/
venv/
uv.lock
+bridge/node_modules/
+bridge/package-lock.json
# IDE / Tools
.vscode/
diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py
index f8d2fdb..6b43e14 100644
--- a/EvoScientist/EvoScientist.py
+++ b/EvoScientist/EvoScientist.py
@@ -23,10 +23,10 @@ from .backends import CustomSandboxBackend, MergedReadOnlyBackend
from .config import get_effective_config, apply_config_to_env
from .llm import get_chat_model
from .mcp import load_mcp_tools
-from .middleware import create_memory_middleware
+from .middleware import create_memory_middleware, create_skills_middleware
from .prompts import RESEARCHER_INSTRUCTIONS, get_system_prompt
from .utils import load_subagents
-from .tools import tavily_search, think_tool, skill_manager
+from .tools import tavily_search, think_tool, skill_manager, view_image
from .paths import (
ensure_dirs,
default_workspace_dir,
@@ -106,10 +106,11 @@ backend = CompositeBackend(
tool_registry = {
"think_tool": think_tool,
"tavily_search": tavily_search,
+ "view_image": view_image,
}
# Base tools that every agent variant gets (before MCP)
-BASE_TOOLS = [think_tool, skill_manager]
+BASE_TOOLS = [think_tool, skill_manager, view_image]
def _build_base_kwargs(base_backend, base_middleware):
@@ -178,6 +179,7 @@ prompt_refs = {
base_middleware = [
create_memory_middleware(MEMORY_DIR, extraction_model=chat_model),
+ create_skills_middleware(backend),
]
# Default agent (no checkpointer) — used by langgraph dev / LangSmith / notebooks.
@@ -245,6 +247,7 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None):
mw = [
create_memory_middleware(MEMORY_DIR, extraction_model=chat_model),
+ create_skills_middleware(be),
]
# Re-load MCP tools from current config (picks up /mcp add changes)
diff --git a/EvoScientist/__init__.py b/EvoScientist/__init__.py
index 2a5af27..29c0520 100644
--- a/EvoScientist/__init__.py
+++ b/EvoScientist/__init__.py
@@ -34,6 +34,9 @@ _EXPORTS: dict[str, tuple[str, str]] = {
# Tools
"tavily_search": (".tools", "tavily_search"),
"think_tool": (".tools", "think_tool"),
+ "view_image": (".tools", "view_image"),
+ # Middleware
+ "create_skills_middleware": (".middleware", "create_skills_middleware"),
# Sessions
"get_checkpointer": (".sessions", "get_checkpointer"),
"generate_thread_id": (".sessions", "generate_thread_id"),
diff --git a/EvoScientist/channels/README.md b/EvoScientist/channels/README.md
new file mode 100644
index 0000000..c445bb9
--- /dev/null
+++ b/EvoScientist/channels/README.md
@@ -0,0 +1,552 @@
+# Channels
+
+EvoScientist provides unified integration with 11 messaging platforms. This document covers the architecture overview, capability matrix, and detailed deployment guide for each channel.
+
+Configuration file: `~/.config/evoscientist/config.yaml` (or use environment variables with the `EVOSCIENTIST_` prefix).
+
+## Architecture
+
+```
+┌──────────┐ ┌──────────┐ ┌──────────┐
+│ Telegram │ │ Discord │ │ Slack │ ... (×11)
+└────┬─────┘ └────┬─────┘ └────┬─────┘
+ │ │ │
+ └─────────────┼─────────────┘
+ ▼
+ ┌──────────────┐
+ │ MessageBus │ async queue, 5000 cap
+ └──────┬───────┘
+ ▼
+ ┌──────────────┐
+ │InboundConsumer│ → Agent → OutboundMessage
+ └──────┬───────┘
+ ▼
+ ┌──────────────┐
+ │ Dispatcher │ routes replies to origin channel
+ └──────────────┘
+```
+
+**Core modules:**
+
+| Module | Responsibility |
+|--------|---------------|
+| `base.py` | Abstract `Channel` base class — declarative readiness checks, retry strategy, mention stripping, send fallback, media handling |
+| `capabilities.py` | `ChannelCapabilities` frozen dataclass — each channel declares its capabilities, framework adapts automatically |
+| `mixins.py` | Reusable patterns: `WebhookMixin` (aiohttp + httpx), `WebSocketMixin` (connect/reconnect/heartbeat), `PollingMixin` (async polling), `TokenMixin` (OAuth token refresh) |
+| `config.py` | `BaseChannelConfig` — shared config fields (allowed_senders, proxy, text_chunk_limit, etc.) |
+| `bus/` | `MessageBus` async message queue + `InboundMessage`/`OutboundMessage` dataclasses |
+| `channel_manager.py` | Lifecycle management (start/stop), health checks, channel registry |
+| `consumer.py` | `InboundConsumer` — dequeue messages, invoke Agent, publish replies |
+| `retry.py` | Configurable exponential backoff retry (`RetryConfig`: attempts, min/max delay, jitter) |
+| `markdown_utils.py` | Universal Markdown converter with per-platform formatting plugins |
+
+## Capability Matrix
+
+| Channel | Format | Max Len | Media | Voice | Sticker | Location | Video | Typing | Reaction | Thread | Group | @Mention | No Public IP | Token Refresh | Proxy | Allowlist |
+|:--------|:------:|:-------:|:-----:|:-----:|:-------:|:--------:|:-----:|:------:|:--------:|:------:|:-----:|:--------:|:------------:|:-------------:|:-----:|:---------:|
+| Telegram | HTML | 4000 | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ | | ✓ | ✓ | ✓ | | ✓ | ✓ |
+| Discord | Discord | 2000 | ✓ | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ |
+| Slack | Mrkdwn | 4000 | ✓ | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ |
+| Feishu | MD | 4096 | ✓ | ✓ | ✓ | | | | ✓ | | ✓ | ✓ | | ✓ | ✓ | ✓ |
+| WeChat | MD | 4096 | ✓ | ✓ | | ✓ | | | | | ✓ | ✓ | | ✓ | ✓ | ✓ |
+| DingTalk | MD | 4096 | ✓ | ✓ | | | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
+| QQ | Plain | 4096 | ✓ | | | | | | | | ✓ | ✓ | ✓ | | | ✓ |
+| Signal | Plain | 4096 | ✓ | ✓ | | | | ✓ | ✓ | | ✓ | ✓ | ✓ | | | ✓ |
+| iMessage | Plain | ∞ | ✓ | ✓ | | | | | | | ✓ | | ✓ | | | ✓ |
+| Email | HTML | ∞ | ✓ | | | | | | | | | | ✓ | | | ✓ |
+
+### Connection Types
+
+| Channel | Transport | Connection Mode | Default Port |
+|-------------|-----------|----------------------------------------|:------------:|
+| Telegram | HTTPS | Long polling (`getUpdates`) | — |
+| Discord | WebSocket | Gateway events (`discord.py`) | — |
+| Slack | WebSocket | Socket Mode (`slack-sdk`) | — |
+| Feishu | HTTP | Webhook `POST /webhook/event` | 9000 |
+| WeChat | HTTP | Webhook `POST /wechat/callback` | 9001 |
+| DingTalk | WebSocket | Stream Mode (DingTalk gateway) | — |
+| QQ | WebSocket | Bot Gateway (`qq-botpy`) | — |
+| Signal | TCP | JSON-RPC (`signal-cli` daemon) | 7583 |
+| iMessage | stdio | JSON-RPC (`imsg` CLI) | — |
+| Email | TCP | IMAP polling + SMTP send | 993/587 |
+
+> **"—"** means no listening port is required — no public IP or port forwarding needed.
+
+## Quick Start
+
+### 1. Install channel dependencies
+
+```bash
+pip install evoscientist[telegram]
+# Available extras: telegram, discord, slack, feishu, wechat,
+# dingtalk, qq, email, signal
+# iMessage requires no extra Python dependencies
+```
+
+### 2. Configure
+
+```bash
+# Option A: Interactive wizard
+EvoSci onboard
+
+# Option B: CLI commands
+EvoSci config set channel_enabled telegram
+EvoSci config set telegram_bot_token "123456:ABC-xxx"
+
+# Option C: Environment variables (EVOSCIENTIST_ prefix, uppercase)
+export EVOSCIENTIST_CHANNEL_ENABLED=telegram
+export EVOSCIENTIST_TELEGRAM_BOT_TOKEN="123456:ABC-xxx"
+```
+
+### 3. Start
+
+```bash
+EvoSci serve # Start agent + all enabled channels
+# or
+EvoSci channel start # Standalone channel mode (message loop only)
+```
+
+### 4. Health check
+
+```bash
+curl http://localhost:8080/healthz
+```
+
+```json
+{
+ "status": "healthy",
+ "channels": { "enabled": ["telegram"], "running": ["telegram"] }
+}
+```
+
+### Running multiple channels
+
+Comma-separate channel names in the config to enable multiple channels simultaneously:
+
+```yaml
+channel_enabled: "telegram,discord,imessage"
+```
+
+All enabled channels run concurrently via the internal message bus.
+
+---
+
+## Channel Deployment Guides
+
+---
+
+### Telegram
+
+**Install:** `pip install evoscientist[telegram]`
+
+**Prerequisites:**
+
+1. Search for [@BotFather](https://t.me/BotFather) in Telegram, send `/newbot`, and follow the prompts to create a bot.
+2. BotFather will return a Bot Token (format: `123456789:ABCdefGHI...`) — save it securely.
+3. Get your user ID: send any message to [@userinfobot](https://t.me/userinfobot), it will reply with your numeric ID.
+4. (Optional) For group use: add the bot to a group, then in BotFather send `/setprivacy` → `Disable` so the bot can read group messages.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "telegram"
+telegram_bot_token: "123456789:ABCdefGHIjklMNOpqrSTUvwxYZ"
+telegram_allowed_senders: "" # Comma-separated user IDs; empty = no restriction
+telegram_proxy: "" # Optional HTTPS proxy (e.g. http://proxy:8080)
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `telegram_bot_token` | `str` | `""` | **Required.** Bot API Token from BotFather |
+| `telegram_allowed_senders` | `str` | `""` | Comma-separated user IDs, empty = allow all |
+| `telegram_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_TELEGRAM_BOT_TOKEN`, `EVOSCIENTIST_TELEGRAM_ALLOWED_SENDERS`, `EVOSCIENTIST_TELEGRAM_PROXY`
+
+**Technical details:** Long polling mode, `drop_pending_updates=True` on startup to skip backlog. Markdown→Telegram HTML auto-conversion (bold, italic, strikethrough, links, code blocks, headings, lists). Falls back to plain text on HTML parse failure. Media routed by extension to `send_photo`/`send_video`/`send_audio`/`send_document`. In groups, only responds when @mentioned; auto-strips @mention. Typing indicator refreshes every 4s. Retry: 3 attempts, min delay 0.4s, parse errors not retried. Text chunk limit: 4000 chars.
+
+---
+
+### Discord
+
+**Install:** `pip install evoscientist[discord]`
+
+**Prerequisites:**
+
+1. Go to [Discord Developer Portal](https://discord.com/developers/applications) → New Application → enter a name.
+2. Left menu **Bot** → Reset Token → copy the Bot Token.
+3. Under **Privileged Gateway Intents**, enable **Message Content Intent** (required to read message content).
+4. Left menu **OAuth2** → URL Generator:
+ - Scopes: check `bot`
+ - Bot Permissions: check `Send Messages`, `Read Message History`, `Attach Files`, `Add Reactions`
+ - Copy the generated URL, open in browser, select a server to invite the bot.
+5. Get user ID: Discord Settings → Advanced → enable Developer Mode → right-click username → Copy User ID.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "discord"
+discord_bot_token: "MTIzNDU2Nzg5.xxxx.xxxxx"
+discord_allowed_senders: "" # Comma-separated user IDs
+discord_allowed_channels: "" # Comma-separated channel IDs
+discord_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `discord_bot_token` | `str` | `""` | **Required.** Bot Token |
+| `discord_allowed_senders` | `str` | `""` | Comma-separated user IDs, empty = allow all |
+| `discord_allowed_channels` | `str` | `""` | Comma-separated channel IDs, empty = allow all |
+| `discord_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_DISCORD_BOT_TOKEN`, `EVOSCIENTIST_DISCORD_ALLOWED_SENDERS`, `EVOSCIENTIST_DISCORD_ALLOWED_CHANNELS`, `EVOSCIENTIST_DISCORD_PROXY`
+
+**Technical details:** WebSocket Gateway (`discord.py`). In server channels, only responds when @mentioned; DMs respond directly. Replies via `MessageReference`. Attachment download (max 20 MB) with safe filename sanitization. Media sent via `discord.File`. Typing indicator refreshes every 8s. Retry: 3 attempts, parses `Retry-After` header for 429s. Text chunk limit: 2000 chars.
+
+---
+
+### Slack
+
+**Install:** `pip install evoscientist[slack]`
+
+**Prerequisites:**
+
+1. Go to [Slack API](https://api.slack.com/apps) → Create New App → From scratch → select workspace.
+2. Left menu **Socket Mode** → enable → Generate App-Level Token, scope `connections:write` → copy App Token (`xapp-...`).
+3. Left menu **OAuth & Permissions** → add Bot Token Scopes:
+ - `chat:write`, `channels:history`, `groups:history`, `im:history`, `files:read`, `files:write`, `reactions:write`
+4. Click **Install to Workspace** → copy Bot User OAuth Token (`xoxb-...`).
+5. Left menu **Event Subscriptions** → enable → Subscribe to bot events: `message.channels`, `message.groups`, `message.im`, `app_mention`.
+6. Get Member ID: click user avatar → profile → **⋮** → Copy member ID.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "slack"
+slack_bot_token: "xoxb-xxxx-xxxx-xxxx"
+slack_app_token: "xapp-1-xxxx-xxxx"
+slack_allowed_senders: "" # Member ID (U...)
+slack_allowed_channels: "" # Channel ID (C...)
+slack_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `slack_bot_token` | `str` | `""` | **Required.** Bot User OAuth Token (`xoxb-`) |
+| `slack_app_token` | `str` | `""` | **Required.** Socket Mode App Token (`xapp-`) |
+| `slack_allowed_senders` | `str` | `""` | Comma-separated Member IDs |
+| `slack_allowed_channels` | `str` | `""` | Comma-separated Channel IDs |
+| `slack_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_SLACK_BOT_TOKEN`, `EVOSCIENTIST_SLACK_APP_TOKEN`, `EVOSCIENTIST_SLACK_ALLOWED_SENDERS`, `EVOSCIENTIST_SLACK_ALLOWED_CHANNELS`, `EVOSCIENTIST_SLACK_PROXY`
+
+**Technical details:** Socket Mode (no public URL needed). Markdown→mrkdwn conversion. DMs respond directly; channels only respond to `app_mention` events. Thread replies via `thread_ts`. Attachments downloaded with Bearer auth. Media sent via `files_upload_v2`. Runs `auth_test()` on startup to verify credentials. Retry: 3 attempts, exponential backoff + jitter. Text chunk limit: 4000 chars.
+
+---
+
+### Feishu (Lark)
+
+**Install:** `pip install evoscientist[feishu]`
+
+**Prerequisites:**
+
+1. Go to [Feishu Open Platform](https://open.feishu.cn/app) (international: [Lark Developer](https://open.larksuite.com/app)) → create a custom app.
+2. Copy the **App ID** and **App Secret**.
+3. Left menu **Event Subscriptions** → set request URL to `http://your-host:9000/webhook/event` → copy **Verification Token** and **Encrypt Key**.
+4. Add event: `im.message.receive_v1` (receive messages).
+5. Left menu **Permissions** → enable `im:message:send_as_bot`.
+6. Create a version and publish.
+
+> Webhook must be publicly reachable. For local dev, use `ngrok http 9000`.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "feishu"
+feishu_app_id: "cli_xxxxxxx"
+feishu_app_secret: "xxxxxxxxxxxxxxxxxx"
+feishu_webhook_port: 9000
+feishu_allowed_senders: "" # open_id
+feishu_domain: "https://open.feishu.cn"
+feishu_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `feishu_app_id` | `str` | `""` | **Required.** App ID |
+| `feishu_app_secret` | `str` | `""` | **Required.** App Secret |
+| `feishu_webhook_port` | `int` | `9000` | Webhook HTTP port |
+| `feishu_allowed_senders` | `str` | `""` | Comma-separated open_ids |
+| `feishu_domain` | `str` | `"https://open.feishu.cn"` | API domain (use `https://open.larksuite.com` for Lark) |
+| `feishu_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_FEISHU_APP_ID`, `EVOSCIENTIST_FEISHU_APP_SECRET`, `EVOSCIENTIST_FEISHU_WEBHOOK_PORT`, `EVOSCIENTIST_FEISHU_DOMAIN`
+
+**Technical details:** Webhook on `POST /webhook/event` with URL verification challenge-response. `tenant_access_token` auto-refresh (2h TTL, refreshes 5 min before expiry). Markdown→Post rich text conversion (code blocks, bold, italic, strikethrough, links, headings, quotes, lists). Plain text fallback. Group @mention filtering. Media: images via `/im/v1/images`, files via `/im/v1/files`. Replies via `/messages/{id}/reply`. Retry: 3 attempts, rate limit delay 2.0s, matches `99991400`/`rate limit`. Text chunk limit: 4096 chars.
+
+---
+
+### WeChat
+
+**Install:** `pip install evoscientist[wechat]`
+
+Two backends supported: **WeCom** (recommended, free, no certification needed) and **WeChat Official Account** (requires verified service account).
+
+#### WeCom
+
+**Prerequisites:**
+
+1. Log in to [WeCom Admin Console](https://work.weixin.qq.com) → App Management → create a custom app.
+2. Copy the **Corp ID**, **AgentId**, and **Secret**.
+3. In app details → Receive Messages → Set API Receive → URL: `http://your-host:9001/wechat/callback` → copy **Token** and **EncodingAESKey**.
+
+```yaml
+channel_enabled: "wechat"
+wechat_backend: "wecom"
+wechat_webhook_port: 9001
+wechat_wecom_corp_id: "ww..."
+wechat_wecom_agent_id: "1000002"
+wechat_wecom_secret: "xxxxxxxxxxxxxxxxxx"
+wechat_wecom_token: "xxxxxxxxxxxxxxxxxx"
+wechat_wecom_encoding_aes_key: "xxxxxxxxxxxxxxxxxx"
+wechat_allowed_senders: ""
+wechat_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `wechat_backend` | `str` | `"wecom"` | `"wecom"` or `"wechatmp"` |
+| `wechat_webhook_port` | `int` | `9001` | Callback HTTP port |
+| `wechat_wecom_corp_id` | `str` | `""` | **Required (WeCom).** Corp ID |
+| `wechat_wecom_agent_id` | `str` | `""` | **Required (WeCom).** App AgentId |
+| `wechat_wecom_secret` | `str` | `""` | **Required (WeCom).** App Secret |
+| `wechat_wecom_token` | `str` | `""` | **Required (WeCom).** Callback Token |
+| `wechat_wecom_encoding_aes_key` | `str` | `""` | **Required (WeCom).** Callback EncodingAESKey |
+
+#### WeChat Official Account
+
+**Prerequisites:**
+
+1. Log in to [WeChat Official Account Platform](https://mp.weixin.qq.com) → Settings & Development → Basic Configuration.
+2. Copy the **AppID** and **AppSecret**.
+3. Server Configuration → URL: `http://your-host:9001/wechat/callback` → set **Token** and **EncodingAESKey**.
+
+```yaml
+wechat_backend: "wechatmp"
+wechat_mp_app_id: "wx..."
+wechat_mp_app_secret: "xxxxxxxxxxxxxxxxxx"
+wechat_mp_token: "xxxxxxxxxxxxxxxxxx"
+wechat_mp_encoding_aes_key: "xxxxxxxxxxxxxxxxxx"
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `wechat_mp_app_id` | `str` | `""` | **Required (MP).** AppID |
+| `wechat_mp_app_secret` | `str` | `""` | **Required (MP).** AppSecret |
+| `wechat_mp_token` | `str` | `""` | **Required (MP).** Server Token |
+| `wechat_mp_encoding_aes_key` | `str` | `""` | **Required (MP).** Server EncodingAESKey |
+
+**Technical details:** Webhook HTTP server. XML message parsing. Signature verification. `access_token` auto-refresh. Optional AES encryption/decryption. WeCom supports Markdown message format; Official Account uses plain text. Media send/receive. Retry + backoff. Text chunk limit: 2048 chars.
+
+---
+
+### DingTalk
+
+**Install:** `pip install evoscientist[dingtalk]`
+
+**Prerequisites:**
+
+1. Go to [DingTalk Open Platform](https://open-dev.dingtalk.com) → App Development → create a bot app.
+2. Copy the **AppKey** (Client ID) and **AppSecret** (Client Secret).
+3. Enable **Stream Mode** in the app configuration — no public IP needed.
+4. Publish the app and add the bot to a group, or test via direct message.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "dingtalk"
+dingtalk_client_id: "ding..."
+dingtalk_client_secret: "xxxxxxxxxxxxxxxxxx"
+dingtalk_allowed_senders: ""
+dingtalk_proxy: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `dingtalk_client_id` | `str` | `""` | **Required.** AppKey |
+| `dingtalk_client_secret` | `str` | `""` | **Required.** AppSecret |
+| `dingtalk_allowed_senders` | `str` | `""` | Comma-separated user IDs |
+| `dingtalk_proxy` | `str` | `""` | HTTPS proxy URL |
+
+**Env vars:** `EVOSCIENTIST_DINGTALK_CLIENT_ID`, `EVOSCIENTIST_DINGTALK_CLIENT_SECRET`
+
+**Technical details:** Stream Mode (WebSocket, no public IP needed). Connects via DingTalk gateway with automatic ping/pong heartbeat and message ACK. `access_token` auto-refresh. Group @mention filtering (strips first `@bot` mention). Supports image, file, video, audio attachment download. Sends in Markdown format (`sampleMarkdown`). Auth errors (`invalidauthentication`/`forbidden`/`40014`) not retried. Text chunk limit: 4096 chars.
+
+---
+
+### QQ
+
+**Install:** `pip install evoscientist[qq]`
+
+**Prerequisites:**
+
+1. Go to [QQ Open Platform](https://q.qq.com) → create a bot application.
+2. Complete developer verification, create a sandbox or production bot.
+3. Copy the **AppID** and **AppSecret**.
+4. Search for and add the bot as a friend in QQ, or add it to a group.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "qq"
+qq_app_id: "xxxxxxxxxx"
+qq_app_secret: "xxxxxxxxxxxxxxxxxx"
+qq_allowed_senders: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `qq_app_id` | `str` | `""` | **Required.** AppID |
+| `qq_app_secret` | `str` | `""` | **Required.** AppSecret |
+| `qq_allowed_senders` | `str` | `""` | Comma-separated user IDs |
+
+**Env vars:** `EVOSCIENTIST_QQ_APP_ID`, `EVOSCIENTIST_QQ_APP_SECRET`
+
+**Technical details:** Uses `qq-botpy` SDK via WebSocket to connect to QQ Bot Gateway. Supports C2C (direct) and group messages. Message deduplication (1000-entry LRU cache). Group @mention filtering (strips first `@bot`). Intents: `public_messages=True`, `direct_message=True`. Text chunk limit: 2048 chars.
+
+---
+
+### Signal
+
+**Install:** `pip install evoscientist[signal]` (also requires [signal-cli](https://github.com/AsamK/signal-cli) installed separately)
+
+**Prerequisites:**
+
+1. Install signal-cli: see [signal-cli installation guide](https://github.com/AsamK/signal-cli#installation).
+2. Register or link a phone number:
+ - Register: `signal-cli -u +1234567890 register`, then `signal-cli -u +1234567890 verify CODE`
+ - Link existing device: `signal-cli link -n "EvoScientist"`
+3. EvoScientist will auto-start the signal-cli daemon if it's not already running.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "signal"
+signal_phone_number: "+1234567890"
+signal_cli_path: "signal-cli"
+signal_config_dir: ""
+signal_allowed_senders: ""
+signal_rpc_port: 7583
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `signal_phone_number` | `str` | `""` | **Required.** Signal phone number (E.164 format) |
+| `signal_cli_path` | `str` | `"signal-cli"` | Path to signal-cli binary |
+| `signal_config_dir` | `str` | `""` | signal-cli config directory (optional) |
+| `signal_allowed_senders` | `str` | `""` | Comma-separated phone numbers |
+| `signal_rpc_port` | `int` | `7583` | JSON RPC socket port |
+
+**Env vars:** `EVOSCIENTIST_SIGNAL_PHONE_NUMBER`, `EVOSCIENTIST_SIGNAL_CLI_PATH`, `EVOSCIENTIST_SIGNAL_RPC_PORT`
+
+**Technical details:** JSON RPC over TCP socket to signal-cli daemon. Auto-starts daemon if not running (`signal-cli -u +NUMBER daemon --socket localhost:PORT`). Listens for `receive` notifications. Sends via `send` RPC method. Group detection via `groupInfo`. Mention detection via UUID matching. No public IP needed. Text chunk limit: 4096 chars.
+
+---
+
+### Email
+
+**Install:** `pip install evoscientist[email]` (core dependencies included, no extras needed)
+
+**Prerequisites:**
+
+1. Prepare an email account with IMAP + SMTP support (Gmail, Outlook, self-hosted, etc.).
+2. **Gmail:** Enable 2FA → generate an App Password. IMAP: `imap.gmail.com:993` (SSL), SMTP: `smtp.gmail.com:587` (STARTTLS).
+3. **Outlook/Office 365:** IMAP: `outlook.office365.com:993` (SSL), SMTP: `smtp.office365.com:587` (STARTTLS).
+4. Ensure IMAP access is enabled in your email settings.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "email"
+email_imap_host: "imap.gmail.com"
+email_imap_port: 993
+email_imap_username: "bot@gmail.com"
+email_imap_password: "xxxx-xxxx-xxxx-xxxx"
+email_imap_mailbox: "INBOX"
+email_imap_use_ssl: true
+email_smtp_host: "smtp.gmail.com"
+email_smtp_port: 587
+email_smtp_username: "bot@gmail.com"
+email_smtp_password: "xxxx-xxxx-xxxx-xxxx"
+email_smtp_use_tls: true
+email_from_address: "bot@gmail.com"
+email_poll_interval: 30
+email_mark_seen: true
+email_allowed_senders: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `email_imap_host` | `str` | `""` | **Required.** IMAP server address |
+| `email_imap_port` | `int` | `993` | IMAP port |
+| `email_imap_username` | `str` | `""` | **Required.** IMAP login username |
+| `email_imap_password` | `str` | `""` | **Required.** IMAP login password (or app password) |
+| `email_imap_mailbox` | `str` | `"INBOX"` | Mailbox folder to monitor |
+| `email_imap_use_ssl` | `bool` | `true` | Use SSL for IMAP connection |
+| `email_smtp_host` | `str` | `""` | **Required.** SMTP server address |
+| `email_smtp_port` | `int` | `587` | SMTP port |
+| `email_smtp_username` | `str` | `""` | **Required.** SMTP login username |
+| `email_smtp_password` | `str` | `""` | **Required.** SMTP login password |
+| `email_smtp_use_tls` | `bool` | `true` | Use STARTTLS (`true`) or SSL (`false`) |
+| `email_from_address` | `str` | `""` | Sender address (defaults to smtp_username) |
+| `email_poll_interval` | `int` | `30` | IMAP poll interval in seconds |
+| `email_mark_seen` | `bool` | `true` | Mark emails as read after processing |
+| `email_max_body_chars` | `int` | `12000` | Max email body chars (truncated beyond) |
+| `email_subject_prefix` | `str` | `"Re: "` | Reply subject prefix |
+| `email_allowed_senders` | `str` | `""` | Comma-separated sender email addresses |
+
+**Env vars:** `EVOSCIENTIST_EMAIL_IMAP_HOST`, `EVOSCIENTIST_EMAIL_IMAP_USERNAME`, `EVOSCIENTIST_EMAIL_IMAP_PASSWORD`, `EVOSCIENTIST_EMAIL_SMTP_HOST`, `EVOSCIENTIST_EMAIL_SMTP_USERNAME`, `EVOSCIENTIST_EMAIL_SMTP_PASSWORD`
+
+**Technical details:** IMAP polling mode, checks for UNSEEN emails periodically (max 20 per cycle). Supports SSL and STARTTLS. Auto-parses multipart emails (prefers text/plain, falls back text/html → plain text). Attachments auto-downloaded. Replies set `In-Reply-To` and `References` headers to maintain email threads. Sends HTML + plain text dual format (multipart/alternative), falls back to plain text on HTML failure. IMAP auto-reconnects on disconnect. Auth errors (auth/login/credential) not retried. No public IP needed. Text chunk limit: no limit.
+
+---
+
+### iMessage
+
+**Install:** No extra Python dependencies. Requires the [imsg](https://github.com/anthropics/imsg) CLI tool.
+
+**Requirements:** macOS only (iMessage is Apple-proprietary). Requires a signed-in Apple ID with iMessage and Full Disk Access permission for the terminal app.
+
+**Prerequisites:**
+
+1. Install imsg CLI:
+ ```bash
+ brew install imsg
+ ```
+2. Verify: `imsg --version`
+3. Ensure Messages.app is signed in and working on macOS.
+
+**Configuration:**
+
+```yaml
+channel_enabled: "imessage"
+imessage_cli_path: "imsg"
+imessage_db_path: ""
+imessage_service: "auto"
+imessage_region: "US"
+imessage_allowed_senders: ""
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `imessage_cli_path` | `str` | `"imsg"` | Path to imsg CLI binary |
+| `imessage_db_path` | `str` | `""` | iMessage database path (empty = default) |
+| `imessage_service` | `str` | `"auto"` | Send service: `imessage`, `sms`, or `auto` |
+| `imessage_region` | `str` | `"US"` | Phone number region code |
+| `imessage_allowed_senders` | `str` | `""` | Comma-separated allowlist (see below) |
+
+**Allowlist formats:** phone (`+1234567890`), email (`user@example.com`), `chat_id:123`, `chat_guid:iMessage;-;+1234567890`, wildcard `*`.
+
+**Env vars:** `EVOSCIENTIST_IMESSAGE_CLI_PATH`, `EVOSCIENTIST_IMESSAGE_SERVICE`, `EVOSCIENTIST_IMESSAGE_ALLOWED_SENDERS`
+
+**Technical details:** JSON-RPC over stdio with imsg CLI. Creates `watch.subscribe` on startup for real-time message streaming (not polling). Supports iMessage + SMS dual channel (`service: auto`). Target resolution supports chat_id, chat_guid, chat_identifier, and phone/email. Attachments read from local paths provided by imsg. Group detection via `is_group` field. RPC errors (AppleScript/permission/not found) not retried; only connection timeouts retried. Plain text format (no Markdown). No public IP needed. Text chunk limit: 4000 chars.
diff --git a/EvoScientist/channels/__init__.py b/EvoScientist/channels/__init__.py
index 1fac31d..f7611a1 100644
--- a/EvoScientist/channels/__init__.py
+++ b/EvoScientist/channels/__init__.py
@@ -1,9 +1,47 @@
"""Communication channels for EvoScientist.
This module provides an extensible interface for different messaging channels
-(iMessage, WeChat, etc.) to communicate with the EvoScientist agent.
+(iMessage, Telegram, Discord) to communicate with the EvoScientist agent.
"""
-from .base import Channel, IncomingMessage, OutgoingMessage
+from .base import Channel, RawIncoming, IncomingMessage, OutgoingMessage
+from .bus import MessageBus, InboundMessage, OutboundMessage
+from .channel_manager import ChannelManager, register_channel, create_channel, available_channels
+from .consumer import InboundConsumer
+from .standalone import run_standalone
-__all__ = ["Channel", "IncomingMessage", "OutgoingMessage"]
+# Backward compat: ChannelServer is now Channel itself
+ChannelServer = Channel
+
+__all__ = [
+ "Channel",
+ "ChannelServer",
+ "ChannelManager",
+ "MessageBus",
+ "RawIncoming",
+ "IncomingMessage",
+ "OutgoingMessage",
+ "InboundMessage",
+ "OutboundMessage",
+ "InboundConsumer",
+ "run_standalone",
+ "register_channel",
+ "create_channel",
+ # New modules
+ "ChannelCapabilities",
+ "UnifiedFormatter",
+ "TypingManager",
+ "chunk_text",
+ # Plugin architecture
+ "ChannelPlugin",
+ "ChannelMeta",
+ "ReloadPolicy",
+]
+
+from .capabilities import ChannelCapabilities
+from .formatter import UnifiedFormatter
+from .middleware import TypingManager
+from .base import chunk_text
+
+# Plugin architecture
+from .plugin import ChannelPlugin, ChannelMeta, ReloadPolicy
diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py
index ecf1260..9923cd2 100644
--- a/EvoScientist/channels/base.py
+++ b/EvoScientist/channels/base.py
@@ -5,42 +5,310 @@ This module defines the Channel interface that all messaging channels
"""
from abc import ABC, abstractmethod
+import asyncio
+import dataclasses
+import logging
+import re
+import time
+from collections import defaultdict
+from collections.abc import Awaitable, Callable as CallableABC
from dataclasses import dataclass, field
from datetime import datetime
-from typing import AsyncIterator
+from pathlib import Path
+from typing import Any, AsyncIterator, Callable
+
+from ..paths import WORKSPACE_ROOT
+
+from .bus.events import InboundMessage, OutboundMessage
+from .capabilities import ChannelCapabilities
+from .formatter import UnifiedFormatter
+from .plugin import ChannelPlugin, ChannelMeta
+
+_logger = logging.getLogger(__name__)
+
+
+# ── Text chunking ────────────────────────────────────────────────────
+
+def chunk_text(text: str, limit: int) -> list[str]:
+ """Split text into chunks that respect logical boundaries.
+
+ Splitting priority (highest to lowest):
+ 1. Code block boundaries (``` fences)
+ 2. Double newlines (paragraph breaks)
+ 3. Single newlines
+ 4. Spaces (word boundaries)
+ 5. Hard cut (last resort)
+
+ Code blocks are never split mid-block when possible. If a single code
+ block exceeds the limit it is sent as its own chunk(s).
+
+ Args:
+ text: The text to split.
+ limit: Maximum characters per chunk.
+
+ Returns:
+ List of text chunks, each <= limit characters.
+ """
+ if not text:
+ return []
+ if len(text) <= limit:
+ return [text]
+
+ chunks: list[str] = []
+ remaining = text
+
+ while remaining:
+ if len(remaining) <= limit:
+ chunks.append(remaining)
+ break
+
+ # Try to find a split point within the limit
+ segment = remaining[:limit]
+
+ # 1. Prefer splitting at code block boundary (``` at line start)
+ best = -1
+ fence_pos = segment.rfind("\n```")
+ if fence_pos > 0:
+ line_end = segment.find("\n", fence_pos + 1)
+ if line_end == -1:
+ line_end = len(segment)
+ best = line_end
+
+ # 2. Double newline (paragraph break)
+ if best == -1:
+ pos = segment.rfind("\n\n")
+ if pos > 0:
+ best = pos
+
+ # 3. Single newline
+ if best == -1:
+ pos = segment.rfind("\n")
+ if pos > 0:
+ best = pos
+
+ # 4. Space (word boundary)
+ if best == -1:
+ pos = segment.rfind(" ")
+ if pos > 0:
+ best = pos
+
+ # 5. Hard cut
+ if best == -1:
+ best = limit
+
+ chunk = remaining[:best].rstrip()
+ if chunk:
+ chunks.append(chunk)
+ remaining = remaining[best:].lstrip("\n")
+
+ return chunks
+
+
+# ── Attachment / media helpers ───────────────────────────────────────
+
+MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20 MB
+MEDIA_DIR = WORKSPACE_ROOT / "media"
+
+IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"})
+VIDEO_EXTS = frozenset({".mp4", ".mov", ".avi", ".webm"})
+AUDIO_EXTS = frozenset({".mp3", ".ogg", ".m4a", ".wav"})
+
+
+def classify_media(ext: str) -> str | None:
+ """Classify a file extension into a media type string.
+
+ Returns ``"image"``, ``"video"``, ``"audio"``, or ``None``.
+ """
+ ext = ext.lower()
+ if ext in IMAGE_EXTS:
+ return "image"
+ if ext in VIDEO_EXTS:
+ return "video"
+ if ext in AUDIO_EXTS:
+ return "audio"
+ return None
+
+
+def media_path(filename: str) -> Path:
+ """Ensure MEDIA_DIR exists and return a path inside it."""
+ MEDIA_DIR.mkdir(parents=True, exist_ok=True)
+ return MEDIA_DIR / filename
+
+
+def check_attachment_size(file_size: int, filename: str) -> str | None:
+ """Return a 'too large' annotation if *file_size* exceeds the limit.
+
+ Returns ``None`` when the file is within the allowed size.
+ """
+ if file_size > MAX_ATTACHMENT_BYTES:
+ return f"[attachment: {filename} - too large ({file_size} bytes)]"
+ return None
+
+
+async def download_attachment(
+ url: str,
+ filename: str,
+ *,
+ channel_name: str = "",
+ headers: dict[str, str] | None = None,
+ file_size: int | None = None,
+ proxy: str | None = None,
+) -> tuple[str | None, str | None]:
+ """Download an attachment via httpx.
+
+ Returns ``(local_path, annotation)``.
+
+ If *file_size* exceeds ``MAX_ATTACHMENT_BYTES``, returns
+ ``(None, too-large-annotation)`` without downloading.
+ On download failure returns ``(None, failure-annotation)``.
+ On success returns ``(local_path_str, success-annotation)``.
+ """
+ if file_size is not None:
+ too_large = check_attachment_size(file_size, filename)
+ if too_large:
+ return None, too_large
+
+ try:
+ import httpx
+
+ safe_name = filename.replace("/", "_")
+ prefix = f"{channel_name}_" if channel_name else ""
+ local_path = media_path(f"{prefix}{safe_name}")
+
+ async with httpx.AsyncClient(proxy=proxy) as client:
+ resp = await client.get(url, headers=headers or {}, timeout=30)
+ if resp.status_code != 200:
+ return None, f"[attachment: {filename} - download failed]"
+
+ # Check Content-Length when file_size was not known beforehand
+ 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
+ if len(resp.content) > MAX_ATTACHMENT_BYTES:
+ return None, check_attachment_size(len(resp.content), filename)
+
+ local_path.write_bytes(resp.content)
+ return str(local_path), f"[attachment: {local_path}]"
+ except Exception as e:
+ _logger.warning(f"Failed to download attachment: {e}")
+ return None, f"[attachment: {filename} - download failed]"
+
+# Deprecated aliases — use InboundMessage / OutboundMessage instead.
+IncomingMessage = InboundMessage
+OutgoingMessage = OutboundMessage
@dataclass
-class IncomingMessage:
- """Represents a message received from a channel."""
+class RawIncoming:
+ """Raw data extracted from a platform-specific message event.
- sender: str # Phone number, email, or unique identifier
- content: str # Message text content
- timestamp: datetime # When the message was sent
- message_id: str # Unique identifier for the message
- metadata: dict = field(default_factory=dict) # Channel-specific metadata
+ Each channel's ``_on_message`` populates this with platform data,
+ then calls ``_enqueue_raw()`` which handles allow-list checks,
+ content merging, and ``InboundMessage`` creation.
+ """
+
+ sender_id: str
+ chat_id: str
+ text: str = ""
+ media_files: list[str] = field(default_factory=list)
+ content_annotations: list[str] = field(default_factory=list)
+ timestamp: datetime = field(default_factory=datetime.now)
+ message_id: str = ""
+ metadata: dict = field(default_factory=dict)
+ is_group: bool = False
+ was_mentioned: bool = True # default True so DMs always pass
-@dataclass
-class OutgoingMessage:
- """Represents a message to be sent through a channel."""
-
- recipient: str # Phone number, email, or unique identifier
- content: str # Message text content
- reply_to: str | None = None # Optional message ID being replied to
- metadata: dict = field(default_factory=dict) # Channel-specific metadata
-
-
-class Channel(ABC):
+class Channel(ChannelPlugin, ABC):
"""Abstract base class for messaging channels.
Subclasses must implement:
- start(): Initialize the channel (connect, authenticate, etc.)
- - stop(): Clean up resources
- - receive(): Async iterator yielding incoming messages
- - send(): Send a message through the channel
+ - _send_chunk(): Send a single text chunk (platform-specific)
+
+ Subclasses may optionally override:
+ - _cleanup(): Channel-specific teardown (called by stop())
+ - _format_chunk(): Convert Markdown to channel format
+ - _is_ready(): Return False if channel cannot send
+ - _resolve_chat_id(): Extract chat_id from message
+ - receive(): Only if custom exit conditions are needed
+
+ Subclasses should set ``name`` to a unique identifier (e.g. "telegram").
"""
+ name: str = "base"
+ capabilities: ChannelCapabilities = ChannelCapabilities()
+ _typing_interval: float = 5.0
+ _ready_attrs: tuple[str, ...] = ()
+
+ def __init__(self, config, *, queue_maxsize: int = 1000):
+ ChannelPlugin.__init__(self)
+ self.id = self.name
+ self.meta = ChannelMeta(id=self.name, label=self.name.title())
+
+ self.config = config
+
+ # Auto-configure formatter from capabilities
+ self._formatter = UnifiedFormatter.for_channel(self.capabilities.format_type)
+ self._queue: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=queue_maxsize)
+ self._running = False
+
+ # Typing indicator — delegated to TypingManager
+ from .middleware import TypingManager
+ self._typing_manager = TypingManager(
+ self._send_typing_action, interval=self._typing_interval,
+ )
+ # Keep legacy dict reference for any subclass that touches it directly
+ self._typing_tasks = self._typing_manager._tasks
+
+ # Bus integration (injected by ChannelManager.register / set_bus)
+ self._bus: Any = None
+ self.send_thinking: bool = False
+ self._on_activity: Callable | None = None
+
+ # Debounce settings
+ self.initial_debounce: float = 2.0
+ self.debounce_step: float = 0.5
+ self.max_debounce: float = 5.0
+
+ # Per-sender message buffers for debouncing
+ self._message_buffers: dict[str, list[str]] = {}
+ self._message_metadata: dict[str, dict] = {}
+ self._message_media: dict[str, list[str]] = {}
+ self._message_ids: dict[str, str] = {}
+ self._debounce_tasks: dict[str, asyncio.Task] = {}
+
+ # Deduplication
+ from .middleware import DedupCache
+ self._dedup = DedupCache()
+
+ # 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")
+
+ # Shared pairing manager for DM pairing mode
+ from .middleware import PairingManager
+ self._pairing_manager = PairingManager()
+
+ # 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)
+
+ # Group history buffer for context injection
+ from .middleware import GroupHistoryBuffer
+ self._group_history = GroupHistoryBuffer()
+
@abstractmethod
async def start(self) -> None:
"""Initialize and start the channel.
@@ -55,56 +323,684 @@ class Channel(ABC):
"""
pass
- @abstractmethod
async def stop(self) -> None:
- """Stop the channel and clean up resources.
+ """Stop the channel. Cancels typing tasks, then calls _cleanup()."""
+ self._running = False
+ await self._typing_manager.stop_all()
+ await self._cleanup()
- This method should:
- - Close connections
- - Cancel background tasks
- - Release any held resources
+ async def _cleanup(self) -> None:
+ """Channel-specific teardown. Override in subclasses."""
+
+ async def receive(self) -> AsyncIterator[InboundMessage]:
+ """Yield incoming messages from the queue.
+
+ Default implementation polls ``self._queue``. Override only if
+ the channel needs custom exit conditions.
"""
- pass
+ while self._running:
+ try:
+ msg = await asyncio.wait_for(self._queue.get(), timeout=1.0)
+ yield msg
+ except asyncio.TimeoutError:
+ continue
+
+ async def send(self, message: OutboundMessage) -> bool:
+ """Send a message. Handles chunking, retry, and error logging.
+
+ Subclasses override ``_send_chunk()`` for the platform-specific call.
+ Override ``_format_chunk()`` to convert Markdown to channel format.
+
+ A per-chat lock ensures messages to the same chat are serialised,
+ preventing out-of-order delivery when multiple sends overlap.
+
+ If formatting expands a chunk beyond the platform limit (e.g. Markdown
+ → HTML), the chunk is automatically re-split at a smaller size. Per-
+ chunk errors are logged but do not abort delivery of remaining chunks.
+
+ When the channel satisfies ``ThreadingAdapter``, its ``reply_to_mode``
+ controls which chunks carry a ``reply_to`` reference.
+ """
+ if not self._is_ready():
+ return False
+ try:
+ chat_id = self._resolve_chat_id(message)
+ limit = self._get_chunk_limit()
+ async with self._send_locks[chat_id]:
+ pairs = self._prepare_chunks(message.content, limit)
+ had_error = False
+ for i, (formatted, raw) in enumerate(pairs):
+ reply_to = self._resolve_reply_to(message.reply_to, i)
+ try:
+ await self._send_with_retry(
+ lambda _cid=chat_id, _fmt=formatted, _raw=raw, _reply=reply_to, _meta=message.metadata: (
+ self._send_chunk(_cid, _fmt, _raw, _reply, _meta)
+ )
+ )
+ except Exception as chunk_err:
+ _logger.error(
+ f"{self.name} chunk {i} send error: {chunk_err}"
+ )
+ had_error = True
+ return not had_error
+ except Exception as e:
+ _logger.error(f"{self.name} send error: {e}")
+ return False
+
+ def _resolve_reply_to(self, reply_to: str | None, chunk_index: int) -> str | None:
+ """Determine the reply_to value for a given chunk index.
+
+ Legacy: reply_to on first chunk only.
+ """
+ if not reply_to:
+ return None
+ return reply_to if chunk_index == 0 else None
+
+ def _prepare_chunks(
+ self, content: str, limit: int,
+ ) -> list[tuple[str, str]]:
+ """Build ``(formatted, raw)`` pairs, re-splitting when formatting
+ expands a chunk beyond *limit*.
+
+ Returns a list of ``(formatted_text, raw_text)`` tuples ready
+ for ``_send_chunk()``.
+ """
+ raw_chunks = chunk_text(content, limit)
+ pairs: list[tuple[str, str]] = []
+ for raw in raw_chunks:
+ formatted = self._format_chunk(raw)
+ if len(formatted) <= limit:
+ pairs.append((formatted, raw))
+ else:
+ # Re-chunk at half the limit to leave room for format expansion
+ sub_limit = max(limit // 2, 500)
+ for sub_raw in chunk_text(raw, sub_limit):
+ sub_fmt = self._format_chunk(sub_raw)
+ if len(sub_fmt) <= limit:
+ pairs.append((sub_fmt, sub_raw))
+ else:
+ # Still too long — send raw text (guaranteed to fit)
+ pairs.append((sub_raw, sub_raw))
+ return pairs
+
+ def _is_ready(self) -> bool:
+ """Return False if the channel cannot send (e.g. client not connected).
+
+ Default checks that every attribute named in ``_ready_attrs`` is truthy.
+ Override for channels with more complex readiness logic.
+ """
+ if not self._ready_attrs:
+ return True
+ return all(getattr(self, attr, None) for attr in self._ready_attrs)
+
+ def _resolve_chat_id(self, message: OutboundMessage) -> str:
+ """Extract chat_id from metadata or recipient. Override if needed."""
+ return message.metadata.get("chat_id", message.recipient)
+
+ def _get_chunk_limit(self) -> int:
+ config_limit = getattr(self.config, "text_chunk_limit", 0)
+ cap_limit = self.capabilities.max_text_length
+ return config_limit or cap_limit or 4096
+
+ def _format_chunk(self, text: str) -> str:
+ """Convert Markdown to channel format via UnifiedFormatter.
+
+ Uses the formatter auto-configured from ``capabilities.format_type``.
+ Subclasses rarely need to override this — set ``capabilities`` instead.
+ """
+ return self._formatter.format(text)
@abstractmethod
- async def receive(self) -> AsyncIterator[IncomingMessage]:
- """Async iterator that yields incoming messages.
+ async def _send_chunk(
+ self, chat_id: str, formatted_text: str, raw_text: str,
+ reply_to: str | None, metadata: dict,
+ ) -> None:
+ """Send a single text chunk. Platform-specific implementation."""
+ ...
- Yields:
- IncomingMessage: Each new message received
+ _format_fallback_patterns: tuple[str, ...] = ("parse", "invalid")
- Example:
- async for msg in channel.receive():
- print(f"From {msg.sender}: {msg.content}")
+ async def _send_with_format_fallback(
+ self, send_fn: CallableABC[[str], Awaitable], formatted: str, raw: str,
+ ) -> None:
+ """Try *send_fn(formatted)*; on format-related errors retry with *raw*.
+
+ Channels whose ``_send_chunk`` follows the try-formatted / except-fallback
+ pattern can delegate to this helper instead of duplicating the logic.
"""
- pass
+ try:
+ await send_fn(formatted)
+ except Exception as e:
+ if formatted != raw and any(
+ p in str(e).lower() for p in self._format_fallback_patterns
+ ):
+ await send_fn(raw)
+ else:
+ raise
- @abstractmethod
- async def send(self, message: OutgoingMessage) -> bool:
- """Send a message through the channel.
+ async def send_media(
+ self,
+ recipient: str,
+ file_path: str,
+ caption: str = "",
+ metadata: dict | None = None,
+ ) -> bool:
+ """Send a media file through the channel.
+
+ Handles the ready-check guard and error logging. Subclasses
+ override ``_send_media_impl()`` with platform-specific logic.
Args:
- message: The message to send
+ recipient: Target recipient or chat identifier.
+ file_path: Local path to the media file.
+ caption: Optional caption text.
+ metadata: Optional channel-specific metadata.
Returns:
- True if sent successfully, False otherwise
+ True if sent successfully, False otherwise.
"""
+ if not self._is_ready():
+ return False
+ try:
+ return await self._send_media_impl(recipient, file_path, caption, metadata)
+ except Exception as e:
+ _logger.error(f"{self.name} send_media error: {e}")
+ return False
+
+ async def _send_media_impl(
+ self,
+ recipient: str,
+ file_path: str,
+ caption: str = "",
+ metadata: dict | None = None,
+ ) -> bool:
+ """Platform-specific media send. Override in subclasses."""
+ return False
+
+ # ── Attachment / proxy helpers ─────────────────────────────────
+
+ def _media_path(self, filename: str) -> Path:
+ """Ensure MEDIA_DIR exists and return a path inside it."""
+ return media_path(filename)
+
+ def _resolve_media_chat_id(self, recipient: str, metadata: dict | None) -> str:
+ """Extract chat_id from metadata, falling back to recipient."""
+ return (metadata or {}).get("chat_id", recipient)
+
+ def _get_proxy(self) -> str | None:
+ """Return the configured proxy URL, or ``None`` if unset/empty."""
+ return getattr(self.config, "proxy", None) or None
+
+ def _check_attachment_size(self, file_size: int, filename: str) -> str | None:
+ """Return a 'too large' annotation string if *file_size* exceeds the limit."""
+ return check_attachment_size(file_size, filename)
+
+ async def _download_attachment(
+ self,
+ url: str,
+ filename: str,
+ *,
+ headers: dict[str, str] | None = None,
+ file_size: int | None = None,
+ ) -> tuple[str | None, str | None]:
+ """Download an attachment via httpx. Returns ``(local_path, annotation)``.
+
+ Delegates to :func:`download_attachment`.
+ """
+ return await download_attachment(
+ url, filename,
+ channel_name=self.name,
+ headers=headers,
+ file_size=file_size,
+ proxy=self._get_proxy(),
+ )
+
+ # ── Send retry abstraction ──────────────────────────────────────
+
+ _non_retryable_patterns: tuple[str, ...] = ()
+ _rate_limit_patterns: tuple[str, ...] = ("429", "ratelimit")
+ _rate_limit_delay: float = 1.0
+
+ def _extract_retry_after(self, exc: Exception) -> float | None:
+ """Extract retry-wait seconds from an exception.
+
+ Returns ``None`` to signal that the error is **not retryable**.
+
+ Pipeline:
+ 1. SDK-provided ``retry_after`` attribute (Telegram / Slack SDKs).
+ 2. HTTP ``Retry-After`` header via :meth:`_parse_retry_after_header`.
+ 3. Non-retryable pattern match → ``None``.
+ 4. Rate-limit pattern match → ``_rate_limit_delay``.
+ 5. Default ``1.0`` s (generic transient-error retry).
+
+ Channels can customise behaviour declaratively via class attributes
+ ``_non_retryable_patterns``, ``_rate_limit_patterns``, and
+ ``_rate_limit_delay``, or override this method entirely.
+ """
+ # 1. SDK retry_after attribute
+ retry = getattr(exc, "retry_after", None)
+ if retry is not None:
+ return float(retry)
+
+ # 2. HTTP Retry-After header
+ header_val = self._parse_retry_after_header(exc)
+ if header_val is not None:
+ return header_val
+
+ msg = str(exc).lower()
+
+ # 3. Non-retryable patterns
+ if self._non_retryable_patterns and any(
+ p in msg for p in self._non_retryable_patterns
+ ):
+ return None
+
+ # 4. Rate-limit patterns
+ if self._rate_limit_patterns and any(
+ p in msg for p in self._rate_limit_patterns
+ ):
+ return self._rate_limit_delay
+
+ # 5. Default
+ return 1.0
+
+ def _parse_retry_after_header(self, exc: Exception) -> float | None:
+ """Try to extract a ``Retry-After`` value from an HTTP response."""
+ resp = getattr(exc, "response", None)
+ if resp is None:
+ return None
+ headers = getattr(resp, "headers", None)
+ if not headers:
+ return None
+ raw = headers.get("Retry-After") or headers.get("retry-after")
+ if raw is None:
+ return None
+ try:
+ return float(raw)
+ except (ValueError, TypeError):
+ return None
+
+ async def _send_with_retry(
+ self,
+ coro_factory: CallableABC[[], Awaitable],
+ max_retries: int = 3,
+ ) -> Any:
+ """Send helper with automatic exponential-backoff retry.
+
+ *coro_factory* is called on every attempt so that the awaitable is
+ fresh. Uses :func:`retry.retry_async` for backoff, jitter, and
+ server-supplied ``Retry-After`` support.
+
+ The *max_retries* parameter is accepted for backward compatibility
+ but the attempt count is taken from ``self._retry_config``.
+ """
+ from .retry import retry_async
+
+ return await retry_async(
+ coro_factory,
+ config=self._retry_config,
+ should_retry=lambda exc, _: self._extract_retry_after(exc) is not None,
+ retry_after_s=self._extract_retry_after,
+ on_retry=lambda info: _logger.warning(
+ f"{self.name} send retry {info.attempt}/{info.max_attempts} "
+ f"in {info.delay_s:.2f}s: {info.error}"
+ ),
+ label=f"{self.name}.send",
+ )
+
+ # ── Typing indicator abstraction ─────────────────────────────────
+
+ async def _send_typing_action(self, chat_id: str) -> None:
+ """Send a single typing indicator. Override in sub-classes."""
+
+ async def start_typing(self, chat_id: str) -> None:
+ """Start a background typing-indicator loop for *chat_id*."""
+ await self._typing_manager.start(chat_id)
+
+ async def stop_typing(self, chat_id: str) -> None:
+ """Cancel the typing-indicator loop for *chat_id*."""
+ await self._typing_manager.stop(chat_id)
+
+ # ── Mention gating ──────────────────────────────────────────────
+
+ async def _send_pairing_response(self, chat_id: str, code: str):
+ """Send pairing code to the sender."""
+ text = f"🔐 Pairing required. Your code: {code}\nThis code expires in 1 hour."
+ await self._send_chunk(chat_id, text, text, None, {})
+
+ def _should_process(self, raw: RawIncoming) -> bool:
+ """Decide whether to process a message based on mention gating."""
+ if not raw.is_group or self.require_mention == "off":
+ return True
+ 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 ──────────────────────────────────────
+
+ def _build_inbound(self, raw: RawIncoming) -> InboundMessage | None:
+ """Build an ``InboundMessage`` from raw platform data.
+
+ Performs mention gating, allow-list checks, and merges text +
+ annotations into a single content string. Returns ``None`` if
+ the message should be dropped (not mentioned, sender not
+ allowed, no content, etc.).
+ """
+ if not self._should_process(raw):
+ return None
+
+ # DM policy handling for non-group messages
+ if not raw.is_group:
+ if self.dm_policy == "open":
+ pass # skip allowlist check for DMs
+ elif self.dm_policy == "pairing":
+ if not self.is_allowed(raw.sender_id) and not self._pairing_manager.is_approved(self.name, raw.sender_id):
+ code = self._pairing_manager.request_pairing(self.name, raw.sender_id)
+ # Schedule pairing response (fire-and-forget)
+ asyncio.ensure_future(self._send_pairing_response(raw.chat_id, code))
+ _logger.info(f"Pairing required for {raw.sender_id}, code sent")
+ return None
+ else: # "allowlist" (default)
+ if not self.is_allowed(raw.sender_id):
+ _logger.debug(
+ f"Ignoring message from non-allowed sender {raw.sender_id}"
+ )
+ return None
+ elif not self.is_allowed(raw.sender_id):
+ _logger.debug(
+ f"Ignoring message from non-allowed sender {raw.sender_id}"
+ )
+ return None
+
+ # Check channel allow-list
+ if not self.is_channel_allowed(raw.chat_id):
+ _logger.debug(
+ f"Ignoring message from non-allowed channel {raw.chat_id}"
+ )
+ return None
+
+ # Apply mention stripping in group messages
+ if raw.is_group:
+ raw = dataclasses.replace(raw, text=self._strip_mention(raw.text))
+
+ # Merge text and annotations into content
+ parts: list[str] = []
+ 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
+
+ # Ensure chat_id is in metadata
+ 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,
+ )
+
+ async def _enqueue_raw(self, raw: RawIncoming) -> None:
+ """Build an InboundMessage from *raw* and put it on the queue.
+
+ Convenience method for subclass ``_on_message`` handlers.
+ Stores non-mentioned group messages in history buffer and injects
+ context when the bot is mentioned.
+ """
+ from .middleware import HistoryEntry
+
+ # Store all group messages in history buffer BEFORE mention gating
+ if raw.is_group:
+ ts = raw.timestamp.timestamp() if hasattr(raw.timestamp, 'timestamp') else time.time()
+ if not raw.was_mentioned:
+ # Store for context, _build_inbound will return None via _should_process
+ self._group_history.add(raw.chat_id, HistoryEntry(
+ sender_id=raw.sender_id,
+ text=raw.text,
+ timestamp=ts,
+ message_id=raw.message_id,
+ ))
+ else:
+ # Inject context before the current message
+ context = self._group_history.format_context(raw.chat_id)
+ if context:
+ raw = dataclasses.replace(
+ raw,
+ text=context + "\n\n[Current message - respond to this]\n" + raw.text,
+ )
+ self._group_history.clear(raw.chat_id)
+
+ msg = self._build_inbound(raw)
+ if msg is not None:
+ # Fire-and-forget ACK reaction
+ 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 + dedup, then publish to bus."""
+ sender = msg.sender_id
+
+ # Deduplication
+ mid = msg.message_id
+ if mid and self._dedup.is_duplicate(mid):
+ _logger.debug(f"Dedup: skipping duplicate message {mid}")
+ return
+
+ if sender not in self._message_buffers:
+ self._message_buffers[sender] = []
+ self._message_metadata[sender] = msg.metadata
+ self._message_media[sender] = []
+ self._message_buffers[sender].append(msg.content)
+ if mid:
+ self._message_ids[sender] = mid
+ 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, "")
+ 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,
+ )
+ await self._bus.publish_inbound(inbound)
+
+ async def _send_status_message(
+ self, sender: str, content: str, metadata: dict | None = None,
+ ) -> None:
+ """Send a status/intermediate message to the channel."""
+ chat_id = (metadata or {}).get("chat_id", sender)
+ await self.send(OutboundMessage(
+ channel=self.name,
+ chat_id=str(chat_id),
+ content=content,
+ metadata=metadata or {},
+ ))
+
+ async def send_thinking_message(
+ self, sender: str, thinking: str, metadata: dict | None = None,
+ ) -> None:
+ """Send a thinking intermediate message to the channel."""
+ if not self.send_thinking:
+ return
+ await self._send_status_message(sender, f"\U0001f9e0\n{thinking}\n\u23f3", metadata)
+
+ async def send_todo_message(
+ self, sender: str, content: str, metadata: dict | None = None,
+ ) -> None:
+ """Send a todo list intermediate message to the channel."""
+ await self._send_status_message(sender, content, metadata)
+
+ async def run(self) -> None:
+ """Run the channel with auto-reconnect (exponential backoff)."""
+ backoff = 1.0
+ max_backoff = 60.0
+ self._running = True
+ while self._running:
+ try:
+ await self.start()
+ backoff = 1.0
+ async for msg in self.receive():
+ _logger.info(f"From {msg.sender_id}: {msg.content[:50]}...")
+ await self.queue_message(msg)
+ except asyncio.CancelledError:
+ break
+ except ChannelError as e:
+ _logger.error(f"Channel {self.name} fatal error: {e}")
+ self._running = False
+ break
+ except Exception as e:
+ _logger.error(f"Channel {self.name} error: {e}")
+ finally:
+ for task in self._debounce_tasks.values():
+ task.cancel()
+ self._debounce_tasks.clear()
+ # Preserve reconnect intent across stop()
+ should_reconnect = self._running
+ try:
+ await self.stop()
+ except Exception:
+ pass
+ self._running = should_reconnect
+
+ if self._running:
+ _logger.info(
+ f"Reconnecting {self.name} in {backoff:.1f}s..."
+ )
+ await asyncio.sleep(backoff)
+ backoff = min(backoff * 2, max_backoff)
+
+ # ── Channel allow-list check ─────────────────────────────────────
+
+ def is_channel_allowed(self, channel_id: str) -> bool:
+ """Return ``True`` if *channel_id* is permitted by config.
+
+ When the allow-list is empty or absent every channel is allowed.
+ """
+ allowed = getattr(self.config, "allowed_channels", None)
+ return not allowed or str(channel_id) in allowed
+
+ # ── Sender allow-list check ──────────────────────────────────────
+
+ def is_allowed(self, sender: str) -> bool:
+ """Check if *sender* is permitted by ``self.config.allowed_senders``.
+
+ Returns ``True`` when the allow-list is empty / None (open access).
+ Supports ``|``-separated composite IDs (e.g. ``"uid|gid"``).
+ Subclasses with richer filtering (iMessage) may override.
+ """
+ config = getattr(self, "config", None)
+ allowed = getattr(config, "allowed_senders", None) if config else None
+ if not allowed:
+ return True
+ sender_str = str(sender)
+ if sender_str in allowed:
+ return True
+ if "|" in sender_str:
+ for part in sender_str.split("|"):
+ if part and part in allowed:
+ return True
+ return False
+
class ChannelError(Exception):
"""Base exception for channel-related errors."""
pass
-
-
-class ChannelPermissionError(ChannelError):
- """Raised when the channel lacks required permissions."""
-
- pass
-
-
-class ChannelConnectionError(ChannelError):
- """Raised when the channel cannot establish a connection."""
-
- pass
diff --git a/EvoScientist/channels/bus/__init__.py b/EvoScientist/channels/bus/__init__.py
new file mode 100644
index 0000000..3510967
--- /dev/null
+++ b/EvoScientist/channels/bus/__init__.py
@@ -0,0 +1,6 @@
+"""Message bus for decoupled channel-agent communication."""
+
+from .events import InboundMessage, OutboundMessage
+from .message_bus import MessageBus
+
+__all__ = ["MessageBus", "InboundMessage", "OutboundMessage"]
diff --git a/EvoScientist/channels/bus/events.py b/EvoScientist/channels/bus/events.py
new file mode 100644
index 0000000..8e94727
--- /dev/null
+++ b/EvoScientist/channels/bus/events.py
@@ -0,0 +1,50 @@
+"""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)
+
+ @property
+ def sender(self) -> str:
+ """Alias for ``sender_id`` (compatibility with IncomingMessage)."""
+ return self.sender_id
+
+ @property
+ def session_key(self) -> str:
+ """Unique key for session identification: ``channel:chat_id``."""
+ return f"{self.channel}:{self.chat_id}"
+
+
+@dataclass
+class OutboundMessage:
+ """Message to send to a chat channel."""
+
+ channel: str
+ chat_id: str
+ content: str
+ reply_to: str | None = None
+ media: list[str] = field(default_factory=list)
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def recipient(self) -> str:
+ """Alias for ``chat_id`` (compatibility with OutgoingMessage)."""
+ return self.chat_id
diff --git a/EvoScientist/channels/bus/message_bus.py b/EvoScientist/channels/bus/message_bus.py
new file mode 100644
index 0000000..3e68697
--- /dev/null
+++ b/EvoScientist/channels/bus/message_bus.py
@@ -0,0 +1,96 @@
+"""Async message bus that decouples chat channels from the agent core.
+
+Channels push messages to the inbound queue; the agent (or any consumer)
+reads from inbound, processes, and pushes responses to the outbound queue.
+A background dispatcher routes outbound messages to the correct channel
+via subscriber callbacks.
+
+Deduplication is handled at the Channel level (single dedup point).
+"""
+
+import asyncio
+import logging
+from typing import Callable, Awaitable
+
+from .events import InboundMessage, OutboundMessage
+
+logger = logging.getLogger(__name__)
+
+OutboundCallback = Callable[[OutboundMessage], Awaitable[None]]
+
+
+class MessageBus:
+ """Async message bus that decouples chat channels from the agent core."""
+
+ def __init__(self):
+ self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=5000)
+ self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue(maxsize=5000)
+ self._outbound_subscribers: dict[str, list[OutboundCallback]] = {}
+ self._running = False
+
+ # ── inbound (channel → agent) ──
+
+ async def publish_inbound(self, msg: InboundMessage) -> None:
+ """Publish a message from a channel to the agent."""
+ await self.inbound.put(msg)
+
+ async def consume_inbound(self) -> InboundMessage:
+ """Consume the next inbound message (blocks until available)."""
+ return await self.inbound.get()
+
+ # ── outbound (agent → channel) ──
+
+ async def publish_outbound(self, msg: OutboundMessage) -> None:
+ """Publish a response from the agent to channels."""
+ await self.outbound.put(msg)
+
+ async def consume_outbound(self) -> OutboundMessage:
+ """Consume the next outbound message (blocks until available)."""
+ return await self.outbound.get()
+
+ # ── subscriber routing ──
+
+ def subscribe_outbound(
+ self, channel: str, callback: OutboundCallback,
+ ) -> None:
+ """Register a callback for outbound messages targeting *channel*."""
+ if channel not in self._outbound_subscribers:
+ self._outbound_subscribers[channel] = []
+ self._outbound_subscribers[channel].append(callback)
+
+ async def dispatch_outbound(self) -> None:
+ """Route outbound messages to subscribed channels.
+
+ Run as a background task — loops until :meth:`stop` is called.
+ """
+ self._running = True
+ while self._running:
+ try:
+ msg = await asyncio.wait_for(
+ self.outbound.get(), timeout=1.0,
+ )
+ except asyncio.TimeoutError:
+ continue
+ subscribers = self._outbound_subscribers.get(msg.channel, [])
+ if not subscribers:
+ logger.warning(f"No subscriber for channel: {msg.channel}")
+ continue
+ for callback in subscribers:
+ try:
+ await callback(msg)
+ except Exception as e:
+ logger.error(
+ f"Error dispatching to {msg.channel}: {e}"
+ )
+
+ def stop(self) -> None:
+ """Stop the dispatcher loop."""
+ self._running = False
+
+ @property
+ def inbound_size(self) -> int:
+ return self.inbound.qsize()
+
+ @property
+ def outbound_size(self) -> int:
+ return self.outbound.qsize()
diff --git a/EvoScientist/channels/capabilities.py b/EvoScientist/channels/capabilities.py
new file mode 100644
index 0000000..df624e2
--- /dev/null
+++ b/EvoScientist/channels/capabilities.py
@@ -0,0 +1,220 @@
+"""Channel capabilities declaration system.
+
+Each channel declares its capabilities via a ChannelCapabilities dataclass,
+enabling the framework to adapt behavior automatically (formatting, reactions,
+streaming, threading, etc.) without per-channel branching in core logic.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+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=0, # no practical limit
+ media_send=True,
+ media_receive=True,
+ html=True,
+ chat_types=("direct",),
+)
+
+IMESSAGE = ChannelCapabilities(
+ format_type="plain",
+ max_text_length=0,
+ typing=False, # Apple does not expose typing indicator API
+ media_send=True,
+ media_receive=True,
+ voice=True,
+ groups=True,
+ mentions=False, # iMessage has no @mention concept
+ reactions=False, # imsg CLI cannot send tapback reactions
+ chat_types=("direct", "group"),
+)
diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py
new file mode 100644
index 0000000..577614c
--- /dev/null
+++ b/EvoScientist/channels/channel_manager.py
@@ -0,0 +1,1089 @@
+"""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 dataclasses
+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, RawIncoming, OutboundMessage
+from .bus import MessageBus
+from .bus.events import InboundMessage
+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)
+# ═════════════════════════════════════════════════════════════════════
+
+from .middleware import (
+ InboundMiddleware,
+ OutboundMiddlewareBase,
+ DedupMiddleware,
+ AllowListMiddleware,
+ MentionGatingMiddleware,
+ GroupHistoryMiddleware,
+ DebounceMiddleware,
+ AckReactionMiddleware,
+ FormattingMiddleware,
+ ChunkingMiddleware,
+ RetryMiddleware,
+ TypingMiddleware,
+ PairingMiddleware,
+)
+
+
+class InboundPipeline:
+ """Processes incoming messages through a middleware chain."""
+
+ def __init__(
+ self,
+ plugin: ChannelPlugin,
+ middlewares: list[InboundMiddleware],
+ ) -> None:
+ self.plugin = plugin
+ self.middlewares = middlewares
+
+ async def process(
+ self,
+ raw: RawIncoming,
+ context: dict[str, Any] | None = None,
+ ) -> RawIncoming | None:
+ """Run *raw* through each middleware. Returns ``None`` if dropped."""
+ ctx = context or {}
+ current: RawIncoming | None = raw
+ for mw in self.middlewares:
+ if current is None:
+ return None
+ current = await mw.process_inbound(current, ctx)
+ return current
+
+
+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_inbound_pipeline(
+ plugin: ChannelPlugin,
+ config: Any,
+) -> InboundPipeline:
+ """Auto-assemble inbound pipeline based on plugin 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
+ """
+ caps = plugin.capabilities
+ middlewares: list[InboundMiddleware] = []
+
+ middlewares.append(DedupMiddleware())
+
+ allowed_senders = getattr(config, "allowed_senders", None)
+ allowed_channels = getattr(config, "allowed_channels", None)
+ dm_policy = getattr(config, "dm_policy", "allowlist")
+ if allowed_senders:
+ allowed_senders = set(allowed_senders) if not isinstance(allowed_senders, set) else allowed_senders
+ if allowed_channels:
+ allowed_channels = set(allowed_channels) if not isinstance(allowed_channels, set) else allowed_channels
+ middlewares.append(AllowListMiddleware(
+ allowed_senders=allowed_senders,
+ allowed_channels=allowed_channels,
+ dm_policy=dm_policy,
+ ))
+
+ if plugin.pairing is not None:
+ middlewares.append(PairingMiddleware(channel_name=plugin.id))
+
+ if caps.groups:
+ middlewares.append(GroupHistoryMiddleware())
+
+ if caps.mentions:
+ strip_fn = None
+ if plugin.mentions is not None:
+ strip_fn = lambda text, _adapter=plugin.mentions: _adapter.strip_mentions(text, {}) # noqa: E731
+ require_mention = getattr(config, "require_mention", "group")
+ middlewares.append(MentionGatingMiddleware(
+ require_mention=require_mention,
+ strip_fn=strip_fn,
+ ))
+
+ return InboundPipeline(plugin, middlewares)
+
+
+def build_outbound_pipeline(
+ plugin: ChannelPlugin,
+ config: Any,
+) -> OutboundPipeline:
+ """Auto-assemble outbound pipeline based on plugin capabilities."""
+ caps = plugin.capabilities
+ middlewares: list[OutboundMiddlewareBase] = []
+ middlewares.append(FormattingMiddleware(caps))
+ 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._inbound_pipelines: dict[str, InboundPipeline] = {}
+ 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._inbound_pipelines[name] = build_inbound_pipeline(channel, config)
+ 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_inbound_pipeline": name in self._inbound_pipelines,
+ "has_outbound_pipeline": name in self._outbound_pipelines,
+ }
+ return result
diff --git a/EvoScientist/channels/config.py b/EvoScientist/channels/config.py
new file mode 100644
index 0000000..f27ad3e
--- /dev/null
+++ b/EvoScientist/channels/config.py
@@ -0,0 +1,126 @@
+"""Base configuration for all channel implementations.
+
+Provides common fields shared across channels, reducing duplication.
+Channel-specific configs inherit from BaseChannelConfig.
+
+Also provides ready-made ConfigAdapter implementations for the two most
+common account patterns:
+
+- ``SingleAccountConfigAdapter`` — one account per channel (default).
+- ``MultiAccountConfigAdapter`` — multiple accounts from a config dict.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass
+class BaseChannelConfig:
+ """Common configuration fields for all channels.
+
+ Subclass this for channel-specific configs. Only add fields
+ here that are used by 3+ channels.
+ """
+
+ allowed_senders: set[str] | None = None
+ allowed_channels: set[str] | None = None
+ text_chunk_limit: int = 4096
+ proxy: str | None = None
+ include_attachments: bool = True
+ accounts: dict | None = None # multi-account config mapping
+
+
+class SingleAccountConfigAdapter:
+ """For channels that only ever have one account (most channels).
+
+ Returns a single ``"default"`` account whose config is the entire
+ channel config object. This is the zero-change default: existing
+ single-account channels get multi-account support for free.
+ """
+
+ def list_account_ids(self, config: Any) -> list[str]:
+ return ["default"]
+
+ def resolve_account(
+ self, config: Any, account_id: str | None = None,
+ ) -> Any:
+ return config
+
+ def is_enabled(self, account: Any, config: Any) -> bool:
+ return True
+
+ def is_configured(self, account: Any, config: Any) -> bool:
+ """Check that the account has at least some non-None values."""
+ if account is None:
+ return False
+ if isinstance(account, dict):
+ return bool(account)
+ # dataclass / object — check that at least one field is truthy
+ if hasattr(account, "__dataclass_fields__"):
+ return any(
+ getattr(account, f, None)
+ for f in account.__dataclass_fields__
+ )
+ return True
+
+
+class MultiAccountConfigAdapter:
+ """For channels that support multiple accounts.
+
+ Expects the channel config to contain a mapping of accounts under
+ a configurable key (default ``"accounts"``). Each entry is keyed
+ by account id and holds account-specific settings.
+
+ Example config structure::
+
+ {
+ "accounts": {
+ "bot1": {"token": "...", "enabled": true},
+ "bot2": {"token": "...", "enabled": false},
+ }
+ }
+ """
+
+ def __init__(
+ self,
+ accounts_key: str = "accounts",
+ required_fields: list[str] | None = None,
+ ) -> None:
+ self._accounts_key = accounts_key
+ self._required_fields = required_fields or []
+
+ def _get_accounts_map(self, config: Any) -> dict[str, Any]:
+ """Extract the accounts mapping from config."""
+ if isinstance(config, dict):
+ return config.get(self._accounts_key, {})
+ return getattr(config, self._accounts_key, None) or {}
+
+ def list_account_ids(self, config: Any) -> list[str]:
+ return list(self._get_accounts_map(config).keys())
+
+ def resolve_account(
+ self, config: Any, account_id: str | None = None,
+ ) -> Any:
+ accounts = self._get_accounts_map(config)
+ if account_id is None:
+ # Return the first account, or empty dict
+ return next(iter(accounts.values()), {})
+ return accounts.get(account_id, {})
+
+ def is_enabled(self, account: Any, config: Any) -> bool:
+ if isinstance(account, dict):
+ return account.get("enabled", True)
+ return getattr(account, "enabled", True)
+
+ def is_configured(self, account: Any, config: Any) -> bool:
+ if not account:
+ return False
+ for f in self._required_fields:
+ if isinstance(account, dict):
+ if not account.get(f):
+ return False
+ elif not getattr(account, f, None):
+ return False
+ return True
diff --git a/EvoScientist/channels/consumer.py b/EvoScientist/channels/consumer.py
new file mode 100644
index 0000000..19e379c
--- /dev/null
+++ b/EvoScientist/channels/consumer.py
@@ -0,0 +1,404 @@
+"""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, field
+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]
+ self._sessions[sender_id] = self.thread_id or 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),
+ 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=f"Error: {e}",
+ metadata=msg.metadata,
+ ))
+ finally:
+ if channel:
+ await channel.stop_typing(msg.chat_id)
+
+ # ── observability ──
+
+ @property
+ def pending_count(self) -> int:
+ """Number of messages waiting in the work queue."""
+ return self._work_queue.qsize()
+
+ @property
+ def active_workers(self) -> int:
+ """Number of worker tasks that are still alive."""
+ return sum(1 for w in self._workers if not w.done())
+
+ @property
+ def metrics(self) -> dict[str, int]:
+ """Cumulative processing counters."""
+ m = self._metrics
+ return {
+ "total_processed": m.total_processed,
+ "total_successes": m.total_successes,
+ "total_failures": m.total_failures,
+ "total_timeouts": m.total_timeouts,
+ "pending": self.pending_count,
+ "active_workers": self.active_workers,
+ "chat_locks": len(self._chat_locks),
+ "sessions": len(self._sessions),
+ }
+
+ # ── internal ──
+
+ def _evict_chat_locks(self) -> None:
+ """Remove chat locks that are not currently held."""
+ stale = [k for k, lock in self._chat_locks.items() if not lock.locked()]
+ for k in stale[:max(1, len(stale) // 2)]:
+ del self._chat_locks[k]
diff --git a/EvoScientist/channels/dingtalk/__init__.py b/EvoScientist/channels/dingtalk/__init__.py
new file mode 100644
index 0000000..2026d82
--- /dev/null
+++ b/EvoScientist/channels/dingtalk/__init__.py
@@ -0,0 +1,29 @@
+"""DingTalk (钉钉) channel for EvoScientist.
+
+Uses Stream Mode (WebSocket) for receiving messages — no public IP needed.
+Sends replies via HTTP API.
+
+Usage in config:
+ channel_enabled = "dingtalk"
+ dingtalk_client_id = "your_app_key"
+ dingtalk_client_secret = "your_app_secret"
+"""
+
+from .channel import DingTalkChannel, DingTalkConfig
+from ..channel_manager import register_channel, _parse_csv
+
+__all__ = ["DingTalkChannel", "DingTalkConfig"]
+
+
+def create_from_config(config) -> DingTalkChannel:
+ allowed = _parse_csv(getattr(config, "dingtalk_allowed_senders", ""))
+ proxy = getattr(config, "dingtalk_proxy", "") or None
+ return DingTalkChannel(DingTalkConfig(
+ client_id=getattr(config, "dingtalk_client_id", ""),
+ client_secret=getattr(config, "dingtalk_client_secret", ""),
+ allowed_senders=allowed,
+ proxy=proxy,
+ ))
+
+
+register_channel("dingtalk", create_from_config)
diff --git a/EvoScientist/channels/dingtalk/channel.py b/EvoScientist/channels/dingtalk/channel.py
new file mode 100644
index 0000000..f407078
--- /dev/null
+++ b/EvoScientist/channels/dingtalk/channel.py
@@ -0,0 +1,287 @@
+"""DingTalk channel — refactored with WebSocketMixin + TokenMixin."""
+
+import asyncio
+import json
+import logging
+from urllib.parse import quote_plus
+import time
+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"
+
+
+@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()
+
+ # ── 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()
+ content = content or payload.get("content", "").strip()
+ if not content:
+ return
+
+ # Download attachments if present
+ annotations: list[str] = []
+ media_paths: list[str] = []
+ 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_url = att.get("downloadCode") or att.get("downloadUrl") or ""
+ # 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}]")
+
+ sender_id = payload.get("senderStaffId") or payload.get("senderId", "")
+ conv_id = payload.get("conversationId", "")
+ is_group = payload.get("conversationType") == "2"
+ # For send API (oToMessages/batchSend), userIds needs staffId, not conversationId
+ chat_id = sender_id
+ create_time = payload.get("createAt") or payload.get("createTime", "")
+
+ # Mention gating: DMs always pass; groups require @bot
+ was_mentioned = not is_group
+ if is_group:
+ # isInAtList is set by DingTalk when bot is @mentioned
+ if payload.get("isInAtList"):
+ was_mentioned = True
+ else:
+ # Fallback: check atUsers array
+ at_users = payload.get("atUsers") or []
+ for u in at_users:
+ if u.get("dingtalkId") == self.config.client_id:
+ was_mentioned = True
+ break
+
+ try:
+ ts = datetime.fromtimestamp(int(create_time) / 1000) if create_time else datetime.now()
+ except (ValueError, TypeError, OSError):
+ ts = datetime.now()
+
+ await self._enqueue_raw(RawIncoming(
+ sender_id=sender_id, chat_id=chat_id, text=content, timestamp=ts,
+ message_id=msg_id, is_group=is_group, was_mentioned=was_mentioned,
+ media_files=media_paths,
+ content_annotations=annotations,
+ metadata={"chat_id": chat_id, "sender_nick": payload.get("senderNick", ""), "backend": "dingtalk"},
+ ))
+
+ # _send_typing_action: inherited no-op (DingTalk has no typing API)
+ # _format_chunk: inherited from base (UnifiedFormatter)
+
+ # ── Send ──────────────────────────────────────────────────────
+
+ async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
+ token = await self._ensure_token()
+ data = await self._api_post(SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps({"text": raw_text, "title": "EvoScientist"}),
+ }, headers={"x-acs-dingtalk-access-token": token})
+ return data
+
+ # ── Media send ────────────────────────────────────────────────
+
+ _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
+
+ async def _send_media_impl(
+ self,
+ recipient: str,
+ file_path: str,
+ caption: str = "",
+ metadata: dict | None = None,
+ ) -> bool:
+ """Send a media file through DingTalk.
+
+ For images: uploads via /media/upload to get media_id, then sends
+ as sampleImageMsg. Non-image files are sent as markdown links
+ (DingTalk robot API does not support arbitrary file uploads).
+ """
+ token = await self._ensure_token()
+ chat_id = self._resolve_media_chat_id(recipient, metadata)
+ headers = {"x-acs-dingtalk-access-token": token}
+ ext = Path(file_path).suffix.lower()
+
+ if ext in self._IMAGE_EXTS:
+ # Try uploading image to get media_id for native image message
+ media_id = await self._upload_dingtalk_media(token, file_path, "image")
+ if media_id:
+ await self._api_post(MEDIA_SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleImageMsg",
+ "msgParam": json.dumps({"photoURL": media_id}),
+ }, headers=headers)
+ else:
+ # Fallback to markdown with file path
+ await self._api_post(MEDIA_SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps({
+ "text": f"" + (f"\n{caption}" if caption else ""),
+ "title": caption or "Image",
+ }),
+ }, headers=headers)
+ else:
+ # Non-image: send as markdown with filename
+ name = Path(file_path).name
+ text = f"[文件] {name}" + (f"\n{caption}" if caption else "")
+ await self._api_post(MEDIA_SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps({"text": text, "title": name}),
+ }, headers=headers)
+
+ if caption and ext in self._IMAGE_EXTS:
+ # Send caption separately for image messages
+ await self._api_post(MEDIA_SEND_URL, {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps({"text": caption, "title": "Caption"}),
+ }, headers=headers)
+ return True
+
+ async def _upload_dingtalk_media(
+ self, token: str, file_path: str, media_type: str = "image",
+ ) -> str | None:
+ """Upload a file to DingTalk media API and return the media_id."""
+ try:
+ url = f"{MEDIA_UPLOAD_URL}?access_token={token}&type={media_type}"
+ with open(file_path, "rb") as f:
+ resp = await self._http_client.post(
+ url, files={"media": (Path(file_path).name, f)},
+ )
+ data = resp.json()
+ return data.get("media_id")
+ except Exception as e:
+ logger.warning(f"DingTalk media upload failed: {e}")
+ return None
+
+ async def _cleanup(self) -> None:
+ if hasattr(self, "_ws_task") and self._ws_task:
+ self._ws_task.cancel()
+ try:
+ await self._ws_task
+ except (asyncio.CancelledError, Exception):
+ pass
+ self._ws_task = None
+ await self._stop_ws()
+ if self._http_client:
+ await self._http_client.aclose()
+ self._http_client = None
+ self._access_token = None
+ logger.info("DingTalk channel stopped")
diff --git a/EvoScientist/channels/dingtalk/probe.py b/EvoScientist/channels/dingtalk/probe.py
new file mode 100644
index 0000000..eb0eeb2
--- /dev/null
+++ b/EvoScientist/channels/dingtalk/probe.py
@@ -0,0 +1,33 @@
+"""DingTalk credential validation."""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+async def validate_dingtalk(
+ client_id: str,
+ client_secret: str,
+ proxy: str | None = None,
+) -> tuple[bool, str]:
+ """Validate DingTalk credentials by fetching an access token."""
+ if not client_id or not client_secret:
+ return False, "client_id and client_secret are required"
+
+ try:
+ import httpx
+ except ImportError:
+ return False, "httpx not installed"
+
+ url = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
+ body = {"appKey": client_id, "appSecret": client_secret}
+
+ try:
+ async with httpx.AsyncClient(proxy=proxy) as client:
+ resp = await client.post(url, json=body, timeout=10)
+ data = resp.json()
+ if data.get("accessToken"):
+ return True, "DingTalk credentials valid"
+ return False, f"Error: {data.get('message', data)}"
+ except Exception as e:
+ return False, f"Error: {e}"
diff --git a/EvoScientist/channels/dingtalk/serve.py b/EvoScientist/channels/dingtalk/serve.py
new file mode 100644
index 0000000..8fb4abb
--- /dev/null
+++ b/EvoScientist/channels/dingtalk/serve.py
@@ -0,0 +1,92 @@
+"""DingTalk channel server.
+
+Standalone script to run the DingTalk channel with CLI options.
+
+Usage:
+ python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET [OPTIONS]
+
+Examples:
+ # Basic usage
+ python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET
+
+ # With proxy and allowed senders
+ python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET --proxy http://proxy:8080 --allow user123
+
+ # With agent and thinking
+ python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET --agent --thinking
+"""
+
+import argparse
+import logging
+
+from .channel import DingTalkChannel, DingTalkConfig
+from ..bus import MessageBus
+from ..standalone import run_standalone
+
+logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger(__name__)
+
+
+def parse_args():
+ """Parse command line arguments."""
+ parser = argparse.ArgumentParser(
+ description="DingTalk channel server",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "--client-id",
+ required=True,
+ help="DingTalk app client ID",
+ )
+ parser.add_argument(
+ "--client-secret",
+ required=True,
+ help="DingTalk app client secret",
+ )
+ parser.add_argument(
+ "--allow",
+ action="append",
+ dest="allowed_senders",
+ help="Allowed sender (DingTalk user ID). Can be used multiple times.",
+ )
+ parser.add_argument(
+ "--proxy",
+ help="HTTP proxy URL",
+ )
+ parser.add_argument(
+ "--agent",
+ action="store_true",
+ help="Use EvoScientist agent as handler (default: echo)",
+ )
+ parser.add_argument(
+ "--thinking",
+ action="store_true",
+ help="Send thinking content as intermediate messages (requires --agent)",
+ )
+ return parser.parse_args()
+
+
+def main():
+ """Entry point."""
+ args = parse_args()
+
+ config = DingTalkConfig(
+ client_id=args.client_id,
+ client_secret=args.client_secret,
+ allowed_senders=set(args.allowed_senders) if args.allowed_senders else None,
+ proxy=args.proxy,
+ )
+
+ send_thinking = args.thinking and args.agent
+ bus = MessageBus()
+ channel = DingTalkChannel(config)
+
+ run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/EvoScientist/channels/discord/__init__.py b/EvoScientist/channels/discord/__init__.py
new file mode 100644
index 0000000..dfc7740
--- /dev/null
+++ b/EvoScientist/channels/discord/__init__.py
@@ -0,0 +1,19 @@
+from .channel import DiscordChannel, DiscordConfig
+from ..channel_manager import register_channel, _parse_csv
+
+__all__ = ["DiscordChannel", "DiscordConfig"]
+
+
+def create_from_config(config) -> DiscordChannel:
+ allowed = _parse_csv(config.discord_allowed_senders)
+ channels = _parse_csv(config.discord_allowed_channels)
+ proxy = config.discord_proxy if config.discord_proxy else None
+ return DiscordChannel(DiscordConfig(
+ bot_token=config.discord_bot_token,
+ allowed_senders=allowed,
+ allowed_channels=channels,
+ proxy=proxy,
+ ))
+
+
+register_channel("discord", create_from_config)
diff --git a/EvoScientist/channels/discord/channel.py b/EvoScientist/channels/discord/channel.py
new file mode 100644
index 0000000..2fe5dd3
--- /dev/null
+++ b/EvoScientist/channels/discord/channel.py
@@ -0,0 +1,256 @@
+"""Discord channel implementation using discord.py."""
+
+import asyncio
+import logging
+import os
+import re
+from dataclasses import dataclass
+from datetime import datetime
+
+from ..base import Channel, RawIncoming, ChannelError
+from ..capabilities import DISCORD as DISCORD_CAPS
+from ..config import BaseChannelConfig
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class DiscordConfig(BaseChannelConfig):
+ bot_token: str = ""
+ text_chunk_limit: int = 4096
+
+
+class DiscordChannel(Channel):
+ """Discord channel using discord.py."""
+
+ name = "discord"
+
+ capabilities = DISCORD_CAPS
+ _typing_interval: float = 8.0
+ _ready_attrs = ("_client",)
+ _mention_pattern = r"<@!?{bot_id}>\s*"
+
+ def __init__(self, config: DiscordConfig):
+ super().__init__(config)
+ self._client = None
+ self._ready = asyncio.Event()
+ # Cache message objects for ACK reactions
+ self._message_cache: dict[str, object] = {}
+ self._MESSAGE_CACHE_MAX = 200
+
+ async def start(self) -> None:
+ try:
+ import discord
+ except ImportError:
+ raise ChannelError(
+ "discord.py not installed. "
+ "Install with: pip install evoscientist[discord]"
+ )
+
+ if not self.config.bot_token:
+ raise ChannelError("Discord bot token is required")
+
+ proxy = (
+ self.config.proxy
+ or os.environ.get("https_proxy")
+ or os.environ.get("HTTPS_PROXY")
+ or os.environ.get("http_proxy")
+ or os.environ.get("HTTP_PROXY")
+ or None
+ )
+
+ logger.info(
+ "Discord connect: token=%s...%s proxy=%s",
+ self.config.bot_token[:8],
+ self.config.bot_token[-4:],
+ proxy or "(none)",
+ )
+
+ intents = discord.Intents.default()
+ intents.message_content = True
+ client_kwargs = {"intents": intents}
+ if proxy:
+ client_kwargs["proxy"] = proxy
+ self._client = discord.Client(**client_kwargs)
+
+ self._start_task_error: BaseException | None = None
+
+ @self._client.event
+ async def on_ready():
+ logger.info(f"Discord bot ready: {self._client.user}")
+ self._ready.set()
+
+ @self._client.event
+ async def on_message(message):
+ await self._on_message(message)
+
+ async def _guarded_start():
+ try:
+ logger.info("Discord gateway: starting client.start()...")
+ await self._client.start(self.config.bot_token)
+ except Exception as exc:
+ logger.error("Discord gateway error: %s: %s", type(exc).__name__, exc)
+ self._start_task_error = exc
+ self._ready.set() # unblock the waiter so it doesn't hang
+
+ logger.info("Discord connect: launching gateway task")
+ asyncio.create_task(_guarded_start())
+
+ try:
+ await asyncio.wait_for(self._ready.wait(), timeout=60)
+ except asyncio.TimeoutError:
+ raise ChannelError(
+ "Discord bot failed to connect within 60s. "
+ "Check network/proxy connectivity to gateway.discord.gg"
+ )
+
+ if self._start_task_error:
+ raise ChannelError(
+ f"Discord bot failed to connect: {self._start_task_error}"
+ )
+
+ self._running = True
+ logger.info("Discord channel started")
+
+ async def _cleanup(self) -> None:
+ if self._client:
+ await self._client.close()
+ logger.info("Discord channel stopped")
+
+ # ── Typing indicator ────────────────────────────────────────────
+
+ async def _send_typing_action(self, chat_id: str) -> None:
+ if not self._client:
+ return
+ ch = self._client.get_channel(int(chat_id))
+ if ch:
+ await ch.trigger_typing()
+
+ # ── ACK Reactions ───────────────────────────────────────────────
+
+ async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None:
+ msg = self._message_cache.get(message_id)
+ if msg:
+ try:
+ await msg.add_reaction(emoji)
+ except Exception as e:
+ logger.debug(f"Discord ACK reaction failed: {e}")
+
+ async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None:
+ msg = self._message_cache.get(message_id)
+ if msg and self._client and self._client.user:
+ try:
+ await msg.remove_reaction(emoji, self._client.user)
+ except Exception as e:
+ logger.debug(f"Discord remove ACK reaction failed: {e}")
+
+ def _cache_message(self, message) -> None:
+ """Cache a discord message object for later reaction use."""
+ mid = str(message.id)
+ self._message_cache[mid] = message
+ # Evict oldest entries if cache is too large
+ if len(self._message_cache) > self._MESSAGE_CACHE_MAX:
+ oldest = list(self._message_cache.keys())[: self._MESSAGE_CACHE_MAX // 2]
+ for k in oldest:
+ self._message_cache.pop(k, None)
+
+ # ── Send ────────────────────────────────────────────────────────
+
+
+ async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
+ import discord
+
+ thread_id = (metadata or {}).get("thread_id", "")
+ target_id = int(thread_id) if thread_id else int(chat_id)
+ ch = self._client.get_channel(target_id)
+ if not ch:
+ raise RuntimeError(f"Discord channel {target_id} not found")
+ ref = None
+ if reply_to:
+ try:
+ ref = discord.MessageReference(
+ message_id=int(reply_to), channel_id=target_id,
+ )
+ except (ValueError, TypeError):
+ pass
+
+ async def _send(text):
+ await ch.send(text, reference=ref)
+
+ await self._send_with_format_fallback(_send, formatted_text, raw_text)
+
+ async def _send_media_impl(
+ self, recipient: str, file_path: str,
+ caption: str = "", metadata: dict | None = None,
+ ) -> bool:
+ import discord
+
+ channel_id = self._resolve_media_chat_id(recipient, metadata)
+ ch = self._client.get_channel(int(channel_id))
+ if not ch:
+ logger.error(f"Discord channel {channel_id} not found")
+ return False
+ file = discord.File(file_path)
+ await ch.send(content=caption or None, file=file)
+ return True
+
+ def _get_bot_identifier(self) -> str | None:
+ if self._client and self._client.user:
+ return str(self._client.user.id)
+ return None
+
+ # ── Inbound ─────────────────────────────────────────────────────
+
+ async def _on_message(self, message) -> None:
+ import discord
+
+ if message.author == self._client.user:
+ return
+
+ # Cache for ACK reactions
+ self._cache_message(message)
+
+ user_id = str(message.author.id)
+ channel_id = str(message.channel.id)
+
+ is_dm = isinstance(message.channel, discord.DMChannel)
+ was_mentioned = is_dm or (self._client.user in message.mentions)
+
+ text = message.content or ""
+ annotations: list[str] = []
+ media_paths: list[str] = []
+
+ if self.config.include_attachments and message.attachments:
+ for attachment in message.attachments:
+ too_large = self._check_attachment_size(
+ attachment.size or 0, attachment.filename,
+ )
+ if too_large:
+ annotations.append(too_large)
+ continue
+ try:
+ safe_name = attachment.filename.replace("/", "_")
+ file_path = self._media_path(f"{attachment.id}_{safe_name}")
+ await attachment.save(file_path)
+ media_paths.append(str(file_path))
+ annotations.append(f"[attachment: {file_path}]")
+ except Exception as e:
+ logger.warning(f"Failed to download Discord attachment: {e}")
+ annotations.append(f"[attachment: {attachment.filename} - download failed]")
+
+ # Detect thread context
+ thread_id = ""
+ parent_channel_id = channel_id
+ if hasattr(message.channel, "parent") and message.channel.parent:
+ # Message is inside a Thread — store thread info
+ thread_id = channel_id # the thread IS the channel
+ parent_channel_id = str(message.channel.parent.id)
+
+ await self._enqueue_raw(RawIncoming(
+ sender_id=user_id, chat_id=parent_channel_id, text=text,
+ media_files=media_paths, content_annotations=annotations,
+ timestamp=message.created_at or datetime.now(),
+ message_id=str(message.id),
+ metadata={"chat_id": parent_channel_id, "thread_id": thread_id},
+ is_group=not is_dm, was_mentioned=was_mentioned,
+ ))
diff --git a/EvoScientist/channels/discord/probe.py b/EvoScientist/channels/discord/probe.py
new file mode 100644
index 0000000..9fbc4c0
--- /dev/null
+++ b/EvoScientist/channels/discord/probe.py
@@ -0,0 +1,33 @@
+"""Discord bot token validation."""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+async def validate_discord_token(token: str, proxy: str | None = None) -> tuple[bool, str]:
+ """Validate a Discord bot token via the REST API.
+
+ Returns:
+ Tuple of (is_valid, message).
+ """
+ if not token:
+ return False, "No token provided"
+
+ try:
+ import httpx
+ except ImportError:
+ return False, "httpx not installed"
+
+ url = "https://discord.com/api/v10/users/@me"
+ headers = {"Authorization": f"Bot {token}"}
+ try:
+ async with httpx.AsyncClient(proxy=proxy) as client:
+ resp = await client.get(url, headers=headers, timeout=10)
+ if resp.status_code == 200:
+ data = resp.json()
+ username = data.get("username", "unknown")
+ return True, f"Bot: {username}"
+ return False, "Invalid token"
+ except Exception as e:
+ return False, f"Error: {e}"
diff --git a/EvoScientist/channels/discord/serve.py b/EvoScientist/channels/discord/serve.py
new file mode 100644
index 0000000..9e8f000
--- /dev/null
+++ b/EvoScientist/channels/discord/serve.py
@@ -0,0 +1,93 @@
+"""Discord channel server.
+
+Standalone script to run the Discord channel with CLI options.
+
+Usage:
+ python -m EvoScientist.channels.discord.serve --bot-token TOKEN [OPTIONS]
+
+Examples:
+ # Allow all senders (default)
+ python -m EvoScientist.channels.discord.serve --bot-token TOKEN
+
+ # Only allow specific senders and channels
+ python -m EvoScientist.channels.discord.serve --bot-token TOKEN --allow 123 --allow-channel 456
+
+ # With proxy, agent and thinking
+ python -m EvoScientist.channels.discord.serve --bot-token TOKEN --proxy http://proxy:8080 --agent --thinking
+"""
+
+import argparse
+import logging
+
+from .channel import DiscordChannel, DiscordConfig
+from ..bus import MessageBus
+from ..standalone import run_standalone
+
+logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ datefmt="%H:%M:%S",
+)
+logger = logging.getLogger(__name__)
+
+
+def parse_args():
+ """Parse command line arguments."""
+ parser = argparse.ArgumentParser(
+ description="Discord channel server",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "--bot-token",
+ required=True,
+ help="Discord bot token",
+ )
+ parser.add_argument(
+ "--allow",
+ action="append",
+ dest="allowed_senders",
+ help="Allowed sender (Discord user ID). Can be used multiple times.",
+ )
+ parser.add_argument(
+ "--allow-channel",
+ action="append",
+ dest="allowed_channels",
+ help="Allowed channel ID. Can be used multiple times.",
+ )
+ parser.add_argument(
+ "--proxy",
+ help="HTTP proxy URL for Discord API requests",
+ )
+ parser.add_argument(
+ "--agent",
+ action="store_true",
+ help="Use EvoScientist agent as handler (default: echo)",
+ )
+ parser.add_argument(
+ "--thinking",
+ action="store_true",
+ help="Send thinking content as intermediate messages (requires --agent)",
+ )
+ return parser.parse_args()
+
+
+def main():
+ """Entry point."""
+ args = parse_args()
+
+ config = DiscordConfig(
+ bot_token=args.bot_token,
+ allowed_senders=set(args.allowed_senders) if args.allowed_senders else None,
+ allowed_channels=set(args.allowed_channels) if args.allowed_channels else None,
+ proxy=args.proxy,
+ )
+
+ send_thinking = args.thinking and args.agent
+ bus = MessageBus()
+ channel = DiscordChannel(config)
+
+ run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/EvoScientist/channels/email/__init__.py b/EvoScientist/channels/email/__init__.py
new file mode 100644
index 0000000..56bd5f9
--- /dev/null
+++ b/EvoScientist/channels/email/__init__.py
@@ -0,0 +1,41 @@
+"""Email channel for EvoScientist.
+
+Uses IMAP polling for inbound + SMTP for outbound. Pure Python, no extra deps.
+
+Usage in config:
+ channel_enabled = "email"
+ email_imap_host = "imap.gmail.com"
+ email_smtp_host = "smtp.gmail.com"
+ ...
+"""
+
+from .channel import EmailChannel, EmailConfig
+from ..channel_manager import register_channel, _parse_csv
+
+__all__ = ["EmailChannel", "EmailConfig"]
+
+
+def create_from_config(config) -> EmailChannel:
+ allowed = _parse_csv(getattr(config, "email_allowed_senders", ""))
+ return EmailChannel(EmailConfig(
+ imap_host=getattr(config, "email_imap_host", ""),
+ imap_port=int(getattr(config, "email_imap_port", 993)),
+ imap_username=getattr(config, "email_imap_username", ""),
+ imap_password=getattr(config, "email_imap_password", ""),
+ imap_mailbox=getattr(config, "email_imap_mailbox", "INBOX"),
+ imap_use_ssl=getattr(config, "email_imap_use_ssl", True),
+ smtp_host=getattr(config, "email_smtp_host", ""),
+ smtp_port=int(getattr(config, "email_smtp_port", 587)),
+ smtp_username=getattr(config, "email_smtp_username", ""),
+ smtp_password=getattr(config, "email_smtp_password", ""),
+ smtp_use_tls=getattr(config, "email_smtp_use_tls", True),
+ from_address=getattr(config, "email_from_address", ""),
+ poll_interval=int(getattr(config, "email_poll_interval", 30)),
+ mark_seen=getattr(config, "email_mark_seen", True),
+ max_body_chars=int(getattr(config, "email_max_body_chars", 12000)),
+ subject_prefix=getattr(config, "email_subject_prefix", "Re: "),
+ allowed_senders=allowed,
+ ))
+
+
+register_channel("email", create_from_config)
diff --git a/EvoScientist/channels/email/channel.py b/EvoScientist/channels/email/channel.py
new file mode 100644
index 0000000..f60e70d
--- /dev/null
+++ b/EvoScientist/channels/email/channel.py
@@ -0,0 +1,349 @@
+"""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 = str(part.get("Content-Disposition", "")) + content_type = part.get_content_type() or "" + is_attachment = "attachment" in content_disp + is_inline_image = ( + "inline" in content_disp + and content_type.startswith("image/") + ) + if is_attachment or is_inline_image: + filename = part.get_filename() or "attachment" + filename = _decode_hdr(filename) + payload_data = part.get_payload(decode=True) + if payload_data: + from ..base import MAX_ATTACHMENT_BYTES, MEDIA_DIR + if len(payload_data) > MAX_ATTACHMENT_BYTES: + attachments.append({"annotation": f"[attachment: {filename} - too large ({len(payload_data)} bytes)]"}) + else: + MEDIA_DIR.mkdir(parents=True, exist_ok=True) + local_path = MEDIA_DIR / f"email_{mid.decode()}_{filename}" + local_path.write_bytes(payload_data) + label = "inline-image" if is_inline_image else "attachment" + attachments.append({"path": str(local_path), "annotation": f"[{label}: {local_path}]"}) + if self.config.mark_seen: + self._imap.store(mid, "+FLAGS", "\\Seen") + results.append({ + "from_addr": from_addr, "from_name": _decode_hdr(from_name), + "subject": _decode_hdr(msg.get("Subject", "")), "body": body, + "message_id": msg.get("Message-ID", ""), "date": msg.get("Date", ""), + "references": msg.get("References", ""), "attachments": attachments, + }) + except Exception as e: + logger.error(f"IMAP fetch: {e}") + return results + + def _extract_body(self, msg) -> str: + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + if ct == "text/plain": + return self._decode_payload(part) + for part in msg.walk(): + if part.get_content_type() == "text/html": + return _strip_html(self._decode_payload(part)) + return "[no text content]" + text = self._decode_payload(msg) + return _strip_html(text) if msg.get_content_type() == "text/html" else text + + @staticmethod + def _decode_payload(part) -> str: + payload = part.get_payload(decode=True) + if not payload: + return "" + charset = part.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + + async def _process_email(self, m: dict) -> None: + subject = m["subject"] + text = f"[邮件] 主题: {subject}\n\n{m['body']}" if subject else m["body"] + try: + ts = email_lib.utils.parsedate_to_datetime(m["date"]) + except Exception: + ts = datetime.now() + # Process attachments + media_paths: list[str] = [] + annotations: list[str] = [] + for att in m.get("attachments", []): + if att.get("path"): + media_paths.append(att["path"]) + if att.get("annotation"): + annotations.append(att["annotation"]) + await self._enqueue_raw(RawIncoming( + sender_id=m["from_addr"], chat_id=m["from_addr"], text=text, timestamp=ts, + message_id=m["message_id"], + media_files=media_paths, + content_annotations=annotations, + metadata={"chat_id": m["from_addr"], "subject": subject, + "original_message_id": m["message_id"], "references": m["references"], "backend": "email"}, + )) + + # ── Send ────────────────────────────────────────────────────── + + def _is_ready(self) -> bool: + return bool(self.config.smtp_host) + + async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): + loop = asyncio.get_event_loop() + try: + await loop.run_in_executor( + None, self._smtp_send_html, chat_id, formatted_text, raw_text, metadata or {}, + ) + except Exception as e: + err_str = str(e).lower() + # Only fall back to plain text for format-related errors, not server rejections + if any(code in err_str for code in ("550", "553", "554", "auth", "rejected")): + raise + logger.warning(f"HTML email failed ({e}), falling back to plain text") + await loop.run_in_executor( + None, self._smtp_send, chat_id, raw_text, metadata or {}, + ) + + def _smtp_send(self, to: str, content: str, meta: dict) -> None: + cfg = self.config + from_addr = cfg.from_address or cfg.smtp_username + logger.debug(f"SMTP plain send: from={from_addr} to={to}") + msg = EmailMessage() + orig_subj = meta.get("subject", "") + msg["Subject"] = f"{cfg.subject_prefix}{orig_subj}" if orig_subj and not orig_subj.lower().startswith("re:") else (orig_subj or "EvoScientist Reply") + msg["From"] = from_addr + msg["To"] = to + orig_id = meta.get("original_message_id", "") + if orig_id: + msg["In-Reply-To"] = orig_id + msg["References"] = f"{meta.get('references', '')} {orig_id}".strip() + msg.set_content(content) + try: + if cfg.smtp_use_tls: + srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) + srv.starttls() + else: + srv = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=ssl.create_default_context(), timeout=30) + srv.login(cfg.smtp_username, cfg.smtp_password) + srv.sendmail(from_addr, [to], msg.as_string()) + srv.quit() + except Exception as e: + logger.error(f"SMTP send failed: from={from_addr} to={to} error={e}") + raise RuntimeError(f"SMTP: {e}") + + def _smtp_send_html(self, to: str, html_content: str, plain_content: str, meta: dict) -> None: + """Send an email with both HTML and plain-text parts.""" + cfg = self.config + from_addr = cfg.from_address or cfg.smtp_username + logger.debug(f"SMTP HTML send: from={from_addr} to={to}") + msg = MIMEMultipart("alternative") + orig_subj = meta.get("subject", "") + msg["Subject"] = f"{cfg.subject_prefix}{orig_subj}" if orig_subj and not orig_subj.lower().startswith("re:") else (orig_subj or "EvoScientist Reply") + msg["From"] = from_addr + msg["To"] = to + orig_id = meta.get("original_message_id", "") + if orig_id: + msg["In-Reply-To"] = orig_id + msg["References"] = f"{meta.get('references', '')} {orig_id}".strip() + msg.attach(MIMEText(plain_content, "plain", "utf-8")) + msg.attach(MIMEText(html_content, "html", "utf-8")) + try: + if cfg.smtp_use_tls: + srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) + srv.starttls() + else: + srv = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=ssl.create_default_context(), timeout=30) + srv.login(cfg.smtp_username, cfg.smtp_password) + srv.sendmail(from_addr, [to], msg.as_string()) + srv.quit() + except Exception as e: + logger.error(f"SMTP HTML send failed: from={from_addr} to={to} error={e}") + raise RuntimeError(f"SMTP HTML: {e}") + + # ── Markdown to HTML formatting ─────────────────────────────── + + + # ── Media send (email attachment) ───────────────────────────── + + async def _send_media_impl( + self, + recipient: str, + file_path: str, + caption: str = "", + metadata: dict | None = None, + ) -> bool: + """Send a file as an email attachment via SMTP.""" + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, self._smtp_send_attachment, recipient, file_path, caption, metadata or {}, + ) + return True + + def _smtp_send_attachment(self, to: str, file_path: str, caption: str, meta: dict) -> None: + """Send an email with a file attachment.""" + cfg = self.config + from_addr = cfg.from_address or cfg.smtp_username + logger.debug(f"SMTP attachment send: from={from_addr} to={to} file={file_path}") + msg = MIMEMultipart() + orig_subj = meta.get("subject", "") + msg["Subject"] = f"{cfg.subject_prefix}{orig_subj}" if orig_subj and not orig_subj.lower().startswith("re:") else (orig_subj or "EvoScientist Reply") + msg["From"] = from_addr + msg["To"] = to + orig_id = meta.get("original_message_id", "") + if orig_id: + msg["In-Reply-To"] = orig_id + msg["References"] = f"{meta.get('references', '')} {orig_id}".strip() + + # Text body + if caption: + msg.attach(MIMEText(caption, "plain", "utf-8")) + + # Attachment + path = Path(file_path) + part = MIMEBase("application", "octet-stream") + part.set_payload(path.read_bytes()) + encoders.encode_base64(part) + part.add_header("Content-Disposition", f"attachment; filename={path.name}") + msg.attach(part) + + try: + if cfg.smtp_use_tls: + srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) + srv.starttls() + else: + srv = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=ssl.create_default_context(), timeout=30) + srv.login(cfg.smtp_username, cfg.smtp_password) + srv.sendmail(from_addr, [to], msg.as_string()) + srv.quit() + except Exception as e: + logger.error(f"SMTP attachment send failed: from={from_addr} to={to} error={e}") + raise RuntimeError(f"SMTP attachment: {e}") + + async def _cleanup(self) -> None: + await self._stop_polling() + if self._imap: + try: + self._imap.close(); self._imap.logout() + except Exception: + pass + self._imap = None + logger.info("Email channel stopped") diff --git a/EvoScientist/channels/email/probe.py b/EvoScientist/channels/email/probe.py new file mode 100644 index 0000000..84c6c49 --- /dev/null +++ b/EvoScientist/channels/email/probe.py @@ -0,0 +1,67 @@ +"""Email credential validation.""" + +import imaplib +import smtplib +import ssl +import logging + +logger = logging.getLogger(__name__) + + +async def validate_email_imap( + host: str, port: int, username: str, password: str, + use_ssl: bool = True, +) -> tuple[bool, str]: + """Validate IMAP credentials.""" + if not host or not username or not password: + return False, "host, username, and password are required" + + import asyncio + loop = asyncio.get_event_loop() + + def _check(): + try: + if use_ssl: + ctx = ssl.create_default_context() + conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) + else: + conn = imaplib.IMAP4(host, port) + conn.login(username, password) + conn.logout() + return True, "IMAP credentials valid" + except imaplib.IMAP4.error as e: + return False, f"IMAP auth failed: {e}" + except Exception as e: + return False, f"IMAP error: {e}" + + return await loop.run_in_executor(None, _check) + + +async def validate_email_smtp( + host: str, port: int, username: str, password: str, + use_tls: bool = True, +) -> tuple[bool, str]: + """Validate SMTP credentials.""" + if not host or not username or not password: + return False, "host, username, and password are required" + + import asyncio + loop = asyncio.get_event_loop() + + def _check(): + try: + if use_tls: + server = smtplib.SMTP(host, port, timeout=10) + server.starttls() + else: + ctx = ssl.create_default_context() + server = smtplib.SMTP_SSL(host, port, context=ctx, timeout=10) + server.login(username, password) + server.quit() + return True, "SMTP credentials valid" + except smtplib.SMTPAuthenticationError as e: + return False, f"SMTP auth failed: {e}" + except Exception as e: + return False, f"SMTP error: {e}" + + return await loop.run_in_executor(None, _check) diff --git a/EvoScientist/channels/email/serve.py b/EvoScientist/channels/email/serve.py new file mode 100644 index 0000000..c014dca --- /dev/null +++ b/EvoScientist/channels/email/serve.py @@ -0,0 +1,124 @@ +"""Email channel server. + +Standalone script to run the Email channel with CLI options. + +Usage: + python -m EvoScientist.channels.email.serve --imap-host HOST --imap-username USER --imap-password PASS --smtp-host HOST --smtp-username USER --smtp-password PASS --from-address ADDR [OPTIONS] + +Examples: + # Basic usage + python -m EvoScientist.channels.email.serve --imap-host imap.gmail.com --imap-username bot@gmail.com --imap-password PASS --smtp-host smtp.gmail.com --smtp-username bot@gmail.com --smtp-password PASS --from-address bot@gmail.com + + # With allowed senders and custom poll interval + python -m EvoScientist.channels.email.serve --imap-host imap.gmail.com --imap-username bot@gmail.com --imap-password PASS --smtp-host smtp.gmail.com --smtp-username bot@gmail.com --smtp-password PASS --from-address bot@gmail.com --allow user@example.com --poll-interval 60 + + # With agent and thinking + python -m EvoScientist.channels.email.serve --imap-host imap.gmail.com --imap-username bot@gmail.com --imap-password PASS --smtp-host smtp.gmail.com --smtp-username bot@gmail.com --smtp-password PASS --from-address bot@gmail.com --agent --thinking +""" + +import argparse +import logging + +from .channel import EmailChannel, EmailConfig +from ..bus import MessageBus +from ..standalone import run_standalone + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger(__name__) + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Email channel server", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--imap-host", + required=True, + help="IMAP server hostname", + ) + parser.add_argument( + "--imap-username", + required=True, + help="IMAP username", + ) + parser.add_argument( + "--imap-password", + required=True, + help="IMAP password", + ) + parser.add_argument( + "--smtp-host", + required=True, + help="SMTP server hostname", + ) + parser.add_argument( + "--smtp-username", + required=True, + help="SMTP username", + ) + parser.add_argument( + "--smtp-password", + required=True, + help="SMTP password", + ) + parser.add_argument( + "--from-address", + required=True, + help="From email address for outgoing messages", + ) + parser.add_argument( + "--allow", + action="append", + dest="allowed_senders", + help="Allowed sender (email address). Can be used multiple times.", + ) + parser.add_argument( + "--poll-interval", + type=int, + default=30, + help="IMAP poll interval in seconds (default: 30)", + ) + parser.add_argument( + "--agent", + action="store_true", + help="Use EvoScientist agent as handler (default: echo)", + ) + parser.add_argument( + "--thinking", + action="store_true", + help="Send thinking content as intermediate messages (requires --agent)", + ) + return parser.parse_args() + + +def main(): + """Entry point.""" + args = parse_args() + + config = EmailConfig( + imap_host=args.imap_host, + imap_username=args.imap_username, + imap_password=args.imap_password, + smtp_host=args.smtp_host, + smtp_username=args.smtp_username, + smtp_password=args.smtp_password, + from_address=args.from_address, + allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, + poll_interval=args.poll_interval, + ) + + send_thinking = args.thinking and args.agent + bus = MessageBus() + channel = EmailChannel(config) + + run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) + + +if __name__ == "__main__": + main() diff --git a/EvoScientist/channels/feishu/__init__.py b/EvoScientist/channels/feishu/__init__.py new file mode 100644 index 0000000..4cc68e5 --- /dev/null +++ b/EvoScientist/channels/feishu/__init__.py @@ -0,0 +1,21 @@ +from .channel import FeishuChannel, FeishuConfig +from ..channel_manager import register_channel, _parse_csv + +__all__ = ["FeishuChannel", "FeishuConfig"] + + +def create_from_config(config) -> FeishuChannel: + allowed = _parse_csv(config.feishu_allowed_senders) + return FeishuChannel(FeishuConfig( + app_id=config.feishu_app_id, + app_secret=config.feishu_app_secret, + verification_token=config.feishu_verification_token, + encrypt_key=config.feishu_encrypt_key, + webhook_port=config.feishu_webhook_port, + allowed_senders=allowed, + feishu_domain=config.feishu_domain, + proxy=getattr(config, 'feishu_proxy', '') or None, + )) + + +register_channel("feishu", create_from_config) diff --git a/EvoScientist/channels/feishu/channel.py b/EvoScientist/channels/feishu/channel.py new file mode 100644 index 0000000..8ba399d --- /dev/null +++ b/EvoScientist/channels/feishu/channel.py @@ -0,0 +1,777 @@ +"""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`` +""" + +import asyncio +import json +import logging +import re +import time +from typing import Any +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from ..base import Channel, RawIncoming, ChannelError +from ..capabilities import FEISHU as FEISHU_CAPS +from ..mixins import WebhookMixin, TokenMixin +from ..config import BaseChannelConfig + +logger = logging.getLogger(__name__) + + +# ── Markdown → Feishu Post conversion ──────────────────────────── + + +def _parse_inline_text(text: str) -> list[dict]: + """Parse inline Markdown elements into Feishu post tag dicts. + + Handles: `code`, **bold**, ~~strikethrough~~, [link](url), _italic_. + """ + elements: list[dict] = [] + # Pattern order matters: code first (protect content), then bold, strikethrough, link, italic + pattern = re.compile( + r"`([^`]+)`" # inline code + r"|\*\*(.+?)\*\*" # bold + r"|~~(.+?)~~" # strikethrough + r"|\[([^\]]+)\]\(([^)]+)\)" # link + r"|_(.+?)_" # italic + ) + pos = 0 + for m in pattern.finditer(text): + # Plain text before this match + if m.start() > pos: + elements.append({"tag": "text", "text": text[pos:m.start()]}) + + if m.group(1) is not None: + # inline code → code_block would be block-level; use text with style + elements.append({ + "tag": "text", "text": m.group(1), + "style": ["code_block"], + }) + elif m.group(2) is not None: + elements.append({ + "tag": "text", "text": m.group(2), + "style": ["bold"], + }) + elif m.group(3) is not None: + elements.append({ + "tag": "text", "text": m.group(3), + "style": ["strikethrough"], + }) + elif m.group(4) is not None: + elements.append({ + "tag": "a", "text": m.group(4), "href": m.group(5), + }) + elif m.group(6) is not None: + elements.append({ + "tag": "text", "text": m.group(6), + "style": ["italic"], + }) + pos = m.end() + + # Remaining plain text + if pos < len(text): + elements.append({"tag": "text", "text": text[pos:]}) + return elements + + +def _parse_inline_elements(line: str) -> list[dict]: + """Parse a single Markdown line into a list of Feishu post elements. + + Handles headings (→ bold), blockquotes (→ italic with prefix), + list items (→ bullet prefix), and plain lines. + """ + # Heading: # Title → bold text + heading_match = re.match(r"^(#{1,6})\s+(.+)$", line) + if heading_match: + return [{"tag": "text", "text": heading_match.group(2), "style": ["bold"]}] + + # Blockquote: > text → italic with "▎" prefix + quote_match = re.match(r"^>\s*(.*)$", line) + if quote_match: + inner = quote_match.group(1) + elements = [{"tag": "text", "text": "▎", "style": ["italic"]}] + elements.extend(_parse_inline_text(inner)) + return elements + + # Unordered list: - item or * item → "• " prefix + list_match = re.match(r"^[\-\*]\s+(.+)$", line) + if list_match: + elements = [{"tag": "text", "text": "• "}] + elements.extend(_parse_inline_text(list_match.group(1))) + return elements + + # Ordered list: 1. item → keep number prefix + ol_match = re.match(r"^(\d+)\.\s+(.+)$", line) + if ol_match: + elements = [{"tag": "text", "text": f"{ol_match.group(1)}. "}] + elements.extend(_parse_inline_text(ol_match.group(2))) + return elements + + # Plain line + return _parse_inline_text(line) + + +def _markdown_to_feishu_post(text: str) -> dict | None: + """Convert Markdown text to Feishu post (rich text) JSON structure. + + Returns a dict like {"zh_cn": {"content": [[...]]}} suitable for + Feishu msg_type="post", or None if the text is empty. + """ + if not text or not text.strip(): + return None + + paragraphs: list[list[dict]] = [] + current_paragraph: list[dict] = [] + in_code_block = False + code_lines: list[str] = [] + code_lang = "" + + for line in text.split("\n"): + # Code block fences + if line.startswith("```"): + if not in_code_block: + # Flush any pending paragraph + if current_paragraph: + paragraphs.append(current_paragraph) + current_paragraph = [] + in_code_block = True + code_lang = line[3:].strip() + code_lines = [] + else: + # End of code block + code_text = "\n".join(code_lines) + paragraphs.append([{ + "tag": "code_block", + "language": code_lang or "plain", + "text": code_text, + }]) + in_code_block = False + code_lines = [] + code_lang = "" + continue + + if in_code_block: + code_lines.append(line) + continue + + # Empty line → new paragraph + if not line.strip(): + if current_paragraph: + paragraphs.append(current_paragraph) + current_paragraph = [] + continue + + # Non-empty line + elements = _parse_inline_elements(line) + if elements: + # Each visual line becomes its own paragraph in Feishu post + if current_paragraph: + paragraphs.append(current_paragraph) + current_paragraph = elements + + # Flush remaining + if in_code_block and code_lines: + code_text = "\n".join(code_lines) + paragraphs.append([{ + "tag": "code_block", + "language": code_lang or "plain", + "text": code_text, + }]) + elif current_paragraph: + paragraphs.append(current_paragraph) + + if not paragraphs: + return None + + return {"zh_cn": {"content": paragraphs}} + + +@dataclass +class FeishuConfig(BaseChannelConfig): + app_id: str = "" + app_secret: str = "" + verification_token: str = "" + encrypt_key: str = "" + webhook_port: int = 9000 + text_chunk_limit: int = 4096 + feishu_domain: str = "https://open.feishu.cn" + + +class FeishuChannel(Channel, WebhookMixin, TokenMixin): + capabilities = FEISHU_CAPS + """Feishu channel using Open API + event subscription webhook.""" + + name = "feishu" + _ready_attrs = ("_http_client", "_access_token") + _rate_limit_patterns = ("99991400", "rate limit", "频率限制") + _rate_limit_delay = 2.0 + + def __init__(self, config: FeishuConfig): + super().__init__(config) + self._mention_names: list[str] = [] # bot mention keys from events + + # ── WebhookMixin overrides ──────────────────────────────────── + + def _get_webhook_port(self) -> int: + return self.config.webhook_port + + def _webhook_routes(self) -> list[tuple[str, str, Any]]: + return [("POST", "/webhook/event", self._handle_event)] + + # ── TokenMixin overrides ────────────────────────────────────── + + async def _fetch_token(self) -> tuple[str, int]: + """Fetch Feishu tenant_access_token.""" + url = f"{self.config.feishu_domain}/open-apis/auth/v3/tenant_access_token/internal" + body = { + "app_id": self.config.app_id, + "app_secret": self.config.app_secret, + } + try: + resp = await self._http_client.post(url, json=body) + data = resp.json() + except Exception as e: + raise ChannelError(f"Failed to get Feishu access token: {e}") + + if data.get("code") != 0: + raise ChannelError( + f"Feishu auth error: {data.get('msg', 'unknown')}" + ) + return data["tenant_access_token"], data.get("expire", 7200) + + # ── Lifecycle ───────────────────────────────────────────────── + + async def start(self) -> None: + try: + from aiohttp import web # noqa: F401 + import httpx # noqa: F401 + except ImportError: + raise ChannelError( + "aiohttp or httpx not installed. " + "Install with: pip install aiohttp httpx" + ) + + if not self.config.app_id: + raise ChannelError("Feishu app_id is required") + if not self.config.app_secret: + raise ChannelError("Feishu app_secret is required") + + # Start webhook server (sets up self._http_client) + await self._start_webhook_server() + + # Verify credentials by fetching initial token + await self._refresh_token() + + self._running = True + logger.info( + f"Feishu channel started " + f"(webhook on port {self.config.webhook_port})" + ) + + async def _cleanup(self) -> None: + await self._stop_webhook_server() + self._access_token = None + logger.info("Feishu channel stopped") + + # ── Token helpers (adapt old API to mixin) ──────────────────── + + async def _ensure_token(self) -> str: + """Return a valid access token, refreshing if needed.""" + return await TokenMixin._ensure_token(self) + + # ── Send (template method overrides) ────────────────────────── + + async def _feishu_send(self, url: str, body: dict, headers: dict) -> bool: + """POST to Feishu API and return True if code==0.""" + try: + resp = await self._http_client.post(url, json=body, headers=headers) + return resp.json().get("code") == 0 + except Exception as e: + logger.warning(f"Feishu send error: {e}") + return False + + async def _send_chunk( + self, chat_id, formatted_text, raw_text, reply_to, metadata, + ): + token = await self._ensure_token() + headers = {"Authorization": f"Bearer {token}"} + post_content = _markdown_to_feishu_post(raw_text) + + # If reply_to is set, try the reply API first + if reply_to: + reply_url = ( + f"{self.config.feishu_domain}" + f"/open-apis/im/v1/messages/{reply_to}/reply" + ) + if post_content is not None: + body = {"msg_type": "post", "content": json.dumps(post_content)} + else: + body = {"msg_type": "text", "content": json.dumps({"text": formatted_text})} + if await self._feishu_send(reply_url, body, headers): + return + + # Normal send (non-reply or reply fallback) + url = ( + f"{self.config.feishu_domain}" + f"/open-apis/im/v1/messages?receive_id_type=chat_id" + ) + + # Try post format first + if post_content is not None: + body = { + "receive_id": chat_id, + "msg_type": "post", + "content": json.dumps(post_content), + } + if await self._feishu_send(url, body, headers): + return + + # Fallback: plain text + body = { + "receive_id": chat_id, + "msg_type": "text", + "content": json.dumps({"text": formatted_text}), + } + if not await self._feishu_send(url, body, headers): + raise RuntimeError("Feishu send failed") + + # ── Media helpers ────────────────────────────────────────────── + + _IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} + + async def _download_media( + self, message_id: str, file_key: str, msg_type: str, + ) -> str | None: + """Download an image or file attachment from Feishu. + + Returns the local file path on success, or None on failure. + """ + token = await self._ensure_token() + resource_type = "image" if msg_type == "image" else "file" + url = ( + f"{self.config.feishu_domain}" + f"/open-apis/im/v1/messages/{message_id}" + f"/resources/{file_key}?type={resource_type}" + ) + headers = {"Authorization": f"Bearer {token}"} + try: + resp = await self._http_client.get(url, headers=headers, timeout=30) + if resp.status_code != 200: + logger.warning( + f"Feishu media download failed: HTTP {resp.status_code}" + ) + return None + + # Check attachment size before writing to disk + cl = resp.headers.get("content-length") + if cl: + try: + too_large = self._check_attachment_size(int(cl), file_key) + if too_large: + logger.warning(too_large) + return None + except (ValueError, TypeError): + pass + from ..base import MAX_ATTACHMENT_BYTES + if len(resp.content) > MAX_ATTACHMENT_BYTES: + logger.warning( + f"Feishu media too large: {len(resp.content)} bytes" + ) + return None + + # Determine extension from Content-Type or default + content_type = resp.headers.get("content-type", "") + ext_map = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + } + ext = ext_map.get(content_type, ".bin") + local_path = self._media_path(f"feishu_{message_id}_{file_key}{ext}") + local_path.write_bytes(resp.content) + return str(local_path) + except Exception as e: + logger.warning(f"Failed to download Feishu media: {e}") + return None + + async def _upload_feishu_resource( + self, url: str, headers: dict, file_path: str, + field_name: str, extra_data: dict, + ) -> dict | None: + """Upload a file to Feishu API. Returns response data or None on failure.""" + with open(file_path, "rb") as f: + resp = await self._http_client.post( + url, headers=headers, data=extra_data, + files={field_name: (Path(file_path).name, f)}, + ) + data = resp.json() + if data.get("code") != 0: + logger.error(f"Feishu upload failed: {data.get('msg')}") + return None + return data["data"] + + async def _send_media_impl( + self, + recipient: str, + file_path: str, + caption: str = "", + metadata: dict | None = None, + ) -> bool: + """Send a media file through Feishu.""" + token = await self._ensure_token() + headers = {"Authorization": f"Bearer {token}"} + chat_id = self._resolve_media_chat_id(recipient, metadata) + + path = Path(file_path) + ext = path.suffix.lower() + is_image = ext in self._IMAGE_EXTENSIONS + + send_url = ( + f"{self.config.feishu_domain}" + f"/open-apis/im/v1/messages?receive_id_type=chat_id" + ) + + if is_image: + upload_url = f"{self.config.feishu_domain}/open-apis/im/v1/images" + data = await self._upload_feishu_resource( + upload_url, headers, file_path, "image", {"image_type": "message"}, + ) + if not data: + return False + body = { + "receive_id": chat_id, + "msg_type": "image", + "content": json.dumps({"image_key": data["image_key"]}), + } + else: + upload_url = f"{self.config.feishu_domain}/open-apis/im/v1/files" + data = await self._upload_feishu_resource( + upload_url, headers, file_path, "file", + {"file_type": "stream", "file_name": path.name}, + ) + if not data: + return False + body = { + "receive_id": chat_id, + "msg_type": "file", + "content": json.dumps({"file_key": data["file_key"]}), + } + + if not await self._feishu_send(send_url, body, headers): + return False + + # Send caption as a separate text message if provided + if caption: + cap_body = { + "receive_id": chat_id, + "msg_type": "text", + "content": json.dumps({"text": caption}), + } + await self._feishu_send(send_url, cap_body, headers) + + return True + + # ── ACK reaction ─────────────────────────────────────────────── + + async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: + """Send an acknowledgment reaction via Feishu Open API.""" + try: + token = await self._ensure_token() + url = f"{self.config.feishu_domain}/open-apis/im/v1/messages/{message_id}/reactions" + await self._http_client.post( + url, + json={"reaction_type": {"emoji_type": emoji}}, + headers={"Authorization": f"Bearer {token}"}, + ) + except Exception as e: + logger.debug(f"Feishu ack reaction failed: {e}") + + async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: + """Remove ACK reaction via Feishu Open API. + + Feishu's DELETE /reactions endpoint requires the reaction_id, which + we don't track. No-op for now. + """ + pass + + # ── Format chunk ───────────────────────────────────────────── + + + # ── Typing indicator ────────────────────────────────────────── + + # Feishu Open API has no typing indicator endpoint. + + # ── Mention stripping ───────────────────────────────────────── + + def _strip_mention(self, text: str) -> str: + """Strip bot @mention placeholders from Feishu text. + + In Feishu v2 events the text contains placeholders like ``@_user_1`` + for each mention. ``_mention_names`` caches the placeholder keys that + belong to the bot (identified during ``_on_message``). + """ + result = text + for key in self._mention_names: + result = result.replace(key, "") + # Clean up extra whitespace left behind + return re.sub(r" +", " ", result).strip() + + # ── Webhook event handler ───────────────────────────────────── + + async def _handle_event(self, request) -> "web.Response": + """Handle POST /webhook/event from Feishu.""" + from aiohttp import web + + try: + body = await request.json() + except Exception: + return web.Response(status=400) + + # ── URL verification challenge ── + if body.get("type") == "url_verification": + challenge = body.get("challenge", "") + return web.json_response({"challenge": challenge}) + + # ── v2 event schema ── + schema = body.get("schema") + if schema == "2.0": + header = body.get("header", {}) + + # Verify token if configured + if self.config.verification_token: + token = header.get("token", "") + if token != self.config.verification_token: + logger.warning("Feishu event token mismatch") + return web.Response(status=403) + + event_type = header.get("event_type", "") + if event_type == "im.message.receive_v1": + await self._on_message(body.get("event", {})) + + # ── v1 event schema (legacy) ── + elif "event" in body: + if self.config.verification_token: + token = body.get("token", "") + if token != self.config.verification_token: + logger.warning("Feishu event token mismatch (v1)") + return web.Response(status=403) + + event = body["event"] + msg_type = event.get("type", "") + if msg_type == "message": + await self._on_message_v1(event) + + return web.Response(status=200) + + async def _on_message(self, event: dict) -> None: + """Handle im.message.receive_v1 event (v2 schema).""" + sender_info = event.get("sender", {}) + sender_id_info = sender_info.get("sender_id", {}) + sender_id = ( + sender_id_info.get("open_id") + or sender_id_info.get("user_id") + or "" + ) + sender_type = sender_info.get("sender_type", "") + + # Skip bot's own messages + if sender_type == "app": + return + + message = event.get("message", {}) + chat_id = message.get("chat_id", "") + msg_type = message.get("message_type", "") + message_id = message.get("message_id", "") + + # In group chats, detect mention status for centralized gating + chat_type = message.get("chat_type", "") + is_group = chat_type == "group" + was_mentioned = True + if is_group: + mentions = message.get("mentions", []) + was_mentioned = bool(mentions) + # Cache bot mention keys — bot mentions have empty user IDs + bot_keys = [] + for m in mentions: + m_id = m.get("id", {}) + # Bot/app mentions have no open_id / user_id + if not m_id.get("open_id") and not m_id.get("user_id"): + key = m.get("key", "") + if key: + bot_keys.append(key) + if bot_keys: + self._mention_names = bot_keys + + # Parse content JSON + content_str = message.get("content", "{}") + try: + content_data = json.loads(content_str) + except json.JSONDecodeError: + content_data = {} + + text = "" + annotations: list[str] = [] + media_paths: list[str] = [] + + if msg_type == "text": + text = content_data.get("text", "") + elif msg_type == "post": + text = self._extract_post_text(content_data) + elif msg_type == "image" and self.config.include_attachments: + image_key = content_data.get("image_key", "") + if image_key: + local = await self._download_media(message_id, image_key, "image") + if local: + media_paths.append(local) + annotations.append(f"[attachment: {local}]") + else: + annotations.append("[image message - download failed]") + else: + annotations.append("[image message]") + elif msg_type == "file" and self.config.include_attachments: + file_key = content_data.get("file_key", "") + file_name = content_data.get("file_name", "unknown") + if file_key: + local = await self._download_media(message_id, file_key, "file") + if local: + media_paths.append(local) + annotations.append(f"[attachment: {local}]") + else: + annotations.append(f"[file: {file_name} - download failed]") + else: + annotations.append(f"[file message: {file_name}]") + elif msg_type in ("audio", "media") and self.config.include_attachments: + # Feishu audio messages are voice recordings + media_label = "voice" if msg_type == "audio" else msg_type + file_key = content_data.get("file_key", "") + if file_key: + local = await self._download_media(message_id, file_key, "file") + if local: + media_paths.append(local) + annotations.append(f"[{media_label}: {local}]") + else: + annotations.append(f"[{media_label} message - download failed]") + else: + annotations.append(f"[{media_label} message]") + elif msg_type == "sticker": + sticker_key = content_data.get("file_key", "") + if sticker_key and self.config.include_attachments: + local = await self._download_media(message_id, sticker_key, "image") + if local: + media_paths.append(local) + annotations.append(f"[sticker: {local}]") + else: + annotations.append("[sticker message]") + else: + annotations.append("[sticker message]") + else: + text = f"[{msg_type} message]" + + if not text and not media_paths and not annotations: + return + + # Parse timestamp (milliseconds) + create_time = message.get("create_time", "") + try: + timestamp = datetime.fromtimestamp( + int(create_time) / 1000 + ) if create_time else datetime.now() + except (ValueError, TypeError, OSError): + timestamp = datetime.now() + + await self._enqueue_raw(RawIncoming( + sender_id=sender_id, + chat_id=chat_id, + text=text, + media_files=media_paths, + content_annotations=annotations, + timestamp=timestamp, + message_id=message_id, + metadata={ + "chat_id": chat_id, + "chat_type": message.get("chat_type", ""), + }, + is_group=is_group, + was_mentioned=was_mentioned, + )) + + async def _on_message_v1(self, event: dict) -> None: + """Handle v1 schema message event (legacy).""" + sender_id = event.get("open_id", "") + if not sender_id: + return + + # Detect group and mention status for centralized gating + chat_type = event.get("chat_type", "") + is_group = chat_type == "group" + was_mentioned = True + if is_group: + text_without_at = event.get("text_without_at_bot", "") + was_mentioned = bool(text_without_at) + + text = event.get("text_without_at_bot", "") or event.get("text", "") + if not text: + return + + chat_id = event.get("open_chat_id", "") + message_id = event.get("open_message_id", "") + + await self._enqueue_raw(RawIncoming( + sender_id=sender_id, + chat_id=chat_id, + text=text, + timestamp=datetime.now(), + message_id=message_id, + metadata={ + "chat_id": chat_id, + "chat_type": event.get("chat_type", ""), + }, + is_group=is_group, + was_mentioned=was_mentioned, + )) + + @staticmethod + def _extract_post_text(content: dict) -> str: + """Extract plain text from Feishu post (rich text) content.""" + parts: list[str] = [] + # Post content has locale keys like "zh_cn", "en_us" + for locale_key in ("zh_cn", "en_us", "ja_jp"): + locale_content = content.get(locale_key) + if locale_content: + title = locale_content.get("title", "") + if title: + parts.append(title) + for paragraph in locale_content.get("content", []): + line_parts: list[str] = [] + for element in paragraph: + tag = element.get("tag", "") + if tag == "text": + line_parts.append(element.get("text", "")) + elif tag == "a": + line_parts.append(element.get("text", "")) + elif tag == "at": + # Skip @mentions of the bot + pass + line = "".join(line_parts).strip() + if line: + parts.append(line) + break # Use first available locale + return "\n".join(parts) diff --git a/EvoScientist/channels/feishu/probe.py b/EvoScientist/channels/feishu/probe.py new file mode 100644 index 0000000..46d962e --- /dev/null +++ b/EvoScientist/channels/feishu/probe.py @@ -0,0 +1,39 @@ +"""Feishu (飞书/Lark) app credential validation.""" + +import logging + +logger = logging.getLogger(__name__) + + +async def validate_feishu_credentials( + app_id: str, + app_secret: str, + domain: str = "https://open.feishu.cn", +) -> tuple[bool, str]: + """Validate Feishu app credentials by requesting a tenant_access_token. + + Returns: + Tuple of (is_valid, message). + """ + if not app_id: + return False, "No app_id provided" + if not app_secret: + return False, "No app_secret provided" + + try: + import httpx + except ImportError: + return False, "httpx not installed" + + url = f"{domain}/open-apis/auth/v3/tenant_access_token/internal" + body = {"app_id": app_id, "app_secret": app_secret} + try: + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=body, timeout=10) + data = resp.json() + if data.get("code") == 0: + return True, f"App: {app_id}" + msg = data.get("msg", "unknown error") + return False, f"Auth failed: {msg}" + except Exception as e: + return False, f"Error: {e}" diff --git a/EvoScientist/channels/feishu/serve.py b/EvoScientist/channels/feishu/serve.py new file mode 100644 index 0000000..c9d0709 --- /dev/null +++ b/EvoScientist/channels/feishu/serve.py @@ -0,0 +1,113 @@ +"""Feishu (飞书/Lark) channel server. + +Standalone script to run the Feishu channel with CLI options. + +Usage: + python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET [OPTIONS] + +Examples: + # Basic setup + python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET + + # With verification token and custom port + python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET \\ + --verification-token TOKEN --webhook-port 9000 + + # With agent and thinking + python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET --agent --thinking +""" + +import argparse +import logging + +from .channel import FeishuChannel, FeishuConfig +from ..bus import MessageBus +from ..standalone import run_standalone + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger(__name__) + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Feishu (飞书/Lark) channel server", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--app-id", + required=True, + help="Feishu App ID", + ) + parser.add_argument( + "--app-secret", + required=True, + help="Feishu App Secret", + ) + parser.add_argument( + "--verification-token", + default="", + help="Feishu event verification token", + ) + parser.add_argument( + "--encrypt-key", + default="", + help="Feishu event encrypt key", + ) + parser.add_argument( + "--webhook-port", + type=int, + default=9000, + help="Port for webhook HTTP server (default: 9000)", + ) + parser.add_argument( + "--domain", + default="https://open.feishu.cn", + help="Feishu API domain (use https://open.larksuite.com for Lark)", + ) + parser.add_argument( + "--allow", + action="append", + dest="allowed_senders", + help="Allowed sender (Feishu open_id). Can be used multiple times.", + ) + parser.add_argument( + "--agent", + action="store_true", + help="Use EvoScientist agent as handler (default: echo)", + ) + parser.add_argument( + "--thinking", + action="store_true", + help="Send thinking content as intermediate messages (requires --agent)", + ) + return parser.parse_args() + + +def main(): + """Entry point.""" + args = parse_args() + + config = FeishuConfig( + app_id=args.app_id, + app_secret=args.app_secret, + verification_token=args.verification_token, + encrypt_key=args.encrypt_key, + webhook_port=args.webhook_port, + feishu_domain=args.domain, + allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, + ) + + send_thinking = args.thinking and args.agent + bus = MessageBus() + channel = FeishuChannel(config) + + run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) + + +if __name__ == "__main__": + main() diff --git a/EvoScientist/channels/formatter.py b/EvoScientist/channels/formatter.py new file mode 100644 index 0000000..c37e854 --- /dev/null +++ b/EvoScientist/channels/formatter.py @@ -0,0 +1,287 @@ +"""Unified formatting pipeline for all channels. + +Internal representation is Markdown. This module converts Markdown to +each platform's native format: HTML, Slack mrkdwn, Discord Markdown, +or plain text. + +Channels no longer need per-file format functions — they just declare +``capabilities.format_type`` and the base class auto-configures a +``UnifiedFormatter`` instance. +""" + +from __future__ import annotations + +import re +from typing import Callable + + +# ═════════════════════════════════════════════════════════════════════ +# Markdown conversion engine (formerly markdown_utils.py) +# ═════════════════════════════════════════════════════════════════════ + +_PLACEHOLDER_PREFIX = "\x00BLOCK" +_INLINE_PREFIX = "\x00INLINE" + +# A formatting rule: (regex_pattern, replacement) +InlineRule = tuple[str, str] + + +def convert_markdown( + text: str, + *, + code_block_formatter: Callable[[str, str], str], + inline_code_formatter: Callable[[str], str], + inline_rules: list[InlineRule], + escape_fn: Callable[[str], str] | None = None, +) -> str: + """Convert Markdown to a channel-specific format. + + Parameters + ---------- + text: + Input Markdown text. + code_block_formatter: + ``(language, code) -> str`` — format a fenced code block. + inline_code_formatter: + ``(code) -> str`` — format an inline code span. + inline_rules: + List of ``(pattern, replacement)`` pairs applied in order to the + remaining text (after code extraction and optional escaping). + escape_fn: + Optional function applied to the non-code text *before* inline + rules. Useful for HTML-escaping (Telegram) or other channel- + specific character escaping. + + Returns + ------- + str + The converted text. + """ + # 1. Extract and protect fenced code blocks (```...```) + code_blocks: list[str] = [] + + def _save_code_block(m: re.Match) -> str: + lang = m.group(1) or "" + code = m.group(2) + formatted = code_block_formatter(lang, code) + idx = len(code_blocks) + code_blocks.append(formatted) + return f"{_PLACEHOLDER_PREFIX}{idx}\x00" + + text = re.sub(r"```(\w*)\n?(.*?)```", _save_code_block, text, flags=re.DOTALL) + + # 2. Extract and protect inline code (`...`) + inline_codes: list[str] = [] + + def _save_inline(m: re.Match) -> str: + code = m.group(1) + formatted = inline_code_formatter(code) + idx = len(inline_codes) + inline_codes.append(formatted) + return f"{_INLINE_PREFIX}{idx}\x00" + + text = re.sub(r"`([^`]+)`", _save_inline, text) + + # 3. Optional escaping of remaining text + if escape_fn is not None: + text = escape_fn(text) + + # 4. Apply inline formatting rules + for pattern, replacement in inline_rules: + text = re.sub(pattern, replacement, text, flags=re.MULTILINE) + + # 5. Restore code blocks and inline code + for idx, html in enumerate(code_blocks): + text = text.replace(f"{_PLACEHOLDER_PREFIX}{idx}\x00", html) + for idx, code in enumerate(inline_codes): + text = text.replace(f"{_INLINE_PREFIX}{idx}\x00", code) + + return text + +# ═════════════════════════════════════════════════════════════════════ +# Shared helpers +# ═════════════════════════════════════════════════════════════════════ + +def _escape_html(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def _noop_escape(text: str) -> str: + return text + + +# ═════════════════════════════════════════════════════════════════════ +# HTML profile (Telegram, Email, Teams) +# ═════════════════════════════════════════════════════════════════════ + +def _html_code_block(lang: str, code: str) -> str: + escaped = _escape_html(code) + if lang: + return f'{escaped}'
+ return f"{escaped}"
+
+
+def _html_inline_code(code: str) -> str:
+ return f"{_escape_html(code)}"
+
+
+_HTML_INLINE_RULES: list[InlineRule] = [
+ # Headings → bold
+ (r"^#{1,6}\s+(.+)$", r"\1"),
+ # Blockquote markers (already escaped to >)
+ (r"^>\s?", ""),
+ # Links [text](url) →
+ (r"\[([^\]]+)\]\(([^)]+)\)", r'\1'),
+ # Bold **text** →
+ (r"\*\*(.+?)\*\*", r"\1"),
+ # Italic _text_ →
+ (r"(?\1"),
+ # Strikethrough ~~text~~ → {code}",
+ inline_code_formatter=lambda code: f"{code}",
+ inline_rules=[
+ (r"\*\*(.+?)\*\*", r"\1"),
+ (r"\*(.+?)\*", r"\1"),
+ ],
+ escape_fn=lambda t: t.replace("&", "&").replace("<", "<").replace(">", ">"),
+ )
+
+ def test_basic_bold_italic(self):
+ result = self._html_converter("**bold** and *italic*")
+ assert "bold" in result
+ assert "italic" in result
+
+ def test_code_block_protection(self):
+ """Code inside blocks should NOT have inline rules applied."""
+ text = "```\n**not bold**\n```"
+ result = self._html_converter(text)
+ assert "" not in result
+ assert "**not bold**" in result
+
+ def test_inline_code_protection(self):
+ text = "Use `**literal**` please"
+ result = self._html_converter(text)
+ assert "" in result
+ # The **literal** inside backticks should be literal
+ assert "**literal**" in result
+
+ def test_escape_fn_does_not_corrupt_placeholders(self):
+ """[B-28] escape_fn must not corrupt NUL-byte placeholders."""
+ text = "```\ncode\n```\nNormal "
+
+ def bad_escape(t):
+ # Strips NUL bytes — would break placeholders
+ return t.replace("\x00", "")
+
+ result = convert_markdown(
+ text,
+ code_block_formatter=lambda l, 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 l, 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 l, 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._build_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):
+ cfg = _FakeConfig(allowed_senders=["alice"])
+ ch = StubChannel(cfg)
+ raw = RawIncoming(sender_id="eve", chat_id="c1", text="hack")
+ assert ch._build_inbound(raw) is None
+
+ def test_drops_disallowed_channel(self):
+ cfg = _FakeConfig(allowed_channels=["c1"])
+ ch = StubChannel(cfg)
+ raw = RawIncoming(sender_id="u1", chat_id="c2", text="hello")
+ assert ch._build_inbound(raw) is None
+
+ def test_drops_empty_content_no_media(self):
+ ch = StubChannel()
+ raw = RawIncoming(sender_id="u1", chat_id="c1", text="")
+ assert ch._build_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._build_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._build_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._build_inbound(raw)
+ assert msg.metadata["chat_id"] == "c1"
+ assert msg.metadata["extra"] == "data"
+
+
+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):
+ async def _test():
+ bus = MessageBus()
+ ch = StubChannel()
+ ch.set_bus(bus)
+ ch.initial_debounce = 0.05
+
+ 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 ch.queue_message(msg) # duplicate
+ await asyncio.sleep(0.2)
+
+ # Only one should be processed (dedup catches second)
+ assert bus.inbound.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, all senders share the same session."""
+ 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")
+ # BUG: Both get the same thread_id
+ assert tid1 == tid2 == "shared_thread"
+
+ 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
+ task = asyncio.create_task(consumer.run())
+ await asyncio.sleep(0.1)
+ await consumer.stop()
+ await asyncio.sleep(0.1)
+ assert consumer._stopping is True
+ _run(_test())
+
+
+class TestInboundConsumerErrorHandling:
+
+ def test_error_message_leaks_info(self):
+ """[B-22] Exception messages are sent directly to users."""
+ # This test documents that internal error details are exposed
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ mgr.register(ch)
+
+ consumer = InboundConsumer(
+ bus=bus, manager=mgr, agent=MagicMock(),
+ thread_id="",
+ )
+
+ # The error message format includes the raw exception
+ # This should be sanitized in production
+ error_msg = f"Error: {RuntimeError('secret internal path /etc/passwd')}"
+ assert "/etc/passwd" in error_msg # Documents the leak
+ _run(_test())
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 8. MessageBus
+# ═══════════════════════════════════════════════════════════════════
+
+class TestMessageBus:
+
+ def test_publish_consume_inbound(self):
+ async def _test():
+ bus = MessageBus()
+ msg = BusInbound(channel="tg", sender_id="u1",
+ chat_id="c1", content="hello")
+ await bus.publish_inbound(msg)
+ assert bus.inbound_size == 1
+ received = await bus.consume_inbound()
+ assert received.content == "hello"
+ assert bus.inbound_size == 0
+ _run(_test())
+
+ def test_publish_consume_outbound(self):
+ async def _test():
+ bus = MessageBus()
+ msg = BusOutbound(channel="tg", chat_id="c1", content="reply")
+ await bus.publish_outbound(msg)
+ assert bus.outbound_size == 1
+ received = await bus.consume_outbound()
+ assert received.content == "reply"
+ _run(_test())
+
+ def test_subscriber_dispatch(self):
+ async def _test():
+ bus = MessageBus()
+ received = []
+ bus.subscribe_outbound("tg", lambda m: received.append(m))
+
+ task = asyncio.create_task(bus.dispatch_outbound())
+ await bus.publish_outbound(BusOutbound(
+ channel="tg", chat_id="c1", content="hello",
+ ))
+ await asyncio.sleep(0.1)
+ bus.stop()
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+ assert len(received) == 1
+ _run(_test())
+
+ def test_no_subscriber_logs_warning(self):
+ """Messages to unsubscribed channels should warn, not crash."""
+ async def _test():
+ bus = MessageBus()
+ task = asyncio.create_task(bus.dispatch_outbound())
+ await bus.publish_outbound(BusOutbound(
+ channel="unknown", chat_id="c1", content="lost",
+ ))
+ await asyncio.sleep(0.1)
+ bus.stop()
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ _run(_test())
+
+ def test_queue_sizes(self):
+ async def _test():
+ bus = MessageBus()
+ assert bus.inbound_size == 0
+ assert bus.outbound_size == 0
+ await bus.publish_inbound(BusInbound(
+ channel="x", sender_id="u", chat_id="c", content="a",
+ ))
+ assert bus.inbound_size == 1
+ _run(_test())
+
+ def test_subscriber_error_does_not_crash_dispatch(self):
+ async def _test():
+ bus = MessageBus()
+
+ async def bad_callback(msg):
+ raise RuntimeError("subscriber crash")
+
+ bus.subscribe_outbound("tg", bad_callback)
+
+ task = asyncio.create_task(bus.dispatch_outbound())
+ await bus.publish_outbound(BusOutbound(
+ channel="tg", chat_id="c1", content="trigger",
+ ))
+ await asyncio.sleep(0.1)
+ bus.stop()
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+ # dispatch should survive the error
+ _run(_test())
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 9. Event dataclasses
+# ═══════════════════════════════════════════════════════════════════
+
+class TestEvents:
+
+ def test_inbound_defaults(self):
+ msg = BusInbound(channel="tg", sender_id="u1",
+ chat_id="c1", content="hi")
+ assert msg.media == []
+ assert msg.metadata == {}
+ assert msg.session_key == "tg:c1"
+ assert isinstance(msg.timestamp, datetime)
+
+ def test_outbound_defaults(self):
+ msg = BusOutbound(channel="tg", chat_id="c1", content="reply")
+ assert msg.reply_to is None
+ assert msg.media == []
+ assert msg.metadata == {}
+
+ def test_inbound_sender_alias(self):
+ msg = InboundMessage(channel="x", sender_id="u1",
+ chat_id="c1", content="hi")
+ assert msg.sender == "u1"
+
+ def test_outbound_recipient_alias(self):
+ msg = OutboundMessage(channel="x", chat_id="c1", content="hi")
+ assert msg.recipient == "c1"
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 10. Integration scenarios
+# ═══════════════════════════════════════════════════════════════════
+
+class TestIntegration:
+
+ def test_full_inbound_pipeline(self):
+ """Raw message → build_inbound → queue_message → bus."""
+ async def _test():
+ bus = MessageBus()
+ ch = StubChannel()
+ ch.set_bus(bus)
+ ch.initial_debounce = 0.05
+
+ raw = RawIncoming(
+ sender_id="user1", chat_id="chat1",
+ text="integration test", message_id="int_001",
+ )
+ await ch._enqueue_raw(raw)
+
+ # _enqueue_raw puts on internal queue, not bus
+ assert ch._queue.qsize() == 1
+ inbound = await ch._queue.get()
+ assert inbound.content == "integration test"
+
+ # Now simulate the bus path via queue_message
+ await ch.queue_message(inbound)
+ await asyncio.sleep(0.2)
+ assert bus.inbound_size == 1
+ _run(_test())
+
+ def test_outbound_dispatch_with_media(self):
+ """Dispatch routes media alongside text content."""
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = StubChannel()
+ media_sent = []
+ ch.send_media = AsyncMock(
+ side_effect=lambda **kw: media_sent.append(kw) or True,
+ )
+ ch.send = AsyncMock(return_value=True)
+ mgr.register(ch)
+
+ task = asyncio.create_task(mgr._dispatch_outbound())
+ await bus.publish_outbound(OutboundMessage(
+ channel="stub", chat_id="c1", content="see attached",
+ media=["/path/doc.pdf"],
+ ))
+ await asyncio.sleep(0.1)
+ task.cancel()
+ try:
+ await task
+ except asyncio.CancelledError:
+ pass
+
+ assert len(media_sent) == 1
+ _run(_test())
+
+ def test_debounce_lost_on_stop(self):
+ """[B-06] Buffered messages are lost when channel stops during debounce."""
+ async def _test():
+ bus = MessageBus()
+ ch = StubChannel()
+ ch.set_bus(bus)
+ ch.initial_debounce = 5.0 # Long debounce
+
+ msg = InboundMessage(
+ channel="stub", sender_id="u1", chat_id="c1",
+ content="will be lost", message_id="m1",
+ metadata={"chat_id": "c1"},
+ )
+ await ch.queue_message(msg)
+ # Message is buffered but debounce hasn't fired yet
+
+ assert len(ch._message_buffers) == 1
+
+ # Stop the channel — debounce tasks are cancelled
+ ch._running = True
+ await ch.stop()
+
+ # BUG: The buffered message was never published
+ assert bus.inbound_size == 0 # Documents data loss
+ _run(_test())
+
+ def test_send_locks_unbounded_growth(self):
+ """[B-03] _send_locks grows without bound for unique chat_ids."""
+ async def _test():
+ ch = StubChannel()
+ for i in range(100):
+ msg = OutboundMessage(
+ channel="stub", chat_id=f"chat_{i}",
+ content="hi", metadata={"chat_id": f"chat_{i}"},
+ )
+ await ch.send(msg)
+
+ # All 100 unique chat_ids created a lock
+ assert len(ch._send_locks) == 100
+ # BUG: These are never cleaned up
+ _run(_test())
+
+
+# ═══════════════════════════════════════════════════════════════════
+# 11. Edge cases and boundary conditions
+# ═══════════════════════════════════════════════════════════════════
+
+class TestEdgeCases:
+
+ def test_chunk_text_single_char_limit(self):
+ chunks = chunk_text("abc", 1)
+ assert all(len(c) <= 1 for c in chunks)
+ assert len(chunks) == 3
+
+ def test_chunk_text_unicode(self):
+ text = "你好世界" * 100
+ chunks = chunk_text(text, 50)
+ assert all(len(c) <= 50 for c in chunks)
+
+ def test_dedup_cache_rapid_same_id(self):
+ dc = DedupCache()
+ assert dc.is_duplicate("x") is False
+ for _ in range(100):
+ assert dc.is_duplicate("x") is True
+
+ def test_channel_send_empty_content(self):
+ async def _test():
+ ch = StubChannel()
+ msg = OutboundMessage(channel="stub", chat_id="c1", content="")
+ ok = await ch.send(msg)
+ # Empty content goes through chunk_text which returns []
+ assert ok is True
+ assert len(ch._sent_chunks) == 0
+ _run(_test())
+
+ def test_raw_incoming_defaults(self):
+ raw = RawIncoming(sender_id="u1", chat_id="c1")
+ assert raw.text == ""
+ assert raw.media_files == []
+ assert raw.content_annotations == []
+ assert raw.is_group is False
+ assert raw.was_mentioned is True
+ assert raw.message_id == ""
+
+ def test_outbound_message_no_metadata_chat_id_resolution(self):
+ """resolve_chat_id falls back to recipient when metadata has no chat_id."""
+ ch = StubChannel()
+ msg = OutboundMessage(
+ channel="stub", chat_id="fallback_id", content="hi",
+ metadata={},
+ )
+ resolved = ch._resolve_chat_id(msg)
+ assert resolved == "fallback_id"
+
+ def test_health_server_response_structure(self):
+ """HealthServer builds response with expected keys."""
+ from EvoScientist.channels.channel_manager import _HealthServer
+
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(StubChannel())
+
+ hs = _HealthServer(mgr, 0)
+ resp = hs._build_response()
+ assert resp["status"] == "healthy"
+ assert "uptime_seconds" in resp
+ assert "channels" in resp
+ assert "queues" in resp
+ assert "health" in resp
diff --git a/tests/test_channel_manager.py b/tests/test_channel_manager.py
new file mode 100644
index 0000000..bb45c93
--- /dev/null
+++ b/tests/test_channel_manager.py
@@ -0,0 +1,167 @@
+"""Tests for ChannelManager."""
+
+import asyncio
+
+import pytest
+
+from EvoScientist.channels.bus.message_bus import MessageBus
+from EvoScientist.channels.bus.events import InboundMessage, OutboundMessage
+from EvoScientist.channels.channel_manager import ChannelManager
+from EvoScientist.channels.base import Channel, InboundMessage, OutboundMessage
+
+
+def _run(coro):
+ """Run an async coroutine safely, creating a fresh event loop."""
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+class _FakeConfig:
+ text_chunk_limit = 4096
+ allowed_senders = None
+
+
+class FakeChannel(Channel):
+ """Minimal channel for testing."""
+
+ name = "fake"
+
+ def __init__(self):
+ super().__init__(_FakeConfig())
+ self._started = False
+ self._stopped = False
+ self._sent: list[OutboundMessage] = []
+
+ async def start(self):
+ self._started = True
+
+ async def stop(self):
+ self._stopped = True
+
+ async def receive(self):
+ while True:
+ try:
+ msg = await asyncio.wait_for(
+ self._queue.get(), timeout=0.5,
+ )
+ yield msg
+ except asyncio.TimeoutError:
+ return
+
+ async def send(self, message: OutboundMessage) -> bool:
+ self._sent.append(message)
+ return True
+
+ async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata):
+ pass
+
+
+class TestChannelManagerRegister:
+ def test_register_channel(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = FakeChannel()
+ result = mgr.register(ch)
+ assert "fake" in mgr.enabled_channels
+ assert mgr.get_channel("fake") is ch
+ assert result is ch
+
+ def test_duplicate_register_raises(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+ with pytest.raises(ValueError, match="already registered"):
+ mgr.register(FakeChannel())
+
+ def test_get_status(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+ status = mgr.get_status()
+ assert "fake" in status
+ assert status["fake"]["registered"] is True
+
+
+class TestChannelManagerDispatch:
+ def test_outbound_dispatch_routes_to_channel(self):
+ async def _test():
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ ch = FakeChannel()
+ mgr.register(ch)
+
+ # Start only the dispatcher (not full start_all)
+ dispatch = asyncio.create_task(
+ mgr._dispatch_outbound()
+ )
+
+ # Publish an outbound message
+ await bus.publish_outbound(OutboundMessage(
+ channel="fake", chat_id="u1",
+ content="hello from agent",
+ ))
+
+ await asyncio.sleep(0.1)
+ dispatch.cancel()
+ try:
+ await dispatch
+ except asyncio.CancelledError:
+ pass
+
+ assert len(ch._sent) == 1
+ assert ch._sent[0].content == "hello from agent"
+ assert ch._sent[0].chat_id == "u1"
+
+ _run(_test())
+
+
+class TestChannelManagerTracking:
+ def test_record_message(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+
+ mgr.record_message("fake", "received")
+ mgr.record_message("fake", "received")
+ mgr.record_message("fake", "sent")
+
+ assert mgr._message_counts["fake"]["received"] == 2
+ assert mgr._message_counts["fake"]["sent"] == 1
+
+ def test_record_message_unknown_channel(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+
+ # Should not raise, auto-creates entry
+ mgr.record_message("unknown", "received")
+ assert mgr._message_counts["unknown"]["received"] == 1
+
+ def test_get_detailed_status(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+
+ # Simulate start_all setting start_times
+ from datetime import datetime
+ mgr._start_times["fake"] = datetime.now()
+ mgr._message_counts["fake"] = {"received": 5, "sent": 3}
+
+ status = mgr.get_detailed_status()
+ assert "fake" in status
+ assert status["fake"]["registered"] is True
+ assert status["fake"]["received"] == 5
+ assert status["fake"]["sent"] == 3
+ assert status["fake"]["uptime_seconds"] >= 0
+ assert status["fake"]["start_time"] is not None
+
+ def test_get_detailed_status_no_start_time(self):
+ bus = MessageBus()
+ mgr = ChannelManager(bus)
+ mgr.register(FakeChannel())
+
+ status = mgr.get_detailed_status()
+ assert status["fake"]["uptime_seconds"] == 0
+ assert status["fake"]["start_time"] is None
diff --git a/tests/test_discord_channel.py b/tests/test_discord_channel.py
new file mode 100644
index 0000000..6d5152d
--- /dev/null
+++ b/tests/test_discord_channel.py
@@ -0,0 +1,71 @@
+"""Tests for Discord channel implementation."""
+
+import asyncio
+
+import pytest
+
+from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig
+from EvoScientist.channels.base import ChannelError
+
+
+def _run(coro):
+ """Run an async coroutine safely, creating a fresh event loop."""
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+class TestDiscordConfig:
+ def test_default_values(self):
+ config = DiscordConfig()
+ assert config.bot_token == ""
+ assert config.allowed_senders is None
+ assert config.allowed_channels is None
+ assert config.text_chunk_limit == 4096
+
+ def test_custom_values(self):
+ config = DiscordConfig(
+ bot_token="test-token",
+ allowed_senders={"111"},
+ allowed_channels={"222"},
+ text_chunk_limit=1000,
+ )
+ assert config.bot_token == "test-token"
+ assert config.allowed_senders == {"111"}
+ assert config.allowed_channels == {"222"}
+ assert config.text_chunk_limit == 1000
+
+
+class TestDiscordChannel:
+ def test_init(self):
+ config = DiscordConfig(bot_token="test")
+ channel = DiscordChannel(config)
+ assert channel.config is config
+ assert channel._running is False
+
+ def test_start_raises_without_token_or_library(self):
+ config = DiscordConfig(bot_token="")
+ channel = DiscordChannel(config)
+ with pytest.raises(ChannelError):
+ _run(channel.start())
+
+ def test_stop_when_not_running(self):
+ config = DiscordConfig(bot_token="test")
+ channel = DiscordChannel(config)
+ _run(channel.stop())
+
+ def test_send_returns_false_without_client(self):
+ from EvoScientist.channels.base import OutboundMessage
+
+ config = DiscordConfig(bot_token="test")
+ channel = DiscordChannel(config)
+ msg = OutboundMessage(
+ channel="discord",
+ chat_id="123",
+ content="hello",
+ metadata={"chat_id": "123"},
+ )
+ result = _run(channel.send(msg))
+ assert result is False
diff --git a/tests/test_mention_gating.py b/tests/test_mention_gating.py
new file mode 100644
index 0000000..0c74bd8
--- /dev/null
+++ b/tests/test_mention_gating.py
@@ -0,0 +1,521 @@
+"""Tests for unified mention gating in base.py and per-channel _strip_mention."""
+
+import asyncio
+from dataclasses import dataclass
+
+import pytest
+
+from EvoScientist.channels.base import Channel, RawIncoming
+
+
+# ── Minimal concrete channel for testing base-class logic ─────────────
+
+
+@dataclass
+class _StubConfig:
+ allowed_senders: set[str] | None = None
+ require_mention: str = "group"
+
+
+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
+
+
+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 TestBuildInboundGating:
+ """Tests that _build_inbound integrates _should_process and _strip_mention."""
+
+ def test_group_not_mentioned_returns_none(self):
+ config = _StubConfig(require_mention="group")
+ ch = _StubChannel(config)
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ is_group=True, was_mentioned=False,
+ )
+ assert ch._build_inbound(raw) is None
+
+ def test_group_mentioned_returns_message(self):
+ config = _StubConfig(require_mention="group")
+ ch = _StubChannel(config)
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ is_group=True, was_mentioned=True,
+ )
+ msg = ch._build_inbound(raw)
+ assert msg is not None
+ assert msg.content == "hello"
+
+ def test_dm_returns_message_even_when_not_mentioned(self):
+ config = _StubConfig(require_mention="group")
+ ch = _StubChannel(config)
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ is_group=False, was_mentioned=False,
+ )
+ msg = ch._build_inbound(raw)
+ assert msg is not None
+
+ def test_strip_mention_called_for_group(self):
+ """When is_group=True, _strip_mention should be applied to text."""
+ config = _StubConfig(require_mention="group")
+ ch = _StubChannel(config)
+
+ # Override _strip_mention to verify it's called
+ ch._strip_mention = lambda text: text.replace("@bot ", "").strip()
+
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="@bot hello",
+ is_group=True, was_mentioned=True,
+ )
+ msg = ch._build_inbound(raw)
+ assert msg is not None
+ assert msg.content == "hello"
+
+ def test_strip_mention_not_called_for_dm(self):
+ """When is_group=False, _strip_mention should NOT be applied."""
+ config = _StubConfig(require_mention="group")
+ ch = _StubChannel(config)
+
+ ch._strip_mention = lambda text: text.replace("@bot ", "").strip()
+
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="@bot hello",
+ is_group=False, was_mentioned=True,
+ )
+ msg = ch._build_inbound(raw)
+ assert msg is not None
+ assert msg.content == "@bot hello"
+
+
+# ── Per-channel _strip_mention tests ─────────────────────────────────
+
+
+class TestTelegramStripMention:
+ def test_strip_username(self):
+ from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+
+ config = TelegramConfig(bot_token="test")
+ ch = TelegramChannel(config)
+ ch._bot_username = "mybot"
+ assert ch._strip_mention("@mybot hello") == "hello"
+ assert ch._strip_mention("@MyBot hello") == "hello"
+ assert ch._strip_mention("hello @mybot world") == "hello world"
+
+ def test_no_username(self):
+ from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+
+ config = TelegramConfig(bot_token="test")
+ ch = TelegramChannel(config)
+ ch._bot_username = ""
+ assert ch._strip_mention("@mybot hello") == "@mybot hello"
+
+
+class TestDiscordStripMention:
+ def test_strip_user_id(self):
+ from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig
+
+ config = DiscordConfig(bot_token="test")
+ ch = DiscordChannel(config)
+
+ # Mock client.user
+ class FakeUser:
+ id = 123456789
+ class FakeClient:
+ user = FakeUser()
+ ch._client = FakeClient()
+
+ assert ch._strip_mention("<@123456789> hello") == "hello"
+ assert ch._strip_mention("<@!123456789> hello") == "hello"
+ assert ch._strip_mention("hello <@123456789> world") == "hello world"
+
+ def test_no_client(self):
+ from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig
+
+ config = DiscordConfig(bot_token="test")
+ ch = DiscordChannel(config)
+ ch._client = None
+ assert ch._strip_mention("<@123> hello") == "<@123> hello"
+
+
+class TestSlackStripMention:
+ def test_strip_user_id(self):
+ from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
+
+ config = SlackConfig(bot_token="test", app_token="xapp-test")
+ ch = SlackChannel(config)
+ ch._bot_user_id = "U123ABC"
+ assert ch._strip_mention("<@U123ABC> hello") == "hello"
+ assert ch._strip_mention("hello <@U123ABC> world") == "hello world"
+
+ def test_no_bot_user_id(self):
+ from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
+
+ config = SlackConfig(bot_token="test", app_token="xapp-test")
+ ch = SlackChannel(config)
+ assert ch._strip_mention("<@U123> hello") == "<@U123> hello"
+
+
+# ── iMessage pipeline integration tests ──────────────────────────
+
+
+class TestIMessageConfig:
+ def test_default_values(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageConfig
+
+ config = IMessageConfig()
+ assert config.allowed_senders is None
+ assert config.include_attachments is True
+ assert config.text_chunk_limit == 4096
+
+ def test_allowed_senders_is_set(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageConfig
+
+ config = IMessageConfig(allowed_senders={"+1234", "chat_id:5"})
+ assert isinstance(config.allowed_senders, set)
+ assert "+1234" in config.allowed_senders
+
+
+class TestIMessageChannel:
+ def test_is_ready_without_client(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ assert ch._is_ready() is False
+
+ def test_is_allowed_always_true(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ assert ch.is_allowed("anyone") is True
+
+ def test_build_inbound_uses_rich_filtering(self):
+ from EvoScientist.channels.imessage.channel_rpc import (
+ IMessageChannelRpc, IMessageConfig,
+ )
+
+ config = IMessageConfig(allowed_senders={"+15551234567"})
+ ch = IMessageChannelRpc(config)
+
+ # Allowed sender passes
+ raw = RawIncoming(
+ sender_id="+15551234567", chat_id="c1", text="hi",
+ metadata={"chat_id": 1, "chat_guid": None},
+ )
+ assert ch._build_inbound(raw) is not None
+
+ # Disallowed sender blocked
+ raw2 = RawIncoming(
+ sender_id="+19999999999", chat_id="c1", text="hi",
+ metadata={"chat_id": 1, "chat_guid": None},
+ )
+ assert ch._build_inbound(raw2) is None
+
+ def test_add_remove_allowed_senders(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ assert ch.config.allowed_senders is None
+
+ ch.add_allowed_sender("+15551234567")
+ assert ch.config.allowed_senders is not None
+ assert len(ch.config.allowed_senders) == 1
+
+ ch.remove_allowed_sender("+15551234567")
+ assert len(ch.config.allowed_senders) == 0
+
+ ch.clear_allowed_senders()
+ assert ch.config.allowed_senders is None
+
+ def test_list_allowed_senders(self):
+ from EvoScientist.channels.imessage.channel_rpc import (
+ IMessageChannelRpc, IMessageConfig,
+ )
+
+ ch = IMessageChannelRpc()
+ assert ch.list_allowed_senders() == []
+
+ config = IMessageConfig(allowed_senders={"a", "b"})
+ ch2 = IMessageChannelRpc(config)
+ result = ch2.list_allowed_senders()
+ assert set(result) == {"a", "b"}
+
+ def test_handle_message_sets_is_group(self):
+ import asyncio
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ # Capture what _build_inbound receives
+ captured = []
+ original = ch._build_inbound
+ def spy(raw):
+ captured.append(raw)
+ return original(raw)
+ ch._build_inbound = spy
+
+ loop = asyncio.new_event_loop()
+ try:
+ loop.run_until_complete(ch._handle_message({
+ "message": {
+ "sender": "+1234",
+ "text": "hello",
+ "is_group": True,
+ "chat_id": 42,
+ "id": "msg1",
+ }
+ }))
+ finally:
+ loop.close()
+
+ assert len(captured) == 1
+ assert captured[0].is_group is True
+ assert captured[0].was_mentioned is True
+
+
+# ── Feishu config tests ──────────────────────────────────────────
+
+
+class TestFeishuConfig:
+ def test_allowed_channels(self):
+ from EvoScientist.channels.feishu.channel import FeishuConfig
+
+ config = FeishuConfig(
+ app_id="test", app_secret="test",
+ allowed_channels={"oc_abc123"},
+ )
+ assert config.allowed_channels == {"oc_abc123"}
+
+
+# ── Slack retry tests ────────────────────────────────────────────
+
+
+class TestSlackRetry:
+ def test_extract_retry_after_from_response(self):
+ from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
+
+ config = SlackConfig(bot_token="test", app_token="xapp-test")
+ ch = SlackChannel(config)
+
+ # Simulate a SlackApiError-like exception with response headers
+ class FakeResponse:
+ headers = {"Retry-After": "30"}
+ class FakeError(Exception):
+ response = FakeResponse()
+
+ result = ch._extract_retry_after(FakeError())
+ assert result == 30.0
+
+ def test_extract_retry_after_fallback(self):
+ from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig
+
+ config = SlackConfig(bot_token="test", app_token="xapp-test")
+ ch = SlackChannel(config)
+
+ # Plain exception without response attribute
+ result = ch._extract_retry_after(Exception("some error"))
+ assert result == 1.0 # base class default
+
+
+# ── Attachment size check tests ──────────────────────────────────
+
+
+class TestAttachmentSizeCheck:
+ """Tests for _check_attachment_size and _download_attachment size guard."""
+
+ def test_check_attachment_size_within_limit(self):
+ config = _StubConfig()
+ ch = _StubChannel(config)
+ assert ch._check_attachment_size(100, "small.txt") is None
+
+ def test_check_attachment_size_exceeds_limit(self):
+ from EvoScientist.channels.base import MAX_ATTACHMENT_BYTES
+
+ config = _StubConfig()
+ ch = _StubChannel(config)
+ result = ch._check_attachment_size(MAX_ATTACHMENT_BYTES + 1, "big.zip")
+ assert result is not None
+ assert "too large" in result
+
+
+# ── Feishu _strip_mention tests ──────────────────────────────────
+
+
+class TestFeishuStripMention:
+ def test_strip_bot_mention_placeholder(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ ch._mention_names = ["@_user_1"]
+ assert ch._strip_mention("@_user_1 hello world") == "hello world"
+
+ def test_strip_multiple_mentions(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ ch._mention_names = ["@_user_1", "@_user_2"]
+ result = ch._strip_mention("@_user_1 @_user_2 hello")
+ assert result == "hello"
+
+ def test_no_mention_names(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ assert ch._strip_mention("hello world") == "hello world"
+
+
+# ── Telegram allowed_channels tests ──────────────────────────────
+
+
+class TestTelegramConfig:
+ def test_allowed_channels_field_exists(self):
+ from EvoScientist.channels.telegram.channel import TelegramConfig
+
+ config = TelegramConfig(bot_token="test", allowed_channels={"-100123"})
+ assert config.allowed_channels == {"-100123"}
+
+ def test_allowed_channels_default_none(self):
+ from EvoScientist.channels.telegram.channel import TelegramConfig
+
+ config = TelegramConfig(bot_token="test")
+ assert config.allowed_channels is None
+
+ def test_channel_allow_list_integration(self):
+ from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+
+ config = TelegramConfig(bot_token="test", allowed_channels={"-100123"})
+ ch = TelegramChannel(config)
+ assert ch.is_channel_allowed("-100123") is True
+ assert ch.is_channel_allowed("-100999") is False
+
+ def test_channel_allow_list_empty_allows_all(self):
+ from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+
+ config = TelegramConfig(bot_token="test")
+ ch = TelegramChannel(config)
+ assert ch.is_channel_allowed("-100999") is True
+
+
+# ── Feishu retry tests ───────────────────────────────────────────
+
+
+class TestFeishuRetry:
+ def test_extract_retry_after_rate_limit(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ result = ch._extract_retry_after(Exception("code 99991400: rate limit"))
+ assert result == 2.0
+
+ def test_extract_retry_after_generic(self):
+ from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig
+
+ config = FeishuConfig(app_id="test", app_secret="test")
+ ch = FeishuChannel(config)
+ result = ch._extract_retry_after(Exception("some error"))
+ assert result == 1.0
+
+
+# ── iMessage reply_to tests ──────────────────────────────────────
+
+
+class TestIMessageReplyTo:
+ def test_send_chunk_includes_reply_to(self):
+ """Verify reply_to is passed through to RPC params."""
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ # Mock the RPC client
+ captured_params = {}
+
+ class FakeClient:
+ async def request(self, method, params):
+ captured_params.update(params)
+ return {}
+
+ ch._client = FakeClient()
+
+ _run(ch._send_chunk("chat123", "hello", "hello", "msg42", {"chat_id": "chat123"}))
+ assert captured_params.get("reply_to") == "msg42"
+
+ def test_send_chunk_omits_reply_to_when_none(self):
+ from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc
+
+ ch = IMessageChannelRpc()
+ captured_params = {}
+
+ class FakeClient:
+ async def request(self, method, params):
+ captured_params.update(params)
+ return {}
+
+ ch._client = FakeClient()
+
+ _run(ch._send_chunk("chat123", "hello", "hello", None, {"chat_id": "chat123"}))
+ assert "reply_to" not in captured_params
diff --git a/tests/test_message_bus.py b/tests/test_message_bus.py
new file mode 100644
index 0000000..7e70eb8
--- /dev/null
+++ b/tests/test_message_bus.py
@@ -0,0 +1,112 @@
+"""Tests for the Message Bus decoupling layer."""
+
+import asyncio
+
+import pytest
+
+from EvoScientist.channels.bus.events import InboundMessage, OutboundMessage
+from EvoScientist.channels.bus.message_bus import MessageBus
+
+
+def _run(coro):
+ """Run an async coroutine safely, creating a fresh event loop."""
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+# ── Event tests ──
+
+
+class TestInboundMessage:
+ def test_session_key(self):
+ msg = InboundMessage(
+ channel="telegram", sender_id="u1",
+ chat_id="c1", content="hi",
+ )
+ assert msg.session_key == "telegram:c1"
+
+ def test_defaults(self):
+ msg = InboundMessage(
+ channel="discord", sender_id="u2",
+ chat_id="c2", content="hello",
+ )
+ assert msg.media == []
+ assert msg.metadata == {}
+ assert msg.message_id == ""
+
+
+class TestOutboundMessage:
+ def test_fields(self):
+ msg = OutboundMessage(
+ channel="telegram", chat_id="c1", content="reply",
+ )
+ assert msg.channel == "telegram"
+ assert msg.chat_id == "c1"
+ assert msg.reply_to is None
+ assert msg.media == []
+
+
+# ── MessageBus tests ──
+
+
+class TestMessageBus:
+ def test_inbound_publish_consume(self):
+ async def _test():
+ bus = MessageBus()
+ msg = InboundMessage(
+ channel="telegram", sender_id="u1",
+ chat_id="c1", content="hello",
+ )
+ await bus.publish_inbound(msg)
+ assert bus.inbound_size == 1
+ got = await bus.consume_inbound()
+ assert got is msg
+ assert bus.inbound_size == 0
+ _run(_test())
+
+ def test_outbound_publish_consume(self):
+ async def _test():
+ bus = MessageBus()
+ msg = OutboundMessage(
+ channel="discord", chat_id="c1", content="reply",
+ )
+ await bus.publish_outbound(msg)
+ assert bus.outbound_size == 1
+ got = await bus.consume_outbound()
+ assert got is msg
+ assert bus.outbound_size == 0
+ _run(_test())
+
+ def test_subscribe_and_dispatch(self):
+ async def _test():
+ bus = MessageBus()
+ received = []
+
+ async def callback(msg):
+ received.append(msg)
+
+ bus.subscribe_outbound("telegram", callback)
+
+ msg = OutboundMessage(
+ channel="telegram", chat_id="c1", content="hi",
+ )
+ await bus.publish_outbound(msg)
+
+ dispatch = asyncio.create_task(bus.dispatch_outbound())
+ await asyncio.sleep(0.05)
+ bus.stop()
+ await asyncio.sleep(0.05)
+ dispatch.cancel()
+
+ assert len(received) == 1
+ assert received[0] is msg
+ _run(_test())
+
+ def test_stop(self):
+ bus = MessageBus()
+ assert bus._running is False
+ bus.stop()
+ assert bus._running is False
diff --git a/tests/test_stream_state.py b/tests/test_stream_state.py
index c5e142d..29a3d00 100644
--- a/tests/test_stream_state.py
+++ b/tests/test_stream_state.py
@@ -554,162 +554,5 @@ class TestParseTodoItemsAdvanced:
# =============================================================================
-# ChannelState queue mechanism
+# ChannelState queue mechanism (removed — replaced by bus mode in channel.py)
# =============================================================================
-
-class TestChannelState:
- """Tests for _ChannelState queue-based communication."""
-
- def test_enqueue_creates_message_in_queue(self):
- """enqueue() should add a ChannelMessage to the queue."""
- from EvoScientist.cli import _ChannelState, ChannelMessage
- import queue
-
- # Clear any existing messages
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
-
- msg_id, event = _ChannelState.enqueue("test content", "sender@test.com", "Email")
- assert msg_id is not None
- assert event is not None
-
- # Message should be in queue
- msg = _ChannelState.message_queue.get_nowait()
- assert isinstance(msg, ChannelMessage)
- assert msg.content == "test content"
- assert msg.sender == "sender@test.com"
- assert msg.channel_type == "Email"
- assert msg.msg_id == msg_id
-
- def test_enqueue_creates_pending_response_slot(self):
- """enqueue() should create a response slot for the message."""
- from EvoScientist.cli import _ChannelState
- import queue
-
- # Clear queue and responses
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
- _ChannelState.pending_responses.clear()
-
- msg_id, _ = _ChannelState.enqueue("test", "sender", "iMessage")
- assert msg_id in _ChannelState.pending_responses
- assert _ChannelState.pending_responses[msg_id]["response"] is None
-
- # Cleanup
- _ChannelState.message_queue.get_nowait()
-
- def test_set_response_updates_slot_and_signals(self):
- """set_response() should update response and signal the event."""
- from EvoScientist.cli import _ChannelState
- import queue
-
- # Clear
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
- _ChannelState.pending_responses.clear()
-
- msg_id, event = _ChannelState.enqueue("test", "sender", "iMessage")
- _ChannelState.message_queue.get_nowait() # Remove from queue
-
- assert not event.is_set()
- _ChannelState.set_response(msg_id, "response text")
-
- assert event.is_set()
- assert _ChannelState.pending_responses[msg_id]["response"] == "response text"
-
- # Cleanup
- _ChannelState.pending_responses.clear()
-
- def test_get_response_waits_and_retrieves(self):
- """get_response() should wait for response and return it."""
- from EvoScientist.cli import _ChannelState
- import threading
- import queue
-
- # Clear
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
- _ChannelState.pending_responses.clear()
-
- msg_id, _ = _ChannelState.enqueue("test", "sender", "iMessage")
- _ChannelState.message_queue.get_nowait()
-
- # Set response in another thread
- def set_later():
- import time
- time.sleep(0.05)
- _ChannelState.set_response(msg_id, "async response")
-
- t = threading.Thread(target=set_later)
- t.start()
-
- response = _ChannelState.get_response(msg_id, timeout=1.0)
- t.join()
-
- assert response == "async response"
- assert msg_id not in _ChannelState.pending_responses # Cleaned up
-
- def test_get_response_returns_none_on_timeout(self):
- """get_response() should return None if timeout expires."""
- from EvoScientist.cli import _ChannelState
- import queue
-
- # Clear
- while True:
- try:
- _ChannelState.message_queue.get_nowait()
- except queue.Empty:
- break
- _ChannelState.pending_responses.clear()
-
- msg_id, _ = _ChannelState.enqueue("test", "sender", "iMessage")
- _ChannelState.message_queue.get_nowait()
-
- # Don't set response, let it timeout
- response = _ChannelState.get_response(msg_id, timeout=0.01)
- assert response is None
-
- # Cleanup
- _ChannelState.pending_responses.clear()
-
- def test_get_response_returns_none_for_unknown_id(self):
- """get_response() should return None for unknown message ID."""
- from EvoScientist.cli import _ChannelState
- response = _ChannelState.get_response("nonexistent-id", timeout=0.01)
- assert response is None
-
- def test_channel_message_dataclass(self):
- """ChannelMessage should store all fields correctly."""
- from EvoScientist.cli import ChannelMessage
-
- msg = ChannelMessage(
- msg_id="id123",
- content="Hello",
- sender="+1234567890",
- channel_type="iMessage",
- metadata={"key": "value"},
- )
- assert msg.msg_id == "id123"
- assert msg.content == "Hello"
- assert msg.sender == "+1234567890"
- assert msg.channel_type == "iMessage"
- assert msg.metadata == {"key": "value"}
-
- def test_channel_message_default_metadata(self):
- """ChannelMessage metadata should default to None."""
- from EvoScientist.cli import ChannelMessage
-
- msg = ChannelMessage("id", "content", "sender", "type")
- assert msg.metadata is None
diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py
new file mode 100644
index 0000000..e50f3a5
--- /dev/null
+++ b/tests/test_telegram_channel.py
@@ -0,0 +1,68 @@
+"""Tests for Telegram channel implementation."""
+
+import asyncio
+
+import pytest
+
+from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
+from EvoScientist.channels.base import ChannelError
+
+
+def _run(coro):
+ """Run an async coroutine safely, creating a fresh event loop."""
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(coro)
+ finally:
+ loop.close()
+
+
+class TestTelegramConfig:
+ def test_default_values(self):
+ config = TelegramConfig()
+ assert config.bot_token == ""
+ assert config.allowed_senders is None
+ assert config.text_chunk_limit == 4096
+
+ def test_custom_values(self):
+ config = TelegramConfig(
+ bot_token="test-token",
+ allowed_senders={"123", "456"},
+ text_chunk_limit=2000,
+ )
+ assert config.bot_token == "test-token"
+ assert config.allowed_senders == {"123", "456"}
+ assert config.text_chunk_limit == 2000
+
+
+class TestTelegramChannel:
+ def test_init(self):
+ config = TelegramConfig(bot_token="test")
+ channel = TelegramChannel(config)
+ assert channel.config is config
+ assert channel._running is False
+
+ def test_start_raises_without_token(self):
+ config = TelegramConfig(bot_token="")
+ channel = TelegramChannel(config)
+ with pytest.raises(ChannelError, match="bot token"):
+ _run(channel.start())
+
+ def test_stop_when_not_running(self):
+ config = TelegramConfig(bot_token="test")
+ channel = TelegramChannel(config)
+ _run(channel.stop())
+
+ def test_send_returns_false_without_app(self):
+ from EvoScientist.channels.base import OutboundMessage
+
+ config = TelegramConfig(bot_token="test")
+ channel = TelegramChannel(config)
+ msg = OutboundMessage(
+ channel="telegram",
+ chat_id="123",
+ content="hello",
+ metadata={"chat_id": "123"},
+ )
+ result = _run(channel.send(msg))
+ assert result is False
diff --git a/tests/test_wechat_channel.py b/tests/test_wechat_channel.py
new file mode 100644
index 0000000..6a5a33d
--- /dev/null
+++ b/tests/test_wechat_channel.py
@@ -0,0 +1,443 @@
+"""Tests for WeChat channel implementation."""
+
+import asyncio
+import hashlib
+import json
+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.
+ """
+
+ @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
From 123a5de1791560d133ca0553a9c76b053fadc438 Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 13:33:59 +0800
Subject: [PATCH 02/12] test weichat
---
EvoScientist/EvoScientist.py | 2 -
EvoScientist/channels/consumer.py | 2 +-
EvoScientist/channels/dingtalk/channel.py | 75 ++++++++-
EvoScientist/channels/email/channel.py | 18 +-
EvoScientist/channels/wechat/channel.py | 18 ++
EvoScientist/stream/events.py | 39 ++++-
botpy.log | 193 ++++++++++++++++++++++
7 files changed, 336 insertions(+), 11 deletions(-)
create mode 100644 botpy.log
diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py
index 6b43e14..28d844e 100644
--- a/EvoScientist/EvoScientist.py
+++ b/EvoScientist/EvoScientist.py
@@ -179,7 +179,6 @@ prompt_refs = {
base_middleware = [
create_memory_middleware(MEMORY_DIR, extraction_model=chat_model),
- create_skills_middleware(backend),
]
# Default agent (no checkpointer) — used by langgraph dev / LangSmith / notebooks.
@@ -247,7 +246,6 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None):
mw = [
create_memory_middleware(MEMORY_DIR, extraction_model=chat_model),
- create_skills_middleware(be),
]
# Re-load MCP tools from current config (picks up /mcp add changes)
diff --git a/EvoScientist/channels/consumer.py b/EvoScientist/channels/consumer.py
index 19e379c..a3ac15c 100644
--- a/EvoScientist/channels/consumer.py
+++ b/EvoScientist/channels/consumer.py
@@ -275,7 +275,7 @@ class InboundConsumer:
await channel.start_typing(msg.chat_id)
async for event in _timeout_aiter(
- stream_agent_events(self.agent, msg.content, thread_id),
+ stream_agent_events(self.agent, msg.content, thread_id, media=msg.media or None),
self._inference_timeout,
):
event_type = event.get("type")
diff --git a/EvoScientist/channels/dingtalk/channel.py b/EvoScientist/channels/dingtalk/channel.py
index f407078..cbbb750 100644
--- a/EvoScientist/channels/dingtalk/channel.py
+++ b/EvoScientist/channels/dingtalk/channel.py
@@ -21,6 +21,7 @@ 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
@@ -67,6 +68,23 @@ class DingTalkChannel(Channel, WebSocketMixin, TokenMixin):
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:
@@ -103,19 +121,67 @@ class DingTalkChannel(Channel, WebSocketMixin, TokenMixin):
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()
- content = content or payload.get("content", "").strip()
if not content:
- return
+ 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_url = att.get("downloadCode") or att.get("downloadUrl") or ""
+ 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):
@@ -142,6 +208,9 @@ class DingTalkChannel(Channel, WebSocketMixin, TokenMixin):
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", "")
conv_id = payload.get("conversationId", "")
is_group = payload.get("conversationType") == "2"
diff --git a/EvoScientist/channels/email/channel.py b/EvoScientist/channels/email/channel.py
index f60e70d..71a4dfa 100644
--- a/EvoScientist/channels/email/channel.py
+++ b/EvoScientist/channels/email/channel.py
@@ -124,14 +124,24 @@ class EmailChannel(Channel, PollingMixin):
attachments = []
if msg.is_multipart():
for part in msg.walk():
- content_disp = str(part.get("Content-Disposition", ""))
+ content_disp = part.get("Content-Disposition") or ""
content_type = part.get_content_type() or ""
- is_attachment = "attachment" in content_disp
+ is_attachment = "attachment" in content_disp.lower()
is_inline_image = (
- "inline" in content_disp
+ "inline" in content_disp.lower()
and content_type.startswith("image/")
)
- if is_attachment or is_inline_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)
diff --git a/EvoScientist/channels/wechat/channel.py b/EvoScientist/channels/wechat/channel.py
index 209fd1d..a97c5d8 100644
--- a/EvoScientist/channels/wechat/channel.py
+++ b/EvoScientist/channels/wechat/channel.py
@@ -316,6 +316,7 @@ class WeChatChannel(Channel, WebhookMixin, TokenMixin):
except Exception:
return web.Response(status=400)
+ logger.info(f"WeChat callback POST received, body length={len(body)}")
xml_data = parse_xml(body)
# If encrypted, decrypt first
@@ -351,6 +352,8 @@ class WeChatChannel(Channel, WebhookMixin, TokenMixin):
msg_id = xml_data.get("MsgId", "")
create_time = xml_data.get("CreateTime", "")
+ logger.info(f"WeChat message received: type={msg_type}, from={from_user}, id={msg_id}, keys={list(xml_data.keys())}")
+
if not from_user:
return
@@ -431,6 +434,21 @@ class WeChatChannel(Channel, WebhookMixin, TokenMixin):
lat = xml_data.get("Location_X", "")
lon = xml_data.get("Location_Y", "")
text = f"[位置] {label} ({lat}, {lon})"
+ elif msg_type == "file":
+ media_id = xml_data.get("MediaId", "")
+ file_name = xml_data.get("Title", f"wechat_file_{msg_id}")
+ logger.info(f"WeChat file message: name={file_name}, media_id={media_id!r}, keys={list(xml_data.keys())}")
+ if media_id:
+ local, ann = await self._download_wechat_media(
+ media_id, f"wechat_file_{msg_id}_{file_name}",
+ )
+ logger.info(f"WeChat file download result: local={local}, ann={ann}")
+ if local:
+ media_paths.append(local)
+ if ann:
+ annotations.append(ann)
+ if not media_paths:
+ annotations.append(f"[file: {file_name}]")
elif msg_type == "link":
title = xml_data.get("Title", "")
description = xml_data.get("Description", "")
diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py
index 97a6d9a..cc96936 100644
--- a/EvoScientist/stream/events.py
+++ b/EvoScientist/stream/events.py
@@ -4,6 +4,9 @@ Async generator that streams events from an agent graph,
plus helpers for processing AI message chunks and tool results.
"""
+import base64
+import mimetypes
+import os
from typing import Any, AsyncIterator
from langchain_core.messages import AIMessage, AIMessageChunk # type: ignore[import-untyped]
@@ -64,6 +67,7 @@ async def stream_agent_events(
message: str,
thread_id: str,
metadata: dict | None = None,
+ media: list[str] | None = None,
) -> AsyncIterator[dict]:
"""Stream events from the agent graph using async iteration.
@@ -75,6 +79,7 @@ async def stream_agent_events(
thread_id: Thread ID for conversation persistence
metadata: Optional metadata dict merged into the LangGraph config
(e.g. agent_name, updated_at for checkpoint persistence).
+ media: Optional list of local file paths for attachments.
Yields:
Event dicts: thinking, text, tool_call, tool_result,
@@ -247,9 +252,41 @@ async def stream_agent_events(
# 4) No real names available yet -- return generic WITHOUT caching
return "sub-agent"
+ # Build user message content: text + inline images + file path references
+ user_content: str | list[dict[str, Any]] = message
+ if media:
+ _IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"})
+ _MAX_INLINE_SIZE = 5 * 1024 * 1024 # 5 MB
+ content_blocks: list[dict[str, Any]] = []
+ if message:
+ content_blocks.append({"type": "text", "text": message})
+ file_refs: list[str] = []
+ for path in media:
+ ext = os.path.splitext(path)[1].lower()
+ if ext in _IMAGE_EXTS and os.path.isfile(path):
+ fsize = os.path.getsize(path)
+ if fsize <= _MAX_INLINE_SIZE:
+ mime = mimetypes.guess_type(path)[0] or "image/png"
+ with open(path, "rb") as fh:
+ b64 = base64.b64encode(fh.read()).decode("ascii")
+ content_blocks.append({"type": "image_url", "image_url": {
+ "url": f"data:{mime};base64,{b64}",
+ }})
+ else:
+ file_refs.append(path)
+ else:
+ file_refs.append(path)
+ if file_refs:
+ ref_text = "\n".join(
+ f"[attached file: {os.path.basename(p)}] path: {p}" for p in file_refs
+ )
+ content_blocks.append({"type": "text", "text": ref_text})
+ if content_blocks:
+ user_content = content_blocks
+
try:
async for chunk in agent.astream(
- {"messages": [{"role": "user", "content": message}]},
+ {"messages": [{"role": "user", "content": user_content}]},
config=config,
stream_mode="messages",
subgraphs=True,
diff --git a/botpy.log b/botpy.log
new file mode 100644
index 0000000..dd71cb8
--- /dev/null
+++ b/botpy.log
@@ -0,0 +1,193 @@
+2026-02-15 02:24:12,880 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
+2026-02-15 02:24:13,074 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 2629
+2026-02-15 02:24:13,559 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
+2026-02-15 02:24:13,559 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 02:24:13,559 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 02:24:13,560 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 02:24:13,751 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
+2026-02-15 02:24:13,885 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
+2026-02-15 02:24:13,886 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 02:30:47,988 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
+2026-02-15 02:30:48,188 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 2234
+2026-02-15 02:30:48,703 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
+2026-02-15 02:30:48,704 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 02:30:48,704 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 02:30:48,704 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 02:30:48,877 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
+2026-02-15 02:30:48,997 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
+2026-02-15 02:30:48,998 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 02:36:24,277 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
+2026-02-15 02:36:24,464 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 1898
+2026-02-15 02:36:25,424 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
+2026-02-15 02:36:25,424 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 02:36:25,425 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 02:36:25,425 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 02:36:25,602 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
+2026-02-15 02:36:25,733 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
+2026-02-15 02:36:25,733 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 03:06:25,660 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 03:06:25,660 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 03:06:30,663 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 03:06:30,663 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 03:06:30,663 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 03:06:30,839 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 03:06:30,934 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 03:06:30,934 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 03:36:30,778 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 03:36:30,780 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 03:36:35,782 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 03:36:35,782 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 03:36:35,782 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 03:36:35,972 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 03:36:36,482 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
+2026-02-15 03:36:36,573 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 03:36:36,573 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 04:06:35,954 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 04:06:35,956 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 04:06:40,958 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 04:06:40,959 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 04:06:40,959 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 04:06:41,163 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 04:06:41,281 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 04:06:41,281 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 04:36:41,169 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 04:36:41,170 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 04:36:46,172 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 04:36:46,173 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 04:36:46,173 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 04:36:46,402 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 04:36:46,499 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 04:36:46,500 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 05:06:46,456 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 05:06:46,457 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 05:06:51,460 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 05:06:51,461 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 05:06:51,461 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 05:06:51,666 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 05:06:51,774 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 05:06:51,775 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 05:36:51,710 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 05:36:51,712 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 05:36:56,715 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 05:36:56,715 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 05:36:56,715 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 05:36:56,922 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 05:36:57,135 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
+2026-02-15 05:36:57,242 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 05:36:57,242 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 06:06:56,940 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 06:06:56,942 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 06:07:01,945 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 06:07:01,945 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 06:07:01,945 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 06:07:02,134 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 06:07:02,241 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 06:07:02,241 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 06:37:02,157 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 06:37:02,158 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 06:37:07,160 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 06:37:07,160 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 06:37:07,160 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 06:37:07,377 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 06:37:07,482 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 06:37:07,483 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 07:07:07,303 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 07:07:07,305 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 07:07:12,308 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 07:07:12,308 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 07:07:12,308 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 07:07:12,494 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 07:07:12,651 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 07:07:12,651 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 07:37:12,481 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 07:37:12,483 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 07:37:17,485 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 07:37:17,486 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 07:37:17,486 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 07:37:17,722 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 07:37:17,927 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
+2026-02-15 07:37:18,044 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 07:37:18,044 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 08:07:17,716 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 08:07:17,718 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 08:07:22,721 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 08:07:22,721 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 08:07:22,722 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 08:07:22,913 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 08:07:23,017 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 08:07:23,018 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 08:37:22,911 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 08:37:22,912 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 08:37:27,914 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 08:37:27,915 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 08:37:27,915 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 08:37:28,153 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 08:37:28,263 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 08:37:28,263 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 09:07:28,149 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 09:07:28,150 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 09:07:33,152 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 09:07:33,153 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 09:07:33,153 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 09:07:33,396 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 09:07:33,515 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 09:07:33,515 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 09:37:33,390 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 09:37:33,392 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 09:37:38,394 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 09:37:38,394 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 09:37:38,394 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 09:37:38,590 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 09:37:38,807 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
+2026-02-15 09:37:38,924 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 09:37:38,924 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 10:07:38,573 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 10:07:38,575 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 10:07:43,577 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 10:07:43,578 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 10:07:43,578 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 10:07:43,746 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 10:07:43,845 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 10:07:43,845 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 10:37:43,780 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 10:37:43,782 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 10:37:48,783 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 10:37:48,783 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 10:37:48,783 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 10:37:48,961 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 10:37:49,089 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 10:37:49,089 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 11:07:48,997 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 11:07:48,999 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 11:07:54,001 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 11:07:54,002 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 11:07:54,002 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 11:07:54,244 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 11:07:54,353 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 11:07:54,353 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 11:37:54,235 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 11:37:54,236 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 11:37:59,237 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 11:37:59,238 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 11:37:59,238 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 11:37:59,430 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 11:37:59,621 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
+2026-02-15 11:37:59,732 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 11:37:59,733 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 12:07:59,439 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
+2026-02-15 12:07:59,440 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
+2026-02-15 12:08:04,442 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 12:08:04,443 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 12:08:04,443 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 12:08:04,630 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
+2026-02-15 12:08:04,737 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
+2026-02-15 12:08:04,738 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 12:33:10,772 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
+2026-02-15 12:33:10,925 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 3889
+2026-02-15 12:33:11,815 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
+2026-02-15 12:33:11,815 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 12:33:11,815 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 12:33:11,816 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 12:33:11,999 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
+2026-02-15 12:33:12,145 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
+2026-02-15 12:33:12,145 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
From 299054edbf5f823ed65f157dbfbff50e9d7a01af Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 13:35:06 +0800
Subject: [PATCH 03/12] test wechat
---
botpy.log | 193 ------------------------------------------------------
1 file changed, 193 deletions(-)
delete mode 100644 botpy.log
diff --git a/botpy.log b/botpy.log
deleted file mode 100644
index dd71cb8..0000000
--- a/botpy.log
+++ /dev/null
@@ -1,193 +0,0 @@
-2026-02-15 02:24:12,880 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
-2026-02-15 02:24:13,074 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 2629
-2026-02-15 02:24:13,559 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
-2026-02-15 02:24:13,559 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 02:24:13,559 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 02:24:13,560 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 02:24:13,751 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
-2026-02-15 02:24:13,885 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
-2026-02-15 02:24:13,886 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 02:30:47,988 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
-2026-02-15 02:30:48,188 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 2234
-2026-02-15 02:30:48,703 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
-2026-02-15 02:30:48,704 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 02:30:48,704 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 02:30:48,704 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 02:30:48,877 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
-2026-02-15 02:30:48,997 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
-2026-02-15 02:30:48,998 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 02:36:24,277 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
-2026-02-15 02:36:24,464 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 1898
-2026-02-15 02:36:25,424 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
-2026-02-15 02:36:25,424 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 02:36:25,425 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 02:36:25,425 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 02:36:25,602 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
-2026-02-15 02:36:25,733 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
-2026-02-15 02:36:25,733 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 03:06:25,660 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 03:06:25,660 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 03:06:30,663 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 03:06:30,663 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 03:06:30,663 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 03:06:30,839 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 03:06:30,934 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 03:06:30,934 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 03:36:30,778 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 03:36:30,780 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 03:36:35,782 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 03:36:35,782 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 03:36:35,782 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 03:36:35,972 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 03:36:36,482 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
-2026-02-15 03:36:36,573 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 03:36:36,573 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 04:06:35,954 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 04:06:35,956 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 04:06:40,958 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 04:06:40,959 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 04:06:40,959 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 04:06:41,163 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 04:06:41,281 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 04:06:41,281 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 04:36:41,169 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 04:36:41,170 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 04:36:46,172 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 04:36:46,173 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 04:36:46,173 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 04:36:46,402 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 04:36:46,499 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 04:36:46,500 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 05:06:46,456 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 05:06:46,457 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 05:06:51,460 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 05:06:51,461 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 05:06:51,461 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 05:06:51,666 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 05:06:51,774 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 05:06:51,775 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 05:36:51,710 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 05:36:51,712 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 05:36:56,715 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 05:36:56,715 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 05:36:56,715 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 05:36:56,922 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 05:36:57,135 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
-2026-02-15 05:36:57,242 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 05:36:57,242 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 06:06:56,940 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 06:06:56,942 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 06:07:01,945 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 06:07:01,945 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 06:07:01,945 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 06:07:02,134 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 06:07:02,241 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 06:07:02,241 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 06:37:02,157 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 06:37:02,158 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 06:37:07,160 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 06:37:07,160 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 06:37:07,160 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 06:37:07,377 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 06:37:07,482 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 06:37:07,483 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 07:07:07,303 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 07:07:07,305 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 07:07:12,308 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 07:07:12,308 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 07:07:12,308 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 07:07:12,494 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 07:07:12,651 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 07:07:12,651 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 07:37:12,481 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 07:37:12,483 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 07:37:17,485 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 07:37:17,486 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 07:37:17,486 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 07:37:17,722 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 07:37:17,927 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
-2026-02-15 07:37:18,044 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 07:37:18,044 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 08:07:17,716 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 08:07:17,718 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 08:07:22,721 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 08:07:22,721 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 08:07:22,722 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 08:07:22,913 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 08:07:23,017 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 08:07:23,018 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 08:37:22,911 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 08:37:22,912 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 08:37:27,914 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 08:37:27,915 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 08:37:27,915 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 08:37:28,153 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 08:37:28,263 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 08:37:28,263 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 09:07:28,149 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 09:07:28,150 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 09:07:33,152 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 09:07:33,153 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 09:07:33,153 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 09:07:33,396 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 09:07:33,515 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 09:07:33,515 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 09:37:33,390 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 09:37:33,392 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 09:37:38,394 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 09:37:38,394 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 09:37:38,394 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 09:37:38,590 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 09:37:38,807 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
-2026-02-15 09:37:38,924 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 09:37:38,924 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 10:07:38,573 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 10:07:38,575 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 10:07:43,577 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 10:07:43,578 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 10:07:43,578 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 10:07:43,746 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 10:07:43,845 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 10:07:43,845 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 10:37:43,780 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 10:37:43,782 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 10:37:48,783 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 10:37:48,783 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 10:37:48,783 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 10:37:48,961 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 10:37:49,089 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 10:37:49,089 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 11:07:48,997 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 11:07:48,999 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 11:07:54,001 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 11:07:54,002 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 11:07:54,002 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 11:07:54,244 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 11:07:54,353 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 11:07:54,353 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 11:37:54,235 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 11:37:54,236 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 11:37:59,237 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 11:37:59,238 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 11:37:59,238 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 11:37:59,430 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 11:37:59,621 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
-2026-02-15 11:37:59,732 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 11:37:59,733 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 12:07:59,439 [INFO] (gateway.py:56)on_closed [botpy] 关闭, 返回码: 4009, 返回信息: Session timed out
-2026-02-15 12:07:59,440 [INFO] (gateway.py:134)ws_connect [botpy] ws关闭, 停止接收消息!
-2026-02-15 12:08:04,442 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 12:08:04,443 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 12:08:04,443 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 12:08:04,630 [INFO] (gateway.py:175)ws_resume [botpy] 重连启动...
-2026-02-15 12:08:04,737 [INFO] (gateway.py:90)on_message [botpy] 机器人重连成功!
-2026-02-15 12:08:04,738 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 12:33:10,772 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
-2026-02-15 12:33:10,925 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 3889
-2026-02-15 12:33:11,815 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
-2026-02-15 12:33:11,815 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 12:33:11,815 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 12:33:11,816 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 12:33:11,999 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
-2026-02-15 12:33:12,145 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
-2026-02-15 12:33:12,145 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
From f4419dae80cb8676d8085bcf10f5a27e0cab71b5 Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 14:01:08 +0800
Subject: [PATCH 04/12] wechat failed
---
EvoScientist/channels/wechat/channel.py | 16 ++++++++++++----
botpy.log | 18 ++++++++++++++++++
2 files changed, 30 insertions(+), 4 deletions(-)
create mode 100644 botpy.log
diff --git a/EvoScientist/channels/wechat/channel.py b/EvoScientist/channels/wechat/channel.py
index a97c5d8..4c2fbb6 100644
--- a/EvoScientist/channels/wechat/channel.py
+++ b/EvoScientist/channels/wechat/channel.py
@@ -337,12 +337,20 @@ class WeChatChannel(Channel, WebhookMixin, TokenMixin):
logger.error(f"WeChat decrypt failed: {e}")
return web.Response(status=500)
- # Route to backend-specific handler
- await self._process_message(xml_data)
+ # Process message asynchronously — WeCom requires a response within
+ # 5 seconds, but media downloads can take much longer. Return
+ # "success" immediately and handle the message in the background.
+ asyncio.create_task(self._safe_process_message(xml_data))
- # Return "success" to acknowledge receipt (async reply via API)
return web.Response(text="success")
+ async def _safe_process_message(self, xml_data: dict[str, str]) -> None:
+ """Wrapper that catches exceptions so fire-and-forget tasks don't leak."""
+ try:
+ await self._process_message(xml_data)
+ except Exception:
+ logger.exception("Error processing WeChat message")
+
async def _process_message(self, xml_data: dict[str, str]) -> None:
"""Process a parsed XML message from WeChat/WeCom callback."""
msg_type = xml_data.get("MsgType", "")
@@ -436,7 +444,7 @@ class WeChatChannel(Channel, WebhookMixin, TokenMixin):
text = f"[位置] {label} ({lat}, {lon})"
elif msg_type == "file":
media_id = xml_data.get("MediaId", "")
- file_name = xml_data.get("Title", f"wechat_file_{msg_id}")
+ file_name = xml_data.get("FileName", "") or xml_data.get("Title", f"wechat_file_{msg_id}")
logger.info(f"WeChat file message: name={file_name}, media_id={media_id!r}, keys={list(xml_data.keys())}")
if media_id:
local, ann = await self._download_wechat_media(
diff --git a/botpy.log b/botpy.log
new file mode 100644
index 0000000..b43c983
--- /dev/null
+++ b/botpy.log
@@ -0,0 +1,18 @@
+2026-02-15 13:46:42,249 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
+2026-02-15 13:46:42,535 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
+2026-02-15 13:46:43,622 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
+2026-02-15 13:46:43,623 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 13:46:43,623 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 13:46:43,623 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 13:46:43,792 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
+2026-02-15 13:46:43,915 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
+2026-02-15 13:46:43,916 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 13:57:47,578 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
+2026-02-15 13:57:47,775 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 6535
+2026-02-15 13:57:48,887 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
+2026-02-15 13:57:48,888 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 13:57:48,888 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 13:57:48,888 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 13:57:49,073 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
+2026-02-15 13:57:49,294 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
+2026-02-15 13:57:49,295 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
From 2f851cdd7bc5fe7677e8f8a266ee8e21a6904a23 Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 14:01:41 +0800
Subject: [PATCH 05/12] wechat fail
---
.gitignore | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 205410b..5fa9c7a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,4 +37,5 @@ memory/
*.ipynb
*CLAUDE.md
*AGENTS.md
-*meals/
\ No newline at end of file
+*meals/
+botpy.log
\ No newline at end of file
From 7a94951af9059efaa64786193e31d961d88638e5 Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 14:21:34 +0800
Subject: [PATCH 06/12] Add me
---
README.md | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/README.md b/README.md
index 22375c3..5c46d90 100644
--- a/README.md
+++ b/README.md
@@ -471,6 +471,15 @@ We thank the authors for their valuable contributions to the open-source communi
Dinos Papakostas
+
+
+
+
+ Ziheng Zhang
+
+
From e672f71c1234cc110238a51c3c821794061140e7 Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 17:21:29 +0800
Subject: [PATCH 07/12] Stable
---
EvoScientist/channels/base.py | 46 ++++++++++++++---------
EvoScientist/channels/capabilities.py | 4 +-
EvoScientist/channels/channel_manager.py | 5 ++-
EvoScientist/channels/consumer.py | 7 +++-
EvoScientist/channels/middleware.py | 17 +++++++--
channel_feature_matrix.xlsx | Bin 0 -> 6840 bytes
tests/test_channel_comprehensive.py | 8 ++--
7 files changed, 58 insertions(+), 29 deletions(-)
create mode 100644 channel_feature_matrix.xlsx
diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py
index 9923cd2..edd475b 100644
--- a/EvoScientist/channels/base.py
+++ b/EvoScientist/channels/base.py
@@ -176,24 +176,31 @@ async def download_attachment(
local_path = media_path(f"{prefix}{safe_name}")
async with httpx.AsyncClient(proxy=proxy) as client:
- resp = await client.get(url, headers=headers or {}, timeout=30)
- if resp.status_code != 200:
- return None, f"[attachment: {filename} - download failed]"
+ 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 when file_size was not known beforehand
- 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
- if len(resp.content) > MAX_ATTACHMENT_BYTES:
- return None, check_attachment_size(len(resp.content), filename)
+ # 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
- local_path.write_bytes(resp.content)
+ # 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}")
@@ -670,7 +677,12 @@ class Channel(ChannelPlugin, ABC):
def _should_process(self, raw: RawIncoming) -> bool:
"""Decide whether to process a message based on mention gating."""
- if not raw.is_group or self.require_mention == "off":
+ if self.require_mention == "off":
+ return True
+ if self.require_mention == "always":
+ return raw.was_mentioned
+ # "group" — require mention only in groups
+ if not raw.is_group:
return True
return raw.was_mentioned
diff --git a/EvoScientist/channels/capabilities.py b/EvoScientist/channels/capabilities.py
index df624e2..b125a22 100644
--- a/EvoScientist/channels/capabilities.py
+++ b/EvoScientist/channels/capabilities.py
@@ -199,7 +199,7 @@ SIGNAL = ChannelCapabilities(
EMAIL = ChannelCapabilities(
format_type="html",
- max_text_length=0, # no practical limit
+ max_text_length=999_999, # no practical limit
media_send=True,
media_receive=True,
html=True,
@@ -208,7 +208,7 @@ EMAIL = ChannelCapabilities(
IMESSAGE = ChannelCapabilities(
format_type="plain",
- max_text_length=0,
+ max_text_length=999_999,
typing=False, # Apple does not expose typing indicator API
media_send=True,
media_receive=True,
diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py
index 577614c..bc2ffb0 100644
--- a/EvoScientist/channels/channel_manager.py
+++ b/EvoScientist/channels/channel_manager.py
@@ -346,7 +346,10 @@ def build_inbound_pipeline(
))
if plugin.pairing is not None:
- middlewares.append(PairingMiddleware(channel_name=plugin.id))
+ middlewares.append(PairingMiddleware(
+ channel_name=plugin.id,
+ dm_policy=dm_policy,
+ ))
if caps.groups:
middlewares.append(GroupHistoryMiddleware())
diff --git a/EvoScientist/channels/consumer.py b/EvoScientist/channels/consumer.py
index a3ac15c..5fdfd28 100644
--- a/EvoScientist/channels/consumer.py
+++ b/EvoScientist/channels/consumer.py
@@ -158,7 +158,10 @@ class InboundConsumer:
# Evict oldest entry
oldest = next(iter(self._sessions))
del self._sessions[oldest]
- self._sessions[sender_id] = self.thread_id or str(uuid.uuid4())
+ 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:
@@ -361,7 +364,7 @@ class InboundConsumer:
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
- content=f"Error: {e}",
+ content="Sorry, something went wrong. Please try again later.",
metadata=msg.metadata,
))
finally:
diff --git a/EvoScientist/channels/middleware.py b/EvoScientist/channels/middleware.py
index 7bfbf40..9ab67bc 100644
--- a/EvoScientist/channels/middleware.py
+++ b/EvoScientist/channels/middleware.py
@@ -511,6 +511,12 @@ class FormattingMiddleware(OutboundMiddlewareBase):
"""Convert text to channel format."""
return self._formatter.format(text)
+ async def process_outbound(
+ self, message: OutboundMessage, context: dict[str, Any],
+ ) -> OutboundMessage | None:
+ formatted = self._formatter.format(message.content)
+ return dataclasses.replace(message, content=formatted)
+
# ── Retry ────────────────────────────────────────────────────────────
@@ -662,11 +668,13 @@ class MentionGatingMiddleware(InboundMiddleware):
return raw
def _should_process(self, raw: RawIncoming) -> bool:
- if not raw.is_group or self.require_mention == "off":
+ if self.require_mention == "off":
return True
if self.require_mention == "always":
return raw.was_mentioned
- # "group" — require mention in groups
+ # "group" — require mention only in groups
+ if not raw.is_group:
+ return True
return raw.was_mentioned
@@ -779,10 +787,12 @@ class PairingMiddleware(InboundMiddleware):
self,
channel_name: str,
send_response_fn: Callable[[str, str], Any] | None = None,
+ dm_policy: str = "allowlist",
) -> None:
self._manager = PairingManager()
self._channel_name = channel_name
self._send_response_fn = send_response_fn
+ self._dm_policy = dm_policy
async def process_inbound(
self, raw: RawIncoming, context: dict[str, Any],
@@ -790,8 +800,7 @@ class PairingMiddleware(InboundMiddleware):
if raw.is_group:
return raw # pairing only applies to DMs
- dm_policy = context.get("dm_policy", "allowlist")
- if dm_policy != "pairing":
+ if self._dm_policy != "pairing":
return raw
if self._manager.is_approved(self._channel_name, raw.sender_id):
diff --git a/channel_feature_matrix.xlsx b/channel_feature_matrix.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..8fa95ad80c02a6672d1e34e6f93a0d96c1e77f1d
GIT binary patch
literal 6840
zcmZ`;1z20#(hU+Q?ogZ-ZE+72r#J*CTD-WsOK~Yy2=49@pus7wrMMI;P^3VC;!ylg
z?|tvxclqy2a?VMR_Vc
z?BM9a{>IUf&C||SX-o;Dn*$SNwd0k0V_GCtA@)n*$n3T;Hn-4*XLcc12m8pP_Fi6t
z$mhEqsQh18R8_az{d6BG1#
zt+8fjnoZimZ7f;BqfStjp=V8-N3fQWx?+g}9e2x{UL=Y@K_G=0cps%UOr)0m{gcCu
zDX;^zg+%3VE;N7}I)1_*of#1T!253(OdOoee)}*2=>qp~V1^k2HdT~`#Fd_(K
zWfc*fq9`1qEEkwKtuM|C&2~dp5kUQSCzl^KP=dQfCSCTBq}&s$rB~eL-poZHxu^ip
za5j{lyW|g#=u?0Eb`$OM2u1XBc5Zb!ucxsI7I|ubly-y!KQWOs*U|vN@>&p1PHom3
z;y|6_X(zbrMYFKEC+oXzySn34jO-}RQoyBDX{
zhuiGGwe%KCe(Hdz`C`KGwuYb;t=Id(<8B(zsB*TAvxXLpV`;fdk>R;N*DPvb$QE*_r)$=KM|1zK*WT0*LVK0d(Bc
z;@l3SpD(ZhM&M%Qz6eVT*#}dH;%Ud&dYQ(D_;<+g;SMKb?L(AWGK7#>cCJUIWVW_J
zUMD}Jxm$rR33(F#AV>^PBT!Ho+>fODa))u0gHUkrrvH?Lueu=;q
zx$}Z4qDNd#rDh-L+z1
zysfq=+#$A8zcphl;l^cqBc~+ci;~2sV;c+F(KW5^n{Y<^7X;3N6c!S`W9R4b
zMf6-_2bbu(U?B@?&Ey*Wv9XQpXlL5iujkr+=gKnnv|q|PKRo@CC`~7oGsxPqfd{IW
z!{QB)?bOYO5oSn{%CL|CgP&PAzfH2Pg`yw8|AC-&=j4EpcTS+2$tel7^DIgjryEyzDe
zKMApFJ*0xt#imzv<1tH!7?bjmIoA^hHaL}q80p~mPk4eSVR$#MD|r({j0X2c;@w7P
zvcz7QOp
zYMw3xn*N%VmN-r!@PjyA(@oT;!{@zd+ornoXv6ALX8e)(*%IXTJ8Eb0NnbXh%}h-~
zx*I#tJXaBxu}dMtE!YSKw`8NU!jFc;_Id`8{AulHM=;ee<_ilU2xWsuE+y~vxxV`b
zZ4rlBiewX+L_3mQOV*MgC_6R28GY(ioI}s$PM;^)n!r4AvtO!~-PYX1#PwE}YE8j`
zP%`B}#Bu(UpLt=aV?C1LUOtAY7D@doJcM?(m2@o9jYdshj#vm+O^sM{QG=nXh{vxq
zj5u}sk(lH1eHEjnwjH-p^bFT0AnS|8X7%>aXC9<>PhSaiTcr|MRD4=CtL@UD9MjM}
zu4%Uxh7p0s?p{|7=3wH1i81hHO%h3p~NS)!Mh1<$5(h9a7v4_$iOu*1r!pfz_Ju
z-5YQaxtd!A%Dl}uEsBp9FoD=e6eMC
zsxMjn_WbMmm3_hsS;b7RK0E|hDMZ>N3KU`zt__u+kT?_qgptIdmxqW!ro|KisaF^u
zk1?B{5sg-f2>45}1c!kQCh74BBWo~$Pe`2C4-OKnJ)*x{u>{(MsB@XlkbIJS*CovD
zEIFr6e^R}`fDyX5rasb2*3Mw0(Bo~N#macUXfQ1dBN3K9DM(wT`Vj7t;oa0NRe7&H
zOLfV$BH*|vxQt}*oq$EM@NJi0WMlwdg>iceRQZxV^99W3D3B)OW_Gdg{Oc;F?{tMu
zkts8tr~;}lH3Y>ZTKAO4@I9e%RdH=q{{knbSAg~jW}Z1{C0r_j484z(P8%|zmxwBwJy$fdr^!g;kz16(q3yyS
z&{M}hh8P;!6Dt8p^1&yovVs>#G=~8pYzPLTCG#fj1QOT=YfM89VK1Itbi_(udNZ!k
z+|z%JDpaAGWWhE);#-V8_G1WA0`^Tr#0;FMQiKd0p0`}>xw!jeQJE4lthw+F;y6*5
zLYeW3#`q;oew}xvIJPEiq82Rf`qdB_hThoR3otLo#1`3hYtwn9G6X9MEn)iezdd8D
zL@tl$kUYxl@DXO}_;kVJ$Fg!bGU6!25(VyE-P{#HulR%l-B(Ead5?>841PH=;@B4!
zY{c5bc+^Z|AGV_QY^urHbi$N$aP31l%k2|L`CGhqPn#^~+9TN_%O#0)^k2OusNy@D
z#wsO0^_kbW=Uegd+)F-g{pf!?i{S3&8+ms}wXwz)bBD(7?7^*viAsKiOY!uQ-p^fX
zYU@K?n;Y5Y%u1IF<#2yb*Hs+Jh}CL7&?$BL9+iEh)*JP)8ln+a=M`cCO6n=F7>i>c
z<1?AjEJ-SVXB)fY3_!~-Pzs6V3UL@k`y3d3v=~o*Asvg_%BQ~%`DAQP6TG;~;zwm_
z=BsLOnSX30oNXWW=2h$!l#6+~W_6>A713+3_e_>JG>JN97OSaTTsmMSGO%tWNLze)
zM~?gR7pn%t};|YLFsyozYft
z_Z*rM<+w%0hzqpix7a$G_bqSk=4(csGAZA$@9jDHKB9HX2^USYNJyIn+um3e31)~T
zqNx(UoX6c1OTs--&s&^e$$82FqdCR$w_}4>r>bh>s!(+&fpVcWh4hD^%U1UudL2^fo)_e}uhA
z<#{t1B2w|>Zf+DUc`H;WPDl%YkwI~EM}S)-c^h`?}~?p3W2XBQ>P&kYV@
zLw9cT5rToK0j=h~DI2^n^Gea?`?qlr5H}xC@2!=zI02BA5T-gVE=~dzK=)?aMl$*m
zZMX0RlydPkBycj=buuMcQK!ywsF*%HSx;vTwlM*LCbrxz6#HZO-SM+spNWvXdrsYI
zZM0pERKO6c1F~K(4K84db*4CToDLta7FcYnabn=qOXDW6!4oMv-~&7PcbC2^AdX!C
z{a7uHDxi=NrZ{^7+a2RuvD+qCR<>%=ymk(Qf50(fxo7x3Tx%8CubHUBa34=RL0EiPrPZ4Tt`p(UR@;Wkbi*^HA
z1g3K1h$hIbwga0`D0%#Izli16V*kWR@cu2(
zq)f>W^xAulS-7ciiloqV5+bF*$0`8Zn8Yukf1m7IG_8+0$M88#_dcf|f4&ZQ$71oxEI}0R0yYWiG|aSp>{2
zT9{+0{M(xy-YVk~;^aU|LRZzeg4}RwIqK36Ha&cjqvi|Dv1d=E>hL~gL}8V;t;Rv*
z6o^^pVH@KR1%=FHA)Vu-SB*A{rtDLUK79=8h66`0*<;f|*567;#!cDLTbKq_$0fz#
z!KZ1P@KG^mPnd2R5izX9`*)0D$k>H3_#O$ud9if)bHoPuHiu82tr_%u8KDpODO=Q)e}lN1u`mV)cS;Fc;Z(GzhG`6WMBA=DpsY
z$~-17uEd9H3PaAt(n2ljBz7$y^|4bwk)Cme*BPYU5Zri#ybDq
zhtUnu#q0Zx=&xUaq};)`=SMxJ?z~xLqhb}smCpWwzKcZmnS1OVYA8y|4Rg%F42-&a
zN#c8^Q6HaMI@R?U5V^S3bs4;#A1d}=F5proaydTp)A+n+YH_>$wa4QVs+k{pon7ii
zu&fZ_=U*umYb*&fIPT>ugRX|c?@Qjo)X$t5gJK3h1xWbIs$HHJ`}U3Sb%@@_PaLRol)Xg9G=DIf9x4U)CxOBFoG?S!h>2?Kh{z
znA!{J`V7*TG!)HI3gBsH+s}VaUfOl;!akNYHs`ufN?C$qBcIgB>NWHErvBC00F7m`
zjSl2{aH&;Myu=`c>1$F&ZW%qj7m_?ZPWJ>8wtV+RCAer6=M&<=jh7!3Ax`(M{$p~GC}=baOBLseD=IT(IYyRR4Aye;nqZJ
zmqQD(*Z(cazfOV{G~BiMoDv0;rWd}0{F#M==JDK(fSW^k?4bZuC-|P2CkKq4Qdxs*!8p#x6mAVt13?HKKK0#U#!FB+QvDKYVDcxS(Y&?HyEw;
zNhHhZR)3%$YtB@VZPV36Aa{p@e}TYaq8y_e&X8r%)wvc(B6|I2BGj+;d5La%iJevs
z8a7z%95HlD_TJ1f+DMStg*M>LTMm@e*qQE|8|9;=+#_wwb@%m`_9|KA>I2rzannnw
z)Q#YLc8@|id?}}Bv+3+#?;jUxFRwkmTbkvt&kejpP=*fkiY$7Dw&|hHWwxt7(Ju6)
zH;!LxM(NGsAD6|yur+xrhLi!Ubnom~#F86oy}}r*x7!cvmnj(a9K>uzi&L719kjK1h?4{G4dsXWLo0MzeHsvwht<7h$?%0i~Gt-XQl~3wt@b=)o8p#^)w3=Z}_Cx%S98(8XXo9F*Z{3
z#*e1NB`$?p5qe*;ii8*0mBk$bpn{4{5>~Pb+}MXBlm1*6Cz!laoAR_tx@4YHn|JQ3
zauaz^wTaWEhNkio-0HXDCdDhK#D}g$CYr7Vf;+r0k=;1DV^UY@=6kJ357FjB-rYjtonxNFw0p#ujuE
zB6N9gILt~Bdgo2ki$C;|o?cmTTH6UU1L;&ALEf9n?BR`m^H>&l>kbgmNjy-z@W9X!
zINd%(H!D`Fl2FZs9n0Npa>eI3@gN191j>4DS7&;WGyrmWItn%X($0nJypP=sW
zd;1XxXVb9ZloII_5UnQRd74|3pN4}Oie>GMPlcnmRMs-@)o3+{h->om-B!)EFR9!l
zH%#92C2M$`3Qk>z51FtO*SnK8f?p6gE=rmq4=8sUX?OQprXDuGaD}OgccvauNu9PJrp5M(DhASHmm=^5IDz@
z(8E5r;e<}w2IZ6K@ZJHSP!|@-HoDHCY)w+R_BsruZStcvE`+H?e2&{R`)68yrqG?F
ztJ_yAU(jtG=n#jaO@F&g%Ji8$5!r@fw`Bug*Q>Q*#pL`_F4D;7$&NZSYeO`4R+@e_
z%3#=*7YYA`RQ1R@TJ;L1qo?cLgOX(y=LI2!J2(a}Tfdc4T}cOfS2KH8Lv=4lGZ+2e
zK~=N6M2Vdy&|m?)D~+$C7(OfGVR7vyvCi9(esJ(t_YvQsLT
z-{aM6XPwO@QmXnAB5I(5jVYgcR>PhM$latjJe99c(8awN-V9dhBJeC{@=4sC;f6Uo
zlzHxWkt-c4rF9y=7K-WqY%Nb(z$=gcrLSw3I`PNL(q@-&Ba5eO+A5J1A$pgTjz4@}
zh{;P!*2cdxC^AXft`ZZ}>GWP$`$Q_aNCp%v{kWb?W`bB&i$r6wJK@Ty#E{JqJ=QvZ
z(`Al6_(=&JPoNgre;|u?=&Ob)t*mJ=)bmN*+1|A{SU0#meq{X?(~5s1&{+HX*e{=c
zl6&&eBfRdP9ip{uvtTm+?@`}G4gBFuA|MhX{qJ1{IGumI0^t1q|2>C?=!XLTpI87O
z0HNo<(f^kFAHp9>;D5na;kW+>LHwbChZ4wt3oq46&{)YmC_#qA11}WfJ5-X`PY;GJ4HSOKb+-%fq9Al0RN9U|Ip6E
giS(BpGLnCsRw@cW_>KhtfChhyz|*
Date: Sun, 15 Feb 2026 18:01:53 +0800
Subject: [PATCH 08/12] reduce code complex
---
EvoScientist/channels/base.py | 152 ++++++++++++------
EvoScientist/channels/bus/events.py | 2 +
EvoScientist/channels/channel_manager.py | 75 +--------
EvoScientist/channels/imessage/channel_rpc.py | 41 +++--
botpy.log | 9 ++
tests/test_channel_comprehensive.py | 79 +++++++--
6 files changed, 213 insertions(+), 145 deletions(-)
diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py
index edd475b..6a77f46 100644
--- a/EvoScientist/channels/base.py
+++ b/EvoScientist/channels/base.py
@@ -9,7 +9,6 @@ import asyncio
import dataclasses
import logging
import re
-import time
from collections import defaultdict
from collections.abc import Awaitable, Callable as CallableABC
from dataclasses import dataclass, field
@@ -291,10 +290,6 @@ class Channel(ChannelPlugin, ABC):
self._message_ids: dict[str, str] = {}
self._debounce_tasks: dict[str, asyncio.Task] = {}
- # Deduplication
- from .middleware import DedupCache
- self._dedup = DedupCache()
-
# Mention gating: "always" | "group" | "off"
self.require_mention: str = getattr(config, "require_mention", "group")
@@ -312,9 +307,56 @@ class Channel(ChannelPlugin, ABC):
# Per-chat send locks to prevent message reordering
self._send_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
- # Group history buffer for context injection
- from .middleware import GroupHistoryBuffer
- self._group_history = GroupHistoryBuffer()
+ # 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:
@@ -721,9 +763,37 @@ class Channel(ChannelPlugin, ABC):
# ── Inbound message pipeline ──────────────────────────────────────
+ def _raw_to_inbound(self, raw: RawIncoming) -> InboundMessage | None:
+ """Convert a RawIncoming to InboundMessage (pure transformation, no filtering).
+
+ Merges text + annotations into content, sets metadata.
+ Returns None only if there is no content and no media.
+ """
+ parts = []
+ if raw.text:
+ parts.append(raw.text)
+ parts.extend(raw.content_annotations)
+ content = "\n".join(p for p in parts if p)
+ if not content and not raw.media_files:
+ return None
+ meta = dict(raw.metadata)
+ meta.setdefault("chat_id", raw.chat_id)
+ return InboundMessage(
+ channel=self.name, sender_id=raw.sender_id, chat_id=raw.chat_id,
+ content=content or "[media only]", timestamp=raw.timestamp,
+ message_id=raw.message_id, media=raw.media_files, metadata=meta,
+ is_group=raw.is_group, was_mentioned=raw.was_mentioned,
+ )
+
def _build_inbound(self, raw: RawIncoming) -> InboundMessage | None:
"""Build an ``InboundMessage`` from raw platform data.
+ .. deprecated::
+ This method is superseded by the middleware pipeline in
+ ``_enqueue_raw()``. It is kept for backward compatibility
+ with tests that call it directly. New code should use
+ ``_enqueue_raw()`` instead.
+
Performs mention gating, allow-list checks, and merges text +
annotations into a single content string. Returns ``None`` if
the message should be dropped (not mentioned, sender not
@@ -792,44 +862,28 @@ class Channel(ChannelPlugin, ABC):
)
async def _enqueue_raw(self, raw: RawIncoming) -> None:
- """Build an InboundMessage from *raw* and put it on the queue.
+ """Run *raw* through the inbound middleware pipeline, convert to
+ InboundMessage, and put it on the queue.
Convenience method for subclass ``_on_message`` handlers.
- Stores non-mentioned group messages in history buffer and injects
- context when the bot is mentioned.
"""
- from .middleware import HistoryEntry
-
- # Store all group messages in history buffer BEFORE mention gating
- if raw.is_group:
- ts = raw.timestamp.timestamp() if hasattr(raw.timestamp, 'timestamp') else time.time()
- if not raw.was_mentioned:
- # Store for context, _build_inbound will return None via _should_process
- self._group_history.add(raw.chat_id, HistoryEntry(
- sender_id=raw.sender_id,
- text=raw.text,
- timestamp=ts,
- message_id=raw.message_id,
- ))
- else:
- # Inject context before the current message
- context = self._group_history.format_context(raw.chat_id)
- if context:
- raw = dataclasses.replace(
- raw,
- text=context + "\n\n[Current message - respond to this]\n" + raw.text,
- )
- self._group_history.clear(raw.chat_id)
-
- msg = self._build_inbound(raw)
- if msg is not None:
- # Fire-and-forget ACK reaction
- if raw.message_id:
- try:
- await self._send_ack_reaction(raw.chat_id, raw.message_id)
- except Exception:
- pass
- await self._queue.put(msg)
+ context: dict = {"channel": self}
+ current: RawIncoming | None = raw
+ for mw in self._inbound_middlewares:
+ if current is None:
+ return
+ current = await mw.process_inbound(current, context)
+ if current is None:
+ return
+ msg = self._raw_to_inbound(current)
+ 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 ──────────────────────────────────────────────
@@ -838,22 +892,16 @@ class Channel(ChannelPlugin, ABC):
self._bus = bus
async def queue_message(self, msg: InboundMessage) -> None:
- """Buffer *msg* with debounce + dedup, then publish to bus."""
+ """Buffer *msg* with debounce, then publish to bus."""
sender = msg.sender_id
- # Deduplication
- mid = msg.message_id
- if mid and self._dedup.is_duplicate(mid):
- _logger.debug(f"Dedup: skipping duplicate message {mid}")
- return
-
if sender not in self._message_buffers:
self._message_buffers[sender] = []
self._message_metadata[sender] = msg.metadata
self._message_media[sender] = []
self._message_buffers[sender].append(msg.content)
- if mid:
- self._message_ids[sender] = mid
+ if msg.message_id:
+ self._message_ids[sender] = msg.message_id
if msg.media:
self._message_media[sender].extend(msg.media)
diff --git a/EvoScientist/channels/bus/events.py b/EvoScientist/channels/bus/events.py
index 8e94727..411006f 100644
--- a/EvoScientist/channels/bus/events.py
+++ b/EvoScientist/channels/bus/events.py
@@ -21,6 +21,8 @@ class InboundMessage:
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:
diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py
index bc2ffb0..2a1dd7f 100644
--- a/EvoScientist/channels/channel_manager.py
+++ b/EvoScientist/channels/channel_manager.py
@@ -248,17 +248,6 @@ class AccountManager:
from .middleware import (
InboundMiddleware,
OutboundMiddlewareBase,
- DedupMiddleware,
- AllowListMiddleware,
- MentionGatingMiddleware,
- GroupHistoryMiddleware,
- DebounceMiddleware,
- AckReactionMiddleware,
- FormattingMiddleware,
- ChunkingMiddleware,
- RetryMiddleware,
- TypingMiddleware,
- PairingMiddleware,
)
@@ -314,67 +303,16 @@ class OutboundPipeline:
return current
-def build_inbound_pipeline(
- plugin: ChannelPlugin,
- config: Any,
-) -> InboundPipeline:
- """Auto-assemble inbound pipeline based on plugin 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
- """
- caps = plugin.capabilities
- middlewares: list[InboundMiddleware] = []
-
- middlewares.append(DedupMiddleware())
-
- allowed_senders = getattr(config, "allowed_senders", None)
- allowed_channels = getattr(config, "allowed_channels", None)
- dm_policy = getattr(config, "dm_policy", "allowlist")
- if allowed_senders:
- allowed_senders = set(allowed_senders) if not isinstance(allowed_senders, set) else allowed_senders
- if allowed_channels:
- allowed_channels = set(allowed_channels) if not isinstance(allowed_channels, set) else allowed_channels
- middlewares.append(AllowListMiddleware(
- allowed_senders=allowed_senders,
- allowed_channels=allowed_channels,
- dm_policy=dm_policy,
- ))
-
- if plugin.pairing is not None:
- middlewares.append(PairingMiddleware(
- channel_name=plugin.id,
- dm_policy=dm_policy,
- ))
-
- if caps.groups:
- middlewares.append(GroupHistoryMiddleware())
-
- if caps.mentions:
- strip_fn = None
- if plugin.mentions is not None:
- strip_fn = lambda text, _adapter=plugin.mentions: _adapter.strip_mentions(text, {}) # noqa: E731
- require_mention = getattr(config, "require_mention", "group")
- middlewares.append(MentionGatingMiddleware(
- require_mention=require_mention,
- strip_fn=strip_fn,
- ))
-
- return InboundPipeline(plugin, middlewares)
-
-
def build_outbound_pipeline(
plugin: ChannelPlugin,
config: Any,
) -> OutboundPipeline:
- """Auto-assemble outbound pipeline based on plugin capabilities."""
- caps = plugin.capabilities
+ """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] = []
- middlewares.append(FormattingMiddleware(caps))
return OutboundPipeline(plugin, middlewares)
@@ -672,7 +610,6 @@ class ChannelManager:
self._health_providers: dict[str, Callable[[], dict]] = {}
self._account_manager = AccountManager()
# Pipelines (built during registration)
- self._inbound_pipelines: dict[str, InboundPipeline] = {}
self._outbound_pipelines: dict[str, OutboundPipeline] = {}
# Shared webhook
self._shared_webhook_port = shared_webhook_port
@@ -742,7 +679,6 @@ class ChannelManager:
if channel.config_adapter is not None:
self._account_manager.register_plugin(channel)
if config is not None:
- self._inbound_pipelines[name] = build_inbound_pipeline(channel, config)
self._outbound_pipelines[name] = build_outbound_pipeline(channel, config)
logger.info(f"Registered channel: {name} (slots: {channel.filled_slots()})")
return channel
@@ -1086,7 +1022,6 @@ class ChannelManager:
"total_successes": health.total_successes,
},
"plugin_slots": channel.filled_slots(),
- "has_inbound_pipeline": name in self._inbound_pipelines,
"has_outbound_pipeline": name in self._outbound_pipelines,
}
return result
diff --git a/EvoScientist/channels/imessage/channel_rpc.py b/EvoScientist/channels/imessage/channel_rpc.py
index 2b2f094..ebf32d6 100644
--- a/EvoScientist/channels/imessage/channel_rpc.py
+++ b/EvoScientist/channels/imessage/channel_rpc.py
@@ -24,6 +24,25 @@ from .targets import (
logger = logging.getLogger(__name__)
+class _IMessageAllowListMiddleware:
+ """Custom allow-list middleware for iMessage's rich sender filtering.
+
+ Supports chat_id/chat_guid matching, wildcard, and normalized
+ phone/email matching — logic that the generic AllowListMiddleware
+ does not cover.
+ """
+
+ def __init__(self, channel: 'IMessageChannelRpc'):
+ self._channel = channel
+
+ async def process_inbound(self, raw, context):
+ chat_id = raw.metadata.get("chat_id")
+ chat_guid = raw.metadata.get("chat_guid")
+ if not self._channel._is_sender_allowed(raw.sender_id, chat_id, chat_guid):
+ return None
+ return raw
+
+
@dataclass
class IMessageConfig(BaseChannelConfig):
"""Configuration for iMessage channel."""
@@ -55,18 +74,18 @@ class IMessageChannelRpc(Channel):
# ── Pipeline overrides ────────────────────────────────────────
- def is_allowed(self, sender: str) -> bool:
- # Rich filtering is handled in _build_inbound via _is_sender_allowed
- return True
+ def _build_inbound_middlewares(self):
+ """Use iMessage-specific allow-list middleware.
- def _build_inbound(self, raw):
- """Override to apply iMessage's rich sender filtering."""
- chat_id = raw.metadata.get("chat_id")
- chat_guid = raw.metadata.get("chat_guid")
- if not self._is_sender_allowed(raw.sender_id, chat_id, chat_guid):
- logger.debug(f"Ignoring message from {raw.sender_id}")
- return None
- return super()._build_inbound(raw)
+ iMessage doesn't need MentionGating (always sets was_mentioned=True).
+ """
+ from ..middleware import DedupMiddleware, GroupHistoryMiddleware
+ middlewares = []
+ middlewares.append(DedupMiddleware())
+ middlewares.append(_IMessageAllowListMiddleware(self))
+ if self.capabilities.groups:
+ middlewares.append(GroupHistoryMiddleware())
+ return middlewares
# ── Incoming message handling ─────────────────────────────────
diff --git a/botpy.log b/botpy.log
index b43c983..eeddecd 100644
--- a/botpy.log
+++ b/botpy.log
@@ -16,3 +16,12 @@
2026-02-15 13:57:49,073 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
2026-02-15 13:57:49,294 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
2026-02-15 13:57:49,295 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 18:01:03,252 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
+2026-02-15 18:01:03,471 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 2348
+2026-02-15 18:01:03,935 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
+2026-02-15 18:01:03,935 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 18:01:03,935 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 18:01:03,935 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 18:01:04,118 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
+2026-02-15 18:01:04,298 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
+2026-02-15 18:01:04,299 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
diff --git a/tests/test_channel_comprehensive.py b/tests/test_channel_comprehensive.py
index ee45860..6b0e9d9 100644
--- a/tests/test_channel_comprehensive.py
+++ b/tests/test_channel_comprehensive.py
@@ -64,6 +64,7 @@ class _FakeConfig:
allowed_channels: list | None = None
proxy: str | None = None
require_mention: str = "group"
+ dm_policy: str = "allowlist"
class StubChannel(Channel):
@@ -618,6 +619,64 @@ class TestChannelBuildInbound:
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):
@@ -670,23 +729,19 @@ class TestChannelDebounce:
_run(_test())
def test_dedup_skips_duplicate(self):
+ """Dedup is now handled in _enqueue_raw pipeline, not queue_message."""
async def _test():
- bus = MessageBus()
ch = StubChannel()
- ch.set_bus(bus)
- ch.initial_debounce = 0.05
- msg = InboundMessage(
- channel="stub", sender_id="u1", chat_id="c1",
- content="hello", message_id="m1",
- metadata={"chat_id": "c1"},
+ raw = RawIncoming(
+ sender_id="u1", chat_id="c1", text="hello",
+ message_id="m1",
)
- await ch.queue_message(msg)
- await ch.queue_message(msg) # duplicate
- await asyncio.sleep(0.2)
+ await ch._enqueue_raw(raw)
+ await ch._enqueue_raw(raw) # duplicate
- # Only one should be processed (dedup catches second)
- assert bus.inbound.qsize() == 1
+ # Only one should be enqueued (dedup catches second)
+ assert ch._queue.qsize() == 1
_run(_test())
def test_debounce_metadata_from_first_message(self):
From 919aa15c5ec129f3e5375c1bf2bf61e2573ea144 Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 18:07:41 +0800
Subject: [PATCH 09/12] stable
---
EvoScientist/channels/base.py | 94 +++---------------------
EvoScientist/channels/channel_manager.py | 27 -------
botpy.log | 9 +++
3 files changed, 18 insertions(+), 112 deletions(-)
diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py
index 6a77f46..25e59b6 100644
--- a/EvoScientist/channels/base.py
+++ b/EvoScientist/channels/base.py
@@ -6,7 +6,6 @@ This module defines the Channel interface that all messaging channels
from abc import ABC, abstractmethod
import asyncio
-import dataclasses
import logging
import re
from collections import defaultdict
@@ -296,9 +295,9 @@ class Channel(ChannelPlugin, ABC):
# DM policy: "open" | "allowlist" | "pairing"
self.dm_policy: str = getattr(config, "dm_policy", "allowlist")
- # Shared pairing manager for DM pairing mode
- from .middleware import PairingManager
- self._pairing_manager = PairingManager()
+ # 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
@@ -712,11 +711,6 @@ class Channel(ChannelPlugin, ABC):
# ── Mention gating ──────────────────────────────────────────────
- async def _send_pairing_response(self, chat_id: str, code: str):
- """Send pairing code to the sender."""
- text = f"🔐 Pairing required. Your code: {code}\nThis code expires in 1 hour."
- await self._send_chunk(chat_id, text, text, None, {})
-
def _should_process(self, raw: RawIncoming) -> bool:
"""Decide whether to process a message based on mention gating."""
if self.require_mention == "off":
@@ -785,82 +779,6 @@ class Channel(ChannelPlugin, ABC):
is_group=raw.is_group, was_mentioned=raw.was_mentioned,
)
- def _build_inbound(self, raw: RawIncoming) -> InboundMessage | None:
- """Build an ``InboundMessage`` from raw platform data.
-
- .. deprecated::
- This method is superseded by the middleware pipeline in
- ``_enqueue_raw()``. It is kept for backward compatibility
- with tests that call it directly. New code should use
- ``_enqueue_raw()`` instead.
-
- Performs mention gating, allow-list checks, and merges text +
- annotations into a single content string. Returns ``None`` if
- the message should be dropped (not mentioned, sender not
- allowed, no content, etc.).
- """
- if not self._should_process(raw):
- return None
-
- # DM policy handling for non-group messages
- if not raw.is_group:
- if self.dm_policy == "open":
- pass # skip allowlist check for DMs
- elif self.dm_policy == "pairing":
- if not self.is_allowed(raw.sender_id) and not self._pairing_manager.is_approved(self.name, raw.sender_id):
- code = self._pairing_manager.request_pairing(self.name, raw.sender_id)
- # Schedule pairing response (fire-and-forget)
- asyncio.ensure_future(self._send_pairing_response(raw.chat_id, code))
- _logger.info(f"Pairing required for {raw.sender_id}, code sent")
- return None
- else: # "allowlist" (default)
- if not self.is_allowed(raw.sender_id):
- _logger.debug(
- f"Ignoring message from non-allowed sender {raw.sender_id}"
- )
- return None
- elif not self.is_allowed(raw.sender_id):
- _logger.debug(
- f"Ignoring message from non-allowed sender {raw.sender_id}"
- )
- return None
-
- # Check channel allow-list
- if not self.is_channel_allowed(raw.chat_id):
- _logger.debug(
- f"Ignoring message from non-allowed channel {raw.chat_id}"
- )
- return None
-
- # Apply mention stripping in group messages
- if raw.is_group:
- raw = dataclasses.replace(raw, text=self._strip_mention(raw.text))
-
- # Merge text and annotations into content
- parts: list[str] = []
- 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
-
- # Ensure chat_id is in metadata
- 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,
- )
-
async def _enqueue_raw(self, raw: RawIncoming) -> None:
"""Run *raw* through the inbound middleware pipeline, convert to
InboundMessage, and put it on the queue.
@@ -899,6 +817,8 @@ class Channel(ChannelPlugin, ABC):
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
@@ -940,6 +860,8 @@ class Channel(ChannelPlugin, ABC):
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
@@ -959,6 +881,8 @@ class Channel(ChannelPlugin, ABC):
media=media,
metadata=metadata or {},
message_id=message_id,
+ is_group=is_group,
+ was_mentioned=was_mentioned,
)
await self._bus.publish_inbound(inbound)
diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py
index 2a1dd7f..30d3449 100644
--- a/EvoScientist/channels/channel_manager.py
+++ b/EvoScientist/channels/channel_manager.py
@@ -246,37 +246,10 @@ class AccountManager:
# ═════════════════════════════════════════════════════════════════════
from .middleware import (
- InboundMiddleware,
OutboundMiddlewareBase,
)
-class InboundPipeline:
- """Processes incoming messages through a middleware chain."""
-
- def __init__(
- self,
- plugin: ChannelPlugin,
- middlewares: list[InboundMiddleware],
- ) -> None:
- self.plugin = plugin
- self.middlewares = middlewares
-
- async def process(
- self,
- raw: RawIncoming,
- context: dict[str, Any] | None = None,
- ) -> RawIncoming | None:
- """Run *raw* through each middleware. Returns ``None`` if dropped."""
- ctx = context or {}
- current: RawIncoming | None = raw
- for mw in self.middlewares:
- if current is None:
- return None
- current = await mw.process_inbound(current, ctx)
- return current
-
-
class OutboundPipeline:
"""Processes outgoing messages through a middleware chain."""
diff --git a/botpy.log b/botpy.log
index eeddecd..6ebafc0 100644
--- a/botpy.log
+++ b/botpy.log
@@ -25,3 +25,12 @@
2026-02-15 18:01:04,118 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
2026-02-15 18:01:04,298 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
2026-02-15 18:01:04,299 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
+2026-02-15 18:03:43,137 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
+2026-02-15 18:03:43,319 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 2188
+2026-02-15 18:03:43,860 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
+2026-02-15 18:03:43,860 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
+2026-02-15 18:03:43,861 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
+2026-02-15 18:03:43,861 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
+2026-02-15 18:03:44,033 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
+2026-02-15 18:03:44,174 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
+2026-02-15 18:03:44,174 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
From 01018c0d248b54d402db04eec7ae62d966b5b9c4 Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 18:08:57 +0800
Subject: [PATCH 10/12] stable
---
botpy.log | 36 --------
channel_feature_matrix.xlsx | Bin 6840 -> 0 bytes
tests/test_channel_comprehensive.py | 32 ++++---
tests/test_mention_gating.py | 125 ++++++++++++++++------------
4 files changed, 92 insertions(+), 101 deletions(-)
delete mode 100644 botpy.log
delete mode 100644 channel_feature_matrix.xlsx
diff --git a/botpy.log b/botpy.log
deleted file mode 100644
index 6ebafc0..0000000
--- a/botpy.log
+++ /dev/null
@@ -1,36 +0,0 @@
-2026-02-15 13:46:42,249 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
-2026-02-15 13:46:42,535 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 7200
-2026-02-15 13:46:43,622 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
-2026-02-15 13:46:43,623 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 13:46:43,623 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 13:46:43,623 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 13:46:43,792 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
-2026-02-15 13:46:43,915 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
-2026-02-15 13:46:43,916 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 13:57:47,578 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
-2026-02-15 13:57:47,775 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 6535
-2026-02-15 13:57:48,887 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
-2026-02-15 13:57:48,888 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 13:57:48,888 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 13:57:48,888 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 13:57:49,073 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
-2026-02-15 13:57:49,294 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
-2026-02-15 13:57:49,295 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 18:01:03,252 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
-2026-02-15 18:01:03,471 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 2348
-2026-02-15 18:01:03,935 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
-2026-02-15 18:01:03,935 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 18:01:03,935 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 18:01:03,935 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 18:01:04,118 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
-2026-02-15 18:01:04,298 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
-2026-02-15 18:01:04,299 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
-2026-02-15 18:03:43,137 [INFO] (client.py:162)_bot_login [botpy] 登录机器人账号中...
-2026-02-15 18:03:43,319 [INFO] (robot.py:65)update_access_token [botpy] access_token expires_in 2188
-2026-02-15 18:03:43,860 [INFO] (client.py:181)_bot_init [botpy] 程序启动...
-2026-02-15 18:03:43,860 [INFO] (connection.py:60)multi_run [botpy] 最大并发连接数: 1, 启动会话数: 1
-2026-02-15 18:03:43,861 [INFO] (client.py:242)bot_connect [botpy] 会话启动中...
-2026-02-15 18:03:43,861 [INFO] (gateway.py:115)ws_connect [botpy] 启动中...
-2026-02-15 18:03:44,033 [INFO] (gateway.py:142)ws_identify [botpy] 鉴权中...
-2026-02-15 18:03:44,174 [INFO] (gateway.py:85)on_message [botpy] 机器人「EvoSci-测试中」启动成功!
-2026-02-15 18:03:44,174 [INFO] (gateway.py:223)_send_heart [botpy] 心跳维持启动...
diff --git a/channel_feature_matrix.xlsx b/channel_feature_matrix.xlsx
deleted file mode 100644
index 8fa95ad80c02a6672d1e34e6f93a0d96c1e77f1d..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 6840
zcmZ`;1z20#(hU+Q?ogZ-ZE+72r#J*CTD-WsOK~Yy2=49@pus7wrMMI;P^3VC;!ylg
z?|tvxclqy2a?VMR_Vc
z?BM9a{>IUf&C||SX-o;Dn*$SNwd0k0V_GCtA@)n*$n3T;Hn-4*XLcc12m8pP_Fi6t
z$mhEqsQh18R8_az{d6BG1#
zt+8fjnoZimZ7f;BqfStjp=V8-N3fQWx?+g}9e2x{UL=Y@K_G=0cps%UOr)0m{gcCu
zDX;^zg+%3VE;N7}I)1_*of#1T!253(OdOoee)}*2=>qp~V1^k2HdT~`#Fd_(K
zWfc*fq9`1qEEkwKtuM|C&2~dp5kUQSCzl^KP=dQfCSCTBq}&s$rB~eL-poZHxu^ip
za5j{lyW|g#=u?0Eb`$OM2u1XBc5Zb!ucxsI7I|ubly-y!KQWOs*U|vN@>&p1PHom3
z;y|6_X(zbrMYFKEC+oXzySn34jO-}RQoyBDX{
zhuiGGwe%KCe(Hdz`C`KGwuYb;t=Id(<8B(zsB*TAvxXLpV`;fdk>R;N*DPvb$QE*_r)$=KM|1zK*WT0*LVK0d(Bc
z;@l3SpD(ZhM&M%Qz6eVT*#}dH;%Ud&dYQ(D_;<+g;SMKb?L(AWGK7#>cCJUIWVW_J
zUMD}Jxm$rR33(F#AV>^PBT!Ho+>fODa))u0gHUkrrvH?Lueu=;q
zx$}Z4qDNd#rDh-L+z1
zysfq=+#$A8zcphl;l^cqBc~+ci;~2sV;c+F(KW5^n{Y<^7X;3N6c!S`W9R4b
zMf6-_2bbu(U?B@?&Ey*Wv9XQpXlL5iujkr+=gKnnv|q|PKRo@CC`~7oGsxPqfd{IW
z!{QB)?bOYO5oSn{%CL|CgP&PAzfH2Pg`yw8|AC-&=j4EpcTS+2$tel7^DIgjryEyzDe
zKMApFJ*0xt#imzv<1tH!7?bjmIoA^hHaL}q80p~mPk4eSVR$#MD|r({j0X2c;@w7P
zvcz7QOp
zYMw3xn*N%VmN-r!@PjyA(@oT;!{@zd+ornoXv6ALX8e)(*%IXTJ8Eb0NnbXh%}h-~
zx*I#tJXaBxu}dMtE!YSKw`8NU!jFc;_Id`8{AulHM=;ee<_ilU2xWsuE+y~vxxV`b
zZ4rlBiewX+L_3mQOV*MgC_6R28GY(ioI}s$PM;^)n!r4AvtO!~-PYX1#PwE}YE8j`
zP%`B}#Bu(UpLt=aV?C1LUOtAY7D@doJcM?(m2@o9jYdshj#vm+O^sM{QG=nXh{vxq
zj5u}sk(lH1eHEjnwjH-p^bFT0AnS|8X7%>aXC9<>PhSaiTcr|MRD4=CtL@UD9MjM}
zu4%Uxh7p0s?p{|7=3wH1i81hHO%h3p~NS)!Mh1<$5(h9a7v4_$iOu*1r!pfz_Ju
z-5YQaxtd!A%Dl}uEsBp9FoD=e6eMC
zsxMjn_WbMmm3_hsS;b7RK0E|hDMZ>N3KU`zt__u+kT?_qgptIdmxqW!ro|KisaF^u
zk1?B{5sg-f2>45}1c!kQCh74BBWo~$Pe`2C4-OKnJ)*x{u>{(MsB@XlkbIJS*CovD
zEIFr6e^R}`fDyX5rasb2*3Mw0(Bo~N#macUXfQ1dBN3K9DM(wT`Vj7t;oa0NRe7&H
zOLfV$BH*|vxQt}*oq$EM@NJi0WMlwdg>iceRQZxV^99W3D3B)OW_Gdg{Oc;F?{tMu
zkts8tr~;}lH3Y>ZTKAO4@I9e%RdH=q{{knbSAg~jW}Z1{C0r_j484z(P8%|zmxwBwJy$fdr^!g;kz16(q3yyS
z&{M}hh8P;!6Dt8p^1&yovVs>#G=~8pYzPLTCG#fj1QOT=YfM89VK1Itbi_(udNZ!k
z+|z%JDpaAGWWhE);#-V8_G1WA0`^Tr#0;FMQiKd0p0`}>xw!jeQJE4lthw+F;y6*5
zLYeW3#`q;oew}xvIJPEiq82Rf`qdB_hThoR3otLo#1`3hYtwn9G6X9MEn)iezdd8D
zL@tl$kUYxl@DXO}_;kVJ$Fg!bGU6!25(VyE-P{#HulR%l-B(Ead5?>841PH=;@B4!
zY{c5bc+^Z|AGV_QY^urHbi$N$aP31l%k2|L`CGhqPn#^~+9TN_%O#0)^k2OusNy@D
z#wsO0^_kbW=Uegd+)F-g{pf!?i{S3&8+ms}wXwz)bBD(7?7^*viAsKiOY!uQ-p^fX
zYU@K?n;Y5Y%u1IF<#2yb*Hs+Jh}CL7&?$BL9+iEh)*JP)8ln+a=M`cCO6n=F7>i>c
z<1?AjEJ-SVXB)fY3_!~-Pzs6V3UL@k`y3d3v=~o*Asvg_%BQ~%`DAQP6TG;~;zwm_
z=BsLOnSX30oNXWW=2h$!l#6+~W_6>A713+3_e_>JG>JN97OSaTTsmMSGO%tWNLze)
zM~?gR7pn%t};|YLFsyozYft
z_Z*rM<+w%0hzqpix7a$G_bqSk=4(csGAZA$@9jDHKB9HX2^USYNJyIn+um3e31)~T
zqNx(UoX6c1OTs--&s&^e$$82FqdCR$w_}4>r>bh>s!(+&fpVcWh4hD^%U1UudL2^fo)_e}uhA
z<#{t1B2w|>Zf+DUc`H;WPDl%YkwI~EM}S)-c^h`?}~?p3W2XBQ>P&kYV@
zLw9cT5rToK0j=h~DI2^n^Gea?`?qlr5H}xC@2!=zI02BA5T-gVE=~dzK=)?aMl$*m
zZMX0RlydPkBycj=buuMcQK!ywsF*%HSx;vTwlM*LCbrxz6#HZO-SM+spNWvXdrsYI
zZM0pERKO6c1F~K(4K84db*4CToDLta7FcYnabn=qOXDW6!4oMv-~&7PcbC2^AdX!C
z{a7uHDxi=NrZ{^7+a2RuvD+qCR<>%=ymk(Qf50(fxo7x3Tx%8CubHUBa34=RL0EiPrPZ4Tt`p(UR@;Wkbi*^HA
z1g3K1h$hIbwga0`D0%#Izli16V*kWR@cu2(
zq)f>W^xAulS-7ciiloqV5+bF*$0`8Zn8Yukf1m7IG_8+0$M88#_dcf|f4&ZQ$71oxEI}0R0yYWiG|aSp>{2
zT9{+0{M(xy-YVk~;^aU|LRZzeg4}RwIqK36Ha&cjqvi|Dv1d=E>hL~gL}8V;t;Rv*
z6o^^pVH@KR1%=FHA)Vu-SB*A{rtDLUK79=8h66`0*<;f|*567;#!cDLTbKq_$0fz#
z!KZ1P@KG^mPnd2R5izX9`*)0D$k>H3_#O$ud9if)bHoPuHiu82tr_%u8KDpODO=Q)e}lN1u`mV)cS;Fc;Z(GzhG`6WMBA=DpsY
z$~-17uEd9H3PaAt(n2ljBz7$y^|4bwk)Cme*BPYU5Zri#ybDq
zhtUnu#q0Zx=&xUaq};)`=SMxJ?z~xLqhb}smCpWwzKcZmnS1OVYA8y|4Rg%F42-&a
zN#c8^Q6HaMI@R?U5V^S3bs4;#A1d}=F5proaydTp)A+n+YH_>$wa4QVs+k{pon7ii
zu&fZ_=U*umYb*&fIPT>ugRX|c?@Qjo)X$t5gJK3h1xWbIs$HHJ`}U3Sb%@@_PaLRol)Xg9G=DIf9x4U)CxOBFoG?S!h>2?Kh{z
znA!{J`V7*TG!)HI3gBsH+s}VaUfOl;!akNYHs`ufN?C$qBcIgB>NWHErvBC00F7m`
zjSl2{aH&;Myu=`c>1$F&ZW%qj7m_?ZPWJ>8wtV+RCAer6=M&<=jh7!3Ax`(M{$p~GC}=baOBLseD=IT(IYyRR4Aye;nqZJ
zmqQD(*Z(cazfOV{G~BiMoDv0;rWd}0{F#M==JDK(fSW^k?4bZuC-|P2CkKq4Qdxs*!8p#x6mAVt13?HKKK0#U#!FB+QvDKYVDcxS(Y&?HyEw;
zNhHhZR)3%$YtB@VZPV36Aa{p@e}TYaq8y_e&X8r%)wvc(B6|I2BGj+;d5La%iJevs
z8a7z%95HlD_TJ1f+DMStg*M>LTMm@e*qQE|8|9;=+#_wwb@%m`_9|KA>I2rzannnw
z)Q#YLc8@|id?}}Bv+3+#?;jUxFRwkmTbkvt&kejpP=*fkiY$7Dw&|hHWwxt7(Ju6)
zH;!LxM(NGsAD6|yur+xrhLi!Ubnom~#F86oy}}r*x7!cvmnj(a9K>uzi&L719kjK1h?4{G4dsXWLo0MzeHsvwht<7h$?%0i~Gt-XQl~3wt@b=)o8p#^)w3=Z}_Cx%S98(8XXo9F*Z{3
z#*e1NB`$?p5qe*;ii8*0mBk$bpn{4{5>~Pb+}MXBlm1*6Cz!laoAR_tx@4YHn|JQ3
zauaz^wTaWEhNkio-0HXDCdDhK#D}g$CYr7Vf;+r0k=;1DV^UY@=6kJ357FjB-rYjtonxNFw0p#ujuE
zB6N9gILt~Bdgo2ki$C;|o?cmTTH6UU1L;&ALEf9n?BR`m^H>&l>kbgmNjy-z@W9X!
zINd%(H!D`Fl2FZs9n0Npa>eI3@gN191j>4DS7&;WGyrmWItn%X($0nJypP=sW
zd;1XxXVb9ZloII_5UnQRd74|3pN4}Oie>GMPlcnmRMs-@)o3+{h->om-B!)EFR9!l
zH%#92C2M$`3Qk>z51FtO*SnK8f?p6gE=rmq4=8sUX?OQprXDuGaD}OgccvauNu9PJrp5M(DhASHmm=^5IDz@
z(8E5r;e<}w2IZ6K@ZJHSP!|@-HoDHCY)w+R_BsruZStcvE`+H?e2&{R`)68yrqG?F
ztJ_yAU(jtG=n#jaO@F&g%Ji8$5!r@fw`Bug*Q>Q*#pL`_F4D;7$&NZSYeO`4R+@e_
z%3#=*7YYA`RQ1R@TJ;L1qo?cLgOX(y=LI2!J2(a}Tfdc4T}cOfS2KH8Lv=4lGZ+2e
zK~=N6M2Vdy&|m?)D~+$C7(OfGVR7vyvCi9(esJ(t_YvQsLT
z-{aM6XPwO@QmXnAB5I(5jVYgcR>PhM$latjJe99c(8awN-V9dhBJeC{@=4sC;f6Uo
zlzHxWkt-c4rF9y=7K-WqY%Nb(z$=gcrLSw3I`PNL(q@-&Ba5eO+A5J1A$pgTjz4@}
zh{;P!*2cdxC^AXft`ZZ}>GWP$`$Q_aNCp%v{kWb?W`bB&i$r6wJK@Ty#E{JqJ=QvZ
z(`Al6_(=&JPoNgre;|u?=&Ob)t*mJ=)bmN*+1|A{SU0#meq{X?(~5s1&{+HX*e{=c
zl6&&eBfRdP9ip{uvtTm+?@`}G4gBFuA|MhX{qJ1{IGumI0^t1q|2>C?=!XLTpI87O
z0HNo<(f^kFAHp9>;D5na;kW+>LHwbChZ4wt3oq46&{)YmC_#qA11}WfJ5-X`PY;GJ4HSOKb+-%fq9Al0RN9U|Ip6E
giS(BpGLnCsRw@cW_>KhtfChhyz|*
Date: Sun, 15 Feb 2026 20:57:21 +0800
Subject: [PATCH 11/12] pass linter and pytest
---
EvoScientist/channels/base.py | 51 ++++++++++++++++++-----
EvoScientist/channels/telegram/channel.py | 8 ++--
tests/test_wechat_channel.py | 17 +++++++-
3 files changed, 59 insertions(+), 17 deletions(-)
diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py
index 25e59b6..fbe938d 100644
--- a/EvoScientist/channels/base.py
+++ b/EvoScientist/channels/base.py
@@ -715,11 +715,12 @@ class Channel(ChannelPlugin, ABC):
"""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
- if not raw.is_group:
- return True
return raw.was_mentioned
_mention_pattern: str | None = None
@@ -757,6 +758,42 @@ class Channel(ChannelPlugin, ABC):
# ── Inbound message pipeline ──────────────────────────────────────
+ async def _build_inbound_async(self, raw: RawIncoming) -> InboundMessage | None:
+ """Async version: run *raw* through inbound middlewares and convert."""
+ context: dict = {"channel": self}
+ current: RawIncoming | None = raw
+ for mw in self._inbound_middlewares:
+ if current is None:
+ return None
+ current = await mw.process_inbound(current, context)
+ if current is None:
+ return None
+ return self._raw_to_inbound(current)
+
+ def _build_inbound(self, raw: RawIncoming) -> InboundMessage | None:
+ """Run *raw* through inbound middlewares and convert to InboundMessage.
+
+ Synchronous wrapper around :meth:`_build_inbound_async`. Safe to
+ call from both sync and async contexts.
+ """
+ import asyncio
+ import concurrent.futures
+
+ try:
+ asyncio.get_running_loop()
+ # Inside a running loop — run in a worker thread
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
+ return pool.submit(
+ lambda: asyncio.run(self._build_inbound_async(raw))
+ ).result()
+ except RuntimeError:
+ # No running loop — safe to create one
+ loop = asyncio.new_event_loop()
+ try:
+ return loop.run_until_complete(self._build_inbound_async(raw))
+ finally:
+ loop.close()
+
def _raw_to_inbound(self, raw: RawIncoming) -> InboundMessage | None:
"""Convert a RawIncoming to InboundMessage (pure transformation, no filtering).
@@ -785,15 +822,7 @@ class Channel(ChannelPlugin, ABC):
Convenience method for subclass ``_on_message`` handlers.
"""
- context: dict = {"channel": self}
- current: RawIncoming | None = raw
- for mw in self._inbound_middlewares:
- if current is None:
- return
- current = await mw.process_inbound(current, context)
- if current is None:
- return
- msg = self._raw_to_inbound(current)
+ msg = self._build_inbound(raw)
if msg is None:
return
if raw.message_id:
diff --git a/EvoScientist/channels/telegram/channel.py b/EvoScientist/channels/telegram/channel.py
index 173aa36..7731c46 100644
--- a/EvoScientist/channels/telegram/channel.py
+++ b/EvoScientist/channels/telegram/channel.py
@@ -1,8 +1,6 @@
"""Telegram channel implementation using python-telegram-bot."""
-import asyncio
import logging
-import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@@ -37,6 +35,9 @@ class TelegramChannel(Channel):
self._bot_username: str = ""
async def start(self) -> None:
+ if not self.config.bot_token:
+ raise ChannelError("Telegram bot token is required")
+
try:
from telegram.ext import (
ApplicationBuilder,
@@ -49,9 +50,6 @@ class TelegramChannel(Channel):
"Install with: pip install evoscientist[telegram]"
)
- if not self.config.bot_token:
- raise ChannelError("Telegram bot token is required")
-
builder = ApplicationBuilder().token(self.config.bot_token)
if self.config.proxy:
builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy)
diff --git a/tests/test_wechat_channel.py b/tests/test_wechat_channel.py
index 6a5a33d..e0ae457 100644
--- a/tests/test_wechat_channel.py
+++ b/tests/test_wechat_channel.py
@@ -2,7 +2,6 @@
import asyncio
import hashlib
-import json
import time
import xml.etree.ElementTree as ET
@@ -240,6 +239,22 @@ class TestWeChatCrypto:
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
From 9b93cea12b1cdaca1f939ef1ec18a4fd051703b5 Mon Sep 17 00:00:00 2001
From: MuXinCG <202322130196@mail.sdu.edu.cn>
Date: Sun, 15 Feb 2026 21:07:47 +0800
Subject: [PATCH 12/12] fix linter bug
---
EvoScientist/EvoScientist.py | 2 +-
EvoScientist/channels/__init__.py | 15 +++++------
EvoScientist/channels/capabilities.py | 2 +-
EvoScientist/channels/channel_manager.py | 9 ++-----
EvoScientist/channels/config.py | 2 +-
EvoScientist/channels/consumer.py | 2 +-
EvoScientist/channels/dingtalk/channel.py | 2 --
EvoScientist/channels/discord/channel.py | 1 -
EvoScientist/channels/email/channel.py | 27 ++++++++++++++-----
EvoScientist/channels/feishu/channel.py | 9 ++++---
EvoScientist/channels/middleware.py | 7 +++--
EvoScientist/channels/mixins.py | 3 +--
EvoScientist/channels/qq/channel.py | 1 -
EvoScientist/channels/signal/channel.py | 3 +--
EvoScientist/channels/signal/probe.py | 3 ++-
EvoScientist/channels/slack/channel.py | 1 -
EvoScientist/channels/wechat/channel.py | 12 ++++++---
EvoScientist/channels/wechat/crypto.py | 2 --
EvoScientist/channels/wechat/verify_server.py | 10 +++++--
EvoScientist/cli/channel.py | 2 +-
EvoScientist/cli/interactive.py | 4 ---
tests/test_bus_integration.py | 5 ++--
tests/test_channel_comprehensive.py | 17 +++++-------
tests/test_channel_manager.py | 3 +--
tests/test_mention_gating.py | 1 -
tests/test_message_bus.py | 1 -
26 files changed, 72 insertions(+), 74 deletions(-)
diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py
index 6c8b500..c3700df 100644
--- a/EvoScientist/EvoScientist.py
+++ b/EvoScientist/EvoScientist.py
@@ -24,7 +24,7 @@ from .backends import CustomSandboxBackend, MergedReadOnlyBackend
from .config import get_effective_config, apply_config_to_env
from .llm import get_chat_model
from .mcp import load_mcp_tools
-from .middleware import create_memory_middleware, create_skills_middleware
+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
diff --git a/EvoScientist/channels/__init__.py b/EvoScientist/channels/__init__.py
index f7611a1..7085a47 100644
--- a/EvoScientist/channels/__init__.py
+++ b/EvoScientist/channels/__init__.py
@@ -4,10 +4,14 @@ This module provides an extensible interface for different messaging channels
(iMessage, Telegram, Discord) to communicate with the EvoScientist agent.
"""
-from .base import Channel, RawIncoming, IncomingMessage, OutgoingMessage
+from .base import Channel, RawIncoming, IncomingMessage, OutgoingMessage, chunk_text
from .bus import MessageBus, InboundMessage, OutboundMessage
+from .capabilities import ChannelCapabilities
from .channel_manager import ChannelManager, register_channel, create_channel, available_channels
from .consumer import InboundConsumer
+from .formatter import UnifiedFormatter
+from .middleware import TypingManager
+from .plugin import ChannelPlugin, ChannelMeta, ReloadPolicy
from .standalone import run_standalone
# Backward compat: ChannelServer is now Channel itself
@@ -27,6 +31,7 @@ __all__ = [
"run_standalone",
"register_channel",
"create_channel",
+ "available_channels",
# New modules
"ChannelCapabilities",
"UnifiedFormatter",
@@ -37,11 +42,3 @@ __all__ = [
"ChannelMeta",
"ReloadPolicy",
]
-
-from .capabilities import ChannelCapabilities
-from .formatter import UnifiedFormatter
-from .middleware import TypingManager
-from .base import chunk_text
-
-# Plugin architecture
-from .plugin import ChannelPlugin, ChannelMeta, ReloadPolicy
diff --git a/EvoScientist/channels/capabilities.py b/EvoScientist/channels/capabilities.py
index b125a22..5060dd9 100644
--- a/EvoScientist/channels/capabilities.py
+++ b/EvoScientist/channels/capabilities.py
@@ -7,7 +7,7 @@ streaming, threading, etc.) without per-channel branching in core logic.
from __future__ import annotations
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from typing import Literal
FormatType = Literal["html", "markdown", "slack_mrkdwn", "discord", "plain"]
diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py
index 30d3449..989cc64 100644
--- a/EvoScientist/channels/channel_manager.py
+++ b/EvoScientist/channels/channel_manager.py
@@ -11,7 +11,6 @@ account management (formerly ``account.py``), and pipeline assembly
from __future__ import annotations
import asyncio
-import dataclasses
import importlib
import json
import logging
@@ -22,9 +21,9 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Callable
-from .base import Channel, RawIncoming, OutboundMessage
+from .base import Channel, OutboundMessage
from .bus import MessageBus
-from .bus.events import InboundMessage
+from .middleware import OutboundMiddlewareBase
from .plugin import ChannelPlugin
logger = logging.getLogger(__name__)
@@ -245,10 +244,6 @@ class AccountManager:
# Inbound / outbound pipelines (formerly pipeline.py)
# ═════════════════════════════════════════════════════════════════════
-from .middleware import (
- OutboundMiddlewareBase,
-)
-
class OutboundPipeline:
"""Processes outgoing messages through a middleware chain."""
diff --git a/EvoScientist/channels/config.py b/EvoScientist/channels/config.py
index f27ad3e..c6afb77 100644
--- a/EvoScientist/channels/config.py
+++ b/EvoScientist/channels/config.py
@@ -12,7 +12,7 @@ common account patterns:
from __future__ import annotations
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from typing import Any
diff --git a/EvoScientist/channels/consumer.py b/EvoScientist/channels/consumer.py
index 5fdfd28..62463c9 100644
--- a/EvoScientist/channels/consumer.py
+++ b/EvoScientist/channels/consumer.py
@@ -12,7 +12,7 @@ from __future__ import annotations
import asyncio
import logging
import uuid
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from typing import Any, AsyncIterator, Callable, TypeVar
from .base import Channel
diff --git a/EvoScientist/channels/dingtalk/channel.py b/EvoScientist/channels/dingtalk/channel.py
index cbbb750..1b1bffb 100644
--- a/EvoScientist/channels/dingtalk/channel.py
+++ b/EvoScientist/channels/dingtalk/channel.py
@@ -4,7 +4,6 @@ import asyncio
import json
import logging
from urllib.parse import quote_plus
-import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@@ -212,7 +211,6 @@ class DingTalkChannel(Channel, WebSocketMixin, TokenMixin):
return
sender_id = payload.get("senderStaffId") or payload.get("senderId", "")
- conv_id = payload.get("conversationId", "")
is_group = payload.get("conversationType") == "2"
# For send API (oToMessages/batchSend), userIds needs staffId, not conversationId
chat_id = sender_id
diff --git a/EvoScientist/channels/discord/channel.py b/EvoScientist/channels/discord/channel.py
index 2fe5dd3..3ef69c9 100644
--- a/EvoScientist/channels/discord/channel.py
+++ b/EvoScientist/channels/discord/channel.py
@@ -3,7 +3,6 @@
import asyncio
import logging
import os
-import re
from dataclasses import dataclass
from datetime import datetime
diff --git a/EvoScientist/channels/email/channel.py b/EvoScientist/channels/email/channel.py
index 71a4dfa..2c2a2da 100644
--- a/EvoScientist/channels/email/channel.py
+++ b/EvoScientist/channels/email/channel.py
@@ -50,12 +50,24 @@ def _strip_html(text: str) -> str:
@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
+ 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):
@@ -352,7 +364,8 @@ class EmailChannel(Channel, PollingMixin):
await self._stop_polling()
if self._imap:
try:
- self._imap.close(); self._imap.logout()
+ self._imap.close()
+ self._imap.logout()
except Exception:
pass
self._imap = None
diff --git a/EvoScientist/channels/feishu/channel.py b/EvoScientist/channels/feishu/channel.py
index 8ba399d..e444be9 100644
--- a/EvoScientist/channels/feishu/channel.py
+++ b/EvoScientist/channels/feishu/channel.py
@@ -16,16 +16,19 @@ Send API:
- ``POST /open-apis/im/v1/messages?receive_id_type=chat_id``
"""
-import asyncio
+from __future__ import annotations
+
import json
import logging
import re
-import time
-from typing import Any
+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
diff --git a/EvoScientist/channels/middleware.py b/EvoScientist/channels/middleware.py
index 9ab67bc..c205fbd 100644
--- a/EvoScientist/channels/middleware.py
+++ b/EvoScientist/channels/middleware.py
@@ -14,11 +14,10 @@ import asyncio
import dataclasses
import logging
import random
-import re
import time
-from collections import OrderedDict, defaultdict, deque
+from collections import OrderedDict, deque
from collections.abc import Awaitable
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from typing import Any, Callable
from .bus.events import InboundMessage, OutboundMessage
@@ -527,7 +526,7 @@ class RetryMiddleware:
"""
def __init__(self, channel_name: str = "unknown") -> None:
- from .retry import RetryConfig, DEFAULT_RETRY, RETRY_PRESETS
+ from .retry import DEFAULT_RETRY, RETRY_PRESETS
self._config = RETRY_PRESETS.get(channel_name, DEFAULT_RETRY)
self._channel_name = channel_name
diff --git a/EvoScientist/channels/mixins.py b/EvoScientist/channels/mixins.py
index fcfe58c..559bffd 100644
--- a/EvoScientist/channels/mixins.py
+++ b/EvoScientist/channels/mixins.py
@@ -16,9 +16,8 @@ import asyncio
import json
import logging
import time
-from typing import Any, Callable
+from typing import Any
-from .base import Channel, ChannelError
logger = logging.getLogger(__name__)
diff --git a/EvoScientist/channels/qq/channel.py b/EvoScientist/channels/qq/channel.py
index c3352bb..0f0e83a 100644
--- a/EvoScientist/channels/qq/channel.py
+++ b/EvoScientist/channels/qq/channel.py
@@ -209,7 +209,6 @@ class QQChannel(Channel):
from pathlib import Path
chat_id = self._resolve_media_chat_id(recipient, metadata)
msg_type = (metadata or {}).get("msg_type", "c2c")
- msg_id = (metadata or {}).get("event_id", "")
ext = Path(file_path).suffix.lower()
file_type = self._FILE_TYPE_MAP.get(ext, 1) # default to image
diff --git a/EvoScientist/channels/signal/channel.py b/EvoScientist/channels/signal/channel.py
index 53affd4..2a577f1 100644
--- a/EvoScientist/channels/signal/channel.py
+++ b/EvoScientist/channels/signal/channel.py
@@ -15,9 +15,8 @@ import json
import logging
import re
import subprocess
-import time
from collections import deque
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from datetime import datetime
from typing import Any
diff --git a/EvoScientist/channels/signal/probe.py b/EvoScientist/channels/signal/probe.py
index c51feb5..8220008 100644
--- a/EvoScientist/channels/signal/probe.py
+++ b/EvoScientist/channels/signal/probe.py
@@ -9,7 +9,8 @@ async def validate_signal(
rpc_port: int = 7583,
) -> tuple[bool, str]:
"""Validate Signal setup by checking signal-cli availability."""
- import asyncio, subprocess
+ import asyncio
+ import subprocess
if not phone_number:
return False, "phone_number is required"
diff --git a/EvoScientist/channels/slack/channel.py b/EvoScientist/channels/slack/channel.py
index 65dc897..21237f7 100644
--- a/EvoScientist/channels/slack/channel.py
+++ b/EvoScientist/channels/slack/channel.py
@@ -2,7 +2,6 @@
import asyncio
import logging
-import re
from dataclasses import dataclass
from datetime import datetime
diff --git a/EvoScientist/channels/wechat/channel.py b/EvoScientist/channels/wechat/channel.py
index 4c2fbb6..291131e 100644
--- a/EvoScientist/channels/wechat/channel.py
+++ b/EvoScientist/channels/wechat/channel.py
@@ -17,16 +17,20 @@ Both backends use httpx (already a core dependency) and aiohttp for
webhook server — matching the Feishu channel pattern.
"""
+from __future__ import annotations
+
import asyncio
import hashlib
-import json
import logging
import re
import time
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
-from typing import Any
+from typing import Any, TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from aiohttp import web
from ..mixins import WebhookMixin, TokenMixin
from ..base import Channel, RawIncoming, ChannelError
@@ -412,7 +416,7 @@ class WeChatChannel(Channel, WebhookMixin, TokenMixin):
if ann:
annotations.append(ann)
else:
- annotations.append(f"[image: no download source]")
+ annotations.append("[image: no download source]")
elif msg_type == "voice":
recognition = xml_data.get("Recognition", "")
media_id = xml_data.get("MediaId", "")
diff --git a/EvoScientist/channels/wechat/crypto.py b/EvoScientist/channels/wechat/crypto.py
index dd27e7c..3652a34 100644
--- a/EvoScientist/channels/wechat/crypto.py
+++ b/EvoScientist/channels/wechat/crypto.py
@@ -13,11 +13,9 @@ References:
import base64
import hashlib
-import socket
import struct
import time
import xml.etree.ElementTree as ET
-from typing import Optional
# Crypto imports — all from the Python standard library + pycryptodome
# (but we'll use a pure-Python fallback if not available)
diff --git a/EvoScientist/channels/wechat/verify_server.py b/EvoScientist/channels/wechat/verify_server.py
index 4a8ec7b..bf00c66 100644
--- a/EvoScientist/channels/wechat/verify_server.py
+++ b/EvoScientist/channels/wechat/verify_server.py
@@ -14,9 +14,15 @@ Usage:
await server.stop()
"""
+from __future__ import annotations
+
import asyncio
import hashlib
import logging
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from aiohttp import web
logger = logging.getLogger(__name__)
@@ -89,7 +95,7 @@ class VerifyServer:
def is_verified(self) -> bool:
return self._verified.is_set()
- async def _handle(self, request) -> "web.Response":
+ async def _handle(self, request) -> web.Response:
"""Handle GET verification request.
During onboarding we use a lenient approach:
@@ -163,7 +169,7 @@ class VerifyServer:
self._verified.set()
return web.Response(text=echostr)
- async def _handle_post(self, request) -> "web.Response":
+ async def _handle_post(self, request) -> web.Response:
"""Handle POST — just acknowledge during verification phase."""
from aiohttp import web
return web.Response(text="success")
diff --git a/EvoScientist/cli/channel.py b/EvoScientist/cli/channel.py
index 363d974..78383a9 100644
--- a/EvoScientist/cli/channel.py
+++ b/EvoScientist/cli/channel.py
@@ -162,7 +162,7 @@ async def _bus_inbound_consumer(
"""
from ..stream import events as _stream_events_mod
from ..stream.display import (
- console, display_final_results, create_streaming_display,
+ console, create_streaming_display,
)
from ..stream.state import StreamState
from ..channels.consumer import _format_todo_list
diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py
index a6c9adf..ecaf323 100644
--- a/EvoScientist/cli/interactive.py
+++ b/EvoScientist/cli/interactive.py
@@ -483,10 +483,6 @@ def cmd_interactive(
state["agent"] = _load_agent(workspace_dir=state["workspace_dir"], checkpointer=checkpointer)
state["thread_id"] = generate_thread_id()
state["resumed"] = False
- # Sync shared refs if channel is running
- if _ChannelState.is_running():
- _ChannelState.agent = state["agent"]
- _ChannelState.thread_id = state["thread_id"]
console.print(f"[green]New session:[/green] [yellow]{state['thread_id']}[/yellow]")
if state["workspace_dir"]:
console.print(f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]\n")
diff --git a/tests/test_bus_integration.py b/tests/test_bus_integration.py
index 7484924..8d73ff4 100644
--- a/tests/test_bus_integration.py
+++ b/tests/test_bus_integration.py
@@ -2,12 +2,11 @@
import asyncio
-import pytest
-from EvoScientist.channels.bus.events import InboundMessage, OutboundMessage
+from EvoScientist.channels.bus.events import InboundMessage
from EvoScientist.channels.bus.message_bus import MessageBus
from EvoScientist.channels.channel_manager import ChannelManager
-from EvoScientist.channels.base import Channel, IncomingMessage, OutgoingMessage
+from EvoScientist.channels.base import Channel, OutgoingMessage
def _run(coro):
diff --git a/tests/test_channel_comprehensive.py b/tests/test_channel_comprehensive.py
index 9bab9f9..6a15416 100644
--- a/tests/test_channel_comprehensive.py
+++ b/tests/test_channel_comprehensive.py
@@ -15,13 +15,10 @@ Test groups:
from __future__ import annotations
import asyncio
-import re
import time
-from collections import OrderedDict
from dataclasses import dataclass
from datetime import datetime
-from typing import Any
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -38,7 +35,7 @@ from EvoScientist.channels.bus.events import (
OutboundMessage as BusOutbound,
)
from EvoScientist.channels.bus.message_bus import MessageBus
-from EvoScientist.channels.channel_manager import ChannelManager, ChannelHealth
+from EvoScientist.channels.channel_manager import ChannelManager
from EvoScientist.channels.consumer import InboundConsumer
from EvoScientist.channels.middleware import DedupCache
from EvoScientist.channels.retry import RetryConfig, RetryInfo, retry_async
@@ -363,7 +360,7 @@ class TestMarkdownUtils:
result = convert_markdown(
text,
- code_block_formatter=lambda l, c: f"[CODE]{c}[/CODE]",
+ 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,
@@ -382,7 +379,7 @@ class TestMarkdownUtils:
text = "Normal text with \x00BLOCK0\x00 in it"
result = convert_markdown(
text,
- code_block_formatter=lambda l, c: f"{c}",
+ code_block_formatter=lambda lang, c: f"{c}",
inline_code_formatter=lambda c: f"{c}",
inline_rules=[],
)
@@ -393,7 +390,7 @@ class TestMarkdownUtils:
text = "before `` after"
result = convert_markdown(
text,
- code_block_formatter=lambda l, c: c,
+ code_block_formatter=lambda lang, c: c,
inline_code_formatter=lambda c: f"[{c}]",
inline_rules=[],
)
@@ -1213,7 +1210,7 @@ class TestInboundConsumer:
async def _test():
consumer = self._make_consumer()
# Start and immediately stop
- task = asyncio.create_task(consumer.run())
+ asyncio.create_task(consumer.run())
await asyncio.sleep(0.1)
await consumer.stop()
await asyncio.sleep(0.1)
@@ -1232,7 +1229,7 @@ class TestInboundConsumerErrorHandling:
ch = StubChannel()
mgr.register(ch)
- consumer = InboundConsumer(
+ _consumer = InboundConsumer(
bus=bus, manager=mgr, agent=MagicMock(),
thread_id="",
)
diff --git a/tests/test_channel_manager.py b/tests/test_channel_manager.py
index bb45c93..9cd8656 100644
--- a/tests/test_channel_manager.py
+++ b/tests/test_channel_manager.py
@@ -5,9 +5,8 @@ import asyncio
import pytest
from EvoScientist.channels.bus.message_bus import MessageBus
-from EvoScientist.channels.bus.events import InboundMessage, OutboundMessage
from EvoScientist.channels.channel_manager import ChannelManager
-from EvoScientist.channels.base import Channel, InboundMessage, OutboundMessage
+from EvoScientist.channels.base import Channel, OutboundMessage
def _run(coro):
diff --git a/tests/test_mention_gating.py b/tests/test_mention_gating.py
index 57edf84..842efc8 100644
--- a/tests/test_mention_gating.py
+++ b/tests/test_mention_gating.py
@@ -3,7 +3,6 @@
import asyncio
from dataclasses import dataclass
-import pytest
from EvoScientist.channels.base import Channel, RawIncoming
from EvoScientist.channels.capabilities import ChannelCapabilities
diff --git a/tests/test_message_bus.py b/tests/test_message_bus.py
index 7e70eb8..700423d 100644
--- a/tests/test_message_bus.py
+++ b/tests/test_message_bus.py
@@ -2,7 +2,6 @@
import asyncio
-import pytest
from EvoScientist.channels.bus.events import InboundMessage, OutboundMessage
from EvoScientist.channels.bus.message_bus import MessageBus