mirror of
https://github.com/captbaritone/webamp.git
synced 2026-01-24 02:36:00 +00:00
Add Mastodon bot
This commit is contained in:
parent
6ff9114fb1
commit
701b094e2c
9 changed files with 1346 additions and 63 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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"`);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
87
packages/skin-database/tasks/mastodon.ts
Normal file
87
packages/skin-database/tasks/mastodon.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue