@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/LICENSE +21 -0
- package/README.md +120 -0
- package/bin/sd.mjs +85 -0
- package/bin/signaldash.js +10 -0
- package/lib/cli.js +358 -0
- package/lib/mcp.js +332 -0
- package/lib/rate-guard.js +219 -0
- package/lib/secrets.js +96 -0
- package/lib/unipile.js +166 -0
- package/package.json +31 -0
- package/skills/signaldash-safe-usage/SKILL.md +69 -0
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { SendRateGuard } from "./rate-guard.js";
|
|
3
|
+
import { UnipileClient, UnipileWarningError } from "./unipile.js";
|
|
4
|
+
|
|
5
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
6
|
+
const CHANNELS = {
|
|
7
|
+
li: { configKey: "linkedin", provider: "LINKEDIN" },
|
|
8
|
+
wa: { configKey: "whatsapp", provider: "WHATSAPP" },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function tool(channel, action) {
|
|
12
|
+
const prefix = channel === "li" ? "LinkedIn" : "WhatsApp";
|
|
13
|
+
const name =
|
|
14
|
+
action === "list"
|
|
15
|
+
? `${channel}_list_chats`
|
|
16
|
+
: action === "read"
|
|
17
|
+
? `${channel}_read_messages`
|
|
18
|
+
: `${channel}_send_message`;
|
|
19
|
+
if (action === "list") {
|
|
20
|
+
return {
|
|
21
|
+
name,
|
|
22
|
+
description: `List ${prefix} chats without fetching individual profiles.`,
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
limit: {
|
|
27
|
+
type: "integer",
|
|
28
|
+
minimum: 1,
|
|
29
|
+
maximum: 50,
|
|
30
|
+
default: 20,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (action === "read") {
|
|
38
|
+
return {
|
|
39
|
+
name,
|
|
40
|
+
description: `Read recent messages from one account-scoped ${prefix} chat.`,
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
chat: { type: "string", minLength: 1 },
|
|
45
|
+
limit: {
|
|
46
|
+
type: "integer",
|
|
47
|
+
minimum: 1,
|
|
48
|
+
maximum: 50,
|
|
49
|
+
default: 20,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ["chat"],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
description: `Send one ${prefix} message. Enforces thread re-read, duplicate detection, pacing, a daily account cap, and stop-on-warning.`,
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
chat: { type: "string", minLength: 1 },
|
|
64
|
+
text: { type: "string", minLength: 1, maxLength: 5000 },
|
|
65
|
+
},
|
|
66
|
+
required: ["chat", "text"],
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const TOOLS = [
|
|
73
|
+
tool("li", "list"),
|
|
74
|
+
tool("li", "read"),
|
|
75
|
+
tool("li", "send"),
|
|
76
|
+
tool("wa", "list"),
|
|
77
|
+
tool("wa", "read"),
|
|
78
|
+
tool("wa", "send"),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
function boundedInteger(value, fallback) {
|
|
82
|
+
if (value === undefined) {
|
|
83
|
+
return fallback;
|
|
84
|
+
}
|
|
85
|
+
if (!Number.isInteger(value) || value < 1 || value > 50) {
|
|
86
|
+
throw new Error("limit must be an integer from 1 to 50");
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requiredText(value, name, maximum = Infinity) {
|
|
92
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
93
|
+
throw new Error(`${name} is required`);
|
|
94
|
+
}
|
|
95
|
+
const text = value.trim();
|
|
96
|
+
if (text.length > maximum) {
|
|
97
|
+
throw new Error(`${name} exceeds ${maximum} characters`);
|
|
98
|
+
}
|
|
99
|
+
return text;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function configuredChannel(config, shortName) {
|
|
103
|
+
const definition = CHANNELS[shortName];
|
|
104
|
+
const account = config.channels?.[definition.configKey];
|
|
105
|
+
if (!account?.accountId) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`${definition.configKey} is not connected; run signaldash connect ${definition.configKey}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return { ...definition, accountId: account.accountId };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cleanChat(chat) {
|
|
114
|
+
return {
|
|
115
|
+
id: chat.id,
|
|
116
|
+
name: chat.name ?? null,
|
|
117
|
+
type: chat.type ?? null,
|
|
118
|
+
timestamp: chat.timestamp ?? null,
|
|
119
|
+
unread_count: chat.unread_count ?? 0,
|
|
120
|
+
archived: chat.archived ?? 0,
|
|
121
|
+
read_only: chat.read_only ?? 0,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cleanMessage(message) {
|
|
126
|
+
return {
|
|
127
|
+
id: message.id ?? message.provider_id ?? null,
|
|
128
|
+
text: message.text ?? null,
|
|
129
|
+
timestamp: message.timestamp ?? null,
|
|
130
|
+
is_sender: Boolean(message.is_sender),
|
|
131
|
+
sender_id: message.sender_id ?? null,
|
|
132
|
+
attachments: (message.attachments || []).map((attachment) => ({
|
|
133
|
+
id: attachment.id ?? null,
|
|
134
|
+
file_name: attachment.file_name ?? null,
|
|
135
|
+
mimetype: attachment.mimetype ?? null,
|
|
136
|
+
file_size: attachment.file_size ?? null,
|
|
137
|
+
type: attachment.type ?? null,
|
|
138
|
+
unavailable: Boolean(attachment.unavailable),
|
|
139
|
+
})),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function itemList(payload) {
|
|
144
|
+
return Array.isArray(payload?.items)
|
|
145
|
+
? payload.items
|
|
146
|
+
: Array.isArray(payload?.messages)
|
|
147
|
+
? payload.messages
|
|
148
|
+
: [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function assertChat(client, channel, chatId) {
|
|
152
|
+
const chat = await client.getChat(chatId);
|
|
153
|
+
if (chat.account_id !== channel.accountId) {
|
|
154
|
+
throw new Error("chat does not belong to the configured account");
|
|
155
|
+
}
|
|
156
|
+
const accountType = String(chat.account_type || "").toUpperCase();
|
|
157
|
+
if (accountType && !accountType.includes(channel.provider)) {
|
|
158
|
+
throw new Error("chat provider does not match the requested channel");
|
|
159
|
+
}
|
|
160
|
+
return chat;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseToolName(name) {
|
|
164
|
+
const match = /^(li|wa)_(list_chats|read_messages|send_message)$/.exec(
|
|
165
|
+
String(name || ""),
|
|
166
|
+
);
|
|
167
|
+
if (!match) {
|
|
168
|
+
throw new Error(`unknown tool: ${name}`);
|
|
169
|
+
}
|
|
170
|
+
return { shortName: match[1], action: match[2] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function executeTool({ name, args, config, client, guard }) {
|
|
174
|
+
const { shortName, action } = parseToolName(name);
|
|
175
|
+
const channel = configuredChannel(config, shortName);
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
if (action === "list_chats") {
|
|
179
|
+
const limit = boundedInteger(args.limit, 20);
|
|
180
|
+
const payload = await client.listChats(channel.accountId, limit);
|
|
181
|
+
return {
|
|
182
|
+
items: itemList(payload)
|
|
183
|
+
.filter((chat) => chat.account_id === channel.accountId)
|
|
184
|
+
.map(cleanChat),
|
|
185
|
+
cursor: payload.cursor ?? null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const chatId = requiredText(args.chat, "chat", 500);
|
|
190
|
+
if (action === "read_messages") {
|
|
191
|
+
await assertChat(client, channel, chatId);
|
|
192
|
+
const limit = boundedInteger(args.limit, 20);
|
|
193
|
+
const payload = await client.listMessages(chatId, limit);
|
|
194
|
+
return {
|
|
195
|
+
chat: chatId,
|
|
196
|
+
items: itemList(payload).map(cleanMessage),
|
|
197
|
+
cursor: payload.cursor ?? null,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const text = requiredText(args.text, "text", 5000);
|
|
202
|
+
const sent = await guard.executeSend({
|
|
203
|
+
channel: channel.configKey,
|
|
204
|
+
accountId: channel.accountId,
|
|
205
|
+
action: async () => {
|
|
206
|
+
await assertChat(client, channel, chatId);
|
|
207
|
+
const recent = itemList(await client.listMessages(chatId, 20));
|
|
208
|
+
const duplicate = recent.find(
|
|
209
|
+
(message) =>
|
|
210
|
+
message.is_sender &&
|
|
211
|
+
typeof message.text === "string" &&
|
|
212
|
+
message.text.trim() === text,
|
|
213
|
+
);
|
|
214
|
+
if (duplicate) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`duplicate blocked: the same outbound text already exists in recent history (${duplicate.id || "message id unavailable"})`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return client.sendMessage(chatId, text);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
sent: true,
|
|
224
|
+
chat: chatId,
|
|
225
|
+
message_id:
|
|
226
|
+
sent.result.message_id ??
|
|
227
|
+
sent.result.messageId ??
|
|
228
|
+
sent.result.id ??
|
|
229
|
+
null,
|
|
230
|
+
safety: sent.safety,
|
|
231
|
+
};
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error instanceof UnipileWarningError) {
|
|
234
|
+
await guard.block(channel.accountId, error.message);
|
|
235
|
+
}
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function result(id, value) {
|
|
241
|
+
return { jsonrpc: "2.0", id, result: value };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function errorResult(id, code, message) {
|
|
245
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function toolResult(id, value, isError = false) {
|
|
249
|
+
return result(id, {
|
|
250
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
251
|
+
...(isError ? { isError: true } : {}),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function runMcp({
|
|
256
|
+
workspace,
|
|
257
|
+
config,
|
|
258
|
+
secrets,
|
|
259
|
+
input = process.stdin,
|
|
260
|
+
output = process.stdout,
|
|
261
|
+
createClient,
|
|
262
|
+
guard,
|
|
263
|
+
}) {
|
|
264
|
+
const client = createClient
|
|
265
|
+
? createClient(secrets.unipileBase, secrets.unipileKey)
|
|
266
|
+
: new UnipileClient({
|
|
267
|
+
base: secrets.unipileBase,
|
|
268
|
+
key: secrets.unipileKey,
|
|
269
|
+
});
|
|
270
|
+
const sendGuard = guard || new SendRateGuard(workspace);
|
|
271
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
272
|
+
|
|
273
|
+
for await (const line of lines) {
|
|
274
|
+
if (!line.trim()) {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
let request;
|
|
278
|
+
try {
|
|
279
|
+
request = JSON.parse(line);
|
|
280
|
+
} catch {
|
|
281
|
+
output.write(`${JSON.stringify(errorResult(null, -32700, "parse error"))}\n`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (request.method === "notifications/initialized") {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (request.method === "initialize") {
|
|
288
|
+
output.write(
|
|
289
|
+
`${JSON.stringify(
|
|
290
|
+
result(request.id, {
|
|
291
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
292
|
+
capabilities: { tools: {} },
|
|
293
|
+
serverInfo: { name: "signaldash", version: "0.1.0" },
|
|
294
|
+
}),
|
|
295
|
+
)}\n`,
|
|
296
|
+
);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (request.method === "tools/list") {
|
|
300
|
+
output.write(
|
|
301
|
+
`${JSON.stringify(result(request.id, { tools: TOOLS }))}\n`,
|
|
302
|
+
);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (request.method === "tools/call") {
|
|
306
|
+
try {
|
|
307
|
+
const value = await executeTool({
|
|
308
|
+
name: request.params?.name,
|
|
309
|
+
args: request.params?.arguments || {},
|
|
310
|
+
config,
|
|
311
|
+
client,
|
|
312
|
+
guard: sendGuard,
|
|
313
|
+
});
|
|
314
|
+
output.write(`${JSON.stringify(toolResult(request.id, value))}\n`);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
output.write(
|
|
317
|
+
`${JSON.stringify(
|
|
318
|
+
toolResult(request.id, { error: error.message }, true),
|
|
319
|
+
)}\n`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (request.id !== undefined) {
|
|
325
|
+
output.write(
|
|
326
|
+
`${JSON.stringify(
|
|
327
|
+
errorResult(request.id, -32601, `method not found: ${request.method}`),
|
|
328
|
+
)}\n`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
mkdir,
|
|
4
|
+
open,
|
|
5
|
+
readFile,
|
|
6
|
+
rename,
|
|
7
|
+
stat,
|
|
8
|
+
unlink,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { UnipileWarningError } from "./unipile.js";
|
|
13
|
+
|
|
14
|
+
export const SEND_SAFETY = Object.freeze({
|
|
15
|
+
linkedin: {
|
|
16
|
+
dailyCap: 18,
|
|
17
|
+
firstDelayMs: [5_000, 15_000],
|
|
18
|
+
intervalMs: [45_000, 90_000],
|
|
19
|
+
},
|
|
20
|
+
whatsapp: {
|
|
21
|
+
dailyCap: 30,
|
|
22
|
+
firstDelayMs: [2_000, 6_000],
|
|
23
|
+
intervalMs: [15_000, 35_000],
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const sleep = (milliseconds) =>
|
|
28
|
+
new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
29
|
+
|
|
30
|
+
function utcDay(date) {
|
|
31
|
+
return date.toISOString().slice(0, 10);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function randomBetween([minimum, maximum], random) {
|
|
35
|
+
return Math.floor(minimum + random() * (maximum - minimum + 1));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class SendRateGuard {
|
|
39
|
+
constructor(
|
|
40
|
+
workspace,
|
|
41
|
+
{
|
|
42
|
+
clock = () => new Date(),
|
|
43
|
+
random = Math.random,
|
|
44
|
+
wait = sleep,
|
|
45
|
+
safety = SEND_SAFETY,
|
|
46
|
+
} = {},
|
|
47
|
+
) {
|
|
48
|
+
this.directory = path.join(workspace, "safety");
|
|
49
|
+
this.statePath = path.join(this.directory, "send-state.json");
|
|
50
|
+
this.lockPath = path.join(this.directory, "send-state.lock");
|
|
51
|
+
this.clock = clock;
|
|
52
|
+
this.random = random;
|
|
53
|
+
this.wait = wait;
|
|
54
|
+
this.safety = safety;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async initialize() {
|
|
58
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
59
|
+
await chmod(this.directory, 0o700);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async acquireLock() {
|
|
63
|
+
await this.initialize();
|
|
64
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
65
|
+
try {
|
|
66
|
+
return await open(this.lockPath, "wx", 0o600);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error.code !== "EEXIST") {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const details = await stat(this.lockPath);
|
|
73
|
+
if (Date.now() - details.mtimeMs > 600_000) {
|
|
74
|
+
await unlink(this.lockPath);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
} catch (statError) {
|
|
78
|
+
if (statError.code !== "ENOENT") {
|
|
79
|
+
throw statError;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await this.wait(100);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new Error("SignalDash send guard is busy; retry later");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async releaseLock(handle) {
|
|
89
|
+
await handle.close();
|
|
90
|
+
try {
|
|
91
|
+
await unlink(this.lockPath);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error.code !== "ENOENT") {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async readState() {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(await readFile(this.statePath, "utf8"));
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error.code === "ENOENT") {
|
|
104
|
+
return { version: 1, days: {}, blocked: {} };
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`cannot read send safety state: ${error.message}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async writeState(state) {
|
|
111
|
+
const days = Object.keys(state.days || {}).sort().slice(-14);
|
|
112
|
+
state.days = Object.fromEntries(days.map((day) => [day, state.days[day]]));
|
|
113
|
+
const temporary = `${this.statePath}.${process.pid}.tmp`;
|
|
114
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
|
|
115
|
+
mode: 0o600,
|
|
116
|
+
});
|
|
117
|
+
await chmod(temporary, 0o600);
|
|
118
|
+
await rename(temporary, this.statePath);
|
|
119
|
+
await chmod(this.statePath, 0o600);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async block(accountId, reason) {
|
|
123
|
+
const handle = await this.acquireLock();
|
|
124
|
+
try {
|
|
125
|
+
const state = await this.readState();
|
|
126
|
+
state.blocked ||= {};
|
|
127
|
+
state.blocked[accountId] = {
|
|
128
|
+
at: this.clock().toISOString(),
|
|
129
|
+
reason: String(reason).slice(0, 500),
|
|
130
|
+
};
|
|
131
|
+
await this.writeState(state);
|
|
132
|
+
} finally {
|
|
133
|
+
await this.releaseLock(handle);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async executeSend({ channel, accountId, action }) {
|
|
138
|
+
const policy = this.safety[channel];
|
|
139
|
+
if (!policy) {
|
|
140
|
+
throw new Error(`unsupported send channel: ${channel}`);
|
|
141
|
+
}
|
|
142
|
+
const handle = await this.acquireLock();
|
|
143
|
+
try {
|
|
144
|
+
const state = await this.readState();
|
|
145
|
+
const blocked = state.blocked?.[accountId];
|
|
146
|
+
if (blocked) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`sending is disabled for this account after a provider warning at ${blocked.at}: ${blocked.reason}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const now = this.clock();
|
|
152
|
+
const day = utcDay(now);
|
|
153
|
+
state.days ||= {};
|
|
154
|
+
state.days[day] ||= {};
|
|
155
|
+
const accountKey = `${channel}:${accountId}`;
|
|
156
|
+
const usage = state.days[day][accountKey] || {
|
|
157
|
+
sent: 0,
|
|
158
|
+
lastSentAt: null,
|
|
159
|
+
};
|
|
160
|
+
if (usage.sent >= policy.dailyCap) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${channel} daily send cap reached (${policy.dailyCap} per account, UTC day)`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const delay = usage.lastSentAt
|
|
167
|
+
? Math.max(
|
|
168
|
+
0,
|
|
169
|
+
new Date(usage.lastSentAt).getTime() +
|
|
170
|
+
randomBetween(policy.intervalMs, this.random) -
|
|
171
|
+
now.getTime(),
|
|
172
|
+
)
|
|
173
|
+
: randomBetween(policy.firstDelayMs, this.random);
|
|
174
|
+
const reservedAt = now.toISOString();
|
|
175
|
+
state.days[day][accountKey] = {
|
|
176
|
+
sent: usage.sent + 1,
|
|
177
|
+
lastSentAt: reservedAt,
|
|
178
|
+
};
|
|
179
|
+
await this.writeState(state);
|
|
180
|
+
if (delay > 0) {
|
|
181
|
+
await this.wait(delay);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let result;
|
|
185
|
+
try {
|
|
186
|
+
result = await action();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof UnipileWarningError) {
|
|
189
|
+
state.blocked ||= {};
|
|
190
|
+
state.blocked[accountId] = {
|
|
191
|
+
at: this.clock().toISOString(),
|
|
192
|
+
reason: error.message.slice(0, 500),
|
|
193
|
+
};
|
|
194
|
+
await this.writeState(state);
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const sentAt = this.clock().toISOString();
|
|
200
|
+
state.days[day][accountKey] = {
|
|
201
|
+
sent: usage.sent + 1,
|
|
202
|
+
lastSentAt: sentAt,
|
|
203
|
+
};
|
|
204
|
+
await this.writeState(state);
|
|
205
|
+
return {
|
|
206
|
+
result,
|
|
207
|
+
safety: {
|
|
208
|
+
channel,
|
|
209
|
+
dailyCap: policy.dailyCap,
|
|
210
|
+
sentToday: usage.sent + 1,
|
|
211
|
+
remainingToday: policy.dailyCap - usage.sent - 1,
|
|
212
|
+
sentAt,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
} finally {
|
|
216
|
+
await this.releaseLock(handle);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
randomBytes,
|
|
5
|
+
} from "node:crypto";
|
|
6
|
+
import {
|
|
7
|
+
chmod,
|
|
8
|
+
mkdir,
|
|
9
|
+
readFile,
|
|
10
|
+
rename,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
const KEY_BYTES = 32;
|
|
16
|
+
|
|
17
|
+
async function keyFor(workspace) {
|
|
18
|
+
const keyPath = path.join(workspace, "device.key");
|
|
19
|
+
try {
|
|
20
|
+
const key = await readFile(keyPath);
|
|
21
|
+
if (key.length !== KEY_BYTES) {
|
|
22
|
+
throw new Error(`invalid encryption key at ${keyPath}`);
|
|
23
|
+
}
|
|
24
|
+
return key;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.code !== "ENOENT") {
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
const key = randomBytes(KEY_BYTES);
|
|
30
|
+
await mkdir(workspace, { recursive: true, mode: 0o700 });
|
|
31
|
+
try {
|
|
32
|
+
await writeFile(keyPath, key, { mode: 0o600, flag: "wx" });
|
|
33
|
+
} catch (writeError) {
|
|
34
|
+
if (writeError.code === "EEXIST") {
|
|
35
|
+
return keyFor(workspace);
|
|
36
|
+
}
|
|
37
|
+
throw writeError;
|
|
38
|
+
}
|
|
39
|
+
await chmod(keyPath, 0o600);
|
|
40
|
+
return key;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function writeSecrets(workspace, value) {
|
|
45
|
+
const key = await keyFor(workspace);
|
|
46
|
+
const iv = randomBytes(12);
|
|
47
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
48
|
+
const encrypted = Buffer.concat([
|
|
49
|
+
cipher.update(JSON.stringify(value), "utf8"),
|
|
50
|
+
cipher.final(),
|
|
51
|
+
]);
|
|
52
|
+
const envelope = {
|
|
53
|
+
version: 1,
|
|
54
|
+
algorithm: "aes-256-gcm",
|
|
55
|
+
iv: iv.toString("base64"),
|
|
56
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
57
|
+
ciphertext: encrypted.toString("base64"),
|
|
58
|
+
};
|
|
59
|
+
const target = path.join(workspace, "secrets.enc");
|
|
60
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
61
|
+
await writeFile(temporary, `${JSON.stringify(envelope)}\n`, { mode: 0o600 });
|
|
62
|
+
await chmod(temporary, 0o600);
|
|
63
|
+
await rename(temporary, target);
|
|
64
|
+
await chmod(target, 0o600);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function readSecrets(workspace) {
|
|
68
|
+
const key = await keyFor(workspace);
|
|
69
|
+
const target = path.join(workspace, "secrets.enc");
|
|
70
|
+
let envelope;
|
|
71
|
+
try {
|
|
72
|
+
envelope = JSON.parse(await readFile(target, "utf8"));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.code === "ENOENT") {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`cannot read encrypted credentials: ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
envelope.version !== 1 ||
|
|
81
|
+
envelope.algorithm !== "aes-256-gcm"
|
|
82
|
+
) {
|
|
83
|
+
throw new Error("unsupported credential envelope");
|
|
84
|
+
}
|
|
85
|
+
const decipher = createDecipheriv(
|
|
86
|
+
"aes-256-gcm",
|
|
87
|
+
key,
|
|
88
|
+
Buffer.from(envelope.iv, "base64"),
|
|
89
|
+
);
|
|
90
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
|
|
91
|
+
const cleartext = Buffer.concat([
|
|
92
|
+
decipher.update(Buffer.from(envelope.ciphertext, "base64")),
|
|
93
|
+
decipher.final(),
|
|
94
|
+
]);
|
|
95
|
+
return JSON.parse(cleartext.toString("utf8"));
|
|
96
|
+
}
|