返回 DeepSeek-Reasonix
bot_runtime_app.go
根目录 / desktop / bot_runtime_app.go
1 package main
2
3 import (
4 "context"
5 "fmt"
6 "log/slog"
7 "os"
8 "strings"
9 "sync"
10 "time"
11
12 "reasonix/internal/bot"
13 "reasonix/internal/botruntime"
14 "reasonix/internal/config"
15 )
16
17 type BotRuntimeStatusView struct {
18 Running bool `json:"running"`
19 Status string `json:"status"`
20 Message string `json:"message"`
21 Connections int `json:"connections"`
22 StartedAt string `json:"startedAt"`
23 }
24
25 type desktopBotRuntime struct {
26 // lifecycleMu serializes start/stop transitions so two apply/stop calls
27 // can't race a gateway into existence. The slow work (gw.Stop teardown,
28 // gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
29 // never block on a restart.
30 lifecycleMu sync.Mutex
31 mu sync.Mutex
32 cancel context.CancelFunc
33 gw *bot.BotGateway
34 status BotRuntimeStatusView
35 }
36
37 func newDesktopBotRuntime() *desktopBotRuntime {
38 return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
39 }
40
41 func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
42 channel := bot.ChannelConfig{
43 Model: strings.TrimSpace(qq.Model),
44 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
45 WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
46 }
47 if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
48 return channels, connectionChannels
49 }
50 if channels == nil {
51 channels = make(map[bot.Platform]bot.ChannelConfig)
52 }
53 if _, ok := channels[bot.PlatformQQ]; !ok {
54 channels[bot.PlatformQQ] = channel
55 }
56 if connectionChannels == nil {
57 connectionChannels = make(map[string]bot.ChannelConfig)
58 }
59 if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
60 connectionChannels[string(bot.PlatformQQ)] = channel
61 }
62 return channels, connectionChannels
63 }
64
65 func (a *App) refreshBotRuntimeAsync() {
66 if a.ctx == nil {
67 return
68 }
69 a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
70 }
71
72 func (a *App) refreshBotRuntime() {
73 // NewApp always pre-fills botRuntime; a nil here means a test-constructed
74 // App with no bot runtime, which must not lazily create one from a
75 // background goroutine (that would race a concurrent refresh).
76 if a.botRuntime == nil {
77 return
78 }
79 var watcherVersion uint64
80 if a.botBridge != nil {
81 watcherVersion = a.botBridge.watcherVersion()
82 }
83 cfg, err := a.loadDesktopBotConfig()
84 if err != nil {
85 a.botRuntime.stop("error", err.Error())
86 return
87 }
88 // Assign through a typed local so a nil *botBridgeHub never becomes a
89 // non-nil bot.DesktopBridge interface inside the gateway config.
90 var bridge bot.DesktopBridge
91 if a.botBridge != nil {
92 // 配置是订阅的持久化事实源:每次运行时重算前重新种子,桌面重启后
93 // /desktop watch 的订阅继续生效。
94 a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
95 bridge = a.botBridge
96 }
97 _ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
98 }
99
100 func (a *App) loadDesktopBotConfig() (*config.Config, error) {
101 // Read-only load feeding the bot runtime and connection diagnostics. It
102 // must load credentials: the runtime resolves app secrets and control
103 // tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
104 // credential-free view load would leave unset on a fresh process.
105 cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
106 if err != nil {
107 return nil, err
108 }
109 return cfg, nil
110 }
111
112 func (a *App) stopBotRuntime() {
113 if a.botRuntime != nil {
114 a.botRuntime.stop("stopped", "bot runtime stopped")
115 }
116 }
117
118 func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
119 if a.botRuntime == nil {
120 return BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}
121 }
122 return a.botRuntime.snapshot()
123 }
124
125 func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
126 if r == nil {
127 return nil
128 }
129 if parent == nil {
130 parent = context.Background()
131 }
132 plan := desktopBotRuntimePlan(cfg)
133 r.lifecycleMu.Lock()
134 defer r.lifecycleMu.Unlock()
135 r.stopCurrent()
136 if !plan.Start {
137 r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
138 return nil
139 }
140
141 logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
142 ctx, cancel := context.WithCancel(parent)
143 modelName := botruntime.ModelName(cfg, "")
144 channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
145 connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
146 channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
147 gwCfg := bot.GatewayConfig{
148 Model: modelName,
149 ToolApprovalMode: cfg.Bot.ToolApprovalMode,
150 MaxSteps: cfg.Bot.MaxSteps,
151 QueueMode: cfg.Bot.QueueMode,
152 QueueCap: cfg.Bot.QueueCap,
153 QueueDrop: cfg.Bot.QueueDrop,
154 PairingEnabled: cfg.Bot.Pairing.Enabled,
155 PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
156 PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
157 IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
158 SelfUserIDs: map[bot.Platform][]string{
159 bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
160 bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
161 bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
162 },
163 ControlEnabled: cfg.Bot.Control.Enabled,
164 ControlAddr: cfg.Bot.Control.Addr,
165 ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
166 WorkspaceRoot: workspaceRoot,
167 Channels: channels,
168 ConnectionChannels: connectionChannels,
169 Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
170 ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
171 Enabled: plan.Enabled,
172 Allowlist: bot.AllowlistConfig{
173 Enabled: cfg.Bot.Allowlist.Enabled,
174 AllowAll: cfg.Bot.Allowlist.AllowAll,
175 Users: map[bot.Platform][]string{
176 bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
177 bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
178 bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
179 },
180 Approvers: map[bot.Platform][]string{
181 bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
182 bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
183 bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
184 },
185 Admins: map[bot.Platform][]string{
186 bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
187 bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
188 bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
189 },
190 Groups: map[bot.Platform][]string{
191 bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
192 bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
193 bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
194 },
195 },
196 Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
197 OnInbound: botruntime.NewRemoteRememberer(logger),
198 OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
199 OnToolApprovalModeChange: onToolApprovalModeChange,
200 Desktop: bridge,
201 }
202 bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
203 if len(bindings) == 0 {
204 cancel()
205 r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
206 return nil
207 }
208 gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
209 if err := gw.Start(ctx); err != nil {
210 cancel()
211 gw.Stop()
212 r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
213 return err
214 }
215 runningConnections := gw.AdapterCount()
216 startErrors := gw.StartErrors()
217 status := "running"
218 message := fmt.Sprintf("%d bot connection(s) running", runningConnections)
219 if len(startErrors) > 0 {
220 status = "degraded"
221 message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
222 }
223 r.mu.Lock()
224 r.cancel = cancel
225 r.gw = gw
226 r.status = BotRuntimeStatusView{
227 Running: true,
228 Status: status,
229 Message: message,
230 Connections: runningConnections,
231 StartedAt: time.Now().UTC().Format(time.RFC3339),
232 }
233 r.mu.Unlock()
234 return nil
235 }
236
237 func (a *App) persistRemoteBotToolApprovalMode(msg bot.InboundMessage, mode string) error {
238 mode = normalizeBotConnectionToolApprovalMode(mode)
239 if mode == "" {
240 return nil
241 }
242 return a.applyConfigOnly(func(c *config.Config) error {
243 id := strings.TrimSpace(msg.ConnectionID)
244 now := time.Now().UTC().Format(time.RFC3339)
245 if id != "" {
246 for i := range c.Bot.Connections {
247 if c.Bot.Connections[i].ID == id || botruntime.ConnectionRuntimeID(c.Bot.Connections[i]) == id {
248 c.Bot.Connections[i].ToolApprovalMode = mode
249 c.Bot.Connections[i].UpdatedAt = now
250 return nil
251 }
252 }
253 }
254 c.Bot.ToolApprovalMode = mode
255 return nil
256 })
257 }
258
259 func summarizeBotRuntimeErrors(errs []error) string {
260 parts := make([]string, 0, len(errs))
261 for _, err := range errs {
262 if err == nil {
263 continue
264 }
265 parts = append(parts, err.Error())
266 }
267 if len(parts) == 0 {
268 return ""
269 }
270 if len(parts) > 3 {
271 hidden := len(parts) - 3
272 parts = append(parts[:3], fmt.Sprintf("%d more", hidden))
273 }
274 return strings.Join(parts, "; ")
275 }
276
277 type botRuntimePlan struct {
278 Start bool
279 Status string
280 Message string
281 Enabled map[bot.Platform]bool
282 }
283
284 func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
285 if cfg == nil {
286 return botRuntimePlan{Status: "error", Message: "config is unavailable"}
287 }
288 if !cfg.Bot.Enabled {
289 return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
290 }
291 if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
292 return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
293 }
294 enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
295 if len(unknown) > 0 {
296 return botRuntimePlan{Status: "error", Message: "unknown bot channel: " + strings.Join(unknown, ", ")}
297 }
298 if !botruntime.HasEnabledPlatform(enabled) {
299 return botRuntimePlan{Status: "stopped", Message: "no bot channels enabled"}
300 }
301 return botRuntimePlan{Start: true, Status: "running", Message: "bot runtime can start", Enabled: enabled}
302 }
303
304 func (r *desktopBotRuntime) stop(status, message string) {
305 r.lifecycleMu.Lock()
306 defer r.lifecycleMu.Unlock()
307 r.stopCurrent()
308 r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
309 }
310
311 // stopCurrent detaches the running gateway under r.mu, then tears it down
312 // off-lock: gw.Stop() closes every session controller (up to the jobs teardown
313 // grace each) and must not stall status/send readers. Callers hold lifecycleMu.
314 func (r *desktopBotRuntime) stopCurrent() {
315 r.mu.Lock()
316 cancel := r.cancel
317 gw := r.gw
318 r.cancel = nil
319 r.gw = nil
320 r.mu.Unlock()
321 if cancel != nil {
322 cancel()
323 }
324 if gw != nil {
325 gw.Stop()
326 }
327 }
328
329 func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
330 r.mu.Lock()
331 r.status = status
332 r.mu.Unlock()
333 }
334
335 func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
336 r.mu.Lock()
337 defer r.mu.Unlock()
338 return r.status
339 }
340
341 // updateConnectionToolApprovalMode updates a connection's tool approval mode
342 // on the running gateway without restarting. Returns true if updated, false if
343 // the gateway is not running or the connection is unknown.
344 func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string) bool {
345 r.mu.Lock()
346 defer r.mu.Unlock()
347 if r.gw == nil {
348 return false
349 }
350 mode = normalizeBotConnectionToolApprovalMode(mode)
351 // Update ConnectionChannels in the internal GatewayConfig so new sessions
352 // pick up the mode. Existing sessions are updated by the gateway directly.
353 r.gw.UpdateConnectionToolApprovalMode(connID, mode)
354 return true
355 }
356
357 // SendToAdapter sends a message through the running gateway's adapter
358 // identified by connID. Returns an error if the gateway is not running
359 // or no matching adapter is found.
360 func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
361 r.mu.Lock()
362 gw := r.gw
363 r.mu.Unlock()
364 if gw == nil {
365 return bot.SendResult{}, nil // gateway not running — silent no-op
366 }
367 return gw.SendToAdapter(ctx, connID, domain, msg)
368 }
369
370 // Running returns true if the bot gateway is currently active.
371 func (r *desktopBotRuntime) Running() bool {
372 r.mu.Lock()
373 defer r.mu.Unlock()
374 return r.gw != nil
375 }
376
377 // ForwardTargets returns the list of bot forward targets derived from the
378 // current config's bot connections and their session mappings. Each mapping
379 // produces one target (connID + chatID + chatType) for event forwarding.
380 func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
381 if cfg == nil {
382 return nil
383 }
384 var targets []botForwardTarget
385 seen := make(map[botForwardTarget]bool)
386 for _, conn := range cfg.Bot.Connections {
387 if !conn.Enabled {
388 continue
389 }
390 connID := botruntime.ConnectionRuntimeID(conn)
391 domain := strings.TrimSpace(conn.Domain)
392 for _, sm := range conn.SessionMappings {
393 remoteID := strings.TrimSpace(sm.RemoteID)
394 if remoteID == "" {
395 continue
396 }
397 chatType := bot.ChatDM
398 if sm.ChatType != "" {
399 chatType = bot.ChatType(sm.ChatType)
400 }
401 target := botForwardTarget{
402 ConnID: connID,
403 Domain: domain,
404 ChatID: remoteID,
405 ChatType: chatType,
406 }
407 if seen[target] {
408 continue
409 }
410 seen[target] = true
411 targets = append(targets, target)
412 }
413 }
414 return targets
415 }
416
416 lines GO