docs(plan): add cross-channel command registry implementation plan

This commit is contained in:
mingmxren 2026-03-01 02:36:10 +08:00
parent 414877da9d
commit 535e43b612

View file

@ -0,0 +1,556 @@
# Cross-Channel Command Registry Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a single-source command system that auto-syncs command handling and Telegram menu registration at startup, while keeping registration optional per channel (e.g., WhatsApp parsing only).
**Architecture:** Introduce a shared `pkg/commands` domain for command definitions + dispatcher, then adapt channels via optional capabilities (`CommandRegistrarCapable`) without changing the core `Channel` interface. Telegram registers menu commands asynchronously with retry and never blocks startup; WhatsApp reuses dispatcher for text command parsing.
**Tech Stack:** Go, `testing` package, existing channel architecture (`pkg/channels/*`), Telegram telego SDK.
---
**Required skills during execution:** @test-driven-development, @verification-before-completion, @requesting-code-review
### Task 1: Create Command Domain (Definition + Registry)
**Files:**
- Create: `pkg/commands/definition.go`
- Create: `pkg/commands/registry.go`
- Test: `pkg/commands/registry_test.go`
**Step 1: Write the failing test**
```go
package commands
import "testing"
func TestRegistry_FilterByChannel(t *testing.T) {
defs := []Definition{
{Name: "help", Description: "Show help"},
{Name: "admin", Description: "Admin only", Channels: []string{"telegram"}},
}
r := NewRegistry(defs)
gotTG := r.ForChannel("telegram")
if len(gotTG) != 2 {
t.Fatalf("telegram defs = %d, want 2", len(gotTG))
}
gotWA := r.ForChannel("whatsapp")
if len(gotWA) != 1 || gotWA[0].Name != "help" {
t.Fatalf("whatsapp defs = %+v, want only help", gotWA)
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./pkg/commands -run TestRegistry_FilterByChannel -v`
Expected: FAIL with missing package/files.
**Step 3: Write minimal implementation**
```go
package commands
type Definition struct {
Name string
Description string
Usage string
Aliases []string
Channels []string
}
type Registry struct {
defs []Definition
}
func NewRegistry(defs []Definition) *Registry { return &Registry{defs: defs} }
func (r *Registry) ForChannel(channel string) []Definition {
out := make([]Definition, 0, len(r.defs))
for _, d := range r.defs {
if len(d.Channels) == 0 {
out = append(out, d)
continue
}
for _, ch := range d.Channels {
if ch == channel {
out = append(out, d)
break
}
}
}
return out
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./pkg/commands -run TestRegistry_FilterByChannel -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add pkg/commands/definition.go pkg/commands/registry.go pkg/commands/registry_test.go
git commit -m "feat(commands): add command definitions and channel-aware registry"
```
### Task 2: Add Dispatcher and Parse Result Contract
**Files:**
- Create: `pkg/commands/dispatcher.go`
- Test: `pkg/commands/dispatcher_test.go`
**Step 1: Write the failing test**
```go
package commands
import (
"context"
"testing"
)
func TestDispatcher_MatchSlashCommand(t *testing.T) {
called := false
defs := []Definition{
{
Name: "help",
Handler: func(context.Context, Request) error {
called = true
return nil
},
},
}
d := NewDispatcher(NewRegistry(defs))
res := d.Dispatch(context.Background(), Request{
Channel: "telegram",
Text: "/help",
})
if !res.Matched || !called || res.Err != nil {
t.Fatalf("dispatch result = %+v, called=%v", res, called)
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./pkg/commands -run TestDispatcher_MatchSlashCommand -v`
Expected: FAIL with undefined types (`Handler`, `Request`, `Dispatcher`).
**Step 3: Write minimal implementation**
```go
type Handler func(ctx context.Context, req Request) error
type Request struct {
Channel string
ChatID string
SenderID string
Text string
MessageID string
}
type Result struct {
Matched bool
Command string
Err error
}
type Dispatcher struct {
reg *Registry
}
func NewDispatcher(reg *Registry) *Dispatcher { return &Dispatcher{reg: reg} }
func (d *Dispatcher) Dispatch(ctx context.Context, req Request) Result {
cmd := strings.TrimSpace(strings.TrimPrefix(strings.SplitN(req.Text, " ", 2)[0], "/"))
for _, def := range d.reg.ForChannel(req.Channel) {
if def.Name == cmd {
err := def.Handler(ctx, req)
return Result{Matched: true, Command: def.Name, Err: err}
}
}
return Result{Matched: false}
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./pkg/commands -run TestDispatcher_MatchSlashCommand -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add pkg/commands/dispatcher.go pkg/commands/dispatcher_test.go pkg/commands/definition.go
git commit -m "feat(commands): add dispatcher and request/result contracts"
```
### Task 3: Move Telegram Built-in Commands to Single Source
**Files:**
- Create: `pkg/commands/builtin.go`
- Modify: `pkg/channels/telegram/telegram_commands.go`
- Test: `pkg/commands/builtin_test.go`
**Step 1: Write the failing test**
```go
package commands
import "testing"
func TestBuiltinDefinitions_ContainsTelegramDefaults(t *testing.T) {
defs := BuiltinDefinitions(nil, nil)
names := map[string]bool{}
for _, d := range defs {
names[d.Name] = true
}
for _, want := range []string{"help", "start", "show", "list"} {
if !names[want] {
t.Fatalf("missing command %q", want)
}
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./pkg/commands -run TestBuiltinDefinitions_ContainsTelegramDefaults -v`
Expected: FAIL with missing `BuiltinDefinitions`.
**Step 3: Write minimal implementation**
```go
func BuiltinDefinitions(bot TelegramReplyer, cfg *config.Config) []Definition {
return []Definition{
{Name: "start", Description: "Start the bot", Usage: "/start", Handler: NewStartHandler(bot)},
{Name: "help", Description: "Show this help message", Usage: "/help", Handler: NewHelpHandler(bot, nil)},
{Name: "show", Description: "Show current configuration", Usage: "/show [model|channel]", Handler: NewShowHandler(bot, cfg)},
{Name: "list", Description: "List available options", Usage: "/list [models|channels]", Handler: NewListHandler(bot, cfg)},
}
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./pkg/commands -run TestBuiltinDefinitions_ContainsTelegramDefaults -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add pkg/commands/builtin.go pkg/commands/builtin_test.go pkg/channels/telegram/telegram_commands.go
git commit -m "refactor(commands): centralize built-in command definitions"
```
### Task 4: Add Channel Capability Interface for Platform Registration
**Files:**
- Modify: `pkg/channels/interfaces.go`
- Create: `pkg/channels/interfaces_command_test.go`
**Step 1: Write the failing test**
```go
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{}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./pkg/channels -run TestCommandRegistrarCapable_Compiles -v`
Expected: FAIL with undefined `CommandRegistrarCapable`.
**Step 3: Write minimal implementation**
```go
type CommandRegistrarCapable interface {
RegisterCommands(ctx context.Context, defs []commands.Definition) error
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./pkg/channels -run TestCommandRegistrarCapable_Compiles -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add pkg/channels/interfaces.go pkg/channels/interfaces_command_test.go
git commit -m "feat(channels): add optional command registrar capability interface"
```
### Task 5: Integrate Telegram Startup Registration (Non-Blocking + Retry)
**Files:**
- Modify: `pkg/channels/telegram/telegram.go`
- Create: `pkg/channels/telegram/command_registration.go`
- Test: `pkg/channels/telegram/command_registration_test.go`
**Step 1: Write the failing test**
```go
package telegram
import (
"context"
"errors"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/commands"
)
func TestRegisterCommandsAsync_DoesNotBlockStart(t *testing.T) {
ch := &TelegramChannel{}
started := make(chan struct{})
ch.registerFunc = func(context.Context, []commands.Definition) error {
close(started)
return errors.New("temp fail")
}
ch.startCommandRegistration(context.Background(), []commands.Definition{{Name: "help"}})
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("registration did not start asynchronously")
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./pkg/channels/telegram -run TestRegisterCommandsAsync_DoesNotBlockStart -v`
Expected: FAIL with undefined `registerFunc` / `startCommandRegistration`.
**Step 3: Write minimal implementation**
```go
func (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands.Definition) error {
cmds := make([]telego.BotCommand, 0, len(defs))
for _, d := range defs {
cmds = append(cmds, telego.BotCommand{Command: d.Name, Description: d.Description})
}
return c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{Commands: cmds})
}
func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []commands.Definition) {
go retryWithBackoff(ctx, func(ctx context.Context) error {
return c.RegisterCommands(ctx, defs)
})
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./pkg/channels/telegram -run TestRegisterCommandsAsync_DoesNotBlockStart -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add pkg/channels/telegram/telegram.go pkg/channels/telegram/command_registration.go pkg/channels/telegram/command_registration_test.go
git commit -m "feat(telegram): register bot commands asynchronously with retry on startup"
```
### Task 6: Route Telegram Incoming Messages Through Shared Dispatcher
**Files:**
- Modify: `pkg/channels/telegram/telegram.go`
- Modify: `pkg/channels/telegram/telegram_commands.go`
- Test: `pkg/channels/telegram/telegram_dispatch_test.go`
**Step 1: Write the failing test**
```go
package telegram
import (
"context"
"testing"
"github.com/sipeed/picoclaw/pkg/commands"
)
func TestHandleCommand_UsesDispatcher(t *testing.T) {
ch := &TelegramChannel{}
called := false
ch.dispatcher = &commands.DispatcherStub{
OnDispatch: func(context.Context, commands.Request) commands.Result {
called = true
return commands.Result{Matched: true}
},
}
handled := ch.dispatchCommand(context.Background(), "/help", "1", "2", "3")
if !handled || !called {
t.Fatalf("handled=%v called=%v", handled, called)
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./pkg/channels/telegram -run TestHandleCommand_UsesDispatcher -v`
Expected: FAIL with missing `dispatcher` / `dispatchCommand`.
**Step 3: Write minimal implementation**
```go
func (c *TelegramChannel) dispatchCommand(
ctx context.Context,
text, chatID, senderID, messageID string,
) bool {
res := c.dispatcher.Dispatch(ctx, commands.Request{
Channel: "telegram", Text: text, ChatID: chatID, SenderID: senderID, MessageID: messageID,
})
return res.Matched
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./pkg/channels/telegram -run TestHandleCommand_UsesDispatcher -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add pkg/channels/telegram/telegram.go pkg/channels/telegram/telegram_commands.go pkg/channels/telegram/telegram_dispatch_test.go
git commit -m "refactor(telegram): use shared command dispatcher for incoming commands"
```
### Task 7: Add WhatsApp Command Parsing via Shared Dispatcher
**Files:**
- Modify: `pkg/channels/whatsapp/whatsapp.go`
- Modify: `pkg/channels/whatsapp_native/whatsapp_native.go`
- Test: `pkg/channels/whatsapp/whatsapp_command_test.go`
- Test: `pkg/channels/whatsapp_native/whatsapp_command_test.go`
**Step 1: Write the failing test**
```go
package whatsapp
import (
"context"
"testing"
"github.com/sipeed/picoclaw/pkg/commands"
)
func TestIncomingMessage_CommandHandledByDispatcher(t *testing.T) {
ch := &WhatsAppChannel{}
called := false
ch.dispatcher = commands.DispatchFunc(func(context.Context, commands.Request) commands.Result {
called = true
return commands.Result{Matched: true}
})
handled := ch.tryHandleCommand(context.Background(), "/help", "chat1", "user1", "mid1")
if !handled || !called {
t.Fatalf("handled=%v called=%v", handled, called)
}
}
```
**Step 2: Run test to verify it fails**
Run: `go test ./pkg/channels/whatsapp -run TestIncomingMessage_CommandHandledByDispatcher -v`
Expected: FAIL with undefined dispatcher/handler method.
**Step 3: Write minimal implementation**
```go
func (c *WhatsAppChannel) tryHandleCommand(ctx context.Context, text, chatID, senderID, messageID string) bool {
if c.dispatcher == nil {
return false
}
res := c.dispatcher.Dispatch(ctx, commands.Request{
Channel: "whatsapp", Text: text, ChatID: chatID, SenderID: senderID, MessageID: messageID,
})
return res.Matched
}
```
**Step 4: Run test to verify it passes**
Run: `go test ./pkg/channels/whatsapp ./pkg/channels/whatsapp_native -run CommandHandledByDispatcher -v`
Expected: PASS.
**Step 5: Commit**
```bash
git add pkg/channels/whatsapp/whatsapp.go pkg/channels/whatsapp_native/whatsapp_native.go pkg/channels/whatsapp/whatsapp_command_test.go pkg/channels/whatsapp_native/whatsapp_command_test.go
git commit -m "feat(whatsapp): reuse shared dispatcher for text commands"
```
### Task 8: End-to-End Verification and Docs Update
**Files:**
- Modify: `README.md`
- Modify: `README.zh.md`
- Modify: `config/config.example.json` (if command-related docs are added)
**Step 1: Write the failing test (integration smoke)**
```go
// pkg/channels/telegram/command_integration_test.go
func TestStart_RegistrationFailureDoesNotStopChannel(t *testing.T) {
// Build channel with mocked register func returning error,
// assert Start() returns nil and channel remains running.
}
```
**Step 2: Run tests to verify current gaps**
Run: `go test ./pkg/channels/telegram -run TestStart_RegistrationFailureDoesNotStopChannel -v`
Expected: FAIL before implementation is complete.
**Step 3: Finish minimal missing implementation + docs**
```markdown
- Add a section: "Command Registry"
- Explain: one-source definitions, Telegram auto registration, WhatsApp parsing-only behavior.
```
**Step 4: Run full verification**
Run: `go test ./pkg/commands ./pkg/channels/... ./pkg/config/... -count=1`
Expected: PASS.
Run: `go test ./... -count=1`
Expected: PASS (or document known unrelated failures with logs).
**Step 5: Commit**
```bash
git add README.md README.zh.md config/config.example.json pkg/commands pkg/channels
git commit -m "docs(channels): document single-source command registry behavior"
```