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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ commit and retries failed intents with capped exponential backoff.
Slack messages are multi-line: an event headline, the challenge as a link to
`CHALLENGE_APP_BASE_URL/challenges/{challengeId}` when the ticket has one, the
support ticket link, and — for a new ticket — the request body as a sanitized,
bounded plain-text preview.
bounded plain-text preview that keeps the author's own line and paragraph breaks
so separate paragraphs and list items do not run together.

Email is published through Bus API v6 to Kafka topic
`external.action.email`. `tc-bus-api-wrapper` appends `/bus/events`, so
Expand Down
57 changes: 57 additions & 0 deletions src/notifications/notification-outbox.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SlackService } from '../integrations/slack.service';
import { MemberDirectoryService } from './member-directory.service';
import {
NotificationOutboxService,
markdownNotificationBlockPreview,
markdownNotificationPreview,
} from './notification-outbox.service';

Expand Down Expand Up @@ -395,6 +396,38 @@ describe('NotificationOutboxService delivery', () => {
expect(message).not.toContain('secret.example');
});

it('keeps the request body line breaks in the opened Slack message', async () => {
const { db, service, slack } = createHarness();
db.$queryRaw.mockResolvedValue([{ id: 'opened-slack', lockedAt }]);
db.notificationOutbox.findUnique.mockImplementation(() => {
const record = claimedRecord({
channel: NotificationChannel.SLACK,
id: 'opened-slack',
});
record.ticket = {
...(record.ticket as Record<string, unknown>),
description:
'My submission failed to upload.\n\nSteps I tried:\n' +
'- Chrome, incognito\n- Firefox\n\nThe error was a 500.',
};
return Promise.resolve(record);
});

await service.dispatch(['opened-slack']);

const message = slack.sendNotification.mock.calls[0][0] as string;
expect(message.split('\n').slice(3)).toEqual([
'Request:',
'My submission failed to upload.',
'',
'Steps I tried:',
'Chrome, incognito',
'Firefox',
'',
'The error was a 500.',
]);
});

it('omits the challenge line when the ticket has no challenge', async () => {
const { db, service, slack } = createHarness();
db.$queryRaw.mockResolvedValue([{ id: 'opened-slack', lockedAt }]);
Expand Down Expand Up @@ -645,3 +678,27 @@ describe('markdownNotificationPreview', () => {
expect(preview).not.toContain('secret.example');
});
});

describe('markdownNotificationBlockPreview', () => {
it('keeps the author line breaks while still removing markdown URLs', () => {
const preview = markdownNotificationBlockPreview(
'# Upload fails\r\nSteps [tried](https://secret.example):\r\n' +
'- Chrome incognito\r\n- Firefox\r\n\r\n\r\nPlease help.',
);

expect(preview).toBe(
'Upload fails\nSteps tried:\nChrome incognito\nFirefox\n\nPlease help.',
);
expect(preview).not.toContain('secret.example');
});

it('bounds the preview without splitting a Unicode code point', () => {
const preview = markdownNotificationBlockPreview(
'First line\nSecond line 😀😀😀😀',
18,
);

expect(preview).toBe('First line\nSecond…');
expect(Array.from(preview).length).toBeLessThanOrEqual(18);
});
});
103 changes: 83 additions & 20 deletions src/notifications/notification-outbox.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,16 @@ const SLACK_BODY_PREVIEW_CHARACTERS = 1_000;
const DEFAULT_CHALLENGE_APP_BASE_URL = 'https://work.topcoder.com';

/**
* Converts user-authored markdown to a compact plain-text notification preview.
* URLs, formatting markers, HTML tags, and excess whitespace are removed before
* the result is bounded without splitting a Unicode code point.
* Strips markdown syntax, HTML tags, link targets, and HTML entities from
* user-authored text while leaving the author's line breaks in place, so the
* single-line and multi-line notification previews share one sanitization pass.
*
* @param markdown untrusted ticket or response markdown.
* @param maximumCharacters maximum Unicode code points in the returned preview.
* @returns normalized plain text with an ellipsis when truncation is required.
* @returns plain text with markdown removed and line breaks preserved.
*/
export function markdownNotificationPreview(
markdown: string,
maximumCharacters = NOTIFICATION_PREVIEW_CHARACTERS,
): string {
const boundedMaximum =
Number.isInteger(maximumCharacters) && maximumCharacters > 0
? maximumCharacters
: NOTIFICATION_PREVIEW_CHARACTERS;
const plainText = String(markdown)
function sanitizeMarkdown(markdown: string): string {
return String(markdown)
.replace(/\r\n?/g, '\n')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/<[^>]*>/g, ' ')
Expand All @@ -56,9 +49,30 @@ export function markdownNotificationPreview(
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#0*39;/gi, "'")
.replace(/\s+/g, ' ')
.trim();
.replace(/&#0*39;/gi, "'");
}

/**
* Normalizes a caller-supplied preview bound, falling back to the stable
* default for non-positive or fractional values.
*
* @param maximumCharacters requested maximum Unicode code points.
* @returns a positive integer preview bound.
*/
function previewLimit(maximumCharacters: number): number {
return Number.isInteger(maximumCharacters) && maximumCharacters > 0
? maximumCharacters
: NOTIFICATION_PREVIEW_CHARACTERS;
}

/**
* Bounds already-sanitized preview text without splitting a Unicode code point.
*
* @param plainText sanitized preview text.
* @param boundedMaximum positive maximum Unicode code points to keep.
* @returns the text, with a trailing ellipsis when truncation is required.
*/
function boundPreview(plainText: string, boundedMaximum: number): string {
const codePoints = Array.from(plainText);
if (codePoints.length <= boundedMaximum) {
return plainText;
Expand All @@ -72,6 +86,52 @@ export function markdownNotificationPreview(
.trimEnd()}…`;
}

/**
* Converts user-authored markdown to a compact single-line plain-text preview.
* URLs, formatting markers, HTML tags, and excess whitespace are removed before
* the result is bounded without splitting a Unicode code point. Used for email
* template data, where the surrounding template controls the layout.
*
* @param markdown untrusted ticket or response markdown.
* @param maximumCharacters maximum Unicode code points in the returned preview.
* @returns normalized plain text with an ellipsis when truncation is required.
*/
export function markdownNotificationPreview(
markdown: string,
maximumCharacters = NOTIFICATION_PREVIEW_CHARACTERS,
): string {
return boundPreview(
sanitizeMarkdown(markdown).replace(/\s+/g, ' ').trim(),
previewLimit(maximumCharacters),
);
}

/**
* Converts user-authored markdown to a plain-text preview that keeps the
* author's line and paragraph breaks, for channels such as Slack that render
* multi-line text. Sanitization and bounding match the single-line preview, so
* raw markdown and embedded URLs are still never published; only horizontal
* whitespace is collapsed and runs of blank lines are reduced to one, which
* keeps separate paragraphs and list items from reading as a single sentence.
*
* @param markdown untrusted ticket or response markdown.
* @param maximumCharacters maximum Unicode code points in the returned preview.
* @returns multi-line plain text with an ellipsis when truncation is required.
*/
export function markdownNotificationBlockPreview(
markdown: string,
maximumCharacters = NOTIFICATION_PREVIEW_CHARACTERS,
): string {
const plainText = sanitizeMarkdown(markdown)
.replace(/[^\S\n]+/g, ' ')
.split('\n')
.map((line) => line.trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return boundPreview(plainText, previewLimit(maximumCharacters));
}

/**
* Reads the polling interval before Nest registers the interval metadata.
* Runtime ECS variables are present before module loading; invalid values use
Expand Down Expand Up @@ -580,7 +640,9 @@ export class NotificationOutboxService {

/**
* Publishes multi-line lifecycle text to Slack. Ticket markdown is never sent
* verbatim; the request body is included only as a sanitized, bounded preview.
* verbatim; the request body is included only as a sanitized, bounded preview
* that keeps the author's own line breaks so paragraphs and list items stay
* readable.
*
* @param record claimed outbox row with ticket data.
* @returns a promise resolved after Slack accepts the message.
Expand All @@ -593,7 +655,7 @@ export class NotificationOutboxService {
this.slackMessage(record, `New support ticket opened by ${handle}.`, [
'Request:',
this.escapeSlack(
markdownNotificationPreview(
markdownNotificationBlockPreview(
record.ticket.description,
SLACK_BODY_PREVIEW_CHARACTERS,
),
Expand Down Expand Up @@ -636,7 +698,8 @@ export class NotificationOutboxService {
*
* @param record claimed outbox row with ticket data.
* @param headline already escaped event-specific first line.
* @param detailLines already escaped lines appended after the ticket link.
* @param detailLines already escaped lines appended after the ticket link;
* an entry may itself span several lines, such as the request body preview.
* @returns newline-separated Slack message text.
*/
private slackMessage(
Expand Down