Files
goclaw/internal/tools/team_notify_queue.go
T
henkedk ad893908a5 fix(telegram): propagate local_key for forum topic routing in team notifications (#800)
* fix(telegram): propagate local_key for forum topic routing in team notifications

Team task status messages (dispatched, completed, progress) were always
delivered to the General topic in Telegram forum groups because the
notification pipeline had no access to the originating topic's local_key.

Root cause: wireTeamProgressNotifySubscriber in gateway_events.go published
OutboundMessage with no Metadata, so the Telegram adapter had no
message_thread_id to route to the correct forum topic.

Fix has two parts:

1. Team notify path (root cause):
   - Add LocalKey field to TeamTaskEventPayload (protocol)
   - Extract local_key from tool context in WithContextInfo()
   - Add LocalKey to NotifyRoutingMeta
   - Pass LocalKey through to OutboundMessage Metadata in both
     leader mode (InboundMessage) and direct mode (OutboundMessage)

2. MCP bridge context (supporting):
   - Propagate local_key and session_key through bridge HTTP headers
   - Add X-Local-Key and X-Session-Key to BridgeContext
   - Extract and inject into tool context in gateway middleware
   - Include in HMAC signature for integrity

Closes #798

* fix(telegram): pass LocalKey from task metadata in all dispatch/fail broadcast sites

The initial fix added LocalKey to the event payload and WithContextInfo(),
but 4 broadcast call sites use individual With* options instead of
WithContextInfo — so LocalKey was never populated for:

- fallback_dispatch (team_tasks_create.go)
- dispatch_unblocked (team_tool_dispatch.go)
- post_turn dispatch (team_tool_validation.go)
- blocker/fail (team_tasks_blocker.go)

Add WithLocalKey() option function and extract TaskMetaLocalKey from task
metadata at each site, matching the existing TaskMetaPeerKind pattern.

* test(mcp): add HMAC verification tests for extra params (localKey, sessionKey)

- Add tests for SignBridgeContext/VerifyBridgeContext with extra params
- Test backward compat fallback for pre-localKey sessions
- Test that param order matters in signature
- Add clarifying comment for routing context injection security model

---------

Co-authored-by: Jens Henke <jens@henke.dk>
Co-authored-by: viettranx <viettranx@gmail.com>
2026-04-12 19:03:47 +07:00

105 lines
2.5 KiB
Go

package tools
import (
"strings"
"sync"
"time"
)
// NotifyRoutingMeta carries routing info for batched team notifications.
type NotifyRoutingMeta struct {
Mode string // "direct" or "leader"
Channel string
ChatID string
UserID string
LeadAgent string // agent key (only used in leader mode)
PeerKind string // "group" or "direct" — routes to correct session (#266)
LocalKey string // composite key with topic suffix for forum routing
}
// TeamNotifyQueue batches team task notifications per chat with debounce,
// following the same pattern as AnnounceQueue for subagent results.
type TeamNotifyQueue struct {
mu sync.Mutex
batches map[string]*notifyBatch // key: "teamID:chatID"
debounce time.Duration
cap int // immediate drain threshold
onDrain func(items []string, meta NotifyRoutingMeta)
}
type notifyBatch struct {
items []string
timer *time.Timer
meta NotifyRoutingMeta
}
// NewTeamNotifyQueue creates a notification queue with debounce and drain callback.
func NewTeamNotifyQueue(debounceMs int, onDrain func(items []string, meta NotifyRoutingMeta)) *TeamNotifyQueue {
if debounceMs <= 0 {
debounceMs = 2000
}
return &TeamNotifyQueue{
batches: make(map[string]*notifyBatch),
debounce: time.Duration(debounceMs) * time.Millisecond,
cap: 20,
onDrain: onDrain,
}
}
// Enqueue adds a formatted notification line to the batch for the given key.
// Resets debounce timer. Drains immediately if cap is reached.
func (q *TeamNotifyQueue) Enqueue(key string, content string, meta NotifyRoutingMeta) {
q.mu.Lock()
defer q.mu.Unlock()
b, ok := q.batches[key]
if !ok {
b = &notifyBatch{meta: meta}
q.batches[key] = b
}
b.items = append(b.items, content)
// Immediate drain if cap reached.
if len(b.items) >= q.cap {
if b.timer != nil {
b.timer.Stop()
}
items := b.items
bMeta := b.meta
delete(q.batches, key)
go q.drain(items, bMeta)
return
}
// Reset debounce timer.
if b.timer != nil {
b.timer.Stop()
}
b.timer = time.AfterFunc(q.debounce, func() {
q.mu.Lock()
b, ok := q.batches[key]
if !ok {
q.mu.Unlock()
return
}
items := b.items
bMeta := b.meta
delete(q.batches, key)
q.mu.Unlock()
q.drain(items, bMeta)
})
}
func (q *TeamNotifyQueue) drain(items []string, meta NotifyRoutingMeta) {
if len(items) == 0 || q.onDrain == nil {
return
}
q.onDrain(items, meta)
}
// FormatBatchedNotify joins notification lines into a single message.
func FormatBatchedNotify(items []string) string {
return strings.Join(items, "\n")
}