Skip to content

Commit e5ed88c

Browse files
committed
Bluesky bot
1 parent 3c88255 commit e5ed88c

File tree

7 files changed

+330
-6
lines changed

7 files changed

+330
-6
lines changed

packages/skin-database/cli.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { setHashesForSkin } from "./skinHash";
3535
import * as S3 from "./s3";
3636
import { generateDescription } from "./services/openAi";
3737
import KeyValue from "./data/KeyValue";
38+
import { postToBluesky } from "./tasks/bluesky";
3839

3940
async function withHandler(
4041
cb: (handler: DiscordEventHandler) => Promise<void>
@@ -81,21 +82,30 @@ program
8182
.argument("[md5]", "md5 of the skin to share")
8283
.option("-t, --twitter", "Share on Twitter")
8384
.option("-i, --instagram", "Share on Instagram")
85+
.option("-b, --bluesky", "Share on Bluesky")
8486
.option("-m, --mastodon", "Share on Mastodon")
85-
.action(async (md5, { twitter, instagram, mastodon }) => {
86-
if (!twitter && !instagram && !mastodon) {
87-
throw new Error("Expected at least one of --twitter or --instagram");
88-
}
87+
.action(async (md5, { twitter, instagram, mastodon, bluesky }) => {
8988
await withDiscordClient(async (client) => {
9089
if (twitter) {
9190
await tweet(client, md5);
91+
return;
9292
}
9393
if (instagram) {
9494
await insta(client, md5);
95+
return;
9596
}
9697
if (mastodon) {
9798
await postToMastodon(client, md5);
99+
return;
100+
}
101+
if (bluesky) {
102+
await postToBluesky(client, md5);
103+
return;
98104
}
105+
106+
throw new Error(
107+
"Expected at least one of --twitter, --instagram, --mastodon, --bluesky"
108+
);
99109
});
100110
});
101111

packages/skin-database/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID");
2929
// Used for session encryption
3030
export const SECRET = env("SECRET");
3131
export const NODE_ENV = env("NODE_ENV") || "production";
32+
export const BLUESKY_PASSWORD = env("BLUESKY_PASSWORD");
33+
export const BLUESKY_USERNAME = env("BLUESKY_USERNAME");
3234

3335
function env(key: string): string {
3436
const value = process.env[key];

packages/skin-database/data/skins.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,17 @@ export async function markAsPostedToMastodon(
144144
);
145145
}
146146

147+
export async function markAsPostedToBlueSky(
148+
md5: string,
149+
postId: string,
150+
url: string
151+
): Promise<void> {
152+
await knex("bluesky_posts").insert(
153+
{ skin_md5: md5, post_id: postId, url },
154+
[]
155+
);
156+
}
157+
147158
// TODO: Also path actor
148159
export async function markAsNSFW(ctx: UserContext, md5: string): Promise<void> {
149160
const index = { objectID: md5, nsfw: true };
@@ -550,6 +561,31 @@ export async function getSkinToPostToMastodon(): Promise<string | null> {
550561
return skin.md5;
551562
}
552563

564+
export async function getSkinToPostToBluesky(): Promise<string | null> {
565+
// TODO: This does not account for skins that have been both approved and rejected
566+
const postables = await knex("skins")
567+
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
568+
.leftJoin("bluesky_posts", "bluesky_posts.skin_md5", "=", "skins.md5")
569+
.leftJoin("tweets", "tweets.skin_md5", "=", "skins.md5")
570+
.leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5")
571+
.where({
572+
"bluesky_posts.id": null,
573+
skin_type: 1,
574+
"skin_reviews.review": "APPROVED",
575+
"refreshes.error": null,
576+
})
577+
.where("likes", ">", 10)
578+
.groupBy("skins.md5")
579+
.orderByRaw("random()")
580+
.limit(1);
581+
582+
const skin = postables[0];
583+
if (skin == null) {
584+
return null;
585+
}
586+
return skin.md5;
587+
}
588+
553589
export async function getUnreviewedSkinCount(): Promise<number> {
554590
const rows = await knex("skins")
555591
.where({ skin_type: 1 })
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as Knex from "knex";
2+
3+
export async function up(knex: Knex): Promise<any> {
4+
await knex.raw(
5+
`CREATE TABLE "bluesky_posts" (
6+
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
7+
"skin_md5" TEXT NOT NULL,
8+
post_id text NOT NULL UNIQUE,
9+
url text NOT NULL UNIQUE
10+
);`
11+
);
12+
}
13+
14+
export async function down(knex: Knex): Promise<any> {
15+
await knex.raw(`DROP TABLE "bluesky_posts"`);
16+
}

packages/skin-database/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"main": "index.js",
55
"license": "MIT",
66
"dependencies": {
7+
"@atproto/api": "^0.17.2",
78
"@next/third-parties": "^15.3.3",
89
"@sentry/node": "^5.27.3",
910
"@sentry/tracing": "^5.27.3",
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import * as Skins from "../data/skins";
2+
import {
3+
AppBskyEmbedImages,
4+
AppBskyFeedPost,
5+
AtpAgent,
6+
BlobRef,
7+
RichText,
8+
} from "@atproto/api";
9+
import {
10+
TWEET_BOT_CHANNEL_ID,
11+
BLUESKY_USERNAME,
12+
BLUESKY_PASSWORD,
13+
} from "../config";
14+
import { Client } from "discord.js";
15+
import sharp from "sharp";
16+
import SkinModel from "../data/SkinModel";
17+
import UserContext from "../data/UserContext";
18+
import { withBufferAsTempFile } from "../utils";
19+
import fs from "fs";
20+
21+
const agent = new AtpAgent({ service: "https://bsky.social" });
22+
23+
export async function postToBluesky(
24+
discordClient: Client,
25+
md5: string | null
26+
): Promise<void> {
27+
if (md5 == null) {
28+
md5 = await Skins.getSkinToPostToBluesky();
29+
}
30+
if (md5 == null) {
31+
console.error("No skins to post to Bluesky");
32+
return;
33+
}
34+
const url = await post(md5);
35+
36+
console.log("Going to post to discord");
37+
const tweetBotChannel = await discordClient.channels.fetch(
38+
TWEET_BOT_CHANNEL_ID
39+
);
40+
// @ts-ignore
41+
await tweetBotChannel.send(url);
42+
console.log("Posted to discord");
43+
}
44+
45+
async function post(md5: string): Promise<string> {
46+
const ctx = new UserContext();
47+
const skin = await SkinModel.fromMd5Assert(ctx, md5);
48+
const screenshot = await Skins.getScreenshotBuffer(md5);
49+
const { width, height } = await sharp(screenshot).metadata();
50+
51+
const image = await sharp(screenshot)
52+
.resize(width * 2, height * 2, {
53+
kernel: sharp.kernel.nearest,
54+
})
55+
.toBuffer();
56+
57+
const name = await skin.getFileName();
58+
const url = skin.getMuseumUrl();
59+
const screenshotFileName = await skin.getScreenshotFileName();
60+
61+
const status = `${name}\n`; // TODO: Should we add hashtags?
62+
63+
await agent.login({
64+
identifier: BLUESKY_USERNAME!,
65+
password: BLUESKY_PASSWORD!,
66+
});
67+
68+
const blob = await withBufferAsTempFile(
69+
image,
70+
screenshotFileName,
71+
async (filePath) => {
72+
return uploadImageFromFilePath(agent, filePath);
73+
}
74+
);
75+
76+
const postData = await buildPost(
77+
agent,
78+
status,
79+
buildImageEmbed(blob, width * 2, height * 2)
80+
);
81+
const postResp = await agent.post(postData);
82+
console.log(postResp);
83+
84+
const postId = postResp.cid;
85+
const postUrl = postResp.uri;
86+
87+
await Skins.markAsPostedToBlueSky(md5, postId, postUrl);
88+
89+
const prefix = "Try on the ";
90+
const suffix = "Winamp Skin Museum";
91+
92+
agent.post({
93+
text: prefix + suffix,
94+
createdAt: new Date().toISOString(),
95+
facets: [
96+
{
97+
$type: "app.bsky.richtext.facet",
98+
index: {
99+
byteStart: prefix.length,
100+
byteEnd: prefix.length + suffix.length,
101+
},
102+
features: [
103+
{
104+
$type: "app.bsky.richtext.facet#link",
105+
uri: url,
106+
},
107+
],
108+
},
109+
],
110+
reply: {
111+
root: postResp,
112+
parent: postResp,
113+
},
114+
$type: "app.bsky.feed.post",
115+
});
116+
117+
// return permalink;
118+
return postUrl;
119+
}
120+
121+
/** Build the embed data for an image. */
122+
function buildImageEmbed(
123+
imgBlob: BlobRef,
124+
width: number,
125+
height: number
126+
): AppBskyEmbedImages.Main {
127+
const image = {
128+
image: imgBlob,
129+
aspectRatio: { width, height },
130+
alt: "",
131+
};
132+
return {
133+
$type: "app.bsky.embed.images",
134+
images: [image],
135+
};
136+
}
137+
138+
/** Build the post data for an image. */
139+
async function buildPost(
140+
agent: AtpAgent,
141+
rawText: string,
142+
imageEmbed: AppBskyEmbedImages.Main
143+
): Promise<AppBskyFeedPost.Record> {
144+
const rt = new RichText({ text: rawText });
145+
await rt.detectFacets(agent);
146+
const { text, facets } = rt;
147+
return {
148+
text,
149+
facets,
150+
$type: "app.bsky.feed.post",
151+
createdAt: new Date().toISOString(),
152+
embed: {
153+
$type: "app.bsky.embed.recordWithMedia",
154+
...imageEmbed,
155+
},
156+
};
157+
}
158+
159+
/** Upload an image from a URL to Bluesky. */
160+
async function uploadImageFromFilePath(
161+
agent: AtpAgent,
162+
filePath: string
163+
): Promise<BlobRef> {
164+
const imageBuff = fs.readFileSync(filePath);
165+
const imgU8 = new Uint8Array(imageBuff);
166+
167+
const dstResp = await agent.uploadBlob(imgU8);
168+
if (!dstResp.success) {
169+
console.log(dstResp);
170+
throw new Error("Failed to upload image");
171+
}
172+
return dstResp.data.blob;
173+
}

0 commit comments

Comments
 (0)