Files
2026-07-13 13:00:08 +08:00

372 lines
11 KiB
Go

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
}