bereach-openclaw 0.3.3 → 0.3.5

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.3",
4
+ "version": "0.3.5",
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.3",
3
+ "version": "0.3.5",
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": {
@@ -20,7 +18,7 @@
20
18
  "bereach": "^0.2.1"
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: 1772721672
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 | 1772721672 |
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: 1772721672
4
+ lastUpdatedAt: 1772724697
5
5
  ---
6
6
 
7
7
  # BeReach Lead Magnet Skill
@@ -28,36 +28,39 @@ All lead magnet scripts MUST be TypeScript using the `bereach` SDK (see main ski
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
- ### collectAllComments(client, postUrl, campaignSlug, opts?)
31
+ ### withRetry(fn, maxRetries?)
32
32
 
33
- Paginates `collectComments` automatically (`while hasMore`, `start += 100`). Prevents the most common bug: fetching only the first 100 comments.
33
+ Wraps any SDK call. Retries on 429 with exponential backoff (`2^attempts` seconds). Throws `FatalError` on 401/404/405, `SkippableError` on everything else.
34
34
 
35
- - `opts.previousTotal` — pass the stored total from last run. Does a free `count: 0` check first; if unchanged, returns `{ skipped: true }` immediately.
36
- - Returns `{ profiles, total, skipped }`.
35
+ ### isWithinActiveHours(campaign)
37
36
 
38
- ### listAllInvitations(client)
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.
39
38
 
40
- Paginates `listInvitations` automatically (`while total > start + count`, `count: 100`). Prevents fetching only the first 10 invitations.
39
+ ### printRecap(client, campaignSlug)
41
40
 
42
- - Returns `{ invitations, total }`.
41
+ Calls `getStats` + `getCredits` and prints a human-friendly end-of-run recap. Async.
43
42
 
44
- ### visitProfileIfNeeded(client, profile, campaignSlug, knownDistance)
43
+ ### collectAllComments(client, postUrl, campaignSlug, opts?)
45
44
 
46
- If `knownDistance` is already set (including 0): returns it immediately (0 credits). If `null`/`undefined`: calls `client.linkedinScrapers.visitProfile` (correct path — not `linkedinActions`).
45
+ Paginates `collectComments` (`while hasMore`, `start += 100`). Returns `{ profiles, total, skipped }`.
47
46
 
48
- Prevents two bugs:
49
- - `knownDistance || 2` — when `knownDistance` is null, the fallback should be a visit, not an assumption.
50
- - `client.linkedinActions.visitProfile` — wrong namespace (doesn't exist).
47
+ - `opts.previousTotal` — does a free `count: 0` check first; if unchanged, returns `{ skipped: true }` immediately.
51
48
 
52
- Returns `{ memberDistance, pendingConnection, visited, firstName, headline, ... }`.
49
+ ### listAllInvitations(client)
50
+
51
+ Paginates `listInvitations` (`while total > start + count`, `count: 100`). Returns `{ invitations, total }`.
52
+
53
+ ### visitProfileIfNeeded(client, profile, campaignSlug, knownDistance)
54
+
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, ... }`.
53
56
 
54
57
  ### findConversationForDmGuard(client, profile, resourceLink, campaignSlug)
55
58
 
56
- 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).
57
60
 
58
- - If link found: syncs action via `syncActions`, returns `{ skip: true, messages }`.
59
- - If no link: returns `{ skip: false, messages }` messages available for DM tone adaptation.
60
- - 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).
61
64
 
62
65
  ## Tone
63
66
 
@@ -141,12 +144,13 @@ All 3 scripts must perform pre-flight at the start of each run:
141
144
 
142
145
  Each script:
143
146
 
144
- 1. Reads `~/.bereach/lead-magnet.json`
145
- 2. Filters `campaigns` where `enabled !== false`
146
- 3. For each campaign: acquire lock `lm-{slug}-{script}` (see "Lock mechanism" below). If locked, skip that campaign — a previous run is still active.
147
- 4. Runs its logic for the campaign.
148
- 5. Prints recap per campaign, releases lock, moves to next campaign.
149
- 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.
150
154
 
151
155
  BeReach dedup ensures no action is performed twice across scripts (same `campaignSlug`).
152
156
 
@@ -185,8 +189,9 @@ For each campaign (from config): scrape commenters and engage them.
185
189
 
186
190
  Accept pending invitations and deliver the resource to campaign-related invitees.
187
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.
188
193
  - `listAllInvitations(client)` → `{ invitations, total }`. Each invitation has: `invitationId`, `sharedSecret`, `fromMember: { name, profileUrl }`. Pagination is handled by the helper.
189
- - **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.
190
195
  - For each invitation:
191
196
  1. `acceptInvitation` with `invitationId` and `sharedSecret`
192
197
  2. If campaign matched: `visitProfileIfNeeded(client, profile, campaignSlug, null)`, then **DM dedup** → DM if not already sent.
@@ -264,19 +269,7 @@ The user can pause or resume any campaign by setting `enabled: false` in `~/.ber
264
269
 
265
270
  ### Recap (print after each run)
266
271
 
267
- At the end of every run, call these two methods and print the recap:
268
-
269
- 1. `getStats` with `campaignSlug` → `{ stats: { message, reply, like, visit, connect }, totalProfiles, creditsUsed }`
270
- 2. `getCredits` → `{ credits: { current, limit, remaining, percentage } }`
271
-
272
- Human-friendly, no jargon. Format:
273
-
274
- {slug} — Recap
275
- {totalProfiles} people reached
276
- {stats.message} DMs sent · {stats.reply} replies · {stats.like} likes
277
- Credits: {credits.current} used · {credits.remaining}/{credits.limit} remaining
278
-
279
- 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.
280
273
 
281
274
  ### Toggleable features
282
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
+ }