Build an XMTP CLI tool
Use this starter project to build an XMTP CLI tool. You can also check out the GitHub repo
Prerequisites
- Node.js version >16.7
Installation
- Run
npm i
in this folder - Run
npm run watch
in your terminal, and leave it running as you complete this exercise - Ensure that installation succeeded by running
./xmtp --help
in another terminal window - Initialize with a random wallet by running
./xmtp init
Tools we will be using
- xmtp-js for interacting with the XMTP network
- yargs for command line parsing
- ink for rendering the CLI using React components
Challenges
Send a message to an address
In src/index.ts
, you will see a command already defined:
.command(
'send <address> <message>',
'Send a message to a blockchain address',
{
address: { type: 'string', demand: true },
message: { type: 'string', demand: true }
},
async (argv: any) => {
throw new Error('BUILD ME')
}
)
We want the user to be able to send the contents of the message
argument to the specified address
.
To start, you'll need to create an instance of the XMTP SDK using the provided loadWallet()
helper.
const { env, message, address } = argv;
const client = await Client.create(loadWallet(), { env });
To send a message, you'll need to create a conversation instance and then send that message to the conversation.
const conversation = await client.conversations.newConversation(address);
const sent = await conversation.send(message);
So, putting it all together, the command will look like this:
.command(
'send <address> <message>',
'Send a message to a blockchain address',
{
address: { type: 'string', demand: true },
message: { type: 'string', demand: true },
},
async (argv: any) => {
const { env, message, address } = argv
const client = await Client.create(loadWallet(), { env })
const conversation = await client.conversations.newConversation(address)
const sent = await conversation.send(message)
// Use the Ink renderer provided in the example
render(<Message {...sent} />)
}
)
Verify it works
./xmtp send 0xF8cd371Ae43e1A6a9bafBB4FD48707607D24aE43 "Hello world"
List all messages from an address
The next command we are going to implement is list-messages
. The starter looks like this:
.command(
'list-messages <address>',
'List all messages from an address',
{ address: { type: 'string', demand: true } },
async (argv) => {
throw new Error('BUILD ME!')
}
)
Load the Client the same as before, and then load the conversation with the supplied address:
const client = await Client.create(loadWallet(), { env });
const convo = await client.conversations.newConversation(address);
Get all the messages in the conversation with:
const messages = await convo.messages();
You can then render them prettily with the supplied renderer component:
const title = `Messages between ${truncateEthAddress(
client.address,
)} and ${truncateEthAddress(convo.peerAddress)}`;
render(<MessageList title={title} messages={messages} />);
The completed command will look like this:
.command(
'list-messages <address>',
'List all messages from an address',
{ address: { type: 'string', demand: true } },
async (argv: any) => {
const { env, address } = argv
const client = await Client.create(loadWallet(), { env })
const conversation = await client.conversations.newConversation(address)
const messages = await conversation.messages()
const title = `Messages between ${truncateEthAddress(
client.address
)} and ${truncateEthAddress(conversation.peerAddress)}`
render(<MessageList title={title} messages={messages} />)
}
)
Verify it works
./xmtp list-messages 0xF8cd371Ae43e1A6a9bafBB4FD48707607D24aE43
Stream all messages
To stream messages from an address, we'll want to use a stateful React component. This will require doing some work in the command, as well as the Ink component
The starter command in index.tsx
should look like this:
.command(
'stream-all',
'Stream messages from any address',
{},
async (argv: any) => {
throw new Error('BUILD ME')
}
)
There is also a starter React component that looks like this:
export const MessageStream = ({ stream, title }: MessageStreamProps) => {
const [messages, setMessages] = useState<DecodedMessage[]>([]);
return <MessageList title={title} messages={messages} />;
};
First, we will want to get a message Stream, which is just an Async Iterable.
const { env } = argv;
const client = await Client.create(loadWallet(), { env });
const stream = await client.conversations.streamAllMessages();
Then we will pass that stream to the component with something like this:
render(<MessageStream stream={stream} title={`Streaming all messages`} />);
Update the MessageStream
React component in renderers.tsx
to listen to the stream and update the state as new messages come in.
We can accomplish that with a useEffect
hook that pulls from the Async Iterable and updates the state each time a message comes in.
You'll want to keep track of seen messages, as duplicates are possible in a short time window.
useEffect(() => {
if (!stream) {
return;
}
// Keep track of all seen messages.
// Would be more performant to keep this to a limited buffer of the most recent 5 messages
const seenMessages = new Set<string>();
const listenForMessages = async () => {
for await (const message of stream) {
if (seenMessages.has(message.id)) {
continue;
}
// Add the message to the existing array
setMessages((existing) => existing.concat(message));
seenMessages.add(message.id);
}
};
listenForMessages();
// When unmounting, always remember to close the stream
return () => {
if (stream) {
stream.return(undefined);
}
};
}, [stream, setMessages]);
Verify it works
./xmtp stream-all
Listen for messages from a single address
The starter for this command should look like:
.command(
'stream <address>',
'Stream messages from an address',
{ address: { type: 'string', demand: true } },
async (argv: any) => {
throw new Error('BUILD ME')
}
)
You can implement this challenge by combining what you learned from listing all messages in a conversation and rendering a message stream.
You can get a message stream from a Conversation
by using the method conversation.stream()
.
Verify it works
./xmtp stream 0xF8cd371Ae43e1A6a9bafBB4FD48707607D24aE43
Proper key management
All the examples thus far have been using a randomly generated wallet and a private key stored in a file on disk. It would be better if we could use this with any existing wallet, and if we weren't touching private keys at all.
With a simple webpage that uses Wagmi, Web3Modal, or any other library that returns an ethers.Signer
you can export XMTP-specific keys and store those on the user's machine.
The command to export keys is Client.getKeys(wallet, { env })
.