@oxygen-agent/cli 1.246.0 → 1.256.13
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/index.js +996 -20
- package/node_modules/@oxygen/shared/dist/directory.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/directory.js +34 -0
- package/node_modules/@oxygen/shared/dist/email-tracking-token.d.ts +82 -0
- package/node_modules/@oxygen/shared/dist/email-tracking-token.js +130 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/index.js +3 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +55 -8
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +25 -15
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +234 -12
- package/node_modules/@oxygen/shared/dist/sequences.js +423 -28
- 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
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const AGENCY_DIRECTORY_SERVICES: readonly ["Cold Email", "LinkedIn Outbound", "Lead Sourcing & Enrichment", "GTM Engineering", "Outreach Strategy & Consulting", "Copywriting & Messaging", "Deliverability & Infrastructure", "RevOps & CRM", "Campaign Management", "Training & Coaching"];
|
|
2
|
+
export type AgencyDirectoryService = (typeof AGENCY_DIRECTORY_SERVICES)[number];
|
|
3
|
+
export declare const AGENCY_DIRECTORY_REGIONS: readonly ["North America", "LATAM", "UK & Ireland", "DACH", "Nordics", "Europe", "APAC", "Middle East & Africa", "Global"];
|
|
4
|
+
export type AgencyDirectoryRegion = (typeof AGENCY_DIRECTORY_REGIONS)[number];
|
|
5
|
+
/** Case-insensitive lookup of a facet value's canonical casing. */
|
|
6
|
+
export declare function canonicalDirectoryFacetValue(options: readonly string[], value: string): string | null;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Curated facet options for the public agency directory. Listings multi-select
|
|
2
|
+
// from these fixed sets (validated server-side) so the public /agencies
|
|
3
|
+
// filters never fragment into free-text variants. Modeled on Instantly's and
|
|
4
|
+
// SmartLead's fixed service taxonomies, tuned to OXYGEN's GTM-engineering ICP.
|
|
5
|
+
export const AGENCY_DIRECTORY_SERVICES = [
|
|
6
|
+
"Cold Email",
|
|
7
|
+
"LinkedIn Outbound",
|
|
8
|
+
"Lead Sourcing & Enrichment",
|
|
9
|
+
"GTM Engineering",
|
|
10
|
+
"Outreach Strategy & Consulting",
|
|
11
|
+
"Copywriting & Messaging",
|
|
12
|
+
"Deliverability & Infrastructure",
|
|
13
|
+
"RevOps & CRM",
|
|
14
|
+
"Campaign Management",
|
|
15
|
+
"Training & Coaching",
|
|
16
|
+
];
|
|
17
|
+
export const AGENCY_DIRECTORY_REGIONS = [
|
|
18
|
+
"North America",
|
|
19
|
+
"LATAM",
|
|
20
|
+
"UK & Ireland",
|
|
21
|
+
"DACH",
|
|
22
|
+
"Nordics",
|
|
23
|
+
"Europe",
|
|
24
|
+
"APAC",
|
|
25
|
+
"Middle East & Africa",
|
|
26
|
+
"Global",
|
|
27
|
+
];
|
|
28
|
+
/** Case-insensitive lookup of a facet value's canonical casing. */
|
|
29
|
+
export function canonicalDirectoryFacetValue(options, value) {
|
|
30
|
+
const normalized = value.trim().toLowerCase();
|
|
31
|
+
if (!normalized)
|
|
32
|
+
return null;
|
|
33
|
+
return options.find((option) => option.toLowerCase() === normalized) ?? null;
|
|
34
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed token for native open/click tracking on cold email. The dispatcher MINTS
|
|
3
|
+
* the token at send time (one per tracked action) and bakes it into a tracking
|
|
4
|
+
* pixel URL (open) or a rewritten link (click); the public /api/t/* routes VERIFY
|
|
5
|
+
* it. The token is the ONLY credential those anonymous endpoints trust, so it
|
|
6
|
+
* binds org + action_id + kind — and, for a click, the SHA-256 hash of the exact
|
|
7
|
+
* destination URL, so a validly-signed click token can never be turned into an
|
|
8
|
+
* open-redirect to an arbitrary host: the route recomputes the hash of the `u`
|
|
9
|
+
* query param and refuses a mismatch.
|
|
10
|
+
*
|
|
11
|
+
* Kill switch / fail-closed: the signing key (EMAIL_TRACKING_SECRET, Doppler
|
|
12
|
+
* oxygen-shared) is read by both the dispatcher (mint) and web (verify). When it
|
|
13
|
+
* is UNSET, injection is DISABLED (no pixel, no link rewrite) and the public
|
|
14
|
+
* routes serve a benign 1x1 gif / plain redirect WITHOUT recording — never a
|
|
15
|
+
* crash, never a silent paid call.
|
|
16
|
+
*
|
|
17
|
+
* Layering mirrors email-unsubscribe-token.ts: pure crypto in @oxygen/shared so
|
|
18
|
+
* the worker/integrations mint path and the web verify route share one
|
|
19
|
+
* implementation, with no env policy baked in (the caller decides what null means).
|
|
20
|
+
*/
|
|
21
|
+
export type EmailTrackingKind = "open" | "click";
|
|
22
|
+
export type EmailTrackingTokenPayload = {
|
|
23
|
+
/** Schema version; only v1 is accepted. */
|
|
24
|
+
v: 1;
|
|
25
|
+
/** Organization id (also the tenant the public route resolves + records into). */
|
|
26
|
+
org: string;
|
|
27
|
+
/** The action id the send belongs to — provenance for the recorded event. */
|
|
28
|
+
act: string;
|
|
29
|
+
/** open (pixel) or click (rewritten link). */
|
|
30
|
+
kind: EmailTrackingKind;
|
|
31
|
+
/** Click only: SHA-256 hex of the destination URL the token authorizes. */
|
|
32
|
+
uh?: string;
|
|
33
|
+
/** Sequence id, for provenance / signal advance. */
|
|
34
|
+
seq?: string;
|
|
35
|
+
/** Enrollment id, so open/click advances the exact journey. */
|
|
36
|
+
enr?: string;
|
|
37
|
+
/** Issued-at (epoch ms), recorded as provenance. */
|
|
38
|
+
iat: number;
|
|
39
|
+
};
|
|
40
|
+
type EnvLike = Record<string, string | undefined>;
|
|
41
|
+
/** SHA-256 hex of a destination URL — the value bound into a click token's `uh`. */
|
|
42
|
+
export declare function hashTrackingUrl(url: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Sign a payload into `<base64url(json)>.<base64url(hmac-sha256)>`. The HMAC is
|
|
45
|
+
* computed over the encoded body, so any tamper invalidates the signature.
|
|
46
|
+
*/
|
|
47
|
+
export declare function signEmailTrackingToken(payload: EmailTrackingTokenPayload, secret: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Verify a token's signature (constant-time) and shape. Returns the payload only
|
|
50
|
+
* when the signature matches AND it is a well-formed v1 payload; null on ANY
|
|
51
|
+
* problem (bad shape, blank/wrong secret, tampered body). Pure: the route maps
|
|
52
|
+
* null -> benign gif / plain redirect with no recording.
|
|
53
|
+
*/
|
|
54
|
+
export declare function verifyEmailTrackingToken(token: string, secret: string): EmailTrackingTokenPayload | null;
|
|
55
|
+
/**
|
|
56
|
+
* Verify a click token AND that the supplied destination URL is the one it
|
|
57
|
+
* authorizes (constant-time hash compare). Returns the payload only when both the
|
|
58
|
+
* signature and the url-hash match; null otherwise. This is what stops the click
|
|
59
|
+
* route from being an open redirect — only the exact signed URL is honored.
|
|
60
|
+
*/
|
|
61
|
+
export declare function verifyEmailClickToken(token: string, destinationUrl: string, secret: string): EmailTrackingTokenPayload | null;
|
|
62
|
+
/**
|
|
63
|
+
* The HMAC signing secret for tracking tokens, or null when unset. This is the
|
|
64
|
+
* kill switch: null means "tracking disabled" everywhere — the dispatcher skips
|
|
65
|
+
* injection and the public routes serve a benign response without recording.
|
|
66
|
+
*/
|
|
67
|
+
export declare function emailTrackingSigningSecret(env?: EnvLike): string | null;
|
|
68
|
+
/**
|
|
69
|
+
* The public app base URL tracking links point at, trailing slash stripped.
|
|
70
|
+
* Prefers NEXT_PUBLIC_APP_URL, then OXYGEN_APP_URL, then the prod host. (A
|
|
71
|
+
* verified per-org tracking domain is used at send time when present; this is the
|
|
72
|
+
* fallback the token routes are always reachable at.)
|
|
73
|
+
*/
|
|
74
|
+
export declare function emailTrackingAppBaseUrl(env?: EnvLike): string;
|
|
75
|
+
/** The open-pixel URL for a signed token: `<base>/api/t/o/<token>`. */
|
|
76
|
+
export declare function buildOpenPixelUrl(token: string, baseUrl: string): string;
|
|
77
|
+
/**
|
|
78
|
+
* The click-tracking URL for a signed token + destination: the destination rides
|
|
79
|
+
* in the `u` query param (the route recomputes its hash against the token's `uh`).
|
|
80
|
+
*/
|
|
81
|
+
export declare function buildClickTrackingUrl(token: string, destinationUrl: string, baseUrl: string): string;
|
|
82
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
function base64urlEncode(value) {
|
|
3
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
4
|
+
}
|
|
5
|
+
/** SHA-256 hex of a destination URL — the value bound into a click token's `uh`. */
|
|
6
|
+
export function hashTrackingUrl(url) {
|
|
7
|
+
return createHash("sha256").update(url, "utf8").digest("hex");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Sign a payload into `<base64url(json)>.<base64url(hmac-sha256)>`. The HMAC is
|
|
11
|
+
* computed over the encoded body, so any tamper invalidates the signature.
|
|
12
|
+
*/
|
|
13
|
+
export function signEmailTrackingToken(payload, secret) {
|
|
14
|
+
const body = base64urlEncode(JSON.stringify(payload));
|
|
15
|
+
const signature = createHmac("sha256", secret).update(body).digest("base64url");
|
|
16
|
+
return `${body}.${signature}`;
|
|
17
|
+
}
|
|
18
|
+
/** Constant-time HMAC-SHA256 check; false on any error (bad base64, length mismatch). */
|
|
19
|
+
function verifySignature(body, providedSig, secret) {
|
|
20
|
+
const expected = createHmac("sha256", secret).update(body).digest();
|
|
21
|
+
let provided;
|
|
22
|
+
try {
|
|
23
|
+
provided = Buffer.from(providedSig, "base64url");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
|
29
|
+
}
|
|
30
|
+
/** Decode + validate the base64url body; typed payload or null on any problem. */
|
|
31
|
+
function parseTokenPayload(body) {
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (!parsed || typeof parsed !== "object")
|
|
40
|
+
return null;
|
|
41
|
+
const candidate = parsed;
|
|
42
|
+
if (candidate.v !== 1)
|
|
43
|
+
return null;
|
|
44
|
+
const org = typeof candidate.org === "string" ? candidate.org.trim() : "";
|
|
45
|
+
const act = typeof candidate.act === "string" ? candidate.act.trim() : "";
|
|
46
|
+
const kind = candidate.kind === "open" || candidate.kind === "click" ? candidate.kind : null;
|
|
47
|
+
if (!org || !act || !kind)
|
|
48
|
+
return null;
|
|
49
|
+
// A click token MUST bind a url hash; an open token must NOT (so a click token
|
|
50
|
+
// can't be replayed against the open route, or vice versa, to skip the check).
|
|
51
|
+
const uh = typeof candidate.uh === "string" && candidate.uh ? candidate.uh : undefined;
|
|
52
|
+
if (kind === "click" && !uh)
|
|
53
|
+
return null;
|
|
54
|
+
if (kind === "open" && uh)
|
|
55
|
+
return null;
|
|
56
|
+
return {
|
|
57
|
+
v: 1,
|
|
58
|
+
org,
|
|
59
|
+
act,
|
|
60
|
+
kind,
|
|
61
|
+
...(uh ? { uh } : {}),
|
|
62
|
+
...(typeof candidate.seq === "string" && candidate.seq ? { seq: candidate.seq } : {}),
|
|
63
|
+
...(typeof candidate.enr === "string" && candidate.enr ? { enr: candidate.enr } : {}),
|
|
64
|
+
iat: typeof candidate.iat === "number" && Number.isFinite(candidate.iat) ? candidate.iat : 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Verify a token's signature (constant-time) and shape. Returns the payload only
|
|
69
|
+
* when the signature matches AND it is a well-formed v1 payload; null on ANY
|
|
70
|
+
* problem (bad shape, blank/wrong secret, tampered body). Pure: the route maps
|
|
71
|
+
* null -> benign gif / plain redirect with no recording.
|
|
72
|
+
*/
|
|
73
|
+
export function verifyEmailTrackingToken(token, secret) {
|
|
74
|
+
if (typeof token !== "string" || typeof secret !== "string" || !secret)
|
|
75
|
+
return null;
|
|
76
|
+
const dot = token.indexOf(".");
|
|
77
|
+
if (dot <= 0 || dot === token.length - 1)
|
|
78
|
+
return null;
|
|
79
|
+
const body = token.slice(0, dot);
|
|
80
|
+
const providedSig = token.slice(dot + 1);
|
|
81
|
+
if (!verifySignature(body, providedSig, secret))
|
|
82
|
+
return null;
|
|
83
|
+
return parseTokenPayload(body);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Verify a click token AND that the supplied destination URL is the one it
|
|
87
|
+
* authorizes (constant-time hash compare). Returns the payload only when both the
|
|
88
|
+
* signature and the url-hash match; null otherwise. This is what stops the click
|
|
89
|
+
* route from being an open redirect — only the exact signed URL is honored.
|
|
90
|
+
*/
|
|
91
|
+
export function verifyEmailClickToken(token, destinationUrl, secret) {
|
|
92
|
+
const payload = verifyEmailTrackingToken(token, secret);
|
|
93
|
+
if (!payload || payload.kind !== "click" || !payload.uh)
|
|
94
|
+
return null;
|
|
95
|
+
const expected = Buffer.from(payload.uh, "utf8");
|
|
96
|
+
const actual = Buffer.from(hashTrackingUrl(destinationUrl), "utf8");
|
|
97
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
|
|
98
|
+
return null;
|
|
99
|
+
return payload;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The HMAC signing secret for tracking tokens, or null when unset. This is the
|
|
103
|
+
* kill switch: null means "tracking disabled" everywhere — the dispatcher skips
|
|
104
|
+
* injection and the public routes serve a benign response without recording.
|
|
105
|
+
*/
|
|
106
|
+
export function emailTrackingSigningSecret(env = process.env) {
|
|
107
|
+
const secret = env.EMAIL_TRACKING_SECRET?.trim();
|
|
108
|
+
return secret ? secret : null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The public app base URL tracking links point at, trailing slash stripped.
|
|
112
|
+
* Prefers NEXT_PUBLIC_APP_URL, then OXYGEN_APP_URL, then the prod host. (A
|
|
113
|
+
* verified per-org tracking domain is used at send time when present; this is the
|
|
114
|
+
* fallback the token routes are always reachable at.)
|
|
115
|
+
*/
|
|
116
|
+
export function emailTrackingAppBaseUrl(env = process.env) {
|
|
117
|
+
const raw = (env.NEXT_PUBLIC_APP_URL || env.OXYGEN_APP_URL || "https://oxygen-agent.com").trim();
|
|
118
|
+
return raw.replace(/\/+$/, "");
|
|
119
|
+
}
|
|
120
|
+
/** The open-pixel URL for a signed token: `<base>/api/t/o/<token>`. */
|
|
121
|
+
export function buildOpenPixelUrl(token, baseUrl) {
|
|
122
|
+
return `${baseUrl.replace(/\/+$/, "")}/api/t/o/${encodeURIComponent(token)}`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The click-tracking URL for a signed token + destination: the destination rides
|
|
126
|
+
* in the `u` query param (the route recomputes its hash against the token's `uh`).
|
|
127
|
+
*/
|
|
128
|
+
export function buildClickTrackingUrl(token, destinationUrl, baseUrl) {
|
|
129
|
+
return `${baseUrl.replace(/\/+$/, "")}/api/t/c/${encodeURIComponent(token)}?u=${encodeURIComponent(destinationUrl)}`;
|
|
130
|
+
}
|
|
@@ -7,10 +7,13 @@ export * from "./cli-envelope.js";
|
|
|
7
7
|
export * from "./cli-result.js";
|
|
8
8
|
export * from "./column-types.js";
|
|
9
9
|
export * from "./credit-guidance.js";
|
|
10
|
+
export * from "./directory.js";
|
|
11
|
+
export * from "./email-tracking-token.js";
|
|
10
12
|
export * from "./email-unsubscribe-token.js";
|
|
11
13
|
export * from "./linkedin-post-url.js";
|
|
12
14
|
export * from "./linkedin-sequences.js";
|
|
13
15
|
export * from "./networks.js";
|
|
16
|
+
export * from "./sequence-template.js";
|
|
14
17
|
export * from "./sequences.js";
|
|
15
18
|
export * from "./log.js";
|
|
16
19
|
export * from "./provider-request-outcomes.js";
|
|
@@ -7,10 +7,13 @@ export * from "./cli-envelope.js";
|
|
|
7
7
|
export * from "./cli-result.js";
|
|
8
8
|
export * from "./column-types.js";
|
|
9
9
|
export * from "./credit-guidance.js";
|
|
10
|
+
export * from "./directory.js";
|
|
11
|
+
export * from "./email-tracking-token.js";
|
|
10
12
|
export * from "./email-unsubscribe-token.js";
|
|
11
13
|
export * from "./linkedin-post-url.js";
|
|
12
14
|
export * from "./linkedin-sequences.js";
|
|
13
15
|
export * from "./networks.js";
|
|
16
|
+
export * from "./sequence-template.js";
|
|
14
17
|
export * from "./sequences.js";
|
|
15
18
|
export * from "./log.js";
|
|
16
19
|
export * from "./provider-request-outcomes.js";
|
|
@@ -16,10 +16,19 @@
|
|
|
16
16
|
* - message — send a message; template supports {{column}} interp
|
|
17
17
|
* - inmail — send an InMail (works on non-connections)
|
|
18
18
|
*/
|
|
19
|
-
|
|
19
|
+
import { type RenderTemplateOptions } from "./sequence-template.js";
|
|
20
|
+
export declare const LINKEDIN_SEQUENCE_STEP_KINDS: readonly ["visit_profile", "invite", "wait_for_connection", "wait", "message", "inmail", "follow", "like_post", "comment_post", "withdraw_invite", "branch", "stop"];
|
|
20
21
|
export type LinkedInSequenceStepKind = typeof LINKEDIN_SEQUENCE_STEP_KINDS[number];
|
|
21
|
-
/**
|
|
22
|
-
export declare const
|
|
22
|
+
/** LinkedIn reaction types for a `like_post` step. */
|
|
23
|
+
export declare const LINKEDIN_POST_REACTIONS: readonly ["like", "celebrate", "support", "funny", "love", "insightful"];
|
|
24
|
+
export type LinkedInPostReaction = typeof LINKEDIN_POST_REACTIONS[number];
|
|
25
|
+
/**
|
|
26
|
+
* Conditions a `branch` step routes on. All are LinkedIn-signal driven.
|
|
27
|
+
* - connection_accepted / already_connected: connection-degree gates.
|
|
28
|
+
* - open_profile: is the lead an Open Profile (free InMail target)? Lets a
|
|
29
|
+
* sequence route `branch open_profile → inmail : invite` (the Expandi play).
|
|
30
|
+
*/
|
|
31
|
+
export declare const LINKEDIN_BRANCH_CONDITIONS: readonly ["connection_accepted", "already_connected", "open_profile"];
|
|
23
32
|
export type LinkedInBranchCondition = typeof LINKEDIN_BRANCH_CONDITIONS[number];
|
|
24
33
|
/** Every step carries a stable id so branch edges can target it by reference. */
|
|
25
34
|
export type LinkedInStepBase = {
|
|
@@ -50,6 +59,11 @@ export type LinkedInMessageStep = LinkedInStepBase & {
|
|
|
50
59
|
kind: "message";
|
|
51
60
|
/** Message body. Supports {{column}} interpolation from the source-table row. */
|
|
52
61
|
template: string;
|
|
62
|
+
/** Files attached to the message (image / document), fetched + uploaded at send. */
|
|
63
|
+
attachments?: {
|
|
64
|
+
url: string;
|
|
65
|
+
name?: string;
|
|
66
|
+
}[];
|
|
53
67
|
};
|
|
54
68
|
export type LinkedInInMailStep = LinkedInStepBase & {
|
|
55
69
|
kind: "inmail";
|
|
@@ -86,7 +100,34 @@ export type LinkedInBranchStep = LinkedInStepBase & {
|
|
|
86
100
|
export type LinkedInStopStep = LinkedInStepBase & {
|
|
87
101
|
kind: "stop";
|
|
88
102
|
};
|
|
89
|
-
|
|
103
|
+
/** Follow the lead — no connection required. A light warm-up touch. */
|
|
104
|
+
export type LinkedInFollowStep = LinkedInStepBase & {
|
|
105
|
+
kind: "follow";
|
|
106
|
+
};
|
|
107
|
+
/** Post-targeting steps share how they pick which of the lead's posts to act on. */
|
|
108
|
+
type LinkedInPostTargetingBase = {
|
|
109
|
+
/** Only act on a post published within this many days. Defaults to 30. */
|
|
110
|
+
post_recency_days?: number;
|
|
111
|
+
/** What to do when the lead has no recent post to act on. Defaults to "skip". */
|
|
112
|
+
on_no_post?: "skip" | "stop";
|
|
113
|
+
};
|
|
114
|
+
export type LinkedInLikePostStep = LinkedInStepBase & LinkedInPostTargetingBase & {
|
|
115
|
+
kind: "like_post";
|
|
116
|
+
/** Reaction to leave. Defaults to "like". */
|
|
117
|
+
reaction?: LinkedInPostReaction;
|
|
118
|
+
};
|
|
119
|
+
export type LinkedInCommentPostStep = LinkedInStepBase & LinkedInPostTargetingBase & {
|
|
120
|
+
kind: "comment_post";
|
|
121
|
+
/** Comment text with {{column}} interpolation. Provide exactly one of this or ai_prompt. */
|
|
122
|
+
text_template?: string;
|
|
123
|
+
/** Prompt for an AI-generated comment (rendered against the row + post). Exactly one of this or text_template. */
|
|
124
|
+
ai_prompt?: string;
|
|
125
|
+
};
|
|
126
|
+
/** Withdraw the still-pending connection invite to this lead (a no-op if none is pending). */
|
|
127
|
+
export type LinkedInWithdrawInviteStep = LinkedInStepBase & {
|
|
128
|
+
kind: "withdraw_invite";
|
|
129
|
+
};
|
|
130
|
+
export type LinkedInSequenceStep = LinkedInVisitProfileStep | LinkedInInviteStep | LinkedInWaitForConnectionStep | LinkedInWaitStep | LinkedInMessageStep | LinkedInInMailStep | LinkedInFollowStep | LinkedInLikePostStep | LinkedInCommentPostStep | LinkedInWithdrawInviteStep | LinkedInBranchStep | LinkedInStopStep;
|
|
90
131
|
export type LinkedInSequenceDefinition = {
|
|
91
132
|
steps: LinkedInSequenceStep[];
|
|
92
133
|
};
|
|
@@ -96,11 +137,17 @@ export type LinkedInSequenceDefinition = {
|
|
|
96
137
|
* that dispatch nothing. NOTE: this is the *action* kind, not the *quota* kind
|
|
97
138
|
* — visit_profile maps to the quota kind profile_view downstream.
|
|
98
139
|
*/
|
|
99
|
-
export
|
|
140
|
+
export type LinkedInStepActionKind = "visit_profile" | "invite" | "message" | "inmail" | "follow" | "like_post" | "comment_post" | "withdraw_invite";
|
|
141
|
+
export declare const LINKEDIN_STEP_ACTION_KIND: Record<LinkedInSequenceStepKind, LinkedInStepActionKind | null>;
|
|
100
142
|
/** Total delay in milliseconds a `wait` step introduces. */
|
|
101
143
|
export declare function waitStepDelayMs(step: LinkedInWaitStep): number;
|
|
102
144
|
/**
|
|
103
|
-
* Render a
|
|
104
|
-
*
|
|
145
|
+
* Render a sequence-copy template against a row's values. Delegates to the shared
|
|
146
|
+
* deterministic engine, so beyond `{{column}}` substitution it also handles
|
|
147
|
+
* `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and `{% if … %}` conditionals.
|
|
148
|
+
* Unknown `{{column}}` placeholders render empty. Used by the dispatch engine to
|
|
149
|
+
* produce the final message text. Pass `{ seed }` to make spintax choices
|
|
150
|
+
* replayable (crash-safe); with no seed a stable template+values seed is derived.
|
|
105
151
|
*/
|
|
106
|
-
export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown
|
|
152
|
+
export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown>, options?: RenderTemplateOptions): string;
|
|
153
|
+
export {};
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - message — send a message; template supports {{column}} interp
|
|
17
17
|
* - inmail — send an InMail (works on non-connections)
|
|
18
18
|
*/
|
|
19
|
+
import { renderTemplate } from "./sequence-template.js";
|
|
19
20
|
export const LINKEDIN_SEQUENCE_STEP_KINDS = [
|
|
20
21
|
"visit_profile",
|
|
21
22
|
"invite",
|
|
@@ -23,17 +24,23 @@ export const LINKEDIN_SEQUENCE_STEP_KINDS = [
|
|
|
23
24
|
"wait",
|
|
24
25
|
"message",
|
|
25
26
|
"inmail",
|
|
27
|
+
// Social-graph steps (the "act like a human warming up a lead" palette):
|
|
28
|
+
"follow", // follow the lead (no connection needed) — a light touch before an invite
|
|
29
|
+
"like_post", // react to the lead's most recent post
|
|
30
|
+
"comment_post", // comment on the lead's most recent post (template or AI-generated)
|
|
31
|
+
"withdraw_invite", // withdraw a still-pending connection invite to this lead
|
|
26
32
|
"branch",
|
|
27
33
|
"stop",
|
|
28
34
|
];
|
|
29
|
-
/**
|
|
30
|
-
export const
|
|
35
|
+
/** LinkedIn reaction types for a `like_post` step. */
|
|
36
|
+
export const LINKEDIN_POST_REACTIONS = ["like", "celebrate", "support", "funny", "love", "insightful"];
|
|
31
37
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
38
|
+
* Conditions a `branch` step routes on. All are LinkedIn-signal driven.
|
|
39
|
+
* - connection_accepted / already_connected: connection-degree gates.
|
|
40
|
+
* - open_profile: is the lead an Open Profile (free InMail target)? Lets a
|
|
41
|
+
* sequence route `branch open_profile → inmail : invite` (the Expandi play).
|
|
36
42
|
*/
|
|
43
|
+
export const LINKEDIN_BRANCH_CONDITIONS = ["connection_accepted", "already_connected", "open_profile"];
|
|
37
44
|
export const LINKEDIN_STEP_ACTION_KIND = {
|
|
38
45
|
visit_profile: "visit_profile",
|
|
39
46
|
invite: "invite",
|
|
@@ -41,6 +48,10 @@ export const LINKEDIN_STEP_ACTION_KIND = {
|
|
|
41
48
|
wait: null,
|
|
42
49
|
message: "message",
|
|
43
50
|
inmail: "inmail",
|
|
51
|
+
follow: "follow",
|
|
52
|
+
like_post: "like_post",
|
|
53
|
+
comment_post: "comment_post",
|
|
54
|
+
withdraw_invite: "withdraw_invite",
|
|
44
55
|
branch: null,
|
|
45
56
|
stop: null,
|
|
46
57
|
};
|
|
@@ -51,14 +62,13 @@ export function waitStepDelayMs(step) {
|
|
|
51
62
|
return (days * 24 + hours) * 60 * 60 * 1000;
|
|
52
63
|
}
|
|
53
64
|
/**
|
|
54
|
-
* Render a
|
|
55
|
-
*
|
|
65
|
+
* Render a sequence-copy template against a row's values. Delegates to the shared
|
|
66
|
+
* deterministic engine, so beyond `{{column}}` substitution it also handles
|
|
67
|
+
* `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and `{% if … %}` conditionals.
|
|
68
|
+
* Unknown `{{column}}` placeholders render empty. Used by the dispatch engine to
|
|
69
|
+
* produce the final message text. Pass `{ seed }` to make spintax choices
|
|
70
|
+
* replayable (crash-safe); with no seed a stable template+values seed is derived.
|
|
56
71
|
*/
|
|
57
|
-
export function renderLinkedInTemplate(template, values) {
|
|
58
|
-
return template
|
|
59
|
-
const value = values[key];
|
|
60
|
-
if (value === null || value === undefined)
|
|
61
|
-
return "";
|
|
62
|
-
return typeof value === "string" ? value : String(value);
|
|
63
|
-
});
|
|
72
|
+
export function renderLinkedInTemplate(template, values, options) {
|
|
73
|
+
return renderTemplate(template, values, options);
|
|
64
74
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic sequence-copy template engine. Renders the copy of a sequence
|
|
3
|
+
* step (LinkedIn message / InMail / invite note, native email subject/body,
|
|
4
|
+
* WhatsApp message, Instantly custom fields) against a lead's row values in a
|
|
5
|
+
* SINGLE deterministic pass. Beyond the original `{{column}}` substitution it
|
|
6
|
+
* supports three composable, nestable constructs:
|
|
7
|
+
*
|
|
8
|
+
* 1. Inline fallback — `{{column|fallback text}}` renders the column's value, or
|
|
9
|
+
* the literal fallback text when the column is missing/empty. `{{column}}`
|
|
10
|
+
* with no `|` keeps the original behavior (empty when the column is missing).
|
|
11
|
+
* 2. Spintax — `{{RANDOM|option a|option b|option c}}` renders ONE option chosen
|
|
12
|
+
* deterministically from the seed (never Math.random — retries/replays never
|
|
13
|
+
* drift). Options may themselves contain `{{column}}`/fallback/spintax/
|
|
14
|
+
* conditionals, which resolve AFTER selection.
|
|
15
|
+
* 3. Conditional blocks — `{% if column %}A{% endif %}` (truthy),
|
|
16
|
+
* `{% if column == "x" %}A{% else %}B{% endif %}` (== / !=), and
|
|
17
|
+
* `{% if column contains "x" %}A{% endif %}` (case-insensitive substring).
|
|
18
|
+
* The `{% else %}` arm and the operator/quoted value are optional; either
|
|
19
|
+
* branch may nest any construct.
|
|
20
|
+
*
|
|
21
|
+
* Determinism contract: the SAME (template, values, seed) always renders
|
|
22
|
+
* byte-identically. The sequencer dispatch passes an enrollment-scoped seed so a
|
|
23
|
+
* crash-replay of a step re-renders identically; with no seed the engine derives
|
|
24
|
+
* a stable seed from the template + values (still deterministic, just not
|
|
25
|
+
* enrollment-scoped). Pure — no I/O, no clock, no randomness.
|
|
26
|
+
*
|
|
27
|
+
* This is the single home for the render engine; `renderLinkedInTemplate`
|
|
28
|
+
* (linkedin-sequences.ts) and `renderSequenceTemplate` (sequences.ts) both
|
|
29
|
+
* delegate here so every surface renders copy identically.
|
|
30
|
+
*/
|
|
31
|
+
export type RenderTemplateOptions = {
|
|
32
|
+
/**
|
|
33
|
+
* Seed for spintax selection. When provided, ALL spintax choices in the
|
|
34
|
+
* template are derived from it, so the same (template, values, seed) renders
|
|
35
|
+
* byte-identically across retries/replays. When omitted, a stable seed is
|
|
36
|
+
* derived from the template + values (still deterministic, not enrollment-scoped).
|
|
37
|
+
*/
|
|
38
|
+
seed?: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Deterministic, replayable FNV-1a hash → uint32. Stable across processes — the
|
|
42
|
+
* single hash used by both spintax selection here and A/B variant assignment in
|
|
43
|
+
* sequences.ts (which imports it from this module).
|
|
44
|
+
*/
|
|
45
|
+
export declare function hashVariantKey(value: string): number;
|
|
46
|
+
/**
|
|
47
|
+
* Render a sequence-copy template against a lead's row values. Supports
|
|
48
|
+
* `{{column}}` substitution, `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and
|
|
49
|
+
* `{% if … %}…{% endif %}` conditionals, in a single deterministic pass. Unknown
|
|
50
|
+
* `{{column}}` placeholders render empty; a malformed placeholder is left literal.
|
|
51
|
+
*/
|
|
52
|
+
export declare function renderTemplate(template: string, values: Record<string, unknown>, options?: RenderTemplateOptions): string;
|
|
53
|
+
/**
|
|
54
|
+
* Every column key a template references — across bare `{{column}}` substitutions,
|
|
55
|
+
* `{{column|fallback}}` fallbacks, `{{RANDOM|…}}` spintax options, and both the
|
|
56
|
+
* condition and branches of `{% if … %}` blocks. Pure scan (no rendering), used by
|
|
57
|
+
* the start-preview variable-resolution check so a column referenced only inside a
|
|
58
|
+
* nested construct still surfaces. Deduplicated.
|
|
59
|
+
*/
|
|
60
|
+
export declare function templateColumnKeys(template: string): string[];
|
|
Binary file
|