@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.
- package/dist/channels/adapter.js +24 -0
- package/dist/channels/baileys.js +158 -0
- package/dist/channels/index.js +16 -0
- package/dist/config.js +4 -0
- package/dist/db/address.js +72 -0
- package/dist/db/check.js +74 -0
- package/dist/db/identity-sync.js +147 -0
- package/dist/db/index.js +44 -0
- package/dist/db/migrate.js +113 -0
- package/dist/db/schema.js +97 -0
- package/dist/gateway/outgoing.js +127 -177
- package/dist/index.js +18 -0
- package/dist/queue/outbound-postsend.js +77 -0
- package/dist/queue/outbound.js +185 -0
- package/dist/queue/sender-worker.js +199 -0
- package/migrations/0000_phase0_identity_control.sql +34 -0
- package/migrations/0001_phase1_outbound.sql +23 -0
- package/migrations/meta/0000_snapshot.json +223 -0
- package/migrations/meta/0001_snapshot.json +377 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +6 -1
|
@@ -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,72 @@
|
|
|
1
|
+
// Channel-agnostic address shape. Inbound, outbound, async, browser,
|
|
2
|
+
// and crons all carry an Address string. Sender worker parses the
|
|
3
|
+
// channel prefix and dispatches to the matching ChannelAdapter.
|
|
4
|
+
//
|
|
5
|
+
// Serialized form (the wire shape stored in DB columns):
|
|
6
|
+
// wa:dm:17867@s.whatsapp.net
|
|
7
|
+
// wa:group:120363@g.us
|
|
8
|
+
// tg:dm:user_12345
|
|
9
|
+
// tg:group:-100123456
|
|
10
|
+
// system:cron:42
|
|
11
|
+
//
|
|
12
|
+
// First two segments are well-known. The third is the platform-native
|
|
13
|
+
// external id, kept verbatim — easier to debug, lossless round-trip.
|
|
14
|
+
export function formatAddress(addr) {
|
|
15
|
+
return `${addr.channel}:${addr.scope}:${addr.externalId}`;
|
|
16
|
+
}
|
|
17
|
+
export function parseAddress(s) {
|
|
18
|
+
const idx1 = s.indexOf(':');
|
|
19
|
+
const idx2 = s.indexOf(':', idx1 + 1);
|
|
20
|
+
if (idx1 < 0 || idx2 < 0) {
|
|
21
|
+
throw new Error(`bad address (need channel:scope:external_id): ${s}`);
|
|
22
|
+
}
|
|
23
|
+
const channel = s.slice(0, idx1);
|
|
24
|
+
const scope = s.slice(idx1 + 1, idx2);
|
|
25
|
+
const externalId = s.slice(idx2 + 1);
|
|
26
|
+
if (!externalId)
|
|
27
|
+
throw new Error(`bad address (empty external id): ${s}`);
|
|
28
|
+
return { channel, scope, externalId };
|
|
29
|
+
}
|
|
30
|
+
// Convert a raw Baileys JID into our address form. JID suffixes:
|
|
31
|
+
// @s.whatsapp.net → wa:dm
|
|
32
|
+
// @g.us → wa:group
|
|
33
|
+
// @lid → wa:dm (LID identities; canonicalized to wa:dm)
|
|
34
|
+
// @newsletter → wa:group (broadcast/channel, treat as group-like)
|
|
35
|
+
//
|
|
36
|
+
// The full JID stays in externalId so it round-trips losslessly.
|
|
37
|
+
export function jidToAddress(jid) {
|
|
38
|
+
if (jid.endsWith('@g.us')) {
|
|
39
|
+
return { channel: 'wa', scope: 'group', externalId: jid };
|
|
40
|
+
}
|
|
41
|
+
if (jid.endsWith('@newsletter')) {
|
|
42
|
+
return { channel: 'wa', scope: 'group', externalId: jid };
|
|
43
|
+
}
|
|
44
|
+
if (jid.endsWith('@s.whatsapp.net') || jid.endsWith('@lid')) {
|
|
45
|
+
return { channel: 'wa', scope: 'dm', externalId: jid };
|
|
46
|
+
}
|
|
47
|
+
// Unknown WA jid shape — preserve verbatim, default to DM.
|
|
48
|
+
return { channel: 'wa', scope: 'dm', externalId: jid };
|
|
49
|
+
}
|
|
50
|
+
// Reverse: pull the platform-native id back out. For WA this is the
|
|
51
|
+
// JID; ChannelAdapter implementations use it directly.
|
|
52
|
+
export function addressToExternalId(addr) {
|
|
53
|
+
const a = typeof addr === 'string' ? parseAddress(addr) : addr;
|
|
54
|
+
return a.externalId;
|
|
55
|
+
}
|
|
56
|
+
// Convenience predicates.
|
|
57
|
+
export function isGroup(addr) {
|
|
58
|
+
const a = typeof addr === 'string' ? parseAddress(addr) : addr;
|
|
59
|
+
return a.scope === 'group';
|
|
60
|
+
}
|
|
61
|
+
export function isDm(addr) {
|
|
62
|
+
const a = typeof addr === 'string' ? parseAddress(addr) : addr;
|
|
63
|
+
return a.scope === 'dm';
|
|
64
|
+
}
|
|
65
|
+
// System addresses for bot-internal flows (cron-fired self-prompts,
|
|
66
|
+
// task-spawned messages without an originating chat).
|
|
67
|
+
export function systemCronAddress(cronId) {
|
|
68
|
+
return `system:cron:${cronId}`;
|
|
69
|
+
}
|
|
70
|
+
export function systemTaskAddress(taskId) {
|
|
71
|
+
return `system:task:${taskId}`;
|
|
72
|
+
}
|
package/dist/db/check.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Schema drift detector. Runs after migrations succeed. Compares the
|
|
2
|
+
// live database's table set against the set drizzle's schema.ts
|
|
3
|
+
// declares, refuses to start on mismatch.
|
|
4
|
+
//
|
|
5
|
+
// What this catches:
|
|
6
|
+
// - "Forgot to run `drizzle-kit generate` after editing schema.ts"
|
|
7
|
+
// → migration didn't include the new table; drift detected.
|
|
8
|
+
// - "Someone ran `ALTER TABLE` directly in prod" → extra columns or
|
|
9
|
+
// missing columns vs. schema.ts.
|
|
10
|
+
//
|
|
11
|
+
// What this does NOT catch (intentionally — keeps the check simple
|
|
12
|
+
// and predictable):
|
|
13
|
+
// - Column type changes that SQLite stored compatibly.
|
|
14
|
+
// - Index differences (drizzle doesn't always emit identical CREATE
|
|
15
|
+
// INDEX text; comparing index DDL is fragile).
|
|
16
|
+
// - Drift in non-drizzle tables (e.g. __drizzle_migrations itself).
|
|
17
|
+
//
|
|
18
|
+
// If we ever need stricter checking, add it here behind a config flag.
|
|
19
|
+
// Start strict-but-narrow; loosen if it bites.
|
|
20
|
+
import { getTableConfig, SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
21
|
+
import { logger } from '../logger.js';
|
|
22
|
+
import * as schema from './schema.js';
|
|
23
|
+
export class SchemaDriftError extends Error {
|
|
24
|
+
diffs;
|
|
25
|
+
constructor(diffs) {
|
|
26
|
+
super(`schema drift detected:\n - ${diffs.join('\n - ')}`);
|
|
27
|
+
this.diffs = diffs;
|
|
28
|
+
this.name = 'SchemaDriftError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function checkSchemaDrift(db) {
|
|
32
|
+
const diffs = [];
|
|
33
|
+
// Discover declared tables by walking the schema module's exports.
|
|
34
|
+
// SQLiteTable is a real class; instanceof is the cleanest discriminator.
|
|
35
|
+
const declared = Object.values(schema)
|
|
36
|
+
.filter((v) => v instanceof SQLiteTable)
|
|
37
|
+
.map(t => getTableConfig(t));
|
|
38
|
+
const liveTables = db
|
|
39
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle_%'")
|
|
40
|
+
.all();
|
|
41
|
+
const liveTableNames = new Set(liveTables.map(r => r.name));
|
|
42
|
+
for (const t of declared) {
|
|
43
|
+
if (!liveTableNames.has(t.name)) {
|
|
44
|
+
diffs.push(`missing table: ${t.name}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const liveCols = db
|
|
48
|
+
.prepare(`PRAGMA table_info(${t.name})`)
|
|
49
|
+
.all();
|
|
50
|
+
const liveColNames = new Set(liveCols.map(c => c.name));
|
|
51
|
+
const declaredColNames = new Set(t.columns.map(c => c.name));
|
|
52
|
+
for (const c of t.columns) {
|
|
53
|
+
if (!liveColNames.has(c.name)) {
|
|
54
|
+
diffs.push(`${t.name}: missing column "${c.name}"`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const name of liveColNames) {
|
|
58
|
+
if (!declaredColNames.has(name)) {
|
|
59
|
+
diffs.push(`${t.name}: unexpected column "${name}" (drift)`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Unexpected tables (drift from out-of-band CREATE TABLE)
|
|
64
|
+
for (const name of liveTableNames) {
|
|
65
|
+
if (!declared.some(t => t.name === name)) {
|
|
66
|
+
diffs.push(`unexpected table "${name}" (drift)`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (diffs.length > 0) {
|
|
70
|
+
logger.fatal({ diffs }, 'schema drift detected; bot refuses to start');
|
|
71
|
+
throw new SchemaDriftError(diffs);
|
|
72
|
+
}
|
|
73
|
+
logger.debug({ tables: declared.map(t => t.name) }, 'schema check passed');
|
|
74
|
+
}
|
|
@@ -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/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Process-wide SQLite handle. Initialized once at boot in src/index.ts
|
|
2
|
+
// (and in any other entry point that needs DB access, like `setup`).
|
|
3
|
+
// Workers and other modules get the handle via `getDb()` — never open
|
|
4
|
+
// their own.
|
|
5
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import * as schema from './schema.js';
|
|
8
|
+
import { runMigrations } from './migrate.js';
|
|
9
|
+
import { checkSchemaDrift } from './check.js';
|
|
10
|
+
let rawDb = null;
|
|
11
|
+
let ormDb = null;
|
|
12
|
+
export function dbPath() {
|
|
13
|
+
return resolve(process.cwd(), 'storage', 'heyamigo.db');
|
|
14
|
+
}
|
|
15
|
+
// Run migrations + drift check + open the singleton. Call once per
|
|
16
|
+
// process at boot. Idempotent — subsequent calls return the existing
|
|
17
|
+
// handle without re-migrating.
|
|
18
|
+
export function initDb() {
|
|
19
|
+
if (ormDb)
|
|
20
|
+
return ormDb;
|
|
21
|
+
const path = dbPath();
|
|
22
|
+
rawDb = runMigrations(path);
|
|
23
|
+
checkSchemaDrift(rawDb);
|
|
24
|
+
ormDb = drizzle(rawDb, { schema });
|
|
25
|
+
return ormDb;
|
|
26
|
+
}
|
|
27
|
+
export function getDb() {
|
|
28
|
+
if (!ormDb)
|
|
29
|
+
throw new Error('db not initialized; call initDb() at boot');
|
|
30
|
+
return ormDb;
|
|
31
|
+
}
|
|
32
|
+
export function getRawDb() {
|
|
33
|
+
if (!rawDb)
|
|
34
|
+
throw new Error('db not initialized; call initDb() at boot');
|
|
35
|
+
return rawDb;
|
|
36
|
+
}
|
|
37
|
+
// Used by graceful shutdown.
|
|
38
|
+
export function closeDb() {
|
|
39
|
+
if (rawDb) {
|
|
40
|
+
rawDb.close();
|
|
41
|
+
rawDb = null;
|
|
42
|
+
ormDb = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Migration runner. Called once at boot from src/index.ts before any
|
|
2
|
+
// worker spins up. Order: pre-migration backup → drizzle migrator →
|
|
3
|
+
// drift check (in src/db/check.ts). If anything throws, the bot
|
|
4
|
+
// refuses to start.
|
|
5
|
+
import Database from 'better-sqlite3';
|
|
6
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
7
|
+
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
9
|
+
import { dirname, resolve } from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { logger } from '../logger.js';
|
|
12
|
+
// Resolve `migrations/` relative to the package install, not cwd. When
|
|
13
|
+
// installed via npm, the bot runs from the user's project dir but the
|
|
14
|
+
// migration SQL files ship inside @c4t4/heyamigo. From src/db/migrate.ts:
|
|
15
|
+
// dist/db/migrate.js ← __filename
|
|
16
|
+
// dist/db/ ← dirname
|
|
17
|
+
// dist/ ← ../
|
|
18
|
+
// <pkg root>/ ← ../../
|
|
19
|
+
// <pkg root>/migrations
|
|
20
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
21
|
+
const MIGRATIONS_FOLDER = resolve(PKG_ROOT, 'migrations');
|
|
22
|
+
const BACKUP_DIR_NAME = 'backups';
|
|
23
|
+
const KEEP_PRE_MIGRATION_BACKUPS = 10;
|
|
24
|
+
// VACUUM INTO is atomic and produces a fully consistent copy even
|
|
25
|
+
// while the DB is being written to. Trivial insurance before any
|
|
26
|
+
// schema change. Skip if no pending migrations to avoid noise on
|
|
27
|
+
// no-op boots.
|
|
28
|
+
function preMigrationBackup(dbPath) {
|
|
29
|
+
// Cheap check: open the DB, ask the migrator what would happen.
|
|
30
|
+
// We can't easily query "what's pending" without invoking drizzle's
|
|
31
|
+
// internals, so instead we check whether our migrations folder has
|
|
32
|
+
// more entries than the drizzle tracking table claims.
|
|
33
|
+
const sqlFiles = existsSync(MIGRATIONS_FOLDER)
|
|
34
|
+
? readdirSync(MIGRATIONS_FOLDER).filter(f => f.endsWith('.sql'))
|
|
35
|
+
: [];
|
|
36
|
+
if (sqlFiles.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
let appliedCount = 0;
|
|
39
|
+
if (existsSync(dbPath)) {
|
|
40
|
+
const probe = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
41
|
+
try {
|
|
42
|
+
const row = probe
|
|
43
|
+
.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
|
|
44
|
+
.get();
|
|
45
|
+
if (row.n > 0) {
|
|
46
|
+
const counted = probe
|
|
47
|
+
.prepare('SELECT count(*) AS n FROM __drizzle_migrations')
|
|
48
|
+
.get();
|
|
49
|
+
appliedCount = counted.n;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
probe.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (appliedCount >= sqlFiles.length)
|
|
57
|
+
return null; // up to date, nothing to back up for
|
|
58
|
+
const backupDir = resolve(dirname(dbPath), BACKUP_DIR_NAME);
|
|
59
|
+
mkdirSync(backupDir, { recursive: true });
|
|
60
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
61
|
+
const backupPath = resolve(backupDir, `pre-migration-${ts}.db`);
|
|
62
|
+
if (existsSync(dbPath)) {
|
|
63
|
+
const tmp = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
64
|
+
try {
|
|
65
|
+
tmp.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
tmp.close();
|
|
69
|
+
}
|
|
70
|
+
logger.info({ backupPath }, 'pre-migration backup written');
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
// No DB yet — nothing to back up. Boot will create it fresh.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
rotatePreMigrationBackups(backupDir);
|
|
77
|
+
return backupPath;
|
|
78
|
+
}
|
|
79
|
+
function rotatePreMigrationBackups(backupDir) {
|
|
80
|
+
const files = readdirSync(backupDir)
|
|
81
|
+
.filter(f => f.startsWith('pre-migration-') && f.endsWith('.db'))
|
|
82
|
+
.map(f => ({ name: f, path: resolve(backupDir, f), mtime: statSync(resolve(backupDir, f)).mtimeMs }))
|
|
83
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
84
|
+
const toDelete = files.slice(KEEP_PRE_MIGRATION_BACKUPS);
|
|
85
|
+
for (const f of toDelete) {
|
|
86
|
+
try {
|
|
87
|
+
unlinkSync(f.path);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
logger.warn({ err, file: f.name }, 'failed to delete old backup');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function runMigrations(dbPath) {
|
|
95
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
96
|
+
const backupPath = preMigrationBackup(dbPath);
|
|
97
|
+
const db = new Database(dbPath);
|
|
98
|
+
db.pragma('journal_mode = WAL'); // required for litestream
|
|
99
|
+
db.pragma('foreign_keys = ON');
|
|
100
|
+
const drizzleDb = drizzle(db);
|
|
101
|
+
try {
|
|
102
|
+
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
logger.fatal({ err, backupPath }, 'migration failed; refusing to start. restore the pre-migration backup if needed.');
|
|
106
|
+
db.close();
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
if (backupPath) {
|
|
110
|
+
logger.info('migrations applied successfully');
|
|
111
|
+
}
|
|
112
|
+
return db;
|
|
113
|
+
}
|