From e5ed88c8ec0d245a16be8727bc66a1911347296a Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 10 Oct 2025 20:25:57 -0700 Subject: [PATCH] Bluesky bot --- packages/skin-database/cli.ts | 18 +- packages/skin-database/config.ts | 2 + packages/skin-database/data/skins.ts | 36 ++++ .../20251010000000_bluesky_posts.ts | 16 ++ packages/skin-database/package.json | 1 + packages/skin-database/tasks/bluesky.ts | 173 ++++++++++++++++++ pnpm-lock.yaml | 90 ++++++++- 7 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 packages/skin-database/migrations/20251010000000_bluesky_posts.ts create mode 100644 packages/skin-database/tasks/bluesky.ts diff --git a/packages/skin-database/cli.ts b/packages/skin-database/cli.ts index b381a87a..50bb067d 100755 --- a/packages/skin-database/cli.ts +++ b/packages/skin-database/cli.ts @@ -35,6 +35,7 @@ import { setHashesForSkin } from "./skinHash"; import * as S3 from "./s3"; import { generateDescription } from "./services/openAi"; import KeyValue from "./data/KeyValue"; +import { postToBluesky } from "./tasks/bluesky"; async function withHandler( cb: (handler: DiscordEventHandler) => Promise @@ -81,21 +82,30 @@ program .argument("[md5]", "md5 of the skin to share") .option("-t, --twitter", "Share on Twitter") .option("-i, --instagram", "Share on Instagram") + .option("-b, --bluesky", "Share on Bluesky") .option("-m, --mastodon", "Share on Mastodon") - .action(async (md5, { twitter, instagram, mastodon }) => { - if (!twitter && !instagram && !mastodon) { - throw new Error("Expected at least one of --twitter or --instagram"); - } + .action(async (md5, { twitter, instagram, mastodon, bluesky }) => { await withDiscordClient(async (client) => { if (twitter) { await tweet(client, md5); + return; } if (instagram) { await insta(client, md5); + return; } if (mastodon) { await postToMastodon(client, md5); + return; } + if (bluesky) { + await postToBluesky(client, md5); + return; + } + + throw new Error( + "Expected at least one of --twitter, --instagram, --mastodon, --bluesky" + ); }); }); diff --git a/packages/skin-database/config.ts b/packages/skin-database/config.ts index bd02ce02..969d1586 100644 --- a/packages/skin-database/config.ts +++ b/packages/skin-database/config.ts @@ -29,6 +29,8 @@ export const INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID"); // Used for session encryption export const SECRET = env("SECRET"); export const NODE_ENV = env("NODE_ENV") || "production"; +export const BLUESKY_PASSWORD = env("BLUESKY_PASSWORD"); +export const BLUESKY_USERNAME = env("BLUESKY_USERNAME"); function env(key: string): string { const value = process.env[key]; diff --git a/packages/skin-database/data/skins.ts b/packages/skin-database/data/skins.ts index c8be20df..6d48f701 100644 --- a/packages/skin-database/data/skins.ts +++ b/packages/skin-database/data/skins.ts @@ -144,6 +144,17 @@ export async function markAsPostedToMastodon( ); } +export async function markAsPostedToBlueSky( + md5: string, + postId: string, + url: string +): Promise { + await knex("bluesky_posts").insert( + { skin_md5: md5, post_id: postId, url }, + [] + ); +} + // TODO: Also path actor export async function markAsNSFW(ctx: UserContext, md5: string): Promise { const index = { objectID: md5, nsfw: true }; @@ -550,6 +561,31 @@ export async function getSkinToPostToMastodon(): Promise { return skin.md5; } +export async function getSkinToPostToBluesky(): Promise { + // TODO: This does not account for skins that have been both approved and rejected + const postables = await knex("skins") + .leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5") + .leftJoin("bluesky_posts", "bluesky_posts.skin_md5", "=", "skins.md5") + .leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5") + .leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5") + .where({ + "bluesky_posts.id": null, + skin_type: 1, + "skin_reviews.review": "APPROVED", + "refreshes.error": null, + }) + .where("likes", ">", 10) + .groupBy("skins.md5") + .orderByRaw("random()") + .limit(1); + + const skin = postables[0]; + if (skin == null) { + return null; + } + return skin.md5; +} + export async function getUnreviewedSkinCount(): Promise { const rows = await knex("skins") .where({ skin_type: 1 }) diff --git a/packages/skin-database/migrations/20251010000000_bluesky_posts.ts b/packages/skin-database/migrations/20251010000000_bluesky_posts.ts new file mode 100644 index 00000000..d3944268 --- /dev/null +++ b/packages/skin-database/migrations/20251010000000_bluesky_posts.ts @@ -0,0 +1,16 @@ +import * as Knex from "knex"; + +export async function up(knex: Knex): Promise { + await knex.raw( + `CREATE TABLE "bluesky_posts" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE, + "skin_md5" TEXT NOT NULL, + post_id text NOT NULL UNIQUE, + url text NOT NULL UNIQUE + );` + ); +} + +export async function down(knex: Knex): Promise { + await knex.raw(`DROP TABLE "bluesky_posts"`); +} diff --git a/packages/skin-database/package.json b/packages/skin-database/package.json index 1e9ca15c..7bdf7805 100644 --- a/packages/skin-database/package.json +++ b/packages/skin-database/package.json @@ -4,6 +4,7 @@ "main": "index.js", "license": "MIT", "dependencies": { + "@atproto/api": "^0.17.2", "@next/third-parties": "^15.3.3", "@sentry/node": "^5.27.3", "@sentry/tracing": "^5.27.3", diff --git a/packages/skin-database/tasks/bluesky.ts b/packages/skin-database/tasks/bluesky.ts new file mode 100644 index 00000000..6e7d9c14 --- /dev/null +++ b/packages/skin-database/tasks/bluesky.ts @@ -0,0 +1,173 @@ +import * as Skins from "../data/skins"; +import { + AppBskyEmbedImages, + AppBskyFeedPost, + AtpAgent, + BlobRef, + RichText, +} from "@atproto/api"; +import { + TWEET_BOT_CHANNEL_ID, + BLUESKY_USERNAME, + BLUESKY_PASSWORD, +} from "../config"; +import { Client } from "discord.js"; +import sharp from "sharp"; +import SkinModel from "../data/SkinModel"; +import UserContext from "../data/UserContext"; +import { withBufferAsTempFile } from "../utils"; +import fs from "fs"; + +const agent = new AtpAgent({ service: "https://bsky.social" }); + +export async function postToBluesky( + discordClient: Client, + md5: string | null +): Promise { + if (md5 == null) { + md5 = await Skins.getSkinToPostToBluesky(); + } + if (md5 == null) { + console.error("No skins to post to Bluesky"); + return; + } + const url = await post(md5); + + console.log("Going to post to discord"); + const tweetBotChannel = await discordClient.channels.fetch( + TWEET_BOT_CHANNEL_ID + ); + // @ts-ignore + await tweetBotChannel.send(url); + console.log("Posted to discord"); +} + +async function post(md5: string): Promise { + const ctx = new UserContext(); + const skin = await SkinModel.fromMd5Assert(ctx, md5); + const screenshot = await Skins.getScreenshotBuffer(md5); + const { width, height } = await sharp(screenshot).metadata(); + + const image = await sharp(screenshot) + .resize(width * 2, height * 2, { + kernel: sharp.kernel.nearest, + }) + .toBuffer(); + + const name = await skin.getFileName(); + const url = skin.getMuseumUrl(); + const screenshotFileName = await skin.getScreenshotFileName(); + + const status = `${name}\n`; // TODO: Should we add hashtags? + + await agent.login({ + identifier: BLUESKY_USERNAME!, + password: BLUESKY_PASSWORD!, + }); + + const blob = await withBufferAsTempFile( + image, + screenshotFileName, + async (filePath) => { + return uploadImageFromFilePath(agent, filePath); + } + ); + + const postData = await buildPost( + agent, + status, + buildImageEmbed(blob, width * 2, height * 2) + ); + const postResp = await agent.post(postData); + console.log(postResp); + + const postId = postResp.cid; + const postUrl = postResp.uri; + + await Skins.markAsPostedToBlueSky(md5, postId, postUrl); + + const prefix = "Try on the "; + const suffix = "Winamp Skin Museum"; + + agent.post({ + text: prefix + suffix, + createdAt: new Date().toISOString(), + facets: [ + { + $type: "app.bsky.richtext.facet", + index: { + byteStart: prefix.length, + byteEnd: prefix.length + suffix.length, + }, + features: [ + { + $type: "app.bsky.richtext.facet#link", + uri: url, + }, + ], + }, + ], + reply: { + root: postResp, + parent: postResp, + }, + $type: "app.bsky.feed.post", + }); + + // return permalink; + return postUrl; +} + +/** Build the embed data for an image. */ +function buildImageEmbed( + imgBlob: BlobRef, + width: number, + height: number +): AppBskyEmbedImages.Main { + const image = { + image: imgBlob, + aspectRatio: { width, height }, + alt: "", + }; + return { + $type: "app.bsky.embed.images", + images: [image], + }; +} + +/** Build the post data for an image. */ +async function buildPost( + agent: AtpAgent, + rawText: string, + imageEmbed: AppBskyEmbedImages.Main +): Promise { + const rt = new RichText({ text: rawText }); + await rt.detectFacets(agent); + const { text, facets } = rt; + return { + text, + facets, + $type: "app.bsky.feed.post", + createdAt: new Date().toISOString(), + embed: { + $type: "app.bsky.embed.recordWithMedia", + ...imageEmbed, + }, + }; +} + +/** Upload an image from a URL to Bluesky. */ +async function uploadImageFromFilePath( + agent: AtpAgent, + filePath: string +): Promise { + const imageBuff = fs.readFileSync(filePath); + const imgU8 = new Uint8Array(imageBuff); + + const dstResp = await agent.uploadBlob(imgU8); + if (!dstResp.success) { + console.log(dstResp); + throw new Error("Failed to upload image"); + } + return dstResp.data.blob; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4fc9adc..c91c9e80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: packages/skin-database: dependencies: + '@atproto/api': + specifier: ^0.17.2 + version: 0.17.2 '@next/third-parties': specifier: ^15.3.3 version: 15.3.3(next@15.3.3(@babel/core@7.27.4)(babel-plugin-macros@3.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) @@ -198,7 +201,7 @@ importers: version: 2.7.0(encoding@0.1.13) openai: specifier: ^4.68.0 - version: 4.68.0(encoding@0.1.13) + version: 4.68.0(encoding@0.1.13)(zod@3.25.76) polygon-clipping: specifier: ^0.15.3 version: 0.15.7 @@ -786,6 +789,21 @@ packages: '@assemblyscript/loader@0.17.14': resolution: {integrity: sha512-+PVTOfla/0XMLRTQLJFPg4u40XcdTfon6GGea70hBGi8Pd7ZymIXyVUR+vK8wt5Jb4MVKTKPIz43Myyebw5mZA==} + '@atproto/api@0.17.2': + resolution: {integrity: sha512-luRY9YPaRQFpm3v7a1bTOaekQ/KPCG3gb0jVyaOtfMXDSfIZJh9lr9MtmGPdEp7AvfE8urkngZ+V/p8Ial3z2g==} + + '@atproto/common-web@0.4.3': + resolution: {integrity: sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==} + + '@atproto/lexicon@0.5.1': + resolution: {integrity: sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==} + + '@atproto/syntax@0.4.1': + resolution: {integrity: sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==} + + '@atproto/xrpc@0.7.5': + resolution: {integrity: sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -4663,6 +4681,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + aws-sdk@2.1592.0: resolution: {integrity: sha512-iwmS46jOEHMNodfrpNBJ5eHwjKAY05t/xYV2cp+KyzMX2yGgt2/EtWWnlcoMGBKR31qKTsjMj5ZPouC9/VeDOA==} engines: {node: '>= 10.0.0'} @@ -8204,6 +8225,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + iso-datestring-validator@2.2.2: + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} + isobject@2.1.0: resolution: {integrity: sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==} engines: {node: '>=0.10.0'} @@ -9500,6 +9524,9 @@ packages: resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} hasBin: true + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + multipipe@0.1.2: resolution: {integrity: sha512-7ZxrUybYv9NonoXgwoOqtStIu18D1c3eFZj27hqgf5kBrBF8Q+tE8V0MW8dKM5QLkQPh1JhhbKgHLY9kifov4Q==} @@ -12514,6 +12541,10 @@ packages: tinyqueue@1.2.3: resolution: {integrity: sha512-Qz9RgWuO9l8lT+Y9xvbzhPT2efIUIFd69N7eF7tJ9lnQl0iLj1M7peK7IoUGZL9DJHw9XftqLreccfxcQgYLxA==} + tlds@1.260.0: + resolution: {integrity: sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -12827,6 +12858,9 @@ packages: resolution: {integrity: sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==} engines: {node: '>=18'} + uint8arrays@3.0.0: + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} + unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -13548,6 +13582,9 @@ packages: zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -13746,6 +13783,39 @@ snapshots: '@assemblyscript/loader@0.17.14': {} + '@atproto/api@0.17.2': + dependencies: + '@atproto/common-web': 0.4.3 + '@atproto/lexicon': 0.5.1 + '@atproto/syntax': 0.4.1 + '@atproto/xrpc': 0.7.5 + await-lock: 2.2.2 + multiformats: 9.9.0 + tlds: 1.260.0 + zod: 3.25.76 + + '@atproto/common-web@0.4.3': + dependencies: + graphemer: 1.4.0 + multiformats: 9.9.0 + uint8arrays: 3.0.0 + zod: 3.25.76 + + '@atproto/lexicon@0.5.1': + dependencies: + '@atproto/common-web': 0.4.3 + '@atproto/syntax': 0.4.1 + iso-datestring-validator: 2.2.2 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/syntax@0.4.1': {} + + '@atproto/xrpc@0.7.5': + dependencies: + '@atproto/lexicon': 0.5.1 + zod: 3.25.76 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -18518,6 +18588,8 @@ snapshots: dependencies: possible-typed-array-names: 1.0.0 + await-lock@2.2.2: {} + aws-sdk@2.1592.0: dependencies: buffer: 4.9.2 @@ -22832,6 +22904,8 @@ snapshots: isexe@2.0.0: {} + iso-datestring-validator@2.2.2: {} + isobject@2.1.0: dependencies: isarray: 1.0.0 @@ -24903,6 +24977,8 @@ snapshots: dns-packet: 5.6.1 thunky: 1.1.0 + multiformats@9.9.0: {} + multipipe@0.1.2: dependencies: duplexer2: 0.0.2 @@ -25339,7 +25415,7 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@4.68.0(encoding@0.1.13): + openai@4.68.0(encoding@0.1.13)(zod@3.25.76): dependencies: '@types/node': 18.19.56 '@types/node-fetch': 2.6.11 @@ -25348,6 +25424,8 @@ snapshots: form-data-encoder: 1.7.2 formdata-node: 4.4.1 node-fetch: 2.7.0(encoding@0.1.13) + optionalDependencies: + zod: 3.25.76 transitivePeerDependencies: - encoding @@ -28430,6 +28508,8 @@ snapshots: tinyqueue@1.2.3: {} + tlds@1.260.0: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -28759,6 +28839,10 @@ snapshots: uint8array-extras@1.4.0: {} + uint8arrays@3.0.0: + dependencies: + multiformats: 9.9.0 + unbox-primitive@1.0.2: dependencies: call-bind: 1.0.7 @@ -29668,4 +29752,6 @@ snapshots: zod@3.22.4: {} + zod@3.25.76: {} + zwitch@2.0.4: {}