shepherd-onboard 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/bin/shepherd-onboard.js +546 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ npx -y shepherd-onboard@latest agent
|
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
The command prints the exact prompt the agent should follow, then the exact follow-up commands to open Shepherd WorkOS login/signup, open source auth, open Granola's API key page, finalize, start cloud raw polling/backfills, and install local Messages sync.
|
|
14
|
-
The agent prompt tells coding agents to ask short selection questions first: existing/new org, sources to connect, and Messages skip/provide-handle. Account creation/relinking always starts with Shepherd WorkOS auth.
|
|
14
|
+
The agent prompt tells coding agents to ask short selection questions first: existing/new org, sources to connect, and Messages skip/provide-handle. If Messages is selected, the agent runs `messages-chats --json`, shows the 20 most recent local Messages chats with contact/group names, and asks which chats to sync. Account creation/relinking always starts with Shepherd WorkOS auth.
|
|
15
15
|
Existing-organization joins are verified from Shepherd login and company email domain; the typed org name is not trusted by itself.
|
|
16
16
|
|
|
17
17
|
## Human Terminal One-liner
|
|
@@ -31,7 +31,8 @@ The command:
|
|
|
31
31
|
- opens Slack authorization
|
|
32
32
|
- opens the Granola desktop app to Settings -> Connectors -> API keys
|
|
33
33
|
- collects the Granola API key after opening the Granola screen when Granola is enabled
|
|
34
|
-
-
|
|
34
|
+
- shows the 20 most recent local Messages chats with contact/group names and only syncs the chats selected by the user
|
|
35
|
+
- sets up local macOS Messages raw sync with a background LaunchAgent scoped to the selected chats
|
|
35
36
|
- starts raw polling/backfill for connected sources
|
|
36
37
|
- does not start wiki generation, memory compilation, or doc summaries
|
|
37
38
|
|
|
@@ -45,6 +46,7 @@ The command does not expose Railway, database, Redis, or internal service detail
|
|
|
45
46
|
--org <name> Organization name
|
|
46
47
|
--granola-api-key <key> Granola API key
|
|
47
48
|
--messages-handle <value> Messages phone number or Apple ID email
|
|
49
|
+
--messages-chat-ids <ids> Comma-separated chat IDs selected from messages-chats
|
|
48
50
|
--messages-backfill-days Local Messages backfill window, default 30
|
|
49
51
|
--no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar)
|
|
50
52
|
--no-slack Skip Slack
|
|
@@ -54,6 +56,20 @@ The command does not expose Railway, database, Redis, or internal service detail
|
|
|
54
56
|
--no-open Print auth URLs instead of opening the browser
|
|
55
57
|
```
|
|
56
58
|
|
|
59
|
+
## Messages Chat Selection
|
|
60
|
+
|
|
61
|
+
List recent local Messages chats:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
npx -y shepherd-onboard@latest messages-chats --json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Pass the selected chat IDs when finishing onboarding:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
npx -y shepherd-onboard@latest agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<id1>,<id2>"
|
|
71
|
+
```
|
|
72
|
+
|
|
57
73
|
## Granola API Keys
|
|
58
74
|
|
|
59
75
|
If Granola does not land on the API keys page, run:
|
package/bin/shepherd-onboard.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
3
3
|
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir, platform } from "node:os";
|
|
@@ -12,6 +12,62 @@ const PACKAGE_SPEC = `${PACKAGE_NAME}@latest`;
|
|
|
12
12
|
const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-agent.json");
|
|
13
13
|
const MAX_BATCH_SIZE = 50;
|
|
14
14
|
const MAX_QUEUE_MESSAGES = 10_000;
|
|
15
|
+
const DEFAULT_RECENT_MESSAGE_CHATS = 20;
|
|
16
|
+
const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
|
|
17
|
+
const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
|
|
18
|
+
"gigabrain-delegation@shepherd-gigabrain.iam.gserviceaccount.com";
|
|
19
|
+
const GOOGLE_WORKSPACE_DELEGATION_CLIENT_ID = "118363960386741325727";
|
|
20
|
+
const GOOGLE_WORKSPACE_DELEGATION_SCOPES = [
|
|
21
|
+
"https://mail.google.com/",
|
|
22
|
+
"https://www.googleapis.com/auth/gmail.addons.current.action.compose",
|
|
23
|
+
"https://www.googleapis.com/auth/gmail.addons.current.message.action",
|
|
24
|
+
"https://www.googleapis.com/auth/gmail.addons.current.message.metadata",
|
|
25
|
+
"https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
|
|
26
|
+
"https://www.googleapis.com/auth/gmail.compose",
|
|
27
|
+
"https://www.googleapis.com/auth/gmail.insert",
|
|
28
|
+
"https://www.googleapis.com/auth/gmail.labels",
|
|
29
|
+
"https://www.googleapis.com/auth/gmail.metadata",
|
|
30
|
+
"https://www.googleapis.com/auth/gmail.modify",
|
|
31
|
+
"https://www.googleapis.com/auth/gmail.readonly",
|
|
32
|
+
"https://www.googleapis.com/auth/gmail.send",
|
|
33
|
+
"https://www.googleapis.com/auth/gmail.settings.basic",
|
|
34
|
+
"https://www.googleapis.com/auth/gmail.settings.sharing",
|
|
35
|
+
"https://www.googleapis.com/auth/calendar",
|
|
36
|
+
"https://www.googleapis.com/auth/calendar.acls",
|
|
37
|
+
"https://www.googleapis.com/auth/calendar.acls.readonly",
|
|
38
|
+
"https://www.googleapis.com/auth/calendar.app.created",
|
|
39
|
+
"https://www.googleapis.com/auth/calendar.calendarlist",
|
|
40
|
+
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
|
|
41
|
+
"https://www.googleapis.com/auth/calendar.calendars",
|
|
42
|
+
"https://www.googleapis.com/auth/calendar.calendars.readonly",
|
|
43
|
+
"https://www.googleapis.com/auth/calendar.events",
|
|
44
|
+
"https://www.googleapis.com/auth/calendar.events.freebusy",
|
|
45
|
+
"https://www.googleapis.com/auth/calendar.events.owned",
|
|
46
|
+
"https://www.googleapis.com/auth/calendar.events.owned.readonly",
|
|
47
|
+
"https://www.googleapis.com/auth/calendar.events.public.readonly",
|
|
48
|
+
"https://www.googleapis.com/auth/calendar.events.readonly",
|
|
49
|
+
"https://www.googleapis.com/auth/calendar.freebusy",
|
|
50
|
+
"https://www.googleapis.com/auth/calendar.readonly",
|
|
51
|
+
"https://www.googleapis.com/auth/calendar.settings.readonly",
|
|
52
|
+
"https://www.googleapis.com/auth/drive",
|
|
53
|
+
"https://www.googleapis.com/auth/drive.appdata",
|
|
54
|
+
"https://www.googleapis.com/auth/drive.apps.readonly",
|
|
55
|
+
"https://www.googleapis.com/auth/drive.file",
|
|
56
|
+
"https://www.googleapis.com/auth/drive.install",
|
|
57
|
+
"https://www.googleapis.com/auth/drive.meet.readonly",
|
|
58
|
+
"https://www.googleapis.com/auth/drive.metadata",
|
|
59
|
+
"https://www.googleapis.com/auth/drive.metadata.readonly",
|
|
60
|
+
"https://www.googleapis.com/auth/drive.photos.readonly",
|
|
61
|
+
"https://www.googleapis.com/auth/drive.readonly",
|
|
62
|
+
"https://www.googleapis.com/auth/drive.scripts",
|
|
63
|
+
"https://www.googleapis.com/auth/documents",
|
|
64
|
+
"https://www.googleapis.com/auth/spreadsheets",
|
|
65
|
+
"https://www.googleapis.com/auth/presentations",
|
|
66
|
+
"https://www.googleapis.com/auth/tasks",
|
|
67
|
+
"https://www.googleapis.com/auth/contacts.readonly",
|
|
68
|
+
"https://www.googleapis.com/auth/drive.activity.readonly",
|
|
69
|
+
"https://www.googleapis.com/auth/directory.readonly",
|
|
70
|
+
];
|
|
15
71
|
|
|
16
72
|
const rawArgv = process.argv.slice(2);
|
|
17
73
|
const command = rawArgv[0] && !rawArgv[0].startsWith("--") ? rawArgv[0] : "onboard";
|
|
@@ -34,6 +90,8 @@ async function dispatch() {
|
|
|
34
90
|
await runAgentOnboarding();
|
|
35
91
|
} else if (command === "granola-api-keys") {
|
|
36
92
|
await openGranolaApiKeys({ noOpen: Boolean(args["no-open"]) });
|
|
93
|
+
} else if (command === "messages-chats") {
|
|
94
|
+
await runMessagesChatsCommand();
|
|
37
95
|
} else if (command === "messages-agent") {
|
|
38
96
|
await runMessagesAgent();
|
|
39
97
|
} else {
|
|
@@ -81,10 +139,10 @@ async function runOnboarding() {
|
|
|
81
139
|
console.log(`Matched existing organization by ${session.account.organizationMatch.type}.`);
|
|
82
140
|
}
|
|
83
141
|
|
|
84
|
-
if (
|
|
85
|
-
console.log("\nGoogle Workspace
|
|
86
|
-
|
|
87
|
-
await waitForEnter("
|
|
142
|
+
if (sources.google) {
|
|
143
|
+
console.log("\nGoogle Workspace domain-wide delegation");
|
|
144
|
+
printGoogleWorkspaceDelegationSetup(session.googleWorkspaceDelegation);
|
|
145
|
+
await waitForEnter("After the Google Workspace super admin authorizes Shepherd in Admin Console, press Enter.");
|
|
88
146
|
}
|
|
89
147
|
|
|
90
148
|
if (session.authUrls?.slack) {
|
|
@@ -94,6 +152,7 @@ async function runOnboarding() {
|
|
|
94
152
|
}
|
|
95
153
|
|
|
96
154
|
const finalizeBody = { sessionToken: session.sessionToken };
|
|
155
|
+
let selectedMessageChats = [];
|
|
97
156
|
|
|
98
157
|
if (sources.granola) {
|
|
99
158
|
await openGranolaApiKeys({ noOpen });
|
|
@@ -103,7 +162,10 @@ async function runOnboarding() {
|
|
|
103
162
|
|
|
104
163
|
if (sources.messages) {
|
|
105
164
|
const handle = await valueOrPrompt("messages-handle", "Messages phone number or Apple ID email", { optional: true });
|
|
106
|
-
if (handle)
|
|
165
|
+
if (handle) {
|
|
166
|
+
finalizeBody.imessage = { handle };
|
|
167
|
+
selectedMessageChats = await selectRecentMessageChats();
|
|
168
|
+
}
|
|
107
169
|
}
|
|
108
170
|
|
|
109
171
|
const finalized = await postJson(
|
|
@@ -127,6 +189,8 @@ async function runOnboarding() {
|
|
|
127
189
|
userId: session.sessionId,
|
|
128
190
|
agentToken: finalized.connected.messages.agentToken,
|
|
129
191
|
backfillDays: Number(args["messages-backfill-days"] ?? 30),
|
|
192
|
+
allowedChatIds: selectedMessageChats.map((chat) => chat.chatId),
|
|
193
|
+
selectedChats: selectedMessageChats,
|
|
130
194
|
});
|
|
131
195
|
|
|
132
196
|
if (!args["no-install-messages-agent"]) {
|
|
@@ -219,6 +283,7 @@ async function runAgentOnboarding() {
|
|
|
219
283
|
account: session.account,
|
|
220
284
|
sources,
|
|
221
285
|
authUrls: session.authUrls ?? {},
|
|
286
|
+
googleWorkspaceDelegation: googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation),
|
|
222
287
|
workosAuth,
|
|
223
288
|
createdAt: new Date().toISOString(),
|
|
224
289
|
});
|
|
@@ -226,6 +291,7 @@ async function runAgentOnboarding() {
|
|
|
226
291
|
const opened = [];
|
|
227
292
|
for (const [provider, url] of Object.entries(session.authUrls ?? {})) {
|
|
228
293
|
if (typeof url !== "string") continue;
|
|
294
|
+
if (provider === "google") continue;
|
|
229
295
|
if (!noOpen) await openOrPrint(url, { noOpen: false });
|
|
230
296
|
opened.push(provider);
|
|
231
297
|
}
|
|
@@ -240,9 +306,11 @@ async function runAgentOnboarding() {
|
|
|
240
306
|
status: "auth_required",
|
|
241
307
|
account: publicAgentAccount(session.account),
|
|
242
308
|
opened,
|
|
309
|
+
googleWorkspaceDelegation: sources.google ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
|
|
243
310
|
granolaApiKeyPage,
|
|
244
311
|
statePath,
|
|
245
|
-
|
|
312
|
+
messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats --json` : undefined,
|
|
313
|
+
nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`,
|
|
246
314
|
needsUserAction: agentNeedsUserAction(sources, opened),
|
|
247
315
|
}, null, 2));
|
|
248
316
|
return;
|
|
@@ -257,18 +325,28 @@ async function runAgentOnboarding() {
|
|
|
257
325
|
if (opened.length) {
|
|
258
326
|
console.log(`Opened browser authorization: ${opened.join(", ")}`);
|
|
259
327
|
}
|
|
328
|
+
if (sources.google) {
|
|
329
|
+
console.log("\nGoogle Workspace domain-wide delegation setup:");
|
|
330
|
+
printGoogleWorkspaceDelegationSetup(session.googleWorkspaceDelegation);
|
|
331
|
+
}
|
|
260
332
|
if (noOpen) {
|
|
261
333
|
for (const [provider, url] of Object.entries(session.authUrls ?? {})) {
|
|
334
|
+
if (provider === "google") continue;
|
|
262
335
|
console.log(`${provider} auth URL: ${url}`);
|
|
263
336
|
}
|
|
264
337
|
}
|
|
265
338
|
console.log(`State saved: ${statePath}`);
|
|
266
339
|
console.log("\nCoding agent next steps:");
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
console.log("
|
|
271
|
-
console.log(
|
|
340
|
+
if (sources.google) {
|
|
341
|
+
console.log("1. Ask the user's Google Workspace super admin to authorize Shepherd in Google Admin Console with the Client ID and scopes above.");
|
|
342
|
+
}
|
|
343
|
+
if (sources.slack) console.log("2. Ask the user to finish the opened Slack browser authorization.");
|
|
344
|
+
if (sources.granola) console.log("3. Ask the user for their Granola API key from the Granola Mac app.");
|
|
345
|
+
if (sources.messages) {
|
|
346
|
+
console.log(`4. Run ${agentCommand()} messages-chats --json, ask the user which recent chats to sync, and keep those chat IDs.`);
|
|
347
|
+
}
|
|
348
|
+
console.log("5. Run:");
|
|
349
|
+
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
|
|
272
350
|
console.log(" Omit either optional flag if that source is not being connected.");
|
|
273
351
|
}
|
|
274
352
|
|
|
@@ -354,8 +432,12 @@ async function continueAgentOnboarding() {
|
|
|
354
432
|
const body = { sessionToken: state.sessionToken };
|
|
355
433
|
const granolaApiKey = stringArg("granola-api-key");
|
|
356
434
|
const messagesHandle = stringArg("messages-handle");
|
|
435
|
+
const selectedMessageChatIds = parseMessageChatIdsArg();
|
|
357
436
|
if (granolaApiKey) body.granolaApiKey = granolaApiKey;
|
|
358
437
|
if (messagesHandle) body.imessage = { handle: messagesHandle };
|
|
438
|
+
if (state.sources.messages && messagesHandle && selectedMessageChatIds.length === 0) {
|
|
439
|
+
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats --json, ask the user which chats to sync, then rerun --continue with --messages-chat-ids "<id1>,<id2>".`);
|
|
440
|
+
}
|
|
359
441
|
|
|
360
442
|
const finalized = await postJson(
|
|
361
443
|
`${state.apiUrl}/onboarding/raw/session/${encodeURIComponent(state.sessionId)}/finalize`,
|
|
@@ -369,6 +451,7 @@ async function continueAgentOnboarding() {
|
|
|
369
451
|
userId: state.sessionId,
|
|
370
452
|
agentToken: finalized.connected.messages.agentToken,
|
|
371
453
|
backfillDays: Number(args["messages-backfill-days"] ?? 30),
|
|
454
|
+
allowedChatIds: selectedMessageChatIds,
|
|
372
455
|
});
|
|
373
456
|
|
|
374
457
|
if (!args["no-install-messages-agent"]) {
|
|
@@ -390,7 +473,7 @@ async function continueAgentOnboarding() {
|
|
|
390
473
|
status: errors ? "waiting" : "completed",
|
|
391
474
|
connected: Object.keys(finalized.connected ?? {}),
|
|
392
475
|
errors: errors ? safeErrorRecord(errors) : undefined,
|
|
393
|
-
nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"` : undefined,
|
|
476
|
+
nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"` : undefined,
|
|
394
477
|
}, null, 2));
|
|
395
478
|
return;
|
|
396
479
|
}
|
|
@@ -401,7 +484,7 @@ async function continueAgentOnboarding() {
|
|
|
401
484
|
console.log(`- ${source}: ${safeError(message)}`);
|
|
402
485
|
}
|
|
403
486
|
console.log("\nAfter the user completes missing auth/details, rerun:");
|
|
404
|
-
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"`);
|
|
487
|
+
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
|
|
405
488
|
console.log(" Omit either optional flag if that source is not being connected.");
|
|
406
489
|
return;
|
|
407
490
|
}
|
|
@@ -425,6 +508,25 @@ async function printAgentStatus() {
|
|
|
425
508
|
}, null, 2));
|
|
426
509
|
}
|
|
427
510
|
|
|
511
|
+
async function runMessagesChatsCommand() {
|
|
512
|
+
const chats = await listRecentMessageChats({
|
|
513
|
+
limit: clampInt(Number(args.limit ?? DEFAULT_RECENT_MESSAGE_CHATS), 1, 100),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (args.json) {
|
|
517
|
+
console.log(JSON.stringify({ chats }, null, 2));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
console.log(`\nRecent local Messages chats (${chats.length})\n`);
|
|
522
|
+
for (let i = 0; i < chats.length; i++) {
|
|
523
|
+
console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
|
|
524
|
+
console.log(` ${chats[i].chatId}`);
|
|
525
|
+
}
|
|
526
|
+
console.log("\nPass selected IDs to:");
|
|
527
|
+
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<id1>,<id2>"`);
|
|
528
|
+
}
|
|
529
|
+
|
|
428
530
|
async function runMessagesAgent() {
|
|
429
531
|
const configPath = stringArg("config");
|
|
430
532
|
if (!configPath) throw new Error("messages-agent requires --config <path>");
|
|
@@ -434,23 +536,30 @@ async function runMessagesAgent() {
|
|
|
434
536
|
const userId = requiredConfigString(config.userId, "userId");
|
|
435
537
|
const agentToken = requiredConfigString(config.agentToken, "agentToken");
|
|
436
538
|
const backfillDays = clampInt(Number(args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS ?? config.backfillDays ?? 30), 0, 3650);
|
|
539
|
+
const allowedChatIds = parseAllowedChatIds(config.allowedChatIds);
|
|
540
|
+
if (allowedChatIds.length === 0) {
|
|
541
|
+
throw new Error("Messages config must include selected chat IDs. Re-run onboarding and select one or more recent Messages chats.");
|
|
542
|
+
}
|
|
437
543
|
|
|
438
544
|
const kit = await import("@photon-ai/imessage-kit");
|
|
439
545
|
const sdk = new kit.IMessageSDK({ debug: args.debug === true });
|
|
440
546
|
const sender = new MessagesBatchSender(apiUrl, agentToken, userId);
|
|
441
|
-
const
|
|
547
|
+
const contactLookup = buildContactLookup();
|
|
548
|
+
const serializer = createMessageSerializer(kit, contactLookup);
|
|
442
549
|
|
|
443
550
|
console.log("Shepherd Messages raw sync starting");
|
|
551
|
+
console.log(`Messages chat filter: ${allowedChatIds.length} selected chat(s)`);
|
|
444
552
|
|
|
445
553
|
try {
|
|
446
554
|
await loadGroupChatNames(sdk, serializer);
|
|
555
|
+
loadSelectedChatNames(config.selectedChats, serializer);
|
|
447
556
|
|
|
448
557
|
if (backfillDays > 0) {
|
|
449
|
-
await runMessagesBackfill(sdk, sender, serializer, backfillDays);
|
|
558
|
+
await runMessagesBackfill(sdk, sender, serializer, backfillDays, allowedChatIds);
|
|
450
559
|
}
|
|
451
560
|
|
|
452
|
-
await gapFillFromWatermark(sdk, sender, serializer, userId);
|
|
453
|
-
await watchMessages(sdk, sender, serializer, userId);
|
|
561
|
+
await gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds);
|
|
562
|
+
await watchMessages(sdk, sender, serializer, userId, allowedChatIds);
|
|
454
563
|
} catch (err) {
|
|
455
564
|
await sdk.close?.().catch(() => undefined);
|
|
456
565
|
throw err;
|
|
@@ -489,12 +598,13 @@ Usage:
|
|
|
489
598
|
npx -y ${PACKAGE_NAME}@latest agent
|
|
490
599
|
npx -y ${PACKAGE_NAME}@latest agent --login
|
|
491
600
|
npx -y ${PACKAGE_NAME}@latest agent --name <name> --org <organization>
|
|
492
|
-
npx -y ${PACKAGE_NAME}@latest
|
|
601
|
+
npx -y ${PACKAGE_NAME}@latest messages-chats --json
|
|
602
|
+
npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids>
|
|
493
603
|
npx -y ${PACKAGE_NAME}@latest agent --status
|
|
494
604
|
npx -y ${PACKAGE_NAME}@latest granola-api-keys
|
|
495
605
|
|
|
496
606
|
Agent mode is non-interactive. It prints the user prompt and exact commands a coding agent should run.
|
|
497
|
-
Always run --login first. WorkOS login/signup creates or relinks the Shepherd account before source setup.
|
|
607
|
+
Always run --login first. WorkOS login/signup creates or relinks the Shepherd account before source setup; Google Workspace uses Admin Console domain-wide delegation, not WorkOS OAuth.
|
|
498
608
|
`);
|
|
499
609
|
return;
|
|
500
610
|
}
|
|
@@ -514,11 +624,27 @@ Options:
|
|
|
514
624
|
return;
|
|
515
625
|
}
|
|
516
626
|
|
|
627
|
+
if (which === "messages-chats") {
|
|
628
|
+
console.log(`Shepherd Messages recent chat selector
|
|
629
|
+
|
|
630
|
+
Usage:
|
|
631
|
+
npx -y ${PACKAGE_NAME}@latest messages-chats
|
|
632
|
+
npx -y ${PACKAGE_NAME}@latest messages-chats --json
|
|
633
|
+
|
|
634
|
+
Options:
|
|
635
|
+
--limit <n> Number of recent chats to show. Defaults to ${DEFAULT_RECENT_MESSAGE_CHATS}.
|
|
636
|
+
--json Print machine-readable chat IDs and labels.
|
|
637
|
+
--help Show this help.
|
|
638
|
+
`);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
517
642
|
console.log(`Shepherd raw sync onboarding
|
|
518
643
|
|
|
519
644
|
Usage:
|
|
520
645
|
npx -y ${PACKAGE_NAME}@latest
|
|
521
646
|
npx -y ${PACKAGE_NAME}@latest agent
|
|
647
|
+
npx -y ${PACKAGE_NAME}@latest messages-chats
|
|
522
648
|
npx -y ${PACKAGE_NAME}@latest granola-api-keys
|
|
523
649
|
|
|
524
650
|
Options:
|
|
@@ -527,9 +653,10 @@ Options:
|
|
|
527
653
|
--org <name> Organization name.
|
|
528
654
|
--granola-api-key <key> Granola API key.
|
|
529
655
|
--messages-handle <value> Messages phone number or Apple ID email.
|
|
656
|
+
--messages-chat-ids <ids> Comma-separated local Messages chat IDs selected from messages-chats.
|
|
530
657
|
--messages-backfill-days <days>
|
|
531
658
|
Local Messages backfill window. Defaults to 30.
|
|
532
|
-
--no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar).
|
|
659
|
+
--no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts).
|
|
533
660
|
--no-slack Skip Slack.
|
|
534
661
|
--no-granola Skip Granola.
|
|
535
662
|
--no-open-granola Do not open the Granola API key screen.
|
|
@@ -552,10 +679,13 @@ function printAgentContract() {
|
|
|
552
679
|
"Start with selection questions to determine what the user wants connected.",
|
|
553
680
|
"Ask whether they are joining an existing organization or creating a new one.",
|
|
554
681
|
"Run Shepherd WorkOS login/signup before source setup. Do not ask whether they already have an account.",
|
|
682
|
+
"Treat WorkOS login as Shepherd account identity only; do not use WorkOS Auth, WorkOS Pipes, or per-user Google OAuth for Google Workspace delegation.",
|
|
555
683
|
"Collect Full name and Organization name as direct text prompts after those choices. The email comes from WorkOS auth.",
|
|
556
684
|
"Tell the user Shepherd verifies existing-org joins from the authenticated WorkOS account and company email domain. The typed org name is not trusted by itself.",
|
|
685
|
+
"If Google Workspace is selected, guide the customer's Google Workspace super admin to authorize Shepherd's Client ID and scopes in Google Admin Console.",
|
|
557
686
|
"Ask Messages as a selectable choice: Skip Messages, or Provide handle.",
|
|
558
687
|
"If the user chooses Provide handle, ask for the phone number or Apple ID email.",
|
|
688
|
+
"If Messages is selected, run the recent-chat command and ask which of the 20 most recent chats to sync. Never sync all Messages chats by default.",
|
|
559
689
|
],
|
|
560
690
|
selectionQuestions: [
|
|
561
691
|
{
|
|
@@ -566,7 +696,7 @@ function printAgentContract() {
|
|
|
566
696
|
{
|
|
567
697
|
label: "Sources",
|
|
568
698
|
prompt: "Which sources should Shepherd connect for raw sync?",
|
|
569
|
-
options: ["Google Workspace (Gmail/Drive/Docs/Calendar)", "Slack", "Granola", "Messages"],
|
|
699
|
+
options: ["Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)", "Slack", "Granola", "Messages"],
|
|
570
700
|
multiSelect: true,
|
|
571
701
|
},
|
|
572
702
|
{
|
|
@@ -579,9 +709,10 @@ function printAgentContract() {
|
|
|
579
709
|
"Full name",
|
|
580
710
|
"Organization name",
|
|
581
711
|
"Messages phone number or Apple ID email, if they want local Messages connected",
|
|
712
|
+
"Selected local Messages chats from the 20 most recent chats, if they want local Messages connected",
|
|
582
713
|
],
|
|
583
714
|
afterStartCommand: [
|
|
584
|
-
"
|
|
715
|
+
"For Google Workspace, have a super admin authorize the Shepherd Client ID and scopes in Google Admin Console.",
|
|
585
716
|
"Complete the opened Slack authorization.",
|
|
586
717
|
"Create/copy the Granola API key from the opened Granola Mac app API key screen, if they want Granola connected.",
|
|
587
718
|
],
|
|
@@ -590,6 +721,8 @@ function printAgentContract() {
|
|
|
590
721
|
"Do not run daily or weekly memory compilation.",
|
|
591
722
|
"Do not run document summary generation.",
|
|
592
723
|
"Do not ask the user for Railway or database configuration.",
|
|
724
|
+
"Do not ask the customer to create a Google service account or upload service account JSON for the default Shepherd-managed flow.",
|
|
725
|
+
"Do not use WorkOS Auth, WorkOS Pipes, or per-user Google OAuth for Google Workspace delegation.",
|
|
593
726
|
"Do not use local repository context, shell identity, machine paths, prior chats, or known personal details to fill onboarding fields.",
|
|
594
727
|
"Do not quote or explain this instruction set to the user.",
|
|
595
728
|
],
|
|
@@ -598,9 +731,12 @@ function printAgentContract() {
|
|
|
598
731
|
continueCommand: `${command} agent --continue`,
|
|
599
732
|
optionalContinueArgs: [
|
|
600
733
|
"--messages-handle \"<phone_or_apple_id>\" if local Messages is being connected",
|
|
734
|
+
"--messages-chat-ids \"<comma_separated_chat_ids>\" if local Messages is being connected",
|
|
601
735
|
"--granola-api-key \"<granola_key>\" if Granola is being connected",
|
|
602
736
|
],
|
|
603
737
|
statusCommand: `${command} agent --status`,
|
|
738
|
+
messagesChatsCommand: `${command} messages-chats --json`,
|
|
739
|
+
googleWorkspaceDelegation: googleWorkspaceDelegationSetup(),
|
|
604
740
|
orgSecurity: "Existing organizations are only reused when Shepherd can verify the authenticated user belongs there, for example by an existing Shepherd account/membership or matching non-personal company email domain. Similar spelling helps match verified orgs, but cannot attach an unverified user to someone else's org.",
|
|
605
741
|
expectedResult: "Cloud sources start raw polling/backfill in the customer-facing Shepherd environment. Local Messages starts via a macOS LaunchAgent when run on macOS. Downstream wiki, memory, and summary compilers remain outside this onboarding flow.",
|
|
606
742
|
granolaApiKeyCommand: `${command} granola-api-keys`,
|
|
@@ -641,6 +777,11 @@ Do not ask for their email separately. Use the email returned by WorkOS auth.
|
|
|
641
777
|
|
|
642
778
|
If they are joining an existing org, ask for the org name they believe they belong to. Shepherd will match similar/case-different org names only when the authenticated account is allowed to join that org. Otherwise it creates or uses a separate org.
|
|
643
779
|
|
|
780
|
+
If Messages is selected, run:
|
|
781
|
+
${payload.messagesChatsCommand}
|
|
782
|
+
|
|
783
|
+
Show the 20 recent local Messages chats to the user as a selection question. Include both DMs and groups, and use the displayed contact/group names. Ask which chats Shepherd should sync. Do not select all chats by default.
|
|
784
|
+
|
|
644
785
|
Then run:
|
|
645
786
|
${payload.startCommand}
|
|
646
787
|
|
|
@@ -662,7 +803,7 @@ If Granola did not come forward, run:
|
|
|
662
803
|
That command opens Granola and tries to navigate to Settings -> Connectors -> API keys. If your tool cannot click inside Granola, leave Granola open and ask the user to go to that screen.
|
|
663
804
|
|
|
664
805
|
After Google Workspace and Slack browser auth is complete, and after the user has copied a Granola API key from the opened Granola screen if they want Granola, run:
|
|
665
|
-
${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --granola-api-key "<granola_key>"
|
|
806
|
+
${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"
|
|
666
807
|
|
|
667
808
|
Omit either optional flag if that source is not being connected.
|
|
668
809
|
|
|
@@ -728,6 +869,29 @@ function publicAgentAccount(account) {
|
|
|
728
869
|
};
|
|
729
870
|
}
|
|
730
871
|
|
|
872
|
+
function googleWorkspaceDelegationSetup(setup) {
|
|
873
|
+
return {
|
|
874
|
+
appName: setup?.appName ?? GOOGLE_WORKSPACE_DELEGATION_APP_NAME,
|
|
875
|
+
serviceAccountEmail:
|
|
876
|
+
setup?.serviceAccountEmail ?? GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL,
|
|
877
|
+
clientId: setup?.clientId ?? GOOGLE_WORKSPACE_DELEGATION_CLIENT_ID,
|
|
878
|
+
scopes: Array.isArray(setup?.scopes) && setup.scopes.length > 0
|
|
879
|
+
? setup.scopes
|
|
880
|
+
: GOOGLE_WORKSPACE_DELEGATION_SCOPES,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function printGoogleWorkspaceDelegationSetup(setup) {
|
|
885
|
+
const resolved = googleWorkspaceDelegationSetup(setup);
|
|
886
|
+
console.log(`App name: ${resolved.appName}`);
|
|
887
|
+
console.log(`Service account email: ${resolved.serviceAccountEmail}`);
|
|
888
|
+
console.log(`Domain-wide delegation OAuth Client ID: ${resolved.clientId}`);
|
|
889
|
+
console.log("Customer action: in Google Admin Console, add the Client ID above and paste these scopes.");
|
|
890
|
+
console.log("Customers do not create a service account or upload service account JSON; Shepherd stores its private service account JSON server-side.");
|
|
891
|
+
console.log("\nScopes:");
|
|
892
|
+
for (const scope of resolved.scopes) console.log(scope);
|
|
893
|
+
}
|
|
894
|
+
|
|
731
895
|
function authenticatedEmail(authenticated) {
|
|
732
896
|
return authenticated?.workosUser?.email ?? authenticated?.account?.email ?? null;
|
|
733
897
|
}
|
|
@@ -738,10 +902,10 @@ function authenticatedName(authenticated) {
|
|
|
738
902
|
|
|
739
903
|
function agentNeedsUserAction(sources, opened) {
|
|
740
904
|
const actions = [];
|
|
741
|
-
if (sources.google
|
|
905
|
+
if (sources.google) actions.push("Have the customer's Google Workspace super admin authorize Shepherd's domain-wide delegation Client ID and scopes in Google Admin Console.");
|
|
742
906
|
if (sources.slack && opened.includes("slack")) actions.push("Complete Slack browser authorization.");
|
|
743
907
|
if (sources.granola) actions.push("Create/copy a Granola API key from the Granola Mac app.");
|
|
744
|
-
if (sources.messages) actions.push("
|
|
908
|
+
if (sources.messages) actions.push("Run messages-chats, ask the user which recent local Messages chats to sync, then pass the selected chat IDs with the Messages handle.");
|
|
745
909
|
return actions;
|
|
746
910
|
}
|
|
747
911
|
|
|
@@ -948,6 +1112,10 @@ async function writeMessagesConfig(input) {
|
|
|
948
1112
|
const dir = join(homedir(), ".shepherd", "raw-messages");
|
|
949
1113
|
await mkdir(dir, { recursive: true });
|
|
950
1114
|
const path = join(dir, `${input.userId}.json`);
|
|
1115
|
+
const allowedChatIds = parseAllowedChatIds(input.allowedChatIds);
|
|
1116
|
+
if (allowedChatIds.length === 0) {
|
|
1117
|
+
throw new Error("Select at least one Messages chat before installing local Messages sync.");
|
|
1118
|
+
}
|
|
951
1119
|
await writeFile(
|
|
952
1120
|
path,
|
|
953
1121
|
JSON.stringify({
|
|
@@ -955,6 +1123,8 @@ async function writeMessagesConfig(input) {
|
|
|
955
1123
|
userId: input.userId,
|
|
956
1124
|
agentToken: input.agentToken,
|
|
957
1125
|
backfillDays: input.backfillDays,
|
|
1126
|
+
allowedChatIds,
|
|
1127
|
+
selectedChats: Array.isArray(input.selectedChats) ? input.selectedChats.map(publicMessageChat) : [],
|
|
958
1128
|
createdAt: new Date().toISOString(),
|
|
959
1129
|
}, null, 2),
|
|
960
1130
|
{ mode: 0o600 },
|
|
@@ -1019,6 +1189,120 @@ async function installMessagesAgent(configPath, userId) {
|
|
|
1019
1189
|
return { label, plistPath, stdoutPath, stderrPath };
|
|
1020
1190
|
}
|
|
1021
1191
|
|
|
1192
|
+
async function selectRecentMessageChats() {
|
|
1193
|
+
const explicitIds = parseMessageChatIdsArg();
|
|
1194
|
+
if (explicitIds.length > 0) {
|
|
1195
|
+
return explicitIds.map((chatId) => ({ chatId, label: chatId, kind: "unknown", participants: [] }));
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (!process.stdin.isTTY) {
|
|
1199
|
+
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats --json and pass --messages-chat-ids "<id1>,<id2>".`);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const chats = await listRecentMessageChats({ limit: DEFAULT_RECENT_MESSAGE_CHATS });
|
|
1203
|
+
if (chats.length === 0) {
|
|
1204
|
+
throw new Error("No recent local Messages chats were found on this Mac.");
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
console.log(`\nSelect local Messages chats to sync\n`);
|
|
1208
|
+
console.log("Shepherd will only pull from the chats you select.");
|
|
1209
|
+
for (let i = 0; i < chats.length; i++) {
|
|
1210
|
+
console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const answer = await prompt("\nEnter chat numbers to sync, separated by commas: ");
|
|
1214
|
+
const indexes = parseSelectionIndexes(answer, chats.length);
|
|
1215
|
+
if (indexes.length === 0) throw new Error("Select at least one Messages chat.");
|
|
1216
|
+
return indexes.map((idx) => chats[idx]);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function listRecentMessageChats({ limit }) {
|
|
1220
|
+
if (platform() !== "darwin") {
|
|
1221
|
+
throw new Error("local Messages chat discovery is only supported on macOS");
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const kit = await import("@photon-ai/imessage-kit");
|
|
1225
|
+
const sdk = new kit.IMessageSDK({ debug: args.debug === true });
|
|
1226
|
+
const contactLookup = buildContactLookup();
|
|
1227
|
+
try {
|
|
1228
|
+
const chats = await sdk.listChats({
|
|
1229
|
+
sortBy: "recent",
|
|
1230
|
+
limit: Math.max(limit, DEFAULT_RECENT_MESSAGE_CHATS),
|
|
1231
|
+
});
|
|
1232
|
+
const visible = chats
|
|
1233
|
+
.filter((chat) => typeof chat.chatId === "string" && chat.chatId.trim())
|
|
1234
|
+
.filter((chat) => chat.kind === "dm" || chat.kind === "group")
|
|
1235
|
+
.slice(0, limit);
|
|
1236
|
+
|
|
1237
|
+
const enriched = [];
|
|
1238
|
+
for (const chat of visible) {
|
|
1239
|
+
enriched.push(await enrichMessageChat(sdk, chat, contactLookup));
|
|
1240
|
+
}
|
|
1241
|
+
return enriched;
|
|
1242
|
+
} finally {
|
|
1243
|
+
await sdk.close?.().catch(() => undefined);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async function enrichMessageChat(sdk, chat, contactLookup) {
|
|
1248
|
+
const recentMessages = await sdk.getMessages({ chatId: chat.chatId, limit: 30 }).catch(() => []);
|
|
1249
|
+
const participants = uniqueParticipants(recentMessages, contactLookup);
|
|
1250
|
+
const dmHandle = chat.kind === "dm"
|
|
1251
|
+
? participants[0]?.handle ?? parseDmHandleFromChatId(chat.chatId)
|
|
1252
|
+
: null;
|
|
1253
|
+
const dmName = dmHandle ? contactLookup.resolveName(dmHandle) : null;
|
|
1254
|
+
const groupNames = participants.map((participant) => participant.name ?? participant.handle).filter(Boolean);
|
|
1255
|
+
const label = cleanChatName(chat.name)
|
|
1256
|
+
?? (chat.kind === "dm" ? dmName ?? dmHandle : null)
|
|
1257
|
+
?? (chat.kind === "group" && groupNames.length ? groupNames.slice(0, 4).join(", ") : null)
|
|
1258
|
+
?? (chat.kind === "group" ? "Group chat" : "Direct message");
|
|
1259
|
+
|
|
1260
|
+
return {
|
|
1261
|
+
chatId: chat.chatId,
|
|
1262
|
+
label,
|
|
1263
|
+
kind: chat.kind ?? "unknown",
|
|
1264
|
+
service: chat.service ?? null,
|
|
1265
|
+
lastMessageAt: isoDate(chat.lastMessageAt),
|
|
1266
|
+
participants,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function uniqueParticipants(messages, contactLookup) {
|
|
1271
|
+
const seen = new Set();
|
|
1272
|
+
const participants = [];
|
|
1273
|
+
for (const msg of messages) {
|
|
1274
|
+
const handle = typeof msg.participant === "string" ? msg.participant.trim() : "";
|
|
1275
|
+
if (!handle || contactLookup.isSelfHandle(handle)) continue;
|
|
1276
|
+
const normalized = normalizeHandle(handle);
|
|
1277
|
+
if (seen.has(normalized)) continue;
|
|
1278
|
+
seen.add(normalized);
|
|
1279
|
+
participants.push({
|
|
1280
|
+
handle,
|
|
1281
|
+
name: contactLookup.resolveName(handle),
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
return participants;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function formatMessageChatOption(chat) {
|
|
1288
|
+
const kind = chat.kind === "group" ? "group" : chat.kind === "dm" ? "dm" : "chat";
|
|
1289
|
+
const names = chat.participants?.map((participant) => participant.name ?? participant.handle).filter(Boolean) ?? [];
|
|
1290
|
+
const people = names.length ? ` · ${names.slice(0, 4).join(", ")}` : "";
|
|
1291
|
+
const when = chat.lastMessageAt ? ` · ${new Date(chat.lastMessageAt).toLocaleString()}` : "";
|
|
1292
|
+
return `${chat.label} (${kind})${people}${when}`;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function publicMessageChat(chat) {
|
|
1296
|
+
return {
|
|
1297
|
+
chatId: chat.chatId,
|
|
1298
|
+
label: chat.label,
|
|
1299
|
+
kind: chat.kind,
|
|
1300
|
+
service: chat.service ?? null,
|
|
1301
|
+
lastMessageAt: chat.lastMessageAt ?? null,
|
|
1302
|
+
participants: Array.isArray(chat.participants) ? chat.participants : [],
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1022
1306
|
async function loadGroupChatNames(sdk, serializer) {
|
|
1023
1307
|
if (typeof sdk.listChats !== "function") return;
|
|
1024
1308
|
try {
|
|
@@ -1032,36 +1316,50 @@ async function loadGroupChatNames(sdk, serializer) {
|
|
|
1032
1316
|
}
|
|
1033
1317
|
}
|
|
1034
1318
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1319
|
+
function loadSelectedChatNames(selectedChats, serializer) {
|
|
1320
|
+
if (!Array.isArray(selectedChats)) return;
|
|
1321
|
+
for (const chat of selectedChats) {
|
|
1322
|
+
if (chat && typeof chat.chatId === "string" && typeof chat.label === "string") {
|
|
1323
|
+
serializer.setChatName(chat.chatId, chat.label);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds) {
|
|
1329
|
+
console.log(`Running ${days}-day Messages backfill for ${allowedChatIds.length} selected chat(s)`);
|
|
1037
1330
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
1038
1331
|
const pageSize = 1000;
|
|
1039
|
-
let offset = 0;
|
|
1040
1332
|
let totalMessages = 0;
|
|
1041
1333
|
let totalStored = 0;
|
|
1042
1334
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1335
|
+
for (const chatId of allowedChatIds) {
|
|
1336
|
+
let offset = 0;
|
|
1337
|
+
while (true) {
|
|
1338
|
+
const messages = await sdk.getMessages({ chatId, since, limit: pageSize, offset });
|
|
1339
|
+
if (!messages.length) break;
|
|
1046
1340
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1341
|
+
totalMessages += messages.length;
|
|
1342
|
+
const result = await sender.send(messages.map((msg) => serializer.serialize(msg)));
|
|
1343
|
+
totalStored += result.stored;
|
|
1344
|
+
saveMessagesWatermark(sender.userId, maxRowId(messages));
|
|
1051
1345
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1346
|
+
if (messages.length < pageSize) break;
|
|
1347
|
+
offset += pageSize;
|
|
1348
|
+
}
|
|
1054
1349
|
}
|
|
1055
1350
|
|
|
1056
1351
|
console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
|
|
1057
1352
|
}
|
|
1058
1353
|
|
|
1059
|
-
async function gapFillFromWatermark(sdk, sender, serializer, userId) {
|
|
1354
|
+
async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds) {
|
|
1060
1355
|
const lastWatermark = loadMessagesWatermark(userId);
|
|
1061
1356
|
if (lastWatermark <= 0) return;
|
|
1062
1357
|
|
|
1063
|
-
const missed =
|
|
1064
|
-
const
|
|
1358
|
+
const missed = [];
|
|
1359
|
+
for (const chatId of allowedChatIds) {
|
|
1360
|
+
missed.push(...await sdk.getMessages({ chatId, limit: 1000 }));
|
|
1361
|
+
}
|
|
1362
|
+
const newMessages = missed.filter((msg) => Number(msg.rowId) > lastWatermark && allowedChatIds.includes(msg.chatId));
|
|
1065
1363
|
if (newMessages.length === 0) return;
|
|
1066
1364
|
|
|
1067
1365
|
const result = await sender.send(newMessages.map((msg) => serializer.serialize(msg)));
|
|
@@ -1069,7 +1367,8 @@ async function gapFillFromWatermark(sdk, sender, serializer, userId) {
|
|
|
1069
1367
|
console.log(`Messages gap-fill complete: stored ${result.stored} of ${newMessages.length}`);
|
|
1070
1368
|
}
|
|
1071
1369
|
|
|
1072
|
-
async function watchMessages(sdk, sender, serializer, userId) {
|
|
1370
|
+
async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
1371
|
+
const allowed = new Set(allowedChatIds);
|
|
1073
1372
|
let buffer = [];
|
|
1074
1373
|
let timer = null;
|
|
1075
1374
|
|
|
@@ -1089,6 +1388,7 @@ async function watchMessages(sdk, sender, serializer, userId) {
|
|
|
1089
1388
|
};
|
|
1090
1389
|
|
|
1091
1390
|
const onMessage = (msg) => {
|
|
1391
|
+
if (!msg.chatId || !allowed.has(msg.chatId)) return;
|
|
1092
1392
|
buffer.push(msg);
|
|
1093
1393
|
if (buffer.length >= MAX_BATCH_SIZE) {
|
|
1094
1394
|
if (timer) clearTimeout(timer);
|
|
@@ -1105,7 +1405,7 @@ async function watchMessages(sdk, sender, serializer, userId) {
|
|
|
1105
1405
|
onError: (err) => console.error("Messages watcher error:", safeError(err)),
|
|
1106
1406
|
});
|
|
1107
1407
|
|
|
1108
|
-
console.log("Watching for new Messages");
|
|
1408
|
+
console.log("Watching for new Messages in selected chats");
|
|
1109
1409
|
|
|
1110
1410
|
const shutdown = async () => {
|
|
1111
1411
|
if (timer) clearTimeout(timer);
|
|
@@ -1118,7 +1418,7 @@ async function watchMessages(sdk, sender, serializer, userId) {
|
|
|
1118
1418
|
process.on("SIGTERM", shutdown);
|
|
1119
1419
|
}
|
|
1120
1420
|
|
|
1121
|
-
function createMessageSerializer(kit) {
|
|
1421
|
+
function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
|
|
1122
1422
|
const chatNames = new Map();
|
|
1123
1423
|
const isImageAttachment = kit.isImageAttachment ?? (() => false);
|
|
1124
1424
|
const isVideoAttachment = kit.isVideoAttachment ?? (() => false);
|
|
@@ -1173,11 +1473,213 @@ function createMessageSerializer(kit) {
|
|
|
1173
1473
|
isForwarded: Boolean(msg.isForwarded),
|
|
1174
1474
|
affectedParticipant: msg.affectedParticipant ?? null,
|
|
1175
1475
|
newGroupName: msg.newGroupName ?? null,
|
|
1476
|
+
_resolved_name: msg.participant ? contactLookup.resolveName(msg.participant) : null,
|
|
1477
|
+
_is_self_handle: msg.participant ? contactLookup.isSelfHandle(msg.participant) : false,
|
|
1176
1478
|
};
|
|
1177
1479
|
},
|
|
1178
1480
|
};
|
|
1179
1481
|
}
|
|
1180
1482
|
|
|
1483
|
+
function buildContactLookup() {
|
|
1484
|
+
const contacts = loadContacts();
|
|
1485
|
+
const myCard = loadMyCard();
|
|
1486
|
+
const handleToName = new Map();
|
|
1487
|
+
const selfHandles = new Set();
|
|
1488
|
+
|
|
1489
|
+
for (const contact of contacts) {
|
|
1490
|
+
for (const phone of contact.phones) {
|
|
1491
|
+
addHandleMapping(handleToName, phone, contact.name);
|
|
1492
|
+
}
|
|
1493
|
+
for (const email of contact.emails) {
|
|
1494
|
+
addHandleMapping(handleToName, email, contact.name);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
if (myCard) {
|
|
1499
|
+
for (const phone of myCard.phones) addSelfHandle(selfHandles, phone);
|
|
1500
|
+
for (const email of myCard.emails) addSelfHandle(selfHandles, email);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
return {
|
|
1504
|
+
resolveName(handle) {
|
|
1505
|
+
const candidates = handleCandidates(handle);
|
|
1506
|
+
for (const candidate of candidates) {
|
|
1507
|
+
const name = handleToName.get(candidate);
|
|
1508
|
+
if (name) return name;
|
|
1509
|
+
}
|
|
1510
|
+
return null;
|
|
1511
|
+
},
|
|
1512
|
+
isSelfHandle(handle) {
|
|
1513
|
+
return handleCandidates(handle).some((candidate) => selfHandles.has(candidate));
|
|
1514
|
+
},
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function emptyContactLookup() {
|
|
1519
|
+
return {
|
|
1520
|
+
resolveName() {
|
|
1521
|
+
return null;
|
|
1522
|
+
},
|
|
1523
|
+
isSelfHandle() {
|
|
1524
|
+
return false;
|
|
1525
|
+
},
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function loadContacts() {
|
|
1530
|
+
if (platform() !== "darwin") return [];
|
|
1531
|
+
const script = `
|
|
1532
|
+
set output to ""
|
|
1533
|
+
tell application "Contacts"
|
|
1534
|
+
repeat with p in every person
|
|
1535
|
+
set pName to name of p
|
|
1536
|
+
set phList to ""
|
|
1537
|
+
repeat with ph in phones of p
|
|
1538
|
+
if phList is not "" then set phList to phList & ","
|
|
1539
|
+
set phList to phList & (value of ph)
|
|
1540
|
+
end repeat
|
|
1541
|
+
set eList to ""
|
|
1542
|
+
repeat with e in emails of p
|
|
1543
|
+
if eList is not "" then set eList to eList & ","
|
|
1544
|
+
set eList to eList & (value of e)
|
|
1545
|
+
end repeat
|
|
1546
|
+
set output to output & pName & "\\t" & phList & "\\t" & eList & "\\n"
|
|
1547
|
+
end repeat
|
|
1548
|
+
end tell
|
|
1549
|
+
return output`;
|
|
1550
|
+
|
|
1551
|
+
try {
|
|
1552
|
+
const raw = execFileSync("osascript", ["-e", script], {
|
|
1553
|
+
encoding: "utf8",
|
|
1554
|
+
timeout: 30_000,
|
|
1555
|
+
});
|
|
1556
|
+
return parseContacts(raw);
|
|
1557
|
+
} catch {
|
|
1558
|
+
return [];
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function loadMyCard() {
|
|
1563
|
+
if (platform() !== "darwin") return null;
|
|
1564
|
+
const script = `
|
|
1565
|
+
tell application "Contacts"
|
|
1566
|
+
set mc to my card
|
|
1567
|
+
set pName to name of mc
|
|
1568
|
+
set phList to ""
|
|
1569
|
+
repeat with ph in phones of mc
|
|
1570
|
+
if phList is not "" then set phList to phList & ","
|
|
1571
|
+
set phList to phList & (value of ph)
|
|
1572
|
+
end repeat
|
|
1573
|
+
set eList to ""
|
|
1574
|
+
repeat with e in emails of mc
|
|
1575
|
+
if eList is not "" then set eList to eList & ","
|
|
1576
|
+
set eList to eList & (value of e)
|
|
1577
|
+
end repeat
|
|
1578
|
+
return pName & "\\t" & phList & "\\t" & eList
|
|
1579
|
+
end tell`;
|
|
1580
|
+
|
|
1581
|
+
try {
|
|
1582
|
+
const raw = execFileSync("osascript", ["-e", script], {
|
|
1583
|
+
encoding: "utf8",
|
|
1584
|
+
timeout: 10_000,
|
|
1585
|
+
});
|
|
1586
|
+
return parseContacts(raw)[0] ?? null;
|
|
1587
|
+
} catch {
|
|
1588
|
+
return null;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function parseContacts(raw) {
|
|
1593
|
+
return String(raw)
|
|
1594
|
+
.split("\n")
|
|
1595
|
+
.filter(Boolean)
|
|
1596
|
+
.map((line) => {
|
|
1597
|
+
const [name, phones, emails] = line.split("\t");
|
|
1598
|
+
return {
|
|
1599
|
+
name: name?.trim() ?? "",
|
|
1600
|
+
phones: phones ? phones.split(",").map((phone) => phone.trim()).filter(Boolean) : [],
|
|
1601
|
+
emails: emails ? emails.split(",").map((email) => email.trim()).filter(Boolean) : [],
|
|
1602
|
+
};
|
|
1603
|
+
})
|
|
1604
|
+
.filter((contact) => contact.name);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function addHandleMapping(map, handle, name) {
|
|
1608
|
+
for (const candidate of handleCandidates(handle)) {
|
|
1609
|
+
map.set(candidate, name);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function addSelfHandle(set, handle) {
|
|
1614
|
+
for (const candidate of handleCandidates(handle)) {
|
|
1615
|
+
set.add(candidate);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function handleCandidates(handle) {
|
|
1620
|
+
const raw = String(handle ?? "").trim();
|
|
1621
|
+
if (!raw) return [];
|
|
1622
|
+
const lower = raw.toLowerCase();
|
|
1623
|
+
const normalized = normalizeHandle(raw);
|
|
1624
|
+
const candidates = new Set([raw, lower, normalized]);
|
|
1625
|
+
if (normalized.startsWith("+1") && normalized.length === 12) {
|
|
1626
|
+
candidates.add(normalized.slice(2));
|
|
1627
|
+
}
|
|
1628
|
+
if (/^\d{10}$/.test(normalized)) {
|
|
1629
|
+
candidates.add(`+1${normalized}`);
|
|
1630
|
+
}
|
|
1631
|
+
return [...candidates].filter(Boolean);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function normalizeHandle(handle) {
|
|
1635
|
+
const raw = String(handle ?? "").trim();
|
|
1636
|
+
if (raw.includes("@")) return raw.toLowerCase();
|
|
1637
|
+
const compact = raw.replace(/[^\d+]/g, "");
|
|
1638
|
+
if (/^1\d{10}$/.test(compact)) return `+${compact}`;
|
|
1639
|
+
if (/^\d{10}$/.test(compact)) return `+1${compact}`;
|
|
1640
|
+
return compact || raw.toLowerCase();
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function cleanChatName(name) {
|
|
1644
|
+
if (typeof name !== "string") return null;
|
|
1645
|
+
const trimmed = name.trim();
|
|
1646
|
+
return trimmed || null;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function parseDmHandleFromChatId(chatId) {
|
|
1650
|
+
const parts = String(chatId ?? "").split(";");
|
|
1651
|
+
if (parts.length >= 3 && parts[1] === "-") return parts.slice(2).join(";") || null;
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function parseSelectionIndexes(answer, max) {
|
|
1656
|
+
const indexes = new Set();
|
|
1657
|
+
for (const part of String(answer ?? "").split(/[,\s]+/).map((value) => value.trim()).filter(Boolean)) {
|
|
1658
|
+
const range = part.match(/^(\d+)-(\d+)$/);
|
|
1659
|
+
if (range) {
|
|
1660
|
+
const start = Number(range[1]);
|
|
1661
|
+
const end = Number(range[2]);
|
|
1662
|
+
for (let value = Math.min(start, end); value <= Math.max(start, end); value++) {
|
|
1663
|
+
if (value >= 1 && value <= max) indexes.add(value - 1);
|
|
1664
|
+
}
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
const value = Number(part);
|
|
1668
|
+
if (Number.isInteger(value) && value >= 1 && value <= max) indexes.add(value - 1);
|
|
1669
|
+
}
|
|
1670
|
+
return [...indexes].sort((a, b) => a - b);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function parseMessageChatIdsArg() {
|
|
1674
|
+
return parseAllowedChatIds(args["messages-chat-ids"] ?? args["message-chat-ids"] ?? args["messages-chats"]);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function parseAllowedChatIds(value) {
|
|
1678
|
+
if (!value) return [];
|
|
1679
|
+
const raw = Array.isArray(value) ? value : String(value).split(",");
|
|
1680
|
+
return [...new Set(raw.map((chatId) => String(chatId).trim()).filter(Boolean))];
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1181
1683
|
class MessagesBatchSender {
|
|
1182
1684
|
constructor(apiUrl, agentToken, userId) {
|
|
1183
1685
|
this.apiUrl = trimTrailingSlash(apiUrl);
|