talon-agent 3.34.1 → 3.36.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 (63) 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/native/chat-wire.ts +6 -2
  24. package/src/frontend/native/emit.ts +30 -11
  25. package/src/frontend/native/handlers.ts +62 -13
  26. package/src/frontend/native/history.ts +30 -6
  27. package/src/frontend/native/media.ts +271 -15
  28. package/src/frontend/native/protocol.ts +34 -1
  29. package/src/frontend/native/queue.ts +4 -7
  30. package/src/frontend/native/routes/chats.ts +30 -12
  31. package/src/frontend/native/routes/host.ts +17 -5
  32. package/src/frontend/native/routes/params.ts +21 -0
  33. package/src/frontend/native/runtime.ts +16 -4
  34. package/src/frontend/native/server.ts +2 -35
  35. package/src/frontend/native/status.ts +3 -1
  36. package/src/frontend/native/turn.ts +39 -17
  37. package/src/frontend/shared/model-commands.ts +400 -0
  38. package/src/frontend/telegram/auth-panel.ts +205 -0
  39. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  40. package/src/frontend/telegram/callbacks/index.ts +16 -0
  41. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  42. package/src/frontend/telegram/commands/auth.ts +58 -0
  43. package/src/frontend/telegram/commands/definitions.ts +4 -0
  44. package/src/frontend/telegram/commands/index.ts +10 -4
  45. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  46. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  47. package/src/frontend/whatsapp/actions/history.ts +100 -0
  48. package/src/frontend/whatsapp/actions/index.ts +4 -1
  49. package/src/frontend/whatsapp/commands.ts +371 -0
  50. package/src/frontend/whatsapp/inbound.ts +24 -46
  51. package/src/frontend/whatsapp/index.ts +12 -1
  52. package/src/frontend/whatsapp/media-store.ts +20 -1
  53. package/src/frontend/whatsapp/message-store.ts +101 -24
  54. package/src/storage/db.ts +7 -1
  55. package/src/storage/history.ts +26 -2
  56. package/src/storage/repositories/history-repo.ts +61 -1
  57. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  58. package/src/storage/sql/db.sql +6 -0
  59. package/src/storage/sql/history.sql +22 -9
  60. package/src/storage/sql/schema.sql +27 -1
  61. package/src/storage/sql/statements.generated.ts +70 -11
  62. package/src/storage/sql/whatsapp-messages.sql +29 -0
  63. 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
  }
package/src/storage/db.ts CHANGED
@@ -68,7 +68,8 @@ let db: SqlDatabase | null = null;
68
68
  *
69
69
  * Column reconciliation runs first: `ALTER TABLE … ADD COLUMN` has no
70
70
  * IF NOT EXISTS form, so columns added to already-shipped tables
71
- * (media_index.content_hash, sessions.metrics) are ensured by attempting the ALTER and
71
+ * (media_index.content_hash, sessions.metrics,
72
+ * history_messages.attachments) are ensured by attempting the ALTER and
72
73
  * swallowing the two expected failures — "duplicate column name"
73
74
  * (column already there) and "no such table" (fresh database; the
74
75
  * CREATE TABLE in schema.sql includes the column).
@@ -94,6 +95,11 @@ function ensureSchema(database: SqlDatabase): void {
94
95
  } catch {
95
96
  /* duplicate column or no such table — both mean nothing to do */
96
97
  }
98
+ try {
99
+ database.exec(dbSql.addHistoryAttachmentsColumn);
100
+ } catch {
101
+ /* duplicate column or no such table — both mean nothing to do */
102
+ }
97
103
  database.exec("BEGIN");
98
104
  try {
99
105
  database.exec(SCHEMA);
@@ -26,7 +26,10 @@ import { formatSmartTimestamp, formatRelativeAge } from "../util/time.js";
26
26
  import { importLegacyJson } from "./legacy-import.js";
27
27
  import * as repo from "./repositories/history-repo.js";
28
28
 
29
- export type { HistoryMessage } from "./repositories/history-repo.js";
29
+ export type {
30
+ HistoryMessage,
31
+ MessageAttachment,
32
+ } from "./repositories/history-repo.js";
30
33
  import type { HistoryMessage } from "./repositories/history-repo.js";
31
34
 
32
35
  // ── Persistence lifecycle ───────────────────────────────────────────────────
@@ -199,17 +202,30 @@ function chatIsEmpty(chatId: string): boolean {
199
202
  return repo.latestMsgId(chatId) === undefined;
200
203
  }
201
204
 
205
+ /** Optional time window for `searchHistory`, in epoch ms. */
206
+ export type HistoryDateRange = { after?: number; before?: number };
207
+
202
208
  export function searchHistory(
203
209
  chatId: string,
204
210
  query: string,
205
211
  limit = 20,
212
+ range: HistoryDateRange = {},
206
213
  ): string {
207
214
  if (chatIsEmpty(chatId)) return "No messages in history.";
208
215
  const match = ftsQuery(query);
209
216
  if (!match) return `No messages matching "${query}".`;
210
217
  let messages: HistoryMessage[];
211
218
  try {
212
- messages = repo.searchFts(chatId, match, limit);
219
+ messages =
220
+ range.after === undefined && range.before === undefined
221
+ ? repo.searchFts(chatId, match, limit)
222
+ : repo.searchFtsBetween(
223
+ chatId,
224
+ match,
225
+ range.after ?? 0,
226
+ range.before ?? Number.MAX_SAFE_INTEGER,
227
+ limit,
228
+ );
213
229
  } catch (err) {
214
230
  logError("history", `FTS search failed for ${JSON.stringify(query)}`, err);
215
231
  return `No messages matching "${query}".`;
@@ -229,6 +245,14 @@ export function getMessagesByUser(
229
245
  return messages.map(formatMessage).join("\n");
230
246
  }
231
247
 
248
+ /** The stored row for one message, or undefined. */
249
+ export function getHistoryMessage(
250
+ chatId: string,
251
+ msgId: number,
252
+ ): HistoryMessage | undefined {
253
+ return repo.byMsgId(chatId, msgId);
254
+ }
255
+
232
256
  export function getMessageById(chatId: string, msgId: number): string {
233
257
  if (chatIsEmpty(chatId)) return "No messages in history.";
234
258
  const msg = repo.byMsgId(chatId, msgId);
@@ -9,6 +9,24 @@ import { escapeLike } from "../../native/sqlguard.js";
9
9
  import { getDatabase, inTransaction } from "../db.js";
10
10
  import { historySql } from "../sql/statements.generated.js";
11
11
  /** One chat message as the domain sees it — the shape the rows map to. */
12
+ /**
13
+ * A file attached to a message, persisted with it. The bridge's own
14
+ * `ClientAttachment` is this plus a per-run `/media` URL — storage keeps only
15
+ * what survives a restart, and the URL is minted fresh on read.
16
+ */
17
+ export type MessageAttachment = {
18
+ /** Absolute path on this host. */
19
+ path: string;
20
+ /** Original file name, for display. */
21
+ name: string;
22
+ /** Size in bytes. */
23
+ size: number;
24
+ /** Best-effort MIME type. */
25
+ mimeType: string;
26
+ /** True when clients render it inline as an image. */
27
+ image: boolean;
28
+ };
29
+
12
30
  export type HistoryMessage = {
13
31
  msgId: number;
14
32
  senderId: number;
@@ -25,8 +43,11 @@ export type HistoryMessage = {
25
43
  mediaType?:
26
44
  "photo" | "document" | "voice" | "sticker" | "video" | "animation";
27
45
  stickerFileId?: string;
28
- /** Saved file path for downloaded media. */
46
+ /** Saved file path for downloaded media (the first attachment's, when
47
+ * a message carries several). */
29
48
  filePath?: string;
49
+ /** Every file attached to this message, oldest row shape holds none. */
50
+ attachments?: MessageAttachment[];
30
51
  };
31
52
 
32
53
  type Row = {
@@ -40,8 +61,31 @@ type Row = {
40
61
  media_type: string | null;
41
62
  sticker_file_id: string | null;
42
63
  file_path: string | null;
64
+ attachments: string | null;
43
65
  };
44
66
 
67
+ /**
68
+ * Parse the persisted attachments column. Anything unreadable (hand-edited
69
+ * row, a shape from a future version) degrades to "no attachments" rather
70
+ * than failing the whole history read.
71
+ */
72
+ function parseAttachments(raw: string | null): MessageAttachment[] | undefined {
73
+ if (!raw) return undefined;
74
+ try {
75
+ const parsed: unknown = JSON.parse(raw);
76
+ if (!Array.isArray(parsed)) return undefined;
77
+ const rows = parsed.filter(
78
+ (a): a is MessageAttachment =>
79
+ typeof a === "object" &&
80
+ a !== null &&
81
+ typeof (a as MessageAttachment).path === "string",
82
+ );
83
+ return rows.length ? rows : undefined;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
45
89
  function rowToMessage(row: Row): HistoryMessage {
46
90
  return {
47
91
  msgId: row.msg_id,
@@ -54,6 +98,7 @@ function rowToMessage(row: Row): HistoryMessage {
54
98
  mediaType: (row.media_type ?? undefined) as HistoryMessage["mediaType"],
55
99
  stickerFileId: row.sticker_file_id ?? undefined,
56
100
  filePath: row.file_path ?? undefined,
101
+ attachments: parseAttachments(row.attachments),
57
102
  };
58
103
  }
59
104
 
@@ -72,6 +117,7 @@ export function insert(chatId: string, msg: HistoryMessage): void {
72
117
  msg.mediaType ?? null,
73
118
  msg.stickerFileId ?? null,
74
119
  msg.filePath ?? null,
120
+ msg.attachments?.length ? JSON.stringify(msg.attachments) : null,
75
121
  );
76
122
  }
77
123
 
@@ -147,6 +193,20 @@ export function searchFts(
147
193
  return rows.reverse().map(rowToMessage);
148
194
  }
149
195
 
196
+ /** `searchFts` restricted to messages with `after <= timestamp < before`. */
197
+ export function searchFtsBetween(
198
+ chatId: string,
199
+ match: string,
200
+ after: number,
201
+ before: number,
202
+ limit: number,
203
+ ): HistoryMessage[] {
204
+ const rows = getDatabase()
205
+ .prepare(historySql.searchFtsBetween)
206
+ .all(chatId, match, after, before, limit) as Row[];
207
+ return rows.reverse().map(rowToMessage);
208
+ }
209
+
150
210
  export function bySenderName(
151
211
  chatId: string,
152
212
  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
+ }
@@ -19,3 +19,9 @@ ALTER TABLE history_messages ADD COLUMN sender_handle TEXT
19
19
  -- Column reconciliation for databases that shipped before per-session
20
20
  -- metrics existed. Fresh databases get the column via schema.sql.
21
21
  ALTER TABLE sessions ADD COLUMN metrics TEXT NOT NULL DEFAULT '{"lifetime":{"counters":{"queries":0,"toolCalls":0,"turnsWithTools":0,"apiCalls":0,"inputTokens":0,"outputTokens":0,"cacheReadTokens":0,"cacheWriteTokens":0,"failedTurns":0,"flowViolationRetries":0,"flowViolationCapExhausted":0,"trailingTextDropped":0},"latency":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsByName":{},"backend":{},"cacheHitPercent":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"apiCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0}},"buckets":{}}'
22
+
23
+ -- name: addHistoryAttachmentsColumn
24
+ -- Column reconciliation for databases that shipped before a message could
25
+ -- carry more than one attachment. Fresh databases get the column via
26
+ -- schema.sql.
27
+ ALTER TABLE history_messages ADD COLUMN attachments TEXT
@@ -4,12 +4,13 @@
4
4
  -- name: insert
5
5
  INSERT OR IGNORE INTO history_messages
6
6
  (chat_id, msg_id, sender_id, sender_name, sender_handle, text,
7
- reply_to_msg_id, timestamp, media_type, sticker_file_id, file_path)
8
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
7
+ reply_to_msg_id, timestamp, media_type, sticker_file_id, file_path,
8
+ attachments)
9
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
9
10
 
10
11
  -- name: recent
11
12
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
12
- timestamp, media_type, sticker_file_id, file_path
13
+ timestamp, media_type, sticker_file_id, file_path, attachments
13
14
  FROM history_messages
14
15
  WHERE chat_id = ? ORDER BY id DESC LIMIT ?
15
16
 
@@ -17,7 +18,7 @@ WHERE chat_id = ? ORDER BY id DESC LIMIT ?
17
18
  -- Scroll-back pagination: the window of messages strictly older than a
18
19
  -- given msg_id, newest-first (the repository reverses to chronological).
19
20
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
20
- timestamp, media_type, sticker_file_id, file_path
21
+ timestamp, media_type, sticker_file_id, file_path, attachments
21
22
  FROM history_messages
22
23
  WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?
23
24
 
@@ -25,7 +26,7 @@ WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?
25
26
  -- Time-cursor variant of recentBefore for the read_history `before` date
26
27
  -- parameter: the newest `limit` messages strictly older than a timestamp.
27
28
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
28
- timestamp, media_type, sticker_file_id, file_path
29
+ timestamp, media_type, sticker_file_id, file_path, attachments
29
30
  FROM history_messages
30
31
  WHERE chat_id = ? AND timestamp < ? ORDER BY id DESC LIMIT ?
31
32
 
@@ -39,29 +40,41 @@ DELETE FROM history_messages WHERE chat_id = ?
39
40
  -- The match param must already be a valid FTS5 expression
40
41
  -- (see history.ts ftsQuery).
41
42
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
42
- timestamp, media_type, sticker_file_id, file_path
43
+ timestamp, media_type, sticker_file_id, file_path, attachments
43
44
  FROM history_messages
44
45
  WHERE chat_id = ?
45
46
  AND id IN (SELECT rowid FROM history_fts WHERE history_fts MATCH ?)
46
47
  ORDER BY id DESC LIMIT ?
47
48
 
49
+ -- name: searchFtsBetween
50
+ -- searchFts restricted to a timestamp window [after, before) — the
51
+ -- search_history `after` / `before` date parameters. Either bound may be
52
+ -- the open end of the range (0 / a far-future value).
53
+ SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
54
+ timestamp, media_type, sticker_file_id, file_path, attachments
55
+ FROM history_messages
56
+ WHERE chat_id = ?
57
+ AND id IN (SELECT rowid FROM history_fts WHERE history_fts MATCH ?)
58
+ AND timestamp >= ? AND timestamp < ?
59
+ ORDER BY id DESC LIMIT ?
60
+
48
61
  -- name: bySenderName
49
62
  -- The fragment param is LIKE-escaped by the repository (backslash escape).
50
63
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
51
- timestamp, media_type, sticker_file_id, file_path
64
+ timestamp, media_type, sticker_file_id, file_path, attachments
52
65
  FROM history_messages
53
66
  WHERE chat_id = ? AND lower(sender_name) LIKE ? ESCAPE '\'
54
67
  ORDER BY id DESC LIMIT ?
55
68
 
56
69
  -- name: byMsgId
57
70
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
58
- timestamp, media_type, sticker_file_id, file_path
71
+ timestamp, media_type, sticker_file_id, file_path, attachments
59
72
  FROM history_messages
60
73
  WHERE chat_id = ? AND msg_id = ? ORDER BY id DESC LIMIT 1
61
74
 
62
75
  -- name: bySenderId
63
76
  SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
64
- timestamp, media_type, sticker_file_id, file_path
77
+ timestamp, media_type, sticker_file_id, file_path, attachments
65
78
  FROM history_messages
66
79
  WHERE chat_id = ? AND sender_id = ? ORDER BY id DESC LIMIT ?
67
80
 
@@ -20,7 +20,12 @@ CREATE TABLE IF NOT EXISTS history_messages (
20
20
  timestamp INTEGER NOT NULL,
21
21
  media_type TEXT,
22
22
  sticker_file_id TEXT,
23
- file_path TEXT
23
+ file_path TEXT,
24
+ -- Files attached to this message, as a JSON array of
25
+ -- {path,name,size,mimeType,image}. A message can carry several (the
26
+ -- companion's composer stages any number), so file_path above holds
27
+ -- only the first for the pre-multi-file row shape. Null when none.
28
+ attachments TEXT
24
29
  );
25
30
  CREATE INDEX IF NOT EXISTS idx_history_chat ON history_messages(chat_id, id);
26
31
  CREATE UNIQUE INDEX IF NOT EXISTS idx_history_chat_msg ON history_messages(chat_id, msg_id);
@@ -251,3 +256,24 @@ CREATE TABLE IF NOT EXISTS journal (
251
256
  payload TEXT NOT NULL
252
257
  );
253
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);