Skip to content

Commit 4ff1408

Browse files
committed
Add assistant-led integration setup flows
1 parent 2137e5a commit 4ff1408

8 files changed

Lines changed: 357 additions & 3 deletions

File tree

ASSISTANT.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ You can also just run:
3434
jot assistant
3535
```
3636

37+
You can also ask the assistant to do setup work directly from the chat itself, for example:
38+
39+
```bash
40+
jot assistant "help me integrate with gmail"
41+
jot assistant "setup whatsapp for me"
42+
jot assistant "connect the browser computer"
43+
```
44+
3745
If provider setup or Gmail auth is incomplete, Jot will walk you through:
3846

3947
- choosing the provider
@@ -93,7 +101,7 @@ You can also use the auth shortcut:
93101
jot assistant auth whatsapp
94102
```
95103

96-
For WhatsApp, `auth whatsapp` now prepares the channel and verifies a configured native bridge instead of opening a browser login flow. Configure `channels.whatsapp.bridgeCommand` and optional `bridgeArgs` in `assistant.json` to point at your local WhatsApp bridge process.
104+
For WhatsApp, `auth whatsapp` now prepares the native bridge path and verifies the local bridge instead of opening a browser login flow. When the bundled bridge is present, Jot will try to configure it automatically and install bridge dependencies before verifying the connection.
97105

98106
Channel send behavior remains confirmation-gated.
99107

assistant.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,8 +760,12 @@ func buildAssistantCapabilities(cfg AssistantConfig, scope string, provider Mode
760760
addBackup := scope == "" || scope == "backup"
761761
addGmail := scope == "" || scope == "gmail"
762762
addCalendar := scope == "" || scope == "calendar"
763+
addSetup := scope == "" || scope == "setup"
763764
var gmail *GmailCapability
764765

766+
if addSetup {
767+
caps = append(caps, &SetupCapability{Config: cfg})
768+
}
765769
if addBackup {
766770
caps = append(caps, &BackupCapability{Config: cfg})
767771
}
@@ -1273,7 +1277,10 @@ func assistantConnectMessagingChannel(stdin io.Reader, stdout io.Writer, cfg Ass
12731277
}
12741278

12751279
func assistantConnectWhatsAppChannel(stdout io.Writer, cfg AssistantConfig, ui termUI) (AssistantConfig, error) {
1276-
next := cfg
1280+
next, err := assistantMaybePrepareWhatsAppBridge(stdout, cfg)
1281+
if err != nil {
1282+
return cfg, err
1283+
}
12771284
settings := assistantChannelConfig(next, assistantChannelWhatsApp)
12781285
settings.Enabled = true
12791286
settings.Onboarded = true
@@ -2290,6 +2297,18 @@ func assistantStatusLineForToolCall(prompt string, call AssistantToolCall) strin
22902297
return "drafting WhatsApp reply..."
22912298
case "gmail.fill_form":
22922299
return "opening form, inspecting fields, and gathering answers..."
2300+
case "setup.connect_service":
2301+
service := strings.TrimSpace(firstStringParam(call.Params, "service", "channel", "name"))
2302+
if service == "" {
2303+
return "setting up integration..."
2304+
}
2305+
return "setting up " + assistantChannelDisplayName(service) + "..."
2306+
case "setup.status_service":
2307+
service := strings.TrimSpace(firstStringParam(call.Params, "service", "channel", "name"))
2308+
if service == "" {
2309+
return "checking integration status..."
2310+
}
2311+
return "checking " + assistantChannelDisplayName(service) + " status..."
22932312
case "backup.export_journal":
22942313
return "creating journal backup..."
22952314
case "backup.import_from_gmail":
@@ -3060,6 +3079,8 @@ func renderAssistantHelp(color bool) string {
30603079
"jot assistant browser status",
30613080
"jot assistant browser connect",
30623081
`jot assistant "summarize my unread emails from today"`,
3082+
`jot assistant "help me integrate with gmail"`,
3083+
`jot assistant "setup whatsapp for me"`,
30633084
`jot assistant "find invoice attachments from the last 30 days and save them to ./invoices"`,
30643085
`jot assistant "read the latest thread from Alice and draft a reply"`,
30653086
`jot assistant "identify meeting requests in emails from this week"`,

assistant_channels.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,12 @@ func assistantParseChannelSelection(value string) []string {
256256
}
257257

258258
func assistantChannelDisplayName(channel string) string {
259+
switch strings.ToLower(strings.TrimSpace(channel)) {
260+
case "gmail":
261+
return "Gmail"
262+
case "browser":
263+
return "browser computer"
264+
}
259265
switch assistantNormalizeChannelName(channel) {
260266
case assistantChannelWhatsApp:
261267
return "WhatsApp"

assistant_runtime.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,7 @@ func (s *AssistantSession) BuildSystemPrompt(now time.Time) string {
15181518
b.WriteString("To back up the local Jot journal, use backup.export_journal. If the user wants it emailed, create the backup first and then send it with gmail.send_email using attachment_paths. If the user says 'email it to me', use gmail.status to learn the connected Gmail address if needed.\n")
15191519
b.WriteString("To restore the Jot journal from Gmail, prefer backup.import_from_gmail. It should find the latest emailed Jot journal backup, download it, import it into the local journal, and leave the notebook ready for jot list.\n")
15201520
b.WriteString("For WhatsApp work, use whatsapp.read_thread to inspect recent context and whatsapp.draft_reply to prepare a reply before sending.\n")
1521+
b.WriteString("If the user asks to set up, connect, integrate, or repair Gmail, the browser computer, WhatsApp, Telegram, Discord, or Instagram, use setup.connect_service. Prefer doing the setup action directly instead of only describing CLI commands.\n")
15211522
b.WriteString("If the user wants help filling a web form, use gmail.fill_form. If the user gives you a direct form URL, call gmail.fill_form with form_url. If the form is linked from an email, include the relevant message_id or thread_id as supporting context. The runtime will use the browser computer to inspect and fill the page.\n")
15221523
b.WriteString("When an email has attachments and the user's task depends on their contents, use gmail.read_attachment to read them directly. Do not ask the user to download attachments just to inspect them.\n")
15231524
b.WriteString("For exact-fact retrieval from Gmail, such as passport numbers, permit numbers, service numbers, or reference numbers, search narrowly first, trust the highest-ranked candidate, and stop broad searching once a strong candidate is found. Inspect the top message or its most relevant attachments before launching another broad gmail.search. Prefer image/scanned documents for identity facts.\n")
@@ -1848,6 +1849,8 @@ func (s *AssistantSession) executeToolWithRuntimeFlow(ctx context.Context, userI
18481849
switch strings.ToLower(strings.TrimSpace(call.Tool)) {
18491850
case "gmail.fill_form":
18501851
return executeAssistantFormFill(ctx, s, call, in, out)
1852+
case "setup.connect_service", "setup.status_service":
1853+
return executeAssistantSetupService(ctx, s, call, in, out)
18511854
case "backup.import_from_gmail":
18521855
return executeAssistantJournalImportFromGmail(ctx, s)
18531856
case "gmail.search":

assistant_setup.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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+
}

assistant_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,43 @@ func TestEnsureGmailSendReady_ReauthsInlineAndContinues(t *testing.T) {
13481348
}
13491349
}
13501350

1351+
func TestBuildAssistantCapabilities_IncludesSetupCapability(t *testing.T) {
1352+
caps, err := buildAssistantCapabilities(AssistantConfig{
1353+
Provider: "ollama",
1354+
Model: "llama3.2",
1355+
OllamaURL: "http://localhost:11434",
1356+
GmailTokenPath: filepath.Join(t.TempDir(), "gmail_token.json"),
1357+
GmailCredPath: filepath.Join(t.TempDir(), "gmail_credentials.json"),
1358+
MemoryPath: filepath.Join(t.TempDir(), "assistant_memory.json"),
1359+
AttachmentSaveDir: filepath.Join(t.TempDir(), "attachments"),
1360+
}, "", &sequentialTestProvider{})
1361+
if err != nil {
1362+
t.Fatalf("buildAssistantCapabilities returned error: %v", err)
1363+
}
1364+
found := false
1365+
for _, cap := range caps {
1366+
if _, ok := cap.(*SetupCapability); ok {
1367+
found = true
1368+
break
1369+
}
1370+
}
1371+
if !found {
1372+
t.Fatal("expected setup capability to be included")
1373+
}
1374+
}
1375+
1376+
func TestAssistantNormalizeSetupService(t *testing.T) {
1377+
if got := assistantNormalizeSetupService("google mail"); got != "gmail" {
1378+
t.Fatalf("expected gmail alias, got %q", got)
1379+
}
1380+
if got := assistantNormalizeSetupService("browser computer"); got != "browser" {
1381+
t.Fatalf("expected browser alias, got %q", got)
1382+
}
1383+
if got := assistantNormalizeSetupService("wa"); got != "whatsapp" {
1384+
t.Fatalf("expected whatsapp alias, got %q", got)
1385+
}
1386+
}
1387+
13511388
func TestCreateJournalBackup_CreatesArchive(t *testing.T) {
13521389
tmp := t.TempDir()
13531390
t.Setenv("HOME", tmp)

0 commit comments

Comments
 (0)