@xmtp/convos-cli 0.1.0 → 0.2.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/dist/commands/agent/serve.d.ts +92 -1
- package/dist/commands/agent/serve.js +355 -7
- package/oclif.manifest.json +1052 -1042
- package/package.json +1 -1
- package/skills/convos-cli/SKILL.md +22 -5
|
@@ -1,4 +1,62 @@
|
|
|
1
|
+
import type { EncodedContent } from "@xmtp/node-bindings";
|
|
1
2
|
import { ConvosBaseCommand } from "../../baseCommand.js";
|
|
3
|
+
/**
|
|
4
|
+
* Stdin command types the agent can send.
|
|
5
|
+
*/
|
|
6
|
+
interface SendCommand {
|
|
7
|
+
type: "send";
|
|
8
|
+
text: string;
|
|
9
|
+
replyTo?: string;
|
|
10
|
+
}
|
|
11
|
+
interface ReactCommand {
|
|
12
|
+
type: "react";
|
|
13
|
+
messageId: string;
|
|
14
|
+
emoji: string;
|
|
15
|
+
action?: "add" | "remove";
|
|
16
|
+
}
|
|
17
|
+
interface AttachCommand {
|
|
18
|
+
type: "attach";
|
|
19
|
+
file: string;
|
|
20
|
+
mimeType?: string;
|
|
21
|
+
replyTo?: string;
|
|
22
|
+
}
|
|
23
|
+
interface RemoteAttachCommand {
|
|
24
|
+
type: "remote-attach";
|
|
25
|
+
url: string;
|
|
26
|
+
contentDigest: string;
|
|
27
|
+
secret: string;
|
|
28
|
+
salt: string;
|
|
29
|
+
nonce: string;
|
|
30
|
+
contentLength: number;
|
|
31
|
+
filename?: string;
|
|
32
|
+
scheme?: string;
|
|
33
|
+
}
|
|
34
|
+
interface RenameCommand {
|
|
35
|
+
type: "rename";
|
|
36
|
+
name: string;
|
|
37
|
+
}
|
|
38
|
+
interface LockCommand {
|
|
39
|
+
type: "lock";
|
|
40
|
+
}
|
|
41
|
+
interface UnlockCommand {
|
|
42
|
+
type: "unlock";
|
|
43
|
+
}
|
|
44
|
+
interface ExplodeCommand {
|
|
45
|
+
type: "explode";
|
|
46
|
+
scheduled?: string;
|
|
47
|
+
}
|
|
48
|
+
interface StopCommand {
|
|
49
|
+
type: "stop";
|
|
50
|
+
}
|
|
51
|
+
export type AgentCommand = SendCommand | ReactCommand | AttachCommand | RemoteAttachCommand | RenameCommand | LockCommand | UnlockCommand | ExplodeCommand | StopCommand;
|
|
52
|
+
/**
|
|
53
|
+
* Encode an ExplodeSettings message matching the iOS content type.
|
|
54
|
+
*
|
|
55
|
+
* Content type: convos.org/explode_settings:1.0
|
|
56
|
+
* Payload: JSON-encoded { expiresAt: ISO8601 string }
|
|
57
|
+
* Fallback: "Conversation expires at {date}"
|
|
58
|
+
*/
|
|
59
|
+
export declare function encodeExplodeSettings(expiresAt: Date): EncodedContent;
|
|
2
60
|
export default class AgentServe extends ConvosBaseCommand {
|
|
3
61
|
static description: string;
|
|
4
62
|
static examples: {
|
|
@@ -16,6 +74,7 @@ export default class AgentServe extends ConvosBaseCommand {
|
|
|
16
74
|
identity: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
75
|
label: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
76
|
"no-invite": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
77
|
+
heartbeat: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
78
|
"log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
79
|
"structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
21
80
|
"app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -27,6 +86,19 @@ export default class AgentServe extends ConvosBaseCommand {
|
|
|
27
86
|
};
|
|
28
87
|
private streams;
|
|
29
88
|
private shutdownResolve?;
|
|
89
|
+
private heartbeatInterval?;
|
|
90
|
+
/** Timestamp (ns) of the last message we emitted — used for catchup on reconnect. */
|
|
91
|
+
private lastMessageTimestampNs;
|
|
92
|
+
/** Timestamp (ns) of the last DM message we processed — used for catchup on reconnect. */
|
|
93
|
+
private lastDmTimestampNs;
|
|
94
|
+
/** IDs of recently emitted messages — used to deduplicate catchup vs live stream. */
|
|
95
|
+
private recentMessageIds;
|
|
96
|
+
/** Guards against concurrent catchup operations during connection flapping. */
|
|
97
|
+
private isCatchingUpMessages;
|
|
98
|
+
private isCatchingUpDms;
|
|
99
|
+
/** Serializes command execution to prevent concurrent appData read-modify-write. */
|
|
100
|
+
private commandQueue;
|
|
101
|
+
private static readonly MAX_RECENT_IDS;
|
|
30
102
|
/**
|
|
31
103
|
* Emit a JSON event to stdout (one line).
|
|
32
104
|
*/
|
|
@@ -35,6 +107,10 @@ export default class AgentServe extends ConvosBaseCommand {
|
|
|
35
107
|
* Emit an error event.
|
|
36
108
|
*/
|
|
37
109
|
private emitError;
|
|
110
|
+
/**
|
|
111
|
+
* Track a message ID as emitted. Returns false if already seen (duplicate).
|
|
112
|
+
*/
|
|
113
|
+
private trackMessageId;
|
|
38
114
|
/**
|
|
39
115
|
* Process a single DM message as a potential join request.
|
|
40
116
|
*/
|
|
@@ -43,12 +119,22 @@ export default class AgentServe extends ConvosBaseCommand {
|
|
|
43
119
|
* Process any pending DM join requests (batch, on startup).
|
|
44
120
|
*/
|
|
45
121
|
private processPendingJoinRequests;
|
|
122
|
+
/**
|
|
123
|
+
* Catch up on DM join requests that may have arrived while disconnected.
|
|
124
|
+
* Guarded against concurrent invocations during connection flapping.
|
|
125
|
+
*/
|
|
126
|
+
private catchUpDmJoinRequests;
|
|
46
127
|
/**
|
|
47
128
|
* Start streaming DM messages for join request processing.
|
|
48
129
|
*/
|
|
49
130
|
private startJoinRequestStream;
|
|
50
131
|
/**
|
|
51
|
-
*
|
|
132
|
+
* Fetch and emit any messages that arrived while the stream was disconnected.
|
|
133
|
+
* Guarded against concurrent invocations during connection flapping.
|
|
134
|
+
*/
|
|
135
|
+
private catchUpMessages;
|
|
136
|
+
/**
|
|
137
|
+
* Start streaming conversation messages with reconnection catchup.
|
|
52
138
|
*/
|
|
53
139
|
private startMessageStream;
|
|
54
140
|
/**
|
|
@@ -59,9 +145,14 @@ export default class AgentServe extends ConvosBaseCommand {
|
|
|
59
145
|
* Handle a parsed stdin command.
|
|
60
146
|
*/
|
|
61
147
|
private handleCommand;
|
|
148
|
+
/**
|
|
149
|
+
* Start emitting periodic heartbeat events.
|
|
150
|
+
*/
|
|
151
|
+
private startHeartbeat;
|
|
62
152
|
/**
|
|
63
153
|
* Trigger graceful shutdown.
|
|
64
154
|
*/
|
|
65
155
|
private shutdown;
|
|
66
156
|
run(): Promise<void>;
|
|
67
157
|
}
|
|
158
|
+
export {};
|
|
@@ -15,6 +15,29 @@ import { parseAppData, serializeAppData, upsertProfile } from "../../utils/metad
|
|
|
15
15
|
import { randomAlphanumeric } from "../../utils/random.js";
|
|
16
16
|
import { getUploadProvider, INLINE_ATTACHMENT_MAX_BYTES, } from "../../utils/upload.js";
|
|
17
17
|
import { buildProfileMap, getAccountAddress, isDisplayableMessage, jsonStringify, normalizeMessageContent, requireGroup, } from "../../utils/xmtp.js";
|
|
18
|
+
/**
|
|
19
|
+
* Encode an ExplodeSettings message matching the iOS content type.
|
|
20
|
+
*
|
|
21
|
+
* Content type: convos.org/explode_settings:1.0
|
|
22
|
+
* Payload: JSON-encoded { expiresAt: ISO8601 string }
|
|
23
|
+
* Fallback: "Conversation expires at {date}"
|
|
24
|
+
*/
|
|
25
|
+
export function encodeExplodeSettings(expiresAt) {
|
|
26
|
+
const payload = JSON.stringify({
|
|
27
|
+
expiresAt: expiresAt.toISOString(),
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
type: {
|
|
31
|
+
authorityId: "convos.org",
|
|
32
|
+
typeId: "explode_settings",
|
|
33
|
+
versionMajor: 1,
|
|
34
|
+
versionMinor: 0,
|
|
35
|
+
},
|
|
36
|
+
parameters: {},
|
|
37
|
+
fallback: `Conversation expires at ${expiresAt.toISOString()}`,
|
|
38
|
+
content: new TextEncoder().encode(payload),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
18
41
|
export default class AgentServe extends ConvosBaseCommand {
|
|
19
42
|
static description = `Run an agent server for a conversation.
|
|
20
43
|
|
|
@@ -35,6 +58,11 @@ STDIN commands (one JSON object per line):
|
|
|
35
58
|
{"type":"attach","file":"./photo.jpg"} Send a file attachment
|
|
36
59
|
{"type":"attach","file":"./img.jpg","replyTo":"<id>"} Reply with attachment
|
|
37
60
|
{"type":"remote-attach","url":"https://...","contentDigest":"...","secret":"...","salt":"...","nonce":"...","contentLength":123}
|
|
61
|
+
{"type":"rename","name":"New Name"} Rename the conversation
|
|
62
|
+
{"type":"lock"} Lock (prevent new joins)
|
|
63
|
+
{"type":"unlock"} Unlock (allow new joins)
|
|
64
|
+
{"type":"explode"} Explode immediately
|
|
65
|
+
{"type":"explode","scheduled":"2025-03-01T00:00:00Z"} Schedule explosion
|
|
38
66
|
{"type":"stop"} Graceful shutdown
|
|
39
67
|
|
|
40
68
|
STDOUT events (one JSON object per line):
|
|
@@ -42,6 +70,7 @@ STDOUT events (one JSON object per line):
|
|
|
42
70
|
{"event":"message",...} New message received
|
|
43
71
|
{"event":"member_joined",...} A member joined via invite
|
|
44
72
|
{"event":"sent",...} Message sent confirmation
|
|
73
|
+
{"event":"heartbeat",...} Periodic health check
|
|
45
74
|
{"event":"error",...} Error occurred
|
|
46
75
|
|
|
47
76
|
STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
@@ -96,9 +125,27 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
96
125
|
description: "Skip generating an invite (attach mode only)",
|
|
97
126
|
default: false,
|
|
98
127
|
}),
|
|
128
|
+
heartbeat: Flags.integer({
|
|
129
|
+
description: "Emit heartbeat events every N seconds (0 to disable)",
|
|
130
|
+
helpValue: "<seconds>",
|
|
131
|
+
default: 0,
|
|
132
|
+
}),
|
|
99
133
|
};
|
|
100
134
|
streams = [];
|
|
101
135
|
shutdownResolve;
|
|
136
|
+
heartbeatInterval;
|
|
137
|
+
/** Timestamp (ns) of the last message we emitted — used for catchup on reconnect. */
|
|
138
|
+
lastMessageTimestampNs = 0n;
|
|
139
|
+
/** Timestamp (ns) of the last DM message we processed — used for catchup on reconnect. */
|
|
140
|
+
lastDmTimestampNs = 0n;
|
|
141
|
+
/** IDs of recently emitted messages — used to deduplicate catchup vs live stream. */
|
|
142
|
+
recentMessageIds = new Set();
|
|
143
|
+
/** Guards against concurrent catchup operations during connection flapping. */
|
|
144
|
+
isCatchingUpMessages = false;
|
|
145
|
+
isCatchingUpDms = false;
|
|
146
|
+
/** Serializes command execution to prevent concurrent appData read-modify-write. */
|
|
147
|
+
commandQueue = Promise.resolve();
|
|
148
|
+
static MAX_RECENT_IDS = 1000;
|
|
102
149
|
/**
|
|
103
150
|
* Emit a JSON event to stdout (one line).
|
|
104
151
|
*/
|
|
@@ -111,6 +158,20 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
111
158
|
emitError(message, details) {
|
|
112
159
|
this.emit({ event: "error", message, ...details });
|
|
113
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Track a message ID as emitted. Returns false if already seen (duplicate).
|
|
163
|
+
*/
|
|
164
|
+
trackMessageId(id) {
|
|
165
|
+
if (this.recentMessageIds.has(id))
|
|
166
|
+
return false;
|
|
167
|
+
this.recentMessageIds.add(id);
|
|
168
|
+
// Evict oldest entries when the set grows too large
|
|
169
|
+
if (this.recentMessageIds.size > AgentServe.MAX_RECENT_IDS) {
|
|
170
|
+
const first = this.recentMessageIds.values().next().value;
|
|
171
|
+
this.recentMessageIds.delete(first);
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
114
175
|
/**
|
|
115
176
|
* Process a single DM message as a potential join request.
|
|
116
177
|
*/
|
|
@@ -213,17 +274,85 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
213
274
|
this.emitError(`Failed to process pending join requests: ${error instanceof Error ? error.message : "unknown"}`);
|
|
214
275
|
}
|
|
215
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Catch up on DM join requests that may have arrived while disconnected.
|
|
279
|
+
* Guarded against concurrent invocations during connection flapping.
|
|
280
|
+
*/
|
|
281
|
+
async catchUpDmJoinRequests(client, identity, sinceNs) {
|
|
282
|
+
if (sinceNs === 0n)
|
|
283
|
+
return;
|
|
284
|
+
if (this.isCatchingUpDms)
|
|
285
|
+
return;
|
|
286
|
+
this.isCatchingUpDms = true;
|
|
287
|
+
try {
|
|
288
|
+
await client.conversations.sync();
|
|
289
|
+
const dms = await client.conversations.list({
|
|
290
|
+
conversationType: 0 /* ConversationType.Dm */,
|
|
291
|
+
consentStates: [0 /* ConsentState.Unknown */],
|
|
292
|
+
});
|
|
293
|
+
for (const dm of dms) {
|
|
294
|
+
try {
|
|
295
|
+
await dm.sync();
|
|
296
|
+
const messages = await dm.messages({
|
|
297
|
+
sentAfterNs: sinceNs,
|
|
298
|
+
direction: 0 /* SortDirection.Ascending */,
|
|
299
|
+
limit: 10,
|
|
300
|
+
});
|
|
301
|
+
for (const message of messages) {
|
|
302
|
+
try {
|
|
303
|
+
const sentAtNs = BigInt(message.sentAt.getTime()) * 1000000n;
|
|
304
|
+
const result = await this.processJoinMessage(message, client, identity);
|
|
305
|
+
if (result) {
|
|
306
|
+
if (sentAtNs > this.lastDmTimestampNs) {
|
|
307
|
+
this.lastDmTimestampNs = sentAtNs;
|
|
308
|
+
}
|
|
309
|
+
this.emit({
|
|
310
|
+
event: "member_joined",
|
|
311
|
+
inboxId: result.joinerInboxId,
|
|
312
|
+
conversationId: result.conversationId,
|
|
313
|
+
timestamp: new Date().toISOString(),
|
|
314
|
+
catchup: true,
|
|
315
|
+
});
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// skip individual message errors
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// skip individual DM errors
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
this.emitError(`Failed to catch up DM join requests: ${error instanceof Error ? error.message : "unknown"}`);
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
this.isCatchingUpDms = false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
216
336
|
/**
|
|
217
337
|
* Start streaming DM messages for join request processing.
|
|
218
338
|
*/
|
|
219
339
|
async startJoinRequestStream(client, identity) {
|
|
220
340
|
try {
|
|
221
|
-
const stream = await client.conversations.streamAllDmMessages(
|
|
341
|
+
const stream = await client.conversations.streamAllDmMessages({
|
|
342
|
+
onRestart: () => {
|
|
343
|
+
void this.catchUpDmJoinRequests(client, identity, this.lastDmTimestampNs);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
222
346
|
this.streams.push(stream);
|
|
223
347
|
(async () => {
|
|
224
348
|
try {
|
|
225
349
|
for await (const message of stream) {
|
|
226
350
|
try {
|
|
351
|
+
// Track last DM timestamp for catchup on reconnect
|
|
352
|
+
const sentAtNs = BigInt(message.sentAt.getTime()) * 1000000n;
|
|
353
|
+
if (sentAtNs > this.lastDmTimestampNs) {
|
|
354
|
+
this.lastDmTimestampNs = sentAtNs;
|
|
355
|
+
}
|
|
227
356
|
const result = await this.processJoinMessage(message, client, identity);
|
|
228
357
|
if (result) {
|
|
229
358
|
this.emit({
|
|
@@ -249,11 +378,63 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
249
378
|
}
|
|
250
379
|
}
|
|
251
380
|
/**
|
|
252
|
-
*
|
|
381
|
+
* Fetch and emit any messages that arrived while the stream was disconnected.
|
|
382
|
+
* Guarded against concurrent invocations during connection flapping.
|
|
383
|
+
*/
|
|
384
|
+
async catchUpMessages(conversation, client, sinceNs) {
|
|
385
|
+
if (sinceNs === 0n)
|
|
386
|
+
return;
|
|
387
|
+
if (this.isCatchingUpMessages)
|
|
388
|
+
return;
|
|
389
|
+
this.isCatchingUpMessages = true;
|
|
390
|
+
try {
|
|
391
|
+
await conversation.sync();
|
|
392
|
+
const missed = await conversation.messages({
|
|
393
|
+
sentAfterNs: sinceNs,
|
|
394
|
+
direction: 0 /* SortDirection.Ascending */,
|
|
395
|
+
});
|
|
396
|
+
for (const message of missed) {
|
|
397
|
+
if (message.senderInboxId === client.inboxId)
|
|
398
|
+
continue;
|
|
399
|
+
if (!isDisplayableMessage(message))
|
|
400
|
+
continue;
|
|
401
|
+
if (!this.trackMessageId(message.id))
|
|
402
|
+
continue;
|
|
403
|
+
const sentAtNs = BigInt(message.sentAt.getTime()) * 1000000n;
|
|
404
|
+
if (sentAtNs > this.lastMessageTimestampNs) {
|
|
405
|
+
this.lastMessageTimestampNs = sentAtNs;
|
|
406
|
+
}
|
|
407
|
+
const profiles = buildProfileMap(conversation.appData ?? "");
|
|
408
|
+
this.emit({
|
|
409
|
+
event: "message",
|
|
410
|
+
id: message.id,
|
|
411
|
+
senderInboxId: message.senderInboxId,
|
|
412
|
+
contentType: message.contentType,
|
|
413
|
+
content: normalizeMessageContent(message, profiles),
|
|
414
|
+
sentAt: message.sentAt.toISOString(),
|
|
415
|
+
deliveryStatus: message.deliveryStatus,
|
|
416
|
+
catchup: true,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
this.emitError(`Failed to catch up messages: ${error instanceof Error ? error.message : "unknown"}`);
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
this.isCatchingUpMessages = false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Start streaming conversation messages with reconnection catchup.
|
|
253
429
|
*/
|
|
254
430
|
async startMessageStream(conversation, client) {
|
|
255
431
|
try {
|
|
256
|
-
const stream = await conversation.stream(
|
|
432
|
+
const stream = await conversation.stream({
|
|
433
|
+
onRestart: () => {
|
|
434
|
+
// On reconnect, fetch missed messages
|
|
435
|
+
void this.catchUpMessages(conversation, client, this.lastMessageTimestampNs);
|
|
436
|
+
},
|
|
437
|
+
});
|
|
257
438
|
this.streams.push(stream);
|
|
258
439
|
(async () => {
|
|
259
440
|
try {
|
|
@@ -264,6 +445,14 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
264
445
|
// Skip content types we can't display cleanly
|
|
265
446
|
if (!isDisplayableMessage(message))
|
|
266
447
|
continue;
|
|
448
|
+
// Skip if already emitted by catchup
|
|
449
|
+
if (!this.trackMessageId(message.id))
|
|
450
|
+
continue;
|
|
451
|
+
// Track last message timestamp for catchup on reconnect
|
|
452
|
+
const sentAtNs = BigInt(message.sentAt.getTime()) * 1000000n;
|
|
453
|
+
if (sentAtNs > this.lastMessageTimestampNs) {
|
|
454
|
+
this.lastMessageTimestampNs = sentAtNs;
|
|
455
|
+
}
|
|
267
456
|
// Rebuild profiles each time so newly-joined members are resolved
|
|
268
457
|
const profiles = buildProfileMap(conversation.appData ?? "");
|
|
269
458
|
this.emit({
|
|
@@ -289,7 +478,7 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
289
478
|
/**
|
|
290
479
|
* Read and process stdin commands.
|
|
291
480
|
*/
|
|
292
|
-
async startStdinReader(conversation) {
|
|
481
|
+
async startStdinReader(conversation, client, identity) {
|
|
293
482
|
// If stdin is a TTY, skip the reader (no piped input)
|
|
294
483
|
if (process.stdin.isTTY)
|
|
295
484
|
return;
|
|
@@ -309,7 +498,8 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
309
498
|
this.emitError("Invalid JSON on stdin", { input: trimmed });
|
|
310
499
|
return;
|
|
311
500
|
}
|
|
312
|
-
|
|
501
|
+
// Serialize commands to prevent concurrent appData read-modify-write
|
|
502
|
+
this.commandQueue = this.commandQueue.then(() => this.handleCommand(cmd, conversation, client, identity));
|
|
313
503
|
});
|
|
314
504
|
rl.on("close", () => {
|
|
315
505
|
// stdin closed — trigger shutdown
|
|
@@ -319,7 +509,7 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
319
509
|
/**
|
|
320
510
|
* Handle a parsed stdin command.
|
|
321
511
|
*/
|
|
322
|
-
async handleCommand(cmd, conversation) {
|
|
512
|
+
async handleCommand(cmd, conversation, client, identity) {
|
|
323
513
|
try {
|
|
324
514
|
switch (cmd.type) {
|
|
325
515
|
case "send": {
|
|
@@ -478,6 +668,139 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
478
668
|
});
|
|
479
669
|
break;
|
|
480
670
|
}
|
|
671
|
+
case "rename": {
|
|
672
|
+
if (!cmd.name) {
|
|
673
|
+
this.emitError("rename command requires 'name' field");
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
await conversation.updateName(cmd.name);
|
|
677
|
+
// Also update the identity store label
|
|
678
|
+
const store = createIdentityStore();
|
|
679
|
+
store.update(identity.id, { label: cmd.name });
|
|
680
|
+
this.emit({
|
|
681
|
+
event: "sent",
|
|
682
|
+
type: "rename",
|
|
683
|
+
name: cmd.name,
|
|
684
|
+
conversationId: conversation.id,
|
|
685
|
+
timestamp: new Date().toISOString(),
|
|
686
|
+
});
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
case "lock": {
|
|
690
|
+
// Step 1: Rotate the invite tag to invalidate all existing invites
|
|
691
|
+
let appData = "";
|
|
692
|
+
try {
|
|
693
|
+
appData = conversation.appData ?? "";
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
// no appData
|
|
697
|
+
}
|
|
698
|
+
const lockMetadata = parseAppData(appData);
|
|
699
|
+
lockMetadata.tag = randomAlphanumeric(10);
|
|
700
|
+
await conversation.updateAppData(serializeAppData(lockMetadata));
|
|
701
|
+
// Step 2: Set addMember permission to deny
|
|
702
|
+
await conversation.updatePermission(0 /* PermissionUpdateType.AddMember */, 1 /* PermissionPolicy.Deny */);
|
|
703
|
+
this.emit({
|
|
704
|
+
event: "sent",
|
|
705
|
+
type: "lock",
|
|
706
|
+
conversationId: conversation.id,
|
|
707
|
+
timestamp: new Date().toISOString(),
|
|
708
|
+
});
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
case "unlock": {
|
|
712
|
+
// Rotate invite tag first
|
|
713
|
+
let appData = "";
|
|
714
|
+
try {
|
|
715
|
+
appData = conversation.appData ?? "";
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
// no appData
|
|
719
|
+
}
|
|
720
|
+
const unlockMetadata = parseAppData(appData);
|
|
721
|
+
unlockMetadata.tag = randomAlphanumeric(10);
|
|
722
|
+
await conversation.updateAppData(serializeAppData(unlockMetadata));
|
|
723
|
+
// Restore addMember permission
|
|
724
|
+
await conversation.updatePermission(0 /* PermissionUpdateType.AddMember */, 0 /* PermissionPolicy.Allow */);
|
|
725
|
+
this.emit({
|
|
726
|
+
event: "sent",
|
|
727
|
+
type: "unlock",
|
|
728
|
+
conversationId: conversation.id,
|
|
729
|
+
timestamp: new Date().toISOString(),
|
|
730
|
+
});
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
case "explode": {
|
|
734
|
+
// Determine expiration time
|
|
735
|
+
let expiresAt;
|
|
736
|
+
const isImmediate = !cmd.scheduled;
|
|
737
|
+
if (cmd.scheduled) {
|
|
738
|
+
expiresAt = new Date(cmd.scheduled);
|
|
739
|
+
if (isNaN(expiresAt.getTime())) {
|
|
740
|
+
this.emitError(`Invalid scheduled date: ${cmd.scheduled}`);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (expiresAt <= new Date()) {
|
|
744
|
+
this.emitError("Scheduled date must be in the future");
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
expiresAt = new Date();
|
|
750
|
+
}
|
|
751
|
+
// Step 1: Send ExplodeSettings message
|
|
752
|
+
const encodedContent = encodeExplodeSettings(expiresAt);
|
|
753
|
+
await conversation.send(encodedContent, { shouldPush: true });
|
|
754
|
+
// Step 2: Update group metadata with expiresAtUnix
|
|
755
|
+
try {
|
|
756
|
+
let appData = "";
|
|
757
|
+
try {
|
|
758
|
+
appData = conversation.appData ?? "";
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
// no appData
|
|
762
|
+
}
|
|
763
|
+
const explodeMetadata = parseAppData(appData);
|
|
764
|
+
explodeMetadata.expiresAtUnix = Math.floor(expiresAt.getTime() / 1000);
|
|
765
|
+
await conversation.updateAppData(serializeAppData(explodeMetadata));
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
// Non-fatal: metadata update is secondary to the message
|
|
769
|
+
}
|
|
770
|
+
if (isImmediate) {
|
|
771
|
+
// Step 3: Remove all other members
|
|
772
|
+
const members = await conversation.members();
|
|
773
|
+
const others = members.filter((m) => m.inboxId !== client.inboxId);
|
|
774
|
+
if (others.length > 0) {
|
|
775
|
+
await conversation.removeMembers(others.map((m) => m.inboxId));
|
|
776
|
+
}
|
|
777
|
+
// Step 4: Delete local identity
|
|
778
|
+
const store = createIdentityStore();
|
|
779
|
+
store.remove(identity.id);
|
|
780
|
+
this.emit({
|
|
781
|
+
event: "sent",
|
|
782
|
+
type: "explode",
|
|
783
|
+
conversationId: conversation.id,
|
|
784
|
+
identityDestroyed: identity.id,
|
|
785
|
+
membersRemoved: others.length,
|
|
786
|
+
expiresAt: expiresAt.toISOString(),
|
|
787
|
+
timestamp: new Date().toISOString(),
|
|
788
|
+
});
|
|
789
|
+
// Explode triggers shutdown
|
|
790
|
+
this.shutdown();
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
this.emit({
|
|
794
|
+
event: "sent",
|
|
795
|
+
type: "explode",
|
|
796
|
+
scheduled: true,
|
|
797
|
+
conversationId: conversation.id,
|
|
798
|
+
expiresAt: expiresAt.toISOString(),
|
|
799
|
+
timestamp: new Date().toISOString(),
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
481
804
|
case "stop":
|
|
482
805
|
this.shutdown();
|
|
483
806
|
break;
|
|
@@ -489,10 +812,31 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
489
812
|
this.emitError(`Command failed: ${error instanceof Error ? error.message : "unknown"}`, { command: cmd });
|
|
490
813
|
}
|
|
491
814
|
}
|
|
815
|
+
/**
|
|
816
|
+
* Start emitting periodic heartbeat events.
|
|
817
|
+
*/
|
|
818
|
+
startHeartbeat(intervalSeconds, conversationId) {
|
|
819
|
+
if (intervalSeconds <= 0)
|
|
820
|
+
return;
|
|
821
|
+
this.heartbeatInterval = setInterval(() => {
|
|
822
|
+
this.emit({
|
|
823
|
+
event: "heartbeat",
|
|
824
|
+
conversationId,
|
|
825
|
+
activeStreams: this.streams.length,
|
|
826
|
+
timestamp: new Date().toISOString(),
|
|
827
|
+
});
|
|
828
|
+
}, intervalSeconds * 1000);
|
|
829
|
+
// Don't let the heartbeat timer keep the process alive on its own
|
|
830
|
+
this.heartbeatInterval.unref();
|
|
831
|
+
}
|
|
492
832
|
/**
|
|
493
833
|
* Trigger graceful shutdown.
|
|
494
834
|
*/
|
|
495
835
|
shutdown() {
|
|
836
|
+
if (this.heartbeatInterval) {
|
|
837
|
+
clearInterval(this.heartbeatInterval);
|
|
838
|
+
this.heartbeatInterval = undefined;
|
|
839
|
+
}
|
|
496
840
|
if (this.shutdownResolve) {
|
|
497
841
|
this.shutdownResolve();
|
|
498
842
|
}
|
|
@@ -641,7 +985,11 @@ STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
|
641
985
|
// Start all concurrent streams
|
|
642
986
|
await this.startMessageStream(group, client);
|
|
643
987
|
await this.startJoinRequestStream(client, identity);
|
|
644
|
-
this.startStdinReader(group);
|
|
988
|
+
this.startStdinReader(group, client, identity);
|
|
989
|
+
// Start heartbeat if configured
|
|
990
|
+
if (flags.heartbeat && flags.heartbeat > 0) {
|
|
991
|
+
this.startHeartbeat(flags.heartbeat, conversationId);
|
|
992
|
+
}
|
|
645
993
|
// Keep running until shutdown
|
|
646
994
|
await new Promise((resolve) => {
|
|
647
995
|
this.shutdownResolve = resolve;
|