bereach-openclaw 0.2.17 → 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.
@@ -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
+ });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "bereach",
3
3
  "name": "BeReach",
4
- "version": "0.2.17",
4
+ "version": "0.3.0",
5
5
  "description": "LinkedIn outreach automation — 33 tools, auto-reply commands",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "bereach-openclaw",
3
- "version": "0.2.17",
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.4"
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
  }
@@ -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: 1772678140
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 | 1772678140 |
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
 
@@ -85,9 +85,9 @@ The SDK auto-reads `BEREACH_API_KEY` from the environment. NEVER hardcode tokens
85
85
 
86
86
  **Mandatory validation loop** — after generating or editing any script:
87
87
 
88
- 1. Run `npx tsc --noEmit` in the script directory. If type errors: fix them using the SDK Reference, then repeat.
89
- 2. Run `npx tsx <script-name>.ts` fix runtime errors (e.g. "linkedinInbox is not defined", "visitProfile is not a function"). If config missing, run with minimal env to reach first SDK call.
90
- 3. Only after both pass: consider the script ready.
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.
91
91
 
92
92
  ### Always
93
93
 
@@ -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: 1772678140
4
+ lastUpdatedAt: 1772701953
5
5
  ---
6
6
 
7
7
  # BeReach Lead Magnet Skill
@@ -20,9 +20,55 @@ All lead magnet scripts MUST be TypeScript using the `bereach` SDK (see main ski
20
20
 
21
21
  **Validation loop (mandatory)** — after writing each script:
22
22
 
23
- 1. `npx tsc --noEmit` fix any type errors using SDK Reference.
24
- 2. `npx tsx <script-name>.ts` fix any runtime errors (e.g. undefined method, wrong resource). If config is missing, run with minimal env to at least reach the first SDK call.
25
- 3. Repeat until both pass. Only then save or paste the script.
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.
26
72
 
27
73
  ## Tone
28
74
 
@@ -133,11 +179,10 @@ Before running a script for a campaign, acquire a lock to prevent concurrent run
133
179
 
134
180
  For each campaign (from config): scrape commenters and engage them.
135
181
 
136
- 1. **Check first** (mandatory): `collectComments` with `count: 0` store `total`. On subsequent rounds, compare with previous `total`. If unchanged, skip this round. This saves credits and rate limit slots.
137
- 2. **Fetch**: `collectComments` with `campaignSlug` response: `profiles[]` (NOT "comments"). Paginate: while `hasMore`, increment `start` by `count`.
138
- 3. Each profile has: `profileUrl`, `commentUrn`, `name`, `profileUrn`, `actionsCompleted: { message, reply, like, visit, connect }`, `knownDistance`, `hasReplyFromPostAuthor`
139
- 4. For each profile (skip own URN, skip `actionsCompleted.message === true`):
140
- a. If `knownDistance` is set, use it (skip visit, saves 1 credit). Otherwise: `client.linkedinScrapers.visitProfile` with `{ profile, 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`
141
186
  b. Distance 1:
142
187
  - **DM dedup** (see "DM dedup" section): check both layers. If either says "already sent", skip. Otherwise: `sendMessage` with `campaignSlug`.
143
188
  - Skip reply if `actionsCompleted.reply === true` or `hasReplyFromPostAuthor === true`. Otherwise: `replyToComment` with `commentUrn` and `campaignSlug`
@@ -151,20 +196,20 @@ For each campaign (from config): scrape commenters and engage them.
151
196
 
152
197
  Accept pending invitations and deliver the resource to campaign-related invitees.
153
198
 
154
- - `listInvitations` → response: `invitations[]`. Each has: `invitationId`, `sharedSecret`, `fromMember: { name, profileUrl }`. Paginate: while `total > start + count`.
155
- - **Campaign attribution**: `listInvitations` 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.
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.
156
201
  - For each invitation:
157
202
  1. `acceptInvitation` with `invitationId` and `sharedSecret`
158
- 2. If campaign matched: `client.linkedinScrapers.visitProfile` with `{ profile, campaignSlug }`, then **DM dedup** → DM if not already sent.
203
+ 2. If campaign matched: `visitProfileIfNeeded(client, profile, campaignSlug, null)`, then **DM dedup** → DM if not already sent.
159
204
  3. If no campaign match (organic invitation): accept only, no DM.
160
205
 
161
206
  ### Script 3 — New connections
162
207
 
163
208
  For each campaign (from config): check if distance 2+ commenters have now connected.
164
209
 
165
- - Re-fetch commenters: `collectComments` with `campaignSlug` — paginate all
210
+ - Re-fetch commenters: `collectAllComments(client, postUrl, campaignSlug)` — pagination handled by helper
166
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)
167
- - For each: `client.linkedinScrapers.visitProfile` with `{ profile, campaignSlug }` → check `memberDistance` and `pendingConnection`
212
+ - For each: `visitProfileIfNeeded(client, profile, campaignSlug, null)` → check `memberDistance` and `pendingConnection`
168
213
  - `memberDistance === 1`: **DM dedup** (see "DM dedup" section) → DM the resource if not already sent.
169
214
  - `pendingConnection === "pending"`: not connected yet, skip
170
215
 
@@ -180,9 +225,7 @@ Check in order. If either says "already sent", skip the DM.
180
225
 
181
226
  **Layer 1 — `actionsCompleted.message`** (free, within campaign). Already checked in the script loop — `true` → skip.
182
227
 
183
- **Layer 2 — DM guard** (0 credits, cross-campaign). Only when `resourceLink` is set. Call `client.linkedinChat.findConversation` with `{ profile, includeMessages: true }` → `{ found, messages }`. `messages` is at the **top level**, not nested. If any message text contains `resourceLink`: skip DM, call `client.campaigns.syncActions` to mark done. If found but no link in messages: pass `messages` as context for DM tone adaptation. The function must return both skip decision and messages.
184
-
185
- **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.
186
229
 
187
230
  ## Language
188
231
 
@@ -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
+ }
package/tsconfig.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "target": "ES2022",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "nodenext",
6
6
  "strict": true,
7
7
  "esModuleInterop": true,
8
8
  "skipLibCheck": true,