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.
- 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/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/history.ts +22 -1
- package/src/storage/repositories/history-repo.ts +14 -0
- package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
- package/src/storage/sql/history.sql +12 -0
- package/src/storage/sql/schema.sql +21 -0
- package/src/storage/sql/statements.generated.ts +50 -1
- 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/history.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|