molecule-core/workspace-server/internal/channels/adapter.go
rabbitblood 00265d7028 feat(channels): first-class Lark/Feishu support via schema-driven config
Lark adapter was already implemented in Go (lark.go — outbound Custom Bot
webhook + inbound Event Subscriptions with constant-time token verify),
but the Canvas connect-form hardcoded a Telegram-shaped pair of inputs
(bot_token + chat_id). Selecting "Lark / Feishu" from the dropdown
silently sent the wrong field names — there was no way to enter a
webhook URL.

Fix: move form shape to the server.

- Add `ConfigField` struct + `ConfigSchema()` method to the
  `ChannelAdapter` interface. Each adapter declares its own fields with
  label/type/required/sensitive/placeholder/help.
- Implement per-adapter schemas:
  - Lark: webhook_url (required+sensitive) + verify_token (optional+sensitive)
  - Slack: bot_token/channel_id/webhook_url/username/icon_emoji
  - Discord: webhook_url + optional public_key
  - Telegram: bot_token + chat_id (unchanged UX, keeps Detect Chats)
- Change `ListAdapters()` to return `[]AdapterInfo` with config_schema
  inline. Sorted deterministically by display name so UI ordering is
  stable across Go's random map iteration.
- Update the 3 existing `ListAdapters` test sites to struct access.

Canvas (`ChannelsTab.tsx`):
- Replace the two hardcoded bot_token/chat_id inputs with a single
  schema-driven `SchemaField` component. Renders one input per field in
  the order the adapter returns them.
- Form state becomes `formValues: Record<string,string>` keyed by
  `ConfigField.key`. Values reset on platform-switch so stale
  Telegram credentials can't leak into a new Lark channel.
- "Detect Chats" stays but only renders for platforms in
  `SUPPORTS_DETECT_CHATS` (Telegram only — the only provider with
  getUpdates).
- Only schema-known keys are posted in `config`, scrubbing any stale
  values from previous platform selections.

Regression tests:
- `TestLark_ConfigSchema` locks in the 2-field Lark contract with the
  required/sensitive flags correctly set.
- `TestListAdapters_IncludesLark` confirms registry wiring + schema
  survives round-trip through ListAdapters.

Known pre-existing `TestStripPluginMarkers_AwkScript` failure in
internal/handlers is unrelated to this change (verified via stash+test
on clean staging).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:51:15 -07:00

91 lines
3.9 KiB
Go

// Package channels provides a pluggable adapter system for social channel
// integrations (Telegram, Slack, Discord, etc.). Each platform implements
// the ChannelAdapter interface and registers itself in the adapter registry.
package channels
import (
"context"
"github.com/gin-gonic/gin"
)
// ChannelAdapter is the interface every social channel must implement.
type ChannelAdapter interface {
// Type returns the channel type identifier (e.g. "telegram", "slack").
Type() string
// DisplayName returns the human-readable name (e.g. "Telegram").
DisplayName() string
// ConfigSchema describes the config fields each adapter needs. The UI
// renders the connect-channel form from this list, so each platform's
// field set (Telegram bot_token+chat_id, Lark webhook_url+verify_token,
// Slack bot_token+channel_id, Discord webhook_url) can be captured
// correctly without per-platform UI branching. Adapters must return the
// same schema on every call — the order is the rendering order.
ConfigSchema() []ConfigField
// ValidateConfig checks that channel_config JSONB has required fields.
ValidateConfig(config map[string]interface{}) error
// SendMessage sends a text message to the social platform.
SendMessage(ctx context.Context, config map[string]interface{}, chatID string, text string) error
// ParseWebhook extracts message info from an incoming webhook request.
ParseWebhook(c *gin.Context, config map[string]interface{}) (*InboundMessage, error)
// StartPolling begins long-polling for platforms that support it.
// Returns nil immediately if the platform only supports webhooks.
StartPolling(ctx context.Context, config map[string]interface{}, onMessage MessageHandler) error
}
// ConfigField describes a single config field for the channels connect-form UI.
// Canvas renders one input per field in order. Values are strings in
// channel_config JSONB — this struct carries only presentation + validation
// hints; ValidateConfig on the adapter is still the source of truth for
// acceptance.
type ConfigField struct {
// Key is the channel_config map key (e.g. "webhook_url").
Key string `json:"key"`
// Label is the human-readable field name (e.g. "Webhook URL").
Label string `json:"label"`
// Type controls the HTML input type: "text" | "password" | "textarea".
Type string `json:"type"`
// Required marks the field as non-optional in the UI. Still enforced
// server-side via ValidateConfig regardless of this flag.
Required bool `json:"required"`
// Sensitive means the value must not be logged or shown unmasked in
// read APIs after creation. Canvas uses this to redact the value in
// list responses; server-side encryption is governed by sensitiveFields
// in secret.go (today: bot_token + webhook_secret only — this flag is
// forward-looking until that list is widened).
Sensitive bool `json:"sensitive"`
// Placeholder is rendered as the input's placeholder attribute.
Placeholder string `json:"placeholder,omitempty"`
// Help is a short one-liner shown below the input.
Help string `json:"help,omitempty"`
}
// InboundMessage is the standardized message from any social platform.
type InboundMessage struct {
ChatID string // Platform-specific chat/channel ID
UserID string // Platform-specific user ID
Username string // Human-readable username
Text string // Message text
MessageID string // Platform-specific message ID (for threading)
Metadata map[string]string // Extra platform-specific data
}
// MessageHandler is called by polling adapters when a message arrives.
type MessageHandler func(ctx context.Context, channelID string, msg *InboundMessage) error
// ChannelRow represents a row from the workspace_channels table.
type ChannelRow struct {
ID string
WorkspaceID string
ChannelType string
Config map[string]interface{}
Enabled bool
AllowedUsers []string
}