feat(commands): Session management [Phase 1/2] command centralization and registration
This commit is contained in:
parent
10ad9e83f9
commit
0d5d1a8f24
24 changed files with 1760 additions and 279 deletions
17
README.md
17
README.md
|
|
@ -338,6 +338,13 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**4. Telegram command menu (auto-registered at startup)**
|
||||||
|
|
||||||
|
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
|
||||||
|
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
|
||||||
|
|
||||||
|
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -735,6 +742,12 @@ For advanced/test setups, you can override the builtin skills root with:
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Unified Command Execution Policy
|
||||||
|
|
||||||
|
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
|
||||||
|
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
|
||||||
|
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
|
||||||
|
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
|
||||||
### 🔒 Security Sandbox
|
### 🔒 Security Sandbox
|
||||||
|
|
||||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||||
|
|
@ -1190,6 +1203,10 @@ picoclaw agent -m "Hello"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
|
||||||
17
README.zh.md
17
README.zh.md
|
|
@ -307,6 +307,13 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||||
|
|
||||||
|
### Telegram 命令注册(启动时自动同步)
|
||||||
|
|
||||||
|
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
||||||
|
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
|
||||||
|
|
||||||
|
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
@ -376,6 +383,12 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 统一命令执行策略
|
||||||
|
|
||||||
|
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
||||||
|
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
|
||||||
|
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
|
||||||
|
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
|
||||||
### 心跳 / 周期性任务 (Heartbeat)
|
### 心跳 / 周期性任务 (Heartbeat)
|
||||||
|
|
||||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
||||||
|
|
@ -715,6 +728,10 @@ picoclaw agent -m "你好"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -11,6 +11,7 @@ require (
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0
|
github.com/modelcontextprotocol/go-sdk v1.3.0
|
||||||
|
|
@ -37,7 +38,6 @@ require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/h2non/filetype v1.1.3 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -61,7 +62,17 @@ type processOptions struct {
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
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(
|
func NewAgentLoop(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
|
@ -518,29 +529,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
return al.processSystemMessage(ctx, msg)
|
return al.processSystemMessage(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
route, agent, err := al.resolveMessageRoute(msg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
// Check for commands
|
// Check for commands
|
||||||
if response, handled := al.handleCommand(ctx, msg); handled {
|
if response, handled := al.handleCommand(ctx, msg); handled {
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to determine agent and session key
|
|
||||||
route := al.registry.ResolveRoute(routing.RouteInput{
|
|
||||||
Channel: msg.Channel,
|
|
||||||
AccountID: msg.Metadata["account_id"],
|
|
||||||
Peer: extractPeer(msg),
|
|
||||||
ParentPeer: extractParentPeer(msg),
|
|
||||||
GuildID: msg.Metadata["guild_id"],
|
|
||||||
TeamID: msg.Metadata["team_id"],
|
|
||||||
})
|
|
||||||
|
|
||||||
agent, ok := al.registry.GetAgent(route.AgentID)
|
|
||||||
if !ok {
|
|
||||||
agent = al.registry.GetDefaultAgent()
|
|
||||||
}
|
|
||||||
if agent == nil {
|
|
||||||
return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||||
if tool, ok := agent.Tools.Get("message"); ok {
|
if tool, ok := agent.Tools.Get("message"); ok {
|
||||||
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
||||||
|
|
@ -548,17 +546,18 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
|
// Resolve session key from route, while preserving explicit agent-scoped keys.
|
||||||
sessionKey := route.SessionKey
|
scopeKey := resolveScopeKey(route, msg.SessionKey)
|
||||||
if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") {
|
sessionKey := scopeKey
|
||||||
sessionKey = msg.SessionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.InfoCF("agent", "Routed message",
|
logger.InfoCF("agent", "Routed message",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
|
"scope_key": scopeKey,
|
||||||
"session_key": sessionKey,
|
"session_key": sessionKey,
|
||||||
"matched_by": route.MatchedBy,
|
"matched_by": route.MatchedBy,
|
||||||
|
"route_agent": route.AgentID,
|
||||||
|
"route_channel": route.Channel,
|
||||||
})
|
})
|
||||||
|
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
|
|
@ -573,6 +572,34 @@ 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: inboundMetadata(msg, metadataKeyAccountID),
|
||||||
|
Peer: extractPeer(msg),
|
||||||
|
ParentPeer: extractParentPeer(msg),
|
||||||
|
GuildID: inboundMetadata(msg, metadataKeyGuildID),
|
||||||
|
TeamID: inboundMetadata(msg, metadataKeyTeamID),
|
||||||
|
})
|
||||||
|
|
||||||
|
agent, ok := al.registry.GetAgent(route.AgentID)
|
||||||
|
if !ok {
|
||||||
|
agent = al.registry.GetDefaultAgent()
|
||||||
|
}
|
||||||
|
if agent == nil {
|
||||||
|
return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return route, agent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string {
|
||||||
|
if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) {
|
||||||
|
return msgSessionKey
|
||||||
|
}
|
||||||
|
return route.SessionKey
|
||||||
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) processSystemMessage(
|
func (al *AgentLoop) processSystemMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg bus.InboundMessage,
|
msg bus.InboundMessage,
|
||||||
|
|
@ -1429,65 +1456,57 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
||||||
return totalChars * 2 / 5
|
return totalChars * 2 / 5
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
|
func (al *AgentLoop) handleCommand(
|
||||||
|
ctx context.Context,
|
||||||
|
msg bus.InboundMessage,
|
||||||
|
) (string, bool) {
|
||||||
|
// Generic command routing is delegated to the centralized commands executor.
|
||||||
|
// Session lifecycle commands are intentionally out of scope for this PR layer.
|
||||||
content := strings.TrimSpace(msg.Content)
|
content := strings.TrimSpace(msg.Content)
|
||||||
if !strings.HasPrefix(content, "/") {
|
if !strings.HasPrefix(content, "/") {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
executor := commands.NewExecutor(commands.NewRegistry(commands.BuiltinDefinitions(al.cfg)))
|
||||||
|
|
||||||
|
var commandReply string
|
||||||
|
result := executor.Execute(ctx, commands.Request{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
ChatID: msg.ChatID,
|
||||||
|
SenderID: msg.SenderID,
|
||||||
|
Text: msg.Content,
|
||||||
|
Reply: func(text string) error {
|
||||||
|
commandReply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
switch result.Outcome {
|
||||||
|
case commands.OutcomeHandled:
|
||||||
|
if result.Err != nil {
|
||||||
|
return mapCommandError(result), true
|
||||||
|
}
|
||||||
|
if commandReply != "" {
|
||||||
|
return commandReply, true
|
||||||
|
}
|
||||||
|
if result.Reply != "" {
|
||||||
|
return result.Reply, true
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
case commands.OutcomePassthrough:
|
||||||
parts := strings.Fields(content)
|
parts := strings.Fields(content)
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := parts[0]
|
cmd := parts[0]
|
||||||
|
if at := strings.Index(cmd, commandMentionSeparator); at > 0 {
|
||||||
|
cmd = cmd[:at]
|
||||||
|
}
|
||||||
|
if cmd != switchCommandToken {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
args := parts[1:]
|
args := parts[1:]
|
||||||
|
|
||||||
switch cmd {
|
|
||||||
case "/show":
|
|
||||||
if len(args) < 1 {
|
|
||||||
return "Usage: /show [model|channel|agents]", true
|
|
||||||
}
|
|
||||||
switch args[0] {
|
|
||||||
case "model":
|
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
|
||||||
if defaultAgent == nil {
|
|
||||||
return "No default agent configured", true
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
|
|
||||||
case "channel":
|
|
||||||
return fmt.Sprintf("Current channel: %s", msg.Channel), true
|
|
||||||
case "agents":
|
|
||||||
agentIDs := al.registry.ListAgentIDs()
|
|
||||||
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("Unknown show target: %s", args[0]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "/list":
|
|
||||||
if len(args) < 1 {
|
|
||||||
return "Usage: /list [models|channels|agents]", true
|
|
||||||
}
|
|
||||||
switch args[0] {
|
|
||||||
case "models":
|
|
||||||
return "Available models: configured in config.json per agent", true
|
|
||||||
case "channels":
|
|
||||||
if al.channelManager == nil {
|
|
||||||
return "Channel manager not initialized", true
|
|
||||||
}
|
|
||||||
channels := al.channelManager.GetEnabledChannels()
|
|
||||||
if len(channels) == 0 {
|
|
||||||
return "No channels enabled", true
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
|
|
||||||
case "agents":
|
|
||||||
agentIDs := al.registry.ListAgentIDs()
|
|
||||||
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("Unknown list target: %s", args[0]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "/switch":
|
|
||||||
if len(args) < 3 || args[1] != "to" {
|
if len(args) < 3 || args[1] != "to" {
|
||||||
return "Usage: /switch [model|channel] to <name>", true
|
return "Usage: /switch [model|channel] to <name>", true
|
||||||
}
|
}
|
||||||
|
|
@ -1502,6 +1521,10 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
}
|
}
|
||||||
oldModel := defaultAgent.Model
|
oldModel := defaultAgent.Model
|
||||||
defaultAgent.Model = value
|
defaultAgent.Model = value
|
||||||
|
if al.cfg != nil {
|
||||||
|
al.cfg.Agents.Defaults.ModelName = value
|
||||||
|
al.cfg.Agents.Defaults.Model = value
|
||||||
|
}
|
||||||
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
|
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
|
||||||
case "channel":
|
case "channel":
|
||||||
if al.channelManager == nil {
|
if al.channelManager == nil {
|
||||||
|
|
@ -1519,6 +1542,13 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mapCommandError(result commands.ExecuteResult) string {
|
||||||
|
if result.Command == "" {
|
||||||
|
return fmt.Sprintf("Failed to execute command: %v", result.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err)
|
||||||
|
}
|
||||||
|
|
||||||
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
||||||
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
if msg.Peer.Kind == "" {
|
if msg.Peer.Kind == "" {
|
||||||
|
|
@ -1535,10 +1565,17 @@ func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
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.
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
||||||
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
parentKind := msg.Metadata["parent_peer_kind"]
|
parentKind := inboundMetadata(msg, metadataKeyParentPeerKind)
|
||||||
parentID := msg.Metadata["parent_peer_id"]
|
parentID := inboundMetadata(msg, metadataKeyParentPeerID)
|
||||||
if parentKind == "" || parentID == "" {
|
if parentKind == "" || parentID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -323,6 +324,29 @@ func (m *simpleMockProvider) GetDefaultModel() string {
|
||||||
return "mock-model"
|
return "mock-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type countingMockProvider struct {
|
||||||
|
response string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
m.calls++
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: m.response,
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) GetDefaultModel() string {
|
||||||
|
return "counting-mock-model"
|
||||||
|
}
|
||||||
|
|
||||||
// mockCustomTool is a simple mock tool for registration testing
|
// mockCustomTool is a simple mock tool for registration testing
|
||||||
type mockCustomTool struct{}
|
type mockCustomTool struct{}
|
||||||
|
|
||||||
|
|
@ -364,6 +388,198 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms
|
||||||
|
|
||||||
const responseTimeout = 3 * time.Second
|
const responseTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
func TestProcessMessage_UsesRouteSessionKey(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &simpleMockProvider{response: "ok"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
msg := bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
route := al.registry.ResolveRoute(routing.RouteInput{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
Peer: extractPeer(msg),
|
||||||
|
})
|
||||||
|
sessionKey := route.SessionKey
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("No default agent found")
|
||||||
|
}
|
||||||
|
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
_ = helper.executeAndGetResponse(t, context.Background(), msg)
|
||||||
|
|
||||||
|
history := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected session history len=2, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||||
|
t.Fatalf("unexpected first message in session: %+v", history[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CommandOutcomes(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: config.SessionConfig{
|
||||||
|
DMScope: "per-channel-peer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
baseMsg := bus.InboundMessage{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/show channel",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if showResp != "Current Channel: whatsapp" {
|
||||||
|
t.Fatalf("unexpected /show reply: %q", showResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/foo",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if fooResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /foo reply: %q", fooResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 1 {
|
||||||
|
t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/new",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if newResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /new reply: %q", newResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 2 {
|
||||||
|
t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "before-switch",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/switch model to after-switch",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") {
|
||||||
|
t.Fatalf("unexpected /switch reply: %q", switchResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/show model",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") {
|
||||||
|
t.Fatalf("unexpected /show model reply after switch: %q", showResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
||||||
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package channels
|
||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
|
import "github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
|
||||||
// TypingCapable — channels that can show a typing/thinking indicator.
|
// TypingCapable — channels that can show a typing/thinking indicator.
|
||||||
// StartTyping begins the indicator and returns a stop function.
|
// StartTyping begins the indicator and returns a stop function.
|
||||||
// The stop function MUST be idempotent and safe to call multiple times.
|
// The stop function MUST be idempotent and safe to call multiple times.
|
||||||
|
|
@ -39,3 +41,10 @@ type PlaceholderRecorder interface {
|
||||||
RecordTypingStop(channel, chatID string, stop func())
|
RecordTypingStop(channel, chatID string, stop func())
|
||||||
RecordReactionUndo(channel, chatID string, undo func())
|
RecordReactionUndo(channel, chatID string, undo func())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandRegistrarCapable is implemented by channels that can register
|
||||||
|
// command menus with their upstream platform (e.g. Telegram BotCommand).
|
||||||
|
// Channels that do not support platform-level command menus can ignore it.
|
||||||
|
type CommandRegistrarCapable interface {
|
||||||
|
RegisterCommands(ctx context.Context, defs []commands.Definition) error
|
||||||
|
}
|
||||||
|
|
|
||||||
16
pkg/channels/interfaces_command_test.go
Normal file
16
pkg/channels/interfaces_command_test.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockRegistrar struct{}
|
||||||
|
|
||||||
|
func (mockRegistrar) RegisterCommands(context.Context, []commands.Definition) error { return nil }
|
||||||
|
|
||||||
|
func TestCommandRegistrarCapable_Compiles(t *testing.T) {
|
||||||
|
var _ CommandRegistrarCapable = mockRegistrar{}
|
||||||
|
}
|
||||||
105
pkg/channels/telegram/command_registration.go
Normal file
105
pkg/channels/telegram/command_registration.go
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var commandRegistrationBackoff = []time.Duration{
|
||||||
|
5 * time.Second,
|
||||||
|
15 * time.Second,
|
||||||
|
60 * time.Second,
|
||||||
|
5 * time.Minute,
|
||||||
|
10 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandRegistrationDelay(attempt int) time.Duration {
|
||||||
|
if len(commandRegistrationBackoff) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
base := commandRegistrationBackoff[min(attempt, len(commandRegistrationBackoff)-1)]
|
||||||
|
// Full jitter in [0.5, 1.0) to avoid synchronized retries across instances.
|
||||||
|
return time.Duration(float64(base) * (0.5 + rand.Float64()*0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterCommands registers bot commands on Telegram platform.
|
||||||
|
func (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands.Definition) error {
|
||||||
|
botCommands := make([]telego.BotCommand, 0, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Name == "" || def.Description == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
botCommands = append(botCommands, telego.BotCommand{
|
||||||
|
Command: def.Name,
|
||||||
|
Description: def.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
|
||||||
|
Commands: botCommands,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []commands.Definition) {
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
register := c.registerFunc
|
||||||
|
if register == nil {
|
||||||
|
register = c.RegisterCommands
|
||||||
|
}
|
||||||
|
|
||||||
|
regCtx, cancel := context.WithCancel(ctx)
|
||||||
|
c.commandRegCancel = cancel
|
||||||
|
|
||||||
|
// Registration runs asynchronously so Telegram message intake is never blocked
|
||||||
|
// by temporary upstream API failures. Retry stops on success or channel shutdown.
|
||||||
|
go func() {
|
||||||
|
attempt := 0
|
||||||
|
timer := time.NewTimer(0)
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
err := register(regCtx, defs)
|
||||||
|
if err == nil {
|
||||||
|
logger.InfoCF("telegram", "Telegram commands registered", map[string]any{
|
||||||
|
"count": len(defs),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delay := commandRegistrationDelay(attempt)
|
||||||
|
logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"retry_after": delay.String(),
|
||||||
|
})
|
||||||
|
attempt++
|
||||||
|
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Reset(delay)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-regCtx.Done():
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
96
pkg/channels/telegram/command_registration_test.go
Normal file
96
pkg/channels/telegram/command_registration_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartCommandRegistration_DoesNotBlock(t *testing.T) {
|
||||||
|
ch := &TelegramChannel{}
|
||||||
|
started := make(chan struct{}, 1)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ch.registerFunc = func(context.Context, []commands.Definition) error {
|
||||||
|
started <- struct{}{}
|
||||||
|
return errors.New("temporary failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help"}})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("registration did not start asynchronously")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) {
|
||||||
|
ch := &TelegramChannel{}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
origBackoff := commandRegistrationBackoff
|
||||||
|
commandRegistrationBackoff = []time.Duration{5 * time.Millisecond}
|
||||||
|
defer func() { commandRegistrationBackoff = origBackoff }()
|
||||||
|
|
||||||
|
var attempts atomic.Int32
|
||||||
|
ch.registerFunc = func(context.Context, []commands.Definition) error {
|
||||||
|
n := attempts.Add(1)
|
||||||
|
if n < 3 {
|
||||||
|
return errors.New("temporary failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(250 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if attempts.Load() >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if attempts.Load() < 3 {
|
||||||
|
t.Fatalf("expected at least 3 attempts, got %d", attempts.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
stable := attempts.Load()
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if attempts.Load() != stable {
|
||||||
|
t.Fatalf("expected retries to stop after success, got %d -> %d", stable, attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) {
|
||||||
|
ch := &TelegramChannel{}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
origBackoff := commandRegistrationBackoff
|
||||||
|
commandRegistrationBackoff = []time.Duration{5 * time.Millisecond}
|
||||||
|
defer func() { commandRegistrationBackoff = origBackoff }()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var attempts atomic.Int32
|
||||||
|
ch.registerFunc = func(context.Context, []commands.Definition) error {
|
||||||
|
attempts.Add(1)
|
||||||
|
return errors.New("always fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}})
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
time.Sleep(20 * time.Millisecond) // allow in-flight attempt to settle
|
||||||
|
stable := attempts.Load()
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if attempts.Load() != stable {
|
||||||
|
t.Fatalf("expected retries to quiesce after cancel, got %d -> %d", stable, attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -42,11 +43,13 @@ type TelegramChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
bot *telego.Bot
|
bot *telego.Bot
|
||||||
bh *th.BotHandler
|
bh *th.BotHandler
|
||||||
commands TelegramCommander
|
|
||||||
config *config.Config
|
config *config.Config
|
||||||
chatIDs map[string]int64
|
chatIDs map[string]int64
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
registerFunc func(context.Context, []commands.Definition) error
|
||||||
|
commandRegCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||||
|
|
@ -93,7 +96,6 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
|
|
||||||
return &TelegramChannel{
|
return &TelegramChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
commands: NewTelegramCommands(bot, cfg),
|
|
||||||
bot: bot,
|
bot: bot,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
chatIDs: make(map[string]int64),
|
chatIDs: make(map[string]int64),
|
||||||
|
|
@ -126,21 +128,6 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
c.bh = bh
|
c.bh = bh
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
|
||||||
return c.commands.Start(ctx, message)
|
|
||||||
}, th.CommandEqual("start"))
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
|
||||||
return c.commands.Help(ctx, message)
|
|
||||||
}, th.CommandEqual("help"))
|
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
|
||||||
return c.commands.Show(ctx, message)
|
|
||||||
}, th.CommandEqual("show"))
|
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
|
||||||
return c.commands.List(ctx, message)
|
|
||||||
}, th.CommandEqual("list"))
|
|
||||||
|
|
||||||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||||
return c.handleMessage(ctx, &message)
|
return c.handleMessage(ctx, &message)
|
||||||
}, th.AnyMessage())
|
}, th.AnyMessage())
|
||||||
|
|
@ -150,6 +137,8 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
"username": c.bot.Username(),
|
"username": c.bot.Username(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
c.startCommandRegistration(c.ctx, commands.NewRegistry(commands.BuiltinDefinitions(c.config)).Definitions())
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err = bh.Start(); err != nil {
|
if err = bh.Start(); err != nil {
|
||||||
logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
|
logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
|
||||||
|
|
@ -174,6 +163,9 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||||
if c.cancel != nil {
|
if c.cancel != nil {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
if c.commandRegCancel != nil {
|
||||||
|
c.commandRegCancel()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -721,34 +713,34 @@ func escapeHTML(text string) string {
|
||||||
|
|
||||||
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
||||||
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
||||||
botUsername := c.bot.Username()
|
text, entities := telegramEntityTextAndList(message)
|
||||||
if botUsername == "" {
|
if text == "" || len(entities) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
entities := message.Entities
|
botUsername := ""
|
||||||
if entities == nil {
|
if c.bot != nil {
|
||||||
entities = message.CaptionEntities
|
botUsername = c.bot.Username()
|
||||||
}
|
|
||||||
|
|
||||||
for _, entity := range entities {
|
|
||||||
if entity.Type == "mention" {
|
|
||||||
// Extract the mention text from the message
|
|
||||||
text := message.Text
|
|
||||||
if text == "" {
|
|
||||||
text = message.Caption
|
|
||||||
}
|
}
|
||||||
runes := []rune(text)
|
runes := []rune(text)
|
||||||
end := entity.Offset + entity.Length
|
|
||||||
if end <= len(runes) {
|
for _, entity := range entities {
|
||||||
mention := string(runes[entity.Offset:end])
|
entityText, ok := telegramEntityText(runes, entity)
|
||||||
if strings.EqualFold(mention, "@"+botUsername) {
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch entity.Type {
|
||||||
|
case telego.EntityTypeMention:
|
||||||
|
if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
case telego.EntityTypeTextMention:
|
||||||
|
if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
case telego.EntityTypeBotCommand:
|
||||||
if entity.Type == "text_mention" && entity.User != nil {
|
if isBotCommandEntityForThisBot(entityText, botUsername) {
|
||||||
if entity.User.Username == botUsername {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -756,6 +748,46 @@ func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) {
|
||||||
|
if message.Text != "" {
|
||||||
|
return message.Text, message.Entities
|
||||||
|
}
|
||||||
|
return message.Caption, message.CaptionEntities
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) {
|
||||||
|
if entity.Offset < 0 || entity.Length <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
end := entity.Offset + entity.Length
|
||||||
|
if entity.Offset >= len(runes) || end > len(runes) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(runes[entity.Offset:end]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBotCommandEntityForThisBot(entityText, botUsername string) bool {
|
||||||
|
if !strings.HasPrefix(entityText, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
command := strings.TrimPrefix(entityText, "/")
|
||||||
|
if command == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
at := strings.IndexRune(command, '@')
|
||||||
|
if at == -1 {
|
||||||
|
// A bare /command delivered to this bot is intended for this bot.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mentionUsername := command[at+1:]
|
||||||
|
if mentionUsername == "" || botUsername == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(mentionUsername, botUsername)
|
||||||
|
}
|
||||||
|
|
||||||
// stripBotMention removes the @bot mention from the content.
|
// stripBotMention removes the @bot mention from the content.
|
||||||
func (c *TelegramChannel) stripBotMention(content string) string {
|
func (c *TelegramChannel) stripBotMention(content string) string {
|
||||||
botUsername := c.bot.Username()
|
botUsername := c.bot.Username()
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
package telegram
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TelegramCommander interface {
|
|
||||||
Help(ctx context.Context, message telego.Message) error
|
|
||||||
Start(ctx context.Context, message telego.Message) error
|
|
||||||
Show(ctx context.Context, message telego.Message) error
|
|
||||||
List(ctx context.Context, message telego.Message) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmd struct {
|
|
||||||
bot *telego.Bot
|
|
||||||
config *config.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander {
|
|
||||||
return &cmd{
|
|
||||||
bot: bot,
|
|
||||||
config: cfg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandArgs(text string) string {
|
|
||||||
parts := strings.SplitN(text, " ", 2)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
|
||||||
msg := `/start - Start the bot
|
|
||||||
/help - Show this help message
|
|
||||||
/show [model|channel] - Show current configuration
|
|
||||||
/list [models|channels] - List available options
|
|
||||||
`
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: msg,
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) Start(ctx context.Context, message telego.Message) error {
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: "Hello! I am PicoClaw 🦞",
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) Show(ctx context.Context, message telego.Message) error {
|
|
||||||
args := commandArgs(message.Text)
|
|
||||||
if args == "" {
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: "Usage: /show [model|channel]",
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response string
|
|
||||||
switch args {
|
|
||||||
case "model":
|
|
||||||
response = fmt.Sprintf("Current Model: %s (Provider: %s)",
|
|
||||||
c.config.Agents.Defaults.GetModelName(),
|
|
||||||
c.config.Agents.Defaults.Provider)
|
|
||||||
case "channel":
|
|
||||||
response = "Current Channel: telegram"
|
|
||||||
default:
|
|
||||||
response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: response,
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
|
||||||
args := commandArgs(message.Text)
|
|
||||||
if args == "" {
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: "Usage: /list [models|channels]",
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response string
|
|
||||||
switch args {
|
|
||||||
case "models":
|
|
||||||
provider := c.config.Agents.Defaults.Provider
|
|
||||||
if provider == "" {
|
|
||||||
provider = "configured default"
|
|
||||||
}
|
|
||||||
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
|
||||||
c.config.Agents.Defaults.GetModelName(), provider)
|
|
||||||
|
|
||||||
case "channels":
|
|
||||||
var enabled []string
|
|
||||||
if c.config.Channels.Telegram.Enabled {
|
|
||||||
enabled = append(enabled, "telegram")
|
|
||||||
}
|
|
||||||
if c.config.Channels.WhatsApp.Enabled {
|
|
||||||
enabled = append(enabled, "whatsapp")
|
|
||||||
}
|
|
||||||
if c.config.Channels.Feishu.Enabled {
|
|
||||||
enabled = append(enabled, "feishu")
|
|
||||||
}
|
|
||||||
if c.config.Channels.Discord.Enabled {
|
|
||||||
enabled = append(enabled, "discord")
|
|
||||||
}
|
|
||||||
if c.config.Channels.Slack.Enabled {
|
|
||||||
enabled = append(enabled, "slack")
|
|
||||||
}
|
|
||||||
response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))
|
|
||||||
|
|
||||||
default:
|
|
||||||
response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: response,
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
52
pkg/channels/telegram/telegram_dispatch_test.go
Normal file
52
pkg/channels/telegram/telegram_dispatch_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "/new",
|
||||||
|
MessageID: 9,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: 123,
|
||||||
|
Type: "private",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 42,
|
||||||
|
FirstName: "Alice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ch.handleMessage(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Channel != "telegram" {
|
||||||
|
t.Fatalf("channel=%q", inbound.Channel)
|
||||||
|
}
|
||||||
|
if inbound.Content != "/new" {
|
||||||
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
pkg/channels/telegram/telegram_group_command_filter_test.go
Normal file
147
pkg/channels/telegram/telegram_group_command_filter_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
ta "github.com/mymmrac/telego/telegoapi"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type getMeCaller struct {
|
||||||
|
username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c getMeCaller) Call(_ context.Context, url string, _ *ta.RequestData) (*ta.Response, error) {
|
||||||
|
if strings.HasSuffix(url, "/getMe") {
|
||||||
|
result := fmt.Sprintf(`{"id":1,"is_bot":true,"first_name":"bot","username":%q}`, c.username)
|
||||||
|
return &ta.Response{Ok: true, Result: []byte(result)}, nil
|
||||||
|
}
|
||||||
|
return &ta.Response{Ok: true, Result: []byte("true")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestTelegramBot(t *testing.T, username string) *telego.Bot {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token := "123456:" + strings.Repeat("a", 35)
|
||||||
|
bot, err := telego.NewBot(token,
|
||||||
|
telego.WithAPICaller(getMeCaller{username: username}),
|
||||||
|
telego.WithDiscardLogger(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBot error: %v", err)
|
||||||
|
}
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGroupMentionOnlyChannel(t *testing.T, botUsername string) (*TelegramChannel, *bus.MessageBus) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil,
|
||||||
|
channels.WithGroupTrigger(config.GroupTriggerConfig{MentionOnly: true}),
|
||||||
|
),
|
||||||
|
bot: newTestTelegramBot(t, botUsername),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
return ch, messageBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
wantForwarded bool
|
||||||
|
wantContent string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "command with bot username",
|
||||||
|
text: "/new@testbot",
|
||||||
|
wantForwarded: true,
|
||||||
|
wantContent: "/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare command",
|
||||||
|
text: "/new",
|
||||||
|
wantForwarded: true,
|
||||||
|
wantContent: "/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command for another bot",
|
||||||
|
text: "/new@otherbot",
|
||||||
|
wantForwarded: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ch, messageBus := newGroupMentionOnlyChannel(t, "testbot")
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: tc.text,
|
||||||
|
Entities: []telego.MessageEntity{{
|
||||||
|
Type: telego.EntityTypeBotCommand,
|
||||||
|
Offset: 0,
|
||||||
|
Length: len([]rune(tc.text)),
|
||||||
|
}},
|
||||||
|
MessageID: 42,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: 123,
|
||||||
|
Type: "group",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 7,
|
||||||
|
FirstName: "Alice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ch.handleMessage(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if tc.wantForwarded {
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Content != tc.wantContent {
|
||||||
|
t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected message to be filtered, got content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) {
|
||||||
|
ch, _ := newGroupMentionOnlyChannel(t, "testbot")
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "@testbot hello",
|
||||||
|
Entities: []telego.MessageEntity{{
|
||||||
|
Type: telego.EntityTypeMention,
|
||||||
|
Offset: 0,
|
||||||
|
Length: len("@testbot"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ch.isBotMentioned(msg) {
|
||||||
|
t.Fatal("expected mention entity to be treated as bot mention")
|
||||||
|
}
|
||||||
|
}
|
||||||
41
pkg/channels/whatsapp/whatsapp_command_test.go
Normal file
41
pkg/channels/whatsapp/whatsapp_command_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &WhatsAppChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.handleIncomingMessage(map[string]any{
|
||||||
|
"type": "message",
|
||||||
|
"id": "mid1",
|
||||||
|
"from": "user1",
|
||||||
|
"chat": "chat1",
|
||||||
|
"content": "/help",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Channel != "whatsapp" {
|
||||||
|
t.Fatalf("channel=%q", inbound.Channel)
|
||||||
|
}
|
||||||
|
if inbound.Content != "/help" {
|
||||||
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
pkg/channels/whatsapp_native/whatsapp_command_test.go
Normal file
56
pkg/channels/whatsapp_native/whatsapp_command_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
//go:build whatsapp_native
|
||||||
|
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||||
|
"go.mau.fi/whatsmeow/types"
|
||||||
|
"go.mau.fi/whatsmeow/types/events"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &WhatsAppNativeChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil),
|
||||||
|
runCtx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := &events.Message{
|
||||||
|
Info: types.MessageInfo{
|
||||||
|
MessageSource: types.MessageSource{
|
||||||
|
Sender: types.NewJID("1001", types.DefaultUserServer),
|
||||||
|
Chat: types.NewJID("1001", types.DefaultUserServer),
|
||||||
|
},
|
||||||
|
ID: "mid1",
|
||||||
|
PushName: "Alice",
|
||||||
|
},
|
||||||
|
Message: &waE2E.Message{
|
||||||
|
Conversation: proto.String("/new"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.handleIncoming(evt)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Channel != "whatsapp_native" {
|
||||||
|
t.Fatalf("channel=%q", inbound.Channel)
|
||||||
|
}
|
||||||
|
if inbound.Content != "/new" {
|
||||||
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
163
pkg/commands/builtin.go
Normal file
163
pkg/commands/builtin.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuiltinDefinitions(cfg *config.Config) []Definition {
|
||||||
|
return []Definition{
|
||||||
|
{
|
||||||
|
Name: "start",
|
||||||
|
Description: "Start the bot",
|
||||||
|
Usage: "/start",
|
||||||
|
Handler: replyText("Hello! I am PicoClaw 🦞"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "help",
|
||||||
|
Description: "Show this help message",
|
||||||
|
Usage: "/help",
|
||||||
|
Handler: func(_ context.Context, req Request) error {
|
||||||
|
if req.Reply == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defs := NewRegistry(BuiltinDefinitions(cfg)).Definitions()
|
||||||
|
return req.Reply(FormatHelpMessage(defs))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
Description: "Show current configuration",
|
||||||
|
Usage: "/show [model|channel]",
|
||||||
|
Handler: func(_ context.Context, req Request) error {
|
||||||
|
if req.Reply == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return req.Reply("Command unavailable in current context.")
|
||||||
|
}
|
||||||
|
args := commandArgs(req.Text)
|
||||||
|
if args == "" {
|
||||||
|
return req.Reply("Usage: /show [model|channel]")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args {
|
||||||
|
case "model":
|
||||||
|
return req.Reply(fmt.Sprintf(
|
||||||
|
"Current Model: %s (Provider: %s)",
|
||||||
|
cfg.Agents.Defaults.GetModelName(),
|
||||||
|
cfg.Agents.Defaults.Provider,
|
||||||
|
))
|
||||||
|
case "channel":
|
||||||
|
return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel))
|
||||||
|
default:
|
||||||
|
return req.Reply(fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List available options",
|
||||||
|
Usage: "/list [models|channels]",
|
||||||
|
Handler: func(_ context.Context, req Request) error {
|
||||||
|
if req.Reply == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return req.Reply("Command unavailable in current context.")
|
||||||
|
}
|
||||||
|
args := commandArgs(req.Text)
|
||||||
|
if args == "" {
|
||||||
|
return req.Reply("Usage: /list [models|channels]")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args {
|
||||||
|
case "models":
|
||||||
|
provider := cfg.Agents.Defaults.Provider
|
||||||
|
if provider == "" {
|
||||||
|
provider = "configured default"
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf(
|
||||||
|
"Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
||||||
|
cfg.Agents.Defaults.GetModelName(),
|
||||||
|
provider,
|
||||||
|
))
|
||||||
|
case "channels":
|
||||||
|
enabled := enabledChannels(cfg)
|
||||||
|
return req.Reply(fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")))
|
||||||
|
default:
|
||||||
|
return req.Reply(fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatHelpMessage(defs []Definition) string {
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return "No commands available."
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
usage := def.Usage
|
||||||
|
if usage == "" {
|
||||||
|
usage = "/" + def.Name
|
||||||
|
}
|
||||||
|
desc := def.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = "No description"
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%s - %s", usage, desc))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandArgs(text string) string {
|
||||||
|
parts := strings.SplitN(text, " ", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyText(text string) Handler {
|
||||||
|
return func(_ context.Context, req Request) error {
|
||||||
|
if req.Reply == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return req.Reply(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabledChannels(cfg *config.Config) []string {
|
||||||
|
enabled := make([]string, 0, 8)
|
||||||
|
if cfg.Channels.Telegram.Enabled {
|
||||||
|
enabled = append(enabled, "telegram")
|
||||||
|
}
|
||||||
|
if cfg.Channels.WhatsApp.Enabled {
|
||||||
|
enabled = append(enabled, "whatsapp")
|
||||||
|
}
|
||||||
|
if cfg.Channels.Feishu.Enabled {
|
||||||
|
enabled = append(enabled, "feishu")
|
||||||
|
}
|
||||||
|
if cfg.Channels.Discord.Enabled {
|
||||||
|
enabled = append(enabled, "discord")
|
||||||
|
}
|
||||||
|
if cfg.Channels.Slack.Enabled {
|
||||||
|
enabled = append(enabled, "slack")
|
||||||
|
}
|
||||||
|
if cfg.Channels.DingTalk.Enabled {
|
||||||
|
enabled = append(enabled, "dingtalk")
|
||||||
|
}
|
||||||
|
if cfg.Channels.LINE.Enabled {
|
||||||
|
enabled = append(enabled, "line")
|
||||||
|
}
|
||||||
|
if cfg.Channels.OneBot.Enabled {
|
||||||
|
enabled = append(enabled, "onebot")
|
||||||
|
}
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
101
pkg/commands/builtin_test.go
Normal file
101
pkg/commands/builtin_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func findDefinitionByName(t *testing.T, defs []Definition, name string) Definition {
|
||||||
|
t.Helper()
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Name == name {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing /%s definition", name)
|
||||||
|
return Definition{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
||||||
|
defs := BuiltinDefinitions(nil)
|
||||||
|
helpDef := findDefinitionByName(t, defs, "help")
|
||||||
|
if helpDef.Handler == nil {
|
||||||
|
t.Fatalf("/help handler should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
err := helpDef.Handler(context.Background(), Request{
|
||||||
|
Text: "/help",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("/help handler error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "/show [model|channel] - Show current configuration") {
|
||||||
|
t.Fatalf("/help reply missing /show usage, got %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "/list [models|channels] - List available options") {
|
||||||
|
t.Fatalf("/help reply missing /list usage, got %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
|
||||||
|
defs := BuiltinDefinitions(&config.Config{})
|
||||||
|
showDef := findDefinitionByName(t, defs, "show")
|
||||||
|
if showDef.Handler == nil {
|
||||||
|
t.Fatalf("/show handler should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []string{"telegram", "whatsapp"}
|
||||||
|
for _, channel := range cases {
|
||||||
|
var reply string
|
||||||
|
err := showDef.Handler(context.Background(), Request{
|
||||||
|
Channel: channel,
|
||||||
|
Text: "/show channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("/show channel handler error on %s: %v", channel, err)
|
||||||
|
}
|
||||||
|
want := "Current Channel: " + channel
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("/show channel reply=%q, want=%q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinListChannels_UsesConfigEnabledChannels(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Channels.Telegram.Enabled = true
|
||||||
|
cfg.Channels.Slack.Enabled = true
|
||||||
|
|
||||||
|
defs := BuiltinDefinitions(cfg)
|
||||||
|
listDef := findDefinitionByName(t, defs, "list")
|
||||||
|
if listDef.Handler == nil {
|
||||||
|
t.Fatalf("/list handler should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
err := listDef.Handler(context.Background(), Request{
|
||||||
|
Text: "/list channels",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("/list channels handler error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "telegram") || !strings.Contains(reply, "slack") {
|
||||||
|
t.Fatalf("/list channels reply=%q, want telegram and slack", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pkg/commands/definition.go
Normal file
16
pkg/commands/definition.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
// Definition is the single-source metadata and behavior contract for a slash command.
|
||||||
|
//
|
||||||
|
// Design notes (phase 1):
|
||||||
|
// - Every channel reads command shape from this type instead of keeping local copies.
|
||||||
|
// - Visibility is global: all definitions are considered available to all channels.
|
||||||
|
// - Platform menu registration (for example Telegram BotCommand) also derives from this
|
||||||
|
// same definition so UI labels and runtime behavior stay aligned.
|
||||||
|
type Definition struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Usage string
|
||||||
|
Aliases []string
|
||||||
|
Handler Handler
|
||||||
|
}
|
||||||
71
pkg/commands/dispatcher.go
Normal file
71
pkg/commands/dispatcher.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler func(ctx context.Context, req Request) error
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Channel string
|
||||||
|
ChatID string
|
||||||
|
SenderID string
|
||||||
|
Text string
|
||||||
|
MessageID string
|
||||||
|
Reply func(text string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandPrefixes = []string{"/", "!"}
|
||||||
|
|
||||||
|
func firstToken(input string) string {
|
||||||
|
parts := strings.Fields(strings.TrimSpace(input))
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
name, ok := trimCommandPrefix(token)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if i := strings.Index(name, "@"); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
76
pkg/commands/executor.go
Normal file
76
pkg/commands/executor.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Outcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OutcomePassthrough means this input should continue through normal agent flow.
|
||||||
|
OutcomePassthrough Outcome = iota
|
||||||
|
// OutcomeHandled means a command handler executed (with or without handler error).
|
||||||
|
OutcomeHandled
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExecuteResult struct {
|
||||||
|
Outcome Outcome
|
||||||
|
Command string
|
||||||
|
Reply string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Executor struct {
|
||||||
|
reg *Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecutor(reg *Registry) *Executor {
|
||||||
|
return &Executor{reg: reg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute implements a two-state command decision:
|
||||||
|
// 1) handled: execute command immediately;
|
||||||
|
// 2) passthrough: not a command or intentionally deferred to agent logic.
|
||||||
|
func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult {
|
||||||
|
cmdName, ok := parseCommandName(req.Text)
|
||||||
|
if !ok {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough}
|
||||||
|
}
|
||||||
|
|
||||||
|
if e == nil || e.reg == nil {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
||||||
|
}
|
||||||
|
|
||||||
|
passthroughCommand := ""
|
||||||
|
|
||||||
|
for _, def := range e.reg.Definitions() {
|
||||||
|
if !matchesCommand(def, cmdName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if passthroughCommand == "" {
|
||||||
|
passthroughCommand = def.Name
|
||||||
|
}
|
||||||
|
if def.Handler == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := def.Handler(ctx, req)
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
|
if passthroughCommand != "" {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: passthroughCommand}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesCommand(def Definition, cmdName string) bool {
|
||||||
|
if normalizeCommandName(def.Name) == cmdName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, alias := range def.Aliases {
|
||||||
|
if normalizeCommandName(alias) == cmdName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
177
pkg/commands/executor_test.go
Normal file
177
pkg/commands/executor_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{{Name: "show"}}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/show"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_UnknownSlashCommand_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{{Name: "show"}}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/unknown"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SupportedCommandWithHandler_ReturnsHandled(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@my_bot"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatalf("expected handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_AliasWithoutHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
Aliases: []string{"display"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/display"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if res.Command != "show" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "show")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_AliasWithHandler_ReturnsHandled(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "clear",
|
||||||
|
Aliases: []string{"reset"},
|
||||||
|
Handler: func(context.Context, Request) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/reset"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if res.Command != "clear" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "clear")
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatalf("expected handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SupportedCommandWithNilHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{Name: "placeholder"},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder list"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if res.Command != "placeholder" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
defs := []Definition{
|
||||||
|
{Name: "placeholder"},
|
||||||
|
{
|
||||||
|
Name: "placeholder",
|
||||||
|
Handler: func(context.Context, Request) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if res.Command != "placeholder" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatalf("expected later handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_HandlerErrorIsPropagated(t *testing.T) {
|
||||||
|
wantErr := errors.New("handler failed")
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "help",
|
||||||
|
Handler: func(context.Context, Request) error {
|
||||||
|
return wantErr
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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 !errors.Is(res.Err, wantErr) {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
55
pkg/commands/registry.go
Normal file
55
pkg/commands/registry.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
defs []Definition
|
||||||
|
index map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry stores the canonical command set used by both dispatch and
|
||||||
|
// optional platform registration adapters.
|
||||||
|
func NewRegistry(defs []Definition) *Registry {
|
||||||
|
stored := make([]Definition, len(defs))
|
||||||
|
copy(stored, defs)
|
||||||
|
|
||||||
|
index := make(map[string]int, len(stored)*2)
|
||||||
|
for i, def := range stored {
|
||||||
|
registerCommandName(index, def.Name, i)
|
||||||
|
for _, alias := range def.Aliases {
|
||||||
|
registerCommandName(index, alias, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Registry{defs: stored, index: index}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definitions returns all registered command definitions.
|
||||||
|
// Command availability is global and no longer channel-scoped.
|
||||||
|
func (r *Registry) Definitions() []Definition {
|
||||||
|
out := make([]Definition, len(r.defs))
|
||||||
|
copy(out, r.defs)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns a command definition by normalized command name or alias.
|
||||||
|
func (r *Registry) Lookup(name string) (Definition, bool) {
|
||||||
|
key := normalizeCommandName(name)
|
||||||
|
if key == "" {
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
idx, ok := r.index[key]
|
||||||
|
if !ok {
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
return r.defs[idx], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCommandName(index map[string]int, name string, defIndex int) {
|
||||||
|
key := normalizeCommandName(name)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := index[key]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
index[key] = defIndex
|
||||||
|
}
|
||||||
49
pkg/commands/registry_test.go
Normal file
49
pkg/commands/registry_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRegistry_Definitions_ReturnsCopy(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{Name: "help", Description: "Show help"},
|
||||||
|
{Name: "admin", Description: "Admin command"},
|
||||||
|
}
|
||||||
|
r := NewRegistry(defs)
|
||||||
|
|
||||||
|
got := r.Definitions()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("definitions len = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
got[0].Name = "mutated"
|
||||||
|
again := r.Definitions()
|
||||||
|
if again[0].Name != "help" {
|
||||||
|
t.Fatalf("registry should not be mutated by caller, got first name %q", again[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Lookup_MatchesByLowercaseNameAndAlias(t *testing.T) {
|
||||||
|
r := NewRegistry([]Definition{
|
||||||
|
{Name: "Help", Aliases: []string{"Assist"}},
|
||||||
|
{Name: "List"},
|
||||||
|
})
|
||||||
|
|
||||||
|
def, ok := r.Lookup("help")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by lowercase name failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
def, ok = r.Lookup("HELP")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by uppercase name failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
def, ok = r.Lookup("assist")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by lowercase alias failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
def, ok = r.Lookup("ASSIST")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by uppercase alias failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
pkg/commands/show_list_handlers_test.go
Normal file
88
pkg/commands/show_list_handlers_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShowListHandlers_ChannelPolicy(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions(cfg)))
|
||||||
|
|
||||||
|
var telegramReply string
|
||||||
|
handled := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "telegram",
|
||||||
|
Text: "/show channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
telegramReply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if handled.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("telegram /show outcome=%v, want=%v", handled.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if telegramReply != "Current Channel: telegram" {
|
||||||
|
t.Fatalf("telegram /show reply=%q, want=%q", telegramReply, "Current Channel: telegram")
|
||||||
|
}
|
||||||
|
|
||||||
|
var whatsappReply string
|
||||||
|
handledWhatsApp := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
Text: "/show channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
whatsappReply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if handledWhatsApp.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("whatsapp /show outcome=%v, want=%v", handledWhatsApp.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if handledWhatsApp.Command != "show" {
|
||||||
|
t.Fatalf("whatsapp /show command=%q, want=%q", handledWhatsApp.Command, "show")
|
||||||
|
}
|
||||||
|
if whatsappReply != "Current Channel: whatsapp" {
|
||||||
|
t.Fatalf("whatsapp /show reply=%q, want=%q", whatsappReply, "Current Channel: whatsapp")
|
||||||
|
}
|
||||||
|
|
||||||
|
passthrough := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
Text: "/foo",
|
||||||
|
})
|
||||||
|
if passthrough.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("whatsapp /foo outcome=%v, want=%v", passthrough.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if passthrough.Command != "foo" {
|
||||||
|
t.Fatalf("whatsapp /foo command=%q, want=%q", passthrough.Command, "foo")
|
||||||
|
}
|
||||||
|
if passthrough.Reply != "" {
|
||||||
|
t.Fatalf("whatsapp /foo reply=%q, want empty", passthrough.Reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Channels.Telegram.Enabled = true
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions(cfg)))
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
Text: "/list channels",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("whatsapp /list outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if res.Command != "list" {
|
||||||
|
t.Fatalf("whatsapp /list command=%q, want=%q", res.Command, "list")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "telegram") {
|
||||||
|
t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue