@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,97 @@
|
|
|
1
|
+
import { Args, Flags } from "@oclif/core";
|
|
2
|
+
import { requireGroup } from "../../utils/xmtp.js";
|
|
3
|
+
import { ConvosBaseCommand } from "../../baseCommand.js";
|
|
4
|
+
import { createClientForIdentity } from "../../utils/client.js";
|
|
5
|
+
import { createIdentityStore } from "../../utils/identities.js";
|
|
6
|
+
import { parseAppData, serializeAppData, upsertProfile, } from "../../utils/metadata.js";
|
|
7
|
+
export default class UpdateProfile extends ConvosBaseCommand {
|
|
8
|
+
static description = `Set your display name and avatar in a conversation.
|
|
9
|
+
|
|
10
|
+
Profiles are stored in the group's metadata (appData) and visible
|
|
11
|
+
to all members. Each conversation has independent profiles — you
|
|
12
|
+
can be a different person in each conversation (ADR 005).
|
|
13
|
+
|
|
14
|
+
Updates are synced to all members via XMTP's group metadata.
|
|
15
|
+
The profile is stored as a compressed protobuf in the group's
|
|
16
|
+
appData field (max 8KB shared across all profiles and metadata).`;
|
|
17
|
+
static examples = [
|
|
18
|
+
{
|
|
19
|
+
command: '<%= config.bin %> <%= command.id %> <conversation-id> --name "Alice"',
|
|
20
|
+
description: "Set your display name",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
command: '<%= config.bin %> <%= command.id %> <conversation-id> --name "Alice" --image "https://example.com/avatar.jpg"',
|
|
24
|
+
description: "Set display name and avatar",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
command: '<%= config.bin %> <%= command.id %> <conversation-id> --name "" --image ""',
|
|
28
|
+
description: "Clear your profile (go anonymous)",
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
static args = {
|
|
32
|
+
id: Args.string({
|
|
33
|
+
description: "The conversation ID",
|
|
34
|
+
required: true,
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
static flags = {
|
|
38
|
+
...ConvosBaseCommand.baseFlags,
|
|
39
|
+
name: Flags.string({
|
|
40
|
+
description: "Display name (empty string to clear)",
|
|
41
|
+
helpValue: "<name>",
|
|
42
|
+
}),
|
|
43
|
+
image: Flags.string({
|
|
44
|
+
description: "Avatar image URL (empty string to clear)",
|
|
45
|
+
helpValue: "<url>",
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
async run() {
|
|
49
|
+
const { args, flags } = await this.parse(UpdateProfile);
|
|
50
|
+
const config = this.getConvosConfig();
|
|
51
|
+
const store = createIdentityStore();
|
|
52
|
+
if (flags.name === undefined && flags.image === undefined) {
|
|
53
|
+
this.error("At least one of --name or --image must be provided");
|
|
54
|
+
}
|
|
55
|
+
const identity = store.getByConversationId(args.id);
|
|
56
|
+
if (!identity) {
|
|
57
|
+
this.error(`No identity found for conversation ${args.id}`);
|
|
58
|
+
}
|
|
59
|
+
const client = await createClientForIdentity(identity, config);
|
|
60
|
+
await client.conversations.sync();
|
|
61
|
+
const conversation = await client.conversations.getConversationById(args.id);
|
|
62
|
+
if (!conversation) {
|
|
63
|
+
this.error(`Conversation ${args.id} not found`);
|
|
64
|
+
}
|
|
65
|
+
const group = requireGroup(conversation);
|
|
66
|
+
// Parse current metadata
|
|
67
|
+
let appData = "";
|
|
68
|
+
try {
|
|
69
|
+
appData = group.appData ?? "";
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// No appData yet
|
|
73
|
+
}
|
|
74
|
+
let metadata = parseAppData(appData);
|
|
75
|
+
// Build profile update
|
|
76
|
+
const profile = {
|
|
77
|
+
inboxId: client.inboxId,
|
|
78
|
+
...(flags.name !== undefined ? { name: flags.name || undefined } : {}),
|
|
79
|
+
...(flags.image !== undefined ? { image: flags.image || undefined } : {}),
|
|
80
|
+
};
|
|
81
|
+
metadata = upsertProfile(metadata, profile);
|
|
82
|
+
// Serialize and push
|
|
83
|
+
const newAppData = serializeAppData(metadata);
|
|
84
|
+
await group.updateAppData(newAppData);
|
|
85
|
+
// Also update local identity store
|
|
86
|
+
if (flags.name !== undefined) {
|
|
87
|
+
store.update(identity.id, { profileName: flags.name || undefined });
|
|
88
|
+
}
|
|
89
|
+
this.output({
|
|
90
|
+
conversationId: args.id,
|
|
91
|
+
inboxId: client.inboxId,
|
|
92
|
+
name: flags.name ?? "(unchanged)",
|
|
93
|
+
image: flags.image ?? "(unchanged)",
|
|
94
|
+
message: "Profile updated",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ConvosBaseCommand } from "../../baseCommand.js";
|
|
2
|
+
export default class ConversationsCreate extends ConvosBaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: {
|
|
5
|
+
command: string;
|
|
6
|
+
description: string;
|
|
7
|
+
}[];
|
|
8
|
+
static flags: {
|
|
9
|
+
name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
"image-url": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
permissions: import("@oclif/core/interfaces").OptionFlag<"all-members" | "admin-only", import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
identity: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
label: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
"profile-name": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
"log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
"structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
|
+
"app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
|
+
"env-file": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
|
+
env: import("@oclif/core/interfaces").OptionFlag<"local" | "dev" | "production" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
21
|
+
"gateway-host": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
22
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
23
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
24
|
+
};
|
|
25
|
+
run(): Promise<void>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Flags } from "@oclif/core";
|
|
2
|
+
import { getAccountAddress } from "../../utils/xmtp.js";
|
|
3
|
+
import qrcode from "qrcode-terminal";
|
|
4
|
+
import { ConvosBaseCommand } from "../../baseCommand.js";
|
|
5
|
+
import { createClientForIdentity } from "../../utils/client.js";
|
|
6
|
+
import { createIdentityStore } from "../../utils/identities.js";
|
|
7
|
+
import { createInviteSlug } from "../../utils/invite.js";
|
|
8
|
+
import { serializeAppData, upsertProfile } from "../../utils/metadata.js";
|
|
9
|
+
import { randomAlphanumeric } from "../../utils/random.js";
|
|
10
|
+
export default class ConversationsCreate extends ConvosBaseCommand {
|
|
11
|
+
static description = `Create a new Convos conversation.
|
|
12
|
+
|
|
13
|
+
Creates a new conversation with a fresh per-conversation identity.
|
|
14
|
+
Each conversation gets its own XMTP inbox for privacy isolation
|
|
15
|
+
(per ADR 002).
|
|
16
|
+
|
|
17
|
+
This command:
|
|
18
|
+
1. Creates a new identity (or uses an existing unlinked one)
|
|
19
|
+
2. Initializes the XMTP client for that identity
|
|
20
|
+
3. Creates a group conversation
|
|
21
|
+
4. Links the identity to the conversation
|
|
22
|
+
|
|
23
|
+
The creator becomes super admin. Others join via invite links.`;
|
|
24
|
+
static examples = [
|
|
25
|
+
{
|
|
26
|
+
command: '<%= config.bin %> <%= command.id %> --name "Project Team"',
|
|
27
|
+
description: "Create a named conversation",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
command: '<%= config.bin %> <%= command.id %> --name "Team" --description "Team discussion"',
|
|
31
|
+
description: "Create with metadata",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
command: "<%= config.bin %> <%= command.id %> --identity <identity-id>",
|
|
35
|
+
description: "Use a pre-created identity",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
command: '<%= config.bin %> <%= command.id %> --name "Private" --permissions admin-only --json',
|
|
39
|
+
description: "Create admin-only group with JSON output",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
static flags = {
|
|
43
|
+
...ConvosBaseCommand.baseFlags,
|
|
44
|
+
name: Flags.string({
|
|
45
|
+
description: "Conversation name",
|
|
46
|
+
helpValue: "<name>",
|
|
47
|
+
}),
|
|
48
|
+
description: Flags.string({
|
|
49
|
+
description: "Conversation description",
|
|
50
|
+
helpValue: "<description>",
|
|
51
|
+
}),
|
|
52
|
+
"image-url": Flags.string({
|
|
53
|
+
description: "Conversation image URL",
|
|
54
|
+
helpValue: "<url>",
|
|
55
|
+
}),
|
|
56
|
+
permissions: Flags.option({
|
|
57
|
+
options: ["all-members", "admin-only"],
|
|
58
|
+
description: "Permission preset",
|
|
59
|
+
default: "all-members",
|
|
60
|
+
})(),
|
|
61
|
+
identity: Flags.string({
|
|
62
|
+
description: "Use an existing unlinked identity ID",
|
|
63
|
+
helpValue: "<id>",
|
|
64
|
+
}),
|
|
65
|
+
label: Flags.string({
|
|
66
|
+
description: "Local label for the identity",
|
|
67
|
+
helpValue: "<label>",
|
|
68
|
+
}),
|
|
69
|
+
"profile-name": Flags.string({
|
|
70
|
+
description: "Profile display name for this conversation",
|
|
71
|
+
helpValue: "<name>",
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
async run() {
|
|
75
|
+
const { flags } = await this.parse(ConversationsCreate);
|
|
76
|
+
const config = this.getConvosConfig();
|
|
77
|
+
const store = createIdentityStore();
|
|
78
|
+
// Get or create identity
|
|
79
|
+
let identity;
|
|
80
|
+
if (flags.identity) {
|
|
81
|
+
identity = store.get(flags.identity);
|
|
82
|
+
if (!identity) {
|
|
83
|
+
this.error(`Identity not found: ${flags.identity}`);
|
|
84
|
+
}
|
|
85
|
+
if (identity.conversationId) {
|
|
86
|
+
this.error(`Identity ${flags.identity} is already linked to conversation ${identity.conversationId}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
identity = store.create({
|
|
91
|
+
label: flags.label ?? flags.name,
|
|
92
|
+
profileName: flags["profile-name"],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const client = await createClientForIdentity(identity, config);
|
|
96
|
+
const permissionsMap = {
|
|
97
|
+
"all-members": 0 /* GroupPermissionsOptions.Default */,
|
|
98
|
+
"admin-only": 1 /* GroupPermissionsOptions.AdminOnly */,
|
|
99
|
+
};
|
|
100
|
+
const options = {
|
|
101
|
+
groupName: flags.name,
|
|
102
|
+
groupDescription: flags.description,
|
|
103
|
+
groupImageUrlSquare: flags["image-url"],
|
|
104
|
+
permissions: permissionsMap[flags.permissions],
|
|
105
|
+
};
|
|
106
|
+
// Convos conversations start with just the creator
|
|
107
|
+
const group = await client.conversations.createGroup([], options);
|
|
108
|
+
// Generate invite tag
|
|
109
|
+
const inviteTag = randomAlphanumeric(10);
|
|
110
|
+
store.update(identity.id, {
|
|
111
|
+
conversationId: group.id,
|
|
112
|
+
inboxId: client.inboxId,
|
|
113
|
+
inviteTag,
|
|
114
|
+
label: flags.label ?? flags.name ?? identity.label,
|
|
115
|
+
profileName: flags["profile-name"] ?? identity.profileName,
|
|
116
|
+
});
|
|
117
|
+
// Store invite tag in appData
|
|
118
|
+
let metadata = { tag: inviteTag, profiles: [] };
|
|
119
|
+
// Write creator's profile to shared metadata so other members can see it
|
|
120
|
+
const profileName = flags["profile-name"];
|
|
121
|
+
if (profileName) {
|
|
122
|
+
metadata = upsertProfile(metadata, {
|
|
123
|
+
inboxId: client.inboxId,
|
|
124
|
+
name: profileName,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
await group.updateAppData(serializeAppData(metadata));
|
|
128
|
+
// Generate invite slug and URL
|
|
129
|
+
const slug = await createInviteSlug(group.id, client.inboxId, inviteTag, identity.walletKey, {
|
|
130
|
+
name: flags.name || undefined,
|
|
131
|
+
description: flags.description || undefined,
|
|
132
|
+
});
|
|
133
|
+
const env = config.env ?? "dev";
|
|
134
|
+
const baseUrl = env === "production"
|
|
135
|
+
? "https://popup.convos.org/v2"
|
|
136
|
+
: "https://dev.convos.org/v2";
|
|
137
|
+
const inviteUrl = `${baseUrl}?i=${encodeURIComponent(slug)}`;
|
|
138
|
+
// Display QR code unless --json
|
|
139
|
+
if (!flags.json) {
|
|
140
|
+
this.log(""); // blank line before QR
|
|
141
|
+
await new Promise((resolve) => {
|
|
142
|
+
qrcode.generate(inviteUrl, { small: true }, (code) => {
|
|
143
|
+
this.log(code);
|
|
144
|
+
resolve();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
this.log(` ${inviteUrl}\n`);
|
|
148
|
+
}
|
|
149
|
+
this.output({
|
|
150
|
+
conversationId: group.id,
|
|
151
|
+
identityId: identity.id,
|
|
152
|
+
address: getAccountAddress(identity.walletKey),
|
|
153
|
+
inboxId: client.inboxId,
|
|
154
|
+
name: flags.name ?? "",
|
|
155
|
+
description: flags.description ?? "",
|
|
156
|
+
permissions: flags.permissions,
|
|
157
|
+
createdAt: group.createdAt.toISOString(),
|
|
158
|
+
invite: {
|
|
159
|
+
slug,
|
|
160
|
+
url: inviteUrl,
|
|
161
|
+
tag: inviteTag,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ConvosBaseCommand } from "../../baseCommand.js";
|
|
2
|
+
export default class ConversationsJoin extends ConvosBaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: {
|
|
5
|
+
command: string;
|
|
6
|
+
description: string;
|
|
7
|
+
}[];
|
|
8
|
+
static args: {
|
|
9
|
+
invite: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
10
|
+
};
|
|
11
|
+
static flags: {
|
|
12
|
+
"no-wait": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
timeout: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
label: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
"profile-name": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
identity: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
"log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
"structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
"app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
|
+
"env-file": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
21
|
+
env: import("@oclif/core/interfaces").OptionFlag<"local" | "dev" | "production" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
22
|
+
"gateway-host": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
23
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
24
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
25
|
+
};
|
|
26
|
+
run(): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { Args, Flags } from "@oclif/core";
|
|
2
|
+
import { getAccountAddress, isGroup } from "../../utils/xmtp.js";
|
|
3
|
+
import { ConvosBaseCommand } from "../../baseCommand.js";
|
|
4
|
+
import { createClientForIdentity } from "../../utils/client.js";
|
|
5
|
+
import { createIdentityStore } from "../../utils/identities.js";
|
|
6
|
+
import { parseInvite, verifyInvite, inviteToSlug } from "../../utils/invite.js";
|
|
7
|
+
import { parseAppData, serializeAppData, upsertProfile, } from "../../utils/metadata.js";
|
|
8
|
+
export default class ConversationsJoin extends ConvosBaseCommand {
|
|
9
|
+
static description = `Join a conversation using an invite slug or URL.
|
|
10
|
+
|
|
11
|
+
Implements the Convos join flow (per ADR 001):
|
|
12
|
+
|
|
13
|
+
1. Parse and validate the invite (verify signature, check expiration)
|
|
14
|
+
2. Create a new per-conversation identity for this conversation
|
|
15
|
+
3. Send the invite slug as a DM to the creator's inbox
|
|
16
|
+
4. Wait for the creator's client to process the join request and
|
|
17
|
+
add this identity to the conversation
|
|
18
|
+
5. Link the identity to the conversation once joined
|
|
19
|
+
|
|
20
|
+
The creator's client (iOS app or another CLI instance running
|
|
21
|
+
'convos conversations process-join-requests') must be online to
|
|
22
|
+
process the join request.
|
|
23
|
+
|
|
24
|
+
Accepts either a raw invite slug or a full URL containing the
|
|
25
|
+
slug as query parameter 'i'.`;
|
|
26
|
+
static examples = [
|
|
27
|
+
{
|
|
28
|
+
command: "<%= config.bin %> <%= command.id %> <invite-slug>",
|
|
29
|
+
description: "Join using a raw invite slug",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
command: '<%= config.bin %> <%= command.id %> "https://popup.convos.org/v2?i=<slug>"',
|
|
33
|
+
description: "Join using a full invite URL",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
command: "<%= config.bin %> <%= command.id %> <slug> --no-wait",
|
|
37
|
+
description: "Send join request without waiting for acceptance",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
command: "<%= config.bin %> <%= command.id %> <slug> --timeout 120 --json",
|
|
41
|
+
description: "Wait up to 2 minutes for acceptance",
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
static args = {
|
|
45
|
+
invite: Args.string({
|
|
46
|
+
description: "Invite slug or URL",
|
|
47
|
+
required: true,
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
static flags = {
|
|
51
|
+
...ConvosBaseCommand.baseFlags,
|
|
52
|
+
"no-wait": Flags.boolean({
|
|
53
|
+
description: "Send the join request but don't wait for the creator to accept",
|
|
54
|
+
default: false,
|
|
55
|
+
}),
|
|
56
|
+
timeout: Flags.integer({
|
|
57
|
+
description: "Seconds to wait for acceptance (default: 60)",
|
|
58
|
+
helpValue: "<seconds>",
|
|
59
|
+
default: 60,
|
|
60
|
+
}),
|
|
61
|
+
label: Flags.string({
|
|
62
|
+
description: "Local label for the new identity",
|
|
63
|
+
helpValue: "<label>",
|
|
64
|
+
}),
|
|
65
|
+
"profile-name": Flags.string({
|
|
66
|
+
description: "Profile display name for this conversation",
|
|
67
|
+
helpValue: "<name>",
|
|
68
|
+
}),
|
|
69
|
+
identity: Flags.string({
|
|
70
|
+
description: "Use an existing unlinked identity instead of creating one",
|
|
71
|
+
helpValue: "<id>",
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
async run() {
|
|
75
|
+
const { args, flags } = await this.parse(ConversationsJoin);
|
|
76
|
+
const config = this.getConvosConfig();
|
|
77
|
+
const store = createIdentityStore();
|
|
78
|
+
// Step 1: Parse invite
|
|
79
|
+
const invite = parseInvite(args.invite);
|
|
80
|
+
// Validate
|
|
81
|
+
if (!(await verifyInvite(invite))) {
|
|
82
|
+
this.error("Invalid invite signature");
|
|
83
|
+
}
|
|
84
|
+
if (invite.expiresAt && invite.expiresAt < new Date()) {
|
|
85
|
+
this.error("Invite has expired");
|
|
86
|
+
}
|
|
87
|
+
if (invite.conversationExpiresAt && invite.conversationExpiresAt < new Date()) {
|
|
88
|
+
this.error("Conversation has expired");
|
|
89
|
+
}
|
|
90
|
+
this.log(`Invite parsed: tag=${invite.tag}` +
|
|
91
|
+
(invite.name ? ` name="${invite.name}"` : "") +
|
|
92
|
+
` creator=${invite.creatorInboxId.slice(0, 12)}...`);
|
|
93
|
+
// Check if we've already joined this conversation (same invite tag)
|
|
94
|
+
const existingIdentity = store.getByInviteTag(invite.tag);
|
|
95
|
+
if (existingIdentity) {
|
|
96
|
+
this.error(`Already joined this conversation.\n` +
|
|
97
|
+
` Identity: ${existingIdentity.id}\n` +
|
|
98
|
+
` Conversation: ${existingIdentity.conversationId ?? "(pending)"}\n` +
|
|
99
|
+
` Label: ${existingIdentity.label ?? ""}\n\n` +
|
|
100
|
+
`Use 'convos conversation send-text ${existingIdentity.conversationId ?? "<id>"}' to send messages.`);
|
|
101
|
+
}
|
|
102
|
+
// Step 2: Get or create identity
|
|
103
|
+
let identity;
|
|
104
|
+
if (flags.identity) {
|
|
105
|
+
identity = store.get(flags.identity);
|
|
106
|
+
if (!identity)
|
|
107
|
+
this.error(`Identity not found: ${flags.identity}`);
|
|
108
|
+
if (identity.conversationId) {
|
|
109
|
+
this.error(`Identity already linked to conversation ${identity.conversationId}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
identity = store.create({
|
|
114
|
+
label: flags.label ?? invite.name,
|
|
115
|
+
profileName: flags["profile-name"],
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Store invite tag immediately so duplicate detection works even if we exit early
|
|
119
|
+
store.update(identity.id, { inviteTag: invite.tag });
|
|
120
|
+
// Step 3: Create XMTP client and send DM to creator
|
|
121
|
+
const client = await createClientForIdentity(identity, config);
|
|
122
|
+
this.log(`Created identity ${identity.id.slice(0, 12)}... (${getAccountAddress(identity.walletKey)})`);
|
|
123
|
+
this.log(`Sending join request to creator inbox ${invite.creatorInboxId.slice(0, 12)}...`);
|
|
124
|
+
// Create DM with creator using their XMTP inbox ID
|
|
125
|
+
const dm = await client.conversations.createDm(invite.creatorInboxId);
|
|
126
|
+
// Send the invite slug as the join request
|
|
127
|
+
const slug = inviteToSlug(invite);
|
|
128
|
+
await dm.sendText(slug);
|
|
129
|
+
this.log("Join request sent.");
|
|
130
|
+
if (flags["no-wait"]) {
|
|
131
|
+
this.output({
|
|
132
|
+
status: "request_sent",
|
|
133
|
+
identityId: identity.id,
|
|
134
|
+
address: getAccountAddress(identity.walletKey),
|
|
135
|
+
inboxId: client.inboxId,
|
|
136
|
+
creatorInboxId: invite.creatorInboxId,
|
|
137
|
+
tag: invite.tag,
|
|
138
|
+
name: invite.name ?? null,
|
|
139
|
+
message: "Join request sent. The creator must accept it. " +
|
|
140
|
+
"Run 'convos conversations list --sync' to check if you've been added.",
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Step 4: Wait for acceptance (poll for new group conversations)
|
|
145
|
+
this.log(`Waiting for acceptance (timeout: ${flags.timeout}s)...`);
|
|
146
|
+
const startTime = Date.now();
|
|
147
|
+
const timeoutMs = flags.timeout * 1000;
|
|
148
|
+
let conversationId;
|
|
149
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
150
|
+
await client.conversations.sync();
|
|
151
|
+
const conversations = await client.conversations.list();
|
|
152
|
+
// Look for a new group conversation (not the DM we created)
|
|
153
|
+
for (const conv of conversations) {
|
|
154
|
+
if (conv.id !== dm.id) {
|
|
155
|
+
conversationId = conv.id;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (conversationId)
|
|
160
|
+
break;
|
|
161
|
+
// Poll every 2 seconds
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
163
|
+
}
|
|
164
|
+
if (!conversationId) {
|
|
165
|
+
this.log("Timed out waiting for acceptance.");
|
|
166
|
+
this.output({
|
|
167
|
+
status: "timeout",
|
|
168
|
+
identityId: identity.id,
|
|
169
|
+
address: getAccountAddress(identity.walletKey),
|
|
170
|
+
inboxId: client.inboxId,
|
|
171
|
+
tag: invite.tag,
|
|
172
|
+
message: "The creator has not yet accepted the join request. " +
|
|
173
|
+
"Run 'convos conversations list --sync' later to check.",
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Step 5: Verify the group's invite tag matches the invite we used (ADR 001)
|
|
178
|
+
// This protects against being added to a different conversation than requested.
|
|
179
|
+
try {
|
|
180
|
+
const conv = await client.conversations.getConversationById(conversationId);
|
|
181
|
+
if (conv && isGroup(conv)) {
|
|
182
|
+
const appData = conv.appData ?? "";
|
|
183
|
+
const metadata = parseAppData(appData);
|
|
184
|
+
if (metadata.tag && metadata.tag !== invite.tag) {
|
|
185
|
+
this.warn(`Invite tag mismatch: expected "${invite.tag}" but group has "${metadata.tag}". ` +
|
|
186
|
+
"You may have been added to a different conversation than expected.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Non-fatal: tag verification is a safety check, don't block joining
|
|
192
|
+
}
|
|
193
|
+
// Step 6: Write joiner's profile to shared metadata
|
|
194
|
+
const profileName = flags["profile-name"];
|
|
195
|
+
if (profileName) {
|
|
196
|
+
try {
|
|
197
|
+
await client.conversations.sync();
|
|
198
|
+
const conv = await client.conversations.getConversationById(conversationId);
|
|
199
|
+
if (conv && isGroup(conv)) {
|
|
200
|
+
await conv.sync();
|
|
201
|
+
const appData = conv.appData ?? "";
|
|
202
|
+
const metadata = parseAppData(appData);
|
|
203
|
+
const updated = upsertProfile(metadata, {
|
|
204
|
+
inboxId: client.inboxId,
|
|
205
|
+
name: profileName,
|
|
206
|
+
});
|
|
207
|
+
await conv.updateAppData(serializeAppData(updated));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
this.warn(`Could not write profile to group metadata: ${error instanceof Error ? error.message : "unknown"}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Step 7: Link identity to conversation
|
|
215
|
+
store.update(identity.id, {
|
|
216
|
+
conversationId,
|
|
217
|
+
inboxId: client.inboxId,
|
|
218
|
+
inviteTag: invite.tag,
|
|
219
|
+
label: flags.label ?? invite.name ?? identity.label,
|
|
220
|
+
profileName: flags["profile-name"] ?? identity.profileName,
|
|
221
|
+
});
|
|
222
|
+
this.output({
|
|
223
|
+
status: "joined",
|
|
224
|
+
conversationId,
|
|
225
|
+
identityId: identity.id,
|
|
226
|
+
address: getAccountAddress(identity.walletKey),
|
|
227
|
+
inboxId: client.inboxId,
|
|
228
|
+
tag: invite.tag,
|
|
229
|
+
name: invite.name ?? null,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ConvosBaseCommand } from "../../baseCommand.js";
|
|
2
|
+
export default class ConversationsList extends ConvosBaseCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: {
|
|
5
|
+
command: string;
|
|
6
|
+
description: string;
|
|
7
|
+
}[];
|
|
8
|
+
static flags: {
|
|
9
|
+
sync: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
"log-level": import("@oclif/core/interfaces").OptionFlag<"off" | "error" | "warn" | "info" | "debug" | "trace" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
"structured-logging": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
"app-version": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
"env-file": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
env: import("@oclif/core/interfaces").OptionFlag<"local" | "dev" | "production" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
"gateway-host": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
|
+
};
|
|
19
|
+
run(): Promise<void>;
|
|
20
|
+
}
|