708 lines
23 KiB
Go
708 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"reasonix/internal/bot"
|
|
"reasonix/internal/event"
|
|
)
|
|
|
|
// errDriveBusy signals that a takeover drive could not start because the target
|
|
// controller was already running a turn. The hub translates it into a
|
|
// user-facing "session busy" message rather than a generic drive failure.
|
|
var errDriveBusy = errors.New("desktop session busy")
|
|
|
|
// botBridgeHub 是 bot 网关对桌面端的"上帝视角"桥(bot.DesktopBridge 的实现)。
|
|
//
|
|
// 职责边界(刻意保持窄):
|
|
// - 观察:tabEventSink.Emit 把每个桌面会话的事件旁路到 observe;hub 记录
|
|
// 待审批/待回答项,并把审批请求、任务完成/出错推送给订阅聊天。
|
|
// - 遥控审批:/desktop approve|deny|answer 经 App.ApproveTab /
|
|
// AnswerQuestionForTab 按 tab 寻址回写。controller 侧幂等(先到者赢),
|
|
// 桌面 UI 与远端并发应答互不干扰。
|
|
// - 不做:向桌面会话注入输入、抢占写 lease。将来的显式接管在
|
|
// bot.DesktopBridge 上扩展,底层走 internal/control 的接管端口。
|
|
//
|
|
// observe 跑在 controller 的事件 goroutine 上,绝不能做网络调用——通知统一
|
|
// 进有界队列由 worker 异步发送,队列满时丢弃并告警。
|
|
type botBridgeHub struct {
|
|
sessions func() []bot.DesktopSessionInfo
|
|
approveTab func(tabID, id string, allow, session, persist bool)
|
|
answerTab func(tabID, id string, answers []QuestionAnswer)
|
|
notify func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
|
|
// drive 把一条远程文本提交为 tab 的新 turn,并把该 turn 的输出转发回 route。
|
|
drive func(tabID, text string, route bot.DesktopWatchRoute) error
|
|
// announce 往会话 transcript 里发一条 Notice,让桌面用户看到接管状态变化。
|
|
announce func(tabID, text string)
|
|
// persistWatchers 把订阅全集回写用户配置(bot.desktop_watchers)。
|
|
persistWatchers func(routes []bot.DesktopWatchRoute) error
|
|
// takeoverChanged 通知桌面前端刷新(TabMeta.RemoteControlled 变化)。
|
|
takeoverChanged func()
|
|
logger *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
watchers map[string]bot.DesktopWatchRoute
|
|
pending map[string]desktopPendingPrompt
|
|
// takeovers: tabID -> 驾驶该会话的聊天路由;takeoverTabs: routeKey -> tabID。
|
|
takeovers map[string]bot.DesktopWatchRoute
|
|
takeoverTabs map[string]string
|
|
// watchSeq 单调递增,标记订阅快照的新旧;persist 时用它丢弃过期写入。
|
|
watchSeq uint64
|
|
// watchPersistDirty keeps a failed local mutation authoritative in memory;
|
|
// a later runtime refresh must not silently restore the older disk snapshot.
|
|
watchPersistDirty bool
|
|
|
|
// persistMu 串行化订阅落盘,并保证只写最新快照(见 SetWatch)。
|
|
persistMu sync.Mutex
|
|
lastPersistSeq uint64
|
|
|
|
queue chan desktopBridgeNotification
|
|
}
|
|
|
|
type desktopPendingPrompt struct {
|
|
tabID string
|
|
kind string // "approval" | "ask"
|
|
tool string
|
|
subject string
|
|
questions []event.AskQuestion
|
|
}
|
|
|
|
// desktopBridgeNotification 是一条待推送的桌面事件。text 与 card 都按订阅路由
|
|
// 现做,因此群聊(多用户)与私聊可给出不同详略——命令行/错误详情只发私聊,群里
|
|
// 只给摘要。route 非 nil 时定向发给该聊天(不看 watch 订阅),用于接管收回等必达通知。
|
|
type desktopBridgeNotification struct {
|
|
text func(route bot.DesktopWatchRoute) string
|
|
card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
|
|
route *bot.DesktopWatchRoute
|
|
}
|
|
|
|
// isSharedChat 判断一个聊天是否为多用户场景(群/话题群/服务器频道)。私聊/单聊
|
|
// 只有操作者本人,可安全展示命令行等敏感详情。
|
|
func isSharedChat(ct bot.ChatType) bool {
|
|
return ct != bot.ChatDM && ct != bot.ChatDirect
|
|
}
|
|
|
|
func constText(s string) func(bot.DesktopWatchRoute) string {
|
|
return func(bot.DesktopWatchRoute) string { return s }
|
|
}
|
|
|
|
const (
|
|
botBridgeQueueSize = 64
|
|
botBridgeSendTimeout = 15 * time.Second
|
|
botBridgeSubjectLimit = 200
|
|
botBridgePendingLimit = 200
|
|
botBridgeErrTextLimit = 300
|
|
botBridgePromptPreview = 500
|
|
)
|
|
|
|
// botBridgeDeps 打包 hub 对宿主(App)的全部依赖,便于测试注入。
|
|
type botBridgeDeps struct {
|
|
sessions func() []bot.DesktopSessionInfo
|
|
approveTab func(tabID, id string, allow, session, persist bool)
|
|
answerTab func(tabID, id string, answers []QuestionAnswer)
|
|
notify func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
|
|
drive func(tabID, text string, route bot.DesktopWatchRoute) error
|
|
announce func(tabID, text string)
|
|
persistWatchers func(routes []bot.DesktopWatchRoute) error
|
|
takeoverChanged func()
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
|
|
logger := deps.logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
h := &botBridgeHub{
|
|
sessions: deps.sessions,
|
|
approveTab: deps.approveTab,
|
|
answerTab: deps.answerTab,
|
|
notify: deps.notify,
|
|
drive: deps.drive,
|
|
announce: deps.announce,
|
|
persistWatchers: deps.persistWatchers,
|
|
takeoverChanged: deps.takeoverChanged,
|
|
logger: logger.With("component", "bot_bridge"),
|
|
watchers: make(map[string]bot.DesktopWatchRoute),
|
|
pending: make(map[string]desktopPendingPrompt),
|
|
takeovers: make(map[string]bot.DesktopWatchRoute),
|
|
takeoverTabs: make(map[string]string),
|
|
queue: make(chan desktopBridgeNotification, botBridgeQueueSize),
|
|
}
|
|
go h.run()
|
|
return h
|
|
}
|
|
|
|
// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
|
|
// 只做内存记账和入队,不做任何阻塞调用。
|
|
func (h *botBridgeHub) observe(tabID string, e event.Event) {
|
|
switch e.Kind {
|
|
case event.ApprovalRequest:
|
|
h.mu.Lock()
|
|
h.rememberPendingLocked(e.Approval.ID, desktopPendingPrompt{
|
|
tabID: tabID,
|
|
kind: "approval",
|
|
tool: e.Approval.Tool,
|
|
subject: truncateForBridge(e.Approval.Subject, botBridgeSubjectLimit),
|
|
})
|
|
watching := len(h.watchers) > 0
|
|
h.mu.Unlock()
|
|
if watching {
|
|
h.enqueue(h.approvalNotification(tabID, e.Approval))
|
|
}
|
|
case event.AskRequest:
|
|
h.mu.Lock()
|
|
h.rememberPendingLocked(e.Ask.ID, desktopPendingPrompt{
|
|
tabID: tabID,
|
|
kind: "ask",
|
|
questions: e.Ask.Questions,
|
|
})
|
|
watching := len(h.watchers) > 0
|
|
h.mu.Unlock()
|
|
if watching {
|
|
h.enqueue(h.askNotification(tabID, e.Ask))
|
|
}
|
|
case event.TurnDone:
|
|
h.mu.Lock()
|
|
for id, p := range h.pending {
|
|
if p.tabID == tabID {
|
|
delete(h.pending, id)
|
|
}
|
|
}
|
|
watching := len(h.watchers) > 0
|
|
h.mu.Unlock()
|
|
if !watching {
|
|
return
|
|
}
|
|
if e.Err != nil && strings.Contains(e.Err.Error(), "context canceled") {
|
|
// 桌面端主动停止的任务不推送,避免正常操作变成噪音。
|
|
return
|
|
}
|
|
h.enqueue(h.turnDoneNotification(tabID, e.Err))
|
|
}
|
|
}
|
|
|
|
// rememberPendingLocked 记录待处理项;容量兜底防泄漏(正常路径 TurnDone 会清理)。
|
|
func (h *botBridgeHub) rememberPendingLocked(id string, p desktopPendingPrompt) {
|
|
if strings.TrimSpace(id) == "" {
|
|
return
|
|
}
|
|
if len(h.pending) >= botBridgePendingLimit {
|
|
h.pending = make(map[string]desktopPendingPrompt)
|
|
}
|
|
h.pending[id] = p
|
|
}
|
|
|
|
func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
|
|
select {
|
|
case h.queue <- n:
|
|
default:
|
|
h.logger.Warn("desktop bridge notification queue full; dropping")
|
|
}
|
|
}
|
|
|
|
func (h *botBridgeHub) run() {
|
|
for n := range h.queue {
|
|
h.deliver(n)
|
|
}
|
|
}
|
|
|
|
func (h *botBridgeHub) deliver(n desktopBridgeNotification) {
|
|
h.mu.Lock()
|
|
var routes []bot.DesktopWatchRoute
|
|
if n.route != nil {
|
|
routes = []bot.DesktopWatchRoute{*n.route}
|
|
} else {
|
|
routes = h.watcherRoutesLocked()
|
|
}
|
|
notify := h.notify
|
|
h.mu.Unlock()
|
|
if notify == nil || len(routes) == 0 {
|
|
return
|
|
}
|
|
// Fan out per route: a single slow/hung connection must not hold the queue
|
|
// worker for its full timeout and back-pressure everyone else's approvals.
|
|
var wg sync.WaitGroup
|
|
for _, route := range routes {
|
|
wg.Add(1)
|
|
go func(route bot.DesktopWatchRoute) {
|
|
defer wg.Done()
|
|
msg := bot.OutboundMessage{
|
|
ChatID: route.ChatID,
|
|
ChatType: route.ChatType,
|
|
}
|
|
if n.text != nil {
|
|
msg.Text = n.text(route)
|
|
}
|
|
if n.card != nil {
|
|
msg.Card = n.card(route)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), botBridgeSendTimeout)
|
|
defer cancel()
|
|
if _, err := notify(ctx, route.ConnectionID, route.Domain, msg); err != nil {
|
|
h.logger.Warn("desktop bridge notification send failed", "platform", route.Platform, "err", err)
|
|
}
|
|
}(route)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// tabLabel 把 tabID 解析成人类可读的会话名。
|
|
func (h *botBridgeHub) tabLabel(tabID string) string {
|
|
if s, ok := h.sessionByTabID(tabID); ok {
|
|
if label := strings.TrimSpace(s.Label); label != "" {
|
|
return label
|
|
}
|
|
if title := strings.TrimSpace(s.Topic); title != "" {
|
|
return title
|
|
}
|
|
}
|
|
return "(未命名会话)"
|
|
}
|
|
|
|
func (h *botBridgeHub) sessionByTabID(tabID string) (bot.DesktopSessionInfo, bool) {
|
|
if h.sessions == nil {
|
|
return bot.DesktopSessionInfo{}, false
|
|
}
|
|
for _, s := range h.sessions() {
|
|
if s.TabID == tabID {
|
|
return s, true
|
|
}
|
|
}
|
|
return bot.DesktopSessionInfo{}, false
|
|
}
|
|
|
|
func (h *botBridgeHub) approvalNotification(tabID string, approval event.Approval) desktopBridgeNotification {
|
|
label := h.tabLabel(tabID)
|
|
// The approval subject is the pending command line; only reveal it in a
|
|
// private chat. In a shared chat show the tool name and point the operator
|
|
// to the desktop / a DM instead of leaking the command to the whole group.
|
|
subjectFor := func(route bot.DesktopWatchRoute) string {
|
|
if isSharedChat(route.ChatType) {
|
|
return "(命令详情仅在桌面端或私聊显示)"
|
|
}
|
|
return truncateForBridge(approval.Subject, botBridgeSubjectLimit)
|
|
}
|
|
return desktopBridgeNotification{
|
|
text: func(route bot.DesktopWatchRoute) string {
|
|
return fmt.Sprintf("⚠️ 桌面会话「%s」需要批准操作\n工具: %s\n操作: %s\n\nID: `%s`\n用 /desktop approve %s 批准,/desktop deny %s 拒绝。桌面端先处理则以先到者为准。",
|
|
label, approval.Tool, subjectFor(route), approval.ID, approval.ID, approval.ID)
|
|
},
|
|
card: func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
|
|
return &bot.InteractiveCard{
|
|
Header: "桌面会话需要批准",
|
|
Elements: []bot.InteractiveCardElement{
|
|
{Tag: "markdown", Content: fmt.Sprintf("**会话**: %s\n\n**工具**: %s\n\n**操作**: %s\n\nID: `%s`", label, approval.Tool, subjectFor(route), approval.ID)},
|
|
{Tag: "action", Extra: map[string]any{
|
|
"actions": []map[string]any{
|
|
desktopCardButton("允许一次", "primary", "/desktop approve "+approval.ID, route),
|
|
desktopCardButton("拒绝", "danger", "/desktop deny "+approval.ID, route),
|
|
},
|
|
}},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
func (h *botBridgeHub) askNotification(tabID string, ask event.Ask) desktopBridgeNotification {
|
|
label := h.tabLabel(tabID)
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "❓ 桌面会话「%s」在等待回答:\n", label)
|
|
for i, q := range ask.Questions {
|
|
fmt.Fprintf(&b, "\n**%d. %s**\n", i+1, truncateForBridge(q.Prompt, botBridgePromptPreview))
|
|
for j, opt := range q.Options {
|
|
fmt.Fprintf(&b, " %d. %s\n", j+1, opt.Label)
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, "\nID: `%s`\n用 /desktop answer %s <选项编号或文本> 回答;桌面端先处理则以先到者为准。", ask.ID, ask.ID)
|
|
privateText := b.String()
|
|
sharedText := fmt.Sprintf("❓ 桌面会话「%s」正在等待回答(问题详情仅在桌面端或私聊显示)。\n\nID: `%s`", label, ask.ID)
|
|
textFor := func(route bot.DesktopWatchRoute) string {
|
|
if isSharedChat(route.ChatType) {
|
|
return sharedText
|
|
}
|
|
return privateText
|
|
}
|
|
|
|
var card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
|
|
if len(ask.Questions) == 1 && len(ask.Questions[0].Options) > 0 {
|
|
options := ask.Questions[0].Options
|
|
card = func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
|
|
if isSharedChat(route.ChatType) {
|
|
return nil
|
|
}
|
|
actions := make([]map[string]any, 0, len(options))
|
|
for i, opt := range options {
|
|
optLabel := strings.TrimSpace(opt.Label)
|
|
if optLabel == "" {
|
|
optLabel = fmt.Sprintf("选项 %d", i+1)
|
|
}
|
|
actions = append(actions, desktopCardButton(optLabel, "primary", fmt.Sprintf("/desktop answer %s %d", ask.ID, i+1), route))
|
|
}
|
|
return &bot.InteractiveCard{
|
|
Header: "桌面会话在等待回答",
|
|
Elements: []bot.InteractiveCardElement{
|
|
{Tag: "markdown", Content: privateText},
|
|
{Tag: "action", Extra: map[string]any{"actions": actions}},
|
|
},
|
|
}
|
|
}
|
|
}
|
|
return desktopBridgeNotification{text: textFor, card: card}
|
|
}
|
|
|
|
func (h *botBridgeHub) turnDoneNotification(tabID string, err error) desktopBridgeNotification {
|
|
label := h.tabLabel(tabID)
|
|
if err != nil {
|
|
// Error text can contain paths/tokens; only detail it in a private chat.
|
|
return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
|
|
if isSharedChat(route.ChatType) {
|
|
return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
|
|
}
|
|
return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(err.Error(), botBridgeErrTextLimit))
|
|
}}
|
|
}
|
|
return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
|
|
}
|
|
|
|
func desktopCardButton(label, style, command string, route bot.DesktopWatchRoute) map[string]any {
|
|
return map[string]any{
|
|
"tag": "button",
|
|
"text": map[string]string{"tag": "plain_text", "content": label},
|
|
"type": style,
|
|
"value": map[string]string{
|
|
"command": command,
|
|
"chat_type": string(route.ChatType),
|
|
},
|
|
}
|
|
}
|
|
|
|
func truncateForBridge(s string, limit int) string {
|
|
s = strings.TrimSpace(s)
|
|
runes := []rune(s)
|
|
if len(runes) <= limit {
|
|
return s
|
|
}
|
|
return string(runes[:limit]) + "…"
|
|
}
|
|
|
|
// ---- bot.DesktopBridge 实现 ----
|
|
|
|
func (h *botBridgeHub) Sessions() []bot.DesktopSessionInfo {
|
|
if h.sessions == nil {
|
|
return nil
|
|
}
|
|
sessions := h.sessions()
|
|
h.mu.Lock()
|
|
byTab := make(map[string][]bot.DesktopPendingInfo, len(h.pending))
|
|
for id, p := range h.pending {
|
|
byTab[p.tabID] = append(byTab[p.tabID], bot.DesktopPendingInfo{ID: id, Kind: p.kind, Tool: p.tool})
|
|
}
|
|
h.mu.Unlock()
|
|
for i := range sessions {
|
|
if pend := byTab[sessions[i].TabID]; len(pend) > 0 {
|
|
sort.Slice(pend, func(a, b int) bool { return pend[a].ID < pend[b].ID })
|
|
sessions[i].Pending = pend
|
|
}
|
|
}
|
|
return sessions
|
|
}
|
|
|
|
func (h *botBridgeHub) SetWatch(route bot.DesktopWatchRoute, enable bool) error {
|
|
h.mu.Lock()
|
|
if enable {
|
|
h.watchers[route.Key()] = route
|
|
} else {
|
|
delete(h.watchers, route.Key())
|
|
}
|
|
h.watchSeq++
|
|
h.watchPersistDirty = true
|
|
seq := h.watchSeq
|
|
routes := h.watcherRoutesLocked()
|
|
persist := h.persistWatchers
|
|
h.mu.Unlock()
|
|
if persist == nil {
|
|
return nil
|
|
}
|
|
// Serialize persists and drop stale ones: two concurrent SetWatch calls
|
|
// (different connections) compute snapshots under h.mu but write config
|
|
// outside it, so their writes could otherwise reorder and let an older
|
|
// snapshot clobber a newer one, silently losing a subscription.
|
|
h.persistMu.Lock()
|
|
defer h.persistMu.Unlock()
|
|
if seq <= h.lastPersistSeq {
|
|
return nil
|
|
}
|
|
if err := persist(routes); err != nil {
|
|
return err
|
|
}
|
|
h.lastPersistSeq = seq
|
|
h.mu.Lock()
|
|
if h.watchSeq == seq {
|
|
h.watchPersistDirty = false
|
|
}
|
|
h.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (h *botBridgeHub) watcherVersion() uint64 {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
return h.watchSeq
|
|
}
|
|
|
|
// seedWatchers applies a config snapshot only if no watch command changed the
|
|
// runtime after the config read began. Fresh external config edits still apply;
|
|
// stale refreshes and failed local persists do not erase newer runtime state.
|
|
func (h *botBridgeHub) seedWatchers(routes []bot.DesktopWatchRoute, expectedSeq uint64) {
|
|
h.persistMu.Lock()
|
|
defer h.persistMu.Unlock()
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.watchSeq != expectedSeq || h.watchPersistDirty {
|
|
return
|
|
}
|
|
h.watchers = make(map[string]bot.DesktopWatchRoute, len(routes))
|
|
for _, r := range routes {
|
|
if strings.TrimSpace(r.ChatID) == "" {
|
|
continue
|
|
}
|
|
h.watchers[r.Key()] = r
|
|
}
|
|
}
|
|
|
|
func (h *botBridgeHub) watcherRoutesLocked() []bot.DesktopWatchRoute {
|
|
routes := make([]bot.DesktopWatchRoute, 0, len(h.watchers))
|
|
for _, r := range h.watchers {
|
|
routes = append(routes, r)
|
|
}
|
|
sort.Slice(routes, func(i, j int) bool { return routes[i].Key() < routes[j].Key() })
|
|
return routes
|
|
}
|
|
|
|
func (h *botBridgeHub) Watching(route bot.DesktopWatchRoute) bool {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
_, ok := h.watchers[route.Key()]
|
|
return ok
|
|
}
|
|
|
|
func (h *botBridgeHub) Approve(approvalID string, allow bool) (string, error) {
|
|
approvalID = strings.TrimSpace(approvalID)
|
|
h.mu.Lock()
|
|
p, ok := h.pending[approvalID]
|
|
if ok && p.kind == "approval" {
|
|
delete(h.pending, approvalID)
|
|
}
|
|
h.mu.Unlock()
|
|
if !ok || p.kind != "approval" {
|
|
return "", fmt.Errorf("未找到待处理的审批 %s(可能已在桌面端处理或已超时)。用 /desktop status 查看当前会话。", approvalID)
|
|
}
|
|
if h.approveTab == nil {
|
|
return "", fmt.Errorf("桌面端审批通道不可用。")
|
|
}
|
|
h.approveTab(p.tabID, approvalID, allow, false, false)
|
|
action := "批准"
|
|
if !allow {
|
|
action = "拒绝"
|
|
}
|
|
return fmt.Sprintf("已提交%s「%s」的操作(%s)。桌面端若已先处理,以先到者为准。", action, h.tabLabel(p.tabID), p.tool), nil
|
|
}
|
|
|
|
func (h *botBridgeHub) AskQuestions(askID string) ([]event.AskQuestion, bool) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
p, ok := h.pending[strings.TrimSpace(askID)]
|
|
if !ok || p.kind != "ask" {
|
|
return nil, false
|
|
}
|
|
return p.questions, true
|
|
}
|
|
|
|
func (h *botBridgeHub) Answer(askID string, answers []event.AskAnswer) (string, error) {
|
|
askID = strings.TrimSpace(askID)
|
|
h.mu.Lock()
|
|
p, ok := h.pending[askID]
|
|
if ok && p.kind == "ask" {
|
|
delete(h.pending, askID)
|
|
}
|
|
h.mu.Unlock()
|
|
if !ok || p.kind != "ask" {
|
|
return "", fmt.Errorf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
|
|
}
|
|
if h.answerTab == nil {
|
|
return "", fmt.Errorf("桌面端问答通道不可用。")
|
|
}
|
|
out := make([]QuestionAnswer, 0, len(answers))
|
|
for _, an := range answers {
|
|
out = append(out, QuestionAnswer{QuestionID: an.QuestionID, Selected: an.Selected})
|
|
}
|
|
h.answerTab(p.tabID, askID, out)
|
|
return fmt.Sprintf("已提交「%s」的回答。桌面端若已先处理,以先到者为准。", h.tabLabel(p.tabID)), nil
|
|
}
|
|
|
|
// ---- 显式接管 ----
|
|
|
|
func (h *botBridgeHub) Takeover(route bot.DesktopWatchRoute, tabID string) (string, error) {
|
|
tabID = strings.TrimSpace(tabID)
|
|
// DM only. In a group the binding is keyed on the group chat, so after an
|
|
// admin takes over, ANY allowlisted member's plain message would be diverted
|
|
// to drive the session — a privilege escalation past the admin gate that
|
|
// establishes the takeover. Restricting to DM keeps the driver identical to
|
|
// the operator who established it.
|
|
if route.ChatType != bot.ChatDM {
|
|
return "", fmt.Errorf("接管仅支持私聊:在群里接管会让其他成员也能驱动你的桌面会话。请在与 bot 的私聊中接管。")
|
|
}
|
|
session, ok := h.sessionByTabID(tabID)
|
|
if !ok {
|
|
return "", fmt.Errorf("未找到会话 %s。用 /desktop status 查看可接管的会话。", tabID)
|
|
}
|
|
if session.Detached {
|
|
return "", fmt.Errorf("会话「%s」在后台运行,暂不支持接管;请先在桌面端打开它。", h.tabLabel(tabID))
|
|
}
|
|
h.mu.Lock()
|
|
if holder, held := h.takeovers[tabID]; held && holder.Key() != route.Key() {
|
|
h.mu.Unlock()
|
|
return "", fmt.Errorf("会话「%s」已被另一个聊天接管。", h.tabLabel(tabID))
|
|
}
|
|
// 同一聊天换目标:先解除旧绑定,并记下旧 tab 以便公告解除。
|
|
released := ""
|
|
if prev, ok := h.takeoverTabs[route.Key()]; ok && prev != tabID {
|
|
delete(h.takeovers, prev)
|
|
released = prev
|
|
}
|
|
h.takeovers[tabID] = route
|
|
h.takeoverTabs[route.Key()] = tabID
|
|
announce := h.announce
|
|
changed := h.takeoverChanged
|
|
h.mu.Unlock()
|
|
if announce != nil {
|
|
if released != "" {
|
|
announce(released, "IM 远程接管已解除(接管方切换到了另一个会话)。")
|
|
}
|
|
announce(tabID, "此会话已被 IM 远程接管(bot 管理员)。在此本地发送任意消息即可收回控制。")
|
|
}
|
|
if changed != nil {
|
|
changed()
|
|
}
|
|
label := h.tabLabel(tabID)
|
|
return fmt.Sprintf("已接管「%s」。现在直接发消息即可驱动它,输出会流回本聊天;/desktop release 解除接管。桌面端本地发言会自动收回控制。", label), nil
|
|
}
|
|
|
|
func (h *botBridgeHub) Release(route bot.DesktopWatchRoute) (string, error) {
|
|
h.mu.Lock()
|
|
tabID, ok := h.takeoverTabs[route.Key()]
|
|
if ok {
|
|
delete(h.takeoverTabs, route.Key())
|
|
delete(h.takeovers, tabID)
|
|
}
|
|
announce := h.announce
|
|
changed := h.takeoverChanged
|
|
h.mu.Unlock()
|
|
if !ok {
|
|
return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
|
|
}
|
|
if announce != nil {
|
|
announce(tabID, "IM 远程接管已解除。")
|
|
}
|
|
if changed != nil {
|
|
changed()
|
|
}
|
|
return fmt.Sprintf("已解除对「%s」的接管。", h.tabLabel(tabID)), nil
|
|
}
|
|
|
|
func (h *botBridgeHub) TakeoverTab(route bot.DesktopWatchRoute) string {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
return h.takeoverTabs[route.Key()]
|
|
}
|
|
|
|
func (h *botBridgeHub) DriveInput(route bot.DesktopWatchRoute, text string) (string, error) {
|
|
h.mu.Lock()
|
|
tabID := h.takeoverTabs[route.Key()]
|
|
h.mu.Unlock()
|
|
if tabID == "" {
|
|
return "", fmt.Errorf("本聊天没有接管任何桌面会话。")
|
|
}
|
|
session, ok := h.sessionByTabID(tabID)
|
|
if !ok || session.Detached {
|
|
// 会话被关闭或转入后台:自动解除绑定,避免消息黑洞。
|
|
h.mu.Lock()
|
|
delete(h.takeoverTabs, route.Key())
|
|
delete(h.takeovers, tabID)
|
|
h.mu.Unlock()
|
|
if changed := h.takeoverChanged; changed != nil {
|
|
changed()
|
|
}
|
|
return "", fmt.Errorf("被接管的会话已关闭或转入后台,接管已自动解除。")
|
|
}
|
|
if session.Running {
|
|
return "", h.busyError(tabID)
|
|
}
|
|
if h.drive == nil {
|
|
return "", fmt.Errorf("桌面端驱动通道不可用。")
|
|
}
|
|
if err := h.drive(tabID, text, route); err != nil {
|
|
if errors.Is(err, errDriveBusy) {
|
|
return "", h.busyError(tabID)
|
|
}
|
|
return "", fmt.Errorf("驱动失败: %v", err)
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (h *botBridgeHub) busyError(tabID string) error {
|
|
return fmt.Errorf("会话「%s」正在执行中,等它完成后再发;或用 /desktop watch on 订阅完成通知。", h.tabLabel(tabID))
|
|
}
|
|
|
|
// reclaimFromDesktop 在桌面用户本地提交输入时收回控制权:解除绑定并通知
|
|
// 远端聊天。由 App.SubmitToTab 调用(bridge 自己的驱动不走这条路)。
|
|
func (h *botBridgeHub) reclaimFromDesktop(tabID string) {
|
|
h.mu.Lock()
|
|
route, ok := h.takeovers[tabID]
|
|
if ok {
|
|
delete(h.takeovers, tabID)
|
|
delete(h.takeoverTabs, route.Key())
|
|
}
|
|
notify := h.notify
|
|
changed := h.takeoverChanged
|
|
h.mu.Unlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
if changed != nil {
|
|
changed()
|
|
}
|
|
if notify == nil {
|
|
return
|
|
}
|
|
label := h.tabLabel(tabID)
|
|
// 直接入通知队列(不依赖 watch 订阅):接管者必须知道控制权没了。
|
|
h.enqueue(desktopBridgeNotification{
|
|
text: constText(fmt.Sprintf("🔓 桌面端已收回会话「%s」的控制权,接管已解除。", label)),
|
|
route: &route,
|
|
})
|
|
}
|
|
|
|
// remoteControlledTabs 返回当前被接管的 tabID 集合(TabMeta 标记用)。
|
|
func (h *botBridgeHub) remoteControlledTabs() map[string]bool {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if len(h.takeovers) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]bool, len(h.takeovers))
|
|
for tabID := range h.takeovers {
|
|
out[tabID] = true
|
|
}
|
|
return out
|
|
}
|