Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 13 additions & 86 deletions app/discord/activityTracker.ts
Original file line number Diff line number Diff line change
@@ -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", {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions app/helpers/discord.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
parseMarkdownBlocks,
getChars,
getWords,
} from "#~/helpers/messageParsing";
import { partition } from "lodash-es";
import type {
Message,
GuildMember,
Expand All @@ -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"];
Expand Down Expand Up @@ -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;
},
);
}
69 changes: 43 additions & 26 deletions app/helpers/modLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
constructDiscordLink,
describeAttachments,
describeReactions,
getMessageStats,
quoteAndEscape,
quoteAndEscapePoll,
} from "#~/helpers/discord";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 (<t:${Math.floor(lastReport.message.createdTimestamp / 1000)}:R>)`;
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)} · <t:${Math.floor(lastReport.message.createdTimestamp / 1000)}:R> ago`).trim(),
embeds: embeds.length === 0 ? undefined : embeds,
allowedMentions: { roles: [moderator] },
};
Expand Down
Loading