talon-agent 3.25.2 → 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 +1 -1
- package/prompts/whatsapp.md +19 -0
- package/src/bootstrap.ts +17 -0
- package/src/core/engine/gateway-actions/history.ts +19 -1
- package/src/core/notify.ts +51 -0
- package/src/frontend/shared/session-status.ts +7 -1
- package/src/frontend/whatsapp/actions/messaging.ts +34 -18
- package/src/frontend/whatsapp/actions/shared.ts +112 -2
- package/src/frontend/whatsapp/auth-state.ts +133 -0
- package/src/frontend/whatsapp/index.ts +185 -33
- package/src/frontend/whatsapp/message-store.ts +16 -0
- package/src/frontend/whatsapp/pairing.ts +75 -0
- package/src/frontend/whatsapp/turn-recovery.ts +113 -0
- package/src/storage/history.ts +27 -0
- package/src/storage/repositories/history-repo.ts +24 -0
- package/src/storage/sql/history.sql +14 -0
- package/src/storage/sql/statements.generated.ts +10 -0
- package/src/util/log.ts +1 -0
package/package.json
CHANGED
package/prompts/whatsapp.md
CHANGED
|
@@ -46,6 +46,25 @@ the file path is in the message, so you can read it immediately.
|
|
|
46
46
|
- No forum topics, no custom admin titles, no stopping a poll.
|
|
47
47
|
- Pins expire (24h, 7d, or 30d) and only pins Talon placed are listable.
|
|
48
48
|
|
|
49
|
+
### Continuity — your context is not the chat
|
|
50
|
+
|
|
51
|
+
Your working context can start fresh at any time (a `/reset`, a daemon
|
|
52
|
+
restart, an overflowed session) while the WhatsApp conversation continues
|
|
53
|
+
uninterrupted. The chat's real record lives in persistent history, both
|
|
54
|
+
sides of it, and you can always get it back:
|
|
55
|
+
|
|
56
|
+
- `read_chat_history` — the recent messages; pass `offset_id` (a msg_id)
|
|
57
|
+
or `before` (an ISO date) to page further back.
|
|
58
|
+
- `search_chat_history` — keyword search over everything said in this
|
|
59
|
+
chat, including your own earlier messages.
|
|
60
|
+
- `get_message_by_id` — one specific message when you hold its id.
|
|
61
|
+
|
|
62
|
+
If the sender references something you don't recognize — "the flights",
|
|
63
|
+
"what you said yesterday", "that plan" — search the history before
|
|
64
|
+
saying you don't know or asking them to repeat themselves. A fresh
|
|
65
|
+
session is your situation, not theirs; to the person on the phone this
|
|
66
|
+
is one long conversation, and it should read like you remember it.
|
|
67
|
+
|
|
49
68
|
### Staying silent
|
|
50
69
|
|
|
51
70
|
Reactions work here, so a react is the light acknowledgement when a
|
package/src/bootstrap.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { appendToJournal } from "./storage/journal.js";
|
|
|
27
27
|
import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
|
|
28
28
|
import { initCron } from "./core/background/cron.js";
|
|
29
29
|
import { initPlanAlerts } from "./core/background/plan-alerts.js";
|
|
30
|
+
import { setAdminNotifier } from "./core/notify.js";
|
|
30
31
|
import {
|
|
31
32
|
initTriggers,
|
|
32
33
|
resumeAfterRestart as resumeTriggersAfterRestart,
|
|
@@ -442,6 +443,22 @@ export async function initBackendAndDispatcher(
|
|
|
442
443
|
(config.adminUserId ? String(config.adminUserId) : undefined),
|
|
443
444
|
});
|
|
444
445
|
|
|
446
|
+
// Admin notification seam (core/notify.ts) — how a subsystem reaches
|
|
447
|
+
// the operator when its own channel is the thing that is broken (the
|
|
448
|
+
// first consumer is WhatsApp pairing: codes must travel over a LIVE
|
|
449
|
+
// frontend, not the dead one's log). Same delivery route as the plan
|
|
450
|
+
// alerts above.
|
|
451
|
+
if (config.adminUserId) {
|
|
452
|
+
const adminChatId = config.adminUserId;
|
|
453
|
+
setAdminNotifier(async (text: string) =>
|
|
454
|
+
resolveFrontendByNumericId(
|
|
455
|
+
adminChatId,
|
|
456
|
+
String(adminChatId),
|
|
457
|
+
frontends,
|
|
458
|
+
).sendMessage(adminChatId, text),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
445
462
|
// Soul — initialize the identity kernel singleton from config so the prompt
|
|
446
463
|
// injection / dream hooks see the right enabled state. Off by default; a
|
|
447
464
|
// failure here must never block startup.
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
getRecentFormatted,
|
|
8
|
+
getFormattedBefore,
|
|
9
|
+
getFormattedBeforeTime,
|
|
8
10
|
searchHistory,
|
|
9
11
|
getMessagesByUser,
|
|
10
12
|
getKnownUsers,
|
|
@@ -15,7 +17,23 @@ import type { SharedActionHandlers } from "./types.js";
|
|
|
15
17
|
export const historyHandlers: SharedActionHandlers = {
|
|
16
18
|
read_history: (body, chatId) => {
|
|
17
19
|
const limit = Math.min(100, Number(body.limit ?? 30));
|
|
18
|
-
|
|
20
|
+
const cid = String(chatId);
|
|
21
|
+
// The tool schema advertises `offset_id` and `before` for paging back;
|
|
22
|
+
// this fallback used to ignore both and hand back the same newest
|
|
23
|
+
// window no matter what the model asked, so "go further back" was a
|
|
24
|
+
// silent no-op on frontends without a platform history API (WhatsApp,
|
|
25
|
+
// native).
|
|
26
|
+
const offsetId = Number(body.offset_id);
|
|
27
|
+
if (Number.isFinite(offsetId) && offsetId > 0) {
|
|
28
|
+
return { ok: true, text: getFormattedBefore(cid, offsetId, limit) };
|
|
29
|
+
}
|
|
30
|
+
if (body.before !== undefined) {
|
|
31
|
+
const ts = Date.parse(String(body.before));
|
|
32
|
+
if (Number.isFinite(ts)) {
|
|
33
|
+
return { ok: true, text: getFormattedBeforeTime(cid, ts, limit) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, text: getRecentFormatted(cid, limit) };
|
|
19
37
|
},
|
|
20
38
|
|
|
21
39
|
search_history: (body, chatId) => {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin notification seam — a way for any subsystem to reach the human
|
|
3
|
+
* operator on their primary chat (adminUserId, usually Telegram).
|
|
4
|
+
*
|
|
5
|
+
* Core cannot import frontends, so the composition root injects the
|
|
6
|
+
* delivery function at boot (bootstrap.ts, next to the plan-alerts
|
|
7
|
+
* wiring, which does the same thing privately). Unwired — early boot,
|
|
8
|
+
* tests, terminal mode with no admin — notifications degrade to a log
|
|
9
|
+
* line rather than throwing.
|
|
10
|
+
*
|
|
11
|
+
* First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
|
|
12
|
+
* recovery needs a human to type a pairing code into the phone — a code
|
|
13
|
+
* that previously only ever appeared in the daemon log, which nobody
|
|
14
|
+
* watches. Alerts about a dead frontend must travel over a LIVE one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { log, logWarn } from "./../util/log.js";
|
|
18
|
+
|
|
19
|
+
let deliver: ((text: string) => Promise<void>) | null = null;
|
|
20
|
+
|
|
21
|
+
/** Wire (or clear) the delivery function. Called by the composition root. */
|
|
22
|
+
export function setAdminNotifier(
|
|
23
|
+
fn: ((text: string) => Promise<void>) | null,
|
|
24
|
+
): void {
|
|
25
|
+
deliver = fn;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Send `text` to the admin chat. Never throws; returns whether delivery
|
|
30
|
+
* was attempted (false = no notifier wired).
|
|
31
|
+
*/
|
|
32
|
+
export async function notifyAdmin(text: string): Promise<boolean> {
|
|
33
|
+
if (!deliver) {
|
|
34
|
+
logWarn(
|
|
35
|
+
"notify",
|
|
36
|
+
`No admin notifier wired; dropping: ${text.slice(0, 120)}`,
|
|
37
|
+
);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await deliver(text);
|
|
42
|
+
log("notify", `Admin notified: ${text.slice(0, 80).replace(/\n/g, " ")}`);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
log(
|
|
46
|
+
"notify",
|
|
47
|
+
`Admin notification failed: ${err instanceof Error ? err.message : err}`,
|
|
48
|
+
);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -44,6 +44,7 @@ import { formatDuration } from "./format.js";
|
|
|
44
44
|
export async function performSessionReset(
|
|
45
45
|
chatId: string,
|
|
46
46
|
backend: Backend | null | undefined,
|
|
47
|
+
opts: { keepHistory?: boolean } = {},
|
|
47
48
|
): Promise<void> {
|
|
48
49
|
const info = getSessionInfo(chatId);
|
|
49
50
|
if (info.turns > 0) {
|
|
@@ -59,7 +60,12 @@ export async function performSessionReset(
|
|
|
59
60
|
);
|
|
60
61
|
}
|
|
61
62
|
resetSession(chatId);
|
|
62
|
-
|
|
63
|
+
// Frontends whose platform keeps the real chat record (Telegram,
|
|
64
|
+
// Discord) clear the local mirror too — the platform still has
|
|
65
|
+
// everything. WhatsApp passes keepHistory: the local store is the ONLY
|
|
66
|
+
// record there, and wiping it on /reset would destroy exactly what the
|
|
67
|
+
// continuity tools (read/search_chat_history) exist to recover.
|
|
68
|
+
if (!opts.keepHistory) clearHistory(chatId);
|
|
63
69
|
resetPulseCheckpoint(chatId);
|
|
64
70
|
backend?.sessions?.resetChat?.(chatId);
|
|
65
71
|
await backend?.sessions?.warmSession?.(chatId);
|
|
@@ -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
|
}),
|
|
@@ -12,6 +12,7 @@ import { log, logError } from "../../../util/log.js";
|
|
|
12
12
|
import type { ActionResult } from "../../../core/types.js";
|
|
13
13
|
import { toWhatsAppChunks } from "../formatting.js";
|
|
14
14
|
import { lookupMessage, rememberMessage } from "../message-store.js";
|
|
15
|
+
import { pushMessage, type HistoryMessage } from "../../../storage/history.js";
|
|
15
16
|
import type { WhatsAppChatInfo } from "../registry.js";
|
|
16
17
|
|
|
17
18
|
/** WhatsApp's own ceiling for a media upload. */
|
|
@@ -128,6 +129,91 @@ export function resolveQuoted(
|
|
|
128
129
|
);
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* How the bot signs its own rows in persistent history. Set once at
|
|
134
|
+
* frontend start from `config.botDisplayName`; the 0 sender id is the
|
|
135
|
+
* cross-frontend "this is the assistant" convention (native/protocol.ts).
|
|
136
|
+
*/
|
|
137
|
+
let botName = "Talon";
|
|
138
|
+
export function setWhatsAppBotName(name: string): void {
|
|
139
|
+
if (name.trim()) botName = name.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* History labels for outbound media, in HistoryMessage's closed
|
|
144
|
+
* vocabulary. Payloads with no file analogue (polls, locations, contact
|
|
145
|
+
* cards) return a text marker instead via `outboundTextMarker`.
|
|
146
|
+
*/
|
|
147
|
+
function outboundMediaType(
|
|
148
|
+
content: AnyMessageContent,
|
|
149
|
+
): NonNullable<HistoryMessage["mediaType"]> | undefined {
|
|
150
|
+
if ("image" in content) return "photo";
|
|
151
|
+
if ("video" in content) return content.gifPlayback ? "animation" : "video";
|
|
152
|
+
if ("audio" in content) return "voice";
|
|
153
|
+
if ("sticker" in content) return "sticker";
|
|
154
|
+
if ("document" in content) return "document";
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A readable stand-in for captionless payloads history can't type. */
|
|
159
|
+
function outboundTextMarker(content: AnyMessageContent): string {
|
|
160
|
+
if ("poll" in content) return `[poll: ${content.poll.name}]`;
|
|
161
|
+
if ("location" in content) return "[location]";
|
|
162
|
+
if ("contacts" in content) return "[contact card]";
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
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
|
+
|
|
131
217
|
/**
|
|
132
218
|
* Send one content payload, remember the resulting message so later
|
|
133
219
|
* tool calls can address it, and report its Talon numeric id.
|
|
@@ -138,16 +224,40 @@ export async function sendContent(
|
|
|
138
224
|
content: AnyMessageContent,
|
|
139
225
|
options: { quoted?: WAMessage } = {},
|
|
140
226
|
): Promise<ActionResult> {
|
|
141
|
-
const sent = await
|
|
227
|
+
const sent = await boundedSend("sendMessage", () =>
|
|
228
|
+
ctx.sock.sendMessage(chat.jid, content, options),
|
|
229
|
+
);
|
|
142
230
|
ctx.gateway.incrementMessages(chat.numericChatId);
|
|
143
231
|
if (!sent?.key) return { ok: true };
|
|
232
|
+
const text =
|
|
233
|
+
"text" in content
|
|
234
|
+
? String(content.text ?? "")
|
|
235
|
+
: "caption" in content && content.caption
|
|
236
|
+
? String(content.caption)
|
|
237
|
+
: outboundTextMarker(content);
|
|
144
238
|
const msgId = rememberMessage({
|
|
145
239
|
key: sent.key,
|
|
146
240
|
chatId: chat.chatId,
|
|
147
241
|
message: sent,
|
|
148
|
-
text
|
|
242
|
+
text,
|
|
149
243
|
senderName: "bot",
|
|
150
244
|
});
|
|
245
|
+
// Persist the bot's side of the conversation. Without this the history
|
|
246
|
+
// store held only inbound messages, so read_chat_history showed a
|
|
247
|
+
// one-sided chat and search_chat_history could never find anything the
|
|
248
|
+
// bot itself had said — exactly the messages a fresh session needs when
|
|
249
|
+
// reconstructing context after a reset or restart.
|
|
250
|
+
const mediaType = outboundMediaType(content);
|
|
251
|
+
if (text || mediaType) {
|
|
252
|
+
pushMessage(chat.chatId, {
|
|
253
|
+
msgId,
|
|
254
|
+
senderId: 0,
|
|
255
|
+
senderName: botName,
|
|
256
|
+
text,
|
|
257
|
+
timestamp: Date.now(),
|
|
258
|
+
...(mediaType ? { mediaType } : {}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
151
261
|
return { ok: true, message_id: msgId };
|
|
152
262
|
}
|
|
153
263
|
|
|
@@ -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";
|
|
@@ -33,12 +31,10 @@ import { toolInputToRecord } from "../../core/agent-runtime/events.js";
|
|
|
33
31
|
import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
|
|
34
32
|
import { performSessionReset } from "../shared/session-status.js";
|
|
35
33
|
import { appendDailyLog } from "../../storage/daily-log.js";
|
|
36
|
-
import { pushMessage } from "../../storage/history.js";
|
|
34
|
+
import { pushMessage, maxMsgIdForChatPrefix } from "../../storage/history.js";
|
|
37
35
|
import {
|
|
38
|
-
recordError,
|
|
39
36
|
recordMessageProcessed,
|
|
40
37
|
recordMessageReceived,
|
|
41
|
-
recordMessageSettled,
|
|
42
38
|
} from "../../util/watchdog.js";
|
|
43
39
|
import { createWhatsAppActionHandler } from "./actions/index.js";
|
|
44
40
|
import {
|
|
@@ -47,9 +43,22 @@ import {
|
|
|
47
43
|
identityAllowed,
|
|
48
44
|
resolveIdentity,
|
|
49
45
|
} from "./identity.js";
|
|
50
|
-
import { sendText } from "./actions/shared.js";
|
|
46
|
+
import { sendText, setWhatsAppBotName } from "./actions/shared.js";
|
|
51
47
|
import { saveInboundMedia } from "./media-store.js";
|
|
52
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
lookupByWaId,
|
|
50
|
+
rememberMessage,
|
|
51
|
+
seedMessageStore,
|
|
52
|
+
} from "./message-store.js";
|
|
53
|
+
import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
|
|
54
|
+
import {
|
|
55
|
+
classifyClose,
|
|
56
|
+
nextPairingDelayMs,
|
|
57
|
+
REPLACED_BACKOFF_MS,
|
|
58
|
+
shouldNotifyPairingCode,
|
|
59
|
+
} from "./pairing.js";
|
|
60
|
+
import { flushAuthWrites, useAtomicAuthState } from "./auth-state.js";
|
|
61
|
+
import { notifyAdmin } from "../../core/notify.js";
|
|
53
62
|
import {
|
|
54
63
|
lookupWhatsAppChat,
|
|
55
64
|
registerWhatsAppChat,
|
|
@@ -151,6 +160,11 @@ export function createWhatsAppFrontend(
|
|
|
151
160
|
let sock: WASocket | null = null;
|
|
152
161
|
let stopping = false;
|
|
153
162
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
163
|
+
/** Consecutive failed pairing cycles since the last successful open. */
|
|
164
|
+
let failedPairingCycles = 0;
|
|
165
|
+
/** Codes issued this outage — throttles the admin notifications. */
|
|
166
|
+
let pairingCodesIssued = 0;
|
|
167
|
+
let lastPairingNotifyAt: number | undefined;
|
|
154
168
|
/** Our own ids (phone and LID), once connected — for mention detection. */
|
|
155
169
|
let selfIds: string[] = [];
|
|
156
170
|
|
|
@@ -217,7 +231,10 @@ export function createWhatsAppFrontend(
|
|
|
217
231
|
return Boolean(ctx?.participant && isSelf(ctx.participant));
|
|
218
232
|
}
|
|
219
233
|
|
|
220
|
-
async function handleInbound(
|
|
234
|
+
async function handleInbound(
|
|
235
|
+
msg: WAMessage,
|
|
236
|
+
opts: { catchUp?: boolean } = {},
|
|
237
|
+
): Promise<void> {
|
|
221
238
|
const jid = msg.key.remoteJid;
|
|
222
239
|
// `fromMe` covers our own sends echoing back; status@broadcast is the
|
|
223
240
|
// Stories feed, which is not a conversation.
|
|
@@ -278,13 +295,17 @@ export function createWhatsAppFrontend(
|
|
|
278
295
|
const replyToWaId =
|
|
279
296
|
msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? undefined;
|
|
280
297
|
const replyTo = replyToWaId ? lookupByWaId(replyToWaId) : undefined;
|
|
298
|
+
const platformTs = Number(msg.messageTimestamp) * 1000;
|
|
281
299
|
pushMessage(chat.chatId, {
|
|
282
300
|
msgId,
|
|
283
301
|
senderId: Number(BigInt(canonicalId(identity) ?? "0") % 2147483647n),
|
|
284
302
|
senderName,
|
|
285
303
|
senderHandle: canonicalId(identity),
|
|
286
304
|
text,
|
|
287
|
-
timestamp
|
|
305
|
+
// The platform timestamp, so a catch-up message recorded late still
|
|
306
|
+
// reads in true order; Date.now() only when Baileys omits it.
|
|
307
|
+
timestamp:
|
|
308
|
+
Number.isFinite(platformTs) && platformTs > 0 ? platformTs : Date.now(),
|
|
288
309
|
...(replyTo ? { replyToMsgId: replyTo.msgId } : {}),
|
|
289
310
|
...(media ? { mediaType: media.type, filePath: media.filePath } : {}),
|
|
290
311
|
});
|
|
@@ -295,6 +316,9 @@ export function createWhatsAppFrontend(
|
|
|
295
316
|
await performSessionReset(
|
|
296
317
|
chat.chatId,
|
|
297
318
|
resolveChatBackend(chat.chatId, gateway.backend),
|
|
319
|
+
// The local history store is WhatsApp's only chat record — a
|
|
320
|
+
// reset clears the model's session, not the conversation log.
|
|
321
|
+
{ keepHistory: true },
|
|
298
322
|
);
|
|
299
323
|
log("whatsapp", `Session reset by ${senderName}`);
|
|
300
324
|
if (sock) {
|
|
@@ -310,13 +334,31 @@ export function createWhatsAppFrontend(
|
|
|
310
334
|
await sendText(
|
|
311
335
|
{ sock, gateway },
|
|
312
336
|
chat,
|
|
313
|
-
"*Commands*\n/reset —
|
|
337
|
+
"*Commands*\n/reset — start a fresh session (chat log kept)\n/help — this message",
|
|
314
338
|
).catch(() => {});
|
|
315
339
|
}
|
|
316
340
|
recordMessageProcessed();
|
|
317
341
|
return;
|
|
318
342
|
}
|
|
319
343
|
|
|
344
|
+
// Catch-up messages (queued while the daemon was down) get a reply
|
|
345
|
+
// turn only while fresh; stale ones are already recorded above and
|
|
346
|
+
// the next live turn reads them from history.
|
|
347
|
+
if (opts.catchUp) {
|
|
348
|
+
if (!shouldReplyToCatchUp(platformTs)) {
|
|
349
|
+
log(
|
|
350
|
+
"whatsapp",
|
|
351
|
+
`[${chat.chatId}] Recorded offline message from ${senderName} (history only — too old for a reply turn)`,
|
|
352
|
+
);
|
|
353
|
+
recordMessageProcessed();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
log(
|
|
357
|
+
"whatsapp",
|
|
358
|
+
`[${chat.chatId}] Catch-up: replying to offline message from ${senderName}`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
320
362
|
const preview = text || `(${media?.type ?? "media"})`;
|
|
321
363
|
log(
|
|
322
364
|
"whatsapp",
|
|
@@ -334,8 +376,8 @@ export function createWhatsAppFrontend(
|
|
|
334
376
|
: "";
|
|
335
377
|
const prompt = `[${senderName}] msg_id:${msgId}: ${text}${mediaNote}`;
|
|
336
378
|
|
|
337
|
-
|
|
338
|
-
|
|
379
|
+
const runTurn = () =>
|
|
380
|
+
execute({
|
|
339
381
|
chatId: chat.chatId,
|
|
340
382
|
numericChatId: chat.numericChatId,
|
|
341
383
|
prompt,
|
|
@@ -374,34 +416,63 @@ export function createWhatsAppFrontend(
|
|
|
374
416
|
}
|
|
375
417
|
},
|
|
376
418
|
});
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
419
|
+
|
|
420
|
+
await runTurnWithRecovery({
|
|
421
|
+
chatId: chat.chatId,
|
|
422
|
+
senderName,
|
|
423
|
+
runTurn,
|
|
424
|
+
sendErrorText: async (text) => {
|
|
425
|
+
if (!sock) return;
|
|
426
|
+
try {
|
|
427
|
+
await sendText({ sock, gateway }, chat, text);
|
|
428
|
+
} catch (sendErr) {
|
|
429
|
+
logError(
|
|
430
|
+
"whatsapp",
|
|
431
|
+
`error delivery failed: ${sendErr instanceof Error ? sendErr.message : sendErr}`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
});
|
|
384
436
|
}
|
|
385
437
|
|
|
386
438
|
/** One socket lifetime. Resolves with what the caller should do next. */
|
|
387
439
|
async function connectOnce(): Promise<"reconnect" | "logged-out" | "stop"> {
|
|
388
|
-
|
|
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);
|
|
389
443
|
const socket = makeWASocket({
|
|
390
444
|
auth: state,
|
|
391
445
|
logger: makeWaLogger(),
|
|
392
446
|
markOnlineOnConnect: false,
|
|
393
447
|
// The account is a bot: announcing "online" would suppress the
|
|
394
448
|
// phone's own notifications for the human who owns the number.
|
|
449
|
+
//
|
|
450
|
+
// 120s per QR/pairing ref — the default (60s + 20s refreshes) gave
|
|
451
|
+
// a ~2½-minute socket lifetime in pairing mode, shorter than it
|
|
452
|
+
// takes a human to pick up their phone and type the code.
|
|
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,
|
|
395
459
|
});
|
|
396
460
|
sock = socket;
|
|
397
461
|
socket.ev.on("creds.update", saveCreds);
|
|
398
462
|
|
|
399
463
|
socket.ev.on("messages.upsert", ({ messages, type }) => {
|
|
400
|
-
// "notify" is a live message
|
|
401
|
-
//
|
|
402
|
-
|
|
464
|
+
// "notify" is a live message. "append" is everything delivered out
|
|
465
|
+
// of band — chiefly messages QUEUED WHILE THE DAEMON WAS DOWN
|
|
466
|
+
// (Baileys marks offline-queued nodes as append), but also our own
|
|
467
|
+
// sends echoing back and newsletter posts, which handleInbound's
|
|
468
|
+
// fromMe/allowlist gates drop. Dropping append wholesale meant any
|
|
469
|
+
// message sent during a restart simply vanished: never recorded,
|
|
470
|
+
// never answered. Appends are processed as catch-up: always
|
|
471
|
+
// recorded, replied to only while fresh.
|
|
472
|
+
if (type !== "notify" && type !== "append") return;
|
|
473
|
+
const catchUp = type === "append";
|
|
403
474
|
for (const msg of messages) {
|
|
404
|
-
void handleInbound(msg).catch((err) => {
|
|
475
|
+
void handleInbound(msg, { catchUp }).catch((err) => {
|
|
405
476
|
logError(
|
|
406
477
|
"whatsapp",
|
|
407
478
|
`inbound handler failed: ${err instanceof Error ? err.message : err}`,
|
|
@@ -420,13 +491,32 @@ export function createWhatsAppFrontend(
|
|
|
420
491
|
pairingRequested = true;
|
|
421
492
|
socket
|
|
422
493
|
.requestPairingCode(bareId(settings.pairingNumber))
|
|
423
|
-
.then((code) =>
|
|
494
|
+
.then((code) => {
|
|
424
495
|
log(
|
|
425
496
|
"whatsapp",
|
|
426
497
|
`Pairing code: ${code} — enter it on ${settings.pairingNumber} ` +
|
|
427
498
|
`via WhatsApp → Linked devices → Link with phone number`,
|
|
428
|
-
)
|
|
429
|
-
|
|
499
|
+
);
|
|
500
|
+
// The daemon log is where pairing codes go to die — the
|
|
501
|
+
// human who has to type this is on another frontend.
|
|
502
|
+
pairingCodesIssued++;
|
|
503
|
+
if (
|
|
504
|
+
shouldNotifyPairingCode(
|
|
505
|
+
pairingCodesIssued,
|
|
506
|
+
lastPairingNotifyAt,
|
|
507
|
+
)
|
|
508
|
+
) {
|
|
509
|
+
lastPairingNotifyAt = Date.now();
|
|
510
|
+
void notifyAdmin(
|
|
511
|
+
`📱 WhatsApp needs re-pairing.\n` +
|
|
512
|
+
`Code: ${code}\n` +
|
|
513
|
+
`On the phone with ${settings.pairingNumber}: WhatsApp → ` +
|
|
514
|
+
`Linked devices → Link with phone number.\n` +
|
|
515
|
+
`Valid for a few minutes; if it expires, the next code ` +
|
|
516
|
+
`arrives automatically.`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
})
|
|
430
520
|
.catch((err) =>
|
|
431
521
|
logError(
|
|
432
522
|
"whatsapp",
|
|
@@ -448,6 +538,12 @@ export function createWhatsAppFrontend(
|
|
|
448
538
|
.filter((id): id is string => Boolean(id))
|
|
449
539
|
.map(bareId);
|
|
450
540
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
541
|
+
if (pairingCodesIssued > 0) {
|
|
542
|
+
void notifyAdmin("✅ WhatsApp re-linked and connected.");
|
|
543
|
+
}
|
|
544
|
+
failedPairingCycles = 0;
|
|
545
|
+
pairingCodesIssued = 0;
|
|
546
|
+
lastPairingNotifyAt = undefined;
|
|
451
547
|
log(
|
|
452
548
|
"whatsapp",
|
|
453
549
|
`Connected as ${socket.user?.name ?? "?"} (${selfIds.join("/") || "?"})`,
|
|
@@ -460,12 +556,37 @@ export function createWhatsAppFrontend(
|
|
|
460
556
|
{ output?: { statusCode?: number } } | undefined
|
|
461
557
|
)?.output?.statusCode;
|
|
462
558
|
if (stopping) return resolve("stop");
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
"
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
+
}
|
|
469
590
|
}
|
|
470
591
|
});
|
|
471
592
|
});
|
|
@@ -498,6 +619,12 @@ export function createWhatsAppFrontend(
|
|
|
498
619
|
getBridgePort: () => gateway.getPort(),
|
|
499
620
|
|
|
500
621
|
async init() {
|
|
622
|
+
setWhatsAppBotName(config.botDisplayName);
|
|
623
|
+
// The in-memory message-id counter restarts at its base every boot,
|
|
624
|
+
// but history persists — seed it past what the table already holds
|
|
625
|
+
// so post-restart messages don't re-issue ids INSERT OR IGNORE then
|
|
626
|
+
// silently drops (chat ids all start with "wa_").
|
|
627
|
+
seedMessageStore((maxMsgIdForChatPrefix("wa_") ?? 0) + 1);
|
|
501
628
|
gateway.registerFrontendHandler(
|
|
502
629
|
"whatsapp",
|
|
503
630
|
createWhatsAppActionHandler(() => sock, gateway),
|
|
@@ -529,8 +656,29 @@ export function createWhatsAppFrontend(
|
|
|
529
656
|
"whatsapp",
|
|
530
657
|
"Logged out by WhatsApp — clearing auth state, re-pairing",
|
|
531
658
|
);
|
|
659
|
+
if (failedPairingCycles === 0 && pairingCodesIssued === 0) {
|
|
660
|
+
void notifyAdmin(
|
|
661
|
+
"⚠️ WhatsApp unlinked this device (logged out). " +
|
|
662
|
+
"Re-pairing — a pairing code follows.",
|
|
663
|
+
);
|
|
664
|
+
}
|
|
532
665
|
rmSync(dirs.whatsappAuth, { recursive: true, force: true });
|
|
666
|
+
// Pairing needs a HUMAN to type a code, so this is not a
|
|
667
|
+
// network-blip backoff: retry quickly once, then space cycles
|
|
668
|
+
// out (5→10→20→30-min cap). The old immediate loop burned a
|
|
669
|
+
// fresh code every ~2½ minutes forever — each invalidating the
|
|
670
|
+
// last, at exactly the cadence WhatsApp rate-limits.
|
|
671
|
+
failedPairingCycles++;
|
|
672
|
+
const pairingDelay = nextPairingDelayMs(failedPairingCycles - 1);
|
|
673
|
+
if (pairingDelay > 0) {
|
|
674
|
+
log(
|
|
675
|
+
"whatsapp",
|
|
676
|
+
`Next pairing attempt in ${Math.round(pairingDelay / 60_000)}m`,
|
|
677
|
+
);
|
|
678
|
+
await new Promise((r) => setTimeout(r, pairingDelay));
|
|
679
|
+
}
|
|
533
680
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
681
|
+
continue;
|
|
534
682
|
}
|
|
535
683
|
await new Promise((r) => setTimeout(r, reconnectDelay));
|
|
536
684
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
@@ -546,6 +694,10 @@ export function createWhatsAppFrontend(
|
|
|
546
694
|
/* already closed */
|
|
547
695
|
}
|
|
548
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();
|
|
549
701
|
await gateway.stop();
|
|
550
702
|
log("whatsapp", "WhatsApp frontend stopped");
|
|
551
703
|
},
|
|
@@ -41,6 +41,22 @@ const ID_BASE = 1_000_000;
|
|
|
41
41
|
const MAX_TRACKED = 2_000;
|
|
42
42
|
|
|
43
43
|
let nextId = ID_BASE;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Raise the id counter past what persistent history already holds.
|
|
47
|
+
*
|
|
48
|
+
* The counter is in-memory and restarts at ID_BASE every boot, but the
|
|
49
|
+
* ids it hands out are also the `msg_id`s written to the history table,
|
|
50
|
+
* where `INSERT OR IGNORE` + UNIQUE(chat_id, msg_id) dedupes. Without
|
|
51
|
+
* this seed, the first messages after a daemon restart re-issue ids the
|
|
52
|
+
* previous run already used — the IGNORE then silently drops them from
|
|
53
|
+
* history, and a reaction/reply addressed at an old id from history hits
|
|
54
|
+
* whatever new message reused the number. Called at frontend start with
|
|
55
|
+
* max(msg_id) over wa_* chats + 1.
|
|
56
|
+
*/
|
|
57
|
+
export function seedMessageStore(floor: number): void {
|
|
58
|
+
if (Number.isFinite(floor)) nextId = Math.max(nextId, Math.floor(floor));
|
|
59
|
+
}
|
|
44
60
|
const byMsgId = new Map<number, StoredMessage>();
|
|
45
61
|
const byWaId = new Map<string, number>();
|
|
46
62
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing-recovery policy for the WhatsApp frontend.
|
|
3
|
+
*
|
|
4
|
+
* When WhatsApp unlinks the device (stream conflict `device_removed`,
|
|
5
|
+
* a manual unlink, an account ban), recovery needs a human: someone must
|
|
6
|
+
* enter a pairing code on the phone. The old loop treated a logout like
|
|
7
|
+
* a network blip — wipe auth, reconnect immediately, request a fresh
|
|
8
|
+
* code, time out, repeat — which generated a new code every ~2½ minutes
|
|
9
|
+
* forever (26 codes in 80 minutes on the live deployment), each one
|
|
10
|
+
* invalidating the last, all of them visible only in the daemon log.
|
|
11
|
+
* That cadence is also exactly the shape WhatsApp rate-limits.
|
|
12
|
+
*
|
|
13
|
+
* Policy, kept pure here so it is testable without a socket:
|
|
14
|
+
* - retry pairing on a growing delay (quick first retry, 30-min cap),
|
|
15
|
+
* - surface each fresh code to the admin over a live frontend, but
|
|
16
|
+
* after the first few, at most one notification per hour — each
|
|
17
|
+
* notification always carries the CURRENT code.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Delay before pairing attempt `attempt` (1-based, i.e. after `attempt` failures). */
|
|
21
|
+
export function nextPairingDelayMs(failedAttempts: number): number {
|
|
22
|
+
if (failedAttempts <= 0) return 0;
|
|
23
|
+
const LADDER = [5_000, 5 * 60_000, 10 * 60_000, 20 * 60_000];
|
|
24
|
+
const CAP = 30 * 60_000;
|
|
25
|
+
return LADDER[failedAttempts - 1] ?? CAP;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** First codes always notify; afterwards at most one per hour. */
|
|
29
|
+
export const PAIRING_NOTIFY_FREE_CODES = 3;
|
|
30
|
+
export const PAIRING_NOTIFY_MIN_GAP_MS = 60 * 60_000;
|
|
31
|
+
|
|
32
|
+
export function shouldNotifyPairingCode(
|
|
33
|
+
codesIssued: number,
|
|
34
|
+
lastNotifiedAt: number | undefined,
|
|
35
|
+
now: number = Date.now(),
|
|
36
|
+
): boolean {
|
|
37
|
+
if (codesIssued <= PAIRING_NOTIFY_FREE_CODES) return true;
|
|
38
|
+
if (lastNotifiedAt === undefined) return true;
|
|
39
|
+
return now - lastNotifiedAt >= PAIRING_NOTIFY_MIN_GAP_MS;
|
|
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;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure semantics for a WhatsApp turn, mirroring the Telegram queue
|
|
3
|
+
* (frontend/telegram/handlers/queue.ts): a user-initiated stop settles
|
|
4
|
+
* silently, a brief transient failure gets one retry, and anything else
|
|
5
|
+
* is REPORTED IN THE CHAT. Before this existed the error went only to
|
|
6
|
+
* the log — the WhatsApp user's message was marked read and then nothing
|
|
7
|
+
* ever came back, which reads as being ignored.
|
|
8
|
+
*
|
|
9
|
+
* Split from index.ts so the policy is unit-testable without a Baileys
|
|
10
|
+
* socket: the caller injects the turn and the delivery.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
classify,
|
|
15
|
+
friendlyMessage,
|
|
16
|
+
RETRY_ELAPSED_CAP_MS,
|
|
17
|
+
} from "../../core/errors.js";
|
|
18
|
+
import { log, logError } from "../../util/log.js";
|
|
19
|
+
import {
|
|
20
|
+
recordError,
|
|
21
|
+
recordMessageProcessed,
|
|
22
|
+
recordMessageSettled,
|
|
23
|
+
} from "../../util/watchdog.js";
|
|
24
|
+
|
|
25
|
+
export type TurnRecoveryDeps = {
|
|
26
|
+
chatId: string;
|
|
27
|
+
senderName: string;
|
|
28
|
+
runTurn: () => Promise<unknown>;
|
|
29
|
+
/** Deliver a friendly error line into the chat. Must not throw. */
|
|
30
|
+
sendErrorText: (text: string) => Promise<void>;
|
|
31
|
+
/** Test seam for the retry pause; defaults to a real setTimeout. */
|
|
32
|
+
wait?: (ms: number) => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function runTurnWithRecovery(
|
|
36
|
+
deps: TurnRecoveryDeps,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const wait =
|
|
39
|
+
deps.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
40
|
+
|
|
41
|
+
const startedAt = Date.now();
|
|
42
|
+
try {
|
|
43
|
+
await deps.runTurn();
|
|
44
|
+
recordMessageProcessed();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const classified = classify(err);
|
|
47
|
+
// A user-initiated /stop is an outcome, not a fault — the stop was
|
|
48
|
+
// already acknowledged, so an error bubble here would contradict it.
|
|
49
|
+
if (classified.reason === "stopped") {
|
|
50
|
+
log("whatsapp", `[${deps.chatId}] turn stopped by user`);
|
|
51
|
+
recordMessageSettled();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
logError(
|
|
55
|
+
"whatsapp",
|
|
56
|
+
`[${deps.chatId}] [${deps.senderName}] ${classified.reason}: ${classified.message}`,
|
|
57
|
+
);
|
|
58
|
+
recordError(classified.message);
|
|
59
|
+
|
|
60
|
+
// Retry once for transients (rate_limit, overloaded, network) — but
|
|
61
|
+
// only when the failed attempt was brief. An attempt that already ran
|
|
62
|
+
// for minutes won't be saved by a 2s pause, and turns serialize per
|
|
63
|
+
// chat, so a blind retry doubles the stall for everything behind it.
|
|
64
|
+
const attemptMs = Date.now() - startedAt;
|
|
65
|
+
if (classified.retryable && attemptMs < RETRY_ELAPSED_CAP_MS) {
|
|
66
|
+
const delayMs = classified.retryAfterMs ?? 2000;
|
|
67
|
+
log(
|
|
68
|
+
"whatsapp",
|
|
69
|
+
`[${deps.chatId}] Retrying after ${classified.reason} (${delayMs}ms)...`,
|
|
70
|
+
);
|
|
71
|
+
try {
|
|
72
|
+
await wait(delayMs);
|
|
73
|
+
await deps.runTurn();
|
|
74
|
+
recordMessageProcessed();
|
|
75
|
+
return;
|
|
76
|
+
} catch (retryErr) {
|
|
77
|
+
const retryClassified = classify(retryErr);
|
|
78
|
+
logError(
|
|
79
|
+
"whatsapp",
|
|
80
|
+
`[${deps.chatId}] Retry failed: ${retryClassified.message}`,
|
|
81
|
+
);
|
|
82
|
+
recordMessageSettled();
|
|
83
|
+
await deps.sendErrorText(friendlyMessage(retryClassified));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
recordMessageSettled();
|
|
89
|
+
await deps.sendErrorText(friendlyMessage(classified));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How fresh a catch-up (offline-queued) message must be to still get a
|
|
95
|
+
* reply turn. Messages older than this are recorded in history — the next
|
|
96
|
+
* live turn sees them — but not answered: replying to a whole night's
|
|
97
|
+
* backlog hours later reads worse than picking up naturally.
|
|
98
|
+
*/
|
|
99
|
+
export const CATCH_UP_REPLY_WINDOW_MS = 15 * 60_000;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Whether a message delivered as catch-up (Baileys `append`) should get a
|
|
103
|
+
* reply turn. `platformTsMs` is the message's own timestamp in ms; a
|
|
104
|
+
* missing/zero timestamp counts as fresh — better one odd reply than a
|
|
105
|
+
* silently dropped question.
|
|
106
|
+
*/
|
|
107
|
+
export function shouldReplyToCatchUp(
|
|
108
|
+
platformTsMs: number,
|
|
109
|
+
now: number = Date.now(),
|
|
110
|
+
): boolean {
|
|
111
|
+
if (!Number.isFinite(platformTsMs) || platformTsMs <= 0) return true;
|
|
112
|
+
return now - platformTsMs <= CATCH_UP_REPLY_WINDOW_MS;
|
|
113
|
+
}
|
package/src/storage/history.ts
CHANGED
|
@@ -81,6 +81,11 @@ export function pushMessage(chatId: string, msg: HistoryMessage): void {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** See repositories/history-repo.ts `maxMsgIdForPrefix`. */
|
|
85
|
+
export function maxMsgIdForChatPrefix(prefix: string): number | undefined {
|
|
86
|
+
return repo.maxMsgIdForPrefix(prefix);
|
|
87
|
+
}
|
|
88
|
+
|
|
84
89
|
export function getRecentHistory(chatId: string, limit = 50): HistoryMessage[] {
|
|
85
90
|
return repo.recent(chatId, limit);
|
|
86
91
|
}
|
|
@@ -98,6 +103,28 @@ export function getHistoryBefore(
|
|
|
98
103
|
return repo.recentBefore(chatId, beforeMsgId, limit);
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
/** Formatted page of the messages strictly older than `beforeMsgId`. */
|
|
107
|
+
export function getFormattedBefore(
|
|
108
|
+
chatId: string,
|
|
109
|
+
beforeMsgId: number,
|
|
110
|
+
limit = 30,
|
|
111
|
+
): string {
|
|
112
|
+
const messages = repo.recentBefore(chatId, beforeMsgId, limit);
|
|
113
|
+
if (messages.length === 0) return "No messages before that point.";
|
|
114
|
+
return messages.map(formatMessage).join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Formatted page of the messages strictly older than a timestamp (ms). */
|
|
118
|
+
export function getFormattedBeforeTime(
|
|
119
|
+
chatId: string,
|
|
120
|
+
beforeTs: number,
|
|
121
|
+
limit = 30,
|
|
122
|
+
): string {
|
|
123
|
+
const messages = repo.recentBeforeTime(chatId, beforeTs, limit);
|
|
124
|
+
if (messages.length === 0) return "No messages before that date.";
|
|
125
|
+
return messages.map(formatMessage).join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
101
128
|
/**
|
|
102
129
|
* Raw (wire-friendly) full-text search over a chat's history. Unlike
|
|
103
130
|
* [searchHistory] — which formats a string for the agent's tool — this
|
|
@@ -108,6 +108,17 @@ export function recentBefore(
|
|
|
108
108
|
return rows.reverse().map(rowToMessage);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
export function recentBeforeTime(
|
|
112
|
+
chatId: string,
|
|
113
|
+
beforeTs: number,
|
|
114
|
+
limit: number,
|
|
115
|
+
): HistoryMessage[] {
|
|
116
|
+
const rows = getDatabase()
|
|
117
|
+
.prepare(historySql.recentBeforeTime)
|
|
118
|
+
.all(chatId, beforeTs, limit) as Row[];
|
|
119
|
+
return rows.reverse().map(rowToMessage);
|
|
120
|
+
}
|
|
121
|
+
|
|
111
122
|
export function setFilePath(
|
|
112
123
|
chatId: string,
|
|
113
124
|
msgId: number,
|
|
@@ -174,6 +185,19 @@ export function latestMsgId(chatId: string): number | undefined {
|
|
|
174
185
|
return row?.msg_id;
|
|
175
186
|
}
|
|
176
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Highest msg_id over every chat whose id begins with `prefix` (compared
|
|
190
|
+
* literally — LIKE wildcards in the prefix are escaped). Undefined when no
|
|
191
|
+
* such chat has any history.
|
|
192
|
+
*/
|
|
193
|
+
export function maxMsgIdForPrefix(prefix: string): number | undefined {
|
|
194
|
+
const pattern = prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`) + "%";
|
|
195
|
+
const row = getDatabase()
|
|
196
|
+
.prepare(historySql.maxMsgIdForPrefix)
|
|
197
|
+
.get(pattern) as { max_id: number | null } | undefined;
|
|
198
|
+
return row?.max_id ?? undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
177
201
|
export type KnownUser = {
|
|
178
202
|
senderId: number;
|
|
179
203
|
name: string;
|
|
@@ -21,6 +21,14 @@ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
|
21
21
|
FROM history_messages
|
|
22
22
|
WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?
|
|
23
23
|
|
|
24
|
+
-- name: recentBeforeTime
|
|
25
|
+
-- Time-cursor variant of recentBefore for the read_history `before` date
|
|
26
|
+
-- parameter: the newest `limit` messages strictly older than a timestamp.
|
|
27
|
+
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
28
|
+
timestamp, media_type, sticker_file_id, file_path
|
|
29
|
+
FROM history_messages
|
|
30
|
+
WHERE chat_id = ? AND timestamp < ? ORDER BY id DESC LIMIT ?
|
|
31
|
+
|
|
24
32
|
-- name: setFilePath
|
|
25
33
|
UPDATE history_messages SET file_path = ? WHERE chat_id = ? AND msg_id = ?
|
|
26
34
|
|
|
@@ -60,6 +68,12 @@ WHERE chat_id = ? AND sender_id = ? ORDER BY id DESC LIMIT ?
|
|
|
60
68
|
-- name: latestMsgId
|
|
61
69
|
SELECT msg_id FROM history_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1
|
|
62
70
|
|
|
71
|
+
-- name: maxMsgIdForPrefix
|
|
72
|
+
-- Highest msg_id across every chat whose id starts with a prefix
|
|
73
|
+
-- (parameter is a LIKE pattern with \ escapes). Seeds the WhatsApp
|
|
74
|
+
-- frontend's in-memory id counter past what history already holds.
|
|
75
|
+
SELECT MAX(msg_id) AS max_id FROM history_messages WHERE chat_id LIKE ? ESCAPE '\'
|
|
76
|
+
|
|
63
77
|
-- name: knownUsers
|
|
64
78
|
SELECT sender_id,
|
|
65
79
|
MAX(timestamp) AS last_seen,
|
|
@@ -346,6 +346,12 @@ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
|
346
346
|
timestamp, media_type, sticker_file_id, file_path
|
|
347
347
|
FROM history_messages
|
|
348
348
|
WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?`,
|
|
349
|
+
recentBeforeTime: `-- Time-cursor variant of recentBefore for the read_history \`before\` date
|
|
350
|
+
-- parameter: the newest \`limit\` messages strictly older than a timestamp.
|
|
351
|
+
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
352
|
+
timestamp, media_type, sticker_file_id, file_path
|
|
353
|
+
FROM history_messages
|
|
354
|
+
WHERE chat_id = ? AND timestamp < ? ORDER BY id DESC LIMIT ?`,
|
|
349
355
|
setFilePath: `UPDATE history_messages SET file_path = ? WHERE chat_id = ? AND msg_id = ?`,
|
|
350
356
|
deleteChat: `DELETE FROM history_messages WHERE chat_id = ?`,
|
|
351
357
|
searchFts: `-- The match param must already be a valid FTS5 expression
|
|
@@ -371,6 +377,10 @@ WHERE chat_id = ? AND msg_id = ? ORDER BY id DESC LIMIT 1`,
|
|
|
371
377
|
FROM history_messages
|
|
372
378
|
WHERE chat_id = ? AND sender_id = ? ORDER BY id DESC LIMIT ?`,
|
|
373
379
|
latestMsgId: `SELECT msg_id FROM history_messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1`,
|
|
380
|
+
maxMsgIdForPrefix: `-- Highest msg_id across every chat whose id starts with a prefix
|
|
381
|
+
-- (parameter is a LIKE pattern with \\ escapes). Seeds the WhatsApp
|
|
382
|
+
-- frontend's in-memory id counter past what history already holds.
|
|
383
|
+
SELECT MAX(msg_id) AS max_id FROM history_messages WHERE chat_id LIKE ? ESCAPE '\\'`,
|
|
374
384
|
knownUsers: `SELECT sender_id,
|
|
375
385
|
MAX(timestamp) AS last_seen,
|
|
376
386
|
COUNT(*) AS message_count,
|