feat: integrate Google services and WhatsApp QR command

This commit is contained in:
Bernardo 2026-03-10 15:26:49 +01:00
parent d2366b4785
commit 5edf1cb27e
13 changed files with 338 additions and 1 deletions

10
debug_home.go Normal file
View file

@ -0,0 +1,10 @@
package main
import (
"fmt"
"os"
)
func main() {
home, _ := os.UserHomeDir()
fmt.Printf("Home: %s\n", home)
fmt.Printf("PICOCLAW_HOME: %s\n", os.Getenv("PICOCLAW_HOME"))
}

View file

@ -79,6 +79,9 @@ func NewAgentInstance(
if cfg.Tools.IsToolEnabled("list_dir") { if cfg.Tools.IsToolEnabled("list_dir") {
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
} }
if cfg.Tools.IsToolEnabled("google") {
toolsRegistry.Register(&tools.GoogleTool{})
}
if cfg.Tools.IsToolEnabled("exec") { if cfg.Tools.IsToolEnabled("exec") {
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
if err != nil { if err != nil {

View file

@ -1833,6 +1833,12 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt
} }
return nil return nil
}, },
GetChannel: func(name string) (any, bool) {
if al.channelManager == nil {
return nil, false
}
return al.channelManager.GetChannel(name)
},
} }
if agent != nil { if agent != nil {
rt.GetModelInfo = func() (string, string) { rt.GetModelInfo = func() (string, string) {

View file

@ -53,7 +53,7 @@ func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
TokenURL: "https://oauth2.googleapis.com/token", TokenURL: "https://oauth2.googleapis.com/token",
ClientID: clientID, ClientID: clientID,
ClientSecret: clientSecret, ClientSecret: clientSecret,
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs", Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar.readonly",
Port: 51121, Port: 51121,
} }
} }

View file

@ -50,3 +50,9 @@ type PlaceholderRecorder interface {
type CommandRegistrarCapable interface { type CommandRegistrarCapable interface {
RegisterCommands(ctx context.Context, defs []commands.Definition) error RegisterCommands(ctx context.Context, defs []commands.Definition) error
} }
// QRProvider is implemented by channels that can provide a QR code string
// (e.g. for WhatsApp pairing).
type QRProvider interface {
GetLastQR() string
}

View file

@ -59,6 +59,7 @@ type WhatsAppNativeChannel struct {
reconnecting bool reconnecting bool
stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls
wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect) wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect)
lastQR string // stores the last QR code string for retrieval
} }
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
@ -187,6 +188,9 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
} }
if evt.Event == "code" { if evt.Event == "code" {
logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil) logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
c.mu.Lock()
c.lastQR = evt.Code
c.mu.Unlock()
qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
Level: qrterminal.L, Level: qrterminal.L,
Writer: os.Stdout, Writer: os.Stdout,
@ -446,3 +450,9 @@ func parseJID(s string) (types.JID, error) {
} }
return types.NewJID(s, types.DefaultUserServer), nil return types.NewJID(s, types.DefaultUserServer), nil
} }
func (c *WhatsAppNativeChannel) GetLastQR() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.lastQR
}

View file

@ -13,5 +13,6 @@ func BuiltinDefinitions() []Definition {
switchCommand(), switchCommand(),
checkCommand(), checkCommand(),
clearCommand(), clearCommand(),
whatsappCommand(),
} }
} }

View file

@ -0,0 +1,51 @@
package commands
import (
"context"
"fmt"
"github.com/sipeed/picoclaw/pkg/channels"
)
func whatsappCommand() Definition {
return Definition{
Name: "whatsapp",
Description: "WhatsApp management commands",
Subcommands: []Definition{
{
Name: "qr",
Description: "Get the latest WhatsApp pairing QR code",
Handler: func(ctx context.Context, req Request, rt Runtime) ExecuteResult {
ch, ok := rt.GetChannel("whatsapp_native")
if !ok {
return ExecuteResult{
Outcome: OutcomeHandled,
Err: fmt.Errorf("whatsapp_native channel is not enabled"),
}
}
qrProvider, ok := ch.(channels.QRProvider)
if !ok {
return ExecuteResult{
Outcome: OutcomeHandled,
Err: fmt.Errorf("whatsapp_native channel does not support QR retrieval"),
}
}
qr := qrProvider.GetLastQR()
if qr == "" {
return ExecuteResult{
Outcome: OutcomeHandled,
Err: fmt.Errorf("no QR code available yet. please wait for the channel to initialize"),
}
}
// For now, return the QR code string.
// Optimization: In the future, we can return an image reference.
_ = req.Reply(fmt.Sprintf("Scan this QR code string (or wait for image support): %s", qr))
return ExecuteResult{Outcome: OutcomeHandled}
},
},
},
}
}

View file

@ -14,4 +14,5 @@ type Runtime struct {
SwitchModel func(value string) (oldModel string, err error) SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error SwitchChannel func(value string) error
ClearHistory func() error ClearHistory func() error
GetChannel func(name string) (any, bool)
} }

View file

@ -689,6 +689,7 @@ type ToolsConfig struct {
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
Google ToolConfig `json:"google" envPrefix:"PICOCLAW_TOOLS_GOOGLE_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
@ -953,6 +954,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.I2C.Enabled return t.I2C.Enabled
case "install_skill": case "install_skill":
return t.InstallSkill.Enabled return t.InstallSkill.Enabled
case "google":
return t.Google.Enabled
case "list_dir": case "list_dir":
return t.ListDir.Enabled return t.ListDir.Enabled
case "message": case "message":

View file

@ -474,6 +474,9 @@ func DefaultConfig() *Config {
InstallSkill: ToolConfig{ InstallSkill: ToolConfig{
Enabled: true, Enabled: true,
}, },
Google: ToolConfig{
Enabled: true,
},
ListDir: ToolConfig{ ListDir: ToolConfig{
Enabled: true, Enabled: true,
}, },

205
pkg/tools/google.go Normal file
View file

@ -0,0 +1,205 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/logger"
)
type GoogleTool struct{}
func (t *GoogleTool) Name() string {
return "google"
}
func (t *GoogleTool) Description() string {
return "Access Google services like Gmail and Calendar. Actions: 'list_emails', 'list_events'. Use 'list_emails' to get recent messages (subject, snippet). Use 'list_events' to get upcoming calendar events (summary, start/end time)."
}
func (t *GoogleTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"list_emails", "list_events"},
"description": "The service action to perform.",
},
"count": map[string]any{
"type": "integer",
"default": 10,
"description": "Number of items to retrieve.",
},
},
"required": []string{"action"},
}
}
func (t *GoogleTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, _ := args["action"].(string)
count := 10
if c, ok := args["count"].(float64); ok {
count = int(c)
}
cred, err := auth.GetCredential("google-antigravity")
if err != nil || cred == nil {
return ErrorResult("Google account not linked. User must authenticate via 'google' provider first.")
}
// Automatic refresh if needed
if cred.NeedsRefresh() {
logger.InfoC("tools", "Refreshing Google access token")
newCred, err := auth.RefreshAccessToken(cred, auth.GoogleAntigravityOAuthConfig())
if err != nil {
return ErrorResult(fmt.Sprintf("Failed to refresh Google token: %v", err))
}
cred = newCred
_ = auth.SetCredential("google-antigravity", cred)
}
switch action {
case "list_emails":
return t.listEmails(ctx, cred, count)
case "list_events":
return t.listEvents(ctx, cred, count)
default:
return ErrorResult("Unknown action")
}
}
func (t *GoogleTool) listEmails(ctx context.Context, cred *auth.AuthCredential, maxResults int) *ToolResult {
url := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=%d", maxResults)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "Bearer "+cred.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("API request failed: %v", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return ErrorResult(fmt.Sprintf("Gmail API error (%d): %s", resp.StatusCode, string(body)))
}
var listResp struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return ErrorResult(fmt.Sprintf("Failed to decode Gmail list: %v", err))
}
var emails []string
for _, m := range listResp.Messages {
msgURL := "https://gmail.googleapis.com/gmail/v1/users/me/messages/" + m.ID
mReq, _ := http.NewRequestWithContext(ctx, "GET", msgURL, nil)
mReq.Header.Set("Authorization", "Bearer "+cred.AccessToken)
mResp, err := http.DefaultClient.Do(mReq)
if err != nil {
continue
}
var msg struct {
Snippet string `json:"snippet"`
Payload struct {
Headers []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"headers"`
} `json:"payload"`
}
_ = json.NewDecoder(mResp.Body).Decode(&msg)
mResp.Body.Close()
subject := "No Subject"
from := "Unknown"
for _, h := range msg.Payload.Headers {
if h.Name == "Subject" {
subject = h.Value
} else if h.Name == "From" {
from = h.Value
}
}
emails = append(emails, fmt.Sprintf("- From: %s\n Subject: %s\n Snippet: %s", from, subject, msg.Snippet))
}
if len(emails) == 0 {
return SilentResult("No messages found.")
}
return SilentResult(fmt.Sprintf("Recent Emails:\n%s", join(emails, "\n\n")))
}
func (t *GoogleTool) listEvents(ctx context.Context, cred *auth.AuthCredential, maxResults int) *ToolResult {
now := time.Now().Format(time.RFC3339)
url := fmt.Sprintf("https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=%s&maxResults=%d&singleEvents=true&orderBy=startTime", now, maxResults)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "Bearer "+cred.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("API request failed: %v", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return ErrorResult(fmt.Sprintf("Calendar API error (%d): %s", resp.StatusCode, string(body)))
}
var eventList struct {
Items []struct {
Summary string `json:"summary"`
Start struct {
DateTime string `json:"dateTime"`
Date string `json:"date"`
} `json:"start"`
End struct {
DateTime string `json:"dateTime"`
Date string `json:"date"`
} `json:"end"`
} `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&eventList); err != nil {
return ErrorResult(fmt.Sprintf("Failed to decode Calendar events: %v", err))
}
var events []string
for _, it := range eventList.Items {
start := it.Start.DateTime
if start == "" {
start = it.Start.Date
}
end := it.End.DateTime
if end == "" {
end = it.End.Date
}
events = append(events, fmt.Sprintf("- Event: %s\n Start: %s\n End: %s", it.Summary, start, end))
}
if len(events) == 0 {
return SilentResult("No upcoming events found.")
}
return SilentResult(fmt.Sprintf("Upcoming Calendar Events:\n%s", join(events, "\n")))
}
func join(s []string, sep string) string {
res := ""
for i, v := range s {
if i > 0 {
res += sep
}
res += v
}
return res
}

View file

@ -0,0 +1,38 @@
---
name: google_sync
description: Synchronize Google services (Email, Calendar) with PicoClaw
---
# Google Sync Skill
This skill allows the agent to synchronize and manage user's Google services.
## Available Tools
### `google`
Provides access to Gmail and Google Calendar.
- `action="list_emails"`: Fetches recent emails.
- `action="list_events"`: Fetches upcoming calendar events.
## Periodic Synchronization
To keep the agent's knowledge up-to-date, use the `cron` tool to schedule periodic sync tasks.
### Example: Sync Every 4 Hours
Call `cron` tool:
```json
{
"action": "add",
"message": "Update my knowledge of recent emails and calendar events using the google tool.",
"every_seconds": 14400,
"deliver": false
}
```
## Self-Syncing Implementation
When triggered by cron, the agent should:
1. Call `google(action="list_emails")`
2. Call `google(action="list_events")`
3. Summarize the findings.
4. Update its long-term knowledge or session summary.