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") {
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
}
if cfg.Tools.IsToolEnabled("google") {
toolsRegistry.Register(&tools.GoogleTool{})
}
if cfg.Tools.IsToolEnabled("exec") {
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
if err != nil {

View file

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

View file

@ -53,7 +53,7 @@ func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
TokenURL: "https://oauth2.googleapis.com/token",
ClientID: clientID,
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,
}
}

View file

@ -50,3 +50,9 @@ type PlaceholderRecorder interface {
type CommandRegistrarCapable interface {
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
stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls
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.
@ -187,6 +188,9 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
}
if evt.Event == "code" {
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{
Level: qrterminal.L,
Writer: os.Stdout,
@ -446,3 +450,9 @@ func parseJID(s string) (types.JID, error) {
}
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(),
checkCommand(),
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)
SwitchChannel func(value string) 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_"`
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
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_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
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
case "install_skill":
return t.InstallSkill.Enabled
case "google":
return t.Google.Enabled
case "list_dir":
return t.ListDir.Enabled
case "message":

View file

@ -474,6 +474,9 @@ func DefaultConfig() *Config {
InstallSkill: ToolConfig{
Enabled: true,
},
Google: ToolConfig{
Enabled: true,
},
ListDir: ToolConfig{
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.