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