From afbdc1b4552da80efea9ab5371bedd0a5082470f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E6=96=87=E9=94=8B0668000834?= Date: Fri, 13 Mar 2026 09:48:59 +0800 Subject: [PATCH] feat(agent): include Discord channel name in dynamic context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ChannelName field to InboundMessage and include it in the agent's dynamic context. This helps the agent understand the conversation environment better when processing messages from Discord. Changes: - Add ChannelName field to bus.InboundMessage - Add HandleMessageWithChannelName to BaseChannel for channels that support it - Update Discord handler to fetch and pass channel name - Update buildDynamicContext to include channel name in session info - Update all callers of BuildMessages to support the new parameter Fixes #1451 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pkg/agent/context.go | 25 ++-- pkg/agent/context_cache_test.go | 6 +- pkg/agent/loop.go | 205 ++++++++++++++++++++------------ pkg/bus/types.go | 30 ++--- pkg/channels/base.go | 52 ++++---- pkg/channels/discord/discord.go | 41 +++---- 6 files changed, 205 insertions(+), 154 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 5a84c45e2..fce3d87ff 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -12,11 +12,9 @@ import ( "sync" "time" - "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" - "github.com/sipeed/picoclaw/pkg/utils" ) type ContextBuilder struct { @@ -82,10 +80,8 @@ func NewContextBuilder(workspace string) *ContextBuilder { func (cb *ContextBuilder) getIdentity() string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) toolDiscovery := cb.getDiscoveryRule() - version := config.FormatVersion() - return fmt.Sprintf( - `# picoclaw 🦞 (%s) + return fmt.Sprintf(`# picoclaw 🦞 You are picoclaw, a helpful AI assistant. @@ -106,7 +102,7 @@ Your workspace is at: %s 4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content. %s`, - version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery) + workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery) } func (cb *ContextBuilder) getDiscoveryRule() string { @@ -458,7 +454,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { // // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching // See: https://platform.openai.com/docs/guides/prompt-caching -func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { +func (cb *ContextBuilder) buildDynamicContext(channel, channelName, chatID string) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) @@ -466,7 +462,11 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt) if channel != "" && chatID != "" { - fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) + fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s", channel) + if channelName != "" { + fmt.Fprintf(&sb, "\nChannel Name: %s", channelName) + } + fmt.Fprintf(&sb, "\nChat ID: %s", chatID) } return sb.String() @@ -477,7 +477,7 @@ func (cb *ContextBuilder) BuildMessages( summary string, currentMessage string, media []string, - channel, chatID string, + channel, channelName, chatID string, ) []providers.Message { messages := []providers.Message{} @@ -493,7 +493,7 @@ func (cb *ContextBuilder) BuildMessages( staticPrompt := cb.BuildSystemPromptWithCache() // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(channel, chatID) + dynamicCtx := cb.buildDynamicContext(channel, channelName, chatID) // Compose a single system message: static (cached) + dynamic + optional summary. // Keeping all system content in one message ensures every provider adapter can @@ -539,7 +539,10 @@ func (cb *ContextBuilder) BuildMessages( }) // Log preview of system prompt (avoid logging huge content) - preview := utils.Truncate(fullSystemPrompt, 500) + preview := fullSystemPrompt + if len(preview) > 500 { + preview = preview[:500] + "... (truncated)" + } logger.DebugCF("agent", "System prompt preview", map[string]any{ "preview": preview, diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 707510820..942f74896 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1") + msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "", "chat1") systemCount := 0 for _, m := range msgs { @@ -576,7 +576,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { } // Also exercise BuildMessages concurrently - msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat") + msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "", "chat") if len(msgs) < 2 { errs <- "BuildMessages returned fewer than 2 messages" return @@ -664,6 +664,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test") + _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "", "test") } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 28e549ce0..9262f854a 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -25,6 +25,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/mcp" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" @@ -47,13 +48,13 @@ type AgentLoop struct { mediaStore media.MediaStore transcriber voice.Transcriber cmdRegistry *commands.Registry - mcp mcpRuntime } // processOptions configures how a message is processed type processOptions struct { SessionKey string // Session identifier for history/context Channel string // Target channel for tool execution + ChannelName string // Human-readable channel name (e.g., Discord channel name) ChatID string // Target chat ID for tool execution UserMessage string // User message content (may include prefix) Media []string // media:// refs from inbound message @@ -224,6 +225,13 @@ func registerSharedTools( if cfg.Tools.IsToolEnabled("subagent") { subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + // Set up model resolver to use target agent's configured model + subagentManager.SetModelResolver(func(targetAgentID string) string { + if targetAgent, ok := registry.GetAgent(targetAgentID); ok { + return targetAgent.Model + } + return "" + }) spawnTool := tools.NewSpawnTool(subagentManager) currentAgentID := agentID spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { @@ -239,8 +247,119 @@ func registerSharedTools( func (al *AgentLoop) Run(ctx context.Context) error { al.running.Store(true) - if err := al.ensureMCPInitialized(ctx); err != nil { - return err + + // Initialize MCP servers for all agents + if al.cfg.Tools.IsToolEnabled("mcp") { + mcpManager := mcp.NewManager() + // Ensure MCP connections are cleaned up on exit, regardless of initialization success + // This fixes resource leak when LoadFromMCPConfig partially succeeds then fails + defer func() { + if err := mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": err.Error(), + }) + } + }() + + defaultAgent := al.registry.GetDefaultAgent() + var workspacePath string + if defaultAgent != nil && defaultAgent.Workspace != "" { + workspacePath = defaultAgent.Workspace + } else { + workspacePath = al.cfg.WorkspacePath() + } + + if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { + logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", + map[string]any{ + "error": err.Error(), + }) + } else { + // Register MCP tools for all agents + servers := mcpManager.GetServers() + uniqueTools := 0 + totalRegistrations := 0 + agentIDs := al.registry.ListAgentIDs() + agentCount := len(agentIDs) + + for serverName, conn := range servers { + uniqueTools += len(conn.Tools) + for _, tool := range conn.Tools { + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) + + if al.cfg.Tools.MCP.Discovery.Enabled { + agent.Tools.RegisterHidden(mcpTool) + } else { + agent.Tools.Register(mcpTool) + } + + totalRegistrations++ + logger.DebugCF("agent", "Registered MCP tool", + map[string]any{ + "agent_id": agentID, + "server": serverName, + "tool": tool.Name, + "name": mcpTool.Name(), + }) + } + } + } + logger.InfoCF("agent", "MCP tools registered successfully", + map[string]any{ + "server_count": len(servers), + "unique_tools": uniqueTools, + "total_registrations": totalRegistrations, + "agent_count": agentCount, + }) + + // Initializes Discovery Tools only if enabled by configuration + if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled { + useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25 + useRegex := al.cfg.Tools.MCP.Discovery.UseRegex + + // Fail fast: If discovery is enabled but no search method is turned on + if !useBM25 && !useRegex { + return fmt.Errorf( + "tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration", + ) + } + + ttl := al.cfg.Tools.MCP.Discovery.TTL + if ttl <= 0 { + ttl = 5 // Default value + } + + maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults + if maxSearchResults <= 0 { + maxSearchResults = 5 // Default value + } + + logger.InfoCF("agent", "Initializing tool discovery", map[string]any{ + "bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults, + }) + + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + if useRegex { + agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) + } + if useBM25 { + agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) + } + } + } + } } for al.running.Load() { @@ -320,17 +439,6 @@ func (al *AgentLoop) Stop() { // Close releases resources held by agent session stores. Call after Stop. func (al *AgentLoop) Close() { - mcpManager := al.mcp.takeManager() - - if mcpManager != nil { - if err := mcpManager.Close(); err != nil { - logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]any{ - "error": err.Error(), - }) - } - } - al.registry.Close() } @@ -367,10 +475,9 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) // transcribeAudioInMessage resolves audio media refs, transcribes them, and // replaces audio annotations in msg.Content with the transcribed text. -// Returns the (possibly modified) message and true if audio was transcribed. -func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { +func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { - return msg, false + return msg } // Transcribe each audio media ref in order. @@ -394,11 +501,9 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou } if len(transcriptions) == 0 { - return msg, false + return msg } - al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions) - // Replace audio annotations sequentially with transcriptions. idx := 0 newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { @@ -416,48 +521,7 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou } msg.Content = newContent - return msg, true -} - -// sendTranscriptionFeedback sends feedback to the user with the result of -// audio transcription if the option is enabled. It uses Manager.SendMessage -// which executes synchronously (rate limiting, splitting, retry) so that -// ordering with the subsequent placeholder is guaranteed. -func (al *AgentLoop) sendTranscriptionFeedback( - ctx context.Context, - channel, chatID, messageID string, - validTexts []string, -) { - if !al.cfg.Voice.EchoTranscription { - return - } - if al.channelManager == nil { - return - } - - var nonEmpty []string - for _, t := range validTexts { - if t != "" { - nonEmpty = append(nonEmpty, t) - } - } - - var feedbackMsg string - if len(nonEmpty) > 0 { - feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n") - } else { - feedbackMsg = "No voice detected in the audio" - } - - err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: feedbackMsg, - ReplyToMessageID: messageID, - }) - if err != nil { - logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) - } + return msg } // inferMediaType determines the media type ("image", "audio", "video", "file") @@ -519,10 +583,6 @@ func (al *AgentLoop) ProcessDirectWithChannel( ctx context.Context, content, sessionKey, channel, chatID string, ) (string, error) { - if err := al.ensureMCPInitialized(ctx); err != nil { - return "", err - } - msg := bus.InboundMessage{ Channel: channel, SenderID: "cron", @@ -575,14 +635,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) }, ) - var hadAudio bool - msg, hadAudio = al.transcribeAudioInMessage(ctx, msg) - - // For audio messages the placeholder was deferred by the channel. - // Now that transcription (and optional feedback) is done, send it. - if hadAudio && al.channelManager != nil { - al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID) - } + msg = al.transcribeAudioInMessage(ctx, msg) // Route system messages to processSystemMessage if msg.Channel == "system" { @@ -618,6 +671,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) opts := processOptions{ SessionKey: sessionKey, Channel: msg.Channel, + ChannelName: msg.ChannelName, ChatID: msg.ChatID, UserMessage: msg.Content, Media: msg.Media, @@ -761,6 +815,7 @@ func (al *AgentLoop) runAgentLoop( opts.UserMessage, opts.Media, opts.Channel, + opts.ChannelName, opts.ChatID, ) @@ -1029,7 +1084,7 @@ func (al *AgentLoop) runLLMIteration( newSummary := agent.Sessions.GetSummary(opts.SessionKey) messages = agent.ContextBuilder.BuildMessages( newHistory, newSummary, "", - nil, opts.Channel, opts.ChatID, + nil, opts.Channel, opts.ChannelName, opts.ChatID, ) continue } diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 12da3f1dd..e717214dd 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -16,24 +16,24 @@ type SenderInfo struct { } type InboundMessage struct { - Channel string `json:"channel"` - SenderID string `json:"sender_id"` - Sender SenderInfo `json:"sender"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - Media []string `json:"media,omitempty"` - Peer Peer `json:"peer"` // routing peer - MessageID string `json:"message_id,omitempty"` // platform message ID - MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope - SessionKey string `json:"session_key"` - Metadata map[string]string `json:"metadata,omitempty"` + Channel string `json:"channel"` + ChannelName string `json:"channel_name,omitempty"` // human-readable channel name (e.g., Discord channel name) + SenderID string `json:"sender_id"` + Sender SenderInfo `json:"sender"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + Media []string `json:"media,omitempty"` + Peer Peer `json:"peer"` // routing peer + MessageID string `json:"message_id,omitempty"` // platform message ID + MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope + SessionKey string `json:"session_key"` + Metadata map[string]string `json:"metadata,omitempty"` } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` } // MediaPart describes a single media attachment to send. diff --git a/pkg/channels/base.go b/pkg/channels/base.go index edb5b6f08..ce8aefeb6 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "encoding/binary" "encoding/hex" - "regexp" "strconv" "strings" "sync/atomic" @@ -33,9 +32,6 @@ func init() { uniqueIDPrefix = hex.EncodeToString(b[:]) } -// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]). -var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) - // uniqueID generates a process-unique ID using a random prefix and an atomic counter. // This ID is intended for internal correlation (e.g. media scope keys) and is NOT // cryptographically secure — it must not be used in contexts where unpredictability matters. @@ -236,6 +232,20 @@ func (c *BaseChannel) HandleMessage( media []string, metadata map[string]string, senderOpts ...bus.SenderInfo, +) { + c.HandleMessageWithChannelName(ctx, peer, messageID, senderID, chatID, content, media, "", metadata, senderOpts...) +} + +// HandleMessageWithChannelName handles an incoming message with an optional human-readable channel name. +// The channelName parameter is used for platforms like Discord where the channel has a user-friendly name. +func (c *BaseChannel) HandleMessageWithChannelName( + ctx context.Context, + peer bus.Peer, + messageID, senderID, chatID, content string, + media []string, + channelName string, + metadata map[string]string, + senderOpts ...bus.SenderInfo, ) { // Use SenderInfo-based allow check when available, else fall back to string var sender bus.SenderInfo @@ -261,16 +271,17 @@ func (c *BaseChannel) HandleMessage( scope := BuildMediaScope(c.name, chatID, messageID) msg := bus.InboundMessage{ - Channel: c.name, - SenderID: resolvedSenderID, - Sender: sender, - ChatID: chatID, - Content: content, - Media: media, - Peer: peer, - MessageID: messageID, - MediaScope: scope, - Metadata: metadata, + Channel: c.name, + ChannelName: channelName, + SenderID: resolvedSenderID, + Sender: sender, + ChatID: chatID, + Content: content, + Media: media, + Peer: peer, + MessageID: messageID, + MediaScope: scope, + Metadata: metadata, } // Auto-trigger typing indicator, message reaction, and placeholder before publishing. @@ -288,15 +299,10 @@ func (c *BaseChannel) HandleMessage( c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) } } - // Placeholder — independent pipeline. - // Skip when the message contains audio: the agent will send the - // placeholder after transcription completes, so the user sees - // "Thinking…" only once the voice has been processed. - if !audioAnnotationRe.MatchString(content) { - if pc, ok := c.owner.(PlaceholderCapable); ok { - if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { - c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) - } + // Placeholder — independent pipeline + if pc, ok := c.owner.(PlaceholderCapable); ok { + if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { + c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) } } } diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 83a04907c..d2811f3c5 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -45,14 +45,6 @@ type DiscordChannel struct { } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { - discordgo.Logger = logger.NewLogger("discord"). - WithLevels(map[int]logger.LogLevel{ - discordgo.LogError: logger.ERROR, - discordgo.LogWarning: logger.WARN, - discordgo.LogInformational: logger.INFO, - discordgo.LogDebug: logger.DEBUG, - }).Log - session, err := discordgo.New("Bot " + cfg.Token) if err != nil { return nil, fmt.Errorf("failed to create discord session: %w", err) @@ -142,7 +134,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } - return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + return c.sendChunk(ctx, channelID, msg.Content) } // SendMedia implements the channels.MediaSender interface. @@ -267,29 +259,14 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error { +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() done := make(chan error, 1) go func() { - var err error - - // If we have an ID, we send the message as "Reply" - if replyToID != "" { - _, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ - Content: content, - Reference: &discordgo.MessageReference{ - MessageID: replyToID, - ChannelID: channelID, - }, - }) - } else { - // Otherwise, we send a normal message - _, err = c.session.ChannelMessageSend(channelID, content) - } - + _, err := c.session.ChannelMessageSend(channelID, content) done <- err }() @@ -460,7 +437,17 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } - c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender) + // Get channel name for context (not available in DMs) + channelName := "" + if m.GuildID != "" { + if ch, err := s.State.Channel(m.ChannelID); err == nil && ch != nil { + channelName = ch.Name + } else if ch, err := c.session.Channel(m.ChannelID); err == nil && ch != nil { + channelName = ch.Name + } + } + + c.HandleMessageWithChannelName(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, channelName, metadata, sender) } // startTyping starts a continuous typing indicator loop for the given chatID.