diff --git a/src/js/components/splatoon/SalmonRunBox.vue b/src/js/components/splatoon/SalmonRunBox.vue index a35d522..892911d 100644 --- a/src/js/components/splatoon/SalmonRunBox.vue +++ b/src/js/components/splatoon/SalmonRunBox.vue @@ -59,7 +59,7 @@ -
+
Soon diff --git a/src/updater/twitter.js b/src/updater/twitter.js index 8d48169..a919a0f 100644 --- a/src/updater/twitter.js +++ b/src/updater/twitter.js @@ -1,82 +1,16 @@ require('./bootstrap'); -const screenshots = require('./screenshots'); -const Twitter = require('twitter'); -const path = require('path'); -const fs = require('fs'); +const { canTweet } = require('./twitter/client'); +const ScheduleTweet = require('./twitter/ScheduleTweet'); +const GearTweet = require('./twitter/GearTweet'); +const SalmonRunTweet = require('./twitter/SalmonRunTweet'); +const NewWeaponTweet = require('./twitter/NewWeaponTweet'); -const dataPath = path.resolve('public/data'); -const lastTweetTimesPath = path.resolve('twitter-lastTweetTimes.json'); - -// Twitter API parameters -const consumer_key = process.env.TWITTER_CONSUMER_KEY; -const consumer_secret = process.env.TWITTER_CONSUMER_SECRET; -const access_token_key = process.env.TWITTER_ACCESS_TOKEN_KEY; -const access_token_secret = process.env.TWITTER_ACCESS_TOKEN_SECRET; - -// Twitter API client -const client = new Twitter({ consumer_key, consumer_secret, access_token_key, access_token_secret }); - -/** - * Utilities - */ - -function canTweet() { - return consumer_key && consumer_secret && access_token_key && access_token_secret; -} - -function getTopOfCurrentHour() { - let date = new Date; - date.setUTCMinutes(0); - date.setUTCSeconds(0); - return Math.floor(date.getTime() / 1000); -} - -function getLastTweetTimes() { - if (fs.existsSync(lastTweetTimesPath)) - return JSON.parse(fs.readFileSync(lastTweetTimesPath)); - return {}; -} - -function getLastTweetTime(key) { - return getLastTweetTimes()[key] || 0; -} - -function shouldTweet(key, time) { - return getLastTweetTime(key) < time; -} - -function updateLastTweetTime(key, time) { - let lastTweetTimes = getLastTweetTimes(); - lastTweetTimes[key] = time; - - fs.writeFileSync(lastTweetTimesPath, JSON.stringify(lastTweetTimes)); -} - -function postMediaTweet(imageData, statusText) { - return new Promise((resolve, reject) => { - // Upload the image - client.post('media/upload', { media: imageData }, (error, media, response) => { - if (error) - return reject(error); - - let status = { - status: statusText, - media_ids: media.media_id_string, - }; - - client.post('statuses/update', status, (error, tweet, response) => { - if (error) - return reject(error); - - resolve(tweet); - }); - }); - }); -} - -/** - * Twitter posts - */ +let tweets = [ + new ScheduleTweet, + new GearTweet, + new SalmonRunTweet, + new NewWeaponTweet, +]; async function maybePostTweets() { if (!canTweet()) { @@ -84,301 +18,18 @@ async function maybePostTweets() { return; } - await maybePostScheduleTweet(); - await maybePostGearTweet(); - await maybePostSalmonRunTweet(); - await maybePostNewWeaponTweet(); + for (let tweet of tweets) + await tweet.maybePostTweet(); } async function testScreenshots() { - await testScheduleScreenshot(); - await testGearScreenshot(); - await testSalmonRunScreenshot(); - await testNewWeaponScreenshot(); -} - -/** - * Main schedule tweets - */ - -async function maybePostScheduleTweet() { - const key = 'schedule'; - - // What time are we posting the schedule for? - let time = getTopOfCurrentHour(); - - // Have we already posted for this time? - if (!shouldTweet(key, time)) { - console.info('Twitter: Schedule: Already posted for this hour'); - return; - } - - // Get the schedules - let schedulesPath = `${dataPath}/schedules.json`; - if (!fs.existsSync(schedulesPath)) { - console.warn('Twitter: Schedule: schedules.json does not exist'); - return; - } - let schedules = JSON.parse(fs.readFileSync(schedulesPath)); - - // Do we have a schedule for the specified time? - if (!schedules.regular.find(s => s.start_time == time)) { - console.info('Twitter: Schedule: No schedule for this hour'); - return; - } - - // Everything looks good, let's post a tweet - await postScheduleTweet(time); - console.info('Twitter: Schedule: Posted latest map schedule'); - - // Update the last tweet time - updateLastTweetTime(key, time); -} - -async function testScheduleScreenshot() { - let schedulesPath = `${dataPath}/schedules.json`; - let schedules = JSON.parse(fs.readFileSync(schedulesPath)); - let imageData = await screenshots.captureScheduleScreenshot(schedules.regular[0].start_time); - fs.writeFileSync('test-screenshot-schedules.png', imageData); - console.info('Saved schedule screenshot'); -} - -async function postScheduleTweet(startTime) { - // Generate the image - let imageData = await screenshots.captureScheduleScreenshot(startTime); - - // Post the tweet - return await postMediaTweet(imageData, 'Current Splatoon 2 map rotation'); -} - -/** - * Gear tweets - */ - -function getMerchandises() { - let merchandisesPath = `${dataPath}/merchandises.json`; - if (!fs.existsSync(merchandisesPath)) { - console.warn('Twitter: Gear: merchandises.json does not exist'); - return []; - } - return JSON.parse(fs.readFileSync(merchandisesPath)).merchandises; -} - -async function maybePostGearTweet() { - const key = 'gear'; - - // What time are we posting the schedule for? - let time = getTopOfCurrentHour(); - - // Make sure we have merchandises - let merchandises = getMerchandises(); - if (!merchandises.length) { - console.info('Twitter: Gear: No merchandises'); - return; - } - - // This one is a little different: we only have end_times for merchandise items, so we - // need to track the latest end_time we've posted - let endTimes = merchandises.map(m => m.end_time); - let lastEndTime = Math.max(...endTimes); - - // Have we already posted for this time? - if (!shouldTweet(key, lastEndTime)) { - console.info('Twitter: Gear: Already posted for this hour'); - return; - } - - // Everything looks good, let's post a tweet - await postGearTweet(time, lastEndTime); - console.info('Twitter: Gear: Posted latest SplatNet gear'); - - // Update the last tweet time - updateLastTweetTime(key, lastEndTime); -} - -async function testGearScreenshot() { - let merchandises = getMerchandises(); - let imageData = await screenshots.captureGearScreenshot(getTopOfCurrentHour(), merchandises[merchandises.length - 1].end_time); - fs.writeFileSync('test-screenshot-gear.png', imageData); - console.info('Saved gear screenshot'); -} - -async function postGearTweet(startTime, endTime) { - // Generate the image - let imageData = await screenshots.captureGearScreenshot(startTime, endTime); - - // Generate the text - let gearText; - let merchandise = getMerchandises().find(g => g.end_time == endTime); - if (!merchandise) - return; - gearText = `Up now on SplatNet: ${merchandise.gear.name} with ${merchandise.skill.name} #splatnet2`; - - // Post the tweet - return await postMediaTweet(imageData, gearText); -} - -/** - * Salmon Run tweets - */ - -function getSalmonRunSchedules() { - let timelinePath = `${dataPath}/timeline.json`; - let calendarPath = `${dataPath}/salmonruncalendar.json`; - let schedules = {}; - - if (fs.existsSync(timelinePath)) { - let timeline = JSON.parse(fs.readFileSync(timelinePath)).coop; - if (timeline) - schedules[timeline.start_time] = timeline; - } - - if (fs.existsSync(calendarPath)) { - let calendar = JSON.parse(fs.readFileSync(calendarPath)).schedules; - if (calendar) { - for (schedule of calendar) - schedules[schedule.start_time] = schedule; - } - } - - return Object.values(schedules); -} - -async function maybePostSalmonRunTweet() { - const key = 'salmonrun'; - - // What time are we posting the schedule for? - let time = getTopOfCurrentHour(); - - // Have we already posted for this time? - if (!shouldTweet(key, time)) { - console.info('Twitter: Salmon Run: Already posted for this hour'); - return; - } - - // Get the schedules - let schedules = getSalmonRunSchedules(); - if (!schedules.length) { - console.warn('Twitter: Salmon Run: no schedules found'); - return; - } - - // Do we have a schedule for the specified time? - if (!schedules.find(s => s.start_time == time)) { - console.info('Twitter: Salmon Run: No schedule for this hour'); - return; - } - - // Everything looks good, let's post a tweet - await postSalmonRunTweet(time); - console.info('Twitter: Salmon Run: Posted Salmon Run schedule'); - - // Update the last tweet time - updateLastTweetTime(key, time); -} - -async function testSalmonRunScreenshot() { - let schedules = getSalmonRunSchedules(); - let imageData = await screenshots.captureSalmonRunScreenshot(schedules[0].start_time); - fs.writeFileSync('test-screenshot-salmonrun.png', imageData); - console.info('Saved Salmon Run screenshot'); -} - -async function postSalmonRunTweet(startTime) { - // Generate the image - let imageData = await screenshots.captureSalmonRunScreenshot(startTime); - - // Post the tweet - return await postMediaTweet(imageData, 'Salmon Run is now open! #salmonrun'); -} - -/** - * New Weapon tweets - */ - -function getNewWeaponAvailabilities(releaseTime = null) { - let timelinePath = `${dataPath}/timeline.json`; - if (!fs.existsSync(timelinePath)) { - console.warn('Twitter: Gear: timeline.json does not exist'); - return; - } - let weaponAvailability = JSON.parse(fs.readFileSync(timelinePath)).weapon_availability; - if (weaponAvailability && weaponAvailability.availabilities) { - if (releaseTime === null) - return weaponAvailability.availabilities; - - return weaponAvailability.availabilities.filter(a => a.release_time == releaseTime); - } -} - -async function maybePostNewWeaponTweet() { - const key = 'weapon'; - - // What time are we posting the schedule for? - let time = getTopOfCurrentHour(); - - // Do we have a weapon? - let weaponAvailabilities = getNewWeaponAvailabilities(time); - if (!weaponAvailabilities || weaponAvailabilities.length == 0) { - console.info('Twitter: No new weapon for this time'); - return; - } - - // Have we already posted for this time? - if (!shouldTweet(key, time)) { - console.info('Twitter: Weapons: Already posted for this hour'); - return; - } - - // Everything looks good, let's post a tweet - await postNewWeaponTweet(time); - console.info('Twitter: Weapons: Posted new weapon'); - - // Update the last tweet time - updateLastTweetTime(key, time); -} - -async function testNewWeaponScreenshot() { - let availabilities = getNewWeaponAvailabilities(); - let availability = availabilities[0]; - if (!availability) { - console.info('No new weapon'); - return; - } - - let imageData = await screenshots.captureNewWeaponScreenshot(availability.release_time); - fs.writeFileSync('test-screenshot-weapon.png', imageData); - console.info('Saved new weapon screenshot'); -} - -async function postNewWeaponTweet(releaseTime) { - // Generate the image - let imageData = await screenshots.captureNewWeaponScreenshot(releaseTime); - - // Generate the text - let availabilities = getNewWeaponAvailabilities(releaseTime); - let tweetText; - if (availabilities.length == 1) { - tweetText = `New weapon, now available: ${availabilities[0].weapon.name} #splatoon2`; - } - else { - tweetText = `New weapons, now available:`; - for (availability of availabilities) - tweetText += `\n- ${availability.weapon.name}`; - tweetText += '\n#splatoon2'; - } - - // Post the tweet - return await postMediaTweet(imageData, tweetText); + for (let tweet of tweets) + await tweet.saveTestScreenshot(); } module.exports = { maybePostTweets, testScreenshots, - postScheduleTweet, - postGearTweet, - postSalmonRunTweet, - postNewWeaponTweet, } require('make-runnable/custom')({ printOutputFrame: false }); diff --git a/src/updater/twitter/GearTweet.js b/src/updater/twitter/GearTweet.js new file mode 100644 index 0000000..399a0f9 --- /dev/null +++ b/src/updater/twitter/GearTweet.js @@ -0,0 +1,40 @@ +const TwitterPostBase = require('./TwitterPostBase'); +const { captureGearScreenshot } = require('../screenshots'); +const { readData, getTopOfCurrentHour } = require('./utilities'); + +class GearTweet extends TwitterPostBase { + getKey() { return 'gear'; } + getName() { return 'Gear'; } + + getMerchandises() { + return readData('merchandises.json').merchandises; + } + + getDataTime() { + // We only have end_times for merchandise items, so we need to track the latest end_time + let endTimes = this.getMerchandises().map(m => m.end_time); + let lastEndTime = Math.max(...endTimes); + return lastEndTime; + } + + getData() { + return this.getMerchandises().find(m => m.end_time == this.getDataTime()); + } + + getTestData() { + let merchandises = this.getMerchandises(); + return merchandises[merchandises.length - 1]; + } + + getImage(data) { + let startTime = getTopOfCurrentHour(); + let endTime = this.getDataTime(); + return captureGearScreenshot(startTime, endTime); + } + + getText(data) { + return `Up now on SplatNet: ${data.gear.name} with ${data.skill.name} #splatnet2`; + } +} + +module.exports = GearTweet; diff --git a/src/updater/twitter/NewWeaponTweet.js b/src/updater/twitter/NewWeaponTweet.js new file mode 100644 index 0000000..9d1d45e --- /dev/null +++ b/src/updater/twitter/NewWeaponTweet.js @@ -0,0 +1,50 @@ +const TwitterPostBase = require('./TwitterPostBase'); +const { captureNewWeaponScreenshot } = require('../screenshots'); +const { readData } = require('./utilities'); + +class NewWeaponTweet extends TwitterPostBase { + getKey() { return 'weapon'; } + getName() { return 'New Weapon'; } + + getNewWeaponAvailabilities() { + let weaponAvailability = readData('timeline.json').weapon_availability; + if (weaponAvailability) + return weaponAvailability.availabilities; + return []; + } + + getData() { + let availabilities = this.getNewWeaponAvailabilities().filter(a => a.release_time == this.getDataTime()); + + // Only return the array if it contains availabilities. + // Otherwise return false (i.e., there's no data, so don't post a Tweet). + if (!availabilities.length) + return false; + + return availabilities; + } + + getTestData() { + let availabilities = this.getNewWeaponAvailabilities() + if (availabilities.length) + return availabilities; + } + + getImage(data) { + return captureNewWeaponScreenshot(data[0].release_time); + } + + getText(data) { + if (data.length == 1) + return `New weapon now available: ${data[0].weapon.name} #splatoon2`; + + let text = `New weapons now available:`; + for (let availability of data) + text += `\n- ${availability.weapon.name}`; + text += '\n#splatoon2'; + + return text; + } +} + +module.exports = NewWeaponTweet; diff --git a/src/updater/twitter/SalmonRunTweet.js b/src/updater/twitter/SalmonRunTweet.js new file mode 100644 index 0000000..ef3d02d --- /dev/null +++ b/src/updater/twitter/SalmonRunTweet.js @@ -0,0 +1,42 @@ +const TwitterPostBase = require('./TwitterPostBase'); +const { captureSalmonRunScreenshot } = require('../screenshots'); +const { readData } = require('./utilities'); + +class SalmonRunTweet extends TwitterPostBase { + getKey() { return 'salmonrun'; } + getName() { return 'Salmon Run'; } + + getSalmonRunSchedules() { + let schedules = {}; + + let timeline = readData('timeline.json').coop; + if (timeline) + schedules[timeline.start_time] = timeline; + + let calendar = readData('salmonruncalendar.json').schedules; + if (calendar) { + for (let schedule of calendar) + schedules[schedule.start_time] = schedule; + } + + return Object.values(schedules); + } + + getData() { + return this.getSalmonRunSchedules().find(s => s.start_time == this.getDataTime()); + } + + getTestData() { + return this.getSalmonRunSchedules()[0]; + } + + getImage(data) { + return captureSalmonRunScreenshot(data.start_time); + } + + getText(data) { + return 'Salmon Run is now open! #salmonrun #splatoon2'; + } +} + +module.exports = SalmonRunTweet; diff --git a/src/updater/twitter/ScheduleTweet.js b/src/updater/twitter/ScheduleTweet.js new file mode 100644 index 0000000..b6291b3 --- /dev/null +++ b/src/updater/twitter/ScheduleTweet.js @@ -0,0 +1,30 @@ +const TwitterPostBase = require('./TwitterPostBase'); +const { captureScheduleScreenshot } = require('../screenshots'); +const { readData } = require('./utilities'); + +class ScheduleTweet extends TwitterPostBase { + getKey() { return 'schedule'; } + getName() { return 'Schedule'; } + + getSchedules() { + return readData('schedules.json'); + } + + getData() { + return this.getSchedules().regular.find(s => s.start_time == this.getDataTime()); + } + + getTestData() { + return this.getSchedules().regular[0]; + } + + getImage(data) { + return captureScheduleScreenshot(data.start_time); + } + + getText(data) { + return 'Current Splatoon 2 map rotation'; + } +} + +module.exports = ScheduleTweet; diff --git a/src/updater/twitter/TwitterPostBase.js b/src/updater/twitter/TwitterPostBase.js new file mode 100644 index 0000000..6a8b2cb --- /dev/null +++ b/src/updater/twitter/TwitterPostBase.js @@ -0,0 +1,154 @@ +const path = require('path'); +const fs = require('fs'); +const { postMediaTweet } = require('./client'); +const { getTopOfCurrentHour, readJson } = require('./utilities'); + +const lastTweetTimesPath = path.resolve('twitter-lastTweetTimes.json'); + +class TwitterPostBase { + maybePostTweet() { + // Make sure we have data to post + if (!this.getData()) { + this.info('No data to post'); + return false; + } + + if (!this.shouldPostForCurrentTime()) { + this.info('Already posted for this time'); + return false; + } + + return this.postTweet(); + } + + async postTweet() { + try { + // Get the Tweet's text and image + let data = this.getData(); + let text = await this.getText(data); + let image = await this.getImage(data); + + // Post to Twitter + let tweet = await postMediaTweet(text, image); + + // Update the last post time + this.updateLastTweetTime(); + + this.info('Posted Tweet'); + } + catch (e) { + this.error('Couldn\'t post Tweet'); + console.error(e); + } + } + + async saveTestScreenshot() { + try { + let data = this.getTestData(); + if (!data) { + this.info('No data available'); + return; + } + + let filename = this.getTestScreenshotFilename(); + let image = await this.getImage(data); + + fs.writeFileSync(filename, image); + this.info('Saved screenshot') + } + catch (e) { + this.error('Couldn\'t save screenshot'); + console.error(e); + } + } + + /** + * Post time helpers + */ + + getLastTweetTimes() { + if (fs.existsSync(lastTweetTimesPath)) + return readJson(lastTweetTimesPath); + return {}; + } + + getLastTweetTime() { + let key = this.getKey(); + return this.getLastTweetTimes()[key] || 0; + } + + updateLastTweetTime() { + let key = this.getKey(); + let time = this.getDataTime(); + let lastTweetTimes = this.getLastTweetTimes(); + + lastTweetTimes[key] = time; + + fs.writeFileSync(lastTweetTimesPath, JSON.stringify(lastTweetTimes)); + } + + shouldPostForCurrentTime() { + // Check whether the current data time has already been posted + let time = this.getDataTime(); + let lastTweetTime = this.getLastTweetTime(); + return lastTweetTime < time; + } + + /** + * Log helpers + */ + + formatLogMessage(message) { + let name = this.getName(); + return `[Twitter] [${name}] ${message}`; + } + + log(message) { + console.log(this.formatLogMessage(message)); + } + + info(message) { + console.info(this.formatLogMessage(message)); + } + + error(message) { + console.error(this.formatLogMessage(message)); + } + + /** + * Overridable methods + */ + + // The unique key for this Tweet (used for storing the last time this Tweet was posted) + getKey() { } + + // The friendly name for this Tweet (used for console log messages) + getName() { } + + // The time which the current Tweet is based off of (usually the top of the current hour) + getDataTime() { + return getTopOfCurrentHour(); + } + + // The current data item the Tweet is based on (used by getImage and getText) + getData() { } + + // Data for test screenshots + getTestData() { + return this.getData(); + } + + // The image data to be posted with the Tweet + getImage(data) { } + + // The text body of the Tweet + getText(data) { } + + // The filename for test screenshots + getTestScreenshotFilename() { + let key = this.getKey(); + return `test-screenshot-${key}.png`; + } +} + +module.exports = TwitterPostBase; diff --git a/src/updater/twitter/client.js b/src/updater/twitter/client.js new file mode 100644 index 0000000..30c55bb --- /dev/null +++ b/src/updater/twitter/client.js @@ -0,0 +1,40 @@ +require('../bootstrap'); +const Twitter = require('twitter'); + +// Twitter API parameters +const consumer_key = process.env.TWITTER_CONSUMER_KEY; +const consumer_secret = process.env.TWITTER_CONSUMER_SECRET; +const access_token_key = process.env.TWITTER_ACCESS_TOKEN_KEY; +const access_token_secret = process.env.TWITTER_ACCESS_TOKEN_SECRET; + +// Twitter API client +const client = new Twitter({ consumer_key, consumer_secret, access_token_key, access_token_secret }); +module.exports.client = client; + +function canTweet() { + return consumer_key && consumer_secret && access_token_key && access_token_secret; +} +module.exports.canTweet = canTweet; + +function postMediaTweet(text, image) { + return new Promise((resolve, reject) => { + // Upload the image + client.post('media/upload', { media: image }, (error, media, response) => { + if (error) + return reject(error); + + let status = { + status: text, + media_ids: media.media_id_string, + }; + + client.post('statuses/update', status, (error, tweet, response) => { + if (error) + return reject(error); + + resolve(tweet); + }); + }); + }); +} +module.exports.postMediaTweet = postMediaTweet; diff --git a/src/updater/twitter/utilities.js b/src/updater/twitter/utilities.js new file mode 100644 index 0000000..efd4c9c --- /dev/null +++ b/src/updater/twitter/utilities.js @@ -0,0 +1,22 @@ +const fs = require('fs'); +const path = require('path'); + +const dataPath = path.resolve('public/data'); + +function getTopOfCurrentHour() { + let date = new Date; + date.setUTCMinutes(0); + date.setUTCSeconds(0); + return Math.floor(date.getTime() / 1000); +} +module.exports.getTopOfCurrentHour = getTopOfCurrentHour; + +function readJson(filename) { + return JSON.parse(fs.readFileSync(filename)); +} +module.exports.readJson = readJson; + +function readData(filename) { + return readJson(`${dataPath}/${filename}`); +} +module.exports.readData = readData;