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>
254 lines
9.2 KiB
Go
254 lines
9.2 KiB
Go
package channels
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// Lark / Feishu (ByteDance) channel adapter — outbound via Custom Bot
|
|
// webhooks, inbound via Event Subscriptions.
|
|
//
|
|
// Outbound shape: POST <webhook_url> {"msg_type":"text","content":{"text":"..."}}
|
|
// Inbound shape: POST <your-registered-url> with one of:
|
|
// {"type":"url_verification","challenge":"...","token":"..."} (handshake)
|
|
// {"schema":"2.0","header":{"token":"...","event_type":"im.message.receive_v1"},
|
|
// "event":{"sender":{"sender_id":{"user_id":"..."}},
|
|
// "message":{"message_id":"...","chat_id":"...","content":"{\"text\":\"hi\"}"}}}
|
|
//
|
|
// Two URL families are accepted: open.feishu.cn (China) and open.larksuite.com
|
|
// (international). Both speak the same payload format — only the host differs.
|
|
type LarkAdapter struct{}
|
|
|
|
const (
|
|
larkFeishuPrefix = "https://open.feishu.cn/open-apis/bot/v2/hook/"
|
|
larkLarkSuitePrefix = "https://open.larksuite.com/open-apis/bot/v2/hook/"
|
|
larkHTTPTimeout = 10 * time.Second
|
|
)
|
|
|
|
func (l *LarkAdapter) Type() string { return "lark" }
|
|
func (l *LarkAdapter) DisplayName() string { return "Lark / Feishu" }
|
|
|
|
// ConfigSchema — Lark Custom Bot webhook URL + optional Event Subscription
|
|
// verify token. The webhook URL already encodes the chat, so no separate
|
|
// chat_id field is needed (and StartPolling is a no-op for Lark — inbound
|
|
// is delivered by ParseWebhook from the Event Subscription callback).
|
|
func (l *LarkAdapter) ConfigSchema() []ConfigField {
|
|
return []ConfigField{
|
|
{
|
|
Key: "webhook_url",
|
|
Label: "Custom Bot Webhook URL",
|
|
Type: "password", // last path component is a secret
|
|
Required: true,
|
|
Sensitive: true,
|
|
Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
|
Help: "From the Lark/Feishu bot page → Webhook settings. open.feishu.cn (China) and open.larksuite.com (international) both accepted.",
|
|
},
|
|
{
|
|
Key: "verify_token",
|
|
Label: "Event Subscription Verify Token",
|
|
Type: "password",
|
|
Required: false,
|
|
Sensitive: true,
|
|
Placeholder: "optional — from Event Subscriptions page",
|
|
Help: "Only needed if you want to receive messages from Lark. Paste the \"Verification Token\" from your app's Event Subscriptions configuration.",
|
|
},
|
|
}
|
|
}
|
|
|
|
// ValidateConfig requires webhook_url to point at a Lark or Feishu Custom
|
|
// Bot endpoint. verify_token is optional — when set, inbound events with a
|
|
// mismatching token are rejected (use Lark's "Verification Token" from the
|
|
// app's Event Subscriptions page).
|
|
func (l *LarkAdapter) ValidateConfig(config map[string]interface{}) error {
|
|
webhookURL, _ := config["webhook_url"].(string)
|
|
if webhookURL == "" {
|
|
return fmt.Errorf("missing required field: webhook_url")
|
|
}
|
|
if !isLarkWebhookURL(webhookURL) {
|
|
return fmt.Errorf("invalid Lark/Feishu webhook URL — must start with %s or %s",
|
|
larkFeishuPrefix, larkLarkSuitePrefix)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isLarkWebhookURL(u string) bool {
|
|
return strings.HasPrefix(u, larkFeishuPrefix) || strings.HasPrefix(u, larkLarkSuitePrefix)
|
|
}
|
|
|
|
// SendMessage posts text to the configured Lark/Feishu Custom Bot webhook.
|
|
// chatID is ignored — the chat is encoded in the webhook URL itself.
|
|
//
|
|
// Lark Custom Bot has no rate-limit tier we can rely on for batched output;
|
|
// callers that fan out should add their own back-pressure.
|
|
func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interface{}, _ string, text string) error {
|
|
webhookURL, _ := config["webhook_url"].(string)
|
|
if webhookURL == "" {
|
|
return fmt.Errorf("webhook_url not configured")
|
|
}
|
|
if !isLarkWebhookURL(webhookURL) {
|
|
return fmt.Errorf("invalid Lark/Feishu webhook URL")
|
|
}
|
|
|
|
payload, err := json.Marshal(map[string]interface{}{
|
|
"msg_type": "text",
|
|
"content": map[string]string{"text": text},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("lark: marshal payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return fmt.Errorf("lark: create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{Timeout: larkHTTPTimeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("lark: send: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("lark: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
// Lark returns 200 even for application errors — the body's `code` field
|
|
// is the truth. code:0 means delivered; anything else is a failure we
|
|
// must surface to the caller, otherwise outbound looks healthy while
|
|
// nothing reaches the chat.
|
|
var apiResp struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
if err := json.Unmarshal(body, &apiResp); err == nil && apiResp.Code != 0 {
|
|
return fmt.Errorf("lark: api error code=%d msg=%s", apiResp.Code, apiResp.Msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParseWebhook handles both the url_verification handshake and event_callback
|
|
// payloads from Lark Event Subscriptions.
|
|
//
|
|
// The handshake (`type: "url_verification"`) returns nil, nil; the calling
|
|
// HTTP handler is responsible for echoing the challenge back to Lark — this
|
|
// matches the Slack pattern in slack.go (kept consistent so the receiver
|
|
// layer can stay generic).
|
|
//
|
|
// For event_callback we currently only surface the v2 message receive event
|
|
// (im.message.receive_v1). Other event types (reactions, member changes)
|
|
// return nil, nil so the receiver responds 200 OK without dispatching.
|
|
func (l *LarkAdapter) ParseWebhook(c *gin.Context, config map[string]interface{}) (*InboundMessage, error) {
|
|
body, err := io.ReadAll(c.Request.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lark: read body: %w", err)
|
|
}
|
|
|
|
// Probe for a v1 url_verification handshake first — it has a top-level
|
|
// `type` field instead of the v2 `schema`/`header` wrapper.
|
|
var probe struct {
|
|
Type string `json:"type"`
|
|
Challenge string `json:"challenge"`
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.Unmarshal(body, &probe); err == nil && probe.Type == "url_verification" {
|
|
// Verify token if operator configured one. Constant-time compare —
|
|
// see #337: any place we compare a user-supplied value against a
|
|
// stored secret must use subtle.ConstantTimeCompare.
|
|
if expected, _ := config["verify_token"].(string); expected != "" {
|
|
if subtle.ConstantTimeCompare([]byte(expected), []byte(probe.Token)) != 1 {
|
|
return nil, fmt.Errorf("lark: url_verification token mismatch")
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// v2 event payload
|
|
var payload struct {
|
|
Schema string `json:"schema"`
|
|
Header struct {
|
|
EventType string `json:"event_type"`
|
|
Token string `json:"token"`
|
|
} `json:"header"`
|
|
Event struct {
|
|
Sender struct {
|
|
SenderID struct {
|
|
UserID string `json:"user_id"`
|
|
OpenID string `json:"open_id"`
|
|
UnionID string `json:"union_id"`
|
|
} `json:"sender_id"`
|
|
} `json:"sender"`
|
|
Message struct {
|
|
MessageID string `json:"message_id"`
|
|
ChatID string `json:"chat_id"`
|
|
ChatType string `json:"chat_type"`
|
|
MessageType string `json:"message_type"`
|
|
Content string `json:"content"` // JSON-encoded string, e.g. {"text":"hi"}
|
|
} `json:"message"`
|
|
} `json:"event"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, fmt.Errorf("lark: parse event: %w", err)
|
|
}
|
|
|
|
// Verify token on event_callback too — same constant-time rule.
|
|
if expected, _ := config["verify_token"].(string); expected != "" {
|
|
if subtle.ConstantTimeCompare([]byte(expected), []byte(payload.Header.Token)) != 1 {
|
|
return nil, fmt.Errorf("lark: event token mismatch")
|
|
}
|
|
}
|
|
|
|
if payload.Header.EventType != "im.message.receive_v1" {
|
|
return nil, nil // ignore non-message events
|
|
}
|
|
if payload.Event.Message.MessageType != "text" {
|
|
return nil, nil // unsupported message type (image / file / sticker / etc.)
|
|
}
|
|
|
|
// content is a JSON-encoded string; for text messages it parses to {"text": "..."}.
|
|
var content struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal([]byte(payload.Event.Message.Content), &content); err != nil {
|
|
return nil, fmt.Errorf("lark: parse message content: %w", err)
|
|
}
|
|
if content.Text == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
// Pick the most identifying sender ID Lark gave us — open_id is always
|
|
// present; user_id is only set when the bot has the contacts permission.
|
|
userID := payload.Event.Sender.SenderID.OpenID
|
|
if payload.Event.Sender.SenderID.UserID != "" {
|
|
userID = payload.Event.Sender.SenderID.UserID
|
|
}
|
|
|
|
return &InboundMessage{
|
|
ChatID: payload.Event.Message.ChatID,
|
|
UserID: userID,
|
|
Text: content.Text,
|
|
MessageID: payload.Event.Message.MessageID,
|
|
Metadata: map[string]string{
|
|
"platform": "lark",
|
|
"chat_type": payload.Event.Message.ChatType,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// StartPolling returns nil immediately. Lark/Feishu Custom Bots are
|
|
// outbound-only at the webhook layer; inbound is delivered via the Event
|
|
// Subscription HTTP callback handled by ParseWebhook.
|
|
func (l *LarkAdapter) StartPolling(_ context.Context, _ map[string]interface{}, _ MessageHandler) error {
|
|
return nil
|
|
}
|