@lofa199419/waha-v2 2.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.
@@ -0,0 +1,342 @@
1
+ import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
2
+ import { z } from "zod";
3
+
4
+ const allowFromEntry = z.union([z.string(), z.number()]);
5
+
6
+ /**
7
+ * Typing indicator and message-chunking config exposed to the control UI.
8
+ */
9
+ const WahaV2TypingConfigSchema = z
10
+ .object({
11
+ enabled: z.boolean().optional(),
12
+ chunking: z.boolean().optional(),
13
+ charsPerSecond: z.number().int().positive().optional(),
14
+ maxChunkLength: z.number().int().positive().optional(),
15
+ debug: z.boolean().optional(),
16
+ })
17
+ .optional();
18
+
19
+ const WahaV2LabelRuleSchema = z.object({
20
+ match: z.string(),
21
+ instruction: z.string(),
22
+ by: z.enum(["name", "id"]).optional(),
23
+ });
24
+
25
+ const WahaV2LabelRoutingConfigSchema = z
26
+ .object({
27
+ enabled: z.boolean().optional(),
28
+ defaultInstruction: z.string().optional(),
29
+ rules: z.array(WahaV2LabelRuleSchema).optional(),
30
+ cacheTtlSec: z.number().int().positive().optional(),
31
+ autoAssignPersonal: z.boolean().optional(),
32
+ personalLabel: z.string().optional(),
33
+ pauseLabels: z.array(z.string()).optional(),
34
+ })
35
+ .optional();
36
+
37
+ /**
38
+ * Per-group action enable/disable flags exposed to the control UI.
39
+ * Each field defaults to `true` (enabled) when omitted.
40
+ */
41
+ const WahaV2ActionConfigSchema = z
42
+ .object({
43
+ sessionManagement: z.boolean().optional(),
44
+ messaging: z.boolean().optional(),
45
+ chats: z.boolean().optional(),
46
+ contacts: z.boolean().optional(),
47
+ groups: z.boolean().optional(),
48
+ status: z.boolean().optional(),
49
+ waChannels: z.boolean().optional(),
50
+ })
51
+ .optional();
52
+
53
+ /**
54
+ * Config schema for a single WAHA account (root or nested under accounts.*).
55
+ */
56
+ const WahaV2AccountSchema = z.object({
57
+ /** Whether this account is enabled. */
58
+ enabled: z.boolean().optional(),
59
+
60
+ /** Friendly display name for this account. */
61
+ name: z.string().optional(),
62
+
63
+ /** Base URL of the WAHA HTTP API instance. */
64
+ baseUrl: z.string().optional(),
65
+
66
+ /** API key for authenticating with WAHA. */
67
+ apiKey: z.string().optional(),
68
+
69
+ /** WAHA session name to use (defaults to "default"). */
70
+ session: z.string().optional(),
71
+
72
+ /**
73
+ * Public URL WAHA will POST inbound messages to.
74
+ * Example: https://your-openclaw-host/webhooks/waha-v2
75
+ * The gateway appends /{accountId} automatically.
76
+ */
77
+ webhookUrl: z.string().optional(),
78
+
79
+ /** DM access policy. */
80
+ dmPolicy: z.enum(["allow", "deny"]).optional(),
81
+
82
+ /** Group access policy. */
83
+ groupPolicy: z.enum(["allow", "deny", "allowlist"]).optional(),
84
+
85
+ /** Debounce inbound bursts per chat before dispatching to the agent. */
86
+ debounceMs: z.number().int().min(0).optional(),
87
+
88
+ /** Allowlist of group JIDs allowed when groupPolicy=allowlist (e.g. 1203...@g.us). */
89
+ allowGroups: z.array(allowFromEntry).optional(),
90
+
91
+ /** Allowlist of WhatsApp JIDs allowed to message this account (e.g. 15550001234@c.us). */
92
+ allowFrom: z.array(allowFromEntry).optional(),
93
+
94
+ /** Pause the bot whenever the WhatsApp owner sends any message in the chat. */
95
+ pauseOnOwnerMessage: z.boolean().optional(),
96
+
97
+ /** Owner-authored message texts that pause the bot immediately, e.g. ["//"]. */
98
+ ownerPauseWords: z.array(z.string()).optional(),
99
+
100
+ /** Owner-authored message texts that resume a manually paused chat, e.g. ["///"]. */
101
+ ownerResumeWords: z.array(z.string()).optional(),
102
+
103
+ /**
104
+ * Action group enable/disable flags.
105
+ * Controls which categories of agent actions are available for this account.
106
+ */
107
+ actions: WahaV2ActionConfigSchema,
108
+
109
+ /** Typing indicator and message-chunking behaviour. */
110
+ typing: WahaV2TypingConfigSchema,
111
+
112
+ /** Label-based inbound instruction routing. */
113
+ labelRouting: WahaV2LabelRoutingConfigSchema,
114
+ });
115
+
116
+ /**
117
+ * Full channels.waha-v2 config schema.
118
+ * Top-level fields are the default account; additional accounts live under `accounts`.
119
+ */
120
+ export const WahaV2ConfigSchema = WahaV2AccountSchema.extend({
121
+ /** Additional named accounts — each key is an accountId. */
122
+ accounts: z.record(z.string(), WahaV2AccountSchema).optional(),
123
+ });
124
+
125
+ export type WahaV2Config = z.infer<typeof WahaV2ConfigSchema>;
126
+
127
+ /**
128
+ * Channel config schema exposed to the control UI.
129
+ * `buildChannelConfigSchema` converts the Zod schema to JSON Schema Draft-7.
130
+ * uiHints add labels, placeholders, and sensitivity flags for each field.
131
+ */
132
+ export const wahaV2ChannelConfigSchema = {
133
+ ...buildChannelConfigSchema(WahaV2ConfigSchema),
134
+ uiHints: {
135
+ baseUrl: {
136
+ label: "WAHA Server URL",
137
+ help: "Base URL of your self-hosted WAHA instance.",
138
+ placeholder: "https://waha.example.com",
139
+ },
140
+ apiKey: {
141
+ label: "API Key",
142
+ sensitive: true,
143
+ help: "Authentication key for the WAHA HTTP API.",
144
+ placeholder: "your-api-key",
145
+ },
146
+ session: {
147
+ label: "Session Name",
148
+ help: 'WAHA session name to use. Defaults to "default".',
149
+ placeholder: "default",
150
+ },
151
+ webhookUrl: {
152
+ label: "Webhook URL",
153
+ help: "Public URL WAHA will POST inbound messages to. Must be reachable by your WAHA server. The gateway appends /{accountId} automatically.",
154
+ placeholder: "https://your-openclaw-host/webhooks/waha-v2",
155
+ },
156
+ dmPolicy: {
157
+ label: "DM Policy",
158
+ help: "Whether to allow or deny direct messages from unknown contacts.",
159
+ advanced: true,
160
+ },
161
+ groupPolicy: {
162
+ label: "Group Policy",
163
+ help: "Whether to allow, deny, or allowlist group messages for this account.",
164
+ advanced: true,
165
+ },
166
+ debounceMs: {
167
+ label: "Inbound Debounce (ms)",
168
+ help: "Wait this long for follow-up messages from the same chat before dispatching one reply cycle.",
169
+ advanced: true,
170
+ placeholder: "1200",
171
+ },
172
+ allowGroups: {
173
+ label: "Allow Groups",
174
+ help: "Allowlist of group JIDs when Group Policy is allowlist. Format: 1203...@g.us",
175
+ advanced: true,
176
+ },
177
+ allowFrom: {
178
+ label: "Allow From",
179
+ help: "Allowlist of WhatsApp JIDs allowed to message this account. Leave empty to allow all. Format: 15550001234@c.us",
180
+ advanced: true,
181
+ },
182
+ pauseOnOwnerMessage: {
183
+ label: "Pause On Owner Message",
184
+ help: "Pause the bot for a chat when the connected WhatsApp owner sends any message in that chat.",
185
+ advanced: true,
186
+ },
187
+ ownerPauseWords: {
188
+ label: "Owner Pause Words",
189
+ help: 'Owner-authored texts that pause the bot immediately. Example: ["//"].',
190
+ advanced: true,
191
+ },
192
+ ownerResumeWords: {
193
+ label: "Owner Resume Words",
194
+ help: 'Owner-authored texts that resume a manually paused chat. Example: ["///"].',
195
+ advanced: true,
196
+ },
197
+ name: {
198
+ label: "Account Label",
199
+ help: "Friendly name for this account (displayed in the UI).",
200
+ advanced: true,
201
+ placeholder: "My WhatsApp",
202
+ },
203
+ enabled: {
204
+ label: "Enabled",
205
+ help: "Enable or disable this WAHA account.",
206
+ },
207
+ accounts: {
208
+ label: "Additional Accounts",
209
+ help: "Configure multiple WAHA accounts (each with its own server, API key, and session). Keyed by account ID.",
210
+ advanced: true,
211
+ },
212
+ actions: {
213
+ label: "Action Groups",
214
+ help: "Enable or disable categories of agent actions. All groups are enabled by default.",
215
+ advanced: true,
216
+ },
217
+ "actions.sessionManagement": {
218
+ label: "Session Management",
219
+ help: "Allow agents to start sessions, scan QR codes, request pairing codes, and log out.",
220
+ advanced: true,
221
+ },
222
+ "actions.messaging": {
223
+ label: "Messaging Actions",
224
+ help: "Allow agents to react, mark seen, send locations/contacts, forward, star, edit, delete, and pin messages.",
225
+ advanced: true,
226
+ },
227
+ "actions.chats": {
228
+ label: "Chat Actions",
229
+ help: "Allow agents to list chats, read history, archive/unarchive, delete chats, and mark as unread.",
230
+ advanced: true,
231
+ },
232
+ "actions.contacts": {
233
+ label: "Contact Actions",
234
+ help: "Allow agents to look up, check, block, and unblock contacts.",
235
+ advanced: true,
236
+ },
237
+ "actions.groups": {
238
+ label: "Group Actions",
239
+ help: "Allow agents to create and manage groups, participants, admins, and invite codes.",
240
+ advanced: true,
241
+ },
242
+ "actions.status": {
243
+ label: "Status / Stories",
244
+ help: "Allow agents to post and delete WhatsApp Status (Stories).",
245
+ advanced: true,
246
+ },
247
+ "actions.waChannels": {
248
+ label: "WA Channels (Newsletters)",
249
+ help: "Allow agents to list, read, create, and delete WhatsApp Channels (newsletters).",
250
+ advanced: true,
251
+ },
252
+ typing: {
253
+ label: "Typing & Chunking",
254
+ help: "Control the typing indicator and how long replies are split into multiple messages.",
255
+ advanced: true,
256
+ },
257
+ "typing.enabled": {
258
+ label: "Typing Indicator",
259
+ help: "Show the 'typing…' indicator in WhatsApp while the agent is composing a reply.",
260
+ advanced: true,
261
+ },
262
+ "typing.chunking": {
263
+ label: "Message Chunking",
264
+ help: "Split long replies into multiple messages at paragraph breaks for a more natural feel.",
265
+ advanced: true,
266
+ },
267
+ "typing.charsPerSecond": {
268
+ label: "Typing Speed (chars/sec)",
269
+ help: "Simulated typing speed used to calculate the delay between chunks. Default: 12 (≈ 2-3 words/s).",
270
+ advanced: true,
271
+ placeholder: "12",
272
+ },
273
+ "typing.maxChunkLength": {
274
+ label: "Max Chunk Length",
275
+ help: "Maximum number of characters per message chunk before splitting further. Default: 1500.",
276
+ advanced: true,
277
+ placeholder: "1500",
278
+ },
279
+ "typing.debug": {
280
+ label: "Typing Debug Logs",
281
+ help: "Emit detailed seen, typing, and chunk-delivery timing logs for WAHA replies.",
282
+ advanced: true,
283
+ },
284
+ labelRouting: {
285
+ label: "Label Routing",
286
+ help: "Inject instruction guardrails based on WhatsApp chat labels.",
287
+ advanced: true,
288
+ },
289
+ "labelRouting.enabled": {
290
+ label: "Enable Label Routing",
291
+ help: "When enabled, inbound messages are enriched with instructions based on chat labels.",
292
+ advanced: true,
293
+ },
294
+ "labelRouting.defaultInstruction": {
295
+ label: "Default Instruction",
296
+ help: "Fallback instruction used when no label rule matches.",
297
+ advanced: true,
298
+ },
299
+ "labelRouting.rules": {
300
+ label: "Label Rules",
301
+ help: "Ordered rules. First matching label injects its instruction.",
302
+ advanced: true,
303
+ },
304
+ "labelRouting.rules[].match": {
305
+ label: "Match Label",
306
+ help: "Label name or ID to match.",
307
+ advanced: true,
308
+ },
309
+ "labelRouting.rules[].by": {
310
+ label: "Match By",
311
+ help: "Match by label name (default) or label ID.",
312
+ advanced: true,
313
+ },
314
+ "labelRouting.rules[].instruction": {
315
+ label: "Instruction",
316
+ help: "Instruction text injected into agent context when the rule matches.",
317
+ advanced: true,
318
+ },
319
+ "labelRouting.cacheTtlSec": {
320
+ label: "Label Cache TTL (sec)",
321
+ help: "How long to cache chat-label lookups. Default: 120 seconds.",
322
+ advanced: true,
323
+ placeholder: "120",
324
+ },
325
+ "labelRouting.autoAssignPersonal": {
326
+ label: "Auto Assign Personal",
327
+ help: "Automatically add the personal label when an inbound message looks non-business.",
328
+ advanced: true,
329
+ },
330
+ "labelRouting.personalLabel": {
331
+ label: "Personal Label",
332
+ help: "Label name or ID to assign for non-business chats. Default: personal",
333
+ advanced: true,
334
+ placeholder: "personal",
335
+ },
336
+ "labelRouting.pauseLabels": {
337
+ label: "Pause Labels",
338
+ help: 'Labels that make the bot stay silent for a chat. Example: ["owner_intervention"].',
339
+ advanced: true,
340
+ },
341
+ },
342
+ };
package/src/deliver.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * WhatsApp-specific reply delivery helpers.
3
+ *
4
+ * Chunks a long agent reply into multiple WhatsApp messages (splitting at
5
+ * paragraph breaks), and calculates a proportional human-like typing delay
6
+ * for each chunk so the conversation feels natural.
7
+ */
8
+ import { chunkTextForOutbound } from "openclaw/plugin-sdk";
9
+
10
+ /** Maximum characters per WhatsApp message chunk (well under the 4096 limit). */
11
+ const MAX_CHUNK_LENGTH = 1500;
12
+
13
+ /**
14
+ * Assumed human typing speed in characters per second.
15
+ * ~12 chars/s ≈ 2-3 words/s — noticeably human without feeling sluggish.
16
+ */
17
+ const CHARS_PER_SECOND = 12;
18
+
19
+ /** Minimum typing delay shown before a chunk, in ms. */
20
+ const MIN_DELAY_MS = 600;
21
+
22
+ /** Maximum typing delay cap — keeps the bot from stalling on very long chunks. */
23
+ const MAX_DELAY_MS = 8000;
24
+
25
+ /**
26
+ * Split a reply into multiple WhatsApp messages.
27
+ *
28
+ * Strategy:
29
+ * 1. Split at paragraph breaks (`\n\n`).
30
+ * 2. If a paragraph still exceeds `maxLength`, break it further at
31
+ * newline/space boundaries via `chunkTextForOutbound`.
32
+ * 3. Always returns at least one non-empty chunk.
33
+ */
34
+ export function chunkWahaMessage(text: string, maxLength = MAX_CHUNK_LENGTH): string[] {
35
+ const paragraphs = text.split(/\n\n+/);
36
+ const chunks: string[] = [];
37
+
38
+ for (const para of paragraphs) {
39
+ const trimmed = para.trim();
40
+ if (!trimmed) continue;
41
+
42
+ if (trimmed.length <= maxLength) {
43
+ chunks.push(trimmed);
44
+ } else {
45
+ // Paragraph too long — split at word/line boundaries.
46
+ for (const sub of chunkTextForOutbound(trimmed, maxLength)) {
47
+ const s = sub.trim();
48
+ if (s) chunks.push(s);
49
+ }
50
+ }
51
+ }
52
+
53
+ // Fallback: if the entire text had no paragraph breaks and didn't split, return as-is.
54
+ return chunks.length > 0 ? chunks : [text.trim()].filter(Boolean);
55
+ }
56
+
57
+ /**
58
+ * Calculate a human-like typing delay proportional to the chunk length.
59
+ *
60
+ * `delay = clamp(length / charsPerSecond * 1000, MIN_DELAY_MS, MAX_DELAY_MS)`
61
+ *
62
+ * Examples at the default speed of 12 chars/s:
63
+ * - 50-char chunk → 4 167 ms → clamped to 4 167 ms
64
+ * - 15-char chunk → 1 250 ms
65
+ * - 300-char chunk → 25 000 ms → clamped to MAX (8 000 ms)
66
+ */
67
+ export function calcTypingDelayMs(text: string, charsPerSecond = CHARS_PER_SECOND): number {
68
+ const raw = Math.round((text.length / charsPerSecond) * 1000);
69
+ return Math.max(MIN_DELAY_MS, Math.min(MAX_DELAY_MS, raw));
70
+ }
package/src/gateway.ts ADDED
@@ -0,0 +1,170 @@
1
+ import type { ChannelGatewayAdapter } from "openclaw/plugin-sdk";
2
+ import { createWahaV2Client } from "./client.js";
3
+ import { probeWahaV2Session } from "./probe.js";
4
+ import { getWahaV2Logger } from "./runtime.js";
5
+ import { removeWahaV2Client, setWahaV2Client } from "./runtime.js";
6
+ import type { ResolvedWahaV2Account } from "./types.js";
7
+
8
+ const HEALTH_INTERVAL_MS = 60_000;
9
+
10
+ // Statuses that mean the session exists but is not running — we can start it.
11
+ const STARTABLE_STATUSES = new Set(["STOPPED", "FAILED"]);
12
+
13
+ // WAHA webhook events we care about.
14
+ const WEBHOOK_EVENTS = ["message", "message.any", "session.status"];
15
+
16
+ /** Build the webhook config block to embed in session create/update calls. */
17
+ function buildWebhookConfig(webhookBaseUrl: string, accountId: string): Record<string, unknown> {
18
+ const url = `${webhookBaseUrl.replace(/\/+$/, "")}/${accountId}`;
19
+ return {
20
+ webhooks: [
21
+ {
22
+ url,
23
+ events: WEBHOOK_EVENTS,
24
+ retries: { policy: "exponential", delaySeconds: 2, attempts: 15 },
25
+ },
26
+ ],
27
+ // Keep WhatsApp Status/Stories out of the agent pipeline.
28
+ ignore: {
29
+ status: true,
30
+ },
31
+ };
32
+ }
33
+
34
+ /** Resolves when the abort signal fires or the delay elapses, whichever is first. */
35
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
36
+ return new Promise<void>((resolve) => {
37
+ const timer = setTimeout(resolve, ms);
38
+ signal.addEventListener(
39
+ "abort",
40
+ () => {
41
+ clearTimeout(timer);
42
+ resolve();
43
+ },
44
+ { once: true },
45
+ );
46
+ });
47
+ }
48
+
49
+ export const wahaV2Gateway: ChannelGatewayAdapter<ResolvedWahaV2Account> = {
50
+ startAccount: async ({ cfg: _cfg, account, abortSignal, setStatus, log }) => {
51
+ const client = createWahaV2Client({
52
+ baseUrl: account.baseUrl,
53
+ apiKey: account.apiKey || undefined,
54
+ });
55
+
56
+ setWahaV2Client(account.accountId, client);
57
+
58
+ // Build webhook config once — reused in create and update calls.
59
+ const webhookConfig = account.webhookUrl
60
+ ? buildWebhookConfig(account.webhookUrl, account.accountId)
61
+ : undefined;
62
+
63
+ // Initial probe — check the live session status.
64
+ let probe = await probeWahaV2Session(client, account.session).catch(() => ({
65
+ ok: false,
66
+ status: undefined as string | undefined,
67
+ }));
68
+
69
+ if (!probe.ok && !abortSignal.aborted) {
70
+ const sessionStatus = String(probe.status ?? "").toUpperCase();
71
+
72
+ if (STARTABLE_STATUSES.has(sessionStatus)) {
73
+ // Session exists but is stopped — start it, then update webhook config separately.
74
+ log?.info(`waha-v2: session "${account.session}" is STOPPED — starting`);
75
+ await client.startSession(account.session).catch((err) => {
76
+ getWahaV2Logger().warn(
77
+ `waha-v2: startSession failed for "${account.session}": ${String(err)}`,
78
+ );
79
+ });
80
+ // Wait for WAHA to bring it up before attempting the webhook update.
81
+ if (!abortSignal.aborted) await sleep(5_000, abortSignal);
82
+ if (webhookConfig && !abortSignal.aborted) {
83
+ await client.updateSession(account.session, webhookConfig).catch((err) => {
84
+ getWahaV2Logger().warn(
85
+ `waha-v2: webhook update failed for "${account.session}": ${String(err)}`,
86
+ );
87
+ });
88
+ }
89
+ } else if (!sessionStatus) {
90
+ // Session doesn't exist — create it with webhook config baked in (one API call, no race).
91
+ log?.info(`waha-v2: session "${account.session}" not found — creating`);
92
+ await client.createSession(account.session, webhookConfig).catch((err) => {
93
+ getWahaV2Logger().warn(
94
+ `waha-v2: createSession failed for "${account.session}": ${String(err)}`,
95
+ );
96
+ });
97
+ if (webhookConfig) {
98
+ log?.info(`waha-v2: webhook embedded in session create for "${account.session}"`);
99
+ }
100
+ }
101
+
102
+ // Re-probe after startup attempt.
103
+ if (!abortSignal.aborted) {
104
+ await sleep(3_000, abortSignal);
105
+ if (!abortSignal.aborted) {
106
+ probe = await probeWahaV2Session(client, account.session).catch(() => ({
107
+ ok: false,
108
+ status: undefined,
109
+ }));
110
+ }
111
+ }
112
+ } else if (probe.ok && webhookConfig && !abortSignal.aborted) {
113
+ // Session already running — update webhook config to ensure it's current.
114
+ await client.updateSession(account.session, webhookConfig).catch((err) => {
115
+ getWahaV2Logger().warn(
116
+ `waha-v2: webhook update failed for "${account.session}": ${String(err)}`,
117
+ );
118
+ });
119
+ log?.info(`waha-v2: updated webhook URL for already-running session "${account.session}"`);
120
+ }
121
+
122
+ setStatus({
123
+ accountId: account.accountId,
124
+ running: true,
125
+ connected: probe.ok,
126
+ lastStartAt: Date.now(),
127
+ ...(probe.ok ? {} : { lastError: `session "${account.session}" not WORKING yet` }),
128
+ });
129
+
130
+ log?.info(
131
+ `waha-v2: account "${account.accountId}" started — ` +
132
+ `baseUrl=${account.baseUrl} session=${account.session} connected=${probe.ok}`,
133
+ );
134
+
135
+ // Health monitoring — re-probe every 60 s and update connected state.
136
+ let healthInterval: ReturnType<typeof setInterval> | null = setInterval(async () => {
137
+ if (abortSignal.aborted) return;
138
+ const health = await probeWahaV2Session(client, account.session).catch(() => ({ ok: false }));
139
+ setStatus({ accountId: account.accountId, running: true, connected: health.ok });
140
+ }, HEALTH_INTERVAL_MS);
141
+
142
+ abortSignal.addEventListener(
143
+ "abort",
144
+ () => {
145
+ if (healthInterval) {
146
+ clearInterval(healthInterval);
147
+ healthInterval = null;
148
+ }
149
+ },
150
+ { once: true },
151
+ );
152
+
153
+ await new Promise<void>((resolve) => {
154
+ abortSignal.addEventListener("abort", () => resolve(), { once: true });
155
+ });
156
+
157
+ removeWahaV2Client(account.accountId);
158
+ log?.info(`waha-v2: account "${account.accountId}" stopped`);
159
+ },
160
+
161
+ stopAccount: async ({ account, setStatus }) => {
162
+ removeWahaV2Client(account.accountId);
163
+ setStatus({
164
+ accountId: account.accountId,
165
+ running: false,
166
+ connected: false,
167
+ lastStopAt: Date.now(),
168
+ });
169
+ },
170
+ };
package/src/login.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { WahaV2Client } from "./client.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Per-account login lock — prevents concurrent pairing attempts
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const LOCK_TTL_MS = 30_000;
8
+
9
+ const loginLocks = new Map<string, number>(); // accountId → expiry timestamp
10
+
11
+ export class WahaV2LoginBusyError extends Error {
12
+ readonly statusCode = 409;
13
+ constructor(accountId: string) {
14
+ super(`waha-v2: login already in progress for account "${accountId}"`);
15
+ this.name = "WahaV2LoginBusyError";
16
+ }
17
+ }
18
+
19
+ export function acquireLoginLock(accountId: string): void {
20
+ const expiry = loginLocks.get(accountId);
21
+ if (expiry !== undefined && expiry > Date.now()) {
22
+ throw new WahaV2LoginBusyError(accountId);
23
+ }
24
+ loginLocks.set(accountId, Date.now() + LOCK_TTL_MS);
25
+ }
26
+
27
+ export function releaseLoginLock(accountId: string): void {
28
+ loginLocks.delete(accountId);
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Wait for session to become connected (polls every 2 s until timeout)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const POLL_INTERVAL_MS = 2_000;
36
+ const WAHA_CONNECTED_STATUSES = new Set(["WORKING", "CONNECTED", "AUTHENTICATED"]);
37
+
38
+ export async function waitForWahaV2Connected(
39
+ client: WahaV2Client,
40
+ session: string,
41
+ timeoutMs: number,
42
+ ): Promise<{ ok: boolean; status?: string }> {
43
+ const deadline = Date.now() + timeoutMs;
44
+ while (Date.now() < deadline) {
45
+ const sessions = await client.listSessions(true).catch(() => []);
46
+ const target = session.trim().toLowerCase();
47
+ const found = sessions.find(
48
+ (s) =>
49
+ String(s.name ?? "")
50
+ .trim()
51
+ .toLowerCase() === target,
52
+ );
53
+ const status = String(found?.status ?? "")
54
+ .trim()
55
+ .toUpperCase();
56
+ if (WAHA_CONNECTED_STATUSES.has(status)) {
57
+ return { ok: true, status: found?.status };
58
+ }
59
+ const remaining = deadline - Date.now();
60
+ if (remaining <= 0) break;
61
+ await new Promise<void>((r) => setTimeout(r, Math.min(POLL_INTERVAL_MS, remaining)));
62
+ }
63
+ return { ok: false };
64
+ }