Refactor Twitter bot code

Related to issue #2.
This commit is contained in:
Unknown 2017-11-02 14:54:26 -07:00
parent 5156d738ca
commit a18a3fb920
9 changed files with 394 additions and 365 deletions

View File

@ -59,7 +59,7 @@
<SalmonRunDetailsBar :schedule="currentSchedule"></SalmonRunDetailsBar>
</div>
<div v-if="upcomingSchedules.length > 0">
<div v-if="!screenshotMode && upcomingSchedules.length > 0">
<div class="salmon-run-future">
<div class="is-size-6 title-squid font-splatoon1">
Soon

View File

@ -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 });

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;