Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions cmd/gateway_consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,88 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
continue
}

// --- Bot mention: route to mentioned bot (Telegram doesn't deliver bot→bot messages) ---
// Same pattern as teammate, using "delegate" lane.
// Session is keyed by target_channel (the mentioned bot's channel), not origin_channel.
if msg.Channel == tools.ChannelSystem && strings.HasPrefix(msg.SenderID, "bot_mention:") {
origChannel := msg.Metadata["origin_channel"]
targetChannel := msg.Metadata["target_channel"]
if targetChannel == "" {
targetChannel = origChannel
}
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
targetChannelType := resolveChannelType(channelMgr, targetChannel)
targetAgent := msg.AgentID
if targetAgent == "" {
targetAgent = cfg.ResolveDefaultAgentID()
}
if origPeerKind == "" {
origPeerKind = string(sessions.PeerGroup)
}

if targetChannel == "" || msg.ChatID == "" {
slog.Warn("bot mention: missing target_channel or chat_id — DROPPED",
"sender", msg.SenderID,
"target", targetAgent,
"target_channel", targetChannel,
"chat_id", msg.ChatID,
)
continue
}

sessionKey := sessions.BuildScopedSessionKey(targetAgent, targetChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, targetAgent, targetChannel, msg.ChatID, origPeerKind)

slog.Info("bot mention → scheduler (delegate lane)",
"from", msg.SenderID,
"to", targetAgent,
"target_channel", targetChannel,
"session", sessionKey,
)

announceUserID := msg.UserID
if origPeerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
announceUserID = fmt.Sprintf("group:%s:%s", targetChannel, msg.ChatID)
}

outMeta := buildAnnounceOutMeta(origLocalKey)

outCh := sched.Schedule(ctx, scheduler.LaneDelegate, agent.RunRequest{
SessionKey: sessionKey,
Message: msg.Content,
Channel: targetChannel,
ChannelType: targetChannelType,
ChatID: msg.ChatID,
PeerKind: origPeerKind,
LocalKey: origLocalKey,
UserID: announceUserID,
RunID: fmt.Sprintf("bot-mention-%s-%s", msg.Metadata["from_agent"], msg.Metadata["to_agent"]),
Stream: false,
})

go func(replyChannel, chatID string, meta map[string]string) {
outcome := <-outCh
if outcome.Err != nil {
slog.Error("bot mention: agent run failed", "error", outcome.Err)
return
}
if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) {
slog.Debug("bot mention: suppressed silent/empty reply")
return
}
outMsg := bus.OutboundMessage{
Channel: replyChannel,
ChatID: chatID,
Content: outcome.Result.Content,
Metadata: meta,
}
appendMediaToOutbound(&outMsg, outcome.Result.Media)
msgBus.PublishOutbound(outMsg)
}(targetChannel, msg.ChatID, outMeta)
continue
}

// --- Command: /reset — clear session history ---
if msg.Metadata["command"] == "reset" {
agentID := msg.AgentID
Expand Down
9 changes: 9 additions & 0 deletions internal/channels/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ type WebhookChannel interface {
WebhookHandler() (path string, handler http.Handler)
}

// BotMentionChannel is implemented by channels (e.g. Telegram) that have a bot username.
// Used for internal bot-to-bot mention routing when one bot's message mentions another.
// Telegram does not deliver bot messages to other bots, so we inject InboundMessage internally.
type BotMentionChannel interface {
Channel
// BotUsername returns the platform bot username without @ (e.g. "v_pm_bot").
BotUsername() string
}

// ReactionChannel extends Channel with status reaction support.
// Channels that implement this interface can show emoji reactions on user messages
// to indicate agent status (thinking, tool call, done, error, stall).
Expand Down
109 changes: 109 additions & 0 deletions internal/channels/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
)

// botMentionRe matches Telegram @mentions (5-32 chars, [a-zA-Z0-9_]).
var botMentionRe = regexp.MustCompile(`@([a-zA-Z0-9_]{5,32})`)

// WebhookRoute holds a path and handler pair for mounting on the main gateway mux.
type WebhookRoute struct {
Path string
Expand Down Expand Up @@ -68,6 +71,10 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
"channel", msg.Channel, "error", err2)
}
}
} else {
// Bot-to-bot mention routing: Telegram does not deliver bot messages to other bots.
// When this bot's message mentions another bot, inject InboundMessage so the other bot wakes up.
m.dispatchBotMentions(ctx, msg, channel)
}

// Clean up temporary media files after successful (or failed) send.
Expand All @@ -83,6 +90,108 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
}
}

// dispatchBotMentions publishes InboundMessage for each @mentioned bot so they receive the message.
// Telegram does not deliver bot→bot messages; this internal routing wakes up the mentioned bots.
func (m *Manager) dispatchBotMentions(ctx context.Context, msg bus.OutboundMessage, fromChannel Channel) {
if msg.Content == "" {
return
}
bmc, ok := fromChannel.(BotMentionChannel)
if !ok {
return
}
fromBotUsername := bmc.BotUsername()
if fromBotUsername == "" {
return
}

// Build map: lowercase bot username → (channelName, agentID)
type targetInfo struct {
channelName string
agentID string
}
targets := make(map[string]targetInfo)
m.mu.RLock()
for name, ch := range m.channels {
if name == msg.Channel {
continue
}
if other, ok := ch.(BotMentionChannel); ok {
uname := other.BotUsername()
if uname == "" {
continue
}
key := strings.ToLower(uname)
agentID := ""
if ag, ok := ch.(interface{ AgentID() string }); ok {
agentID = ag.AgentID()
}
targets[key] = targetInfo{channelName: name, agentID: agentID}
}
}
m.mu.RUnlock()

// Find unique @mentions in content
matches := botMentionRe.FindAllStringSubmatch(msg.Content, -1)
seen := make(map[string]bool)
for _, submatch := range matches {
if len(submatch) < 2 {
continue
}
username := strings.ToLower(submatch[1])
if seen[username] {
continue
}
seen[username] = true
if username == strings.ToLower(fromBotUsername) {
continue
}
tgt, ok := targets[username]
if !ok {
continue
}
// Inject InboundMessage for the mentioned bot (same pattern as teammate).
// target_channel: the mentioned bot's channel — session is keyed by this (not origin).
// When user mentions techlead, session is agent+telegram_techlead+chatID.
meta := map[string]string{
"origin_channel": msg.Channel,
"target_channel": tgt.channelName,
"origin_peer_kind": "group",
"from_agent": fromBotUsername,
"to_agent": tgt.agentID,
}
if v := msg.Metadata["local_key"]; v != "" {
meta["origin_local_key"] = v
} else if msg.ChatID != "" {
meta["origin_local_key"] = msg.ChatID
}
for _, k := range []string{"message_thread_id", "group_id"} {
if v := msg.Metadata[k]; v != "" {
meta[k] = v
}
}

content := fmt.Sprintf("[Message from @%s]: %s", fromBotUsername, msg.Content)
m.bus.PublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("bot_mention:%s:%s", msg.Channel, fromBotUsername),
ChatID: msg.ChatID,
Content: content,
Media: nil,
UserID: fmt.Sprintf("bot:%s:%s", msg.Channel, fromBotUsername),
AgentID: tgt.agentID,
Metadata: meta,
})
slog.Info("bot mention routed",
"from_channel", msg.Channel,
"from_bot", fromBotUsername,
"to_channel", tgt.channelName,
"to_agent", tgt.agentID,
"chat_id", msg.ChatID,
)
}
}

// WebhookHandlers returns all webhook handlers from channels that implement WebhookChannel.
// Used to mount webhook routes on the main gateway mux.
func (m *Manager) WebhookHandlers() []WebhookRoute {
Expand Down
5 changes: 5 additions & 0 deletions internal/channels/telegram/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@ func (c *Channel) draftTransportEnabled() bool {
return *c.config.DraftTransport
}

// BotUsername implements channels.BotMentionChannel for internal bot-to-bot mention routing.
func (c *Channel) BotUsername() string {
return c.bot.Username()
}

// ReasoningStreamEnabled returns whether reasoning should be shown as a separate message.
// Default: true. Set "reasoning_stream": false to hide reasoning (only show answer).
func (c *Channel) ReasoningStreamEnabled() bool {
Expand Down
41 changes: 41 additions & 0 deletions internal/channels/telegram/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ func markdownToTelegramHTML(text string) string {
inlineCodes := extractInlineCodes(text)
text = inlineCodes.text

// Extract and protect @mentions (e.g. @vinaco_pm_bot) so the italic regex
// _([^_]+)_ does not treat underscores inside usernames as markdown italic.
mentions := extractMentions(text)
text = mentions.text

// Strip markdown headers
text = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")

Expand Down Expand Up @@ -93,6 +98,11 @@ func markdownToTelegramHTML(text string) string {
// List items
text = regexp.MustCompile(`(?m)^[-*]\s+`).ReplaceAllString(text, "• ")

// Restore @mentions (plain text, no escaping)
for i, mention := range mentions.mentions {
text = strings.ReplaceAll(text, fmt.Sprintf("\x00MN%d\x00", i), mention)
}

// Restore inline code
for i, code := range inlineCodes.codes {
escaped := escapeHTML(code)
Expand Down Expand Up @@ -162,6 +172,37 @@ func extractInlineCodes(text string) inlineCodeMatch {
return inlineCodeMatch{text: text, codes: codes}
}

// mentionMatch holds extracted @mentions for later restoration.
type mentionMatch struct {
text string // text with \x00MNn\x00 placeholders
mentions []string // original @username strings
}

// extractMentions finds Telegram @mentions (5-32 chars, [a-zA-Z0-9_]) and replaces
// them with placeholders so the italic regex _([^_]+)_ does not mangle underscores
// inside usernames (e.g. @v_pm_bot → @vpmbot).
// Telegram usernames: 5-32 chars, [a-zA-Z0-9_]. Go regexp (RE2) has no lookbehind,
// so we use a simple pattern; may match @domain in [email protected] (harmless).
var mentionRe = regexp.MustCompile(`@[a-zA-Z0-9_]{5,32}`)

func extractMentions(text string) mentionMatch {
matches := mentionRe.FindAllString(text, -1)
mentions := make([]string, 0)
seen := make(map[string]int)
for _, m := range matches {
if _, ok := seen[m]; !ok {
seen[m] = len(mentions)
mentions = append(mentions, m)
}
}

text = mentionRe.ReplaceAllStringFunc(text, func(s string) string {
return fmt.Sprintf("\x00MN%d\x00", seen[s])
})

return mentionMatch{text: text, mentions: mentions}
}

func escapeHTML(text string) string {
text = strings.ReplaceAll(text, "&", "&amp;")
text = strings.ReplaceAll(text, "<", "&lt;")
Expand Down
10 changes: 10 additions & 0 deletions internal/channels/telegram/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import (
"testing"
)

func TestMarkdownToTelegramHTML_MentionsPreserved(t *testing.T) {
// @mentions with underscores must not be mangled by the italic regex _([^_]+)_.
// Before fix: @v_pm_bot became @vinaco<pm>bot (underscores consumed).
input := "Bạn có thể hỏi @v_pm_bot nhé."
got := markdownToTelegramHTML(input)
if !strings.Contains(got, "@v_pm_bot") {
t.Errorf("markdownToTelegramHTML(%q): expected @v_pm_bot preserved, got %q", input, got)
}
}

func TestDisplayWidth(t *testing.T) {
tests := []struct {
input string
Expand Down