@dench.com/cli 3.5.6-staging.344 → 3.6.0-staging.356
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 +65 -55
- package/app-sdk.d.ts +1286 -31
- package/apps.ts +1 -1
- package/campaign.ts +1949 -212
- package/crm.ts +21 -12
- package/cron.ts +6 -5
- package/dench.ts +76 -106
- package/fs-daemon.ts +6 -6
- package/image.ts +4 -4
- package/leads.ts +60 -22
- package/lib/api-schemas.ts +1094 -30
- package/lib/app-sdk-catalog.generated.ts +82 -6
- package/lib/app-sdk-surface.ts +240 -0
- package/lib/campaign-agent.ts +1564 -0
- package/lib/campaign-csv.ts +883 -0
- package/lib/campaign-filters.ts +141 -0
- package/lib/campaign-lines.ts +217 -0
- package/lib/campaign-steps.ts +721 -0
- package/lib/campaign-timeline.ts +189 -0
- package/lib/cli-version.ts +14 -0
- package/lib/command-registry.ts +491 -8
- package/lib/connection-source.ts +10 -0
- package/lib/enrichment-gateway.ts +22 -3
- package/lib/public-error.ts +41 -0
- package/lib/search-pricing.ts +9 -0
- package/package.json +1 -1
- package/search.ts +5 -34
- package/tools.ts +51 -42
- package/web.ts +1 -1
package/campaign.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* `dench campaign <subcommand>` — outreach campaigns (email + LinkedIn steps
|
|
3
3
|
* on one schedule) from the terminal, and therefore from chat.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* multichannel` drives the same rows and keeps working — this
|
|
8
|
-
* the fuller surface (
|
|
9
|
-
* Now/Next feed, per-person controls, the activity log).
|
|
5
|
+
* Most subcommands call the outreach functions through the authenticated
|
|
6
|
+
* client; the signal builder calls `/api/v1/campaigns/builder/*` over HTTP.
|
|
7
|
+
* `dench email multichannel` drives the same rows and keeps working — this
|
|
8
|
+
* namespace is the fuller surface (search and add people, schedule, steps
|
|
9
|
+
* round-trip, the Now/Next feed, per-person controls, the activity log).
|
|
10
10
|
*
|
|
11
11
|
* `<campaign>` is an id or an exact, case-insensitive name. A name shared by
|
|
12
12
|
* several live campaigns is an error that lists the ids.
|
|
@@ -20,6 +20,29 @@ import { readFile } from "node:fs/promises";
|
|
|
20
20
|
import type { ConvexHttpClient } from "convex/browser";
|
|
21
21
|
import { makeFunctionReference } from "convex/server";
|
|
22
22
|
import { collectPaginated } from "./email";
|
|
23
|
+
import { usesSearchFlags } from "./lib/campaign-filters";
|
|
24
|
+
import {
|
|
25
|
+
LINE_CENTS_PER_PERSON,
|
|
26
|
+
LinesFileError,
|
|
27
|
+
type LineTemplate,
|
|
28
|
+
linesTemplateFromFile,
|
|
29
|
+
normalizedLineKey,
|
|
30
|
+
} from "./lib/campaign-lines";
|
|
31
|
+
import {
|
|
32
|
+
checkCampaignSteps,
|
|
33
|
+
hasHtmlAndTextBody,
|
|
34
|
+
planStepEdits,
|
|
35
|
+
STEP_TYPES,
|
|
36
|
+
StepEditError,
|
|
37
|
+
staleHtmlSteps,
|
|
38
|
+
staleHtmlWarning,
|
|
39
|
+
stepToInput,
|
|
40
|
+
} from "./lib/campaign-steps";
|
|
41
|
+
import {
|
|
42
|
+
personAddress,
|
|
43
|
+
type TimelineResult,
|
|
44
|
+
timelineSteps,
|
|
45
|
+
} from "./lib/campaign-timeline";
|
|
23
46
|
import {
|
|
24
47
|
assertNoUnknownFlags,
|
|
25
48
|
CliArgError,
|
|
@@ -27,9 +50,18 @@ import {
|
|
|
27
50
|
hasFlag,
|
|
28
51
|
shift as shiftRaw,
|
|
29
52
|
} from "./lib/cli-args";
|
|
53
|
+
import { cliVersion } from "./lib/cli-version";
|
|
54
|
+
import { formatCents } from "./lib/search-pricing";
|
|
30
55
|
|
|
31
56
|
export class CampaignCliError extends Error {}
|
|
32
57
|
|
|
58
|
+
export function unknownSubcommandMessage(
|
|
59
|
+
subcommand: string,
|
|
60
|
+
version: string,
|
|
61
|
+
): string {
|
|
62
|
+
return `Unknown campaign subcommand: ${subcommand}. Run 'dench campaign help'. This is dench ${version}; a newer CLI may have it. Update with 'npm install -g @dench.com/cli@latest' or run 'npx -y -p @dench.com/cli@latest dench campaign ${subcommand} ...'.`;
|
|
63
|
+
}
|
|
64
|
+
|
|
33
65
|
type JsonRecord = Record<string, unknown>;
|
|
34
66
|
|
|
35
67
|
type CampaignCliContext = {
|
|
@@ -37,6 +69,7 @@ type CampaignCliContext = {
|
|
|
37
69
|
args: string[];
|
|
38
70
|
jsonOutput: boolean;
|
|
39
71
|
sessionToken?: string;
|
|
72
|
+
host?: string;
|
|
40
73
|
};
|
|
41
74
|
|
|
42
75
|
const OUTREACH = "functions/outreachSequences";
|
|
@@ -49,7 +82,6 @@ export const MAX_CAMPAIGN_PEOPLE = 300;
|
|
|
49
82
|
/** Page size used when reading a CRM view; 100 keeps rows under the byte budget. */
|
|
50
83
|
export const VIEW_PAGE_SIZE = 100;
|
|
51
84
|
const MAX_VIEW_PAGES = 50;
|
|
52
|
-
const MAX_STEPS = 20;
|
|
53
85
|
const PEOPLE_PREVIEW_LIMIT = 50;
|
|
54
86
|
const DEFAULT_EVENTS_PAGE_SIZE = 50;
|
|
55
87
|
const MAX_EVENTS_PAGE_SIZE = 200;
|
|
@@ -90,6 +122,7 @@ export type CampaignRow = {
|
|
|
90
122
|
ownerName?: string;
|
|
91
123
|
isOwner?: boolean;
|
|
92
124
|
createdAt?: number;
|
|
125
|
+
personalizationTemplate?: { variables?: Array<{ key: string }> };
|
|
93
126
|
};
|
|
94
127
|
|
|
95
128
|
type Enrollment = {
|
|
@@ -100,9 +133,11 @@ type Enrollment = {
|
|
|
100
133
|
nextRunAt?: number;
|
|
101
134
|
lastError?: string;
|
|
102
135
|
repliedAt?: number;
|
|
136
|
+
repliedChannel?: string;
|
|
103
137
|
linkedinProfileUrl?: string;
|
|
104
138
|
entryId?: string;
|
|
105
139
|
leadEntryId?: string;
|
|
140
|
+
firstContactedAt?: number;
|
|
106
141
|
};
|
|
107
142
|
|
|
108
143
|
type LinkedinHold = { reason: string; until?: number };
|
|
@@ -205,13 +240,23 @@ type CampaignLive = {
|
|
|
205
240
|
|
|
206
241
|
type LaunchBlockerGroup = {
|
|
207
242
|
code: string;
|
|
243
|
+
scope?: string;
|
|
208
244
|
title: string;
|
|
209
245
|
hint?: string;
|
|
210
246
|
stepIndex?: number;
|
|
211
|
-
people: Array<{
|
|
247
|
+
people: Array<{
|
|
248
|
+
enrollmentId?: string;
|
|
249
|
+
name: string;
|
|
250
|
+
fields?: Array<{ key: string; label: string }>;
|
|
251
|
+
}>;
|
|
212
252
|
count: number;
|
|
213
253
|
};
|
|
214
254
|
|
|
255
|
+
export type ExplainedBlocker = LaunchBlockerGroup & {
|
|
256
|
+
reasons: string[];
|
|
257
|
+
fix: string[];
|
|
258
|
+
};
|
|
259
|
+
|
|
215
260
|
type LaunchReadiness = {
|
|
216
261
|
ready: boolean;
|
|
217
262
|
fingerprint?: string;
|
|
@@ -568,38 +613,20 @@ export function businessHoursSchedule(timezone: string): SendSchedule {
|
|
|
568
613
|
};
|
|
569
614
|
}
|
|
570
615
|
|
|
571
|
-
export
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
"linkedin_connection",
|
|
575
|
-
"linkedin_message",
|
|
576
|
-
"linkedin_visit",
|
|
577
|
-
] as const;
|
|
578
|
-
|
|
579
|
-
const STEP_KEYS = new Set([
|
|
580
|
-
"type",
|
|
581
|
-
"waitHours",
|
|
582
|
-
"emailMailboxAccountId",
|
|
583
|
-
"emailSubject",
|
|
584
|
-
"emailHtmlBody",
|
|
585
|
-
"emailTextBody",
|
|
586
|
-
"emailReplyToPrevious",
|
|
587
|
-
"onlyIf",
|
|
588
|
-
"linkedinAccountId",
|
|
589
|
-
"linkedinMessageBody",
|
|
590
|
-
"linkedinMessageMode",
|
|
591
|
-
"connectionTimeoutHours",
|
|
592
|
-
]);
|
|
616
|
+
export { STEP_TYPES };
|
|
617
|
+
|
|
618
|
+
export type StepsFile = { steps: JsonRecord[]; warnings: string[] };
|
|
593
619
|
|
|
594
620
|
/**
|
|
595
621
|
* Parse a steps file: a JSON array of step objects (or `{ "steps": [...] }`).
|
|
596
622
|
* Checks the shape the server validates so a typo is caught here with a
|
|
597
623
|
* step number instead of a Convex validator message.
|
|
598
624
|
*/
|
|
599
|
-
export function
|
|
625
|
+
export function readStepsJson(
|
|
600
626
|
raw: string,
|
|
601
627
|
source = "The steps file",
|
|
602
|
-
|
|
628
|
+
options: { allowSingleBraces?: boolean } = {},
|
|
629
|
+
): StepsFile {
|
|
603
630
|
let parsed: unknown;
|
|
604
631
|
try {
|
|
605
632
|
parsed = JSON.parse(raw);
|
|
@@ -621,75 +648,28 @@ export function parseStepsJson(
|
|
|
621
648
|
if (steps.length === 0) {
|
|
622
649
|
throw new CampaignCliError(`${source} has no steps.`);
|
|
623
650
|
}
|
|
624
|
-
|
|
651
|
+
const check = checkCampaignSteps(steps, {
|
|
652
|
+
allowSingleBraces: options.allowSingleBraces,
|
|
653
|
+
singleBraceOverride: "--allow-single-braces",
|
|
654
|
+
});
|
|
655
|
+
if (check.errors.length > 0) {
|
|
625
656
|
throw new CampaignCliError(
|
|
626
|
-
|
|
657
|
+
check.errors.map((issue) => issue.message).join("\n"),
|
|
627
658
|
);
|
|
628
659
|
}
|
|
629
|
-
return steps
|
|
630
|
-
const label = `Step ${index + 1}`;
|
|
631
|
-
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
|
632
|
-
throw new CampaignCliError(`${label} must be an object with a "type".`);
|
|
633
|
-
}
|
|
634
|
-
const row = step as JsonRecord;
|
|
635
|
-
const type = row.type;
|
|
636
|
-
if (
|
|
637
|
-
typeof type !== "string" ||
|
|
638
|
-
!(STEP_TYPES as readonly string[]).includes(type)
|
|
639
|
-
) {
|
|
640
|
-
throw new CampaignCliError(
|
|
641
|
-
`${label} has type ${JSON.stringify(type)}. Use one of: ${STEP_TYPES.join(", ")}.`,
|
|
642
|
-
);
|
|
643
|
-
}
|
|
644
|
-
const unknown = Object.keys(row).filter((key) => !STEP_KEYS.has(key));
|
|
645
|
-
if (unknown.length > 0) {
|
|
646
|
-
throw new CampaignCliError(
|
|
647
|
-
`${label} has keys the engine does not know: ${unknown.join(", ")}. Allowed: ${[...STEP_KEYS].join(", ")}.`,
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
if (type === "wait") {
|
|
651
|
-
if (typeof row.waitHours !== "number" || !(row.waitHours > 0)) {
|
|
652
|
-
throw new CampaignCliError(
|
|
653
|
-
`${label} is a wait step and needs "waitHours" above 0.`,
|
|
654
|
-
);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
if (row.waitHours !== undefined && typeof row.waitHours !== "number") {
|
|
658
|
-
throw new CampaignCliError(
|
|
659
|
-
`${label}: "waitHours" must be a number of hours.`,
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
if (
|
|
663
|
-
row.onlyIf !== undefined &&
|
|
664
|
-
!["always", "connected", "not_connected"].includes(String(row.onlyIf))
|
|
665
|
-
) {
|
|
666
|
-
throw new CampaignCliError(
|
|
667
|
-
`${label}: "onlyIf" must be always, connected or not_connected.`,
|
|
668
|
-
);
|
|
669
|
-
}
|
|
670
|
-
if (
|
|
671
|
-
row.linkedinMessageMode !== undefined &&
|
|
672
|
-
!["skip_if_not_connected", "wait_until_connected"].includes(
|
|
673
|
-
String(row.linkedinMessageMode),
|
|
674
|
-
)
|
|
675
|
-
) {
|
|
676
|
-
throw new CampaignCliError(
|
|
677
|
-
`${label}: "linkedinMessageMode" must be skip_if_not_connected or wait_until_connected.`,
|
|
678
|
-
);
|
|
679
|
-
}
|
|
680
|
-
return row;
|
|
681
|
-
});
|
|
660
|
+
return { steps: steps as JsonRecord[], warnings: check.warnings };
|
|
682
661
|
}
|
|
683
662
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
return input;
|
|
663
|
+
export function parseStepsJson(
|
|
664
|
+
raw: string,
|
|
665
|
+
source = "The steps file",
|
|
666
|
+
options: { allowSingleBraces?: boolean } = {},
|
|
667
|
+
): JsonRecord[] {
|
|
668
|
+
return readStepsJson(raw, source, options).steps;
|
|
691
669
|
}
|
|
692
670
|
|
|
671
|
+
export { stepToInput };
|
|
672
|
+
|
|
693
673
|
/**
|
|
694
674
|
* A `--start`/`--end` value. A full timestamp is an instant already; a bare
|
|
695
675
|
* date (`2026-09-03`) stays a date until the campaign's timezone is known,
|
|
@@ -944,7 +924,7 @@ export function hasScheduleChanges(patch: SchedulePatch): boolean {
|
|
|
944
924
|
return Object.keys(patch).length > 0;
|
|
945
925
|
}
|
|
946
926
|
|
|
947
|
-
function localTimezone(): string {
|
|
927
|
+
export function localTimezone(): string {
|
|
948
928
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
949
929
|
}
|
|
950
930
|
|
|
@@ -1007,6 +987,8 @@ const STEP_LABELS: Record<string, string> = {
|
|
|
1007
987
|
linkedin_connection: "Connect on LinkedIn",
|
|
1008
988
|
linkedin_message: "LinkedIn message",
|
|
1009
989
|
linkedin_visit: "Visit profile",
|
|
990
|
+
linkedin_like_post: "React to a post",
|
|
991
|
+
linkedin_inmail: "LinkedIn InMail",
|
|
1010
992
|
};
|
|
1011
993
|
|
|
1012
994
|
function stepLabel(step: { type: string; waitHours?: unknown }): string {
|
|
@@ -1048,6 +1030,9 @@ const NOW_VERBS: Record<string, string> = {
|
|
|
1048
1030
|
profile: "Visiting the LinkedIn profile of",
|
|
1049
1031
|
invite: "Sending a connection request to",
|
|
1050
1032
|
message: "Sending a LinkedIn message to",
|
|
1033
|
+
like: "Reacting to a recent post by",
|
|
1034
|
+
inmail: "Sending an InMail to",
|
|
1035
|
+
withdraw: "Withdrawing the pending invitation for",
|
|
1051
1036
|
email: "Sending an email to",
|
|
1052
1037
|
};
|
|
1053
1038
|
|
|
@@ -1056,6 +1041,9 @@ const NEXT_VERBS: Record<string, string> = {
|
|
|
1056
1041
|
visit: "Visit the LinkedIn profile of",
|
|
1057
1042
|
invite: "Send a connection request to",
|
|
1058
1043
|
message: "Send a LinkedIn message to",
|
|
1044
|
+
like: "React to a recent post by",
|
|
1045
|
+
inmail: "Send an InMail to",
|
|
1046
|
+
withdraw: "Withdraw the pending invitation for",
|
|
1059
1047
|
email: "Send an email to",
|
|
1060
1048
|
wait: "Wait for",
|
|
1061
1049
|
};
|
|
@@ -1065,6 +1053,8 @@ const TOUCH_LABELS: Record<string, string> = {
|
|
|
1065
1053
|
linkedin_connection: "invite sent",
|
|
1066
1054
|
linkedin_message: "LinkedIn message sent",
|
|
1067
1055
|
linkedin_visit: "profile visited",
|
|
1056
|
+
linkedin_like_post: "post reaction sent",
|
|
1057
|
+
linkedin_inmail: "InMail sent",
|
|
1068
1058
|
wait: "wait finished",
|
|
1069
1059
|
};
|
|
1070
1060
|
|
|
@@ -1299,10 +1289,48 @@ async function runCreate(ctx: CampaignCliContext): Promise<void> {
|
|
|
1299
1289
|
`${formatWeekdays(s.weekdays)} ${s.startHour}-${s.endHour} ${s.timezone}`,
|
|
1300
1290
|
)
|
|
1301
1291
|
.join("; ")}`,
|
|
1302
|
-
|
|
1292
|
+
"Next: dench campaign steps set <campaign> --file steps.json",
|
|
1293
|
+
'Add people: dench campaign search --titles Founder --limit 25 --out audience.json, then dench campaign add <campaign> --people-file audience.json (or --csv, --list, --object lead --view "<view>")',
|
|
1294
|
+
"Then: dench campaign launch <campaign> --check",
|
|
1303
1295
|
]);
|
|
1304
1296
|
}
|
|
1305
1297
|
|
|
1298
|
+
async function runDuplicate(ctx: CampaignCliContext): Promise<void> {
|
|
1299
|
+
const name = valueFlag(ctx.args, "--name")?.trim();
|
|
1300
|
+
if (name === "") {
|
|
1301
|
+
throw new CampaignCliError("--name needs the name for the copy");
|
|
1302
|
+
}
|
|
1303
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
1304
|
+
unknownFlags(ctx.args, "campaign duplicate");
|
|
1305
|
+
const row = await resolveCampaign(ctx, reference);
|
|
1306
|
+
const created = (await callMutation(ctx, `${OUTREACH}:duplicateSequence`, {
|
|
1307
|
+
sequenceId: row._id,
|
|
1308
|
+
})) as { sequenceId: string };
|
|
1309
|
+
if (name !== undefined) {
|
|
1310
|
+
await callMutation(ctx, `${OUTREACH}:updateDraft`, {
|
|
1311
|
+
sequenceId: created.sequenceId,
|
|
1312
|
+
name,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
const copyName = name ?? `${row.name} copy`;
|
|
1316
|
+
const steps = row.stepCount ?? 0;
|
|
1317
|
+
out(
|
|
1318
|
+
ctx,
|
|
1319
|
+
{
|
|
1320
|
+
sequenceId: created.sequenceId,
|
|
1321
|
+
name: copyName,
|
|
1322
|
+
status: "draft",
|
|
1323
|
+
duplicatedFrom: row._id,
|
|
1324
|
+
stepCount: steps,
|
|
1325
|
+
people: 0,
|
|
1326
|
+
},
|
|
1327
|
+
[
|
|
1328
|
+
`Duplicated "${row.name}" as the draft "${copyName}" [${created.sequenceId}]: ${steps} ${steps === 1 ? "step" : "steps"}, the same schedule, senders and personalized lines, its lead source (paused), and nobody in it yet.`,
|
|
1329
|
+
`Next: dench campaign add ${created.sequenceId} ... (up to ${MAX_CAMPAIGN_PEOPLE} people), then dench campaign launch ${created.sequenceId} --check`,
|
|
1330
|
+
],
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1306
1334
|
async function runGet(ctx: CampaignCliContext): Promise<void> {
|
|
1307
1335
|
const reference = shift(ctx.args, "campaign id or name");
|
|
1308
1336
|
unknownFlags(ctx.args, "campaign get");
|
|
@@ -1393,7 +1421,9 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
|
|
|
1393
1421
|
" Next: nothing scheduled — everyone is finished, waiting on a reply, or stopped",
|
|
1394
1422
|
);
|
|
1395
1423
|
} else if (sequence.status === "draft") {
|
|
1396
|
-
lines.push(
|
|
1424
|
+
lines.push(
|
|
1425
|
+
" Next: check it is ready — dench campaign launch <campaign> --check (launch only after the user says yes)",
|
|
1426
|
+
);
|
|
1397
1427
|
}
|
|
1398
1428
|
if (live?.paused) {
|
|
1399
1429
|
const reason =
|
|
@@ -1429,7 +1459,7 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
|
|
|
1429
1459
|
lines.push("", `People (${shown.length} of ${enrollments.length})`);
|
|
1430
1460
|
if (enrollments.length === 0) {
|
|
1431
1461
|
lines.push(
|
|
1432
|
-
|
|
1462
|
+
" nobody yet — dench campaign search --titles Founder --limit 25 --out audience.json, then dench campaign add <campaign> --people-file audience.json",
|
|
1433
1463
|
);
|
|
1434
1464
|
}
|
|
1435
1465
|
for (const enrollment of shown)
|
|
@@ -1471,7 +1501,7 @@ async function runSet(ctx: CampaignCliContext): Promise<void> {
|
|
|
1471
1501
|
}
|
|
1472
1502
|
if (row.status === "archived") {
|
|
1473
1503
|
throw new CampaignCliError(
|
|
1474
|
-
|
|
1504
|
+
`This campaign is archived and can't be changed. Start again from a copy: dench campaign duplicate ${row._id}`,
|
|
1475
1505
|
);
|
|
1476
1506
|
}
|
|
1477
1507
|
// One updateDraft carries the rename too, so the server accepts or rejects
|
|
@@ -1504,30 +1534,43 @@ async function runSet(ctx: CampaignCliContext): Promise<void> {
|
|
|
1504
1534
|
]);
|
|
1505
1535
|
}
|
|
1506
1536
|
|
|
1537
|
+
const STEPS_ACTIONS: Record<string, string> = {
|
|
1538
|
+
get: "get",
|
|
1539
|
+
show: "get",
|
|
1540
|
+
set: "set",
|
|
1541
|
+
replace: "set",
|
|
1542
|
+
edit: "edit",
|
|
1543
|
+
update: "edit",
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1507
1546
|
async function runSteps(ctx: CampaignCliContext): Promise<void> {
|
|
1508
1547
|
const file = valueFlag(ctx.args, "--file");
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
action === "replace"
|
|
1515
|
-
) {
|
|
1516
|
-
ctx.args.shift();
|
|
1517
|
-
action = action === "show" ? "get" : action === "replace" ? "set" : action;
|
|
1518
|
-
} else {
|
|
1519
|
-
action = file ? "set" : "get";
|
|
1520
|
-
}
|
|
1548
|
+
const allowSingleBraces = hasFlag(ctx.args, "--allow-single-braces");
|
|
1549
|
+
const withIds = hasFlag(ctx.args, "--with-ids");
|
|
1550
|
+
let action = STEPS_ACTIONS[ctx.args[0] ?? ""];
|
|
1551
|
+
if (action) ctx.args.shift();
|
|
1552
|
+
else action = file ? "set" : "get";
|
|
1521
1553
|
const reference = shift(ctx.args, "campaign id or name");
|
|
1522
1554
|
unknownFlags(ctx.args, `campaign steps ${action}`);
|
|
1555
|
+
if (withIds && action !== "get") {
|
|
1556
|
+
throw new CampaignCliError(
|
|
1557
|
+
"--with-ids is for steps get. steps edit reads stepId and updatedAt from the file.",
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1523
1560
|
const row = await resolveCampaign(ctx, reference);
|
|
1524
1561
|
if (action === "get") {
|
|
1525
1562
|
const detail = await loadDetail(ctx, String(row._id));
|
|
1526
1563
|
const steps = [...detail.steps]
|
|
1527
1564
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
1528
|
-
.map((step) =>
|
|
1529
|
-
|
|
1530
|
-
|
|
1565
|
+
.map((step) =>
|
|
1566
|
+
withIds
|
|
1567
|
+
? {
|
|
1568
|
+
stepId: String(step._id),
|
|
1569
|
+
updatedAt: step.updatedAt,
|
|
1570
|
+
...stepToInput(step),
|
|
1571
|
+
}
|
|
1572
|
+
: stepToInput(step),
|
|
1573
|
+
);
|
|
1531
1574
|
console.log(JSON.stringify(steps, null, 2));
|
|
1532
1575
|
return;
|
|
1533
1576
|
}
|
|
@@ -1536,26 +1579,138 @@ async function runSteps(ctx: CampaignCliContext): Promise<void> {
|
|
|
1536
1579
|
"Missing required --file steps.json (a JSON array of steps).",
|
|
1537
1580
|
);
|
|
1538
1581
|
}
|
|
1582
|
+
if (action === "edit") return runStepsEdit(ctx, row, file, allowSingleBraces);
|
|
1539
1583
|
if (row.status !== "draft") {
|
|
1540
1584
|
throw new CampaignCliError(
|
|
1541
|
-
|
|
1585
|
+
`steps set only works while a campaign is a draft. After launch, change a step's text or wait in place: dench campaign steps get ${row._id} --with-ids > steps.json, edit it, then dench campaign steps edit ${row._id} --file steps.json (the number, order and types of steps stay fixed).`,
|
|
1542
1586
|
);
|
|
1543
1587
|
}
|
|
1544
1588
|
const raw = await readFile(file, "utf8").catch(() => {
|
|
1545
1589
|
throw new CampaignCliError(`Cannot read ${file}`);
|
|
1546
1590
|
});
|
|
1547
|
-
const steps =
|
|
1591
|
+
const { steps, warnings } = readStepsJson(raw, file, { allowSingleBraces });
|
|
1592
|
+
if (steps.some(hasHtmlAndTextBody)) {
|
|
1593
|
+
const stored = (await loadDetail(ctx, String(row._id))).steps;
|
|
1594
|
+
for (const index of staleHtmlSteps(steps, stored)) {
|
|
1595
|
+
delete steps[index].emailHtmlBody;
|
|
1596
|
+
warnings.push(staleHtmlWarning(index));
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1548
1599
|
const result = (await callMutation(ctx, `${OUTREACH}:replaceSteps`, {
|
|
1549
1600
|
sequenceId: row._id,
|
|
1550
1601
|
steps,
|
|
1551
1602
|
})) as { ok: true; stepCount: number };
|
|
1552
|
-
out(ctx, { ...result, sequenceId: row._id, steps }, [
|
|
1603
|
+
out(ctx, { ...result, sequenceId: row._id, steps, warnings }, [
|
|
1553
1604
|
`Saved ${result.stepCount} steps on "${row.name}": ${steps
|
|
1554
1605
|
.map((step) => stepLabel(step as { type: string; waitHours?: unknown }))
|
|
1555
1606
|
.join(" → ")}`,
|
|
1607
|
+
...warnings.map((warning) => `Warning: ${warning}`),
|
|
1556
1608
|
]);
|
|
1557
1609
|
}
|
|
1558
1610
|
|
|
1611
|
+
function stepsInFile(parsed: unknown): unknown[] | null {
|
|
1612
|
+
if (Array.isArray(parsed)) return parsed;
|
|
1613
|
+
if (
|
|
1614
|
+
parsed &&
|
|
1615
|
+
typeof parsed === "object" &&
|
|
1616
|
+
Array.isArray((parsed as JsonRecord).steps)
|
|
1617
|
+
)
|
|
1618
|
+
return (parsed as JsonRecord).steps as unknown[];
|
|
1619
|
+
return null;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
async function runStepsEdit(
|
|
1623
|
+
ctx: CampaignCliContext,
|
|
1624
|
+
row: CampaignRow,
|
|
1625
|
+
file: string,
|
|
1626
|
+
allowSingleBraces: boolean,
|
|
1627
|
+
): Promise<void> {
|
|
1628
|
+
if (row.status === "archived") {
|
|
1629
|
+
throw new CampaignCliError(
|
|
1630
|
+
`"${row.name}" is archived, so its steps can't change. Start again from a copy: dench campaign duplicate ${row._id}`,
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
const raw = await readFile(file, "utf8").catch(() => {
|
|
1634
|
+
throw new CampaignCliError(`Cannot read ${file}`);
|
|
1635
|
+
});
|
|
1636
|
+
let parsed: unknown;
|
|
1637
|
+
try {
|
|
1638
|
+
parsed = JSON.parse(raw);
|
|
1639
|
+
} catch {
|
|
1640
|
+
throw new CampaignCliError(`${file} is not valid JSON.`);
|
|
1641
|
+
}
|
|
1642
|
+
const entries = stepsInFile(parsed);
|
|
1643
|
+
if (!entries) {
|
|
1644
|
+
throw new CampaignCliError(
|
|
1645
|
+
`${file} must be a JSON array of steps (or an object with a "steps" array), like dench campaign steps get ${row._id} --with-ids prints.`,
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
const detail = await loadDetail(ctx, String(row._id));
|
|
1649
|
+
const status = detail.sequence.status ?? row.status;
|
|
1650
|
+
const draft = status === "draft";
|
|
1651
|
+
let plan: ReturnType<typeof planStepEdits>;
|
|
1652
|
+
try {
|
|
1653
|
+
plan = planStepEdits(detail.steps, entries, {
|
|
1654
|
+
allowSingleBraces,
|
|
1655
|
+
singleBraceOverride: "--allow-single-braces",
|
|
1656
|
+
campaign: String(row._id),
|
|
1657
|
+
draft,
|
|
1658
|
+
});
|
|
1659
|
+
} catch (error) {
|
|
1660
|
+
if (error instanceof StepEditError)
|
|
1661
|
+
throw new CampaignCliError(error.message);
|
|
1662
|
+
throw error;
|
|
1663
|
+
}
|
|
1664
|
+
const warningLines = plan.warnings.map((warning) => `Warning: ${warning}`);
|
|
1665
|
+
if (plan.changes.length === 0) {
|
|
1666
|
+
out(
|
|
1667
|
+
ctx,
|
|
1668
|
+
{
|
|
1669
|
+
ok: true,
|
|
1670
|
+
sequenceId: row._id,
|
|
1671
|
+
status,
|
|
1672
|
+
changed: [],
|
|
1673
|
+
warnings: plan.warnings,
|
|
1674
|
+
},
|
|
1675
|
+
[
|
|
1676
|
+
`No step in ${file} differs from "${row.name}", so nothing was saved.`,
|
|
1677
|
+
...warningLines,
|
|
1678
|
+
],
|
|
1679
|
+
);
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (draft) {
|
|
1683
|
+
await callMutation(ctx, `${OUTREACH}:replaceSteps`, {
|
|
1684
|
+
sequenceId: row._id,
|
|
1685
|
+
steps: plan.steps,
|
|
1686
|
+
});
|
|
1687
|
+
} else {
|
|
1688
|
+
await callMutation(ctx, `${OUTREACH}:updateSteps`, {
|
|
1689
|
+
sequenceId: row._id,
|
|
1690
|
+
steps: plan.updates,
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
out(
|
|
1694
|
+
ctx,
|
|
1695
|
+
{
|
|
1696
|
+
ok: true,
|
|
1697
|
+
sequenceId: row._id,
|
|
1698
|
+
status,
|
|
1699
|
+
saved: draft ? "replaced" : "updated in place",
|
|
1700
|
+
changed: plan.changes,
|
|
1701
|
+
warnings: plan.warnings,
|
|
1702
|
+
},
|
|
1703
|
+
[
|
|
1704
|
+
`Updated ${plan.changes.length} of ${plan.steps.length} steps on "${row.name}"${draft ? "" : " for sends from now on; messages already sent are unchanged"}:`,
|
|
1705
|
+
...plan.changes.map(
|
|
1706
|
+
(change) =>
|
|
1707
|
+
` Step ${change.step} (${stepLabel(plan.steps[change.step - 1] as { type: string; waitHours?: unknown })}): ${change.fields.join(", ")}`,
|
|
1708
|
+
),
|
|
1709
|
+
...warningLines,
|
|
1710
|
+
],
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1559
1714
|
function emptyTotals(): EnrollFromCrmResult {
|
|
1560
1715
|
return {
|
|
1561
1716
|
added: 0,
|
|
@@ -1703,7 +1858,7 @@ function describeEnrollTotals(
|
|
|
1703
1858
|
}
|
|
1704
1859
|
if (totals.duplicatesElsewhere > 0) {
|
|
1705
1860
|
lines.push(
|
|
1706
|
-
`
|
|
1861
|
+
` Enrolled in another campaign, skipped: ${totals.duplicatesElsewhere} (pass --allow-duplicates to add anyway)`,
|
|
1707
1862
|
);
|
|
1708
1863
|
}
|
|
1709
1864
|
if (totals.suppressed > 0)
|
|
@@ -1738,6 +1893,7 @@ function describeEnrollTotals(
|
|
|
1738
1893
|
}
|
|
1739
1894
|
|
|
1740
1895
|
async function runAdd(ctx: CampaignCliContext): Promise<void> {
|
|
1896
|
+
if (usesSearchFlags(ctx.args)) return runAgentCommand(ctx, "add");
|
|
1741
1897
|
const objectName = valueFlag(ctx.args, "--object");
|
|
1742
1898
|
const viewName = valueFlag(ctx.args, "--view");
|
|
1743
1899
|
const entriesRaw = valueFlag(ctx.args, "--entries");
|
|
@@ -1754,7 +1910,7 @@ async function runAdd(ctx: CampaignCliContext): Promise<void> {
|
|
|
1754
1910
|
unknownFlags(ctx.args, "campaign add");
|
|
1755
1911
|
|
|
1756
1912
|
const usage =
|
|
1757
|
-
'Usage: dench campaign add <campaign> --object <name> --view "<view>" | --object <name> --entries id1,id2 | [--name "Ada"] --email ada@x.com and/or --linkedin-url https://linkedin.com/in/ada';
|
|
1913
|
+
'Usage: dench campaign add <campaign> --titles Founder --locations "United States" | --search \'{"titles":["Founder"]}\' | --people-file audience.json | --csv people.csv | --list <artifactId> | --object <name> --view "<view>" | --object <name> --entries id1,id2 | [--name "Ada"] --email ada@x.com and/or --linkedin-url https://linkedin.com/in/ada';
|
|
1758
1914
|
const row = await resolveCampaign(ctx, reference);
|
|
1759
1915
|
if (row.status === "archived") {
|
|
1760
1916
|
throw new CampaignCliError(
|
|
@@ -1856,7 +2012,7 @@ async function runAdd(ctx: CampaignCliContext): Promise<void> {
|
|
|
1856
2012
|
}
|
|
1857
2013
|
if (!email && !linkedinProfileUrl) {
|
|
1858
2014
|
throw new CampaignCliError(
|
|
1859
|
-
`Pass --email and/or --linkedin-url for one person,
|
|
2015
|
+
`Pass --email and/or --linkedin-url for one person, --object with --view/--entries for CRM records, or search filters (--titles, --search), --people-file, --csv or --list. ${usage}`,
|
|
1860
2016
|
);
|
|
1861
2017
|
}
|
|
1862
2018
|
const result = (await callMutation(ctx, `${OUTREACH}:addEnrollment`, {
|
|
@@ -1871,31 +2027,272 @@ async function runAdd(ctx: CampaignCliContext): Promise<void> {
|
|
|
1871
2027
|
]);
|
|
1872
2028
|
}
|
|
1873
2029
|
|
|
1874
|
-
|
|
1875
|
-
|
|
2030
|
+
const STEP_BLOCKER_REASONS: Record<string, RegExp> = {
|
|
2031
|
+
email_sender_missing: /sender is missing|needs a sender mailbox/i,
|
|
2032
|
+
linkedin_sender_missing: /sender is missing|needs a sender account/i,
|
|
2033
|
+
email_sender_foreign: /sender is private to another member/i,
|
|
2034
|
+
linkedin_sender_foreign: /sender is private to another member/i,
|
|
2035
|
+
email_sender_disconnected: /sender must be reconnected/i,
|
|
2036
|
+
linkedin_sender_disconnected: /sender must be reconnected/i,
|
|
2037
|
+
reply_without_prior_email: /replies to a previous email/i,
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
function blockerReasons(
|
|
2041
|
+
group: LaunchBlockerGroup,
|
|
2042
|
+
reasons: readonly string[],
|
|
2043
|
+
): string[] {
|
|
2044
|
+
if (typeof group.stepIndex !== "number") return [];
|
|
2045
|
+
const step = new RegExp(`\\bstep ${group.stepIndex + 1}\\b`, "i");
|
|
2046
|
+
const forStep = reasons.filter((reason) => step.test(reason));
|
|
2047
|
+
const own = STEP_BLOCKER_REASONS[group.code];
|
|
2048
|
+
if (own) return forStep.filter((reason) => own.test(reason));
|
|
2049
|
+
return forStep.filter(
|
|
2050
|
+
(reason) =>
|
|
2051
|
+
!Object.values(STEP_BLOCKER_REASONS).some((pattern) =>
|
|
2052
|
+
pattern.test(reason),
|
|
2053
|
+
),
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
const STEPS_FIX = (id: string) => [
|
|
2058
|
+
`dench campaign steps get ${id} > steps.json`,
|
|
2059
|
+
`dench campaign steps set ${id} --file steps.json`,
|
|
2060
|
+
];
|
|
2061
|
+
|
|
2062
|
+
const BLOCKER_FIXES: Record<string, (id: string) => string[]> = {
|
|
2063
|
+
no_steps: STEPS_FIX,
|
|
2064
|
+
too_many_steps: STEPS_FIX,
|
|
2065
|
+
step_invalid: STEPS_FIX,
|
|
2066
|
+
reply_without_prior_email: STEPS_FIX,
|
|
2067
|
+
person_empty_subject: STEPS_FIX,
|
|
2068
|
+
person_empty_email_body: STEPS_FIX,
|
|
2069
|
+
person_empty_linkedin_message: STEPS_FIX,
|
|
2070
|
+
person_linkedin_message_too_long: STEPS_FIX,
|
|
2071
|
+
person_missing_email_fields: STEPS_FIX,
|
|
2072
|
+
person_missing_linkedin_fields: STEPS_FIX,
|
|
2073
|
+
too_many_prospects: (id) => [
|
|
2074
|
+
`dench campaign people ${id}`,
|
|
2075
|
+
`dench campaign remove ${id} --person <enrollmentId>`,
|
|
2076
|
+
],
|
|
2077
|
+
linkedin_duplicate_conflict: (id) => [
|
|
2078
|
+
`dench campaign people ${id}`,
|
|
2079
|
+
`dench campaign remove ${id} --person <enrollmentId>`,
|
|
2080
|
+
"dench campaign archive <otherDraftCampaign>",
|
|
2081
|
+
"dench campaign stop <otherCampaign> --person <otherEnrollmentId>",
|
|
2082
|
+
],
|
|
2083
|
+
person_duplicate_sequence: () => [
|
|
2084
|
+
"dench campaign list",
|
|
2085
|
+
"dench campaign archive <otherDraftCampaign>",
|
|
2086
|
+
"dench campaign stop <otherCampaign> --person <otherEnrollmentId>",
|
|
2087
|
+
],
|
|
2088
|
+
no_prospects: (id) => [
|
|
2089
|
+
`dench campaign add ${id} --titles "<title>" --locations "<location>"`,
|
|
2090
|
+
`dench campaign add ${id} --people-file audience.json`,
|
|
2091
|
+
],
|
|
2092
|
+
missing_business_hours: (id) => [
|
|
2093
|
+
`dench campaign set ${id} --timezone <IANA timezone> --window "Mon-Fri 9-18"`,
|
|
2094
|
+
],
|
|
2095
|
+
email_sender_missing: (id) => [
|
|
2096
|
+
"dench campaign accounts",
|
|
2097
|
+
`dench campaign set ${id} --email-mailboxes <emailMailboxAccountId>`,
|
|
2098
|
+
],
|
|
2099
|
+
email_sender_foreign: (id) => [
|
|
2100
|
+
"dench campaign accounts",
|
|
2101
|
+
`dench campaign set ${id} --email-mailboxes <emailMailboxAccountId>`,
|
|
2102
|
+
],
|
|
2103
|
+
email_sender_disconnected: (id) => [
|
|
2104
|
+
"dench campaign accounts",
|
|
2105
|
+
`dench campaign set ${id} --email-mailboxes <emailMailboxAccountId>`,
|
|
2106
|
+
],
|
|
2107
|
+
linkedin_sender_missing: (id) => [
|
|
2108
|
+
"dench campaign accounts",
|
|
2109
|
+
`dench campaign set ${id} --linkedin-accounts <linkedinAccountId>`,
|
|
2110
|
+
],
|
|
2111
|
+
linkedin_sender_foreign: (id) => [
|
|
2112
|
+
"dench campaign accounts",
|
|
2113
|
+
`dench campaign set ${id} --linkedin-accounts <linkedinAccountId>`,
|
|
2114
|
+
],
|
|
2115
|
+
linkedin_sender_disconnected: (id) => [
|
|
2116
|
+
"dench campaign accounts",
|
|
2117
|
+
`dench campaign set ${id} --linkedin-accounts <linkedinAccountId>`,
|
|
2118
|
+
],
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
const REMOVABLE_BLOCKERS = new Set([
|
|
2122
|
+
"audience_already_started",
|
|
2123
|
+
"person_no_channel",
|
|
2124
|
+
"person_invalid_email",
|
|
2125
|
+
"person_suppressed",
|
|
2126
|
+
"person_duplicate_sequence",
|
|
2127
|
+
"person_missing_email_fields",
|
|
2128
|
+
"person_missing_linkedin_fields",
|
|
2129
|
+
]);
|
|
2130
|
+
|
|
2131
|
+
const MAX_REMOVE_FIXES = 5;
|
|
2132
|
+
|
|
2133
|
+
function removeFixes(group: LaunchBlockerGroup, sequenceId: string): string[] {
|
|
2134
|
+
const ids = group.people.flatMap((person) =>
|
|
2135
|
+
person.enrollmentId ? [person.enrollmentId] : [],
|
|
2136
|
+
);
|
|
2137
|
+
if (!ids.length)
|
|
2138
|
+
return [`dench campaign remove ${sequenceId} --person <enrollmentId>`];
|
|
2139
|
+
return [
|
|
2140
|
+
...ids
|
|
2141
|
+
.slice(0, MAX_REMOVE_FIXES)
|
|
2142
|
+
.map((id) => `dench campaign remove ${sequenceId} --person ${id}`),
|
|
2143
|
+
...(ids.length > MAX_REMOVE_FIXES
|
|
2144
|
+
? [`dench campaign people ${sequenceId}`]
|
|
2145
|
+
: []),
|
|
2146
|
+
];
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
const PERSON_SET_FIXES: Record<
|
|
2150
|
+
string,
|
|
2151
|
+
(person: LaunchBlockerGroup["people"][number]) => string
|
|
2152
|
+
> = {
|
|
2153
|
+
person_no_channel: () => "--email <address> and/or --linkedin-url <url>",
|
|
2154
|
+
person_invalid_email: () => "--email <address>",
|
|
2155
|
+
person_missing_email_fields: (person) => fieldFlags(person),
|
|
2156
|
+
person_missing_linkedin_fields: (person) => fieldFlags(person),
|
|
2157
|
+
};
|
|
2158
|
+
|
|
2159
|
+
function fieldFlags(person: LaunchBlockerGroup["people"][number]): string {
|
|
2160
|
+
const keys = (person.fields ?? []).map((field) => field.key);
|
|
2161
|
+
return keys.length
|
|
2162
|
+
? keys.map((key) => `--field "${key}=<value>"`).join(" ")
|
|
2163
|
+
: '--field "<Field>=<value>"';
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function personSetFixes(
|
|
2167
|
+
group: LaunchBlockerGroup,
|
|
2168
|
+
sequenceId: string,
|
|
2169
|
+
): string[] {
|
|
2170
|
+
const flags = PERSON_SET_FIXES[group.code];
|
|
2171
|
+
if (!flags) return [];
|
|
2172
|
+
return group.people
|
|
2173
|
+
.filter((person) => person.enrollmentId)
|
|
2174
|
+
.slice(0, MAX_REMOVE_FIXES)
|
|
2175
|
+
.map(
|
|
2176
|
+
(person) =>
|
|
2177
|
+
`dench campaign person set ${sequenceId} --person ${person.enrollmentId} ${flags(person)}`,
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
export function explainBlockers(
|
|
2182
|
+
readiness: LaunchReadiness,
|
|
2183
|
+
sequenceId: string,
|
|
2184
|
+
): ExplainedBlocker[] {
|
|
2185
|
+
const reasons = readiness.blockingReasons ?? [];
|
|
1876
2186
|
const groups = readiness.blockers ?? [];
|
|
1877
|
-
if (groups.length
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
2187
|
+
if (!groups.length)
|
|
2188
|
+
return reasons.map((reason) => ({
|
|
2189
|
+
code: "blocked",
|
|
2190
|
+
title: reason,
|
|
2191
|
+
people: [],
|
|
2192
|
+
count: 1,
|
|
2193
|
+
reasons: [],
|
|
2194
|
+
fix: [],
|
|
2195
|
+
}));
|
|
2196
|
+
return groups.map((group) => ({
|
|
2197
|
+
...group,
|
|
2198
|
+
reasons: blockerReasons(group, reasons),
|
|
2199
|
+
fix: [
|
|
2200
|
+
...(BLOCKER_FIXES[group.code]?.(sequenceId) ?? []),
|
|
2201
|
+
...personSetFixes(group, sequenceId),
|
|
2202
|
+
...(REMOVABLE_BLOCKERS.has(group.code)
|
|
2203
|
+
? removeFixes(group, sequenceId)
|
|
2204
|
+
: []),
|
|
2205
|
+
],
|
|
2206
|
+
}));
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function describeBlockers(blockers: readonly ExplainedBlocker[]): string[] {
|
|
2210
|
+
const lines: string[] = [];
|
|
2211
|
+
for (const group of blockers) {
|
|
2212
|
+
const step =
|
|
2213
|
+
typeof group.stepIndex === "number"
|
|
2214
|
+
? ` (step ${group.stepIndex + 1})`
|
|
2215
|
+
: "";
|
|
2216
|
+
const names = group.people.slice(0, 3).map((person) => {
|
|
2217
|
+
const fields = (person.fields ?? []).map((field) => field.label);
|
|
2218
|
+
return fields.length
|
|
2219
|
+
? `${person.name} (missing ${fields.join(", ")})`
|
|
2220
|
+
: person.name;
|
|
2221
|
+
});
|
|
2222
|
+
const more =
|
|
2223
|
+
group.people.length > 3 ? `, +${group.people.length - 3} more` : "";
|
|
2224
|
+
const who = names.length > 0 ? ` — ${names.join(", ")}${more}` : "";
|
|
2225
|
+
lines.push(` - ${group.title}${step}${who}`);
|
|
2226
|
+
for (const reason of group.reasons) lines.push(` why: ${reason}`);
|
|
2227
|
+
if (group.hint) lines.push(` ${group.hint}`);
|
|
2228
|
+
for (const command of group.fix) lines.push(` fix: ${command}`);
|
|
1894
2229
|
}
|
|
1895
2230
|
return lines;
|
|
1896
2231
|
}
|
|
1897
2232
|
|
|
2233
|
+
function readinessCounts(readiness: LaunchReadiness): string {
|
|
2234
|
+
const channels = [
|
|
2235
|
+
readiness.usesEmail ? "email" : "",
|
|
2236
|
+
readiness.usesLinkedin ? "LinkedIn" : "",
|
|
2237
|
+
].filter(Boolean);
|
|
2238
|
+
const people = readiness.audienceCount ?? 0;
|
|
2239
|
+
const steps = readiness.stepCount ?? 0;
|
|
2240
|
+
return `${people} ${people === 1 ? "person" : "people"}, ${steps} ${steps === 1 ? "step" : "steps"}${channels.length ? ` (${channels.join(" and ")})` : ""}`;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
function launchCommand(row: CampaignRow, readiness: LaunchReadiness): string {
|
|
2244
|
+
return `dench campaign launch ${row._id}${readiness.usesLinkedin ? " --ack-linkedin-risk" : ""}`;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
async function runLaunchCheck(
|
|
2248
|
+
ctx: CampaignCliContext,
|
|
2249
|
+
row: CampaignRow,
|
|
2250
|
+
readiness: LaunchReadiness,
|
|
2251
|
+
): Promise<void> {
|
|
2252
|
+
const ready =
|
|
2253
|
+
readiness.ready === true && typeof readiness.fingerprint === "string";
|
|
2254
|
+
const blockers = explainBlockers(readiness, String(row._id));
|
|
2255
|
+
const warnings = readiness.warnings ?? [];
|
|
2256
|
+
const payload = {
|
|
2257
|
+
sequenceId: row._id,
|
|
2258
|
+
name: row.name,
|
|
2259
|
+
status: row.status,
|
|
2260
|
+
ready,
|
|
2261
|
+
fingerprint: readiness.fingerprint ?? null,
|
|
2262
|
+
audienceCount: readiness.audienceCount ?? 0,
|
|
2263
|
+
stepCount: readiness.stepCount ?? 0,
|
|
2264
|
+
usesEmail: readiness.usesEmail === true,
|
|
2265
|
+
usesLinkedin: readiness.usesLinkedin === true,
|
|
2266
|
+
needsLinkedinAck: readiness.usesLinkedin === true,
|
|
2267
|
+
blockers,
|
|
2268
|
+
warnings,
|
|
2269
|
+
next: ready
|
|
2270
|
+
? launchCommand(row, readiness)
|
|
2271
|
+
: `dench campaign launch ${row._id} --check`,
|
|
2272
|
+
};
|
|
2273
|
+
const lines = ready
|
|
2274
|
+
? [`"${row.name}" is ready to launch: ${readinessCounts(readiness)}.`]
|
|
2275
|
+
: [
|
|
2276
|
+
`"${row.name}" is not ready to launch (${blockers.length} ${blockers.length === 1 ? "blocker" : "blockers"}; ${readinessCounts(readiness)}):`,
|
|
2277
|
+
...describeBlockers(blockers),
|
|
2278
|
+
];
|
|
2279
|
+
if (warnings.length)
|
|
2280
|
+
lines.push("Warnings:", ...warnings.map((warning) => ` - ${warning}`));
|
|
2281
|
+
lines.push(`Fingerprint: ${readiness.fingerprint ?? "none"}`);
|
|
2282
|
+
lines.push(
|
|
2283
|
+
ready
|
|
2284
|
+
? `Nothing was launched. After the user says yes: ${launchCommand(row, readiness)}${readiness.usesLinkedin ? " (LinkedIn steps need the account owner to accept the LinkedIn risk)" : ""}`
|
|
2285
|
+
: `Nothing was launched. Fix the blockers, then run: dench campaign launch ${row._id} --check`,
|
|
2286
|
+
);
|
|
2287
|
+
out(ctx, payload, lines);
|
|
2288
|
+
if (!ready)
|
|
2289
|
+
throw new CampaignCliError(
|
|
2290
|
+
`"${row.name}" is not ready to launch: ${blockers.length} ${blockers.length === 1 ? "blocker" : "blockers"}. Nothing was launched.`,
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
|
|
1898
2294
|
async function runLaunch(ctx: CampaignCliContext): Promise<void> {
|
|
2295
|
+
const check = hasFlag(ctx.args, "--check");
|
|
1899
2296
|
const ack = hasFlag(ctx.args, "--ack-linkedin-risk");
|
|
1900
2297
|
const reference = shift(ctx.args, "campaign id or name");
|
|
1901
2298
|
unknownFlags(ctx.args, "campaign launch");
|
|
@@ -1910,10 +2307,11 @@ async function runLaunch(ctx: CampaignCliContext): Promise<void> {
|
|
|
1910
2307
|
const readiness = (await callQuery(ctx, `${OUTREACH}:previewLaunch`, {
|
|
1911
2308
|
sequenceId: row._id,
|
|
1912
2309
|
})) as LaunchReadiness;
|
|
2310
|
+
if (check) return runLaunchCheck(ctx, row, readiness);
|
|
1913
2311
|
if (readiness.ready !== true || typeof readiness.fingerprint !== "string") {
|
|
1914
2312
|
const lines = [
|
|
1915
2313
|
`"${row.name}" is not ready to launch:`,
|
|
1916
|
-
...describeBlockers(readiness),
|
|
2314
|
+
...describeBlockers(explainBlockers(readiness, String(row._id))),
|
|
1917
2315
|
];
|
|
1918
2316
|
if ((readiness.warnings ?? []).length > 0) {
|
|
1919
2317
|
lines.push(
|
|
@@ -1959,9 +2357,10 @@ async function runLaunch(ctx: CampaignCliContext): Promise<void> {
|
|
|
1959
2357
|
async function runLifecycle(
|
|
1960
2358
|
ctx: CampaignCliContext,
|
|
1961
2359
|
action: "pause" | "resume" | "archive",
|
|
2360
|
+
invoked: string = action,
|
|
1962
2361
|
): Promise<void> {
|
|
1963
2362
|
const reference = shift(ctx.args, "campaign id or name");
|
|
1964
|
-
unknownFlags(ctx.args, `campaign ${
|
|
2363
|
+
unknownFlags(ctx.args, `campaign ${invoked}`);
|
|
1965
2364
|
const row = await resolveCampaign(ctx, reference);
|
|
1966
2365
|
const already =
|
|
1967
2366
|
action === "pause"
|
|
@@ -1985,7 +2384,7 @@ async function runLifecycle(
|
|
|
1985
2384
|
if (action === "resume" && row.status !== "paused") {
|
|
1986
2385
|
throw new CampaignCliError(
|
|
1987
2386
|
row.status === "draft"
|
|
1988
|
-
? `"${row.name}" is a draft.
|
|
2387
|
+
? `"${row.name}" is a draft, not a paused campaign. Check it with: dench campaign launch ${row._id} --check, then launch it once the user says yes.`
|
|
1989
2388
|
: `Only a paused campaign can be resumed. "${row.name}" is ${row.status}.`,
|
|
1990
2389
|
);
|
|
1991
2390
|
}
|
|
@@ -2006,6 +2405,11 @@ async function runLifecycle(
|
|
|
2006
2405
|
: "Archived";
|
|
2007
2406
|
out(ctx, { ok: true, sequenceId: row._id, status, changed: true }, [
|
|
2008
2407
|
`${verb} "${row.name}".${action === "archive" ? " Everyone still in flight was stopped." : ""}`,
|
|
2408
|
+
...(invoked === "delete"
|
|
2409
|
+
? [
|
|
2410
|
+
"Campaigns are archived, not erased: it leaves dench campaign list, and its people, leads and activity stay. dench campaign list --all still shows it.",
|
|
2411
|
+
]
|
|
2412
|
+
: []),
|
|
2009
2413
|
]);
|
|
2010
2414
|
}
|
|
2011
2415
|
|
|
@@ -2072,75 +2476,717 @@ function resolvePerson(detail: CampaignDetail, reference: string): Enrollment {
|
|
|
2072
2476
|
);
|
|
2073
2477
|
}
|
|
2074
2478
|
|
|
2075
|
-
async function
|
|
2479
|
+
async function removeEnrollment(
|
|
2076
2480
|
ctx: CampaignCliContext,
|
|
2077
|
-
|
|
2481
|
+
enrollmentId: string,
|
|
2078
2482
|
): Promise<void> {
|
|
2483
|
+
await callMutation(ctx, `${OUTREACH}:removeDraftEnrollment`, {
|
|
2484
|
+
enrollmentId,
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
async function runRemove(ctx: CampaignCliContext): Promise<void> {
|
|
2079
2489
|
const person = requiredFlag(ctx.args, "--person");
|
|
2080
2490
|
const reference = shift(ctx.args, "campaign id or name");
|
|
2081
|
-
unknownFlags(ctx.args,
|
|
2491
|
+
unknownFlags(ctx.args, "campaign remove");
|
|
2082
2492
|
const row = await resolveCampaign(ctx, reference);
|
|
2083
|
-
|
|
2084
|
-
const enrollment = resolvePerson(detail, person);
|
|
2085
|
-
const name = enrollment.displayName?.trim() || enrollment._id;
|
|
2086
|
-
if (action === "stop") {
|
|
2087
|
-
if (
|
|
2088
|
-
[
|
|
2089
|
-
"finished",
|
|
2090
|
-
"stopped",
|
|
2091
|
-
"replied",
|
|
2092
|
-
"bounced",
|
|
2093
|
-
"complained",
|
|
2094
|
-
"unsubscribed",
|
|
2095
|
-
"failed",
|
|
2096
|
-
].includes(enrollment.status)
|
|
2097
|
-
) {
|
|
2098
|
-
out(
|
|
2099
|
-
ctx,
|
|
2100
|
-
{
|
|
2101
|
-
ok: true,
|
|
2102
|
-
enrollmentId: enrollment._id,
|
|
2103
|
-
status: enrollment.status,
|
|
2104
|
-
changed: false,
|
|
2105
|
-
},
|
|
2106
|
-
[`${name} is already ${enrollment.status}; nothing more will be sent.`],
|
|
2107
|
-
);
|
|
2108
|
-
return;
|
|
2109
|
-
}
|
|
2110
|
-
await callMutation(ctx, `${OUTREACH}:stopEnrollment`, {
|
|
2111
|
-
enrollmentId: enrollment._id,
|
|
2112
|
-
});
|
|
2113
|
-
out(
|
|
2114
|
-
ctx,
|
|
2115
|
-
{
|
|
2116
|
-
ok: true,
|
|
2117
|
-
enrollmentId: enrollment._id,
|
|
2118
|
-
status: "stopped",
|
|
2119
|
-
changed: true,
|
|
2120
|
-
},
|
|
2121
|
-
[
|
|
2122
|
-
`Stopped ${name} in "${row.name}". They will get nothing more from this campaign.`,
|
|
2123
|
-
],
|
|
2124
|
-
);
|
|
2125
|
-
return;
|
|
2126
|
-
}
|
|
2127
|
-
if (enrollment.status !== "paused") {
|
|
2493
|
+
if (row.status !== "draft") {
|
|
2128
2494
|
throw new CampaignCliError(
|
|
2129
|
-
|
|
2495
|
+
`Only a draft campaign's people can be removed; "${row.name}" is ${row.status}. To make sure nothing more is sent to someone, use: dench campaign stop ${row._id} --person <enrollmentId>`,
|
|
2130
2496
|
);
|
|
2131
2497
|
}
|
|
2132
|
-
|
|
2498
|
+
const detail = await loadDetail(ctx, String(row._id));
|
|
2499
|
+
const enrollment = resolvePerson(detail, person);
|
|
2500
|
+
const name = enrollment.displayName?.trim() || enrollment._id;
|
|
2501
|
+
if (enrollment.firstContactedAt !== undefined) {
|
|
2133
2502
|
throw new CampaignCliError(
|
|
2134
|
-
|
|
2503
|
+
`${name} has already been contacted, so they can't be removed. Use: dench campaign stop ${row._id} --person ${enrollment._id}`,
|
|
2135
2504
|
);
|
|
2136
2505
|
}
|
|
2137
|
-
await
|
|
2138
|
-
enrollmentId: enrollment._id,
|
|
2139
|
-
});
|
|
2506
|
+
await removeEnrollment(ctx, enrollment._id);
|
|
2140
2507
|
out(
|
|
2141
2508
|
ctx,
|
|
2142
|
-
{
|
|
2143
|
-
|
|
2509
|
+
{
|
|
2510
|
+
ok: true,
|
|
2511
|
+
sequenceId: row._id,
|
|
2512
|
+
enrollmentId: enrollment._id,
|
|
2513
|
+
status: "removed",
|
|
2514
|
+
changed: true,
|
|
2515
|
+
},
|
|
2516
|
+
[
|
|
2517
|
+
`Removed ${name} from the draft "${row.name}". Nothing had been sent to them, and their place in the ${MAX_CAMPAIGN_PEOPLE}-person cap is free again.`,
|
|
2518
|
+
],
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
const PERSON_SET_USAGE =
|
|
2523
|
+
'dench campaign person set <campaign> --person <enrollmentId|name> [--email ada@new.com] [--linkedin-url https://linkedin.com/in/ada] [--field "Job Title=CTO" ...]';
|
|
2524
|
+
|
|
2525
|
+
export function parseFieldFlags(
|
|
2526
|
+
values: readonly string[],
|
|
2527
|
+
): Array<{ key: string; value: string }> {
|
|
2528
|
+
return values.map((raw) => {
|
|
2529
|
+
const at = raw.indexOf("=");
|
|
2530
|
+
const key =
|
|
2531
|
+
at === -1
|
|
2532
|
+
? ""
|
|
2533
|
+
: raw
|
|
2534
|
+
.slice(0, at)
|
|
2535
|
+
.trim()
|
|
2536
|
+
.replace(/^\{\{\s*|\s*\}\}$/g, "");
|
|
2537
|
+
const value = at === -1 ? "" : raw.slice(at + 1).trim();
|
|
2538
|
+
if (!key || !value) {
|
|
2539
|
+
throw new CampaignCliError(
|
|
2540
|
+
`--field "${raw}" needs a name and a value, e.g. --field "Job Title=CTO" for {{Job Title}}.`,
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
return { key, value };
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
function errorText(error: unknown): string {
|
|
2548
|
+
if (
|
|
2549
|
+
error &&
|
|
2550
|
+
typeof error === "object" &&
|
|
2551
|
+
"data" in error &&
|
|
2552
|
+
typeof (error as { data: unknown }).data === "string"
|
|
2553
|
+
)
|
|
2554
|
+
return (error as { data: string }).data;
|
|
2555
|
+
return error instanceof Error ? error.message : String(error);
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
async function runPersonSet(ctx: CampaignCliContext): Promise<void> {
|
|
2559
|
+
const person = requiredFlag(ctx.args, "--person");
|
|
2560
|
+
const email = valueFlag(ctx.args, "--email")?.trim();
|
|
2561
|
+
const linkedinProfileUrl = valueFlag(ctx.args, "--linkedin-url")?.trim();
|
|
2562
|
+
const fields = parseFieldFlags(getAllFlags(ctx.args, "--field"));
|
|
2563
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
2564
|
+
unknownFlags(ctx.args, "campaign person set");
|
|
2565
|
+
if (email === "" || linkedinProfileUrl === "") {
|
|
2566
|
+
throw new CampaignCliError(
|
|
2567
|
+
`--email and --linkedin-url need a value. ${PERSON_SET_USAGE}`,
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
if (!email && !linkedinProfileUrl && fields.length === 0) {
|
|
2571
|
+
throw new CampaignCliError(
|
|
2572
|
+
`Nothing to change. Pass --email, --linkedin-url and/or --field "Name=Value". ${PERSON_SET_USAGE}`,
|
|
2573
|
+
);
|
|
2574
|
+
}
|
|
2575
|
+
const row = await resolveCampaign(ctx, reference);
|
|
2576
|
+
const detail = await loadDetail(ctx, String(row._id));
|
|
2577
|
+
const enrollment = resolvePerson(detail, person);
|
|
2578
|
+
const name = enrollment.displayName?.trim() || enrollment._id;
|
|
2579
|
+
if (enrollment.status !== "draft") {
|
|
2580
|
+
throw new CampaignCliError(
|
|
2581
|
+
`${name} is ${enrollment.status}. A person's email, LinkedIn URL and fields can only change before the campaign launches. To reach them at a new address, stop them and add them again: dench campaign stop ${row._id} --person ${enrollment._id}, then dench campaign add ${row._id} --email <address> and/or --linkedin-url <url>.`,
|
|
2582
|
+
);
|
|
2583
|
+
}
|
|
2584
|
+
const lineKeys = new Set(
|
|
2585
|
+
(row.personalizationTemplate?.variables ?? []).map((variable) =>
|
|
2586
|
+
normalizedLineKey(variable.key),
|
|
2587
|
+
),
|
|
2588
|
+
);
|
|
2589
|
+
const line = fields.find((field) =>
|
|
2590
|
+
lineKeys.has(normalizedLineKey(field.key)),
|
|
2591
|
+
);
|
|
2592
|
+
if (line) {
|
|
2593
|
+
throw new CampaignCliError(
|
|
2594
|
+
`{{${line.key}}} is a personalized line, not a merge field. Set it with: dench campaign personalize edit ${row._id} --person ${enrollment._id} --line ${line.key} --text "${line.value}". Nothing was saved.`,
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
const saved: string[] = [];
|
|
2598
|
+
const save = async (label: string, fn: string, args: JsonRecord) => {
|
|
2599
|
+
try {
|
|
2600
|
+
await callMutation(ctx, `${OUTREACH}:${fn}`, {
|
|
2601
|
+
enrollmentId: enrollment._id,
|
|
2602
|
+
...args,
|
|
2603
|
+
});
|
|
2604
|
+
} catch (error) {
|
|
2605
|
+
throw new CampaignCliError(
|
|
2606
|
+
`Could not save ${label}: ${errorText(error)}${saved.length ? ` Already saved: ${saved.join(", ")}.` : " Nothing was saved."}`,
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
saved.push(label);
|
|
2610
|
+
};
|
|
2611
|
+
if (email) await save("the email", "updateEnrollmentEmail", { email });
|
|
2612
|
+
if (linkedinProfileUrl)
|
|
2613
|
+
await save("the LinkedIn URL", "updateEnrollmentLinkedin", {
|
|
2614
|
+
linkedinProfileUrl,
|
|
2615
|
+
});
|
|
2616
|
+
for (const field of fields)
|
|
2617
|
+
await save(`{{${field.key}}}`, "updateEnrollmentMergeField", field);
|
|
2618
|
+
const parts = [
|
|
2619
|
+
...(email ? [`email ${email}`] : []),
|
|
2620
|
+
...(linkedinProfileUrl ? [`LinkedIn ${linkedinProfileUrl}`] : []),
|
|
2621
|
+
...fields.map((field) => `{{${field.key}}} = ${field.value}`),
|
|
2622
|
+
];
|
|
2623
|
+
out(
|
|
2624
|
+
ctx,
|
|
2625
|
+
{
|
|
2626
|
+
ok: true,
|
|
2627
|
+
sequenceId: row._id,
|
|
2628
|
+
enrollmentId: enrollment._id,
|
|
2629
|
+
updated: {
|
|
2630
|
+
...(email ? { email } : {}),
|
|
2631
|
+
...(linkedinProfileUrl ? { linkedinProfileUrl } : {}),
|
|
2632
|
+
...(fields.length
|
|
2633
|
+
? {
|
|
2634
|
+
fields: Object.fromEntries(
|
|
2635
|
+
fields.map((field) => [field.key, field.value]),
|
|
2636
|
+
),
|
|
2637
|
+
}
|
|
2638
|
+
: {}),
|
|
2639
|
+
},
|
|
2640
|
+
},
|
|
2641
|
+
[
|
|
2642
|
+
`Updated ${name} in "${row.name}": ${parts.join(" · ")}.`,
|
|
2643
|
+
`Check the campaign again: dench campaign launch ${row._id} --check`,
|
|
2644
|
+
],
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
const PERSON_SHOW_USAGE =
|
|
2649
|
+
"dench campaign person show <campaign> --person <enrollmentId|name>";
|
|
2650
|
+
|
|
2651
|
+
const SENT_TEXT_LINES = 6;
|
|
2652
|
+
|
|
2653
|
+
async function runPersonShow(ctx: CampaignCliContext): Promise<void> {
|
|
2654
|
+
const person = requiredFlag(ctx.args, "--person");
|
|
2655
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
2656
|
+
unknownFlags(ctx.args, "campaign person show");
|
|
2657
|
+
const row = await resolveCampaign(ctx, reference);
|
|
2658
|
+
const detail = await loadDetail(ctx, String(row._id));
|
|
2659
|
+
const enrollment = resolvePerson(detail, person);
|
|
2660
|
+
const timeline = (await callQuery(ctx, `${OUTREACH}:timeline`, {
|
|
2661
|
+
enrollmentId: enrollment._id,
|
|
2662
|
+
})) as TimelineResult;
|
|
2663
|
+
const steps = timelineSteps(timeline);
|
|
2664
|
+
const described = describePerson(enrollment, detail);
|
|
2665
|
+
const address = personAddress(timeline.enrollment);
|
|
2666
|
+
const stepDocs = new Map(
|
|
2667
|
+
timeline.steps.map((step) => [String(step._id), step]),
|
|
2668
|
+
);
|
|
2669
|
+
const lines = [described.line];
|
|
2670
|
+
const contact = [address.email, address.linkedinProfileUrl].filter(Boolean);
|
|
2671
|
+
if (contact.length) lines.push(` ${contact.join(" · ")}`);
|
|
2672
|
+
if (!steps.length) {
|
|
2673
|
+
lines.push(" Nothing has happened for this person yet.");
|
|
2674
|
+
}
|
|
2675
|
+
for (const step of steps) {
|
|
2676
|
+
const doc = stepDocs.get(step.stepId);
|
|
2677
|
+
const label = stepLabel(doc ?? { type: step.type });
|
|
2678
|
+
const when = step.at === null ? "" : ` · ${fmtRelative(step.at)}`;
|
|
2679
|
+
lines.push(
|
|
2680
|
+
` Step ${step.step} · ${label} · ${[step.outcome, ...step.detail].join(" · ")}${when}`,
|
|
2681
|
+
);
|
|
2682
|
+
if (step.subject) lines.push(` Subject: ${step.subject}`);
|
|
2683
|
+
if (step.text) {
|
|
2684
|
+
const body = step.text.split("\n");
|
|
2685
|
+
lines.push(
|
|
2686
|
+
...body.slice(0, SENT_TEXT_LINES).map((line) => ` ${line}`),
|
|
2687
|
+
);
|
|
2688
|
+
if (body.length > SENT_TEXT_LINES) lines.push(" …");
|
|
2689
|
+
}
|
|
2690
|
+
if (step.error) lines.push(` ${step.error}`);
|
|
2691
|
+
}
|
|
2692
|
+
out(
|
|
2693
|
+
ctx,
|
|
2694
|
+
{
|
|
2695
|
+
sequenceId: row._id,
|
|
2696
|
+
...described.json,
|
|
2697
|
+
...address,
|
|
2698
|
+
steps,
|
|
2699
|
+
},
|
|
2700
|
+
lines,
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
async function runPersonCommand(ctx: CampaignCliContext): Promise<void> {
|
|
2705
|
+
const verb = ctx.args.shift();
|
|
2706
|
+
if (verb === "set") return runPersonSet(ctx);
|
|
2707
|
+
if (verb === "show" || verb === "get" || verb === "history")
|
|
2708
|
+
return runPersonShow(ctx);
|
|
2709
|
+
throw new CampaignCliError(
|
|
2710
|
+
`Use one of:\n ${PERSON_SET_USAGE}\n ${PERSON_SHOW_USAGE}`,
|
|
2711
|
+
);
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
const PERSONALIZE = "functions/outreachPersonalization";
|
|
2715
|
+
|
|
2716
|
+
type PersonalizationStatus = {
|
|
2717
|
+
template: (LineTemplate & { version?: number }) | null;
|
|
2718
|
+
counts: { done: number; pending: number; failed: number; total: number };
|
|
2719
|
+
paused: boolean;
|
|
2720
|
+
notice: { code: string; at: number } | null;
|
|
2721
|
+
suggestedContext: { sells?: string };
|
|
2722
|
+
estimatedCentsPerPerson: number;
|
|
2723
|
+
eligible: number;
|
|
2724
|
+
};
|
|
2725
|
+
|
|
2726
|
+
type BackfillResult = {
|
|
2727
|
+
version: number;
|
|
2728
|
+
eligible: number;
|
|
2729
|
+
estimatedCents: number;
|
|
2730
|
+
needsConfirm: boolean;
|
|
2731
|
+
queued: number;
|
|
2732
|
+
};
|
|
2733
|
+
|
|
2734
|
+
const PERSONALIZE_USAGE = [
|
|
2735
|
+
"dench campaign personalize get <campaign>",
|
|
2736
|
+
"dench campaign personalize set <campaign> --file lines.json",
|
|
2737
|
+
"dench campaign personalize write <campaign> [--all] [--yes]",
|
|
2738
|
+
'dench campaign personalize edit <campaign> --person <enrollmentId|name> --line <key> --text "..."',
|
|
2739
|
+
"dench campaign personalize rewrite <campaign> --person <enrollmentId|name> [--line <key>]",
|
|
2740
|
+
"dench campaign personalize clear <campaign>",
|
|
2741
|
+
];
|
|
2742
|
+
|
|
2743
|
+
function plural(count: number, one: string, many: string): string {
|
|
2744
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
async function personalizationStatus(
|
|
2748
|
+
ctx: CampaignCliContext,
|
|
2749
|
+
sequenceId: string,
|
|
2750
|
+
): Promise<PersonalizationStatus> {
|
|
2751
|
+
return (await callQuery(ctx, `${PERSONALIZE}:getStatus`, {
|
|
2752
|
+
sequenceId,
|
|
2753
|
+
})) as PersonalizationStatus;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function linesText(row: CampaignRow, status: PersonalizationStatus): string[] {
|
|
2757
|
+
const template = status.template;
|
|
2758
|
+
if (!template?.variables.length) {
|
|
2759
|
+
return [
|
|
2760
|
+
`"${row.name}" has no personalized lines.`,
|
|
2761
|
+
`Add up to 5 AI-written lines, about ${formatCents(status.estimatedCentsPerPerson || LINE_CENTS_PER_PERSON)} a person: dench campaign personalize set ${row._id} --file lines.json`,
|
|
2762
|
+
];
|
|
2763
|
+
}
|
|
2764
|
+
const lines = [
|
|
2765
|
+
`Personalized lines in "${row.name}" (${template.variables.length}):`,
|
|
2766
|
+
];
|
|
2767
|
+
for (const variable of template.variables) {
|
|
2768
|
+
lines.push(
|
|
2769
|
+
` {{${variable.key}}} ${variable.label} — up to ${variable.maxChars ?? 200} characters${variable.fallbackFrom ? `; without a line, uses {{${variable.fallbackFrom}}}` : ""}`,
|
|
2770
|
+
` instructions: ${variable.prompt}`,
|
|
2771
|
+
` fallback: ${variable.fallback}`,
|
|
2772
|
+
);
|
|
2773
|
+
}
|
|
2774
|
+
lines.push(`What you sell: ${template.context.sells}`);
|
|
2775
|
+
if (template.context.offer) lines.push(`Offer: ${template.context.offer}`);
|
|
2776
|
+
const { done, pending, failed, total } = status.counts;
|
|
2777
|
+
lines.push(
|
|
2778
|
+
`Written for ${done} of ${plural(total, "person", "people")}${pending ? ` · ${pending} ${status.paused ? "waiting for the campaign to resume" : "being written"}` : ""}${failed ? ` · ${failed} using the fallback` : ""} · about ${formatCents(status.estimatedCentsPerPerson)} a person`,
|
|
2779
|
+
);
|
|
2780
|
+
if (status.eligible > 0 && pending === 0)
|
|
2781
|
+
lines.push(
|
|
2782
|
+
`${plural(status.eligible, "person needs", "people need")} lines: dench campaign personalize write ${row._id}`,
|
|
2783
|
+
);
|
|
2784
|
+
if (status.notice?.code === "insufficient_credits")
|
|
2785
|
+
lines.push(
|
|
2786
|
+
`Writing stopped because the workspace ran out of credits. Add credits, then run dench campaign personalize write ${row._id}`,
|
|
2787
|
+
);
|
|
2788
|
+
return lines;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
function backfillText(row: CampaignRow, result: BackfillResult): string {
|
|
2792
|
+
if (result.needsConfirm)
|
|
2793
|
+
return `${plural(result.eligible, "person needs", "people need")} lines, about ${formatCents(result.estimatedCents)}. Nothing was written yet; confirm with: dench campaign personalize write ${row._id} --yes`;
|
|
2794
|
+
if (result.queued > 0)
|
|
2795
|
+
return `Writing lines for ${plural(result.queued, "person", "people")} now, about ${formatCents(result.estimatedCents)}. Messages wait up to 2 hours for a person's line, then use its fallback.`;
|
|
2796
|
+
return "Nobody needs a new line right now.";
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
async function readJsonFile(file: string): Promise<unknown> {
|
|
2800
|
+
const raw = await readFile(file, "utf8").catch(() => {
|
|
2801
|
+
throw new CampaignCliError(`Cannot read ${file}`);
|
|
2802
|
+
});
|
|
2803
|
+
try {
|
|
2804
|
+
return JSON.parse(raw);
|
|
2805
|
+
} catch {
|
|
2806
|
+
throw new CampaignCliError(`${file} is not valid JSON.`);
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
function writableForLines(row: CampaignRow): void {
|
|
2811
|
+
if (row.status === "archived") {
|
|
2812
|
+
throw new CampaignCliError(
|
|
2813
|
+
`"${row.name}" is archived, so its personalized lines can't change. Start again from a copy: dench campaign duplicate ${row._id}`,
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
function noLines(row: CampaignRow): CampaignCliError {
|
|
2819
|
+
return new CampaignCliError(
|
|
2820
|
+
`"${row.name}" has no personalized lines. Add them first: dench campaign personalize set ${row._id} --file lines.json`,
|
|
2821
|
+
);
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
function resolveLineKey(
|
|
2825
|
+
template: LineTemplate | null,
|
|
2826
|
+
reference: string,
|
|
2827
|
+
row: CampaignRow,
|
|
2828
|
+
): string {
|
|
2829
|
+
const variables = template?.variables ?? [];
|
|
2830
|
+
if (!variables.length) throw noLines(row);
|
|
2831
|
+
const wanted = normalizedLineKey(reference.replace(/^\{\{\s*|\s*\}\}$/g, ""));
|
|
2832
|
+
const match = variables.find(
|
|
2833
|
+
(variable) =>
|
|
2834
|
+
normalizedLineKey(variable.key) === wanted ||
|
|
2835
|
+
normalizedLineKey(variable.label) === wanted,
|
|
2836
|
+
);
|
|
2837
|
+
if (!match) {
|
|
2838
|
+
throw new CampaignCliError(
|
|
2839
|
+
`"${reference}" is not one of the personalized lines in "${row.name}". Use one of: ${variables.map((variable) => variable.key).join(", ")}.`,
|
|
2840
|
+
);
|
|
2841
|
+
}
|
|
2842
|
+
return match.key;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
async function personalizeGet(ctx: CampaignCliContext): Promise<void> {
|
|
2846
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
2847
|
+
unknownFlags(ctx.args, "campaign personalize get");
|
|
2848
|
+
const row = await resolveCampaign(ctx, reference);
|
|
2849
|
+
const status = await personalizationStatus(ctx, String(row._id));
|
|
2850
|
+
out(ctx, { sequenceId: row._id, ...status }, linesText(row, status));
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
async function personalizeSet(ctx: CampaignCliContext): Promise<void> {
|
|
2854
|
+
const file = requiredFlag(ctx.args, "--file");
|
|
2855
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
2856
|
+
unknownFlags(ctx.args, "campaign personalize set");
|
|
2857
|
+
const parsed = await readJsonFile(file);
|
|
2858
|
+
const row = await resolveCampaign(ctx, reference);
|
|
2859
|
+
writableForLines(row);
|
|
2860
|
+
const status = await personalizationStatus(ctx, String(row._id));
|
|
2861
|
+
let template: LineTemplate;
|
|
2862
|
+
try {
|
|
2863
|
+
template = linesTemplateFromFile(parsed, {
|
|
2864
|
+
existing: status.template,
|
|
2865
|
+
suggestedSells: status.suggestedContext.sells,
|
|
2866
|
+
source: file,
|
|
2867
|
+
});
|
|
2868
|
+
} catch (error) {
|
|
2869
|
+
if (error instanceof LinesFileError)
|
|
2870
|
+
throw new CampaignCliError(error.message);
|
|
2871
|
+
throw error;
|
|
2872
|
+
}
|
|
2873
|
+
const result = (await callMutation(ctx, `${PERSONALIZE}:setTemplate`, {
|
|
2874
|
+
sequenceId: row._id,
|
|
2875
|
+
template,
|
|
2876
|
+
})) as BackfillResult;
|
|
2877
|
+
const keys = template.variables.map((variable) => `{{${variable.key}}}`);
|
|
2878
|
+
out(ctx, { ok: true, sequenceId: row._id, template, ...result }, [
|
|
2879
|
+
`Saved ${plural(template.variables.length, "personalized line", "personalized lines")} on "${row.name}": ${keys.join(", ")}. Put each one in your steps where that line belongs.`,
|
|
2880
|
+
backfillText(row, result),
|
|
2881
|
+
]);
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
async function personalizeClear(ctx: CampaignCliContext): Promise<void> {
|
|
2885
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
2886
|
+
unknownFlags(ctx.args, "campaign personalize clear");
|
|
2887
|
+
const row = await resolveCampaign(ctx, reference);
|
|
2888
|
+
writableForLines(row);
|
|
2889
|
+
await callMutation(ctx, `${PERSONALIZE}:setTemplate`, {
|
|
2890
|
+
sequenceId: row._id,
|
|
2891
|
+
template: null,
|
|
2892
|
+
});
|
|
2893
|
+
out(ctx, { ok: true, sequenceId: row._id, template: null }, [
|
|
2894
|
+
`Turned off personalized lines on "${row.name}". Lines not yet written were canceled.`,
|
|
2895
|
+
]);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
async function personalizeWrite(ctx: CampaignCliContext): Promise<void> {
|
|
2899
|
+
const all = hasFlag(ctx.args, "--all");
|
|
2900
|
+
const yes = hasFlag(ctx.args, "--yes");
|
|
2901
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
2902
|
+
unknownFlags(ctx.args, "campaign personalize write");
|
|
2903
|
+
const row = await resolveCampaign(ctx, reference);
|
|
2904
|
+
writableForLines(row);
|
|
2905
|
+
const result = (await callMutation(ctx, `${PERSONALIZE}:backfill`, {
|
|
2906
|
+
sequenceId: row._id,
|
|
2907
|
+
mode: all ? "all" : "missing",
|
|
2908
|
+
...(yes ? { confirmed: true } : {}),
|
|
2909
|
+
})) as BackfillResult;
|
|
2910
|
+
const payload = {
|
|
2911
|
+
sequenceId: row._id,
|
|
2912
|
+
mode: all ? "all" : "missing",
|
|
2913
|
+
...result,
|
|
2914
|
+
};
|
|
2915
|
+
if (result.needsConfirm) {
|
|
2916
|
+
out(ctx, payload, [
|
|
2917
|
+
`${all ? "Rewriting" : "Writing"} lines for ${plural(result.eligible, "person", "people")} in "${row.name}" costs about ${formatCents(result.estimatedCents)}. Nothing was written.`,
|
|
2918
|
+
]);
|
|
2919
|
+
throw new CampaignCliError(
|
|
2920
|
+
`Confirm the cost with: dench campaign personalize write ${row._id}${all ? " --all" : ""} --yes`,
|
|
2921
|
+
);
|
|
2922
|
+
}
|
|
2923
|
+
out(ctx, payload, [backfillText(row, result)]);
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
async function personalizeEdit(ctx: CampaignCliContext): Promise<void> {
|
|
2927
|
+
const person = requiredFlag(ctx.args, "--person");
|
|
2928
|
+
const line = requiredFlag(ctx.args, "--line");
|
|
2929
|
+
if (!ctx.args.includes("--text")) {
|
|
2930
|
+
throw new CampaignCliError(
|
|
2931
|
+
'Missing required --text "..." (an empty --text "" undoes your edit).',
|
|
2932
|
+
);
|
|
2933
|
+
}
|
|
2934
|
+
const text = valueFlag(ctx.args, "--text") ?? "";
|
|
2935
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
2936
|
+
unknownFlags(ctx.args, "campaign personalize edit");
|
|
2937
|
+
const row = await resolveCampaign(ctx, reference);
|
|
2938
|
+
const [status, detail] = await Promise.all([
|
|
2939
|
+
personalizationStatus(ctx, String(row._id)),
|
|
2940
|
+
loadDetail(ctx, String(row._id)),
|
|
2941
|
+
]);
|
|
2942
|
+
const key = resolveLineKey(status.template, line, row);
|
|
2943
|
+
const enrollment = resolvePerson(detail, person);
|
|
2944
|
+
const name = enrollment.displayName?.trim() || enrollment._id;
|
|
2945
|
+
await callMutation(ctx, `${PERSONALIZE}:updateAiVariable`, {
|
|
2946
|
+
enrollmentId: enrollment._id,
|
|
2947
|
+
key,
|
|
2948
|
+
value: text,
|
|
2949
|
+
});
|
|
2950
|
+
const cleared = !text.trim();
|
|
2951
|
+
out(
|
|
2952
|
+
ctx,
|
|
2953
|
+
{
|
|
2954
|
+
ok: true,
|
|
2955
|
+
sequenceId: row._id,
|
|
2956
|
+
enrollmentId: enrollment._id,
|
|
2957
|
+
key,
|
|
2958
|
+
value: cleared ? null : text.trim(),
|
|
2959
|
+
},
|
|
2960
|
+
[
|
|
2961
|
+
cleared
|
|
2962
|
+
? `Undid the edit to ${name}'s {{${key}}} in "${row.name}". They get the fallback until a line is written: dench campaign personalize rewrite ${row._id} --person ${enrollment._id} --line ${key}`
|
|
2963
|
+
: `Set ${name}'s {{${key}}} in "${row.name}". Braces are removed and text past the line's length is cut.`,
|
|
2964
|
+
],
|
|
2965
|
+
);
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
async function personalizeRewrite(ctx: CampaignCliContext): Promise<void> {
|
|
2969
|
+
const person = requiredFlag(ctx.args, "--person");
|
|
2970
|
+
const line = valueFlag(ctx.args, "--line");
|
|
2971
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
2972
|
+
unknownFlags(ctx.args, "campaign personalize rewrite");
|
|
2973
|
+
const row = await resolveCampaign(ctx, reference);
|
|
2974
|
+
writableForLines(row);
|
|
2975
|
+
const [status, detail] = await Promise.all([
|
|
2976
|
+
personalizationStatus(ctx, String(row._id)),
|
|
2977
|
+
loadDetail(ctx, String(row._id)),
|
|
2978
|
+
]);
|
|
2979
|
+
if (!status.template?.variables.length) throw noLines(row);
|
|
2980
|
+
const key =
|
|
2981
|
+
line === undefined ? undefined : resolveLineKey(status.template, line, row);
|
|
2982
|
+
const enrollment = resolvePerson(detail, person);
|
|
2983
|
+
const name = enrollment.displayName?.trim() || enrollment._id;
|
|
2984
|
+
const result = (await callMutation(
|
|
2985
|
+
ctx,
|
|
2986
|
+
`${PERSONALIZE}:rewriteForEnrollment`,
|
|
2987
|
+
{ enrollmentId: enrollment._id, ...(key ? { key } : {}) },
|
|
2988
|
+
)) as { queued: boolean };
|
|
2989
|
+
const what = key ? `{{${key}}}` : "lines";
|
|
2990
|
+
out(
|
|
2991
|
+
ctx,
|
|
2992
|
+
{
|
|
2993
|
+
ok: true,
|
|
2994
|
+
sequenceId: row._id,
|
|
2995
|
+
enrollmentId: enrollment._id,
|
|
2996
|
+
...(key ? { key } : {}),
|
|
2997
|
+
queued: result.queued,
|
|
2998
|
+
},
|
|
2999
|
+
[
|
|
3000
|
+
result.queued
|
|
3001
|
+
? `Writing new ${what} for ${name} in "${row.name}", about ${formatCents(status.estimatedCentsPerPerson)}. See them with: dench campaign preview ${row._id} --person ${enrollment._id}`
|
|
3002
|
+
: `New ${what} for ${name} are already being written.`,
|
|
3003
|
+
],
|
|
3004
|
+
);
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
async function runPersonalize(ctx: CampaignCliContext): Promise<void> {
|
|
3008
|
+
const verb = ctx.args.shift();
|
|
3009
|
+
switch (verb) {
|
|
3010
|
+
case "get":
|
|
3011
|
+
case "show":
|
|
3012
|
+
case "status":
|
|
3013
|
+
return personalizeGet(ctx);
|
|
3014
|
+
case "set":
|
|
3015
|
+
return personalizeSet(ctx);
|
|
3016
|
+
case "clear":
|
|
3017
|
+
case "off":
|
|
3018
|
+
return personalizeClear(ctx);
|
|
3019
|
+
case "write":
|
|
3020
|
+
case "backfill":
|
|
3021
|
+
return personalizeWrite(ctx);
|
|
3022
|
+
case "edit":
|
|
3023
|
+
return personalizeEdit(ctx);
|
|
3024
|
+
case "rewrite":
|
|
3025
|
+
return personalizeRewrite(ctx);
|
|
3026
|
+
default:
|
|
3027
|
+
throw new CampaignCliError(
|
|
3028
|
+
`Use one of:\n ${PERSONALIZE_USAGE.join("\n ")}`,
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
async function runSync(ctx: CampaignCliContext): Promise<void> {
|
|
3034
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
3035
|
+
unknownFlags(ctx.args, "campaign sync");
|
|
3036
|
+
const row = await resolveCampaign(ctx, reference);
|
|
3037
|
+
if (row.status === "archived" || row.status === "completed") {
|
|
3038
|
+
out(
|
|
3039
|
+
ctx,
|
|
3040
|
+
{ ok: true, sequenceId: row._id, status: row.status, updated: 0 },
|
|
3041
|
+
[
|
|
3042
|
+
`"${row.name}" is ${row.status}, so nobody in it can use new contact details. Nothing changed.`,
|
|
3043
|
+
],
|
|
3044
|
+
);
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
const result = (await callMutation(
|
|
3048
|
+
ctx,
|
|
3049
|
+
`${OUTREACH}:syncEnrollmentsFromRecords`,
|
|
3050
|
+
{ sequenceId: row._id },
|
|
3051
|
+
)) as { updated: number };
|
|
3052
|
+
out(
|
|
3053
|
+
ctx,
|
|
3054
|
+
{
|
|
3055
|
+
ok: true,
|
|
3056
|
+
sequenceId: row._id,
|
|
3057
|
+
status: row.status,
|
|
3058
|
+
updated: result.updated,
|
|
3059
|
+
},
|
|
3060
|
+
[
|
|
3061
|
+
result.updated === 0
|
|
3062
|
+
? `Nobody in "${row.name}" had anything new on their CRM record.`
|
|
3063
|
+
: `Updated ${result.updated} ${result.updated === 1 ? "person" : "people"} in "${row.name}" from their CRM records.`,
|
|
3064
|
+
"Only empty details are filled (email, LinkedIn URL, name and merge fields); nothing already in the campaign is overwritten.",
|
|
3065
|
+
],
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
async function runResumeAll(ctx: CampaignCliContext): Promise<void> {
|
|
3070
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
3071
|
+
unknownFlags(ctx.args, "campaign resume-person");
|
|
3072
|
+
const row = await resolveCampaign(ctx, reference);
|
|
3073
|
+
if (row.status !== "active") {
|
|
3074
|
+
throw new CampaignCliError(
|
|
3075
|
+
row.status === "paused"
|
|
3076
|
+
? `"${row.name}" is paused. Resume the campaign first: dench campaign resume ${row._id}`
|
|
3077
|
+
: `Only people in an active campaign can be resumed. "${row.name}" is ${row.status}.`,
|
|
3078
|
+
);
|
|
3079
|
+
}
|
|
3080
|
+
const result = (await callMutation(
|
|
3081
|
+
ctx,
|
|
3082
|
+
`${OUTREACH}:resumePausedEnrollments`,
|
|
3083
|
+
{ sequenceId: row._id },
|
|
3084
|
+
)) as { resumed: number };
|
|
3085
|
+
out(ctx, { ok: true, sequenceId: row._id, resumed: result.resumed }, [
|
|
3086
|
+
result.resumed === 0
|
|
3087
|
+
? `Nobody in "${row.name}" was paused, so nothing changed.`
|
|
3088
|
+
: `Resumed ${result.resumed} paused ${result.resumed === 1 ? "person" : "people"} in "${row.name}". Their next step is scheduled now.`,
|
|
3089
|
+
]);
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
async function runPerson(
|
|
3093
|
+
ctx: CampaignCliContext,
|
|
3094
|
+
action: "stop" | "resume-person",
|
|
3095
|
+
): Promise<void> {
|
|
3096
|
+
if (action === "resume-person" && hasFlag(ctx.args, "--all")) {
|
|
3097
|
+
if (ctx.args.includes("--person")) {
|
|
3098
|
+
throw new CampaignCliError(
|
|
3099
|
+
"Pass either --person or --all to resume-person, not both.",
|
|
3100
|
+
);
|
|
3101
|
+
}
|
|
3102
|
+
return runResumeAll(ctx);
|
|
3103
|
+
}
|
|
3104
|
+
if (action === "resume-person" && !ctx.args.includes("--person")) {
|
|
3105
|
+
throw new CampaignCliError(
|
|
3106
|
+
"Missing required --person <enrollmentId|name>, or --all to resume every paused person.",
|
|
3107
|
+
);
|
|
3108
|
+
}
|
|
3109
|
+
const person = requiredFlag(ctx.args, "--person");
|
|
3110
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
3111
|
+
unknownFlags(ctx.args, `campaign ${action}`);
|
|
3112
|
+
const row = await resolveCampaign(ctx, reference);
|
|
3113
|
+
const detail = await loadDetail(ctx, String(row._id));
|
|
3114
|
+
const enrollment = resolvePerson(detail, person);
|
|
3115
|
+
const name = enrollment.displayName?.trim() || enrollment._id;
|
|
3116
|
+
if (action === "stop") {
|
|
3117
|
+
if (row.status === "draft" && enrollment.firstContactedAt === undefined) {
|
|
3118
|
+
await removeEnrollment(ctx, enrollment._id);
|
|
3119
|
+
out(
|
|
3120
|
+
ctx,
|
|
3121
|
+
{
|
|
3122
|
+
ok: true,
|
|
3123
|
+
enrollmentId: enrollment._id,
|
|
3124
|
+
status: "removed",
|
|
3125
|
+
changed: true,
|
|
3126
|
+
},
|
|
3127
|
+
[
|
|
3128
|
+
`Removed ${name} from the draft "${row.name}" instead of stopping them: nothing had been sent, and a stopped person would block the launch.`,
|
|
3129
|
+
],
|
|
3130
|
+
);
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
if (
|
|
3134
|
+
[
|
|
3135
|
+
"finished",
|
|
3136
|
+
"stopped",
|
|
3137
|
+
"replied",
|
|
3138
|
+
"bounced",
|
|
3139
|
+
"complained",
|
|
3140
|
+
"unsubscribed",
|
|
3141
|
+
"failed",
|
|
3142
|
+
].includes(enrollment.status)
|
|
3143
|
+
) {
|
|
3144
|
+
out(
|
|
3145
|
+
ctx,
|
|
3146
|
+
{
|
|
3147
|
+
ok: true,
|
|
3148
|
+
enrollmentId: enrollment._id,
|
|
3149
|
+
status: enrollment.status,
|
|
3150
|
+
changed: false,
|
|
3151
|
+
},
|
|
3152
|
+
[`${name} is already ${enrollment.status}; nothing more will be sent.`],
|
|
3153
|
+
);
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
await callMutation(ctx, `${OUTREACH}:stopEnrollment`, {
|
|
3157
|
+
enrollmentId: enrollment._id,
|
|
3158
|
+
});
|
|
3159
|
+
out(
|
|
3160
|
+
ctx,
|
|
3161
|
+
{
|
|
3162
|
+
ok: true,
|
|
3163
|
+
enrollmentId: enrollment._id,
|
|
3164
|
+
status: "stopped",
|
|
3165
|
+
changed: true,
|
|
3166
|
+
},
|
|
3167
|
+
[
|
|
3168
|
+
`Stopped ${name} in "${row.name}". They will get nothing more from this campaign.`,
|
|
3169
|
+
],
|
|
3170
|
+
);
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
if (enrollment.status !== "paused") {
|
|
3174
|
+
throw new CampaignCliError(
|
|
3175
|
+
`${name} is ${enrollment.status}, not paused. Only a paused person can be resumed.`,
|
|
3176
|
+
);
|
|
3177
|
+
}
|
|
3178
|
+
if (row.status !== "active") {
|
|
3179
|
+
throw new CampaignCliError(
|
|
3180
|
+
`Resume the campaign first: dench campaign resume ${row._id}`,
|
|
3181
|
+
);
|
|
3182
|
+
}
|
|
3183
|
+
await callMutation(ctx, `${OUTREACH}:resumeEnrollment`, {
|
|
3184
|
+
enrollmentId: enrollment._id,
|
|
3185
|
+
});
|
|
3186
|
+
out(
|
|
3187
|
+
ctx,
|
|
3188
|
+
{ ok: true, enrollmentId: enrollment._id, status: "active", changed: true },
|
|
3189
|
+
[`Resumed ${name} in "${row.name}".`],
|
|
2144
3190
|
);
|
|
2145
3191
|
}
|
|
2146
3192
|
|
|
@@ -2179,6 +3225,493 @@ type EventRow = {
|
|
|
2179
3225
|
stepIndex?: number;
|
|
2180
3226
|
};
|
|
2181
3227
|
|
|
3228
|
+
type MessagePreview = {
|
|
3229
|
+
channel: "email" | "linkedin";
|
|
3230
|
+
subject?: string;
|
|
3231
|
+
textBody?: string;
|
|
3232
|
+
htmlBody?: string;
|
|
3233
|
+
messageBody?: string;
|
|
3234
|
+
fromName?: string;
|
|
3235
|
+
fromEmail?: string;
|
|
3236
|
+
toName: string;
|
|
3237
|
+
toEmail?: string;
|
|
3238
|
+
missingFields: string[];
|
|
3239
|
+
warnings: string[];
|
|
3240
|
+
skippedReason?: string;
|
|
3241
|
+
personalized?: Array<{
|
|
3242
|
+
key: string;
|
|
3243
|
+
label: string;
|
|
3244
|
+
value: string;
|
|
3245
|
+
state: string;
|
|
3246
|
+
}>;
|
|
3247
|
+
};
|
|
3248
|
+
|
|
3249
|
+
const PREVIEW = "functions/outreachMessagePreview";
|
|
3250
|
+
|
|
3251
|
+
const LINE_STATE_TEXT: Record<string, string> = {
|
|
3252
|
+
written: "written",
|
|
3253
|
+
edited: "edited by hand",
|
|
3254
|
+
writing: "being written; the fallback shows until then",
|
|
3255
|
+
paused: "waiting for the campaign to resume; the fallback shows until then",
|
|
3256
|
+
fallback: "nothing specific found, so the fallback is used",
|
|
3257
|
+
not_written: "not written yet; the fallback shows until then",
|
|
3258
|
+
};
|
|
3259
|
+
|
|
3260
|
+
function htmlToText(html: string): string {
|
|
3261
|
+
return html
|
|
3262
|
+
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
|
3263
|
+
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, "\n")
|
|
3264
|
+
.replace(/<[^>]+>/g, "")
|
|
3265
|
+
.replace(/ /g, " ")
|
|
3266
|
+
.replace(/</g, "<")
|
|
3267
|
+
.replace(/>/g, ">")
|
|
3268
|
+
.replace(/"/g, '"')
|
|
3269
|
+
.replace(/'/g, "'")
|
|
3270
|
+
.replace(/&/g, "&")
|
|
3271
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
3272
|
+
.trim();
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
function indent(text: string): string[] {
|
|
3276
|
+
return text.split("\n").map((line) => ` ${line}`);
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
function previewLines(
|
|
3280
|
+
stepNumber: number,
|
|
3281
|
+
step: { type: string; waitHours?: unknown },
|
|
3282
|
+
preview: MessagePreview,
|
|
3283
|
+
): string[] {
|
|
3284
|
+
const from =
|
|
3285
|
+
preview.fromName || preview.fromEmail
|
|
3286
|
+
? ` · from ${preview.fromName ?? ""}${preview.fromEmail ? `${preview.fromName ? " " : ""}<${preview.fromEmail}>` : ""}`
|
|
3287
|
+
: "";
|
|
3288
|
+
const to =
|
|
3289
|
+
preview.channel === "email" && preview.toEmail
|
|
3290
|
+
? ` to ${preview.toEmail}`
|
|
3291
|
+
: "";
|
|
3292
|
+
const lines = [`Step ${stepNumber} · ${stepLabel(step)}${from}${to}`];
|
|
3293
|
+
if (preview.skippedReason) lines.push(` skipped: ${preview.skippedReason}`);
|
|
3294
|
+
if (preview.channel === "email") {
|
|
3295
|
+
lines.push(` Subject: ${preview.subject ?? ""}`);
|
|
3296
|
+
const body = preview.textBody?.trim() || htmlToText(preview.htmlBody ?? "");
|
|
3297
|
+
lines.push(...indent(body || "(empty)"));
|
|
3298
|
+
} else {
|
|
3299
|
+
lines.push(...indent(preview.messageBody?.trim() || "(empty)"));
|
|
3300
|
+
}
|
|
3301
|
+
for (const line of preview.personalized ?? [])
|
|
3302
|
+
lines.push(
|
|
3303
|
+
` {{${line.key}}} ${line.label}: ${LINE_STATE_TEXT[line.state] ?? line.state}`,
|
|
3304
|
+
);
|
|
3305
|
+
if (preview.missingFields.length)
|
|
3306
|
+
lines.push(
|
|
3307
|
+
` missing: ${preview.missingFields.map((field) => `{{${field}}}`).join(", ")}`,
|
|
3308
|
+
);
|
|
3309
|
+
for (const warning of preview.warnings) lines.push(` note: ${warning}`);
|
|
3310
|
+
return lines;
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
async function runPreview(ctx: CampaignCliContext): Promise<void> {
|
|
3314
|
+
const personRef = valueFlag(ctx.args, "--person");
|
|
3315
|
+
const stepRaw = valueFlag(ctx.args, "--step");
|
|
3316
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
3317
|
+
unknownFlags(ctx.args, "campaign preview");
|
|
3318
|
+
const row = await resolveCampaign(ctx, reference);
|
|
3319
|
+
const detail = await loadDetail(ctx, String(row._id));
|
|
3320
|
+
const steps = [...detail.steps].sort(
|
|
3321
|
+
(a, b) => (a.order ?? 0) - (b.order ?? 0),
|
|
3322
|
+
);
|
|
3323
|
+
const sendable = steps
|
|
3324
|
+
.map((step, index) => ({ step, number: index + 1 }))
|
|
3325
|
+
.filter(({ step }) => step.type !== "wait");
|
|
3326
|
+
if (!sendable.length) {
|
|
3327
|
+
throw new CampaignCliError(
|
|
3328
|
+
`"${row.name}" has no email or LinkedIn steps to preview yet. Add them: dench campaign steps set ${row._id} --file steps.json`,
|
|
3329
|
+
);
|
|
3330
|
+
}
|
|
3331
|
+
let chosen = sendable;
|
|
3332
|
+
if (stepRaw !== undefined) {
|
|
3333
|
+
const number = Number(stepRaw);
|
|
3334
|
+
if (!Number.isInteger(number) || number < 1 || number > steps.length) {
|
|
3335
|
+
throw new CampaignCliError(
|
|
3336
|
+
`--step must be a step number from 1 to ${steps.length} (see dench campaign steps get ${row._id}).`,
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
chosen = sendable.filter((entry) => entry.number === number);
|
|
3340
|
+
if (!chosen.length) {
|
|
3341
|
+
throw new CampaignCliError(
|
|
3342
|
+
`Step ${number} is a wait, so it sends nothing. Steps with a message: ${sendable.map((entry) => entry.number).join(", ")}.`,
|
|
3343
|
+
);
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
if (!detail.enrollments.length) {
|
|
3347
|
+
throw new CampaignCliError(
|
|
3348
|
+
`Nobody is in "${row.name}" yet, so there is no one to preview for. Add people first: dench campaign add ${row._id} ...`,
|
|
3349
|
+
);
|
|
3350
|
+
}
|
|
3351
|
+
const enrollment = personRef
|
|
3352
|
+
? resolvePerson(detail, personRef)
|
|
3353
|
+
: detail.enrollments[0];
|
|
3354
|
+
const name = enrollment.displayName?.trim() || enrollment._id;
|
|
3355
|
+
const rendered = await Promise.all(
|
|
3356
|
+
chosen.map(async ({ step, number }) => ({
|
|
3357
|
+
step,
|
|
3358
|
+
number,
|
|
3359
|
+
preview: (await callQuery(ctx, `${PREVIEW}:preview`, {
|
|
3360
|
+
sequenceId: row._id,
|
|
3361
|
+
enrollmentId: enrollment._id,
|
|
3362
|
+
stepId: step._id,
|
|
3363
|
+
stepIndex: step.order ?? number - 1,
|
|
3364
|
+
})) as MessagePreview,
|
|
3365
|
+
})),
|
|
3366
|
+
);
|
|
3367
|
+
const lines = [
|
|
3368
|
+
`What ${name} gets from "${row.name}"${personRef ? "" : " (the first person; pick someone with --person)"}:`,
|
|
3369
|
+
];
|
|
3370
|
+
for (const { step, number, preview } of rendered)
|
|
3371
|
+
lines.push("", ...previewLines(number, step, preview));
|
|
3372
|
+
out(
|
|
3373
|
+
ctx,
|
|
3374
|
+
{
|
|
3375
|
+
sequenceId: row._id,
|
|
3376
|
+
enrollmentId: enrollment._id,
|
|
3377
|
+
person: name,
|
|
3378
|
+
steps: rendered.map(({ step, number, preview }) => ({
|
|
3379
|
+
step: number,
|
|
3380
|
+
stepId: step._id,
|
|
3381
|
+
type: step.type,
|
|
3382
|
+
...preview,
|
|
3383
|
+
})),
|
|
3384
|
+
},
|
|
3385
|
+
lines,
|
|
3386
|
+
);
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
const ACTIVITY = "functions/crm/activity";
|
|
3390
|
+
const REPLIES_DEFAULT_LIMIT = 20;
|
|
3391
|
+
const REPLIES_MAX_LIMIT = 50;
|
|
3392
|
+
const REPLY_LOOKUP_CONCURRENCY = 5;
|
|
3393
|
+
|
|
3394
|
+
const CHANNEL_KINDS: Record<string, string[]> = {
|
|
3395
|
+
email: ["email_in", "email_out"],
|
|
3396
|
+
linkedin: ["linkedin_in", "linkedin_out"],
|
|
3397
|
+
};
|
|
3398
|
+
|
|
3399
|
+
type LedgerThread = {
|
|
3400
|
+
type: string;
|
|
3401
|
+
thread?: { threadKey: string; title?: string };
|
|
3402
|
+
};
|
|
3403
|
+
|
|
3404
|
+
type LedgerMessage = {
|
|
3405
|
+
kind: string;
|
|
3406
|
+
occurredAt: number;
|
|
3407
|
+
direction?: "inbound" | "outbound";
|
|
3408
|
+
from: string;
|
|
3409
|
+
snippet: string;
|
|
3410
|
+
};
|
|
3411
|
+
|
|
3412
|
+
type InboundReply = {
|
|
3413
|
+
at: number;
|
|
3414
|
+
channel: "email" | "linkedin";
|
|
3415
|
+
from: string;
|
|
3416
|
+
text: string;
|
|
3417
|
+
subject?: string;
|
|
3418
|
+
answered: boolean;
|
|
3419
|
+
};
|
|
3420
|
+
|
|
3421
|
+
function parseSince(raw: string): number {
|
|
3422
|
+
const relative = /^(\d+)([hdw])$/i.exec(raw.trim());
|
|
3423
|
+
if (relative) {
|
|
3424
|
+
const unit = relative[2].toLowerCase();
|
|
3425
|
+
const span =
|
|
3426
|
+
unit === "h" ? 3_600_000 : unit === "d" ? 86_400_000 : 604_800_000;
|
|
3427
|
+
return Date.now() - Number(relative[1]) * span;
|
|
3428
|
+
}
|
|
3429
|
+
const parsed = Date.parse(raw);
|
|
3430
|
+
if (Number.isNaN(parsed)) {
|
|
3431
|
+
throw new CampaignCliError(
|
|
3432
|
+
"--since must look like 12h, 7d, 2w or a date such as 2026-09-01",
|
|
3433
|
+
);
|
|
3434
|
+
}
|
|
3435
|
+
return parsed;
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
async function latestInbound(
|
|
3439
|
+
ctx: CampaignCliContext,
|
|
3440
|
+
places: ReadonlyArray<{ entryId: string; objectName: string }>,
|
|
3441
|
+
channel: string | undefined,
|
|
3442
|
+
): Promise<InboundReply | null> {
|
|
3443
|
+
let best: InboundReply | null = null;
|
|
3444
|
+
for (const place of places) {
|
|
3445
|
+
let feed: { items?: LedgerThread[] };
|
|
3446
|
+
try {
|
|
3447
|
+
feed = (await callQuery(ctx, `${ACTIVITY}:listForEntry`, {
|
|
3448
|
+
objectName: place.objectName,
|
|
3449
|
+
entryId: place.entryId,
|
|
3450
|
+
order: "desc",
|
|
3451
|
+
limit: 3,
|
|
3452
|
+
kinds: (channel && CHANNEL_KINDS[channel]) || [
|
|
3453
|
+
...CHANNEL_KINDS.email,
|
|
3454
|
+
...CHANNEL_KINDS.linkedin,
|
|
3455
|
+
],
|
|
3456
|
+
})) as { items?: LedgerThread[] };
|
|
3457
|
+
} catch {
|
|
3458
|
+
continue;
|
|
3459
|
+
}
|
|
3460
|
+
for (const item of feed.items ?? []) {
|
|
3461
|
+
if (item.type !== "thread" || !item.thread) continue;
|
|
3462
|
+
let messages: LedgerMessage[];
|
|
3463
|
+
try {
|
|
3464
|
+
messages = (await callQuery(ctx, `${ACTIVITY}:listThreadMessages`, {
|
|
3465
|
+
objectName: place.objectName,
|
|
3466
|
+
entryId: place.entryId,
|
|
3467
|
+
threadKey: item.thread.threadKey,
|
|
3468
|
+
limit: 20,
|
|
3469
|
+
})) as LedgerMessage[];
|
|
3470
|
+
} catch {
|
|
3471
|
+
continue;
|
|
3472
|
+
}
|
|
3473
|
+
const inbound = [...messages]
|
|
3474
|
+
.reverse()
|
|
3475
|
+
.find((message) => message.direction === "inbound");
|
|
3476
|
+
if (!inbound || (best && inbound.occurredAt <= best.at)) continue;
|
|
3477
|
+
best = {
|
|
3478
|
+
at: inbound.occurredAt,
|
|
3479
|
+
channel: inbound.kind.startsWith("linkedin") ? "linkedin" : "email",
|
|
3480
|
+
from: inbound.from,
|
|
3481
|
+
text: inbound.snippet,
|
|
3482
|
+
...(item.thread.title ? { subject: item.thread.title } : {}),
|
|
3483
|
+
answered: messages.some(
|
|
3484
|
+
(message) =>
|
|
3485
|
+
message.direction === "outbound" &&
|
|
3486
|
+
message.occurredAt > inbound.occurredAt,
|
|
3487
|
+
),
|
|
3488
|
+
};
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
return best;
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
async function runReplies(ctx: CampaignCliContext): Promise<void> {
|
|
3495
|
+
const limitRaw = valueFlag(ctx.args, "--limit");
|
|
3496
|
+
const sinceRaw = valueFlag(ctx.args, "--since");
|
|
3497
|
+
const personRef = valueFlag(ctx.args, "--person");
|
|
3498
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
3499
|
+
unknownFlags(ctx.args, "campaign replies");
|
|
3500
|
+
const limit =
|
|
3501
|
+
limitRaw === undefined ? REPLIES_DEFAULT_LIMIT : Number(limitRaw);
|
|
3502
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > REPLIES_MAX_LIMIT) {
|
|
3503
|
+
throw new CampaignCliError(
|
|
3504
|
+
`--limit must be a whole number from 1 to ${REPLIES_MAX_LIMIT}`,
|
|
3505
|
+
);
|
|
3506
|
+
}
|
|
3507
|
+
const since = sinceRaw === undefined ? undefined : parseSince(sinceRaw);
|
|
3508
|
+
const row = await resolveCampaign(ctx, reference);
|
|
3509
|
+
const detail = await loadDetail(ctx, String(row._id));
|
|
3510
|
+
const only = personRef ? resolvePerson(detail, personRef) : null;
|
|
3511
|
+
const replied = detail.enrollments
|
|
3512
|
+
.filter(
|
|
3513
|
+
(enrollment) =>
|
|
3514
|
+
(enrollment.status === "replied" || enrollment.repliedAt != null) &&
|
|
3515
|
+
(!only || enrollment._id === only._id) &&
|
|
3516
|
+
(since === undefined || (enrollment.repliedAt ?? 0) >= since),
|
|
3517
|
+
)
|
|
3518
|
+
.sort((a, b) => (b.repliedAt ?? 0) - (a.repliedAt ?? 0));
|
|
3519
|
+
const shown = replied.slice(0, limit);
|
|
3520
|
+
const people: Array<{
|
|
3521
|
+
enrollment: Enrollment;
|
|
3522
|
+
name: string;
|
|
3523
|
+
reply: InboundReply | null;
|
|
3524
|
+
}> = [];
|
|
3525
|
+
for (let start = 0; start < shown.length; start += REPLY_LOOKUP_CONCURRENCY) {
|
|
3526
|
+
people.push(
|
|
3527
|
+
...(await Promise.all(
|
|
3528
|
+
shown
|
|
3529
|
+
.slice(start, start + REPLY_LOOKUP_CONCURRENCY)
|
|
3530
|
+
.map(async (enrollment) => {
|
|
3531
|
+
const connected = detail.connectedEntries?.[String(enrollment._id)];
|
|
3532
|
+
const places = [
|
|
3533
|
+
connected
|
|
3534
|
+
? {
|
|
3535
|
+
entryId: connected.entryId,
|
|
3536
|
+
objectName: connected.objectName,
|
|
3537
|
+
}
|
|
3538
|
+
: null,
|
|
3539
|
+
connected?.lead
|
|
3540
|
+
? { entryId: connected.lead.entryId, objectName: "lead" }
|
|
3541
|
+
: null,
|
|
3542
|
+
].filter(
|
|
3543
|
+
(
|
|
3544
|
+
place,
|
|
3545
|
+
index,
|
|
3546
|
+
all,
|
|
3547
|
+
): place is {
|
|
3548
|
+
entryId: string;
|
|
3549
|
+
objectName: string;
|
|
3550
|
+
} =>
|
|
3551
|
+
place !== null &&
|
|
3552
|
+
all.findIndex((other) => other?.entryId === place.entryId) ===
|
|
3553
|
+
index,
|
|
3554
|
+
);
|
|
3555
|
+
return {
|
|
3556
|
+
enrollment,
|
|
3557
|
+
name:
|
|
3558
|
+
enrollment.displayName?.trim() ||
|
|
3559
|
+
connected?.label ||
|
|
3560
|
+
enrollment._id,
|
|
3561
|
+
reply: await latestInbound(
|
|
3562
|
+
ctx,
|
|
3563
|
+
places,
|
|
3564
|
+
enrollment.repliedChannel,
|
|
3565
|
+
),
|
|
3566
|
+
};
|
|
3567
|
+
}),
|
|
3568
|
+
)),
|
|
3569
|
+
);
|
|
3570
|
+
}
|
|
3571
|
+
const header = `${replied.length} ${replied.length === 1 ? "person" : "people"} replied to "${row.name}"${since !== undefined ? ` since ${fmtWhen(since)}` : ""}${replied.length > shown.length ? ` (showing the newest ${shown.length}; raise --limit, up to ${REPLIES_MAX_LIMIT})` : replied.length ? " (newest first)" : ""}`;
|
|
3572
|
+
const lines = [header];
|
|
3573
|
+
for (const { enrollment, name, reply } of people) {
|
|
3574
|
+
const connected = detail.connectedEntries?.[String(enrollment._id)];
|
|
3575
|
+
const bits = [
|
|
3576
|
+
`replied${enrollment.repliedChannel ? ` by ${enrollment.repliedChannel}` : ""}${enrollment.repliedAt ? ` ${fmtRelative(enrollment.repliedAt)}` : ""}`,
|
|
3577
|
+
];
|
|
3578
|
+
if (connected?.lead?.status) bits.push(`lead: ${connected.lead.status}`);
|
|
3579
|
+
if (reply && !reply.answered) bits.push("not answered yet");
|
|
3580
|
+
lines.push(` ${name} — ${bits.join(" · ")} [${enrollment._id}]`);
|
|
3581
|
+
if (reply) {
|
|
3582
|
+
lines.push(
|
|
3583
|
+
...reply.text.split("\n").map((line) => ` ${line}`),
|
|
3584
|
+
` — ${reply.channel}${reply.subject ? `, "${reply.subject}"` : ""}, ${fmtWhen(reply.at)}`,
|
|
3585
|
+
);
|
|
3586
|
+
} else {
|
|
3587
|
+
lines.push(
|
|
3588
|
+
" (the message text is not in the activity ledger yet; dench crm activity <object> <entryId> shows what has arrived)",
|
|
3589
|
+
);
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
out(
|
|
3593
|
+
ctx,
|
|
3594
|
+
{
|
|
3595
|
+
sequenceId: row._id,
|
|
3596
|
+
count: replied.length,
|
|
3597
|
+
shown: shown.length,
|
|
3598
|
+
replies: people.map(({ enrollment, name, reply }) => {
|
|
3599
|
+
const connected = detail.connectedEntries?.[String(enrollment._id)];
|
|
3600
|
+
return {
|
|
3601
|
+
enrollmentId: enrollment._id,
|
|
3602
|
+
name,
|
|
3603
|
+
status: enrollment.status,
|
|
3604
|
+
repliedAt: enrollment.repliedAt ?? null,
|
|
3605
|
+
repliedChannel: enrollment.repliedChannel ?? null,
|
|
3606
|
+
lead: connected?.lead ?? null,
|
|
3607
|
+
record: connected
|
|
3608
|
+
? { entryId: connected.entryId, objectName: connected.objectName }
|
|
3609
|
+
: null,
|
|
3610
|
+
reply,
|
|
3611
|
+
};
|
|
3612
|
+
}),
|
|
3613
|
+
},
|
|
3614
|
+
lines,
|
|
3615
|
+
);
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
type Membership = {
|
|
3619
|
+
enrollment: {
|
|
3620
|
+
_id: string;
|
|
3621
|
+
sequenceId: string;
|
|
3622
|
+
displayName?: string;
|
|
3623
|
+
status: string;
|
|
3624
|
+
currentStepIndex?: number;
|
|
3625
|
+
repliedAt?: number;
|
|
3626
|
+
};
|
|
3627
|
+
sequenceName: string;
|
|
3628
|
+
isOwner?: boolean;
|
|
3629
|
+
senders?: {
|
|
3630
|
+
email?: { label: string; status: string } | null;
|
|
3631
|
+
linkedin?: { label: string; status: string } | null;
|
|
3632
|
+
};
|
|
3633
|
+
};
|
|
3634
|
+
|
|
3635
|
+
async function runWhere(ctx: CampaignCliContext): Promise<void> {
|
|
3636
|
+
const entryId = shift(ctx.args, "CRM record id");
|
|
3637
|
+
unknownFlags(ctx.args, "campaign where");
|
|
3638
|
+
const memberships = (await callQuery(
|
|
3639
|
+
ctx,
|
|
3640
|
+
`${OUTREACH}:listEnrollmentsForEntry`,
|
|
3641
|
+
{ entryId },
|
|
3642
|
+
)) as Membership[];
|
|
3643
|
+
const lines = [
|
|
3644
|
+
memberships.length
|
|
3645
|
+
? `This record is in ${memberships.length} ${memberships.length === 1 ? "campaign" : "campaigns"} (newest first):`
|
|
3646
|
+
: "This record is not in any campaign.",
|
|
3647
|
+
];
|
|
3648
|
+
for (const { enrollment, sequenceName, senders } of memberships) {
|
|
3649
|
+
const bits = [enrollment.status];
|
|
3650
|
+
if (typeof enrollment.currentStepIndex === "number")
|
|
3651
|
+
bits.push(`step ${enrollment.currentStepIndex + 1}`);
|
|
3652
|
+
if (enrollment.repliedAt) bits.push("replied");
|
|
3653
|
+
const from = [senders?.email?.label, senders?.linkedin?.label].filter(
|
|
3654
|
+
Boolean,
|
|
3655
|
+
);
|
|
3656
|
+
if (from.length) bits.push(`sent from ${from.join(" + ")}`);
|
|
3657
|
+
lines.push(
|
|
3658
|
+
` ${sequenceName} — ${bits.join(" · ")} [${enrollment.sequenceId}, person ${enrollment._id}]`,
|
|
3659
|
+
);
|
|
3660
|
+
}
|
|
3661
|
+
out(
|
|
3662
|
+
ctx,
|
|
3663
|
+
{
|
|
3664
|
+
entryId,
|
|
3665
|
+
count: memberships.length,
|
|
3666
|
+
campaigns: memberships.map(({ enrollment, sequenceName, senders }) => ({
|
|
3667
|
+
sequenceId: enrollment.sequenceId,
|
|
3668
|
+
name: sequenceName,
|
|
3669
|
+
enrollmentId: enrollment._id,
|
|
3670
|
+
status: enrollment.status,
|
|
3671
|
+
currentStepIndex: enrollment.currentStepIndex ?? null,
|
|
3672
|
+
repliedAt: enrollment.repliedAt ?? null,
|
|
3673
|
+
senders: senders ?? null,
|
|
3674
|
+
})),
|
|
3675
|
+
},
|
|
3676
|
+
lines,
|
|
3677
|
+
);
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
type PipelineCounts = {
|
|
3681
|
+
total: number;
|
|
3682
|
+
statuses: Array<{ name: string; color: string; count: number }>;
|
|
3683
|
+
other: number;
|
|
3684
|
+
};
|
|
3685
|
+
|
|
3686
|
+
async function runPipeline(ctx: CampaignCliContext): Promise<void> {
|
|
3687
|
+
const reference = shift(ctx.args, "campaign id or name");
|
|
3688
|
+
unknownFlags(ctx.args, "campaign pipeline");
|
|
3689
|
+
const row = await resolveCampaign(ctx, reference);
|
|
3690
|
+
const counts = (await callQuery(
|
|
3691
|
+
ctx,
|
|
3692
|
+
`${OUTREACH}:getSequenceLeadStatusCounts`,
|
|
3693
|
+
{
|
|
3694
|
+
sequenceId: row._id,
|
|
3695
|
+
},
|
|
3696
|
+
)) as PipelineCounts | null;
|
|
3697
|
+
if (!counts) throw new CampaignCliError("Campaign not found");
|
|
3698
|
+
const width = Math.max(
|
|
3699
|
+
5,
|
|
3700
|
+
...counts.statuses.map((status) => status.name.length),
|
|
3701
|
+
);
|
|
3702
|
+
const lines = [
|
|
3703
|
+
`Where ${counts.total} ${counts.total === 1 ? "person" : "people"} in "${row.name}" stand in the lead pipeline:`,
|
|
3704
|
+
...counts.statuses.map(
|
|
3705
|
+
(status) => ` ${status.name.padEnd(width)} ${status.count}`,
|
|
3706
|
+
),
|
|
3707
|
+
];
|
|
3708
|
+
if (counts.other > 0)
|
|
3709
|
+
lines.push(
|
|
3710
|
+
` ${"other".padEnd(width)} ${counts.other} (no lead, or a status outside the pipeline)`,
|
|
3711
|
+
);
|
|
3712
|
+
out(ctx, { sequenceId: row._id, ...counts }, lines);
|
|
3713
|
+
}
|
|
3714
|
+
|
|
2182
3715
|
async function runEvents(ctx: CampaignCliContext): Promise<void> {
|
|
2183
3716
|
const limitRaw = valueFlag(ctx.args, "--limit");
|
|
2184
3717
|
const maxPagesRaw = valueFlag(ctx.args, "--max-pages");
|
|
@@ -2249,8 +3782,32 @@ type MailboxRow = {
|
|
|
2249
3782
|
isOwner?: boolean;
|
|
2250
3783
|
visibility?: string;
|
|
2251
3784
|
remainingToday?: number;
|
|
3785
|
+
warmupStatus?: string;
|
|
3786
|
+
readyAt?: number;
|
|
3787
|
+
dailyColdSendCap?: number;
|
|
3788
|
+
};
|
|
3789
|
+
|
|
3790
|
+
const WARMUP_TEXT: Record<string, string> = {
|
|
3791
|
+
ready: "warm-up done",
|
|
3792
|
+
skipped: "warm-up skipped",
|
|
3793
|
+
warming: "warming up",
|
|
3794
|
+
pending_auth: "warm-up waiting for the owner to finish connecting it",
|
|
3795
|
+
error: "warm-up needs the owner's attention",
|
|
2252
3796
|
};
|
|
2253
3797
|
|
|
3798
|
+
function describeWarmup(mailbox: {
|
|
3799
|
+
warmupStatus: string | null;
|
|
3800
|
+
readyAt: number | null;
|
|
3801
|
+
}): string | null {
|
|
3802
|
+
if (!mailbox.warmupStatus) return null;
|
|
3803
|
+
const text =
|
|
3804
|
+
WARMUP_TEXT[mailbox.warmupStatus] ??
|
|
3805
|
+
mailbox.warmupStatus.replace(/_/g, " ");
|
|
3806
|
+
return mailbox.warmupStatus === "warming" && mailbox.readyAt
|
|
3807
|
+
? `${text} until ${fmtWhen(mailbox.readyAt)}`
|
|
3808
|
+
: text;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
2254
3811
|
type LinkedinSenderRow = {
|
|
2255
3812
|
linkedinAccountId: string;
|
|
2256
3813
|
displayName?: string;
|
|
@@ -2291,6 +3848,9 @@ async function runAccounts(ctx: CampaignCliContext): Promise<void> {
|
|
|
2291
3848
|
isOwner: row.isOwner === true,
|
|
2292
3849
|
visibility: row.visibility ?? null,
|
|
2293
3850
|
remainingToday: remainingCapacity(row.remainingToday),
|
|
3851
|
+
dailyColdSendCap: remainingCapacity(row.dailyColdSendCap),
|
|
3852
|
+
warmupStatus: row.warmupStatus ?? null,
|
|
3853
|
+
readyAt: typeof row.readyAt === "number" ? row.readyAt : null,
|
|
2294
3854
|
}),
|
|
2295
3855
|
);
|
|
2296
3856
|
// An allowlist on the client as well as the query's return validator keeps
|
|
@@ -2314,10 +3874,10 @@ async function runAccounts(ctx: CampaignCliContext): Promise<void> {
|
|
|
2314
3874
|
}));
|
|
2315
3875
|
const lines = [
|
|
2316
3876
|
`Mailboxes (${mailboxes.length}) — use these ids with --email-mailboxes:`,
|
|
2317
|
-
...mailboxes.map(
|
|
2318
|
-
(m)
|
|
2319
|
-
|
|
2320
|
-
),
|
|
3877
|
+
...mailboxes.map((m) => {
|
|
3878
|
+
const warmup = describeWarmup(m);
|
|
3879
|
+
return ` ${m.email ?? "(no address)"} — ${m.provider ?? "mailbox"} · ${m.status ?? "unknown"}${m.statusReason ? ` (${m.statusReason})` : ""}${warmup ? ` · ${warmup}` : ""}${m.remainingToday === null ? "" : ` · ${m.remainingToday}${m.dailyColdSendCap === null ? "" : ` of ${m.dailyColdSendCap}`} cold emails left today`}${m.ownerName && !m.isOwner ? ` · ${m.ownerName}` : ""}${m.visibility === "private" ? " · private" : ""} [${m.emailMailboxAccountId}]`;
|
|
3880
|
+
}),
|
|
2321
3881
|
];
|
|
2322
3882
|
if (mailboxes.length === 0) {
|
|
2323
3883
|
lines.push(
|
|
@@ -2340,7 +3900,7 @@ async function runAccounts(ctx: CampaignCliContext): Promise<void> {
|
|
|
2340
3900
|
);
|
|
2341
3901
|
}
|
|
2342
3902
|
const note =
|
|
2343
|
-
"Capacity is a usage snapshot before queued work and campaign limits. Pool changes require access for both you and the campaign owner; launch and dispatch revalidate availability.";
|
|
3903
|
+
"Capacity is a usage snapshot before queued work and campaign limits. A mailbox's daily cap and warm-up, and a LinkedIn account's limits and hours, are set by its owner in the Dench app. Pool changes require access for both you and the campaign owner; launch and dispatch revalidate availability.";
|
|
2344
3904
|
lines.push("", note);
|
|
2345
3905
|
out(
|
|
2346
3906
|
ctx,
|
|
@@ -2366,6 +3926,13 @@ const SUBCOMMAND_ALIASES: Record<string, string> = {
|
|
|
2366
3926
|
start: "launch",
|
|
2367
3927
|
activity: "events",
|
|
2368
3928
|
senders: "accounts",
|
|
3929
|
+
delete: "archive",
|
|
3930
|
+
copy: "duplicate",
|
|
3931
|
+
clone: "duplicate",
|
|
3932
|
+
memberships: "where",
|
|
3933
|
+
personalization: "personalize",
|
|
3934
|
+
lines: "personalize",
|
|
3935
|
+
variables: "personalize",
|
|
2369
3936
|
};
|
|
2370
3937
|
|
|
2371
3938
|
export function campaignHelp(subcommand?: string): void {
|
|
@@ -2398,12 +3965,31 @@ function campaignHelpText(): string {
|
|
|
2398
3965
|
Outreach campaigns send email and LinkedIn steps to a list of people on one
|
|
2399
3966
|
schedule. <campaign> is an id or an exact (case-insensitive) name.
|
|
2400
3967
|
|
|
3968
|
+
Start here (references/CAMPAIGNS.md has the full recipe, from a brief to launch):
|
|
3969
|
+
1. dench campaign accounts sender ids: mailboxes and LinkedIn accounts
|
|
3970
|
+
2. dench campaign draft --name "..." --timezone <tz> [--email-mailboxes ..] [--linkedin-accounts ..]
|
|
3971
|
+
3. dench campaign search --filters @filters.json --not-industries "..." --limit 25 --out audience.json
|
|
3972
|
+
4. dench campaign add <campaign> --people-file audience.json
|
|
3973
|
+
5. dench campaign steps set <campaign> --file steps.json, then dench campaign preview <campaign>
|
|
3974
|
+
6. dench campaign launch <campaign> --check, then launch once the user says yes
|
|
3975
|
+
|
|
2401
3976
|
Browse:
|
|
2402
3977
|
dench campaign list [--all] # live campaigns; --all includes archived
|
|
2403
3978
|
dench campaign get <campaign> # status, schedule, funnel, Now/Next, holds, people
|
|
2404
3979
|
dench campaign people <campaign> [--status active|paused|finished|replied|stopped|bounced|failed|draft|complained|unsubscribed]
|
|
2405
3980
|
dench campaign events <campaign> [--limit 50] [--max-pages 1] # activity log, newest first
|
|
2406
|
-
dench campaign
|
|
3981
|
+
dench campaign preview <campaign> [--person <enrollmentId|name>] [--step N] # the exact messages one person gets
|
|
3982
|
+
Renders every email and LinkedIn step (or step N) with their fields and personalized lines filled in, and
|
|
3983
|
+
says what is missing or would be skipped. Nothing is sent. Without --person it uses the first person.
|
|
3984
|
+
dench campaign person show <campaign> --person <enrollmentId|name> # what the campaign did for one person, step by step
|
|
3985
|
+
Each step with its outcome (sent, opened, clicked, skipped and why, waiting, failed), when it happened, and the
|
|
3986
|
+
subject and start of the text that was sent. Read-only.
|
|
3987
|
+
dench campaign replies <campaign> [--person <enrollmentId|name>] [--since 7d] [--limit 20] # who replied, with their latest reply
|
|
3988
|
+
Lists the people who replied, newest first, with the text of their latest inbound email or LinkedIn message
|
|
3989
|
+
(up to 400 characters, quoted history removed) and whether you have answered. Read-only.
|
|
3990
|
+
dench campaign pipeline <campaign> # how many of its people are at each lead Status (New, Contacted, Replied, ...)
|
|
3991
|
+
dench campaign where <entryId> # the campaigns a CRM record (person or lead) is in, and its status in each
|
|
3992
|
+
dench campaign accounts # email + LinkedIn sender ids, warm-up, availability and capacity
|
|
2407
3993
|
|
|
2408
3994
|
Create and configure:
|
|
2409
3995
|
dench campaign create --name "Founder outreach" [--timezone America/Los_Angeles] \\
|
|
@@ -2413,22 +3999,109 @@ Create and configure:
|
|
|
2413
3999
|
dench campaign set <campaign> [--name "New name"] [--timezone <tz>] [--window "<spec>" ...] \\
|
|
2414
4000
|
[--email-mailboxes id1,id2|none] [--linkedin-accounts id1,id2|none] \\
|
|
2415
4001
|
[--daily-cap 1-100] [--stop-on-reply|--no-stop-on-reply] [--start <ISO>|--clear-start] [--end <ISO>|--clear-end]
|
|
2416
|
-
dench campaign
|
|
2417
|
-
|
|
4002
|
+
dench campaign duplicate <campaign> [--name "Founder outreach 2"] # new draft: same steps, schedule, senders and lines, no people
|
|
4003
|
+
Over ${MAX_CAMPAIGN_PEOPLE} people: duplicate, then add the next page of the search (--page-token) to the copy.
|
|
4004
|
+
dench campaign steps get <campaign> [--with-ids] # prints steps as JSON you can edit; --with-ids adds stepId and updatedAt
|
|
4005
|
+
dench campaign steps set <campaign> --file steps.json [--allow-single-braces] # replaces every step; drafts only
|
|
4006
|
+
dench campaign steps edit <campaign> --file steps.json [--allow-single-braces] # changes text and waits in place, also after launch
|
|
4007
|
+
Keeps the number, order and type of every step. Each step in the file is its full new content: leaving out a
|
|
4008
|
+
subject, body, note, onlyIf or emailReplyToPrevious clears it (senders and LinkedIn wait settings are kept).
|
|
4009
|
+
Steps match by order, or by stepId from steps get --with-ids to edit only some; a step saved since you read it
|
|
4010
|
+
is refused, not overwritten. Messages already sent, or already being prepared, keep their text.
|
|
2418
4011
|
|
|
2419
4012
|
Fill it:
|
|
4013
|
+
dench campaign draft [--name "New campaign"] [--request-id <8-64 char stable id>] [--timezone <tz>] \\
|
|
4014
|
+
[--window "Mon-Fri 9-18" ...] [--daily-cap 30] [--email-mailboxes id1,id2|none] [--linkedin-accounts id1,id2|none] \\
|
|
4015
|
+
[--stop-on-reply|--no-stop-on-reply] [--start <ISO>] [--end <ISO>] # same settings as create; a retry returns the same draft
|
|
4016
|
+
dench campaign search [<campaign>] --filters '{"titles":["Founder"]}' [--titles Founder,CEO] [--limit 50] [--page-token <token>] \\
|
|
4017
|
+
[--out audience.json [--force]] [--raw]
|
|
4018
|
+
dench campaign add <campaign> --search '{"titles":["Founder"],"locations":["United States"]}' [--limit 50] [--preview] [--allow-duplicates] [--allow-active]
|
|
4019
|
+
dench campaign add <campaign> --titles Founder --locations "United States" [--headcount 11-50] [--limit 50] [--page-token <token>] [--allow-active] \\
|
|
4020
|
+
[--out added.json [--force]] [--raw]
|
|
4021
|
+
dench campaign add <campaign> --people-file audience.json [--allow-duplicates]
|
|
4022
|
+
dench campaign add <campaign> --csv people.csv [--map "email=Work Email" ...] [--preview] [--allow-duplicates]
|
|
4023
|
+
Reads a spreadsheet export the way the app's Add people does: the email and LinkedIn columns are found from
|
|
4024
|
+
what they hold, whatever they are called; the name comes from First name + Last name columns or from one
|
|
4025
|
+
Name, Full name or Contact column, found by its header; every other column becomes a merge value
|
|
4026
|
+
({{Company}}, {{Job Title}}). --map email|linkedin|name|first|last="<header or column letter>" overrides a
|
|
4027
|
+
guess (none switches a field off). --preview shows how each column is read and adds nobody. Comma,
|
|
4028
|
+
semicolon and tab files work, up to 500 people.
|
|
4029
|
+
dench campaign add <campaign> --list <artifactId> [--limit 100] [--enrichment enriched] [--preview] [--allow-duplicates]
|
|
4030
|
+
Adds people from a saved list (dench lists; made by dench leads find --save-as) without searching or paying
|
|
4031
|
+
again: new people become Leads during enrollment, with custom variables preserved. --enrichment keeps only rows at that stage
|
|
4032
|
+
(all, enriched, in_progress, no_contacts, failed, not_enriched); --preview shows the rows and adds nobody.
|
|
2420
4033
|
dench campaign add <campaign> --object lead --view "Q3 targets" [--email-field Email] [--linkedin-field "POC LinkedIn URL"] \\
|
|
2421
4034
|
[--name-field Name] [--allow-duplicates]
|
|
2422
4035
|
dench campaign add <campaign> --object people --entries id1,id2 [--relation-field Contact] [--reverse]
|
|
2423
4036
|
dench campaign add <campaign> [--name "Ada Lovelace"] --email ada@x.com and/or --linkedin-url https://linkedin.com/in/ada
|
|
2424
4037
|
|
|
4038
|
+
Personalized lines (written by AI for each person, about 3 cents a person; up to 5 per campaign):
|
|
4039
|
+
dench campaign personalize get <campaign> # the lines, their {{keys}}, and how many are written
|
|
4040
|
+
dench campaign personalize set <campaign> --file lines.json # {"sells":"...","lines":[{"label":"Company hook","instructions":"...","fallback":"..."}]}
|
|
4041
|
+
Optional per line: key (else made from the label), maxChars 20-300, fallbackFrom (a field to use before the fallback).
|
|
4042
|
+
Saving writes lines for up to 100 people right away; more waits for personalize write --yes. Use each as {{key}} in steps.
|
|
4043
|
+
dench campaign personalize write <campaign> [--all] [--yes] # write missing lines (--all rewrites everyone); --yes confirms the cost
|
|
4044
|
+
dench campaign personalize edit <campaign> --person <enrollmentId|name> --line <key> --text "..." # your own text; --text "" undoes it
|
|
4045
|
+
dench campaign personalize rewrite <campaign> --person <enrollmentId|name> [--line <key>] # a fresh line for one person
|
|
4046
|
+
dench campaign personalize clear <campaign> # turn lines off (take the {{keys}} out of the steps first)
|
|
4047
|
+
|
|
4048
|
+
Signal builder and sources:
|
|
4049
|
+
dench campaign builder profile
|
|
4050
|
+
dench campaign builder save --file draft.json
|
|
4051
|
+
dench campaign builder get <draftId>
|
|
4052
|
+
dench campaign builder build <draftId>
|
|
4053
|
+
dench campaign builder create <draftId> [--name "New campaign"] [--timezone <tz>]
|
|
4054
|
+
dench campaign builder opener <draftId> <leadId> --text "Opening line"
|
|
4055
|
+
dench campaign builder autofill --description "VP Sales at US software companies"
|
|
4056
|
+
dench campaign builder terms --file targeting.json
|
|
4057
|
+
dench campaign builder estimate --file estimate.json
|
|
4058
|
+
dench campaign sources get <campaign>
|
|
4059
|
+
dench campaign sources signals <campaign>
|
|
4060
|
+
dench campaign sources set <campaign> --data '{"cadence":"weekly"}'
|
|
4061
|
+
dench campaign sources preview <campaign> --signal hiring
|
|
4062
|
+
|
|
4063
|
+
--filters and --search take the same JSON with every people filter (@file.json
|
|
4064
|
+
works) on both search and add. The dench leads find filter flags (--titles,
|
|
4065
|
+
--locations, --industries, --seniorities, --headcount, --functions,
|
|
4066
|
+
--company-domains, --company-names, --company-keywords, --profile-keywords,
|
|
4067
|
+
--skills, --years-in-role, --exact, ...) add to it: list flags append to the
|
|
4068
|
+
JSON lists, so {"value":"Staffing and Recruiting","exclude":true} exclusions
|
|
4069
|
+
stay. Leave values out with --not-<flag> on any list flag (--exclude-<flag>
|
|
4070
|
+
works too), e.g. --not-industries "Staffing and Recruiting" --not-company-keywords
|
|
4071
|
+
agency --not-domains example.com; each value is sent as {"value":"...","exclude":true},
|
|
4072
|
+
and a search still needs at least one include filter. Names with a comma that
|
|
4073
|
+
match a known value stay whole; any other comma splits a value in two, so wrap a
|
|
4074
|
+
value that contains one in double quotes inside the shell quotes
|
|
4075
|
+
(--titles '"Vice President, Information Systems"') or use --filters JSON. A Note
|
|
4076
|
+
on stderr shows how a value was split (not for domains or exact filter values).
|
|
4077
|
+
--limit is at most 100 for search and 50 per add; continue with --page-token.
|
|
4078
|
+
Search and --preview cost 1 cent per person returned; preview means no enrollment, not
|
|
4079
|
+
free search. The most a call can cost prints on stderr before it runs, and results
|
|
4080
|
+
carry estimatedCents and chargedCents. Output lists each person once (rows and
|
|
4081
|
+
recipients) with nextPageToken on top; --raw adds the raw people records and
|
|
4082
|
+
--out <file> writes the full result to a file you can pass to --people-file.
|
|
4083
|
+
--out is checked before anything is billed: its folder must exist and the file
|
|
4084
|
+
must not (--force replaces it, so give each page its own name). If it still
|
|
4085
|
+
cannot be written, the result prints on stdout and is not lost.
|
|
4086
|
+
--people-file accepts a recipient array or the JSON response from campaign search.
|
|
4087
|
+
It adds already-fetched people without another search. Searching and adding never
|
|
4088
|
+
launch a campaign: run launch --check, then launch once the user says yes.
|
|
4089
|
+
Adding people to a launched campaign starts their steps; a search add into a
|
|
4090
|
+
campaign that is not a draft needs --allow-active. Every add result has campaignStatus.
|
|
4091
|
+
|
|
2425
4092
|
Run it:
|
|
2426
|
-
dench campaign launch <campaign>
|
|
4093
|
+
dench campaign launch <campaign> --check # readiness only, never launches: blockers with why + fix, warnings, fingerprint; exit 1 when blocked
|
|
4094
|
+
dench campaign launch <campaign> [--ack-linkedin-risk] # launches as soon as it is ready (real messages go out); prints blockers otherwise; start is the same command
|
|
2427
4095
|
dench campaign pause <campaign>
|
|
2428
4096
|
dench campaign resume <campaign>
|
|
2429
4097
|
dench campaign archive <campaign> # stops everyone still in flight
|
|
2430
|
-
|
|
2431
|
-
dench campaign
|
|
4098
|
+
dench campaign delete is the same command: like Delete in the app, it archives (nothing is erased; leads and activity stay)
|
|
4099
|
+
dench campaign stop <campaign> --person <enrollmentId|name> # nothing more is sent; in a draft, a never-contacted person is removed instead
|
|
4100
|
+
dench campaign remove <campaign> --person <enrollmentId|name> # drafts only: drop someone never contacted, freeing their place
|
|
4101
|
+
dench campaign resume-person <campaign> --person <enrollmentId|name> | --all # --all: every paused person, e.g. after a reconnect
|
|
4102
|
+
dench campaign person set <campaign> --person <enrollmentId|name> [--email ada@new.com] [--linkedin-url <url>] \\
|
|
4103
|
+
[--field "Job Title=CTO" ...] # before launch: fix an address or fill a {{merge field}}; --field repeats
|
|
4104
|
+
dench campaign sync <campaign> # fill people's empty email, LinkedIn URL, name and fields from their CRM records
|
|
2432
4105
|
|
|
2433
4106
|
Send window spec (--window, repeatable, all in --timezone):
|
|
2434
4107
|
"Mon-Fri 9-18" "Mon,Wed,Fri 10-16" "Weekdays 9am-5pm" "Sat-Sun 10-14"
|
|
@@ -2437,7 +4110,27 @@ Send window spec (--window, repeatable, all in --timezone):
|
|
|
2437
4110
|
of that day in the campaign's timezone; add a time and offset to be exact.
|
|
2438
4111
|
|
|
2439
4112
|
steps.json: a JSON array of steps, in order. Types: email, wait, linkedin_visit,
|
|
2440
|
-
linkedin_connection, linkedin_message
|
|
4113
|
+
linkedin_connection, linkedin_message, linkedin_like_post, linkedin_inmail.
|
|
4114
|
+
See references/CAMPAIGNS.md for every field.
|
|
4115
|
+
Post reactions: likeReaction = like|celebrate|support|love|insightful|funny
|
|
4116
|
+
(default like); likeMaxPostAgeDays = 1-365 (default 30); likeSkipNextWait = true
|
|
4117
|
+
by default, skipping consecutive following waits when the reaction is skipped.
|
|
4118
|
+
InMail: linkedinMessageBody required (1-1900 chars), inmailSubject optional
|
|
4119
|
+
(up to 200 chars). Connected people skip InMail automatically; do not set onlyIf.
|
|
4120
|
+
Connection requests: withdrawAfterHours = 168-2160 (7-90 days); omitted or null
|
|
4121
|
+
means no automatic withdrawal. Only invitations sent by the campaign are withdrawn.
|
|
4122
|
+
Message acceptance waits: connectionTimeoutHours = 1-2160 (default 720), only
|
|
4123
|
+
with linkedinMessageMode=wait_until_connected (the default after a connection).
|
|
4124
|
+
steps set refuses a field the step type would drop (onlyIf off email/post-like, a body on
|
|
4125
|
+
a visit, a reply-in-thread first email), a missing subject or body, a note over
|
|
4126
|
+
300, message over 3000 or InMail over 1900 characters, out-of-range step settings,
|
|
4127
|
+
a wait of 0 or over 2160 hours, and {TOKEN}
|
|
4128
|
+
single braces, which would go out as typed (use {{TOKEN}}; --allow-single-braces
|
|
4129
|
+
keeps literal braces). It warns when several LinkedIn messages after a
|
|
4130
|
+
connection request each wait 30 days for the accept. An email with both
|
|
4131
|
+
emailHtmlBody and emailTextBody sends the HTML, so when you edit only the
|
|
4132
|
+
text, the HTML copied from steps get is dropped (with a warning) and the
|
|
4133
|
+
new text is sent.
|
|
2441
4134
|
|
|
2442
4135
|
Sender pools: --email-mailboxes and --linkedin-accounts select accounts once for
|
|
2443
4136
|
the campaign (emailMailboxAccountIds / linkedinAccountIds in the API). Accepts
|
|
@@ -2447,8 +4140,8 @@ can be selected. Launch checks availability. A person's sender stays fixed for
|
|
|
2447
4140
|
their conversation. Legacy step senders are preserved until a pool is configured.
|
|
2448
4141
|
|
|
2449
4142
|
A campaign holds at most ${MAX_CAMPAIGN_PEOPLE} people. steps set only works on drafts. After
|
|
2450
|
-
launch,
|
|
2451
|
-
daily cap and stop-on-reply stay editable
|
|
4143
|
+
launch, change a step's text or wait time with steps edit; the schedule,
|
|
4144
|
+
daily cap and stop-on-reply stay editable with set. Leads move New → Contacted → Replied
|
|
2452
4145
|
on their own as steps go out.
|
|
2453
4146
|
|
|
2454
4147
|
Auth: a workspace member's credentials (dench signin, a member DENCH_API_KEY, or
|
|
@@ -2458,11 +4151,23 @@ an agent session). An organization-only key cannot run outreach.
|
|
|
2458
4151
|
|
|
2459
4152
|
// ─── Entry point ──────────────────────────────────────────────────────────
|
|
2460
4153
|
|
|
4154
|
+
async function runAgentCommand(ctx: CampaignCliContext, command: string) {
|
|
4155
|
+
const { runCampaignAgentCommand } = await import("./lib/campaign-agent");
|
|
4156
|
+
return runCampaignAgentCommand(
|
|
4157
|
+
{
|
|
4158
|
+
...ctx,
|
|
4159
|
+
resolveCampaign: (reference) => resolveCampaign(ctx, reference),
|
|
4160
|
+
},
|
|
4161
|
+
command,
|
|
4162
|
+
);
|
|
4163
|
+
}
|
|
4164
|
+
|
|
2461
4165
|
export async function runCampaignCommand(opts: {
|
|
2462
4166
|
convex: ConvexHttpClient;
|
|
2463
4167
|
args: string[];
|
|
2464
4168
|
sessionToken?: string;
|
|
2465
4169
|
jsonOutput?: boolean;
|
|
4170
|
+
host?: string;
|
|
2466
4171
|
}): Promise<void> {
|
|
2467
4172
|
const args = [...opts.args];
|
|
2468
4173
|
const jsonOutput = hasFlag(args, "--json") || opts.jsonOutput === true;
|
|
@@ -2471,6 +4176,7 @@ export async function runCampaignCommand(opts: {
|
|
|
2471
4176
|
args,
|
|
2472
4177
|
jsonOutput,
|
|
2473
4178
|
sessionToken: opts.sessionToken,
|
|
4179
|
+
host: opts.host,
|
|
2474
4180
|
};
|
|
2475
4181
|
const subcommand = args.shift();
|
|
2476
4182
|
if (
|
|
@@ -2487,12 +4193,21 @@ export async function runCampaignCommand(opts: {
|
|
|
2487
4193
|
return;
|
|
2488
4194
|
}
|
|
2489
4195
|
switch (subcommand) {
|
|
4196
|
+
case "draft":
|
|
4197
|
+
case "search":
|
|
4198
|
+
case "sources":
|
|
4199
|
+
case "builder":
|
|
4200
|
+
return runAgentCommand(ctx, subcommand);
|
|
2490
4201
|
case "list":
|
|
2491
4202
|
case "ls":
|
|
2492
4203
|
return await runList(ctx);
|
|
2493
4204
|
case "create":
|
|
2494
4205
|
case "new":
|
|
2495
4206
|
return await runCreate(ctx);
|
|
4207
|
+
case "duplicate":
|
|
4208
|
+
case "copy":
|
|
4209
|
+
case "clone":
|
|
4210
|
+
return await runDuplicate(ctx);
|
|
2496
4211
|
case "get":
|
|
2497
4212
|
case "status":
|
|
2498
4213
|
case "show":
|
|
@@ -2515,12 +4230,34 @@ export async function runCampaignCommand(opts: {
|
|
|
2515
4230
|
return await runLifecycle(ctx, "resume");
|
|
2516
4231
|
case "archive":
|
|
2517
4232
|
return await runLifecycle(ctx, "archive");
|
|
4233
|
+
case "delete":
|
|
4234
|
+
return await runLifecycle(ctx, "archive", "delete");
|
|
2518
4235
|
case "people":
|
|
2519
4236
|
return await runPeople(ctx);
|
|
2520
4237
|
case "stop":
|
|
2521
4238
|
return await runPerson(ctx, "stop");
|
|
4239
|
+
case "remove":
|
|
4240
|
+
return await runRemove(ctx);
|
|
2522
4241
|
case "resume-person":
|
|
2523
4242
|
return await runPerson(ctx, "resume-person");
|
|
4243
|
+
case "person":
|
|
4244
|
+
return await runPersonCommand(ctx);
|
|
4245
|
+
case "sync":
|
|
4246
|
+
return await runSync(ctx);
|
|
4247
|
+
case "personalize":
|
|
4248
|
+
case "personalization":
|
|
4249
|
+
case "lines":
|
|
4250
|
+
case "variables":
|
|
4251
|
+
return await runPersonalize(ctx);
|
|
4252
|
+
case "preview":
|
|
4253
|
+
return await runPreview(ctx);
|
|
4254
|
+
case "replies":
|
|
4255
|
+
return await runReplies(ctx);
|
|
4256
|
+
case "pipeline":
|
|
4257
|
+
return await runPipeline(ctx);
|
|
4258
|
+
case "where":
|
|
4259
|
+
case "memberships":
|
|
4260
|
+
return await runWhere(ctx);
|
|
2524
4261
|
case "events":
|
|
2525
4262
|
case "activity":
|
|
2526
4263
|
return await runEvents(ctx);
|
|
@@ -2529,7 +4266,7 @@ export async function runCampaignCommand(opts: {
|
|
|
2529
4266
|
return await runAccounts(ctx);
|
|
2530
4267
|
default:
|
|
2531
4268
|
throw new CampaignCliError(
|
|
2532
|
-
|
|
4269
|
+
unknownSubcommandMessage(subcommand, await cliVersion()),
|
|
2533
4270
|
);
|
|
2534
4271
|
}
|
|
2535
4272
|
}
|