返回 DeepSeek-Reasonix
bot_bridge.go
根目录 / desktop / bot_bridge.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "sort"
9 "strings"
10 "sync"
11 "time"
12
13 "reasonix/internal/bot"
14 "reasonix/internal/event"
15 )
16
17 // errDriveBusy signals that a takeover drive could not start because the target
18 // controller was already running a turn. The hub translates it into a
19 // user-facing "session busy" message rather than a generic drive failure.
20 var errDriveBusy = errors.New("desktop session busy")
21
22 // botBridgeHub 是 bot 网关对桌面端的"上帝视角"桥(bot.DesktopBridge 的实现)。
23 //
24 // 职责边界(刻意保持窄):
25 // - 观察:tabEventSink.Emit 把每个桌面会话的事件旁路到 observe;hub 记录
26 // 待审批/待回答项,并把审批请求、任务完成/出错推送给订阅聊天。
27 // - 遥控审批:/desktop approve|deny|answer 经 App.ApproveTab /
28 // AnswerQuestionForTab 按 tab 寻址回写。controller 侧幂等(先到者赢),
29 // 桌面 UI 与远端并发应答互不干扰。
30 // - 不做:向桌面会话注入输入、抢占写 lease。将来的显式接管在
31 // bot.DesktopBridge 上扩展,底层走 internal/control 的接管端口。
32 //
33 // observe 跑在 controller 的事件 goroutine 上,绝不能做网络调用——通知统一
34 // 进有界队列由 worker 异步发送,队列满时丢弃并告警。
35 type botBridgeHub struct {
36 sessions func() []bot.DesktopSessionInfo
37 approveTab func(tabID, id string, allow, session, persist bool)
38 answerTab func(tabID, id string, answers []QuestionAnswer)
39 notify func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
40 // drive 把一条远程文本提交为 tab 的新 turn,并把该 turn 的输出转发回 route。
41 drive func(tabID, text string, route bot.DesktopWatchRoute) error
42 // announce 往会话 transcript 里发一条 Notice,让桌面用户看到接管状态变化。
43 announce func(tabID, text string)
44 // persistWatchers 把订阅全集回写用户配置(bot.desktop_watchers)。
45 persistWatchers func(routes []bot.DesktopWatchRoute) error
46 // takeoverChanged 通知桌面前端刷新(TabMeta.RemoteControlled 变化)。
47 takeoverChanged func()
48 logger *slog.Logger
49
50 mu sync.Mutex
51 watchers map[string]bot.DesktopWatchRoute
52 pending map[string]desktopPendingPrompt
53 // takeovers: tabID -> 驾驶该会话的聊天路由;takeoverTabs: routeKey -> tabID。
54 takeovers map[string]bot.DesktopWatchRoute
55 takeoverTabs map[string]string
56 // watchSeq 单调递增,标记订阅快照的新旧;persist 时用它丢弃过期写入。
57 watchSeq uint64
58 // watchPersistDirty keeps a failed local mutation authoritative in memory;
59 // a later runtime refresh must not silently restore the older disk snapshot.
60 watchPersistDirty bool
61
62 // persistMu 串行化订阅落盘,并保证只写最新快照(见 SetWatch)。
63 persistMu sync.Mutex
64 lastPersistSeq uint64
65
66 queue chan desktopBridgeNotification
67 }
68
69 type desktopPendingPrompt struct {
70 tabID string
71 kind string // "approval" | "ask"
72 tool string
73 subject string
74 questions []event.AskQuestion
75 }
76
77 // desktopBridgeNotification 是一条待推送的桌面事件。text 与 card 都按订阅路由
78 // 现做,因此群聊(多用户)与私聊可给出不同详略——命令行/错误详情只发私聊,群里
79 // 只给摘要。route 非 nil 时定向发给该聊天(不看 watch 订阅),用于接管收回等必达通知。
80 type desktopBridgeNotification struct {
81 text func(route bot.DesktopWatchRoute) string
82 card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
83 route *bot.DesktopWatchRoute
84 }
85
86 // isSharedChat 判断一个聊天是否为多用户场景(群/话题群/服务器频道)。私聊/单聊
87 // 只有操作者本人,可安全展示命令行等敏感详情。
88 func isSharedChat(ct bot.ChatType) bool {
89 return ct != bot.ChatDM && ct != bot.ChatDirect
90 }
91
92 func constText(s string) func(bot.DesktopWatchRoute) string {
93 return func(bot.DesktopWatchRoute) string { return s }
94 }
95
96 const (
97 botBridgeQueueSize = 64
98 botBridgeSendTimeout = 15 * time.Second
99 botBridgeSubjectLimit = 200
100 botBridgePendingLimit = 200
101 botBridgeErrTextLimit = 300
102 botBridgePromptPreview = 500
103 )
104
105 // botBridgeDeps 打包 hub 对宿主(App)的全部依赖,便于测试注入。
106 type botBridgeDeps struct {
107 sessions func() []bot.DesktopSessionInfo
108 approveTab func(tabID, id string, allow, session, persist bool)
109 answerTab func(tabID, id string, answers []QuestionAnswer)
110 notify func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
111 drive func(tabID, text string, route bot.DesktopWatchRoute) error
112 announce func(tabID, text string)
113 persistWatchers func(routes []bot.DesktopWatchRoute) error
114 takeoverChanged func()
115 logger *slog.Logger
116 }
117
118 func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
119 logger := deps.logger
120 if logger == nil {
121 logger = slog.Default()
122 }
123 h := &botBridgeHub{
124 sessions: deps.sessions,
125 approveTab: deps.approveTab,
126 answerTab: deps.answerTab,
127 notify: deps.notify,
128 drive: deps.drive,
129 announce: deps.announce,
130 persistWatchers: deps.persistWatchers,
131 takeoverChanged: deps.takeoverChanged,
132 logger: logger.With("component", "bot_bridge"),
133 watchers: make(map[string]bot.DesktopWatchRoute),
134 pending: make(map[string]desktopPendingPrompt),
135 takeovers: make(map[string]bot.DesktopWatchRoute),
136 takeoverTabs: make(map[string]string),
137 queue: make(chan desktopBridgeNotification, botBridgeQueueSize),
138 }
139 go h.run()
140 return h
141 }
142
143 // observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
144 // 只做内存记账和入队,不做任何阻塞调用。
145 func (h *botBridgeHub) observe(tabID string, e event.Event) {
146 switch e.Kind {
147 case event.ApprovalRequest:
148 h.mu.Lock()
149 h.rememberPendingLocked(e.Approval.ID, desktopPendingPrompt{
150 tabID: tabID,
151 kind: "approval",
152 tool: e.Approval.Tool,
153 subject: truncateForBridge(e.Approval.Subject, botBridgeSubjectLimit),
154 })
155 watching := len(h.watchers) > 0
156 h.mu.Unlock()
157 if watching {
158 h.enqueue(h.approvalNotification(tabID, e.Approval))
159 }
160 case event.AskRequest:
161 h.mu.Lock()
162 h.rememberPendingLocked(e.Ask.ID, desktopPendingPrompt{
163 tabID: tabID,
164 kind: "ask",
165 questions: e.Ask.Questions,
166 })
167 watching := len(h.watchers) > 0
168 h.mu.Unlock()
169 if watching {
170 h.enqueue(h.askNotification(tabID, e.Ask))
171 }
172 case event.TurnDone:
173 h.mu.Lock()
174 for id, p := range h.pending {
175 if p.tabID == tabID {
176 delete(h.pending, id)
177 }
178 }
179 watching := len(h.watchers) > 0
180 h.mu.Unlock()
181 if !watching {
182 return
183 }
184 if e.Err != nil && strings.Contains(e.Err.Error(), "context canceled") {
185 // 桌面端主动停止的任务不推送,避免正常操作变成噪音。
186 return
187 }
188 h.enqueue(h.turnDoneNotification(tabID, e))
189 }
190 }
191
192 // rememberPendingLocked 记录待处理项;容量兜底防泄漏(正常路径 TurnDone 会清理)。
193 func (h *botBridgeHub) rememberPendingLocked(id string, p desktopPendingPrompt) {
194 if strings.TrimSpace(id) == "" {
195 return
196 }
197 if len(h.pending) >= botBridgePendingLimit {
198 h.pending = make(map[string]desktopPendingPrompt)
199 }
200 h.pending[id] = p
201 }
202
203 func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
204 select {
205 case h.queue <- n:
206 default:
207 h.logger.Warn("desktop bridge notification queue full; dropping")
208 }
209 }
210
211 func (h *botBridgeHub) run() {
212 for n := range h.queue {
213 h.deliver(n)
214 }
215 }
216
217 func (h *botBridgeHub) deliver(n desktopBridgeNotification) {
218 h.mu.Lock()
219 var routes []bot.DesktopWatchRoute
220 if n.route != nil {
221 routes = []bot.DesktopWatchRoute{*n.route}
222 } else {
223 routes = h.watcherRoutesLocked()
224 }
225 notify := h.notify
226 h.mu.Unlock()
227 if notify == nil || len(routes) == 0 {
228 return
229 }
230 // Fan out per route: a single slow/hung connection must not hold the queue
231 // worker for its full timeout and back-pressure everyone else's approvals.
232 var wg sync.WaitGroup
233 for _, route := range routes {
234 wg.Add(1)
235 go func(route bot.DesktopWatchRoute) {
236 defer wg.Done()
237 msg := bot.OutboundMessage{
238 ChatID: route.ChatID,
239 ChatType: route.ChatType,
240 }
241 if n.text != nil {
242 msg.Text = n.text(route)
243 }
244 if n.card != nil {
245 msg.Card = n.card(route)
246 }
247 ctx, cancel := context.WithTimeout(context.Background(), botBridgeSendTimeout)
248 defer cancel()
249 if _, err := notify(ctx, route.ConnectionID, route.Domain, msg); err != nil {
250 h.logger.Warn("desktop bridge notification send failed", "platform", route.Platform, "err", err)
251 }
252 }(route)
253 }
254 wg.Wait()
255 }
256
257 // tabLabel 把 tabID 解析成人类可读的会话名。
258 func (h *botBridgeHub) tabLabel(tabID string) string {
259 if s, ok := h.sessionByTabID(tabID); ok {
260 if label := strings.TrimSpace(s.Label); label != "" {
261 return label
262 }
263 if title := strings.TrimSpace(s.Topic); title != "" {
264 return title
265 }
266 }
267 return "(未命名会话)"
268 }
269
270 func (h *botBridgeHub) sessionByTabID(tabID string) (bot.DesktopSessionInfo, bool) {
271 if h.sessions == nil {
272 return bot.DesktopSessionInfo{}, false
273 }
274 for _, s := range h.sessions() {
275 if s.TabID == tabID {
276 return s, true
277 }
278 }
279 return bot.DesktopSessionInfo{}, false
280 }
281
282 func (h *botBridgeHub) approvalNotification(tabID string, approval event.Approval) desktopBridgeNotification {
283 label := h.tabLabel(tabID)
284 // The approval subject is the pending command line; only reveal it in a
285 // private chat. In a shared chat show the tool name and point the operator
286 // to the desktop / a DM instead of leaking the command to the whole group.
287 subjectFor := func(route bot.DesktopWatchRoute) string {
288 if isSharedChat(route.ChatType) {
289 return "(命令详情仅在桌面端或私聊显示)"
290 }
291 return truncateForBridge(approval.Subject, botBridgeSubjectLimit)
292 }
293 return desktopBridgeNotification{
294 text: func(route bot.DesktopWatchRoute) string {
295 return fmt.Sprintf("⚠️ 桌面会话「%s」需要批准操作\n工具: %s\n操作: %s\n\nID: `%s`\n用 /desktop approve %s 批准,/desktop deny %s 拒绝。桌面端先处理则以先到者为准。",
296 label, approval.Tool, subjectFor(route), approval.ID, approval.ID, approval.ID)
297 },
298 card: func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
299 return &bot.InteractiveCard{
300 Header: "桌面会话需要批准",
301 Elements: []bot.InteractiveCardElement{
302 {Tag: "markdown", Content: fmt.Sprintf("**会话**: %s\n\n**工具**: %s\n\n**操作**: %s\n\nID: `%s`", label, approval.Tool, subjectFor(route), approval.ID)},
303 {Tag: "action", Extra: map[string]any{
304 "actions": []map[string]any{
305 desktopCardButton("允许一次", "primary", "/desktop approve "+approval.ID, route),
306 desktopCardButton("拒绝", "danger", "/desktop deny "+approval.ID, route),
307 },
308 }},
309 },
310 }
311 },
312 }
313 }
314
315 func (h *botBridgeHub) askNotification(tabID string, ask event.Ask) desktopBridgeNotification {
316 label := h.tabLabel(tabID)
317 var b strings.Builder
318 fmt.Fprintf(&b, "❓ 桌面会话「%s」在等待回答:\n", label)
319 for i, q := range ask.Questions {
320 fmt.Fprintf(&b, "\n**%d. %s**\n", i+1, truncateForBridge(q.Prompt, botBridgePromptPreview))
321 for j, opt := range q.Options {
322 fmt.Fprintf(&b, " %d. %s\n", j+1, opt.Label)
323 }
324 }
325 fmt.Fprintf(&b, "\nID: `%s`\n用 /desktop answer %s <选项编号或文本> 回答;桌面端先处理则以先到者为准。", ask.ID, ask.ID)
326 privateText := b.String()
327 sharedText := fmt.Sprintf("❓ 桌面会话「%s」正在等待回答(问题详情仅在桌面端或私聊显示)。\n\nID: `%s`", label, ask.ID)
328 textFor := func(route bot.DesktopWatchRoute) string {
329 if isSharedChat(route.ChatType) {
330 return sharedText
331 }
332 return privateText
333 }
334
335 var card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
336 if len(ask.Questions) == 1 && len(ask.Questions[0].Options) > 0 {
337 options := ask.Questions[0].Options
338 card = func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
339 if isSharedChat(route.ChatType) {
340 return nil
341 }
342 actions := make([]map[string]any, 0, len(options))
343 for i, opt := range options {
344 optLabel := strings.TrimSpace(opt.Label)
345 if optLabel == "" {
346 optLabel = fmt.Sprintf("选项 %d", i+1)
347 }
348 actions = append(actions, desktopCardButton(optLabel, "primary", fmt.Sprintf("/desktop answer %s %d", ask.ID, i+1), route))
349 }
350 return &bot.InteractiveCard{
351 Header: "桌面会话在等待回答",
352 Elements: []bot.InteractiveCardElement{
353 {Tag: "markdown", Content: privateText},
354 {Tag: "action", Extra: map[string]any{"actions": actions}},
355 },
356 }
357 }
358 }
359 return desktopBridgeNotification{text: textFor, card: card}
360 }
361
362 func (h *botBridgeHub) turnDoneNotification(tabID string, e event.Event) desktopBridgeNotification {
363 label := h.tabLabel(tabID)
364 if e.Outcome == event.TurnOutcomeIncompleteRead {
365 return desktopBridgeNotification{text: constText(fmt.Sprintf("⏸️ 桌面会话「%s」的读取任务尚未完成,已保留当前结果。请补充读取范围后继续。", label))}
366 }
367 if e.Outcome == event.TurnOutcomeRecoveryPaused {
368 return desktopBridgeNotification{text: constText(fmt.Sprintf(
369 "⏸️ 桌面会话「%s」已暂停自动重试。已完成的工作会保留;发送“继续”即可开始新一轮,也可以补充要求调整方向。",
370 label,
371 ))}
372 }
373 if e.Outcome == event.TurnOutcomeCompletionUncertain {
374 return desktopBridgeNotification{text: constText(fmt.Sprintf(
375 "⏸️ 桌面会话「%s」本轮完成状态未确认。当前结果和已完成工作均已保留;发送“继续”可接着完成,也可以补充说明需要调整的内容。",
376 label,
377 ))}
378 }
379 if e.Err != nil {
380 // Error text can contain paths/tokens; only detail it in a private chat.
381 return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
382 if isSharedChat(route.ChatType) {
383 return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
384 }
385 return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(e.Err.Error(), botBridgeErrTextLimit))
386 }}
387 }
388 return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
389 }
390
391 func desktopCardButton(label, style, command string, route bot.DesktopWatchRoute) map[string]any {
392 return map[string]any{
393 "tag": "button",
394 "text": map[string]string{"tag": "plain_text", "content": label},
395 "type": style,
396 "value": map[string]string{
397 "command": command,
398 "chat_type": string(route.ChatType),
399 },
400 }
401 }
402
403 func truncateForBridge(s string, limit int) string {
404 s = strings.TrimSpace(s)
405 runes := []rune(s)
406 if len(runes) <= limit {
407 return s
408 }
409 return string(runes[:limit]) + "…"
410 }
411
412 // bot.DesktopBridge 实现
413
414 func (h *botBridgeHub) Sessions() []bot.DesktopSessionInfo {
415 if h.sessions == nil {
416 return nil
417 }
418 sessions := h.sessions()
419 h.mu.Lock()
420 byTab := make(map[string][]bot.DesktopPendingInfo, len(h.pending))
421 for id, p := range h.pending {
422 byTab[p.tabID] = append(byTab[p.tabID], bot.DesktopPendingInfo{ID: id, Kind: p.kind, Tool: p.tool})
423 }
424 h.mu.Unlock()
425 for i := range sessions {
426 if pend := byTab[sessions[i].TabID]; len(pend) > 0 {
427 sort.Slice(pend, func(a, b int) bool { return pend[a].ID < pend[b].ID })
428 sessions[i].Pending = pend
429 }
430 }
431 return sessions
432 }
433
434 func (h *botBridgeHub) SetWatch(route bot.DesktopWatchRoute, enable bool) error {
435 h.mu.Lock()
436 if enable {
437 h.watchers[route.Key()] = route
438 } else {
439 delete(h.watchers, route.Key())
440 }
441 h.watchSeq++
442 h.watchPersistDirty = true
443 seq := h.watchSeq
444 routes := h.watcherRoutesLocked()
445 persist := h.persistWatchers
446 h.mu.Unlock()
447 if persist == nil {
448 return nil
449 }
450 // Serialize persists and drop stale ones: two concurrent SetWatch calls
451 // (different connections) compute snapshots under h.mu but write config
452 // outside it, so their writes could otherwise reorder and let an older
453 // snapshot clobber a newer one, silently losing a subscription.
454 h.persistMu.Lock()
455 defer h.persistMu.Unlock()
456 if seq <= h.lastPersistSeq {
457 return nil
458 }
459 if err := persist(routes); err != nil {
460 return err
461 }
462 h.lastPersistSeq = seq
463 h.mu.Lock()
464 if h.watchSeq == seq {
465 h.watchPersistDirty = false
466 }
467 h.mu.Unlock()
468 return nil
469 }
470
471 func (h *botBridgeHub) watcherVersion() uint64 {
472 h.mu.Lock()
473 defer h.mu.Unlock()
474 return h.watchSeq
475 }
476
477 // seedWatchers applies a config snapshot only if no watch command changed the
478 // runtime after the config read began. Fresh external config edits still apply;
479 // stale refreshes and failed local persists do not erase newer runtime state.
480 func (h *botBridgeHub) seedWatchers(routes []bot.DesktopWatchRoute, expectedSeq uint64) {
481 h.persistMu.Lock()
482 defer h.persistMu.Unlock()
483 h.mu.Lock()
484 defer h.mu.Unlock()
485 if h.watchSeq != expectedSeq || h.watchPersistDirty {
486 return
487 }
488 h.watchers = make(map[string]bot.DesktopWatchRoute, len(routes))
489 for _, r := range routes {
490 if strings.TrimSpace(r.ChatID) == "" {
491 continue
492 }
493 h.watchers[r.Key()] = r
494 }
495 }
496
497 func (h *botBridgeHub) watcherRoutesLocked() []bot.DesktopWatchRoute {
498 routes := make([]bot.DesktopWatchRoute, 0, len(h.watchers))
499 for _, r := range h.watchers {
500 routes = append(routes, r)
501 }
502 sort.Slice(routes, func(i, j int) bool { return routes[i].Key() < routes[j].Key() })
503 return routes
504 }
505
506 func (h *botBridgeHub) Watching(route bot.DesktopWatchRoute) bool {
507 h.mu.Lock()
508 defer h.mu.Unlock()
509 _, ok := h.watchers[route.Key()]
510 return ok
511 }
512
513 func (h *botBridgeHub) Approve(approvalID string, allow bool) (string, error) {
514 approvalID = strings.TrimSpace(approvalID)
515 h.mu.Lock()
516 p, ok := h.pending[approvalID]
517 if ok && p.kind == "approval" {
518 delete(h.pending, approvalID)
519 }
520 h.mu.Unlock()
521 if !ok || p.kind != "approval" {
522 return "", fmt.Errorf("未找到待处理的审批 %s(可能已在桌面端处理或已超时)。用 /desktop status 查看当前会话。", approvalID)
523 }
524 if h.approveTab == nil {
525 return "", fmt.Errorf("桌面端审批通道不可用。")
526 }
527 h.approveTab(p.tabID, approvalID, allow, false, false)
528 action := "批准"
529 if !allow {
530 action = "拒绝"
531 }
532 return fmt.Sprintf("已提交%s「%s」的操作(%s)。桌面端若已先处理,以先到者为准。", action, h.tabLabel(p.tabID), p.tool), nil
533 }
534
535 func (h *botBridgeHub) AskQuestions(askID string) ([]event.AskQuestion, bool) {
536 h.mu.Lock()
537 defer h.mu.Unlock()
538 p, ok := h.pending[strings.TrimSpace(askID)]
539 if !ok || p.kind != "ask" {
540 return nil, false
541 }
542 return p.questions, true
543 }
544
545 func (h *botBridgeHub) Answer(askID string, answers []event.AskAnswer) (string, error) {
546 askID = strings.TrimSpace(askID)
547 h.mu.Lock()
548 p, ok := h.pending[askID]
549 if ok && p.kind == "ask" {
550 delete(h.pending, askID)
551 }
552 h.mu.Unlock()
553 if !ok || p.kind != "ask" {
554 return "", fmt.Errorf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
555 }
556 if h.answerTab == nil {
557 return "", fmt.Errorf("桌面端问答通道不可用。")
558 }
559 out := make([]QuestionAnswer, 0, len(answers))
560 for _, an := range answers {
561 out = append(out, QuestionAnswer{QuestionID: an.QuestionID, Selected: an.Selected})
562 }
563 h.answerTab(p.tabID, askID, out)
564 return fmt.Sprintf("已提交「%s」的回答。桌面端若已先处理,以先到者为准。", h.tabLabel(p.tabID)), nil
565 }
566
567 // 显式接管
568
569 func (h *botBridgeHub) Takeover(route bot.DesktopWatchRoute, tabID string) (string, error) {
570 tabID = strings.TrimSpace(tabID)
571 // DM only. In a group the binding is keyed on the group chat, so after an
572 // admin takes over, ANY allowlisted member's plain message would be diverted
573 // to drive the session — a privilege escalation past the admin gate that
574 // establishes the takeover. Restricting to DM keeps the driver identical to
575 // the operator who established it.
576 if route.ChatType != bot.ChatDM {
577 return "", fmt.Errorf("接管仅支持私聊:在群里接管会让其他成员也能驱动你的桌面会话。请在与 bot 的私聊中接管。")
578 }
579 session, ok := h.sessionByTabID(tabID)
580 if !ok {
581 return "", fmt.Errorf("未找到会话 %s。用 /desktop status 查看可接管的会话。", tabID)
582 }
583 if session.Detached {
584 return "", fmt.Errorf("会话「%s」在后台运行,暂不支持接管;请先在桌面端打开它。", h.tabLabel(tabID))
585 }
586 h.mu.Lock()
587 if holder, held := h.takeovers[tabID]; held && holder.Key() != route.Key() {
588 h.mu.Unlock()
589 return "", fmt.Errorf("会话「%s」已被另一个聊天接管。", h.tabLabel(tabID))
590 }
591 // 同一聊天换目标:先解除旧绑定,并记下旧 tab 以便公告解除。
592 released := ""
593 if prev, ok := h.takeoverTabs[route.Key()]; ok && prev != tabID {
594 delete(h.takeovers, prev)
595 released = prev
596 }
597 h.takeovers[tabID] = route
598 h.takeoverTabs[route.Key()] = tabID
599 announce := h.announce
600 changed := h.takeoverChanged
601 h.mu.Unlock()
602 if announce != nil {
603 if released != "" {
604 announce(released, "IM 远程接管已解除(接管方切换到了另一个会话)。")
605 }
606 announce(tabID, "此会话已被 IM 远程接管(bot 管理员)。在此本地发送任意消息即可收回控制。")
607 }
608 if changed != nil {
609 changed()
610 }
611 label := h.tabLabel(tabID)
612 return fmt.Sprintf("已接管「%s」。现在直接发消息即可驱动它,输出会流回本聊天;/desktop release 解除接管。桌面端本地发言会自动收回控制。", label), nil
613 }
614
615 func (h *botBridgeHub) Release(route bot.DesktopWatchRoute) (string, error) {
616 h.mu.Lock()
617 tabID, ok := h.takeoverTabs[route.Key()]
618 if ok {
619 delete(h.takeoverTabs, route.Key())
620 delete(h.takeovers, tabID)
621 }
622 announce := h.announce
623 changed := h.takeoverChanged
624 h.mu.Unlock()
625 if !ok {
626 return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
627 }
628 if announce != nil {
629 announce(tabID, "IM 远程接管已解除。")
630 }
631 if changed != nil {
632 changed()
633 }
634 return fmt.Sprintf("已解除对「%s」的接管。", h.tabLabel(tabID)), nil
635 }
636
637 func (h *botBridgeHub) TakeoverTab(route bot.DesktopWatchRoute) string {
638 h.mu.Lock()
639 defer h.mu.Unlock()
640 return h.takeoverTabs[route.Key()]
641 }
642
643 func (h *botBridgeHub) DriveInput(route bot.DesktopWatchRoute, text string) (string, error) {
644 h.mu.Lock()
645 tabID := h.takeoverTabs[route.Key()]
646 h.mu.Unlock()
647 if tabID == "" {
648 return "", fmt.Errorf("本聊天没有接管任何桌面会话。")
649 }
650 session, ok := h.sessionByTabID(tabID)
651 if !ok || session.Detached {
652 // 会话被关闭或转入后台:自动解除绑定,避免消息黑洞。
653 h.mu.Lock()
654 delete(h.takeoverTabs, route.Key())
655 delete(h.takeovers, tabID)
656 h.mu.Unlock()
657 if changed := h.takeoverChanged; changed != nil {
658 changed()
659 }
660 return "", fmt.Errorf("被接管的会话已关闭或转入后台,接管已自动解除。")
661 }
662 if session.Running {
663 return "", h.busyError(tabID)
664 }
665 if h.drive == nil {
666 return "", fmt.Errorf("桌面端驱动通道不可用。")
667 }
668 if err := h.drive(tabID, text, route); err != nil {
669 if errors.Is(err, errDriveBusy) {
670 return "", h.busyError(tabID)
671 }
672 return "", fmt.Errorf("驱动失败: %w", err)
673 }
674 return "", nil
675 }
676
677 func (h *botBridgeHub) busyError(tabID string) error {
678 return fmt.Errorf("会话「%s」正在执行中,等它完成后再发;或用 /desktop watch on 订阅完成通知。", h.tabLabel(tabID))
679 }
680
681 // reclaimFromDesktop 在桌面用户本地提交输入时收回控制权:解除绑定并通知
682 // 远端聊天。由 App.SubmitToTab 调用(bridge 自己的驱动不走这条路)。
683 func (h *botBridgeHub) reclaimFromDesktop(tabID string) {
684 h.mu.Lock()
685 route, ok := h.takeovers[tabID]
686 if ok {
687 delete(h.takeovers, tabID)
688 delete(h.takeoverTabs, route.Key())
689 }
690 notify := h.notify
691 changed := h.takeoverChanged
692 h.mu.Unlock()
693 if !ok {
694 return
695 }
696 if changed != nil {
697 changed()
698 }
699 if notify == nil {
700 return
701 }
702 label := h.tabLabel(tabID)
703 // 直接入通知队列(不依赖 watch 订阅):接管者必须知道控制权没了。
704 h.enqueue(desktopBridgeNotification{
705 text: constText(fmt.Sprintf("🔓 桌面端已收回会话「%s」的控制权,接管已解除。", label)),
706 route: &route,
707 })
708 }
709
710 // remoteControlledTabs 返回当前被接管的 tabID 集合(TabMeta 标记用)。
711 func (h *botBridgeHub) remoteControlledTabs() map[string]bool {
712 h.mu.Lock()
713 defer h.mu.Unlock()
714 if len(h.takeovers) == 0 {
715 return nil
716 }
717 out := make(map[string]bool, len(h.takeovers))
718 for tabID := range h.takeovers {
719 out[tabID] = true
720 }
721 return out
722 }
723
723 lines GO