Merge branch 'sipeed:main' into fix/inbound-dedup-messageid

This commit is contained in:
mosir 2026-03-01 13:24:54 +08:00 committed by GitHub
commit 8072a0c299
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 53 additions and 76 deletions

View file

@ -71,7 +71,7 @@ func NewSkillsCommand() *cobra.Command {
newInstallBuiltinCommand(workspaceFn), newInstallBuiltinCommand(workspaceFn),
newListBuiltinCommand(), newListBuiltinCommand(),
newRemoveCommand(installerFn), newRemoveCommand(installerFn),
newSearchCommand(installerFn), newSearchCommand(),
newShowCommand(loaderFn), newShowCommand(loaderFn),
) )

View file

@ -15,6 +15,8 @@ import (
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
const skillsSearchMaxResults = 20
func skillsListCmd(loader *skills.SkillsLoader) { func skillsListCmd(loader *skills.SkillsLoader) {
allSkills := loader.ListSkills() allSkills := loader.ListSkills()
@ -215,34 +217,43 @@ func skillsListBuiltinCmd() {
} }
} }
func skillsSearchCmd(installer *skills.SkillInstaller) { func skillsSearchCmd(query string) {
fmt.Println("Searching for available skills...") fmt.Println("Searching for available skills...")
cfg, err := internal.LoadConfig()
if err != nil {
fmt.Printf("✗ Failed to load config: %v\n", err)
return
}
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
availableSkills, err := installer.ListAvailableSkills(ctx) results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults)
if err != nil { if err != nil {
fmt.Printf("✗ Failed to fetch skills list: %v\n", err) fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
return return
} }
if len(availableSkills) == 0 { if len(results) == 0 {
fmt.Println("No skills available.") fmt.Println("No skills available.")
return return
} }
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills)) fmt.Printf("\nAvailable Skills (%d):\n", len(results))
fmt.Println("--------------------") fmt.Println("--------------------")
for _, skill := range availableSkills { for _, result := range results {
fmt.Printf(" 📦 %s\n", skill.Name) fmt.Printf(" 📦 %s\n", result.DisplayName)
fmt.Printf(" %s\n", skill.Description) fmt.Printf(" %s\n", result.Summary)
fmt.Printf(" Repo: %s\n", skill.Repository) fmt.Printf(" Slug: %s\n", result.Slug)
if skill.Author != "" { fmt.Printf(" Registry: %s\n", result.RegistryName)
fmt.Printf(" Author: %s\n", skill.Author) if result.Version != "" {
} fmt.Printf(" Version: %s\n", result.Version)
if len(skill.Tags) > 0 {
fmt.Printf(" Tags: %v\n", skill.Tags)
} }
fmt.Println() fmt.Println()
} }

View file

@ -2,20 +2,19 @@ package skills
import ( import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/skills"
) )
func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command { func newSearchCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "search", Use: "search [query]",
Short: "Search available skills", Short: "Search available skills",
RunE: func(_ *cobra.Command, _ []string) error { Args: cobra.MaximumNArgs(1),
installer, err := installerFn() RunE: func(_ *cobra.Command, args []string) error {
if err != nil { query := ""
return err if len(args) == 1 {
query = args[0]
} }
skillsSearchCmd(installer) skillsSearchCmd(query)
return nil return nil
}, },
} }

View file

@ -8,11 +8,11 @@ import (
) )
func TestNewSearchSubcommand(t *testing.T) { func TestNewSearchSubcommand(t *testing.T) {
cmd := newSearchCommand(nil) cmd := newSearchCommand()
require.NotNil(t, cmd) require.NotNil(t, cmd)
assert.Equal(t, "search", cmd.Use) assert.Equal(t, "search [query]", cmd.Use)
assert.Equal(t, "Search available skills", cmd.Short) assert.Equal(t, "Search available skills", cmd.Short)
assert.Nil(t, cmd.Run) assert.Nil(t, cmd.Run)

View file

@ -148,6 +148,10 @@ func (c *WeComAppChannel) Name() string {
func (c *WeComAppChannel) Start(ctx context.Context) error { func (c *WeComAppChannel) Start(ctx context.Context) error {
logger.InfoC("wecom_app", "Starting WeCom App channel...") logger.InfoC("wecom_app", "Starting WeCom App channel...")
// Cancel the context created in the constructor to avoid a resource leak.
if c.cancel != nil {
c.cancel()
}
c.ctx, c.cancel = context.WithCancel(ctx) c.ctx, c.cancel = context.WithCancel(ctx)
// Get initial access token // Get initial access token
@ -601,14 +605,14 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
return return
} }
c.processedMsgs[msgID] = true c.processedMsgs[msgID] = true
c.msgMu.Unlock() // Clean up old messages while still holding the lock to avoid a data race
// on len(). Reset the map but re-insert the current msgID so it remains
// Clean up old messages periodically (keep last 1000) // deduplicated.
if len(c.processedMsgs) > 1000 { if len(c.processedMsgs) > 1000 {
c.msgMu.Lock()
c.processedMsgs = make(map[string]bool) c.processedMsgs = make(map[string]bool)
c.msgMu.Unlock() c.processedMsgs[msgID] = true
} }
c.msgMu.Unlock()
senderID := msg.FromUserName senderID := msg.FromUserName
chatID := senderID // WeCom App uses user ID as chat ID for direct messages chatID := senderID // WeCom App uses user ID as chat ID for direct messages

View file

@ -112,6 +112,10 @@ func (c *WeComBotChannel) Name() string {
func (c *WeComBotChannel) Start(ctx context.Context) error { func (c *WeComBotChannel) Start(ctx context.Context) error {
logger.InfoC("wecom", "Starting WeCom Bot channel...") logger.InfoC("wecom", "Starting WeCom Bot channel...")
// Cancel the context created in the constructor to avoid a resource leak.
if c.cancel != nil {
c.cancel()
}
c.ctx, c.cancel = context.WithCancel(ctx) c.ctx, c.cancel = context.WithCancel(ctx)
c.SetRunning(true) c.SetRunning(true)
@ -326,14 +330,14 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
return return
} }
c.processedMsgs[msgID] = true c.processedMsgs[msgID] = true
c.msgMu.Unlock() // Clean up old messages while still holding the lock to avoid a data race
// on len(). Reset the map but re-insert the current msgID so it remains
// Clean up old messages periodically (keep last 1000) // deduplicated.
if len(c.processedMsgs) > 1000 { if len(c.processedMsgs) > 1000 {
c.msgMu.Lock()
c.processedMsgs = make(map[string]bool) c.processedMsgs = make(map[string]bool)
c.msgMu.Unlock() c.processedMsgs[msgID] = true
} }
c.msgMu.Unlock()
senderID := msg.From.UserID senderID := msg.From.UserID

View file

@ -2,7 +2,6 @@ package skills
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -18,14 +17,6 @@ type SkillInstaller struct {
workspace string workspace string
} }
type AvailableSkill struct {
Name string `json:"name"`
Repository string `json:"repository"`
Description string `json:"description"`
Author string `json:"author"`
Tags []string `json:"tags"`
}
func NewSkillInstaller(workspace string) *SkillInstaller { func NewSkillInstaller(workspace string) *SkillInstaller {
return &SkillInstaller{ return &SkillInstaller{
workspace: workspace, workspace: workspace,
@ -89,35 +80,3 @@ func (si *SkillInstaller) Uninstall(skillName string) error {
return nil return nil
} }
func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableSkill, error) {
url := "https://raw.githubusercontent.com/sipeed/picoclaw-skills/main/skills.json"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := utils.DoRequestWithRetry(client, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to fetch skills list: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var skills []AvailableSkill
if err := json.Unmarshal(body, &skills); err != nil {
return nil, fmt.Errorf("failed to parse skills list: %w", err)
}
return skills, nil
}