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 }