@dench.com/cli 3.8.0-staging.389 → 3.8.1-staging.390
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/app-sdk.d.ts +132 -27
- package/campaign.ts +335 -54
- package/chat.ts +2 -4
- package/lib/api-schemas.ts +186 -20
- package/lib/app-sdk-catalog.generated.ts +3 -3
- package/lib/campaign-steps.ts +474 -12
- package/lib/campaign-timeline.ts +69 -4
- package/lib/command-registry.ts +3 -3
- package/lib/references.generated.json +14 -14
- package/package.json +1 -1
package/campaign.ts
CHANGED
|
@@ -31,14 +31,18 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
checkCampaignSteps,
|
|
33
33
|
hasHtmlAndTextBody,
|
|
34
|
+
hasWorkflowShape,
|
|
35
|
+
isBranchStep,
|
|
34
36
|
planStepEdits,
|
|
35
37
|
STEP_TYPES,
|
|
36
38
|
StepEditError,
|
|
37
39
|
staleHtmlSteps,
|
|
38
40
|
staleHtmlWarning,
|
|
39
41
|
stepToInput,
|
|
42
|
+
WORKFLOW_END,
|
|
40
43
|
} from "./lib/campaign-steps";
|
|
41
44
|
import {
|
|
45
|
+
branchExitLabel,
|
|
42
46
|
personAddress,
|
|
43
47
|
type TimelineResult,
|
|
44
48
|
timelineSteps,
|
|
@@ -109,6 +113,7 @@ export type CampaignRow = {
|
|
|
109
113
|
sendSchedules?: SendSchedule[];
|
|
110
114
|
dailySendCap?: number;
|
|
111
115
|
stepCount?: number;
|
|
116
|
+
stepModel?: string;
|
|
112
117
|
totalEnrolled?: number;
|
|
113
118
|
activeCount?: number;
|
|
114
119
|
pausedCount?: number;
|
|
@@ -147,6 +152,8 @@ type CurrentRun = {
|
|
|
147
152
|
type?: string;
|
|
148
153
|
stepIndex?: number;
|
|
149
154
|
waitUntil?: number;
|
|
155
|
+
/** A wait for acceptance: when it stops waiting either way. */
|
|
156
|
+
timeoutAt?: number;
|
|
150
157
|
retryAt?: number;
|
|
151
158
|
lastError?: string;
|
|
152
159
|
hold?: LinkedinHold;
|
|
@@ -189,6 +196,8 @@ type CampaignAnalytics = {
|
|
|
189
196
|
clicked?: number;
|
|
190
197
|
linkedinSent?: number;
|
|
191
198
|
replyRate?: number;
|
|
199
|
+
/** Workflow branch steps: how many people took each exit. */
|
|
200
|
+
branchStats?: Array<{ stepId: string; yes: number; no: number }>;
|
|
192
201
|
truncated?: boolean;
|
|
193
202
|
};
|
|
194
203
|
|
|
@@ -211,6 +220,10 @@ type CampaignLive = {
|
|
|
211
220
|
personName: string | null;
|
|
212
221
|
at: number;
|
|
213
222
|
hold: LinkedinHold | null;
|
|
223
|
+
/** Waiting for them to accept: when the wait ends either way. */
|
|
224
|
+
acceptanceDeadline?: number;
|
|
225
|
+
/** An If / else: what it checks, e.g. "Has an email address". */
|
|
226
|
+
label?: string;
|
|
214
227
|
} | null;
|
|
215
228
|
paused: {
|
|
216
229
|
reason: string;
|
|
@@ -617,28 +630,72 @@ export { STEP_TYPES };
|
|
|
617
630
|
|
|
618
631
|
export type StepsFile = { steps: JsonRecord[]; warnings: string[] };
|
|
619
632
|
|
|
633
|
+
export type StepModel = "linear" | "workflow";
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* The model to save steps as: the file's `stepModel`, else a workflow when
|
|
637
|
+
* the campaign is one or the steps have keys, paths or logic steps.
|
|
638
|
+
* Undefined leaves a linear campaign linear.
|
|
639
|
+
*/
|
|
640
|
+
function stepModelFor(
|
|
641
|
+
steps: readonly unknown[],
|
|
642
|
+
fileModel: unknown,
|
|
643
|
+
campaignModel: StepModel | undefined,
|
|
644
|
+
source: string,
|
|
645
|
+
): StepModel | undefined {
|
|
646
|
+
if (
|
|
647
|
+
fileModel !== undefined &&
|
|
648
|
+
fileModel !== "linear" &&
|
|
649
|
+
fileModel !== "workflow"
|
|
650
|
+
) {
|
|
651
|
+
throw new CampaignCliError(
|
|
652
|
+
`${source}: "stepModel" must be "workflow" or "linear".`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
if (campaignModel === "workflow" && fileModel === "linear") {
|
|
656
|
+
throw new CampaignCliError(
|
|
657
|
+
`This campaign is a workflow, so it can't go back to a plain list of steps. Leave "stepModel" out, or start a new campaign with dench campaign draft and set the steps there (dench campaign duplicate keeps the workflow).`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
const shaped = steps.findIndex(hasWorkflowShape);
|
|
661
|
+
if (fileModel === "linear" && shaped >= 0) {
|
|
662
|
+
throw new CampaignCliError(
|
|
663
|
+
`Step ${shaped + 1}: condition, wait_for_connection, "key", "next", "yesNext" and "noNext" only work in a workflow. Set "stepModel": "workflow" (or leave it out) to save these steps as one.`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
if (fileModel === "workflow" || campaignModel === "workflow" || shaped >= 0)
|
|
667
|
+
return "workflow";
|
|
668
|
+
return fileModel === "linear" ? "linear" : undefined;
|
|
669
|
+
}
|
|
670
|
+
|
|
620
671
|
/**
|
|
621
|
-
* Parse a steps file: a JSON array of step objects (or `{ "steps": [...] }
|
|
672
|
+
* Parse a steps file: a JSON array of step objects (or `{ "steps": [...] }`,
|
|
673
|
+
* optionally with `"stepModel"`).
|
|
622
674
|
* Checks the shape the server validates so a typo is caught here with a
|
|
623
675
|
* step number instead of a Convex validator message.
|
|
624
676
|
*/
|
|
625
677
|
export function readStepsJson(
|
|
626
678
|
raw: string,
|
|
627
679
|
source = "The steps file",
|
|
628
|
-
options: { allowSingleBraces?: boolean } = {},
|
|
629
|
-
): StepsFile {
|
|
680
|
+
options: { allowSingleBraces?: boolean; stepModel?: StepModel } = {},
|
|
681
|
+
): StepsFile & { stepModel?: StepModel } {
|
|
630
682
|
let parsed: unknown;
|
|
631
683
|
try {
|
|
632
684
|
parsed = JSON.parse(raw);
|
|
633
685
|
} catch {
|
|
634
686
|
throw new CampaignCliError(`${source} is not valid JSON.`);
|
|
635
687
|
}
|
|
688
|
+
const wrapped =
|
|
689
|
+
parsed &&
|
|
690
|
+
typeof parsed === "object" &&
|
|
691
|
+
!Array.isArray(parsed) &&
|
|
692
|
+
Array.isArray((parsed as JsonRecord).steps)
|
|
693
|
+
? (parsed as JsonRecord)
|
|
694
|
+
: null;
|
|
636
695
|
const steps = Array.isArray(parsed)
|
|
637
696
|
? parsed
|
|
638
|
-
:
|
|
639
|
-
|
|
640
|
-
Array.isArray((parsed as JsonRecord).steps)
|
|
641
|
-
? ((parsed as JsonRecord).steps as unknown[])
|
|
697
|
+
: wrapped
|
|
698
|
+
? (wrapped.steps as unknown[])
|
|
642
699
|
: null;
|
|
643
700
|
if (!steps) {
|
|
644
701
|
throw new CampaignCliError(
|
|
@@ -648,22 +705,33 @@ export function readStepsJson(
|
|
|
648
705
|
if (steps.length === 0) {
|
|
649
706
|
throw new CampaignCliError(`${source} has no steps.`);
|
|
650
707
|
}
|
|
708
|
+
const stepModel = stepModelFor(
|
|
709
|
+
steps,
|
|
710
|
+
wrapped?.stepModel,
|
|
711
|
+
options.stepModel,
|
|
712
|
+
source,
|
|
713
|
+
);
|
|
651
714
|
const check = checkCampaignSteps(steps, {
|
|
652
715
|
allowSingleBraces: options.allowSingleBraces,
|
|
653
716
|
singleBraceOverride: "--allow-single-braces",
|
|
717
|
+
workflow: stepModel === "workflow",
|
|
654
718
|
});
|
|
655
719
|
if (check.errors.length > 0) {
|
|
656
720
|
throw new CampaignCliError(
|
|
657
721
|
check.errors.map((issue) => issue.message).join("\n"),
|
|
658
722
|
);
|
|
659
723
|
}
|
|
660
|
-
return {
|
|
724
|
+
return {
|
|
725
|
+
steps: steps as JsonRecord[],
|
|
726
|
+
warnings: check.warnings,
|
|
727
|
+
...(stepModel ? { stepModel } : {}),
|
|
728
|
+
};
|
|
661
729
|
}
|
|
662
730
|
|
|
663
731
|
export function parseStepsJson(
|
|
664
732
|
raw: string,
|
|
665
733
|
source = "The steps file",
|
|
666
|
-
options: { allowSingleBraces?: boolean } = {},
|
|
734
|
+
options: { allowSingleBraces?: boolean; stepModel?: StepModel } = {},
|
|
667
735
|
): JsonRecord[] {
|
|
668
736
|
return readStepsJson(raw, source, options).steps;
|
|
669
737
|
}
|
|
@@ -989,16 +1057,84 @@ const STEP_LABELS: Record<string, string> = {
|
|
|
989
1057
|
linkedin_visit: "Visit profile",
|
|
990
1058
|
linkedin_like_post: "React to a post",
|
|
991
1059
|
linkedin_inmail: "LinkedIn InMail",
|
|
1060
|
+
condition: "If / else",
|
|
1061
|
+
wait_for_connection: "Wait for them to accept",
|
|
992
1062
|
};
|
|
993
1063
|
|
|
994
|
-
|
|
1064
|
+
type LabeledStep = { type: string; waitHours?: unknown; label?: unknown };
|
|
1065
|
+
|
|
1066
|
+
/** A step's name; If / else steps use the server's label of what they check. */
|
|
1067
|
+
function stepLabel(step: LabeledStep): string {
|
|
995
1068
|
if (step.type === "wait") {
|
|
996
1069
|
const hours = typeof step.waitHours === "number" ? step.waitHours : 0;
|
|
997
1070
|
return `Wait ${fmtHours(hours)}`;
|
|
998
1071
|
}
|
|
1072
|
+
if (isBranchStep(step.type) && typeof step.label === "string" && step.label)
|
|
1073
|
+
return step.label;
|
|
999
1074
|
return STEP_LABELS[step.type] ?? step.type;
|
|
1000
1075
|
}
|
|
1001
1076
|
|
|
1077
|
+
type WorkflowStep = LabeledStep & {
|
|
1078
|
+
_id?: unknown;
|
|
1079
|
+
key?: unknown;
|
|
1080
|
+
next?: unknown;
|
|
1081
|
+
yesNext?: unknown;
|
|
1082
|
+
noNext?: unknown;
|
|
1083
|
+
connectionTimeoutHours?: unknown;
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* One line per workflow step, numbered as stored: where each exit leads,
|
|
1088
|
+
* shown only where it is not simply the following step.
|
|
1089
|
+
*/
|
|
1090
|
+
export function workflowStepLines(steps: readonly WorkflowStep[]): string[] {
|
|
1091
|
+
const keyIndex = new Map<string, number>();
|
|
1092
|
+
steps.forEach((step, index) => {
|
|
1093
|
+
if (typeof step.key === "string" && !keyIndex.has(step.key))
|
|
1094
|
+
keyIndex.set(step.key, index);
|
|
1095
|
+
});
|
|
1096
|
+
const target = (to: unknown): string => {
|
|
1097
|
+
if (to === undefined || to === WORKFLOW_END) return "End";
|
|
1098
|
+
const index = typeof to === "string" ? keyIndex.get(to) : undefined;
|
|
1099
|
+
return index === undefined
|
|
1100
|
+
? `"${String(to)}" (not a step)`
|
|
1101
|
+
: `${stepLabel(steps[index])} (step ${index + 1})`;
|
|
1102
|
+
};
|
|
1103
|
+
return steps.map((step, index) => {
|
|
1104
|
+
let name = stepLabel(step);
|
|
1105
|
+
if (
|
|
1106
|
+
step.type === "wait_for_connection" &&
|
|
1107
|
+
typeof step.connectionTimeoutHours === "number"
|
|
1108
|
+
)
|
|
1109
|
+
name += ` (up to ${fmtHours(step.connectionTimeoutHours)})`;
|
|
1110
|
+
if (isBranchStep(step.type))
|
|
1111
|
+
return `${index + 1}. ${name} → ${branchExitLabel(step.type, "yes")}: ${target(step.yesNext)} · ${branchExitLabel(step.type, "no")}: ${target(step.noNext)}`;
|
|
1112
|
+
const next = step.next;
|
|
1113
|
+
const plain =
|
|
1114
|
+
next === undefined ||
|
|
1115
|
+
(next === WORKFLOW_END
|
|
1116
|
+
? index === steps.length - 1
|
|
1117
|
+
: keyIndex.get(String(next)) === index + 1);
|
|
1118
|
+
return plain
|
|
1119
|
+
? `${index + 1}. ${name}`
|
|
1120
|
+
: `${index + 1}. ${name} → ${target(next)}`;
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function describeSteps(
|
|
1125
|
+
steps: readonly WorkflowStep[],
|
|
1126
|
+
workflow: boolean,
|
|
1127
|
+
): string[] {
|
|
1128
|
+
if (!workflow)
|
|
1129
|
+
return [
|
|
1130
|
+
`Steps (${steps.length}): ${steps.map((step) => stepLabel(step)).join(" → ")}`,
|
|
1131
|
+
];
|
|
1132
|
+
return [
|
|
1133
|
+
`Steps (${steps.length}, a workflow with paths):`,
|
|
1134
|
+
...workflowStepLines(steps).map((line) => ` ${line}`),
|
|
1135
|
+
];
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1002
1138
|
const HOLD_TEXT: Record<string, string> = {
|
|
1003
1139
|
checking: "checking whether they are already connected",
|
|
1004
1140
|
spacing: "spacing out sends so they look human",
|
|
@@ -1046,6 +1182,7 @@ const NEXT_VERBS: Record<string, string> = {
|
|
|
1046
1182
|
withdraw: "Withdraw the pending invitation for",
|
|
1047
1183
|
email: "Send an email to",
|
|
1048
1184
|
wait: "Wait for",
|
|
1185
|
+
check: "Run an If / else check for",
|
|
1049
1186
|
};
|
|
1050
1187
|
|
|
1051
1188
|
const TOUCH_LABELS: Record<string, string> = {
|
|
@@ -1056,6 +1193,8 @@ const TOUCH_LABELS: Record<string, string> = {
|
|
|
1056
1193
|
linkedin_like_post: "post reaction sent",
|
|
1057
1194
|
linkedin_inmail: "InMail sent",
|
|
1058
1195
|
wait: "wait finished",
|
|
1196
|
+
condition: "If / else checked",
|
|
1197
|
+
wait_for_connection: "acceptance wait finished",
|
|
1059
1198
|
};
|
|
1060
1199
|
|
|
1061
1200
|
const RUN_STATUS_TEXT: Record<string, string> = {
|
|
@@ -1100,10 +1239,31 @@ function describePerson(
|
|
|
1100
1239
|
const hold = describeHold(run?.hold);
|
|
1101
1240
|
const bits: string[] = [enrollment.status];
|
|
1102
1241
|
if (run?.status && enrollment.status === "active") {
|
|
1103
|
-
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1242
|
+
// An If / else or acceptance check reads the profile; it sends nothing.
|
|
1243
|
+
const runText =
|
|
1244
|
+
isBranchStep(run.type) &&
|
|
1245
|
+
(run.status === "queued" || run.status === "running")
|
|
1246
|
+
? "checking"
|
|
1247
|
+
: (RUN_STATUS_TEXT[run.status] ?? run.status);
|
|
1248
|
+
const stepDoc =
|
|
1249
|
+
typeof run.stepIndex === "number"
|
|
1250
|
+
? detail.steps.find((step) => step.order === run.stepIndex)
|
|
1251
|
+
: undefined;
|
|
1252
|
+
const stepText = run.type
|
|
1253
|
+
? isBranchStep(run.type) && stepDoc?.type === run.type
|
|
1254
|
+
? stepLabel(stepDoc)
|
|
1255
|
+
: (STEP_LABELS[run.type] ?? run.type)
|
|
1256
|
+
: "step";
|
|
1257
|
+
bits.push(
|
|
1258
|
+
`${runText}: ${isBranchStep(run.type) ? stepText : stepText.toLowerCase()}`,
|
|
1259
|
+
);
|
|
1260
|
+
if (
|
|
1261
|
+
run.type === "wait_for_connection" &&
|
|
1262
|
+
run.status === "waiting" &&
|
|
1263
|
+
run.timeoutAt
|
|
1264
|
+
) {
|
|
1265
|
+
bits.push(`stops waiting ${fmtWhenRelative(run.timeoutAt)}`);
|
|
1266
|
+
} else if (run.status === "waiting" && run.waitUntil) {
|
|
1107
1267
|
bits.push(`until ${fmtWhenRelative(run.waitUntil)}`);
|
|
1108
1268
|
} else if (run.status === "retry" && run.retryAt) {
|
|
1109
1269
|
bits.push(`retry ${fmtRelative(run.retryAt)}`);
|
|
@@ -1130,6 +1290,9 @@ function describePerson(
|
|
|
1130
1290
|
status: run.status,
|
|
1131
1291
|
type: run.type,
|
|
1132
1292
|
waitUntil: run.waitUntil,
|
|
1293
|
+
...(run.timeoutAt !== undefined
|
|
1294
|
+
? { timeoutAt: run.timeoutAt }
|
|
1295
|
+
: {}),
|
|
1133
1296
|
retryAt: run.retryAt,
|
|
1134
1297
|
lastError: run.lastError,
|
|
1135
1298
|
}
|
|
@@ -1360,11 +1523,12 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
|
|
|
1360
1523
|
const steps = [...detail.steps].sort(
|
|
1361
1524
|
(a, b) => (a.order ?? 0) - (b.order ?? 0),
|
|
1362
1525
|
);
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
:
|
|
1367
|
-
|
|
1526
|
+
const workflow = sequence.stepModel === "workflow";
|
|
1527
|
+
if (steps.length === 0)
|
|
1528
|
+
lines.push(
|
|
1529
|
+
"Steps: none yet — dench campaign steps set <campaign> --file steps.json",
|
|
1530
|
+
);
|
|
1531
|
+
else lines.push(...describeSteps(steps, workflow));
|
|
1368
1532
|
if (sequence.lastError) lines.push(`Last error: ${sequence.lastError}`);
|
|
1369
1533
|
|
|
1370
1534
|
lines.push("", "Funnel");
|
|
@@ -1396,6 +1560,7 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
|
|
|
1396
1560
|
lines.push(
|
|
1397
1561
|
` Done finished ${a.finished ?? 0} · stopped ${a.stopped ?? 0} · failed ${a.failed ?? 0}`,
|
|
1398
1562
|
);
|
|
1563
|
+
lines.push(...branchStatLines(steps, a.branchStats));
|
|
1399
1564
|
if (a.truncated) {
|
|
1400
1565
|
lines.push(
|
|
1401
1566
|
" (counts are approximate — this campaign has more history than one read can hold)",
|
|
@@ -1410,13 +1575,8 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
|
|
|
1410
1575
|
} else if (sequence.status === "active") {
|
|
1411
1576
|
lines.push(" Now: nothing is being sent right now");
|
|
1412
1577
|
}
|
|
1413
|
-
if (live?.next) {
|
|
1414
|
-
|
|
1415
|
-
const hold = describeHold(live.next.hold);
|
|
1416
|
-
lines.push(
|
|
1417
|
-
` Next: ${NEXT_VERBS[live.next.kind] ?? live.next.kind}${who} at ${fmtWhenRelative(live.next.at)}${hold ? ` — on hold: ${hold}` : ""}`,
|
|
1418
|
-
);
|
|
1419
|
-
} else if (sequence.status === "active") {
|
|
1578
|
+
if (live?.next) lines.push(` Next: ${describeNext(live.next)}`);
|
|
1579
|
+
else if (sequence.status === "active") {
|
|
1420
1580
|
lines.push(
|
|
1421
1581
|
" Next: nothing scheduled — everyone is finished, waiting on a reply, or stopped",
|
|
1422
1582
|
);
|
|
@@ -1476,6 +1636,34 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
|
|
|
1476
1636
|
);
|
|
1477
1637
|
}
|
|
1478
1638
|
|
|
1639
|
+
/** How many people took each exit of the workflow's branch steps. */
|
|
1640
|
+
function branchStatLines(
|
|
1641
|
+
steps: readonly WorkflowStep[],
|
|
1642
|
+
stats: CampaignAnalytics["branchStats"],
|
|
1643
|
+
): string[] {
|
|
1644
|
+
const lines: string[] = [];
|
|
1645
|
+
for (const stat of stats ?? []) {
|
|
1646
|
+
const index = steps.findIndex((step) => String(step._id) === stat.stepId);
|
|
1647
|
+
if (index < 0) continue;
|
|
1648
|
+
const step = steps[index];
|
|
1649
|
+
lines.push(
|
|
1650
|
+
`${lines.length ? " " : " Paths "} step ${index + 1} ${stepLabel(step)} → ${branchExitLabel(step.type, "yes")} ${stat.yes} · ${branchExitLabel(step.type, "no")} ${stat.no}`,
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
return lines;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
export function describeNext(next: NonNullable<CampaignLive["next"]>): string {
|
|
1657
|
+
const hold = describeHold(next.hold);
|
|
1658
|
+
const held = hold ? ` — on hold: ${hold}` : "";
|
|
1659
|
+
if (typeof next.acceptanceDeadline === "number")
|
|
1660
|
+
return `Wait for ${next.personName ?? "them"} to accept the connection request — next check ${fmtWhenRelative(next.at)}, stops waiting ${fmtWhenRelative(next.acceptanceDeadline)}${held}`;
|
|
1661
|
+
const who = next.personName ? ` ${next.personName}` : "";
|
|
1662
|
+
if (next.kind === "check" && next.label)
|
|
1663
|
+
return `Check "${next.label}" for${who || " the next person"} at ${fmtWhenRelative(next.at)}${held}`;
|
|
1664
|
+
return `${NEXT_VERBS[next.kind] ?? next.kind}${who} at ${fmtWhenRelative(next.at)}${held}`;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1479
1667
|
async function runSet(ctx: CampaignCliContext): Promise<void> {
|
|
1480
1668
|
const name = valueFlag(ctx.args, "--name")?.trim();
|
|
1481
1669
|
if (name === "") {
|
|
@@ -1571,7 +1759,12 @@ async function runSteps(ctx: CampaignCliContext): Promise<void> {
|
|
|
1571
1759
|
}
|
|
1572
1760
|
: stepToInput(step),
|
|
1573
1761
|
);
|
|
1574
|
-
|
|
1762
|
+
// A workflow says so, so the file saves back as one even without paths.
|
|
1763
|
+
const printed =
|
|
1764
|
+
detail.sequence.stepModel === "workflow"
|
|
1765
|
+
? { stepModel: "workflow", steps }
|
|
1766
|
+
: steps;
|
|
1767
|
+
console.log(JSON.stringify(printed, null, 2));
|
|
1575
1768
|
return;
|
|
1576
1769
|
}
|
|
1577
1770
|
if (!file) {
|
|
@@ -1588,7 +1781,10 @@ async function runSteps(ctx: CampaignCliContext): Promise<void> {
|
|
|
1588
1781
|
const raw = await readFile(file, "utf8").catch(() => {
|
|
1589
1782
|
throw new CampaignCliError(`Cannot read ${file}`);
|
|
1590
1783
|
});
|
|
1591
|
-
const { steps, warnings } = readStepsJson(raw, file, {
|
|
1784
|
+
const { steps, warnings, stepModel } = readStepsJson(raw, file, {
|
|
1785
|
+
allowSingleBraces,
|
|
1786
|
+
stepModel: row.stepModel === "workflow" ? "workflow" : undefined,
|
|
1787
|
+
});
|
|
1592
1788
|
if (steps.some(hasHtmlAndTextBody)) {
|
|
1593
1789
|
const stored = (await loadDetail(ctx, String(row._id))).steps;
|
|
1594
1790
|
for (const index of staleHtmlSteps(steps, stored)) {
|
|
@@ -1599,13 +1795,36 @@ async function runSteps(ctx: CampaignCliContext): Promise<void> {
|
|
|
1599
1795
|
const result = (await callMutation(ctx, `${OUTREACH}:replaceSteps`, {
|
|
1600
1796
|
sequenceId: row._id,
|
|
1601
1797
|
steps,
|
|
1798
|
+
...(stepModel ? { stepModel } : {}),
|
|
1602
1799
|
})) as { ok: true; stepCount: number };
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1800
|
+
const workflow = stepModel === "workflow";
|
|
1801
|
+
// A workflow is stored in path order, which can differ from the file's.
|
|
1802
|
+
const saved = workflow
|
|
1803
|
+
? sortedSteps(await loadDetail(ctx, String(row._id)))
|
|
1804
|
+
: (steps as WorkflowStep[]);
|
|
1805
|
+
out(
|
|
1806
|
+
ctx,
|
|
1807
|
+
{
|
|
1808
|
+
...result,
|
|
1809
|
+
sequenceId: row._id,
|
|
1810
|
+
...(stepModel ? { stepModel } : {}),
|
|
1811
|
+
steps,
|
|
1812
|
+
warnings,
|
|
1813
|
+
},
|
|
1814
|
+
[
|
|
1815
|
+
workflow
|
|
1816
|
+
? `Saved ${result.stepCount} steps on "${row.name}" as a workflow:`
|
|
1817
|
+
: `Saved ${result.stepCount} steps on "${row.name}": ${saved
|
|
1818
|
+
.map((step) => stepLabel(step))
|
|
1819
|
+
.join(" → ")}`,
|
|
1820
|
+
...(workflow ? workflowStepLines(saved).map((line) => ` ${line}`) : []),
|
|
1821
|
+
...warnings.map((warning) => `Warning: ${warning}`),
|
|
1822
|
+
],
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function sortedSteps(detail: CampaignDetail): CampaignDetail["steps"] {
|
|
1827
|
+
return [...detail.steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
1609
1828
|
}
|
|
1610
1829
|
|
|
1611
1830
|
function stepsInFile(parsed: unknown): unknown[] | null {
|
|
@@ -1646,6 +1865,7 @@ async function runStepsEdit(
|
|
|
1646
1865
|
);
|
|
1647
1866
|
}
|
|
1648
1867
|
const detail = await loadDetail(ctx, String(row._id));
|
|
1868
|
+
const workflow = detail.sequence.stepModel === "workflow";
|
|
1649
1869
|
const status = detail.sequence.status ?? row.status;
|
|
1650
1870
|
const draft = status === "draft";
|
|
1651
1871
|
let plan: ReturnType<typeof planStepEdits>;
|
|
@@ -1655,6 +1875,7 @@ async function runStepsEdit(
|
|
|
1655
1875
|
singleBraceOverride: "--allow-single-braces",
|
|
1656
1876
|
campaign: String(row._id),
|
|
1657
1877
|
draft,
|
|
1878
|
+
...(workflow ? { workflow } : {}),
|
|
1658
1879
|
});
|
|
1659
1880
|
} catch (error) {
|
|
1660
1881
|
if (error instanceof StepEditError)
|
|
@@ -1683,6 +1904,9 @@ async function runStepsEdit(
|
|
|
1683
1904
|
await callMutation(ctx, `${OUTREACH}:replaceSteps`, {
|
|
1684
1905
|
sequenceId: row._id,
|
|
1685
1906
|
steps: plan.steps,
|
|
1907
|
+
...(workflow || plan.steps.some(hasWorkflowShape)
|
|
1908
|
+
? { stepModel: "workflow" }
|
|
1909
|
+
: {}),
|
|
1686
1910
|
});
|
|
1687
1911
|
} else {
|
|
1688
1912
|
await callMutation(ctx, `${OUTREACH}:updateSteps`, {
|
|
@@ -1704,7 +1928,7 @@ async function runStepsEdit(
|
|
|
1704
1928
|
`Updated ${plan.changes.length} of ${plan.steps.length} steps on "${row.name}"${draft ? "" : " for sends from now on; messages already sent are unchanged"}:`,
|
|
1705
1929
|
...plan.changes.map(
|
|
1706
1930
|
(change) =>
|
|
1707
|
-
` Step ${change.step} (${stepLabel(plan.steps[change.step - 1] as
|
|
1931
|
+
` Step ${change.step} (${stepLabel(plan.steps[change.step - 1] as LabeledStep)}): ${change.fields.join(", ")}`,
|
|
1708
1932
|
),
|
|
1709
1933
|
...warningLines,
|
|
1710
1934
|
],
|
|
@@ -2035,6 +2259,13 @@ const STEP_BLOCKER_REASONS: Record<string, RegExp> = {
|
|
|
2035
2259
|
email_sender_disconnected: /sender must be reconnected/i,
|
|
2036
2260
|
linkedin_sender_disconnected: /sender must be reconnected/i,
|
|
2037
2261
|
reply_without_prior_email: /replies to a previous email/i,
|
|
2262
|
+
workflow_path_invalid:
|
|
2263
|
+
/isn't a step in this campaign|goes back to|makes a loop|has nothing on its|can't also have a single next step|only If \/ else and Wait (?:until they|for them to) accept steps can branch|nobody will reach it|share the id|marks where a path stops|only work in a workflow campaign/i,
|
|
2264
|
+
wait_for_connection_without_invite:
|
|
2265
|
+
/without a LinkedIn connection request before it/i,
|
|
2266
|
+
opened_check_without_email: /reaches it without an email/i,
|
|
2267
|
+
open_tracking_unavailable: /opens aren't tracked/i,
|
|
2268
|
+
condition_field_missing: /which your leads don't have/i,
|
|
2038
2269
|
};
|
|
2039
2270
|
|
|
2040
2271
|
function blockerReasons(
|
|
@@ -2042,7 +2273,11 @@ function blockerReasons(
|
|
|
2042
2273
|
reasons: readonly string[],
|
|
2043
2274
|
): string[] {
|
|
2044
2275
|
if (typeof group.stepIndex !== "number") return [];
|
|
2045
|
-
|
|
2276
|
+
// "step 3", or one of a list: "Steps 1 and 3 share the id …".
|
|
2277
|
+
const step = new RegExp(
|
|
2278
|
+
`\\bsteps? (?:\\d+(?:, | and ))*${group.stepIndex + 1}\\b`,
|
|
2279
|
+
"i",
|
|
2280
|
+
);
|
|
2046
2281
|
const forStep = reasons.filter((reason) => step.test(reason));
|
|
2047
2282
|
const own = STEP_BLOCKER_REASONS[group.code];
|
|
2048
2283
|
if (own) return forStep.filter((reason) => own.test(reason));
|
|
@@ -2060,6 +2295,14 @@ const STEPS_FIX = (id: string) => [
|
|
|
2060
2295
|
];
|
|
2061
2296
|
|
|
2062
2297
|
const BLOCKER_FIXES: Record<string, (id: string) => string[]> = {
|
|
2298
|
+
workflow_path_invalid: STEPS_FIX,
|
|
2299
|
+
wait_for_connection_without_invite: STEPS_FIX,
|
|
2300
|
+
opened_check_without_email: STEPS_FIX,
|
|
2301
|
+
open_tracking_unavailable: STEPS_FIX,
|
|
2302
|
+
condition_field_missing: (id) => [
|
|
2303
|
+
"dench crm fields list lead",
|
|
2304
|
+
...STEPS_FIX(id),
|
|
2305
|
+
],
|
|
2063
2306
|
no_steps: STEPS_FIX,
|
|
2064
2307
|
too_many_steps: STEPS_FIX,
|
|
2065
2308
|
step_invalid: STEPS_FIX,
|
|
@@ -2522,18 +2765,24 @@ async function runRemove(ctx: CampaignCliContext): Promise<void> {
|
|
|
2522
2765
|
const PERSON_SET_USAGE =
|
|
2523
2766
|
'dench campaign person set <campaign> --person <enrollmentId|name> [--email ada@new.com] [--linkedin-url https://linkedin.com/in/ada] [--field "Job Title=CTO" ...]';
|
|
2524
2767
|
|
|
2768
|
+
/** A field reference may be pasted directly from either merge-token syntax. */
|
|
2769
|
+
function unwrappedFieldReference(reference: string): string {
|
|
2770
|
+
const value = reference.trim();
|
|
2771
|
+
if (
|
|
2772
|
+
(value.startsWith("{{") && value.endsWith("}}")) ||
|
|
2773
|
+
(value.startsWith("[[") && value.endsWith("]]"))
|
|
2774
|
+
) {
|
|
2775
|
+
return value.slice(2, -2).trim();
|
|
2776
|
+
}
|
|
2777
|
+
return value;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2525
2780
|
export function parseFieldFlags(
|
|
2526
2781
|
values: readonly string[],
|
|
2527
2782
|
): Array<{ key: string; value: string }> {
|
|
2528
2783
|
return values.map((raw) => {
|
|
2529
2784
|
const at = raw.indexOf("=");
|
|
2530
|
-
const key =
|
|
2531
|
-
at === -1
|
|
2532
|
-
? ""
|
|
2533
|
-
: raw
|
|
2534
|
-
.slice(0, at)
|
|
2535
|
-
.trim()
|
|
2536
|
-
.replace(/^\{\{\s*|\s*\}\}$/g, "");
|
|
2785
|
+
const key = at === -1 ? "" : unwrappedFieldReference(raw.slice(0, at));
|
|
2537
2786
|
const value = at === -1 ? "" : raw.slice(at + 1).trim();
|
|
2538
2787
|
if (!key || !value) {
|
|
2539
2788
|
throw new CampaignCliError(
|
|
@@ -2676,8 +2925,12 @@ async function runPersonShow(ctx: CampaignCliContext): Promise<void> {
|
|
|
2676
2925
|
const doc = stepDocs.get(step.stepId);
|
|
2677
2926
|
const label = stepLabel(doc ?? { type: step.type });
|
|
2678
2927
|
const when = step.at === null ? "" : ` · ${fmtRelative(step.at)}`;
|
|
2928
|
+
const until =
|
|
2929
|
+
step.acceptanceDeadline !== undefined
|
|
2930
|
+
? ` · stops waiting ${fmtWhenRelative(step.acceptanceDeadline)}`
|
|
2931
|
+
: "";
|
|
2679
2932
|
lines.push(
|
|
2680
|
-
` Step ${step.step} · ${label} · ${[step.outcome, ...step.detail].join(" · ")}${when}`,
|
|
2933
|
+
` Step ${step.step} · ${label} · ${[step.outcome, ...step.detail].join(" · ")}${when}${until}`,
|
|
2681
2934
|
);
|
|
2682
2935
|
if (step.subject) lines.push(` Subject: ${step.subject}`);
|
|
2683
2936
|
if (step.text) {
|
|
@@ -2828,7 +3081,7 @@ function resolveLineKey(
|
|
|
2828
3081
|
): string {
|
|
2829
3082
|
const variables = template?.variables ?? [];
|
|
2830
3083
|
if (!variables.length) throw noLines(row);
|
|
2831
|
-
const wanted = normalizedLineKey(reference
|
|
3084
|
+
const wanted = normalizedLineKey(unwrappedFieldReference(reference));
|
|
2832
3085
|
const match = variables.find(
|
|
2833
3086
|
(variable) =>
|
|
2834
3087
|
normalizedLineKey(variable.key) === wanted ||
|
|
@@ -3322,7 +3575,7 @@ async function runPreview(ctx: CampaignCliContext): Promise<void> {
|
|
|
3322
3575
|
);
|
|
3323
3576
|
const sendable = steps
|
|
3324
3577
|
.map((step, index) => ({ step, number: index + 1 }))
|
|
3325
|
-
.filter(({ step }) => step.type !== "wait");
|
|
3578
|
+
.filter(({ step }) => step.type !== "wait" && !isBranchStep(step.type));
|
|
3326
3579
|
if (!sendable.length) {
|
|
3327
3580
|
throw new CampaignCliError(
|
|
3328
3581
|
`"${row.name}" has no email or LinkedIn steps to preview yet. Add them: dench campaign steps set ${row._id} --file steps.json`,
|
|
@@ -3338,8 +3591,9 @@ async function runPreview(ctx: CampaignCliContext): Promise<void> {
|
|
|
3338
3591
|
}
|
|
3339
3592
|
chosen = sendable.filter((entry) => entry.number === number);
|
|
3340
3593
|
if (!chosen.length) {
|
|
3594
|
+
const kind = steps[number - 1]?.type;
|
|
3341
3595
|
throw new CampaignCliError(
|
|
3342
|
-
`Step ${number} is a wait, so it sends nothing. Steps with a message: ${sendable.map((entry) => entry.number).join(", ")}.`,
|
|
3596
|
+
`Step ${number} is ${kind === "condition" ? "an If / else" : kind === "wait_for_connection" ? "a Wait for them to accept" : "a wait"}, so it sends nothing. Steps with a message: ${sendable.map((entry) => entry.number).join(", ")}.`,
|
|
3343
3597
|
);
|
|
3344
3598
|
}
|
|
3345
3599
|
}
|
|
@@ -3364,8 +3618,13 @@ async function runPreview(ctx: CampaignCliContext): Promise<void> {
|
|
|
3364
3618
|
})) as MessagePreview,
|
|
3365
3619
|
})),
|
|
3366
3620
|
);
|
|
3621
|
+
const firstPerson = personRef
|
|
3622
|
+
? ""
|
|
3623
|
+
: " (the first person; pick someone with --person)";
|
|
3367
3624
|
const lines = [
|
|
3368
|
-
|
|
3625
|
+
detail.sequence.stepModel === "workflow"
|
|
3626
|
+
? `The messages on every path of "${row.name}", for ${name}${firstPerson}. They get only the ones on the path they take:`
|
|
3627
|
+
: `What ${name} gets from "${row.name}"${firstPerson}:`,
|
|
3369
3628
|
];
|
|
3370
3629
|
for (const { step, number, preview } of rendered)
|
|
3371
3630
|
lines.push("", ...previewLines(number, step, preview));
|
|
@@ -4002,12 +4261,16 @@ Create and configure:
|
|
|
4002
4261
|
dench campaign duplicate <campaign> [--name "Founder outreach 2"] # new draft: same steps, schedule, senders and lines, no people
|
|
4003
4262
|
Over ${MAX_CAMPAIGN_PEOPLE} people: duplicate, then add the next page of the search (--page-token) to the copy.
|
|
4004
4263
|
dench campaign steps get <campaign> [--with-ids] # prints steps as JSON you can edit; --with-ids adds stepId and updatedAt
|
|
4264
|
+
A workflow prints {"stepModel":"workflow","steps":[...]} with each step's key and paths; steps set takes it back as is.
|
|
4005
4265
|
dench campaign steps set <campaign> --file steps.json [--allow-single-braces] # replaces every step; drafts only
|
|
4006
4266
|
dench campaign steps edit <campaign> --file steps.json [--allow-single-braces] # changes text and waits in place, also after launch
|
|
4007
4267
|
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
|
|
4009
|
-
|
|
4010
|
-
|
|
4268
|
+
subject, body, note, onlyIf or emailReplyToPrevious clears it (senders, LinkedIn wait settings, workflow
|
|
4269
|
+
paths and If / else settings are kept). Steps match by order (a workflow's by key, in any order), or by
|
|
4270
|
+
stepId from steps get --with-ids to edit only some; a step saved since you read it is refused, not
|
|
4271
|
+
overwritten. Messages already sent, or already being prepared, keep their text. After launch a workflow's
|
|
4272
|
+
paths (key, next, yesNext, noNext) and what an If / else checks (check, conditionField, conditionOperator)
|
|
4273
|
+
stay fixed; conditionValue can change.
|
|
4011
4274
|
|
|
4012
4275
|
Fill it:
|
|
4013
4276
|
dench campaign draft [--name "New campaign"] [--request-id <8-64 char stable id>] [--timezone <tz>] \\
|
|
@@ -4110,11 +4373,29 @@ Send window spec (--window, repeatable, all in --timezone):
|
|
|
4110
4373
|
of that day in the campaign's timezone; add a time and offset to be exact.
|
|
4111
4374
|
|
|
4112
4375
|
steps.json: a JSON array of steps, in order. Types: email, wait, linkedin_visit,
|
|
4113
|
-
linkedin_connection, linkedin_message, linkedin_like_post, linkedin_inmail
|
|
4376
|
+
linkedin_connection, linkedin_message, linkedin_like_post, linkedin_inmail,
|
|
4377
|
+
condition (If / else) and wait_for_connection (Wait for them to accept).
|
|
4114
4378
|
See references/CAMPAIGNS.md for every field.
|
|
4379
|
+
Workflows (branching paths): give steps a "key" and say where each goes. A
|
|
4380
|
+
condition or wait_for_connection step needs "yesNext" and "noNext" (Yes / No, or
|
|
4381
|
+
Accepted / Not accepted); any other step may have "next" (default: the step after
|
|
4382
|
+
it). Each points at another step's "key", or "end" to stop there. condition needs
|
|
4383
|
+
"check": linkedin_connected, has_email, opened_last_email or field_value (with
|
|
4384
|
+
"conditionField", "conditionOperator" is|is_not|contains|is_empty|is_not_empty and,
|
|
4385
|
+
except for the empty checks, "conditionValue"). wait_for_connection waits
|
|
4386
|
+
connectionTimeoutHours = 24-2160 (1-90 days, default 720) and needs a
|
|
4387
|
+
linkedin_connection before it on every path. Steps with keys, paths or these
|
|
4388
|
+
types save the campaign as a workflow ("stepModel": "workflow"); a workflow
|
|
4389
|
+
stays one, and in a workflow a LinkedIn message does not wait for the accept on
|
|
4390
|
+
its own. steps set checks keys and paths before saving; the rest (every step
|
|
4391
|
+
reachable, paths only moving forward) is checked when it saves.
|
|
4115
4392
|
Post reactions: likeReaction = like|celebrate|support|love|insightful|funny
|
|
4116
|
-
(default like); likeMaxPostAgeDays = 1-365 (default 30);
|
|
4117
|
-
|
|
4393
|
+
(default like); likeMaxPostAgeDays = 1-365 (default 30); likeCount = 1-3 recent
|
|
4394
|
+
posts (default 1). likeSkipPreviousWait = true checks for eligible posts before
|
|
4395
|
+
the immediately preceding wait starts; no eligible posts skips that wait and
|
|
4396
|
+
the like while keeping following waits (omitted defaults false).
|
|
4397
|
+
likeSkipNextWait = true by default, skipping consecutive following waits when
|
|
4398
|
+
the reaction itself is skipped. Set false to keep those waits.
|
|
4118
4399
|
InMail: linkedinMessageBody required (1-1900 chars), inmailSubject optional
|
|
4119
4400
|
(up to 200 chars). Connected people skip InMail automatically; do not set onlyIf.
|
|
4120
4401
|
Connection requests: withdrawAfterHours = 168-2160 (7-90 days); omitted or null
|