返回 DeepSeek-Reasonix
render.go
根目录 / internal / bot / render.go
1 package bot
2
3 import (
4 "context"
5 "fmt"
6 "log/slog"
7 "strings"
8 "time"
9 "unicode"
10
11 "reasonix/internal/event"
12 )
13
14 // messageEditor 是适配器的可选能力:原地编辑已发送的消息。实现它的适配器
15 // (目前是飞书,经 Im.Message.Patch)获得回合中的流式输出——渲染器不断更新
16 // 同一条“live 消息”,而不是攒到回合结束一次性分段发送。
17 type messageEditor interface {
18 EditMessage(ctx context.Context, messageID string, msg OutboundMessage) error
19 }
20
21 // renderSink 将 Reasonix 事件流渲染为平台消息。
22 type renderSink struct {
23 ctx context.Context
24 adapter Adapter
25 editor messageEditor // 非 nil 时启用原地编辑流式输出
26 connID string
27 domain string
28 chatID string
29 chatType ChatType
30 userID string
31 replyTo string
32 logger *slog.Logger
33 ctrl botController
34 onApproval func(event.Approval)
35 onAsk func(event.Ask)
36
37 // 渲染缓冲
38 buf strings.Builder
39 thinking strings.Builder
40 inThinking bool
41 toolNames map[string]string // tool ID -> name
42 lastFlush time.Time
43 lastProgress time.Time
44 progressCount int
45
46 // 流式 live 消息状态(editor != nil 时使用)
47 liveMsgID string // 正在原地编辑的消息 ID;空表示当前块还没创建消息
48 liveSentBytes int // buf 前缀中已成功送达 live 消息的字节数
49 lastEdit time.Time // 上次成功 create/edit 的时间,用于限频
50 }
51
52 const (
53 renderSoftFlushAfter = 1200 * time.Millisecond
54 renderMaxChunkRunes = 1800
55 renderHardChunkRunes = 3500
56 renderProgressMinInterval = 2 * time.Second
57 renderMaxProgressMessages = 3
58 )
59
60 func newRenderSink(ctx context.Context, adapter Adapter, connID, domain, chatID string, chatType ChatType, userID string, replyTo string, logger *slog.Logger, onApproval func(event.Approval), onAsk func(event.Ask)) *renderSink {
61 editor, _ := adapter.(messageEditor)
62 return &renderSink{
63 ctx: ctx,
64 adapter: adapter,
65 editor: editor,
66 connID: connID,
67 domain: domain,
68 chatID: chatID,
69 chatType: chatType,
70 userID: userID,
71 replyTo: replyTo,
72 logger: logger,
73 onApproval: onApproval,
74 onAsk: onAsk,
75 toolNames: make(map[string]string),
76 lastFlush: time.Now(),
77 }
78 }
79
80 func (s *renderSink) Emit(e event.Event) {
81 switch e.Kind {
82 case event.TurnStarted:
83 s.buf.Reset()
84 s.thinking.Reset()
85 s.inThinking = false
86 s.toolNames = make(map[string]string)
87 s.progressCount = 0
88 s.lastProgress = time.Time{}
89 s.liveMsgID = ""
90 s.liveSentBytes = 0
91 s.lastEdit = time.Time{}
92
93 case event.Reasoning:
94 if !s.inThinking {
95 s.inThinking = true
96 }
97 s.thinking.WriteString(e.Text)
98
99 case event.Text:
100 if s.inThinking {
101 s.inThinking = false
102 }
103 s.buf.WriteString(e.Text)
104 s.maybeStream()
105
106 case event.Message:
107 // full message received, do nothing extra
108
109 case event.ToolDispatch:
110 if e.Tool.Refreshed {
111 break
112 }
113 name := renderToolName(e.Tool)
114 s.toolNames[e.Tool.ID] = name
115 s.sendProgress(fmt.Sprintf("正在执行: %s", name), false)
116
117 case event.ToolResult:
118 name := s.toolNames[e.Tool.ID]
119 if name == "" {
120 name = renderToolName(e.Tool)
121 }
122 if e.Tool.Err != "" {
123 s.sendProgress(fmt.Sprintf("%s 执行失败,稍后会在结果中说明。", name), true)
124 }
125
126 case event.ToolProgress:
127 // Keep streaming tool output out of IM channels; the session transcript
128 // still records the complete controller turn for desktop review.
129
130 case event.ApprovalRequest:
131 // 发送审批请求
132 if s.onApproval != nil {
133 s.onApproval(e.Approval)
134 }
135 approvalText := renderApprovalText(e.Approval)
136 msg := OutboundMessage{
137 ConnectionID: s.connID,
138 Domain: s.domain,
139 ChatID: s.chatID,
140 ChatType: s.chatType,
141 Text: approvalText,
142 ReplyToMsgID: s.replyTo,
143 }
144 switch s.adapter.Platform() {
145 case PlatformQQ:
146 if isRecoveryApproval(e.Approval) {
147 msg.Keyboard = recoveryKeyboard(e.Approval)
148 } else {
149 msg.Keyboard = approvalKeyboard(e.Approval.ID)
150 }
151 case PlatformFeishu:
152 if isRecoveryApproval(e.Approval) {
153 msg.Card = recoveryCard(e.Approval, s.chatType, s.userID)
154 } else {
155 msg.Card = approvalCard(e.Approval, s.chatType, s.userID)
156 }
157 }
158 _ = s.send(msg)
159
160 case event.AskRequest:
161 if s.onAsk != nil {
162 s.onAsk(e.Ask)
163 }
164 // 发送问答请求
165 askText := renderAskText(e.Ask)
166 msg := OutboundMessage{
167 ConnectionID: s.connID,
168 Domain: s.domain,
169 ChatID: s.chatID,
170 ChatType: s.chatType,
171 Text: askText,
172 ReplyToMsgID: s.replyTo,
173 }
174 if s.adapter.Platform() == PlatformFeishu {
175 msg.Card = askCard(e.Ask, askText, s.chatType, s.userID)
176 }
177 _ = s.send(msg)
178
179 case event.TurnDone:
180 // 刷新缓冲
181 s.flush()
182 if e.Err != nil {
183 if !strings.Contains(e.Err.Error(), "context canceled") {
184 _ = s.send(OutboundMessage{
185 ConnectionID: s.connID,
186 Domain: s.domain,
187 ChatID: s.chatID,
188 ChatType: s.chatType,
189 Text: fmt.Sprintf("❌ 执行出错: %v", e.Err),
190 ReplyToMsgID: s.replyTo,
191 })
192 }
193 }
194
195 case event.Notice:
196 if e.Audience == event.NoticeAudienceOperator {
197 // Persistence recovery remains available through controller logs and
198 // local operator surfaces. It is not actionable for the remote chat
199 // participant and must not interrupt their conversation (#7215).
200 s.logger.Debug("bot suppressed operator notice", "code", e.Code)
201 break
202 }
203 if e.Level == event.LevelWarn {
204 _ = s.send(OutboundMessage{
205 ConnectionID: s.connID,
206 Domain: s.domain,
207 ChatID: s.chatID,
208 ChatType: s.chatType,
209 Text: fmt.Sprintf("⚠️ %s", e.Text),
210 ReplyToMsgID: s.replyTo,
211 })
212 }
213
214 case event.CompactionStarted:
215 _ = s.send(OutboundMessage{
216 ConnectionID: s.connID,
217 Domain: s.domain,
218 ChatID: s.chatID,
219 ChatType: s.chatType,
220 Text: "🔄 正在压缩上下文...",
221 ReplyToMsgID: s.replyTo,
222 })
223 }
224 }
225
226 func (s *renderSink) flush() {
227 for strings.TrimSpace(s.buf.String()) != "" {
228 raw := s.buf.String()
229 // When streaming into a live message, finalize the whole remaining text
230 // with one edit instead of splitting at a semantic boundary — otherwise
231 // a final answer that does not end on a boundary (code block, list, URL)
232 // gets shrunk in place and its tail re-sent as a separate message,
233 // defeating the point of in-place streaming. Only fall back to boundary
234 // chunking when the remainder genuinely exceeds the hard cap.
235 if s.editor != nil && s.liveMsgID != "" && len([]rune(raw)) < renderHardChunkRunes {
236 s.flushPrefix(len(raw))
237 continue
238 }
239 idx := renderFlushIndex(raw, renderSoftFlushAfter)
240 if idx <= 0 {
241 idx = byteIndexForRuneLimit(raw, renderMaxChunkRunes)
242 }
243 if idx <= 0 || idx > len(raw) {
244 idx = len(raw)
245 }
246 s.flushPrefix(idx)
247 }
248 }
249
250 func (s *renderSink) flushPrefix(idx int) {
251 raw := s.buf.String()
252 if idx <= 0 || idx > len(raw) {
253 idx = len(raw)
254 }
255 text := strings.TrimSpace(raw[:idx])
256 if text == "" {
257 remaining := raw[idx:]
258 s.buf.Reset()
259 s.buf.WriteString(remaining)
260 s.lastFlush = time.Now()
261 return
262 }
263 // resumeFrom marks where the not-yet-delivered remainder starts. On success
264 // it is idx (the block boundary). On edit failure the live message is frozen
265 // at raw[:liveSentBytes], so anything already shown past idx must NOT be
266 // re-queued — the resume point becomes max(idx, liveSentBytes), otherwise the
267 // [idx, liveSentBytes] span is both displayed and re-sent (duplication).
268 resumeFrom := idx
269 if s.liveMsgID != "" {
270 // 当前块已有 live 消息:把最终内容原地编辑进去,而不是再发一条。
271 if err := s.editLive(text); err != nil {
272 s.logger.Warn("bot live message final edit failed; sending tail as new message", "err", err)
273 if tail := strings.TrimSpace(raw[min(s.liveSentBytes, idx):idx]); tail != "" {
274 _ = s.send(s.textMessage(tail))
275 }
276 if s.liveSentBytes > resumeFrom {
277 resumeFrom = s.liveSentBytes
278 }
279 }
280 s.liveMsgID = ""
281 s.liveSentBytes = 0
282 } else {
283 _ = s.send(s.textMessage(text))
284 }
285 if resumeFrom > len(raw) {
286 resumeFrom = len(raw)
287 }
288 remaining := raw[resumeFrom:]
289 s.buf.Reset()
290 s.buf.WriteString(remaining)
291 s.lastFlush = time.Now()
292 }
293
294 // maybeStream 在每个文本增量后驱动流式输出:把已缓冲文本 create/edit 到
295 // live 消息。仅当适配器支持原地编辑时启用;限频间隔复用 renderSoftFlushAfter
296 // (1.2s,低于飞书单消息 Patch 的 QPS 上限)。
297 func (s *renderSink) maybeStream() {
298 if s.editor == nil {
299 return
300 }
301 raw := s.buf.String()
302 if len([]rune(raw)) >= renderHardChunkRunes {
303 // 当前块过长:按语义边界收尾 live 消息,剩余文本进入下一块。
304 idx := lastSemanticBoundary(raw, renderMaxChunkRunes)
305 if idx <= 0 {
306 idx = byteIndexForRuneLimit(raw, renderMaxChunkRunes)
307 }
308 s.flushPrefix(idx)
309 return
310 }
311 last := s.lastEdit
312 if s.liveMsgID == "" {
313 last = s.lastFlush
314 }
315 if time.Since(last) < renderSoftFlushAfter {
316 return
317 }
318 text := strings.TrimSpace(raw)
319 if text == "" {
320 return
321 }
322 if s.liveMsgID == "" {
323 res, err := s.adapter.Send(s.ctx, s.textMessage(text))
324 if err != nil {
325 // 创建失败(可能是瞬时网络错误):文本留在 buf 里,限频后重试;
326 // 就算一直失败,回合末的 flush 也会兜底发送。
327 s.logger.Warn("bot live message create failed", "err", err)
328 s.lastFlush = time.Now()
329 return
330 }
331 if strings.TrimSpace(res.MessageID) == "" {
332 // 平台没回消息 ID,无法编辑:本回合退回“攒到回合末分段发送”,
333 // 已发出的前缀从 buf 里去掉避免重复。
334 s.editor = nil
335 s.cutBufPrefix(len(raw))
336 return
337 }
338 s.liveMsgID = res.MessageID
339 s.liveSentBytes = len(raw)
340 s.lastEdit = time.Now()
341 return
342 }
343 if err := s.editLive(text); err != nil {
344 // 编辑失败(限频/超长/消息被撤回):结束这个块,已送达前缀不再重发,
345 // 未送达的尾部留在 buf 里由下一条消息续上。
346 s.logger.Warn("bot live message edit failed; rotating to new message", "err", err)
347 s.cutBufPrefix(s.liveSentBytes)
348 s.liveMsgID = ""
349 s.liveSentBytes = 0
350 return
351 }
352 s.liveSentBytes = len(raw)
353 s.lastEdit = time.Now()
354 }
355
356 func (s *renderSink) editLive(text string) error {
357 err := s.editor.EditMessage(s.ctx, s.liveMsgID, s.textMessage(text))
358 if err == nil {
359 s.lastEdit = time.Now()
360 }
361 return err
362 }
363
364 // cutBufPrefix 从 buf 头部移除 n 个字节(已送达 live 消息的内容)。
365 func (s *renderSink) cutBufPrefix(n int) {
366 raw := s.buf.String()
367 if n <= 0 {
368 return
369 }
370 if n > len(raw) {
371 n = len(raw)
372 }
373 remaining := raw[n:]
374 s.buf.Reset()
375 s.buf.WriteString(remaining)
376 s.lastFlush = time.Now()
377 }
378
379 func (s *renderSink) textMessage(text string) OutboundMessage {
380 return OutboundMessage{
381 ConnectionID: s.connID,
382 Domain: s.domain,
383 ChatID: s.chatID,
384 ChatType: s.chatType,
385 Text: text,
386 ReplyToMsgID: s.replyTo,
387 }
388 }
389
390 func (s *renderSink) sendProgress(text string, force bool) {
391 text = strings.TrimSpace(text)
392 if text == "" {
393 return
394 }
395 now := time.Now()
396 if s.progressCount >= renderMaxProgressMessages {
397 return
398 }
399 if !force && !s.lastProgress.IsZero() && now.Sub(s.lastProgress) < renderProgressMinInterval {
400 return
401 }
402 _ = s.send(OutboundMessage{
403 ConnectionID: s.connID,
404 Domain: s.domain,
405 ChatID: s.chatID,
406 ChatType: s.chatType,
407 Text: text,
408 ReplyToMsgID: s.replyTo,
409 })
410 s.progressCount++
411 s.lastProgress = now
412 }
413
414 func renderToolName(t event.Tool) string {
415 if name := strings.TrimSpace(t.Name); name != "" {
416 return name
417 }
418 if id := strings.TrimSpace(t.ID); id != "" {
419 return id
420 }
421 return "tool"
422 }
423
424 func renderFlushIndex(text string, elapsed time.Duration) int {
425 if strings.TrimSpace(text) == "" {
426 return 0
427 }
428 runes := []rune(text)
429 if len(runes) >= renderHardChunkRunes {
430 if idx := lastSemanticBoundary(text, renderHardChunkRunes); idx > 0 {
431 return idx
432 }
433 return byteIndexForRuneLimit(text, renderMaxChunkRunes)
434 }
435 if len(runes) >= renderMaxChunkRunes {
436 if idx := lastSemanticBoundary(text, renderMaxChunkRunes); idx > 0 {
437 return idx
438 }
439 }
440 if elapsed < renderSoftFlushAfter {
441 return 0
442 }
443 return lastSemanticBoundary(text, len(runes))
444 }
445
446 func lastSemanticBoundary(text string, maxRunes int) int {
447 if maxRunes <= 0 {
448 return 0
449 }
450 count := 0
451 lastBoundary := 0
452 lastNonSpaceBoundary := 0
453 inFence := false
454 for idx, r := range text {
455 if strings.HasPrefix(text[idx:], "```") {
456 inFence = !inFence
457 }
458 count++
459 if count > maxRunes {
460 break
461 }
462 next := idx + len(string(r))
463 if r == '\n' && !inFence {
464 lastNonSpaceBoundary = next
465 lastBoundary = next
466 continue
467 }
468 if unicode.IsSpace(r) {
469 if lastNonSpaceBoundary > 0 {
470 lastBoundary = next
471 }
472 continue
473 }
474 if inFence {
475 continue
476 }
477 if isSemanticBoundaryRune(r) {
478 lastNonSpaceBoundary = next
479 lastBoundary = next
480 }
481 }
482 return lastBoundary
483 }
484
485 func isSemanticBoundaryRune(r rune) bool {
486 switch r {
487 case '.', '!', '?', ';', '。', '!', '?', ';', '…':
488 return true
489 default:
490 return false
491 }
492 }
493
494 func byteIndexForRuneLimit(text string, maxRunes int) int {
495 if maxRunes <= 0 {
496 return 0
497 }
498 count := 0
499 for idx, r := range text {
500 count++
501 if count >= maxRunes {
502 return idx + len(string(r))
503 }
504 }
505 return len(text)
506 }
507
508 func (s *renderSink) send(msg OutboundMessage) error {
509 _, err := s.adapter.Send(s.ctx, msg)
510 return err
511 }
512
513 func approvalKeyboard(id string) *InlineKeyboard {
514 return &InlineKeyboard{Rows: []InlineKeyboardRow{{
515 Buttons: []InlineKeyboardButton{
516 {ID: "allow_once", Label: "允许一次", Style: 1, CallbackID: "/approve " + id},
517 {ID: "deny", Label: "拒绝", Style: 2, CallbackID: "/deny " + id},
518 },
519 }}}
520 }
521
522 func recoveryKeyboard(a event.Approval) *InlineKeyboard {
523 if isRecoveryPlanChange(a) {
524 return &InlineKeyboard{Rows: []InlineKeyboardRow{{Buttons: []InlineKeyboardButton{
525 {ID: "recovery_continue", Label: "1 采用并继续", Style: 0, CallbackID: "/recovery-continue " + a.ID},
526 {ID: "recovery_revise", Label: "2 不采用并调整", Style: 0, CallbackID: "/recovery-revise " + a.ID},
527 }}}}
528 }
529 buttons := []InlineKeyboardButton{{ID: "recovery_continue", Label: "1 继续一次", Style: 1, CallbackID: "/recovery-continue " + a.ID}}
530 if a.Recovery != nil && a.Recovery.CanGrantTask {
531 buttons = append(buttons, InlineKeyboardButton{ID: "recovery_continue_task", Label: "2 本任务允许同类", Style: 0, CallbackID: "/recovery-continue-task " + a.ID})
532 return &InlineKeyboard{Rows: []InlineKeyboardRow{{Buttons: buttons}, {Buttons: []InlineKeyboardButton{{ID: "recovery_revise", Label: "3 换个办法", Style: 0, CallbackID: "/recovery-revise " + a.ID}}}}}
533 }
534 buttons = append(buttons, InlineKeyboardButton{ID: "recovery_revise", Label: "2 换个办法", Style: 0, CallbackID: "/recovery-revise " + a.ID})
535 return &InlineKeyboard{Rows: []InlineKeyboardRow{{Buttons: buttons}}}
536 }
537
538 func isRecoveryApproval(a event.Approval) bool {
539 return strings.EqualFold(strings.TrimSpace(a.Kind), "recovery") || a.Recovery != nil
540 }
541
542 func isRecoveryPlanChange(a event.Approval) bool {
543 if !isRecoveryApproval(a) || a.Recovery == nil {
544 return false
545 }
546 switch strings.ToLower(strings.TrimSpace(a.Recovery.ChangeKind)) {
547 case "strategy", "scope":
548 return true
549 default:
550 return false
551 }
552 }
553
554 func renderApprovalText(a event.Approval) string {
555 if isRecoveryApproval(a) {
556 return renderRecoveryText(a)
557 }
558 return fmt.Sprintf("⚠️ 需要批准操作:\n工具: %s\n操作: %s\n\nID: `%s`\n回复 1 批准,回复 2 拒绝;也可用 /approve %s 或 /deny %s。",
559 a.Tool, a.Subject, a.ID, a.ID, a.ID)
560 }
561
562 func renderRecoveryText(a event.Approval) string {
563 var b strings.Builder
564 if isRecoveryPlanChange(a) {
565 b.WriteString("⚠️ 执行计划需要你的决定\n")
566 } else {
567 b.WriteString("⚠️ 执行前确认\n")
568 }
569 rec := a.Recovery
570 if rec != nil {
571 if isRecoveryPlanChange(a) && (strings.TrimSpace(rec.PlanBefore) != "" || strings.TrimSpace(rec.PlanAfter) != "") {
572 if before := clipBotPlan(rec.PlanBefore); before != "" {
573 fmt.Fprintf(&b, "原计划:\n%s\n", before)
574 }
575 if after := clipBotPlan(rec.PlanAfter); after != "" {
576 fmt.Fprintf(&b, "新计划:\n%s\n", after)
577 }
578 } else if next := firstNonEmptyBot(rec.NextAction, a.Subject, a.Tool); next != "" {
579 fmt.Fprintf(&b, "即将执行: %s\n", next)
580 }
581 why := firstNonEmptyBot(rec.ChangeRationale, rec.ReviewRationale, a.Reason)
582 if why != "" {
583 fmt.Fprintf(&b, "原因: %s\n", why)
584 }
585 } else {
586 fmt.Fprintf(&b, "即将执行: %s\n", firstNonEmptyBot(a.Subject, a.Tool))
587 }
588 if isRecoveryPlanChange(a) {
589 fmt.Fprintf(&b, "\nID: `%s`\n回复 1 采用新计划并继续,2 不采用并让 Auto 调整。需要给出具体意见时,可使用 `/recovery-revise %s <调整意见>`。", a.ID, a.ID)
590 } else if rec != nil && rec.CanGrantTask {
591 if scope := strings.TrimSpace(rec.TaskGrantScope); scope != "" {
592 fmt.Fprintf(&b, "授权范围: %s\n", scope)
593 }
594 fmt.Fprintf(&b, "\nID: `%s`\n回复 1 继续一次,2 在本任务内允许同类操作,3 换个办法。范围扩大或风险升级仍会再次确认。", a.ID)
595 } else {
596 fmt.Fprintf(&b, "\nID: `%s`\n回复 1 继续,2 换个办法。", a.ID)
597 }
598 return b.String()
599 }
600
601 func approvalCard(a event.Approval, chatType ChatType, userID string) *InteractiveCard {
602 return &InteractiveCard{
603 Header: "需要批准操作",
604 Elements: []InteractiveCardElement{
605 {Tag: "markdown", Content: fmt.Sprintf("**工具**: %s\n\n**操作**: %s\n\nID: `%s`", a.Tool, a.Subject, a.ID)},
606 {Tag: "action", Extra: map[string]any{
607 "actions": []map[string]any{
608 {"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "允许一次"}, "type": "primary", "value": cardActionValue("/approve "+a.ID, chatType, userID)},
609 {"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "拒绝"}, "type": "danger", "value": cardActionValue("/deny "+a.ID, chatType, userID)},
610 },
611 }},
612 },
613 }
614 }
615
616 func recoveryCard(a event.Approval, chatType ChatType, userID string) *InteractiveCard {
617 if isRecoveryPlanChange(a) {
618 return &InteractiveCard{
619 Header: "执行计划需要你的决定",
620 Elements: []InteractiveCardElement{
621 {Tag: "markdown", Content: renderRecoveryText(a)},
622 {Tag: "action", Extra: map[string]any{
623 "actions": []map[string]any{
624 {"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "采用并继续"}, "type": "default", "value": cardActionValue("/recovery-continue "+a.ID, chatType, userID)},
625 {"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "不采用并调整"}, "type": "default", "value": cardActionValue("/recovery-revise "+a.ID, chatType, userID)},
626 },
627 }},
628 },
629 }
630 }
631 actions := []map[string]any{
632 {"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "继续一次"}, "type": "primary", "value": cardActionValue("/recovery-continue "+a.ID, chatType, userID)},
633 }
634 if a.Recovery != nil && a.Recovery.CanGrantTask {
635 actions = append(actions, map[string]any{"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "本任务允许同类"}, "type": "default", "value": cardActionValue("/recovery-continue-task "+a.ID, chatType, userID)})
636 }
637 actions = append(actions, map[string]any{"tag": "button", "text": map[string]string{"tag": "plain_text", "content": "换个办法"}, "type": "default", "value": cardActionValue("/recovery-revise "+a.ID, chatType, userID)})
638 return &InteractiveCard{
639 Header: "执行前确认",
640 Elements: []InteractiveCardElement{
641 {Tag: "markdown", Content: renderRecoveryText(a)},
642 {Tag: "action", Extra: map[string]any{
643 "actions": actions,
644 }},
645 },
646 }
647 }
648
649 func clipBotPlan(plan string) string {
650 plan = strings.TrimSpace(plan)
651 const maxRunes = 800
652 runes := []rune(plan)
653 if len(runes) <= maxRunes {
654 return plan
655 }
656 return strings.TrimSpace(string(runes[:maxRunes])) + "…"
657 }
658
659 func firstNonEmptyBot(vals ...string) string {
660 for _, v := range vals {
661 if strings.TrimSpace(v) != "" {
662 return strings.TrimSpace(v)
663 }
664 }
665 return ""
666 }
667
668 func cardActionValue(command string, chatType ChatType, userID string) map[string]string {
669 value := map[string]string{
670 "command": command,
671 "chat_type": string(chatType),
672 }
673 if strings.TrimSpace(userID) != "" {
674 value["user_id"] = strings.TrimSpace(userID)
675 }
676 return value
677 }
678
679 func renderAskText(ask event.Ask) string {
680 var qb strings.Builder
681 qb.WriteString("❓ 请回答以下问题:\n")
682 for i, q := range ask.Questions {
683 fmt.Fprintf(&qb, "\n**%d. %s**\n", i+1, q.Prompt)
684 for j, opt := range q.Options {
685 fmt.Fprintf(&qb, " %d. %s", j+1, opt.Label)
686 if opt.Description != "" {
687 fmt.Fprintf(&qb, " — %s", opt.Description)
688 }
689 qb.WriteString("\n")
690 }
691 if q.Multi {
692 qb.WriteString(" (可多选)\n")
693 }
694 }
695 fmt.Fprintf(&qb, "\nID: `%s`", ask.ID)
696 if askSupportsNumericShortcut(ask) {
697 fmt.Fprintf(&qb, "\n直接回复选项编号即可回答;也可用 /answer %s <选项编号或文本>。", ask.ID)
698 } else {
699 fmt.Fprintf(&qb, "\n用 /answer %s <选项编号或文本> 回答;多题可用 q1=1;q2=2。", ask.ID)
700 }
701 return qb.String()
702 }
703
704 func askCard(ask event.Ask, fallback string, chatType ChatType, userID string) *InteractiveCard {
705 card := &InteractiveCard{
706 Header: "需要回答问题",
707 Elements: []InteractiveCardElement{
708 {Tag: "markdown", Content: fallback},
709 },
710 }
711 if !askSupportsNumericShortcut(ask) {
712 return card
713 }
714 question := ask.Questions[0]
715 actions := make([]map[string]any, 0, len(question.Options))
716 for i, opt := range question.Options {
717 label := strings.TrimSpace(opt.Label)
718 if label == "" {
719 label = fmt.Sprintf("选项 %d", i+1)
720 }
721 actions = append(actions, map[string]any{
722 "tag": "button",
723 "text": map[string]string{"tag": "plain_text", "content": label},
724 "type": "primary",
725 "value": cardActionValue(fmt.Sprintf("/answer %s %d", ask.ID, i+1), chatType, userID),
726 })
727 }
728 if len(actions) > 0 {
729 card.Elements = append(card.Elements, InteractiveCardElement{Tag: "action", Extra: map[string]any{"actions": actions}})
730 }
731 return card
732 }
733
734 func askSupportsNumericShortcut(ask event.Ask) bool {
735 return len(ask.Questions) == 1 && len(ask.Questions[0].Options) > 0
736 }
737
737 lines GO