@chipallen2/snazi 0.1.0

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,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.imessageAdapter = void 0;
4
+ const FDA_HINT = 'Grant Full Disk Access to your terminal (or the node binary) in ' +
5
+ 'System Settings > Privacy & Security > Full Disk Access, then retry.';
6
+ // Lazy require: only load chatdb (and thus better-sqlite3) when we actually
7
+ // read on macOS. Keeps Windows/Linux installs free of the native dependency at
8
+ // runtime — they never reach this code because availability() returns first.
9
+ function chatdb() {
10
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
11
+ return require('../chatdb');
12
+ }
13
+ exports.imessageAdapter = {
14
+ id: 'imessage',
15
+ displayName: 'iMessage',
16
+ platforms: ['darwin'],
17
+ availability() {
18
+ if (process.platform !== 'darwin') {
19
+ return {
20
+ available: false,
21
+ reason: `iMessage can only be read on macOS (this host is ${process.platform}).`,
22
+ };
23
+ }
24
+ let probe;
25
+ try {
26
+ probe = chatdb().probeChatDb();
27
+ }
28
+ catch (e) {
29
+ // better-sqlite3 is an optional native dependency; if it failed to
30
+ // install (or load), report it cleanly instead of throwing.
31
+ return {
32
+ available: false,
33
+ reason: `iMessage backend unavailable: ${String(e instanceof Error ? e.message : e)}`,
34
+ detail: 'Reinstall to build the native better-sqlite3 module (needs Xcode Command Line Tools).',
35
+ };
36
+ }
37
+ if (probe.ok)
38
+ return { available: true };
39
+ return { available: false, reason: probe.reason, detail: FDA_HINT };
40
+ },
41
+ listInboundSenders(sinceMinutes) {
42
+ return chatdb().listInboundSenders(sinceMinutes);
43
+ },
44
+ readMessagesFrom(sender, sinceMinutes) {
45
+ return chatdb().readMessagesFrom(sender, sinceMinutes);
46
+ },
47
+ };
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getAdapter = getAdapter;
4
+ exports.listAdapters = listAdapters;
5
+ exports.resolveReadableAdapter = resolveReadableAdapter;
6
+ const imessage_1 = require("./imessage");
7
+ const ADAPTERS = new Map([imessage_1.imessageAdapter].map((a) => [a.id, a]));
8
+ /** Look up a registered adapter by channel id, or undefined. */
9
+ function getAdapter(id) {
10
+ return ADAPTERS.get(id);
11
+ }
12
+ /** Every registered adapter (in registration order). */
13
+ function listAdapters() {
14
+ return [...ADAPTERS.values()];
15
+ }
16
+ /**
17
+ * Resolve a channel id to an adapter that can actually READ on this host.
18
+ * Never throws: returns a helpful `error` string when the channel is unknown
19
+ * or unavailable on this platform, so callers can surface it as JSON.
20
+ */
21
+ function resolveReadableAdapter(channel) {
22
+ const adapter = getAdapter(channel);
23
+ if (!adapter) {
24
+ const known = listAdapters()
25
+ .map((a) => a.id)
26
+ .join(', ');
27
+ return {
28
+ error: `Unknown channel '${channel}'. Known channels: ${known || '(none)'}.`,
29
+ };
30
+ }
31
+ const availability = adapter.availability();
32
+ if (!availability.available) {
33
+ const detail = availability.detail ? ` ${availability.detail}` : '';
34
+ return {
35
+ error: `Channel '${channel}' is not available on this machine: ${availability.reason ?? 'unavailable'}.${detail}`,
36
+ };
37
+ }
38
+ return { adapter };
39
+ }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ /**
3
+ * Channel adapter contract.
4
+ *
5
+ * A ChannelAdapter is the LOCAL message SOURCE for one medium (iMessage today;
6
+ * Gmail/Outlook/etc. later). It answers two questions for the gate:
7
+ * - WHO messaged recently (`listInboundSenders`) — never content.
8
+ * - WHAT one (already-approved) sender said (`readMessagesFrom`).
9
+ *
10
+ * Adapters also declare which OS platforms they can run on and self-report
11
+ * availability, so a channel that can't run here (e.g. iMessage on Windows)
12
+ * degrades to a clear message instead of a crash. The approval gate (api.ts)
13
+ * and the server list are channel-agnostic and live OUTSIDE this contract —
14
+ * adapters only provide the local source of messages, never the decision.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
package/dist/chatdb.js ADDED
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.unixMsToAppleNs = unixMsToAppleNs;
40
+ exports.appleNsToDate = appleNsToDate;
41
+ exports.chatDbPath = chatDbPath;
42
+ exports.probeChatDb = probeChatDb;
43
+ exports.listInboundSenders = listInboundSenders;
44
+ exports.readMessagesFrom = readMessagesFrom;
45
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
46
+ const os = __importStar(require("os"));
47
+ const path = __importStar(require("path"));
48
+ const fs = __importStar(require("fs"));
49
+ const DEFAULT_CHAT_DB = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
50
+ /**
51
+ * Resolve the chat.db path. Honors the `SNAZI_CHAT_DB` env var (if set and
52
+ * non-empty) so tests can point at a synthetic DB; otherwise the real
53
+ * ~/Library/Messages/chat.db. Read per-call so tests can set/unset freely.
54
+ */
55
+ function getChatDbPath() {
56
+ const override = process.env.SNAZI_CHAT_DB;
57
+ if (override && override.trim() !== '')
58
+ return override;
59
+ return DEFAULT_CHAT_DB;
60
+ }
61
+ // Apple Cocoa epoch: 2001-01-01 in unix seconds.
62
+ const APPLE_EPOCH = 978307200;
63
+ /** Convert a unix-ms cutoff into Apple's nanosecond `message.date` units. */
64
+ function unixMsToAppleNs(unixMs) {
65
+ return Math.floor((unixMs / 1000 - APPLE_EPOCH) * 1e9);
66
+ }
67
+ /** Convert Apple's `message.date` (ns) back into a JS Date. */
68
+ function appleNsToDate(appleNs) {
69
+ return new Date((appleNs / 1e9 + APPLE_EPOCH) * 1000);
70
+ }
71
+ function openDb() {
72
+ const chatDb = getChatDbPath();
73
+ if (!fs.existsSync(chatDb)) {
74
+ throw new Error(`chat.db not found at ${chatDb}. Is this a Mac with Messages?`);
75
+ }
76
+ try {
77
+ return new better_sqlite3_1.default(chatDb, { readonly: true, fileMustExist: true });
78
+ }
79
+ catch (e) {
80
+ throw new Error(`Cannot open chat.db (${String(e)}). Grant your terminal Full Disk Access in System Settings > Privacy & Security.`);
81
+ }
82
+ }
83
+ /** The resolved chat.db path (honors SNAZI_CHAT_DB). Exposed for diagnostics. */
84
+ function chatDbPath() {
85
+ return getChatDbPath();
86
+ }
87
+ /**
88
+ * Non-throwing readability probe used by the iMessage channel adapter's
89
+ * availability check. Returns `{ ok: true }` only if chat.db exists AND can be
90
+ * opened + queried read-only (i.e. Full Disk Access is granted). Otherwise
91
+ * returns a human-readable reason — never throws.
92
+ */
93
+ function probeChatDb() {
94
+ const chatDb = getChatDbPath();
95
+ if (!fs.existsSync(chatDb)) {
96
+ return { ok: false, reason: `chat.db not found at ${chatDb}.` };
97
+ }
98
+ let db;
99
+ try {
100
+ db = new better_sqlite3_1.default(chatDb, { readonly: true, fileMustExist: true });
101
+ // A trivial query forces the file open: this is what fails without FDA.
102
+ db.prepare('SELECT 1').get();
103
+ return { ok: true };
104
+ }
105
+ catch (e) {
106
+ return {
107
+ ok: false,
108
+ reason: `Cannot open chat.db at ${chatDb} (likely missing Full Disk Access): ${String(e instanceof Error ? e.message : e)}`,
109
+ };
110
+ }
111
+ finally {
112
+ try {
113
+ db?.close();
114
+ }
115
+ catch {
116
+ // ignore close errors on a probe
117
+ }
118
+ }
119
+ }
120
+ // A 1:1 (direct) conversation is a chat with exactly ONE participant in
121
+ // chat_handle_join. Group chats have 2+. We scope BOTH listing and reading to
122
+ // these so "approve a person" means "their DMs" — never their group-chat
123
+ // traffic. This also avoids leaking who's in your group threads.
124
+ const DIRECT_CHATS_SQL = `
125
+ SELECT chat_id FROM chat_handle_join
126
+ GROUP BY chat_id HAVING COUNT(*) = 1
127
+ `;
128
+ /**
129
+ * Return distinct INBOUND senders in the window — WHO only, never WHAT.
130
+ * Scoped to 1:1 conversations (group chats are excluded).
131
+ * `sinceMinutes` is the lookback window in minutes.
132
+ */
133
+ function listInboundSenders(sinceMinutes) {
134
+ const cutoffNs = unixMsToAppleNs(Date.now() - sinceMinutes * 60000);
135
+ const db = openDb();
136
+ try {
137
+ const rows = db
138
+ .prepare(`SELECT h.id AS sender, COUNT(*) AS cnt, MAX(m.date) AS latest
139
+ FROM message m
140
+ JOIN handle h ON m.handle_id = h.ROWID
141
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
142
+ WHERE m.is_from_me = 0 AND m.date > ?
143
+ AND cmj.chat_id IN (${DIRECT_CHATS_SQL})
144
+ GROUP BY h.id
145
+ ORDER BY latest DESC`)
146
+ .all(cutoffNs);
147
+ return rows.map((r) => ({
148
+ sender: r.sender,
149
+ message_count: r.cnt,
150
+ latest_at: appleNsToDate(r.latest).toISOString(),
151
+ }));
152
+ }
153
+ finally {
154
+ db.close();
155
+ }
156
+ }
157
+ /**
158
+ * Return actual message TEXT for ONE sender's 1:1 conversation in the window —
159
+ * BOTH directions (the sender's inbound messages AND the user's outbound replies),
160
+ * in chronological order, each tagged with its direction.
161
+ *
162
+ * Scoping is by CHAT, not by handle_id: we find the 1:1 chat(s) whose sole
163
+ * participant is `sender`, then return every message in those chats. This is
164
+ * deliberate — in a real chat.db, OUTBOUND messages have handle_id = 0 (they
165
+ * are NOT tagged with the recipient's handle), so a handle-only join would
166
+ * silently drop the user's own replies. Joining via the chat captures both sides
167
+ * correctly and keeps group-chat traffic out.
168
+ *
169
+ * Caller MUST have verified the sender is approved before calling this.
170
+ */
171
+ function readMessagesFrom(sender, sinceMinutes) {
172
+ const cutoffNs = unixMsToAppleNs(Date.now() - sinceMinutes * 60000);
173
+ const db = openDb();
174
+ try {
175
+ const rows = db
176
+ .prepare(`SELECT m.date AS date, m.text AS text, m.is_from_me AS is_from_me
177
+ FROM message m
178
+ JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
179
+ WHERE cmj.chat_id IN (
180
+ SELECT chj.chat_id
181
+ FROM chat_handle_join chj
182
+ JOIN handle h ON h.ROWID = chj.handle_id
183
+ WHERE chj.chat_id IN (${DIRECT_CHATS_SQL})
184
+ AND h.id = ?
185
+ )
186
+ AND m.date > ? AND m.text IS NOT NULL
187
+ ORDER BY m.date ASC`)
188
+ .all(sender, cutoffNs);
189
+ return rows.map((r) => {
190
+ const fromMe = r.is_from_me === 1;
191
+ return {
192
+ date: appleNsToDate(r.date).toISOString(),
193
+ text: r.text,
194
+ from_me: fromMe,
195
+ direction: fromMe ? 'outgoing' : 'incoming',
196
+ };
197
+ });
198
+ }
199
+ finally {
200
+ db.close();
201
+ }
202
+ }