package cmd import ( "context" "encoding/json" "fmt" "log/slog" "strings" "sync" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/scheduler" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // consumeInboundMessages reads inbound messages from channels (Telegram, Discord, etc.) // and routes them through the scheduler/agent loop, then publishes the response back. // Also handles subagent announcements: routes them through the parent agent's session // (matching TS subagent-announce.ts pattern) so the agent can reformulate for the user. func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker, sessStore store.SessionStore, agentStore store.AgentStore, contactCollector *store.ContactCollector, postTurn tools.PostTurnProcessor) { slog.Info("inbound message consumer started") // Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts). // TTL=20min, max=5000 entries — prevents webhook retries / double-taps from duplicating agent runs. dedupe := bus.NewDedupeCache(20*time.Minute, 5000) // Per-session announce serialization: prevents concurrent announce runs from // reading stale session history. Without this, Announce #2 can start while // Announce #1 is still running, read history that doesn't include Announce #1's // messages (written only after agent loop completes), and generate responses // with wrong context (e.g. "waiting for Tiểu La" when Tiểu La already finished). var announceMu sync.Map // sessionKey → *sync.Mutex getAnnounceMu := func(key string) *sync.Mutex { v, _ := announceMu.LoadOrStore(key, &sync.Mutex{}) return v.(*sync.Mutex) } // Track running teammate tasks so they can be cancelled when the task is // cancelled/failed externally (e.g. lead cancels via team_tasks tool). var taskRunSessions sync.Map // taskID (string) → sessionKey (string) msgBus.Subscribe("consumer.team-task-cancel", func(event bus.Event) { if event.Name != protocol.EventTeamTaskCancelled && event.Name != protocol.EventTeamTaskFailed { return } payload, ok := event.Payload.(protocol.TeamTaskEventPayload) if !ok { return } if sessKey, ok := taskRunSessions.Load(payload.TaskID); ok { if cancelled := sched.CancelSession(sessKey.(string)); cancelled { slog.Info("team task cancelled: stopped running agent", "task_id", payload.TaskID, "session", sessKey) } taskRunSessions.Delete(payload.TaskID) } }) // Inbound debounce: merge rapid messages from the same sender before processing. // Matching TS createInboundDebouncer from src/auto-reply/inbound-debounce.ts. debounceMs := cfg.Gateway.InboundDebounceMs if debounceMs == 0 { debounceMs = 1000 // default: 1000ms } debouncer := bus.NewInboundDebouncer( time.Duration(debounceMs)*time.Millisecond, func(msg bus.InboundMessage) { processNormalMessage(ctx, msg, agents, cfg, sched, channelMgr, teamStore, quotaChecker, sessStore, agentStore, contactCollector, postTurn, msgBus) }, ) defer debouncer.Stop() slog.Info("inbound debounce configured", "debounce_ms", debounceMs) for { msg, ok := msgBus.ConsumeInbound(ctx) if !ok { slog.Info("inbound message consumer stopped") return } // --- Dedup: skip duplicate inbound messages (matching TS shouldSkipDuplicateInbound) --- if msgID := msg.Metadata["message_id"]; msgID != "" { dedupeKey := fmt.Sprintf("%s|%s|%s|%s", msg.Channel, msg.SenderID, msg.ChatID, msgID) if dedupe.IsDuplicate(dedupeKey) { slog.Debug("dedup: skipping duplicate message", "key", dedupeKey) continue } } if handleSubagentAnnounce(ctx, msg, cfg, sched, channelMgr, msgBus, getAnnounceMu) { continue } if handleTeammateMessage(ctx, msg, cfg, sched, channelMgr, teamStore, agentStore, msgBus, postTurn, &taskRunSessions) { continue } if handleResetCommand(msg, cfg, sessStore) { continue } if handleStopCommand(msg, cfg, sched, sessStore, msgBus) { continue } // --- Normal messages: route through debouncer --- debouncer.Push(msg) } } // autoSetFollowup sets followup reminders on in_progress tasks when the lead agent // replies on a real channel. Only sets followup if the task doesn't already have one // (respects LLM-initiated ask_user). Fire-and-forget, logs errors. func autoSetFollowup(ctx context.Context, teamStore store.TeamStore, agentStore store.AgentStore, agentKey, channel, chatID, content string) { if agentStore == nil { return } // agentKey may be a slug ("default") or a UUID string (from WS clients). var ag *store.AgentData var err error if id, parseErr := uuid.Parse(agentKey); parseErr == nil { ag, err = agentStore.GetByID(ctx, id) } else { ag, err = agentStore.GetByKey(ctx, agentKey) } if err != nil || ag == nil { return } team, err := teamStore.GetTeamForAgent(ctx, ag.ID) if err != nil || team == nil || team.LeadAgentID != ag.ID { return // only lead agent triggers auto-set } // Followup is a v2 feature. if !isConsumerTeamV2(team) { return } // Skip auto-followup when lead is waiting for teammates (not user). if hasMember, _ := teamStore.HasActiveMemberTasks(ctx, team.ID, ag.ID); hasMember { slog.Debug("auto-followup: skipping, active member tasks exist", "team_id", team.ID) return } interval, max := parseFollowupSettings(team) followupAt := time.Now().Add(interval) msg := truncateForReminder(content, 200) n, err := teamStore.SetFollowupForActiveTasks(ctx, team.ID, channel, chatID, followupAt, max, msg) if err != nil { slog.Warn("auto-set followup failed", "channel", channel, "chat_id", chatID, "error", err) } else if n > 0 { slog.Info("auto-set followup: set", "channel", channel, "chat_id", chatID, "count", n, "followup_at", followupAt) } } // isConsumerTeamV2 delegates to tools.IsTeamV2 for version checking. var isConsumerTeamV2 = tools.IsTeamV2 // parseFollowupSettings extracts followup interval and max reminders from team settings. func parseFollowupSettings(team *store.TeamData) (time.Duration, int) { const ( defaultIntervalMins = 30 defaultMax = 0 // unlimited ) if team.Settings == nil { return time.Duration(defaultIntervalMins) * time.Minute, defaultMax } var settings map[string]any if json.Unmarshal(team.Settings, &settings) != nil { return time.Duration(defaultIntervalMins) * time.Minute, defaultMax } interval := defaultIntervalMins if v, ok := settings["followup_interval_minutes"].(float64); ok && v > 0 { interval = int(v) } max := defaultMax if v, ok := settings["followup_max_reminders"].(float64); ok && v >= 0 { max = int(v) } return time.Duration(interval) * time.Minute, max } // truncateForReminder truncates content to maxLen chars, taking the last line as context. func truncateForReminder(content string, maxLen int) string { // Use last non-empty line as it's typically the most relevant. lines := strings.Split(strings.TrimSpace(content), "\n") msg := lines[len(lines)-1] if len(msg) > maxLen { msg = msg[:maxLen] + "..." } return msg } // appendMediaToOutbound converts agent MediaResults to outbound MediaAttachments // on the given OutboundMessage. Handles voice annotation when applicable. func appendMediaToOutbound(msg *bus.OutboundMessage, media []agent.MediaResult) { for _, mr := range media { msg.Media = append(msg.Media, bus.MediaAttachment{ URL: mr.Path, ContentType: mr.ContentType, }) if mr.AsVoice { if msg.Metadata == nil { msg.Metadata = make(map[string]string) } msg.Metadata["audio_as_voice"] = "true" } } }