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
@@ -36,11 +50,43 @@ func stripNarration(text string) string {
}
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).
diff --git a/internal/services/channelbridge/sanitize_test.go b/internal/services/channelbridge/sanitize_test.go
index 3742f28..73ef992 100644
--- a/internal/services/channelbridge/sanitize_test.go
+++ b/internal/services/channelbridge/sanitize_test.go
@@ -89,3 +89,72 @@ func TestSplitBubbles_OnlyActionIsEmpty(t *testing.T) {
t.Errorf("splitBubbles = %#v, want empty", got)
}
}
+
+func TestStripNarration_StripsFullStarRoleplay(t *testing.T) {
+ t.Parallel()
+ in := "*动作:弯起眼睛笑了*\n当然看得到呀,亲爱的。"
+ got := stripNarration(in)
+ want := "当然看得到呀,亲爱的。"
+ if got != want {
+ t.Errorf("stripNarration = %q, want %q", got, want)
+ }
+}
+
+func TestStripNarration_StripsKeywordHalfStarRoleplay(t *testing.T) {
+ t.Parallel()
+ in := "*旁白:心里有点甜*\n这是正文。"
+ got := stripNarration(in)
+ want := "这是正文。"
+ if got != want {
+ t.Errorf("stripNarration = %q, want %q", got, want)
+ }
+}
+
+func TestStripNarration_KeepsPlainEmphasis(t *testing.T) {
+ t.Parallel()
+ in := "这是一段 *强调* 文字,不是动作。"
+ if got := stripNarration(in); got != in {
+ t.Errorf("stripNarration stripped emphasis: %q -> %q", in, got)
+ }
+}
+
+func TestStripNarration_MixedStarAndXml(t *testing.T) {
+ t.Parallel()
+ in := "拍拍肩膀 *旁白:其实有点开心* 你来了呀。对了 *强调*。"
+ got := stripNarration(in)
+ for _, leaked := range []string{"action", "旁白", "拍拍", "其实有点开心"} {
+ if strings.Contains(got, leaked) {
+ t.Errorf("stripNarration left role-play %q: %q", leaked, got)
+ }
+ }
+ if !strings.Contains(got, "你来了呀") || !strings.Contains(got, "*强调*") {
+ t.Errorf("stripNarration dropped chat/emphasis: %q", got)
+ }
+}
+
+func TestStripNarration_OnlyStarActionBecomesEmpty(t *testing.T) {
+ t.Parallel()
+ if got := stripNarration("*弯起眼睛*"); got != "" {
+ t.Errorf("stripNarration = %q, want empty", got)
+ }
+}
+
+func TestStripNarration_KeepsLongKeywordlessFullStar(t *testing.T) {
+ t.Parallel()
+ in := "*这是一段很长的全角星号包裹的普通段落,足足有几十个字,超过阈值所以应当被保留而不是被剥掉。*"
+ if got := stripNarration(in); got != in {
+ t.Errorf("stripNarration dropped long full-width star text: %q -> %q", in, got)
+ }
+}
+
+func TestSplitBubbles_StripsStarRoleplayInParagraphs(t *testing.T) {
+ t.Parallel()
+ in := "
*动作:弯起眼睛*当然看得到呀。
*旁白:心里甜* 好。
"
+ got := splitBubbles(in)
+ if len(got) != 2 || got[0] != "当然看得到呀。" {
+ t.Errorf("splitBubbles = %#v", got)
+ }
+ if got[1] != "好。" {
+ t.Errorf("splitBubbles[1] = %q, want 好。", got[1])
+ }
+}