@spikelabs/lobster-shell-plugin 0.2.2
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/README.md +80 -0
- package/dist/chunk-RNTMVHEF.js +202 -0
- package/dist/index.d.ts +191 -0
- package/dist/index.js +905 -0
- package/dist/oauth-flow-O6AKA5CZ.js +6 -0
- package/openclaw.plugin.json +22 -0
- package/package.json +63 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OAuthFlow
|
|
3
|
+
} from "./chunk-RNTMVHEF.js";
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
import { hostname, platform } from "os";
|
|
7
|
+
|
|
8
|
+
// src/setup-page.ts
|
|
9
|
+
function renderSetupPage(opts) {
|
|
10
|
+
const statusColor = opts.connected ? "#22c55e" : "#ef4444";
|
|
11
|
+
const statusText = opts.connected ? "Connected" : "Disconnected";
|
|
12
|
+
return (
|
|
13
|
+
/* html */
|
|
14
|
+
`<!DOCTYPE html>
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="UTF-8">
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
19
|
+
<title>Lobster Shell \u2014 Setup</title>
|
|
20
|
+
<style>
|
|
21
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
22
|
+
body {
|
|
23
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
24
|
+
background: #0a0a0a;
|
|
25
|
+
color: #e5e5e5;
|
|
26
|
+
min-height: 100vh;
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
}
|
|
31
|
+
.card {
|
|
32
|
+
background: #171717;
|
|
33
|
+
border: 1px solid #262626;
|
|
34
|
+
border-radius: 12px;
|
|
35
|
+
padding: 2rem;
|
|
36
|
+
max-width: 420px;
|
|
37
|
+
width: 100%;
|
|
38
|
+
}
|
|
39
|
+
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
|
40
|
+
.status {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 0.5rem;
|
|
44
|
+
margin: 1rem 0;
|
|
45
|
+
font-size: 0.9rem;
|
|
46
|
+
}
|
|
47
|
+
.dot {
|
|
48
|
+
width: 10px;
|
|
49
|
+
height: 10px;
|
|
50
|
+
border-radius: 50%;
|
|
51
|
+
background: ${statusColor};
|
|
52
|
+
}
|
|
53
|
+
.info {
|
|
54
|
+
background: #1a1a2e;
|
|
55
|
+
border-radius: 8px;
|
|
56
|
+
padding: 0.75rem 1rem;
|
|
57
|
+
font-size: 0.8rem;
|
|
58
|
+
color: #a1a1aa;
|
|
59
|
+
margin: 1rem 0;
|
|
60
|
+
word-break: break-all;
|
|
61
|
+
}
|
|
62
|
+
.info strong { color: #e5e5e5; }
|
|
63
|
+
.btn {
|
|
64
|
+
display: inline-block;
|
|
65
|
+
padding: 0.6rem 1.2rem;
|
|
66
|
+
border-radius: 8px;
|
|
67
|
+
border: none;
|
|
68
|
+
font-size: 0.9rem;
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
font-weight: 500;
|
|
72
|
+
}
|
|
73
|
+
.btn-primary {
|
|
74
|
+
background: #6366f1;
|
|
75
|
+
color: white;
|
|
76
|
+
}
|
|
77
|
+
.btn-primary:hover { background: #4f46e5; }
|
|
78
|
+
.btn-danger {
|
|
79
|
+
background: #dc2626;
|
|
80
|
+
color: white;
|
|
81
|
+
margin-left: 0.5rem;
|
|
82
|
+
}
|
|
83
|
+
.btn-danger:hover { background: #b91c1c; }
|
|
84
|
+
.message {
|
|
85
|
+
background: #1a2e1a;
|
|
86
|
+
border: 1px solid #22c55e33;
|
|
87
|
+
color: #86efac;
|
|
88
|
+
padding: 0.5rem 0.75rem;
|
|
89
|
+
border-radius: 6px;
|
|
90
|
+
font-size: 0.8rem;
|
|
91
|
+
margin-bottom: 1rem;
|
|
92
|
+
}
|
|
93
|
+
.error {
|
|
94
|
+
background: #2e1a1a;
|
|
95
|
+
border: 1px solid #ef444433;
|
|
96
|
+
color: #fca5a5;
|
|
97
|
+
padding: 0.5rem 0.75rem;
|
|
98
|
+
border-radius: 6px;
|
|
99
|
+
font-size: 0.8rem;
|
|
100
|
+
margin-bottom: 1rem;
|
|
101
|
+
}
|
|
102
|
+
</style>
|
|
103
|
+
</head>
|
|
104
|
+
<body>
|
|
105
|
+
<div class="card">
|
|
106
|
+
<h1>Lobster Shell</h1>
|
|
107
|
+
<p style="color: #a1a1aa; font-size: 0.85rem;">Connect your OpenClaw agent to Lobster Shell cloud.</p>
|
|
108
|
+
|
|
109
|
+
${opts.message ? `<div class="message">${opts.message}</div>` : ""}
|
|
110
|
+
${opts.error ? `<div class="error">${opts.error}</div>` : ""}
|
|
111
|
+
|
|
112
|
+
<div class="status">
|
|
113
|
+
<span class="dot"></span>
|
|
114
|
+
<span>${statusText}</span>
|
|
115
|
+
${opts.bridgeStatus && opts.bridgeStatus !== statusText.toLowerCase() ? `<span style="color:#a1a1aa">(bridge: ${opts.bridgeStatus})</span>` : ""}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
${opts.connected ? `
|
|
119
|
+
<div class="info">
|
|
120
|
+
<div><strong>Gateway ID:</strong> ${opts.gatewayPublicId}</div>
|
|
121
|
+
<div><strong>Channel:</strong> ${opts.channelName}</div>
|
|
122
|
+
</div>
|
|
123
|
+
` : ""}
|
|
124
|
+
|
|
125
|
+
<div style="margin-top: 1rem;">
|
|
126
|
+
${opts.connected ? `<span style="color: #a1a1aa; font-size: 0.85rem;">Gateway is connected to Lobster Shell cloud.</span>` : `<a href="/lobster/connect" class="btn btn-primary">Connect to Lobster Shell</a>`}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</body>
|
|
130
|
+
</html>`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/cloud-bridge.ts
|
|
135
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
136
|
+
import { join } from "path";
|
|
137
|
+
|
|
138
|
+
// src/bridge-protocol.ts
|
|
139
|
+
var ABLY_EVENT = {
|
|
140
|
+
PLUGIN: "plugin-event",
|
|
141
|
+
COMMAND: "cloud-command"
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// src/cloud-bridge.ts
|
|
145
|
+
var CloudBridge = class {
|
|
146
|
+
cloudUrl;
|
|
147
|
+
stateDir;
|
|
148
|
+
logger;
|
|
149
|
+
_status = "disconnected";
|
|
150
|
+
ably = null;
|
|
151
|
+
channel = null;
|
|
152
|
+
heartbeatTimer = null;
|
|
153
|
+
commandHandlers = [];
|
|
154
|
+
constructor(opts) {
|
|
155
|
+
this.cloudUrl = opts.cloudUrl;
|
|
156
|
+
this.stateDir = opts.stateDir;
|
|
157
|
+
this.logger = opts.logger;
|
|
158
|
+
}
|
|
159
|
+
get status() {
|
|
160
|
+
return this._status;
|
|
161
|
+
}
|
|
162
|
+
onCommand(handler) {
|
|
163
|
+
this.commandHandlers.push(handler);
|
|
164
|
+
}
|
|
165
|
+
// -------------------------------------------------------------------------
|
|
166
|
+
// Connect
|
|
167
|
+
// -------------------------------------------------------------------------
|
|
168
|
+
async connect() {
|
|
169
|
+
const auth = await this.loadAuth();
|
|
170
|
+
if (!auth) {
|
|
171
|
+
this.logger.info("[lobster:bridge] No auth credentials \u2014 skipping connection");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this._status = "connecting";
|
|
175
|
+
this.logger.info("[lobster:bridge] Connecting to Ably\u2026");
|
|
176
|
+
try {
|
|
177
|
+
const Ably = await import("ably");
|
|
178
|
+
this.ably = new Ably.Realtime({
|
|
179
|
+
authCallback: async (_params, callback) => {
|
|
180
|
+
try {
|
|
181
|
+
const tokenResponse = await this.fetchAblyToken(auth);
|
|
182
|
+
callback(null, tokenResponse.tokenRequest);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
this.logger.error(`[lobster:bridge] Ably auth failed: ${err}`);
|
|
185
|
+
callback(err instanceof Error ? err.message : String(err), null);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
autoConnect: true,
|
|
189
|
+
disconnectedRetryTimeout: 1e3,
|
|
190
|
+
suspendedRetryTimeout: 5e3
|
|
191
|
+
});
|
|
192
|
+
this.ably.connection.on("connected", () => {
|
|
193
|
+
this._status = "connected";
|
|
194
|
+
this.logger.info("[lobster:bridge] Connected to Ably");
|
|
195
|
+
this.startHeartbeat();
|
|
196
|
+
});
|
|
197
|
+
this.ably.connection.on("disconnected", () => {
|
|
198
|
+
this._status = "disconnected";
|
|
199
|
+
this.logger.warn("[lobster:bridge] Disconnected from Ably, will retry\u2026");
|
|
200
|
+
});
|
|
201
|
+
this.ably.connection.on("failed", () => {
|
|
202
|
+
this._status = "error";
|
|
203
|
+
this.logger.error("[lobster:bridge] Ably connection failed");
|
|
204
|
+
});
|
|
205
|
+
this.channel = this.ably.channels.get(auth.channelName);
|
|
206
|
+
this.channel.on("failed", (stateChange) => {
|
|
207
|
+
this._status = "error";
|
|
208
|
+
this.logger.error(
|
|
209
|
+
`[lobster:bridge] Channel failed: ${stateChange.reason?.message ?? "unknown"}`
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
try {
|
|
213
|
+
await this.channel.subscribe(ABLY_EVENT.COMMAND, (message) => {
|
|
214
|
+
const cmd = message.data;
|
|
215
|
+
this.logger.info(`[lobster:bridge] Received command: ${cmd.type}`);
|
|
216
|
+
for (const handler of this.commandHandlers) {
|
|
217
|
+
try {
|
|
218
|
+
handler(cmd);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
this.logger.error(`[lobster:bridge] Command handler error: ${err}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
} catch (err) {
|
|
225
|
+
this.logger.error(`[lobster:bridge] Channel subscribe failed: ${err}`);
|
|
226
|
+
this._status = "error";
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
await this.channel.presence.enter({ status: "online" });
|
|
231
|
+
} catch (err) {
|
|
232
|
+
this.logger.warn(`[lobster:bridge] Presence enter failed: ${err}`);
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
this._status = "error";
|
|
236
|
+
this.logger.error(`[lobster:bridge] Connection error: ${err}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// -------------------------------------------------------------------------
|
|
240
|
+
// Disconnect
|
|
241
|
+
// -------------------------------------------------------------------------
|
|
242
|
+
async disconnect() {
|
|
243
|
+
this.stopHeartbeat();
|
|
244
|
+
if (this.channel) {
|
|
245
|
+
try {
|
|
246
|
+
await this.channel.presence.leave();
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
this.channel.unsubscribe();
|
|
250
|
+
this.channel = null;
|
|
251
|
+
}
|
|
252
|
+
if (this.ably) {
|
|
253
|
+
this.ably.close();
|
|
254
|
+
this.ably = null;
|
|
255
|
+
}
|
|
256
|
+
this._status = "disconnected";
|
|
257
|
+
this.logger.info("[lobster:bridge] Disconnected");
|
|
258
|
+
}
|
|
259
|
+
// -------------------------------------------------------------------------
|
|
260
|
+
// Publish (fire-and-forget)
|
|
261
|
+
// -------------------------------------------------------------------------
|
|
262
|
+
publish(event) {
|
|
263
|
+
if (!this.channel || this._status !== "connected") return;
|
|
264
|
+
this.channel.publish(ABLY_EVENT.PLUGIN, event).catch((err) => {
|
|
265
|
+
this.logger.warn(`[lobster:bridge] Publish failed: ${err}`);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
// -------------------------------------------------------------------------
|
|
269
|
+
// Tenant JWT
|
|
270
|
+
// -------------------------------------------------------------------------
|
|
271
|
+
/**
|
|
272
|
+
* Get a valid tenant JWT, refreshing from cloud if expired or near-expiry.
|
|
273
|
+
* Returns null if not authenticated.
|
|
274
|
+
*/
|
|
275
|
+
async getTenantToken() {
|
|
276
|
+
const auth = await this.loadAuth();
|
|
277
|
+
if (!auth?.tenantToken) return null;
|
|
278
|
+
const expiresAt = auth.tenantTokenExpiresAt ? new Date(auth.tenantTokenExpiresAt).getTime() : 0;
|
|
279
|
+
if (Date.now() > expiresAt - 5 * 60 * 1e3) {
|
|
280
|
+
const refreshed = await this.refreshTenantToken(auth);
|
|
281
|
+
return refreshed;
|
|
282
|
+
}
|
|
283
|
+
return auth.tenantToken;
|
|
284
|
+
}
|
|
285
|
+
async refreshTenantToken(auth) {
|
|
286
|
+
this.logger.info("[lobster:bridge] Refreshing tenant JWT\u2026");
|
|
287
|
+
try {
|
|
288
|
+
const res = await fetch(`${this.cloudUrl}/api/plugin/token`, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: {
|
|
291
|
+
"Content-Type": "application/json",
|
|
292
|
+
Authorization: `Bearer ${auth.accessToken}`
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
if (!res.ok) {
|
|
296
|
+
this.logger.warn(`[lobster:bridge] Tenant token refresh failed: ${res.status}`);
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
const data = await res.json();
|
|
300
|
+
const updated = {
|
|
301
|
+
...auth,
|
|
302
|
+
tenantToken: data.token,
|
|
303
|
+
tenantTokenExpiresAt: data.expiresAt
|
|
304
|
+
};
|
|
305
|
+
await mkdir(this.stateDir, { recursive: true });
|
|
306
|
+
await writeFile(
|
|
307
|
+
join(this.stateDir, "auth.json"),
|
|
308
|
+
JSON.stringify(updated, null, 2)
|
|
309
|
+
);
|
|
310
|
+
this.logger.info("[lobster:bridge] Tenant JWT refreshed");
|
|
311
|
+
return data.token;
|
|
312
|
+
} catch (err) {
|
|
313
|
+
this.logger.error(`[lobster:bridge] Tenant token refresh error: ${err}`);
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// -------------------------------------------------------------------------
|
|
318
|
+
// Internals
|
|
319
|
+
// -------------------------------------------------------------------------
|
|
320
|
+
async loadAuth() {
|
|
321
|
+
try {
|
|
322
|
+
const raw = await readFile(join(this.stateDir, "auth.json"), "utf-8");
|
|
323
|
+
return JSON.parse(raw);
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async fetchAblyToken(auth) {
|
|
329
|
+
const res = await fetch(`${this.cloudUrl}/api/plugin/ably-token`, {
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: {
|
|
332
|
+
"Content-Type": "application/json",
|
|
333
|
+
Authorization: `Bearer ${auth.accessToken}`
|
|
334
|
+
},
|
|
335
|
+
body: JSON.stringify({ gatewayPublicId: auth.gatewayPublicId })
|
|
336
|
+
});
|
|
337
|
+
if (res.status === 401 && auth.refreshToken) {
|
|
338
|
+
this.logger.info("[lobster:bridge] Access token expired, refreshing\u2026");
|
|
339
|
+
const { OAuthFlow: OAuthFlow2 } = await import("./oauth-flow-O6AKA5CZ.js");
|
|
340
|
+
const oauthFlow2 = new OAuthFlow2({
|
|
341
|
+
stateDir: this.stateDir,
|
|
342
|
+
cloudUrl: this.cloudUrl,
|
|
343
|
+
logger: this.logger
|
|
344
|
+
});
|
|
345
|
+
const refreshed = await oauthFlow2.refreshAccessToken();
|
|
346
|
+
if (!refreshed) {
|
|
347
|
+
throw new Error("Access token expired and refresh failed \u2014 re-authenticate at /lobster/connect");
|
|
348
|
+
}
|
|
349
|
+
const retryRes = await fetch(`${this.cloudUrl}/api/plugin/ably-token`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: {
|
|
352
|
+
"Content-Type": "application/json",
|
|
353
|
+
Authorization: `Bearer ${refreshed.accessToken}`
|
|
354
|
+
},
|
|
355
|
+
body: JSON.stringify({ gatewayPublicId: refreshed.gatewayPublicId })
|
|
356
|
+
});
|
|
357
|
+
if (!retryRes.ok) {
|
|
358
|
+
throw new Error(`Ably token request failed after refresh: ${retryRes.status}`);
|
|
359
|
+
}
|
|
360
|
+
return await retryRes.json();
|
|
361
|
+
}
|
|
362
|
+
if (!res.ok) {
|
|
363
|
+
throw new Error(`Ably token request failed: ${res.status}`);
|
|
364
|
+
}
|
|
365
|
+
return await res.json();
|
|
366
|
+
}
|
|
367
|
+
startHeartbeat() {
|
|
368
|
+
this.stopHeartbeat();
|
|
369
|
+
this.heartbeatTimer = setInterval(() => {
|
|
370
|
+
this.publish({ type: "heartbeat", timestamp: Date.now() });
|
|
371
|
+
}, 3e4);
|
|
372
|
+
}
|
|
373
|
+
stopHeartbeat() {
|
|
374
|
+
if (this.heartbeatTimer) {
|
|
375
|
+
clearInterval(this.heartbeatTimer);
|
|
376
|
+
this.heartbeatTimer = null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// src/gateway-ws-client.ts
|
|
382
|
+
import { randomUUID } from "crypto";
|
|
383
|
+
var GatewayWsClient = class {
|
|
384
|
+
port;
|
|
385
|
+
token;
|
|
386
|
+
logger;
|
|
387
|
+
ws = null;
|
|
388
|
+
pending = /* @__PURE__ */ new Map();
|
|
389
|
+
_connected = false;
|
|
390
|
+
chatHandler = null;
|
|
391
|
+
constructor(opts) {
|
|
392
|
+
this.port = opts.port;
|
|
393
|
+
this.token = opts.token;
|
|
394
|
+
this.logger = opts.logger;
|
|
395
|
+
}
|
|
396
|
+
get connected() {
|
|
397
|
+
return this._connected;
|
|
398
|
+
}
|
|
399
|
+
onChat(handler) {
|
|
400
|
+
this.chatHandler = handler;
|
|
401
|
+
}
|
|
402
|
+
// -------------------------------------------------------------------------
|
|
403
|
+
// Connect
|
|
404
|
+
// -------------------------------------------------------------------------
|
|
405
|
+
async connect() {
|
|
406
|
+
const WebSocket = (await import("ws")).default;
|
|
407
|
+
return new Promise((resolve, reject) => {
|
|
408
|
+
const url = `ws://127.0.0.1:${this.port}`;
|
|
409
|
+
this.logger.info(`[lobster:ws] Connecting to gateway at ${url}`);
|
|
410
|
+
this.ws = new WebSocket(url, { handshakeTimeout: 8e3 });
|
|
411
|
+
const openTimeout = setTimeout(() => {
|
|
412
|
+
reject(new Error("WS open timeout"));
|
|
413
|
+
this.ws?.close();
|
|
414
|
+
}, 1e4);
|
|
415
|
+
this.ws.once("open", () => {
|
|
416
|
+
clearTimeout(openTimeout);
|
|
417
|
+
this.setupMessageHandler();
|
|
418
|
+
this.handshake().then(resolve).catch(reject);
|
|
419
|
+
});
|
|
420
|
+
this.ws.once("error", (err) => {
|
|
421
|
+
clearTimeout(openTimeout);
|
|
422
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
423
|
+
});
|
|
424
|
+
this.ws.on("close", () => {
|
|
425
|
+
this._connected = false;
|
|
426
|
+
this.logger.warn("[lobster:ws] Gateway WebSocket closed");
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// -------------------------------------------------------------------------
|
|
431
|
+
// Send message to agent
|
|
432
|
+
// -------------------------------------------------------------------------
|
|
433
|
+
async sendMessage(text, sessionKey) {
|
|
434
|
+
if (!this._connected) {
|
|
435
|
+
this.logger.warn("[lobster:ws] Cannot send \u2014 not connected to gateway");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.logger.info(`[lobster:ws] \u2192 Sending to gateway: text="${text.slice(0, 100)}" sessionKey=${sessionKey ?? "main"}`);
|
|
439
|
+
const res = await this.request("chat.send", {
|
|
440
|
+
sessionKey: sessionKey ?? "main",
|
|
441
|
+
message: text,
|
|
442
|
+
idempotencyKey: randomUUID()
|
|
443
|
+
});
|
|
444
|
+
if (!res.ok) {
|
|
445
|
+
this.logger.error(`[lobster:ws] chat.send failed: ${JSON.stringify(res.error)}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// -------------------------------------------------------------------------
|
|
449
|
+
// Disconnect
|
|
450
|
+
// -------------------------------------------------------------------------
|
|
451
|
+
disconnect() {
|
|
452
|
+
for (const waiter of this.pending.values()) {
|
|
453
|
+
clearTimeout(waiter.timeout);
|
|
454
|
+
}
|
|
455
|
+
this.pending.clear();
|
|
456
|
+
if (this.ws) {
|
|
457
|
+
this.ws.close();
|
|
458
|
+
this.ws = null;
|
|
459
|
+
}
|
|
460
|
+
this._connected = false;
|
|
461
|
+
}
|
|
462
|
+
// -------------------------------------------------------------------------
|
|
463
|
+
// Internals
|
|
464
|
+
// -------------------------------------------------------------------------
|
|
465
|
+
async handshake() {
|
|
466
|
+
const res = await this.request("connect", {
|
|
467
|
+
minProtocol: 3,
|
|
468
|
+
maxProtocol: 3,
|
|
469
|
+
client: {
|
|
470
|
+
id: "cli",
|
|
471
|
+
displayName: "Lobster Shell Plugin",
|
|
472
|
+
version: "0.1.0",
|
|
473
|
+
platform: "node",
|
|
474
|
+
mode: "cli",
|
|
475
|
+
instanceId: "lobster-shell-bridge"
|
|
476
|
+
},
|
|
477
|
+
role: "operator",
|
|
478
|
+
scopes: ["operator.read", "operator.write"],
|
|
479
|
+
auth: { token: this.token }
|
|
480
|
+
});
|
|
481
|
+
if (!res.ok) {
|
|
482
|
+
throw new Error(`Gateway handshake failed: ${JSON.stringify(res.error)}`);
|
|
483
|
+
}
|
|
484
|
+
this._connected = true;
|
|
485
|
+
this.logger.info("[lobster:ws] Connected to gateway as operator");
|
|
486
|
+
}
|
|
487
|
+
request(method, params, timeoutMs = 15e3) {
|
|
488
|
+
return new Promise((resolve, reject) => {
|
|
489
|
+
if (!this.ws || this.ws.readyState !== 1) {
|
|
490
|
+
reject(new Error("WebSocket not open"));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const id = randomUUID();
|
|
494
|
+
const frame = { type: "req", id, method, params };
|
|
495
|
+
const timeout = setTimeout(() => {
|
|
496
|
+
this.pending.delete(id);
|
|
497
|
+
reject(new Error(`Timeout waiting for ${method}`));
|
|
498
|
+
}, timeoutMs);
|
|
499
|
+
this.pending.set(id, { resolve, reject, timeout });
|
|
500
|
+
this.ws.send(JSON.stringify(frame));
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
setupMessageHandler() {
|
|
504
|
+
if (!this.ws) return;
|
|
505
|
+
this.ws.on("message", (data) => {
|
|
506
|
+
const text = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
507
|
+
let frame;
|
|
508
|
+
try {
|
|
509
|
+
frame = JSON.parse(text);
|
|
510
|
+
} catch {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (frame.type === "res") {
|
|
514
|
+
const waiter = this.pending.get(frame.id);
|
|
515
|
+
if (waiter) {
|
|
516
|
+
this.pending.delete(frame.id);
|
|
517
|
+
clearTimeout(waiter.timeout);
|
|
518
|
+
waiter.resolve(frame);
|
|
519
|
+
}
|
|
520
|
+
} else if (frame.type === "event" && frame.event === "chat") {
|
|
521
|
+
const payload = frame.payload;
|
|
522
|
+
const contentPreview = payload.message?.content ? typeof payload.message.content === "string" ? payload.message.content.slice(0, 80) : JSON.stringify(payload.message.content).slice(0, 80) : "(none)";
|
|
523
|
+
this.logger.info(
|
|
524
|
+
`[lobster:ws] \u2190 Gateway chat event: state=${payload.state} role=${payload.message?.role ?? "?"} sessionKey=${payload.sessionKey} content="${contentPreview}"`
|
|
525
|
+
);
|
|
526
|
+
if (this.chatHandler) {
|
|
527
|
+
this.chatHandler(payload);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
// src/api-client.ts
|
|
535
|
+
var bridge = null;
|
|
536
|
+
function initApiClient(cloudBridge) {
|
|
537
|
+
bridge = cloudBridge;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/index.ts
|
|
541
|
+
var PLUGIN_ID = "lobster-shell";
|
|
542
|
+
var CHANNEL_ID = "lobster-shell";
|
|
543
|
+
var PLUGIN_VERSION = "0.2.2";
|
|
544
|
+
var DEFAULT_CLOUD_URL = "https://www.lobstershell.ai";
|
|
545
|
+
var gatewayPort;
|
|
546
|
+
var bridge2 = null;
|
|
547
|
+
var oauthFlow = null;
|
|
548
|
+
var serviceCtx = null;
|
|
549
|
+
var gatewayWs = null;
|
|
550
|
+
function getCloudUrl(api) {
|
|
551
|
+
const cfg = api.pluginConfig ?? {};
|
|
552
|
+
return cfg["cloudUrl"] ?? DEFAULT_CLOUD_URL;
|
|
553
|
+
}
|
|
554
|
+
function buildGatewayInfo() {
|
|
555
|
+
return {
|
|
556
|
+
pluginVersion: PLUGIN_VERSION,
|
|
557
|
+
gatewayPort: gatewayPort ?? 18789,
|
|
558
|
+
os: `${platform()} ${process.arch}`,
|
|
559
|
+
hostname: hostname()
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function parseQuery(req) {
|
|
563
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
564
|
+
const query = {};
|
|
565
|
+
for (const [key, value] of url.searchParams) {
|
|
566
|
+
query[key] = value;
|
|
567
|
+
}
|
|
568
|
+
return query;
|
|
569
|
+
}
|
|
570
|
+
function sendHtml(res, statusCode, html) {
|
|
571
|
+
res.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8" });
|
|
572
|
+
res.end(html);
|
|
573
|
+
}
|
|
574
|
+
function sendRedirect(res, location) {
|
|
575
|
+
res.writeHead(302, { Location: location });
|
|
576
|
+
res.end();
|
|
577
|
+
}
|
|
578
|
+
function sendText(res, statusCode, text) {
|
|
579
|
+
res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8" });
|
|
580
|
+
res.end(text);
|
|
581
|
+
}
|
|
582
|
+
var plugin = {
|
|
583
|
+
id: PLUGIN_ID,
|
|
584
|
+
name: "Lobster Shell",
|
|
585
|
+
description: "Connect your OpenClaw agent to Lobster Shell cloud",
|
|
586
|
+
version: PLUGIN_VERSION,
|
|
587
|
+
register(api) {
|
|
588
|
+
const { logger } = api;
|
|
589
|
+
logger.info("Lobster Shell plugin loading\u2026");
|
|
590
|
+
const cloudUrl = getCloudUrl(api);
|
|
591
|
+
api.registerChannel({
|
|
592
|
+
id: CHANNEL_ID,
|
|
593
|
+
meta: {
|
|
594
|
+
id: CHANNEL_ID,
|
|
595
|
+
label: "Lobster Shell",
|
|
596
|
+
selectionLabel: "Lobster Shell",
|
|
597
|
+
docsPath: "/plugins/lobster-shell",
|
|
598
|
+
blurb: "AI avatar powered by Lobster Shell"
|
|
599
|
+
},
|
|
600
|
+
capabilities: {
|
|
601
|
+
chatTypes: ["direct"],
|
|
602
|
+
media: true
|
|
603
|
+
},
|
|
604
|
+
config: {
|
|
605
|
+
listAccountIds: () => ["default"],
|
|
606
|
+
resolveAccount: () => ({ accountId: "default", enabled: true })
|
|
607
|
+
},
|
|
608
|
+
outbound: {
|
|
609
|
+
deliveryMode: "direct",
|
|
610
|
+
async sendText(ctx) {
|
|
611
|
+
logger.info(
|
|
612
|
+
`[lobster:outbound] to=${ctx.to} text=${ctx.text.slice(0, 120)}\u2026`
|
|
613
|
+
);
|
|
614
|
+
bridge2?.publish({
|
|
615
|
+
type: "agent_response",
|
|
616
|
+
channelId: CHANNEL_ID,
|
|
617
|
+
to: ctx.to,
|
|
618
|
+
text: ctx.text
|
|
619
|
+
});
|
|
620
|
+
return { ok: true, messageId: `spike-${Date.now()}` };
|
|
621
|
+
},
|
|
622
|
+
async sendMedia(ctx) {
|
|
623
|
+
logger.info(
|
|
624
|
+
`[lobster:outbound] media to=${ctx.to} url=${ctx.mediaUrl ?? "none"}`
|
|
625
|
+
);
|
|
626
|
+
return { ok: true, messageId: `spike-media-${Date.now()}` };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
api.on("message_sent", (event, ctx) => {
|
|
631
|
+
logger.info(
|
|
632
|
+
`[lobster:hook] message_sent ch=${ctx.channelId} to=${event.to} ok=${event.success}`
|
|
633
|
+
);
|
|
634
|
+
bridge2?.publish({
|
|
635
|
+
type: "agent_response",
|
|
636
|
+
channelId: ctx.channelId,
|
|
637
|
+
to: event.to,
|
|
638
|
+
text: event.content ?? "",
|
|
639
|
+
conversationId: ctx.conversationId
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
api.on(
|
|
643
|
+
"message_received",
|
|
644
|
+
(event, ctx) => {
|
|
645
|
+
logger.info(
|
|
646
|
+
`[lobster:hook] message_received ch=${ctx.channelId} from=${event.from}`
|
|
647
|
+
);
|
|
648
|
+
bridge2?.publish({
|
|
649
|
+
type: "message_received",
|
|
650
|
+
channelId: ctx.channelId,
|
|
651
|
+
from: event.from,
|
|
652
|
+
content: event.content,
|
|
653
|
+
conversationId: ctx.conversationId,
|
|
654
|
+
timestamp: event.timestamp
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
);
|
|
658
|
+
api.on("gateway_start", (event, _ctx) => {
|
|
659
|
+
gatewayPort = event.port;
|
|
660
|
+
logger.info(`[lobster:hook] gateway started on port ${gatewayPort}`);
|
|
661
|
+
bridge2?.publish({ type: "gateway_start", port: gatewayPort });
|
|
662
|
+
});
|
|
663
|
+
api.registerHttpRoute({
|
|
664
|
+
path: "/lobster/setup",
|
|
665
|
+
auth: "plugin",
|
|
666
|
+
async handler(req, res) {
|
|
667
|
+
if (req.method !== "GET") {
|
|
668
|
+
sendText(res, 405, "Method Not Allowed");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const query = parseQuery(req);
|
|
672
|
+
const flow = oauthFlow;
|
|
673
|
+
const auth = flow ? await flow.loadAuth() : null;
|
|
674
|
+
sendHtml(
|
|
675
|
+
res,
|
|
676
|
+
200,
|
|
677
|
+
renderSetupPage({
|
|
678
|
+
connected: !!auth,
|
|
679
|
+
gatewayPublicId: auth?.gatewayPublicId,
|
|
680
|
+
channelName: auth?.channelName,
|
|
681
|
+
bridgeStatus: bridge2?.status ?? "not started",
|
|
682
|
+
message: query["success"] ? "Connected to Lobster Shell!" : void 0
|
|
683
|
+
})
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
api.registerHttpRoute({
|
|
688
|
+
path: "/lobster/connect",
|
|
689
|
+
auth: "plugin",
|
|
690
|
+
async handler(req, res) {
|
|
691
|
+
if (req.method !== "GET") {
|
|
692
|
+
sendText(res, 405, "Method Not Allowed");
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (!oauthFlow) {
|
|
696
|
+
sendText(res, 500, "Plugin not initialized yet");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const host = req.headers.host ?? `localhost:${gatewayPort ?? 18789}`;
|
|
700
|
+
const proto = req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
|
|
701
|
+
const redirectUri = `${proto}://${host}/lobster/oauth/callback`;
|
|
702
|
+
try {
|
|
703
|
+
const authUrl = await oauthFlow.initiateFlow(redirectUri);
|
|
704
|
+
sendRedirect(res, authUrl);
|
|
705
|
+
} catch (err) {
|
|
706
|
+
logger.error(`[lobster:oauth] Failed to initiate flow: ${err}`);
|
|
707
|
+
sendHtml(
|
|
708
|
+
res,
|
|
709
|
+
200,
|
|
710
|
+
renderSetupPage({
|
|
711
|
+
connected: false,
|
|
712
|
+
error: `OAuth initiation failed: ${err instanceof Error ? err.message : String(err)}`
|
|
713
|
+
})
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
api.registerHttpRoute({
|
|
719
|
+
path: "/lobster/oauth/callback",
|
|
720
|
+
auth: "plugin",
|
|
721
|
+
async handler(req, res) {
|
|
722
|
+
if (req.method !== "GET") {
|
|
723
|
+
sendText(res, 405, "Method Not Allowed");
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (!oauthFlow) {
|
|
727
|
+
sendText(res, 500, "Plugin not initialized yet");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const query = parseQuery(req);
|
|
731
|
+
try {
|
|
732
|
+
await oauthFlow.handleCallback(query, buildGatewayInfo());
|
|
733
|
+
if (bridge2 && serviceCtx) {
|
|
734
|
+
await bridge2.disconnect();
|
|
735
|
+
await bridge2.connect();
|
|
736
|
+
}
|
|
737
|
+
sendRedirect(res, "/lobster/setup?success=1");
|
|
738
|
+
} catch (err) {
|
|
739
|
+
logger.error(`[lobster:oauth] Callback failed: ${err}`);
|
|
740
|
+
sendHtml(
|
|
741
|
+
res,
|
|
742
|
+
200,
|
|
743
|
+
renderSetupPage({
|
|
744
|
+
connected: false,
|
|
745
|
+
error: `OAuth callback failed: ${err instanceof Error ? err.message : String(err)}`
|
|
746
|
+
})
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
api.registerService({
|
|
752
|
+
id: `${PLUGIN_ID}-bridge`,
|
|
753
|
+
async start(ctx) {
|
|
754
|
+
serviceCtx = ctx;
|
|
755
|
+
ctx.logger.info("[lobster:bridge] service starting\u2026");
|
|
756
|
+
ctx.logger.info(`[lobster:bridge] state dir: ${ctx.stateDir}`);
|
|
757
|
+
oauthFlow = new OAuthFlow({
|
|
758
|
+
stateDir: ctx.stateDir,
|
|
759
|
+
cloudUrl,
|
|
760
|
+
logger: ctx.logger
|
|
761
|
+
});
|
|
762
|
+
bridge2 = new CloudBridge({
|
|
763
|
+
cloudUrl,
|
|
764
|
+
stateDir: ctx.stateDir,
|
|
765
|
+
logger: ctx.logger
|
|
766
|
+
});
|
|
767
|
+
bridge2.onCommand((cmd) => {
|
|
768
|
+
if (cmd.type === "ping") {
|
|
769
|
+
ctx.logger.info("[lobster:bridge] Received ping, publishing heartbeat");
|
|
770
|
+
bridge2?.publish({ type: "heartbeat", timestamp: Date.now() });
|
|
771
|
+
} else if (cmd.type === "send_message") {
|
|
772
|
+
ctx.logger.info(`[lobster:bridge] \u2190 Received send_message from cloud: text="${cmd.text.slice(0, 100)}" sessionKey=${cmd.sessionKey}`);
|
|
773
|
+
bridge2?.publish({
|
|
774
|
+
type: "message_received",
|
|
775
|
+
channelId: "cloud",
|
|
776
|
+
from: "cloud",
|
|
777
|
+
content: cmd.text,
|
|
778
|
+
sessionKey: cmd.sessionKey
|
|
779
|
+
});
|
|
780
|
+
if (cmd.sessionKey) {
|
|
781
|
+
const gatewayKey = `agent:main:${cmd.sessionKey}`;
|
|
782
|
+
sessionKeyMap.set(gatewayKey, cmd.sessionKey);
|
|
783
|
+
ctx.logger.info(`[lobster:bridge] sessionKey mapping: ${gatewayKey} \u2192 ${cmd.sessionKey}`);
|
|
784
|
+
}
|
|
785
|
+
gatewayWs?.sendMessage(cmd.text, cmd.sessionKey);
|
|
786
|
+
} else if (cmd.type === "avatar_config") {
|
|
787
|
+
ctx.logger.info(`[lobster:bridge] Avatar config received: ${cmd.avatarId}`);
|
|
788
|
+
} else if (cmd.type === "update_settings") {
|
|
789
|
+
ctx.logger.info("[lobster:bridge] Settings update received");
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
await bridge2.connect();
|
|
793
|
+
const port = gatewayPort ?? 18789;
|
|
794
|
+
const token = process.env["OPENCLAW_GATEWAY_TOKEN"] ?? "dev-token-1234";
|
|
795
|
+
gatewayWs = new GatewayWsClient({ port, token, logger: ctx.logger });
|
|
796
|
+
const thinkingPublished = /* @__PURE__ */ new Set();
|
|
797
|
+
const sessionContentLen = /* @__PURE__ */ new Map();
|
|
798
|
+
const sessionKeyMap = /* @__PURE__ */ new Map();
|
|
799
|
+
gatewayWs.onChat((payload) => {
|
|
800
|
+
const gatewaySessionKey = payload.sessionKey ?? "__default__";
|
|
801
|
+
const cloudSessionKey = sessionKeyMap.get(gatewaySessionKey) ?? gatewaySessionKey;
|
|
802
|
+
if (payload.state === "final" && payload.message?.role === "assistant") {
|
|
803
|
+
thinkingPublished.delete(gatewaySessionKey);
|
|
804
|
+
sessionContentLen.delete(gatewaySessionKey);
|
|
805
|
+
sessionKeyMap.delete(gatewaySessionKey);
|
|
806
|
+
const raw = payload.message.content;
|
|
807
|
+
let text;
|
|
808
|
+
if (typeof raw === "string") {
|
|
809
|
+
text = raw;
|
|
810
|
+
} else if (Array.isArray(raw)) {
|
|
811
|
+
text = raw.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
812
|
+
} else {
|
|
813
|
+
text = String(raw ?? "");
|
|
814
|
+
}
|
|
815
|
+
ctx.logger.info(`[lobster:ws] \u2713 Agent final response (${text.length} chars, sessionKey=${cloudSessionKey}): "${text.slice(0, 120)}"`);
|
|
816
|
+
bridge2?.publish({
|
|
817
|
+
type: "agent_response",
|
|
818
|
+
channelId: "webchat",
|
|
819
|
+
to: "cloud",
|
|
820
|
+
text,
|
|
821
|
+
runId: payload.runId,
|
|
822
|
+
sessionKey: cloudSessionKey
|
|
823
|
+
});
|
|
824
|
+
} else if (payload.state !== "final" && payload.message?.role === "assistant") {
|
|
825
|
+
if (!thinkingPublished.has(gatewaySessionKey)) {
|
|
826
|
+
thinkingPublished.add(gatewaySessionKey);
|
|
827
|
+
ctx.logger.info(`[lobster:ws] \u25C6 Agent thinking (gatewayKey=${gatewaySessionKey}, cloudKey=${cloudSessionKey})`);
|
|
828
|
+
bridge2?.publish({
|
|
829
|
+
type: "agent_thinking",
|
|
830
|
+
runId: payload.runId,
|
|
831
|
+
sessionKey: cloudSessionKey
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
const rawDelta = payload.message.content;
|
|
835
|
+
let fullText;
|
|
836
|
+
if (typeof rawDelta === "string" && rawDelta.length > 0) {
|
|
837
|
+
fullText = rawDelta;
|
|
838
|
+
} else if (Array.isArray(rawDelta)) {
|
|
839
|
+
const joined = rawDelta.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
840
|
+
if (joined.length > 0) fullText = joined;
|
|
841
|
+
}
|
|
842
|
+
if (fullText) {
|
|
843
|
+
const prevLen = sessionContentLen.get(gatewaySessionKey) ?? 0;
|
|
844
|
+
const deltaText = fullText.slice(prevLen);
|
|
845
|
+
sessionContentLen.set(gatewaySessionKey, fullText.length);
|
|
846
|
+
if (deltaText.length > 0) {
|
|
847
|
+
ctx.logger.info(`[lobster:ws] \u25C6 Delta (${deltaText.length} new chars, cumulative=${fullText.length}, sessionKey=${cloudSessionKey}): "${deltaText.slice(0, 60)}"`);
|
|
848
|
+
bridge2?.publish({
|
|
849
|
+
type: "agent_response_delta",
|
|
850
|
+
text: deltaText,
|
|
851
|
+
runId: payload.runId,
|
|
852
|
+
sessionKey: cloudSessionKey
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
try {
|
|
859
|
+
await gatewayWs.connect();
|
|
860
|
+
} catch (err) {
|
|
861
|
+
ctx.logger.warn(`[lobster:ws] Gateway WS connection failed: ${err}`);
|
|
862
|
+
}
|
|
863
|
+
initApiClient(bridge2);
|
|
864
|
+
},
|
|
865
|
+
async stop(ctx) {
|
|
866
|
+
ctx.logger.info("[lobster:bridge] service stopping\u2026");
|
|
867
|
+
gatewayWs?.disconnect();
|
|
868
|
+
gatewayWs = null;
|
|
869
|
+
bridge2?.publish({ type: "gateway_stop" });
|
|
870
|
+
await bridge2?.disconnect();
|
|
871
|
+
bridge2 = null;
|
|
872
|
+
oauthFlow = null;
|
|
873
|
+
serviceCtx = null;
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
api.registerCommand({
|
|
877
|
+
name: "lobster-status",
|
|
878
|
+
description: "Show Lobster Shell plugin status",
|
|
879
|
+
requireAuth: true,
|
|
880
|
+
handler(_ctx) {
|
|
881
|
+
const status = {
|
|
882
|
+
plugin: PLUGIN_ID,
|
|
883
|
+
version: PLUGIN_VERSION,
|
|
884
|
+
gatewayPort: gatewayPort ?? "unknown",
|
|
885
|
+
cloudBridge: bridge2?.status ?? "not started",
|
|
886
|
+
channel: CHANNEL_ID
|
|
887
|
+
};
|
|
888
|
+
return {
|
|
889
|
+
text: [
|
|
890
|
+
"**Lobster Shell Status**",
|
|
891
|
+
`Plugin: ${status.plugin} v${status.version}`,
|
|
892
|
+
`Gateway: port ${status.gatewayPort}`,
|
|
893
|
+
`Cloud Bridge: ${status.cloudBridge}`,
|
|
894
|
+
`Channel: ${status.channel}`
|
|
895
|
+
].join("\n")
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
logger.info("Lobster Shell plugin registered successfully");
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
var index_default = plugin;
|
|
903
|
+
export {
|
|
904
|
+
index_default as default
|
|
905
|
+
};
|