返回 DeepSeek-Reasonix
bot.go
根目录 / internal / cli / bot.go
1 package cli
2
3 import (
4 "context"
5 "flag"
6 "fmt"
7 "log/slog"
8 "os"
9 "os/signal"
10 "strings"
11 "syscall"
12 "time"
13
14 "reasonix/internal/bot"
15 "reasonix/internal/bot/weixin"
16 "reasonix/internal/botruntime"
17 "reasonix/internal/config"
18 )
19
20 func botCommand(args []string, version string) int {
21 if len(args) < 1 {
22 botUsage()
23 return 2
24 }
25
26 sub := args[0]
27 rest := args[1:]
28
29 switch sub {
30 case "start":
31 return botStart(rest, version)
32 case "doctor":
33 return botDoctor(rest)
34 case "pairing":
35 return botPairing(rest)
36 case "weixin-login":
37 return botWeixinLogin(rest)
38 case "help", "--help", "-h":
39 botUsage()
40 return 0
41 default:
42 fmt.Fprintf(os.Stderr, "unknown bot subcommand %q\n\n", sub)
43 botUsage()
44 return 2
45 }
46 }
47
48 func botStart(args []string, version string) int {
49 fs := flag.NewFlagSet("bot start", flag.ContinueOnError)
50 channels := fs.String("channels", "", "启用的平台,逗号分隔:qq,feishu,lark,weixin")
51 dir := fs.String("dir", "", "工作目录")
52 model := fs.String("model", "", "模型名(空则用 default_model)")
53
54 if code, ok := parseCommandFlags(fs, args); !ok {
55 return code
56 }
57
58 ctx, cancel := context.WithCancel(context.Background())
59 defer cancel()
60
61 cfg, err := loadBotCommandConfig()
62 if err != nil {
63 fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
64 return 1
65 }
66
67 if !cfg.Bot.Enabled {
68 fmt.Fprintln(os.Stderr, "error: bot is not enabled in config — set [bot] enabled = true")
69 return 1
70 }
71 if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
72 fmt.Fprintln(os.Stderr, "error: bot requires explicit access control; set per-connection access, enable pairing, configure [bot.allowlist], or set allow_all = true intentionally")
73 return 1
74 }
75
76 workspaceRoot := *dir
77 if workspaceRoot == "" {
78 if wd, err := os.Getwd(); err == nil {
79 workspaceRoot = wd
80 }
81 }
82
83 requestedChannels := splitBotChannels(*channels)
84 enabledPlatforms, unknownChannels := botruntime.EnabledPlatforms(cfg, requestedChannels)
85 for _, ch := range unknownChannels {
86 fmt.Fprintf(os.Stderr, "warning: unknown channel %q\n", ch)
87 }
88 if !botruntime.HasEnabledPlatform(enabledPlatforms) {
89 fmt.Fprintln(os.Stderr, "error: no bot channels enabled — enable at least one in config")
90 return 1
91 }
92
93 modelName := botruntime.ModelName(cfg, *model)
94
95 logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
96 rememberInboundRemote := botruntime.NewRemoteRememberer(logger)
97
98 // 构建网关配置
99 botChannels := botruntime.ChannelConfigs(cfg.Bot.Connections, *model == "", *dir == "")
100 botConnectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, *model == "", *dir == "")
101 botChannels, botConnectionChannels = botruntime.MergeLegacyDingtalkChannel(cfg.Bot.Dingtalk, botChannels, botConnectionChannels)
102 gwCfg := bot.GatewayConfig{
103 Model: modelName,
104 ToolApprovalMode: cfg.Bot.ToolApprovalMode,
105 MaxSteps: cfg.Bot.MaxSteps,
106 QueueMode: cfg.Bot.QueueMode,
107 QueueCap: cfg.Bot.QueueCap,
108 QueueDrop: cfg.Bot.QueueDrop,
109 PairingEnabled: cfg.Bot.Pairing.Enabled,
110 PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
111 PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
112 IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
113 SelfUserIDs: map[bot.Platform][]string{
114 bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
115 bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
116 bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
117 bot.PlatformDingtalk: cfg.Bot.SelfUserIDs.Dingtalk,
118 },
119 ControlEnabled: cfg.Bot.Control.Enabled,
120 ControlAddr: cfg.Bot.Control.Addr,
121 ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
122 WorkspaceRoot: workspaceRoot,
123 Channels: botChannels,
124 ConnectionChannels: botConnectionChannels,
125 Routes: botruntime.RouteConfigs(cfg.Bot.Routes, *model == "", *dir == ""),
126 ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
127 Enabled: enabledPlatforms,
128 Allowlist: botAllowlistConfig(cfg.Bot.Allowlist),
129 Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
130 ModelResolver: botruntime.ModelResolver(cfg),
131 OnInbound: rememberInboundRemote,
132 OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
133 }
134
135 feishuDomains := botruntime.RequestedFeishuDomains(requestedChannels)
136 gw := bot.NewGatewayWithAdapterBindings(gwCfg, botruntime.AdapterBindings(cfg, enabledPlatforms, feishuDomains, logger), logger)
137
138 // 信号处理
139 sigCh := make(chan os.Signal, 1)
140 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
141
142 go func() {
143 <-sigCh
144 fmt.Fprintln(os.Stderr, "\nshutting down...")
145 cancel()
146 }()
147
148 fmt.Fprintf(os.Stderr, "reasonix bot starting (model: %s, channels: %s)...\n", modelName, *channels)
149 fmt.Fprintf(os.Stderr, "version: %s\n", version)
150
151 if err := gw.Start(ctx); err != nil {
152 gw.Stop()
153 fmt.Fprintf(os.Stderr, "error: start gateway: %v\n", err)
154 return 1
155 }
156 defer gw.Stop()
157
158 // 等待信号或 context 取消
159 <-ctx.Done()
160 return 0
161 }
162
163 func splitBotChannels(raw string) []string {
164 raw = strings.TrimSpace(raw)
165 if raw == "" {
166 return nil
167 }
168 return strings.Split(raw, ",")
169 }
170
171 func botDoctor(args []string) int {
172 fs := flag.NewFlagSet("bot doctor", flag.ContinueOnError)
173 jsonOut := fs.Bool("json", false, "JSON 格式输出")
174 deep := fs.Bool("deep", false, "执行更详细的本机诊断")
175
176 if code, ok := parseCommandFlags(fs, args); !ok {
177 return code
178 }
179
180 cfg, err := loadBotCommandConfig()
181 if err != nil {
182 fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
183 return 1
184 }
185
186 bc := cfg.Bot
187
188 type checkResult struct {
189 Name string `json:"name"`
190 Status string `json:"status"`
191 Detail string `json:"detail,omitempty"`
192 }
193
194 var results []checkResult
195
196 addCheck := func(name, status, detail string) {
197 results = append(results, checkResult{Name: name, Status: status, Detail: detail})
198 }
199
200 // 基础检查
201 if bc.Enabled {
202 addCheck("bot.enabled", "ok", "")
203 } else {
204 addCheck("bot.enabled", "disabled", "bot is not enabled in config")
205 }
206 if *deep {
207 if path := config.UserConfigPath(); path != "" {
208 if _, err := os.Stat(path); err == nil {
209 addCheck("bot.config.user", "ok", path)
210 } else {
211 addCheck("bot.config.user", "missing", path)
212 }
213 }
214 if dir := config.SessionDir(); dir != "" {
215 addCheck("bot.sessions.dir", "ok", dir)
216 }
217 }
218 queueMode := bot.NormalizeQueueMode(bc.QueueMode)
219 queueCap := bc.QueueCap
220 if queueCap <= 0 {
221 queueCap = bot.DefaultQueueCap
222 }
223 addCheck("bot.queue", "ok", fmt.Sprintf("mode=%s cap=%d drop=%s", queueMode, queueCap, bot.NormalizeQueueDrop(bc.QueueDrop)))
224 if bc.Pairing.Enabled {
225 addCheck("bot.pairing", "enabled", fmt.Sprintf("ttl=%dm max_pending=%d", bc.Pairing.RequestTTLMinutes, bc.Pairing.MaxPendingPerPlatform))
226 } else {
227 addCheck("bot.pairing", "disabled", "")
228 }
229 if *deep {
230 reqs, err := bot.ListPairingRequests()
231 if err != nil {
232 addCheck("bot.pairing.pending", "error", err.Error())
233 } else {
234 addCheck("bot.pairing.pending", "ok", fmt.Sprintf("%d pending", len(reqs)))
235 }
236 if path := bot.PairingStorePath(); path != "" {
237 if info, err := os.Stat(path); err == nil {
238 addCheck("bot.pairing.store", "ok", fmt.Sprintf("%s mode=%s", path, info.Mode().Perm()))
239 } else {
240 addCheck("bot.pairing.store", "missing", path)
241 }
242 }
243 }
244 if *deep {
245 selfStatus := "disabled"
246 if bc.IgnoreSelfMessages {
247 selfStatus = "enabled"
248 }
249 addCheck("bot.self_protection", selfStatus,
250 fmt.Sprintf("self_ids=%d", len(bc.SelfUserIDs.QQ)+len(bc.SelfUserIDs.Feishu)+len(bc.SelfUserIDs.Weixin)))
251 controlStatus := "disabled"
252 controlDetail := ""
253 if bc.Control.Enabled {
254 controlStatus = "enabled"
255 tokenStatus := "missing_token"
256 if strings.TrimSpace(bc.Control.TokenEnv) != "" && os.Getenv(strings.TrimSpace(bc.Control.TokenEnv)) != "" {
257 tokenStatus = "token_set"
258 }
259 addr := strings.TrimSpace(bc.Control.Addr)
260 if addr == "" {
261 addr = "127.0.0.1:37913"
262 }
263 controlDetail = fmt.Sprintf("addr=%s token_env=%s %s", addr, bc.Control.TokenEnv, tokenStatus)
264 }
265 addCheck("bot.control", controlStatus, controlDetail)
266 addCheck("bot.routes", "ok", fmt.Sprintf("%d routes", len(bc.Routes)))
267 }
268
269 // QQ 检查
270 if bc.QQ.Enabled {
271 addCheck("bot.qq.enabled", "ok", "")
272 secret := os.Getenv(bc.QQ.AppSecretEnv)
273 if secret == "" {
274 addCheck("bot.qq.app_secret", "missing", bc.QQ.AppSecretEnv+" is not set")
275 } else {
276 addCheck("bot.qq.app_secret", "ok", bc.QQ.AppSecretEnv+" is set")
277 }
278 if bc.QQ.AppID == "" {
279 addCheck("bot.qq.app_id", "missing", "app_id is empty")
280 } else {
281 addCheck("bot.qq.app_id", "ok", "app_id configured")
282 }
283 } else {
284 addCheck("bot.qq", "disabled", "")
285 }
286
287 // 飞书检查
288 if bc.Feishu.Enabled {
289 addCheck("bot.feishu.enabled", "ok", "")
290 secret := os.Getenv(bc.Feishu.AppSecretEnv)
291 if secret == "" {
292 addCheck("bot.feishu.app_secret", "missing", bc.Feishu.AppSecretEnv+" is not set")
293 } else {
294 addCheck("bot.feishu.app_secret", "ok", bc.Feishu.AppSecretEnv+" is set")
295 }
296 if bc.Feishu.AppID == "" {
297 addCheck("bot.feishu.app_id", "missing", "app_id is empty")
298 } else {
299 addCheck("bot.feishu.app_id", "ok", "app_id configured")
300 }
301 mode := bc.Feishu.Mode
302 if mode == "" {
303 mode = "webhook"
304 }
305 addCheck("bot.feishu.mode", "ok", mode)
306 } else {
307 addCheck("bot.feishu", "disabled", "")
308 }
309
310 // 微信检查
311 if bc.Weixin.Enabled {
312 addCheck("bot.weixin.enabled", "ok", "")
313 token := os.Getenv(bc.Weixin.TokenEnv)
314 if token != "" {
315 addCheck("bot.weixin.token", "ok", bc.Weixin.TokenEnv+" is set")
316 } else if weixin.HasSavedAccount(bc.Weixin.AccountID) {
317 addCheck("bot.weixin.token", "ok", "saved iLink account is available")
318 } else {
319 addCheck("bot.weixin.token", "missing", bc.Weixin.TokenEnv+" is not set; run `reasonix bot weixin-login` to save an iLink account")
320 }
321 } else {
322 addCheck("bot.weixin", "disabled", "")
323 }
324
325 enabledConnections := 0
326 for _, conn := range bc.Connections {
327 if conn.Enabled {
328 enabledConnections++
329 }
330 }
331 addCheck("bot.connections", "ok", fmt.Sprintf("enabled=%d total=%d", enabledConnections, len(bc.Connections)))
332 for _, conn := range bc.Connections {
333 id := strings.TrimSpace(conn.ID)
334 if id == "" {
335 id = strings.TrimSpace(conn.Provider)
336 }
337 status := "ok"
338 if !conn.Enabled {
339 status = "disabled"
340 } else if len(conn.SessionMappings) == 0 && (conn.Provider == string(bot.PlatformFeishu) || conn.Provider == string(bot.PlatformWeixin)) {
341 status = "missing"
342 }
343 addCheck("bot.connection."+id+".session_mappings", status,
344 fmt.Sprintf("provider=%s mappings=%d", conn.Provider, len(conn.SessionMappings)))
345 }
346
347 // Allowlist 检查
348 if bc.Allowlist.AllowAll {
349 addCheck("bot.allowlist", "open", "allow_all=true — every reachable user can trigger local tools")
350 } else if bc.Allowlist.Enabled {
351 addCheck("bot.allowlist", "enabled",
352 fmt.Sprintf("qq=%d feishu=%d weixin=%d users approvers=%d admins=%d",
353 len(bc.Allowlist.QQUsers),
354 len(bc.Allowlist.FeishuUsers),
355 len(bc.Allowlist.WeixinUsers),
356 len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers),
357 len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins)))
358 } else {
359 addCheck("bot.allowlist", "missing", "bot start will refuse without allowlist or allow_all=true")
360 }
361 if *deep {
362 addCheck("bot.roles", "ok",
363 fmt.Sprintf("approvers=%d admins=%d",
364 len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers),
365 len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins)))
366 }
367
368 if *jsonOut {
369 fmt.Println("[")
370 for i, r := range results {
371 comma := ","
372 if i == len(results)-1 {
373 comma = ""
374 }
375 fmt.Printf(" {\"name\":%q,\"status\":%q,\"detail\":%q}%s\n", r.Name, r.Status, r.Detail, comma)
376 }
377 fmt.Println("]")
378 } else {
379 for _, r := range results {
380 marker := "✓"
381 if r.Status == "missing" || r.Status == "disabled" {
382 marker = "✗"
383 }
384 fmt.Printf(" %s %s: %s", marker, r.Name, r.Status)
385 if r.Detail != "" {
386 fmt.Printf(" — %s", r.Detail)
387 }
388 fmt.Println()
389 }
390 }
391
392 return 0
393 }
394
395 func botPairing(args []string) int {
396 if len(args) < 1 {
397 botPairingUsage()
398 return 2
399 }
400 switch args[0] {
401 case "list":
402 reqs, err := bot.ListPairingRequests()
403 if err != nil {
404 fmt.Fprintf(os.Stderr, "error: list pairing requests: %v\n", err)
405 return 1
406 }
407 if len(reqs) == 0 {
408 fmt.Println("No pending bot pairing requests.")
409 return 0
410 }
411 for _, req := range reqs {
412 fmt.Printf("%s\t%s\t%s\tuser=%s\tchat=%s\texpires=%s\n",
413 req.Code,
414 req.Platform,
415 req.ChatType,
416 req.UserID,
417 req.ChatID,
418 req.ExpiresAt.Local().Format("2006-01-02 15:04"),
419 )
420 }
421 return 0
422 case "approve":
423 if len(args) < 2 {
424 fmt.Fprintln(os.Stderr, "error: pairing approve requires a code")
425 return 2
426 }
427 req, err := bot.ApprovePairingCode(args[1])
428 if err != nil {
429 fmt.Fprintf(os.Stderr, "error: approve pairing: %v\n", err)
430 return 1
431 }
432 fmt.Printf("Approved %s user %s for %s.\n", req.Platform, req.UserID, req.ChatID)
433 return 0
434 case "reject", "deny":
435 if len(args) < 2 {
436 fmt.Fprintln(os.Stderr, "error: pairing reject requires a code")
437 return 2
438 }
439 req, err := bot.RejectPairingCode(args[1])
440 if err != nil {
441 fmt.Fprintf(os.Stderr, "error: reject pairing: %v\n", err)
442 return 1
443 }
444 fmt.Printf("Rejected %s user %s for %s.\n", req.Platform, req.UserID, req.ChatID)
445 return 0
446 default:
447 fmt.Fprintf(os.Stderr, "unknown bot pairing subcommand %q\n\n", args[0])
448 botPairingUsage()
449 return 2
450 }
451 }
452
453 func botPairingUsage() {
454 fmt.Print(`reasonix bot pairing — approve pending bot DM pairings
455
456 Usage:
457 reasonix bot pairing list
458 reasonix bot pairing approve CODE
459 reasonix bot pairing reject CODE
460 `)
461 }
462
463 func botWeixinLogin(args []string) int {
464 fs := flag.NewFlagSet("bot weixin-login", flag.ContinueOnError)
465 timeoutSeconds := fs.Int("timeout", 480, "登录超时时间(秒)")
466 if code, ok := parseCommandFlags(fs, args); !ok {
467 return code
468 }
469
470 cfg, err := loadBotCommandConfig()
471 if err != nil {
472 fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
473 return 1
474 }
475
476 if !cfg.Bot.Weixin.Enabled {
477 fmt.Fprintln(os.Stderr, "error: weixin bot is not enabled in config")
478 return 1
479 }
480
481 ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeoutSeconds)*time.Second)
482 defer cancel()
483 result, err := weixin.Login(ctx, os.Stdout, time.Duration(*timeoutSeconds)*time.Second)
484 if err != nil {
485 fmt.Fprintf(os.Stderr, "error: weixin login failed: %v\n", err)
486 return 1
487 }
488 fmt.Printf("\n微信登录成功: account_id=%s user_id=%s base_url=%s\n", result.AccountID, result.UserID, result.BaseURL)
489 fmt.Println("凭据已保存到 Reasonix 用户配置目录;也可以把 [bot.weixin] account_id 设置为该 account_id。")
490
491 return 0
492 }
493
494 func loadBotCommandConfig() (*config.Config, error) {
495 cfg, err := config.Load()
496 if err != nil {
497 return nil, err
498 }
499 userPath := config.UserConfigPath()
500 if strings.TrimSpace(userPath) == "" {
501 return cfg, nil
502 }
503 if _, err := os.Stat(userPath); err != nil {
504 return cfg, nil
505 }
506 userCfg := config.LoadForEdit(userPath)
507 if botConfigIsUserOwned(userCfg.Bot) {
508 cfg.Bot = userCfg.Bot
509 }
510 return cfg, nil
511 }
512
513 func botConfigIsUserOwned(bc config.BotConfig) bool {
514 if bc.Enabled || len(bc.Connections) > 0 || bc.QQ.Enabled || bc.Feishu.Enabled || bc.Weixin.Enabled {
515 return true
516 }
517 if bc.Allowlist.AllowAll || botruntime.AllowlistUserCount(bc.Allowlist) > 0 {
518 return true
519 }
520 if botruntime.BotAccessActive(bc.QQ.Access) {
521 return true
522 }
523 for _, conn := range bc.Connections {
524 if botruntime.BotAccessActive(conn.Access) {
525 return true
526 }
527 }
528 return len(bc.Allowlist.QQGroups)+len(bc.Allowlist.FeishuGroups)+len(bc.Allowlist.WeixinGroups)+
529 len(bc.Allowlist.QQApprovers)+len(bc.Allowlist.FeishuApprovers)+len(bc.Allowlist.WeixinApprovers)+
530 len(bc.Allowlist.QQAdmins)+len(bc.Allowlist.FeishuAdmins)+len(bc.Allowlist.WeixinAdmins) > 0
531 }
532
533 func botUsage() {
534 fmt.Print(`reasonix bot — multi-channel IM bot gateway (QQ / Feishu / WeChat)
535
536 Usage:
537 reasonix bot start [--channels qq,feishu,lark,weixin] [--dir PATH] [--model NAME]
538 reasonix bot doctor [--json] [--deep]
539 reasonix bot pairing list|approve|reject
540 reasonix bot weixin-login [--timeout SECONDS]
541
542 Subcommands:
543 start 启动 bot 网关
544 doctor 诊断 bot 配置和连通性
545 pairing 查看或批准 IM 私聊配对
546 weixin-login 微信 iLink 二维码登录
547
548 Examples:
549 reasonix bot start --channels qq,feishu
550 reasonix bot start --dir /path/to/project --model deepseek-pro
551 reasonix bot doctor --json
552
553 Configuration:
554 Edit reasonix.toml:
555 [bot] enabled / model / max_steps
556 [bot] queue_mode / queue_cap / queue_drop
557 [bot.pairing] enabled / request_ttl_minutes / max_pending_per_platform
558 [bot.allowlist] enabled / users / approvers / admins / groups
559 [bot.qq] enabled / app_id / app_secret_env
560 [bot.feishu] enabled / app_id / app_secret_env / verification_token / mode
561 [bot.weixin] enabled / account_id / token_env / api_base
562
563 All secrets are read from environment variables; never put keys in config files.
564 `)
565 }
566
567 // botAllowlistConfig 把 [bot.allowlist] 全局名单合成进网关 Allowlist(含钉钉)。
568 func botAllowlistConfig(al config.BotAllowlist) bot.AllowlistConfig {
569 return bot.AllowlistConfig{
570 Enabled: al.Enabled,
571 AllowAll: al.AllowAll,
572 Users: map[bot.Platform][]string{
573 bot.PlatformQQ: al.QQUsers,
574 bot.PlatformFeishu: al.FeishuUsers,
575 bot.PlatformWeixin: al.WeixinUsers,
576 bot.PlatformDingtalk: al.DingtalkUsers,
577 },
578 Approvers: map[bot.Platform][]string{
579 bot.PlatformQQ: al.QQApprovers,
580 bot.PlatformFeishu: al.FeishuApprovers,
581 bot.PlatformWeixin: al.WeixinApprovers,
582 bot.PlatformDingtalk: al.DingtalkApprovers,
583 },
584 Admins: map[bot.Platform][]string{
585 bot.PlatformQQ: al.QQAdmins,
586 bot.PlatformFeishu: al.FeishuAdmins,
587 bot.PlatformWeixin: al.WeixinAdmins,
588 bot.PlatformDingtalk: al.DingtalkAdmins,
589 },
590 Groups: map[bot.Platform][]string{
591 bot.PlatformQQ: al.QQGroups,
592 bot.PlatformFeishu: al.FeishuGroups,
593 bot.PlatformWeixin: al.WeixinGroups,
594 bot.PlatformDingtalk: al.DingtalkGroups,
595 },
596 }
597 }
598
598 lines GO