Files
Duy /zuey/ f771cff77c fix(channels): coalesce multi-attachment inbounds (#63) (#90)
Implements 3 coalescing layers to handle rapid multi-attachment inbounds:
- Bus debouncer: delays inbound messages 1s, merges duplicates
- Web chat debouncer: buffers client-side inbound frames for batch RPC
- Telegram album aggregator: collects album members via AfterFunc+Stop timer

Drops media-bypass shortcut (forces 1s media floor). Aggregator enforces:
- AfterFunc+Stop timer discipline with ordered drain on stop
- 2-tuple (album_id, sender) keying for isolation
- Dual DoS caps: max 10 albums per sender, max 100 messages per album
- merged_message_ids dedup seeding across all 3 surfaces

Closes #63
2026-05-28 18:30:34 +07:00

187 lines
4.5 KiB
Go

package methods
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// chatMediaDebounceFloorMs is the minimum debounce window applied to Web Chat
// sends that carry media when the post-override delay would otherwise be 0.
// Mirrors cmd/gateway_consumer_debounce.go mediaDebounceFloorMs (Phase 1) —
// duplicated by value (1000ms) to keep gateway/methods decoupled from cmd.
const chatMediaDebounceFloorMs = 1000
type chatSendRequest struct {
ctx context.Context
client *gateway.Client
requestID string
params chatSendParams
loop agent.Agent
userID string
sessionKey string
}
type chatDebouncer struct {
mu sync.Mutex
buffers map[string]*chatDebounceBuffer
flushFn func([]chatSendRequest)
}
type chatDebounceBuffer struct {
items []chatSendRequest
timer *time.Timer
}
func newChatDebouncer(flushFn func([]chatSendRequest)) *chatDebouncer {
return &chatDebouncer{
buffers: make(map[string]*chatDebounceBuffer),
flushFn: flushFn,
}
}
// Push appends an item to the per-key buffer.
//
// Behavior:
// - delay > 0: append + (re)set the silence timer (existing buffered path).
// - delay <= 0 AND a buffer already exists with items: append the incoming
// item to the buffer and flush immediately (merge-then-flush). Required so
// a no-media follow-up cannot bypass a buffered media chat-send and trigger
// a duplicate dispatch (Phase 1.5 Rule #4, mirrors bus debouncer Rule #1).
// - delay <= 0 AND no buffer exists: dispatch immediately (passthrough).
func (d *chatDebouncer) Push(key string, delay time.Duration, item chatSendRequest) {
if delay <= 0 {
d.mu.Lock()
buf, exists := d.buffers[key]
if exists && len(buf.items) > 0 {
buf.items = append(buf.items, item)
if buf.timer != nil {
buf.timer.Stop()
}
items := buf.items
delete(d.buffers, key)
d.mu.Unlock()
d.flushFn(items)
return
}
d.mu.Unlock()
d.flushFn([]chatSendRequest{item})
return
}
d.mu.Lock()
buf, exists := d.buffers[key]
if !exists {
buf = &chatDebounceBuffer{}
d.buffers[key] = buf
}
buf.items = append(buf.items, item)
if buf.timer != nil {
buf.timer.Stop()
}
buf.timer = time.AfterFunc(delay, func() {
d.Flush(key)
})
d.mu.Unlock()
}
func (d *chatDebouncer) Flush(key string) {
items := d.Take(key)
if len(items) == 0 {
return
}
d.flushFn(items)
}
func (d *chatDebouncer) Take(key string) []chatSendRequest {
d.mu.Lock()
buf, ok := d.buffers[key]
if !ok || len(buf.items) == 0 {
d.mu.Unlock()
return nil
}
if buf.timer != nil {
buf.timer.Stop()
}
items := buf.items
delete(d.buffers, key)
d.mu.Unlock()
return items
}
func (d *chatDebouncer) Discard(key string) {
d.mu.Lock()
defer d.mu.Unlock()
buf, ok := d.buffers[key]
if !ok {
return
}
if buf.timer != nil {
buf.timer.Stop()
}
delete(d.buffers, key)
}
func (d *chatDebouncer) Stop() {
d.mu.Lock()
keys := make([]string, 0, len(d.buffers))
for key := range d.buffers {
keys = append(keys, key)
}
d.mu.Unlock()
for _, key := range keys {
d.Flush(key)
}
}
func mergeChatSendRequests(items []chatSendRequest) chatSendParams {
if len(items) == 0 {
return chatSendParams{}
}
last := items[len(items)-1].params
parts := make([]string, 0, len(items))
for _, item := range items {
if item.params.Message != "" {
parts = append(parts, item.params.Message)
}
}
last.Message = strings.Join(parts, "\n")
return last
}
// chatDebounceDelay computes the per-send debounce window.
//
// Precedence: agent override (when set) overrides the global config. The media
// floor fires ONLY when the post-override delay is exactly 0 AND the message
// carries media — a non-zero agent override (even below the floor) is honored
// verbatim. Mirrors Phase 1's resolveInboundDebounceDelay + applyMediaFloor.
func chatDebounceDelay(cfg *config.Config, agentOtherConfig json.RawMessage, hasMedia bool) time.Duration {
debounceMs := 0
if cfg != nil {
debounceMs = cfg.Gateway.InboundDebounceMs
}
if overrideMs, ok := store.ParseInboundDebounceMsFromOtherConfig(agentOtherConfig); ok {
debounceMs = overrideMs
}
if debounceMs <= 0 && hasMedia {
debounceMs = chatMediaDebounceFloorMs
}
if debounceMs <= 0 {
return 0
}
return time.Duration(debounceMs) * time.Millisecond
}
func chatDebounceKey(userID, sessionKey string) string {
return userID + ":" + sessionKey
}