@clawchatsai/connector 0.0.1
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/dist/gateway-bridge.d.ts +53 -0
- package/dist/gateway-bridge.js +183 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +544 -0
- package/dist/migrate.d.ts +16 -0
- package/dist/migrate.js +114 -0
- package/dist/shim.d.ts +61 -0
- package/dist/shim.js +154 -0
- package/dist/signaling-client.d.ts +74 -0
- package/dist/signaling-client.js +322 -0
- package/dist/updater.d.ts +21 -0
- package/dist/updater.js +64 -0
- package/dist/webrtc-peer.d.ts +127 -0
- package/dist/webrtc-peer.js +257 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +37 -0
- package/server.js +4058 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @shellchat/tunnel — OpenClaw plugin entry point
|
|
3
|
+
*
|
|
4
|
+
* Registers ShellChat as a gateway plugin, providing:
|
|
5
|
+
* - Local HTTP API bridge via createApp()
|
|
6
|
+
* - WebRTC DataChannel for browser connections
|
|
7
|
+
* - Signaling client for NAT traversal
|
|
8
|
+
* - Gateway bridge for local OpenClaw communication
|
|
9
|
+
*
|
|
10
|
+
* Spec: specs/multitenant-p2p.md sections 6.1-6.2
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as http from 'node:http';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { SignalingClient } from './signaling-client.js';
|
|
16
|
+
import { WebRTCPeerManager } from './webrtc-peer.js';
|
|
17
|
+
import { dispatchRpc } from './shim.js';
|
|
18
|
+
import { checkForUpdates, performUpdate } from './updater.js';
|
|
19
|
+
// Inline from shared/api-version.ts to avoid rootDir conflict
|
|
20
|
+
const CURRENT_API_VERSION = 1;
|
|
21
|
+
export const PLUGIN_ID = 'tunnel';
|
|
22
|
+
export const PLUGIN_VERSION = '0.0.1';
|
|
23
|
+
/** Max DataChannel message size (~256KB, leave room for envelope) */
|
|
24
|
+
const MAX_DC_MESSAGE_SIZE = 256 * 1024;
|
|
25
|
+
/** Active DataChannel connections: connectionId → send function */
|
|
26
|
+
const connectedClients = new Map();
|
|
27
|
+
let app = null;
|
|
28
|
+
let signaling = null;
|
|
29
|
+
let webrtcPeer = null;
|
|
30
|
+
let healthServer = null;
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Config helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
const CONFIG_DIR = path.join(process.env.HOME || '/root', '.openclaw', 'shellchat');
|
|
35
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
36
|
+
const RUNTIME_FILE = path.join(CONFIG_DIR, 'runtime.json');
|
|
37
|
+
function loadConfig() {
|
|
38
|
+
try {
|
|
39
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
40
|
+
const cfg = JSON.parse(raw);
|
|
41
|
+
if (!cfg.userId || !cfg.apiKey || !cfg.serverUrl) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return cfg;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Service lifecycle
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
async function startShellChat(ctx, api) {
|
|
54
|
+
const config = loadConfig();
|
|
55
|
+
if (!config) {
|
|
56
|
+
ctx.logger.info('ShellChat not configured. Run: openclaw shellchat setup <token>');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// 1. Check for updates
|
|
60
|
+
const update = await checkForUpdates();
|
|
61
|
+
if (update) {
|
|
62
|
+
ctx.logger.info(`Update available: ${update.current} → ${update.latest}`);
|
|
63
|
+
if (ctx._forceUpdate) {
|
|
64
|
+
try {
|
|
65
|
+
await performUpdate();
|
|
66
|
+
ctx.logger.info(`Updated to ${update.latest}. Requesting graceful restart...`);
|
|
67
|
+
api.runtime.requestRestart?.('shellchat update');
|
|
68
|
+
return; // will restart with new version
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
ctx.logger.error(`Auto-update failed: ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// 2. Resolve gateway token: runtime API → config file → error
|
|
76
|
+
const gwCfg = api.config;
|
|
77
|
+
const gwAuth = gwCfg?.['gateway']?.['auth'];
|
|
78
|
+
const gatewayToken = gwAuth?.['token'] || config.gatewayToken || '';
|
|
79
|
+
if (!gatewayToken) {
|
|
80
|
+
ctx.logger.error('No gateway token available. Re-run: openclaw shellchat setup <token>');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// 3. Import server.js and create app instance with plugin paths
|
|
84
|
+
const dataDir = path.join(ctx.stateDir, 'shellchat', 'data');
|
|
85
|
+
const uploadsDir = path.join(ctx.stateDir, 'shellchat', 'uploads');
|
|
86
|
+
// Dynamic import of server.js (plain JS, no type declarations)
|
|
87
|
+
// @ts-expect-error — server.js is plain JS with no .d.ts
|
|
88
|
+
const serverModule = await import('../server.js');
|
|
89
|
+
app = serverModule.createApp({
|
|
90
|
+
dataDir,
|
|
91
|
+
uploadsDir,
|
|
92
|
+
gatewayUrl: 'ws://localhost:18789',
|
|
93
|
+
authToken: '', // P2P: DataChannel is the auth boundary (signaling authenticates both sides)
|
|
94
|
+
gatewayToken, // For WS auth to local OpenClaw gateway
|
|
95
|
+
});
|
|
96
|
+
// 4. Connect createApp's gateway client (handles persistence + event relay)
|
|
97
|
+
app.gatewayClient.connect();
|
|
98
|
+
// Wire DataChannel clients as broadcast targets so they receive gateway events
|
|
99
|
+
app.gatewayClient.addBroadcastTarget((data) => {
|
|
100
|
+
for (const [id, client] of connectedClients) {
|
|
101
|
+
try {
|
|
102
|
+
client.send(JSON.stringify({ type: 'gateway-event', payload: data }));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
connectedClients.delete(id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
// 5. Connect to signaling server
|
|
110
|
+
signaling = new SignalingClient(config.serverUrl, config.userId, config.apiKey);
|
|
111
|
+
signaling.on('connected', () => {
|
|
112
|
+
ctx.logger.info('Connected to signaling server');
|
|
113
|
+
});
|
|
114
|
+
signaling.on('auth-rejected', (reason) => {
|
|
115
|
+
ctx.logger.error(`Signaling auth rejected: ${reason}`);
|
|
116
|
+
});
|
|
117
|
+
signaling.on('version-rejected', (current, minimum) => {
|
|
118
|
+
ctx.logger.error(`Plugin version ${current} rejected, minimum: ${minimum}`);
|
|
119
|
+
});
|
|
120
|
+
signaling.on('force-update', async (targetVersion) => {
|
|
121
|
+
ctx.logger.info(`Force update to ${targetVersion} requested`);
|
|
122
|
+
try {
|
|
123
|
+
await performUpdate();
|
|
124
|
+
ctx.logger.info('Update complete, requesting restart');
|
|
125
|
+
api.runtime.requestRestart?.('forced update');
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
ctx.logger.error(`Force update failed: ${e.message}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
signaling.on('account-suspended', (reason) => {
|
|
132
|
+
ctx.logger.error(`Account suspended: ${reason}`);
|
|
133
|
+
broadcastToClients({ type: 'account-suspended', reason });
|
|
134
|
+
});
|
|
135
|
+
// 6. Initialize WebRTC peer manager
|
|
136
|
+
webrtcPeer = new WebRTCPeerManager();
|
|
137
|
+
webrtcPeer.on('datachannel', (dc, connectionId) => {
|
|
138
|
+
ctx.logger.info(`Browser connected via DataChannel: ${connectionId}`);
|
|
139
|
+
setupDataChannelHandler(dc, connectionId, ctx);
|
|
140
|
+
signaling?.reportConnectionCount(webrtcPeer?.activeCount ?? 0);
|
|
141
|
+
});
|
|
142
|
+
webrtcPeer.on('datachannel-closed', (connectionId) => {
|
|
143
|
+
ctx.logger.info(`Browser disconnected: ${connectionId}`);
|
|
144
|
+
connectedClients.delete(connectionId);
|
|
145
|
+
signaling?.reportConnectionCount(webrtcPeer?.activeCount ?? 0);
|
|
146
|
+
});
|
|
147
|
+
// Wire signaling ICE events to peer manager
|
|
148
|
+
signaling.on('ice-offer', async (offer) => {
|
|
149
|
+
if (!webrtcPeer)
|
|
150
|
+
return;
|
|
151
|
+
try {
|
|
152
|
+
const answer = await webrtcPeer.handleOffer(offer);
|
|
153
|
+
signaling?.sendIceAnswer(answer.connectionId, answer.sdp, answer.candidates);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
ctx.logger.error(`ICE offer handling failed: ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
// ICE servers arrive before offers — buffer them
|
|
160
|
+
signaling.on('ice-servers', (data) => {
|
|
161
|
+
webrtcPeer?.setIceServers(data);
|
|
162
|
+
});
|
|
163
|
+
// Trickle ICE candidates from browser → plugin
|
|
164
|
+
signaling.on('ice-candidate', (data) => {
|
|
165
|
+
webrtcPeer?.handleIceCandidate(data.connectionId, data.candidate);
|
|
166
|
+
});
|
|
167
|
+
// Trickle ICE candidates from plugin → browser
|
|
168
|
+
webrtcPeer.on('ice-candidate-local', (data) => {
|
|
169
|
+
signaling?.sendIceCandidate(data.connectionId, data.candidate);
|
|
170
|
+
});
|
|
171
|
+
await signaling.connect();
|
|
172
|
+
// 7. Start health endpoint for CLI status queries
|
|
173
|
+
healthServer = http.createServer((req, res) => {
|
|
174
|
+
if (req.url === '/status' && req.method === 'GET') {
|
|
175
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
176
|
+
res.end(JSON.stringify({
|
|
177
|
+
version: PLUGIN_VERSION,
|
|
178
|
+
pid: process.pid,
|
|
179
|
+
uptime: process.uptime(),
|
|
180
|
+
gateway: { connected: app?.gatewayClient?.connected ?? false },
|
|
181
|
+
signaling: { connected: signaling?.isConnected ?? false },
|
|
182
|
+
clients: { active: connectedClients.size },
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
res.writeHead(404);
|
|
187
|
+
res.end();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
healthServer.listen(0, '127.0.0.1', () => {
|
|
191
|
+
const addr = healthServer.address();
|
|
192
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
193
|
+
fs.writeFileSync(RUNTIME_FILE, JSON.stringify({
|
|
194
|
+
pid: process.pid,
|
|
195
|
+
healthPort: addr.port,
|
|
196
|
+
startedAt: new Date().toISOString(),
|
|
197
|
+
}, null, 2), { mode: 0o600 });
|
|
198
|
+
ctx.logger.info(`Health endpoint on 127.0.0.1:${addr.port}`);
|
|
199
|
+
});
|
|
200
|
+
ctx.logger.info('ShellChat service started');
|
|
201
|
+
}
|
|
202
|
+
async function stopShellChat(ctx) {
|
|
203
|
+
ctx.logger.info('ShellChat service stopping...');
|
|
204
|
+
// 0. Tear down health endpoint
|
|
205
|
+
if (healthServer) {
|
|
206
|
+
healthServer.close();
|
|
207
|
+
healthServer = null;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
fs.unlinkSync(RUNTIME_FILE);
|
|
211
|
+
}
|
|
212
|
+
catch { /* already gone */ }
|
|
213
|
+
// 1. Notify connected browsers and close DataChannels
|
|
214
|
+
for (const [id, client] of connectedClients) {
|
|
215
|
+
try {
|
|
216
|
+
client.send(JSON.stringify({ type: 'gateway-shutdown' }));
|
|
217
|
+
}
|
|
218
|
+
catch { /* already closed */ }
|
|
219
|
+
connectedClients.delete(id);
|
|
220
|
+
}
|
|
221
|
+
// 2. Close all WebRTC peer connections
|
|
222
|
+
webrtcPeer?.closeAll();
|
|
223
|
+
webrtcPeer = null;
|
|
224
|
+
// 3. Disconnect from signaling server
|
|
225
|
+
signaling?.disconnect();
|
|
226
|
+
signaling = null;
|
|
227
|
+
// 4. Close SQLite databases
|
|
228
|
+
app?.shutdown();
|
|
229
|
+
app = null;
|
|
230
|
+
ctx.logger.info('ShellChat service stopped');
|
|
231
|
+
}
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// DataChannel message handler (spec section 6.4)
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
function setupDataChannelHandler(dc, connectionId, ctx) {
|
|
236
|
+
connectedClients.set(connectionId, dc);
|
|
237
|
+
dc.onMessage(async (data) => {
|
|
238
|
+
console.log(`[DC:${connectionId}] Received: ${data.substring(0, 200)}`);
|
|
239
|
+
let msg;
|
|
240
|
+
try {
|
|
241
|
+
msg = JSON.parse(data);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
dc.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
switch (msg['type']) {
|
|
248
|
+
case 'rpc':
|
|
249
|
+
await handleRpcMessage(dc, msg, ctx);
|
|
250
|
+
break;
|
|
251
|
+
case 'gateway-msg':
|
|
252
|
+
// Forward to local OpenClaw gateway via createApp's gateway client
|
|
253
|
+
if (app?.gatewayClient && typeof msg['payload'] === 'string') {
|
|
254
|
+
app.gatewayClient.sendToGateway(msg['payload']);
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
default:
|
|
258
|
+
dc.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
dc.onClosed(() => {
|
|
262
|
+
connectedClients.delete(connectionId);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
async function handleRpcMessage(dc, msg, ctx) {
|
|
266
|
+
if (!app) {
|
|
267
|
+
dc.send(JSON.stringify({
|
|
268
|
+
type: 'rpc-res',
|
|
269
|
+
id: msg['id'],
|
|
270
|
+
status: 503,
|
|
271
|
+
body: { error: 'Plugin not ready' },
|
|
272
|
+
}));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// API version compatibility check
|
|
276
|
+
const apiVersion = msg['apiVersion'];
|
|
277
|
+
if (apiVersion && apiVersion > CURRENT_API_VERSION) {
|
|
278
|
+
dc.send(JSON.stringify({
|
|
279
|
+
type: 'rpc-res',
|
|
280
|
+
id: msg['id'],
|
|
281
|
+
status: 426,
|
|
282
|
+
body: {
|
|
283
|
+
error: 'upgrade_required',
|
|
284
|
+
pluginVersion: PLUGIN_VERSION,
|
|
285
|
+
message: 'Your gateway plugin needs an update.',
|
|
286
|
+
},
|
|
287
|
+
}));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const rpcReq = {
|
|
291
|
+
id: msg['id'] || '',
|
|
292
|
+
method: msg['method'] || 'GET',
|
|
293
|
+
url: msg['url'] || '/',
|
|
294
|
+
headers: msg['headers'] || { 'content-type': 'application/json' },
|
|
295
|
+
body: msg['body'] != null ? JSON.stringify(msg['body']) : undefined,
|
|
296
|
+
};
|
|
297
|
+
try {
|
|
298
|
+
const response = await dispatchRpc(rpcReq, app.handleRequest);
|
|
299
|
+
const responseMsg = {
|
|
300
|
+
type: 'rpc-res',
|
|
301
|
+
id: response.id,
|
|
302
|
+
status: response.status,
|
|
303
|
+
body: response.body,
|
|
304
|
+
};
|
|
305
|
+
const responseStr = JSON.stringify(responseMsg);
|
|
306
|
+
if (responseStr.length > MAX_DC_MESSAGE_SIZE) {
|
|
307
|
+
sendChunked(dc, response.id, response.status, response.body);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
dc.send(responseStr);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
ctx.logger.error(`RPC error: ${e.message}`);
|
|
315
|
+
dc.send(JSON.stringify({
|
|
316
|
+
type: 'rpc-res',
|
|
317
|
+
id: msg['id'],
|
|
318
|
+
status: 500,
|
|
319
|
+
body: { error: 'Internal plugin error' },
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
function sendChunked(dc, id, status, body) {
|
|
324
|
+
const data = typeof body === 'string' ? body : JSON.stringify(body);
|
|
325
|
+
const chunkSize = MAX_DC_MESSAGE_SIZE - 200; // room for envelope
|
|
326
|
+
const totalChunks = Math.ceil(data.length / chunkSize);
|
|
327
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
328
|
+
dc.send(JSON.stringify({
|
|
329
|
+
type: 'rpc-chunk',
|
|
330
|
+
id,
|
|
331
|
+
status,
|
|
332
|
+
index: i,
|
|
333
|
+
total: totalChunks,
|
|
334
|
+
data: data.slice(i * chunkSize, (i + 1) * chunkSize),
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Broadcast helper
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
function broadcastToClients(msg) {
|
|
342
|
+
const data = JSON.stringify(msg);
|
|
343
|
+
for (const [id, client] of connectedClients) {
|
|
344
|
+
try {
|
|
345
|
+
client.send(data);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
connectedClients.delete(id);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Status helper
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
function formatStatus() {
|
|
356
|
+
const lines = [];
|
|
357
|
+
lines.push(`ShellChat Plugin v${PLUGIN_VERSION}`);
|
|
358
|
+
lines.push(`Gateway: ${app?.gatewayClient?.connected ? 'connected' : 'disconnected'}`);
|
|
359
|
+
lines.push(`Signaling: ${signaling?.isConnected ? 'connected' : 'disconnected'}`);
|
|
360
|
+
lines.push(`Clients: ${connectedClients.size}`);
|
|
361
|
+
return lines.join('\n');
|
|
362
|
+
}
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// CLI handlers
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
async function handleSetup(token) {
|
|
367
|
+
// Decode base64 token
|
|
368
|
+
let tokenData;
|
|
369
|
+
try {
|
|
370
|
+
const decoded = Buffer.from(token, 'base64').toString('utf8');
|
|
371
|
+
tokenData = JSON.parse(decoded);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
console.error('Invalid setup token. Check that you copied it correctly.');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (new Date(tokenData.expiresAt) < new Date()) {
|
|
378
|
+
console.error('Setup token has expired. Generate a new one from shellchat.example.com.');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
console.log('Setting up ShellChat...');
|
|
382
|
+
console.log(` Server: ${tokenData.serverUrl}`);
|
|
383
|
+
// Generate API key for signaling server auth
|
|
384
|
+
const { randomBytes } = await import('node:crypto');
|
|
385
|
+
const apiKey = randomBytes(32).toString('hex');
|
|
386
|
+
// Read gateway token from OpenClaw config
|
|
387
|
+
let gatewayToken = '';
|
|
388
|
+
try {
|
|
389
|
+
const openclawConfigPath = path.join(process.env.HOME || '/root', '.openclaw', 'openclaw.json');
|
|
390
|
+
const openclawConfig = JSON.parse(fs.readFileSync(openclawConfigPath, 'utf8'));
|
|
391
|
+
gatewayToken = openclawConfig.gateway?.auth?.token || openclawConfig.auth?.token || openclawConfig.token || '';
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
console.error('Could not read gateway token from ~/.openclaw/openclaw.json');
|
|
395
|
+
console.error('Make sure OpenClaw is installed and configured.');
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (!gatewayToken) {
|
|
399
|
+
console.error('No gateway token found in ~/.openclaw/openclaw.json');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// Connect to signaling server to complete setup
|
|
403
|
+
const { WebSocket } = await import('ws');
|
|
404
|
+
const ws = new WebSocket(tokenData.serverUrl);
|
|
405
|
+
await new Promise((resolve, reject) => {
|
|
406
|
+
const timeout = setTimeout(() => {
|
|
407
|
+
ws.close();
|
|
408
|
+
reject(new Error('Setup timed out'));
|
|
409
|
+
}, 30_000);
|
|
410
|
+
ws.on('open', () => {
|
|
411
|
+
ws.send(JSON.stringify({
|
|
412
|
+
type: 'setup',
|
|
413
|
+
setupSecret: tokenData.setupSecret,
|
|
414
|
+
apiKey,
|
|
415
|
+
}));
|
|
416
|
+
});
|
|
417
|
+
ws.on('message', (raw) => {
|
|
418
|
+
const msg = JSON.parse(raw.toString());
|
|
419
|
+
if (msg.type === 'setup-complete') {
|
|
420
|
+
clearTimeout(timeout);
|
|
421
|
+
console.log(` User: ${msg.userId}`);
|
|
422
|
+
// Save config
|
|
423
|
+
const config = {
|
|
424
|
+
userId: msg.userId,
|
|
425
|
+
serverUrl: tokenData.serverUrl,
|
|
426
|
+
apiKey,
|
|
427
|
+
gatewayToken,
|
|
428
|
+
schemaVersion: 1,
|
|
429
|
+
installedAt: new Date().toISOString(),
|
|
430
|
+
};
|
|
431
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
432
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
433
|
+
// Create data directories
|
|
434
|
+
const dataDir = path.join(CONFIG_DIR, 'data');
|
|
435
|
+
const uploadsDir = path.join(CONFIG_DIR, 'uploads');
|
|
436
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
437
|
+
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
438
|
+
console.log(' ShellChat is ready!');
|
|
439
|
+
console.log(' Open shellchat.example.com in your browser to start chatting.');
|
|
440
|
+
ws.close();
|
|
441
|
+
resolve();
|
|
442
|
+
}
|
|
443
|
+
else if (msg.type === 'setup-error') {
|
|
444
|
+
clearTimeout(timeout);
|
|
445
|
+
ws.close();
|
|
446
|
+
reject(new Error(`Setup failed: ${msg.reason}`));
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
ws.on('error', (err) => {
|
|
450
|
+
clearTimeout(timeout);
|
|
451
|
+
reject(err);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
async function handleStatus() {
|
|
456
|
+
// CLI runs in a separate process — module-level vars are null here.
|
|
457
|
+
// Query the live service via the health endpoint instead.
|
|
458
|
+
let runtime;
|
|
459
|
+
try {
|
|
460
|
+
runtime = JSON.parse(fs.readFileSync(RUNTIME_FILE, 'utf8'));
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
console.log('ShellChat: offline (service not running)');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// Verify PID is alive
|
|
467
|
+
try {
|
|
468
|
+
process.kill(runtime.pid, 0);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
console.log('ShellChat: offline (stale runtime file)');
|
|
472
|
+
try {
|
|
473
|
+
fs.unlinkSync(RUNTIME_FILE);
|
|
474
|
+
}
|
|
475
|
+
catch { /* ignore */ }
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
// Query health endpoint
|
|
479
|
+
try {
|
|
480
|
+
const body = await new Promise((resolve, reject) => {
|
|
481
|
+
const req = http.get(`http://127.0.0.1:${runtime.healthPort}/status`, (res) => {
|
|
482
|
+
let data = '';
|
|
483
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
484
|
+
res.on('end', () => resolve(data));
|
|
485
|
+
});
|
|
486
|
+
req.on('error', reject);
|
|
487
|
+
req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); });
|
|
488
|
+
});
|
|
489
|
+
const status = JSON.parse(body);
|
|
490
|
+
console.log(`ShellChat Plugin v${status.version}`);
|
|
491
|
+
console.log(`Uptime: ${Math.floor(status.uptime)}s`);
|
|
492
|
+
console.log(`Gateway: ${status.gateway.connected ? 'connected' : 'disconnected'}`);
|
|
493
|
+
console.log(`Signaling: ${status.signaling.connected ? 'connected' : 'disconnected'}`);
|
|
494
|
+
console.log(`Clients: ${status.clients.active}`);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
console.log('ShellChat: offline (could not reach service)');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function handleReset() {
|
|
501
|
+
try {
|
|
502
|
+
fs.rmSync(CONFIG_DIR, { recursive: true, force: true });
|
|
503
|
+
console.log('ShellChat data removed. Plugin disconnected.');
|
|
504
|
+
}
|
|
505
|
+
catch (e) {
|
|
506
|
+
console.error(`Reset failed: ${e.message}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// Plugin definition
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
const plugin = {
|
|
513
|
+
id: PLUGIN_ID,
|
|
514
|
+
name: 'ShellChat',
|
|
515
|
+
description: 'Connects your gateway to ShellChat via WebRTC P2P',
|
|
516
|
+
register(api) {
|
|
517
|
+
// Background service: signaling + gateway bridge + future WebRTC
|
|
518
|
+
api.registerService({
|
|
519
|
+
id: 'shellchat-service',
|
|
520
|
+
start: (ctx) => startShellChat(ctx, api),
|
|
521
|
+
stop: (ctx) => stopShellChat(ctx),
|
|
522
|
+
});
|
|
523
|
+
// CLI commands
|
|
524
|
+
api.registerCli((ctx) => {
|
|
525
|
+
const cmd = ctx.program.command('shellchat');
|
|
526
|
+
cmd.command('setup <token>')
|
|
527
|
+
.description('Set up ShellChat with a setup token')
|
|
528
|
+
.action((token) => handleSetup(String(token)));
|
|
529
|
+
cmd.command('status')
|
|
530
|
+
.description('Show ShellChat connection status')
|
|
531
|
+
.action(() => handleStatus());
|
|
532
|
+
cmd.command('reset')
|
|
533
|
+
.description('Disconnect and remove all ShellChat data')
|
|
534
|
+
.action(() => handleReset());
|
|
535
|
+
});
|
|
536
|
+
// Slash command for status from any channel
|
|
537
|
+
api.registerCommand({
|
|
538
|
+
name: 'shellchat',
|
|
539
|
+
description: 'Show ShellChat tunnel status',
|
|
540
|
+
handler: () => ({ text: formatStatus() }),
|
|
541
|
+
});
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
export default plugin;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema migration runner for the ShellChat plugin SQLite database.
|
|
3
|
+
*
|
|
4
|
+
* - Tracks schema version in a `_schema_version` table
|
|
5
|
+
* - Runs migrations sequentially from current version to target
|
|
6
|
+
* - Creates a backup before migrating (if not `:memory:` and currentVersion > 0)
|
|
7
|
+
* - Wraps all migrations in a transaction with rollback on error
|
|
8
|
+
*/
|
|
9
|
+
import type Database from 'better-sqlite3';
|
|
10
|
+
export declare const SCHEMA_VERSION = 1;
|
|
11
|
+
/**
|
|
12
|
+
* Run all pending migrations against the given database.
|
|
13
|
+
*
|
|
14
|
+
* Safe to call on every startup — exits early if already up to date.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runMigrations(db: Database): void;
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema migration runner for the ShellChat plugin SQLite database.
|
|
3
|
+
*
|
|
4
|
+
* - Tracks schema version in a `_schema_version` table
|
|
5
|
+
* - Runs migrations sequentially from current version to target
|
|
6
|
+
* - Creates a backup before migrating (if not `:memory:` and currentVersion > 0)
|
|
7
|
+
* - Wraps all migrations in a transaction with rollback on error
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
export const SCHEMA_VERSION = 1;
|
|
11
|
+
const migrations = [
|
|
12
|
+
{
|
|
13
|
+
version: 1,
|
|
14
|
+
up: (db) => {
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
session_key TEXT UNIQUE NOT NULL,
|
|
19
|
+
title TEXT DEFAULT 'New chat',
|
|
20
|
+
pinned INTEGER DEFAULT 0,
|
|
21
|
+
pin_order INTEGER DEFAULT 0,
|
|
22
|
+
model TEXT,
|
|
23
|
+
last_session_id TEXT,
|
|
24
|
+
sort_order INTEGER DEFAULT 0,
|
|
25
|
+
unread_count INTEGER DEFAULT 0,
|
|
26
|
+
created_at INTEGER NOT NULL,
|
|
27
|
+
updated_at INTEGER NOT NULL
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
thread_id TEXT NOT NULL,
|
|
33
|
+
role TEXT NOT NULL,
|
|
34
|
+
content TEXT NOT NULL,
|
|
35
|
+
status TEXT DEFAULT 'sent',
|
|
36
|
+
metadata TEXT,
|
|
37
|
+
seq INTEGER,
|
|
38
|
+
timestamp INTEGER NOT NULL,
|
|
39
|
+
created_at INTEGER NOT NULL,
|
|
40
|
+
FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, timestamp);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_messages_dedup ON messages(thread_id, role, timestamp);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS unread_messages (
|
|
47
|
+
thread_id TEXT NOT NULL,
|
|
48
|
+
message_id TEXT NOT NULL,
|
|
49
|
+
created_at INTEGER NOT NULL,
|
|
50
|
+
PRIMARY KEY (thread_id, message_id),
|
|
51
|
+
FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_unread_thread ON unread_messages(thread_id);
|
|
55
|
+
|
|
56
|
+
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
|
57
|
+
content, content=messages, content_rowid=rowid,
|
|
58
|
+
tokenize='porter unicode61'
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
|
|
62
|
+
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
63
|
+
END;
|
|
64
|
+
`);
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Run all pending migrations against the given database.
|
|
70
|
+
*
|
|
71
|
+
* Safe to call on every startup — exits early if already up to date.
|
|
72
|
+
*/
|
|
73
|
+
export function runMigrations(db) {
|
|
74
|
+
// Ensure the version-tracking table exists
|
|
75
|
+
db.exec(`
|
|
76
|
+
CREATE TABLE IF NOT EXISTS _schema_version (
|
|
77
|
+
version INTEGER NOT NULL
|
|
78
|
+
);
|
|
79
|
+
`);
|
|
80
|
+
// Read the current schema version (0 = fresh database)
|
|
81
|
+
const row = db.prepare('SELECT version FROM _schema_version LIMIT 1').get();
|
|
82
|
+
const currentVersion = row?.version ?? 0;
|
|
83
|
+
if (currentVersion >= SCHEMA_VERSION) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Create a backup before migrating an existing database
|
|
87
|
+
const dbPath = db.name;
|
|
88
|
+
if (currentVersion > 0 && dbPath !== ':memory:') {
|
|
89
|
+
const backupPath = `${dbPath}.backup-v${currentVersion}`;
|
|
90
|
+
fs.copyFileSync(dbPath, backupPath);
|
|
91
|
+
}
|
|
92
|
+
// Run all pending migrations inside a single transaction
|
|
93
|
+
const migrate = db.transaction(() => {
|
|
94
|
+
for (const migration of migrations) {
|
|
95
|
+
if (migration.version > currentVersion) {
|
|
96
|
+
migration.up(db);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Record the new schema version
|
|
100
|
+
if (row === undefined) {
|
|
101
|
+
db.prepare('INSERT INTO _schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
db.prepare('UPDATE _schema_version SET version = ?').run(SCHEMA_VERSION);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
migrate();
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
// better-sqlite3 transactions automatically roll back on throw
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|