/**
* Copyright (C) 2025 "SilenusTA https://www.twitch.tv/olddepressedgamer"
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Buffer } from 'buffer';
import https from 'https';
import * as querystring from 'querystring';
const localConfig = {
SYSTEM_LOGGING_TAG: '[KickAPIs.js]',
maxChatMessageLength: 500,
retryCounter: 2,
attemptCounter: 0,
};
let callbacks = { updateRefreshTokenFn: null };
let Credentials = {};
// ============================================================================
// FUNCTION: init
// ============================================================================
/**
*
* @param {function} updateRefreshTokenFn
*/
function init (updateRefreshTokenFn)
{
callbacks.updateRefreshTokenFn = updateRefreshTokenFn;
}
// ============================================================================
// FUNCTION: setCredentials
// ============================================================================
/**
*
* @param {object} creds
*/
function setCredentials (creds)
{
Credentials = structuredClone(creds);
}
// ============================================================================
// FUNCTION: setCredentials
// ============================================================================
/**
*
* @param {boolean} forStreamer
* @param {string} accessToken
* @param {string} refreshToken
*/
function updateTokens (forStreamer, accessToken, refreshToken, expiry)
{
if (forStreamer)
{
Credentials.kickAccessToken = accessToken;
Credentials.kickRefreshToken = refreshToken;
Credentials.kickRefreshExpires = expiry
callbacks.updateRefreshTokenFn('kickAccessToken', accessToken);
callbacks.updateRefreshTokenFn('kickRefreshToken', refreshToken);
callbacks.updateRefreshTokenFn('kickRefreshExpires', expiry);
} else
{
Credentials.kickBotAccessToken = accessToken;
Credentials.kickBotRefreshToken = refreshToken;
Credentials.kickBotRefreshExpires = expiry
callbacks.updateRefreshTokenFn('kickBotAccessToken', accessToken);
callbacks.updateRefreshTokenFn('kickBotRefreshToken', refreshToken);
callbacks.updateRefreshTokenFn('kickBotRefreshExpires', expiry);
}
}
// ============================================================================
// FUNCTION: makeRequest
// ============================================================================
/**
*
* @param {object} param { hostname, path, method, headers, data }
* @returns Promise
*/
function makeRequest ({ hostname, path, method = 'GET', headers = {}, data = null })
{
return new Promise((resolve, reject) =>
{
const req = https.request({ hostname, path, method, headers }, (res) =>
{
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () =>
{
if (res.statusCode >= 200 && res.statusCode < 300)
{
try
{
resolve((body) ? JSON.parse(body) : {});
} catch (e)
{
reject({ type: 'parse_error', message: e.message, body });
}
} else if (res.statusCode === 401)
{
reject({ type: 'Unauthorized', statusCode: res.statusCode, body });
} else
{
reject({ type: 'error', statusCode: res.statusCode, body });
}
});
});
req.on('error', (err) => reject({ type: 'request_error', message: err }));
if (data) req.write(data);
req.end();
});
}
// ============================================================================
// FUNCTION: buildTokenPayload
// ============================================================================
/**
*
* @param {string} refreshToken
* @returns object
*/
function buildTokenPayload (refreshToken)
{
return querystring.stringify({
refresh_token: refreshToken,
client_id: Credentials.kickApplicationClientId,
client_secret: Credentials.kickApplicationSecret,
grant_type: 'refresh_token',
});
}
// ============================================================================
// FUNCTION: refreshToken
// ============================================================================
/**
*
* @param {boolean} forStreamer
*/
async function refreshToken (forStreamer = true)
{
const refreshToken = forStreamer ? Credentials.kickRefreshToken : Credentials.kickBotRefreshToken;
const refreshTokenExpires = forStreamer ? Credentials.kickRefreshExpires : Credentials.kickBotRefreshExpires;
const postData = buildTokenPayload(refreshToken);
try
{
const response = await makeRequest({
hostname: 'id.kick.com',
path: '/oauth/token',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
},
data: postData,
});
const { access_token, refresh_token, expires_in } = response;
if (!access_token) throw new Error('No access token in response');
updateTokens(forStreamer, access_token, refresh_token, Date.now() + (expires_in * 1000));
} catch (err)
{
try
{
if (err.body && JSON.parse(err.body)?.error === 'invalid_grant')
{
throw { type: 'invalid_grant', message: 'Reauthorization required', location: `refreshToken(${forStreamer})` };
}
throw { type: 'refresh_failed', message: 'Token refresh failed', details: err };
}
catch (parseErr)
{
throw { type: 'error', message: 'Error parsing Error Message', details: err };
}
}
}
// ============================================================================
// FUNCTION: withAuthRetry
// ============================================================================
/**
*
* @param {function} fn
* @param {boolean} forStreamer
* @returns object from function
*/
async function withAuthRetry (fn, forStreamer = true)
{
try
{
return await fn();
} catch (err)
{
if (err.type === 'Unauthorized')
{
await refreshToken(forStreamer);
return await fn();
}
throw err;
}
}
// ============================================================================
// FUNCTION: authorizedGet
// ============================================================================
/**
*
* @param {object} path
* @param {boolean} forStreamer
* @returns Promise
*/
function authorizedGet (path, forStreamer = true)
{
const token = forStreamer ? Credentials.kickAccessToken : Credentials.kickBotAccessToken;
return makeRequest({
hostname: 'api.kick.com',
path,
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
}
// ============================================================================
// FUNCTION: getUser
// ============================================================================
/**
*
* @param {boolean} forStreamer
* @returns Promise
*/
const getUser = (forStreamer = true) => withAuthRetry(() => authorizedGet('/public/v1/users', forStreamer), forStreamer);
// ============================================================================
// FUNCTION: getChannel
// ============================================================================
/**
*
* @returns Promise
*/
const getChannel = (userId = "") => withAuthRetry(() => authorizedGet(`/public/v1/channels?broadcaster_user_id=${userId}`));
// ============================================================================
// FUNCTION: getLivestream
// ============================================================================
/**
*
* @param {string} userId
* @returns Promise
*/
const getLivestream = (userId) => withAuthRetry(() =>
makeRequest({
hostname: 'api.kick.com',
path: `/public/v1/livestreams?broadcaster_user_id=${userId}`,
headers: { Authorization: `Bearer ${Credentials.kickAccessToken}`, Accept: '*/*' },
}));
// ============================================================================
// FUNCTION: sendChatMessage
// ============================================================================
/**
*
* @param {*} messageData {account, message, triggerActionRef}
* @returns Promise
*/
async function sendChatMessage (messageData)
{
if (!messageData.message || messageData.message == "")
return;
const token = messageData.account === 'user' ? Credentials.kickAccessToken : Credentials.kickBotAccessToken;
let message = messageData.message
.replace(/\p{Emoji_Presentation}/gu, '')
.replaceAll('olddepMarv ', '[emote:3626103:olddepressedgamerMarv]');// temp fix. need to get emojis working
// remove html
message = sanitiseHTML(message);
if (message.length > localConfig.maxChatMessageLength - 4)
message = message.slice(0, localConfig.maxChatMessageLength - 4) + "..."
const postData = JSON.stringify({ broadcaster_user_id: Credentials.userId, content: message, type: 'user' });
return withAuthRetry(() =>
makeRequest({
hostname: 'api.kick.com',
path: '/public/v1/chat',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'Content-Length': Buffer.byteLength(postData),
},
data: postData,
})
);
}
// ============================================================================
// FUNCTION: setTitleAndCategory
// ============================================================================
/**
*
* @param {string} title
* @param {number} categoryId
* @returns Promise
*/
async function setTitleAndCategory (title, categoryId)
{
const postData = JSON.stringify({ category_id: categoryId, stream_title: title });
return withAuthRetry(() =>
makeRequest({
hostname: 'api.kick.com',
path: '/public/v1/channels',
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${Credentials.kickAccessToken}`,
'Content-Length': Buffer.byteLength(postData),
},
data: postData,
})
);
}
// ============================================================================
// FUNCTION: searchCategories
// ============================================================================
/**
*
* @param {string} categoryName
* @returns Promise
*/
async function searchCategories (categoryName)
{
return withAuthRetry(() =>
authorizedGet(`/public/v1/categories?q=${encodeURIComponent(categoryName)}`)
);
}
// ============================================================================
// FUNCTION: getCategory
// ============================================================================
/**
*
* @param {string} categoryId
* @returns Promise
*/
async function getCategory (categoryId)
{
return withAuthRetry(() =>
authorizedGet(`/public/v1/categories/${categoryId}`)
);
}
// ============================================================================
// FUNCTION: getChannelData
// ============================================================================
/**
*
* @param {string} username
* @returns Promise
*/
async function getChannelData (username)
{
return makeRequest({
hostname: 'api.stream-stuff.com',
path: `/kick/channels/${username}`,
headers: { Accept: 'application/json' },
});
}
// #######################################################################
// ######################### sanitiseHTML ################################
// #######################################################################
/**
* Removes html chars from a string to avoid chat message html injection
* @param {string} string
* @returns {string} the parsed string
*/
function sanitiseHTML (string)
{
// sanitiser
var entityMap = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'/': '/',
'`': '`',
'=': '='
};
return String(string).replace(/[&<>"'`=\/]/g, function (s)
{
return entityMap[s];
});
}
export { getChannel, getChannelData, getLivestream, getUser, init, getCategory, searchCategories, sendChatMessage, setCredentials, setTitleAndCategory };