├── .env-sample ├── package.json ├── .gitignore ├── README.md └── index.js /.env-sample: -------------------------------------------------------------------------------- 1 | TWITCH_ACCESS_TOKEN= -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "channel-points-sample", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start":"node index.js" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "dotenv": "^8.2.0", 14 | "got": "^11.8.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sample Code for Channel Points using NodeJS 2 | 3 | This is a basic sample that aims to show how to use the new [Channel Points APIs](https://blog.twitch.tv/en/2020/11/13/twitch-developer-day-2020-introducing-the-channel-points-api-eventsub-and-more/), including creating rewards, getting rewards, getting redemptions, and updating the fulfillment status of each reward. 4 | 5 | To do so, this sample will: 6 | 7 | * Create a new custom reward (if it doesn't exist) 8 | * Poll for new redemptions 9 | * Follow the user as the action 10 | * Mark the redemption as fulfilled (or cancelled if an error happens) 11 | 12 | This sample is in Node to show the ease of the new APIs. 13 | 14 | **Note** 15 | 16 | This example polls for redemptions, but for larger-scale production tools, you should look into using [EventSub's Channel Points Webhook](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelchannel_points_custom_reward_redemptionadd) as it will scale more quickly than this example. 17 | 18 | ## How to Use 19 | 20 | To use this sample, you will need to do three things: 21 | 22 | * Get a user token with appropriate scopes 23 | * Set the token in a `.env` file 24 | * Install required packages 25 | 26 | ### Getting a Token 27 | 28 | The first is to install the Twitch CLI [here](https://github.com/twitchdev/twitch-cli) which will enable you to generate a token quickly using the following command: 29 | 30 | ```sh 31 | twitch token -u -s "channel:manage:redemptions user:edit:follows" 32 | ``` 33 | 34 | Alternatively, you can generate a token using the normal OAuth 2.0 flow as described in the [Authentication Docs](https://dev.twitch.tv/docs/authentication). The token requires two scopes: 35 | 36 | * channel:manage:redemptions 37 | * user:edit:follows 38 | 39 | ### Setting the Token in an Environment File (.env) 40 | 41 | Once you have the token, you will want to copy `.env-sample` into a new file `.env`. Once you've done so, set the `TWITCH_ACCESS_TOKEN` to the token generated above. 42 | 43 | An example .env file would look like: 44 | 45 | ``` 46 | TWITCH_ACCESS_TOKEN=abcdef12345 47 | ``` 48 | 49 | The sample will call the validate endpoint to get the broadcaster and Client ID used here. 50 | 51 | ### Install Required Packages 52 | 53 | To install the required packages, you just need to run the following in the root of the project: 54 | 55 | ```sh 56 | npm install 57 | ``` 58 | 59 | ### Running the Sample 60 | 61 | Once you've done the above, you can run the sample. 62 | 63 | To do so, simply run: 64 | 65 | ```sh 66 | npm start 67 | ``` 68 | 69 | This will start the file. 70 | 71 | ## Adjusting the File 72 | 73 | All of the code is in the [`index.js`](index.js) file. Please feel free to update the `customRewardBody` variable to meet your needs. -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const got = require('got') 2 | require('dotenv').config() 3 | 4 | let token = process.env['TWITCH_ACCESS_TOKEN'] 5 | 6 | const customRewardBody = { 7 | title: "Sample: Follow me!", 8 | prompt: "Follows the requesting user!", 9 | cost: 10 * 1000 * 1000, 10 | is_enabled: true, 11 | is_global_cooldown_enabled: true, 12 | global_cooldown_seconds: 10 * 60, 13 | } 14 | 15 | 16 | let clientId = "" 17 | let userId = "" 18 | let headers = {} 19 | let rewardId = "" 20 | let pollingInterval 21 | 22 | // validates the provided token and validates the token has the correct scope(s). additionally, uses the response to pull the correct client_id and broadcaster_id 23 | const validateToken = async () => { 24 | let r 25 | try { 26 | let { body } = await got(`https://id.twitch.tv/oauth2/validate`, { 27 | headers: { 28 | "Authorization": `Bearer ${token}` 29 | } 30 | }) 31 | r = JSON.parse(body) 32 | } catch (error) { 33 | console.log('Invalid token. Please get a new token using twitch token -u -s "channel:manage:redemptions user:edit:follows"') 34 | return false 35 | } 36 | 37 | if(r.scopes.indexOf("channel:manage:redemptions") == -1 || r.scopes.indexOf("user:edit:follows") == -1 || !r.hasOwnProperty('user_id')){ 38 | console.log('Invalid scopes. Please get a new token using twitch token -u -s "channel:manage:redemptions user:edit:follows"') 39 | return false 40 | } 41 | 42 | // update the global variables to returned values 43 | clientId = r.client_id 44 | userId = r.user_id 45 | headers = { 46 | "Authorization": `Bearer ${token}`, 47 | "Client-ID": clientId, 48 | "Content-Type": "application/json" 49 | } 50 | 51 | return true 52 | } 53 | 54 | // returns an object containing the custom rewards, or if an error, null 55 | const getCustomRewards = async () => { 56 | try { 57 | let { body } = await got(`https://api.twitch.tv/helix/channel_points/custom_rewards?broadcaster_id=${userId}`, { headers: headers }) 58 | return JSON.parse(body).data 59 | } catch (error) { 60 | console.log(error) 61 | return null 62 | } 63 | } 64 | 65 | // if the custom reward doesn't exist, creates it. returns true if successful, false if not 66 | const addCustomReward = async () => { 67 | try { 68 | let { body } = await got.post(`https://api.twitch.tv/helix/channel_points/custom_rewards?broadcaster_id=${userId}`, { 69 | headers: headers, 70 | body: JSON.stringify(customRewardBody), 71 | responseType: 'json', 72 | }) 73 | 74 | rewardId = body.data[0].id 75 | return true 76 | } catch (error) { 77 | console.log("Failed to add the reward. Please try again.") 78 | return false 79 | } 80 | } 81 | 82 | // function for polling every 15 seconds to check for user redemptions 83 | const pollForRedemptions = async () => { 84 | try { 85 | let { body } = await got(`https://api.twitch.tv/helix/channel_points/custom_rewards/redemptions?broadcaster_id=${userId}&reward_id=${rewardId}&status=UNFULFILLED`, { 86 | headers: headers, 87 | responseType: 'json', 88 | }) 89 | 90 | let redemptions = body.data 91 | let successfulRedemptions = [] 92 | let failedRedemptions = [] 93 | 94 | for (let redemption of redemptions) { 95 | // can't follow yourself :) 96 | if (redemption.broadcaster_id == redemption.user_id) { 97 | failedRedemptions.push(redemption.id) 98 | continue 99 | } 100 | // if failed, add to the failed redemptions 101 | if (await followUser(redemption.broadcaster_id, redemption.user_id) == false) { 102 | failedRedemptions.push(redemption.id) 103 | continue 104 | } 105 | // otherwise, add to the successful redemption list 106 | successfulRedemptions.push(redemption.id) 107 | } 108 | 109 | // do this in parallel 110 | await Promise.all([ 111 | fulfillRewards(successfulRedemptions, "FULFILLED"), 112 | fulfillRewards(failedRedemptions, "CANCELED") 113 | ]) 114 | 115 | console.log(`Processed ${successfulRedemptions.length + failedRedemptions.length} redemptions.`) 116 | 117 | // instead of an interval, we wait 15 seconds between completion and the next call 118 | pollingInterval = setTimeout(pollForRedemptions, 15 * 1000) 119 | } catch (error) { 120 | console.log("Unable to fetch redemptions.") 121 | } 122 | } 123 | 124 | // Follows from the user (fromUser) to another user (toUser). Returns true on success, false on failure 125 | const followUser = async (fromUser, toUser) => { 126 | try { 127 | await got.post(`https://api.twitch.tv/helix/users/follows?from_id=${fromUser}&to_id=${toUser}`, { headers: headers }) 128 | return true 129 | } catch (error) { 130 | console.log(`Unable to follow user ${toUser}`) 131 | return false 132 | } 133 | } 134 | 135 | const fulfillRewards = async (ids, status) => { 136 | // if empty, just cancel 137 | if (ids.length == 0) { 138 | return 139 | } 140 | 141 | // transforms the list of ids to ids=id for the API call 142 | ids = ids.map(v => `id=${v}`) 143 | 144 | try { 145 | await got.patch(`https://api.twitch.tv/helix/channel_points/custom_rewards/redemptions?broadcaster_id=${userId}&reward_id=${rewardId}&${ids.join("&")}`, { 146 | headers, 147 | json: { 148 | status: status 149 | } 150 | }) 151 | } catch (error) { 152 | console.log(error) 153 | } 154 | } 155 | 156 | // main function - sets up the reward and sets the interval for polling 157 | const main = async () => { 158 | if (await validateToken() == false) { 159 | return 160 | } 161 | 162 | let rewards = await getCustomRewards() 163 | if (rewards != null) { 164 | rewards.forEach(v => { 165 | // since the title is enforced as unique, it will be a good identifier to use to get the right ID on cold-boot 166 | if (v.title == customRewardBody.title) { 167 | rewardId = v.id 168 | } 169 | }) 170 | }else{ 171 | console.log("The streamer does not have access to Channel Points. They need to be a Twitch Affiliate or Partner."); 172 | } 173 | // if the reward isn't set up, add it 174 | if (rewardId == "" && await addCustomReward() == false) { 175 | return 176 | } 177 | 178 | pollForRedemptions() 179 | } 180 | 181 | // start the script 182 | main() 183 | --------------------------------------------------------------------------------