managarten/services/mana-matrix-bot/internal/session/redis.go
Till JS 819568c3df feat(infra): consolidate 21 Matrix bots into Go binary + add Go API gateway
Replace 21 separate NestJS Matrix bot processes (~2.1 GB RAM, ~4.2 GB Docker images)
with a single Go binary using plugin architecture (8.6 MB binary, ~30 MB RAM).

New services:
- services/mana-matrix-bot/ — Go Matrix bot with 21 plugins (mautrix-go, Redis sessions)
- services/mana-api-gateway-go/ — Go API gateway (rate limiting, API keys, credit billing)

Deleted:
- 21 services/matrix-*-bot/ directories
- packages/bot-services/ and packages/matrix-bot-common/
- Legacy deploy scripts and CI build jobs

Updated:
- docker-compose.macmini.yml: new Go services, legacy bots removed
- CI/CD: change detection + build jobs for Go services
- Root package.json: new dev:matrix, build:matrix, test:matrix scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:03:00 +01:00

174 lines
4.2 KiB
Go

package session
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
)
// RedisStore is a Redis-backed session manager.
// Sessions are shared across all plugins and persist across restarts.
type RedisStore struct {
client *redis.Client
prefix string
ttl time.Duration
}
// RedisConfig holds configuration for the Redis session store.
type RedisConfig struct {
Host string
Port int
Password string
Prefix string // key prefix (default: "mana-bot:session:")
TTL time.Duration // session TTL (default: 24h)
}
// NewRedisStore creates a new Redis session store.
func NewRedisStore(cfg RedisConfig) (*RedisStore, error) {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: 0,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("redis ping: %w", err)
}
prefix := cfg.Prefix
if prefix == "" {
prefix = "mana-bot:session:"
}
ttl := cfg.TTL
if ttl == 0 {
ttl = 24 * time.Hour
}
slog.Info("redis session store connected", "addr", fmt.Sprintf("%s:%d", cfg.Host, cfg.Port))
return &RedisStore{
client: client,
prefix: prefix,
ttl: ttl,
}, nil
}
// key builds the Redis key for a user's session data.
func (s *RedisStore) key(userID string) string {
return s.prefix + userID
}
// tokenKey builds the Redis key for a user's auth token.
func (s *RedisStore) tokenKey(userID string) string {
return s.prefix + "token:" + userID
}
// Get retrieves a session value.
func (s *RedisStore) Get(userID, key string) (any, bool) {
ctx := context.Background()
val, err := s.client.HGet(ctx, s.key(userID), key).Result()
if err != nil {
return nil, false
}
// Try to unmarshal as JSON first
var result any
if err := json.Unmarshal([]byte(val), &result); err == nil {
return result, true
}
return val, true
}
// Set stores a session value.
func (s *RedisStore) Set(userID, key string, value any) {
ctx := context.Background()
data, err := json.Marshal(value)
if err != nil {
slog.Error("redis session set: marshal failed", "error", err)
return
}
pipe := s.client.Pipeline()
pipe.HSet(ctx, s.key(userID), key, string(data))
pipe.Expire(ctx, s.key(userID), s.ttl)
if _, err := pipe.Exec(ctx); err != nil {
slog.Error("redis session set failed", "error", err)
}
}
// Delete removes a session value.
func (s *RedisStore) Delete(userID, key string) {
ctx := context.Background()
if err := s.client.HDel(ctx, s.key(userID), key).Err(); err != nil {
slog.Error("redis session delete failed", "error", err)
}
}
// GetToken returns the stored auth token for a user.
func (s *RedisStore) GetToken(userID string) (string, bool) {
ctx := context.Background()
// Get token and expiry from hash
vals, err := s.client.HMGet(ctx, s.tokenKey(userID), "token", "expires_at").Result()
if err != nil || len(vals) < 2 || vals[0] == nil {
return "", false
}
token, ok := vals[0].(string)
if !ok || token == "" {
return "", false
}
// Check expiry
if expiresStr, ok := vals[1].(string); ok && expiresStr != "" {
expiresAt, err := time.Parse(time.RFC3339, expiresStr)
if err == nil && time.Now().After(expiresAt) {
// Token expired, clean up
s.client.Del(ctx, s.tokenKey(userID))
return "", false
}
}
return token, true
}
// SetToken stores an auth token with expiration.
func (s *RedisStore) SetToken(userID, token string, expiresAt time.Time) {
ctx := context.Background()
pipe := s.client.Pipeline()
pipe.HSet(ctx, s.tokenKey(userID), map[string]any{
"token": token,
"expires_at": expiresAt.Format(time.RFC3339),
})
// Set Redis TTL to match token expiry (plus buffer)
ttl := time.Until(expiresAt) + 1*time.Hour
if ttl < s.ttl {
ttl = s.ttl
}
pipe.Expire(ctx, s.tokenKey(userID), ttl)
if _, err := pipe.Exec(ctx); err != nil {
slog.Error("redis set token failed", "error", err)
}
}
// IsLoggedIn checks if a user has a valid token.
func (s *RedisStore) IsLoggedIn(userID string) bool {
_, ok := s.GetToken(userID)
return ok
}
// Close closes the Redis connection.
func (s *RedisStore) Close() error {
return s.client.Close()
}