|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "fmt" |
| 7 | + "io" |
| 8 | + "os" |
| 9 | + "os/exec" |
| 10 | + "path/filepath" |
| 11 | + "strings" |
| 12 | +) |
| 13 | + |
| 14 | +type SetupCapability struct { |
| 15 | + Config AssistantConfig |
| 16 | +} |
| 17 | + |
| 18 | +func (c *SetupCapability) Name() string { return "setup" } |
| 19 | + |
| 20 | +func (c *SetupCapability) Description() string { |
| 21 | + return "Connect and verify assistant integrations like Gmail, the browser computer, and messaging channels." |
| 22 | +} |
| 23 | + |
| 24 | +func (c *SetupCapability) Tools() []Tool { |
| 25 | + return []Tool{ |
| 26 | + { |
| 27 | + Name: "setup.connect_service", |
| 28 | + Description: "Connect or repair an assistant integration such as gmail, browser, whatsapp, telegram, discord, or instagram. This is handled by the assistant runtime because it may open browser auth or local setup flows.", |
| 29 | + ParamSchema: `{"type":"object","properties":{"service":{"type":"string"}},"required":["service"]}`, |
| 30 | + }, |
| 31 | + { |
| 32 | + Name: "setup.status_service", |
| 33 | + Description: "Check whether an assistant integration is connected and ready.", |
| 34 | + ParamSchema: `{"type":"object","properties":{"service":{"type":"string"}},"required":["service"]}`, |
| 35 | + }, |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +func (c *SetupCapability) Execute(toolName string, params map[string]any) (ToolResult, error) { |
| 40 | + err := fmt.Errorf("%s is handled by the assistant runtime", strings.TrimSpace(toolName)) |
| 41 | + return ToolResult{Success: false, Error: err.Error()}, err |
| 42 | +} |
| 43 | + |
| 44 | +func executeAssistantSetupService(ctx context.Context, s *AssistantSession, call AssistantToolCall, in io.Reader, out io.Writer) (ToolResult, error) { |
| 45 | + if ctx != nil && ctx.Err() != nil { |
| 46 | + return ToolResult{Success: false, Error: ctx.Err().Error()}, ctx.Err() |
| 47 | + } |
| 48 | + service := strings.TrimSpace(firstStringParam(call.Params, "service", "channel", "name")) |
| 49 | + service = assistantNormalizeSetupService(service) |
| 50 | + if service == "" { |
| 51 | + err := errors.New("service must be one of gmail, browser, whatsapp, telegram, discord, or instagram") |
| 52 | + return ToolResult{Success: false, Error: err.Error()}, err |
| 53 | + } |
| 54 | + switch strings.ToLower(strings.TrimSpace(call.Tool)) { |
| 55 | + case "setup.status_service": |
| 56 | + return assistantSetupStatusResult(s.Config, service) |
| 57 | + case "setup.connect_service": |
| 58 | + cfg, summary, err := assistantConnectServiceForRuntime(in, out, s.Config, service) |
| 59 | + if err != nil { |
| 60 | + return ToolResult{Success: false, Error: err.Error()}, err |
| 61 | + } |
| 62 | + if err := SaveAssistantConfigFile(cfg); err != nil { |
| 63 | + return ToolResult{Success: false, Error: err.Error()}, err |
| 64 | + } |
| 65 | + s.Config = cfg |
| 66 | + if caps, err := buildAssistantCapabilities(cfg, "", s.Provider); err == nil { |
| 67 | + s.Capabilities = caps |
| 68 | + } |
| 69 | + return ToolResult{ |
| 70 | + Success: true, |
| 71 | + Text: summary, |
| 72 | + Data: map[string]any{ |
| 73 | + "assistant_final": true, |
| 74 | + "service": service, |
| 75 | + "summary": summary, |
| 76 | + "connected": true, |
| 77 | + }, |
| 78 | + }, nil |
| 79 | + default: |
| 80 | + err := fmt.Errorf("unknown setup tool %q", call.Tool) |
| 81 | + return ToolResult{Success: false, Error: err.Error()}, err |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +func assistantNormalizeSetupService(value string) string { |
| 86 | + value = strings.ToLower(strings.TrimSpace(value)) |
| 87 | + switch value { |
| 88 | + case "gmail", "google mail": |
| 89 | + return "gmail" |
| 90 | + case "browser", "browser computer", "forms": |
| 91 | + return "browser" |
| 92 | + default: |
| 93 | + return assistantNormalizeChannelName(value) |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +func assistantSetupStatusResult(cfg AssistantConfig, service string) (ToolResult, error) { |
| 98 | + switch service { |
| 99 | + case "gmail": |
| 100 | + gmail, err := NewGmailCapability(cfg) |
| 101 | + if err != nil { |
| 102 | + return ToolResult{Success: false, Error: err.Error()}, err |
| 103 | + } |
| 104 | + return gmail.Execute("gmail.status", map[string]any{}) |
| 105 | + case "browser": |
| 106 | + connected := cfg.BrowserEnabled && cfg.BrowserConnected && assistantBrowserProfileExists(cfg) |
| 107 | + return ToolResult{ |
| 108 | + Success: true, |
| 109 | + Text: map[bool]string{true: "browser computer connected", false: "browser computer not connected"}[connected], |
| 110 | + Data: map[string]any{ |
| 111 | + "connected": connected, |
| 112 | + "profile": strings.TrimSpace(cfg.BrowserProfilePath), |
| 113 | + }, |
| 114 | + }, nil |
| 115 | + default: |
| 116 | + settings := assistantChannelConfig(cfg, service) |
| 117 | + connected := settings.Enabled && settings.Connected |
| 118 | + return ToolResult{ |
| 119 | + Success: true, |
| 120 | + Text: fmt.Sprintf("%s %s", assistantChannelDisplayName(service), map[bool]string{true: "connected", false: "not connected"}[connected]), |
| 121 | + Data: map[string]any{ |
| 122 | + "connected": connected, |
| 123 | + "bridge": strings.TrimSpace(settings.BridgeCommand), |
| 124 | + "account": strings.TrimSpace(settings.AccountLabel), |
| 125 | + }, |
| 126 | + }, nil |
| 127 | + } |
| 128 | +} |
| 129 | + |
| 130 | +func assistantConnectServiceForRuntime(stdin io.Reader, stdout io.Writer, cfg AssistantConfig, service string) (AssistantConfig, string, error) { |
| 131 | + switch service { |
| 132 | + case "gmail": |
| 133 | + gmail, err := NewGmailCapability(cfg) |
| 134 | + if err != nil { |
| 135 | + return cfg, "", err |
| 136 | + } |
| 137 | + if err := gmail.Authenticate(stdout); err != nil { |
| 138 | + return cfg, "", err |
| 139 | + } |
| 140 | + return cfg, "Gmail connected and ready.", nil |
| 141 | + case "browser": |
| 142 | + next, err := assistantConnectBrowserProfile(stdin, stdout, cfg) |
| 143 | + if err != nil { |
| 144 | + return cfg, "", err |
| 145 | + } |
| 146 | + return next, "Browser computer connected and ready.", nil |
| 147 | + default: |
| 148 | + next, err := assistantConnectMessagingChannel(stdin, stdout, cfg, service) |
| 149 | + if err != nil { |
| 150 | + return cfg, "", err |
| 151 | + } |
| 152 | + settings := assistantChannelConfig(next, service) |
| 153 | + if settings.Enabled && settings.Connected { |
| 154 | + return next, assistantChannelDisplayName(service) + " connected and ready.", nil |
| 155 | + } |
| 156 | + if service == assistantChannelWhatsApp && strings.TrimSpace(settings.BridgeCommand) != "" { |
| 157 | + return next, "WhatsApp bridge is prepared. If pairing is still pending, scan the QR code and ask me to finish setup again.", nil |
| 158 | + } |
| 159 | + return next, assistantChannelDisplayName(service) + " setup is prepared but not fully connected yet.", nil |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +func assistantMaybePrepareWhatsAppBridge(stdout io.Writer, cfg AssistantConfig) (AssistantConfig, error) { |
| 164 | + next := cfg |
| 165 | + settings := assistantChannelConfig(next, assistantChannelWhatsApp) |
| 166 | + if strings.TrimSpace(settings.BridgeCommand) != "" { |
| 167 | + if next.Channels == nil { |
| 168 | + next.Channels = make(map[string]AssistantChannelConfig) |
| 169 | + } |
| 170 | + next.Channels[assistantChannelWhatsApp] = settings |
| 171 | + return next, nil |
| 172 | + } |
| 173 | + nodePath, err := exec.LookPath("node") |
| 174 | + if err != nil { |
| 175 | + return cfg, errors.New("node is required to set up the WhatsApp bridge automatically") |
| 176 | + } |
| 177 | + bridgePath, err := assistantFindBundledWhatsAppBridge() |
| 178 | + if err != nil { |
| 179 | + return cfg, err |
| 180 | + } |
| 181 | + settings.BridgeCommand = nodePath |
| 182 | + settings.BridgeArgs = []string{bridgePath} |
| 183 | + if next.Channels == nil { |
| 184 | + next.Channels = make(map[string]AssistantChannelConfig) |
| 185 | + } |
| 186 | + next.Channels[assistantChannelWhatsApp] = settings |
| 187 | + if stdout != nil { |
| 188 | + ui := newTermUI(stdout) |
| 189 | + _, _ = fmt.Fprintln(stdout, " "+ui.tdim("configured the bundled local WhatsApp bridge automatically.")) |
| 190 | + } |
| 191 | + if err := assistantEnsureWhatsAppBridgeDependencies(stdout, bridgePath); err != nil { |
| 192 | + return next, err |
| 193 | + } |
| 194 | + return next, nil |
| 195 | +} |
| 196 | + |
| 197 | +func assistantFindBundledWhatsAppBridge() (string, error) { |
| 198 | + candidates := []string{} |
| 199 | + if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" { |
| 200 | + candidates = append(candidates, filepath.Join(wd, "tools", "whatsapp-bridge", "index.mjs")) |
| 201 | + } |
| 202 | + if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" { |
| 203 | + base := filepath.Dir(exe) |
| 204 | + candidates = append(candidates, |
| 205 | + filepath.Join(base, "tools", "whatsapp-bridge", "index.mjs"), |
| 206 | + filepath.Join(filepath.Dir(base), "tools", "whatsapp-bridge", "index.mjs"), |
| 207 | + ) |
| 208 | + } |
| 209 | + seen := map[string]struct{}{} |
| 210 | + for _, candidate := range candidates { |
| 211 | + candidate = filepath.Clean(candidate) |
| 212 | + if _, ok := seen[strings.ToLower(candidate)]; ok { |
| 213 | + continue |
| 214 | + } |
| 215 | + seen[strings.ToLower(candidate)] = struct{}{} |
| 216 | + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { |
| 217 | + return candidate, nil |
| 218 | + } |
| 219 | + } |
| 220 | + return "", errors.New("could not find the bundled WhatsApp bridge on disk") |
| 221 | +} |
| 222 | + |
| 223 | +func assistantEnsureWhatsAppBridgeDependencies(stdout io.Writer, bridgePath string) error { |
| 224 | + bridgeDir := filepath.Dir(strings.TrimSpace(bridgePath)) |
| 225 | + required := filepath.Join(bridgeDir, "node_modules", "@whiskeysockets", "baileys", "package.json") |
| 226 | + if info, err := os.Stat(required); err == nil && !info.IsDir() { |
| 227 | + return nil |
| 228 | + } |
| 229 | + npmPath, err := exec.LookPath("npm") |
| 230 | + if err != nil { |
| 231 | + return errors.New("npm is required to install WhatsApp bridge dependencies automatically") |
| 232 | + } |
| 233 | + if stdout != nil { |
| 234 | + ui := newTermUI(stdout) |
| 235 | + _, _ = fmt.Fprintln(stdout, " "+ui.tdim("installing WhatsApp bridge dependencies...")) |
| 236 | + } |
| 237 | + cmd := exec.Command(npmPath, "install") |
| 238 | + cmd.Dir = bridgeDir |
| 239 | + cmd.Stdout = stdout |
| 240 | + cmd.Stderr = stdout |
| 241 | + if err := cmd.Run(); err != nil { |
| 242 | + return fmt.Errorf("npm install failed for the WhatsApp bridge: %w", err) |
| 243 | + } |
| 244 | + return nil |
| 245 | +} |
0 commit comments