bereach-openclaw 0.3.2 → 0.3.4
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.
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
withRetry,
|
|
9
9
|
SkippableError,
|
|
10
10
|
FatalError,
|
|
11
|
+
isWithinActiveHours,
|
|
12
|
+
printRecap,
|
|
11
13
|
} from "../src/lead-magnet/helpers";
|
|
12
14
|
|
|
13
15
|
beforeEach(() => {
|
|
@@ -39,8 +41,13 @@ function createMockClient(overrides: Partial<Record<string, Record<string, unkno
|
|
|
39
41
|
},
|
|
40
42
|
campaigns: {
|
|
41
43
|
syncActions: vi.fn(),
|
|
44
|
+
getStats: vi.fn(),
|
|
42
45
|
...overrides.campaigns,
|
|
43
46
|
},
|
|
47
|
+
profile: {
|
|
48
|
+
getCredits: vi.fn(),
|
|
49
|
+
...overrides.profile,
|
|
50
|
+
},
|
|
44
51
|
} as unknown as Bereach;
|
|
45
52
|
}
|
|
46
53
|
|
|
@@ -682,3 +689,67 @@ describe("findConversationForDmGuard", () => {
|
|
|
682
689
|
expect(result.skip).toBe(true);
|
|
683
690
|
});
|
|
684
691
|
});
|
|
692
|
+
|
|
693
|
+
// ── isWithinActiveHours ──────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
describe("isWithinActiveHours", () => {
|
|
696
|
+
afterEach(() => {
|
|
697
|
+
vi.useRealTimers();
|
|
698
|
+
vi.spyOn(globalThis, "setTimeout").mockImplementation(((cb: () => void) => {
|
|
699
|
+
cb();
|
|
700
|
+
return 0;
|
|
701
|
+
}) as unknown as typeof setTimeout);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("returns true when current time is within default window", () => {
|
|
705
|
+
vi.setSystemTime(new Date("2026-03-05T14:00:00Z"));
|
|
706
|
+
expect(isWithinActiveHours({})).toBe(true);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("returns false when outside custom window", () => {
|
|
710
|
+
vi.setSystemTime(new Date("2026-03-05T04:00:00Z"));
|
|
711
|
+
expect(isWithinActiveHours({ activeHours: { start: "09:00", end: "17:00" }, timezone: "UTC" })).toBe(false);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("returns true when inside custom window", () => {
|
|
715
|
+
vi.setSystemTime(new Date("2026-03-05T12:00:00Z"));
|
|
716
|
+
expect(isWithinActiveHours({ activeHours: { start: "09:00", end: "17:00" }, timezone: "UTC" })).toBe(true);
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// ── printRecap ───────────────────────────────────────────────────────
|
|
721
|
+
|
|
722
|
+
describe("printRecap", () => {
|
|
723
|
+
it("calls getStats and getCredits and logs recap", async () => {
|
|
724
|
+
const client = createMockClient();
|
|
725
|
+
(client.campaigns.getStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
726
|
+
stats: { message: 5, reply: 3, like: 10 },
|
|
727
|
+
totalProfiles: 15,
|
|
728
|
+
creditsUsed: 18,
|
|
729
|
+
} as never);
|
|
730
|
+
(client.profile.getCredits as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
731
|
+
credits: { current: 50, limit: 1000, remaining: 950 },
|
|
732
|
+
} as never);
|
|
733
|
+
|
|
734
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
735
|
+
await printRecap(client, "test-campaign");
|
|
736
|
+
|
|
737
|
+
expect(client.campaigns.getStats).toHaveBeenCalledWith({ campaignSlug: "test-campaign" });
|
|
738
|
+
expect(client.profile.getCredits).toHaveBeenCalled();
|
|
739
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("test-campaign"));
|
|
740
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("15 people reached"));
|
|
741
|
+
logSpy.mockRestore();
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it("never throws on API error", async () => {
|
|
745
|
+
const client = createMockClient();
|
|
746
|
+
(client.campaigns.getStats as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
|
747
|
+
Object.assign(new Error("server error"), { statusCode: 500 }),
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
751
|
+
await expect(printRecap(client, "failing-campaign")).resolves.toBeUndefined();
|
|
752
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Recap unavailable"));
|
|
753
|
+
logSpy.mockRestore();
|
|
754
|
+
});
|
|
755
|
+
});
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bereach-openclaw",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "BeReach LinkedIn automation plugin for OpenClaw",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"exports": {
|
|
7
|
-
".": "./src/index.ts"
|
|
8
|
-
"./lead-magnet": "./src/lead-magnet/helpers.ts"
|
|
7
|
+
".": "./src/index.ts"
|
|
9
8
|
},
|
|
10
9
|
"scripts": {
|
|
11
|
-
"test:register": "tsx scripts/test-register.ts",
|
|
12
10
|
"test": "vitest run"
|
|
13
11
|
},
|
|
14
12
|
"openclaw": {
|
|
@@ -17,10 +15,10 @@
|
|
|
17
15
|
]
|
|
18
16
|
},
|
|
19
17
|
"dependencies": {
|
|
20
|
-
"bereach": "^0.
|
|
18
|
+
"bereach": "^0.1.4"
|
|
21
19
|
},
|
|
22
20
|
"devDependencies": {
|
|
23
|
-
"@types/node": "^22.
|
|
21
|
+
"@types/node": "^22.19.3",
|
|
24
22
|
"tsx": "^4.21.0",
|
|
25
23
|
"typescript": "^5.9.3",
|
|
26
24
|
"vitest": "^4.0.18"
|
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: 1772724697
|
|
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 | 1772724697 |
|
|
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
|
|
|
@@ -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: 1772724697
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# BeReach Lead Magnet Skill
|
|
@@ -24,51 +24,43 @@ All lead magnet scripts MUST be TypeScript using the `bereach` SDK (see main ski
|
|
|
24
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
25
|
3. **Both must succeed** — tsx passing alone is NOT sufficient. Only save or paste the script when BOTH succeed.
|
|
26
26
|
|
|
27
|
-
## Helpers
|
|
27
|
+
## Helpers (reference patterns — do NOT import)
|
|
28
28
|
|
|
29
|
-
`
|
|
29
|
+
Before writing any lead-magnet script, read `plugins/openclaw/src/lead-magnet/helpers.ts` as context. It contains battle-tested implementations for the patterns most likely to go wrong: pagination, SDK path selection, error handling, and DM guard logic. **Do not import this file** — it is not an npm package. Instead, replicate the relevant patterns inline in your script using the raw `bereach` SDK.
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
import {
|
|
33
|
-
collectAllComments,
|
|
34
|
-
listAllInvitations,
|
|
35
|
-
visitProfileIfNeeded,
|
|
36
|
-
findConversationForDmGuard,
|
|
37
|
-
} from "bereach-openclaw/lead-magnet";
|
|
38
|
-
```
|
|
31
|
+
### withRetry(fn, maxRetries?)
|
|
39
32
|
|
|
40
|
-
|
|
33
|
+
Wraps any SDK call. Retries on 429 with exponential backoff (`2^attempts` seconds). Throws `FatalError` on 401/404/405, `SkippableError` on everything else.
|
|
41
34
|
|
|
42
|
-
###
|
|
35
|
+
### isWithinActiveHours(campaign)
|
|
43
36
|
|
|
44
|
-
|
|
37
|
+
Checks if current time is within the campaign's `activeHours` window in the campaign's `timezone`. Defaults to 07:00-23:00 Europe/Paris.
|
|
45
38
|
|
|
46
|
-
|
|
47
|
-
- Returns `{ profiles, total, skipped }`.
|
|
39
|
+
### printRecap(client, campaignSlug)
|
|
48
40
|
|
|
49
|
-
|
|
41
|
+
Calls `getStats` + `getCredits` and prints a human-friendly end-of-run recap. Async.
|
|
50
42
|
|
|
51
|
-
|
|
43
|
+
### collectAllComments(client, postUrl, campaignSlug, opts?)
|
|
52
44
|
|
|
53
|
-
|
|
45
|
+
Paginates `collectComments` (`while hasMore`, `start += 100`). Returns `{ profiles, total, skipped }`.
|
|
54
46
|
|
|
55
|
-
|
|
47
|
+
- `opts.previousTotal` — does a free `count: 0` check first; if unchanged, returns `{ skipped: true }` immediately.
|
|
48
|
+
|
|
49
|
+
### listAllInvitations(client)
|
|
56
50
|
|
|
57
|
-
|
|
51
|
+
Paginates `listInvitations` (`while total > start + count`, `count: 100`). Returns `{ invitations, total }`.
|
|
58
52
|
|
|
59
|
-
|
|
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).
|
|
53
|
+
### visitProfileIfNeeded(client, profile, campaignSlug, knownDistance)
|
|
62
54
|
|
|
63
|
-
Returns `{ memberDistance, pendingConnection, visited,
|
|
55
|
+
If `knownDistance` is set (including 0): returns it immediately (0 credits). Otherwise calls `client.linkedinScrapers.visitProfile` (not `linkedinActions`). Uses `!= null` (not `||`). Returns `{ memberDistance, pendingConnection, visited, ... }`.
|
|
64
56
|
|
|
65
57
|
### findConversationForDmGuard(client, profile, resourceLink, campaignSlug)
|
|
66
58
|
|
|
67
|
-
Checks whether `resourceLink` was already sent via DM. Uses `client.linkedinChat.findConversation` (
|
|
59
|
+
Checks whether `resourceLink` was already sent via DM. Uses `client.linkedinChat.findConversation` (not `linkedinInbox`). Reads `messages` at the top level (not nested).
|
|
68
60
|
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
61
|
+
- Link found → syncs action, returns `{ skip: true, messages }`.
|
|
62
|
+
- No link → returns `{ skip: false, messages }` (messages available for DM tone adaptation).
|
|
63
|
+
- Error → returns `{ skip: true }` (fail-safe).
|
|
72
64
|
|
|
73
65
|
## Tone
|
|
74
66
|
|
|
@@ -152,12 +144,13 @@ All 3 scripts must perform pre-flight at the start of each run:
|
|
|
152
144
|
|
|
153
145
|
Each script:
|
|
154
146
|
|
|
155
|
-
1. Reads `~/.bereach/lead-magnet.json`
|
|
156
|
-
2.
|
|
157
|
-
3.
|
|
158
|
-
4.
|
|
159
|
-
5.
|
|
160
|
-
6.
|
|
147
|
+
1. Reads `~/.bereach/lead-magnet.json`, validates config (see "Config validation"), filters `campaigns` where `enabled !== false`.
|
|
148
|
+
2. For each campaign: `isWithinActiveHours(campaign)` — skip if outside window.
|
|
149
|
+
3. Acquire lock `lm-{slug}-{script}` (see "Lock mechanism" below). If locked, skip that campaign.
|
|
150
|
+
4. Wrap every SDK call in `withRetry(...)` — handles 429 retries and error classification.
|
|
151
|
+
5. Runs its logic for the campaign.
|
|
152
|
+
6. `printRecap(client, campaignSlug)` — prints human-friendly stats, releases lock, moves to next campaign.
|
|
153
|
+
7. At end: aggregate recap for all campaigns.
|
|
161
154
|
|
|
162
155
|
BeReach dedup ensures no action is performed twice across scripts (same `campaignSlug`).
|
|
163
156
|
|
|
@@ -196,8 +189,9 @@ For each campaign (from config): scrape commenters and engage them.
|
|
|
196
189
|
|
|
197
190
|
Accept pending invitations and deliver the resource to campaign-related invitees.
|
|
198
191
|
|
|
192
|
+
- **Build attribution map first**: for each enabled campaign, call `collectAllComments(client, postUrl, campaignSlug)` and build a `Map<profileUrl, campaign>` from all commenters. This map tells you which campaign an inviter belongs to.
|
|
199
193
|
- `listAllInvitations(client)` → `{ invitations, total }`. Each invitation has: `invitationId`, `sharedSecret`, `fromMember: { name, profileUrl }`. Pagination is handled by the helper.
|
|
200
|
-
- **Campaign attribution**:
|
|
194
|
+
- **Campaign attribution**: look up `invitation.fromMember.profileUrl` in the map. If found, that invitation is campaign-related. Accept all invitations, but only DM if campaign-matched.
|
|
201
195
|
- For each invitation:
|
|
202
196
|
1. `acceptInvitation` with `invitationId` and `sharedSecret`
|
|
203
197
|
2. If campaign matched: `visitProfileIfNeeded(client, profile, campaignSlug, null)`, then **DM dedup** → DM if not already sent.
|
|
@@ -275,19 +269,7 @@ The user can pause or resume any campaign by setting `enabled: false` in `~/.ber
|
|
|
275
269
|
|
|
276
270
|
### Recap (print after each run)
|
|
277
271
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
1. `getStats` with `campaignSlug` → `{ stats: { message, reply, like, visit, connect }, totalProfiles, creditsUsed }`
|
|
281
|
-
2. `getCredits` → `{ credits: { current, limit, remaining, percentage } }`
|
|
282
|
-
|
|
283
|
-
Human-friendly, no jargon. Format:
|
|
284
|
-
|
|
285
|
-
{slug} — Recap
|
|
286
|
-
{totalProfiles} people reached
|
|
287
|
-
{stats.message} DMs sent · {stats.reply} replies · {stats.like} likes
|
|
288
|
-
Credits: {credits.current} used · {credits.remaining}/{credits.limit} remaining
|
|
289
|
-
|
|
290
|
-
You can ask me to disable features to save credits.
|
|
272
|
+
Use `printRecap(client, campaignSlug)` — see Helpers section. It handles `getStats` + `getCredits` and formats the output.
|
|
291
273
|
|
|
292
274
|
### Toggleable features
|
|
293
275
|
|
package/src/commands/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export function registerCommands(api: any) {
|
|
|
11
11
|
description: "Show current BeReach credit balance",
|
|
12
12
|
handler: async () => {
|
|
13
13
|
const client = getClient();
|
|
14
|
-
const res = await
|
|
14
|
+
const res = await client.profile.getCredits();
|
|
15
15
|
const c = res.credits;
|
|
16
16
|
return {
|
|
17
17
|
text: [
|
|
@@ -27,7 +27,7 @@ export function registerCommands(api: any) {
|
|
|
27
27
|
description: "Show LinkedIn rate limit summary",
|
|
28
28
|
handler: async () => {
|
|
29
29
|
const client = getClient();
|
|
30
|
-
const res = await
|
|
30
|
+
const res = await client.profile.getLimits();
|
|
31
31
|
const lines = ["BeReach Rate Limits:"];
|
|
32
32
|
for (const [action, data] of Object.entries(res.limits) as [string, any][]) {
|
|
33
33
|
const d = data.daily;
|
|
@@ -42,7 +42,7 @@ export function registerCommands(api: any) {
|
|
|
42
42
|
description: "Show detailed per-action LinkedIn rate limits",
|
|
43
43
|
handler: async () => {
|
|
44
44
|
const client = getClient();
|
|
45
|
-
const res = await
|
|
45
|
+
const res = await client.profile.getLimits();
|
|
46
46
|
const lines = [`BeReach Rate Limits (multiplier: ${res.multiplier}x):`];
|
|
47
47
|
for (const [action, data] of Object.entries(res.limits) as [string, any][]) {
|
|
48
48
|
const d = data.daily;
|
|
@@ -67,7 +67,7 @@ export function registerCommands(api: any) {
|
|
|
67
67
|
.description("Show LinkedIn rate limit summary")
|
|
68
68
|
.action(async () => {
|
|
69
69
|
const client = getClient();
|
|
70
|
-
const res = await
|
|
70
|
+
const res = await client.profile.getLimits();
|
|
71
71
|
console.log("BeReach Rate Limits:");
|
|
72
72
|
for (const [action, data] of Object.entries(res.limits) as [string, any][]) {
|
|
73
73
|
const d = data.daily;
|
|
@@ -64,6 +64,7 @@ export async function collectAllComments(
|
|
|
64
64
|
if (check.total === opts.previousTotal) {
|
|
65
65
|
return { profiles: [] as CollectLinkedInCommentsProfile[], total: opts.previousTotal, skipped: true };
|
|
66
66
|
}
|
|
67
|
+
await readPause();
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
const allProfiles: CollectLinkedInCommentsProfile[] = [];
|
|
@@ -153,7 +154,7 @@ export async function findConversationForDmGuard(
|
|
|
153
154
|
if (!data.found) return { skip: false, messages: [] };
|
|
154
155
|
|
|
155
156
|
const messages = data.messages ?? [];
|
|
156
|
-
const alreadySent = messages.some((m) =>
|
|
157
|
+
const alreadySent = messages.some((m) => m.text.includes(resourceLink));
|
|
157
158
|
|
|
158
159
|
if (alreadySent) {
|
|
159
160
|
try {
|
|
@@ -169,3 +170,38 @@ export async function findConversationForDmGuard(
|
|
|
169
170
|
|
|
170
171
|
return { skip: false, messages };
|
|
171
172
|
}
|
|
173
|
+
|
|
174
|
+
/** Check if current time is within campaign active hours. Defaults to 07:00-23:00 Europe/Paris. */
|
|
175
|
+
export function isWithinActiveHours(campaign: {
|
|
176
|
+
activeHours?: { start: string; end: string };
|
|
177
|
+
timezone?: string;
|
|
178
|
+
}): boolean {
|
|
179
|
+
const { start = "07:00", end = "23:00" } = campaign.activeHours ?? {};
|
|
180
|
+
const tz = campaign.timezone ?? "Europe/Paris";
|
|
181
|
+
const now = new Intl.DateTimeFormat("en-GB", {
|
|
182
|
+
timeZone: tz,
|
|
183
|
+
hour: "2-digit",
|
|
184
|
+
minute: "2-digit",
|
|
185
|
+
hour12: false,
|
|
186
|
+
}).format(new Date());
|
|
187
|
+
return now >= start && now < end;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Print end-of-run recap: campaign stats + credit balance. Never throws. */
|
|
191
|
+
export async function printRecap(client: Bereach, campaignSlug: string): Promise<void> {
|
|
192
|
+
try {
|
|
193
|
+
const stats = await withRetry(() => client.campaigns.getStats({ campaignSlug }));
|
|
194
|
+
const credits = await withRetry(() => client.profile.getCredits());
|
|
195
|
+
|
|
196
|
+
console.log(`\n${campaignSlug} — Recap`);
|
|
197
|
+
console.log(`${stats.totalProfiles} people reached`);
|
|
198
|
+
console.log(
|
|
199
|
+
`${stats.stats.message ?? 0} DMs sent · ${stats.stats.reply ?? 0} replies · ${stats.stats.like ?? 0} likes`,
|
|
200
|
+
);
|
|
201
|
+
console.log(
|
|
202
|
+
`Credits: ${credits.credits.current} used · ${credits.credits.remaining}/${credits.credits.limit} remaining`,
|
|
203
|
+
);
|
|
204
|
+
} catch {
|
|
205
|
+
console.log(`\n${campaignSlug} — Recap unavailable (API error)`);
|
|
206
|
+
}
|
|
207
|
+
}
|