mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
609ca49cf5
* feat(channel): add Matrix adapter support * fix(channel): prevent reasoning leaks in Matrix replies * fix(channel): persist Matrix sync cursors * fix(channel): improve Matrix markdown rendering * fix(channel): support Matrix attachments and multimodal history * fix(channel): expand Matrix reply media context * fix(handlers): allow media downloads for chat-access bots * fix(channel): classify Matrix DMs as direct chats * fix(channel): auto-join Matrix room invites * fix(channel): resolve Matrix room aliases for outbound send * fix(web): use Matrix brand icon in channel badges Replace the generic Matrix hashtag badge with the official brand asset so channel badges feel recognizable and fit the circular mask cleanly. * fix(channel): add Matrix room whitelist controls Let Matrix bots decide whether to auto-join invites and restrict inbound activity to allowed rooms or aliases. Expose the new controls in the web settings UI with line-based whitelist input so access rules stay explicit. * fix(channel): stabilize Matrix multimodal follow-ups and settings * fix(flow): avoid gosec panic on byte decoding * fix: fix golangci-lint * fix(channel): remove Matrix built-in ACL * fix(channel): preserve Matrix image captions * fix(channel): validate Matrix homeserver and sync access Fail Matrix connections early when the homeserver, access token, or /sync capability is misconfigured so bot health checks surface actionable errors. * fix(channel): preserve optional toggles and relax Matrix startup validation * fix(channel): tighten Matrix mention fallback parsing * fix(flow): skip structured assistant tool-call outputs * fix(flow): resolve merged resolver duplication Keep the internal agent resolver implementation after merging main so split helper files do not redeclare flow symbols. Restore user message normalization in sanitize and persistence paths to keep flow tests and command packages building. * fix(flow): remove unused merged resolver helper Drop the leftover truncate helper and import from the resolver merge fix so golangci-lint passes again without affecting flow behavior. --------- Co-authored-by: Acbox Liu <acbox0328@gmail.com>
414 lines
13 KiB
Go
414 lines
13 KiB
Go
package channel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"github.com/memohai/memoh/internal/db"
|
|
"github.com/memohai/memoh/internal/db/sqlc"
|
|
)
|
|
|
|
// ErrChannelConfigNotFound indicates the bot has no persisted config for the channel type.
|
|
var ErrChannelConfigNotFound = errors.New("channel config not found")
|
|
|
|
// Store provides CRUD operations for channel configurations, user bindings, and sessions.
|
|
type Store struct {
|
|
queries *sqlc.Queries
|
|
registry *Registry
|
|
}
|
|
|
|
// NewStore creates a Store backed by the given database queries and adapter registry.
|
|
func NewStore(queries *sqlc.Queries, registry *Registry) *Store {
|
|
if registry == nil {
|
|
registry = NewRegistry()
|
|
}
|
|
return &Store{queries: queries, registry: registry}
|
|
}
|
|
|
|
// UpsertConfig creates or updates a bot's channel configuration.
|
|
func (s *Store) UpsertConfig(ctx context.Context, botID string, channelType ChannelType, req UpsertConfigRequest) (ChannelConfig, error) {
|
|
if s.queries == nil {
|
|
return ChannelConfig{}, errors.New("channel queries not configured")
|
|
}
|
|
if channelType == "" {
|
|
return ChannelConfig{}, errors.New("channel type is required")
|
|
}
|
|
normalized, err := s.registry.NormalizeConfig(channelType, req.Credentials)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
credentialsPayload, err := json.Marshal(normalized)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
selfIdentity := req.SelfIdentity
|
|
if selfIdentity == nil {
|
|
selfIdentity = map[string]any{}
|
|
}
|
|
externalIdentity := strings.TrimSpace(req.ExternalIdentity)
|
|
if discovered, extID, err := s.registry.DiscoverSelf(ctx, channelType, normalized); err == nil && discovered != nil {
|
|
for k, v := range discovered {
|
|
if _, exists := selfIdentity[k]; !exists {
|
|
selfIdentity[k] = v
|
|
}
|
|
}
|
|
if externalIdentity == "" && strings.TrimSpace(extID) != "" {
|
|
externalIdentity = strings.TrimSpace(extID)
|
|
}
|
|
}
|
|
selfPayload, err := json.Marshal(selfIdentity)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
routing := req.Routing
|
|
if routing == nil {
|
|
routing = map[string]any{}
|
|
}
|
|
routingPayload, err := json.Marshal(routing)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
disabled := false
|
|
if req.Disabled != nil {
|
|
disabled = *req.Disabled
|
|
}
|
|
verifiedAt := pgtype.Timestamptz{Valid: false}
|
|
if req.VerifiedAt != nil {
|
|
verifiedAt = pgtype.Timestamptz{Time: req.VerifiedAt.UTC(), Valid: true}
|
|
}
|
|
row, err := s.queries.UpsertBotChannelConfig(ctx, sqlc.UpsertBotChannelConfigParams{
|
|
BotID: botUUID,
|
|
ChannelType: channelType.String(),
|
|
Credentials: credentialsPayload,
|
|
ExternalIdentity: pgtype.Text{
|
|
String: externalIdentity,
|
|
Valid: externalIdentity != "",
|
|
},
|
|
SelfIdentity: selfPayload,
|
|
Routing: routingPayload,
|
|
Capabilities: []byte("{}"),
|
|
Disabled: disabled,
|
|
VerifiedAt: verifiedAt,
|
|
})
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
return normalizeChannelConfigFromRow(row)
|
|
}
|
|
|
|
// DeleteConfig removes a bot's channel configuration.
|
|
func (s *Store) DeleteConfig(ctx context.Context, botID string, channelType ChannelType) error {
|
|
if s.queries == nil {
|
|
return errors.New("channel queries not configured")
|
|
}
|
|
if channelType == "" {
|
|
return errors.New("channel type is required")
|
|
}
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.queries.DeleteBotChannelConfig(ctx, sqlc.DeleteBotChannelConfigParams{
|
|
BotID: botUUID,
|
|
ChannelType: channelType.String(),
|
|
})
|
|
}
|
|
|
|
// UpdateConfigDisabled updates only the disabled flag for a bot channel config and returns latest config.
|
|
func (s *Store) UpdateConfigDisabled(ctx context.Context, botID string, channelType ChannelType, disabled bool) (ChannelConfig, error) {
|
|
if s.queries == nil {
|
|
return ChannelConfig{}, errors.New("channel queries not configured")
|
|
}
|
|
if channelType == "" {
|
|
return ChannelConfig{}, errors.New("channel type is required")
|
|
}
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
row, err := s.queries.UpdateBotChannelConfigDisabled(ctx, sqlc.UpdateBotChannelConfigDisabledParams{
|
|
BotID: botUUID,
|
|
ChannelType: channelType.String(),
|
|
Disabled: disabled,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ChannelConfig{}, fmt.Errorf("%w", ErrChannelConfigNotFound)
|
|
}
|
|
return ChannelConfig{}, err
|
|
}
|
|
return normalizeChannelConfigFromRow(row)
|
|
}
|
|
|
|
// SaveMatrixSyncSinceToken persists the Matrix /sync cursor without mutating channel config updated_at.
|
|
func (s *Store) SaveMatrixSyncSinceToken(ctx context.Context, configID string, since string) error {
|
|
if s.queries == nil {
|
|
return errors.New("channel queries not configured")
|
|
}
|
|
pgConfigID, err := db.ParseUUID(configID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := s.queries.SaveMatrixSyncSinceToken(ctx, sqlc.SaveMatrixSyncSinceTokenParams{
|
|
ID: pgConfigID,
|
|
SinceToken: strings.TrimSpace(since),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("%w", ErrChannelConfigNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpsertChannelIdentityConfig creates or updates a channel identity's channel binding.
|
|
func (s *Store) UpsertChannelIdentityConfig(ctx context.Context, channelIdentityID string, channelType ChannelType, req UpsertChannelIdentityConfigRequest) (ChannelIdentityBinding, error) {
|
|
if s.queries == nil {
|
|
return ChannelIdentityBinding{}, errors.New("channel queries not configured")
|
|
}
|
|
if channelType == "" {
|
|
return ChannelIdentityBinding{}, errors.New("channel type is required")
|
|
}
|
|
normalized, err := s.registry.NormalizeUserConfig(channelType, req.Config)
|
|
if err != nil {
|
|
return ChannelIdentityBinding{}, err
|
|
}
|
|
payload, err := json.Marshal(normalized)
|
|
if err != nil {
|
|
return ChannelIdentityBinding{}, err
|
|
}
|
|
pgChannelIdentityID, err := db.ParseUUID(channelIdentityID)
|
|
if err != nil {
|
|
return ChannelIdentityBinding{}, err
|
|
}
|
|
row, err := s.queries.UpsertUserChannelBinding(ctx, sqlc.UpsertUserChannelBindingParams{
|
|
UserID: pgChannelIdentityID,
|
|
ChannelType: channelType.String(),
|
|
Config: payload,
|
|
})
|
|
if err != nil {
|
|
return ChannelIdentityBinding{}, err
|
|
}
|
|
return normalizeChannelIdentityBinding(row)
|
|
}
|
|
|
|
// ResolveEffectiveConfig returns the active channel configuration for a bot.
|
|
// For configless channel types, a synthetic config is returned.
|
|
func (s *Store) ResolveEffectiveConfig(ctx context.Context, botID string, channelType ChannelType) (ChannelConfig, error) {
|
|
if s.queries == nil {
|
|
return ChannelConfig{}, errors.New("channel queries not configured")
|
|
}
|
|
if channelType == "" {
|
|
return ChannelConfig{}, errors.New("channel type is required")
|
|
}
|
|
if s.registry.IsConfigless(channelType) {
|
|
return ChannelConfig{
|
|
ID: channelType.String() + ":" + strings.TrimSpace(botID),
|
|
BotID: strings.TrimSpace(botID),
|
|
ChannelType: channelType,
|
|
}, nil
|
|
}
|
|
botUUID, err := db.ParseUUID(botID)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
row, err := s.queries.GetBotChannelConfig(ctx, sqlc.GetBotChannelConfigParams{
|
|
BotID: botUUID,
|
|
ChannelType: channelType.String(),
|
|
})
|
|
if err == nil {
|
|
return normalizeChannelConfigFromGetRow(row)
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return ChannelConfig{}, err
|
|
}
|
|
return ChannelConfig{}, fmt.Errorf("%w", ErrChannelConfigNotFound)
|
|
}
|
|
|
|
// ListConfigsByType returns all channel configurations of the given type.
|
|
func (s *Store) ListConfigsByType(ctx context.Context, channelType ChannelType) ([]ChannelConfig, error) {
|
|
if s.queries == nil {
|
|
return nil, errors.New("channel queries not configured")
|
|
}
|
|
if s.registry.IsConfigless(channelType) {
|
|
return []ChannelConfig{}, nil
|
|
}
|
|
rows, err := s.queries.ListBotChannelConfigsByType(ctx, channelType.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]ChannelConfig, 0, len(rows))
|
|
for _, row := range rows {
|
|
item, err := normalizeChannelConfigFromListRow(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// GetChannelIdentityConfig returns the channel identity's channel binding for the given channel type.
|
|
func (s *Store) GetChannelIdentityConfig(ctx context.Context, channelIdentityID string, channelType ChannelType) (ChannelIdentityBinding, error) {
|
|
if s.queries == nil {
|
|
return ChannelIdentityBinding{}, errors.New("channel queries not configured")
|
|
}
|
|
if channelType == "" {
|
|
return ChannelIdentityBinding{}, errors.New("channel type is required")
|
|
}
|
|
pgChannelIdentityID, err := db.ParseUUID(channelIdentityID)
|
|
if err != nil {
|
|
return ChannelIdentityBinding{}, err
|
|
}
|
|
row, err := s.queries.GetUserChannelBinding(ctx, sqlc.GetUserChannelBindingParams{
|
|
UserID: pgChannelIdentityID,
|
|
ChannelType: channelType.String(),
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ChannelIdentityBinding{}, errors.New("channel user config not found")
|
|
}
|
|
return ChannelIdentityBinding{}, err
|
|
}
|
|
config, err := DecodeConfigMap(row.Config)
|
|
if err != nil {
|
|
return ChannelIdentityBinding{}, err
|
|
}
|
|
return ChannelIdentityBinding{
|
|
ID: row.ID.String(),
|
|
ChannelType: ChannelType(row.ChannelType),
|
|
ChannelIdentityID: row.UserID.String(),
|
|
Config: config,
|
|
CreatedAt: db.TimeFromPg(row.CreatedAt),
|
|
UpdatedAt: db.TimeFromPg(row.UpdatedAt),
|
|
}, nil
|
|
}
|
|
|
|
// ListChannelIdentityConfigsByType returns all channel identity bindings for the given channel type.
|
|
func (s *Store) ListChannelIdentityConfigsByType(ctx context.Context, channelType ChannelType) ([]ChannelIdentityBinding, error) {
|
|
if s.queries == nil {
|
|
return nil, errors.New("channel queries not configured")
|
|
}
|
|
rows, err := s.queries.ListUserChannelBindingsByPlatform(ctx, channelType.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]ChannelIdentityBinding, 0, len(rows))
|
|
for _, row := range rows {
|
|
item, err := normalizeChannelIdentityBinding(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// ResolveChannelIdentityBinding finds the channel identity ID whose channel binding matches the given criteria.
|
|
func (s *Store) ResolveChannelIdentityBinding(ctx context.Context, channelType ChannelType, criteria BindingCriteria) (string, error) {
|
|
rows, err := s.ListChannelIdentityConfigsByType(ctx, channelType)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, ok := s.registry.Get(channelType); !ok {
|
|
return "", fmt.Errorf("unsupported channel type: %s", channelType)
|
|
}
|
|
for _, row := range rows {
|
|
if s.registry.MatchUserBinding(channelType, row.Config, criteria) {
|
|
return row.ChannelIdentityID, nil
|
|
}
|
|
}
|
|
return "", errors.New("channel user binding not found")
|
|
}
|
|
|
|
func normalizeChannelConfigFromRow(row sqlc.BotChannelConfig) (ChannelConfig, error) {
|
|
return normalizeChannelConfigFields(
|
|
row.ID, row.BotID, row.ChannelType,
|
|
row.Credentials, row.ExternalIdentity, row.SelfIdentity, row.Routing,
|
|
row.Disabled, row.VerifiedAt, row.CreatedAt, row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func normalizeChannelConfigFromGetRow(row sqlc.BotChannelConfig) (ChannelConfig, error) {
|
|
return normalizeChannelConfigFields(
|
|
row.ID, row.BotID, row.ChannelType,
|
|
row.Credentials, row.ExternalIdentity, row.SelfIdentity, row.Routing,
|
|
row.Disabled, row.VerifiedAt, row.CreatedAt, row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func normalizeChannelConfigFromListRow(row sqlc.BotChannelConfig) (ChannelConfig, error) {
|
|
return normalizeChannelConfigFields(
|
|
row.ID, row.BotID, row.ChannelType,
|
|
row.Credentials, row.ExternalIdentity, row.SelfIdentity, row.Routing,
|
|
row.Disabled, row.VerifiedAt, row.CreatedAt, row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func normalizeChannelConfigFields(
|
|
id, botID pgtype.UUID, channelType string,
|
|
credentials []byte, externalIdentity pgtype.Text, selfIdentity, routing []byte,
|
|
disabled bool, verifiedAt, createdAt, updatedAt pgtype.Timestamptz,
|
|
) (ChannelConfig, error) {
|
|
credentialsMap, err := DecodeConfigMap(credentials)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
selfIdentityMap, err := DecodeConfigMap(selfIdentity)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
routingMap, err := DecodeConfigMap(routing)
|
|
if err != nil {
|
|
return ChannelConfig{}, err
|
|
}
|
|
verifiedAtTime := time.Time{}
|
|
if verifiedAt.Valid {
|
|
verifiedAtTime = verifiedAt.Time
|
|
}
|
|
externalIdentityStr := ""
|
|
if externalIdentity.Valid {
|
|
externalIdentityStr = strings.TrimSpace(externalIdentity.String)
|
|
}
|
|
return ChannelConfig{
|
|
ID: id.String(),
|
|
BotID: botID.String(),
|
|
ChannelType: ChannelType(channelType),
|
|
Credentials: credentialsMap,
|
|
ExternalIdentity: externalIdentityStr,
|
|
SelfIdentity: selfIdentityMap,
|
|
Routing: routingMap,
|
|
Disabled: disabled,
|
|
VerifiedAt: verifiedAtTime,
|
|
CreatedAt: db.TimeFromPg(createdAt),
|
|
UpdatedAt: db.TimeFromPg(updatedAt),
|
|
}, nil
|
|
}
|
|
|
|
func normalizeChannelIdentityBinding(row sqlc.UserChannelBinding) (ChannelIdentityBinding, error) {
|
|
config, err := DecodeConfigMap(row.Config)
|
|
if err != nil {
|
|
return ChannelIdentityBinding{}, err
|
|
}
|
|
return ChannelIdentityBinding{
|
|
ID: row.ID.String(),
|
|
ChannelType: ChannelType(row.ChannelType),
|
|
ChannelIdentityID: row.UserID.String(),
|
|
Config: config,
|
|
CreatedAt: db.TimeFromPg(row.CreatedAt),
|
|
UpdatedAt: db.TimeFromPg(row.UpdatedAt),
|
|
}, nil
|
|
}
|