Merge remote-tracking branch 'origin/staging' into dallin-bus-1714-run-sql-endpoint-needs-to-use-same-functionality-as-the-get

This commit is contained in:
dal 2025-09-15 15:53:19 -06:00
commit cf7b1d0109
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
5 changed files with 299 additions and 16 deletions

View File

@ -32,19 +32,7 @@ export const updateReport = async ({
reportId,
...data
}: UpdateReportRequest & { reportId: string }) => {
return mainApiV2
.put<UpdateReportResponse>(`/reports/${reportId}`, data)
.then((res) => res.data)
.catch((err) => {
console.error(err);
const IS_STAGING = window.location.hostname.includes('staging');
if (IS_STAGING) {
alert(
'Look at the request payload and TELL NATE IMMEDIATELY. This error will only show up staging.'
);
}
throw err;
});
return mainApiV2.put<UpdateReportResponse>(`/reports/${reportId}`, data).then((res) => res.data);
};
/**

View File

@ -45,6 +45,7 @@ export * from './utils/validation-helpers';
export * from './utils/message-formatter';
export * from './utils/oauth-helpers';
export { convertMarkdownToSlack } from './utils/markdown-to-slack';
export { decodeHtmlEntities, decodeSlackMessageText } from './utils/html-entities';
// Reactions
export { addReaction, removeReaction, getReactions } from './reactions';

View File

@ -1,4 +1,5 @@
import { WebClient } from '@slack/web-api';
import { decodeSlackMessageText } from './utils/html-entities';
// Define our own simple types to avoid complex Slack API type issues
interface SlackBlock {
@ -45,9 +46,12 @@ export async function getThreadMessages({
inclusive: true, // Include the parent message
});
// Cast the result to our SlackMessage type
// Cast the result to our SlackMessage type and decode HTML entities
const messages = result.messages || [];
return messages as SlackMessage[];
return messages.map((message) => ({
...message,
text: decodeSlackMessageText(message.text),
})) as SlackMessage[];
} catch (error) {
console.error('Failed to get thread messages:', error);
throw error;
@ -81,7 +85,11 @@ export async function getMessage({
});
if (result.messages && result.messages.length > 0) {
return result.messages[0] as SlackMessage;
const message = result.messages[0];
return {
...message,
text: decodeSlackMessageText(message?.text),
} as SlackMessage;
}
return null;

View File

@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import { decodeHtmlEntities, decodeSlackMessageText } from './html-entities';
describe('decodeHtmlEntities', () => {
it('should decode common HTML entities', () => {
expect(decodeHtmlEntities('&lt;')).toBe('<');
expect(decodeHtmlEntities('&gt;')).toBe('>');
expect(decodeHtmlEntities('&amp;')).toBe('&');
expect(decodeHtmlEntities('&quot;')).toBe('"');
expect(decodeHtmlEntities('&#39;')).toBe("'");
expect(decodeHtmlEntities('&apos;')).toBe("'");
});
it('should decode multiple entities in a string', () => {
expect(decodeHtmlEntities('&lt;div&gt;Hello &amp; World&lt;/div&gt;')).toBe(
'<div>Hello & World</div>'
);
expect(decodeHtmlEntities('&quot;Hello&quot; &amp; &#39;World&#39;')).toBe(
'"Hello" & \'World\''
);
});
it('should decode numeric character references', () => {
// Decimal references
expect(decodeHtmlEntities('&#60;')).toBe('<');
expect(decodeHtmlEntities('&#62;')).toBe('>');
expect(decodeHtmlEntities('&#38;')).toBe('&');
expect(decodeHtmlEntities('&#123;')).toBe('{');
expect(decodeHtmlEntities('&#125;')).toBe('}');
// Hexadecimal references
expect(decodeHtmlEntities('&#x3C;')).toBe('<');
expect(decodeHtmlEntities('&#x3E;')).toBe('>');
expect(decodeHtmlEntities('&#x26;')).toBe('&');
expect(decodeHtmlEntities('&#x7B;')).toBe('{');
expect(decodeHtmlEntities('&#x7D;')).toBe('}');
// Case insensitive hex
expect(decodeHtmlEntities('&#X3C;')).toBe('<');
expect(decodeHtmlEntities('&#X3E;')).toBe('>');
});
it('should handle special characters', () => {
expect(decodeHtmlEntities('&nbsp;')).toBe(' ');
expect(decodeHtmlEntities('&ndash;')).toBe('');
expect(decodeHtmlEntities('&mdash;')).toBe('—');
expect(decodeHtmlEntities('&hellip;')).toBe('…');
expect(decodeHtmlEntities('&ldquo;')).toBe('\u201C'); // Left double quotation mark
expect(decodeHtmlEntities('&rdquo;')).toBe('\u201D'); // Right double quotation mark
});
it('should handle empty or undefined input', () => {
expect(decodeHtmlEntities('')).toBe('');
expect(decodeHtmlEntities(null as unknown as string)).toBe(null);
expect(decodeHtmlEntities(undefined as unknown as string)).toBe(undefined);
});
it('should preserve text without entities', () => {
expect(decodeHtmlEntities('Hello World')).toBe('Hello World');
expect(decodeHtmlEntities('No entities here!')).toBe('No entities here!');
});
it('should handle repeated entities', () => {
expect(decodeHtmlEntities('&amp;&amp;&amp;')).toBe('&&&');
expect(decodeHtmlEntities('&lt;&lt;&lt;')).toBe('<<<');
});
it('should decode entities in code examples', () => {
const input = 'if (x &lt; 10 &amp;&amp; y &gt; 5) { console.log(&quot;Hello&quot;); }';
const expected = 'if (x < 10 && y > 5) { console.log("Hello"); }';
expect(decodeHtmlEntities(input)).toBe(expected);
});
});
describe('decodeSlackMessageText', () => {
it('should decode HTML entities while preserving Slack user mentions', () => {
const input = '&lt;@U123456&gt; said &quot;Hello&quot;';
const expected = '<@U123456> said "Hello"';
expect(decodeSlackMessageText(input)).toBe(expected);
// When mention is already properly formatted
const input2 = '<@U123456> said &quot;Hello&quot;';
const expected2 = '<@U123456> said "Hello"';
expect(decodeSlackMessageText(input2)).toBe(expected2);
});
it('should preserve Slack channel mentions', () => {
const input = 'Check out &lt;#C123456&gt; for more info';
const expected = 'Check out <#C123456> for more info';
expect(decodeSlackMessageText(input)).toBe(expected);
// When channel mention is already properly formatted
const input2 = 'Check out <#C123456> for more info';
const expected2 = 'Check out <#C123456> for more info';
expect(decodeSlackMessageText(input2)).toBe(expected2);
});
it('should preserve Slack links', () => {
const input = 'Visit &lt;https://example.com|our website&gt; for details';
const expected = 'Visit <https://example.com|our website> for details';
expect(decodeSlackMessageText(input)).toBe(expected);
// When link is already properly formatted
const input2 = 'Visit <https://example.com|our website> for details';
const expected2 = 'Visit <https://example.com|our website> for details';
expect(decodeSlackMessageText(input2)).toBe(expected2);
});
it('should preserve simple Slack URLs', () => {
const input = 'Check &lt;https://example.com&gt;';
const expected = 'Check <https://example.com>';
expect(decodeSlackMessageText(input)).toBe(expected);
// When URL is already properly formatted
const input2 = 'Check <https://example.com>';
const expected2 = 'Check <https://example.com>';
expect(decodeSlackMessageText(input2)).toBe(expected2);
});
it('should decode entities in regular text while preserving Slack formatting', () => {
const input = '<@U123456> wrote: &lt;div&gt;Hello &amp; welcome&lt;/div&gt; in <#C789012>';
const expected = '<@U123456> wrote: <div>Hello & welcome</div> in <#C789012>';
expect(decodeSlackMessageText(input)).toBe(expected);
});
it('should handle mixed content with code blocks', () => {
const input =
'Here&#39;s the code: if (x &lt; 10 &amp;&amp; y &gt; 5) { alert(&quot;Hi&quot;); }';
const expected = 'Here\'s the code: if (x < 10 && y > 5) { alert("Hi"); }';
expect(decodeSlackMessageText(input)).toBe(expected);
});
it('should handle undefined or empty input', () => {
expect(decodeSlackMessageText(undefined)).toBe(undefined);
expect(decodeSlackMessageText('')).toBe('');
expect(decodeSlackMessageText(' ')).toBe(' ');
});
it('should handle complex Slack messages', () => {
const input =
'<@U123456> mentioned <@U789012> in <#C345678>: &quot;Check this &lt;https://example.com|link&gt; for the &lt;code&gt; example&quot;';
const expected =
'<@U123456> mentioned <@U789012> in <#C345678>: "Check this <https://example.com|link> for the <code> example"';
expect(decodeSlackMessageText(input)).toBe(expected);
});
it('should handle messages with multiple entity types', () => {
const input = 'Testing &amp; more: &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;';
const expected = "Testing & more: <script>alert('XSS')</script>";
expect(decodeSlackMessageText(input)).toBe(expected);
});
});

View File

@ -0,0 +1,134 @@
/**
* Decode HTML entities from Slack messages
* Slack API returns text with HTML entities encoded (e.g., &lt; for <, &gt; for >, &amp; for &)
* This function decodes these entities back to their original characters
*/
/**
* Map of HTML entities to their decoded characters
* Based on common entities found in Slack messages
*/
const HTML_ENTITIES: Record<string, string> = {
'&lt;': '<',
'&gt;': '>',
'&amp;': '&',
'&quot;': '"',
'&#39;': "'",
'&apos;': "'",
'&#x27;': "'",
'&#x2F;': '/',
'&#47;': '/',
'&#96;': '`',
'&#x60;': '`',
'&nbsp;': ' ',
'&#160;': ' ',
'&ndash;': '',
'&mdash;': '—',
'&hellip;': '…',
'&ldquo;': '\u201C',
'&rdquo;': '\u201D',
'&lsquo;': '\u2018',
'&rsquo;': '\u2019',
};
/**
* Decode HTML entities in a string
* @param text - The text containing HTML entities
* @returns The decoded text with HTML entities replaced by their characters
*/
export function decodeHtmlEntities(text: string): string {
if (!text) {
return text;
}
// Replace known HTML entities
let decodedText = text;
for (const [entity, replacement] of Object.entries(HTML_ENTITIES)) {
// Use global replace to handle multiple occurrences
const regex = new RegExp(escapeRegExp(entity), 'g');
decodedText = decodedText.replace(regex, replacement);
}
// Handle numeric character references (e.g., &#123; or &#x7B;)
// Decimal: &#123;
decodedText = decodedText.replace(/&#(\d+);/g, (_match, code) => {
const charCode = Number.parseInt(code, 10);
return String.fromCharCode(charCode);
});
// Hexadecimal: &#x7B; or &#X7B;
decodedText = decodedText.replace(/&#[xX]([0-9a-fA-F]+);/g, (_match, code) => {
const charCode = Number.parseInt(code, 16);
return String.fromCharCode(charCode);
});
return decodedText;
}
/**
* Escape special regex characters in a string
* @param string - The string to escape
* @returns The escaped string safe for use in regex
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Decode HTML entities in Slack message text while preserving Slack-specific formatting
* This function is aware of Slack's message format and preserves user/channel mentions
* @param slackText - The Slack message text with potential HTML entities
* @returns The decoded text with HTML entities replaced
*/
export function decodeSlackMessageText(slackText: string | undefined): string | undefined {
if (!slackText) {
return slackText;
}
// Slack uses <@USERID> for user mentions and <#CHANNELID> for channel mentions
// These should not be decoded as HTML entities, so we need to be careful
// The &lt; and &gt; around these are actual HTML entities that should be decoded
// But the < and > that are already part of mentions should be preserved
// First, protect Slack mentions by temporarily replacing them
const mentionPlaceholders = new Map<string, string>();
let placeholderIndex = 0;
// Protect user mentions <@USERID>
let protectedText = slackText.replace(/<@[A-Z0-9]+>/g, (match) => {
const placeholder = `__SLACK_USER_MENTION_${placeholderIndex++}__`;
mentionPlaceholders.set(placeholder, match);
return placeholder;
});
// Protect channel mentions <#CHANNELID>
protectedText = protectedText.replace(/<#[A-Z0-9]+>/g, (match) => {
const placeholder = `__SLACK_CHANNEL_MENTION_${placeholderIndex++}__`;
mentionPlaceholders.set(placeholder, match);
return placeholder;
});
// Protect links <URL|text>
protectedText = protectedText.replace(/<[^>]+\|[^>]+>/g, (match) => {
const placeholder = `__SLACK_LINK_${placeholderIndex++}__`;
mentionPlaceholders.set(placeholder, match);
return placeholder;
});
// Protect simple links <URL>
protectedText = protectedText.replace(/<(https?:\/\/[^>]+)>/g, (match) => {
const placeholder = `__SLACK_URL_${placeholderIndex++}__`;
mentionPlaceholders.set(placeholder, match);
return placeholder;
});
// Now decode HTML entities
let decodedText = decodeHtmlEntities(protectedText);
// Restore the protected Slack mentions
for (const [placeholder, original] of mentionPlaceholders) {
decodedText = decodedText.replace(placeholder, original);
}
return decodedText;
}