Files
multica/server/internal/integrations/lark/store.go
beast fd813f2569 fix(lark): isolate topic-group sessions by thread (#5061)
* fix(lark): isolate topic-group sessions by thread

A Feishu topic group (话题群) collapsed every topic into one
chat_session: the session binder passed the raw group chat id as the
engine BindingKey, violating the engine.EnsureSessionInput contract
that a threaded platform must never key sessions by raw chat id.
Multiple users @-mentioning the bot in different topics shared one
transcript, and replies all landed in whichever topic wrote
last_thread_id last.

Adopt the Slack channel:threadRoot model: a message inside a topic
(thread_id present) keys the session by "chat:thread" and persists the
real chat id in the binding config (larkBindingConfig); outbound paths
(chat reply, error card) resolve the send target via outboundChatID —
config first, falling back to the key for pre-topic rows, which keeps
legacy bindings routing unchanged. P2p and plain (non-topic) group
chats keep the raw chat id key and existing behavior.

No migration: existing topic-group sessions stay as-is; new topic
messages create per-topic sessions from the first @-mention onward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(lark): remove unused CreateLarkChatSessionBinding helper

The helper and its CreateChatSessionBindingParams had no callers; all chat-session bindings are created through the shared engine.EnsureSession path. Dropping the dead code removes a way to bypass the engine and create topic bindings with a hardcoded empty config.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 16:11:18 +08:00

301 lines
11 KiB
Go

package lark
// Channel-backed store for the Feishu integration.
//
// MUL-3515 generalized the lark_* tables into channel_* (a channel_type
// discriminator + a JSONB `config` blob for the platform-specific
// identifiers/credentials). This file owns the one boundary where that JSONB
// is (de)serialized: the rest of the package keeps working with flat domain
// structs whose fields mirror the retired db.Lark* rows one-for-one, so the
// call sites are a mechanical rename rather than a reshape.
//
// The feishu config blob carries exactly the columns that used to be flat on
// lark_installation / lark_user_binding:
//
// installation: app_id, app_secret_encrypted (base64), tenant_key,
// bot_open_id, bot_union_id, region
// user binding: union_id
//
// app_secret_encrypted is secretbox ciphertext stored as a base64 string.
// The decoder is whitespace-tolerant on purpose: the migration backfill writes
// it via PostgreSQL encode(...,'base64'), which MIME-wraps every 76 chars, and
// a sealed ~72-byte secret exceeds that. The encoder always emits unwrapped
// base64, so rows written by Go are already clean; stripping on read keeps
// both sources interchangeable.
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Installation is the flat, feishu-shaped view of a channel_installation row.
// It keeps field parity with the lark_installation row it replaced, so the
// cutover was a rename at the ~190 call sites. The feishu-specific fields
// (AppID, AppSecretEncrypted, TenantKey, BotOpenID, BotUnionID, Region) come
// from the JSONB config; the rest are flat columns.
type Installation struct {
ID pgtype.UUID
WorkspaceID pgtype.UUID
AgentID pgtype.UUID
AppID string
AppSecretEncrypted []byte
TenantKey pgtype.Text
BotOpenID string
InstallerUserID pgtype.UUID
Status string
WsLeaseToken pgtype.Text
WsLeaseExpiresAt pgtype.Timestamptz
InstalledAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
BotUnionID pgtype.Text
Region string
}
// UserBinding is the flat view of a channel_user_binding row. ChannelUserID is
// the feishu open_id; UnionID (secondary identity) lives in the JSONB config.
type UserBinding struct {
ID pgtype.UUID
WorkspaceID pgtype.UUID
MulticaUserID pgtype.UUID
InstallationID pgtype.UUID
ChannelUserID string
UnionID pgtype.Text
BoundAt pgtype.Timestamptz
}
// ChatSessionBinding is the flat view of a channel_chat_session_binding row.
type ChatSessionBinding struct {
ID pgtype.UUID
ChatSessionID pgtype.UUID
InstallationID pgtype.UUID
ChannelChatID string
ChatType string
// Config carries the real chat id (larkBindingConfig) when ChannelChatID
// is a composite "chat:thread" topic-isolation key; "{}" otherwise.
Config []byte
CreatedAt pgtype.Timestamptz
LastMessageID pgtype.Text
LastThreadID pgtype.Text
}
// InboundMessageDedup is the flat view of a channel_inbound_message_dedup row.
// Every field is a flat column (no JSON), so this mirrors the channel row 1:1.
type InboundMessageDedup struct {
InstallationID pgtype.UUID
MessageID string
ReceivedAt pgtype.Timestamptz
ProcessedAt pgtype.Timestamptz
ClaimToken pgtype.UUID
}
// BindingTokenRow is the flat view of a channel_binding_token row. ChannelUserID
// is the feishu open_id the token will bind once redeemed. (Named *Row to avoid
// colliding with BindingToken in binding_token.go, which is the freshly-minted
// raw-token shape returned to the caller.)
type BindingTokenRow struct {
TokenHash string
WorkspaceID pgtype.UUID
InstallationID pgtype.UUID
ChannelUserID string
ExpiresAt pgtype.Timestamptz
ConsumedAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
// OutboundCardMessage is the flat view of a channel_outbound_card_message row.
type OutboundCardMessage struct {
ID pgtype.UUID
ChatSessionID pgtype.UUID
TaskID pgtype.UUID
ChannelChatID string
ChannelCardMessageID string
Status string
LastPatchedAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
// feishuInstallConfig is the JSON shape of channel_installation.config for the
// feishu channel. app_secret_encrypted is decoded by hand (see decodeSecret)
// rather than as a json []byte field, so MIME-wrapped base64 from the SQL
// backfill round-trips too. omitempty mirrors the migration's jsonb_strip_nulls.
type feishuInstallConfig struct {
AppID string `json:"app_id"`
AppSecretEncrypted string `json:"app_secret_encrypted,omitempty"`
TenantKey string `json:"tenant_key,omitempty"`
BotOpenID string `json:"bot_open_id,omitempty"`
BotUnionID string `json:"bot_union_id,omitempty"`
Region string `json:"region,omitempty"`
}
// feishuBindingConfig is the JSON shape of channel_user_binding.config.
type feishuBindingConfig struct {
UnionID string `json:"union_id,omitempty"`
}
// installationFromRow decodes a channel_installation row (flat columns + JSONB
// config) into the flat Installation domain struct.
func installationFromRow(row db.ChannelInstallation) (Installation, error) {
var cfg feishuInstallConfig
if len(row.Config) > 0 {
if err := json.Unmarshal(row.Config, &cfg); err != nil {
return Installation{}, fmt.Errorf("decode installation config: %w", err)
}
}
secret, err := decodeSecret(cfg.AppSecretEncrypted)
if err != nil {
return Installation{}, fmt.Errorf("decode app_secret_encrypted: %w", err)
}
return Installation{
ID: row.ID,
WorkspaceID: row.WorkspaceID,
AgentID: row.AgentID,
AppID: cfg.AppID,
AppSecretEncrypted: secret,
TenantKey: textOrNull(cfg.TenantKey),
BotOpenID: cfg.BotOpenID,
InstallerUserID: row.InstallerUserID,
Status: row.Status,
WsLeaseToken: row.WsLeaseToken,
WsLeaseExpiresAt: row.WsLeaseExpiresAt,
InstalledAt: row.InstalledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
BotUnionID: textOrNull(cfg.BotUnionID),
Region: cfg.Region,
}, nil
}
// encodeInstallConfig builds the channel_installation.config JSONB from the
// feishu fields of an Installation. The secret is emitted as unwrapped base64.
func encodeInstallConfig(inst Installation) ([]byte, error) {
cfg := feishuInstallConfig{
AppID: inst.AppID,
TenantKey: inst.TenantKey.String,
BotOpenID: inst.BotOpenID,
BotUnionID: inst.BotUnionID.String,
Region: inst.Region,
}
if len(inst.AppSecretEncrypted) > 0 {
cfg.AppSecretEncrypted = base64.StdEncoding.EncodeToString(inst.AppSecretEncrypted)
}
return json.Marshal(cfg)
}
// userBindingFromRow decodes a channel_user_binding row into UserBinding.
func userBindingFromRow(row db.ChannelUserBinding) (UserBinding, error) {
var cfg feishuBindingConfig
if len(row.Config) > 0 {
if err := json.Unmarshal(row.Config, &cfg); err != nil {
return UserBinding{}, fmt.Errorf("decode user binding config: %w", err)
}
}
return UserBinding{
ID: row.ID,
WorkspaceID: row.WorkspaceID,
MulticaUserID: row.MulticaUserID,
InstallationID: row.InstallationID,
ChannelUserID: row.ChannelUserID,
UnionID: textOrNull(cfg.UnionID),
BoundAt: row.BoundAt,
}, nil
}
// encodeBindingConfig builds channel_user_binding.config from a UserBinding.
// Returns the JSON null-stripped (an absent union_id is "{}"), so the
// upsert's `config || jsonb_strip_nulls(EXCLUDED.config)` merge never clobbers
// a previously-captured union_id with this write.
func encodeBindingConfig(b UserBinding) ([]byte, error) {
return json.Marshal(feishuBindingConfig{UnionID: b.UnionID.String})
}
// chatSessionBindingFromRow copies a channel_chat_session_binding row into the
// flat domain struct. Config stays raw bytes; outboundChatID decodes it.
func chatSessionBindingFromRow(row db.ChannelChatSessionBinding) ChatSessionBinding {
return ChatSessionBinding{
ID: row.ID,
ChatSessionID: row.ChatSessionID,
InstallationID: row.InstallationID,
ChannelChatID: row.ChannelChatID,
ChatType: row.ChatType,
Config: row.Config,
CreatedAt: row.CreatedAt,
LastMessageID: row.LastMessageID,
LastThreadID: row.LastThreadID,
}
}
// dedupFromRow copies a channel_inbound_message_dedup row into the flat domain
// struct. No JSON: every field is a flat column.
func dedupFromRow(row db.ChannelInboundMessageDedup) InboundMessageDedup {
return InboundMessageDedup{
InstallationID: row.InstallationID,
MessageID: row.MessageID,
ReceivedAt: row.ReceivedAt,
ProcessedAt: row.ProcessedAt,
ClaimToken: row.ClaimToken,
}
}
// bindingTokenFromRow copies a channel_binding_token row into the flat domain
// struct.
func bindingTokenFromRow(row db.ChannelBindingToken) BindingTokenRow {
return BindingTokenRow{
TokenHash: row.TokenHash,
WorkspaceID: row.WorkspaceID,
InstallationID: row.InstallationID,
ChannelUserID: row.ChannelUserID,
ExpiresAt: row.ExpiresAt,
ConsumedAt: row.ConsumedAt,
CreatedAt: row.CreatedAt,
}
}
// outboundCardFromRow copies a channel_outbound_card_message row into the flat
// domain struct.
func outboundCardFromRow(row db.ChannelOutboundCardMessage) OutboundCardMessage {
return OutboundCardMessage{
ID: row.ID,
ChatSessionID: row.ChatSessionID,
TaskID: row.TaskID,
ChannelChatID: row.ChannelChatID,
ChannelCardMessageID: row.ChannelCardMessageID,
Status: row.Status,
LastPatchedAt: row.LastPatchedAt,
CreatedAt: row.CreatedAt,
}
}
// decodeSecret base64-decodes the stored app secret ciphertext. It tolerates
// the newline wrapping PostgreSQL's encode(...,'base64') inserts, so secrets
// written by the SQL backfill and by encodeInstallConfig both decode. An empty
// string yields a nil slice (an installation mid-registration before the
// secret is sealed).
func decodeSecret(s string) ([]byte, error) {
if s == "" {
return nil, nil
}
return base64.StdEncoding.DecodeString(stripWhitespace(s))
}
// stripWhitespace removes the ASCII whitespace MIME base64 wrapping introduces.
func stripWhitespace(s string) string {
if !strings.ContainsAny(s, "\n\r \t") {
return s
}
return strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
default:
return r
}
}, s)
}