@dench.com/cli 3.8.0-staging.389 → 3.8.2-staging.391
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/ads.ts +6 -2
- package/app-sdk.d.ts +132 -27
- package/apps.ts +7 -3
- package/browser.ts +8 -3
- package/campaign.ts +336 -55
- package/chat.ts +8 -6
- package/cron.ts +7 -3
- package/dench.ts +52 -94
- package/image.ts +6 -2
- package/leads.ts +6 -2
- 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-args.ts +26 -9
- package/lib/command-help.ts +192 -39
- package/lib/command-registry.ts +3 -3
- package/lib/file-command-contract.ts +106 -0
- package/lib/references.generated.json +14 -14
- package/meetings.ts +6 -2
- package/members.ts +6 -2
- package/package.json +1 -1
- package/search.ts +6 -2
- package/tools.ts +9 -5
- package/web.ts +20 -6
package/lib/campaign-steps.ts
CHANGED
|
@@ -6,7 +6,10 @@ export const MAX_CONNECTION_NOTE_CHARS = 300;
|
|
|
6
6
|
export const MAX_INMAIL_SUBJECT_CHARS = 200;
|
|
7
7
|
export const MAX_INMAIL_BODY_CHARS = 1900;
|
|
8
8
|
export const MAX_LIKE_POST_AGE_DAYS = 365;
|
|
9
|
+
export const MAX_LIKE_POST_COUNT = 3;
|
|
9
10
|
export const MIN_WITHDRAW_AFTER_HOURS = 7 * 24;
|
|
11
|
+
export const MIN_ACCEPTANCE_WAIT_HOURS = 24;
|
|
12
|
+
export const MAX_ACCEPTANCE_WAIT_HOURS = 90 * 24;
|
|
10
13
|
export const LINKEDIN_REACTIONS = [
|
|
11
14
|
"like",
|
|
12
15
|
"celebrate",
|
|
@@ -24,10 +27,36 @@ export const STEP_TYPES = [
|
|
|
24
27
|
"linkedin_visit",
|
|
25
28
|
"linkedin_like_post",
|
|
26
29
|
"linkedin_inmail",
|
|
30
|
+
"condition",
|
|
31
|
+
"wait_for_connection",
|
|
27
32
|
] as const;
|
|
28
33
|
|
|
29
34
|
export type StepType = (typeof STEP_TYPES)[number];
|
|
30
35
|
|
|
36
|
+
/** Workflow steps that send nothing and continue on a Yes or a No path. */
|
|
37
|
+
export const BRANCH_STEP_TYPES = ["condition", "wait_for_connection"] as const;
|
|
38
|
+
|
|
39
|
+
export const WORKFLOW_END = "end";
|
|
40
|
+
|
|
41
|
+
export const CONDITION_CHECKS = [
|
|
42
|
+
"linkedin_connected",
|
|
43
|
+
"has_email",
|
|
44
|
+
"opened_last_email",
|
|
45
|
+
"field_value",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
export const CONDITION_OPERATORS = [
|
|
49
|
+
"is",
|
|
50
|
+
"is_not",
|
|
51
|
+
"is_empty",
|
|
52
|
+
"is_not_empty",
|
|
53
|
+
"contains",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export function isBranchStep(type: unknown): boolean {
|
|
57
|
+
return (BRANCH_STEP_TYPES as readonly unknown[]).includes(type);
|
|
58
|
+
}
|
|
59
|
+
|
|
31
60
|
export const STEP_KEYS = [
|
|
32
61
|
"type",
|
|
33
62
|
"waitHours",
|
|
@@ -43,11 +72,41 @@ export const STEP_KEYS = [
|
|
|
43
72
|
"connectionTimeoutHours",
|
|
44
73
|
"likeReaction",
|
|
45
74
|
"likeMaxPostAgeDays",
|
|
75
|
+
"likeSkipPreviousWait",
|
|
46
76
|
"likeSkipNextWait",
|
|
77
|
+
"likeCount",
|
|
47
78
|
"inmailSubject",
|
|
48
79
|
"withdrawAfterHours",
|
|
80
|
+
"key",
|
|
81
|
+
"next",
|
|
82
|
+
"yesNext",
|
|
83
|
+
"noNext",
|
|
84
|
+
"check",
|
|
85
|
+
"conditionField",
|
|
86
|
+
"conditionOperator",
|
|
87
|
+
"conditionValue",
|
|
49
88
|
] as const;
|
|
50
89
|
|
|
90
|
+
/** A workflow step's id and where each of its exits leads. */
|
|
91
|
+
export const PATH_KEYS = ["key", "next", "yesNext", "noNext"] as const;
|
|
92
|
+
|
|
93
|
+
/** Fixed once a campaign launches: its paths and what each If / else checks. */
|
|
94
|
+
export const FROZEN_AFTER_LAUNCH = [
|
|
95
|
+
...PATH_KEYS,
|
|
96
|
+
"check",
|
|
97
|
+
"conditionField",
|
|
98
|
+
"conditionOperator",
|
|
99
|
+
] as const;
|
|
100
|
+
|
|
101
|
+
/** Steps with an id, a path or an If / else / acceptance wait make a workflow. */
|
|
102
|
+
export function hasWorkflowShape(step: unknown): boolean {
|
|
103
|
+
if (!step || typeof step !== "object") return false;
|
|
104
|
+
const row = step as Record<string, unknown>;
|
|
105
|
+
return (
|
|
106
|
+
PATH_KEYS.some((key) => row[key] !== undefined) || isBranchStep(row.type)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
51
110
|
const FIELDS_BY_TYPE: Record<StepType, readonly string[]> = {
|
|
52
111
|
wait: ["waitHours"],
|
|
53
112
|
email: [
|
|
@@ -75,13 +134,23 @@ const FIELDS_BY_TYPE: Record<StepType, readonly string[]> = {
|
|
|
75
134
|
"onlyIf",
|
|
76
135
|
"likeReaction",
|
|
77
136
|
"likeMaxPostAgeDays",
|
|
137
|
+
"likeSkipPreviousWait",
|
|
78
138
|
"likeSkipNextWait",
|
|
139
|
+
"likeCount",
|
|
79
140
|
],
|
|
80
141
|
linkedin_inmail: [
|
|
81
142
|
"linkedinAccountId",
|
|
82
143
|
"linkedinMessageBody",
|
|
83
144
|
"inmailSubject",
|
|
84
145
|
],
|
|
146
|
+
condition: [
|
|
147
|
+
"linkedinAccountId",
|
|
148
|
+
"check",
|
|
149
|
+
"conditionField",
|
|
150
|
+
"conditionOperator",
|
|
151
|
+
"conditionValue",
|
|
152
|
+
],
|
|
153
|
+
wait_for_connection: ["linkedinAccountId", "connectionTimeoutHours"],
|
|
85
154
|
};
|
|
86
155
|
|
|
87
156
|
const TEXT_FIELDS = [
|
|
@@ -92,6 +161,8 @@ const TEXT_FIELDS = [
|
|
|
92
161
|
"linkedinAccountId",
|
|
93
162
|
"linkedinMessageBody",
|
|
94
163
|
"inmailSubject",
|
|
164
|
+
"conditionField",
|
|
165
|
+
"conditionValue",
|
|
95
166
|
] as const;
|
|
96
167
|
|
|
97
168
|
const TEMPLATE_FIELDS = [
|
|
@@ -108,6 +179,11 @@ const MESSAGE_MODES = ["skip_if_not_connected", "wait_until_connected"];
|
|
|
108
179
|
export type StepCheckOptions = {
|
|
109
180
|
allowSingleBraces?: boolean;
|
|
110
181
|
singleBraceOverride?: string;
|
|
182
|
+
/**
|
|
183
|
+
* The steps form a workflow (paths, If / else, Wait for them to accept).
|
|
184
|
+
* Unset, steps with a key, a path or a logic type make one.
|
|
185
|
+
*/
|
|
186
|
+
workflow?: boolean;
|
|
111
187
|
};
|
|
112
188
|
|
|
113
189
|
export type StepIssue = { step: number; field?: string; message: string };
|
|
@@ -124,7 +200,7 @@ function holdsNothing(field: string, value: unknown): boolean {
|
|
|
124
200
|
}
|
|
125
201
|
|
|
126
202
|
export function singleBraceTokens(text: string): string[] {
|
|
127
|
-
const outside = text.replace(/\{\{[^{}]*\}\}/g, " ");
|
|
203
|
+
const outside = text.replace(/\{\{[^{}]*\}\}|\[\[[^[\]]*\]\]/g, " ");
|
|
128
204
|
return [
|
|
129
205
|
...new Set(
|
|
130
206
|
[...outside.matchAll(/\{\s*([A-Za-z_][A-Za-z0-9_ .-]{0,60}?)\s*\}/g)].map(
|
|
@@ -177,7 +253,7 @@ export function staleHtmlWarning(index: number): string {
|
|
|
177
253
|
}
|
|
178
254
|
|
|
179
255
|
function hasEmptyMergeToken(text: string): boolean {
|
|
180
|
-
return /\{\{\s*\}\}/.test(text);
|
|
256
|
+
return /\{\{\s*\}\}|\[\[\s*\]\]/.test(text);
|
|
181
257
|
}
|
|
182
258
|
|
|
183
259
|
function listSteps(numbers: number[]): string {
|
|
@@ -240,6 +316,25 @@ function checkStepFieldShapes(
|
|
|
240
316
|
`${label}: "likeMaxPostAgeDays" must be a whole number from 1 to ${MAX_LIKE_POST_AGE_DAYS}.`,
|
|
241
317
|
"likeMaxPostAgeDays",
|
|
242
318
|
);
|
|
319
|
+
if (
|
|
320
|
+
row.likeCount !== undefined &&
|
|
321
|
+
(typeof row.likeCount !== "number" ||
|
|
322
|
+
!Number.isInteger(row.likeCount) ||
|
|
323
|
+
row.likeCount < 1 ||
|
|
324
|
+
row.likeCount > MAX_LIKE_POST_COUNT)
|
|
325
|
+
)
|
|
326
|
+
fail(
|
|
327
|
+
`${label}: "likeCount" must be a whole number from 1 to ${MAX_LIKE_POST_COUNT}.`,
|
|
328
|
+
"likeCount",
|
|
329
|
+
);
|
|
330
|
+
if (
|
|
331
|
+
row.likeSkipPreviousWait !== undefined &&
|
|
332
|
+
typeof row.likeSkipPreviousWait !== "boolean"
|
|
333
|
+
)
|
|
334
|
+
fail(
|
|
335
|
+
`${label}: "likeSkipPreviousWait" must be true or false.`,
|
|
336
|
+
"likeSkipPreviousWait",
|
|
337
|
+
);
|
|
243
338
|
if (
|
|
244
339
|
row.likeSkipNextWait !== undefined &&
|
|
245
340
|
typeof row.likeSkipNextWait !== "boolean"
|
|
@@ -261,14 +356,22 @@ function checkStepFieldShapes(
|
|
|
261
356
|
"withdrawAfterHours",
|
|
262
357
|
);
|
|
263
358
|
|
|
359
|
+
checkLogicFieldShapes(row, label, fail);
|
|
360
|
+
|
|
264
361
|
const kind = stepType === "email" ? "an email" : `a ${stepType}`;
|
|
265
362
|
const allowed = FIELDS_BY_TYPE[stepType];
|
|
266
363
|
const dropped = (STEP_KEYS as readonly string[]).filter(
|
|
267
364
|
(key) =>
|
|
268
|
-
key !== "type" &&
|
|
365
|
+
key !== "type" &&
|
|
366
|
+
!(PATH_KEYS as readonly string[]).includes(key) &&
|
|
367
|
+
!allowed.includes(key) &&
|
|
368
|
+
!holdsNothing(key, row[key]),
|
|
269
369
|
);
|
|
370
|
+
if (stepType === "condition") dropped.push(...conditionDrops(row));
|
|
270
371
|
for (const field of dropped) {
|
|
271
|
-
if (
|
|
372
|
+
if (stepType === "condition" && allowed.includes(field))
|
|
373
|
+
fail(conditionDropMessage(row, field, label), field);
|
|
374
|
+
else if (field === "onlyIf")
|
|
272
375
|
fail(
|
|
273
376
|
`${label}: "onlyIf" only works on email or linkedin_like_post steps; ${kind} step would drop it. InMail already skips connected people.`,
|
|
274
377
|
field,
|
|
@@ -286,6 +389,86 @@ function checkStepFieldShapes(
|
|
|
286
389
|
}
|
|
287
390
|
}
|
|
288
391
|
|
|
392
|
+
const CHECK_NAMES: Record<string, string> = {
|
|
393
|
+
linkedin_connected: "whether they are connected on LinkedIn",
|
|
394
|
+
has_email: "whether they have an email address",
|
|
395
|
+
opened_last_email: "whether they opened the last email",
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
function checkLogicFieldShapes(
|
|
399
|
+
row: Record<string, unknown>,
|
|
400
|
+
label: string,
|
|
401
|
+
fail: Fail,
|
|
402
|
+
): void {
|
|
403
|
+
for (const field of PATH_KEYS) {
|
|
404
|
+
const value = row[field];
|
|
405
|
+
if (value !== undefined && (typeof value !== "string" || !value.trim()))
|
|
406
|
+
fail(
|
|
407
|
+
field === "key"
|
|
408
|
+
? `${label}: "key" must be a short text id, like "invite".`
|
|
409
|
+
: `${label}: "${field}" must be another step's "key", or "${WORKFLOW_END}" to stop there.`,
|
|
410
|
+
field,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (
|
|
414
|
+
row.check !== undefined &&
|
|
415
|
+
!(CONDITION_CHECKS as readonly unknown[]).includes(row.check)
|
|
416
|
+
)
|
|
417
|
+
fail(
|
|
418
|
+
`${label}: "check" must be one of: ${CONDITION_CHECKS.join(", ")}.`,
|
|
419
|
+
"check",
|
|
420
|
+
);
|
|
421
|
+
if (
|
|
422
|
+
row.conditionOperator !== undefined &&
|
|
423
|
+
!(CONDITION_OPERATORS as readonly unknown[]).includes(row.conditionOperator)
|
|
424
|
+
)
|
|
425
|
+
fail(
|
|
426
|
+
`${label}: "conditionOperator" must be one of: ${CONDITION_OPERATORS.join(", ")}.`,
|
|
427
|
+
"conditionOperator",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function operatorTakesValue(operator: unknown): boolean {
|
|
432
|
+
return operator !== "is_empty" && operator !== "is_not_empty";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Condition fields the If / else's own check would drop. */
|
|
436
|
+
function conditionDrops(row: Record<string, unknown>): string[] {
|
|
437
|
+
const drops: string[] = [];
|
|
438
|
+
// An edit that leaves "check" out keeps the stored one, unknown here.
|
|
439
|
+
if (row.check === undefined) return drops;
|
|
440
|
+
if (
|
|
441
|
+
row.check !== "linkedin_connected" &&
|
|
442
|
+
!holdsNothing("linkedinAccountId", row.linkedinAccountId)
|
|
443
|
+
)
|
|
444
|
+
drops.push("linkedinAccountId");
|
|
445
|
+
for (const field of ["conditionField", "conditionOperator"] as const)
|
|
446
|
+
if (row.check !== "field_value" && !holdsNothing(field, row[field]))
|
|
447
|
+
drops.push(field);
|
|
448
|
+
if (
|
|
449
|
+
!holdsNothing("conditionValue", row.conditionValue) &&
|
|
450
|
+
(row.check !== "field_value" || !operatorTakesValue(row.conditionOperator))
|
|
451
|
+
)
|
|
452
|
+
drops.push("conditionValue");
|
|
453
|
+
return drops;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function conditionDropMessage(
|
|
457
|
+
row: Record<string, unknown>,
|
|
458
|
+
field: string,
|
|
459
|
+
label: string,
|
|
460
|
+
): string {
|
|
461
|
+
if (field === "linkedinAccountId")
|
|
462
|
+
return `${label}: "linkedinAccountId" is only kept on an If / else that checks "linkedin_connected", so it would be dropped.`;
|
|
463
|
+
if (field === "conditionValue" && row.check === "field_value")
|
|
464
|
+
return `${label}: "conditionValue" would be dropped, because "${String(row.conditionOperator)}" compares against nothing.`;
|
|
465
|
+
const checked =
|
|
466
|
+
typeof row.check === "string" && CHECK_NAMES[row.check]
|
|
467
|
+
? ` This one checks ${CHECK_NAMES[row.check]}.`
|
|
468
|
+
: "";
|
|
469
|
+
return `${label}: "${field}" is only kept when "check" is "field_value", so it would be dropped.${checked}`;
|
|
470
|
+
}
|
|
471
|
+
|
|
289
472
|
function checkTemplateTokens(
|
|
290
473
|
row: Record<string, unknown>,
|
|
291
474
|
label: string,
|
|
@@ -297,7 +480,10 @@ function checkTemplateTokens(
|
|
|
297
480
|
const text = row[field];
|
|
298
481
|
if (typeof text !== "string") continue;
|
|
299
482
|
if (hasEmptyMergeToken(text))
|
|
300
|
-
fail(
|
|
483
|
+
fail(
|
|
484
|
+
`${label}: "${field}" has an empty merge token ({{ }} or [[ ]]).`,
|
|
485
|
+
field,
|
|
486
|
+
);
|
|
301
487
|
if (options.allowSingleBraces) continue;
|
|
302
488
|
const tokens = singleBraceTokens(text);
|
|
303
489
|
if (tokens.length)
|
|
@@ -373,6 +559,146 @@ function checkTimeoutRange(timeout: unknown, label: string, fail: Fail): void {
|
|
|
373
559
|
);
|
|
374
560
|
}
|
|
375
561
|
|
|
562
|
+
function checkCondition(
|
|
563
|
+
row: Record<string, unknown>,
|
|
564
|
+
label: string,
|
|
565
|
+
fail: Fail,
|
|
566
|
+
): void {
|
|
567
|
+
if (row.check === undefined) {
|
|
568
|
+
fail(
|
|
569
|
+
`${label} is an If / else (condition) step and needs "check": ${CONDITION_CHECKS.join(", ")}.`,
|
|
570
|
+
"check",
|
|
571
|
+
);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (row.check !== "field_value") return;
|
|
575
|
+
if (!trimmedText(row.conditionField))
|
|
576
|
+
fail(
|
|
577
|
+
`${label} checks a lead field and needs "conditionField", the field's name (like "Job Title").`,
|
|
578
|
+
"conditionField",
|
|
579
|
+
);
|
|
580
|
+
if (row.conditionOperator === undefined)
|
|
581
|
+
fail(
|
|
582
|
+
`${label} checks a lead field and needs "conditionOperator": ${CONDITION_OPERATORS.join(", ")}.`,
|
|
583
|
+
"conditionOperator",
|
|
584
|
+
);
|
|
585
|
+
else if (
|
|
586
|
+
operatorTakesValue(row.conditionOperator) &&
|
|
587
|
+
!trimmedText(row.conditionValue)
|
|
588
|
+
)
|
|
589
|
+
fail(
|
|
590
|
+
`${label}: "conditionOperator": "${String(row.conditionOperator)}" needs "conditionValue", the text to compare the field with.`,
|
|
591
|
+
"conditionValue",
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function checkAcceptanceWait(
|
|
596
|
+
timeout: unknown,
|
|
597
|
+
label: string,
|
|
598
|
+
fail: Fail,
|
|
599
|
+
): void {
|
|
600
|
+
if (timeout === undefined) return;
|
|
601
|
+
if (
|
|
602
|
+
typeof timeout !== "number" ||
|
|
603
|
+
!Number.isFinite(timeout) ||
|
|
604
|
+
timeout < MIN_ACCEPTANCE_WAIT_HOURS ||
|
|
605
|
+
timeout > MAX_ACCEPTANCE_WAIT_HOURS
|
|
606
|
+
)
|
|
607
|
+
fail(
|
|
608
|
+
`${label}: "connectionTimeoutHours" must be ${MIN_ACCEPTANCE_WAIT_HOURS}-${MAX_ACCEPTANCE_WAIT_HOURS} hours (1-90 days) of waiting for them to accept.`,
|
|
609
|
+
"connectionTimeoutHours",
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const BRANCH_NAMES: Record<string, { name: string; yes: string; no: string }> =
|
|
614
|
+
{
|
|
615
|
+
condition: { name: "If / else", yes: "Yes", no: "No" },
|
|
616
|
+
wait_for_connection: {
|
|
617
|
+
name: "Wait for them to accept",
|
|
618
|
+
yes: "Accepted",
|
|
619
|
+
no: "Not accepted",
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The paths a workflow's steps declare, step by step: unique keys, targets
|
|
625
|
+
* that exist, and the right exits for each kind of step. Whether every step
|
|
626
|
+
* is reachable and every path moves forward is checked when the steps save.
|
|
627
|
+
*/
|
|
628
|
+
function checkWorkflowPaths(
|
|
629
|
+
steps: readonly unknown[],
|
|
630
|
+
errors: StepIssue[],
|
|
631
|
+
): void {
|
|
632
|
+
const rows = steps.map((value) =>
|
|
633
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
634
|
+
? (value as Record<string, unknown>)
|
|
635
|
+
: null,
|
|
636
|
+
);
|
|
637
|
+
const firstWithKey = new Map<string, number>();
|
|
638
|
+
rows.forEach((row, index) => {
|
|
639
|
+
const key = row?.key;
|
|
640
|
+
if (typeof key === "string" && key && !firstWithKey.has(key))
|
|
641
|
+
firstWithKey.set(key, index);
|
|
642
|
+
});
|
|
643
|
+
rows.forEach((row, index) => {
|
|
644
|
+
if (!row) return;
|
|
645
|
+
const step = index + 1;
|
|
646
|
+
const fail: Fail = (message, field) =>
|
|
647
|
+
errors.push({ step, ...(field ? { field } : {}), message });
|
|
648
|
+
const branch = BRANCH_NAMES[String(row.type)];
|
|
649
|
+
const label = branch ? `Step ${step} (${branch.name})` : `Step ${step}`;
|
|
650
|
+
const key = typeof row.key === "string" ? row.key : undefined;
|
|
651
|
+
if (key === WORKFLOW_END)
|
|
652
|
+
fail(
|
|
653
|
+
`${label}: "key" can't be "${WORKFLOW_END}", because "${WORKFLOW_END}" marks where a path stops.`,
|
|
654
|
+
"key",
|
|
655
|
+
);
|
|
656
|
+
else if (key && firstWithKey.get(key) !== index)
|
|
657
|
+
fail(
|
|
658
|
+
`Steps ${(firstWithKey.get(key) ?? 0) + 1} and ${step} share the key "${key}". Give each step its own key.`,
|
|
659
|
+
"key",
|
|
660
|
+
);
|
|
661
|
+
if (branch) {
|
|
662
|
+
for (const [field, exit] of [
|
|
663
|
+
["yesNext", branch.yes],
|
|
664
|
+
["noNext", branch.no],
|
|
665
|
+
] as const)
|
|
666
|
+
if (row[field] === undefined)
|
|
667
|
+
fail(
|
|
668
|
+
`${label} needs "${field}", where the "${exit}" path goes: another step's "key", or "${WORKFLOW_END}".`,
|
|
669
|
+
field,
|
|
670
|
+
);
|
|
671
|
+
if (row.next !== undefined)
|
|
672
|
+
fail(
|
|
673
|
+
`${label} continues on "yesNext" and "noNext", so it can't also have "next".`,
|
|
674
|
+
"next",
|
|
675
|
+
);
|
|
676
|
+
} else {
|
|
677
|
+
for (const field of ["yesNext", "noNext"] as const)
|
|
678
|
+
if (row[field] !== undefined)
|
|
679
|
+
fail(
|
|
680
|
+
`${label}: only condition and wait_for_connection steps branch, so "${field}" doesn't apply. Give it a single "next".`,
|
|
681
|
+
field,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
const exits = branch
|
|
685
|
+
? (["yesNext", "noNext"] as const)
|
|
686
|
+
: (["next"] as const);
|
|
687
|
+
for (const field of exits) {
|
|
688
|
+
const target = row[field];
|
|
689
|
+
if (typeof target !== "string" || !target || target === WORKFLOW_END)
|
|
690
|
+
continue;
|
|
691
|
+
if (target === key)
|
|
692
|
+
fail(`${label}: "${field}" points back at the step itself.`, field);
|
|
693
|
+
else if (!firstWithKey.has(target))
|
|
694
|
+
fail(
|
|
695
|
+
`${label}: "${field}" points at "${target}", which is not the "key" of any step. Use another step's "key", or "${WORKFLOW_END}" to stop there.`,
|
|
696
|
+
field,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
376
702
|
function stepRow(
|
|
377
703
|
value: unknown,
|
|
378
704
|
label: string,
|
|
@@ -408,9 +734,12 @@ export function checkCampaignSteps(
|
|
|
408
734
|
step: 0,
|
|
409
735
|
message: `A campaign can have at most ${MAX_CAMPAIGN_STEPS} steps (got ${steps.length}).`,
|
|
410
736
|
});
|
|
737
|
+
const shaped = steps.some(hasWorkflowShape);
|
|
738
|
+
const workflow = options.workflow ?? shaped;
|
|
411
739
|
let emailSeen = false;
|
|
412
740
|
let connectSeen = false;
|
|
413
741
|
const waitingMessages: number[] = [];
|
|
742
|
+
const unwaitedMessages: number[] = [];
|
|
414
743
|
steps.forEach((value, index) => {
|
|
415
744
|
const step = index + 1;
|
|
416
745
|
const label = `Step ${step}`;
|
|
@@ -432,7 +761,19 @@ export function checkCampaignSteps(
|
|
|
432
761
|
|
|
433
762
|
if (stepType === "email") {
|
|
434
763
|
const reply = row.emailReplyToPrevious === true;
|
|
435
|
-
if (
|
|
764
|
+
if (
|
|
765
|
+
reply &&
|
|
766
|
+
workflow &&
|
|
767
|
+
!steps.some(
|
|
768
|
+
(other, at) =>
|
|
769
|
+
at !== index && (other as { type?: unknown })?.type === "email",
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
fail(
|
|
773
|
+
`${label}: "emailReplyToPrevious" needs an email step before it on every path, and this workflow has no other email. Give this email its own "emailSubject".`,
|
|
774
|
+
"emailReplyToPrevious",
|
|
775
|
+
);
|
|
776
|
+
else if (reply && !emailSeen && !workflow)
|
|
436
777
|
fail(
|
|
437
778
|
`${label}: "emailReplyToPrevious" needs an earlier email step to reply to, so the first email would lose it. Give this email its own "emailSubject".`,
|
|
438
779
|
"emailReplyToPrevious",
|
|
@@ -468,29 +809,47 @@ export function checkCampaignSteps(
|
|
|
468
809
|
|
|
469
810
|
if (stepType === "linkedin_message") {
|
|
470
811
|
checkMessageBody(trimmedText(row.linkedinMessageBody), label, fail);
|
|
812
|
+
// A workflow waits for the accept with its own wait_for_connection step.
|
|
813
|
+
const waitsByDefault = connectSeen && !workflow;
|
|
471
814
|
const mode =
|
|
472
815
|
row.linkedinMessageMode ??
|
|
473
|
-
(
|
|
816
|
+
(waitsByDefault ? "wait_until_connected" : "skip_if_not_connected");
|
|
474
817
|
const timeout = row.connectionTimeoutHours;
|
|
475
818
|
if (timeout !== undefined) {
|
|
476
819
|
if (mode !== "wait_until_connected")
|
|
477
820
|
fail(
|
|
478
|
-
`${label}: "connectionTimeoutHours" only applies with "linkedinMessageMode": "wait_until_connected"${
|
|
821
|
+
`${label}: "connectionTimeoutHours" only applies with "linkedinMessageMode": "wait_until_connected"${waitsByDefault ? "" : workflow ? " (in a workflow, a wait_for_connection step waits for the accept instead)" : " (the default only after a linkedin_connection step)"}, so it would be dropped.`,
|
|
479
822
|
"connectionTimeoutHours",
|
|
480
823
|
);
|
|
481
824
|
else checkTimeoutRange(timeout, label, fail);
|
|
482
825
|
}
|
|
483
826
|
if (
|
|
484
|
-
|
|
827
|
+
waitsByDefault &&
|
|
485
828
|
mode === "wait_until_connected" &&
|
|
486
829
|
(timeout ?? DEFAULT_CONNECTION_WAIT_HOURS) ===
|
|
487
830
|
DEFAULT_CONNECTION_WAIT_HOURS
|
|
488
831
|
)
|
|
489
832
|
waitingMessages.push(step);
|
|
833
|
+
if (
|
|
834
|
+
workflow &&
|
|
835
|
+
!shaped &&
|
|
836
|
+
connectSeen &&
|
|
837
|
+
row.linkedinMessageMode === undefined
|
|
838
|
+
)
|
|
839
|
+
unwaitedMessages.push(step);
|
|
490
840
|
}
|
|
491
841
|
|
|
842
|
+
if (stepType === "condition") checkCondition(row, label, fail);
|
|
843
|
+
if (stepType === "wait_for_connection")
|
|
844
|
+
checkAcceptanceWait(row.connectionTimeoutHours, label, fail);
|
|
845
|
+
|
|
492
846
|
checkTemplateTokens(row, label, fail, options);
|
|
493
847
|
});
|
|
848
|
+
if (workflow) checkWorkflowPaths(steps, errors);
|
|
849
|
+
if (unwaitedMessages.length > 0)
|
|
850
|
+
warnings.push(
|
|
851
|
+
`${listSteps(unwaitedMessages)} ${unwaitedMessages.length === 1 ? "is a LinkedIn message" : "are LinkedIn messages"} after the connection request, and this campaign is a workflow, so ${unwaitedMessages.length === 1 ? "it doesn't" : "they don't"} wait for the accept: anyone who hasn't accepted by then is skipped. To wait, edit the workflow dench campaign steps get <campaign> prints, or add a wait_for_connection step after the request with "yesNext" leading to the message. To skip on purpose, set "linkedinMessageMode": "skip_if_not_connected".`,
|
|
852
|
+
);
|
|
494
853
|
if (waitingMessages.length >= 2)
|
|
495
854
|
warnings.push(
|
|
496
855
|
`${listSteps(waitingMessages)} are LinkedIn messages after the connection request. Each waits up to ${DEFAULT_CONNECTION_WAIT_HOURS / 24} days for the accept by default, so someone who never accepts holds the sequence for up to ${(waitingMessages.length * DEFAULT_CONNECTION_WAIT_HOURS) / 24} days. Give the later ones "linkedinMessageMode": "skip_if_not_connected" or a shorter "connectionTimeoutHours".`,
|
|
@@ -552,6 +911,8 @@ export function checkStepEdits(
|
|
|
552
911
|
);
|
|
553
912
|
}
|
|
554
913
|
}
|
|
914
|
+
if (stepType === "wait_for_connection")
|
|
915
|
+
checkAcceptanceWait(row.connectionTimeoutHours, label, fail);
|
|
555
916
|
checkTemplateTokens(row, label, fail, options);
|
|
556
917
|
});
|
|
557
918
|
return { errors, warnings: [] };
|
|
@@ -564,8 +925,12 @@ const KEEP_WHEN_OMITTED = [
|
|
|
564
925
|
"connectionTimeoutHours",
|
|
565
926
|
"likeReaction",
|
|
566
927
|
"likeMaxPostAgeDays",
|
|
928
|
+
"likeSkipPreviousWait",
|
|
567
929
|
"likeSkipNextWait",
|
|
930
|
+
"likeCount",
|
|
568
931
|
"withdrawAfterHours",
|
|
932
|
+
...FROZEN_AFTER_LAUNCH,
|
|
933
|
+
"conditionValue",
|
|
569
934
|
] as const;
|
|
570
935
|
|
|
571
936
|
const CLEARED_VALUES: Record<string, unknown> = {
|
|
@@ -587,8 +952,63 @@ const TYPE_NAMES: Record<StepType, string> = {
|
|
|
587
952
|
linkedin_message: "a LinkedIn message",
|
|
588
953
|
linkedin_like_post: "a post reaction",
|
|
589
954
|
linkedin_inmail: "an InMail",
|
|
955
|
+
condition: "an If / else",
|
|
956
|
+
wait_for_connection: "a Wait for them to accept",
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
const STEP_NAMES: Record<string, string> = {
|
|
960
|
+
condition: "If / else",
|
|
961
|
+
wait_for_connection: "Wait for them to accept",
|
|
590
962
|
};
|
|
591
963
|
|
|
964
|
+
/** Why a launched campaign's file can't change these paths and checks. */
|
|
965
|
+
function frozenFieldProblems(
|
|
966
|
+
stored: readonly StoredStep[],
|
|
967
|
+
edits: ReadonlyMap<number, Record<string, unknown>>,
|
|
968
|
+
campaign: string,
|
|
969
|
+
): string[] {
|
|
970
|
+
const problems: string[] = [];
|
|
971
|
+
let paths = false;
|
|
972
|
+
let checks = false;
|
|
973
|
+
for (const [index, fields] of [...edits.entries()].sort(
|
|
974
|
+
([a], [b]) => a - b,
|
|
975
|
+
)) {
|
|
976
|
+
const before = stepToInput(stored[index]);
|
|
977
|
+
const changed = FROZEN_AFTER_LAUNCH.filter(
|
|
978
|
+
(field) => fields[field] !== undefined && fields[field] !== before[field],
|
|
979
|
+
);
|
|
980
|
+
if (!changed.length) continue;
|
|
981
|
+
const name = STEP_NAMES[stored[index].type];
|
|
982
|
+
const label = `Step ${index + 1}${name ? ` (${name})` : ""}`;
|
|
983
|
+
const list = changed.map((field) => `"${field}"`).join(", ");
|
|
984
|
+
const isPath = (field: string) =>
|
|
985
|
+
(PATH_KEYS as readonly string[]).includes(field);
|
|
986
|
+
if (changed.some(isPath)) paths = true;
|
|
987
|
+
if (!changed.every(isPath)) checks = true;
|
|
988
|
+
problems.push(
|
|
989
|
+
`${label}: ${list} can't change after launch (the file has ${changed
|
|
990
|
+
.map(
|
|
991
|
+
(field) =>
|
|
992
|
+
`${field} ${JSON.stringify(fields[field])} instead of ${JSON.stringify(before[field] ?? null)}`,
|
|
993
|
+
)
|
|
994
|
+
.join(", ")}).`,
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
if (!problems.length) return problems;
|
|
998
|
+
const why = [
|
|
999
|
+
paths
|
|
1000
|
+
? "Where a launched campaign's steps lead stays fixed, because people may already be on those paths."
|
|
1001
|
+
: "",
|
|
1002
|
+
checks
|
|
1003
|
+
? 'A launched If / else keeps what it checks; you can still change its "conditionValue".'
|
|
1004
|
+
: "",
|
|
1005
|
+
].filter(Boolean);
|
|
1006
|
+
return [
|
|
1007
|
+
...problems,
|
|
1008
|
+
`${why.join(" ")} Nothing was saved. To change it, copy the campaign with dench campaign duplicate ${campaign} and set the copy's steps.`,
|
|
1009
|
+
];
|
|
1010
|
+
}
|
|
1011
|
+
|
|
592
1012
|
export class StepEditError extends Error {}
|
|
593
1013
|
|
|
594
1014
|
export type StoredStep = Record<string, unknown> & { type: string };
|
|
@@ -607,6 +1027,27 @@ export type StepEditOptions = StepCheckOptions & {
|
|
|
607
1027
|
draft?: boolean;
|
|
608
1028
|
};
|
|
609
1029
|
|
|
1030
|
+
/**
|
|
1031
|
+
* Where each file row's `key` sits among the stored steps: null when some row
|
|
1032
|
+
* has no key or two rows share one (match by order instead), undefined when
|
|
1033
|
+
* every row has its own key but one names no stored step.
|
|
1034
|
+
*/
|
|
1035
|
+
function keyMatch(
|
|
1036
|
+
stored: readonly StoredStep[],
|
|
1037
|
+
rows: readonly Record<string, unknown>[],
|
|
1038
|
+
): number[] | null | undefined {
|
|
1039
|
+
const keys = rows.map((row) => row.key);
|
|
1040
|
+
if (
|
|
1041
|
+
keys.some((key) => typeof key !== "string" || !key) ||
|
|
1042
|
+
new Set(keys).size !== keys.length
|
|
1043
|
+
)
|
|
1044
|
+
return null;
|
|
1045
|
+
const indexes = keys.map((key) =>
|
|
1046
|
+
stored.findIndex((step) => step.key === key),
|
|
1047
|
+
);
|
|
1048
|
+
return indexes.includes(-1) ? undefined : indexes;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
610
1051
|
export function planStepEdits(
|
|
611
1052
|
storedSteps: readonly StoredStep[],
|
|
612
1053
|
entries: readonly unknown[],
|
|
@@ -632,14 +1073,24 @@ export function planStepEdits(
|
|
|
632
1073
|
const byId = withIds > 0;
|
|
633
1074
|
if (!byId && rows.length !== stored.length)
|
|
634
1075
|
throw new StepEditError(
|
|
635
|
-
`The file has ${rows.length} ${rows.length === 1 ? "step" : "steps"} and the campaign has ${stored.length}. steps edit changes steps in place, matched by order, so it needs all ${stored.length}; to edit only some, include "stepId" from dench campaign steps get ${campaign} --with-ids.`,
|
|
1076
|
+
`The file has ${rows.length} ${rows.length === 1 ? "step" : "steps"} and the campaign has ${stored.length}. steps edit changes steps in place, matched by ${options.workflow ? '"key" or by order' : "order"}, so it needs all ${stored.length}; to edit only some, include "stepId" from dench campaign steps get ${campaign} --with-ids.`,
|
|
636
1077
|
);
|
|
1078
|
+
// A workflow is stored in path order, which can differ from the file's,
|
|
1079
|
+
// so its steps match by key whenever every key names a stored step.
|
|
1080
|
+
const keyIndex = byId || !options.workflow ? null : keyMatch(stored, rows);
|
|
1081
|
+
if (keyIndex === undefined && !options.draft) {
|
|
1082
|
+
const keys = new Set(stored.map((step) => step.key));
|
|
1083
|
+
const unknown = rows.findIndex((row) => !keys.has(row.key));
|
|
1084
|
+
throw new StepEditError(
|
|
1085
|
+
`Step ${unknown + 1} in the file has "key": ${JSON.stringify(rows[unknown].key)}, which is not a step of this campaign. After launch every step keeps its "key"; use the keys dench campaign steps get ${campaign} prints.`,
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
637
1088
|
const restructure = options.draft
|
|
638
1089
|
? `To add, remove, reorder or retype steps on a draft, use dench campaign steps set ${campaign} --file steps.json.`
|
|
639
1090
|
: `After launch a step keeps its type and place; to change the sequence, copy it with dench campaign duplicate ${campaign} and set the copy's steps.`;
|
|
640
1091
|
const edits = new Map<number, Record<string, unknown>>();
|
|
641
1092
|
rows.forEach((row, position) => {
|
|
642
|
-
let index = position;
|
|
1093
|
+
let index = keyIndex?.[position] ?? position;
|
|
643
1094
|
if (byId) {
|
|
644
1095
|
index = stored.findIndex(
|
|
645
1096
|
(step) => String(step._id) === String(row.stepId),
|
|
@@ -665,6 +1116,10 @@ export function planStepEdits(
|
|
|
665
1116
|
const { stepId: _stepId, updatedAt: _updatedAt, ...fields } = row;
|
|
666
1117
|
edits.set(index, fields);
|
|
667
1118
|
});
|
|
1119
|
+
if (!options.draft) {
|
|
1120
|
+
const frozen = frozenFieldProblems(stored, edits, campaign);
|
|
1121
|
+
if (frozen.length) throw new StepEditError(frozen.join("\n"));
|
|
1122
|
+
}
|
|
668
1123
|
|
|
669
1124
|
const warnings: string[] = [];
|
|
670
1125
|
const steps = stored.map((step, index) => {
|
|
@@ -674,9 +1129,16 @@ export function planStepEdits(
|
|
|
674
1129
|
const after: Record<string, unknown> = {};
|
|
675
1130
|
for (const [key, value] of Object.entries(fields))
|
|
676
1131
|
if (value !== undefined) after[key] = value;
|
|
1132
|
+
const kept: string[] = [];
|
|
677
1133
|
for (const key of KEEP_WHEN_OMITTED)
|
|
678
|
-
if (after[key] === undefined && before[key] !== undefined)
|
|
1134
|
+
if (after[key] === undefined && before[key] !== undefined) {
|
|
679
1135
|
after[key] = before[key];
|
|
1136
|
+
kept.push(key);
|
|
1137
|
+
}
|
|
1138
|
+
// A new check or comparison leaves behind the saved settings it ignores.
|
|
1139
|
+
if (step.type === "condition")
|
|
1140
|
+
for (const key of conditionDrops(after))
|
|
1141
|
+
if (kept.includes(key)) delete after[key];
|
|
680
1142
|
if (
|
|
681
1143
|
hasHtmlAndTextBody(before) &&
|
|
682
1144
|
trimmedText(after.emailTextBody) !== "" &&
|