Remove X posting and rename the bot commands and modules to social

This commit is contained in:
Matt Isenhower
2026-09-07 12:56:53 -07:00
parent 9258447df7
commit 235455bfea
27 changed files with 161 additions and 227 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

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

View File

@@ -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
View File

@@ -0,0 +1 @@
export * from './social.js';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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();
}

View File

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

View File

@@ -1 +0,0 @@
export * from './twitter.js';

View File

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

View File

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

View File

@@ -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: [] };

View File

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

View File

@@ -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']]);

View File

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

View File

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