package bot import ( "crypto/rand" "encoding/json" "errors" "fmt" "math/big" "os" "path/filepath" "strings" "sync" "time" "reasonix/internal/config" "reasonix/internal/fileutil" fileencoding "reasonix/internal/fileutil/encoding" ) const ( defaultPairingTTL = time.Hour defaultPairingMaxPending = 3 pairingAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" ) type PairingConfig struct { Enabled bool RequestTTL time.Duration MaxPendingPerPlatform int } type PairingRequest struct { Code string `json:"code"` Platform Platform `json:"platform"` ConnectionID string `json:"connection_id,omitempty"` Domain string `json:"domain,omitempty"` ChatType ChatType `json:"chat_type"` ChatID string `json:"chat_id"` UserID string `json:"user_id"` UserName string `json:"user_name,omitempty"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` } type pairingFile struct { Requests []PairingRequest `json:"requests"` } // pairingMu serializes every load-modify-save of the pairing store, so // concurrent adapter dispatch goroutines (offerPairing) and CLI approve/reject // can't interleave RMWs and drop each other's requests. It must not be held // across user-config edits (see ApprovePairingCode). var pairingMu sync.Mutex func NormalizePairingConfig(cfg PairingConfig) PairingConfig { if cfg.RequestTTL <= 0 { cfg.RequestTTL = defaultPairingTTL } if cfg.MaxPendingPerPlatform <= 0 { cfg.MaxPendingPerPlatform = defaultPairingMaxPending } return cfg } func PairingStorePath() string { dir := config.MemoryUserDir() if strings.TrimSpace(dir) == "" { return "" } return filepath.Join(dir, "bot", "pairing.json") } func CreateOrRefreshPairingRequest(msg InboundMessage, cfg PairingConfig) (PairingRequest, bool, error) { cfg = NormalizePairingConfig(cfg) if !cfg.Enabled { return PairingRequest{}, false, errors.New("bot pairing is disabled") } if msg.ChatType != ChatDM && msg.ChatType != ChatDirect { return PairingRequest{}, false, errors.New("bot pairing only supports direct messages") } if strings.TrimSpace(msg.UserID) == "" || strings.TrimSpace(msg.ChatID) == "" { return PairingRequest{}, false, errors.New("bot pairing needs user_id and chat_id") } path := PairingStorePath() if path == "" { return PairingRequest{}, false, errors.New("reasonix user state directory is unavailable") } pairingMu.Lock() defer pairingMu.Unlock() store, err := loadPairingFile(path) if err != nil { return PairingRequest{}, false, err } now := time.Now().UTC() store.Requests = pruneExpiredPairingRequests(store.Requests, now) for _, req := range store.Requests { if pairingRequestMatches(req, msg) { return req, false, savePairingFile(path, store) } } pendingForPlatform := 0 for _, req := range store.Requests { if req.Platform == msg.Platform && strings.TrimSpace(req.ConnectionID) == strings.TrimSpace(msg.ConnectionID) { pendingForPlatform++ } } if pendingForPlatform >= cfg.MaxPendingPerPlatform { return PairingRequest{}, false, fmt.Errorf("too many pending pairing requests for %s", msg.Platform) } code, err := newPairingCode() if err != nil { return PairingRequest{}, false, err } req := PairingRequest{ Code: code, Platform: msg.Platform, ConnectionID: strings.TrimSpace(msg.ConnectionID), Domain: strings.TrimSpace(msg.Domain), ChatType: msg.ChatType, ChatID: strings.TrimSpace(msg.ChatID), UserID: strings.TrimSpace(msg.UserID), UserName: strings.TrimSpace(msg.UserName), CreatedAt: now, ExpiresAt: now.Add(cfg.RequestTTL), } store.Requests = append(store.Requests, req) return req, true, savePairingFile(path, store) } func ListPairingRequests() ([]PairingRequest, error) { path := PairingStorePath() if path == "" { return nil, errors.New("reasonix user state directory is unavailable") } pairingMu.Lock() defer pairingMu.Unlock() store, err := loadPairingFile(path) if err != nil { return nil, err } now := time.Now().UTC() next := pruneExpiredPairingRequests(store.Requests, now) if len(next) != len(store.Requests) { store.Requests = next if err := savePairingFile(path, store); err != nil { return nil, err } } return next, nil } func ApprovePairingCode(code string) (PairingRequest, error) { req, err := removePairingCode(code) if err != nil { return PairingRequest{}, err } userPath := config.UserConfigPath() if userPath == "" { return PairingRequest{}, errors.New("reasonix user config path is unavailable") } unlock := config.LockUserConfigEdits() defer unlock() cfg := config.LoadForEdit(userPath) if approvePairingForConnectionAccess(&cfg.Bot, req) { if err := cfg.SaveTo(userPath); err != nil { return PairingRequest{}, err } return req, nil } cfg.Bot.Allowlist.Enabled = true switch req.Platform { case PlatformQQ: cfg.Bot.Allowlist.QQUsers, _ = appendUnique(cfg.Bot.Allowlist.QQUsers, req.UserID) case PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers, _ = appendUnique(cfg.Bot.Allowlist.FeishuUsers, req.UserID) case PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers, _ = appendUnique(cfg.Bot.Allowlist.WeixinUsers, req.UserID) } if allowlistAdminCount(cfg.Bot.Allowlist) == 0 { switch req.Platform { case PlatformQQ: cfg.Bot.Allowlist.QQAdmins, _ = appendUnique(cfg.Bot.Allowlist.QQAdmins, req.UserID) cfg.Bot.Allowlist.QQApprovers, _ = appendUnique(cfg.Bot.Allowlist.QQApprovers, req.UserID) case PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins, _ = appendUnique(cfg.Bot.Allowlist.FeishuAdmins, req.UserID) cfg.Bot.Allowlist.FeishuApprovers, _ = appendUnique(cfg.Bot.Allowlist.FeishuApprovers, req.UserID) case PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins, _ = appendUnique(cfg.Bot.Allowlist.WeixinAdmins, req.UserID) cfg.Bot.Allowlist.WeixinApprovers, _ = appendUnique(cfg.Bot.Allowlist.WeixinApprovers, req.UserID) } } if err := cfg.SaveTo(userPath); err != nil { return PairingRequest{}, err } return req, nil } func approvePairingForConnectionAccess(botCfg *config.BotConfig, req PairingRequest) bool { if botCfg == nil { return false } connectionID := strings.TrimSpace(req.ConnectionID) if connectionID != "" { for i := range botCfg.Connections { if pairingConnectionMatches(botCfg.Connections[i], connectionID) { approvePairingAccess(&botCfg.Connections[i].Access, req.UserID) return true } } } if req.Platform == PlatformQQ && (connectionID == "" || connectionID == string(PlatformQQ)) { approvePairingAccess(&botCfg.QQ.Access, req.UserID) return true } return false } func approvePairingAccess(access *config.BotAccessConfig, userID string) { if access == nil { return } wasEmpty := !access.AllowAll && len(access.Users) == 0 && len(access.Groups) == 0 && len(access.Approvers) == 0 && len(access.Admins) == 0 access.Enabled = true access.Users, _ = appendUnique(access.Users, userID) if wasEmpty { access.Admins, _ = appendUnique(access.Admins, userID) access.Approvers, _ = appendUnique(access.Approvers, userID) } } func pairingConnectionMatches(conn config.BotConnectionConfig, connectionID string) bool { connectionID = strings.TrimSpace(connectionID) if connectionID == "" { return false } if strings.TrimSpace(conn.ID) == connectionID { return true } return pairingConnectionRuntimeID(conn) == connectionID } func pairingConnectionRuntimeID(conn config.BotConnectionConfig) string { if id := strings.TrimSpace(conn.ID); id != "" { return id } provider := strings.TrimSpace(conn.Provider) domain := strings.TrimSpace(conn.Domain) if provider == "" { return "" } if domain == "" { return provider } return provider + "-" + domain } func RejectPairingCode(code string) (PairingRequest, error) { return removePairingCode(code) } func removePairingCode(code string) (PairingRequest, error) { code = strings.ToUpper(strings.TrimSpace(code)) if code == "" { return PairingRequest{}, errors.New("pairing code is required") } path := PairingStorePath() if path == "" { return PairingRequest{}, errors.New("reasonix user state directory is unavailable") } pairingMu.Lock() defer pairingMu.Unlock() store, err := loadPairingFile(path) if err != nil { return PairingRequest{}, err } now := time.Now().UTC() next := pruneExpiredPairingRequests(store.Requests, now) var found PairingRequest kept := next[:0] for _, req := range next { if strings.EqualFold(req.Code, code) { found = req continue } kept = append(kept, req) } if found.Code == "" { return PairingRequest{}, fmt.Errorf("pairing code %s not found or expired", code) } store.Requests = kept return found, savePairingFile(path, store) } func loadPairingFile(path string) (pairingFile, error) { var store pairingFile data, err := fileencoding.ReadFileUTF8(path) if errors.Is(err, os.ErrNotExist) { return store, nil } if err != nil { return store, err } if len(strings.TrimSpace(string(data))) == 0 { return store, nil } if err := json.Unmarshal(data, &store); err != nil { return pairingFile{}, err } return store, nil } // savePairingFile persists the store via tmpfile+rename so a crash or a // concurrent reader never observes a truncated pairing.json. func savePairingFile(path string, store pairingFile) error { data, err := json.MarshalIndent(store, "", " ") if err != nil { return err } return fileutil.AtomicWriteFile(path, append(data, '\n'), 0o600) } func pruneExpiredPairingRequests(reqs []PairingRequest, now time.Time) []PairingRequest { out := reqs[:0] for _, req := range reqs { if req.ExpiresAt.IsZero() || now.Before(req.ExpiresAt) { out = append(out, req) } } return out } func pairingRequestMatches(req PairingRequest, msg InboundMessage) bool { return req.Platform == msg.Platform && strings.TrimSpace(req.ConnectionID) == strings.TrimSpace(msg.ConnectionID) && strings.TrimSpace(req.ChatID) == strings.TrimSpace(msg.ChatID) && strings.TrimSpace(req.UserID) == strings.TrimSpace(msg.UserID) } func newPairingCode() (string, error) { var b strings.Builder max := big.NewInt(int64(len(pairingAlphabet))) for i := 0; i < 8; i++ { n, err := rand.Int(rand.Reader, max) if err != nil { return "", err } b.WriteByte(pairingAlphabet[n.Int64()]) } return b.String(), nil } func allowlistAdminCount(a config.BotAllowlist) int { return len(a.QQAdmins) + len(a.FeishuAdmins) + len(a.WeixinAdmins) } func appendUnique(values []string, next string) ([]string, bool) { next = strings.TrimSpace(next) if next == "" { return values, false } for _, value := range values { if strings.TrimSpace(value) == next { return values, false } } return append(values, next), true }