* Refine Discord webhook scoring notifications (fixes #180, #181) - Filter scored matches to only show matchups where a manager scores Brackt points (winner drafted) or has a team eliminated (loser drafted); matches with no manager involvement are suppressed entirely. - Escape Discord markdown characters (_*~`|\) in team names and usernames to prevent formatting issues (e.g. double-underscore names causing unintended underlines). - Replace full standings with a "Standings Changes" section that shows only teams whose points changed, plus any teams whose rank shifted as a result, with ↑N / ↓N indicators for rank movement. - Pass previousRanks map from scoring-calculator to the notification so rank-displaced teams (who didn't score points themselves) are included. - Update test webhook in league settings to demonstrate rank changes and a sample scored match. - Update all discord service tests to cover the new filtering, escaping, and standings-change behaviour. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * fix: only set winnerUsername when winning team's score actually changed Previously, winnerUsername was set for any drafted winner regardless of whether points were actually scored. This caused R64 wins (where the scoring system may not award points until later rounds) to appear in Discord's Scored Matches section even when no Brackt points were earned. Now winnerUsername is only set when the winner's team's totalPoints changed after recalculation. loserUsername (eliminations) is always set when the loser is drafted, since being knocked out is always notable. https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 * Refactor Discord notification logic in scoring calculator - Compute changedTeamIds once up front; derive hasChanges from it instead of duplicating the same iteration - Merge usernameForParticipant and winnerUsernameForParticipant into a single function with a requireScoreChange flag - Replace hasDraftedParticipantMatches with hasScoredMatchesToShow, which checks that at least one scoredMatch has a displayable username — prevents sending a contentless Discord embed when a drafted winner doesn't score any points and the loser isn't drafted - Update inline comment to be sport-agnostic https://claude.ai/code/session_01FciwfdG9Sfr5ZrXUHPeB18 --------- Co-authored-by: Claude <noreply@anthropic.com>
146 lines
4.5 KiB
TypeScript
146 lines
4.5 KiB
TypeScript
interface DiscordEmbed {
|
|
title?: string;
|
|
description?: string;
|
|
color?: number;
|
|
footer?: { text: string };
|
|
}
|
|
|
|
interface DiscordWebhookPayload {
|
|
content?: string;
|
|
embeds?: DiscordEmbed[];
|
|
}
|
|
|
|
export async function sendDiscordWebhook(
|
|
webhookUrl: string,
|
|
payload: DiscordWebhookPayload
|
|
): Promise<void> {
|
|
const response = await fetch(webhookUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => "");
|
|
throw new Error(`Discord webhook failed: ${response.status} ${text}`);
|
|
}
|
|
}
|
|
|
|
/** Escape characters that trigger Discord markdown formatting. */
|
|
function escapeMarkdown(text: string): string {
|
|
return text.replace(/[_*~`|\\]/g, "\\$&");
|
|
}
|
|
|
|
export interface StandingEntry {
|
|
teamId: string;
|
|
teamName: string;
|
|
username?: string;
|
|
totalPoints: number;
|
|
rank: number;
|
|
}
|
|
|
|
export interface ScoredMatch {
|
|
winnerName: string;
|
|
loserName: string;
|
|
winnerUsername?: string;
|
|
loserUsername?: string;
|
|
}
|
|
|
|
export async function sendStandingsUpdateNotification({
|
|
webhookUrl,
|
|
seasonName,
|
|
standings,
|
|
previousStandings,
|
|
previousRanks,
|
|
sportName,
|
|
eventName,
|
|
scoredMatches,
|
|
}: {
|
|
webhookUrl: string;
|
|
seasonName: string;
|
|
standings: StandingEntry[];
|
|
previousStandings: Map<string, number>;
|
|
previousRanks?: Map<string, number>;
|
|
sportName?: string;
|
|
eventName?: string;
|
|
scoredMatches?: ScoredMatch[];
|
|
}): Promise<void> {
|
|
const sections: string[] = [];
|
|
|
|
// Header: "Sport Name — Event Name"
|
|
if (sportName || eventName) {
|
|
const parts = [sportName, eventName].filter(Boolean);
|
|
sections.push(`**${parts.join(" — ")}**`);
|
|
}
|
|
|
|
// Scored matches section — only show matches where at least one manager
|
|
// (fantasy team owner) scored Brackt points or had a team eliminated.
|
|
const relevantMatches = scoredMatches?.filter(
|
|
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
|
);
|
|
if (relevantMatches && relevantMatches.length > 0) {
|
|
sections.push("\n**Scored Matches**");
|
|
for (const match of relevantMatches) {
|
|
const winnerLabel = match.winnerUsername
|
|
? `${escapeMarkdown(match.winnerName)} (${escapeMarkdown(match.winnerUsername)})`
|
|
: escapeMarkdown(match.winnerName);
|
|
const loserLabel = match.loserUsername
|
|
? `${escapeMarkdown(match.loserName)} (${escapeMarkdown(match.loserUsername)})`
|
|
: escapeMarkdown(match.loserName);
|
|
sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
|
|
}
|
|
}
|
|
|
|
// Standings changes section — show teams whose points or rank changed.
|
|
const changedTeams = standings.filter((s) => {
|
|
const prevPoints = previousStandings.get(s.teamId);
|
|
const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
|
|
const prevRank = previousRanks?.get(s.teamId);
|
|
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
|
|
return pointsChanged || rankChanged;
|
|
});
|
|
|
|
if (changedTeams.length > 0) {
|
|
sections.push("\n**Standings Changes**");
|
|
for (const s of changedTeams) {
|
|
const prevPoints = previousStandings.get(s.teamId);
|
|
let pointDelta = "";
|
|
if (prevPoints !== undefined && prevPoints !== s.totalPoints) {
|
|
const diff = Math.round(s.totalPoints - prevPoints);
|
|
const sign = diff > 0 ? "+" : "";
|
|
pointDelta = ` **(${sign}${diff} pts)**`;
|
|
}
|
|
|
|
let rankDelta = "";
|
|
if (previousRanks) {
|
|
const prevRank = previousRanks.get(s.teamId);
|
|
if (prevRank !== undefined && prevRank !== s.rank) {
|
|
const moved = prevRank - s.rank; // positive = moved up
|
|
rankDelta = moved > 0 ? ` ↑${moved}` : ` ↓${Math.abs(moved)}`;
|
|
}
|
|
}
|
|
|
|
const escapedName = escapeMarkdown(s.teamName);
|
|
const escapedUsername = s.username ? escapeMarkdown(s.username) : undefined;
|
|
const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName;
|
|
sections.push(`${s.rank}. ${label} — ${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
|
|
}
|
|
}
|
|
|
|
const MAX_DESCRIPTION = 4096;
|
|
let description = sections.join("\n");
|
|
if (description.length > MAX_DESCRIPTION) {
|
|
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
|
|
}
|
|
|
|
await sendDiscordWebhook(webhookUrl, {
|
|
embeds: [
|
|
{
|
|
title: `📊 Standings Update — ${seasonName}`,
|
|
description,
|
|
color: 0x5865f2, // Discord blurple
|
|
footer: { text: "brackt.com" },
|
|
},
|
|
],
|
|
});
|
|
}
|