talon-agent 3.34.1 → 3.35.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.
Files changed (47) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -10
  3. package/package.json +3 -3
  4. package/src/backend/codex/auth.ts +40 -1
  5. package/src/backend/codex/handler/message.ts +16 -1
  6. package/src/backend/codex/plan-usage.ts +27 -2
  7. package/src/backend/kilo/handler/message.ts +2 -0
  8. package/src/backend/kilo/server.ts +1 -0
  9. package/src/backend/opencode/handler/message.ts +2 -0
  10. package/src/backend/opencode/server.ts +1 -0
  11. package/src/backend/remote-server/chat-turn.ts +13 -3
  12. package/src/backend/remote-server/lifecycle.ts +37 -0
  13. package/src/backend/remote-server/server-bindings.ts +12 -0
  14. package/src/backend/remote-server/state.ts +8 -0
  15. package/src/backend/remote-server/turn.ts +93 -23
  16. package/src/bootstrap.ts +24 -10
  17. package/src/core/auth/expiry-monitor.ts +89 -0
  18. package/src/core/auth/login-flow.ts +247 -0
  19. package/src/core/auth/status.ts +193 -0
  20. package/src/core/engine/gateway-actions/history.ts +12 -1
  21. package/src/core/errors.ts +1 -1
  22. package/src/core/tools/history.ts +10 -1
  23. package/src/frontend/shared/model-commands.ts +400 -0
  24. package/src/frontend/telegram/auth-panel.ts +205 -0
  25. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  26. package/src/frontend/telegram/callbacks/index.ts +16 -0
  27. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  28. package/src/frontend/telegram/commands/auth.ts +58 -0
  29. package/src/frontend/telegram/commands/definitions.ts +4 -0
  30. package/src/frontend/telegram/commands/index.ts +10 -4
  31. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  32. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  33. package/src/frontend/whatsapp/actions/history.ts +100 -0
  34. package/src/frontend/whatsapp/actions/index.ts +4 -1
  35. package/src/frontend/whatsapp/commands.ts +371 -0
  36. package/src/frontend/whatsapp/inbound.ts +24 -46
  37. package/src/frontend/whatsapp/index.ts +12 -1
  38. package/src/frontend/whatsapp/media-store.ts +20 -1
  39. package/src/frontend/whatsapp/message-store.ts +101 -24
  40. package/src/storage/history.ts +22 -1
  41. package/src/storage/repositories/history-repo.ts +14 -0
  42. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  43. package/src/storage/sql/history.sql +12 -0
  44. package/src/storage/sql/schema.sql +21 -0
  45. package/src/storage/sql/statements.generated.ts +50 -1
  46. package/src/storage/sql/whatsapp-messages.sql +29 -0
  47. package/src/storage/whatsapp-messages.ts +51 -0
@@ -9,14 +9,22 @@
9
9
  * not just the id. So each message Talon sees — inbound or sent — is
10
10
  * assigned a monotonic numeric id here and remembered alongside its key.
11
11
  *
12
- * `forward_message` needs the full message, not only its key, so the
13
- * proto is retained too. The map is bounded and evicts oldest-first: a
14
- * long-running daemon must not accumulate every message it ever saw, and
15
- * a model addressing a message thousands of turns back is not a case
16
- * worth holding memory for.
12
+ * `forward_message` and media re-downloads need the full message, not
13
+ * only its key, so the proto is retained too. The in-memory map is a
14
+ * bounded cache that evicts oldest-first; the record is the
15
+ * `whatsapp_messages` table (storage/whatsapp-messages.ts), which a miss
16
+ * falls through to — so an id from before the last restart, or from
17
+ * thousands of messages back, still resolves.
17
18
  */
18
19
 
19
- import type { WAMessage, WAMessageKey } from "baileys";
20
+ import { proto, type WAMessage, type WAMessageKey } from "baileys";
21
+ import {
22
+ getWhatsAppMessage,
23
+ getWhatsAppMessageByWaId,
24
+ saveWhatsAppMessage,
25
+ clearWhatsAppMessages,
26
+ type WhatsAppMessageRecord,
27
+ } from "../../storage/whatsapp-messages.js";
20
28
 
21
29
  export type StoredMessage = {
22
30
  /** Talon-facing numeric id. */
@@ -25,7 +33,7 @@ export type StoredMessage = {
25
33
  key: WAMessageKey;
26
34
  /** Talon chat id (wa_dm_… / wa_group_…). */
27
35
  chatId: string;
28
- /** Retained for forward_message, which re-sends the whole message. */
36
+ /** Retained for forward_message and media re-downloads. */
29
37
  message?: WAMessage;
30
38
  /** Plain text at the time it was stored (copy_message, previews). */
31
39
  text: string;
@@ -43,16 +51,15 @@ const MAX_TRACKED = 2_000;
43
51
  let nextId = ID_BASE;
44
52
 
45
53
  /**
46
- * Raise the id counter past what persistent history already holds.
54
+ * Raise the id counter past what persistent storage already holds.
47
55
  *
48
56
  * 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
57
+ * ids it hands out are also the `msg_id`s written to the history and
58
+ * whatsapp_messages tables, where `INSERT OR IGNORE` dedupes. Without
51
59
  * 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.
60
+ * previous run already used — the IGNORE then silently drops them, and
61
+ * a reaction/reply addressed at an old id hits whatever new message
62
+ * reused the number. Called at frontend start with max(msg_id) + 1.
56
63
  */
57
64
  export function seedMessageStore(floor: number): void {
58
65
  if (Number.isFinite(floor)) nextId = Math.max(nextId, Math.floor(floor));
@@ -71,10 +78,74 @@ function evictOldest(): void {
71
78
  }
72
79
  }
73
80
 
81
+ function cache(stored: StoredMessage): void {
82
+ byMsgId.set(stored.msgId, stored);
83
+ if (stored.key.id) byWaId.set(stored.key.id, stored.msgId);
84
+ evictOldest();
85
+ }
86
+
87
+ /**
88
+ * Proto ↔ JSON. Byte fields (media keys, hashes) travel as base64:
89
+ * `toJSON` on a WebMessageInfo instance emits them that way, and
90
+ * `fromObject` decodes them back, so a rehydrated message downloads.
91
+ * Normalising through `fromObject` first also covers a plain object
92
+ * that was never a proto instance (a Uint8Array would otherwise
93
+ * stringify as `{"0":…}`).
94
+ */
95
+ function serializeMessage(message: WAMessage): string {
96
+ return JSON.stringify(proto.WebMessageInfo.fromObject(message).toJSON());
97
+ }
98
+
99
+ /** Rebuild a stored message from its persisted row. */
100
+ function fromRecord(record: WhatsAppMessageRecord): StoredMessage {
101
+ const key: WAMessageKey = {
102
+ id: record.waId,
103
+ remoteJid: record.remoteJid,
104
+ fromMe: record.fromMe,
105
+ ...(record.participant ? { participant: record.participant } : {}),
106
+ };
107
+ let message: WAMessage | undefined;
108
+ if (record.messageJson) {
109
+ try {
110
+ message = proto.WebMessageInfo.fromObject(
111
+ JSON.parse(record.messageJson),
112
+ ) as WAMessage;
113
+ } catch {
114
+ message = undefined; // an undecodable proto loses only forwarding
115
+ }
116
+ }
117
+ return {
118
+ msgId: record.msgId,
119
+ key,
120
+ chatId: record.chatId,
121
+ text: record.text,
122
+ senderName: record.senderName,
123
+ timestamp: record.timestamp,
124
+ ...(message ? { message } : {}),
125
+ };
126
+ }
127
+
128
+ function persist(stored: StoredMessage): void {
129
+ if (!stored.key.id || !stored.key.remoteJid) return;
130
+ saveWhatsAppMessage({
131
+ chatId: stored.chatId,
132
+ msgId: stored.msgId,
133
+ waId: stored.key.id,
134
+ remoteJid: stored.key.remoteJid,
135
+ fromMe: stored.key.fromMe === true,
136
+ participant: stored.key.participant ?? undefined,
137
+ senderName: stored.senderName,
138
+ text: stored.text,
139
+ timestamp: stored.timestamp,
140
+ messageJson: stored.message ? serializeMessage(stored.message) : undefined,
141
+ });
142
+ }
143
+
74
144
  /**
75
145
  * Record a message and return its Talon numeric id. Re-recording a
76
146
  * WhatsApp id already seen returns the original number, so the same
77
- * message never gets two identities (Baileys can re-deliver on reconnect).
147
+ * message never gets two identities (Baileys can re-deliver on reconnect,
148
+ * and a restart in between must not change the answer).
78
149
  */
79
150
  export function rememberMessage(entry: {
80
151
  key: WAMessageKey;
@@ -86,7 +157,7 @@ export function rememberMessage(entry: {
86
157
  }): number {
87
158
  const waId = entry.key.id;
88
159
  if (waId) {
89
- const existing = byWaId.get(waId);
160
+ const existing = byWaId.get(waId) ?? getWhatsAppMessageByWaId(waId)?.msgId;
90
161
  if (existing !== undefined) return existing;
91
162
  }
92
163
  const msgId = nextId++;
@@ -99,21 +170,26 @@ export function rememberMessage(entry: {
99
170
  timestamp: entry.timestamp ?? Date.now(),
100
171
  ...(entry.message ? { message: entry.message } : {}),
101
172
  };
102
- byMsgId.set(msgId, stored);
103
- if (waId) byWaId.set(waId, msgId);
104
- evictOldest();
173
+ cache(stored);
174
+ persist(stored);
105
175
  return msgId;
106
176
  }
107
177
 
108
178
  /** Look up a stored message by its Talon numeric id. */
109
179
  export function lookupMessage(msgId: number): StoredMessage | undefined {
110
- return byMsgId.get(msgId);
180
+ const cached = byMsgId.get(msgId);
181
+ if (cached) return cached;
182
+ const record = getWhatsAppMessage(msgId);
183
+ if (!record) return undefined;
184
+ const stored = fromRecord(record);
185
+ cache(stored);
186
+ return stored;
111
187
  }
112
188
 
113
189
  /** Look up by WhatsApp's own id (reply resolution on inbound). */
114
190
  export function lookupByWaId(waId: string): StoredMessage | undefined {
115
- const msgId = byWaId.get(waId);
116
- return msgId === undefined ? undefined : byMsgId.get(msgId);
191
+ const msgId = byWaId.get(waId) ?? getWhatsAppMessageByWaId(waId)?.msgId;
192
+ return msgId === undefined ? undefined : lookupMessage(msgId);
117
193
  }
118
194
 
119
195
  /**
@@ -134,12 +210,12 @@ export function resolveKey(
134
210
  if (!Number.isFinite(msgId)) {
135
211
  return { error: `Invalid message_id: ${String(value)}` };
136
212
  }
137
- const stored = byMsgId.get(msgId);
213
+ const stored = lookupMessage(msgId);
138
214
  if (!stored) {
139
215
  return {
140
216
  error:
141
217
  `Unknown message_id ${msgId} — WhatsApp message ids are assigned ` +
142
- `when Talon sees the message; only recent ones can be acted on.`,
218
+ `when Talon sees the message; use read_chat_history to find one.`,
143
219
  };
144
220
  }
145
221
  if (stored.chatId !== chatId) {
@@ -152,5 +228,6 @@ export function resolveKey(
152
228
  export function resetMessageStore(): void {
153
229
  byMsgId.clear();
154
230
  byWaId.clear();
231
+ clearWhatsAppMessages();
155
232
  nextId = ID_BASE;
156
233
  }
@@ -199,17 +199,30 @@ function chatIsEmpty(chatId: string): boolean {
199
199
  return repo.latestMsgId(chatId) === undefined;
200
200
  }
201
201
 
202
+ /** Optional time window for `searchHistory`, in epoch ms. */
203
+ export type HistoryDateRange = { after?: number; before?: number };
204
+
202
205
  export function searchHistory(
203
206
  chatId: string,
204
207
  query: string,
205
208
  limit = 20,
209
+ range: HistoryDateRange = {},
206
210
  ): string {
207
211
  if (chatIsEmpty(chatId)) return "No messages in history.";
208
212
  const match = ftsQuery(query);
209
213
  if (!match) return `No messages matching "${query}".`;
210
214
  let messages: HistoryMessage[];
211
215
  try {
212
- messages = repo.searchFts(chatId, match, limit);
216
+ messages =
217
+ range.after === undefined && range.before === undefined
218
+ ? repo.searchFts(chatId, match, limit)
219
+ : repo.searchFtsBetween(
220
+ chatId,
221
+ match,
222
+ range.after ?? 0,
223
+ range.before ?? Number.MAX_SAFE_INTEGER,
224
+ limit,
225
+ );
213
226
  } catch (err) {
214
227
  logError("history", `FTS search failed for ${JSON.stringify(query)}`, err);
215
228
  return `No messages matching "${query}".`;
@@ -229,6 +242,14 @@ export function getMessagesByUser(
229
242
  return messages.map(formatMessage).join("\n");
230
243
  }
231
244
 
245
+ /** The stored row for one message, or undefined. */
246
+ export function getHistoryMessage(
247
+ chatId: string,
248
+ msgId: number,
249
+ ): HistoryMessage | undefined {
250
+ return repo.byMsgId(chatId, msgId);
251
+ }
252
+
232
253
  export function getMessageById(chatId: string, msgId: number): string {
233
254
  if (chatIsEmpty(chatId)) return "No messages in history.";
234
255
  const msg = repo.byMsgId(chatId, msgId);
@@ -147,6 +147,20 @@ export function searchFts(
147
147
  return rows.reverse().map(rowToMessage);
148
148
  }
149
149
 
150
+ /** `searchFts` restricted to messages with `after <= timestamp < before`. */
151
+ export function searchFtsBetween(
152
+ chatId: string,
153
+ match: string,
154
+ after: number,
155
+ before: number,
156
+ limit: number,
157
+ ): HistoryMessage[] {
158
+ const rows = getDatabase()
159
+ .prepare(historySql.searchFtsBetween)
160
+ .all(chatId, match, after, before, limit) as Row[];
161
+ return rows.reverse().map(rowToMessage);
162
+ }
163
+
150
164
  export function bySenderName(
151
165
  chatId: string,
152
166
  nameFragment: string,
@@ -0,0 +1,101 @@
1
+ /**
2
+ * WhatsApp message-key repository — executes the statements in
3
+ * sql/whatsapp-messages.sql against the `whatsapp_messages` table; no
4
+ * SQL text lives here. The public store (storage/whatsapp-messages.ts)
5
+ * holds the domain API; this module owns statement execution and the
6
+ * row↔domain mapping.
7
+ */
8
+
9
+ import { getDatabase } from "../db.js";
10
+ import { whatsappMessagesSql } from "../sql/statements.generated.js";
11
+
12
+ /** One persisted WhatsApp message as the domain sees it. */
13
+ export type WhatsAppMessageRecord = {
14
+ chatId: string;
15
+ msgId: number;
16
+ waId: string;
17
+ remoteJid: string;
18
+ fromMe: boolean;
19
+ participant?: string;
20
+ senderName: string;
21
+ text: string;
22
+ timestamp: number;
23
+ /** The full WAMessage proto as JSON, when it was retained. */
24
+ messageJson?: string;
25
+ };
26
+
27
+ type Row = {
28
+ chat_id: string;
29
+ msg_id: number;
30
+ wa_id: string;
31
+ remote_jid: string;
32
+ from_me: number;
33
+ participant: string | null;
34
+ sender_name: string;
35
+ text: string;
36
+ timestamp: number;
37
+ message_json: string | null;
38
+ };
39
+
40
+ function rowToRecord(row: Row): WhatsAppMessageRecord {
41
+ return {
42
+ chatId: row.chat_id,
43
+ msgId: row.msg_id,
44
+ waId: row.wa_id,
45
+ remoteJid: row.remote_jid,
46
+ fromMe: row.from_me === 1,
47
+ participant: row.participant ?? undefined,
48
+ senderName: row.sender_name,
49
+ text: row.text,
50
+ timestamp: row.timestamp,
51
+ messageJson: row.message_json ?? undefined,
52
+ };
53
+ }
54
+
55
+ export function insert(record: WhatsAppMessageRecord): void {
56
+ getDatabase()
57
+ .prepare(whatsappMessagesSql.insert)
58
+ .run(
59
+ record.chatId,
60
+ record.msgId,
61
+ record.waId,
62
+ record.remoteJid,
63
+ record.fromMe ? 1 : 0,
64
+ record.participant ?? null,
65
+ record.senderName,
66
+ record.text,
67
+ record.timestamp,
68
+ record.messageJson ?? null,
69
+ );
70
+ }
71
+
72
+ export function byMsgId(msgId: number): WhatsAppMessageRecord | undefined {
73
+ const row = getDatabase().prepare(whatsappMessagesSql.byMsgId).get(msgId) as
74
+ Row | undefined;
75
+ return row ? rowToRecord(row) : undefined;
76
+ }
77
+
78
+ export function byWaId(waId: string): WhatsAppMessageRecord | undefined {
79
+ const row = getDatabase().prepare(whatsappMessagesSql.byWaId).get(waId) as
80
+ Row | undefined;
81
+ return row ? rowToRecord(row) : undefined;
82
+ }
83
+
84
+ export function maxMsgId(): number | undefined {
85
+ const row = getDatabase().prepare(whatsappMessagesSql.maxMsgId).get() as {
86
+ max_id: number | null;
87
+ };
88
+ return row.max_id ?? undefined;
89
+ }
90
+
91
+ /** Delete rows older than `cutoff` (ms); returns the number removed. */
92
+ export function deleteOlderThan(cutoff: number): number {
93
+ const result = getDatabase()
94
+ .prepare(whatsappMessagesSql.deleteOlderThan)
95
+ .run(cutoff) as { changes: number | bigint };
96
+ return Number(result.changes);
97
+ }
98
+
99
+ export function deleteAll(): void {
100
+ getDatabase().prepare(whatsappMessagesSql.deleteAll).run();
101
+ }
@@ -45,6 +45,18 @@ WHERE chat_id = ?
45
45
  AND id IN (SELECT rowid FROM history_fts WHERE history_fts MATCH ?)
46
46
  ORDER BY id DESC LIMIT ?
47
47
 
48
+ -- name: searchFtsBetween
49
+ -- searchFts restricted to a timestamp window [after, before) — the
50
+ -- search_history `after` / `before` date parameters. Either bound may be
51
+ -- the open end of the range (0 / a far-future value).
52
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
53
+ timestamp, media_type, sticker_file_id, file_path
54
+ FROM history_messages
55
+ WHERE chat_id = ?
56
+ AND id IN (SELECT rowid FROM history_fts WHERE history_fts MATCH ?)
57
+ AND timestamp >= ? AND timestamp < ?
58
+ ORDER BY id DESC LIMIT ?
59
+
48
60
  -- name: bySenderName
49
61
  -- The fragment param is LIKE-escaped by the repository (backslash escape).
50
62
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
@@ -251,3 +251,24 @@ CREATE TABLE IF NOT EXISTS journal (
251
251
  payload TEXT NOT NULL
252
252
  );
253
253
  CREATE INDEX IF NOT EXISTS idx_journal_type ON journal(type, seq);
254
+
255
+ -- WhatsApp message keys: Talon's numeric message id ↔ WhatsApp's
256
+ -- (id, remoteJid, fromMe, participant) key, plus the full proto as JSON
257
+ -- so react/reply/forward/download keep working on messages from before
258
+ -- a restart and media past the CDN TTL can be re-requested.
259
+ CREATE TABLE IF NOT EXISTS whatsapp_messages (
260
+ chat_id TEXT NOT NULL,
261
+ msg_id INTEGER NOT NULL,
262
+ wa_id TEXT NOT NULL,
263
+ remote_jid TEXT NOT NULL,
264
+ from_me INTEGER NOT NULL DEFAULT 0,
265
+ participant TEXT,
266
+ sender_name TEXT NOT NULL DEFAULT '',
267
+ text TEXT NOT NULL DEFAULT '',
268
+ timestamp INTEGER NOT NULL,
269
+ message_json TEXT,
270
+ PRIMARY KEY (chat_id, msg_id)
271
+ );
272
+ CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_msg ON whatsapp_messages(msg_id);
273
+ CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_wa_id ON whatsapp_messages(wa_id);
274
+ CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_time ON whatsapp_messages(timestamp);
@@ -255,7 +255,28 @@ CREATE TABLE IF NOT EXISTS journal (
255
255
  type TEXT NOT NULL,
256
256
  payload TEXT NOT NULL
257
257
  );
258
- CREATE INDEX IF NOT EXISTS idx_journal_type ON journal(type, seq);`;
258
+ CREATE INDEX IF NOT EXISTS idx_journal_type ON journal(type, seq);
259
+
260
+ -- WhatsApp message keys: Talon's numeric message id ↔ WhatsApp's
261
+ -- (id, remoteJid, fromMe, participant) key, plus the full proto as JSON
262
+ -- so react/reply/forward/download keep working on messages from before
263
+ -- a restart and media past the CDN TTL can be re-requested.
264
+ CREATE TABLE IF NOT EXISTS whatsapp_messages (
265
+ chat_id TEXT NOT NULL,
266
+ msg_id INTEGER NOT NULL,
267
+ wa_id TEXT NOT NULL,
268
+ remote_jid TEXT NOT NULL,
269
+ from_me INTEGER NOT NULL DEFAULT 0,
270
+ participant TEXT,
271
+ sender_name TEXT NOT NULL DEFAULT '',
272
+ text TEXT NOT NULL DEFAULT '',
273
+ timestamp INTEGER NOT NULL,
274
+ message_json TEXT,
275
+ PRIMARY KEY (chat_id, msg_id)
276
+ );
277
+ CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_msg ON whatsapp_messages(msg_id);
278
+ CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_wa_id ON whatsapp_messages(wa_id);
279
+ CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_time ON whatsapp_messages(timestamp);`;
259
280
 
260
281
  export const chatSettingsSql = {
261
282
  upsert: `INSERT OR REPLACE INTO chat_settings (chat_id, settings) VALUES (?, ?)`,
@@ -361,6 +382,16 @@ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
361
382
  FROM history_messages
362
383
  WHERE chat_id = ?
363
384
  AND id IN (SELECT rowid FROM history_fts WHERE history_fts MATCH ?)
385
+ ORDER BY id DESC LIMIT ?`,
386
+ searchFtsBetween: `-- searchFts restricted to a timestamp window [after, before) — the
387
+ -- search_history \`after\` / \`before\` date parameters. Either bound may be
388
+ -- the open end of the range (0 / a far-future value).
389
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
390
+ timestamp, media_type, sticker_file_id, file_path
391
+ FROM history_messages
392
+ WHERE chat_id = ?
393
+ AND id IN (SELECT rowid FROM history_fts WHERE history_fts MATCH ?)
394
+ AND timestamp >= ? AND timestamp < ?
364
395
  ORDER BY id DESC LIMIT ?`,
365
396
  bySenderName: `-- The fragment param is LIKE-escaped by the repository (backslash escape).
366
397
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
@@ -548,3 +579,21 @@ WHERE chat_id = ?1
548
579
  LIMIT ?2
549
580
  )`,
550
581
  } as const;
582
+
583
+ export const whatsappMessagesSql = {
584
+ insert: `INSERT OR IGNORE INTO whatsapp_messages
585
+ (chat_id, msg_id, wa_id, remote_jid, from_me, participant, sender_name,
586
+ text, timestamp, message_json)
587
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
588
+ byMsgId: `SELECT chat_id, msg_id, wa_id, remote_jid, from_me, participant, sender_name,
589
+ text, timestamp, message_json
590
+ FROM whatsapp_messages WHERE msg_id = ? LIMIT 1`,
591
+ byWaId: `-- Newest first: a WhatsApp id re-delivered on reconnect maps to the row
592
+ -- that already exists for it.
593
+ SELECT chat_id, msg_id, wa_id, remote_jid, from_me, participant, sender_name,
594
+ text, timestamp, message_json
595
+ FROM whatsapp_messages WHERE wa_id = ? ORDER BY msg_id DESC LIMIT 1`,
596
+ maxMsgId: `SELECT MAX(msg_id) AS max_id FROM whatsapp_messages`,
597
+ deleteOlderThan: `DELETE FROM whatsapp_messages WHERE timestamp < ?`,
598
+ deleteAll: `DELETE FROM whatsapp_messages`,
599
+ } as const;
@@ -0,0 +1,29 @@
1
+ -- Statements for the whatsapp_messages table (see
2
+ -- repositories/whatsapp-messages-repo.ts for parameter order and mapping).
3
+
4
+ -- name: insert
5
+ INSERT OR IGNORE INTO whatsapp_messages
6
+ (chat_id, msg_id, wa_id, remote_jid, from_me, participant, sender_name,
7
+ text, timestamp, message_json)
8
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
9
+
10
+ -- name: byMsgId
11
+ SELECT chat_id, msg_id, wa_id, remote_jid, from_me, participant, sender_name,
12
+ text, timestamp, message_json
13
+ FROM whatsapp_messages WHERE msg_id = ? LIMIT 1
14
+
15
+ -- name: byWaId
16
+ -- Newest first: a WhatsApp id re-delivered on reconnect maps to the row
17
+ -- that already exists for it.
18
+ SELECT chat_id, msg_id, wa_id, remote_jid, from_me, participant, sender_name,
19
+ text, timestamp, message_json
20
+ FROM whatsapp_messages WHERE wa_id = ? ORDER BY msg_id DESC LIMIT 1
21
+
22
+ -- name: maxMsgId
23
+ SELECT MAX(msg_id) AS max_id FROM whatsapp_messages
24
+
25
+ -- name: deleteOlderThan
26
+ DELETE FROM whatsapp_messages WHERE timestamp < ?
27
+
28
+ -- name: deleteAll
29
+ DELETE FROM whatsapp_messages
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Persistent WhatsApp message keys.
3
+ *
4
+ * The WhatsApp frontend hands every message it sees a numeric Talon id
5
+ * and needs the WhatsApp key (and, for forwards and media re-downloads,
6
+ * the whole proto) back later. Its in-memory store is a bounded cache;
7
+ * this table is the record, so a reply, reaction or download addressed
8
+ * at a message from before the last restart still resolves.
9
+ *
10
+ * The proto travels as JSON — the frontend serialises and rehydrates it;
11
+ * storage never imports Baileys.
12
+ */
13
+
14
+ import * as repo from "./repositories/whatsapp-messages-repo.js";
15
+
16
+ export type { WhatsAppMessageRecord } from "./repositories/whatsapp-messages-repo.js";
17
+
18
+ /** How long a message's key (and retained proto) stays retrievable. */
19
+ const WHATSAPP_MESSAGE_RETENTION_MS = 180 * 24 * 60 * 60 * 1000;
20
+
21
+ /** Persist a message; a (chat, msg_id) already stored is left as is. */
22
+ export function saveWhatsAppMessage(record: repo.WhatsAppMessageRecord): void {
23
+ repo.insert(record);
24
+ }
25
+
26
+ export function getWhatsAppMessage(
27
+ msgId: number,
28
+ ): repo.WhatsAppMessageRecord | undefined {
29
+ return repo.byMsgId(msgId);
30
+ }
31
+
32
+ export function getWhatsAppMessageByWaId(
33
+ waId: string,
34
+ ): repo.WhatsAppMessageRecord | undefined {
35
+ return repo.byWaId(waId);
36
+ }
37
+
38
+ /** Highest Talon id ever persisted — the restart seed for the id counter. */
39
+ export function maxWhatsAppMsgId(): number | undefined {
40
+ return repo.maxMsgId();
41
+ }
42
+
43
+ /** Drop rows past the retention window; returns the number removed. */
44
+ export function pruneWhatsAppMessages(now = Date.now()): number {
45
+ return repo.deleteOlderThan(now - WHATSAPP_MESSAGE_RETENTION_MS);
46
+ }
47
+
48
+ /** Test seam: forget every persisted message. */
49
+ export function clearWhatsAppMessages(): void {
50
+ repo.deleteAll();
51
+ }