@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,24 @@
1
+ // Channel adapter interface. The sender worker drains the outbound
2
+ // queue, parses each row's address (wa:dm:..., tg:dm:..., etc.), and
3
+ // dispatches to the adapter for the matching channel. Adapters are
4
+ // the *only* place that talks to a channel SDK (Baileys, Telegram bot,
5
+ // whatever). Workers stay channel-agnostic.
6
+ // Distinguishes between "the channel said no, try again later" and
7
+ // "the message itself is broken." Sender worker uses this to decide
8
+ // retry vs DLQ.
9
+ export class TransientChannelError extends Error {
10
+ cause;
11
+ constructor(message, cause) {
12
+ super(message);
13
+ this.cause = cause;
14
+ this.name = 'TransientChannelError';
15
+ }
16
+ }
17
+ export class PermanentChannelError extends Error {
18
+ cause;
19
+ constructor(message, cause) {
20
+ super(message);
21
+ this.cause = cause;
22
+ this.name = 'PermanentChannelError';
23
+ }
24
+ }
@@ -0,0 +1,158 @@
1
+ // Baileys (WhatsApp) channel adapter. Wraps the existing wa/sender.ts
2
+ // behind the ChannelAdapter interface so the sender worker stays
3
+ // channel-agnostic.
4
+ //
5
+ // The WASocket is created in src/wa/socket.ts and replaced on each
6
+ // reconnect. Boot path (or the connection callback) calls
7
+ // setBaileysSocket(sock) so the adapter always points at the live one.
8
+ import { readFileSync, statSync } from 'fs';
9
+ import { basename, extname } from 'path';
10
+ import { PermanentChannelError, TransientChannelError, } from './adapter.js';
11
+ let activeSocket = null;
12
+ export function setBaileysSocket(sock) {
13
+ activeSocket = sock;
14
+ }
15
+ const MIME_MAP = {
16
+ '.png': 'image/png',
17
+ '.jpg': 'image/jpeg',
18
+ '.jpeg': 'image/jpeg',
19
+ '.gif': 'image/gif',
20
+ '.webp': 'image/webp',
21
+ '.mp4': 'video/mp4',
22
+ '.avi': 'video/avi',
23
+ '.mov': 'video/quicktime',
24
+ '.mkv': 'video/x-matroska',
25
+ '.mp3': 'audio/mpeg',
26
+ '.ogg': 'audio/ogg',
27
+ '.opus': 'audio/opus',
28
+ '.m4a': 'audio/mp4',
29
+ '.wav': 'audio/wav',
30
+ '.pdf': 'application/pdf',
31
+ '.doc': 'application/msword',
32
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
33
+ '.xls': 'application/vnd.ms-excel',
34
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
35
+ '.csv': 'text/csv',
36
+ '.txt': 'text/plain',
37
+ '.zip': 'application/zip',
38
+ };
39
+ function mimeFor(filePath, fallback) {
40
+ if (fallback)
41
+ return fallback;
42
+ const ext = extname(filePath).toLowerCase();
43
+ return MIME_MAP[ext] ?? 'application/octet-stream';
44
+ }
45
+ function requireSocket() {
46
+ if (!activeSocket) {
47
+ // Socket isn't ready — sender worker should not have tried. This
48
+ // becomes "retry later" so the message stays in queue until WA
49
+ // reconnects.
50
+ throw new TransientChannelError('baileys socket not connected');
51
+ }
52
+ return activeSocket;
53
+ }
54
+ function requireFile(path) {
55
+ try {
56
+ const stat = statSync(path);
57
+ const buf = readFileSync(path);
58
+ return { buf, bytes: stat.size };
59
+ }
60
+ catch (err) {
61
+ throw new PermanentChannelError(`media file unreadable: ${path} (${err.message})`, err);
62
+ }
63
+ }
64
+ // Map a Baileys send error onto our transient/permanent classification.
65
+ // Network/connection issues → transient. Anything else (invalid jid,
66
+ // payload, etc.) → permanent.
67
+ function classifyBaileysError(err) {
68
+ const message = err instanceof Error ? err.message : String(err);
69
+ // Heuristic: Baileys throws "connection closed" / "timed out" /
70
+ // "socket" for transient stuff; everything else assume permanent.
71
+ const transientHints = [
72
+ 'connection closed',
73
+ 'connection lost',
74
+ 'timed out',
75
+ 'timeout',
76
+ 'socket',
77
+ 'lost connection',
78
+ 'no connection',
79
+ ];
80
+ const lower = message.toLowerCase();
81
+ if (transientHints.some((h) => lower.includes(h))) {
82
+ return new TransientChannelError(message, err);
83
+ }
84
+ return new PermanentChannelError(message, err);
85
+ }
86
+ async function sendOne(sock, jid, msg) {
87
+ const quoteOpts = msg.quoteMsgId
88
+ ? // Baileys quoting actually needs the full WAMessage to embed in
89
+ // contextInfo; we only have the id. For now we send without
90
+ // quoting in that case (future: cache recent WAMessages keyed
91
+ // by id so we can rehydrate the quote target).
92
+ undefined
93
+ : undefined;
94
+ switch (msg.kind) {
95
+ case 'text':
96
+ if (!msg.text) {
97
+ throw new PermanentChannelError('text outbound has no body');
98
+ }
99
+ return sock.sendMessage(jid, { text: msg.text }, quoteOpts);
100
+ case 'image': {
101
+ if (!msg.mediaPath) {
102
+ throw new PermanentChannelError('image outbound missing mediaPath');
103
+ }
104
+ const { buf } = requireFile(msg.mediaPath);
105
+ return sock.sendMessage(jid, { image: buf, caption: msg.text ?? undefined }, quoteOpts);
106
+ }
107
+ case 'video': {
108
+ if (!msg.mediaPath) {
109
+ throw new PermanentChannelError('video outbound missing mediaPath');
110
+ }
111
+ const { buf } = requireFile(msg.mediaPath);
112
+ return sock.sendMessage(jid, {
113
+ video: buf,
114
+ caption: msg.text ?? undefined,
115
+ mimetype: mimeFor(msg.mediaPath, msg.mediaMime),
116
+ }, quoteOpts);
117
+ }
118
+ case 'audio': {
119
+ if (!msg.mediaPath) {
120
+ throw new PermanentChannelError('audio outbound missing mediaPath');
121
+ }
122
+ const { buf } = requireFile(msg.mediaPath);
123
+ return sock.sendMessage(jid, { audio: buf, mimetype: mimeFor(msg.mediaPath, msg.mediaMime) }, quoteOpts);
124
+ }
125
+ case 'document': {
126
+ if (!msg.mediaPath) {
127
+ throw new PermanentChannelError('document outbound missing mediaPath');
128
+ }
129
+ const { buf } = requireFile(msg.mediaPath);
130
+ return sock.sendMessage(jid, {
131
+ document: buf,
132
+ mimetype: mimeFor(msg.mediaPath, msg.mediaMime),
133
+ fileName: basename(msg.mediaPath),
134
+ caption: msg.text ?? undefined,
135
+ }, quoteOpts);
136
+ }
137
+ }
138
+ }
139
+ export const baileysAdapter = {
140
+ channel: 'wa',
141
+ async send(externalId, msg) {
142
+ const sock = requireSocket();
143
+ let sent;
144
+ try {
145
+ sent = await sendOne(sock, externalId, msg);
146
+ }
147
+ catch (err) {
148
+ throw classifyBaileysError(err);
149
+ }
150
+ const msgId = sent?.key?.id;
151
+ if (!msgId) {
152
+ // Baileys returned without an id; treat as transient so we retry
153
+ // (rather than silently losing track of whether the send happened).
154
+ throw new TransientChannelError('baileys send returned no message id');
155
+ }
156
+ return { msgId };
157
+ },
158
+ };
@@ -0,0 +1,16 @@
1
+ // Channel adapter registry. Sender worker calls getChannelAdapter(name)
2
+ // keyed off the parsed address.channel.
3
+ import { baileysAdapter } from './baileys.js';
4
+ const REGISTRY = {
5
+ wa: baileysAdapter,
6
+ // tg: telegramAdapter, // Phase 8
7
+ };
8
+ export function getChannelAdapter(channel) {
9
+ const adapter = REGISTRY[channel];
10
+ if (!adapter) {
11
+ throw new Error(`no channel adapter registered for channel="${channel}"`);
12
+ }
13
+ return adapter;
14
+ }
15
+ export { setBaileysSocket } from './baileys.js';
16
+ export { PermanentChannelError, TransientChannelError, } from './adapter.js';
package/dist/config.js CHANGED
@@ -70,6 +70,10 @@ const ConfigSchema = z.object({
70
70
  errorMessage: z.string(),
71
71
  maxMessageAgeMs: z.number(),
72
72
  showStats: z.boolean().default(true),
73
+ // Hard cap on outbound media size enforced by the sender worker.
74
+ // Default 25MB matches WhatsApp's published per-message media limit
75
+ // for most kinds. Set to null to disable the check.
76
+ maxOutboundMediaBytes: z.number().int().positive().nullable().default(25 * 1024 * 1024),
73
77
  }),
74
78
  storage: z.object({
75
79
  messagesDir: z.string(),
@@ -0,0 +1,147 @@
1
+ // Derive persons + identities from existing config/access.json. Runs
2
+ // at boot, idempotently. The file stays authoritative; the DB rows
3
+ // are a *derived view* so future queue rows can reference person_id
4
+ // without joining against the file every time.
5
+ //
6
+ // No schema change to access.json. Phase 0 doesn't touch the file.
7
+ // Person mapping:
8
+ // - config.owner.number → person-owner
9
+ // - each entry in access.users → person-<sanitized-number>
10
+ // - each entry in access.dms.allowed → ensure a person exists
11
+ // - groups are addresses, not persons — skipped
12
+ //
13
+ // Re-running the sync on every boot:
14
+ // - upserts display_name (in case access.json was edited)
15
+ // - adds new identities (in case the owner added a number)
16
+ // - never deletes (avoid surprise data loss; explicit cleanup
17
+ // command can come later)
18
+ import { eq } from 'drizzle-orm';
19
+ import { config } from '../config.js';
20
+ import { logger } from '../logger.js';
21
+ import { jidToAddress, formatAddress } from './address.js';
22
+ import { getDb } from './index.js';
23
+ import { identities, persons } from './schema.js';
24
+ import { getAccess } from '../wa/whitelist.js';
25
+ const OWNER_PERSON_ID = 'person-owner';
26
+ function personIdForNumber(number) {
27
+ // Strip non-digits, prefix with 'person-'. Stable + deterministic.
28
+ const sanitized = number.replace(/\D/g, '');
29
+ return `person-${sanitized}`;
30
+ }
31
+ function dmAddressFor(number) {
32
+ const sanitized = number.replace(/\D/g, '');
33
+ return formatAddress(jidToAddress(`${sanitized}@s.whatsapp.net`));
34
+ }
35
+ function collectSeeds() {
36
+ const access = getAccess();
37
+ const now = Math.floor(Date.now() / 1000);
38
+ void now;
39
+ const out = new Map();
40
+ // Owner
41
+ if (config.owner.number) {
42
+ out.set(OWNER_PERSON_ID, {
43
+ id: OWNER_PERSON_ID,
44
+ displayName: 'Owner',
45
+ addresses: [dmAddressFor(config.owner.number)],
46
+ });
47
+ }
48
+ // Users
49
+ for (const [number, entry] of Object.entries(access.users ?? {})) {
50
+ const id = number === config.owner.number ? OWNER_PERSON_ID : personIdForNumber(number);
51
+ const existing = out.get(id);
52
+ const addr = dmAddressFor(number);
53
+ if (existing) {
54
+ if (entry.name)
55
+ existing.displayName = entry.name;
56
+ if (!existing.addresses.includes(addr))
57
+ existing.addresses.push(addr);
58
+ }
59
+ else {
60
+ out.set(id, {
61
+ id,
62
+ displayName: entry.name ?? null,
63
+ addresses: [addr],
64
+ });
65
+ }
66
+ }
67
+ // DM allowed — ensure persons exist, even without a name
68
+ for (const dm of access.dms?.allowed ?? []) {
69
+ const id = dm.number === config.owner.number ? OWNER_PERSON_ID : personIdForNumber(dm.number);
70
+ const addr = dmAddressFor(dm.number);
71
+ const existing = out.get(id);
72
+ if (existing) {
73
+ if (!existing.addresses.includes(addr))
74
+ existing.addresses.push(addr);
75
+ }
76
+ else {
77
+ out.set(id, { id, displayName: null, addresses: [addr] });
78
+ }
79
+ }
80
+ return [...out.values()];
81
+ }
82
+ // Idempotent upsert. Inserts new rows; updates display_name on existing
83
+ // person rows (in case it was filled in later); never deletes.
84
+ export function syncIdentitiesFromAccess() {
85
+ const db = getDb();
86
+ const seeds = collectSeeds();
87
+ const now = Math.floor(Date.now() / 1000);
88
+ let personsUpserted = 0;
89
+ let identitiesUpserted = 0;
90
+ db.transaction((tx) => {
91
+ for (const seed of seeds) {
92
+ const existing = tx
93
+ .select()
94
+ .from(persons)
95
+ .where(eq(persons.id, seed.id))
96
+ .all();
97
+ if (existing.length === 0) {
98
+ tx.insert(persons)
99
+ .values({
100
+ id: seed.id,
101
+ displayName: seed.displayName,
102
+ timezone: config.owner.timezone,
103
+ createdAt: now,
104
+ })
105
+ .run();
106
+ personsUpserted++;
107
+ }
108
+ else if (seed.displayName &&
109
+ existing[0].displayName !== seed.displayName) {
110
+ tx.update(persons)
111
+ .set({ displayName: seed.displayName })
112
+ .where(eq(persons.id, seed.id))
113
+ .run();
114
+ personsUpserted++;
115
+ }
116
+ for (const addr of seed.addresses) {
117
+ const have = tx
118
+ .select()
119
+ .from(identities)
120
+ .where(eq(identities.address, addr))
121
+ .all();
122
+ if (have.length === 0) {
123
+ tx.insert(identities)
124
+ .values({ personId: seed.id, address: addr, addedAt: now })
125
+ .run();
126
+ identitiesUpserted++;
127
+ }
128
+ // If have.length > 0 but personId differs, leave it — manual
129
+ // merge required. Don't reassign silently.
130
+ }
131
+ }
132
+ });
133
+ logger.info({ personsUpserted, identitiesUpserted, seeded: seeds.length }, 'identity sync from access.json complete');
134
+ return { personsUpserted, identitiesUpserted };
135
+ }
136
+ // Lookup helper used by the inbound resolution step (in later phases).
137
+ // Returns null if the address has no matching person — caller decides
138
+ // whether to auto-create or treat as stranger.
139
+ export function personIdForAddress(address) {
140
+ const db = getDb();
141
+ const row = db
142
+ .select({ personId: identities.personId })
143
+ .from(identities)
144
+ .where(eq(identities.address, address))
145
+ .get();
146
+ return row?.personId ?? null;
147
+ }
package/dist/db/schema.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // `npx drizzle-kit generate` produces a SQL migration in migrations/.
3
3
  // Direct ALTER/CREATE/DROP outside this flow is forbidden — see the
4
4
  // "Cardinal rule" section in refactor.md.
5
- import { sqliteTable, text, integer, primaryKey, index } from 'drizzle-orm/sqlite-core';
5
+ import { sql } from 'drizzle-orm';
6
+ import { sqliteTable, text, integer, primaryKey, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
6
7
  // ──────────────────────────────────────────────────────────────────
7
8
  // Identity
8
9
  // ──────────────────────────────────────────────────────────────────
@@ -51,3 +52,46 @@ export const control = sqliteTable('control', {
51
52
  requestedBy: text('requested_by'),
52
53
  requestedAt: integer('requested_at').notNull(),
53
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
+ }));