@moxxy/plugin-channel-signal 0.27.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/dist/channel/chunker.d.ts +69 -0
  3. package/dist/channel/chunker.d.ts.map +1 -0
  4. package/dist/channel/chunker.js +133 -0
  5. package/dist/channel/chunker.js.map +1 -0
  6. package/dist/channel/turn-runner.d.ts +33 -0
  7. package/dist/channel/turn-runner.d.ts.map +1 -0
  8. package/dist/channel/turn-runner.js +70 -0
  9. package/dist/channel/turn-runner.js.map +1 -0
  10. package/dist/channel/voice.d.ts +37 -0
  11. package/dist/channel/voice.d.ts.map +1 -0
  12. package/dist/channel/voice.js +88 -0
  13. package/dist/channel/voice.js.map +1 -0
  14. package/dist/channel.d.ts +154 -0
  15. package/dist/channel.d.ts.map +1 -0
  16. package/dist/channel.js +468 -0
  17. package/dist/channel.js.map +1 -0
  18. package/dist/index.d.ts +28 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +215 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/jsonrpc.d.ts +53 -0
  23. package/dist/jsonrpc.d.ts.map +1 -0
  24. package/dist/jsonrpc.js +167 -0
  25. package/dist/jsonrpc.js.map +1 -0
  26. package/dist/keys.d.ts +37 -0
  27. package/dist/keys.d.ts.map +1 -0
  28. package/dist/keys.js +55 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/pair-flow.d.ts +21 -0
  31. package/dist/pair-flow.d.ts.map +1 -0
  32. package/dist/pair-flow.js +136 -0
  33. package/dist/pair-flow.js.map +1 -0
  34. package/dist/permission.d.ts +21 -0
  35. package/dist/permission.d.ts.map +1 -0
  36. package/dist/permission.js +27 -0
  37. package/dist/permission.js.map +1 -0
  38. package/dist/schema.d.ts +4295 -0
  39. package/dist/schema.d.ts.map +1 -0
  40. package/dist/schema.js +84 -0
  41. package/dist/schema.js.map +1 -0
  42. package/dist/setup-wizard.d.ts +14 -0
  43. package/dist/setup-wizard.d.ts.map +1 -0
  44. package/dist/setup-wizard.js +85 -0
  45. package/dist/setup-wizard.js.map +1 -0
  46. package/dist/sidecar.d.ts +137 -0
  47. package/dist/sidecar.d.ts.map +1 -0
  48. package/dist/sidecar.js +421 -0
  49. package/dist/sidecar.js.map +1 -0
  50. package/package.json +89 -0
  51. package/src/channel/chunker.test.ts +135 -0
  52. package/src/channel/chunker.ts +147 -0
  53. package/src/channel/turn-runner.ts +97 -0
  54. package/src/channel/voice.test.ts +136 -0
  55. package/src/channel/voice.ts +118 -0
  56. package/src/channel.test.ts +364 -0
  57. package/src/channel.ts +607 -0
  58. package/src/index.ts +289 -0
  59. package/src/jsonrpc.test.ts +128 -0
  60. package/src/jsonrpc.ts +198 -0
  61. package/src/keys.test.ts +45 -0
  62. package/src/keys.ts +56 -0
  63. package/src/pair-flow.ts +161 -0
  64. package/src/permission.ts +36 -0
  65. package/src/schema.ts +98 -0
  66. package/src/setup-wizard.ts +98 -0
  67. package/src/sidecar.test.ts +276 -0
  68. package/src/sidecar.ts +511 -0
  69. package/src/subcommands.test.ts +206 -0
@@ -0,0 +1,364 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ClientSession as Session } from '@moxxy/sdk';
3
+ import type { VaultStore } from '@moxxy/plugin-vault';
4
+ import { SignalChannel, type SignalRpcLike } from './channel.js';
5
+ import { SIGNAL_ACCOUNT_KEY, SIGNAL_ALLOWED_SENDERS_KEY } from './keys.js';
6
+ import { MAX_INBOUND_TEXT_CHARS } from './schema.js';
7
+
8
+ const OWNER = '+19998887777';
9
+ const FRIEND = '+15550001111';
10
+ const STRANGER = '+14440002222';
11
+
12
+ function stubVault(seed: Record<string, string> = {}): VaultStore {
13
+ const map = new Map(Object.entries(seed));
14
+ return {
15
+ get: async (k: string) => map.get(k) ?? null,
16
+ set: async (k: string, v: string) => {
17
+ map.set(k, v);
18
+ },
19
+ has: async (k: string) => map.has(k),
20
+ delete: async (k: string) => map.delete(k),
21
+ } as unknown as VaultStore;
22
+ }
23
+
24
+ function makeFakeSession(opts: { hangTurns?: boolean } = {}) {
25
+ const listeners = new Set<(e: unknown) => void>();
26
+ const prompts: string[] = [];
27
+ let release: (() => void) | null = null;
28
+ const session = {
29
+ tools: { list: () => [{ name: 'Read' }, { name: 'Grep' }] },
30
+ transcribers: { tryGetActive: () => null },
31
+ setPermissionResolver: vi.fn(),
32
+ log: {
33
+ subscribe: (fn: (e: unknown) => void) => {
34
+ listeners.add(fn);
35
+ return () => listeners.delete(fn);
36
+ },
37
+ },
38
+ runTurn(prompt: string) {
39
+ prompts.push(prompt);
40
+ const hang = opts.hangTurns;
41
+ return (async function* () {
42
+ if (hang) {
43
+ await new Promise<void>((resolve) => {
44
+ release = resolve;
45
+ });
46
+ }
47
+ })();
48
+ },
49
+ };
50
+ return {
51
+ session: session as unknown as Session,
52
+ raw: session,
53
+ prompts,
54
+ emitLog: (e: unknown) => {
55
+ for (const fn of listeners) fn(e);
56
+ },
57
+ releaseTurn: () => release?.(),
58
+ };
59
+ }
60
+
61
+ class FakeRpc implements SignalRpcLike {
62
+ requests: Array<{ method: string; params: Record<string, unknown> }> = [];
63
+ private readonly notif = new Map<string, Set<(p: unknown) => void>>();
64
+ private ts = 1_000;
65
+ async request(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
66
+ this.requests.push({ method, params });
67
+ if (method === 'send') return { timestamp: this.ts++ };
68
+ return {};
69
+ }
70
+ onNotification(method: string, listener: (params: unknown) => void): () => void {
71
+ let set = this.notif.get(method);
72
+ if (!set) {
73
+ set = new Set();
74
+ this.notif.set(method, set);
75
+ }
76
+ set.add(listener);
77
+ return () => set.delete(listener);
78
+ }
79
+ onClose(): () => void {
80
+ return () => undefined;
81
+ }
82
+ close(): void {}
83
+ /** Push one inbound `receive` notification into the channel. */
84
+ receive(params: unknown): void {
85
+ for (const fn of this.notif.get('receive') ?? []) fn(params);
86
+ }
87
+ sends(): Array<Record<string, unknown>> {
88
+ return this.requests.filter((r) => r.method === 'send').map((r) => r.params);
89
+ }
90
+ }
91
+
92
+ function makeChannel(overrides: {
93
+ vault?: VaultStore;
94
+ hangTurns?: boolean;
95
+ allowedTools?: string[];
96
+ } = {}) {
97
+ const rpc = new FakeRpc();
98
+ const sidecarStops: number[] = [];
99
+ const sidecarAccounts: string[] = [];
100
+ const fake = makeFakeSession({ ...(overrides.hangTurns ? { hangTurns: true } : {}) });
101
+ const channel = new SignalChannel({
102
+ vault:
103
+ overrides.vault ??
104
+ stubVault({ [SIGNAL_ALLOWED_SENDERS_KEY]: JSON.stringify([FRIEND]) }),
105
+ account: OWNER,
106
+ ...(overrides.allowedTools ? { allowedTools: overrides.allowedTools } : {}),
107
+ findBinaryFn: () => '/fake/bin/signal-cli',
108
+ listAccountsFn: async () => [OWNER],
109
+ sidecarFactory: ({ account }) => {
110
+ sidecarAccounts.push(account);
111
+ return {
112
+ start: async () => rpc,
113
+ stop: async () => {
114
+ sidecarStops.push(1);
115
+ },
116
+ onExit: () => () => undefined,
117
+ };
118
+ },
119
+ });
120
+ return { channel, rpc, fake, sidecarStops, sidecarAccounts };
121
+ }
122
+
123
+ const dataEnvelope = (from: string, message: string | null, extra: Record<string, unknown> = {}) => ({
124
+ account: OWNER,
125
+ envelope: {
126
+ sourceNumber: from,
127
+ sourceUuid: null,
128
+ timestamp: 1,
129
+ dataMessage: { timestamp: 1, message, ...extra },
130
+ },
131
+ });
132
+
133
+ const noteToSelfEnvelope = (message: string, timestamp = 7_777) => ({
134
+ account: OWNER,
135
+ envelope: {
136
+ sourceNumber: OWNER,
137
+ syncMessage: { sentMessage: { timestamp, message, destinationNumber: OWNER } },
138
+ },
139
+ });
140
+
141
+ describe('SignalChannel start()', () => {
142
+ it('swaps in the real allow-list permission resolver on the session', async () => {
143
+ const { channel, fake } = makeChannel({ allowedTools: ['Read'] });
144
+ const handle = await channel.start({ session: fake.session });
145
+ expect(fake.raw.setPermissionResolver).toHaveBeenCalledWith(channel.permissionResolver);
146
+ expect(channel.permissionResolver.name).toBe('signal-allow-list');
147
+ expect(channel.connected).toBe(true);
148
+ await handle.stop();
149
+ });
150
+
151
+ it('throws a pairing hint when unlinked and not in pair/dedicated mode', async () => {
152
+ const { fake } = makeChannel();
153
+ const channel = new SignalChannel({
154
+ vault: stubVault(),
155
+ account: OWNER,
156
+ findBinaryFn: () => '/fake/bin/signal-cli',
157
+ listAccountsFn: async () => [], // nothing linked locally
158
+ sidecarFactory: () => {
159
+ throw new Error('daemon must not boot when unlinked');
160
+ },
161
+ });
162
+ await expect(channel.start({ session: fake.session })).rejects.toThrow(/signal pair/);
163
+ });
164
+
165
+ it('throws the install hint when signal-cli is missing', async () => {
166
+ const { fake } = makeChannel();
167
+ const channel = new SignalChannel({
168
+ vault: stubVault(),
169
+ account: OWNER,
170
+ findBinaryFn: () => null,
171
+ });
172
+ await expect(channel.start({ session: fake.session })).rejects.toThrow(/signal-cli not found/);
173
+ });
174
+ });
175
+
176
+ describe('inbound gating (every session-reaching path)', () => {
177
+ it('runs a turn for an allow-listed sender and replies to them', async () => {
178
+ const { channel, rpc, fake } = makeChannel();
179
+ const handle = await channel.start({ session: fake.session });
180
+ rpc.receive(dataEnvelope(FRIEND, 'hello moxxy'));
181
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['hello moxxy']));
182
+ await vi.waitFor(() => {
183
+ const sends = rpc.sends();
184
+ expect(sends.length).toBeGreaterThan(0);
185
+ expect(sends.at(-1)).toEqual({ recipient: [FRIEND], message: '(no output)' });
186
+ });
187
+ await handle.stop();
188
+ });
189
+
190
+ it('silently drops senders that are not allow-listed', async () => {
191
+ const { channel, rpc, fake } = makeChannel();
192
+ const handle = await channel.start({ session: fake.session });
193
+ rpc.receive(dataEnvelope(STRANGER, 'let me in'));
194
+ await new Promise((r) => setTimeout(r, 20));
195
+ expect(fake.prompts).toEqual([]);
196
+ expect(rpc.sends()).toEqual([]); // no reply — a reply would leak the bot's existence
197
+ await handle.stop();
198
+ });
199
+
200
+ it('drops group messages (v1 is direct-message only)', async () => {
201
+ const { channel, rpc, fake } = makeChannel();
202
+ const handle = await channel.start({ session: fake.session });
203
+ rpc.receive(dataEnvelope(FRIEND, 'in a group', { groupInfo: { groupId: 'g1' } }));
204
+ await new Promise((r) => setTimeout(r, 20));
205
+ expect(fake.prompts).toEqual([]);
206
+ await handle.stop();
207
+ });
208
+
209
+ it('drops data messages from our own account (echo path)', async () => {
210
+ const { channel, rpc, fake } = makeChannel();
211
+ const handle = await channel.start({ session: fake.session });
212
+ rpc.receive(dataEnvelope(OWNER, 'talking to myself'));
213
+ await new Promise((r) => setTimeout(r, 20));
214
+ expect(fake.prompts).toEqual([]);
215
+ await handle.stop();
216
+ });
217
+
218
+ it('accepts the owner Note-to-Self and replies into it', async () => {
219
+ const { channel, rpc, fake } = makeChannel();
220
+ const handle = await channel.start({ session: fake.session });
221
+ rpc.receive(noteToSelfEnvelope('remind me to stretch'));
222
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['remind me to stretch']));
223
+ await vi.waitFor(() => {
224
+ expect(rpc.sends().at(-1)).toEqual({ noteToSelf: true, message: '(no output)' });
225
+ });
226
+ await handle.stop();
227
+ });
228
+
229
+ it("ignores the owner's outbound messages to other people", async () => {
230
+ const { channel, rpc, fake } = makeChannel();
231
+ const handle = await channel.start({ session: fake.session });
232
+ rpc.receive({
233
+ account: OWNER,
234
+ envelope: {
235
+ sourceNumber: OWNER,
236
+ syncMessage: {
237
+ sentMessage: { timestamp: 5, message: 'private chat', destinationNumber: FRIEND },
238
+ },
239
+ },
240
+ });
241
+ await new Promise((r) => setTimeout(r, 20));
242
+ expect(fake.prompts).toEqual([]);
243
+ await handle.stop();
244
+ });
245
+
246
+ it('drops sync echoes of its own sends (loop protection)', async () => {
247
+ const { channel, rpc, fake } = makeChannel();
248
+ const handle = await channel.start({ session: fake.session });
249
+
250
+ // Run one Note-to-Self turn so the channel records its send timestamp.
251
+ rpc.receive(noteToSelfEnvelope('first', 1));
252
+ await vi.waitFor(() => expect(rpc.sends().length).toBe(1));
253
+ const echoedTimestamp = 1_000; // FakeRpc's first send timestamp
254
+
255
+ // The linked-device echo of OUR reply comes back as a sync sentMessage
256
+ // with the timestamp our send returned — must NOT trigger a turn.
257
+ rpc.receive(noteToSelfEnvelope('(no output)', echoedTimestamp));
258
+ await new Promise((r) => setTimeout(r, 20));
259
+ expect(fake.prompts).toEqual(['first']);
260
+
261
+ // A genuinely new Note-to-Self message still gets through.
262
+ rpc.receive(noteToSelfEnvelope('second', 2));
263
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['first', 'second']));
264
+ await handle.stop();
265
+ });
266
+
267
+ it('drops schema-invalid and oversized payloads without crashing', async () => {
268
+ const { channel, rpc, fake } = makeChannel();
269
+ const handle = await channel.start({ session: fake.session });
270
+ rpc.receive(null);
271
+ rpc.receive('garbage');
272
+ rpc.receive({ envelope: { dataMessage: { message: 42 } } });
273
+ rpc.receive(dataEnvelope(FRIEND, 'x'.repeat(MAX_INBOUND_TEXT_CHARS + 1)));
274
+ await new Promise((r) => setTimeout(r, 20));
275
+ expect(fake.prompts).toEqual([]);
276
+ // Still alive: a valid message afterwards works.
277
+ rpc.receive(dataEnvelope(FRIEND, 'still alive?'));
278
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['still alive?']));
279
+ await handle.stop();
280
+ });
281
+
282
+ it('refuses overlapping turns with a busy reply (single-flight)', async () => {
283
+ const { channel, rpc, fake } = makeChannel({ hangTurns: true });
284
+ const handle = await channel.start({ session: fake.session });
285
+ rpc.receive(dataEnvelope(FRIEND, 'long task'));
286
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['long task']));
287
+ rpc.receive(dataEnvelope(FRIEND, 'impatient follow-up'));
288
+ await vi.waitFor(() => {
289
+ expect(rpc.sends().some((s) => String(s['message']).includes('still working'))).toBe(true);
290
+ });
291
+ expect(fake.prompts).toEqual(['long task']);
292
+ fake.releaseTurn();
293
+ await handle.stop();
294
+ });
295
+ });
296
+
297
+ describe('lifecycle', () => {
298
+ it('stop() shuts the sidecar down and resolves running', async () => {
299
+ const { channel, fake, sidecarStops } = makeChannel();
300
+ const handle = await channel.start({ session: fake.session });
301
+ await handle.stop('test');
302
+ expect(sidecarStops).toEqual([1]);
303
+ await expect(handle.running).resolves.toBeUndefined();
304
+ });
305
+
306
+ it('pair mode opens a link window, then boots the daemon once linked', async () => {
307
+ const rpc = new FakeRpc();
308
+ const fake = makeFakeSession();
309
+ const vault = stubVault(); // no account stored yet
310
+ let completeLink: ((r: { account: string | null }) => void) | null = null;
311
+ const sidecarAccounts: string[] = [];
312
+ const channel = new SignalChannel({
313
+ vault,
314
+ findBinaryFn: () => '/fake/bin/signal-cli',
315
+ listAccountsFn: async () => [],
316
+ linkFactory: () => ({
317
+ uri: Promise.resolve('sgnl://linkdevice?uuid=abc&pub_key=def'),
318
+ completed: new Promise((resolve) => {
319
+ completeLink = resolve;
320
+ }),
321
+ cancel: () => undefined,
322
+ }),
323
+ sidecarFactory: ({ account }) => {
324
+ sidecarAccounts.push(account);
325
+ return { start: async () => rpc, stop: async () => undefined, onExit: () => () => undefined };
326
+ },
327
+ });
328
+ const linked: string[] = [];
329
+ channel.onLinked((account) => linked.push(account));
330
+
331
+ const handle = await channel.start({ session: fake.session, pair: true });
332
+ expect(channel.requestUrl).toBe('sgnl://linkdevice?uuid=abc&pub_key=def');
333
+ expect(channel.connected).toBe(false);
334
+
335
+ completeLink!({ account: OWNER });
336
+ await vi.waitFor(() => expect(channel.connected).toBe(true));
337
+ expect(channel.requestUrl).toBeNull();
338
+ expect(sidecarAccounts).toEqual([OWNER]);
339
+ expect(linked).toEqual([OWNER]);
340
+ expect(await vault.get(SIGNAL_ACCOUNT_KEY)).toBe(OWNER);
341
+
342
+ // The daemon is live: a Note-to-Self message drives a turn.
343
+ rpc.receive(noteToSelfEnvelope('hello after linking'));
344
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['hello after linking']));
345
+ await handle.stop();
346
+ });
347
+
348
+ it('dedicated mode (desktop panel) opens the same link window without pair', async () => {
349
+ const fake = makeFakeSession();
350
+ const channel = new SignalChannel({
351
+ vault: stubVault(),
352
+ findBinaryFn: () => '/fake/bin/signal-cli',
353
+ listAccountsFn: async () => [],
354
+ linkFactory: () => ({
355
+ uri: Promise.resolve('sgnl://linkdevice?uuid=xyz'),
356
+ completed: new Promise(() => undefined), // never completes in this test
357
+ cancel: () => undefined,
358
+ }),
359
+ });
360
+ const handle = await channel.start({ session: fake.session, dedicated: true });
361
+ expect(channel.requestUrl).toBe('sgnl://linkdevice?uuid=xyz');
362
+ await handle.stop();
363
+ });
364
+ });