fix(channelbridge): IM 发送过滤 action/narration + <p>拆多气泡 + 0.5-2s 间隔
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

把回复转发给 IM 适配器(sendReply)时剥掉 <action>/<narration> 动作/内心独白(Web 端
才渲染灰斜体,IM 只能收纯文本),并按 <p> 段落拆成多条气泡分别发送;多气泡发送间隔
随机 0.5~2s(仅 IM,Web 前端不延迟)。新增 sanitize.go stripNarration/splitBubbles/
bubbleGap + 测试。
This commit is contained in:
Aska committed 2026-09-07 17:50:56 +08:00
1 parent dcb0b33954
commit 5a0c1e2fd1
11 files changed
+790 -12

No files matched your search

@@ -597,19 +597,38 @@ func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a ada
if a == nil {
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
}
out := adapter.UnifiedMessage{
Platform: in.Platform,
ChannelType: in.ChannelType,
ChannelID: in.ChannelID,
GroupID: in.GroupID,
GroupName: in.GroupName,
SenderID: in.SenderID,
Sender: in.Sender,
Content: text,
ContentType: adapter.ContentText,
Timestamp: time.Now().UTC(),
// IM channels receive the reply as one or more bubbles: role-play/action/narration
// markup (rendered as muted italic on the Web) is stripped, and each <p> paragraph
// becomes its own bubble/message (design/17 §2). A reply that was only an action is
// dropped rather than sent empty. A multi-bubble reply spaces its sends 0.5-2s apart
// so a burst doesn't flood the channel; the Web frontend has no such delay.
bubbles := splitBubbles(text)
if len(bubbles) == 0 {
b.log.Debug("reply has no plain text, skipped", "platform", in.Platform, "channel", in.ChannelID)
return nil
}
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{out}})
for i, content := range bubbles {
if i > 0 {
time.Sleep(bubbleGap())
}
out := adapter.UnifiedMessage{
Platform: in.Platform,
ChannelType: in.ChannelType,
ChannelID: in.ChannelID,
GroupID: in.GroupID,
GroupName: in.GroupName,
SenderID: in.SenderID,
Sender: in.Sender,
Content: content,
ContentType: adapter.ContentText,
Timestamp: time.Now().UTC(),
}
// Each bubble is its own send so the gap lands between messages.
if err := a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{out}}); err != nil {
return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err)
}
}
return nil
}
// adapter returns the registered adapter for a platform name, or nil.
@@ -0,0 +1,81 @@
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
}
@@ -0,0 +1,91 @@
package channelbridge
import (
"strings"
"testing"
)
func TestStripNarration_RemovesActionBlock(t *testing.T) {
t.Parallel()
in := "<action>弯起眼睛笑了</action>\n当然看得到呀,亲爱的。\n怎么突然这么问,是想我了吗?"
got := stripNarration(in)
want := "当然看得到呀,亲爱的。\n怎么突然这么问,是想我了吗?"
if got != want {
t.Errorf("stripNarration = %q, want %q", got, want)
}
}
func TestStripNarration_RemovesNarrationAndParagraphs(t *testing.T) {
t.Parallel()
in := "<p>你好呀</p>\n<narration>心里有点甜</narration>\n<p>今天天气不错</p>"
got := stripNarration(in)
// Paragraph markers flattened to newlines; narration block dropped entirely.
if got != "你好呀\n今天天气不错" {
t.Errorf("stripNarration = %q, want %q", got, "你好呀\n今天天气不错")
}
}
func TestStripNarration_PureChatKept(t *testing.T) {
t.Parallel()
in := "就是普通一句话,没有动作。\n第二行。"
if got := stripNarration(in); got != in {
t.Errorf("stripNarration changed plain text: %q -> %q", in, got)
}
}
func TestStripNarration_OnlyActionBecomesEmpty(t *testing.T) {
t.Parallel()
in := "<action>拍拍你</action>"
if got := stripNarration(in); got != "" {
t.Errorf("stripNarration = %q, want empty", got)
}
}
func TestStripNarration_CaseAndStrayTags(t *testing.T) {
t.Parallel()
in := "<Action>扑上来</Action> 你好 <narration/> 世界"
got := stripNarration(in)
if strings.Contains(got, "action") || strings.Contains(got, "narration") || strings.Contains(got, "扑上来") {
t.Errorf("stripNarration left role-play markup: %q", got)
}
if !strings.Contains(got, "你好") || !strings.Contains(got, "世界") {
t.Errorf("stripNarration dropped chat text: %q", got)
}
}
func TestSplitBubbles_Paragraphs(t *testing.T) {
t.Parallel()
in := "<p>晚上好呀,亲爱的♪</p><p>怎么突然这么问,是想我了吗?</p>"
got := splitBubbles(in)
if len(got) != 2 || got[0] != "晚上好呀,亲爱的♪" || got[1] != "怎么突然这么问,是想我了吗?" {
t.Errorf("splitBubbles = %#v", got)
}
}
func TestSplitBubbles_StripsRoleplayAndDropsEmpty(t *testing.T) {
t.Parallel()
in := "<p><action>弯起眼睛笑了</action>当然看得到呀。</p><p>❤</p>"
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])
}
}
func TestSplitBubbles_NoParagraphIsSingleBubble(t *testing.T) {
t.Parallel()
in := "就是普通一句话\n带个换行。"
got := splitBubbles(in)
if len(got) != 1 || got[0] != in {
t.Errorf("splitBubbles = %#v", got)
}
}
func TestSplitBubbles_OnlyActionIsEmpty(t *testing.T) {
t.Parallel()
if got := splitBubbles("<action>拍拍你</action>"); len(got) != 0 {
t.Errorf("splitBubbles = %#v, want empty", got)
}
}