@c4t4/heyamigo 0.9.0 → 0.9.1
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/dist/channels/adapter.js +24 -0
- package/dist/channels/baileys.js +158 -0
- package/dist/channels/index.js +16 -0
- package/dist/config.js +4 -0
- package/dist/db/identity-sync.js +147 -0
- package/dist/db/schema.js +45 -1
- package/dist/gateway/outgoing.js +127 -177
- package/dist/index.js +13 -2
- package/dist/queue/outbound-postsend.js +77 -0
- package/dist/queue/outbound.js +185 -0
- package/dist/queue/sender-worker.js +199 -0
- package/migrations/0001_phase1_outbound.sql +23 -0
- package/migrations/meta/0001_snapshot.json +377 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
package/dist/gateway/outgoing.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, statSync } from 'fs';
|
|
2
|
+
import { extname } from 'path';
|
|
2
3
|
import { isJidGroup } from 'baileys';
|
|
3
4
|
import { config } from '../config.js';
|
|
5
|
+
import { formatAddress, jidToAddress } from '../db/address.js';
|
|
4
6
|
import { logger } from '../logger.js';
|
|
5
|
-
import {
|
|
6
|
-
import { detectMediaType
|
|
7
|
-
import { getSocket } from '../wa/socket.js';
|
|
7
|
+
import { enqueueOutbound } from '../queue/outbound.js';
|
|
8
|
+
import { detectMediaType } from '../wa/sender.js';
|
|
8
9
|
// Matches [FILE: path], [IMAGE: path], [VIDEO: path], [AUDIO: path], [DOCUMENT: path]
|
|
9
10
|
const FILE_TAG_RE = /\[(?:FILE|IMAGE|VIDEO|AUDIO|DOCUMENT):\s*([^\]]+)\]/gi;
|
|
10
11
|
function extractFiles(reply) {
|
|
@@ -21,104 +22,140 @@ function extractFiles(reply) {
|
|
|
21
22
|
}).trim();
|
|
22
23
|
return { text, files };
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
function kindForFile(filePath) {
|
|
26
|
+
return detectMediaType(filePath);
|
|
27
|
+
}
|
|
28
|
+
const MIME_MAP = {
|
|
29
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
30
|
+
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
31
|
+
'.mp4': 'video/mp4', '.avi': 'video/avi', '.mov': 'video/quicktime',
|
|
32
|
+
'.mkv': 'video/x-matroska',
|
|
33
|
+
'.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.opus': 'audio/opus',
|
|
34
|
+
'.m4a': 'audio/mp4', '.wav': 'audio/wav',
|
|
35
|
+
'.pdf': 'application/pdf',
|
|
36
|
+
'.doc': 'application/msword',
|
|
37
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
38
|
+
'.xls': 'application/vnd.ms-excel',
|
|
39
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
40
|
+
'.csv': 'text/csv', '.txt': 'text/plain', '.zip': 'application/zip',
|
|
41
|
+
};
|
|
42
|
+
function mimeFor(filePath) {
|
|
43
|
+
return MIME_MAP[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
44
|
+
}
|
|
45
|
+
function fileSize(filePath) {
|
|
46
|
+
try {
|
|
47
|
+
return statSync(filePath).size;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
29
51
|
}
|
|
52
|
+
}
|
|
53
|
+
// `originalMsg` is currently ignored when routing through the outbound
|
|
54
|
+
// queue — Baileys quoting needs the full WAMessage embedded in
|
|
55
|
+
// contextInfo, which we'd have to serialize through the DB row and
|
|
56
|
+
// reconstruct. Known regression for Phase 1; see refactor-scrap.md.
|
|
57
|
+
// Kept in the signature so existing callers don't change.
|
|
58
|
+
export async function handleReply(job, result, _originalMsg) {
|
|
30
59
|
const raw = result.reply?.replaceAll('—', ', ').replaceAll('–', '-');
|
|
31
60
|
if (!raw)
|
|
32
61
|
return;
|
|
33
62
|
const { text, files } = extractFiles(raw);
|
|
34
63
|
const isGroup = isJidGroup(job.jid) === true;
|
|
35
|
-
|
|
64
|
+
void isGroup; // quoting deferred; see comment above
|
|
65
|
+
const address = formatAddress(jidToAddress(job.jid));
|
|
36
66
|
const footer = result.stats && config.reply.showStats
|
|
37
67
|
? formatStatsFooter(result.stats)
|
|
38
68
|
: '';
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
text: caption || `[${mediaType}: ${filePath}]`,
|
|
67
|
-
messageType: `${mediaType}Message`,
|
|
68
|
-
mediaPath: filePath,
|
|
69
|
-
mediaType,
|
|
70
|
-
});
|
|
71
|
-
logger.info({ jid: job.jid, path: filePath, mediaType }, 'file sent');
|
|
72
|
-
// Clean up temp file after sending
|
|
73
|
-
try {
|
|
74
|
-
unlinkSync(filePath);
|
|
75
|
-
}
|
|
76
|
-
catch { }
|
|
77
|
-
if (files.length > 1)
|
|
78
|
-
await sleep(config.reply.chunkDelayMs);
|
|
79
|
-
}
|
|
80
|
-
// Send text (skip if already used as caption on single file)
|
|
81
|
-
const textAlreadySent = files.length === 1 && text && text.length <= 1000 && detectMediaType(files[0]) !== 'audio';
|
|
82
|
-
if (text && !textAlreadySent) {
|
|
83
|
-
const chunks = chunkText(text, config.reply.chunkChars);
|
|
84
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
85
|
-
const chunk = chunks[i];
|
|
86
|
-
const q = i === 0 && files.length === 0 ? quoted : undefined;
|
|
87
|
-
const isLast = i === chunks.length - 1;
|
|
88
|
-
const chunkForSend = isLast && footer ? `${chunk}\n\n${footer}` : chunk;
|
|
89
|
-
await sendText(sock, job.jid, chunkForSend, q);
|
|
90
|
-
await append({
|
|
91
|
-
id: `reply-${Date.now()}-${i}`,
|
|
92
|
-
jid: job.jid,
|
|
93
|
-
direction: 'out',
|
|
94
|
-
fromMe: true,
|
|
95
|
-
sender: sock.user?.id ?? '',
|
|
96
|
-
senderNumber: config.owner.number,
|
|
97
|
-
timestamp: Math.floor(Date.now() / 1000),
|
|
98
|
-
text: chunk,
|
|
99
|
-
messageType: 'conversation',
|
|
100
|
-
});
|
|
101
|
-
if (i < chunks.length - 1)
|
|
102
|
-
await sleep(config.reply.chunkDelayMs);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
if (config.reply.typingIndicator) {
|
|
106
|
-
await sock
|
|
107
|
-
.sendPresenceUpdate('paused', job.jid)
|
|
108
|
-
.catch(() => undefined);
|
|
109
|
-
}
|
|
110
|
-
logger.info({
|
|
111
|
-
jid: job.jid,
|
|
112
|
-
files: files.length,
|
|
113
|
-
chars: text.length,
|
|
114
|
-
}, 'reply sent');
|
|
69
|
+
let pieceIdx = 0;
|
|
70
|
+
const baseKey = `reply-${job.jid}-${Date.now()}`;
|
|
71
|
+
const enqueuePiece = (input) => {
|
|
72
|
+
enqueueOutbound({ ...input, idempotencyKey: `${baseKey}-${pieceIdx++}` });
|
|
73
|
+
};
|
|
74
|
+
// Files first. Caption goes on the single-file-with-short-text case,
|
|
75
|
+
// matching pre-refactor behavior.
|
|
76
|
+
for (let i = 0; i < files.length; i++) {
|
|
77
|
+
const filePath = files[i];
|
|
78
|
+
const isFirst = i === 0;
|
|
79
|
+
const kind = kindForFile(filePath);
|
|
80
|
+
const supportsCaption = kind !== 'audio';
|
|
81
|
+
const caption = isFirst && text && text.length <= 1000 && files.length === 1 && supportsCaption
|
|
82
|
+
? text
|
|
83
|
+
: undefined;
|
|
84
|
+
const willHaveTextAfter = !!text && !(files.length === 1 && text.length <= 1000 && supportsCaption);
|
|
85
|
+
const captionForSend = caption && footer && !willHaveTextAfter
|
|
86
|
+
? `${caption}\n\n${footer}`
|
|
87
|
+
: caption;
|
|
88
|
+
enqueuePiece({
|
|
89
|
+
address,
|
|
90
|
+
kind,
|
|
91
|
+
text: captionForSend,
|
|
92
|
+
mediaPath: filePath,
|
|
93
|
+
mediaMime: mimeFor(filePath),
|
|
94
|
+
mediaBytes: fileSize(filePath),
|
|
95
|
+
});
|
|
115
96
|
}
|
|
116
|
-
|
|
117
|
-
|
|
97
|
+
// Text — skip if already used as a caption on a single file.
|
|
98
|
+
const textAlreadySent = files.length === 1 &&
|
|
99
|
+
text &&
|
|
100
|
+
text.length <= 1000 &&
|
|
101
|
+
kindForFile(files[0]) !== 'audio';
|
|
102
|
+
if (text && !textAlreadySent) {
|
|
103
|
+
const chunks = chunkText(text, config.reply.chunkChars);
|
|
104
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
105
|
+
const chunk = chunks[i];
|
|
106
|
+
const isLast = i === chunks.length - 1;
|
|
107
|
+
const chunkForSend = isLast && footer ? `${chunk}\n\n${footer}` : chunk;
|
|
108
|
+
enqueuePiece({ address, kind: 'text', text: chunkForSend });
|
|
109
|
+
}
|
|
118
110
|
}
|
|
111
|
+
logger.info({ jid: job.jid, files: files.length, chars: text.length, pieces: pieceIdx }, 'reply enqueued for outbound');
|
|
119
112
|
}
|
|
120
|
-
|
|
121
|
-
|
|
113
|
+
// Proactive outbound: send a message to a chat without an incoming
|
|
114
|
+
// trigger. Same parsing as handleReply; enqueues outbound rows.
|
|
115
|
+
// `text` may contain [FILE:...] tags; they're extracted and enqueued
|
|
116
|
+
// as media. Returns true if anything was enqueued.
|
|
117
|
+
export async function initiate(params) {
|
|
118
|
+
const raw = params.text.replaceAll('—', ', ').replaceAll('–', '-');
|
|
119
|
+
if (!raw.trim())
|
|
120
|
+
return false;
|
|
121
|
+
const { text, files } = extractFiles(raw);
|
|
122
|
+
if (!text && files.length === 0)
|
|
123
|
+
return false;
|
|
124
|
+
const address = formatAddress(jidToAddress(params.jid));
|
|
125
|
+
let pieceIdx = 0;
|
|
126
|
+
const baseKey = `initiate-${params.jid}-${Date.now()}`;
|
|
127
|
+
const enqueuePiece = (input) => {
|
|
128
|
+
enqueueOutbound({ ...input, idempotencyKey: `${baseKey}-${pieceIdx++}` });
|
|
129
|
+
};
|
|
130
|
+
for (let i = 0; i < files.length; i++) {
|
|
131
|
+
const filePath = files[i];
|
|
132
|
+
const isFirst = i === 0;
|
|
133
|
+
const kind = kindForFile(filePath);
|
|
134
|
+
const supportsCaption = kind !== 'audio';
|
|
135
|
+
const caption = isFirst && text && text.length <= 1000 && files.length === 1 && supportsCaption
|
|
136
|
+
? text
|
|
137
|
+
: undefined;
|
|
138
|
+
enqueuePiece({
|
|
139
|
+
address,
|
|
140
|
+
kind,
|
|
141
|
+
text: caption,
|
|
142
|
+
mediaPath: filePath,
|
|
143
|
+
mediaMime: mimeFor(filePath),
|
|
144
|
+
mediaBytes: fileSize(filePath),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const textAlreadySent = files.length === 1 &&
|
|
148
|
+
text &&
|
|
149
|
+
text.length <= 1000 &&
|
|
150
|
+
kindForFile(files[0]) !== 'audio';
|
|
151
|
+
if (text && !textAlreadySent) {
|
|
152
|
+
const chunks = chunkText(text, config.reply.chunkChars);
|
|
153
|
+
for (const chunk of chunks) {
|
|
154
|
+
enqueuePiece({ address, kind: 'text', text: chunk });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
logger.info({ jid: params.jid, files: files.length, chars: text.length, pieces: pieceIdx }, 'proactive message enqueued for outbound');
|
|
158
|
+
return pieceIdx > 0;
|
|
122
159
|
}
|
|
123
160
|
// Append-only-at-send footer. Never stored, never in Claude's recent-context
|
|
124
161
|
// feedback loop. Adaptive: shows only what's interesting for this reply.
|
|
@@ -142,16 +179,12 @@ export function formatStatsFooter(stats) {
|
|
|
142
179
|
else if (pct >= 70)
|
|
143
180
|
parts.push(`${pct}% ctx`);
|
|
144
181
|
}
|
|
145
|
-
// Fresh session — resume is default, says nothing
|
|
146
182
|
if (stats.fresh)
|
|
147
183
|
parts.push('fresh');
|
|
148
|
-
// Journal flagged — show each slug (usually 0 or 1)
|
|
149
184
|
for (const slug of stats.journalSlugs)
|
|
150
185
|
parts.push(`+journal:${slug}`);
|
|
151
|
-
// Digest fired
|
|
152
186
|
if (stats.hasDigest)
|
|
153
187
|
parts.push('+digest');
|
|
154
|
-
// Async spawned
|
|
155
188
|
if (stats.asyncCount > 0) {
|
|
156
189
|
parts.push(stats.asyncCount === 1 ? '+async' : `+${stats.asyncCount} async`);
|
|
157
190
|
}
|
|
@@ -164,89 +197,6 @@ function compactTokens(n) {
|
|
|
164
197
|
return `${(n / 1000).toFixed(1)}k`;
|
|
165
198
|
return `${Math.round(n / 1000)}k`;
|
|
166
199
|
}
|
|
167
|
-
// Proactive outbound: send a message to a chat without an incoming trigger.
|
|
168
|
-
// Extracts [FILE:]/[IMAGE:]/etc tags the same way handleReply does — files
|
|
169
|
-
// get sent as WhatsApp media, remaining text sent normally. Chunks, persists
|
|
170
|
-
// to the message log, never throws. Callers are responsible for the
|
|
171
|
-
// canSendProactive() gate — this function does not re-check it.
|
|
172
|
-
export async function initiate(params) {
|
|
173
|
-
const sock = getSocket();
|
|
174
|
-
if (!sock) {
|
|
175
|
-
logger.warn({ jid: params.jid }, 'initiate: no socket available');
|
|
176
|
-
return false;
|
|
177
|
-
}
|
|
178
|
-
const raw = params.text.replaceAll('—', ', ').replaceAll('–', '-');
|
|
179
|
-
if (!raw.trim())
|
|
180
|
-
return false;
|
|
181
|
-
const { text, files } = extractFiles(raw);
|
|
182
|
-
try {
|
|
183
|
-
// Send any files first — images, video, PDFs, audio, etc.
|
|
184
|
-
for (const filePath of files) {
|
|
185
|
-
const isFirst = filePath === files[0];
|
|
186
|
-
const mediaType = detectMediaType(filePath);
|
|
187
|
-
const supportsCaption = mediaType !== 'audio';
|
|
188
|
-
const caption = isFirst &&
|
|
189
|
-
text &&
|
|
190
|
-
text.length <= 1000 &&
|
|
191
|
-
files.length === 1 &&
|
|
192
|
-
supportsCaption
|
|
193
|
-
? text
|
|
194
|
-
: undefined;
|
|
195
|
-
await sendFile(sock, params.jid, filePath, caption);
|
|
196
|
-
await append({
|
|
197
|
-
id: `initiate-file-${Date.now()}`,
|
|
198
|
-
jid: params.jid,
|
|
199
|
-
direction: 'out',
|
|
200
|
-
fromMe: true,
|
|
201
|
-
sender: sock.user?.id ?? '',
|
|
202
|
-
senderNumber: config.owner.number,
|
|
203
|
-
timestamp: Math.floor(Date.now() / 1000),
|
|
204
|
-
text: caption || `[${mediaType}: ${filePath}]`,
|
|
205
|
-
messageType: `${mediaType}Message`,
|
|
206
|
-
mediaPath: filePath,
|
|
207
|
-
mediaType,
|
|
208
|
-
});
|
|
209
|
-
logger.info({ jid: params.jid, path: filePath, mediaType }, 'proactive file sent');
|
|
210
|
-
try {
|
|
211
|
-
unlinkSync(filePath);
|
|
212
|
-
}
|
|
213
|
-
catch { }
|
|
214
|
-
if (files.length > 1)
|
|
215
|
-
await sleep(config.reply.chunkDelayMs);
|
|
216
|
-
}
|
|
217
|
-
// Send text — skip only when it was used as the caption on a single file
|
|
218
|
-
const textAlreadySent = files.length === 1 &&
|
|
219
|
-
text &&
|
|
220
|
-
text.length <= 1000 &&
|
|
221
|
-
detectMediaType(files[0]) !== 'audio';
|
|
222
|
-
if (text && !textAlreadySent) {
|
|
223
|
-
const chunks = chunkText(text, config.reply.chunkChars);
|
|
224
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
225
|
-
const chunk = chunks[i];
|
|
226
|
-
await sendText(sock, params.jid, chunk);
|
|
227
|
-
await append({
|
|
228
|
-
id: `initiate-${Date.now()}-${i}`,
|
|
229
|
-
jid: params.jid,
|
|
230
|
-
direction: 'out',
|
|
231
|
-
fromMe: true,
|
|
232
|
-
sender: sock.user?.id ?? '',
|
|
233
|
-
senderNumber: config.owner.number,
|
|
234
|
-
timestamp: Math.floor(Date.now() / 1000),
|
|
235
|
-
text: chunk,
|
|
236
|
-
messageType: 'conversation',
|
|
237
|
-
});
|
|
238
|
-
if (i < chunks.length - 1)
|
|
239
|
-
await sleep(config.reply.chunkDelayMs);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
logger.info({ jid: params.jid, files: files.length, chars: text.length }, 'proactive message sent');
|
|
243
|
-
return true;
|
|
244
|
-
}
|
|
245
|
-
catch (err) {
|
|
246
|
-
logger.error({ err, jid: params.jid }, 'initiate failed');
|
|
247
|
-
return false;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
200
|
export function chunkText(text, maxChars) {
|
|
251
201
|
if (text.length <= maxChars)
|
|
252
202
|
return [text];
|
package/dist/index.js
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
+
import { setBaileysSocket } from './channels/index.js';
|
|
1
2
|
import { closeDb, initDb } from './db/index.js';
|
|
3
|
+
import { syncIdentitiesFromAccess } from './db/identity-sync.js';
|
|
2
4
|
import { attachIncoming } from './gateway/incoming.js';
|
|
3
5
|
import { handleReply } from './gateway/outgoing.js';
|
|
4
6
|
import { logger } from './logger.js';
|
|
5
7
|
import { startScheduler } from './memory/scheduler.js';
|
|
6
8
|
import { replayPending } from './queue/queue.js';
|
|
9
|
+
import { startSenderWorker, stopSenderWorker } from './queue/sender-worker.js';
|
|
7
10
|
import { startSocket } from './wa/socket.js';
|
|
8
11
|
async function main() {
|
|
9
12
|
logger.info('heyamigo starting');
|
|
10
13
|
// Migrations + drift check first; refuses to start on schema mismatch.
|
|
11
|
-
// Additive — flat-file storage (sessions.json, memory files,
|
|
12
|
-
// access.json) still authoritative until later phases swap them.
|
|
13
14
|
initDb();
|
|
15
|
+
// Derived view: populate persons + identities from access.json.
|
|
16
|
+
syncIdentitiesFromAccess();
|
|
17
|
+
// Sender worker drains outbound queue → channel adapters. Started
|
|
18
|
+
// before the socket so it's ready when handleReply enqueues rows.
|
|
19
|
+
startSenderWorker();
|
|
14
20
|
startScheduler();
|
|
15
21
|
await startSocket((sock) => {
|
|
16
22
|
attachIncoming(sock);
|
|
23
|
+
// Point the Baileys adapter at the live socket. Called on each
|
|
24
|
+
// reconnect with a fresh sock; the adapter just keeps the latest.
|
|
25
|
+
setBaileysSocket(sock);
|
|
17
26
|
});
|
|
18
27
|
// Replay any jobs left from a previous crash (no original WAMessage
|
|
19
28
|
// available, so replies are sent as plain messages, not quoted).
|
|
@@ -23,11 +32,13 @@ async function main() {
|
|
|
23
32
|
}
|
|
24
33
|
process.on('SIGINT', () => {
|
|
25
34
|
logger.info('SIGINT received, shutting down');
|
|
35
|
+
stopSenderWorker();
|
|
26
36
|
closeDb();
|
|
27
37
|
process.exit(0);
|
|
28
38
|
});
|
|
29
39
|
process.on('SIGTERM', () => {
|
|
30
40
|
logger.info('SIGTERM received, shutting down');
|
|
41
|
+
stopSenderWorker();
|
|
31
42
|
closeDb();
|
|
32
43
|
process.exit(0);
|
|
33
44
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Post-send bookkeeping run by the sender worker after a successful
|
|
2
|
+
// channel send. Kept separate from the worker loop so it's easy to
|
|
3
|
+
// extend (e.g. emit metrics, fire webhooks) without bloating the loop.
|
|
4
|
+
//
|
|
5
|
+
// Today's job:
|
|
6
|
+
// - Append a row to the message log so Claude sees what the bot
|
|
7
|
+
// said in future bootstrap context.
|
|
8
|
+
// - Delete the local file if the outbound row pointed at one and the
|
|
9
|
+
// path looks like ephemeral outbox content. We trust producers to
|
|
10
|
+
// only point at files they want unlinked after send.
|
|
11
|
+
import { existsSync, unlinkSync } from 'fs';
|
|
12
|
+
import { isAbsolute, resolve } from 'path';
|
|
13
|
+
import { config } from '../config.js';
|
|
14
|
+
import { addressToExternalId, parseAddress } from '../db/address.js';
|
|
15
|
+
import { logger } from '../logger.js';
|
|
16
|
+
import { append } from '../store/messages.js';
|
|
17
|
+
export async function afterSend(row, sentMsgId) {
|
|
18
|
+
await persistToMessageLog(row, sentMsgId);
|
|
19
|
+
maybeUnlinkMedia(row);
|
|
20
|
+
}
|
|
21
|
+
async function persistToMessageLog(row, msgId) {
|
|
22
|
+
let address;
|
|
23
|
+
try {
|
|
24
|
+
address = parseAddress(row.address);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Message log is currently WA-specific (keyed by JID). Only persist
|
|
30
|
+
// WA-bound replies for now; multi-channel log unification comes in a
|
|
31
|
+
// later phase.
|
|
32
|
+
if (address.channel !== 'wa')
|
|
33
|
+
return;
|
|
34
|
+
const jid = addressToExternalId(address);
|
|
35
|
+
const messageType = row.kind === 'text' ? 'conversation' : `${row.kind}Message`;
|
|
36
|
+
try {
|
|
37
|
+
await append({
|
|
38
|
+
id: `outbound-${row.id}-${msgId}`,
|
|
39
|
+
jid,
|
|
40
|
+
direction: 'out',
|
|
41
|
+
fromMe: true,
|
|
42
|
+
sender: '',
|
|
43
|
+
senderNumber: config.owner.number,
|
|
44
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
45
|
+
text: row.text ?? (row.mediaPath ? `[${row.kind}: ${row.mediaPath}]` : ''),
|
|
46
|
+
messageType,
|
|
47
|
+
mediaPath: row.mediaPath ?? undefined,
|
|
48
|
+
mediaType: row.kind === 'text' ? undefined : row.kind,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
logger.warn({ err, outboundId: row.id }, 'failed to append to message log (send already succeeded)');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function maybeUnlinkMedia(row) {
|
|
56
|
+
if (!row.mediaPath)
|
|
57
|
+
return;
|
|
58
|
+
// Resolve relative paths the same way the sender worker did when
|
|
59
|
+
// calling the adapter, so we delete the actual file.
|
|
60
|
+
const path = isAbsolute(row.mediaPath)
|
|
61
|
+
? row.mediaPath
|
|
62
|
+
: resolve(process.cwd(), row.mediaPath);
|
|
63
|
+
// Only auto-delete files inside known-ephemeral directories. Inbound
|
|
64
|
+
// media in storage/media/ has its own retention cron and should not
|
|
65
|
+
// be touched here. config.storage.mediaDir = inbound media.
|
|
66
|
+
const inboundMediaDir = resolve(process.cwd(), config.storage.mediaDir);
|
|
67
|
+
if (path.startsWith(inboundMediaDir)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
if (existsSync(path))
|
|
72
|
+
unlinkSync(path);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger.warn({ err, path }, 'failed to unlink outbound media file');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Outbound queue helpers. Producers (chat workers, async workers,
|
|
2
|
+
// crons, external triggers) call enqueueOutbound. The sender worker
|
|
3
|
+
// drains via claimNextOutbound + markOutbound{Done,Failed,Retry}.
|
|
4
|
+
//
|
|
5
|
+
// All mutations preserve the claimed_by safety check on completion:
|
|
6
|
+
// only the holder of a claim can mark it done/failed. A slow worker
|
|
7
|
+
// that comes back after TTL-reclaim will harmlessly no-op.
|
|
8
|
+
import { and, asc, eq, isNull, lte, or, sql } from 'drizzle-orm';
|
|
9
|
+
import { getDb } from '../db/index.js';
|
|
10
|
+
import { outbound } from '../db/schema.js';
|
|
11
|
+
// Insert a row, or no-op when the same idempotency_key already exists.
|
|
12
|
+
// Returns the row either way so callers can log/observe.
|
|
13
|
+
export function enqueueOutbound(input) {
|
|
14
|
+
const db = getDb();
|
|
15
|
+
const now = Math.floor(Date.now() / 1000);
|
|
16
|
+
// Idempotency: look up first. SQLite has no INSERT ... ON CONFLICT
|
|
17
|
+
// returning the previous row, so we serve it in two queries inside
|
|
18
|
+
// a transaction.
|
|
19
|
+
if (input.idempotencyKey) {
|
|
20
|
+
const found = db
|
|
21
|
+
.select()
|
|
22
|
+
.from(outbound)
|
|
23
|
+
.where(eq(outbound.idempotencyKey, input.idempotencyKey))
|
|
24
|
+
.get();
|
|
25
|
+
if (found)
|
|
26
|
+
return { inserted: false, row: found };
|
|
27
|
+
}
|
|
28
|
+
const inserted = db
|
|
29
|
+
.insert(outbound)
|
|
30
|
+
.values({
|
|
31
|
+
address: input.address,
|
|
32
|
+
kind: input.kind,
|
|
33
|
+
text: input.text ?? null,
|
|
34
|
+
mediaPath: input.mediaPath ?? null,
|
|
35
|
+
mediaMime: input.mediaMime ?? null,
|
|
36
|
+
mediaBytes: input.mediaBytes ?? null,
|
|
37
|
+
quoteMsgId: input.quoteMsgId ?? null,
|
|
38
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
39
|
+
status: 'pending',
|
|
40
|
+
attempts: 0,
|
|
41
|
+
nextAttemptAt: null,
|
|
42
|
+
lastError: null,
|
|
43
|
+
claimedBy: null,
|
|
44
|
+
claimedAt: null,
|
|
45
|
+
createdAt: now,
|
|
46
|
+
updatedAt: now,
|
|
47
|
+
})
|
|
48
|
+
.returning()
|
|
49
|
+
.get();
|
|
50
|
+
return { inserted: true, row: inserted };
|
|
51
|
+
}
|
|
52
|
+
// Atomic claim. Returns the row or null if nothing's ready.
|
|
53
|
+
// Reserves rows whose nextAttemptAt is null (ready immediately) OR in
|
|
54
|
+
// the past (backoff elapsed). Single-statement so two workers can't
|
|
55
|
+
// claim the same row.
|
|
56
|
+
export function claimNextOutbound(workerId) {
|
|
57
|
+
const db = getDb();
|
|
58
|
+
const now = Math.floor(Date.now() / 1000);
|
|
59
|
+
// SQLite supports UPDATE ... RETURNING since 3.35.
|
|
60
|
+
return db.transaction((tx) => {
|
|
61
|
+
const target = tx
|
|
62
|
+
.select({ id: outbound.id })
|
|
63
|
+
.from(outbound)
|
|
64
|
+
.where(and(eq(outbound.status, 'pending'), or(isNull(outbound.nextAttemptAt), lte(outbound.nextAttemptAt, now))))
|
|
65
|
+
.orderBy(asc(outbound.id))
|
|
66
|
+
.limit(1)
|
|
67
|
+
.get();
|
|
68
|
+
if (!target)
|
|
69
|
+
return null;
|
|
70
|
+
const claimed = tx
|
|
71
|
+
.update(outbound)
|
|
72
|
+
.set({
|
|
73
|
+
status: 'sending',
|
|
74
|
+
claimedBy: workerId,
|
|
75
|
+
claimedAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
})
|
|
78
|
+
.where(and(eq(outbound.id, target.id), eq(outbound.status, 'pending')))
|
|
79
|
+
.returning()
|
|
80
|
+
.get();
|
|
81
|
+
return claimed ?? null;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Mark done — succeeds only when the row is still owned by the caller.
|
|
85
|
+
// Returns whether the update actually applied.
|
|
86
|
+
export function markOutboundDone(id, workerId) {
|
|
87
|
+
const db = getDb();
|
|
88
|
+
const now = Math.floor(Date.now() / 1000);
|
|
89
|
+
const result = db
|
|
90
|
+
.update(outbound)
|
|
91
|
+
.set({ status: 'done', updatedAt: now })
|
|
92
|
+
.where(and(eq(outbound.id, id), eq(outbound.status, 'sending'), eq(outbound.claimedBy, workerId)))
|
|
93
|
+
.returning({ id: outbound.id })
|
|
94
|
+
.all();
|
|
95
|
+
return result.length > 0;
|
|
96
|
+
}
|
|
97
|
+
// Backoff schedule: 1s, 5s, 30s, 2min, give up.
|
|
98
|
+
const BACKOFF_SECONDS = [1, 5, 30, 120];
|
|
99
|
+
const MAX_ATTEMPTS = BACKOFF_SECONDS.length;
|
|
100
|
+
// Transient failure: return to pending with next_attempt_at set, or
|
|
101
|
+
// move to DLQ if attempts exceeded. Caller-owned check applies.
|
|
102
|
+
export function markOutboundRetryOrDlq(id, workerId, errorMessage) {
|
|
103
|
+
const db = getDb();
|
|
104
|
+
return db.transaction((tx) => {
|
|
105
|
+
const row = tx
|
|
106
|
+
.select()
|
|
107
|
+
.from(outbound)
|
|
108
|
+
.where(eq(outbound.id, id))
|
|
109
|
+
.get();
|
|
110
|
+
if (!row || row.status !== 'sending' || row.claimedBy !== workerId) {
|
|
111
|
+
return { retried: false, deadLettered: false };
|
|
112
|
+
}
|
|
113
|
+
const now = Math.floor(Date.now() / 1000);
|
|
114
|
+
const nextAttempts = row.attempts + 1;
|
|
115
|
+
if (nextAttempts > MAX_ATTEMPTS) {
|
|
116
|
+
tx.update(outbound)
|
|
117
|
+
.set({
|
|
118
|
+
status: 'dlq',
|
|
119
|
+
attempts: nextAttempts,
|
|
120
|
+
lastError: errorMessage,
|
|
121
|
+
claimedBy: null,
|
|
122
|
+
claimedAt: null,
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
})
|
|
125
|
+
.where(eq(outbound.id, id))
|
|
126
|
+
.run();
|
|
127
|
+
return { retried: false, deadLettered: true };
|
|
128
|
+
}
|
|
129
|
+
const backoff = BACKOFF_SECONDS[Math.min(row.attempts, BACKOFF_SECONDS.length - 1)];
|
|
130
|
+
tx.update(outbound)
|
|
131
|
+
.set({
|
|
132
|
+
status: 'pending',
|
|
133
|
+
attempts: nextAttempts,
|
|
134
|
+
nextAttemptAt: now + backoff,
|
|
135
|
+
lastError: errorMessage,
|
|
136
|
+
claimedBy: null,
|
|
137
|
+
claimedAt: null,
|
|
138
|
+
updatedAt: now,
|
|
139
|
+
})
|
|
140
|
+
.where(eq(outbound.id, id))
|
|
141
|
+
.run();
|
|
142
|
+
return { retried: true, deadLettered: false };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Permanent failure (no retry). Used when the error is unrecoverable
|
|
146
|
+
// — e.g. media file missing, malformed address.
|
|
147
|
+
export function markOutboundFailed(id, workerId, errorMessage) {
|
|
148
|
+
const db = getDb();
|
|
149
|
+
const now = Math.floor(Date.now() / 1000);
|
|
150
|
+
const result = db
|
|
151
|
+
.update(outbound)
|
|
152
|
+
.set({
|
|
153
|
+
status: 'failed',
|
|
154
|
+
lastError: errorMessage,
|
|
155
|
+
claimedBy: null,
|
|
156
|
+
claimedAt: null,
|
|
157
|
+
updatedAt: now,
|
|
158
|
+
})
|
|
159
|
+
.where(and(eq(outbound.id, id), eq(outbound.status, 'sending'), eq(outbound.claimedBy, workerId)))
|
|
160
|
+
.returning({ id: outbound.id })
|
|
161
|
+
.all();
|
|
162
|
+
return result.length > 0;
|
|
163
|
+
}
|
|
164
|
+
// Orchestrator helper: reclaim rows whose worker died mid-send.
|
|
165
|
+
// "Stuck in sending past TTL" → return to pending so another worker
|
|
166
|
+
// can pick them up. attempts NOT incremented (the worker may have
|
|
167
|
+
// died before even talking to the channel).
|
|
168
|
+
const CLAIM_TTL_SECONDS = 60;
|
|
169
|
+
export function reclaimStuckOutbound() {
|
|
170
|
+
const db = getDb();
|
|
171
|
+
const cutoff = Math.floor(Date.now() / 1000) - CLAIM_TTL_SECONDS;
|
|
172
|
+
const result = db
|
|
173
|
+
.update(outbound)
|
|
174
|
+
.set({
|
|
175
|
+
status: 'pending',
|
|
176
|
+
claimedBy: null,
|
|
177
|
+
claimedAt: null,
|
|
178
|
+
// intentionally leaving updatedAt as-is so observability can spot reclaims
|
|
179
|
+
updatedAt: sql `${outbound.updatedAt}`,
|
|
180
|
+
})
|
|
181
|
+
.where(and(eq(outbound.status, 'sending'), lte(outbound.claimedAt, cutoff)))
|
|
182
|
+
.returning({ id: outbound.id })
|
|
183
|
+
.all();
|
|
184
|
+
return result.length;
|
|
185
|
+
}
|