返回 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.TurnOutcomeRecoveryPaused {
365 return desktopBridgeNotification{text: constText(fmt.Sprintf(
366 "⏸️ 桌面会话「%s」已暂停自动重试。已完成的工作会保留;发送“继续”即可开始新一轮,也可以补充要求调整方向。",
367 label,
368 ))}
369 }
370 if e.Err != nil {
371 // Error text can contain paths/tokens; only detail it in a private chat.
372 return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
373 if isSharedChat(route.ChatType) {
374 return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
375 }
376 return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(e.Err.Error(), botBridgeErrTextLimit))
377 }}
378 }
379 return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
380 }
381
382 func desktopCardButton(label, style, command string, route bot.DesktopWatchRoute) map[string]any {
383 return map[string]any{
384 "tag": "button",
385 "text": map[string]string{"tag": "plain_text", "content": label},
386 "type": style,
387 "value": map[string]string{
388 "command": command,
389 "chat_type": string(route.ChatType),
390 },
391 }
392 }
393
394 func truncateForBridge(s string, limit int) string {
395 s = strings.TrimSpace(s)
396 runes := []rune(s)
397 if len(runes) <= limit {
398 return s
399 }
400 return string(runes[:limit]) + "…"
401 }
402
403 // ---- bot.DesktopBridge 实现 ----
404
405 func (h *botBridgeHub) Sessions() []bot.DesktopSessionInfo {
406 if h.sessions == nil {
407 return nil
408 }
409 sessions := h.sessions()
410 h.mu.Lock()
411 byTab := make(map[string][]bot.DesktopPendingInfo, len(h.pending))
412 for id, p := range h.pending {
413 byTab[p.tabID] = append(byTab[p.tabID], bot.DesktopPendingInfo{ID: id, Kind: p.kind, Tool: p.tool})
414 }
415 h.mu.Unlock()
416 for i := range sessions {
417 if pend := byTab[sessions[i].TabID]; len(pend) > 0 {
418 sort.Slice(pend, func(a, b int) bool { return pend[a].ID < pend[b].ID })
419 sessions[i].Pending = pend
420 }
421 }
422 return sessions
423 }
424
425 func (h *botBridgeHub) SetWatch(route bot.DesktopWatchRoute, enable bool) error {
426 h.mu.Lock()
427 if enable {
428 h.watchers[route.Key()] = route
429 } else {
430 delete(h.watchers, route.Key())
431 }
432 h.watchSeq++
433 h.watchPersistDirty = true
434 seq := h.watchSeq
435 routes := h.watcherRoutesLocked()
436 persist := h.persistWatchers
437 h.mu.Unlock()
438 if persist == nil {
439 return nil
440 }
441 // Serialize persists and drop stale ones: two concurrent SetWatch calls
442 // (different connections) compute snapshots under h.mu but write config
443 // outside it, so their writes could otherwise reorder and let an older
444 // snapshot clobber a newer one, silently losing a subscription.
445 h.persistMu.Lock()
446 defer h.persistMu.Unlock()
447 if seq <= h.lastPersistSeq {
448 return nil
449 }
450 if err := persist(routes); err != nil {
451 return err
452 }
453 h.lastPersistSeq = seq
454 h.mu.Lock()
455 if h.watchSeq == seq {
456 h.watchPersistDirty = false
457 }
458 h.mu.Unlock()
459 return nil
460 }
461
462 func (h *botBridgeHub) watcherVersion() uint64 {
463 h.mu.Lock()
464 defer h.mu.Unlock()
465 return h.watchSeq
466 }
467
468 // seedWatchers applies a config snapshot only if no watch command changed the
469 // runtime after the config read began. Fresh external config edits still apply;
470 // stale refreshes and failed local persists do not erase newer runtime state.
471 func (h *botBridgeHub) seedWatchers(routes []bot.DesktopWatchRoute, expectedSeq uint64) {
472 h.persistMu.Lock()
473 defer h.persistMu.Unlock()
474 h.mu.Lock()
475 defer h.mu.Unlock()
476 if h.watchSeq != expectedSeq || h.watchPersistDirty {
477 return
478 }
479 h.watchers = make(map[string]bot.DesktopWatchRoute, len(routes))
480 for _, r := range routes {
481 if strings.TrimSpace(r.ChatID) == "" {
482 continue
483 }
484 h.watchers[r.Key()] = r
485 }
486 }
487
488 func (h *botBridgeHub) watcherRoutesLocked() []bot.DesktopWatchRoute {
489 routes := make([]bot.DesktopWatchRoute, 0, len(h.watchers))
490 for _, r := range h.watchers {
491 routes = append(routes, r)
492 }
493 sort.Slice(routes, func(i, j int) bool { return routes[i].Key() < routes[j].Key() })
494 return routes
495 }
496
497 func (h *botBridgeHub) Watching(route bot.DesktopWatchRoute) bool {
498 h.mu.Lock()
499 defer h.mu.Unlock()
500 _, ok := h.watchers[route.Key()]
501 return ok
502 }
503
504 func (h *botBridgeHub) Approve(approvalID string, allow bool) (string, error) {
505 approvalID = strings.TrimSpace(approvalID)
506 h.mu.Lock()
507 p, ok := h.pending[approvalID]
508 if ok && p.kind == "approval" {
509 delete(h.pending, approvalID)
510 }
511 h.mu.Unlock()
512 if !ok || p.kind != "approval" {
513 return "", fmt.Errorf("未找到待处理的审批 %s(可能已在桌面端处理或已超时)。用 /desktop status 查看当前会话。", approvalID)
514 }
515 if h.approveTab == nil {
516 return "", fmt.Errorf("桌面端审批通道不可用。")
517 }
518 h.approveTab(p.tabID, approvalID, allow, false, false)
519 action := "批准"
520 if !allow {
521 action = "拒绝"
522 }
523 return fmt.Sprintf("已提交%s「%s」的操作(%s)。桌面端若已先处理,以先到者为准。", action, h.tabLabel(p.tabID), p.tool), nil
524 }
525
526 func (h *botBridgeHub) AskQuestions(askID string) ([]event.AskQuestion, bool) {
527 h.mu.Lock()
528 defer h.mu.Unlock()
529 p, ok := h.pending[strings.TrimSpace(askID)]
530 if !ok || p.kind != "ask" {
531 return nil, false
532 }
533 return p.questions, true
534 }
535
536 func (h *botBridgeHub) Answer(askID string, answers []event.AskAnswer) (string, error) {
537 askID = strings.TrimSpace(askID)
538 h.mu.Lock()
539 p, ok := h.pending[askID]
540 if ok && p.kind == "ask" {
541 delete(h.pending, askID)
542 }
543 h.mu.Unlock()
544 if !ok || p.kind != "ask" {
545 return "", fmt.Errorf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
546 }
547 if h.answerTab == nil {
548 return "", fmt.Errorf("桌面端问答通道不可用。")
549 }
550 out := make([]QuestionAnswer, 0, len(answers))
551 for _, an := range answers {
552 out = append(out, QuestionAnswer{QuestionID: an.QuestionID, Selected: an.Selected})
553 }
554 h.answerTab(p.tabID, askID, out)
555 return fmt.Sprintf("已提交「%s」的回答。桌面端若已先处理,以先到者为准。", h.tabLabel(p.tabID)), nil
556 }
557
558 // ---- 显式接管 ----
559
560 func (h *botBridgeHub) Takeover(route bot.DesktopWatchRoute, tabID string) (string, error) {
561 tabID = strings.TrimSpace(tabID)
562 // DM only. In a group the binding is keyed on the group chat, so after an
563 // admin takes over, ANY allowlisted member's plain message would be diverted
564 // to drive the session — a privilege escalation past the admin gate that
565 // establishes the takeover. Restricting to DM keeps the driver identical to
566 // the operator who established it.
567 if route.ChatType != bot.ChatDM {
568 return "", fmt.Errorf("接管仅支持私聊:在群里接管会让其他成员也能驱动你的桌面会话。请在与 bot 的私聊中接管。")
569 }
570 session, ok := h.sessionByTabID(tabID)
571 if !ok {
572 return "", fmt.Errorf("未找到会话 %s。用 /desktop status 查看可接管的会话。", tabID)
573 }
574 if session.Detached {
575 return "", fmt.Errorf("会话「%s」在后台运行,暂不支持接管;请先在桌面端打开它。", h.tabLabel(tabID))
576 }
577 h.mu.Lock()
578 if holder, held := h.takeovers[tabID]; held && holder.Key() != route.Key() {
579 h.mu.Unlock()
580 return "", fmt.Errorf("会话「%s」已被另一个聊天接管。", h.tabLabel(tabID))
581 }
582 // 同一聊天换目标:先解除旧绑定,并记下旧 tab 以便公告解除。
583 released := ""
584 if prev, ok := h.takeoverTabs[route.Key()]; ok && prev != tabID {
585 delete(h.takeovers, prev)
586 released = prev
587 }
588 h.takeovers[tabID] = route
589 h.takeoverTabs[route.Key()] = tabID
590 announce := h.announce
591 changed := h.takeoverChanged
592 h.mu.Unlock()
593 if announce != nil {
594 if released != "" {
595 announce(released, "IM 远程接管已解除(接管方切换到了另一个会话)。")
596 }
597 announce(tabID, "此会话已被 IM 远程接管(bot 管理员)。在此本地发送任意消息即可收回控制。")
598 }
599 if changed != nil {
600 changed()
601 }
602 label := h.tabLabel(tabID)
603 return fmt.Sprintf("已接管「%s」。现在直接发消息即可驱动它,输出会流回本聊天;/desktop release 解除接管。桌面端本地发言会自动收回控制。", label), nil
604 }
605
606 func (h *botBridgeHub) Release(route bot.DesktopWatchRoute) (string, error) {
607 h.mu.Lock()
608 tabID, ok := h.takeoverTabs[route.Key()]
609 if ok {
610 delete(h.takeoverTabs, route.Key())
611 delete(h.takeovers, tabID)
612 }
613 announce := h.announce
614 changed := h.takeoverChanged
615 h.mu.Unlock()
616 if !ok {
617 return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
618 }
619 if announce != nil {
620 announce(tabID, "IM 远程接管已解除。")
621 }
622 if changed != nil {
623 changed()
624 }
625 return fmt.Sprintf("已解除对「%s」的接管。", h.tabLabel(tabID)), nil
626 }
627
628 func (h *botBridgeHub) TakeoverTab(route bot.DesktopWatchRoute) string {
629 h.mu.Lock()
630 defer h.mu.Unlock()
631 return h.takeoverTabs[route.Key()]
632 }
633
634 func (h *botBridgeHub) DriveInput(route bot.DesktopWatchRoute, text string) (string, error) {
635 h.mu.Lock()
636 tabID := h.takeoverTabs[route.Key()]
637 h.mu.Unlock()
638 if tabID == "" {
639 return "", fmt.Errorf("本聊天没有接管任何桌面会话。")
640 }
641 session, ok := h.sessionByTabID(tabID)
642 if !ok || session.Detached {
643 // 会话被关闭或转入后台:自动解除绑定,避免消息黑洞。
644 h.mu.Lock()
645 delete(h.takeoverTabs, route.Key())
646 delete(h.takeovers, tabID)
647 h.mu.Unlock()
648 if changed := h.takeoverChanged; changed != nil {
649 changed()
650 }
651 return "", fmt.Errorf("被接管的会话已关闭或转入后台,接管已自动解除。")
652 }
653 if session.Running {
654 return "", h.busyError(tabID)
655 }
656 if h.drive == nil {
657 return "", fmt.Errorf("桌面端驱动通道不可用。")
658 }
659 if err := h.drive(tabID, text, route); err != nil {
660 if errors.Is(err, errDriveBusy) {
661 return "", h.busyError(tabID)
662 }
663 return "", fmt.Errorf("驱动失败: %v", err)
664 }
665 return "", nil
666 }
667
668 func (h *botBridgeHub) busyError(tabID string) error {
669 return fmt.Errorf("会话「%s」正在执行中,等它完成后再发;或用 /desktop watch on 订阅完成通知。", h.tabLabel(tabID))
670 }
671
672 // reclaimFromDesktop 在桌面用户本地提交输入时收回控制权:解除绑定并通知
673 // 远端聊天。由 App.SubmitToTab 调用(bridge 自己的驱动不走这条路)。
674 func (h *botBridgeHub) reclaimFromDesktop(tabID string) {
675 h.mu.Lock()
676 route, ok := h.takeovers[tabID]
677 if ok {
678 delete(h.takeovers, tabID)
679 delete(h.takeoverTabs, route.Key())
680 }
681 notify := h.notify
682 changed := h.takeoverChanged
683 h.mu.Unlock()
684 if !ok {
685 return
686 }
687 if changed != nil {
688 changed()
689 }
690 if notify == nil {
691 return
692 }
693 label := h.tabLabel(tabID)
694 // 直接入通知队列(不依赖 watch 订阅):接管者必须知道控制权没了。
695 h.enqueue(desktopBridgeNotification{
696 text: constText(fmt.Sprintf("🔓 桌面端已收回会话「%s」的控制权,接管已解除。", label)),
697 route: &route,
698 })
699 }
700
701 // remoteControlledTabs 返回当前被接管的 tabID 集合(TabMeta 标记用)。
702 func (h *botBridgeHub) remoteControlledTabs() map[string]bool {
703 h.mu.Lock()
704 defer h.mu.Unlock()
705 if len(h.takeovers) == 0 {
706 return nil
707 }
708 out := make(map[string]bool, len(h.takeovers))
709 for tabID := range h.takeovers {
710 out[tabID] = true
711 }
712 return out
713 }
714
714 lines GO