bereach-openclaw 0.2.16 → 0.3.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/__tests__/helpers.test.ts +684 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +10 -4
- package/skills/bereach/SKILL.md +10 -6
- package/skills/bereach/sub/lead-magnet.md +66 -15
- package/src/lead-magnet/helpers.ts +171 -0
- package/tsconfig.json +2 -2
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import type { Bereach } from "bereach";
|
|
3
|
+
import {
|
|
4
|
+
collectAllComments,
|
|
5
|
+
listAllInvitations,
|
|
6
|
+
visitProfileIfNeeded,
|
|
7
|
+
findConversationForDmGuard,
|
|
8
|
+
withRetry,
|
|
9
|
+
SkippableError,
|
|
10
|
+
FatalError,
|
|
11
|
+
} from "../src/lead-magnet/helpers";
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.spyOn(globalThis, "setTimeout").mockImplementation(((cb: () => void) => {
|
|
15
|
+
cb();
|
|
16
|
+
return 0;
|
|
17
|
+
}) as unknown as typeof setTimeout);
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ── Mock factory ────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function createMockClient(overrides: Partial<Record<string, Record<string, unknown>>> = {}) {
|
|
26
|
+
return {
|
|
27
|
+
linkedinScrapers: {
|
|
28
|
+
collectComments: vi.fn(),
|
|
29
|
+
visitProfile: vi.fn(),
|
|
30
|
+
...overrides.linkedinScrapers,
|
|
31
|
+
},
|
|
32
|
+
linkedinActions: {
|
|
33
|
+
listInvitations: vi.fn(),
|
|
34
|
+
...overrides.linkedinActions,
|
|
35
|
+
},
|
|
36
|
+
linkedinChat: {
|
|
37
|
+
findConversation: vi.fn(),
|
|
38
|
+
...overrides.linkedinChat,
|
|
39
|
+
},
|
|
40
|
+
campaigns: {
|
|
41
|
+
syncActions: vi.fn(),
|
|
42
|
+
...overrides.campaigns,
|
|
43
|
+
},
|
|
44
|
+
} as unknown as Bereach;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function commentsPage(
|
|
48
|
+
profiles: Array<Record<string, unknown>>,
|
|
49
|
+
opts: { total: number; start: number; hasMore: boolean },
|
|
50
|
+
) {
|
|
51
|
+
return {
|
|
52
|
+
success: true,
|
|
53
|
+
profiles,
|
|
54
|
+
count: profiles.length,
|
|
55
|
+
total: opts.total,
|
|
56
|
+
start: opts.start,
|
|
57
|
+
hasMore: opts.hasMore,
|
|
58
|
+
previousTotal: null,
|
|
59
|
+
creditsUsed: 1,
|
|
60
|
+
retryAfter: 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function invitationsPage(
|
|
65
|
+
invitations: Array<Record<string, unknown>>,
|
|
66
|
+
opts: { total: number; start: number },
|
|
67
|
+
) {
|
|
68
|
+
return {
|
|
69
|
+
success: true,
|
|
70
|
+
invitations,
|
|
71
|
+
total: opts.total,
|
|
72
|
+
start: opts.start,
|
|
73
|
+
count: invitations.length,
|
|
74
|
+
creditsUsed: 1,
|
|
75
|
+
retryAfter: 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── withRetry ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("withRetry", () => {
|
|
82
|
+
it("returns result on success", async () => {
|
|
83
|
+
const result = await withRetry(() => Promise.resolve(42));
|
|
84
|
+
expect(result).toBe(42);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("retries on 429 (statusCode) and succeeds", async () => {
|
|
88
|
+
let calls = 0;
|
|
89
|
+
const result = await withRetry(async () => {
|
|
90
|
+
calls++;
|
|
91
|
+
if (calls < 3) {
|
|
92
|
+
throw Object.assign(new Error("rate limited"), { statusCode: 429 });
|
|
93
|
+
}
|
|
94
|
+
return "ok";
|
|
95
|
+
});
|
|
96
|
+
expect(result).toBe("ok");
|
|
97
|
+
expect(calls).toBe(3);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("retries on 429 (status fallback) and succeeds", async () => {
|
|
101
|
+
let calls = 0;
|
|
102
|
+
const result = await withRetry(async () => {
|
|
103
|
+
calls++;
|
|
104
|
+
if (calls < 2) {
|
|
105
|
+
throw Object.assign(new Error("rate limited"), { status: 429 });
|
|
106
|
+
}
|
|
107
|
+
return "ok";
|
|
108
|
+
});
|
|
109
|
+
expect(result).toBe("ok");
|
|
110
|
+
expect(calls).toBe(2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("throws FatalError on 401", async () => {
|
|
114
|
+
await expect(
|
|
115
|
+
withRetry(() => {
|
|
116
|
+
throw Object.assign(new Error("unauthorized"), { statusCode: 401 });
|
|
117
|
+
}),
|
|
118
|
+
).rejects.toThrow(FatalError);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws FatalError on 404", async () => {
|
|
122
|
+
await expect(
|
|
123
|
+
withRetry(() => {
|
|
124
|
+
throw Object.assign(new Error("not found"), { statusCode: 404 });
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow(FatalError);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("throws FatalError on 405", async () => {
|
|
130
|
+
await expect(
|
|
131
|
+
withRetry(() => {
|
|
132
|
+
throw Object.assign(new Error("not allowed"), { statusCode: 405 });
|
|
133
|
+
}),
|
|
134
|
+
).rejects.toThrow(FatalError);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("throws SkippableError on 400", async () => {
|
|
138
|
+
await expect(
|
|
139
|
+
withRetry(() => {
|
|
140
|
+
throw Object.assign(new Error("bad request"), { statusCode: 400 });
|
|
141
|
+
}),
|
|
142
|
+
).rejects.toThrow(SkippableError);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("throws SkippableError on 500", async () => {
|
|
146
|
+
await expect(
|
|
147
|
+
withRetry(() => {
|
|
148
|
+
throw Object.assign(new Error("server error"), { statusCode: 500 });
|
|
149
|
+
}),
|
|
150
|
+
).rejects.toThrow(SkippableError);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("throws SkippableError after exhausting 429 retries", async () => {
|
|
154
|
+
await expect(
|
|
155
|
+
withRetry(
|
|
156
|
+
() => {
|
|
157
|
+
throw Object.assign(new Error("rate limited"), { statusCode: 429 });
|
|
158
|
+
},
|
|
159
|
+
2,
|
|
160
|
+
),
|
|
161
|
+
).rejects.toThrow(SkippableError);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("throws SkippableError on errors without status", async () => {
|
|
165
|
+
await expect(
|
|
166
|
+
withRetry(() => {
|
|
167
|
+
throw new Error("generic failure");
|
|
168
|
+
}),
|
|
169
|
+
).rejects.toThrow(SkippableError);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── collectAllComments ──────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
describe("collectAllComments", () => {
|
|
176
|
+
it("fetches a single page when hasMore is false", async () => {
|
|
177
|
+
const profiles = [{ name: "Alice", profileUrl: "u/alice" }];
|
|
178
|
+
const client = createMockClient();
|
|
179
|
+
const mock = vi.mocked(client.linkedinScrapers.collectComments);
|
|
180
|
+
mock.mockResolvedValueOnce(commentsPage(profiles, { total: 1, start: 0, hasMore: false }));
|
|
181
|
+
|
|
182
|
+
const result = await collectAllComments(client, "https://post", "slug");
|
|
183
|
+
|
|
184
|
+
expect(result.profiles).toHaveLength(1);
|
|
185
|
+
expect(result.total).toBe(1);
|
|
186
|
+
expect(result.skipped).toBe(false);
|
|
187
|
+
expect(mock).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(mock).toHaveBeenCalledWith({ postUrl: "https://post", start: 0, count: 100, campaignSlug: "slug" });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("paginates across 3 pages", async () => {
|
|
192
|
+
const client = createMockClient();
|
|
193
|
+
const mock = vi.mocked(client.linkedinScrapers.collectComments);
|
|
194
|
+
|
|
195
|
+
const page1 = Array.from({ length: 100 }, (_, i) => ({ name: `p${i}` }));
|
|
196
|
+
const page2 = Array.from({ length: 100 }, (_, i) => ({ name: `p${100 + i}` }));
|
|
197
|
+
const page3 = [{ name: "p200" }, { name: "p201" }];
|
|
198
|
+
|
|
199
|
+
mock
|
|
200
|
+
.mockResolvedValueOnce(commentsPage(page1, { total: 202, start: 0, hasMore: true }))
|
|
201
|
+
.mockResolvedValueOnce(commentsPage(page2, { total: 202, start: 100, hasMore: true }))
|
|
202
|
+
.mockResolvedValueOnce(commentsPage(page3, { total: 202, start: 200, hasMore: false }));
|
|
203
|
+
|
|
204
|
+
const result = await collectAllComments(client, "https://post", "slug");
|
|
205
|
+
|
|
206
|
+
expect(result.profiles).toHaveLength(202);
|
|
207
|
+
expect(result.total).toBe(202);
|
|
208
|
+
expect(result.skipped).toBe(false);
|
|
209
|
+
expect(mock).toHaveBeenCalledTimes(3);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("skips when previousTotal matches current total", async () => {
|
|
213
|
+
const client = createMockClient();
|
|
214
|
+
const mock = vi.mocked(client.linkedinScrapers.collectComments);
|
|
215
|
+
mock.mockResolvedValueOnce(commentsPage([], { total: 50, start: 0, hasMore: false }));
|
|
216
|
+
|
|
217
|
+
const result = await collectAllComments(client, "https://post", "slug", { previousTotal: 50 });
|
|
218
|
+
|
|
219
|
+
expect(result.skipped).toBe(true);
|
|
220
|
+
expect(result.profiles).toHaveLength(0);
|
|
221
|
+
expect(result.total).toBe(50);
|
|
222
|
+
expect(mock).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(mock).toHaveBeenCalledWith({ postUrl: "https://post", count: 0 });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("fetches when previousTotal differs from current total", async () => {
|
|
227
|
+
const client = createMockClient();
|
|
228
|
+
const mock = vi.mocked(client.linkedinScrapers.collectComments);
|
|
229
|
+
|
|
230
|
+
mock
|
|
231
|
+
.mockResolvedValueOnce(commentsPage([], { total: 55, start: 0, hasMore: false }))
|
|
232
|
+
.mockResolvedValueOnce(
|
|
233
|
+
commentsPage([{ name: "new" }], { total: 55, start: 0, hasMore: false }),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const result = await collectAllComments(client, "https://post", "slug", { previousTotal: 50 });
|
|
237
|
+
|
|
238
|
+
expect(result.skipped).toBe(false);
|
|
239
|
+
expect(mock).toHaveBeenCalledTimes(2);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("handles empty post (0 comments)", async () => {
|
|
243
|
+
const client = createMockClient();
|
|
244
|
+
const mock = vi.mocked(client.linkedinScrapers.collectComments);
|
|
245
|
+
mock.mockResolvedValueOnce(commentsPage([], { total: 0, start: 0, hasMore: false }));
|
|
246
|
+
|
|
247
|
+
const result = await collectAllComments(client, "https://post", "slug");
|
|
248
|
+
|
|
249
|
+
expect(result.profiles).toHaveLength(0);
|
|
250
|
+
expect(result.total).toBe(0);
|
|
251
|
+
expect(result.skipped).toBe(false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("does not pass previousTotal=0 as no-op (0 is a valid total)", async () => {
|
|
255
|
+
const client = createMockClient();
|
|
256
|
+
const mock = vi.mocked(client.linkedinScrapers.collectComments);
|
|
257
|
+
mock.mockResolvedValueOnce(commentsPage([], { total: 0, start: 0, hasMore: false }));
|
|
258
|
+
|
|
259
|
+
const result = await collectAllComments(client, "https://post", "slug", { previousTotal: 0 });
|
|
260
|
+
|
|
261
|
+
expect(result.skipped).toBe(true);
|
|
262
|
+
expect(result.total).toBe(0);
|
|
263
|
+
expect(mock).toHaveBeenCalledTimes(1);
|
|
264
|
+
expect(mock).toHaveBeenCalledWith({ postUrl: "https://post", count: 0 });
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── listAllInvitations ──────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
describe("listAllInvitations", () => {
|
|
271
|
+
it("fetches a single page when total <= count", async () => {
|
|
272
|
+
const invitations = [{ invitationId: "1", sharedSecret: "s1", entityUrn: "u1" }];
|
|
273
|
+
const client = createMockClient();
|
|
274
|
+
const mock = vi.mocked(client.linkedinActions.listInvitations);
|
|
275
|
+
mock.mockResolvedValueOnce(invitationsPage(invitations, { total: 1, start: 0 }));
|
|
276
|
+
|
|
277
|
+
const result = await listAllInvitations(client);
|
|
278
|
+
|
|
279
|
+
expect(result.invitations).toHaveLength(1);
|
|
280
|
+
expect(result.total).toBe(1);
|
|
281
|
+
expect(mock).toHaveBeenCalledTimes(1);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("paginates across 2 pages", async () => {
|
|
285
|
+
const client = createMockClient();
|
|
286
|
+
const mock = vi.mocked(client.linkedinActions.listInvitations);
|
|
287
|
+
|
|
288
|
+
const page1 = Array.from({ length: 100 }, (_, i) => ({
|
|
289
|
+
invitationId: `${i}`,
|
|
290
|
+
sharedSecret: `s${i}`,
|
|
291
|
+
entityUrn: `u${i}`,
|
|
292
|
+
}));
|
|
293
|
+
const page2 = [{ invitationId: "100", sharedSecret: "s100", entityUrn: "u100" }];
|
|
294
|
+
|
|
295
|
+
mock
|
|
296
|
+
.mockResolvedValueOnce(invitationsPage(page1, { total: 101, start: 0 }))
|
|
297
|
+
.mockResolvedValueOnce(invitationsPage(page2, { total: 101, start: 100 }));
|
|
298
|
+
|
|
299
|
+
const result = await listAllInvitations(client);
|
|
300
|
+
|
|
301
|
+
expect(result.invitations).toHaveLength(101);
|
|
302
|
+
expect(result.total).toBe(101);
|
|
303
|
+
expect(mock).toHaveBeenCalledTimes(2);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("handles no pending invitations", async () => {
|
|
307
|
+
const client = createMockClient();
|
|
308
|
+
const mock = vi.mocked(client.linkedinActions.listInvitations);
|
|
309
|
+
mock.mockResolvedValueOnce(invitationsPage([], { total: 0, start: 0 }));
|
|
310
|
+
|
|
311
|
+
const result = await listAllInvitations(client);
|
|
312
|
+
|
|
313
|
+
expect(result.invitations).toHaveLength(0);
|
|
314
|
+
expect(result.total).toBe(0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("handles exactly 100 invitations (boundary: total === start + count)", async () => {
|
|
318
|
+
const client = createMockClient();
|
|
319
|
+
const mock = vi.mocked(client.linkedinActions.listInvitations);
|
|
320
|
+
|
|
321
|
+
const page = Array.from({ length: 100 }, (_, i) => ({
|
|
322
|
+
invitationId: `${i}`,
|
|
323
|
+
sharedSecret: `s${i}`,
|
|
324
|
+
entityUrn: `u${i}`,
|
|
325
|
+
}));
|
|
326
|
+
mock.mockResolvedValueOnce(invitationsPage(page, { total: 100, start: 0 }));
|
|
327
|
+
|
|
328
|
+
const result = await listAllInvitations(client);
|
|
329
|
+
|
|
330
|
+
expect(result.invitations).toHaveLength(100);
|
|
331
|
+
expect(mock).toHaveBeenCalledTimes(1);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ── visitProfileIfNeeded ────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
describe("visitProfileIfNeeded", () => {
|
|
338
|
+
it("returns knownDistance without visiting when set", async () => {
|
|
339
|
+
const client = createMockClient();
|
|
340
|
+
const mock = vi.mocked(client.linkedinScrapers.visitProfile);
|
|
341
|
+
|
|
342
|
+
const result = await visitProfileIfNeeded(client, "u/alice", "slug", 1);
|
|
343
|
+
|
|
344
|
+
expect(result.memberDistance).toBe(1);
|
|
345
|
+
expect(result.visited).toBe(false);
|
|
346
|
+
expect(result.pendingConnection).toBeNull();
|
|
347
|
+
expect(mock).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("returns knownDistance=0 without visiting (0 is valid)", async () => {
|
|
351
|
+
const client = createMockClient();
|
|
352
|
+
const mock = vi.mocked(client.linkedinScrapers.visitProfile);
|
|
353
|
+
|
|
354
|
+
const result = await visitProfileIfNeeded(client, "u/alice", "slug", 0);
|
|
355
|
+
|
|
356
|
+
expect(result.memberDistance).toBe(0);
|
|
357
|
+
expect(result.visited).toBe(false);
|
|
358
|
+
expect(mock).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("returns knownDistance=2 without visiting", async () => {
|
|
362
|
+
const client = createMockClient();
|
|
363
|
+
const mock = vi.mocked(client.linkedinScrapers.visitProfile);
|
|
364
|
+
|
|
365
|
+
const result = await visitProfileIfNeeded(client, "u/alice", "slug", 2);
|
|
366
|
+
|
|
367
|
+
expect(result.memberDistance).toBe(2);
|
|
368
|
+
expect(result.visited).toBe(false);
|
|
369
|
+
expect(mock).not.toHaveBeenCalled();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("calls linkedinScrapers.visitProfile when knownDistance is null", async () => {
|
|
373
|
+
const client = createMockClient();
|
|
374
|
+
const mock = vi.mocked(client.linkedinScrapers.visitProfile);
|
|
375
|
+
mock.mockResolvedValueOnce({
|
|
376
|
+
success: true,
|
|
377
|
+
firstName: "Alice",
|
|
378
|
+
lastName: "Smith",
|
|
379
|
+
headline: "Engineer",
|
|
380
|
+
publicIdentifier: "alice",
|
|
381
|
+
profileUrl: "https://linkedin.com/in/alice",
|
|
382
|
+
profileUrn: "urn:li:fsd_profile:123",
|
|
383
|
+
imageUrl: null,
|
|
384
|
+
memberDistance: 1,
|
|
385
|
+
pendingConnection: "none",
|
|
386
|
+
cached: false,
|
|
387
|
+
creditsUsed: 1,
|
|
388
|
+
retryAfter: 0,
|
|
389
|
+
} as never);
|
|
390
|
+
|
|
391
|
+
const result = await visitProfileIfNeeded(client, "u/alice", "slug", null);
|
|
392
|
+
|
|
393
|
+
expect(result.memberDistance).toBe(1);
|
|
394
|
+
expect(result.visited).toBe(true);
|
|
395
|
+
expect(result.pendingConnection).toBe("none");
|
|
396
|
+
expect(result.firstName).toBe("Alice");
|
|
397
|
+
expect(result.profileUrn).toBe("urn:li:fsd_profile:123");
|
|
398
|
+
expect(mock).toHaveBeenCalledWith({ profile: "u/alice", campaignSlug: "slug" });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("calls visitProfile when knownDistance is undefined", async () => {
|
|
402
|
+
const client = createMockClient();
|
|
403
|
+
const mock = vi.mocked(client.linkedinScrapers.visitProfile);
|
|
404
|
+
mock.mockResolvedValueOnce({
|
|
405
|
+
success: true,
|
|
406
|
+
firstName: "Bob",
|
|
407
|
+
lastName: "Jones",
|
|
408
|
+
headline: null,
|
|
409
|
+
publicIdentifier: "bob",
|
|
410
|
+
profileUrl: "https://linkedin.com/in/bob",
|
|
411
|
+
imageUrl: null,
|
|
412
|
+
memberDistance: 2,
|
|
413
|
+
pendingConnection: "pending",
|
|
414
|
+
cached: false,
|
|
415
|
+
creditsUsed: 1,
|
|
416
|
+
retryAfter: 0,
|
|
417
|
+
} as never);
|
|
418
|
+
|
|
419
|
+
const result = await visitProfileIfNeeded(client, "u/bob", "slug", undefined);
|
|
420
|
+
|
|
421
|
+
expect(result.memberDistance).toBe(2);
|
|
422
|
+
expect(result.visited).toBe(true);
|
|
423
|
+
expect(result.pendingConnection).toBe("pending");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("handles null memberDistance from visit response", async () => {
|
|
427
|
+
const client = createMockClient();
|
|
428
|
+
const mock = vi.mocked(client.linkedinScrapers.visitProfile);
|
|
429
|
+
mock.mockResolvedValueOnce({
|
|
430
|
+
success: true,
|
|
431
|
+
firstName: "X",
|
|
432
|
+
lastName: "Y",
|
|
433
|
+
headline: null,
|
|
434
|
+
publicIdentifier: "x",
|
|
435
|
+
profileUrl: "u/x",
|
|
436
|
+
imageUrl: null,
|
|
437
|
+
memberDistance: null,
|
|
438
|
+
pendingConnection: "none",
|
|
439
|
+
cached: false,
|
|
440
|
+
creditsUsed: 1,
|
|
441
|
+
retryAfter: 0,
|
|
442
|
+
} as never);
|
|
443
|
+
|
|
444
|
+
const result = await visitProfileIfNeeded(client, "u/x", "slug", null);
|
|
445
|
+
|
|
446
|
+
expect(result.memberDistance).toBeNull();
|
|
447
|
+
expect(result.visited).toBe(true);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("propagates FatalError from visitProfile", async () => {
|
|
451
|
+
const client = createMockClient();
|
|
452
|
+
const mock = vi.mocked(client.linkedinScrapers.visitProfile);
|
|
453
|
+
mock.mockRejectedValueOnce(Object.assign(new Error("unauthorized"), { statusCode: 401 }));
|
|
454
|
+
|
|
455
|
+
await expect(
|
|
456
|
+
visitProfileIfNeeded(client, "u/x", "slug", null),
|
|
457
|
+
).rejects.toThrow(FatalError);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// ── findConversationForDmGuard ──────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
describe("findConversationForDmGuard", () => {
|
|
464
|
+
it("returns skip=false when no conversation found", async () => {
|
|
465
|
+
const client = createMockClient();
|
|
466
|
+
const mock = vi.mocked(client.linkedinChat.findConversation);
|
|
467
|
+
mock.mockResolvedValueOnce({
|
|
468
|
+
success: true,
|
|
469
|
+
found: false,
|
|
470
|
+
conversation: null,
|
|
471
|
+
messages: null,
|
|
472
|
+
creditsUsed: 0,
|
|
473
|
+
retryAfter: 0,
|
|
474
|
+
} as never);
|
|
475
|
+
|
|
476
|
+
const result = await findConversationForDmGuard(client, "u/alice", "https://link", "slug");
|
|
477
|
+
|
|
478
|
+
expect(result.skip).toBe(false);
|
|
479
|
+
expect(result.messages).toHaveLength(0);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("returns skip=true when resourceLink found in messages", async () => {
|
|
483
|
+
const client = createMockClient();
|
|
484
|
+
const findMock = vi.mocked(client.linkedinChat.findConversation);
|
|
485
|
+
const syncMock = vi.mocked(client.campaigns.syncActions);
|
|
486
|
+
|
|
487
|
+
findMock.mockResolvedValueOnce({
|
|
488
|
+
success: true,
|
|
489
|
+
found: true,
|
|
490
|
+
conversation: null,
|
|
491
|
+
messages: [
|
|
492
|
+
{
|
|
493
|
+
messageUrn: "m1",
|
|
494
|
+
text: "Here is the resource: https://link",
|
|
495
|
+
deliveredAt: 1000,
|
|
496
|
+
senderProfileUrn: "urn:me",
|
|
497
|
+
sender: { name: "Me", profileUrl: "u/me" },
|
|
498
|
+
attachments: [],
|
|
499
|
+
},
|
|
500
|
+
],
|
|
501
|
+
creditsUsed: 0,
|
|
502
|
+
retryAfter: 0,
|
|
503
|
+
} as never);
|
|
504
|
+
syncMock.mockResolvedValueOnce({ success: true, synced: [], creditsUsed: 0, retryAfter: 0 } as never);
|
|
505
|
+
|
|
506
|
+
const result = await findConversationForDmGuard(client, "u/alice", "https://link", "slug");
|
|
507
|
+
|
|
508
|
+
expect(result.skip).toBe(true);
|
|
509
|
+
expect(result.messages).toHaveLength(1);
|
|
510
|
+
expect(syncMock).toHaveBeenCalledOnce();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("returns skip=false when conversation exists but link not in messages", async () => {
|
|
514
|
+
const client = createMockClient();
|
|
515
|
+
const findMock = vi.mocked(client.linkedinChat.findConversation);
|
|
516
|
+
|
|
517
|
+
findMock.mockResolvedValueOnce({
|
|
518
|
+
success: true,
|
|
519
|
+
found: true,
|
|
520
|
+
conversation: null,
|
|
521
|
+
messages: [
|
|
522
|
+
{
|
|
523
|
+
messageUrn: "m1",
|
|
524
|
+
text: "Hello! Nice to connect.",
|
|
525
|
+
deliveredAt: 1000,
|
|
526
|
+
senderProfileUrn: "urn:them",
|
|
527
|
+
sender: { name: "Alice", profileUrl: "u/alice" },
|
|
528
|
+
attachments: [],
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
creditsUsed: 0,
|
|
532
|
+
retryAfter: 0,
|
|
533
|
+
} as never);
|
|
534
|
+
|
|
535
|
+
const result = await findConversationForDmGuard(client, "u/alice", "https://link", "slug");
|
|
536
|
+
|
|
537
|
+
expect(result.skip).toBe(false);
|
|
538
|
+
expect(result.messages).toHaveLength(1);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("returns messages for tone adaptation when no link found", async () => {
|
|
542
|
+
const client = createMockClient();
|
|
543
|
+
const findMock = vi.mocked(client.linkedinChat.findConversation);
|
|
544
|
+
|
|
545
|
+
findMock.mockResolvedValueOnce({
|
|
546
|
+
success: true,
|
|
547
|
+
found: true,
|
|
548
|
+
conversation: null,
|
|
549
|
+
messages: [
|
|
550
|
+
{
|
|
551
|
+
messageUrn: "m1",
|
|
552
|
+
text: "Hey! How are you?",
|
|
553
|
+
deliveredAt: 1000,
|
|
554
|
+
senderProfileUrn: "urn:them",
|
|
555
|
+
sender: { name: "Alice", profileUrl: "u/alice" },
|
|
556
|
+
attachments: [],
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
messageUrn: "m2",
|
|
560
|
+
text: "Great, thanks!",
|
|
561
|
+
deliveredAt: 2000,
|
|
562
|
+
senderProfileUrn: "urn:me",
|
|
563
|
+
sender: { name: "Me", profileUrl: "u/me" },
|
|
564
|
+
attachments: [],
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
creditsUsed: 0,
|
|
568
|
+
retryAfter: 0,
|
|
569
|
+
} as never);
|
|
570
|
+
|
|
571
|
+
const result = await findConversationForDmGuard(client, "u/alice", "https://link", "slug");
|
|
572
|
+
|
|
573
|
+
expect(result.skip).toBe(false);
|
|
574
|
+
expect(result.messages).toHaveLength(2);
|
|
575
|
+
expect(result.messages[0].text).toBe("Hey! How are you?");
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("returns skip=true (fail-safe) when findConversation throws", async () => {
|
|
579
|
+
const client = createMockClient();
|
|
580
|
+
const findMock = vi.mocked(client.linkedinChat.findConversation);
|
|
581
|
+
findMock.mockRejectedValueOnce(Object.assign(new Error("network"), { statusCode: 500 }));
|
|
582
|
+
|
|
583
|
+
const result = await findConversationForDmGuard(client, "u/alice", "https://link", "slug");
|
|
584
|
+
|
|
585
|
+
expect(result.skip).toBe(true);
|
|
586
|
+
expect(result.messages).toHaveLength(0);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("still returns skip=true when syncActions fails after finding link", async () => {
|
|
590
|
+
const client = createMockClient();
|
|
591
|
+
const findMock = vi.mocked(client.linkedinChat.findConversation);
|
|
592
|
+
const syncMock = vi.mocked(client.campaigns.syncActions);
|
|
593
|
+
|
|
594
|
+
findMock.mockResolvedValueOnce({
|
|
595
|
+
success: true,
|
|
596
|
+
found: true,
|
|
597
|
+
conversation: null,
|
|
598
|
+
messages: [
|
|
599
|
+
{
|
|
600
|
+
messageUrn: "m1",
|
|
601
|
+
text: "https://link already sent",
|
|
602
|
+
deliveredAt: 1000,
|
|
603
|
+
senderProfileUrn: "urn:me",
|
|
604
|
+
sender: { name: "Me", profileUrl: "u/me" },
|
|
605
|
+
attachments: [],
|
|
606
|
+
},
|
|
607
|
+
],
|
|
608
|
+
creditsUsed: 0,
|
|
609
|
+
retryAfter: 0,
|
|
610
|
+
} as never);
|
|
611
|
+
syncMock.mockRejectedValueOnce(new Error("sync failed"));
|
|
612
|
+
|
|
613
|
+
const result = await findConversationForDmGuard(client, "u/alice", "https://link", "slug");
|
|
614
|
+
|
|
615
|
+
expect(result.skip).toBe(true);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("handles conversation found but messages array is null", async () => {
|
|
619
|
+
const client = createMockClient();
|
|
620
|
+
const findMock = vi.mocked(client.linkedinChat.findConversation);
|
|
621
|
+
|
|
622
|
+
findMock.mockResolvedValueOnce({
|
|
623
|
+
success: true,
|
|
624
|
+
found: true,
|
|
625
|
+
conversation: null,
|
|
626
|
+
messages: null,
|
|
627
|
+
creditsUsed: 0,
|
|
628
|
+
retryAfter: 0,
|
|
629
|
+
} as never);
|
|
630
|
+
|
|
631
|
+
const result = await findConversationForDmGuard(client, "u/alice", "https://link", "slug");
|
|
632
|
+
|
|
633
|
+
expect(result.skip).toBe(false);
|
|
634
|
+
expect(result.messages).toHaveLength(0);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("handles conversation found but messages array is empty", async () => {
|
|
638
|
+
const client = createMockClient();
|
|
639
|
+
const findMock = vi.mocked(client.linkedinChat.findConversation);
|
|
640
|
+
|
|
641
|
+
findMock.mockResolvedValueOnce({
|
|
642
|
+
success: true,
|
|
643
|
+
found: true,
|
|
644
|
+
conversation: null,
|
|
645
|
+
messages: [],
|
|
646
|
+
creditsUsed: 0,
|
|
647
|
+
retryAfter: 0,
|
|
648
|
+
} as never);
|
|
649
|
+
|
|
650
|
+
const result = await findConversationForDmGuard(client, "u/alice", "https://link", "slug");
|
|
651
|
+
|
|
652
|
+
expect(result.skip).toBe(false);
|
|
653
|
+
expect(result.messages).toHaveLength(0);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("matches resourceLink as substring in message text", async () => {
|
|
657
|
+
const client = createMockClient();
|
|
658
|
+
const findMock = vi.mocked(client.linkedinChat.findConversation);
|
|
659
|
+
const syncMock = vi.mocked(client.campaigns.syncActions);
|
|
660
|
+
|
|
661
|
+
findMock.mockResolvedValueOnce({
|
|
662
|
+
success: true,
|
|
663
|
+
found: true,
|
|
664
|
+
conversation: null,
|
|
665
|
+
messages: [
|
|
666
|
+
{
|
|
667
|
+
messageUrn: "m1",
|
|
668
|
+
text: "Voici ta ressource https://link/resource?id=123 bonne lecture !",
|
|
669
|
+
deliveredAt: 1000,
|
|
670
|
+
senderProfileUrn: "urn:me",
|
|
671
|
+
sender: { name: "Me", profileUrl: "u/me" },
|
|
672
|
+
attachments: [],
|
|
673
|
+
},
|
|
674
|
+
],
|
|
675
|
+
creditsUsed: 0,
|
|
676
|
+
retryAfter: 0,
|
|
677
|
+
} as never);
|
|
678
|
+
syncMock.mockResolvedValueOnce({ success: true, synced: [], creditsUsed: 0, retryAfter: 0 } as never);
|
|
679
|
+
|
|
680
|
+
const result = await findConversationForDmGuard(client, "u/alice", "https://link/resource", "slug");
|
|
681
|
+
|
|
682
|
+
expect(result.skip).toBe(true);
|
|
683
|
+
});
|
|
684
|
+
});
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bereach-openclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "BeReach LinkedIn automation plugin for OpenClaw",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./lead-magnet": "./src/lead-magnet/helpers.ts"
|
|
9
|
+
},
|
|
6
10
|
"scripts": {
|
|
7
|
-
"test:register": "tsx scripts/test-register.ts"
|
|
11
|
+
"test:register": "tsx scripts/test-register.ts",
|
|
12
|
+
"test": "vitest run"
|
|
8
13
|
},
|
|
9
14
|
"openclaw": {
|
|
10
15
|
"extensions": [
|
|
@@ -12,11 +17,12 @@
|
|
|
12
17
|
]
|
|
13
18
|
},
|
|
14
19
|
"dependencies": {
|
|
15
|
-
"bereach": "^0.1
|
|
20
|
+
"bereach": "^0.2.1"
|
|
16
21
|
},
|
|
17
22
|
"devDependencies": {
|
|
18
23
|
"@types/node": "^22.10.0",
|
|
19
24
|
"tsx": "^4.21.0",
|
|
20
|
-
"typescript": "^5.9.3"
|
|
25
|
+
"typescript": "^5.9.3",
|
|
26
|
+
"vitest": "^4.0.18"
|
|
21
27
|
}
|
|
22
28
|
}
|
package/skills/bereach/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bereach
|
|
3
3
|
description: "Automate LinkedIn outreach via BeReach (berea.ch). Use when: prospecting, engaging posts, scraping engagement, searching LinkedIn, managing inbox, running campaigns, managing invitations. Requires BEREACH_API_KEY."
|
|
4
|
-
lastUpdatedAt:
|
|
4
|
+
lastUpdatedAt: 1772701953
|
|
5
5
|
metadata: { "openclaw": { "requires": { "env": ["BEREACH_API_KEY"] }, "primaryEnv": "BEREACH_API_KEY" } }
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -17,7 +17,7 @@ Automate LinkedIn prospection and engagement via BeReach.
|
|
|
17
17
|
|
|
18
18
|
| Sub-skill | Keywords | URL | lastUpdatedAt |
|
|
19
19
|
| --------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------- |
|
|
20
|
-
| Lead Magnet | lead magnet, comment to DM, resource delivery, post giveaway, auto-accept invitations, scheduled run, recap, campaign stats, pause | sub/lead-magnet.md |
|
|
20
|
+
| Lead Magnet | lead magnet, comment to DM, resource delivery, post giveaway, auto-accept invitations, scheduled run, recap, campaign stats, pause | sub/lead-magnet.md | 1772701953 |
|
|
21
21
|
| OpenClaw Optimization | openclaw, model, opus, sonnet, haiku, config, SOUL.md, heartbeat, prompt caching, AI cost reduction, /model | openclaw-optimization.md | 1772619338 |
|
|
22
22
|
| SDK Reference | sdk, method, parameter, signature, reference, api, script | sdk-reference.md | 1772672714 |
|
|
23
23
|
|
|
@@ -81,9 +81,13 @@ The SDK auto-reads `BEREACH_API_KEY` from the environment. NEVER hardcode tokens
|
|
|
81
81
|
|
|
82
82
|
- Lead magnet scripts use the SDK. Tools are for interactive agent use only.
|
|
83
83
|
- Import from the `bereach` SDK exclusively. If a method doesn't exist, the operation doesn't exist.
|
|
84
|
-
- Scripts MUST be TypeScript (`.ts`).
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
- Scripts MUST be TypeScript (`.ts`). Load the [SDK Reference](sdk-reference.md) sub-skill for method signatures.
|
|
85
|
+
|
|
86
|
+
**Mandatory validation loop** — after generating or editing any script:
|
|
87
|
+
|
|
88
|
+
1. **Step 1 (blocking)** — Run `npx tsc --noEmit` in the script directory. It MUST exit 0. If type errors: fix them using the SDK Reference, then repeat. Do NOT run tsx until tsc passes.
|
|
89
|
+
2. **Step 2** — Run `npx tsx <script-name>.ts`. Fix runtime errors (e.g. "visitProfile is not a function"). If config missing, run with minimal env to reach first SDK call.
|
|
90
|
+
3. **Both must succeed** — tsx passing alone is NOT sufficient. Do not consider the script ready until BOTH tsc and tsx succeed.
|
|
87
91
|
|
|
88
92
|
### Always
|
|
89
93
|
|
|
@@ -139,7 +143,7 @@ More workflows coming soon. You can build your own using the SDK methods and too
|
|
|
139
143
|
|
|
140
144
|
Crons are OpenClaw scheduled tasks. Create entries in `~/.openclaw/cron/jobs.json` (via `openclaw cron add`).
|
|
141
145
|
|
|
142
|
-
**Lead Magnet** — 3 global crons.
|
|
146
|
+
**Lead Magnet** — 3 global crons. Campaign config at `~/.bereach/lead-magnet.json`. Each cron runs one script that loops over all enabled campaigns:
|
|
143
147
|
|
|
144
148
|
```json
|
|
145
149
|
{ "id": "lm-comments", "every": "1h", "skill": "bereach", "sessionTarget": "spawn", "prompt": "Run lm-comments.ts and report recap" }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bereach-lead-magnet
|
|
3
3
|
description: "Lead magnet workflow — deliver a resource to everyone who engages with a LinkedIn post."
|
|
4
|
-
lastUpdatedAt:
|
|
4
|
+
lastUpdatedAt: 1772701953
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# BeReach Lead Magnet Skill
|
|
@@ -16,6 +16,60 @@ Requires [BeReach SKILL.md](SKILL.md) (constraints, SDK setup, anti-hallucinatio
|
|
|
16
16
|
|
|
17
17
|
All lead magnet scripts MUST be TypeScript using the `bereach` SDK (see main skill for SDK setup and rules).
|
|
18
18
|
|
|
19
|
+
**Always use full SDK paths** — e.g. `client.linkedinChat.findConversation`, `client.linkedinScrapers.visitProfile`. Never infer the resource from method name alone (e.g. "inbox" ≠ linkedinInbox; "visit" ≠ linkedinActions.visitProfile).
|
|
20
|
+
|
|
21
|
+
**Validation loop (mandatory)** — after writing each script:
|
|
22
|
+
|
|
23
|
+
1. **Step 1 (blocking)** — `npx tsc --noEmit` MUST exit 0. Fix any type errors using SDK Reference. Do NOT run tsx until tsc passes.
|
|
24
|
+
2. **Step 2** — `npx tsx <script-name>.ts`. Fix runtime errors (e.g. undefined method, wrong resource). If config missing, run with minimal env to reach first SDK call.
|
|
25
|
+
3. **Both must succeed** — tsx passing alone is NOT sufficient. Only save or paste the script when BOTH succeed.
|
|
26
|
+
|
|
27
|
+
## Helpers
|
|
28
|
+
|
|
29
|
+
`bereach-openclaw` ships helper functions that handle the patterns most likely to go wrong: pagination, SDK path selection, and DM guard logic. Use them to avoid common mistakes. If you need custom behavior, you can call the SDK directly — but understand why the helper exists first.
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import {
|
|
33
|
+
collectAllComments,
|
|
34
|
+
listAllInvitations,
|
|
35
|
+
visitProfileIfNeeded,
|
|
36
|
+
findConversationForDmGuard,
|
|
37
|
+
} from "bereach-openclaw/lead-magnet";
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Your spec should list which helpers you plan to use.
|
|
41
|
+
|
|
42
|
+
### collectAllComments(client, postUrl, campaignSlug, opts?)
|
|
43
|
+
|
|
44
|
+
Paginates `collectComments` automatically (`while hasMore`, `start += 100`). Prevents the most common bug: fetching only the first 100 comments.
|
|
45
|
+
|
|
46
|
+
- `opts.previousTotal` — pass the stored total from last run. Does a free `count: 0` check first; if unchanged, returns `{ skipped: true }` immediately.
|
|
47
|
+
- Returns `{ profiles, total, skipped }`.
|
|
48
|
+
|
|
49
|
+
### listAllInvitations(client)
|
|
50
|
+
|
|
51
|
+
Paginates `listInvitations` automatically (`while total > start + count`, `count: 100`). Prevents fetching only the first 10 invitations.
|
|
52
|
+
|
|
53
|
+
- Returns `{ invitations, total }`.
|
|
54
|
+
|
|
55
|
+
### visitProfileIfNeeded(client, profile, campaignSlug, knownDistance)
|
|
56
|
+
|
|
57
|
+
If `knownDistance` is already set (including 0): returns it immediately (0 credits). If `null`/`undefined`: calls `client.linkedinScrapers.visitProfile` (correct path — not `linkedinActions`).
|
|
58
|
+
|
|
59
|
+
Prevents two bugs:
|
|
60
|
+
- `knownDistance || 2` — when `knownDistance` is null, the fallback should be a visit, not an assumption.
|
|
61
|
+
- `client.linkedinActions.visitProfile` — wrong namespace (doesn't exist).
|
|
62
|
+
|
|
63
|
+
Returns `{ memberDistance, pendingConnection, visited, firstName, headline, ... }`.
|
|
64
|
+
|
|
65
|
+
### findConversationForDmGuard(client, profile, resourceLink, campaignSlug)
|
|
66
|
+
|
|
67
|
+
Checks whether `resourceLink` was already sent via DM. Uses `client.linkedinChat.findConversation` (correct path — not `linkedinInbox`). Reads `messages` at the top level (not nested).
|
|
68
|
+
|
|
69
|
+
- If link found: syncs action via `syncActions`, returns `{ skip: true, messages }`.
|
|
70
|
+
- If no link: returns `{ skip: false, messages }` — messages available for DM tone adaptation.
|
|
71
|
+
- On error: returns `{ skip: true }` — fail-safe, never DM on lookup failure.
|
|
72
|
+
|
|
19
73
|
## Tone
|
|
20
74
|
|
|
21
75
|
All script output and chatbot messages are **for the end user**. Write like you're talking to a non-technical person. Never mention internal concepts like "Track A", "Layer 2", "DM guard", "server-side dedup", "retryAfter", "URN", "O(1) lookup", etc. The campaign slug is fine to show — it helps the user know which campaign is running. Just say what happened in plain language: "Sent the resource to 12 people", "Skipped 3 (already received it)", "Outside active hours, will resume at 7:00".
|
|
@@ -125,11 +179,10 @@ Before running a script for a campaign, acquire a lock to prevent concurrent run
|
|
|
125
179
|
|
|
126
180
|
For each campaign (from config): scrape commenters and engage them.
|
|
127
181
|
|
|
128
|
-
1. **
|
|
129
|
-
2.
|
|
130
|
-
3.
|
|
131
|
-
|
|
132
|
-
a. If `knownDistance` is set, use it (skip visit, saves 1 credit). Otherwise: `visitProfile` with `campaignSlug` → `memberDistance`, `pendingConnection`
|
|
182
|
+
1. **Fetch all comments**: `collectAllComments(client, postUrl, campaignSlug, { previousTotal })` — handles pagination and count-0 pre-check. If `skipped`, exit early.
|
|
183
|
+
2. Each profile has: `profileUrl`, `commentUrn`, `name`, `profileUrn`, `actionsCompleted: { message, reply, like, visit, connect }`, `knownDistance`, `hasReplyFromPostAuthor`
|
|
184
|
+
3. For each profile (skip own URN, skip `actionsCompleted.message === true`):
|
|
185
|
+
a. `visitProfileIfNeeded(client, profile, campaignSlug, knownDistance)` → `memberDistance`, `pendingConnection`
|
|
133
186
|
b. Distance 1:
|
|
134
187
|
- **DM dedup** (see "DM dedup" section): check both layers. If either says "already sent", skip. Otherwise: `sendMessage` with `campaignSlug`.
|
|
135
188
|
- Skip reply if `actionsCompleted.reply === true` or `hasReplyFromPostAuthor === true`. Otherwise: `replyToComment` with `commentUrn` and `campaignSlug`
|
|
@@ -143,20 +196,20 @@ For each campaign (from config): scrape commenters and engage them.
|
|
|
143
196
|
|
|
144
197
|
Accept pending invitations and deliver the resource to campaign-related invitees.
|
|
145
198
|
|
|
146
|
-
- `
|
|
147
|
-
- **Campaign attribution**:
|
|
199
|
+
- `listAllInvitations(client)` → `{ invitations, total }`. Each invitation has: `invitationId`, `sharedSecret`, `fromMember: { name, profileUrl }`. Pagination is handled by the helper.
|
|
200
|
+
- **Campaign attribution**: the helper returns ALL pending invitations. Cross-reference each inviter against campaign commenter lists to find which campaign (if any) they belong to. Accept all invitations, but only DM if campaign-matched.
|
|
148
201
|
- For each invitation:
|
|
149
202
|
1. `acceptInvitation` with `invitationId` and `sharedSecret`
|
|
150
|
-
2. If campaign matched: `
|
|
203
|
+
2. If campaign matched: `visitProfileIfNeeded(client, profile, campaignSlug, null)`, then **DM dedup** → DM if not already sent.
|
|
151
204
|
3. If no campaign match (organic invitation): accept only, no DM.
|
|
152
205
|
|
|
153
206
|
### Script 3 — New connections
|
|
154
207
|
|
|
155
208
|
For each campaign (from config): check if distance 2+ commenters have now connected.
|
|
156
209
|
|
|
157
|
-
- Re-fetch commenters: `
|
|
210
|
+
- Re-fetch commenters: `collectAllComments(client, postUrl, campaignSlug)` — pagination handled by helper
|
|
158
211
|
- Filter: `actionsCompleted.message === false` and `knownDistance > 1` (skip profiles where `knownDistance` is null — they haven't been visited yet and belong to Script 1)
|
|
159
|
-
- For each: `
|
|
212
|
+
- For each: `visitProfileIfNeeded(client, profile, campaignSlug, null)` → check `memberDistance` and `pendingConnection`
|
|
160
213
|
- `memberDistance === 1`: **DM dedup** (see "DM dedup" section) → DM the resource if not already sent.
|
|
161
214
|
- `pendingConnection === "pending"`: not connected yet, skip
|
|
162
215
|
|
|
@@ -172,9 +225,7 @@ Check in order. If either says "already sent", skip the DM.
|
|
|
172
225
|
|
|
173
226
|
**Layer 1 — `actionsCompleted.message`** (free, within campaign). Already checked in the script loop — `true` → skip.
|
|
174
227
|
|
|
175
|
-
**Layer 2 —
|
|
176
|
-
|
|
177
|
-
**Fail-safe**: if `findConversation` errors or all retries fail, **skip this profile** — do NOT send the DM. A failed lookup treated as "not found" causes duplicates. The profile will be retried next run.
|
|
228
|
+
**Layer 2 — DM guard** (0 credits, cross-campaign). Only when `resourceLink` is set. Use `findConversationForDmGuard(client, profile, resourceLink, campaignSlug)` → `{ skip, messages }`. The helper handles: correct SDK path (`linkedinChat`, not `linkedinInbox`), top-level `messages`, `syncActions` on match, and fail-safe on error (skip = true). Pass returned `messages` as context for DM tone adaptation when `skip` is false.
|
|
178
229
|
|
|
179
230
|
## Language
|
|
180
231
|
|
|
@@ -206,7 +257,7 @@ Follow pacing rules from the main skill (Constraints #2). No exceptions — slee
|
|
|
206
257
|
|
|
207
258
|
## Cron
|
|
208
259
|
|
|
209
|
-
Three global crons.
|
|
260
|
+
Three global crons. Campaign config at `~/.bereach/lead-magnet.json`. Each cron runs one script that loops over all enabled campaigns.
|
|
210
261
|
|
|
211
262
|
All crons MUST use `"sessionTarget": "spawn"` to avoid blocking the main session. Each spawned sub-agent runs the script independently and pushes the recap back to the user when done.
|
|
212
263
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Bereach } from "bereach";
|
|
2
|
+
import type {
|
|
3
|
+
CollectLinkedInCommentsProfile,
|
|
4
|
+
Invitation,
|
|
5
|
+
FindLinkedInConversationMessage,
|
|
6
|
+
} from "bereach/models/operations";
|
|
7
|
+
import { Action } from "bereach/models/operations";
|
|
8
|
+
|
|
9
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
10
|
+
|
|
11
|
+
function randomDelay(minS: number, maxS: number) {
|
|
12
|
+
return sleep((minS + Math.random() * (maxS - minS)) * 1000);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 2-6s pause — read actions. */
|
|
16
|
+
export const readPause = () => randomDelay(2, 6);
|
|
17
|
+
/** 8-12s pause — write actions. */
|
|
18
|
+
export const writePause = () => randomDelay(8, 12);
|
|
19
|
+
|
|
20
|
+
export class SkippableError extends Error {
|
|
21
|
+
readonly skip = true as const;
|
|
22
|
+
constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "SkippableError"; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class FatalError extends Error {
|
|
26
|
+
readonly fatal = true as const;
|
|
27
|
+
constructor(message: string) { super(message); this.name = "FatalError"; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Retry on 429 with exponential backoff, throw FatalError (401/404/405) or SkippableError (rest). */
|
|
31
|
+
export async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
|
|
32
|
+
let attempts = 0;
|
|
33
|
+
while (true) {
|
|
34
|
+
try {
|
|
35
|
+
return await fn();
|
|
36
|
+
} catch (err: unknown) {
|
|
37
|
+
const e = err as { status?: number; statusCode?: number; message?: string };
|
|
38
|
+
const status = e?.statusCode ?? e?.status;
|
|
39
|
+
|
|
40
|
+
if (status === 429 && attempts < maxRetries) {
|
|
41
|
+
await sleep(2 ** attempts * 1000);
|
|
42
|
+
attempts++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (status === 401 || status === 404 || status === 405) {
|
|
46
|
+
throw new FatalError(e?.message ?? `Fatal error (HTTP ${status})`);
|
|
47
|
+
}
|
|
48
|
+
throw new SkippableError(e?.message ?? `Skippable error (HTTP ${status})`, { cause: err });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Paginate all comments. Pass `previousTotal` for a free count-0 early exit. */
|
|
54
|
+
export async function collectAllComments(
|
|
55
|
+
client: Bereach,
|
|
56
|
+
postUrl: string,
|
|
57
|
+
campaignSlug: string,
|
|
58
|
+
opts?: { previousTotal?: number },
|
|
59
|
+
) {
|
|
60
|
+
if (opts?.previousTotal != null) {
|
|
61
|
+
const check = await withRetry(() =>
|
|
62
|
+
client.linkedinScrapers.collectComments({ postUrl, count: 0 }),
|
|
63
|
+
);
|
|
64
|
+
if (check.total === opts.previousTotal) {
|
|
65
|
+
return { profiles: [] as CollectLinkedInCommentsProfile[], total: opts.previousTotal, skipped: true };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const allProfiles: CollectLinkedInCommentsProfile[] = [];
|
|
70
|
+
let start = 0;
|
|
71
|
+
const count = 100;
|
|
72
|
+
let total = 0;
|
|
73
|
+
|
|
74
|
+
while (true) {
|
|
75
|
+
const page = await withRetry(() =>
|
|
76
|
+
client.linkedinScrapers.collectComments({ postUrl, start, count, campaignSlug }),
|
|
77
|
+
);
|
|
78
|
+
if (page.profiles.length > 0) allProfiles.push(...page.profiles);
|
|
79
|
+
total = page.total;
|
|
80
|
+
if (!page.hasMore) break;
|
|
81
|
+
start += count;
|
|
82
|
+
await readPause();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { profiles: allProfiles, total, skipped: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Paginate all pending invitations. */
|
|
89
|
+
export async function listAllInvitations(client: Bereach) {
|
|
90
|
+
const allInvitations: Invitation[] = [];
|
|
91
|
+
let start = 0;
|
|
92
|
+
const count = 100;
|
|
93
|
+
|
|
94
|
+
while (true) {
|
|
95
|
+
const page = await withRetry(() =>
|
|
96
|
+
client.linkedinActions.listInvitations({ start, count }),
|
|
97
|
+
);
|
|
98
|
+
if (page.invitations.length > 0) allInvitations.push(...page.invitations);
|
|
99
|
+
if (page.total <= start + count) break;
|
|
100
|
+
start += count;
|
|
101
|
+
await readPause();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { invitations: allInvitations, total: allInvitations.length };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Use knownDistance when set (0 credits), otherwise call linkedinScrapers.visitProfile.
|
|
109
|
+
* NOT linkedinActions.visitProfile — wrong namespace.
|
|
110
|
+
*/
|
|
111
|
+
export async function visitProfileIfNeeded(
|
|
112
|
+
client: Bereach,
|
|
113
|
+
profile: string,
|
|
114
|
+
campaignSlug: string,
|
|
115
|
+
knownDistance: number | null | undefined,
|
|
116
|
+
) {
|
|
117
|
+
if (knownDistance != null) {
|
|
118
|
+
return { visited: false as const, memberDistance: knownDistance, pendingConnection: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const v = await withRetry(() =>
|
|
122
|
+
client.linkedinScrapers.visitProfile({ profile, campaignSlug }),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return { ...v, visited: true as const, memberDistance: v.memberDistance ?? null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface DmGuardResult {
|
|
129
|
+
skip: boolean;
|
|
130
|
+
messages: FindLinkedInConversationMessage[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if resourceLink was already sent via DM.
|
|
135
|
+
* Uses linkedinChat.findConversation (NOT linkedinInbox). Messages at top level.
|
|
136
|
+
* Fail-safe: on error, skip=true (never DM on lookup failure).
|
|
137
|
+
*/
|
|
138
|
+
export async function findConversationForDmGuard(
|
|
139
|
+
client: Bereach,
|
|
140
|
+
profile: string,
|
|
141
|
+
resourceLink: string,
|
|
142
|
+
campaignSlug: string,
|
|
143
|
+
): Promise<DmGuardResult> {
|
|
144
|
+
let data: Awaited<ReturnType<typeof client.linkedinChat.findConversation>>;
|
|
145
|
+
try {
|
|
146
|
+
data = await withRetry(() =>
|
|
147
|
+
client.linkedinChat.findConversation({ profile, includeMessages: true }),
|
|
148
|
+
);
|
|
149
|
+
} catch {
|
|
150
|
+
return { skip: true, messages: [] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!data.found) return { skip: false, messages: [] };
|
|
154
|
+
|
|
155
|
+
const messages = data.messages ?? [];
|
|
156
|
+
const alreadySent = messages.some((m) => m.text.includes(resourceLink));
|
|
157
|
+
|
|
158
|
+
if (alreadySent) {
|
|
159
|
+
try {
|
|
160
|
+
await client.campaigns.syncActions({
|
|
161
|
+
campaignSlug,
|
|
162
|
+
body: { profiles: [{ profile, actions: [Action.Message] }] },
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
// non-critical
|
|
166
|
+
}
|
|
167
|
+
return { skip: true, messages };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { skip: false, messages };
|
|
171
|
+
}
|