Files
goclaw/cmd/gateway_events.go
T
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

376 lines
12 KiB
Go

package cmd
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// wireEventSubscribers registers team task audit and team progress notification subscribers on the message bus.
// Must be called after pgStores and msgBus are initialized.
func (d *gatewayDeps) wireEventSubscribers() {
d.wireTeamTaskAuditSubscriber()
d.wireTeamProgressNotifySubscriber()
}
// wireTeamTaskAuditSubscriber persists team task lifecycle events to the team_task_events table.
func (d *gatewayDeps) wireTeamTaskAuditSubscriber() {
if d.pgStores.Teams == nil {
return
}
teamEventStore := d.pgStores.Teams
d.msgBus.Subscribe(bus.TopicTeamTaskAudit, func(evt bus.Event) {
eventType := teamTaskEventType(evt.Name)
if eventType == "" {
return
}
payload, ok := evt.Payload.(protocol.TeamTaskEventPayload)
if !ok {
return
}
taskID, err := uuid.Parse(payload.TaskID)
if err != nil {
return
}
// Propagate tenant from bus event to ensure correct tenant isolation.
auditCtx := store.WithTenantID(context.Background(), evt.TenantID)
// Populate data field with event-specific context for audit trail.
var data json.RawMessage
switch evt.Name {
case protocol.EventTeamTaskFailed, protocol.EventTeamTaskRejected, protocol.EventTeamTaskCancelled:
if payload.Reason != "" {
data, _ = json.Marshal(map[string]string{"reason": payload.Reason})
}
case protocol.EventTeamTaskCommented:
if payload.CommentText != "" {
data, _ = json.Marshal(map[string]string{"comment_text": payload.CommentText})
}
case protocol.EventTeamTaskProgress:
data, _ = json.Marshal(map[string]any{"progress_percent": payload.ProgressPercent, "progress_step": payload.ProgressStep})
}
if err := teamEventStore.RecordTaskEvent(auditCtx, &store.TeamTaskEventData{
TaskID: taskID,
EventType: eventType,
ActorType: payload.ActorType,
ActorID: payload.ActorID,
Data: data,
}); err != nil {
slog.Warn("team_task_audit.record_failed", "task_id", payload.TaskID, "event", eventType, "error", err)
}
})
slog.Info("team task event subscriber registered")
}
// wireTeamProgressNotifySubscriber forwards task events to chat channels.
// Reads team.settings.notifications config; direct mode sends outbound, leader mode
// injects into leader agent session. Notifications are batched per chat
// with 2s debounce to avoid spamming users when multiple tasks dispatch at once.
func (d *gatewayDeps) wireTeamProgressNotifySubscriber() {
if d.pgStores.Teams == nil {
return
}
notifyTeamStore := d.pgStores.Teams
notifyAgentStore := d.pgStores.Agents
teamNotifyQueue := tools.NewTeamNotifyQueue(2000, func(items []string, meta tools.NotifyRoutingMeta) {
content := tools.FormatBatchedNotify(items)
if meta.Mode == "leader" {
leaderContent := fmt.Sprintf("[Auto-status — relay to user, NO task actions]\n%s\n\nBriefly inform the user. Do NOT create, retry, reassign, or modify any tasks.", content)
d.msgBus.TryPublishInbound(bus.InboundMessage{
Channel: meta.Channel,
SenderID: "notification:progress",
ChatID: meta.ChatID,
AgentID: meta.LeadAgent,
UserID: meta.UserID,
PeerKind: meta.PeerKind,
Content: leaderContent,
Metadata: func() map[string]string {
m := map[string]string{"run_kind": tools.RunKindNotification}
if meta.LocalKey != "" {
m["local_key"] = meta.LocalKey
}
return m
}(),
})
} else {
d.msgBus.PublishOutbound(bus.OutboundMessage{
Channel: meta.Channel,
ChatID: meta.ChatID,
Content: content,
Metadata: buildAnnounceOutMeta(meta.LocalKey),
})
}
})
d.msgBus.Subscribe("consumer.team-notify", func(evt bus.Event) {
payload, ok := evt.Payload.(protocol.TeamTaskEventPayload)
if !ok || payload.TeamID == "" || payload.Channel == "" {
return
}
var notifyType string
switch evt.Name {
case protocol.EventTeamTaskDispatched:
notifyType = "dispatched"
case protocol.EventTeamTaskAssigned:
notifyType = "dispatched" // same config flag — human assign also notifies
case protocol.EventTeamTaskFailed:
notifyType = "failed"
case protocol.EventTeamTaskProgress:
notifyType = "progress"
case protocol.EventTeamTaskCompleted:
notifyType = "completed"
case protocol.EventTeamTaskCommented:
notifyType = "commented"
case protocol.EventTeamTaskCreated:
// Only notify for human-created tasks (agent-created go through dispatch).
if payload.ActorType != "human" {
return
}
notifyType = "new_task"
default:
return
}
teamUUID, err := uuid.Parse(payload.TeamID)
if err != nil {
return
}
team, err := notifyTeamStore.GetTeamUnscoped(context.Background(), teamUUID)
if err != nil || team == nil {
return
}
teamNotifyCfg := tools.ParseTeamNotifyConfig(team.Settings)
// Check if this notification type is enabled.
switch notifyType {
case "dispatched":
if !teamNotifyCfg.Dispatched {
return
}
case "failed":
if !teamNotifyCfg.Failed {
return
}
case "progress":
if !teamNotifyCfg.Progress {
return
}
case "completed":
if !teamNotifyCfg.Completed {
return
}
case "commented":
if !teamNotifyCfg.Commented {
return
}
case "new_task":
if !teamNotifyCfg.NewTask {
return
}
}
// Skip internal channels.
if payload.Channel == tools.ChannelSystem || payload.Channel == tools.ChannelTeammate {
return
}
// Resolve lead agent key (needed for leader mode routing + completed-by-leader skip).
var leadAgentKey string
if notifyAgentStore != nil {
if la, err := notifyAgentStore.GetByIDUnscoped(context.Background(), team.LeadAgentID); err == nil {
leadAgentKey = la.AgentKey
}
}
// Skip completed notification if task was completed by the leader
// (leader is already talking to the user, notification would be redundant).
if notifyType == "completed" && payload.OwnerAgentKey == leadAgentKey {
return
}
// Build notification message.
var content string
agentName := payload.OwnerAgentKey
if payload.OwnerDisplayName != "" {
agentName = payload.OwnerDisplayName
}
switch evt.Name {
case protocol.EventTeamTaskDispatched:
if payload.ActorID == "dispatch_unblocked" {
content = fmt.Sprintf("▶️ Task #%d \"%s\" → unblocked, dispatched to %s", payload.TaskNumber, payload.Subject, agentName)
} else {
content = fmt.Sprintf("📋 Task #%d \"%s\" → dispatched to %s", payload.TaskNumber, payload.Subject, agentName)
}
case protocol.EventTeamTaskAssigned:
content = fmt.Sprintf("📋 Task #%d \"%s\" → assigned to %s", payload.TaskNumber, payload.Subject, agentName)
case protocol.EventTeamTaskCompleted:
content = fmt.Sprintf("✅ Task #%d \"%s\" completed", payload.TaskNumber, payload.Subject)
case protocol.EventTeamTaskProgress:
if payload.ProgressStep != "" {
content = fmt.Sprintf("⏳ Task #%d \"%s\": %d%% — %s", payload.TaskNumber, payload.Subject, payload.ProgressPercent, payload.ProgressStep)
} else {
content = fmt.Sprintf("⏳ Task #%d \"%s\": %d%%", payload.TaskNumber, payload.Subject, payload.ProgressPercent)
}
case protocol.EventTeamTaskFailed:
reason := payload.Reason
if len(reason) > 200 {
reason = reason[:200] + "..."
}
content = fmt.Sprintf("❌ Task #%d \"%s\" failed: %s", payload.TaskNumber, payload.Subject, reason)
case protocol.EventTeamTaskCommented:
actor := payload.ActorID
if actor == "" {
actor = "unknown"
}
content = fmt.Sprintf("💬 Task #%d \"%s\": comment from %s", payload.TaskNumber, payload.Subject, actor)
case protocol.EventTeamTaskCreated:
content = fmt.Sprintf("📋 New task #%d \"%s\" created", payload.TaskNumber, payload.Subject)
}
// In leader mode, require resolved agent key for routing.
if teamNotifyCfg.Mode == "leader" && leadAgentKey == "" {
return
}
batchKey := payload.TeamID + ":" + payload.ChatID
teamNotifyQueue.Enqueue(batchKey, content, tools.NotifyRoutingMeta{
Mode: teamNotifyCfg.Mode,
Channel: payload.Channel,
ChatID: payload.ChatID,
UserID: payload.UserID,
LeadAgent: leadAgentKey,
PeerKind: payload.PeerKind,
LocalKey: payload.LocalKey,
})
})
slog.Info("team progress notification subscriber registered")
}
// wireAuditSubscriber sets up the audit log subscriber that persists events to activity_logs.
// Uses a buffered channel with a single worker to avoid unbounded goroutines.
// Returns the audit channel so the shutdown goroutine can close it to flush pending entries.
func (d *gatewayDeps) wireAuditSubscriber() chan bus.AuditEventPayload {
if d.pgStores.Activity == nil {
return nil
}
auditCh := make(chan bus.AuditEventPayload, 256)
d.msgBus.Subscribe(bus.TopicAudit, func(evt bus.Event) {
if evt.Name != protocol.EventAuditLog {
return
}
payload, ok := evt.Payload.(bus.AuditEventPayload)
if !ok {
return
}
select {
case auditCh <- payload:
default:
slog.Warn("audit.queue_full", "action", payload.Action)
}
})
go func() {
for payload := range auditCh {
auditCtx := store.WithTenantID(context.Background(), payload.TenantID)
if err := d.pgStores.Activity.Log(auditCtx, &store.ActivityLog{
ActorType: payload.ActorType,
ActorID: payload.ActorID,
Action: payload.Action,
EntityType: payload.EntityType,
EntityID: payload.EntityID,
IPAddress: payload.IPAddress,
Details: payload.Details,
}); err != nil {
slog.Warn("audit.log_failed", "action", payload.Action, "error", err)
}
}
}()
slog.Info("audit subscriber registered")
return auditCh
}
// wireChannelStreamingSubscriber subscribes to agent events for channel streaming/reaction forwarding.
// Events emitted by agent loops are broadcast to the bus; we forward them to the channel manager
// which routes to StreamingChannel/ReactionChannel. Also updates the Router activity registry.
func (d *gatewayDeps) wireChannelStreamingSubscriber() {
d.msgBus.Subscribe(bus.TopicChannelStreaming, func(event bus.Event) {
if event.Name != protocol.EventAgent {
return
}
agentEvent, ok := event.Payload.(agent.AgentEvent)
if !ok {
return
}
d.channelMgr.HandleAgentEvent(agentEvent.Type, agentEvent.RunID, agentEvent.Payload)
// Route activity events to Router (status registry) and DelegateManager (progress tracking).
if agentEvent.Type == protocol.AgentEventActivity {
payloadMap, _ := agentEvent.Payload.(map[string]any)
phase, _ := payloadMap["phase"].(string)
tool, _ := payloadMap["tool"].(string)
iteration := 0
if v, ok := payloadMap["iteration"].(int); ok {
iteration = v
}
if sessionKey := d.agentRouter.SessionKeyForRun(agentEvent.RunID); sessionKey != "" {
d.agentRouter.UpdateActivity(sessionKey, agentEvent.RunID, phase, tool, iteration)
}
}
// Clear activity on terminal events
if agentEvent.Type == protocol.AgentEventRunCompleted ||
agentEvent.Type == protocol.AgentEventRunFailed ||
agentEvent.Type == protocol.AgentEventRunCancelled {
if sessionKey := d.agentRouter.SessionKeyForRun(agentEvent.RunID); sessionKey != "" {
d.agentRouter.ClearActivity(sessionKey)
}
}
})
}
// teamTaskEventType maps bus event names to team_task_events.event_type values.
// Returns empty string for non-task events (caller should skip).
func teamTaskEventType(eventName string) string {
switch eventName {
case protocol.EventTeamTaskCreated:
return "created"
case protocol.EventTeamTaskClaimed:
return "claimed"
case protocol.EventTeamTaskAssigned:
return "assigned"
case protocol.EventTeamTaskDispatched:
return "dispatched"
case protocol.EventTeamTaskCompleted:
return "completed"
case protocol.EventTeamTaskFailed:
return "failed"
case protocol.EventTeamTaskCancelled:
return "cancelled"
case protocol.EventTeamTaskReviewed:
return "reviewed"
case protocol.EventTeamTaskApproved:
return "approved"
case protocol.EventTeamTaskRejected:
return "rejected"
case protocol.EventTeamTaskCommented:
return "commented"
case protocol.EventTeamTaskProgress:
return "progress"
case protocol.EventTeamTaskUpdated:
return "updated"
case protocol.EventTeamTaskStale:
return "stale"
default:
return ""
}
}