diff --git a/debug_home.go b/debug_home.go new file mode 100644 index 000000000..96092fe2d --- /dev/null +++ b/debug_home.go @@ -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")) +} diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index fd8ceb79f..565d94bcc 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -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 { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d8e034437..f8f0c4bc0 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -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) { diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index 4667e3d81..790c216a9 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -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, } } diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index b3a493761..9626ec804 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -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 +} diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 188a7c8fa..73ad97aa3 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -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 +} diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index aed6a1874..e31424dc4 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -13,5 +13,6 @@ func BuiltinDefinitions() []Definition { switchCommand(), checkCommand(), clearCommand(), + whatsappCommand(), } } diff --git a/pkg/commands/cmd_whatsapp.go b/pkg/commands/cmd_whatsapp.go new file mode 100644 index 000000000..a926f9caa --- /dev/null +++ b/pkg/commands/cmd_whatsapp.go @@ -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} + }, + }, + }, + } +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 037184686..d6c74f8ed 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -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) } diff --git a/pkg/config/config.go b/pkg/config/config.go index a47ab3091..aea368299 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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": diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 9a87ece56..2de444545 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -474,6 +474,9 @@ func DefaultConfig() *Config { InstallSkill: ToolConfig{ Enabled: true, }, + Google: ToolConfig{ + Enabled: true, + }, ListDir: ToolConfig{ Enabled: true, }, diff --git a/pkg/tools/google.go b/pkg/tools/google.go new file mode 100644 index 000000000..82835e722 --- /dev/null +++ b/pkg/tools/google.go @@ -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 +} diff --git a/workspace/skills/google_sync/SKILL.md b/workspace/skills/google_sync/SKILL.md new file mode 100644 index 000000000..1095b126d --- /dev/null +++ b/workspace/skills/google_sync/SKILL.md @@ -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.