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.
- package/__tests__/helpers.test.ts +684 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +10 -4
- package/skills/bereach/SKILL.md +5 -5
- package/skills/bereach/sub/lead-magnet.md +60 -17
- 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
|
|
|
@@ -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
|
|
90
|
-
3.
|
|
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:
|
|
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`
|
|
24
|
-
2. `npx tsx <script-name>.ts
|
|
25
|
-
3.
|
|
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. **
|
|
137
|
-
2.
|
|
138
|
-
3.
|
|
139
|
-
|
|
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
|
-
- `
|
|
155
|
-
- **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.
|
|
156
201
|
- For each invitation:
|
|
157
202
|
1. `acceptInvitation` with `invitationId` and `sharedSecret`
|
|
158
|
-
2. If campaign matched: `client
|
|
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: `
|
|
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
|
|
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.
|
|
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
|
+
}
|