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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,9 @@ DIGITALOCEAN_TOKEN=
SENTRY_INGEST=
SENTRY_RELEASES=

## PostHog analytics (frontend)
VITE_PUBLIC_POSTHOG_KEY=phc_…
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
## PostHog analytics (backend - can use same key as frontend)
POSTHOG_KEY=phc_…
POSTHOG_HOST=https://us.i.posthog.com
4 changes: 4 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ jobs:
STRIPE_WEBHOOK_SECRET: "${{ secrets.STRIPE_WEBHOOK_SECRET }}"
VITE_PUBLIC_POSTHOG_KEY: "${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}"
VITE_PUBLIC_POSTHOG_HOST: "${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}"
POSTHOG_KEY: "${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}"
POSTHOG_HOST: "${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}"
DATABASE_URL: "${{ secrets.DATABASE_URL }}"
EOF

Expand Down Expand Up @@ -179,6 +181,8 @@ jobs:
--from-literal=STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }} \
--from-literal=VITE_PUBLIC_POSTHOG_KEY=${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} \
--from-literal=VITE_PUBLIC_POSTHOG_HOST=${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} \
--from-literal=POSTHOG_KEY=${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} \
--from-literal=POSTHOG_HOST=${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} \
--from-literal=DATABASE_URL=/data/mod-bot.sqlite3 \
--dry-run=client -o yaml | kubectl apply -f -

Expand Down
6 changes: 6 additions & 0 deletions app/commands/setupHoneypot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {

import db from "#~/db.server.js";
import type { AnyCommand } from "#~/helpers/discord.js";
import { featureStats } from "#~/helpers/metrics";
import { log } from "#~/helpers/observability.js";

const DEFAULT_MESSAGE_TEXT =
Expand Down Expand Up @@ -65,6 +66,11 @@ export const Command = [
.execute();
if (result[0].numInsertedOrUpdatedRows ?? 0 > 0) {
await castedChannel.send(messageText);
featureStats.honeypotSetup(
interaction.guildId,
interaction.user.id,
honeypotChannel.id,
);
}

await interaction.reply({
Expand Down
8 changes: 8 additions & 0 deletions app/commands/setupReactjiChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {

import db from "#~/db.server.js";
import { type SlashCommand } from "#~/helpers/discord";
import { featureStats } from "#~/helpers/metrics";

export const Command = {
command: new SlashCommandBuilder()
Expand Down Expand Up @@ -86,6 +87,13 @@ export const Command = {
)
.execute();

featureStats.reactjiChannelSetup(
guildId,
configuredById,
emoji,
threshold,
);

const thresholdText =
threshold === 1 ? "" : ` (after ${threshold} reactions)`;
await interaction.reply({
Expand Down
20 changes: 18 additions & 2 deletions app/commands/setupTickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type ModalCommand,
type SlashCommand,
} from "#~/helpers/discord";
import { featureStats } from "#~/helpers/metrics";
import { fetchSettings, SETTINGS } from "#~/models/guilds.server";

const DEFAULT_BUTTON_TEXT = "Open a private ticket with the moderators";
Expand Down Expand Up @@ -111,6 +112,12 @@ export const Command = [
role_id: roleId,
})
.execute();

featureStats.ticketChannelSetup(
interaction.guild.id,
interaction.user.id,
ticketChannel?.id ?? interaction.channelId,
);
} catch (e) {
console.error(`error:`, e);
}
Expand Down Expand Up @@ -204,7 +211,7 @@ export const Command = [
await thread.send(`${user.displayName} said:
${quoteMessageContent(concern)}`);
await thread.send({
content: "When youve finished, please close the ticket.",
content: "When you've finished, please close the ticket.",
components: [
// @ts-expect-error Types for this are super busted
new ActionRowBuilder().addComponents(
Expand All @@ -224,6 +231,8 @@ ${quoteMessageContent(concern)}`);
],
});

featureStats.ticketCreated(interaction.guild.id, user.id, thread.id);

void interaction.reply({
content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`,
ephemeral: true,
Expand Down Expand Up @@ -260,7 +269,7 @@ ${quoteMessageContent(concern)}`);
rest.delete(Routes.threadMembers(threadId, ticketOpenerUserId)),
rest.post(Routes.channelMessages(modLog), {
body: {
content: `<@${ticketOpenerUserId}>s ticket <#${threadId}> closed by <@${interactionUserId}>${feedback ? `. feedback: ${feedback}` : ""}`,
content: `<@${ticketOpenerUserId}>'s ticket <#${threadId}> closed by <@${interactionUserId}>${feedback ? `. feedback: ${feedback}` : ""}`,
allowedMentions: {},
},
}),
Expand All @@ -270,6 +279,13 @@ ${quoteMessageContent(concern)}`);
}),
]);

featureStats.ticketClosed(
interaction.guild.id,
interactionUserId,
ticketOpenerUserId,
!!feedback?.trim(),
);

return;
},
} as MessageComponentCommand,
Expand Down
5 changes: 5 additions & 0 deletions app/commands/track.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { Button } from "reacord";

import { reacord } from "#~/discord/client.server";
import { featureStats } from "#~/helpers/metrics";
import { reportUser } from "#~/helpers/modLog";
import {
markMessageAsDeleted,
Expand All @@ -27,6 +28,10 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => {
staff: user,
});

if (interaction.guildId) {
featureStats.userTracked(interaction.guildId, user.id, message.author.id);
}

const instance = reacord.ephemeralReply(
interaction,
<>
Expand Down
8 changes: 8 additions & 0 deletions app/discord/automod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Events, type Client } from "discord.js";

import { isStaff } from "#~/helpers/discord";
import { isSpam } from "#~/helpers/isSpam";
import { featureStats } from "#~/helpers/metrics";
import { reportUser } from "#~/helpers/modLog";
import {
markMessageAsDeleted,
Expand Down Expand Up @@ -34,6 +35,12 @@ export default async (bot: Client) => {
.delete()
.then(() => markMessageAsDeleted(message.id, message.guild!.id));

featureStats.spamDetected(
message.guild.id,
message.author.id,
message.channelId,
);

if (warnings >= AUTO_SPAM_THRESHOLD) {
await Promise.all([
member.kick("Autokicked for spamming"),
Expand All @@ -42,6 +49,7 @@ export default async (bot: Client) => {
allowedMentions: {},
}),
]);
featureStats.spamKicked(message.guild.id, message.author.id, warnings);
}
}
});
Expand Down
4 changes: 4 additions & 0 deletions app/discord/deployCommands.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export const deployCommands = async (client: Client) => {
: deployTestCommands(client, localCommands));

client.on(Events.InteractionCreate, (interaction) => {
log("info", "deployCommands", "Handling interaction", {
type: interaction.type,
id: interaction.id,
});
switch (interaction.type) {
case InteractionType.ApplicationCommand: {
const config = matchCommand(interaction.commandName);
Expand Down
12 changes: 11 additions & 1 deletion app/discord/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { deployCommands } from "#~/discord/deployCommands.server";
import { startEscalationResolver } from "#~/discord/escalationResolver";
import onboardGuild from "#~/discord/onboardGuild";
import { startReactjiChanneler } from "#~/discord/reactjiChanneler";
import { botStats } from "#~/helpers/metrics";
import { botStats, shutdownMetrics } from "#~/helpers/metrics";
import { log, trackPerformance } from "#~/helpers/observability";
import Sentry from "#~/helpers/sentry.server";

Expand Down Expand Up @@ -134,4 +134,14 @@ export default function init() {
// Track reconnections in business analytics
botStats.reconnection(client.guilds.cache.size, client.users.cache.size);
});

// Graceful shutdown handler to flush metrics
const handleShutdown = async (signal: string) => {
log("info", "Gateway", `Received ${signal}, shutting down gracefully`, {});
await shutdownMetrics();
process.exit(0);
};

process.on("SIGTERM", () => void handleShutdown("SIGTERM"));
process.on("SIGINT", () => void handleShutdown("SIGINT"));
}
2 changes: 2 additions & 0 deletions app/discord/honeypotTracker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ChannelType, Events, type Client } from "discord.js";

import db from "#~/db.server.js";
import { featureStats } from "#~/helpers/metrics";
import { reportUser } from "#~/helpers/modLog.js";
import { log } from "#~/helpers/observability";
import { fetchSettings, SETTINGS } from "#~/models/guilds.server.js";
Expand Down Expand Up @@ -103,6 +104,7 @@ export async function startHoneypotTracking(client: Client) {
staff: client.user ?? false,
}),
]);
featureStats.honeypotTriggered(msg.guildId, member.id, msg.channelId);
} catch (e) {
log(
"error",
Expand Down
73 changes: 44 additions & 29 deletions app/discord/onboardGuild.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChannelType, Events, type Client, type TextChannel } from "discord.js";

import { botStats } from "#~/helpers/metrics";
import { retry } from "#~/helpers/misc";
import { fetchGuild } from "#~/models/guilds.server";

Expand All @@ -11,35 +12,49 @@ export default async (bot: Client) => {
// available after downtime, or when actually added to a new guild
bot.on(Events.GuildCreate, async (guild) => {
const appGuild = await fetchGuild(guild.id);
if (!appGuild) {
await deployCommands(client);

const welcomeMessage = `Euno is here! Set it up with \`/setup\``;

const channels = await guild.channels.fetch();
const likelyChannels = channels.filter((c): c is TextChannel =>
Boolean(
c &&
c.type === ChannelType.GuildText &&
(c.name.includes("mod") || c.name.includes("intro")),
),
);

await retry(5, async (n) => {
switch (n) {
case 0:
void guild.systemChannel!.send(welcomeMessage);
return;
case 1:
void guild.publicUpdatesChannel!.send(welcomeMessage);
return;
default: {
if (likelyChannels.size < n - 2) return;
void likelyChannels.at(n - 2)!.send(welcomeMessage);
return;
}
botStats.guildJoined(guild);
if (appGuild) return;

// New installation - deploy commands and send welcome message
await deployCommands(client);

const welcomeMessage = `Euno is here! Set it up with \`/setup\``;

const channels = await guild.channels.fetch();
const likelyChannels = channels.filter((c): c is TextChannel =>
Boolean(
c &&
c.type === ChannelType.GuildText &&
(c.name.includes("mod") || c.name.includes("intro")),
),
);

await retry(5, async (n) => {
switch (n) {
case 0:
void guild.systemChannel!.send(welcomeMessage);
return;
case 1:
void guild.publicUpdatesChannel!.send(welcomeMessage);
return;
default: {
if (likelyChannels.size < n - 2) return;
void likelyChannels.at(n - 2)!.send(welcomeMessage);
return;
}
});
}
}
});
});

// Track when the bot is removed from a guild
// GuildDelete also fires when a guild becomes temporarily unavailable,
// so we check the unavailable flag and verify the guild exists in our DB
bot.on(Events.GuildDelete, async (guild) => {
if (guild.available === false) return;

const appGuild = await fetchGuild(guild.id);
if (!appGuild) return;

botStats.guildRemoved(guild);
});
};
3 changes: 3 additions & 0 deletions app/discord/reactjiChanneler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Events, type Client } from "discord.js";

import db from "#~/db.server";
import { featureStats } from "#~/helpers/metrics";
import { log } from "#~/helpers/observability";

export async function startReactjiChanneler(client: Client) {
Expand Down Expand Up @@ -100,6 +101,8 @@ export async function startReactjiChanneler(client: Client) {
allowedMentions: { users: [] },
});

featureStats.reactjiTriggered(guildId, user.id, emoji, message.id);

log("info", "ReactjiChanneler", "Message forwarded successfully", {
messageId: message.id,
targetChannelId: config.channel_id,
Expand Down
3 changes: 2 additions & 1 deletion app/helpers/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export const sentryReleases = getEnv("SENTRY_RELEASES", true);
export const stripeSecretKey = getEnv("STRIPE_SECRET_KEY");
export const stripeWebhookSecret = getEnv("STRIPE_WEBHOOK_SECRET");

export const amplitudeKey = getEnv("AMPLITUDE_API_KEY", true);
export const posthogApiKey = getEnv("POSTHOG_KEY", true);
export const posthogHost = getEnv("POSTHOG_HOST", true);

if (!ok) throw new Error("Environment misconfigured");
console.log("");
Loading
Loading