@dench.com/cli 3.5.8-staging.350 → 3.6.0
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 +11 -2
- 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
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
export const MAX_CAMPAIGN_STEPS = 20;
|
|
2
|
+
export const MAX_WAIT_HOURS = 2160;
|
|
3
|
+
export const DEFAULT_CONNECTION_WAIT_HOURS = 720;
|
|
4
|
+
export const MAX_LINKEDIN_MESSAGE_CHARS = 3000;
|
|
5
|
+
export const MAX_CONNECTION_NOTE_CHARS = 300;
|
|
6
|
+
export const MAX_INMAIL_SUBJECT_CHARS = 200;
|
|
7
|
+
export const MAX_INMAIL_BODY_CHARS = 1900;
|
|
8
|
+
export const MAX_LIKE_POST_AGE_DAYS = 365;
|
|
9
|
+
export const MIN_WITHDRAW_AFTER_HOURS = 7 * 24;
|
|
10
|
+
export const LINKEDIN_REACTIONS = [
|
|
11
|
+
"like",
|
|
12
|
+
"celebrate",
|
|
13
|
+
"support",
|
|
14
|
+
"love",
|
|
15
|
+
"insightful",
|
|
16
|
+
"funny",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export const STEP_TYPES = [
|
|
20
|
+
"wait",
|
|
21
|
+
"email",
|
|
22
|
+
"linkedin_connection",
|
|
23
|
+
"linkedin_message",
|
|
24
|
+
"linkedin_visit",
|
|
25
|
+
"linkedin_like_post",
|
|
26
|
+
"linkedin_inmail",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
export type StepType = (typeof STEP_TYPES)[number];
|
|
30
|
+
|
|
31
|
+
export const STEP_KEYS = [
|
|
32
|
+
"type",
|
|
33
|
+
"waitHours",
|
|
34
|
+
"emailMailboxAccountId",
|
|
35
|
+
"emailSubject",
|
|
36
|
+
"emailHtmlBody",
|
|
37
|
+
"emailTextBody",
|
|
38
|
+
"emailReplyToPrevious",
|
|
39
|
+
"onlyIf",
|
|
40
|
+
"linkedinAccountId",
|
|
41
|
+
"linkedinMessageBody",
|
|
42
|
+
"linkedinMessageMode",
|
|
43
|
+
"connectionTimeoutHours",
|
|
44
|
+
"likeReaction",
|
|
45
|
+
"likeMaxPostAgeDays",
|
|
46
|
+
"likeSkipNextWait",
|
|
47
|
+
"inmailSubject",
|
|
48
|
+
"withdrawAfterHours",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
const FIELDS_BY_TYPE: Record<StepType, readonly string[]> = {
|
|
52
|
+
wait: ["waitHours"],
|
|
53
|
+
email: [
|
|
54
|
+
"emailMailboxAccountId",
|
|
55
|
+
"emailSubject",
|
|
56
|
+
"emailHtmlBody",
|
|
57
|
+
"emailTextBody",
|
|
58
|
+
"emailReplyToPrevious",
|
|
59
|
+
"onlyIf",
|
|
60
|
+
],
|
|
61
|
+
linkedin_visit: ["linkedinAccountId"],
|
|
62
|
+
linkedin_connection: [
|
|
63
|
+
"linkedinAccountId",
|
|
64
|
+
"linkedinMessageBody",
|
|
65
|
+
"withdrawAfterHours",
|
|
66
|
+
],
|
|
67
|
+
linkedin_message: [
|
|
68
|
+
"linkedinAccountId",
|
|
69
|
+
"linkedinMessageBody",
|
|
70
|
+
"linkedinMessageMode",
|
|
71
|
+
"connectionTimeoutHours",
|
|
72
|
+
],
|
|
73
|
+
linkedin_like_post: [
|
|
74
|
+
"linkedinAccountId",
|
|
75
|
+
"onlyIf",
|
|
76
|
+
"likeReaction",
|
|
77
|
+
"likeMaxPostAgeDays",
|
|
78
|
+
"likeSkipNextWait",
|
|
79
|
+
],
|
|
80
|
+
linkedin_inmail: [
|
|
81
|
+
"linkedinAccountId",
|
|
82
|
+
"linkedinMessageBody",
|
|
83
|
+
"inmailSubject",
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const TEXT_FIELDS = [
|
|
88
|
+
"emailMailboxAccountId",
|
|
89
|
+
"emailSubject",
|
|
90
|
+
"emailHtmlBody",
|
|
91
|
+
"emailTextBody",
|
|
92
|
+
"linkedinAccountId",
|
|
93
|
+
"linkedinMessageBody",
|
|
94
|
+
"inmailSubject",
|
|
95
|
+
] as const;
|
|
96
|
+
|
|
97
|
+
const TEMPLATE_FIELDS = [
|
|
98
|
+
"emailSubject",
|
|
99
|
+
"emailHtmlBody",
|
|
100
|
+
"emailTextBody",
|
|
101
|
+
"linkedinMessageBody",
|
|
102
|
+
"inmailSubject",
|
|
103
|
+
] as const;
|
|
104
|
+
|
|
105
|
+
const ONLY_IF = ["always", "connected", "not_connected"];
|
|
106
|
+
const MESSAGE_MODES = ["skip_if_not_connected", "wait_until_connected"];
|
|
107
|
+
|
|
108
|
+
export type StepCheckOptions = {
|
|
109
|
+
allowSingleBraces?: boolean;
|
|
110
|
+
singleBraceOverride?: string;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export type StepIssue = { step: number; field?: string; message: string };
|
|
114
|
+
|
|
115
|
+
export type StepCheck = { errors: StepIssue[]; warnings: string[] };
|
|
116
|
+
|
|
117
|
+
function holdsNothing(field: string, value: unknown): boolean {
|
|
118
|
+
if (value === undefined) return true;
|
|
119
|
+
if (field === "emailReplyToPrevious") return value === false;
|
|
120
|
+
if (field === "onlyIf") return value === "always";
|
|
121
|
+
if (field === "withdrawAfterHours") return value === null;
|
|
122
|
+
if (typeof value === "string") return !value.trim();
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function singleBraceTokens(text: string): string[] {
|
|
127
|
+
const outside = text.replace(/\{\{[^{}]*\}\}/g, " ");
|
|
128
|
+
return [
|
|
129
|
+
...new Set(
|
|
130
|
+
[...outside.matchAll(/\{\s*([A-Za-z_][A-Za-z0-9_ .-]{0,60}?)\s*\}/g)].map(
|
|
131
|
+
(match) => match[0],
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function trimmedText(value: unknown): string {
|
|
138
|
+
return typeof value === "string" ? value.trim() : "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function hasHtmlAndTextBody(step: unknown): boolean {
|
|
142
|
+
if (!step || typeof step !== "object") return false;
|
|
143
|
+
const row = step as Record<string, unknown>;
|
|
144
|
+
return (
|
|
145
|
+
row.type === "email" &&
|
|
146
|
+
trimmedText(row.emailHtmlBody) !== "" &&
|
|
147
|
+
trimmedText(row.emailTextBody) !== ""
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function staleHtmlSteps(
|
|
152
|
+
next: readonly unknown[],
|
|
153
|
+
stored: readonly unknown[],
|
|
154
|
+
): number[] {
|
|
155
|
+
const storedEmails = stored.filter(hasHtmlAndTextBody) as Array<
|
|
156
|
+
Record<string, unknown>
|
|
157
|
+
>;
|
|
158
|
+
return next.flatMap((step, index) => {
|
|
159
|
+
if (!hasHtmlAndTextBody(step)) return [];
|
|
160
|
+
const row = step as Record<string, unknown>;
|
|
161
|
+
const html = trimmedText(row.emailHtmlBody);
|
|
162
|
+
const text = trimmedText(row.emailTextBody);
|
|
163
|
+
const sameHtml = storedEmails.filter(
|
|
164
|
+
(candidate) => trimmedText(candidate.emailHtmlBody) === html,
|
|
165
|
+
);
|
|
166
|
+
return sameHtml.length > 0 &&
|
|
167
|
+
sameHtml.every(
|
|
168
|
+
(candidate) => trimmedText(candidate.emailTextBody) !== text,
|
|
169
|
+
)
|
|
170
|
+
? [index]
|
|
171
|
+
: [];
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function staleHtmlWarning(index: number): string {
|
|
176
|
+
return `Step ${index + 1}: "emailTextBody" was edited but "emailHtmlBody" is still the copy from before, and the HTML is what gets sent, so the old HTML was dropped and the email goes out as the new text.`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function hasEmptyMergeToken(text: string): boolean {
|
|
180
|
+
return /\{\{\s*\}\}/.test(text);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function listSteps(numbers: number[]): string {
|
|
184
|
+
if (numbers.length <= 1) return `Step ${numbers[0]}`;
|
|
185
|
+
return `Steps ${numbers.slice(0, -1).join(", ")} and ${numbers.at(-1)}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type Fail = (message: string, field?: string) => void;
|
|
189
|
+
|
|
190
|
+
function checkStepFieldShapes(
|
|
191
|
+
row: Record<string, unknown>,
|
|
192
|
+
stepType: StepType,
|
|
193
|
+
label: string,
|
|
194
|
+
fail: Fail,
|
|
195
|
+
): void {
|
|
196
|
+
for (const field of TEXT_FIELDS)
|
|
197
|
+
if (row[field] !== undefined && typeof row[field] !== "string")
|
|
198
|
+
fail(`${label}: "${field}" must be text.`, field);
|
|
199
|
+
if (
|
|
200
|
+
row.emailReplyToPrevious !== undefined &&
|
|
201
|
+
typeof row.emailReplyToPrevious !== "boolean"
|
|
202
|
+
)
|
|
203
|
+
fail(`${label}: "emailReplyToPrevious" must be true or false.`);
|
|
204
|
+
if (
|
|
205
|
+
row.onlyIf !== undefined &&
|
|
206
|
+
(typeof row.onlyIf !== "string" || !ONLY_IF.includes(row.onlyIf))
|
|
207
|
+
)
|
|
208
|
+
fail(
|
|
209
|
+
`${label}: "onlyIf" must be always, connected or not_connected.`,
|
|
210
|
+
"onlyIf",
|
|
211
|
+
);
|
|
212
|
+
if (
|
|
213
|
+
row.linkedinMessageMode !== undefined &&
|
|
214
|
+
(typeof row.linkedinMessageMode !== "string" ||
|
|
215
|
+
!MESSAGE_MODES.includes(row.linkedinMessageMode))
|
|
216
|
+
)
|
|
217
|
+
fail(
|
|
218
|
+
`${label}: "linkedinMessageMode" must be skip_if_not_connected or wait_until_connected.`,
|
|
219
|
+
"linkedinMessageMode",
|
|
220
|
+
);
|
|
221
|
+
if (row.waitHours !== undefined && typeof row.waitHours !== "number")
|
|
222
|
+
fail(`${label}: "waitHours" must be a number of hours.`, "waitHours");
|
|
223
|
+
if (
|
|
224
|
+
row.likeReaction !== undefined &&
|
|
225
|
+
(typeof row.likeReaction !== "string" ||
|
|
226
|
+
!(LINKEDIN_REACTIONS as readonly string[]).includes(row.likeReaction))
|
|
227
|
+
)
|
|
228
|
+
fail(
|
|
229
|
+
`${label}: "likeReaction" must be one of: ${LINKEDIN_REACTIONS.join(", ")}.`,
|
|
230
|
+
"likeReaction",
|
|
231
|
+
);
|
|
232
|
+
if (
|
|
233
|
+
row.likeMaxPostAgeDays !== undefined &&
|
|
234
|
+
(typeof row.likeMaxPostAgeDays !== "number" ||
|
|
235
|
+
!Number.isInteger(row.likeMaxPostAgeDays) ||
|
|
236
|
+
row.likeMaxPostAgeDays < 1 ||
|
|
237
|
+
row.likeMaxPostAgeDays > MAX_LIKE_POST_AGE_DAYS)
|
|
238
|
+
)
|
|
239
|
+
fail(
|
|
240
|
+
`${label}: "likeMaxPostAgeDays" must be a whole number from 1 to ${MAX_LIKE_POST_AGE_DAYS}.`,
|
|
241
|
+
"likeMaxPostAgeDays",
|
|
242
|
+
);
|
|
243
|
+
if (
|
|
244
|
+
row.likeSkipNextWait !== undefined &&
|
|
245
|
+
typeof row.likeSkipNextWait !== "boolean"
|
|
246
|
+
)
|
|
247
|
+
fail(
|
|
248
|
+
`${label}: "likeSkipNextWait" must be true or false.`,
|
|
249
|
+
"likeSkipNextWait",
|
|
250
|
+
);
|
|
251
|
+
if (
|
|
252
|
+
row.withdrawAfterHours !== undefined &&
|
|
253
|
+
row.withdrawAfterHours !== null &&
|
|
254
|
+
(typeof row.withdrawAfterHours !== "number" ||
|
|
255
|
+
!Number.isFinite(row.withdrawAfterHours) ||
|
|
256
|
+
row.withdrawAfterHours < MIN_WITHDRAW_AFTER_HOURS ||
|
|
257
|
+
row.withdrawAfterHours > MAX_WAIT_HOURS)
|
|
258
|
+
)
|
|
259
|
+
fail(
|
|
260
|
+
`${label}: "withdrawAfterHours" must be ${MIN_WITHDRAW_AFTER_HOURS}-${MAX_WAIT_HOURS} hours (7-90 days), or null to turn withdrawal off.`,
|
|
261
|
+
"withdrawAfterHours",
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const kind = stepType === "email" ? "an email" : `a ${stepType}`;
|
|
265
|
+
const allowed = FIELDS_BY_TYPE[stepType];
|
|
266
|
+
const dropped = (STEP_KEYS as readonly string[]).filter(
|
|
267
|
+
(key) =>
|
|
268
|
+
key !== "type" && !allowed.includes(key) && !holdsNothing(key, row[key]),
|
|
269
|
+
);
|
|
270
|
+
for (const field of dropped) {
|
|
271
|
+
if (field === "onlyIf")
|
|
272
|
+
fail(
|
|
273
|
+
`${label}: "onlyIf" only works on email or linkedin_like_post steps; ${kind} step would drop it. InMail already skips connected people.`,
|
|
274
|
+
field,
|
|
275
|
+
);
|
|
276
|
+
else if (field === "linkedinMessageBody" && stepType === "linkedin_visit")
|
|
277
|
+
fail(
|
|
278
|
+
`${label}: ${kind} step sends no text, so "linkedinMessageBody" would be dropped. Use linkedin_connection for an invite note or linkedin_message for a message.`,
|
|
279
|
+
field,
|
|
280
|
+
);
|
|
281
|
+
else
|
|
282
|
+
fail(
|
|
283
|
+
`${label}: "${field}" does not apply to ${kind} step and would be dropped.`,
|
|
284
|
+
field,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function checkTemplateTokens(
|
|
290
|
+
row: Record<string, unknown>,
|
|
291
|
+
label: string,
|
|
292
|
+
fail: Fail,
|
|
293
|
+
options: StepCheckOptions,
|
|
294
|
+
): void {
|
|
295
|
+
const override = options.singleBraceOverride ?? '"allowSingleBraces": true';
|
|
296
|
+
for (const field of TEMPLATE_FIELDS) {
|
|
297
|
+
const text = row[field];
|
|
298
|
+
if (typeof text !== "string") continue;
|
|
299
|
+
if (hasEmptyMergeToken(text))
|
|
300
|
+
fail(`${label}: "${field}" has an empty {{ }} merge token.`, field);
|
|
301
|
+
if (options.allowSingleBraces) continue;
|
|
302
|
+
const tokens = singleBraceTokens(text);
|
|
303
|
+
if (tokens.length)
|
|
304
|
+
fail(
|
|
305
|
+
`${label}: "${field}" has ${tokens.join(", ")}. Single braces are sent exactly as typed; merge tokens use double braces: ${tokens
|
|
306
|
+
.map((token) => `{${token}}`)
|
|
307
|
+
.join(", ")}. If the braces are meant literally, pass ${override}.`,
|
|
308
|
+
field,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function checkWaitHours(hours: unknown, label: string, fail: Fail): void {
|
|
314
|
+
if (typeof hours !== "number" || !(hours > 0))
|
|
315
|
+
fail(`${label} is a wait step and needs "waitHours" above 0.`, "waitHours");
|
|
316
|
+
else if (!Number.isFinite(hours) || hours > MAX_WAIT_HOURS)
|
|
317
|
+
fail(
|
|
318
|
+
`${label}: "waitHours" must be at most ${MAX_WAIT_HOURS} (90 days).`,
|
|
319
|
+
"waitHours",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function checkConnectionNote(note: string, label: string, fail: Fail): void {
|
|
324
|
+
if (note.length > MAX_CONNECTION_NOTE_CHARS)
|
|
325
|
+
fail(
|
|
326
|
+
`${label}: the connection note is ${note.length} characters; LinkedIn allows ${MAX_CONNECTION_NOTE_CHARS}.`,
|
|
327
|
+
"linkedinMessageBody",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function checkMessageBody(
|
|
332
|
+
body: string,
|
|
333
|
+
label: string,
|
|
334
|
+
fail: Fail,
|
|
335
|
+
type: "linkedin_message" | "linkedin_inmail" = "linkedin_message",
|
|
336
|
+
): void {
|
|
337
|
+
const limit =
|
|
338
|
+
type === "linkedin_inmail"
|
|
339
|
+
? MAX_INMAIL_BODY_CHARS
|
|
340
|
+
: MAX_LINKEDIN_MESSAGE_CHARS;
|
|
341
|
+
const name =
|
|
342
|
+
type === "linkedin_inmail" ? "InMail messages" : "LinkedIn messages";
|
|
343
|
+
if (!body)
|
|
344
|
+
fail(
|
|
345
|
+
`${label} is a ${type} step and needs "linkedinMessageBody".`,
|
|
346
|
+
"linkedinMessageBody",
|
|
347
|
+
);
|
|
348
|
+
else if (body.length > limit)
|
|
349
|
+
fail(
|
|
350
|
+
`${label}: "linkedinMessageBody" is ${body.length} characters; ${name} are limited to ${limit}.`,
|
|
351
|
+
"linkedinMessageBody",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function checkInmailSubject(subject: string, label: string, fail: Fail): void {
|
|
356
|
+
if (subject.length > MAX_INMAIL_SUBJECT_CHARS)
|
|
357
|
+
fail(
|
|
358
|
+
`${label}: "inmailSubject" is ${subject.length} characters; InMail subjects are limited to ${MAX_INMAIL_SUBJECT_CHARS}.`,
|
|
359
|
+
"inmailSubject",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function checkTimeoutRange(timeout: unknown, label: string, fail: Fail): void {
|
|
364
|
+
if (
|
|
365
|
+
typeof timeout !== "number" ||
|
|
366
|
+
!Number.isFinite(timeout) ||
|
|
367
|
+
timeout < 1 ||
|
|
368
|
+
timeout > MAX_WAIT_HOURS
|
|
369
|
+
)
|
|
370
|
+
fail(
|
|
371
|
+
`${label}: "connectionTimeoutHours" must be 1-${MAX_WAIT_HOURS} hours.`,
|
|
372
|
+
"connectionTimeoutHours",
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function stepRow(
|
|
377
|
+
value: unknown,
|
|
378
|
+
label: string,
|
|
379
|
+
fail: Fail,
|
|
380
|
+
): { row: Record<string, unknown>; stepType: StepType } | null {
|
|
381
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
382
|
+
fail(`${label} must be an object with a "type".`);
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
const row = value as Record<string, unknown>;
|
|
386
|
+
const type = row.type;
|
|
387
|
+
if (
|
|
388
|
+
typeof type !== "string" ||
|
|
389
|
+
!(STEP_TYPES as readonly string[]).includes(type)
|
|
390
|
+
) {
|
|
391
|
+
fail(
|
|
392
|
+
`${label} has type ${JSON.stringify(type)}. Use one of: ${STEP_TYPES.join(", ")}.`,
|
|
393
|
+
"type",
|
|
394
|
+
);
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
return { row, stepType: type as StepType };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function checkCampaignSteps(
|
|
401
|
+
steps: readonly unknown[],
|
|
402
|
+
options: StepCheckOptions = {},
|
|
403
|
+
): StepCheck {
|
|
404
|
+
const errors: StepIssue[] = [];
|
|
405
|
+
const warnings: string[] = [];
|
|
406
|
+
if (steps.length > MAX_CAMPAIGN_STEPS)
|
|
407
|
+
errors.push({
|
|
408
|
+
step: 0,
|
|
409
|
+
message: `A campaign can have at most ${MAX_CAMPAIGN_STEPS} steps (got ${steps.length}).`,
|
|
410
|
+
});
|
|
411
|
+
let emailSeen = false;
|
|
412
|
+
let connectSeen = false;
|
|
413
|
+
const waitingMessages: number[] = [];
|
|
414
|
+
steps.forEach((value, index) => {
|
|
415
|
+
const step = index + 1;
|
|
416
|
+
const label = `Step ${step}`;
|
|
417
|
+
const fail: Fail = (message, field) =>
|
|
418
|
+
errors.push({ step, ...(field ? { field } : {}), message });
|
|
419
|
+
const parsed = stepRow(value, label, fail);
|
|
420
|
+
if (!parsed) return;
|
|
421
|
+
const { row, stepType } = parsed;
|
|
422
|
+
const unknown = Object.keys(row).filter(
|
|
423
|
+
(key) => !(STEP_KEYS as readonly string[]).includes(key),
|
|
424
|
+
);
|
|
425
|
+
if (unknown.length)
|
|
426
|
+
fail(
|
|
427
|
+
`${label} has keys the engine does not know: ${unknown.join(", ")}. Allowed: ${STEP_KEYS.join(", ")}.`,
|
|
428
|
+
);
|
|
429
|
+
checkStepFieldShapes(row, stepType, label, fail);
|
|
430
|
+
|
|
431
|
+
if (stepType === "wait") checkWaitHours(row.waitHours, label, fail);
|
|
432
|
+
|
|
433
|
+
if (stepType === "email") {
|
|
434
|
+
const reply = row.emailReplyToPrevious === true;
|
|
435
|
+
if (reply && !emailSeen)
|
|
436
|
+
fail(
|
|
437
|
+
`${label}: "emailReplyToPrevious" needs an earlier email step to reply to, so the first email would lose it. Give this email its own "emailSubject".`,
|
|
438
|
+
"emailReplyToPrevious",
|
|
439
|
+
);
|
|
440
|
+
const subject = trimmedText(row.emailSubject);
|
|
441
|
+
if (!subject && !reply)
|
|
442
|
+
fail(
|
|
443
|
+
`${label} is an email step and needs "emailSubject"${emailSeen ? ' (or "emailReplyToPrevious": true to reply in the same thread)' : ""}.`,
|
|
444
|
+
"emailSubject",
|
|
445
|
+
);
|
|
446
|
+
if (!trimmedText(row.emailHtmlBody) && !trimmedText(row.emailTextBody))
|
|
447
|
+
fail(
|
|
448
|
+
`${label} is an email step and needs "emailTextBody" or "emailHtmlBody".`,
|
|
449
|
+
"emailTextBody",
|
|
450
|
+
);
|
|
451
|
+
emailSeen = true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (stepType === "linkedin_connection") {
|
|
455
|
+
checkConnectionNote(trimmedText(row.linkedinMessageBody), label, fail);
|
|
456
|
+
connectSeen = true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (stepType === "linkedin_inmail") {
|
|
460
|
+
checkMessageBody(
|
|
461
|
+
trimmedText(row.linkedinMessageBody),
|
|
462
|
+
label,
|
|
463
|
+
fail,
|
|
464
|
+
stepType,
|
|
465
|
+
);
|
|
466
|
+
checkInmailSubject(trimmedText(row.inmailSubject), label, fail);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (stepType === "linkedin_message") {
|
|
470
|
+
checkMessageBody(trimmedText(row.linkedinMessageBody), label, fail);
|
|
471
|
+
const mode =
|
|
472
|
+
row.linkedinMessageMode ??
|
|
473
|
+
(connectSeen ? "wait_until_connected" : "skip_if_not_connected");
|
|
474
|
+
const timeout = row.connectionTimeoutHours;
|
|
475
|
+
if (timeout !== undefined) {
|
|
476
|
+
if (mode !== "wait_until_connected")
|
|
477
|
+
fail(
|
|
478
|
+
`${label}: "connectionTimeoutHours" only applies with "linkedinMessageMode": "wait_until_connected"${connectSeen ? "" : " (the default only after a linkedin_connection step)"}, so it would be dropped.`,
|
|
479
|
+
"connectionTimeoutHours",
|
|
480
|
+
);
|
|
481
|
+
else checkTimeoutRange(timeout, label, fail);
|
|
482
|
+
}
|
|
483
|
+
if (
|
|
484
|
+
connectSeen &&
|
|
485
|
+
mode === "wait_until_connected" &&
|
|
486
|
+
(timeout ?? DEFAULT_CONNECTION_WAIT_HOURS) ===
|
|
487
|
+
DEFAULT_CONNECTION_WAIT_HOURS
|
|
488
|
+
)
|
|
489
|
+
waitingMessages.push(step);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
checkTemplateTokens(row, label, fail, options);
|
|
493
|
+
});
|
|
494
|
+
if (waitingMessages.length >= 2)
|
|
495
|
+
warnings.push(
|
|
496
|
+
`${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".`,
|
|
497
|
+
);
|
|
498
|
+
return { errors, warnings };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function stepToInput(
|
|
502
|
+
step: Record<string, unknown>,
|
|
503
|
+
): Record<string, unknown> {
|
|
504
|
+
const input: Record<string, unknown> = {};
|
|
505
|
+
for (const key of STEP_KEYS) {
|
|
506
|
+
if (
|
|
507
|
+
step[key] !== undefined &&
|
|
508
|
+
(step[key] !== null || key === "withdrawAfterHours")
|
|
509
|
+
)
|
|
510
|
+
input[key] = step[key];
|
|
511
|
+
}
|
|
512
|
+
return input;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function checkStepEdits(
|
|
516
|
+
edits: readonly unknown[],
|
|
517
|
+
options: StepCheckOptions = {},
|
|
518
|
+
): StepCheck {
|
|
519
|
+
const errors: StepIssue[] = [];
|
|
520
|
+
edits.forEach((value, index) => {
|
|
521
|
+
const step = index + 1;
|
|
522
|
+
const label = `Edit ${step}`;
|
|
523
|
+
const fail: Fail = (message, field) =>
|
|
524
|
+
errors.push({ step, ...(field ? { field } : {}), message });
|
|
525
|
+
const parsed = stepRow(value, label, fail);
|
|
526
|
+
if (!parsed) return;
|
|
527
|
+
const { row, stepType } = parsed;
|
|
528
|
+
checkStepFieldShapes(row, stepType, label, fail);
|
|
529
|
+
if (stepType === "wait" && row.waitHours !== undefined)
|
|
530
|
+
checkWaitHours(row.waitHours, label, fail);
|
|
531
|
+
if (stepType === "linkedin_connection")
|
|
532
|
+
checkConnectionNote(trimmedText(row.linkedinMessageBody), label, fail);
|
|
533
|
+
if (stepType === "linkedin_inmail") {
|
|
534
|
+
if (row.linkedinMessageBody !== undefined)
|
|
535
|
+
checkMessageBody(
|
|
536
|
+
trimmedText(row.linkedinMessageBody),
|
|
537
|
+
label,
|
|
538
|
+
fail,
|
|
539
|
+
stepType,
|
|
540
|
+
);
|
|
541
|
+
checkInmailSubject(trimmedText(row.inmailSubject), label, fail);
|
|
542
|
+
}
|
|
543
|
+
if (stepType === "linkedin_message") {
|
|
544
|
+
if (row.linkedinMessageBody !== undefined)
|
|
545
|
+
checkMessageBody(trimmedText(row.linkedinMessageBody), label, fail);
|
|
546
|
+
if (row.connectionTimeoutHours !== undefined) {
|
|
547
|
+
checkTimeoutRange(row.connectionTimeoutHours, label, fail);
|
|
548
|
+
if (row.linkedinMessageMode === "skip_if_not_connected")
|
|
549
|
+
fail(
|
|
550
|
+
`${label}: "connectionTimeoutHours" only applies with "linkedinMessageMode": "wait_until_connected".`,
|
|
551
|
+
"connectionTimeoutHours",
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
checkTemplateTokens(row, label, fail, options);
|
|
556
|
+
});
|
|
557
|
+
return { errors, warnings: [] };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const KEEP_WHEN_OMITTED = [
|
|
561
|
+
"emailMailboxAccountId",
|
|
562
|
+
"linkedinAccountId",
|
|
563
|
+
"linkedinMessageMode",
|
|
564
|
+
"connectionTimeoutHours",
|
|
565
|
+
"likeReaction",
|
|
566
|
+
"likeMaxPostAgeDays",
|
|
567
|
+
"likeSkipNextWait",
|
|
568
|
+
"withdrawAfterHours",
|
|
569
|
+
] as const;
|
|
570
|
+
|
|
571
|
+
const CLEARED_VALUES: Record<string, unknown> = {
|
|
572
|
+
emailSubject: "",
|
|
573
|
+
emailHtmlBody: "",
|
|
574
|
+
emailTextBody: "",
|
|
575
|
+
linkedinMessageBody: "",
|
|
576
|
+
emailReplyToPrevious: false,
|
|
577
|
+
onlyIf: "always",
|
|
578
|
+
inmailSubject: "",
|
|
579
|
+
withdrawAfterHours: null,
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const TYPE_NAMES: Record<StepType, string> = {
|
|
583
|
+
wait: "a wait",
|
|
584
|
+
email: "an email",
|
|
585
|
+
linkedin_visit: "a profile visit",
|
|
586
|
+
linkedin_connection: "a connection request",
|
|
587
|
+
linkedin_message: "a LinkedIn message",
|
|
588
|
+
linkedin_like_post: "a post reaction",
|
|
589
|
+
linkedin_inmail: "an InMail",
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
export class StepEditError extends Error {}
|
|
593
|
+
|
|
594
|
+
export type StoredStep = Record<string, unknown> & { type: string };
|
|
595
|
+
|
|
596
|
+
export type StepChange = { step: number; type: string; fields: string[] };
|
|
597
|
+
|
|
598
|
+
export type StepEditPlan = {
|
|
599
|
+
steps: Record<string, unknown>[];
|
|
600
|
+
updates: Record<string, unknown>[];
|
|
601
|
+
changes: StepChange[];
|
|
602
|
+
warnings: string[];
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
export type StepEditOptions = StepCheckOptions & {
|
|
606
|
+
campaign?: string;
|
|
607
|
+
draft?: boolean;
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
export function planStepEdits(
|
|
611
|
+
storedSteps: readonly StoredStep[],
|
|
612
|
+
entries: readonly unknown[],
|
|
613
|
+
options: StepEditOptions = {},
|
|
614
|
+
): StepEditPlan {
|
|
615
|
+
const campaign = options.campaign ?? "<campaign>";
|
|
616
|
+
const stored = [...storedSteps].sort(
|
|
617
|
+
(a, b) => Number(a.order ?? 0) - Number(b.order ?? 0),
|
|
618
|
+
);
|
|
619
|
+
if (!entries.length) throw new StepEditError("The file has no steps.");
|
|
620
|
+
const rows = entries.map((entry, index) => {
|
|
621
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
622
|
+
throw new StepEditError(
|
|
623
|
+
`Step ${index + 1} in the file must be an object with a "type".`,
|
|
624
|
+
);
|
|
625
|
+
return entry as Record<string, unknown>;
|
|
626
|
+
});
|
|
627
|
+
const withIds = rows.filter((row) => row.stepId !== undefined).length;
|
|
628
|
+
if (withIds > 0 && withIds < rows.length)
|
|
629
|
+
throw new StepEditError(
|
|
630
|
+
`Give every step its "stepId" (from dench campaign steps get ${campaign} --with-ids), or leave "stepId" out of all of them to match the steps by order.`,
|
|
631
|
+
);
|
|
632
|
+
const byId = withIds > 0;
|
|
633
|
+
if (!byId && rows.length !== stored.length)
|
|
634
|
+
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.`,
|
|
636
|
+
);
|
|
637
|
+
const restructure = options.draft
|
|
638
|
+
? `To add, remove, reorder or retype steps on a draft, use dench campaign steps set ${campaign} --file steps.json.`
|
|
639
|
+
: `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
|
+
const edits = new Map<number, Record<string, unknown>>();
|
|
641
|
+
rows.forEach((row, position) => {
|
|
642
|
+
let index = position;
|
|
643
|
+
if (byId) {
|
|
644
|
+
index = stored.findIndex(
|
|
645
|
+
(step) => String(step._id) === String(row.stepId),
|
|
646
|
+
);
|
|
647
|
+
if (index < 0)
|
|
648
|
+
throw new StepEditError(
|
|
649
|
+
`Step id ${String(row.stepId)} is not in this campaign. Run dench campaign steps get ${campaign} --with-ids for the current ids.`,
|
|
650
|
+
);
|
|
651
|
+
if (edits.has(index))
|
|
652
|
+
throw new StepEditError(
|
|
653
|
+
`Step ${index + 1} (id ${String(row.stepId)}) is in the file twice.`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
const current = stored[index];
|
|
657
|
+
if (row.type !== current.type)
|
|
658
|
+
throw new StepEditError(
|
|
659
|
+
`Step ${index + 1} is ${TYPE_NAMES[current.type as StepType] ?? current.type} step, and the file makes it ${JSON.stringify(row.type)}. ${restructure}`,
|
|
660
|
+
);
|
|
661
|
+
if (row.updatedAt !== undefined && row.updatedAt !== current.updatedAt)
|
|
662
|
+
throw new StepEditError(
|
|
663
|
+
`Step ${index + 1} was saved again after you read it. Run dench campaign steps get ${campaign} --with-ids again and redo the edit, so the newer change is not overwritten.`,
|
|
664
|
+
);
|
|
665
|
+
const { stepId: _stepId, updatedAt: _updatedAt, ...fields } = row;
|
|
666
|
+
edits.set(index, fields);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const warnings: string[] = [];
|
|
670
|
+
const steps = stored.map((step, index) => {
|
|
671
|
+
const before = stepToInput(step);
|
|
672
|
+
const fields = edits.get(index);
|
|
673
|
+
if (!fields) return before;
|
|
674
|
+
const after: Record<string, unknown> = {};
|
|
675
|
+
for (const [key, value] of Object.entries(fields))
|
|
676
|
+
if (value !== undefined) after[key] = value;
|
|
677
|
+
for (const key of KEEP_WHEN_OMITTED)
|
|
678
|
+
if (after[key] === undefined && before[key] !== undefined)
|
|
679
|
+
after[key] = before[key];
|
|
680
|
+
if (
|
|
681
|
+
hasHtmlAndTextBody(before) &&
|
|
682
|
+
trimmedText(after.emailTextBody) !== "" &&
|
|
683
|
+
trimmedText(after.emailHtmlBody) === trimmedText(before.emailHtmlBody) &&
|
|
684
|
+
trimmedText(after.emailTextBody) !== trimmedText(before.emailTextBody)
|
|
685
|
+
) {
|
|
686
|
+
delete after.emailHtmlBody;
|
|
687
|
+
warnings.push(staleHtmlWarning(index));
|
|
688
|
+
}
|
|
689
|
+
return after;
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
const check = checkCampaignSteps(steps, options);
|
|
693
|
+
if (check.errors.length)
|
|
694
|
+
throw new StepEditError(
|
|
695
|
+
check.errors.map((issue) => issue.message).join("\n"),
|
|
696
|
+
);
|
|
697
|
+
warnings.push(...check.warnings);
|
|
698
|
+
|
|
699
|
+
const updates: Record<string, unknown>[] = [];
|
|
700
|
+
const changes: StepChange[] = [];
|
|
701
|
+
steps.forEach((after, index) => {
|
|
702
|
+
const step = stored[index];
|
|
703
|
+
const before = stepToInput(step);
|
|
704
|
+
const fields = [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
705
|
+
.filter((key) => key !== "type" && before[key] !== after[key])
|
|
706
|
+
.filter(
|
|
707
|
+
(key) => after[key] !== undefined || CLEARED_VALUES[key] !== undefined,
|
|
708
|
+
);
|
|
709
|
+
if (!fields.length) return;
|
|
710
|
+
updates.push({
|
|
711
|
+
stepId: String(step._id),
|
|
712
|
+
expectedUpdatedAt: step.updatedAt,
|
|
713
|
+
type: step.type,
|
|
714
|
+
...Object.fromEntries(
|
|
715
|
+
fields.map((key) => [key, after[key] ?? CLEARED_VALUES[key]]),
|
|
716
|
+
),
|
|
717
|
+
});
|
|
718
|
+
changes.push({ step: index + 1, type: step.type, fields });
|
|
719
|
+
});
|
|
720
|
+
return { steps, updates, changes, warnings };
|
|
721
|
+
}
|