@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { defineChannel, definePlugin, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
3
|
+
import { SignalChannel, type SignalChannelOptions } from './channel.js';
|
|
4
|
+
import {
|
|
5
|
+
SIGNAL_ACCOUNT_ENV,
|
|
6
|
+
SIGNAL_ACCOUNT_KEY,
|
|
7
|
+
SIGNAL_ALLOWED_SENDERS_KEY,
|
|
8
|
+
parseAllowedSenders,
|
|
9
|
+
} from './keys.js';
|
|
10
|
+
import {
|
|
11
|
+
SIGNAL_CLI_INSTALL_HINT,
|
|
12
|
+
findSignalCliOnPath,
|
|
13
|
+
listSignalAccounts,
|
|
14
|
+
signalCliDataDir,
|
|
15
|
+
} from './sidecar.js';
|
|
16
|
+
import { runSignalWizard } from './setup-wizard.js';
|
|
17
|
+
import { runSignalPairFlow } from './pair-flow.js';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
SignalChannel,
|
|
21
|
+
type SignalChannelOptions,
|
|
22
|
+
type SignalStartOpts,
|
|
23
|
+
type SendTarget,
|
|
24
|
+
type SignalRpcLike,
|
|
25
|
+
type SignalSidecarLike,
|
|
26
|
+
} from './channel.js';
|
|
27
|
+
export { buildSignalPermissionResolver } from './permission.js';
|
|
28
|
+
export { SignalRpcClient, type RpcStream } from './jsonrpc.js';
|
|
29
|
+
export {
|
|
30
|
+
SignalSidecar,
|
|
31
|
+
startLinkProcess,
|
|
32
|
+
listSignalAccounts,
|
|
33
|
+
findSignalCliOnPath,
|
|
34
|
+
signalCliDataDir,
|
|
35
|
+
signalCliAttachmentsDir,
|
|
36
|
+
SIGNAL_CLI_INSTALL_HINT,
|
|
37
|
+
type LinkProcessHandle,
|
|
38
|
+
type SpawnFn,
|
|
39
|
+
type SpawnedProcess,
|
|
40
|
+
} from './sidecar.js';
|
|
41
|
+
export {
|
|
42
|
+
receiveParamsSchema,
|
|
43
|
+
envelopeSchema,
|
|
44
|
+
attachmentSchema,
|
|
45
|
+
MAX_INBOUND_TEXT_CHARS,
|
|
46
|
+
type SignalEnvelope,
|
|
47
|
+
type SignalAttachment,
|
|
48
|
+
} from './schema.js';
|
|
49
|
+
export {
|
|
50
|
+
ChunkedSender,
|
|
51
|
+
takeChunk,
|
|
52
|
+
splitForSignal,
|
|
53
|
+
SIGNAL_CHUNK_SOFT_LIMIT,
|
|
54
|
+
SIGNAL_CHUNK_HARD_LIMIT,
|
|
55
|
+
} from './channel/chunker.js';
|
|
56
|
+
export {
|
|
57
|
+
SIGNAL_ACCOUNT_KEY,
|
|
58
|
+
SIGNAL_ACCOUNT_ENV,
|
|
59
|
+
SIGNAL_ALLOWED_SENDERS_KEY,
|
|
60
|
+
parseAllowedSenders,
|
|
61
|
+
normalizeSender,
|
|
62
|
+
E164_RE,
|
|
63
|
+
} from './keys.js';
|
|
64
|
+
|
|
65
|
+
export interface BuildSignalPluginOptions {
|
|
66
|
+
/** Host-injected encrypted secret store (available immediately). */
|
|
67
|
+
readonly vault: VaultStore;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the Signal channel plugin with a host-injected vault (mirrors the
|
|
72
|
+
* Telegram/Slack plugins' vault injection).
|
|
73
|
+
*/
|
|
74
|
+
export function buildSignalPlugin(opts: BuildSignalPluginOptions): Plugin {
|
|
75
|
+
return makeSignalPlugin(() => opts.vault);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Discovery-loadable default export: resolves the vault from the inter-plugin
|
|
80
|
+
* service registry in `onInit` (the vault plugin publishes `'vault'`). Requires
|
|
81
|
+
* `@moxxy/plugin-vault` to load first (declared in `package.json`
|
|
82
|
+
* `moxxy.requirements`). The channel + subcommands read the vault via
|
|
83
|
+
* `getVault()`, so resolution is deferred to call time — after `onInit` wired it.
|
|
84
|
+
*/
|
|
85
|
+
export const signalPlugin: Plugin = (() => {
|
|
86
|
+
let resolved: VaultStore | null = null;
|
|
87
|
+
const getVault = (): VaultStore => {
|
|
88
|
+
if (!resolved) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'@moxxy/plugin-channel-signal: the "vault" service is unavailable — @moxxy/plugin-vault must load first',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return resolved;
|
|
94
|
+
};
|
|
95
|
+
const hooks: LifecycleHooks = {
|
|
96
|
+
onInit: (ctx) => {
|
|
97
|
+
resolved = ctx.services.require<VaultStore>('vault');
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
return makeSignalPlugin(getVault, hooks);
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
export default signalPlugin;
|
|
104
|
+
|
|
105
|
+
function readStringArray(value: unknown): string[] | undefined {
|
|
106
|
+
if (Array.isArray(value)) return value.map((x) => String(x));
|
|
107
|
+
if (typeof value === 'string' && value.trim()) {
|
|
108
|
+
return value
|
|
109
|
+
.split(',')
|
|
110
|
+
.map((s) => s.trim())
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function makeSignalPlugin(getVault: () => VaultStore, hooks?: LifecycleHooks): Plugin {
|
|
117
|
+
return definePlugin({
|
|
118
|
+
name: '@moxxy/plugin-channel-signal',
|
|
119
|
+
version: '0.0.0',
|
|
120
|
+
...(hooks ? { hooks } : {}),
|
|
121
|
+
channels: [
|
|
122
|
+
defineChannel({
|
|
123
|
+
name: 'signal',
|
|
124
|
+
description:
|
|
125
|
+
'Signal messenger channel via a signal-cli JSON-RPC sidecar. Links as a secondary device (QR); Note-to-Self + allow-listed senders drive the agent.',
|
|
126
|
+
// A linked device sees ALL the account owner's messages, so this
|
|
127
|
+
// channel runs on its own dedicated, isolated runner (separate socket +
|
|
128
|
+
// sticky session), like Slack — the bot keeps its own persistent
|
|
129
|
+
// history apart from the user's desktop/TUI work.
|
|
130
|
+
dedicatedRunner: true,
|
|
131
|
+
sessionSource: 'signal',
|
|
132
|
+
// Self-described config so a control surface (TUI `/channels`, `moxxy
|
|
133
|
+
// channels start`, the desktop panel) can configure + run Signal
|
|
134
|
+
// without a hardcoded table.
|
|
135
|
+
config: {
|
|
136
|
+
fields: [
|
|
137
|
+
{
|
|
138
|
+
name: 'account',
|
|
139
|
+
label: 'Account number (E.164)',
|
|
140
|
+
vaultKey: SIGNAL_ACCOUNT_KEY,
|
|
141
|
+
required: true,
|
|
142
|
+
secret: false,
|
|
143
|
+
placeholder: '+15551234567',
|
|
144
|
+
help: 'The Signal account moxxy links to as a secondary device (needs signal-cli on PATH)',
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
hasRequestUrl: false,
|
|
148
|
+
runHint:
|
|
149
|
+
'Scan the QR with your phone (Signal → Settings → Linked Devices → Link New Device); then message your own "Note to Self" to talk to moxxy.',
|
|
150
|
+
connect: {
|
|
151
|
+
kind: 'qr',
|
|
152
|
+
title: 'Link your Signal',
|
|
153
|
+
hint: 'On your phone: Signal → Settings → Linked Devices → Link New Device, then scan the QR.',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
create: (deps) => {
|
|
157
|
+
const options = deps.options;
|
|
158
|
+
const channelOpts: SignalChannelOptions = {
|
|
159
|
+
vault: getVault(),
|
|
160
|
+
...(typeof options?.['account'] === 'string' ? { account: options['account'] } : {}),
|
|
161
|
+
...(typeof options?.['binary'] === 'string' ? { binary: options['binary'] } : {}),
|
|
162
|
+
...(() => {
|
|
163
|
+
const tools = readStringArray(options?.['allowedTools']);
|
|
164
|
+
return tools ? { allowedTools: tools } : {};
|
|
165
|
+
})(),
|
|
166
|
+
...(() => {
|
|
167
|
+
const senders = readStringArray(options?.['allowedSenders']);
|
|
168
|
+
return senders ? { allowedSenders: senders } : {};
|
|
169
|
+
})(),
|
|
170
|
+
logger: deps.logger as never,
|
|
171
|
+
};
|
|
172
|
+
return new SignalChannel(channelOpts);
|
|
173
|
+
},
|
|
174
|
+
isAvailable: async () => {
|
|
175
|
+
// Gate 1: the signal-cli binary. A pure PATH scan (no spawn — this
|
|
176
|
+
// runs on every `moxxy channels list` / `moxxy doctor`), and a miss
|
|
177
|
+
// must NEVER crash discovery: return a friendly install hint.
|
|
178
|
+
try {
|
|
179
|
+
if (!findSignalCliOnPath()) {
|
|
180
|
+
return { ok: false, reason: SIGNAL_CLI_INSTALL_HINT };
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
return { ok: false, reason: SIGNAL_CLI_INSTALL_HINT };
|
|
184
|
+
}
|
|
185
|
+
// Gate 2: an account number (env first — the vault may not be wired
|
|
186
|
+
// in a probe/listing context).
|
|
187
|
+
if (process.env[SIGNAL_ACCOUNT_ENV]?.trim()) return { ok: true };
|
|
188
|
+
try {
|
|
189
|
+
if (await getVault().has(SIGNAL_ACCOUNT_KEY)) return { ok: true };
|
|
190
|
+
} catch {
|
|
191
|
+
/* vault unavailable in a probe context — fall through */
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: `No Signal account configured. Run \`moxxy channels signal setup\`, set ${SIGNAL_ACCOUNT_ENV}, or store one in the vault as '${SIGNAL_ACCOUNT_KEY}'.`,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
interactiveCommand: 'setup',
|
|
199
|
+
subcommands: {
|
|
200
|
+
setup: {
|
|
201
|
+
description:
|
|
202
|
+
'Interactive setup: check signal-cli, store the account number, pick the tool allow-list, then link + start. Shown by default for `moxxy signal` on a TTY.',
|
|
203
|
+
run: async (ctx) => {
|
|
204
|
+
if (process.stdin.isTTY !== true) {
|
|
205
|
+
// Headless: just start the channel (account must already be
|
|
206
|
+
// configured + linked).
|
|
207
|
+
return ctx.startChannel();
|
|
208
|
+
}
|
|
209
|
+
return runSignalWizard(ctx);
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
pair: {
|
|
213
|
+
description:
|
|
214
|
+
'Link this machine to your Signal account: prints a QR to scan from Signal → Settings → Linked Devices — the same mechanism the desktop uses.',
|
|
215
|
+
run: async (ctx) => {
|
|
216
|
+
if (process.stdin.isTTY !== true) {
|
|
217
|
+
process.stderr.write(
|
|
218
|
+
'Pairing needs a TTY to show the QR. Run `moxxy channels signal pair` on a workstation, or pair from the desktop Channels panel.\n',
|
|
219
|
+
);
|
|
220
|
+
return 1;
|
|
221
|
+
}
|
|
222
|
+
return runSignalPairFlow(ctx);
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
status: {
|
|
226
|
+
description:
|
|
227
|
+
'Report signal-cli / account / linking / allow-list state as JSON.',
|
|
228
|
+
run: async (ctx) => {
|
|
229
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
230
|
+
if (!vault) {
|
|
231
|
+
process.stderr.write('vault unavailable\n');
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
const binary = findSignalCliOnPath();
|
|
235
|
+
const account =
|
|
236
|
+
process.env[SIGNAL_ACCOUNT_ENV]?.trim() || (await vault.get(SIGNAL_ACCOUNT_KEY));
|
|
237
|
+
const allowedSenders = parseAllowedSenders(
|
|
238
|
+
await vault.get(SIGNAL_ALLOWED_SENDERS_KEY),
|
|
239
|
+
);
|
|
240
|
+
// Linked state needs a one-shot signal-cli spawn (slow JVM);
|
|
241
|
+
// report null when the probe isn't possible/fails.
|
|
242
|
+
let linked: boolean | null = null;
|
|
243
|
+
if (binary && account) {
|
|
244
|
+
try {
|
|
245
|
+
linked = (await listSignalAccounts({ binary })).includes(account);
|
|
246
|
+
} catch {
|
|
247
|
+
linked = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
process.stdout.write(
|
|
251
|
+
JSON.stringify(
|
|
252
|
+
{
|
|
253
|
+
binaryFound: binary != null,
|
|
254
|
+
account: account || null,
|
|
255
|
+
linked,
|
|
256
|
+
allowedSenders,
|
|
257
|
+
dataDir: signalCliDataDir(),
|
|
258
|
+
},
|
|
259
|
+
null,
|
|
260
|
+
2,
|
|
261
|
+
) + '\n',
|
|
262
|
+
);
|
|
263
|
+
return 0;
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
unpair: {
|
|
267
|
+
description:
|
|
268
|
+
"Forget the stored account + sender allow-list. (signal-cli's own linked-device store is untouched; remove the device from Signal → Linked Devices on your phone to fully unlink.)",
|
|
269
|
+
run: async (ctx) => {
|
|
270
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
271
|
+
if (!vault) {
|
|
272
|
+
process.stderr.write('vault unavailable\n');
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
const removedAccount = await vault.delete(SIGNAL_ACCOUNT_KEY);
|
|
276
|
+
await vault.delete(SIGNAL_ALLOWED_SENDERS_KEY);
|
|
277
|
+
process.stdout.write(
|
|
278
|
+
removedAccount
|
|
279
|
+
? 'unpaired (vault cleared). To fully unlink, remove the "moxxy" device in Signal → Settings → Linked Devices on your phone.\n'
|
|
280
|
+
: 'no account was configured\n',
|
|
281
|
+
);
|
|
282
|
+
return 0;
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { SignalRpcClient, type RpcStream } from './jsonrpc.js';
|
|
4
|
+
|
|
5
|
+
class FakeStream extends EventEmitter implements RpcStream {
|
|
6
|
+
written: string[] = [];
|
|
7
|
+
ended = false;
|
|
8
|
+
write(data: string): boolean {
|
|
9
|
+
this.written.push(data);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
end(): void {
|
|
13
|
+
this.ended = true;
|
|
14
|
+
}
|
|
15
|
+
/** Feed one raw chunk into the client. */
|
|
16
|
+
feed(raw: string): void {
|
|
17
|
+
this.emit('data', Buffer.from(raw, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
lastRequest(): { id: string; method: string; params: unknown } {
|
|
20
|
+
return JSON.parse(this.written.at(-1)!) as { id: string; method: string; params: unknown };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('SignalRpcClient', () => {
|
|
25
|
+
it('matches responses to requests by id', async () => {
|
|
26
|
+
const stream = new FakeStream();
|
|
27
|
+
const client = new SignalRpcClient({ stream });
|
|
28
|
+
const p = client.request('send', { message: 'hi' });
|
|
29
|
+
const req = stream.lastRequest();
|
|
30
|
+
expect(req.method).toBe('send');
|
|
31
|
+
stream.feed(`{"jsonrpc":"2.0","id":"${req.id}","result":{"timestamp":42}}\n`);
|
|
32
|
+
await expect(p).resolves.toEqual({ timestamp: 42 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rejects on a JSON-RPC error reply', async () => {
|
|
36
|
+
const stream = new FakeStream();
|
|
37
|
+
const client = new SignalRpcClient({ stream });
|
|
38
|
+
const p = client.request('send', {});
|
|
39
|
+
const req = stream.lastRequest();
|
|
40
|
+
stream.feed(`{"jsonrpc":"2.0","id":"${req.id}","error":{"code":-32602,"message":"bad params"}}\n`);
|
|
41
|
+
await expect(p).rejects.toThrow(/bad params/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('dispatches notifications to subscribers and survives listener throw', () => {
|
|
45
|
+
const stream = new FakeStream();
|
|
46
|
+
const client = new SignalRpcClient({ stream });
|
|
47
|
+
const seen: unknown[] = [];
|
|
48
|
+
client.onNotification('receive', () => {
|
|
49
|
+
throw new Error('boom');
|
|
50
|
+
});
|
|
51
|
+
client.onNotification('receive', (params) => seen.push(params));
|
|
52
|
+
stream.feed('{"jsonrpc":"2.0","method":"receive","params":{"envelope":{"sourceNumber":"+1"}}}\n');
|
|
53
|
+
expect(seen).toEqual([{ envelope: { sourceNumber: '+1' } }]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles split + coalesced frames', async () => {
|
|
57
|
+
const stream = new FakeStream();
|
|
58
|
+
const client = new SignalRpcClient({ stream });
|
|
59
|
+
const p1 = client.request('a');
|
|
60
|
+
const p2 = client.request('b');
|
|
61
|
+
const id1 = JSON.parse(stream.written[0]!).id as string;
|
|
62
|
+
const id2 = JSON.parse(stream.written[1]!).id as string;
|
|
63
|
+
const line1 = `{"jsonrpc":"2.0","id":"${id1}","result":1}\n`;
|
|
64
|
+
const line2 = `{"jsonrpc":"2.0","id":"${id2}","result":2}\n`;
|
|
65
|
+
const combined = line1 + line2;
|
|
66
|
+
stream.feed(combined.slice(0, 10));
|
|
67
|
+
stream.feed(combined.slice(10));
|
|
68
|
+
await expect(p1).resolves.toBe(1);
|
|
69
|
+
await expect(p2).resolves.toBe(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('times out a request the daemon never answers', async () => {
|
|
73
|
+
vi.useFakeTimers();
|
|
74
|
+
try {
|
|
75
|
+
const stream = new FakeStream();
|
|
76
|
+
const client = new SignalRpcClient({ stream, requestTimeoutMs: 500 });
|
|
77
|
+
const p = client.request('version');
|
|
78
|
+
const assertion = expect(p).rejects.toThrow(/timed out/);
|
|
79
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
80
|
+
await assertion;
|
|
81
|
+
} finally {
|
|
82
|
+
vi.useRealTimers();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects pending requests when the stream closes', async () => {
|
|
87
|
+
const stream = new FakeStream();
|
|
88
|
+
const client = new SignalRpcClient({ stream });
|
|
89
|
+
const p = client.request('version');
|
|
90
|
+
stream.emit('close');
|
|
91
|
+
await expect(p).rejects.toThrow(/socket closed/);
|
|
92
|
+
await expect(client.request('version')).rejects.toThrow(/closed/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('notifies onClose listeners with the reason', () => {
|
|
96
|
+
const stream = new FakeStream();
|
|
97
|
+
const client = new SignalRpcClient({ stream });
|
|
98
|
+
const reasons: string[] = [];
|
|
99
|
+
client.onClose((r) => reasons.push(r));
|
|
100
|
+
stream.emit('error', new Error('ECONNRESET'));
|
|
101
|
+
expect(reasons).toEqual(['socket error: ECONNRESET']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('drops an oversized un-delimited line instead of buffering forever', () => {
|
|
105
|
+
const warn = vi.fn();
|
|
106
|
+
const stream = new FakeStream();
|
|
107
|
+
const client = new SignalRpcClient({ stream, logger: { warn } });
|
|
108
|
+
stream.feed('x'.repeat(9 * 1024 * 1024)); // > 8MB cap, no newline
|
|
109
|
+
expect(warn).toHaveBeenCalledWith(
|
|
110
|
+
'signal rpc: dropped oversized un-delimited line',
|
|
111
|
+
expect.objectContaining({ bytes: expect.any(Number) }),
|
|
112
|
+
);
|
|
113
|
+
// Client still works afterwards.
|
|
114
|
+
const p = client.request('version');
|
|
115
|
+
const req = stream.lastRequest();
|
|
116
|
+
stream.feed(`{"jsonrpc":"2.0","id":"${req.id}","result":"ok"}\n`);
|
|
117
|
+
return expect(p).resolves.toBe('ok');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('ignores garbage lines and unknown ids', () => {
|
|
121
|
+
const stream = new FakeStream();
|
|
122
|
+
const client = new SignalRpcClient({ stream });
|
|
123
|
+
stream.feed('not json\n');
|
|
124
|
+
stream.feed('{"jsonrpc":"2.0","id":"999","result":1}\n');
|
|
125
|
+
// No throw — and the client still accepts requests.
|
|
126
|
+
void client;
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/jsonrpc.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal newline-delimited JSON-RPC 2.0 client for the signal-cli daemon
|
|
3
|
+
* socket (`signal-cli -a <account> daemon --socket <path>`).
|
|
4
|
+
*
|
|
5
|
+
* The daemon speaks one JSON object per line over the UNIX socket:
|
|
6
|
+
* → {"jsonrpc":"2.0","id":"…","method":"send","params":{…}}
|
|
7
|
+
* ← {"jsonrpc":"2.0","id":"…","result":{…}} | {"…","error":{code,message}}
|
|
8
|
+
* ← {"jsonrpc":"2.0","method":"receive","params":{"envelope":{…}}} (notification)
|
|
9
|
+
*
|
|
10
|
+
* The transport is injected as a plain duplex-ish stream so tests never open a
|
|
11
|
+
* real socket (mirrors the browser sidecar's injectable spawn).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The stream slice this client needs — `net.Socket` satisfies it. */
|
|
15
|
+
export interface RpcStream {
|
|
16
|
+
write(data: string): unknown;
|
|
17
|
+
on(event: 'data', listener: (chunk: Buffer | string) => void): unknown;
|
|
18
|
+
on(event: 'close', listener: () => void): unknown;
|
|
19
|
+
on(event: 'error', listener: (err: Error) => void): unknown;
|
|
20
|
+
end?(): unknown;
|
|
21
|
+
destroy?(): unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RpcLogger {
|
|
25
|
+
warn?(msg: string, meta?: Record<string, unknown>): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SignalRpcClientOptions {
|
|
29
|
+
readonly stream: RpcStream;
|
|
30
|
+
/** Per-request timeout. signal-cli sends synchronously to the Signal server,
|
|
31
|
+
* so allow generous headroom. Default 30s. */
|
|
32
|
+
readonly requestTimeoutMs?: number;
|
|
33
|
+
readonly logger?: RpcLogger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cap on a single buffered line. Receive envelopes are small (attachments are
|
|
38
|
+
* written to signal-cli's data dir, not inlined), so anything beyond this is a
|
|
39
|
+
* malformed/hostile peer; we drop the buffer instead of growing unbounded.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_LINE_BUFFER = 8 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
interface PendingCall {
|
|
44
|
+
resolve(value: unknown): void;
|
|
45
|
+
reject(err: Error): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class SignalRpcClient {
|
|
49
|
+
private readonly stream: RpcStream;
|
|
50
|
+
private readonly requestTimeoutMs: number;
|
|
51
|
+
private readonly logger: RpcLogger | undefined;
|
|
52
|
+
private readonly pending = new Map<string, PendingCall>();
|
|
53
|
+
private readonly notificationListeners = new Map<string, Set<(params: unknown) => void>>();
|
|
54
|
+
private readonly closeListeners = new Set<(reason: string) => void>();
|
|
55
|
+
private buffer = '';
|
|
56
|
+
private nextId = 1;
|
|
57
|
+
private closed = false;
|
|
58
|
+
|
|
59
|
+
constructor(opts: SignalRpcClientOptions) {
|
|
60
|
+
this.stream = opts.stream;
|
|
61
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? 30_000;
|
|
62
|
+
this.logger = opts.logger;
|
|
63
|
+
this.stream.on('data', (chunk) => this.onData(chunk));
|
|
64
|
+
this.stream.on('close', () => this.teardown('socket closed'));
|
|
65
|
+
this.stream.on('error', (err) => this.teardown(`socket error: ${err.message}`));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Subscribe to a JSON-RPC notification method (e.g. 'receive'). */
|
|
69
|
+
onNotification(method: string, listener: (params: unknown) => void): () => void {
|
|
70
|
+
let set = this.notificationListeners.get(method);
|
|
71
|
+
if (!set) {
|
|
72
|
+
set = new Set();
|
|
73
|
+
this.notificationListeners.set(method, set);
|
|
74
|
+
}
|
|
75
|
+
set.add(listener);
|
|
76
|
+
return () => set.delete(listener);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Fires once when the underlying stream closes/errors (daemon died). */
|
|
80
|
+
onClose(listener: (reason: string) => void): () => void {
|
|
81
|
+
this.closeListeners.add(listener);
|
|
82
|
+
return () => this.closeListeners.delete(listener);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
request(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
|
86
|
+
if (this.closed) {
|
|
87
|
+
return Promise.reject(new Error(`signal-cli rpc closed (cannot call ${method})`));
|
|
88
|
+
}
|
|
89
|
+
const id = String(this.nextId++);
|
|
90
|
+
const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
91
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
92
|
+
// Per-call timeout so a wedged daemon never strands the caller.
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
if (this.pending.delete(id)) {
|
|
95
|
+
reject(new Error(`signal-cli rpc "${method}" timed out after ${this.requestTimeoutMs}ms`));
|
|
96
|
+
}
|
|
97
|
+
}, this.requestTimeoutMs);
|
|
98
|
+
timer.unref?.();
|
|
99
|
+
this.pending.set(id, {
|
|
100
|
+
resolve: (v) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
resolve(v);
|
|
103
|
+
},
|
|
104
|
+
reject: (e) => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
reject(e);
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
this.stream.write(payload + '\n');
|
|
111
|
+
} catch (err) {
|
|
112
|
+
this.pending.delete(id);
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
close(): void {
|
|
120
|
+
this.teardown('client closed');
|
|
121
|
+
try {
|
|
122
|
+
this.stream.end?.();
|
|
123
|
+
this.stream.destroy?.();
|
|
124
|
+
} catch {
|
|
125
|
+
/* best-effort */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private teardown(reason: string): void {
|
|
130
|
+
if (this.closed) return;
|
|
131
|
+
this.closed = true;
|
|
132
|
+
for (const [, p] of this.pending) p.reject(new Error(`signal-cli rpc: ${reason}`));
|
|
133
|
+
this.pending.clear();
|
|
134
|
+
for (const listener of this.closeListeners) {
|
|
135
|
+
try {
|
|
136
|
+
listener(reason);
|
|
137
|
+
} catch {
|
|
138
|
+
/* listener errors must not break teardown */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
this.closeListeners.clear();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private onData(chunk: Buffer | string): void {
|
|
145
|
+
this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
146
|
+
let nl: number;
|
|
147
|
+
while ((nl = this.buffer.indexOf('\n')) !== -1) {
|
|
148
|
+
const line = this.buffer.slice(0, nl);
|
|
149
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
150
|
+
if (line.trim()) this.handleLine(line);
|
|
151
|
+
}
|
|
152
|
+
if (this.buffer.length > MAX_LINE_BUFFER) {
|
|
153
|
+
this.logger?.warn?.('signal rpc: dropped oversized un-delimited line', {
|
|
154
|
+
bytes: this.buffer.length,
|
|
155
|
+
});
|
|
156
|
+
this.buffer = '';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private handleLine(line: string): void {
|
|
161
|
+
let msg: {
|
|
162
|
+
id?: string | number;
|
|
163
|
+
method?: string;
|
|
164
|
+
params?: unknown;
|
|
165
|
+
result?: unknown;
|
|
166
|
+
error?: { code?: number; message?: string };
|
|
167
|
+
};
|
|
168
|
+
try {
|
|
169
|
+
msg = JSON.parse(line) as typeof msg;
|
|
170
|
+
} catch {
|
|
171
|
+
this.logger?.warn?.('signal rpc: ignoring unparseable line');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Notification: has a method, no id.
|
|
175
|
+
if (msg.method !== undefined && msg.id === undefined) {
|
|
176
|
+
const listeners = this.notificationListeners.get(msg.method);
|
|
177
|
+
if (!listeners) return;
|
|
178
|
+
for (const listener of listeners) {
|
|
179
|
+
try {
|
|
180
|
+
listener(msg.params);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.logger?.warn?.('signal rpc: notification listener threw', { err: String(err) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// Response: match by id.
|
|
188
|
+
const id = msg.id !== undefined ? String(msg.id) : null;
|
|
189
|
+
const p = id ? this.pending.get(id) : undefined;
|
|
190
|
+
if (!p || !id) return; // late/unknown reply — ignore
|
|
191
|
+
this.pending.delete(id);
|
|
192
|
+
if (msg.error) {
|
|
193
|
+
p.reject(new Error(`signal-cli rpc error ${msg.error.code ?? ''}: ${msg.error.message ?? 'unknown'}`));
|
|
194
|
+
} else {
|
|
195
|
+
p.resolve(msg.result);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/keys.test.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { E164_RE, normalizeSender, parseAllowedSenders } from './keys.js';
|
|
3
|
+
|
|
4
|
+
describe('E164_RE', () => {
|
|
5
|
+
it('accepts plausible numbers and rejects junk', () => {
|
|
6
|
+
expect(E164_RE.test('+15551234567')).toBe(true);
|
|
7
|
+
expect(E164_RE.test('+4915771234567')).toBe(true);
|
|
8
|
+
expect(E164_RE.test('15551234567')).toBe(false);
|
|
9
|
+
expect(E164_RE.test('+0155')).toBe(false);
|
|
10
|
+
expect(E164_RE.test('+1555123456789012345')).toBe(false);
|
|
11
|
+
expect(E164_RE.test('')).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('normalizeSender', () => {
|
|
16
|
+
it('lowercases UUIDs but preserves numbers', () => {
|
|
17
|
+
expect(normalizeSender(' +15551234567 ')).toBe('+15551234567');
|
|
18
|
+
expect(normalizeSender('AB0F5EAD-0000-4000-8000-00805F9B34FB')).toBe(
|
|
19
|
+
'ab0f5ead-0000-4000-8000-00805f9b34fb',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('parseAllowedSenders', () => {
|
|
25
|
+
it('parses a JSON array of numbers/uuids', () => {
|
|
26
|
+
const raw = JSON.stringify(['+15551234567', 'AB0F5EAD-0000-4000-8000-00805F9B34FB']);
|
|
27
|
+
expect(parseAllowedSenders(raw)).toEqual([
|
|
28
|
+
'+15551234567',
|
|
29
|
+
'ab0f5ead-0000-4000-8000-00805f9b34fb',
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('fails closed on corrupt values', () => {
|
|
34
|
+
expect(parseAllowedSenders(null)).toEqual([]);
|
|
35
|
+
expect(parseAllowedSenders('')).toEqual([]);
|
|
36
|
+
expect(parseAllowedSenders('not json')).toEqual([]);
|
|
37
|
+
expect(parseAllowedSenders('"a string"')).toEqual([]);
|
|
38
|
+
expect(parseAllowedSenders('{"a":1}')).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('drops entries that are neither E.164 nor uuid', () => {
|
|
42
|
+
const raw = JSON.stringify(['+15551234567', 'bob', 42, '../../etc/passwd']);
|
|
43
|
+
expect(parseAllowedSenders(raw)).toEqual(['+15551234567']);
|
|
44
|
+
});
|
|
45
|
+
});
|