diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d6f5d0b6b..d42c9d700 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -58,7 +58,17 @@ type processOptions struct { NoHistory bool // If true, don't load session history (for heartbeat) } -const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." +const ( + defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." + sessionKeyAgentPrefix = "agent:" + switchCommandToken = "/switch" + commandMentionSeparator = "@" + metadataKeyAccountID = "account_id" + metadataKeyGuildID = "guild_id" + metadataKeyTeamID = "team_id" + metadataKeyParentPeerKind = "parent_peer_kind" + metadataKeyParentPeerID = "parent_peer_id" +) func NewAgentLoop( cfg *config.Config, @@ -498,11 +508,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { route := al.registry.ResolveRoute(routing.RouteInput{ Channel: msg.Channel, - AccountID: msg.Metadata["account_id"], + AccountID: inboundMetadata(msg, metadataKeyAccountID), Peer: extractPeer(msg), ParentPeer: extractParentPeer(msg), - GuildID: msg.Metadata["guild_id"], - TeamID: msg.Metadata["team_id"], + GuildID: inboundMetadata(msg, metadataKeyGuildID), + TeamID: inboundMetadata(msg, metadataKeyTeamID), }) agent, ok := al.registry.GetAgent(route.AgentID) @@ -517,7 +527,7 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv } func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string { - if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, "agent:") { + if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) { return msgSessionKey } return route.SessionKey @@ -1430,10 +1440,10 @@ func (al *AgentLoop) handleCommand( return "", false } cmd := parts[0] - if at := strings.Index(cmd, "@"); at > 0 { + if at := strings.Index(cmd, commandMentionSeparator); at > 0 { cmd = cmd[:at] } - if cmd != "/switch" { + if cmd != switchCommandToken { return "", false } @@ -1529,10 +1539,17 @@ func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID} } +func inboundMetadata(msg bus.InboundMessage, key string) string { + if msg.Metadata == nil { + return "" + } + return msg.Metadata[key] +} + // extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { - parentKind := msg.Metadata["parent_peer_kind"] - parentID := msg.Metadata["parent_peer_id"] + parentKind := inboundMetadata(msg, metadataKeyParentPeerKind) + parentID := inboundMetadata(msg, metadataKeyParentPeerID) if parentKind == "" || parentID == "" { return nil } diff --git a/pkg/commands/dispatcher.go b/pkg/commands/dispatcher.go index 205b10f99..e6596a15c 100644 --- a/pkg/commands/dispatcher.go +++ b/pkg/commands/dispatcher.go @@ -16,6 +16,8 @@ type Request struct { Reply func(text string) error } +var commandPrefixes = []string{"/", "!"} + func firstToken(input string) string { parts := strings.Fields(strings.TrimSpace(input)) if len(parts) == 0 { @@ -24,24 +26,41 @@ func firstToken(input string) string { return parts[0] } -// parseCommandName accepts both "/name" and "/name@bot", then normalizes to "name". +// parseCommandName accepts "/name", "!name", and Telegram's "/name@bot", then +// normalizes to lowercase command names. func parseCommandName(input string) (string, bool) { token := firstToken(input) - if token == "" || !strings.HasPrefix(token, "/") { + if token == "" { return "", false } - name := strings.TrimPrefix(token, "/") + name, ok := trimCommandPrefix(token) + if !ok { + return "", false + } if i := strings.Index(name, "@"); i >= 0 { name = name[:i] } - name = strings.TrimSpace(name) + name = normalizeCommandName(name) if name == "" { return "", false } return name, true } +func trimCommandPrefix(token string) (string, bool) { + for _, prefix := range commandPrefixes { + if strings.HasPrefix(token, prefix) { + return strings.TrimPrefix(token, prefix), true + } + } + return "", false +} + +func normalizeCommandName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + func contains(items []string, target string) bool { for _, item := range items { if item == target { diff --git a/pkg/commands/executor.go b/pkg/commands/executor.go index 69d4a83b0..59e5deaee 100644 --- a/pkg/commands/executor.go +++ b/pkg/commands/executor.go @@ -64,8 +64,13 @@ func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult { } func matchesCommand(def Definition, cmdName string) bool { - if def.Name == cmdName { + if normalizeCommandName(def.Name) == cmdName { return true } - return contains(def.Aliases, cmdName) + for _, alias := range def.Aliases { + if normalizeCommandName(alias) == cmdName { + return true + } + } + return false } diff --git a/pkg/commands/executor_test.go b/pkg/commands/executor_test.go index ebf091413..1ff46d5ee 100644 --- a/pkg/commands/executor_test.go +++ b/pkg/commands/executor_test.go @@ -153,3 +153,25 @@ func TestExecutor_HandlerErrorIsPropagated(t *testing.T) { t.Fatalf("err=%v, want=%v", res.Err, wantErr) } } + +func TestExecutor_SupportsBangPrefixAndCaseInsensitiveCommand(t *testing.T) { + called := false + defs := []Definition{ + { + Name: "help", + Handler: func(context.Context, Request) error { + called = true + return nil + }, + }, + } + ex := NewExecutor(NewRegistry(defs)) + + res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "!HELP"}) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !called { + t.Fatalf("expected handler to be called") + } +}