whatsappd 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/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/bin/whatsappd.js +18 -0
- package/dist/adapter-BES4PRA9.mjs +1778 -0
- package/dist/adapters/eve.d.mts +83 -0
- package/dist/adapters/eve.mjs +181 -0
- package/dist/index.d.mts +283 -0
- package/dist/index.mjs +15 -0
- package/dist/ports-DeTIMztA.d.mts +60 -0
- package/dist/sidecar/index.d.mts +47 -0
- package/dist/sidecar/index.mjs +326 -0
- package/dist/stores/libsql.d.mts +16 -0
- package/dist/stores/libsql.mjs +57 -0
- package/dist/stores/memory.d.mts +6 -0
- package/dist/stores/memory.mjs +18 -0
- package/dist/tools/index.d.mts +99 -0
- package/dist/tools/index.mjs +117 -0
- package/dist/types-B8d1OyHV.d.mts +71 -0
- package/dist/update-Bi5ZPUjP.d.mts +341 -0
- package/dist/wire-Bx6OmkxC.d.mts +135 -0
- package/dist/wire-CsVVkLhn.mjs +122 -0
- package/package.json +98 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { a as createSession, i as qrAuth, n as fileStore, r as pairingAuth, t as createChannelAdapter } from "../adapter-BES4PRA9.mjs";
|
|
2
|
+
import { i as toWireUpdate, n as reviveOutbound, r as toWireMessage, t as messagePreview } from "../wire-CsVVkLhn.mjs";
|
|
3
|
+
import pino from "pino";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import qrcode from "qrcode-terminal";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
//#region src/sidecar/server.ts
|
|
8
|
+
/**
|
|
9
|
+
* Sidecar HTTP server — wraps one account's {@link WhatsAppChannelAdapter}
|
|
10
|
+
* behind a plain HTTP surface so framework adapters (Eve, or any HTTP client)
|
|
11
|
+
* never touch the WhatsApp socket. One sidecar process = one WhatsApp account;
|
|
12
|
+
* run more processes for more numbers (they can all forward into the same app —
|
|
13
|
+
* events carry `accountId` so the receiver can tell them apart):
|
|
14
|
+
*
|
|
15
|
+
* POST /send { accountId, chatId, content, opts? } → { ref }
|
|
16
|
+
* POST /markRead { accountId, chatId } → { ok }
|
|
17
|
+
* POST /setTyping { accountId, chatId, kind? } → { ok }
|
|
18
|
+
* GET /media/:accountId/:messageId → decrypted bytes
|
|
19
|
+
* GET /health → { ok, accounts }
|
|
20
|
+
*
|
|
21
|
+
* Inbound WhatsApp events are pushed the other way: the server subscribes to
|
|
22
|
+
* the adapter and POSTs each event (as a `SidecarEvent`) to the configured
|
|
23
|
+
* forward targets. Media bytes never travel in events — messages carry a
|
|
24
|
+
* `/media/...` URL and the consumer pulls bytes on demand (the handles are
|
|
25
|
+
* kept in a bounded in-memory cache).
|
|
26
|
+
*
|
|
27
|
+
* All routes (and forwarded events) carry `Authorization: Bearer <token>`
|
|
28
|
+
* when a token is configured.
|
|
29
|
+
*/
|
|
30
|
+
var HttpError = class extends Error {
|
|
31
|
+
status;
|
|
32
|
+
constructor(status, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.status = status;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
async function readJson(req) {
|
|
38
|
+
const chunks = [];
|
|
39
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
throw new HttpError(400, "invalid JSON body");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function sendJson(res, status, body) {
|
|
47
|
+
const data = JSON.stringify(body);
|
|
48
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
49
|
+
res.end(data);
|
|
50
|
+
}
|
|
51
|
+
function createSidecarServer(options) {
|
|
52
|
+
const log = options.logger;
|
|
53
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
54
|
+
const forward = options.forward ?? [];
|
|
55
|
+
const mediaCapacity = options.mediaCacheSize ?? 512;
|
|
56
|
+
const adapter = options.adapter;
|
|
57
|
+
const media = /* @__PURE__ */ new Map();
|
|
58
|
+
function cacheMedia(key, handle) {
|
|
59
|
+
media.set(key, handle);
|
|
60
|
+
while (media.size > mediaCapacity) {
|
|
61
|
+
const oldest = media.keys().next().value;
|
|
62
|
+
if (oldest === void 0) break;
|
|
63
|
+
media.delete(oldest);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A misaddressed request is a routing bug on the caller's side (e.g. an app
|
|
68
|
+
* serving several sidecars replying to the wrong one) — 404 it rather than
|
|
69
|
+
* silently delivering through the wrong account.
|
|
70
|
+
*/
|
|
71
|
+
function requireAccount(accountId) {
|
|
72
|
+
if (typeof accountId !== "string" || accountId.length === 0) throw new HttpError(400, "accountId is required");
|
|
73
|
+
if (accountId !== adapter.accountId) throw new HttpError(404, `unknown account ${accountId}`);
|
|
74
|
+
return adapter;
|
|
75
|
+
}
|
|
76
|
+
async function forwardEvent(event) {
|
|
77
|
+
await Promise.all(forward.map(async (target) => {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetchFn(target.url, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: {
|
|
82
|
+
"content-type": "application/json",
|
|
83
|
+
...target.token && { authorization: `Bearer ${target.token}` }
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify(event)
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) log?.warn({
|
|
88
|
+
url: target.url,
|
|
89
|
+
status: res.status
|
|
90
|
+
}, "event forward failed");
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log?.warn({
|
|
93
|
+
url: target.url,
|
|
94
|
+
err
|
|
95
|
+
}, "event forward failed");
|
|
96
|
+
}
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
function toSidecarEvent(accountId, event) {
|
|
100
|
+
switch (event.type) {
|
|
101
|
+
case "message": {
|
|
102
|
+
if (event.message.fromMe) return void 0;
|
|
103
|
+
let mediaUrl;
|
|
104
|
+
if ("media" in event.message) {
|
|
105
|
+
const path = `/media/${encodeURIComponent(accountId)}/${encodeURIComponent(event.message.id)}`;
|
|
106
|
+
cacheMedia(`${accountId}/${event.message.id}`, event.message.media);
|
|
107
|
+
mediaUrl = options.baseUrl ? new URL(path, options.baseUrl).href : path;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
type: "message",
|
|
111
|
+
accountId,
|
|
112
|
+
chatId: event.ref.chatId,
|
|
113
|
+
isGroup: event.ref.isGroup,
|
|
114
|
+
from: event.ref.from,
|
|
115
|
+
pushName: event.ref.pushName,
|
|
116
|
+
message: toWireMessage(event.message, mediaUrl)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
case "update": return {
|
|
120
|
+
type: "update",
|
|
121
|
+
accountId,
|
|
122
|
+
chatId: event.ref.chatId,
|
|
123
|
+
update: toWireUpdate(event.update)
|
|
124
|
+
};
|
|
125
|
+
case "status": return {
|
|
126
|
+
type: "status",
|
|
127
|
+
accountId,
|
|
128
|
+
status: event.status
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const unsubscribe = adapter.subscribe({ async onEvent(event) {
|
|
133
|
+
const wire = toSidecarEvent(adapter.accountId, event);
|
|
134
|
+
if (wire) await forwardEvent(wire);
|
|
135
|
+
} });
|
|
136
|
+
async function route(req, res) {
|
|
137
|
+
if (options.token && req.headers.authorization !== `Bearer ${options.token}`) throw new HttpError(401, "unauthorized");
|
|
138
|
+
const url = new URL(req.url ?? "/", "http://sidecar");
|
|
139
|
+
const key = `${req.method} ${url.pathname}`;
|
|
140
|
+
if (key === "GET /health") {
|
|
141
|
+
sendJson(res, 200, {
|
|
142
|
+
ok: true,
|
|
143
|
+
accounts: [adapter.accountId]
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (key === "POST /send") {
|
|
148
|
+
const body = await readJson(req);
|
|
149
|
+
sendJson(res, 200, { ref: await requireAccount(body.accountId).send(body.chatId, reviveOutbound(body.content), body.opts) });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (key === "POST /markRead") {
|
|
153
|
+
const body = await readJson(req);
|
|
154
|
+
await requireAccount(body.accountId).markRead(body.chatId);
|
|
155
|
+
sendJson(res, 200, { ok: true });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (key === "POST /setTyping") {
|
|
159
|
+
const body = await readJson(req);
|
|
160
|
+
await requireAccount(body.accountId).setTyping(body.chatId, body.kind ?? "typing");
|
|
161
|
+
sendJson(res, 200, { ok: true });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const mediaMatch = /^\/media\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
165
|
+
if (req.method === "GET" && mediaMatch) {
|
|
166
|
+
const cacheKey = `${decodeURIComponent(mediaMatch[1])}/${decodeURIComponent(mediaMatch[2])}`;
|
|
167
|
+
const handle = media.get(cacheKey);
|
|
168
|
+
if (!handle) throw new HttpError(404, "media not available (expired from cache?)");
|
|
169
|
+
const bytes = await handle.download();
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
"content-type": handle.mimetype ?? "application/octet-stream",
|
|
172
|
+
"content-length": bytes.length
|
|
173
|
+
});
|
|
174
|
+
res.end(bytes);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
throw new HttpError(404, `no route for ${key}`);
|
|
178
|
+
}
|
|
179
|
+
const server = createServer((req, res) => {
|
|
180
|
+
route(req, res).catch((err) => {
|
|
181
|
+
const status = err instanceof HttpError ? err.status : 500;
|
|
182
|
+
if (status === 500) log?.error({
|
|
183
|
+
err,
|
|
184
|
+
url: req.url
|
|
185
|
+
}, "sidecar request failed");
|
|
186
|
+
const message = err instanceof Error ? err.message : "internal error";
|
|
187
|
+
if (!res.headersSent) sendJson(res, status, { error: message });
|
|
188
|
+
else res.end();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
server,
|
|
193
|
+
listen(port, host = "0.0.0.0") {
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
server.once("error", reject);
|
|
196
|
+
server.listen(port, host, () => {
|
|
197
|
+
const address = server.address();
|
|
198
|
+
const boundPort = typeof address === "object" && address ? address.port : port;
|
|
199
|
+
log?.info({ port: boundPort }, "sidecar listening");
|
|
200
|
+
resolve({ port: boundPort });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
close() {
|
|
205
|
+
unsubscribe();
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/sidecar/index.ts
|
|
214
|
+
/**
|
|
215
|
+
* Sidecar entry point — boots one WhatsApp session, its channel adapter, and
|
|
216
|
+
* the HTTP server. One sidecar process = one WhatsApp account; run more
|
|
217
|
+
* processes for more numbers. Run it standalone:
|
|
218
|
+
*
|
|
219
|
+
* WHATSAPP_FORWARD_URLS=... node --experimental-strip-types src/sidecar/index.ts
|
|
220
|
+
*
|
|
221
|
+
* Environment:
|
|
222
|
+
* PORT HTTP port (default 8788)
|
|
223
|
+
* HOST bind host (default 0.0.0.0)
|
|
224
|
+
* WHATSAPP_ACCOUNT account label on events/requests (default "default")
|
|
225
|
+
* WHATSAPP_STORE_DIR credential store directory (default ./.wa-auth)
|
|
226
|
+
* WHATSAPP_PAIRING_PHONE E.164 phone for pairing-code login (default: QR)
|
|
227
|
+
* WHATSAPP_FORWARD_URLS comma-separated framework endpoints inbound
|
|
228
|
+
* events are POSTed to (e.g. the Eve channel's
|
|
229
|
+
* https://app.example.com/api/channels/whatsapp/event)
|
|
230
|
+
* WHATSAPP_SIDECAR_TOKEN shared bearer token, both directions
|
|
231
|
+
* WHATSAPP_BASE_URL this sidecar's public URL, used to build
|
|
232
|
+
* absolute media URLs in events
|
|
233
|
+
* LOG_LEVEL pino level (default info)
|
|
234
|
+
*
|
|
235
|
+
* First run prints a QR (or pairing code) — link it in WhatsApp → Linked
|
|
236
|
+
* devices. Credentials persist in WHATSAPP_STORE_DIR, so restarts reconnect
|
|
237
|
+
* silently.
|
|
238
|
+
*/
|
|
239
|
+
function pairingLogger(accountId, log) {
|
|
240
|
+
return (event) => {
|
|
241
|
+
if (event.type !== "status") return;
|
|
242
|
+
const status = event.status;
|
|
243
|
+
switch (status.phase) {
|
|
244
|
+
case "pairing":
|
|
245
|
+
if (status.pairing.step === "challenge_live") {
|
|
246
|
+
if (status.pairing.qr) {
|
|
247
|
+
log.info({ accountId }, "scan this QR in WhatsApp → Linked devices");
|
|
248
|
+
qrcode.generate(status.pairing.qr, { small: true });
|
|
249
|
+
} else if (status.pairing.code) log.info({
|
|
250
|
+
accountId,
|
|
251
|
+
code: status.pairing.code
|
|
252
|
+
}, "enter this pairing code");
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
case "online":
|
|
256
|
+
log.info({ accountId }, "online — sendable");
|
|
257
|
+
break;
|
|
258
|
+
case "logged_out":
|
|
259
|
+
case "suspended":
|
|
260
|
+
log.error({
|
|
261
|
+
accountId,
|
|
262
|
+
reason: status.reason
|
|
263
|
+
}, `terminal: ${status.phase}`);
|
|
264
|
+
break;
|
|
265
|
+
default: log.debug({
|
|
266
|
+
accountId,
|
|
267
|
+
phase: status.phase
|
|
268
|
+
}, "status");
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async function runSidecar(env = process.env) {
|
|
273
|
+
const accountId = env.WHATSAPP_ACCOUNT ?? "default";
|
|
274
|
+
const storeDir = env.WHATSAPP_STORE_DIR ?? "./.wa-auth";
|
|
275
|
+
const token = env.WHATSAPP_SIDECAR_TOKEN;
|
|
276
|
+
const phone = env.WHATSAPP_PAIRING_PHONE;
|
|
277
|
+
const forward = (env.WHATSAPP_FORWARD_URLS ?? "").split(",").map((s) => s.trim()).filter(Boolean).map((url) => ({
|
|
278
|
+
url,
|
|
279
|
+
token
|
|
280
|
+
}));
|
|
281
|
+
const logger = pino({
|
|
282
|
+
level: env.LOG_LEVEL ?? "info",
|
|
283
|
+
transport: {
|
|
284
|
+
target: "pino-pretty",
|
|
285
|
+
options: { colorize: true }
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
const adapter = createChannelAdapter({
|
|
289
|
+
session: createSession({
|
|
290
|
+
store: fileStore(storeDir),
|
|
291
|
+
auth: phone ? pairingAuth(phone) : qrAuth(),
|
|
292
|
+
logger
|
|
293
|
+
}),
|
|
294
|
+
accountId,
|
|
295
|
+
logger
|
|
296
|
+
});
|
|
297
|
+
adapter.subscribe({ onEvent: pairingLogger(accountId, logger) });
|
|
298
|
+
const server = createSidecarServer({
|
|
299
|
+
adapter,
|
|
300
|
+
forward,
|
|
301
|
+
token,
|
|
302
|
+
baseUrl: env.WHATSAPP_BASE_URL,
|
|
303
|
+
logger
|
|
304
|
+
});
|
|
305
|
+
await adapter.start();
|
|
306
|
+
const { port } = await server.listen(Number(env.PORT ?? 8788), env.HOST);
|
|
307
|
+
logger.info({
|
|
308
|
+
port,
|
|
309
|
+
accountId,
|
|
310
|
+
forward: forward.map((f) => f.url)
|
|
311
|
+
}, "sidecar up");
|
|
312
|
+
return {
|
|
313
|
+
port,
|
|
314
|
+
server,
|
|
315
|
+
async close() {
|
|
316
|
+
await server.close();
|
|
317
|
+
await adapter.stop();
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) runSidecar().catch((err) => {
|
|
322
|
+
console.error(err);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
});
|
|
325
|
+
//#endregion
|
|
326
|
+
export { createSidecarServer, messagePreview, reviveOutbound, runSidecar, toWireMessage, toWireUpdate };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { n as SessionStore } from "../ports-DeTIMztA.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/stores/libsql.d.ts
|
|
4
|
+
interface LibsqlStoreOptions {
|
|
5
|
+
/** `file:wa-auth.db` for local, or a `libsql://…turso.io` URL for remote. */
|
|
6
|
+
url: string;
|
|
7
|
+
/** Auth token for a remote Turso database. */
|
|
8
|
+
authToken?: string;
|
|
9
|
+
/** Namespace — one row-space per account. Default `"default"`. */
|
|
10
|
+
account?: string;
|
|
11
|
+
/** Table name. Default `"wa_auth"`. */
|
|
12
|
+
table?: string;
|
|
13
|
+
}
|
|
14
|
+
declare function libsqlStore(options: LibsqlStoreOptions): SessionStore;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { LibsqlStoreOptions, libsqlStore };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
//#region src/stores/libsql.ts
|
|
2
|
+
/** Validate the table name ourselves — it's interpolated, never parameterizable. */
|
|
3
|
+
function safeTable(name) {
|
|
4
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`libsqlStore: invalid table name ${JSON.stringify(name)}`);
|
|
5
|
+
return name;
|
|
6
|
+
}
|
|
7
|
+
function libsqlStore(options) {
|
|
8
|
+
const account = options.account ?? "default";
|
|
9
|
+
const table = safeTable(options.table ?? "wa_auth");
|
|
10
|
+
let ready;
|
|
11
|
+
const connect = () => ready ??= (async () => {
|
|
12
|
+
let createClient;
|
|
13
|
+
try {
|
|
14
|
+
({createClient} = await import("@libsql/client"));
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error("libsqlStore requires the optional peer dependency '@libsql/client'. Install it: npm i @libsql/client");
|
|
17
|
+
}
|
|
18
|
+
const client = createClient({
|
|
19
|
+
url: options.url,
|
|
20
|
+
...options.authToken != null && { authToken: options.authToken }
|
|
21
|
+
});
|
|
22
|
+
await client.execute(`CREATE TABLE IF NOT EXISTS ${table} (account TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (account, key))`);
|
|
23
|
+
return client;
|
|
24
|
+
})();
|
|
25
|
+
return {
|
|
26
|
+
async read(key) {
|
|
27
|
+
const value = (await (await connect()).execute({
|
|
28
|
+
sql: `SELECT value FROM ${table} WHERE account = ? AND key = ?`,
|
|
29
|
+
args: [account, key]
|
|
30
|
+
})).rows[0]?.value;
|
|
31
|
+
return value == null ? null : String(value);
|
|
32
|
+
},
|
|
33
|
+
async write(entries) {
|
|
34
|
+
const pairs = Object.entries(entries);
|
|
35
|
+
if (pairs.length === 0) return;
|
|
36
|
+
await (await connect()).batch(pairs.map(([key, value]) => value === null ? {
|
|
37
|
+
sql: `DELETE FROM ${table} WHERE account = ? AND key = ?`,
|
|
38
|
+
args: [account, key]
|
|
39
|
+
} : {
|
|
40
|
+
sql: `INSERT INTO ${table} (account, key, value) VALUES (?, ?, ?) ON CONFLICT(account, key) DO UPDATE SET value = excluded.value`,
|
|
41
|
+
args: [
|
|
42
|
+
account,
|
|
43
|
+
key,
|
|
44
|
+
value
|
|
45
|
+
]
|
|
46
|
+
}), "write");
|
|
47
|
+
},
|
|
48
|
+
async clear() {
|
|
49
|
+
await (await connect()).execute({
|
|
50
|
+
sql: `DELETE FROM ${table} WHERE account = ?`,
|
|
51
|
+
args: [account]
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { libsqlStore };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/stores/memory.ts
|
|
2
|
+
function memoryStore() {
|
|
3
|
+
const map = /* @__PURE__ */ new Map();
|
|
4
|
+
return {
|
|
5
|
+
async read(key) {
|
|
6
|
+
return map.get(key) ?? null;
|
|
7
|
+
},
|
|
8
|
+
async write(entries) {
|
|
9
|
+
for (const [key, value] of Object.entries(entries)) if (value === null) map.delete(key);
|
|
10
|
+
else map.set(key, value);
|
|
11
|
+
},
|
|
12
|
+
async clear() {
|
|
13
|
+
map.clear();
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { memoryStore };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { b as BinaryInput, v as PresenceKind, x as MessageRef } from "../update-Bi5ZPUjP.mjs";
|
|
2
|
+
import { i as WhatsAppChannelAdapter } from "../types-B8d1OyHV.mjs";
|
|
3
|
+
|
|
4
|
+
//#region src/tools/index.d.ts
|
|
5
|
+
/** Media kinds that `sendMedia` accepts. */
|
|
6
|
+
type MediaKind = "image" | "video" | "audio" | "document" | "sticker";
|
|
7
|
+
/**
|
|
8
|
+
* Per-call context injected by the framework adapter.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* `chatId` is the conversation the agent is currently responding in, and
|
|
12
|
+
* `adapter` is the channel adapter for the account handling that conversation.
|
|
13
|
+
*/
|
|
14
|
+
interface ToolContext {
|
|
15
|
+
readonly chatId: string;
|
|
16
|
+
readonly adapter: WhatsAppChannelAdapter;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A generic agent tool: a stable `name`, an LLM-facing `description`, and a
|
|
20
|
+
* typed `call`.
|
|
21
|
+
*
|
|
22
|
+
* @typeParam I - The tool's input shape.
|
|
23
|
+
* @typeParam O - The tool's result shape.
|
|
24
|
+
*/
|
|
25
|
+
interface AgentTool<I = Record<string, unknown>, O = unknown> {
|
|
26
|
+
/** Unique, namespaced tool name (e.g. `"whatsapp.sendText"`). */
|
|
27
|
+
readonly name: string;
|
|
28
|
+
/** Human-readable description surfaced to the model. */
|
|
29
|
+
readonly description: string;
|
|
30
|
+
/**
|
|
31
|
+
* Execute the tool.
|
|
32
|
+
*
|
|
33
|
+
* @param input - The tool's typed input.
|
|
34
|
+
* @param ctx - The per-call {@link ToolContext}.
|
|
35
|
+
* @returns The tool's result.
|
|
36
|
+
*/
|
|
37
|
+
call(input: I, ctx: ToolContext): Promise<O>;
|
|
38
|
+
}
|
|
39
|
+
interface SendTextInput {
|
|
40
|
+
text: string;
|
|
41
|
+
}
|
|
42
|
+
declare const sendText: AgentTool<SendTextInput, MessageRef>;
|
|
43
|
+
interface SendMediaInput {
|
|
44
|
+
kind: MediaKind;
|
|
45
|
+
media: BinaryInput;
|
|
46
|
+
caption?: string;
|
|
47
|
+
/** audio: send as a push-to-talk voice note. */
|
|
48
|
+
ptt?: boolean;
|
|
49
|
+
/** audio: duration in seconds. */
|
|
50
|
+
seconds?: number;
|
|
51
|
+
/** audio/document: override mimetype. */
|
|
52
|
+
mimetype?: string;
|
|
53
|
+
/** document: filename (required for documents). */
|
|
54
|
+
fileName?: string;
|
|
55
|
+
/** video: play silently like a GIF. */
|
|
56
|
+
gifPlayback?: boolean;
|
|
57
|
+
}
|
|
58
|
+
declare const sendMedia: AgentTool<SendMediaInput, MessageRef>;
|
|
59
|
+
interface ReplyInput {
|
|
60
|
+
text: string;
|
|
61
|
+
quote: MessageRef;
|
|
62
|
+
}
|
|
63
|
+
declare const reply: AgentTool<ReplyInput, MessageRef>;
|
|
64
|
+
declare const markRead: AgentTool<void, void>;
|
|
65
|
+
interface SetTypingInput {
|
|
66
|
+
kind?: PresenceKind;
|
|
67
|
+
}
|
|
68
|
+
declare const setTyping: AgentTool<SetTypingInput, void>;
|
|
69
|
+
interface ReactInput {
|
|
70
|
+
emoji: string;
|
|
71
|
+
ref: MessageRef;
|
|
72
|
+
}
|
|
73
|
+
declare const react: AgentTool<ReactInput, MessageRef>;
|
|
74
|
+
interface EditInput {
|
|
75
|
+
text: string;
|
|
76
|
+
ref: MessageRef;
|
|
77
|
+
}
|
|
78
|
+
declare const edit: AgentTool<EditInput, MessageRef>;
|
|
79
|
+
interface DeleteInput {
|
|
80
|
+
ref: MessageRef;
|
|
81
|
+
}
|
|
82
|
+
declare const deleteMsg: AgentTool<DeleteInput, MessageRef>;
|
|
83
|
+
/** All eight tools, in a fixed order. */
|
|
84
|
+
declare const allTools: AgentTool<any, any>[];
|
|
85
|
+
/**
|
|
86
|
+
* Get the full set of tools for a conversation.
|
|
87
|
+
*
|
|
88
|
+
* @remarks
|
|
89
|
+
* Because each tool receives its {@link ToolContext} at call time, this
|
|
90
|
+
* currently returns {@link allTools} unchanged. It exists as the stable entry
|
|
91
|
+
* point a framework adapter calls once per conversation (or per session) to
|
|
92
|
+
* obtain the tool array for its agent.
|
|
93
|
+
*
|
|
94
|
+
* @param _ctx - The conversation context (reserved for future per-context binding).
|
|
95
|
+
* @returns The eight agent tools.
|
|
96
|
+
*/
|
|
97
|
+
declare function bindTools(_ctx: ToolContext): AgentTool<any, any>[];
|
|
98
|
+
//#endregion
|
|
99
|
+
export { AgentTool, DeleteInput, EditInput, MediaKind, ReactInput, ReplyInput, SendMediaInput, SendTextInput, SetTypingInput, ToolContext, allTools, bindTools, deleteMsg, edit, markRead, react, reply, sendMedia, sendText, setTyping };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//#region src/tools/index.ts
|
|
2
|
+
const sendText = {
|
|
3
|
+
name: "whatsapp.sendText",
|
|
4
|
+
description: "Send a text message to the current WhatsApp conversation.",
|
|
5
|
+
async call({ text }, ctx) {
|
|
6
|
+
return ctx.adapter.send(ctx.chatId, { text });
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
function mediaOutbound(input) {
|
|
10
|
+
switch (input.kind) {
|
|
11
|
+
case "image": return {
|
|
12
|
+
image: input.media,
|
|
13
|
+
caption: input.caption
|
|
14
|
+
};
|
|
15
|
+
case "video": return {
|
|
16
|
+
video: input.media,
|
|
17
|
+
caption: input.caption,
|
|
18
|
+
gifPlayback: input.gifPlayback
|
|
19
|
+
};
|
|
20
|
+
case "audio": return {
|
|
21
|
+
audio: input.media,
|
|
22
|
+
ptt: input.ptt,
|
|
23
|
+
seconds: input.seconds,
|
|
24
|
+
mimetype: input.mimetype
|
|
25
|
+
};
|
|
26
|
+
case "document": return {
|
|
27
|
+
document: input.media,
|
|
28
|
+
fileName: input.fileName ?? "file",
|
|
29
|
+
mimetype: input.mimetype ?? "application/octet-stream",
|
|
30
|
+
caption: input.caption
|
|
31
|
+
};
|
|
32
|
+
case "sticker": return { sticker: input.media };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const sendMedia = {
|
|
36
|
+
name: "whatsapp.sendMedia",
|
|
37
|
+
description: "Send an image, video, audio, document, or sticker to the current conversation. Provide `media` as a Buffer, { url }, or { stream }. Documents require `fileName`.",
|
|
38
|
+
async call(input, ctx) {
|
|
39
|
+
return ctx.adapter.send(ctx.chatId, mediaOutbound(input));
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const reply = {
|
|
43
|
+
name: "whatsapp.reply",
|
|
44
|
+
description: "Reply to a specific message by quoting it.",
|
|
45
|
+
async call({ text, quote }, ctx) {
|
|
46
|
+
return ctx.adapter.send(ctx.chatId, { text }, { quote });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const markRead = {
|
|
50
|
+
name: "whatsapp.markRead",
|
|
51
|
+
description: "Mark the current conversation as read (blue ticks).",
|
|
52
|
+
async call(_input, ctx) {
|
|
53
|
+
await ctx.adapter.markRead(ctx.chatId);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const setTyping = {
|
|
57
|
+
name: "whatsapp.setTyping",
|
|
58
|
+
description: "Set typing or recording presence in the current conversation. Defaults to 'typing'; pass 'recording' for voice-note mode.",
|
|
59
|
+
async call({ kind = "typing" }, ctx) {
|
|
60
|
+
await ctx.adapter.setTyping(ctx.chatId, kind);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const react = {
|
|
64
|
+
name: "whatsapp.react",
|
|
65
|
+
description: "React to a message with an emoji. Pass empty string to clear.",
|
|
66
|
+
async call({ emoji, ref }, ctx) {
|
|
67
|
+
return ctx.adapter.send(ctx.chatId, { react: {
|
|
68
|
+
to: ref,
|
|
69
|
+
emoji
|
|
70
|
+
} });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const edit = {
|
|
74
|
+
name: "whatsapp.edit",
|
|
75
|
+
description: "Edit a previously sent message.",
|
|
76
|
+
async call({ text, ref }, ctx) {
|
|
77
|
+
return ctx.adapter.send(ctx.chatId, { edit: {
|
|
78
|
+
target: ref,
|
|
79
|
+
text
|
|
80
|
+
} });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const deleteMsg = {
|
|
84
|
+
name: "whatsapp.delete",
|
|
85
|
+
description: "Delete a message (revoke for everyone).",
|
|
86
|
+
async call({ ref }, ctx) {
|
|
87
|
+
return ctx.adapter.send(ctx.chatId, { delete: ref });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** All eight tools, in a fixed order. */
|
|
91
|
+
const allTools = [
|
|
92
|
+
sendText,
|
|
93
|
+
sendMedia,
|
|
94
|
+
reply,
|
|
95
|
+
markRead,
|
|
96
|
+
setTyping,
|
|
97
|
+
react,
|
|
98
|
+
edit,
|
|
99
|
+
deleteMsg
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* Get the full set of tools for a conversation.
|
|
103
|
+
*
|
|
104
|
+
* @remarks
|
|
105
|
+
* Because each tool receives its {@link ToolContext} at call time, this
|
|
106
|
+
* currently returns {@link allTools} unchanged. It exists as the stable entry
|
|
107
|
+
* point a framework adapter calls once per conversation (or per session) to
|
|
108
|
+
* obtain the tool array for its agent.
|
|
109
|
+
*
|
|
110
|
+
* @param _ctx - The conversation context (reserved for future per-context binding).
|
|
111
|
+
* @returns The eight agent tools.
|
|
112
|
+
*/
|
|
113
|
+
function bindTools(_ctx) {
|
|
114
|
+
return allTools;
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
export { allTools, bindTools, deleteMsg, edit, markRead, react, reply, sendMedia, sendText, setTyping };
|