feat(channel/session): 渠道↔会话映射 + 主人识别(actor元数据+白名单,不读消息内容) + 群聊隔离 + 主动推送策略(iLink24h/OneBot限频)
This commit is contained in:
1 parent
3ce9489f8e
commit
7adaf490c7
7 files changed
+1133
No files matched your search
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user