mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 10:08:38 +02:00
* 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>
301 lines
11 KiB
Go
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)
|
|
}
|