mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 16:15:24 +00:00
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
187 lines
4.5 KiB
Go
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
|
|
}
|