Add Mastodon bot

This commit is contained in:
Jordan Eldredge 2022-11-10 20:45:09 -08:00
parent 6ff9114fb1
commit 701b094e2c
9 changed files with 1346 additions and 63 deletions

View file

@ -8,6 +8,7 @@ import * as Skins from "./data/skins";
import Discord from "discord.js";
import { tweet } from "./tasks/tweet";
import { insta } from "./tasks/insta";
import { postToMastodon } from "./tasks/mastodon";
import md5Buffer from "md5";
import { addSkinFromBuffer } from "./addSkin";
import { scrapeLikeData } from "./tasks/scrapeLikes";
@ -73,13 +74,14 @@ program
.command("share")
.description(
"Share a skin on Twitter and Instagram. If no md5 is " +
"given, random approved skins are shared."
"given, random approved skins are shared."
)
.argument("[md5]", "md5 of the skin to share")
.option("-t, --twitter", "Share on Twitter")
.option("-i, --instagram", "Share on Instagram")
.action(async (md5, { twitter, instagram }) => {
if (!twitter && !instagram) {
.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");
}
await withDiscordClient(async (client) => {
@ -89,6 +91,9 @@ program
if (instagram) {
await insta(client, md5);
}
if (mastodon) {
await postToMastodon(client, md5);
}
});
});
@ -102,7 +107,7 @@ program
.option(
"--delete",
"Delete a skin from the database, including its S3 files " +
"CloudFlare cache and seach index entries."
"CloudFlare cache and seach index entries."
)
.option(
"--hide",
@ -111,7 +116,7 @@ program
.option(
"--delete-local",
"Delete a skin from the database only, NOT including its S3 files " +
"CloudFlare cache and seach index entries."
"CloudFlare cache and seach index entries."
)
.option("--index", "Update the seach index for a skin.")
.option(
@ -190,18 +195,18 @@ program
.option(
"--fetch-metadata <count>",
"Fetch missing metadata for <count> items from the Internet " +
"Archive. Currently it only fetches missing metadata. In the " +
"future it could refresh stale metadata."
"Archive. Currently it only fetches missing metadata. In the " +
"future it could refresh stale metadata."
)
.option(
"--fetch-items",
"Seach the Internet Archive for items that we don't know about" +
"and add them to our database."
"and add them to our database."
)
.option(
"--update-metadata <count>",
"Find <count> items in our database that have incorrect or incomplete " +
"metadata, and update the Internet Archive"
"metadata, and update the Internet Archive"
)
.option(
"--upload-new",
@ -235,7 +240,7 @@ program
.command("stats")
.description(
"Report information about skins in the database. " +
"Identical to `!stats` in Discord."
"Identical to `!stats` in Discord."
)
.action(async () => {
console.table([await Skins.getStats()]);
@ -281,17 +286,17 @@ program
.option(
"--likes",
"Scrape @winampskins tweets for like and retweet counts, " +
"and update the database."
"and update the database."
)
.option(
"--milestones",
"Check the most recent @winampskins tweets to see if they have " +
"passed a milestone. If so, notify the Discord channel."
"passed a milestone. If so, notify the Discord channel."
)
.option(
"--followers",
"Check if @winampskins has passed a follower count milestone. " +
"If so, notify the Discord channel."
"If so, notify the Discord channel."
)
.action(async ({ likes, milestones, followers }) => {
if (likes) {
@ -319,7 +324,7 @@ program
.option(
"--upload-ia-screenshot <md5>",
"Upload a screenshot of a skin to the skin's Internet Archive itme. " +
"[[Warning!]] This might result in multiple screenshots on the item."
"[[Warning!]] This might result in multiple screenshots on the item."
)
.option(
"--upload-missing-screenshots",

View file

@ -23,6 +23,7 @@ export const TWITTER_CREDS = {
accessToken: env("TWITTER_ACCESS_TOKEN"),
accessTokenSecret: env("TWITTER_ACCESS_TOKEN_SECRET"),
};
export const MASTODON_ACCESS_TOKEN = env("MASTODON_ACCESS_TOKEN");
export const INSTAGRAM_ACCESS_TOKEN = env("INSTAGRAM_ACCESS_TOKEN");
export const INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID");
// Used for session encryption

View file

@ -27,7 +27,7 @@ export const IS_NOT_README =
/(genex\.txt)|(genexinfo\.txt)|(gen_gslyrics\.txt)|(region\.txt)|(pledit\.txt)|(viscolor\.txt)|(winampmb\.txt)|("gen_ex help\.txt)|(mbinner\.txt)$/i;
export default class SkinModel {
constructor(readonly ctx: UserContext, readonly row: SkinRow) {}
constructor(readonly ctx: UserContext, readonly row: SkinRow) { }
static async fromMd5(
ctx: UserContext,
@ -370,9 +370,9 @@ export default class SkinModel {
);
}
async withScreenshotTempFile(
cb: (file: string) => Promise<void>
): Promise<void> {
async withScreenshotTempFile<T>(
cb: (file: string) => Promise<T>
): Promise<T> {
if (this.getSkinType() === "MODERN") {
throw new Error("Modern skins do not have screenshots.");
}

View file

@ -131,6 +131,17 @@ export async function markAsPostedToInstagram(
);
}
export async function markAsPostedToMastodon(
md5: string,
postId: string,
url: string
): Promise<void> {
await knex("mastodon_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 };
@ -489,6 +500,28 @@ export async function getSkinToPostToInstagram(): Promise<string | null> {
return skin.md5;
}
export async function getSkinToPostToMastodon(): Promise<string | null> {
// TODO: This does not account for skins that have been both approved and rejected
const tweetables = await knex("skins")
.leftJoin("skin_reviews", "skin_reviews.skin_md5", "=", "skins.md5")
.leftJoin("mastodon_posts", "mastodon_posts.skin_md5", "=", "skins.md5")
.leftJoin("refreshes", "refreshes.skin_md5", "=", "skins.md5")
.where({
"mastodon_posts.id": null,
skin_type: 1,
"skin_reviews.review": "APPROVED",
"refreshes.error": null,
})
.groupBy("skins.md5")
.orderByRaw("random()")
.limit(1);
const skin = tweetables[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 "mastodon_posts" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
"skin_md5" TEXT NOT NULL,
post_id text TEXT NOT_NULL UNIQUE,
url text TEXT NOT_NULL UNIQUE
);`
);
}
export async function down(knex: Knex): Promise<any> {
await knex.raw(`DROP TABLE "mastodon_posts"`);
}

View file

@ -32,6 +32,7 @@
"imagemin-optipng": "^7.0.0",
"knex": "^0.21.1",
"lru-cache": "^6.0.0",
"mastodon-api": "^1.3.0",
"md5": "^2.2.1",
"node-fetch": "^2.6.7",
"polygon-clipping": "^0.15.3",

View file

@ -0,0 +1,87 @@
import * as Skins from "../data/skins";
import Mastodon from "mastodon-api";
import { TWEET_BOT_CHANNEL_ID, MASTODON_ACCESS_TOKEN } 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";
export async function postToMastodon(
discordClient: Client,
md5: string | null
): Promise<void> {
if (md5 == null) {
md5 = await Skins.getSkinToPostToMastodon();
}
if (md5 == null) {
console.error("No skins to post to mastodon");
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\n${url}`; // TODO: Should we add hashtags?
const API_URL = "https://botsin.space/api/v1/";
// Does file need to be a readStream?
const M = new Mastodon({
access_token: MASTODON_ACCESS_TOKEN,
timeout_ms: 60 * 1000, // optional HTTP request timeout to apply to all requests.
api_url: API_URL, // optional, defaults to https://mastodon.social/api/v1/
});
const { resp, data } = await withBufferAsTempFile(
image,
screenshotFileName,
async (filePath) => {
return M.post("media", {
file: fs.createReadStream(filePath),
focus: "0,1",
});
}
);
if (resp.statusCode != 200) {
console.log(JSON.stringify(resp, null, 2));
console.log("data", data);
throw new Error(
`Failed to upload media. Got status code ${resp.statusCode}`
);
}
const result = await M.post("statuses", { status, media_ids: [data.id] });
const postId = result.data.id;
const postUrl = result.data.url;
await Skins.markAsPostedToMastodon(md5, postId, postUrl);
// return permalink;
return postUrl;
}

View file

@ -53,20 +53,29 @@ export function throwAfter(message, ms) {
});
}
export async function withUrlAsTempFile(
export async function withUrlAsTempFile<T>(
url: string,
filename: string,
cb: (file: string) => Promise<void>
): Promise<void> {
cb: (file: string) => Promise<T>
): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}.`);
}
const result = await response.buffer();
return withBufferAsTempFile(result, filename, cb);
}
export async function withBufferAsTempFile<T>(
buffer: Buffer,
filename: string,
cb: (file: string) => Promise<T>
): Promise<T> {
const tempDir = temp.mkdirSync();
const tempFile = path.join(tempDir, filename);
fs.writeFileSync(tempFile, result);
await cb(tempFile);
fs.writeFileSync(tempFile, buffer);
const r = await cb(tempFile);
fs.unlinkSync(tempFile);
fs.rmdirSync(tempDir);
return r;
}

1211
yarn.lock

File diff suppressed because it is too large Load diff