Auto-update social media avatars
Some checks failed
Build frontend / build (22.x) (push) Has been cancelled
Deploy / deploy-frontend (push) Has been cancelled
Deploy / deploy-backend (push) Has been cancelled
Fix code styles / build (push) Has been cancelled
Tests / test (22.x) (push) Has been cancelled

This commit is contained in:
Matt Isenhower 2026-02-20 08:56:19 -08:00
parent 00f0ee4cf1
commit c1664590b5
12 changed files with 198 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

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

View File

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

View File

@ -41,7 +41,7 @@ function defaultStatusGenerators() {
];
}
function defaultClients() {
export function defaultClients() {
return [
// new TwitterClient,
new MastodonClient,

View File

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

View File

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