返回 DeepSeek-Reasonix
gateway.go
根目录 / internal / bot / gateway.go
1 package bot
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "sort"
11 "strconv"
12 "strings"
13 "sync"
14 "time"
15
16 "reasonix/internal/agent"
17 "reasonix/internal/boot"
18 "reasonix/internal/config"
19 "reasonix/internal/control"
20 "reasonix/internal/event"
21 "reasonix/internal/secrets"
22 "reasonix/internal/session"
23 "reasonix/internal/sessioninbox"
24 )
25
26 // GatewayConfig 是 BotGateway 的配置。
27 type GatewayConfig struct {
28 Model string
29 ToolApprovalMode string
30 MaxSteps int
31 QueueMode string
32 QueueCap int
33 QueueDrop string
34 PairingEnabled bool
35 PairingTTL time.Duration
36 PairingMaxPending int
37 // ModelResolver 校验模型引用是否可解析且已配置(provider 存在、模型
38 // 存在、API key 已配)。/model 切换前调用:校验失败则不写入覆盖,
39 // 保留当前 controller 可继续聊天(失败原子性)。nil 时跳过预校验。
40 ModelResolver func(ref string) error
41 // IgnoreSelfMessages drops messages that are clearly sent by this bot. It
42 // uses configured SelfUserIDs plus recently returned outbound message IDs.
43 IgnoreSelfMessages bool
44 SelfUserIDs map[Platform][]string
45 ControlEnabled bool
46 ControlAddr string
47 ControlToken string
48 // ApprovalTimeout bounds how long a tool-approval/ask prompt blocks a bot
49 // session waiting for a remote user's reply. Zero falls back to
50 // defaultBotApprovalTimeout so an abandoned prompt can't wedge the bot forever
51 // (#4626, #4402). A negative value disables the timeout (wait indefinitely).
52 ApprovalTimeout time.Duration
53 WorkspaceRoot string
54 Channels map[Platform]ChannelConfig
55 ConnectionChannels map[string]ChannelConfig
56 Routes []RouteConfig
57 ConnectionAccess map[string]AccessConfig
58 Allowlist AllowlistConfig
59 Enabled map[Platform]bool
60 Debounce time.Duration
61 // OnInbound observes every allowlisted inbound message before dispatch.
62 //
63 // Reentrancy contract for all GatewayConfig callbacks (OnInbound,
64 // OnSessionReady, OnToolApprovalModeChange): they run synchronously on
65 // gateway-owned dispatch/turn goroutines; OnSessionReady can also run on a
66 // controller recovery/autosave goroutine. Stop drains all of those paths
67 // before returning. A callback must therefore never call Stop, nor block
68 // until a goroutine that does so completes — Stop would wait on the very
69 // goroutine running the callback, a guaranteed deadlock. Hosts that want to
70 // shut the gateway down in reaction to a callback must trigger the shutdown
71 // asynchronously.
72 OnInbound func(InboundMessage)
73 // OnSessionReady notifies the host after the bot has created, reused, or
74 // recovered the controller for an inbound remote. Hosts may persist the
75 // concrete session ID or keep the remote as a read-only channel.
76 OnSessionReady func(InboundMessage, string) error
77 // OnToolApprovalModeChange persists a remote IM /mode request.
78 // The gateway updates the live session and in-memory defaults first; this
79 // callback lets desktop save the chosen connection mode to user config.
80 OnToolApprovalModeChange func(InboundMessage, string) error
81 // Desktop, when the gateway is embedded in the desktop app, gives bot
82 // chats a god view over desktop sessions (/desktop commands): global
83 // status, event subscriptions, and remote approvals for any live desktop
84 // session. Nil when the gateway runs standalone (reasonix bot start).
85 Desktop DesktopBridge
86 }
87
88 // ChannelConfig overrides gateway defaults for one IM channel.
89 type ChannelConfig struct {
90 Model string
91 ToolApprovalMode string
92 WorkspaceRoot string
93 SessionMappings []SessionMapping
94 }
95
96 // SessionMapping is the runtime subset of a saved bot connection mapping used
97 // to route a remote chat/user/thread back to its intended workspace.
98 type SessionMapping struct {
99 RemoteID string
100 SessionID string
101 SessionSource string
102 ChatType string
103 UserID string
104 ThreadID string
105 Scope string
106 WorkspaceRoot string
107 UpdatedAt string
108 }
109
110 // RouteConfig applies per-remote overrides. Empty match fields are wildcards;
111 // the first matching route wins.
112 type RouteConfig struct {
113 ConnectionID string
114 Platform Platform
115 ChatType ChatType
116 ChatID string
117 UserID string
118 ThreadID string
119 Channel ChannelConfig
120 }
121
122 // AdapterBinding attaches an adapter instance to one saved bot connection.
123 // Feishu and Lark share PlatformFeishu, so ID/Domain keep their sessions,
124 // replies, and per-connection settings separated at runtime.
125 type AdapterBinding struct {
126 ID string
127 Domain string
128 Platform Platform
129 Adapter Adapter
130 }
131
132 // AllowlistConfig 控制哪些用户/群可以使用 bot。
133 type AllowlistConfig struct {
134 Enabled bool
135 AllowAll bool
136 Users map[Platform][]string
137 Approvers map[Platform][]string
138 Admins map[Platform][]string
139 Groups map[Platform][]string
140 }
141
142 // AccessConfig controls who may use one concrete bot connection.
143 type AccessConfig struct {
144 Enabled bool
145 AllowAll bool
146 PairingEnabled bool
147 Users []string
148 Groups []string
149 Approvers []string
150 Admins []string
151 }
152
153 // AdapterHealthSnapshot describes the gateway's current view of one adapter.
154 type AdapterHealthSnapshot struct {
155 ID string `json:"id"`
156 Platform Platform `json:"platform"`
157 Domain string `json:"domain,omitempty"`
158 Name string `json:"name,omitempty"`
159 Status string `json:"status"`
160 StartedAt time.Time `json:"started_at,omitempty"`
161 LastMessageAt time.Time `json:"last_message_at,omitempty"`
162 LastSendAt time.Time `json:"last_send_at,omitempty"`
163 LastErrorAt time.Time `json:"last_error_at,omitempty"`
164 LastError string `json:"last_error,omitempty"`
165 Messages int64 `json:"messages"`
166 Sends int64 `json:"sends"`
167 SendErrors int64 `json:"send_errors"`
168 Closed bool `json:"closed"`
169 }
170
171 // BotGateway 是 reasonix bot 消息网关,管理 Controller 生命周期、session 并发、
172 // 事件渲染和平台适配器。
173 type BotGateway struct {
174 cfg GatewayConfig
175 adapters []AdapterBinding
176 sessions *SessionManager
177 startErr []error
178
179 lifecycleMu sync.Mutex
180 started bool
181 stopped bool
182 runCancel context.CancelFunc
183 startDone chan struct{}
184 stopDone chan struct{}
185 gatewayWG sync.WaitGroup
186 turnWG sync.WaitGroup
187
188 mu sync.Mutex
189 controllers map[string]*sessionState // session key -> active state
190 pendingReactionCleanups map[string][]func()
191 allowlist map[Platform]map[string]bool
192 groupAllowlist map[Platform]map[string]bool
193 selfUserIDs map[Platform]map[string]bool
194 outboundMessageIDs map[string]time.Time
195 adapterHealth map[string]*AdapterHealthSnapshot
196 controlServer *controlHTTPServer
197 sessionOverrides map[string]sessionRuntimeOverride
198 buildController func(context.Context, boot.Options) (*control.Controller, error)
199
200 sessionServicesMu sync.Mutex
201 sessionServices map[string]*session.Service
202
203 logger *slog.Logger
204 }
205
206 // botController is the slice of the controller's driving port the gateway needs:
207 // session lifecycle, turn execution, and approval/ask handling. The bot never
208 // touches goals, checkpoints, or memory, so it depends on those sub-ports only —
209 // not the concrete *control.Controller and its ~99 methods.
210 type botController interface {
211 control.Lifecycle
212 control.TurnControl
213 control.Approvals
214 }
215
216 type sessionState struct {
217 lifecycleMu sync.Mutex
218 retired bool
219 ctrl botController
220 sink *sessionEventSink
221 leases *control.SessionLeaseKeeper
222 platform Platform
223 connectionID string
224 model string
225 workspaceRoot string
226 toolApprovalMode string
227 sessionPath string
228 sessionRef session.SessionRef
229 releaseRuntimeOnly bool
230 onSessionTransition func(control.SessionTransitionInfo) error
231 // mappingDegraded records that this state intentionally runs on a fresh
232 // session because its session_mappings target could not be used at build
233 // time. It keeps later messages (whose profile re-resolves the mapping)
234 // from tearing the state down every turn; convergence back onto the
235 // mapped file happens on the next gateway restart.
236 mappingDegraded bool
237 cancel context.CancelFunc
238 pendingAsks map[string][]event.AskQuestion
239 pendingApprovals map[string]event.Approval
240 lastApprovalID string
241 lastAskID string
242 createdAt time.Time
243 lastActive time.Time
244 }
245
246 var errBotSessionRetired = errors.New("bot session retired during recovery")
247
248 type sessionRuntimeProfile struct {
249 model string
250 workspaceRoot string
251 toolApprovalMode string
252 sessionPath string
253 sessionRef session.SessionRef
254 // sessionPathOptional marks sessionPath as a persisted session_mappings
255 // binding rather than an explicit /attach: when the mapped file cannot be
256 // loaded or leased, the session degrades to a fresh path instead of
257 // dropping the message (#6917).
258 sessionPathOptional bool
259 sessionRefOptional bool
260 }
261
262 type sessionRuntimeOverride struct {
263 channel ChannelConfig
264 sessionPath string
265 label string
266 }
267
268 type sessionEventSink struct {
269 mu sync.RWMutex
270 target event.Sink
271 }
272
273 type pendingReactionAdapter interface {
274 AddPendingReaction(ctx context.Context, messageID string) (func(), error)
275 }
276
277 const outboundEchoTTL = 10 * time.Minute
278
279 func (s *sessionEventSink) setTarget(target event.Sink) {
280 s.mu.Lock()
281 defer s.mu.Unlock()
282 s.target = target
283 }
284
285 func (s *sessionEventSink) Emit(e event.Event) {
286 s.mu.RLock()
287 target := s.target
288 s.mu.RUnlock()
289 if target != nil {
290 target.Emit(e)
291 }
292 }
293
294 // NewGateway 创建一个新的 BotGateway。
295 func NewGateway(cfg GatewayConfig, adapters map[Platform]Adapter, logger *slog.Logger) *BotGateway {
296 bindings := make([]AdapterBinding, 0, len(adapters))
297 for plat, adapter := range adapters {
298 bindings = append(bindings, AdapterBinding{ID: string(plat), Platform: plat, Adapter: adapter})
299 }
300 return NewGatewayWithAdapterBindings(cfg, bindings, logger)
301 }
302
303 // NewGatewayWithAdapterBindings creates a gateway with one or more adapter
304 // instances per platform.
305 func NewGatewayWithAdapterBindings(cfg GatewayConfig, adapters []AdapterBinding, logger *slog.Logger) *BotGateway {
306 if logger == nil {
307 logger = slog.Default()
308 }
309 if cfg.Debounce <= 0 {
310 cfg.Debounce = 1500 * time.Millisecond
311 }
312 cfg.QueueMode = NormalizeQueueMode(cfg.QueueMode)
313 if cfg.QueueCap <= 0 {
314 cfg.QueueCap = DefaultQueueCap
315 }
316 cfg.QueueDrop = NormalizeQueueDrop(cfg.QueueDrop)
317 if cfg.PairingTTL <= 0 {
318 cfg.PairingTTL = defaultPairingTTL
319 }
320 if cfg.PairingMaxPending <= 0 {
321 cfg.PairingMaxPending = defaultPairingMaxPending
322 }
323 gw := &BotGateway{
324 cfg: cfg,
325 adapters: normalizeAdapterBindings(adapters),
326 sessions: NewSessionManager(cfg.Debounce),
327 controllers: make(map[string]*sessionState),
328 pendingReactionCleanups: make(map[string][]func()),
329 allowlist: make(map[Platform]map[string]bool),
330 groupAllowlist: make(map[Platform]map[string]bool),
331 selfUserIDs: make(map[Platform]map[string]bool),
332 outboundMessageIDs: make(map[string]time.Time),
333 adapterHealth: make(map[string]*AdapterHealthSnapshot),
334 sessionOverrides: make(map[string]sessionRuntimeOverride),
335 sessionServices: make(map[string]*session.Service),
336 buildController: boot.Build,
337 logger: logger.With("component", "bot_gateway"),
338 }
339 gw.buildAllowlist()
340 gw.buildSelfUserIDs()
341 for _, binding := range gw.adapters {
342 gw.setAdapterConfigured(binding)
343 }
344 return gw
345 }
346
347 func normalizeAdapterBindings(adapters []AdapterBinding) []AdapterBinding {
348 out := make([]AdapterBinding, 0, len(adapters))
349 for _, binding := range adapters {
350 if binding.Adapter == nil {
351 continue
352 }
353 if binding.Platform == "" {
354 binding.Platform = binding.Adapter.Platform()
355 }
356 if strings.TrimSpace(binding.ID) == "" {
357 binding.ID = string(binding.Platform)
358 }
359 binding.ID = strings.TrimSpace(binding.ID)
360 binding.Domain = strings.TrimSpace(binding.Domain)
361 out = append(out, binding)
362 }
363 return out
364 }
365
366 func (gw *BotGateway) buildAllowlist() {
367 for _, plat := range []Platform{PlatformQQ, PlatformFeishu, PlatformWeixin, PlatformDingtalk} {
368 gw.allowlist[plat] = make(map[string]bool)
369 if !gw.cfg.Allowlist.Enabled {
370 continue
371 }
372 addAllowlistUsers(gw.allowlist[plat], gw.cfg.Allowlist.Users[plat])
373 addAllowlistUsers(gw.allowlist[plat], gw.cfg.Allowlist.Admins[plat])
374 addAllowlistUsers(gw.allowlist[plat], gw.cfg.Allowlist.Approvers[plat])
375 gw.groupAllowlist[plat] = make(map[string]bool)
376 for _, gid := range gw.cfg.Allowlist.Groups[plat] {
377 gw.groupAllowlist[plat][gid] = true
378 }
379 }
380 }
381
382 func addAllowlistUsers(dst map[string]bool, users []string) {
383 for _, uid := range users {
384 uid = strings.TrimSpace(uid)
385 if uid != "" {
386 dst[uid] = true
387 }
388 }
389 }
390
391 func (gw *BotGateway) buildSelfUserIDs() {
392 for _, plat := range []Platform{PlatformQQ, PlatformFeishu, PlatformWeixin, PlatformDingtalk} {
393 gw.selfUserIDs[plat] = stringSet(gw.cfg.SelfUserIDs[plat])
394 }
395 }
396
397 // Start 启动所有已启用的平台适配器并开始处理消息。
398 func (gw *BotGateway) Start(ctx context.Context) (err error) {
399 gw.lifecycleMu.Lock()
400 if gw.stopped {
401 gw.lifecycleMu.Unlock()
402 return errors.New("bot gateway already stopped")
403 }
404 if gw.started {
405 gw.lifecycleMu.Unlock()
406 return errors.New("bot gateway already started")
407 }
408 gw.started = true
409 runCtx, cancel := context.WithCancel(ctx)
410 gw.runCancel = cancel
411 startDone := make(chan struct{})
412 gw.startDone = startDone
413 gw.lifecycleMu.Unlock()
414 defer func() {
415 if err != nil {
416 cancel()
417 }
418 gw.lifecycleMu.Lock()
419 if err != nil {
420 gw.runCancel = nil
421 }
422 close(startDone)
423 gw.lifecycleMu.Unlock()
424 }()
425
426 started := make([]AdapterBinding, 0, len(gw.adapters))
427 var startErr []error
428 for _, binding := range gw.adapters {
429 if !gw.cfg.Enabled[binding.Platform] {
430 gw.logger.Info("platform disabled, skipping", "platform", binding.Platform, "connection", binding.ID)
431 gw.markAdapterDisabled(binding)
432 continue
433 }
434 gw.logger.Info("starting adapter", "platform", binding.Platform, "connection", binding.ID, "domain", binding.Domain)
435 if err := binding.Adapter.Start(runCtx); err != nil {
436 wrapped := fmt.Errorf("start adapter %s: %w", binding.ID, err)
437 startErr = append(startErr, wrapped)
438 gw.markAdapterStartFailed(binding, err)
439 gw.logger.Warn("adapter start failed", "platform", binding.Platform, "connection", binding.ID, "domain", binding.Domain, "err", err)
440 continue
441 }
442 gw.markAdapterStarted(binding)
443 started = append(started, binding)
444 }
445 // SendToAdapter reads gw.adapters under gw.mu; publish the started set under
446 // the same lock.
447 gw.mu.Lock()
448 gw.adapters = started
449 gw.startErr = startErr
450 gw.mu.Unlock()
451 if len(started) == 0 && len(startErr) > 0 {
452 return errors.Join(startErr...)
453 }
454 if err := gw.startControlServer(runCtx); err != nil {
455 for _, binding := range started {
456 _ = binding.Adapter.Stop()
457 }
458 return err
459 }
460
461 // 合并所有适配器的消息通道
462 for _, binding := range gw.adapters {
463 gw.gatewayWG.Go(func() {
464 gw.dispatchLoop(runCtx, binding)
465 })
466 }
467
468 return nil
469 }
470
471 func (gw *BotGateway) AdapterCount() int {
472 gw.mu.Lock()
473 defer gw.mu.Unlock()
474 return len(gw.adapters)
475 }
476
477 func (gw *BotGateway) StartErrors() []error {
478 gw.mu.Lock()
479 defer gw.mu.Unlock()
480 out := make([]error, len(gw.startErr))
481 copy(out, gw.startErr)
482 return out
483 }
484
485 // AdapterHealth returns a stable snapshot of all configured adapter instances.
486 func (gw *BotGateway) AdapterHealth() []AdapterHealthSnapshot {
487 gw.mu.Lock()
488 defer gw.mu.Unlock()
489 out := make([]AdapterHealthSnapshot, 0, len(gw.adapterHealth))
490 for _, health := range gw.adapterHealth {
491 if health == nil {
492 continue
493 }
494 out = append(out, *health)
495 }
496 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
497 return out
498 }
499
500 func (gw *BotGateway) setAdapterConfigured(binding AdapterBinding) {
501 gw.mu.Lock()
502 defer gw.mu.Unlock()
503 gw.ensureAdapterHealthLocked(binding).Status = "configured"
504 }
505
506 func (gw *BotGateway) markAdapterDisabled(binding AdapterBinding) {
507 gw.mu.Lock()
508 defer gw.mu.Unlock()
509 health := gw.ensureAdapterHealthLocked(binding)
510 health.Status = "disabled"
511 health.Closed = true
512 }
513
514 func (gw *BotGateway) markAdapterStarted(binding AdapterBinding) {
515 now := time.Now()
516 gw.mu.Lock()
517 defer gw.mu.Unlock()
518 health := gw.ensureAdapterHealthLocked(binding)
519 health.Status = "running"
520 health.StartedAt = now
521 health.LastError = ""
522 health.Closed = false
523 }
524
525 func (gw *BotGateway) markAdapterStartFailed(binding AdapterBinding, err error) {
526 gw.mu.Lock()
527 defer gw.mu.Unlock()
528 health := gw.ensureAdapterHealthLocked(binding)
529 health.Status = "error"
530 health.Closed = true
531 health.LastErrorAt = time.Now()
532 if err != nil {
533 health.LastError = err.Error()
534 }
535 }
536
537 func (gw *BotGateway) markAdapterMessage(binding AdapterBinding) {
538 now := time.Now()
539 gw.mu.Lock()
540 defer gw.mu.Unlock()
541 health := gw.ensureAdapterHealthLocked(binding)
542 health.Status = "running"
543 health.LastMessageAt = now
544 health.Messages++
545 health.Closed = false
546 }
547
548 func (gw *BotGateway) markAdapterClosed(binding AdapterBinding) {
549 gw.mu.Lock()
550 defer gw.mu.Unlock()
551 health := gw.ensureAdapterHealthLocked(binding)
552 if health.Status == "running" {
553 health.Status = "closed"
554 }
555 health.Closed = true
556 }
557
558 func (gw *BotGateway) markAdapterSend(binding AdapterBinding, err error) {
559 now := time.Now()
560 gw.mu.Lock()
561 defer gw.mu.Unlock()
562 health := gw.ensureAdapterHealthLocked(binding)
563 if err != nil {
564 health.SendErrors++
565 health.LastErrorAt = now
566 health.LastError = err.Error()
567 if health.Status == "running" {
568 health.Status = "degraded"
569 }
570 return
571 }
572 health.Sends++
573 health.LastSendAt = now
574 if health.Status == "degraded" {
575 health.Status = "running"
576 }
577 }
578
579 func (gw *BotGateway) ensureAdapterHealthLocked(binding AdapterBinding) *AdapterHealthSnapshot {
580 id := strings.TrimSpace(binding.ID)
581 if id == "" && binding.Adapter != nil {
582 id = binding.Adapter.Name()
583 }
584 if id == "" {
585 id = string(binding.Platform)
586 }
587 health := gw.adapterHealth[id]
588 if health == nil {
589 health = &AdapterHealthSnapshot{ID: id}
590 gw.adapterHealth[id] = health
591 }
592 health.Platform = binding.Platform
593 health.Domain = strings.TrimSpace(binding.Domain)
594 if binding.Adapter != nil {
595 health.Name = binding.Adapter.Name()
596 }
597 if strings.TrimSpace(health.Status) == "" {
598 health.Status = "configured"
599 }
600 return health
601 }
602
603 // Stop 停止所有适配器并关闭所有 session。它会等待 dispatch 与 turn goroutine
604 // 全部退出,所以绝不能在 GatewayConfig 回调里同步调用(见 OnInbound 的
605 // reentrancy contract),否则 Stop 会等待正在运行该回调的 goroutine 自己。
606 func (gw *BotGateway) Stop() {
607 gw.lifecycleMu.Lock()
608 if gw.stopped {
609 stopDone := gw.stopDone
610 gw.lifecycleMu.Unlock()
611 if stopDone != nil {
612 <-stopDone
613 }
614 return
615 }
616 gw.stopped = true
617 stopDone := make(chan struct{})
618 gw.stopDone = stopDone
619 cancel := gw.runCancel
620 gw.runCancel = nil
621 startDone := gw.startDone
622 gw.lifecycleMu.Unlock()
623 defer close(stopDone)
624
625 if cancel != nil {
626 cancel()
627 }
628 if startDone != nil {
629 <-startDone
630 }
631
632 // Cancel sessions that already exist before waiting for dispatch to drain.
633 // A dispatch already inside handleMessage may still publish a late session,
634 // so closeSessions is repeated after gatewayWG and turnWG reach zero.
635 gw.closeSessions()
636 for _, binding := range gw.adapters {
637 if err := binding.Adapter.Stop(); err != nil {
638 gw.logger.Warn("error stopping adapter", "platform", binding.Platform, "connection", binding.ID, "err", err)
639 }
640 gw.markAdapterClosed(binding)
641 }
642 gw.stopControlServer()
643 gw.gatewayWG.Wait()
644 gw.closeSessions()
645 gw.turnWG.Wait()
646 gw.finishSessionTeardown()
647 }
648
649 func (gw *BotGateway) closeSessions() {
650 var states []*sessionState
651 gw.mu.Lock()
652 for key, state := range gw.controllers {
653 states = append(states, state)
654 delete(gw.controllers, key)
655 }
656 gw.mu.Unlock()
657 for _, state := range states {
658 gw.closeSessionState(state)
659 }
660 }
661
662 // closeSessionState tears down a session state that has been unlinked from
663 // gw.controllers. runTurn publishes state.cancel under gw.mu on every turn —
664 // possibly after the state was already unlinked — so snapshot and clear the
665 // field inside the lock and invoke it outside (the same discipline as
666 // cancelActiveSession).
667 func (gw *BotGateway) closeSessionState(state *sessionState) {
668 if state == nil {
669 return
670 }
671 // Serialize retirement with recovery ownership handoffs. Stop unlinks
672 // sessions before turn goroutines drain, so a recovery callback captured by
673 // the controller can still arrive here. Marking the state retired under the
674 // same lock prevents that callback from reacquiring a lease after teardown;
675 // an already-running handoff completes before the lease is released below.
676 state.lifecycleMu.Lock()
677 if state.retired {
678 state.lifecycleMu.Unlock()
679 return
680 }
681 state.retired = true
682 state.lifecycleMu.Unlock()
683
684 gw.mu.Lock()
685 cancel := state.cancel
686 state.cancel = nil
687 gw.mu.Unlock()
688 if cancel != nil {
689 cancel()
690 }
691 if state.ctrl != nil {
692 if state.releaseRuntimeOnly {
693 if releaser, ok := state.ctrl.(interface{ ReleaseResources() }); ok {
694 releaser.ReleaseResources()
695 } else {
696 state.ctrl.Close()
697 }
698 } else {
699 state.ctrl.Close()
700 }
701 }
702 if state.leases != nil {
703 state.leases.Release()
704 }
705 }
706
707 // unlinkAndCloseSessionState removes state from the live gateway before closing
708 // it. It is used when a controller has already rotated its transcript but the
709 // replacement lease could not be acquired: retaining that state would let the
710 // next message reuse a controller that no longer owns its active session path.
711 func (gw *BotGateway) unlinkAndCloseSessionState(key string, state *sessionState) {
712 if state == nil {
713 return
714 }
715 gw.mu.Lock()
716 if gw.controllers[key] == state {
717 delete(gw.controllers, key)
718 }
719 gw.mu.Unlock()
720 gw.closeSessionState(state)
721 }
722
723 func (gw *BotGateway) dispatchLoop(ctx context.Context, binding AdapterBinding) {
724 for {
725 select {
726 case <-ctx.Done():
727 gw.markAdapterClosed(binding)
728 return
729 case msg, ok := <-binding.Adapter.Messages():
730 if !ok {
731 gw.markAdapterClosed(binding)
732 return
733 }
734 gw.markAdapterMessage(binding)
735 gw.handleMessage(ctx, binding, msg)
736 }
737 }
738 }
739
740 func (gw *BotGateway) handleMessage(ctx context.Context, binding AdapterBinding, msg InboundMessage) {
741 msg.Platform = binding.Platform
742 if msg.ConnectionID == "" {
743 msg.ConnectionID = binding.ID
744 }
745 if msg.Domain == "" {
746 msg.Domain = binding.Domain
747 }
748 if gw.isSelfMessage(msg) {
749 gw.logger.Debug("bot ignored self message", "platform", binding.Platform, "connection", msg.ConnectionID, "chat", hashID(msg.ChatID), "message", hashID(msg.MessageID), "user", hashID(msg.UserID))
750 return
751 }
752 src := msg.Session()
753 key := BuildSessionKey(src)
754 logFields := []any{
755 "platform", binding.Platform,
756 "connection", msg.ConnectionID,
757 "domain", msg.Domain,
758 "chat_type", msg.ChatType,
759 "chat", hashID(msg.ChatID),
760 "user", hashID(msg.UserID),
761 "operator", hashID(msg.OperatorID),
762 "thread", hashID(msg.ThreadID),
763 "message", hashID(msg.MessageID),
764 "text_chars", len([]rune(msg.Text)),
765 "session", key[:8],
766 }
767 gw.logger.Info("bot inbound message", logFields...)
768
769 // allowlist 检查
770 if !gw.checkAllowlist(binding.Platform, msg) {
771 gw.logger.Info("user not in allowlist", "platform", binding.Platform, "connection", msg.ConnectionID, "user", hashID(msg.UserID))
772 if gw.offerPairing(ctx, binding.Adapter, msg) {
773 return
774 }
775 _ = gw.sendText(ctx, binding.Adapter, msg, "抱歉,您没有使用此 bot 的权限。")
776 return
777 }
778 if gw.cfg.OnInbound != nil {
779 gw.cfg.OnInbound(msg)
780 }
781
782 if normalized, ok := gw.normalizeApprovalShortcut(key, msg.Text); ok {
783 msg.Text = normalized
784 } else if normalized, ok := gw.normalizeAskShortcut(key, msg.Text); ok {
785 msg.Text = normalized
786 } else if _, ok := decisionShortcutCommand(msg.Text); ok && gw.sessions.IsActive(key) {
787 _ = gw.sendText(ctx, binding.Adapter, msg, "没有找到可匹配的待处理操作。请重新触发一次操作后回复编号,或按消息中的 ID 使用 /approve、/deny 或 /answer。")
788 return
789 }
790
791 // 斜杠命令处理
792 if IsSlashBypass(msg.Text) {
793 gw.logger.Info("bot slash command", logFields...)
794 gw.handleSlashCommand(ctx, binding.Adapter, key, msg)
795 return
796 }
797
798 // 已接管桌面会话的聊天:普通消息直接驱动那个桌面会话,不进 bot 自己的
799 // 会话机器(斜杠命令仍走上面的分支,/desktop release 永远可达)。
800 if gw.divertToDesktopTakeover(ctx, binding.Adapter, msg) {
801 gw.logger.Info("bot message diverted to desktop takeover", logFields...)
802 return
803 }
804
805 cleanup := gw.addPendingReaction(ctx, binding.Platform, binding.Adapter, msg)
806
807 queueMode := gw.queueMode(key, msg)
808 warnDeprecatedQueueDrop(gw.cfg.QueueDrop)
809 if gw.sessions.IsActive(key) {
810 // Busy session: durable inbox is the authority (not SessionManager.pending).
811 if IsSlashBypass(msg.Text) {
812 // Slash commands still acquire through the session lock below.
813 } else {
814 switch queueMode {
815 case QueueModeSteer:
816 if rec, ok := gw.steerActiveSessionDurable(ctx, binding.Adapter, key, msg); ok {
817 gw.logger.Info("bot message steered into active turn", "session", key[:8], "item", rec.ItemID)
818 if cleanup != nil {
819 cleanup()
820 }
821 _ = gw.sendText(ctx, binding.Adapter, msg, formatQueuedReceipt(rec)+"(已并入当前任务)")
822 return
823 }
824 case QueueModeInterrupt:
825 gw.cancelActiveSession(key)
826 runReactionCleanups(gw.takeReactionCleanups(key))
827 rec, err := gw.interruptActiveSessionDurable(ctx, binding.Adapter, key, msg)
828 gw.storeReactionCleanup(key, cleanup)
829 if err != nil {
830 gw.logger.Warn("bot interrupt enqueue failed", "session", key[:8], "err", err)
831 _ = gw.sendText(ctx, binding.Adapter, msg, "排队失败:"+err.Error())
832 return
833 }
834 gw.logger.Info("bot active turn interrupted; newest message durable-queued", "session", key[:8], "item", rec.ItemID)
835 _ = gw.sendText(ctx, binding.Adapter, msg, "已停止当前任务。"+formatQueuedReceipt(rec))
836 return
837 case QueueModeCollect:
838 if rec, err := gw.collectActiveSessionDurable(ctx, binding.Adapter, key, msg); err == nil {
839 gw.storeReactionCleanup(key, cleanup)
840 _ = gw.sendText(ctx, binding.Adapter, msg, formatQueuedReceipt(rec))
841 return
842 } else if errors.Is(err, sessioninbox.ErrCapacityItems) || errors.Is(err, sessioninbox.ErrCapacityBytes) || errors.Is(err, sessioninbox.ErrItemTooLarge) {
843 if cleanup != nil {
844 cleanup()
845 }
846 _ = gw.sendText(ctx, binding.Adapter, msg, "当前会话排队已满,请稍后再发,或使用 /queue pause 后清理。")
847 return
848 }
849 default: // followup
850 if rec, err := gw.followupActiveSessionDurable(ctx, binding.Adapter, key, msg); err == nil {
851 gw.storeReactionCleanup(key, cleanup)
852 _ = gw.sendText(ctx, binding.Adapter, msg, formatQueuedReceipt(rec))
853 return
854 } else if errors.Is(err, sessioninbox.ErrCapacityItems) || errors.Is(err, sessioninbox.ErrCapacityBytes) || errors.Is(err, sessioninbox.ErrItemTooLarge) {
855 if cleanup != nil {
856 cleanup()
857 }
858 _ = gw.sendText(ctx, binding.Adapter, msg, "当前会话排队已满,请稍后再发。")
859 return
860 }
861 }
862 }
863 }
864
865 // session 并发控制 — only the active-turn lock remains here; bodies live in inbox.
866 result := gw.sessions.TryAcquireWithQueue(key, msg, QueueOptions{
867 Mode: QueueModeFollowup, // never drop_old; capacity enforced by inbox
868 Cap: sessioninbox.DefaultMaxItems,
869 Drop: QueueDropNew,
870 })
871 if result.Rejected {
872 gw.logger.Warn("bot queue rejected message", "session", key[:8], "pending", result.Pending, "mode", result.Mode)
873 if cleanup != nil {
874 cleanup()
875 }
876 _ = gw.sendText(ctx, binding.Adapter, msg, "当前会话排队已满,请稍后再发,或使用 /queue 管理队列。")
877 return
878 }
879 gw.dispatchQueueResult(ctx, binding.Adapter, key, msg, cleanup, result)
880 }
881
882 func (gw *BotGateway) queueMode(key string, msg InboundMessage) string {
883 return gw.sessions.QueueMode(key, gw.cfg.QueueMode)
884 }
885
886 func (gw *BotGateway) sessionAPI(key string) control.SessionAPI {
887 gw.mu.Lock()
888 state, ok := gw.controllers[key]
889 gw.mu.Unlock()
890 if !ok || state == nil || state.ctrl == nil {
891 return nil
892 }
893 if api, ok := state.ctrl.(control.SessionAPI); ok {
894 return api
895 }
896 return nil
897 }
898
899 func (gw *BotGateway) steerActiveSessionDurable(ctx context.Context, adapter Adapter, key string, msg InboundMessage) (sessioninbox.InboxReceipt, bool) {
900 text := strings.TrimSpace(msg.Text)
901 if text == "" && len(msg.MediaURLs) == 0 && len(msg.Media) == 0 {
902 return sessioninbox.InboxReceipt{}, false
903 }
904 gw.mu.Lock()
905 state, ok := gw.controllers[key]
906 gw.mu.Unlock()
907 if !ok || state.ctrl == nil {
908 return sessioninbox.InboxReceipt{}, false
909 }
910 msg = gw.prepareDurableInboxMessage(ctx, adapter, msg, state)
911 text = msg.Text
912 if strings.TrimSpace(text) == "" {
913 return sessioninbox.InboxReceipt{}, false
914 }
915 msg.Text = text
916 api, ok := state.ctrl.(control.SessionAPI)
917 if !ok {
918 // Legacy fallback.
919 if steerer, ok := state.ctrl.(interface{ TrySteer(string) bool }); ok && steerer.TrySteer(text) {
920 return sessioninbox.InboxReceipt{Disposition: sessioninbox.DispositionSteerAccepted}, true
921 }
922 return sessioninbox.InboxReceipt{}, false
923 }
924 rec, err := enqueueViaInbox(api, msg, sessioninbox.IntentSteer)
925 if err != nil {
926 return sessioninbox.InboxReceipt{}, false
927 }
928 return rec, true
929 }
930
931 func (gw *BotGateway) cancelActiveSession(key string) {
932 // state.cancel is rewritten under gw.mu on every turn (runTurn), so copy it
933 // inside the lock and invoke it outside.
934 var cancel context.CancelFunc
935 gw.mu.Lock()
936 state, ok := gw.controllers[key]
937 if ok && state != nil {
938 cancel = state.cancel
939 }
940 gw.mu.Unlock()
941 if !ok || state == nil {
942 return
943 }
944 if cancel != nil {
945 cancel()
946 return
947 }
948 if state.ctrl != nil {
949 state.ctrl.Cancel()
950 }
951 }
952
953 func (gw *BotGateway) storeReactionCleanup(key string, cleanup func()) {
954 if cleanup == nil {
955 return
956 }
957 gw.mu.Lock()
958 defer gw.mu.Unlock()
959 gw.pendingReactionCleanups[key] = append(gw.pendingReactionCleanups[key], cleanup)
960 }
961
962 func (gw *BotGateway) flushReactionCleanups(key string, cleanup func()) {
963 stored := gw.takeReactionCleanups(key)
964 runReactionCleanups(stored)
965 if cleanup != nil {
966 cleanup()
967 }
968 }
969
970 func (gw *BotGateway) takeReactionCleanups(key string) []func() {
971 gw.mu.Lock()
972 defer gw.mu.Unlock()
973 stored := gw.pendingReactionCleanups[key]
974 delete(gw.pendingReactionCleanups, key)
975 return stored
976 }
977
978 func runReactionCleanups(cleanups []func()) {
979 for _, cleanup := range cleanups {
980 if cleanup != nil {
981 cleanup()
982 }
983 }
984 }
985
986 func makeReactionCleanup(cleanups []func()) func() {
987 if len(cleanups) == 0 {
988 return nil
989 }
990 return func() {
991 runReactionCleanups(cleanups)
992 }
993 }
994
995 func (gw *BotGateway) addPendingReaction(ctx context.Context, plat Platform, adapter Adapter, msg InboundMessage) func() {
996 if strings.TrimSpace(msg.MessageID) == "" {
997 return nil
998 }
999 reactor, ok := adapter.(pendingReactionAdapter)
1000 if !ok {
1001 return nil
1002 }
1003 cleanup, err := reactor.AddPendingReaction(ctx, msg.MessageID)
1004 if err != nil {
1005 gw.logger.Warn("pending reaction failed", "platform", plat, "err", err)
1006 return nil
1007 }
1008 return cleanup
1009 }
1010
1011 func (gw *BotGateway) isSelfMessage(msg InboundMessage) bool {
1012 if !gw.cfg.IgnoreSelfMessages {
1013 return false
1014 }
1015 actor := strings.TrimSpace(msg.UserID)
1016 if strings.TrimSpace(msg.OperatorID) != "" {
1017 actor = strings.TrimSpace(msg.OperatorID)
1018 }
1019 if actor != "" && gw.selfUserIDs[msg.Platform][actor] {
1020 return true
1021 }
1022 messageID := strings.TrimSpace(msg.MessageID)
1023 if messageID == "" {
1024 return false
1025 }
1026 key := outboundMessageKey(msg.Platform, msg.ConnectionID, msg.Domain, msg.ChatID, messageID)
1027 now := time.Now()
1028 gw.mu.Lock()
1029 defer gw.mu.Unlock()
1030 gw.pruneOutboundMessagesLocked(now)
1031 _, ok := gw.outboundMessageIDs[key]
1032 return ok
1033 }
1034
1035 func (gw *BotGateway) rememberOutboundMessage(platform Platform, connID, domain, chatID, messageID string) {
1036 messageID = strings.TrimSpace(messageID)
1037 if !gw.cfg.IgnoreSelfMessages || messageID == "" {
1038 return
1039 }
1040 now := time.Now()
1041 key := outboundMessageKey(platform, connID, domain, chatID, messageID)
1042 gw.mu.Lock()
1043 defer gw.mu.Unlock()
1044 gw.pruneOutboundMessagesLocked(now)
1045 gw.outboundMessageIDs[key] = now.Add(outboundEchoTTL)
1046 }
1047
1048 func (gw *BotGateway) pruneOutboundMessagesLocked(now time.Time) {
1049 for key, expiresAt := range gw.outboundMessageIDs {
1050 if !expiresAt.After(now) {
1051 delete(gw.outboundMessageIDs, key)
1052 }
1053 }
1054 }
1055
1056 func outboundMessageKey(platform Platform, connID, domain, chatID, messageID string) string {
1057 return strings.Join([]string{
1058 string(platform),
1059 strings.TrimSpace(connID),
1060 strings.TrimSpace(domain),
1061 strings.TrimSpace(chatID),
1062 strings.TrimSpace(messageID),
1063 }, "\x00")
1064 }
1065
1066 func (gw *BotGateway) connectionAccess(msg InboundMessage) (AccessConfig, bool) {
1067 if gw.cfg.ConnectionAccess == nil {
1068 return AccessConfig{}, false
1069 }
1070 id := strings.TrimSpace(msg.ConnectionID)
1071 if id == "" {
1072 return AccessConfig{}, false
1073 }
1074 access, ok := gw.cfg.ConnectionAccess[id]
1075 if !ok {
1076 return AccessConfig{}, false
1077 }
1078 if !accessConfigActive(access) {
1079 return AccessConfig{}, false
1080 }
1081 return access, true
1082 }
1083
1084 func accessConfigActive(access AccessConfig) bool {
1085 return access.Enabled ||
1086 access.AllowAll ||
1087 access.PairingEnabled ||
1088 len(access.Users) > 0 ||
1089 len(access.Groups) > 0 ||
1090 len(access.Approvers) > 0 ||
1091 len(access.Admins) > 0
1092 }
1093
1094 func (gw *BotGateway) checkAllowlist(plat Platform, msg InboundMessage) bool {
1095 if access, ok := gw.connectionAccess(msg); ok {
1096 return checkConnectionAllowlist(access, msg)
1097 }
1098 if gw.cfg.Allowlist.AllowAll {
1099 return true
1100 }
1101 if !gw.cfg.Allowlist.Enabled {
1102 return false
1103 }
1104 actor := msg.UserID
1105 if msg.OperatorID != "" {
1106 actor = msg.OperatorID
1107 }
1108 if !gw.allowlist[plat][actor] {
1109 return false
1110 }
1111 groups := gw.groupAllowlist[plat]
1112 if chatUsesGroupAllowlist(msg.ChatType) && len(groups) > 0 && !groups[msg.ChatID] {
1113 return false
1114 }
1115 return true
1116 }
1117
1118 func checkConnectionAllowlist(access AccessConfig, msg InboundMessage) bool {
1119 if access.AllowAll {
1120 return true
1121 }
1122 if !access.Enabled {
1123 return false
1124 }
1125 actor := msg.UserID
1126 if msg.OperatorID != "" {
1127 actor = msg.OperatorID
1128 }
1129 users := stringSet(append(append(append([]string{}, access.Users...), access.Admins...), access.Approvers...))
1130 groups := stringSet(access.Groups)
1131 actorAllowed := users[actor]
1132 groupAllowed := chatUsesGroupAllowlist(msg.ChatType) && groups[msg.ChatID]
1133 if len(users) == 0 && len(groups) == 0 {
1134 return false
1135 }
1136 return actorAllowed || groupAllowed
1137 }
1138
1139 func (gw *BotGateway) requireCommandRole(ctx context.Context, adapter Adapter, msg InboundMessage, role string) bool {
1140 if gw.checkCommandRole(msg.Platform, msg, role) {
1141 return true
1142 }
1143 _ = gw.sendText(ctx, adapter, msg, "抱歉,你没有执行此 bot 命令的权限。")
1144 return false
1145 }
1146
1147 func (gw *BotGateway) checkCommandRole(plat Platform, msg InboundMessage, role string) bool {
1148 actor := msg.UserID
1149 if msg.OperatorID != "" {
1150 actor = msg.OperatorID
1151 }
1152 if strings.TrimSpace(actor) == "" {
1153 return false
1154 }
1155 if access, ok := gw.connectionAccess(msg); ok {
1156 admins := stringSet(access.Admins)
1157 approvers := stringSet(access.Approvers)
1158 if len(admins) == 0 && len(approvers) == 0 {
1159 return true
1160 }
1161 if admins[actor] {
1162 return true
1163 }
1164 return role == "approver" && approvers[actor]
1165 }
1166 admins := stringSet(gw.cfg.Allowlist.Admins[plat])
1167 approvers := stringSet(gw.cfg.Allowlist.Approvers[plat])
1168 if len(admins) == 0 && len(approvers) == 0 {
1169 return true
1170 }
1171 if admins[actor] {
1172 return true
1173 }
1174 if role == "approver" && approvers[actor] {
1175 return true
1176 }
1177 return false
1178 }
1179
1180 func stringSet(values []string) map[string]bool {
1181 out := make(map[string]bool, len(values))
1182 for _, value := range values {
1183 value = strings.TrimSpace(value)
1184 if value != "" {
1185 out[value] = true
1186 }
1187 }
1188 return out
1189 }
1190
1191 func (gw *BotGateway) offerPairing(ctx context.Context, adapter Adapter, msg InboundMessage) bool {
1192 if access, ok := gw.connectionAccess(msg); ok {
1193 if !access.PairingEnabled {
1194 return false
1195 }
1196 } else if !gw.cfg.PairingEnabled {
1197 return false
1198 }
1199 req, created, err := CreateOrRefreshPairingRequest(msg, PairingConfig{
1200 Enabled: true,
1201 RequestTTL: gw.cfg.PairingTTL,
1202 MaxPendingPerPlatform: gw.cfg.PairingMaxPending,
1203 })
1204 if err != nil {
1205 gw.logger.Warn("bot pairing request failed", "platform", msg.Platform, "chat_type", msg.ChatType, "err", err)
1206 return false
1207 }
1208 prefix := "需要先完成配对。"
1209 if !created {
1210 prefix = "你已有待批准的配对请求。"
1211 }
1212 text := fmt.Sprintf("%s\n配对码: %s\n请在本机运行: reasonix bot pairing approve %s\n此码将在 %s 过期。",
1213 prefix, req.Code, req.Code, req.ExpiresAt.Local().Format("2006-01-02 15:04"))
1214 _ = gw.sendText(ctx, adapter, msg, text)
1215 return true
1216 }
1217
1218 func chatUsesGroupAllowlist(chatType ChatType) bool {
1219 switch chatType {
1220 case ChatGroup, ChatGuild, ChatThread:
1221 return true
1222 default:
1223 return false
1224 }
1225 }
1226
1227 func approvalShortcutCommand(text string) (string, bool) {
1228 switch strings.ToLower(strings.TrimSpace(text)) {
1229 case "1", "y", "yes", "ok", "同意", "批准", "允许", "允许一次":
1230 return "/approve", true
1231 case "2", "0", "n", "no", "deny", "拒绝":
1232 return "/deny", true
1233 default:
1234 return "", false
1235 }
1236 }
1237
1238 func recoveryShortcutCommand(text string, canGrantTask bool) (string, bool) {
1239 switch strings.ToLower(strings.TrimSpace(text)) {
1240 case "1", "y", "yes", "ok", "继续", "继续此变更", "continue":
1241 return "/recovery-continue", true
1242 case "2", "a", "同类", "本任务允许", "allow similar":
1243 if canGrantTask {
1244 return "/recovery-continue-task", true
1245 }
1246 return "/recovery-revise", true
1247 case "3":
1248 if canGrantTask {
1249 return "/recovery-revise", true
1250 }
1251 return "", false
1252 case "修改", "修改方案", "换个办法", "revise":
1253 return "/recovery-revise", true
1254 default:
1255 return "", false
1256 }
1257 }
1258
1259 func (gw *BotGateway) pendingRecoveryCanGrantTask(key, id string) bool {
1260 gw.mu.Lock()
1261 defer gw.mu.Unlock()
1262 state, ok := gw.controllers[key]
1263 if !ok || state.pendingApprovals == nil {
1264 return false
1265 }
1266 a, ok := state.pendingApprovals[id]
1267 return ok && a.Recovery != nil && a.Recovery.CanGrantTask
1268 }
1269
1270 func (gw *BotGateway) pendingApprovalIsRecovery(key, id string) bool {
1271 gw.mu.Lock()
1272 defer gw.mu.Unlock()
1273 state, ok := gw.controllers[key]
1274 if !ok || state.pendingApprovals == nil {
1275 return false
1276 }
1277 a, ok := state.pendingApprovals[id]
1278 if !ok {
1279 return false
1280 }
1281 return strings.EqualFold(strings.TrimSpace(a.Kind), "recovery") || a.Recovery != nil
1282 }
1283
1284 func decisionShortcutCommand(text string) (string, bool) {
1285 if command, ok := approvalShortcutCommand(text); ok {
1286 return command, true
1287 }
1288 if _, ok := askShortcutAnswer(text); ok {
1289 return "/answer", true
1290 }
1291 return "", false
1292 }
1293
1294 func (gw *BotGateway) currentPendingApprovalID(key string) string {
1295 gw.mu.Lock()
1296 defer gw.mu.Unlock()
1297 state, ok := gw.controllers[key]
1298 if !ok || len(state.pendingApprovals) == 0 {
1299 return ""
1300 }
1301 if state.lastApprovalID != "" {
1302 if _, ok := state.pendingApprovals[state.lastApprovalID]; ok {
1303 return state.lastApprovalID
1304 }
1305 }
1306 for id := range state.pendingApprovals {
1307 return id
1308 }
1309 return ""
1310 }
1311
1312 func (gw *BotGateway) forgetPendingApproval(key, id string) {
1313 gw.mu.Lock()
1314 defer gw.mu.Unlock()
1315 state, ok := gw.controllers[key]
1316 if !ok || state.pendingApprovals == nil {
1317 return
1318 }
1319 delete(state.pendingApprovals, id)
1320 if state.lastApprovalID == id {
1321 state.lastApprovalID = ""
1322 for nextID := range state.pendingApprovals {
1323 state.lastApprovalID = nextID
1324 break
1325 }
1326 }
1327 }
1328
1329 func (gw *BotGateway) normalizeAskShortcut(key, text string) (string, bool) {
1330 raw := strings.TrimSpace(text)
1331 if raw == "" || strings.HasPrefix(raw, "/") {
1332 return "", false
1333 }
1334 askID := gw.currentPendingAskIDForReply(key)
1335 if askID == "" {
1336 return "", false
1337 }
1338 return "/answer " + askID + " " + raw, true
1339 }
1340
1341 func askShortcutAnswer(text string) (string, bool) {
1342 raw := strings.TrimSpace(text)
1343 if raw == "" {
1344 return "", false
1345 }
1346 if strings.ContainsAny(raw, " \t\n;=") {
1347 return "", false
1348 }
1349 if _, err := strconv.Atoi(raw); err == nil {
1350 return raw, true
1351 }
1352 return "", false
1353 }
1354
1355 func (gw *BotGateway) currentPendingAskIDForReply(key string) string {
1356 gw.mu.Lock()
1357 defer gw.mu.Unlock()
1358 state, ok := gw.controllers[key]
1359 if !ok || len(state.pendingAsks) == 0 {
1360 return ""
1361 }
1362 if state.lastAskID != "" {
1363 if _, ok := state.pendingAsks[state.lastAskID]; ok {
1364 return state.lastAskID
1365 }
1366 }
1367 if len(state.pendingAsks) != 1 {
1368 return ""
1369 }
1370 for id := range state.pendingAsks {
1371 return id
1372 }
1373 return ""
1374 }
1375
1376 func (gw *BotGateway) handleSlashCommandCore(ctx context.Context, adapter Adapter, key string, msg InboundMessage) {
1377 switch {
1378 case strings.HasPrefix(msg.Text, "/stop"):
1379 var cancel context.CancelFunc
1380 gw.mu.Lock()
1381 if state, ok := gw.controllers[key]; ok {
1382 cancel = state.cancel
1383 }
1384 gw.mu.Unlock()
1385 if cancel != nil {
1386 cancel()
1387 }
1388 gw.sessions.ForceRelease(key)
1389 _ = gw.sendText(ctx, adapter, msg, "已停止当前任务。")
1390
1391 case strings.HasPrefix(msg.Text, "/new") || strings.HasPrefix(msg.Text, "/reset"):
1392 var cancel context.CancelFunc
1393 gw.mu.Lock()
1394 state, ok := gw.controllers[key]
1395 if ok {
1396 cancel = state.cancel
1397 }
1398 gw.mu.Unlock()
1399 if ok {
1400 if cancel != nil {
1401 cancel()
1402 }
1403 // NewSession refuses to rotate while a turn is running; the cancel
1404 // above is asynchronous, so give the turn a bounded window to
1405 // unwind before rotating.
1406 deadline := time.Now().Add(5 * time.Second)
1407 for state.ctrl.Running() && time.Now().Before(deadline) {
1408 time.Sleep(10 * time.Millisecond)
1409 }
1410 if err := state.ctrl.NewSession(); err != nil {
1411 gw.logger.Warn("new session failed", "err", err)
1412 gw.sessions.ForceRelease(key)
1413 _ = gw.sendText(ctx, adapter, msg, "新会话创建失败,请稍后重试。")
1414 return
1415 }
1416 if state.leases != nil && strings.TrimSpace(state.ctrl.SessionPath()) != "" {
1417 if err := rebindBotSessionWriteAuthority(state, state.ctrl.SessionPath()); err != nil {
1418 gw.logger.Warn("new session lease failed", "err", control.SessionInUseMessage(err))
1419 gw.unlinkAndCloseSessionState(key, state)
1420 gw.sessions.ForceRelease(key)
1421 _ = gw.sendText(ctx, adapter, msg, "新会话创建失败:无法取得写入权限。请关闭其他 Reasonix 窗口或进程后重试。")
1422 return
1423 }
1424 }
1425 // /new 后把旋转出的不可变身份钉为会话覆盖,避免下一条消息重新解析回旧绑定。
1426 gw.mu.Lock()
1427 if gw.controllers[key] == state {
1428 rotated := state.ctrl.SessionPath()
1429 override, exists := gw.sessionOverrides[key]
1430 if !exists {
1431 override = sessionRuntimeOverride{}
1432 }
1433 if identity, ok := state.ctrl.(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() {
1434 if ref, bound := identity.SessionRef(); bound {
1435 state.sessionRef = ref
1436 state.sessionPath = ""
1437 override.sessionPath = botSessionRefTarget(ref)
1438 }
1439 } else {
1440 state.sessionPath = rotated
1441 override.sessionPath = rotated
1442 }
1443 gw.sessionOverrides[key] = override
1444 }
1445 gw.mu.Unlock()
1446 gw.rememberSessionReady(msg, state.ctrl)
1447 }
1448 gw.sessions.ForceRelease(key)
1449 _ = gw.sendText(ctx, adapter, msg, "已开始新会话。")
1450
1451 case strings.HasPrefix(msg.Text, "/approve"):
1452 if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
1453 return
1454 }
1455 // 从消息中解析 approval ID
1456 parts := strings.Fields(msg.Text)
1457 if len(parts) < 2 {
1458 _ = gw.sendText(ctx, adapter, msg, "用法: /approve <id>")
1459 return
1460 }
1461 gw.mu.Lock()
1462 state, ok := gw.controllers[key]
1463 gw.mu.Unlock()
1464 if ok && state.ctrl != nil {
1465 // Recovery cards map allow → continue for older clients that only know Approve.
1466 if gw.pendingApprovalIsRecovery(key, parts[1]) {
1467 _ = state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionContinue, "")
1468 } else {
1469 state.ctrl.Approve(parts[1], true, false, false)
1470 }
1471 gw.forgetPendingApproval(key, parts[1])
1472 _ = gw.sendText(ctx, adapter, msg, "已批准。")
1473 } else {
1474 _ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待审批操作,请重新触发一次操作。")
1475 }
1476
1477 case strings.HasPrefix(msg.Text, "/deny"):
1478 if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
1479 return
1480 }
1481 parts := strings.Fields(msg.Text)
1482 if len(parts) < 2 {
1483 _ = gw.sendText(ctx, adapter, msg, "用法: /deny <id>")
1484 return
1485 }
1486 gw.mu.Lock()
1487 state, ok := gw.controllers[key]
1488 gw.mu.Unlock()
1489 if ok && state.ctrl != nil {
1490 if gw.pendingApprovalIsRecovery(key, parts[1]) {
1491 _ = state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionRevise, "")
1492 } else {
1493 state.ctrl.Approve(parts[1], false, false, false)
1494 }
1495 gw.forgetPendingApproval(key, parts[1])
1496 _ = gw.sendText(ctx, adapter, msg, "已拒绝。")
1497 } else {
1498 _ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待审批操作,请重新触发一次操作。")
1499 }
1500
1501 case strings.HasPrefix(msg.Text, "/recovery-continue-task"):
1502 if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
1503 return
1504 }
1505 parts := strings.Fields(msg.Text)
1506 if len(parts) < 2 {
1507 _ = gw.sendText(ctx, adapter, msg, "用法: /recovery-continue-task <id>")
1508 return
1509 }
1510 gw.mu.Lock()
1511 state, ok := gw.controllers[key]
1512 gw.mu.Unlock()
1513 if ok && state.ctrl != nil {
1514 if err := state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionContinueTask, ""); err != nil {
1515 _ = gw.sendText(ctx, adapter, msg, "确认失败: "+err.Error())
1516 return
1517 }
1518 gw.forgetPendingApproval(key, parts[1])
1519 _ = gw.sendText(ctx, adapter, msg, "已继续;本任务内同类操作将自动执行,范围扩大或风险升级仍会确认。")
1520 } else {
1521 _ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待确认操作。")
1522 }
1523
1524 case strings.HasPrefix(msg.Text, "/recovery-continue"):
1525 if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
1526 return
1527 }
1528 parts := strings.Fields(msg.Text)
1529 if len(parts) < 2 {
1530 _ = gw.sendText(ctx, adapter, msg, "用法: /recovery-continue <id>")
1531 return
1532 }
1533 gw.mu.Lock()
1534 state, ok := gw.controllers[key]
1535 gw.mu.Unlock()
1536 if ok && state.ctrl != nil {
1537 if err := state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionContinue, ""); err != nil {
1538 _ = gw.sendText(ctx, adapter, msg, "确认失败: "+err.Error())
1539 return
1540 }
1541 gw.forgetPendingApproval(key, parts[1])
1542 _ = gw.sendText(ctx, adapter, msg, "已继续。")
1543 } else {
1544 _ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待确认操作。")
1545 }
1546
1547 case strings.HasPrefix(msg.Text, "/recovery-revise"):
1548 if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
1549 return
1550 }
1551 parts := strings.Fields(msg.Text)
1552 if len(parts) < 2 {
1553 _ = gw.sendText(ctx, adapter, msg, "用法: /recovery-revise <id> [补充要求]")
1554 return
1555 }
1556 feedback := strings.TrimSpace(strings.Join(parts[2:], " "))
1557 gw.mu.Lock()
1558 state, ok := gw.controllers[key]
1559 gw.mu.Unlock()
1560 if ok && state.ctrl != nil {
1561 if err := state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionRevise, feedback); err != nil {
1562 _ = gw.sendText(ctx, adapter, msg, "修改方案失败: "+err.Error())
1563 return
1564 }
1565 gw.forgetPendingApproval(key, parts[1])
1566 _ = gw.sendText(ctx, adapter, msg, "已拒绝当前变更并注入修改要求。")
1567 } else {
1568 _ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的恢复检查点。")
1569 }
1570
1571 case strings.HasPrefix(msg.Text, "/recovery-stop"):
1572 // Backward compatibility for cards rendered by an older client: reject
1573 // the proposed mutation but leave task cancellation to ordinary /stop.
1574 if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
1575 return
1576 }
1577 parts := strings.Fields(msg.Text)
1578 if len(parts) < 2 {
1579 _ = gw.sendText(ctx, adapter, msg, "用法: /recovery-stop <id>")
1580 return
1581 }
1582 gw.mu.Lock()
1583 state, ok := gw.controllers[key]
1584 gw.mu.Unlock()
1585 if ok && state.ctrl != nil {
1586 if err := state.ctrl.ResolveRecovery(parts[1], agent.RecoveryActionRevise, "cancel this proposed action"); err != nil {
1587 _ = gw.sendText(ctx, adapter, msg, "取消变更失败: "+err.Error())
1588 return
1589 }
1590 gw.forgetPendingApproval(key, parts[1])
1591 _ = gw.sendText(ctx, adapter, msg, "已取消当前变更;如需停止整个任务,请使用 /stop。")
1592 } else {
1593 _ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的恢复检查点。")
1594 }
1595
1596 case strings.HasPrefix(msg.Text, "/answer"):
1597 parts := strings.Fields(msg.Text)
1598 if len(parts) < 3 {
1599 _ = gw.sendText(ctx, adapter, msg, "用法: /answer <id> <选项或 q1=选项;q2=选项>")
1600 return
1601 }
1602 askID := parts[1]
1603 rawAnswer := strings.TrimSpace(strings.Join(parts[2:], " "))
1604 gw.mu.Lock()
1605 state, ok := gw.controllers[key]
1606 var questions []event.AskQuestion
1607 if ok {
1608 questions = state.pendingAsks[askID]
1609 delete(state.pendingAsks, askID)
1610 if state.lastAskID == askID {
1611 state.lastAskID = ""
1612 for nextID := range state.pendingAsks {
1613 state.lastAskID = nextID
1614 break
1615 }
1616 }
1617 }
1618 gw.mu.Unlock()
1619 if !ok || state.ctrl == nil {
1620 _ = gw.sendText(ctx, adapter, msg, "没有找到当前会话。")
1621 return
1622 }
1623 answers := parseAskAnswers(questions, rawAnswer)
1624 state.ctrl.AnswerQuestion(askID, answers)
1625 _ = gw.sendText(ctx, adapter, msg, "已提交回答。")
1626
1627 case strings.HasPrefix(msg.Text, "/mode"):
1628 if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
1629 return
1630 }
1631 mode, statusOnly, ok := parseToolApprovalModeCommand(msg.Text)
1632 if !ok {
1633 _ = gw.sendText(ctx, adapter, msg, "用法: /mode read-only|workspace-write|danger-full-access|status")
1634 return
1635 }
1636 if statusOnly {
1637 _ = gw.sendText(ctx, adapter, msg, gw.toolApprovalModeStatusText(key, msg))
1638 return
1639 }
1640 persistErr := gw.setToolApprovalModeForMessage(key, msg, mode)
1641 text := toolApprovalModeChangedText(mode)
1642 if persistErr != nil {
1643 text += "\n当前会话已生效,但保存到设置失败:" + persistErr.Error()
1644 }
1645 _ = gw.sendText(ctx, adapter, msg, text)
1646
1647 case strings.HasPrefix(msg.Text, "/queue"):
1648 if reply, handled, kick := gw.handleQueueInboxCommand(ctx, key, msg); handled {
1649 _ = gw.sendText(ctx, adapter, msg, reply)
1650 if kick {
1651 gw.kickInbox(ctx, adapter, key, msg)
1652 }
1653 return
1654 }
1655 mode, clear, statusOnly, ok := parseQueueCommand(msg.Text)
1656 if !ok {
1657 _ = gw.sendText(ctx, adapter, msg, "用法: /queue steer|followup|collect|interrupt|status|list|show|delete|move|pause|resume|retry|default")
1658 return
1659 }
1660 if statusOnly {
1661 _ = gw.sendText(ctx, adapter, msg, gw.queueStatusText(key, msg))
1662 return
1663 }
1664 if clear {
1665 gw.sessions.ClearQueueMode(key)
1666 _ = gw.sendText(ctx, adapter, msg, "已恢复默认队列模式:"+queueModeLabel(gw.queueMode(key, msg))+"。")
1667 return
1668 }
1669 gw.sessions.SetQueueMode(key, mode)
1670 _ = gw.sendText(ctx, adapter, msg, "已切换队列模式:"+queueModeLabel(mode)+"。")
1671
1672 case slashCommandVerb(msg.Text) == "/projects":
1673 if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
1674 return
1675 }
1676 query := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/projects"))
1677 _ = gw.sendText(ctx, adapter, msg, formatBotProjects(gw.buildProjectIndex(), query, botProjectListLimit))
1678
1679 case slashCommandVerb(msg.Text) == "/use":
1680 if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
1681 return
1682 }
1683 _ = gw.sendText(ctx, adapter, msg, gw.handleUseProjectCommand(ctx, msg, msg.Text))
1684
1685 case slashCommandVerb(msg.Text) == "/model":
1686 if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
1687 return
1688 }
1689 _ = gw.sendText(ctx, adapter, msg, gw.handleModelCommand(ctx, msg, msg.Text))
1690
1691 case slashCommandVerb(msg.Text) == "/sessions":
1692 if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
1693 return
1694 }
1695 _ = gw.sendText(ctx, adapter, msg, gw.handleSessionsCommand(msg.Text))
1696
1697 case slashCommandVerb(msg.Text) == "/attach":
1698 if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
1699 return
1700 }
1701 _ = gw.sendText(ctx, adapter, msg, gw.handleAttachSessionCommand(ctx, msg, msg.Text))
1702
1703 case slashCommandVerb(msg.Text) == "/search":
1704 if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
1705 return
1706 }
1707 _ = gw.sendText(ctx, adapter, msg, gw.handleProjectSearchCommand(ctx, msg.Text))
1708
1709 case strings.HasPrefix(msg.Text, "/desktop"):
1710 // God view over the embedding desktop app: listing every live desktop
1711 // session and answering its approvals is strictly more power than the
1712 // per-session approver role, so gate on admin.
1713 if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
1714 return
1715 }
1716 _ = gw.sendText(ctx, adapter, msg, gw.handleDesktopCommand(msg))
1717
1718 case strings.HasPrefix(msg.Text, "/status"):
1719 active := gw.sessions.ActiveCount()
1720 pending := gw.sessions.PendingCount(key)
1721 gw.mu.Lock()
1722 sessions := len(gw.controllers)
1723 gw.mu.Unlock()
1724 mode := gw.currentToolApprovalMode(key, msg)
1725 _ = gw.sendText(ctx, adapter, msg, fmt.Sprintf("活跃任务数: %d\n保留会话数: %d\n工具审批模式: %s\n队列模式: %s\n当前会话排队: %d\n连接健康: %s", active, sessions, toolApprovalModeLabel(mode), queueModeLabel(gw.queueMode(key, msg)), pending, gw.adapterHealthSummaryText()))
1726
1727 case strings.HasPrefix(msg.Text, "/help"):
1728 _ = gw.sendText(ctx, adapter, msg, botHelpText())
1729 }
1730 }
1731
1732 func (gw *BotGateway) kickInbox(ctx context.Context, adapter Adapter, key string, fallback InboundMessage) {
1733 if gw.sessions.IsActive(key) {
1734 return
1735 }
1736 next := gw.nextInboxTurn(key, fallback)
1737 if next == nil {
1738 return
1739 }
1740 if !gw.sessions.TryAcquireIdle(key) {
1741 return
1742 }
1743 gw.turnWG.Go(func() {
1744 gw.runTurnItem(ctx, adapter, key, next.msg, next.itemID, nil)
1745 })
1746 }
1747
1748 func slashCommandVerb(text string) string {
1749 parts := strings.Fields(strings.TrimSpace(text))
1750 if len(parts) == 0 {
1751 return ""
1752 }
1753 return strings.ToLower(parts[0])
1754 }
1755
1756 func (gw *BotGateway) handleUseProjectCommand(ctx context.Context, msg InboundMessage, text string) string {
1757 key := BuildSessionKey(msg.Session())
1758 selector := parseUseProjectSelector(text)
1759 if selector == "" {
1760 return "用法: /use project <项目 id|名称|路径>,或 /use project default 恢复默认路由。"
1761 }
1762 if isDefaultBotSelector(selector) {
1763 switched, err := gw.setSessionRuntimeOverride(ctx, key, msg, sessionRuntimeOverride{}, false)
1764 if err != nil {
1765 return botRuntimeSwitchFailedText("切换项目")
1766 }
1767 if !switched {
1768 return botRuntimeSwitchBusyText()
1769 }
1770 return "已恢复当前远端会话的默认项目路由。下一条消息会按 bot 配置重新选择 workspace。"
1771 }
1772 projects := gw.buildProjectIndex()
1773 project, matches := resolveBotProject(projects, selector)
1774 if project.Root == "" {
1775 if len(matches) > 0 {
1776 return "匹配到多个项目,请使用项目 id:\n" + formatBotProjects(matches, "", botProjectListLimit)
1777 }
1778 return "没有匹配的项目。可先用 /projects 查看当前索引。"
1779 }
1780 switched, err := gw.setSessionRuntimeOverride(ctx, key, msg, sessionRuntimeOverride{
1781 channel: ChannelConfig{WorkspaceRoot: project.Root},
1782 label: "project:" + project.ID,
1783 }, true)
1784 if err != nil {
1785 return botRuntimeSwitchFailedText("切换项目")
1786 }
1787 if !switched {
1788 return botRuntimeSwitchBusyText()
1789 }
1790 return fmt.Sprintf("已将当前远端会话切到项目 %s %s。\n下一条消息将在 %s 中运行。", project.ID, project.Name, displayBotPath(project.Root))
1791 }
1792
1793 // handleModelCommand 处理 /model:无参查询当前会话生效模型,带参切换当前
1794 // 远端会话的模型(可选 --provider <name> 一并切换供应商)。模型以
1795 // provider/model 写入会话运行时覆盖,仅影响当前会话。
1796 func (gw *BotGateway) handleModelCommand(ctx context.Context, msg InboundMessage, text string) string {
1797 model, provider, statusOnly, ok := parseModelSelector(text)
1798 if !ok {
1799 return "用法: /model <模型名> [--provider <供应商>],或 /model 查看当前模型。"
1800 }
1801 if statusOnly {
1802 // 查询会话生效模型:走完整解析(覆盖 → 通道/路由 → 全局默认),否则
1803 // per-channel/per-connection 的模型设置(如钉钉直配 model)会被漏报。
1804 effective, _, _ := gw.sessionOptionsForMessage(msg)
1805 if strings.TrimSpace(effective) == "" {
1806 return "当前会话未指定模型,使用 bot 默认模型。"
1807 }
1808 return fmt.Sprintf("当前会话模型:%s", effective)
1809 }
1810 if strings.TrimSpace(model) == "" && strings.TrimSpace(provider) != "" {
1811 // 仅 provider 无模型名会存成无法解析的 "provider/" 空模型,下一条消息
1812 // 构建会话失败;要求显式模型名。
1813 return "用法: /model <模型名> [--provider <供应商>],或 /model 查看当前模型。"
1814 }
1815 key := BuildSessionKey(msg.Session())
1816 ref := strings.TrimSpace(model)
1817 if provider != "" {
1818 ref = strings.TrimSpace(provider) + "/" + ref
1819 }
1820 // 失败原子性:先校验模型可解析且已配置,无效则直接拒绝并保留当前
1821 // controller,不写入覆盖、不销毁旧会话(否则下一条消息构建失败)。
1822 if gw.cfg.ModelResolver != nil {
1823 if err := gw.cfg.ModelResolver(ref); err != nil {
1824 return fmt.Sprintf("模型 %s 不可用:%v", ref, err)
1825 }
1826 }
1827 // 复用 /use 的会话覆盖机制:只改 model,保留现有 workspace/tool 覆盖。
1828 var existing sessionRuntimeOverride
1829 gw.mu.Lock()
1830 existing = gw.sessionOverrides[key]
1831 gw.mu.Unlock()
1832 existing.channel.Model = ref
1833 switched, err := gw.setSessionRuntimeOverride(ctx, key, msg, existing, true)
1834 if err != nil {
1835 return botRuntimeSwitchFailedText("切换模型")
1836 }
1837 if !switched {
1838 return botRuntimeSwitchBusyText()
1839 }
1840 if provider != "" {
1841 return fmt.Sprintf("已将当前会话模型切换到 %s(供应商 %s)。", model, provider)
1842 }
1843 return fmt.Sprintf("已将当前会话模型切换到 %s。", ref)
1844 }
1845
1846 func parseModelSelector(text string) (model, provider string, statusOnly, ok bool) {
1847 parts := strings.Fields(text)
1848 if len(parts) == 0 || strings.ToLower(strings.TrimSpace(parts[0])) != "/model" {
1849 return "", "", false, false
1850 }
1851 if len(parts) == 1 {
1852 return "", "", true, true
1853 }
1854 rest := parts[1:]
1855 var models, providers []string
1856 for i := 0; i < len(rest); i++ {
1857 tok := rest[i]
1858 if strings.EqualFold(tok, "--provider") || strings.EqualFold(tok, "-p") {
1859 if i+1 < len(rest) {
1860 providers = append(providers, rest[i+1])
1861 i++
1862 }
1863 continue
1864 }
1865 if strings.HasPrefix(tok, "-") {
1866 continue
1867 }
1868 models = append(models, tok)
1869 }
1870 if len(models) == 0 {
1871 return "", strings.Join(providers, " "), false, true
1872 }
1873 return strings.Join(models, " "), strings.Join(providers, " "), false, true
1874 }
1875
1876 func parseUseProjectSelector(text string) string {
1877 parts := strings.Fields(text)
1878 if len(parts) < 2 || strings.ToLower(parts[0]) != "/use" {
1879 return ""
1880 }
1881 if len(parts) >= 3 && strings.EqualFold(parts[1], "project") {
1882 return strings.TrimSpace(strings.Join(parts[2:], " "))
1883 }
1884 return strings.TrimSpace(strings.Join(parts[1:], " "))
1885 }
1886
1887 func (gw *BotGateway) handleSessionsCommand(text string) string {
1888 query := parseSessionsQuery(text)
1889 projects := gw.buildProjectIndex()
1890 sessions := gw.buildSessionIndex(projects)
1891 return formatBotSessions(sessions, query, botSessionListLimit)
1892 }
1893
1894 func parseSessionsQuery(text string) string {
1895 parts := strings.Fields(text)
1896 if len(parts) <= 1 {
1897 return ""
1898 }
1899 if strings.EqualFold(parts[1], "search") {
1900 return strings.TrimSpace(strings.Join(parts[2:], " "))
1901 }
1902 return strings.TrimSpace(strings.Join(parts[1:], " "))
1903 }
1904
1905 func (gw *BotGateway) handleAttachSessionCommand(ctx context.Context, msg InboundMessage, text string) string {
1906 key := BuildSessionKey(msg.Session())
1907 selector := parseAttachSessionSelector(text)
1908 if selector == "" {
1909 return "用法: /attach session <会话 id|关键词|path:...>"
1910 }
1911 projects := gw.buildProjectIndex()
1912 sessions := gw.buildSessionIndex(projects)
1913 session, matches := resolveBotSession(sessions, selector)
1914 if session.ID == "" {
1915 if len(matches) > 0 {
1916 return "匹配到多个会话,请使用会话 id:\n" + formatBotSessions(matches, "", botSessionListLimit)
1917 }
1918 return "没有匹配的会话。可先用 /sessions search <关键词> 查看当前索引。"
1919 }
1920 if session.SessionPath == "" {
1921 return "这个会话没有可恢复的 path: transcript,暂时不能 attach。"
1922 }
1923 if info, err := os.Stat(session.SessionPath); err != nil || info.IsDir() {
1924 return "会话文件不可用或已被移动:" + displayBotPath(session.SessionPath)
1925 }
1926 workspaceRoot := session.WorkspaceRoot
1927 if workspaceRoot == "" {
1928 project := botProjectForPath(projects, session.SessionPath)
1929 workspaceRoot = project.Root
1930 }
1931 switched, err := gw.setSessionRuntimeOverride(ctx, key, msg, sessionRuntimeOverride{
1932 channel: ChannelConfig{WorkspaceRoot: workspaceRoot},
1933 sessionPath: session.SessionPath,
1934 label: "session:" + session.ID,
1935 }, true)
1936 if err != nil {
1937 return botRuntimeSwitchFailedText("attach")
1938 }
1939 if !switched {
1940 return botRuntimeSwitchBusyText()
1941 }
1942 projectName := firstNonEmptyString(session.ProjectName, botProjectName(workspaceRoot), "global")
1943 return fmt.Sprintf("已 attach 到会话 %s(%s)。\n下一条消息会从 %s 继续。", session.ID, projectName, displayBotPath(session.SessionPath))
1944 }
1945
1946 func parseAttachSessionSelector(text string) string {
1947 parts := strings.Fields(text)
1948 if len(parts) < 3 || !strings.EqualFold(parts[0], "/attach") || !strings.EqualFold(parts[1], "session") {
1949 return ""
1950 }
1951 return strings.TrimSpace(strings.Join(parts[2:], " "))
1952 }
1953
1954 func (gw *BotGateway) handleProjectSearchCommand(ctx context.Context, text string) string {
1955 parts := strings.Fields(text)
1956 if len(parts) < 3 || !strings.EqualFold(parts[1], "all") {
1957 return "用法: /search all <关键词>"
1958 }
1959 query := strings.TrimSpace(strings.Join(parts[2:], " "))
1960 searchCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
1961 defer cancel()
1962 results, err := searchBotProjects(searchCtx, gw.buildProjectIndex(), query, botSearchListLimit)
1963 if err != nil {
1964 return "检索失败:" + err.Error()
1965 }
1966 return formatBotProjectSearchResults(results, botSearchListLimit)
1967 }
1968
1969 func botSessionHasActiveWork(state *sessionState) bool {
1970 if state == nil || state.ctrl == nil {
1971 return false
1972 }
1973 status, ok := safeBotControllerRuntimeStatus(state.ctrl)
1974 if !ok {
1975 return true
1976 }
1977 return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
1978 }
1979
1980 func safeBotControllerRuntimeStatus(ctrl botController) (control.RuntimeStatus, bool) {
1981 if ctrl == nil {
1982 return control.RuntimeStatus{}, false
1983 }
1984 return ctrl.RuntimeStatus(), true
1985 }
1986
1987 func (gw *BotGateway) sessionRuntimeOverrideForMessage(msg InboundMessage) (sessionRuntimeOverride, bool) {
1988 key := BuildSessionKey(msg.Session())
1989 gw.mu.Lock()
1990 defer gw.mu.Unlock()
1991 override, ok := gw.sessionOverrides[key]
1992 return override, ok
1993 }
1994
1995 func isDefaultBotSelector(selector string) bool {
1996 switch strings.ToLower(strings.TrimSpace(selector)) {
1997 case "default", "reset", "inherit", "global", "none", "默认", "重置":
1998 return true
1999 default:
2000 return false
2001 }
2002 }
2003
2004 func parseQueueCommand(text string) (mode string, clear bool, statusOnly bool, ok bool) {
2005 parts := strings.Fields(text)
2006 if len(parts) == 0 || strings.ToLower(strings.TrimSpace(parts[0])) != "/queue" {
2007 return "", false, false, false
2008 }
2009 if len(parts) == 1 {
2010 return "", false, true, true
2011 }
2012 switch strings.ToLower(strings.TrimSpace(parts[1])) {
2013 case "status", "state", "show", "状态", "查看":
2014 return "", false, true, true
2015 case "default", "reset", "inherit", "默认", "重置":
2016 return "", true, false, true
2017 default:
2018 if normalized := NormalizeOptionalQueueMode(parts[1]); normalized != "" {
2019 return normalized, false, false, true
2020 }
2021 return "", false, false, false
2022 }
2023 }
2024
2025 func (gw *BotGateway) queueStatusText(key string, msg InboundMessage) string {
2026 inboxN := 0
2027 paused := false
2028 if api := gw.sessionAPI(key); api != nil {
2029 snap := api.InboxSnapshot()
2030 inboxN = len(snap.Items)
2031 paused = snap.Paused
2032 }
2033 return fmt.Sprintf("当前队列模式:%s\n持久化 Inbox: %d%s\n全局上限: %d\n溢出策略: 拒绝新消息(queue_drop 已弃用)\n用法:/queue steer|followup|collect|interrupt|status|list|show|delete|move|pause|resume|retry|default",
2034 queueModeLabel(gw.queueMode(key, msg)),
2035 inboxN,
2036 map[bool]string{true: " (paused)", false: ""}[paused],
2037 sessioninbox.DefaultMaxItems,
2038 )
2039 }
2040
2041 func queueModeLabel(mode string) string {
2042 switch NormalizeQueueMode(mode) {
2043 case QueueModeFollowup:
2044 return "逐条跟进"
2045 case QueueModeCollect:
2046 return "合并收集"
2047 case QueueModeInterrupt:
2048 return "打断重跑"
2049 default:
2050 return "即时补充"
2051 }
2052 }
2053
2054 func (gw *BotGateway) adapterHealthSummaryText() string {
2055 snapshots := gw.AdapterHealth()
2056 if len(snapshots) == 0 {
2057 return "未启动"
2058 }
2059 parts := make([]string, 0, len(snapshots))
2060 for _, h := range snapshots {
2061 label := strings.TrimSpace(h.ID)
2062 if label == "" {
2063 label = string(h.Platform)
2064 }
2065 status := strings.TrimSpace(h.Status)
2066 if status == "" {
2067 status = "unknown"
2068 }
2069 parts = append(parts, fmt.Sprintf("%s=%s", label, status))
2070 }
2071 return strings.Join(parts, ", ")
2072 }
2073
2074 func parseToolApprovalModeCommand(text string) (mode string, statusOnly bool, ok bool) {
2075 parts := strings.Fields(text)
2076 if len(parts) == 0 {
2077 return "", false, false
2078 }
2079 cmd := strings.ToLower(strings.TrimSpace(parts[0]))
2080 switch cmd {
2081 case "/mode":
2082 if len(parts) == 1 {
2083 return "", true, true
2084 }
2085 return parseToolApprovalModeArg(parts[1])
2086 default:
2087 return "", false, false
2088 }
2089 }
2090
2091 func parseToolApprovalModeArg(arg string) (mode string, statusOnly bool, ok bool) {
2092 switch strings.ToLower(strings.TrimSpace(arg)) {
2093 case "status", "state", "show", "状态", "查看":
2094 return "", true, true
2095 case "danger-full-access", "full", "full-access", "完全权限":
2096 return control.ToolApprovalDangerFullAccess, false, true
2097 case "read-only", "readonly", "ask", "仅可查看":
2098 return control.ToolApprovalReadOnly, false, true
2099 case "workspace-write", "workspace", "auto", "yolo", "工作区内修改":
2100 return control.ToolApprovalWorkspaceWrite, false, true
2101 default:
2102 return "", false, false
2103 }
2104 }
2105
2106 func (gw *BotGateway) setToolApprovalModeForMessage(key string, msg InboundMessage, mode string) error {
2107 mode = normalizeBotToolApprovalMode(mode)
2108 var ctrl botController
2109
2110 gw.mu.Lock()
2111 if state, ok := gw.controllers[key]; ok {
2112 ctrl = state.ctrl
2113 }
2114 gw.updateToolApprovalModeDefaultLocked(msg, mode)
2115 gw.mu.Unlock()
2116
2117 if ctrl != nil {
2118 ctrl.SetToolApprovalMode(mode)
2119 }
2120 if gw.cfg.OnToolApprovalModeChange != nil {
2121 return gw.cfg.OnToolApprovalModeChange(msg, mode)
2122 }
2123 return nil
2124 }
2125
2126 func (gw *BotGateway) updateToolApprovalModeDefaultLocked(msg InboundMessage, mode string) {
2127 if id := strings.TrimSpace(msg.ConnectionID); id != "" {
2128 if gw.cfg.ConnectionChannels == nil {
2129 gw.cfg.ConnectionChannels = make(map[string]ChannelConfig)
2130 }
2131 channel := gw.cfg.ConnectionChannels[id]
2132 channel.ToolApprovalMode = mode
2133 gw.cfg.ConnectionChannels[id] = channel
2134 return
2135 }
2136 if msg.Platform != "" {
2137 if gw.cfg.Channels == nil {
2138 gw.cfg.Channels = make(map[Platform]ChannelConfig)
2139 }
2140 channel := gw.cfg.Channels[msg.Platform]
2141 channel.ToolApprovalMode = mode
2142 gw.cfg.Channels[msg.Platform] = channel
2143 return
2144 }
2145 gw.cfg.ToolApprovalMode = mode
2146 }
2147
2148 func (gw *BotGateway) currentToolApprovalMode(key string, msg InboundMessage) string {
2149 var ctrl botController
2150 gw.mu.Lock()
2151 if state, ok := gw.controllers[key]; ok {
2152 ctrl = state.ctrl
2153 }
2154 gw.mu.Unlock()
2155 if ctrl != nil {
2156 return ctrl.ToolApprovalMode()
2157 }
2158 _, _, mode := gw.sessionOptionsForMessage(msg)
2159 return mode
2160 }
2161
2162 func (gw *BotGateway) toolApprovalModeStatusText(key string, msg InboundMessage) string {
2163 mode := gw.currentToolApprovalMode(key, msg)
2164 return fmt.Sprintf("当前权限:%s\n用法:/mode read-only|workspace-write|danger-full-access|status", toolApprovalModeLabel(mode))
2165 }
2166
2167 func toolApprovalModeChangedText(mode string) string {
2168 switch normalizeBotToolApprovalMode(mode) {
2169 case control.ToolApprovalDangerFullAccess:
2170 return "已切换为完全权限:普通工具审批将自动放行,显式禁止仍然生效。"
2171 case control.ToolApprovalWorkspaceWrite:
2172 return "已切换为工作区内修改:工作区与会话临时目录可写,越界操作需要授权。"
2173 default:
2174 return "已切换为仅可查看:读取可直接执行,写入与外部副作用需要授权。"
2175 }
2176 }
2177
2178 func toolApprovalModeLabel(mode string) string {
2179 switch normalizeBotToolApprovalMode(mode) {
2180 case control.ToolApprovalDangerFullAccess:
2181 return "完全权限"
2182 case control.ToolApprovalWorkspaceWrite:
2183 return "工作区内修改"
2184 default:
2185 return "仅可查看"
2186 }
2187 }
2188
2189 func (gw *BotGateway) runTurn(ctx context.Context, adapter Adapter, key string, msg InboundMessage, cleanup func()) {
2190 gw.runTurnItem(ctx, adapter, key, msg, "", cleanup)
2191 }
2192
2193 func (gw *BotGateway) runTurnItem(ctx context.Context, adapter Adapter, key string, msg InboundMessage, inboxItemID string, cleanup func()) {
2194 gw.logger.Info("bot turn started", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
2195 defer gw.finishTurnItem(ctx, adapter, key, msg, cleanup)
2196
2197 state := gw.sessionForNewTurn(ctx, adapter, key, msg)
2198 if state == nil {
2199 return
2200 }
2201 gw.rememberSessionReady(msg, state.ctrl)
2202
2203 // 构建输入文本:群聊中在消息前加上发送者名,并把 IM 媒体保存为 @附件引用。
2204 input := msg.Text
2205 if inboxItemID == "" {
2206 input = gw.inputTextWithMedia(ctx, adapter, msg, state)
2207 }
2208 if inboxItemID == "" && msg.ChatType == ChatGroup {
2209 userName := strings.TrimSpace(msg.UserName)
2210 if msg.ResolveUserName != nil {
2211 if resolved := strings.TrimSpace(msg.ResolveUserName(ctx)); resolved != "" {
2212 userName = resolved
2213 }
2214 }
2215 input = fmt.Sprintf("[%s] %s", userName, input)
2216 }
2217
2218 // 发送"正在输入"状态
2219 _ = adapter.SendTyping(ctx, msg.ChatID)
2220
2221 // 创建事件渲染 sink
2222 sink := newRenderSink(
2223 ctx,
2224 adapter,
2225 msg.ConnectionID,
2226 msg.Domain,
2227 msg.ChatID,
2228 msg.ChatType,
2229 msg.UserID,
2230 msg.MessageID,
2231 gw.logger,
2232 func(approval event.Approval) {
2233 gw.mu.Lock()
2234 if state.pendingApprovals == nil {
2235 state.pendingApprovals = make(map[string]event.Approval)
2236 }
2237 state.pendingApprovals[approval.ID] = approval
2238 state.lastApprovalID = approval.ID
2239 gw.mu.Unlock()
2240 },
2241 func(ask event.Ask) {
2242 gw.mu.Lock()
2243 if state.pendingAsks == nil {
2244 state.pendingAsks = make(map[string][]event.AskQuestion)
2245 }
2246 state.pendingAsks[ask.ID] = ask.Questions
2247 state.lastAskID = ask.ID
2248 gw.mu.Unlock()
2249 },
2250 )
2251 // Finish initializing the sink before publishing it as the live target: once
2252 // setTarget runs, other goroutines can reach this sink via state.sink.Emit.
2253 sink.ctrl = state.ctrl
2254 state.sink.setTarget(sink)
2255 defer state.sink.setTarget(nil)
2256
2257 // 创建带取消的 context
2258 turnCtx, cancel := context.WithCancel(ctx)
2259 defer cancel()
2260
2261 gw.mu.Lock()
2262 live := gw.controllers[key] == state
2263 if live {
2264 state.cancel = cancel
2265 }
2266 state.lastActive = time.Now()
2267 gw.mu.Unlock()
2268 if !live {
2269 // The session was closed (gateway stop or runtime rebuild) after this
2270 // turn picked it up; a cancel published now would never be consumed, so
2271 // abort the turn instead of running it uncancellable.
2272 cancel()
2273 }
2274
2275 // 运行一轮对话
2276 var err error
2277 if inboxItemID == "" {
2278 err = state.ctrl.RunTurn(turnCtx, input)
2279 } else if api, ok := state.ctrl.(interface {
2280 RunInboxTurn(context.Context, string) error
2281 }); ok {
2282 err = api.RunInboxTurn(turnCtx, inboxItemID)
2283 } else {
2284 err = fmt.Errorf("controller cannot run durable inbox item")
2285 }
2286 sink.Emit(event.Event{Kind: event.TurnDone, Err: err})
2287 if err != nil {
2288 gw.logger.Warn("turn error", "session", key[:8], "err", err)
2289 return
2290 }
2291 gw.logger.Info("bot turn completed", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
2292 }
2293
2294 func (gw *BotGateway) inputTextWithMedia(ctx context.Context, adapter Adapter, msg InboundMessage, state *sessionState) string {
2295 input := msg.Text
2296 if len(msg.MediaURLs) == 0 && len(msg.Media) == 0 {
2297 return input
2298 }
2299 workspaceRoot := ""
2300 if state != nil && state.ctrl != nil {
2301 workspaceRoot = state.ctrl.WorkspaceRoot()
2302 }
2303 if strings.TrimSpace(workspaceRoot) == "" {
2304 _, workspaceRoot, _ = gw.sessionOptionsForMessage(msg)
2305 }
2306 refs, errs := saveInboundMedia(ctx, workspaceRoot, msg.MediaURLs)
2307 itemRefs, fallbacks, itemErrs := saveInboundMediaItems(ctx, workspaceRoot, msg.Media)
2308 refs = append(refs, itemRefs...)
2309 errs = append(errs, itemErrs...)
2310 if len(errs) > 0 {
2311 gw.logger.Warn("bot media attachment failed", "platform", msg.Platform, "chat", hashID(msg.ChatID), "errors", len(errs))
2312 _ = gw.sendText(ctx, adapter, msg, fmt.Sprintf("有 %d 个附件保存失败;我会先处理可用内容。", len(errs)))
2313 }
2314 return appendMediaRefs(appendMediaFallbacks(input, fallbacks), refs)
2315 }
2316
2317 func (gw *BotGateway) getOrCreateSession(ctx context.Context, key string, msg InboundMessage) *sessionState {
2318 profile := gw.sessionProfileForMessage(msg)
2319 switch state, claim := gw.claimSession(key, msg, profile); claim {
2320 case sessionReused:
2321 safeBotSetToolApprovalMode(state.ctrl, profile.toolApprovalMode)
2322 gw.logger.Info("bot session reused", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
2323 return state
2324 case sessionChangeDeferred:
2325 safeBotSetToolApprovalMode(state.ctrl, profile.toolApprovalMode)
2326 gw.logger.Warn("bot session runtime change deferred while work is active", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
2327 return state
2328 case sessionRetired:
2329 gw.closeSessionState(state)
2330 gw.logger.Warn("bot session runtime changed; rebuilding", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8], "old_workspace_set", strings.TrimSpace(state.workspaceRoot) != "", "new_workspace_set", profile.workspaceRoot != "", "old_model", state.model, "new_model", profile.model)
2331 }
2332
2333 // Create the lease owner first so recovery or intentional transitions can
2334 // move ownership before the controller commits to the target path.
2335 sessionSink := &sessionEventSink{}
2336 leases := control.NewSessionLeaseKeeper()
2337 state := &sessionState{
2338 sink: sessionSink,
2339 leases: leases,
2340 platform: msg.Platform,
2341 connectionID: strings.TrimSpace(msg.ConnectionID),
2342 model: profile.model,
2343 workspaceRoot: profile.workspaceRoot,
2344 toolApprovalMode: profile.toolApprovalMode,
2345 sessionPath: profile.sessionPath,
2346 pendingAsks: make(map[string][]event.AskQuestion),
2347 createdAt: time.Now(),
2348 lastActive: time.Now(),
2349 }
2350 state.onSessionTransition = gw.botSessionTransitionHandler(key, msg, state)
2351 gw.logger.Info("bot session creating", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8], "model", profile.model, "workspace_set", profile.workspaceRoot != "", "tool_approval_mode", profile.toolApprovalMode)
2352 ctrl, err := gw.buildBotController(ctx, boot.Options{
2353 Model: profile.model,
2354 MaxSteps: gw.cfg.MaxSteps,
2355 MaxStepsKey: "bot.max_steps",
2356 RequireKey: true,
2357 Sink: sessionSink,
2358 StatsSource: "bot",
2359 WorkspaceRoot: profile.workspaceRoot,
2360 SessionDir: botSessionDir(profile.workspaceRoot),
2361 ApprovalTimeout: gw.approvalTimeout(),
2362 OnSessionRecovered: gw.botSessionRecoveredHandler(key, msg, state),
2363 OnSessionTransition: state.onSessionTransition,
2364 })
2365 if err != nil {
2366 leases.Release()
2367 gw.logger.Error("build controller failed", "err", secrets.RedactError(err))
2368 return nil
2369 }
2370 state.ctrl = ctrl
2371 if identity, ok := any(ctrl).(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() {
2372 ref, bindErr := bindBotSessionIdentity(ctx, identity, profile, msg)
2373 if bindErr != nil && (profile.sessionRefOptional || profile.sessionPathOptional) {
2374 gw.logger.Warn("mapped bot session unavailable; starting fresh", "err", bindErr)
2375 ref, bindErr = identity.BindFreshSession(ctx, "")
2376 state.mappingDegraded = bindErr == nil
2377 }
2378 if bindErr != nil {
2379 ctrl.Close()
2380 leases.Release()
2381 gw.logger.Error("bind bot v3 session failed", "err", secrets.RedactError(bindErr))
2382 return nil
2383 }
2384 state.sessionRef = ref
2385 state.sessionPath = ""
2386 } else if profile.sessionPath != "" {
2387 // A mapped binding degrades to a fresh session on failure; only an
2388 // explicit /attach is allowed to hard-fail the message, because the
2389 // user named that exact session.
2390 degrade := func(reason string, err error) bool {
2391 if !profile.sessionPathOptional {
2392 return false
2393 }
2394 gw.logger.Warn("mapped bot session unavailable; starting fresh", "reason", reason, "session_path", profile.sessionPath, "err", err)
2395 profile.sessionPath = ""
2396 state.sessionPath = ""
2397 state.mappingDegraded = true
2398 return true
2399 }
2400 if err := leases.Rebind(profile.sessionPath); err != nil {
2401 if !degrade("lease held elsewhere", err) {
2402 ctrl.Close()
2403 leases.Release()
2404 gw.logger.Error("attached bot session is in use", "err", control.SessionInUseMessage(err))
2405 return nil
2406 }
2407 } else if loaded, err := agent.LoadSession(profile.sessionPath); err != nil {
2408 if os.IsNotExist(err) && profile.sessionPathOptional {
2409 // First message on a deterministic chat→file path: pin the new
2410 // conversation there instead of orphaning a timestamp file.
2411 ctrl.SetSessionPath(profile.sessionPath)
2412 } else if !degrade("load failed", err) {
2413 ctrl.Close()
2414 leases.Release()
2415 if os.IsNotExist(err) {
2416 gw.logger.Error("attached bot session missing", "session_path", profile.sessionPath)
2417 } else {
2418 gw.logger.Error("attached bot session load failed", "session_path", profile.sessionPath, "err", err)
2419 }
2420 return nil
2421 }
2422 } else {
2423 ctrl.Resume(loaded, profile.sessionPath)
2424 }
2425 }
2426 ctrl.EnableInteractiveApproval()
2427 ctrl.SetToolApprovalMode(profile.toolApprovalMode)
2428 if identity, ok := any(ctrl).(control.IdentityLifecycle); !ok || !identity.UsesExclusiveSession() {
2429 ctrl.EnsureSessionPath()
2430 if err := rebindBotSessionWriteAuthority(state, ctrl.SessionPath()); err != nil {
2431 ctrl.Close()
2432 leases.Release()
2433 gw.logger.Error("bot session lease failed", "err", control.SessionInUseMessage(err))
2434 return nil
2435 }
2436 }
2437 var replace *sessionState
2438 gw.mu.Lock()
2439 // Re-check under the lock: while we were off-lock in boot.Build, a second
2440 // message for the same key may have built and registered its own session.
2441 // Reuse it only when it still targets this message's runtime profile.
2442 if existing, ok := gw.controllers[key]; ok {
2443 if sessionStateMatchesRuntime(existing, profile) {
2444 updateSessionStateRuntime(existing, msg, profile)
2445 gw.mu.Unlock()
2446 ctrl.Close()
2447 leases.Release()
2448 safeBotSetToolApprovalMode(existing.ctrl, profile.toolApprovalMode)
2449 gw.logger.Info("bot session built concurrently; discarding duplicate", "platform", msg.Platform, "chat", hashID(msg.ChatID), "session", key[:8])
2450 return existing
2451 }
2452 delete(gw.controllers, key)
2453 replace = existing
2454 }
2455 gw.controllers[key] = state
2456 gw.mu.Unlock()
2457 gw.closeSessionState(replace)
2458
2459 gw.logger.Info("bot session created", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
2460 return state
2461 }
2462
2463 func updateSessionStateRuntime(state *sessionState, msg InboundMessage, profile sessionRuntimeProfile) {
2464 if state == nil {
2465 return
2466 }
2467 if state.connectionID == "" {
2468 state.connectionID = strings.TrimSpace(msg.ConnectionID)
2469 }
2470 if state.platform == "" {
2471 state.platform = msg.Platform
2472 }
2473 state.model = profile.model
2474 state.workspaceRoot = profile.workspaceRoot
2475 state.toolApprovalMode = profile.toolApprovalMode
2476 state.sessionPath = profile.sessionPath
2477 if profile.sessionRef.SessionID != "" {
2478 if profile.sessionRef.HostID == "" {
2479 profile.sessionRef.HostID = state.sessionRef.HostID
2480 }
2481 state.sessionRef = profile.sessionRef
2482 }
2483 state.lastActive = time.Now()
2484 }
2485
2486 func (gw *BotGateway) sessionProfileForMessage(msg InboundMessage) sessionRuntimeProfile {
2487 override, enabled := gw.sessionRuntimeOverrideForMessage(msg)
2488 return gw.sessionProfileForResolvedOverride(msg, override, enabled)
2489 }
2490
2491 func (gw *BotGateway) sessionProfileForResolvedOverride(msg InboundMessage, override sessionRuntimeOverride, enabled bool) sessionRuntimeProfile {
2492 model, workspaceRoot, toolApprovalMode := gw.sessionOptionsForResolvedOverride(msg, override, enabled)
2493 var sessionPath string
2494 var sessionRef session.SessionRef
2495 sessionPathOptional := false
2496 sessionRefOptional := false
2497 if enabled {
2498 if ref, ok := parseBotSessionRefTarget(override.sessionPath); ok {
2499 sessionRef = ref
2500 } else {
2501 sessionPath = override.sessionPath
2502 }
2503 }
2504 // A persisted session_mappings binding is the durable chat→session link
2505 // the desktop writes into the connection config. Without consuming it
2506 // here, every gateway restart or runtime rebuild opened a brand-new
2507 // session file for the chat and the configured binding was display-only
2508 // (#6917, #6934).
2509 if sessionPath == "" && sessionRef.SessionID == "" {
2510 if mapped := gw.sessionMappingTargetForMessage(msg); mapped != "" {
2511 if ref, ok := parseBotSessionRefTarget(mapped); ok {
2512 sessionRef = ref
2513 sessionRefOptional = true
2514 } else if path := botSessionPathFromTarget(mapped); path != "" {
2515 sessionPath = path
2516 sessionPathOptional = true
2517 }
2518 }
2519 }
2520 // No explicit binding: pin a deterministic per-chat file so the chat reuses
2521 // one conversation across restarts (dsh-dingtalk-channel's `ding-<chatId>`
2522 // analogue). Optional, mirroring mapping degrade semantics.
2523 if sessionPath == "" && sessionRef.SessionID == "" && strings.TrimSpace(msg.ChatID) != "" {
2524 sessionRef = session.SessionRef{SessionID: "bot-" + BuildSessionKey(msg.Session())}
2525 sessionRefOptional = true
2526 }
2527 return sessionRuntimeProfile{
2528 model: strings.TrimSpace(model),
2529 workspaceRoot: strings.TrimSpace(workspaceRoot),
2530 toolApprovalMode: normalizeBotToolApprovalMode(toolApprovalMode),
2531 sessionPath: canonicalBotPath(sessionPath),
2532 sessionPathOptional: sessionPathOptional,
2533 sessionRef: sessionRef,
2534 sessionRefOptional: sessionRefOptional,
2535 }
2536 }
2537
2538 // sessionMappingPathForMessage resolves the persisted session_mappings entry
2539 // for a message to an existing session file. Only bindings that resolve to a
2540 // present, readable file participate — a moved or deleted target quietly
2541 // degrades to normal session creation rather than blocking the chat.
2542 func (gw *BotGateway) sessionMappingTargetForMessage(msg InboundMessage) string {
2543 gw.mu.Lock()
2544 var mappings []SessionMapping
2545 if msg.ConnectionID != "" {
2546 if channel, ok := gw.cfg.ConnectionChannels[msg.ConnectionID]; ok {
2547 mappings = channel.SessionMappings
2548 }
2549 }
2550 if len(mappings) == 0 {
2551 if channel, ok := gw.cfg.Channels[msg.Platform]; ok {
2552 mappings = channel.SessionMappings
2553 }
2554 }
2555 gw.mu.Unlock()
2556 mapping, ok := matchingSessionMapping(mappings, msg)
2557 if !ok {
2558 return ""
2559 }
2560 target := strings.TrimSpace(mapping.SessionID)
2561 if target == "" {
2562 target = strings.TrimSpace(mapping.SessionSource)
2563 }
2564 if _, ok := parseBotSessionRefTarget(target); ok {
2565 return target
2566 }
2567 path := botSessionPathFromTarget(target)
2568 if path == "" {
2569 return ""
2570 }
2571 if info, err := os.Stat(path); err != nil || info.IsDir() {
2572 return ""
2573 }
2574 return path
2575 }
2576
2577 func sessionStateMatchesRuntime(state *sessionState, profile sessionRuntimeProfile) bool {
2578 if state == nil || state.ctrl == nil {
2579 return false
2580 }
2581 if stateModel := strings.TrimSpace(state.model); stateModel != "" && profile.model != "" && stateModel != profile.model {
2582 return false
2583 }
2584 stateRoot := strings.TrimSpace(state.workspaceRoot)
2585 wantRoot := strings.TrimSpace(profile.workspaceRoot)
2586 if stateRoot == "" {
2587 root, ok := safeBotControllerWorkspaceRoot(state.ctrl)
2588 if ok {
2589 stateRoot = strings.TrimSpace(root)
2590 } else if wantRoot != "" {
2591 return false
2592 }
2593 }
2594 if stateRoot != wantRoot {
2595 return false
2596 }
2597 // A state that already degraded off its mapped session keeps running on
2598 // its fresh path even though the profile re-resolves the mapping each
2599 // message; rebuilding here would spawn a new session per message while the
2600 // mapped file stays unavailable.
2601 if profile.sessionPathOptional && state.mappingDegraded {
2602 return true
2603 }
2604 if profile.sessionRefOptional && state.mappingDegraded {
2605 return true
2606 }
2607 if profile.sessionRef.SessionID != "" {
2608 if state.sessionRef.SessionID != profile.sessionRef.SessionID {
2609 return false
2610 }
2611 if profile.sessionRef.HostID != "" && state.sessionRef.HostID != profile.sessionRef.HostID {
2612 return false
2613 }
2614 identity, ok := state.ctrl.(control.IdentityLifecycle)
2615 if !ok {
2616 return false
2617 }
2618 ref, bound := identity.SessionRef()
2619 return bound && ref == state.sessionRef
2620 }
2621 if canonicalBotPath(state.sessionPath) != canonicalBotPath(profile.sessionPath) {
2622 return false
2623 }
2624 if profile.sessionPath != "" && canonicalBotPath(state.ctrl.SessionPath()) != canonicalBotPath(profile.sessionPath) {
2625 return false
2626 }
2627 return true
2628 }
2629
2630 func safeBotControllerWorkspaceRoot(ctrl botController) (string, bool) {
2631 if ctrl == nil {
2632 return "", false
2633 }
2634 return ctrl.WorkspaceRoot(), true
2635 }
2636
2637 func safeBotSetToolApprovalMode(ctrl botController, mode string) {
2638 if ctrl == nil {
2639 return
2640 }
2641 ctrl.SetToolApprovalMode(mode)
2642 }
2643
2644 // defaultBotApprovalTimeout caps how long a bot session waits for a remote
2645 // user's approval/ask reply before treating it as denied, so an abandoned
2646 // prompt (or a dropped IM event) can't leave the session wedged forever
2647 // (#4626, #4402). 30 minutes is generous for a human reply yet bounded.
2648 const defaultBotApprovalTimeout = 30 * time.Minute
2649
2650 // approvalTimeout resolves the configured bot approval wait: zero uses the
2651 // bounded default; a negative value opts out (wait indefinitely).
2652 func (gw *BotGateway) approvalTimeout() time.Duration {
2653 switch {
2654 case gw.cfg.ApprovalTimeout < 0:
2655 return 0
2656 case gw.cfg.ApprovalTimeout == 0:
2657 return defaultBotApprovalTimeout
2658 default:
2659 return gw.cfg.ApprovalTimeout
2660 }
2661 }
2662
2663 func botSessionDir(workspaceRoot string) string {
2664 if strings.TrimSpace(workspaceRoot) == "" {
2665 return config.SessionDir()
2666 }
2667 if dir := config.ProjectSessionDir(workspaceRoot); dir != "" {
2668 return dir
2669 }
2670 return config.SessionDir()
2671 }
2672
2673 // BotSessionPathForChat derives the deterministic per-chat session file for a
2674 // message with no persisted mapping or /attach binding. Reusing BuildSessionKey
2675 // (already a stable chat-identity hash) makes the same chat hit one file across
2676 // restarts — the chat-side analogue of dsh-dingtalk-channel's `ding-<chatId>`
2677 // scheme. Empty when the message has no stable chat identity.
2678 func BotSessionPathForChat(sessionDir string, src SessionSource) string {
2679 if strings.TrimSpace(sessionDir) == "" || strings.TrimSpace(src.ChatID) == "" {
2680 return ""
2681 }
2682 return filepath.Join(sessionDir, "bot-"+BuildSessionKey(src)+".jsonl")
2683 }
2684
2685 func (gw *BotGateway) rememberSessionReady(msg InboundMessage, ctrl botController) {
2686 if gw.cfg.OnSessionReady == nil || ctrl == nil {
2687 return
2688 }
2689 if identity, ok := ctrl.(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() {
2690 if ref, bound := identity.SessionRef(); bound {
2691 gw.rememberSessionTarget(msg, botSessionRefTarget(ref))
2692 return
2693 }
2694 }
2695 gw.rememberSessionPath(msg, ctrl.SessionPath())
2696 }
2697
2698 func (gw *BotGateway) rememberSessionPath(msg InboundMessage, sessionPath string) {
2699 gw.rememberSessionTarget(msg, botSessionTarget(sessionPath))
2700 }
2701
2702 func (gw *BotGateway) rememberSessionTarget(msg InboundMessage, sessionID string) {
2703 if gw.cfg.OnSessionReady == nil {
2704 return
2705 }
2706 if sessionID == "" {
2707 return
2708 }
2709 if err := gw.cfg.OnSessionReady(msg, sessionID); err != nil {
2710 gw.logger.Warn("remember bot session failed", "platform", msg.Platform, "connection", msg.ConnectionID, "err", err)
2711 }
2712 }
2713
2714 func botSessionRefTarget(ref session.SessionRef) string {
2715 if strings.TrimSpace(ref.HostID) == "" || strings.TrimSpace(ref.SessionID) == "" {
2716 return ""
2717 }
2718 return "session:" + ref.HostID + ":" + ref.SessionID
2719 }
2720
2721 func parseBotSessionRefTarget(target string) (session.SessionRef, bool) {
2722 target = strings.TrimSpace(target)
2723 if !strings.HasPrefix(target, "session:") {
2724 return session.SessionRef{}, false
2725 }
2726 parts := strings.SplitN(strings.TrimPrefix(target, "session:"), ":", 2)
2727 if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
2728 return session.SessionRef{}, false
2729 }
2730 return session.SessionRef{HostID: strings.TrimSpace(parts[0]), SessionID: strings.TrimSpace(parts[1])}, true
2731 }
2732
2733 // botSessionRecoveredHandler keeps the controller path, its writer lease, and
2734 // the remote-to-session mapping on the same recovery generation. The lease
2735 // handoff runs first and is failure-atomic: if the recovery path is already
2736 // owned, the controller stays on the original path and the old lease remains
2737 // held. Mapping updates are limited to this exact sessionState so a late
2738 // callback from a retired controller cannot overwrite its replacement.
2739 func (gw *BotGateway) botSessionRecoveredHandler(key string, msg InboundMessage, state *sessionState) func(control.SessionRecoveryInfo) error {
2740 return func(info control.SessionRecoveryInfo) error {
2741 if state == nil || state.leases == nil {
2742 return nil
2743 }
2744 // Keep the lease handoff and mapping publication atomic with respect to
2745 // state retirement. In particular, never let a callback that outlives
2746 // Stop reacquire a lease after closeSessionState has released it.
2747 state.lifecycleMu.Lock()
2748 defer state.lifecycleMu.Unlock()
2749 if state.retired {
2750 return errBotSessionRetired
2751 }
2752 if err := state.leases.HandleSessionRecovered(info); err != nil {
2753 return err
2754 }
2755
2756 originalPath := canonicalBotPath(info.OriginalPath)
2757 recoveryPath := canonicalBotPath(info.RecoveryPath)
2758 live := false
2759 gw.mu.Lock()
2760 if gw.controllers[key] == state {
2761 live = true
2762 if canonicalBotPath(state.sessionPath) == originalPath {
2763 state.sessionPath = recoveryPath
2764 }
2765 if override, ok := gw.sessionOverrides[key]; ok && canonicalBotPath(override.sessionPath) == originalPath {
2766 override.sessionPath = recoveryPath
2767 gw.sessionOverrides[key] = override
2768 }
2769 }
2770 gw.mu.Unlock()
2771
2772 if live {
2773 gw.rememberSessionPath(msg, recoveryPath)
2774 }
2775 return nil
2776 }
2777 }
2778
2779 func botSessionTarget(sessionPath string) string {
2780 sessionPath = strings.TrimSpace(sessionPath)
2781 if sessionPath == "" {
2782 return ""
2783 }
2784 return "path:" + sessionPath
2785 }
2786
2787 func (gw *BotGateway) sessionOptionsForMessage(msg InboundMessage) (model string, workspaceRoot string, toolApprovalMode string) {
2788 override, enabled := gw.sessionRuntimeOverrideForMessage(msg)
2789 return gw.sessionOptionsForResolvedOverride(msg, override, enabled)
2790 }
2791
2792 func (gw *BotGateway) sessionOptionsForResolvedOverride(msg InboundMessage, override sessionRuntimeOverride, enabled bool) (model string, workspaceRoot string, toolApprovalMode string) {
2793 // cfg.ToolApprovalMode / Channels / ConnectionChannels are rewritten under
2794 // gw.mu at runtime (/mode, UpdateConnectionToolApprovalMode), so snapshot them
2795 // under a short lock and resolve outside it. Copying the ChannelConfig value is enough: writers
2796 // replace whole map entries and never mutate SessionMappings in place.
2797 gw.mu.Lock()
2798 model = gw.cfg.Model
2799 workspaceRoot = gw.cfg.WorkspaceRoot
2800 toolApprovalMode = normalizeBotToolApprovalMode(gw.cfg.ToolApprovalMode)
2801 var connChannel ChannelConfig
2802 connOK := false
2803 if msg.ConnectionID != "" {
2804 connChannel, connOK = gw.cfg.ConnectionChannels[msg.ConnectionID]
2805 }
2806 platChannel, platOK := gw.cfg.Channels[msg.Platform]
2807 gw.mu.Unlock()
2808
2809 var mappings []SessionMapping
2810 if connOK {
2811 applyBotChannelOptions(connChannel, &model, &workspaceRoot, &toolApprovalMode)
2812 mappings = connChannel.SessionMappings
2813 if mapping, ok := matchingSessionMapping(mappings, msg); ok {
2814 workspaceRoot = workspaceRootForSessionMapping(mapping, workspaceRoot)
2815 }
2816 model, workspaceRoot, toolApprovalMode = gw.applyRouteOptions(msg, model, workspaceRoot, toolApprovalMode)
2817 if enabled {
2818 applyBotChannelOptions(override.channel, &model, &workspaceRoot, &toolApprovalMode)
2819 }
2820 return model, workspaceRoot, toolApprovalMode
2821 }
2822 if platOK {
2823 applyBotChannelOptions(platChannel, &model, &workspaceRoot, &toolApprovalMode)
2824 mappings = platChannel.SessionMappings
2825 }
2826 if mapping, ok := matchingSessionMapping(mappings, msg); ok {
2827 workspaceRoot = workspaceRootForSessionMapping(mapping, workspaceRoot)
2828 }
2829 model, workspaceRoot, toolApprovalMode = gw.applyRouteOptions(msg, model, workspaceRoot, toolApprovalMode)
2830 if enabled {
2831 applyBotChannelOptions(override.channel, &model, &workspaceRoot, &toolApprovalMode)
2832 }
2833 return model, workspaceRoot, toolApprovalMode
2834 }
2835
2836 func (gw *BotGateway) applyRouteOptions(msg InboundMessage, model, workspaceRoot, toolApprovalMode string) (string, string, string) {
2837 for _, route := range gw.cfg.Routes {
2838 if routeMatchesMessage(route, msg) {
2839 applyBotChannelOptions(route.Channel, &model, &workspaceRoot, &toolApprovalMode)
2840 break
2841 }
2842 }
2843 return model, workspaceRoot, toolApprovalMode
2844 }
2845
2846 func applyBotChannelOptions(channel ChannelConfig, model *string, workspaceRoot *string, toolApprovalMode *string) {
2847 if value := strings.TrimSpace(channel.Model); value != "" {
2848 *model = value
2849 }
2850 if value := strings.TrimSpace(channel.WorkspaceRoot); value != "" {
2851 *workspaceRoot = value
2852 }
2853 if value := normalizeOptionalBotToolApprovalMode(channel.ToolApprovalMode); value != "" {
2854 *toolApprovalMode = value
2855 }
2856 }
2857
2858 func matchingSessionMapping(mappings []SessionMapping, msg InboundMessage) (SessionMapping, bool) {
2859 for i := range mappings {
2860 if sessionMappingMatches(mappings[i], msg) {
2861 return mappings[i], true
2862 }
2863 }
2864 return SessionMapping{}, false
2865 }
2866
2867 func sessionMappingMatches(mapping SessionMapping, msg InboundMessage) bool {
2868 if strings.TrimSpace(mapping.RemoteID) != strings.TrimSpace(msg.ChatID) {
2869 return false
2870 }
2871 chatType, userID, threadID := sessionMappingIdentity(msg)
2872 mappingChatType := strings.TrimSpace(mapping.ChatType)
2873 if mappingChatType == "" {
2874 return chatType == ""
2875 }
2876 if mappingChatType != chatType {
2877 return false
2878 }
2879 if strings.TrimSpace(mapping.UserID) != userID {
2880 return false
2881 }
2882 return strings.TrimSpace(mapping.ThreadID) == threadID
2883 }
2884
2885 func sessionMappingIdentity(msg InboundMessage) (chatType string, userID string, threadID string) {
2886 switch msg.ChatType {
2887 case ChatGroup, ChatGuild:
2888 chatType = string(msg.ChatType)
2889 userID = strings.TrimSpace(msg.UserID)
2890 case ChatThread:
2891 chatType = string(msg.ChatType)
2892 threadID = strings.TrimSpace(msg.ThreadID)
2893 if threadID == "" {
2894 threadID = strings.TrimSpace(msg.ChatID)
2895 }
2896 }
2897 return chatType, userID, threadID
2898 }
2899
2900 func workspaceRootForSessionMapping(mapping SessionMapping, fallback string) string {
2901 if root := strings.TrimSpace(mapping.WorkspaceRoot); root != "" {
2902 return root
2903 }
2904 if strings.EqualFold(strings.TrimSpace(mapping.Scope), "global") {
2905 return ""
2906 }
2907 return fallback
2908 }
2909
2910 func routeMatchesMessage(route RouteConfig, msg InboundMessage) bool {
2911 if value := strings.TrimSpace(route.ConnectionID); value != "" && value != strings.TrimSpace(msg.ConnectionID) {
2912 return false
2913 }
2914 if route.Platform != "" && route.Platform != msg.Platform {
2915 return false
2916 }
2917 if route.ChatType != "" && route.ChatType != msg.ChatType {
2918 return false
2919 }
2920 if value := strings.TrimSpace(route.ChatID); value != "" && value != strings.TrimSpace(msg.ChatID) {
2921 return false
2922 }
2923 if value := strings.TrimSpace(route.UserID); value != "" && value != strings.TrimSpace(msg.UserID) {
2924 return false
2925 }
2926 if value := strings.TrimSpace(route.ThreadID); value != "" && value != strings.TrimSpace(msg.ThreadID) {
2927 return false
2928 }
2929 return true
2930 }
2931
2932 func normalizeBotToolApprovalMode(mode string) string {
2933 if value := normalizeOptionalBotToolApprovalMode(mode); value != "" {
2934 return value
2935 }
2936 return control.ToolApprovalWorkspaceWrite
2937 }
2938
2939 func normalizeOptionalBotToolApprovalMode(mode string) string {
2940 if strings.TrimSpace(mode) == "" {
2941 return ""
2942 }
2943 return config.NormalizeToolApprovalMode(mode)
2944 }
2945
2946 func (gw *BotGateway) sendText(ctx context.Context, adapter Adapter, msg InboundMessage, text string) error {
2947 out := OutboundMessage{
2948 ConnectionID: msg.ConnectionID,
2949 Domain: msg.Domain,
2950 ChatID: msg.ChatID,
2951 ChatType: msg.ChatType,
2952 Text: text,
2953 ReplyToMsgID: msg.MessageID,
2954 SessionWebhook: msg.SessionWebhook,
2955 }
2956 binding := AdapterBinding{
2957 ID: strings.TrimSpace(msg.ConnectionID),
2958 Domain: strings.TrimSpace(msg.Domain),
2959 Platform: msg.Platform,
2960 Adapter: adapter,
2961 }
2962 if binding.Platform == "" && adapter != nil {
2963 binding.Platform = adapter.Platform()
2964 }
2965 if binding.ID == "" && adapter != nil {
2966 binding.ID = adapter.Name()
2967 }
2968 result, err := gw.sendViaAdapter(ctx, binding, out)
2969 if err != nil {
2970 gw.logger.Warn("bot send failed", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "reply_to", hashID(msg.MessageID), "err", err)
2971 return err
2972 }
2973 gw.logger.Info("bot send completed", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "reply_to", hashID(msg.MessageID), "message", hashID(result.MessageID))
2974 return err
2975 }
2976
2977 func (gw *BotGateway) sendViaAdapter(ctx context.Context, binding AdapterBinding, msg OutboundMessage) (SendResult, error) {
2978 if binding.Adapter == nil {
2979 return SendResult{}, errors.New("bot send: adapter is nil")
2980 }
2981 if strings.TrimSpace(msg.ConnectionID) == "" {
2982 msg.ConnectionID = binding.ID
2983 }
2984 if strings.TrimSpace(msg.Domain) == "" {
2985 msg.Domain = binding.Domain
2986 }
2987 result, err := binding.Adapter.Send(ctx, msg)
2988 gw.markAdapterSend(binding, err)
2989 for _, messageID := range result.DeliveredMessageIDs() {
2990 gw.rememberOutboundMessage(binding.Platform, binding.ID, binding.Domain, msg.ChatID, messageID)
2991 }
2992 return result, err
2993 }
2994
2995 func parseAskAnswers(questions []event.AskQuestion, raw string) []event.AskAnswer {
2996 raw = strings.TrimSpace(raw)
2997 if len(questions) == 0 {
2998 return []event.AskAnswer{{Selected: []string{raw}}}
2999 }
3000 byID := make(map[string]*event.AskQuestion, len(questions))
3001 for i := range questions {
3002 q := &questions[i]
3003 byID[q.ID] = q
3004 byID[fmt.Sprintf("%d", i+1)] = q
3005 }
3006 answerMap := make(map[string][]string, len(questions))
3007 if strings.Contains(raw, "=") {
3008 for part := range strings.SplitSeq(raw, ";") {
3009 k, v, ok := strings.Cut(part, "=")
3010 if !ok {
3011 continue
3012 }
3013 q := byID[strings.TrimSpace(k)]
3014 if q == nil {
3015 continue
3016 }
3017 answerMap[q.ID] = normalizeAskSelection(*q, strings.TrimSpace(v))
3018 }
3019 } else if len(questions) == 1 {
3020 answerMap[questions[0].ID] = normalizeAskSelection(questions[0], raw)
3021 }
3022 out := make([]event.AskAnswer, 0, len(questions))
3023 for _, q := range questions {
3024 out = append(out, event.AskAnswer{QuestionID: q.ID, Selected: answerMap[q.ID]})
3025 }
3026 return out
3027 }
3028
3029 func normalizeAskSelection(q event.AskQuestion, raw string) []string {
3030 parts := []string{raw}
3031 if q.Multi && strings.Contains(raw, ",") {
3032 parts = strings.Split(raw, ",")
3033 }
3034 out := make([]string, 0, len(parts))
3035 for _, part := range parts {
3036 part = strings.TrimSpace(part)
3037 if part == "" {
3038 continue
3039 }
3040 if idx, err := strconv.Atoi(part); err == nil && idx >= 1 && idx <= len(q.Options) {
3041 out = append(out, q.Options[idx-1].Label)
3042 continue
3043 }
3044 out = append(out, part)
3045 }
3046 return out
3047 }
3048
3049 // UpdateConnectionToolApprovalMode updates the in-memory tool approval mode for
3050 // a single bot connection without restarting the gateway. Empty mode clears the
3051 // connection override, so existing sessions inherit the current gateway default.
3052 func (gw *BotGateway) UpdateConnectionToolApprovalMode(connID, mode string) {
3053 connID = strings.TrimSpace(connID)
3054 if connID == "" {
3055 return
3056 }
3057 mode = normalizeOptionalBotToolApprovalMode(mode)
3058 type controllerMode struct {
3059 ctrl botController
3060 mode string
3061 }
3062 var updates []controllerMode
3063
3064 gw.mu.Lock()
3065 if gw.cfg.ConnectionChannels == nil {
3066 gw.cfg.ConnectionChannels = make(map[string]ChannelConfig)
3067 }
3068 ch := gw.cfg.ConnectionChannels[connID]
3069 ch.ToolApprovalMode = mode
3070 gw.cfg.ConnectionChannels[connID] = ch
3071 // Update every active session that belongs to this connection.
3072 for _, state := range gw.controllers {
3073 if state == nil || state.ctrl == nil || strings.TrimSpace(state.connectionID) != connID {
3074 continue
3075 }
3076 effectiveMode := mode
3077 if effectiveMode == "" {
3078 effectiveMode = normalizeBotToolApprovalMode(gw.cfg.ToolApprovalMode)
3079 }
3080 updates = append(updates, controllerMode{ctrl: state.ctrl, mode: effectiveMode})
3081 }
3082 gw.mu.Unlock()
3083
3084 for _, update := range updates {
3085 update.ctrl.SetToolApprovalMode(update.mode)
3086 }
3087 }
3088
3089 // SendToAdapter sends a message through the adapter identified by connID.
3090 // Returns an error if no matching adapter is found.
3091 func (gw *BotGateway) SendToAdapter(ctx context.Context, connID, domain string, msg OutboundMessage) (SendResult, error) {
3092 connID = strings.TrimSpace(connID)
3093 domain = strings.TrimSpace(domain)
3094 var target AdapterBinding
3095 gw.mu.Lock()
3096 for _, binding := range gw.adapters {
3097 if strings.TrimSpace(binding.ID) == connID &&
3098 (domain == "" || strings.EqualFold(strings.TrimSpace(binding.Domain), domain)) {
3099 target = binding
3100 break
3101 }
3102 }
3103 gw.mu.Unlock()
3104 if target.Adapter != nil {
3105 return gw.sendViaAdapter(ctx, target, msg)
3106 }
3107 return SendResult{}, fmt.Errorf("SendToAdapter: no adapter found for connection %q (domain %q)", connID, domain)
3108 }
3109
3110 // SendTextToAdapter sends a plain text message through the adapter identified by connID.
3111 func (gw *BotGateway) SendTextToAdapter(ctx context.Context, connID, domain, chatID string, chatType ChatType, text string) (SendResult, error) {
3112 return gw.SendToAdapter(ctx, connID, domain, OutboundMessage{
3113 ChatID: chatID,
3114 ChatType: chatType,
3115 Text: text,
3116 })
3117 }
3118
3119 // TestSendToAdapter sends a test message through the adapter identified by
3120 // connID. The adapter must implement TestSender (currently dingtalk, which
3121 // replies to the most recent chat it learned a session webhook for). Returns
3122 // a readable error when the adapter is missing or does not support test sends.
3123 func (gw *BotGateway) TestSendToAdapter(ctx context.Context, connID, domain, text string) (SendResult, error) {
3124 connID = strings.TrimSpace(connID)
3125 domain = strings.TrimSpace(domain)
3126 var target AdapterBinding
3127 gw.mu.Lock()
3128 for _, binding := range gw.adapters {
3129 if strings.TrimSpace(binding.ID) == connID &&
3130 (domain == "" || strings.EqualFold(strings.TrimSpace(binding.Domain), domain)) {
3131 target = binding
3132 break
3133 }
3134 }
3135 gw.mu.Unlock()
3136 if target.Adapter == nil {
3137 return SendResult{}, fmt.Errorf("no bot adapter found for %q (domain %q)", connID, domain)
3138 }
3139 ts, ok := target.Adapter.(TestSender)
3140 if !ok {
3141 return SendResult{}, fmt.Errorf("bot adapter %q does not support test sends", connID)
3142 }
3143 return ts.TestSend(ctx, text)
3144 }
3145
3145 lines GO