Messaging Channels

Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source: crates/cheng-adapters/src, crates/cheng-core/src/ports/channel_adapter.rs, crates/cheng-core/src/domain/channel, crates/cheng-api/src/services/adapter_health.rs, crates/cheng-api/src/services/channel_ingest.rs

A channel connects an external messaging platform to a ChengOS workflow. A message arrives from Telegram or Slack, the platform-specific payload is normalised into one shared message model, a conversation is resolved, and a workflow runs. The reply travels back out through the same adapter.

Five first-party adapters ship in the box: Telegram, WhatsApp, Slack, WeCom (企业微信), and DingTalk (钉钉).

Who does what

The split between framework and adapter is strict, and knowing it saves you looking in the wrong place:

The framework doesThe adapter does
chk_ API key validationTranslate platform payload → normalised message
Conversation resolutionTranslate normalised command → platform API call
Message persistencePlatform connection setup (token, OAuth, QR)
Workflow executionVerify webhook authenticity (signature/token)
WebSocket broadcastingAnswer platform verification challenges
Webhook URL computation

Adapter lookup is keyed by the raw app_type string, never an enum. That is deliberate: it leaves room for external adapters written in other languages to register arbitrary app_type values without any core change. An unknown app_type returns "not found" rather than an error.

The adapters

AdapterAuth patternTransport
Telegramwebhook_tokenLong polling (default) or webhook
WhatsAppwebhook_signatureWebhook, HMAC-SHA256 on X-Hub-Signature-256
SlackoauthWebhook (default) or Socket Mode
WeComwebhook_encrypted_signatureWebhook, SHA1 signature + AES-CBC encrypted XML
DingTalkstream_connectionPersistent WebSocket to the DingTalk Stream gateway

Telegram, Slack Socket Mode, and DingTalk work without a public HTTPS URL. This is the single most useful fact on this page: if you are running ChengOS on a laptop or inside an intranet, choose Telegram polling, Slack Socket Mode, or DingTalk and you never have to expose anything. WhatsApp and WeCom require a publicly reachable webhook.

Each adapter declares its capabilities (direct_message, group_chat, reactions, message_edit, media_upload, typing_indicator, webhooks, long_polling, …), and the frontend shows or hides features per channel accordingly. Telegram supports typing indicators but not reactions or edits; Slack supports reactions, edits, and deletes but not typing.

Configuration

Configuration lives in the channel record's connection_config JSON, which the framework treats as opaque. Shapes:

// Telegram
{ "bot_token": "…", "connection_mode": "polling",
  "webhook_secret": "only-for-webhook-mode",
  "allowed_updates": ["message", "callback_query"] }

// Slack
{ "connection_mode": "webhook", "bot_token": "xoxb-…",
  "signing_secret": "…", "app_token": "xapp-…" }

// WhatsApp
{ "phone_number_id": "…", "business_account_id": "…", "access_token": "…",
  "signing_secret": "…", "webhook_verify_token": "…", "api_version": "v21.0" }

// WeCom
{ "corp_id": "ww…", "agent_id": "1000002", "corp_secret": "…",
  "token": "…", "encoding_aes_key": "<43-char base64url>" }

// DingTalk
{ "client_id": "…", "client_secret": "…", "robot_code": "…" }

connection_mode defaults to polling for Telegram and webhook for Slack. Slack's app_token (xapp-…) is required only for Socket Mode; signing_secret only for webhook mode.

Webhook URLs are computed at runtime from PUBLIC_BASE_URL plus the channel UUID — you never store one. For Telegram webhook mode the URL is validated: it must be HTTPS and must not be localhost, and connect fails with an explicit message if PUBLIC_BASE_URL is wrong.

Connection lifecycle

unconfigured ──connect()──► configuring ──complete_connect()──► active
                    │                                             │
                    └──────────── (single-step) ──────────────────┘
                                                                  │
                       health check fails / token expires ────► degraded
                                                                  │
                                    auto-reconnect succeeds ──────┘

  disconnected  (manual)          error { message }  (needs manual intervention)

Token-based adapters reach active inside connect() and return NotSupported from complete_connect(). OAuth and QR adapters return configuring with the redirect URL or QR payload in setup_data, and finish in complete_connect().

connect() must be idempotent: retrying after a timeout or a deploy restart must not create duplicate webhook registrations or corrupt remote state.

Health monitoring

A background service checks active and degraded channels every ADAPTER_HEALTH_INTERVAL_SECS (default 300). Unhealthy active channels are marked degraded; degraded channels are reconnected automatically and restored to active.

Worker-based platforms — Telegram polling, Slack Socket Mode, DingTalk Stream — are treated specially, and the reason is worth reading:

Inbound: the normalised message

Every platform payload becomes a NormalizedInboundMessage:

external_message_id   ← used for deduplication
external_user_id
external_chat_id      ← the routing key
message_type          ← text | image | file | audio | video | location | reaction | event
text, attachments, reply_to_message_id
metadata              ← sender_display_name, is_group, thread_id, mentioned_bot, …
timestamp
raw                   ← original payload, kept for audit; the core never parses it

One webhook delivery can contain several messages (platforms batch), so normalisation returns a vector, and a delivery containing only status updates returns an empty one.

Non-text messages get a content placeholder — [image], [file], [audio], [video], [location], [reaction], [event:…] — so the conversation history stays readable, while the actual media is mapped into attachments and stored as artifacts.

The ingest pipeline is shared: webhook deliveries and polling/socket workers go through the same code, and it owns transport concerns only. Command semantics (~new, ~workflow, ~~y, numbered selections, cancellation) live in the conversation command service, so external channels, the CLI, and tests share exactly one behaviour.

Outbound

Replies are NormalizedOutboundCommands naming a target chat and one of: send_text, send_media, reply, react, typing. An idempotency_key prevents duplicate sends on retry where the platform supports it.

Errors

Adapter failures are classified into stable categories — Config, Auth, PlatformApi, Normalization, NotSupported, Internal. These meanings are contractual: new variants may be added, but existing ones are never repurposed. When a channel misbehaves, the category tells you whether to look at your configuration, your credentials, or the platform.

REST surface

GET|POST       /workspaces/:ws/channels
GET|PUT|DELETE /workspaces/:ws/channels/:id
POST|DELETE    /workspaces/:ws/channels/:id/connect
POST           /workspaces/:ws/channels/:id/connect/complete
GET            /workspaces/:ws/channels/:id/status
GET            /workspaces/:ws/channels/:id/capabilities
GET            /workspaces/:ws/channels/:id/auth-pattern

GET|POST       /webhooks/:channel_uuid        (no auth — platforms call this)

Channel management is JWT-protected under a workspace. The webhook endpoints are unauthenticated by necessity — authenticity is established by the adapter's signature check, not by a bearer token.

Next steps