package session import ( "context" "database/sql" "fmt" "log/slog" "strings" "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // DDL for the channel↔session mapping. The composite primary key over // (platform, channel_id) is the unique key the design requires: each real-world // channel maps to exactly one internal session_id. created_at/updated_at follow // the project's timestamp convention; actor_role records the identity lane so the // mapping survives restarts. // // The created_at/updated_at DEFAULT expressions are a fallback only: every write // path (insertOrIgnoreSessionSQL) supplies explicit fixed-width UTC timestamps // via formatSessionTime, so the 3-digit-millisecond strftime defaults are never // exercised and no row mixes timestamp widths. const createTableSQL = ` CREATE TABLE IF NOT EXISTS channel_sessions ( platform TEXT NOT NULL, channel_id TEXT NOT NULL, session_id TEXT NOT NULL, actor_role TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), PRIMARY KEY (platform, channel_id) )` const createIndexSQL = ` CREATE INDEX IF NOT EXISTS idx_channel_sessions_actor_role ON channel_sessions(actor_role)` const insertOrIgnoreSessionSQL = ` INSERT OR IGNORE INTO channel_sessions (platform, channel_id, session_id, actor_role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)` const selectSessionSQL = ` SELECT session_id FROM channel_sessions WHERE platform = ? AND channel_id = ?` // listSessionsSQL selects every persisted channel mapping, newest first. Group // sessions key on a channel_id carrying groupChannelPrefix; direct sessions use // their channel id. created_at is fixed-width UTC text (see sessionTimeLayout), // so its text order is its time order; rowid breaks created_at ties so mappings // written in the same coarse clock tick still list in a stable order // (channel_sessions is an ordinary rowid table, not WITHOUT ROWID). const listSessionSQL = ` SELECT platform, channel_id, session_id, actor_role FROM channel_sessions ORDER BY created_at DESC, rowid DESC` // groupChannelPrefix marks a channel id as a group conversation so a session // reconstructed from the backing table (where no IsGroup flag is persisted) can // still report group membership. const groupChannelPrefix = "group:" // SessionManager owns the channel↔session mapping. It backs the map with the // SQLite table channel_sessions so a channel keeps its internal session across // restarts, and it caches lookups in memory to avoid a query per message. The // cache is guarded by an RWMutex for concurrent adapter use. type SessionManager struct { mu sync.RWMutex db *sql.DB log *slog.Logger cache map[string]Session } // New builds a SessionManager, ensuring the channel_sessions table and its index // exist. It performs no migration bookkeeping: the schema is created here so the // module is self-contained. A nil db is rejected. ctx is used for DDL. func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*SessionManager, error) { if db == nil { return nil, ErrNilDB } log = logger.WithSource(log, "channel/session") if _, err := db.ExecContext(ctx, createTableSQL); err != nil { return nil, fmt.Errorf("session: ensure channel_sessions: %w", err) } if _, err := db.ExecContext(ctx, createIndexSQL); err != nil { return nil, fmt.Errorf("session: ensure channel_sessions index: %w", err) } return &SessionManager{db: db, log: log, cache: make(map[string]Session)}, nil } // GetOrCreate resolves the actor for msg and returns the Session bound to the // (platform, channel) the message represents, creating the mapping on first // sight. The owner whitelist is applied here (ResolveActor), so the owner flag // is set structurally rather than from prompt text. Concurrent calls for the // same channel return the same session. func (m *SessionManager) GetOrCreate(ctx context.Context, msg adapter.UnifiedMessage, owner *user.OwnerProfile) (Session, error) { actor := ResolveActor(msg, owner) cid := channelID(msg) if cid == "" { return Session{}, fmt.Errorf("session: get-or-create %s: %w", msg.Platform, ErrInvalidChannel) } key := msg.Platform + "\x00" + cid if s, ok := m.lookupCached(key); ok { return s, nil } m.mu.Lock() defer m.mu.Unlock() if s, ok := m.cache[key]; ok { return s, nil } sessionID, err := m.persist(ctx, msg.Platform, cid, actor) if err != nil { return Session{}, err } s := Session{ Platform: msg.Platform, ChannelID: cid, SessionID: sessionID, ActorRole: actor.Role, Owner: actor.IsOwner(), IsGroup: actor.IsGroupChat(), MemWritable: actor.IsOwner() && !actor.IsGroupChat(), } m.cache[key] = s return s, nil } // List returns every known session, newest first, across the backing table and // the in-memory cache. Rows that are still in the cache are returned with their // full derived metadata (Owner/IsGroup/MemWritable from the original resolve); // rows recovered from the table (for example from a previous process) are // reconstructed from the persisted actor role. The returned slice is non-nil. It // is safe for concurrent use. func (m *SessionManager) List(ctx context.Context) ([]Session, error) { rows, err := m.db.QueryContext(ctx, listSessionSQL) if err != nil { return nil, fmt.Errorf("session: list: %w", err) } defer func() { _ = rows.Close() }() sessions := make([]Session, 0) for rows.Next() { var platform, cid, sessionID, roleStr string if err := rows.Scan(&platform, &cid, &sessionID, &roleStr); err != nil { return nil, fmt.Errorf("session: scan list: %w", err) } key := platform + "\x00" + cid if cached, ok := m.lookupCached(key); ok { sessions = append(sessions, cached) continue } role := adapter.Role(roleStr) owner := role == adapter.RoleOwner isGroup := strings.HasPrefix(cid, groupChannelPrefix) sessions = append(sessions, Session{ Platform: platform, ChannelID: cid, SessionID: sessionID, ActorRole: role, Owner: owner, IsGroup: isGroup, MemWritable: owner && !isGroup, }) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("session: iterate list: %w", err) } return sessions, nil } // lookupCached returns the cached session for key under a read lock. func (m *SessionManager) lookupCached(key string) (Session, bool) { m.mu.RLock() defer m.mu.RUnlock() s, ok := m.cache[key] return s, ok } // persist writes the channel mapping if it does not exist yet and reads back the // canonical session_id. INSERT OR IGNORE makes it safe under concurrency: the // winning writer's session_id becomes the binding. It is called under the write // lock so the in-memory cache and the table stay in agreement. func (m *SessionManager) persist(ctx context.Context, platform, cid string, actor adapter.Actor) (string, error) { now := formatSessionTime(time.Now()) if _, err := m.db.ExecContext(ctx, insertOrIgnoreSessionSQL, platform, cid, newSessionID(), string(actor.Role), now, now); err != nil { return "", fmt.Errorf("session: persist %s/%s: %w", platform, cid, err) } var sessionID string if err := m.db.QueryRowContext(ctx, selectSessionSQL, platform, cid).Scan(&sessionID); err != nil { return "", fmt.Errorf("session: read back %s/%s: %w", platform, cid, err) } if m.log != nil { m.log.Debug("channel session bound", "platform", platform, "channel_id", cid, "session_id", sessionID, "actor_role", actor.Role) } return sessionID, nil } // channelID derives the stable channel key used for session mapping. Group // messages key on the group (prefixed so a group never collides with a direct // channel of the same id); direct messages key on their channel id, falling back // to the sender id when no explicit channel id is present. func channelID(msg adapter.UnifiedMessage) string { if msg.IsGroup() { if msg.GroupID != "" { return groupChannelPrefix + msg.GroupID } return msg.ChannelID } if msg.ChannelID != "" { return msg.ChannelID } return msg.SenderID }