diff --git a/config/config.example.json b/config/config.example.json index abc928e92..402d1e443 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -112,6 +112,11 @@ "reconnect_interval": 5, "group_trigger_prefix": [], "allow_from": [] + }, + "pushover": { + "enabled": false, + "app_token": "YOUR_PUSHOVER_APP_TOKEN", + "user_key": "YOUR_PUSHOVER_USER_KEY" } }, "providers": { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e7b48d47a..91a639f42 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -117,6 +117,17 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A }) agent.Tools.Register(messageTool) + // Pushover tool - send push notifications + pushoverTool := tools.NewPushoverTool() + pushoverTool.SetPushoverCallback(func(message string) error { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: "pushover", + Content: message, + }) + return nil + }) + agent.Tools.Register(pushoverTool) + // Spawn tool with allowlist checker subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 7f6abc4cb..47bd83621 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -176,6 +176,19 @@ func (m *Manager) initChannels() error { } } + if m.config.Channels.Pushover.Enabled && m.config.Channels.Pushover.AppToken != "" && m.config.Channels.Pushover.UserKey != "" { + logger.DebugC("channels", "Attempting to initialize Pushover channel") + pushover, err := NewPushoverChannel(m.config.Channels.Pushover, m.bus) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize Pushover channel", map[string]interface{}{ + "error": err.Error(), + }) + } else { + m.channels["pushover"] = pushover + logger.InfoC("channels", "Pushover channel enabled successfully") + } + } + logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{ "enabled_channels": len(m.channels), }) diff --git a/pkg/channels/pushover.go b/pkg/channels/pushover.go new file mode 100644 index 000000000..231fd616f --- /dev/null +++ b/pkg/channels/pushover.go @@ -0,0 +1,89 @@ +package channels + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type PushoverChannel struct { + *BaseChannel + config config.PushoverConfig + client *http.Client +} + +func NewPushoverChannel(cfg config.PushoverConfig, bus *bus.MessageBus) (*PushoverChannel, error) { + base := NewBaseChannel("pushover", cfg, bus, nil) + + return &PushoverChannel{ + BaseChannel: base, + config: cfg, + client: &http.Client{}, + }, nil +} + +func (c *PushoverChannel) Name() string { + return "pushover" +} + +func (c *PushoverChannel) Start(ctx context.Context) error { + logger.InfoC("pushover", "Starting Pushover channel") + c.setRunning(true) + return nil +} + +func (c *PushoverChannel) Stop(ctx context.Context) error { + logger.InfoC("pushover", "Stopping Pushover channel") + c.setRunning(false) + return nil +} + +func (c *PushoverChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("pushover channel not running") + } + + if c.config.AppToken == "" || c.config.UserKey == "" { + return fmt.Errorf("pushover app_token and user_key are required") + } + + data := url.Values{} + data.Set("token", c.config.AppToken) + data.Set("user", c.config.UserKey) + + // Truncate message if too long (Pushover limit is 1024 chars) + message := msg.Content + if len(message) > 1024 { + message = message[:1021] + "..." + } + data.Set("message", message) + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.pushover.net/1/messages.json", strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("failed to send notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("pushover API returned status %d", resp.StatusCode) + } + + logger.DebugCF("pushover", "Notification sent", map[string]any{ + "content_length": len(msg.Content), + }) + + return nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 0d41796a4..6361498bd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -190,6 +190,7 @@ type ChannelsConfig struct { Slack SlackConfig `json:"slack"` LINE LINEConfig `json:"line"` OneBot OneBotConfig `json:"onebot"` + Pushover PushoverConfig `json:"pushover"` } type WhatsAppConfig struct { @@ -267,6 +268,12 @@ type OneBotConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` } +type PushoverConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PUSHOVER_ENABLED"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_PUSHOVER_APP_TOKEN"` + UserKey string `json:"user_key" env:"PICOCLAW_CHANNELS_PUSHOVER_USER_KEY"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 70ba67adf..a326ff029 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -88,6 +88,11 @@ func DefaultConfig() *Config { GroupTriggerPrefix: []string{}, AllowFrom: FlexibleStringSlice{}, }, + Pushover: PushoverConfig{ + Enabled: false, + AppToken: "", + UserKey: "", + }, }, Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{WebSearch: true}, diff --git a/pkg/tools/pushover.go b/pkg/tools/pushover.go new file mode 100644 index 000000000..cc4e4bb7f --- /dev/null +++ b/pkg/tools/pushover.go @@ -0,0 +1,61 @@ +package tools + +import ( + "context" + "fmt" +) + +type PushoverTool struct { + pushoverCallback func(message string) error +} + +func NewPushoverTool() *PushoverTool { + return &PushoverTool{} +} + +func (t *PushoverTool) Name() string { + return "pushover" +} + +func (t *PushoverTool) Description() string { + return "Send a push notification to your phone via Pushover. Use this when you need to notify yourself of something important." +} + +func (t *PushoverTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "message": map[string]interface{}{ + "type": "string", + "description": "The notification message to send to your phone", + }, + }, + "required": []string{"message"}, + } +} + +func (t *PushoverTool) SetPushoverCallback(callback func(message string) error) { + t.pushoverCallback = callback +} + +func (t *PushoverTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + message, ok := args["message"].(string) + if !ok { + return &ToolResult{ForLLM: "message is required", IsError: true} + } + + if t.pushoverCallback == nil { + return &ToolResult{ForLLM: "Pushover not configured", IsError: true} + } + + if err := t.pushoverCallback(message); err != nil { + return &ToolResult{ + ForLLM: fmt.Sprintf("failed to send pushover notification: %v", err), + IsError: true, + } + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("Push notification sent: %s", message), + } +} diff --git a/workspace/skills/pushover/SKILL.md b/workspace/skills/pushover/SKILL.md new file mode 100644 index 000000000..7769f4eac --- /dev/null +++ b/workspace/skills/pushover/SKILL.md @@ -0,0 +1,23 @@ +--- +name: pushover +description: Send notifications via Pushover. +metadata: {"picoclaw":{"emoji":"🔔","requires":{"config":["channels.pushover"]}}} +--- + +# Pushover + +Pushover is a notification service. Use the pushover tool to send push notifications to your devices. + +## Configuration + +```json +{ + "channels": { + "pushover": { + "enabled": true, + "app_token": "your-app-token", + "user_key": "your-user-key" + } + } +} +```