Files
2026-07-13 13:00:08 +08:00

269 lines
9.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package bot
import (
"context"
"fmt"
"strings"
"reasonix/internal/event"
)
// DesktopSessionInfo 是一个桌面 live 会话(tab)的快照,用于 /desktop status。
type DesktopSessionInfo struct {
TabID string
Label string
Workspace string
Topic string
Ready bool
Running bool
PendingPrompt bool
// Detached 标记后台运行的会话(已从可见 tab 分离,controller 仍存活)。
Detached bool
// Pending 列出该会话当前待处理的审批/问答,便于用户在推送丢失时仍能
// 用 /desktop approve|answer <id> 处理。
Pending []DesktopPendingInfo
}
// DesktopPendingInfo 是一条待处理的审批或问答的摘要。
type DesktopPendingInfo struct {
ID string
Kind string // "approval" | "ask"
Tool string
}
// DesktopWatchRoute 标识一个订阅了桌面事件的 bot 聊天。
type DesktopWatchRoute struct {
ConnectionID string
Domain string
Platform Platform
ChatType ChatType
ChatID string
}
// Key 返回订阅表的稳定键。
func (r DesktopWatchRoute) Key() string {
return fmt.Sprintf("%s|%s|%s|%s", r.Platform, r.ConnectionID, r.Domain, r.ChatID)
}
// DesktopBridge 由桌面端进程实现,让 bot 聊天获得对整个桌面端的上帝视角:
// 全局会话清单、事件订阅、以及对任意桌面 live 会话的远程审批/问答。
//
// 语义约定:
// - 审批应答与桌面 UI 是"先到者赢"controller 侧幂等,重复应答被静默忽略),
// Approve/Answer 的返回文案应体现"以先到者为准"。
// - 接管是"显式"的:/desktop takeover 绑定聊天与会话,桌面端会在会话
// transcript 里看到提示;桌面用户在该会话本地发送任意消息即自动收回
// 控制并通知远端。接管期间桌面输入不被锁定(controller 侧先到者赢)。
type DesktopBridge interface {
// Sessions 枚举当前所有桌面 live 会话(含后台 detached)。
Sessions() []DesktopSessionInfo
// SetWatch 订阅/退订当前聊天的桌面事件推送。
SetWatch(route DesktopWatchRoute, enable bool) error
// Watching 返回该聊天当前是否在订阅。
Watching(route DesktopWatchRoute) bool
// Approve 应答任意桌面会话的待审批项,返回用户可读的结果文案。
Approve(approvalID string, allow bool) (string, error)
// AskQuestions 返回某个待回答 ask 的问题列表(用于把 IM 文本解析成选项)。
AskQuestions(askID string) ([]event.AskQuestion, bool)
// Answer 应答任意桌面会话的待回答 ask,返回用户可读的结果文案。
Answer(askID string, answers []event.AskAnswer) (string, error)
// Takeover 把该聊天绑定为某个桌面会话的远程驾驶者,返回用户可读文案。
Takeover(route DesktopWatchRoute, tabID string) (string, error)
// Release 解除该聊天的接管绑定,返回用户可读文案。
Release(route DesktopWatchRoute) (string, error)
// TakeoverTab 返回该聊天当前接管的 tabID,未接管时为空串。
TakeoverTab(route DesktopWatchRoute) string
// DriveInput 把一条文本作为新 turn 提交到该聊天接管的桌面会话。
// 成功时输出会经事件转发流回聊天;返回的文案(可为空)用于即时回执。
DriveInput(route DesktopWatchRoute, text string) (string, error)
}
func desktopRouteFromMessage(msg InboundMessage) DesktopWatchRoute {
return DesktopWatchRoute{
ConnectionID: msg.ConnectionID,
Domain: msg.Domain,
Platform: msg.Platform,
ChatType: msg.ChatType,
ChatID: msg.ChatID,
}
}
const desktopCommandUsage = "用法:\n" +
"/desktop status - 查看桌面端所有 live 会话\n" +
"/desktop watch on|off|status - 订阅/退订桌面事件推送(审批请求、任务完成/出错)\n" +
"/desktop approve <id> - 批准桌面会话的待审批操作\n" +
"/desktop deny <id> - 拒绝桌面会话的待审批操作\n" +
"/desktop answer <id> <选项编号或文本> - 回答桌面会话的提问\n" +
"/desktop takeover <tab> - 接管桌面会话,后续消息直接驱动它\n" +
"/desktop release - 解除接管,回到普通 bot 会话"
// handleDesktopCommand 处理 /desktop 系列命令(上帝视角:观察 + 遥控审批)。
// 调用方已完成 admin 角色门控。
func (gw *BotGateway) handleDesktopCommand(msg InboundMessage) string {
bridge := gw.cfg.Desktop
if bridge == nil {
return "此 bot 未运行在桌面端进程内,/desktop 命令不可用。请在桌面端设置里启用 bot。"
}
fields := strings.Fields(msg.Text)
sub := ""
if len(fields) > 1 {
sub = strings.ToLower(fields[1])
}
switch sub {
case "", "status", "sessions":
return formatDesktopSessions(bridge.Sessions())
case "watch":
arg := ""
if len(fields) > 2 {
arg = strings.ToLower(fields[2])
}
route := desktopRouteFromMessage(msg)
switch arg {
case "on":
if err := bridge.SetWatch(route, true); err != nil {
return "已在本次运行中订阅桌面事件,但保存订阅失败;桌面端重启后可能需要重新订阅。"
}
return "已订阅桌面事件:审批请求、任务完成/出错会推送到本聊天。订阅已保存,桌面端重启后仍会生效。用 /desktop watch off 退订。"
case "off":
if err := bridge.SetWatch(route, false); err != nil {
return "已在本次运行中退订桌面事件,但保存失败;桌面端重启后订阅可能恢复。"
}
return "已退订桌面事件推送,持久化订阅也已移除。"
case "", "state":
if bridge.Watching(route) {
return "本聊天正在订阅桌面事件推送。用 /desktop watch off 退订。"
}
return "本聊天未订阅桌面事件推送。用 /desktop watch on 订阅。"
default:
return desktopCommandUsage
}
case "approve", "deny":
if len(fields) < 3 {
return desktopCommandUsage
}
feedback, err := bridge.Approve(fields[2], sub == "approve")
if err != nil {
return err.Error()
}
return feedback
case "answer":
if len(fields) < 4 {
return desktopCommandUsage
}
askID := fields[2]
questions, ok := bridge.AskQuestions(askID)
if !ok {
return fmt.Sprintf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
}
raw := strings.Join(fields[3:], " ")
answers := parseAskAnswers(questions, raw)
feedback, err := bridge.Answer(askID, answers)
if err != nil {
return err.Error()
}
return feedback
case "takeover":
if len(fields) < 3 {
return desktopCommandUsage
}
feedback, err := bridge.Takeover(desktopRouteFromMessage(msg), fields[2])
if err != nil {
return err.Error()
}
return feedback
case "release":
feedback, err := bridge.Release(desktopRouteFromMessage(msg))
if err != nil {
return err.Error()
}
return feedback
default:
return desktopCommandUsage
}
}
// divertToDesktopTakeover 把已接管聊天的普通消息改道到桌面会话。斜杠命令
// 不经过这里(仍走 handleSlashCommand),所以 /desktop release 永远可用。
// 返回 true 表示消息已被桌面接管通道消费。
func (gw *BotGateway) divertToDesktopTakeover(ctx context.Context, adapter Adapter, msg InboundMessage) bool {
if strings.HasPrefix(strings.TrimSpace(msg.Text), "/") {
return false
}
bridge := gw.cfg.Desktop
if bridge == nil {
return false
}
route := desktopRouteFromMessage(msg)
if bridge.TakeoverTab(route) == "" {
return false
}
// Re-check the continuing capability, not only the command that created it.
// A runtime refresh can revoke admin while leaving this process-local binding
// alive; base allowlist admission alone must not preserve takeover power.
if !gw.checkCommandRole(msg.Platform, msg, "admin") {
_, _ = bridge.Release(route)
_ = gw.sendText(ctx, adapter, msg, "桌面接管已解除:当前账号不再具有 bot 管理员权限。")
return true
}
if strings.TrimSpace(msg.Text) == "" {
_ = gw.sendText(ctx, adapter, msg, "接管模式暂不支持转发附件,请发送文本。")
return true
}
feedback, err := bridge.DriveInput(route, msg.Text)
if err != nil {
_ = gw.sendText(ctx, adapter, msg, err.Error())
return true
}
if strings.TrimSpace(feedback) != "" {
_ = gw.sendText(ctx, adapter, msg, feedback)
}
return true
}
func formatDesktopSessions(sessions []DesktopSessionInfo) string {
if len(sessions) == 0 {
return "桌面端当前没有 live 会话。"
}
var b strings.Builder
fmt.Fprintf(&b, "桌面端 live 会话(%d 个):\n", len(sessions))
for _, s := range sessions {
state := "空闲"
switch {
case s.PendingPrompt:
state = "⚠️ 等待审批/回答"
case s.Running:
state = "▶️ 执行中"
case !s.Ready:
state = "启动中"
}
label := strings.TrimSpace(s.Label)
if label == "" {
label = strings.TrimSpace(s.Topic)
}
if label == "" {
label = "(未命名)"
}
if s.Detached {
state += "·后台"
}
fmt.Fprintf(&b, "\n- %s [%s]", label, state)
if ws := strings.TrimSpace(s.Workspace); ws != "" {
fmt.Fprintf(&b, "\n 项目: %s", ws)
}
fmt.Fprintf(&b, "\n tab: %s", s.TabID)
for _, p := range s.Pending {
kind := "审批"
if p.Kind == "ask" {
kind = "提问"
}
line := fmt.Sprintf("\n 待%s: %s", kind, p.ID)
if tool := strings.TrimSpace(p.Tool); tool != "" {
line += " (" + tool + ")"
}
b.WriteString(line)
}
}
b.WriteString("\n\n用 /desktop watch on 订阅审批与完成事件;/desktop takeover <tab> 接管某个会话。")
return b.String()
}