@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,19 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Generate a random alphanumeric string of the given length.
|
|
4
|
+
* Uses rejection sampling to avoid modulo bias.
|
|
5
|
+
*/
|
|
6
|
+
export function randomAlphanumeric(length) {
|
|
7
|
+
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
8
|
+
const maxValid = 256 - (256 % chars.length); // 252 for 62 chars
|
|
9
|
+
const result = [];
|
|
10
|
+
while (result.length < length) {
|
|
11
|
+
const bytes = randomBytes(length - result.length);
|
|
12
|
+
for (const b of bytes) {
|
|
13
|
+
if (b < maxValid && result.length < length) {
|
|
14
|
+
result.push(chars[b % chars.length]);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return result.join("");
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface UploadProvider {
|
|
2
|
+
name: string;
|
|
3
|
+
upload(data: Uint8Array, filename: string, mimeType: string): Promise<string>;
|
|
4
|
+
}
|
|
5
|
+
interface UploadConfig {
|
|
6
|
+
uploadProvider?: string;
|
|
7
|
+
uploadProviderToken?: string;
|
|
8
|
+
uploadProviderGateway?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function getUploadProvider(config: UploadConfig): UploadProvider | null;
|
|
11
|
+
/** Max size for inline attachments (bytes). Files larger than this
|
|
12
|
+
* are automatically sent as remote attachments when a provider is configured. */
|
|
13
|
+
export declare const INLINE_ATTACHMENT_MAX_BYTES = 1000000;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
class PinataProvider {
|
|
2
|
+
name = "pinata";
|
|
3
|
+
#jwt;
|
|
4
|
+
#gateway;
|
|
5
|
+
constructor(jwt, gateway) {
|
|
6
|
+
this.#jwt = jwt;
|
|
7
|
+
this.#gateway =
|
|
8
|
+
gateway?.replace(/\/$/, "") ?? "https://gateway.pinata.cloud";
|
|
9
|
+
}
|
|
10
|
+
async upload(data, filename, _mimeType) {
|
|
11
|
+
const formData = new FormData();
|
|
12
|
+
formData.append("file", new Blob([data]), filename);
|
|
13
|
+
const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: `Bearer ${this.#jwt}`,
|
|
17
|
+
},
|
|
18
|
+
body: formData,
|
|
19
|
+
});
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
const text = await response.text();
|
|
22
|
+
throw new Error(`Pinata upload failed (${response.status}): ${text}`);
|
|
23
|
+
}
|
|
24
|
+
const result = (await response.json());
|
|
25
|
+
return `${this.#gateway}/ipfs/${result.IpfsHash}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const PROVIDER_FACTORIES = {
|
|
29
|
+
pinata: (config) => {
|
|
30
|
+
if (!config.uploadProviderToken) {
|
|
31
|
+
throw new Error("Pinata requires a JWT token. Set CONVOS_UPLOAD_PROVIDER_TOKEN or use --upload-provider-token.");
|
|
32
|
+
}
|
|
33
|
+
return new PinataProvider(config.uploadProviderToken, config.uploadProviderGateway);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
function isProviderName(name) {
|
|
37
|
+
return Object.hasOwn(PROVIDER_FACTORIES, name);
|
|
38
|
+
}
|
|
39
|
+
export function getUploadProvider(config) {
|
|
40
|
+
if (!config.uploadProvider) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (!isProviderName(config.uploadProvider)) {
|
|
44
|
+
const available = Object.keys(PROVIDER_FACTORIES).join(", ");
|
|
45
|
+
throw new Error(`Unknown upload provider: ${config.uploadProvider}. Available: ${available}`);
|
|
46
|
+
}
|
|
47
|
+
return PROVIDER_FACTORIES[config.uploadProvider](config);
|
|
48
|
+
}
|
|
49
|
+
/** Max size for inline attachments (bytes). Files larger than this
|
|
50
|
+
* are automatically sent as remote attachments when a provider is configured. */
|
|
51
|
+
export const INLINE_ATTACHMENT_MAX_BYTES = 1_000_000;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities extracted from @xmtp/cli that convos-cli needs.
|
|
3
|
+
*
|
|
4
|
+
* The published @xmtp/cli@0.1.0 only exports { run } — these small
|
|
5
|
+
* helpers are inlined here so convos-cli can be used with the official
|
|
6
|
+
* npm package instead of a local fork.
|
|
7
|
+
*/
|
|
8
|
+
import { Dm, Group, type DecodedMessage, type Conversation } from "@xmtp/node-sdk";
|
|
9
|
+
export declare function isGroup(conversation: Conversation): conversation is Group;
|
|
10
|
+
export declare function isDm(conversation: Conversation): conversation is Dm;
|
|
11
|
+
export declare function requireGroup(conversation: Conversation): Group;
|
|
12
|
+
export declare function requireDm(conversation: Conversation): Dm;
|
|
13
|
+
export declare function toHexBytes(hex: string): Uint8Array;
|
|
14
|
+
export declare function hexToBytes(value: string): Uint8Array;
|
|
15
|
+
export declare function getAccountAddress(walletKey: string): string;
|
|
16
|
+
export declare const VALID_ENVS: readonly ["local", "dev", "production"];
|
|
17
|
+
/** Map of inboxId (lowercase) → display name. */
|
|
18
|
+
export type ProfileMap = Map<string, string>;
|
|
19
|
+
/**
|
|
20
|
+
* Build a ProfileMap from a Group's appData.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildProfileMap(appData: string): ProfileMap;
|
|
23
|
+
/**
|
|
24
|
+
* Returns true if the message has a content type we know how to display.
|
|
25
|
+
* Use this to filter out unknown/binary content types from streams.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isDisplayableMessage(message: DecodedMessage): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Normalize message content to a string for display. NAPI-backed objects
|
|
30
|
+
* (like GroupUpdated) don't have enumerable properties, so JSON.stringify
|
|
31
|
+
* produces `{}` or they coerce to `[object Object]`. This function always
|
|
32
|
+
* returns a string safe for display.
|
|
33
|
+
*
|
|
34
|
+
* @param profiles - optional ProfileMap for resolving inbox IDs to names.
|
|
35
|
+
* When omitted, unresolved members appear as "Somebody".
|
|
36
|
+
*/
|
|
37
|
+
export declare function normalizeMessageContent(message: DecodedMessage, profiles?: ProfileMap): string;
|
|
38
|
+
export declare function isTTY(): boolean;
|
|
39
|
+
export declare function jsonStringify(data: unknown, pretty?: boolean): string;
|
|
40
|
+
export declare function formatHuman(data: unknown, indent?: number): string;
|
|
41
|
+
export interface Section {
|
|
42
|
+
title: string;
|
|
43
|
+
data: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
export declare function formatSections(sections: Section[], indent?: number): string;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities extracted from @xmtp/cli that convos-cli needs.
|
|
3
|
+
*
|
|
4
|
+
* The published @xmtp/cli@0.1.0 only exports { run } — these small
|
|
5
|
+
* helpers are inlined here so convos-cli can be used with the official
|
|
6
|
+
* npm package instead of a local fork.
|
|
7
|
+
*/
|
|
8
|
+
import { stdout } from "node:process";
|
|
9
|
+
import { Dm, Group, } from "@xmtp/node-sdk";
|
|
10
|
+
import { parseAppData } from "./metadata.js";
|
|
11
|
+
import { isHex, toBytes } from "viem";
|
|
12
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
13
|
+
// ─── Conversation type guards ───
|
|
14
|
+
export function isGroup(conversation) {
|
|
15
|
+
return conversation instanceof Group;
|
|
16
|
+
}
|
|
17
|
+
export function isDm(conversation) {
|
|
18
|
+
return conversation instanceof Dm;
|
|
19
|
+
}
|
|
20
|
+
export function requireGroup(conversation) {
|
|
21
|
+
if (!isGroup(conversation)) {
|
|
22
|
+
throw new Error("This command is only available for group conversations");
|
|
23
|
+
}
|
|
24
|
+
return conversation;
|
|
25
|
+
}
|
|
26
|
+
export function requireDm(conversation) {
|
|
27
|
+
if (!isDm(conversation)) {
|
|
28
|
+
throw new Error("This command is only available for DM conversations");
|
|
29
|
+
}
|
|
30
|
+
return conversation;
|
|
31
|
+
}
|
|
32
|
+
// ─── Hex / key helpers ───
|
|
33
|
+
export function toHexBytes(hex) {
|
|
34
|
+
const prefixedHex = hex.startsWith("0x") ? hex : `0x${hex}`;
|
|
35
|
+
return hexToBytes(prefixedHex);
|
|
36
|
+
}
|
|
37
|
+
export function hexToBytes(value) {
|
|
38
|
+
const hex = value.startsWith("0x") ? value : `0x${value}`;
|
|
39
|
+
if (!isHex(hex, { strict: true })) {
|
|
40
|
+
throw new Error(`Invalid hex string: ${value}`);
|
|
41
|
+
}
|
|
42
|
+
return toBytes(hex);
|
|
43
|
+
}
|
|
44
|
+
export function getAccountAddress(walletKey) {
|
|
45
|
+
const account = privateKeyToAccount(walletKey);
|
|
46
|
+
return account.address;
|
|
47
|
+
}
|
|
48
|
+
// ─── Config constants ───
|
|
49
|
+
export const VALID_ENVS = ["local", "dev", "production"];
|
|
50
|
+
/**
|
|
51
|
+
* Build a ProfileMap from a Group's appData.
|
|
52
|
+
*/
|
|
53
|
+
export function buildProfileMap(appData) {
|
|
54
|
+
const metadata = parseAppData(appData);
|
|
55
|
+
const map = new Map();
|
|
56
|
+
for (const p of metadata.profiles) {
|
|
57
|
+
if (p.name) {
|
|
58
|
+
map.set(p.inboxId.toLowerCase(), p.name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return map;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve an inbox ID to a display name, falling back to "Somebody".
|
|
65
|
+
*/
|
|
66
|
+
function resolveName(inboxId, profiles) {
|
|
67
|
+
return profiles.get(inboxId.toLowerCase()) ?? "Somebody";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Produce human-readable descriptions for a GroupUpdated event.
|
|
71
|
+
*/
|
|
72
|
+
function describeGroupUpdated(content, profiles) {
|
|
73
|
+
const descriptions = [];
|
|
74
|
+
const initiator = resolveName(content.initiatedByInboxId, profiles);
|
|
75
|
+
for (const inbox of content.addedInboxes) {
|
|
76
|
+
const added = resolveName(inbox.inboxId, profiles);
|
|
77
|
+
if (inbox.inboxId.toLowerCase() === content.initiatedByInboxId.toLowerCase()) {
|
|
78
|
+
descriptions.push(`${added} joined by invite`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
descriptions.push(`${initiator} added ${added}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const inbox of content.removedInboxes) {
|
|
85
|
+
const removed = resolveName(inbox.inboxId, profiles);
|
|
86
|
+
descriptions.push(`${initiator} removed ${removed}`);
|
|
87
|
+
}
|
|
88
|
+
for (const inbox of content.leftInboxes) {
|
|
89
|
+
const left = resolveName(inbox.inboxId, profiles);
|
|
90
|
+
descriptions.push(`${left} left the group`);
|
|
91
|
+
}
|
|
92
|
+
for (const change of content.metadataFieldChanges) {
|
|
93
|
+
const field = change.fieldName.replace(/_/g, " ");
|
|
94
|
+
if (change.newValue) {
|
|
95
|
+
descriptions.push(`${initiator} changed ${field} to "${change.newValue}"`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
descriptions.push(`${initiator} cleared ${field}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const inbox of content.addedAdminInboxes) {
|
|
102
|
+
const admin = resolveName(inbox.inboxId, profiles);
|
|
103
|
+
descriptions.push(`${initiator} made ${admin} an admin`);
|
|
104
|
+
}
|
|
105
|
+
for (const inbox of content.removedAdminInboxes) {
|
|
106
|
+
const admin = resolveName(inbox.inboxId, profiles);
|
|
107
|
+
descriptions.push(`${initiator} removed ${admin} as admin`);
|
|
108
|
+
}
|
|
109
|
+
for (const inbox of content.addedSuperAdminInboxes) {
|
|
110
|
+
const admin = resolveName(inbox.inboxId, profiles);
|
|
111
|
+
descriptions.push(`${initiator} made ${admin} a super admin`);
|
|
112
|
+
}
|
|
113
|
+
for (const inbox of content.removedSuperAdminInboxes) {
|
|
114
|
+
const admin = resolveName(inbox.inboxId, profiles);
|
|
115
|
+
descriptions.push(`${initiator} removed ${admin} as super admin`);
|
|
116
|
+
}
|
|
117
|
+
if (descriptions.length === 0) {
|
|
118
|
+
descriptions.push("Group updated");
|
|
119
|
+
}
|
|
120
|
+
return descriptions;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Content type IDs that we know how to display.
|
|
124
|
+
* Everything else may be a NAPI object that serializes as `[object Object]`.
|
|
125
|
+
*/
|
|
126
|
+
const DISPLAYABLE_TYPE_IDS = new Set([
|
|
127
|
+
"text",
|
|
128
|
+
"markdown",
|
|
129
|
+
"group_updated",
|
|
130
|
+
"reaction",
|
|
131
|
+
"reply",
|
|
132
|
+
"attachment",
|
|
133
|
+
"remoteStaticAttachment",
|
|
134
|
+
"multiRemoteStaticAttachment",
|
|
135
|
+
]);
|
|
136
|
+
/**
|
|
137
|
+
* Returns true if the message has a content type we know how to display.
|
|
138
|
+
* Use this to filter out unknown/binary content types from streams.
|
|
139
|
+
*/
|
|
140
|
+
export function isDisplayableMessage(message) {
|
|
141
|
+
const ct = message.contentType;
|
|
142
|
+
if (ct.authorityId !== "xmtp.org")
|
|
143
|
+
return false;
|
|
144
|
+
if (DISPLAYABLE_TYPE_IDS.has(ct.typeId))
|
|
145
|
+
return true;
|
|
146
|
+
// Fallback: if content is already a string, it's safe to display
|
|
147
|
+
return typeof message.content === "string";
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Normalize message content to a string for display. NAPI-backed objects
|
|
151
|
+
* (like GroupUpdated) don't have enumerable properties, so JSON.stringify
|
|
152
|
+
* produces `{}` or they coerce to `[object Object]`. This function always
|
|
153
|
+
* returns a string safe for display.
|
|
154
|
+
*
|
|
155
|
+
* @param profiles - optional ProfileMap for resolving inbox IDs to names.
|
|
156
|
+
* When omitted, unresolved members appear as "Somebody".
|
|
157
|
+
*/
|
|
158
|
+
export function normalizeMessageContent(message, profiles) {
|
|
159
|
+
const ct = message.contentType;
|
|
160
|
+
// Text / markdown — already a string
|
|
161
|
+
if (typeof message.content === "string") {
|
|
162
|
+
return message.content;
|
|
163
|
+
}
|
|
164
|
+
// GroupUpdated — human-readable description
|
|
165
|
+
if (ct.authorityId === "xmtp.org" &&
|
|
166
|
+
ct.typeId === "group_updated" &&
|
|
167
|
+
message.content != null &&
|
|
168
|
+
typeof message.content === "object") {
|
|
169
|
+
return describeGroupUpdated(message.content, profiles ?? new Map()).join("; ");
|
|
170
|
+
}
|
|
171
|
+
// Reaction — e.g. "reacted 👍 to <msgId>"
|
|
172
|
+
if (ct.authorityId === "xmtp.org" &&
|
|
173
|
+
ct.typeId === "reaction" &&
|
|
174
|
+
message.content != null &&
|
|
175
|
+
typeof message.content === "object") {
|
|
176
|
+
const r = message.content;
|
|
177
|
+
const verb = r.action === 2 ? "removed" : "reacted";
|
|
178
|
+
return `${verb} ${r.content} to ${r.reference}`;
|
|
179
|
+
}
|
|
180
|
+
// Reply — extract text content if possible
|
|
181
|
+
if (ct.authorityId === "xmtp.org" &&
|
|
182
|
+
ct.typeId === "reply" &&
|
|
183
|
+
message.content != null &&
|
|
184
|
+
typeof message.content === "object") {
|
|
185
|
+
const r = message.content;
|
|
186
|
+
const text = typeof r.content === "string" ? r.content : JSON.stringify(r.content);
|
|
187
|
+
return `reply to ${r.reference}: ${text}`;
|
|
188
|
+
}
|
|
189
|
+
// Inline attachment — filename and mime type
|
|
190
|
+
if (ct.authorityId === "xmtp.org" &&
|
|
191
|
+
ct.typeId === "attachment" &&
|
|
192
|
+
message.content != null &&
|
|
193
|
+
typeof message.content === "object") {
|
|
194
|
+
const a = message.content;
|
|
195
|
+
const name = a.filename || "unnamed";
|
|
196
|
+
return `[attachment: ${name} (${a.mimeType})]`;
|
|
197
|
+
}
|
|
198
|
+
// Remote attachment — URL, filename, mime info
|
|
199
|
+
if (ct.authorityId === "xmtp.org" &&
|
|
200
|
+
ct.typeId === "remoteStaticAttachment" &&
|
|
201
|
+
message.content != null &&
|
|
202
|
+
typeof message.content === "object") {
|
|
203
|
+
const a = message.content;
|
|
204
|
+
const name = a.filename || "unnamed";
|
|
205
|
+
return `[remote attachment: ${name} (${a.contentLength} bytes) ${a.url}]`;
|
|
206
|
+
}
|
|
207
|
+
// Multi remote attachment
|
|
208
|
+
if (ct.authorityId === "xmtp.org" &&
|
|
209
|
+
ct.typeId === "multiRemoteStaticAttachment" &&
|
|
210
|
+
message.content != null &&
|
|
211
|
+
typeof message.content === "object") {
|
|
212
|
+
const m = message.content;
|
|
213
|
+
const count = m.attachments?.length ?? 0;
|
|
214
|
+
const names = m.attachments?.map((a) => a.filename || "unnamed").join(", ") ?? "";
|
|
215
|
+
return `[${count} attachments: ${names}]`;
|
|
216
|
+
}
|
|
217
|
+
// Fallback — stringify safely
|
|
218
|
+
try {
|
|
219
|
+
const str = JSON.stringify(message.content);
|
|
220
|
+
return str !== undefined ? str : "";
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return "";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// ─── Output formatting ───
|
|
227
|
+
export function isTTY() {
|
|
228
|
+
return stdout.isTTY ?? false;
|
|
229
|
+
}
|
|
230
|
+
export function jsonStringify(data, pretty = false) {
|
|
231
|
+
return JSON.stringify(data, (_, value) => (typeof value === "bigint" ? value.toString() : value), pretty ? 2 : undefined);
|
|
232
|
+
}
|
|
233
|
+
export function formatHuman(data, indent = 0) {
|
|
234
|
+
const prefix = " ".repeat(indent);
|
|
235
|
+
if (data === null || data === undefined)
|
|
236
|
+
return "";
|
|
237
|
+
if (typeof data === "string")
|
|
238
|
+
return prefix + data;
|
|
239
|
+
if (typeof data === "number" ||
|
|
240
|
+
typeof data === "boolean" ||
|
|
241
|
+
typeof data === "bigint")
|
|
242
|
+
return prefix + String(data);
|
|
243
|
+
if (Array.isArray(data)) {
|
|
244
|
+
if (data.length === 0)
|
|
245
|
+
return prefix + "(empty)";
|
|
246
|
+
if (typeof data[0] === "object" && data[0] !== null)
|
|
247
|
+
return formatTable(data, prefix);
|
|
248
|
+
return data.map((item) => formatHuman(item, indent)).join("\n");
|
|
249
|
+
}
|
|
250
|
+
if (typeof data === "object")
|
|
251
|
+
return formatObject(data, prefix);
|
|
252
|
+
return prefix + jsonStringify(data);
|
|
253
|
+
}
|
|
254
|
+
function stringifyValue(value) {
|
|
255
|
+
if (value === null || value === undefined)
|
|
256
|
+
return "";
|
|
257
|
+
if (typeof value === "string")
|
|
258
|
+
return value;
|
|
259
|
+
if (typeof value === "number" ||
|
|
260
|
+
typeof value === "boolean" ||
|
|
261
|
+
typeof value === "bigint")
|
|
262
|
+
return String(value);
|
|
263
|
+
return jsonStringify(value);
|
|
264
|
+
}
|
|
265
|
+
function formatTable(rows, prefix = "") {
|
|
266
|
+
if (rows.length === 0)
|
|
267
|
+
return "";
|
|
268
|
+
const keys = Object.keys(rows[0]);
|
|
269
|
+
const widths = keys.map((key) => Math.max(key.length, ...rows.map((row) => stringifyValue(row[key]).length)));
|
|
270
|
+
const header = prefix + keys.map((key, i) => key.padEnd(widths[i])).join(" ");
|
|
271
|
+
const separator = prefix + widths.map((w) => "-".repeat(w)).join(" ");
|
|
272
|
+
const body = rows
|
|
273
|
+
.map((row) => prefix +
|
|
274
|
+
keys
|
|
275
|
+
.map((key, i) => stringifyValue(row[key]).padEnd(widths[i]))
|
|
276
|
+
.join(" "))
|
|
277
|
+
.join("\n");
|
|
278
|
+
return `${header}\n${separator}\n${body}`;
|
|
279
|
+
}
|
|
280
|
+
function formatObject(obj, prefix = "", keyWidth) {
|
|
281
|
+
const entries = Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== "");
|
|
282
|
+
if (entries.length === 0)
|
|
283
|
+
return "";
|
|
284
|
+
const maxKeyLen = keyWidth ?? Math.max(...entries.map(([k]) => k.length));
|
|
285
|
+
return entries
|
|
286
|
+
.map(([key, value]) => `${prefix}${key.padEnd(maxKeyLen)} ${formatHuman(value)}`)
|
|
287
|
+
.join("\n");
|
|
288
|
+
}
|
|
289
|
+
export function formatSections(sections, indent = 0) {
|
|
290
|
+
const prefix = " ".repeat(indent);
|
|
291
|
+
const allKeys = sections.flatMap((s) => Object.entries(s.data)
|
|
292
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== "")
|
|
293
|
+
.map(([k]) => k));
|
|
294
|
+
const keyWidth = allKeys.length > 0 ? Math.max(...allKeys.map((k) => k.length)) : 0;
|
|
295
|
+
return sections
|
|
296
|
+
.map((s) => `${s.title}\n\n${formatObject(s.data, prefix, keyWidth)}`)
|
|
297
|
+
.join("\n\n");
|
|
298
|
+
}
|