diff --git a/app/discord/activityTracker.ts b/app/discord/activityTracker.ts index 5b0cde5..0c07829 100644 --- a/app/discord/activityTracker.ts +++ b/app/discord/activityTracker.ts @@ -1,21 +1,9 @@ import { Events, ChannelType } from "discord.js"; -import type { Client, Message, PartialMessage, TextChannel } from "discord.js"; +import type { Client, Message, TextChannel } from "discord.js"; import db from "#~/db.server"; -import { - parseMarkdownBlocks, - getChars, - getWords, -} from "#~/helpers/messageParsing"; -import { partition } from "lodash-es"; import { log, trackPerformance } from "#~/helpers/observability"; import { threadStats } from "#~/helpers/metrics"; - -export type CodeStats = { - chars: number; - words: number; - lines: number; - lang: string | undefined; -}; +import { getMessageStats } from "#~/helpers/discord.js"; export async function startActivityTracking(client: Client) { log("info", "ActivityTracker", "Starting activity tracking", { @@ -100,6 +88,8 @@ export async function startActivityTracking(client: Client) { .insertInto("message_stats") .values({ ...info, + code_stats: JSON.stringify(info.code_stats), + link_stats: JSON.stringify(info.link_stats), message_id: msg.id, author_id: msg.author.id, guild_id: msg.guildId, @@ -116,8 +106,8 @@ export async function startActivityTracking(client: Client) { channelId: msg.channelId, charCount: info.char_count, wordCount: info.word_count, - hasCode: info.code_stats !== "[]", - hasLinks: info.link_stats !== "[]", + hasCode: info.code_stats.length > 0, + hasLinks: info.link_stats.length > 0, }); // Track message in business analytics @@ -131,7 +121,13 @@ export async function startActivityTracking(client: Client) { const info = await getMessageStats(msg); if (!info) return; - await updateStatsById(msg.id).set(info).execute(); + await updateStatsById(msg.id) + .set({ + ...info, + code_stats: JSON.stringify(info.code_stats), + link_stats: JSON.stringify(info.link_stats), + }) + .execute(); log("debug", "ActivityTracker", "Message stats updated", { messageId: msg.id, @@ -206,75 +202,6 @@ function updateStatsById(id: string) { return db.updateTable("message_stats").where("message_id", "=", id); } -async function getMessageStats(msg: Message | PartialMessage) { - return trackPerformance( - "startActivityTracking: getMessageStats", - async () => { - const { content } = await msg.fetch(); - - const blocks = parseMarkdownBlocks(content); - - // TODO: groupBy would be better here, but this was easier to keep typesafe - const [textblocks, nontextblocks] = partition( - blocks, - (b) => b.type === "text", - ); - const [links, codeblocks] = partition( - nontextblocks, - (b) => b.type === "link", - ); - - const linkStats = links.map((link) => link.url); - - const { wordCount, charCount } = [...links, ...textblocks].reduce( - (acc, block) => { - const content = - block.type === "link" ? (block.label ?? "") : block.content; - const words = getWords(content).length; - const chars = getChars(content).length; - return { - wordCount: acc.wordCount + words, - charCount: acc.charCount + chars, - }; - }, - { wordCount: 0, charCount: 0 }, - ); - - const codeStats = codeblocks.map((block): CodeStats => { - switch (block.type) { - case "fencedcode": { - const content = block.code.join("\n"); - return { - chars: getChars(content).length, - words: getWords(content).length, - lines: block.code.length, - lang: block.lang, - }; - } - case "inlinecode": { - return { - chars: getChars(block.code).length, - words: getWords(block.code).length, - lines: 1, - lang: undefined, - }; - } - } - }); - - const values = { - char_count: charCount, - word_count: wordCount, - code_stats: JSON.stringify(codeStats), - link_stats: JSON.stringify(linkStats), - react_count: msg.reactions.cache.size, - sent_at: msg.createdTimestamp, - }; - return values; - }, - ); -} - export async function reportByGuild(guildId: string) { return trackPerformance( "reportByGuild", diff --git a/app/helpers/discord.ts b/app/helpers/discord.ts index 9cfbe9e..81b1cd8 100644 --- a/app/helpers/discord.ts +++ b/app/helpers/discord.ts @@ -1,3 +1,9 @@ +import { + parseMarkdownBlocks, + getChars, + getWords, +} from "#~/helpers/messageParsing"; +import { partition } from "lodash-es"; import type { Message, GuildMember, @@ -21,6 +27,7 @@ import { SlashCommandBuilder, } from "discord.js"; import prettyBytes from "pretty-bytes"; +import { trackPerformance } from "./observability"; const staffRoles = ["mvp", "moderator", "admin", "admins"]; const helpfulRoles = ["mvp", "star helper"]; @@ -197,3 +204,83 @@ export type ModalCommand = { export const isModalCommand = (config: AnyCommand): config is ModalCommand => "type" in config.command && config.command.type === InteractionType.ModalSubmit; + +type CodeStats = { + chars: number; + words: number; + lines: number; + lang: string | undefined; +}; +/** + * getMessageStats is a helper to retrieve common metrics from a message + * @param msg A Discord Message or PartialMessage object + * @returns { chars: number; words: number; lines: number; lang?: string } + */ +export async function getMessageStats(msg: Message | PartialMessage) { + return trackPerformance( + "startActivityTracking: getMessageStats", + async () => { + const { content } = await msg.fetch(); + + const blocks = parseMarkdownBlocks(content); + + // TODO: groupBy would be better here, but this was easier to keep typesafe + const [textblocks, nontextblocks] = partition( + blocks, + (b) => b.type === "text", + ); + const [links, codeblocks] = partition( + nontextblocks, + (b) => b.type === "link", + ); + + const linkStats = links.map((link) => link.url); + + const { wordCount, charCount } = [...links, ...textblocks].reduce( + (acc, block) => { + const content = + block.type === "link" ? (block.label ?? "") : block.content; + const words = getWords(content).length; + const chars = getChars(content).length; + return { + wordCount: acc.wordCount + words, + charCount: acc.charCount + chars, + }; + }, + { wordCount: 0, charCount: 0 }, + ); + + const codeStats = codeblocks.map((block): CodeStats => { + switch (block.type) { + case "fencedcode": { + const content = block.code.join("\n"); + return { + chars: getChars(content).length, + words: getWords(content).length, + lines: block.code.length, + lang: block.lang, + }; + } + case "inlinecode": { + return { + chars: getChars(block.code).length, + words: getWords(block.code).length, + lines: 1, + lang: undefined, + }; + } + } + }); + + const values = { + char_count: charCount, + word_count: wordCount, + code_stats: codeStats, + link_stats: linkStats, + react_count: msg.reactions.cache.size, + sent_at: msg.createdTimestamp, + }; + return values; + }, + ); +} diff --git a/app/helpers/modLog.ts b/app/helpers/modLog.ts index 2b1c2da..5ffdcf9 100644 --- a/app/helpers/modLog.ts +++ b/app/helpers/modLog.ts @@ -25,6 +25,7 @@ import { constructDiscordLink, describeAttachments, describeReactions, + getMessageStats, quoteAndEscape, quoteAndEscapePoll, } from "#~/helpers/discord"; @@ -199,23 +200,45 @@ export const reportUser = async ({ staff, }); + // If it has the data for a poll, use a specialized formatting function + const reportedMessage = message.poll + ? quoteAndEscapePoll(message.poll) + : quoteAndEscape(message.content).trim(); // Send the detailed log message to thread - const logMessage = await thread.send(logBody); - logMessage.forward(modLog); + const [logMessage] = await Promise.all([ + thread.send(logBody), + thread.send(reportedMessage), + ]); // Try to record the report in database - const recordResult = await recordReport({ - reportedMessageId: message.id, - reportedChannelId: message.channel.id, - reportedUserId: message.author.id, - guildId: guild.id, - logMessageId: logMessage.id, - logChannelId: thread.id, - reason, - staffId: staff ? staff.id : undefined, - staffUsername: staff ? staff.username : undefined, - extra, - }); + const [recordResult] = await Promise.all([ + recordReport({ + reportedMessageId: message.id, + reportedChannelId: message.channel.id, + reportedUserId: message.author.id, + guildId: guild.id, + logMessageId: logMessage.id, + logChannelId: thread.id, + reason, + staffId: staff ? staff.id : undefined, + staffUsername: staff ? staff.username : undefined, + extra, + }), + logMessage.forward(modLog), + ]); + if (thread.parent?.isSendable()) { + const singleLine = message.cleanContent + .slice(0, 50) + .replaceAll("\n", "\\n"); + const truncatedMessage = + message.cleanContent.length > 50 + ? `${singleLine.slice(0, 50)}…` + : singleLine; + const stats = await getMessageStats(message); + await thread.parent.send( + `> ${truncatedMessage}\n-# ${stats.char_count} chars in ${stats.word_count} words. ${stats.link_stats.length} links, ${stats.code_stats.reduce((count, { lines }) => count + lines, 0)} lines of code`, + ); + } // If the record was not inserted due to unique constraint (duplicate), // this means another process already reported the same message while we were preparing the log. @@ -283,27 +306,21 @@ const constructLog = async ({ throw new Error("No role configured to be used as moderator"); } - const preface = `<@${lastReport.message.author.id}> (${ - lastReport.message.author.username - }) posted ${formatDistanceToNowStrict(lastReport.message.createdAt)} before this log ()`; - const extra = origExtra ? `${origExtra}\n` : ""; - - // If it has the data for a poll, use a specialized formatting function - const reportedMessage = message.poll - ? quoteAndEscapePoll(message.poll) - : quoteAndEscape(message.content).trim(); - const { content: report, embeds: reactions = [] } = makeReportMessage(lastReport); + const preface = `${report} ${constructDiscordLink(message)} by <@${lastReport.message.author.id}> (${ + lastReport.message.author.username + })`; + const extra = origExtra ? `${origExtra}\n` : ""; + const embeds = [ describeAttachments(message.attachments), ...reactions, ].filter((e): e is APIEmbed => Boolean(e)); return { content: truncateMessage(`${preface} -${extra}${reportedMessage} -${report} · ${constructDiscordLink(message)}`).trim(), +-# ${extra}${formatDistanceToNowStrict(lastReport.message.createdAt)} · ago`).trim(), embeds: embeds.length === 0 ? undefined : embeds, allowedMentions: { roles: [moderator] }, };