molecule-core/platform/internal/channels/lark.go
rabbitblood e7710d2e6f feat(channels): Lark / Feishu adapter (outbound webhook + Events API inbound)
New ChannelAdapter implementation for Lark (international, open.larksuite.com)
and Feishu (China, open.feishu.cn). Both speak the same payload format —
only the host differs — so a single adapter covers both.

Outbound: POST text to a Custom Bot webhook URL with msg_type:"text".
Lark returns 200 OK even when delivery fails — the body's `code` field is
the truth. Adapter parses the response and returns a Go error when
code != 0 so callers don't think a revoked-webhook send succeeded.

Inbound: handles both v1 url_verification (handshake) and v2 event_callback
(im.message.receive_v1) shapes. Optional verify_token field — when set,
inbound payloads with mismatching tokens are rejected via constant-time
compare (#337 class — never raw == against a stored secret).

Sender ID resolution prefers user_id → falls back to open_id (open_id is
always present; user_id only when the bot has the contacts permission).
Non-text message types and non-message events return nil, nil so the
receiver responds 200 OK without dispatching.

Tests: 23 cases — identity, ValidateConfig (6 sub-cases incl. URL prefix
matrix), SendMessage (no URL / invalid prefix / happy-path body shape /
api-error-code surfacing), ParseWebhook (handshake + token mismatch +
text message + open_id fallback + non-message + non-text + token mismatch
+ malformed JSON + malformed content + empty text), StartPolling no-op,
registry presence.

Also: make migration 023 idempotent (ADD COLUMN IF NOT EXISTS) — the
platform's migration runner has no schema_migrations tracking table, so
every .up.sql replays on every boot. Without IF NOT EXISTS the second
boot against an existing volume crashes with "column already exists".
Followup issue to be filed for proper migration tracking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 07:10:58 -07:00

227 lines
8.0 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" }
// 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 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
}