156 lines
5.5 KiB
Go
156 lines
5.5 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"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.
|
|
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 = ?`
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 := time.Now().UTC().Format(time.RFC3339Nano)
|
|
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 "group:" + msg.GroupID
|
|
}
|
|
return msg.ChannelID
|
|
}
|
|
if msg.ChannelID != "" {
|
|
return msg.ChannelID
|
|
}
|
|
return msg.SenderID
|
|
}
|