@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.
- package/README.md +281 -0
- package/com.soup-nazi.snazi-serve.plist +46 -0
- package/dist/address.js +61 -0
- package/dist/api.js +101 -0
- package/dist/cache.js +173 -0
- package/dist/channels/imessage.js +47 -0
- package/dist/channels/index.js +39 -0
- package/dist/channels/types.js +16 -0
- package/dist/chatdb.js +202 -0
- package/dist/cli.js +516 -0
- package/dist/client.js +106 -0
- package/dist/config.js +110 -0
- package/dist/daemon.js +89 -0
- package/dist/doctor.js +99 -0
- package/dist/init.js +155 -0
- package/dist/server.js +466 -0
- package/package.json +52 -0
|
@@ -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
|
+
}
|