// Copyright 2018-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const fs = require('fs');
const auth = require('../auth.js');
const patreon = require('patreon');
const https = require('https');
require('./subModule.js').extend(Patreon); // Extends the SubModule class.
/**
* @classdesc Modifies the {@link SpikeyBot} object with an interface for
* checking the Patreon status of users.
* @class
* @augments SubModule
* @listens Command#patreon
*/
function Patreon() {
const self = this;
/** @inheritdoc */
this.myName = 'Patreon';
/** @inheritdoc */
this.helpMessage = null;
/**
* Filename of the file that will store the creator token information for
* fetching campaign information.
*
* @private
* @default
* @constant
* @type {string}
*/
const tokenFile = './save/patreonCreatorTokens.json';
/**
* Cached token values stored in {@link tokenFile}.
*
* @private
* @default
* @constant
* @type {{
* access_token: string,
* refresh_token: string,
* expires_at: number,
* scope: string,
* token_type: string
* }}
*/
const tokenData = {};
/**
* Cached campaign information that was fetched from Patreon previously.
*
* @private
* @default
* @constant
* @type {{
* timestamp: number,
* data: object
* }}
*/
const campaignInfo = {};
/**
* The amount of time the {@link campaignInfo} will be cached for before
* re-fetching from Patreon.
*
* @private
* @default 3 Hours
* @constant
* @type {number}
*/
const campaignCacheTime = 3 * 60 * 60 * 1000;
/**
* The filename in the user's directory of the file where the settings related
* to Patreon rewards are stored.
*
* @private
* @constant
* @default
* @type {string}
*/
const patreonSettingsFilename = '/patreonSettings.json';
/**
* Path to the file storing information about each patron tier rewards.
*
* @private
* @constant
* @default
* @type {string}
*/
const patreonTierPermFile = './save/patreonTiers.json';
/**
* The parsed data from file about patron tier rewards.
*
* @see {@link Patreon~patreonTierPermFile}
*
* @private
* @default
* @type {Array.<{0: number, 1: string[]}>}
*/
let patreonTiers = {};
/**
* File where the template for the Patreon settings is stored.
*
* @see {@link Patreon~patreonSettingsTemplate}
* @see {@link WebAccount~patreonSettingsTemplate}
*
* @private
* @constant
* @default
* @type {string}
*/
const patreonSettingsTemplateFile = './save/patreonSettingTemplate.json';
/**
* The parsed data from {@link Patreon~patreonSettingsTemplateFile}. Data
* that outlines the available options that can be changed, and their possible
* values.
*
* @private
*
* @default
* @type {object.<object>}
*/
let patreonSettingsTemplate = {};
/**
* Parse tiers from file.
*
* @see {@link Patreon~patreonTierPermFile}
* @private
*/
function updateTierPerms() {
fs.readFile(patreonTierPermFile, (err, data) => {
if (err) {
self.error('Failed to read ' + patreonTierPermFile);
return;
}
try {
const parsed = JSON.parse(data);
if (!parsed) return;
patreonTiers = Object.entries(parsed);
} catch (e) {
console.error(e);
}
});
}
updateTierPerms();
fs.watchFile(patreonTierPermFile, {persistent: false}, (curr, prev) => {
if (curr.mtime == prev.mtime) return;
if (self.initialized) {
self.debug('Re-reading Patreon tier reward information from file');
} else {
console.log('Patreon: Re-reading tier reward information from file');
}
updateTierPerms();
});
/**
* Parse template from file.
*
* @see {@link Patreon~patreonSettingsTemplate}
* @private
*/
function updatePatreonSettingsTemplate() {
fs.readFile(patreonSettingsTemplateFile, (err, data) => {
if (err) {
self.error('Failed to read ' + patreonSettingsTemplateFile);
return;
}
try {
const parsed = JSON.parse(data);
if (!parsed) return;
patreonSettingsTemplate = parsed;
} catch (e) {
self.error('Failed to parse ' + patreonSettingsTemplateFile);
console.error(e);
}
});
}
updatePatreonSettingsTemplate();
fs.watchFile(
patreonSettingsTemplateFile, {persistent: false}, (curr, prev) => {
if (curr.mtime == prev.mtime) return;
if (self.initialized) {
self.debug(
'Re-reading Patreon setting template information from file');
} else {
console.log(
'Patreon: Re-reading setting template information from file');
}
updatePatreonSettingsTemplate();
});
/** @inheritdoc */
this.initialize = function() {
self.bot.patreon = toExport;
self.command.on('patreon', commandPatreon);
self.common.connectSQL();
};
/** @inheritdoc */
this.shutdown = function() {
self.command.deleteEvent('patreon');
fs.unwatchFile(patreonTierPermFile);
fs.unwatchFile(patreonSettingsTemplateFile);
};
fs.readFile(tokenFile, (err, data) => {
if (err) {
self.error(
'Failed to read Patreon API token information from file: ' +
tokenFile);
console.error(err);
return;
}
try {
const parsed = JSON.parse(data);
Object.assign(tokenData, parsed);
} catch (err) {
self.error('Failed to parse Patroen API tokens from file: ' + tokenFile);
console.error(err);
}
});
/**
* Shows the user's Patreon information to the user.
*
* @private
* @type {commandHandler}
* @param {Discord~Message} msg Message that triggered command.
* @listens Command#patreon
*/
function commandPatreon(msg) {
/**
* Verifies that valid data was found, then fetches all permissions for the
* user's pledge amount.
*
* @private
* @type {Patreon~basicCB}
* @param {?string} err The error string, or null if no error.
* @param {?{status: object, message: string}} data The returned data if
* there was no error.
*/
function getPerms(err, data) {
if (err) {
if (err.startsWith('User has not connected')) {
self.common.reply(
msg,
'If you love SpikeyBot and wish to support SpikeyRobot, please ' +
'consider becoming a patron on Patreon!\npatreon.com/Campbe' +
'llCrowley\n\nIf you have already pledged, be sure to link ' +
'your accounts in order to receive your rewards.\nspikeybot' +
'.com/account',
'https://www.patreon.com/campbellcrowley\nhttps://www.spikeybot' +
'.com/account/');
} else if (err.startsWith('User has never pledged')) {
self.common.reply(
msg,
'You currently have not pledged anything on Patreon.\nIf you lo' +
've SpikeyBot, or wish to receive the perks of becoming a p' +
'atron, please consider supporting SpikeyRobot on Patreon.',
'https://www.patreon.com/campbellcrowley');
return;
} else {
self.common.reply(
msg, 'Oops! Something went wrong while fetching your Patreon ' +
'information!',
err);
}
return;
}
const pledgeAmount = data.status.pledge;
if (!pledgeAmount || isNaN(Number(pledgeAmount))) {
self.common.reply(
msg,
'You currently have not pledged anything on Patreon.\nIf you ' +
'love SpikeyBot, or wish to receive the perks of becoming a ' +
'patron, please consider supporting SpikeyRobot on Patreon.',
'https://www.patreon.com/campbellcrowley');
return;
}
toExport.getLevelPerms(pledgeAmount, false, onGetPerms);
}
/**
* Verifies that valid data was found, then fetches all permissions for the
* user's pledge amount.
*
* @private
* @type {Patreon~basicCB}
* @param {?string} err The error string, or null if no error.
* @param {?{status: string[], message: string}} data The returned data if
* there was no error.
*/
function onGetPerms(err, data) {
if (err) {
self.common.reply(
msg,
'Oops! Something went wrong while fetching reward information!',
err);
return;
}
const permString = data.status.join(', ');
self.common.reply(
msg, 'Thank you for supporting SpikeyRobot!\n' +
'Here are your current rewards: ' + permString,
'https://www.patreon.com/campbellcrowley');
}
fetchPatreonRow(msg.author.id, getPerms);
}
/**
* Basic callback function that has two parameters. One with error
* information, and the other with data if there was no error.
*
* @callback Patreon~basicCB
* @param {?string} err The error string, or null if no error.
* @param {?{status: *, message: string}} data The returned data if there was
* no error.
*/
/**
* @classdesc The object to put into the {@link SpikeyBot} object. This
* contains all of the public data available through that interface. Data will
* be available after {@link Patreon.initialize} has been called, at
* `SpikeyBot.patreon`.
* @class
*/
function toExport() {}
/**
* Check that a user or channel or guild has permission for something. Checks
* overrides for each, and if the user does not have an override, the request
* is forwarded to {@link toExport.checkPerm}.
*
* @public
*
* @param {?string|number} uId The Discord user ID to check.
* @param {?string|number} cId The Discord channel ID to check.
* @param {?string|number} gId The Discord guild ID to check.
* @param {?string} perm The permission string to check against. Null to check
* for overrides only.
* @param {Patreon~basicCB} cb Callback with parameters for error and success
* values.
* @param {boolean} cb.data.status If the given IDs have permission.
*/
toExport.checkAllPerms = function(uId, cId, gId, perm, cb) {
switch (gId) {
case '318603252675379210': // Games
cb(null, {status: true, message: 'Guild has override.'});
return;
}
switch (cId) {
case '420045412679024660': // #bottesting
cb(null, {status: true, message: 'Channel has override.'});
return;
}
switch (uId) {
case self.common.spikeyId:
case '126464376059330562': // Rohan
cb(null, {status: true, message: 'User has override.'});
return;
}
if (uId && perm) {
toExport.checkPerm(uId, perm, cb);
} else {
cb(null, {status: false, message: 'User does not have permission.'});
}
};
/**
* Fetch all permissions for a given user, channel, or guild.
*
* @public
* @param {?string|number} uId The ID of the Discord user.
* @param {?string|number} cId The Discord channel ID.
* @param {?string|number} gId The Discord guild ID.
* @param {Patreon~basicCB} cb Callback once operation is complete.
*/
toExport.getAllPerms = function(uId, cId, gId, cb) {
toExport.checkAllPerms(uId, cId, gId, null, onGetOverrides);
/**
* Handle response from checking IDs for overrides.
*
* @private
* @type {Patreon~basicCB}
* @param {?string} err The error string, or null if no error.
* @param {?{status: boolean, message: string}} info The returned data if
* there was no error.
*/
function onGetOverrides(err, info) {
if (info.status) {
getPerms(
null,
{status: {pledge: Number.MAX_SAFE_INTEGER}, message: info.message});
} else {
fetchPatreonRow(uId, getPerms);
}
}
/**
* Verifies that valid data was found, then fetches all permissions for the
* user's pledge amount.
*
* @private
* @type {Patreon~basicCB}
* @param {?string} err The error string, or null if no error.
* @param {?{status: object, message: string}} data The returned data if
* there was no error.
*/
function getPerms(err, data) {
if (err) {
cb(err, null);
return;
}
const pledgeAmount = data.status.pledge;
if (!pledgeAmount || isNaN(Number(pledgeAmount))) {
cb('User is not pledged', null);
return;
}
toExport.getLevelPerms(pledgeAmount, false, onGetPerms);
}
/**
* Verifies that valid data was found, then fetches all permissions for the
* user's pledge amount.
*
* @private
* @type {Patreon~basicCB}
* @param {?string} err The error string, or null if no error.
* @param {?{status: string[], message: string}} data The returned data if
* there was no error.
*/
function onGetPerms(err, data) {
if (err) {
cb(err, null);
return;
}
cb(null, data);
}
};
/**
* Check that a user has a specific permission. Permissions are defined in
* {@link Patreon~patreonTierPermFile}. This does not check overrides.
*
* @public
*
* @param {string|number} uId The Discord user ID to check.
* @param {string} perm The permission string to check against.
* @param {Patreon~basicCB} cb Callback with parameters for error and success
* values.
* @param {boolean} cb.data.status If the user has permission.
*/
toExport.checkPerm = function(uId, perm, cb) {
/**
* Checks the received data from the Patreon table against the given perm
* string.
*
* @private
* @type {Patreon~basicCB}
* @param {?string} err The error string, or null if no error.
* @param {?{status: object, message: string}} data The returned data if
* there was no error.
*/
function checkPerm(err, data) {
if (err) {
cb(err, data);
return;
}
const pledgeAmount = data.status.pledge;
if (!pledgeAmount || isNaN(Number(pledgeAmount))) {
cb(null, {status: false, message: 'User is not currently pledged.'});
return;
}
if (!perm) {
cb(null, {status: true, message: 'User is patron.'});
return;
}
for (let i = 0; i < patreonTiers.length; i++) {
for (let j = 0; j < patreonTiers[i][1].length; j++) {
if (patreonTiers[i][1][j] == perm) {
if (patreonTiers[i][0] <= pledgeAmount) {
cb(null, {status: true, message: 'User has permission.'});
return;
}
}
}
}
cb(null, {status: false, message: 'User does not have permission.'});
}
fetchPatreonRow(uId, checkPerm);
};
/**
* Responds with all permissions available at the given pledge amount.
*
* @public
*
* @param {number} pledgeAmount The amount in cents that the user has pledged.
* @param {boolean} exclusive Only get the rewards received at the exact
* pledge amount. Does not show all tier rewards below the pledge amount.
* @param {Patreon~basicCB} cb Callback with parameters for error and success
* values.
* @param {string[]} cb.data.status All of the permission strings.
*/
toExport.getLevelPerms = function(pledgeAmount, exclusive, cb) {
let output = [];
for (let i = 0; i < patreonTiers.length; i++) {
if (patreonTiers[i][0] <= pledgeAmount) {
if (exclusive && patreonTiers[i][0] != pledgeAmount) continue;
output = output.concat(patreonTiers[i][1]);
}
}
cb(null, {status: output, message: 'Success'});
};
/**
* Responds with the settings value for a user if they have permission for the
* setting, otherwise replies with the default value.
*
* @public
*
* @param {?number|string} uId The user id to check, or null to get the
* default value.
* @param {?number|string} cId The Discord channel id to check, or null to get
* the default value.
* @param {?number|string} gId The Discord guild id to check, or null to get
* the default value.
* @param {string} permString The permission to check with subvalues separated
* by spaces.
* @param {Patreon~basicCB} cb Callback with parameters for error and success
* values.
* @param {*} cb.data.status The setting's value.
*/
toExport.getSettingValue = function(uId, cId, gId, permString, cb) {
const permVals = permString.split(' ');
const perm = permVals[0];
if (!patreonSettingsTemplate[perm]) {
cb('Invalid Permission', null);
return;
}
toExport.checkAllPerms(uId, cId, gId, perm, onCheckPerm);
/**
* After check for user perms, this will fetch either the default value, or
* the user's custom setting.
*
* @private
* @type {Patreon~basicCB}
* @param {?string} err The error string, or null if no error.
* @param {?{status: boolean, message: string}} info The returned data if
* there was no error.
*/
function onCheckPerm(err, info) {
if (err || !info.status) {
fetchValue(patreonSettingsTemplate, permVals.concat(['default']), cb);
} else {
self.common.readAndParse(
`${self.common.userSaveDir}${uId}${patreonSettingsFilename}`,
(err, parsed) => {
fetchValue(parsed || {}, permVals, onFetchedValue);
});
}
}
/**
* Searches an object for the given key values.
*
* @private
* @param {object} obj The object to traverse.
* @param {string[]} keys The keys to step through.
* @param {Patreon~basicCB} myCb The callback with the final value.
*/
function fetchValue(obj, keys, myCb) {
if (keys.length == 1) {
myCb(null, {status: obj[keys[0]], message: 'Success'});
return;
} else if (typeof obj[keys[0]] === 'undefined') {
myCb('Invalid Setting: ' + keys[1], null);
return;
} else {
fetchValue(obj[keys[0]], keys.slice(1), myCb);
}
}
/**
* After a user's setting value has been fetched, check if it has been
* set, if not then return the default.
*
* @private
* @type {Patreon~basicCB}
* @param {?string} err The error string, or null if no error.
* @param {?{status: *, message: string}} info The returned data if
* there was no error.
*/
function onFetchedValue(err, info) {
if (err || typeof info.status === 'undefined') {
onCheckPerm(null, {status: null, message: 'User value unset'});
} else {
cb(null, info);
}
}
};
/**
* Get the Patreon information for a given Discord user.
*
* @private
* @param {string|number} uId The Discord user ID to check.
* @param {Patreon~basicCB} cb Callback with parameters for error and success
* values.
* @param {?object} cb.data.status A single row if it was found.
*/
function fetchPatreonRow(uId, cb) {
/**
* SQL query response callback for request to the Discord table.
*
* @private
* @param {Error} err Errors during the query.
* @param {Array} rows The results of the query.
*/
function receivedDiscordRow(err, rows) {
if (err) {
/* self.error('Failed to lookup user in Discord: ' + uId);
console.error(err); */
cb('Failed to find user in database.', null);
return;
}
if (!rows || rows.length != 1) {
cb('User has not connected their Patreon ' +
'account to their Discord account.',
null);
return;
}
const user = rows[0];
if (!user.patreonId) {
cb('User has not connected their Patreon ' +
'account to their Discord account.',
null);
return;
}
const toSend = global.sqlCon.format(
'SELECT * FROM Patreon WHERE id=? LIMIT 1', [user.patreonId]);
global.sqlCon.query(toSend, receivedPatreonRow);
}
/**
* SQL query response callback for request to the Patreon table.
*
* @private
* @param {Error} err Errors during the query.
* @param {Array} rows The results of the query.
*/
function receivedPatreonRow(err, rows) {
if (err) {
self.error('Failed to lookup user in Patreon: ' + uId);
console.error(err);
cb('Failed to find user in database.', null);
return;
}
if (!rows || rows.length != 1) {
cb('User has never pledged.', null);
return;
}
cb(null, {status: rows[0], message: 'Success'});
}
const toSend =
global.sqlCon.format('SELECT * FROM Discord WHERE id=? LIMIT 1', [uId]);
global.sqlCon.query(toSend, receivedDiscordRow);
}
/**
* Fetch the campaign information for ourself.
*
* @public
* @param {Patreon~basicCB} cb Callback with parameters for error and success
* values.
*/
toExport.fetchCampaign = function(cb) {
const now = Date.now();
if (now - campaignInfo.timestamp <= campaignCacheTime) {
cb(null, {status: campaignInfo.data, message: 'Success'});
return;
}
campaignInfo.timestamp = now;
fetchAccessToken((err, res) => {
if (err) {
cb(err);
return;
}
const accessToken = res.status;
const patreonAPIClient = patreon.patreon(accessToken);
patreonAPIClient('/current_user/campaigns?includes=goals')
.then((data) => {
// console.log('Data:', data);
// const store = data.store;
// const user = store.findAll('user').map((user) =>
// user.serialize());
// console.log('user is', user);
// const campaign = store.findAll('campaign')
// .map((campaign) => campaign.serialize());
const serializable = data.rawJson;
campaignInfo.data = serializable;
toExport.fetchCampaign(cb);
})
.catch((err) => {
console.error('error!', err);
cb(err);
});
});
};
/**
* Get the current access token for making a request on our behalf. If the
* token has expired, it will first be refreshed.
*
* @private
* @param {Patreon~basicCB} cb Callback with parameters for error and success
* values.
*/
function fetchAccessToken(cb) {
if (!tokenData || !tokenData.refresh_token || !tokenData.access_token) {
cb('No Patreon API tokens');
return;
}
const now = Date.now();
if (now - tokenData.expires_at < 0) {
cb(null, {status: tokenData.access_token, message: 'Success'});
return;
}
const host = {
protocol: 'https:',
host: 'www.patreon.com',
path: '/api/oauth2/token?grant_type=refresh_token&refresh_token=' +
tokenData.refresh_token + '&client_id=' + auth.patreonClientId +
'&client_secret=' + auth.patreonClientSecret,
method: 'POST',
headers: {
'User-Agent': require('./common.js').ua,
},
};
const req = https.request(host, (res) => {
let content = '';
res.on('data', (chunk) => content += chunk);
res.on('end', () => {
if (res.statusCode == 200) {
let parsed;
try {
parsed = JSON.parse(content);
} catch (err) {
self.error('Failed to parse response from Patreon!');
console.error(err);
cb(err);
return;
}
Object.assign(tokenData, parsed);
tokenData.expires_at = now + (tokenData.expires_in * 1000);
self.common.mkAndWrite(
tokenFile, null, JSON.stringify(tokenData), (err) => {
if (!err) return;
self.error(`Failed to save token data to file: ${tokenFile}`);
console.error(err, tokenData);
});
fetchAccessToken(cb);
} else {
self.common.error(content);
cb('Failed to refresh access_token');
return;
}
});
});
req.end();
}
}
module.exports = new Patreon();