@moxxy/plugin-channel-imessage 0.29.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/LICENSE +21 -0
- package/dist/bluebubbles-client.d.ts +79 -0
- package/dist/bluebubbles-client.d.ts.map +1 -0
- package/dist/bluebubbles-client.js +113 -0
- package/dist/bluebubbles-client.js.map +1 -0
- package/dist/channel/chunker.d.ts +66 -0
- package/dist/channel/chunker.d.ts.map +1 -0
- package/dist/channel/chunker.js +130 -0
- package/dist/channel/chunker.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +32 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +58 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel.d.ts +87 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +264 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +211 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +70 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +111 -0
- package/dist/keys.js.map +1 -0
- package/dist/message-gate.d.ts +40 -0
- package/dist/message-gate.d.ts.map +1 -0
- package/dist/message-gate.js +54 -0
- package/dist/message-gate.js.map +1 -0
- package/dist/permission.d.ts +25 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +31 -0
- package/dist/permission.js.map +1 -0
- package/dist/schema.d.ts +96 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +41 -0
- package/dist/schema.js.map +1 -0
- package/dist/setup-wizard.d.ts +15 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +127 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +96 -0
- package/src/bluebubbles-client.ts +182 -0
- package/src/channel/chunker.test.ts +132 -0
- package/src/channel/chunker.ts +144 -0
- package/src/channel/turn-runner.ts +82 -0
- package/src/channel.test.ts +256 -0
- package/src/channel.ts +352 -0
- package/src/index.ts +273 -0
- package/src/keys.test.ts +86 -0
- package/src/keys.ts +109 -0
- package/src/message-gate.test.ts +126 -0
- package/src/message-gate.ts +94 -0
- package/src/permission.ts +40 -0
- package/src/schema.test.ts +51 -0
- package/src/schema.ts +47 -0
- package/src/setup-wizard.ts +155 -0
- package/src/subcommands.test.ts +194 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { defineChannel, definePlugin, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
3
|
+
import { ImessageChannel, type ImessageChannelOptions } from './channel.js';
|
|
4
|
+
import {
|
|
5
|
+
IMESSAGE_ALLOWED_HANDLES_KEY,
|
|
6
|
+
IMESSAGE_OWNER_HANDLES_KEY,
|
|
7
|
+
IMESSAGE_SERVER_PASSWORD_ENV,
|
|
8
|
+
IMESSAGE_SERVER_PASSWORD_KEY,
|
|
9
|
+
IMESSAGE_SERVER_URL_ENV,
|
|
10
|
+
IMESSAGE_SERVER_URL_KEY,
|
|
11
|
+
parseHandleList,
|
|
12
|
+
} from './keys.js';
|
|
13
|
+
import { runImessageWizard } from './setup-wizard.js';
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
ImessageChannel,
|
|
17
|
+
type ImessageChannelOptions,
|
|
18
|
+
type ImessageStartOpts,
|
|
19
|
+
} from './channel.js';
|
|
20
|
+
export { buildImessagePermissionResolver } from './permission.js';
|
|
21
|
+
export {
|
|
22
|
+
BlueBubblesClient,
|
|
23
|
+
makeTempGuid,
|
|
24
|
+
type BlueBubblesClientLike,
|
|
25
|
+
type BlueBubblesClientOptions,
|
|
26
|
+
type SocketLike,
|
|
27
|
+
} from './bluebubbles-client.js';
|
|
28
|
+
export { gateInboundMessage, type GateState, type GateVerdict } from './message-gate.js';
|
|
29
|
+
export { messageSchema, MAX_INBOUND_TEXT_CHARS, type ImessageMessage } from './schema.js';
|
|
30
|
+
export {
|
|
31
|
+
ChunkedSender,
|
|
32
|
+
takeChunk,
|
|
33
|
+
splitForImessage,
|
|
34
|
+
IMESSAGE_CHUNK_SOFT_LIMIT,
|
|
35
|
+
IMESSAGE_CHUNK_HARD_LIMIT,
|
|
36
|
+
} from './channel/chunker.js';
|
|
37
|
+
export {
|
|
38
|
+
IMESSAGE_SERVER_URL_KEY,
|
|
39
|
+
IMESSAGE_SERVER_URL_ENV,
|
|
40
|
+
IMESSAGE_SERVER_PASSWORD_KEY,
|
|
41
|
+
IMESSAGE_SERVER_PASSWORD_ENV,
|
|
42
|
+
IMESSAGE_ALLOWED_HANDLES_KEY,
|
|
43
|
+
IMESSAGE_OWNER_HANDLES_KEY,
|
|
44
|
+
parseHandleList,
|
|
45
|
+
parseDmChatGuid,
|
|
46
|
+
normalizeHandle,
|
|
47
|
+
isHandle,
|
|
48
|
+
E164_RE,
|
|
49
|
+
EMAIL_RE,
|
|
50
|
+
} from './keys.js';
|
|
51
|
+
|
|
52
|
+
export interface BuildImessagePluginOptions {
|
|
53
|
+
/** Host-injected encrypted secret store (available immediately). */
|
|
54
|
+
readonly vault: VaultStore;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the iMessage channel plugin with a host-injected vault (mirrors the
|
|
59
|
+
* Signal/WhatsApp plugins' vault injection).
|
|
60
|
+
*/
|
|
61
|
+
export function buildImessagePlugin(opts: BuildImessagePluginOptions): Plugin {
|
|
62
|
+
return makeImessagePlugin(() => opts.vault);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Discovery-loadable default export: resolves the vault from the inter-plugin
|
|
67
|
+
* service registry in `onInit` (the vault plugin publishes `'vault'`). Requires
|
|
68
|
+
* `@moxxy/plugin-vault` to load first (declared in `package.json`
|
|
69
|
+
* `moxxy.requirements`).
|
|
70
|
+
*/
|
|
71
|
+
export const imessagePlugin: Plugin = (() => {
|
|
72
|
+
let resolved: VaultStore | null = null;
|
|
73
|
+
const getVault = (): VaultStore => {
|
|
74
|
+
if (!resolved) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'@moxxy/plugin-channel-imessage: the "vault" service is unavailable — @moxxy/plugin-vault must load first',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return resolved;
|
|
80
|
+
};
|
|
81
|
+
const hooks: LifecycleHooks = {
|
|
82
|
+
onInit: (ctx) => {
|
|
83
|
+
resolved = ctx.services.require<VaultStore>('vault');
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
return makeImessagePlugin(getVault, hooks);
|
|
87
|
+
})();
|
|
88
|
+
|
|
89
|
+
export default imessagePlugin;
|
|
90
|
+
|
|
91
|
+
function readStringArray(value: unknown): string[] | undefined {
|
|
92
|
+
if (Array.isArray(value)) return value.map((x) => String(x));
|
|
93
|
+
if (typeof value === 'string' && value.trim()) {
|
|
94
|
+
return value
|
|
95
|
+
.split(',')
|
|
96
|
+
.map((s) => s.trim())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function envConfigured(): boolean {
|
|
103
|
+
const url = process.env[IMESSAGE_SERVER_URL_ENV];
|
|
104
|
+
const pass = process.env[IMESSAGE_SERVER_PASSWORD_ENV];
|
|
105
|
+
return typeof url === 'string' && url.trim().length > 0 && typeof pass === 'string' && pass.trim().length > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeImessagePlugin(getVault: () => VaultStore, hooks?: LifecycleHooks): Plugin {
|
|
109
|
+
return definePlugin({
|
|
110
|
+
name: '@moxxy/plugin-channel-imessage',
|
|
111
|
+
version: '0.0.0',
|
|
112
|
+
...(hooks ? { hooks } : {}),
|
|
113
|
+
channels: [
|
|
114
|
+
defineChannel({
|
|
115
|
+
name: 'imessage',
|
|
116
|
+
description:
|
|
117
|
+
'iMessage channel via a localhost BlueBubbles server (macOS only). Allow-listed handles (and your own self-chat) drive the agent; replies go out as buffered chunked sends.',
|
|
118
|
+
// A BlueBubbles server sees ALL the account owner's messages, so this
|
|
119
|
+
// channel runs on its own dedicated, isolated runner (separate socket +
|
|
120
|
+
// sticky session), like Signal — it keeps its own persistent history
|
|
121
|
+
// apart from the user's desktop/TUI work.
|
|
122
|
+
dedicatedRunner: true,
|
|
123
|
+
sessionSource: 'imessage',
|
|
124
|
+
config: {
|
|
125
|
+
fields: [
|
|
126
|
+
{
|
|
127
|
+
name: 'serverUrl',
|
|
128
|
+
label: 'BlueBubbles server URL',
|
|
129
|
+
vaultKey: IMESSAGE_SERVER_URL_KEY,
|
|
130
|
+
required: true,
|
|
131
|
+
secret: false,
|
|
132
|
+
placeholder: 'http://localhost:1234',
|
|
133
|
+
help: 'The BlueBubbles server running on this Mac (default port 1234)',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'password',
|
|
137
|
+
label: 'BlueBubbles server password',
|
|
138
|
+
vaultKey: IMESSAGE_SERVER_PASSWORD_KEY,
|
|
139
|
+
required: true,
|
|
140
|
+
secret: true,
|
|
141
|
+
help: 'The password set in the BlueBubbles app (sent as a query param on every request)',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
hasRequestUrl: false,
|
|
145
|
+
runHint:
|
|
146
|
+
'Install the BlueBubbles server on this Mac, sign in to Messages, set a password, then run `moxxy channels imessage setup` to add allowed handles.',
|
|
147
|
+
connect: {
|
|
148
|
+
kind: 'instructions',
|
|
149
|
+
title: 'Connect BlueBubbles',
|
|
150
|
+
steps: [
|
|
151
|
+
'Install the BlueBubbles server on this Mac from https://bluebubbles.app and sign in to Messages.',
|
|
152
|
+
'In the BlueBubbles app, set a server password and note the port (default 1234).',
|
|
153
|
+
'Run `moxxy channels imessage setup` and paste the URL + password, then add the handles allowed to talk to moxxy.',
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
create: (deps) => {
|
|
158
|
+
const options = deps.options;
|
|
159
|
+
const channelOpts: ImessageChannelOptions = {
|
|
160
|
+
vault: getVault(),
|
|
161
|
+
...(typeof options?.['serverUrl'] === 'string' ? { serverUrl: options['serverUrl'] } : {}),
|
|
162
|
+
...(typeof options?.['password'] === 'string' ? { password: options['password'] } : {}),
|
|
163
|
+
...(() => {
|
|
164
|
+
const tools = readStringArray(options?.['allowedTools']);
|
|
165
|
+
return tools ? { allowedTools: tools } : {};
|
|
166
|
+
})(),
|
|
167
|
+
...(() => {
|
|
168
|
+
const handles = readStringArray(options?.['allowedHandles']);
|
|
169
|
+
return handles ? { allowedHandles: handles } : {};
|
|
170
|
+
})(),
|
|
171
|
+
...(() => {
|
|
172
|
+
const handles = readStringArray(options?.['ownerHandles']);
|
|
173
|
+
return handles ? { ownerHandles: handles } : {};
|
|
174
|
+
})(),
|
|
175
|
+
logger: deps.logger as never,
|
|
176
|
+
};
|
|
177
|
+
return new ImessageChannel(channelOpts);
|
|
178
|
+
},
|
|
179
|
+
isAvailable: async () => {
|
|
180
|
+
// Pure check — NO network probe (this runs on every `moxxy channels
|
|
181
|
+
// list` / `moxxy doctor`). The reachability ping is deferred to start().
|
|
182
|
+
if (process.platform !== 'darwin') {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
reason: 'iMessage requires macOS (a BlueBubbles server running on this Mac).',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// Env pair short-circuit (the vault may not be wired in a probe context).
|
|
189
|
+
if (envConfigured()) return { ok: true };
|
|
190
|
+
try {
|
|
191
|
+
const vault = getVault();
|
|
192
|
+
if ((await vault.has(IMESSAGE_SERVER_URL_KEY)) && (await vault.has(IMESSAGE_SERVER_PASSWORD_KEY))) {
|
|
193
|
+
return { ok: true };
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
/* vault unavailable in a probe context — fall through */
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
reason: 'No BlueBubbles server configured. Run `moxxy channels imessage setup`.',
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
interactiveCommand: 'setup',
|
|
204
|
+
subcommands: {
|
|
205
|
+
setup: {
|
|
206
|
+
description:
|
|
207
|
+
'Interactive setup: store the BlueBubbles server URL + password, the handle allow-list and your own self-chat handles, pick the tool allow-list, then start. Shown by default for `moxxy imessage` on a TTY.',
|
|
208
|
+
run: async (ctx) => {
|
|
209
|
+
if (process.stdin.isTTY !== true) {
|
|
210
|
+
// Headless: just start the channel (server must already be configured).
|
|
211
|
+
return ctx.startChannel();
|
|
212
|
+
}
|
|
213
|
+
return runImessageWizard(ctx);
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
status: {
|
|
217
|
+
description: 'Report platform / server-config / allow-list state as JSON.',
|
|
218
|
+
run: async (ctx) => {
|
|
219
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
220
|
+
if (!vault) {
|
|
221
|
+
process.stderr.write('vault unavailable\n');
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
const serverUrl =
|
|
225
|
+
process.env[IMESSAGE_SERVER_URL_ENV]?.trim() || (await vault.get(IMESSAGE_SERVER_URL_KEY));
|
|
226
|
+
const passwordSet =
|
|
227
|
+
(process.env[IMESSAGE_SERVER_PASSWORD_ENV]?.trim() ?? '').length > 0 ||
|
|
228
|
+
(await vault.has(IMESSAGE_SERVER_PASSWORD_KEY));
|
|
229
|
+
const allowedHandles = parseHandleList(await vault.get(IMESSAGE_ALLOWED_HANDLES_KEY));
|
|
230
|
+
const ownerHandles = parseHandleList(await vault.get(IMESSAGE_OWNER_HANDLES_KEY));
|
|
231
|
+
process.stdout.write(
|
|
232
|
+
JSON.stringify(
|
|
233
|
+
{
|
|
234
|
+
platform: process.platform,
|
|
235
|
+
supported: process.platform === 'darwin',
|
|
236
|
+
serverUrl: serverUrl || null,
|
|
237
|
+
passwordSet,
|
|
238
|
+
allowedHandles,
|
|
239
|
+
ownerHandles,
|
|
240
|
+
},
|
|
241
|
+
null,
|
|
242
|
+
2,
|
|
243
|
+
) + '\n',
|
|
244
|
+
);
|
|
245
|
+
return 0;
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
unpair: {
|
|
249
|
+
description:
|
|
250
|
+
'Forget the stored BlueBubbles server URL + password and the handle allow-list. (The BlueBubbles app itself is untouched — uninstall or reset it there to fully disconnect.)',
|
|
251
|
+
run: async (ctx) => {
|
|
252
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
253
|
+
if (!vault) {
|
|
254
|
+
process.stderr.write('vault unavailable\n');
|
|
255
|
+
return 1;
|
|
256
|
+
}
|
|
257
|
+
const removedUrl = await vault.delete(IMESSAGE_SERVER_URL_KEY);
|
|
258
|
+
await vault.delete(IMESSAGE_SERVER_PASSWORD_KEY);
|
|
259
|
+
await vault.delete(IMESSAGE_ALLOWED_HANDLES_KEY);
|
|
260
|
+
await vault.delete(IMESSAGE_OWNER_HANDLES_KEY);
|
|
261
|
+
process.stdout.write(
|
|
262
|
+
removedUrl
|
|
263
|
+
? 'unpaired (vault cleared). The BlueBubbles server app is untouched — reset it there to fully disconnect.\n'
|
|
264
|
+
: 'no BlueBubbles server was configured\n',
|
|
265
|
+
);
|
|
266
|
+
return 0;
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
}
|
package/src/keys.test.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
E164_RE,
|
|
4
|
+
EMAIL_RE,
|
|
5
|
+
isHandle,
|
|
6
|
+
normalizeHandle,
|
|
7
|
+
parseDmChatGuid,
|
|
8
|
+
parseHandleList,
|
|
9
|
+
} from './keys.js';
|
|
10
|
+
|
|
11
|
+
describe('handle shapes', () => {
|
|
12
|
+
it('E164_RE accepts plausible numbers and rejects junk', () => {
|
|
13
|
+
expect(E164_RE.test('+15551234567')).toBe(true);
|
|
14
|
+
expect(E164_RE.test('+4915771234567')).toBe(true);
|
|
15
|
+
expect(E164_RE.test('15551234567')).toBe(false);
|
|
16
|
+
expect(E164_RE.test('+0155')).toBe(false);
|
|
17
|
+
expect(E164_RE.test('')).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('EMAIL_RE accepts Apple-ID emails and rejects junk', () => {
|
|
21
|
+
expect(EMAIL_RE.test('friend@icloud.com')).toBe(true);
|
|
22
|
+
expect(EMAIL_RE.test('a.b+c@example.co.uk')).toBe(true);
|
|
23
|
+
expect(EMAIL_RE.test('not-an-email')).toBe(false);
|
|
24
|
+
expect(EMAIL_RE.test('two @spaces.com')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('isHandle recognises numbers and emails only', () => {
|
|
28
|
+
expect(isHandle('+15551234567')).toBe(true);
|
|
29
|
+
expect(isHandle('friend@icloud.com')).toBe(true);
|
|
30
|
+
expect(isHandle('bob')).toBe(false);
|
|
31
|
+
expect(isHandle('../../etc/passwd')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('normalizeHandle', () => {
|
|
36
|
+
it('lowercases emails but preserves numbers', () => {
|
|
37
|
+
expect(normalizeHandle(' +15551234567 ')).toBe('+15551234567');
|
|
38
|
+
expect(normalizeHandle(' Friend@ICloud.com ')).toBe('friend@icloud.com');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('parseHandleList', () => {
|
|
43
|
+
it('parses a JSON array of numbers/emails', () => {
|
|
44
|
+
const raw = JSON.stringify(['+15551234567', 'Friend@iCloud.com']);
|
|
45
|
+
expect(parseHandleList(raw)).toEqual(['+15551234567', 'friend@icloud.com']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('fails closed on missing or corrupt values', () => {
|
|
49
|
+
expect(parseHandleList(null)).toEqual([]);
|
|
50
|
+
expect(parseHandleList(undefined)).toEqual([]);
|
|
51
|
+
expect(parseHandleList('')).toEqual([]);
|
|
52
|
+
expect(parseHandleList('not json')).toEqual([]);
|
|
53
|
+
expect(parseHandleList('"a string"')).toEqual([]);
|
|
54
|
+
expect(parseHandleList('{"a":1}')).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('drops entries that are neither E.164 nor email, and de-dupes', () => {
|
|
58
|
+
const raw = JSON.stringify(['+15551234567', 'bob', 42, '../../etc/passwd', '+15551234567']);
|
|
59
|
+
expect(parseHandleList(raw)).toEqual(['+15551234567']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('parseDmChatGuid', () => {
|
|
64
|
+
it('parses a 1:1 chat guid', () => {
|
|
65
|
+
expect(parseDmChatGuid('iMessage;-;+15551234567')).toEqual({
|
|
66
|
+
service: 'iMessage',
|
|
67
|
+
handle: '+15551234567',
|
|
68
|
+
});
|
|
69
|
+
expect(parseDmChatGuid('SMS;-;Friend@iCloud.com')).toEqual({
|
|
70
|
+
service: 'SMS',
|
|
71
|
+
handle: 'friend@icloud.com',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects group guids (v1 is DM-only)', () => {
|
|
76
|
+
expect(parseDmChatGuid('iMessage;+;chat1234567890')).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects malformed / non-handle guids', () => {
|
|
80
|
+
expect(parseDmChatGuid(null)).toBeNull();
|
|
81
|
+
expect(parseDmChatGuid('')).toBeNull();
|
|
82
|
+
expect(parseDmChatGuid('iMessage;-;')).toBeNull();
|
|
83
|
+
expect(parseDmChatGuid('iMessage;-;not-a-handle')).toBeNull();
|
|
84
|
+
expect(parseDmChatGuid('iMessage;-;+1;extra')).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault keys + pure handle helpers shared by the channel, its subcommands, and
|
|
3
|
+
* the interactive setup wizard. Kept in their own module (the signal/whatsapp
|
|
4
|
+
* `keys.ts` pattern) so wizard helpers can import them without pulling in the
|
|
5
|
+
* plugin's full index (or, worse, socket.io-client — which stays behind a lazy
|
|
6
|
+
* import in `bluebubbles-client.ts`).
|
|
7
|
+
*
|
|
8
|
+
* Note on custody: the BlueBubbles server (a native macOS app) holds the actual
|
|
9
|
+
* iMessage identity + message store. The moxxy vault only stores the localhost
|
|
10
|
+
* server URL, its password, and two JSON handle arrays — the allow-list of OTHER
|
|
11
|
+
* people who may drive the agent, and the owner's own handle(s) that enable
|
|
12
|
+
* texting moxxy from your own devices (the "self-chat").
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Vault key for the BlueBubbles server URL (e.g. http://localhost:1234). */
|
|
16
|
+
export const IMESSAGE_SERVER_URL_KEY = 'imessage_server_url';
|
|
17
|
+
/** Env override for the server URL — beats the vault (shared precedence). */
|
|
18
|
+
export const IMESSAGE_SERVER_URL_ENV = 'MOXXY_IMESSAGE_SERVER_URL';
|
|
19
|
+
/** Vault key for the BlueBubbles server password. */
|
|
20
|
+
export const IMESSAGE_SERVER_PASSWORD_KEY = 'imessage_server_password';
|
|
21
|
+
/** Env override for the server password — beats the vault. */
|
|
22
|
+
export const IMESSAGE_SERVER_PASSWORD_ENV = 'MOXXY_IMESSAGE_SERVER_PASSWORD';
|
|
23
|
+
/**
|
|
24
|
+
* Vault key for the allow-list: a JSON array of iMessage handles (E.164 phone
|
|
25
|
+
* numbers and/or Apple-ID emails) that OTHER people may use to drive the
|
|
26
|
+
* session. The owner's own handle is NOT listed here — it lives in
|
|
27
|
+
* {@link IMESSAGE_OWNER_HANDLES_KEY} so an inbound from a friend and an outbound
|
|
28
|
+
* from the owner stay distinguishable (never react to the owner's private
|
|
29
|
+
* conversations with allow-listed friends).
|
|
30
|
+
*/
|
|
31
|
+
export const IMESSAGE_ALLOWED_HANDLES_KEY = 'imessage_allowed_handles';
|
|
32
|
+
/**
|
|
33
|
+
* Vault key for the owner's OWN handle(s): a JSON array of the account owner's
|
|
34
|
+
* E.164 numbers / Apple-ID emails. A message the account itself sent
|
|
35
|
+
* (`isFromMe`) drives a turn only when it lands in a 1:1 chat with one of these
|
|
36
|
+
* handles — i.e. the owner texting their own "self-chat" from any Apple device.
|
|
37
|
+
* Empty (the default) means self-chat is off and every `isFromMe` message is
|
|
38
|
+
* dropped (fail closed); the friend allow-list path is unaffected.
|
|
39
|
+
*/
|
|
40
|
+
export const IMESSAGE_OWNER_HANDLES_KEY = 'imessage_owner_handles';
|
|
41
|
+
|
|
42
|
+
/** E.164 shape: `+` then 7–15 digits, no leading zero. */
|
|
43
|
+
export const E164_RE = /^\+[1-9]\d{6,14}$/;
|
|
44
|
+
|
|
45
|
+
/** Pragmatic email shape (Apple-ID handles). Deliberately loose but anchored. */
|
|
46
|
+
export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
47
|
+
|
|
48
|
+
/** Whether a normalized string is a plausible iMessage handle. */
|
|
49
|
+
export function isHandle(value: string): boolean {
|
|
50
|
+
return E164_RE.test(value) || EMAIL_RE.test(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Canonical handle for allow-list comparisons: trimmed, and lowercased when it
|
|
55
|
+
* is an email (Apple-ID handles are case-insensitive). Phone numbers are left
|
|
56
|
+
* as-is (they are already digits + `+`).
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeHandle(raw: string): string {
|
|
59
|
+
const trimmed = raw.trim();
|
|
60
|
+
return trimmed.includes('@') ? trimmed.toLowerCase() : trimmed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a stored handle list. Returns `[]` for a missing OR corrupt value
|
|
65
|
+
* rather than throwing — a corrupt vault entry must degrade to "nobody extra is
|
|
66
|
+
* allowed" (fail closed), never crash the channel or silently allow. Non-string
|
|
67
|
+
* entries and entries that are neither an E.164 number nor an email are dropped.
|
|
68
|
+
*/
|
|
69
|
+
export function parseHandleList(raw: string | null | undefined): string[] {
|
|
70
|
+
if (!raw) return [];
|
|
71
|
+
let parsed: unknown;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(raw);
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(parsed)) return [];
|
|
78
|
+
const out = new Set<string>();
|
|
79
|
+
for (const entry of parsed) {
|
|
80
|
+
if (typeof entry !== 'string') continue;
|
|
81
|
+
const handle = normalizeHandle(entry);
|
|
82
|
+
if (isHandle(handle)) out.add(handle);
|
|
83
|
+
}
|
|
84
|
+
return [...out];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse the chat GUID BlueBubbles stamps on every message
|
|
89
|
+
* (`SERVICE;TYPE;IDENTIFIER`, e.g. `iMessage;-;+15551234567`). TYPE is `-` for a
|
|
90
|
+
* 1:1 chat and `+` for a group. Returns null for anything that is not a
|
|
91
|
+
* well-formed 1:1 GUID (groups included) so callers treat it as "not a DM" and
|
|
92
|
+
* drop it (v1 is direct-message only).
|
|
93
|
+
*/
|
|
94
|
+
export function parseDmChatGuid(
|
|
95
|
+
guid: string | null | undefined,
|
|
96
|
+
): { readonly service: string; readonly handle: string } | null {
|
|
97
|
+
if (typeof guid !== 'string') return null;
|
|
98
|
+
const parts = guid.split(';');
|
|
99
|
+
if (parts.length !== 3) return null;
|
|
100
|
+
const [rawService, rawType, rawIdentifier] = parts;
|
|
101
|
+
if (rawService === undefined || rawType === undefined || rawIdentifier === undefined) return null;
|
|
102
|
+
const service = rawService.trim();
|
|
103
|
+
const type = rawType.trim();
|
|
104
|
+
const identifier = rawIdentifier.trim();
|
|
105
|
+
if (service.length === 0 || type !== '-' || identifier.length === 0) return null;
|
|
106
|
+
const handle = normalizeHandle(identifier);
|
|
107
|
+
if (!isHandle(handle)) return null;
|
|
108
|
+
return { service, handle };
|
|
109
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { gateInboundMessage, type GateState } from './message-gate.js';
|
|
3
|
+
import { MAX_INBOUND_TEXT_CHARS } from './schema.js';
|
|
4
|
+
|
|
5
|
+
const OWNER = '+19998887777';
|
|
6
|
+
const FRIEND = '+15550001111';
|
|
7
|
+
const STRANGER = '+14440002222';
|
|
8
|
+
|
|
9
|
+
const chatFor = (handle: string): string => `iMessage;-;${handle}`;
|
|
10
|
+
|
|
11
|
+
function state(overrides: Partial<GateState> = {}): GateState {
|
|
12
|
+
return {
|
|
13
|
+
ownerHandles: new Set([OWNER]),
|
|
14
|
+
allowedHandles: new Set([FRIEND]),
|
|
15
|
+
isOwnSend: () => false,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function msg(opts: {
|
|
21
|
+
chatGuid: string;
|
|
22
|
+
text?: string | null;
|
|
23
|
+
isFromMe?: boolean;
|
|
24
|
+
sender?: string;
|
|
25
|
+
guid?: string;
|
|
26
|
+
tempGuid?: string;
|
|
27
|
+
}): Record<string, unknown> {
|
|
28
|
+
return {
|
|
29
|
+
guid: opts.guid ?? 'msg-1',
|
|
30
|
+
...(opts.tempGuid ? { tempGuid: opts.tempGuid } : {}),
|
|
31
|
+
text: opts.text ?? 'hi',
|
|
32
|
+
isFromMe: opts.isFromMe ?? false,
|
|
33
|
+
...(opts.sender ? { handle: { address: opts.sender } } : {}),
|
|
34
|
+
chats: [{ guid: opts.chatGuid }],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('gateInboundMessage', () => {
|
|
39
|
+
it('accepts an allow-listed sender text message and targets their chat', () => {
|
|
40
|
+
const v = gateInboundMessage(state(), msg({ chatGuid: chatFor(FRIEND), text: 'hi there', sender: FRIEND }));
|
|
41
|
+
expect(v).toEqual({ ok: true, chatGuid: chatFor(FRIEND), text: 'hi there' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("accepts the owner's own self-chat message (isFromMe in an owner chat)", () => {
|
|
45
|
+
const v = gateInboundMessage(state(), msg({ chatGuid: chatFor(OWNER), text: 'note to self', isFromMe: true }));
|
|
46
|
+
expect(v).toEqual({ ok: true, chatGuid: chatFor(OWNER), text: 'note to self' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('drops an isFromMe message in a FOREIGN chat (owner talking to others)', () => {
|
|
50
|
+
const v = gateInboundMessage(state(), msg({ chatGuid: chatFor(FRIEND), isFromMe: true }));
|
|
51
|
+
expect(v).toEqual({ ok: false, reason: 'own message in a foreign chat' });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("drops the bot's OWN outbound echo by guid (loop protection)", () => {
|
|
55
|
+
const st = state({ isOwnSend: (id) => id === 'echo-guid' });
|
|
56
|
+
const v = gateInboundMessage(
|
|
57
|
+
st,
|
|
58
|
+
msg({ chatGuid: chatFor(OWNER), isFromMe: true, guid: 'echo-guid' }),
|
|
59
|
+
);
|
|
60
|
+
expect(v).toEqual({ ok: false, reason: 'own outbound echo (guid)' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("drops the bot's OWN outbound echo by tempGuid", () => {
|
|
64
|
+
const st = state({ isOwnSend: (id) => id === 'temp-xyz' });
|
|
65
|
+
const v = gateInboundMessage(
|
|
66
|
+
st,
|
|
67
|
+
msg({ chatGuid: chatFor(FRIEND), sender: FRIEND, guid: 'g2', tempGuid: 'temp-xyz' }),
|
|
68
|
+
);
|
|
69
|
+
expect(v).toEqual({ ok: false, reason: 'own outbound echo (tempGuid)' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('drops an un-allow-listed sender (silent — no reply)', () => {
|
|
73
|
+
const v = gateInboundMessage(state(), msg({ chatGuid: chatFor(STRANGER), sender: STRANGER }));
|
|
74
|
+
expect(v).toEqual({ ok: false, reason: 'sender not allow-listed' });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('drops group messages (v1 is direct-message only)', () => {
|
|
78
|
+
const v = gateInboundMessage(state(), msg({ chatGuid: 'iMessage;+;chat123', sender: FRIEND }));
|
|
79
|
+
expect(v).toEqual({ ok: false, reason: 'not a 1:1 direct message' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('resolves the sender from the chat guid when the message has no handle', () => {
|
|
83
|
+
const v = gateInboundMessage(state(), msg({ chatGuid: chatFor(FRIEND) }));
|
|
84
|
+
expect(v).toEqual({ ok: true, chatGuid: chatFor(FRIEND), text: 'hi' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('drops self-chat when no owner handles are configured (fail closed)', () => {
|
|
88
|
+
const v = gateInboundMessage(
|
|
89
|
+
state({ ownerHandles: new Set() }),
|
|
90
|
+
msg({ chatGuid: chatFor(OWNER), isFromMe: true }),
|
|
91
|
+
);
|
|
92
|
+
expect(v).toEqual({ ok: false, reason: 'own message in a foreign chat' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('drops schema-invalid payloads without throwing', () => {
|
|
96
|
+
expect(gateInboundMessage(state(), null)).toEqual({ ok: false, reason: 'invalid message shape' });
|
|
97
|
+
expect(gateInboundMessage(state(), 'garbage')).toEqual({
|
|
98
|
+
ok: false,
|
|
99
|
+
reason: 'invalid message shape',
|
|
100
|
+
});
|
|
101
|
+
expect(gateInboundMessage(state(), { text: 'no guid', chats: [] })).toEqual({
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: 'invalid message shape',
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('drops a message with no chat', () => {
|
|
108
|
+
expect(gateInboundMessage(state(), { guid: 'm', text: 'x' })).toEqual({
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: 'message without a chat',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects oversized text at the schema boundary', () => {
|
|
115
|
+
const v = gateInboundMessage(
|
|
116
|
+
state(),
|
|
117
|
+
msg({ chatGuid: chatFor(FRIEND), sender: FRIEND, text: 'a'.repeat(MAX_INBOUND_TEXT_CHARS + 1) }),
|
|
118
|
+
);
|
|
119
|
+
expect(v).toEqual({ ok: false, reason: 'invalid message shape' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('drops empty / whitespace-only text', () => {
|
|
123
|
+
const v = gateInboundMessage(state(), msg({ chatGuid: chatFor(FRIEND), sender: FRIEND, text: ' ' }));
|
|
124
|
+
expect(v).toEqual({ ok: false, reason: 'no message text' });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { normalizeHandle, parseDmChatGuid } from './keys.js';
|
|
2
|
+
import { messageSchema } from './schema.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single inbound gate every session-reaching path goes through (AGENTS.md:
|
|
6
|
+
* gate EVERY session-reaching path behind pairing; A8: zod-validate inbound
|
|
7
|
+
* before it touches the session). Pure — the channel feeds it the raw
|
|
8
|
+
* BlueBubbles `new-message` payload plus its identity/allow-list state and
|
|
9
|
+
* branches on the verdict.
|
|
10
|
+
*
|
|
11
|
+
* Order of checks (each is load-bearing, mirroring the WhatsApp gate):
|
|
12
|
+
* 1. shape-validate + size-cap the consumed fields (zod).
|
|
13
|
+
* 2. drop own echoes: BlueBubbles emits `new-message` for the account's OWN
|
|
14
|
+
* sends too (our replies come back `isFromMe`), so without the sent-id check
|
|
15
|
+
* the bot would answer itself in a loop — and a self-chat reply would be
|
|
16
|
+
* mistaken for a fresh owner prompt.
|
|
17
|
+
* 3. direct messages only (v1): a group chat GUID is dropped — group fan-in
|
|
18
|
+
* needs its own trust story.
|
|
19
|
+
* 4. drop `isFromMe` messages outside the owner's self-chat: those are the
|
|
20
|
+
* owner talking to OTHER people from an Apple device — never a prompt. Only
|
|
21
|
+
* `isFromMe` in a 1:1 chat with one of the owner's own handles drives a turn.
|
|
22
|
+
* 5. allow-list by normalized handle for inbound (non-`isFromMe`) messages.
|
|
23
|
+
* Unauthorized senders are dropped SILENTLY — replying would leak the bot's
|
|
24
|
+
* existence.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type GateVerdict =
|
|
28
|
+
| { readonly ok: false; readonly reason: string }
|
|
29
|
+
| { readonly ok: true; readonly chatGuid: string; readonly text: string };
|
|
30
|
+
|
|
31
|
+
export interface GateState {
|
|
32
|
+
/** The owner's own normalized handle(s) — the self-chat identities. */
|
|
33
|
+
readonly ownerHandles: ReadonlySet<string>;
|
|
34
|
+
/** Normalized handles of OTHER people allowed to drive the session. */
|
|
35
|
+
readonly allowedHandles: ReadonlySet<string>;
|
|
36
|
+
/** True for a guid/tempGuid of THIS channel's own recent send (echo/loop protection). */
|
|
37
|
+
readonly isOwnSend: (id: string) => boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function gateInboundMessage(state: GateState, raw: unknown): GateVerdict {
|
|
41
|
+
const parsed = messageSchema.safeParse(raw);
|
|
42
|
+
if (!parsed.success) return drop('invalid message shape');
|
|
43
|
+
const message = parsed.data;
|
|
44
|
+
|
|
45
|
+
const chats = message.chats;
|
|
46
|
+
const firstChat = chats && chats.length > 0 ? chats[0] : undefined;
|
|
47
|
+
if (!firstChat) return drop('message without a chat');
|
|
48
|
+
const chatGuid = firstChat.guid;
|
|
49
|
+
|
|
50
|
+
// Own-echo drop FIRST — a reply we sent into a self-chat comes back isFromMe
|
|
51
|
+
// and would otherwise re-enter as an owner prompt (loop).
|
|
52
|
+
if (state.isOwnSend(message.guid)) return drop('own outbound echo (guid)');
|
|
53
|
+
const tempGuid = message.tempGuid;
|
|
54
|
+
if (tempGuid && state.isOwnSend(tempGuid)) return drop('own outbound echo (tempGuid)');
|
|
55
|
+
|
|
56
|
+
const dm = parseDmChatGuid(chatGuid);
|
|
57
|
+
if (!dm) return drop('not a 1:1 direct message');
|
|
58
|
+
|
|
59
|
+
if (message.isFromMe === true) {
|
|
60
|
+
// Same-account messages: only the owner's own self-chat is a prompt surface.
|
|
61
|
+
if (!state.ownerHandles.has(dm.handle)) {
|
|
62
|
+
return drop('own message in a foreign chat');
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
const senderHandle = resolveSender(message.handle, dm.handle);
|
|
66
|
+
if (!state.allowedHandles.has(senderHandle)) {
|
|
67
|
+
return drop('sender not allow-listed');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const text = typeof message.text === 'string' ? message.text.trim() : '';
|
|
72
|
+
if (text.length === 0) return drop('no message text');
|
|
73
|
+
|
|
74
|
+
return { ok: true, chatGuid, text };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The sender's handle for an inbound message: the message's own `handle`
|
|
79
|
+
* address when present, else the 1:1 chat's counterpart (they are the same
|
|
80
|
+
* party in a DM). Narrowed once, then plain access — no optional-chain depth.
|
|
81
|
+
*/
|
|
82
|
+
function resolveSender(
|
|
83
|
+
handle: { readonly address?: string | null } | null | undefined,
|
|
84
|
+
chatHandle: string,
|
|
85
|
+
): string {
|
|
86
|
+
if (handle && typeof handle.address === 'string' && handle.address.length > 0) {
|
|
87
|
+
return normalizeHandle(handle.address);
|
|
88
|
+
}
|
|
89
|
+
return chatHandle;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function drop(reason: string): GateVerdict {
|
|
93
|
+
return { ok: false, reason };
|
|
94
|
+
}
|