talon-agent 3.25.3 → 3.26.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/package.json
CHANGED
|
@@ -17,7 +17,13 @@ import {
|
|
|
17
17
|
resolveKey,
|
|
18
18
|
} from "../message-store.js";
|
|
19
19
|
import { recordPin, listPins, forgetPin } from "../pins.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
resolveQuoted,
|
|
22
|
+
sendContent,
|
|
23
|
+
sendText,
|
|
24
|
+
tryAction,
|
|
25
|
+
boundedSend,
|
|
26
|
+
} from "./shared.js";
|
|
21
27
|
import type { WhatsAppActionHandlers } from "./types.js";
|
|
22
28
|
|
|
23
29
|
/** WhatsApp keeps a pin for 24h, 7d, or 30d — no indefinite option. */
|
|
@@ -89,9 +95,11 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
89
95
|
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
90
96
|
// An empty emoji clears the reaction — WhatsApp's own semantics,
|
|
91
97
|
// and the only way to take one back.
|
|
92
|
-
await
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
await boundedSend("react", () =>
|
|
99
|
+
ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
100
|
+
react: { text: String(body.emoji ?? ""), key: resolved.key },
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
95
103
|
return { ok: true };
|
|
96
104
|
}),
|
|
97
105
|
|
|
@@ -103,10 +111,12 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
103
111
|
if (!text.trim()) {
|
|
104
112
|
return { ok: false, error: "edit_message: text is required" };
|
|
105
113
|
}
|
|
106
|
-
await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
await boundedSend("edit", () =>
|
|
115
|
+
ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
116
|
+
text: toWhatsAppText(text),
|
|
117
|
+
edit: resolved.key,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
110
120
|
return { ok: true, message_id: resolved.stored.msgId };
|
|
111
121
|
}),
|
|
112
122
|
|
|
@@ -114,7 +124,9 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
114
124
|
tryAction("delete_message", async () => {
|
|
115
125
|
const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
|
|
116
126
|
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
117
|
-
await
|
|
127
|
+
await boundedSend("delete", () =>
|
|
128
|
+
ctx.sock.sendMessage(ctx.chat!.jid, { delete: resolved.key }),
|
|
129
|
+
);
|
|
118
130
|
forgetPin(ctx.chat!.chatId, resolved.stored.msgId);
|
|
119
131
|
return { ok: true };
|
|
120
132
|
}),
|
|
@@ -166,11 +178,13 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
166
178
|
const time =
|
|
167
179
|
PIN_DURATIONS.find((d) => d >= requested) ??
|
|
168
180
|
PIN_DURATIONS[PIN_DURATIONS.length - 1];
|
|
169
|
-
await
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
181
|
+
await boundedSend("pin", () =>
|
|
182
|
+
ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
183
|
+
pin: resolved.key,
|
|
184
|
+
type: proto.PinInChat.Type.PIN_FOR_ALL,
|
|
185
|
+
time,
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
174
188
|
recordPin(ctx.chat!.chatId, resolved.stored);
|
|
175
189
|
return { ok: true };
|
|
176
190
|
}),
|
|
@@ -179,10 +193,12 @@ export const messagingHandlers: WhatsAppActionHandlers = {
|
|
|
179
193
|
tryAction("unpin_message", async () => {
|
|
180
194
|
const resolved = resolveKey(body.message_id, ctx.chat!.chatId);
|
|
181
195
|
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
182
|
-
await
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
196
|
+
await boundedSend("unpin", () =>
|
|
197
|
+
ctx.sock.sendMessage(ctx.chat!.jid, {
|
|
198
|
+
pin: resolved.key,
|
|
199
|
+
type: proto.PinInChat.Type.UNPIN_FOR_ALL,
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
186
202
|
forgetPin(ctx.chat!.chatId, resolved.stored.msgId);
|
|
187
203
|
return { ok: true };
|
|
188
204
|
}),
|
|
@@ -163,6 +163,57 @@ function outboundTextMarker(content: AnyMessageContent): string {
|
|
|
163
163
|
return "";
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// ── Serialized, time-bounded sends ─────────────────────────────────────────
|
|
167
|
+
//
|
|
168
|
+
// Ported from OpenClaw's socket-timing adapter. Two failure modes this
|
|
169
|
+
// removes, both observed live:
|
|
170
|
+
// - a send on a dying socket hanging a turn indefinitely ("timed out
|
|
171
|
+
// waiting for message" with nothing delivered and no error surfaced),
|
|
172
|
+
// - interleaved sends racing each other's Baileys internals.
|
|
173
|
+
// Every outbound WhatsApp operation goes through one FIFO per process
|
|
174
|
+
// with a hard timeout; on timeout the queue advances so later sends
|
|
175
|
+
// aren't wedged behind the dead one.
|
|
176
|
+
|
|
177
|
+
const SEND_TIMEOUT_MS = 60_000;
|
|
178
|
+
let sendTail: Promise<unknown> = Promise.resolve();
|
|
179
|
+
|
|
180
|
+
export class WhatsAppSendTimeoutError extends Error {
|
|
181
|
+
constructor(operation: string) {
|
|
182
|
+
super(
|
|
183
|
+
`WhatsApp ${operation} timed out after ${SEND_TIMEOUT_MS / 1000}s — ` +
|
|
184
|
+
`delivery state unknown (the socket may be dead or reconnecting)`,
|
|
185
|
+
);
|
|
186
|
+
this.name = "WhatsAppSendTimeoutError";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run one socket operation serialized behind every earlier one, bounded
|
|
192
|
+
* by SEND_TIMEOUT_MS. The timeout rejects THIS caller but releases the
|
|
193
|
+
* queue, so a wedged operation can't dam everything after it.
|
|
194
|
+
*/
|
|
195
|
+
export function boundedSend<T>(
|
|
196
|
+
operation: string,
|
|
197
|
+
run: () => Promise<T>,
|
|
198
|
+
): Promise<T> {
|
|
199
|
+
const prev = sendTail.catch(() => {});
|
|
200
|
+
const result = prev.then(() => {
|
|
201
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
202
|
+
return Promise.race([
|
|
203
|
+
run(),
|
|
204
|
+
new Promise<never>((_, reject) => {
|
|
205
|
+
timer = setTimeout(
|
|
206
|
+
() => reject(new WhatsAppSendTimeoutError(operation)),
|
|
207
|
+
SEND_TIMEOUT_MS,
|
|
208
|
+
);
|
|
209
|
+
timer.unref?.();
|
|
210
|
+
}),
|
|
211
|
+
]).finally(() => clearTimeout(timer));
|
|
212
|
+
});
|
|
213
|
+
sendTail = result.catch(() => {});
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
166
217
|
/**
|
|
167
218
|
* Send one content payload, remember the resulting message so later
|
|
168
219
|
* tool calls can address it, and report its Talon numeric id.
|
|
@@ -173,7 +224,9 @@ export async function sendContent(
|
|
|
173
224
|
content: AnyMessageContent,
|
|
174
225
|
options: { quoted?: WAMessage } = {},
|
|
175
226
|
): Promise<ActionResult> {
|
|
176
|
-
const sent = await
|
|
227
|
+
const sent = await boundedSend("sendMessage", () =>
|
|
228
|
+
ctx.sock.sendMessage(chat.jid, content, options),
|
|
229
|
+
);
|
|
177
230
|
ctx.gateway.incrementMessages(chat.numericChatId);
|
|
178
231
|
if (!sent?.key) return { ok: true };
|
|
179
232
|
const text =
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic multi-file auth state for Baileys — a drop-in replacement for
|
|
3
|
+
* `useMultiFileAuthState` with two hardening changes ported from how
|
|
4
|
+
* OpenClaw persists WhatsApp credentials:
|
|
5
|
+
*
|
|
6
|
+
* 1. Every write is ATOMIC (tmp + rename via write-file-atomic). The
|
|
7
|
+
* upstream helper uses a bare `writeFile`, so a daemon restart or
|
|
8
|
+
* crash mid-write tears creds.json or a signal-key file. A torn
|
|
9
|
+
* signal key is invisible until the server starts rejecting stanzas
|
|
10
|
+
* ("smax-invalid: stanza rejected — likely stale device"), which is
|
|
11
|
+
* the documented prelude to WhatsApp unlinking the device — the
|
|
12
|
+
* exact sequence the live deployment logged before its
|
|
13
|
+
* `device_removed` conflict.
|
|
14
|
+
* 2. Writes to the same file are serialized through a promise chain,
|
|
15
|
+
* and `flushAuthWrites()` lets shutdown drain the queue before the
|
|
16
|
+
* process exits, so `talon stop` can't strand a half-persisted key.
|
|
17
|
+
*
|
|
18
|
+
* The on-disk format is byte-compatible with upstream: same folder, same
|
|
19
|
+
* file names, same BufferJSON encoding — existing auth dirs just work.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdir, readFile, stat, unlink } from "node:fs/promises";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import writeFileAtomic from "write-file-atomic";
|
|
25
|
+
import { BufferJSON, initAuthCreds, proto } from "baileys";
|
|
26
|
+
import type { AuthenticationState, SignalDataTypeMap } from "baileys";
|
|
27
|
+
|
|
28
|
+
/** file path → tail of its write chain. Module-level: one dir per process. */
|
|
29
|
+
const writeTails = new Map<string, Promise<void>>();
|
|
30
|
+
|
|
31
|
+
function enqueueWrite(
|
|
32
|
+
filePath: string,
|
|
33
|
+
task: () => Promise<void>,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
const tail = (writeTails.get(filePath) ?? Promise.resolve())
|
|
36
|
+
.catch(() => {})
|
|
37
|
+
.then(task);
|
|
38
|
+
writeTails.set(
|
|
39
|
+
filePath,
|
|
40
|
+
tail.catch(() => {}),
|
|
41
|
+
);
|
|
42
|
+
return tail;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve when every queued auth write has settled. Call before exit. */
|
|
46
|
+
export async function flushAuthWrites(): Promise<void> {
|
|
47
|
+
await Promise.allSettled(writeTails.values());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Baileys' name mangling, kept identical for on-disk compatibility. */
|
|
51
|
+
function fixFileName(file: string): string {
|
|
52
|
+
return file.replace(/\//g, "__").replace(/:/g, "-");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function useAtomicAuthState(folder: string): Promise<{
|
|
56
|
+
state: AuthenticationState;
|
|
57
|
+
saveCreds: () => Promise<void>;
|
|
58
|
+
}> {
|
|
59
|
+
const info = await stat(folder).catch(() => undefined);
|
|
60
|
+
if (info && !info.isDirectory()) {
|
|
61
|
+
throw new Error(`not a directory: ${folder}`);
|
|
62
|
+
}
|
|
63
|
+
if (!info) await mkdir(folder, { recursive: true });
|
|
64
|
+
|
|
65
|
+
const writeData = (data: unknown, file: string): Promise<void> => {
|
|
66
|
+
const filePath = join(folder, fixFileName(file));
|
|
67
|
+
return enqueueWrite(filePath, () =>
|
|
68
|
+
writeFileAtomic(filePath, JSON.stringify(data, BufferJSON.replacer)),
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const readData = async (file: string): Promise<unknown> => {
|
|
73
|
+
const filePath = join(folder, fixFileName(file));
|
|
74
|
+
// Reads wait for any pending write to that file, so a get() racing a
|
|
75
|
+
// set() sees the new value rather than the old file.
|
|
76
|
+
await (writeTails.get(filePath) ?? Promise.resolve()).catch(() => {});
|
|
77
|
+
try {
|
|
78
|
+
const raw = await readFile(filePath, "utf-8");
|
|
79
|
+
return JSON.parse(raw, BufferJSON.reviver);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const removeData = (file: string): Promise<void> => {
|
|
86
|
+
const filePath = join(folder, fixFileName(file));
|
|
87
|
+
return enqueueWrite(filePath, async () => {
|
|
88
|
+
await unlink(filePath).catch(() => {});
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const creds =
|
|
93
|
+
((await readData("creds.json")) as ReturnType<
|
|
94
|
+
typeof initAuthCreds
|
|
95
|
+
> | null) ?? initAuthCreds();
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
state: {
|
|
99
|
+
creds,
|
|
100
|
+
keys: {
|
|
101
|
+
get: async <T extends keyof SignalDataTypeMap>(
|
|
102
|
+
type: T,
|
|
103
|
+
ids: string[],
|
|
104
|
+
) => {
|
|
105
|
+
const data: { [id: string]: SignalDataTypeMap[T] } = {};
|
|
106
|
+
await Promise.all(
|
|
107
|
+
ids.map(async (id) => {
|
|
108
|
+
let value = await readData(`${type}-${id}.json`);
|
|
109
|
+
if (type === "app-state-sync-key" && value) {
|
|
110
|
+
value = proto.Message.AppStateSyncKeyData.fromObject(value);
|
|
111
|
+
}
|
|
112
|
+
data[id] = value as SignalDataTypeMap[T];
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
return data;
|
|
116
|
+
},
|
|
117
|
+
set: async (data) => {
|
|
118
|
+
const tasks: Promise<void>[] = [];
|
|
119
|
+
for (const category in data) {
|
|
120
|
+
const entries = data[category as keyof SignalDataTypeMap];
|
|
121
|
+
for (const id in entries) {
|
|
122
|
+
const value = entries[id];
|
|
123
|
+
const file = `${category}-${id}.json`;
|
|
124
|
+
tasks.push(value ? writeData(value, file) : removeData(file));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
await Promise.all(tasks);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
saveCreds: () => writeData(creds, "creds.json"),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -16,9 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { rmSync } from "node:fs";
|
|
18
18
|
import makeWASocket, {
|
|
19
|
-
DisconnectReason,
|
|
20
19
|
isJidGroup,
|
|
21
|
-
useMultiFileAuthState,
|
|
22
20
|
type WAMessage,
|
|
23
21
|
type WASocket,
|
|
24
22
|
} from "baileys";
|
|
@@ -53,7 +51,13 @@ import {
|
|
|
53
51
|
seedMessageStore,
|
|
54
52
|
} from "./message-store.js";
|
|
55
53
|
import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
|
|
56
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
classifyClose,
|
|
56
|
+
nextPairingDelayMs,
|
|
57
|
+
REPLACED_BACKOFF_MS,
|
|
58
|
+
shouldNotifyPairingCode,
|
|
59
|
+
} from "./pairing.js";
|
|
60
|
+
import { flushAuthWrites, useAtomicAuthState } from "./auth-state.js";
|
|
57
61
|
import { notifyAdmin } from "../../core/notify.js";
|
|
58
62
|
import {
|
|
59
63
|
lookupWhatsAppChat,
|
|
@@ -433,7 +437,9 @@ export function createWhatsAppFrontend(
|
|
|
433
437
|
|
|
434
438
|
/** One socket lifetime. Resolves with what the caller should do next. */
|
|
435
439
|
async function connectOnce(): Promise<"reconnect" | "logged-out" | "stop"> {
|
|
436
|
-
|
|
440
|
+
// Atomic replacement for Baileys' useMultiFileAuthState — same disk
|
|
441
|
+
// format, torn-write-proof (see auth-state.ts for why that matters).
|
|
442
|
+
const { state, saveCreds } = await useAtomicAuthState(dirs.whatsappAuth);
|
|
437
443
|
const socket = makeWASocket({
|
|
438
444
|
auth: state,
|
|
439
445
|
logger: makeWaLogger(),
|
|
@@ -445,6 +451,11 @@ export function createWhatsAppFrontend(
|
|
|
445
451
|
// a ~2½-minute socket lifetime in pairing mode, shorter than it
|
|
446
452
|
// takes a human to pick up their phone and type the code.
|
|
447
453
|
qrTimeout: 120_000,
|
|
454
|
+
// OpenClaw's production timings: the Baileys 20s connect timeout
|
|
455
|
+
// is tight on a loaded box, and a slightly faster keepalive spots
|
|
456
|
+
// a dead transport sooner.
|
|
457
|
+
connectTimeoutMs: 60_000,
|
|
458
|
+
keepAliveIntervalMs: 25_000,
|
|
448
459
|
});
|
|
449
460
|
sock = socket;
|
|
450
461
|
socket.ev.on("creds.update", saveCreds);
|
|
@@ -545,12 +556,37 @@ export function createWhatsAppFrontend(
|
|
|
545
556
|
{ output?: { statusCode?: number } } | undefined
|
|
546
557
|
)?.output?.statusCode;
|
|
547
558
|
if (stopping) return resolve("stop");
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
"
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
559
|
+
const disposition = classifyClose(code, state.creds.registered);
|
|
560
|
+
switch (disposition.kind) {
|
|
561
|
+
case "pairing-accepted":
|
|
562
|
+
// 515 right after a pairing code/QR is SUCCESS, not an
|
|
563
|
+
// error: WhatsApp requires one reconnect with the same
|
|
564
|
+
// credentials to complete the login.
|
|
565
|
+
log(
|
|
566
|
+
"whatsapp",
|
|
567
|
+
"Pairing accepted (515) — reconnecting to complete login",
|
|
568
|
+
);
|
|
569
|
+
reconnectDelay = RECONNECT_BASE_MS;
|
|
570
|
+
return resolve("reconnect");
|
|
571
|
+
case "replaced":
|
|
572
|
+
// Another socket owns this session (440). Fighting it with
|
|
573
|
+
// an instant reconnect just steals the session back and
|
|
574
|
+
// forth; sit out a full minute instead.
|
|
575
|
+
logWarn(
|
|
576
|
+
"whatsapp",
|
|
577
|
+
"Connection replaced by another client (440) — backing off",
|
|
578
|
+
);
|
|
579
|
+
reconnectDelay = REPLACED_BACKOFF_MS;
|
|
580
|
+
return resolve("reconnect");
|
|
581
|
+
case "logged-out":
|
|
582
|
+
return resolve("logged-out");
|
|
583
|
+
default:
|
|
584
|
+
log(
|
|
585
|
+
"whatsapp",
|
|
586
|
+
`Connection closed (code ${code ?? "?"}) — reconnecting`,
|
|
587
|
+
);
|
|
588
|
+
return resolve("reconnect");
|
|
589
|
+
}
|
|
554
590
|
}
|
|
555
591
|
});
|
|
556
592
|
});
|
|
@@ -658,6 +694,10 @@ export function createWhatsAppFrontend(
|
|
|
658
694
|
/* already closed */
|
|
659
695
|
}
|
|
660
696
|
sock = null;
|
|
697
|
+
// Drain queued credential writes before the process exits — a key
|
|
698
|
+
// half-written at shutdown is invisible until the server starts
|
|
699
|
+
// rejecting stanzas with it.
|
|
700
|
+
await flushAuthWrites();
|
|
661
701
|
await gateway.stop();
|
|
662
702
|
log("whatsapp", "WhatsApp frontend stopped");
|
|
663
703
|
},
|
|
@@ -38,3 +38,38 @@ export function shouldNotifyPairingCode(
|
|
|
38
38
|
if (lastNotifiedAt === undefined) return true;
|
|
39
39
|
return now - lastNotifiedAt >= PAIRING_NOTIFY_MIN_GAP_MS;
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
// ── Close-code taxonomy ─────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What one Baileys `connection: "close"` means for the reconnect loop.
|
|
46
|
+
* Ported from how OpenClaw's connection controller reads DisconnectReason
|
|
47
|
+
* — the old handler knew only "loggedOut or not", which turned pairing
|
|
48
|
+
* success (515) into an anonymous reconnect and let another client's
|
|
49
|
+
* takeover (440) start a reconnect fight.
|
|
50
|
+
*/
|
|
51
|
+
export type CloseDisposition =
|
|
52
|
+
| { kind: "pairing-accepted" } // 515: code entered — reconnect NOW, same creds
|
|
53
|
+
| { kind: "replaced" } // 440: another socket owns the session — back off hard
|
|
54
|
+
| { kind: "logged-out"; midPairing: boolean } // 401: dead creds
|
|
55
|
+
| { kind: "reconnect" }; // everything else: transient
|
|
56
|
+
|
|
57
|
+
export function classifyClose(
|
|
58
|
+
statusCode: number | undefined,
|
|
59
|
+
credsRegistered: boolean,
|
|
60
|
+
): CloseDisposition {
|
|
61
|
+
switch (statusCode) {
|
|
62
|
+
case 515: // DisconnectReason.restartRequired — expected right after pairing
|
|
63
|
+
return { kind: "pairing-accepted" };
|
|
64
|
+
case 440: // DisconnectReason.connectionReplaced
|
|
65
|
+
return { kind: "replaced" };
|
|
66
|
+
case 401: // DisconnectReason.loggedOut
|
|
67
|
+
case 403: // DisconnectReason.forbidden — account-level, same recovery
|
|
68
|
+
return { kind: "logged-out", midPairing: !credsRegistered };
|
|
69
|
+
default:
|
|
70
|
+
return { kind: "reconnect" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** How long to sit out after a 440 — another client owns the session. */
|
|
75
|
+
export const REPLACED_BACKOFF_MS = 60_000;
|