From 235455bfea742a181920ee4be00f85b02650a920 Mon Sep 17 00:00:00 2001 From: Matt Isenhower Date: Mon, 7 Sep 2026 12:56:53 -0700 Subject: [PATCH] Remove X posting and rename the bot commands and modules to social --- .env.example | 6 -- package-lock.json | 7 --- package.json | 9 ++- src/app/index.js | 4 +- src/app/local.js | 4 +- .../clients/BlueskyClient.js | 0 src/app/social/index.js | 1 + .../GearTweet.js => social/posts/GearPost.js} | 4 +- .../posts/NewStagePost.js} | 6 +- .../posts/NewWeaponPost.js} | 10 ++-- .../posts/SalmonRunGearPost.js} | 4 +- .../posts/SalmonRunPost.js} | 10 ++-- .../posts/SchedulePost.js} | 4 +- .../posts/SocialPostBase.js} | 59 +++++++++---------- .../posts/SplatfestPost.js} | 8 +-- src/app/social/posts/index.js | 25 ++++++++ src/app/social/social.js | 21 +++++++ src/app/twitter/clients/TwitterClient.js | 48 --------------- src/app/twitter/index.js | 1 - src/app/twitter/tweets/index.js | 25 -------- src/app/twitter/twitter.js | 22 ------- .../{twitter => social}/BlueskyClient.test.js | 2 +- .../SocialPostBase.test.js} | 44 +++++++------- .../tweets.test.js => social/posts.test.js} | 56 +++++++++--------- test/{twitter => social}/support.js | 0 workers/updater/posters.spec.mjs | 2 +- workers/updater/src/posters.mjs | 6 +- 27 files changed, 161 insertions(+), 227 deletions(-) rename src/app/{twitter => social}/clients/BlueskyClient.js (100%) create mode 100644 src/app/social/index.js rename src/app/{twitter/tweets/GearTweet.js => social/posts/GearPost.js} (91%) rename src/app/{twitter/tweets/NewStageTweet.js => social/posts/NewStagePost.js} (76%) rename src/app/{twitter/tweets/NewWeaponTweet.js => social/posts/NewWeaponPost.js} (86%) rename src/app/{twitter/tweets/SalmonRunGearTweet.js => social/posts/SalmonRunGearPost.js} (89%) rename src/app/{twitter/tweets/SalmonRunTweet.js => social/posts/SalmonRunPost.js} (93%) rename src/app/{twitter/tweets/ScheduleTweet.js => social/posts/SchedulePost.js} (97%) rename src/app/{twitter/tweets/TwitterPostBase.js => social/posts/SocialPostBase.js} (78%) rename src/app/{twitter/tweets/SplatfestTweet.js => social/posts/SplatfestPost.js} (95%) create mode 100644 src/app/social/posts/index.js create mode 100644 src/app/social/social.js delete mode 100644 src/app/twitter/clients/TwitterClient.js delete mode 100644 src/app/twitter/index.js delete mode 100644 src/app/twitter/tweets/index.js delete mode 100644 src/app/twitter/twitter.js rename test/{twitter => social}/BlueskyClient.test.js (96%) rename test/{twitter/TwitterPostBase.test.js => social/SocialPostBase.test.js} (74%) rename test/{twitter/tweets.test.js => social/posts.test.js} (67%) rename test/{twitter => social}/support.js (100%) diff --git a/.env.example b/.env.example index 83cbf75..c011522 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/package-lock.json b/package-lock.json index 526f7e7..75daf51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index afd8442..a352210 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/index.js b/src/app/index.js index 3c00b06..8d9bd25 100644 --- a/src/app/index.js +++ b/src/app/index.js @@ -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)); diff --git a/src/app/local.js b/src/app/local.js index 3ef31bd..4402c43 100644 --- a/src/app/local.js +++ b/src/app/local.js @@ -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() { diff --git a/src/app/twitter/clients/BlueskyClient.js b/src/app/social/clients/BlueskyClient.js similarity index 100% rename from src/app/twitter/clients/BlueskyClient.js rename to src/app/social/clients/BlueskyClient.js diff --git a/src/app/social/index.js b/src/app/social/index.js new file mode 100644 index 0000000..1a99b4b --- /dev/null +++ b/src/app/social/index.js @@ -0,0 +1 @@ +export * from './social.js'; diff --git a/src/app/twitter/tweets/GearTweet.js b/src/app/social/posts/GearPost.js similarity index 91% rename from src/app/twitter/tweets/GearTweet.js rename to src/app/social/posts/GearPost.js index 61f3095..8995284 100644 --- a/src/app/twitter/tweets/GearTweet.js +++ b/src/app/social/posts/GearPost.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'; } diff --git a/src/app/twitter/tweets/NewStageTweet.js b/src/app/social/posts/NewStagePost.js similarity index 76% rename from src/app/twitter/tweets/NewStageTweet.js rename to src/app/social/posts/NewStagePost.js index 005d068..a6369cb 100644 --- a/src/app/twitter/tweets/NewStageTweet.js +++ b/src/app/social/posts/NewStagePost.js @@ -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`; } } diff --git a/src/app/twitter/tweets/NewWeaponTweet.js b/src/app/social/posts/NewWeaponPost.js similarity index 86% rename from src/app/twitter/tweets/NewWeaponTweet.js rename to src/app/social/posts/NewWeaponPost.js index 74c788e..4ceee81 100644 --- a/src/app/twitter/tweets/NewWeaponTweet.js +++ b/src/app/social/posts/NewWeaponPost.js @@ -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; } } diff --git a/src/app/twitter/tweets/SalmonRunGearTweet.js b/src/app/social/posts/SalmonRunGearPost.js similarity index 89% rename from src/app/twitter/tweets/SalmonRunGearTweet.js rename to src/app/social/posts/SalmonRunGearPost.js index 0aba499..391c8dd 100644 --- a/src/app/twitter/tweets/SalmonRunGearTweet.js +++ b/src/app/social/posts/SalmonRunGearPost.js @@ -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'; } diff --git a/src/app/twitter/tweets/SalmonRunTweet.js b/src/app/social/posts/SalmonRunPost.js similarity index 93% rename from src/app/twitter/tweets/SalmonRunTweet.js rename to src/app/social/posts/SalmonRunPost.js index 2d5b31a..e6e4484 100644 --- a/src/app/twitter/tweets/SalmonRunTweet.js +++ b/src/app/social/posts/SalmonRunPost.js @@ -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; } diff --git a/src/app/twitter/tweets/ScheduleTweet.js b/src/app/social/posts/SchedulePost.js similarity index 97% rename from src/app/twitter/tweets/ScheduleTweet.js rename to src/app/social/posts/SchedulePost.js index d775d7c..6dc42a4 100644 --- a/src/app/twitter/tweets/ScheduleTweet.js +++ b/src/app/social/posts/SchedulePost.js @@ -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'; } diff --git a/src/app/twitter/tweets/TwitterPostBase.js b/src/app/social/posts/SocialPostBase.js similarity index 78% rename from src/app/twitter/tweets/TwitterPostBase.js rename to src/app/social/posts/SocialPostBase.js index 956bdcc..0a1035c 100644 --- a/src/app/twitter/tweets/TwitterPostBase.js +++ b/src/app/social/posts/SocialPostBase.js @@ -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; } } diff --git a/src/app/twitter/tweets/SplatfestTweet.js b/src/app/social/posts/SplatfestPost.js similarity index 95% rename from src/app/twitter/tweets/SplatfestTweet.js rename to src/app/social/posts/SplatfestPost.js index 683a859..22ad2c2 100644 --- a/src/app/twitter/tweets/SplatfestTweet.js +++ b/src/app/social/posts/SplatfestPost.js @@ -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()); } diff --git a/src/app/social/posts/index.js b/src/app/social/posts/index.js new file mode 100644 index 0000000..e1f99a9 --- /dev/null +++ b/src/app/social/posts/index.js @@ -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), + ]; +} diff --git a/src/app/social/social.js b/src/app/social/social.js new file mode 100644 index 0000000..89607a2 --- /dev/null +++ b/src/app/social/social.js @@ -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(); +} diff --git a/src/app/twitter/clients/TwitterClient.js b/src/app/twitter/clients/TwitterClient.js deleted file mode 100644 index b82d65d..0000000 --- a/src/app/twitter/clients/TwitterClient.js +++ /dev/null @@ -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 } }); - } -} diff --git a/src/app/twitter/index.js b/src/app/twitter/index.js deleted file mode 100644 index 8d0cddf..0000000 --- a/src/app/twitter/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './twitter.js'; diff --git a/src/app/twitter/tweets/index.js b/src/app/twitter/tweets/index.js deleted file mode 100644 index 8d4647c..0000000 --- a/src/app/twitter/tweets/index.js +++ /dev/null @@ -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), - ]; -} diff --git a/src/app/twitter/twitter.js b/src/app/twitter/twitter.js deleted file mode 100644 index 3c3d0f8..0000000 --- a/src/app/twitter/twitter.js +++ /dev/null @@ -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(); -} diff --git a/test/twitter/BlueskyClient.test.js b/test/social/BlueskyClient.test.js similarity index 96% rename from test/twitter/BlueskyClient.test.js rename to test/social/BlueskyClient.test.js index b70d2c9..664f2a1 100644 --- a/test/twitter/BlueskyClient.test.js +++ b/test/social/BlueskyClient.test.js @@ -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: [] }; diff --git a/test/twitter/TwitterPostBase.test.js b/test/social/SocialPostBase.test.js similarity index 74% rename from test/twitter/TwitterPostBase.test.js rename to test/social/SocialPostBase.test.js index 48c7bd5..b27e950 100644 --- a/test/twitter/TwitterPostBase.test.js +++ b/test/social/SocialPostBase.test.js @@ -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); }); diff --git a/test/twitter/tweets.test.js b/test/social/posts.test.js similarity index 67% rename from test/twitter/tweets.test.js rename to test/social/posts.test.js index 724032b..8781a5f 100644 --- a/test/twitter/tweets.test.js +++ b/test/social/posts.test.js @@ -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']]); diff --git a/test/twitter/support.js b/test/social/support.js similarity index 100% rename from test/twitter/support.js rename to test/social/support.js diff --git a/workers/updater/posters.spec.mjs b/workers/updater/posters.spec.mjs index beafdeb..e1f30c7 100644 --- a/workers/updater/posters.spec.mjs +++ b/workers/updater/posters.spec.mjs @@ -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(); diff --git a/workers/updater/src/posters.mjs b/workers/updater/src/posters.mjs index 536025f..ad1f63f 100644 --- a/workers/updater/src/posters.mjs +++ b/workers/updater/src/posters.mjs @@ -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);