blog.christoffer.online
Hi, my name is Christoffer and I am an Internet addict.

How I created a !clip command for a Twitch channel, that automatically creates a clip and posts it to a Discord server via Nightbot using AWS Lambda functions (a technical guide)

2019-06-05 18:00

Introduction

This blog post kinda assumes you have a (web) developer background, as I will dive right into common tools and practices within web development, which might confuse non-developers. Maybe. I don't know. ¯\(ツ)/¯ We'll see.

So, I follow a Twitch streamer called KajaKeks which is a great variety streamer, but also has a habit to end up in funny situations worth clipping.

Since the Twitch keyboard shortcut ALT+X while watching her is way too much effort for me, I thought, would it be cool just to type !clip in the Twitch channel, and then a clip will be automatically created and then posted in the #clip channel in community Discord server.

So I started to read more about the Twitch, Discord and Nightbot APIs and about AWS Lambda functions and came up with a solution that tied all these technologies together, that requires very little effort from me to maintain.

The result is pretty straight forward. If someone types !clip in the Twitch channel, Nightbot will respond with that a clip was created:

The result in Twitch

And if you look in the Discord server a clip was automatically posted:

The result in Discord

Pretty nifty!

And here is Kaja explaining the feature to the rest of her community:

Technically the solution I will present below has the following flow to automatically generate a 30 second long clip and post it to Discord:

It is possible to swap different parts out for other alternatively solutions. For example, I use AWS Lambdas (I'll explain what that is later on), but you can for example use Google Cloud Platform's cloud functions or Microsoft Azure Functions, or create your own standalone web server. I also use node.js for my code, but AWS Lambdas support multiple languages, etc.

But at least you will see how I personally did this.

This is a pretty lengthy guide with lots of steps, so I will try and be short and only go through the necessary steps below. So let's go!

Table of Index

Step 1 - Registering a new Twitch application

In order to create clips programmatically, there has to be a registered Twitch application. For Kaja, I actually created a new Twitch user, called “KajaClipper”, to separate things from my own personal Twitch account, and on that user, I registered a new Twitch application.

Creating an application is pretty straight forward. Visit your Twitch developer dashboard and click on "Register Your App":

Register Your App

Fill out the details and save:

Fill out your details

Then go back into your application and you will see the application's Client ID and Secret.

See application ID and Secret

You will need both later on, and both are sensitive - so make sure you don't spread this publicly.

Step 2 - Giving the Twitch application the rights to create clips on your behalf

Clips can only be created by Twitch users, so we need to give our newly created Twitch application the rights to create clips on behalf of the user. Our end goal here is to get an Authorization Code (which we will be using later on).

In order to give our application the rights, we need to follow the OAuth Authorization Code Flow.

Since we control both the User and the Application, we can cheat a bit and redirect ourselves to localhost.

So basically, we just need to enter this URL in our browser:

https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=##CLIENT_ID##&redirect_uri=http://localhost/&scope=clips:edit

(Replace the ##CLIENT_ID## with your application's client ID above.)

This will bring us to a classic OAUTH access screen, which we will authorize.

oauth the app

Afterwards Twitch will redirect us to http://localhost/ - which is most likely a broken page. But don't be afraid, what we need here is the code query parameter:

getting authorization code

Save this code, as this our Authorization Code which we will be using in a moment.

Step 3 - Getting the access & refresh token for your code

With the above Authorization Code we can now get a User Access Token. This is done by simply doing an HTTP POST request to Twitch OAuth APIs again. The most easiest way to do this is by doing a CURL command in a terminal:

curl -X POST -k -i 'https://id.twitch.tv/oauth2/token?client_id=##CLIENT_ID##&client_secret=##CLIENT_SECRET##&code=##AUTH_CODE##&grant_type=authorization_code&redirect_uri=http://localhost/'

(Replace the ##CLIENT_ID## and ##CLIENT_SECRET## with your application's client ID and Secret above, and ##AUTH_CODE## with the Authorization Code you got above.)

Executing this should result is a JSON response that looks similar to:

{
  "access_token": "##ACCESS_TOKEN##",
  "expires_in": 14707,
  "refresh_token": "##REFRESH_TOKEN##",
  "scope": [
    "clips:edit"
  ],
  "token_type": "bearer"
}

This is great. The access token we got (obfuscated with ##ACCESS_TOKEN above) now allows us to trigger the Twitch APIs on behalf of the user.

Also note that you can only do this request once. If you do it a second time you will get an authorization error, so you will need to start over and get a new Authorization Code.

However, if you look at the expires_in you will notice the token will only last 14707 seconds. That's slightly over 4 hours. That's no good. We need be able to create clips at any time, without doing this whole process again.

So what we are really interested in is the ##REFRESH_TOKEN##.

We will be using the refresh token each time we want to send a request to Twitch, in order to generate a new access token. This will allow us to send requests on behalf of the user even though it's gone more than 4 hours between. So each time we speak with Twitch we will do two requests; one to refresh and get a new access token, and the other to actually create a clip. However normally you don't refresh the token unless you know it's expired, but we are lazy today.

Step 4 - Getting the Twitch channel's broadcast ID

Another thing we need to get is the Twitch channel's broadcast ID. We know the channel's name is KajaKeks, but we don't know the actual broadcast ID.

This can easily be fetched, again using the Twitch APIs, by visiting this URL in a browser:

https://api.twitch.tv/kraken/users/?api_version=5&client_id=##CLIENT_ID##&login=KajaKeks

(Replace ##CLIENT_ID## with your application's client ID and KajaKeks with your own channel.)

The response should be in JSON, similar to:

{
  "_total": 1,
  "users": [
    {
      "display_name": "KajaKeks",
      "_id": "176275234",
      "name": "kajakeks",
      "type": "user",
      "bio": "Kaja the uncarriable, Mother of cookies",
      "created_at": "2017-10-02T16:59:06.576926Z",
      "updated_at": "2019-06-04T21:31:34.835908Z",
      "logo": "https://static-cdn.jtvnw.net/jtv_user_pictures/13e21a0a-624e-4b73-be33-ddda81c7fe13-profile_image-300x300.jpeg"
    }
  ]
}

Save the _id value here, as this is the broadcaster ID which we will be using later on.

Step 5 - Creating a Discord channel webhook

In order to send data to a Discord server channel, we need to create a webhook. This is pretty straight forward.

As an administrator, simply go to the channel's settings and create a new webhook:

creating a new webhook in discord

What we want here is the webhook URL (which is sensitive, so don't share this).

fetch the webhook url

It should look something like this:

https://discordapp.com/api/webhooks/1234/abcd

Again, save this, as we will be using this later on.

Step 6 - Creating an AWS Lambda function

Creating this as an AWS Lambda function felt very natural for me, as it is basically code you upload to a cloud infrastructure and only gets invoked if its triggered by an event (an HTTP request in our case). So I don't need to build or maintain my own web server.

I am already an AWS customer, so I already have an account and billing enabled, etc.

Creating a Lambda function is very straight forward as well. Simply head over to your Lambda management console and hit "Create function":

creating a new lambda function

Create one from scratch and enter a function name. Since I know node.js, I simply just took that. However you can do this any language you want. The principles are the same.

enter name and make it open

This will create a new Lambda function, but no way to actually trigger it. So we need to add an API gateway and make it Open.

enter name and make it open

Once we save the function, it will give us a generic URL which will invoke the function:

get the lambda api url

If you simple visit the URL:

https://06jc5ef8qj.execute-api.eu-west-1.amazonaws.com/default/my-awesome-twitch-clip-function

You will see that the function will return a "Hello from Lambda!". This is simply because our Lambda function is basically just a simple HTTP handler that returns "Hello from Labda":

get the lambda api url

But we will come back and fix that code later on.

Step 7 - Creating the Nightbot !clip command

Since Kaja is using Nightbot, we simply created a new !clip command that does an URL fetch:

$(urlfetch https://06jc5ef8qj.execute-api.eu-west-1.amazonaws.com/default/my-awesome-twitch-clip-function)

This means that anyone who does !clip will actually just do a simple tell Nightbot to do an HTTP GET request to the Lambda function and paste the response back to the Twitch channel.

As a bonus thing, also notice that Nightbot sends the name of the user that invokes the command as the Nightbot-User header. This will be handy later on in our code!

Step 8 - Actually writing the code that ties everything together

If we go back to our Lambda function, we can now create write the whole code that ties everything together:

screenshot of the lamda code

So we all know any code can be written in a million different ways, and the code I wrote for this project is pretty ad hoc, but it works, and that's good enough for me.

Instead of going through it all, I will simply paste it in a modified version below and anyone reading this can look at how I solved different things.

Just remember to replace all the sensitive variables such as the application's ID and secret, as we all at the broadcaster ID and the Discord server webhook.

Hope the code makes sense, enjoy!

const https = require("https");

const CLIENT_ID = "";
const CLIENT_SECRET = "";
const REFRESH_TOKEN = "";
const CHANNEL_ID = ;

const ERROR_TYPE_TWITCH_CHANNEL_OFFLINE = 1;

async function getRefreshedAccessToken() {

    const response = await doPost(
        "id.twitch.tv",
        "/oauth2/token?grant_type=refresh_token&refresh_token=" + REFRESH_TOKEN + "&client_id=" + CLIENT_ID + "&client_secret=" + CLIENT_SECRET,
        undefined,
        undefined
    );

    const json = JSON.parse(response);
    return json.access_token;

}

async function createTwitchClip(accessToken) {

    try {

        const response = await doPost(
            "api.twitch.tv",
            "/helix/clips?has_delay=true&broadcaster_id=" + CHANNEL_ID,
            undefined,
            {
                "Authorization": "Bearer " + accessToken,
            }
        );

        const json = JSON.parse(response);
        console.log(json);

        const clipID = json.data[0].id;
        console.log("clipID=", clipID);

        return "https://clips.twitch.tv/" + clipID;

    } catch (error) {

        if (typeof error === "string" && error.indexOf("Clipping is not possible for an offline channel.") !== -1) {
            const newError = new Error("Someone tried to clip while the channel is offline :ugh:");
            newError.type = ERROR_TYPE_TWITCH_CHANNEL_OFFLINE;
            throw newError;
        }

        throw error;

    }

}

async function sendToDiscord(message) {

    const postData = JSON.stringify({
        "content": message,
    });

    const path = "/api/webhooks/1234/abcd";

    await doPost(
        "discordapp.com",
        path,
        postData,
        {
            "Content-Type": "multipart/form-data",
        }
    );

}

function doPost(hostname, path, postData, headers) {
    return new Promise((resolve, reject) => {

        const options = {
            method: "POST",
            hostname,
            path,
            port: 443,
            headers,
        };

        const request = https.request(options, (response) => {
            response.setEncoding("utf8");
            let returnData = "";

            response.on("data", (chunk) => {
                returnData += chunk;
            });

            response.on("end", () => {

                if (response.statusCode < 200 || response.statusCode >= 300) {
                    reject(returnData);
                } else {
                    resolve(returnData);
                }

            });

            response.on("error", (error) => {
                reject(error);
            });

        });

        if (postData) {
            request.write(postData);
        }

        request.end();
    });

}

async function main(nightbotUserName) {

    try {

        const accessToken = await getRefreshedAccessToken();

        const responseClipURL = await createTwitchClip(accessToken);

        const messageDiscord = "A new clip was created" + (nightbotUserName ? " by " + nightbotUserName : "") + "! <:happy:523461804093865985> \n" + responseClipURL;
        await sendToDiscord(messageDiscord);

        const messageWeb = "A new clip was created in the Discord server! :) ";
        return messageWeb;

    } catch (error) {

        if (error.type === ERROR_TYPE_TWITCH_CHANNEL_OFFLINE) {
            return "I can't clip while the channel is offline :(";
        }

        console.log("final error=", error);
        console.log("final error type='" + error.type + "'");
        await sendToDiscord(error.message);
        return error.message;
    }

}

exports.handler = async (event) => {

    let nightbotUserName = undefined;
    const nightbotUserHeader = event && event.headers && event.headers["Nightbot-User"];
    if (nightbotUserHeader) {
        const fragements = nightbotUserHeader.match(/displayName=([^\&]+)\&/i);
        if (fragements && fragements.length === 2) {
            nightbotUserName = fragements[1];
        }
    }

    const message = await main(nightbotUserName);

    const response = {
        statusCode: 200,
        body: message,
    };
    return response;

};