@c4t4/heyamigo 0.8.15 → 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.
@@ -0,0 +1,97 @@
1
+ // Schema source of truth. Every DDL change goes here first, then
2
+ // `npx drizzle-kit generate` produces a SQL migration in migrations/.
3
+ // Direct ALTER/CREATE/DROP outside this flow is forbidden — see the
4
+ // "Cardinal rule" section in refactor.md.
5
+ import { sql } from 'drizzle-orm';
6
+ import { sqliteTable, text, integer, primaryKey, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
7
+ // ──────────────────────────────────────────────────────────────────
8
+ // Identity
9
+ // ──────────────────────────────────────────────────────────────────
10
+ // Canonical humans the bot knows about. One row per real person; same
11
+ // person on multiple channels = multiple rows in `identities`, one row
12
+ // here.
13
+ export const persons = sqliteTable('persons', {
14
+ id: text('id').primaryKey(),
15
+ displayName: text('display_name'),
16
+ timezone: text('timezone'),
17
+ createdAt: integer('created_at').notNull(),
18
+ });
19
+ // Channel-addressable identities resolving to a person. Address is
20
+ // channel-prefixed: wa:dm:..., wa:group:..., tg:dm:..., etc.
21
+ export const identities = sqliteTable('identities', {
22
+ personId: text('person_id').notNull().references(() => persons.id),
23
+ address: text('address').notNull().unique(),
24
+ addedAt: integer('added_at').notNull(),
25
+ }, t => ({
26
+ pk: primaryKey({ columns: [t.personId, t.address] }),
27
+ }));
28
+ // ──────────────────────────────────────────────────────────────────
29
+ // Runtime control
30
+ // ──────────────────────────────────────────────────────────────────
31
+ // Worker registry. Every worker (chat, async, browser, sender, memory,
32
+ // orchestrator) inserts a row at startup and updates last_seen as a
33
+ // heartbeat. Orchestrator uses this for liveness detection and
34
+ // graceful shutdown drain (see refactor.md §Workers).
35
+ export const workers = sqliteTable('workers', {
36
+ id: text('id').primaryKey(), // `${hostname}-${pid}-${slot}`
37
+ kind: text('kind').notNull(), // 'chat'|'async'|'browser'|'sender'|'memory'|'orchestrator'
38
+ status: text('status').notNull(), // 'idle'|'busy'|'draining'|'dead'
39
+ currentJob: text('current_job'), // 'queue:id' if busy
40
+ lastSeen: integer('last_seen').notNull(),
41
+ startedAt: integer('started_at').notNull(),
42
+ }, t => ({
43
+ byKindStatus: index('workers_by_kind_status').on(t.kind, t.status),
44
+ byLastSeen: index('workers_by_last_seen').on(t.lastSeen),
45
+ }));
46
+ // Bot-wide control signals. Insert a row to request shutdown/pause/
47
+ // reload; orchestrator picks it up on its next tick. Single-key, so
48
+ // using PK on key gives us upsert semantics.
49
+ export const control = sqliteTable('control', {
50
+ key: text('key').primaryKey(), // 'shutdown'|'pause'|'reload_config'
51
+ value: text('value'),
52
+ requestedBy: text('requested_by'),
53
+ requestedAt: integer('requested_at').notNull(),
54
+ });
55
+ // ──────────────────────────────────────────────────────────────────
56
+ // Outbound queue (Phase 1)
57
+ // ──────────────────────────────────────────────────────────────────
58
+ // Replies waiting to be sent on any channel. Workers (chat / async /
59
+ // browser) insert rows here; the sender worker drains by channel
60
+ // adapter. AI code never touches the WA socket directly.
61
+ //
62
+ // Status lifecycle:
63
+ // pending → sending → done (happy path)
64
+ // sending → pending (transient fail, attempts++, backoff)
65
+ // sending → failed (permanent fail, kept for inspection)
66
+ // sending → dlq (attempts > N; dead-letter)
67
+ //
68
+ // idempotency_key prevents double-sends when a worker is reclaimed
69
+ // after TTL and its delayed insert collides with the replacement
70
+ // worker's insert. Format suggested: `from-inbound-<id>` or
71
+ // `from-async-<task-id>`.
72
+ export const outbound = sqliteTable('outbound', {
73
+ id: integer('id').primaryKey({ autoIncrement: true }),
74
+ address: text('address').notNull(), // 'wa:dm:...' | 'wa:group:...' | etc.
75
+ kind: text('kind').notNull(), // 'text'|'image'|'video'|'audio'|'document'
76
+ text: text('text'), // body or caption
77
+ mediaPath: text('media_path'), // relative to storage/ when set
78
+ mediaMime: text('media_mime'),
79
+ mediaBytes: integer('media_bytes'), // enforced cap
80
+ quoteMsgId: text('quote_msg_id'),
81
+ idempotencyKey: text('idempotency_key'),
82
+ status: text('status').notNull(), // 'pending'|'sending'|'done'|'failed'|'dlq'
83
+ attempts: integer('attempts').notNull().default(0),
84
+ nextAttemptAt: integer('next_attempt_at'), // unix seconds; null = ready immediately
85
+ lastError: text('last_error'),
86
+ claimedBy: text('claimed_by'),
87
+ claimedAt: integer('claimed_at'),
88
+ createdAt: integer('created_at').notNull(),
89
+ updatedAt: integer('updated_at').notNull(),
90
+ }, t => ({
91
+ byStatusNext: index('outbound_by_status_next').on(t.status, t.nextAttemptAt),
92
+ byAddress: index('outbound_by_address').on(t.address),
93
+ // Sparse unique: enforced only when idempotencyKey is non-null.
94
+ uniqIdemp: uniqueIndex('outbound_idempotency_key_uq')
95
+ .on(t.idempotencyKey)
96
+ .where(sql `${t.idempotencyKey} IS NOT NULL`),
97
+ }));
@@ -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,14 +1,28 @@
1
+ import { setBaileysSocket } from './channels/index.js';
2
+ import { closeDb, initDb } from './db/index.js';
3
+ import { syncIdentitiesFromAccess } from './db/identity-sync.js';
1
4
  import { attachIncoming } from './gateway/incoming.js';
2
5
  import { handleReply } from './gateway/outgoing.js';
3
6
  import { logger } from './logger.js';
4
7
  import { startScheduler } from './memory/scheduler.js';
5
8
  import { replayPending } from './queue/queue.js';
9
+ import { startSenderWorker, stopSenderWorker } from './queue/sender-worker.js';
6
10
  import { startSocket } from './wa/socket.js';
7
11
  async function main() {
8
12
  logger.info('heyamigo starting');
13
+ // Migrations + drift check first; refuses to start on schema mismatch.
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();
9
20
  startScheduler();
10
21
  await startSocket((sock) => {
11
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);
12
26
  });
13
27
  // Replay any jobs left from a previous crash (no original WAMessage
14
28
  // available, so replies are sent as plain messages, not quoted).
@@ -18,10 +32,14 @@ async function main() {
18
32
  }
19
33
  process.on('SIGINT', () => {
20
34
  logger.info('SIGINT received, shutting down');
35
+ stopSenderWorker();
36
+ closeDb();
21
37
  process.exit(0);
22
38
  });
23
39
  process.on('SIGTERM', () => {
24
40
  logger.info('SIGTERM received, shutting down');
41
+ stopSenderWorker();
42
+ closeDb();
25
43
  process.exit(0);
26
44
  });
27
45
  main().catch((err) => {
@@ -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
+ }