cli-starter
Starter project for building an XMTP CLI
Setup
Prerequisites
Installation
yarn install
yarn build
- Run
./xmtp --help
in another terminal window
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
Intialize random wallet
Initialize with a random wallet by running:
./xmtp init
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) => {
const { env, message, address } = argv
const client = await Client.create(loadWallet(), {
env: env as "dev" | "production" | "local",
})
const conversation = await client.conversations.newConversation(address)
const sent = await conversation.send(message)
render(<Message msg={sent} />)
},
)
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 conversaiton.
const conversation = await client.conversations.newConversation(address)
const sent = await conversation.send(message)
So, putting it all together the command will look like:
.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)
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
.command(
"list-messages <address>",
"List all messages from an address",
{ address: { type: "string", demand: true } },
async (argv) => {
const { env, address } = argv
const client = await Client.create(loadWallet(), {
env: env as "dev" | "production" | "local",
})
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} />)
},
)
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:
.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
.command(
"stream-all",
"Stream messages coming from any address",
{},
async (argv) => {
const { env } = argv
const client = await Client.create(loadWallet(), {
env: env as "dev" | "production" | "local",
})
const stream = await client.conversations.streamAllMessages()
render(<MessageStream stream={stream} title={`Streaming all messages`} />)
},
)
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
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
}
const seenMessages = new Set<string>()
const listenForMessages = async () => {
for await (const message of stream) {
if (seenMessages.has(message.id)) {
continue
}
setMessages((existing) => existing.concat(message))
seenMessages.add(message.id)
}
}
listenForMessages()
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) => {
const { env, address } = argv
const client = await Client.create(loadWallet(), {
env: env as "dev" | "production" | "local",
})
const conversation = await client.conversations.newConversation(address)
const stream = await conversation.streamMessages()
render(
<MessageStream stream={stream} title={`Streaming conv messages`} />,
)
},
)
You can implement this challenge by combining what you learned from listing all messages in a conversation and rendering a message stream.
Hint: You can get a message stream from a Conversation
by using the method conversation.streamMessages()
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 })
.