@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.
@@ -0,0 +1,199 @@
1
+ // Sender worker. Drains the outbound queue and pushes each row to the
2
+ // matching channel adapter. One per process (no concurrency) so
3
+ // per-address ordering is preserved naturally and rate-limiting lives
4
+ // in one place.
5
+ import { hostname } from 'os';
6
+ import { resolve } from 'path';
7
+ import { eq } from 'drizzle-orm';
8
+ import { config } from '../config.js';
9
+ import { getDb } from '../db/index.js';
10
+ import { parseAddress } from '../db/address.js';
11
+ import { workers } from '../db/schema.js';
12
+ import { getChannelAdapter, PermanentChannelError, TransientChannelError, } from '../channels/index.js';
13
+ import { logger } from '../logger.js';
14
+ import { claimNextOutbound, markOutboundDone, markOutboundFailed, markOutboundRetryOrDlq, } from './outbound.js';
15
+ import { afterSend } from './outbound-postsend.js';
16
+ const HEARTBEAT_INTERVAL_MS = 5_000;
17
+ const IDLE_POLL_INTERVAL_MS = 500; // when queue empty
18
+ const BUSY_POLL_INTERVAL_MS = 50; // immediately fetch next after a successful send
19
+ let workerId = null;
20
+ let stopping = false;
21
+ let heartbeatTimer = null;
22
+ function newWorkerId() {
23
+ return `${hostname()}-${process.pid}-sender-0`;
24
+ }
25
+ function registerWorker(id) {
26
+ const db = getDb();
27
+ const now = Math.floor(Date.now() / 1000);
28
+ db.insert(workers)
29
+ .values({
30
+ id,
31
+ kind: 'sender',
32
+ status: 'idle',
33
+ currentJob: null,
34
+ lastSeen: now,
35
+ startedAt: now,
36
+ })
37
+ .onConflictDoUpdate({
38
+ target: workers.id,
39
+ set: { status: 'idle', currentJob: null, lastSeen: now, startedAt: now },
40
+ })
41
+ .run();
42
+ }
43
+ function setWorkerStatus(id, status, currentJob = null) {
44
+ const db = getDb();
45
+ db.update(workers)
46
+ .set({
47
+ status,
48
+ currentJob,
49
+ lastSeen: Math.floor(Date.now() / 1000),
50
+ })
51
+ .where(eq(workers.id, id))
52
+ .run();
53
+ }
54
+ function heartbeat(id) {
55
+ const db = getDb();
56
+ db.update(workers)
57
+ .set({ lastSeen: Math.floor(Date.now() / 1000) })
58
+ .where(eq(workers.id, id))
59
+ .run();
60
+ }
61
+ // Translate an outbound row into the channel-agnostic message shape.
62
+ // Media paths are stored relative to the project root in the row;
63
+ // resolved to absolute here so the adapter can readFileSync directly.
64
+ function rowToMessage(row) {
65
+ return {
66
+ kind: row.kind,
67
+ text: row.text ?? undefined,
68
+ mediaPath: row.mediaPath ? resolve(process.cwd(), row.mediaPath) : undefined,
69
+ mediaMime: row.mediaMime ?? undefined,
70
+ quoteMsgId: row.quoteMsgId ?? undefined,
71
+ };
72
+ }
73
+ // Enforce media-size cap. mediaBytes is stored on the row by the
74
+ // producer; if missing, we trust the channel to enforce its own limit.
75
+ function tooLarge(row) {
76
+ const cap = config.reply.maxOutboundMediaBytes ?? null;
77
+ if (cap === null)
78
+ return null;
79
+ if (row.mediaBytes !== null && row.mediaBytes > cap) {
80
+ return `media too large: ${row.mediaBytes} > ${cap} bytes`;
81
+ }
82
+ return null;
83
+ }
84
+ async function processOne(id, row) {
85
+ setWorkerStatus(id, 'busy', `outbound:${row.id}`);
86
+ // Size cap → permanent fail; no point retrying same payload.
87
+ const sizeError = tooLarge(row);
88
+ if (sizeError) {
89
+ markOutboundFailed(row.id, id, sizeError);
90
+ logger.warn({ id: row.id, address: row.address }, sizeError);
91
+ setWorkerStatus(id, 'idle');
92
+ return;
93
+ }
94
+ let address;
95
+ try {
96
+ address = parseAddress(row.address);
97
+ }
98
+ catch (err) {
99
+ markOutboundFailed(row.id, id, `bad address: ${row.address}`);
100
+ logger.warn({ id: row.id, err }, 'outbound row has unparseable address');
101
+ setWorkerStatus(id, 'idle');
102
+ return;
103
+ }
104
+ // system:* addresses are bot-internal; not real channels. Drop them
105
+ // with a friendly log so future cron-emitted system messages don't
106
+ // accidentally try to "send" anywhere.
107
+ if (address.channel === 'system') {
108
+ markOutboundFailed(row.id, id, 'system addresses are not sendable');
109
+ logger.warn({ id: row.id, address: row.address }, 'system address routed to sender');
110
+ setWorkerStatus(id, 'idle');
111
+ return;
112
+ }
113
+ let adapter;
114
+ try {
115
+ adapter = getChannelAdapter(address.channel);
116
+ }
117
+ catch (err) {
118
+ markOutboundFailed(row.id, id, err.message);
119
+ logger.error({ id: row.id, channel: address.channel }, 'no adapter for channel');
120
+ setWorkerStatus(id, 'idle');
121
+ return;
122
+ }
123
+ try {
124
+ const result = await adapter.send(address.externalId, rowToMessage(row));
125
+ const ok = markOutboundDone(row.id, id);
126
+ if (!ok) {
127
+ // Lost the claim — orchestrator reclaimed it as stuck, or
128
+ // status changed under us. The send already happened though
129
+ // (channel returned a msg_id), so we just log and move on. The
130
+ // reclaimed copy may re-send → that's why idempotency_key
131
+ // matters at the producer side.
132
+ logger.warn({ id: row.id, msgId: result.msgId }, 'outbound sent but markDone failed (claim lost?)');
133
+ }
134
+ else {
135
+ await afterSend(row, result.msgId).catch((err) => {
136
+ logger.error({ err, id: row.id }, 'afterSend hook failed');
137
+ });
138
+ logger.info({ id: row.id, address: row.address, kind: row.kind, msgId: result.msgId }, 'outbound sent');
139
+ }
140
+ }
141
+ catch (err) {
142
+ if (err instanceof PermanentChannelError) {
143
+ markOutboundFailed(row.id, id, err.message);
144
+ logger.error({ id: row.id, err: err.message }, 'outbound permanent failure');
145
+ }
146
+ else if (err instanceof TransientChannelError) {
147
+ const result = markOutboundRetryOrDlq(row.id, id, err.message);
148
+ if (result.deadLettered) {
149
+ logger.error({ id: row.id }, 'outbound dead-lettered after max attempts');
150
+ }
151
+ else if (result.retried) {
152
+ logger.warn({ id: row.id, err: err.message }, 'outbound transient fail, will retry');
153
+ }
154
+ }
155
+ else {
156
+ // Unexpected throw — treat as transient (safer to retry than to
157
+ // give up on something we don't understand).
158
+ const result = markOutboundRetryOrDlq(row.id, id, `unexpected error: ${err.message}`);
159
+ logger.error({ id: row.id, err, retried: result.retried, dlq: result.deadLettered }, 'outbound unexpected error');
160
+ }
161
+ }
162
+ finally {
163
+ setWorkerStatus(id, 'idle');
164
+ }
165
+ }
166
+ async function loop(id) {
167
+ while (!stopping) {
168
+ let processed = false;
169
+ try {
170
+ const row = claimNextOutbound(id);
171
+ if (row) {
172
+ await processOne(id, row);
173
+ processed = true;
174
+ }
175
+ }
176
+ catch (err) {
177
+ logger.error({ err }, 'sender worker loop error');
178
+ }
179
+ const delay = processed ? BUSY_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS;
180
+ await new Promise((res) => setTimeout(res, delay));
181
+ }
182
+ setWorkerStatus(id, 'dead');
183
+ }
184
+ export function startSenderWorker() {
185
+ if (workerId) {
186
+ logger.warn('sender worker already started; ignoring');
187
+ return;
188
+ }
189
+ workerId = newWorkerId();
190
+ registerWorker(workerId);
191
+ heartbeatTimer = setInterval(() => workerId && heartbeat(workerId), HEARTBEAT_INTERVAL_MS);
192
+ void loop(workerId).catch((err) => logger.fatal({ err }, 'sender worker loop crashed'));
193
+ logger.info({ workerId }, 'sender worker started');
194
+ }
195
+ export function stopSenderWorker() {
196
+ stopping = true;
197
+ if (heartbeatTimer)
198
+ clearInterval(heartbeatTimer);
199
+ }
@@ -0,0 +1,23 @@
1
+ CREATE TABLE `outbound` (
2
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `address` text NOT NULL,
4
+ `kind` text NOT NULL,
5
+ `text` text,
6
+ `media_path` text,
7
+ `media_mime` text,
8
+ `media_bytes` integer,
9
+ `quote_msg_id` text,
10
+ `idempotency_key` text,
11
+ `status` text NOT NULL,
12
+ `attempts` integer DEFAULT 0 NOT NULL,
13
+ `next_attempt_at` integer,
14
+ `last_error` text,
15
+ `claimed_by` text,
16
+ `claimed_at` integer,
17
+ `created_at` integer NOT NULL,
18
+ `updated_at` integer NOT NULL
19
+ );
20
+ --> statement-breakpoint
21
+ CREATE INDEX `outbound_by_status_next` ON `outbound` (`status`,`next_attempt_at`);--> statement-breakpoint
22
+ CREATE INDEX `outbound_by_address` ON `outbound` (`address`);--> statement-breakpoint
23
+ CREATE UNIQUE INDEX `outbound_idempotency_key_uq` ON `outbound` (`idempotency_key`) WHERE "outbound"."idempotency_key" IS NOT NULL;
@@ -0,0 +1,377 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "56b9231e-919c-4b8d-9a77-68735c33cbb0",
5
+ "prevId": "16f29db1-6a21-41fb-b7e2-933ea7384dad",
6
+ "tables": {
7
+ "control": {
8
+ "name": "control",
9
+ "columns": {
10
+ "key": {
11
+ "name": "key",
12
+ "type": "text",
13
+ "primaryKey": true,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "value": {
18
+ "name": "value",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": false,
22
+ "autoincrement": false
23
+ },
24
+ "requested_by": {
25
+ "name": "requested_by",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": false,
29
+ "autoincrement": false
30
+ },
31
+ "requested_at": {
32
+ "name": "requested_at",
33
+ "type": "integer",
34
+ "primaryKey": false,
35
+ "notNull": true,
36
+ "autoincrement": false
37
+ }
38
+ },
39
+ "indexes": {},
40
+ "foreignKeys": {},
41
+ "compositePrimaryKeys": {},
42
+ "uniqueConstraints": {},
43
+ "checkConstraints": {}
44
+ },
45
+ "identities": {
46
+ "name": "identities",
47
+ "columns": {
48
+ "person_id": {
49
+ "name": "person_id",
50
+ "type": "text",
51
+ "primaryKey": false,
52
+ "notNull": true,
53
+ "autoincrement": false
54
+ },
55
+ "address": {
56
+ "name": "address",
57
+ "type": "text",
58
+ "primaryKey": false,
59
+ "notNull": true,
60
+ "autoincrement": false
61
+ },
62
+ "added_at": {
63
+ "name": "added_at",
64
+ "type": "integer",
65
+ "primaryKey": false,
66
+ "notNull": true,
67
+ "autoincrement": false
68
+ }
69
+ },
70
+ "indexes": {
71
+ "identities_address_unique": {
72
+ "name": "identities_address_unique",
73
+ "columns": [
74
+ "address"
75
+ ],
76
+ "isUnique": true
77
+ }
78
+ },
79
+ "foreignKeys": {
80
+ "identities_person_id_persons_id_fk": {
81
+ "name": "identities_person_id_persons_id_fk",
82
+ "tableFrom": "identities",
83
+ "tableTo": "persons",
84
+ "columnsFrom": [
85
+ "person_id"
86
+ ],
87
+ "columnsTo": [
88
+ "id"
89
+ ],
90
+ "onDelete": "no action",
91
+ "onUpdate": "no action"
92
+ }
93
+ },
94
+ "compositePrimaryKeys": {
95
+ "identities_person_id_address_pk": {
96
+ "columns": [
97
+ "person_id",
98
+ "address"
99
+ ],
100
+ "name": "identities_person_id_address_pk"
101
+ }
102
+ },
103
+ "uniqueConstraints": {},
104
+ "checkConstraints": {}
105
+ },
106
+ "outbound": {
107
+ "name": "outbound",
108
+ "columns": {
109
+ "id": {
110
+ "name": "id",
111
+ "type": "integer",
112
+ "primaryKey": true,
113
+ "notNull": true,
114
+ "autoincrement": true
115
+ },
116
+ "address": {
117
+ "name": "address",
118
+ "type": "text",
119
+ "primaryKey": false,
120
+ "notNull": true,
121
+ "autoincrement": false
122
+ },
123
+ "kind": {
124
+ "name": "kind",
125
+ "type": "text",
126
+ "primaryKey": false,
127
+ "notNull": true,
128
+ "autoincrement": false
129
+ },
130
+ "text": {
131
+ "name": "text",
132
+ "type": "text",
133
+ "primaryKey": false,
134
+ "notNull": false,
135
+ "autoincrement": false
136
+ },
137
+ "media_path": {
138
+ "name": "media_path",
139
+ "type": "text",
140
+ "primaryKey": false,
141
+ "notNull": false,
142
+ "autoincrement": false
143
+ },
144
+ "media_mime": {
145
+ "name": "media_mime",
146
+ "type": "text",
147
+ "primaryKey": false,
148
+ "notNull": false,
149
+ "autoincrement": false
150
+ },
151
+ "media_bytes": {
152
+ "name": "media_bytes",
153
+ "type": "integer",
154
+ "primaryKey": false,
155
+ "notNull": false,
156
+ "autoincrement": false
157
+ },
158
+ "quote_msg_id": {
159
+ "name": "quote_msg_id",
160
+ "type": "text",
161
+ "primaryKey": false,
162
+ "notNull": false,
163
+ "autoincrement": false
164
+ },
165
+ "idempotency_key": {
166
+ "name": "idempotency_key",
167
+ "type": "text",
168
+ "primaryKey": false,
169
+ "notNull": false,
170
+ "autoincrement": false
171
+ },
172
+ "status": {
173
+ "name": "status",
174
+ "type": "text",
175
+ "primaryKey": false,
176
+ "notNull": true,
177
+ "autoincrement": false
178
+ },
179
+ "attempts": {
180
+ "name": "attempts",
181
+ "type": "integer",
182
+ "primaryKey": false,
183
+ "notNull": true,
184
+ "autoincrement": false,
185
+ "default": 0
186
+ },
187
+ "next_attempt_at": {
188
+ "name": "next_attempt_at",
189
+ "type": "integer",
190
+ "primaryKey": false,
191
+ "notNull": false,
192
+ "autoincrement": false
193
+ },
194
+ "last_error": {
195
+ "name": "last_error",
196
+ "type": "text",
197
+ "primaryKey": false,
198
+ "notNull": false,
199
+ "autoincrement": false
200
+ },
201
+ "claimed_by": {
202
+ "name": "claimed_by",
203
+ "type": "text",
204
+ "primaryKey": false,
205
+ "notNull": false,
206
+ "autoincrement": false
207
+ },
208
+ "claimed_at": {
209
+ "name": "claimed_at",
210
+ "type": "integer",
211
+ "primaryKey": false,
212
+ "notNull": false,
213
+ "autoincrement": false
214
+ },
215
+ "created_at": {
216
+ "name": "created_at",
217
+ "type": "integer",
218
+ "primaryKey": false,
219
+ "notNull": true,
220
+ "autoincrement": false
221
+ },
222
+ "updated_at": {
223
+ "name": "updated_at",
224
+ "type": "integer",
225
+ "primaryKey": false,
226
+ "notNull": true,
227
+ "autoincrement": false
228
+ }
229
+ },
230
+ "indexes": {
231
+ "outbound_by_status_next": {
232
+ "name": "outbound_by_status_next",
233
+ "columns": [
234
+ "status",
235
+ "next_attempt_at"
236
+ ],
237
+ "isUnique": false
238
+ },
239
+ "outbound_by_address": {
240
+ "name": "outbound_by_address",
241
+ "columns": [
242
+ "address"
243
+ ],
244
+ "isUnique": false
245
+ },
246
+ "outbound_idempotency_key_uq": {
247
+ "name": "outbound_idempotency_key_uq",
248
+ "columns": [
249
+ "idempotency_key"
250
+ ],
251
+ "isUnique": true,
252
+ "where": "\"outbound\".\"idempotency_key\" IS NOT NULL"
253
+ }
254
+ },
255
+ "foreignKeys": {},
256
+ "compositePrimaryKeys": {},
257
+ "uniqueConstraints": {},
258
+ "checkConstraints": {}
259
+ },
260
+ "persons": {
261
+ "name": "persons",
262
+ "columns": {
263
+ "id": {
264
+ "name": "id",
265
+ "type": "text",
266
+ "primaryKey": true,
267
+ "notNull": true,
268
+ "autoincrement": false
269
+ },
270
+ "display_name": {
271
+ "name": "display_name",
272
+ "type": "text",
273
+ "primaryKey": false,
274
+ "notNull": false,
275
+ "autoincrement": false
276
+ },
277
+ "timezone": {
278
+ "name": "timezone",
279
+ "type": "text",
280
+ "primaryKey": false,
281
+ "notNull": false,
282
+ "autoincrement": false
283
+ },
284
+ "created_at": {
285
+ "name": "created_at",
286
+ "type": "integer",
287
+ "primaryKey": false,
288
+ "notNull": true,
289
+ "autoincrement": false
290
+ }
291
+ },
292
+ "indexes": {},
293
+ "foreignKeys": {},
294
+ "compositePrimaryKeys": {},
295
+ "uniqueConstraints": {},
296
+ "checkConstraints": {}
297
+ },
298
+ "workers": {
299
+ "name": "workers",
300
+ "columns": {
301
+ "id": {
302
+ "name": "id",
303
+ "type": "text",
304
+ "primaryKey": true,
305
+ "notNull": true,
306
+ "autoincrement": false
307
+ },
308
+ "kind": {
309
+ "name": "kind",
310
+ "type": "text",
311
+ "primaryKey": false,
312
+ "notNull": true,
313
+ "autoincrement": false
314
+ },
315
+ "status": {
316
+ "name": "status",
317
+ "type": "text",
318
+ "primaryKey": false,
319
+ "notNull": true,
320
+ "autoincrement": false
321
+ },
322
+ "current_job": {
323
+ "name": "current_job",
324
+ "type": "text",
325
+ "primaryKey": false,
326
+ "notNull": false,
327
+ "autoincrement": false
328
+ },
329
+ "last_seen": {
330
+ "name": "last_seen",
331
+ "type": "integer",
332
+ "primaryKey": false,
333
+ "notNull": true,
334
+ "autoincrement": false
335
+ },
336
+ "started_at": {
337
+ "name": "started_at",
338
+ "type": "integer",
339
+ "primaryKey": false,
340
+ "notNull": true,
341
+ "autoincrement": false
342
+ }
343
+ },
344
+ "indexes": {
345
+ "workers_by_kind_status": {
346
+ "name": "workers_by_kind_status",
347
+ "columns": [
348
+ "kind",
349
+ "status"
350
+ ],
351
+ "isUnique": false
352
+ },
353
+ "workers_by_last_seen": {
354
+ "name": "workers_by_last_seen",
355
+ "columns": [
356
+ "last_seen"
357
+ ],
358
+ "isUnique": false
359
+ }
360
+ },
361
+ "foreignKeys": {},
362
+ "compositePrimaryKeys": {},
363
+ "uniqueConstraints": {},
364
+ "checkConstraints": {}
365
+ }
366
+ },
367
+ "views": {},
368
+ "enums": {},
369
+ "_meta": {
370
+ "schemas": {},
371
+ "tables": {},
372
+ "columns": {}
373
+ },
374
+ "internal": {
375
+ "indexes": {}
376
+ }
377
+ }
@@ -8,6 +8,13 @@
8
8
  "when": 1779668680508,
9
9
  "tag": "0000_phase0_identity_control",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1779669712372,
16
+ "tag": "0001_phase1_outbound",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c4t4/heyamigo",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "WhatsApp AI bot powered by Claude with long-term memory, browser control, and role-based access",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",