把回复转发给 IM 适配器(sendReply)时剥掉 <action>/<narration> 动作/内心独白(Web 端 才渲染灰斜体,IM 只能收纯文本),并按 <p> 段落拆成多条气泡分别发送;多气泡发送间隔 随机 0.5~2s(仅 IM,Web 前端不延迟)。新增 sanitize.go stripNarration/splitBubbles/ bubbleGap + 测试。
82 lines
3.4 KiB
Go
82 lines
3.4 KiB
Go
package channelbridge
|
|
|
|
import (
|
|
"math/rand"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// This file holds the chat-reply sanitizer used before a reply is delivered to an
|
|
// IM channel adapter (design/17 §2 + the role-play convention: the Web client
|
|
// renders <action>/<narration> as muted italic; IM channels must receive plain chat
|
|
// text only, with the action/monologue markup removed).
|
|
|
|
var (
|
|
// pairedRoleplayRe matches a complete <action>...</action> / <narration>...</narration>
|
|
// block including its inner text, so an action/monologue is dropped wholesale before
|
|
// IM delivery. Non-greedy and case-insensitive to tolerate a model that emits <Action>.
|
|
pairedRoleplayRe = regexp.MustCompile(`(?is)<(action|narration)\b[^>]*?>.*?</(action|narration)\s*>`)
|
|
// strayRoleplayRe matches any remaining single opening/closing role-play tag (for a
|
|
// malformed or unpaired emission).
|
|
strayRoleplayRe = regexp.MustCompile(`(?is)</?(action|narration)\b[^>]*?>`)
|
|
// paragraphRe matches <p>...</p> / <p/> paragraph markers, keeping the inner text.
|
|
paragraphRe = regexp.MustCompile(`(?is)</?p\b[^>]*?>`)
|
|
// blankLinesRe collapses runs of blank lines left behind after stripping.
|
|
blankLinesRe = regexp.MustCompile(`\n\s*\n+`)
|
|
)
|
|
|
|
// stripNarration returns the plain chat text of a reply with role-play/action/
|
|
// narration markup removed, paragraph markers flattened and blank-line runs
|
|
// collapsed. It never raises or panics on malformed input; a reply that was only an
|
|
// action/monologue becomes an empty string (caller may then skip the send).
|
|
func stripNarration(text string) string {
|
|
if strings.TrimSpace(text) == "" {
|
|
return ""
|
|
}
|
|
s := pairedRoleplayRe.ReplaceAllString(text, "")
|
|
s = strayRoleplayRe.ReplaceAllString(s, "")
|
|
s = paragraphRe.ReplaceAllString(s, "")
|
|
s = blankLinesRe.ReplaceAllString(s, "\n")
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// paragraphBlockRe matches an entire <p>...</p> block, capturing its inner text
|
|
// (design: each <p> is one chat bubble; the Web client renders each as its own
|
|
// bubble and the IM adapters deliver each as its own message).
|
|
var paragraphBlockRe = regexp.MustCompile(`(?is)<p\b[^>]*?>(.*?)</p\s*>`)
|
|
|
|
// splitBubbles turns a reply into the list of chat bubbles it carries, in order.
|
|
// Each <p>...</p> block is one bubble, with role-play/action/narration stripped from
|
|
// its text; empty bubbles are dropped. A reply with no <p> markers is treated as a
|
|
// single bubble (newlines preserved). A pure action/monologue yields an empty slice.
|
|
func splitBubbles(text string) []string {
|
|
if !strings.Contains(strings.ToLower(text), "<p") {
|
|
if c := stripNarration(text); c != "" {
|
|
return []string{c}
|
|
}
|
|
return nil
|
|
}
|
|
out := make([]string, 0)
|
|
for _, m := range paragraphBlockRe.FindAllStringSubmatch(text, -1) {
|
|
if c := stripNarration(m[1]); c != "" {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
// <p> markers present but no well-formed block — fall back to whole text.
|
|
if c := stripNarration(text); c != "" {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bubbleGap returns a randomized 0.5s..2s delay used between IM outbound bubbles so a
|
|
// burst of paragraphs doesn't flood the channel (design/17 §2). The Web frontend is
|
|
// not affected — it renders all bubbles from the persisted stream without any delay.
|
|
// It is only consulted when a reply carries more than one bubble.
|
|
func bubbleGap() time.Duration {
|
|
return 500*time.Millisecond + time.Duration(rand.Float64()*1500)*time.Millisecond
|
|
}
|