refactor(channelbridge): 多气泡分割+旁白剥离+随机延迟上移为IM公共出口——sendReply/SendProactive统一(微信主动推送也分割)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
Aska committed 2026-09-08 16:26:35 +08:00
1 parent d09b25071d
commit bcf7fe5de0
4 files changed
+233 -36

No files matched your search

@@ -169,8 +169,12 @@ type Bridge struct {
engage EngageConfig
engageFor func(platform, groupID string) *EngageConfig
engageRand func() float64
engageMu sync.Mutex
engageGroups map[string]*engageGroupState
// bubbleGapFn is the injectable delay between IM outbound bubbles (0.5-2s),
// defaulting to the package bubbleGap. Tests inject a fixed short gap so the
// multi-bubble delay is asserted deterministically without sleeping a real 0.5-2s.
bubbleGapFn func() time.Duration
engageMu sync.Mutex
engageGroups map[string]*engageGroupState
mu sync.RWMutex
adapters map[string]adapter.PlatformAdapter
@@ -242,6 +246,7 @@ func New(opts Options) (*Bridge, error) {
engage: opts.Engage,
engageFor: opts.EngageFor,
engageRand: engageRand,
bubbleGapFn: bubbleGap,
engageGroups: make(map[string]*engageGroupState),
adapters: make(map[string]adapter.PlatformAdapter),
connected: make(map[string]bool),
@@ -660,7 +665,8 @@ func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.Sess
// sendReply delivers the loop's text back to the channel's source conversation
// through the account adapter that received the message (multi-account safe,
// design/16 §4.6 §5). It mirrors the inbound message's channel targeting fields so
// the adapter resolves the same peer the message came from.
// the adapter resolves the same peer the message came from, and it exits through the
// shared IM bubble dispatcher (strip role-play/narration + <p> split + 0.5-2s gap).
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
if a == nil {
a = b.adapter(in.Platform)
@@ -668,38 +674,9 @@ 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)
}
// 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
}
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
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}, text)
}
// adapter returns the registered adapter for a platform name, or nil.