@xmanrui/dsh-im 0.1.0 → 0.2.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/README.md +32 -12
- package/THIRD_PARTY_NOTICES.md +35 -3
- package/lib/client.js +772 -68
- package/lib/index.js +255 -13339
- package/package.json +5 -4
- package/plugin-src/client/channel-logos.js +13 -0
- package/plugin-src/client/channels/shared/token-channel.js +32 -15
- package/plugin-src/client/channels/whatsapp/api.js +123 -0
- package/plugin-src/client/channels/whatsapp/index.js +433 -0
- package/plugin-src/client/channels/whatsapp/styles.js +19 -0
- package/plugin-src/client/index.js +20 -2
- package/plugin-src/client/styles.js +3 -1
- package/plugin-src/host/build.mjs +22 -2
- package/plugin-src/host/channels/whatsapp/index.mjs +35 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +121 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +140 -0
- package/plugin-src/host/index.mjs +3 -0
- package/scripts/verify-package.mjs +28 -2
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/whatsapp/config-store.mjs +165 -0
- package/src/channels/whatsapp/harness-client.mjs +3 -0
- package/src/channels/whatsapp/state-store.mjs +3 -0
- package/src/channels/whatsapp/whatsapp-bridge.mjs +15 -0
- package/src/channels/whatsapp/whatsapp-controller.mjs +388 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +299 -0
- package/src/channels/whatsapp/whatsapp-web-session.mjs +212 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { deriveWhatsappBotId, maskWhatsappAccount } from './config-store.mjs';
|
|
4
|
+
|
|
5
|
+
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
|
6
|
+
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
|
|
7
|
+
const QR_TTL_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
function safeError(code, message) {
|
|
10
|
+
return Object.freeze({ code, message });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function publicAttempt(record) {
|
|
14
|
+
if (!record) return null;
|
|
15
|
+
return {
|
|
16
|
+
attemptId: record.id,
|
|
17
|
+
status: record.state,
|
|
18
|
+
qrRevision: record.qrRevision,
|
|
19
|
+
pollIntervalMs: 1_000,
|
|
20
|
+
...(record.qrValue ? { qrValue: record.qrValue } : {}),
|
|
21
|
+
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
|
22
|
+
...(record.botId ? { botId: record.botId } : {}),
|
|
23
|
+
...(record.error ? { error: structuredClone(record.error) } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class WhatsappController {
|
|
28
|
+
#configStore;
|
|
29
|
+
#authPath;
|
|
30
|
+
#createSession;
|
|
31
|
+
#createRuntime;
|
|
32
|
+
#deleteAuth;
|
|
33
|
+
#deleteState;
|
|
34
|
+
#logger;
|
|
35
|
+
#runtimes = new Map();
|
|
36
|
+
#errors = new Map();
|
|
37
|
+
#attempts = new Map();
|
|
38
|
+
#transitions = new Map();
|
|
39
|
+
#activeAttemptId = null;
|
|
40
|
+
#revision = 0;
|
|
41
|
+
#closed = false;
|
|
42
|
+
|
|
43
|
+
constructor({
|
|
44
|
+
configStore,
|
|
45
|
+
authPath,
|
|
46
|
+
createSession,
|
|
47
|
+
createRuntime,
|
|
48
|
+
deleteAuth = async () => {},
|
|
49
|
+
deleteState = async () => {},
|
|
50
|
+
logger = console,
|
|
51
|
+
}) {
|
|
52
|
+
if (!configStore || typeof configStore.list !== 'function'
|
|
53
|
+
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
|
54
|
+
throw new TypeError('WhatsappController requires a config store');
|
|
55
|
+
}
|
|
56
|
+
if (typeof authPath !== 'function' || typeof createSession !== 'function'
|
|
57
|
+
|| typeof createRuntime !== 'function') {
|
|
58
|
+
throw new TypeError('WhatsappController dependencies are incomplete');
|
|
59
|
+
}
|
|
60
|
+
this.#configStore = configStore;
|
|
61
|
+
this.#authPath = authPath;
|
|
62
|
+
this.#createSession = createSession;
|
|
63
|
+
this.#createRuntime = createRuntime;
|
|
64
|
+
this.#deleteAuth = deleteAuth;
|
|
65
|
+
this.#deleteState = deleteState;
|
|
66
|
+
this.#logger = logger;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async initialize() {
|
|
70
|
+
if (this.#closed) return this.status();
|
|
71
|
+
for (const config of this.#configStore.list()) {
|
|
72
|
+
await this.#withBotTransition(config.botId, async () => {
|
|
73
|
+
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
|
|
74
|
+
try {
|
|
75
|
+
await this.#startRuntime(config);
|
|
76
|
+
this.#errors.delete(config.botId);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
this.#errors.set(config.botId, safeError(
|
|
79
|
+
error?.code === 'relink-required' ? 'relink-required' : 'connection-failed',
|
|
80
|
+
error?.code === 'relink-required'
|
|
81
|
+
? 'WhatsApp 关联设备已失效,请移除后重新扫码。'
|
|
82
|
+
: 'WhatsApp 连接未就绪,插件会自动重试。',
|
|
83
|
+
));
|
|
84
|
+
this.#logger.warn?.(`[dsh-im:whatsapp] bot ${config.botId} failed to initialize`);
|
|
85
|
+
} finally {
|
|
86
|
+
this.#touch();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return this.status();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async startProvisioning() {
|
|
94
|
+
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
|
95
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
96
|
+
let resolveFirstQr;
|
|
97
|
+
let rejectFirstQr;
|
|
98
|
+
let firstQrSettled = false;
|
|
99
|
+
const firstQr = new Promise((resolve, reject) => {
|
|
100
|
+
resolveFirstQr = () => {
|
|
101
|
+
if (firstQrSettled) return;
|
|
102
|
+
firstQrSettled = true;
|
|
103
|
+
resolve();
|
|
104
|
+
};
|
|
105
|
+
rejectFirstQr = (error) => {
|
|
106
|
+
if (firstQrSettled) return;
|
|
107
|
+
firstQrSettled = true;
|
|
108
|
+
reject(error);
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
const id = randomUUID();
|
|
112
|
+
const record = {
|
|
113
|
+
id,
|
|
114
|
+
state: 'starting',
|
|
115
|
+
authDirectory: id,
|
|
116
|
+
createdAt: Date.now(),
|
|
117
|
+
expiresAt: null,
|
|
118
|
+
qrRevision: 0,
|
|
119
|
+
qrValue: null,
|
|
120
|
+
controller: new AbortController(),
|
|
121
|
+
session: null,
|
|
122
|
+
task: null,
|
|
123
|
+
error: null,
|
|
124
|
+
botId: null,
|
|
125
|
+
};
|
|
126
|
+
this.#attempts.set(id, record);
|
|
127
|
+
this.#activeAttemptId = id;
|
|
128
|
+
this.#touch();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const session = await this.#createSession({
|
|
132
|
+
authDir: this.#authPath(record.authDirectory),
|
|
133
|
+
signal: record.controller.signal,
|
|
134
|
+
logger: this.#logger,
|
|
135
|
+
onQr: (value) => {
|
|
136
|
+
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)
|
|
137
|
+
|| typeof value !== 'string' || !value) return;
|
|
138
|
+
record.qrValue = value;
|
|
139
|
+
record.qrRevision += 1;
|
|
140
|
+
record.expiresAt = Date.now() + QR_TTL_MS;
|
|
141
|
+
record.state = 'pending';
|
|
142
|
+
this.#touch();
|
|
143
|
+
resolveFirstQr();
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
record.session = session;
|
|
147
|
+
record.task = session.ready.then((identity) => this.#completeProvisioning(record, identity))
|
|
148
|
+
.catch((error) => this.#failProvisioning(record, error, rejectFirstQr));
|
|
149
|
+
await firstQr;
|
|
150
|
+
return publicAttempt(record);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
await this.#failProvisioning(record, error, rejectFirstQr);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
registrationStatus(attemptId) {
|
|
158
|
+
return publicAttempt(this.#attempts.get(attemptId));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async cancelProvisioning(attemptId) {
|
|
162
|
+
const record = this.#attempts.get(attemptId);
|
|
163
|
+
if (!record) return null;
|
|
164
|
+
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
|
165
|
+
record.controller.abort();
|
|
166
|
+
await record.session?.close().catch(() => undefined);
|
|
167
|
+
await record.task?.catch(() => undefined);
|
|
168
|
+
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
|
169
|
+
record.state = 'cancelled';
|
|
170
|
+
record.error = safeError('cancelled', '扫码接入已取消。');
|
|
171
|
+
}
|
|
172
|
+
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
175
|
+
this.#touch();
|
|
176
|
+
return publicAttempt(record);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async reconnectBot(botId) {
|
|
180
|
+
const config = this.#configStore.get(botId);
|
|
181
|
+
if (!config) throw new Error('Unknown WhatsApp bot');
|
|
182
|
+
await this.#withBotTransition(botId, async () => {
|
|
183
|
+
try {
|
|
184
|
+
await this.#startRuntime(config);
|
|
185
|
+
this.#errors.delete(botId);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
this.#errors.set(botId, safeError(
|
|
188
|
+
error?.code === 'relink-required' ? 'relink-required' : 'connection-failed',
|
|
189
|
+
error?.code === 'relink-required'
|
|
190
|
+
? 'WhatsApp 关联设备已失效,请移除后重新扫码。'
|
|
191
|
+
: 'WhatsApp 连接仍未就绪,请稍后重试。',
|
|
192
|
+
));
|
|
193
|
+
throw error;
|
|
194
|
+
} finally {
|
|
195
|
+
this.#touch();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
return this.status();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async deleteBot(botId) {
|
|
202
|
+
const config = this.#configStore.get(botId);
|
|
203
|
+
if (!config) throw new Error('Unknown WhatsApp bot');
|
|
204
|
+
await this.#withBotTransition(botId, async () => {
|
|
205
|
+
await this.#stopRuntime(botId);
|
|
206
|
+
try {
|
|
207
|
+
await this.#configStore.remove(botId);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
await this.#startRuntime(config).catch(() => undefined);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
await Promise.allSettled([
|
|
213
|
+
this.#deleteAuth(config.authDirectory),
|
|
214
|
+
this.#deleteState({ botId, config }),
|
|
215
|
+
]);
|
|
216
|
+
this.#errors.delete(botId);
|
|
217
|
+
this.#touch();
|
|
218
|
+
});
|
|
219
|
+
return this.status();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
status() {
|
|
223
|
+
const bots = this.#configStore.list().map((config) => {
|
|
224
|
+
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
|
225
|
+
const connected = runtimeStatus?.ready === true
|
|
226
|
+
&& runtimeStatus.connectionState === 'connected'
|
|
227
|
+
&& runtimeStatus.harnessReachable === true;
|
|
228
|
+
const state = connected ? 'connected'
|
|
229
|
+
: runtimeStatus?.connectionState === 'connecting' ? 'connecting'
|
|
230
|
+
: this.#errors.has(config.botId) || runtimeStatus?.connectionState === 'failed'
|
|
231
|
+
? 'error' : 'offline';
|
|
232
|
+
return {
|
|
233
|
+
botId: config.botId,
|
|
234
|
+
state,
|
|
235
|
+
connected,
|
|
236
|
+
configured: true,
|
|
237
|
+
bot: { name: config.name, idMasked: maskWhatsappAccount(config.accountJid) },
|
|
238
|
+
health: {
|
|
239
|
+
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
240
|
+
summary: connected ? 'WhatsApp Web 关联设备运行正常'
|
|
241
|
+
: state === 'error' ? 'WhatsApp 连接未就绪' : 'WhatsApp 连接当前离线',
|
|
242
|
+
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
|
243
|
+
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
|
244
|
+
},
|
|
245
|
+
stats: {
|
|
246
|
+
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
247
|
+
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
248
|
+
},
|
|
249
|
+
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
const connectedCount = bots.filter((bot) => bot.connected).length;
|
|
253
|
+
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
|
|
254
|
+
return {
|
|
255
|
+
schemaVersion: 1,
|
|
256
|
+
revision: this.#revision,
|
|
257
|
+
state: active && ACTIVE_ATTEMPT_STATES.has(active.state) ? 'provisioning'
|
|
258
|
+
: bots.length === 0 ? 'disconnected'
|
|
259
|
+
: connectedCount === bots.length ? 'connected'
|
|
260
|
+
: connectedCount > 0 ? 'degraded' : 'offline',
|
|
261
|
+
bots,
|
|
262
|
+
totals: { configured: bots.length, connected: connectedCount },
|
|
263
|
+
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
|
264
|
+
? { provisioning: publicAttempt(active) } : {}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async close() {
|
|
269
|
+
if (this.#closed) return;
|
|
270
|
+
this.#closed = true;
|
|
271
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
272
|
+
await Promise.allSettled([...this.#transitions.values()]);
|
|
273
|
+
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async #completeProvisioning(record, identity) {
|
|
277
|
+
if (record.controller.signal.aborted || this.#closed) return;
|
|
278
|
+
record.state = 'connecting';
|
|
279
|
+
record.qrValue = null;
|
|
280
|
+
record.expiresAt = null;
|
|
281
|
+
this.#touch();
|
|
282
|
+
const botId = deriveWhatsappBotId(identity.accountJid);
|
|
283
|
+
record.botId = botId;
|
|
284
|
+
await record.session?.close();
|
|
285
|
+
const previous = this.#configStore.get(botId);
|
|
286
|
+
const config = {
|
|
287
|
+
botId,
|
|
288
|
+
accountJid: identity.accountJid,
|
|
289
|
+
authDirectory: record.authDirectory,
|
|
290
|
+
name: identity.name,
|
|
291
|
+
createdAt: previous?.createdAt ?? new Date().toISOString(),
|
|
292
|
+
connectedAt: new Date().toISOString(),
|
|
293
|
+
};
|
|
294
|
+
try {
|
|
295
|
+
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });
|
|
296
|
+
await this.#configStore.save(config);
|
|
297
|
+
if (record.controller.signal.aborted || this.#closed) throw Object.assign(new Error(), { name: 'AbortError' });
|
|
298
|
+
try {
|
|
299
|
+
await this.#startRuntime(config);
|
|
300
|
+
this.#errors.delete(botId);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
this.#errors.set(botId, safeError('connection-failed', 'WhatsApp 已绑定,消息连接暂未就绪。'));
|
|
303
|
+
this.#logger.warn?.(`[dsh-im:whatsapp] bot ${botId} did not reconnect after QR binding`);
|
|
304
|
+
}
|
|
305
|
+
if (previous?.authDirectory && previous.authDirectory !== config.authDirectory) {
|
|
306
|
+
await this.#deleteAuth(previous.authDirectory).catch(() => undefined);
|
|
307
|
+
}
|
|
308
|
+
record.state = 'connected';
|
|
309
|
+
record.error = null;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (record.controller.signal.aborted || this.#closed || error?.name === 'AbortError') {
|
|
312
|
+
await this.#stopRuntime(botId);
|
|
313
|
+
if (previous) await this.#configStore.save(previous).catch(() => undefined);
|
|
314
|
+
else await this.#configStore.remove(botId).catch(() => undefined);
|
|
315
|
+
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
|
|
316
|
+
if (previous) await this.#startRuntime(previous).catch(() => undefined);
|
|
317
|
+
record.state = 'cancelled';
|
|
318
|
+
record.error = safeError('cancelled', '扫码接入已取消。');
|
|
319
|
+
} else {
|
|
320
|
+
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
|
|
321
|
+
record.state = 'failed';
|
|
322
|
+
record.error = safeError('activation-failed', 'WhatsApp 已扫码,但无法保存关联设备。');
|
|
323
|
+
this.#logger.error?.('[dsh-im:whatsapp] unable to persist linked-device session');
|
|
324
|
+
}
|
|
325
|
+
} finally {
|
|
326
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
327
|
+
this.#touch();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async #failProvisioning(record, error, rejectFirstQr = () => {}) {
|
|
332
|
+
if (TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
333
|
+
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
|
334
|
+
record.state = 'cancelled';
|
|
335
|
+
record.error = safeError('cancelled', '扫码接入已取消。');
|
|
336
|
+
} else {
|
|
337
|
+
record.state = 'failed';
|
|
338
|
+
record.error = safeError('qr-connect-failed', '无法连接 WhatsApp,请重新生成二维码。');
|
|
339
|
+
}
|
|
340
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
341
|
+
await record.session?.close().catch(() => undefined);
|
|
342
|
+
await this.#deleteAuth(record.authDirectory).catch(() => undefined);
|
|
343
|
+
this.#touch();
|
|
344
|
+
rejectFirstQr(error);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async #startRuntime(config) {
|
|
348
|
+
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
|
349
|
+
await this.#stopRuntime(config.botId);
|
|
350
|
+
if (this.#closed) throw new Error('WhatsApp controller is closed');
|
|
351
|
+
const runtime = await this.#createRuntime({
|
|
352
|
+
botId: config.botId,
|
|
353
|
+
config,
|
|
354
|
+
authDir: this.#authPath(config.authDirectory),
|
|
355
|
+
});
|
|
356
|
+
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
357
|
+
throw new TypeError('createRuntime returned an invalid WhatsApp runtime');
|
|
358
|
+
}
|
|
359
|
+
this.#runtimes.set(config.botId, runtime);
|
|
360
|
+
try {
|
|
361
|
+
await runtime.start();
|
|
362
|
+
} catch (error) {
|
|
363
|
+
await runtime.stop().catch(() => undefined);
|
|
364
|
+
this.#runtimes.delete(config.botId);
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async #stopRuntime(botId) {
|
|
370
|
+
const runtime = this.#runtimes.get(botId);
|
|
371
|
+
this.#runtimes.delete(botId);
|
|
372
|
+
await runtime?.stop().catch(() => undefined);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#withBotTransition(botId, operation) {
|
|
376
|
+
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
|
377
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
378
|
+
const settled = current.finally(() => {
|
|
379
|
+
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
|
380
|
+
});
|
|
381
|
+
this.#transitions.set(botId, settled);
|
|
382
|
+
return settled;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#touch() {
|
|
386
|
+
this.#revision += 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {
|
|
2
|
+
areJidsSameUser,
|
|
3
|
+
normalizeMessageContent,
|
|
4
|
+
} from '@whiskeysockets/baileys';
|
|
5
|
+
|
|
6
|
+
import { splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
7
|
+
import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
|
|
8
|
+
import { createWhatsappWebSession } from './whatsapp-web-session.mjs';
|
|
9
|
+
|
|
10
|
+
function messageContext(content) {
|
|
11
|
+
return content?.extendedTextMessage?.contextInfo
|
|
12
|
+
?? content?.imageMessage?.contextInfo
|
|
13
|
+
?? content?.videoMessage?.contextInfo
|
|
14
|
+
?? content?.documentMessage?.contextInfo
|
|
15
|
+
?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function messageText(content) {
|
|
19
|
+
return content?.conversation
|
|
20
|
+
?? content?.extendedTextMessage?.text
|
|
21
|
+
?? content?.imageMessage?.caption
|
|
22
|
+
?? content?.videoMessage?.caption
|
|
23
|
+
?? content?.documentMessage?.caption
|
|
24
|
+
?? '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeWhatsappMessage(message, accountJid) {
|
|
28
|
+
const remoteJid = typeof message?.key?.remoteJid === 'string' ? message.key.remoteJid : '';
|
|
29
|
+
const alternateRemoteJid = typeof message?.key?.remoteJidAlt === 'string'
|
|
30
|
+
? message.key.remoteJidAlt : '';
|
|
31
|
+
const messageId = typeof message?.key?.id === 'string' ? message.key.id : '';
|
|
32
|
+
if (!remoteJid || !messageId || remoteJid === 'status@broadcast'
|
|
33
|
+
|| remoteJid.endsWith('@newsletter')) return null;
|
|
34
|
+
const group = remoteJid.endsWith('@g.us');
|
|
35
|
+
const fromMe = message.key.fromMe === true;
|
|
36
|
+
const selfChat = fromMe && !group
|
|
37
|
+
&& [remoteJid, alternateRemoteJid].some((jid) => jid && areJidsSameUser(jid, accountJid));
|
|
38
|
+
if (fromMe && !selfChat) return null;
|
|
39
|
+
const senderJid = selfChat ? accountJid : group ? message.key.participant : remoteJid;
|
|
40
|
+
if (typeof senderJid !== 'string' || !senderJid) return null;
|
|
41
|
+
const content = normalizeMessageContent(message.message);
|
|
42
|
+
const context = messageContext(content);
|
|
43
|
+
const mentioned = Array.isArray(context?.mentionedJid)
|
|
44
|
+
&& context.mentionedJid.some((jid) => areJidsSameUser(jid, accountJid));
|
|
45
|
+
const replyToSelf = typeof context?.participant === 'string'
|
|
46
|
+
&& areJidsSameUser(context.participant, accountJid);
|
|
47
|
+
return {
|
|
48
|
+
messageId: `${remoteJid}:${messageId}`,
|
|
49
|
+
providerMessageId: messageId,
|
|
50
|
+
senderId: senderJid,
|
|
51
|
+
senderIsBot: false,
|
|
52
|
+
kind: group ? 'group' : 'direct',
|
|
53
|
+
conversationId: remoteJid,
|
|
54
|
+
content: messageText(content),
|
|
55
|
+
addressed: !group || mentioned || replyToSelf,
|
|
56
|
+
selfChat,
|
|
57
|
+
replyTarget: { jid: remoteJid, quoted: message, selfChat },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class RecentWhatsappOutboundIds {
|
|
62
|
+
#ids = new Map();
|
|
63
|
+
|
|
64
|
+
has(id) {
|
|
65
|
+
this.#purge();
|
|
66
|
+
return this.#ids.has(id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
remember(id) {
|
|
70
|
+
if (typeof id !== 'string' || !id) return;
|
|
71
|
+
this.#purge();
|
|
72
|
+
this.#ids.set(id, Date.now() + 5 * 60_000);
|
|
73
|
+
while (this.#ids.size > 256) this.#ids.delete(this.#ids.keys().next().value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#purge() {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
for (const [id, expiresAt] of this.#ids) {
|
|
79
|
+
if (expiresAt > now) continue;
|
|
80
|
+
this.#ids.delete(id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class WhatsappBotClient {
|
|
86
|
+
#socket;
|
|
87
|
+
#outboundIds;
|
|
88
|
+
#typingTimers = new Map();
|
|
89
|
+
|
|
90
|
+
constructor(socket, outboundIds) {
|
|
91
|
+
this.#socket = socket;
|
|
92
|
+
this.#outboundIds = outboundIds;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async sendText(target, text) {
|
|
96
|
+
await this.#stopTyping(target.jid);
|
|
97
|
+
let result = null;
|
|
98
|
+
for (const [index, chunk] of splitMessageText(text, 4_000).entries()) {
|
|
99
|
+
result = await this.#socket.sendMessage(
|
|
100
|
+
target.jid,
|
|
101
|
+
{ text: chunk },
|
|
102
|
+
index === 0 && target.quoted ? { quoted: target.quoted } : undefined,
|
|
103
|
+
);
|
|
104
|
+
this.#outboundIds.remember(result?.key?.id);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async sendTyping(target) {
|
|
110
|
+
if (!target.selfChat && target.quoted?.key) {
|
|
111
|
+
await this.#socket.readMessages([target.quoted.key]).catch(() => undefined);
|
|
112
|
+
}
|
|
113
|
+
await this.#socket.sendPresenceUpdate('composing', target.jid);
|
|
114
|
+
await this.#stopTyping(target.jid, false);
|
|
115
|
+
const timer = setInterval(() => {
|
|
116
|
+
void this.#socket.sendPresenceUpdate('composing', target.jid).catch(() => {
|
|
117
|
+
void this.#stopTyping(target.jid);
|
|
118
|
+
});
|
|
119
|
+
}, 20_000);
|
|
120
|
+
timer.unref?.();
|
|
121
|
+
this.#typingTimers.set(target.jid, timer);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async close() {
|
|
125
|
+
const jids = [...this.#typingTimers.keys()];
|
|
126
|
+
await Promise.allSettled(jids.map((jid) => this.#stopTyping(jid)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async #stopTyping(jid, sendPaused = true) {
|
|
130
|
+
const timer = this.#typingTimers.get(jid);
|
|
131
|
+
if (timer) clearInterval(timer);
|
|
132
|
+
this.#typingTimers.delete(jid);
|
|
133
|
+
if (sendPaused) await this.#socket.sendPresenceUpdate('paused', jid).catch(() => undefined);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createWhatsappRuntimeStatus() {
|
|
138
|
+
return {
|
|
139
|
+
startedAt: null,
|
|
140
|
+
ready: false,
|
|
141
|
+
connectionState: 'idle',
|
|
142
|
+
harnessReachable: false,
|
|
143
|
+
lastCheckedAt: null,
|
|
144
|
+
lastConnectedAt: null,
|
|
145
|
+
lastError: null,
|
|
146
|
+
...createWhatsappBridgeStatus(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class WhatsappRuntime {
|
|
151
|
+
#config;
|
|
152
|
+
#authDir;
|
|
153
|
+
#harness;
|
|
154
|
+
#state;
|
|
155
|
+
#logger;
|
|
156
|
+
#replyTimeoutMs;
|
|
157
|
+
#connectTimeoutMs;
|
|
158
|
+
#createSession;
|
|
159
|
+
#status = createWhatsappRuntimeStatus();
|
|
160
|
+
#abortController = null;
|
|
161
|
+
#session = null;
|
|
162
|
+
#client = null;
|
|
163
|
+
#bridge = null;
|
|
164
|
+
#starting = null;
|
|
165
|
+
|
|
166
|
+
constructor({
|
|
167
|
+
config,
|
|
168
|
+
authDir,
|
|
169
|
+
harness,
|
|
170
|
+
state,
|
|
171
|
+
logger = console,
|
|
172
|
+
replyTimeoutMs = 600_000,
|
|
173
|
+
connectTimeoutMs = 30_000,
|
|
174
|
+
createSession = createWhatsappWebSession,
|
|
175
|
+
}) {
|
|
176
|
+
if (!config || !authDir || !harness || !state || typeof createSession !== 'function') {
|
|
177
|
+
throw new TypeError('WhatsappRuntime requires config, auth directory, Harness, state, and session factory');
|
|
178
|
+
}
|
|
179
|
+
this.#config = config;
|
|
180
|
+
this.#authDir = authDir;
|
|
181
|
+
this.#harness = harness;
|
|
182
|
+
this.#state = state;
|
|
183
|
+
this.#logger = logger;
|
|
184
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
185
|
+
this.#connectTimeoutMs = connectTimeoutMs;
|
|
186
|
+
this.#createSession = createSession;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get status() {
|
|
190
|
+
return structuredClone(this.#status);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async start() {
|
|
194
|
+
if (this.#status.ready && this.#session) return this.status;
|
|
195
|
+
if (this.#starting) return this.#starting;
|
|
196
|
+
this.#starting = this.#start().finally(() => { this.#starting = null; });
|
|
197
|
+
return this.#starting;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async #start() {
|
|
201
|
+
await this.stop();
|
|
202
|
+
this.#status.startedAt = new Date().toISOString();
|
|
203
|
+
this.#status.connectionState = 'connecting';
|
|
204
|
+
this.#status.lastError = null;
|
|
205
|
+
await this.#harness.ensureRunning();
|
|
206
|
+
this.#status.harnessReachable = true;
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
this.#abortController = controller;
|
|
209
|
+
const outboundIds = new RecentWhatsappOutboundIds();
|
|
210
|
+
let rejectRelink;
|
|
211
|
+
const relinkRequired = new Promise((_, reject) => { rejectRelink = reject; });
|
|
212
|
+
void relinkRequired.catch(() => undefined);
|
|
213
|
+
try {
|
|
214
|
+
const session = await this.#createSession({
|
|
215
|
+
authDir: this.#authDir,
|
|
216
|
+
signal: controller.signal,
|
|
217
|
+
logger: this.#logger,
|
|
218
|
+
onQr: () => rejectRelink(Object.assign(
|
|
219
|
+
new Error('WhatsApp linked-device session must be scanned again'),
|
|
220
|
+
{ code: 'relink-required' },
|
|
221
|
+
)),
|
|
222
|
+
onMessage: async (raw) => {
|
|
223
|
+
const message = normalizeWhatsappMessage(raw, this.#config.accountJid);
|
|
224
|
+
if (!message || outboundIds.has(message.providerMessageId) || !this.#bridge) return;
|
|
225
|
+
this.#status.lastCheckedAt = Date.now();
|
|
226
|
+
await this.#bridge.accept(message);
|
|
227
|
+
},
|
|
228
|
+
onDisconnect: ({ error }) => {
|
|
229
|
+
if (controller.signal.aborted) return;
|
|
230
|
+
this.#status.ready = false;
|
|
231
|
+
this.#status.connectionState = 'failed';
|
|
232
|
+
this.#status.lastError = error?.message ?? 'WhatsApp Web connection closed';
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
this.#session = session;
|
|
236
|
+
let timer;
|
|
237
|
+
const identity = await Promise.race([
|
|
238
|
+
session.ready,
|
|
239
|
+
relinkRequired,
|
|
240
|
+
new Promise((_, reject) => {
|
|
241
|
+
timer = setTimeout(
|
|
242
|
+
() => reject(new Error('WhatsApp Web did not connect in time')),
|
|
243
|
+
this.#connectTimeoutMs,
|
|
244
|
+
);
|
|
245
|
+
}),
|
|
246
|
+
]).finally(() => clearTimeout(timer));
|
|
247
|
+
if (!areJidsSameUser(identity.accountJid, this.#config.accountJid)) {
|
|
248
|
+
throw new Error('WhatsApp linked account does not match the saved bot');
|
|
249
|
+
}
|
|
250
|
+
const client = new WhatsappBotClient(session.socket, outboundIds);
|
|
251
|
+
this.#client = client;
|
|
252
|
+
this.#bridge = new WhatsappHarnessBridge({
|
|
253
|
+
bot: client,
|
|
254
|
+
harness: this.#harness,
|
|
255
|
+
state: this.#state,
|
|
256
|
+
status: this.#status,
|
|
257
|
+
logger: this.#logger,
|
|
258
|
+
replyTimeoutMs: this.#replyTimeoutMs,
|
|
259
|
+
});
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
this.#status.ready = true;
|
|
262
|
+
this.#status.connectionState = 'connected';
|
|
263
|
+
this.#status.lastCheckedAt = now;
|
|
264
|
+
this.#status.lastConnectedAt = now;
|
|
265
|
+
return this.status;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
this.#status.ready = false;
|
|
268
|
+
this.#status.connectionState = 'failed';
|
|
269
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
270
|
+
await this.stop();
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async logout() {
|
|
276
|
+
await this.#session?.logout().catch(() => undefined);
|
|
277
|
+
return this.stop();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async stop() {
|
|
281
|
+
const session = this.#session;
|
|
282
|
+
const client = this.#client;
|
|
283
|
+
const bridge = this.#bridge;
|
|
284
|
+
this.#abortController?.abort();
|
|
285
|
+
this.#abortController = null;
|
|
286
|
+
this.#session = null;
|
|
287
|
+
this.#client = null;
|
|
288
|
+
this.#bridge = null;
|
|
289
|
+
await client?.close().catch(() => undefined);
|
|
290
|
+
await session?.close().catch(() => undefined);
|
|
291
|
+
await Promise.race([
|
|
292
|
+
bridge?.waitForIdle() ?? Promise.resolve(),
|
|
293
|
+
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
|
294
|
+
]);
|
|
295
|
+
this.#status.ready = false;
|
|
296
|
+
this.#status.connectionState = 'idle';
|
|
297
|
+
return this.status;
|
|
298
|
+
}
|
|
299
|
+
}
|