package channelbridge import ( "math/rand" "regexp" "strings" "time" "unicode/utf8" ) // 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 / as muted italic; IM channels must receive plain chat // text only, with the action/monologue markup removed). var ( // pairedRoleplayRe matches a complete ... / ... // 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 . pairedRoleplayRe = regexp.MustCompile(`(?is)<(action|narration)\b[^>]*?>.*?`) // strayRoleplayRe matches any remaining single opening/closing role-play tag (for a // malformed or unpaired emission). strayRoleplayRe = regexp.MustCompile(`(?is)]*?>`) // paragraphRe matches

...

/

paragraph markers, keeping the inner text. paragraphRe = regexp.MustCompile(`(?is)]*?>`) // blankLinesRe collapses runs of blank lines left behind after stripping. blankLinesRe = regexp.MustCompile(`\n\s*\n+`) // starRoleplayRe matches a full-width star-wrapped block *...* — the drift // form when the model forgets the / XML tags. The body is // captured so a non-role-play full-width emphasis can be left alone. starRoleplayRe = regexp.MustCompile(`(?s)*([\s\S]*?)*`) // halfStarRoleplayRe matches a half-width star block that clearly carries a // role-play keyword (动作/旁白/内心/神态/轻声), so *动作:弯起眼睛* is stripped // while plain markdown *emphasis* is left untouched. halfStarRoleplayRe = regexp.MustCompile(`(?s)\*((?:动作|旁白|内心|神态|轻声)\s*[::]?[\s\S]*?)\*`) ) // shortRoleplayRunes caps how long a keyword-less full-width star block may be // and still be treated as a role-play remark (design §9.5: conservative — a long // keyword-less *...* paragraph is left as text rather than dropped wholesale). const shortRoleplayRunes = 40 // 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 = starRoleplayRe.ReplaceAllStringFunc(s, func(m string) string { if isStarRoleplay(strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(m, "*"), "*"))) { return "" } return m }) s = halfStarRoleplayRe.ReplaceAllString(s, "") s = paragraphRe.ReplaceAllString(s, "") s = blankLinesRe.ReplaceAllString(s, "\n") return strings.TrimSpace(s) } // isStarRoleplay reports whether a full-width star block *...* holds a role-play // remark: it carries a role-play keyword, or it is a short stand-alone fragment // (no newline, no long body) — the drift shape *弯起眼睛*. A long keyword-less // paragraph wrapped in stars is kept so a real message is not dropped. func isStarRoleplay(inner string) bool { t := strings.TrimSpace(inner) if t == "" { return false } if hasRoleplayPrefix(t) { return true } return !strings.Contains(t, "\n") && utf8.RuneCountInString(t) <= shortRoleplayRunes } // hasRoleplayPrefix reports whether the block starts with a role-play keyword. func hasRoleplayPrefix(t string) bool { for _, p := range []string{"动作", "旁白", "内心", "神态", "轻声"} { if strings.HasPrefix(t, p) { return true } } return false } // paragraphBlockRe matches an entire

...

block, capturing its inner text // (design: each

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)]*?>(.*?)`) // splitBubbles turns a reply into the list of chat bubbles it carries, in order. // Each

...

block is one bubble, with role-play/action/narration stripped from // its text; empty bubbles are dropped. A reply with no

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), " 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 }