diff --git a/app/cron.mjs b/app/cron.mjs index 9b3e1ff..b430769 100644 --- a/app/cron.mjs +++ b/app/cron.mjs @@ -3,6 +3,7 @@ import { update } from './data/index.mjs'; import { warmCaches } from './splatnet/index.mjs'; import { sendStatuses } from './social/index.mjs'; import { archiveData } from './data/DataArchiver.mjs'; +import { updateAvatars } from './social/updateAvatars.mjs'; let updating = false; @@ -35,4 +36,6 @@ export default function() { new CronJob('20 * * * *', () => { return updateIfNotUpdating('all'); }, null, true); + + new CronJob('30 * * * *', updateAvatars, null, true); } diff --git a/app/index.mjs b/app/index.mjs index 3d8cd51..67bb15e 100644 --- a/app/index.mjs +++ b/app/index.mjs @@ -11,6 +11,7 @@ import ThreadsClient from './social/clients/ThreadsClient.mjs'; import { archiveData } from './data/DataArchiver.mjs'; import { sentryInit } from './common/sentry.mjs'; import { sync, syncUpload, syncDownload } from './sync/index.mjs'; +import { updateAvatars } from './social/updateAvatars.mjs'; consoleStamp(console); dotenv.config({ quiet: true }); @@ -30,6 +31,7 @@ const actions = { sync, syncUpload, syncDownload, + updateAvatars, }; const command = process.argv[2]; diff --git a/app/social/avatarVariants.mjs b/app/social/avatarVariants.mjs new file mode 100644 index 0000000..495edc3 --- /dev/null +++ b/app/social/avatarVariants.mjs @@ -0,0 +1,45 @@ +const defaultVariant = { + key: 'default', + displayName: 'Splatoon3.ink', + avatar: 'default.png', +}; + +const variants = [ + { + key: 'pride', + displayName: 'Splatoon3.ink', + avatar: 'pride.png', + start: { month: 6, day: 1 }, + end: { month: 7, day: 1 }, + }, + { + key: 'halloween', + displayName: 'Splatoon3.eek \u{1F383}', + avatar: 'halloween.png', + start: { month: 10, day: 1 }, + end: { month: 11, day: 1 }, + }, +]; + +function isDateInRange(date, start, end) { + let month = date.getMonth() + 1; + let day = date.getDate(); + let value = month * 100 + day; + + return value >= start.month * 100 + start.day + && value <= end.month * 100 + end.day; +} + +/** + * Get the avatar variant for the given date (or now). + * @param {Date} [date] + */ +export function getCurrentVariant(date = new Date()) { + for (let variant of variants) { + if (isDateInRange(date, variant.start, variant.end)) { + return variant; + } + } + + return defaultVariant; +} diff --git a/app/social/avatarVariants.test.mjs b/app/social/avatarVariants.test.mjs new file mode 100644 index 0000000..421ca0d --- /dev/null +++ b/app/social/avatarVariants.test.mjs @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { getCurrentVariant } from './avatarVariants.mjs'; + +// Date-only strings (YYYY-MM-DD) are parsed as UTC, which can shift the +// local date. Adding T00:00 makes them parse as local time instead. +function date(str) { + return new Date(`${str}T00:00`); +} + +describe('getCurrentVariant', () => { + it('returns default for most of the year', () => { + expect(getCurrentVariant(date('2026-01-15')).key).toBe('default'); + expect(getCurrentVariant(date('2026-03-01')).key).toBe('default'); + expect(getCurrentVariant(date('2026-07-04')).key).toBe('default'); + expect(getCurrentVariant(date('2026-12-25')).key).toBe('default'); + }); + + it('returns pride for June', () => { + let variant = getCurrentVariant(date('2026-06-15')); + expect(variant.key).toBe('pride'); + expect(variant.displayName).toBe('Splatoon3.ink'); + }); + + it('returns pride on boundary days', () => { + expect(getCurrentVariant(date('2026-06-01')).key).toBe('pride'); + expect(getCurrentVariant(date('2026-07-01')).key).toBe('pride'); // padded for US timezones + }); + + it('returns default just outside pride range', () => { + expect(getCurrentVariant(date('2026-05-31')).key).toBe('default'); + expect(getCurrentVariant(date('2026-07-02')).key).toBe('default'); + }); + + it('returns halloween for October', () => { + let variant = getCurrentVariant(date('2026-10-15')); + expect(variant.key).toBe('halloween'); + expect(variant.displayName).toBe('Splatoon3.eek \u{1F383}'); + }); + + it('returns halloween on boundary days', () => { + expect(getCurrentVariant(date('2026-10-01')).key).toBe('halloween'); + expect(getCurrentVariant(date('2026-11-01')).key).toBe('halloween'); // padded for US timezones + }); + + it('returns default just outside halloween range', () => { + expect(getCurrentVariant(date('2026-09-30')).key).toBe('default'); + expect(getCurrentVariant(date('2026-11-02')).key).toBe('default'); + }); + + it('returns the correct avatar filename', () => { + expect(getCurrentVariant(date('2026-01-01')).avatar).toBe('default.png'); + expect(getCurrentVariant(date('2026-06-01')).avatar).toBe('pride.png'); + expect(getCurrentVariant(date('2026-10-01')).avatar).toBe('halloween.png'); + }); + + it('defaults to now when no date is provided', () => { + let variant = getCurrentVariant(); + expect(variant).toHaveProperty('key'); + expect(variant).toHaveProperty('displayName'); + expect(variant).toHaveProperty('avatar'); + }); +}); diff --git a/app/social/avatars/default.png b/app/social/avatars/default.png new file mode 100644 index 0000000..aa0daf3 Binary files /dev/null and b/app/social/avatars/default.png differ diff --git a/app/social/avatars/halloween.png b/app/social/avatars/halloween.png new file mode 100644 index 0000000..efda776 Binary files /dev/null and b/app/social/avatars/halloween.png differ diff --git a/app/social/avatars/pride.png b/app/social/avatars/pride.png new file mode 100644 index 0000000..7848657 Binary files /dev/null and b/app/social/avatars/pride.png differ diff --git a/app/social/clients/BlueskyClient.mjs b/app/social/clients/BlueskyClient.mjs index f8946c0..8919d59 100644 --- a/app/social/clients/BlueskyClient.mjs +++ b/app/social/clients/BlueskyClient.mjs @@ -30,6 +30,21 @@ export default class BlueskyClient extends Client } } + async updateProfile(avatarBuffer, displayName) { + await this.login(); + + let jpeg = await sharp(avatarBuffer).jpeg().toBuffer(); + let uploadResponse = await this.#agent.uploadBlob(jpeg, { encoding: 'image/jpeg' }); + + await this.#agent.upsertProfile((existing) => { + return { + ...existing, + avatar: uploadResponse.data.blob, + displayName, + }; + }); + } + async send(status, generator) { if (!status.media?.length) { console.error(`[${this.name}] No media provided for ${generator.key}`); diff --git a/app/social/clients/MastodonClient.mjs b/app/social/clients/MastodonClient.mjs index cf6447f..2a770a2 100644 --- a/app/social/clients/MastodonClient.mjs +++ b/app/social/clients/MastodonClient.mjs @@ -22,6 +22,20 @@ export default class MastodonClient extends Client return this.#url && this.#accessToken; } + async updateProfile(avatarBuffer, displayName) { + const masto = await createRestAPIClient({ + url: this.#url, + accessToken: this.#accessToken, + disableVersionCheck: true, + disableExperimentalWarning: true, + }); + + await masto.v1.accounts.updateCredentials({ + avatar: new Blob([avatarBuffer]), + displayName, + }); + } + async send(status, generator) { if (!status.media?.length) { console.error(`[${this.name}] No media provided for ${generator.key}`); diff --git a/app/social/index.mjs b/app/social/index.mjs index c7fa897..31a0785 100644 --- a/app/social/index.mjs +++ b/app/social/index.mjs @@ -41,7 +41,7 @@ function defaultStatusGenerators() { ]; } -function defaultClients() { +export function defaultClients() { return [ // new TwitterClient, new MastodonClient, diff --git a/app/social/updateAvatars.mjs b/app/social/updateAvatars.mjs new file mode 100644 index 0000000..b98fab0 --- /dev/null +++ b/app/social/updateAvatars.mjs @@ -0,0 +1,55 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import prefixedConsole from '../common/prefixedConsole.mjs'; +import ValueCache from '../common/ValueCache.mjs'; +import { defaultClients } from './index.mjs'; +import { getCurrentVariant } from './avatarVariants.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const avatarsDir = path.join(__dirname, 'avatars'); + +const console = prefixedConsole('Social', 'Avatar'); + +function cacheForClient(client) { + return new ValueCache(`social.${client.key}.avatar`); +} + +export async function updateAvatars() { + let variant = getCurrentVariant(); + let avatarBuffer = null; + + for (let client of defaultClients()) { + if (!client.updateProfile || !(await client.canSend())) { + continue; + } + + let cache = cacheForClient(client); + let cachedVariant = await cache.getData(); + + if (cachedVariant === variant.key) { + continue; + } + + console.log(`[${client.name}] Avatar update needed: ${cachedVariant || '(none)'} -> ${variant.key}`); + + // Read avatar file on first client that needs an update + if (!avatarBuffer) { + try { + let avatarPath = path.join(avatarsDir, variant.avatar); + avatarBuffer = await fs.readFile(avatarPath); + } catch (error) { + console.error(`Failed to read avatar file ${variant.avatar}:`, error.message); + return; + } + } + + try { + await client.updateProfile(avatarBuffer, variant.displayName); + await cache.setData(variant.key); + console.log(`[${client.name}] Profile updated successfully`); + } catch (error) { + console.error(`[${client.name}] Failed to update profile:`, error.message); + } + } +} diff --git a/package.json b/package.json index fef5e56..a98b70b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "social:test:mastodon": "node app/index.mjs socialTestMastodon", "social:test:bluesky": "node app/index.mjs socialTestBluesky", "social:test:threads": "node app/index.mjs socialTestThreads", + "social:avatars": "node app/index.mjs updateAvatars", "splatnet:quick": "node app/index.mjs splatnet quick", "splatnet": "node app/index.mjs splatnet default", "splatnet:all": "node app/index.mjs splatnet all",