mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
6acdd191c7
commit bcdb026ae43e4f95d0b2c4f9bd440a2df9d6b514 Author: Ran <16112591+chen-ran@users.noreply.github.com> Date: Thu Feb 12 17:10:32 2026 +0800 chore: update DEVELOPMENT.md commit30281742efMerge:ca5c6a15b05f13Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Thu Feb 12 15:49:17 2026 +0800 merge(github/main): integrate fx dependency injection framework Merge upstream fx refactor and adapt all services to use go.uber.org/fx for dependency injection. Resolve conflicts in main.go, server.go, and service constructors while preserving our domain model changes. - Fix telegram adapter panic on shutdown (double close channel) - Fix feishu adapter processing messages after stop - Increase directory lookup timeout from 2s to 5s commitca5c6a1866Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Thu Feb 12 15:33:09 2026 +0800 refactor(core): restructure conversation, channel and message domains - Rename chat module to conversation with flow-based architecture - Move channelidentities into channel/identities subpackage - Add channel/route for routing logic - Add message service with event hub - Add MCP providers: container, directory, schedule - Refactor Feishu/Telegram adapters with directory and stream support - Add platform management page and channel badges in web UI - Update database schema for conversations, messages and channel routes - Add @memoh/shared package for cross-package type definitions commit75e2ef0467Merge:d99ba3801cb6c8Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Thu Feb 12 14:45:49 2026 +0800 merge(github): merge github/main, resolve index.ts URL conflict Keep our defensive absolute-URL check in createAuthFetcher. commitd99ba38b7dMerge:860e20f35ce7d1Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Thu Feb 12 05:20:18 2026 +0800 merge(github): merge github/main, keep our code and docs/spec commit860e20fe70Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Wed Feb 11 22:13:27 2026 +0800 docs(docs): add concepts and style guides for VitePress site - Add concepts: identity-and-binding, index (en/zh) - Add style: terminology (en/zh) - Update index and zh/index - Update .vitepress/config.ts commita75fdb8040Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Wed Feb 11 17:37:16 2026 +0800 refactor(mcp): standardize unified tool gateway on go-sdk Split business executors from federation sources and migrate unified tool/federation transports to the official go-sdk for stricter MCP compliance and safer session lifecycle handling. Add targeted regression tests for accept compatibility, initialization retries, pending cleanup, and include updated swagger artifacts. commit02b33c8e85Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Wed Feb 11 15:42:21 2026 +0800 refactor(core): finalize user-centric identity and policy cleanup Unify auth and chat identity semantics around user_id, enforce personal-bot owner-only authorization, and remove legacy compatibility branches in integration tests. commit06e8619a37Author: BBQ <bbq@BBQdeMacBook-Air.local> Date: Wed Feb 11 14:47:03 2026 +0800 refactor(core): migrate channel identity and binding across app Align channel identity and bind flow across backend and app-facing layers, including generated swagger artifacts and package lock updates while excluding docs content changes.
1001 lines
27 KiB
Go
1001 lines
27 KiB
Go
package conversation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"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"
|
|
)
|
|
|
|
var (
|
|
ErrChatNotFound = errors.New("chat not found")
|
|
ErrNotParticipant = errors.New("not a participant")
|
|
ErrPermissionDenied = errors.New("permission denied")
|
|
)
|
|
|
|
// Service manages chat lifecycle, participants, settings, and routes.
|
|
type Service struct {
|
|
queries *sqlc.Queries
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewService creates a chat service.
|
|
func NewService(log *slog.Logger, queries *sqlc.Queries) *Service {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &Service{
|
|
queries: queries,
|
|
logger: log.With(slog.String("service", "chat")),
|
|
}
|
|
}
|
|
|
|
// --- Chat CRUD ---
|
|
|
|
// Create creates a new chat and adds the creator as owner.
|
|
func (s *Service) Create(ctx context.Context, botID, channelIdentityID string, req CreateRequest) (Chat, error) {
|
|
kind := strings.TrimSpace(req.Kind)
|
|
if kind == "" {
|
|
kind = KindDirect
|
|
}
|
|
if kind != KindDirect && kind != KindGroup && kind != KindThread {
|
|
return Chat{}, fmt.Errorf("invalid chat kind: %s", kind)
|
|
}
|
|
|
|
pgBotID, err := parseUUID(botID)
|
|
if err != nil {
|
|
return Chat{}, fmt.Errorf("invalid bot id: %w", err)
|
|
}
|
|
pgChannelIdentityID := pgtype.UUID{}
|
|
if strings.TrimSpace(channelIdentityID) != "" {
|
|
pgChannelIdentityID, err = parseUUID(channelIdentityID)
|
|
if err != nil {
|
|
return Chat{}, fmt.Errorf("invalid user id: %w", err)
|
|
}
|
|
}
|
|
|
|
var pgParent pgtype.UUID
|
|
if kind == KindThread && strings.TrimSpace(req.ParentChatID) != "" {
|
|
pgParent, err = parseUUID(req.ParentChatID)
|
|
if err != nil {
|
|
return Chat{}, fmt.Errorf("invalid parent chat id: %w", err)
|
|
}
|
|
}
|
|
|
|
metadata, err := json.Marshal(nonNilMap(req.Metadata))
|
|
if err != nil {
|
|
return Chat{}, fmt.Errorf("marshal chat metadata: %w", err)
|
|
}
|
|
|
|
row, err := s.queries.CreateChat(ctx, sqlc.CreateChatParams{
|
|
BotID: pgBotID,
|
|
Kind: kind,
|
|
ParentChatID: pgParent,
|
|
Title: strings.TrimSpace(req.Title),
|
|
CreatedByUserID: pgChannelIdentityID,
|
|
Metadata: metadata,
|
|
})
|
|
if err != nil {
|
|
return Chat{}, fmt.Errorf("create chat: %w", err)
|
|
}
|
|
|
|
// Add creator as owner when user identity is available.
|
|
if pgChannelIdentityID.Valid {
|
|
if _, err := s.queries.AddChatParticipant(ctx, sqlc.AddChatParticipantParams{
|
|
ChatID: row.ID,
|
|
UserID: pgChannelIdentityID,
|
|
Role: RoleOwner,
|
|
}); err != nil {
|
|
return Chat{}, fmt.Errorf("add owner participant: %w", err)
|
|
}
|
|
}
|
|
|
|
// For threads, copy participants from parent.
|
|
if kind == KindThread && pgParent.Valid {
|
|
if err := s.queries.CopyParticipantsToChat(ctx, sqlc.CopyParticipantsToChatParams{
|
|
ChatID: pgParent,
|
|
ChatID2: row.ID,
|
|
}); err != nil {
|
|
s.logger.Warn("copy parent participants failed", slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
return toChatFromCreate(row), nil
|
|
}
|
|
|
|
// Get returns a chat by ID.
|
|
func (s *Service) Get(ctx context.Context, chatID string) (Chat, error) {
|
|
pgID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return Chat{}, ErrChatNotFound
|
|
}
|
|
row, err := s.queries.GetChatByID(ctx, pgID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Chat{}, ErrChatNotFound
|
|
}
|
|
return Chat{}, err
|
|
}
|
|
return toChatFromGet(row), nil
|
|
}
|
|
|
|
// GetReadAccess resolves whether a user can read a chat.
|
|
func (s *Service) GetReadAccess(ctx context.Context, chatID, channelIdentityID string) (ChatReadAccess, error) {
|
|
pgChatID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return ChatReadAccess{}, ErrPermissionDenied
|
|
}
|
|
pgChannelIdentityID, err := parseUUID(channelIdentityID)
|
|
if err != nil {
|
|
return ChatReadAccess{}, ErrPermissionDenied
|
|
}
|
|
row, err := s.queries.GetChatReadAccessByUser(ctx, sqlc.GetChatReadAccessByUserParams{
|
|
ChatID: pgChatID,
|
|
UserID: pgChannelIdentityID,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ChatReadAccess{}, ErrPermissionDenied
|
|
}
|
|
return ChatReadAccess{}, err
|
|
}
|
|
return ChatReadAccess{
|
|
AccessMode: row.AccessMode,
|
|
ParticipantRole: strings.TrimSpace(row.ParticipantRole),
|
|
LastObservedAt: pgTimePtr(row.LastObservedAt),
|
|
}, nil
|
|
}
|
|
|
|
// ListByBotAndChannelIdentity returns all chats visible to the user for a bot.
|
|
func (s *Service) ListByBotAndChannelIdentity(ctx context.Context, botID, channelIdentityID string) ([]ChatListItem, error) {
|
|
pgBotID, err := parseUUID(botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pgChannelIdentityID, err := parseUUID(channelIdentityID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.queries.ListVisibleChatsByBotAndUser(ctx, sqlc.ListVisibleChatsByBotAndUserParams{
|
|
BotID: pgBotID,
|
|
UserID: pgChannelIdentityID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chats := make([]ChatListItem, 0, len(rows))
|
|
for _, row := range rows {
|
|
chats = append(chats, toChatListItem(row))
|
|
}
|
|
return chats, nil
|
|
}
|
|
|
|
// ListThreads returns threads for a parent chat.
|
|
func (s *Service) ListThreads(ctx context.Context, parentChatID string) ([]Chat, error) {
|
|
pgID, err := parseUUID(parentChatID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.queries.ListThreadsByParent(ctx, pgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chats := make([]Chat, 0, len(rows))
|
|
for _, row := range rows {
|
|
chats = append(chats, toChatFromThread(row))
|
|
}
|
|
return chats, nil
|
|
}
|
|
|
|
// Delete deletes a chat (cascade deletes messages, routes, participants, settings).
|
|
func (s *Service) Delete(ctx context.Context, chatID string) error {
|
|
pgID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return ErrChatNotFound
|
|
}
|
|
return s.queries.DeleteChat(ctx, pgID)
|
|
}
|
|
|
|
// --- Participants ---
|
|
|
|
// AddParticipant adds a user identity to a chat.
|
|
func (s *Service) AddParticipant(ctx context.Context, chatID, channelIdentityID, role string) (Participant, error) {
|
|
pgChatID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return Participant{}, err
|
|
}
|
|
pgChannelIdentityID, err := parseUUID(channelIdentityID)
|
|
if err != nil {
|
|
return Participant{}, err
|
|
}
|
|
if role == "" {
|
|
role = RoleMember
|
|
}
|
|
row, err := s.queries.AddChatParticipant(ctx, sqlc.AddChatParticipantParams{
|
|
ChatID: pgChatID,
|
|
UserID: pgChannelIdentityID,
|
|
Role: role,
|
|
})
|
|
if err != nil {
|
|
return Participant{}, err
|
|
}
|
|
return toParticipantFromAdd(row), nil
|
|
}
|
|
|
|
// GetParticipant returns a participant record.
|
|
func (s *Service) GetParticipant(ctx context.Context, chatID, channelIdentityID string) (Participant, error) {
|
|
pgChatID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return Participant{}, ErrNotParticipant
|
|
}
|
|
pgChannelIdentityID, err := parseUUID(channelIdentityID)
|
|
if err != nil {
|
|
return Participant{}, ErrNotParticipant
|
|
}
|
|
row, err := s.queries.GetChatParticipant(ctx, sqlc.GetChatParticipantParams{
|
|
ChatID: pgChatID,
|
|
UserID: pgChannelIdentityID,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Participant{}, ErrNotParticipant
|
|
}
|
|
return Participant{}, err
|
|
}
|
|
return toParticipantFromGet(row), nil
|
|
}
|
|
|
|
// IsParticipant checks whether a user identity is a participant in a chat.
|
|
func (s *Service) IsParticipant(ctx context.Context, chatID, channelIdentityID string) (bool, error) {
|
|
_, err := s.GetParticipant(ctx, chatID, channelIdentityID)
|
|
if errors.Is(err, ErrNotParticipant) {
|
|
return false, nil
|
|
}
|
|
return err == nil, err
|
|
}
|
|
|
|
// ListParticipants returns all participants for a chat.
|
|
func (s *Service) ListParticipants(ctx context.Context, chatID string) ([]Participant, error) {
|
|
pgID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.queries.ListChatParticipants(ctx, pgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
participants := make([]Participant, 0, len(rows))
|
|
for _, row := range rows {
|
|
participants = append(participants, toParticipantFromList(row))
|
|
}
|
|
return participants, nil
|
|
}
|
|
|
|
// RemoveParticipant removes a user identity from a chat.
|
|
func (s *Service) RemoveParticipant(ctx context.Context, chatID, channelIdentityID string) error {
|
|
pgChatID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pgChannelIdentityID, err := parseUUID(channelIdentityID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.queries.RemoveChatParticipant(ctx, sqlc.RemoveChatParticipantParams{
|
|
ChatID: pgChatID,
|
|
UserID: pgChannelIdentityID,
|
|
})
|
|
}
|
|
|
|
// --- Settings ---
|
|
|
|
// GetSettings returns settings for a chat. Returns defaults if not found.
|
|
func (s *Service) GetSettings(ctx context.Context, chatID string) (Settings, error) {
|
|
pgID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return defaultSettings(chatID), nil
|
|
}
|
|
row, err := s.queries.GetChatSettings(ctx, pgID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return defaultSettings(chatID), nil
|
|
}
|
|
return Settings{}, err
|
|
}
|
|
return toSettingsFromRead(row), nil
|
|
}
|
|
|
|
// UpdateSettings updates chat settings.
|
|
func (s *Service) UpdateSettings(ctx context.Context, chatID string, req UpdateSettingsRequest) (Settings, error) {
|
|
current, err := s.GetSettings(ctx, chatID)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
if req.ModelID != nil {
|
|
current.ModelID = *req.ModelID
|
|
}
|
|
|
|
pgID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
row, err := s.queries.UpsertChatSettings(ctx, sqlc.UpsertChatSettingsParams{
|
|
ID: pgID,
|
|
ModelID: toPgText(current.ModelID),
|
|
})
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return toSettingsFromUpsert(row), nil
|
|
}
|
|
|
|
// --- Routes ---
|
|
|
|
// CreateRoute creates a new chat route.
|
|
func (s *Service) CreateRoute(ctx context.Context, chatID string, r Route) (Route, error) {
|
|
pgChatID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return Route{}, err
|
|
}
|
|
pgBotID, err := parseUUID(r.BotID)
|
|
if err != nil {
|
|
return Route{}, err
|
|
}
|
|
var pgConfigID pgtype.UUID
|
|
if strings.TrimSpace(r.ChannelConfigID) != "" {
|
|
pgConfigID, err = parseUUID(r.ChannelConfigID)
|
|
if err != nil {
|
|
return Route{}, err
|
|
}
|
|
}
|
|
metadata, err := json.Marshal(nonNilMap(r.Metadata))
|
|
if err != nil {
|
|
return Route{}, fmt.Errorf("marshal route metadata: %w", err)
|
|
}
|
|
row, err := s.queries.CreateChatRoute(ctx, sqlc.CreateChatRouteParams{
|
|
ChatID: pgChatID,
|
|
BotID: pgBotID,
|
|
Platform: r.Platform,
|
|
ChannelConfigID: pgConfigID,
|
|
ConversationID: r.ConversationID,
|
|
ThreadID: toPgText(r.ThreadID),
|
|
ReplyTarget: toPgText(r.ReplyTarget),
|
|
Metadata: metadata,
|
|
})
|
|
if err != nil {
|
|
return Route{}, fmt.Errorf("create route: %w", err)
|
|
}
|
|
return toRouteFromCreate(row), nil
|
|
}
|
|
|
|
// FindRoute looks up a route by (bot_id, platform, conversation_id, thread_id).
|
|
func (s *Service) FindRoute(ctx context.Context, botID, platform, conversationID, threadID string) (Route, error) {
|
|
pgBotID, err := parseUUID(botID)
|
|
if err != nil {
|
|
return Route{}, err
|
|
}
|
|
row, err := s.queries.FindChatRoute(ctx, sqlc.FindChatRouteParams{
|
|
BotID: pgBotID,
|
|
Platform: platform,
|
|
ConversationID: conversationID,
|
|
ThreadID: toPgText(threadID),
|
|
})
|
|
if err != nil {
|
|
return Route{}, err
|
|
}
|
|
return toRouteFromFind(row), nil
|
|
}
|
|
|
|
// GetRouteByID returns a single route by its ID.
|
|
func (s *Service) GetRouteByID(ctx context.Context, routeID string) (Route, error) {
|
|
pgID, err := parseUUID(routeID)
|
|
if err != nil {
|
|
return Route{}, err
|
|
}
|
|
row, err := s.queries.GetChatRouteByID(ctx, pgID)
|
|
if err != nil {
|
|
return Route{}, err
|
|
}
|
|
return toRouteFromGet(row), nil
|
|
}
|
|
|
|
// ListRoutes lists all routes for a chat.
|
|
func (s *Service) ListRoutes(ctx context.Context, chatID string) ([]Route, error) {
|
|
pgID, err := parseUUID(chatID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.queries.ListChatRoutes(ctx, pgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
routes := make([]Route, 0, len(rows))
|
|
for _, row := range rows {
|
|
routes = append(routes, toRouteFromList(row))
|
|
}
|
|
return routes, nil
|
|
}
|
|
|
|
// DeleteRoute deletes a route.
|
|
func (s *Service) DeleteRoute(ctx context.Context, routeID string) error {
|
|
pgID, err := parseUUID(routeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.queries.DeleteChatRoute(ctx, pgID)
|
|
}
|
|
|
|
// UpdateRouteReplyTarget updates the reply target for a route.
|
|
func (s *Service) UpdateRouteReplyTarget(ctx context.Context, routeID, replyTarget string) error {
|
|
pgID, err := parseUUID(routeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.queries.UpdateChatRouteReplyTarget(ctx, sqlc.UpdateChatRouteReplyTargetParams{
|
|
ID: pgID,
|
|
ReplyTarget: toPgText(replyTarget),
|
|
})
|
|
}
|
|
|
|
// --- ResolveChat ---
|
|
|
|
// ResolveChat finds or creates a chat for a channel inbound message.
|
|
func (s *Service) ResolveChat(ctx context.Context, botID, platform, conversationID, threadID, conversationType, channelIdentityID, channelConfigID, replyTarget string) (ResolveChatResult, error) {
|
|
// Look up existing route.
|
|
route, err := s.FindRoute(ctx, botID, platform, conversationID, threadID)
|
|
if err == nil {
|
|
// Route found, ensure the sender identity is a participant.
|
|
if strings.TrimSpace(channelIdentityID) != "" {
|
|
ok, checkErr := s.IsParticipant(ctx, route.ChatID, channelIdentityID)
|
|
if checkErr != nil {
|
|
return ResolveChatResult{}, fmt.Errorf("check chat participant: %w", checkErr)
|
|
}
|
|
if !ok {
|
|
if _, err := s.AddParticipant(ctx, route.ChatID, channelIdentityID, RoleMember); err != nil {
|
|
s.logger.Warn("auto-add participant failed", slog.Any("error", err))
|
|
}
|
|
}
|
|
}
|
|
// Update reply target if changed.
|
|
if strings.TrimSpace(replyTarget) != "" && replyTarget != route.ReplyTarget {
|
|
if err := s.UpdateRouteReplyTarget(ctx, route.ID, replyTarget); err != nil && s.logger != nil {
|
|
s.logger.Warn("update route reply target failed", slog.Any("error", err))
|
|
}
|
|
}
|
|
pgRouteChatID, parseErr := parseUUID(route.ChatID)
|
|
if parseErr != nil {
|
|
return ResolveChatResult{}, fmt.Errorf("parse route chat id: %w", parseErr)
|
|
}
|
|
if err := s.queries.TouchChat(ctx, pgRouteChatID); err != nil && s.logger != nil {
|
|
s.logger.Warn("touch chat failed", slog.Any("error", err))
|
|
}
|
|
return ResolveChatResult{ChatID: route.ChatID, RouteID: route.ID, Created: false}, nil
|
|
}
|
|
|
|
// Route not found, create chat + route + participant.
|
|
kind := determineChatKind(threadID, conversationType)
|
|
creatorChannelIdentityID := s.resolveChatCreatorChannelIdentityID(ctx, botID, channelIdentityID, kind)
|
|
|
|
var parentChatID string
|
|
if kind == KindThread {
|
|
parentRoute, parentErr := s.FindRoute(ctx, botID, platform, conversationID, "")
|
|
if parentErr == nil {
|
|
parentChatID = parentRoute.ChatID
|
|
}
|
|
}
|
|
|
|
c, err := s.Create(ctx, botID, creatorChannelIdentityID, CreateRequest{
|
|
Kind: kind,
|
|
ParentChatID: parentChatID,
|
|
})
|
|
if err != nil {
|
|
return ResolveChatResult{}, fmt.Errorf("create chat: %w", err)
|
|
}
|
|
if strings.TrimSpace(channelIdentityID) != "" && strings.TrimSpace(channelIdentityID) != strings.TrimSpace(creatorChannelIdentityID) {
|
|
if _, err := s.AddParticipant(ctx, c.ID, channelIdentityID, RoleMember); err != nil {
|
|
s.logger.Warn("auto-add creator participant failed", slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
newRoute, err := s.CreateRoute(ctx, c.ID, Route{
|
|
BotID: botID,
|
|
Platform: platform,
|
|
ChannelConfigID: channelConfigID,
|
|
ConversationID: conversationID,
|
|
ThreadID: threadID,
|
|
ReplyTarget: replyTarget,
|
|
})
|
|
if err != nil {
|
|
return ResolveChatResult{}, fmt.Errorf("create route: %w", err)
|
|
}
|
|
|
|
return ResolveChatResult{ChatID: c.ID, RouteID: newRoute.ID, Created: true}, nil
|
|
}
|
|
|
|
// --- Messages ---
|
|
|
|
// PersistMessage writes a single message to bot_history_messages.
|
|
func (s *Service) PersistMessage(ctx context.Context, botID, routeID, senderChannelIdentityID, senderUserID, platform, externalMessageID, sourceReplyToMessageID, role string, content json.RawMessage, metadata map[string]any) (Message, error) {
|
|
pgBotID, err := parseUUID(botID)
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
var pgRouteID pgtype.UUID
|
|
if strings.TrimSpace(routeID) != "" {
|
|
pgRouteID, err = parseUUID(routeID)
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
}
|
|
var pgSender pgtype.UUID
|
|
if strings.TrimSpace(senderChannelIdentityID) != "" {
|
|
pgSender, err = parseUUID(senderChannelIdentityID)
|
|
if err != nil {
|
|
return Message{}, fmt.Errorf("invalid sender channel identity id: %w", err)
|
|
}
|
|
}
|
|
var pgSenderUser pgtype.UUID
|
|
if strings.TrimSpace(senderUserID) != "" {
|
|
pgSenderUser, err = parseUUID(senderUserID)
|
|
if err != nil {
|
|
return Message{}, fmt.Errorf("invalid sender user id: %w", err)
|
|
}
|
|
}
|
|
metaBytes, err := json.Marshal(nonNilMap(metadata))
|
|
if err != nil {
|
|
return Message{}, fmt.Errorf("marshal message metadata: %w", err)
|
|
}
|
|
if len(content) == 0 {
|
|
content = []byte("{}")
|
|
}
|
|
|
|
row, err := s.queries.CreateMessage(ctx, sqlc.CreateMessageParams{
|
|
BotID: pgBotID,
|
|
RouteID: pgRouteID,
|
|
SenderChannelIdentityID: pgSender,
|
|
SenderUserID: pgSenderUser,
|
|
Platform: toPgText(platform),
|
|
ExternalMessageID: toPgText(externalMessageID),
|
|
SourceReplyToMessageID: toPgText(sourceReplyToMessageID),
|
|
Role: role,
|
|
Content: content,
|
|
Metadata: metaBytes,
|
|
})
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
return toMessageFromCreate(row), nil
|
|
}
|
|
|
|
// ListMessages returns all messages for a bot.
|
|
func (s *Service) ListMessages(ctx context.Context, botID string) ([]Message, error) {
|
|
pgID, err := parseUUID(botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.queries.ListMessages(ctx, pgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toMessagesFromList(rows), nil
|
|
}
|
|
|
|
// ListMessagesSince returns bot messages since a given time.
|
|
func (s *Service) ListMessagesSince(ctx context.Context, botID string, since time.Time) ([]Message, error) {
|
|
pgID, err := parseUUID(botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.queries.ListMessagesSince(ctx, sqlc.ListMessagesSinceParams{
|
|
BotID: pgID,
|
|
CreatedAt: pgtype.Timestamptz{Time: since, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toMessagesFromSince(rows), nil
|
|
}
|
|
|
|
// ListMessagesLatest returns the latest N bot messages (most recent first).
|
|
func (s *Service) ListMessagesLatest(ctx context.Context, botID string, limit int32) ([]Message, error) {
|
|
pgID, err := parseUUID(botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.queries.ListMessagesLatest(ctx, sqlc.ListMessagesLatestParams{
|
|
BotID: pgID,
|
|
MaxCount: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toMessagesFromLatest(rows), nil
|
|
}
|
|
|
|
// DeleteMessages deletes all messages for a bot.
|
|
func (s *Service) DeleteMessages(ctx context.Context, botID string) error {
|
|
pgID, err := parseUUID(botID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.queries.DeleteMessagesByBot(ctx, pgID)
|
|
}
|
|
|
|
// --- conversion helpers ---
|
|
|
|
func toChatFromCreate(row sqlc.CreateChatRow) Chat {
|
|
return toChatFields(
|
|
row.ID,
|
|
row.BotID,
|
|
row.Kind,
|
|
row.ParentChatID,
|
|
row.Title,
|
|
row.CreatedByUserID,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func toChatFromGet(row sqlc.GetChatByIDRow) Chat {
|
|
return toChatFields(
|
|
row.ID,
|
|
row.BotID,
|
|
row.Kind,
|
|
row.ParentChatID,
|
|
row.Title,
|
|
row.CreatedByUserID,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func toChatFromThread(row sqlc.ListThreadsByParentRow) Chat {
|
|
return toChatFields(
|
|
row.ID,
|
|
row.BotID,
|
|
row.Kind,
|
|
row.ParentChatID,
|
|
row.Title,
|
|
row.CreatedByUserID,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func toChatFields(id, botID pgtype.UUID, kind string, parentChatID pgtype.UUID, title pgtype.Text, createdBy pgtype.UUID, metadata []byte, createdAt, updatedAt pgtype.Timestamptz) Chat {
|
|
return Chat{
|
|
ID: id.String(),
|
|
BotID: botID.String(),
|
|
Kind: kind,
|
|
ParentChatID: parentChatID.String(),
|
|
Title: db.TextToString(title),
|
|
CreatedBy: createdBy.String(),
|
|
Metadata: parseJSONMap(metadata),
|
|
CreatedAt: createdAt.Time,
|
|
UpdatedAt: updatedAt.Time,
|
|
}
|
|
}
|
|
|
|
func toChatListItem(row sqlc.ListVisibleChatsByBotAndUserRow) ChatListItem {
|
|
return ChatListItem{
|
|
ID: row.ID.String(),
|
|
BotID: row.BotID.String(),
|
|
Kind: row.Kind,
|
|
ParentChatID: row.ParentChatID.String(),
|
|
Title: db.TextToString(row.Title),
|
|
CreatedBy: row.CreatedByUserID.String(),
|
|
Metadata: parseJSONMap(row.Metadata),
|
|
CreatedAt: row.CreatedAt.Time,
|
|
UpdatedAt: row.UpdatedAt.Time,
|
|
AccessMode: row.AccessMode,
|
|
ParticipantRole: strings.TrimSpace(row.ParticipantRole),
|
|
LastObservedAt: pgTimePtr(row.LastObservedAt),
|
|
}
|
|
}
|
|
|
|
func toParticipantFromAdd(row sqlc.AddChatParticipantRow) Participant {
|
|
return toParticipantFields(row.ChatID, row.UserID, row.Role, row.JoinedAt)
|
|
}
|
|
|
|
func toParticipantFromGet(row sqlc.GetChatParticipantRow) Participant {
|
|
return toParticipantFields(row.ChatID, row.UserID, row.Role, row.JoinedAt)
|
|
}
|
|
|
|
func toParticipantFromList(row sqlc.ListChatParticipantsRow) Participant {
|
|
return toParticipantFields(row.ChatID, row.UserID, row.Role, row.JoinedAt)
|
|
}
|
|
|
|
func toParticipantFields(chatID, userID pgtype.UUID, role string, joinedAt pgtype.Timestamptz) Participant {
|
|
return Participant{
|
|
ChatID: chatID.String(),
|
|
UserID: userID.String(),
|
|
Role: role,
|
|
JoinedAt: joinedAt.Time,
|
|
}
|
|
}
|
|
|
|
func toSettingsFromRead(row sqlc.GetChatSettingsRow) Settings {
|
|
return Settings{
|
|
ChatID: row.ChatID.String(),
|
|
ModelID: db.TextToString(row.ModelID),
|
|
}
|
|
}
|
|
|
|
func toSettingsFromUpsert(row sqlc.UpsertChatSettingsRow) Settings {
|
|
return Settings{
|
|
ChatID: row.ChatID.String(),
|
|
ModelID: db.TextToString(row.ModelID),
|
|
}
|
|
}
|
|
|
|
func toRouteFromCreate(row sqlc.CreateChatRouteRow) Route {
|
|
return toRouteFields(
|
|
row.ID,
|
|
row.ChatID,
|
|
row.BotID,
|
|
row.Platform,
|
|
row.ChannelConfigID,
|
|
row.ConversationID,
|
|
row.ThreadID,
|
|
row.ReplyTarget,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func toRouteFromFind(row sqlc.FindChatRouteRow) Route {
|
|
return toRouteFields(
|
|
row.ID,
|
|
row.ChatID,
|
|
row.BotID,
|
|
row.Platform,
|
|
row.ChannelConfigID,
|
|
row.ConversationID,
|
|
row.ThreadID,
|
|
row.ReplyTarget,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func toRouteFromGet(row sqlc.GetChatRouteByIDRow) Route {
|
|
return toRouteFields(
|
|
row.ID,
|
|
row.ChatID,
|
|
row.BotID,
|
|
row.Platform,
|
|
row.ChannelConfigID,
|
|
row.ConversationID,
|
|
row.ThreadID,
|
|
row.ReplyTarget,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func toRouteFromList(row sqlc.ListChatRoutesRow) Route {
|
|
return toRouteFields(
|
|
row.ID,
|
|
row.ChatID,
|
|
row.BotID,
|
|
row.Platform,
|
|
row.ChannelConfigID,
|
|
row.ConversationID,
|
|
row.ThreadID,
|
|
row.ReplyTarget,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
row.UpdatedAt,
|
|
)
|
|
}
|
|
|
|
func toRouteFields(id, chatID, botID pgtype.UUID, platform string, channelConfigID pgtype.UUID, conversationID string, threadID, replyTarget pgtype.Text, metadata []byte, createdAt, updatedAt pgtype.Timestamptz) Route {
|
|
return Route{
|
|
ID: id.String(),
|
|
ChatID: chatID.String(),
|
|
BotID: botID.String(),
|
|
Platform: platform,
|
|
ChannelConfigID: channelConfigID.String(),
|
|
ConversationID: conversationID,
|
|
ThreadID: db.TextToString(threadID),
|
|
ReplyTarget: db.TextToString(replyTarget),
|
|
Metadata: parseJSONMap(metadata),
|
|
CreatedAt: createdAt.Time,
|
|
UpdatedAt: updatedAt.Time,
|
|
}
|
|
}
|
|
|
|
func toMessageFromCreate(row sqlc.CreateMessageRow) Message {
|
|
return toMessageFields(
|
|
row.ID,
|
|
row.BotID,
|
|
row.RouteID,
|
|
row.SenderChannelIdentityID,
|
|
row.SenderUserID,
|
|
row.Platform,
|
|
row.ExternalMessageID,
|
|
row.SourceReplyToMessageID,
|
|
row.Role,
|
|
row.Content,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
)
|
|
}
|
|
|
|
func toMessageFromListRow(row sqlc.ListMessagesRow) Message {
|
|
return toMessageFields(
|
|
row.ID,
|
|
row.BotID,
|
|
row.RouteID,
|
|
row.SenderChannelIdentityID,
|
|
row.SenderUserID,
|
|
row.Platform,
|
|
row.ExternalMessageID,
|
|
row.SourceReplyToMessageID,
|
|
row.Role,
|
|
row.Content,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
)
|
|
}
|
|
|
|
func toMessageFromSinceRow(row sqlc.ListMessagesSinceRow) Message {
|
|
return toMessageFields(
|
|
row.ID,
|
|
row.BotID,
|
|
row.RouteID,
|
|
row.SenderChannelIdentityID,
|
|
row.SenderUserID,
|
|
row.Platform,
|
|
row.ExternalMessageID,
|
|
row.SourceReplyToMessageID,
|
|
row.Role,
|
|
row.Content,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
)
|
|
}
|
|
|
|
func toMessageFromLatestRow(row sqlc.ListMessagesLatestRow) Message {
|
|
return toMessageFields(
|
|
row.ID,
|
|
row.BotID,
|
|
row.RouteID,
|
|
row.SenderChannelIdentityID,
|
|
row.SenderUserID,
|
|
row.Platform,
|
|
row.ExternalMessageID,
|
|
row.SourceReplyToMessageID,
|
|
row.Role,
|
|
row.Content,
|
|
row.Metadata,
|
|
row.CreatedAt,
|
|
)
|
|
}
|
|
|
|
func toMessageFields(id, botID, routeID, senderChannelIdentityID, senderUserID pgtype.UUID, platform, externalMessageID, sourceReplyToMessageID pgtype.Text, role string, content, metadata []byte, createdAt pgtype.Timestamptz) Message {
|
|
return Message{
|
|
ID: id.String(),
|
|
BotID: botID.String(),
|
|
RouteID: routeID.String(),
|
|
SenderChannelIdentityID: senderChannelIdentityID.String(),
|
|
SenderUserID: senderUserID.String(),
|
|
Platform: db.TextToString(platform),
|
|
ExternalMessageID: db.TextToString(externalMessageID),
|
|
SourceReplyToMessageID: db.TextToString(sourceReplyToMessageID),
|
|
Role: role,
|
|
Content: json.RawMessage(content),
|
|
Metadata: parseJSONMap(metadata),
|
|
CreatedAt: createdAt.Time,
|
|
}
|
|
}
|
|
|
|
func toMessagesFromList(rows []sqlc.ListMessagesRow) []Message {
|
|
msgs := make([]Message, 0, len(rows))
|
|
for _, row := range rows {
|
|
msgs = append(msgs, toMessageFromListRow(row))
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func toMessagesFromSince(rows []sqlc.ListMessagesSinceRow) []Message {
|
|
msgs := make([]Message, 0, len(rows))
|
|
for _, row := range rows {
|
|
msgs = append(msgs, toMessageFromSinceRow(row))
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func toMessagesFromLatest(rows []sqlc.ListMessagesLatestRow) []Message {
|
|
msgs := make([]Message, 0, len(rows))
|
|
for _, row := range rows {
|
|
msgs = append(msgs, toMessageFromLatestRow(row))
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func defaultSettings(chatID string) Settings {
|
|
return Settings{
|
|
ChatID: chatID,
|
|
}
|
|
}
|
|
|
|
func determineChatKind(threadID, conversationType string) string {
|
|
if strings.TrimSpace(threadID) != "" {
|
|
return KindThread
|
|
}
|
|
ct := strings.ToLower(strings.TrimSpace(conversationType))
|
|
if ct == "p2p" || ct == "private" || ct == "" {
|
|
return KindDirect
|
|
}
|
|
return KindGroup
|
|
}
|
|
|
|
func toPgText(s string) pgtype.Text {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return pgtype.Text{}
|
|
}
|
|
return pgtype.Text{String: s, Valid: true}
|
|
}
|
|
|
|
func pgTimePtr(ts pgtype.Timestamptz) *time.Time {
|
|
if !ts.Valid {
|
|
return nil
|
|
}
|
|
value := ts.Time
|
|
return &value
|
|
}
|
|
|
|
func nonNilMap(m map[string]any) map[string]any {
|
|
if m == nil {
|
|
return map[string]any{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func parseJSONMap(data []byte) map[string]any {
|
|
if len(data) == 0 {
|
|
return nil
|
|
}
|
|
var m map[string]any
|
|
_ = json.Unmarshal(data, &m)
|
|
return m
|
|
}
|
|
|
|
func (s *Service) resolveChatCreatorChannelIdentityID(ctx context.Context, botID, fallbackChannelIdentityID, kind string) string {
|
|
fallback := strings.TrimSpace(fallbackChannelIdentityID)
|
|
if kind != KindGroup || s.queries == nil {
|
|
return fallback
|
|
}
|
|
pgBotID, err := parseUUID(botID)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
row, err := s.queries.GetBotByID(ctx, pgBotID)
|
|
if err != nil {
|
|
s.logger.Warn("resolve bot owner for group chat failed", slog.Any("error", err))
|
|
return fallback
|
|
}
|
|
ownerChannelIdentityID := row.OwnerUserID.String()
|
|
if strings.TrimSpace(ownerChannelIdentityID) == "" {
|
|
return fallback
|
|
}
|
|
return ownerChannelIdentityID
|
|
}
|