@c4t4/heyamigo 0.9.0 → 0.9.2

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.
@@ -1,10 +1,11 @@
1
- import { existsSync, unlinkSync } from 'fs';
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 { append } from '../store/messages.js';
6
- import { detectMediaType, sendFile, sendText } from '../wa/sender.js';
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
- export async function handleReply(job, result, originalMsg) {
25
- const sock = getSocket();
26
- if (!sock) {
27
- logger.warn({ jid: job.jid }, 'no socket available to send reply');
28
- return;
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
- const quoted = isGroup && config.reply.quoteInGroups ? originalMsg : undefined;
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
- try {
40
- // Send files first (images, videos, PDFs, audio, etc.)
41
- for (const filePath of files) {
42
- const isFirst = filePath === files[0];
43
- const mediaType = detectMediaType(filePath);
44
- // First file gets caption if text is short + single file + supports captions
45
- const supportsCaption = mediaType !== 'audio';
46
- const caption = isFirst && text && text.length <= 1000 && files.length === 1 && supportsCaption
47
- ? text
48
- : undefined;
49
- // Append footer to caption at send time only (not to storage). Only
50
- // when this media file is the final user-facing payload (no text
51
- // coming after, single file with caption case).
52
- const willHaveTextAfter = !!text &&
53
- !(files.length === 1 && text.length <= 1000 && supportsCaption);
54
- const captionForSend = caption && footer && !willHaveTextAfter
55
- ? `${caption}\n\n${footer}`
56
- : caption;
57
- await sendFile(sock, job.jid, filePath, captionForSend, isFirst ? quoted : undefined);
58
- await append({
59
- id: `reply-file-${Date.now()}`,
60
- jid: job.jid,
61
- direction: 'out',
62
- fromMe: true,
63
- sender: sock.user?.id ?? '',
64
- senderNumber: config.owner.number,
65
- timestamp: Math.floor(Date.now() / 1000),
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
- catch (err) {
117
- logger.error({ err, jid: job.jid }, 'failed to send reply');
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
- function sleep(ms) {
121
- return new Promise((r) => setTimeout(r, ms));
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
  });
@@ -14,6 +14,7 @@ const KINDS = [
14
14
  'JOURNAL-NEW',
15
15
  'ASYNC',
16
16
  'ASYNC-BROWSER',
17
+ 'SEND-TEXT',
17
18
  ];
18
19
  // Walk backwards from the end of the string, tracking bracket depth, to find
19
20
  // the `[` that matches the final `]`. Returns the tag kind, its payload, and
@@ -74,6 +75,7 @@ export function extractFlags(reply) {
74
75
  const journalCreates = [];
75
76
  const asyncTasks = [];
76
77
  const asyncBrowserTasks = [];
78
+ const sendTexts = [];
77
79
  while (true) {
78
80
  const peeled = peelTrailingTag(current);
79
81
  if (!peeled)
@@ -105,6 +107,11 @@ export function extractFlags(reply) {
105
107
  asyncBrowserTasks.unshift({ description: payload });
106
108
  }
107
109
  }
110
+ else if (kind === 'SEND-TEXT') {
111
+ const parsed = parseSendTextPayload(payload);
112
+ if (parsed)
113
+ sendTexts.unshift(parsed);
114
+ }
108
115
  }
109
116
  return {
110
117
  clean: current,
@@ -113,6 +120,7 @@ export function extractFlags(reply) {
113
120
  journalCreates,
114
121
  asyncTasks,
115
122
  asyncBrowserTasks,
123
+ sendTexts,
116
124
  };
117
125
  }
118
126
  // Legacy helper kept so existing callers still compile.
@@ -121,6 +129,26 @@ export function extractDigestFlag(reply) {
121
129
  return { clean: r.clean, flag: r.digest };
122
130
  }
123
131
  const JOURNAL_SEP_RE = /\s*(?:[—\-–]|:)\s*/;
132
+ // Parse `address=<addr> body="..."` style key=value payload.
133
+ // Body is delimited by double quotes; everything else by whitespace.
134
+ // Returns null if address or body is missing.
135
+ function parseSendTextPayload(payload) {
136
+ // Grab body="..." first (longest match so quoted body can contain spaces)
137
+ const bodyMatch = payload.match(/\bbody\s*=\s*"((?:[^"\\]|\\.)*)"/);
138
+ if (!bodyMatch)
139
+ return null;
140
+ const body = bodyMatch[1]
141
+ .replace(/\\"/g, '"')
142
+ .replace(/\\\\/g, '\\');
143
+ if (!body.trim())
144
+ return null;
145
+ // Strip the body=... portion so address parsing doesn't trip on it
146
+ const withoutBody = payload.replace(bodyMatch[0], '').trim();
147
+ const addrMatch = withoutBody.match(/\baddress\s*=\s*([^\s]+)/);
148
+ if (!addrMatch)
149
+ return null;
150
+ return { address: addrMatch[1], body };
151
+ }
124
152
  function parseJournalPayload(payload) {
125
153
  // Split on first em-dash, en-dash, hyphen, or colon between slug and note.
126
154
  const match = payload.match(/^([a-zA-Z0-9][a-zA-Z0-9-]*)(.*)$/);
@@ -124,7 +124,20 @@ async function executeAsyncTask(task) {
124
124
  // as markers; clean pre-marker text is only sent to chat when short (a
125
125
  // failure explanation or tight ack) or when no markers fired at all.
126
126
  const { extractFlags } = await import('../memory/digest-flag.js');
127
- const { clean, digest, journals, journalCreates } = extractFlags(output);
127
+ const { clean, digest, journals, journalCreates, sendTexts } = extractFlags(output);
128
+ // SEND-TEXT: async task wants to text a different chat too.
129
+ if (sendTexts.length > 0) {
130
+ const { enqueueOutbound } = await import('./outbound.js');
131
+ for (let i = 0; i < sendTexts.length; i++) {
132
+ const t = sendTexts[i];
133
+ enqueueOutbound({
134
+ address: t.address,
135
+ kind: 'text',
136
+ text: t.body,
137
+ idempotencyKey: `async-sendtext-${task.id}-${i}`,
138
+ });
139
+ }
140
+ }
128
141
  // Journal creates run first so an entry flagged in the same output against
129
142
  // a new slug lands correctly.
130
143
  const { appendEntry, createJournal, getJournal, isValidSlug } = await import('../memory/journals.js');
@@ -402,7 +415,19 @@ async function runBrowserTask(task) {
402
415
  }
403
416
  // Route markers the same way the general async lane does.
404
417
  const { extractFlags } = await import('../memory/digest-flag.js');
405
- const { clean, digest, journals, journalCreates } = extractFlags(reply);
418
+ const { clean, digest, journals, journalCreates, sendTexts } = extractFlags(reply);
419
+ if (sendTexts.length > 0) {
420
+ const { enqueueOutbound } = await import('./outbound.js');
421
+ for (let i = 0; i < sendTexts.length; i++) {
422
+ const t = sendTexts[i];
423
+ enqueueOutbound({
424
+ address: t.address,
425
+ kind: 'text',
426
+ text: t.body,
427
+ idempotencyKey: `browser-sendtext-${task.id}-${i}`,
428
+ });
429
+ }
430
+ }
406
431
  const { appendEntry, createJournal, getJournal, isValidSlug } = await import('../memory/journals.js');
407
432
  for (const op of journalCreates) {
408
433
  if (!isValidSlug(op.slug))
@@ -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
+ }