@oxygen-agent/cli 1.146.1 → 1.152.15
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 +1 -1
- package/dist/http-client.js +40 -2
- package/dist/index.js +315 -14
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +7 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +83 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +268 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/http-client.js
CHANGED
|
@@ -86,9 +86,10 @@ path, options = {}) {
|
|
|
86
86
|
if (!response.ok || !envelope.ok) {
|
|
87
87
|
const failure = envelope;
|
|
88
88
|
const responseTraceId = response.headers.get("x-oxygen-trace-id") ?? traceId;
|
|
89
|
+
const details = withRetryAfterDetails(failure.error.details, response);
|
|
89
90
|
throw new OxygenError(failure.error.code, failure.error.message, {
|
|
90
|
-
details: withTraceDetails(
|
|
91
|
-
exitCode:
|
|
91
|
+
details: withTraceDetails(details, responseTraceId, compatibility, apiUrl),
|
|
92
|
+
exitCode: 1,
|
|
92
93
|
});
|
|
93
94
|
}
|
|
94
95
|
return envelope.data;
|
|
@@ -200,6 +201,43 @@ function readEnvelopeCompatibility(envelope) {
|
|
|
200
201
|
}
|
|
201
202
|
return compatibility;
|
|
202
203
|
}
|
|
204
|
+
// Surface the server's 429 backoff as a first-class `retry_after_seconds` detail
|
|
205
|
+
// so loop-driving callers can wait the right amount of time instead of dead-
|
|
206
|
+
// reckoning. Prefers a value the API already put in details; otherwise derives
|
|
207
|
+
// it from the RFC 6585 Retry-After header (or reset_at). Non-429 responses and
|
|
208
|
+
// unparseable values pass through untouched.
|
|
209
|
+
function withRetryAfterDetails(details, response) {
|
|
210
|
+
if (response.status !== 429)
|
|
211
|
+
return details;
|
|
212
|
+
const record = details && typeof details === "object" && !Array.isArray(details)
|
|
213
|
+
? details
|
|
214
|
+
: null;
|
|
215
|
+
if (record && typeof record.retry_after_seconds === "number")
|
|
216
|
+
return details;
|
|
217
|
+
const retryAfterSeconds = retryAfterSecondsFromResponse(response, record);
|
|
218
|
+
if (retryAfterSeconds === null)
|
|
219
|
+
return details;
|
|
220
|
+
if (record)
|
|
221
|
+
return { ...record, retry_after_seconds: retryAfterSeconds };
|
|
222
|
+
if (details === undefined)
|
|
223
|
+
return { retry_after_seconds: retryAfterSeconds };
|
|
224
|
+
return { details, retry_after_seconds: retryAfterSeconds };
|
|
225
|
+
}
|
|
226
|
+
function retryAfterSecondsFromResponse(response, details) {
|
|
227
|
+
const header = response.headers.get("retry-after");
|
|
228
|
+
if (header) {
|
|
229
|
+
const seconds = Number(header);
|
|
230
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
231
|
+
return Math.ceil(seconds);
|
|
232
|
+
}
|
|
233
|
+
const resetAt = details?.reset_at;
|
|
234
|
+
if (typeof resetAt === "string") {
|
|
235
|
+
const resetMs = Date.parse(resetAt);
|
|
236
|
+
if (!Number.isNaN(resetMs))
|
|
237
|
+
return Math.max(0, Math.ceil((resetMs - Date.now()) / 1000));
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
203
241
|
function withTraceDetails(details, traceId, compatibility, apiUrl) {
|
|
204
242
|
const serverVersion = compatibility.version;
|
|
205
243
|
const fields = {
|
package/dist/index.js
CHANGED
|
@@ -2066,28 +2066,31 @@ export function createProgram() {
|
|
|
2066
2066
|
}));
|
|
2067
2067
|
program
|
|
2068
2068
|
.command("search")
|
|
2069
|
-
.description("Agent-operable
|
|
2069
|
+
.description("Agent-operable people, signal, web, scrape, and local-business search jobs. For company search use 'oxygen companies search'.")
|
|
2070
2070
|
.addCommand(new Command("plan")
|
|
2071
2071
|
.description("Plan an Oxygen search or scrape route before running provider jobs.")
|
|
2072
2072
|
.requiredOption("--goal <file|text>", "Search/scrape goal text, or a local file path containing the goal.")
|
|
2073
|
-
.option("--kind <kind>", "Route kind:
|
|
2073
|
+
.option("--kind <kind>", "Route kind: people_search, signal_search, web_search, web_scrape, local_business_search, or source_specific_scrape. For company search use 'oxygen companies search plan'.")
|
|
2074
2074
|
.option("--target-count <n>", "Optional target row count.")
|
|
2075
2075
|
.option("--geography <text>", "Optional geography hint.")
|
|
2076
2076
|
.option("--known-urls <urls>", "Comma-separated known URLs for scrape routes.")
|
|
2077
2077
|
.option("--provider-hints <providers>", "Comma-separated provider hints.")
|
|
2078
2078
|
.option("--json", "Print a JSON envelope.")
|
|
2079
2079
|
.action(async (options) => {
|
|
2080
|
-
await handleAsyncAction("search plan", options, () =>
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2080
|
+
await handleAsyncAction("search plan", options, () => {
|
|
2081
|
+
assertNotCompanySearchKind(options.kind);
|
|
2082
|
+
return requestOxygen("/api/cli/search/plan", {
|
|
2083
|
+
method: "POST",
|
|
2084
|
+
body: {
|
|
2085
|
+
goal: readFileIfPresent(options.goal),
|
|
2086
|
+
...(readOption(options.kind) ? { kind: readOption(options.kind) } : {}),
|
|
2087
|
+
...(readPositiveInt(options.targetCount) ? { target_count: readPositiveInt(options.targetCount) } : {}),
|
|
2088
|
+
...(readOption(options.geography) ? { geography: readOption(options.geography) } : {}),
|
|
2089
|
+
...(readOption(options.knownUrls) ? { known_urls: readCsvOption(options.knownUrls) } : {}),
|
|
2090
|
+
...(readOption(options.providerHints) ? { provider_hints: readCsvOption(options.providerHints) } : {}),
|
|
2091
|
+
},
|
|
2092
|
+
});
|
|
2093
|
+
});
|
|
2091
2094
|
}))
|
|
2092
2095
|
.addCommand(new Command("run")
|
|
2093
2096
|
.description("Preview or enqueue a durable table-backed search/scrape job from a search plan.")
|
|
@@ -2191,6 +2194,19 @@ export function createProgram() {
|
|
|
2191
2194
|
.requiredOption("--prompt <text-or-file>", "Company-search prompt, or a path to a prompt file.")
|
|
2192
2195
|
.option("--target-count <n>", "Desired company count for routing and estimates.")
|
|
2193
2196
|
.option("--source-intent <intent>", "Override detected intent: sizing, structured, technology, hiring, local, known_source, concept, web, url, or fallback.")
|
|
2197
|
+
.option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path; wins over individual flags per top-level filter path.")
|
|
2198
|
+
.option("--industries <csv>", "Comma-separated industries to include.")
|
|
2199
|
+
.option("--exclude-industries <csv>", "Comma-separated industries to exclude.")
|
|
2200
|
+
.option("--keywords <csv>", "Comma-separated keywords to include.")
|
|
2201
|
+
.option("--exclude-keywords <csv>", "Comma-separated keywords to exclude.")
|
|
2202
|
+
.option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 country codes.")
|
|
2203
|
+
.option("--employees <range>", "Employee count range: 20-200, 500+, or -50.")
|
|
2204
|
+
.option("--funding-stages <csv>", "Comma-separated funding stages: pre_seed, seed, series_a, ...")
|
|
2205
|
+
.option("--technologies <csv>", "Comma-separated technologies to include.")
|
|
2206
|
+
.option("--revenue <range>", "Annual revenue (USD) range: 1000000-20000000, 1000000+, or -5000000.")
|
|
2207
|
+
.option("--founded <range>", "Founded year range: 2015-2024, 2020+, or -2010.")
|
|
2208
|
+
.option("--lookalike <csv>", "Comma-separated lookalike company domains.")
|
|
2209
|
+
.option("--estimate", "Run a free count probe for an estimated match count.")
|
|
2194
2210
|
.option("--materialize-preview", "Create a preview table with route rows.")
|
|
2195
2211
|
.option("--json", "Print a JSON envelope.")
|
|
2196
2212
|
.action(async (options) => {
|
|
@@ -2212,6 +2228,19 @@ export function createProgram() {
|
|
|
2212
2228
|
.option("--max-credits <n>", "Required credit ceiling for live runs.")
|
|
2213
2229
|
.option("--target-count <n>", "Desired company count when planning from --prompt.")
|
|
2214
2230
|
.option("--source-intent <intent>", "Override detected intent when planning from --prompt.")
|
|
2231
|
+
.option("--filters-json <json-or-file>", "CompanySearchFilters JSON inline or a @file/path when planning from --prompt; wins over individual flags per top-level filter path.")
|
|
2232
|
+
.option("--industries <csv>", "Comma-separated industries to include when planning from --prompt.")
|
|
2233
|
+
.option("--exclude-industries <csv>", "Comma-separated industries to exclude when planning from --prompt.")
|
|
2234
|
+
.option("--keywords <csv>", "Comma-separated keywords to include when planning from --prompt.")
|
|
2235
|
+
.option("--exclude-keywords <csv>", "Comma-separated keywords to exclude when planning from --prompt.")
|
|
2236
|
+
.option("--countries <csv>", "Comma-separated ISO 3166-1 alpha-2 country codes when planning from --prompt.")
|
|
2237
|
+
.option("--employees <range>", "Employee count range when planning from --prompt: 20-200, 500+, or -50.")
|
|
2238
|
+
.option("--funding-stages <csv>", "Comma-separated funding stages when planning from --prompt.")
|
|
2239
|
+
.option("--technologies <csv>", "Comma-separated technologies to include when planning from --prompt.")
|
|
2240
|
+
.option("--revenue <range>", "Annual revenue (USD) range when planning from --prompt.")
|
|
2241
|
+
.option("--founded <range>", "Founded year range when planning from --prompt.")
|
|
2242
|
+
.option("--lookalike <csv>", "Comma-separated lookalike company domains when planning from --prompt.")
|
|
2243
|
+
.option("--estimate", "Run a free count probe when planning from --prompt.")
|
|
2215
2244
|
.option("--approved", "Required for live runs after inspecting dry-run output.")
|
|
2216
2245
|
.option("--json", "Print a JSON envelope.")
|
|
2217
2246
|
.action(async (options) => {
|
|
@@ -3151,7 +3180,12 @@ export function createProgram() {
|
|
|
3151
3180
|
.option("--profile-views-per-day <n>", "Daily profile views cap.")
|
|
3152
3181
|
.option("--follows-per-day <n>", "Daily follows cap.")
|
|
3153
3182
|
.option("--likes-per-day <n>", "Daily likes cap.")
|
|
3154
|
-
.option("--total-actions-per-day <n>", "Daily cap across all action types.")
|
|
3183
|
+
.option("--total-actions-per-day <n>", "Daily cap across all send/action types.")
|
|
3184
|
+
.option("--relations-reads-per-day <n>", "Daily cap on relations/connections list reads (scrape protection).")
|
|
3185
|
+
.option("--messages-reads-per-day <n>", "Daily cap on chat and message-history reads.")
|
|
3186
|
+
.option("--searches-per-day <n>", "Daily cap on LinkedIn search executions.")
|
|
3187
|
+
.option("--api-reads-per-day <n>", "Daily cap on all other LinkedIn API reads.")
|
|
3188
|
+
.option("--total-reads-per-day <n>", "Daily cap across all read types.")
|
|
3155
3189
|
.option("--min-spacing-seconds <n>", "Minimum seconds between actions.")
|
|
3156
3190
|
.option("--spacing-jitter-seconds <n>", "Random jitter seconds added to action spacing.")
|
|
3157
3191
|
.option("--timezone <tz>", "IANA timezone for working hours, e.g. America/New_York.")
|
|
@@ -3253,6 +3287,152 @@ export function createProgram() {
|
|
|
3253
3287
|
body.message_limit = Number(messageLimit);
|
|
3254
3288
|
return requestOxygen("/api/cli/linkedin/inbox/sync", { method: "POST", body });
|
|
3255
3289
|
});
|
|
3290
|
+
})))
|
|
3291
|
+
.addCommand(new Command("sequences")
|
|
3292
|
+
.description("LinkedIn outreach sequences: multi-step campaigns over a lead table dispatched across sender accounts with rate limits and reply-stop.")
|
|
3293
|
+
.addCommand(new Command("list")
|
|
3294
|
+
.description("List LinkedIn sequences with status and credit usage.")
|
|
3295
|
+
.option("--status <status>", "Filter by status: draft, active, paused, or archived.")
|
|
3296
|
+
.option("--json", "Print a JSON envelope.")
|
|
3297
|
+
.action(async (options) => {
|
|
3298
|
+
await handleAsyncAction("linkedin sequences list", options, () => {
|
|
3299
|
+
const params = new URLSearchParams();
|
|
3300
|
+
const status = readOption(options.status);
|
|
3301
|
+
if (status)
|
|
3302
|
+
params.set("status", status);
|
|
3303
|
+
const suffix = params.toString();
|
|
3304
|
+
return requestOxygen(`/api/cli/linkedin/sequences${suffix ? `?${suffix}` : ""}`);
|
|
3305
|
+
});
|
|
3306
|
+
}))
|
|
3307
|
+
.addCommand(new Command("create")
|
|
3308
|
+
.description("Create a draft sequence from a steps JSON file. Assign sender accounts with --senders.")
|
|
3309
|
+
.requiredOption("--name <name>", "Human-readable sequence name.")
|
|
3310
|
+
.requiredOption("--slug <slug>", "Unique slug for the sequence.")
|
|
3311
|
+
.requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }.")
|
|
3312
|
+
.requiredOption("--senders <ids>", "Comma-separated sender account ids (or connection / Unipile ids).")
|
|
3313
|
+
.option("--table <id>", "Source table id whose rows supply {{column}} template values.")
|
|
3314
|
+
.option("--url-column <key>", "Column key holding each lead's LinkedIn URL/provider id.")
|
|
3315
|
+
.option("--max-credits <n>", "Credit cap for the sequence (also set when starting).")
|
|
3316
|
+
.option("--json", "Print a JSON envelope.")
|
|
3317
|
+
.action(async (options) => {
|
|
3318
|
+
await handleAsyncAction("linkedin sequences create", options, () => {
|
|
3319
|
+
const stepsPath = readOption(options.stepsFile);
|
|
3320
|
+
if (!stepsPath)
|
|
3321
|
+
throw new Error("--steps-file is required.");
|
|
3322
|
+
const raw = readFileSync(resolve(stepsPath), "utf8");
|
|
3323
|
+
const definition = JSON.parse(raw);
|
|
3324
|
+
const senders = (readOption(options.senders) ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
3325
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
3326
|
+
return requestOxygen("/api/cli/linkedin/sequences", {
|
|
3327
|
+
method: "POST",
|
|
3328
|
+
body: {
|
|
3329
|
+
name: readOption(options.name),
|
|
3330
|
+
slug: readOption(options.slug),
|
|
3331
|
+
definition,
|
|
3332
|
+
senders,
|
|
3333
|
+
...(readOption(options.table) ? { source_table_id: readOption(options.table) } : {}),
|
|
3334
|
+
...(readOption(options.urlColumn) ? { linkedin_url_column_key: readOption(options.urlColumn) } : {}),
|
|
3335
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
3336
|
+
},
|
|
3337
|
+
});
|
|
3338
|
+
});
|
|
3339
|
+
}))
|
|
3340
|
+
.addCommand(new Command("get")
|
|
3341
|
+
.description("Get a sequence's definition, senders, status, and credit usage.")
|
|
3342
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
3343
|
+
.option("--json", "Print a JSON envelope.")
|
|
3344
|
+
.action(async (sequence, options) => {
|
|
3345
|
+
await handleAsyncAction("linkedin sequences get", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}`));
|
|
3346
|
+
}))
|
|
3347
|
+
.addCommand(new Command("enroll")
|
|
3348
|
+
.description("Enroll leads into a sequence from a JSON file of { leads: [...] }. Idempotent per table row.")
|
|
3349
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
3350
|
+
.requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, row_values }] }.")
|
|
3351
|
+
.option("--json", "Print a JSON envelope.")
|
|
3352
|
+
.action(async (sequence, options) => {
|
|
3353
|
+
await handleAsyncAction("linkedin sequences enroll", options, () => {
|
|
3354
|
+
const leadsPath = readOption(options.leadsFile);
|
|
3355
|
+
if (!leadsPath)
|
|
3356
|
+
throw new Error("--leads-file is required.");
|
|
3357
|
+
const parsed = JSON.parse(readFileSync(resolve(leadsPath), "utf8"));
|
|
3358
|
+
return requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/enroll`, {
|
|
3359
|
+
method: "POST",
|
|
3360
|
+
body: { leads: parsed.leads ?? [] },
|
|
3361
|
+
});
|
|
3362
|
+
});
|
|
3363
|
+
}))
|
|
3364
|
+
.addCommand(new Command("start")
|
|
3365
|
+
.description("Start a sequence. Dispatches REAL LinkedIn actions — requires --approved and --max-credits. Without them, returns a preview. Use --dry-run to activate in simulated mode (no sends, no credits).")
|
|
3366
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
3367
|
+
.option("--approved", "Approve and activate live. Without this flag, returns a preview only.")
|
|
3368
|
+
.option("--max-credits <n>", "Credit cap (required with --approved).")
|
|
3369
|
+
.option("--dry-run", "Activate in dry-run mode: advance every step with simulated sends, no LinkedIn actions, no credits.")
|
|
3370
|
+
.option("--json", "Print a JSON envelope.")
|
|
3371
|
+
.action(async (sequence, options) => {
|
|
3372
|
+
await handleAsyncAction("linkedin sequences start", options, () => {
|
|
3373
|
+
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
3374
|
+
return requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/start`, {
|
|
3375
|
+
method: "POST",
|
|
3376
|
+
body: {
|
|
3377
|
+
...(options.dryRun ? { dry_run: true } : {}),
|
|
3378
|
+
...(options.approved ? { approved: true } : {}),
|
|
3379
|
+
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
3380
|
+
},
|
|
3381
|
+
});
|
|
3382
|
+
});
|
|
3383
|
+
}))
|
|
3384
|
+
.addCommand(new Command("pause")
|
|
3385
|
+
.description("Pause an active sequence (stops new dispatches; enrollments resume on un-pause).")
|
|
3386
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
3387
|
+
.option("--json", "Print a JSON envelope.")
|
|
3388
|
+
.action(async (sequence, options) => {
|
|
3389
|
+
await handleAsyncAction("linkedin sequences pause", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/status`, {
|
|
3390
|
+
method: "POST", body: { status: "paused" },
|
|
3391
|
+
}));
|
|
3392
|
+
}))
|
|
3393
|
+
.addCommand(new Command("resume")
|
|
3394
|
+
.description("Resume a paused sequence.")
|
|
3395
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
3396
|
+
.option("--json", "Print a JSON envelope.")
|
|
3397
|
+
.action(async (sequence, options) => {
|
|
3398
|
+
await handleAsyncAction("linkedin sequences resume", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/status`, {
|
|
3399
|
+
method: "POST", body: { status: "active" },
|
|
3400
|
+
}));
|
|
3401
|
+
}))
|
|
3402
|
+
.addCommand(new Command("archive")
|
|
3403
|
+
.description("Archive a sequence (terminal; cannot be reactivated).")
|
|
3404
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
3405
|
+
.option("--json", "Print a JSON envelope.")
|
|
3406
|
+
.action(async (sequence, options) => {
|
|
3407
|
+
await handleAsyncAction("linkedin sequences archive", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/status`, {
|
|
3408
|
+
method: "POST", body: { status: "archived" },
|
|
3409
|
+
}));
|
|
3410
|
+
}))
|
|
3411
|
+
.addCommand(new Command("enrollments")
|
|
3412
|
+
.description("List a sequence's per-lead enrollments and their state.")
|
|
3413
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
3414
|
+
.option("--status <status>", "Filter by enrollment status (active, waiting_connection, replied, completed, stopped, failed).")
|
|
3415
|
+
.option("--limit <n>", "Maximum enrollments to return (1-500).")
|
|
3416
|
+
.option("--json", "Print a JSON envelope.")
|
|
3417
|
+
.action(async (sequence, options) => {
|
|
3418
|
+
await handleAsyncAction("linkedin sequences enrollments", options, () => {
|
|
3419
|
+
const params = new URLSearchParams();
|
|
3420
|
+
const status = readOption(options.status);
|
|
3421
|
+
if (status)
|
|
3422
|
+
params.set("status", status);
|
|
3423
|
+
const limit = readOption(options.limit);
|
|
3424
|
+
if (limit)
|
|
3425
|
+
params.set("limit", limit);
|
|
3426
|
+
const suffix = params.toString();
|
|
3427
|
+
return requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
|
|
3428
|
+
});
|
|
3429
|
+
}))
|
|
3430
|
+
.addCommand(new Command("stats")
|
|
3431
|
+
.description("Show the sequence funnel: enrolled, invites sent, connected, replied, with acceptance and reply rates.")
|
|
3432
|
+
.argument("<sequence>", "Sequence id or slug.")
|
|
3433
|
+
.option("--json", "Print a JSON envelope.")
|
|
3434
|
+
.action(async (sequence, options) => {
|
|
3435
|
+
await handleAsyncAction("linkedin sequences stats", options, () => requestOxygen(`/api/cli/linkedin/sequences/${encodeURIComponent(sequence)}/stats`));
|
|
3256
3436
|
})));
|
|
3257
3437
|
program
|
|
3258
3438
|
.command("workflows")
|
|
@@ -4214,10 +4394,13 @@ function isNetworkTimeoutError(error) {
|
|
|
4214
4394
|
}
|
|
4215
4395
|
function readCompaniesSearchPlanBody(options) {
|
|
4216
4396
|
const targetCount = readPositiveInt(options.targetCount);
|
|
4397
|
+
const filters = readCompanySearchFilters(options);
|
|
4217
4398
|
return {
|
|
4218
4399
|
prompt: readFileIfPresent(options.prompt),
|
|
4219
4400
|
...(targetCount !== undefined ? { target_count: targetCount } : {}),
|
|
4220
4401
|
...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
|
|
4402
|
+
...(filters ? { filters } : {}),
|
|
4403
|
+
...(options.estimate ? { estimate: true } : {}),
|
|
4221
4404
|
...(options.materializePreview ? { materialize_preview: true } : {}),
|
|
4222
4405
|
};
|
|
4223
4406
|
}
|
|
@@ -4230,6 +4413,7 @@ function readCompaniesSearchRunBody(options) {
|
|
|
4230
4413
|
const maxPages = readPositiveInt(options.maxPages);
|
|
4231
4414
|
const maxCredits = readPositiveNumber(options.maxCredits);
|
|
4232
4415
|
const targetCount = readPositiveInt(options.targetCount);
|
|
4416
|
+
const filters = prompt ? readCompanySearchFilters(options) : null;
|
|
4233
4417
|
return {
|
|
4234
4418
|
...(prompt ? { prompt } : {}),
|
|
4235
4419
|
...(plan ? { plan } : {}),
|
|
@@ -4242,9 +4426,121 @@ function readCompaniesSearchRunBody(options) {
|
|
|
4242
4426
|
...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
|
|
4243
4427
|
...(targetCount !== undefined ? { target_count: targetCount } : {}),
|
|
4244
4428
|
...(options.sourceIntent ? { source_intent: options.sourceIntent } : {}),
|
|
4429
|
+
...(filters ? { filters } : {}),
|
|
4430
|
+
...(prompt && options.estimate ? { estimate: true } : {}),
|
|
4245
4431
|
...(options.approved ? { approved: true } : {}),
|
|
4246
4432
|
};
|
|
4247
4433
|
}
|
|
4434
|
+
// CompanySearchFilters mirror (structural — the server validates the shape). Builds a
|
|
4435
|
+
// filters object from individual --industries/--countries/--employees/etc. flags, then
|
|
4436
|
+
// lets --filters-json win per top-level filter path so an agent can pass a precise object
|
|
4437
|
+
// while still using convenience flags for the rest.
|
|
4438
|
+
function readCompanySearchFilters(options) {
|
|
4439
|
+
const filters = {};
|
|
4440
|
+
const industries = readIncludeExclude(options.industries, options.excludeIndustries);
|
|
4441
|
+
if (industries)
|
|
4442
|
+
filters.industries = industries;
|
|
4443
|
+
const keywords = readIncludeExclude(options.keywords, options.excludeKeywords);
|
|
4444
|
+
if (keywords)
|
|
4445
|
+
filters.keywords = keywords;
|
|
4446
|
+
const countries = readCsvOption(options.countries);
|
|
4447
|
+
if (countries.length > 0)
|
|
4448
|
+
filters.geo = { countries };
|
|
4449
|
+
const employeeCount = readRangeOption(options.employees, "--employees");
|
|
4450
|
+
if (employeeCount)
|
|
4451
|
+
filters.employee_count = employeeCount;
|
|
4452
|
+
const fundingStages = readCsvOption(options.fundingStages);
|
|
4453
|
+
if (fundingStages.length > 0)
|
|
4454
|
+
filters.funding = { stages: fundingStages };
|
|
4455
|
+
const technologies = readIncludeExclude(options.technologies, undefined);
|
|
4456
|
+
if (technologies)
|
|
4457
|
+
filters.technologies = technologies;
|
|
4458
|
+
const revenue = readRangeOption(options.revenue, "--revenue");
|
|
4459
|
+
if (revenue)
|
|
4460
|
+
filters.revenue_usd = revenue;
|
|
4461
|
+
const founded = readRangeOption(options.founded, "--founded");
|
|
4462
|
+
if (founded)
|
|
4463
|
+
filters.founded_year = founded;
|
|
4464
|
+
const lookalike = readCsvOption(options.lookalike);
|
|
4465
|
+
if (lookalike.length > 0)
|
|
4466
|
+
filters.lookalike_domains = lookalike;
|
|
4467
|
+
const explicit = options.filtersJson ? parseJsonObject(readFileIfPresent(options.filtersJson)) : null;
|
|
4468
|
+
if (explicit) {
|
|
4469
|
+
for (const [key, value] of Object.entries(explicit)) {
|
|
4470
|
+
filters[key] = value;
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
return Object.keys(filters).length > 0 ? filters : null;
|
|
4474
|
+
}
|
|
4475
|
+
function readIncludeExclude(include, exclude) {
|
|
4476
|
+
const includeValues = readCsvOption(include);
|
|
4477
|
+
const excludeValues = readCsvOption(exclude);
|
|
4478
|
+
const result = {};
|
|
4479
|
+
if (includeValues.length > 0)
|
|
4480
|
+
result.include = includeValues;
|
|
4481
|
+
if (excludeValues.length > 0)
|
|
4482
|
+
result.exclude = excludeValues;
|
|
4483
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
4484
|
+
}
|
|
4485
|
+
// Range flags accept "20-200" -> { min, max }, "500+" -> { min }, "-50" -> { max },
|
|
4486
|
+
// and a bare "200" -> { min, max } (exact value).
|
|
4487
|
+
function readRangeOption(value, flag) {
|
|
4488
|
+
const raw = readOption(value);
|
|
4489
|
+
if (!raw)
|
|
4490
|
+
return null;
|
|
4491
|
+
if (/^\d+\+$/.test(raw)) {
|
|
4492
|
+
return { min: readRangeNumber(raw.slice(0, -1), flag, raw) };
|
|
4493
|
+
}
|
|
4494
|
+
if (/^-\d+$/.test(raw)) {
|
|
4495
|
+
return { max: readRangeNumber(raw.slice(1), flag, raw) };
|
|
4496
|
+
}
|
|
4497
|
+
if (/^\d+-\d+$/.test(raw)) {
|
|
4498
|
+
const [minRaw, maxRaw] = raw.split("-");
|
|
4499
|
+
const min = readRangeNumber(minRaw, flag, raw);
|
|
4500
|
+
const max = readRangeNumber(maxRaw, flag, raw);
|
|
4501
|
+
if (min > max) {
|
|
4502
|
+
throw new OxygenError("invalid_range", `${flag} min must not exceed max.`, {
|
|
4503
|
+
details: { flag, value: raw },
|
|
4504
|
+
exitCode: 1,
|
|
4505
|
+
});
|
|
4506
|
+
}
|
|
4507
|
+
return { min, max };
|
|
4508
|
+
}
|
|
4509
|
+
if (/^\d+$/.test(raw)) {
|
|
4510
|
+
const bound = readRangeNumber(raw, flag, raw);
|
|
4511
|
+
return { min: bound, max: bound };
|
|
4512
|
+
}
|
|
4513
|
+
throw new OxygenError("invalid_range", `${flag} expects a range like 20-200, 500+, or -50.`, {
|
|
4514
|
+
details: { flag, value: raw },
|
|
4515
|
+
exitCode: 1,
|
|
4516
|
+
});
|
|
4517
|
+
}
|
|
4518
|
+
function readRangeNumber(value, flag, raw) {
|
|
4519
|
+
const parsed = Number(value);
|
|
4520
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
4521
|
+
throw new OxygenError("invalid_range", `${flag} bounds must be non-negative integers.`, {
|
|
4522
|
+
details: { flag, value: raw },
|
|
4523
|
+
exitCode: 1,
|
|
4524
|
+
});
|
|
4525
|
+
}
|
|
4526
|
+
return parsed;
|
|
4527
|
+
}
|
|
4528
|
+
// Company search has moved to the dedicated companies-search surface. The CLI rejects
|
|
4529
|
+
// `oxygen search plan --kind company_search` client-side so a stale agent gets a typed,
|
|
4530
|
+
// self-correcting error instead of an opaque server 400. Message mirrors
|
|
4531
|
+
// COMPANY_SEARCH_MOVED_MESSAGE in @oxygen/tools (CLI does not import that package).
|
|
4532
|
+
const COMPANY_SEARCH_MOVED_MESSAGE = "Company search is handled by the dedicated companies-search surface. Use: oxygen companies search plan --prompt \"<goal>\" (CLI) or oxygen_companies_search_plan (MCP).";
|
|
4533
|
+
function assertNotCompanySearchKind(kind) {
|
|
4534
|
+
if (readOption(kind) === "company_search") {
|
|
4535
|
+
throw new OxygenError("use_companies_search", COMPANY_SEARCH_MOVED_MESSAGE, {
|
|
4536
|
+
details: {
|
|
4537
|
+
equivalent_cli: "oxygen companies search plan --prompt <goal>",
|
|
4538
|
+
equivalent_mcp: "oxygen_companies_search_plan",
|
|
4539
|
+
},
|
|
4540
|
+
exitCode: 1,
|
|
4541
|
+
});
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4248
4544
|
function readCompanySearchPlanJson(value) {
|
|
4249
4545
|
const parsed = parseJsonObject(readFileIfPresent(value));
|
|
4250
4546
|
const data = parsed.data;
|
|
@@ -6654,6 +6950,11 @@ options) {
|
|
|
6654
6950
|
setLimit("follows_per_day", options.followsPerDay);
|
|
6655
6951
|
setLimit("likes_per_day", options.likesPerDay);
|
|
6656
6952
|
setLimit("total_actions_per_day", options.totalActionsPerDay);
|
|
6953
|
+
setLimit("relations_reads_per_day", options.relationsReadsPerDay);
|
|
6954
|
+
setLimit("messages_reads_per_day", options.messagesReadsPerDay);
|
|
6955
|
+
setLimit("searches_per_day", options.searchesPerDay);
|
|
6956
|
+
setLimit("api_reads_per_day", options.apiReadsPerDay);
|
|
6957
|
+
setLimit("total_reads_per_day", options.totalReadsPerDay);
|
|
6657
6958
|
setLimit("min_action_spacing_seconds", options.minSpacingSeconds);
|
|
6658
6959
|
setLimit("action_spacing_jitter_seconds", options.spacingJitterSeconds);
|
|
6659
6960
|
const workingHours = {};
|
|
@@ -4,10 +4,12 @@ export * from "./billing.js";
|
|
|
4
4
|
export * from "./cell-format.js";
|
|
5
5
|
export * from "./column-types.js";
|
|
6
6
|
export * from "./credit-guidance.js";
|
|
7
|
+
export * from "./linkedin-sequences.js";
|
|
7
8
|
export * from "./log.js";
|
|
8
9
|
export * from "./provider-request-outcomes.js";
|
|
9
10
|
export * from "./signup-lead-deliveries.js";
|
|
10
11
|
export * from "./telemetry.js";
|
|
12
|
+
export declare const MAX_ROW_LOOP_WRITE_ROWS = 500;
|
|
11
13
|
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
12
14
|
[key: string]: JsonValue;
|
|
13
15
|
};
|
|
@@ -5,10 +5,17 @@ export * from "./billing.js";
|
|
|
5
5
|
export * from "./cell-format.js";
|
|
6
6
|
export * from "./column-types.js";
|
|
7
7
|
export * from "./credit-guidance.js";
|
|
8
|
+
export * from "./linkedin-sequences.js";
|
|
8
9
|
export * from "./log.js";
|
|
9
10
|
export * from "./provider-request-outcomes.js";
|
|
10
11
|
export * from "./signup-lead-deliveries.js";
|
|
11
12
|
export * from "./telemetry.js";
|
|
13
|
+
// Maximum rows a single row-loop write (insert/upsert/preview) may process. The
|
|
14
|
+
// row-loop engine issues one DB round-trip per row, so a 500-row write already
|
|
15
|
+
// approaches request timeouts (~50s observed in prod); larger batches must use
|
|
16
|
+
// the COPY-based bulk engine. Tenant-db enforces this and the CLI/API row caps
|
|
17
|
+
// reference it so they never advertise a batch the row-loop will reject.
|
|
18
|
+
export const MAX_ROW_LOOP_WRITE_ROWS = 500;
|
|
12
19
|
export class OxygenError extends Error {
|
|
13
20
|
code;
|
|
14
21
|
details;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn sequence step schema — the shared contract validated identically by
|
|
3
|
+
* CLI, MCP, API, and web. A sequence is an ordered list of steps applied to
|
|
4
|
+
* each enrolled lead; the worker dispatch engine materializes one
|
|
5
|
+
* sequence_action per step as it comes due.
|
|
6
|
+
*
|
|
7
|
+
* Step kinds:
|
|
8
|
+
* - visit_profile — view the lead's profile (warms up before an invite)
|
|
9
|
+
* - invite — send a connection request, optional note_template
|
|
10
|
+
* - wait_for_connection — gate: wait until the invite is accepted (new_relation
|
|
11
|
+
* webhook) or timeout_days elapses; on timeout stop or
|
|
12
|
+
* continue per on_timeout
|
|
13
|
+
* - wait — fixed delay (days/hours) before the next step
|
|
14
|
+
* - message — send a message (opens a chat if none exists);
|
|
15
|
+
* template supports {{column}} interpolation
|
|
16
|
+
* - inmail — send an InMail (works on non-connections); subject +
|
|
17
|
+
* template
|
|
18
|
+
*/
|
|
19
|
+
export declare const LINKEDIN_SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "wait", "message", "inmail"];
|
|
20
|
+
export type LinkedInSequenceStepKind = typeof LINKEDIN_SEQUENCE_STEP_KINDS[number];
|
|
21
|
+
export type LinkedInVisitProfileStep = {
|
|
22
|
+
kind: "visit_profile";
|
|
23
|
+
};
|
|
24
|
+
export type LinkedInInviteStep = {
|
|
25
|
+
kind: "invite";
|
|
26
|
+
/** Optional connection-request note. Supports {{column}} interpolation. ~300 char max on LinkedIn. */
|
|
27
|
+
note_template?: string;
|
|
28
|
+
};
|
|
29
|
+
export type LinkedInWaitForConnectionStep = {
|
|
30
|
+
kind: "wait_for_connection";
|
|
31
|
+
/** Days to wait for the invite to be accepted before acting on on_timeout. */
|
|
32
|
+
timeout_days: number;
|
|
33
|
+
/** What to do if the invite is never accepted. Defaults to "stop". */
|
|
34
|
+
on_timeout?: "stop" | "continue";
|
|
35
|
+
};
|
|
36
|
+
export type LinkedInWaitStep = {
|
|
37
|
+
kind: "wait";
|
|
38
|
+
/** Fixed delay before the next step. Provide days and/or hours (>= 1 total). */
|
|
39
|
+
days?: number;
|
|
40
|
+
hours?: number;
|
|
41
|
+
};
|
|
42
|
+
export type LinkedInMessageStep = {
|
|
43
|
+
kind: "message";
|
|
44
|
+
/** Message body. Supports {{column}} interpolation from the source-table row. */
|
|
45
|
+
template: string;
|
|
46
|
+
};
|
|
47
|
+
export type LinkedInInMailStep = {
|
|
48
|
+
kind: "inmail";
|
|
49
|
+
subject_template: string;
|
|
50
|
+
template: string;
|
|
51
|
+
};
|
|
52
|
+
export type LinkedInSequenceStep = LinkedInVisitProfileStep | LinkedInInviteStep | LinkedInWaitForConnectionStep | LinkedInWaitStep | LinkedInMessageStep | LinkedInInMailStep;
|
|
53
|
+
export type LinkedInSequenceDefinition = {
|
|
54
|
+
steps: LinkedInSequenceStep[];
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The dispatch-queue action kind a step produces (matches the
|
|
58
|
+
* ox_linkedin.sequence_actions.action_kind enum), or null for gate/wait steps
|
|
59
|
+
* that dispatch nothing. NOTE: this is the *action* kind, not the *quota* kind
|
|
60
|
+
* — visit_profile maps to the quota kind profile_view downstream.
|
|
61
|
+
*/
|
|
62
|
+
export declare const LINKEDIN_STEP_ACTION_KIND: Record<LinkedInSequenceStepKind, "visit_profile" | "invite" | "message" | "inmail" | null>;
|
|
63
|
+
export type LinkedInSequenceLintIssue = {
|
|
64
|
+
path: string;
|
|
65
|
+
message: string;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Validate a raw sequence definition. Returns the normalized definition or
|
|
69
|
+
* throws OxygenError("invalid_linkedin_sequence") with per-step issues. Pure —
|
|
70
|
+
* safe to call from any surface.
|
|
71
|
+
*/
|
|
72
|
+
export declare function validateLinkedInSequenceDefinition(input: unknown): LinkedInSequenceDefinition;
|
|
73
|
+
/** Non-throwing variant for lint surfaces. */
|
|
74
|
+
export declare function lintLinkedInSequenceDefinition(input: unknown): LinkedInSequenceLintIssue[];
|
|
75
|
+
/** Total delay in milliseconds a `wait` step introduces. */
|
|
76
|
+
export declare function waitStepDelayMs(step: LinkedInWaitStep): number;
|
|
77
|
+
/**
|
|
78
|
+
* Render a {{column}} template against a row's values. Unknown placeholders
|
|
79
|
+
* render empty. Used by the dispatch engine to produce the final message text.
|
|
80
|
+
*/
|
|
81
|
+
export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown>): string;
|
|
82
|
+
/** Column keys referenced by {{...}} placeholders across all steps. */
|
|
83
|
+
export declare function linkedInTemplateVariables(definition: LinkedInSequenceDefinition): string[];
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { OxygenError } from "./index.js";
|
|
2
|
+
/**
|
|
3
|
+
* LinkedIn sequence step schema — the shared contract validated identically by
|
|
4
|
+
* CLI, MCP, API, and web. A sequence is an ordered list of steps applied to
|
|
5
|
+
* each enrolled lead; the worker dispatch engine materializes one
|
|
6
|
+
* sequence_action per step as it comes due.
|
|
7
|
+
*
|
|
8
|
+
* Step kinds:
|
|
9
|
+
* - visit_profile — view the lead's profile (warms up before an invite)
|
|
10
|
+
* - invite — send a connection request, optional note_template
|
|
11
|
+
* - wait_for_connection — gate: wait until the invite is accepted (new_relation
|
|
12
|
+
* webhook) or timeout_days elapses; on timeout stop or
|
|
13
|
+
* continue per on_timeout
|
|
14
|
+
* - wait — fixed delay (days/hours) before the next step
|
|
15
|
+
* - message — send a message (opens a chat if none exists);
|
|
16
|
+
* template supports {{column}} interpolation
|
|
17
|
+
* - inmail — send an InMail (works on non-connections); subject +
|
|
18
|
+
* template
|
|
19
|
+
*/
|
|
20
|
+
export const LINKEDIN_SEQUENCE_STEP_KINDS = [
|
|
21
|
+
"visit_profile",
|
|
22
|
+
"invite",
|
|
23
|
+
"wait_for_connection",
|
|
24
|
+
"wait",
|
|
25
|
+
"message",
|
|
26
|
+
"inmail",
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* The dispatch-queue action kind a step produces (matches the
|
|
30
|
+
* ox_linkedin.sequence_actions.action_kind enum), or null for gate/wait steps
|
|
31
|
+
* that dispatch nothing. NOTE: this is the *action* kind, not the *quota* kind
|
|
32
|
+
* — visit_profile maps to the quota kind profile_view downstream.
|
|
33
|
+
*/
|
|
34
|
+
export const LINKEDIN_STEP_ACTION_KIND = {
|
|
35
|
+
visit_profile: "visit_profile",
|
|
36
|
+
invite: "invite",
|
|
37
|
+
wait_for_connection: null,
|
|
38
|
+
wait: null,
|
|
39
|
+
message: "message",
|
|
40
|
+
inmail: "inmail",
|
|
41
|
+
};
|
|
42
|
+
const MAX_STEPS = 25;
|
|
43
|
+
const MAX_TEMPLATE_LENGTH = 8_000;
|
|
44
|
+
const MAX_NOTE_LENGTH = 300;
|
|
45
|
+
/**
|
|
46
|
+
* Validate a raw sequence definition. Returns the normalized definition or
|
|
47
|
+
* throws OxygenError("invalid_linkedin_sequence") with per-step issues. Pure —
|
|
48
|
+
* safe to call from any surface.
|
|
49
|
+
*/
|
|
50
|
+
export function validateLinkedInSequenceDefinition(input) {
|
|
51
|
+
const issues = [];
|
|
52
|
+
const steps = collectSteps(input, issues);
|
|
53
|
+
const normalized = [];
|
|
54
|
+
steps.forEach((rawStep, index) => {
|
|
55
|
+
const step = normalizeStep(rawStep, index, issues);
|
|
56
|
+
if (step)
|
|
57
|
+
normalized.push(step);
|
|
58
|
+
});
|
|
59
|
+
validateStructure(normalized, issues);
|
|
60
|
+
if (issues.length > 0) {
|
|
61
|
+
throw new OxygenError("invalid_linkedin_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
|
|
62
|
+
}
|
|
63
|
+
return { steps: normalized };
|
|
64
|
+
}
|
|
65
|
+
/** Non-throwing variant for lint surfaces. */
|
|
66
|
+
export function lintLinkedInSequenceDefinition(input) {
|
|
67
|
+
try {
|
|
68
|
+
validateLinkedInSequenceDefinition(input);
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof OxygenError && error.details && typeof error.details === "object") {
|
|
73
|
+
const issues = error.details.issues;
|
|
74
|
+
if (Array.isArray(issues))
|
|
75
|
+
return issues;
|
|
76
|
+
}
|
|
77
|
+
return [{ path: "steps", message: error instanceof Error ? error.message : "Invalid sequence." }];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function collectSteps(input, issues) {
|
|
81
|
+
const record = isRecord(input) ? input : null;
|
|
82
|
+
const steps = record?.steps;
|
|
83
|
+
if (!Array.isArray(steps)) {
|
|
84
|
+
issues.push({ path: "steps", message: "steps must be an array." });
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
if (steps.length === 0) {
|
|
88
|
+
issues.push({ path: "steps", message: "A sequence needs at least one step." });
|
|
89
|
+
}
|
|
90
|
+
if (steps.length > MAX_STEPS) {
|
|
91
|
+
issues.push({ path: "steps", message: `A sequence may have at most ${MAX_STEPS} steps.` });
|
|
92
|
+
}
|
|
93
|
+
return steps;
|
|
94
|
+
}
|
|
95
|
+
function normalizeStep(// skipcq: JS-R1005 -- step normalization validates a discriminated sequence DSL with per-step fields.
|
|
96
|
+
raw, index, issues) {
|
|
97
|
+
const path = `steps[${index}]`;
|
|
98
|
+
if (!isRecord(raw)) {
|
|
99
|
+
issues.push({ path, message: "Each step must be an object." });
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const kind = raw.kind;
|
|
103
|
+
if (typeof kind !== "string" || !LINKEDIN_SEQUENCE_STEP_KINDS.includes(kind)) {
|
|
104
|
+
issues.push({
|
|
105
|
+
path: `${path}.kind`,
|
|
106
|
+
message: `kind must be one of: ${LINKEDIN_SEQUENCE_STEP_KINDS.join(", ")}.`,
|
|
107
|
+
});
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case "visit_profile":
|
|
112
|
+
return { kind: "visit_profile" };
|
|
113
|
+
case "invite": {
|
|
114
|
+
const note = optionalTemplate(raw.note_template, `${path}.note_template`, MAX_NOTE_LENGTH, issues);
|
|
115
|
+
return note !== undefined ? { kind: "invite", note_template: note } : { kind: "invite" };
|
|
116
|
+
}
|
|
117
|
+
case "wait_for_connection": {
|
|
118
|
+
// timeout_days is optional (defaults to 14); only validate when provided.
|
|
119
|
+
const timeoutDays = raw.timeout_days === undefined || raw.timeout_days === null
|
|
120
|
+
? undefined
|
|
121
|
+
: positiveInt(raw.timeout_days, `${path}.timeout_days`, issues);
|
|
122
|
+
const onTimeout = raw.on_timeout;
|
|
123
|
+
if (onTimeout !== undefined && onTimeout !== "stop" && onTimeout !== "continue") {
|
|
124
|
+
issues.push({ path: `${path}.on_timeout`, message: "on_timeout must be 'stop' or 'continue'." });
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
kind: "wait_for_connection",
|
|
128
|
+
timeout_days: timeoutDays ?? 14,
|
|
129
|
+
on_timeout: onTimeout === "continue" ? "continue" : "stop",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
case "wait": {
|
|
133
|
+
const days = optionalNonNegativeInt(raw.days, `${path}.days`, issues);
|
|
134
|
+
const hours = optionalNonNegativeInt(raw.hours, `${path}.hours`, issues);
|
|
135
|
+
if ((days ?? 0) + (hours ?? 0) <= 0) {
|
|
136
|
+
issues.push({ path, message: "A wait step needs days and/or hours totaling at least 1 hour." });
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
kind: "wait",
|
|
140
|
+
...(days !== undefined ? { days } : {}),
|
|
141
|
+
...(hours !== undefined ? { hours } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
case "message": {
|
|
145
|
+
const template = requiredTemplate(raw.template, `${path}.template`, issues);
|
|
146
|
+
return { kind: "message", template: template ?? "" };
|
|
147
|
+
}
|
|
148
|
+
case "inmail": {
|
|
149
|
+
const subject = requiredTemplate(raw.subject_template, `${path}.subject_template`, issues);
|
|
150
|
+
const template = requiredTemplate(raw.template, `${path}.template`, issues);
|
|
151
|
+
return { kind: "inmail", subject_template: subject ?? "", template: template ?? "" };
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Structural rules across steps:
|
|
159
|
+
* - The first send step must be invite or visit_profile (you can't message a
|
|
160
|
+
* stranger without connecting); a message before any invite/wait_for_connection
|
|
161
|
+
* is valid for warm lists whose leads are already 1st-degree connections.
|
|
162
|
+
* - wait_for_connection must be preceded by an invite.
|
|
163
|
+
* - Two consecutive wait/wait_for_connection steps are pointless.
|
|
164
|
+
*/
|
|
165
|
+
function validateStructure(steps, issues) {
|
|
166
|
+
let sawInvite = false;
|
|
167
|
+
steps.forEach((step, index) => {
|
|
168
|
+
if (step.kind === "invite")
|
|
169
|
+
sawInvite = true;
|
|
170
|
+
if (step.kind === "wait_for_connection" && !sawInvite) {
|
|
171
|
+
issues.push({
|
|
172
|
+
path: `steps[${index}]`,
|
|
173
|
+
message: "wait_for_connection must come after an invite step.",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (step.kind === "message" && !sawInvite && !steps.slice(0, index).some((s) => s.kind === "wait_for_connection")) {
|
|
177
|
+
// A message before connecting only works for existing 1st-degree
|
|
178
|
+
// connections. This is valid for warm lists; do not add a fatal issue
|
|
179
|
+
// until the API has a separate warning channel.
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function requiredTemplate(value, path, issues) {
|
|
184
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
185
|
+
issues.push({ path, message: "is required and must be a non-empty string." });
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
if (value.length > MAX_TEMPLATE_LENGTH) {
|
|
189
|
+
issues.push({ path, message: `must be at most ${MAX_TEMPLATE_LENGTH} characters.` });
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function optionalTemplate(value, path, maxLength, issues) {
|
|
195
|
+
if (value === undefined || value === null)
|
|
196
|
+
return undefined;
|
|
197
|
+
if (typeof value !== "string") {
|
|
198
|
+
issues.push({ path, message: "must be a string." });
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
if (value.length > maxLength) {
|
|
202
|
+
issues.push({ path, message: `must be at most ${maxLength} characters.` });
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
function positiveInt(value, path, issues) {
|
|
208
|
+
const num = Number(value);
|
|
209
|
+
if (!Number.isInteger(num) || num <= 0) {
|
|
210
|
+
issues.push({ path, message: "must be a positive integer." });
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
return num;
|
|
214
|
+
}
|
|
215
|
+
function optionalNonNegativeInt(value, path, issues) {
|
|
216
|
+
if (value === undefined || value === null)
|
|
217
|
+
return undefined;
|
|
218
|
+
const num = Number(value);
|
|
219
|
+
if (!Number.isInteger(num) || num < 0) {
|
|
220
|
+
issues.push({ path, message: "must be a non-negative integer." });
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return num;
|
|
224
|
+
}
|
|
225
|
+
/** Total delay in milliseconds a `wait` step introduces. */
|
|
226
|
+
export function waitStepDelayMs(step) {
|
|
227
|
+
const days = step.days ?? 0;
|
|
228
|
+
const hours = step.hours ?? 0;
|
|
229
|
+
return (days * 24 + hours) * 60 * 60 * 1000;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Render a {{column}} template against a row's values. Unknown placeholders
|
|
233
|
+
* render empty. Used by the dispatch engine to produce the final message text.
|
|
234
|
+
*/
|
|
235
|
+
export function renderLinkedInTemplate(template, values) {
|
|
236
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, key) => {
|
|
237
|
+
const value = values[key];
|
|
238
|
+
if (value === null || value === undefined)
|
|
239
|
+
return "";
|
|
240
|
+
return typeof value === "string" ? value : String(value);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/** Column keys referenced by {{...}} placeholders across all steps. */
|
|
244
|
+
export function linkedInTemplateVariables(definition) {
|
|
245
|
+
const vars = new Set();
|
|
246
|
+
const scan = (template) => {
|
|
247
|
+
if (!template)
|
|
248
|
+
return;
|
|
249
|
+
for (const match of template.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)) {
|
|
250
|
+
if (match[1])
|
|
251
|
+
vars.add(match[1]);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
for (const step of definition.steps) {
|
|
255
|
+
if (step.kind === "invite")
|
|
256
|
+
scan(step.note_template);
|
|
257
|
+
if (step.kind === "message")
|
|
258
|
+
scan(step.template);
|
|
259
|
+
if (step.kind === "inmail") {
|
|
260
|
+
scan(step.subject_template);
|
|
261
|
+
scan(step.template);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return [...vars];
|
|
265
|
+
}
|
|
266
|
+
function isRecord(value) {
|
|
267
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
268
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const OXYGEN_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_VERSION = "1.152.15";
|
|
2
2
|
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.135.0";
|