EvoScientist provides unified integration with 11 messaging platforms. This document covers the architecture overview, capability matrix, and detailed deployment guide for each channel.
EvoScientist provides unified integration with 10 messaging platforms. This document covers the architecture overview, message processing pipeline, capability matrix, security model, deployment guides, and troubleshooting.
Configuration file: `~/.config/evoscientist/config.yaml` (or use environment variables with the `EVOSCIENTIST_` prefix).
1. OutboundMessage arrives on MessageBus outbound queue
│
2. Dispatcher routes to origin channel by name
│
3. Channel.send() processes the response:
├── Stop typing indicator
├── Format text (Markdown → platform format)
├── Chunk text to platform limit (code-block-aware splitting)
├── Send each chunk via _send_chunk() with format fallback
├── Send media attachments via _send_media_impl()
└── Retry on transient errors (exponential backoff)
```
### Text Chunking
Long responses are split intelligently with this priority:
1. Markdown code block fence boundaries
2. Double newlines (paragraph breaks)
3. Single newlines
4. Space characters
5. Hard cut at limit (last resort)
Code blocks are never split mid-block when possible. Each chunk is sent as a separate message.
## Middleware Pipeline
Middleware runs sequentially on each inbound message. Each middleware can pass, modify, or drop the message.
### 1. DedupMiddleware
Prevents duplicate message processing using a bounded LRU cache with TTL.
- Cache size: 1000 entries (configurable)
- TTL: 60 seconds
- Key: `message_id` from the platform
- Messages with the same ID within the TTL window are silently dropped
### 2. AllowListMiddleware
Enforces sender and channel restrictions based on the `dm_policy` config.
| Policy | Behavior |
|--------|----------|
| `"open"` | Accept messages from anyone |
| `"allowlist"` | Only accept from `allowed_senders` / `allowed_channels` |
| `"pairing"` | Require DM pairing before accepting (see PairingMiddleware) |
When `allowed_senders` is set (non-empty), only messages from listed sender IDs pass through. Same for `allowed_channels`.
### 3. PairingMiddleware
Handles an interactive DM pairing flow for the `"pairing"` dm_policy.
- First message from an unknown sender triggers a pairing request
- The sender must provide a valid pairing code
- Once paired, the sender is added to the allowlist for future messages
### 4. GroupHistoryMiddleware
Buffers recent group chat messages to provide conversation context.
- Only active when `capabilities.groups = True`
- Maintains a per-chat rolling buffer (default: 50 messages, 5-minute max age)
- When the bot is mentioned in a group, recent history is injected into the message metadata so the agent can see prior context
- Non-mentioned group messages are buffered but not forwarded (see MentionGating)
### 5. MentionGatingMiddleware
Controls whether the bot responds in group chats.
| `require_mention` | Behavior |
|-------------------|----------|
| `True` / `"group"` | Only respond to @mentions in groups; always respond in DMs |
| `False` / `"none"` | Respond to all messages in all contexts |
| `"always"` | Require @mention even in DMs |
Default: `"group"` — the bot ignores group messages unless explicitly @mentioned.
Mention detection is platform-specific:
- **Telegram**: checks for `@bot_username` in text
- **Discord**: checks `message.mentions` for bot user
- **Slack**: handled via separate `app_mention` event type
- **Feishu**: checks `mentions` array in event payload
- **DingTalk**: checks `isInAtList` flag or `atUsers` array
- **WeChat (WeCom)**: checks `AtUserList` XML field
## Capability Matrix
| Channel | Format | Max Len | Media | Voice | Sticker | Location | Video | Typing | Reaction | Thread | Group | @Mention | No Public IP | Token Refresh | Proxy | Allowlist |
- API tokens are stored in the config file or environment variables, never logged at INFO level
- Discord logs only the first 8 and last 4 characters of the bot token for debugging
- WeChat/Feishu tokens are auto-refreshed before expiry (5-minute margin on 2-hour TTL)
- Webhook signature verification is enforced when `token`/`encoding_aes_key` is configured (WeChat, Feishu)
### Group Chat Behavior
By default, the bot only responds in group chats when explicitly @mentioned. This prevents the bot from responding to every message in a busy group. Configure via:
**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.
**Technical details:** Long polling mode, `drop_pending_updates=True` on startup to skip backlog. Markdown to 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. ACK reaction (eyes emoji) on message receipt, removed after reply. Retry: 3 attempts, min delay 0.4s, parse errors not retried. Text chunk limit: 4000 chars.
**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.
**Technical details:** WebSocket Gateway (`discord.py`). In server channels, only responds when @mentioned; DMs respond directly. Thread-aware: messages in threads are tracked with `parent_channel_id` and `thread_id`. Replies via `MessageReference`. Message cache (200 entries) for ACK emoji reactions. 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.
---
@@ -211,13 +415,13 @@ discord_proxy: ""
**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:
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:
**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.
**Technical details:** Socket Mode (no public URL needed). Markdown to mrkdwn conversion. DMs respond directly; channels respond to `app_mention` events. Thread replies via `thread_ts` -- all replies are threaded to the original message. Typing indicator approximated by posting/deleting a "..." message (Slack has no bot typing API). ACK reaction (eyes emoji) on message receipt. Attachments downloaded with Bearer auth. Media sent via `files_upload_v2`. Runs `auth_test()` on startup to verify credentials and cache bot user ID. Retry: 3 attempts, exponential backoff + jitter. Text chunk limit: 4000 chars.
---
@@ -250,11 +454,11 @@ slack_proxy: ""
**Prerequisites:**
1. Go to [Feishu Open Platform](https://open.feishu.cn/app) (international: [Lark Developer](https://open.larksuite.com/app)) → create a custom app.
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**.
3. Left menu **Event Subscriptions** -> set request URL to `http://your-host:9000/webhook/event` -> copy **Verification Token** and **Encrypt Key**.
**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.
**Technical details:** Webhook HTTP server for inbound (XML message parsing). GET callback for URL verification (SHA1 signature check). POST callback for message handling -- returns `"success"` within 5s and processes asynchronously. Optional AES encryption/decryption via `WeChatCrypto`. `access_token` auto-refresh (2h TTL, 5-min margin). Token-expired errors (40014, 42001) trigger automatic retry with refreshed token. WeCom supports Markdown message format with plain text fallback; Official Account uses plain text only (customer service API). WeCom group messages sent via `/appchat/send` endpoint (group IDs start with `wr`). Typing indicator approximated by posting/recalling a "..." message (WeCom only). Supports text, image, voice, video, location, file, and link message types. Media upload via `/media/upload`. Text chunk limit: 4096 chars.
**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.
**Technical details:** Stream Mode via WebSocket -- connects to DingTalk gateway (`/v1.0/gateway/connections/open`) with automatic ticket-based auth. Ping/pong heartbeat with system topic handling. Message ACK via JSON response. `accessToken` auto-refresh. Sends via robot `oToMessages/batchSend` API in Markdown format (`sampleMarkdown`). Image uploads via `/media/upload` API with `sampleImageMsg`. Group @mention detection via `isInAtList` flag with `atUsers` array fallback. `downloadCode` resolution via `/robot/messageFiles/download` API for file/image/video/audio attachments. Auth errors (`invalidauthentication`/`forbidden`/`40014`) not retried. Text chunk limit: 4096 chars.
---
@@ -391,7 +595,7 @@ dingtalk_proxy: ""
**Prerequisites:**
1. Go to [QQ Open Platform](https://q.qq.com) → create a bot application.
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.
@@ -461,7 +665,7 @@ signal_rpc_port: 7583
**Prerequisites:**
1. Prepare an email account with IMAP + SMTP support (Gmail, Outlook, self-hosted, etc.).
**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.
**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.
**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.
---
## Running Multiple Channels
Comma-separate channel names in the config to enable multiple channels simultaneously:
```yaml
channel_enabled: "telegram,discord,slack"
```
All enabled channels run concurrently via the internal `MessageBus`. Each channel:
- Has its own connection lifecycle (connect, reconnect, health check)
- Shares the same `InboundConsumer` worker pool and agent instance
- Routes outbound replies back to the originating channel automatically
EvoScientist can be controlled remotely via iMessage. The channel shares the same agent and conversation thread as the CLI — messages from your phone go through the same session.
EvoScientist integrates with 10 messaging platforms, allowing you to control the agent remotely from any chat app. All channels share the same agent core — messages from any platform go through the same processing pipeline.
```Shell
# Enable during onboard
EvoSci onboard # Step 7 configures iMessage
| Channel | Transport | Public IP Required | Install Extra |
EvoSci config set imessage_allowed_senders "+1234567890"
**Quick start:**
```bash
# 1. Install channel dependencies
pip install evoscientist[telegram]# or discord, slack, feishu, etc.
# 2. Configure via wizard or CLI
EvoSci onboard # interactive setup
# or
EvoSci config set channel_enabled telegram
EvoSci config set telegram_bot_token "123456:ABC-xxx"
# 3. Start
EvoSci serve # agent + all enabled channels
```
The channel can also be started manually with `/channel` in the interactive CLI.
Multiple channels can run concurrently — comma-separate names in the config:
**Features:**
- Thinking content and todo lists are forwarded to iMessage as intermediate messages
- Media files (images, PDFs) are auto-sent when the agent writes (`write_file`) or reads (`read_file`) them — no extra commands needed
```yaml
channel_enabled:"telegram,discord,slack"
```
The channel can also be started interactively with `/channel` in the CLI session.
> [!NOTE]
> For per-channel setup guides, capability matrix, architecture details, and troubleshooting, see the **[Channel Integration Guide](./EvoScientist/channels)**.
"""[B-19] Sessions evict oldest by insertion, not by access."""
deftest_session_eviction_is_lru(self):
"""Sessions use LRU eviction: recently accessed senders are kept."""
consumer=self._make_consumer()
consumer._sessions.clear()
@@ -1260,13 +1260,12 @@ class TestInboundConsumer:
foriinrange(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"]
# Access "user_0" via _get_thread_id (triggers LRU move_to_end)
consumer._get_thread_id("user_0")
# Force eviction by exceeding limit (simulate)
# Note: actual limit is 10_000, we test the logic pattern
# "user_0" should now be at the end (most recently used)
oldest=next(iter(consumer._sessions))
assertoldest=="user_0"# Still first in insertion order
assertoldest=="user_1"# user_1 is now the least recently used
deftest_metrics_initial(self):
consumer=self._make_consumer()
@@ -1537,20 +1536,20 @@ class TestIntegration:
assertflushed.content=="will be lost"
_run(_test())
deftest_send_locks_unbounded_growth(self):
"""[B-03] _send_locks grows without bound for unique chat_ids."""
deftest_send_locks_bounded_growth(self):
"""_send_locks stays bounded via LRU eviction of unlocked entries."""
asyncdef_test():
ch=StubChannel()
foriinrange(100):
ch._send_locks_max=10# Small limit for testing
foriinrange(20):
msg=OutboundMessage(
channel="stub",chat_id=f"chat_{i}",
content="hi",metadata={"chat_id":f"chat_{i}"},
)
awaitch.send(msg)
# All 100 unique chat_ids created a lock
assertlen(ch._send_locks)==100
# BUG: These are never cleaned up
# Should be bounded at max + 1 (the newly inserted entry)
assertlen(ch._send_locks)<=ch._send_locks_max+1
_run(_test())
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.