@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.
@@ -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
  });
@@ -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
+ }