@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.
- package/LICENSE +21 -0
- package/dist/channel/chunker.d.ts +69 -0
- package/dist/channel/chunker.d.ts.map +1 -0
- package/dist/channel/chunker.js +133 -0
- package/dist/channel/chunker.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +33 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +70 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/voice.d.ts +37 -0
- package/dist/channel/voice.d.ts.map +1 -0
- package/dist/channel/voice.js +88 -0
- package/dist/channel/voice.js.map +1 -0
- package/dist/channel.d.ts +154 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +468 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +215 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonrpc.d.ts +53 -0
- package/dist/jsonrpc.d.ts.map +1 -0
- package/dist/jsonrpc.js +167 -0
- package/dist/jsonrpc.js.map +1 -0
- package/dist/keys.d.ts +37 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +55 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +21 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +136 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/permission.d.ts +21 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +27 -0
- package/dist/permission.js.map +1 -0
- package/dist/schema.d.ts +4295 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +84 -0
- package/dist/schema.js.map +1 -0
- package/dist/setup-wizard.d.ts +14 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +85 -0
- package/dist/setup-wizard.js.map +1 -0
- package/dist/sidecar.d.ts +137 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +421 -0
- package/dist/sidecar.js.map +1 -0
- package/package.json +89 -0
- package/src/channel/chunker.test.ts +135 -0
- package/src/channel/chunker.ts +147 -0
- package/src/channel/turn-runner.ts +97 -0
- package/src/channel/voice.test.ts +136 -0
- package/src/channel/voice.ts +118 -0
- package/src/channel.test.ts +364 -0
- package/src/channel.ts +607 -0
- package/src/index.ts +289 -0
- package/src/jsonrpc.test.ts +128 -0
- package/src/jsonrpc.ts +198 -0
- package/src/keys.test.ts +45 -0
- package/src/keys.ts +56 -0
- package/src/pair-flow.ts +161 -0
- package/src/permission.ts +36 -0
- package/src/schema.ts +98 -0
- package/src/setup-wizard.ts +98 -0
- package/src/sidecar.test.ts +276 -0
- package/src/sidecar.ts +511 -0
- package/src/subcommands.test.ts +206 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import type { RpcStream } from './jsonrpc.js';
|
|
7
|
+
import {
|
|
8
|
+
SignalSidecar,
|
|
9
|
+
findSignalCliOnPath,
|
|
10
|
+
listSignalAccounts,
|
|
11
|
+
signalCliAttachmentsDir,
|
|
12
|
+
signalCliDataDir,
|
|
13
|
+
startLinkProcess,
|
|
14
|
+
type SpawnedProcess,
|
|
15
|
+
} from './sidecar.js';
|
|
16
|
+
|
|
17
|
+
class FakeChild extends EventEmitter {
|
|
18
|
+
pid = 4242;
|
|
19
|
+
kills: string[] = [];
|
|
20
|
+
stderr = new EventEmitter();
|
|
21
|
+
stdout = new EventEmitter();
|
|
22
|
+
/** Which signal (if any) makes this child actually exit. */
|
|
23
|
+
exitOn: Set<string>;
|
|
24
|
+
constructor(exitOn: string[] = ['SIGTERM', 'SIGKILL']) {
|
|
25
|
+
super();
|
|
26
|
+
this.exitOn = new Set(exitOn);
|
|
27
|
+
}
|
|
28
|
+
kill(signal: NodeJS.Signals = 'SIGTERM'): boolean {
|
|
29
|
+
this.kills.push(signal);
|
|
30
|
+
if (this.exitOn.has(signal)) {
|
|
31
|
+
// Exit asynchronously, like a real process.
|
|
32
|
+
setImmediate(() => this.emit('exit', null));
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class HealthyStream extends EventEmitter implements RpcStream {
|
|
39
|
+
written: string[] = [];
|
|
40
|
+
write(data: string): boolean {
|
|
41
|
+
this.written.push(data);
|
|
42
|
+
// Auto-answer any request (the health check's `version`).
|
|
43
|
+
const req = JSON.parse(data) as { id: string; method: string };
|
|
44
|
+
setImmediate(() => {
|
|
45
|
+
this.emit('data', `{"jsonrpc":"2.0","id":"${req.id}","result":{"version":"0.13.18"}}\n`);
|
|
46
|
+
});
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
end(): void {}
|
|
50
|
+
destroy(): void {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('SignalSidecar lifecycle', () => {
|
|
54
|
+
it('spawns the daemon with account + unix socket args and health-checks it', async () => {
|
|
55
|
+
const child = new FakeChild();
|
|
56
|
+
let spawned: { command: string; args: ReadonlyArray<string> } | null = null;
|
|
57
|
+
const sidecar = new SignalSidecar({
|
|
58
|
+
account: '+15551234567',
|
|
59
|
+
binary: '/opt/bin/signal-cli',
|
|
60
|
+
spawnFn: (command, args) => {
|
|
61
|
+
spawned = { command, args };
|
|
62
|
+
return child as unknown as SpawnedProcess;
|
|
63
|
+
},
|
|
64
|
+
connectFn: async () => new HealthyStream(),
|
|
65
|
+
});
|
|
66
|
+
const rpc = await sidecar.start();
|
|
67
|
+
expect(spawned).not.toBeNull();
|
|
68
|
+
expect(spawned!.command).toBe('/opt/bin/signal-cli');
|
|
69
|
+
expect(spawned!.args).toEqual([
|
|
70
|
+
'-a',
|
|
71
|
+
'+15551234567',
|
|
72
|
+
'daemon',
|
|
73
|
+
'--socket',
|
|
74
|
+
sidecar.socketPath,
|
|
75
|
+
'--receive-mode',
|
|
76
|
+
'on-start',
|
|
77
|
+
]);
|
|
78
|
+
expect(rpc).toBeTruthy();
|
|
79
|
+
await sidecar.stop();
|
|
80
|
+
expect(child.kills).toEqual(['SIGTERM']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('retries the socket until the daemon is up', async () => {
|
|
84
|
+
const child = new FakeChild();
|
|
85
|
+
let attempts = 0;
|
|
86
|
+
const sidecar = new SignalSidecar({
|
|
87
|
+
account: '+1',
|
|
88
|
+
spawnFn: () => child as unknown as SpawnedProcess,
|
|
89
|
+
connectFn: async () => {
|
|
90
|
+
attempts += 1;
|
|
91
|
+
if (attempts < 3) throw new Error('ECONNREFUSED');
|
|
92
|
+
return new HealthyStream();
|
|
93
|
+
},
|
|
94
|
+
bootTimeoutMs: 5_000,
|
|
95
|
+
});
|
|
96
|
+
await sidecar.start();
|
|
97
|
+
expect(attempts).toBe(3);
|
|
98
|
+
await sidecar.stop();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejects with the stderr tail when the daemon exits during boot', async () => {
|
|
102
|
+
const child = new FakeChild();
|
|
103
|
+
const sidecar = new SignalSidecar({
|
|
104
|
+
account: '+1',
|
|
105
|
+
spawnFn: () => {
|
|
106
|
+
setImmediate(() => {
|
|
107
|
+
child.stderr.emit('data', Buffer.from('User +1 is not registered.\n'));
|
|
108
|
+
child.emit('exit', 1);
|
|
109
|
+
});
|
|
110
|
+
return child as unknown as SpawnedProcess;
|
|
111
|
+
},
|
|
112
|
+
connectFn: async () => {
|
|
113
|
+
throw new Error('ECONNREFUSED');
|
|
114
|
+
},
|
|
115
|
+
bootTimeoutMs: 5_000,
|
|
116
|
+
});
|
|
117
|
+
await expect(sidecar.start()).rejects.toThrow(/not registered/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('rejects when the socket never opens within the boot timeout', async () => {
|
|
121
|
+
const child = new FakeChild();
|
|
122
|
+
const sidecar = new SignalSidecar({
|
|
123
|
+
account: '+1',
|
|
124
|
+
spawnFn: () => child as unknown as SpawnedProcess,
|
|
125
|
+
connectFn: async () => {
|
|
126
|
+
throw new Error('ECONNREFUSED');
|
|
127
|
+
},
|
|
128
|
+
bootTimeoutMs: 600,
|
|
129
|
+
killGraceMs: 50,
|
|
130
|
+
});
|
|
131
|
+
await expect(sidecar.start()).rejects.toThrow(/did not open its socket within 600ms/);
|
|
132
|
+
// The child was reaped, not orphaned.
|
|
133
|
+
expect(child.kills).toContain('SIGTERM');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('escalates SIGTERM → SIGKILL when the daemon ignores the grace window', async () => {
|
|
137
|
+
const child = new FakeChild(['SIGKILL']); // ignores SIGTERM
|
|
138
|
+
const sidecar = new SignalSidecar({
|
|
139
|
+
account: '+1',
|
|
140
|
+
spawnFn: () => child as unknown as SpawnedProcess,
|
|
141
|
+
connectFn: async () => new HealthyStream(),
|
|
142
|
+
killGraceMs: 60,
|
|
143
|
+
});
|
|
144
|
+
await sidecar.start();
|
|
145
|
+
await sidecar.stop();
|
|
146
|
+
expect(child.kills).toEqual(['SIGTERM', 'SIGKILL']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('notifies onExit when the daemon dies unexpectedly', async () => {
|
|
150
|
+
const child = new FakeChild();
|
|
151
|
+
const sidecar = new SignalSidecar({
|
|
152
|
+
account: '+1',
|
|
153
|
+
spawnFn: () => child as unknown as SpawnedProcess,
|
|
154
|
+
connectFn: async () => new HealthyStream(),
|
|
155
|
+
});
|
|
156
|
+
await sidecar.start();
|
|
157
|
+
const codes: Array<number | null> = [];
|
|
158
|
+
sidecar.onExit((code) => codes.push(code));
|
|
159
|
+
child.emit('exit', 137);
|
|
160
|
+
expect(codes).toEqual([137]);
|
|
161
|
+
await sidecar.stop();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('startLinkProcess', () => {
|
|
166
|
+
it('resolves the sgnl:// URI from stdout and the account on success', async () => {
|
|
167
|
+
const child = new FakeChild();
|
|
168
|
+
const link = startLinkProcess({
|
|
169
|
+
deviceName: 'moxxy',
|
|
170
|
+
spawnFn: (command, args) => {
|
|
171
|
+
expect(command).toBe('signal-cli');
|
|
172
|
+
expect(args).toEqual(['link', '-n', 'moxxy']);
|
|
173
|
+
return child as unknown as SpawnedProcess;
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
child.stdout.emit('data', Buffer.from('sgnl://linkdevice?uuid=abc&pub_key=def\n'));
|
|
177
|
+
await expect(link.uri).resolves.toBe('sgnl://linkdevice?uuid=abc&pub_key=def');
|
|
178
|
+
child.stdout.emit('data', Buffer.from('Associated with: +15551234567 (device id: 2)\n'));
|
|
179
|
+
child.emit('exit', 0);
|
|
180
|
+
await expect(link.completed).resolves.toEqual({ account: '+15551234567' });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('understands the legacy tsdevice:/ URI form', async () => {
|
|
184
|
+
const child = new FakeChild();
|
|
185
|
+
const link = startLinkProcess({
|
|
186
|
+
deviceName: 'moxxy',
|
|
187
|
+
spawnFn: () => child as unknown as SpawnedProcess,
|
|
188
|
+
});
|
|
189
|
+
child.stdout.emit('data', Buffer.from('tsdevice:/?uuid=abc&pub_key=def\n'));
|
|
190
|
+
await expect(link.uri).resolves.toBe('tsdevice:/?uuid=abc&pub_key=def');
|
|
191
|
+
link.cancel();
|
|
192
|
+
expect(child.kills).toEqual(['SIGTERM']);
|
|
193
|
+
child.emit('exit', 1);
|
|
194
|
+
await expect(link.completed).rejects.toThrow(/exited with code 1/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('rejects both promises when linking fails before a URI appears', async () => {
|
|
198
|
+
const child = new FakeChild();
|
|
199
|
+
const link = startLinkProcess({
|
|
200
|
+
deviceName: 'moxxy',
|
|
201
|
+
spawnFn: () => child as unknown as SpawnedProcess,
|
|
202
|
+
});
|
|
203
|
+
child.emit('exit', 2);
|
|
204
|
+
await expect(link.uri).rejects.toThrow(/code 2/);
|
|
205
|
+
await expect(link.completed).rejects.toThrow(/code 2/);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('listSignalAccounts', () => {
|
|
210
|
+
it('parses --output=json account listings', async () => {
|
|
211
|
+
const child = new FakeChild();
|
|
212
|
+
const p = listSignalAccounts({
|
|
213
|
+
spawnFn: (command, args) => {
|
|
214
|
+
expect(args).toEqual(['--output=json', 'listAccounts']);
|
|
215
|
+
void command;
|
|
216
|
+
setImmediate(() => {
|
|
217
|
+
child.stdout.emit('data', Buffer.from('[{"number":"+15551234567"},{"number":"+491771234567"}]\n'));
|
|
218
|
+
child.emit('exit', 0);
|
|
219
|
+
});
|
|
220
|
+
return child as unknown as SpawnedProcess;
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
await expect(p).resolves.toEqual(['+15551234567', '+491771234567']);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('falls back to scraping numbers from plain-text output', async () => {
|
|
227
|
+
const child = new FakeChild();
|
|
228
|
+
const p = listSignalAccounts({
|
|
229
|
+
spawnFn: () => {
|
|
230
|
+
setImmediate(() => {
|
|
231
|
+
child.stdout.emit('data', Buffer.from('Number: +15551234567\n'));
|
|
232
|
+
child.emit('exit', 0);
|
|
233
|
+
});
|
|
234
|
+
return child as unknown as SpawnedProcess;
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
await expect(p).resolves.toEqual(['+15551234567']);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('findSignalCliOnPath', () => {
|
|
242
|
+
it('returns null (never throws) when the binary is absent', () => {
|
|
243
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mox-sig-path-'));
|
|
244
|
+
try {
|
|
245
|
+
expect(findSignalCliOnPath({ PATH: tmp })).toBeNull();
|
|
246
|
+
expect(findSignalCliOnPath({ PATH: undefined as unknown as string })).toBeNull();
|
|
247
|
+
expect(findSignalCliOnPath({})).toBeNull();
|
|
248
|
+
} finally {
|
|
249
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('finds an executable signal-cli on PATH', () => {
|
|
254
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mox-sig-path-'));
|
|
255
|
+
try {
|
|
256
|
+
const bin = path.join(tmp, 'signal-cli');
|
|
257
|
+
fs.writeFileSync(bin, '#!/bin/sh\n', { mode: 0o755 });
|
|
258
|
+
expect(findSignalCliOnPath({ PATH: `/nonexistent${path.delimiter}${tmp}` })).toBe(bin);
|
|
259
|
+
} finally {
|
|
260
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('signal-cli data dirs', () => {
|
|
266
|
+
it('honors XDG_DATA_HOME', () => {
|
|
267
|
+
expect(signalCliDataDir({ XDG_DATA_HOME: '/x/data' })).toBe(path.join('/x/data', 'signal-cli'));
|
|
268
|
+
expect(signalCliAttachmentsDir({ XDG_DATA_HOME: '/x/data' })).toBe(
|
|
269
|
+
path.join('/x/data', 'signal-cli', 'attachments'),
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('defaults under the home dir', () => {
|
|
274
|
+
expect(signalCliDataDir({})).toBe(path.join(os.homedir(), '.local', 'share', 'signal-cli'));
|
|
275
|
+
});
|
|
276
|
+
});
|