How to Build a Terminal Chat App

Ayooluwa I.
Ayooluwa I.
Published March 5, 2020 Updated March 9, 2020

Let's create a functional chat interface right in the terminal with Node.js! Although building a chat app is no small task, with the help of Stream's fully featured Chat API, it'll only take a few lines of code, as you'll see.

Prerequisites

Before proceeding with this tutorial, make sure you have Node.js and Yarn installed on your computer.

Signing Up for Stream

To start creating our project, let's get our free Stream Chat API keys. You can do so by signing up here, and creating a new application, once logged in. Take note of the application keys, as we'll be making use of them shortly:

Creating an Authentication Server

Before a user can join a chatroom, they need to be authenticated using a login system. After doing so, a token is returned to the client. Create a new directory for this project, cd into it, and initialize your Node project with a package.json file:

mkdir terminal-chat
cd terminal-chat
yarn init -y

Next, install the dependencies we'll be needing to build the server:

yarn add dotenv express stream-chat cors body-parser

Now that we've installed the necessary packages, let's set up our environment with the variables retrieved from the Stream dashboard. Create a .env file in the root of your project and set it up as follows:

PORT=5500
STREAM_API_KEY=<your api key>
STREAM_APP_SECRET=<your app secret>

The dotenv package takes care of making the variables specified in this file accessible in our code, via the process.env object.

Next, create a new server.js file in the project root, and paste in the following code:

require('dotenv').config();

const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const { StreamChat } = require('stream-chat');

const app = express();

app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

// initialize Stream Chat SDK
const serverSideClient = new StreamChat(
  process.env.STREAM_API_KEY,
  process.env.STREAM_APP_SECRET
);

app.post('/join', async (req, res) => {
  const { username } = req.body;
  const token = serverSideClient.createToken(username);
  try {
    await serverSideClient.updateUser(
      {
        id: username,
        name: username,
      },
      token
    );

    const admin = { id: 'admin' };
    const channel = serverSideClient.channel('team', 'general', {
      name: 'General',
      created_by: admin,
    });

    await channel.create();
    await channel.addMembers([username, 'admin']);

    res
      .status(200)
      .json({ user: { username }, token, api_key: process.env.STREAM_API_KEY });
  } catch (err) {
    console.error(err);
    res.status(500);
  }
});

const server = app.listen(process.env.PORT || 5500, () => {
  const { port } = server.address();
  console.log(`Server running on PORT ${port}`);
});

As mentioned earlier, we're not doing any real authentication here, so the token is generated straightaway, once a request is made to the /join route. The username of the user is all we need to generate a token, and this token is valid indefinitely, by default.

You can set an expiration to tokens by passing it as the second parameter.

The generated token is used to create or update the user on the Stream instance, and they are subsequently added to a general channel of the team type. This team type is meant for group conversations à la Slack. You can learn about other channel types here.

At this point, you can start the server by running node server.js which should make it available on port 5500.

Creating the Client

Now that the server is up and running, let's set up a simple chat client that will enable us to send messages back and forth, right in the terminal!

We need to add a few other packages for this step:

sh
1
yarn add axios prompt ora util neo-blessed

Next, create a new app.js file and populate it with the following code:

const axios = require('axios');
const prompt = require('prompt');
const ora = require('ora');
const { StreamChat } = require('stream-chat');
const util = require('util');
const blessed = require('neo-blessed');

function fetchToken(username) {
  return axios.post('http://localhost:5500/join', {
    username,
  });
}

async function main() {
  const spinner = ora();
  prompt.start();
  prompt.message = '';

  const get = util.promisify(prompt.get);

  const usernameSchema = [
    {
      description: 'Enter your username',
      name: 'username',
      type: 'string',
      pattern: /^[a-zA-Z0-9\-]+$/,
      message: 'Username must be only letters, numbers, or dashes',
      required: true,
    },
  ];

  const { username } = await get(usernameSchema);
  try {
    spinner.start('Fetching authentication token...');
    const response = await fetchToken(username);
    spinner.succeed(`Token fetched successfully!`);

    const { token } = response.data;
    const apiKey = response.data.api_key;

    const chatClient = new StreamChat(apiKey);

    spinner.start('Authenticating user...');
    await chatClient.setUser(
      {
        id: username,
        name: username,
      },
      token
    );
    spinner.succeed(`Authenticated successfully as ${username}!`);

    spinner.start('Connecting to the General channel...');
    const channel = chatClient.channel('team', 'general');
    await channel.watch();
    spinner.succeed('Connection successful!');
  } catch (err) {
    spinner.fail();
    console.log(err);
    process.exit(1);
  }
}
main();

When the above code is executed, the user's username is requested via the prompt package. This username is then sent to the server, which should be running by now, so that a token is retrieved, which is then used to specify the current user and connect to the General channel that was set up earlier.

At this point, we know that the user is authenticated and subscribed to the General channel. But how do we send and receive messages in the channel? That's where a Terminal User Interface comes in!

Creating a Terminal Chat UI Using Blessed

Blessed is a terminal interface library for Node.js. At the time of writing, the original package has not been updated in over three years, so we'll make use of this fork, which seems to be the most active one at the moment.

Update your code in app.js as shown below:

const axios = require('axios');
const prompt = require('prompt');
const ora = require('ora');
const { StreamChat } = require('stream-chat');
const util = require('util');
const blessed = require('neo-blessed');

function fetchToken(username) {
  return axios.post('http://localhost:5500/join', {
    username,
  });
}

async function main() {
  const spinner = ora();
  prompt.start();
  prompt.message = '';

  const get = util.promisify(prompt.get);

  const usernameSchema = [
    {
      description: 'Enter your username',
      name: 'username',
      type: 'string',
      pattern: /^[a-zA-Z0-9\-]+$/,
      message: 'Username must be only letters, numbers, or dashes',
      required: true,
    },
  ];

  const { username } = await get(usernameSchema);
  try {
    spinner.start('Fetching authentication token...');
    const response = await fetchToken(username);
    spinner.succeed(`Token fetched successfully!`);

    const { token } = response.data;
    const apiKey = response.data.api_key;

    const chatClient = new StreamChat(apiKey);

    spinner.start('Authenticating user...');
    await chatClient.setUser(
      {
        id: username,
        name: username,
      },
      token
    );
    spinner.succeed(`Authenticated successfully as ${username}!`);

    spinner.start('Connecting to the General channel...');
    const channel = chatClient.channel('team', 'general');
    await channel.watch();
    spinner.succeed('Connection successful!');
    process.stdin.removeAllListeners('data');

    const screen = blessed.screen({
      smartCSR: true,
      title: 'Stream Chat Demo',
    });

    var messageList = blessed.list({
      align: 'left',
      mouse: true,
      keys: true,
      width: '100%',
      height: '90%',
      top: 0,
      left: 0,
      scrollbar: {
        ch: ' ',
        inverse: true,
      },
      items: [],
    });

    // Append our box to the screen.
    var input = blessed.textarea({
      bottom: 0,
      height: '10%',
      inputOnFocus: true,
      padding: {
        top: 1,
        left: 2,
      },
      style: {
        fg: '#787878',
        bg: '#454545',

        focus: {
          fg: '#f6f6f6',
          bg: '#353535',
        },
      },
    });

    input.key('enter', async function() {
      var message = this.getValue();
      try {
        await channel.sendMessage({
          text: message,
        });
      } catch (err) {
        // error handling
      } finally {
        this.clearValue();
        screen.render();
      }
    });

    // Append our box to the screen.
    screen.key(['escape', 'q', 'C-c'], function() {
      return process.exit(0);
    });

    screen.append(messageList);
    screen.append(input);
    input.focus();

    screen.render();

    channel.on('message.new', async event => {
      messageList.addItem(`${event.user.id}: ${event.message.text}`);
      messageList.scrollTo(100);
      screen.render();
    });
  } catch (err) {
    spinner.fail();
    console.log(err);
    process.exit(1);
  }
}
main();

The relevant changes are between lines 57 and 128. We're using the blessed package to assemble a typical chat interface with a text input at the bottom, and the list of messages just above the input. The blessed documentation goes into detail on each of the widgets available to you and their configuration options.

Between lines 99 and 111, we're able to retrieve the text entered in the input once the user presses the Enter key, and send this text as a new message to the channel using the sendMessage method, which is documented here. Once the message is sent, the text input is cleared to allow the user to compose a new message.

On line 124, we're listening for new messages using an event on the channel so that they can be displayed in the message list. Each time this event is triggered, we append the text to the items array on messageList via the addItem method, which has the effect of printing the message on the screen.

If you enter node app.js in your terminal, a username will be requested, and once authenticated, a UI will pop up from which you can send and receive messages. You can open up multiple terminal instances and chat in between them, as shown below:

Final Thoughts

In this tutorial, we created a terminal chat app with Node, and used the Stream Chat API to send and receive messages through a Blessed terminal interface. This is only an example of what is possible with Stream. If you want to take this further, make sure to check the Stream docs to find out all the features that are available to you.

The complete source code used in this tutorial can be found in this GitHub repository. Thanks for reading and happy coding!

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->