diff --git a/.gitignore b/.gitignore index 5fa9c7a..a3548d1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,6 @@ build/ .venv/ venv/ uv.lock -bridge/node_modules/ -bridge/package-lock.json # IDE / Tools .vscode/ @@ -37,5 +35,4 @@ memory/ *.ipynb *CLAUDE.md *AGENTS.md -*meals/ -botpy.log \ No newline at end of file +*meals/ \ No newline at end of file diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index c3700df..0b1614d 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -27,7 +27,7 @@ from .mcp import load_mcp_tools from .middleware import create_memory_middleware from .prompts import RESEARCHER_INSTRUCTIONS, get_system_prompt from .utils import load_subagents -from .tools import tavily_search, think_tool, skill_manager, view_image +from .tools import tavily_search, think_tool, skill_manager from . import paths as _paths_mod from .paths import set_active_workspace, set_workspace_root @@ -110,11 +110,10 @@ backend = CompositeBackend( tool_registry = { "think_tool": think_tool, "tavily_search": tavily_search, - "view_image": view_image, } # Base tools that every agent variant gets (before MCP) -BASE_TOOLS = [think_tool, skill_manager, view_image] +BASE_TOOLS = [think_tool, skill_manager] # Cache MCP tools by the effective config signature to avoid reconnecting # to MCP servers on every `/new` when config is unchanged. diff --git a/EvoScientist/__init__.py b/EvoScientist/__init__.py index 29c0520..2a5af27 100644 --- a/EvoScientist/__init__.py +++ b/EvoScientist/__init__.py @@ -34,9 +34,6 @@ _EXPORTS: dict[str, tuple[str, str]] = { # Tools "tavily_search": (".tools", "tavily_search"), "think_tool": (".tools", "think_tool"), - "view_image": (".tools", "view_image"), - # Middleware - "create_skills_middleware": (".middleware", "create_skills_middleware"), # Sessions "get_checkpointer": (".sessions", "get_checkpointer"), "generate_thread_id": (".sessions", "generate_thread_id"), diff --git a/EvoScientist/channels/README.md b/EvoScientist/channels/README.md deleted file mode 100644 index c445bb9..0000000 --- a/EvoScientist/channels/README.md +++ /dev/null @@ -1,552 +0,0 @@ -# Channels - -EvoScientist provides unified integration with 11 messaging platforms. This document covers the architecture overview, capability matrix, and detailed deployment guide for each channel. - -Configuration file: `~/.config/evoscientist/config.yaml` (or use environment variables with the `EVOSCIENTIST_` prefix). - -## Architecture - -``` -┌──────────┐ ┌──────────┐ ┌──────────┐ -│ Telegram │ │ Discord │ │ Slack │ ... (×11) -└────┬─────┘ └────┬─────┘ └────┬─────┘ - │ │ │ - └─────────────┼─────────────┘ - ▼ - ┌──────────────┐ - │ MessageBus │ async queue, 5000 cap - └──────┬───────┘ - ▼ - ┌──────────────┐ - │InboundConsumer│ → Agent → OutboundMessage - └──────┬───────┘ - ▼ - ┌──────────────┐ - │ Dispatcher │ routes replies to origin channel - └──────────────┘ -``` - -**Core modules:** - -| Module | Responsibility | -|--------|---------------| -| `base.py` | Abstract `Channel` base class — declarative readiness checks, retry strategy, mention stripping, send fallback, media handling | -| `capabilities.py` | `ChannelCapabilities` frozen dataclass — each channel declares its capabilities, framework adapts automatically | -| `mixins.py` | Reusable patterns: `WebhookMixin` (aiohttp + httpx), `WebSocketMixin` (connect/reconnect/heartbeat), `PollingMixin` (async polling), `TokenMixin` (OAuth token refresh) | -| `config.py` | `BaseChannelConfig` — shared config fields (allowed_senders, proxy, text_chunk_limit, etc.) | -| `bus/` | `MessageBus` async message queue + `InboundMessage`/`OutboundMessage` dataclasses | -| `channel_manager.py` | Lifecycle management (start/stop), health checks, channel registry | -| `consumer.py` | `InboundConsumer` — dequeue messages, invoke Agent, publish replies | -| `retry.py` | Configurable exponential backoff retry (`RetryConfig`: attempts, min/max delay, jitter) | -| `markdown_utils.py` | Universal Markdown converter with per-platform formatting plugins | - -## Capability Matrix - -| Channel | Format | Max Len | Media | Voice | Sticker | Location | Video | Typing | Reaction | Thread | Group | @Mention | No Public IP | Token Refresh | Proxy | Allowlist | -|:--------|:------:|:-------:|:-----:|:-----:|:-------:|:--------:|:-----:|:------:|:--------:|:------:|:-----:|:--------:|:------------:|:-------------:|:-----:|:---------:| -| Telegram | HTML | 4000 | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ | | ✓ | ✓ | ✓ | | ✓ | ✓ | -| Discord | Discord | 2000 | ✓ | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ | -| Slack | Mrkdwn | 4000 | ✓ | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ | -| Feishu | MD | 4096 | ✓ | ✓ | ✓ | | | | ✓ | | ✓ | ✓ | | ✓ | ✓ | ✓ | -| WeChat | MD | 4096 | ✓ | ✓ | | ✓ | | | | | ✓ | ✓ | | ✓ | ✓ | ✓ | -| DingTalk | MD | 4096 | ✓ | ✓ | | | | | | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| QQ | Plain | 4096 | ✓ | | | | | | | | ✓ | ✓ | ✓ | | | ✓ | -| Signal | Plain | 4096 | ✓ | ✓ | | | | ✓ | ✓ | | ✓ | ✓ | ✓ | | | ✓ | -| iMessage | Plain | ∞ | ✓ | ✓ | | | | | | | ✓ | | ✓ | | | ✓ | -| Email | HTML | ∞ | ✓ | | | | | | | | | | ✓ | | | ✓ | - -### Connection Types - -| Channel | Transport | Connection Mode | Default Port | -|-------------|-----------|----------------------------------------|:------------:| -| Telegram | HTTPS | Long polling (`getUpdates`) | — | -| Discord | WebSocket | Gateway events (`discord.py`) | — | -| Slack | WebSocket | Socket Mode (`slack-sdk`) | — | -| Feishu | HTTP | Webhook `POST /webhook/event` | 9000 | -| WeChat | HTTP | Webhook `POST /wechat/callback` | 9001 | -| DingTalk | WebSocket | Stream Mode (DingTalk gateway) | — | -| QQ | WebSocket | Bot Gateway (`qq-botpy`) | — | -| Signal | TCP | JSON-RPC (`signal-cli` daemon) | 7583 | -| iMessage | stdio | JSON-RPC (`imsg` CLI) | — | -| Email | TCP | IMAP polling + SMTP send | 993/587 | - -> **"—"** means no listening port is required — no public IP or port forwarding needed. - -## Quick Start - -### 1. Install channel dependencies - -```bash -pip install evoscientist[telegram] -# Available extras: telegram, discord, slack, feishu, wechat, -# dingtalk, qq, email, signal -# iMessage requires no extra Python dependencies -``` - -### 2. Configure - -```bash -# Option A: Interactive wizard -EvoSci onboard - -# Option B: CLI commands -EvoSci config set channel_enabled telegram -EvoSci config set telegram_bot_token "123456:ABC-xxx" - -# Option C: Environment variables (EVOSCIENTIST_ prefix, uppercase) -export EVOSCIENTIST_CHANNEL_ENABLED=telegram -export EVOSCIENTIST_TELEGRAM_BOT_TOKEN="123456:ABC-xxx" -``` - -### 3. Start - -```bash -EvoSci serve # Start agent + all enabled channels -# or -EvoSci channel start # Standalone channel mode (message loop only) -``` - -### 4. Health check - -```bash -curl http://localhost:8080/healthz -``` - -```json -{ - "status": "healthy", - "channels": { "enabled": ["telegram"], "running": ["telegram"] } -} -``` - -### Running multiple channels - -Comma-separate channel names in the config to enable multiple channels simultaneously: - -```yaml -channel_enabled: "telegram,discord,imessage" -``` - -All enabled channels run concurrently via the internal message bus. - ---- - -## Channel Deployment Guides - ---- - -### Telegram - -**Install:** `pip install evoscientist[telegram]` - -**Prerequisites:** - -1. Search for [@BotFather](https://t.me/BotFather) in Telegram, send `/newbot`, and follow the prompts to create a bot. -2. BotFather will return a Bot Token (format: `123456789:ABCdefGHI...`) — save it securely. -3. Get your user ID: send any message to [@userinfobot](https://t.me/userinfobot), it will reply with your numeric ID. -4. (Optional) For group use: add the bot to a group, then in BotFather send `/setprivacy` → `Disable` so the bot can read group messages. - -**Configuration:** - -```yaml -channel_enabled: "telegram" -telegram_bot_token: "123456789:ABCdefGHIjklMNOpqrSTUvwxYZ" -telegram_allowed_senders: "" # Comma-separated user IDs; empty = no restriction -telegram_proxy: "" # Optional HTTPS proxy (e.g. http://proxy:8080) -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `telegram_bot_token` | `str` | `""` | **Required.** Bot API Token from BotFather | -| `telegram_allowed_senders` | `str` | `""` | Comma-separated user IDs, empty = allow all | -| `telegram_proxy` | `str` | `""` | HTTPS proxy URL | - -**Env vars:** `EVOSCIENTIST_TELEGRAM_BOT_TOKEN`, `EVOSCIENTIST_TELEGRAM_ALLOWED_SENDERS`, `EVOSCIENTIST_TELEGRAM_PROXY` - -**Technical details:** Long polling mode, `drop_pending_updates=True` on startup to skip backlog. Markdown→Telegram HTML auto-conversion (bold, italic, strikethrough, links, code blocks, headings, lists). Falls back to plain text on HTML parse failure. Media routed by extension to `send_photo`/`send_video`/`send_audio`/`send_document`. In groups, only responds when @mentioned; auto-strips @mention. Typing indicator refreshes every 4s. Retry: 3 attempts, min delay 0.4s, parse errors not retried. Text chunk limit: 4000 chars. - ---- - -### Discord - -**Install:** `pip install evoscientist[discord]` - -**Prerequisites:** - -1. Go to [Discord Developer Portal](https://discord.com/developers/applications) → New Application → enter a name. -2. Left menu **Bot** → Reset Token → copy the Bot Token. -3. Under **Privileged Gateway Intents**, enable **Message Content Intent** (required to read message content). -4. Left menu **OAuth2** → URL Generator: - - Scopes: check `bot` - - Bot Permissions: check `Send Messages`, `Read Message History`, `Attach Files`, `Add Reactions` - - Copy the generated URL, open in browser, select a server to invite the bot. -5. Get user ID: Discord Settings → Advanced → enable Developer Mode → right-click username → Copy User ID. - -**Configuration:** - -```yaml -channel_enabled: "discord" -discord_bot_token: "MTIzNDU2Nzg5.xxxx.xxxxx" -discord_allowed_senders: "" # Comma-separated user IDs -discord_allowed_channels: "" # Comma-separated channel IDs -discord_proxy: "" -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `discord_bot_token` | `str` | `""` | **Required.** Bot Token | -| `discord_allowed_senders` | `str` | `""` | Comma-separated user IDs, empty = allow all | -| `discord_allowed_channels` | `str` | `""` | Comma-separated channel IDs, empty = allow all | -| `discord_proxy` | `str` | `""` | HTTPS proxy URL | - -**Env vars:** `EVOSCIENTIST_DISCORD_BOT_TOKEN`, `EVOSCIENTIST_DISCORD_ALLOWED_SENDERS`, `EVOSCIENTIST_DISCORD_ALLOWED_CHANNELS`, `EVOSCIENTIST_DISCORD_PROXY` - -**Technical details:** WebSocket Gateway (`discord.py`). In server channels, only responds when @mentioned; DMs respond directly. Replies via `MessageReference`. Attachment download (max 20 MB) with safe filename sanitization. Media sent via `discord.File`. Typing indicator refreshes every 8s. Retry: 3 attempts, parses `Retry-After` header for 429s. Text chunk limit: 2000 chars. - ---- - -### Slack - -**Install:** `pip install evoscientist[slack]` - -**Prerequisites:** - -1. Go to [Slack API](https://api.slack.com/apps) → Create New App → From scratch → select workspace. -2. Left menu **Socket Mode** → enable → Generate App-Level Token, scope `connections:write` → copy App Token (`xapp-...`). -3. Left menu **OAuth & Permissions** → add Bot Token Scopes: - - `chat:write`, `channels:history`, `groups:history`, `im:history`, `files:read`, `files:write`, `reactions:write` -4. Click **Install to Workspace** → copy Bot User OAuth Token (`xoxb-...`). -5. Left menu **Event Subscriptions** → enable → Subscribe to bot events: `message.channels`, `message.groups`, `message.im`, `app_mention`. -6. Get Member ID: click user avatar → profile → **⋮** → Copy member ID. - -**Configuration:** - -```yaml -channel_enabled: "slack" -slack_bot_token: "xoxb-xxxx-xxxx-xxxx" -slack_app_token: "xapp-1-xxxx-xxxx" -slack_allowed_senders: "" # Member ID (U...) -slack_allowed_channels: "" # Channel ID (C...) -slack_proxy: "" -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `slack_bot_token` | `str` | `""` | **Required.** Bot User OAuth Token (`xoxb-`) | -| `slack_app_token` | `str` | `""` | **Required.** Socket Mode App Token (`xapp-`) | -| `slack_allowed_senders` | `str` | `""` | Comma-separated Member IDs | -| `slack_allowed_channels` | `str` | `""` | Comma-separated Channel IDs | -| `slack_proxy` | `str` | `""` | HTTPS proxy URL | - -**Env vars:** `EVOSCIENTIST_SLACK_BOT_TOKEN`, `EVOSCIENTIST_SLACK_APP_TOKEN`, `EVOSCIENTIST_SLACK_ALLOWED_SENDERS`, `EVOSCIENTIST_SLACK_ALLOWED_CHANNELS`, `EVOSCIENTIST_SLACK_PROXY` - -**Technical details:** Socket Mode (no public URL needed). Markdown→mrkdwn conversion. DMs respond directly; channels only respond to `app_mention` events. Thread replies via `thread_ts`. Attachments downloaded with Bearer auth. Media sent via `files_upload_v2`. Runs `auth_test()` on startup to verify credentials. Retry: 3 attempts, exponential backoff + jitter. Text chunk limit: 4000 chars. - ---- - -### Feishu (Lark) - -**Install:** `pip install evoscientist[feishu]` - -**Prerequisites:** - -1. Go to [Feishu Open Platform](https://open.feishu.cn/app) (international: [Lark Developer](https://open.larksuite.com/app)) → create a custom app. -2. Copy the **App ID** and **App Secret**. -3. Left menu **Event Subscriptions** → set request URL to `http://your-host:9000/webhook/event` → copy **Verification Token** and **Encrypt Key**. -4. Add event: `im.message.receive_v1` (receive messages). -5. Left menu **Permissions** → enable `im:message:send_as_bot`. -6. Create a version and publish. - -> Webhook must be publicly reachable. For local dev, use `ngrok http 9000`. - -**Configuration:** - -```yaml -channel_enabled: "feishu" -feishu_app_id: "cli_xxxxxxx" -feishu_app_secret: "xxxxxxxxxxxxxxxxxx" -feishu_webhook_port: 9000 -feishu_allowed_senders: "" # open_id -feishu_domain: "https://open.feishu.cn" -feishu_proxy: "" -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `feishu_app_id` | `str` | `""` | **Required.** App ID | -| `feishu_app_secret` | `str` | `""` | **Required.** App Secret | -| `feishu_webhook_port` | `int` | `9000` | Webhook HTTP port | -| `feishu_allowed_senders` | `str` | `""` | Comma-separated open_ids | -| `feishu_domain` | `str` | `"https://open.feishu.cn"` | API domain (use `https://open.larksuite.com` for Lark) | -| `feishu_proxy` | `str` | `""` | HTTPS proxy URL | - -**Env vars:** `EVOSCIENTIST_FEISHU_APP_ID`, `EVOSCIENTIST_FEISHU_APP_SECRET`, `EVOSCIENTIST_FEISHU_WEBHOOK_PORT`, `EVOSCIENTIST_FEISHU_DOMAIN` - -**Technical details:** Webhook on `POST /webhook/event` with URL verification challenge-response. `tenant_access_token` auto-refresh (2h TTL, refreshes 5 min before expiry). Markdown→Post rich text conversion (code blocks, bold, italic, strikethrough, links, headings, quotes, lists). Plain text fallback. Group @mention filtering. Media: images via `/im/v1/images`, files via `/im/v1/files`. Replies via `/messages/{id}/reply`. Retry: 3 attempts, rate limit delay 2.0s, matches `99991400`/`rate limit`. Text chunk limit: 4096 chars. - ---- - -### WeChat - -**Install:** `pip install evoscientist[wechat]` - -Two backends supported: **WeCom** (recommended, free, no certification needed) and **WeChat Official Account** (requires verified service account). - -#### WeCom - -**Prerequisites:** - -1. Log in to [WeCom Admin Console](https://work.weixin.qq.com) → App Management → create a custom app. -2. Copy the **Corp ID**, **AgentId**, and **Secret**. -3. In app details → Receive Messages → Set API Receive → URL: `http://your-host:9001/wechat/callback` → copy **Token** and **EncodingAESKey**. - -```yaml -channel_enabled: "wechat" -wechat_backend: "wecom" -wechat_webhook_port: 9001 -wechat_wecom_corp_id: "ww..." -wechat_wecom_agent_id: "1000002" -wechat_wecom_secret: "xxxxxxxxxxxxxxxxxx" -wechat_wecom_token: "xxxxxxxxxxxxxxxxxx" -wechat_wecom_encoding_aes_key: "xxxxxxxxxxxxxxxxxx" -wechat_allowed_senders: "" -wechat_proxy: "" -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `wechat_backend` | `str` | `"wecom"` | `"wecom"` or `"wechatmp"` | -| `wechat_webhook_port` | `int` | `9001` | Callback HTTP port | -| `wechat_wecom_corp_id` | `str` | `""` | **Required (WeCom).** Corp ID | -| `wechat_wecom_agent_id` | `str` | `""` | **Required (WeCom).** App AgentId | -| `wechat_wecom_secret` | `str` | `""` | **Required (WeCom).** App Secret | -| `wechat_wecom_token` | `str` | `""` | **Required (WeCom).** Callback Token | -| `wechat_wecom_encoding_aes_key` | `str` | `""` | **Required (WeCom).** Callback EncodingAESKey | - -#### WeChat Official Account - -**Prerequisites:** - -1. Log in to [WeChat Official Account Platform](https://mp.weixin.qq.com) → Settings & Development → Basic Configuration. -2. Copy the **AppID** and **AppSecret**. -3. Server Configuration → URL: `http://your-host:9001/wechat/callback` → set **Token** and **EncodingAESKey**. - -```yaml -wechat_backend: "wechatmp" -wechat_mp_app_id: "wx..." -wechat_mp_app_secret: "xxxxxxxxxxxxxxxxxx" -wechat_mp_token: "xxxxxxxxxxxxxxxxxx" -wechat_mp_encoding_aes_key: "xxxxxxxxxxxxxxxxxx" -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `wechat_mp_app_id` | `str` | `""` | **Required (MP).** AppID | -| `wechat_mp_app_secret` | `str` | `""` | **Required (MP).** AppSecret | -| `wechat_mp_token` | `str` | `""` | **Required (MP).** Server Token | -| `wechat_mp_encoding_aes_key` | `str` | `""` | **Required (MP).** Server EncodingAESKey | - -**Technical details:** Webhook HTTP server. XML message parsing. Signature verification. `access_token` auto-refresh. Optional AES encryption/decryption. WeCom supports Markdown message format; Official Account uses plain text. Media send/receive. Retry + backoff. Text chunk limit: 2048 chars. - ---- - -### DingTalk - -**Install:** `pip install evoscientist[dingtalk]` - -**Prerequisites:** - -1. Go to [DingTalk Open Platform](https://open-dev.dingtalk.com) → App Development → create a bot app. -2. Copy the **AppKey** (Client ID) and **AppSecret** (Client Secret). -3. Enable **Stream Mode** in the app configuration — no public IP needed. -4. Publish the app and add the bot to a group, or test via direct message. - -**Configuration:** - -```yaml -channel_enabled: "dingtalk" -dingtalk_client_id: "ding..." -dingtalk_client_secret: "xxxxxxxxxxxxxxxxxx" -dingtalk_allowed_senders: "" -dingtalk_proxy: "" -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `dingtalk_client_id` | `str` | `""` | **Required.** AppKey | -| `dingtalk_client_secret` | `str` | `""` | **Required.** AppSecret | -| `dingtalk_allowed_senders` | `str` | `""` | Comma-separated user IDs | -| `dingtalk_proxy` | `str` | `""` | HTTPS proxy URL | - -**Env vars:** `EVOSCIENTIST_DINGTALK_CLIENT_ID`, `EVOSCIENTIST_DINGTALK_CLIENT_SECRET` - -**Technical details:** Stream Mode (WebSocket, no public IP needed). Connects via DingTalk gateway with automatic ping/pong heartbeat and message ACK. `access_token` auto-refresh. Group @mention filtering (strips first `@bot` mention). Supports image, file, video, audio attachment download. Sends in Markdown format (`sampleMarkdown`). Auth errors (`invalidauthentication`/`forbidden`/`40014`) not retried. Text chunk limit: 4096 chars. - ---- - -### QQ - -**Install:** `pip install evoscientist[qq]` - -**Prerequisites:** - -1. Go to [QQ Open Platform](https://q.qq.com) → create a bot application. -2. Complete developer verification, create a sandbox or production bot. -3. Copy the **AppID** and **AppSecret**. -4. Search for and add the bot as a friend in QQ, or add it to a group. - -**Configuration:** - -```yaml -channel_enabled: "qq" -qq_app_id: "xxxxxxxxxx" -qq_app_secret: "xxxxxxxxxxxxxxxxxx" -qq_allowed_senders: "" -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `qq_app_id` | `str` | `""` | **Required.** AppID | -| `qq_app_secret` | `str` | `""` | **Required.** AppSecret | -| `qq_allowed_senders` | `str` | `""` | Comma-separated user IDs | - -**Env vars:** `EVOSCIENTIST_QQ_APP_ID`, `EVOSCIENTIST_QQ_APP_SECRET` - -**Technical details:** Uses `qq-botpy` SDK via WebSocket to connect to QQ Bot Gateway. Supports C2C (direct) and group messages. Message deduplication (1000-entry LRU cache). Group @mention filtering (strips first `@bot`). Intents: `public_messages=True`, `direct_message=True`. Text chunk limit: 2048 chars. - ---- - -### Signal - -**Install:** `pip install evoscientist[signal]` (also requires [signal-cli](https://github.com/AsamK/signal-cli) installed separately) - -**Prerequisites:** - -1. Install signal-cli: see [signal-cli installation guide](https://github.com/AsamK/signal-cli#installation). -2. Register or link a phone number: - - Register: `signal-cli -u +1234567890 register`, then `signal-cli -u +1234567890 verify CODE` - - Link existing device: `signal-cli link -n "EvoScientist"` -3. EvoScientist will auto-start the signal-cli daemon if it's not already running. - -**Configuration:** - -```yaml -channel_enabled: "signal" -signal_phone_number: "+1234567890" -signal_cli_path: "signal-cli" -signal_config_dir: "" -signal_allowed_senders: "" -signal_rpc_port: 7583 -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `signal_phone_number` | `str` | `""` | **Required.** Signal phone number (E.164 format) | -| `signal_cli_path` | `str` | `"signal-cli"` | Path to signal-cli binary | -| `signal_config_dir` | `str` | `""` | signal-cli config directory (optional) | -| `signal_allowed_senders` | `str` | `""` | Comma-separated phone numbers | -| `signal_rpc_port` | `int` | `7583` | JSON RPC socket port | - -**Env vars:** `EVOSCIENTIST_SIGNAL_PHONE_NUMBER`, `EVOSCIENTIST_SIGNAL_CLI_PATH`, `EVOSCIENTIST_SIGNAL_RPC_PORT` - -**Technical details:** JSON RPC over TCP socket to signal-cli daemon. Auto-starts daemon if not running (`signal-cli -u +NUMBER daemon --socket localhost:PORT`). Listens for `receive` notifications. Sends via `send` RPC method. Group detection via `groupInfo`. Mention detection via UUID matching. No public IP needed. Text chunk limit: 4096 chars. - ---- - -### Email - -**Install:** `pip install evoscientist[email]` (core dependencies included, no extras needed) - -**Prerequisites:** - -1. Prepare an email account with IMAP + SMTP support (Gmail, Outlook, self-hosted, etc.). -2. **Gmail:** Enable 2FA → generate an App Password. IMAP: `imap.gmail.com:993` (SSL), SMTP: `smtp.gmail.com:587` (STARTTLS). -3. **Outlook/Office 365:** IMAP: `outlook.office365.com:993` (SSL), SMTP: `smtp.office365.com:587` (STARTTLS). -4. Ensure IMAP access is enabled in your email settings. - -**Configuration:** - -```yaml -channel_enabled: "email" -email_imap_host: "imap.gmail.com" -email_imap_port: 993 -email_imap_username: "bot@gmail.com" -email_imap_password: "xxxx-xxxx-xxxx-xxxx" -email_imap_mailbox: "INBOX" -email_imap_use_ssl: true -email_smtp_host: "smtp.gmail.com" -email_smtp_port: 587 -email_smtp_username: "bot@gmail.com" -email_smtp_password: "xxxx-xxxx-xxxx-xxxx" -email_smtp_use_tls: true -email_from_address: "bot@gmail.com" -email_poll_interval: 30 -email_mark_seen: true -email_allowed_senders: "" -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `email_imap_host` | `str` | `""` | **Required.** IMAP server address | -| `email_imap_port` | `int` | `993` | IMAP port | -| `email_imap_username` | `str` | `""` | **Required.** IMAP login username | -| `email_imap_password` | `str` | `""` | **Required.** IMAP login password (or app password) | -| `email_imap_mailbox` | `str` | `"INBOX"` | Mailbox folder to monitor | -| `email_imap_use_ssl` | `bool` | `true` | Use SSL for IMAP connection | -| `email_smtp_host` | `str` | `""` | **Required.** SMTP server address | -| `email_smtp_port` | `int` | `587` | SMTP port | -| `email_smtp_username` | `str` | `""` | **Required.** SMTP login username | -| `email_smtp_password` | `str` | `""` | **Required.** SMTP login password | -| `email_smtp_use_tls` | `bool` | `true` | Use STARTTLS (`true`) or SSL (`false`) | -| `email_from_address` | `str` | `""` | Sender address (defaults to smtp_username) | -| `email_poll_interval` | `int` | `30` | IMAP poll interval in seconds | -| `email_mark_seen` | `bool` | `true` | Mark emails as read after processing | -| `email_max_body_chars` | `int` | `12000` | Max email body chars (truncated beyond) | -| `email_subject_prefix` | `str` | `"Re: "` | Reply subject prefix | -| `email_allowed_senders` | `str` | `""` | Comma-separated sender email addresses | - -**Env vars:** `EVOSCIENTIST_EMAIL_IMAP_HOST`, `EVOSCIENTIST_EMAIL_IMAP_USERNAME`, `EVOSCIENTIST_EMAIL_IMAP_PASSWORD`, `EVOSCIENTIST_EMAIL_SMTP_HOST`, `EVOSCIENTIST_EMAIL_SMTP_USERNAME`, `EVOSCIENTIST_EMAIL_SMTP_PASSWORD` - -**Technical details:** IMAP polling mode, checks for UNSEEN emails periodically (max 20 per cycle). Supports SSL and STARTTLS. Auto-parses multipart emails (prefers text/plain, falls back text/html → plain text). Attachments auto-downloaded. Replies set `In-Reply-To` and `References` headers to maintain email threads. Sends HTML + plain text dual format (multipart/alternative), falls back to plain text on HTML failure. IMAP auto-reconnects on disconnect. Auth errors (auth/login/credential) not retried. No public IP needed. Text chunk limit: no limit. - ---- - -### iMessage - -**Install:** No extra Python dependencies. Requires the [imsg](https://github.com/anthropics/imsg) CLI tool. - -**Requirements:** macOS only (iMessage is Apple-proprietary). Requires a signed-in Apple ID with iMessage and Full Disk Access permission for the terminal app. - -**Prerequisites:** - -1. Install imsg CLI: - ```bash - brew install imsg - ``` -2. Verify: `imsg --version` -3. Ensure Messages.app is signed in and working on macOS. - -**Configuration:** - -```yaml -channel_enabled: "imessage" -imessage_cli_path: "imsg" -imessage_db_path: "" -imessage_service: "auto" -imessage_region: "US" -imessage_allowed_senders: "" -``` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `imessage_cli_path` | `str` | `"imsg"` | Path to imsg CLI binary | -| `imessage_db_path` | `str` | `""` | iMessage database path (empty = default) | -| `imessage_service` | `str` | `"auto"` | Send service: `imessage`, `sms`, or `auto` | -| `imessage_region` | `str` | `"US"` | Phone number region code | -| `imessage_allowed_senders` | `str` | `""` | Comma-separated allowlist (see below) | - -**Allowlist formats:** phone (`+1234567890`), email (`user@example.com`), `chat_id:123`, `chat_guid:iMessage;-;+1234567890`, wildcard `*`. - -**Env vars:** `EVOSCIENTIST_IMESSAGE_CLI_PATH`, `EVOSCIENTIST_IMESSAGE_SERVICE`, `EVOSCIENTIST_IMESSAGE_ALLOWED_SENDERS` - -**Technical details:** JSON-RPC over stdio with imsg CLI. Creates `watch.subscribe` on startup for real-time message streaming (not polling). Supports iMessage + SMS dual channel (`service: auto`). Target resolution supports chat_id, chat_guid, chat_identifier, and phone/email. Attachments read from local paths provided by imsg. Group detection via `is_group` field. RPC errors (AppleScript/permission/not found) not retried; only connection timeouts retried. Plain text format (no Markdown). No public IP needed. Text chunk limit: 4000 chars. diff --git a/EvoScientist/channels/__init__.py b/EvoScientist/channels/__init__.py index 7085a47..1fac31d 100644 --- a/EvoScientist/channels/__init__.py +++ b/EvoScientist/channels/__init__.py @@ -1,44 +1,9 @@ """Communication channels for EvoScientist. This module provides an extensible interface for different messaging channels -(iMessage, Telegram, Discord) to communicate with the EvoScientist agent. +(iMessage, WeChat, etc.) to communicate with the EvoScientist agent. """ -from .base import Channel, RawIncoming, IncomingMessage, OutgoingMessage, chunk_text -from .bus import MessageBus, InboundMessage, OutboundMessage -from .capabilities import ChannelCapabilities -from .channel_manager import ChannelManager, register_channel, create_channel, available_channels -from .consumer import InboundConsumer -from .formatter import UnifiedFormatter -from .middleware import TypingManager -from .plugin import ChannelPlugin, ChannelMeta, ReloadPolicy -from .standalone import run_standalone +from .base import Channel, IncomingMessage, OutgoingMessage -# Backward compat: ChannelServer is now Channel itself -ChannelServer = Channel - -__all__ = [ - "Channel", - "ChannelServer", - "ChannelManager", - "MessageBus", - "RawIncoming", - "IncomingMessage", - "OutgoingMessage", - "InboundMessage", - "OutboundMessage", - "InboundConsumer", - "run_standalone", - "register_channel", - "create_channel", - "available_channels", - # New modules - "ChannelCapabilities", - "UnifiedFormatter", - "TypingManager", - "chunk_text", - # Plugin architecture - "ChannelPlugin", - "ChannelMeta", - "ReloadPolicy", -] +__all__ = ["Channel", "IncomingMessage", "OutgoingMessage"] diff --git a/EvoScientist/channels/base.py b/EvoScientist/channels/base.py index fbe938d..ecf1260 100644 --- a/EvoScientist/channels/base.py +++ b/EvoScientist/channels/base.py @@ -5,358 +5,42 @@ This module defines the Channel interface that all messaging channels """ from abc import ABC, abstractmethod -import asyncio -import logging -import re -from collections import defaultdict -from collections.abc import Awaitable, Callable as CallableABC from dataclasses import dataclass, field from datetime import datetime -from pathlib import Path -from typing import Any, AsyncIterator, Callable - -from ..paths import WORKSPACE_ROOT - -from .bus.events import InboundMessage, OutboundMessage -from .capabilities import ChannelCapabilities -from .formatter import UnifiedFormatter -from .plugin import ChannelPlugin, ChannelMeta - -_logger = logging.getLogger(__name__) - - -# ── Text chunking ──────────────────────────────────────────────────── - -def chunk_text(text: str, limit: int) -> list[str]: - """Split text into chunks that respect logical boundaries. - - Splitting priority (highest to lowest): - 1. Code block boundaries (``` fences) - 2. Double newlines (paragraph breaks) - 3. Single newlines - 4. Spaces (word boundaries) - 5. Hard cut (last resort) - - Code blocks are never split mid-block when possible. If a single code - block exceeds the limit it is sent as its own chunk(s). - - Args: - text: The text to split. - limit: Maximum characters per chunk. - - Returns: - List of text chunks, each <= limit characters. - """ - if not text: - return [] - if len(text) <= limit: - return [text] - - chunks: list[str] = [] - remaining = text - - while remaining: - if len(remaining) <= limit: - chunks.append(remaining) - break - - # Try to find a split point within the limit - segment = remaining[:limit] - - # 1. Prefer splitting at code block boundary (``` at line start) - best = -1 - fence_pos = segment.rfind("\n```") - if fence_pos > 0: - line_end = segment.find("\n", fence_pos + 1) - if line_end == -1: - line_end = len(segment) - best = line_end - - # 2. Double newline (paragraph break) - if best == -1: - pos = segment.rfind("\n\n") - if pos > 0: - best = pos - - # 3. Single newline - if best == -1: - pos = segment.rfind("\n") - if pos > 0: - best = pos - - # 4. Space (word boundary) - if best == -1: - pos = segment.rfind(" ") - if pos > 0: - best = pos - - # 5. Hard cut - if best == -1: - best = limit - - chunk = remaining[:best].rstrip() - if chunk: - chunks.append(chunk) - remaining = remaining[best:].lstrip("\n") - - return chunks - - -# ── Attachment / media helpers ─────────────────────────────────────── - -MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20 MB -MEDIA_DIR = WORKSPACE_ROOT / "media" - -IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}) -VIDEO_EXTS = frozenset({".mp4", ".mov", ".avi", ".webm"}) -AUDIO_EXTS = frozenset({".mp3", ".ogg", ".m4a", ".wav"}) - - -def classify_media(ext: str) -> str | None: - """Classify a file extension into a media type string. - - Returns ``"image"``, ``"video"``, ``"audio"``, or ``None``. - """ - ext = ext.lower() - if ext in IMAGE_EXTS: - return "image" - if ext in VIDEO_EXTS: - return "video" - if ext in AUDIO_EXTS: - return "audio" - return None - - -def media_path(filename: str) -> Path: - """Ensure MEDIA_DIR exists and return a path inside it.""" - MEDIA_DIR.mkdir(parents=True, exist_ok=True) - return MEDIA_DIR / filename - - -def check_attachment_size(file_size: int, filename: str) -> str | None: - """Return a 'too large' annotation if *file_size* exceeds the limit. - - Returns ``None`` when the file is within the allowed size. - """ - if file_size > MAX_ATTACHMENT_BYTES: - return f"[attachment: {filename} - too large ({file_size} bytes)]" - return None - - -async def download_attachment( - url: str, - filename: str, - *, - channel_name: str = "", - headers: dict[str, str] | None = None, - file_size: int | None = None, - proxy: str | None = None, -) -> tuple[str | None, str | None]: - """Download an attachment via httpx. - - Returns ``(local_path, annotation)``. - - If *file_size* exceeds ``MAX_ATTACHMENT_BYTES``, returns - ``(None, too-large-annotation)`` without downloading. - On download failure returns ``(None, failure-annotation)``. - On success returns ``(local_path_str, success-annotation)``. - """ - if file_size is not None: - too_large = check_attachment_size(file_size, filename) - if too_large: - return None, too_large - - try: - import httpx - - safe_name = filename.replace("/", "_") - prefix = f"{channel_name}_" if channel_name else "" - local_path = media_path(f"{prefix}{safe_name}") - - async with httpx.AsyncClient(proxy=proxy) as client: - async with client.stream("GET", url, headers=headers or {}, timeout=30) as resp: - if resp.status_code != 200: - return None, f"[attachment: {filename} - download failed]" - - # Check Content-Length header before downloading body - if file_size is None: - cl = resp.headers.get("content-length") - if cl: - try: - too_large = check_attachment_size(int(cl), filename) - if too_large: - return None, too_large - except (ValueError, TypeError): - pass - - # Stream body with incremental size check - chunks: list[bytes] = [] - total = 0 - async for chunk in resp.aiter_bytes(): - total += len(chunk) - if total > MAX_ATTACHMENT_BYTES: - return None, check_attachment_size(total, filename) - chunks.append(chunk) - - local_path.write_bytes(b"".join(chunks)) - return str(local_path), f"[attachment: {local_path}]" - except Exception as e: - _logger.warning(f"Failed to download attachment: {e}") - return None, f"[attachment: {filename} - download failed]" - -# Deprecated aliases — use InboundMessage / OutboundMessage instead. -IncomingMessage = InboundMessage -OutgoingMessage = OutboundMessage +from typing import AsyncIterator @dataclass -class RawIncoming: - """Raw data extracted from a platform-specific message event. +class IncomingMessage: + """Represents a message received from a channel.""" - Each channel's ``_on_message`` populates this with platform data, - then calls ``_enqueue_raw()`` which handles allow-list checks, - content merging, and ``InboundMessage`` creation. - """ - - sender_id: str - chat_id: str - text: str = "" - media_files: list[str] = field(default_factory=list) - content_annotations: list[str] = field(default_factory=list) - timestamp: datetime = field(default_factory=datetime.now) - message_id: str = "" - metadata: dict = field(default_factory=dict) - is_group: bool = False - was_mentioned: bool = True # default True so DMs always pass + sender: str # Phone number, email, or unique identifier + content: str # Message text content + timestamp: datetime # When the message was sent + message_id: str # Unique identifier for the message + metadata: dict = field(default_factory=dict) # Channel-specific metadata -class Channel(ChannelPlugin, ABC): +@dataclass +class OutgoingMessage: + """Represents a message to be sent through a channel.""" + + recipient: str # Phone number, email, or unique identifier + content: str # Message text content + reply_to: str | None = None # Optional message ID being replied to + metadata: dict = field(default_factory=dict) # Channel-specific metadata + + +class Channel(ABC): """Abstract base class for messaging channels. Subclasses must implement: - start(): Initialize the channel (connect, authenticate, etc.) - - _send_chunk(): Send a single text chunk (platform-specific) - - Subclasses may optionally override: - - _cleanup(): Channel-specific teardown (called by stop()) - - _format_chunk(): Convert Markdown to channel format - - _is_ready(): Return False if channel cannot send - - _resolve_chat_id(): Extract chat_id from message - - receive(): Only if custom exit conditions are needed - - Subclasses should set ``name`` to a unique identifier (e.g. "telegram"). + - stop(): Clean up resources + - receive(): Async iterator yielding incoming messages + - send(): Send a message through the channel """ - name: str = "base" - capabilities: ChannelCapabilities = ChannelCapabilities() - _typing_interval: float = 5.0 - _ready_attrs: tuple[str, ...] = () - - def __init__(self, config, *, queue_maxsize: int = 1000): - ChannelPlugin.__init__(self) - self.id = self.name - self.meta = ChannelMeta(id=self.name, label=self.name.title()) - - self.config = config - - # Auto-configure formatter from capabilities - self._formatter = UnifiedFormatter.for_channel(self.capabilities.format_type) - self._queue: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=queue_maxsize) - self._running = False - - # Typing indicator — delegated to TypingManager - from .middleware import TypingManager - self._typing_manager = TypingManager( - self._send_typing_action, interval=self._typing_interval, - ) - # Keep legacy dict reference for any subclass that touches it directly - self._typing_tasks = self._typing_manager._tasks - - # Bus integration (injected by ChannelManager.register / set_bus) - self._bus: Any = None - self.send_thinking: bool = False - self._on_activity: Callable | None = None - - # Debounce settings - self.initial_debounce: float = 2.0 - self.debounce_step: float = 0.5 - self.max_debounce: float = 5.0 - - # Per-sender message buffers for debouncing - self._message_buffers: dict[str, list[str]] = {} - self._message_metadata: dict[str, dict] = {} - self._message_media: dict[str, list[str]] = {} - self._message_ids: dict[str, str] = {} - self._debounce_tasks: dict[str, asyncio.Task] = {} - - # Mention gating: "always" | "group" | "off" - self.require_mention: str = getattr(config, "require_mention", "group") - - # DM policy: "open" | "allowlist" | "pairing" - self.dm_policy: str = getattr(config, "dm_policy", "allowlist") - - # Per-sender is_group / was_mentioned for debounce merge - self._message_is_group: dict[str, bool] = {} - self._message_was_mentioned: dict[str, bool] = {} - - # Retry configuration (auto-resolved from channel name) - from .retry import RetryConfig, DEFAULT_RETRY, RETRY_PRESETS - self._retry_config: RetryConfig = RETRY_PRESETS.get(self.name, DEFAULT_RETRY) - - # Per-chat send locks to prevent message reordering - self._send_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) - - # Build inbound middleware pipeline - self._inbound_middlewares = self._build_inbound_middlewares() - - def _build_inbound_middlewares(self) -> list: - """Build the inbound middleware chain from config and capabilities. - - Middleware order: - 1. DedupMiddleware — drop duplicates early - 2. AllowListMiddleware — enforce sender/channel restrictions - 3. PairingMiddleware — handle DM pairing (if applicable) - 4. GroupHistoryMiddleware — buffer/inject group history - 5. MentionGatingMiddleware — filter by mention policy - """ - from .middleware import ( - DedupMiddleware, AllowListMiddleware, - PairingMiddleware, GroupHistoryMiddleware, MentionGatingMiddleware, - ) - middlewares = [] - middlewares.append(DedupMiddleware()) - # AllowList - allowed_senders = getattr(self.config, "allowed_senders", None) - allowed_channels = getattr(self.config, "allowed_channels", None) - if allowed_senders and not isinstance(allowed_senders, set): - allowed_senders = set(allowed_senders) - if allowed_channels and not isinstance(allowed_channels, set): - allowed_channels = set(allowed_channels) - middlewares.append(AllowListMiddleware( - allowed_senders=allowed_senders, - allowed_channels=allowed_channels, - dm_policy=self.dm_policy, - )) - # Pairing - if self.dm_policy == "pairing": - async def _send_pair(chat_id, text): - await self._send_chunk(chat_id, text, text, None, {}) - middlewares.append(PairingMiddleware( - channel_name=self.name, - send_response_fn=_send_pair, - dm_policy=self.dm_policy, - )) - # GroupHistory - if self.capabilities.groups: - middlewares.append(GroupHistoryMiddleware()) - # MentionGating - if self.capabilities.mentions: - middlewares.append(MentionGatingMiddleware( - require_mention=self.require_mention, - strip_fn=self._strip_mention, - )) - return middlewares - @abstractmethod async def start(self) -> None: """Initialize and start the channel. @@ -371,649 +55,56 @@ class Channel(ChannelPlugin, ABC): """ pass - async def stop(self) -> None: - """Stop the channel. Cancels typing tasks, then calls _cleanup().""" - self._running = False - await self._typing_manager.stop_all() - await self._cleanup() - - async def _cleanup(self) -> None: - """Channel-specific teardown. Override in subclasses.""" - - async def receive(self) -> AsyncIterator[InboundMessage]: - """Yield incoming messages from the queue. - - Default implementation polls ``self._queue``. Override only if - the channel needs custom exit conditions. - """ - while self._running: - try: - msg = await asyncio.wait_for(self._queue.get(), timeout=1.0) - yield msg - except asyncio.TimeoutError: - continue - - async def send(self, message: OutboundMessage) -> bool: - """Send a message. Handles chunking, retry, and error logging. - - Subclasses override ``_send_chunk()`` for the platform-specific call. - Override ``_format_chunk()`` to convert Markdown to channel format. - - A per-chat lock ensures messages to the same chat are serialised, - preventing out-of-order delivery when multiple sends overlap. - - If formatting expands a chunk beyond the platform limit (e.g. Markdown - → HTML), the chunk is automatically re-split at a smaller size. Per- - chunk errors are logged but do not abort delivery of remaining chunks. - - When the channel satisfies ``ThreadingAdapter``, its ``reply_to_mode`` - controls which chunks carry a ``reply_to`` reference. - """ - if not self._is_ready(): - return False - try: - chat_id = self._resolve_chat_id(message) - limit = self._get_chunk_limit() - async with self._send_locks[chat_id]: - pairs = self._prepare_chunks(message.content, limit) - had_error = False - for i, (formatted, raw) in enumerate(pairs): - reply_to = self._resolve_reply_to(message.reply_to, i) - try: - await self._send_with_retry( - lambda _cid=chat_id, _fmt=formatted, _raw=raw, _reply=reply_to, _meta=message.metadata: ( - self._send_chunk(_cid, _fmt, _raw, _reply, _meta) - ) - ) - except Exception as chunk_err: - _logger.error( - f"{self.name} chunk {i} send error: {chunk_err}" - ) - had_error = True - return not had_error - except Exception as e: - _logger.error(f"{self.name} send error: {e}") - return False - - def _resolve_reply_to(self, reply_to: str | None, chunk_index: int) -> str | None: - """Determine the reply_to value for a given chunk index. - - Legacy: reply_to on first chunk only. - """ - if not reply_to: - return None - return reply_to if chunk_index == 0 else None - - def _prepare_chunks( - self, content: str, limit: int, - ) -> list[tuple[str, str]]: - """Build ``(formatted, raw)`` pairs, re-splitting when formatting - expands a chunk beyond *limit*. - - Returns a list of ``(formatted_text, raw_text)`` tuples ready - for ``_send_chunk()``. - """ - raw_chunks = chunk_text(content, limit) - pairs: list[tuple[str, str]] = [] - for raw in raw_chunks: - formatted = self._format_chunk(raw) - if len(formatted) <= limit: - pairs.append((formatted, raw)) - else: - # Re-chunk at half the limit to leave room for format expansion - sub_limit = max(limit // 2, 500) - for sub_raw in chunk_text(raw, sub_limit): - sub_fmt = self._format_chunk(sub_raw) - if len(sub_fmt) <= limit: - pairs.append((sub_fmt, sub_raw)) - else: - # Still too long — send raw text (guaranteed to fit) - pairs.append((sub_raw, sub_raw)) - return pairs - - def _is_ready(self) -> bool: - """Return False if the channel cannot send (e.g. client not connected). - - Default checks that every attribute named in ``_ready_attrs`` is truthy. - Override for channels with more complex readiness logic. - """ - if not self._ready_attrs: - return True - return all(getattr(self, attr, None) for attr in self._ready_attrs) - - def _resolve_chat_id(self, message: OutboundMessage) -> str: - """Extract chat_id from metadata or recipient. Override if needed.""" - return message.metadata.get("chat_id", message.recipient) - - def _get_chunk_limit(self) -> int: - config_limit = getattr(self.config, "text_chunk_limit", 0) - cap_limit = self.capabilities.max_text_length - return config_limit or cap_limit or 4096 - - def _format_chunk(self, text: str) -> str: - """Convert Markdown to channel format via UnifiedFormatter. - - Uses the formatter auto-configured from ``capabilities.format_type``. - Subclasses rarely need to override this — set ``capabilities`` instead. - """ - return self._formatter.format(text) - @abstractmethod - async def _send_chunk( - self, chat_id: str, formatted_text: str, raw_text: str, - reply_to: str | None, metadata: dict, - ) -> None: - """Send a single text chunk. Platform-specific implementation.""" - ... + async def stop(self) -> None: + """Stop the channel and clean up resources. - _format_fallback_patterns: tuple[str, ...] = ("parse", "invalid") - - async def _send_with_format_fallback( - self, send_fn: CallableABC[[str], Awaitable], formatted: str, raw: str, - ) -> None: - """Try *send_fn(formatted)*; on format-related errors retry with *raw*. - - Channels whose ``_send_chunk`` follows the try-formatted / except-fallback - pattern can delegate to this helper instead of duplicating the logic. + This method should: + - Close connections + - Cancel background tasks + - Release any held resources """ - try: - await send_fn(formatted) - except Exception as e: - if formatted != raw and any( - p in str(e).lower() for p in self._format_fallback_patterns - ): - await send_fn(raw) - else: - raise - - async def send_media( - self, - recipient: str, - file_path: str, - caption: str = "", - metadata: dict | None = None, - ) -> bool: - """Send a media file through the channel. - - Handles the ready-check guard and error logging. Subclasses - override ``_send_media_impl()`` with platform-specific logic. - - Args: - recipient: Target recipient or chat identifier. - file_path: Local path to the media file. - caption: Optional caption text. - metadata: Optional channel-specific metadata. - - Returns: - True if sent successfully, False otherwise. - """ - if not self._is_ready(): - return False - try: - return await self._send_media_impl(recipient, file_path, caption, metadata) - except Exception as e: - _logger.error(f"{self.name} send_media error: {e}") - return False - - async def _send_media_impl( - self, - recipient: str, - file_path: str, - caption: str = "", - metadata: dict | None = None, - ) -> bool: - """Platform-specific media send. Override in subclasses.""" - return False - - # ── Attachment / proxy helpers ───────────────────────────────── - - def _media_path(self, filename: str) -> Path: - """Ensure MEDIA_DIR exists and return a path inside it.""" - return media_path(filename) - - def _resolve_media_chat_id(self, recipient: str, metadata: dict | None) -> str: - """Extract chat_id from metadata, falling back to recipient.""" - return (metadata or {}).get("chat_id", recipient) - - def _get_proxy(self) -> str | None: - """Return the configured proxy URL, or ``None`` if unset/empty.""" - return getattr(self.config, "proxy", None) or None - - def _check_attachment_size(self, file_size: int, filename: str) -> str | None: - """Return a 'too large' annotation string if *file_size* exceeds the limit.""" - return check_attachment_size(file_size, filename) - - async def _download_attachment( - self, - url: str, - filename: str, - *, - headers: dict[str, str] | None = None, - file_size: int | None = None, - ) -> tuple[str | None, str | None]: - """Download an attachment via httpx. Returns ``(local_path, annotation)``. - - Delegates to :func:`download_attachment`. - """ - return await download_attachment( - url, filename, - channel_name=self.name, - headers=headers, - file_size=file_size, - proxy=self._get_proxy(), - ) - - # ── Send retry abstraction ────────────────────────────────────── - - _non_retryable_patterns: tuple[str, ...] = () - _rate_limit_patterns: tuple[str, ...] = ("429", "ratelimit") - _rate_limit_delay: float = 1.0 - - def _extract_retry_after(self, exc: Exception) -> float | None: - """Extract retry-wait seconds from an exception. - - Returns ``None`` to signal that the error is **not retryable**. - - Pipeline: - 1. SDK-provided ``retry_after`` attribute (Telegram / Slack SDKs). - 2. HTTP ``Retry-After`` header via :meth:`_parse_retry_after_header`. - 3. Non-retryable pattern match → ``None``. - 4. Rate-limit pattern match → ``_rate_limit_delay``. - 5. Default ``1.0`` s (generic transient-error retry). - - Channels can customise behaviour declaratively via class attributes - ``_non_retryable_patterns``, ``_rate_limit_patterns``, and - ``_rate_limit_delay``, or override this method entirely. - """ - # 1. SDK retry_after attribute - retry = getattr(exc, "retry_after", None) - if retry is not None: - return float(retry) - - # 2. HTTP Retry-After header - header_val = self._parse_retry_after_header(exc) - if header_val is not None: - return header_val - - msg = str(exc).lower() - - # 3. Non-retryable patterns - if self._non_retryable_patterns and any( - p in msg for p in self._non_retryable_patterns - ): - return None - - # 4. Rate-limit patterns - if self._rate_limit_patterns and any( - p in msg for p in self._rate_limit_patterns - ): - return self._rate_limit_delay - - # 5. Default - return 1.0 - - def _parse_retry_after_header(self, exc: Exception) -> float | None: - """Try to extract a ``Retry-After`` value from an HTTP response.""" - resp = getattr(exc, "response", None) - if resp is None: - return None - headers = getattr(resp, "headers", None) - if not headers: - return None - raw = headers.get("Retry-After") or headers.get("retry-after") - if raw is None: - return None - try: - return float(raw) - except (ValueError, TypeError): - return None - - async def _send_with_retry( - self, - coro_factory: CallableABC[[], Awaitable], - max_retries: int = 3, - ) -> Any: - """Send helper with automatic exponential-backoff retry. - - *coro_factory* is called on every attempt so that the awaitable is - fresh. Uses :func:`retry.retry_async` for backoff, jitter, and - server-supplied ``Retry-After`` support. - - The *max_retries* parameter is accepted for backward compatibility - but the attempt count is taken from ``self._retry_config``. - """ - from .retry import retry_async - - return await retry_async( - coro_factory, - config=self._retry_config, - should_retry=lambda exc, _: self._extract_retry_after(exc) is not None, - retry_after_s=self._extract_retry_after, - on_retry=lambda info: _logger.warning( - f"{self.name} send retry {info.attempt}/{info.max_attempts} " - f"in {info.delay_s:.2f}s: {info.error}" - ), - label=f"{self.name}.send", - ) - - # ── Typing indicator abstraction ───────────────────────────────── - - async def _send_typing_action(self, chat_id: str) -> None: - """Send a single typing indicator. Override in sub-classes.""" - - async def start_typing(self, chat_id: str) -> None: - """Start a background typing-indicator loop for *chat_id*.""" - await self._typing_manager.start(chat_id) - - async def stop_typing(self, chat_id: str) -> None: - """Cancel the typing-indicator loop for *chat_id*.""" - await self._typing_manager.stop(chat_id) - - # ── Mention gating ────────────────────────────────────────────── - - def _should_process(self, raw: RawIncoming) -> bool: - """Decide whether to process a message based on mention gating.""" - if self.require_mention == "off": - return True - # Both "always" and "group" allow DMs through unconditionally - if not raw.is_group: - return True - if self.require_mention == "always": - return raw.was_mentioned - # "group" — require mention only in groups - return raw.was_mentioned - - _mention_pattern: str | None = None - _mention_strip_count: int = 0 # 0 = all occurrences, 1 = first only - - def _get_bot_identifier(self) -> str | None: - """Return the bot's identifier for mention pattern substitution. - - Override in subclasses where ``_mention_pattern`` contains - ``{bot_id}`` placeholder. - """ - return None - - def _strip_mention(self, text: str) -> str: - """Strip bot mention from text using the ``_mention_pattern`` approach.""" - if not self._mention_pattern: - return text - pattern = self._mention_pattern - if "{bot_id}" in pattern: - bot_id = self._get_bot_identifier() - if not bot_id: - return text - pattern = pattern.replace("{bot_id}", re.escape(bot_id)) - return re.sub(pattern, "", text, count=self._mention_strip_count).strip() - - # ── ACK reaction ───────────────────────────────────────────────── - - async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - """Send an acknowledgment reaction to a message. Override in subclasses that support reactions.""" - pass # Default no-op; channels override if they support reactions - - async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - """Remove the ack reaction after replying. Override in subclasses.""" pass - # ── Inbound message pipeline ────────────────────────────────────── + @abstractmethod + async def receive(self) -> AsyncIterator[IncomingMessage]: + """Async iterator that yields incoming messages. - async def _build_inbound_async(self, raw: RawIncoming) -> InboundMessage | None: - """Async version: run *raw* through inbound middlewares and convert.""" - context: dict = {"channel": self} - current: RawIncoming | None = raw - for mw in self._inbound_middlewares: - if current is None: - return None - current = await mw.process_inbound(current, context) - if current is None: - return None - return self._raw_to_inbound(current) + Yields: + IncomingMessage: Each new message received - def _build_inbound(self, raw: RawIncoming) -> InboundMessage | None: - """Run *raw* through inbound middlewares and convert to InboundMessage. - - Synchronous wrapper around :meth:`_build_inbound_async`. Safe to - call from both sync and async contexts. + Example: + async for msg in channel.receive(): + print(f"From {msg.sender}: {msg.content}") """ - import asyncio - import concurrent.futures + pass - try: - asyncio.get_running_loop() - # Inside a running loop — run in a worker thread - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - return pool.submit( - lambda: asyncio.run(self._build_inbound_async(raw)) - ).result() - except RuntimeError: - # No running loop — safe to create one - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(self._build_inbound_async(raw)) - finally: - loop.close() + @abstractmethod + async def send(self, message: OutgoingMessage) -> bool: + """Send a message through the channel. - def _raw_to_inbound(self, raw: RawIncoming) -> InboundMessage | None: - """Convert a RawIncoming to InboundMessage (pure transformation, no filtering). + Args: + message: The message to send - Merges text + annotations into content, sets metadata. - Returns None only if there is no content and no media. + Returns: + True if sent successfully, False otherwise """ - parts = [] - if raw.text: - parts.append(raw.text) - parts.extend(raw.content_annotations) - content = "\n".join(p for p in parts if p) - if not content and not raw.media_files: - return None - meta = dict(raw.metadata) - meta.setdefault("chat_id", raw.chat_id) - return InboundMessage( - channel=self.name, sender_id=raw.sender_id, chat_id=raw.chat_id, - content=content or "[media only]", timestamp=raw.timestamp, - message_id=raw.message_id, media=raw.media_files, metadata=meta, - is_group=raw.is_group, was_mentioned=raw.was_mentioned, - ) - - async def _enqueue_raw(self, raw: RawIncoming) -> None: - """Run *raw* through the inbound middleware pipeline, convert to - InboundMessage, and put it on the queue. - - Convenience method for subclass ``_on_message`` handlers. - """ - msg = self._build_inbound(raw) - if msg is None: - return - if raw.message_id: - try: - await self._send_ack_reaction(raw.chat_id, raw.message_id) - except Exception: - pass - await self._queue.put(msg) - - # ── Bus integration ────────────────────────────────────────────── - - def set_bus(self, bus) -> None: - """Inject the MessageBus reference (called by ChannelManager).""" - self._bus = bus - - async def queue_message(self, msg: InboundMessage) -> None: - """Buffer *msg* with debounce, then publish to bus.""" - sender = msg.sender_id - - if sender not in self._message_buffers: - self._message_buffers[sender] = [] - self._message_metadata[sender] = msg.metadata - self._message_media[sender] = [] - self._message_is_group[sender] = msg.is_group - self._message_was_mentioned[sender] = msg.was_mentioned - self._message_buffers[sender].append(msg.content) - if msg.message_id: - self._message_ids[sender] = msg.message_id - if msg.media: - self._message_media[sender].extend(msg.media) - - if self._on_activity: - try: - self._on_activity(sender, "received") - except Exception: - pass - - if sender in self._debounce_tasks: - self._debounce_tasks[sender].cancel() - - msg_count = len(self._message_buffers[sender]) - wait = min( - self.initial_debounce + (msg_count - 1) * self.debounce_step, - self.max_debounce, - ) - _logger.debug( - f"Debounce for {sender}: {wait:.1f}s (message #{msg_count})" - ) - - async def debounce_callback(_s=sender, _w=wait): - await asyncio.sleep(_w) - await self._process_buffered_messages(_s) - - self._debounce_tasks[sender] = asyncio.create_task( - debounce_callback() - ) - - async def _process_buffered_messages(self, sender: str) -> None: - """Flush buffered messages for *sender* and publish to bus.""" - if sender not in self._message_buffers: - return - - messages = self._message_buffers.pop(sender, []) - metadata = self._message_metadata.pop(sender, None) - media = self._message_media.pop(sender, []) - message_id = self._message_ids.pop(sender, "") - is_group = self._message_is_group.pop(sender, False) - was_mentioned = self._message_was_mentioned.pop(sender, True) - self._debounce_tasks.pop(sender, None) - if not messages: - return - - merged_content = "\n".join(messages) - _logger.info( - f"Processing {len(messages)} merged message(s) from {sender}" - ) - - if self._bus: - chat_id = (metadata or {}).get("chat_id", sender) - inbound = InboundMessage( - channel=self.name, - sender_id=sender, - chat_id=str(chat_id), - content=merged_content, - media=media, - metadata=metadata or {}, - message_id=message_id, - is_group=is_group, - was_mentioned=was_mentioned, - ) - await self._bus.publish_inbound(inbound) - - async def _send_status_message( - self, sender: str, content: str, metadata: dict | None = None, - ) -> None: - """Send a status/intermediate message to the channel.""" - chat_id = (metadata or {}).get("chat_id", sender) - await self.send(OutboundMessage( - channel=self.name, - chat_id=str(chat_id), - content=content, - metadata=metadata or {}, - )) - - async def send_thinking_message( - self, sender: str, thinking: str, metadata: dict | None = None, - ) -> None: - """Send a thinking intermediate message to the channel.""" - if not self.send_thinking: - return - await self._send_status_message(sender, f"\U0001f9e0\n{thinking}\n\u23f3", metadata) - - async def send_todo_message( - self, sender: str, content: str, metadata: dict | None = None, - ) -> None: - """Send a todo list intermediate message to the channel.""" - await self._send_status_message(sender, content, metadata) - - async def run(self) -> None: - """Run the channel with auto-reconnect (exponential backoff).""" - backoff = 1.0 - max_backoff = 60.0 - self._running = True - while self._running: - try: - await self.start() - backoff = 1.0 - async for msg in self.receive(): - _logger.info(f"From {msg.sender_id}: {msg.content[:50]}...") - await self.queue_message(msg) - except asyncio.CancelledError: - break - except ChannelError as e: - _logger.error(f"Channel {self.name} fatal error: {e}") - self._running = False - break - except Exception as e: - _logger.error(f"Channel {self.name} error: {e}") - finally: - for task in self._debounce_tasks.values(): - task.cancel() - self._debounce_tasks.clear() - # Preserve reconnect intent across stop() - should_reconnect = self._running - try: - await self.stop() - except Exception: - pass - self._running = should_reconnect - - if self._running: - _logger.info( - f"Reconnecting {self.name} in {backoff:.1f}s..." - ) - await asyncio.sleep(backoff) - backoff = min(backoff * 2, max_backoff) - - # ── Channel allow-list check ───────────────────────────────────── - - def is_channel_allowed(self, channel_id: str) -> bool: - """Return ``True`` if *channel_id* is permitted by config. - - When the allow-list is empty or absent every channel is allowed. - """ - allowed = getattr(self.config, "allowed_channels", None) - return not allowed or str(channel_id) in allowed - - # ── Sender allow-list check ────────────────────────────────────── - - def is_allowed(self, sender: str) -> bool: - """Check if *sender* is permitted by ``self.config.allowed_senders``. - - Returns ``True`` when the allow-list is empty / None (open access). - Supports ``|``-separated composite IDs (e.g. ``"uid|gid"``). - Subclasses with richer filtering (iMessage) may override. - """ - config = getattr(self, "config", None) - allowed = getattr(config, "allowed_senders", None) if config else None - if not allowed: - return True - sender_str = str(sender) - if sender_str in allowed: - return True - if "|" in sender_str: - for part in sender_str.split("|"): - if part and part in allowed: - return True - return False + pass class ChannelError(Exception): """Base exception for channel-related errors.""" pass + + +class ChannelPermissionError(ChannelError): + """Raised when the channel lacks required permissions.""" + + pass + + +class ChannelConnectionError(ChannelError): + """Raised when the channel cannot establish a connection.""" + + pass diff --git a/EvoScientist/channels/bus/__init__.py b/EvoScientist/channels/bus/__init__.py deleted file mode 100644 index 3510967..0000000 --- a/EvoScientist/channels/bus/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Message bus for decoupled channel-agent communication.""" - -from .events import InboundMessage, OutboundMessage -from .message_bus import MessageBus - -__all__ = ["MessageBus", "InboundMessage", "OutboundMessage"] diff --git a/EvoScientist/channels/bus/events.py b/EvoScientist/channels/bus/events.py deleted file mode 100644 index 411006f..0000000 --- a/EvoScientist/channels/bus/events.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Event types for the message bus.""" - -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any - - -@dataclass -class InboundMessage: - """Message received from a chat channel. - - Carries enough context for the bus to route and for the agent - to build a session: which channel, who sent it, which chat. - """ - - channel: str - sender_id: str - chat_id: str - content: str - timestamp: datetime = field(default_factory=datetime.now) - message_id: str = "" - media: list[str] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) - is_group: bool = False - was_mentioned: bool = True - - @property - def sender(self) -> str: - """Alias for ``sender_id`` (compatibility with IncomingMessage).""" - return self.sender_id - - @property - def session_key(self) -> str: - """Unique key for session identification: ``channel:chat_id``.""" - return f"{self.channel}:{self.chat_id}" - - -@dataclass -class OutboundMessage: - """Message to send to a chat channel.""" - - channel: str - chat_id: str - content: str - reply_to: str | None = None - media: list[str] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) - - @property - def recipient(self) -> str: - """Alias for ``chat_id`` (compatibility with OutgoingMessage).""" - return self.chat_id diff --git a/EvoScientist/channels/bus/message_bus.py b/EvoScientist/channels/bus/message_bus.py deleted file mode 100644 index 3e68697..0000000 --- a/EvoScientist/channels/bus/message_bus.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Async message bus that decouples chat channels from the agent core. - -Channels push messages to the inbound queue; the agent (or any consumer) -reads from inbound, processes, and pushes responses to the outbound queue. -A background dispatcher routes outbound messages to the correct channel -via subscriber callbacks. - -Deduplication is handled at the Channel level (single dedup point). -""" - -import asyncio -import logging -from typing import Callable, Awaitable - -from .events import InboundMessage, OutboundMessage - -logger = logging.getLogger(__name__) - -OutboundCallback = Callable[[OutboundMessage], Awaitable[None]] - - -class MessageBus: - """Async message bus that decouples chat channels from the agent core.""" - - def __init__(self): - self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=5000) - self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue(maxsize=5000) - self._outbound_subscribers: dict[str, list[OutboundCallback]] = {} - self._running = False - - # ── inbound (channel → agent) ── - - async def publish_inbound(self, msg: InboundMessage) -> None: - """Publish a message from a channel to the agent.""" - await self.inbound.put(msg) - - async def consume_inbound(self) -> InboundMessage: - """Consume the next inbound message (blocks until available).""" - return await self.inbound.get() - - # ── outbound (agent → channel) ── - - async def publish_outbound(self, msg: OutboundMessage) -> None: - """Publish a response from the agent to channels.""" - await self.outbound.put(msg) - - async def consume_outbound(self) -> OutboundMessage: - """Consume the next outbound message (blocks until available).""" - return await self.outbound.get() - - # ── subscriber routing ── - - def subscribe_outbound( - self, channel: str, callback: OutboundCallback, - ) -> None: - """Register a callback for outbound messages targeting *channel*.""" - if channel not in self._outbound_subscribers: - self._outbound_subscribers[channel] = [] - self._outbound_subscribers[channel].append(callback) - - async def dispatch_outbound(self) -> None: - """Route outbound messages to subscribed channels. - - Run as a background task — loops until :meth:`stop` is called. - """ - self._running = True - while self._running: - try: - msg = await asyncio.wait_for( - self.outbound.get(), timeout=1.0, - ) - except asyncio.TimeoutError: - continue - subscribers = self._outbound_subscribers.get(msg.channel, []) - if not subscribers: - logger.warning(f"No subscriber for channel: {msg.channel}") - continue - for callback in subscribers: - try: - await callback(msg) - except Exception as e: - logger.error( - f"Error dispatching to {msg.channel}: {e}" - ) - - def stop(self) -> None: - """Stop the dispatcher loop.""" - self._running = False - - @property - def inbound_size(self) -> int: - return self.inbound.qsize() - - @property - def outbound_size(self) -> int: - return self.outbound.qsize() diff --git a/EvoScientist/channels/capabilities.py b/EvoScientist/channels/capabilities.py deleted file mode 100644 index 5060dd9..0000000 --- a/EvoScientist/channels/capabilities.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Channel capabilities declaration system. - -Each channel declares its capabilities via a ChannelCapabilities dataclass, -enabling the framework to adapt behavior automatically (formatting, reactions, -streaming, threading, etc.) without per-channel branching in core logic. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - -FormatType = Literal["html", "markdown", "slack_mrkdwn", "discord", "plain"] - - -@dataclass(frozen=True) -class ChannelCapabilities: - """Immutable declaration of what a channel supports. - - Set once as a class attribute on each Channel subclass. - The framework inspects these at runtime to auto-configure behavior. - """ - - # ── Messaging features ────────────────────────────────────────── - format_type: FormatType = "plain" - max_text_length: int = 4096 - max_file_size: int = 20 * 1024 * 1024 # 20 MB - - # ── Interaction capabilities ──────────────────────────────────── - streaming: bool = False # edit-in-place streaming output - threading: bool = False # message threads / topics - reactions: bool = False # emoji reactions on messages - typing: bool = False # typing indicator API - inline_buttons: bool = False # inline keyboard / action buttons - - # ── Media capabilities ────────────────────────────────────────── - media_send: bool = False # can send files/images - media_receive: bool = False # can receive files/images - voice: bool = False # platform has voice/audio messages that arrive as downloadable files (receive only, not bot sending) - stickers: bool = False # supports sticker receive (not bot sending) - location: bool = False # supports location message receive (not bot sending) - video: bool = False # video messages - - # ── Group features ────────────────────────────────────────────── - groups: bool = False # group chat support - mentions: bool = False # @mention detection - - # ── Rich text ─────────────────────────────────────────────────── - markdown: bool = False # supports Markdown rendering - html: bool = False # supports HTML rendering - - # ── Extended capabilities ──────────────────────────────────────── - chat_types: tuple[str, ...] = () # ("direct", "group", "channel", "thread") - edit: bool = False # message editing after send - unsend: bool = False # message recall / unsend - block_streaming: bool = False # block edit-in-place streaming - native_commands: bool = False # platform-native slash commands - polls: bool = False # poll / vote messages - - def supports(self, feature: str) -> bool: - """Check if a feature is supported by name.""" - return getattr(self, feature, False) - - -# ═════════════════════════════════════════════════════════════════════ -# Pre-built capability profiles for each channel -# ═════════════════════════════════════════════════════════════════════ - -TELEGRAM = ChannelCapabilities( - format_type="html", - max_text_length=4000, - streaming=False, # could edit messages, but not implemented yet - threading=False, # topics exist but not used yet - reactions=True, - typing=True, - media_send=True, - media_receive=True, - voice=True, - stickers=True, - location=True, - groups=True, - mentions=True, - html=True, - chat_types=("direct", "group", "channel"), - edit=True, - unsend=True, - native_commands=True, - polls=True, -) - -DISCORD = ChannelCapabilities( - format_type="discord", - max_text_length=2000, - streaming=False, - threading=True, - reactions=True, - typing=True, - media_send=True, - media_receive=True, - voice=False, # no distinct voice message type in Discord bot API - groups=True, - mentions=True, - markdown=True, - chat_types=("direct", "group", "thread"), - edit=True, - unsend=True, - native_commands=True, - polls=True, -) - -SLACK = ChannelCapabilities( - format_type="slack_mrkdwn", - max_text_length=4000, - streaming=False, - threading=True, - reactions=True, - typing=True, - media_send=True, - media_receive=True, - voice=False, # no distinct voice message type in Slack bot API - groups=True, - mentions=True, - chat_types=("direct", "group", "thread"), - edit=True, - unsend=True, - native_commands=True, -) - -FEISHU = ChannelCapabilities( - format_type="markdown", - max_text_length=4096, - reactions=True, - typing=False, # no typing API - media_send=True, - media_receive=True, - voice=True, - stickers=True, - groups=True, - mentions=True, - markdown=True, - chat_types=("direct", "group"), - edit=True, - unsend=True, -) - -DINGTALK = ChannelCapabilities( - format_type="markdown", - max_text_length=4096, - typing=False, # no typing API for bots - media_send=True, - media_receive=True, - voice=True, - groups=True, - mentions=True, - markdown=True, - chat_types=("direct", "group"), -) - -QQ = ChannelCapabilities( - format_type="plain", - max_text_length=4096, - typing=False, # no typing API for QQ bots - media_send=True, - media_receive=True, - voice=False, # qq-botpy does not expose voice as a distinct message type - groups=True, - mentions=True, - chat_types=("direct", "group", "channel"), - unsend=True, -) - -WECHAT = ChannelCapabilities( - format_type="markdown", # WeCom supports markdown - max_text_length=4096, - typing=False, # no typing API - media_send=True, - media_receive=True, - voice=True, - location=True, - groups=True, - mentions=True, - markdown=True, - chat_types=("direct", "group"), - unsend=True, -) - -SIGNAL = ChannelCapabilities( - format_type="plain", - max_text_length=4096, - reactions=True, - typing=True, - media_send=True, - media_receive=True, - voice=True, - groups=True, - mentions=True, - chat_types=("direct", "group"), -) - -EMAIL = ChannelCapabilities( - format_type="html", - max_text_length=999_999, # no practical limit - media_send=True, - media_receive=True, - html=True, - chat_types=("direct",), -) - -IMESSAGE = ChannelCapabilities( - format_type="plain", - max_text_length=999_999, - typing=False, # Apple does not expose typing indicator API - media_send=True, - media_receive=True, - voice=True, - groups=True, - mentions=False, # iMessage has no @mention concept - reactions=False, # imsg CLI cannot send tapback reactions - chat_types=("direct", "group"), -) diff --git a/EvoScientist/channels/channel_manager.py b/EvoScientist/channels/channel_manager.py deleted file mode 100644 index 989cc64..0000000 --- a/EvoScientist/channels/channel_manager.py +++ /dev/null @@ -1,995 +0,0 @@ -"""Unified channel manager for coordinating chat channels. - -Manages channel lifecycle (start/stop), wires each channel to the -message bus, and routes outbound messages to the correct channel. - -Also provides the global channel registry (formerly in ``registry.py``), -account management (formerly ``account.py``), and pipeline assembly -(formerly ``pipeline.py``). -""" - -from __future__ import annotations - -import asyncio -import importlib -import json -import logging -import pkgutil -import time -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from typing import Any, Callable - -from .base import Channel, OutboundMessage -from .bus import MessageBus -from .middleware import OutboundMiddlewareBase -from .plugin import ChannelPlugin - -logger = logging.getLogger(__name__) - - -# ═════════════════════════════════════════════════════════════════════ -# Account management (formerly account.py) -# ═════════════════════════════════════════════════════════════════════ - -@dataclass -class ChannelAccountSnapshot: - """Point-in-time snapshot of a single account's connection state.""" - - account_id: str - channel: str - connected: bool = False - started_at: float = 0.0 - last_outbound_at: float = 0.0 - error: str | None = None - - def mark_connected(self) -> None: - self.connected = True - self.started_at = time.monotonic() - self.error = None - - def mark_disconnected(self, error: str | None = None) -> None: - self.connected = False - self.error = error - - def mark_outbound(self) -> None: - self.last_outbound_at = time.monotonic() - - -@dataclass -class AccountConfig: - """Per-account configuration wrapper.""" - - account_id: str - channel_id: str # which plugin - enabled: bool = True - config: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class AccountState: - """Runtime state for a single account.""" - - account_id: str - channel_id: str - status: str = "stopped" # stopped | starting | running | error - snapshot: ChannelAccountSnapshot | None = None - error: str | None = None - started_at: float = 0.0 - - -class AccountManager: - """Manages multiple accounts across channel plugins. - - Works with the ``ConfigAdapter`` protocol on each plugin to discover - accounts and manage their lifecycle independently. - """ - - def __init__(self) -> None: - self._plugins: dict[str, ChannelPlugin] = {} - self._states: dict[str, AccountState] = {} # key: "{channel_id}:{account_id}" - - @staticmethod - def _key(channel_id: str, account_id: str) -> str: - return f"{channel_id}:{account_id}" - - def register_plugin(self, plugin: ChannelPlugin) -> None: - """Register a plugin that supports multi-account.""" - self._plugins[plugin.id] = plugin - logger.info(f"AccountManager: registered plugin '{plugin.id}'") - - async def start_account( - self, - channel_id: str, - account_id: str, - config: Any = None, - ) -> None: - """Start a specific account on a plugin.""" - plugin = self._plugins.get(channel_id) - if plugin is None: - raise ValueError(f"No plugin registered for channel '{channel_id}'") - - key = self._key(channel_id, account_id) - state = self._states.get(key) - if state is None: - state = AccountState(account_id=account_id, channel_id=channel_id) - self._states[key] = state - - if state.status == "running": - logger.warning(f"Account {key} is already running") - return - - state.status = "starting" - state.error = None - try: - account_config = config - if plugin.config_adapter is not None and config is not None: - account_config = plugin.config_adapter.resolve_account(config, account_id) - - await plugin.start(account_config, account_id=account_id) - state.status = "running" - state.started_at = time.monotonic() - state.snapshot = ChannelAccountSnapshot( - account_id=account_id, channel=channel_id, - ) - state.snapshot.mark_connected() - logger.info(f"Account {key} started") - except Exception as e: - state.status = "error" - state.error = str(e) - logger.error(f"Failed to start account {key}: {e}") - raise - - async def stop_account(self, channel_id: str, account_id: str) -> None: - """Stop a specific account on a plugin.""" - plugin = self._plugins.get(channel_id) - if plugin is None: - raise ValueError(f"No plugin registered for channel '{channel_id}'") - - key = self._key(channel_id, account_id) - state = self._states.get(key) - if state is None or state.status == "stopped": - logger.debug(f"Account {key} is already stopped") - return - - try: - await plugin.stop(account_id=account_id) - state.status = "stopped" - if state.snapshot is not None: - state.snapshot.mark_disconnected() - logger.info(f"Account {key} stopped") - except Exception as e: - state.status = "error" - state.error = str(e) - if state.snapshot is not None: - state.snapshot.mark_disconnected(error=str(e)) - logger.error(f"Error stopping account {key}: {e}") - raise - - async def restart_account( - self, - channel_id: str, - account_id: str, - config: Any = None, - ) -> None: - """Restart a specific account.""" - await self.stop_account(channel_id, account_id) - await self.start_account(channel_id, account_id, config) - - async def start_all(self, channel_id: str, config: Any = None) -> None: - """Start all accounts for a given channel plugin.""" - plugin = self._plugins.get(channel_id) - if plugin is None: - raise ValueError(f"No plugin registered for channel '{channel_id}'") - - adapter = plugin.config_adapter - if adapter is None: - await self.start_account(channel_id, "default", config) - return - - if config is None: - logger.warning(f"No config provided for start_all on '{channel_id}'") - return - - for account_id in adapter.list_account_ids(config): - if adapter.is_enabled( - adapter.resolve_account(config, account_id), config, - ): - try: - await self.start_account(channel_id, account_id, config) - except Exception as e: - logger.error( - f"Failed to start account {channel_id}:{account_id}: {e}" - ) - - async def stop_all(self, channel_id: str) -> None: - """Stop all accounts for a given channel plugin.""" - keys_to_stop = [ - (state.channel_id, state.account_id) - for state in self._states.values() - if state.channel_id == channel_id and state.status != "stopped" - ] - for cid, aid in keys_to_stop: - try: - await self.stop_account(cid, aid) - except Exception as e: - logger.error(f"Failed to stop account {cid}:{aid}: {e}") - - def get_state( - self, channel_id: str, account_id: str, - ) -> AccountState | None: - """Get the runtime state for a specific account.""" - return self._states.get(self._key(channel_id, account_id)) - - def list_accounts( - self, channel_id: str | None = None, - ) -> list[AccountState]: - """List account states, optionally filtered by channel.""" - if channel_id is None: - return list(self._states.values()) - return [ - s for s in self._states.values() if s.channel_id == channel_id - ] - - def get_snapshot( - self, channel_id: str, account_id: str, - ) -> ChannelAccountSnapshot | None: - """Get the connection snapshot for a specific account.""" - state = self._states.get(self._key(channel_id, account_id)) - return state.snapshot if state else None - - -# ═════════════════════════════════════════════════════════════════════ -# Inbound / outbound pipelines (formerly pipeline.py) -# ═════════════════════════════════════════════════════════════════════ - - -class OutboundPipeline: - """Processes outgoing messages through a middleware chain.""" - - def __init__( - self, - plugin: ChannelPlugin, - middlewares: list[OutboundMiddlewareBase], - ) -> None: - self.plugin = plugin - self.middlewares = middlewares - - async def process( - self, - message: OutboundMessage, - context: dict[str, Any] | None = None, - ) -> OutboundMessage | None: - """Run *message* through each middleware. Returns ``None`` if dropped.""" - ctx = context or {} - current: OutboundMessage | None = message - for mw in self.middlewares: - if current is None: - return None - current = await mw.process_outbound(current, ctx) - return current - - -def build_outbound_pipeline( - plugin: ChannelPlugin, - config: Any, -) -> OutboundPipeline: - """Auto-assemble outbound pipeline based on plugin capabilities. - - FormattingMiddleware has been removed — Channel.send() handles - formatting + chunking via _format_chunk() / _prepare_chunks(). - """ - middlewares: list[OutboundMiddlewareBase] = [] - return OutboundPipeline(plugin, middlewares) - - -# ── Per-channel health tracking ────────────────────────────────────── - -@dataclass -class ChannelHealth: - """Tracks send success / failure metrics for a single channel.""" - - consecutive_failures: int = 0 - last_failure_time: float | None = None - last_failure_error: str | None = None - total_failures: int = 0 - total_successes: int = 0 - - -# ── Minimal HTTP health-check server ──────────────────────────────── - -class _HealthServer: - """Zero-dependency HTTP health-check endpoint using ``asyncio.start_server``. - - Responds to ``GET /healthz`` with a JSON status payload; all other - requests receive a 404. A per-connection timeout prevents slow - clients from tying up the server. - """ - - _CONNECTION_TIMEOUT = 5.0 # seconds - - def __init__(self, manager: ChannelManager, port: int) -> None: - self._manager = manager - self._port = port - self._server: asyncio.AbstractServer | None = None - self._start_time: float = 0.0 - - async def start(self) -> None: - self._start_time = time.monotonic() - self._server = await asyncio.start_server( - self._handle_connection, "0.0.0.0", self._port, - ) - addrs = [s.getsockname() for s in self._server.sockets] - logger.info(f"Health server listening on {addrs}") - - async def stop(self) -> None: - if self._server is not None: - self._server.close() - await self._server.wait_closed() - self._server = None - logger.info("Health server stopped") - - async def _handle_connection( - self, - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - ) -> None: - try: - await asyncio.wait_for( - self._process_request(reader, writer), - timeout=self._CONNECTION_TIMEOUT, - ) - except (asyncio.TimeoutError, ConnectionError, OSError): - pass - finally: - try: - writer.close() - await writer.wait_closed() - except (ConnectionError, OSError): - pass - - async def _process_request( - self, - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - ) -> None: - request_line = await reader.readline() - # Consume remaining headers - while True: - line = await reader.readline() - if line in (b"\r\n", b"\n", b""): - break - - parts = request_line.decode("utf-8", errors="replace").split() - if len(parts) >= 2 and parts[0] == "GET" and parts[1] == "/healthz": - body = self._build_response() - payload = json.dumps(body).encode() - header = ( - "HTTP/1.1 200 OK\r\n" - "Content-Type: application/json\r\n" - f"Content-Length: {len(payload)}\r\n" - "Connection: close\r\n" - "\r\n" - ) - else: - payload = b'{"error":"not found"}' - header = ( - "HTTP/1.1 404 Not Found\r\n" - "Content-Type: application/json\r\n" - f"Content-Length: {len(payload)}\r\n" - "Connection: close\r\n" - "\r\n" - ) - writer.write(header.encode() + payload) - await writer.drain() - - def _build_response(self) -> dict[str, Any]: - mgr = self._manager - health_map: dict[str, Any] = {} - for name, h in mgr._health.items(): - health_map[name] = { - "consecutive_failures": h.consecutive_failures, - "total_successes": h.total_successes, - "total_failures": h.total_failures, - } - accounts_map: dict[str, Any] = {} - for state in mgr._account_manager.list_accounts(): - key = f"{state.channel_id}:{state.account_id}" - accounts_map[key] = { - "account_id": state.account_id, - "channel": state.channel_id, - "status": state.status, - "error": state.error, - } - resp: dict[str, Any] = { - "status": "healthy", - "uptime_seconds": round(time.monotonic() - self._start_time, 1), - "channels": { - "enabled": mgr.enabled_channels, - "running": mgr.running_channels(), - }, - "queues": { - "inbound_size": mgr.bus.inbound_size, - "outbound_size": mgr.bus.outbound_size, - }, - "health": health_map, - "accounts": accounts_map, - } - for pname, provider in mgr._health_providers.items(): - try: - resp[pname] = provider() - except Exception: - resp[pname] = {"error": "provider failed"} - return resp - - -# ── Channel registry ────────────────────────────────────────────────── - -ChannelFactory = Callable[..., Channel] - -_CHANNEL_REGISTRY: dict[str, ChannelFactory] = {} - - -def _parse_csv(value: str) -> set[str] | None: - """Parse comma-separated string into a set, or ``None`` if empty.""" - if not value or not value.strip(): - return None - items = {s.strip() for s in value.split(",") if s.strip()} - return items if items else None - - -def register_channel(name: str, factory: ChannelFactory) -> None: - """Register a channel factory under *name*.""" - _CHANNEL_REGISTRY[name] = factory - - -def create_channel(name: str, config) -> Channel: - """Create a channel instance using the registered factory for *name*.""" - factory = _CHANNEL_REGISTRY.get(name) - if not factory: - raise ValueError( - f"Unknown channel type: {name}. " - f"Available: {list(_CHANNEL_REGISTRY.keys())}" - ) - return factory(config) - - -def available_channels() -> list[str]: - """Return the names of all available channel types. - - Triggers auto-discovery if the registry is empty. - """ - if not _CHANNEL_REGISTRY: - _ensure_channels_registered() - return list(_CHANNEL_REGISTRY.keys()) - - -def _discover_channel_subpackages() -> list[str]: - """Discover all channel sub-packages under the channels directory. - - Returns a list of sub-package names (e.g. ["telegram", "discord", ...]). - Excludes non-channel directories (bus, __pycache__) and plain modules. - """ - channels_dir = Path(__file__).parent - _EXCLUDED = {"bus", "__pycache__"} - names = [] - for info in pkgutil.iter_modules([str(channels_dir)]): - if info.ispkg and info.name not in _EXCLUDED: - names.append(info.name) - return sorted(names) - - -def _ensure_channels_registered(types: list[str] | None = None) -> None: - """Lazily import channel sub-packages to trigger registration. - - If *types* is given, only those channels are imported. - If *types* is ``None``, all discovered channel sub-packages are imported. - """ - if types is None: - targets = _discover_channel_subpackages() - else: - # Only import the ones that exist as sub-packages - available = set(_discover_channel_subpackages()) - targets = [t for t in types if t in available] - - for t in targets: - module_name = f"EvoScientist.channels.{t}" - if t not in _CHANNEL_REGISTRY: - try: - importlib.import_module(module_name) - except ImportError as e: - logger.debug(f"Could not import channel {t}: {e}") - - -# ── Shared webhook server ───────────────────────────────────────── - -class SharedWebhookServer: - """Single aiohttp server that hosts routes from multiple HTTP channels. - - When ``shared_webhook_port`` is configured, ``ChannelManager`` collects - routes from every channel that exposes ``_webhook_routes()`` and starts - one server instead of letting each channel bind its own port. - """ - - def __init__(self, port: int) -> None: - self._port = port - self._app: Any = None - self._runner: Any = None - self._site: Any = None - - async def start(self, routes: list[tuple[str, str, Any]]) -> None: - from aiohttp import web - - self._app = web.Application() - for method, path, handler in routes: - if method.upper() == "GET": - self._app.router.add_get(path, handler) - else: - self._app.router.add_post(path, handler) - - self._runner = web.AppRunner(self._app) - await self._runner.setup() - self._site = web.TCPSite(self._runner, "0.0.0.0", self._port) - await self._site.start() - logger.info( - f"Shared webhook server started on 0.0.0.0:{self._port} " - f"with {len(routes)} route(s)" - ) - - async def stop(self) -> None: - if self._site: - await self._site.stop() - self._site = None - if self._runner: - await self._runner.cleanup() - self._runner = None - logger.info("Shared webhook server stopped") - - -class ChannelManager: - """Manages all chat channels and coordinates message routing. - - Responsibilities: - - Register channels and inject bus reference - - Start / stop all channels - - Route outbound messages from the bus to the correct channel - """ - - def __init__( - self, - bus: MessageBus, - *, - health_port: int = 8080, - drain_timeout: float = 30.0, - shared_webhook_port: int = 0, - ): - self.bus = bus - self._channels: dict[str, Channel] = {} - self._tasks: list[asyncio.Task] = [] - self._dispatch_task: asyncio.Task | None = None - self._start_times: dict[str, datetime] = {} - self._message_counts: dict[str, dict[str, int]] = {} - self._health: dict[str, ChannelHealth] = {} - self._is_running: bool = False - self._health_port = health_port - self._health_server: _HealthServer | None = None - self._drain_timeout = drain_timeout - self._health_providers: dict[str, Callable[[], dict]] = {} - self._account_manager = AccountManager() - # Pipelines (built during registration) - self._outbound_pipelines: dict[str, OutboundPipeline] = {} - # Shared webhook - self._shared_webhook_port = shared_webhook_port - self._shared_webhook_server: SharedWebhookServer | None = None - - @classmethod - def from_config(cls, config, bus: MessageBus | None = None) -> "ChannelManager": - """Create a ChannelManager from application config. - - Parses ``config.channel_enabled`` (comma-separated channel types), - creates each Channel instance, and registers them. - - Args: - config: Application config with channel settings. - bus: Optional MessageBus instance. A new one is created if not provided. - - Returns: - A fully configured ChannelManager. - """ - if bus is None: - bus = MessageBus() - shared_webhook_port = getattr(config, "shared_webhook_port", 0) or 0 - manager = cls(bus, shared_webhook_port=shared_webhook_port) - types = [t.strip() for t in (config.channel_enabled or "").split(",") if t.strip()] - if not types: - raise ValueError("No channels enabled") - _ensure_channels_registered(types) - for ct in types: - channel = create_channel(ct, config) - manager.register(channel, config=config) - return manager - - # ── registration ── - - def register( - self, - channel: Channel, - *, - config: Any = None, - **kwargs: Any, - ) -> Channel: - """Register a channel and inject the bus reference. - - Since Channel IS-A ChannelPlugin, the channel is also registered - in the plugin registry. If *config* is provided, inbound/outbound - pipelines are built for the channel. - - Args: - channel: The channel instance (must have a unique ``name``). - config: Optional app config for building pipelines. - **kwargs: Extra kwargs applied to the channel - (e.g. ``send_thinking=True``, ``initial_debounce=3.0``). - - Returns: - The channel instance. - """ - name = channel.name - if name in self._channels: - raise ValueError(f"Channel '{name}' already registered") - - channel.set_bus(self.bus) - for key, value in kwargs.items(): - if hasattr(channel, key): - setattr(channel, key, value) - self._channels[name] = channel - self._health[name] = ChannelHealth() - if channel.config_adapter is not None: - self._account_manager.register_plugin(channel) - if config is not None: - self._outbound_pipelines[name] = build_outbound_pipeline(channel, config) - logger.info(f"Registered channel: {name} (slots: {channel.filled_slots()})") - return channel - - # ── lifecycle ── - - async def start_all(self) -> None: - """Start the outbound dispatcher and all registered channels.""" - if not self._channels: - logger.warning("No channels registered") - return - - self._is_running = True - - await self.start_health() - - # Start shared webhook server before individual channels - await self._setup_shared_webhook() - - self._dispatch_task = asyncio.create_task( - self._dispatch_outbound() - ) - - now = datetime.now() - for name, channel in self._channels.items(): - logger.info(f"Starting channel: {name}") - self._start_times[name] = now - if name not in self._message_counts: - self._message_counts[name] = {"received": 0, "sent": 0} - task = asyncio.create_task(channel.run()) - self._tasks.append(task) - - await asyncio.gather(*self._tasks, return_exceptions=True) - - async def stop_all(self) -> None: - """Stop all channels and the outbound dispatcher. - - Before shutting down channels, attempts to drain the outbound - queue so that pending replies are delivered. - """ - logger.info("Stopping all channels...") - self._is_running = False - - # Drain outbound queue — try to send pending replies - drained = 0 - deadline = time.monotonic() + self._drain_timeout - while time.monotonic() < deadline: - try: - msg = self.bus.outbound.get_nowait() - except asyncio.QueueEmpty: - break - channel = self._channels.get(msg.channel) - if channel and msg.content: - try: - await asyncio.wait_for( - channel.send(msg), - timeout=max(1.0, deadline - time.monotonic()), - ) - drained += 1 - except Exception: - pass - dropped = self.bus.outbound.qsize() - if drained or dropped: - logger.info(f"Outbound drain: {drained} sent, {dropped} dropped") - - if self._dispatch_task: - self._dispatch_task.cancel() - try: - await self._dispatch_task - except asyncio.CancelledError: - pass - - for name, channel in self._channels.items(): - try: - channel._running = False - await channel.stop() - logger.info(f"Stopped channel: {name}") - except Exception as e: - logger.error(f"Error stopping {name}: {e}") - - for task in self._tasks: - task.cancel() - self._tasks.clear() - - # Stop shared webhook server - if self._shared_webhook_server is not None: - await self._shared_webhook_server.stop() - self._shared_webhook_server = None - - await self.stop_health() - - # ── health server ── - - async def start_health(self) -> None: - """Start the HTTP health-check endpoint (if configured).""" - if self._health_port and self._health_server is None: - self._health_server = _HealthServer(self, self._health_port) - await self._health_server.start() - - async def stop_health(self) -> None: - """Stop the HTTP health-check endpoint.""" - if self._health_server is not None: - await self._health_server.stop() - self._health_server = None - - # ── shared webhook ── - - async def _setup_shared_webhook(self) -> None: - """Collect routes from HTTP channels and start a shared server. - - Only active when ``shared_webhook_port > 0``. For each channel - that exposes ``_webhook_routes()``, the routes are gathered and - a sentinel attribute (``_shared_webhook_server``) is set so the - channel's own ``start()`` skips creating its own aiohttp server. - """ - if not self._shared_webhook_port: - return - - all_routes: list[tuple[str, str, Any]] = [] - for name, channel in self._channels.items(): - routes_fn = getattr(channel, "_webhook_routes", None) - if routes_fn is None: - continue - routes = routes_fn() - if not routes: - continue - # Set sentinel so the channel skips its own server - channel._shared_webhook_server = True # type: ignore[attr-defined] - all_routes.extend(routes) - logger.debug( - f"Shared webhook: collected {len(routes)} route(s) " - f"from '{name}'" - ) - - if not all_routes: - logger.info("Shared webhook: no HTTP channels found, skipping") - return - - self._shared_webhook_server = SharedWebhookServer( - self._shared_webhook_port, - ) - await self._shared_webhook_server.start(all_routes) - - def register_health_provider( - self, name: str, provider: Callable[[], dict], - ) -> None: - """Register a callable that returns extra data for ``/healthz``.""" - self._health_providers[name] = provider - - # ── outbound routing ── - - async def _dispatch_outbound(self) -> None: - """Route outbound messages from the bus to the correct channel.""" - logger.info("Outbound dispatcher started") - while True: - try: - msg: OutboundMessage = await asyncio.wait_for( - self.bus.consume_outbound(), timeout=1.0, - ) - except asyncio.TimeoutError: - continue - except asyncio.CancelledError: - break - - channel = self._channels.get(msg.channel) - - if not channel: - logger.warning(f"Unknown channel: {msg.channel}") - continue - - try: - # Run outbound pipeline if available (formatting, etc.) - if msg.channel in self._outbound_pipelines: - processed = await self._outbound_pipelines[msg.channel].process(msg) - if processed is None: - continue # dropped by pipeline - msg = processed - - if msg.content: - await channel.send(msg) - - for media_path in msg.media: - try: - await channel.send_media( - recipient=msg.chat_id, - file_path=media_path, - metadata=msg.metadata, - ) - except Exception as e: - logger.error( - f"Error sending media to {msg.channel}: {e}" - ) - - # Success - health = self._health.get(msg.channel) - if health is not None: - health.consecutive_failures = 0 - health.total_successes += 1 - except Exception as e: - logger.error( - f"Error sending to {msg.channel}: {e}" - ) - health = self._health.get(msg.channel) - if health is not None: - health.consecutive_failures += 1 - health.total_failures += 1 - health.last_failure_time = time.monotonic() - health.last_failure_error = str(e) - - # ── per-account lifecycle ── - - async def start_account( - self, - channel_id: str, - account_id: str, - config: Any = None, - ) -> None: - """Start a specific account on a registered plugin.""" - await self._account_manager.start_account(channel_id, account_id, config) - - async def stop_account( - self, - channel_id: str, - account_id: str, - ) -> None: - """Stop a specific account on a registered plugin.""" - await self._account_manager.stop_account(channel_id, account_id) - - def list_accounts( - self, - channel_id: str | None = None, - ) -> list[AccountState]: - """List account states, optionally filtered by channel.""" - return self._account_manager.list_accounts(channel_id) - - @property - def account_manager(self) -> AccountManager: - """Access the underlying AccountManager.""" - return self._account_manager - - # ── queries ── - - def get_channel(self, name: str) -> Channel | None: - """Get a channel by name.""" - return self._channels.get(name) - - def get_server(self, name: str) -> Channel | None: - """Backward compat: returns the Channel (was ChannelServer).""" - return self._channels.get(name) - - def get_status(self) -> dict[str, Any]: - """Get status of all registered channels.""" - return { - name: { - "registered": True, - "running": channel._running, - "slots": channel.filled_slots(), - } - for name, channel in self._channels.items() - } - - @property - def is_running(self) -> bool: - """Whether the manager is currently running.""" - return self._is_running - - @property - def enabled_channels(self) -> list[str]: - """List of registered channel names.""" - return list(self._channels.keys()) - - def running_channels(self) -> list[str]: - """Return names of currently running channels.""" - return [name for name, ch in self._channels.items() if ch._running] - - def get_stats(self) -> dict: - """Return summary stats for all channels.""" - return { - "channels": self.enabled_channels, - "running": self.running_channels(), - "message_counts": dict(self._message_counts), - } - - async def add_channel(self, channel_type: str, config) -> Channel: - """Dynamically add and start a channel at runtime.""" - _ensure_channels_registered([channel_type]) - channel = create_channel(channel_type, config) - self.register(channel) - self._start_times[channel_type] = datetime.now() - if channel_type not in self._message_counts: - self._message_counts[channel_type] = {"received": 0, "sent": 0} - task = asyncio.create_task(channel.run()) - self._tasks.append(task) - return channel - - async def remove_channel(self, channel_type: str) -> None: - """Stop and remove a channel at runtime.""" - channel = self._channels.pop(channel_type, None) - if channel: - channel._running = False - await channel.stop() - logger.info(f"Removed channel: {channel_type}") - - def record_message(self, channel_name: str, direction: str) -> None: - """Record a message for tracking. - - Args: - channel_name: Channel name (e.g. "telegram"). - direction: "received" or "sent". - """ - if channel_name not in self._message_counts: - self._message_counts[channel_name] = {"received": 0, "sent": 0} - if direction in self._message_counts[channel_name]: - self._message_counts[channel_name][direction] += 1 - - def get_detailed_status(self) -> dict[str, Any]: - """Get detailed status of all registered channels. - - Returns: - Dict keyed by channel name with running, start_time, message - counts, health, and plugin information. - """ - now = datetime.now() - result = {} - for name, channel in self._channels.items(): - start = self._start_times.get(name) - counts = self._message_counts.get(name, {"received": 0, "sent": 0}) - health = self._health.get(name, ChannelHealth()) - result[name] = { - "registered": True, - "running": channel._running, - "start_time": start, - "uptime_seconds": (now - start).total_seconds() if start else 0, - "received": counts["received"], - "sent": counts["sent"], - "health": { - "consecutive_failures": health.consecutive_failures, - "last_failure_time": health.last_failure_time, - "last_failure_error": health.last_failure_error, - "total_failures": health.total_failures, - "total_successes": health.total_successes, - }, - "plugin_slots": channel.filled_slots(), - "has_outbound_pipeline": name in self._outbound_pipelines, - } - return result diff --git a/EvoScientist/channels/config.py b/EvoScientist/channels/config.py deleted file mode 100644 index c6afb77..0000000 --- a/EvoScientist/channels/config.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Base configuration for all channel implementations. - -Provides common fields shared across channels, reducing duplication. -Channel-specific configs inherit from BaseChannelConfig. - -Also provides ready-made ConfigAdapter implementations for the two most -common account patterns: - -- ``SingleAccountConfigAdapter`` — one account per channel (default). -- ``MultiAccountConfigAdapter`` — multiple accounts from a config dict. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - - -@dataclass -class BaseChannelConfig: - """Common configuration fields for all channels. - - Subclass this for channel-specific configs. Only add fields - here that are used by 3+ channels. - """ - - allowed_senders: set[str] | None = None - allowed_channels: set[str] | None = None - text_chunk_limit: int = 4096 - proxy: str | None = None - include_attachments: bool = True - accounts: dict | None = None # multi-account config mapping - - -class SingleAccountConfigAdapter: - """For channels that only ever have one account (most channels). - - Returns a single ``"default"`` account whose config is the entire - channel config object. This is the zero-change default: existing - single-account channels get multi-account support for free. - """ - - def list_account_ids(self, config: Any) -> list[str]: - return ["default"] - - def resolve_account( - self, config: Any, account_id: str | None = None, - ) -> Any: - return config - - def is_enabled(self, account: Any, config: Any) -> bool: - return True - - def is_configured(self, account: Any, config: Any) -> bool: - """Check that the account has at least some non-None values.""" - if account is None: - return False - if isinstance(account, dict): - return bool(account) - # dataclass / object — check that at least one field is truthy - if hasattr(account, "__dataclass_fields__"): - return any( - getattr(account, f, None) - for f in account.__dataclass_fields__ - ) - return True - - -class MultiAccountConfigAdapter: - """For channels that support multiple accounts. - - Expects the channel config to contain a mapping of accounts under - a configurable key (default ``"accounts"``). Each entry is keyed - by account id and holds account-specific settings. - - Example config structure:: - - { - "accounts": { - "bot1": {"token": "...", "enabled": true}, - "bot2": {"token": "...", "enabled": false}, - } - } - """ - - def __init__( - self, - accounts_key: str = "accounts", - required_fields: list[str] | None = None, - ) -> None: - self._accounts_key = accounts_key - self._required_fields = required_fields or [] - - def _get_accounts_map(self, config: Any) -> dict[str, Any]: - """Extract the accounts mapping from config.""" - if isinstance(config, dict): - return config.get(self._accounts_key, {}) - return getattr(config, self._accounts_key, None) or {} - - def list_account_ids(self, config: Any) -> list[str]: - return list(self._get_accounts_map(config).keys()) - - def resolve_account( - self, config: Any, account_id: str | None = None, - ) -> Any: - accounts = self._get_accounts_map(config) - if account_id is None: - # Return the first account, or empty dict - return next(iter(accounts.values()), {}) - return accounts.get(account_id, {}) - - def is_enabled(self, account: Any, config: Any) -> bool: - if isinstance(account, dict): - return account.get("enabled", True) - return getattr(account, "enabled", True) - - def is_configured(self, account: Any, config: Any) -> bool: - if not account: - return False - for f in self._required_fields: - if isinstance(account, dict): - if not account.get(f): - return False - elif not getattr(account, f, None): - return False - return True diff --git a/EvoScientist/channels/consumer.py b/EvoScientist/channels/consumer.py deleted file mode 100644 index 62463c9..0000000 --- a/EvoScientist/channels/consumer.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Unified inbound message consumer. - -Provides :class:`InboundConsumer` — a single class that consumes -inbound messages from the :class:`MessageBus`, runs them through -the agent, and publishes outbound responses. This replaces the -inline consumer loops that were duplicated in ``cli.py`` and -``standalone.py``. -""" - -from __future__ import annotations - -import asyncio -import logging -import uuid -from dataclasses import dataclass -from typing import Any, AsyncIterator, Callable, TypeVar - -from .base import Channel -from .bus import MessageBus -from .bus.events import InboundMessage, OutboundMessage - -logger = logging.getLogger(__name__) - -T = TypeVar("T") - -_MAX_CHAT_LOCKS = 10_000 -_MAX_SESSIONS = 10_000 - - -@dataclass -class ConsumerMetrics: - """Cumulative processing counters for the consumer.""" - - total_processed: int = 0 - total_successes: int = 0 - total_failures: int = 0 - total_timeouts: int = 0 - - -async def _timeout_aiter( - agen: AsyncIterator[T], - idle_timeout: float, -) -> AsyncIterator[T]: - """Wrap an async iterator with a per-yield idle timeout. - - If ``__anext__()`` does not produce a value within *idle_timeout* - seconds, :class:`asyncio.TimeoutError` is raised. Continuous - yielding resets the timer each time, so only a truly stalled - generator will trigger the timeout. - """ - ait = agen.__aiter__() - try: - while True: - try: - item = await asyncio.wait_for(ait.__anext__(), timeout=idle_timeout) - except StopAsyncIteration: - return - yield item - finally: - if hasattr(ait, "aclose"): - await ait.aclose() - - -def _format_todo_list(todos: list[dict]) -> str: - """Format todo items as a numbered list.""" - lines = ["\U0001f4cb Todo List\n"] # 📋 - for i, item in enumerate(todos, 1): - content = item.get("content", "") - lines.append(f"{i}. {content}") - lines.append(f"\n\U0001f680 {len(todos)} tasks") # 🚀 - return "\n".join(lines) - - -class InboundConsumer: - """Consume inbound messages from the bus, process via agent, publish outbound. - - Parameters - ---------- - bus: - The MessageBus to consume from / publish to. - manager: - The ChannelManager (used to look up channel instances). - agent: - The agent object (must support ``stream_agent_events``). - thread_id: - Default thread ID for agent conversations. - send_thinking: - Whether to forward thinking messages to the channel. - on_message_received: - Optional callback ``(msg: InboundMessage) -> None`` invoked when - a message is consumed (e.g. for CLI Rich display). - on_streaming_event: - Optional callback ``(event: dict) -> None`` invoked for each - streaming event from the agent. - on_message_sent: - Optional callback ``(msg: OutboundMessage) -> None`` invoked when - the outbound message is published. - inference_timeout: - Per-yield idle timeout in seconds for the agent stream. If the - agent produces no event for this long, the inference is aborted. - max_concurrent: - Number of worker coroutines (= max parallel inferences). - max_pending: - Maximum depth of the internal work queue. When full, the - consumer loop blocks (back-pressure). - drain_timeout: - Seconds to wait for in-flight workers to finish during ``stop()``. - """ - - def __init__( - self, - bus: MessageBus, - manager: Any, - agent: Any, - thread_id: str, - *, - send_thinking: bool = False, - on_message_received: Callable[[InboundMessage], None] | None = None, - on_streaming_event: Callable[[dict], None] | None = None, - on_message_sent: Callable[[OutboundMessage], None] | None = None, - inference_timeout: float = 300.0, - max_concurrent: int = 5, - max_pending: int = 50, - drain_timeout: float = 30.0, - ): - self.bus = bus - self.manager = manager - self.agent = agent - self.thread_id = thread_id - self.send_thinking = send_thinking - self._on_message_received = on_message_received - self._on_streaming_event = on_streaming_event - self._on_message_sent = on_message_sent - self._sessions: dict[str, str] = {} # sender_id -> thread_id - - # Per-chat locks: same chat is processed serially (bounded) - self._chat_locks: dict[str, asyncio.Lock] = {} - - # Inference timeout - self._inference_timeout = inference_timeout - - # Worker pool - self._max_concurrent = max_concurrent - self._work_queue: asyncio.Queue[InboundMessage | None] = asyncio.Queue( - maxsize=max_pending, - ) - self._workers: list[asyncio.Task] = [] - self._stopping = False - self._drain_timeout = drain_timeout - - # Metrics - self._metrics = ConsumerMetrics() - - def _get_thread_id(self, sender_id: str) -> str: - """Get or create a thread ID for the given sender.""" - if sender_id not in self._sessions: - if len(self._sessions) >= _MAX_SESSIONS: - # Evict oldest entry - oldest = next(iter(self._sessions)) - del self._sessions[oldest] - if self.thread_id: - self._sessions[sender_id] = f"{self.thread_id}:{sender_id}" - else: - self._sessions[sender_id] = str(uuid.uuid4()) - return self._sessions[sender_id] - - def _get_channel(self, channel_name: str) -> Channel | None: - """Look up the channel by name from the manager.""" - return self.manager.get_channel(channel_name) - - # ── lifecycle ── - - async def run(self) -> None: - """Main consumer loop — runs until ``stop()`` or cancellation. - - Spawns *max_concurrent* worker coroutines that pull from an - internal bounded queue. The loop reads from the bus and feeds - the queue; when the queue is full the loop blocks (back-pressure). - """ - self._stopping = False - self._workers = [ - asyncio.create_task(self._worker(i)) - for i in range(self._max_concurrent) - ] - try: - while not self._stopping: - try: - msg = await asyncio.wait_for( - self.bus.consume_inbound(), timeout=1.0, - ) - except asyncio.TimeoutError: - continue - except asyncio.CancelledError: - break - if self._stopping: - break - await self._work_queue.put(msg) # blocks when full (back-pressure) - finally: - if not self._stopping: - await self.stop() - - async def stop(self) -> None: - """Gracefully drain in-flight work and shut down workers.""" - self._stopping = True - logger.info("Consumer stopping: draining in-flight messages...") - pending_count = self._work_queue.qsize() - - # Send a None sentinel per worker so each exits its loop - for _ in self._workers: - try: - self._work_queue.put_nowait(None) - except asyncio.QueueFull: - pass - - # Wait for workers to finish, then force-cancel stragglers - if self._workers: - done, still_running = await asyncio.wait( - self._workers, timeout=self._drain_timeout, - ) - for task in still_running: - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - logger.info( - f"Consumer drain: {len(done)} finished, " - f"{len(still_running)} force-cancelled, " - f"{pending_count} were pending" - ) - self._workers.clear() - - # ── workers ── - - async def _worker(self, worker_id: int) -> None: - """Pull messages from the work queue and process them.""" - while True: - msg = await self._work_queue.get() - if msg is None: - break # shutdown sentinel - try: - await self._handle_message(msg) - except Exception: - logger.exception(f"Worker {worker_id} unhandled error") - finally: - self._work_queue.task_done() - - async def _handle_message(self, msg: InboundMessage) -> None: - """Process a single inbound message.""" - from ..stream.events import stream_agent_events - - if self._on_message_received: - try: - self._on_message_received(msg) - except Exception: - pass - - channel = self._get_channel(msg.channel) - thread_id = self._get_thread_id(msg.sender_id) - session_key = msg.session_key # "channel:chat_id" - - # Lazily create per-chat lock; evict stale locks when too many - if session_key not in self._chat_locks: - self._chat_locks[session_key] = asyncio.Lock() - if len(self._chat_locks) > _MAX_CHAT_LOCKS: - self._evict_chat_locks() - - self._metrics.total_processed += 1 - - async with self._chat_locks[session_key]: - try: - final_content = "" - thinking_buffer: list[str] = [] - todo_sent = False - thinking_sent = False - - if channel: - await channel.start_typing(msg.chat_id) - - async for event in _timeout_aiter( - stream_agent_events(self.agent, msg.content, thread_id, media=msg.media or None), - self._inference_timeout, - ): - event_type = event.get("type") - - if self._on_streaming_event: - try: - self._on_streaming_event(event) - except Exception: - pass - - if event_type == "thinking": - thinking_text = event.get("content", "") - if thinking_text: - thinking_buffer.append(thinking_text) - - elif event_type == "tool_call": - if event.get("name") == "write_todos" and not todo_sent: - todos = event.get("args", {}).get("todos", []) - if todos and channel: - if thinking_buffer and not thinking_sent: - full_thinking = "".join(thinking_buffer) - if full_thinking: - await channel.send_thinking_message( - msg.sender_id, - full_thinking, - msg.metadata, - ) - thinking_sent = True - thinking_buffer.clear() - await channel.send_todo_message( - msg.sender_id, - _format_todo_list(todos), - msg.metadata, - ) - todo_sent = True - - elif event_type == "text": - final_content += event.get("content", "") - - elif event_type == "done": - final_content = event.get("content", "") or final_content - - if thinking_buffer and not thinking_sent and channel: - full_thinking = "".join(thinking_buffer) - if full_thinking: - await channel.send_thinking_message( - msg.sender_id, full_thinking, msg.metadata, - ) - - outbound = OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=final_content or "No response", - reply_to=msg.message_id or None, - metadata=msg.metadata, - ) - await self.bus.publish_outbound(outbound) - - self._metrics.total_successes += 1 - - if self._on_message_sent: - try: - self._on_message_sent(outbound) - except Exception: - pass - - except asyncio.TimeoutError: - self._metrics.total_timeouts += 1 - logger.error( - f"Inference timeout ({self._inference_timeout}s idle) " - f"for {msg.sender_id} in {session_key}" - ) - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="Sorry, the response timed out. Please try again.", - metadata=msg.metadata, - )) - - except Exception as e: - self._metrics.total_failures += 1 - logger.error(f"Agent error: {e}") - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content="Sorry, something went wrong. Please try again later.", - metadata=msg.metadata, - )) - finally: - if channel: - await channel.stop_typing(msg.chat_id) - - # ── observability ── - - @property - def pending_count(self) -> int: - """Number of messages waiting in the work queue.""" - return self._work_queue.qsize() - - @property - def active_workers(self) -> int: - """Number of worker tasks that are still alive.""" - return sum(1 for w in self._workers if not w.done()) - - @property - def metrics(self) -> dict[str, int]: - """Cumulative processing counters.""" - m = self._metrics - return { - "total_processed": m.total_processed, - "total_successes": m.total_successes, - "total_failures": m.total_failures, - "total_timeouts": m.total_timeouts, - "pending": self.pending_count, - "active_workers": self.active_workers, - "chat_locks": len(self._chat_locks), - "sessions": len(self._sessions), - } - - # ── internal ── - - def _evict_chat_locks(self) -> None: - """Remove chat locks that are not currently held.""" - stale = [k for k, lock in self._chat_locks.items() if not lock.locked()] - for k in stale[:max(1, len(stale) // 2)]: - del self._chat_locks[k] diff --git a/EvoScientist/channels/dingtalk/__init__.py b/EvoScientist/channels/dingtalk/__init__.py deleted file mode 100644 index 2026d82..0000000 --- a/EvoScientist/channels/dingtalk/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""DingTalk (钉钉) channel for EvoScientist. - -Uses Stream Mode (WebSocket) for receiving messages — no public IP needed. -Sends replies via HTTP API. - -Usage in config: - channel_enabled = "dingtalk" - dingtalk_client_id = "your_app_key" - dingtalk_client_secret = "your_app_secret" -""" - -from .channel import DingTalkChannel, DingTalkConfig -from ..channel_manager import register_channel, _parse_csv - -__all__ = ["DingTalkChannel", "DingTalkConfig"] - - -def create_from_config(config) -> DingTalkChannel: - allowed = _parse_csv(getattr(config, "dingtalk_allowed_senders", "")) - proxy = getattr(config, "dingtalk_proxy", "") or None - return DingTalkChannel(DingTalkConfig( - client_id=getattr(config, "dingtalk_client_id", ""), - client_secret=getattr(config, "dingtalk_client_secret", ""), - allowed_senders=allowed, - proxy=proxy, - )) - - -register_channel("dingtalk", create_from_config) diff --git a/EvoScientist/channels/dingtalk/channel.py b/EvoScientist/channels/dingtalk/channel.py deleted file mode 100644 index 1b1bffb..0000000 --- a/EvoScientist/channels/dingtalk/channel.py +++ /dev/null @@ -1,354 +0,0 @@ -"""DingTalk channel — refactored with WebSocketMixin + TokenMixin.""" - -import asyncio -import json -import logging -from urllib.parse import quote_plus -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path - -from ..base import Channel, RawIncoming, ChannelError -from ..capabilities import DINGTALK as DINGTALK_CAPS -from ..mixins import WebSocketMixin, TokenMixin -from ..config import BaseChannelConfig - -logger = logging.getLogger(__name__) - -GATEWAY_URL = "https://api.dingtalk.com/v1.0/gateway/connections/open" -TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/accessToken" -SEND_URL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" -MEDIA_SEND_URL = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" -MEDIA_UPLOAD_URL = "https://oapi.dingtalk.com/media/upload" -FILE_DOWNLOAD_URL = "https://api.dingtalk.com/v1.0/robot/messageFiles/download" - - -@dataclass -class DingTalkConfig(BaseChannelConfig): - client_id: str = "" - client_secret: str = "" - text_chunk_limit: int = 4096 - - -class DingTalkChannel(Channel, WebSocketMixin, TokenMixin): - capabilities = DINGTALK_CAPS - name = "dingtalk" - _ready_attrs = ("_http_client", "_access_token") - _non_retryable_patterns = ("invalidauthentication", "forbidden", "40014") - _mention_pattern = r"@\S+\s*" - _mention_strip_count = 1 - - def __init__(self, config: DingTalkConfig): - super().__init__(config) - - async def start(self) -> None: - import httpx - if not self.config.client_id or not self.config.client_secret: - raise ChannelError("DingTalk client_id and client_secret are required") - self._http_client = httpx.AsyncClient(timeout=15, proxy=self.config.proxy) - await self._refresh_token() - self._running = True - logger.info("DingTalk channel starting (Stream Mode)...") - self._ws_task = asyncio.create_task(self._ws_loop()) - - # ── TokenMixin ──────────────────────────────────────────────── - - async def _fetch_token(self) -> tuple[str, int]: - data = await self._api_post(TOKEN_URL, { - "appKey": self.config.client_id, - "appSecret": self.config.client_secret, - }) - token = data.get("accessToken") - if not token: - raise ChannelError(f"DingTalk auth error: {data}") - return token, int(data.get("expireIn", 7200)) - - async def _api_post(self, url, body, headers=None): - resp = await self._http_client.post(url, json=body, headers=headers) - return resp.json() - - async def _resolve_download_code(self, download_code: str) -> str | None: - """Exchange a DingTalk downloadCode for a real download URL.""" - try: - token = await self._ensure_token() - data = await self._api_post( - FILE_DOWNLOAD_URL, - {"downloadCode": download_code, "robotCode": self.config.client_id}, - headers={"x-acs-dingtalk-access-token": token}, - ) - url = data.get("downloadUrl") or "" - if url: - return url - logger.warning(f"DingTalk downloadCode resolve failed: {data}") - except Exception as e: - logger.warning(f"DingTalk downloadCode resolve error: {e}") - return None - - # ── WebSocketMixin ──────────────────────────────────────────── - - async def _get_ws_url(self) -> str: - resp = await self._http_client.post(GATEWAY_URL, json={ - "clientId": self.config.client_id, - "clientSecret": self.config.client_secret, - "subscriptions": [{"type": "CALLBACK", "topic": "/v1.0/im/bot/messages/get"}], - "ua": "dingtalk-sdk-python/v0.24.3-union", - }) - data = resp.json() - endpoint, ticket = data.get("endpoint"), data.get("ticket") - if not endpoint or not ticket: - raise ChannelError(f"DingTalk gateway failed: {data}") - return f"{endpoint}?ticket={quote_plus(ticket)}" - - async def _on_ws_message(self, data) -> None: - if not isinstance(data, dict): - return - headers = data.get("headers", {}) - msg_id = headers.get("messageId", "") - - # System ping - if data.get("type") == "SYSTEM" and headers.get("topic") == "ping": - await self._ws_send_json({"code": 200, "headers": headers, "message": "OK", "data": data.get("data", "")}) - return - - # ACK - await self._ws_send_json({"code": 200, "headers": {"contentType": "application/json", "messageId": msg_id}, "message": "OK", "data": "{}"}) - - if data.get("type") != "CALLBACK": - return - - payload = data.get("data", "{}") - payload = json.loads(payload) if isinstance(payload, str) else payload - text_obj = payload.get("text", {}) - content = (text_obj.get("content", "") if isinstance(text_obj, dict) else str(text_obj)).strip() - if not content: - raw_content = payload.get("content", "") - content = raw_content.strip() if isinstance(raw_content, str) else "" - - # Download attachments if present - annotations: list[str] = [] - media_paths: list[str] = [] - - # DingTalk file/image messages may put download info in - # payload["content"] (as a dict) instead of in a dedicated - # "fileContent"/"imageContent" key. - raw_content_obj = payload.get("content") - if isinstance(raw_content_obj, dict) and raw_content_obj not in [ - payload.get(k) for k in ("imageContent", "fileContent", "videoContent", "audioContent") - ]: - msg_type = payload.get("msgtype") or payload.get("msgType") or "" - media_label = msg_type or "file" - file_size = raw_content_obj.get("fileSize") or raw_content_obj.get("downloadSize") or 0 - file_name = raw_content_obj.get("fileName") or raw_content_obj.get("name") or f"dingtalk_{msg_type}" - download_code = raw_content_obj.get("downloadCode") or "" - download_url = raw_content_obj.get("downloadUrl") or "" - # downloadCode is NOT a URL — resolve it via DingTalk API first - if download_code and not download_code.startswith("http"): - resolved = await self._resolve_download_code(download_code) - if resolved: - download_url = resolved - elif download_code: - download_url = download_code - if download_url: - try: - dl_token = await self._ensure_token() - dl_headers = {"x-acs-dingtalk-access-token": dl_token} - except Exception: - dl_headers = None - local, ann = await self._download_attachment( - download_url, f"dingtalk_{file_name}", - headers=dl_headers, - file_size=int(file_size) if file_size else None, - ) - if local: - media_paths.append(local) - if ann: - ann = ann.replace("[attachment:", f"[{media_label}:") - annotations.append(ann) - elif file_name: - annotations.append(f"[{media_label}: {file_name}]") - - for att_key in ("imageContent", "fileContent", "videoContent", "audioContent"): - att = payload.get(att_key) - if att and isinstance(att, dict): - file_size = att.get("fileSize") or att.get("downloadSize") or 0 - file_name = att.get("fileName", att_key) - download_code = att.get("downloadCode") or "" - download_url = att.get("downloadUrl") or "" - # Resolve downloadCode via API if it's not a URL - if download_code and not download_code.startswith("http"): - resolved = await self._resolve_download_code(download_code) - if resolved: - download_url = resolved - elif download_code: - download_url = download_code - # DingTalk audioContent is voice messages - media_label = "voice" if att_key == "audioContent" else att_key - if download_url and (self.config.include_attachments if hasattr(self.config, 'include_attachments') else True): - # DingTalk download URLs require access token - try: - dl_token = await self._ensure_token() - dl_headers = {"x-acs-dingtalk-access-token": dl_token} - except Exception: - dl_headers = None - local, ann = await self._download_attachment( - download_url, f"dingtalk_{file_name}", - headers=dl_headers, - file_size=int(file_size) if file_size else None, - ) - if local: - media_paths.append(local) - if ann: - ann = ann.replace("[attachment:", f"[{media_label}:") - annotations.append(ann) - elif file_size: - too_large = self._check_attachment_size(int(file_size), file_name) - if too_large: - annotations.append(too_large) - else: - annotations.append(f"[{media_label}: {file_name}]") - - if not content and not media_paths and not annotations: - return - - sender_id = payload.get("senderStaffId") or payload.get("senderId", "") - is_group = payload.get("conversationType") == "2" - # For send API (oToMessages/batchSend), userIds needs staffId, not conversationId - chat_id = sender_id - create_time = payload.get("createAt") or payload.get("createTime", "") - - # Mention gating: DMs always pass; groups require @bot - was_mentioned = not is_group - if is_group: - # isInAtList is set by DingTalk when bot is @mentioned - if payload.get("isInAtList"): - was_mentioned = True - else: - # Fallback: check atUsers array - at_users = payload.get("atUsers") or [] - for u in at_users: - if u.get("dingtalkId") == self.config.client_id: - was_mentioned = True - break - - try: - ts = datetime.fromtimestamp(int(create_time) / 1000) if create_time else datetime.now() - except (ValueError, TypeError, OSError): - ts = datetime.now() - - await self._enqueue_raw(RawIncoming( - sender_id=sender_id, chat_id=chat_id, text=content, timestamp=ts, - message_id=msg_id, is_group=is_group, was_mentioned=was_mentioned, - media_files=media_paths, - content_annotations=annotations, - metadata={"chat_id": chat_id, "sender_nick": payload.get("senderNick", ""), "backend": "dingtalk"}, - )) - - # _send_typing_action: inherited no-op (DingTalk has no typing API) - # _format_chunk: inherited from base (UnifiedFormatter) - - # ── Send ────────────────────────────────────────────────────── - - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - token = await self._ensure_token() - data = await self._api_post(SEND_URL, { - "robotCode": self.config.client_id, - "userIds": [chat_id], - "msgKey": "sampleMarkdown", - "msgParam": json.dumps({"text": raw_text, "title": "EvoScientist"}), - }, headers={"x-acs-dingtalk-access-token": token}) - return data - - # ── Media send ──────────────────────────────────────────────── - - _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} - - async def _send_media_impl( - self, - recipient: str, - file_path: str, - caption: str = "", - metadata: dict | None = None, - ) -> bool: - """Send a media file through DingTalk. - - For images: uploads via /media/upload to get media_id, then sends - as sampleImageMsg. Non-image files are sent as markdown links - (DingTalk robot API does not support arbitrary file uploads). - """ - token = await self._ensure_token() - chat_id = self._resolve_media_chat_id(recipient, metadata) - headers = {"x-acs-dingtalk-access-token": token} - ext = Path(file_path).suffix.lower() - - if ext in self._IMAGE_EXTS: - # Try uploading image to get media_id for native image message - media_id = await self._upload_dingtalk_media(token, file_path, "image") - if media_id: - await self._api_post(MEDIA_SEND_URL, { - "robotCode": self.config.client_id, - "userIds": [chat_id], - "msgKey": "sampleImageMsg", - "msgParam": json.dumps({"photoURL": media_id}), - }, headers=headers) - else: - # Fallback to markdown with file path - await self._api_post(MEDIA_SEND_URL, { - "robotCode": self.config.client_id, - "userIds": [chat_id], - "msgKey": "sampleMarkdown", - "msgParam": json.dumps({ - "text": f"![image]({file_path})" + (f"\n{caption}" if caption else ""), - "title": caption or "Image", - }), - }, headers=headers) - else: - # Non-image: send as markdown with filename - name = Path(file_path).name - text = f"[文件] {name}" + (f"\n{caption}" if caption else "") - await self._api_post(MEDIA_SEND_URL, { - "robotCode": self.config.client_id, - "userIds": [chat_id], - "msgKey": "sampleMarkdown", - "msgParam": json.dumps({"text": text, "title": name}), - }, headers=headers) - - if caption and ext in self._IMAGE_EXTS: - # Send caption separately for image messages - await self._api_post(MEDIA_SEND_URL, { - "robotCode": self.config.client_id, - "userIds": [chat_id], - "msgKey": "sampleMarkdown", - "msgParam": json.dumps({"text": caption, "title": "Caption"}), - }, headers=headers) - return True - - async def _upload_dingtalk_media( - self, token: str, file_path: str, media_type: str = "image", - ) -> str | None: - """Upload a file to DingTalk media API and return the media_id.""" - try: - url = f"{MEDIA_UPLOAD_URL}?access_token={token}&type={media_type}" - with open(file_path, "rb") as f: - resp = await self._http_client.post( - url, files={"media": (Path(file_path).name, f)}, - ) - data = resp.json() - return data.get("media_id") - except Exception as e: - logger.warning(f"DingTalk media upload failed: {e}") - return None - - async def _cleanup(self) -> None: - if hasattr(self, "_ws_task") and self._ws_task: - self._ws_task.cancel() - try: - await self._ws_task - except (asyncio.CancelledError, Exception): - pass - self._ws_task = None - await self._stop_ws() - if self._http_client: - await self._http_client.aclose() - self._http_client = None - self._access_token = None - logger.info("DingTalk channel stopped") diff --git a/EvoScientist/channels/dingtalk/probe.py b/EvoScientist/channels/dingtalk/probe.py deleted file mode 100644 index eb0eeb2..0000000 --- a/EvoScientist/channels/dingtalk/probe.py +++ /dev/null @@ -1,33 +0,0 @@ -"""DingTalk credential validation.""" - -import logging - -logger = logging.getLogger(__name__) - - -async def validate_dingtalk( - client_id: str, - client_secret: str, - proxy: str | None = None, -) -> tuple[bool, str]: - """Validate DingTalk credentials by fetching an access token.""" - if not client_id or not client_secret: - return False, "client_id and client_secret are required" - - try: - import httpx - except ImportError: - return False, "httpx not installed" - - url = "https://api.dingtalk.com/v1.0/oauth2/accessToken" - body = {"appKey": client_id, "appSecret": client_secret} - - try: - async with httpx.AsyncClient(proxy=proxy) as client: - resp = await client.post(url, json=body, timeout=10) - data = resp.json() - if data.get("accessToken"): - return True, "DingTalk credentials valid" - return False, f"Error: {data.get('message', data)}" - except Exception as e: - return False, f"Error: {e}" diff --git a/EvoScientist/channels/dingtalk/serve.py b/EvoScientist/channels/dingtalk/serve.py deleted file mode 100644 index 8fb4abb..0000000 --- a/EvoScientist/channels/dingtalk/serve.py +++ /dev/null @@ -1,92 +0,0 @@ -"""DingTalk channel server. - -Standalone script to run the DingTalk channel with CLI options. - -Usage: - python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET [OPTIONS] - -Examples: - # Basic usage - python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET - - # With proxy and allowed senders - python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET --proxy http://proxy:8080 --allow user123 - - # With agent and thinking - python -m EvoScientist.channels.dingtalk.serve --client-id ID --client-secret SECRET --agent --thinking -""" - -import argparse -import logging - -from .channel import DingTalkChannel, DingTalkConfig -from ..bus import MessageBus -from ..standalone import run_standalone - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", -) -logger = logging.getLogger(__name__) - - -def parse_args(): - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="DingTalk channel server", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--client-id", - required=True, - help="DingTalk app client ID", - ) - parser.add_argument( - "--client-secret", - required=True, - help="DingTalk app client secret", - ) - parser.add_argument( - "--allow", - action="append", - dest="allowed_senders", - help="Allowed sender (DingTalk user ID). Can be used multiple times.", - ) - parser.add_argument( - "--proxy", - help="HTTP proxy URL", - ) - parser.add_argument( - "--agent", - action="store_true", - help="Use EvoScientist agent as handler (default: echo)", - ) - parser.add_argument( - "--thinking", - action="store_true", - help="Send thinking content as intermediate messages (requires --agent)", - ) - return parser.parse_args() - - -def main(): - """Entry point.""" - args = parse_args() - - config = DingTalkConfig( - client_id=args.client_id, - client_secret=args.client_secret, - allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, - proxy=args.proxy, - ) - - send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = DingTalkChannel(config) - - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) - - -if __name__ == "__main__": - main() diff --git a/EvoScientist/channels/discord/__init__.py b/EvoScientist/channels/discord/__init__.py deleted file mode 100644 index dfc7740..0000000 --- a/EvoScientist/channels/discord/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from .channel import DiscordChannel, DiscordConfig -from ..channel_manager import register_channel, _parse_csv - -__all__ = ["DiscordChannel", "DiscordConfig"] - - -def create_from_config(config) -> DiscordChannel: - allowed = _parse_csv(config.discord_allowed_senders) - channels = _parse_csv(config.discord_allowed_channels) - proxy = config.discord_proxy if config.discord_proxy else None - return DiscordChannel(DiscordConfig( - bot_token=config.discord_bot_token, - allowed_senders=allowed, - allowed_channels=channels, - proxy=proxy, - )) - - -register_channel("discord", create_from_config) diff --git a/EvoScientist/channels/discord/channel.py b/EvoScientist/channels/discord/channel.py deleted file mode 100644 index 3ef69c9..0000000 --- a/EvoScientist/channels/discord/channel.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Discord channel implementation using discord.py.""" - -import asyncio -import logging -import os -from dataclasses import dataclass -from datetime import datetime - -from ..base import Channel, RawIncoming, ChannelError -from ..capabilities import DISCORD as DISCORD_CAPS -from ..config import BaseChannelConfig - -logger = logging.getLogger(__name__) - - -@dataclass -class DiscordConfig(BaseChannelConfig): - bot_token: str = "" - text_chunk_limit: int = 4096 - - -class DiscordChannel(Channel): - """Discord channel using discord.py.""" - - name = "discord" - - capabilities = DISCORD_CAPS - _typing_interval: float = 8.0 - _ready_attrs = ("_client",) - _mention_pattern = r"<@!?{bot_id}>\s*" - - def __init__(self, config: DiscordConfig): - super().__init__(config) - self._client = None - self._ready = asyncio.Event() - # Cache message objects for ACK reactions - self._message_cache: dict[str, object] = {} - self._MESSAGE_CACHE_MAX = 200 - - async def start(self) -> None: - try: - import discord - except ImportError: - raise ChannelError( - "discord.py not installed. " - "Install with: pip install evoscientist[discord]" - ) - - if not self.config.bot_token: - raise ChannelError("Discord bot token is required") - - proxy = ( - self.config.proxy - or os.environ.get("https_proxy") - or os.environ.get("HTTPS_PROXY") - or os.environ.get("http_proxy") - or os.environ.get("HTTP_PROXY") - or None - ) - - logger.info( - "Discord connect: token=%s...%s proxy=%s", - self.config.bot_token[:8], - self.config.bot_token[-4:], - proxy or "(none)", - ) - - intents = discord.Intents.default() - intents.message_content = True - client_kwargs = {"intents": intents} - if proxy: - client_kwargs["proxy"] = proxy - self._client = discord.Client(**client_kwargs) - - self._start_task_error: BaseException | None = None - - @self._client.event - async def on_ready(): - logger.info(f"Discord bot ready: {self._client.user}") - self._ready.set() - - @self._client.event - async def on_message(message): - await self._on_message(message) - - async def _guarded_start(): - try: - logger.info("Discord gateway: starting client.start()...") - await self._client.start(self.config.bot_token) - except Exception as exc: - logger.error("Discord gateway error: %s: %s", type(exc).__name__, exc) - self._start_task_error = exc - self._ready.set() # unblock the waiter so it doesn't hang - - logger.info("Discord connect: launching gateway task") - asyncio.create_task(_guarded_start()) - - try: - await asyncio.wait_for(self._ready.wait(), timeout=60) - except asyncio.TimeoutError: - raise ChannelError( - "Discord bot failed to connect within 60s. " - "Check network/proxy connectivity to gateway.discord.gg" - ) - - if self._start_task_error: - raise ChannelError( - f"Discord bot failed to connect: {self._start_task_error}" - ) - - self._running = True - logger.info("Discord channel started") - - async def _cleanup(self) -> None: - if self._client: - await self._client.close() - logger.info("Discord channel stopped") - - # ── Typing indicator ──────────────────────────────────────────── - - async def _send_typing_action(self, chat_id: str) -> None: - if not self._client: - return - ch = self._client.get_channel(int(chat_id)) - if ch: - await ch.trigger_typing() - - # ── ACK Reactions ─────────────────────────────────────────────── - - async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - msg = self._message_cache.get(message_id) - if msg: - try: - await msg.add_reaction(emoji) - except Exception as e: - logger.debug(f"Discord ACK reaction failed: {e}") - - async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - msg = self._message_cache.get(message_id) - if msg and self._client and self._client.user: - try: - await msg.remove_reaction(emoji, self._client.user) - except Exception as e: - logger.debug(f"Discord remove ACK reaction failed: {e}") - - def _cache_message(self, message) -> None: - """Cache a discord message object for later reaction use.""" - mid = str(message.id) - self._message_cache[mid] = message - # Evict oldest entries if cache is too large - if len(self._message_cache) > self._MESSAGE_CACHE_MAX: - oldest = list(self._message_cache.keys())[: self._MESSAGE_CACHE_MAX // 2] - for k in oldest: - self._message_cache.pop(k, None) - - # ── Send ──────────────────────────────────────────────────────── - - - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - import discord - - thread_id = (metadata or {}).get("thread_id", "") - target_id = int(thread_id) if thread_id else int(chat_id) - ch = self._client.get_channel(target_id) - if not ch: - raise RuntimeError(f"Discord channel {target_id} not found") - ref = None - if reply_to: - try: - ref = discord.MessageReference( - message_id=int(reply_to), channel_id=target_id, - ) - except (ValueError, TypeError): - pass - - async def _send(text): - await ch.send(text, reference=ref) - - await self._send_with_format_fallback(_send, formatted_text, raw_text) - - async def _send_media_impl( - self, recipient: str, file_path: str, - caption: str = "", metadata: dict | None = None, - ) -> bool: - import discord - - channel_id = self._resolve_media_chat_id(recipient, metadata) - ch = self._client.get_channel(int(channel_id)) - if not ch: - logger.error(f"Discord channel {channel_id} not found") - return False - file = discord.File(file_path) - await ch.send(content=caption or None, file=file) - return True - - def _get_bot_identifier(self) -> str | None: - if self._client and self._client.user: - return str(self._client.user.id) - return None - - # ── Inbound ───────────────────────────────────────────────────── - - async def _on_message(self, message) -> None: - import discord - - if message.author == self._client.user: - return - - # Cache for ACK reactions - self._cache_message(message) - - user_id = str(message.author.id) - channel_id = str(message.channel.id) - - is_dm = isinstance(message.channel, discord.DMChannel) - was_mentioned = is_dm or (self._client.user in message.mentions) - - text = message.content or "" - annotations: list[str] = [] - media_paths: list[str] = [] - - if self.config.include_attachments and message.attachments: - for attachment in message.attachments: - too_large = self._check_attachment_size( - attachment.size or 0, attachment.filename, - ) - if too_large: - annotations.append(too_large) - continue - try: - safe_name = attachment.filename.replace("/", "_") - file_path = self._media_path(f"{attachment.id}_{safe_name}") - await attachment.save(file_path) - media_paths.append(str(file_path)) - annotations.append(f"[attachment: {file_path}]") - except Exception as e: - logger.warning(f"Failed to download Discord attachment: {e}") - annotations.append(f"[attachment: {attachment.filename} - download failed]") - - # Detect thread context - thread_id = "" - parent_channel_id = channel_id - if hasattr(message.channel, "parent") and message.channel.parent: - # Message is inside a Thread — store thread info - thread_id = channel_id # the thread IS the channel - parent_channel_id = str(message.channel.parent.id) - - await self._enqueue_raw(RawIncoming( - sender_id=user_id, chat_id=parent_channel_id, text=text, - media_files=media_paths, content_annotations=annotations, - timestamp=message.created_at or datetime.now(), - message_id=str(message.id), - metadata={"chat_id": parent_channel_id, "thread_id": thread_id}, - is_group=not is_dm, was_mentioned=was_mentioned, - )) diff --git a/EvoScientist/channels/discord/probe.py b/EvoScientist/channels/discord/probe.py deleted file mode 100644 index 9fbc4c0..0000000 --- a/EvoScientist/channels/discord/probe.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Discord bot token validation.""" - -import logging - -logger = logging.getLogger(__name__) - - -async def validate_discord_token(token: str, proxy: str | None = None) -> tuple[bool, str]: - """Validate a Discord bot token via the REST API. - - Returns: - Tuple of (is_valid, message). - """ - if not token: - return False, "No token provided" - - try: - import httpx - except ImportError: - return False, "httpx not installed" - - url = "https://discord.com/api/v10/users/@me" - headers = {"Authorization": f"Bot {token}"} - try: - async with httpx.AsyncClient(proxy=proxy) as client: - resp = await client.get(url, headers=headers, timeout=10) - if resp.status_code == 200: - data = resp.json() - username = data.get("username", "unknown") - return True, f"Bot: {username}" - return False, "Invalid token" - except Exception as e: - return False, f"Error: {e}" diff --git a/EvoScientist/channels/discord/serve.py b/EvoScientist/channels/discord/serve.py deleted file mode 100644 index 9e8f000..0000000 --- a/EvoScientist/channels/discord/serve.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Discord channel server. - -Standalone script to run the Discord channel with CLI options. - -Usage: - python -m EvoScientist.channels.discord.serve --bot-token TOKEN [OPTIONS] - -Examples: - # Allow all senders (default) - python -m EvoScientist.channels.discord.serve --bot-token TOKEN - - # Only allow specific senders and channels - python -m EvoScientist.channels.discord.serve --bot-token TOKEN --allow 123 --allow-channel 456 - - # With proxy, agent and thinking - python -m EvoScientist.channels.discord.serve --bot-token TOKEN --proxy http://proxy:8080 --agent --thinking -""" - -import argparse -import logging - -from .channel import DiscordChannel, DiscordConfig -from ..bus import MessageBus -from ..standalone import run_standalone - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", -) -logger = logging.getLogger(__name__) - - -def parse_args(): - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Discord channel server", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--bot-token", - required=True, - help="Discord bot token", - ) - parser.add_argument( - "--allow", - action="append", - dest="allowed_senders", - help="Allowed sender (Discord user ID). Can be used multiple times.", - ) - parser.add_argument( - "--allow-channel", - action="append", - dest="allowed_channels", - help="Allowed channel ID. Can be used multiple times.", - ) - parser.add_argument( - "--proxy", - help="HTTP proxy URL for Discord API requests", - ) - parser.add_argument( - "--agent", - action="store_true", - help="Use EvoScientist agent as handler (default: echo)", - ) - parser.add_argument( - "--thinking", - action="store_true", - help="Send thinking content as intermediate messages (requires --agent)", - ) - return parser.parse_args() - - -def main(): - """Entry point.""" - args = parse_args() - - config = DiscordConfig( - bot_token=args.bot_token, - allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, - allowed_channels=set(args.allowed_channels) if args.allowed_channels else None, - proxy=args.proxy, - ) - - send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = DiscordChannel(config) - - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) - - -if __name__ == "__main__": - main() diff --git a/EvoScientist/channels/email/__init__.py b/EvoScientist/channels/email/__init__.py deleted file mode 100644 index 56bd5f9..0000000 --- a/EvoScientist/channels/email/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Email channel for EvoScientist. - -Uses IMAP polling for inbound + SMTP for outbound. Pure Python, no extra deps. - -Usage in config: - channel_enabled = "email" - email_imap_host = "imap.gmail.com" - email_smtp_host = "smtp.gmail.com" - ... -""" - -from .channel import EmailChannel, EmailConfig -from ..channel_manager import register_channel, _parse_csv - -__all__ = ["EmailChannel", "EmailConfig"] - - -def create_from_config(config) -> EmailChannel: - allowed = _parse_csv(getattr(config, "email_allowed_senders", "")) - return EmailChannel(EmailConfig( - imap_host=getattr(config, "email_imap_host", ""), - imap_port=int(getattr(config, "email_imap_port", 993)), - imap_username=getattr(config, "email_imap_username", ""), - imap_password=getattr(config, "email_imap_password", ""), - imap_mailbox=getattr(config, "email_imap_mailbox", "INBOX"), - imap_use_ssl=getattr(config, "email_imap_use_ssl", True), - smtp_host=getattr(config, "email_smtp_host", ""), - smtp_port=int(getattr(config, "email_smtp_port", 587)), - smtp_username=getattr(config, "email_smtp_username", ""), - smtp_password=getattr(config, "email_smtp_password", ""), - smtp_use_tls=getattr(config, "email_smtp_use_tls", True), - from_address=getattr(config, "email_from_address", ""), - poll_interval=int(getattr(config, "email_poll_interval", 30)), - mark_seen=getattr(config, "email_mark_seen", True), - max_body_chars=int(getattr(config, "email_max_body_chars", 12000)), - subject_prefix=getattr(config, "email_subject_prefix", "Re: "), - allowed_senders=allowed, - )) - - -register_channel("email", create_from_config) diff --git a/EvoScientist/channels/email/channel.py b/EvoScientist/channels/email/channel.py deleted file mode 100644 index 2c2a2da..0000000 --- a/EvoScientist/channels/email/channel.py +++ /dev/null @@ -1,372 +0,0 @@ -"""Email channel — refactored with PollingMixin.""" - -import asyncio -import email as email_lib -import email.utils -import html -import imaplib -import logging -import re -import smtplib -import ssl -from dataclasses import dataclass -from datetime import datetime -from email.header import decode_header, make_header -from email.message import EmailMessage -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.mime.base import MIMEBase -from email import encoders -from email.utils import parseaddr -from pathlib import Path - -from ..base import Channel, RawIncoming, ChannelError -from ..capabilities import EMAIL as EMAIL_CAPS -from ..mixins import PollingMixin -from ..config import BaseChannelConfig - -logger = logging.getLogger(__name__) - - -def _decode_hdr(raw: str) -> str: - try: - return str(make_header(decode_header(raw))) if raw else "" - except Exception: - return raw or "" - - -def _strip_html(text: str) -> str: - text = re.sub(r"", "\n", text, flags=re.I) - text = re.sub(r"]*>", "\n", text, flags=re.I) - text = re.sub(r"

", "\n", text, flags=re.I) - text = re.sub(r"<[^>]+>", "", text) - return html.unescape(text).strip() - - - - - - - -@dataclass -class EmailConfig(BaseChannelConfig): - imap_host: str = "" - imap_port: int = 993 - imap_username: str = "" - imap_password: str = "" - imap_mailbox: str = "INBOX" - imap_use_ssl: bool = True - smtp_host: str = "" - smtp_port: int = 587 - smtp_username: str = "" - smtp_password: str = "" - smtp_use_tls: bool = True - from_address: str = "" - poll_interval: int = 30 - mark_seen: bool = True - max_body_chars: int = 12000 - subject_prefix: str = "Re: " - allowed_senders: set[str] | None = None - text_chunk_limit: int = 4096 - - -class EmailChannel(Channel, PollingMixin): - capabilities = EMAIL_CAPS - name = "email" - _non_retryable_patterns = ("auth", "login", "credential") - - def __init__(self, config: EmailConfig): - super().__init__(config) - self._imap: imaplib.IMAP4_SSL | imaplib.IMAP4 | None = None - - async def start(self) -> None: - cfg = self.config - if not cfg.imap_host or not cfg.imap_username: - raise ChannelError("Email imap_host and imap_username are required") - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self._connect_imap) - self._running = True - logger.info(f"Email channel started (IMAP: {cfg.imap_host}, poll {cfg.poll_interval}s)") - await self._start_polling() - - def _connect_imap(self) -> None: - cfg = self.config - try: - if cfg.imap_use_ssl: - self._imap = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port, ssl_context=ssl.create_default_context()) - else: - self._imap = imaplib.IMAP4(cfg.imap_host, cfg.imap_port) - self._imap.login(cfg.imap_username, cfg.imap_password) - self._imap.select(cfg.imap_mailbox) - except Exception as e: - raise ChannelError(f"IMAP failed: {e}") - - def _reconnect_imap(self) -> None: - try: - if self._imap: - self._imap.noop() - return - except Exception: - pass - self._connect_imap() - - async def _poll_once(self) -> None: - loop = asyncio.get_event_loop() - messages = await loop.run_in_executor(None, self._fetch_unseen) - for m in messages: - await self._process_email(m) - - def _fetch_unseen(self) -> list[dict]: - self._reconnect_imap() - results = [] - try: - st, data = self._imap.search(None, "UNSEEN") - if st != "OK": - return [] - for mid in data[0].split()[-20:]: - st, msg_data = self._imap.fetch(mid, "(RFC822)") - if st != "OK": - continue - msg = email_lib.message_from_bytes(msg_data[0][1]) - from_name, from_addr = parseaddr(msg.get("From", "")) - body = self._extract_body(msg) - if len(body) > self.config.max_body_chars: - body = body[:self.config.max_body_chars] + "\n[...truncated]" - # Extract attachments and inline images - attachments = [] - if msg.is_multipart(): - for part in msg.walk(): - content_disp = part.get("Content-Disposition") or "" - content_type = part.get_content_type() or "" - is_attachment = "attachment" in content_disp.lower() - is_inline_image = ( - "inline" in content_disp.lower() - and content_type.startswith("image/") - ) - # Also detect non-text parts with a filename but no - # Content-Disposition header (common for PDFs, docs, - # etc. sent by some email clients). - is_named_file = ( - not is_attachment - and not is_inline_image - and part.get_filename() - and not content_type.startswith("multipart/") - and not content_type.startswith("text/") - ) - if is_attachment or is_inline_image or is_named_file: - filename = part.get_filename() or "attachment" - filename = _decode_hdr(filename) - payload_data = part.get_payload(decode=True) - if payload_data: - from ..base import MAX_ATTACHMENT_BYTES, MEDIA_DIR - if len(payload_data) > MAX_ATTACHMENT_BYTES: - attachments.append({"annotation": f"[attachment: {filename} - too large ({len(payload_data)} bytes)]"}) - else: - MEDIA_DIR.mkdir(parents=True, exist_ok=True) - local_path = MEDIA_DIR / f"email_{mid.decode()}_{filename}" - local_path.write_bytes(payload_data) - label = "inline-image" if is_inline_image else "attachment" - attachments.append({"path": str(local_path), "annotation": f"[{label}: {local_path}]"}) - if self.config.mark_seen: - self._imap.store(mid, "+FLAGS", "\\Seen") - results.append({ - "from_addr": from_addr, "from_name": _decode_hdr(from_name), - "subject": _decode_hdr(msg.get("Subject", "")), "body": body, - "message_id": msg.get("Message-ID", ""), "date": msg.get("Date", ""), - "references": msg.get("References", ""), "attachments": attachments, - }) - except Exception as e: - logger.error(f"IMAP fetch: {e}") - return results - - def _extract_body(self, msg) -> str: - if msg.is_multipart(): - for part in msg.walk(): - ct = part.get_content_type() - if ct == "text/plain": - return self._decode_payload(part) - for part in msg.walk(): - if part.get_content_type() == "text/html": - return _strip_html(self._decode_payload(part)) - return "[no text content]" - text = self._decode_payload(msg) - return _strip_html(text) if msg.get_content_type() == "text/html" else text - - @staticmethod - def _decode_payload(part) -> str: - payload = part.get_payload(decode=True) - if not payload: - return "" - charset = part.get_content_charset() or "utf-8" - return payload.decode(charset, errors="replace") - - async def _process_email(self, m: dict) -> None: - subject = m["subject"] - text = f"[邮件] 主题: {subject}\n\n{m['body']}" if subject else m["body"] - try: - ts = email_lib.utils.parsedate_to_datetime(m["date"]) - except Exception: - ts = datetime.now() - # Process attachments - media_paths: list[str] = [] - annotations: list[str] = [] - for att in m.get("attachments", []): - if att.get("path"): - media_paths.append(att["path"]) - if att.get("annotation"): - annotations.append(att["annotation"]) - await self._enqueue_raw(RawIncoming( - sender_id=m["from_addr"], chat_id=m["from_addr"], text=text, timestamp=ts, - message_id=m["message_id"], - media_files=media_paths, - content_annotations=annotations, - metadata={"chat_id": m["from_addr"], "subject": subject, - "original_message_id": m["message_id"], "references": m["references"], "backend": "email"}, - )) - - # ── Send ────────────────────────────────────────────────────── - - def _is_ready(self) -> bool: - return bool(self.config.smtp_host) - - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - loop = asyncio.get_event_loop() - try: - await loop.run_in_executor( - None, self._smtp_send_html, chat_id, formatted_text, raw_text, metadata or {}, - ) - except Exception as e: - err_str = str(e).lower() - # Only fall back to plain text for format-related errors, not server rejections - if any(code in err_str for code in ("550", "553", "554", "auth", "rejected")): - raise - logger.warning(f"HTML email failed ({e}), falling back to plain text") - await loop.run_in_executor( - None, self._smtp_send, chat_id, raw_text, metadata or {}, - ) - - def _smtp_send(self, to: str, content: str, meta: dict) -> None: - cfg = self.config - from_addr = cfg.from_address or cfg.smtp_username - logger.debug(f"SMTP plain send: from={from_addr} to={to}") - msg = EmailMessage() - orig_subj = meta.get("subject", "") - msg["Subject"] = f"{cfg.subject_prefix}{orig_subj}" if orig_subj and not orig_subj.lower().startswith("re:") else (orig_subj or "EvoScientist Reply") - msg["From"] = from_addr - msg["To"] = to - orig_id = meta.get("original_message_id", "") - if orig_id: - msg["In-Reply-To"] = orig_id - msg["References"] = f"{meta.get('references', '')} {orig_id}".strip() - msg.set_content(content) - try: - if cfg.smtp_use_tls: - srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) - srv.starttls() - else: - srv = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=ssl.create_default_context(), timeout=30) - srv.login(cfg.smtp_username, cfg.smtp_password) - srv.sendmail(from_addr, [to], msg.as_string()) - srv.quit() - except Exception as e: - logger.error(f"SMTP send failed: from={from_addr} to={to} error={e}") - raise RuntimeError(f"SMTP: {e}") - - def _smtp_send_html(self, to: str, html_content: str, plain_content: str, meta: dict) -> None: - """Send an email with both HTML and plain-text parts.""" - cfg = self.config - from_addr = cfg.from_address or cfg.smtp_username - logger.debug(f"SMTP HTML send: from={from_addr} to={to}") - msg = MIMEMultipart("alternative") - orig_subj = meta.get("subject", "") - msg["Subject"] = f"{cfg.subject_prefix}{orig_subj}" if orig_subj and not orig_subj.lower().startswith("re:") else (orig_subj or "EvoScientist Reply") - msg["From"] = from_addr - msg["To"] = to - orig_id = meta.get("original_message_id", "") - if orig_id: - msg["In-Reply-To"] = orig_id - msg["References"] = f"{meta.get('references', '')} {orig_id}".strip() - msg.attach(MIMEText(plain_content, "plain", "utf-8")) - msg.attach(MIMEText(html_content, "html", "utf-8")) - try: - if cfg.smtp_use_tls: - srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) - srv.starttls() - else: - srv = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=ssl.create_default_context(), timeout=30) - srv.login(cfg.smtp_username, cfg.smtp_password) - srv.sendmail(from_addr, [to], msg.as_string()) - srv.quit() - except Exception as e: - logger.error(f"SMTP HTML send failed: from={from_addr} to={to} error={e}") - raise RuntimeError(f"SMTP HTML: {e}") - - # ── Markdown to HTML formatting ─────────────────────────────── - - - # ── Media send (email attachment) ───────────────────────────── - - async def _send_media_impl( - self, - recipient: str, - file_path: str, - caption: str = "", - metadata: dict | None = None, - ) -> bool: - """Send a file as an email attachment via SMTP.""" - loop = asyncio.get_event_loop() - await loop.run_in_executor( - None, self._smtp_send_attachment, recipient, file_path, caption, metadata or {}, - ) - return True - - def _smtp_send_attachment(self, to: str, file_path: str, caption: str, meta: dict) -> None: - """Send an email with a file attachment.""" - cfg = self.config - from_addr = cfg.from_address or cfg.smtp_username - logger.debug(f"SMTP attachment send: from={from_addr} to={to} file={file_path}") - msg = MIMEMultipart() - orig_subj = meta.get("subject", "") - msg["Subject"] = f"{cfg.subject_prefix}{orig_subj}" if orig_subj and not orig_subj.lower().startswith("re:") else (orig_subj or "EvoScientist Reply") - msg["From"] = from_addr - msg["To"] = to - orig_id = meta.get("original_message_id", "") - if orig_id: - msg["In-Reply-To"] = orig_id - msg["References"] = f"{meta.get('references', '')} {orig_id}".strip() - - # Text body - if caption: - msg.attach(MIMEText(caption, "plain", "utf-8")) - - # Attachment - path = Path(file_path) - part = MIMEBase("application", "octet-stream") - part.set_payload(path.read_bytes()) - encoders.encode_base64(part) - part.add_header("Content-Disposition", f"attachment; filename={path.name}") - msg.attach(part) - - try: - if cfg.smtp_use_tls: - srv = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30) - srv.starttls() - else: - srv = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, context=ssl.create_default_context(), timeout=30) - srv.login(cfg.smtp_username, cfg.smtp_password) - srv.sendmail(from_addr, [to], msg.as_string()) - srv.quit() - except Exception as e: - logger.error(f"SMTP attachment send failed: from={from_addr} to={to} error={e}") - raise RuntimeError(f"SMTP attachment: {e}") - - async def _cleanup(self) -> None: - await self._stop_polling() - if self._imap: - try: - self._imap.close() - self._imap.logout() - except Exception: - pass - self._imap = None - logger.info("Email channel stopped") diff --git a/EvoScientist/channels/email/probe.py b/EvoScientist/channels/email/probe.py deleted file mode 100644 index 84c6c49..0000000 --- a/EvoScientist/channels/email/probe.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Email credential validation.""" - -import imaplib -import smtplib -import ssl -import logging - -logger = logging.getLogger(__name__) - - -async def validate_email_imap( - host: str, port: int, username: str, password: str, - use_ssl: bool = True, -) -> tuple[bool, str]: - """Validate IMAP credentials.""" - if not host or not username or not password: - return False, "host, username, and password are required" - - import asyncio - loop = asyncio.get_event_loop() - - def _check(): - try: - if use_ssl: - ctx = ssl.create_default_context() - conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) - else: - conn = imaplib.IMAP4(host, port) - conn.login(username, password) - conn.logout() - return True, "IMAP credentials valid" - except imaplib.IMAP4.error as e: - return False, f"IMAP auth failed: {e}" - except Exception as e: - return False, f"IMAP error: {e}" - - return await loop.run_in_executor(None, _check) - - -async def validate_email_smtp( - host: str, port: int, username: str, password: str, - use_tls: bool = True, -) -> tuple[bool, str]: - """Validate SMTP credentials.""" - if not host or not username or not password: - return False, "host, username, and password are required" - - import asyncio - loop = asyncio.get_event_loop() - - def _check(): - try: - if use_tls: - server = smtplib.SMTP(host, port, timeout=10) - server.starttls() - else: - ctx = ssl.create_default_context() - server = smtplib.SMTP_SSL(host, port, context=ctx, timeout=10) - server.login(username, password) - server.quit() - return True, "SMTP credentials valid" - except smtplib.SMTPAuthenticationError as e: - return False, f"SMTP auth failed: {e}" - except Exception as e: - return False, f"SMTP error: {e}" - - return await loop.run_in_executor(None, _check) diff --git a/EvoScientist/channels/email/serve.py b/EvoScientist/channels/email/serve.py deleted file mode 100644 index c014dca..0000000 --- a/EvoScientist/channels/email/serve.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Email channel server. - -Standalone script to run the Email channel with CLI options. - -Usage: - python -m EvoScientist.channels.email.serve --imap-host HOST --imap-username USER --imap-password PASS --smtp-host HOST --smtp-username USER --smtp-password PASS --from-address ADDR [OPTIONS] - -Examples: - # Basic usage - python -m EvoScientist.channels.email.serve --imap-host imap.gmail.com --imap-username bot@gmail.com --imap-password PASS --smtp-host smtp.gmail.com --smtp-username bot@gmail.com --smtp-password PASS --from-address bot@gmail.com - - # With allowed senders and custom poll interval - python -m EvoScientist.channels.email.serve --imap-host imap.gmail.com --imap-username bot@gmail.com --imap-password PASS --smtp-host smtp.gmail.com --smtp-username bot@gmail.com --smtp-password PASS --from-address bot@gmail.com --allow user@example.com --poll-interval 60 - - # With agent and thinking - python -m EvoScientist.channels.email.serve --imap-host imap.gmail.com --imap-username bot@gmail.com --imap-password PASS --smtp-host smtp.gmail.com --smtp-username bot@gmail.com --smtp-password PASS --from-address bot@gmail.com --agent --thinking -""" - -import argparse -import logging - -from .channel import EmailChannel, EmailConfig -from ..bus import MessageBus -from ..standalone import run_standalone - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", -) -logger = logging.getLogger(__name__) - - -def parse_args(): - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Email channel server", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--imap-host", - required=True, - help="IMAP server hostname", - ) - parser.add_argument( - "--imap-username", - required=True, - help="IMAP username", - ) - parser.add_argument( - "--imap-password", - required=True, - help="IMAP password", - ) - parser.add_argument( - "--smtp-host", - required=True, - help="SMTP server hostname", - ) - parser.add_argument( - "--smtp-username", - required=True, - help="SMTP username", - ) - parser.add_argument( - "--smtp-password", - required=True, - help="SMTP password", - ) - parser.add_argument( - "--from-address", - required=True, - help="From email address for outgoing messages", - ) - parser.add_argument( - "--allow", - action="append", - dest="allowed_senders", - help="Allowed sender (email address). Can be used multiple times.", - ) - parser.add_argument( - "--poll-interval", - type=int, - default=30, - help="IMAP poll interval in seconds (default: 30)", - ) - parser.add_argument( - "--agent", - action="store_true", - help="Use EvoScientist agent as handler (default: echo)", - ) - parser.add_argument( - "--thinking", - action="store_true", - help="Send thinking content as intermediate messages (requires --agent)", - ) - return parser.parse_args() - - -def main(): - """Entry point.""" - args = parse_args() - - config = EmailConfig( - imap_host=args.imap_host, - imap_username=args.imap_username, - imap_password=args.imap_password, - smtp_host=args.smtp_host, - smtp_username=args.smtp_username, - smtp_password=args.smtp_password, - from_address=args.from_address, - allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, - poll_interval=args.poll_interval, - ) - - send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = EmailChannel(config) - - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) - - -if __name__ == "__main__": - main() diff --git a/EvoScientist/channels/feishu/__init__.py b/EvoScientist/channels/feishu/__init__.py deleted file mode 100644 index 4cc68e5..0000000 --- a/EvoScientist/channels/feishu/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from .channel import FeishuChannel, FeishuConfig -from ..channel_manager import register_channel, _parse_csv - -__all__ = ["FeishuChannel", "FeishuConfig"] - - -def create_from_config(config) -> FeishuChannel: - allowed = _parse_csv(config.feishu_allowed_senders) - return FeishuChannel(FeishuConfig( - app_id=config.feishu_app_id, - app_secret=config.feishu_app_secret, - verification_token=config.feishu_verification_token, - encrypt_key=config.feishu_encrypt_key, - webhook_port=config.feishu_webhook_port, - allowed_senders=allowed, - feishu_domain=config.feishu_domain, - proxy=getattr(config, 'feishu_proxy', '') or None, - )) - - -register_channel("feishu", create_from_config) diff --git a/EvoScientist/channels/feishu/channel.py b/EvoScientist/channels/feishu/channel.py deleted file mode 100644 index e444be9..0000000 --- a/EvoScientist/channels/feishu/channel.py +++ /dev/null @@ -1,780 +0,0 @@ -"""Feishu (飞书/Lark) channel implementation. - -Receives messages via an HTTP event subscription webhook (aiohttp), -sends replies via Feishu Open API REST endpoints. - -Feishu Open API docs: https://open.feishu.cn/document - -Authentication: - - App ID + App Secret → tenant_access_token (2-hour TTL, auto-refreshed) - -Event subscription: - - URL verification challenge on first request - - ``im.message.receive_v1`` events for incoming messages - -Send API: - - ``POST /open-apis/im/v1/messages?receive_id_type=chat_id`` -""" - -from __future__ import annotations - -import json -import logging -import re -from typing import Any, TYPE_CHECKING -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path - -if TYPE_CHECKING: - from aiohttp import web - -from ..base import Channel, RawIncoming, ChannelError -from ..capabilities import FEISHU as FEISHU_CAPS -from ..mixins import WebhookMixin, TokenMixin -from ..config import BaseChannelConfig - -logger = logging.getLogger(__name__) - - -# ── Markdown → Feishu Post conversion ──────────────────────────── - - -def _parse_inline_text(text: str) -> list[dict]: - """Parse inline Markdown elements into Feishu post tag dicts. - - Handles: `code`, **bold**, ~~strikethrough~~, [link](url), _italic_. - """ - elements: list[dict] = [] - # Pattern order matters: code first (protect content), then bold, strikethrough, link, italic - pattern = re.compile( - r"`([^`]+)`" # inline code - r"|\*\*(.+?)\*\*" # bold - r"|~~(.+?)~~" # strikethrough - r"|\[([^\]]+)\]\(([^)]+)\)" # link - r"|_(.+?)_" # italic - ) - pos = 0 - for m in pattern.finditer(text): - # Plain text before this match - if m.start() > pos: - elements.append({"tag": "text", "text": text[pos:m.start()]}) - - if m.group(1) is not None: - # inline code → code_block would be block-level; use text with style - elements.append({ - "tag": "text", "text": m.group(1), - "style": ["code_block"], - }) - elif m.group(2) is not None: - elements.append({ - "tag": "text", "text": m.group(2), - "style": ["bold"], - }) - elif m.group(3) is not None: - elements.append({ - "tag": "text", "text": m.group(3), - "style": ["strikethrough"], - }) - elif m.group(4) is not None: - elements.append({ - "tag": "a", "text": m.group(4), "href": m.group(5), - }) - elif m.group(6) is not None: - elements.append({ - "tag": "text", "text": m.group(6), - "style": ["italic"], - }) - pos = m.end() - - # Remaining plain text - if pos < len(text): - elements.append({"tag": "text", "text": text[pos:]}) - return elements - - -def _parse_inline_elements(line: str) -> list[dict]: - """Parse a single Markdown line into a list of Feishu post elements. - - Handles headings (→ bold), blockquotes (→ italic with prefix), - list items (→ bullet prefix), and plain lines. - """ - # Heading: # Title → bold text - heading_match = re.match(r"^(#{1,6})\s+(.+)$", line) - if heading_match: - return [{"tag": "text", "text": heading_match.group(2), "style": ["bold"]}] - - # Blockquote: > text → italic with "▎" prefix - quote_match = re.match(r"^>\s*(.*)$", line) - if quote_match: - inner = quote_match.group(1) - elements = [{"tag": "text", "text": "▎", "style": ["italic"]}] - elements.extend(_parse_inline_text(inner)) - return elements - - # Unordered list: - item or * item → "• " prefix - list_match = re.match(r"^[\-\*]\s+(.+)$", line) - if list_match: - elements = [{"tag": "text", "text": "• "}] - elements.extend(_parse_inline_text(list_match.group(1))) - return elements - - # Ordered list: 1. item → keep number prefix - ol_match = re.match(r"^(\d+)\.\s+(.+)$", line) - if ol_match: - elements = [{"tag": "text", "text": f"{ol_match.group(1)}. "}] - elements.extend(_parse_inline_text(ol_match.group(2))) - return elements - - # Plain line - return _parse_inline_text(line) - - -def _markdown_to_feishu_post(text: str) -> dict | None: - """Convert Markdown text to Feishu post (rich text) JSON structure. - - Returns a dict like {"zh_cn": {"content": [[...]]}} suitable for - Feishu msg_type="post", or None if the text is empty. - """ - if not text or not text.strip(): - return None - - paragraphs: list[list[dict]] = [] - current_paragraph: list[dict] = [] - in_code_block = False - code_lines: list[str] = [] - code_lang = "" - - for line in text.split("\n"): - # Code block fences - if line.startswith("```"): - if not in_code_block: - # Flush any pending paragraph - if current_paragraph: - paragraphs.append(current_paragraph) - current_paragraph = [] - in_code_block = True - code_lang = line[3:].strip() - code_lines = [] - else: - # End of code block - code_text = "\n".join(code_lines) - paragraphs.append([{ - "tag": "code_block", - "language": code_lang or "plain", - "text": code_text, - }]) - in_code_block = False - code_lines = [] - code_lang = "" - continue - - if in_code_block: - code_lines.append(line) - continue - - # Empty line → new paragraph - if not line.strip(): - if current_paragraph: - paragraphs.append(current_paragraph) - current_paragraph = [] - continue - - # Non-empty line - elements = _parse_inline_elements(line) - if elements: - # Each visual line becomes its own paragraph in Feishu post - if current_paragraph: - paragraphs.append(current_paragraph) - current_paragraph = elements - - # Flush remaining - if in_code_block and code_lines: - code_text = "\n".join(code_lines) - paragraphs.append([{ - "tag": "code_block", - "language": code_lang or "plain", - "text": code_text, - }]) - elif current_paragraph: - paragraphs.append(current_paragraph) - - if not paragraphs: - return None - - return {"zh_cn": {"content": paragraphs}} - - -@dataclass -class FeishuConfig(BaseChannelConfig): - app_id: str = "" - app_secret: str = "" - verification_token: str = "" - encrypt_key: str = "" - webhook_port: int = 9000 - text_chunk_limit: int = 4096 - feishu_domain: str = "https://open.feishu.cn" - - -class FeishuChannel(Channel, WebhookMixin, TokenMixin): - capabilities = FEISHU_CAPS - """Feishu channel using Open API + event subscription webhook.""" - - name = "feishu" - _ready_attrs = ("_http_client", "_access_token") - _rate_limit_patterns = ("99991400", "rate limit", "频率限制") - _rate_limit_delay = 2.0 - - def __init__(self, config: FeishuConfig): - super().__init__(config) - self._mention_names: list[str] = [] # bot mention keys from events - - # ── WebhookMixin overrides ──────────────────────────────────── - - def _get_webhook_port(self) -> int: - return self.config.webhook_port - - def _webhook_routes(self) -> list[tuple[str, str, Any]]: - return [("POST", "/webhook/event", self._handle_event)] - - # ── TokenMixin overrides ────────────────────────────────────── - - async def _fetch_token(self) -> tuple[str, int]: - """Fetch Feishu tenant_access_token.""" - url = f"{self.config.feishu_domain}/open-apis/auth/v3/tenant_access_token/internal" - body = { - "app_id": self.config.app_id, - "app_secret": self.config.app_secret, - } - try: - resp = await self._http_client.post(url, json=body) - data = resp.json() - except Exception as e: - raise ChannelError(f"Failed to get Feishu access token: {e}") - - if data.get("code") != 0: - raise ChannelError( - f"Feishu auth error: {data.get('msg', 'unknown')}" - ) - return data["tenant_access_token"], data.get("expire", 7200) - - # ── Lifecycle ───────────────────────────────────────────────── - - async def start(self) -> None: - try: - from aiohttp import web # noqa: F401 - import httpx # noqa: F401 - except ImportError: - raise ChannelError( - "aiohttp or httpx not installed. " - "Install with: pip install aiohttp httpx" - ) - - if not self.config.app_id: - raise ChannelError("Feishu app_id is required") - if not self.config.app_secret: - raise ChannelError("Feishu app_secret is required") - - # Start webhook server (sets up self._http_client) - await self._start_webhook_server() - - # Verify credentials by fetching initial token - await self._refresh_token() - - self._running = True - logger.info( - f"Feishu channel started " - f"(webhook on port {self.config.webhook_port})" - ) - - async def _cleanup(self) -> None: - await self._stop_webhook_server() - self._access_token = None - logger.info("Feishu channel stopped") - - # ── Token helpers (adapt old API to mixin) ──────────────────── - - async def _ensure_token(self) -> str: - """Return a valid access token, refreshing if needed.""" - return await TokenMixin._ensure_token(self) - - # ── Send (template method overrides) ────────────────────────── - - async def _feishu_send(self, url: str, body: dict, headers: dict) -> bool: - """POST to Feishu API and return True if code==0.""" - try: - resp = await self._http_client.post(url, json=body, headers=headers) - return resp.json().get("code") == 0 - except Exception as e: - logger.warning(f"Feishu send error: {e}") - return False - - async def _send_chunk( - self, chat_id, formatted_text, raw_text, reply_to, metadata, - ): - token = await self._ensure_token() - headers = {"Authorization": f"Bearer {token}"} - post_content = _markdown_to_feishu_post(raw_text) - - # If reply_to is set, try the reply API first - if reply_to: - reply_url = ( - f"{self.config.feishu_domain}" - f"/open-apis/im/v1/messages/{reply_to}/reply" - ) - if post_content is not None: - body = {"msg_type": "post", "content": json.dumps(post_content)} - else: - body = {"msg_type": "text", "content": json.dumps({"text": formatted_text})} - if await self._feishu_send(reply_url, body, headers): - return - - # Normal send (non-reply or reply fallback) - url = ( - f"{self.config.feishu_domain}" - f"/open-apis/im/v1/messages?receive_id_type=chat_id" - ) - - # Try post format first - if post_content is not None: - body = { - "receive_id": chat_id, - "msg_type": "post", - "content": json.dumps(post_content), - } - if await self._feishu_send(url, body, headers): - return - - # Fallback: plain text - body = { - "receive_id": chat_id, - "msg_type": "text", - "content": json.dumps({"text": formatted_text}), - } - if not await self._feishu_send(url, body, headers): - raise RuntimeError("Feishu send failed") - - # ── Media helpers ────────────────────────────────────────────── - - _IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} - - async def _download_media( - self, message_id: str, file_key: str, msg_type: str, - ) -> str | None: - """Download an image or file attachment from Feishu. - - Returns the local file path on success, or None on failure. - """ - token = await self._ensure_token() - resource_type = "image" if msg_type == "image" else "file" - url = ( - f"{self.config.feishu_domain}" - f"/open-apis/im/v1/messages/{message_id}" - f"/resources/{file_key}?type={resource_type}" - ) - headers = {"Authorization": f"Bearer {token}"} - try: - resp = await self._http_client.get(url, headers=headers, timeout=30) - if resp.status_code != 200: - logger.warning( - f"Feishu media download failed: HTTP {resp.status_code}" - ) - return None - - # Check attachment size before writing to disk - cl = resp.headers.get("content-length") - if cl: - try: - too_large = self._check_attachment_size(int(cl), file_key) - if too_large: - logger.warning(too_large) - return None - except (ValueError, TypeError): - pass - from ..base import MAX_ATTACHMENT_BYTES - if len(resp.content) > MAX_ATTACHMENT_BYTES: - logger.warning( - f"Feishu media too large: {len(resp.content)} bytes" - ) - return None - - # Determine extension from Content-Type or default - content_type = resp.headers.get("content-type", "") - ext_map = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "image/webp": ".webp", - "image/bmp": ".bmp", - } - ext = ext_map.get(content_type, ".bin") - local_path = self._media_path(f"feishu_{message_id}_{file_key}{ext}") - local_path.write_bytes(resp.content) - return str(local_path) - except Exception as e: - logger.warning(f"Failed to download Feishu media: {e}") - return None - - async def _upload_feishu_resource( - self, url: str, headers: dict, file_path: str, - field_name: str, extra_data: dict, - ) -> dict | None: - """Upload a file to Feishu API. Returns response data or None on failure.""" - with open(file_path, "rb") as f: - resp = await self._http_client.post( - url, headers=headers, data=extra_data, - files={field_name: (Path(file_path).name, f)}, - ) - data = resp.json() - if data.get("code") != 0: - logger.error(f"Feishu upload failed: {data.get('msg')}") - return None - return data["data"] - - async def _send_media_impl( - self, - recipient: str, - file_path: str, - caption: str = "", - metadata: dict | None = None, - ) -> bool: - """Send a media file through Feishu.""" - token = await self._ensure_token() - headers = {"Authorization": f"Bearer {token}"} - chat_id = self._resolve_media_chat_id(recipient, metadata) - - path = Path(file_path) - ext = path.suffix.lower() - is_image = ext in self._IMAGE_EXTENSIONS - - send_url = ( - f"{self.config.feishu_domain}" - f"/open-apis/im/v1/messages?receive_id_type=chat_id" - ) - - if is_image: - upload_url = f"{self.config.feishu_domain}/open-apis/im/v1/images" - data = await self._upload_feishu_resource( - upload_url, headers, file_path, "image", {"image_type": "message"}, - ) - if not data: - return False - body = { - "receive_id": chat_id, - "msg_type": "image", - "content": json.dumps({"image_key": data["image_key"]}), - } - else: - upload_url = f"{self.config.feishu_domain}/open-apis/im/v1/files" - data = await self._upload_feishu_resource( - upload_url, headers, file_path, "file", - {"file_type": "stream", "file_name": path.name}, - ) - if not data: - return False - body = { - "receive_id": chat_id, - "msg_type": "file", - "content": json.dumps({"file_key": data["file_key"]}), - } - - if not await self._feishu_send(send_url, body, headers): - return False - - # Send caption as a separate text message if provided - if caption: - cap_body = { - "receive_id": chat_id, - "msg_type": "text", - "content": json.dumps({"text": caption}), - } - await self._feishu_send(send_url, cap_body, headers) - - return True - - # ── ACK reaction ─────────────────────────────────────────────── - - async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - """Send an acknowledgment reaction via Feishu Open API.""" - try: - token = await self._ensure_token() - url = f"{self.config.feishu_domain}/open-apis/im/v1/messages/{message_id}/reactions" - await self._http_client.post( - url, - json={"reaction_type": {"emoji_type": emoji}}, - headers={"Authorization": f"Bearer {token}"}, - ) - except Exception as e: - logger.debug(f"Feishu ack reaction failed: {e}") - - async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - """Remove ACK reaction via Feishu Open API. - - Feishu's DELETE /reactions endpoint requires the reaction_id, which - we don't track. No-op for now. - """ - pass - - # ── Format chunk ───────────────────────────────────────────── - - - # ── Typing indicator ────────────────────────────────────────── - - # Feishu Open API has no typing indicator endpoint. - - # ── Mention stripping ───────────────────────────────────────── - - def _strip_mention(self, text: str) -> str: - """Strip bot @mention placeholders from Feishu text. - - In Feishu v2 events the text contains placeholders like ``@_user_1`` - for each mention. ``_mention_names`` caches the placeholder keys that - belong to the bot (identified during ``_on_message``). - """ - result = text - for key in self._mention_names: - result = result.replace(key, "") - # Clean up extra whitespace left behind - return re.sub(r" +", " ", result).strip() - - # ── Webhook event handler ───────────────────────────────────── - - async def _handle_event(self, request) -> "web.Response": - """Handle POST /webhook/event from Feishu.""" - from aiohttp import web - - try: - body = await request.json() - except Exception: - return web.Response(status=400) - - # ── URL verification challenge ── - if body.get("type") == "url_verification": - challenge = body.get("challenge", "") - return web.json_response({"challenge": challenge}) - - # ── v2 event schema ── - schema = body.get("schema") - if schema == "2.0": - header = body.get("header", {}) - - # Verify token if configured - if self.config.verification_token: - token = header.get("token", "") - if token != self.config.verification_token: - logger.warning("Feishu event token mismatch") - return web.Response(status=403) - - event_type = header.get("event_type", "") - if event_type == "im.message.receive_v1": - await self._on_message(body.get("event", {})) - - # ── v1 event schema (legacy) ── - elif "event" in body: - if self.config.verification_token: - token = body.get("token", "") - if token != self.config.verification_token: - logger.warning("Feishu event token mismatch (v1)") - return web.Response(status=403) - - event = body["event"] - msg_type = event.get("type", "") - if msg_type == "message": - await self._on_message_v1(event) - - return web.Response(status=200) - - async def _on_message(self, event: dict) -> None: - """Handle im.message.receive_v1 event (v2 schema).""" - sender_info = event.get("sender", {}) - sender_id_info = sender_info.get("sender_id", {}) - sender_id = ( - sender_id_info.get("open_id") - or sender_id_info.get("user_id") - or "" - ) - sender_type = sender_info.get("sender_type", "") - - # Skip bot's own messages - if sender_type == "app": - return - - message = event.get("message", {}) - chat_id = message.get("chat_id", "") - msg_type = message.get("message_type", "") - message_id = message.get("message_id", "") - - # In group chats, detect mention status for centralized gating - chat_type = message.get("chat_type", "") - is_group = chat_type == "group" - was_mentioned = True - if is_group: - mentions = message.get("mentions", []) - was_mentioned = bool(mentions) - # Cache bot mention keys — bot mentions have empty user IDs - bot_keys = [] - for m in mentions: - m_id = m.get("id", {}) - # Bot/app mentions have no open_id / user_id - if not m_id.get("open_id") and not m_id.get("user_id"): - key = m.get("key", "") - if key: - bot_keys.append(key) - if bot_keys: - self._mention_names = bot_keys - - # Parse content JSON - content_str = message.get("content", "{}") - try: - content_data = json.loads(content_str) - except json.JSONDecodeError: - content_data = {} - - text = "" - annotations: list[str] = [] - media_paths: list[str] = [] - - if msg_type == "text": - text = content_data.get("text", "") - elif msg_type == "post": - text = self._extract_post_text(content_data) - elif msg_type == "image" and self.config.include_attachments: - image_key = content_data.get("image_key", "") - if image_key: - local = await self._download_media(message_id, image_key, "image") - if local: - media_paths.append(local) - annotations.append(f"[attachment: {local}]") - else: - annotations.append("[image message - download failed]") - else: - annotations.append("[image message]") - elif msg_type == "file" and self.config.include_attachments: - file_key = content_data.get("file_key", "") - file_name = content_data.get("file_name", "unknown") - if file_key: - local = await self._download_media(message_id, file_key, "file") - if local: - media_paths.append(local) - annotations.append(f"[attachment: {local}]") - else: - annotations.append(f"[file: {file_name} - download failed]") - else: - annotations.append(f"[file message: {file_name}]") - elif msg_type in ("audio", "media") and self.config.include_attachments: - # Feishu audio messages are voice recordings - media_label = "voice" if msg_type == "audio" else msg_type - file_key = content_data.get("file_key", "") - if file_key: - local = await self._download_media(message_id, file_key, "file") - if local: - media_paths.append(local) - annotations.append(f"[{media_label}: {local}]") - else: - annotations.append(f"[{media_label} message - download failed]") - else: - annotations.append(f"[{media_label} message]") - elif msg_type == "sticker": - sticker_key = content_data.get("file_key", "") - if sticker_key and self.config.include_attachments: - local = await self._download_media(message_id, sticker_key, "image") - if local: - media_paths.append(local) - annotations.append(f"[sticker: {local}]") - else: - annotations.append("[sticker message]") - else: - annotations.append("[sticker message]") - else: - text = f"[{msg_type} message]" - - if not text and not media_paths and not annotations: - return - - # Parse timestamp (milliseconds) - create_time = message.get("create_time", "") - try: - timestamp = datetime.fromtimestamp( - int(create_time) / 1000 - ) if create_time else datetime.now() - except (ValueError, TypeError, OSError): - timestamp = datetime.now() - - await self._enqueue_raw(RawIncoming( - sender_id=sender_id, - chat_id=chat_id, - text=text, - media_files=media_paths, - content_annotations=annotations, - timestamp=timestamp, - message_id=message_id, - metadata={ - "chat_id": chat_id, - "chat_type": message.get("chat_type", ""), - }, - is_group=is_group, - was_mentioned=was_mentioned, - )) - - async def _on_message_v1(self, event: dict) -> None: - """Handle v1 schema message event (legacy).""" - sender_id = event.get("open_id", "") - if not sender_id: - return - - # Detect group and mention status for centralized gating - chat_type = event.get("chat_type", "") - is_group = chat_type == "group" - was_mentioned = True - if is_group: - text_without_at = event.get("text_without_at_bot", "") - was_mentioned = bool(text_without_at) - - text = event.get("text_without_at_bot", "") or event.get("text", "") - if not text: - return - - chat_id = event.get("open_chat_id", "") - message_id = event.get("open_message_id", "") - - await self._enqueue_raw(RawIncoming( - sender_id=sender_id, - chat_id=chat_id, - text=text, - timestamp=datetime.now(), - message_id=message_id, - metadata={ - "chat_id": chat_id, - "chat_type": event.get("chat_type", ""), - }, - is_group=is_group, - was_mentioned=was_mentioned, - )) - - @staticmethod - def _extract_post_text(content: dict) -> str: - """Extract plain text from Feishu post (rich text) content.""" - parts: list[str] = [] - # Post content has locale keys like "zh_cn", "en_us" - for locale_key in ("zh_cn", "en_us", "ja_jp"): - locale_content = content.get(locale_key) - if locale_content: - title = locale_content.get("title", "") - if title: - parts.append(title) - for paragraph in locale_content.get("content", []): - line_parts: list[str] = [] - for element in paragraph: - tag = element.get("tag", "") - if tag == "text": - line_parts.append(element.get("text", "")) - elif tag == "a": - line_parts.append(element.get("text", "")) - elif tag == "at": - # Skip @mentions of the bot - pass - line = "".join(line_parts).strip() - if line: - parts.append(line) - break # Use first available locale - return "\n".join(parts) diff --git a/EvoScientist/channels/feishu/probe.py b/EvoScientist/channels/feishu/probe.py deleted file mode 100644 index 46d962e..0000000 --- a/EvoScientist/channels/feishu/probe.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Feishu (飞书/Lark) app credential validation.""" - -import logging - -logger = logging.getLogger(__name__) - - -async def validate_feishu_credentials( - app_id: str, - app_secret: str, - domain: str = "https://open.feishu.cn", -) -> tuple[bool, str]: - """Validate Feishu app credentials by requesting a tenant_access_token. - - Returns: - Tuple of (is_valid, message). - """ - if not app_id: - return False, "No app_id provided" - if not app_secret: - return False, "No app_secret provided" - - try: - import httpx - except ImportError: - return False, "httpx not installed" - - url = f"{domain}/open-apis/auth/v3/tenant_access_token/internal" - body = {"app_id": app_id, "app_secret": app_secret} - try: - async with httpx.AsyncClient() as client: - resp = await client.post(url, json=body, timeout=10) - data = resp.json() - if data.get("code") == 0: - return True, f"App: {app_id}" - msg = data.get("msg", "unknown error") - return False, f"Auth failed: {msg}" - except Exception as e: - return False, f"Error: {e}" diff --git a/EvoScientist/channels/feishu/serve.py b/EvoScientist/channels/feishu/serve.py deleted file mode 100644 index c9d0709..0000000 --- a/EvoScientist/channels/feishu/serve.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Feishu (飞书/Lark) channel server. - -Standalone script to run the Feishu channel with CLI options. - -Usage: - python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET [OPTIONS] - -Examples: - # Basic setup - python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET - - # With verification token and custom port - python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET \\ - --verification-token TOKEN --webhook-port 9000 - - # With agent and thinking - python -m EvoScientist.channels.feishu.serve --app-id ID --app-secret SECRET --agent --thinking -""" - -import argparse -import logging - -from .channel import FeishuChannel, FeishuConfig -from ..bus import MessageBus -from ..standalone import run_standalone - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", -) -logger = logging.getLogger(__name__) - - -def parse_args(): - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Feishu (飞书/Lark) channel server", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--app-id", - required=True, - help="Feishu App ID", - ) - parser.add_argument( - "--app-secret", - required=True, - help="Feishu App Secret", - ) - parser.add_argument( - "--verification-token", - default="", - help="Feishu event verification token", - ) - parser.add_argument( - "--encrypt-key", - default="", - help="Feishu event encrypt key", - ) - parser.add_argument( - "--webhook-port", - type=int, - default=9000, - help="Port for webhook HTTP server (default: 9000)", - ) - parser.add_argument( - "--domain", - default="https://open.feishu.cn", - help="Feishu API domain (use https://open.larksuite.com for Lark)", - ) - parser.add_argument( - "--allow", - action="append", - dest="allowed_senders", - help="Allowed sender (Feishu open_id). Can be used multiple times.", - ) - parser.add_argument( - "--agent", - action="store_true", - help="Use EvoScientist agent as handler (default: echo)", - ) - parser.add_argument( - "--thinking", - action="store_true", - help="Send thinking content as intermediate messages (requires --agent)", - ) - return parser.parse_args() - - -def main(): - """Entry point.""" - args = parse_args() - - config = FeishuConfig( - app_id=args.app_id, - app_secret=args.app_secret, - verification_token=args.verification_token, - encrypt_key=args.encrypt_key, - webhook_port=args.webhook_port, - feishu_domain=args.domain, - allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, - ) - - send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = FeishuChannel(config) - - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) - - -if __name__ == "__main__": - main() diff --git a/EvoScientist/channels/formatter.py b/EvoScientist/channels/formatter.py deleted file mode 100644 index c37e854..0000000 --- a/EvoScientist/channels/formatter.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Unified formatting pipeline for all channels. - -Internal representation is Markdown. This module converts Markdown to -each platform's native format: HTML, Slack mrkdwn, Discord Markdown, -or plain text. - -Channels no longer need per-file format functions — they just declare -``capabilities.format_type`` and the base class auto-configures a -``UnifiedFormatter`` instance. -""" - -from __future__ import annotations - -import re -from typing import Callable - - -# ═════════════════════════════════════════════════════════════════════ -# Markdown conversion engine (formerly markdown_utils.py) -# ═════════════════════════════════════════════════════════════════════ - -_PLACEHOLDER_PREFIX = "\x00BLOCK" -_INLINE_PREFIX = "\x00INLINE" - -# A formatting rule: (regex_pattern, replacement) -InlineRule = tuple[str, str] - - -def convert_markdown( - text: str, - *, - code_block_formatter: Callable[[str, str], str], - inline_code_formatter: Callable[[str], str], - inline_rules: list[InlineRule], - escape_fn: Callable[[str], str] | None = None, -) -> str: - """Convert Markdown to a channel-specific format. - - Parameters - ---------- - text: - Input Markdown text. - code_block_formatter: - ``(language, code) -> str`` — format a fenced code block. - inline_code_formatter: - ``(code) -> str`` — format an inline code span. - inline_rules: - List of ``(pattern, replacement)`` pairs applied in order to the - remaining text (after code extraction and optional escaping). - escape_fn: - Optional function applied to the non-code text *before* inline - rules. Useful for HTML-escaping (Telegram) or other channel- - specific character escaping. - - Returns - ------- - str - The converted text. - """ - # 1. Extract and protect fenced code blocks (```...```) - code_blocks: list[str] = [] - - def _save_code_block(m: re.Match) -> str: - lang = m.group(1) or "" - code = m.group(2) - formatted = code_block_formatter(lang, code) - idx = len(code_blocks) - code_blocks.append(formatted) - return f"{_PLACEHOLDER_PREFIX}{idx}\x00" - - text = re.sub(r"```(\w*)\n?(.*?)```", _save_code_block, text, flags=re.DOTALL) - - # 2. Extract and protect inline code (`...`) - inline_codes: list[str] = [] - - def _save_inline(m: re.Match) -> str: - code = m.group(1) - formatted = inline_code_formatter(code) - idx = len(inline_codes) - inline_codes.append(formatted) - return f"{_INLINE_PREFIX}{idx}\x00" - - text = re.sub(r"`([^`]+)`", _save_inline, text) - - # 3. Optional escaping of remaining text - if escape_fn is not None: - text = escape_fn(text) - - # 4. Apply inline formatting rules - for pattern, replacement in inline_rules: - text = re.sub(pattern, replacement, text, flags=re.MULTILINE) - - # 5. Restore code blocks and inline code - for idx, html in enumerate(code_blocks): - text = text.replace(f"{_PLACEHOLDER_PREFIX}{idx}\x00", html) - for idx, code in enumerate(inline_codes): - text = text.replace(f"{_INLINE_PREFIX}{idx}\x00", code) - - return text - -# ═════════════════════════════════════════════════════════════════════ -# Shared helpers -# ═════════════════════════════════════════════════════════════════════ - -def _escape_html(text: str) -> str: - return text.replace("&", "&").replace("<", "<").replace(">", ">") - - -def _noop_escape(text: str) -> str: - return text - - -# ═════════════════════════════════════════════════════════════════════ -# HTML profile (Telegram, Email, Teams) -# ═════════════════════════════════════════════════════════════════════ - -def _html_code_block(lang: str, code: str) -> str: - escaped = _escape_html(code) - if lang: - return f'
{escaped}
' - return f"
{escaped}
" - - -def _html_inline_code(code: str) -> str: - return f"{_escape_html(code)}" - - -_HTML_INLINE_RULES: list[InlineRule] = [ - # Headings → bold - (r"^#{1,6}\s+(.+)$", r"\1"), - # Blockquote markers (already escaped to >) - (r"^>\s?", ""), - # Links [text](url) → - (r"\[([^\]]+)\]\(([^)]+)\)", r'\1'), - # Bold **text** → - (r"\*\*(.+?)\*\*", r"\1"), - # Italic _text_ → - (r"(?\1"), - # Strikethrough ~~text~~ → - (r"~~(.+?)~~", r"\1"), - # List items - (r"^[\-\*]\s+", "• "), -] - - -# ═════════════════════════════════════════════════════════════════════ -# Slack mrkdwn profile -# ═════════════════════════════════════════════════════════════════════ - -def _slack_code_block(lang: str, code: str) -> str: - return f"```\n{code}```" - - -def _slack_inline_code(code: str) -> str: - return f"`{code}`" - - -_SLACK_INLINE_RULES: list[InlineRule] = [ - (r"^#{1,6}\s+(.+)$", r"*\1*"), - (r"\[([^\]]+)\]\(([^)]+)\)", r"<\2|\1>"), - (r"\*\*(.+?)\*\*", r"*\1*"), - (r"~~(.+?)~~", r"~\1~"), - (r"^[\-\*]\s+", "• "), -] - - -# ═════════════════════════════════════════════════════════════════════ -# Discord profile (mostly passthrough, headings → bold) -# ═════════════════════════════════════════════════════════════════════ - -def _discord_code_block(lang: str, code: str) -> str: - return f"```{lang}\n{code}```" - - -def _discord_inline_code(code: str) -> str: - return f"`{code}`" - - -_DISCORD_INLINE_RULES: list[InlineRule] = [ - (r"^#{1,6}\s+(.+)$", r"**\1**"), -] - - -# ═════════════════════════════════════════════════════════════════════ -# Plain text profile (strip all formatting) -# ═════════════════════════════════════════════════════════════════════ - -def _plain_code_block(lang: str, code: str) -> str: - return code - - -def _plain_inline_code(code: str) -> str: - return code - - -_PLAIN_INLINE_RULES: list[InlineRule] = [ - (r"^#{1,6}\s+", ""), - (r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)"), - (r"\*\*(.+?)\*\*", r"\1"), - (r"(? str: - return f"```{lang}\n{code}```" - - -def _md_inline_code(code: str) -> str: - return f"`{code}`" - - -_MD_INLINE_RULES: list[InlineRule] = [] # passthrough — already Markdown - - -# ═════════════════════════════════════════════════════════════════════ -# Unified Formatter -# ═════════════════════════════════════════════════════════════════════ - -class UnifiedFormatter: - """Converts internal Markdown to a target platform format. - - Instantiated once per channel based on its ``capabilities.format_type``. - """ - - _PROFILES: dict[str, dict] = { - "html": dict( - code_block_formatter=_html_code_block, - inline_code_formatter=_html_inline_code, - inline_rules=_HTML_INLINE_RULES, - escape_fn=_escape_html, - ), - "slack_mrkdwn": dict( - code_block_formatter=_slack_code_block, - inline_code_formatter=_slack_inline_code, - inline_rules=_SLACK_INLINE_RULES, - escape_fn=None, - ), - "discord": dict( - code_block_formatter=_discord_code_block, - inline_code_formatter=_discord_inline_code, - inline_rules=_DISCORD_INLINE_RULES, - escape_fn=None, - ), - "markdown": dict( - code_block_formatter=_md_code_block, - inline_code_formatter=_md_inline_code, - inline_rules=_MD_INLINE_RULES, - escape_fn=None, - ), - "plain": dict( - code_block_formatter=_plain_code_block, - inline_code_formatter=_plain_inline_code, - inline_rules=_PLAIN_INLINE_RULES, - escape_fn=None, - ), - } - - def __init__(self, format_type: str = "plain") -> None: - self._format_type = format_type - profile = self._PROFILES.get(format_type) - if profile is None: - raise ValueError( - f"Unknown format_type: {format_type!r}. " - f"Available: {list(self._PROFILES.keys())}" - ) - self._profile = profile - - @property - def format_type(self) -> str: - return self._format_type - - def format(self, text: str) -> str: - """Convert Markdown *text* to the target format.""" - if not text: - return text - return convert_markdown(text, **self._profile) - - @classmethod - def for_channel(cls, format_type: str) -> "UnifiedFormatter": - """Factory: create a formatter for the given format type.""" - return cls(format_type) diff --git a/EvoScientist/channels/imessage/__init__.py b/EvoScientist/channels/imessage/__init__.py index 412fcc2..cfc980f 100644 --- a/EvoScientist/channels/imessage/__init__.py +++ b/EvoScientist/channels/imessage/__init__.py @@ -19,7 +19,6 @@ from .targets import ( IMessageTarget, IMessageService, ) -from ..channel_manager import register_channel, _parse_csv __all__ = [ "IMessageChannel", @@ -32,11 +31,3 @@ __all__ = [ "IMessageTarget", "IMessageService", ] - - -def create_from_config(config) -> IMessageChannel: - allowed = _parse_csv(config.imessage_allowed_senders) - return IMessageChannel(IMessageConfig(allowed_senders=allowed)) - - -register_channel("imessage", create_from_config) diff --git a/EvoScientist/channels/imessage/channel_rpc.py b/EvoScientist/channels/imessage/channel_rpc.py index ebf32d6..1f92fd4 100644 --- a/EvoScientist/channels/imessage/channel_rpc.py +++ b/EvoScientist/channels/imessage/channel_rpc.py @@ -6,12 +6,11 @@ via JSON-RPC, similar to OpenClaw's approach. import asyncio import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime -from pathlib import Path +from typing import AsyncIterator -from ..base import Channel, RawIncoming, ChannelError -from ..config import BaseChannelConfig +from ..base import Channel, IncomingMessage, OutgoingMessage, ChannelError from .rpc_client import ImsgRpcClient, RpcNotification from .targets import ( normalize_handle, @@ -24,32 +23,15 @@ 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): +class IMessageConfig: """Configuration for iMessage channel.""" cli_path: str = "imsg" db_path: str | None = None - text_chunk_limit: int = 4096 + allowed_senders: list[str] = field(default_factory=list) + include_attachments: bool = False + text_chunk_limit: int = 4000 service: str = "auto" # imessage, sms, or auto region: str = "US" @@ -64,39 +46,21 @@ class IMessageChannelRpc(Channel): config: Channel configuration """ - name = "imessage" - _ready_attrs = ("_client",) - def __init__(self, config: IMessageConfig | None = None): - super().__init__(config or IMessageConfig()) + self.config = config or IMessageConfig() self._client: ImsgRpcClient | None = None + self._running = False + self._message_queue: asyncio.Queue[IncomingMessage] = asyncio.Queue() self._subscription_id: int | None = None - # ── Pipeline overrides ──────────────────────────────────────── - - def _build_inbound_middlewares(self): - """Use iMessage-specific allow-list middleware. - - 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 ───────────────────────────────── - def _handle_notification(self, notification: RpcNotification) -> None: """Handle incoming RPC notifications.""" if notification.method == "message": - asyncio.create_task(self._handle_message(notification.params)) + self._handle_message(notification.params) elif notification.method == "error": logger.error(f"imsg error: {notification.params}") - async def _handle_message(self, params: dict | None) -> None: + def _handle_message(self, params: dict | None) -> None: """Process incoming message notification.""" if not params: return @@ -113,7 +77,16 @@ class IMessageChannelRpc(Channel): if not sender: return + # Check allowed senders + chat_id = message.get("chat_id") + chat_guid = message.get("chat_guid") + if not self._is_sender_allowed(sender, chat_id, chat_guid): + logger.debug(f"Ignoring message from {sender}") + return + text = message.get("text", "").strip() + if not text: + return # Parse timestamp timestamp = datetime.now() @@ -132,61 +105,23 @@ class IMessageChannelRpc(Channel): } # Handle attachments if enabled - annotations: list[str] = [] - media_paths: list[str] = [] - _VOICE_EXTS = {".caf", ".m4a", ".aac", ".ogg", ".opus", ".mp3", ".amr"} if self.config.include_attachments: attachments = message.get("attachments", []) - for att in attachments: - # imsg CLI provides local file paths for attachments - file_path = att if isinstance(att, str) else att.get("path", "") - if not file_path: - annotations.append("[attachment: missing path]") - continue - att_path = Path(file_path) - is_voice = att_path.suffix.lower() in _VOICE_EXTS - media_label = "voice" if is_voice else "attachment" - if att_path.exists(): - fname = att_path.name - # Check file size before copying - from ..base import MAX_ATTACHMENT_BYTES - if att_path.stat().st_size > MAX_ATTACHMENT_BYTES: - annotations.append( - f"[{media_label}: {fname} - too large " - f"({att_path.stat().st_size} bytes)]" - ) - else: - local = self._media_path(f"imsg_{fname}") - try: - import shutil - shutil.copy2(str(att_path), str(local)) - media_paths.append(str(local)) - annotations.append(f"[{media_label}: {local}]") - except Exception as e: - logger.warning(f"Failed to copy iMessage attachment: {e}") - annotations.append(f"[{media_label}: {fname} - copy failed]") - else: - annotations.append(f"[{media_label}: {file_path} - not found]") + if attachments: + metadata["attachments"] = attachments - if not text and not media_paths and not annotations: - return - - is_group = message.get("is_group", False) - - await self._enqueue_raw(RawIncoming( - sender_id=sender, - chat_id=str(metadata.get("chat_id", sender)), - text=text, - media_files=media_paths, - content_annotations=annotations, + incoming = IncomingMessage( + sender=sender, + content=text, timestamp=timestamp, message_id=str(message.get("id", "")), metadata=metadata, - is_group=is_group, - was_mentioned=True, # iMessage has no mention concept - )) + ) - # ── Sender filtering ────────────────────────────────────────── + try: + self._message_queue.put_nowait(incoming) + except asyncio.QueueFull: + logger.warning("Message queue full, dropping message") def _is_sender_allowed( self, @@ -244,35 +179,28 @@ class IMessageChannelRpc(Channel): return False - def _normalize_sender(self, sender: str) -> str: - """Normalize a sender identifier.""" - return sender if sender.startswith("chat") else normalize_handle(sender) - def add_allowed_sender(self, sender: str) -> None: """Add a sender to the allowed list.""" - normalized = self._normalize_sender(sender) - if self.config.allowed_senders is None: - self.config.allowed_senders = set() - self.config.allowed_senders.add(normalized) - logger.info(f"Added allowed sender: {normalized}") + normalized = normalize_handle(sender) if not sender.startswith("chat") else sender + if normalized not in self.config.allowed_senders: + self.config.allowed_senders.append(normalized) + logger.info(f"Added allowed sender: {normalized}") def remove_allowed_sender(self, sender: str) -> None: """Remove a sender from the allowed list.""" - normalized = self._normalize_sender(sender) - if self.config.allowed_senders: - self.config.allowed_senders.discard(normalized) + normalized = normalize_handle(sender) if not sender.startswith("chat") else sender + if normalized in self.config.allowed_senders: + self.config.allowed_senders.remove(normalized) logger.info(f"Removed allowed sender: {normalized}") def clear_allowed_senders(self) -> None: """Clear allowed list (allow all).""" - self.config.allowed_senders = None + self.config.allowed_senders = [] logger.info("Cleared allowed senders (allowing all)") def list_allowed_senders(self) -> list[str]: """Get current allowed senders.""" - return list(self.config.allowed_senders) if self.config.allowed_senders else [] - - # ── Lifecycle ───────────────────────────────────────────────── + return self.config.allowed_senders async def start(self) -> None: """Initialize and start the channel.""" @@ -303,7 +231,11 @@ class IMessageChannelRpc(Channel): self._running = True logger.info("iMessage channel started") - async def _cleanup(self) -> None: + async def stop(self) -> None: + """Stop the channel and clean up.""" + logger.info("Stopping iMessage channel...") + self._running = False + if self._client and self._subscription_id: try: await self._client.request( @@ -312,82 +244,149 @@ class IMessageChannelRpc(Channel): ) except Exception: pass + if self._client: await self._client.stop() self._client = None + logger.info("iMessage channel stopped") - # ── Send (template method overrides) ────────────────────────── - - def _resolve_target(self, chat_id: str | None, metadata: dict | None) -> dict: - """Resolve send target from metadata or chat_id string.""" - meta = metadata or {} - for key in ("chat_id", "chat_guid", "chat_identifier"): - if meta.get(key): - return {key: meta[key]} - if chat_id: + async def receive(self) -> AsyncIterator[IncomingMessage]: + """Yield incoming messages from the queue.""" + while self._running: try: - target = parse_target(chat_id) - if isinstance(target, ChatIdTarget): - return {"chat_id": target.chat_id} - elif isinstance(target, ChatGuidTarget): - return {"chat_guid": target.chat_guid} - elif isinstance(target, ChatIdentifierTarget): - return {"chat_identifier": target.chat_identifier} + msg = await asyncio.wait_for( + self._message_queue.get(), + timeout=1.0, + ) + yield msg + except asyncio.TimeoutError: + continue + + def _segment_message(self, content: str) -> list[str]: + """Split long message into segments.""" + limit = self.config.text_chunk_limit + if len(content) <= limit: + return [content] + + segments = [] + remaining = content + + while remaining: + if len(remaining) <= limit: + segments.append(remaining) + break + + chunk = remaining[:limit] + # Try split at newline + nl_pos = chunk.rfind("\n") + if nl_pos > limit // 2: + split_pos = nl_pos + 1 + else: + # Try split at space + sp_pos = chunk.rfind(" ") + if sp_pos > limit // 2: + split_pos = sp_pos + 1 else: - return {"to": target.to, "service": target.service.value} - except ValueError: - return {"to": chat_id} - return {} + split_pos = limit - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - """Send a single text chunk via iMessage RPC.""" + segments.append(remaining[:split_pos].rstrip()) + remaining = remaining[split_pos:].lstrip() + + return segments + + async def send(self, message: OutgoingMessage) -> bool: + """Send a message via iMessage.""" if not self._client: - raise RuntimeError("iMessage client not running") + logger.error("Cannot send: client not running") + return False + segments = self._segment_message(message.content) + + for segment in segments: + params = self._build_send_params(message, segment) + if not params: + logger.error(f"_build_send_params returned None for recipient={message.recipient}, metadata={message.metadata}") + return False + + try: + logger.debug(f"Calling imsg send with params: {params}") + await self._client.request("send", params) + except Exception as e: + logger.error(f"Send failed: {e}") + logger.error(f"Failed params were: {params}") + return False + + return True + + def _build_send_params( + self, message: OutgoingMessage, text: str + ) -> dict | None: + """Build send parameters from message.""" params: dict = { - "text": formatted_text, + "text": text, "service": self.config.service, "region": self.config.region, } - params.update(self._resolve_target(chat_id, metadata)) - if reply_to: - params["reply_to"] = reply_to + logger.debug(f"Building send params - recipient: {message.recipient}, metadata: {message.metadata}") - await self._client.request("send", params) + # Check metadata for chat targets + chat_id = message.metadata.get("chat_id") + chat_guid = message.metadata.get("chat_guid") + chat_identifier = message.metadata.get("chat_identifier") - # ── Retry logic (override base) ─────────────────────────────── + if chat_id: + params["chat_id"] = chat_id + elif chat_guid: + params["chat_guid"] = chat_guid + elif chat_identifier: + params["chat_identifier"] = chat_identifier + elif message.recipient: + # Parse recipient to determine target type + try: + target = parse_target(message.recipient) + if isinstance(target, ChatIdTarget): + params["chat_id"] = target.chat_id + elif isinstance(target, ChatGuidTarget): + params["chat_guid"] = target.chat_guid + elif isinstance(target, ChatIdentifierTarget): + params["chat_identifier"] = target.chat_identifier + else: + params["to"] = target.to + params["service"] = target.service.value + except ValueError: + params["to"] = message.recipient + else: + logger.error("Cannot send: no recipient or chat target") + return None - def _format_chunk(self, text: str) -> str: - """iMessage uses plain text; no formatting conversion needed.""" - return text + logger.debug(f"Built send params: {params}") + return params - - def _extract_retry_after(self, exc: Exception) -> float | None: - """iMessage-specific retry logic. - - RPC errors (e.g. AppleScript failures) are generally not - retryable. Transient connection issues get a short retry. - """ - msg = str(exc).lower() - if "not found" in msg or "applescript" in msg or "permission" in msg: - return None # not retryable - if "timeout" in msg or "connection" in msg: - return 1.0 - return None # default: don't retry RPC errors - - async def _send_media_impl( + async def send_media( self, recipient: str, file_path: str, caption: str = "", metadata: dict | None = None, ) -> bool: - """Send a media file via iMessage.""" + """Send a media file via iMessage. + + Args: + recipient: Target recipient or chat target + file_path: Local path to the media file + caption: Optional caption text + metadata: Optional metadata with chat_id etc. + + Returns: + True if sent successfully + """ if not self._client: + logger.error("Cannot send media: client not running") return False + metadata = metadata or {} params: dict = { "file": file_path, "service": self.config.service, @@ -397,11 +396,32 @@ class IMessageChannelRpc(Channel): if caption: params["text"] = caption - target = self._resolve_target(recipient, metadata) - if not target: + # Determine target + chat_id = metadata.get("chat_id") + chat_guid = metadata.get("chat_guid") + + if chat_id: + params["chat_id"] = chat_id + elif chat_guid: + params["chat_guid"] = chat_guid + elif recipient: + try: + target = parse_target(recipient) + if isinstance(target, ChatIdTarget): + params["chat_id"] = target.chat_id + elif isinstance(target, ChatGuidTarget): + params["chat_guid"] = target.chat_guid + else: + params["to"] = target.to + except ValueError: + params["to"] = recipient + else: logger.error("Cannot send media: no recipient") return False - params.update(target) - await self._client.request("send", params) - return True + try: + await self._client.request("send", params) + return True + except Exception as e: + logger.error(f"Send media failed: {e}") + return False diff --git a/EvoScientist/channels/imessage/serve.py b/EvoScientist/channels/imessage/serve.py index c8038f3..ff7c9b3 100644 --- a/EvoScientist/channels/imessage/serve.py +++ b/EvoScientist/channels/imessage/serve.py @@ -16,16 +16,337 @@ Examples: python -m EvoScientist.channels.imessage.serve --cli-path /usr/local/bin/imsg """ +import asyncio import argparse import logging +import signal +from typing import Callable from . import IMessageChannel, IMessageConfig -from ..bus import MessageBus -from ..standalone import run_standalone +from ..base import OutgoingMessage logger = logging.getLogger(__name__) +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) + + +def create_agent_handler( + on_thinking: Callable | None = None, + on_todo: Callable | None = None, +): + """Create handler that uses EvoScientist agent. + + Args: + on_thinking: Optional async callback for thinking content. + Signature: async def on_thinking(sender: str, thinking: str) -> None + on_todo: Optional async callback for todo list updates. + Signature: async def on_todo(sender: str, content: str, metadata: dict) -> None + """ + import os + from langchain_core.messages import HumanMessage + from ...config import get_effective_config, apply_config_to_env + from ...paths import set_workspace_root, ensure_dirs + from ...EvoScientist import create_cli_agent + from ...stream.events import stream_agent_events + + # Apply config so default_workdir is respected in non-CLI entry points + config = get_effective_config() + apply_config_to_env(config) + if config.default_workdir: + workdir = os.path.abspath(os.path.expanduser(config.default_workdir)) + set_workspace_root(workdir) + ensure_dirs() + + agent = create_cli_agent() + sessions: dict[str, str] = {} # sender -> thread_id + + async def handler(msg) -> str: + import uuid + sender = msg.sender + if sender not in sessions: + sessions[sender] = str(uuid.uuid4()) + thread_id = sessions[sender] + + if on_thinking: + final_content = "" + thinking_buffer = [] + todo_sent = False + thinking_sent = False + _MIN_THINKING_LEN = 200 # Skip short thinking (simple conversations) + + async for event in stream_agent_events(agent, msg.content, thread_id): + event_type = event.get("type") + + 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 on_todo and not todo_sent: + todos = event.get("args", {}).get("todos", []) + if todos: + # Flush thinking before todo (only if long enough) + if thinking_buffer and not thinking_sent: + full_thinking = "".join(thinking_buffer) + if len(full_thinking) >= _MIN_THINKING_LEN: + await on_thinking(sender, full_thinking, msg.metadata) + thinking_sent = True + thinking_buffer.clear() + await on_todo(sender, _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: + full_thinking = "".join(thinking_buffer) + if len(full_thinking) >= _MIN_THINKING_LEN: + await on_thinking(sender, full_thinking, msg.metadata) + thinking_sent = True + + return final_content or "No response" + else: + config = {"configurable": {"thread_id": thread_id}} + result = agent.invoke( + {"messages": [HumanMessage(content=msg.content)]}, + config=config, + ) + messages = result.get("messages", []) + for m in reversed(messages): + if hasattr(m, "content") and m.type == "ai": + content = m.content + # Handle structured content (thinking mode) + if isinstance(content, list): + text_parts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text_parts.append(block.get("text", "")) + return "\n".join(text_parts) if text_parts else "No response" + # Handle plain string content + return content + return "No response" + + return handler + + +class IMessageServer: + """Server that runs the iMessage channel and handles messages.""" + + def __init__( + self, + config: IMessageConfig, + handler: Callable | None = None, + send_thinking: bool = False, + initial_debounce: float = 2.0, + debounce_step: float = 0.5, + max_debounce: float = 5.0, + on_activity: Callable | None = None, + ): + """Initialize iMessage server. + + Args: + config: iMessage channel configuration. + handler: Message handler function. If None, uses echo handler. + send_thinking: If True, send thinking content as intermediate messages. + initial_debounce: Wait time after first message (seconds). + debounce_step: Additional wait per subsequent message. + max_debounce: Maximum debounce window cap. + on_activity: Optional callback(sender, direction) for notifications. + """ + self.config = config + self.channel = IMessageChannel(config) + self.send_thinking = send_thinking + self.initial_debounce = initial_debounce + self.debounce_step = debounce_step + self.max_debounce = max_debounce + self._running = False + self._pending_thinking: dict[str, str] = {} # sender -> accumulated thinking + self._on_activity = on_activity + + # Message buffering for debounce + self._message_buffers: dict[str, list[str]] = {} # sender -> [messages] + self._message_metadata: dict[str, dict] = {} # sender -> metadata (from first message) + self._debounce_tasks: dict[str, asyncio.Task] = {} # sender -> pending task + self._processing: set[str] = set() # senders currently being processed + + if handler: + self.handler = handler + else: + self.handler = self._default_handler + + async def _default_handler(self, msg) -> str: + """Default echo handler.""" + return f"Echo: {msg.content}" + + async def _process_buffered_messages(self, sender: str) -> None: + """Process all buffered messages for a sender. + + If the sender is currently being processed, skip — new messages + stay in the buffer and will be picked up after current processing. + """ + # Don't start a new handler if one is already running for this sender + if sender in self._processing: + logger.debug(f"Agent busy for {sender}, messages stay queued") + return + + if sender not in self._message_buffers: + return + + messages = self._message_buffers.pop(sender, []) + metadata = self._message_metadata.pop(sender, None) + 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}") + + self._processing.add(sender) + try: + class MergedMessage: + def __init__(self, s, c, m): + self.sender = s + self.content = c + self.metadata = m + + merged_msg = MergedMessage(sender, merged_content, metadata) + response = await self.handler(merged_msg) + + if response: + await self.channel.send(OutgoingMessage( + recipient=sender, + content=response, + metadata=metadata or {}, + )) + if self._on_activity: + try: + self._on_activity(sender, "replied") + except Exception: + pass + except Exception as e: + logger.error(f"Handler error: {e}") + finally: + self._processing.discard(sender) + + # If new messages arrived during processing, restart debounce + if sender in self._message_buffers and self._message_buffers[sender]: + msg_count = len(self._message_buffers[sender]) + wait = min( + self.initial_debounce + (msg_count - 1) * self.debounce_step, + self.max_debounce, + ) + logger.info(f"New messages queued for {sender}, restarting debounce ({wait:.1f}s)") + + async def restart_debounce(_s=sender, _w=wait): + await asyncio.sleep(_w) + await self._process_buffered_messages(_s) + + self._debounce_tasks[sender] = asyncio.create_task(restart_debounce()) + + async def _queue_message(self, msg) -> None: + """Queue a message with progressive debounce. + + If agent is busy, just buffer — messages will be picked up + after current processing finishes. Otherwise, start debounce: + 1st: 2.0s, 2nd: 2.5s, 3rd: 3.0s, ... up to max_debounce. + """ + sender = msg.sender + + if sender not in self._message_buffers: + self._message_buffers[sender] = [] + self._message_metadata[sender] = msg.metadata + self._message_buffers[sender].append(msg.content) + + if self._on_activity: + try: + self._on_activity(sender, "received") + except Exception: + pass + + # Agent is busy — just buffer, no debounce needed + if sender in self._processing: + logger.debug(f"Agent busy for {sender}, buffering message #{len(self._message_buffers[sender])}") + return + + 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 send_todo_message(self, sender: str, content: str, metadata: dict | None = None) -> None: + """Send todo list as intermediate message.""" + logger.debug(f"Sending todo list to {sender}") + await self.channel.send(OutgoingMessage( + recipient=sender, + content=content, + metadata=metadata or {}, + )) + + async def send_thinking_message(self, sender: str, thinking: str, metadata: dict | None = None) -> None: + """Send thinking content as intermediate message.""" + if not self.send_thinking: + return + + logger.debug(f"Sending thinking to {sender} with metadata: {metadata}") + content = f"\U0001f9e0\n{thinking}\n\u23f3" + await self.channel.send(OutgoingMessage( + recipient=sender, + content=content, + metadata=metadata or {}, + )) + logger.debug(f"Sent thinking to {sender}: {thinking[:50]}...") + + async def run(self) -> None: + """Run the server.""" + await self.channel.start() + self._running = True + + logger.info("iMessage server running. Press Ctrl+C to stop.") + if self.config.allowed_senders: + logger.info(f"Allowed senders: {self.config.allowed_senders}") + else: + logger.info("Allowing all senders") + logger.info(f"Debounce: {self.initial_debounce}s + {self.debounce_step}s/msg (max {self.max_debounce}s)") + + try: + async for msg in self.channel.receive(): + logger.info(f"From {msg.sender}: {msg.content[:50]}...") + await self._queue_message(msg) + finally: + for task in self._debounce_tasks.values(): + task.cancel() + await self.channel.stop() + + async def stop(self) -> None: + """Stop the server.""" + self._running = False + await self.channel.stop() + + def parse_args(): """Parse command line arguments.""" parser = argparse.ArgumentParser( @@ -65,8 +386,8 @@ def parse_args(): return parser.parse_args() -def main(): - """Entry point.""" +async def async_main(): + """Async entry point.""" args = parse_args() config = IMessageConfig( @@ -76,11 +397,42 @@ def main(): include_attachments=args.attachments, ) + handler = None send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = IMessageChannel(config) - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) + if args.agent: + logger.info("Loading EvoScientist agent...") + logger.info("Agent loaded") + + server = IMessageServer( + config, + handler=None, + send_thinking=send_thinking, + ) + + if args.agent: + on_thinking = server.send_thinking_message if send_thinking else None + on_todo = server.send_todo_message + handler = create_agent_handler(on_thinking=on_thinking, on_todo=on_todo) + server.handler = handler + if send_thinking: + logger.info("Thinking messages enabled") + + loop = asyncio.get_event_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, lambda: asyncio.create_task(server.stop())) + + await server.run() + + +def main(): + """Entry point.""" + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + asyncio.run(async_main()) if __name__ == "__main__": diff --git a/EvoScientist/channels/middleware.py b/EvoScientist/channels/middleware.py deleted file mode 100644 index c205fbd..0000000 --- a/EvoScientist/channels/middleware.py +++ /dev/null @@ -1,814 +0,0 @@ -"""Composable message processing middleware. - -Each middleware is a standalone class that can be composed into a pipeline. -They extract logic that was previously baked into the Channel base class, -making it reusable across both legacy and plugin-based channels. - -Also contains the supporting data structures (DedupCache, GroupHistoryBuffer, -TypingManager, PairingManager) that were previously in separate files. -""" - -from __future__ import annotations - -import asyncio -import dataclasses -import logging -import random -import time -from collections import OrderedDict, deque -from collections.abc import Awaitable -from dataclasses import dataclass -from typing import Any, Callable - -from .bus.events import InboundMessage, OutboundMessage -from .base import RawIncoming - -_logger = logging.getLogger(__name__) - - -# ═══════════════════════════════════════════════════════════════════════ -# Supporting data structures -# ═══════════════════════════════════════════════════════════════════════ - - -# ── Dedup cache ────────────────────────────────────────────────────── - -_DEDUP_MAX = 1000 -_DEDUP_TRIM = 500 -_DEDUP_TTL = 3600 # 1 hour - - -class DedupCache: - """Bounded ordered cache with TTL for detecting duplicate message IDs. - - Entries expire after *ttl_seconds* and are pruned lazily on each - lookup. When the cache exceeds *max_size* entries it is trimmed - down to *trim_to* by evicting the oldest entries. Accessed entries - are moved to the end (LRU behaviour). - """ - - def __init__( - self, - max_size: int = _DEDUP_MAX, - trim_to: int = _DEDUP_TRIM, - ttl_seconds: float = _DEDUP_TTL, - ) -> None: - self._seen: OrderedDict[str, float] = OrderedDict() - self._max = max_size - self._trim = trim_to - self._ttl = ttl_seconds - - # ── public API ────────────────────────────────────────────────── - - def is_duplicate(self, msg_id: str) -> bool: - """Return ``True`` if *msg_id* has been seen before. - - First-time IDs are recorded and ``False`` is returned. - Empty / falsy IDs are never considered duplicates. - Expired entries are pruned before the check. - """ - if not msg_id: - return False - - self._prune() - - if msg_id in self._seen: - # LRU: refresh position and timestamp - self._seen.move_to_end(msg_id) - self._seen[msg_id] = time.monotonic() - return True - - self._seen[msg_id] = time.monotonic() - if len(self._seen) > self._max: - while len(self._seen) > self._trim: - self._seen.popitem(last=False) - return False - - def clear(self) -> None: - """Remove all entries.""" - self._seen.clear() - - @property - def size(self) -> int: - """Number of entries currently in the cache.""" - return len(self._seen) - - # ── internal ──────────────────────────────────────────────────── - - def _prune(self) -> None: - """Remove entries older than *ttl_seconds*.""" - cutoff = time.monotonic() - self._ttl - # OrderedDict is insertion-ordered; oldest entries are first. - while self._seen: - key, ts = next(iter(self._seen.items())) - if ts > cutoff: - break - self._seen.popitem(last=False) - - -# ── Group history buffer ───────────────────────────────────────────── - -@dataclass -class HistoryEntry: - sender_id: str - text: str - timestamp: float - message_id: str = "" - - -class GroupHistoryBuffer: - """Per-chat circular buffer of recent messages.""" - - def __init__(self, max_per_chat: int = 50, max_age_seconds: int = 3600): - self._buffers: dict[str, deque[HistoryEntry]] = {} - self._max = max_per_chat - self._max_age = max_age_seconds - - def add(self, chat_id: str, entry: HistoryEntry) -> None: - """Add a message to the chat's history buffer.""" - if chat_id not in self._buffers: - self._buffers[chat_id] = deque(maxlen=self._max) - self._buffers[chat_id].append(entry) - - def get_recent(self, chat_id: str, limit: int = 20) -> list[HistoryEntry]: - """Get recent messages for context injection, excluding expired ones.""" - buf = self._buffers.get(chat_id) - if not buf: - return [] - now = time.time() - recent = [e for e in buf if now - e.timestamp < self._max_age] - return recent[-limit:] - - def format_context(self, chat_id: str, limit: int = 20) -> str: - """Format recent messages as context block for the agent.""" - entries = self.get_recent(chat_id, limit) - if not entries: - return "" - lines = ["[Chat messages since your last reply - for context]"] - for e in entries: - lines.append(f"[from: {e.sender_id}] {e.text}") - lines.append("[/Chat context]") - return "\n".join(lines) - - def clear(self, chat_id: str) -> None: - """Clear history for a chat (e.g., after the bot replies).""" - self._buffers.pop(chat_id, None) - - -# ── Typing indicator manager ───────────────────────────────────────── - -class TypingManager: - """Manages background typing-indicator loops per chat_id. - - Args: - send_action: Async callable that sends a single typing indicator - for a given chat_id. - interval: Seconds between typing indicator sends. - """ - - def __init__( - self, - send_action: Callable[[str], Awaitable[None]], - interval: float = 5.0, - ) -> None: - self._send_action = send_action - self._interval = interval - self._tasks: dict[str, asyncio.Task] = {} - - async def start(self, chat_id: str) -> None: - """Start a background typing-indicator loop for *chat_id*.""" - await self.stop(chat_id) - - async def _loop() -> None: - while True: - try: - await self._send_action(chat_id) - except Exception: - pass - await asyncio.sleep(self._interval) - - self._tasks[chat_id] = asyncio.create_task(_loop()) - - async def stop(self, chat_id: str) -> None: - """Cancel the typing-indicator loop for *chat_id*.""" - task = self._tasks.pop(chat_id, None) - if task: - task.cancel() - - async def stop_all(self) -> None: - """Cancel all active typing-indicator loops.""" - for cid in list(self._tasks): - await self.stop(cid) - - @property - def active_chats(self) -> list[str]: - """Return chat_ids with active typing loops.""" - return list(self._tasks) - - -# ── Pairing manager ───────────────────────────────────────────────── - -@dataclass -class PairingRequest: - sender_id: str - channel: str - code: str - created_at: float - approved: bool = False - - -class PairingManager: - """Manages DM pairing codes for channel access control.""" - - CODE_EXPIRY = 3600 # 1 hour - MAX_PENDING = 50 # max pending requests - - def __init__(self): - self._pending: dict[str, PairingRequest] = {} # code -> request - self._approved: set[str] = set() # "channel:sender_id" keys - - def is_approved(self, channel: str, sender_id: str) -> bool: - """Check if sender is already approved.""" - return f"{channel}:{sender_id}" in self._approved - - def request_pairing(self, channel: str, sender_id: str) -> str: - """Generate a pairing code for a new sender. Returns the code.""" - # Check if already has pending request - for code, req in list(self._pending.items()): - if req.sender_id == sender_id and req.channel == channel: - if time.time() - req.created_at < self.CODE_EXPIRY: - return code # return existing code - else: - del self._pending[code] - break - - # Cleanup expired - self._cleanup_expired() - - # Generate new code - code = f"{random.randint(100000, 999999)}" - while code in self._pending: - code = f"{random.randint(100000, 999999)}" - - self._pending[code] = PairingRequest( - sender_id=sender_id, - channel=channel, - code=code, - created_at=time.time(), - ) - _logger.info(f"Pairing code {code} generated for {channel}:{sender_id}") - return code - - def approve(self, code: str) -> tuple[bool, str]: - """Approve a pairing code. Returns (success, message).""" - req = self._pending.get(code) - if not req: - return False, f"Unknown code: {code}" - if time.time() - req.created_at > self.CODE_EXPIRY: - del self._pending[code] - return False, f"Code {code} expired" - - key = f"{req.channel}:{req.sender_id}" - self._approved.add(key) - del self._pending[code] - _logger.info(f"Approved pairing for {key}") - return True, f"Approved {req.sender_id} on {req.channel}" - - def reject(self, code: str) -> tuple[bool, str]: - """Reject a pairing code.""" - if code in self._pending: - del self._pending[code] - return True, f"Rejected code {code}" - return False, f"Unknown code: {code}" - - def list_pending(self) -> list[PairingRequest]: - """List all pending (non-expired) requests.""" - self._cleanup_expired() - return list(self._pending.values()) - - def _cleanup_expired(self): - now = time.time() - expired = [c for c, r in self._pending.items() if now - r.created_at > self.CODE_EXPIRY] - for c in expired: - del self._pending[c] - - -# ═══════════════════════════════════════════════════════════════════════ -# Middleware classes -# ═══════════════════════════════════════════════════════════════════════ - - -# ── Inbound middleware base ────────────────────────────────────────── - -class InboundMiddleware: - """Base class for inbound message processing middleware.""" - - async def process_inbound( - self, raw: RawIncoming, context: dict[str, Any], - ) -> RawIncoming | None: - """Process an inbound raw message. - - Return the (possibly modified) RawIncoming to continue the - pipeline, or ``None`` to drop the message. - """ - return raw - - -class OutboundMiddlewareBase: - """Base class for outbound message processing middleware.""" - - async def process_outbound( - self, message: OutboundMessage, context: dict[str, Any], - ) -> OutboundMessage | None: - """Process an outbound message. - - Return the (possibly modified) OutboundMessage to continue, - or ``None`` to drop it. - """ - return message - - -# ── Dedup ──────────────────────────────────────────────────────────── - -class DedupMiddleware(InboundMiddleware): - """Message deduplication using a bounded TTL cache.""" - - def __init__( - self, - max_size: int = 1000, - trim_to: int = 500, - ttl_seconds: float = 3600.0, - ) -> None: - self._cache = DedupCache( - max_size=max_size, trim_to=trim_to, ttl_seconds=ttl_seconds, - ) - - async def process_inbound( - self, raw: RawIncoming, context: dict[str, Any], - ) -> RawIncoming | None: - if raw.message_id and self._cache.is_duplicate(raw.message_id): - _logger.debug(f"Dedup: skipping duplicate message {raw.message_id}") - return None - return raw - - -# ── Debounce ───────────────────────────────────────────────────────── - -class DebounceMiddleware: - """Per-sender message batching with configurable timing. - - This middleware collects messages from the same sender and merges - them after a debounce delay. It does not follow the simple - process_inbound pattern because it needs to buffer across calls. - - Usage: call ``submit()`` for each message; merged results are - delivered via the ``on_ready`` callback. - """ - - def __init__( - self, - *, - initial_debounce: float = 2.0, - debounce_step: float = 0.5, - max_debounce: float = 5.0, - on_ready: Callable[[InboundMessage], Any] | None = None, - ) -> None: - self.initial_debounce = initial_debounce - self.debounce_step = debounce_step - self.max_debounce = max_debounce - self.on_ready = on_ready - - self._buffers: dict[str, list[str]] = {} - self._metadata: dict[str, dict] = {} - self._media: dict[str, list[str]] = {} - self._message_ids: dict[str, str] = {} - self._tasks: dict[str, asyncio.Task] = {} - self._channel_name: str = "" - - def set_channel_name(self, name: str) -> None: - self._channel_name = name - - async def submit(self, msg: InboundMessage) -> None: - """Buffer *msg* and schedule flush after debounce delay.""" - sender = msg.sender_id - - if sender not in self._buffers: - self._buffers[sender] = [] - self._metadata[sender] = msg.metadata - self._media[sender] = [] - self._buffers[sender].append(msg.content) - if msg.message_id: - self._message_ids[sender] = msg.message_id - if msg.media: - self._media[sender].extend(msg.media) - - if sender in self._tasks: - self._tasks[sender].cancel() - - count = len(self._buffers[sender]) - wait = min( - self.initial_debounce + (count - 1) * self.debounce_step, - self.max_debounce, - ) - - async def _flush(_s: str = sender, _w: float = wait) -> None: - await asyncio.sleep(_w) - await self._flush_sender(_s) - - self._tasks[sender] = asyncio.create_task(_flush()) - - async def _flush_sender(self, sender: str) -> None: - messages = self._buffers.pop(sender, []) - metadata = self._metadata.pop(sender, None) - media = self._media.pop(sender, []) - message_id = self._message_ids.pop(sender, "") - self._tasks.pop(sender, None) - if not messages: - return - - merged = "\n".join(messages) - chat_id = (metadata or {}).get("chat_id", sender) - inbound = InboundMessage( - channel=self._channel_name, - sender_id=sender, - chat_id=str(chat_id), - content=merged, - media=media, - metadata=metadata or {}, - message_id=message_id, - ) - if self.on_ready: - await self.on_ready(inbound) - - def cancel_all(self) -> None: - """Cancel all pending debounce tasks.""" - for task in self._tasks.values(): - task.cancel() - self._tasks.clear() - - -# ── Chunking ───────────────────────────────────────────────────────── - -class ChunkingMiddleware(OutboundMiddlewareBase): - """Auto-split messages respecting format expansion. - - Wraps the existing ``chunking.chunk_text`` utility and the - re-splitting logic from ``Channel._prepare_chunks``. - """ - - def __init__(self, capabilities: Any) -> None: - from .capabilities import ChannelCapabilities - self._capabilities: ChannelCapabilities = capabilities - - def prepare_chunks( - self, - content: str, - limit: int, - format_fn: Callable[[str], str] | None = None, - ) -> list[tuple[str, str]]: - """Build ``(formatted, raw)`` pairs, re-splitting when needed. - - If *format_fn* is None, formatted == raw. - """ - from .base import chunk_text - - if format_fn is None: - format_fn = lambda t: t # noqa: E731 - - raw_chunks = chunk_text(content, limit) - pairs: list[tuple[str, str]] = [] - for raw in raw_chunks: - formatted = format_fn(raw) - if len(formatted) <= limit: - pairs.append((formatted, raw)) - else: - sub_limit = max(limit // 2, 500) - for sub_raw in chunk_text(raw, sub_limit): - sub_fmt = format_fn(sub_raw) - if len(sub_fmt) <= limit: - pairs.append((sub_fmt, sub_raw)) - else: - pairs.append((sub_raw, sub_raw)) - return pairs - - -# ── Formatting ─────────────────────────────────────────────────────── - -class FormattingMiddleware(OutboundMiddlewareBase): - """Markdown -> channel format conversion. - - Uses ``UnifiedFormatter`` configured from capabilities. - """ - - def __init__(self, capabilities: Any) -> None: - from .formatter import UnifiedFormatter - from .capabilities import ChannelCapabilities - caps: ChannelCapabilities = capabilities - self._formatter = UnifiedFormatter.for_channel(caps.format_type) - - def format(self, text: str) -> str: - """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 ──────────────────────────────────────────────────────────── - -class RetryMiddleware: - """Exponential backoff send retry. - - Wraps ``retry.retry_async`` with channel-appropriate configuration. - """ - - def __init__(self, channel_name: str = "unknown") -> None: - from .retry import DEFAULT_RETRY, RETRY_PRESETS - self._config = RETRY_PRESETS.get(channel_name, DEFAULT_RETRY) - self._channel_name = channel_name - - async def execute( - self, - coro_factory: Callable[[], Any], - should_retry: Callable[[Exception, int], bool] | None = None, - retry_after_s: Callable[[Exception], float | None] | None = None, - ) -> Any: - """Execute *coro_factory* with retry logic.""" - from .retry import retry_async - - return await retry_async( - coro_factory, - config=self._config, - should_retry=should_retry or (lambda exc, _: True), - retry_after_s=retry_after_s, - on_retry=lambda info: _logger.warning( - f"{self._channel_name} retry {info.attempt}/{info.max_attempts} " - f"in {info.delay_s:.2f}s: {info.error}" - ), - label=f"{self._channel_name}.send", - ) - - -# ── Typing ─────────────────────────────────────────────────────────── - -class TypingMiddleware: - """Typing indicator management. - - Wraps ``TypingManager`` for use as a standalone middleware component. - """ - - def __init__( - self, - send_typing_fn: Callable[[str], Any], - interval: float = 5.0, - ) -> None: - self._manager = TypingManager(send_typing_fn, interval=interval) - - async def start(self, chat_id: str) -> None: - await self._manager.start(chat_id) - - async def stop(self, chat_id: str) -> None: - await self._manager.stop(chat_id) - - async def stop_all(self) -> None: - await self._manager.stop_all() - - -# ── ACK Reaction ───────────────────────────────────────────────────── - -class AckReactionMiddleware: - """ACK emoji reaction with configurable scope. - - Scope controls when reactions are sent: - - ``"all"``: react to every message - - ``"direct"``: react only in DMs - - ``"group-all"``: react in group chats (all messages) - - ``"group-mentions"``: react in groups only when mentioned - - ``"off"``: disable reactions - """ - - def __init__( - self, - *, - scope: str = "all", - emoji: str = "\U0001f440", - remove_after_reply: bool = False, - send_fn: Callable[[str, str, str], Any] | None = None, - remove_fn: Callable[[str, str, str], Any] | None = None, - ) -> None: - self.scope = scope - self.emoji = emoji - self.remove_after_reply = remove_after_reply - self._send_fn = send_fn - self._remove_fn = remove_fn - self._pending: dict[str, str] = {} # chat_id -> message_id - - def should_react(self, *, is_group: bool, was_mentioned: bool) -> bool: - if self.scope == "off": - return False - if self.scope == "all": - return True - if self.scope == "direct": - return not is_group - if self.scope == "group-all": - return is_group - if self.scope == "group-mentions": - return is_group and was_mentioned - return False - - async def send_ack(self, chat_id: str, message_id: str) -> None: - if self._send_fn and message_id: - try: - await self._send_fn(chat_id, message_id, self.emoji) - if self.remove_after_reply: - self._pending[chat_id] = message_id - except Exception: - pass - - async def remove_ack(self, chat_id: str) -> None: - message_id = self._pending.pop(chat_id, None) - if message_id and self._remove_fn: - try: - await self._remove_fn(chat_id, message_id, self.emoji) - except Exception: - pass - - -# ── Mention Gating ─────────────────────────────────────────────────── - -class MentionGatingMiddleware(InboundMiddleware): - """Filter messages based on mention policy. - - Policy values: - - ``"always"``: require mention in all chats - - ``"group"``: require mention only in groups (default) - - ``"off"``: never require mention - """ - - def __init__( - self, - require_mention: str = "group", - strip_fn: Callable[[str], str] | None = None, - ) -> None: - self.require_mention = require_mention - self._strip_fn = strip_fn - - async def process_inbound( - self, raw: RawIncoming, context: dict[str, Any], - ) -> RawIncoming | None: - if not self._should_process(raw): - return None - # Strip mentions from group messages - if raw.is_group and self._strip_fn: - raw = dataclasses.replace(raw, text=self._strip_fn(raw.text)) - return raw - - def _should_process(self, raw: RawIncoming) -> bool: - 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 - - -# ── AllowList ──────────────────────────────────────────────────────── - -class AllowListMiddleware(InboundMiddleware): - """Sender and channel allow-list enforcement.""" - - def __init__( - self, - allowed_senders: set[str] | None = None, - allowed_channels: set[str] | None = None, - dm_policy: str = "allowlist", - ) -> None: - self.allowed_senders = allowed_senders - self.allowed_channels = allowed_channels - self.dm_policy = dm_policy - - async def process_inbound( - self, raw: RawIncoming, context: dict[str, Any], - ) -> RawIncoming | None: - # Channel allow-list - if self.allowed_channels and str(raw.chat_id) not in self.allowed_channels: - _logger.debug(f"Ignoring message from non-allowed channel {raw.chat_id}") - return None - - # Sender allow-list - if not raw.is_group and self.dm_policy == "open": - return raw # open DMs bypass sender checks - - if not self._is_sender_allowed(raw.sender_id): - _logger.debug(f"Ignoring message from non-allowed sender {raw.sender_id}") - return None - - return raw - - def _is_sender_allowed(self, sender: str) -> bool: - if not self.allowed_senders: - return True - sender_str = str(sender) - if sender_str in self.allowed_senders: - return True - if "|" in sender_str: - for part in sender_str.split("|"): - if part and part in self.allowed_senders: - return True - return False - - -# ── Group History ──────────────────────────────────────────────────── - -class GroupHistoryMiddleware(InboundMiddleware): - """Buffer non-mentioned group messages, inject as context when mentioned.""" - - def __init__( - self, - max_per_chat: int = 50, - max_age_seconds: int = 3600, - ) -> None: - self._buffer = GroupHistoryBuffer( - max_per_chat=max_per_chat, max_age_seconds=max_age_seconds, - ) - - async def process_inbound( - self, raw: RawIncoming, context: dict[str, Any], - ) -> RawIncoming | None: - if not raw.is_group: - return raw - - ts = ( - raw.timestamp.timestamp() - if hasattr(raw.timestamp, "timestamp") - else time.time() - ) - - if not raw.was_mentioned: - self._buffer.add( - raw.chat_id, - HistoryEntry( - sender_id=raw.sender_id, - text=raw.text, - timestamp=ts, - message_id=raw.message_id, - ), - ) - # Don't drop here — let MentionGatingMiddleware handle that - return raw - - # Mentioned: inject history context - history_context = self._buffer.format_context(raw.chat_id) - if history_context: - raw = dataclasses.replace( - raw, - text=history_context + "\n\n[Current message - respond to this]\n" + raw.text, - ) - self._buffer.clear(raw.chat_id) - return raw - - -# ── Pairing ────────────────────────────────────────────────────────── - -class PairingMiddleware(InboundMiddleware): - """DM pairing flow management. - - When dm_policy is "pairing", unapproved DM senders receive a - pairing code. Approved senders pass through normally. - """ - - def __init__( - 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], - ) -> RawIncoming | None: - if raw.is_group: - return raw # pairing only applies to DMs - - if self._dm_policy != "pairing": - return raw - - if self._manager.is_approved(self._channel_name, raw.sender_id): - return raw - - # Request pairing - code = self._manager.request_pairing(self._channel_name, raw.sender_id) - if self._send_response_fn: - text = f"\U0001f510 Pairing required. Your code: {code}\nThis code expires in 1 hour." - asyncio.ensure_future(self._send_response_fn(raw.chat_id, text)) - _logger.info(f"Pairing required for {raw.sender_id}, code sent") - return None diff --git a/EvoScientist/channels/mixins.py b/EvoScientist/channels/mixins.py deleted file mode 100644 index 559bffd..0000000 --- a/EvoScientist/channels/mixins.py +++ /dev/null @@ -1,306 +0,0 @@ -"""Reusable channel mixins for common architecture patterns. - -Three mixins that eliminate boilerplate across channels: - -- ``WebhookMixin`` — aiohttp webhook server + httpx client + token refresh -- ``WebSocketMixin`` — WS connect/reconnect/heartbeat loop -- ``PollingMixin`` — async poll loop with backoff - -Each mixin works with the Channel base class. Subclasses override -a small set of abstract/hook methods to define platform-specific behavior. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import time -from typing import Any - - -logger = logging.getLogger(__name__) - - -# ═════════════════════════════════════════════════════════════════════ -# Token refresh mixin (shared by Webhook & WebSocket channels) -# ═════════════════════════════════════════════════════════════════════ - -class TokenMixin: - """Mixin for channels that need OAuth-style token management. - - Subclass must implement ``_fetch_token()`` returning - ``(access_token, expires_in_seconds)``. - """ - - _access_token: str | None = None - _token_expires: float = 0 - _http_client: Any = None # httpx.AsyncClient - - async def _fetch_token(self) -> tuple[str, int]: - """Fetch a new access token. Return (token, expires_in_seconds). - - Must be implemented by the channel. - """ - raise NotImplementedError - - async def _refresh_token(self) -> None: - token, expire = await self._fetch_token() - self._access_token = token - self._token_expires = time.monotonic() + expire - 300 - logger.debug(f"{getattr(self, 'name', '?')} token refreshed, expires in {expire}s") - - async def _ensure_token(self) -> str: - if not self._access_token or time.monotonic() >= self._token_expires: - await self._refresh_token() - return self._access_token - - -# ═════════════════════════════════════════════════════════════════════ -# Webhook + REST mixin -# ═════════════════════════════════════════════════════════════════════ - -class WebhookMixin: - """Mixin for channels that use an HTTP webhook server for inbound - and REST API for outbound. - - Provides: - - aiohttp web server lifecycle (start/stop) - - httpx async client lifecycle - - Route registration via ``_webhook_routes()`` - - Subclass must implement: - - ``_webhook_routes()`` → list of (method, path, handler) - - ``_get_webhook_port()`` → int - """ - - _http_client: Any = None - _runner: Any = None - _site: Any = None - - def _get_webhook_port(self) -> int: - return getattr(self.config, "webhook_port", 9000) - - def _webhook_routes(self) -> list[tuple[str, str, Any]]: - """Return [(method, path, handler), ...]. Override in subclass.""" - return [] - - async def _start_webhook_server(self) -> None: - """Start aiohttp webhook server + httpx client. - - If ``_shared_webhook_server`` is set (by ChannelManager), the - aiohttp server is already running on the shared port — only - create the httpx outbound client. - """ - import httpx - - proxy = getattr(self.config, "proxy", None) or None - self._http_client = httpx.AsyncClient(timeout=15, proxy=proxy) - - # Shared webhook mode: routes already registered on shared server - if getattr(self, "_shared_webhook_server", None): - logger.info(f"{getattr(self, 'name', '?')} using shared webhook server") - return - - from aiohttp import web - - app = web.Application() - for method, path, handler in self._webhook_routes(): - if method.upper() == "GET": - app.router.add_get(path, handler) - else: - app.router.add_post(path, handler) - - self._runner = web.AppRunner(app) - await self._runner.setup() - port = self._get_webhook_port() - self._site = web.TCPSite(self._runner, "0.0.0.0", port) - await self._site.start() - logger.info(f"{getattr(self, 'name', '?')} webhook on port {port}") - - async def _stop_webhook_server(self) -> None: - if self._site: - await self._site.stop() - self._site = None - if self._runner: - await self._runner.cleanup() - self._runner = None - if self._http_client: - await self._http_client.aclose() - self._http_client = None - - async def _api_post(self, url: str, body: dict, headers: dict | None = None) -> dict: - """POST JSON to API, return parsed response. Raises on HTTP error.""" - resp = await self._http_client.post(url, json=body, headers=headers) - data = resp.json() - return data - - async def _api_get(self, url: str, headers: dict | None = None) -> dict: - resp = await self._http_client.get(url, headers=headers) - return resp.json() - - -# ═════════════════════════════════════════════════════════════════════ -# WebSocket mixin -# ═════════════════════════════════════════════════════════════════════ - -class WebSocketMixin: - """Mixin for channels that receive messages via WebSocket. - - Provides: - - Connect/reconnect loop with exponential backoff - - Heartbeat task management - - Message dispatch - - Subclass must implement: - - ``_get_ws_url()`` → WebSocket URL to connect to - - ``_on_ws_message(data)`` → handle a parsed message dict - - ``_on_ws_connected(ws)`` → called after connection (send identify, etc.) - - Optional overrides: - - ``_ws_heartbeat_interval`` → seconds between heartbeats (0 = disabled) - - ``_on_ws_heartbeat(ws)`` → send heartbeat - """ - - _ws_session: Any = None - _ws_heartbeat_task: asyncio.Task | None = None - _ws_heartbeat_interval: float = 0 # 0 = no heartbeat - _ws_reconnect_delay: float = 5.0 - - async def _get_ws_url(self) -> str: - raise NotImplementedError - - async def _on_ws_connected(self, ws) -> None: - """Called after WebSocket connects. Send identify/auth here.""" - pass - - async def _on_ws_message(self, data: dict | str) -> None: - """Handle a single WebSocket message.""" - raise NotImplementedError - - async def _on_ws_heartbeat(self, ws) -> None: - """Send a heartbeat. Override if needed.""" - pass - - async def _ws_loop(self) -> None: - """Main WebSocket loop with auto-reconnect.""" - import os - import aiohttp - - while getattr(self, "_running", False): - try: - ws_url = await self._get_ws_url() - # Resolve proxy: channel config > environment variable - proxy = getattr(getattr(self, "config", None), "proxy", None) - if not proxy: - proxy = (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.debug(f"{getattr(self, 'name', '?')} WS connecting to {ws_url[:60]}... proxy={proxy}") - async with aiohttp.ClientSession() as session: - async with session.ws_connect(ws_url, proxy=proxy, timeout=aiohttp.ClientWSTimeout(ws_close=30)) as ws: - logger.info(f"{getattr(self, 'name', '?')} WebSocket connected") - self._ws_session = ws - await self._on_ws_connected(ws) - - # Start heartbeat if configured - if self._ws_heartbeat_interval > 0: - self._ws_heartbeat_task = asyncio.create_task( - self._ws_heartbeat_loop(ws) - ) - - async for msg in ws: - if msg.type == aiohttp.WSMsgType.TEXT: - try: - data = json.loads(msg.data) - except (json.JSONDecodeError, TypeError): - data = msg.data - await self._on_ws_message(data) - elif msg.type in ( - aiohttp.WSMsgType.CLOSED, - aiohttp.WSMsgType.ERROR, - ): - break - - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"{getattr(self, 'name', '?')} WS error: {e}") - - self._ws_cleanup_heartbeat() - self._ws_session = None - - if getattr(self, "_running", False): - logger.info(f"{getattr(self, 'name', '?')} reconnecting in {self._ws_reconnect_delay}s...") - await asyncio.sleep(self._ws_reconnect_delay) - - async def _ws_heartbeat_loop(self, ws) -> None: - while True: - try: - await self._on_ws_heartbeat(ws) - except Exception: - break - await asyncio.sleep(self._ws_heartbeat_interval) - - def _ws_cleanup_heartbeat(self) -> None: - if self._ws_heartbeat_task: - self._ws_heartbeat_task.cancel() - self._ws_heartbeat_task = None - - async def _ws_send_json(self, data: dict) -> None: - """Send JSON to the active WebSocket.""" - if self._ws_session: - await self._ws_session.send_str(json.dumps(data)) - - async def _stop_ws(self) -> None: - self._ws_cleanup_heartbeat() - if self._ws_session: - await self._ws_session.close() - self._ws_session = None - - -# ═════════════════════════════════════════════════════════════════════ -# Polling mixin -# ═════════════════════════════════════════════════════════════════════ - -class PollingMixin: - """Mixin for channels that poll for new messages. - - Provides: - - Poll loop with configurable interval - - Error handling + reconnect - - Subclass must implement: - - ``_poll_once()`` → fetch and enqueue new messages - - ``_get_poll_interval()`` → seconds between polls - """ - - _poll_task: asyncio.Task | None = None - - def _get_poll_interval(self) -> float: - return getattr(self.config, "poll_interval", 30) - - async def _poll_once(self) -> None: - """Fetch new messages and enqueue them. Override in subclass.""" - raise NotImplementedError - - async def _start_polling(self) -> None: - self._poll_task = asyncio.create_task(self._poll_loop()) - - async def _poll_loop(self) -> None: - interval = self._get_poll_interval() - while getattr(self, "_running", False): - try: - await self._poll_once() - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"{getattr(self, 'name', '?')} poll error: {e}") - await asyncio.sleep(interval) - - async def _stop_polling(self) -> None: - if self._poll_task: - self._poll_task.cancel() - self._poll_task = None diff --git a/EvoScientist/channels/plugin.py b/EvoScientist/channels/plugin.py deleted file mode 100644 index e80685f..0000000 --- a/EvoScientist/channels/plugin.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Plugin-based channel interface. - -A ChannelPlugin is a declarative object with optional adapter slots. -The framework inspects which slots are filled and auto-assembles -the message processing pipeline. - -The ``Channel`` base class extends ``ChannelPlugin``, so all channel -implementations are automatically ChannelPlugin instances. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any, Protocol, runtime_checkable - -from .capabilities import ChannelCapabilities - - -# ── Channel metadata ───────────────────────────────────────────────── - -@dataclass -class ChannelMeta: - """Channel metadata for registry and UI.""" - - id: str - label: str - description: str = "" - docs_path: str = "" - system_image: str = "" # icon name - - -# ── Adapter Protocols (slots) ──────────────────────────────────────── - -@runtime_checkable -class ConfigAdapter(Protocol): - """Account configuration management.""" - - def list_account_ids(self, config: Any) -> list[str]: ... - def resolve_account(self, config: Any, account_id: str | None = None) -> Any: ... - def is_enabled(self, account: Any, config: Any) -> bool: ... - def is_configured(self, account: Any, config: Any) -> bool: ... - - -@runtime_checkable -class SecurityAdapter(Protocol): - """DM policy and security warnings.""" - - def resolve_dm_policy(self, ctx: Any) -> str: ... # "open" | "allowlist" | "pairing" - def collect_warnings(self, ctx: Any) -> list[str]: ... - - -@runtime_checkable -class GroupAdapter(Protocol): - """Per-group policy resolution.""" - - def resolve_require_mention(self, ctx: Any) -> bool | None: ... - def resolve_tool_policy(self, ctx: Any) -> dict[str, Any] | None: ... - def resolve_intro_hint(self, ctx: Any) -> str | None: ... - - -@runtime_checkable -class MentionAdapter(Protocol): - """Bot mention detection and stripping.""" - - def strip_mentions(self, text: str, ctx: Any) -> str: ... - - -@runtime_checkable -class OutboundAdapter(Protocol): - """Outbound message delivery.""" - - delivery_mode: str # "direct" | "gateway" | "hybrid" - - async def send_text(self, ctx: Any) -> bool: ... - async def send_media(self, ctx: Any) -> bool: ... - - -@runtime_checkable -class ThreadingAdapter(Protocol): - """Reply threading behavior.""" - - def resolve_reply_to_mode(self, ctx: Any) -> str: ... # "off" | "first" | "all" - - -@runtime_checkable -class StreamingAdapter(Protocol): - """Edit-in-place streaming output.""" - - async def edit_message(self, chat_id: str, message_id: str, text: str) -> bool: ... - - -@runtime_checkable -class DirectoryAdapter(Protocol): - """Contact/group directory queries.""" - - async def list_peers(self, ctx: Any) -> list[dict]: ... - async def list_groups(self, ctx: Any) -> list[dict]: ... - async def list_group_members(self, ctx: Any) -> list[dict]: ... - - -@runtime_checkable -class StatusAdapter(Protocol): - """Health probing and status reporting.""" - - async def probe_account(self, ctx: Any) -> Any: ... - async def audit_account(self, ctx: Any) -> Any: ... - def collect_status_issues(self, accounts: list) -> list[dict]: ... - - -@runtime_checkable -class HeartbeatAdapter(Protocol): - """Channel heartbeat / readiness checks.""" - - async def check_ready(self, ctx: Any) -> tuple[bool, str]: ... - - -@runtime_checkable -class ActionsAdapter(Protocol): - """Message actions (react, edit, delete, poll, etc.).""" - - def list_actions(self) -> list[str]: ... - async def handle_action(self, action: str, ctx: Any) -> Any: ... - - -@runtime_checkable -class PairingAdapter(Protocol): - """DM pairing flow.""" - - id_label: str - - def normalize_entry(self, entry: str) -> str: ... - async def notify_approval(self, ctx: Any) -> None: ... - - -@runtime_checkable -class OnboardingAdapter(Protocol): - """Interactive setup wizard hooks.""" - - async def wizard_steps(self, ctx: Any) -> list[dict]: ... - async def validate_step(self, step: str, value: Any) -> str | None: ... - - -# ── Reload policy ──────────────────────────────────────────────────── - -@dataclass -class ReloadPolicy: - """Declares which config prefixes trigger a channel reload.""" - - config_prefixes: list[str] = field(default_factory=list) - noop_prefixes: list[str] = field(default_factory=list) - - -# ── ChannelPlugin ──────────────────────────────────────────────────── - -class ChannelPlugin: - """Declarative channel plugin with optional adapter slots. - - Replaces the monolithic Channel base class. Each slot is optional — - the framework adapts behavior based on which are present. - - Usage:: - - class MyPlugin(ChannelPlugin): - id = "my_channel" - meta = ChannelMeta(id="my_channel", label="My Channel") - capabilities = ChannelCapabilities(...) - - def __init__(self): - self.outbound = MyOutboundAdapter() - self.config_adapter = MyConfigAdapter() - - async def start(self, config, account_id=None): - ... - - async def stop(self, account_id=None): - ... - """ - - id: str = "" - meta: ChannelMeta | None = None - capabilities: ChannelCapabilities = ChannelCapabilities() - - # Optional adapter slots — fill what you need - # Default: SingleAccountConfigAdapter so every plugin has multi-account - # support out of the box (returns a single "default" account). - config_adapter: ConfigAdapter | None = None - - def __init_subclass__(cls, **kwargs: Any) -> None: - super().__init_subclass__(**kwargs) - - def __init__(self) -> None: - # Provide default SingleAccountConfigAdapter if not overridden - if self.config_adapter is None: - from .config import SingleAccountConfigAdapter - self.config_adapter = SingleAccountConfigAdapter() - security: SecurityAdapter | None = None - groups: GroupAdapter | None = None - mentions: MentionAdapter | None = None - outbound: OutboundAdapter | None = None - threading: ThreadingAdapter | None = None - streaming: StreamingAdapter | None = None - directory: DirectoryAdapter | None = None - status: StatusAdapter | None = None - heartbeat: HeartbeatAdapter | None = None - actions: ActionsAdapter | None = None - pairing: PairingAdapter | None = None - onboarding: OnboardingAdapter | None = None - - # Lifecycle - reload: ReloadPolicy | None = None - - # Connection management - async def start(self, config: Any, account_id: str | None = None) -> None: - """Start the channel (or a specific account).""" - - async def stop(self, account_id: str | None = None) -> None: - """Stop the channel (or a specific account).""" - - def filled_slots(self) -> list[str]: - """Return names of adapter slots that are not None.""" - slot_names = [ - "config_adapter", "security", "groups", "mentions", "outbound", - "threading", "streaming", "directory", "status", "heartbeat", - "actions", "pairing", "onboarding", - ] - return [s for s in slot_names if getattr(self, s, None) is not None] diff --git a/EvoScientist/channels/qq/__init__.py b/EvoScientist/channels/qq/__init__.py deleted file mode 100644 index 57e0c7b..0000000 --- a/EvoScientist/channels/qq/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""QQ channel for EvoScientist. - -Uses the official qq-botpy SDK for WebSocket connection. - -Usage in config: - channel_enabled = "qq" - qq_app_id = "your_app_id" - qq_app_secret = "your_app_secret" -""" - -from .channel import QQChannel, QQConfig -from ..channel_manager import register_channel, _parse_csv - -__all__ = ["QQChannel", "QQConfig"] - - -def create_from_config(config) -> QQChannel: - allowed = _parse_csv(getattr(config, "qq_allowed_senders", "")) - return QQChannel(QQConfig( - app_id=getattr(config, "qq_app_id", ""), - app_secret=getattr(config, "qq_app_secret", ""), - allowed_senders=allowed, - )) - - -register_channel("qq", create_from_config) diff --git a/EvoScientist/channels/qq/channel.py b/EvoScientist/channels/qq/channel.py deleted file mode 100644 index 0f0e83a..0000000 --- a/EvoScientist/channels/qq/channel.py +++ /dev/null @@ -1,258 +0,0 @@ -"""QQ Bot channel — powered by botpy SDK. - -Uses the official qq-botpy SDK for WebSocket connection and message handling. -No manual WebSocket protocol implementation needed. -""" - -import asyncio -import logging -from collections import deque -from dataclasses import dataclass -from datetime import datetime - -from ..base import Channel, RawIncoming, ChannelError -from ..capabilities import QQ as QQ_CAPS -from ..config import BaseChannelConfig - -logger = logging.getLogger(__name__) - -try: - import botpy - from botpy.message import C2CMessage, GroupMessage - - QQ_AVAILABLE = True -except ImportError: - QQ_AVAILABLE = False - botpy = None - C2CMessage = None - GroupMessage = None - - -@dataclass -class QQConfig(BaseChannelConfig): - app_id: str = "" - app_secret: str = "" - text_chunk_limit: int = 4096 - - -def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": - """Create a botpy Client subclass bound to the given channel.""" - intents = botpy.Intents(public_messages=True, direct_message=True) - - class _Bot(botpy.Client): - def __init__(self): - super().__init__(intents=intents) - - async def on_ready(self): - logger.info(f"QQ bot ready: {self.robot.name}") - - async def on_c2c_message_create(self, message: "C2CMessage"): - await channel._on_msg(message, "c2c") - - async def on_group_at_message_create(self, message: "GroupMessage"): - await channel._on_msg(message, "group") - - return _Bot - - -class QQChannel(Channel): - capabilities = QQ_CAPS - name = "qq" - _ready_attrs = ("_client", "_running") - _mention_pattern = r"@\S+\s*" - _mention_strip_count = 1 - - def __init__(self, config: QQConfig): - super().__init__(config) - self._client: "botpy.Client | None" = None - self._bot_task: asyncio.Task | None = None - self._processed_ids: deque = deque(maxlen=1000) - self._msg_seq: dict[str, int] = {} # msg_id -> next seq number - self._msg_seq_order: deque = deque(maxlen=500) - - # ── Lifecycle ───────────────────────────────────────────────── - - async def start(self) -> None: - if not QQ_AVAILABLE: - raise ChannelError("QQ SDK not installed. Run: pip install qq-botpy") - if not self.config.app_id or not self.config.app_secret: - raise ChannelError("QQ app_id and app_secret are required") - self._running = True - BotClass = _make_bot_class(self) - self._client = BotClass() - self._bot_task = asyncio.create_task(self._run_bot()) - logger.info("QQ channel starting...") - - async def _run_bot(self) -> None: - try: - await self._client.start(appid=self.config.app_id, secret=self.config.app_secret) - except Exception as e: - logger.error(f"QQ auth failed: {e}") - self._running = False - - # ── Incoming ────────────────────────────────────────────────── - - async def _on_msg(self, message, msg_type: str) -> None: - try: - if message.id in self._processed_ids: - return - self._processed_ids.append(message.id) - - author = message.author - content = (message.content or "").strip() - - if msg_type == "c2c": - sender_id = str(getattr(author, "user_openid", "")) - chat_id = sender_id - else: - sender_id = str(getattr(author, "member_openid", "")) - chat_id = str(getattr(message, "group_openid", "")) - - # Handle attachments (images, files, audio, video) - annotations: list[str] = [] - media_paths: list[str] = [] - attachments = getattr(message, "attachments", None) or [] - for att in attachments: - url = getattr(att, "url", "") or "" - filename = getattr(att, "filename", "attachment") or "attachment" - content_type = getattr(att, "content_type", "") or "" - if url: - local, ann = await self._download_attachment( - url, f"qq_{filename}", - ) - if local: - media_paths.append(local) - if ann: - annotations.append(ann) - else: - annotations.append(f"[{content_type or 'attachment'}: {filename}]") - - if not content and not media_paths and not annotations: - return - - await self._enqueue_raw(RawIncoming( - sender_id=sender_id, - chat_id=chat_id, - text=content, - media_files=media_paths, - content_annotations=annotations, - timestamp=datetime.now(), - message_id=message.id, - is_group=(msg_type == "group"), - was_mentioned=True, - metadata={ - "chat_id": chat_id, - "msg_type": msg_type, - "event_id": message.id, - "backend": "qq", - }, - )) - except Exception as e: - logger.error(f"Error handling QQ message: {e}") - - # ── Send ────────────────────────────────────────────────────── - - def _next_msg_seq(self, msg_id: str) -> int: - """Return the next msg_seq for *msg_id* and increment the counter.""" - seq = self._msg_seq.get(msg_id, 1) - self._msg_seq[msg_id] = seq + 1 - if msg_id not in set(self._msg_seq_order): - self._msg_seq_order.append(msg_id) - if len(self._msg_seq_order) > 500: - oldest = self._msg_seq_order.popleft() - self._msg_seq.pop(oldest, None) - return seq - - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - if not self._client: - raise ChannelError("QQ client not initialized") - msg_type = (metadata or {}).get("msg_type", "c2c") - msg_id = (metadata or {}).get("event_id", "") - seq = self._next_msg_seq(msg_id) - if msg_type == "group": - await self._client.api.post_group_message( - group_openid=chat_id, msg_type=0, - content=raw_text, msg_id=msg_id, msg_seq=seq, - ) - else: - await self._client.api.post_c2c_message( - openid=chat_id, msg_type=0, - content=raw_text, msg_id=msg_id, msg_seq=seq, - ) - - # _send_typing_action: inherited no-op (QQ Bot API has no typing indicator) - - # ── Media send ──────────────────────────────────────────────── - - # qq-botpy file_type constants: 1=image, 2=video, 3=audio - _FILE_TYPE_MAP = { - ".jpg": 1, ".jpeg": 1, ".png": 1, ".gif": 1, ".webp": 1, ".bmp": 1, - ".mp4": 2, ".mov": 2, ".avi": 2, - ".mp3": 3, ".ogg": 3, ".m4a": 3, ".wav": 3, ".silk": 3, - } - - async def _send_media_impl( - self, - recipient: str, - file_path: str, - caption: str = "", - metadata: dict | None = None, - ) -> bool: - """Send a media file through QQ Bot API. - - Uses post_group_file / post_c2c_file with a URL. Local files - without a public URL are not supported — falls back to a text hint. - """ - if not self._client: - raise ChannelError("QQ client not initialized") - - from pathlib import Path - chat_id = self._resolve_media_chat_id(recipient, metadata) - msg_type = (metadata or {}).get("msg_type", "c2c") - ext = Path(file_path).suffix.lower() - file_type = self._FILE_TYPE_MAP.get(ext, 1) # default to image - - # qq-botpy file API requires a URL, not a local path - is_url = file_path.startswith("http://") or file_path.startswith("https://") - if not is_url: - # Fallback: send text hint for local files - name = Path(file_path).name - hint = f"[文件] {name}" + (f"\n{caption}" if caption else "") - await self._send_chunk(chat_id, hint, hint, None, metadata or {}) - return True - - try: - if msg_type == "group": - await self._client.api.post_group_file( - group_openid=chat_id, - file_type=file_type, - url=file_path, - srv_send_msg=True, - ) - else: - await self._client.api.post_c2c_file( - openid=chat_id, - file_type=file_type, - url=file_path, - srv_send_msg=True, - ) - except Exception as e: - logger.warning(f"QQ media send failed: {e}") - return False - - if caption: - await self._send_chunk(chat_id, caption, caption, None, metadata or {}) - return True - - # ── Cleanup ─────────────────────────────────────────────────── - - async def _cleanup(self) -> None: - self._running = False - if self._bot_task: - self._bot_task.cancel() - try: - await self._bot_task - except asyncio.CancelledError: - pass - self._client = None - logger.info("QQ channel stopped") diff --git a/EvoScientist/channels/qq/probe.py b/EvoScientist/channels/qq/probe.py deleted file mode 100644 index b1fb555..0000000 --- a/EvoScientist/channels/qq/probe.py +++ /dev/null @@ -1,33 +0,0 @@ -"""QQ Bot credential validation.""" - -import logging - -logger = logging.getLogger(__name__) - -QQ_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken" - - -async def validate_qq( - app_id: str, - app_secret: str, -) -> tuple[bool, str]: - """Validate QQ Bot credentials by fetching an access token.""" - if not app_id or not app_secret: - return False, "app_id and app_secret are required" - - try: - import httpx - except ImportError: - return False, "httpx not installed" - - body = {"appId": app_id, "clientSecret": app_secret} - - try: - async with httpx.AsyncClient() as client: - resp = await client.post(QQ_TOKEN_URL, json=body, timeout=10) - data = resp.json() - if data.get("access_token"): - return True, "QQ Bot credentials valid" - return False, f"Error: {data.get('message', data)}" - except Exception as e: - return False, f"Error: {e}" diff --git a/EvoScientist/channels/qq/serve.py b/EvoScientist/channels/qq/serve.py deleted file mode 100644 index 4e34552..0000000 --- a/EvoScientist/channels/qq/serve.py +++ /dev/null @@ -1,87 +0,0 @@ -"""QQ channel server. - -Standalone script to run the QQ channel with CLI options. - -Usage: - python -m EvoScientist.channels.qq.serve --app-id ID --app-secret SECRET [OPTIONS] - -Examples: - # Basic usage - python -m EvoScientist.channels.qq.serve --app-id ID --app-secret SECRET - - # Sandbox mode with allowed senders - python -m EvoScientist.channels.qq.serve --app-id ID --app-secret SECRET --allow user123 - - # With agent and thinking - python -m EvoScientist.channels.qq.serve --app-id ID --app-secret SECRET --agent --thinking -""" - -import argparse -import logging - -from .channel import QQChannel, QQConfig -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="QQ channel server", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--app-id", - required=True, - help="QQ bot app ID", - ) - parser.add_argument( - "--app-secret", - required=True, - help="QQ bot app secret", - ) - parser.add_argument( - "--allow", - action="append", - dest="allowed_senders", - help="Allowed sender (QQ user 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 = QQConfig( - app_id=args.app_id, - app_secret=args.app_secret, - allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, - ) - - send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = QQChannel(config) - - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) - - -if __name__ == "__main__": - main() diff --git a/EvoScientist/channels/retry.py b/EvoScientist/channels/retry.py deleted file mode 100644 index b98d7a5..0000000 --- a/EvoScientist/channels/retry.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Configurable exponential-backoff retry for async callables.""" - -from __future__ import annotations - -import asyncio -import random -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import TypeVar - -T = TypeVar("T") - - -@dataclass -class RetryConfig: - """Configuration for retry behaviour.""" - - attempts: int = 3 - min_delay_s: float = 0.3 - max_delay_s: float = 30.0 - jitter: float = 0.1 # ±10 % random offset - - -@dataclass -class RetryInfo: - """Information passed to the *on_retry* callback.""" - - attempt: int - max_attempts: int - delay_s: float - error: Exception - label: str | None = None - - -async def retry_async( - fn: Callable[[], Awaitable[T]], - config: RetryConfig = RetryConfig(), - *, - should_retry: Callable[[Exception, int], bool] | None = None, - retry_after_s: Callable[[Exception], float | None] | None = None, - on_retry: Callable[[RetryInfo], None] | None = None, - label: str | None = None, -) -> T: - """Execute *fn* with exponential-backoff retry. - - Parameters - ---------- - fn: - Zero-argument async factory — called on every attempt so the - awaitable is always fresh. - config: - Retry timing / attempt parameters. - should_retry: - ``(exception, attempt) -> bool``. Return ``False`` to abort - immediately. When *None* every exception is retried. - retry_after_s: - ``(exception) -> seconds | None``. If the server provides a - ``Retry-After`` value (e.g. HTTP 429), return it here. The - actual delay will be ``max(server_value, min_delay_s)``. - on_retry: - Optional callback invoked before each retry sleep. - label: - Human-readable label included in :class:`RetryInfo`. - """ - last_exc: Exception | None = None - for attempt in range(1, config.attempts + 1): - try: - return await fn() - except Exception as exc: - last_exc = exc - - if attempt >= config.attempts: - raise - - if should_retry is not None and not should_retry(exc, attempt): - raise - - # Compute delay - server_delay: float | None = None - if retry_after_s is not None: - server_delay = retry_after_s(exc) - - if server_delay is not None: - base_delay = max(server_delay, config.min_delay_s) - else: - base_delay = config.min_delay_s * (2 ** (attempt - 1)) - - # Apply jitter - jittered = base_delay * (1 + random.uniform(-config.jitter, config.jitter)) - - # Clamp to [min_delay_s, max_delay_s] - delay = max(config.min_delay_s, min(jittered, config.max_delay_s)) - - if on_retry is not None: - on_retry(RetryInfo( - attempt=attempt, - max_attempts=config.attempts, - delay_s=delay, - error=exc, - label=label, - )) - - await asyncio.sleep(delay) - - # Should never reach here, but satisfy the type checker. - assert last_exc is not None # noqa: S101 - raise last_exc - - -# ── Presets ────────────────────────────────────────────────────────── - -TELEGRAM_RETRY = RetryConfig(attempts=3, min_delay_s=0.4, max_delay_s=30.0, jitter=0.1) -DEFAULT_RETRY = RetryConfig() - -# Discord, Slack, Teams, Feishu all use the same config (attempts=3, -# min_delay_s=0.5, max_delay_s=30.0, jitter=0.1) — close enough to -# DEFAULT_RETRY that separate presets add no value. Channels that -# don't appear in RETRY_PRESETS already fall back to DEFAULT_RETRY. - -RETRY_PRESETS: dict[str, RetryConfig] = { - "telegram": TELEGRAM_RETRY, -} diff --git a/EvoScientist/channels/signal/__init__.py b/EvoScientist/channels/signal/__init__.py deleted file mode 100644 index 2abc039..0000000 --- a/EvoScientist/channels/signal/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Signal channel for EvoScientist. - -Uses signal-cli in JSON RPC mode — no public IP needed. - -Usage in config: - channel_enabled = "signal" - signal_phone_number = "+1234567890" -""" - -from .channel import SignalChannel, SignalConfig -from ..channel_manager import register_channel, _parse_csv - -__all__ = ["SignalChannel", "SignalConfig"] - - -def create_from_config(config) -> SignalChannel: - allowed = _parse_csv(getattr(config, "signal_allowed_senders", "")) - return SignalChannel(SignalConfig( - phone_number=getattr(config, "signal_phone_number", ""), - cli_path=getattr(config, "signal_cli_path", "signal-cli"), - config_dir=getattr(config, "signal_config_dir", "") or None, - rpc_port=int(getattr(config, "signal_rpc_port", 7583)), - allowed_senders=allowed, - )) - - -register_channel("signal", create_from_config) diff --git a/EvoScientist/channels/signal/channel.py b/EvoScientist/channels/signal/channel.py deleted file mode 100644 index 2a577f1..0000000 --- a/EvoScientist/channels/signal/channel.py +++ /dev/null @@ -1,442 +0,0 @@ -"""Signal channel implementation via signal-cli JSON RPC. - -Pure Python — communicates with signal-cli daemon over TCP socket. - -Architecture: -1. signal-cli must be running in JSON RPC mode: - signal-cli -u +NUMBER daemon --socket localhost:7583 -2. We connect via TCP, send JSON RPC requests, receive events -3. Inbound: listen for "receive" method notifications -4. Outbound: call "send" method via JSON RPC -""" - -import asyncio -import json -import logging -import re -import subprocess -from collections import deque -from dataclasses import dataclass -from datetime import datetime -from typing import Any - -from ..base import Channel, RawIncoming, ChannelError -from ..capabilities import SIGNAL as SIGNAL_CAPS -from ..config import BaseChannelConfig - -logger = logging.getLogger(__name__) - - -@dataclass -class SignalConfig(BaseChannelConfig): - """Configuration for Signal channel.""" - phone_number: str = "" - cli_path: str = "signal-cli" - config_dir: str | None = None - rpc_port: int = 7583 - text_chunk_limit: int = 4096 - - -class SignalChannel(Channel): - capabilities = SIGNAL_CAPS - """Signal channel using signal-cli JSON RPC. - - No public IP needed — local TCP socket connection. - Requires signal-cli to be installed and registered. - """ - - name = "signal" - _non_retryable_patterns = ("unregistered", "auth") - - def __init__(self, config: SignalConfig): - super().__init__(config) - self._reader: asyncio.StreamReader | None = None - self._writer: asyncio.StreamWriter | None = None - self._rpc_id = 0 - self._daemon_proc = None - # Cache message_id → sender for reaction targetAuthor (bounded) - self._msg_senders: dict[str, str] = {} - self._msg_senders_order: deque = deque(maxlen=200) - - async def start(self) -> None: - if not self.config.phone_number: - raise ChannelError("Signal phone_number is required") - - # Try to start signal-cli daemon if not already running - await self._ensure_daemon() - - # Connect to JSON RPC socket - await self._connect() - - self._running = True - logger.info(f"Signal channel started (phone: {self.config.phone_number})") - - # Listen for incoming messages in background task - # (start() must return so that run() can iterate receive()) - self._listen_task = asyncio.create_task(self._listen_loop()) - - async def _ensure_daemon(self) -> None: - """Start signal-cli daemon if not already running.""" - try: - reader, writer = await asyncio.wait_for( - asyncio.open_connection("localhost", self.config.rpc_port), - timeout=2, - ) - writer.close() - await writer.wait_closed() - logger.info("signal-cli daemon already running") - return - except (ConnectionRefusedError, asyncio.TimeoutError, OSError): - pass - - # Start daemon - cmd = [self.config.cli_path, "-u", self.config.phone_number] - if self.config.config_dir: - cmd.extend(["--config", self.config.config_dir]) - cmd.extend(["daemon", "--tcp", - f"localhost:{self.config.rpc_port}", "--no-receive-stdout"]) - - logger.info(f"Starting signal-cli daemon: {' '.join(cmd)}") - try: - self._daemon_proc = subprocess.Popen( - cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - except FileNotFoundError: - raise ChannelError( - f"signal-cli not found at '{self.config.cli_path}'. " - "Install: https://github.com/AsamK/signal-cli" - ) - - # Wait for daemon to be ready - for _ in range(30): - await asyncio.sleep(1) - try: - reader, writer = await asyncio.open_connection( - "localhost", self.config.rpc_port, - ) - writer.close() - await writer.wait_closed() - logger.info("signal-cli daemon started") - return - except (ConnectionRefusedError, OSError): - continue - - raise ChannelError("signal-cli daemon failed to start within 30s") - - async def _connect(self) -> None: - """Connect to signal-cli JSON RPC socket.""" - try: - self._reader, self._writer = await asyncio.open_connection( - "localhost", self.config.rpc_port, - ) - except Exception as e: - raise ChannelError(f"Cannot connect to signal-cli: {e}") - - async def _listen_loop(self) -> None: - """Listen for incoming JSON RPC notifications.""" - while self._running and self._reader: - try: - line = await self._reader.readline() - if not line: - break - data = json.loads(line.decode()) - await self._handle_rpc(data) - except asyncio.CancelledError: - break - except json.JSONDecodeError: - continue - except Exception as e: - logger.error(f"Signal listen error: {e}") - # Reconnect - if self._running: - await asyncio.sleep(2) - try: - await self._connect() - except Exception: - pass - - async def _handle_rpc(self, data: dict) -> None: - """Handle a JSON RPC message from signal-cli.""" - method = data.get("method", "") - - if method != "receive": - return - - params = data.get("params", {}) - envelope = params.get("envelope", {}) - source = envelope.get("source") or envelope.get("sourceUuid") or "" - source_number = envelope.get("sourceNumber") or source - source_name = envelope.get("sourceName") or "" - timestamp = envelope.get("timestamp", 0) - - # Ignore messages from self - if source_number == self.config.phone_number or source == self.config.phone_number: - logger.debug("Ignoring message from self") - return - - # Data message (text) - data_msg = envelope.get("dataMessage", {}) - if data_msg: - text = data_msg.get("message", "") - group_info = data_msg.get("groupInfo", {}) - is_group = bool(group_info) - chat_id = group_info.get("groupId", source_number) if is_group else source_number - msg_ts = data_msg.get("timestamp", timestamp) - - media_paths: list[str] = [] - annotations: list[str] = [] - _VOICE_TYPES = {"audio/aac", "audio/ogg", "audio/mp4", "audio/mpeg", "audio/opus"} - attachments = data_msg.get("attachments", []) - for att in attachments: - att_size = att.get("size", 0) - att_name = att.get("filename", "attachment") - att_file = att.get("file") # signal-cli provides local path - content_type = att.get("contentType", "") - is_voice = content_type in _VOICE_TYPES or att.get("voiceNote", False) - media_label = "voice" if is_voice else "attachment" - if att_file: - from pathlib import Path as _Path - att_path = _Path(att_file) - if att_path.exists(): - from ..base import MAX_ATTACHMENT_BYTES - if att_path.stat().st_size > MAX_ATTACHMENT_BYTES: - annotations.append(f"[{media_label}: {att_name} - too large ({att_path.stat().st_size} bytes)]") - else: - local = self._media_path(f"signal_{att_name}") - import shutil - shutil.copy2(str(att_path), str(local)) - media_paths.append(str(local)) - annotations.append(f"[{media_label}: {local}]") - else: - annotations.append(f"[{media_label}: {att_name} - file not found]") - elif att_size: - too_large = self._check_attachment_size(att_size, att_name) - if too_large: - annotations.append(too_large) - else: - annotations.append(f"[{media_label}: {att_name}]") - - if not text and not media_paths and not annotations: - if not attachments: - return - # Had attachments but none downloaded successfully - if not annotations: - text = "[attachment]" - - try: - ts = datetime.fromtimestamp(msg_ts / 1000) if msg_ts else datetime.now() - except (ValueError, TypeError, OSError): - ts = datetime.now() - - was_mentioned = not is_group # DMs always pass - if is_group: - mentions = data_msg.get("mentions", []) - for m in mentions: - if m.get("uuid") == self.config.phone_number or m.get("number") == self.config.phone_number: - was_mentioned = True - break - - # Cache message_id → sender for reaction targetAuthor - self._cache_msg_sender(str(msg_ts), source_number) - - logger.info("Signal message from %s: %s", source_number, text[:50] if text else "[media]") - await self._enqueue_raw(RawIncoming( - sender_id=source_number, - chat_id=chat_id, - text=text, - content_annotations=annotations, - media_files=media_paths, - timestamp=ts, - message_id=str(msg_ts), - is_group=is_group, - was_mentioned=was_mentioned, - metadata={ - "chat_id": chat_id, - "source_name": source_name, - "sender_id": source_number, - "backend": "signal", - }, - )) - - # ── Typing indicator ──────────────────────────────────────────── - - async def _send_typing_action(self, chat_id: str) -> None: - """Send typing indicator via signal-cli JSON RPC.""" - params: dict[str, Any] = { - "account": self.config.phone_number, - } - if self._is_group_id(chat_id): - params["groupId"] = chat_id - else: - params["recipient"] = [chat_id] - try: - await self._rpc_call("sendTyping", params) - except Exception: - pass # typing indicator is best-effort - - # ── ACK reaction ───────────────────────────────────────────── - - def _cache_msg_sender(self, message_id: str, sender: str) -> None: - """Store message_id → sender mapping for reaction targetAuthor.""" - if len(self._msg_senders) >= 200: - oldest = self._msg_senders_order.popleft() - self._msg_senders.pop(oldest, None) - self._msg_senders[message_id] = sender - self._msg_senders_order.append(message_id) - - async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - """Send an acknowledgment reaction via signal-cli sendReaction.""" - target_author = self._msg_senders.get(message_id, "") - if not target_author: - return # cannot send reaction without knowing the original sender - try: - params: dict[str, Any] = { - "account": self.config.phone_number, - "emoji": emoji, - "targetAuthor": target_author, - "targetTimestamp": int(message_id), - } - if self._is_group_id(chat_id): - params["groupId"] = chat_id - else: - params["recipient"] = [chat_id] - await self._rpc_call("sendReaction", params) - except Exception as e: - logger.debug(f"Signal ack reaction failed: {e}") - - async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - """Remove ACK reaction via signal-cli sendReaction --remove.""" - target_author = self._msg_senders.get(message_id, "") - if not target_author: - return - try: - params: dict[str, Any] = { - "account": self.config.phone_number, - "emoji": emoji, - "targetAuthor": target_author, - "targetTimestamp": int(message_id), - "remove": True, - } - if self._is_group_id(chat_id): - params["groupId"] = chat_id - else: - params["recipient"] = [chat_id] - await self._rpc_call("sendReaction", params) - except Exception as e: - logger.debug(f"Signal remove ACK reaction failed: {e}") - - # ── Send ────────────────────────────────────────────────────── - - @staticmethod - def _is_group_id(chat_id: str) -> bool: - """Return True if *chat_id* looks like a Signal group ID. - - Group IDs are base64-encoded strings (e.g. ``"aB3d...=="``). - Individual recipients are either phone numbers (``"+1234..."``) - or UUIDs (``"817ab5e9-..."``) — neither of which is a group. - """ - return not chat_id.startswith("+") and "-" not in chat_id - - def _is_ready(self) -> bool: - return self._writer is not None and not self._writer.is_closing() - - async def _rpc_call(self, method: str, params: dict) -> dict | None: - """Send a JSON RPC call to signal-cli.""" - if not self._writer: - return None - - self._rpc_id += 1 - request = { - "jsonrpc": "2.0", - "id": self._rpc_id, - "method": method, - "params": params, - } - line = json.dumps(request) + "\n" - self._writer.write(line.encode()) - await self._writer.drain() - return None # We don't wait for response in this simple impl - - async def _send_chunk( - self, chat_id, formatted_text, raw_text, reply_to, metadata, - ): - # Determine if group or individual - params: dict[str, Any] = { - "message": raw_text, - "account": self.config.phone_number, - } - - if self._is_group_id(chat_id): - params["groupId"] = chat_id - else: - params["recipient"] = [chat_id] - - await self._rpc_call("send", params) - - # ── Formatting ──────────────────────────────────────────────── - - - # ── Mention stripping ──────────────────────────────────────────── - - def _strip_mention(self, text: str) -> str: - """Strip bot mention from Signal messages. - - Signal mentions are embedded as special objects that reference - the phone number. The text contains a placeholder character (U+FFFC) - at the mention position. - """ - phone = self.config.phone_number - if phone: - # Remove phone number if directly mentioned as text - text = re.sub(rf"@?{re.escape(phone)}\s*", "", text).strip() - # Remove Unicode Object Replacement Character used as mention placeholder - text = text.replace("\uFFFC", "").strip() - return text - - # ── Media send ──────────────────────────────────────────────── - - async def _send_media_impl( - self, - recipient: str, - file_path: str, - caption: str = "", - metadata: dict | None = None, - ) -> bool: - """Send a media file via signal-cli JSON RPC. - - Uses the "send" RPC method with the attachments parameter. - """ - chat_id = self._resolve_media_chat_id(recipient, metadata) - params: dict[str, Any] = { - "account": self.config.phone_number, - "attachments": [file_path], - } - if caption: - params["message"] = caption - - if self._is_group_id(chat_id): - params["groupId"] = chat_id - else: - params["recipient"] = [chat_id] - - await self._rpc_call("send", params) - return True - - # ── Cleanup ─────────────────────────────────────────────────── - - async def _cleanup(self) -> None: - if hasattr(self, "_listen_task") and self._listen_task: - self._listen_task.cancel() - self._listen_task = None - if self._writer: - self._writer.close() - try: - await self._writer.wait_closed() - except Exception: - pass - self._writer = None - self._reader = None - if self._daemon_proc: - self._daemon_proc.terminate() - self._daemon_proc = None - logger.info("Signal channel stopped") diff --git a/EvoScientist/channels/signal/probe.py b/EvoScientist/channels/signal/probe.py deleted file mode 100644 index 8220008..0000000 --- a/EvoScientist/channels/signal/probe.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Signal credential validation.""" -import logging -logger = logging.getLogger(__name__) - - -async def validate_signal( - phone_number: str, - cli_path: str = "signal-cli", - rpc_port: int = 7583, -) -> tuple[bool, str]: - """Validate Signal setup by checking signal-cli availability.""" - import asyncio - import subprocess - - if not phone_number: - return False, "phone_number is required" - - # Check signal-cli binary - loop = asyncio.get_event_loop() - def _check(): - try: - result = subprocess.run( - [cli_path, "--version"], capture_output=True, text=True, timeout=5, - ) - if result.returncode == 0: - return True, f"signal-cli {result.stdout.strip()}" - return False, "signal-cli returned error" - except FileNotFoundError: - return False, f"signal-cli not found at '{cli_path}'" - except Exception as e: - return False, f"Error: {e}" - - return await loop.run_in_executor(None, _check) diff --git a/EvoScientist/channels/signal/serve.py b/EvoScientist/channels/signal/serve.py deleted file mode 100644 index 1d04964..0000000 --- a/EvoScientist/channels/signal/serve.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Signal channel server. - -Standalone script to run the Signal channel with CLI options. - -Usage: - python -m EvoScientist.channels.signal.serve --phone-number NUMBER [OPTIONS] - -Examples: - # Basic usage - python -m EvoScientist.channels.signal.serve --phone-number +1234567890 - - # With custom signal-cli path and allowed senders - python -m EvoScientist.channels.signal.serve --phone-number +1234567890 --cli-path /usr/local/bin/signal-cli --allow +9876543210 - - # With agent and thinking - python -m EvoScientist.channels.signal.serve --phone-number +1234567890 --agent --thinking -""" - -import argparse -import logging - -from .channel import SignalChannel, SignalConfig -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="Signal channel server", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--phone-number", - required=True, - help="Signal phone number (e.g. +1234567890)", - ) - parser.add_argument( - "--cli-path", - default="signal-cli", - help="Path to signal-cli binary (default: signal-cli)", - ) - parser.add_argument( - "--config-dir", - help="signal-cli config directory", - ) - parser.add_argument( - "--rpc-port", - type=int, - default=7583, - help="signal-cli JSON RPC port (default: 7583)", - ) - parser.add_argument( - "--allow", - action="append", - dest="allowed_senders", - help="Allowed sender (phone number). 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 = SignalConfig( - phone_number=args.phone_number, - cli_path=args.cli_path, - config_dir=args.config_dir, - rpc_port=args.rpc_port, - allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, - ) - - send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = SignalChannel(config) - - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) - - -if __name__ == "__main__": - main() diff --git a/EvoScientist/channels/slack/__init__.py b/EvoScientist/channels/slack/__init__.py deleted file mode 100644 index d526cc4..0000000 --- a/EvoScientist/channels/slack/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from .channel import SlackChannel, SlackConfig -from ..channel_manager import register_channel, _parse_csv - -__all__ = ["SlackChannel", "SlackConfig"] - - -def create_from_config(config) -> SlackChannel: - allowed = _parse_csv(config.slack_allowed_senders) - channels = _parse_csv(config.slack_allowed_channels) - return SlackChannel(SlackConfig( - bot_token=config.slack_bot_token, - app_token=config.slack_app_token, - allowed_senders=allowed, - allowed_channels=channels, - proxy=getattr(config, 'slack_proxy', '') or None, - )) - - -register_channel("slack", create_from_config) diff --git a/EvoScientist/channels/slack/channel.py b/EvoScientist/channels/slack/channel.py deleted file mode 100644 index 21237f7..0000000 --- a/EvoScientist/channels/slack/channel.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Slack channel implementation using slack-sdk Socket Mode.""" - -import asyncio -import logging -from dataclasses import dataclass -from datetime import datetime - -from ..base import Channel, RawIncoming, ChannelError -from ..capabilities import SLACK as SLACK_CAPS -from ..config import BaseChannelConfig - -logger = logging.getLogger(__name__) - - -@dataclass -class SlackConfig(BaseChannelConfig): - bot_token: str = "" - app_token: str = "" - text_chunk_limit: int = 4096 - - -class SlackChannel(Channel): - """Slack channel using slack-sdk Socket Mode.""" - - name = "slack" - - capabilities = SLACK_CAPS - _ready_attrs = ("_web_client",) - _mention_pattern = r"<@{bot_id}>\s*" - - def __init__(self, config: SlackConfig): - super().__init__(config) - self._socket_client = None - self._web_client = None - self._typing_message_ts: dict[str, str] = {} - - async def start(self) -> None: - try: - from slack_sdk.web.async_client import AsyncWebClient - from slack_sdk.socket_mode.aiohttp import SocketModeClient - from slack_sdk.socket_mode.request import SocketModeRequest - from slack_sdk.socket_mode.response import SocketModeResponse - except ImportError: - raise ChannelError( - "slack-sdk or aiohttp not installed. " - "Install with: pip install evoscientist[slack]" - ) - - if not self.config.bot_token: - raise ChannelError("Slack bot token is required") - if not self.config.app_token: - raise ChannelError( - "Slack app token is required for Socket Mode " - "(starts with xapp-)" - ) - - self._web_client = AsyncWebClient( - token=self.config.bot_token, - proxy=self._get_proxy(), - ) - - # Get bot user ID for filtering own messages - try: - auth = await asyncio.wait_for( - self._web_client.auth_test(), timeout=15, - ) - self._bot_user_id = auth["user_id"] - except asyncio.TimeoutError: - raise ChannelError( - "Slack auth_test timed out — check network and bot token" - ) - except Exception as e: - raise ChannelError(f"Failed to authenticate Slack bot: {e}") - - self._socket_client = SocketModeClient( - app_token=self.config.app_token, - web_client=self._web_client, - ) - - async def _event_handler( - client: SocketModeClient, - req: SocketModeRequest, - ) -> None: - # Acknowledge immediately - resp = SocketModeResponse(envelope_id=req.envelope_id) - await client.send_socket_mode_response(resp) - - logger.debug(f"Slack socket event: type={req.type}") - - if req.type == "events_api": - event = req.payload.get("event", {}) - event_type = event.get("type", "") - if event_type == "message" and "subtype" not in event: - is_dm = event.get("channel_type") == "im" - await self._on_message( - event, is_group=not is_dm, was_mentioned=is_dm, - ) - elif event_type == "app_mention": - await self._on_message( - event, is_group=True, was_mentioned=True, - ) - - self._socket_client.socket_mode_request_listeners.append( - _event_handler - ) - try: - await asyncio.wait_for( - self._socket_client.connect(), timeout=30, - ) - except asyncio.TimeoutError: - raise ChannelError( - "Slack Socket Mode connection timed out — " - "check app token (must start with xapp-) and " - "ensure Socket Mode is enabled in your Slack app settings" - ) - self._running = True - logger.info("Slack channel started (Socket Mode)") - - async def _cleanup(self) -> None: - if self._socket_client: - await self._socket_client.close() - logger.info("Slack channel stopped") - - # ── Typing indicator (override base) ──────────────────────────── - - async def _send_typing_action(self, chat_id: str) -> None: - """Send typing indicator via Slack. - - Slack's Web API and Socket Mode do not expose a dedicated - typing-indicator endpoint for bot tokens. We approximate - the experience by posting a short-lived status message that - is deleted once the real reply is sent (handled by - ``stop_typing``). When the status post fails we silently - fall back to no indicator. - """ - if not self._web_client: - return - try: - resp = await self._web_client.chat_postMessage( - channel=chat_id, - text="\u2026", # "…" ellipsis as minimal typing hint - ) - ts = resp.get("ts") - if ts: - self._typing_message_ts[chat_id] = ts - except Exception: - pass - - async def stop_typing(self, chat_id: str) -> None: - """Cancel typing loop and clean up the status message.""" - # Delete the ephemeral "…" message if we posted one - ts = self._typing_message_ts.pop(chat_id, None) - if ts and self._web_client: - try: - await self._web_client.chat_delete(channel=chat_id, ts=ts) - except Exception: - pass - await super().stop_typing(chat_id) - - # ── Send (template method overrides) ────────────────────────── - - - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - kwargs = dict(channel=chat_id) - # Always route to thread if thread_ts is present in metadata, - # not just for the first chunk (reply_to is only set for chunk 0). - if metadata: - thread_ts = metadata.get("thread_ts") - if thread_ts: - kwargs["thread_ts"] = thread_ts - - async def _send(text): - await self._web_client.chat_postMessage(text=text, **kwargs) - - 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: - """Send a media file through Slack.""" - channel_id = self._resolve_media_chat_id(recipient, metadata) - await self._web_client.files_upload_v2( - channel=channel_id, - file=file_path, - initial_comment=caption or None, - ) - return True - - def _get_bot_identifier(self) -> str | None: - return getattr(self, "_bot_user_id", None) - - # ── ACK Reactions ─────────────────────────────────────────────── - - async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "eyes") -> None: - """Add an emoji reaction to acknowledge receipt.""" - if self._web_client and message_id: - try: - await self._web_client.reactions_add( - channel=chat_id, timestamp=message_id, name=emoji, - ) - except Exception as e: - logger.debug(f"Slack ACK reaction failed: {e}") - - async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "eyes") -> None: - """Remove the ACK reaction after replying.""" - if self._web_client and message_id: - try: - await self._web_client.reactions_remove( - channel=chat_id, timestamp=message_id, name=emoji, - ) - except Exception as e: - logger.debug(f"Slack remove ACK reaction failed: {e}") - - async def _on_message( - self, - event: dict, - *, - is_group: bool = False, - was_mentioned: bool = True, - ) -> None: - """Handle an incoming Slack message event.""" - user_id = event.get("user", "") - - # Skip bot's own messages - if user_id == getattr(self, "_bot_user_id", None): - logger.debug("Skipping own bot message") - return - - # Skip bot messages (e.g. from other bots) - if event.get("bot_id"): - logger.debug(f"Skipping bot message from bot_id={event.get('bot_id')}") - return - - channel_id = event.get("channel", "") - - text = event.get("text", "") - - annotations: list[str] = [] - media_paths: list[str] = [] - - # Handle file attachments - if self.config.include_attachments: - files = event.get("files", []) - for file_info in files: - file_size = file_info.get("size", 0) - filename = file_info.get("name", "unknown") - - url = file_info.get("url_private_download") or file_info.get( - "url_private" - ) - if url and self._web_client: - headers = { - "Authorization": f"Bearer {self.config.bot_token}" - } - local_path, annotation = await self._download_attachment( - url, f"{file_info.get('id', 'unknown')}_{filename}", - headers=headers, - file_size=file_size, - ) - if local_path: - media_paths.append(local_path) - if annotation: - annotations.append(annotation) - - ts = event.get("ts", "") - thread_ts = event.get("thread_ts") or ts - try: - timestamp = datetime.fromtimestamp(float(ts)) if ts else datetime.now() - except (ValueError, TypeError): - timestamp = datetime.now() - - await self._enqueue_raw(RawIncoming( - sender_id=user_id, - chat_id=channel_id, - text=text, - media_files=media_paths, - content_annotations=annotations, - timestamp=timestamp, - message_id=ts, - metadata={"chat_id": channel_id, "thread_ts": thread_ts}, - is_group=is_group, - was_mentioned=was_mentioned, - )) - logger.info( - f"Slack message queued: sender={user_id}, " - f"channel={channel_id}, content={text[:50]}" - ) diff --git a/EvoScientist/channels/slack/probe.py b/EvoScientist/channels/slack/probe.py deleted file mode 100644 index 002e1b1..0000000 --- a/EvoScientist/channels/slack/probe.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Slack bot token validation.""" - -import logging - -logger = logging.getLogger(__name__) - - -async def validate_slack_tokens( - bot_token: str, - app_token: str | None = None, -) -> tuple[bool, str]: - """Validate Slack bot token via the auth.test API. - - Optionally checks the app-level token format (must start with ``xapp-``). - - Returns: - Tuple of (is_valid, message). - """ - if not bot_token: - return False, "No bot token provided" - - try: - import httpx - except ImportError: - return False, "httpx not installed" - - # Validate bot token via auth.test - url = "https://slack.com/api/auth.test" - headers = {"Authorization": f"Bearer {bot_token}"} - try: - async with httpx.AsyncClient() as client: - resp = await client.post(url, headers=headers, timeout=10) - data = resp.json() - if not data.get("ok"): - error = data.get("error", "unknown error") - return False, f"Invalid bot token: {error}" - bot_name = data.get("user", "unknown") - team = data.get("team", "unknown") - except Exception as e: - return False, f"Error: {e}" - - # Optionally validate app token format - if app_token: - if not app_token.startswith("xapp-"): - return False, "App token must start with 'xapp-'" - - return True, f"Bot: {bot_name} (team: {team})" diff --git a/EvoScientist/channels/slack/serve.py b/EvoScientist/channels/slack/serve.py deleted file mode 100644 index 18f8eed..0000000 --- a/EvoScientist/channels/slack/serve.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Slack channel server. - -Standalone script to run the Slack channel with CLI options. - -Usage: - python -m EvoScientist.channels.slack.serve --bot-token TOKEN --app-token TOKEN [OPTIONS] - -Examples: - # Allow all senders (default) - python -m EvoScientist.channels.slack.serve --bot-token xoxb-... --app-token xapp-... - - # Only allow specific senders and channels - python -m EvoScientist.channels.slack.serve --bot-token xoxb-... --app-token xapp-... --allow U123 --allow-channel C456 - - # With agent and thinking - python -m EvoScientist.channels.slack.serve --bot-token xoxb-... --app-token xapp-... --agent --thinking -""" - -import argparse -import logging - -from .channel import SlackChannel, SlackConfig -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="Slack channel server", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--bot-token", - required=True, - help="Slack bot token (xoxb-...)", - ) - parser.add_argument( - "--app-token", - required=True, - help="Slack app-level token for Socket Mode (xapp-...)", - ) - parser.add_argument( - "--allow", - action="append", - dest="allowed_senders", - help="Allowed sender (Slack 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( - "--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 = SlackConfig( - bot_token=args.bot_token, - app_token=args.app_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, - ) - - send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = SlackChannel(config) - - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) - - -if __name__ == "__main__": - main() diff --git a/EvoScientist/channels/standalone.py b/EvoScientist/channels/standalone.py deleted file mode 100644 index e85c0c3..0000000 --- a/EvoScientist/channels/standalone.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Shared standalone runner for channel servers. - -Provides the channel-agnostic agent loop that any channel can use to -run headless — consuming inbound messages from the bus, streaming -agent events, and dispatching outbound replies. - -Usage from a channel's ``main()``:: - - from EvoScientist.channels.standalone import run_standalone - - channel = SomeChannel(config) - bus = MessageBus() - run_standalone(channel, bus, use_agent=True, send_thinking=True) -""" - -import asyncio -import logging -import signal - -from .base import Channel -from .bus import MessageBus -from .bus.events import OutboundMessage -from .consumer import InboundConsumer - -logger = logging.getLogger(__name__) - - -async def standalone_outbound_dispatcher( - bus: MessageBus, channel: Channel, -) -> None: - """Consume outbound messages from the bus and send via channel.""" - while True: - try: - msg: OutboundMessage = await asyncio.wait_for( - bus.consume_outbound(), timeout=1.0, - ) - except asyncio.TimeoutError: - continue - except asyncio.CancelledError: - break - - try: - if msg.content: - await channel.send(msg) - except Exception as e: - logger.error(f"Error sending outbound: {e}") - - -async def _async_main( - channel: Channel, bus: MessageBus, - use_agent: bool, send_thinking: bool, -) -> None: - """Async entry point — gather channel, dispatcher and optional consumer.""" - from .channel_manager import ChannelManager - - channel.set_bus(bus) - if send_thinking: - channel.send_thinking = True - - # Create a lightweight manager for the consumer to use - manager = ChannelManager(bus) - manager._channels[channel.name] = channel - - await manager.start_health() - - tasks = [channel.run()] - - dispatcher = standalone_outbound_dispatcher(bus, channel) - tasks.append(dispatcher) - - consumer: InboundConsumer | None = None - if use_agent: - logger.info("Loading EvoScientist agent...") - from ..EvoScientist import create_cli_agent - agent = create_cli_agent() - logger.info("Agent loaded") - - consumer = InboundConsumer( - bus=bus, - manager=manager, - agent=agent, - thread_id="", - send_thinking=send_thinking, - ) - manager.register_health_provider("consumer", lambda: consumer.metrics) - tasks.append(consumer.run()) - if send_thinking: - logger.info("Thinking messages enabled") - - async def _graceful_shutdown() -> None: - """Graceful shutdown: drain consumer, flush outbound, stop channel.""" - logger.info("Graceful shutdown initiated...") - if consumer is not None: - await consumer.stop() - # Drain outbound queue before stopping the channel - drained = 0 - while True: - try: - msg = bus.outbound.get_nowait() - except asyncio.QueueEmpty: - break - try: - if msg.content: - await asyncio.wait_for(channel.send(msg), timeout=5.0) - drained += 1 - except Exception: - pass - if drained: - logger.info(f"Outbound drain: {drained} sent") - channel._running = False - await channel.stop() - await manager.stop_health() - - loop = asyncio.get_event_loop() - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler( - sig, lambda s=sig: asyncio.create_task(_graceful_shutdown()), - ) - - await asyncio.gather(*tasks) - - -def run_standalone( - channel: Channel, bus: MessageBus, *, - use_agent: bool = False, send_thinking: bool = False, -) -> None: - """Synchronous entry point that spins up the standalone runner. - - Parameters - ---------- - channel: - A fully-configured :class:`Channel` instance. - bus: - The :class:`MessageBus` shared with *channel*. - use_agent: - When ``True``, load the EvoScientist agent and process inbound - messages through it. - send_thinking: - When ``True`` **and** *use_agent* is set, forward intermediate - thinking messages to the channel. - """ - asyncio.run(_async_main(channel, bus, use_agent, send_thinking)) diff --git a/EvoScientist/channels/telegram/__init__.py b/EvoScientist/channels/telegram/__init__.py deleted file mode 100644 index 1e5a4ca..0000000 --- a/EvoScientist/channels/telegram/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from .channel import TelegramChannel, TelegramConfig -from ..channel_manager import register_channel, _parse_csv - -__all__ = ["TelegramChannel", "TelegramConfig"] - - -def create_from_config(config) -> TelegramChannel: - allowed = _parse_csv(config.telegram_allowed_senders) - proxy = config.telegram_proxy if config.telegram_proxy else None - return TelegramChannel(TelegramConfig( - bot_token=config.telegram_bot_token, - allowed_senders=allowed, - proxy=proxy, - )) - - -register_channel("telegram", create_from_config) diff --git a/EvoScientist/channels/telegram/channel.py b/EvoScientist/channels/telegram/channel.py deleted file mode 100644 index 7731c46..0000000 --- a/EvoScientist/channels/telegram/channel.py +++ /dev/null @@ -1,289 +0,0 @@ -"""Telegram channel implementation using python-telegram-bot.""" - -import logging -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path - -from ..base import Channel, RawIncoming, ChannelError, IMAGE_EXTS, VIDEO_EXTS, AUDIO_EXTS -from ..capabilities import TELEGRAM as TELEGRAM_CAPS -from ..config import BaseChannelConfig - -logger = logging.getLogger(__name__) - - -@dataclass -class TelegramConfig(BaseChannelConfig): - bot_token: str = "" - text_chunk_limit: int = 4096 - - -class TelegramChannel(Channel): - """Telegram channel using python-telegram-bot with long polling.""" - - name = "telegram" - - capabilities = TELEGRAM_CAPS - _typing_interval: float = 4.0 - _ready_attrs = ("_app",) - _non_retryable_patterns = ("parse", "can't parse") - _mention_pattern = r"(?i)@{bot_id}\s*" - - def __init__(self, config: TelegramConfig): - super().__init__(config) - self._app = None - 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, - MessageHandler, - filters, - ) - except ImportError: - raise ChannelError( - "python-telegram-bot not installed. " - "Install with: pip install evoscientist[telegram]" - ) - - builder = ApplicationBuilder().token(self.config.bot_token) - if self.config.proxy: - builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) - self._app = builder.build() - - # Accept text and media message types - media_filter = filters.TEXT - if self.config.include_attachments: - media_filter = ( - filters.TEXT - | filters.PHOTO - | filters.VOICE - | filters.AUDIO - | filters.Document.ALL - | filters.VIDEO - | filters.Sticker.ALL - | filters.LOCATION - ) - - self._app.add_handler( - MessageHandler(media_filter & ~filters.COMMAND, self._on_message) - ) - - await self._app.initialize() - # Cache bot username for @mention detection in groups - bot_info = await self._app.bot.get_me() - self._bot_username = (bot_info.username or "").lower() - await self._app.start() - await self._app.updater.start_polling(drop_pending_updates=True) - self._running = True - logger.info("Telegram channel started (polling)") - - async def _cleanup(self) -> None: - if self._app: - if self._app.updater and self._app.updater.running: - await self._app.updater.stop() - await self._app.stop() - await self._app.shutdown() - logger.info("Telegram channel stopped") - - # ── Typing indicator (override base) ──────────────────────────── - - async def _send_typing_action(self, chat_id: str) -> None: - """Send typing action via Telegram Bot API.""" - if self._app: - await self._app.bot.send_chat_action( - chat_id=int(chat_id), action="typing", - ) - - # ── Send (template method overrides) ────────────────────────── - - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - reply_id = int(reply_to) if reply_to else None - - async def _send(text): - await self._app.bot.send_message( - chat_id=int(chat_id), text=text, - parse_mode="HTML" if text == formatted_text else None, - reply_to_message_id=reply_id, - ) - - await self._send_with_format_fallback(_send, formatted_text, raw_text) - - _MEDIA_SENDERS = { - IMAGE_EXTS: ("send_photo", "photo"), - VIDEO_EXTS: ("send_video", "video"), - AUDIO_EXTS: ("send_audio", "audio"), - } - - async def _send_media_impl( - self, - recipient: str, - file_path: str, - caption: str = "", - metadata: dict | None = None, - ) -> bool: - """Send a media file through Telegram.""" - chat_id = int(self._resolve_media_chat_id(recipient, metadata)) - cap = caption or None - ext = Path(file_path).suffix.lower() - for exts, (method, param) in self._MEDIA_SENDERS.items(): - if ext in exts: - await getattr(self._app.bot, method)( - chat_id=chat_id, caption=cap, **{param: file_path}, - ) - return True - await self._app.bot.send_document( - chat_id=chat_id, document=file_path, caption=cap, - ) - return True - - def _get_bot_identifier(self) -> str | None: - return self._bot_username or None - - async def _send_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - """Send an acknowledgment reaction via Telegram.""" - if self._app: - try: - from telegram import ReactionTypeEmoji - await self._app.bot.set_message_reaction( - chat_id=int(chat_id), - message_id=int(message_id), - reaction=[ReactionTypeEmoji(emoji)], - ) - except Exception as e: - logger.debug(f"Telegram ACK reaction failed: {e}") - - async def _remove_ack_reaction(self, chat_id: str, message_id: str, emoji: str = "👀") -> None: - """Remove the ack reaction by setting empty reaction list.""" - if self._app: - try: - await self._app.bot.set_message_reaction( - chat_id=int(chat_id), - message_id=int(message_id), - reaction=[], - ) - except Exception as e: - logger.debug(f"Telegram remove ACK reaction failed: {e}") - - async def _on_message(self, update, context) -> None: - """Handler callback for text, photos, voice, audio, documents, video.""" - if not update.message: - return - - message = update.message - user_id = str(message.from_user.id) - chat_id = str(message.chat_id) - - # Detect group and mention status for centralized gating - is_group = message.chat.type in ("group", "supergroup") - was_mentioned = True # DM default - if is_group and self._bot_username: - text_check = (message.text or message.caption or "").lower() - was_mentioned = f"@{self._bot_username}" in text_check - - content_parts: list[str] = [] - media_paths: list[str] = [] - - # Text content - if message.text: - content_parts.append(message.text) - if message.caption: - content_parts.append(message.caption) - - # Handle media files - annotations: list[str] = [] - if self.config.include_attachments: - media_file = None - media_type = None - - if message.photo: - media_file = message.photo[-1] # Largest size - media_type = "image" - elif message.voice: - media_file = message.voice - media_type = "voice" - elif message.audio: - media_file = message.audio - media_type = "audio" - elif message.video: - media_file = message.video - media_type = "video" - elif message.document: - media_file = message.document - media_type = "file" - elif message.sticker: - media_file = message.sticker - media_type = "sticker" - - # Location is not a downloadable file — handle separately - if message.location and not media_file: - loc = message.location - annotations.append( - f"[位置] ({loc.latitude}, {loc.longitude})" - ) - - if media_file and self._app: - file_size = getattr(media_file, 'file_size', 0) or 0 - too_large = self._check_attachment_size(file_size, media_type) - if too_large: - annotations.append(too_large) - else: - try: - file = await self._app.bot.get_file( - media_file.file_id, - ) - ext = self._get_extension( - media_type, - getattr(media_file, 'mime_type', None), - ) - file_path = self._media_path( - f"{media_file.file_id[:16]}{ext}" - ) - await file.download_to_drive(str(file_path)) - - media_paths.append(str(file_path)) - annotations.append(f"[{media_type}: {file_path}]") - logger.debug( - f"Downloaded {media_type} to {file_path}" - ) - except Exception as e: - logger.error(f"Failed to download media: {e}") - annotations.append( - f"[{media_type}: download failed]" - ) - - text_content = "\n".join(content_parts) if content_parts else "" - - await self._enqueue_raw(RawIncoming( - sender_id=user_id, - chat_id=chat_id, - text=text_content, - media_files=media_paths, - content_annotations=annotations, - timestamp=message.date or datetime.now(), - message_id=str(message.message_id), - metadata={"chat_id": chat_id}, - is_group=is_group, - was_mentioned=was_mentioned, - )) - - _MIME_TO_EXT = { - "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", - "image/webp": ".webp", "audio/ogg": ".ogg", "audio/mpeg": ".mp3", - "audio/mp4": ".m4a", "video/mp4": ".mp4", "video/quicktime": ".mov", - } - _TYPE_TO_EXT = { - "image": ".jpg", "voice": ".ogg", "audio": ".mp3", - "video": ".mp4", "file": "", "sticker": ".webp", - } - - @staticmethod - def _get_extension(media_type: str, mime_type: str | None) -> str: - """Get file extension based on media type and MIME type.""" - if mime_type and mime_type in TelegramChannel._MIME_TO_EXT: - return TelegramChannel._MIME_TO_EXT[mime_type] - return TelegramChannel._TYPE_TO_EXT.get(media_type, "") diff --git a/EvoScientist/channels/telegram/probe.py b/EvoScientist/channels/telegram/probe.py deleted file mode 100644 index fefec54..0000000 --- a/EvoScientist/channels/telegram/probe.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Telegram bot token validation.""" - -import logging - -logger = logging.getLogger(__name__) - - -async def validate_telegram_token(token: str, proxy: str | None = None) -> tuple[bool, str]: - """Validate a Telegram bot token via the getMe 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 = f"https://api.telegram.org/bot{token}/getMe" - try: - async with httpx.AsyncClient(proxy=proxy) as client: - resp = await client.get(url, timeout=10) - data = resp.json() - if data.get("ok"): - username = data["result"].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/telegram/serve.py b/EvoScientist/channels/telegram/serve.py deleted file mode 100644 index 074e69b..0000000 --- a/EvoScientist/channels/telegram/serve.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Telegram channel server. - -Standalone script to run the Telegram channel with CLI options. - -Usage: - python -m EvoScientist.channels.telegram.serve --bot-token TOKEN [OPTIONS] - -Examples: - # Allow all senders (default) - python -m EvoScientist.channels.telegram.serve --bot-token TOKEN - - # Only allow specific senders - python -m EvoScientist.channels.telegram.serve --bot-token TOKEN --allow 123456 --allow 789012 - - # With agent and thinking - python -m EvoScientist.channels.telegram.serve --bot-token TOKEN --agent --thinking -""" - -import argparse -import logging - -from .channel import TelegramChannel, TelegramConfig -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="Telegram channel server", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--bot-token", - required=True, - help="Telegram bot token from @BotFather", - ) - parser.add_argument( - "--allow", - action="append", - dest="allowed_senders", - help="Allowed sender (Telegram user 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 = TelegramConfig( - bot_token=args.bot_token, - allowed_senders=set(args.allowed_senders) if args.allowed_senders else None, - ) - - send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = TelegramChannel(config) - - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) - - -if __name__ == "__main__": - main() diff --git a/EvoScientist/channels/wechat/__init__.py b/EvoScientist/channels/wechat/__init__.py deleted file mode 100644 index 009f134..0000000 --- a/EvoScientist/channels/wechat/__init__.py +++ /dev/null @@ -1,69 +0,0 @@ -"""WeChat channel implementations for EvoScientist. - -Supports multiple WeChat backends: - - **wecom**: 企业微信应用 (WeCom / WeChat Work) via official API - — Most stable, pure HTTP, no third-party dependencies - - **wechatmp**: 微信公众号 (WeChat Official Account) via official API - — Pure HTTP webhook, suitable for public-facing bots - -Both backends use httpx (already a core dependency) and receive messages -via HTTP webhook, send replies via REST API. - -Usage in config: - channel_enabled = "wechat" - wechat_backend = "wecom" # or "wechatmp" - - # WeCom settings - wechat_wecom_corp_id = "..." - wechat_wecom_agent_id = "..." - wechat_wecom_secret = "..." - wechat_wecom_token = "..." - wechat_wecom_encoding_aes_key = "..." - wechat_webhook_port = 9001 - - # OR: Official Account settings - wechat_mp_app_id = "..." - wechat_mp_app_secret = "..." - wechat_mp_token = "..." - wechat_mp_encoding_aes_key = "..." - wechat_webhook_port = 9001 -""" - -from .channel import WeChatChannel, WeComConfig, WeChatMPConfig -from ..channel_manager import register_channel, _parse_csv - -__all__ = ["WeChatChannel", "WeComConfig", "WeChatMPConfig"] - - -def create_from_config(config) -> WeChatChannel: - backend = getattr(config, "wechat_backend", "wecom") or "wecom" - allowed = _parse_csv(getattr(config, "wechat_allowed_senders", "")) - proxy = getattr(config, "wechat_proxy", "") or None - port = int(getattr(config, "wechat_webhook_port", 9001) or 9001) - - if backend == "wechatmp": - mp_config = WeChatMPConfig( - app_id=getattr(config, "wechat_mp_app_id", ""), - app_secret=getattr(config, "wechat_mp_app_secret", ""), - token=getattr(config, "wechat_mp_token", ""), - encoding_aes_key=getattr(config, "wechat_mp_encoding_aes_key", ""), - webhook_port=port, - allowed_senders=allowed, - proxy=proxy, - ) - return WeChatChannel(mp_config, backend="wechatmp") - else: - wecom_config = WeComConfig( - corp_id=getattr(config, "wechat_wecom_corp_id", ""), - agent_id=getattr(config, "wechat_wecom_agent_id", ""), - secret=getattr(config, "wechat_wecom_secret", ""), - token=getattr(config, "wechat_wecom_token", ""), - encoding_aes_key=getattr(config, "wechat_wecom_encoding_aes_key", ""), - webhook_port=port, - allowed_senders=allowed, - proxy=proxy, - ) - return WeChatChannel(wecom_config, backend="wecom") - - -register_channel("wechat", create_from_config) diff --git a/EvoScientist/channels/wechat/channel.py b/EvoScientist/channels/wechat/channel.py deleted file mode 100644 index 291131e..0000000 --- a/EvoScientist/channels/wechat/channel.py +++ /dev/null @@ -1,807 +0,0 @@ -"""WeChat channel implementation. - -Supports two backends via a unified Channel interface: - -1. **wecom** (企业微信应用): Corporate WeChat official API - - Receives messages via HTTP callback (XML + optional AES encryption) - - Sends replies via REST API (POST /cgi-bin/message/send) - - Supports text, image, file, markdown messages - - Token auto-refresh with 2-hour TTL - -2. **wechatmp** (微信公众号): WeChat Official Account API - - Receives messages via HTTP callback (XML + optional AES encryption) - - Sends replies via REST API (POST /cgi-bin/message/custom/send) - - Supports text, image, news messages - -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 logging -import re -import time -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Any, TYPE_CHECKING - -if TYPE_CHECKING: - from aiohttp import web - -from ..mixins import WebhookMixin, TokenMixin -from ..base import Channel, RawIncoming, ChannelError -from ..capabilities import WECHAT as WECHAT_CAPS - -logger = logging.getLogger(__name__) - - -# ── Markdown → plain text (fallback for WeChat text messages) ──── - -def _strip_markdown(text: str) -> str: - """Strip Markdown formatting for plain-text WeChat messages.""" - # Remove code blocks - text = re.sub(r"```[\s\S]*?```", lambda m: m.group(0).strip("`").strip(), text) - # Remove inline code - text = re.sub(r"`([^`]+)`", r"\1", text) - # Remove bold - text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) - # Remove italic - text = re.sub(r"(? list[tuple[str, str, Any]]: - """Return HTTP routes for the shared webhook server.""" - return [ - ("GET", "/wechat/callback", self._handle_verify), - ("POST", "/wechat/callback", self._handle_message), - ] - - async def start(self) -> None: - try: - from aiohttp import web - import httpx # noqa: F401 - except ImportError: - raise ChannelError( - "aiohttp or httpx not installed. " - "Install with: pip install aiohttp httpx" - ) - - self._validate_config() - - import httpx - self._http_client = httpx.AsyncClient( - timeout=15, - proxy=self._get_proxy(), - ) - - # Set up message encryption if configured - if self.config.encoding_aes_key and self.config.token: - from .crypto import WeChatCrypto - app_id = self._get_app_id() - self._crypto = WeChatCrypto( - token=self.config.token, - encoding_aes_key=self.config.encoding_aes_key, - app_id=app_id, - ) - - # Verify credentials by fetching initial token - await self._refresh_token() - - if not getattr(self, "_shared_webhook_server", None): - app = web.Application() - app.router.add_get("/wechat/callback", self._handle_verify) - app.router.add_post("/wechat/callback", self._handle_message) - - self._runner = web.AppRunner(app) - await self._runner.setup() - self._site = web.TCPSite( - self._runner, "0.0.0.0", self.config.webhook_port, - ) - await self._site.start() - - self._running = True - logger.info( - f"WeChat channel started " - f"(backend={self._backend}, " - f"webhook on port {self.config.webhook_port})" - ) - - async def _cleanup(self) -> None: - if self._site: - await self._site.stop() - if self._runner: - await self._runner.cleanup() - if self._http_client: - await self._http_client.aclose() - self._http_client = None - self._access_token = None - logger.info("WeChat channel stopped") - - def _validate_config(self) -> None: - """Validate required config fields based on backend.""" - if self._backend == "wecom": - cfg = self.config - if not cfg.corp_id: - raise ChannelError("WeCom corp_id is required") - if not cfg.secret: - raise ChannelError("WeCom secret is required") - if not cfg.agent_id: - raise ChannelError("WeCom agent_id is required") - elif self._backend == "wechatmp": - cfg = self.config - if not cfg.app_id: - raise ChannelError("WeChat MP app_id is required") - if not cfg.app_secret: - raise ChannelError("WeChat MP app_secret is required") - - def _get_app_id(self) -> str: - """Return the app identifier for crypto operations.""" - if self._backend == "wecom": - return self.config.corp_id - return self.config.app_id - - # ── Token management ────────────────────────────────────────── - - async def _refresh_token(self) -> None: - """Fetch or refresh the access_token.""" - if self._backend == "wecom": - url = ( - f"https://qyapi.weixin.qq.com/cgi-bin/gettoken" - f"?corpid={self.config.corp_id}" - f"&corpsecret={self.config.secret}" - ) - else: - url = ( - f"https://api.weixin.qq.com/cgi-bin/token" - f"?grant_type=client_credential" - f"&appid={self.config.app_id}" - f"&secret={self.config.app_secret}" - ) - - try: - resp = await self._http_client.get(url) - data = resp.json() - except Exception as e: - raise ChannelError(f"Failed to get WeChat access token: {e}") - - if data.get("errcode", 0) != 0: - raise ChannelError( - f"WeChat auth error ({data.get('errcode')}): " - f"{data.get('errmsg', 'unknown')}" - ) - - self._access_token = data["access_token"] - expire = data.get("expires_in", 7200) - # Refresh 5 minutes before expiry - self._token_expires = time.monotonic() + expire - 300 - logger.debug(f"WeChat token refreshed, expires in {expire}s") - - async def _ensure_token(self) -> str: - """Return a valid access token, refreshing if needed.""" - if not self._access_token or time.monotonic() >= self._token_expires: - await self._refresh_token() - return self._access_token - - # ── Signature verification (GET callback) ───────────────────── - - async def _handle_verify(self, request) -> "web.Response": - """Handle GET /wechat/callback for URL verification. - - WeChat/WeCom sends: msg_signature, timestamp, nonce, echostr - We decrypt echostr (encrypted mode) or verify signature (plain mode) - and return the plain echostr. - """ - from aiohttp import web - - signature = request.query.get("msg_signature") or request.query.get("signature", "") - timestamp = request.query.get("timestamp", "") - nonce = request.query.get("nonce", "") - echostr = request.query.get("echostr", "") - - logger.info(f"Verify request received: timestamp={timestamp}") - - if not echostr: - return web.Response(status=400, text="missing echostr") - - # Encrypted mode: WeCom sends msg_signature and encrypted echostr - if self._crypto and request.query.get("msg_signature"): - # Verify signature first - sig_ok = self._crypto.verify_signature(signature, timestamp, nonce, echostr) - if not sig_ok: - logger.warning("WeChat verify: signature mismatch") - # Try to decrypt regardless — the decrypted echostr must be returned - try: - plain_echostr, _ = self._crypto.decrypt(echostr) - logger.info("WeChat verify: echostr decrypted successfully") - return web.Response(text=plain_echostr) - except Exception as e: - logger.error(f"WeChat verify: echostr decrypt failed: {e}") - return web.Response(status=500) - else: - # Plain mode verification - token = self.config.token - if token: - parts = sorted([token, timestamp, nonce]) - expected = hashlib.sha1("".join(parts).encode()).hexdigest() - if expected != signature: - logger.warning("WeChat verify: signature mismatch (plain)") - return web.Response(status=403) - return web.Response(text=echostr) - - # ── Inbound message handling (POST callback) ────────────────── - - async def _handle_message(self, request) -> "web.Response": - """Handle POST /wechat/callback for incoming messages.""" - from aiohttp import web - from .crypto import parse_xml - - try: - body = await request.text() - 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 - encrypt = xml_data.get("Encrypt", "") - if encrypt and self._crypto: - signature = request.query.get("msg_signature", "") - timestamp = request.query.get("timestamp", "") - nonce = request.query.get("nonce", "") - - if not self._crypto.verify_signature(signature, timestamp, nonce, encrypt): - logger.warning("WeChat message signature mismatch") - return web.Response(status=403) - - try: - decrypted_xml, from_id = self._crypto.decrypt(encrypt) - xml_data = parse_xml(decrypted_xml) - except Exception as e: - logger.error(f"WeChat decrypt failed: {e}") - return web.Response(status=500) - - # 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 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", "") - from_user = xml_data.get("FromUserName", "") - to_user = xml_data.get("ToUserName", "") - content = xml_data.get("Content", "") - 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 - - # Determine chat_id - # For WeCom: FromUserName is the user's UserID - # For MP: FromUserName is the user's OpenID - chat_id = from_user - - # Group chat detection - is_group = False - was_mentioned = True # Default: treat as mentioned (DMs) - - # WeCom group detection: ChatId field indicates a group message - if self._backend == "wecom": - group_chat_id = xml_data.get("ChatId", "") - if group_chat_id: - is_group = True - chat_id = group_chat_id - # WeCom sets MsgType=event with Event=sys when bot is @mentioned, - # but for text messages we check the XML AtUserList field - at_user_list = xml_data.get("AtUserList", "") - was_mentioned = bool(at_user_list) - - # Handle different message types - text = "" - annotations: list[str] = [] - media_paths: list[str] = [] - - if msg_type == "text": - text = content - elif msg_type == "image": - pic_url = xml_data.get("PicUrl", "") - media_id = xml_data.get("MediaId", "") - if pic_url: - local, ann = await self._download_attachment( - pic_url, f"wechat_{msg_id}.jpg", - ) - if local: - media_paths.append(local) - if ann: - annotations.append(ann) - elif media_id: - local, ann = await self._download_wechat_media( - media_id, f"wechat_image_{msg_id}", - ) - if local: - media_paths.append(local) - if ann: - annotations.append(ann) - else: - annotations.append("[image: no download source]") - elif msg_type == "voice": - recognition = xml_data.get("Recognition", "") - media_id = xml_data.get("MediaId", "") - if media_id: - local, ann = await self._download_wechat_media(media_id, f"wechat_voice_{msg_id}") - if local: - media_paths.append(local) - if ann: - ann = ann.replace("[attachment:", "[voice:") - annotations.append(ann) - if recognition: - text = f"[语音识别] {recognition}" - elif not media_paths: - annotations.append("[voice message]") - elif msg_type in ("video", "shortvideo"): - media_id = xml_data.get("MediaId", "") - if media_id: - local, ann = await self._download_wechat_media(media_id, f"wechat_{msg_type}_{msg_id}") - if local: - media_paths.append(local) - if ann: - annotations.append(ann) - if not media_paths: - annotations.append(f"[{msg_type} message]") - elif msg_type == "location": - label = xml_data.get("Label", "") - 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("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( - 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", "") - url = xml_data.get("Url", "") - text = f"[链接] {title}\n{description}\n{url}" - elif msg_type == "event": - event_type = xml_data.get("Event", "") - if event_type == "subscribe": - text = "[用户关注]" - elif event_type == "unsubscribe": - logger.info(f"User {from_user} unsubscribed") - return # Don't process - elif event_type == "CLICK": - event_key = xml_data.get("EventKey", "") - text = f"[菜单点击] {event_key}" - elif event_type in ("LOCATION", "VIEW"): - # Periodic location reports and menu-link clicks — ignore - return - else: - logger.debug(f"Ignoring WeChat event: {event_type}") - return - else: - text = f"[{msg_type} message]" - - if not text and not media_paths and not annotations: - return - - # Parse timestamp - try: - timestamp = datetime.fromtimestamp( - int(create_time) - ) if create_time else datetime.now() - except (ValueError, TypeError, OSError): - timestamp = datetime.now() - - await self._enqueue_raw(RawIncoming( - sender_id=from_user, - chat_id=chat_id, - text=text, - media_files=media_paths, - content_annotations=annotations, - timestamp=timestamp, - message_id=msg_id, - is_group=is_group, - was_mentioned=was_mentioned, - metadata={ - "chat_id": chat_id, - "to_user": to_user, - "backend": self._backend, - }, - )) - - # ── Send (template method overrides) ────────────────────────── - - def _format_chunk(self, text: str) -> str: - """WeCom uses markdown formatter; MP uses plain text.""" - if self._backend == "wecom": - return self._formatter.format(text) # markdown profile - return _strip_markdown(text) - - async def _send_chunk( - self, chat_id, formatted_text, raw_text, reply_to, metadata, - ): - token = await self._ensure_token() - - if self._backend == "wecom": - # Group chat: use appchat/send endpoint - if chat_id.startswith("wr"): - try: - await self._wecom_send_group_markdown(token, chat_id, raw_text) - return - except Exception: - pass - await self._wecom_send_group_text(token, chat_id, raw_text) - else: - # DM: Try markdown first, fall back to plain text - try: - await self._wecom_send_markdown(token, chat_id, raw_text) - return - except Exception: - pass - await self._wecom_send_text(token, chat_id, raw_text) - else: - await self._mp_send_text(token, chat_id, raw_text) - - # ── WeCom send ──────────────────────────────────────────────── - - async def _wecom_send_text( - self, token: str, user_id: str, text: str, - ) -> None: - """Send a text message via WeCom API.""" - url = ( - f"https://qyapi.weixin.qq.com/cgi-bin/message/send" - f"?access_token={token}" - ) - body = { - "touser": user_id, - "msgtype": "text", - "agentid": int(self.config.agent_id), - "text": {"content": _strip_markdown(text)}, - } - await self._post_api(url, body) - - async def _wecom_send_markdown( - self, token: str, user_id: str, text: str, - ) -> None: - """Send a markdown message via WeCom API. - - Note: WeCom markdown only supports a subset of Markdown - (no code blocks, no images). Falls back to text if the - message is too complex. - """ - url = ( - f"https://qyapi.weixin.qq.com/cgi-bin/message/send" - f"?access_token={token}" - ) - body = { - "touser": user_id, - "msgtype": "markdown", - "agentid": int(self.config.agent_id), - "markdown": {"content": text}, - } - await self._post_api(url, body) - - # ── WeCom group send ──────────────────────────────────────────── - - async def _wecom_send_group_text( - self, token: str, chatid: str, text: str, - ) -> None: - """Send a text message to a WeCom group chat.""" - url = ( - f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send" - f"?access_token={token}" - ) - body = { - "chatid": chatid, - "msgtype": "text", - "text": {"content": _strip_markdown(text)}, - } - await self._post_api(url, body) - - async def _wecom_send_group_markdown( - self, token: str, chatid: str, text: str, - ) -> None: - """Send a markdown message to a WeCom group chat.""" - url = ( - f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send" - f"?access_token={token}" - ) - body = { - "chatid": chatid, - "msgtype": "markdown", - "markdown": {"content": text}, - } - await self._post_api(url, body) - - # ── MP send ─────────────────────────────────────────────────── - - async def _mp_send_text( - self, token: str, openid: str, text: str, - ) -> None: - """Send a text message via WeChat MP customer service API.""" - url = ( - f"https://api.weixin.qq.com/cgi-bin/message/custom/send" - f"?access_token={token}" - ) - body = { - "touser": openid, - "msgtype": "text", - "text": {"content": _strip_markdown(text)}, - } - await self._post_api(url, body) - - # ── Media send ──────────────────────────────────────────────── - - async def _send_media_impl( - self, - recipient: str, - file_path: str, - caption: str = "", - metadata: dict | None = None, - ) -> bool: - """Send a media file via WeChat/WeCom.""" - token = await self._ensure_token() - chat_id = self._resolve_media_chat_id(recipient, metadata) - - # Upload media to get media_id - media_id = await self._upload_media(token, file_path) - if not media_id: - return False - - path = Path(file_path) - ext = path.suffix.lower() - is_image = ext in {".jpg", ".jpeg", ".png", ".gif", ".bmp"} - - if self._backend == "wecom": - msg_type = "image" if is_image else "file" - # Group chat: use appchat/send endpoint - if chat_id.startswith("wr"): - url = ( - f"https://qyapi.weixin.qq.com/cgi-bin/appchat/send" - f"?access_token={token}" - ) - body = { - "chatid": chat_id, - "msgtype": msg_type, - msg_type: {"media_id": media_id}, - } - else: - url = ( - f"https://qyapi.weixin.qq.com/cgi-bin/message/send" - f"?access_token={token}" - ) - body = { - "touser": chat_id, - "msgtype": msg_type, - "agentid": int(self.config.agent_id), - msg_type: {"media_id": media_id}, - } - else: - url = ( - f"https://api.weixin.qq.com/cgi-bin/message/custom/send" - f"?access_token={token}" - ) - msg_type = "image" if is_image else "file" # MP only supports image - if not is_image: - # MP doesn't support file via customer service API; - # send caption as text instead - if caption: - await self._mp_send_text(token, chat_id, f"[文件] {path.name}\n{caption}") - return True - body = { - "touser": chat_id, - "msgtype": "image", - "image": {"media_id": media_id}, - } - - await self._post_api(url, body) - - # Send caption separately if provided - if caption: - if self._backend == "wecom": - if chat_id.startswith("wr"): - await self._wecom_send_group_text(token, chat_id, caption) - else: - await self._wecom_send_text(token, chat_id, caption) - else: - await self._mp_send_text(token, chat_id, caption) - - return True - - async def _upload_media( - self, token: str, file_path: str, - ) -> str | None: - """Upload a media file and return the media_id.""" - path = Path(file_path) - ext = path.suffix.lower() - is_image = ext in {".jpg", ".jpeg", ".png", ".gif", ".bmp"} - media_type = "image" if is_image else "file" - - if self._backend == "wecom": - url = ( - f"https://qyapi.weixin.qq.com/cgi-bin/media/upload" - f"?access_token={token}&type={media_type}" - ) - else: - url = ( - f"https://api.weixin.qq.com/cgi-bin/media/upload" - f"?access_token={token}&type={media_type}" - ) - - try: - with open(file_path, "rb") as f: - resp = await self._http_client.post( - url, - files={"media": (path.name, f)}, - ) - data = resp.json() - if data.get("errcode", 0) != 0 and "media_id" not in data: - logger.error( - f"WeChat media upload failed: {data.get('errmsg')}" - ) - return None - return data.get("media_id") - except Exception as e: - logger.error(f"WeChat media upload error: {e}") - return None - - # ── Media download helper ──────────────────────────────────── - - async def _download_wechat_media( - self, media_id: str, filename: str, - ) -> tuple[str | None, str | None]: - """Download media by media_id via WeChat/WeCom media API.""" - token = await self._ensure_token() - if self._backend == "wecom": - url = f"https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}" - else: - url = f"https://api.weixin.qq.com/cgi-bin/media/get?access_token={token}&media_id={media_id}" - return await self._download_attachment(url, filename) - - # ── Shared API helper ───────────────────────────────────────── - - async def _post_api(self, url: str, body: dict) -> dict: - """POST to WeChat/WeCom API, check errcode, return response.""" - try: - resp = await self._http_client.post(url, json=body) - data = resp.json() - except Exception as e: - raise RuntimeError(f"WeChat API error: {e}") - - errcode = data.get("errcode", 0) - if errcode != 0: - errmsg = data.get("errmsg", "unknown") - # Token expired — refresh and retry once - if errcode in (40014, 42001): - logger.warning("WeChat token expired, refreshing...") - await self._refresh_token() - token = self._access_token - # Replace token in URL - if "access_token=" in url: - url = re.sub( - r"access_token=[^&]+", - f"access_token={token}", - url, - ) - resp = await self._http_client.post(url, json=body) - data = resp.json() - if data.get("errcode", 0) != 0: - raise RuntimeError( - f"WeChat API error after retry: " - f"{data.get('errmsg')}" - ) - return data - else: - raise RuntimeError( - f"WeChat API error ({errcode}): {errmsg}" - ) - - return data - - # _send_typing_action: inherited no-op (WeChat has no typing API) - - diff --git a/EvoScientist/channels/wechat/crypto.py b/EvoScientist/channels/wechat/crypto.py deleted file mode 100644 index 3652a34..0000000 --- a/EvoScientist/channels/wechat/crypto.py +++ /dev/null @@ -1,187 +0,0 @@ -"""WeChat / WeCom crypto helpers. - -Implements the message encryption/decryption protocol used by both -WeCom (企业微信) and WeChat Official Account (公众号) callback APIs. - -The protocol uses AES-256-CBC with a key derived from the EncodingAESKey -(base64-encoded 43-char string → 32-byte AES key). - -References: - - WeCom: https://developer.work.weixin.qq.com/document/path/90930 - - MP: https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Message_Encryption_and_Decryption_Instructions.html -""" - -import base64 -import hashlib -import struct -import time -import xml.etree.ElementTree as ET - -# Crypto imports — all from the Python standard library + pycryptodome -# (but we'll use a pure-Python fallback if not available) -try: - from Crypto.Cipher import AES - _HAS_PYCRYPTO = True -except ImportError: - _HAS_PYCRYPTO = False - - -def _pkcs7_pad(data: bytes, block_size: int = 32) -> bytes: - """PKCS#7 padding.""" - pad_len = block_size - (len(data) % block_size) - return data + bytes([pad_len]) * pad_len - - -def _pkcs7_unpad(data: bytes) -> bytes: - """PKCS#7 unpadding.""" - pad_len = data[-1] - if pad_len < 1 or pad_len > 32: - return data - return data[:-pad_len] - - -def _aes_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes: - """AES-256-CBC decryption.""" - if _HAS_PYCRYPTO: - cipher = AES.new(key, AES.MODE_CBC, iv) - return cipher.decrypt(ciphertext) - else: - # Pure-Python AES fallback (slower but no C deps) - # We'll try pyaes as a fallback - try: - import pyaes - decrypter = pyaes.Decrypter( - pyaes.AESModeOfOperationCBC(key, iv=iv) - ) - decrypted = decrypter.feed(ciphertext) - decrypted += decrypter.feed() - return decrypted - except ImportError: - raise ImportError( - "WeChat message decryption requires pycryptodome or pyaes. " - "Install with: pip install pycryptodome" - ) - - -def _aes_encrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes: - """AES-256-CBC encryption.""" - if _HAS_PYCRYPTO: - cipher = AES.new(key, AES.MODE_CBC, iv) - return cipher.encrypt(plaintext) - else: - try: - import pyaes - encrypter = pyaes.Encrypter( - pyaes.AESModeOfOperationCBC(key, iv=iv) - ) - encrypted = encrypter.feed(plaintext) - encrypted += encrypter.feed() - return encrypted - except ImportError: - raise ImportError( - "WeChat message encryption requires pycryptodome or pyaes. " - "Install with: pip install pycryptodome" - ) - - -class WeChatCrypto: - """Handles WeChat/WeCom message encryption and decryption. - - Parameters - ---------- - token: - The Token configured in the WeChat/WeCom callback URL settings. - encoding_aes_key: - The 43-character EncodingAESKey (base64-encoded). - app_id: - The AppID (for MP) or CorpID (for WeCom). - """ - - def __init__(self, token: str, encoding_aes_key: str, app_id: str): - self.token = token - self.app_id = app_id - # Decode the AES key: EncodingAESKey + "=" → base64 decode → 32 bytes - self.aes_key = base64.b64decode(encoding_aes_key + "=") - # IV is the first 16 bytes of the key - self.iv = self.aes_key[:16] - - def verify_signature( - self, signature: str, timestamp: str, nonce: str, - encrypt: str = "", - ) -> bool: - """Verify the callback signature. - - For plain-mode verification (no encryption), *encrypt* can be empty. - """ - parts = sorted([self.token, timestamp, nonce] + ([encrypt] if encrypt else [])) - sha1 = hashlib.sha1("".join(parts).encode()).hexdigest() - return sha1 == signature - - def decrypt(self, encrypt: str) -> tuple[str, str]: - """Decrypt an encrypted message. - - Returns ``(xml_content, from_app_id)`` tuple. - """ - ciphertext = base64.b64decode(encrypt) - plaintext = _aes_decrypt(self.aes_key, self.iv, ciphertext) - plaintext = _pkcs7_unpad(plaintext) - - # plaintext layout: - # 16 bytes random + 4 bytes msg_len (big-endian) + msg + app_id - msg_len = struct.unpack("!I", plaintext[16:20])[0] - msg = plaintext[20:20 + msg_len].decode("utf-8") - from_app_id = plaintext[20 + msg_len:].decode("utf-8") - return msg, from_app_id - - def encrypt(self, reply_msg: str) -> str: - """Encrypt a reply message. - - Returns the base64-encoded ciphertext. - """ - msg_bytes = reply_msg.encode("utf-8") - app_id_bytes = self.app_id.encode("utf-8") - - # Random 16 bytes + msg_len (4 bytes big-endian) + msg + app_id - import os - random_bytes = os.urandom(16) - msg_len = struct.pack("!I", len(msg_bytes)) - plaintext = random_bytes + msg_len + msg_bytes + app_id_bytes - plaintext = _pkcs7_pad(plaintext) - - ciphertext = _aes_encrypt(self.aes_key, self.iv, plaintext) - return base64.b64encode(ciphertext).decode("utf-8") - - def generate_signature( - self, encrypt: str, timestamp: str, nonce: str, - ) -> str: - """Generate the msg_signature for an encrypted reply.""" - parts = sorted([self.token, timestamp, nonce, encrypt]) - return hashlib.sha1("".join(parts).encode()).hexdigest() - - def wrap_encrypted_reply(self, reply_msg: str) -> str: - """Encrypt a reply and wrap it in the XML envelope. - - Returns the full XML string to return in the HTTP response. - """ - encrypt = self.encrypt(reply_msg) - timestamp = str(int(time.time())) - nonce = hashlib.md5(str(time.time()).encode()).hexdigest()[:10] - signature = self.generate_signature(encrypt, timestamp, nonce) - - return ( - f"" - f"" - f"" - f"{timestamp}" - f"" - f"" - ) - - -def parse_xml(xml_str: str) -> dict[str, str]: - """Parse a WeChat callback XML into a flat dict.""" - root = ET.fromstring(xml_str) - result = {} - for child in root: - result[child.tag] = child.text or "" - return result diff --git a/EvoScientist/channels/wechat/probe.py b/EvoScientist/channels/wechat/probe.py deleted file mode 100644 index ffa26c2..0000000 --- a/EvoScientist/channels/wechat/probe.py +++ /dev/null @@ -1,72 +0,0 @@ -"""WeChat/WeCom credential validation.""" - -import logging - -logger = logging.getLogger(__name__) - - -async def validate_wecom( - corp_id: str, - secret: str, - proxy: str | None = None, -) -> tuple[bool, str]: - """Validate WeCom credentials by fetching an access token. - - Returns: - Tuple of (is_valid, message). - """ - if not corp_id or not secret: - return False, "corp_id and secret are required" - - try: - import httpx - except ImportError: - return False, "httpx not installed" - - url = ( - f"https://qyapi.weixin.qq.com/cgi-bin/gettoken" - f"?corpid={corp_id}&corpsecret={secret}" - ) - try: - async with httpx.AsyncClient(proxy=proxy) as client: - resp = await client.get(url, timeout=10) - data = resp.json() - if data.get("errcode", 0) == 0: - return True, "WeCom credentials valid" - return False, f"Error ({data.get('errcode')}): {data.get('errmsg')}" - except Exception as e: - return False, f"Error: {e}" - - -async def validate_wechat_mp( - app_id: str, - app_secret: str, - proxy: str | None = None, -) -> tuple[bool, str]: - """Validate WeChat Official Account credentials. - - Returns: - Tuple of (is_valid, message). - """ - if not app_id or not app_secret: - return False, "app_id and app_secret are required" - - try: - import httpx - except ImportError: - return False, "httpx not installed" - - url = ( - f"https://api.weixin.qq.com/cgi-bin/token" - f"?grant_type=client_credential" - f"&appid={app_id}&secret={app_secret}" - ) - try: - async with httpx.AsyncClient(proxy=proxy) as client: - resp = await client.get(url, timeout=10) - data = resp.json() - if "access_token" in data: - return True, "WeChat MP credentials valid" - return False, f"Error ({data.get('errcode')}): {data.get('errmsg')}" - except Exception as e: - return False, f"Error: {e}" diff --git a/EvoScientist/channels/wechat/serve.py b/EvoScientist/channels/wechat/serve.py deleted file mode 100644 index 738ad6a..0000000 --- a/EvoScientist/channels/wechat/serve.py +++ /dev/null @@ -1,130 +0,0 @@ -"""WeChat channel server. - -Standalone script to run the WeChat channel with CLI options. - -Usage: - # WeCom (企业微信应用) - python -m EvoScientist.channels.wechat.serve \\ - --backend wecom \\ - --corp-id CORP_ID \\ - --agent-id AGENT_ID \\ - --secret SECRET \\ - --token TOKEN \\ - --aes-key AES_KEY - - # WeChat Official Account (公众号) - python -m EvoScientist.channels.wechat.serve \\ - --backend wechatmp \\ - --app-id APP_ID \\ - --app-secret APP_SECRET \\ - --token TOKEN \\ - --aes-key AES_KEY - -Options: - --port PORT Webhook listen port (default: 9001) - --allow USER_ID Allowed sender (repeatable) - --agent Use EvoScientist agent as handler - --thinking Send thinking content as intermediate messages -""" - -import argparse -import logging - -from .channel import WeChatChannel, WeComConfig, WeChatMPConfig -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="WeChat channel server", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "--backend", - choices=["wecom", "wechatmp"], - default="wecom", - help="WeChat backend type (default: wecom)", - ) - parser.add_argument("--port", type=int, default=9001, help="Webhook port") - parser.add_argument( - "--allow", - action="append", - dest="allowed_senders", - help="Allowed sender ID (repeatable)", - ) - parser.add_argument( - "--agent", - action="store_true", - help="Use EvoScientist agent as handler", - ) - parser.add_argument( - "--thinking", - action="store_true", - help="Send thinking content (requires --agent)", - ) - - # WeCom settings - wecom = parser.add_argument_group("WeCom (企业微信)") - wecom.add_argument("--corp-id", default="", help="WeCom Corp ID") - wecom.add_argument("--agent-id", default="", help="WeCom Agent ID") - wecom.add_argument("--secret", default="", help="WeCom Secret") - - # MP settings - mp = parser.add_argument_group("WeChat Official Account (公众号)") - mp.add_argument("--app-id", default="", help="MP App ID") - mp.add_argument("--app-secret", default="", help="MP App Secret") - - # Shared settings - parser.add_argument("--token", default="", help="Callback verification token") - parser.add_argument("--aes-key", default="", help="EncodingAESKey") - parser.add_argument("--proxy", default="", help="HTTP proxy URL") - - return parser.parse_args() - - -def main(): - """Entry point.""" - args = parse_args() - allowed = set(args.allowed_senders) if args.allowed_senders else None - proxy = args.proxy or None - - if args.backend == "wecom": - config = WeComConfig( - corp_id=args.corp_id, - agent_id=args.agent_id, - secret=args.secret, - token=args.token, - encoding_aes_key=args.aes_key, - webhook_port=args.port, - allowed_senders=allowed, - proxy=proxy, - ) - else: - config = WeChatMPConfig( - app_id=args.app_id, - app_secret=args.app_secret, - token=args.token, - encoding_aes_key=args.aes_key, - webhook_port=args.port, - allowed_senders=allowed, - proxy=proxy, - ) - - send_thinking = args.thinking and args.agent - bus = MessageBus() - channel = WeChatChannel(config, backend=args.backend) - - run_standalone(channel, bus, use_agent=args.agent, send_thinking=send_thinking) - - -if __name__ == "__main__": - main() diff --git a/EvoScientist/channels/wechat/verify_server.py b/EvoScientist/channels/wechat/verify_server.py deleted file mode 100644 index bf00c66..0000000 --- a/EvoScientist/channels/wechat/verify_server.py +++ /dev/null @@ -1,175 +0,0 @@ -"""WeChat callback verification server. - -Provides a lightweight temporary HTTP server that handles the WeChat/WeCom -URL verification handshake during onboarding. This solves the chicken-and-egg -problem: WeChat requires a live server to verify the callback URL before -saving, but the main EvoScientist service isn't running during onboard. - -Usage: - server = VerifyServer(port, token, encoding_aes_key, corp_id) - await server.start() - # ... user clicks "Save" in WeCom admin console ... - # ... server auto-responds to the verification GET request ... - await server.wait_for_verify(timeout=120) - 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__) - - -class VerifyServer: - """Temporary HTTP server for WeChat/WeCom callback URL verification. - - Handles the GET verification request (signature + echostr) and - signals when verification succeeds. - """ - - def __init__( - self, - port: int, - token: str, - encoding_aes_key: str = "", - app_id: str = "", - ): - self.port = port - self.token = token - self._crypto = None - self._runner = None - self._site = None - self._verified = asyncio.Event() - - if encoding_aes_key and token and app_id: - from .crypto import WeChatCrypto - self._crypto = WeChatCrypto( - token=token, - encoding_aes_key=encoding_aes_key, - app_id=app_id, - ) - - async def start(self) -> None: - """Start the verification server.""" - from aiohttp import web - - app = web.Application() - app.router.add_get("/wechat/callback", self._handle) - # Also handle POST in case WeCom sends a POST for some reason - app.router.add_post("/wechat/callback", self._handle_post) - - self._runner = web.AppRunner(app) - await self._runner.setup() - self._site = web.TCPSite(self._runner, "0.0.0.0", self.port) - await self._site.start() - logger.info(f"Verify server listening on port {self.port}") - - async def stop(self) -> None: - """Stop the verification server.""" - if self._site: - await self._site.stop() - if self._runner: - await self._runner.cleanup() - self._site = None - self._runner = None - - async def wait_for_verify(self, timeout: float = 120) -> bool: - """Wait for verification to succeed. - - Returns True if verified within timeout, False otherwise. - """ - try: - await asyncio.wait_for(self._verified.wait(), timeout=timeout) - return True - except asyncio.TimeoutError: - return False - - @property - def is_verified(self) -> bool: - return self._verified.is_set() - - async def _handle(self, request) -> web.Response: - """Handle GET verification request. - - During onboarding we use a lenient approach: - 1. Try strict crypto verification (encrypted mode) - 2. Try strict plain-mode signature check - 3. If both fail, fall back to decrypting echostr without - signature check (WeCom requires the decrypted echostr) - 4. Last resort: echo back raw echostr - - This ensures the callback URL can be saved even if Token/AESKey - have minor issues, while still attempting proper verification. - """ - from aiohttp import web - - signature = ( - request.query.get("msg_signature") - or request.query.get("signature", "") - ) - timestamp = request.query.get("timestamp", "") - nonce = request.query.get("nonce", "") - echostr = request.query.get("echostr", "") - - logger.info( - f"Verify request: msg_signature={signature[:16]}... " - f"timestamp={timestamp} nonce={nonce} " - f"echostr={echostr[:32]}..." - ) - - if not echostr: - return web.Response(status=400, text="missing echostr") - - # Attempt 1: Encrypted mode with full signature verification - if self._crypto and request.query.get("msg_signature"): - sig_ok = self._crypto.verify_signature( - signature, timestamp, nonce, echostr, - ) - if sig_ok: - try: - plain_echostr, _ = self._crypto.decrypt(echostr) - self._verified.set() - logger.info("✓ Verified (encrypted, signature OK)") - return web.Response(text=plain_echostr) - except Exception as e: - logger.warning(f"Signature OK but decrypt failed: {e}") - else: - logger.warning("Signature mismatch, trying decrypt anyway...") - - # Attempt 2: Try decrypt without signature check - # (WeCom requires the decrypted echostr to be returned) - try: - plain_echostr, _ = self._crypto.decrypt(echostr) - self._verified.set() - logger.info("✓ Verified (decrypted, signature skipped)") - return web.Response(text=plain_echostr) - except Exception as e: - logger.warning(f"Decrypt also failed: {e}") - - # Attempt 3: Plain mode signature check - if self.token: - parts = sorted([self.token, timestamp, nonce]) - expected = hashlib.sha1("".join(parts).encode()).hexdigest() - if expected == signature: - self._verified.set() - logger.info("✓ Verified (plain mode)") - return web.Response(text=echostr) - - # Attempt 4: Last resort — just echo back the echostr - # This won't work for encrypted mode (WeCom expects decrypted), - # but works for plain mode with wrong token. - logger.warning("All verification methods failed, echoing raw echostr") - self._verified.set() - return web.Response(text=echostr) - - 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/__init__.py b/EvoScientist/cli/__init__.py index 9a42d11..87d2f2c 100644 --- a/EvoScientist/cli/__init__.py +++ b/EvoScientist/cli/__init__.py @@ -7,7 +7,7 @@ from ..stream.state import ( # noqa: F401 _parse_todo_items, _build_todo_stats, ) -from .channel import _channels_is_running, _channels_stop # noqa: F401 +from .channel import ChannelMessage, _ChannelState # noqa: F401 from .agent import _deduplicate_run_name # noqa: F401 from ._app import app # noqa: F401 diff --git a/EvoScientist/cli/_app.py b/EvoScientist/cli/_app.py index 603d094..d749701 100644 --- a/EvoScientist/cli/_app.py +++ b/EvoScientist/cli/_app.py @@ -45,7 +45,3 @@ Sub-agents (-e): planner-agent | research-agent | code-agent | debug-agent | dat """ mcp_app = typer.Typer(help=_MCP_HELP, invoke_without_command=True) app.add_typer(mcp_app, name="mcp") - -# Channel subcommand group -channel_app = typer.Typer(help="Channel management commands") -app.add_typer(channel_app, name="channel") diff --git a/EvoScientist/cli/channel.py b/EvoScientist/cli/channel.py index 78383a9..1b69c84 100644 --- a/EvoScientist/cli/channel.py +++ b/EvoScientist/cli/channel.py @@ -1,12 +1,14 @@ -"""Background channel management — bus mode with ChannelManager.""" +"""Background iMessage channel — state management, thread lifecycle, handlers.""" import asyncio import logging +import queue import threading -from typing import Any, Optional +import uuid +from dataclasses import dataclass +from typing import Any from rich.panel import Panel -from rich.table import Table from rich.text import Text from ..stream.display import console @@ -14,267 +16,213 @@ from ..stream.display import console _channel_logger = logging.getLogger(__name__) -# Module-level channel state (bus mode) -_manager: Optional[Any] = None # ChannelManager -_bus_loop: Optional[asyncio.AbstractEventLoop] = None -_bus_thread: Optional[threading.Thread] = None -_cli_agent: Any = None # shared agent reference (same as CLI) -_cli_thread_id: Optional[str] = None # shared thread_id (same conversation) +@dataclass +class ChannelMessage: + """Message from a channel (iMessage, Email, etc.).""" + msg_id: str + content: str + sender: str + channel_type: str # "iMessage", "Email", "Slack" + metadata: Any = None -def _channels_is_running(channel_type: str | None = None) -> bool: - """Check whether channels are running.""" - if _manager is None: - return False - if channel_type: - ch = _manager.get_channel(channel_type) - return ch is not None and ch._running - return _manager.is_running and bool(_manager.running_channels()) +class _ChannelState: + """Singleton tracking background iMessage channel and message queue.""" + + server = None # IMessageServer | None + thread = None # threading.Thread | None + loop = None # asyncio.AbstractEventLoop | None + agent = None # shared agent reference (same as CLI) + thread_id = None # shared thread_id (same conversation as CLI) + + # Queue-based communication between channel thread and main CLI thread + message_queue: queue.Queue = queue.Queue() + pending_responses: dict = {} # msg_id -> {"event": Event, "response": str | None} + _response_lock = threading.Lock() + + @classmethod + def is_running(cls) -> bool: + return cls.thread is not None and cls.thread.is_alive() + + @classmethod + def stop(cls): + if cls.loop and cls.server: + cls.loop.call_soon_threadsafe( + lambda: asyncio.ensure_future(cls.server.stop()) + ) + if cls.thread: + cls.thread.join(timeout=5) + cls.server = None + cls.thread = None + cls.loop = None + cls.agent = None + cls.thread_id = None + # Clear pending responses + with cls._response_lock: + for slot in cls.pending_responses.values(): + slot["event"].set() # Unblock any waiting handlers + cls.pending_responses.clear() + + @classmethod + def enqueue( + cls, + content: str, + sender: str, + channel_type: str, + metadata: Any = None, + ) -> tuple[str, threading.Event]: + """Enqueue a message from any channel for main thread processing. + + Returns: + Tuple of (msg_id, event) - caller can wait on event for response. + """ + msg_id = str(uuid.uuid4()) + event = threading.Event() + with cls._response_lock: + cls.pending_responses[msg_id] = {"event": event, "response": None} + cls.message_queue.put(ChannelMessage(msg_id, content, sender, channel_type, metadata)) + return msg_id, event + + @classmethod + def set_response(cls, msg_id: str, response: str) -> None: + """Set response and signal completion.""" + with cls._response_lock: + if msg_id in cls.pending_responses: + cls.pending_responses[msg_id]["response"] = response + cls.pending_responses[msg_id]["event"].set() + + @classmethod + def get_response(cls, msg_id: str, timeout: float = 300) -> str | None: + """Wait for and retrieve response. + + Args: + msg_id: The message ID to get response for. + timeout: Maximum seconds to wait (default 300 = 5 minutes). + + Returns: + The response text, or None if timed out or not found. + """ + with cls._response_lock: + slot = cls.pending_responses.get(msg_id) + if not slot: + return None + if slot["event"].wait(timeout=timeout): + with cls._response_lock: + return cls.pending_responses.pop(msg_id, {}).get("response") + return None -def _channels_running_list() -> list[str]: - """Return names of running channels.""" - return _manager.running_channels() if _manager else [] +def _run_channel_thread(server): + """Entry point for background channel thread.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + _ChannelState.loop = loop + try: + loop.run_until_complete(server.run()) + except Exception as e: + _channel_logger.error(f"Channel error: {e}") + finally: + loop.close() -def _channels_stop(channel_type: str | None = None) -> None: - """Stop channel(s) and clean up module-level state.""" - global _manager, _bus_loop, _bus_thread, _cli_agent, _cli_thread_id +def _create_channel_handler(): + """Create iMessage handler that enqueues messages for main thread processing. - if channel_type is None: - # Stop everything - if _bus_loop and _manager: - try: - future = asyncio.run_coroutine_threadsafe( - _manager.stop_all(), _bus_loop, - ) - future.result(timeout=10) - except Exception: - pass - if _manager: - _manager.bus.stop() - if _bus_thread: - _bus_thread.join(timeout=5) - _manager = None - _bus_loop = None - _bus_thread = None - _cli_agent = None - _cli_thread_id = None + The handler enqueues messages to the shared queue and waits for the main + CLI thread to process them with full Rich Live streaming. This ensures + channel messages get the same display quality as direct CLI input. + + Returns: + Async handler function: (msg) -> str + """ + + async def handler(msg) -> str: + # Enqueue for main thread to process with full Live streaming + msg_id, event = _ChannelState.enqueue( + content=msg.content, + sender=msg.sender, + channel_type="iMessage", + metadata=msg.metadata, + ) + + # Wait indefinitely for main thread to process and set response + # (no timeout - let the agent work as long as needed) + await asyncio.to_thread(event.wait) + + # Get the response + with _ChannelState._response_lock: + response = _ChannelState.pending_responses.pop(msg_id, {}).get("response", "") + + return response if response else "(empty response)" + + return handler + + +def _cmd_channel(args: str, agent: Any, thread_id: str) -> None: + """Start iMessage channel in background thread using the shared agent. + + CLI and iMessage share the same agent + thread_id (same conversation). + When an iMessage arrives, the main CLI thread processes it with full + Rich Live streaming — same experience as direct CLI input. + + Usage: /channel [--allow SENDER] + """ + from ..channels.imessage import IMessageConfig + from ..channels.imessage.serve import IMessageServer + + if _ChannelState.is_running(): + console.print("[dim]iMessage channel already running[/dim]") + console.print("[dim]Use[/dim] /channel stop [dim]to disconnect[/dim]\n") return - # Stop a specific channel - if _manager and _bus_loop: - try: - future = asyncio.run_coroutine_threadsafe( - _manager.remove_channel(channel_type), _bus_loop, - ) - future.result(timeout=5) - except Exception: - pass + parts = args.split() if args else [] + allowed = set() - if _manager and not _manager.running_channels(): - _cli_agent = None - _cli_thread_id = None + for i, p in enumerate(parts): + if p == "--allow" and i + 1 < len(parts): + allowed.add(parts[i + 1]) - -def _start_channels_bus_mode(config, agent, thread_id: str, show_thinking: bool = True) -> None: - """Start all channels in bus mode with MessageBus + ChannelManager. - - Creates a single event loop in a daemon thread running the bus, - ChannelManager, and the inbound consumer. - """ - global _manager, _bus_loop, _bus_thread - - from ..channels.channel_manager import ChannelManager - - mgr = ChannelManager.from_config(config) - - if show_thinking: - for channel in mgr._channels.values(): - channel.send_thinking = True - - _manager = mgr - - def _bus_thread_entry(): - global _bus_loop - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - _bus_loop = loop - - async def _run(): - consumer = asyncio.create_task( - _bus_inbound_consumer(mgr.bus, mgr, agent, thread_id, show_thinking) - ) - try: - await mgr.start_all() - finally: - consumer.cancel() - try: - await consumer - except asyncio.CancelledError: - pass - - try: - loop.run_until_complete(_run()) - except Exception as e: - _channel_logger.error(f"Bus thread error: {e}") - finally: - loop.close() - - thread = threading.Thread(target=_bus_thread_entry, daemon=True) - _bus_thread = thread - thread.start() - - # Wait briefly for the loop to start - import time - for _ in range(20): - if _bus_loop is not None: - break - time.sleep(0.1) - - -def _add_channel_to_running_bus(channel_type: str, config) -> None: - """Dynamically add a single channel to the already-running bus. - - Raises: - RuntimeError: If the bus loop or manager is not initialised. - ValueError: If the channel type is unknown or already registered. - """ - if not _manager or not _bus_loop: - raise RuntimeError("Bus not initialised") - - async def _do_add(): - channel = await _manager.add_channel(channel_type, config) - channel.send_thinking = True - - future = asyncio.run_coroutine_threadsafe(_do_add(), _bus_loop) - future.result(timeout=10) - - -async def _bus_inbound_consumer( - bus, manager, agent, thread_id: str, show_thinking: bool = True, -) -> None: - """Core bridge: consume inbound messages from bus and run agent. - - Streams agent events on the bus loop with Rich Live real-time display - (identical to interactive CLI) and sends thinking / todo / answer to - the originating channel via direct ``await`` calls. - """ - from ..stream import events as _stream_events_mod - from ..stream.display import ( - console, create_streaming_display, + config = IMessageConfig( + allowed_senders=list(allowed) if allowed else [], ) - from ..stream.state import StreamState - from ..channels.consumer import _format_todo_list - from ..channels.bus.events import OutboundMessage - from rich.live import Live - from rich.text import Text as _Text - def _print_separator(): - width = console.size.width - console.print(_Text("\u2500" * width, style="dim")) + # Store shared agent reference — no separate agent creation + _ChannelState.agent = agent + _ChannelState.thread_id = thread_id - while True: - try: - msg = await asyncio.wait_for(bus.consume_inbound(), timeout=1.0) - except asyncio.TimeoutError: - continue - except asyncio.CancelledError: - break + # Read send_thinking preference from config + from ..config import load_config as _load_config + send_thinking = _load_config().imessage_send_thinking - _channel_logger.info( - f"[bus] Processing from {msg.channel}:{msg.sender_id}: " - f"{msg.content[:60]}..." - ) - manager.record_message(msg.channel, "received") + server = IMessageServer( + config, + handler=_create_channel_handler(), + send_thinking=send_thinking, + ) - # CLI: show query from channel (mirrors interactive prompt) - source_label = _Text() - source_label.append(f"[{msg.channel}] ", style="cyan bold") - source_label.append(msg.content) - console.print(source_label) + _ChannelState.server = server + _ChannelState.thread = threading.Thread( + target=_run_channel_thread, + args=(server,), + daemon=True, + ) + _ChannelState.thread.start() - channel = manager.get_channel(msg.channel) - state = StreamState() - thinking_sent = False - todo_sent = False + console.print("[green]iMessage channel running in background[/green]") + if allowed: + console.print(f"[dim]Allowed:[/dim] {allowed}") + else: + console.print("[dim]Allowed: all senders[/dim]") + console.print("[dim]Use[/dim] /channel stop [dim]to disconnect[/dim]\n") - if channel: - await channel.start_typing(msg.chat_id) - try: - with Live(console=console, refresh_per_second=10, transient=False) as live: - live.update(create_streaming_display(is_waiting=True)) - - async for event in _stream_events_mod.stream_agent_events( - agent, msg.content, thread_id, - ): - etype = state.handle_event(event) - - # Channel: send thinking on transition - if (etype != "thinking" - and not thinking_sent - and state.thinking_text): - if channel and show_thinking: - await channel.send_thinking_message( - msg.sender_id, state.thinking_text, msg.metadata, - ) - thinking_sent = True - - # Channel: send todo list - if (etype == "tool_call" - and event.get("name") == "write_todos" - and not todo_sent - and state.todo_items): - if channel: - await channel.send_todo_message( - msg.sender_id, - _format_todo_list(state.todo_items), - msg.metadata, - ) - todo_sent = True - - # CLI: Live update - live.update(create_streaming_display( - **state.get_display_args(), - show_thinking=show_thinking, - )) - if etype in ( - "tool_call", "tool_result", - "subagent_start", "subagent_tool_call", - "subagent_tool_result", "subagent_end", - ): - live.refresh() - - # Flush remaining thinking - if (not thinking_sent - and state.thinking_text): - if channel and show_thinking: - await channel.send_thinking_message( - msg.sender_id, state.thinking_text, msg.metadata, - ) - - # Channel: publish answer - await bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=state.response_text or "No response", - reply_to=msg.message_id or None, - metadata=msg.metadata, - )) - manager.record_message(msg.channel, "sent") - console.print(_Text("> ", style="blue bold"), end="") - except Exception as e: - _channel_logger.error(f"[bus] Agent error: {e}") - await bus.publish_outbound(OutboundMessage( - channel=msg.channel, - chat_id=msg.chat_id, - content=f"Error processing message: {e}", - metadata=msg.metadata, - )) - finally: - if channel: - await channel.stop_typing(msg.chat_id) +def _cmd_channel_stop() -> None: + """Stop background iMessage channel.""" + if not _ChannelState.is_running(): + console.print("[dim]No channel running[/dim]\n") + return + _ChannelState.stop() + console.print("[dim]iMessage channel stopped[/dim]\n") def _print_channel_panel(channels: list[tuple[str, bool, str]]) -> None: @@ -304,124 +252,38 @@ def _print_channel_panel(channels: list[tuple[str, bool, str]]) -> None: console.print() -def _cmd_channel(args: str, agent: Any, thread_id: str) -> None: - """Start a channel in background using bus mode. - - Usage: - /channel [telegram|discord|imessage] -- start channel (default from config) - /channel status -- show current channel status - /channel stop -- stop running channel - """ - global _cli_agent, _cli_thread_id - - from ..config import load_config - app_config = load_config() - - channel_type = args.strip().lower() if args and args.strip() else "" - if channel_type == "status": - running = _channels_running_list() - if running and _manager: - detailed = _manager.get_detailed_status() - table = Table(title="Channel Status", show_header=True, expand=False) - table.add_column("Channel", style="cyan") - table.add_column("Status") - table.add_column("Uptime", style="dim") - table.add_column("Rx", justify="right") - table.add_column("Tx", justify="right") - for ch_name in running: - info = detailed.get(ch_name, {}) - secs = info.get("uptime_seconds", 0) - mins, s = divmod(int(secs), 60) - hours, mins = divmod(mins, 60) - uptime = f"{hours}h{mins:02d}m" if hours else f"{mins}m{s:02d}s" - rx = str(info.get("received", 0)) - tx = str(info.get("sent", 0)) - table.add_row(ch_name, "[green]running[/green]", uptime, rx, tx) - console.print(table) - console.print() - else: - console.print("[dim]No channel running[/dim]\n") - return - - if not channel_type: - channel_type = app_config.channel_enabled - if not channel_type: - console.print("[yellow]No channel configured.[/yellow]") - console.print("[dim]Run[/dim] evosci onboard [dim]or specify:[/dim] /channel telegram\n") - return - - requested = [t.strip() for t in channel_type.split(",") if t.strip()] - - if _channels_is_running(): - running = _channels_running_list() - results: list[tuple[str, bool, str]] = [] - for ct in requested: - if ct in running: - results.append((ct, True, "already running")) - else: - try: - _add_channel_to_running_bus(ct, app_config) - results.append((ct, True, "connected (bus)")) - except Exception as e: - results.append((ct, False, str(e))) - _print_channel_panel(results) - return - - _cli_agent = agent - _cli_thread_id = thread_id - - # Override channel_enabled for this invocation - original = app_config.channel_enabled - app_config.channel_enabled = channel_type - try: - _start_channels_bus_mode(app_config, agent, thread_id) - results = [(ct, True, "connected (bus)") for ct in requested] - except Exception as e: - results = [(ct, False, str(e)) for ct in requested] - finally: - app_config.channel_enabled = original - - _print_channel_panel(results) - - -def _cmd_channel_stop(channel_type: str | None = None) -> None: - """Stop background channel(s). - - Args: - channel_type: Specific channel to stop, or None to stop all. - """ - if not _channels_is_running(): - console.print("[dim]No channel running[/dim]\n") - return - if channel_type: - if not _channels_is_running(channel_type): - console.print(f"[dim]{channel_type} is not running[/dim]\n") - return - _channels_stop(channel_type) - console.print(f"[dim]{channel_type} stopped[/dim]\n") - else: - running = _channels_running_list() - _channels_stop() - console.print(f"[dim]{', '.join(running)} stopped[/dim]\n") - - -def _auto_start_channel(agent: Any, thread_id: str, config) -> None: - """Start channels automatically from config (bus mode). +def _auto_start_channel(agent: Any, thread_id: str, allowed_senders_csv: str, send_thinking: bool = True) -> None: + """Start iMessage channel automatically from config. Args: agent: Compiled agent graph. thread_id: Current thread ID. - config: EvoScientistConfig with channel settings. + allowed_senders_csv: Comma-separated allowed senders (empty = all). + send_thinking: Whether to forward thinking content to channel. """ - global _cli_agent, _cli_thread_id + try: + from ..channels.imessage import IMessageConfig + from ..channels.imessage.serve import IMessageServer - if not config.channel_enabled: - return + allowed: set[str] | None = None + if allowed_senders_csv.strip(): + allowed = {s.strip() for s in allowed_senders_csv.split(",") if s.strip()} - _cli_agent = agent - _cli_thread_id = thread_id + config = IMessageConfig(allowed_senders=list(allowed) if allowed else []) - _start_channels_bus_mode(config, agent, thread_id) - types = [t.strip() for t in config.channel_enabled.split(",") if t.strip()] - results = [(ct, True, "connected (bus)") for ct in types] - _print_channel_panel(results) + _ChannelState.agent = agent + _ChannelState.thread_id = thread_id + + server = IMessageServer(config, handler=_create_channel_handler(), send_thinking=send_thinking) + _ChannelState.server = server + _ChannelState.thread = threading.Thread( + target=_run_channel_thread, + args=(server,), + daemon=True, + ) + _ChannelState.thread.start() + + detail = ", ".join(sorted(allowed)) if allowed else "all senders" + _print_channel_panel([("iMessage", True, detail)]) + except Exception as e: + _print_channel_panel([("iMessage", False, str(e))]) diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 43c2624..3c1d468 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -11,10 +11,9 @@ import typer # type: ignore[import-untyped] from rich.table import Table from ..stream.display import console -from ..paths import ensure_dirs, default_workspace_dir, set_workspace_root -from ._app import app, config_app, mcp_app, channel_app -from .agent import _deduplicate_run_name, _create_session_workspace, _load_agent, _shorten_path -from .channel import _channels_stop, _start_channels_bus_mode +from ..paths import ensure_dirs, set_workspace_root +from ._app import app, config_app, mcp_app +from .agent import _deduplicate_run_name, _create_session_workspace, _load_agent from .mcp_ui import ( _mcp_list_servers, _mcp_add_server_from_kwargs, @@ -46,100 +45,6 @@ def onboard( run_onboard(skip_validation=skip_validation) -# ============================================================================= -# Channel setup command -# ============================================================================= - -@channel_app.command("setup") -def channel_setup(): - """Interactive channel configuration wizard. - - Guides you through selecting and configuring a messaging channel - (Telegram, Discord, or iMessage). - """ - import asyncio - try: - asyncio.get_event_loop() - except RuntimeError: - asyncio.set_event_loop(asyncio.new_event_loop()) - - from ..config import load_config, save_config - from ..config.onboard import _step_channels - - config = load_config() - updates = _step_channels(config) - if updates: - for key, value in updates.items(): - setattr(config, key, value) - save_config(config) - console.print("[green]Channel configuration saved.[/green]") - else: - console.print("[dim]No changes made.[/dim]") - - -# ============================================================================= -# Serve command (headless mode) -# ============================================================================= - -@app.command() -def serve( - no_thinking: bool = typer.Option(False, "--no-thinking", help="Disable thinking relay to channels"), - workdir: Optional[str] = typer.Option(None, "--workdir", help="Override workspace directory"), -): - """Run EvoScientist in headless mode -- channels only, no interactive prompt. - - Starts all configured channels and processes messages via the agent. - Press Ctrl+C to shut down. - """ - import nest_asyncio # type: ignore[import-untyped] - import uuid - nest_asyncio.apply() - - from dotenv import load_dotenv, find_dotenv # type: ignore[import-untyped] - load_dotenv(find_dotenv(), override=True) - - from ..config import get_effective_config, apply_config_to_env - - config = get_effective_config() - apply_config_to_env(config) - - if not config.channel_enabled: - console.print("[red]No channels configured.[/red]") - console.print("[dim]Run [bold]evosci channel setup[/bold] first.[/dim]") - raise typer.Exit(1) - - show_thinking = not no_thinking - ensure_dirs() - - if workdir: - ws = os.path.abspath(os.path.expanduser(workdir)) - os.makedirs(ws, exist_ok=True) - else: - ws = str(default_workspace_dir()) - os.makedirs(ws, exist_ok=True) - - console.print("[dim]Loading agent...[/dim]") - agent = _load_agent(workspace_dir=ws) - tid = str(uuid.uuid4()) - - _start_channels_bus_mode(config, agent, tid, show_thinking) - console.print("[green]Serve mode started (bus mode).[/green]") - - console.print(f"[dim]Thread: {tid}[/dim]") - console.print(f"[dim]Workspace: {_shorten_path(ws)}[/dim]") - console.print("[dim]Press Ctrl+C to stop.[/dim]\n") - - import time - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - console.print("\n[dim]Shutting down...[/dim]") - finally: - _channels_stop() - console.print("[dim]Stopped.[/dim]") - - # ============================================================================= # Config commands # ============================================================================= @@ -530,6 +435,9 @@ def _main_callback( mode=effective_mode, model=config.model, provider=config.provider, + imessage_enabled=config.imessage_enabled, + imessage_allowed_senders=config.imessage_allowed_senders, + imessage_send_thinking=config.imessage_send_thinking, run_name=name, thread_id=thread_id, ) diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index ecaf323..8544221 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -2,6 +2,7 @@ import asyncio import os +import queue import sys from datetime import datetime, timezone from typing import Any @@ -32,12 +33,12 @@ from ..sessions import ( from ..stream.display import console, _run_streaming from .agent import _shorten_path, _create_session_workspace, _load_agent from .channel import ( - _channels_is_running, + ChannelMessage, + _ChannelState, _cmd_channel, _cmd_channel_stop, _auto_start_channel, ) -import EvoScientist.cli.channel as _ch_mod from .mcp_ui import _cmd_mcp from .skills_cmd import _cmd_list_skills, _cmd_install_skill, _cmd_uninstall_skill @@ -176,6 +177,9 @@ def cmd_interactive( mode: str | None = None, model: str | None = None, provider: str | None = None, + imessage_enabled: bool = False, + imessage_allowed_senders: str = "", + imessage_send_thinking: bool = True, run_name: str | None = None, thread_id: str | None = None, ) -> None: @@ -191,6 +195,9 @@ def cmd_interactive( mode: Workspace mode ('daemon' or 'run'), displayed in banner model: Model name to display in banner provider: LLM provider name to display in banner + imessage_enabled: Whether to auto-start iMessage channel + imessage_allowed_senders: Comma-separated allowed senders + imessage_send_thinking: Whether to forward thinking to channel run_name: Optional run name for /new session deduplication thread_id: Optional thread ID to resume a previous session """ @@ -224,6 +231,106 @@ def cmd_interactive( "resumed": False, } + def _process_channel_message(msg: ChannelMessage) -> None: + """Process a message from a channel with full Live streaming.""" + # Move past the current prompt line to avoid interference with prompt_toolkit + # Then move back up and clear that line + sys.stdout.write("\n\033[A\033[2K\r") + sys.stdout.flush() + # Display prompt with channel source on second line + console.print(f"[bold blue]>[/bold blue] {msg.content}") + console.print(Text.assemble( + ("[", "dim"), + (f"{msg.channel_type}: Received from ", "dim"), + (msg.sender, "cyan"), + ("]", "dim"), + )) + _print_separator() + console.print() + + # Build channel callbacks for intermediate messages (thinking + todo + files) + on_thinking = None + on_todo = None + on_file_write = None + if _ChannelState.is_running() and _ChannelState.server and _ChannelState.loop: + def _send_thinking(thinking_text: str) -> None: + try: + asyncio.run_coroutine_threadsafe( + _ChannelState.server.send_thinking_message( + msg.sender, thinking_text, msg.metadata, + ), + _ChannelState.loop, + ) + except Exception: + pass # Non-critical — don't break main flow + + def _send_todo(todo_items: list) -> None: + try: + lines = [f"\U0001f4cb {len(todo_items)} tasks ongoing"] # 📋 + for i, item in enumerate(todo_items, 1): + content = item.get("content", "") + lines.append(f"{i}. {content}") + lines.append("\U0001f680") # 🚀 + formatted = "\n".join(lines) + asyncio.run_coroutine_threadsafe( + _ChannelState.server.send_todo_message( + msg.sender, formatted, msg.metadata, + ), + _ChannelState.loop, + ) + except Exception: + pass # Non-critical — don't break main flow + + def _send_file(real_path: str) -> None: + try: + asyncio.run_coroutine_threadsafe( + _ChannelState.server.channel.send_media( + recipient=msg.sender, file_path=real_path, + metadata=msg.metadata, + ), + _ChannelState.loop, + ) + except Exception: + pass # Non-critical — don't break main flow + + on_thinking = _send_thinking + on_todo = _send_todo + on_file_write = _send_file + + try: + meta = _build_metadata(state["workspace_dir"], model) + # Use SAME _run_streaming as CLI input — full Live experience + response_text = _run_streaming( + state["agent"], msg.content, state["thread_id"], show_thinking, + interactive=True, on_thinking=on_thinking, on_todo=on_todo, + on_file_write=on_file_write, metadata=meta, + ) + + # Set response for channel handler to retrieve + _ChannelState.set_response(msg.msg_id, response_text or "") + # Show replied indicator + console.print(Text.assemble( + ("[", "dim"), + (f"{msg.channel_type}: Replied to ", "dim"), + (msg.sender, "cyan"), + ("]", "dim"), + )) + except Exception as e: + console.print(f"[red]Channel processing error: {e}[/red]") + _ChannelState.set_response(msg.msg_id, f"Error: {e}") + + _print_separator() + + async def _check_channel_queue(): + """Background task to check channel queue periodically.""" + while state["running"]: + try: + msg = _ChannelState.message_queue.get_nowait() + _process_channel_message(msg) + except queue.Empty: + pass + await asyncio.sleep(0.1) # Check every 100ms + async def _resolve_thread_id(tid: str) -> str | None: """Resolve a (possibly partial) thread ID. Returns full ID or None.""" if await thread_exists(tid): @@ -379,9 +486,9 @@ def cmd_interactive( console.print("[dim]Loading session...[/dim]") state["agent"] = _load_agent(workspace_dir=state["workspace_dir"], checkpointer=checkpointer) # Sync shared refs if channel is running - if _channels_is_running(): - _ch_mod._cli_agent = state["agent"] - _ch_mod._cli_thread_id = state["thread_id"] + if _ChannelState.is_running(): + _ChannelState.agent = state["agent"] + _ChannelState.thread_id = state["thread_id"] console.print(f"[green]Resumed session:[/green] [yellow]{resolved}[/yellow]") if state["workspace_dir"]: console.print(f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]") @@ -430,13 +537,11 @@ def cmd_interactive( print_banner(state["thread_id"], state["workspace_dir"], memory_dir, mode, model, provider) # Start background queue checker - # (no longer needed — bus mode handles messages internally) + queue_task = asyncio.create_task(_check_channel_queue()) - # Auto-start channel if enabled in config - from ..config import load_config - config = load_config() - if config and config.channel_enabled and not _channels_is_running(): - _auto_start_channel(state["agent"], state["thread_id"], config) + # Auto-start iMessage channel if enabled in config + if imessage_enabled and not _ChannelState.is_running(): + _auto_start_channel(state["agent"], state["thread_id"], imessage_allowed_senders, imessage_send_thinking) try: _print_separator() @@ -483,6 +588,10 @@ 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") @@ -517,9 +626,8 @@ def cmd_interactive( if user_input.lower().startswith("/channel"): args = user_input[len("/channel"):].strip() - if args.lower().startswith("stop"): - stop_arg = args[len("stop"):].strip() - _cmd_channel_stop(stop_arg or None) + if args.lower() == "stop": + _cmd_channel_stop() else: _cmd_channel(args, state["agent"], state["thread_id"]) continue @@ -552,7 +660,11 @@ def cmd_interactive( else: console.print(f"[red]Error: {e}[/red]") finally: - pass + queue_task.cancel() + try: + await queue_task + except asyncio.CancelledError: + pass # Run the async main loop try: diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 134ef5c..7af786c 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -85,101 +85,9 @@ class EvoScientistConfig: show_thinking: bool = True # Channel Settings - channel_enabled: str = "" # "imessage" | "telegram" | "discord" | "" (comma-separated for multiple) - require_mention: str = "group" # "always" | "group" | "off" - text_chunk_limit: int = 0 # 0 = use capability default - allowed_channels: str = "" # comma-separated channel IDs, empty = allow all - - # iMessage Settings - imessage_enabled: bool = False # legacy compat - imessage_allowed_senders: str = "" - imessage_send_thinking: bool = True - - # Telegram Settings - telegram_bot_token: str = "" - telegram_allowed_senders: str = "" - telegram_proxy: str = "" - - # Discord Settings - discord_bot_token: str = "" - discord_allowed_senders: str = "" - discord_allowed_channels: str = "" - discord_proxy: str = "" - - # Slack Settings - slack_bot_token: str = "" - slack_app_token: str = "" - slack_allowed_senders: str = "" - slack_allowed_channels: str = "" - slack_proxy: str = "" - - # Feishu Settings - feishu_app_id: str = "" - feishu_app_secret: str = "" - feishu_verification_token: str = "" - feishu_encrypt_key: str = "" - feishu_webhook_port: int = 9000 - feishu_allowed_senders: str = "" - feishu_domain: str = "https://open.feishu.cn" - feishu_proxy: str = "" - - # WeChat Settings - wechat_backend: str = "wecom" - wechat_webhook_port: int = 9001 - wechat_allowed_senders: str = "" - wechat_proxy: str = "" - wechat_wecom_corp_id: str = "" - wechat_wecom_agent_id: str = "" - wechat_wecom_secret: str = "" - wechat_wecom_token: str = "" - wechat_wecom_encoding_aes_key: str = "" - wechat_mp_app_id: str = "" - wechat_mp_app_secret: str = "" - wechat_mp_token: str = "" - wechat_mp_encoding_aes_key: str = "" - - # DingTalk Settings - dingtalk_client_id: str = "" - dingtalk_client_secret: str = "" - dingtalk_allowed_senders: str = "" - dingtalk_proxy: str = "" - - # Email Settings - email_imap_host: str = "" - email_imap_port: int = 993 - email_imap_username: str = "" - email_imap_password: str = "" - email_imap_mailbox: str = "INBOX" - email_imap_use_ssl: bool = True - email_smtp_host: str = "" - email_smtp_port: int = 587 - email_smtp_username: str = "" - email_smtp_password: str = "" - email_smtp_use_tls: bool = True - email_from_address: str = "" - email_poll_interval: int = 30 - email_mark_seen: bool = True - email_max_body_chars: int = 12000 - email_subject_prefix: str = "Re: " - email_allowed_senders: str = "" - - # QQ Settings - qq_app_id: str = "" - qq_app_secret: str = "" - qq_allowed_senders: str = "" - - # Signal Settings - signal_phone_number: str = "" - signal_cli_path: str = "signal-cli" - signal_config_dir: str = "" - signal_allowed_senders: str = "" - signal_rpc_port: int = 7583 - - # Shared webhook port (0 = disabled) - shared_webhook_port: int = 9000 - - # DM access control policy - dm_policy: str = "allowlist" + imessage_enabled: bool = False + imessage_allowed_senders: str = "" # comma-separated, empty = allow all + imessage_send_thinking: bool = True # forward thinking to channel # ============================================================================= diff --git a/EvoScientist/middleware/__init__.py b/EvoScientist/middleware/__init__.py index b95208e..45c07e2 100644 --- a/EvoScientist/middleware/__init__.py +++ b/EvoScientist/middleware/__init__.py @@ -4,8 +4,6 @@ Re-exports middleware classes and factory functions so that existing ``from EvoScientist.middleware import X`` imports continue to work. """ -from deepagents.middleware.skills import SkillsMiddleware - from .memory import ( EvoMemoryMiddleware, EvoMemoryState, @@ -13,30 +11,9 @@ from .memory import ( create_memory_middleware, ) - -def create_skills_middleware(composite_backend) -> SkillsMiddleware: - """Create a SkillsMiddleware that loads skills. - - Uses the CompositeBackend directly so that skill paths in the system - prompt match the ``/skills/`` route (e.g. ``/skills/find-skills/SKILL.md``). - - Args: - composite_backend: The CompositeBackend that routes ``/skills/`` to - the MergedReadOnlyBackend. - - Returns: - Configured SkillsMiddleware instance - """ - return SkillsMiddleware( - backend=composite_backend, - sources=["/skills/"], - ) - - __all__ = [ "EvoMemoryMiddleware", "EvoMemoryState", "ExtractedMemory", "create_memory_middleware", - "create_skills_middleware", ] diff --git a/EvoScientist/prompts.py b/EvoScientist/prompts.py index a96b71f..a20288c 100644 --- a/EvoScientist/prompts.py +++ b/EvoScientist/prompts.py @@ -279,7 +279,3 @@ def get_system_prompt(max_concurrent: int = 3, max_iterations: int = 3) -> str: max_iterations=max_iterations, ) return EXPERIMENT_WORKFLOW + "\n" + delegation - - -# Default export (backward compatible) -SYSTEM_PROMPT = get_system_prompt() diff --git a/EvoScientist/stream/emitter.py b/EvoScientist/stream/emitter.py index b6bb2bc..85e9e63 100644 --- a/EvoScientist/stream/emitter.py +++ b/EvoScientist/stream/emitter.py @@ -86,7 +86,7 @@ class StreamEventEmitter: @staticmethod def done(response: str = "") -> StreamEvent: """Done event.""" - return StreamEvent("done", {"type": "done", "content": response, "response": response}) + return StreamEvent("done", {"type": "done", "response": response}) @staticmethod def error(message: str) -> StreamEvent: diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py index cc96936..97a6d9a 100644 --- a/EvoScientist/stream/events.py +++ b/EvoScientist/stream/events.py @@ -4,9 +4,6 @@ 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] @@ -67,7 +64,6 @@ 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. @@ -79,7 +75,6 @@ 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, @@ -252,41 +247,9 @@ 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": user_content}]}, + {"messages": [{"role": "user", "content": message}]}, config=config, stream_mode="messages", subgraphs=True, diff --git a/EvoScientist/stream/formatter.py b/EvoScientist/stream/formatter.py index 8035482..a42529c 100644 --- a/EvoScientist/stream/formatter.py +++ b/EvoScientist/stream/formatter.py @@ -68,14 +68,10 @@ class ToolResultFormatter: return ContentType.TEXT - def is_success(self, content: str) -> bool: - """Check if content indicates successful execution.""" - return _is_success(content) - def format(self, name: str, content: str, max_length: int = 800) -> FormattedResult: """Format tool result based on detected content type.""" content_type = self.detect_type(content) - success = self.is_success(content) + success = _is_success(content) formatter_map = { ContentType.SUCCESS: self._format_success, diff --git a/EvoScientist/tools/__init__.py b/EvoScientist/tools/__init__.py index c7a5008..b11de60 100644 --- a/EvoScientist/tools/__init__.py +++ b/EvoScientist/tools/__init__.py @@ -6,13 +6,11 @@ to work unchanged thanks to these re-exports. from .search import tavily_search, fetch_webpage_content from .think import think_tool -from .image import view_image from .skill_manager import skill_manager __all__ = [ "tavily_search", "fetch_webpage_content", "think_tool", - "view_image", "skill_manager", ] diff --git a/EvoScientist/tools/image.py b/EvoScientist/tools/image.py deleted file mode 100644 index 75683d8..0000000 --- a/EvoScientist/tools/image.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Image viewing tool.""" - -import base64 -import mimetypes -import os - -from langchain_core.tools import tool - -from ..paths import resolve_virtual_path - -# Supported image extensions and their MIME types -_IMAGE_EXTENSIONS = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".svg": "image/svg+xml", -} - -# Max file size for image viewing (5MB) -_MAX_IMAGE_SIZE = 5 * 1024 * 1024 - - -@tool(parse_docstring=True) -def view_image(image_path: str) -> "list | str": - """View and analyze an image file. - - Use this tool when you need to see the visual content of an image file - (PNG, JPEG, GIF, WebP). The image will be displayed so you can describe, - analyze, or answer questions about it. - - Note: Use this instead of read_file for image files. read_file only - returns binary data, while view_image lets you actually see the image. - - Args: - image_path: Path to the image file (relative to workspace or absolute) - - Returns: - Image content blocks that the model can visually process - """ - # Resolve virtual workspace paths: /image.png → {workspace}/image.png - resolved = image_path - if not os.path.isfile(resolved): - resolved = str(resolve_virtual_path(image_path)) - - if not os.path.isfile(resolved): - return f"Error: File not found: {image_path}" - image_path = resolved - - ext = os.path.splitext(image_path)[1].lower() - mime_type = _IMAGE_EXTENSIONS.get(ext) - if not mime_type: - # Fallback to mimetypes module - mime_type, _ = mimetypes.guess_type(image_path) - if not mime_type or not mime_type.startswith("image/"): - return f"Error: Not a supported image format: {ext}" - - file_size = os.path.getsize(image_path) - if file_size > _MAX_IMAGE_SIZE: - size_mb = file_size / (1024 * 1024) - return f"Error: Image too large ({size_mb:.1f}MB). Max is 5MB." - - with open(image_path, "rb") as f: - data = base64.b64encode(f.read()).decode("ascii") - - size_kb = file_size / 1024 - filename = os.path.basename(image_path) - - return [ - {"type": "text", "text": f"Image: {filename} ({size_kb:.0f}KB, {mime_type})"}, - {"type": "image", "base64": data, "mime_type": mime_type}, - ] diff --git a/EvoScientist/utils.py b/EvoScientist/utils.py index fce47ec..6a105c6 100644 --- a/EvoScientist/utils.py +++ b/EvoScientist/utils.py @@ -78,10 +78,6 @@ def format_messages(messages): console.print(Panel(content, title=f"📝 {msg_type}", border_style="white")) -def format_message(messages): - """Alias for format_messages for backward compatibility.""" - return format_messages(messages) - def show_prompt(prompt_text: str, title: str = "Prompt", border_style: str = "blue"): """Display a prompt with rich formatting and XML tag highlighting. diff --git a/README.md b/README.md index e003373..1a1108a 100644 --- a/README.md +++ b/README.md @@ -473,15 +473,6 @@ We thank the authors for their valuable contributions to the open-source communi Dinos Papakostas - - - Ziheng Zhang -
- Ziheng Zhang -
- diff --git a/pyproject.toml b/pyproject.toml index 59f7bde..14aeff1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,17 +44,6 @@ dev = [ "ruff>=0.5", "build>=1.0", ] -telegram = ["python-telegram-bot>=21.0"] -discord = ["discord.py>=2.3"] -slack = ["slack-sdk>=3.27", "aiohttp>=3.9"] -wechat = ["pycryptodome>=3.20"] -all-channels = [ - "python-telegram-bot>=21.0", - "discord.py>=2.3", - "aiohttp>=3.9", - "slack-sdk>=3.27", - "pycryptodome>=3.20", -] [project.urls] "Homepage" = "https://github.com/EvoScientist/EvoScientist" diff --git a/tests/test_bus_integration.py b/tests/test_bus_integration.py deleted file mode 100644 index 8d73ff4..0000000 --- a/tests/test_bus_integration.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Tests for bus-mode agent integration (_bus_inbound_consumer).""" - -import asyncio - - -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, OutgoingMessage - - -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 bus integration testing.""" - - name = "fake" - - def __init__(self): - super().__init__(_FakeConfig()) - self._started = False - self._stopped = False - self._sent: list[OutgoingMessage] = [] - - 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: OutgoingMessage) -> bool: - self._sent.append(message) - return True - - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - pass - - -def _mock_stream_events(content, reply): - """Create a mock stream_agent_events that yields text then done.""" - async def _stream(agent, message, thread_id): - yield {"type": "text", "content": reply} - yield {"type": "done", "response": reply} - return _stream - - -def _mock_stream_events_error(error_msg): - """Create a mock stream_agent_events that raises.""" - async def _stream(agent, message, thread_id): - raise RuntimeError(error_msg) - yield # make it an async generator # pragma: no cover - return _stream - - -def _mock_stream_events_with_thinking(thinking_text, reply): - """Create a mock stream_agent_events that yields thinking then done.""" - async def _stream(agent, message, thread_id): - yield {"type": "thinking", "content": thinking_text} - yield {"type": "text", "content": reply} - yield {"type": "done", "content": reply} - return _stream - - -class TestBusInboundConsumer: - """Test the _bus_inbound_consumer bridge function.""" - - def test_processes_inbound_and_publishes_outbound(self): - """InboundMessage -> agent -> OutboundMessage flow.""" - from EvoScientist.cli.channel import _bus_inbound_consumer - - async def _test(): - bus = MessageBus() - manager = ChannelManager(bus) - ch = FakeChannel() - manager.register(ch) - - mock_stream = _mock_stream_events( - "hello agent", "Reply to: hello agent", - ) - - import EvoScientist.stream.events as events_mod - original = events_mod.stream_agent_events - events_mod.stream_agent_events = mock_stream - - try: - consumer = asyncio.create_task( - _bus_inbound_consumer(bus, manager, None, "test-thread", False) - ) - - await bus.publish_inbound(InboundMessage( - channel="fake", - sender_id="user1", - chat_id="chat1", - content="hello agent", - )) - - await asyncio.sleep(0.5) - - outbound = await asyncio.wait_for( - bus.consume_outbound(), timeout=2.0, - ) - assert outbound.channel == "fake" - assert outbound.chat_id == "chat1" - assert "Reply to: hello agent" in outbound.content - - consumer.cancel() - try: - await consumer - except asyncio.CancelledError: - pass - finally: - events_mod.stream_agent_events = original - - _run(_test()) - - def test_agent_error_publishes_error_outbound(self): - """When agent raises, an error message is published outbound.""" - from EvoScientist.cli.channel import _bus_inbound_consumer - - async def _test(): - bus = MessageBus() - manager = ChannelManager(bus) - ch = FakeChannel() - manager.register(ch) - - mock_stream = _mock_stream_events_error("agent crashed") - - import EvoScientist.stream.events as events_mod - original = events_mod.stream_agent_events - events_mod.stream_agent_events = mock_stream - - try: - consumer = asyncio.create_task( - _bus_inbound_consumer(bus, manager, None, "test-thread", False) - ) - - await bus.publish_inbound(InboundMessage( - channel="fake", - sender_id="user1", - chat_id="chat1", - content="crash me", - )) - - await asyncio.sleep(0.5) - - outbound = await asyncio.wait_for( - bus.consume_outbound(), timeout=2.0, - ) - assert outbound.channel == "fake" - assert "Error" in outbound.content or "error" in outbound.content.lower() - assert "agent crashed" in outbound.content - - consumer.cancel() - try: - await consumer - except asyncio.CancelledError: - pass - finally: - events_mod.stream_agent_events = original - - _run(_test()) - - def test_message_counting(self): - """Messages are counted via record_message.""" - from EvoScientist.cli.channel import _bus_inbound_consumer - - async def _test(): - bus = MessageBus() - manager = ChannelManager(bus) - ch = FakeChannel() - manager.register(ch) - - mock_stream = _mock_stream_events("test", "ok") - - import EvoScientist.stream.events as events_mod - original = events_mod.stream_agent_events - events_mod.stream_agent_events = mock_stream - - try: - consumer = asyncio.create_task( - _bus_inbound_consumer(bus, manager, None, "test-thread", False) - ) - - await bus.publish_inbound(InboundMessage( - channel="fake", - sender_id="u1", - chat_id="c1", - content="test", - )) - - await asyncio.sleep(0.5) - await asyncio.wait_for(bus.consume_outbound(), timeout=2.0) - - assert manager._message_counts["fake"]["received"] == 1 - assert manager._message_counts["fake"]["sent"] == 1 - - consumer.cancel() - try: - await consumer - except asyncio.CancelledError: - pass - finally: - events_mod.stream_agent_events = original - - _run(_test()) - - def test_thinking_sent_to_channel(self): - """Thinking messages are sent to the channel when show_thinking=True.""" - from EvoScientist.cli.channel import _bus_inbound_consumer - - async def _test(): - bus = MessageBus() - manager = ChannelManager(bus) - ch = FakeChannel() - server = manager.register(ch) - server.send_thinking = True - - long_thinking = "A" * 250 # >= _MIN_THINKING_LEN (200) - mock_stream = _mock_stream_events_with_thinking( - long_thinking, "final answer", - ) - - import EvoScientist.stream.events as events_mod - original = events_mod.stream_agent_events - events_mod.stream_agent_events = mock_stream - - try: - consumer = asyncio.create_task( - _bus_inbound_consumer( - bus, manager, None, "test-thread", True, - ) - ) - - await bus.publish_inbound(InboundMessage( - channel="fake", - sender_id="user1", - chat_id="chat1", - content="think about this", - metadata={"chat_id": "chat1"}, - )) - - await asyncio.sleep(0.5) - - # Drain outbound (final answer) - outbound = await asyncio.wait_for( - bus.consume_outbound(), timeout=2.0, - ) - assert "final answer" in outbound.content - - # Check that thinking was sent via channel.send - thinking_msgs = [ - m for m in ch._sent - if "\U0001f9e0" in m.content - ] - assert len(thinking_msgs) == 1 - assert long_thinking in thinking_msgs[0].content - - consumer.cancel() - try: - await consumer - except asyncio.CancelledError: - pass - finally: - events_mod.stream_agent_events = original - - _run(_test()) diff --git a/tests/test_channel_comprehensive.py b/tests/test_channel_comprehensive.py deleted file mode 100644 index 6a15416..0000000 --- a/tests/test_channel_comprehensive.py +++ /dev/null @@ -1,1545 +0,0 @@ -"""Comprehensive channel test suite — covers all major functionalities and known bug scenarios. - -Bug IDs prefixed with [B-xx] map to the internal bug report. -Test groups: - 1. DedupCache — dedup correctness, TTL, LRU, boundary - 2. RetryConfig / retry — exponential backoff, jitter, should_retry - 3. chunk_text — text splitting, code fences, edge cases - 4. markdown_utils — placeholder integrity, escape_fn, inline/block - 5. Channel base — send, debounce, typing, allow-list, reconnect - 6. ChannelManager — register, dispatch, health, add/remove, drain - 7. InboundConsumer — worker pool, session, timeout, error handling - 8. MessageBus — pub/sub, backpressure, subscriber dispatch -""" - -from __future__ import annotations - -import asyncio -import time -from dataclasses import dataclass -from datetime import datetime -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from EvoScientist.channels.base import ( - Channel, - ChannelError, - OutboundMessage, - InboundMessage, - RawIncoming, - chunk_text, -) -from EvoScientist.channels.bus.events import ( - InboundMessage as BusInbound, - OutboundMessage as BusOutbound, -) -from EvoScientist.channels.bus.message_bus import MessageBus -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 -from EvoScientist.channels.formatter import convert_markdown - - -# ═══════════════════════════════════════════════════════════════════ -# Helpers -# ═══════════════════════════════════════════════════════════════════ - -def _run(coro): - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -@dataclass -class _FakeConfig: - text_chunk_limit: int = 4096 - allowed_senders: list | None = None - allowed_channels: list | None = None - proxy: str | None = None - require_mention: str = "group" - dm_policy: str = "allowlist" - - -class StubChannel(Channel): - """Minimal concrete channel for unit testing.""" - - name = "stub" - - def __init__(self, config=None): - super().__init__(config or _FakeConfig()) - self._sent_chunks: list[tuple] = [] - self._typing_started: list[str] = [] - self._typing_stopped: list[str] = [] - self._started = False - - async def start(self): - self._started = True - self._running = True - - async def _send_chunk(self, chat_id, formatted, raw, reply_to, metadata): - self._sent_chunks.append((chat_id, formatted, raw, reply_to, metadata)) - - async def _send_typing_action(self, chat_id): - self._typing_started.append(chat_id) - - -# ═══════════════════════════════════════════════════════════════════ -# 1. DedupCache -# ═══════════════════════════════════════════════════════════════════ - -class TestDedupCache: - - def test_first_message_is_not_duplicate(self): - dc = DedupCache() - assert dc.is_duplicate("msg_001") is False - - def test_same_id_is_duplicate(self): - dc = DedupCache() - dc.is_duplicate("msg_001") - assert dc.is_duplicate("msg_001") is True - - def test_empty_id_never_duplicate(self): - dc = DedupCache() - assert dc.is_duplicate("") is False - assert dc.is_duplicate("") is False - - def test_ttl_expiry(self): - dc = DedupCache(ttl_seconds=0.05) - dc.is_duplicate("msg_001") - time.sleep(0.1) - # After TTL, the entry should be pruned - assert dc.is_duplicate("msg_001") is False - - def test_max_size_trim(self): - dc = DedupCache(max_size=5, trim_to=2) - for i in range(6): - dc.is_duplicate(f"m{i}") - # After exceeding max_size, trimmed to trim_to - assert dc.size <= 3 # 2 kept + the just-inserted one - - def test_lru_refresh(self): - """Accessing an entry refreshes its position (LRU).""" - dc = DedupCache(max_size=3, trim_to=1, ttl_seconds=60) - dc.is_duplicate("a") - dc.is_duplicate("b") - # Re-access "a" to move it to end - dc.is_duplicate("a") - dc.is_duplicate("c") - # Now exceed — oldest insertion-order should be "b" - dc.is_duplicate("d") - # "a" was refreshed, so "b" should have been evicted - assert dc.is_duplicate("b") is False # "b" was evicted - - def test_clear(self): - dc = DedupCache() - dc.is_duplicate("x") - dc.clear() - assert dc.size == 0 - assert dc.is_duplicate("x") is False - - -# ═══════════════════════════════════════════════════════════════════ -# 2. Retry -# ═══════════════════════════════════════════════════════════════════ - -class TestRetryAsync: - - def test_success_on_first_attempt(self): - call_count = 0 - - async def _fn(): - nonlocal call_count - call_count += 1 - return "ok" - - result = _run(retry_async(_fn)) - assert result == "ok" - assert call_count == 1 - - def test_retries_on_failure_then_succeeds(self): - attempts = [] - - async def _fn(): - attempts.append(1) - if len(attempts) < 3: - raise RuntimeError("transient") - return "recovered" - - result = _run(retry_async( - _fn, - config=RetryConfig(attempts=5, min_delay_s=0.01, max_delay_s=0.05), - )) - assert result == "recovered" - assert len(attempts) == 3 - - def test_exhausts_retries_raises(self): - async def _fn(): - raise ValueError("permanent") - - with pytest.raises(ValueError, match="permanent"): - _run(retry_async( - _fn, - config=RetryConfig(attempts=2, min_delay_s=0.01), - )) - - def test_should_retry_false_aborts(self): - """[B-01] should_retry returning False should abort immediately.""" - call_count = 0 - - async def _fn(): - nonlocal call_count - call_count += 1 - raise PermissionError("forbidden") - - with pytest.raises(PermissionError): - _run(retry_async( - _fn, - config=RetryConfig(attempts=5, min_delay_s=0.01), - should_retry=lambda exc, _: False, - )) - assert call_count == 1 # No retry happened - - def test_server_retry_after_respected(self): - """retry_after_s callback provides server-supplied delay.""" - delays = [] - - async def _fn(): - if len(delays) < 1: - raise RuntimeError("429") - return "ok" - - def _on_retry(info: RetryInfo): - delays.append(info.delay_s) - - _run(retry_async( - _fn, - config=RetryConfig(attempts=3, min_delay_s=0.01, max_delay_s=10, jitter=0), - retry_after_s=lambda _: 0.5, - on_retry=_on_retry, - )) - assert len(delays) == 1 - assert delays[0] >= 0.5 - - def test_jitter_applied(self): - """With jitter > 0, delays should vary.""" - delays = [] - - async def _fn(): - if len(delays) < 5: - raise RuntimeError("fail") - return "ok" - - _run(retry_async( - _fn, - config=RetryConfig(attempts=10, min_delay_s=0.01, max_delay_s=1.0, jitter=0.5), - on_retry=lambda info: delays.append(info.delay_s), - )) - # With 50% jitter, not all delays should be identical - if len(delays) > 1: - assert len(set(f"{d:.4f}" for d in delays)) > 1 - - -# ═══════════════════════════════════════════════════════════════════ -# 3. chunk_text -# ═══════════════════════════════════════════════════════════════════ - -class TestChunkText: - - def test_short_text_single_chunk(self): - assert chunk_text("hello", 100) == ["hello"] - - def test_empty_text(self): - assert chunk_text("", 100) == [] - - def test_exact_limit(self): - text = "a" * 100 - assert chunk_text(text, 100) == [text] - - def test_splits_at_paragraph_break(self): - text = "first paragraph\n\nsecond paragraph" - chunks = chunk_text(text, 25) - assert len(chunks) == 2 - assert "first" in chunks[0] - assert "second" in chunks[1] - - def test_splits_at_newline(self): - text = "line one\nline two\nline three" - chunks = chunk_text(text, 15) - assert all(len(c) <= 15 for c in chunks) - assert len(chunks) >= 2 - - def test_splits_at_space(self): - text = "word " * 30 - chunks = chunk_text(text, 20) - assert all(len(c) <= 20 for c in chunks) - - def test_hard_cut_no_separators(self): - text = "a" * 200 - chunks = chunk_text(text, 50) - assert all(len(c) <= 50 for c in chunks) - - def test_code_block_fence_split(self): - """[B-08] Code block fence splitting should not break mid-block.""" - code = "```python\nprint('hello')\nprint('world')\n```" - text = "Before.\n\n" + code + "\n\nAfter some text here." - chunks = chunk_text(text, 40) - # Verify we get multiple chunks and none are empty - assert len(chunks) >= 2 - assert all(c.strip() for c in chunks) - - def test_code_block_preserved_when_fits(self): - code = "```\ncode\n```" - text = f"intro\n\n{code}\n\noutro" - chunks = chunk_text(text, 200) - assert len(chunks) == 1 - assert "```" in chunks[0] - - def test_whitespace_only_input(self): - """[B-09] Whitespace-heavy input should not produce empty chunks.""" - text = " \n\n \n\n content \n\n " - chunks = chunk_text(text, 20) - assert all(c.strip() for c in chunks) - - def test_very_small_limit(self): - """Limit below typical message sizes.""" - text = "Hello, this is a test message." - chunks = chunk_text(text, 5) - assert all(len(c) <= 5 for c in chunks) - assert "".join(c.replace(" ", "") for c in chunks).replace(" ", "") != "" - - -# ═══════════════════════════════════════════════════════════════════ -# 4. markdown_utils — convert_markdown -# ═══════════════════════════════════════════════════════════════════ - -class TestMarkdownUtils: - - @staticmethod - def _html_converter(text: str) -> str: - return convert_markdown( - text, - code_block_formatter=lambda lang, code: f"
{code}
", - inline_code_formatter=lambda code: f"{code}", - inline_rules=[ - (r"\*\*(.+?)\*\*", r"\1"), - (r"\*(.+?)\*", r"\1"), - ], - escape_fn=lambda t: t.replace("&", "&").replace("<", "<").replace(">", ">"), - ) - - def test_basic_bold_italic(self): - result = self._html_converter("**bold** and *italic*") - assert "bold" in result - assert "italic" in result - - def test_code_block_protection(self): - """Code inside blocks should NOT have inline rules applied.""" - text = "```\n**not bold**\n```" - result = self._html_converter(text) - assert "" not in result - assert "**not bold**" in result - - def test_inline_code_protection(self): - text = "Use `**literal**` please" - result = self._html_converter(text) - assert "" in result - # The **literal** inside backticks should be literal - assert "**literal**" in result - - def test_escape_fn_does_not_corrupt_placeholders(self): - """[B-28] escape_fn must not corrupt NUL-byte placeholders.""" - text = "```\ncode\n```\nNormal " - - def bad_escape(t): - # Strips NUL bytes — would break placeholders - return t.replace("\x00", "") - - result = convert_markdown( - text, - code_block_formatter=lambda lang, c: f"[CODE]{c}[/CODE]", - inline_code_formatter=lambda c: f"[IC]{c}[/IC]", - inline_rules=[], - escape_fn=bad_escape, - ) - # If placeholders were corrupted, the code block won't be restored - # This test DOCUMENTS the bug — it should fail until the bug is fixed - # After fix: assert "[CODE]" in result - # Current behavior: placeholder is corrupted - if "\x00" in text: - pass # Can't easily test without modifying source - # At minimum, verify the function doesn't crash - assert isinstance(result, str) - - def test_placeholder_collision_with_user_input(self): - """[B-28 variant] User input containing placeholder pattern.""" - text = "Normal text with \x00BLOCK0\x00 in it" - result = convert_markdown( - text, - code_block_formatter=lambda lang, c: f"
{c}
", - inline_code_formatter=lambda c: f"{c}", - inline_rules=[], - ) - assert isinstance(result, str) - - def test_empty_inline_code(self): - """[B-29] Empty backtick pairs should not crash.""" - text = "before `` after" - result = convert_markdown( - text, - code_block_formatter=lambda lang, c: c, - inline_code_formatter=lambda c: f"[{c}]", - inline_rules=[], - ) - assert isinstance(result, str) - - def test_nested_code_fence_on_same_line(self): - """[B-30] Opening fence with code on same line.""" - text = "```pythonprint('hi')```" - result = convert_markdown( - text, - code_block_formatter=lambda lang, code: f"LANG={lang}|CODE={code}", - inline_code_formatter=lambda c: c, - inline_rules=[], - ) - assert isinstance(result, str) - - -# ═══════════════════════════════════════════════════════════════════ -# 5. Channel base class -# ═══════════════════════════════════════════════════════════════════ - -class TestChannelSend: - - def test_send_single_chunk(self): - async def _test(): - ch = StubChannel() - msg = OutboundMessage( - channel="stub", chat_id="c1", content="hello", - metadata={"chat_id": "c1"}, - ) - ok = await ch.send(msg) - assert ok is True - assert len(ch._sent_chunks) == 1 - assert ch._sent_chunks[0][0] == "c1" - assert ch._sent_chunks[0][2] == "hello" # raw - _run(_test()) - - def test_send_multi_chunk(self): - async def _test(): - cfg = _FakeConfig(text_chunk_limit=10) - ch = StubChannel(cfg) - msg = OutboundMessage( - channel="stub", chat_id="c1", - content="hello world this is a long message", - metadata={"chat_id": "c1"}, - ) - ok = await ch.send(msg) - assert ok is True - assert len(ch._sent_chunks) > 1 - _run(_test()) - - def test_send_returns_false_when_not_ready(self): - async def _test(): - ch = StubChannel() - ch._is_ready = lambda: False - msg = OutboundMessage(channel="stub", chat_id="c1", content="hi") - ok = await ch.send(msg) - assert ok is False - _run(_test()) - - def test_send_per_chat_lock_serializes(self): - """[B-03] Per-chat locks prevent message reordering.""" - async def _test(): - ch = StubChannel() - order = [] - - original_send_chunk = ch._send_chunk - - async def slow_send(chat_id, fmt, raw, reply_to, meta): - order.append(raw) - await asyncio.sleep(0.05) - await original_send_chunk(chat_id, fmt, raw, reply_to, meta) - - ch._send_chunk = slow_send - - msg1 = OutboundMessage(channel="stub", chat_id="c1", content="first", metadata={"chat_id": "c1"}) - msg2 = OutboundMessage(channel="stub", chat_id="c1", content="second", metadata={"chat_id": "c1"}) - - await asyncio.gather(ch.send(msg1), ch.send(msg2)) - # Both complete; order may vary but no interleaving within a single send - assert len(order) == 2 - _run(_test()) - - def test_reply_to_only_on_first_chunk(self): - """reply_to should only be passed to the first chunk.""" - async def _test(): - cfg = _FakeConfig(text_chunk_limit=10) - ch = StubChannel(cfg) - msg = OutboundMessage( - channel="stub", chat_id="c1", - content="a very long message that will be split into multiple parts", - reply_to="msg_42", - metadata={"chat_id": "c1"}, - ) - await ch.send(msg) - reply_tos = [c[3] for c in ch._sent_chunks] - assert reply_tos[0] == "msg_42" - assert all(r is None for r in reply_tos[1:]) - _run(_test()) - - -class TestChannelAllowList: - - def test_open_access_when_no_list(self): - ch = StubChannel() - assert ch.is_allowed("anyone") is True - - def test_allowed_sender_passes(self): - cfg = _FakeConfig(allowed_senders=["alice", "bob"]) - ch = StubChannel(cfg) - assert ch.is_allowed("alice") is True - assert ch.is_allowed("bob") is True - - def test_disallowed_sender_blocked(self): - cfg = _FakeConfig(allowed_senders=["alice"]) - ch = StubChannel(cfg) - assert ch.is_allowed("eve") is False - - def test_composite_sender_id(self): - """Pipe-separated composite IDs should match any component.""" - cfg = _FakeConfig(allowed_senders=["12345"]) - ch = StubChannel(cfg) - assert ch.is_allowed("12345|alice") is True - - def test_channel_allow_list(self): - cfg = _FakeConfig(allowed_channels=["chan_1", "chan_2"]) - ch = StubChannel(cfg) - assert ch.is_channel_allowed("chan_1") is True - assert ch.is_channel_allowed("chan_3") is False - - def test_channel_allow_list_empty_allows_all(self): - cfg = _FakeConfig(allowed_channels=None) - ch = StubChannel(cfg) - assert ch.is_channel_allowed("any_channel") is True - - -class TestChannelMentionGating: - - def test_dm_always_passes(self): - ch = StubChannel() - raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi", - is_group=False, was_mentioned=False) - assert ch._should_process(raw) is True - - def test_group_mentioned_passes(self): - ch = StubChannel() - raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi", - is_group=True, was_mentioned=True) - assert ch._should_process(raw) is True - - def test_group_not_mentioned_blocked(self): - ch = StubChannel() - ch.require_mention = "group" - raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi", - is_group=True, was_mentioned=False) - assert ch._should_process(raw) is False - - def test_mention_off_passes_all(self): - ch = StubChannel() - ch.require_mention = "off" - raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi", - is_group=True, was_mentioned=False) - assert ch._should_process(raw) is True - - -class TestChannelBuildInbound: - - def test_builds_valid_inbound(self): - ch = StubChannel() - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="hello", - message_id="m1", media_files=["/path/img.jpg"], - ) - msg = ch._raw_to_inbound(raw) - assert msg is not None - assert msg.channel == "stub" - assert msg.sender_id == "u1" - assert msg.content == "hello" - assert msg.media == ["/path/img.jpg"] - - def test_drops_disallowed_sender(self): - async def _test(): - cfg = _FakeConfig(allowed_senders=["alice"]) - ch = StubChannel(cfg) - raw = RawIncoming(sender_id="eve", chat_id="c1", text="hack") - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 0 - _run(_test()) - - def test_drops_disallowed_channel(self): - async def _test(): - cfg = _FakeConfig(allowed_channels=["c1"]) - ch = StubChannel(cfg) - raw = RawIncoming(sender_id="u1", chat_id="c2", text="hello") - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 0 - _run(_test()) - - def test_drops_empty_content_no_media(self): - ch = StubChannel() - raw = RawIncoming(sender_id="u1", chat_id="c1", text="") - assert ch._raw_to_inbound(raw) is None - - def test_media_only_message_passes(self): - ch = StubChannel() - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="", - media_files=["/path/file.pdf"], - ) - msg = ch._raw_to_inbound(raw) - assert msg is not None - assert msg.content == "[media only]" - - def test_annotations_merged(self): - ch = StubChannel() - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="main text", - content_annotations=["[attachment: photo.jpg]"], - ) - msg = ch._raw_to_inbound(raw) - assert "[attachment: photo.jpg]" in msg.content - - def test_metadata_preserves_chat_id(self): - ch = StubChannel() - raw = RawIncoming(sender_id="u1", chat_id="c1", text="hi", - metadata={"extra": "data"}) - msg = ch._raw_to_inbound(raw) - assert msg.metadata["chat_id"] == "c1" - assert msg.metadata["extra"] == "data" - - -class TestInboundPipeline: - """Tests for the new middleware-based inbound pipeline in _enqueue_raw().""" - - def test_pipeline_dedup(self): - """Duplicate messages are dropped by the pipeline.""" - async def _test(): - ch = StubChannel() - raw = RawIncoming(sender_id="u1", chat_id="c1", text="hello", message_id="m1") - await ch._enqueue_raw(raw) - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 1 - _run(_test()) - - def test_pipeline_allowlist_blocks(self): - """Non-allowed senders are blocked by the pipeline.""" - async def _test(): - cfg = _FakeConfig(allowed_senders=["alice"]) - ch = StubChannel(cfg) - raw = RawIncoming(sender_id="eve", chat_id="c1", text="hack") - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 0 - _run(_test()) - - def test_pipeline_allowlist_passes(self): - """Allowed senders pass through the pipeline.""" - async def _test(): - cfg = _FakeConfig(allowed_senders=["alice"]) - ch = StubChannel(cfg) - raw = RawIncoming(sender_id="alice", chat_id="c1", text="hello") - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 1 - _run(_test()) - - def test_pipeline_channel_allowlist_blocks(self): - """Non-allowed channels are blocked by the pipeline.""" - async def _test(): - cfg = _FakeConfig(allowed_channels=["c1"]) - ch = StubChannel(cfg) - raw = RawIncoming(sender_id="u1", chat_id="c2", text="hello") - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 0 - _run(_test()) - - def test_pipeline_inbound_has_is_group(self): - """InboundMessage carries is_group and was_mentioned from RawIncoming.""" - async def _test(): - ch = StubChannel() - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="hello", - is_group=True, was_mentioned=True, - ) - await ch._enqueue_raw(raw) - msg = await ch._queue.get() - assert msg.is_group is True - assert msg.was_mentioned is True - _run(_test()) - - -class TestChannelDebounce: - - def test_single_message_processed(self): - """A single message should be published after debounce delay.""" - async def _test(): - bus = MessageBus() - ch = StubChannel() - ch.set_bus(bus) - ch.initial_debounce = 0.05 - ch.max_debounce = 0.1 - - msg = InboundMessage( - channel="stub", sender_id="u1", chat_id="c1", - content="hello", message_id="m1", - metadata={"chat_id": "c1"}, - ) - await ch.queue_message(msg) - await asyncio.sleep(0.2) - - # Check bus received the message - assert bus.inbound.qsize() == 1 - received = await bus.consume_inbound() - assert received.content == "hello" - _run(_test()) - - def test_rapid_messages_merged(self): - """[B-05] Multiple rapid messages should be merged.""" - async def _test(): - bus = MessageBus() - ch = StubChannel() - ch.set_bus(bus) - ch.initial_debounce = 0.1 - ch.max_debounce = 0.3 - - for i in range(3): - msg = InboundMessage( - channel="stub", sender_id="u1", chat_id="c1", - content=f"part{i}", message_id=f"m{i}", - metadata={"chat_id": "c1"}, - ) - await ch.queue_message(msg) - await asyncio.sleep(0.01) - - await asyncio.sleep(0.5) - assert bus.inbound.qsize() == 1 - received = await bus.consume_inbound() - assert "part0" in received.content - assert "part1" in received.content - assert "part2" in received.content - _run(_test()) - - def test_dedup_skips_duplicate(self): - """Dedup is now handled in _enqueue_raw pipeline, not queue_message.""" - async def _test(): - ch = StubChannel() - - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="hello", - message_id="m1", - ) - await ch._enqueue_raw(raw) - await ch._enqueue_raw(raw) # duplicate - - # Only one should be enqueued (dedup catches second) - assert ch._queue.qsize() == 1 - _run(_test()) - - def test_debounce_metadata_from_first_message(self): - """[B-05] Metadata from the first message in a debounce window is kept.""" - async def _test(): - bus = MessageBus() - ch = StubChannel() - ch.set_bus(bus) - ch.initial_debounce = 0.1 - - msg1 = InboundMessage( - channel="stub", sender_id="u1", chat_id="c1", - content="first", message_id="m1", - metadata={"chat_id": "c1", "key": "val1"}, - ) - msg2 = InboundMessage( - channel="stub", sender_id="u1", chat_id="c1", - content="second", message_id="m2", - metadata={"chat_id": "c2", "key": "val2"}, - ) - await ch.queue_message(msg1) - await asyncio.sleep(0.01) - await ch.queue_message(msg2) - await asyncio.sleep(0.3) - - received = await bus.consume_inbound() - # BUG: metadata is from msg1 only; msg2's metadata is lost - assert received.metadata["key"] == "val1" - _run(_test()) - - -class TestChannelTyping: - - def test_start_and_stop_typing(self): - async def _test(): - ch = StubChannel() - await ch.start_typing("c1") - assert "c1" in ch._typing_tasks - await asyncio.sleep(0.1) - await ch.stop_typing("c1") - assert "c1" not in ch._typing_tasks - _run(_test()) - - def test_double_start_cancels_previous(self): - async def _test(): - ch = StubChannel() - await ch.start_typing("c1") - task1 = ch._typing_tasks["c1"] - await ch.start_typing("c1") - task2 = ch._typing_tasks["c1"] - assert task1 is not task2 - # Allow the event loop to process the cancellation - await asyncio.sleep(0) - assert task1.cancelled() or task1.done() - await ch.stop_typing("c1") - _run(_test()) - - def test_stop_typing_idempotent(self): - async def _test(): - ch = StubChannel() - # Should not raise even if never started - await ch.stop_typing("nonexistent") - _run(_test()) - - -class TestChannelReconnect: - - def test_run_reconnects_on_error(self): - """Channel.run() should reconnect with backoff on transient errors.""" - async def _test(): - ch = StubChannel() - start_count = 0 - original_start = ch.start - - async def flaky_start(): - nonlocal start_count - start_count += 1 - if start_count <= 2: - raise ConnectionError("transient") - await original_start() - # Stop after successful start to end the test - ch._running = False - - ch.start = flaky_start - await ch.run() - assert start_count == 3 - _run(_test()) - - def test_run_stops_on_channel_error(self): - """ChannelError should stop the channel permanently.""" - async def _test(): - ch = StubChannel() - - async def fatal_start(): - raise ChannelError("fatal") - - ch.start = fatal_start - await ch.run() - assert ch._running is False - _run(_test()) - - -class TestExtractRetryAfter: - - def test_never_returns_none(self): - """[B-01] Base _extract_retry_after always returns float, never None.""" - ch = StubChannel() - # Even for a generic exception, it returns 1.0 instead of None - result = ch._extract_retry_after(ValueError("bad")) - # BUG: This should return None for non-retryable errors - # Current behavior: always returns 1.0 - assert result is not None # Documents the bug - - def test_extracts_retry_after_attribute(self): - ch = StubChannel() - - class RateLimitError(Exception): - retry_after = 5.0 - - result = ch._extract_retry_after(RateLimitError("rate limited")) - assert result == 5.0 - - def test_detects_429_in_message(self): - ch = StubChannel() - result = ch._extract_retry_after(RuntimeError("HTTP 429 Too Many Requests")) - assert result == 1.0 - - -class TestChannelAttachments: - - def test_check_attachment_size_within_limit(self): - ch = StubChannel() - result = ch._check_attachment_size(1024, "small.txt") - assert result is None - - def test_check_attachment_size_too_large(self): - ch = StubChannel() - result = ch._check_attachment_size(30 * 1024 * 1024, "huge.bin") - assert result is not None - assert "too large" in result - - def test_send_media_returns_false_when_not_ready(self): - async def _test(): - ch = StubChannel() - ch._is_ready = lambda: False - ok = await ch.send_media("r1", "/path/file.txt") - assert ok is False - _run(_test()) - - -# ═══════════════════════════════════════════════════════════════════ -# 6. ChannelManager -# ═══════════════════════════════════════════════════════════════════ - -class TestChannelManagerRegister: - - def test_register_and_lookup(self): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = StubChannel() - mgr.register(ch) - assert mgr.get_channel("stub") is ch - assert "stub" in mgr.enabled_channels - - def test_duplicate_raises(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(StubChannel()) - with pytest.raises(ValueError, match="already registered"): - mgr.register(StubChannel()) - - def test_register_injects_bus(self): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = StubChannel() - mgr.register(ch) - assert ch._bus is bus - - def test_register_applies_kwargs(self): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = StubChannel() - mgr.register(ch, send_thinking=True, initial_debounce=5.0) - assert ch.send_thinking is True - assert ch.initial_debounce == 5.0 - - def test_health_entry_created(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(StubChannel()) - assert "stub" in mgr._health - - -class TestChannelManagerDispatch: - - def test_dispatch_routes_to_channel(self): - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = StubChannel() - # Override send to track calls - sent = [] - ch.send = AsyncMock(side_effect=lambda m: sent.append(m) or True) - mgr.register(ch) - - task = asyncio.create_task(mgr._dispatch_outbound()) - await bus.publish_outbound(OutboundMessage( - channel="stub", chat_id="c1", content="hello", - )) - await asyncio.sleep(0.1) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - assert len(sent) == 1 - assert sent[0].content == "hello" - _run(_test()) - - def test_dispatch_unknown_channel_logged(self): - """Messages to unknown channels should be logged, not crash.""" - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus) - - task = asyncio.create_task(mgr._dispatch_outbound()) - await bus.publish_outbound(OutboundMessage( - channel="nonexistent", chat_id="c1", content="hello", - )) - await asyncio.sleep(0.1) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - # Should not raise - _run(_test()) - - def test_dispatch_ignores_send_return_false(self): - """[B-18] _dispatch_outbound ignores send() return value — health is inaccurate.""" - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = StubChannel() - - async def failing_send(msg): - return False # Indicates failure - - ch.send = failing_send - mgr.register(ch) - - task = asyncio.create_task(mgr._dispatch_outbound()) - await bus.publish_outbound(OutboundMessage( - channel="stub", chat_id="c1", content="hello", - )) - await asyncio.sleep(0.1) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - health = mgr._health["stub"] - # BUG: health shows success even though send returned False - assert health.total_successes == 1 # Documents the bug - assert health.total_failures == 0 # Should be 1 - _run(_test()) - - -class TestChannelManagerHealth: - - def test_health_tracks_success(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(StubChannel()) - health = mgr._health["stub"] - health.total_successes = 5 - health.consecutive_failures = 0 - assert health.total_successes == 5 - - def test_health_tracks_failure(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(StubChannel()) - health = mgr._health["stub"] - health.consecutive_failures = 3 - health.total_failures = 10 - health.last_failure_error = "timeout" - assert health.consecutive_failures == 3 - assert health.last_failure_error == "timeout" - - -class TestChannelManagerDynamicOps: - - def test_add_channel_runtime(self): - """[B-15] add_channel uses channel_type as key for start_times - but register() uses channel.name — potential mismatch.""" - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus) - # We can't easily test add_channel without registry, - # but we can verify the key mismatch concern - ch = StubChannel() - ch.name = "custom_name" - mgr.register(ch) - assert "custom_name" in mgr._channels - # If add_channel used "other_type" but channel.name is "custom_name", - # start_times would be keyed differently - _run(_test()) - - def test_remove_channel(self): - """[B-14] remove_channel removes from dict but doesn't cancel task.""" - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = StubChannel() - mgr.register(ch) - assert "stub" in mgr._channels - - await mgr.remove_channel("stub") - assert "stub" not in mgr._channels - _run(_test()) - - def test_remove_nonexistent_channel(self): - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus) - await mgr.remove_channel("ghost") # should not raise - _run(_test()) - - -class TestChannelManagerDrain: - - def test_stop_all_drains_outbound(self): - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus, drain_timeout=1.0) - ch = StubChannel() - sent = [] - ch.send = AsyncMock(side_effect=lambda m: sent.append(m) or True) - mgr.register(ch) - - # Pre-load an outbound message - await bus.publish_outbound(OutboundMessage( - channel="stub", chat_id="c1", content="drain me", - )) - - await mgr.stop_all() - # The drain loop should have sent it - assert len(sent) == 1 - assert sent[0].content == "drain me" - _run(_test()) - - -class TestChannelManagerStatus: - - def test_get_status(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(StubChannel()) - status = mgr.get_status() - assert "stub" in status - assert status["stub"]["registered"] is True - - def test_running_channels(self): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = StubChannel() - mgr.register(ch) - assert mgr.running_channels() == [] - ch._running = True - assert mgr.running_channels() == ["stub"] - - def test_get_stats(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(StubChannel()) - stats = mgr.get_stats() - assert "channels" in stats - assert "running" in stats - assert "message_counts" in stats - - -# ═══════════════════════════════════════════════════════════════════ -# 7. InboundConsumer -# ═══════════════════════════════════════════════════════════════════ - -class TestInboundConsumer: - - @staticmethod - def _make_consumer(bus=None, mgr=None, agent=None, **kw): - bus = bus or MessageBus() - if mgr is None: - mgr = ChannelManager(bus) - mgr.register(StubChannel()) - if agent is None: - agent = MagicMock() - return InboundConsumer( - bus=bus, manager=mgr, agent=agent, - thread_id="", max_concurrent=2, max_pending=10, - inference_timeout=2.0, drain_timeout=1.0, **kw, - ) - - def test_session_key_format(self): - msg = BusInbound(channel="tg", sender_id="u1", chat_id="c1", content="hi") - assert msg.session_key == "tg:c1" - - def test_get_thread_id_creates_unique(self): - consumer = self._make_consumer() - tid1 = consumer._get_thread_id("user_a") - tid2 = consumer._get_thread_id("user_b") - assert tid1 != tid2 - - def test_get_thread_id_returns_same_for_same_sender(self): - consumer = self._make_consumer() - tid1 = consumer._get_thread_id("user_a") - tid2 = consumer._get_thread_id("user_a") - assert tid1 == tid2 - - def test_shared_thread_id_bug(self): - """[B-20] If thread_id is non-empty, senders get unique thread IDs with shared prefix.""" - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(StubChannel()) - consumer = InboundConsumer( - bus=bus, manager=mgr, agent=MagicMock(), - thread_id="shared_thread", # Non-empty! - ) - tid1 = consumer._get_thread_id("alice") - tid2 = consumer._get_thread_id("bob") - # Fixed: Each sender gets a unique thread_id using thread_id as prefix - assert tid1 != tid2 - assert tid1 == "shared_thread:alice" - assert tid2 == "shared_thread:bob" - - def test_session_eviction_is_fifo_not_lru(self): - """[B-19] Sessions evict oldest by insertion, not by access.""" - consumer = self._make_consumer() - consumer._sessions.clear() - - # Fill up to limit - for i in range(10): - consumer._sessions[f"user_{i}"] = f"thread_{i}" - - # Access "user_0" (should make it LRU-recent, but dict doesn't) - _ = consumer._sessions["user_0"] - - # Force eviction by exceeding limit (simulate) - # Note: actual limit is 10_000, we test the logic pattern - oldest = next(iter(consumer._sessions)) - assert oldest == "user_0" # Still first in insertion order - - def test_metrics_initial(self): - consumer = self._make_consumer() - m = consumer.metrics - assert m["total_processed"] == 0 - assert m["total_successes"] == 0 - assert m["total_failures"] == 0 - assert m["total_timeouts"] == 0 - - def test_stop_graceful(self): - async def _test(): - consumer = self._make_consumer() - # Start and immediately stop - asyncio.create_task(consumer.run()) - await asyncio.sleep(0.1) - await consumer.stop() - await asyncio.sleep(0.1) - assert consumer._stopping is True - _run(_test()) - - -class TestInboundConsumerErrorHandling: - - def test_error_message_leaks_info(self): - """[B-22] Exception messages are sent directly to users.""" - # This test documents that internal error details are exposed - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = StubChannel() - mgr.register(ch) - - _consumer = InboundConsumer( - bus=bus, manager=mgr, agent=MagicMock(), - thread_id="", - ) - - # The error message format includes the raw exception - # This should be sanitized in production - error_msg = f"Error: {RuntimeError('secret internal path /etc/passwd')}" - assert "/etc/passwd" in error_msg # Documents the leak - _run(_test()) - - -# ═══════════════════════════════════════════════════════════════════ -# 8. MessageBus -# ═══════════════════════════════════════════════════════════════════ - -class TestMessageBus: - - def test_publish_consume_inbound(self): - async def _test(): - bus = MessageBus() - msg = BusInbound(channel="tg", sender_id="u1", - chat_id="c1", content="hello") - await bus.publish_inbound(msg) - assert bus.inbound_size == 1 - received = await bus.consume_inbound() - assert received.content == "hello" - assert bus.inbound_size == 0 - _run(_test()) - - def test_publish_consume_outbound(self): - async def _test(): - bus = MessageBus() - msg = BusOutbound(channel="tg", chat_id="c1", content="reply") - await bus.publish_outbound(msg) - assert bus.outbound_size == 1 - received = await bus.consume_outbound() - assert received.content == "reply" - _run(_test()) - - def test_subscriber_dispatch(self): - async def _test(): - bus = MessageBus() - received = [] - bus.subscribe_outbound("tg", lambda m: received.append(m)) - - task = asyncio.create_task(bus.dispatch_outbound()) - await bus.publish_outbound(BusOutbound( - channel="tg", chat_id="c1", content="hello", - )) - await asyncio.sleep(0.1) - bus.stop() - await asyncio.sleep(0.1) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - assert len(received) == 1 - _run(_test()) - - def test_no_subscriber_logs_warning(self): - """Messages to unsubscribed channels should warn, not crash.""" - async def _test(): - bus = MessageBus() - task = asyncio.create_task(bus.dispatch_outbound()) - await bus.publish_outbound(BusOutbound( - channel="unknown", chat_id="c1", content="lost", - )) - await asyncio.sleep(0.1) - bus.stop() - await asyncio.sleep(0.1) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - _run(_test()) - - def test_queue_sizes(self): - async def _test(): - bus = MessageBus() - assert bus.inbound_size == 0 - assert bus.outbound_size == 0 - await bus.publish_inbound(BusInbound( - channel="x", sender_id="u", chat_id="c", content="a", - )) - assert bus.inbound_size == 1 - _run(_test()) - - def test_subscriber_error_does_not_crash_dispatch(self): - async def _test(): - bus = MessageBus() - - async def bad_callback(msg): - raise RuntimeError("subscriber crash") - - bus.subscribe_outbound("tg", bad_callback) - - task = asyncio.create_task(bus.dispatch_outbound()) - await bus.publish_outbound(BusOutbound( - channel="tg", chat_id="c1", content="trigger", - )) - await asyncio.sleep(0.1) - bus.stop() - await asyncio.sleep(0.1) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - # dispatch should survive the error - _run(_test()) - - -# ═══════════════════════════════════════════════════════════════════ -# 9. Event dataclasses -# ═══════════════════════════════════════════════════════════════════ - -class TestEvents: - - def test_inbound_defaults(self): - msg = BusInbound(channel="tg", sender_id="u1", - chat_id="c1", content="hi") - assert msg.media == [] - assert msg.metadata == {} - assert msg.session_key == "tg:c1" - assert isinstance(msg.timestamp, datetime) - - def test_outbound_defaults(self): - msg = BusOutbound(channel="tg", chat_id="c1", content="reply") - assert msg.reply_to is None - assert msg.media == [] - assert msg.metadata == {} - - def test_inbound_sender_alias(self): - msg = InboundMessage(channel="x", sender_id="u1", - chat_id="c1", content="hi") - assert msg.sender == "u1" - - def test_outbound_recipient_alias(self): - msg = OutboundMessage(channel="x", chat_id="c1", content="hi") - assert msg.recipient == "c1" - - -# ═══════════════════════════════════════════════════════════════════ -# 10. Integration scenarios -# ═══════════════════════════════════════════════════════════════════ - -class TestIntegration: - - def test_full_inbound_pipeline(self): - """Raw message → build_inbound → queue_message → bus.""" - async def _test(): - bus = MessageBus() - ch = StubChannel() - ch.set_bus(bus) - ch.initial_debounce = 0.05 - - raw = RawIncoming( - sender_id="user1", chat_id="chat1", - text="integration test", message_id="int_001", - ) - await ch._enqueue_raw(raw) - - # _enqueue_raw puts on internal queue, not bus - assert ch._queue.qsize() == 1 - inbound = await ch._queue.get() - assert inbound.content == "integration test" - - # Now simulate the bus path via queue_message - await ch.queue_message(inbound) - await asyncio.sleep(0.2) - assert bus.inbound_size == 1 - _run(_test()) - - def test_outbound_dispatch_with_media(self): - """Dispatch routes media alongside text content.""" - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = StubChannel() - media_sent = [] - ch.send_media = AsyncMock( - side_effect=lambda **kw: media_sent.append(kw) or True, - ) - ch.send = AsyncMock(return_value=True) - mgr.register(ch) - - task = asyncio.create_task(mgr._dispatch_outbound()) - await bus.publish_outbound(OutboundMessage( - channel="stub", chat_id="c1", content="see attached", - media=["/path/doc.pdf"], - )) - await asyncio.sleep(0.1) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - assert len(media_sent) == 1 - _run(_test()) - - def test_debounce_lost_on_stop(self): - """[B-06] Buffered messages are lost when channel stops during debounce.""" - async def _test(): - bus = MessageBus() - ch = StubChannel() - ch.set_bus(bus) - ch.initial_debounce = 5.0 # Long debounce - - msg = InboundMessage( - channel="stub", sender_id="u1", chat_id="c1", - content="will be lost", message_id="m1", - metadata={"chat_id": "c1"}, - ) - await ch.queue_message(msg) - # Message is buffered but debounce hasn't fired yet - - assert len(ch._message_buffers) == 1 - - # Stop the channel — debounce tasks are cancelled - ch._running = True - await ch.stop() - - # BUG: The buffered message was never published - assert bus.inbound_size == 0 # Documents data loss - _run(_test()) - - def test_send_locks_unbounded_growth(self): - """[B-03] _send_locks grows without bound for unique chat_ids.""" - async def _test(): - ch = StubChannel() - for i in range(100): - msg = OutboundMessage( - channel="stub", chat_id=f"chat_{i}", - content="hi", metadata={"chat_id": f"chat_{i}"}, - ) - await ch.send(msg) - - # All 100 unique chat_ids created a lock - assert len(ch._send_locks) == 100 - # BUG: These are never cleaned up - _run(_test()) - - -# ═══════════════════════════════════════════════════════════════════ -# 11. Edge cases and boundary conditions -# ═══════════════════════════════════════════════════════════════════ - -class TestEdgeCases: - - def test_chunk_text_single_char_limit(self): - chunks = chunk_text("abc", 1) - assert all(len(c) <= 1 for c in chunks) - assert len(chunks) == 3 - - def test_chunk_text_unicode(self): - text = "你好世界" * 100 - chunks = chunk_text(text, 50) - assert all(len(c) <= 50 for c in chunks) - - def test_dedup_cache_rapid_same_id(self): - dc = DedupCache() - assert dc.is_duplicate("x") is False - for _ in range(100): - assert dc.is_duplicate("x") is True - - def test_channel_send_empty_content(self): - async def _test(): - ch = StubChannel() - msg = OutboundMessage(channel="stub", chat_id="c1", content="") - ok = await ch.send(msg) - # Empty content goes through chunk_text which returns [] - assert ok is True - assert len(ch._sent_chunks) == 0 - _run(_test()) - - def test_raw_incoming_defaults(self): - raw = RawIncoming(sender_id="u1", chat_id="c1") - assert raw.text == "" - assert raw.media_files == [] - assert raw.content_annotations == [] - assert raw.is_group is False - assert raw.was_mentioned is True - assert raw.message_id == "" - - def test_outbound_message_no_metadata_chat_id_resolution(self): - """resolve_chat_id falls back to recipient when metadata has no chat_id.""" - ch = StubChannel() - msg = OutboundMessage( - channel="stub", chat_id="fallback_id", content="hi", - metadata={}, - ) - resolved = ch._resolve_chat_id(msg) - assert resolved == "fallback_id" - - def test_health_server_response_structure(self): - """HealthServer builds response with expected keys.""" - from EvoScientist.channels.channel_manager import _HealthServer - - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(StubChannel()) - - hs = _HealthServer(mgr, 0) - resp = hs._build_response() - assert resp["status"] == "healthy" - assert "uptime_seconds" in resp - assert "channels" in resp - assert "queues" in resp - assert "health" in resp diff --git a/tests/test_channel_manager.py b/tests/test_channel_manager.py deleted file mode 100644 index 9cd8656..0000000 --- a/tests/test_channel_manager.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Tests for ChannelManager.""" - -import asyncio - -import pytest - -from EvoScientist.channels.bus.message_bus import MessageBus -from EvoScientist.channels.channel_manager import ChannelManager -from EvoScientist.channels.base import Channel, OutboundMessage - - -def _run(coro): - """Run an async coroutine safely, creating a fresh event loop.""" - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -class _FakeConfig: - text_chunk_limit = 4096 - allowed_senders = None - - -class FakeChannel(Channel): - """Minimal channel for testing.""" - - name = "fake" - - def __init__(self): - super().__init__(_FakeConfig()) - self._started = False - self._stopped = False - self._sent: list[OutboundMessage] = [] - - async def start(self): - self._started = True - - async def stop(self): - self._stopped = True - - async def receive(self): - while True: - try: - msg = await asyncio.wait_for( - self._queue.get(), timeout=0.5, - ) - yield msg - except asyncio.TimeoutError: - return - - async def send(self, message: OutboundMessage) -> bool: - self._sent.append(message) - return True - - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - pass - - -class TestChannelManagerRegister: - def test_register_channel(self): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = FakeChannel() - result = mgr.register(ch) - assert "fake" in mgr.enabled_channels - assert mgr.get_channel("fake") is ch - assert result is ch - - def test_duplicate_register_raises(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(FakeChannel()) - with pytest.raises(ValueError, match="already registered"): - mgr.register(FakeChannel()) - - def test_get_status(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(FakeChannel()) - status = mgr.get_status() - assert "fake" in status - assert status["fake"]["registered"] is True - - -class TestChannelManagerDispatch: - def test_outbound_dispatch_routes_to_channel(self): - async def _test(): - bus = MessageBus() - mgr = ChannelManager(bus) - ch = FakeChannel() - mgr.register(ch) - - # Start only the dispatcher (not full start_all) - dispatch = asyncio.create_task( - mgr._dispatch_outbound() - ) - - # Publish an outbound message - await bus.publish_outbound(OutboundMessage( - channel="fake", chat_id="u1", - content="hello from agent", - )) - - await asyncio.sleep(0.1) - dispatch.cancel() - try: - await dispatch - except asyncio.CancelledError: - pass - - assert len(ch._sent) == 1 - assert ch._sent[0].content == "hello from agent" - assert ch._sent[0].chat_id == "u1" - - _run(_test()) - - -class TestChannelManagerTracking: - def test_record_message(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(FakeChannel()) - - mgr.record_message("fake", "received") - mgr.record_message("fake", "received") - mgr.record_message("fake", "sent") - - assert mgr._message_counts["fake"]["received"] == 2 - assert mgr._message_counts["fake"]["sent"] == 1 - - def test_record_message_unknown_channel(self): - bus = MessageBus() - mgr = ChannelManager(bus) - - # Should not raise, auto-creates entry - mgr.record_message("unknown", "received") - assert mgr._message_counts["unknown"]["received"] == 1 - - def test_get_detailed_status(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(FakeChannel()) - - # Simulate start_all setting start_times - from datetime import datetime - mgr._start_times["fake"] = datetime.now() - mgr._message_counts["fake"] = {"received": 5, "sent": 3} - - status = mgr.get_detailed_status() - assert "fake" in status - assert status["fake"]["registered"] is True - assert status["fake"]["received"] == 5 - assert status["fake"]["sent"] == 3 - assert status["fake"]["uptime_seconds"] >= 0 - assert status["fake"]["start_time"] is not None - - def test_get_detailed_status_no_start_time(self): - bus = MessageBus() - mgr = ChannelManager(bus) - mgr.register(FakeChannel()) - - status = mgr.get_detailed_status() - assert status["fake"]["uptime_seconds"] == 0 - assert status["fake"]["start_time"] is None diff --git a/tests/test_discord_channel.py b/tests/test_discord_channel.py deleted file mode 100644 index 6d5152d..0000000 --- a/tests/test_discord_channel.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Tests for Discord channel implementation.""" - -import asyncio - -import pytest - -from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig -from EvoScientist.channels.base import ChannelError - - -def _run(coro): - """Run an async coroutine safely, creating a fresh event loop.""" - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -class TestDiscordConfig: - def test_default_values(self): - config = DiscordConfig() - assert config.bot_token == "" - assert config.allowed_senders is None - assert config.allowed_channels is None - assert config.text_chunk_limit == 4096 - - def test_custom_values(self): - config = DiscordConfig( - bot_token="test-token", - allowed_senders={"111"}, - allowed_channels={"222"}, - text_chunk_limit=1000, - ) - assert config.bot_token == "test-token" - assert config.allowed_senders == {"111"} - assert config.allowed_channels == {"222"} - assert config.text_chunk_limit == 1000 - - -class TestDiscordChannel: - def test_init(self): - config = DiscordConfig(bot_token="test") - channel = DiscordChannel(config) - assert channel.config is config - assert channel._running is False - - def test_start_raises_without_token_or_library(self): - config = DiscordConfig(bot_token="") - channel = DiscordChannel(config) - with pytest.raises(ChannelError): - _run(channel.start()) - - def test_stop_when_not_running(self): - config = DiscordConfig(bot_token="test") - channel = DiscordChannel(config) - _run(channel.stop()) - - def test_send_returns_false_without_client(self): - from EvoScientist.channels.base import OutboundMessage - - config = DiscordConfig(bot_token="test") - channel = DiscordChannel(config) - msg = OutboundMessage( - channel="discord", - chat_id="123", - content="hello", - metadata={"chat_id": "123"}, - ) - result = _run(channel.send(msg)) - assert result is False diff --git a/tests/test_mention_gating.py b/tests/test_mention_gating.py deleted file mode 100644 index 842efc8..0000000 --- a/tests/test_mention_gating.py +++ /dev/null @@ -1,541 +0,0 @@ -"""Tests for unified mention gating in base.py and per-channel _strip_mention.""" - -import asyncio -from dataclasses import dataclass - - -from EvoScientist.channels.base import Channel, RawIncoming -from EvoScientist.channels.capabilities import ChannelCapabilities - - -# ── Minimal concrete channel for testing base-class logic ───────────── - - -@dataclass -class _StubConfig: - allowed_senders: set[str] | None = None - require_mention: str = "group" - dm_policy: str = "allowlist" - - -class _StubChannel(Channel): - name = "stub" - - async def start(self): - pass - - async def _send_chunk(self, chat_id, formatted_text, raw_text, reply_to, metadata): - pass - - -class _MentionStubChannel(_StubChannel): - """Stub with mention gating enabled.""" - capabilities = ChannelCapabilities(mentions=True) - - -def _run(coro): - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -# ── _should_process tests ──────────────────────────────────────────── - - -class TestShouldProcess: - """Tests for the centralized _should_process gate.""" - - def _make(self, require_mention="group"): - config = _StubConfig(require_mention=require_mention) - return _StubChannel(config) - - def test_dm_always_passes_with_group_mode(self): - ch = self._make("group") - raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=False, was_mentioned=False) - assert ch._should_process(raw) is True - - def test_dm_always_passes_with_always_mode(self): - ch = self._make("always") - raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=False, was_mentioned=False) - assert ch._should_process(raw) is True - - def test_dm_always_passes_with_off_mode(self): - ch = self._make("off") - raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=False, was_mentioned=False) - assert ch._should_process(raw) is True - - def test_group_mentioned_passes_with_group_mode(self): - ch = self._make("group") - raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=True) - assert ch._should_process(raw) is True - - def test_group_not_mentioned_blocked_with_group_mode(self): - ch = self._make("group") - raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=False) - assert ch._should_process(raw) is False - - def test_group_not_mentioned_passes_with_off_mode(self): - ch = self._make("off") - raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=False) - assert ch._should_process(raw) is True - - def test_group_not_mentioned_blocked_with_always_mode(self): - ch = self._make("always") - raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=False) - assert ch._should_process(raw) is False - - def test_group_mentioned_passes_with_always_mode(self): - ch = self._make("always") - raw = RawIncoming(sender_id="u1", chat_id="c1", is_group=True, was_mentioned=True) - assert ch._should_process(raw) is True - - -class TestPipelineGating: - """Tests that _enqueue_raw pipeline integrates mention gating and _strip_mention.""" - - def test_group_not_mentioned_dropped(self): - async def _test(): - config = _StubConfig(require_mention="group") - ch = _MentionStubChannel(config) - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="hello", - is_group=True, was_mentioned=False, - ) - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 0 - _run(_test()) - - def test_group_mentioned_passes(self): - async def _test(): - config = _StubConfig(require_mention="group") - ch = _MentionStubChannel(config) - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="hello", - is_group=True, was_mentioned=True, - ) - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 1 - msg = await ch._queue.get() - assert msg.content == "hello" - _run(_test()) - - def test_dm_passes_even_when_not_mentioned(self): - async def _test(): - config = _StubConfig(require_mention="group") - ch = _MentionStubChannel(config) - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="hello", - is_group=False, was_mentioned=False, - ) - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 1 - _run(_test()) - - def test_strip_mention_called_for_group(self): - """When is_group=True, _strip_mention should be applied to text.""" - async def _test(): - config = _StubConfig(require_mention="group") - ch = _MentionStubChannel(config) - ch._strip_mention = lambda text: text.replace("@bot ", "").strip() - # Rebuild middlewares so MentionGatingMiddleware picks up new strip_fn - ch._inbound_middlewares = ch._build_inbound_middlewares() - - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="@bot hello", - is_group=True, was_mentioned=True, - ) - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 1 - msg = await ch._queue.get() - assert msg.content == "hello" - _run(_test()) - - def test_strip_mention_not_called_for_dm(self): - """When is_group=False, _strip_mention should NOT be applied.""" - async def _test(): - config = _StubConfig(require_mention="group") - ch = _MentionStubChannel(config) - ch._strip_mention = lambda text: text.replace("@bot ", "").strip() - ch._inbound_middlewares = ch._build_inbound_middlewares() - - raw = RawIncoming( - sender_id="u1", chat_id="c1", text="@bot hello", - is_group=False, was_mentioned=True, - ) - await ch._enqueue_raw(raw) - assert ch._queue.qsize() == 1 - msg = await ch._queue.get() - assert msg.content == "@bot hello" - _run(_test()) - - -# ── Per-channel _strip_mention tests ───────────────────────────────── - - -class TestTelegramStripMention: - def test_strip_username(self): - from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig - - config = TelegramConfig(bot_token="test") - ch = TelegramChannel(config) - ch._bot_username = "mybot" - assert ch._strip_mention("@mybot hello") == "hello" - assert ch._strip_mention("@MyBot hello") == "hello" - assert ch._strip_mention("hello @mybot world") == "hello world" - - def test_no_username(self): - from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig - - config = TelegramConfig(bot_token="test") - ch = TelegramChannel(config) - ch._bot_username = "" - assert ch._strip_mention("@mybot hello") == "@mybot hello" - - -class TestDiscordStripMention: - def test_strip_user_id(self): - from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig - - config = DiscordConfig(bot_token="test") - ch = DiscordChannel(config) - - # Mock client.user - class FakeUser: - id = 123456789 - class FakeClient: - user = FakeUser() - ch._client = FakeClient() - - assert ch._strip_mention("<@123456789> hello") == "hello" - assert ch._strip_mention("<@!123456789> hello") == "hello" - assert ch._strip_mention("hello <@123456789> world") == "hello world" - - def test_no_client(self): - from EvoScientist.channels.discord.channel import DiscordChannel, DiscordConfig - - config = DiscordConfig(bot_token="test") - ch = DiscordChannel(config) - ch._client = None - assert ch._strip_mention("<@123> hello") == "<@123> hello" - - -class TestSlackStripMention: - def test_strip_user_id(self): - from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig - - config = SlackConfig(bot_token="test", app_token="xapp-test") - ch = SlackChannel(config) - ch._bot_user_id = "U123ABC" - assert ch._strip_mention("<@U123ABC> hello") == "hello" - assert ch._strip_mention("hello <@U123ABC> world") == "hello world" - - def test_no_bot_user_id(self): - from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig - - config = SlackConfig(bot_token="test", app_token="xapp-test") - ch = SlackChannel(config) - assert ch._strip_mention("<@U123> hello") == "<@U123> hello" - - -# ── iMessage pipeline integration tests ────────────────────────── - - -class TestIMessageConfig: - def test_default_values(self): - from EvoScientist.channels.imessage.channel_rpc import IMessageConfig - - config = IMessageConfig() - assert config.allowed_senders is None - assert config.include_attachments is True - assert config.text_chunk_limit == 4096 - - def test_allowed_senders_is_set(self): - from EvoScientist.channels.imessage.channel_rpc import IMessageConfig - - config = IMessageConfig(allowed_senders={"+1234", "chat_id:5"}) - assert isinstance(config.allowed_senders, set) - assert "+1234" in config.allowed_senders - - -class TestIMessageChannel: - def test_is_ready_without_client(self): - from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc - - ch = IMessageChannelRpc() - assert ch._is_ready() is False - - def test_is_allowed_always_true(self): - from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc - - ch = IMessageChannelRpc() - assert ch.is_allowed("anyone") is True - - def test_build_inbound_uses_rich_filtering(self): - from EvoScientist.channels.imessage.channel_rpc import ( - IMessageChannelRpc, IMessageConfig, - ) - - config = IMessageConfig(allowed_senders={"+15551234567"}) - ch = IMessageChannelRpc(config) - - # Allowed sender passes - raw = RawIncoming( - sender_id="+15551234567", chat_id="c1", text="hi", - metadata={"chat_id": 1, "chat_guid": None}, - ) - assert ch._build_inbound(raw) is not None - - # Disallowed sender blocked - raw2 = RawIncoming( - sender_id="+19999999999", chat_id="c1", text="hi", - metadata={"chat_id": 1, "chat_guid": None}, - ) - assert ch._build_inbound(raw2) is None - - def test_add_remove_allowed_senders(self): - from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc - - ch = IMessageChannelRpc() - assert ch.config.allowed_senders is None - - ch.add_allowed_sender("+15551234567") - assert ch.config.allowed_senders is not None - assert len(ch.config.allowed_senders) == 1 - - ch.remove_allowed_sender("+15551234567") - assert len(ch.config.allowed_senders) == 0 - - ch.clear_allowed_senders() - assert ch.config.allowed_senders is None - - def test_list_allowed_senders(self): - from EvoScientist.channels.imessage.channel_rpc import ( - IMessageChannelRpc, IMessageConfig, - ) - - ch = IMessageChannelRpc() - assert ch.list_allowed_senders() == [] - - config = IMessageConfig(allowed_senders={"a", "b"}) - ch2 = IMessageChannelRpc(config) - result = ch2.list_allowed_senders() - assert set(result) == {"a", "b"} - - def test_handle_message_sets_is_group(self): - import asyncio - from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc - - ch = IMessageChannelRpc() - # Capture what _build_inbound receives - captured = [] - original = ch._build_inbound - def spy(raw): - captured.append(raw) - return original(raw) - ch._build_inbound = spy - - loop = asyncio.new_event_loop() - try: - loop.run_until_complete(ch._handle_message({ - "message": { - "sender": "+1234", - "text": "hello", - "is_group": True, - "chat_id": 42, - "id": "msg1", - } - })) - finally: - loop.close() - - assert len(captured) == 1 - assert captured[0].is_group is True - assert captured[0].was_mentioned is True - - -# ── Feishu config tests ────────────────────────────────────────── - - -class TestFeishuConfig: - def test_allowed_channels(self): - from EvoScientist.channels.feishu.channel import FeishuConfig - - config = FeishuConfig( - app_id="test", app_secret="test", - allowed_channels={"oc_abc123"}, - ) - assert config.allowed_channels == {"oc_abc123"} - - -# ── Slack retry tests ──────────────────────────────────────────── - - -class TestSlackRetry: - def test_extract_retry_after_from_response(self): - from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig - - config = SlackConfig(bot_token="test", app_token="xapp-test") - ch = SlackChannel(config) - - # Simulate a SlackApiError-like exception with response headers - class FakeResponse: - headers = {"Retry-After": "30"} - class FakeError(Exception): - response = FakeResponse() - - result = ch._extract_retry_after(FakeError()) - assert result == 30.0 - - def test_extract_retry_after_fallback(self): - from EvoScientist.channels.slack.channel import SlackChannel, SlackConfig - - config = SlackConfig(bot_token="test", app_token="xapp-test") - ch = SlackChannel(config) - - # Plain exception without response attribute - result = ch._extract_retry_after(Exception("some error")) - assert result == 1.0 # base class default - - -# ── Attachment size check tests ────────────────────────────────── - - -class TestAttachmentSizeCheck: - """Tests for _check_attachment_size and _download_attachment size guard.""" - - def test_check_attachment_size_within_limit(self): - config = _StubConfig() - ch = _StubChannel(config) - assert ch._check_attachment_size(100, "small.txt") is None - - def test_check_attachment_size_exceeds_limit(self): - from EvoScientist.channels.base import MAX_ATTACHMENT_BYTES - - config = _StubConfig() - ch = _StubChannel(config) - result = ch._check_attachment_size(MAX_ATTACHMENT_BYTES + 1, "big.zip") - assert result is not None - assert "too large" in result - - -# ── Feishu _strip_mention tests ────────────────────────────────── - - -class TestFeishuStripMention: - def test_strip_bot_mention_placeholder(self): - from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig - - config = FeishuConfig(app_id="test", app_secret="test") - ch = FeishuChannel(config) - ch._mention_names = ["@_user_1"] - assert ch._strip_mention("@_user_1 hello world") == "hello world" - - def test_strip_multiple_mentions(self): - from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig - - config = FeishuConfig(app_id="test", app_secret="test") - ch = FeishuChannel(config) - ch._mention_names = ["@_user_1", "@_user_2"] - result = ch._strip_mention("@_user_1 @_user_2 hello") - assert result == "hello" - - def test_no_mention_names(self): - from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig - - config = FeishuConfig(app_id="test", app_secret="test") - ch = FeishuChannel(config) - assert ch._strip_mention("hello world") == "hello world" - - -# ── Telegram allowed_channels tests ────────────────────────────── - - -class TestTelegramConfig: - def test_allowed_channels_field_exists(self): - from EvoScientist.channels.telegram.channel import TelegramConfig - - config = TelegramConfig(bot_token="test", allowed_channels={"-100123"}) - assert config.allowed_channels == {"-100123"} - - def test_allowed_channels_default_none(self): - from EvoScientist.channels.telegram.channel import TelegramConfig - - config = TelegramConfig(bot_token="test") - assert config.allowed_channels is None - - def test_channel_allow_list_integration(self): - from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig - - config = TelegramConfig(bot_token="test", allowed_channels={"-100123"}) - ch = TelegramChannel(config) - assert ch.is_channel_allowed("-100123") is True - assert ch.is_channel_allowed("-100999") is False - - def test_channel_allow_list_empty_allows_all(self): - from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig - - config = TelegramConfig(bot_token="test") - ch = TelegramChannel(config) - assert ch.is_channel_allowed("-100999") is True - - -# ── Feishu retry tests ─────────────────────────────────────────── - - -class TestFeishuRetry: - def test_extract_retry_after_rate_limit(self): - from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig - - config = FeishuConfig(app_id="test", app_secret="test") - ch = FeishuChannel(config) - result = ch._extract_retry_after(Exception("code 99991400: rate limit")) - assert result == 2.0 - - def test_extract_retry_after_generic(self): - from EvoScientist.channels.feishu.channel import FeishuChannel, FeishuConfig - - config = FeishuConfig(app_id="test", app_secret="test") - ch = FeishuChannel(config) - result = ch._extract_retry_after(Exception("some error")) - assert result == 1.0 - - -# ── iMessage reply_to tests ────────────────────────────────────── - - -class TestIMessageReplyTo: - def test_send_chunk_includes_reply_to(self): - """Verify reply_to is passed through to RPC params.""" - from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc - - ch = IMessageChannelRpc() - # Mock the RPC client - captured_params = {} - - class FakeClient: - async def request(self, method, params): - captured_params.update(params) - return {} - - ch._client = FakeClient() - - _run(ch._send_chunk("chat123", "hello", "hello", "msg42", {"chat_id": "chat123"})) - assert captured_params.get("reply_to") == "msg42" - - def test_send_chunk_omits_reply_to_when_none(self): - from EvoScientist.channels.imessage.channel_rpc import IMessageChannelRpc - - ch = IMessageChannelRpc() - captured_params = {} - - class FakeClient: - async def request(self, method, params): - captured_params.update(params) - return {} - - ch._client = FakeClient() - - _run(ch._send_chunk("chat123", "hello", "hello", None, {"chat_id": "chat123"})) - assert "reply_to" not in captured_params diff --git a/tests/test_message_bus.py b/tests/test_message_bus.py deleted file mode 100644 index 700423d..0000000 --- a/tests/test_message_bus.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Tests for the Message Bus decoupling layer.""" - -import asyncio - - -from EvoScientist.channels.bus.events import InboundMessage, OutboundMessage -from EvoScientist.channels.bus.message_bus import MessageBus - - -def _run(coro): - """Run an async coroutine safely, creating a fresh event loop.""" - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -# ── Event tests ── - - -class TestInboundMessage: - def test_session_key(self): - msg = InboundMessage( - channel="telegram", sender_id="u1", - chat_id="c1", content="hi", - ) - assert msg.session_key == "telegram:c1" - - def test_defaults(self): - msg = InboundMessage( - channel="discord", sender_id="u2", - chat_id="c2", content="hello", - ) - assert msg.media == [] - assert msg.metadata == {} - assert msg.message_id == "" - - -class TestOutboundMessage: - def test_fields(self): - msg = OutboundMessage( - channel="telegram", chat_id="c1", content="reply", - ) - assert msg.channel == "telegram" - assert msg.chat_id == "c1" - assert msg.reply_to is None - assert msg.media == [] - - -# ── MessageBus tests ── - - -class TestMessageBus: - def test_inbound_publish_consume(self): - async def _test(): - bus = MessageBus() - msg = InboundMessage( - channel="telegram", sender_id="u1", - chat_id="c1", content="hello", - ) - await bus.publish_inbound(msg) - assert bus.inbound_size == 1 - got = await bus.consume_inbound() - assert got is msg - assert bus.inbound_size == 0 - _run(_test()) - - def test_outbound_publish_consume(self): - async def _test(): - bus = MessageBus() - msg = OutboundMessage( - channel="discord", chat_id="c1", content="reply", - ) - await bus.publish_outbound(msg) - assert bus.outbound_size == 1 - got = await bus.consume_outbound() - assert got is msg - assert bus.outbound_size == 0 - _run(_test()) - - def test_subscribe_and_dispatch(self): - async def _test(): - bus = MessageBus() - received = [] - - async def callback(msg): - received.append(msg) - - bus.subscribe_outbound("telegram", callback) - - msg = OutboundMessage( - channel="telegram", chat_id="c1", content="hi", - ) - await bus.publish_outbound(msg) - - dispatch = asyncio.create_task(bus.dispatch_outbound()) - await asyncio.sleep(0.05) - bus.stop() - await asyncio.sleep(0.05) - dispatch.cancel() - - assert len(received) == 1 - assert received[0] is msg - _run(_test()) - - def test_stop(self): - bus = MessageBus() - assert bus._running is False - bus.stop() - assert bus._running is False diff --git a/tests/test_stream_state.py b/tests/test_stream_state.py index 29a3d00..c5e142d 100644 --- a/tests/test_stream_state.py +++ b/tests/test_stream_state.py @@ -554,5 +554,162 @@ class TestParseTodoItemsAdvanced: # ============================================================================= -# ChannelState queue mechanism (removed — replaced by bus mode in channel.py) +# ChannelState queue mechanism # ============================================================================= + +class TestChannelState: + """Tests for _ChannelState queue-based communication.""" + + def test_enqueue_creates_message_in_queue(self): + """enqueue() should add a ChannelMessage to the queue.""" + from EvoScientist.cli import _ChannelState, ChannelMessage + import queue + + # Clear any existing messages + while True: + try: + _ChannelState.message_queue.get_nowait() + except queue.Empty: + break + + msg_id, event = _ChannelState.enqueue("test content", "sender@test.com", "Email") + assert msg_id is not None + assert event is not None + + # Message should be in queue + msg = _ChannelState.message_queue.get_nowait() + assert isinstance(msg, ChannelMessage) + assert msg.content == "test content" + assert msg.sender == "sender@test.com" + assert msg.channel_type == "Email" + assert msg.msg_id == msg_id + + def test_enqueue_creates_pending_response_slot(self): + """enqueue() should create a response slot for the message.""" + from EvoScientist.cli import _ChannelState + import queue + + # Clear queue and responses + while True: + try: + _ChannelState.message_queue.get_nowait() + except queue.Empty: + break + _ChannelState.pending_responses.clear() + + msg_id, _ = _ChannelState.enqueue("test", "sender", "iMessage") + assert msg_id in _ChannelState.pending_responses + assert _ChannelState.pending_responses[msg_id]["response"] is None + + # Cleanup + _ChannelState.message_queue.get_nowait() + + def test_set_response_updates_slot_and_signals(self): + """set_response() should update response and signal the event.""" + from EvoScientist.cli import _ChannelState + import queue + + # Clear + while True: + try: + _ChannelState.message_queue.get_nowait() + except queue.Empty: + break + _ChannelState.pending_responses.clear() + + msg_id, event = _ChannelState.enqueue("test", "sender", "iMessage") + _ChannelState.message_queue.get_nowait() # Remove from queue + + assert not event.is_set() + _ChannelState.set_response(msg_id, "response text") + + assert event.is_set() + assert _ChannelState.pending_responses[msg_id]["response"] == "response text" + + # Cleanup + _ChannelState.pending_responses.clear() + + def test_get_response_waits_and_retrieves(self): + """get_response() should wait for response and return it.""" + from EvoScientist.cli import _ChannelState + import threading + import queue + + # Clear + while True: + try: + _ChannelState.message_queue.get_nowait() + except queue.Empty: + break + _ChannelState.pending_responses.clear() + + msg_id, _ = _ChannelState.enqueue("test", "sender", "iMessage") + _ChannelState.message_queue.get_nowait() + + # Set response in another thread + def set_later(): + import time + time.sleep(0.05) + _ChannelState.set_response(msg_id, "async response") + + t = threading.Thread(target=set_later) + t.start() + + response = _ChannelState.get_response(msg_id, timeout=1.0) + t.join() + + assert response == "async response" + assert msg_id not in _ChannelState.pending_responses # Cleaned up + + def test_get_response_returns_none_on_timeout(self): + """get_response() should return None if timeout expires.""" + from EvoScientist.cli import _ChannelState + import queue + + # Clear + while True: + try: + _ChannelState.message_queue.get_nowait() + except queue.Empty: + break + _ChannelState.pending_responses.clear() + + msg_id, _ = _ChannelState.enqueue("test", "sender", "iMessage") + _ChannelState.message_queue.get_nowait() + + # Don't set response, let it timeout + response = _ChannelState.get_response(msg_id, timeout=0.01) + assert response is None + + # Cleanup + _ChannelState.pending_responses.clear() + + def test_get_response_returns_none_for_unknown_id(self): + """get_response() should return None for unknown message ID.""" + from EvoScientist.cli import _ChannelState + response = _ChannelState.get_response("nonexistent-id", timeout=0.01) + assert response is None + + def test_channel_message_dataclass(self): + """ChannelMessage should store all fields correctly.""" + from EvoScientist.cli import ChannelMessage + + msg = ChannelMessage( + msg_id="id123", + content="Hello", + sender="+1234567890", + channel_type="iMessage", + metadata={"key": "value"}, + ) + assert msg.msg_id == "id123" + assert msg.content == "Hello" + assert msg.sender == "+1234567890" + assert msg.channel_type == "iMessage" + assert msg.metadata == {"key": "value"} + + def test_channel_message_default_metadata(self): + """ChannelMessage metadata should default to None.""" + from EvoScientist.cli import ChannelMessage + + msg = ChannelMessage("id", "content", "sender", "type") + assert msg.metadata is None diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py deleted file mode 100644 index e50f3a5..0000000 --- a/tests/test_telegram_channel.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Tests for Telegram channel implementation.""" - -import asyncio - -import pytest - -from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig -from EvoScientist.channels.base import ChannelError - - -def _run(coro): - """Run an async coroutine safely, creating a fresh event loop.""" - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -class TestTelegramConfig: - def test_default_values(self): - config = TelegramConfig() - assert config.bot_token == "" - assert config.allowed_senders is None - assert config.text_chunk_limit == 4096 - - def test_custom_values(self): - config = TelegramConfig( - bot_token="test-token", - allowed_senders={"123", "456"}, - text_chunk_limit=2000, - ) - assert config.bot_token == "test-token" - assert config.allowed_senders == {"123", "456"} - assert config.text_chunk_limit == 2000 - - -class TestTelegramChannel: - def test_init(self): - config = TelegramConfig(bot_token="test") - channel = TelegramChannel(config) - assert channel.config is config - assert channel._running is False - - def test_start_raises_without_token(self): - config = TelegramConfig(bot_token="") - channel = TelegramChannel(config) - with pytest.raises(ChannelError, match="bot token"): - _run(channel.start()) - - def test_stop_when_not_running(self): - config = TelegramConfig(bot_token="test") - channel = TelegramChannel(config) - _run(channel.stop()) - - def test_send_returns_false_without_app(self): - from EvoScientist.channels.base import OutboundMessage - - config = TelegramConfig(bot_token="test") - channel = TelegramChannel(config) - msg = OutboundMessage( - channel="telegram", - chat_id="123", - content="hello", - metadata={"chat_id": "123"}, - ) - result = _run(channel.send(msg)) - assert result is False diff --git a/tests/test_wechat_channel.py b/tests/test_wechat_channel.py deleted file mode 100644 index e0ae457..0000000 --- a/tests/test_wechat_channel.py +++ /dev/null @@ -1,458 +0,0 @@ -"""Tests for WeChat channel implementation.""" - -import asyncio -import hashlib -import time -import xml.etree.ElementTree as ET - -import pytest - -from EvoScientist.channels.wechat.channel import ( - WeChatChannel, - WeComConfig, - WeChatMPConfig, - _strip_markdown, -) -from EvoScientist.channels.wechat.crypto import ( - WeChatCrypto, - parse_xml, - _pkcs7_pad, - _pkcs7_unpad, -) -from EvoScientist.channels.base import ChannelError - - -def _run(coro): - """Run an async coroutine safely, creating a fresh event loop.""" - loop = asyncio.new_event_loop() - try: - return loop.run_until_complete(coro) - finally: - loop.close() - - -# ── Config tests ────────────────────────────────────────────────── - -class TestWeComConfig: - def test_default_values(self): - config = WeComConfig() - assert config.corp_id == "" - assert config.agent_id == "" - assert config.secret == "" - assert config.webhook_port == 9001 - assert config.allowed_senders is None - assert config.text_chunk_limit == 4096 - - def test_custom_values(self): - config = WeComConfig( - corp_id="corp123", - agent_id="1000001", - secret="my-secret", - token="my-token", - encoding_aes_key="a" * 43, - webhook_port=8080, - allowed_senders={"user1", "user2"}, - ) - assert config.corp_id == "corp123" - assert config.agent_id == "1000001" - assert config.allowed_senders == {"user1", "user2"} - assert config.webhook_port == 8080 - - -class TestWeChatMPConfig: - def test_default_values(self): - config = WeChatMPConfig() - assert config.app_id == "" - assert config.app_secret == "" - assert config.webhook_port == 9001 - - def test_custom_values(self): - config = WeChatMPConfig( - app_id="wx1234", - app_secret="secret", - token="mp-token", - ) - assert config.app_id == "wx1234" - - -# ── Channel init / lifecycle tests ──────────────────────────────── - -class TestWeChatChannelInit: - def test_wecom_init(self): - config = WeComConfig(corp_id="corp", agent_id="1", secret="s") - channel = WeChatChannel(config, backend="wecom") - assert channel.name == "wechat" - assert channel._backend == "wecom" - assert channel._running is False - - def test_mp_init(self): - config = WeChatMPConfig(app_id="wx", app_secret="s") - channel = WeChatChannel(config, backend="wechatmp") - assert channel._backend == "wechatmp" - - def test_start_raises_without_corp_id(self): - config = WeComConfig(corp_id="", agent_id="1", secret="s") - channel = WeChatChannel(config, backend="wecom") - with pytest.raises(ChannelError, match="corp_id"): - _run(channel.start()) - - def test_start_raises_without_secret(self): - config = WeComConfig(corp_id="corp", agent_id="1", secret="") - channel = WeChatChannel(config, backend="wecom") - with pytest.raises(ChannelError, match="secret"): - _run(channel.start()) - - def test_start_raises_without_agent_id(self): - config = WeComConfig(corp_id="corp", agent_id="", secret="s") - channel = WeChatChannel(config, backend="wecom") - with pytest.raises(ChannelError, match="agent_id"): - _run(channel.start()) - - def test_start_raises_mp_without_app_id(self): - config = WeChatMPConfig(app_id="", app_secret="s") - channel = WeChatChannel(config, backend="wechatmp") - with pytest.raises(ChannelError, match="app_id"): - _run(channel.start()) - - def test_stop_when_not_running(self): - config = WeComConfig(corp_id="c", agent_id="1", secret="s") - channel = WeChatChannel(config, backend="wecom") - _run(channel.stop()) # Should not raise - - def test_send_returns_false_without_client(self): - from EvoScientist.channels.base import OutboundMessage - - config = WeComConfig(corp_id="c", agent_id="1", secret="s") - channel = WeChatChannel(config, backend="wecom") - msg = OutboundMessage( - channel="wechat", - chat_id="user1", - content="hello", - metadata={"chat_id": "user1"}, - ) - result = _run(channel.send(msg)) - assert result is False - - -# ── Markdown stripping tests ────────────────────────────────────── - -class TestStripMarkdown: - def test_plain_text(self): - assert _strip_markdown("hello world") == "hello world" - - def test_bold(self): - assert _strip_markdown("**bold**") == "bold" - - def test_italic(self): - assert _strip_markdown("_italic_") == "italic" - - def test_code(self): - assert _strip_markdown("`code`") == "code" - - def test_link(self): - result = _strip_markdown("[text](https://example.com)") - assert "text" in result - assert "https://example.com" in result - - def test_heading(self): - assert _strip_markdown("## Title").strip() == "Title" - - def test_list_items(self): - result = _strip_markdown("- item1\n- item2") - assert "• item1" in result - assert "• item2" in result - - def test_strikethrough(self): - assert _strip_markdown("~~deleted~~") == "deleted" - - def test_code_block(self): - text = "```python\nprint('hi')\n```" - result = _strip_markdown(text) - assert "print('hi')" in result - - -# ── XML parsing tests ───────────────────────────────────────────── - -class TestParseXml: - def test_basic_text_message(self): - xml = ( - "" - "" - "" - "" - "" - "1234" - "1700000000" - "" - ) - data = parse_xml(xml) - assert data["MsgType"] == "text" - assert data["Content"] == "hello" - assert data["FromUserName"] == "user123" - assert data["MsgId"] == "1234" - - def test_image_message(self): - xml = ( - "" - "" - "" - "" - "" - "" - ) - data = parse_xml(xml) - assert data["MsgType"] == "image" - assert data["PicUrl"] == "https://example.com/img.jpg" - - def test_event_message(self): - xml = ( - "" - "" - "" - "" - "" - ) - data = parse_xml(xml) - assert data["MsgType"] == "event" - assert data["Event"] == "subscribe" - - -# ── Crypto tests ────────────────────────────────────────────────── - -class TestPKCS7: - def test_pad_unpad_roundtrip(self): - data = b"hello" - padded = _pkcs7_pad(data) - assert len(padded) % 32 == 0 - assert _pkcs7_unpad(padded) == data - - def test_pad_block_aligned(self): - data = b"x" * 32 - padded = _pkcs7_pad(data) - assert len(padded) == 64 # full padding block added - assert _pkcs7_unpad(padded) == data - - -class TestWeChatCrypto: - """Test the encryption/decryption roundtrip. - - Uses a deterministic 43-char EncodingAESKey. - """ - - # Skip encryption tests when no crypto backend is available - _has_crypto = False - try: - from Crypto.Cipher import AES as _aes # noqa: F401 - _has_crypto = True - except ImportError: - try: - import pyaes as _pyaes # noqa: F401 - _has_crypto = True - except ImportError: - pass - pytestmark = pytest.mark.skipif( - not _has_crypto, - reason="pycryptodome or pyaes required for encryption tests", - ) - - @pytest.fixture - def crypto(self): - # 43 base64 chars → 32 bytes AES key - key = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" - return WeChatCrypto( - token="test_token", - encoding_aes_key=key, - app_id="wx_test_app", - ) - - def test_encrypt_decrypt_roundtrip(self, crypto): - msg = "Hello WeChat!" - encrypted = crypto.encrypt(msg) - decrypted, app_id = crypto.decrypt(encrypted) - assert decrypted == msg - assert app_id == "wx_test_app" - - def test_verify_signature(self, crypto): - timestamp = "1609459200" - nonce = "abc123" - parts = sorted([crypto.token, timestamp, nonce]) - expected = hashlib.sha1("".join(parts).encode()).hexdigest() - assert crypto.verify_signature(expected, timestamp, nonce) - assert not crypto.verify_signature("wrong", timestamp, nonce) - - def test_verify_signature_with_encrypt(self, crypto): - timestamp = "1609459200" - nonce = "abc123" - encrypt = "some_encrypted_data" - parts = sorted([crypto.token, timestamp, nonce, encrypt]) - expected = hashlib.sha1("".join(parts).encode()).hexdigest() - assert crypto.verify_signature(expected, timestamp, nonce, encrypt) - - def test_generate_signature(self, crypto): - encrypt = "test_encrypted" - timestamp = "1609459200" - nonce = "abc" - sig = crypto.generate_signature(encrypt, timestamp, nonce) - parts = sorted([crypto.token, timestamp, nonce, encrypt]) - expected = hashlib.sha1("".join(parts).encode()).hexdigest() - assert sig == expected - - def test_wrap_encrypted_reply(self, crypto): - msg = "Reply" - xml_reply = crypto.wrap_encrypted_reply(msg) - assert "" in xml_reply - assert "" in xml_reply - assert "" in xml_reply - assert "" in xml_reply - - # Parse and verify the encrypted content decrypts back - root = ET.fromstring(xml_reply) - encrypt = root.find("Encrypt").text - decrypted, app_id = crypto.decrypt(encrypt) - assert decrypted == msg - - -# ── Message processing tests ────────────────────────────────────── - -class TestMessageProcessing: - """Test the _process_message method with various XML payloads.""" - - def _make_channel(self): - config = WeComConfig( - corp_id="corp", agent_id="1", secret="s", - ) - return WeChatChannel(config, backend="wecom") - - def test_text_message_queued(self): - channel = self._make_channel() - - async def _test(): - await channel._process_message({ - "MsgType": "text", - "Content": "Hello!", - "FromUserName": "user1", - "ToUserName": "bot", - "MsgId": "100", - "CreateTime": str(int(time.time())), - }) - # Check message was enqueued - assert not channel._queue.empty() - msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) - assert msg.content == "Hello!" - assert msg.sender_id == "user1" - assert msg.channel == "wechat" - - _run(_test()) - - def test_location_message(self): - channel = self._make_channel() - - async def _test(): - await channel._process_message({ - "MsgType": "location", - "Location_X": "39.9", - "Location_Y": "116.4", - "Label": "Beijing", - "FromUserName": "user1", - "ToUserName": "bot", - "MsgId": "101", - "CreateTime": str(int(time.time())), - }) - msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) - assert "Beijing" in msg.content - assert "39.9" in msg.content - - _run(_test()) - - def test_voice_recognition(self): - channel = self._make_channel() - - async def _test(): - await channel._process_message({ - "MsgType": "voice", - "Recognition": "你好世界", - "FromUserName": "user1", - "ToUserName": "bot", - "MsgId": "102", - "CreateTime": str(int(time.time())), - }) - msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) - assert "你好世界" in msg.content - - _run(_test()) - - def test_link_message(self): - channel = self._make_channel() - - async def _test(): - await channel._process_message({ - "MsgType": "link", - "Title": "Test Link", - "Description": "A description", - "Url": "https://example.com", - "FromUserName": "user1", - "ToUserName": "bot", - "MsgId": "103", - "CreateTime": str(int(time.time())), - }) - msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) - assert "Test Link" in msg.content - assert "https://example.com" in msg.content - - _run(_test()) - - def test_subscribe_event(self): - channel = self._make_channel() - - async def _test(): - await channel._process_message({ - "MsgType": "event", - "Event": "subscribe", - "FromUserName": "user1", - "ToUserName": "bot", - "MsgId": "", - "CreateTime": str(int(time.time())), - }) - msg = await asyncio.wait_for(channel._queue.get(), timeout=1.0) - assert "关注" in msg.content - - _run(_test()) - - def test_unsubscribe_ignored(self): - channel = self._make_channel() - - async def _test(): - await channel._process_message({ - "MsgType": "event", - "Event": "unsubscribe", - "FromUserName": "user1", - "ToUserName": "bot", - "MsgId": "", - "CreateTime": str(int(time.time())), - }) - assert channel._queue.empty() - - _run(_test()) - - def test_empty_message_ignored(self): - channel = self._make_channel() - - async def _test(): - await channel._process_message({ - "MsgType": "text", - "Content": "", - "FromUserName": "", - "ToUserName": "bot", - }) - assert channel._queue.empty() - - _run(_test()) - - -# ── Registration test ───────────────────────────────────────────── - -class TestChannelRegistration: - def test_wechat_registered(self): - from EvoScientist.channels.channel_manager import available_channels - channels = available_channels() - assert "wechat" in channels