@xmtp/convos-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +572 -0
- package/bin/dev.js +4 -0
- package/bin/run.js +4 -0
- package/dist/baseCommand.d.ts +46 -0
- package/dist/baseCommand.js +171 -0
- package/dist/commands/agent/serve.d.ts +67 -0
- package/dist/commands/agent/serve.js +662 -0
- package/dist/commands/conversation/add-members.d.ts +19 -0
- package/dist/commands/conversation/add-members.js +39 -0
- package/dist/commands/conversation/consent-state.d.ts +18 -0
- package/dist/commands/conversation/consent-state.js +24 -0
- package/dist/commands/conversation/download-attachment.d.ts +28 -0
- package/dist/commands/conversation/download-attachment.js +164 -0
- package/dist/commands/conversation/explode.d.ts +24 -0
- package/dist/commands/conversation/explode.js +156 -0
- package/dist/commands/conversation/info.d.ts +22 -0
- package/dist/commands/conversation/info.js +79 -0
- package/dist/commands/conversation/invite.d.ts +26 -0
- package/dist/commands/conversation/invite.js +137 -0
- package/dist/commands/conversation/lock.d.ts +24 -0
- package/dist/commands/conversation/lock.js +98 -0
- package/dist/commands/conversation/members.d.ts +22 -0
- package/dist/commands/conversation/members.js +39 -0
- package/dist/commands/conversation/messages.d.ts +31 -0
- package/dist/commands/conversation/messages.js +141 -0
- package/dist/commands/conversation/permissions.d.ts +18 -0
- package/dist/commands/conversation/permissions.js +33 -0
- package/dist/commands/conversation/profiles.d.ts +22 -0
- package/dist/commands/conversation/profiles.js +80 -0
- package/dist/commands/conversation/remove-members.d.ts +19 -0
- package/dist/commands/conversation/remove-members.js +36 -0
- package/dist/commands/conversation/send-attachment.d.ts +30 -0
- package/dist/commands/conversation/send-attachment.js +187 -0
- package/dist/commands/conversation/send-reaction.d.ts +21 -0
- package/dist/commands/conversation/send-reaction.js +38 -0
- package/dist/commands/conversation/send-remote-attachment.d.ts +30 -0
- package/dist/commands/conversation/send-remote-attachment.js +96 -0
- package/dist/commands/conversation/send-reply.d.ts +32 -0
- package/dist/commands/conversation/send-reply.js +170 -0
- package/dist/commands/conversation/send-text.d.ts +24 -0
- package/dist/commands/conversation/send-text.js +64 -0
- package/dist/commands/conversation/stream.d.ts +24 -0
- package/dist/commands/conversation/stream.js +81 -0
- package/dist/commands/conversation/sync.d.ts +18 -0
- package/dist/commands/conversation/sync.js +25 -0
- package/dist/commands/conversation/update-consent.d.ts +19 -0
- package/dist/commands/conversation/update-consent.js +35 -0
- package/dist/commands/conversation/update-description.d.ts +19 -0
- package/dist/commands/conversation/update-description.js +28 -0
- package/dist/commands/conversation/update-name.d.ts +19 -0
- package/dist/commands/conversation/update-name.js +29 -0
- package/dist/commands/conversation/update-profile.d.ts +24 -0
- package/dist/commands/conversation/update-profile.js +97 -0
- package/dist/commands/conversations/create.d.ts +26 -0
- package/dist/commands/conversations/create.js +165 -0
- package/dist/commands/conversations/join.d.ts +27 -0
- package/dist/commands/conversations/join.js +232 -0
- package/dist/commands/conversations/list.d.ts +20 -0
- package/dist/commands/conversations/list.js +109 -0
- package/dist/commands/conversations/process-join-requests.d.ts +26 -0
- package/dist/commands/conversations/process-join-requests.js +261 -0
- package/dist/commands/conversations/sync.d.ts +19 -0
- package/dist/commands/conversations/sync.js +50 -0
- package/dist/commands/identity/create.d.ts +21 -0
- package/dist/commands/identity/create.js +56 -0
- package/dist/commands/identity/info.d.ts +22 -0
- package/dist/commands/identity/info.js +63 -0
- package/dist/commands/identity/list.d.ts +19 -0
- package/dist/commands/identity/list.js +59 -0
- package/dist/commands/identity/remove.d.ts +23 -0
- package/dist/commands/identity/remove.js +51 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.js +91 -0
- package/dist/commands/reset.d.ts +17 -0
- package/dist/commands/reset.js +93 -0
- package/dist/help.d.ts +4 -0
- package/dist/help.js +31 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +15 -0
- package/dist/utils/client.d.ts +8 -0
- package/dist/utils/client.js +58 -0
- package/dist/utils/config.d.ts +15 -0
- package/dist/utils/config.js +1 -0
- package/dist/utils/identities.d.ts +49 -0
- package/dist/utils/identities.js +92 -0
- package/dist/utils/invite.d.ts +70 -0
- package/dist/utils/invite.js +339 -0
- package/dist/utils/metadata.d.ts +39 -0
- package/dist/utils/metadata.js +180 -0
- package/dist/utils/mime.d.ts +2 -0
- package/dist/utils/mime.js +42 -0
- package/dist/utils/random.d.ts +5 -0
- package/dist/utils/random.js +19 -0
- package/dist/utils/upload.d.ts +14 -0
- package/dist/utils/upload.js +51 -0
- package/dist/utils/xmtp.d.ts +45 -0
- package/dist/utils/xmtp.js +298 -0
- package/oclif.manifest.json +5562 -0
- package/package.json +124 -0
- package/skills/convos-cli/SKILL.md +588 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { Args, Flags } from "@oclif/core";
|
|
5
|
+
import { encryptAttachment, } from "@xmtp/node-sdk";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import QRCode from "qrcode";
|
|
9
|
+
import { ConvosBaseCommand } from "../../baseCommand.js";
|
|
10
|
+
import { createClientForIdentity } from "../../utils/client.js";
|
|
11
|
+
import { createIdentityStore } from "../../utils/identities.js";
|
|
12
|
+
import { createInviteSlug, decryptConversationToken, parseInvite, verifyInvite, verifyInviteSignature, } from "../../utils/invite.js";
|
|
13
|
+
import { getMimeType } from "../../utils/mime.js";
|
|
14
|
+
import { parseAppData, serializeAppData, upsertProfile } from "../../utils/metadata.js";
|
|
15
|
+
import { randomAlphanumeric } from "../../utils/random.js";
|
|
16
|
+
import { getUploadProvider, INLINE_ATTACHMENT_MAX_BYTES, } from "../../utils/upload.js";
|
|
17
|
+
import { buildProfileMap, getAccountAddress, isDisplayableMessage, jsonStringify, normalizeMessageContent, requireGroup, } from "../../utils/xmtp.js";
|
|
18
|
+
export default class AgentServe extends ConvosBaseCommand {
|
|
19
|
+
static description = `Run an agent server for a conversation.
|
|
20
|
+
|
|
21
|
+
Starts a long-running process that combines message streaming,
|
|
22
|
+
join request processing, and stdin command handling into a single
|
|
23
|
+
session — ideal for AI agents and bots.
|
|
24
|
+
|
|
25
|
+
If no conversation ID is provided, creates a new conversation.
|
|
26
|
+
Displays the QR code invite on stderr so agents can share it.
|
|
27
|
+
|
|
28
|
+
Uses an ndjson (newline-delimited JSON) protocol:
|
|
29
|
+
|
|
30
|
+
STDIN commands (one JSON object per line):
|
|
31
|
+
{"type":"send","text":"Hello"} Send a text message
|
|
32
|
+
{"type":"send","text":"Re","replyTo":"<id>"} Reply to a message
|
|
33
|
+
{"type":"react","messageId":"<id>","emoji":"👍"} React to a message
|
|
34
|
+
{"type":"react","messageId":"<id>","emoji":"👍","action":"remove"}
|
|
35
|
+
{"type":"attach","file":"./photo.jpg"} Send a file attachment
|
|
36
|
+
{"type":"attach","file":"./img.jpg","replyTo":"<id>"} Reply with attachment
|
|
37
|
+
{"type":"remote-attach","url":"https://...","contentDigest":"...","secret":"...","salt":"...","nonce":"...","contentLength":123}
|
|
38
|
+
{"type":"stop"} Graceful shutdown
|
|
39
|
+
|
|
40
|
+
STDOUT events (one JSON object per line):
|
|
41
|
+
{"event":"ready",...} Session started, includes invite URL
|
|
42
|
+
{"event":"message",...} New message received
|
|
43
|
+
{"event":"member_joined",...} A member joined via invite
|
|
44
|
+
{"event":"sent",...} Message sent confirmation
|
|
45
|
+
{"event":"error",...} Error occurred
|
|
46
|
+
|
|
47
|
+
STDERR: QR code, diagnostic logs (does not interfere with protocol)`;
|
|
48
|
+
static examples = [
|
|
49
|
+
{
|
|
50
|
+
command: '<%= config.bin %> <%= command.id %> --name "My Bot"',
|
|
51
|
+
description: "Create a new conversation and start serving",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
command: "<%= config.bin %> <%= command.id %> <conversation-id>",
|
|
55
|
+
description: "Attach to an existing conversation",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
command: '<%= config.bin %> <%= command.id %> --name "Agent" --profile-name "Assistant" --permissions admin-only',
|
|
59
|
+
description: "Create an admin-only conversation with a profile name",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
static args = {
|
|
63
|
+
id: Args.string({
|
|
64
|
+
description: "Existing conversation ID to attach to (omit to create new)",
|
|
65
|
+
required: false,
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
static flags = {
|
|
69
|
+
...ConvosBaseCommand.baseFlags,
|
|
70
|
+
name: Flags.string({
|
|
71
|
+
description: "Conversation name (when creating new)",
|
|
72
|
+
helpValue: "<name>",
|
|
73
|
+
}),
|
|
74
|
+
description: Flags.string({
|
|
75
|
+
description: "Conversation description (when creating new)",
|
|
76
|
+
helpValue: "<description>",
|
|
77
|
+
}),
|
|
78
|
+
permissions: Flags.option({
|
|
79
|
+
options: ["all-members", "admin-only"],
|
|
80
|
+
description: "Permission preset (when creating new)",
|
|
81
|
+
default: "all-members",
|
|
82
|
+
})(),
|
|
83
|
+
"profile-name": Flags.string({
|
|
84
|
+
description: "Profile display name for this conversation",
|
|
85
|
+
helpValue: "<name>",
|
|
86
|
+
}),
|
|
87
|
+
identity: Flags.string({
|
|
88
|
+
description: "Use an existing unlinked identity ID (when creating new)",
|
|
89
|
+
helpValue: "<id>",
|
|
90
|
+
}),
|
|
91
|
+
label: Flags.string({
|
|
92
|
+
description: "Local label for the identity",
|
|
93
|
+
helpValue: "<label>",
|
|
94
|
+
}),
|
|
95
|
+
"no-invite": Flags.boolean({
|
|
96
|
+
description: "Skip generating an invite (attach mode only)",
|
|
97
|
+
default: false,
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
streams = [];
|
|
101
|
+
shutdownResolve;
|
|
102
|
+
/**
|
|
103
|
+
* Emit a JSON event to stdout (one line).
|
|
104
|
+
*/
|
|
105
|
+
emit(event) {
|
|
106
|
+
process.stdout.write(jsonStringify(event) + "\n");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Emit an error event.
|
|
110
|
+
*/
|
|
111
|
+
emitError(message, details) {
|
|
112
|
+
this.emit({ event: "error", message, ...details });
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Process a single DM message as a potential join request.
|
|
116
|
+
*/
|
|
117
|
+
async processJoinMessage(message, client, identity) {
|
|
118
|
+
if (message.senderInboxId === client.inboxId)
|
|
119
|
+
return;
|
|
120
|
+
const text = typeof message.content === "string" ? message.content : null;
|
|
121
|
+
if (!text)
|
|
122
|
+
return;
|
|
123
|
+
let invite;
|
|
124
|
+
try {
|
|
125
|
+
invite = parseInvite(text);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const dmConversation = (await client.conversations.getConversationById(message.conversationId));
|
|
131
|
+
if (!(await verifyInvite(invite))) {
|
|
132
|
+
if (dmConversation)
|
|
133
|
+
dmConversation.updateConsentState(2 /* ConsentState.Denied */);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (!(await verifyInviteSignature(invite, identity.walletKey))) {
|
|
137
|
+
if (dmConversation)
|
|
138
|
+
dmConversation.updateConsentState(2 /* ConsentState.Denied */);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (invite.creatorInboxId !== client.inboxId) {
|
|
142
|
+
if (dmConversation)
|
|
143
|
+
dmConversation.updateConsentState(2 /* ConsentState.Denied */);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (invite.expiresAt && invite.expiresAt < new Date())
|
|
147
|
+
return;
|
|
148
|
+
let conversationId;
|
|
149
|
+
try {
|
|
150
|
+
conversationId = decryptConversationToken(invite.conversationToken, client.inboxId, Buffer.from(identity.walletKey.replace("0x", ""), "hex"));
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
if (dmConversation)
|
|
154
|
+
dmConversation.updateConsentState(2 /* ConsentState.Denied */);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const conversation = await client.conversations.getConversationById(conversationId);
|
|
158
|
+
if (!conversation)
|
|
159
|
+
return;
|
|
160
|
+
const group = requireGroup(conversation);
|
|
161
|
+
try {
|
|
162
|
+
const appData = group.appData ?? "";
|
|
163
|
+
const metadata = parseAppData(appData);
|
|
164
|
+
if (metadata.tag && invite.tag !== metadata.tag)
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// skip tag check
|
|
169
|
+
}
|
|
170
|
+
await group.addMembers([message.senderInboxId]);
|
|
171
|
+
if (dmConversation)
|
|
172
|
+
dmConversation.updateConsentState(1 /* ConsentState.Allowed */);
|
|
173
|
+
return { conversationId, joinerInboxId: message.senderInboxId };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Process any pending DM join requests (batch, on startup).
|
|
177
|
+
*/
|
|
178
|
+
async processPendingJoinRequests(client, identity) {
|
|
179
|
+
try {
|
|
180
|
+
await client.conversations.sync();
|
|
181
|
+
const dms = await client.conversations.list({
|
|
182
|
+
conversationType: 0 /* ConversationType.Dm */,
|
|
183
|
+
consentStates: [0 /* ConsentState.Unknown */],
|
|
184
|
+
});
|
|
185
|
+
for (const dm of dms) {
|
|
186
|
+
try {
|
|
187
|
+
await dm.sync();
|
|
188
|
+
const messages = await dm.messages({ limit: 10 });
|
|
189
|
+
for (const message of messages) {
|
|
190
|
+
try {
|
|
191
|
+
const result = await this.processJoinMessage(message, client, identity);
|
|
192
|
+
if (result) {
|
|
193
|
+
this.emit({
|
|
194
|
+
event: "member_joined",
|
|
195
|
+
inboxId: result.joinerInboxId,
|
|
196
|
+
conversationId: result.conversationId,
|
|
197
|
+
timestamp: new Date().toISOString(),
|
|
198
|
+
});
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// skip individual message errors
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// skip individual DM errors
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
this.emitError(`Failed to process pending join requests: ${error instanceof Error ? error.message : "unknown"}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Start streaming DM messages for join request processing.
|
|
218
|
+
*/
|
|
219
|
+
async startJoinRequestStream(client, identity) {
|
|
220
|
+
try {
|
|
221
|
+
const stream = await client.conversations.streamAllDmMessages();
|
|
222
|
+
this.streams.push(stream);
|
|
223
|
+
(async () => {
|
|
224
|
+
try {
|
|
225
|
+
for await (const message of stream) {
|
|
226
|
+
try {
|
|
227
|
+
const result = await this.processJoinMessage(message, client, identity);
|
|
228
|
+
if (result) {
|
|
229
|
+
this.emit({
|
|
230
|
+
event: "member_joined",
|
|
231
|
+
inboxId: result.joinerInboxId,
|
|
232
|
+
conversationId: result.conversationId,
|
|
233
|
+
timestamp: new Date().toISOString(),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// skip individual message errors
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// stream ended
|
|
244
|
+
}
|
|
245
|
+
})();
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
this.emitError(`Failed to start join request stream: ${error instanceof Error ? error.message : "unknown"}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Start streaming conversation messages.
|
|
253
|
+
*/
|
|
254
|
+
async startMessageStream(conversation, client) {
|
|
255
|
+
try {
|
|
256
|
+
const stream = await conversation.stream();
|
|
257
|
+
this.streams.push(stream);
|
|
258
|
+
(async () => {
|
|
259
|
+
try {
|
|
260
|
+
for await (const message of stream) {
|
|
261
|
+
// Skip our own messages (they get a "sent" event instead)
|
|
262
|
+
if (message.senderInboxId === client.inboxId)
|
|
263
|
+
continue;
|
|
264
|
+
// Skip content types we can't display cleanly
|
|
265
|
+
if (!isDisplayableMessage(message))
|
|
266
|
+
continue;
|
|
267
|
+
// Rebuild profiles each time so newly-joined members are resolved
|
|
268
|
+
const profiles = buildProfileMap(conversation.appData ?? "");
|
|
269
|
+
this.emit({
|
|
270
|
+
event: "message",
|
|
271
|
+
id: message.id,
|
|
272
|
+
senderInboxId: message.senderInboxId,
|
|
273
|
+
contentType: message.contentType,
|
|
274
|
+
content: normalizeMessageContent(message, profiles),
|
|
275
|
+
sentAt: message.sentAt.toISOString(),
|
|
276
|
+
deliveryStatus: message.deliveryStatus,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// stream ended
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
this.emitError(`Failed to start message stream: ${error instanceof Error ? error.message : "unknown"}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Read and process stdin commands.
|
|
291
|
+
*/
|
|
292
|
+
async startStdinReader(conversation) {
|
|
293
|
+
// If stdin is a TTY, skip the reader (no piped input)
|
|
294
|
+
if (process.stdin.isTTY)
|
|
295
|
+
return;
|
|
296
|
+
const rl = createInterface({
|
|
297
|
+
input: process.stdin,
|
|
298
|
+
terminal: false,
|
|
299
|
+
});
|
|
300
|
+
rl.on("line", (line) => {
|
|
301
|
+
const trimmed = line.trim();
|
|
302
|
+
if (!trimmed)
|
|
303
|
+
return;
|
|
304
|
+
let cmd;
|
|
305
|
+
try {
|
|
306
|
+
cmd = JSON.parse(trimmed);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
this.emitError("Invalid JSON on stdin", { input: trimmed });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
void this.handleCommand(cmd, conversation);
|
|
313
|
+
});
|
|
314
|
+
rl.on("close", () => {
|
|
315
|
+
// stdin closed — trigger shutdown
|
|
316
|
+
this.shutdown();
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Handle a parsed stdin command.
|
|
321
|
+
*/
|
|
322
|
+
async handleCommand(cmd, conversation) {
|
|
323
|
+
try {
|
|
324
|
+
switch (cmd.type) {
|
|
325
|
+
case "send": {
|
|
326
|
+
if (!cmd.text) {
|
|
327
|
+
this.emitError("send command requires 'text' field");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
let messageId;
|
|
331
|
+
if (cmd.replyTo) {
|
|
332
|
+
const { encodeText } = await import("@xmtp/node-sdk");
|
|
333
|
+
messageId = await conversation.sendReply({
|
|
334
|
+
reference: cmd.replyTo,
|
|
335
|
+
content: encodeText(cmd.text),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
messageId = await conversation.sendText(cmd.text);
|
|
340
|
+
}
|
|
341
|
+
this.emit({
|
|
342
|
+
event: "sent",
|
|
343
|
+
id: messageId,
|
|
344
|
+
text: cmd.text,
|
|
345
|
+
...(cmd.replyTo && { replyTo: cmd.replyTo }),
|
|
346
|
+
timestamp: new Date().toISOString(),
|
|
347
|
+
});
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case "react": {
|
|
351
|
+
if (!cmd.messageId || !cmd.emoji) {
|
|
352
|
+
this.emitError("react command requires 'messageId' and 'emoji' fields");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const action = cmd.action === "remove" ? 2 /* ReactionAction.Removed */ : 1 /* ReactionAction.Added */;
|
|
356
|
+
const reaction = {
|
|
357
|
+
reference: cmd.messageId,
|
|
358
|
+
referenceInboxId: "",
|
|
359
|
+
action,
|
|
360
|
+
content: cmd.emoji,
|
|
361
|
+
schema: 1 /* ReactionSchema.Unicode */,
|
|
362
|
+
};
|
|
363
|
+
const reactionMessageId = await conversation.sendReaction(reaction);
|
|
364
|
+
this.emit({
|
|
365
|
+
event: "sent",
|
|
366
|
+
id: reactionMessageId,
|
|
367
|
+
type: "reaction",
|
|
368
|
+
messageId: cmd.messageId,
|
|
369
|
+
emoji: cmd.emoji,
|
|
370
|
+
action: cmd.action ?? "add",
|
|
371
|
+
timestamp: new Date().toISOString(),
|
|
372
|
+
});
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case "attach": {
|
|
376
|
+
if (!cmd.file) {
|
|
377
|
+
this.emitError("attach command requires 'file' field");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const content = await readFile(cmd.file);
|
|
381
|
+
const filename = basename(cmd.file);
|
|
382
|
+
const mimeType = cmd.mimeType ?? getMimeType(cmd.file);
|
|
383
|
+
const attachment = { mimeType, content, filename };
|
|
384
|
+
const needsRemote = content.length > INLINE_ATTACHMENT_MAX_BYTES;
|
|
385
|
+
let messageId;
|
|
386
|
+
let sendType;
|
|
387
|
+
let url;
|
|
388
|
+
if (needsRemote) {
|
|
389
|
+
const config = this.getConvosConfig();
|
|
390
|
+
const provider = getUploadProvider(config);
|
|
391
|
+
if (!provider) {
|
|
392
|
+
this.emitError(`File is ${content.length} bytes (>${INLINE_ATTACHMENT_MAX_BYTES}). ` +
|
|
393
|
+
`Configure an upload provider (CONVOS_UPLOAD_PROVIDER) to send large files.`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const encrypted = encryptAttachment(attachment);
|
|
397
|
+
url = await provider.upload(encrypted.payload, filename, mimeType);
|
|
398
|
+
if (cmd.replyTo) {
|
|
399
|
+
const { encodeRemoteAttachment } = await import("@xmtp/node-sdk");
|
|
400
|
+
messageId = await conversation.sendReply({
|
|
401
|
+
reference: cmd.replyTo,
|
|
402
|
+
content: encodeRemoteAttachment({
|
|
403
|
+
url,
|
|
404
|
+
contentDigest: encrypted.contentDigest,
|
|
405
|
+
secret: encrypted.secret,
|
|
406
|
+
salt: encrypted.salt,
|
|
407
|
+
nonce: encrypted.nonce,
|
|
408
|
+
scheme: "https",
|
|
409
|
+
contentLength: encrypted.payload.length,
|
|
410
|
+
filename,
|
|
411
|
+
}),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
messageId = await conversation.sendRemoteAttachment({
|
|
416
|
+
url,
|
|
417
|
+
contentDigest: encrypted.contentDigest,
|
|
418
|
+
secret: encrypted.secret,
|
|
419
|
+
salt: encrypted.salt,
|
|
420
|
+
nonce: encrypted.nonce,
|
|
421
|
+
scheme: "https",
|
|
422
|
+
contentLength: encrypted.payload.length,
|
|
423
|
+
filename,
|
|
424
|
+
}, false);
|
|
425
|
+
}
|
|
426
|
+
sendType = "remote";
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
if (cmd.replyTo) {
|
|
430
|
+
const { encodeAttachment } = await import("@xmtp/node-sdk");
|
|
431
|
+
messageId = await conversation.sendReply({
|
|
432
|
+
reference: cmd.replyTo,
|
|
433
|
+
content: encodeAttachment(attachment),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
messageId = await conversation.sendAttachment(attachment, false);
|
|
438
|
+
}
|
|
439
|
+
sendType = "inline";
|
|
440
|
+
}
|
|
441
|
+
this.emit({
|
|
442
|
+
event: "sent",
|
|
443
|
+
id: messageId,
|
|
444
|
+
type: "attachment",
|
|
445
|
+
filename,
|
|
446
|
+
mimeType,
|
|
447
|
+
size: content.length,
|
|
448
|
+
sendType,
|
|
449
|
+
...(url && { url }),
|
|
450
|
+
...(cmd.replyTo && { replyTo: cmd.replyTo }),
|
|
451
|
+
timestamp: new Date().toISOString(),
|
|
452
|
+
});
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
case "remote-attach": {
|
|
456
|
+
if (!cmd.url || !cmd.contentDigest || !cmd.secret || !cmd.salt || !cmd.nonce || !cmd.contentLength) {
|
|
457
|
+
this.emitError("remote-attach command requires 'url', 'contentDigest', 'secret', 'salt', 'nonce', and 'contentLength' fields");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const remoteAttachment = {
|
|
461
|
+
url: cmd.url,
|
|
462
|
+
contentDigest: cmd.contentDigest,
|
|
463
|
+
secret: new Uint8Array(Buffer.from(cmd.secret, "base64")),
|
|
464
|
+
salt: new Uint8Array(Buffer.from(cmd.salt, "base64")),
|
|
465
|
+
nonce: new Uint8Array(Buffer.from(cmd.nonce, "base64")),
|
|
466
|
+
scheme: cmd.scheme ?? "https",
|
|
467
|
+
contentLength: cmd.contentLength,
|
|
468
|
+
filename: cmd.filename,
|
|
469
|
+
};
|
|
470
|
+
const remoteMessageId = await conversation.sendRemoteAttachment(remoteAttachment, false);
|
|
471
|
+
this.emit({
|
|
472
|
+
event: "sent",
|
|
473
|
+
id: remoteMessageId,
|
|
474
|
+
type: "remote-attachment",
|
|
475
|
+
url: cmd.url,
|
|
476
|
+
filename: cmd.filename,
|
|
477
|
+
timestamp: new Date().toISOString(),
|
|
478
|
+
});
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
case "stop":
|
|
482
|
+
this.shutdown();
|
|
483
|
+
break;
|
|
484
|
+
default:
|
|
485
|
+
this.emitError(`Unknown command type: ${cmd.type}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
this.emitError(`Command failed: ${error instanceof Error ? error.message : "unknown"}`, { command: cmd });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Trigger graceful shutdown.
|
|
494
|
+
*/
|
|
495
|
+
shutdown() {
|
|
496
|
+
if (this.shutdownResolve) {
|
|
497
|
+
this.shutdownResolve();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async run() {
|
|
501
|
+
const { args, flags } = await this.parse(AgentServe);
|
|
502
|
+
const config = this.getConvosConfig();
|
|
503
|
+
const store = createIdentityStore();
|
|
504
|
+
let identity;
|
|
505
|
+
let client;
|
|
506
|
+
let group;
|
|
507
|
+
let conversationId;
|
|
508
|
+
let inviteUrl;
|
|
509
|
+
let inviteSlug;
|
|
510
|
+
let inviteTag;
|
|
511
|
+
if (args.id) {
|
|
512
|
+
// ─── Attach to existing conversation ───
|
|
513
|
+
const existing = store.getByConversationId(args.id);
|
|
514
|
+
if (!existing) {
|
|
515
|
+
this.error(`No identity found for conversation: ${args.id}`);
|
|
516
|
+
}
|
|
517
|
+
identity = existing;
|
|
518
|
+
client = await createClientForIdentity(identity, config);
|
|
519
|
+
const conv = await client.conversations.getConversationById(args.id);
|
|
520
|
+
if (!conv) {
|
|
521
|
+
this.error(`Conversation not found: ${args.id}`);
|
|
522
|
+
}
|
|
523
|
+
group = requireGroup(conv);
|
|
524
|
+
conversationId = args.id;
|
|
525
|
+
// Generate invite unless --no-invite
|
|
526
|
+
if (!flags["no-invite"]) {
|
|
527
|
+
let appData = "";
|
|
528
|
+
try {
|
|
529
|
+
appData = group.appData ?? "";
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// no appData
|
|
533
|
+
}
|
|
534
|
+
let metadata = parseAppData(appData);
|
|
535
|
+
inviteTag = metadata.tag;
|
|
536
|
+
if (!inviteTag) {
|
|
537
|
+
inviteTag = randomAlphanumeric(10);
|
|
538
|
+
metadata = { ...metadata, tag: inviteTag };
|
|
539
|
+
await group.updateAppData(serializeAppData(metadata));
|
|
540
|
+
}
|
|
541
|
+
inviteSlug = await createInviteSlug(conversationId, client.inboxId, inviteTag, identity.walletKey, {
|
|
542
|
+
name: group.name || undefined,
|
|
543
|
+
description: group.description || undefined,
|
|
544
|
+
});
|
|
545
|
+
const env = config.env ?? "dev";
|
|
546
|
+
const baseUrl = env === "production"
|
|
547
|
+
? "https://popup.convos.org/v2"
|
|
548
|
+
: "https://dev.convos.org/v2";
|
|
549
|
+
inviteUrl = `${baseUrl}?i=${encodeURIComponent(inviteSlug)}`;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
// ─── Create new conversation ───
|
|
554
|
+
if (flags.identity) {
|
|
555
|
+
const existing = store.get(flags.identity);
|
|
556
|
+
if (!existing)
|
|
557
|
+
this.error(`Identity not found: ${flags.identity}`);
|
|
558
|
+
if (existing.conversationId) {
|
|
559
|
+
this.error(`Identity ${flags.identity} is already linked to conversation ${existing.conversationId}`);
|
|
560
|
+
}
|
|
561
|
+
identity = existing;
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
identity = store.create({
|
|
565
|
+
label: flags.label ?? flags.name,
|
|
566
|
+
profileName: flags["profile-name"],
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
client = await createClientForIdentity(identity, config);
|
|
570
|
+
const permissionsMap = {
|
|
571
|
+
"all-members": 0 /* GroupPermissionsOptions.Default */,
|
|
572
|
+
"admin-only": 1 /* GroupPermissionsOptions.AdminOnly */,
|
|
573
|
+
};
|
|
574
|
+
const options = {
|
|
575
|
+
groupName: flags.name,
|
|
576
|
+
groupDescription: flags.description,
|
|
577
|
+
permissions: permissionsMap[flags.permissions],
|
|
578
|
+
};
|
|
579
|
+
group = await client.conversations.createGroup([], options);
|
|
580
|
+
conversationId = group.id;
|
|
581
|
+
inviteTag = randomAlphanumeric(10);
|
|
582
|
+
store.update(identity.id, {
|
|
583
|
+
conversationId,
|
|
584
|
+
inboxId: client.inboxId,
|
|
585
|
+
inviteTag,
|
|
586
|
+
label: flags.label ?? flags.name ?? identity.label,
|
|
587
|
+
profileName: flags["profile-name"] ?? identity.profileName,
|
|
588
|
+
});
|
|
589
|
+
// Store invite tag + profile in appData
|
|
590
|
+
let metadata = {
|
|
591
|
+
tag: inviteTag,
|
|
592
|
+
profiles: [],
|
|
593
|
+
};
|
|
594
|
+
const profileName = flags["profile-name"];
|
|
595
|
+
if (profileName) {
|
|
596
|
+
metadata = upsertProfile(metadata, {
|
|
597
|
+
inboxId: client.inboxId,
|
|
598
|
+
name: profileName,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
await group.updateAppData(serializeAppData(metadata));
|
|
602
|
+
// Generate invite
|
|
603
|
+
inviteSlug = await createInviteSlug(conversationId, client.inboxId, inviteTag, identity.walletKey, {
|
|
604
|
+
name: flags.name || undefined,
|
|
605
|
+
description: flags.description || undefined,
|
|
606
|
+
});
|
|
607
|
+
const env = config.env ?? "dev";
|
|
608
|
+
const baseUrl = env === "production"
|
|
609
|
+
? "https://popup.convos.org/v2"
|
|
610
|
+
: "https://dev.convos.org/v2";
|
|
611
|
+
inviteUrl = `${baseUrl}?i=${encodeURIComponent(inviteSlug)}`;
|
|
612
|
+
}
|
|
613
|
+
// Generate QR code image (PNG) so agents can display it instantly
|
|
614
|
+
let qrCodePath;
|
|
615
|
+
if (inviteUrl) {
|
|
616
|
+
qrCodePath = join(tmpdir(), `convos-invite-${conversationId}.png`);
|
|
617
|
+
await QRCode.toFile(qrCodePath, inviteUrl, {
|
|
618
|
+
type: "png",
|
|
619
|
+
width: 512,
|
|
620
|
+
margin: 2,
|
|
621
|
+
});
|
|
622
|
+
process.stderr.write(`QR code saved to: ${qrCodePath}\n`);
|
|
623
|
+
process.stderr.write(`Invite URL: ${inviteUrl}\n`);
|
|
624
|
+
}
|
|
625
|
+
// Emit ready event
|
|
626
|
+
this.emit({
|
|
627
|
+
event: "ready",
|
|
628
|
+
conversationId,
|
|
629
|
+
identityId: identity.id,
|
|
630
|
+
inboxId: client.inboxId,
|
|
631
|
+
address: getAccountAddress(identity.walletKey),
|
|
632
|
+
name: group.name ?? "",
|
|
633
|
+
...(inviteUrl && { inviteUrl }),
|
|
634
|
+
...(inviteSlug && { inviteSlug }),
|
|
635
|
+
...(inviteTag && { inviteTag }),
|
|
636
|
+
...(qrCodePath && { qrCodePath }),
|
|
637
|
+
timestamp: new Date().toISOString(),
|
|
638
|
+
});
|
|
639
|
+
// Process any pending join requests from before we started
|
|
640
|
+
await this.processPendingJoinRequests(client, identity);
|
|
641
|
+
// Start all concurrent streams
|
|
642
|
+
await this.startMessageStream(group, client);
|
|
643
|
+
await this.startJoinRequestStream(client, identity);
|
|
644
|
+
this.startStdinReader(group);
|
|
645
|
+
// Keep running until shutdown
|
|
646
|
+
await new Promise((resolve) => {
|
|
647
|
+
this.shutdownResolve = resolve;
|
|
648
|
+
process.on("SIGINT", () => {
|
|
649
|
+
process.stderr.write("\nShutting down...\n");
|
|
650
|
+
resolve();
|
|
651
|
+
});
|
|
652
|
+
process.on("SIGTERM", () => {
|
|
653
|
+
process.stderr.write("\nShutting down...\n");
|
|
654
|
+
resolve();
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
// Clean up streams
|
|
658
|
+
for (const stream of this.streams) {
|
|
659
|
+
void stream.return();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ConvosBaseCommand } from "../../baseCommand.js";
|
|
2
|
+
export default class ConversationAddMembers extends ConvosBaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static strict: boolean;
|
|
5
|
+
static args: {
|
|
6
|
+
id: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
};
|
|
8
|
+
static flags: {
|
|
9
|
+
"log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
"structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
"app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
"env-file": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
env: import("@oclif/core/interfaces").OptionFlag<"local" | "dev" | "production" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
"gateway-host": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
};
|
|
18
|
+
run(): Promise<void>;
|
|
19
|
+
}
|