Files
goclaw/internal/store/pending_message_store.go
minhdang03 1d68a21f65 fix(channels): archive group messages before the pending buffer drops them (#1514)
channel_pending_messages is a buffer, not an archive. Rows were deleted
outright on two paths: the bot being mentioned hands the buffer to the
agent and clears the key, and LLM compaction replaces old rows with a
summary. For group capture that buffer held the only copy of the raw
text, so a single mention or compaction pass destroyed days of messages
nothing had read yet.

Copy every row into channel_message_archive inside the same transaction
as the delete, tagged with the reason (consumed, compacted, stale).
Archived rows keep their original id, so a replayed delete is a no-op.
Add ListArchivedByKey so consumers that need full history read the
archive instead of the buffer.
2026-08-15 23:48:15 +07:00

94 lines
4.4 KiB
Go

package store
import (
"context"
"time"
"github.com/google/uuid"
)
// PendingMessage represents a buffered group chat message (or LLM-generated summary)
// stored in channel_pending_messages table.
type PendingMessage struct {
ID uuid.UUID `json:"id" db:"id"`
ChannelName string `json:"channel_name" db:"channel_name"`
HistoryKey string `json:"history_key" db:"history_key"`
ParentHistoryKey string `json:"parent_history_key,omitempty" db:"parent_history_key"`
Sender string `json:"sender" db:"sender"`
SenderID string `json:"sender_id" db:"sender_id"`
Body string `json:"body" db:"body"`
PlatformMsgID string `json:"platform_msg_id" db:"platform_msg_id"`
IsSummary bool `json:"is_summary" db:"is_summary"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// Archive reasons recorded on channel_message_archive rows.
const (
// ArchiveReasonConsumed marks messages dropped because the buffer was handed
// to the agent (bot mentioned) or cleared by an operator.
ArchiveReasonConsumed = "consumed"
// ArchiveReasonCompacted marks messages replaced by an LLM summary row.
ArchiveReasonCompacted = "compacted"
// ArchiveReasonStale marks messages dropped by TTL cleanup.
ArchiveReasonStale = "stale"
)
// ArchivedMessage is a pending message preserved after it left the live buffer.
type ArchivedMessage struct {
PendingMessage
ArchivedAt time.Time `json:"archived_at" db:"archived_at"`
ArchiveReason string `json:"archive_reason" db:"archive_reason"`
}
// PendingMessageGroup is a summary row for the grouped overview page.
type PendingMessageGroup struct {
ChannelName string `json:"channel_name" db:"channel_name"`
HistoryKey string `json:"history_key" db:"history_key"`
ParentHistoryKey string `json:"parent_history_key,omitempty" db:"parent_history_key"`
GroupTitle string `json:"group_title,omitempty" db:"group_title"`
ParentGroupTitle string `json:"parent_group_title,omitempty" db:"-"`
MessageCount int `json:"message_count" db:"message_count"`
HasSummary bool `json:"has_summary" db:"has_summary"`
LastActivity time.Time `json:"last_activity" db:"last_activity"`
}
// PendingMessageStore persists group chat messages for context when bot is mentioned.
type PendingMessageStore interface {
// AppendBatch inserts multiple pending messages in a single query.
AppendBatch(ctx context.Context, msgs []PendingMessage) error
// ListByKey returns all pending messages for a channel+historyKey, ordered by created_at ASC.
ListByKey(ctx context.Context, channelName, historyKey string) ([]PendingMessage, error)
// DeleteByKey removes all pending messages for a channel+historyKey.
// Removed rows are copied to the archive first.
DeleteByKey(ctx context.Context, channelName, historyKey string) error
// Compact atomically deletes old messages (by IDs) and inserts a summary row.
// Deleted rows are copied to the archive in the same transaction.
Compact(ctx context.Context, deleteIDs []uuid.UUID, summary *PendingMessage) error
// DeleteStale removes messages older than the given duration for inactive groups.
// Removed rows are copied to the archive first.
DeleteStale(ctx context.Context, olderThan time.Duration) (int64, error)
// ListArchivedByKey returns archived messages for a channel+historyKey created at
// or after `since`, ordered by created_at ASC. A zero `since` returns everything;
// limit <= 0 means no limit.
ListArchivedByKey(ctx context.Context, channelName, historyKey string, since time.Time, limit int) ([]ArchivedMessage, error)
// ListGroups returns all distinct channel+historyKey groups with message counts.
ListGroups(ctx context.Context) ([]PendingMessageGroup, error)
// CountAll returns the total number of pending messages across all groups.
CountAll(ctx context.Context) (int64, error)
// CountByKey returns the number of pending messages for a specific channel+historyKey.
CountByKey(ctx context.Context, channelName, historyKey string) (int, error)
// ResolveGroupTitles looks up chat_title from session metadata for each group.
// Returns a map of "channel_name:history_key" → title. Used only by the UI layer.
ResolveGroupTitles(ctx context.Context, groups []PendingMessageGroup) (map[string]string, error)
}