feat(channels): add Pushover notification channel and tool

Add Pushover as an outbound notification channel with a corresponding
pushover tool that the agent can use to send push notifications.

- Add PushoverConfig to config with app_token and user_key fields
- Add PushoverChannel implementation with message truncation (1024 char limit)
- Add PushoverTool for agent-initiated notifications
- Register Pushover channel in manager and tool in agent loop
- Add SKILL.md documentation and config example
This commit is contained in:
Vernon Stinebaker 2026-02-20 17:40:01 +08:00
parent 36a8a038ee
commit 126d40c19f
8 changed files with 214 additions and 0 deletions

View file

@ -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": {

View file

@ -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)

View file

@ -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),
})

89
pkg/channels/pushover.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -88,6 +88,11 @@ func DefaultConfig() *Config {
GroupTriggerPrefix: []string{},
AllowFrom: FlexibleStringSlice{},
},
Pushover: PushoverConfig{
Enabled: false,
AppToken: "",
UserKey: "",
},
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true},

61
pkg/tools/pushover.go Normal file
View file

@ -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),
}
}

View file

@ -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"
}
}
}
```