@floomhq/signaldash 0.1.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/lib/unipile.js ADDED
@@ -0,0 +1,166 @@
1
+ const WARNING_PATTERN =
2
+ /warning|checkpoint|restriction|rate.?limit|automated activity|spam|temporar(?:ily)? blocked/i;
3
+
4
+ function topLevelWarning(payload) {
5
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
6
+ return null;
7
+ }
8
+ for (const key of ["warning", "warnings"]) {
9
+ const value = payload[key];
10
+ if (value !== undefined && value !== null && value !== "") {
11
+ return `${key}: ${JSON.stringify(value).slice(0, 300)}`;
12
+ }
13
+ }
14
+ const description = [
15
+ payload.type,
16
+ payload.title,
17
+ payload.detail,
18
+ payload.error,
19
+ payload.message,
20
+ ]
21
+ .filter((value) => typeof value === "string")
22
+ .join(" ");
23
+ return WARNING_PATTERN.test(description) ? description.slice(0, 300) : null;
24
+ }
25
+
26
+ export class UnipileWarningError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "UnipileWarningError";
30
+ }
31
+ }
32
+
33
+ export function accountIsReady(account) {
34
+ const sources = Array.isArray(account?.sources) ? account.sources : [];
35
+ return (
36
+ Boolean(account?.id) &&
37
+ (sources.length === 0 ||
38
+ sources.every(
39
+ (source) => String(source.status || "").toUpperCase() === "OK",
40
+ ))
41
+ );
42
+ }
43
+
44
+ export class UnipileClient {
45
+ constructor({ base, key, fetchImpl = globalThis.fetch }) {
46
+ this.base = base.replace(/\/+$/, "");
47
+ this.key = key;
48
+ this.fetchImpl = fetchImpl;
49
+ }
50
+
51
+ async request(method, route, { query, body } = {}) {
52
+ if (!route.startsWith("/") || route.startsWith("//")) {
53
+ throw new Error("invalid Unipile route");
54
+ }
55
+ const url = new URL(`${this.base}${route}`);
56
+ for (const [key, value] of Object.entries(query || {})) {
57
+ if (value !== undefined && value !== null && value !== "") {
58
+ url.searchParams.set(key, String(value));
59
+ }
60
+ }
61
+ let response;
62
+ try {
63
+ response = await this.fetchImpl(url, {
64
+ method,
65
+ headers: {
66
+ "X-API-KEY": this.key,
67
+ Accept: "application/json",
68
+ ...(body ? { "Content-Type": "application/json" } : {}),
69
+ },
70
+ body: body ? JSON.stringify(body) : undefined,
71
+ signal: AbortSignal.timeout(30_000),
72
+ });
73
+ } catch (error) {
74
+ throw new Error(
75
+ `Unipile request failed: ${error.cause?.code || error.message}`,
76
+ );
77
+ }
78
+
79
+ const raw = await response.text();
80
+ let payload = {};
81
+ if (raw.trim()) {
82
+ try {
83
+ payload = JSON.parse(raw);
84
+ } catch {
85
+ throw new Error(`Unipile returned invalid JSON (${response.status})`);
86
+ }
87
+ }
88
+
89
+ const warningHeader = [...response.headers.entries()].find(([name]) =>
90
+ name.toLowerCase().includes("warning"),
91
+ );
92
+ const warning = topLevelWarning(payload);
93
+ if (warningHeader || response.status === 403 || response.status === 429 || warning) {
94
+ const detail =
95
+ warningHeader?.join(": ") ||
96
+ warning ||
97
+ `HTTP ${response.status}`;
98
+ throw new UnipileWarningError(
99
+ `Unipile safety warning on ${route}: ${detail}`,
100
+ );
101
+ }
102
+ if (!response.ok) {
103
+ const detail = [
104
+ payload.title,
105
+ payload.detail,
106
+ payload.message,
107
+ raw.slice(0, 200),
108
+ ].find((value) => typeof value === "string" && value.trim());
109
+ throw new Error(
110
+ `Unipile request failed (${response.status})${detail ? `: ${detail}` : ""}`,
111
+ );
112
+ }
113
+ return payload;
114
+ }
115
+
116
+ listAccounts() {
117
+ return this.request("GET", "/accounts");
118
+ }
119
+
120
+ async createHostedAuthLink(provider) {
121
+ const payload = await this.request("POST", "/hosted/accounts/link", {
122
+ body: {
123
+ type: "create",
124
+ providers: [provider],
125
+ api_url: this.base,
126
+ expiresOn: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
127
+ },
128
+ });
129
+ let url;
130
+ try {
131
+ url = new URL(payload.url);
132
+ } catch {
133
+ throw new Error("Unipile did not return a hosted-auth URL");
134
+ }
135
+ if (url.protocol !== "https:" || url.hostname !== "account.unipile.com") {
136
+ throw new Error("Unipile returned an unexpected hosted-auth URL");
137
+ }
138
+ return url.toString();
139
+ }
140
+
141
+ listChats(accountId, limit) {
142
+ return this.request("GET", "/chats", {
143
+ query: { account_id: accountId, limit },
144
+ });
145
+ }
146
+
147
+ getChat(chatId) {
148
+ return this.request("GET", `/chats/${encodeURIComponent(chatId)}`);
149
+ }
150
+
151
+ listMessages(chatId, limit) {
152
+ return this.request(
153
+ "GET",
154
+ `/chats/${encodeURIComponent(chatId)}/messages`,
155
+ { query: { limit } },
156
+ );
157
+ }
158
+
159
+ sendMessage(chatId, text) {
160
+ return this.request(
161
+ "POST",
162
+ `/chats/${encodeURIComponent(chatId)}/messages`,
163
+ { body: { text } },
164
+ );
165
+ }
166
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@floomhq/signaldash",
3
+ "version": "0.1.0",
4
+ "description": "Secure LinkedIn and WhatsApp MCP access for AI agents",
5
+ "type": "module",
6
+ "bin": {
7
+ "signaldash": "bin/sd.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "skills",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test",
21
+ "check": "node --check bin/signaldash.js && node --check lib/cli.js && node --check lib/mcp.js && node --check lib/rate-guard.js && node --check lib/secrets.js && node --check lib/unipile.js"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "linkedin",
26
+ "whatsapp",
27
+ "unipile",
28
+ "ai-agent"
29
+ ],
30
+ "license": "MIT"
31
+ }
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: signaldash-safe-usage
3
+ description: Safely read and send LinkedIn or WhatsApp messages through the SignalDash MCP without triggering account restrictions or contacting the wrong person. Use whenever an agent uses li_list_chats, li_read_messages, li_send_message, wa_list_chats, wa_read_messages, or wa_send_message.
4
+ ---
5
+
6
+ # Use SignalDash safely
7
+
8
+ Treat LinkedIn and WhatsApp as human accounts, not bulk messaging APIs.
9
+
10
+ ## Before every send
11
+
12
+ 1. Confirm the requested channel and exact chat.
13
+ 2. Use `li_list_chats` or `wa_list_chats`; never fetch profiles in bulk.
14
+ 3. Read at least the 10 most recent messages in the exact thread.
15
+ 4. Check the recipient, prior conversation, last inbound message, and whether
16
+ the proposed text was already sent.
17
+ 5. Obtain explicit human approval for the exact recipient and exact text when
18
+ the user has not already approved both.
19
+ 6. Send one message, then read the thread again to confirm delivery.
20
+
21
+ Never infer a recipient from a partial name. Never send blind. Never retry a
22
+ send after an ambiguous timeout without first reading the thread.
23
+
24
+ ## LinkedIn limits
25
+
26
+ - Keep normal messaging at 15 to 20 sends per day. SignalDash hard-caps at 18
27
+ send attempts per account per UTC day.
28
+ - Let SignalDash enforce 45 to 90 seconds between sends. Do not parallelize
29
+ send calls or bypass the guard.
30
+ - LinkedIn invitation ceilings vary and often surface around 100 invitations
31
+ per week. SignalDash exposes no invitation tool. Do not automate invites.
32
+ - Never bulk-fetch profiles, run enrichment sweeps, or repeatedly open
33
+ individual profiles. Prefer chat lists, exported connection data, and
34
+ cached records.
35
+ - Stop all LinkedIn activity on any checkpoint, warning, restriction, unusual
36
+ verification prompt, HTTP 403, or HTTP 429.
37
+
38
+ LinkedIn detects behavior, not only API request frequency. Repeated copy,
39
+ bursting sends, high profile-view volume, many ignored invitations, concurrent
40
+ sessions, and repeated checkpoint retries trigger restrictions.
41
+
42
+ ## WhatsApp limits
43
+
44
+ - SignalDash hard-caps at 30 send attempts per account per UTC day and
45
+ enforces 15 to 35 seconds between sends.
46
+ - Send only in existing, relevant conversations unless the human explicitly
47
+ authorizes a new contact.
48
+ - Avoid repeated identical text, bulk cold outreach, rapid group posting, and
49
+ many new chats from a fresh number.
50
+ - Use an established number with real conversation history. Keep the primary
51
+ phone powered on and connected so linked-device history remains current.
52
+ - Stop on logout, QR re-authentication, delivery anomalies, provider warnings,
53
+ HTTP 403, or HTTP 429.
54
+
55
+ ## When a guard blocks
56
+
57
+ Do not work around daily caps, jitter, duplicate detection, account scoping,
58
+ or the persistent warning lock.
59
+
60
+ On a warning:
61
+
62
+ 1. Stop all sends on that account.
63
+ 2. Inspect LinkedIn or WhatsApp manually for a restriction or checkpoint.
64
+ 3. Wait until the account is clearly back in good standing.
65
+ 4. Let a human clear only that account's entry in
66
+ `~/.signaldash/safety/send-state.json`.
67
+ 5. Resume at a lower volume.
68
+
69
+ Let the agent act slowly and human-like. Account health outranks throughput.