import './setup.ts'; import fs from 'fs'; import type { ReadStream } from 'node:fs'; import { AtpAgent, RichText } from '@atproto/api'; import * as Sentry from '@sentry/node'; // @ts-expect-error Mastodon doesn't have type declarations import Mastodon from 'mastodon'; import Twitter from 'twitter'; import buildLocaleList from '../src/buildLocaleList.ts'; import { newDate } from '../src/helpers.ts'; const __dirname = new URL('.', import.meta.url).pathname; const locales = buildLocaleList('_'); type BlueskyId = { uri: string; cid: string }; type PublisherIds = { twitter: string; mastodon: string; bluesky: BlueskyId; }; type Publishers = { [P in keyof PublisherIds]: ( tweet: string, image: ReadStream | null, rootId: PublisherIds[P] | undefined, previousId: PublisherIds[P] | undefined, locale: string ) => Promise; }; const publishers: Publishers = { async twitter( tweet: string, _image: ReadStream | null, _rootId: string | undefined, previousId: string | undefined, _locale: string, ) { const client = new Twitter({ consumer_key: process.env.TWITTER_CALENDAR_CONSUMER_KEY!, consumer_secret: process.env.TWITTER_CALENDAR_CONSUMER_SECRET!, access_token_key: process.env.TWITTER_CALENDAR_ACCESS_TOKEN_KEY!, access_token_secret: process.env.TWITTER_CALENDAR_ACCESS_TOKEN_SECRET!, }); try { const tweetResponse = await client.post('statuses/update', { status: tweet, ...previousId ? { in_reply_to_status_id: previousId } : {}, }); console.log(tweetResponse); return tweetResponse.id_str; } catch (error) { console.error(error); Sentry.captureException(error); } }, async mastodon( tweet: string, image: ReadStream | null, _rootId: string | undefined, previousId: string | undefined, locale: string, ) { const client = new Mastodon({ access_token: process.env.MASTODON_CALENDAR_ACCESS_TOKEN, api_url: `https://${process.env.MASTODON_CALENDAR_INSTANCE}/api/v1/`, }); const mediaIds = []; if (image) { try { const mediaResponse = await client.post('media', { file: image, description: 'Screenshot of the link above', }); console.log(mediaResponse); mediaIds.push(mediaResponse.data.id); } catch (error) { console.error(error); Sentry.captureException(error); } } const language = tweet.match(/^\[(.*)]/)![1]; try { const tweetResponse = await client.post('statuses', { status: tweet, media_ids: mediaIds, visibility: 'unlisted', ...previousId ? { in_reply_to_id: previousId, spoiler_text: language, } : {}, language: locale, }); console.log(tweetResponse.data); return tweetResponse.data.id; } catch (error) { console.error(error); Sentry.captureException(error); } }, async bluesky( tweet: string, image: ReadStream | null, rootId: BlueskyId | undefined, previousId: BlueskyId | undefined, locale: string, ) { const agent = new AtpAgent({ service: 'https://bsky.social' }); await agent.login({ identifier: process.env.BLUESKY_CALENDAR_IDENTIFIER!, password: process.env.BLUESKY_CALENDAR_PASSWORD!, }); const media = []; if (image) { try { const uploadResponse = await agent.uploadBlob(image as unknown as Blob, { encoding: 'image/png' }); console.log(uploadResponse); media.push(uploadResponse.data.blob); } catch (error) { console.error(error); Sentry.captureException(error); } } try { const rt = new RichText({ text: tweet, }); await rt.detectFacets(agent); const postResponse = await agent.post({ $type: 'app.bsky.feed.post', text: rt.text, facets: rt.facets, embed: { $type: 'app.bsky.embed.images', images: media.map((blob) => ({ image: blob, alt: '' })), }, ...(rootId && previousId) ? { reply: { root: rootId, parent: previousId, }, } : {}, langs: [locale], createdAt: newDate().toISOString(), }); console.log(postResponse); return { uri: postResponse.uri, cid: postResponse.cid, }; } catch (error) { console.error(error); Sentry.captureException(error); } }, }; const isPublisher = (value: string): value is keyof PublisherIds => { return value in publishers; }; const tmpDir = `${__dirname}/../cache/tmp`; fs.mkdirSync(tmpDir, { recursive: true }); const imageTmpPath = `${tmpDir}/calendar-tmp.png`; const rootPostId: Partial = {}; const previousId: Partial = {}; const publish = async

(publisher: P, message: string, locale: string) => { console.log(`Publishing: ${publisher}`); let imageStream: ReadStream | null = null; try { imageStream = fs.createReadStream(imageTmpPath); } catch { void 0; } const postId = await publishers[publisher]( message, imageStream, rootPostId[publisher], previousId[publisher], locale, ); console.log(postId); if (!rootPostId[publisher]) { rootPostId[publisher] = postId; } previousId[publisher] = postId; }; (async () => { if (process.argv.length !== 4) { console.error('Missing parameters. Usage: node server/calendarBot.ts '); return; } for (const locale of process.argv[2].split(',')) { console.log('------------'); console.log(locales[locale].name); try { const { message, image } = await (await fetch(`${locales[locale].url}/api/calendar/today`)).json(); console.log('<<<', message, '>>>'); if (!message) { continue; } fs.writeFileSync( imageTmpPath, Buffer.from(await (await fetch(image)).arrayBuffer()), { encoding: 'binary' }, ); for (const publisher of process.argv[3].split(',')) { if (isPublisher(publisher)) { await publish(publisher, message, locale); } } } catch (error) { console.error(error); Sentry.captureException(error); } } })();