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 / 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+`) ) // 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

...

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 }