How I created a !clip command that automatically created a Twitch clip and posted it to my Discord (using AWS Lambdas)

How I created a !clip command for my Twitch chat that creates and posts a clip to Discord

So, I recently created a !clip command for my Twitch chat, that automatically created a clip and posted it to my Discord, using Lambda function in Amazon Web Services. If you are interested in how I did that, then this guide might be for you!

Watch the video

There is also a YouTube video that goes through each step. It is not as detailed as this written guide, but could give a brief overview of what's needed.

Introduction to the written guide

So how do you make a coding guide fun, useful and most importantly comprehendible to understand? So, as an attempt to make it as clear as possible, I have broken down this guide into 9 steps, which we will go through together one by one.

However there are some prerequisites for this to be even feasible:

I won't go through how to create all these accounts, but it is fairly straightforward for each service.

Technically, this is how the solution works:

Also, please note that this is how I did this. It is possible to swap different parts out for other alternative solutions. For example, I use Amazon Web Services 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. I use StreamElements chatbot, but you can use Streamlabs or Nightbot, etc.

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

One last comment. In this guide I will be showing passwords and secrets in plain text. Don't do this yourself. This information is sensitive so when you do this guide, please do not share these secrets with anyone. I can however do this, since I have already deleted everything, making the passwords useless.

Okay, so let's go!

Pricing and how much this will cost us

But before we jump in, let's talk about pricing and how much this would cost us.

So using Discord webhook and posting to Discord is free.

As of when I am doing this guide, AWS Lambda functions is in the AWS free tier, meaning if you do less than a million requests, it is also free! But do double check this, if you are from the future!

Step 1 - Creating a Discord webhook

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

As an Discord server administrator, simply go to the channel's settings of the channel you want the clip to be posted to and create a new webhook.

You can assign a name to the webhook, upload an image, but what we really want here is the webhook URL.

Please note that the Webhook URL is sensitive information, so do not share this to anyone, or else anyone can post data to your Discord channel!

The URL will look something similar to:

https://discordapp.com/api/webhooks/abc123/abc123

After you have created your webhook save this whole URL, as we will be using this later on. I just copied the URL, opened up Notepad and pasted it there.

Step 2 - Registering a Twitch application

In order to create clips programmatically, there first has to be a registered Twitch application.

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

Pick an appropriate name (I took "ClipCommand") and for the "OAuth Redirect URL" type in "http://localhost/". Be very thorough with this as the next step won't work if there is a typo. Why it is localhost will make sense in a moment as well, and as the category I simply picked "Application Integration".

Once the application has been registered, go back into the application and you will see both the application's "Client ID" and "Secret". Copy and save both of them, as we will need them later on.

Also remember that both the "Client ID" and the "Secret", is sensitive information, so do not share this to anyone as well.

Step 3 - Giving the Twitch application the rights to create clips on our behalf

Now that we have registered our application, we need to give the application the rights to create clips on our behalf as a Twitch user. This will mean that any clips the application creates will look as if we manually did it with our user account.

In order to give our application the rights, we need to follow something called the "OAuth Authorization Code Flow". Our end goal here is to get something called the "Authorization Code", which we will be using later on.

Since we control both the user and the application, we can cheat the flow a bit and do this purely locally by using the host name "localhost" - that's why it was important the "OAuth Redirect URL" when registering our application was "http://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.

So in my particular example, the actual URL would be:

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

Simply paste the URL in your browser and you will be directed to an "OAuth Authorization Screen", where you will be asked if our application ("ClipCommand") may create clips on our behalf.

After pressing "Authorize", Twitch will redirect us to "http://localhost/" - which probably will look like a broken page. But don't be afraid, because what we need is in the new URL in the browser, which will look something like this:

http://localhost/?code=3qei509yg5v2uzar2aibtalo3vwiet&scope=clips%3Aedit

What we need here is to grab the value of the query parameter "code". This is the "Authorization Code" we were looking for.

So in our case, our "Authorization Code" is "3qei509yg5v2uzar2aibtalo3vwiet". Save this and as always, this is sensitive information so do not share this with anyone.

Step 4 - Getting the refresh token for our code

With the above "Authorization Code" we can now get something called the "User Access Token". This is done by simply doing a specific HTTP POST request to Twitch's OAuth API:

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/

Again, 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.

If you are a web developer, you know there are a bunch of online and offline tools that can do HTTP requests. Pick any you want.

What's important here is that when doing this request we will get both an "Access token" and a "Refresh token". The "Access token" is only valid for slightly over 4 hours (you can see this by inspecting the "expires_in" value). That's no good for us, since we need to be able to create clips at any time, without doing this whole process over again. So that's why we are only interested in the "Refresh token".

In this guide though, I have prepared a JavaScript snippet that does this for us which we simply need to run, for example, over at JSFiddle:

var clientID = prompt("Enter the application's Client ID"); var secret = prompt("Enter the application's Secret"); var authCode = prompt("Enter the authorization code"); var xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function() { if (this.readyState === 4) { if (this.status === 200) { console.log("response", this.responseText); var data = JSON.parse(this.responseText); alert("Your refresh token is:\n\n" + data.refresh_token + "\n\n"); } else { alert("Something went wrong please check the browser request logs."); } } }; var url = "https://id.twitch.tv/oauth2/token?client_id=" + clientID + "&client_secret=" + secret + "&code=" + authCode + "&grant_type=authorization_code&redirect_uri=http://localhost/"; xhttp.open("POST", url, true); xhttp.send();

After running this, it should end with displaying the "Refresh token" for us.

We can use this "Refresh Token" each time we do 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 each time.

So technically this means that each time we want Twitch to create a clip, we will be sending two requests; one to refresh and get a new access token, and then a second request to actually create a clip. However, normally you don't refresh the token unless you know it's expired, but in this guide, we will do both.

Step 5 - Getting the Twitch channel's Broadcast ID

Each Twitch channel has a fancy name, such as "SpecialAgentSqueaky", however technically each channel also has a "Broadcast ID" and this "Broadcast ID" is what is used when dealing with the Twitch APIs.

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=##CHANNEL_NAME##

Again, replace ##CLIENT_ID## with your application's client ID and #CHANNEL_NAME## with your own channel.

In the response we are looking for "_id". That is the channel's Broadcast ID.

As usual, I have prepared a JavaScript snippet that does this for us:

var clientID = prompt("Enter the application's Client ID"); var channelName = prompt("Enter Twitch channel name"); var xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function() { if (this.readyState === 4) { if (this.status === 200) { console.log("response", this.responseText); var data = JSON.parse(this.responseText); alert("The Broadcast ID is:\n\n" + data.users[0]._id + "\n\n"); } else { alert("Something went wrong please check the browser request logs."); } } }; var url = "https://api.twitch.tv/kraken/users/?api_version=5&client_id=" + clientID + "&login=" + channelName; xhttp.open("GET", url, true); xhttp.send();

Save the Broadcast ID, as we will be using this later.

Step 6 - Creating the Lambda function

This step requires that you are somewhat familiar with Amazon Web Services and Lambda functions.

A Lambda function is basically code we have uploaded to a cloud infrastructure (in our case Amazon Web Services) that does not require a traditional web server to run, but still can be invoked by an event. In our case, the event is a simple HTTP request.

In this section, we will simply create and prepare a Lambda function, which is pretty straight forward in Amazon Web Services. We simply head over to our "Lambda management console" and hit the "Create function".

Create one from scratch and enter a function name. In our case we will be using Node.js. However you can do this any language you want. The principles are the same.

This will create a new Lambda function, with a generic "Hello from Lambda" default code. This is great, but there is no way to actually trigger this function yet. So we need to add an "API gateway" and make it "Open".

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

In my specific case, my URL became:

https://pk35ltpn14.execute-api.eu-north-1.amazonaws.com/default/my-clip-command

So if we simply visit the URL in our browser, we will see a "Hello from Lambda!" response, as expected.

This is okay for now, as we will come back and add the real code later on. Just save the URL for the next step.

Step 7 - Creating the !clip command for our chat bot

Since I am using StreamElements, I will be showing how I did it using their bot. However, the principle is pretty much the same for any other chatbot, for example Streamlabs or Nightbot.

All we need to do is simply create a !clip command that triggers the URL of our newly created Lambda function.

So for StreamElements, simply head over to the "Chat commands" and then "Custom commands" section of the bot and create a new command with the keyword "!clip".

As the response, use the "$urlfetch" function with the URL, but we will also append the user's name as a query parameter. So the full command will be:

$(urlfetch https://pk35ltpn14.execute-api.eu-north-1.amazonaws.com/default/my-clip-command?user=${user.name})

Save the command, and if we type "!clip" in chat now, we should see "Hello from Lambda!" popping up.

Step 8 - Getting the Discord emote ID

Step 8 is really a bonus step, and we might be getting a little ahead of ourselves here, but this step adds more flavor for when clips are being posted in Discord, so just bare with me, and you will soon understand why.

So let's say we want to use a specific emote when posting to Discord. In order to do that, we need to figure out the emote's specific ID. Each emote has a very long unique ID in Discord.

To get that ID, simply open up Discord, post the emote we want to use, then right click on the mote and pick "Copy URL". Then paste in the URL so we can grab the ID. Save the ID for later use.

Step 9 - Actually adding the code that ties everything together

Now comes the part where we actually tie everything together, and where we will be using all the IDs we have fetched along the way.

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. Feel free to read, learn, modify and rewrite the whole code if you want. It's totally up to you.

However in this guide, we are basically just going to copy the entire block of code I have written and paste it directly into the Lambda function.

So let's start by copying and pasting the code found below in the guide.

Once that is done, there is a section at the top of the code, we will simply add each value into their respective variables. Most variables are self explanatory, but take particular note on how I use the Discord emote ID in the message sent to Discord.

When everything is replaced, simply save the function and test the command in your chat.

Everything should be working!

The full Lambda function code

// License MIT, Author Special Agent Squeaky (specialagentsqueaky.com), Last updated 2020-06-10 const https = require("https"); /* * Please add all the necessary values below * -------------------------------------------------- */ const APP_CLIENT_ID = ""; const APP_CLIENT_SECRET = ""; const APP_REFRESH_TOKEN = ""; const DISCORD_WEBHOOK_ID = ""; const DISCORD_WEBHOOK_TOKEN = ""; const CHANNEL_BROADCAST_ID = ""; const POST_MESSAGE_TWITCH_CHAT = () => { // It is possible to use channel emotes here, but the bot needs to be a subscriber return "A new clip was created in the Discord server! :)"; } const POST_MESSAGE_DISCORD = ( username, clipURL ) => { return "A new clip was created" + (username ? " by @" + username : "") + "! :)\n" + clipURL; } /* * -------------------------------------------------- */ 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=" + APP_REFRESH_TOKEN + "&client_id=" + APP_CLIENT_ID + "&client_secret=" + APP_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=false&broadcaster_id=" + CHANNEL_BROADCAST_ID, undefined, { "Authorization": "Bearer " + accessToken, "Client-ID": APP_CLIENT_ID, } ); const json = JSON.parse(response); console.log("create-twitch-clip-json", json); const clipID = json.data[0].id; console.log("create-twitch-clip-clip-id=", 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/" + DISCORD_WEBHOOK_ID + "/" + DISCORD_WEBHOOK_TOKEN; await doPost( "discordapp.com", path, postData, { "Content-Type": "application/json", } ); } 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( username ) { let accessToken; let responseClipURL; let messageDiscord; try { accessToken = await getRefreshedAccessToken(); } catch( error ) { console.error("problem-fetching-access-token", error); return "Unexpected problem when fetching the access token."; } try { console.log("accesstoken", accessToken); responseClipURL = await createTwitchClip(accessToken); } catch( error ) { console.error("problem-creating-clip", error); if( typeof error === "string" && error.indexOf("{") === 0 ) { error = JSON.parse(error); // Twitch broke =( if( error.error === "Service Unavailable" && error.status === 503 ) { return "Twitch API didn't want to create a clip right now, you need to manually create the clip :("; } } if( error.type === ERROR_TYPE_TWITCH_CHANNEL_OFFLINE ) { return "I can't clip while the channel is offline :("; } return "Unexpected problem when creating the clip."; } try { messageDiscord = POST_MESSAGE_DISCORD(username, responseClipURL); await sendToDiscord(messageDiscord); } catch( error ) { console.error("problem-sending-to-discord", error); return "Unexpected problem when posting to Discord."; } try { const messageWeb = POST_MESSAGE_TWITCH_CHAT(); return messageWeb; } catch( error ) { console.error("problem-getting-twitch-chat-response", error); return "Unexpected problem getting response to Twitch chat"; } } exports.handler = async ( event ) => { const username = event["queryStringParameters"] && event["queryStringParameters"]["user"]; console.log("username", username); const message = await main(username); const response = { statusCode: 200, headers: { "content-type": "text/plain; charset=UTF-8" }, body: message, }; return response; };