mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-09-29 12:47:14 -05:00
Remove X posting and rename the bot commands and modules to social
This commit is contained in:
@@ -21,9 +21,3 @@ CLOUDFLARE_BROWSER_RUN_API_TOKEN=
|
||||
BLUESKY_SERVICE=https://bsky.social
|
||||
BLUESKY_IDENTIFIER=splatoon2.ink # Handle or email address
|
||||
BLUESKY_PASSWORD=
|
||||
|
||||
# (Optional) Twitter API parameters
|
||||
TWITTER_CONSUMER_KEY=
|
||||
TWITTER_CONSUMER_SECRET=
|
||||
TWITTER_ACCESS_TOKEN_KEY=
|
||||
TWITTER_ACCESS_TOKEN_SECRET=
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -22,7 +22,6 @@
|
||||
"lodash": "^4.18.1",
|
||||
"mkdirp": "^3.0.1",
|
||||
"moment-timezone": "^0.6.3",
|
||||
"twitter-api-v2": "^1.29.1",
|
||||
"v-click-outside": "^3.0.1",
|
||||
"vue": "^2.7.16",
|
||||
"vue-clipboard2": "^0.3.1",
|
||||
@@ -14657,12 +14656,6 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/twitter-api-v2": {
|
||||
"version": "1.29.1",
|
||||
"resolved": "https://registry.npmjs.org/twitter-api-v2/-/twitter-api-v2-1.29.1.tgz",
|
||||
"integrity": "sha512-l5U8aP6woe+gv0RViz/YA0YEG/NRtqkO1iXWqxNfrn3MSc2V85u6w44oNDBWhy8zVV9r36SJv/uXNmnfni8Uxw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -17,11 +17,9 @@
|
||||
"test": "node --test && npm run worker:test && npm run updater:test",
|
||||
"lint": "eslint .",
|
||||
"cron": "node src/app/cron.js",
|
||||
"start": "npm run splatnet && npm run twitter && npm run cron",
|
||||
"start": "npm run splatnet && npm run social && npm run cron",
|
||||
"locale-man": "node node_modules/locale-man/ -l en,es,es-MX,fr,fr-CA,de,nl,it,ru,ja -o src/locale",
|
||||
"splatnet": "node src/app/index.js splatnet",
|
||||
"twitter": "node src/app/index.js twitter",
|
||||
"twitter:test": "node src/app/index.js twitterTest",
|
||||
"utility:copyTranslation": "node src/utility/index.js copyTranslation",
|
||||
"utility:getSplatNetLanguageFiles": "node src/utility/index.js getSplatNetLanguageFiles",
|
||||
"utility:updateGear": "node src/utility/index.js updateGear",
|
||||
@@ -34,7 +32,9 @@
|
||||
"updater:test": "vitest run --config workers/updater/vitest.config.mjs",
|
||||
"updater:deploy:dry-run": "wrangler deploy --config workers/updater/wrangler.jsonc --dry-run",
|
||||
"updater:deploy": "wrangler deploy --config workers/updater/wrangler.jsonc",
|
||||
"updater:tail": "wrangler tail --config workers/updater/wrangler.jsonc"
|
||||
"updater:tail": "wrangler tail --config workers/updater/wrangler.jsonc",
|
||||
"social": "node src/app/index.js social",
|
||||
"social:test": "node src/app/index.js socialTest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.20.42",
|
||||
@@ -53,7 +53,6 @@
|
||||
"lodash": "^4.18.1",
|
||||
"mkdirp": "^3.0.1",
|
||||
"moment-timezone": "^0.6.3",
|
||||
"twitter-api-v2": "^1.29.1",
|
||||
"v-click-outside": "^3.0.1",
|
||||
"vue": "^2.7.16",
|
||||
"vue-clipboard2": "^0.3.1",
|
||||
|
||||
@@ -4,6 +4,6 @@ import { updateAllLocally, postLocally, testScreenshotsLocally } from './local.j
|
||||
|
||||
await runAction({
|
||||
splatnet: updateAllLocally,
|
||||
twitter: postLocally,
|
||||
twitterTest: testScreenshotsLocally,
|
||||
social: postLocally,
|
||||
socialTest: testScreenshotsLocally,
|
||||
}, process.argv.slice(2));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import FilesystemStorage from '../common/storage/FilesystemStorage.js';
|
||||
import { updateAll } from './updater/index.js';
|
||||
import { maybePostTweets, testScreenshots } from './twitter/index.js';
|
||||
import { sendStatuses, testScreenshots } from './social/index.js';
|
||||
|
||||
export function filesystemStorage() {
|
||||
return {
|
||||
@@ -19,7 +19,7 @@ export async function updateAllLocally() {
|
||||
}
|
||||
|
||||
export function postLocally() {
|
||||
return maybePostTweets(filesystemStorage());
|
||||
return sendStatuses(filesystemStorage());
|
||||
}
|
||||
|
||||
export function testScreenshotsLocally() {
|
||||
|
||||
1
src/app/social/index.js
Normal file
1
src/app/social/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './social.js';
|
||||
@@ -1,8 +1,8 @@
|
||||
import TwitterPostBase from './TwitterPostBase.js';
|
||||
import SocialPostBase from './SocialPostBase.js';
|
||||
import { captureGearScreenshot } from '../../screenshots/index.js';
|
||||
import { getTopOfCurrentHour } from '../../../common/time.js';
|
||||
|
||||
export default class GearTweet extends TwitterPostBase {
|
||||
export default class GearPost extends SocialPostBase {
|
||||
getKey() { return 'gear'; }
|
||||
getName() { return 'Gear'; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import TwitterPostBase from './TwitterPostBase.js';
|
||||
import SocialPostBase from './SocialPostBase.js';
|
||||
|
||||
export default class NewStageTweet extends TwitterPostBase {
|
||||
export default class NewStagePost extends SocialPostBase {
|
||||
getKey() { return 'newstage'; }
|
||||
getName() { return 'New Stage'; }
|
||||
|
||||
@@ -20,6 +20,6 @@ export default class NewStageTweet extends TwitterPostBase {
|
||||
async getText(data) {
|
||||
let hours = (data.first_available - await this.getDataTime()) / 60 / 60;
|
||||
let duration = (hours == 1) ? '1 hour' : `${hours} hours`;
|
||||
return `NEW STAGE: The first schedules for ${data.name} have been posted! Start playing the new stage when this tweet is ${duration} old. #splatoon2`;
|
||||
return `NEW STAGE: The first schedules for ${data.name} have been posted! Start playing the new stage when this post is ${duration} old. #splatoon2`;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import TwitterPostBase from './TwitterPostBase.js';
|
||||
import SocialPostBase from './SocialPostBase.js';
|
||||
import { captureNewWeaponScreenshot } from '../../screenshots/index.js';
|
||||
|
||||
export default class NewWeaponTweet extends TwitterPostBase {
|
||||
export default class NewWeaponPost extends SocialPostBase {
|
||||
getKey() { return 'weapon'; }
|
||||
getName() { return 'New Weapon'; }
|
||||
|
||||
@@ -17,7 +17,7 @@ export default class NewWeaponTweet extends TwitterPostBase {
|
||||
let availabilities = (await this.getNewWeaponAvailabilities()).filter(a => a.release_time == time);
|
||||
|
||||
// Only return the array if it contains availabilities.
|
||||
// Otherwise return false (i.e., there's no data, so don't post a Tweet).
|
||||
// Otherwise return false (i.e., there's no data, so don't post a Post).
|
||||
if (!availabilities.length)
|
||||
return false;
|
||||
|
||||
@@ -38,7 +38,7 @@ export default class NewWeaponTweet extends TwitterPostBase {
|
||||
if (data.length == 1)
|
||||
return `NEW WEAPON: The ${data[0].weapon.name} is now available! #splatoon2`;
|
||||
|
||||
// Make sure we don't exceed the max tweet text length
|
||||
// Make sure we don't exceed the max post text length
|
||||
const names = data.map(availability => `- ${availability.weapon.name}`);
|
||||
for (let i = names.length; i > 0; i--) {
|
||||
let text = 'New weapons now available:\n';
|
||||
@@ -49,7 +49,7 @@ export default class NewWeaponTweet extends TwitterPostBase {
|
||||
}
|
||||
text += '#splatoon2';
|
||||
|
||||
if (text.length <= this.getMaxTweetLength()) {
|
||||
if (text.length <= this.getMaxPostLength()) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import TwitterPostBase from './TwitterPostBase.js';
|
||||
import SocialPostBase from './SocialPostBase.js';
|
||||
import { captureSalmonRunGearScreenshot } from '../../screenshots/index.js';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
export default class SalmonRunGearTweet extends TwitterPostBase {
|
||||
export default class SalmonRunGearPost extends SocialPostBase {
|
||||
getKey() { return 'salmonrungear'; }
|
||||
getName() { return 'Salmon Run Gear'; }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import TwitterPostBase from './TwitterPostBase.js';
|
||||
import SocialPostBase from './SocialPostBase.js';
|
||||
import { captureSalmonRunScreenshot } from '../../screenshots/index.js';
|
||||
|
||||
const PREVIOUS_SCHEDULE_KEY = 'salmonrun-previousSchedule.json';
|
||||
|
||||
export default class SalmonRunTweet extends TwitterPostBase {
|
||||
export default class SalmonRunPost extends SocialPostBase {
|
||||
getKey() { return 'salmonrun'; }
|
||||
getName() { return 'Salmon Run'; }
|
||||
|
||||
@@ -51,17 +51,17 @@ export default class SalmonRunTweet extends TwitterPostBase {
|
||||
await this.updatePreviousSchedule(current);
|
||||
}
|
||||
|
||||
// Post a tweet if a schedule just started, or periodically every 12 hours
|
||||
// Post a post if a schedule just started, or periodically every 12 hours
|
||||
if (current && (time - current.start_time) % (12 * 60 * 60) === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Post a tweet if the previous schedule just closed
|
||||
// Post a post if the previous schedule just closed
|
||||
if (previous && previous.end_time === time) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Otherwise, don't post a tweet
|
||||
// Otherwise, don't post a post
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import TwitterPostBase from './TwitterPostBase.js';
|
||||
import SocialPostBase from './SocialPostBase.js';
|
||||
import { captureScheduleScreenshot } from '../../screenshots/index.js';
|
||||
import finalFest from '../../../common/data/finalFest.json' with { type: 'json' };
|
||||
import shiftyStations from '../../../common/data/shiftyStations.json' with { type: 'json' };
|
||||
|
||||
export default class ScheduleTweet extends TwitterPostBase {
|
||||
export default class SchedulePost extends SocialPostBase {
|
||||
getKey() { return 'schedule'; }
|
||||
getName() { return 'Schedule'; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getTopOfCurrentHour } from '../../../common/time.js';
|
||||
import { pngSize } from '../../../common/png.js';
|
||||
|
||||
export default class TwitterPostBase {
|
||||
export default class SocialPostBase {
|
||||
/**
|
||||
* @param {{ publicStorage: object, privateStorage: object }} storage
|
||||
* publicStorage holds the site data this post reads and the public copy of its image;
|
||||
@@ -14,7 +14,7 @@ export default class TwitterPostBase {
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
async maybePostTweet() {
|
||||
async maybePost() {
|
||||
// Make sure we have data to post
|
||||
if (!await this.getData()) {
|
||||
this.info('No data to post');
|
||||
@@ -33,11 +33,11 @@ export default class TwitterPostBase {
|
||||
|
||||
// Make sure we can post or save to a file
|
||||
if (!await this.canPost() && !this.getPublicImageFilename()) {
|
||||
this.error('Twitter API parameters not specified');
|
||||
this.error('Social API parameters not specified');
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.postTweet();
|
||||
return this.post();
|
||||
}
|
||||
|
||||
async canPost() {
|
||||
@@ -47,9 +47,9 @@ export default class TwitterPostBase {
|
||||
return false;
|
||||
}
|
||||
|
||||
async postTweet() {
|
||||
async post() {
|
||||
try {
|
||||
// Get the Tweet's text and image
|
||||
// Get the Post's text and image
|
||||
let data = await this.getData();
|
||||
let text = await this.getText(data);
|
||||
let image = await this.getMedia(data, 'image/png');
|
||||
@@ -79,7 +79,7 @@ export default class TwitterPostBase {
|
||||
};
|
||||
|
||||
await client.send(status);
|
||||
await this.updateLastTweetTime(client);
|
||||
await this.updateLastPostTime(client);
|
||||
this.info(`Posted to ${client.name}`);
|
||||
} catch (e) {
|
||||
this.error(`Couldn't post to ${client.name}`);
|
||||
@@ -88,7 +88,7 @@ export default class TwitterPostBase {
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
this.error('Couldn\'t post Tweet');
|
||||
this.error('Couldn\'t post Post');
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
@@ -143,37 +143,34 @@ export default class TwitterPostBase {
|
||||
* Post time helpers
|
||||
*/
|
||||
|
||||
getLastTweetTimesKey(client) {
|
||||
switch (client.key) {
|
||||
case 'bluesky': return 'bluesky-lastPostTimes.json';
|
||||
case 'twitter': return 'twitter-lastTweetTimes.json';
|
||||
}
|
||||
getLastPostTimesKey(client) {
|
||||
return `${client.key}-lastPostTimes.json`;
|
||||
}
|
||||
|
||||
async getLastTweetTimes(client) {
|
||||
return await this.readState(this.getLastTweetTimesKey(client)) ?? {};
|
||||
async getLastPostTimes(client) {
|
||||
return await this.readState(this.getLastPostTimesKey(client)) ?? {};
|
||||
}
|
||||
|
||||
async getLastTweetTime(client) {
|
||||
async getLastPostTime(client) {
|
||||
let key = this.getKey();
|
||||
return (await this.getLastTweetTimes(client))[key] || 0;
|
||||
return (await this.getLastPostTimes(client))[key] || 0;
|
||||
}
|
||||
|
||||
async updateLastTweetTime(client) {
|
||||
async updateLastPostTime(client) {
|
||||
let key = this.getKey();
|
||||
let time = await this.getDataTime();
|
||||
let lastTweetTimes = await this.getLastTweetTimes(client);
|
||||
let lastPostTimes = await this.getLastPostTimes(client);
|
||||
|
||||
lastTweetTimes[key] = time;
|
||||
lastPostTimes[key] = time;
|
||||
|
||||
await this.writeState(this.getLastTweetTimesKey(client), lastTweetTimes);
|
||||
await this.writeState(this.getLastPostTimesKey(client), lastPostTimes);
|
||||
}
|
||||
|
||||
async shouldPostForCurrentTime(client) {
|
||||
// Check whether the current data time has already been posted
|
||||
let time = await this.getDataTime();
|
||||
let lastTweetTime = await this.getLastTweetTime(client);
|
||||
return lastTweetTime < time;
|
||||
let lastPostTime = await this.getLastPostTime(client);
|
||||
return lastPostTime < time;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +179,7 @@ export default class TwitterPostBase {
|
||||
|
||||
formatLogMessage(message) {
|
||||
let name = this.getName();
|
||||
return `[Twitter] [${name}] ${message}`;
|
||||
return `[Social] [${name}] ${message}`;
|
||||
}
|
||||
|
||||
log(message) {
|
||||
@@ -201,18 +198,18 @@ export default class TwitterPostBase {
|
||||
* Overridable methods
|
||||
*/
|
||||
|
||||
// The unique key for this Tweet (used for storing the last time this Tweet was posted)
|
||||
// The unique key for this Post (used for storing the last time this Post was posted)
|
||||
getKey() { }
|
||||
|
||||
// The friendly name for this Tweet (used for console log messages)
|
||||
// The friendly name for this Post (used for console log messages)
|
||||
getName() { }
|
||||
|
||||
// The time which the current Tweet is based off of (usually the top of the current hour)
|
||||
// The time which the current Post is based off of (usually the top of the current hour)
|
||||
async getDataTime() {
|
||||
return getTopOfCurrentHour();
|
||||
}
|
||||
|
||||
// The current data item the Tweet is based on (used by getImage and getText)
|
||||
// The current data item the Post is based on (used by getImage and getText)
|
||||
async getData() { }
|
||||
|
||||
// Data for test screenshots
|
||||
@@ -220,7 +217,7 @@ export default class TwitterPostBase {
|
||||
return this.getData();
|
||||
}
|
||||
|
||||
// The image to post with the Tweet, as a screenshot result ({ image, type, width, height })
|
||||
// The image to post with the Post, as a screenshot result ({ image, type, width, height })
|
||||
// or raw PNG bytes. `format` is 'png' or 'jpeg'.
|
||||
async getImage(data, format) { }
|
||||
|
||||
@@ -240,7 +237,7 @@ export default class TwitterPostBase {
|
||||
// The filename to store the image as (optional)
|
||||
getPublicImageFilename() { }
|
||||
|
||||
// The text body of the Tweet
|
||||
// The text body of the Post
|
||||
async getText(data) { }
|
||||
|
||||
// The key for test screenshots in public storage
|
||||
@@ -249,7 +246,7 @@ export default class TwitterPostBase {
|
||||
return `test-screenshots/${key}.png`;
|
||||
}
|
||||
|
||||
getMaxTweetLength() {
|
||||
getMaxPostLength() {
|
||||
return 280;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import TwitterPostBase from './TwitterPostBase.js';
|
||||
import SocialPostBase from './SocialPostBase.js';
|
||||
import { captureSplatfestScreenshot } from '../../screenshots/index.js';
|
||||
import { splatoonRegions } from '../../../common/regions.js';
|
||||
|
||||
export default class SplatfestTweet extends TwitterPostBase {
|
||||
export default class SplatfestPost extends SocialPostBase {
|
||||
constructor(region, storage, clients) {
|
||||
super(storage, clients);
|
||||
|
||||
@@ -46,7 +46,7 @@ export default class SplatfestTweet extends TwitterPostBase {
|
||||
// Festival results
|
||||
festival = festivals.find(f => f.times.result == time);
|
||||
if (festival) {
|
||||
// We only want to post the results tweet if we actually have results
|
||||
// We only want to post the results post if we actually have results
|
||||
let results = (await this.getResults()).find(r => r.festival_id == festival.festival_id);
|
||||
if (results)
|
||||
return { festival, results, type: 'result' };
|
||||
@@ -97,7 +97,7 @@ export default class SplatfestTweet extends TwitterPostBase {
|
||||
|
||||
async shouldPostForCurrentTime(client) {
|
||||
if (await super.shouldPostForCurrentTime(client)) {
|
||||
// Prevent duplicate tweets for Splatfests occurring in multiple regions
|
||||
// Prevent duplicate posts for Splatfests occurring in multiple regions
|
||||
return (this.region == (await this.regions())[0] || !await this.isSimultaneous());
|
||||
}
|
||||
|
||||
25
src/app/social/posts/index.js
Normal file
25
src/app/social/posts/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import SchedulePost from './SchedulePost.js';
|
||||
import GearPost from './GearPost.js';
|
||||
import SalmonRunPost from './SalmonRunPost.js';
|
||||
// import SalmonRunGearPost from './SalmonRunGearPost.js';
|
||||
import NewWeaponPost from './NewWeaponPost.js';
|
||||
import NewStagePost from './NewStagePost.js';
|
||||
import SplatfestPost from './SplatfestPost.js';
|
||||
|
||||
/**
|
||||
* @param {{ publicStorage: object, privateStorage: object }} storage
|
||||
* @param {object[]} clients
|
||||
*/
|
||||
export function createPosts(storage, clients) {
|
||||
return [
|
||||
new SchedulePost(storage, clients),
|
||||
new GearPost(storage, clients),
|
||||
new SalmonRunPost(storage, clients),
|
||||
// new SalmonRunGearPost(storage, clients),
|
||||
new NewWeaponPost(storage, clients),
|
||||
new NewStagePost(storage, clients),
|
||||
new SplatfestPost('na', storage, clients),
|
||||
new SplatfestPost('eu', storage, clients),
|
||||
new SplatfestPost('jp', storage, clients),
|
||||
];
|
||||
}
|
||||
21
src/app/social/social.js
Normal file
21
src/app/social/social.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createPosts } from './posts/index.js';
|
||||
import BlueskyClient from './clients/BlueskyClient.js';
|
||||
|
||||
export function createClients() {
|
||||
return [new BlueskyClient];
|
||||
}
|
||||
|
||||
/**
|
||||
* Post whatever is due for the current hour.
|
||||
* @param {{ publicStorage: object, privateStorage: object }} storage
|
||||
* @param {object[]} [clients]
|
||||
*/
|
||||
export async function sendStatuses(storage, clients = createClients()) {
|
||||
for (let post of createPosts(storage, clients))
|
||||
await post.maybePost();
|
||||
}
|
||||
|
||||
export async function testScreenshots(storage, clients = createClients()) {
|
||||
for (let post of createPosts(storage, clients))
|
||||
await post.saveTestScreenshot();
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { TwitterApi } from 'twitter-api-v2';
|
||||
|
||||
export default class TwitterClient
|
||||
{
|
||||
key = 'twitter';
|
||||
name = 'Twitter';
|
||||
|
||||
/** @member {TwitterApi} */
|
||||
#api;
|
||||
|
||||
async canSend() {
|
||||
return process.env.TWITTER_CONSUMER_KEY
|
||||
&& process.env.TWITTER_CONSUMER_SECRET
|
||||
&& process.env.TWITTER_ACCESS_TOKEN_KEY
|
||||
&& process.env.TWITTER_ACCESS_TOKEN_SECRET;
|
||||
}
|
||||
|
||||
api() {
|
||||
if (!this.#api) {
|
||||
this.#api = new TwitterApi({
|
||||
appKey: process.env.TWITTER_CONSUMER_KEY,
|
||||
appSecret: process.env.TWITTER_CONSUMER_SECRET,
|
||||
accessToken: process.env.TWITTER_ACCESS_TOKEN_KEY,
|
||||
accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET,
|
||||
});
|
||||
}
|
||||
|
||||
return this.#api;
|
||||
}
|
||||
|
||||
async send(status, generator) {
|
||||
// Upload images
|
||||
let mediaIds = await Promise.all(
|
||||
status.media.map(async m => {
|
||||
let id = await this.api().v1.uploadMedia(Buffer.from(m.file), { mimeType: m.type });
|
||||
|
||||
if (m.altText) {
|
||||
await this.api().v1.createMediaMetadata(id, { alt_text: { text: m.altText } });
|
||||
}
|
||||
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
// Send status
|
||||
await this.api().v2.tweet(status.status, { media: { media_ids: mediaIds } });
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './twitter.js';
|
||||
@@ -1,25 +0,0 @@
|
||||
import ScheduleTweet from './ScheduleTweet.js';
|
||||
import GearTweet from './GearTweet.js';
|
||||
import SalmonRunTweet from './SalmonRunTweet.js';
|
||||
// import SalmonRunGearTweet from './SalmonRunGearTweet.js';
|
||||
import NewWeaponTweet from './NewWeaponTweet.js';
|
||||
import NewStageTweet from './NewStageTweet.js';
|
||||
import SplatfestTweet from './SplatfestTweet.js';
|
||||
|
||||
/**
|
||||
* @param {{ publicStorage: object, privateStorage: object }} storage
|
||||
* @param {object[]} clients
|
||||
*/
|
||||
export function createTweets(storage, clients) {
|
||||
return [
|
||||
new ScheduleTweet(storage, clients),
|
||||
new GearTweet(storage, clients),
|
||||
new SalmonRunTweet(storage, clients),
|
||||
// new SalmonRunGearTweet(storage, clients),
|
||||
new NewWeaponTweet(storage, clients),
|
||||
new NewStageTweet(storage, clients),
|
||||
new SplatfestTweet('na', storage, clients),
|
||||
new SplatfestTweet('eu', storage, clients),
|
||||
new SplatfestTweet('jp', storage, clients),
|
||||
];
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createTweets } from './tweets/index.js';
|
||||
import BlueskyClient from './clients/BlueskyClient.js';
|
||||
import TwitterClient from './clients/TwitterClient.js';
|
||||
|
||||
export function createClients() {
|
||||
return [new BlueskyClient, new TwitterClient];
|
||||
}
|
||||
|
||||
/**
|
||||
* Post whatever is due for the current hour.
|
||||
* @param {{ publicStorage: object, privateStorage: object }} storage
|
||||
* @param {object[]} [clients]
|
||||
*/
|
||||
export async function maybePostTweets(storage, clients = createClients()) {
|
||||
for (let tweet of createTweets(storage, clients))
|
||||
await tweet.maybePostTweet();
|
||||
}
|
||||
|
||||
export async function testScreenshots(storage, clients = createClients()) {
|
||||
for (let tweet of createTweets(storage, clients))
|
||||
await tweet.saveTestScreenshot();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import BlueskyClient from '../../src/app/twitter/clients/BlueskyClient.js';
|
||||
import BlueskyClient from '../../src/app/social/clients/BlueskyClient.js';
|
||||
|
||||
function fakeAgent() {
|
||||
const calls = { uploads: [], posts: [] };
|
||||
@@ -1,11 +1,11 @@
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import TwitterPostBase from '../../src/app/twitter/tweets/TwitterPostBase.js';
|
||||
import SocialPostBase from '../../src/app/social/posts/SocialPostBase.js';
|
||||
import { storage, fakeClient, json, seed } from './support.js';
|
||||
|
||||
const IMAGE = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
class HourlyPost extends TwitterPostBase {
|
||||
class HourlyPost extends SocialPostBase {
|
||||
constructor(s, clients, { data = { id: 1 }, time = 3600, publicImage = 'hourly.png' } = {}) {
|
||||
super(s, clients);
|
||||
this.data = data; this.time = time; this.publicImage = publicImage; this.images = 0;
|
||||
@@ -19,47 +19,47 @@ class HourlyPost extends TwitterPostBase {
|
||||
async getText(data) { return `Post ${data.id}`; }
|
||||
}
|
||||
|
||||
let s, bluesky, twitter;
|
||||
beforeEach(() => { s = storage(); bluesky = fakeClient('bluesky'); twitter = fakeClient('twitter'); });
|
||||
let s, bluesky, other;
|
||||
beforeEach(() => { s = storage(); bluesky = fakeClient('bluesky'); other = fakeClient('other'); });
|
||||
|
||||
test('posts to every client, saves the public image, and records the time per client', async () => {
|
||||
const post = new HourlyPost(s, [bluesky, twitter]);
|
||||
assert.equal(await post.maybePostTweet(), undefined); // postTweet ran
|
||||
const post = new HourlyPost(s, [bluesky, other]);
|
||||
assert.equal(await post.maybePost(), undefined); // post ran
|
||||
|
||||
assert.deepEqual(bluesky.sent, [{ status: 'Post 1', media: [{ file: IMAGE, type: 'image/png' }] }]);
|
||||
assert.deepEqual(twitter.sent, bluesky.sent);
|
||||
assert.deepEqual(other.sent, bluesky.sent);
|
||||
assert.equal(post.images, 1);
|
||||
assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), IMAGE);
|
||||
assert.deepEqual(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), { hourly: 3600 });
|
||||
assert.deepEqual(await json(s.privateBucket, 'twitter-lastTweetTimes.json'), { hourly: 3600 });
|
||||
assert.deepEqual(await json(s.privateBucket, 'other-lastPostTimes.json'), { hourly: 3600 });
|
||||
});
|
||||
|
||||
test('does not post the same data time twice, and posts again when it moves on', async () => {
|
||||
await seed(s.privateBucket, 'bluesky-lastPostTimes.json', { hourly: 3600, other: 99 });
|
||||
const post = new HourlyPost(s, [bluesky]);
|
||||
assert.equal(await post.maybePostTweet(), false);
|
||||
assert.equal(await post.maybePost(), false);
|
||||
assert.deepEqual(bluesky.sent, []);
|
||||
assert.equal(post.images, 0);
|
||||
|
||||
post.time = 7200;
|
||||
await post.maybePostTweet();
|
||||
await post.maybePost();
|
||||
assert.equal(bluesky.sent.length, 1);
|
||||
assert.deepEqual(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), { hourly: 7200, other: 99 });
|
||||
});
|
||||
|
||||
test('a client that already posted is skipped while another still posts', async () => {
|
||||
await seed(s.privateBucket, 'bluesky-lastPostTimes.json', { hourly: 3600 });
|
||||
await new HourlyPost(s, [bluesky, twitter]).maybePostTweet();
|
||||
await new HourlyPost(s, [bluesky, other]).maybePost();
|
||||
assert.deepEqual(bluesky.sent, []);
|
||||
assert.equal(twitter.sent.length, 1);
|
||||
assert.equal(other.sent.length, 1);
|
||||
});
|
||||
|
||||
test('a failing client does not record a post time and does not block the other client', async () => {
|
||||
const broken = fakeClient('bluesky', { fail: true });
|
||||
await new HourlyPost(s, [broken, twitter]).maybePostTweet();
|
||||
assert.equal(twitter.sent.length, 1);
|
||||
await new HourlyPost(s, [broken, other]).maybePost();
|
||||
assert.equal(other.sent.length, 1);
|
||||
assert.equal(await json(s.privateBucket, 'bluesky-lastPostTimes.json'), null);
|
||||
assert.deepEqual(await json(s.privateBucket, 'twitter-lastTweetTimes.json'), { hourly: 3600 });
|
||||
assert.deepEqual(await json(s.privateBucket, 'other-lastPostTimes.json'), { hourly: 3600 });
|
||||
});
|
||||
|
||||
test('renders once per media type a client needs, with size from the screenshot', async () => {
|
||||
@@ -67,24 +67,24 @@ test('renders once per media type a client needs, with size from the screenshot'
|
||||
async getImage(data, format) { this.images++; return { image: new Uint8Array([format === 'jpeg' ? 2 : 1]), type: format === 'jpeg' ? 'image/jpeg' : 'image/png', width: 2432, height: 1368 }; }
|
||||
}
|
||||
const jpegClient = fakeClient('bluesky'); jpegClient.mediaType = 'image/jpeg';
|
||||
const post = new ScreenshotPost(s, [jpegClient, twitter]);
|
||||
await post.maybePostTweet();
|
||||
const post = new ScreenshotPost(s, [jpegClient, other]);
|
||||
await post.maybePost();
|
||||
|
||||
assert.equal(post.images, 2); // one PNG (public copy + Twitter), one JPEG (Bluesky)
|
||||
assert.equal(post.images, 2); // one PNG (public copy + Other), one JPEG (Bluesky)
|
||||
assert.deepEqual(jpegClient.sent[0].media, [{ file: new Uint8Array([2]), type: 'image/jpeg', width: 2432, height: 1368 }]);
|
||||
assert.deepEqual(twitter.sent[0].media, [{ file: new Uint8Array([1]), type: 'image/png', width: 2432, height: 1368 }]);
|
||||
assert.deepEqual(other.sent[0].media, [{ file: new Uint8Array([1]), type: 'image/png', width: 2432, height: 1368 }]);
|
||||
assert.deepEqual(new Uint8Array(await (await s.publicBucket.get('twitter-images/hourly.png')).arrayBuffer()), new Uint8Array([1]));
|
||||
});
|
||||
|
||||
test('with no data there is nothing to post', async () => {
|
||||
const post = new HourlyPost(s, [bluesky], { data: null });
|
||||
assert.equal(await post.maybePostTweet(), false);
|
||||
assert.equal(await post.maybePost(), false);
|
||||
assert.equal(post.images, 0);
|
||||
});
|
||||
|
||||
test('with no client able to send, the public image is still saved', async () => {
|
||||
const offline = fakeClient('bluesky', { canSend: false });
|
||||
await new HourlyPost(s, [offline]).maybePostTweet();
|
||||
await new HourlyPost(s, [offline]).maybePost();
|
||||
assert.deepEqual(offline.sent, []);
|
||||
assert.ok(await s.publicBucket.get('twitter-images/hourly.png'));
|
||||
});
|
||||
@@ -92,7 +92,7 @@ test('with no client able to send, the public image is still saved', async () =>
|
||||
test('with no client able to send and no public image, it gives up', async () => {
|
||||
const offline = fakeClient('bluesky', { canSend: false });
|
||||
const post = new HourlyPost(s, [offline], { publicImage: null });
|
||||
assert.equal(await post.maybePostTweet(), false);
|
||||
assert.equal(await post.maybePost(), false);
|
||||
assert.equal(post.images, 0);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import ScheduleTweet from '../../src/app/twitter/tweets/ScheduleTweet.js';
|
||||
import SalmonRunTweet from '../../src/app/twitter/tweets/SalmonRunTweet.js';
|
||||
import NewStageTweet from '../../src/app/twitter/tweets/NewStageTweet.js';
|
||||
import SplatfestTweet from '../../src/app/twitter/tweets/SplatfestTweet.js';
|
||||
import { createTweets } from '../../src/app/twitter/tweets/index.js';
|
||||
import SchedulePost from '../../src/app/social/posts/SchedulePost.js';
|
||||
import SalmonRunPost from '../../src/app/social/posts/SalmonRunPost.js';
|
||||
import NewStagePost from '../../src/app/social/posts/NewStagePost.js';
|
||||
import SplatfestPost from '../../src/app/social/posts/SplatfestPost.js';
|
||||
import { createPosts } from '../../src/app/social/posts/index.js';
|
||||
import { storage, nextRun, fakeClient, json, seed } from './support.js';
|
||||
|
||||
const NOW = 3600 * 1000; // an hour boundary, as getTopOfCurrentHour returns
|
||||
@@ -18,16 +18,16 @@ beforeEach(async () => {
|
||||
await seed(s.publicBucket, 'data/festivals.json', { na: { festivals: [], results: [] }, eu: { festivals: [], results: [] }, jp: { festivals: [], results: [] } });
|
||||
});
|
||||
|
||||
function pinTime(tweet, time) {
|
||||
tweet.getDataTime = async () => time;
|
||||
tweet.getImage = async () => new Uint8Array([1]);
|
||||
return tweet;
|
||||
function pinTime(post, time) {
|
||||
post.getDataTime = async () => time;
|
||||
post.getImage = async () => new Uint8Array([1]);
|
||||
return post;
|
||||
}
|
||||
|
||||
test('createTweets builds every post against the same storage and clients', () => {
|
||||
const tweets = createTweets(s, [client]);
|
||||
assert.deepEqual(tweets.map(t => t.getKey()), ['schedule', 'gear', 'salmonrun', 'weapon', 'newstage', 'splatfest-na', 'splatfest-eu', 'splatfest-jp']);
|
||||
assert.ok(tweets.every(t => t.publicStorage === s.publicStorage && t.clients[0] === client));
|
||||
test('createPosts builds every post against the same storage and clients', () => {
|
||||
const posts = createPosts(s, [client]);
|
||||
assert.deepEqual(posts.map(t => t.getKey()), ['schedule', 'gear', 'salmonrun', 'weapon', 'newstage', 'splatfest-na', 'splatfest-eu', 'splatfest-jp']);
|
||||
assert.ok(posts.every(t => t.publicStorage === s.publicStorage && t.clients[0] === client));
|
||||
});
|
||||
|
||||
test('schedule: posts the rotation for the current hour, with new-stage wording when the stage is new', async () => {
|
||||
@@ -37,13 +37,13 @@ test('schedule: posts the rotation for the current hour, with new-stage wording
|
||||
});
|
||||
await seed(s.privateBucket, 'stages.json', [{ ...reef, first_seen: -1, first_available: -1 }, { ...skipper, first_seen: NOW - 7200, first_available: NOW }]);
|
||||
|
||||
const tweet = pinTime(new ScheduleTweet(s, [client]), NOW);
|
||||
await tweet.maybePostTweet();
|
||||
const post = pinTime(new SchedulePost(s, [client]), NOW);
|
||||
await post.maybePost();
|
||||
assert.equal(client.sent[0].status, 'NEW STAGE: Skipper Pavilion is now open! #maprotation #splatoon2');
|
||||
assert.ok(await s.publicBucket.get('twitter-images/schedule.png'));
|
||||
|
||||
const later = pinTime(new ScheduleTweet(nextRun(s), [client]), NOW + 3600);
|
||||
assert.equal(await later.maybePostTweet(), false); // no rotation starts at that hour
|
||||
const later = pinTime(new SchedulePost(nextRun(s), [client]), NOW + 3600);
|
||||
assert.equal(await later.maybePost(), false); // no rotation starts at that hour
|
||||
});
|
||||
|
||||
test('schedule: the plain rotation text names the ranked and league rules', async () => {
|
||||
@@ -51,8 +51,8 @@ test('schedule: the plain rotation text names the ranked and league rules', asyn
|
||||
await seed(s.publicBucket, 'data/schedules.json', {
|
||||
regular: [rotation(NOW, reef, fitness, 'Turf War')], gachi: [rotation(NOW, reef, fitness, 'Splat Zones')], league: [rotation(NOW, reef, fitness, 'Rainmaker')],
|
||||
});
|
||||
const tweet = pinTime(new ScheduleTweet(s, [client]), NOW);
|
||||
assert.equal(await tweet.getText(await tweet.getData()), 'Splatoon 2 map rotation: Ranked game mode: Splat Zones, League game mode: Rainmaker #maprotation');
|
||||
const post = pinTime(new SchedulePost(s, [client]), NOW);
|
||||
assert.equal(await post.getText(await post.getData()), 'Splatoon 2 map rotation: Ranked game mode: Splat Zones, League game mode: Rainmaker #maprotation');
|
||||
});
|
||||
|
||||
test('salmon run: posts when a shift opens, remembers it, and posts again when it closes', async () => {
|
||||
@@ -60,28 +60,28 @@ test('salmon run: posts when a shift opens, remembers it, and posts again when i
|
||||
await seed(s.publicBucket, 'data/coop-schedules.json', { schedules: [shift], details: [shift] });
|
||||
await seed(s.publicBucket, 'data/timeline.json', { coop: { reward_gear: { gear: { name: 'Cap' } } } });
|
||||
|
||||
await pinTime(new SalmonRunTweet(nextRun(s), [client]), NOW).maybePostTweet();
|
||||
await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW).maybePost();
|
||||
assert.equal(client.sent[0].status, 'Salmon Run is now open on Spawning Grounds! Current reward gear is the Cap. #salmonrun #splatoon2');
|
||||
assert.deepEqual(await json(s.privateBucket, 'salmonrun-previousSchedule.json'), shift);
|
||||
|
||||
assert.equal(await pinTime(new SalmonRunTweet(nextRun(s), [client]), NOW + 3600).maybePostTweet(), false); // mid-shift, not a 12-hour mark
|
||||
assert.equal(await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW + 3600).maybePost(), false); // mid-shift, not a 12-hour mark
|
||||
|
||||
await pinTime(new SalmonRunTweet(nextRun(s), [client]), NOW + 3600 * 12).maybePostTweet();
|
||||
await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW + 3600 * 12).maybePost();
|
||||
assert.match(client.sent[1].status, /is still open on Spawning Grounds/);
|
||||
|
||||
const next = { start_time: NOW + 3600 * 40, end_time: NOW + 3600 * 76 };
|
||||
await seed(s.publicBucket, 'data/coop-schedules.json', { schedules: [next], details: [] });
|
||||
await pinTime(new SalmonRunTweet(nextRun(s), [client]), NOW + 3600 * 36).maybePostTweet();
|
||||
await pinTime(new SalmonRunPost(nextRun(s), [client]), NOW + 3600 * 36).maybePost();
|
||||
assert.equal(client.sent[2].status, 'Salmon Run is now closed. The next shift starts in 4 hours! #salmonrun #splatoon2');
|
||||
});
|
||||
|
||||
test('new stage: posts the mirrored stage image the hour the stage was first seen', async () => {
|
||||
await seed(s.privateBucket, 'stages.json', [{ id: '22', name: 'Skipper Pavilion', image: '/images/stage/22.png', first_seen: NOW, first_available: NOW + 7200 }]);
|
||||
await s.publicBucket.put('assets/splatnet/images/stage/22.png', new Uint8Array([7]));
|
||||
const tweet = new NewStageTweet(s, [client]);
|
||||
tweet.getDataTime = async () => NOW;
|
||||
await tweet.maybePostTweet();
|
||||
assert.equal(client.sent[0].status, 'NEW STAGE: The first schedules for Skipper Pavilion have been posted! Start playing the new stage when this tweet is 2 hours old. #splatoon2');
|
||||
const post = new NewStagePost(s, [client]);
|
||||
post.getDataTime = async () => NOW;
|
||||
await post.maybePost();
|
||||
assert.equal(client.sent[0].status, 'NEW STAGE: The first schedules for Skipper Pavilion have been posted! Start playing the new stage when this post is 2 hours old. #splatoon2');
|
||||
assert.deepEqual(client.sent[0].media[0].file, new Uint8Array([7]));
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ test('splatfest: a global fest posts once, from the first region', async () => {
|
||||
const sentBy = [];
|
||||
for (const region of ['na', 'eu', 'jp']) {
|
||||
const c = fakeClient('bluesky');
|
||||
await pinTime(new SplatfestTweet(region, s, [c]), NOW).maybePostTweet();
|
||||
await pinTime(new SplatfestPost(region, s, [c]), NOW).maybePost();
|
||||
if (c.sent.length) sentBy.push([region, c.sent[0].status]);
|
||||
}
|
||||
assert.deepEqual(sentBy, [['na', 'The global Splatfest is now open! #splatfest #splatoon2']]);
|
||||
@@ -14,7 +14,7 @@ describe('runPosters', () => {
|
||||
process.env.SITE_URL = 'https://example.test';
|
||||
process.env.CLOUDFLARE_ACCOUNT_ID = 'acct';
|
||||
process.env.CLOUDFLARE_BROWSER_RUN_API_TOKEN = 'token';
|
||||
for (let name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD', 'TWITTER_CONSUMER_KEY'])
|
||||
for (let name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD'])
|
||||
delete process.env[name];
|
||||
|
||||
let now = getTopOfCurrentHour();
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// updaters have published the hour's data. The Worker's counterpart of postLocally().
|
||||
//
|
||||
// The posters read their credentials and the screenshot configuration from process.env,
|
||||
// which Workers populate from the bindings: BLUESKY_*, TWITTER_*, SITE_URL,
|
||||
// which Workers populate from the bindings: BLUESKY_*, SITE_URL,
|
||||
// CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_BROWSER_RUN_API_TOKEN. With no social credentials set
|
||||
// they only render and save the public images (shadow mode).
|
||||
|
||||
import { maybePostTweets, createClients } from '../../../src/app/twitter/index.js';
|
||||
import { sendStatuses, createClients } from '../../../src/app/social/index.js';
|
||||
import { bucketStorage } from './updaters.mjs';
|
||||
import { createLogger } from './log.mjs';
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function runPosters(env) {
|
||||
if (await client.canSend())
|
||||
enabled.push(client.key);
|
||||
|
||||
await maybePostTweets(bucketStorage(env), clients);
|
||||
await sendStatuses(bucketStorage(env), clients);
|
||||
|
||||
let summary = { ok: true, ms: Date.now() - started, clients: enabled };
|
||||
log.info('Posters finished', summary);
|
||||
|
||||
Reference in New Issue
Block a user