# 05 - Channels and Messaging
Channels connect external messaging platforms to the GoClaw agent runtime via a shared message bus. Each channel implementation translates platform-specific events into a unified `InboundMessage`, and converts agent responses into platform-appropriate outbound messages.
---
## 1. Message Flow
```mermaid
flowchart LR
subgraph Platforms
TG["Telegram"]
DC["Discord"]
SL["Slack"]
FS["Feishu/Lark"]
ZL["Zalo OA"]
ZLP["Zalo Personal"]
WA["WhatsApp"]
end
subgraph "Channel Layer"
CH["Channel.Start()
Listen for events"]
HM["HandleMessage()
Build InboundMessage"]
end
subgraph Core
BUS["MessageBus"]
AGENT["Agent Loop"]
end
subgraph Outbound
DISPATCH["Manager.dispatchOutbound()"]
SEND["Channel.Send()
Format + deliver"]
end
TG --> CH
DC --> CH
SL --> CH
FS --> CH
ZL --> CH
ZLP --> CH
WA --> CH
CH --> HM
HM --> BUS
BUS --> AGENT
AGENT -->|OutboundMessage| BUS
BUS --> DISPATCH
DISPATCH --> SEND
SEND --> TG
SEND --> DC
SEND --> SL
SEND --> FS
SEND --> ZL
SEND --> ZLP
SEND --> WA
```
Internal channels (`cli`, `system`, `subagent`, `browser`) are silently skipped by the outbound dispatcher and never forwarded to external platforms. The `browser` channel uses WebSocket directly on the gateway connection.
### Message Routing Prefixes
The consumer routes system messages based on sender ID prefixes:
| Prefix | Route | Scheduler Lane |
|--------|-------|:-:|
| `subagent:` | Parent session queue | subagent |
| `delegate:` | Parent agent's original session (legacy session key format) | team |
| `teammate:` | Target agent session | team |
### Inbound Debounce
Normal channel messages pass through the shared inbound debouncer before agent execution. `gateway.inbound_debounce_ms` merges rapid text messages from the same `channel:chatID:senderID:agentID`; `0` means no debounce and positive values set the wait window. Agents can override the global value with `other_config.inbound_debounce_ms`; unset inherits the global config. Command/control messages such as `/stop`, `/reset`, and system escalations bypass the debouncer.
### Human-like Delivery
`gateway.chat_behavior` controls optional channel-only delivery polish. Delivery
text is not added to session history or the main provider messages.
- `quick_ack` sends one short receipt for non-streaming channel runs.
`mode="sidecar_generated"` uses a bounded sidecar LLM call;
`mode="llm_generated"` is kept as a backward-compatible alias;
`mode="fixed_template"` sends the first template; `mode="off"` disables it.
- `quick_ack.provider/model` can choose a cheaper sidecar provider/model. If
unset, the delivery generator falls back to the agent provider/model. Timeout,
max token, and max char limits bound the call; templates are fallback output on
errors, timeouts, or empty sidecar text.
- `intermediate_replies` sends sidecar-generated progress during tool phases. It
is independent from `quick_ack`: either feature can be enabled or disabled
alone.
- Sidecar progress uses bounded delivery metadata only: message preview, locale,
channel/peer, agent label, and tool phase. It does not receive session
history, tool arguments, tool output, tool schemas, memory, or system prompts.
- Deterministic Tool Status Messages no longer emit channel text. Platform
reactions and explicit Show Reasoning delivery remain separate behavior.
- `final_split` splits long final text replies into a bounded number of
paragraph messages.
- Override order is Channel > Agent > Workspace. Agent overrides live in
`agents.other_config.delivery_behavior`; channel overrides continue under
`chat_behavior`.
- Legacy `gateway.block_reply` and channel `block_reply` values are still read
as inherited `intermediate_replies.enabled` defaults when the newer
`chat_behavior.intermediate_replies.enabled` field is unset.
Splitting is intentionally conservative. Replies containing fenced code, tables, lists, quotes, JSON/XML-ish blocks, or URL-only paragraphs remain a single message. Media replies and streaming deliveries are not split.
Progress messages are not added to session history by this behavior. Existing run timeline handling for explicit `block.reply` remains unchanged.
### Outbound Media Delivery
Agent tools can queue media files through `Result.Media`; the consumer converts
those entries into `OutboundMessage.Media` and preserves file order, MIME type,
filename, and optional captions.
- `send_file` accepts either the legacy single `path` + optional `caption`, or
`attachments: [{path, caption?}, ...]` for batch delivery of existing
workspace files.
- Telegram groups compatible outbound media into `sendMediaGroup` album chunks
of 2-10 items. Photo/video items can share a chunk; documents group only with
documents; audio groups only with audio. Voice-mode audio, singleton chunks,
oversized images sent as documents, and incompatible runs use the existing
ordered single-send fallback.
- Discord already sends multiple files plus optional text in one message.
- Slack and other media-capable channels keep ordered fallback behavior unless
their adapter advertises a stronger batch capability.
### Reasoning Delivery
Telegram channel config supports explicit reasoning delivery:
| `reasoning_delivery` | Behavior |
|----------------------|----------|
| `streaming_only` | Legacy behavior. Show model reasoning only in the live streaming lane. |
| `always_bubbles` | Force provider streaming internally and send reasoning as bounded channel bubbles, even when `dm_stream` / `group_stream` are off. |
| `off` | Suppress reasoning output in the channel. Traces and provider usage remain unaffected. |
Backward compatibility: if `reasoning_delivery` is missing, legacy `reasoning_stream=false` resolves to `off`; otherwise it resolves to `streaming_only`. Explicit `reasoning_delivery` always wins over the legacy boolean. Reasoning bubbles are delivery-only messages and are not added to assistant history.
**Multi-attachment coalescing (#63).** Messages carrying attachments do NOT bypass the debouncer — that pre-fix shortcut was the source of N-replies for one user action. Instead, when media is present the effective window is `max(configured, mediaFloor)` so multi-file uploads land in the same buffer and flush together. Three surfaces apply the same invariant:
| Surface | Buffer key | Trigger |
|---------|------------|---------|
| `internal/bus/inbound_debounce.go` | `(channel, chatID, senderID, agentID)` | Any inbound passing through the shared bus |
| `internal/gateway/methods/chat_debounce.go` | `(userKey, sessionKey)` | `/v1/chat/completions` streaming sessions |
| `internal/channels/telegram/album_aggregator.go` | `(chatID, MediaGroupID)` | Telegram media-group updates (album = N updates sharing one `MediaGroupID`) |
The Telegram album aggregator runs at the channel layer after all access gates (mention, pairing, allow-list) pass — it buffers per `MediaGroupID`, pins the sender on first arrival as a security tripwire (mismatched sender → `security.album_sender_mismatch` + drop), and dispatches ONE call to the downstream pipeline on a 500ms silence window. `Channel.Stop()` synchronously drains pending albums before `pollCancel` so in-flight bursts always reach the agent loop. See `CONTRIBUTING.md` → "Multi-attachment coalescing" for the eight cross-surface invariants any new surface must honor.
---
## 2. Channel Interfaces
Every channel must implement the base interface:
| Method | Description |
|--------|-------------|
| `Name()` | Channel instance name (e.g., `"telegram"`, `"discord"`) |
| `Type()` | Platform type identifier (e.g., `"telegram"`, `"zalo_personal"`). For config-based channels equals `Name()`; for DB instances may differ. |
| `Start(ctx)` | Begin listening for messages (non-blocking) |
| `Stop(ctx)` | Graceful shutdown |
| `Send(ctx, msg)` | Deliver an outbound message to the platform |
| `IsRunning()` | Whether the channel is actively processing |
| `IsAllowed(senderID)` | Check if a sender passes the allowlist |
### Extended Interfaces
| Interface | Purpose | Implemented By |
|-----------|---------|----------------|
| `StreamingChannel` | Real-time streaming updates | Telegram, Slack |
| `WebhookChannel` | Webhook HTTP handler mounting | Facebook, Feishu/Lark, Pancake |
| `ReactionChannel` | Status reactions on messages | Telegram, Slack, Feishu |
| `BlockReplyChannel` | Override gateway block_reply setting | Discord, Feishu/Lark, Pancake, Slack, Zalo OA, Zalo Personal |
| `ChatBehaviorChannel` | Override gateway chat_behavior setting | Bitrix24, Discord, Feishu/Lark, Pancake, Slack, Telegram, WhatsApp, Zalo OA, Zalo Personal |
| `ReasoningDeliveryChannel` | Override channel-visible reasoning delivery | Telegram |
`BaseChannel` provides a shared implementation that all channels embed: allowlist matching, `HandleMessage()`, `CheckPolicy()`, and user ID extraction.
### Webhook Mount
Channels implementing `WebhookChannel` expose an HTTP handler that can be mounted on the gateway's main HTTP mux. This enables single-port operation — no separate webhook server needed.
```mermaid
flowchart TD
GW["Gateway HTTP Mux"] --> WH{"WebhookChannel?"}
WH -->|Yes| MOUNT["Mount handler on main mux
(e.g., /feishu/events)"]
WH -->|No| SKIP["Channel uses its own transport
(polling, gateway events, etc.)"]
```
---
## 3. Channel Policy
### DM Policies
| Policy | Behavior |
|--------|----------|
| `pairing` | Require pairing code for new senders |
| `allowlist` | Only whitelisted senders accepted |
| `open` | Accept all DMs |
| `disabled` | Reject all DMs |
### Group Policies
| Policy | Behavior |
|--------|----------|
| `open` | Accept all group messages |
| `allowlist` | Only whitelisted groups accepted |
| `disabled` | No group messages processed |
### Policy Evaluation
```mermaid
flowchart TD
MSG["Incoming message"] --> KIND{"PeerKind?"}
KIND -->|direct| DMP{"DM Policy?"}
KIND -->|group| GPP{"Group Policy?"}
DMP -->|disabled| REJECT["Reject"]
DMP -->|open| ACCEPT["Accept"]
DMP -->|allowlist| AL1{"In allowlist?"}
AL1 -->|Yes| ACCEPT
AL1 -->|No| REJECT
DMP -->|pairing| PAIR{"Already paired
or in allowlist?"}
PAIR -->|Yes| ACCEPT
PAIR -->|No| PAIR_REPLY["Send pairing instructions
(debounce 60s)"]
GPP -->|disabled| REJECT
GPP -->|open| ACCEPT
GPP -->|allowlist| AL2{"In allowlist?"}
AL2 -->|Yes| ACCEPT
AL2 -->|No| REJECT
```
---
## 4. Channel Comparison
| Feature | Telegram | Feishu/Lark | Discord | Slack | WhatsApp | Zalo OA | Zalo Personal | Bitrix24 |
|---------|----------|-------------|---------|-------|----------|---------|---------------|----------|
| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | Direct protocol (in-process) | Long polling | Internal protocol | Long polling (REST) |
| DM support | Yes | Yes | Yes | Yes | Yes | Yes (DM only) | Yes | Yes |
| Group support | Yes (mention gating) | Yes | Yes | Yes (mention gating + thread cache) | Yes | No | Yes | Yes |
| Forum/Topics | Yes (per-topic config) | Yes (topic session mode) | -- | -- | -- | -- | -- | -- |
| Message limit | 4,096 chars | Configurable (default 4,000) | 2,000 chars | 4,000 chars | WhatsApp native limit | 2,000 chars | 2,000 chars | 4,096 chars |
| Streaming | Typing indicator | Streaming message cards | Edit "Thinking..." | Edit "Thinking..." (throttled 1s) | No | No | No | No |
| Media | Photos, voice, files | Images, files (30 MB) | Files, embeds | Files (download w/ SSRF protection) | Images, audio, video, documents | Images (5 MB) | -- | Files (20 MB default) |
| Speech-to-text | Yes (STT proxy) | -- | -- | -- | -- | -- | -- | -- |
| Voice routing | Yes (VoiceAgentID) | -- | -- | -- | -- | -- | -- | -- |
| Rich formatting | Markdown → HTML | Card messages | Markdown | Markdown → mrkdwn | Plain text | Plain text | Plain text | Plain text |
| Bot commands | 10+ commands | -- | -- | -- | -- | -- | -- | -- |
| Tool allow list | Per-topic | -- | -- | -- | -- | -- | -- | -- |
| Pairing support | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Status reactions | Yes | Yes | -- | Yes | -- | -- | -- | -- |
---
## 5. Telegram
The Telegram channel uses long polling via the `telego` library (Telegram Bot API).
### Core Behaviors
- **Group mention gating**: By default, bot must be @mentioned in groups (`requireMention: true`). Pending messages without a mention are stored in a history buffer (default 50 messages) and included as context when the bot is eventually mentioned.
- **Typing indicator**: A "typing" action is sent while the agent is processing.
- **Proxy support**: Optional HTTP proxy configured via channel config.
- **Cancel commands**: `/stop` and `/stopall` intercepted before the 800ms debouncer. See [08-scheduling-cron.md](./08-scheduling-cron.md) for details.
- **Concurrent group support**: Group sessions support up to 3 concurrent agent runs.
- **Bot reply as implicit mention**: Replying to a bot message in a group counts as mentioning the bot.
- **Show Reasoning delivery**: `reasoning_delivery=always_bubbles` decouples provider streaming from Telegram live streaming so reasoning can appear as bounded normal messages while the final answer remains non-streaming.
### Formatting Pipeline
```mermaid
flowchart TD
IN["LLM Output (Markdown)"] --> S1["Extract tables as placeholders"]
S1 --> S2["Extract code blocks as placeholders"]
S2 --> S3["Extract inline code as placeholders"]
S3 --> S4["Convert Markdown to HTML
(headers, bold, italic, links, lists)"]
S4 --> S5["Restore placeholders:
inline code → code tags
code blocks → pre tags
tables → pre (ASCII-aligned)"]
S5 --> S6["Chunk at 4,000 chars
(split at paragraph > line > space)"]
S6 --> S7["Send as HTML
(fallback: plain text on error)"]
```
Tables are rendered as ASCII-aligned text inside `
` tags. CJK and emoji characters are counted as 2-column width for proper alignment.
### Forum Topics
Telegram forum topics (supergroup threads) get per-topic configuration with layered merging.
```mermaid
flowchart TD
GLOBAL["Global defaults
(TelegramConfig)"] --> WILD["Wildcard group '*'
(if configured)"]
WILD --> GROUP["Specific group
by chat ID"]
GROUP --> TOPIC["Specific topic
within group"]
TOPIC --> RESOLVED["Resolved topic config"]
```
**Configurable per topic:**
| Field | Description |
|-------|-------------|
| `groupPolicy` | open, allowlist, pairing, disabled |
| `requireMention` | Override mention gating for this topic |
| `allowFrom` | User/ID allowlist for this topic |
| `enabled` | Enable/disable this specific topic |
| `skills` | Override available skills (nil=inherit, []=none, ["x","y"]=whitelist) |
| `tools` | Override available tools (supports `group:xxx` syntax) |
| `systemPrompt` | Additional system prompt (concatenated at topic level) |
**Session key format:**
| Context | Key Format |
|---------|------------|
| Regular chat | `"-12345"` |
| Forum topic | `"-12345:topic:99"` |
| DM thread | `"-12345:thread:55"` |
General topic (ID=1) is stripped during send — Telegram API requires no thread ID for the general topic. Deleted topics are detected via error message matching and retried without the thread ID.
### Tool Allow List (Per-Topic)
Each topic can restrict which tools the agent may use. The `tools` field accepts tool names and group references:
- `nil` = inherit all tools (no restriction)
- `[]` = no tools for this topic
- `["web_search", "group:fs"]` = only web search and filesystem tools
The tool allow list is passed via message metadata and applied by the policy engine before the LLM sees the tool definitions.
### Voice Message Transcription (STT)
Voice and audio messages are transcribed through a unified `audio.Manager` interface. All channels (Telegram, Discord, Feishu, WhatsApp) route transcription requests through the same STT chain.
#### Unified STT Flow
```mermaid
flowchart TD
VOICE["Voice/audio message"] --> ROUTE{Channel type?}
ROUTE -->|Telegram/Discord/Feishu| DOWNLOAD["Download audio file"]
ROUTE -->|WhatsApp| WHATSAPP_CHECK{"whatsapp_enabled
in settings?"}
WHATSAPP_CHECK -->|No| WA_FALLBACK["[Voice message]
(default opt-out)"]
WHATSAPP_CHECK -->|Yes| DOWNLOAD
DOWNLOAD --> STT_CHECK{"STT providers
configured?"}
STT_CHECK -->|Yes| STT_CHAIN["Try providers in order:
elevenlabs, proxy"]
STT_CHECK -->|No| LEGACY{"Legacy bridge
providers?"}
LEGACY -->|Yes| STT_CHAIN
LEGACY -->|No| FALLBACK["[Voice message]"]
STT_CHAIN -->|Success| TEXT["Transcribed text"]
STT_CHAIN -->|Fails/Timeout 10s| FALLBACK
TEXT --> INJECT["Prepend to message:
[audio: filename] Transcript: text"]
FALLBACK --> FALLBACK_INJECT["[Voice message]"]
INJECT --> AGENT["Agent context"]
FALLBACK_INJECT --> AGENT
VOICE --> ROUTING{"VoiceAgentID
configured
(Telegram)?"}
ROUTING -->|Yes| VOICE_AGENT["Route to voice-specific agent"]
ROUTING -->|No| DEFAULT_AGENT["Default channel agent"]
```
#### Configuration & Decision Rules
**Decision 2 (Conflict rule):** When `builtin_tools[stt].settings.providers[]` is present in the database, it OVERRIDES all legacy channel-specific STT configs. The legacy STT bridge (`STTProxyURL` → `proxy` provider) only activates when the providers list is empty or missing.
| Setting | Behavior |
|---------|----------|
| `providers: ["elevenlabs", "proxy"]` | Try ElevenLabs Scribe first; fall back to legacy proxy |
| `providers: []` (empty) | Skip all STT; voice → `[Voice message]` fallback |
| `providers` missing (nil) | Check for legacy bridge at startup; activate if `STTProxyURL` exists |
**Decision 6 (WhatsApp opt-in):** WhatsApp voice message STT is **OFF by default** (`whatsapp_enabled: false`). Rationale: WhatsApp voice messages are end-to-end encrypted; sending audio to an external STT provider breaks E2E encryption. Admins must explicitly toggle STT in the UI at **Config → Audio → STT** and acknowledge the E2E breaking change.
When disabled (default):
- Voice messages surface in agent context as `[Voice message]` (localized via i18n key `channel.voice_message_fallback`)
- No audio leaves the device
When enabled:
- Voice audio is transcribed via the configured STT chain
- Fallback to `[Voice message]` on failure/timeout (10s wall clock)
#### Per-Channel Behavior
| Channel | STT Support | Notes |
|---------|:-:|---------|
| **Telegram** | Yes | Uses unified chain; preserves Telegram voice MIME; legacy proxy override is keyed by platform type `telegram`; voice routing via `VoiceAgentID` config |
| **Discord** | Yes | Standard flow; no special routing |
| **Feishu** | Yes | Standard flow; no special routing |
| **WhatsApp** | Yes (opt-in) | Requires explicit admin approval; default OFF |
#### Factory Integration
All channel factories accept `audioMgr *audio.Manager`:
- Telegram: `FactoryWithStoresAndAudio(..., audioMgr)`
- Discord: `FactoryWithAudio(..., audioMgr)`
- Feishu: `FactoryWithStoresAndAudio(..., audioMgr)`
- WhatsApp: `FactoryWithDBAudio(..., audioMgr, builtinToolStore)`
WhatsApp additionally accepts `builtinToolStore store.BuiltinToolStore` to fetch the per-message `whatsapp_enabled` opt-in flag. Wiring is in `cmd/gateway_channels_setup.go` and `cmd/gateway.go`.
### Bot Commands
Commands are processed before enriching content with reply/forward context (to prevent parsing issues).
| Command | Description | Group Restriction |
|---------|-------------|:-:|
| `/help` | Show command list | -- |
| `/start` | Passthrough to agent | -- |
| `/stop` | Cancel current run | -- |
| `/stopall` | Cancel all runs | -- |
| `/reset` | Clear session history | Writers only |
| `/status` | Bot status + username | -- |
| `/tasks` | Team task list | -- |
| `/task_detail ` | View task detail | -- |
| `/addwriter` | Add group file writer (reply to target user) | Writers only |
| `/removewriter` | Remove group file writer | Writers only |
| `/writers` | List group file writers | -- |
### Group File Writer Restrictions
In group chats, write-sensitive operations (file writes, `/reset`) are restricted to designated writers. The group ID format is `group:telegram:{chatID}`.
- Permission check queries the database: `IsGroupFileWriter(agentID, groupID, senderID)`
- Fail-open on database errors (security logged as `security.reset_writer_check_failed`)
- Writers are managed via `/addwriter` and `/removewriter` commands
---
## 6. Feishu/Lark
The Feishu/Lark channel connects via native HTTP with two transport modes.
### Transport Modes
```mermaid
flowchart TD
MODE{"Connection mode?"} -->|"websocket (default)"| WS["WebSocket Client
Persistent connection
Auto-reconnect"]
MODE -->|"webhook"| WH["Webhook endpoint
Mounted on gateway mux
or separate port"]
```
When using webhook mode with port=0, the handler is mounted directly on the gateway's main HTTP mux (see webhook mount in Section 2). If a separate port is configured, a dedicated server is started.
### Configuration
| Field | Default | Description |
|-------|---------|-------------|
| `ConnectionMode` | `"websocket"` | `"websocket"` or `"webhook"` |
| `WebhookPort` | 0 | 0 = mount on gateway mux; >0 = separate server |
| `WebhookPath` | `"/feishu/events"` | Webhook endpoint path |
| `RenderMode` | `"auto"` | `"auto"` (detect code/tables), `"card"`, or default text |
| `TextChunkLimit` | 4,000 | Max characters per text message |
| `MediaMaxMB` | 30 | Max file size for media (MB) |
| `TopicSessionMode` | disabled | `"enabled"` for thread-per-topic session isolation |
| `RequireMention` | true | Require bot mention in group |
| `GroupAllowFrom` | -- | Group-level allowlist (separate from DM) |
| `ReactionLevel` | -- | `"off"`, `"minimal"` (terminal only), or full |
### Streaming Message Cards
Responses are delivered as interactive card messages with real-time streaming updates.
```mermaid
flowchart TD
START["Agent starts responding"] --> CREATE["Create card
(streaming_mode: true)"]
CREATE --> SEND["Send interactive message
with card JSON"]
SEND --> UPDATE["Update card element
with accumulated text
(throttled: 100ms min)"]
UPDATE -->|"more chunks"| UPDATE
UPDATE -->|"done"| CLOSE["Close stream
(streaming_mode: false)"]
```
Each update increments a sequence number for ordering. Updates are throttled at 100ms minimum intervals to avoid API rate limiting. The streaming card displays content with a print animation effect (50ms frequency, 2-character steps).
### Media Handling
**Receive (inbound)**: Images, files, audio, video, and stickers are downloaded from the Feishu API with configurable size limits (default 30 MB). Oversized files are silently skipped.
| Media Type | Saved As |
|------------|----------|
| Image | `.png` |
| File | Original extension |
| Audio | `.opus` |
| Video | `.mp4` |
| Sticker | `.png` |
**Filename preservation**: When a user uploads or shares a file with a recognizable name, the channel adapter populates `bus.MediaFile.Filename` with the original filename (e.g., Feishu file display name, Telegram `file_name`). This filename is then sanitized and persisted to disk in the format `{stem}-{8hex}{ext}` (e.g., `báo-cáo-2024-a1b2c3d4.pdf` → `bao-cao-2024-a1b2c3d4.pdf`). The sanitizer supports Vietnamese diacritics, CJK scripts, and filesystem safety. Media without a user-provided filename (voice notes, clipboard pastes) fall back to UUID-only names. Disk names with semantic stems enable vault enrichment to process documents contextually. See `internal/agent/media_filename.go` for sanitization rules.
**Send (outbound)**: Files are uploaded to Feishu with automatic type detection (opus, mp4, pdf, doc, xls, ppt, or generic stream).
### Mention Resolution
Feishu sends content with placeholder tokens (e.g., `@_user_1`) for mentioned users. GoClaw processes these:
- **Bot mentions**: Stripped entirely (just the trigger, not meaningful content)
- **User mentions**: Replaced with `@DisplayName` from the mention list
- Fallback detection when bot ID is unknown
### Topic Session Mode
When enabled, each thread gets an isolated session:
- Session key includes the thread root message ID: `"{chatID}:topic:{rootID}"`
- Different threads within the same group maintain separate conversation histories
- Disabled by default
### Thread Reply Routing
When a message is sent inside a Lark thread (detected via the `thread_id` field in the inbound event), the inbound handler stamps `metadata["feishu_reply_target_id"]` with the triggering message ID. During outbound delivery, the channel routes responses back to the same thread using `LarkClient.ReplyMessage()` which POSTs to `/open-apis/im/v1/messages/{message_id}/reply` with `reply_in_thread: true`.
- **Automatic thread detection**: No configuration needed; replies are routed based on inbound `thread_id`
- **Metadata propagation**: The `feishu_reply_target_id` key is included in the `routingMetaKeys` allowlist so replies, block replies, and placeholder updates all land in the correct thread
- **Graceful fallback**: If the reply endpoint fails (e.g., thread root deleted), the channel falls back to `SendMessage()` for the regular chat
- **Applies to**: Text, card, image, and file messages
### Document URL Auto-Fetch
When a user pastes a Lark docx (document) URL in a message, the channel automatically fetches the document raw text and injects it into the agent prompt for context.
**URL detection**: Regex pattern matches `https://*.larksuite.com/docx/` and `*.feishu.cn/docx/` URLs.
**Auto-fetch behavior**:
- Document content fetched via Lark API `GET /open-apis/docx/v1/documents/{id}/raw_content`
- Content injected as `[Lark Doc: ] ... [End of Lark Doc]` markers around the raw text
- Rune-safe truncation at 8000 runes per document to respect token budgets
- Results cached per channel instance with LRU eviction (128 entries, 5-minute TTL)
**Access control**: Requires bot app to have `docx:document:readonly` permission **and** document owner must explicitly grant the bot access to each document. If access is denied or document not found, a visible inline marker appears: `[Lark Doc X: access denied — grant the bot app read permission on this document]`
**Safeguards**:
- Limited to docx documents only (sheets, base, wiki deferred)
- Maximum 10 document fetches per inbound message (spam protection)
- Soft-fail on API errors (no outbound message blocks)
**Configuration**: No new config flags. Supported document type and cache tunables (8000 rune limit, 10-URL cap, 5-min TTL, 128-entry cache) are hardcoded.
### Writer Management Commands
Group chats support file-write permission management via slash commands. Permissions are scoped to the group via `group:feishu:`. DM users who attempt these commands receive a hint that they only work in groups.
**Commands** (group-only):
| Command | Description | Requires Target | Permission |
|---------|-------------|:---:|:---:|
| `/addwriter <@user or reply>` | Grant file_writer permission to target user | Yes | Writers only |
| `/removewriter <@user or reply>` | Revoke file_writer permission from target user | Yes | Writers only |
| `/writers` | List current group writers with displayName | No | -- |
**Target specification**: Commands require explicit identification via reply-to or @mention. Bare `/addwriter` without a target is rejected — prevents accidental privilege capture.
**Bootstrap behavior**: Groups with no writers allow the first writer to grant themselves via `/addwriter @self` (explicit self-mention). This enables initial configuration without external admin intervention.
**Authorization**:
- Only existing writers can manage the writer list (enforce via `IsGroupFileWriter` check)
- Last-writer guard: If removing a writer would leave zero writers, operation is rejected with user-facing message
- Database errors are fail-open; security issues are logged as `security.writer_check_failed`
**Implementation**: Timeout of 10 seconds bounds Feishu API calls. Requires `AgentStore` and `ConfigPermissionStore` wired to the Feishu channel via constructor options.
---
## 7. Discord
The Discord channel uses the `discordgo` library to connect via the Discord Gateway.
### Key Behaviors
- **Gateway intents**: Requests `GuildMessages`, `DirectMessages`, and `MessageContent` intents
- **Message limit**: 2,000-character limit with automatic splitting at newlines
- **Placeholder editing**: Sends "Thinking..." → edits with actual response
- **Mention gating**: `requireMention` default true; bot mention stripped from content
- **Bot identity**: Fetches `@me` on startup to detect and ignore own messages
- **Typing indicator**: 9-second keepalive while agent processes
- **Group history**: Pending message buffer for context when mentioned
- **Thread backfill**: When the bot is mentioned inside a Discord thread, the channel fetches up to 25 prior thread messages before the triggering message through Discord REST, prepends their text as context, and downloads up to 15 prior attachments for the same inbound media pipeline. This is thread-only, bounded to 5 MB per backfilled file with a 30-second timeout, and gracefully falls back to the current message when Discord lacks `READ_MESSAGE_HISTORY` or the REST request fails.
---
## 8. Slack
The Slack channel uses the `slack-go/slack` library to connect via Socket Mode (WebSocket).
### Key Behaviors
- **Socket Mode**: Uses `xapp-` App-Level Token for WebSocket connection (no public URL needed)
- **Three token types**: `xoxb-` (Bot Token, required), `xapp-` (App-Level Token, required), `xoxp-` (User Token, optional for custom identity)
- **Token prefix validation**: Tokens validated at startup (`xoxb-`, `xapp-`, `xoxp-` prefixes)
- **Message limit**: 4,000-character limit with automatic splitting at newline boundaries
- **Placeholder editing**: Sends "Thinking..." → edits with actual response (same as Discord)
- **Mention gating**: `requireMention` default true; `<@botUserID>` stripped from content
- **Thread participation cache**: After bot replies in a thread, subsequent messages in that thread auto-trigger response without @mention (24h TTL)
- **Message dedup**: `channel+ts` key prevents duplicate processing on Socket Mode reconnect
- **Message debounce**: Per-thread batching of rapid messages (300ms default, configurable; `debounce_delay: 0` disables)
- **Dead socket classification**: Non-retryable auth errors (invalid_auth, token_revoked) fail fast instead of infinite reconnect
- **Streaming**: Edit-in-place via `chat.update` with 1000ms throttle (Slack Tier 3 rate limit)
- **Reactions**: Status emoji on user messages (thinking_face, hammer_and_wrench, white_check_mark, x, hourglass_flowing_sand)
- **SSRF protection**: File download hostname allowlist (*.slack.com, *.slack-edge.com, *.slack-files.com), auth token stripped on redirect
- **Health probe**: `auth.test()` with 2.5s timeout for monitoring integration
### Formatting Pipeline
```
LLM markdown → htmlTagsToMarkdown() → extractSlackTokens() → escapeHTMLEntities()
→ extractCodeBlocks() → convertTablesToCodeBlocks() → bold/strike/header/link conversion
→ restore tokens/code blocks → Slack mrkdwn
```
Key conversions: `**bold**` → `*bold*`, `~~strike~~` → `~strike~`, `[text](url)` → ``, `# Header` → `*Header*`, tables → code blocks.
### Environment Variables
```
GOCLAW_SLACK_BOT_TOKEN → channels.slack.bot_token
GOCLAW_SLACK_APP_TOKEN → channels.slack.app_token
GOCLAW_SLACK_USER_TOKEN → channels.slack.user_token (optional)
```
Auto-enables when both bot_token and app_token are set.
---
## 9. WhatsApp
The WhatsApp channel connects directly to the WhatsApp network via the multi-device protocol. Authentication state is stored in the database (PostgreSQL standard, SQLite for desktop edition).
### Key Behaviors
- **Direct connection**: In-process WhatsApp client (direct to WhatsApp servers, no external bridge)
- **Database auth store**: Persists auth state, keys, and device info in the database
- **QR code authentication**: Interactive QR code for initial pairing, served via WebSocket API
- **Auto-reconnect**: Built-in reconnection with exponential backoff
- **DM and group support**: Full group messaging with mention detection via JID format
- **Media handling**: Direct media download/upload to WhatsApp servers with type detection
- **Typing indicators**: Typing state managed per chat with auto-refresh
- **Group mention gating**: Detects when bot is mentioned via LID (Local ID) and JID (standard format)
---
## 10. Zalo OA
The Zalo OA (Official Account) channel connects to the Zalo OA Bot API.
### Key Behaviors
- **DM only**: No group support. Only direct messages are processed
- **Text limit**: 2,000-character maximum per message
- **Long polling**: Default 30-second timeout, 5-second backoff on errors
- **Media**: Image support with 5 MB default limit
- **Default DM policy**: `"pairing"` (requires pairing code)
- **Pairing debounce**: 60-second debounce on pairing instructions
---
## 11. Zalo Personal
The Zalo Personal channel provides access to personal Zalo accounts using a reverse-engineered protocol. This is an unofficial integration.
### Key Differences from Zalo OA
| Aspect | Zalo OA | Zalo Personal |
|--------|---------|---------------|
| Protocol | Official Bot API | Reverse-engineered (zcago, MIT) |
| DM support | Yes | Yes |
| Group support | No | Yes |
| Default DM policy | `pairing` | `allowlist` (restrictive) |
| Default group policy | N/A | `allowlist` (restrictive) |
| Authentication | API credentials | Pre-loaded credentials or QR scan |
| Risk | None | Account may be locked/banned |
### Security Warning
Zalo Personal uses an unofficial, reverse-engineered protocol. The account used may be locked or banned by Zalo at any time. A security warning is logged on startup: `security.unofficial_api`.
### Resilience
- Maximum 10 restart attempts
- Exponential backoff up to 60 seconds
- Special handling for error code 3000: 60-second initial delay
- Typing controller per thread
---
## 12. Channel-Isolated Workspaces
Each channel instance can target a specific agent, providing workspace isolation across channels.
```mermaid
flowchart TD
CH1["Telegram Channel
→ Agent: assistant"] --> WS1["Workspace: assistant/"]
CH2["Discord Channel
→ Agent: coder"] --> WS2["Workspace: coder/"]
CH3["Feishu Channel
→ Agent: assistant"] --> WS1
WS1 --> USER1["user_alice/"]
WS1 --> USER2["user_bob/"]
WS2 --> USER3["user_charlie/"]
```
Channel instances are loaded from the database with their assigned agent ID. The agent key is resolved and propagated through the message pipeline, ensuring all filesystem tools, context files, and memory operations use the correct workspace.
---
## 13. Passive Memory Extraction
Passive channel memory is an opt-in per-channel feature. When enabled in
`channel_instances.config.passive_memory`, the gateway periodically reads the
existing tenant-scoped `channel_pending_messages` group buffers, redacts sensitive
content, asks the background LLM provider for durable fact candidates, and stores
only candidates in the review queue.
Default behavior is privacy-first:
- Disabled by default.
- Group-only in v1.
- Review mode enabled by default.
- Runs by manual trigger, message cap, or interval.
- New extraction tables store metadata, summaries, topics/entities, confidence,
status, and redaction counts, but not raw message bodies.
Approved items write an `episodic_summaries` row with `source_type='channel'`
and a deterministic `source_id`; existing consolidation workers then handle KG
promotion. Reject/delete prevents later writes. Delete also removes the linked
episodic row when one exists; already-promoted KG nodes are not synchronously
deleted in v1.
---
## 14. Local Key Propagation
Thread/topic context is preserved through the entire message pipeline using a `local_key` in message metadata. This ensures subagent, delegation, and team message results land in the correct thread — not the root chat.
| Platform | Local Key Format |
|----------|-----------------|
| Telegram (chat) | `"-12345"` |
| Telegram (topic) | `"-12345:topic:99"` |
| Telegram (thread) | `"-12345:thread:55"` |
| Feishu (chat) | `"oc_xyz"` |
| Feishu (topic) | `"oc_xyz:topic:{root_msg_id}"` |
All channel state — placeholders, streams, reactions, typing controllers, thread IDs — is keyed by this composite `local_key`. When delegation or team messages complete, the `local_key` from the original message is preserved in metadata and used to route the response back to the correct location.
---
## 15. Per-User Isolation
Channels provide per-user isolation through compound sender IDs and context propagation:
- **User scoping**: Each channel constructs a compound sender ID (e.g., `telegram:123456`) which maps to a `user_id`. The session key format `agent:{agentId}:{channel}:direct:{peerId}` ensures each user has isolated conversation history per agent.
- **Context propagation**: `HandleMessage()` injects `AgentID`, `UserID`, and `AgentType` into the context. These flow to the ContextFileInterceptor, MemoryInterceptor, and per-user file seeding.
- **Pairing storage**: PostgreSQL (`pairing_requests` and `paired_devices` tables).
- **Session persistence**: PostgreSQL `sessions` table with write-behind caching.
---
## 16. Pairing System
The pairing system provides a DM authentication flow for channels using the `pairing` DM policy.
```mermaid
flowchart TD
NEW["New user sends DM"] --> CHECK{"DM policy = pairing?"}
CHECK -->|No| PROCESS["Process normally"]
CHECK -->|Yes| PAIRED{"Already paired
or in allowlist?"}
PAIRED -->|Yes| PROCESS
PAIRED -->|No| CODE["Generate 8-char code
(valid 60 min)"]
CODE --> REPLY["Send pairing instructions
(debounce 60s)"]
REPLY --> WAIT["Wait for admin approval"]
WAIT --> APPROVE["Admin approves via
device.pair.approve"]
APPROVE --> DONE["User paired, future messages
processed normally"]
```
### Code Specification
| Aspect | Value |
|--------|-------|
| Length | 8 characters |
| Alphabet | `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (excludes ambiguous: 0, O, 1, I, L) |
| TTL | 60 minutes |
| Max pending per account | 3 |
| Reply debounce | 60 seconds per sender |
---
## 16. Bitrix24
The Bitrix24 channel connects to a Bitrix24 portal via the `imbot.v2.*` REST API. Authentication uses an app token with `imbot` scope.
### Key Behaviors
- **Text limit**: 4,096 characters per message with automatic splitting at newlines
- **Media support**: Both inbound and outbound file transfers with MIME preservation
- **Default DM policy**: `"pairing"` (requires pairing code)
- **Pairing debounce**: 60-second debounce on pairing instructions
- **Media max size**: Configurable `media_max_mb` (default 20 MB) applies symmetrically to inbound downloads and outbound uploads
### Inbound Media
When a user sends a file to the bot:
1. Resolve file metadata via `imbot.v2.File.download` → obtain one-time authenticated download URL
2. Stream file to temp directory, preserving MIME type
3. Forward to agent via `bus.MediaFile` with original filename
4. Agent pipeline routes to appropriate reader (`read_image`, `read_document`, `read_audio`, `read_video`)
**Configuration**: Size cap via `media_max_mb` (per `channel_instance` or config default). Oversized files are silently skipped (best-effort).
### Outbound Media
Agent-produced media files are uploaded to the chat via `imbot.v2.File.upload`:
1. Read file from workspace
2. Encode as base64
3. POST to `imbot.v2.File.upload` with bot ID and chat ID
4. Upload succeeds atomically: file is stored in portal Drive, attached to chat, and posted in a single REST call
**Configuration**: Size cap via same `media_max_mb` knob (symmetric with inbound).
### OAuth Scope
The bot app must have the `imbot` scope granted. The `disk` scope is **not** required — all file operations are scoped to the message thread context.
**Implementation**: `internal/channels/bitrix24/download.go` (inbound), `send_media.go` (outbound). New `BaseChannel.HandleMessageMedia()` method (in `internal/channels/channel.go`) centralizes media-aware message handling across all channels.
---
## File Reference
| Module | Path | Purpose |
|---|---|---|
| Channel core | `internal/channels/` | `Channel` interface, `BaseChannel` (incl. `HandleMessageMedia()` method), `Manager` (StartAll/StopAll), outbound dispatcher, DB instance loader |
| Platform adapters | `internal/channels/{telegram,feishu,discord,slack,whatsapp,zalo,bitrix24}/` | Per-platform: message handling, formatting, streaming, reactions, media, pairing |
| Bitrix24 media | `internal/channels/bitrix24/download.go`, `send_media.go` | Inbound file download via `imbot.v2.File.download`, outbound upload via `imbot.v2.File.upload` |
| Audio / STT | `internal/audio/` | Audio manager, STT chain resolution, legacy STT bridge |
| Pairing & routing | `internal/store/pg/pairing.go`, `cmd/gateway_consumer.go` | Pairing code persistence, inbound message routing and cancel interception |
Use `grep` or your editor's symbol search for specific files.
---
## Cross-References
| Document | Relevant Content |
|----------|-----------------|
| [00-architecture-overview.md](./00-architecture-overview.md) | Channel startup in gateway sequence |
| [03-tools-system.md](./03-tools-system.md) | Tool policy engine, per-request tool allow list |
| [08-scheduling-cron.md](./08-scheduling-cron.md) | /stop and /stopall commands, scheduler lanes, cron |
| [09-security.md](./09-security.md) | Group file writer restrictions, security logging |
| [11-agent-teams.md](./11-agent-teams.md) | Team message routing, delegation result delivery |
| [project-changelog.md](./project-changelog.md) | Phase 5 audio manager & unified STT implementation |