diff --git a/cmd/gateway.go b/cmd/gateway.go index fcbcf4db..8528ee16 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -718,6 +718,35 @@ func runGateway() { if cs, ok := t.(tools.ChannelSenderAware); ok { cs.SetChannelSender(channelMgr.SendToChannel) } + if ce, ok := t.(tools.ChannelEditorAware); ok { + ce.SetChannelEditor(channelMgr.EditChannelMessage) + } + if tr, ok := t.(tools.TopicResolverAware); ok && pgStores != nil && pgStores.Contacts != nil { + contacts := pgStores.Contacts + tr.SetTopicResolver(func(ctx context.Context, channel, chatID, topicName string) (string, bool) { + list, err := contacts.ListContacts(ctx, store.ContactListOpts{ + ChannelInstance: channel, + ContactType: "topic", + Limit: 500, + }) + if err != nil { + return "", false + } + want := strings.ToLower(strings.TrimSpace(topicName)) + for _, c := range list { + if c.SenderID != chatID || c.ThreadID == nil || c.DisplayName == nil { + continue + } + if strings.ToLower(strings.TrimSpace(*c.DisplayName)) == want { + return *c.ThreadID, true + } + } + return "", false + }) + } + if tp, ok := t.(tools.TopicPosterAware); ok { + tp.SetTopicPoster(channelMgr.PostToTopic) + } if tc, ok := t.(tools.ChannelTenantCheckerAware); ok { tc.SetChannelTenantChecker(channelMgr.ChannelTenantID) } diff --git a/docs/telegram-trigger-words.md b/docs/telegram-trigger-words.md new file mode 100644 index 00000000..71a95e43 --- /dev/null +++ b/docs/telegram-trigger-words.md @@ -0,0 +1,69 @@ +# Telegram trigger words (wake an agent by name) + +In group chats a Telegram bot normally only reacts when it is `@mentioned`, +addressed with a `/command@bot`, or replied to. **Trigger words** let an agent +also wake up when a message names it by an alias — without an explicit mention. + +Trigger words are a property of the **agent**, not the channel, so they travel +with the agent across every channel it serves. They are declared in the agent's +`IDENTITY.md` context file. + +## How to configure + +Add a `Trigger words:` line to the agent's `IDENTITY.md` (comma-separated): + +```markdown +# IDENTITY.md — Who Am I? + +- **Name:** Rex +- **Trigger words:** Alice, Boss, Chief +- **Creature:** an AI assistant that keeps a team's chats in order +- **Purpose:** answer questions and run tasks for the team +- **Emoji:** 🤖 +``` + +The plain `Key: Value` form works too: + +```markdown +Name: Rex +Trigger words: Alice, Boss, Chief +Emoji: 🤖 +``` + +With the config above, in a group the bot wakes on messages like +`Alice, what's the status?` or `hey boss` — no `@mention` required. It keeps +ignoring unrelated chatter. + +Edits to `IDENTITY.md` take effect within ~60s (the channel caches the parsed +list per agent); no restart needed. + +## Matching rules + +- **Whole word, case-insensitive.** `Boss` matches `boss` and `BOSS`, and + matches even with surrounding punctuation (`boss!`, `hey, boss`). It does + **not** match substrings — `bosses` or `bossy` will not trigger. +- **Unicode-aware.** Matching tokenizes on Unicode letters/digits rather than an + ASCII `\b`, so aliases in any script (Cyrillic, CJK, accented Latin, …) match + as whole words. +- Both the message text and a media **caption** are checked. + +## Requirements + +- **Groups only.** DMs already respond to every message, so trigger words only + affect group (and channel) chats. +- **Disable the bot's Group Privacy in BotFather** (`/mybots` → Bot Settings → + Group Privacy → Turn off), then re-add the bot to the group — otherwise + Telegram never delivers plain (non-mention) group messages to the bot, and the + gate has nothing to evaluate. Making the bot a group admin has the same effect. +- The group's pairing/policy gate still applies: a trigger word is treated like + an `@mention`, so an unpaired group under `group_policy: pairing` still gets a + pairing prompt rather than an answer. + +## Implementation + +- `bootstrap.ParseTriggerWords` extracts the list from `IDENTITY.md`. +- `internal/channels/telegram/wake_words.go` normalizes the list and does the + whole-word, Unicode-aware match. +- The channel loads the agent's list via `GetAgentContextFiles` (tenant-scoped) + and caches it per agent; the group gate in `handlers.go` treats a match as a + mention. diff --git a/internal/agent/loop_pipeline_callbacks.go b/internal/agent/loop_pipeline_callbacks.go index 0c362b2f..1c6a5828 100644 --- a/internal/agent/loop_pipeline_callbacks.go +++ b/internal/agent/loop_pipeline_callbacks.go @@ -267,20 +267,31 @@ func (l *Loop) makeBuildFilteredTools(req *RunRequest) func(state *pipeline.RunS cacheValid = true } - mcpDefs := 0 - for _, td := range toolDefs { - if td.Function != nil && strings.HasPrefix(strings.TrimSpace(td.Function.Name), "mcp_") { - mcpDefs++ - } - } slog.Debug("mcp.filtered_tools", "tool_defs_count", len(toolDefs), - "mcp_defs_count", mcpDefs, + "mcp_defs_count", countMCPToolDefs(toolDefs), "iteration", state.Iteration) return toolDefs, nil } } +// countMCPToolDefs counts MCP-bridged tool definitions (name prefix "mcp_"). +// It skips entries with a nil Function — e.g. the native image_generation +// sentinel providers.ToolDefinition{Type: "image_generation"} — which would +// otherwise nil-deref (the v3.14.0 panic on every message for codex agents). +func countMCPToolDefs(toolDefs []providers.ToolDefinition) int { + n := 0 + for _, td := range toolDefs { + if td.Function == nil { + continue + } + if strings.HasPrefix(strings.TrimSpace(td.Function.Name), "mcp_") { + n++ + } + } + return n +} + // makeAuthorizeToolCall enforces a runtime fail-closed allowlist check before // every tool execution. AllowedTools is keyed by canonical registry names (built // by ThinkStage from FilterTools output). The model may emit prefixed names when diff --git a/internal/agent/loop_pipeline_callbacks_test.go b/internal/agent/loop_pipeline_callbacks_test.go index 18d374b3..889c0f69 100644 --- a/internal/agent/loop_pipeline_callbacks_test.go +++ b/internal/agent/loop_pipeline_callbacks_test.go @@ -102,3 +102,35 @@ func TestSupportsPromptCacheParams(t *testing.T) { t.Fatal("generic provider should not support prompt cache params") } } + +// A Function-nil tool definition (e.g. the native image_generation sentinel, +// providers.ToolDefinition{Type: "image_generation"}) must not panic the +// mcp-def counter. Regression for the v3.14.0 nil-pointer crash. +func TestCountMCPToolDefs_SkipsNilFunction(t *testing.T) { + defs := []providers.ToolDefinition{ + {Type: "image_generation"}, // Function == nil + {Function: &providers.ToolFunctionSchema{Name: "mcp_notion_search"}}, + {Function: &providers.ToolFunctionSchema{Name: " mcp_slack_post "}}, + {Function: &providers.ToolFunctionSchema{Name: "read_file"}}, + } + + if got := countMCPToolDefs(defs); got != 2 { + t.Errorf("countMCPToolDefs = %d, want 2", got) + } +} + +// The image_generation sentinel must carry a non-nil Function so the many +// pipeline/provider sites that read td.Function.Name (think_stage, codex_build, +// shouldRetryTaskMCP, history tool names, …) never nil-deref. Root-cause guard +// for the v3.14.0 crash — one landmine removed instead of guarding every site. +func TestImageGenToolDef_FunctionNonNil(t *testing.T) { + if imageGenToolDef.Type != "image_generation" { + t.Fatalf("sentinel Type = %q, want image_generation", imageGenToolDef.Type) + } + if imageGenToolDef.Function == nil { + t.Fatal("sentinel Function must be non-nil to avoid downstream nil-deref") + } + if imageGenToolDef.Function.Name != "image_generation" { + t.Errorf("sentinel Function.Name = %q, want image_generation", imageGenToolDef.Function.Name) + } +} diff --git a/internal/agent/loop_tool_filter.go b/internal/agent/loop_tool_filter.go index 2b5e049e..6acf78b7 100644 --- a/internal/agent/loop_tool_filter.go +++ b/internal/agent/loop_tool_filter.go @@ -8,10 +8,16 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/tools" ) -// imageGenToolDef is the native image_generation tool sentinel. Its Type-only form -// is passed through by the Codex/OpenAI request builder as a bare {"type":"image_generation"} -// object — no "function" wrapper, no parameters. -var imageGenToolDef = providers.ToolDefinition{Type: "image_generation"} +// imageGenToolDef is the native image_generation tool sentinel. The request +// builder keys off Type (Codex emits a bare {"type":"image_generation"} object, +// no function wrapper). Function is populated with a name-only schema so the many +// pipeline/provider sites that read td.Function.Name (think_stage allowlist, +// shouldRetryTaskMCP, history tool names, non-codex request builders) never +// nil-deref — the v3.14.0 crash was one such site. +var imageGenToolDef = providers.ToolDefinition{ + Type: "image_generation", + Function: &providers.ToolFunctionSchema{Name: "image_generation"}, +} func (l *Loop) toolVisibleForChannel(name, channelType string, telegramManagerPermissions []string) bool { if name == "telegram_manager" { diff --git a/internal/bootstrap/trigger_words.go b/internal/bootstrap/trigger_words.go new file mode 100644 index 00000000..a0ab73c6 --- /dev/null +++ b/internal/bootstrap/trigger_words.go @@ -0,0 +1,42 @@ +package bootstrap + +import "strings" + +// ParseTriggerWords extracts the agent's trigger-word aliases from IDENTITY.md. +// +// Trigger words let an agent respond in group chats when named by an alias +// (e.g. "Alice") without an explicit @mention. They are declared in IDENTITY.md +// using the existing Key: Value convention, comma-separated: +// +// Trigger words: Alice, Boss, Chief +// +// The key match is case-insensitive and tolerates the markdown bullet form +// (`- **Trigger words:** …`) and the singular "Trigger word". Returns nil when +// no trigger-word line is present. +func ParseTriggerWords(identityContent string) []string { + for line := range strings.SplitSeq(identityContent, "\n") { + line = strings.TrimSpace(line) + // Strip markdown bullet + bold markers: "- **Trigger words:** x" → "Trigger words:** x". + line = strings.TrimPrefix(line, "-") + line = strings.TrimSpace(line) + line = strings.ReplaceAll(line, "*", "") + + idx := strings.Index(line, ":") + if idx <= 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(line[:idx])) + if key != "trigger words" && key != "trigger word" { + continue + } + + var words []string + for w := range strings.SplitSeq(line[idx+1:], ",") { + if w = strings.TrimSpace(w); w != "" { + words = append(words, w) + } + } + return words + } + return nil +} diff --git a/internal/bootstrap/trigger_words_test.go b/internal/bootstrap/trigger_words_test.go new file mode 100644 index 00000000..65a8f14e --- /dev/null +++ b/internal/bootstrap/trigger_words_test.go @@ -0,0 +1,53 @@ +package bootstrap + +import ( + "reflect" + "testing" +) + +func TestParseTriggerWords(t *testing.T) { + cases := []struct { + name string + content string + want []string + }{ + { + name: "plain key value", + content: "Name: Rex\nTrigger words: Alice, Boss, Chief\nEmoji: 🤖", + want: []string{"Alice", "Boss", "Chief"}, + }, + { + name: "markdown bullet form", + content: "- **Name:** Rex\n- **Trigger words:** Alice, Boss\n", + want: []string{"Alice", "Boss"}, + }, + { + name: "case-insensitive key and singular", + content: "trigger word: Alice", + want: []string{"Alice"}, + }, + { + name: "drops blanks and trims", + content: "Trigger words: Alice ,, Chief , ", + want: []string{"Alice", "Chief"}, + }, + { + name: "missing key", + content: "Name: Rex\nEmoji: 🤖", + want: nil, + }, + { + name: "empty content", + content: "", + want: nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ParseTriggerWords(tc.content) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("ParseTriggerWords() = %#v, want %#v", got, tc.want) + } + }) + } +} diff --git a/internal/channels/dispatch.go b/internal/channels/dispatch.go index 6febd4a3..42a9f006 100644 --- a/internal/channels/dispatch.go +++ b/internal/channels/dispatch.go @@ -195,6 +195,52 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten return channel.Send(ctx, msg) } +// MessageEditor is optionally implemented by channels that support editing an +// existing message in place (e.g. Telegram admin editing a channel post). +type MessageEditor interface { + EditMessage(ctx context.Context, chatID string, messageID int, content string) error +} + +// EditChannelMessage edits an existing message in a channel by name. Returns an +// error if the channel is unknown or its type does not support editing. +func (m *Manager) EditChannelMessage(ctx context.Context, channelName, chatID string, messageID int, content string) error { + m.mu.RLock() + channel, exists := m.channels[channelName] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("channel %s not found", channelName) + } + editor, ok := channel.(MessageEditor) + if !ok { + return fmt.Errorf("channel %s (%s) does not support editing messages", channelName, channel.Type()) + } + return editor.EditMessage(ctx, chatID, messageID, content) +} + +// TopicMessagePoster is optionally implemented by channels that can post a +// message into a forum topic and return the sent message's id. +type TopicMessagePoster interface { + PostToTopic(ctx context.Context, chatID string, threadID int, content string) (int, error) +} + +// PostToTopic posts a message into a forum topic of a channel and returns the +// sent message id. Errors if the channel is unknown or does not support it. +func (m *Manager) PostToTopic(ctx context.Context, channelName, chatID string, threadID int, content string) (int, error) { + m.mu.RLock() + channel, exists := m.channels[channelName] + m.mu.RUnlock() + + if !exists { + return 0, fmt.Errorf("channel %s not found", channelName) + } + poster, ok := channel.(TopicMessagePoster) + if !ok { + return 0, fmt.Errorf("channel %s (%s) does not support topic posting", channelName, channel.Type()) + } + return poster.PostToTopic(ctx, chatID, threadID, content) +} + // SendMediaToChannel delivers a message with media attachments to a specific channel by name. // media must be non-empty; use SendToChannel for text-only messages. // Returns ErrMediaUnsupported if the channel type does not support media. diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index 9ca14968..d739f8ea 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/url" + "runtime/debug" "strings" "sync" "time" @@ -40,6 +41,9 @@ type Channel struct { reactions sync.Map // localKey string → *StatusReactionController threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing) mentionMode string // "strict" (default) or "yield" + triggerWords map[string]struct{} // cached, normalized agent trigger-words from IDENTITY.md; whole-word, case-insensitive + triggerWordsAt time.Time // when triggerWords was last refreshed + triggerMu sync.Mutex // guards triggerWords/triggerWordsAt botDisplayName string // bot's first_name from GetMe (e.g. "ViệtBot"); captured once at Start pollCtx context.Context // long-polling context (cancelled by pollCancel); promoted from Start-local so background helpers (e.g. albumAggregator) can derive from it pollCancel context.CancelFunc // cancels the long polling context @@ -238,6 +242,7 @@ func (c *Channel) Start(ctx context.Context) error { AllowedUpdates: []string{ "message", "edited_message", + "channel_post", "callback_query", "my_chat_member", }, @@ -297,13 +302,21 @@ func (c *Channel) Start(ctx context.Context) error { slog.Info("telegram updates channel closed") return } - if update.Message != nil { + if update.Message != nil || update.ChannelPost != nil { select { case c.handlerSem <- struct{}{}: c.handlerWg.Add(1) go func(u telego.Update) { defer c.handlerWg.Done() defer func() { <-c.handlerSem }() + // Never let a single malformed update crash the whole + // gateway — recover and log instead of propagating. + defer func() { + if r := recover(); r != nil { + slog.Error("telegram: handleMessage panic recovered", + "channel", c.Name(), "panic", r, "stack", string(debug.Stack())) + } + }() c.handleMessage(pollCtx, u) }(update) case <-pollCtx.Done(): diff --git a/internal/channels/telegram/context.go b/internal/channels/telegram/context.go index 1b32ce7a..06ce7027 100644 --- a/internal/channels/telegram/context.go +++ b/internal/channels/telegram/context.go @@ -30,6 +30,7 @@ type ReplyInfo struct { Sender string // sender name Body string // quoted message text IsBotReply bool // true if replying to bot's own message + MessageID int // id of the replied-to message (edit target) } // LocationInfo contains geographic coordinates. @@ -66,10 +67,11 @@ func enrichContentWithContext(content string, msgCtx *MessageContext) string { result.WriteString(content) - // Append reply context + // Append reply context. Include the replied-to message id so the agent can + // target it with message(action=edit) — e.g. flip a status marker in place. if msgCtx.ReplyInfo != nil && msgCtx.ReplyInfo.Body != "" { - result.WriteString(fmt.Sprintf("\n\n[Replying to %s]\n%s\n[/Replying]", - msgCtx.ReplyInfo.Sender, msgCtx.ReplyInfo.Body)) + result.WriteString(fmt.Sprintf("\n\n[Replying to %s | reply_to_message_id=%d]\n%s\n[/Replying]", + msgCtx.ReplyInfo.Sender, msgCtx.ReplyInfo.MessageID, msgCtx.ReplyInfo.Body)) } // Append location @@ -123,7 +125,7 @@ func extractReplyInfo(msg *telego.Message, botUsername string) *ReplyInfo { return nil } - info := &ReplyInfo{} + info := &ReplyInfo{MessageID: reply.MessageID} // Determine sender name if reply.From != nil { diff --git a/internal/channels/telegram/edit_caption_test.go b/internal/channels/telegram/edit_caption_test.go new file mode 100644 index 00000000..f1f217a4 --- /dev/null +++ b/internal/channels/telegram/edit_caption_test.go @@ -0,0 +1,14 @@ +package telegram + +import "testing" + +// EditMessage must recognize the "no text to edit" error to fall back to caption editing. +func TestNoTextToEditRegex(t *testing.T) { + yes := `telego: editMessageText: api: 400 "Bad Request: there is no text in the message to edit"` + if !noTextToEditRe.MatchString(yes) { + t.Error("expected match for 'no text in the message to edit'") + } + if noTextToEditRe.MatchString("Bad Request: message can't be edited") { + t.Error("unrelated edit error must not match") + } +} diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index de7aa945..dcb71eae 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -23,10 +23,16 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { // Inject tenant scope so store queries filter by the correct tenant_id. ctx = store.WithTenantID(ctx, c.TenantID()) + // Channel posts arrive as update.ChannelPost (same *telego.Message type) and + // are routed through the same group-style gate below. message := update.Message + if message == nil { + message = update.ChannelPost + } if message == nil { return } + user, isChannel := resolveMessageSender(message) // Proactive migration detection: group upgraded to supergroup. // Must run BEFORE isServiceMessage() — migration messages have no text/media. @@ -37,6 +43,24 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { return } + // Learn forum topics (name → thread id) so the agent can later post to a + // topic by name. forum_topic_created is a service message (no content), so + // capture it BEFORE the isServiceMessage skip below. + if message.ForumTopicCreated != nil && message.MessageThreadID != 0 { + if cc := c.ContactCollector(); cc != nil { + topicCtx := store.WithTenantID(ctx, c.TenantID()) + cc.EnsureContact(topicCtx, c.Type(), c.Name(), + fmt.Sprintf("%d", message.Chat.ID), "", + message.ForumTopicCreated.Name, "", + "group", "topic", + fmt.Sprintf("%d", message.MessageThreadID), "topic") + } + slog.Info("telegram: forum topic learned", + "chat_id", message.Chat.ID, "thread_id", message.MessageThreadID, + "name", message.ForumTopicCreated.Name) + return + } + // Skip service messages (member added/removed, title changed, etc.). // These have no text/caption and no meaningful media — processing them // pollutes mention gate and history with "[empty message]" entries. @@ -49,15 +73,16 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { return } - user := message.From if user == nil { + // Non-channel message with no sender (e.g. some service posts): drop. return } userID := fmt.Sprintf("%d", user.ID) senderID := userID - isGroup := message.Chat.Type == "group" || message.Chat.Type == "supergroup" + // Channels reuse the group path (mention/trigger gate, pairing, history). + isGroup := message.Chat.Type == "group" || message.Chat.Type == "supergroup" || isChannel slog.Debug("telegram message received", "chat_type", message.Chat.Type, @@ -296,6 +321,12 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { wasMentioned = true } + // Trigger words: naming the bot by one of its agent's IDENTITY.md aliases + // (e.g. "Alice") wakes it in groups without an explicit @mention. + if !wasMentioned && c.matchesTriggerWords(ctx, message) { + wasMentioned = true + } + // Yield mode: skip only if another bot/user is explicitly mentioned (not us). // If nobody is mentioned → respond. If we are mentioned → respond. if mentionMode == "yield" && !wasMentioned { @@ -351,7 +382,9 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { // Collect forum topic as a distinct delivery target (including General). if isForum && messageThreadID > 0 { threadStr := fmt.Sprintf("%d", messageThreadID) - cc.EnsureContact(ctx, c.Type(), c.Name(), chatIDStr, "", message.Chat.Title, "", "group", "topic", threadStr, "topic") + // Empty display_name: don't overwrite the real topic name learned + // from forum_topic_created with the group title (COALESCE keeps it). + cc.EnsureContact(ctx, c.Type(), c.Name(), chatIDStr, "", "", "", "group", "topic", threadStr, "topic") } } @@ -436,7 +469,12 @@ func (c *Channel) processResolvedMessage(ctx context.Context, rctx resolvedMessa return } rep := members[0] - user := rep.From + // Channel posts have no From — synthesize a sender from the channel (same as + // handleMessage) so the user.* metadata below never nil-derefs. + user, _ := resolveMessageSender(rep) + if user == nil { + return + } content := rctx.content // --- Media download (only when bot will process the message) --- @@ -468,7 +506,7 @@ func (c *Channel) processResolvedMessage(ctx context.Context, rctx resolvedMessa var mediaFiles []bus.MediaFile if len(mediaList) > 0 { - var extraContent string + var extraContent strings.Builder for i := range mediaList { m := &mediaList[i] switch m.Type { @@ -488,7 +526,7 @@ func (c *Channel) processResolvedMessage(ctx context.Context, rctx resolvedMessa if err != nil { slog.Warn("document extraction failed", "file", m.FileName, "error", err) } else if docContent != "" { - extraContent += "\n\n" + docContent + extraContent.WriteString("\n\n" + docContent) } } case "video", "animation": @@ -515,8 +553,8 @@ func (c *Channel) processResolvedMessage(ctx context.Context, rctx resolvedMessa content = fullTags } } - if extraContent != "" { - content += extraContent + if extraContent.String() != "" { + content += extraContent.String() } } @@ -692,7 +730,7 @@ func (c *Channel) processResolvedMessage(ctx context.Context, rctx resolvedMessa // Collect forum topic as a distinct delivery target (including General). if rctx.isForum && rctx.messageThreadID > 0 { threadStr := fmt.Sprintf("%d", rctx.messageThreadID) - cc.EnsureContact(ctx, c.Type(), c.Name(), rctx.chatIDStr, "", rep.Chat.Title, "", "group", "topic", threadStr, "topic") + cc.EnsureContact(ctx, c.Type(), c.Name(), rctx.chatIDStr, "", "", "", "group", "topic", threadStr, "topic") } } } diff --git a/internal/channels/telegram/handlers_utils.go b/internal/channels/telegram/handlers_utils.go index bffce9f5..f2478132 100644 --- a/internal/channels/telegram/handlers_utils.go +++ b/internal/channels/telegram/handlers_utils.go @@ -41,6 +41,23 @@ func stripBotMention(text, botUsername string) string { return strings.TrimSpace(regexp.MustCompile(pattern).ReplaceAllString(text, "$1")) } +// resolveMessageSender returns the effective sender for a message and whether it +// is a channel post. Channel posts are authored by the channel and carry no From, +// so a synthetic sender is derived from the channel itself, letting channel posts +// flow through the same group-style gate and routing as group messages. +func resolveMessageSender(message *telego.Message) (*telego.User, bool) { + isChannel := message.Chat.Type == "channel" + user := message.From + if user == nil && isChannel { + user = &telego.User{ + ID: message.Chat.ID, + FirstName: message.Chat.Title, + Username: message.Chat.Username, + } + } + return user, isChannel +} + // detectMention checks if a Telegram message mentions the bot. // Checks both msg.Text/Entities (text messages) and msg.Caption/CaptionEntities (photo/media messages). func (c *Channel) detectMention(msg *telego.Message, botUsername string) bool { diff --git a/internal/channels/telegram/send.go b/internal/channels/telegram/send.go index 42bd43fb..cde94be7 100644 --- a/internal/channels/telegram/send.go +++ b/internal/channels/telegram/send.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "regexp" + "strconv" "strings" "time" @@ -23,6 +24,7 @@ import ( var ( parseErrRe = regexp.MustCompile(`(?i)can't parse entities|parse entities|find end of the entity`) messageNotModifiedRe = regexp.MustCompile(`(?i)message is not modified`) + noTextToEditRe = regexp.MustCompile(`(?i)no text in the message to edit`) threadNotFoundRe = regexp.MustCompile(`(?i)message thread not found`) messageTooLongRe = regexp.MustCompile(`(?i)message is too long|entities too long`) htmlTagRe = regexp.MustCompile(`<[^>]*>`) @@ -791,6 +793,71 @@ func (c *Channel) sendDocument(ctx context.Context, chatID telego.ChatID, filePa return err } +// EditMessage edits an existing message's text by chat id string, applying the +// same markdown→HTML rendering as outbound sends. Implements channels.MessageEditor +// so the agent's `message` tool (action=edit) can flip status markers in place. +// The bot must be an admin with edit rights in the target chat/channel. +func (c *Channel) EditMessage(ctx context.Context, chatID string, messageID int, content string) error { + id, err := strconv.ParseInt(chatID, 10, 64) + if err != nil { + return fmt.Errorf("invalid telegram chat id %q: %w", chatID, err) + } + html := markdownToTelegramHTML(content) + err = c.editMessage(ctx, id, messageID, html) + // Media messages (photo/document with a caption, e.g. a receipt image with a + // status line) have no text — editMessageText returns "there is no text in the + // message to edit". Fall back to editing the caption instead. + if err != nil && noTextToEditRe.MatchString(err.Error()) { + return c.editMessageCaption(ctx, id, messageID, html) + } + return err +} + +// editMessageCaption edits the caption of a media message (photo/document). +func (c *Channel) editMessageCaption(ctx context.Context, chatID int64, messageID int, htmlCaption string) error { + editCap := &telego.EditMessageCaptionParams{ + ChatID: tu.ID(chatID), + MessageID: messageID, + Caption: htmlCaption, + ParseMode: telego.ModeHTML, + } + return c.retrySend(ctx, "editMessageCaption", nil, func(ctx context.Context) error { + _, err := c.bot.EditMessageCaption(ctx, editCap) + if err != nil && messageNotModifiedRe.MatchString(err.Error()) { + return nil + } + return err + }) +} + +// PostToTopic posts a message into a forum topic (thread) and returns the sent +// message id, so the agent can remember it and edit that exact post later. +// Implements channels.TopicMessagePoster. Applies the same markdown→HTML render +// as regular sends. threadID <= 0 posts to the group's General topic. +func (c *Channel) PostToTopic(ctx context.Context, chatID string, threadID int, content string) (int, error) { + id, err := strconv.ParseInt(chatID, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid telegram chat id %q: %w", chatID, err) + } + msg := tu.Message(tu.ID(id), markdownToTelegramHTML(content)) + msg.ParseMode = telego.ModeHTML + if threadID > 0 { + msg.MessageThreadID = threadID + } + var sentID int + err = c.retrySend(ctx, "sendMessage", nil, func(ctx context.Context) error { + sent, e := c.bot.SendMessage(ctx, msg) + if e != nil { + return e + } + if sent != nil { + sentID = sent.MessageID + } + return nil + }) + return sentID, err +} + // editMessage edits an existing message's text. // Uses retrySend since edits are idempotent and may fail on transient network issues. func (c *Channel) editMessage(ctx context.Context, chatID int64, messageID int, htmlText string) error { diff --git a/internal/channels/telegram/wake_words.go b/internal/channels/telegram/wake_words.go new file mode 100644 index 00000000..33dc28f8 --- /dev/null +++ b/internal/channels/telegram/wake_words.go @@ -0,0 +1,125 @@ +package telegram + +import ( + "context" + "log/slog" + "strings" + "time" + "unicode" + + "github.com/mymmrac/telego" + + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// triggerWordsTTL bounds how long parsed IDENTITY.md trigger words are cached +// before a re-read, so edits take effect without a restart. +const triggerWordsTTL = 60 * time.Second + +// matchesTriggerWords reports whether the message names the bot by one of its +// agent's IDENTITY.md trigger-word aliases, in either the text or the media +// caption. Group-only gate; fails open (no match) when none are configured or +// the lookup errors — it never wakes spuriously and never crashes the handler. +func (c *Channel) matchesTriggerWords(ctx context.Context, msg *telego.Message) bool { + if msg == nil { + return false + } + set := c.agentTriggerWords(ctx) + if len(set) == 0 { + return false + } + return textHasWakeWord(msg.Text, set) || textHasWakeWord(msg.Caption, set) +} + +// agentTriggerWords returns the normalized trigger-word set for this channel's +// agent, parsed from its IDENTITY.md context file and cached for triggerWordsTTL. +// One channel instance serves exactly one agent, so a single cached set suffices. +func (c *Channel) agentTriggerWords(ctx context.Context) map[string]struct{} { + c.triggerMu.Lock() + defer c.triggerMu.Unlock() + + if !c.triggerWordsAt.IsZero() && time.Since(c.triggerWordsAt) < triggerWordsTTL { + return c.triggerWords + } + // Refresh timestamp up front: on error we keep the stale set but avoid + // hammering the store on every message. + c.triggerWordsAt = time.Now() + + if c.agentStore == nil { + c.triggerWords = nil + return nil + } + // The agent + context-file stores are tenant-scoped: without tenant in ctx + // they error with "tenant_id required" and trigger words would silently never + // load. Inject scope up front for both lookups. + ctx = store.WithTenantID(ctx, c.TenantID()) + agentID, err := c.resolveAgentUUID(ctx) + if err != nil { + slog.Debug("telegram: trigger words — resolve agent failed", "channel", c.Name(), "error", err) + return c.triggerWords + } + files, err := c.agentStore.GetAgentContextFiles(ctx, agentID) + if err != nil { + slog.Debug("telegram: trigger words — load context files failed", "channel", c.Name(), "error", err) + return c.triggerWords + } + for _, f := range files { + if f.FileName == bootstrap.IdentityFile { + c.triggerWords = normalizeWakeWords(bootstrap.ParseTriggerWords(f.Content)) + return c.triggerWords + } + } + c.triggerWords = nil + return nil +} + +// normalizeWakeWords lowercases and trims the configured wake-words into a +// lookup set, dropping blanks. Matching is whole-word and case-insensitive. +func normalizeWakeWords(words []string) map[string]struct{} { + if len(words) == 0 { + return nil + } + set := make(map[string]struct{}, len(words)) + for _, w := range words { + w = strings.ToLower(strings.TrimSpace(w)) + if w == "" { + continue + } + set[w] = struct{}{} + } + if len(set) == 0 { + return nil + } + return set +} + +// textHasWakeWord reports whether text contains any wake-word as a whole word. +// Tokenizes on runs of Unicode letters/digits (Unicode-safe, unlike ASCII \b) +// so "café" matches "café here" but not "cafés"; works for any script. +func textHasWakeWord(text string, set map[string]struct{}) bool { + if text == "" || len(set) == 0 { + return false + } + start := -1 + for i, r := range text { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + if start < 0 { + start = i + } + continue + } + if start >= 0 { + if _, ok := set[strings.ToLower(text[start:i])]; ok { + return true + } + start = -1 + } + } + if start >= 0 { + if _, ok := set[strings.ToLower(text[start:])]; ok { + return true + } + } + return false +} diff --git a/internal/channels/telegram/wake_words_test.go b/internal/channels/telegram/wake_words_test.go new file mode 100644 index 00000000..610a9b4c --- /dev/null +++ b/internal/channels/telegram/wake_words_test.go @@ -0,0 +1,176 @@ +package telegram + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/mymmrac/telego" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// triggerFakeStore implements just the two AgentStore methods agentTriggerWords +// needs; the embedded interface makes any other call panic (none expected). +// GetAgentContextFiles mirrors the real store: it requires tenant scope in ctx. +type triggerFakeStore struct { + store.AgentStore + agentID uuid.UUID + files []store.AgentContextFileData +} + +func (f *triggerFakeStore) GetByKey(ctx context.Context, key string) (*store.AgentData, error) { + return &store.AgentData{BaseModel: store.BaseModel{ID: f.agentID}}, nil +} + +func (f *triggerFakeStore) GetAgentContextFiles(ctx context.Context, id uuid.UUID) ([]store.AgentContextFileData, error) { + if store.TenantIDFromContext(ctx) == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + return f.files, nil +} + +// agentTriggerWords must propagate tenant scope to GetAgentContextFiles, or the +// scoped store errors and trigger words silently never load (groups never fire). +func TestAgentTriggerWords_ScopesTenantAndLoadsIdentity(t *testing.T) { + fake := &triggerFakeStore{ + agentID: uuid.New(), + files: []store.AgentContextFileData{ + {FileName: "SOUL.md", Content: "irrelevant"}, + {FileName: "IDENTITY.md", Content: "Name: Rex\nTrigger words: Alice, Boss"}, + }, + } + c := &Channel{BaseChannel: channels.NewBaseChannel(channels.TypeTelegram, nil, nil), agentStore: fake} + c.SetAgentID("my-agent") + c.SetTenantID(uuid.New()) + + set := c.agentTriggerWords(context.Background()) + if len(set) != 2 { + t.Fatalf("expected 2 trigger words loaded, got %d: %v", len(set), set) + } + if !c.matchesTriggerWords(context.Background(), &telego.Message{Text: "hey Alice"}) { + t.Error("expected trigger match after loading from IDENTITY.md") + } +} + +// newTriggerChannel returns a Channel with a pre-warmed trigger-word cache so +// matchesTriggerWords can be tested without a store. +func newTriggerChannel(words ...string) *Channel { + return &Channel{ + triggerWords: normalizeWakeWords(words), + triggerWordsAt: time.Now(), + } +} + +func TestChannelMatchesTriggerWords(t *testing.T) { + ctx := context.Background() + c := newTriggerChannel("Alice", "Boss") + + if !c.matchesTriggerWords(ctx, &telego.Message{Text: "hey, Alice"}) { + t.Error("expected match in message text") + } + if !c.matchesTriggerWords(ctx, &telego.Message{Caption: "look, boss!"}) { + t.Error("expected match in media caption") + } + if c.matchesTriggerWords(ctx, &telego.Message{Text: "bossy talk"}) { + t.Error("substring must not match") + } + if c.matchesTriggerWords(ctx, &telego.Message{Text: "hello there"}) { + t.Error("unrelated text must not match") + } + + // nil agentStore + expired cache → fails open, never matches, never panics. + empty := &Channel{} + if empty.matchesTriggerWords(ctx, &telego.Message{Text: "Alice"}) { + t.Error("channel with no trigger words must never match") + } +} + +func TestNormalizeWakeWords(t *testing.T) { + set := normalizeWakeWords([]string{"Alice", " Boss ", "CHIEF", "", " "}) + if len(set) != 3 { + t.Fatalf("expected 3 normalized words, got %d: %v", len(set), set) + } + for _, w := range []string{"alice", "boss", "chief"} { + if _, ok := set[w]; !ok { + t.Errorf("expected normalized set to contain %q", w) + } + } +} + +func TestTextHasWakeWord(t *testing.T) { + set := normalizeWakeWords([]string{"Alice", "Boss", "Chief"}) + + cases := []struct { + name string + text string + want bool + }{ + {"exact", "Alice", true}, + {"uppercase", "ALICE come here", true}, + {"trailing punctuation", "alice,", true}, + {"leading phrase and bang", "hey, chief!", true}, + {"mid sentence", "well boss you", true}, + {"substring not whole word", "chieftain nearby", false}, + {"plural not whole word", "bosses here", false}, + {"empty text", "", false}, + {"unrelated", "hello everyone", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := textHasWakeWord(tc.text, set); got != tc.want { + t.Errorf("textHasWakeWord(%q) = %v, want %v", tc.text, got, tc.want) + } + }) + } +} + +// Non-ASCII letters must be treated as word characters — the whole reason the +// matcher tokenizes on unicode.IsLetter instead of using an ASCII \b regex. +func TestTextHasWakeWord_UnicodeWordBoundary(t *testing.T) { + set := normalizeWakeWords([]string{"café"}) + if !textHasWakeWord("meet at café tonight", set) { + t.Error("whole non-ASCII word must match") + } + if textHasWakeWord("two cafés opened", set) { + t.Error("plural (trailing non-ASCII letter) must not match — proves the tokenizer is Unicode-aware, unlike ASCII \\b") + } +} + +func TestTextHasWakeWord_EmptySet(t *testing.T) { + if textHasWakeWord("Alice here", nil) { + t.Error("empty set must never match") + } + if textHasWakeWord("Alice here", map[string]struct{}{}) { + t.Error("empty set must never match") + } +} + +func TestResolveMessageSender(t *testing.T) { + // Channel post: From is nil → synthesize sender from the channel. + post := &telego.Message{Chat: telego.Chat{ID: -100123, Type: "channel", Title: "My Channel", Username: "mychan"}} + u, isCh := resolveMessageSender(post) + if !isCh { + t.Error("channel post must be detected as channel") + } + if u == nil || u.ID != -100123 || u.FirstName != "My Channel" || u.Username != "mychan" { + t.Errorf("synthetic sender = %+v, want channel-derived", u) + } + + // Group message with a real sender: returned unchanged, not a channel. + from := &telego.User{ID: 42, FirstName: "Ann"} + grp := &telego.Message{Chat: telego.Chat{ID: -55, Type: "supergroup"}, From: from} + u2, isCh2 := resolveMessageSender(grp) + if isCh2 || u2 != from { + t.Errorf("group sender = %+v isChannel=%v, want original sender, not channel", u2, isCh2) + } + + // DM with no From and not a channel: nil sender (dropped by caller). + dm := &telego.Message{Chat: telego.Chat{ID: 7, Type: "private"}} + if u3, isCh3 := resolveMessageSender(dm); u3 != nil || isCh3 { + t.Errorf("private no-From = %+v isChannel=%v, want nil/false", u3, isCh3) + } +} diff --git a/internal/tools/message.go b/internal/tools/message.go index ab212613..46c57116 100644 --- a/internal/tools/message.go +++ b/internal/tools/message.go @@ -26,6 +26,9 @@ type MessageTool struct { workspace string restrict bool sender ChannelSender + editor ChannelEditor + topicResolver TopicResolver + topicPoster TopicPoster msgBus *bus.MessageBus tenantChecker ChannelTenantChecker } @@ -35,6 +38,9 @@ func NewMessageTool(workspace string, restrict bool) *MessageTool { } func (t *MessageTool) SetChannelSender(s ChannelSender) { t.sender = s } +func (t *MessageTool) SetChannelEditor(e ChannelEditor) { t.editor = e } +func (t *MessageTool) SetTopicResolver(r TopicResolver) { t.topicResolver = r } +func (t *MessageTool) SetTopicPoster(p TopicPoster) { t.topicPoster = p } func (t *MessageTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b } func (t *MessageTool) SetChannelTenantChecker(c ChannelTenantChecker) { t.tenantChecker = c } @@ -49,8 +55,16 @@ func (t *MessageTool) Parameters() map[string]any { "properties": map[string]any{ "action": map[string]any{ "type": "string", - "description": "Action to perform: 'send'", - "enum": []string{"send"}, + "description": "Action: 'send' a new message, or 'edit' an existing one (change its text in place). To edit, the user usually replies to the target message — use its id from reply_to_message_id in your context.", + "enum": []string{"send", "edit"}, + }, + "message_id": map[string]any{ + "type": "integer", + "description": "For action='edit': the id of the message to change. Take it from reply_to_message_id when the user replied to the message they want edited.", + }, + "topic": map[string]any{ + "type": "string", + "description": "For action='send' in a forum group: the name of the target topic to post into (e.g. 'Announcements'). Posts into that topic of the CURRENT group and returns the sent message_id in the result — remember it if you may need to edit that post later. Omit to reply in the current topic/chat.", }, "channel": map[string]any{ "type": "string", @@ -79,8 +93,16 @@ func (t *MessageTool) Parameters() map[string]any { func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *Result { action := argString(args, "action") + if action == "edit" { + return t.executeEdit(ctx, args) + } if action != "send" { - return ErrorResult(fmt.Sprintf("unsupported action: %s (only 'send' is supported)", action)) + return ErrorResult(fmt.Sprintf("unsupported action: %s (only 'send' and 'edit' are supported)", action)) + } + + // Posting into a named forum topic of the current group. + if topic := argString(args, "topic"); topic != "" { + return t.executeTopicSend(ctx, args, topic) } message := argString(args, "message") @@ -268,6 +290,108 @@ func (t *MessageTool) buildOutboundMetadata(ctx context.Context, target, forward return meta } +// executeTopicSend posts a message into a named forum topic of the current group. +// The topic is always in the current chat, so channel/chat come from context +// (reliable) — this also sidesteps the self-send guard, since a topic post is a +// deliberate cross-topic delivery, not a reply loop. +func (t *MessageTool) executeTopicSend(ctx context.Context, args map[string]any, topicName string) *Result { + message := argString(args, "message") + if message == "" { + return ErrorResult("message is required") + } + channel := ToolChannelFromCtx(ctx) + if channel == "" { + channel = argString(args, "channel") + } + target := ToolChatIDFromCtx(ctx) + if target == "" { + target = argString(args, "target") + } + if channel == "" || target == "" { + return ErrorResult("posting to a topic needs the current channel/chat context") + } + if t.topicResolver == nil { + return ErrorResult("topic posting is not supported in this context") + } + threadID, ok := t.topicResolver(ctx, channel, target, topicName) + if !ok { + return ErrorResult(fmt.Sprintf("topic %q not found in this group — I only know topics created while I'm in the group. Ask an admin to (re)create it, or tell me the topic differently.", topicName)) + } + if err := t.validateChannelTenant(ctx, channel, target); err != nil { + return err + } + + // Preferred path: post synchronously and return the sent message_id so the + // agent can remember it and later edit that exact post (no duplicates). + if t.topicPoster != nil { + threadNum, _ := strconv.Atoi(threadID) + msgID, err := t.topicPoster(ctx, channel, target, threadNum, message) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to post to topic %q: %v", topicName, err)) + } + return SilentResult(fmt.Sprintf(`{"status":"sent","channel":"%s","target":"%s","topic":"%s","thread_id":%d,"message_id":%d}`, channel, target, topicName, threadNum, msgID)) + } + + // Fallback: async via the bus (no message_id available). + if t.msgBus == nil { + return ErrorResult("topic posting requires the message bus") + } + t.msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: target, + Content: message, + Metadata: map[string]string{ + "group_id": target, + "message_thread_id": threadID, + }, + }) + return SilentResult(fmt.Sprintf(`{"status":"sent","channel":"%s","target":"%s","topic":"%s","thread_id":"%s"}`, channel, target, topicName, threadID)) +} + +// executeEdit edits an existing message in a channel (e.g. flip a ❌ to a ✅ in a +// status post). The message_id is typically the id of the message the user +// replied to — surfaced to the agent as reply_to_message_id in context. +func (t *MessageTool) executeEdit(ctx context.Context, args map[string]any) *Result { + if t.editor == nil { + return ErrorResult("editing messages is not supported in this context") + } + messageID := argInt(args, "message_id") + if messageID == 0 { + return ErrorResult("message_id is required for edit (use the id of the message to change — usually the one the user replied to)") + } + message := argString(args, "message") + if message == "" { + return ErrorResult("message (the new full text) is required for edit") + } + + // Edits target the message the user replied to in the CURRENT chat, so prefer + // the context channel/chat (reliable) over LLM-supplied args — models often + // pass the platform name ("telegram") instead of the channel instance, or a + // null target. Fall back to args only when context is absent. + channel := ToolChannelFromCtx(ctx) + if channel == "" { + channel = argString(args, "channel") + } + if channel == "" { + return ErrorResult("channel is required (no current channel in context)") + } + target := ToolChatIDFromCtx(ctx) + if target == "" { + target = argString(args, "target") + } + if target == "" { + return ErrorResult("target chat ID is required (no current chat in context)") + } + + if err := t.validateChannelTenant(ctx, channel, target); err != nil { + return err + } + if err := t.editor(ctx, channel, target, messageID, message); err != nil { + return ErrorResult(fmt.Sprintf("failed to edit message: %v", err)) + } + return SilentResult(fmt.Sprintf(`{"status":"edited","channel":"%s","target":"%s","message_id":%d}`, channel, target, messageID)) +} + // validateChannelTenant checks the target channel belongs to the current tenant. // Returns an error Result if the send should be blocked, nil if allowed. func (t *MessageTool) validateChannelTenant(ctx context.Context, channel, target string) *Result { diff --git a/internal/tools/message_test.go b/internal/tools/message_test.go index 47919796..18c5c6f6 100644 --- a/internal/tools/message_test.go +++ b/internal/tools/message_test.go @@ -905,3 +905,143 @@ func TestMessageTargetEnforced(t *testing.T) { } } } + +func TestMessageToolEditAction(t *testing.T) { + var gotChannel, gotChat, gotContent string + var gotMsgID int + tool := NewMessageTool("", true) + tool.SetChannelEditor(func(_ context.Context, ch, chatID string, messageID int, content string) error { + gotChannel, gotChat, gotMsgID, gotContent = ch, chatID, messageID, content + return nil + }) + r := tool.Execute(context.Background(), map[string]any{ + "action": "edit", + "channel": "telegram", + "target": float64(-1003995384344), + "message_id": float64(42), + "message": "Status\nItem: ✅\nAlice: ✅", + }) + if r.IsError { + t.Fatalf("unexpected error: %s", r.ForLLM) + } + if gotChannel != "telegram" || gotChat != "-1003995384344" || gotMsgID != 42 { + t.Errorf("editor got channel=%q chat=%q msgID=%d, want telegram/-1003995384344/42", gotChannel, gotChat, gotMsgID) + } + if gotContent == "" || !strings.Contains(gotContent, "Alice: ✅") { + t.Errorf("editor content = %q, want new status text", gotContent) + } +} + +func TestMessageToolEditRequiresMessageID(t *testing.T) { + tool := NewMessageTool("", true) + tool.SetChannelEditor(func(_ context.Context, _, _ string, _ int, _ string) error { return nil }) + r := tool.Execute(context.Background(), map[string]any{ + "action": "edit", + "channel": "telegram", + "target": "123", + "message": "x", + }) + if !r.IsError { + t.Error("edit without message_id must error") + } +} + +func TestMessageToolEditPrefersContextChannel(t *testing.T) { + var gotChannel, gotChat string + tool := NewMessageTool("", true) + tool.SetChannelEditor(func(_ context.Context, ch, chatID string, _ int, _ string) error { + gotChannel, gotChat = ch, chatID + return nil + }) + // Context has the real channel instance + chat; LLM wrongly passes platform name + null target. + ctx := WithToolChatID(WithToolChannel(context.Background(), "mychan"), "-1003995384344") + r := tool.Execute(ctx, map[string]any{ + "action": "edit", + "channel": "telegram", // wrong — must be ignored in favor of ctx + "target": nil, + "message_id": float64(42), + "message": "Status\nAlice: ✅\nItem: ✅", + }) + if r.IsError { + t.Fatalf("unexpected error: %s", r.ForLLM) + } + if gotChannel != "mychan" || gotChat != "-1003995384344" { + t.Errorf("editor got channel=%q chat=%q, want mychan/-1003995384344 (context wins)", gotChannel, gotChat) + } +} + +func TestMessageToolTopicSend(t *testing.T) { + var gotChannel, gotChat, gotTopic string + tool := NewMessageTool("", true) + tool.SetTopicResolver(func(_ context.Context, ch, chatID, name string) (string, bool) { + gotChannel, gotChat, gotTopic = ch, chatID, name + if name == "Announcements" { + return "77", true + } + return "", false + }) + mb := bus.New() + tool.SetMessageBus(mb) + + ctx := WithToolChatID(WithToolChannel(context.Background(), "mychan"), "-100500") + r := tool.Execute(ctx, map[string]any{ + "action": "send", + "topic": "Announcements", + "message": "invoice marked paid, but Alice has not transferred yet", + }) + if r.IsError { + t.Fatalf("unexpected error: %s", r.ForLLM) + } + if gotChannel != "mychan" || gotChat != "-100500" || gotTopic != "Announcements" { + t.Errorf("resolver got %q/%q/%q, want mychan/-100500/Announcements", gotChannel, gotChat, gotTopic) + } + out, ok := mb.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected an outbound message") + } + if out.Metadata["message_thread_id"] != "77" { + t.Errorf("outbound thread id = %q, want 77", out.Metadata["message_thread_id"]) + } +} + +func TestMessageToolTopicNotFound(t *testing.T) { + tool := NewMessageTool("", true) + tool.SetTopicResolver(func(_ context.Context, _, _, _ string) (string, bool) { return "", false }) + tool.SetMessageBus(bus.New()) + ctx := WithToolChatID(WithToolChannel(context.Background(), "mychan"), "-100500") + r := tool.Execute(ctx, map[string]any{"action": "send", "topic": "Nonexistent", "message": "x"}) + if !r.IsError { + t.Error("unknown topic must return an error") + } +} + +func TestMessageToolTopicSendReturnsMessageID(t *testing.T) { + var gotThread int + tool := NewMessageTool("", true) + tool.SetTopicResolver(func(_ context.Context, _, _, name string) (string, bool) { + if name == "Announcements" { + return "77", true + } + return "", false + }) + // Synchronous poster returns the sent message id. + tool.SetTopicPoster(func(_ context.Context, ch, chatID string, threadID int, content string) (int, error) { + gotThread = threadID + return 4242, nil + }) + ctx := WithToolChatID(WithToolChannel(context.Background(), "mychan"), "-100500") + r := tool.Execute(ctx, map[string]any{ + "action": "send", + "topic": "Announcements", + "message": "Terminator 2 — not watched", + }) + if r.IsError { + t.Fatalf("unexpected error: %s", r.ForLLM) + } + if gotThread != 77 { + t.Errorf("poster got thread %d, want 77", gotThread) + } + if !strings.Contains(r.ForLLM, `"message_id":4242`) { + t.Errorf("result must include the sent message_id, got: %s", r.ForLLM) + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go index 661fb8b4..7a8d2276 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -101,6 +101,36 @@ type ChannelSenderAware interface { SetChannelSender(ChannelSender) } +// ChannelEditor abstracts editing an existing message in a channel. +// Implemented by channels.Manager.EditChannelMessage. Not all channel types +// support editing arbitrary messages; unsupported channels return an error. +type ChannelEditor func(ctx context.Context, channel, chatID string, messageID int, content string) error + +// ChannelEditorAware tools can receive a channel editor function. +type ChannelEditorAware interface { + SetChannelEditor(ChannelEditor) +} + +// TopicResolver resolves a forum topic name to its message_thread_id within a +// specific chat, so the agent can post into a named topic (e.g. "Announcements"). +// Returns ("", false) when the topic is unknown. +type TopicResolver func(ctx context.Context, channel, chatID, topicName string) (threadID string, ok bool) + +// TopicResolverAware tools can receive a forum topic resolver. +type TopicResolverAware interface { + SetTopicResolver(TopicResolver) +} + +// TopicPoster synchronously posts a message into a forum topic (by thread id) +// and returns the sent message's id, so the agent can remember it and edit that +// exact message later instead of posting a duplicate. +type TopicPoster func(ctx context.Context, channel, chatID string, threadID int, content string) (messageID int, err error) + +// TopicPosterAware tools can receive a topic poster. +type TopicPosterAware interface { + SetTopicPoster(TopicPoster) +} + // ChannelTenantChecker returns the tenant UUID for a channel instance. // Used by the message tool to prevent cross-tenant sends. // Returns (tenantID, exists). Zero tenantID means legacy/config-based channel.