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.
- package/LICENSE +1 -1
- package/README.md +0 -10
- package/package.json +3 -3
- package/src/backend/codex/auth.ts +40 -1
- package/src/backend/codex/handler/message.ts +16 -1
- package/src/backend/codex/plan-usage.ts +27 -2
- package/src/backend/kilo/handler/message.ts +2 -0
- package/src/backend/kilo/server.ts +1 -0
- package/src/backend/opencode/handler/message.ts +2 -0
- package/src/backend/opencode/server.ts +1 -0
- package/src/backend/remote-server/chat-turn.ts +13 -3
- package/src/backend/remote-server/lifecycle.ts +37 -0
- package/src/backend/remote-server/server-bindings.ts +12 -0
- package/src/backend/remote-server/state.ts +8 -0
- package/src/backend/remote-server/turn.ts +93 -23
- package/src/bootstrap.ts +24 -10
- package/src/core/auth/expiry-monitor.ts +89 -0
- package/src/core/auth/login-flow.ts +247 -0
- package/src/core/auth/status.ts +193 -0
- package/src/core/engine/gateway-actions/history.ts +12 -1
- package/src/core/errors.ts +1 -1
- package/src/core/tools/history.ts +10 -1
- package/src/frontend/native/chat-wire.ts +6 -2
- package/src/frontend/native/emit.ts +30 -11
- package/src/frontend/native/handlers.ts +62 -13
- package/src/frontend/native/history.ts +30 -6
- package/src/frontend/native/media.ts +271 -15
- package/src/frontend/native/protocol.ts +34 -1
- package/src/frontend/native/queue.ts +4 -7
- package/src/frontend/native/routes/chats.ts +30 -12
- package/src/frontend/native/routes/host.ts +17 -5
- package/src/frontend/native/routes/params.ts +21 -0
- package/src/frontend/native/runtime.ts +16 -4
- package/src/frontend/native/server.ts +2 -35
- package/src/frontend/native/status.ts +3 -1
- package/src/frontend/native/turn.ts +39 -17
- package/src/frontend/shared/model-commands.ts +400 -0
- package/src/frontend/telegram/auth-panel.ts +205 -0
- package/src/frontend/telegram/callbacks/auth.ts +70 -0
- package/src/frontend/telegram/callbacks/index.ts +16 -0
- package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
- package/src/frontend/telegram/commands/auth.ts +58 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +10 -4
- package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
- package/src/frontend/whatsapp/actions/history.ts +100 -0
- package/src/frontend/whatsapp/actions/index.ts +4 -1
- package/src/frontend/whatsapp/commands.ts +371 -0
- package/src/frontend/whatsapp/inbound.ts +24 -46
- package/src/frontend/whatsapp/index.ts +12 -1
- package/src/frontend/whatsapp/media-store.ts +20 -1
- package/src/frontend/whatsapp/message-store.ts +101 -24
- package/src/storage/db.ts +7 -1
- package/src/storage/history.ts +26 -2
- package/src/storage/repositories/history-repo.ts +61 -1
- package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
- package/src/storage/sql/db.sql +6 -0
- package/src/storage/sql/history.sql +22 -9
- package/src/storage/sql/schema.sql +27 -1
- package/src/storage/sql/statements.generated.ts +70 -11
- package/src/storage/sql/whatsapp-messages.sql +29 -0
- 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`
|
|
13
|
-
* proto is retained too. The map is
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
50
|
-
* where `INSERT OR IGNORE`
|
|
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
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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 :
|
|
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 =
|
|
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;
|
|
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
|
|
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);
|
package/src/storage/history.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
+
}
|
package/src/storage/sql/db.sql
CHANGED
|
@@ -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
|
-
|
|
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);
|