返回 DeepSeek-Reasonix
pairing.go
根目录 / internal / bot / pairing.go
1 package bot
2
3 import (
4 "crypto/rand"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "math/big"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "time"
14
15 "reasonix/internal/config"
16 "reasonix/internal/fileutil"
17 fileencoding "reasonix/internal/fileutil/encoding"
18 )
19
20 const (
21 defaultPairingTTL = time.Hour
22 defaultPairingMaxPending = 3
23 pairingAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
24 )
25
26 type PairingConfig struct {
27 Enabled bool
28 RequestTTL time.Duration
29 MaxPendingPerPlatform int
30 }
31
32 type PairingRequest struct {
33 Code string `json:"code"`
34 Platform Platform `json:"platform"`
35 ConnectionID string `json:"connection_id,omitempty"`
36 Domain string `json:"domain,omitempty"`
37 ChatType ChatType `json:"chat_type"`
38 ChatID string `json:"chat_id"`
39 UserID string `json:"user_id"`
40 UserName string `json:"user_name,omitempty"`
41 CreatedAt time.Time `json:"created_at"`
42 ExpiresAt time.Time `json:"expires_at"`
43 }
44
45 type pairingFile struct {
46 Requests []PairingRequest `json:"requests"`
47 }
48
49 // pairingMu serializes every load-modify-save of the pairing store, so
50 // concurrent adapter dispatch goroutines (offerPairing) and CLI approve/reject
51 // can't interleave RMWs and drop each other's requests. It must not be held
52 // across user-config edits (see ApprovePairingCode).
53 var pairingMu sync.Mutex
54
55 func NormalizePairingConfig(cfg PairingConfig) PairingConfig {
56 if cfg.RequestTTL <= 0 {
57 cfg.RequestTTL = defaultPairingTTL
58 }
59 if cfg.MaxPendingPerPlatform <= 0 {
60 cfg.MaxPendingPerPlatform = defaultPairingMaxPending
61 }
62 return cfg
63 }
64
65 func PairingStorePath() string {
66 dir := config.MemoryUserDir()
67 if strings.TrimSpace(dir) == "" {
68 return ""
69 }
70 return filepath.Join(dir, "bot", "pairing.json")
71 }
72
73 func CreateOrRefreshPairingRequest(msg InboundMessage, cfg PairingConfig) (PairingRequest, bool, error) {
74 cfg = NormalizePairingConfig(cfg)
75 if !cfg.Enabled {
76 return PairingRequest{}, false, errors.New("bot pairing is disabled")
77 }
78 if msg.ChatType != ChatDM && msg.ChatType != ChatDirect {
79 return PairingRequest{}, false, errors.New("bot pairing only supports direct messages")
80 }
81 if strings.TrimSpace(msg.UserID) == "" || strings.TrimSpace(msg.ChatID) == "" {
82 return PairingRequest{}, false, errors.New("bot pairing needs user_id and chat_id")
83 }
84 path := PairingStorePath()
85 if path == "" {
86 return PairingRequest{}, false, errors.New("reasonix user state directory is unavailable")
87 }
88 pairingMu.Lock()
89 defer pairingMu.Unlock()
90 store, err := loadPairingFile(path)
91 if err != nil {
92 return PairingRequest{}, false, err
93 }
94 now := time.Now().UTC()
95 store.Requests = pruneExpiredPairingRequests(store.Requests, now)
96 for _, req := range store.Requests {
97 if pairingRequestMatches(req, msg) {
98 return req, false, savePairingFile(path, store)
99 }
100 }
101 pendingForPlatform := 0
102 for _, req := range store.Requests {
103 if req.Platform == msg.Platform && strings.TrimSpace(req.ConnectionID) == strings.TrimSpace(msg.ConnectionID) {
104 pendingForPlatform++
105 }
106 }
107 if pendingForPlatform >= cfg.MaxPendingPerPlatform {
108 return PairingRequest{}, false, fmt.Errorf("too many pending pairing requests for %s", msg.Platform)
109 }
110 code, err := newPairingCode()
111 if err != nil {
112 return PairingRequest{}, false, err
113 }
114 req := PairingRequest{
115 Code: code,
116 Platform: msg.Platform,
117 ConnectionID: strings.TrimSpace(msg.ConnectionID),
118 Domain: strings.TrimSpace(msg.Domain),
119 ChatType: msg.ChatType,
120 ChatID: strings.TrimSpace(msg.ChatID),
121 UserID: strings.TrimSpace(msg.UserID),
122 UserName: strings.TrimSpace(msg.UserName),
123 CreatedAt: now,
124 ExpiresAt: now.Add(cfg.RequestTTL),
125 }
126 store.Requests = append(store.Requests, req)
127 return req, true, savePairingFile(path, store)
128 }
129
130 func ListPairingRequests() ([]PairingRequest, error) {
131 path := PairingStorePath()
132 if path == "" {
133 return nil, errors.New("reasonix user state directory is unavailable")
134 }
135 pairingMu.Lock()
136 defer pairingMu.Unlock()
137 store, err := loadPairingFile(path)
138 if err != nil {
139 return nil, err
140 }
141 now := time.Now().UTC()
142 next := pruneExpiredPairingRequests(store.Requests, now)
143 if len(next) != len(store.Requests) {
144 store.Requests = next
145 if err := savePairingFile(path, store); err != nil {
146 return nil, err
147 }
148 }
149 return next, nil
150 }
151
152 func ApprovePairingCode(code string) (PairingRequest, error) {
153 req, err := removePairingCode(code)
154 if err != nil {
155 return PairingRequest{}, err
156 }
157 userPath := config.UserConfigPath()
158 if userPath == "" {
159 return PairingRequest{}, errors.New("reasonix user config path is unavailable")
160 }
161 unlock := config.LockUserConfigEdits()
162 defer unlock()
163 cfg := config.LoadForEdit(userPath)
164 if approvePairingForConnectionAccess(&cfg.Bot, req) {
165 if err := cfg.SaveTo(userPath); err != nil {
166 return PairingRequest{}, err
167 }
168 return req, nil
169 }
170 cfg.Bot.Allowlist.Enabled = true
171 switch req.Platform {
172 case PlatformQQ:
173 cfg.Bot.Allowlist.QQUsers, _ = appendUnique(cfg.Bot.Allowlist.QQUsers, req.UserID)
174 case PlatformFeishu:
175 cfg.Bot.Allowlist.FeishuUsers, _ = appendUnique(cfg.Bot.Allowlist.FeishuUsers, req.UserID)
176 case PlatformWeixin:
177 cfg.Bot.Allowlist.WeixinUsers, _ = appendUnique(cfg.Bot.Allowlist.WeixinUsers, req.UserID)
178 }
179 if allowlistAdminCount(cfg.Bot.Allowlist) == 0 {
180 switch req.Platform {
181 case PlatformQQ:
182 cfg.Bot.Allowlist.QQAdmins, _ = appendUnique(cfg.Bot.Allowlist.QQAdmins, req.UserID)
183 cfg.Bot.Allowlist.QQApprovers, _ = appendUnique(cfg.Bot.Allowlist.QQApprovers, req.UserID)
184 case PlatformFeishu:
185 cfg.Bot.Allowlist.FeishuAdmins, _ = appendUnique(cfg.Bot.Allowlist.FeishuAdmins, req.UserID)
186 cfg.Bot.Allowlist.FeishuApprovers, _ = appendUnique(cfg.Bot.Allowlist.FeishuApprovers, req.UserID)
187 case PlatformWeixin:
188 cfg.Bot.Allowlist.WeixinAdmins, _ = appendUnique(cfg.Bot.Allowlist.WeixinAdmins, req.UserID)
189 cfg.Bot.Allowlist.WeixinApprovers, _ = appendUnique(cfg.Bot.Allowlist.WeixinApprovers, req.UserID)
190 }
191 }
192 if err := cfg.SaveTo(userPath); err != nil {
193 return PairingRequest{}, err
194 }
195 return req, nil
196 }
197
198 func approvePairingForConnectionAccess(botCfg *config.BotConfig, req PairingRequest) bool {
199 if botCfg == nil {
200 return false
201 }
202 connectionID := strings.TrimSpace(req.ConnectionID)
203 if connectionID != "" {
204 for i := range botCfg.Connections {
205 if pairingConnectionMatches(botCfg.Connections[i], connectionID) {
206 approvePairingAccess(&botCfg.Connections[i].Access, req.UserID)
207 return true
208 }
209 }
210 }
211 if req.Platform == PlatformQQ && (connectionID == "" || connectionID == string(PlatformQQ)) {
212 approvePairingAccess(&botCfg.QQ.Access, req.UserID)
213 return true
214 }
215 return false
216 }
217
218 func approvePairingAccess(access *config.BotAccessConfig, userID string) {
219 if access == nil {
220 return
221 }
222 wasEmpty := !access.AllowAll &&
223 len(access.Users) == 0 &&
224 len(access.Groups) == 0 &&
225 len(access.Approvers) == 0 &&
226 len(access.Admins) == 0
227 access.Enabled = true
228 access.Users, _ = appendUnique(access.Users, userID)
229 if wasEmpty {
230 access.Admins, _ = appendUnique(access.Admins, userID)
231 access.Approvers, _ = appendUnique(access.Approvers, userID)
232 }
233 }
234
235 func pairingConnectionMatches(conn config.BotConnectionConfig, connectionID string) bool {
236 connectionID = strings.TrimSpace(connectionID)
237 if connectionID == "" {
238 return false
239 }
240 if strings.TrimSpace(conn.ID) == connectionID {
241 return true
242 }
243 return pairingConnectionRuntimeID(conn) == connectionID
244 }
245
246 func pairingConnectionRuntimeID(conn config.BotConnectionConfig) string {
247 if id := strings.TrimSpace(conn.ID); id != "" {
248 return id
249 }
250 provider := strings.TrimSpace(conn.Provider)
251 domain := strings.TrimSpace(conn.Domain)
252 if provider == "" {
253 return ""
254 }
255 if domain == "" {
256 return provider
257 }
258 return provider + "-" + domain
259 }
260
261 func RejectPairingCode(code string) (PairingRequest, error) {
262 return removePairingCode(code)
263 }
264
265 func removePairingCode(code string) (PairingRequest, error) {
266 code = strings.ToUpper(strings.TrimSpace(code))
267 if code == "" {
268 return PairingRequest{}, errors.New("pairing code is required")
269 }
270 path := PairingStorePath()
271 if path == "" {
272 return PairingRequest{}, errors.New("reasonix user state directory is unavailable")
273 }
274 pairingMu.Lock()
275 defer pairingMu.Unlock()
276 store, err := loadPairingFile(path)
277 if err != nil {
278 return PairingRequest{}, err
279 }
280 now := time.Now().UTC()
281 next := pruneExpiredPairingRequests(store.Requests, now)
282 var found PairingRequest
283 kept := next[:0]
284 for _, req := range next {
285 if strings.EqualFold(req.Code, code) {
286 found = req
287 continue
288 }
289 kept = append(kept, req)
290 }
291 if found.Code == "" {
292 return PairingRequest{}, fmt.Errorf("pairing code %s not found or expired", code)
293 }
294 store.Requests = kept
295 return found, savePairingFile(path, store)
296 }
297
298 func loadPairingFile(path string) (pairingFile, error) {
299 var store pairingFile
300 data, err := fileencoding.ReadFileUTF8(path)
301 if errors.Is(err, os.ErrNotExist) {
302 return store, nil
303 }
304 if err != nil {
305 return store, err
306 }
307 if len(strings.TrimSpace(string(data))) == 0 {
308 return store, nil
309 }
310 if err := json.Unmarshal(data, &store); err != nil {
311 return pairingFile{}, err
312 }
313 return store, nil
314 }
315
316 // savePairingFile persists the store via tmpfile+rename so a crash or a
317 // concurrent reader never observes a truncated pairing.json.
318 func savePairingFile(path string, store pairingFile) error {
319 data, err := json.MarshalIndent(store, "", " ")
320 if err != nil {
321 return err
322 }
323 return fileutil.AtomicWriteFile(path, append(data, '\n'), 0o600)
324 }
325
326 func pruneExpiredPairingRequests(reqs []PairingRequest, now time.Time) []PairingRequest {
327 out := reqs[:0]
328 for _, req := range reqs {
329 if req.ExpiresAt.IsZero() || now.Before(req.ExpiresAt) {
330 out = append(out, req)
331 }
332 }
333 return out
334 }
335
336 func pairingRequestMatches(req PairingRequest, msg InboundMessage) bool {
337 return req.Platform == msg.Platform &&
338 strings.TrimSpace(req.ConnectionID) == strings.TrimSpace(msg.ConnectionID) &&
339 strings.TrimSpace(req.ChatID) == strings.TrimSpace(msg.ChatID) &&
340 strings.TrimSpace(req.UserID) == strings.TrimSpace(msg.UserID)
341 }
342
343 func newPairingCode() (string, error) {
344 var b strings.Builder
345 max := big.NewInt(int64(len(pairingAlphabet)))
346 for i := 0; i < 8; i++ {
347 n, err := rand.Int(rand.Reader, max)
348 if err != nil {
349 return "", err
350 }
351 b.WriteByte(pairingAlphabet[n.Int64()])
352 }
353 return b.String(), nil
354 }
355
356 func allowlistAdminCount(a config.BotAllowlist) int {
357 return len(a.QQAdmins) + len(a.FeishuAdmins) + len(a.WeixinAdmins)
358 }
359
360 func appendUnique(values []string, next string) ([]string, bool) {
361 next = strings.TrimSpace(next)
362 if next == "" {
363 return values, false
364 }
365 for _, value := range values {
366 if strings.TrimSpace(value) == next {
367 return values, false
368 }
369 }
370 return append(values, next), true
371 }
372
372 lines GO