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
+ });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "bereach",
3
3
  "name": "BeReach",
4
- "version": "0.3.2",
4
+ "version": "0.3.4",
5
5
  "description": "LinkedIn outreach automation — 33 tools, auto-reply commands",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,14 +1,12 @@
1
1
  {
2
2
  "name": "bereach-openclaw",
3
- "version": "0.3.2",
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.2.1"
18
+ "bereach": "^0.1.4"
21
19
  },
22
20
  "devDependencies": {
23
- "@types/node": "^22.10.0",
21
+ "@types/node": "^22.19.3",
24
22
  "tsx": "^4.21.0",
25
23
  "typescript": "^5.9.3",
26
24
  "vitest": "^4.0.18"
@@ -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: 1772701953
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 | 1772701953 |
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: 1772701953
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
- `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.
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
- ```typescript
32
- import {
33
- collectAllComments,
34
- listAllInvitations,
35
- visitProfileIfNeeded,
36
- findConversationForDmGuard,
37
- } from "bereach-openclaw/lead-magnet";
38
- ```
31
+ ### withRetry(fn, maxRetries?)
39
32
 
40
- Your spec should list which helpers you plan to use.
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
- ### collectAllComments(client, postUrl, campaignSlug, opts?)
35
+ ### isWithinActiveHours(campaign)
43
36
 
44
- Paginates `collectComments` automatically (`while hasMore`, `start += 100`). Prevents the most common bug: fetching only the first 100 comments.
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
- - `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 }`.
39
+ ### printRecap(client, campaignSlug)
48
40
 
49
- ### listAllInvitations(client)
41
+ Calls `getStats` + `getCredits` and prints a human-friendly end-of-run recap. Async.
50
42
 
51
- Paginates `listInvitations` automatically (`while total > start + count`, `count: 100`). Prevents fetching only the first 10 invitations.
43
+ ### collectAllComments(client, postUrl, campaignSlug, opts?)
52
44
 
53
- - Returns `{ invitations, total }`.
45
+ Paginates `collectComments` (`while hasMore`, `start += 100`). Returns `{ profiles, total, skipped }`.
54
46
 
55
- ### visitProfileIfNeeded(client, profile, campaignSlug, knownDistance)
47
+ - `opts.previousTotal` does a free `count: 0` check first; if unchanged, returns `{ skipped: true }` immediately.
48
+
49
+ ### listAllInvitations(client)
56
50
 
57
- If `knownDistance` is already set (including 0): returns it immediately (0 credits). If `null`/`undefined`: calls `client.linkedinScrapers.visitProfile` (correct path not `linkedinActions`).
51
+ Paginates `listInvitations` (`while total > start + count`, `count: 100`). Returns `{ invitations, total }`.
58
52
 
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).
53
+ ### visitProfileIfNeeded(client, profile, campaignSlug, knownDistance)
62
54
 
63
- Returns `{ memberDistance, pendingConnection, visited, firstName, headline, ... }`.
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` (correct path — not `linkedinInbox`). Reads `messages` at the top level (not nested).
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
- - 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.
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. Filters `campaigns` where `enabled !== false`
157
- 3. For each campaign: acquire lock `lm-{slug}-{script}` (see "Lock mechanism" below). If locked, skip that campaign — a previous run is still active.
158
- 4. Runs its logic for the campaign.
159
- 5. Prints recap per campaign, releases lock, moves to next campaign.
160
- 6. At end: aggregate recap for all campaigns.
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**: 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.
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
- At the end of every run, call these two methods and print the recap:
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
 
@@ -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 (client as any).profile.getCredits();
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 (client as any).profile.getLimits();
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 (client as any).profile.getLimits();
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 (client as any).profile.getLimits();
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) => (m.text ?? "").includes(resourceLink));
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
+ }