Bluesky bot

This commit is contained in:
Jordan Eldredge 2025-10-10 20:25:57 -07:00
parent 3c882550e3
commit e5ed88c8ec
7 changed files with 330 additions and 6 deletions

View file

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

View file

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

View file

@ -144,6 +144,17 @@ export async function markAsPostedToMastodon(
);
}
export async function markAsPostedToBlueSky(
md5: string,
postId: string,
url: string
): Promise<void> {
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<void> {
const index = { objectID: md5, nsfw: true };
@ -550,6 +561,31 @@ export async function getSkinToPostToMastodon(): Promise<string | null> {
return skin.md5;
}
export async function getSkinToPostToBluesky(): Promise<string | null> {
// 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<number> {
const rows = await knex("skins")
.where({ skin_type: 1 })

View file

@ -0,0 +1,16 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<any> {
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<any> {
await knex.raw(`DROP TABLE "bluesky_posts"`);
}

View file

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

View file

@ -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<void> {
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<string> {
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<AppBskyFeedPost.Record> {
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<BlobRef> {
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;
}

90
pnpm-lock.yaml generated
View file

@ -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: {}