webamp/packages/skin-database/api/DiscordEventHandler.ts
Jordan Eldredge 86588f56e6 Add diagnostic logging to NSFW review flow
NSFW reports posted via the website-side GraphQL mutation aren't
triggering reviews when moderators react in Discord, but the bot-command
path (!review) works. Adds structured logging to disambiguate the two
paths and pinpoint which step in the path-B (graphql) flow is failing:

- DiscordEventHandler: client lifecycle events (ready, error, disconnect,
  reconnecting, shardError), login failures, top-level
  messageReactionAdd listener so we can see whether the Next.js-side
  gateway receives reactions at all
- DiscordEventHandler.handle: entry log + try/catch so swallowed errors
  surface (notify is fire-and-forget)
- requestReview: entry, resolved tweetStatus, explicit "skipping
  already-reviewed" log
- postSkin: new optional source param so logs can distinguish the bot
  command from the graphql mutation; logs at entry, after send, on bot
  reaction success/failure, awaiting reactions, awaitReactions resolved
- loggingFilter wrapping the awaitReactions filter to log every observed
  reaction with pass/fail and any filter errors
2026-06-27 17:00:11 -07:00

251 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ApiAction } from "./types";
import Discord, { TextChannel } from "discord.js";
import * as Config from "../config";
import SkinModel from "../data/SkinModel";
import * as DiscordUtils from "../discord-bot/utils";
import UserContext from "../data/UserContext";
import logger from "../logger";
export default class DiscordEventHandler {
_clientPromise: Promise<Discord.Client>;
constructor() {
logger.info("DiscordEventHandler: constructing");
const _client = new Discord.Client();
_client.on("ready", () => {
logger.info("DiscordEventHandler: client ready", {
user: _client.user?.tag,
});
});
_client.on("error", (err: any) => {
logger.error("DiscordEventHandler: client error", {
err: err?.message,
stack: err?.stack,
});
});
_client.on("disconnect", (event: any) => {
logger.warn("DiscordEventHandler: client disconnect", { event });
});
_client.on("reconnecting", () => {
logger.warn("DiscordEventHandler: client reconnecting");
});
_client.on("shardError", (err: any) => {
logger.error("DiscordEventHandler: shard error", {
err: err?.message,
stack: err?.stack,
});
});
_client.on("messageReactionAdd", (reaction: any, user: any) => {
logger.info("DiscordEventHandler: messageReactionAdd", {
emoji: reaction?.emoji?.name,
msgId: reaction?.message?.id,
channelId: reaction?.message?.channel?.id,
userId: user?.id,
username: user?.username,
userIsBot: user?.bot,
});
});
this._clientPromise = _client
.login(Config.discordToken)
.then(() => _client)
.catch((err) => {
logger.error("DiscordEventHandler: login failed", {
err: err?.message,
stack: err?.stack,
});
throw err;
});
}
async dispose() {
const client = await this._clientPromise;
client.destroy();
}
private async getClient() {
return this._clientPromise;
}
private async getChannel(channelId: string): Promise<TextChannel> {
const client = await this.getClient();
const dest = (await client.channels.fetch(channelId)) as TextChannel | null;
if (dest == null) {
throw new Error(`Could not get channel with id: ${channelId}`);
}
return dest;
}
async handle(action: ApiAction): Promise<void> {
const actionMd5 = "md5" in action ? action.md5 : undefined;
logger.info("DiscordEventHandler.handle: entry", {
type: action.type,
md5: actionMd5,
});
try {
await this._handle(action);
} catch (err: any) {
logger.error("DiscordEventHandler.handle: failed", {
type: action.type,
md5: actionMd5,
err: err?.message,
stack: err?.stack,
});
}
}
private async _handle(action: ApiAction): Promise<void> {
const ctx = new UserContext();
switch (action.type) {
case "REVIEW_REQUESTED":
await this.requestReview(action.md5, ctx);
break;
case "APPROVED_SKIN":
await this.reportSkin(
action.md5,
ctx,
(filename) => `Approved by ${ctx.username}: ${filename}`
);
break;
case "REJECTED_SKIN":
await this.reportSkin(
action.md5,
ctx,
(filename) => `Rejected by ${ctx.username}: ${filename}`
);
break;
case "MARKED_SKIN_NSFW":
await this.reportSkin(
action.md5,
ctx,
(filename) => `Marked NSFW by ${ctx.username}: ${filename}`,
Config.NSFW_SKIN_CHANNEL_ID
);
break;
case "CLASSIC_SKIN_UPLOADED":
await this.reportSkin(
action.md5,
ctx,
(filename) => `New skin uploaded: ${filename}`,
Config.SKIN_UPLOADS_CHANNEL_ID
);
break;
case "MODERN_SKIN_UPLOADED": {
const dest = await this.getChannel(Config.SKIN_UPLOADS_CHANNEL_ID);
await dest.send(`Someone uploaded a new modern skin: ${action.md5}`);
break;
}
case "SKIN_UPLOAD_ERROR": {
const dest = await this.getChannel(Config.SKIN_UPLOADS_CHANNEL_ID);
await dest.send(
`Encountered an error processing upload ${action.uploadId}: ${action.message}`
);
break;
}
case "GOT_FEEDBACK": {
const dest = await this.getChannel(Config.FEEDBACK_SKIN_CHANNEL_ID);
const userMessage = action.message
.split("\n")
.map((line) => {
return `> ${line}`;
})
.join("\n");
let message = `Feedback:\n\n ${userMessage}`;
if (action.email != null) {
message += `\n\n--${action.email}`;
}
if (action.url != null) {
message += `\n(${action.url})`;
}
await dest.send(message);
break;
}
case "SYNCED_TO_ARCHIVE": {
const dest = await this.getChannel(Config.SKIN_UPLOADS_CHANNEL_ID);
const message = `Synced skins to archive.org. Success: ${action.successes.toLocaleString()} Errors: ${action.errors.toLocaleString()} Skipped: ${action.skips.toLocaleString()}.`;
await dest.send(message);
break;
}
case "STARTED_SYNC_TO_ARCHIVE": {
const dest = await this.getChannel(Config.SKIN_UPLOADS_CHANNEL_ID);
const message = `Starting sync to archive.org. Found ${action.count.toLocaleString()} to sync.`;
await dest.send(message);
break;
}
case "POPULAR_TWEET": {
const dest = await this.getChannel(Config.POPULAR_TWEETS_CHANNEL_ID);
const diff = Date.now() - Number(action.date);
const seconds = diff / 1000;
const minutes = seconds / 60;
const hours = Math.round(minutes / 60);
const message = `⭐️ This tweet passed **${action.bracket}** likes! (**${action.likes}** likes in **${hours}** hours)\n\n${action.url}`;
await dest.send(message);
break;
}
case "TWEET_BOT_MILESTONE": {
const dest = await this.getChannel(Config.POPULAR_TWEETS_CHANNEL_ID);
const message = `🎉 Tweet Bot Milestone! Just passed ${action.bracket.toLocaleString()} Followers 🎉`;
await dest.send(message);
break;
}
}
}
private async reportSkin(
md5: string,
ctx: UserContext,
getFilename: (filename: string) => string,
channelId = Config.SKIN_REVIEW_CHANNEL_ID
) {
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
return;
}
const dest = await this.getChannel(channelId);
await DiscordUtils.postSkin({
md5,
title: getFilename,
dest,
});
}
private async requestReview(md5: string, ctx: UserContext) {
logger.info("requestReview: entry", { md5 });
const skin = await SkinModel.fromMd5(ctx, md5);
if (skin == null) {
logger.warn("requestReview: skin not found", { md5 });
return;
}
const dest = await this.getChannel(Config.NSFW_SKIN_CHANNEL_ID);
const tweetStatus = await skin.getTweetStatus();
logger.info("requestReview: resolved", {
md5,
tweetStatus,
channelId: Config.NSFW_SKIN_CHANNEL_ID,
});
if (tweetStatus === "UNREVIEWED") {
await DiscordUtils.postSkin({
md5,
title: (filename) => `Review: ${filename}`,
dest,
source: "graphql:request_nsfw_review_for_skin",
});
} else {
logger.info("requestReview: skipping post, already reviewed", {
md5,
tweetStatus,
});
// Too much nosie
// await DiscordUtils.sendAlreadyReviewed({ md5, dest });
}
}
}