Files
goclaw/docs/05-channels-messaging.md
T
bd5adc61c8 feat(bitrix24): imbot.v2 migration, 2-way media, openline sender-tag echo, and hardening (#1236)
* refactor(bitrix24): rename "Path B" framing to maintainer-specified naming [B24:2794]

Per maintainer hard rule #10 (no generic "Path A/B" framing) from PR #1061
review. The Bitrix24 MCP auto-onboard flow is Bitrix-specific glue
("Bitrix24 OAuth -> existing mcp_user_credentials bridge"), NOT a generic
MCP architecture pattern.

Naming convention applied consistently:
- First mention per file: full "Bitrix24 OAuth -> existing
  mcp_user_credentials bridge" (matches maintainer comment verbatim).
- Subsequent mentions in same file: shortened "mcp_user_credentials bridge".
- Test/log context referencing literal endpoint /api/auto-onboard: keep
  "auto-onboard" reference (it's the actual API endpoint name).

Changes are documentation-only:
- Rename in code comments + test descriptions + plan docs.
- Clarify framing in mcp_client.go + provisioner.go doc comments to
  emphasize Bitrix-specific glue (not generic MCP infra).
- Reuse existing mcp_user_credentials table + MCPServerStore methods
  (no schema / store / abstraction change).

Files:
- cmd/gateway.go (factory registration doc)
- internal/channels/bitrix24/{channel,factory,mcp_client,provisioner}.go
- internal/channels/bitrix24/{mcp_client,provisioner}_test.go
- plan/goclaw-mcp-integration.md (21 occurrences)

Verified: go build + MCP-related tests pass (TestProvision*,
TestInitMCPProvisioner*, TestMCPClient*).

Phase 1 of Path C execution per
plans/reports/decision-log-260519-1555-bitrix24-pr-fork-decision.md.

* fix: confine outbound media paths to agent workspace [B24:2794]

Tool MEDIA:<path> output reached channel file-upload sinks (Bitrix
imbot.v2.File.upload, Telegram sendDocument, etc.) verbatim via
parseMediaResult, with no workspace-boundary check. A malicious or buggy
tool emitting MEDIA:/etc/passwd could exfiltrate arbitrary files to chat.

Extract the EvalSymlinks+Rel containment from extractMediaFromContent into
a shared confineToWorkspace helper and apply it at the parseMediaResult
sink in processToolResult. Fixing at the source/egress boundary protects
every channel at once rather than per-channel. Paths that escape the
workspace are dropped and logged (security.media_path_rejected).

Add TestConfineToWorkspace (boundary unit) and
TestParseMediaResultConfinedToWorkspace (sink regression for H2).

* feat(bitrix24): support inbound + outbound media via imbot.v2 File API [B24:2794]

Bitrix24 channel was text-only; attachments were parsed but dropped.
- Inbound: download chat files via imbot.v2.File.download (one-time URL),
  forward to the agent with MIME preserved (internal/channels/bitrix24/download.go).
- Outbound: upload agent media to the chat via imbot.v2.File.upload
  (internal/channels/bitrix24/send_media.go).
- Add BaseChannel.HandleMessageMedia to preserve MIME/filename through the bus.
- Per-channel media_max_mb cap (default 20) applies to both directions.

Tests: 92 pass (internal/channels/bitrix24 + internal/channels), go vet clean (PG + sqliteonly).

* refactor(bitrix24): migrate messaging/bot-list/unregister to imbot v2 API [B24:2794]

Move outbound REST calls to the imbot v2 family (keeps register on v1):
- imbot.message.add -> imbot.v2.Chat.Message.send (fields.message shape, live-verified)
- imbot.bot.list (+ legacy imbot.list fallback) -> imbot.v2.Bot.list; add botListRows
  to normalize the v2 {bots:[...]} envelope, legacy array, and id-keyed map forms
- imbot.unregister -> imbot.v2.Bot.unregister

Bot registration stays on v1 imbot.register: v2 imbot.v2.Bot.register changes the
event-delivery model (per-event handler URLs -> eventMode), which would require
rewriting the inbound event parser. No user-facing behavior change.

Tests: bitrix24 package green; go vet ./... clean.

* feat(bitrix24): route whisper via v1 SKIP_CONNECTOR + add v2 replyId [B24:2794]

Bot was leaking HiddenMessage (whisper) replies to the external Zalo
connector because every outbound call went through imbot.v2.Chat.Message.send,
which has no equivalent of the v1 SKIP_CONNECTOR flag. Branch the outbound
path on inbound visibility:

  whisper → imbot.message.add + SKIP_CONNECTOR=Y  (v1, send_v1.go)
  public  → imbot.v2.Chat.Message.send + fields.replyId  (v2, send_v2.go)

Pipeline:
  events.go        parse data[PARAMS][PARAMS][COMPONENT_ID]=HiddenMessage
                   into EventParams.IsHiddenMessage (form + JSON variants)
  handle.go        set bitrix_visibility on InboundMessage.Metadata
  consumer         forward visibility + message_id into OutboundMessage
  send.go          resolveSendOptions + sendChunk dispatcher +
                   shared callWithRateLimitRetry helper
  metadata_keys.go single source of truth for the keys + values

Defaults preserve pre-refactor behaviour: callers that don't populate
bitrix_visibility still go through v2 public, and replyId is omitted
unless a numeric bitrix_message_id arrives in metadata.

Tests:
  TestParseEvent_FormURLEncoded_IsHiddenMessage  (3 cases)
  TestParseEvent_JSON_IsHiddenMessage             (3 cases)
  TestResolveSendOptions                          (8 cases)
  TestSend_BranchesOnVisibility                   (4 cases)

* feat(bitrix24): openline sender-tag echo on replies [B24:2794]

Openline sender-tag echo (this change):
- Capture the connector sender tag ("[name #id]:" or "[name] #id:") from
  inbound openline group messages, strip it from the body the agent sees,
  and re-prepend the canonical "[name] #id:" form to the reply so the Open
  Channel connector routes the answer back to the right external user.
- New sender_prefix.go helper (+ test) accepts both inbound layouts and
  emits one canonical form; scoped to messages carrying the tag, so plain
  chats are unaffected.
- metadata_keys.go: MetaKeySenderPrefix; handle.go capture/strip/stash;
  gateway_consumer_normal.go forwards the key; send.go prepends it on the
  first chunk before chunking.

Bundled bitrix24 channel-core work already on this branch:
- handle.go: @mention is the sole trigger for both staff and connector
  customers; unmentioned traffic is dropped (was: drop all connector msgs).
- isGroupMessageType: treat SONET_GROUP "B" as a group.
- handle_test.go, mcp_client_test.go: cover the above.

* feat(bitrix24): accept colon-less openline sender tag, echo [name] #id [B24:2794]

The Open Channel connector dropped the trailing colon from its sender tag:
inbound now arrives as "[Name] #id <msg>" (was "[Name] #id: <msg>"). The
id-bearing patterns required the colon, so the tag fell through to the
name-only branch and the reply echoed "[Name]" — dropping the #id the
connector needs to route the answer back.

- sender_prefix.go: make the trailing ":" optional on both id layouts
  ([name #id] / [name] #id, with or without colon) and echo the canonical
  "[name] #id" (no colon) to match the connector's current format. Bare
  "[name]" (no id) still echoes "[name]" for Open Channel only.
- handle.go: gate the bare name-only layout to Open Channel (isOpenChannel)
  so ordinary group chats starting with "[x] ..." are left untouched.
- sender_prefix_test.go: cover colon/no-colon x id-inside/id-outside, the
  name-only openline case, and the non-openline no-op.

* fix: security and robustness fixes from the bitrix24 channel review [B24:2794]

- download.go: block redirect-based SSRF on inbound media. CheckRedirect
  re-validates each hop (http(s) only, reject private/loopback/link-local
  hosts, cap hops); the initial portal-domain pin is no longer bypassable
  via a 3xx to an internal service. Public-host redirects still allowed.
- handle.go: extract/echo the openline sender tag only for Open Channel
  sessions (was: any group chat), removing bogus prefixes in CRM group
  chats and narrowing the forged-tag misroute surface.
- loop_tools.go + loop_media.go: confine result.Media to the agent / team /
  tenant-allowed roots (new confineToAnyRoot) before a channel uploads it,
  so a prompt-injected out-of-workspace path (e.g. /etc/passwd) cannot
  exfiltrate, while legitimate cross-workspace media (team files, delegatee
  output) still flows.
- send_media.go: bounded outbound read via io.LimitReader replaces the
  os.Stat + os.ReadFile pair, closing the TOCTOU size-cap bypass; cap a
  single message's outbound attachments at 10 (mirrors inbound).
- register.go: paginate imbot.v2.Bot.list (limit/offset + hasNextPage,
  capped at 40 pages) so verify/lookup see bots past the first 50.
- mcp_client.go: redact access_token / refresh_token / client_secret from an
  echoed MCP error body before it is logged or returned (+ test).

* fix(security): validate resolved dial IP on Bitrix media redirects [B24:2794]

The inbound media download redirect guard only string-checked the redirect
hostname (isPrivateOrLoopback on req.URL.Hostname()), so a redirect to a public
hostname that resolves to 127.0.0.1 / 169.254.169.254 / an RFC1918 address — or a
DNS-rebinding swap between check and dial — still passed the guard and the client
would connect. Reported in PR review.

Add security.NewRedirectFollowingSafeClient: it follows redirects but validates
the RESOLVED destination IP of every hop at dial time via net.Dialer.Control,
reusing the existing blocked-CIDR list. The IP it checks is the IP actually
dialed, so both redirect-to-internal and DNS rebinding are refused, while
legitimate public CDN redirects still succeed. download.go now uses it instead of
the hostname-string guard.

Tests: deterministic dial-control table (loopback / link-local / private /
multicast / unspecified / public, v4 + v6), malformed/non-IP addr, test bypass,
loopback-dial-blocked client wiring, and redirect cap + scheme checks.

* feat(bitrix24): per-participant Zalo openline identity from 3-token sender tag [B24:2794]

Parse the connector's "[Name] #uid #msgId" sender tag so each external
customer in a shared Open Channel group gets its own contact + USER.md
instead of collapsing onto the connector proxy id. Identity minting is
gated on IS_CONNECTOR=Y to reject operator forged tags. Echo back the
msgId only ("#msgId") on replies; keep the legacy single-number and
name-only layouts unchanged. Zero DB migration.

- sender_prefix.go: parseOpenlineSenderTag() classifies 3-token / legacy / name-only
- handle.go: synthetic senderID "openlines:{instance}:{chat}:{uid}" + participant_user_id metadata, gated on FromIsConnector
- gateway_consumer_normal.go: deriveGroupUserID() routes participant -> per-person scope, group fallback otherwise
- send.go: buildAddressMention numeric-id guard so synthetic ids don't emit invalid [USER=...] BBCode
- MetaKeyMessageID kept as Bitrix MESSAGE_ID (drives v2 fields.replyId); connector msgId surfaced only via echo prefix

---------

Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
Co-authored-by: Chinh Dang <chinhdang@192.168.68.104>
2026-06-22 14:23:34 +07:00

41 KiB

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

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()<br/>Listen for events"]
        HM["HandleMessage()<br/>Build InboundMessage"]
    end

    subgraph Core
        BUS["MessageBus"]
        AGENT["Agent Loop"]
    end

    subgraph Outbound
        DISPATCH["Manager.dispatchOutbound()"]
        SEND["Channel.Send()<br/>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.

flowchart TD
    GW["Gateway HTTP Mux"] --> WH{"WebhookChannel?"}
    WH -->|Yes| MOUNT["Mount handler on main mux<br/>(e.g., /feishu/events)"]
    WH -->|No| SKIP["Channel uses its own transport<br/>(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

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<br/>or in allowlist?"}
    PAIR -->|Yes| ACCEPT
    PAIR -->|No| PAIR_REPLY["Send pairing instructions<br/>(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 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

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<br/>(headers, bold, italic, links, lists)"]
    S4 --> S5["Restore placeholders:<br/>inline code → code tags<br/>code blocks → pre tags<br/>tables → pre (ASCII-aligned)"]
    S5 --> S6["Chunk at 4,000 chars<br/>(split at paragraph > line > space)"]
    S6 --> S7["Send as HTML<br/>(fallback: plain text on error)"]

Tables are rendered as ASCII-aligned text inside <pre> 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.

flowchart TD
    GLOBAL["Global defaults<br/>(TelegramConfig)"] --> WILD["Wildcard group '*'<br/>(if configured)"]
    WILD --> GROUP["Specific group<br/>by chat ID"]
    GROUP --> TOPIC["Specific topic<br/>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

flowchart TD
    VOICE["Voice/audio message"] --> ROUTE{Channel type?}
    
    ROUTE -->|Telegram/Discord/Feishu| DOWNLOAD["Download audio file"]
    ROUTE -->|WhatsApp| WHATSAPP_CHECK{"whatsapp_enabled<br/>in settings?"}
    
    WHATSAPP_CHECK -->|No| WA_FALLBACK["[Voice message]<br/>(default opt-out)"]
    WHATSAPP_CHECK -->|Yes| DOWNLOAD
    
    DOWNLOAD --> STT_CHECK{"STT providers<br/>configured?"}
    STT_CHECK -->|Yes| STT_CHAIN["Try providers in order:<br/>elevenlabs, proxy"]
    STT_CHECK -->|No| LEGACY{"Legacy bridge<br/>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:<br/>[audio: filename] Transcript: text"]
    FALLBACK --> FALLBACK_INJECT["[Voice message]"]
    
    INJECT --> AGENT["Agent context"]
    FALLBACK_INJECT --> AGENT
    
    VOICE --> ROUTING{"VoiceAgentID<br/>configured<br/>(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 <id> 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

flowchart TD
    MODE{"Connection mode?"} -->|"websocket (default)"| WS["WebSocket Client<br/>Persistent connection<br/>Auto-reconnect"]
    MODE -->|"webhook"| WH["Webhook endpoint<br/>Mounted on gateway mux<br/>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.

flowchart TD
    START["Agent starts responding"] --> CREATE["Create card<br/>(streaming_mode: true)"]
    CREATE --> SEND["Send interactive message<br/>with card JSON"]
    SEND --> UPDATE["Update card element<br/>with accumulated text<br/>(throttled: 100ms min)"]
    UPDATE -->|"more chunks"| UPDATE
    UPDATE -->|"done"| CLOSE["Close stream<br/>(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/<id> and *.feishu.cn/docx/<id> URLs.

Auto-fetch behavior:

  • Document content fetched via Lark API GET /open-apis/docx/v1/documents/{id}/raw_content
  • Content injected as [Lark Doc: <url>] ... [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:<chatID>. 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) → <url|text>, # 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.

flowchart TD
    CH1["Telegram Channel<br/>→ Agent: assistant"] --> WS1["Workspace: assistant/"]
    CH2["Discord Channel<br/>→ Agent: coder"] --> WS2["Workspace: coder/"]
    CH3["Feishu Channel<br/>→ 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.

flowchart TD
    NEW["New user sends DM"] --> CHECK{"DM policy = pairing?"}
    CHECK -->|No| PROCESS["Process normally"]
    CHECK -->|Yes| PAIRED{"Already paired<br/>or in allowlist?"}
    PAIRED -->|Yes| PROCESS
    PAIRED -->|No| CODE["Generate 8-char code<br/>(valid 60 min)"]
    CODE --> REPLY["Send pairing instructions<br/>(debounce 60s)"]
    REPLY --> WAIT["Wait for admin approval"]
    WAIT --> APPROVE["Admin approves via<br/>device.pair.approve"]
    APPROVE --> DONE["User paired, future messages<br/>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 Channel startup in gateway sequence
03-tools-system.md Tool policy engine, per-request tool allow list
08-scheduling-cron.md /stop and /stopall commands, scheduler lanes, cron
09-security.md Group file writer restrictions, security logging
11-agent-teams.md Team message routing, delegation result delivery
project-changelog.md Phase 5 audio manager & unified STT implementation