2380 lines
73 KiB
Go
2380 lines
73 KiB
Go
package bot
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"reasonix/internal/agent"
|
||
"reasonix/internal/boot"
|
||
"reasonix/internal/config"
|
||
"reasonix/internal/control"
|
||
"reasonix/internal/event"
|
||
)
|
||
|
||
// GatewayConfig 是 BotGateway 的配置。
|
||
type GatewayConfig struct {
|
||
Model string
|
||
ToolApprovalMode string
|
||
MaxSteps int
|
||
QueueMode string
|
||
QueueCap int
|
||
QueueDrop string
|
||
PairingEnabled bool
|
||
PairingTTL time.Duration
|
||
PairingMaxPending int
|
||
// IgnoreSelfMessages drops messages that are clearly sent by this bot. It
|
||
// uses configured SelfUserIDs plus recently returned outbound message IDs.
|
||
IgnoreSelfMessages bool
|
||
SelfUserIDs map[Platform][]string
|
||
ControlEnabled bool
|
||
ControlAddr string
|
||
ControlToken string
|
||
// ApprovalTimeout bounds how long a tool-approval/ask prompt blocks a bot
|
||
// session waiting for a remote user's reply. Zero falls back to
|
||
// defaultBotApprovalTimeout so an abandoned prompt can't wedge the bot forever
|
||
// (#4626, #4402). A negative value disables the timeout (wait indefinitely).
|
||
ApprovalTimeout time.Duration
|
||
WorkspaceRoot string
|
||
Channels map[Platform]ChannelConfig
|
||
ConnectionChannels map[string]ChannelConfig
|
||
Routes []RouteConfig
|
||
ConnectionAccess map[string]AccessConfig
|
||
Allowlist AllowlistConfig
|
||
Enabled map[Platform]bool
|
||
Debounce time.Duration
|
||
OnInbound func(InboundMessage)
|
||
// OnSessionReady notifies the host after the bot has created or reused the
|
||
// controller for an inbound remote. Hosts may persist the concrete session ID
|
||
// or keep the remote as a read-only channel.
|
||
OnSessionReady func(InboundMessage, string) error
|
||
// OnToolApprovalModeChange persists a remote IM request such as /yolo on.
|
||
// The gateway updates the live session and in-memory defaults first; this
|
||
// callback lets desktop save the chosen connection mode to user config.
|
||
OnToolApprovalModeChange func(InboundMessage, string) error
|
||
// Desktop, when the gateway is embedded in the desktop app, gives bot
|
||
// chats a god view over desktop sessions (/desktop commands): global
|
||
// status, event subscriptions, and remote approvals for any live desktop
|
||
// session. Nil when the gateway runs standalone (reasonix bot start).
|
||
Desktop DesktopBridge
|
||
}
|
||
|
||
// ChannelConfig overrides gateway defaults for one IM channel.
|
||
type ChannelConfig struct {
|
||
Model string
|
||
ToolApprovalMode string
|
||
WorkspaceRoot string
|
||
SessionMappings []SessionMapping
|
||
}
|
||
|
||
// SessionMapping is the runtime subset of a saved bot connection mapping used
|
||
// to route a remote chat/user/thread back to its intended workspace.
|
||
type SessionMapping struct {
|
||
RemoteID string
|
||
SessionID string
|
||
SessionSource string
|
||
ChatType string
|
||
UserID string
|
||
ThreadID string
|
||
Scope string
|
||
WorkspaceRoot string
|
||
UpdatedAt string
|
||
}
|
||
|
||
// RouteConfig applies per-remote overrides. Empty match fields are wildcards;
|
||
// the first matching route wins.
|
||
type RouteConfig struct {
|
||
ConnectionID string
|
||
Platform Platform
|
||
ChatType ChatType
|
||
ChatID string
|
||
UserID string
|
||
ThreadID string
|
||
Channel ChannelConfig
|
||
}
|
||
|
||
// AdapterBinding attaches an adapter instance to one saved bot connection.
|
||
// Feishu and Lark share PlatformFeishu, so ID/Domain keep their sessions,
|
||
// replies, and per-connection settings separated at runtime.
|
||
type AdapterBinding struct {
|
||
ID string
|
||
Domain string
|
||
Platform Platform
|
||
Adapter Adapter
|
||
}
|
||
|
||
// AllowlistConfig 控制哪些用户/群可以使用 bot。
|
||
type AllowlistConfig struct {
|
||
Enabled bool
|
||
AllowAll bool
|
||
Users map[Platform][]string
|
||
Approvers map[Platform][]string
|
||
Admins map[Platform][]string
|
||
Groups map[Platform][]string
|
||
}
|
||
|
||
// AccessConfig controls who may use one concrete bot connection.
|
||
type AccessConfig struct {
|
||
Enabled bool
|
||
AllowAll bool
|
||
PairingEnabled bool
|
||
Users []string
|
||
Groups []string
|
||
Approvers []string
|
||
Admins []string
|
||
}
|
||
|
||
// AdapterHealthSnapshot describes the gateway's current view of one adapter.
|
||
type AdapterHealthSnapshot struct {
|
||
ID string `json:"id"`
|
||
Platform Platform `json:"platform"`
|
||
Domain string `json:"domain,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
Status string `json:"status"`
|
||
StartedAt time.Time `json:"started_at,omitempty"`
|
||
LastMessageAt time.Time `json:"last_message_at,omitempty"`
|
||
LastSendAt time.Time `json:"last_send_at,omitempty"`
|
||
LastErrorAt time.Time `json:"last_error_at,omitempty"`
|
||
LastError string `json:"last_error,omitempty"`
|
||
Messages int64 `json:"messages"`
|
||
Sends int64 `json:"sends"`
|
||
SendErrors int64 `json:"send_errors"`
|
||
Closed bool `json:"closed"`
|
||
}
|
||
|
||
// BotGateway 是 reasonix bot 消息网关,管理 Controller 生命周期、session 并发、
|
||
// 事件渲染和平台适配器。
|
||
type BotGateway struct {
|
||
cfg GatewayConfig
|
||
adapters []AdapterBinding
|
||
sessions *SessionManager
|
||
startErr []error
|
||
|
||
mu sync.Mutex
|
||
controllers map[string]*sessionState // session key -> active state
|
||
pendingReactionCleanups map[string][]func()
|
||
allowlist map[Platform]map[string]bool
|
||
groupAllowlist map[Platform]map[string]bool
|
||
selfUserIDs map[Platform]map[string]bool
|
||
outboundMessageIDs map[string]time.Time
|
||
adapterHealth map[string]*AdapterHealthSnapshot
|
||
controlServer *controlHTTPServer
|
||
sessionOverrides map[string]sessionRuntimeOverride
|
||
|
||
logger *slog.Logger
|
||
}
|
||
|
||
// botController is the slice of the controller's driving port the gateway needs:
|
||
// session lifecycle, turn execution, and approval/ask handling. The bot never
|
||
// touches goals, checkpoints, or memory, so it depends on those sub-ports only —
|
||
// not the concrete *control.Controller and its ~99 methods.
|
||
type botController interface {
|
||
control.Lifecycle
|
||
control.TurnControl
|
||
control.Approvals
|
||
}
|
||
|
||
type sessionState struct {
|
||
ctrl botController
|
||
sink *sessionEventSink
|
||
platform Platform
|
||
connectionID string
|
||
model string
|
||
workspaceRoot string
|
||
toolApprovalMode string
|
||
sessionPath string
|
||
cancel context.CancelFunc
|
||
pendingAsks map[string][]event.AskQuestion
|
||
pendingApprovals map[string]event.Approval
|
||
lastApprovalID string
|
||
lastAskID string
|
||
createdAt time.Time
|
||
lastActive time.Time
|
||
}
|
||
|
||
type sessionRuntimeProfile struct {
|
||
model string
|
||
workspaceRoot string
|
||
toolApprovalMode string
|
||
sessionPath string
|
||
}
|
||
|
||
type sessionRuntimeOverride struct {
|
||
channel ChannelConfig
|
||
sessionPath string
|
||
label string
|
||
}
|
||
|
||
type sessionEventSink struct {
|
||
mu sync.RWMutex
|
||
target event.Sink
|
||
}
|
||
|
||
type pendingReactionAdapter interface {
|
||
AddPendingReaction(ctx context.Context, messageID string) (func(), error)
|
||
}
|
||
|
||
const outboundEchoTTL = 10 * time.Minute
|
||
|
||
func (s *sessionEventSink) setTarget(target event.Sink) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.target = target
|
||
}
|
||
|
||
func (s *sessionEventSink) Emit(e event.Event) {
|
||
s.mu.RLock()
|
||
target := s.target
|
||
s.mu.RUnlock()
|
||
if target != nil {
|
||
target.Emit(e)
|
||
}
|
||
}
|
||
|
||
// NewGateway 创建一个新的 BotGateway。
|
||
func NewGateway(cfg GatewayConfig, adapters map[Platform]Adapter, logger *slog.Logger) *BotGateway {
|
||
bindings := make([]AdapterBinding, 0, len(adapters))
|
||
for plat, adapter := range adapters {
|
||
bindings = append(bindings, AdapterBinding{ID: string(plat), Platform: plat, Adapter: adapter})
|
||
}
|
||
return NewGatewayWithAdapterBindings(cfg, bindings, logger)
|
||
}
|
||
|
||
// NewGatewayWithAdapterBindings creates a gateway with one or more adapter
|
||
// instances per platform.
|
||
func NewGatewayWithAdapterBindings(cfg GatewayConfig, adapters []AdapterBinding, logger *slog.Logger) *BotGateway {
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
if cfg.Debounce <= 0 {
|
||
cfg.Debounce = 1500 * time.Millisecond
|
||
}
|
||
cfg.QueueMode = NormalizeQueueMode(cfg.QueueMode)
|
||
if cfg.QueueCap <= 0 {
|
||
cfg.QueueCap = DefaultQueueCap
|
||
}
|
||
cfg.QueueDrop = NormalizeQueueDrop(cfg.QueueDrop)
|
||
if cfg.PairingTTL <= 0 {
|
||
cfg.PairingTTL = defaultPairingTTL
|
||
}
|
||
if cfg.PairingMaxPending <= 0 {
|
||
cfg.PairingMaxPending = defaultPairingMaxPending
|
||
}
|
||
gw := &BotGateway{
|
||
cfg: cfg,
|
||
adapters: normalizeAdapterBindings(adapters),
|
||
sessions: NewSessionManager(cfg.Debounce),
|
||
controllers: make(map[string]*sessionState),
|
||
pendingReactionCleanups: make(map[string][]func()),
|
||
allowlist: make(map[Platform]map[string]bool),
|
||
groupAllowlist: make(map[Platform]map[string]bool),
|
||
selfUserIDs: make(map[Platform]map[string]bool),
|
||
outboundMessageIDs: make(map[string]time.Time),
|
||
adapterHealth: make(map[string]*AdapterHealthSnapshot),
|
||
sessionOverrides: make(map[string]sessionRuntimeOverride),
|
||
logger: logger.With("component", "bot_gateway"),
|
||
}
|
||
gw.buildAllowlist()
|
||
gw.buildSelfUserIDs()
|
||
for _, binding := range gw.adapters {
|
||
gw.setAdapterConfigured(binding)
|
||
}
|
||
return gw
|
||
}
|
||
|
||
func normalizeAdapterBindings(adapters []AdapterBinding) []AdapterBinding {
|
||
out := make([]AdapterBinding, 0, len(adapters))
|
||
for _, binding := range adapters {
|
||
if binding.Adapter == nil {
|
||
continue
|
||
}
|
||
if binding.Platform == "" {
|
||
binding.Platform = binding.Adapter.Platform()
|
||
}
|
||
if strings.TrimSpace(binding.ID) == "" {
|
||
binding.ID = string(binding.Platform)
|
||
}
|
||
binding.ID = strings.TrimSpace(binding.ID)
|
||
binding.Domain = strings.TrimSpace(binding.Domain)
|
||
out = append(out, binding)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (gw *BotGateway) buildAllowlist() {
|
||
for _, plat := range []Platform{PlatformQQ, PlatformFeishu, PlatformWeixin} {
|
||
gw.allowlist[plat] = make(map[string]bool)
|
||
if !gw.cfg.Allowlist.Enabled {
|
||
continue
|
||
}
|
||
addAllowlistUsers(gw.allowlist[plat], gw.cfg.Allowlist.Users[plat])
|
||
addAllowlistUsers(gw.allowlist[plat], gw.cfg.Allowlist.Admins[plat])
|
||
addAllowlistUsers(gw.allowlist[plat], gw.cfg.Allowlist.Approvers[plat])
|
||
gw.groupAllowlist[plat] = make(map[string]bool)
|
||
for _, gid := range gw.cfg.Allowlist.Groups[plat] {
|
||
gw.groupAllowlist[plat][gid] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
func addAllowlistUsers(dst map[string]bool, users []string) {
|
||
for _, uid := range users {
|
||
uid = strings.TrimSpace(uid)
|
||
if uid != "" {
|
||
dst[uid] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) buildSelfUserIDs() {
|
||
for _, plat := range []Platform{PlatformQQ, PlatformFeishu, PlatformWeixin} {
|
||
gw.selfUserIDs[plat] = stringSet(gw.cfg.SelfUserIDs[plat])
|
||
}
|
||
}
|
||
|
||
// Start 启动所有已启用的平台适配器并开始处理消息。
|
||
func (gw *BotGateway) Start(ctx context.Context) error {
|
||
started := make([]AdapterBinding, 0, len(gw.adapters))
|
||
var startErr []error
|
||
for _, binding := range gw.adapters {
|
||
if !gw.cfg.Enabled[binding.Platform] {
|
||
gw.logger.Info("platform disabled, skipping", "platform", binding.Platform, "connection", binding.ID)
|
||
gw.markAdapterDisabled(binding)
|
||
continue
|
||
}
|
||
gw.logger.Info("starting adapter", "platform", binding.Platform, "connection", binding.ID, "domain", binding.Domain)
|
||
if err := binding.Adapter.Start(ctx); err != nil {
|
||
wrapped := fmt.Errorf("start adapter %s: %w", binding.ID, err)
|
||
startErr = append(startErr, wrapped)
|
||
gw.markAdapterStartFailed(binding, err)
|
||
gw.logger.Warn("adapter start failed", "platform", binding.Platform, "connection", binding.ID, "domain", binding.Domain, "err", err)
|
||
continue
|
||
}
|
||
gw.markAdapterStarted(binding)
|
||
started = append(started, binding)
|
||
}
|
||
// SendToAdapter reads gw.adapters under gw.mu; publish the started set under
|
||
// the same lock.
|
||
gw.mu.Lock()
|
||
gw.adapters = started
|
||
gw.startErr = startErr
|
||
gw.mu.Unlock()
|
||
if len(started) == 0 && len(startErr) > 0 {
|
||
return errors.Join(startErr...)
|
||
}
|
||
if err := gw.startControlServer(ctx); err != nil {
|
||
for _, binding := range started {
|
||
_ = binding.Adapter.Stop()
|
||
}
|
||
return err
|
||
}
|
||
|
||
// 合并所有适配器的消息通道
|
||
for _, binding := range gw.adapters {
|
||
go gw.dispatchLoop(ctx, binding)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (gw *BotGateway) AdapterCount() int {
|
||
return len(gw.adapters)
|
||
}
|
||
|
||
func (gw *BotGateway) StartErrors() []error {
|
||
out := make([]error, len(gw.startErr))
|
||
copy(out, gw.startErr)
|
||
return out
|
||
}
|
||
|
||
// AdapterHealth returns a stable snapshot of all configured adapter instances.
|
||
func (gw *BotGateway) AdapterHealth() []AdapterHealthSnapshot {
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
out := make([]AdapterHealthSnapshot, 0, len(gw.adapterHealth))
|
||
for _, health := range gw.adapterHealth {
|
||
if health == nil {
|
||
continue
|
||
}
|
||
out = append(out, *health)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||
return out
|
||
}
|
||
|
||
func (gw *BotGateway) setAdapterConfigured(binding AdapterBinding) {
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
gw.ensureAdapterHealthLocked(binding).Status = "configured"
|
||
}
|
||
|
||
func (gw *BotGateway) markAdapterDisabled(binding AdapterBinding) {
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
health := gw.ensureAdapterHealthLocked(binding)
|
||
health.Status = "disabled"
|
||
health.Closed = true
|
||
}
|
||
|
||
func (gw *BotGateway) markAdapterStarted(binding AdapterBinding) {
|
||
now := time.Now()
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
health := gw.ensureAdapterHealthLocked(binding)
|
||
health.Status = "running"
|
||
health.StartedAt = now
|
||
health.LastError = ""
|
||
health.Closed = false
|
||
}
|
||
|
||
func (gw *BotGateway) markAdapterStartFailed(binding AdapterBinding, err error) {
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
health := gw.ensureAdapterHealthLocked(binding)
|
||
health.Status = "error"
|
||
health.Closed = true
|
||
health.LastErrorAt = time.Now()
|
||
if err != nil {
|
||
health.LastError = err.Error()
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) markAdapterMessage(binding AdapterBinding) {
|
||
now := time.Now()
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
health := gw.ensureAdapterHealthLocked(binding)
|
||
health.Status = "running"
|
||
health.LastMessageAt = now
|
||
health.Messages++
|
||
health.Closed = false
|
||
}
|
||
|
||
func (gw *BotGateway) markAdapterClosed(binding AdapterBinding) {
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
health := gw.ensureAdapterHealthLocked(binding)
|
||
if health.Status == "running" {
|
||
health.Status = "closed"
|
||
}
|
||
health.Closed = true
|
||
}
|
||
|
||
func (gw *BotGateway) markAdapterSend(binding AdapterBinding, err error) {
|
||
now := time.Now()
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
health := gw.ensureAdapterHealthLocked(binding)
|
||
if err != nil {
|
||
health.SendErrors++
|
||
health.LastErrorAt = now
|
||
health.LastError = err.Error()
|
||
if health.Status == "running" {
|
||
health.Status = "degraded"
|
||
}
|
||
return
|
||
}
|
||
health.Sends++
|
||
health.LastSendAt = now
|
||
if health.Status == "degraded" {
|
||
health.Status = "running"
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) ensureAdapterHealthLocked(binding AdapterBinding) *AdapterHealthSnapshot {
|
||
id := strings.TrimSpace(binding.ID)
|
||
if id == "" && binding.Adapter != nil {
|
||
id = binding.Adapter.Name()
|
||
}
|
||
if id == "" {
|
||
id = string(binding.Platform)
|
||
}
|
||
health := gw.adapterHealth[id]
|
||
if health == nil {
|
||
health = &AdapterHealthSnapshot{ID: id}
|
||
gw.adapterHealth[id] = health
|
||
}
|
||
health.Platform = binding.Platform
|
||
health.Domain = strings.TrimSpace(binding.Domain)
|
||
if binding.Adapter != nil {
|
||
health.Name = binding.Adapter.Name()
|
||
}
|
||
if strings.TrimSpace(health.Status) == "" {
|
||
health.Status = "configured"
|
||
}
|
||
return health
|
||
}
|
||
|
||
// Stop 停止所有适配器并关闭所有 session。
|
||
func (gw *BotGateway) Stop() {
|
||
var states []*sessionState
|
||
gw.mu.Lock()
|
||
for key, state := range gw.controllers {
|
||
states = append(states, state)
|
||
delete(gw.controllers, key)
|
||
}
|
||
gw.mu.Unlock()
|
||
for _, state := range states {
|
||
closeBotSessionState(state)
|
||
}
|
||
|
||
for _, binding := range gw.adapters {
|
||
if err := binding.Adapter.Stop(); err != nil {
|
||
gw.logger.Warn("error stopping adapter", "platform", binding.Platform, "connection", binding.ID, "err", err)
|
||
}
|
||
gw.markAdapterClosed(binding)
|
||
}
|
||
gw.stopControlServer()
|
||
}
|
||
|
||
func (gw *BotGateway) dispatchLoop(ctx context.Context, binding AdapterBinding) {
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
gw.markAdapterClosed(binding)
|
||
return
|
||
case msg, ok := <-binding.Adapter.Messages():
|
||
if !ok {
|
||
gw.markAdapterClosed(binding)
|
||
return
|
||
}
|
||
gw.markAdapterMessage(binding)
|
||
gw.handleMessage(ctx, binding, msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) handleMessage(ctx context.Context, binding AdapterBinding, msg InboundMessage) {
|
||
msg.Platform = binding.Platform
|
||
if msg.ConnectionID == "" {
|
||
msg.ConnectionID = binding.ID
|
||
}
|
||
if msg.Domain == "" {
|
||
msg.Domain = binding.Domain
|
||
}
|
||
if gw.isSelfMessage(msg) {
|
||
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))
|
||
return
|
||
}
|
||
src := msg.Session()
|
||
key := BuildSessionKey(src)
|
||
logFields := []any{
|
||
"platform", binding.Platform,
|
||
"connection", msg.ConnectionID,
|
||
"domain", msg.Domain,
|
||
"chat_type", msg.ChatType,
|
||
"chat", hashID(msg.ChatID),
|
||
"user", hashID(msg.UserID),
|
||
"operator", hashID(msg.OperatorID),
|
||
"thread", hashID(msg.ThreadID),
|
||
"message", hashID(msg.MessageID),
|
||
"text_chars", len([]rune(msg.Text)),
|
||
"session", key[:8],
|
||
}
|
||
gw.logger.Info("bot inbound message", logFields...)
|
||
|
||
// allowlist 检查
|
||
if !gw.checkAllowlist(binding.Platform, msg) {
|
||
gw.logger.Info("user not in allowlist", "platform", binding.Platform, "connection", msg.ConnectionID, "user", hashID(msg.UserID))
|
||
if gw.offerPairing(ctx, binding.Adapter, msg) {
|
||
return
|
||
}
|
||
_ = gw.sendText(ctx, binding.Adapter, msg, "抱歉,您没有使用此 bot 的权限。")
|
||
return
|
||
}
|
||
if gw.cfg.OnInbound != nil {
|
||
gw.cfg.OnInbound(msg)
|
||
}
|
||
|
||
if normalized, ok := gw.normalizeApprovalShortcut(key, msg.Text); ok {
|
||
msg.Text = normalized
|
||
} else if normalized, ok := gw.normalizeAskShortcut(key, msg.Text); ok {
|
||
msg.Text = normalized
|
||
} else if _, ok := decisionShortcutCommand(msg.Text); ok && gw.sessions.IsActive(key) {
|
||
_ = gw.sendText(ctx, binding.Adapter, msg, "没有找到可匹配的待处理操作。请重新触发一次操作后回复编号,或按消息中的 ID 使用 /approve、/deny 或 /answer。")
|
||
return
|
||
}
|
||
|
||
// 斜杠命令处理
|
||
if IsSlashBypass(msg.Text) {
|
||
gw.logger.Info("bot slash command", logFields...)
|
||
gw.handleSlashCommand(ctx, binding.Adapter, key, msg)
|
||
return
|
||
}
|
||
|
||
// 已接管桌面会话的聊天:普通消息直接驱动那个桌面会话,不进 bot 自己的
|
||
// 会话机器(斜杠命令仍走上面的分支,/desktop release 永远可达)。
|
||
if gw.divertToDesktopTakeover(ctx, binding.Adapter, msg) {
|
||
gw.logger.Info("bot message diverted to desktop takeover", logFields...)
|
||
return
|
||
}
|
||
|
||
cleanup := gw.addPendingReaction(ctx, binding.Platform, binding.Adapter, msg)
|
||
|
||
queueMode := gw.queueMode(key, msg)
|
||
if gw.sessions.IsActive(key) {
|
||
switch queueMode {
|
||
case QueueModeSteer:
|
||
if gw.steerActiveSession(ctx, binding.Adapter, key, msg) {
|
||
gw.logger.Info("bot message steered into active turn", "session", key[:8])
|
||
if cleanup != nil {
|
||
cleanup()
|
||
}
|
||
_ = gw.sendText(ctx, binding.Adapter, msg, "已收到,会并入当前任务。")
|
||
return
|
||
}
|
||
case QueueModeInterrupt:
|
||
gw.cancelActiveSession(key)
|
||
runReactionCleanups(gw.takeReactionCleanups(key))
|
||
result := gw.sessions.ReplacePending(key, msg)
|
||
gw.storeReactionCleanup(key, cleanup)
|
||
gw.logger.Info("bot active turn interrupted; newest message queued", "session", key[:8], "pending", result.Pending)
|
||
_ = gw.sendText(ctx, binding.Adapter, msg, "已停止当前任务,稍后处理这条新消息。")
|
||
return
|
||
}
|
||
}
|
||
|
||
// session 并发控制
|
||
result := gw.sessions.TryAcquireWithQueue(key, msg, QueueOptions{
|
||
Mode: queueMode,
|
||
Cap: gw.cfg.QueueCap,
|
||
Drop: gw.cfg.QueueDrop,
|
||
})
|
||
if result.Rejected {
|
||
gw.logger.Warn("bot queue rejected message", "session", key[:8], "pending", result.Pending, "mode", result.Mode)
|
||
if cleanup != nil {
|
||
cleanup()
|
||
}
|
||
_ = gw.sendText(ctx, binding.Adapter, msg, "当前会话排队已满,请稍后再发,或使用 /queue interrupt 中断当前任务。")
|
||
return
|
||
}
|
||
if result.Queued {
|
||
gw.logger.Debug("message queued", "session", key[:8], "mode", result.Mode, "pending", result.Pending, "dropped", result.Dropped)
|
||
gw.storeReactionCleanup(key, cleanup)
|
||
return
|
||
}
|
||
if !result.Acquired {
|
||
gw.logger.Debug("session busy without queue action", "session", key[:8])
|
||
gw.storeReactionCleanup(key, cleanup)
|
||
return
|
||
}
|
||
|
||
// Run the turn on its own goroutine so the dispatch loop stays free to read
|
||
// the next inbound message. A turn that hits interactive approval/ask blocks
|
||
// inside RunTurn waiting for ctrl.Approve/AnswerQuestion — and the ONLY path
|
||
// that calls those is handleSlashCommand on this same dispatch goroutine. Run
|
||
// it inline and the loop can never deliver the /approve (or card) reply that
|
||
// would unblock it: the session wedges until restart (#4701, #4863, #4402).
|
||
// Per-session serialization is still held by the session lock (active[key]),
|
||
// which the deferred Release inside runTurn clears.
|
||
go gw.runTurn(ctx, binding.Adapter, key, msg, cleanup)
|
||
}
|
||
|
||
func (gw *BotGateway) queueMode(key string, msg InboundMessage) string {
|
||
return gw.sessions.QueueMode(key, gw.cfg.QueueMode)
|
||
}
|
||
|
||
func (gw *BotGateway) steerActiveSession(ctx context.Context, adapter Adapter, key string, msg InboundMessage) bool {
|
||
text := strings.TrimSpace(msg.Text)
|
||
if text == "" && len(msg.MediaURLs) == 0 && len(msg.Media) == 0 {
|
||
return false
|
||
}
|
||
gw.mu.Lock()
|
||
state, ok := gw.controllers[key]
|
||
gw.mu.Unlock()
|
||
if !ok || state.ctrl == nil {
|
||
return false
|
||
}
|
||
text = gw.inputTextWithMedia(ctx, adapter, msg, state)
|
||
if strings.TrimSpace(text) == "" {
|
||
return false
|
||
}
|
||
state.ctrl.Steer(text)
|
||
return true
|
||
}
|
||
|
||
func (gw *BotGateway) cancelActiveSession(key string) {
|
||
// state.cancel is rewritten under gw.mu on every turn (runTurn), so copy it
|
||
// inside the lock and invoke it outside.
|
||
var cancel context.CancelFunc
|
||
gw.mu.Lock()
|
||
state, ok := gw.controllers[key]
|
||
if ok && state != nil {
|
||
cancel = state.cancel
|
||
}
|
||
gw.mu.Unlock()
|
||
if !ok || state == nil {
|
||
return
|
||
}
|
||
if cancel != nil {
|
||
cancel()
|
||
return
|
||
}
|
||
if state.ctrl != nil {
|
||
state.ctrl.Cancel()
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) storeReactionCleanup(key string, cleanup func()) {
|
||
if cleanup == nil {
|
||
return
|
||
}
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
gw.pendingReactionCleanups[key] = append(gw.pendingReactionCleanups[key], cleanup)
|
||
}
|
||
|
||
func (gw *BotGateway) flushReactionCleanups(key string, cleanup func()) {
|
||
stored := gw.takeReactionCleanups(key)
|
||
runReactionCleanups(stored)
|
||
if cleanup != nil {
|
||
cleanup()
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) takeReactionCleanups(key string) []func() {
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
stored := gw.pendingReactionCleanups[key]
|
||
delete(gw.pendingReactionCleanups, key)
|
||
return stored
|
||
}
|
||
|
||
func runReactionCleanups(cleanups []func()) {
|
||
for _, cleanup := range cleanups {
|
||
if cleanup != nil {
|
||
cleanup()
|
||
}
|
||
}
|
||
}
|
||
|
||
func makeReactionCleanup(cleanups []func()) func() {
|
||
if len(cleanups) == 0 {
|
||
return nil
|
||
}
|
||
return func() {
|
||
runReactionCleanups(cleanups)
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) addPendingReaction(ctx context.Context, plat Platform, adapter Adapter, msg InboundMessage) func() {
|
||
if strings.TrimSpace(msg.MessageID) == "" {
|
||
return nil
|
||
}
|
||
reactor, ok := adapter.(pendingReactionAdapter)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
cleanup, err := reactor.AddPendingReaction(ctx, msg.MessageID)
|
||
if err != nil {
|
||
gw.logger.Warn("pending reaction failed", "platform", plat, "err", err)
|
||
return nil
|
||
}
|
||
return cleanup
|
||
}
|
||
|
||
func (gw *BotGateway) isSelfMessage(msg InboundMessage) bool {
|
||
if !gw.cfg.IgnoreSelfMessages {
|
||
return false
|
||
}
|
||
actor := strings.TrimSpace(msg.UserID)
|
||
if strings.TrimSpace(msg.OperatorID) != "" {
|
||
actor = strings.TrimSpace(msg.OperatorID)
|
||
}
|
||
if actor != "" && gw.selfUserIDs[msg.Platform][actor] {
|
||
return true
|
||
}
|
||
messageID := strings.TrimSpace(msg.MessageID)
|
||
if messageID == "" {
|
||
return false
|
||
}
|
||
key := outboundMessageKey(msg.Platform, msg.ConnectionID, msg.Domain, msg.ChatID, messageID)
|
||
now := time.Now()
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
gw.pruneOutboundMessagesLocked(now)
|
||
_, ok := gw.outboundMessageIDs[key]
|
||
return ok
|
||
}
|
||
|
||
func (gw *BotGateway) rememberOutboundMessage(platform Platform, connID, domain, chatID, messageID string) {
|
||
messageID = strings.TrimSpace(messageID)
|
||
if !gw.cfg.IgnoreSelfMessages || messageID == "" {
|
||
return
|
||
}
|
||
now := time.Now()
|
||
key := outboundMessageKey(platform, connID, domain, chatID, messageID)
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
gw.pruneOutboundMessagesLocked(now)
|
||
gw.outboundMessageIDs[key] = now.Add(outboundEchoTTL)
|
||
}
|
||
|
||
func (gw *BotGateway) pruneOutboundMessagesLocked(now time.Time) {
|
||
for key, expiresAt := range gw.outboundMessageIDs {
|
||
if !expiresAt.After(now) {
|
||
delete(gw.outboundMessageIDs, key)
|
||
}
|
||
}
|
||
}
|
||
|
||
func outboundMessageKey(platform Platform, connID, domain, chatID, messageID string) string {
|
||
return strings.Join([]string{
|
||
string(platform),
|
||
strings.TrimSpace(connID),
|
||
strings.TrimSpace(domain),
|
||
strings.TrimSpace(chatID),
|
||
strings.TrimSpace(messageID),
|
||
}, "\x00")
|
||
}
|
||
|
||
func (gw *BotGateway) connectionAccess(msg InboundMessage) (AccessConfig, bool) {
|
||
if gw.cfg.ConnectionAccess == nil {
|
||
return AccessConfig{}, false
|
||
}
|
||
id := strings.TrimSpace(msg.ConnectionID)
|
||
if id == "" {
|
||
return AccessConfig{}, false
|
||
}
|
||
access, ok := gw.cfg.ConnectionAccess[id]
|
||
if !ok {
|
||
return AccessConfig{}, false
|
||
}
|
||
if !accessConfigActive(access) {
|
||
return AccessConfig{}, false
|
||
}
|
||
return access, true
|
||
}
|
||
|
||
func accessConfigActive(access AccessConfig) bool {
|
||
return access.Enabled ||
|
||
access.AllowAll ||
|
||
access.PairingEnabled ||
|
||
len(access.Users) > 0 ||
|
||
len(access.Groups) > 0 ||
|
||
len(access.Approvers) > 0 ||
|
||
len(access.Admins) > 0
|
||
}
|
||
|
||
func (gw *BotGateway) checkAllowlist(plat Platform, msg InboundMessage) bool {
|
||
if access, ok := gw.connectionAccess(msg); ok {
|
||
return checkConnectionAllowlist(access, msg)
|
||
}
|
||
if gw.cfg.Allowlist.AllowAll {
|
||
return true
|
||
}
|
||
if !gw.cfg.Allowlist.Enabled {
|
||
return false
|
||
}
|
||
actor := msg.UserID
|
||
if msg.OperatorID != "" {
|
||
actor = msg.OperatorID
|
||
}
|
||
if !gw.allowlist[plat][actor] {
|
||
return false
|
||
}
|
||
groups := gw.groupAllowlist[plat]
|
||
if chatUsesGroupAllowlist(msg.ChatType) && len(groups) > 0 && !groups[msg.ChatID] {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func checkConnectionAllowlist(access AccessConfig, msg InboundMessage) bool {
|
||
if access.AllowAll {
|
||
return true
|
||
}
|
||
if !access.Enabled {
|
||
return false
|
||
}
|
||
actor := msg.UserID
|
||
if msg.OperatorID != "" {
|
||
actor = msg.OperatorID
|
||
}
|
||
users := stringSet(append(append(append([]string{}, access.Users...), access.Admins...), access.Approvers...))
|
||
groups := stringSet(access.Groups)
|
||
actorAllowed := users[actor]
|
||
groupAllowed := chatUsesGroupAllowlist(msg.ChatType) && groups[msg.ChatID]
|
||
if len(users) == 0 && len(groups) == 0 {
|
||
return false
|
||
}
|
||
return actorAllowed || groupAllowed
|
||
}
|
||
|
||
func (gw *BotGateway) requireCommandRole(ctx context.Context, adapter Adapter, msg InboundMessage, role string) bool {
|
||
if gw.checkCommandRole(msg.Platform, msg, role) {
|
||
return true
|
||
}
|
||
_ = gw.sendText(ctx, adapter, msg, "抱歉,你没有执行此 bot 命令的权限。")
|
||
return false
|
||
}
|
||
|
||
func (gw *BotGateway) checkCommandRole(plat Platform, msg InboundMessage, role string) bool {
|
||
actor := msg.UserID
|
||
if msg.OperatorID != "" {
|
||
actor = msg.OperatorID
|
||
}
|
||
if strings.TrimSpace(actor) == "" {
|
||
return false
|
||
}
|
||
if access, ok := gw.connectionAccess(msg); ok {
|
||
admins := stringSet(access.Admins)
|
||
approvers := stringSet(access.Approvers)
|
||
if len(admins) == 0 && len(approvers) == 0 {
|
||
return true
|
||
}
|
||
if admins[actor] {
|
||
return true
|
||
}
|
||
return role == "approver" && approvers[actor]
|
||
}
|
||
admins := stringSet(gw.cfg.Allowlist.Admins[plat])
|
||
approvers := stringSet(gw.cfg.Allowlist.Approvers[plat])
|
||
if len(admins) == 0 && len(approvers) == 0 {
|
||
return true
|
||
}
|
||
if admins[actor] {
|
||
return true
|
||
}
|
||
if role == "approver" && approvers[actor] {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func stringSet(values []string) map[string]bool {
|
||
out := make(map[string]bool, len(values))
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
out[value] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (gw *BotGateway) offerPairing(ctx context.Context, adapter Adapter, msg InboundMessage) bool {
|
||
if access, ok := gw.connectionAccess(msg); ok {
|
||
if !access.PairingEnabled {
|
||
return false
|
||
}
|
||
} else if !gw.cfg.PairingEnabled {
|
||
return false
|
||
}
|
||
req, created, err := CreateOrRefreshPairingRequest(msg, PairingConfig{
|
||
Enabled: true,
|
||
RequestTTL: gw.cfg.PairingTTL,
|
||
MaxPendingPerPlatform: gw.cfg.PairingMaxPending,
|
||
})
|
||
if err != nil {
|
||
gw.logger.Warn("bot pairing request failed", "platform", msg.Platform, "chat_type", msg.ChatType, "err", err)
|
||
return false
|
||
}
|
||
prefix := "需要先完成配对。"
|
||
if !created {
|
||
prefix = "你已有待批准的配对请求。"
|
||
}
|
||
text := fmt.Sprintf("%s\n配对码: %s\n请在本机运行: reasonix bot pairing approve %s\n此码将在 %s 过期。",
|
||
prefix, req.Code, req.Code, req.ExpiresAt.Local().Format("2006-01-02 15:04"))
|
||
_ = gw.sendText(ctx, adapter, msg, text)
|
||
return true
|
||
}
|
||
|
||
func chatUsesGroupAllowlist(chatType ChatType) bool {
|
||
switch chatType {
|
||
case ChatGroup, ChatGuild, ChatThread:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) normalizeApprovalShortcut(key, text string) (string, bool) {
|
||
command, ok := approvalShortcutCommand(text)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
approvalID := gw.currentPendingApprovalID(key)
|
||
if approvalID == "" {
|
||
return "", false
|
||
}
|
||
return command + " " + approvalID, true
|
||
}
|
||
|
||
func approvalShortcutCommand(text string) (string, bool) {
|
||
switch strings.ToLower(strings.TrimSpace(text)) {
|
||
case "1", "y", "yes", "ok", "同意", "批准", "允许", "允许一次":
|
||
return "/approve", true
|
||
case "2", "0", "n", "no", "deny", "拒绝":
|
||
return "/deny", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func decisionShortcutCommand(text string) (string, bool) {
|
||
if command, ok := approvalShortcutCommand(text); ok {
|
||
return command, true
|
||
}
|
||
if _, ok := askShortcutAnswer(text); ok {
|
||
return "/answer", true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func (gw *BotGateway) currentPendingApprovalID(key string) string {
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
state, ok := gw.controllers[key]
|
||
if !ok || len(state.pendingApprovals) == 0 {
|
||
return ""
|
||
}
|
||
if state.lastApprovalID != "" {
|
||
if _, ok := state.pendingApprovals[state.lastApprovalID]; ok {
|
||
return state.lastApprovalID
|
||
}
|
||
}
|
||
for id := range state.pendingApprovals {
|
||
return id
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (gw *BotGateway) forgetPendingApproval(key, id string) {
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
state, ok := gw.controllers[key]
|
||
if !ok || state.pendingApprovals == nil {
|
||
return
|
||
}
|
||
delete(state.pendingApprovals, id)
|
||
if state.lastApprovalID == id {
|
||
state.lastApprovalID = ""
|
||
for nextID := range state.pendingApprovals {
|
||
state.lastApprovalID = nextID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) normalizeAskShortcut(key, text string) (string, bool) {
|
||
raw := strings.TrimSpace(text)
|
||
if raw == "" || strings.HasPrefix(raw, "/") {
|
||
return "", false
|
||
}
|
||
askID := gw.currentPendingAskIDForReply(key)
|
||
if askID == "" {
|
||
return "", false
|
||
}
|
||
return "/answer " + askID + " " + raw, true
|
||
}
|
||
|
||
func askShortcutAnswer(text string) (string, bool) {
|
||
raw := strings.TrimSpace(text)
|
||
if raw == "" {
|
||
return "", false
|
||
}
|
||
if strings.ContainsAny(raw, " \t\n;=") {
|
||
return "", false
|
||
}
|
||
if _, err := strconv.Atoi(raw); err == nil {
|
||
return raw, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func (gw *BotGateway) currentPendingAskIDForReply(key string) string {
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
state, ok := gw.controllers[key]
|
||
if !ok || len(state.pendingAsks) == 0 {
|
||
return ""
|
||
}
|
||
if state.lastAskID != "" {
|
||
if _, ok := state.pendingAsks[state.lastAskID]; ok {
|
||
return state.lastAskID
|
||
}
|
||
}
|
||
if len(state.pendingAsks) != 1 {
|
||
return ""
|
||
}
|
||
for id := range state.pendingAsks {
|
||
return id
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (gw *BotGateway) handleSlashCommand(ctx context.Context, adapter Adapter, key string, msg InboundMessage) {
|
||
switch {
|
||
case strings.HasPrefix(msg.Text, "/stop"):
|
||
var cancel context.CancelFunc
|
||
gw.mu.Lock()
|
||
if state, ok := gw.controllers[key]; ok {
|
||
cancel = state.cancel
|
||
}
|
||
gw.mu.Unlock()
|
||
if cancel != nil {
|
||
cancel()
|
||
}
|
||
gw.sessions.ForceRelease(key)
|
||
_ = gw.sendText(ctx, adapter, msg, "已停止当前任务。")
|
||
|
||
case strings.HasPrefix(msg.Text, "/new") || strings.HasPrefix(msg.Text, "/reset"):
|
||
var cancel context.CancelFunc
|
||
gw.mu.Lock()
|
||
state, ok := gw.controllers[key]
|
||
if ok {
|
||
cancel = state.cancel
|
||
}
|
||
gw.mu.Unlock()
|
||
if ok {
|
||
if cancel != nil {
|
||
cancel()
|
||
}
|
||
// NewSession refuses to rotate while a turn is running; the cancel
|
||
// above is asynchronous, so give the turn a bounded window to
|
||
// unwind before rotating.
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
for state.ctrl.Running() && time.Now().Before(deadline) {
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
if err := state.ctrl.NewSession(); err != nil {
|
||
gw.logger.Warn("new session failed", "err", err)
|
||
gw.sessions.ForceRelease(key)
|
||
_ = gw.sendText(ctx, adapter, msg, "新会话创建失败,请稍后重试。")
|
||
return
|
||
}
|
||
gw.rememberSessionReady(msg, state.ctrl)
|
||
}
|
||
gw.sessions.ForceRelease(key)
|
||
_ = gw.sendText(ctx, adapter, msg, "已开始新会话。")
|
||
|
||
case strings.HasPrefix(msg.Text, "/approve"):
|
||
if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
|
||
return
|
||
}
|
||
// 从消息中解析 approval ID
|
||
parts := strings.Fields(msg.Text)
|
||
if len(parts) < 2 {
|
||
_ = gw.sendText(ctx, adapter, msg, "用法: /approve <id>")
|
||
return
|
||
}
|
||
gw.mu.Lock()
|
||
state, ok := gw.controllers[key]
|
||
gw.mu.Unlock()
|
||
if ok && state.ctrl != nil {
|
||
state.ctrl.Approve(parts[1], true, false, false)
|
||
gw.forgetPendingApproval(key, parts[1])
|
||
_ = gw.sendText(ctx, adapter, msg, "已批准。")
|
||
} else {
|
||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待审批操作,请重新触发一次操作。")
|
||
}
|
||
|
||
case strings.HasPrefix(msg.Text, "/deny"):
|
||
if !gw.requireCommandRole(ctx, adapter, msg, "approver") {
|
||
return
|
||
}
|
||
parts := strings.Fields(msg.Text)
|
||
if len(parts) < 2 {
|
||
_ = gw.sendText(ctx, adapter, msg, "用法: /deny <id>")
|
||
return
|
||
}
|
||
gw.mu.Lock()
|
||
state, ok := gw.controllers[key]
|
||
gw.mu.Unlock()
|
||
if ok && state.ctrl != nil {
|
||
state.ctrl.Approve(parts[1], false, false, false)
|
||
gw.forgetPendingApproval(key, parts[1])
|
||
_ = gw.sendText(ctx, adapter, msg, "已拒绝。")
|
||
} else {
|
||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话中的待审批操作,请重新触发一次操作。")
|
||
}
|
||
|
||
case strings.HasPrefix(msg.Text, "/answer"):
|
||
parts := strings.Fields(msg.Text)
|
||
if len(parts) < 3 {
|
||
_ = gw.sendText(ctx, adapter, msg, "用法: /answer <id> <选项或 q1=选项;q2=选项>")
|
||
return
|
||
}
|
||
askID := parts[1]
|
||
rawAnswer := strings.TrimSpace(strings.Join(parts[2:], " "))
|
||
gw.mu.Lock()
|
||
state, ok := gw.controllers[key]
|
||
var questions []event.AskQuestion
|
||
if ok {
|
||
questions = state.pendingAsks[askID]
|
||
delete(state.pendingAsks, askID)
|
||
if state.lastAskID == askID {
|
||
state.lastAskID = ""
|
||
for nextID := range state.pendingAsks {
|
||
state.lastAskID = nextID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
gw.mu.Unlock()
|
||
if !ok || state.ctrl == nil {
|
||
_ = gw.sendText(ctx, adapter, msg, "没有找到当前会话。")
|
||
return
|
||
}
|
||
answers := parseAskAnswers(questions, rawAnswer)
|
||
state.ctrl.AnswerQuestion(askID, answers)
|
||
_ = gw.sendText(ctx, adapter, msg, "已提交回答。")
|
||
|
||
case strings.HasPrefix(msg.Text, "/yolo") || strings.HasPrefix(msg.Text, "/mode"):
|
||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||
return
|
||
}
|
||
mode, statusOnly, ok := parseToolApprovalModeCommand(msg.Text)
|
||
if !ok {
|
||
_ = gw.sendText(ctx, adapter, msg, "用法: /yolo on|off|auto|status,或 /mode yolo|ask|auto")
|
||
return
|
||
}
|
||
if statusOnly {
|
||
_ = gw.sendText(ctx, adapter, msg, gw.toolApprovalModeStatusText(key, msg))
|
||
return
|
||
}
|
||
persistErr := gw.setToolApprovalModeForMessage(key, msg, mode)
|
||
text := toolApprovalModeChangedText(mode)
|
||
if persistErr != nil {
|
||
text += "\n当前会话已生效,但保存到设置失败:" + persistErr.Error()
|
||
}
|
||
_ = gw.sendText(ctx, adapter, msg, text)
|
||
|
||
case strings.HasPrefix(msg.Text, "/queue"):
|
||
mode, clear, statusOnly, ok := parseQueueCommand(msg.Text)
|
||
if !ok {
|
||
_ = gw.sendText(ctx, adapter, msg, "用法: /queue steer|followup|collect|interrupt|status|default")
|
||
return
|
||
}
|
||
if statusOnly {
|
||
_ = gw.sendText(ctx, adapter, msg, gw.queueStatusText(key, msg))
|
||
return
|
||
}
|
||
if clear {
|
||
gw.sessions.ClearQueueMode(key)
|
||
_ = gw.sendText(ctx, adapter, msg, "已恢复默认队列模式:"+queueModeLabel(gw.queueMode(key, msg))+"。")
|
||
return
|
||
}
|
||
gw.sessions.SetQueueMode(key, mode)
|
||
_ = gw.sendText(ctx, adapter, msg, "已切换队列模式:"+queueModeLabel(mode)+"。")
|
||
|
||
case slashCommandVerb(msg.Text) == "/projects":
|
||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||
return
|
||
}
|
||
query := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/projects"))
|
||
_ = gw.sendText(ctx, adapter, msg, formatBotProjects(gw.buildProjectIndex(), query, botProjectListLimit))
|
||
|
||
case slashCommandVerb(msg.Text) == "/use":
|
||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||
return
|
||
}
|
||
_ = gw.sendText(ctx, adapter, msg, gw.handleUseProjectCommand(key, msg.Text))
|
||
|
||
case slashCommandVerb(msg.Text) == "/sessions":
|
||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||
return
|
||
}
|
||
_ = gw.sendText(ctx, adapter, msg, gw.handleSessionsCommand(msg.Text))
|
||
|
||
case slashCommandVerb(msg.Text) == "/attach":
|
||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||
return
|
||
}
|
||
_ = gw.sendText(ctx, adapter, msg, gw.handleAttachSessionCommand(key, msg.Text))
|
||
|
||
case slashCommandVerb(msg.Text) == "/search":
|
||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||
return
|
||
}
|
||
_ = gw.sendText(ctx, adapter, msg, gw.handleProjectSearchCommand(ctx, msg.Text))
|
||
|
||
case strings.HasPrefix(msg.Text, "/desktop"):
|
||
// God view over the embedding desktop app: listing every live desktop
|
||
// session and answering its approvals is strictly more power than the
|
||
// per-session approver role, so gate on admin.
|
||
if !gw.requireCommandRole(ctx, adapter, msg, "admin") {
|
||
return
|
||
}
|
||
_ = gw.sendText(ctx, adapter, msg, gw.handleDesktopCommand(msg))
|
||
|
||
case strings.HasPrefix(msg.Text, "/status"):
|
||
active := gw.sessions.ActiveCount()
|
||
pending := gw.sessions.PendingCount(key)
|
||
gw.mu.Lock()
|
||
sessions := len(gw.controllers)
|
||
gw.mu.Unlock()
|
||
mode := gw.currentToolApprovalMode(key, msg)
|
||
_ = 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()))
|
||
|
||
case strings.HasPrefix(msg.Text, "/help"):
|
||
help := "可用命令:\n" +
|
||
"/stop - 停止当前任务\n" +
|
||
"/new - 开始新会话\n" +
|
||
"/reset - 重置会话\n" +
|
||
"/approve <id> - 批准操作\n" +
|
||
"/deny <id> - 拒绝操作\n" +
|
||
"/answer <id> <选项> - 回答 ask 问题\n" +
|
||
"/yolo on|off|auto|status - 切换或查看工具审批模式\n" +
|
||
"/mode yolo|ask|auto - 切换工具审批模式\n" +
|
||
"/queue steer|followup|collect|interrupt|status - 切换或查看队列模式\n" +
|
||
"/projects [关键词] - 查看可切换项目索引\n" +
|
||
"/use project <id|名称> - 将当前远端会话切到某个项目\n" +
|
||
"/sessions search <关键词> - 搜索可 attach 的历史会话\n" +
|
||
"/attach session <id|关键词> - 绑定当前远端会话到已有历史会话\n" +
|
||
"/search all <关键词> - 跨已索引项目检索文件内容\n" +
|
||
"/desktop status|watch|approve|deny|answer - 桌面端上帝视角(需内嵌运行)\n" +
|
||
"/status - 查看状态\n" +
|
||
"/help - 显示帮助"
|
||
_ = gw.sendText(ctx, adapter, msg, help)
|
||
}
|
||
}
|
||
|
||
func slashCommandVerb(text string) string {
|
||
parts := strings.Fields(strings.TrimSpace(text))
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
return strings.ToLower(parts[0])
|
||
}
|
||
|
||
func (gw *BotGateway) handleUseProjectCommand(key, text string) string {
|
||
selector := parseUseProjectSelector(text)
|
||
if selector == "" {
|
||
return "用法: /use project <项目 id|名称|路径>,或 /use project default 恢复默认路由。"
|
||
}
|
||
if isDefaultBotSelector(selector) {
|
||
gw.setSessionRuntimeOverride(key, sessionRuntimeOverride{}, false)
|
||
return "已恢复当前远端会话的默认项目路由。下一条消息会按 bot 配置重新选择 workspace。"
|
||
}
|
||
projects := gw.buildProjectIndex()
|
||
project, matches := resolveBotProject(projects, selector)
|
||
if project.Root == "" {
|
||
if len(matches) > 0 {
|
||
return "匹配到多个项目,请使用项目 id:\n" + formatBotProjects(matches, "", botProjectListLimit)
|
||
}
|
||
return "没有匹配的项目。可先用 /projects 查看当前索引。"
|
||
}
|
||
gw.setSessionRuntimeOverride(key, sessionRuntimeOverride{
|
||
channel: ChannelConfig{WorkspaceRoot: project.Root},
|
||
label: "project:" + project.ID,
|
||
}, true)
|
||
return fmt.Sprintf("已将当前远端会话切到项目 %s %s。\n下一条消息将在 %s 中运行。", project.ID, project.Name, displayBotPath(project.Root))
|
||
}
|
||
|
||
func parseUseProjectSelector(text string) string {
|
||
parts := strings.Fields(text)
|
||
if len(parts) < 2 || strings.ToLower(parts[0]) != "/use" {
|
||
return ""
|
||
}
|
||
if len(parts) >= 3 && strings.EqualFold(parts[1], "project") {
|
||
return strings.TrimSpace(strings.Join(parts[2:], " "))
|
||
}
|
||
return strings.TrimSpace(strings.Join(parts[1:], " "))
|
||
}
|
||
|
||
func (gw *BotGateway) handleSessionsCommand(text string) string {
|
||
query := parseSessionsQuery(text)
|
||
projects := gw.buildProjectIndex()
|
||
sessions := gw.buildSessionIndex(projects)
|
||
return formatBotSessions(sessions, query, botSessionListLimit)
|
||
}
|
||
|
||
func parseSessionsQuery(text string) string {
|
||
parts := strings.Fields(text)
|
||
if len(parts) <= 1 {
|
||
return ""
|
||
}
|
||
if strings.EqualFold(parts[1], "search") {
|
||
return strings.TrimSpace(strings.Join(parts[2:], " "))
|
||
}
|
||
return strings.TrimSpace(strings.Join(parts[1:], " "))
|
||
}
|
||
|
||
func (gw *BotGateway) handleAttachSessionCommand(key, text string) string {
|
||
selector := parseAttachSessionSelector(text)
|
||
if selector == "" {
|
||
return "用法: /attach session <会话 id|关键词|path:...>"
|
||
}
|
||
projects := gw.buildProjectIndex()
|
||
sessions := gw.buildSessionIndex(projects)
|
||
session, matches := resolveBotSession(sessions, selector)
|
||
if session.ID == "" {
|
||
if len(matches) > 0 {
|
||
return "匹配到多个会话,请使用会话 id:\n" + formatBotSessions(matches, "", botSessionListLimit)
|
||
}
|
||
return "没有匹配的会话。可先用 /sessions search <关键词> 查看当前索引。"
|
||
}
|
||
if session.SessionPath == "" {
|
||
return "这个会话没有可恢复的 path: transcript,暂时不能 attach。"
|
||
}
|
||
if info, err := os.Stat(session.SessionPath); err != nil || info.IsDir() {
|
||
return "会话文件不可用或已被移动:" + displayBotPath(session.SessionPath)
|
||
}
|
||
workspaceRoot := session.WorkspaceRoot
|
||
if workspaceRoot == "" {
|
||
project := botProjectForPath(projects, session.SessionPath)
|
||
workspaceRoot = project.Root
|
||
}
|
||
gw.setSessionRuntimeOverride(key, sessionRuntimeOverride{
|
||
channel: ChannelConfig{WorkspaceRoot: workspaceRoot},
|
||
sessionPath: session.SessionPath,
|
||
label: "session:" + session.ID,
|
||
}, true)
|
||
projectName := firstNonEmptyString(session.ProjectName, botProjectName(workspaceRoot), "global")
|
||
return fmt.Sprintf("已 attach 到会话 %s(%s)。\n下一条消息会从 %s 继续。", session.ID, projectName, displayBotPath(session.SessionPath))
|
||
}
|
||
|
||
func parseAttachSessionSelector(text string) string {
|
||
parts := strings.Fields(text)
|
||
if len(parts) < 3 || !strings.EqualFold(parts[0], "/attach") || !strings.EqualFold(parts[1], "session") {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(strings.Join(parts[2:], " "))
|
||
}
|
||
|
||
func (gw *BotGateway) handleProjectSearchCommand(ctx context.Context, text string) string {
|
||
parts := strings.Fields(text)
|
||
if len(parts) < 3 || !strings.EqualFold(parts[1], "all") {
|
||
return "用法: /search all <关键词>"
|
||
}
|
||
query := strings.TrimSpace(strings.Join(parts[2:], " "))
|
||
searchCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||
defer cancel()
|
||
results, err := searchBotProjects(searchCtx, gw.buildProjectIndex(), query, botSearchListLimit)
|
||
if err != nil {
|
||
return "检索失败:" + err.Error()
|
||
}
|
||
return formatBotProjectSearchResults(results, botSearchListLimit)
|
||
}
|
||
|
||
func (gw *BotGateway) setSessionRuntimeOverride(key string, override sessionRuntimeOverride, enabled bool) {
|
||
var old *sessionState
|
||
gw.mu.Lock()
|
||
if state, ok := gw.controllers[key]; ok {
|
||
old = state
|
||
delete(gw.controllers, key)
|
||
}
|
||
if enabled {
|
||
override.sessionPath = canonicalBotPath(override.sessionPath)
|
||
override.channel.WorkspaceRoot = canonicalBotPath(override.channel.WorkspaceRoot)
|
||
gw.sessionOverrides[key] = override
|
||
} else {
|
||
delete(gw.sessionOverrides, key)
|
||
}
|
||
gw.mu.Unlock()
|
||
closeBotSessionState(old)
|
||
gw.sessions.ForceRelease(key)
|
||
}
|
||
|
||
func (gw *BotGateway) sessionRuntimeOverrideForMessage(msg InboundMessage) (sessionRuntimeOverride, bool) {
|
||
key := BuildSessionKey(msg.Session())
|
||
gw.mu.Lock()
|
||
defer gw.mu.Unlock()
|
||
override, ok := gw.sessionOverrides[key]
|
||
return override, ok
|
||
}
|
||
|
||
func isDefaultBotSelector(selector string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(selector)) {
|
||
case "default", "reset", "inherit", "global", "none", "默认", "重置":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func parseQueueCommand(text string) (mode string, clear bool, statusOnly bool, ok bool) {
|
||
parts := strings.Fields(text)
|
||
if len(parts) == 0 || strings.ToLower(strings.TrimSpace(parts[0])) != "/queue" {
|
||
return "", false, false, false
|
||
}
|
||
if len(parts) == 1 {
|
||
return "", false, true, true
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(parts[1])) {
|
||
case "status", "state", "show", "状态", "查看":
|
||
return "", false, true, true
|
||
case "default", "reset", "inherit", "默认", "重置":
|
||
return "", true, false, true
|
||
default:
|
||
if normalized := NormalizeOptionalQueueMode(parts[1]); normalized != "" {
|
||
return normalized, false, false, true
|
||
}
|
||
return "", false, false, false
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) queueStatusText(key string, msg InboundMessage) string {
|
||
return fmt.Sprintf("当前队列模式:%s\n当前会话排队: %d\n全局上限: %d\n溢出策略: %s\n用法:/queue steer|followup|collect|interrupt|status|default",
|
||
queueModeLabel(gw.queueMode(key, msg)),
|
||
gw.sessions.PendingCount(key),
|
||
gw.cfg.QueueCap,
|
||
queueDropLabel(gw.cfg.QueueDrop),
|
||
)
|
||
}
|
||
|
||
func queueModeLabel(mode string) string {
|
||
switch NormalizeQueueMode(mode) {
|
||
case QueueModeFollowup:
|
||
return "逐条跟进"
|
||
case QueueModeCollect:
|
||
return "合并收集"
|
||
case QueueModeInterrupt:
|
||
return "打断重跑"
|
||
default:
|
||
return "即时补充"
|
||
}
|
||
}
|
||
|
||
func queueDropLabel(drop string) string {
|
||
switch NormalizeQueueDrop(drop) {
|
||
case QueueDropOld:
|
||
return "丢弃最早消息"
|
||
case QueueDropNew:
|
||
return "拒绝新消息"
|
||
default:
|
||
return "压缩摘要"
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) adapterHealthSummaryText() string {
|
||
snapshots := gw.AdapterHealth()
|
||
if len(snapshots) == 0 {
|
||
return "未启动"
|
||
}
|
||
parts := make([]string, 0, len(snapshots))
|
||
for _, h := range snapshots {
|
||
label := strings.TrimSpace(h.ID)
|
||
if label == "" {
|
||
label = string(h.Platform)
|
||
}
|
||
status := strings.TrimSpace(h.Status)
|
||
if status == "" {
|
||
status = "unknown"
|
||
}
|
||
parts = append(parts, fmt.Sprintf("%s=%s", label, status))
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
func parseToolApprovalModeCommand(text string) (mode string, statusOnly bool, ok bool) {
|
||
parts := strings.Fields(text)
|
||
if len(parts) == 0 {
|
||
return "", false, false
|
||
}
|
||
cmd := strings.ToLower(strings.TrimSpace(parts[0]))
|
||
switch cmd {
|
||
case "/yolo":
|
||
if len(parts) == 1 {
|
||
return control.ToolApprovalYolo, false, true
|
||
}
|
||
return parseToolApprovalModeArg(parts[1])
|
||
case "/mode":
|
||
if len(parts) == 1 {
|
||
return "", true, true
|
||
}
|
||
return parseToolApprovalModeArg(parts[1])
|
||
default:
|
||
return "", false, false
|
||
}
|
||
}
|
||
|
||
func parseToolApprovalModeArg(arg string) (mode string, statusOnly bool, ok bool) {
|
||
switch strings.ToLower(strings.TrimSpace(arg)) {
|
||
case "status", "state", "show", "状态", "查看":
|
||
return "", true, true
|
||
case "on", "enable", "enabled", "true", "1", "yolo", "full", "full-access", "bypass", "开启", "打开":
|
||
return control.ToolApprovalYolo, false, true
|
||
case "off", "disable", "disabled", "false", "0", "ask", "询问", "关闭":
|
||
return control.ToolApprovalAsk, false, true
|
||
case "auto", "自动":
|
||
return control.ToolApprovalAuto, false, true
|
||
default:
|
||
return "", false, false
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) setToolApprovalModeForMessage(key string, msg InboundMessage, mode string) error {
|
||
mode = normalizeBotToolApprovalMode(mode)
|
||
var ctrl botController
|
||
|
||
gw.mu.Lock()
|
||
if state, ok := gw.controllers[key]; ok {
|
||
ctrl = state.ctrl
|
||
}
|
||
gw.updateToolApprovalModeDefaultLocked(msg, mode)
|
||
gw.mu.Unlock()
|
||
|
||
if ctrl != nil {
|
||
ctrl.SetToolApprovalMode(mode)
|
||
}
|
||
if gw.cfg.OnToolApprovalModeChange != nil {
|
||
return gw.cfg.OnToolApprovalModeChange(msg, mode)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (gw *BotGateway) updateToolApprovalModeDefaultLocked(msg InboundMessage, mode string) {
|
||
if id := strings.TrimSpace(msg.ConnectionID); id != "" {
|
||
if gw.cfg.ConnectionChannels == nil {
|
||
gw.cfg.ConnectionChannels = make(map[string]ChannelConfig)
|
||
}
|
||
channel := gw.cfg.ConnectionChannels[id]
|
||
channel.ToolApprovalMode = mode
|
||
gw.cfg.ConnectionChannels[id] = channel
|
||
return
|
||
}
|
||
if msg.Platform != "" {
|
||
if gw.cfg.Channels == nil {
|
||
gw.cfg.Channels = make(map[Platform]ChannelConfig)
|
||
}
|
||
channel := gw.cfg.Channels[msg.Platform]
|
||
channel.ToolApprovalMode = mode
|
||
gw.cfg.Channels[msg.Platform] = channel
|
||
return
|
||
}
|
||
gw.cfg.ToolApprovalMode = mode
|
||
}
|
||
|
||
func (gw *BotGateway) currentToolApprovalMode(key string, msg InboundMessage) string {
|
||
var ctrl botController
|
||
gw.mu.Lock()
|
||
if state, ok := gw.controllers[key]; ok {
|
||
ctrl = state.ctrl
|
||
}
|
||
gw.mu.Unlock()
|
||
if ctrl != nil {
|
||
return ctrl.ToolApprovalMode()
|
||
}
|
||
_, _, mode := gw.sessionOptionsForMessage(msg)
|
||
return mode
|
||
}
|
||
|
||
func (gw *BotGateway) toolApprovalModeStatusText(key string, msg InboundMessage) string {
|
||
mode := gw.currentToolApprovalMode(key, msg)
|
||
return fmt.Sprintf("当前工具审批模式:%s\n用法:/yolo on|off|auto|status,或 /mode yolo|ask|auto", toolApprovalModeLabel(mode))
|
||
}
|
||
|
||
func toolApprovalModeChangedText(mode string) string {
|
||
switch normalizeBotToolApprovalMode(mode) {
|
||
case control.ToolApprovalYolo:
|
||
return "已开启 YOLO:普通工具审批将自动放行;Ask 问题和计划批准仍会等待确认。"
|
||
case control.ToolApprovalAuto:
|
||
return "已切换为自动模式:策略允许的工具会自动放行,仍保留需要询问或拒绝的规则。"
|
||
default:
|
||
return "已切回询问模式:工具执行前会请求确认。"
|
||
}
|
||
}
|
||
|
||
func toolApprovalModeLabel(mode string) string {
|
||
switch normalizeBotToolApprovalMode(mode) {
|
||
case control.ToolApprovalYolo:
|
||
return "YOLO"
|
||
case control.ToolApprovalAuto:
|
||
return "自动"
|
||
default:
|
||
return "询问"
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) runTurn(ctx context.Context, adapter Adapter, key string, msg InboundMessage, cleanup func()) {
|
||
gw.logger.Info("bot turn started", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
|
||
defer func() {
|
||
// 检查是否有等待队列中的消息
|
||
next := gw.sessions.Release(key)
|
||
if next != nil {
|
||
if cleanup != nil {
|
||
cleanup()
|
||
}
|
||
nextCleanup := makeReactionCleanup(gw.takeReactionCleanups(key))
|
||
gw.logger.Info("bot pending message released", "platform", next.Platform, "chat_type", next.ChatType, "chat", hashID(next.ChatID), "session", key[:8])
|
||
gw.runTurn(ctx, adapter, key, *next, nextCleanup)
|
||
return
|
||
}
|
||
gw.flushReactionCleanups(key, cleanup)
|
||
}()
|
||
|
||
// 获取或创建 Controller
|
||
state := gw.getOrCreateSession(ctx, key, msg)
|
||
if state == nil || state.ctrl == nil {
|
||
_ = gw.sendText(ctx, adapter, msg, "内部错误:无法创建会话。")
|
||
return
|
||
}
|
||
gw.rememberSessionReady(msg, state.ctrl)
|
||
|
||
// 构建输入文本:群聊中在消息前加上发送者名,并把 IM 媒体保存为 @附件引用。
|
||
input := gw.inputTextWithMedia(ctx, adapter, msg, state)
|
||
if msg.ChatType == ChatGroup {
|
||
userName := strings.TrimSpace(msg.UserName)
|
||
if msg.ResolveUserName != nil {
|
||
if resolved := strings.TrimSpace(msg.ResolveUserName(ctx)); resolved != "" {
|
||
userName = resolved
|
||
}
|
||
}
|
||
input = fmt.Sprintf("[%s] %s", userName, input)
|
||
}
|
||
|
||
// 发送"正在输入"状态
|
||
_ = adapter.SendTyping(ctx, msg.ChatID)
|
||
|
||
// 创建事件渲染 sink
|
||
sink := newRenderSink(
|
||
ctx,
|
||
adapter,
|
||
msg.ConnectionID,
|
||
msg.Domain,
|
||
msg.ChatID,
|
||
msg.ChatType,
|
||
msg.UserID,
|
||
msg.MessageID,
|
||
gw.logger,
|
||
func(approval event.Approval) {
|
||
gw.mu.Lock()
|
||
if state.pendingApprovals == nil {
|
||
state.pendingApprovals = make(map[string]event.Approval)
|
||
}
|
||
state.pendingApprovals[approval.ID] = approval
|
||
state.lastApprovalID = approval.ID
|
||
gw.mu.Unlock()
|
||
},
|
||
func(ask event.Ask) {
|
||
gw.mu.Lock()
|
||
if state.pendingAsks == nil {
|
||
state.pendingAsks = make(map[string][]event.AskQuestion)
|
||
}
|
||
state.pendingAsks[ask.ID] = ask.Questions
|
||
state.lastAskID = ask.ID
|
||
gw.mu.Unlock()
|
||
},
|
||
)
|
||
// Finish initializing the sink before publishing it as the live target: once
|
||
// setTarget runs, other goroutines can reach this sink via state.sink.Emit.
|
||
sink.ctrl = state.ctrl
|
||
state.sink.setTarget(sink)
|
||
defer state.sink.setTarget(nil)
|
||
|
||
// 创建带取消的 context
|
||
turnCtx, cancel := context.WithCancel(ctx)
|
||
defer cancel()
|
||
|
||
gw.mu.Lock()
|
||
state.cancel = cancel
|
||
state.lastActive = time.Now()
|
||
gw.mu.Unlock()
|
||
|
||
// 运行一轮对话
|
||
err := state.ctrl.RunTurn(turnCtx, input)
|
||
sink.Emit(event.Event{Kind: event.TurnDone, Err: err})
|
||
if err != nil {
|
||
gw.logger.Warn("turn error", "session", key[:8], "err", err)
|
||
return
|
||
}
|
||
gw.logger.Info("bot turn completed", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
|
||
}
|
||
|
||
func (gw *BotGateway) inputTextWithMedia(ctx context.Context, adapter Adapter, msg InboundMessage, state *sessionState) string {
|
||
input := msg.Text
|
||
if len(msg.MediaURLs) == 0 && len(msg.Media) == 0 {
|
||
return input
|
||
}
|
||
workspaceRoot := ""
|
||
if state != nil && state.ctrl != nil {
|
||
workspaceRoot = state.ctrl.WorkspaceRoot()
|
||
}
|
||
if strings.TrimSpace(workspaceRoot) == "" {
|
||
_, workspaceRoot, _ = gw.sessionOptionsForMessage(msg)
|
||
}
|
||
refs, errs := saveInboundMedia(ctx, workspaceRoot, msg.MediaURLs)
|
||
itemRefs, fallbacks, itemErrs := saveInboundMediaItems(ctx, workspaceRoot, msg.Media)
|
||
refs = append(refs, itemRefs...)
|
||
errs = append(errs, itemErrs...)
|
||
if len(errs) > 0 {
|
||
gw.logger.Warn("bot media attachment failed", "platform", msg.Platform, "chat", hashID(msg.ChatID), "errors", len(errs))
|
||
_ = gw.sendText(ctx, adapter, msg, fmt.Sprintf("有 %d 个附件保存失败;我会先处理可用内容。", len(errs)))
|
||
}
|
||
return appendMediaRefs(appendMediaFallbacks(input, fallbacks), refs)
|
||
}
|
||
|
||
func (gw *BotGateway) getOrCreateSession(ctx context.Context, key string, msg InboundMessage) *sessionState {
|
||
profile := gw.sessionProfileForMessage(msg)
|
||
var stale *sessionState
|
||
gw.mu.Lock()
|
||
if state, ok := gw.controllers[key]; ok {
|
||
if !sessionStateMatchesRuntime(state, profile) {
|
||
delete(gw.controllers, key)
|
||
stale = state
|
||
gw.mu.Unlock()
|
||
closeBotSessionState(stale)
|
||
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(stale.workspaceRoot) != "", "new_workspace_set", profile.workspaceRoot != "", "old_model", stale.model, "new_model", profile.model)
|
||
} else {
|
||
updateSessionStateRuntime(state, msg, profile)
|
||
gw.mu.Unlock()
|
||
safeBotSetToolApprovalMode(state.ctrl, profile.toolApprovalMode)
|
||
gw.logger.Info("bot session reused", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
|
||
return state
|
||
}
|
||
} else {
|
||
gw.mu.Unlock()
|
||
}
|
||
|
||
// 创建新 Controller
|
||
sessionSink := &sessionEventSink{}
|
||
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)
|
||
ctrl, err := boot.Build(ctx, boot.Options{
|
||
Model: profile.model,
|
||
MaxSteps: gw.cfg.MaxSteps,
|
||
RequireKey: true,
|
||
Sink: sessionSink,
|
||
WorkspaceRoot: profile.workspaceRoot,
|
||
SessionDir: botSessionDir(profile.workspaceRoot),
|
||
ApprovalTimeout: gw.approvalTimeout(),
|
||
})
|
||
if err != nil {
|
||
gw.logger.Error("build controller failed", "err", err)
|
||
return nil
|
||
}
|
||
if profile.sessionPath != "" {
|
||
loaded, err := agent.LoadSession(profile.sessionPath)
|
||
if err != nil {
|
||
ctrl.Close()
|
||
if os.IsNotExist(err) {
|
||
gw.logger.Error("attached bot session missing", "session_path", profile.sessionPath)
|
||
} else {
|
||
gw.logger.Error("attached bot session load failed", "session_path", profile.sessionPath, "err", err)
|
||
}
|
||
return nil
|
||
}
|
||
ctrl.Resume(loaded, profile.sessionPath)
|
||
}
|
||
ctrl.EnableInteractiveApproval()
|
||
ctrl.SetToolApprovalMode(profile.toolApprovalMode)
|
||
ctrl.EnsureSessionPath()
|
||
|
||
var replace *sessionState
|
||
gw.mu.Lock()
|
||
// Re-check under the lock: while we were off-lock in boot.Build, a second
|
||
// message for the same key may have built and registered its own session.
|
||
// Reuse it only when it still targets this message's runtime profile.
|
||
if existing, ok := gw.controllers[key]; ok {
|
||
if sessionStateMatchesRuntime(existing, profile) {
|
||
updateSessionStateRuntime(existing, msg, profile)
|
||
gw.mu.Unlock()
|
||
ctrl.Close()
|
||
safeBotSetToolApprovalMode(existing.ctrl, profile.toolApprovalMode)
|
||
gw.logger.Info("bot session built concurrently; discarding duplicate", "platform", msg.Platform, "chat", hashID(msg.ChatID), "session", key[:8])
|
||
return existing
|
||
}
|
||
delete(gw.controllers, key)
|
||
replace = existing
|
||
}
|
||
state := &sessionState{
|
||
ctrl: ctrl,
|
||
sink: sessionSink,
|
||
platform: msg.Platform,
|
||
connectionID: strings.TrimSpace(msg.ConnectionID),
|
||
model: profile.model,
|
||
workspaceRoot: profile.workspaceRoot,
|
||
toolApprovalMode: profile.toolApprovalMode,
|
||
sessionPath: profile.sessionPath,
|
||
pendingAsks: make(map[string][]event.AskQuestion),
|
||
createdAt: time.Now(),
|
||
lastActive: time.Now(),
|
||
}
|
||
gw.controllers[key] = state
|
||
gw.mu.Unlock()
|
||
closeBotSessionState(replace)
|
||
|
||
gw.logger.Info("bot session created", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "session", key[:8])
|
||
return state
|
||
}
|
||
|
||
func updateSessionStateRuntime(state *sessionState, msg InboundMessage, profile sessionRuntimeProfile) {
|
||
if state == nil {
|
||
return
|
||
}
|
||
if state.connectionID == "" {
|
||
state.connectionID = strings.TrimSpace(msg.ConnectionID)
|
||
}
|
||
if state.platform == "" {
|
||
state.platform = msg.Platform
|
||
}
|
||
state.model = profile.model
|
||
state.workspaceRoot = profile.workspaceRoot
|
||
state.toolApprovalMode = profile.toolApprovalMode
|
||
state.sessionPath = profile.sessionPath
|
||
state.lastActive = time.Now()
|
||
}
|
||
|
||
func closeBotSessionState(state *sessionState) {
|
||
if state == nil {
|
||
return
|
||
}
|
||
if state.cancel != nil {
|
||
state.cancel()
|
||
}
|
||
if state.ctrl != nil {
|
||
state.ctrl.Close()
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) sessionProfileForMessage(msg InboundMessage) sessionRuntimeProfile {
|
||
model, workspaceRoot, toolApprovalMode := gw.sessionOptionsForMessage(msg)
|
||
var sessionPath string
|
||
if override, ok := gw.sessionRuntimeOverrideForMessage(msg); ok {
|
||
sessionPath = override.sessionPath
|
||
}
|
||
return sessionRuntimeProfile{
|
||
model: strings.TrimSpace(model),
|
||
workspaceRoot: strings.TrimSpace(workspaceRoot),
|
||
toolApprovalMode: normalizeBotToolApprovalMode(toolApprovalMode),
|
||
sessionPath: canonicalBotPath(sessionPath),
|
||
}
|
||
}
|
||
|
||
func sessionStateMatchesRuntime(state *sessionState, profile sessionRuntimeProfile) bool {
|
||
if state == nil || state.ctrl == nil {
|
||
return false
|
||
}
|
||
if stateModel := strings.TrimSpace(state.model); stateModel != "" && profile.model != "" && stateModel != profile.model {
|
||
return false
|
||
}
|
||
stateRoot := strings.TrimSpace(state.workspaceRoot)
|
||
wantRoot := strings.TrimSpace(profile.workspaceRoot)
|
||
if stateRoot == "" {
|
||
root, ok := safeBotControllerWorkspaceRoot(state.ctrl)
|
||
if ok {
|
||
stateRoot = strings.TrimSpace(root)
|
||
} else if wantRoot != "" {
|
||
return false
|
||
}
|
||
}
|
||
if stateRoot != wantRoot {
|
||
return false
|
||
}
|
||
if canonicalBotPath(state.sessionPath) != canonicalBotPath(profile.sessionPath) {
|
||
return false
|
||
}
|
||
if profile.sessionPath != "" && canonicalBotPath(state.ctrl.SessionPath()) != canonicalBotPath(profile.sessionPath) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func safeBotControllerWorkspaceRoot(ctrl botController) (root string, ok bool) {
|
||
if ctrl == nil {
|
||
return "", false
|
||
}
|
||
defer func() {
|
||
if recover() != nil {
|
||
root = ""
|
||
ok = false
|
||
}
|
||
}()
|
||
return ctrl.WorkspaceRoot(), true
|
||
}
|
||
|
||
func safeBotSetToolApprovalMode(ctrl botController, mode string) {
|
||
if ctrl == nil {
|
||
return
|
||
}
|
||
defer func() {
|
||
_ = recover()
|
||
}()
|
||
ctrl.SetToolApprovalMode(mode)
|
||
}
|
||
|
||
// defaultBotApprovalTimeout caps how long a bot session waits for a remote
|
||
// user's approval/ask reply before treating it as denied, so an abandoned
|
||
// prompt (or a dropped IM event) can't leave the session wedged forever
|
||
// (#4626, #4402). 30 minutes is generous for a human reply yet bounded.
|
||
const defaultBotApprovalTimeout = 30 * time.Minute
|
||
|
||
// approvalTimeout resolves the configured bot approval wait: zero uses the
|
||
// bounded default; a negative value opts out (wait indefinitely).
|
||
func (gw *BotGateway) approvalTimeout() time.Duration {
|
||
switch {
|
||
case gw.cfg.ApprovalTimeout < 0:
|
||
return 0
|
||
case gw.cfg.ApprovalTimeout == 0:
|
||
return defaultBotApprovalTimeout
|
||
default:
|
||
return gw.cfg.ApprovalTimeout
|
||
}
|
||
}
|
||
|
||
func botSessionDir(workspaceRoot string) string {
|
||
if strings.TrimSpace(workspaceRoot) == "" {
|
||
return config.SessionDir()
|
||
}
|
||
if dir := config.ProjectSessionDir(workspaceRoot); dir != "" {
|
||
return dir
|
||
}
|
||
return config.SessionDir()
|
||
}
|
||
|
||
func (gw *BotGateway) rememberSessionReady(msg InboundMessage, ctrl botController) {
|
||
if gw.cfg.OnSessionReady == nil || ctrl == nil {
|
||
return
|
||
}
|
||
sessionID := botSessionTarget(ctrl.SessionPath())
|
||
if sessionID == "" {
|
||
return
|
||
}
|
||
if err := gw.cfg.OnSessionReady(msg, sessionID); err != nil {
|
||
gw.logger.Warn("remember bot session failed", "platform", msg.Platform, "connection", msg.ConnectionID, "err", err)
|
||
}
|
||
}
|
||
|
||
func botSessionTarget(sessionPath string) string {
|
||
sessionPath = strings.TrimSpace(sessionPath)
|
||
if sessionPath == "" {
|
||
return ""
|
||
}
|
||
return "path:" + sessionPath
|
||
}
|
||
|
||
func (gw *BotGateway) sessionOptionsForMessage(msg InboundMessage) (model string, workspaceRoot string, toolApprovalMode string) {
|
||
// cfg.ToolApprovalMode / Channels / ConnectionChannels are rewritten under
|
||
// gw.mu at runtime (/yolo, UpdateConnectionToolApprovalMode), so snapshot them
|
||
// under a short lock and resolve outside it — applyRuntimeOverrideOptions
|
||
// takes gw.mu itself. Copying the ChannelConfig value is enough: writers
|
||
// replace whole map entries and never mutate SessionMappings in place.
|
||
gw.mu.Lock()
|
||
model = gw.cfg.Model
|
||
workspaceRoot = gw.cfg.WorkspaceRoot
|
||
toolApprovalMode = normalizeBotToolApprovalMode(gw.cfg.ToolApprovalMode)
|
||
var connChannel ChannelConfig
|
||
connOK := false
|
||
if msg.ConnectionID != "" {
|
||
connChannel, connOK = gw.cfg.ConnectionChannels[msg.ConnectionID]
|
||
}
|
||
platChannel, platOK := gw.cfg.Channels[msg.Platform]
|
||
gw.mu.Unlock()
|
||
|
||
var mappings []SessionMapping
|
||
if connOK {
|
||
applyBotChannelOptions(connChannel, &model, &workspaceRoot, &toolApprovalMode)
|
||
mappings = connChannel.SessionMappings
|
||
if mapping, ok := matchingSessionMapping(mappings, msg); ok {
|
||
workspaceRoot = workspaceRootForSessionMapping(mapping, workspaceRoot)
|
||
}
|
||
model, workspaceRoot, toolApprovalMode = gw.applyRouteOptions(msg, model, workspaceRoot, toolApprovalMode)
|
||
model, workspaceRoot, toolApprovalMode = gw.applyRuntimeOverrideOptions(msg, model, workspaceRoot, toolApprovalMode)
|
||
return model, workspaceRoot, toolApprovalMode
|
||
}
|
||
if platOK {
|
||
applyBotChannelOptions(platChannel, &model, &workspaceRoot, &toolApprovalMode)
|
||
mappings = platChannel.SessionMappings
|
||
}
|
||
if mapping, ok := matchingSessionMapping(mappings, msg); ok {
|
||
workspaceRoot = workspaceRootForSessionMapping(mapping, workspaceRoot)
|
||
}
|
||
model, workspaceRoot, toolApprovalMode = gw.applyRouteOptions(msg, model, workspaceRoot, toolApprovalMode)
|
||
model, workspaceRoot, toolApprovalMode = gw.applyRuntimeOverrideOptions(msg, model, workspaceRoot, toolApprovalMode)
|
||
return model, workspaceRoot, toolApprovalMode
|
||
}
|
||
|
||
func (gw *BotGateway) applyRuntimeOverrideOptions(msg InboundMessage, model, workspaceRoot, toolApprovalMode string) (string, string, string) {
|
||
if override, ok := gw.sessionRuntimeOverrideForMessage(msg); ok {
|
||
applyBotChannelOptions(override.channel, &model, &workspaceRoot, &toolApprovalMode)
|
||
}
|
||
return model, workspaceRoot, toolApprovalMode
|
||
}
|
||
|
||
func (gw *BotGateway) applyRouteOptions(msg InboundMessage, model, workspaceRoot, toolApprovalMode string) (string, string, string) {
|
||
for _, route := range gw.cfg.Routes {
|
||
if routeMatchesMessage(route, msg) {
|
||
applyBotChannelOptions(route.Channel, &model, &workspaceRoot, &toolApprovalMode)
|
||
break
|
||
}
|
||
}
|
||
return model, workspaceRoot, toolApprovalMode
|
||
}
|
||
|
||
func applyBotChannelOptions(channel ChannelConfig, model *string, workspaceRoot *string, toolApprovalMode *string) {
|
||
if value := strings.TrimSpace(channel.Model); value != "" {
|
||
*model = value
|
||
}
|
||
if value := strings.TrimSpace(channel.WorkspaceRoot); value != "" {
|
||
*workspaceRoot = value
|
||
}
|
||
if value := normalizeOptionalBotToolApprovalMode(channel.ToolApprovalMode); value != "" {
|
||
*toolApprovalMode = value
|
||
}
|
||
}
|
||
|
||
func matchingSessionMapping(mappings []SessionMapping, msg InboundMessage) (SessionMapping, bool) {
|
||
for i := range mappings {
|
||
if sessionMappingMatches(mappings[i], msg) {
|
||
return mappings[i], true
|
||
}
|
||
}
|
||
return SessionMapping{}, false
|
||
}
|
||
|
||
func sessionMappingMatches(mapping SessionMapping, msg InboundMessage) bool {
|
||
if strings.TrimSpace(mapping.RemoteID) != strings.TrimSpace(msg.ChatID) {
|
||
return false
|
||
}
|
||
chatType, userID, threadID := sessionMappingIdentity(msg)
|
||
mappingChatType := strings.TrimSpace(mapping.ChatType)
|
||
if mappingChatType == "" {
|
||
return chatType == ""
|
||
}
|
||
if mappingChatType != chatType {
|
||
return false
|
||
}
|
||
if strings.TrimSpace(mapping.UserID) != userID {
|
||
return false
|
||
}
|
||
return strings.TrimSpace(mapping.ThreadID) == threadID
|
||
}
|
||
|
||
func sessionMappingIdentity(msg InboundMessage) (chatType string, userID string, threadID string) {
|
||
switch msg.ChatType {
|
||
case ChatGroup, ChatGuild:
|
||
chatType = string(msg.ChatType)
|
||
userID = strings.TrimSpace(msg.UserID)
|
||
case ChatThread:
|
||
chatType = string(msg.ChatType)
|
||
threadID = strings.TrimSpace(msg.ThreadID)
|
||
if threadID == "" {
|
||
threadID = strings.TrimSpace(msg.ChatID)
|
||
}
|
||
}
|
||
return chatType, userID, threadID
|
||
}
|
||
|
||
func workspaceRootForSessionMapping(mapping SessionMapping, fallback string) string {
|
||
if root := strings.TrimSpace(mapping.WorkspaceRoot); root != "" {
|
||
return root
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(mapping.Scope), "global") {
|
||
return ""
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func routeMatchesMessage(route RouteConfig, msg InboundMessage) bool {
|
||
if value := strings.TrimSpace(route.ConnectionID); value != "" && value != strings.TrimSpace(msg.ConnectionID) {
|
||
return false
|
||
}
|
||
if route.Platform != "" && route.Platform != msg.Platform {
|
||
return false
|
||
}
|
||
if route.ChatType != "" && route.ChatType != msg.ChatType {
|
||
return false
|
||
}
|
||
if value := strings.TrimSpace(route.ChatID); value != "" && value != strings.TrimSpace(msg.ChatID) {
|
||
return false
|
||
}
|
||
if value := strings.TrimSpace(route.UserID); value != "" && value != strings.TrimSpace(msg.UserID) {
|
||
return false
|
||
}
|
||
if value := strings.TrimSpace(route.ThreadID); value != "" && value != strings.TrimSpace(msg.ThreadID) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func normalizeBotToolApprovalMode(mode string) string {
|
||
if value := normalizeOptionalBotToolApprovalMode(mode); value != "" {
|
||
return value
|
||
}
|
||
return control.ToolApprovalAsk
|
||
}
|
||
|
||
func normalizeOptionalBotToolApprovalMode(mode string) string {
|
||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||
case control.ToolApprovalAsk:
|
||
return control.ToolApprovalAsk
|
||
case control.ToolApprovalAuto:
|
||
return control.ToolApprovalAuto
|
||
case control.ToolApprovalYolo, "full", "full-access", "bypass":
|
||
return control.ToolApprovalYolo
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func (gw *BotGateway) sendText(ctx context.Context, adapter Adapter, msg InboundMessage, text string) error {
|
||
out := OutboundMessage{
|
||
ConnectionID: msg.ConnectionID,
|
||
Domain: msg.Domain,
|
||
ChatID: msg.ChatID,
|
||
ChatType: msg.ChatType,
|
||
Text: text,
|
||
ReplyToMsgID: msg.MessageID,
|
||
}
|
||
binding := AdapterBinding{
|
||
ID: strings.TrimSpace(msg.ConnectionID),
|
||
Domain: strings.TrimSpace(msg.Domain),
|
||
Platform: msg.Platform,
|
||
Adapter: adapter,
|
||
}
|
||
if binding.Platform == "" && adapter != nil {
|
||
binding.Platform = adapter.Platform()
|
||
}
|
||
if binding.ID == "" && adapter != nil {
|
||
binding.ID = adapter.Name()
|
||
}
|
||
result, err := gw.sendViaAdapter(ctx, binding, out)
|
||
if err != nil {
|
||
gw.logger.Warn("bot send failed", "platform", msg.Platform, "chat_type", msg.ChatType, "chat", hashID(msg.ChatID), "reply_to", hashID(msg.MessageID), "err", err)
|
||
return err
|
||
}
|
||
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))
|
||
return err
|
||
}
|
||
|
||
func (gw *BotGateway) sendViaAdapter(ctx context.Context, binding AdapterBinding, msg OutboundMessage) (SendResult, error) {
|
||
if binding.Adapter == nil {
|
||
return SendResult{}, errors.New("bot send: adapter is nil")
|
||
}
|
||
if strings.TrimSpace(msg.ConnectionID) == "" {
|
||
msg.ConnectionID = binding.ID
|
||
}
|
||
if strings.TrimSpace(msg.Domain) == "" {
|
||
msg.Domain = binding.Domain
|
||
}
|
||
result, err := binding.Adapter.Send(ctx, msg)
|
||
gw.markAdapterSend(binding, err)
|
||
for _, messageID := range result.DeliveredMessageIDs() {
|
||
gw.rememberOutboundMessage(binding.Platform, binding.ID, binding.Domain, msg.ChatID, messageID)
|
||
}
|
||
return result, err
|
||
}
|
||
|
||
func parseAskAnswers(questions []event.AskQuestion, raw string) []event.AskAnswer {
|
||
raw = strings.TrimSpace(raw)
|
||
if len(questions) == 0 {
|
||
return []event.AskAnswer{{Selected: []string{raw}}}
|
||
}
|
||
byID := make(map[string]*event.AskQuestion, len(questions))
|
||
for i := range questions {
|
||
q := &questions[i]
|
||
byID[q.ID] = q
|
||
byID[fmt.Sprintf("%d", i+1)] = q
|
||
}
|
||
answerMap := make(map[string][]string, len(questions))
|
||
if strings.Contains(raw, "=") {
|
||
for _, part := range strings.Split(raw, ";") {
|
||
k, v, ok := strings.Cut(part, "=")
|
||
if !ok {
|
||
continue
|
||
}
|
||
q := byID[strings.TrimSpace(k)]
|
||
if q == nil {
|
||
continue
|
||
}
|
||
answerMap[q.ID] = normalizeAskSelection(*q, strings.TrimSpace(v))
|
||
}
|
||
} else if len(questions) == 1 {
|
||
answerMap[questions[0].ID] = normalizeAskSelection(questions[0], raw)
|
||
}
|
||
out := make([]event.AskAnswer, 0, len(questions))
|
||
for _, q := range questions {
|
||
out = append(out, event.AskAnswer{QuestionID: q.ID, Selected: answerMap[q.ID]})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeAskSelection(q event.AskQuestion, raw string) []string {
|
||
parts := []string{raw}
|
||
if q.Multi && strings.Contains(raw, ",") {
|
||
parts = strings.Split(raw, ",")
|
||
}
|
||
out := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
if idx, err := strconv.Atoi(part); err == nil && idx >= 1 && idx <= len(q.Options) {
|
||
out = append(out, q.Options[idx-1].Label)
|
||
continue
|
||
}
|
||
out = append(out, part)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// UpdateConnectionToolApprovalMode updates the in-memory tool approval mode for
|
||
// a single bot connection without restarting the gateway. Empty mode clears the
|
||
// connection override, so existing sessions inherit the current gateway default.
|
||
func (gw *BotGateway) UpdateConnectionToolApprovalMode(connID, mode string) {
|
||
connID = strings.TrimSpace(connID)
|
||
if connID == "" {
|
||
return
|
||
}
|
||
mode = normalizeOptionalBotToolApprovalMode(mode)
|
||
type controllerMode struct {
|
||
ctrl botController
|
||
mode string
|
||
}
|
||
var updates []controllerMode
|
||
|
||
gw.mu.Lock()
|
||
if gw.cfg.ConnectionChannels == nil {
|
||
gw.cfg.ConnectionChannels = make(map[string]ChannelConfig)
|
||
}
|
||
ch := gw.cfg.ConnectionChannels[connID]
|
||
ch.ToolApprovalMode = mode
|
||
gw.cfg.ConnectionChannels[connID] = ch
|
||
// Update every active session that belongs to this connection.
|
||
for _, state := range gw.controllers {
|
||
if state == nil || state.ctrl == nil || strings.TrimSpace(state.connectionID) != connID {
|
||
continue
|
||
}
|
||
effectiveMode := mode
|
||
if effectiveMode == "" {
|
||
effectiveMode = normalizeBotToolApprovalMode(gw.cfg.ToolApprovalMode)
|
||
}
|
||
updates = append(updates, controllerMode{ctrl: state.ctrl, mode: effectiveMode})
|
||
}
|
||
gw.mu.Unlock()
|
||
|
||
for _, update := range updates {
|
||
update.ctrl.SetToolApprovalMode(update.mode)
|
||
}
|
||
}
|
||
|
||
// SendToAdapter sends a message through the adapter identified by connID.
|
||
// Returns an error if no matching adapter is found.
|
||
func (gw *BotGateway) SendToAdapter(ctx context.Context, connID, domain string, msg OutboundMessage) (SendResult, error) {
|
||
connID = strings.TrimSpace(connID)
|
||
domain = strings.TrimSpace(domain)
|
||
var target AdapterBinding
|
||
gw.mu.Lock()
|
||
for _, binding := range gw.adapters {
|
||
if strings.TrimSpace(binding.ID) == connID &&
|
||
(domain == "" || strings.EqualFold(strings.TrimSpace(binding.Domain), domain)) {
|
||
target = binding
|
||
break
|
||
}
|
||
}
|
||
gw.mu.Unlock()
|
||
if target.Adapter != nil {
|
||
return gw.sendViaAdapter(ctx, target, msg)
|
||
}
|
||
return SendResult{}, fmt.Errorf("SendToAdapter: no adapter found for connection %q (domain %q)", connID, domain)
|
||
}
|
||
|
||
// SendTextToAdapter sends a plain text message through the adapter identified by connID.
|
||
func (gw *BotGateway) SendTextToAdapter(ctx context.Context, connID, domain, chatID string, chatType ChatType, text string) (SendResult, error) {
|
||
return gw.SendToAdapter(ctx, connID, domain, OutboundMessage{
|
||
ChatID: chatID,
|
||
ChatType: chatType,
|
||
Text: text,
|
||
})
|
||
}
|