diff --git a/integrations/email/integration.definition.ts b/integrations/email/integration.definition.ts index c20e9085c88..ca6ab8c97e2 100644 --- a/integrations/email/integration.definition.ts +++ b/integrations/email/integration.definition.ts @@ -11,7 +11,7 @@ const emailSchema = z.object({ export default new IntegrationDefinition({ name: 'email', - version: '0.0.1', + version: '0.1.0', readme: 'hub.md', icon: 'icon.svg', configuration: { diff --git a/integrations/email/src/imap.ts b/integrations/email/src/imap.ts index 8f787271fa3..9a9735304e3 100644 --- a/integrations/email/src/imap.ts +++ b/integrations/email/src/imap.ts @@ -63,6 +63,8 @@ export const getMessages = async function ( imapBodies.push('TEXT') } + if (box.messages.total === 0) return { messages: [] } + const { firstElementIndex, lastElementIndex } = paging.pageToSpan({ page: range.page, perPage: range.perPage, diff --git a/integrations/email/src/setup.ts b/integrations/email/src/setup.ts index 17f48763ea2..f6ee3bc1fe0 100644 --- a/integrations/email/src/setup.ts +++ b/integrations/email/src/setup.ts @@ -18,7 +18,10 @@ export const register: bp.IntegrationProps['register'] = async (props) => { await getMessages({ page: 0, perPage: 1 }, props) } catch (thrown: unknown) { const err = thrown instanceof Error ? thrown : new Error(`${thrown}`) - throw new sdk.RuntimeError('An error occured when registering the integration. Verify your configuration.', err) + console.log(err.message) + throw new sdk.RuntimeError( + `An error occured when registering the integration: ${err.message} Verify your configuration.` + ) } } diff --git a/integrations/telegram/definitions/channels.ts b/integrations/telegram/definitions/channels.ts new file mode 100644 index 00000000000..05ee5ac9017 --- /dev/null +++ b/integrations/telegram/definitions/channels.ts @@ -0,0 +1,48 @@ +import { z, messages } from '@botpress/sdk' + +const _textMessageDefinition = { + ...messages.defaults.text, + schema: messages.defaults.text.schema.extend({ + text: messages.defaults.text.schema.shape.text + .max(4096) + .describe('The text content of the Telegram message (Limit 4096 characters)'), + }), +} + +const _imageMessageDefinition = { + ...messages.defaults.image, + schema: messages.defaults.image.schema.extend({ + caption: z.string().optional().describe('The caption/description of the image'), + }), +} + +const _audioMessageDefinition = { + ...messages.defaults.audio, + schema: messages.defaults.audio.schema.extend({ + caption: z.string().optional().describe('The caption/transcription of the audio message'), + }), +} + +const _blocSchema = z.union([ + z.object({ type: z.literal('text'), payload: _textMessageDefinition.schema }), + z.object({ type: z.literal('image'), payload: _imageMessageDefinition.schema }), + z.object({ type: z.literal('audio'), payload: _audioMessageDefinition.schema }), + z.object({ type: z.literal('video'), payload: messages.defaults.video.schema }), + z.object({ type: z.literal('file'), payload: messages.defaults.file.schema }), + z.object({ type: z.literal('location'), payload: messages.defaults.location.schema }), +]) + +const _blocMessageDefinition = { + ...messages.defaults.bloc, + schema: z.object({ + items: z.array(_blocSchema), + }), +} + +export const telegramMessageChannels = { + ...messages.defaults, + text: _textMessageDefinition, + image: _imageMessageDefinition, + audio: _audioMessageDefinition, + bloc: _blocMessageDefinition, +} diff --git a/integrations/telegram/hub.md b/integrations/telegram/hub.md index 1652b04a5b3..877c9a346f9 100644 --- a/integrations/telegram/hub.md +++ b/integrations/telegram/hub.md @@ -1,3 +1,18 @@ The Telegram integration allows your AI-powered chatbot to seamlessly interact with Telegram, a popular messaging platform with a large user base. Connect your chatbot to Telegram and engage with your audience in real-time conversations. With this integration, you can automate customer support, provide personalized recommendations, send notifications, and handle inquiries directly within Telegram. Leverage Telegram's rich features, including text messages, inline buttons, media files, and more, to create dynamic and interactive chatbot experiences. Empower your chatbot to deliver exceptional user experiences on Telegram with the Telegram Integration for Botpress. + +## Migrating from version `0.x.x` to `1.x.x` + +### Removal of proactive conversations (and proactive users) + +- Telegram does not currently support proactive conversations, so any bots using this feature will need to be updated to use the normal conversation flow. + +### Removal of dedicated Markdown messages type + +- The `markdown` channel message type is being deprecated in favor of integrating this behavior into the base `text` message type. +- This new Markdown behavior (commonmark spec) will allow image Markdown. However, since Telegram does not support mixed message types, it will split the message into multiple messages with images sent in between text messages. + +### Addition of message limits + +- Telegram has a message length limit of 4096 characters, so that limit has been added to the text parameter in the `text` message payload. Going over this limit will result in the message being rejected. diff --git a/integrations/telegram/integration.definition.ts b/integrations/telegram/integration.definition.ts index bd50ae907a6..7815abfa300 100644 --- a/integrations/telegram/integration.definition.ts +++ b/integrations/telegram/integration.definition.ts @@ -1,11 +1,12 @@ /* bplint-disable */ -import { z, IntegrationDefinition, messages } from '@botpress/sdk' +import { z, IntegrationDefinition } from '@botpress/sdk' import { sentry as sentryHelpers } from '@botpress/sdk-addons' import typingIndicator from './bp_modules/typing-indicator' +import { telegramMessageChannels } from './definitions/channels' export default new IntegrationDefinition({ name: 'telegram', - version: '0.7.4', + version: '1.0.0', title: 'Telegram', description: 'Engage with your audience in real-time.', icon: 'icon.svg', @@ -17,20 +18,10 @@ export default new IntegrationDefinition({ }, channels: { channel: { - messages: { - ...messages.defaults, - markdown: messages.markdown, - audio: { - ...messages.defaults.audio, - schema: messages.defaults.audio.schema.extend({ - caption: z.string().optional().describe('The caption/transcription of the audio message'), - }), - }, - }, + messages: telegramMessageChannels, message: { tags: { id: {}, chatId: {} } }, conversation: { tags: { id: {}, fromUserId: {}, fromUserUsername: {}, fromUserName: {}, chatId: {} }, - creation: { enabled: true, requiredTags: ['id'] }, }, }, }, @@ -41,7 +32,6 @@ export default new IntegrationDefinition({ tags: { id: {}, }, - creation: { enabled: true, requiredTags: ['id'] }, }, }).extend(typingIndicator, () => ({ entities: {}, diff --git a/integrations/telegram/package.json b/integrations/telegram/package.json index 5ec290a5796..e55184c5b14 100644 --- a/integrations/telegram/package.json +++ b/integrations/telegram/package.json @@ -12,6 +12,9 @@ "@botpress/sdk": "workspace:*", "@botpress/sdk-addons": "workspace:*", "lodash": "^4.17.21", + "markdown-it": "^14.1.0", + "nanoid": "^5.1.5", + "sanitize-html": "^2.17.0", "telegraf": "^4.16.3" }, "devDependencies": { @@ -19,7 +22,9 @@ "@botpress/common": "workspace:*", "@botpresshub/typing-indicator": "workspace:*", "@sentry/cli": "^2.39.1", - "@types/lodash": "^4.14.191" + "@types/lodash": "^4.14.191", + "@types/markdown-it": "^14.1.2", + "@types/sanitize-html": "^2.16.0" }, "bpDependencies": { "typing-indicator": "../../interfaces/typing-indicator" diff --git a/integrations/telegram/src/index.ts b/integrations/telegram/src/index.ts index bdf95d70998..1697d56406c 100644 --- a/integrations/telegram/src/index.ts +++ b/integrations/telegram/src/index.ts @@ -1,31 +1,42 @@ -import { RuntimeError } from '@botpress/client' import { sentry as sentryHelpers } from '@botpress/sdk-addons' import { ok } from 'assert/strict' -import { Markup, Telegraf } from 'telegraf' +import { Telegraf } from 'telegraf' import type { User } from 'telegraf/typings/core/types/typegram' +import { + handleAudioMessage, + handleBlocMessage, + handleCardMessage, + handleCarouselMessage, + handleChoiceMessage, + handleDropdownMessage, + handleFileMessage, + handleImageMessage, + handleLocationMessage, + handleTextMessage, + handleVideoMessage, +} from './misc/message-handlers' import { TelegramMessage } from './misc/types' import { getUserPictureDataUri, getUserNameFromTelegramUser, getChat, - sendCard, - ackMessage, convertTelegramMessageToBotpressMessage, wrapHandler, getMessageId, + mapToRuntimeErrorAndThrow, } from './misc/utils' import * as bp from '.botpress' const integration = new bp.Integration({ register: async ({ webhookUrl, ctx }) => { const telegraf = new Telegraf(ctx.configuration.botToken) - await telegraf.telegram.setWebhook(webhookUrl) + await telegraf.telegram.setWebhook(webhookUrl).catch(mapToRuntimeErrorAndThrow) }, unregister: async ({ ctx }) => { const telegraf = new Telegraf(ctx.configuration.botToken) - await telegraf.telegram.deleteWebhook({ drop_pending_updates: true }) + await telegraf.telegram.deleteWebhook({ drop_pending_updates: true }).catch(mapToRuntimeErrorAndThrow) }, actions: { startTypingIndicator: async ({ input, ctx, client }) => { @@ -36,8 +47,10 @@ const integration = new bp.Integration({ const chat = getChat(conversation) const messageId = getMessageId(message) - await telegraf.telegram.sendChatAction(chat, 'typing') - await telegraf.telegram.setMessageReaction(chat, messageId, [{ type: 'emoji', emoji: '👀' }]) + await telegraf.telegram.sendChatAction(chat, 'typing').catch(mapToRuntimeErrorAndThrow) + await telegraf.telegram + .setMessageReaction(chat, messageId, [{ type: 'emoji', emoji: '👀' }]) + .catch(mapToRuntimeErrorAndThrow) return {} }, @@ -49,7 +62,7 @@ const integration = new bp.Integration({ const chat = getChat(conversation) const messageId = getMessageId(message) - await telegraf.telegram.setMessageReaction(chat, messageId, []) + await telegraf.telegram.setMessageReaction(chat, messageId, []).catch(mapToRuntimeErrorAndThrow) return {} }, @@ -57,100 +70,17 @@ const integration = new bp.Integration({ channels: { channel: { messages: { - text: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - const { text } = payload - logger.forBot().debug(`Sending text message to Telegram chat ${chat}:`, text) - const message = await client.telegram.sendMessage(chat, text) - await ackMessage(message, ack) - }, - image: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - logger.forBot().debug(`Sending image message to Telegram chat ${chat}`, payload.imageUrl) - const message = await client.telegram.sendPhoto(chat, payload.imageUrl) - await ackMessage(message, ack) - }, - markdown: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - logger.forBot().debug(`Sending markdown message to Telegram chat ${chat}:`, payload.markdown) - const message = await client.telegram.sendMessage(chat, payload.markdown, { - parse_mode: 'MarkdownV2', - }) - await ackMessage(message, ack) - }, - audio: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - logger.forBot().debug(`Sending audio voice to Telegram chat ${chat}:`, payload.audioUrl) - try { - const message = await client.telegram.sendVoice(chat, payload.audioUrl, { caption: payload.caption }) - await ackMessage(message, ack) - } catch { - // If the audio file is too large to be voice, Telegram should send it as an audio file, but if for some reason it doesn't, we can send it as an audio file - const message = await client.telegram.sendAudio(chat, payload.audioUrl, { caption: payload.caption }) - await ackMessage(message, ack) - } - }, - video: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - logger.forBot().debug(`Sending video message to Telegram chat ${chat}:`, payload.videoUrl) - const message = await client.telegram.sendVideo(chat, payload.videoUrl) - await ackMessage(message, ack) - }, - file: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - logger.forBot().debug(`Sending file message to Telegram chat ${chat}:`, payload.fileUrl) - const message = await client.telegram.sendDocument(chat, payload.fileUrl) - await ackMessage(message, ack) - }, - location: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - logger.forBot().debug(`Sending location message to Telegram chat ${chat}:`, { - latitude: payload.latitude, - longitude: payload.longitude, - }) - const message = await client.telegram.sendLocation(chat, payload.latitude, payload.longitude) - await ackMessage(message, ack) - }, - card: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - logger.forBot().debug(`Sending card message to Telegram chat ${chat}:`, payload) - await sendCard(payload, client, chat, ack) - }, - carousel: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - logger.forBot().debug(`Sending carousel message to Telegram chat ${chat}:`, payload) - payload.items.forEach(async (item) => { - await sendCard(item, client, chat, ack) - }) - }, - dropdown: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - const buttons = payload.options.map((choice) => Markup.button.callback(choice.label, choice.value)) - logger.forBot().debug(`Sending dropdown message to Telegram chat ${chat}:`, payload) - const message = await client.telegram.sendMessage(chat, payload.text, Markup.keyboard(buttons).oneTime()) - await ackMessage(message, ack) - }, - choice: async ({ payload, ctx, conversation, ack, logger }) => { - const client = new Telegraf(ctx.configuration.botToken) - const chat = getChat(conversation) - logger.forBot().debug(`Sending choice message to Telegram chat ${chat}:`, payload) - const buttons = payload.options.map((choice) => Markup.button.callback(choice.label, choice.value)) - const message = await client.telegram.sendMessage(chat, payload.text, Markup.keyboard(buttons).oneTime()) - await ackMessage(message, ack) - }, - bloc: () => { - throw new RuntimeError('Not implemented') - }, + text: handleTextMessage, + image: handleImageMessage, + audio: handleAudioMessage, + video: handleVideoMessage, + file: handleFileMessage, + location: handleLocationMessage, + card: handleCardMessage, + carousel: handleCarouselMessage, + dropdown: handleDropdownMessage, + choice: handleChoiceMessage, + bloc: handleBlocMessage, }, }, }, @@ -226,6 +156,7 @@ const integration = new bp.Integration({ const bpMessage = await convertTelegramMessageToBotpressMessage({ message, telegram: telegraf.telegram, + logger, }) logger.forBot().debug(`Received message from user ${telegramUserId}: ${JSON.stringify(message, null, 2)}`) @@ -240,45 +171,6 @@ const integration = new bp.Integration({ conversationId: conversation.id, }) }), - createUser: async ({ client, tags, ctx }) => { - const strId = tags.id - const userId = Number(strId) - - if (isNaN(userId)) { - return - } - - const telegraf = new Telegraf(ctx.configuration.botToken) - const member = await telegraf.telegram.getChatMember(userId, userId) - - const { user } = await client.getOrCreateUser({ tags: { id: `${member.user.id}` } }) - - return { - body: JSON.stringify({ user: { id: user.id } }), - headers: {}, - statusCode: 200, - } - }, - createConversation: async ({ client, channel, tags, ctx }) => { - const chatId = tags.id - if (!chatId) { - return - } - - const telegraf = new Telegraf(ctx.configuration.botToken) - const chat = await telegraf.telegram.getChat(chatId) - - const { conversation } = await client.getOrCreateConversation({ - channel, - tags: { id: chat.id.toString() }, - }) - - return { - body: JSON.stringify({ conversation: { id: conversation.id } }), - headers: {}, - statusCode: 200, - } - }, }) export default sentryHelpers.wrapIntegration(integration, { diff --git a/integrations/telegram/src/misc/markdown-to-telegram-html.test.ts b/integrations/telegram/src/misc/markdown-to-telegram-html.test.ts new file mode 100644 index 00000000000..d27ccdea9ea --- /dev/null +++ b/integrations/telegram/src/misc/markdown-to-telegram-html.test.ts @@ -0,0 +1,435 @@ +import { describe, expect, test } from 'vitest' +import { + markdownHtmlToTelegramPayloads, + MarkdownToTelegramHtmlResult, + MixedPayloads, + stdMarkdownToTelegramHtml, +} from './markdown-to-telegram-html' +import { TestCase } from '../../tests/types' + +type MarkdownToTelegramHtmlTestCase = TestCase | TestCase + +const markdownToTelegramHtmlTestCases: MarkdownToTelegramHtmlTestCase[] = [ + // ==== Testing each mark type ==== + { + input: '**Bold**', + expects: 'Bold', + description: 'Apply bold style to text', + }, + { + input: '__Bold__', + expects: 'Bold', + description: 'Alternative apply bold style to text', + }, + { + input: '*Italic*', + expects: 'Italic', + description: 'Apply italic style to text', + }, + { + input: '_Italic_', + expects: 'Italic', + description: 'Alternative apply italic style to text', + }, + { + input: '~~Strike~~', + expects: 'Strike', + description: 'Apply strikethrough style to text', + }, + { + input: '||Spoiler||', + expects: 'Spoiler', + description: 'Apply spoiler style to text', + skip: true, // Why? - Feature is not yet implemented + }, + { + input: '`Code Snippet`', + expects: 'Code Snippet', + description: 'Apply code style to text', + }, + { + input: '```\nconsole.log("Code Block")\n```', + expects: '
console.log("Code Block")\n
', + description: 'Apply code block style to text - Without language', + }, + { + input: '```typescript\nconsole.log("Code Block")\n```', + expects: '
console.log("Code Block")\n
', + description: 'Apply code block style to text - With language', + }, + { + input: '\tconsole.log("Indented Code Block")', + expects: '
console.log("Indented Code Block")\n
', + description: 'Apply alternative code block style to text using indentation', + }, + { + input: '> Blockquote', + expects: '
\n\nBlockquote\n
', + description: 'Apply blockquote style to text', + }, + { + input: '[Hyperlink](https://www.botpress.com/)', + expects: 'Hyperlink', + description: 'Convert hyperlink markup to html link', + }, + { + input: '[Hyperlink](https://www.botpress.com/ "Tooltip Title")', + expects: 'Hyperlink', + // NOTE: Telegram does not support the title attribute, however, it just ignores it instead of causing a crash + description: 'Markdown hyperlink title gets carried over to html link', + }, + { + input: '[Hyperlink][id]\n\n[id]: https://www.botpress.com/ "Tooltip Title"', + expects: 'Hyperlink', + // NOTE: Telegram does not support the title attribute, however, it just ignores it instead of causing a crash + description: 'Convert hyperlink markup using footnote style syntax to html link', + }, + { + input: 'https://www.botpress.com/', + expects: 'https://www.botpress.com/', + description: 'Implicit link gets auto-converted into html link', + }, + { + input: '[Phone Number](tel:5141234567)', + expects: '5141234567', + description: + 'Convert phone number markdown to plain text phone number (Telegram does not support "tel" links, but will convert phone numbers into links for us)', + }, + { + input: '[Phone Number](tel:5141234567 "Tooltip Title")', + expects: '5141234567', + description: + 'Convert phone number markdown with title attribute to plain text phone number (Telegram does not support "tel" links, but will convert phone numbers into links for us)', + }, + { + input: '[Phone Number][id]\n\n[id]: tel:5141234567 "Tooltip Title"', + expects: '5141234567', + description: + 'Convert phone number markdown using footnote style syntax to plain text phone number (Telegram does not support "tel" links, but will convert phone number into links for us)', + }, + { + input: '[Botpress Email](mailto:test@botpress.com)', + expects: 'test@botpress.com', + description: + 'Convert email markdown to plain text email address (Telegram does not support "mailto" links, but will convert email addresses into links for us)', + }, + { + input: '[Botpress Email](mailto:test@botpress.com "Tooltip Title")', + expects: 'test@botpress.com', + description: + 'Convert email markdown with title attribute to plain text email address (Telegram does not support "mailto" links, but will convert email addresses into links for us)', + }, + { + input: '[Botpress Email][id]\n\n[id]: mailto:test@botpress.com "Tooltip Title"', + expects: 'test@botpress.com', + description: + 'Convert email markdown using footnote style syntax to plain text email address (Telegram does not support "mailto" links, but will convert email addresses into links for us)', + }, + { + input: + '[Botpress Email](mailto:test@botpress.com "Tooltip Title")[Hyperlink](https://www.botpress.com/ "Tooltip Title")', + expects: 'test@botpress.comHyperlink', + description: + "Ensure that the mailto/tel replacer doesn't break normal hyperlinks located immediately after it (Checking for race-condition)", + }, + { + input: '![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600)', + expects: { + html: '', + extractedData: { + images: [ + { + alt: 'Botpress Brand Logo', + src: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600', + pos: 0, + }, + ], + }, + }, + description: 'Markdown images get extracted since Telegram does not support images embedded into text messages', + }, + { + input: + '![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600 "Title Tooltip Text")', + expects: { + html: '', + extractedData: { + images: [ + { + alt: 'Botpress Brand Logo', + src: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600', + title: 'Title Tooltip Text', + pos: 0, + }, + ], + }, + }, + description: 'Title attribute gets extracted from markdown image', + }, + // ==== Advanced Tests ==== + { + input: '> Blockquote Layer 1\n> > Blockquote Layer 2\n> > > Blockquote Layer 3', + expects: + '
\n\nBlockquote Layer 1\n
\n\nBlockquote Layer 2\n
\n\nBlockquote Layer 3\n
\n
\n
', + // NOTE: Telegram does not support nested blockquotes, rather it just flattens it into one layer (No crash) + description: 'Apply nested blockquotes to text', + }, + { + input: '# Header 1\n## Header 2\n### Header 3\n#### Header 4\n##### Header 5\n###### Header 6', + expects: 'Header 1\nHeader 2\nHeader 3\nHeader 4\nHeader 5\nHeader 6', + description: 'Remove header styles since Telegram does not support headers', + }, + { + input: 'Header 2\n---\nHello World', + expects: 'Header 2\n\nHello World', + description: 'Remove alternate header style since Telegram does not support headers', + }, + { + input: '(c) (C) (r) (R) (tm) (TM) (p) (P) +-', + expects: '© © Âź Âź ℱ ℱ (p) (P) ±', + description: 'Convert text into their typographic equivalents', + }, + { + input: '!!!!!! ???? ,,', + expects: '!!! ??? ,', + description: 'Remove excess characters', + }, + { + input: 'Word -- ---', + expects: 'Word – —', + description: 'Convert 2 dashes into an "en dash" & 3 dashes into an "em dash" (Must follow a word)', + }, + { + input: 'Hello\n\n---\n\nBotpress\n***\nWorld\n___', + expects: 'Hello\n\n\nBotpress\n\n\nWorld', + // NOTE: 3 dashes variant requires an additional newline, otherwise it converts into a size 2 header + description: 'Remove horizontal rules (3 dashes, asterisks, or underscores) since Telegram does not support them', + }, + { + input: '"Double quotes" and \'Single quotes\'', + expects: '“Double quotes” and ‘Single quotes’', + description: 'Convert double & singles quotes into fancy double & single quotes', + }, + { + input: '**~~Bold-Strike~~**', + expects: 'Bold-Strike', + description: 'Multiple nested effects all get applied', + }, + { + input: '`**Code-Bold**`', + expects: '**Code-Bold**', + description: 'Markdown nested within a code snippet does not get converted to HTML', + }, + { + input: '```\n**CodeBlock-Bold**\n```', + expects: '
**CodeBlock-Bold**\n
', + description: 'Markdown nested within a code block does not get converted to HTML', + }, + { + input: 'This is line one. \nThis is line two.', + expects: 'This is line one.\n\nThis is line two.', + description: 'Converts hardbreak into multiple newlines', + }, + { + input: '_cut**off_**', + expects: 'cut**off**', + description: 'Markdown that gets cutoff (bold in this case) by another markdown does not convert to html', + }, + { + input: '**Hello**\n**World**', + expects: 'Hello\nWorld', + description: 'Multiline styling produces separate html tags for each line', + }, + { + input: '- Item 1\n- Item 2\n- Item 3', + expects: '- Item 1\n- Item 2\n- Item 3', + description: 'Markdown unordered lists do not convert to html since Telegram does not support them', + }, + { + input: '1) Item 1\n2) Item 2\n3) Item 3', + expects: '1) Item 1\n2) Item 2\n3) Item 3', + description: 'Markdown ordered lists do not convert to html since Telegram does not support them', + }, + { + input: '| Item 1 | Item 2 | Item 3 |\n| - | - | - |\n| Value 1 | Value 2 | Value 3 |', + expects: '| Item 1 | Item 2 | Item 3 |\n| - | - | - |\n| Value 1 | Value 2 | Value 3 |', + description: 'Markdown tables do not convert to html since Telegram does not support them', + }, +] + +describe('Standard Markdown to Telegram HTML Conversion', () => { + markdownToTelegramHtmlTestCases.forEach( + ({ input, expects, description, skip = false }: MarkdownToTelegramHtmlTestCase) => { + test.skipIf(skip)(description, () => { + const { html, extractedData } = stdMarkdownToTelegramHtml(input) + + if (typeof expects === 'string') { + expect(html).toBe(expects) + } else { + expect(html).toBe(expects.html) + expect(extractedData).toEqual(expects.extractedData) + } + }) + } + ) + + test('Ensure javascript injection via markdown link is not possible', () => { + const { html } = stdMarkdownToTelegramHtml("[click me](javascript:alert('XSS'))") + expect(html).toBe('[click me](javascript:alert(‘XSS’))') + }) + + test('Ensure javascript injection via html link is not possible', () => { + const { html } = stdMarkdownToTelegramHtml('click me') + expect(html).toBe('<a href=“javascript:alert(‘XSS’)”>click me</a>') + }) + + test('Ensure javascript injection via html image handler is not possible', () => { + const { html } = stdMarkdownToTelegramHtml('alt text') + expect(html).toBe('<img src=“image.jpg” alt=“alt text” onerror=“alert(‘xss’)”>') + }) +}) + +type MarkdownToTelegramHtmlWithExtractedImagesTestCase = TestCase + +const extractedImagesTestCases: MarkdownToTelegramHtmlWithExtractedImagesTestCase[] = [ + { + input: + '![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600 "Title Tooltip Text")![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=300 "Title Tooltip Text")', + expects: [ + { + type: 'image', + imageUrl: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600', + }, + { + type: 'image', + imageUrl: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=300', + }, + ], + description: 'Two images get extracted in the correct order', + }, + { + input: + '![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600 "Title Tooltip Text")\n\n![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=300 "Title Tooltip Text")', + expects: [ + { + type: 'image', + imageUrl: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600', + }, + { + type: 'image', + imageUrl: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=300', + }, + ], + description: 'Two images with whitespace in between removes whitespace', + }, + { + input: + 'Text Before\n![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600 "Title Tooltip Text")', + expects: [ + { + type: 'text', + text: 'Text Before\n', + }, + { + type: 'image', + imageUrl: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600', + }, + ], + description: 'Text followed by an image', + }, + { + input: + '![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600 "Title Tooltip Text")\nText in the middle\n![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=300 "Title Tooltip Text")', + expects: [ + { + type: 'image', + imageUrl: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600', + }, + { + type: 'text', + text: '\nText in the middle\n', + }, + { + type: 'image', + imageUrl: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=300', + }, + ], + description: 'Two images with text in the middle', + }, + { + input: + '![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600 "Title Tooltip Text")\nText After', + expects: [ + { + type: 'image', + imageUrl: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600', + }, + { + type: 'text', + text: '\nText After', + }, + ], + description: 'Image followed by text', + }, + { + input: + 'Text Before\n![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600 "Title Tooltip Text")\nText After', + expects: [ + { + type: 'text', + text: 'Text Before\n', + }, + { + type: 'image', + imageUrl: 'https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600', + }, + { + type: 'text', + text: '\nText After', + }, + ], + description: 'Image surrounded by text', + }, + { + input: + "Hello **World**\nHello _World_\nHello `World`\nHello [World](https://example.com)\nHello ![World](https://en.wikipedia.org/wiki/Image#/media/File:Image_created_with_a_mobile_phone.png)\nHello ![World](https://en.wikipedia.org/wiki/Image#/media/File:TEIDE.JPG)\n```\nconsole.log('Hello, World!')\n```", + expects: [ + { + type: 'text', + text: 'Hello World\nHello World\nHello World\nHello World\nHello ', + }, + { + type: 'image', + imageUrl: 'https://en.wikipedia.org/wiki/Image#/media/File:Image_created_with_a_mobile_phone.png', + }, + { + type: 'text', + text: '\nHello ', + }, + { + type: 'image', + imageUrl: 'https://en.wikipedia.org/wiki/Image#/media/File:TEIDE.JPG', + }, + { + type: 'text', + text: "\n
console.log('Hello, World!')\n
", + }, + ], + description: "Ensure that first image url doesn't override the second image url or vice versa", + }, +] + +describe('Markdown to Telegram HTML Conversion with Extracted Images', () => { + test.each(extractedImagesTestCases)('$description', ({ input, expects }) => { + const { html, extractedData } = stdMarkdownToTelegramHtml(input) + + // Every test case should have extracted images + if (!extractedData.images || extractedData.images.length === 0) { + throw new Error('The image extraction failed to extract images') + } + + const payloads = markdownHtmlToTelegramPayloads(html, extractedData.images) + + expect(payloads).toEqual(expects) + }) +}) diff --git a/integrations/telegram/src/misc/markdown-to-telegram-html.ts b/integrations/telegram/src/misc/markdown-to-telegram-html.ts new file mode 100644 index 00000000000..7c6e0381bfd --- /dev/null +++ b/integrations/telegram/src/misc/markdown-to-telegram-html.ts @@ -0,0 +1,201 @@ +import MarkdownIt from 'markdown-it' +import { nanoid } from 'nanoid' +import sanitizeHtml from 'sanitize-html' +import { spliceText } from './string-utils' + +const sanitizerConfig: sanitizeHtml.IOptions = { + allowedTags: ['strong', 'b', 'em', 'i', 's', 'del', 'code', 'pre', 'blockquote', 'a', 'img'], + allowedAttributes: { + a: ['href', 'title'], + code: ['class'], + img: ['src', 'srcset', 'alt', 'title'], + }, +} + +const md = MarkdownIt({ + xhtmlOut: true, + linkify: true, + breaks: false, + typographer: true, +}).disable(['table', 'list']) + +type RawImageData = { + marker: string + src: string + alt: string + title?: string +} + +type ImageData = { + src: string + alt: string + title?: string + pos: number +} + +type RawExtractedData = Partial<{ + images: RawImageData[] +}> + +type ExtractedData = Partial<{ + images: ImageData[] +}> + +function ruleHandler( + handler: ( + token: MarkdownIt.Token, + env: RawExtractedData, + tokens: MarkdownIt.Token[], + idx: number, + options: MarkdownIt.Options + ) => string +) { + return (tokens: MarkdownIt.Token[], idx: number, options: MarkdownIt.Options, env: RawExtractedData) => { + const token = tokens[idx] + if (!token) throw new Error('Token not found') + return handler(token, env, tokens, idx, options) + } +} + +const textReplacer = md.renderer.rules.text ?? ruleHandler((token) => md.utils.escapeHtml(token.content)) + +md.renderer.rules.paragraph_open = () => '\n' +md.renderer.rules.paragraph_close = () => '\n' +md.renderer.rules.heading_open = () => '' +md.renderer.rules.heading_close = () => '\n' +md.renderer.rules.hr = () => '\n' +md.renderer.rules.text = textReplacer + +md.renderer.rules.link_open = ruleHandler((token: MarkdownIt.Token) => { + const href = token.attrGet('href')?.trim() ?? '' + + // Just sends the email or the phone number as is since Telegram will be the one to convert it + if (href.startsWith('mailto:') || href.startsWith('tel:')) { + md.renderer.rules.text = () => '' + return href.replace(/mailto:|tel:/, '') + } + + const formattedAttributes = token.attrs?.reduce( + (formattedAttrs, [attrName, attrValue]) => `${formattedAttrs} ${attrName}="${attrValue}"`, + '' + ) + + return `<${token.tag}${formattedAttributes ?? ''}>` +}) + +md.renderer.rules.link_close = ruleHandler((token: MarkdownIt.Token) => { + if (md.renderer.rules.text !== textReplacer) { + md.renderer.rules.text = textReplacer + return '' + } + return `` +}) + +md.renderer.rules.image = ruleHandler((token: MarkdownIt.Token, env: RawExtractedData) => { + const src = token?.attrGet('src')?.trim() ?? '' + const alt = token?.content ?? '' + const title = token?.attrGet('title')?.trim() ?? '' + + if (src.length > 0) { + if (!env.images) env.images = [] + + const marker = `` + const imageData: RawImageData = { marker, src, alt } + if (title.length > 0) imageData.title = title + + env.images.push(imageData) + + return marker + } + + return '' +}) + +const _extractImagePositions = ( + html: string, + extractedImages: RawImageData[] +): { html: string; images: ImageData[] } => { + if (extractedImages.length === 0) return { html, images: [] } + + const images = extractedImages.map(({ marker, ...image }): ImageData => { + const pos = html.indexOf(marker) + if (pos === -1) { + // This should never be thrown, if it does, it's a bug + throw new Error('Image marker not found') + } + + html = spliceText(html, pos, pos + marker.length, '') + + return { + ...image, + pos, + } + }) + + return { + html, + images, + } +} + +export type MarkdownToTelegramHtmlResult = { + html: string + extractedData: ExtractedData +} +export function stdMarkdownToTelegramHtml(markdown: string): MarkdownToTelegramHtmlResult { + const rawExtractedData: RawExtractedData = {} + let telegramHtml = md + .render(markdown, rawExtractedData) + .trim() + // .replace(/\|\|([^|]([^\n\r]*[^|\n\r])?)\|\|/g, "$1") // Telegram Spoilers will be implemented in a later version + .replace(//g, '\n') + + const extractedData: ExtractedData = {} + if (rawExtractedData.images) { + const { html, images } = _extractImagePositions(telegramHtml, rawExtractedData.images) + telegramHtml = html + + if (images.length > 0) { + extractedData.images = images + } + } + + return { + html: sanitizeHtml(telegramHtml, sanitizerConfig), + extractedData, + } +} + +function _splitAtIndices(value: string, indices: number[]) { + const reversedSegments: string[] = [] + let remainder = value + + for (let i = indices.length - 1; i >= 0; i--) { + const splitPos = indices[i] + const segment = remainder.slice(splitPos) + remainder = remainder.slice(0, splitPos) + reversedSegments.push(segment) + } + + reversedSegments.push(remainder) + + return reversedSegments.reverse() +} + +export type MixedPayloads = ({ type: 'text'; text: string } | { type: 'image'; imageUrl: string })[] +export function markdownHtmlToTelegramPayloads(html: string, images: ImageData[]): MixedPayloads { + const imageIndices = images.map((image) => image.pos) + const htmlParts = _splitAtIndices(html, imageIndices) + + return htmlParts.reduce((payloads: MixedPayloads, htmlPart: string, index: number) => { + if (htmlPart.trim().length > 0) { + payloads.push({ type: 'text', text: htmlPart }) + } + const image = images[index] + if (image) { + payloads.push({ type: 'image', imageUrl: image.src }) + } + + return payloads + }, []) +} diff --git a/integrations/telegram/src/misc/message-handlers.ts b/integrations/telegram/src/misc/message-handlers.ts new file mode 100644 index 00000000000..a55bb35369a --- /dev/null +++ b/integrations/telegram/src/misc/message-handlers.ts @@ -0,0 +1,211 @@ +import { RuntimeError } from '@botpress/client' +import { Markup, Telegraf } from 'telegraf' +import { markdownHtmlToTelegramPayloads, stdMarkdownToTelegramHtml } from './markdown-to-telegram-html' +import { ackMessage, getChat, mapToRuntimeErrorAndThrow, sendCard } from './utils' +import * as bp from '.botpress' + +export type MessageHandlerProps = bp.MessageProps['channel'][T] + +const sendHtmlTextMessage = async ( + client: Telegraf, + ack: MessageHandlerProps<'text'>['ack'], + chat: string, + html: string +) => { + const message = await client.telegram + .sendMessage(chat, html, { + parse_mode: 'HTML', + }) + .catch(mapToRuntimeErrorAndThrow) + await ackMessage(message, ack) +} + +export const handleTextMessage = async (props: MessageHandlerProps<'text'>) => { + const { payload, ctx, conversation, ack, logger } = props + const { text } = payload + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + logger.forBot().debug(`Sending markdown message to Telegram chat ${chat}:`, text) + const { html, extractedData } = stdMarkdownToTelegramHtml(text) + + if (!extractedData.images || extractedData.images.length === 0) { + await sendHtmlTextMessage(client, ack, chat, html) + return + } + + const payloads = markdownHtmlToTelegramPayloads(html, extractedData.images) + + for (const payload of payloads) { + if (payload.type === 'text') { + await sendHtmlTextMessage(client, ack, chat, payload.text) + } else { + await handleImageMessage({ ...props, payload: { imageUrl: payload.imageUrl }, type: 'image' }) + } + } +} + +export const handleImageMessage = async ({ payload, ctx, conversation, ack, logger }: MessageHandlerProps<'image'>) => { + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + logger.forBot().debug(`Sending image message to Telegram chat ${chat}`, payload.imageUrl) + const message = await client.telegram + .sendPhoto(chat, payload.imageUrl, { + caption: payload.caption, + }) + .catch(mapToRuntimeErrorAndThrow) + await ackMessage(message, ack) +} + +export const handleAudioMessage = async ({ payload, ctx, conversation, ack, logger }: MessageHandlerProps<'audio'>) => { + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + logger.forBot().debug(`Sending audio voice to Telegram chat ${chat}:`, payload.audioUrl) + try { + const message = await client.telegram + .sendVoice(chat, payload.audioUrl, { caption: payload.caption }) + .catch(mapToRuntimeErrorAndThrow) + await ackMessage(message, ack) + } catch { + // If the audio file is too large to be voice, Telegram should send it as an audio file, but if for some reason it doesn't, we can send it as an audio file + const message = await client.telegram + .sendAudio(chat, payload.audioUrl, { caption: payload.caption }) + .catch(mapToRuntimeErrorAndThrow) + await ackMessage(message, ack) + } +} + +export const handleVideoMessage = async ({ payload, ctx, conversation, ack, logger }: MessageHandlerProps<'video'>) => { + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + logger.forBot().debug(`Sending video message to Telegram chat ${chat}:`, payload.videoUrl) + const message = await client.telegram.sendVideo(chat, payload.videoUrl).catch(mapToRuntimeErrorAndThrow) + await ackMessage(message, ack) +} + +export const handleFileMessage = async ({ payload, ctx, conversation, ack, logger }: MessageHandlerProps<'file'>) => { + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + logger.forBot().debug(`Sending file message to Telegram chat ${chat}:`, payload.fileUrl) + const message = await client.telegram.sendDocument(chat, payload.fileUrl).catch(mapToRuntimeErrorAndThrow) + await ackMessage(message, ack) +} + +export const handleLocationMessage = async ({ + payload, + ctx, + conversation, + ack, + logger, +}: MessageHandlerProps<'location'>) => { + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + logger.forBot().debug(`Sending location message to Telegram chat ${chat}:`, { + latitude: payload.latitude, + longitude: payload.longitude, + }) + const message = await client.telegram + .sendLocation(chat, payload.latitude, payload.longitude) + .catch(mapToRuntimeErrorAndThrow) + await ackMessage(message, ack) +} + +export const handleCardMessage = async ({ payload, ctx, conversation, ack, logger }: MessageHandlerProps<'card'>) => { + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + logger.forBot().debug(`Sending card message to Telegram chat ${chat}:`, payload) + await sendCard(payload, client, chat, ack) +} + +export const handleCarouselMessage = async ({ + payload, + ctx, + conversation, + ack, + logger, +}: MessageHandlerProps<'carousel'>) => { + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + logger.forBot().debug(`Sending carousel message to Telegram chat ${chat}:`, payload) + payload.items.forEach(async (item) => { + await sendCard(item, client, chat, ack) + }) +} + +export const handleDropdownMessage = async ({ + payload, + ctx, + conversation, + ack, + logger, +}: MessageHandlerProps<'dropdown'>) => { + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + const buttons = payload.options.map((choice) => Markup.button.callback(choice.label, choice.value)) + logger.forBot().debug(`Sending dropdown message to Telegram chat ${chat}:`, payload) + const message = await client.telegram + .sendMessage(chat, payload.text, Markup.keyboard(buttons).oneTime()) + .catch(mapToRuntimeErrorAndThrow) + await ackMessage(message, ack) +} + +export const handleChoiceMessage = async ({ + payload, + ctx, + conversation, + ack, + logger, +}: MessageHandlerProps<'choice'>) => { + const client = new Telegraf(ctx.configuration.botToken) + const chat = getChat(conversation) + logger.forBot().debug(`Sending choice message to Telegram chat ${chat}:`, payload) + const buttons = payload.options.map((choice) => Markup.button.callback(choice.label, choice.value)) + const message = await client.telegram + .sendMessage(chat, payload.text, Markup.keyboard(buttons).oneTime()) + .catch(mapToRuntimeErrorAndThrow) + await ackMessage(message, ack) +} + +export const handleBlocMessage = async ({ + client, + payload, + ctx, + conversation, + ...rest +}: MessageHandlerProps<'bloc'>) => { + if (payload.items.length > 20) { + throw new RuntimeError('Telegram only allows 20 messages to be sent every 60 seconds') + } + + for (const item of payload.items) { + switch (item.type) { + case 'text': + await handleTextMessage({ ...rest, type: item.type, client, payload: item.payload, ctx, conversation }) + break + case 'image': + await handleImageMessage({ ...rest, type: item.type, client, payload: item.payload, ctx, conversation }) + break + case 'audio': + await handleAudioMessage({ ...rest, type: item.type, client, payload: item.payload, ctx, conversation }) + break + case 'video': + await handleVideoMessage({ ...rest, type: item.type, client, payload: item.payload, ctx, conversation }) + break + case 'file': + await handleFileMessage({ ...rest, type: item.type, client, payload: item.payload, ctx, conversation }) + break + case 'location': + await handleLocationMessage({ + ...rest, + type: item.type, + client, + payload: item.payload, + ctx, + conversation, + }) + break + default: + // @ts-ignore + throw new RuntimeError(`Unsupported message type: ${item?.type ?? 'Unknown'}`) + } + } +} diff --git a/integrations/telegram/src/misc/string-utils.ts b/integrations/telegram/src/misc/string-utils.ts new file mode 100644 index 00000000000..9fe5a87fe67 --- /dev/null +++ b/integrations/telegram/src/misc/string-utils.ts @@ -0,0 +1,3 @@ +export const spliceText = (text: string, start: number, end: number, replacement: string) => { + return text.substring(0, start) + replacement + text.substring(end) +} diff --git a/integrations/telegram/src/misc/telegram-to-markdown.test.ts b/integrations/telegram/src/misc/telegram-to-markdown.test.ts new file mode 100644 index 00000000000..007cfe1d3ac --- /dev/null +++ b/integrations/telegram/src/misc/telegram-to-markdown.test.ts @@ -0,0 +1,652 @@ +import { describe, expect, test } from 'vitest' +import { telegramTextMsgToStdMarkdown, type TelegramMark } from './telegram-to-markdown' +import { TestCase } from '../../tests/types' + +type TelegramToMarkdownTestCase = TestCase< + { + text: string + marks: TelegramMark[] + }, + string +> & { expectsWarnings?: string[] } + +const telegramToMarkdownTestCases: TelegramToMarkdownTestCase[] = [ + // ==== Testing each mark type ==== + { + input: { + text: 'Bold', + marks: [ + { + offset: 0, + length: 4, + type: 'bold', + }, + ], + }, + expects: '**Bold**', + description: 'Apply bold mark to text', + }, + { + input: { + text: 'Italic', + marks: [ + { + offset: 0, + length: 6, + type: 'italic', + }, + ], + }, + expects: '*Italic*', + description: 'Apply italic mark to text', + }, + { + input: { + text: 'Strike', + marks: [ + { + offset: 0, + length: 6, + type: 'strikethrough', + }, + ], + }, + expects: '~~Strike~~', + description: 'Apply strikethrough mark to text', + }, + { + input: { + text: 'Spoiler', + marks: [ + { + offset: 0, + length: 7, + type: 'spoiler', + }, + ], + }, + expects: '||Spoiler||', + description: 'Apply spoiler mark to text', + }, + { + input: { + text: 'Code Snippet', + marks: [ + { + offset: 0, + length: 12, + type: 'code', + }, + ], + }, + expects: '`Code Snippet`', + description: 'Apply code mark to text', + }, + { + input: { + text: 'console.log("Code Block")', + marks: [ + { + offset: 0, + length: 25, + type: 'pre', + }, + ], + }, + expects: '```\nconsole.log("Code Block")\n```', + description: 'Apply code block mark to text - Without language', + }, + { + input: { + text: 'console.log("Code Block")', + marks: [ + { + offset: 0, + length: 25, + type: 'pre', + language: 'javascript', + }, + ], + }, + expects: '```javascript\nconsole.log("Code Block")\n```', + description: 'Apply code block mark to text - With language', + }, + { + input: { + text: 'Blockquote', + marks: [ + { + offset: 0, + length: 5, + type: 'blockquote', + }, + ], + }, + expects: '> Blockquote', + description: 'Apply blockquote mark to text', + }, + { + input: { + text: 'Hyperlink', + marks: [ + { + offset: 0, + length: 9, + type: 'text_link', + url: 'https://www.botpress.com/', + }, + ], + }, + expects: '[Hyperlink](https://www.botpress.com/)', + description: 'Apply text link mark to text', + }, + { + input: { + text: '514-123-4567', + marks: [ + { + offset: 0, + length: 12, + type: 'phone_number', + }, + ], + }, + expects: '[514-123-4567](tel:5141234567)', + description: 'Apply phone number mark to text', + }, + { + input: { + text: 'something@yopmail.com', + marks: [ + { + offset: 0, + length: 21, + type: 'email', + }, + ], + }, + expects: '[something@yopmail.com](mailto:something@yopmail.com)', + description: 'Apply email mark to text', + }, + { + input: { + text: 'Underline', + marks: [ + { + offset: 0, + length: 9, + type: 'underline', + }, + ], + }, + expects: 'Underline', + expectsWarnings: ['Unknown mark type: underline'], + description: 'Do not apply unsupported underline effect', + }, + // ===== Effect Overlapping Tests ===== + { + input: { + text: 'abcdefgh', + marks: [ + { offset: 0, length: 1, type: 'bold' }, + { offset: 1, length: 1, type: 'strikethrough' }, + { offset: 2, length: 1, type: 'bold' }, + { offset: 3, length: 1, type: 'strikethrough' }, + { offset: 4, length: 1, type: 'bold' }, + { offset: 5, length: 1, type: 'strikethrough' }, + { offset: 6, length: 1, type: 'bold' }, + { offset: 7, length: 1, type: 'strikethrough' }, + ], + }, + expects: '**a**~~b~~**c**~~d~~**e**~~f~~**g**~~h~~', + description: 'Contiguous non-overlapping effects should remain separate', + }, + { + input: { + text: 'Hello New World', + marks: [ + { offset: 0, length: 5, type: 'bold' }, + { offset: 10, length: 5, type: 'strikethrough' }, + ], + }, + expects: '**Hello** New ~~World~~', + description: 'Non-overlapping effects with gap should remain separate', + }, + { + input: { + text: 'Hello New World', + marks: [ + { offset: 0, length: 9, type: 'bold' }, + { offset: 6, length: 9, type: 'italic' }, + ], + }, + expects: '**Hello *New*** *World*', + description: 'Overlapping effect segments should be merged', + }, + { + input: { + text: 'Hello T World', + marks: [ + { offset: 0, length: 7, type: 'bold' }, + { offset: 6, length: 7, type: 'italic' }, + ], + }, + expects: '**Hello *T*** *World*', + description: 'Single character overlapping effect should be merged', + }, + { + input: { + text: 'Multiple Effects', + marks: [ + { + offset: 0, + length: 16, + type: 'bold', + }, + { + offset: 0, + length: 16, + type: 'strikethrough', + }, + ], + }, + expects: '~~**Multiple Effects**~~', + description: 'Overlapping effects on the same range get combined', + }, + { + input: { + text: 'C', + marks: [ + { + offset: 0, + length: 1, + type: 'bold', + }, + { + offset: 0, + length: 1, + type: 'strikethrough', + }, + ], + }, + expects: '~~**C**~~', + description: 'Overlapping effects on a single character get combined', + }, + { + input: { + text: 'Once upon a time', + marks: [ + { + offset: 0, + length: 4, + type: 'bold', + }, + { + offset: 0, + length: 16, + type: 'italic', + }, + ], + }, + expects: '***Once** upon a time*', + description: 'Encapsulated effect (start) gets nested', + }, + { + input: { + text: 'Once upon a time', + marks: [ + { + offset: 5, + length: 4, + type: 'bold', + }, + { + offset: 0, + length: 16, + type: 'italic', + }, + ], + }, + expects: '*Once **upon** a time*', + description: 'Encapsulated effect (center) gets nested', + }, + { + input: { + text: 'Once upon a time', + marks: [ + { + offset: 12, + length: 4, + type: 'bold', + }, + { + offset: 0, + length: 16, + type: 'italic', + }, + ], + }, + expects: '*Once upon a **time***', + description: 'Encapsulated effect (end) gets nested', + }, + { + input: { + text: 'Once upon a time', + marks: [ + { + offset: 10, + length: 1, + type: 'bold', + }, + { + offset: 0, + length: 16, + type: 'italic', + }, + ], + }, + expects: '*Once upon **a** time*', + description: 'Encapsulated effect on a single character gets nested', + }, + // ===== Advanced test cases ===== + { + input: { + text: 'Multiple Effects', + marks: [ + { offset: 0, length: 16, type: 'bold' }, + { offset: 0, length: 16, type: 'strikethrough' }, + { offset: 0, length: 16, type: 'italic' }, + { offset: 0, length: 16, type: 'spoiler' }, + ], + }, + expects: '||*~~**Multiple Effects**~~*||', + description: 'Multiple effects on the same range get combined', + }, + { + input: { + text: 'C', + marks: [ + { offset: 0, length: 1, type: 'bold' }, + { offset: 0, length: 1, type: 'strikethrough' }, + { offset: 0, length: 1, type: 'italic' }, + { offset: 0, length: 1, type: 'spoiler' }, + ], + }, + expects: '||*~~**C**~~*||', + description: 'Multiple effects on a single character get combined', + }, + { + input: { + text: 'FizzleWhizzyZigzagDazzleHuzzah', + marks: [ + { offset: 0, length: 18, type: 'bold' }, + { offset: 6, length: 18, type: 'strikethrough' }, + { offset: 12, length: 12, type: 'italic' }, + ], + }, + expects: '**Fizzle~~Whizzy*Zigzag*~~**~~*Dazzle*~~Huzzah', + description: 'Check that partial overlapping types get correctly nested', + }, + { + input: { + text: 'FizzleWhizzyZigzagDazzleHuzzah', + marks: [ + { offset: 0, length: 24, type: 'spoiler' }, + { offset: 6, length: 18, type: 'bold' }, + { offset: 12, length: 12, type: 'italic' }, + { offset: 18, length: 6, type: 'strikethrough' }, + ], + }, + expects: '||Fizzle**Whizzy*Zigzag~~Dazzle~~***||Huzzah', + description: 'Check that progressive overlapping types get correctly nested', + }, + { + input: { + text: 'Spoiler\n\n\n\n\n\nText', + marks: [ + { + offset: 0, + length: 17, + type: 'spoiler', + }, + ], + }, + expects: '||Spoiler\n\n\n\n\n\nText||', + description: 'Apply mark effect to multiline text', + }, + { + input: { + text: 'Hello\nNothing\nMore Quotes!', + marks: [ + { + offset: 0, + length: 5, + type: 'blockquote', + }, + { + offset: 14, + length: 12, + type: 'blockquote', + }, + ], + }, + expects: '> Hello\nNothing\n> More Quotes!', + description: 'Apply blockquote markdown to multiple lines, with non-quote line in between', + }, + { + input: { + text: 'Hello Quote World', + marks: [ + { + offset: 0, + length: 17, + type: 'blockquote', + }, + { + offset: 6, + length: 5, + type: 'spoiler', + }, + ], + }, + expects: '> Hello ||Quote|| World', + description: + // An incorrect outcome of this test case would be "> Hello ||> Quote||> World" + "Ensure any effect nested within blockquote mark doesn't create multiple blockquote marks (It should only ever be at the start of a line)", + }, + { + input: { + text: 'Quote Line 1\nQuote Line 2\nQuote Line 3', + marks: [ + { + offset: 0, + length: 38, + type: 'blockquote', + }, + ], + }, + expects: '> Quote Line 1\n> Quote Line 2\n> Quote Line 3', + description: 'Multiline blockquote produces a blockquote mark for each line', + }, + { + input: { + text: 'Quote Line 1\nQuote Line 2\nQuote Line 3', + marks: [ + { + offset: 0, + length: 38, + type: 'blockquote', + }, + { + offset: 13, + length: 12, + type: 'bold', + }, + ], + }, + expects: '> Quote Line 1\n> **Quote Line 2**\n> Quote Line 3', + description: 'Multiline blockquote produces a blockquote mark for each line, with intersecting effect', + }, + { + input: { + text: 'Quote Line 1\n\n\n\nQuote Line 2', + marks: [ + { + offset: 0, + length: 28, + type: 'blockquote', + }, + ], + }, + expects: '> Quote Line 1\n> \n> \n> \n> Quote Line 2', + description: 'Multiline blockquote produces a blockquote mark for each line, with empty lines', + }, + { + input: { + text: 'Quote Line 1\n\n\n\nQuote Line 2', + marks: [ + { + offset: 0, + length: 28, + type: 'blockquote', + }, + { + offset: 0, + length: 28, + type: 'bold', + }, + ], + }, + expects: '> **Quote Line 1\n> \n> \n> \n> Quote Line 2**', + description: + 'Multiline blockquote produces a blockquote mark for each line, with empty lines and intersecting effect', + }, + { + input: { + text: 'Hello Many Effects World', + marks: [ + { + offset: 6, + length: 12, + type: 'bold', + }, + { + offset: 6, + length: 12, + type: 'italic', + }, + { + offset: 6, + length: 12, + type: 'strikethrough', + }, + ], + }, + expects: 'Hello ~~***Many Effects***~~ World', + description: "Apply multiple effects to phrase 'Many Effects'", + }, + { + input: { + text: 'Some Link', + marks: [ + { + offset: 0, + length: 9, + type: 'text_link', + url: 'https://botpress.com/', + }, + { + offset: 5, + length: 4, + type: 'bold', + }, + ], + }, + expects: '[Some **Link**](https://botpress.com/)', + description: 'Apply markdown effects to specific words in hyperlink text', + }, + { + input: { + text: 'Some Nested Marks', + marks: [ + { + offset: 0, + length: 17, + type: 'spoiler', + }, + { + offset: 0, + length: 5, + type: 'italic', + }, + { + offset: 5, + length: 6, + type: 'bold', + }, + ], + }, + expects: '||*Some* **Nested** Marks||', + description: 'Nested effects maintain their start/end positions in post-process', + }, + { + input: { + text: 'Some Nested Marks', + marks: [ + { + offset: 0, + length: 5, + type: 'italic', + }, + { + offset: 0, + length: 9, + type: 'spoiler', + }, + { + offset: 5, + length: 6, + type: 'bold', + }, + ], + }, + expects: '||*Some* **Nest**||**ed** Marks', + description: 'Ensure no overlapping when a longer mark partially nests/cuts off a smaller mark', + }, + { + input: { + text: 'Hello Many Effects World', + marks: [ + { + offset: 6, + length: 12, + type: 'bold', + }, + { + offset: 8, + length: 10, + type: 'italic', + }, + ], + }, + expects: 'Hello **Ma*ny Effects*** World', + description: 'Apply effect encapsulated within another effect', + }, +] + +const _alphabetically = (a: string, b: string) => a.localeCompare(b) + +describe('Telegram to Markdown Conversion', () => { + test.each(telegramToMarkdownTestCases)( + '$description', + ({ input, expects, expectsWarnings }: TelegramToMarkdownTestCase) => { + const { text, warnings = [] } = telegramTextMsgToStdMarkdown(input.text, input.marks) + + if (expectsWarnings && expectsWarnings.length > 0) { + expect(warnings.sort(_alphabetically)).toEqual(expectsWarnings.sort(_alphabetically)) + } + + expect(text).toBe(expects) + } + ) +}) diff --git a/integrations/telegram/src/misc/telegram-to-markdown.ts b/integrations/telegram/src/misc/telegram-to-markdown.ts new file mode 100644 index 00000000000..a6be70547a8 --- /dev/null +++ b/integrations/telegram/src/misc/telegram-to-markdown.ts @@ -0,0 +1,323 @@ +import { spliceText } from './string-utils' + +type Range = { + /** Inclusive */ + start: number + /** Exclusive */ + end: number +} + +type MarkEffect = { + type: string + url?: string + language?: string +} + +type MarkSegment = Range & { + effects: MarkEffect[] + /** A set of effect(s) that are encompassed by a parent effect scope + * + * @remark The nested segments' range MUST be within the parent's bounds */ + children?: MarkSegment[] +} + +export type TelegramMark = { + type: string + offset: number + length: number + url?: string + language?: string +} + +// Higher === Applied after other effects +const markSortOffsets: Record = { + bold: 0, + italic: 0, + strikethrough: 0, + spoiler: 0, + code: 1, + pre: 1, + blockquote: 2, + text_link: 0, + phone_number: 0, + email: 0, + underline: 0, +} + +const _applyWhitespaceSensitiveMark = (markHandler: MarkHandler) => { + return (text: string, data: Record): string => { + let startWhitespace: string | undefined = undefined + let endWhitespace: string | undefined = undefined + const matchAllResult = text.matchAll(/(?^\s+)|(?\s+$)/g) + Array.from(matchAllResult).forEach((match) => { + startWhitespace ??= match.groups?.start + endWhitespace ??= match.groups?.end + }) + + return `${startWhitespace ?? ''}${markHandler(text.trim(), data)}${endWhitespace ?? ''}` + } +} + +type MarkHandler = (text: string, data: Record) => string +const _handlers: Record = { + bold: _applyWhitespaceSensitiveMark((text: string) => `**${text}**`), + italic: _applyWhitespaceSensitiveMark((text: string) => `*${text}*`), + strikethrough: _applyWhitespaceSensitiveMark((text: string) => `~~${text}~~`), + spoiler: _applyWhitespaceSensitiveMark((text: string) => `||${text}||`), + code: _applyWhitespaceSensitiveMark((text: string) => `\`${text}\``), + pre: (text: string, data: Record) => `\`\`\`${data?.language || ''}\n${text}\n\`\`\``, + blockquote: (text: string) => `> ${text.replace(/\n/g, '\n> ')}`, + text_link: _applyWhitespaceSensitiveMark( + (text: string, data: Record) => `[${text}](${data?.url || '#'})` + ), + phone_number: _applyWhitespaceSensitiveMark((text: string) => { + return `[${text}](tel:${text.replace(/\D/g, '')})` + }), + email: _applyWhitespaceSensitiveMark((text: string) => { + return `[${text}](mailto:${text})` + }), +} + +const _isOverlapping = (a: Range, b: Range) => { + return a.start < b.end && b.start < a.end +} + +const _splitIfOverlapping = (rangeA: MarkSegment, rangeB: MarkSegment): MarkSegment[] => { + if (!_isOverlapping(rangeA, rangeB)) { + return [rangeA] + } + + const result: MarkSegment[] = [] + const startOverlap = Math.max(rangeA.start, rangeB.start) + const endOverlap = Math.min(rangeA.end, rangeB.end) + + const startMin = Math.min(rangeA.start, rangeB.start) + if (startMin < startOverlap) { + const startType = rangeA.start < startOverlap ? rangeA.effects : rangeB.effects + result.push({ start: startMin, end: startOverlap, effects: startType }) + } + + result.push({ start: startOverlap, end: endOverlap, effects: rangeA.effects.concat(rangeB.effects) }) + + const endMax = Math.max(rangeA.end, rangeB.end) + if (endOverlap < endMax) { + const endType = endOverlap < rangeB.end ? rangeB.effects : rangeA.effects + result.push({ start: endOverlap, end: endMax, effects: endType }) + } + + return result +} + +const _combineEffects = (range: MarkSegment, otherIndex: number, arr: MarkSegment[]) => { + if (otherIndex !== -1) { + const otherEffects = arr[otherIndex]?.effects ?? [] + const uniqueEffects = range.effects.filter( + ({ type }: MarkEffect) => !otherEffects.some(({ type: otherType }: MarkEffect) => type === otherType) + ) + arr[otherIndex]?.effects.push(...uniqueEffects) + } +} + +const _byAscendingStartThenByDescendingLength = (a: MarkSegment, b: MarkSegment) => { + return a.start !== b.start ? a.start - b.start : b.end - a.end +} +const _byDescendingStartIndex = (a: MarkSegment, b: MarkSegment) => b.start - a.start +const _splitAnyOverlaps = (ranges: MarkSegment[]): MarkSegment[] => { + if (ranges.length < 2) { + return ranges + } + + // TODO: Optimize if possible + const rangesToSplit = [...ranges].sort(_byAscendingStartThenByDescendingLength) + return rangesToSplit.reduce( + (splitRanges: MarkSegment[], range: MarkSegment) => { + let newSplitRanges = splitRanges + .reduce((arr: MarkSegment[], otherRange: MarkSegment) => { + const newRanges = _splitIfOverlapping(otherRange, range) + return arr.concat(newRanges) + }, []) + .filter((range: MarkSegment, index: number, arr: MarkSegment[]) => { + if (range.start === range.end) return false + const otherIndex = arr.findIndex(({ start, end }) => range.start === start && range.end === end) + _combineEffects(range, otherIndex, arr) + return index === otherIndex + }) + + if (newSplitRanges.every((otherRange) => !_isOverlapping(range, otherRange))) { + newSplitRanges = newSplitRanges.concat(range).sort(_byAscendingStartThenByDescendingLength) + } + + return newSplitRanges + }, + [rangesToSplit.shift()!] + ) +} + +const _areSegmentsNonOverlappingContiguous = (text: string, sortedRanges: MarkSegment[]) => { + if (sortedRanges.length === 0) return true + + // Not sure if this check should be done at runtime or if we should just trust the unit tests here + if (sortedRanges[0]!.start !== 0 || sortedRanges[sortedRanges.length - 1]!.end !== text.length) { + return false + } + + return sortedRanges.every((range, index, arr) => { + const nextRange = arr[index + 1] + if (!nextRange) return true + + return range.end === nextRange.start + }) +} + +const _hasMarkType = (marks: MarkEffect[], markType: string) => { + return marks.some((otherMark) => otherMark.type === markType) +} + +const _postProcessNestedEffects = ( + unprocessedSegments: MarkSegment[], + precheck?: (sortedSegments: MarkSegment[]) => void +) => { + const reversedSegments: MarkSegment[] = [...unprocessedSegments].sort(_byDescendingStartIndex) + precheck?.([...reversedSegments].reverse()) + + for (let index = reversedSegments.length - 1; index > 0; index--) { + const segment: MarkSegment = reversedSegments[index]! + if (segment.effects.length === 0) continue + + const otherIndex = index - 1 + const otherSegment: MarkSegment = reversedSegments[otherIndex]! + + const sharedMarks: MarkEffect[] = [] + const segmentNonSharedMarks: MarkEffect[] = [] + segment.effects.forEach((mark: MarkEffect) => { + if (_hasMarkType(otherSegment.effects, mark.type)) { + sharedMarks.push(mark) + } else { + segmentNonSharedMarks.push(mark) + } + }) + + if (sharedMarks.length === 0) { + continue + } + + const otherSegmentNonSharedMarks: MarkEffect[] = otherSegment.effects.filter( + (mark: MarkEffect) => !_hasMarkType(sharedMarks, mark.type) + ) + + let childSegments: MarkSegment[] = [...(segment.children ?? [])].concat(otherSegment.children ?? []) + if (segmentNonSharedMarks.length > 0) { + childSegments = childSegments.concat({ + start: segment.start, + end: segment.end, + effects: segmentNonSharedMarks, + }) + } + if (otherSegmentNonSharedMarks.length > 0) { + childSegments = childSegments.concat({ + start: otherSegment.start, + end: otherSegment.end, + effects: otherSegmentNonSharedMarks, + }) + } + + const mergedSegment: MarkSegment = { + start: segment.start, + end: otherSegment.end, + effects: sharedMarks, + } + if (childSegments.length > 0) { + mergedSegment.children = childSegments + } + + reversedSegments[otherIndex] = mergedSegment + reversedSegments.splice(index, 1) + } + + // This is done after the above loop to not post-process + // the children before all the potential children are added + const processedSegments = reversedSegments.reverse() + processedSegments.forEach((segment) => { + if (segment.children) { + segment.children = _postProcessNestedEffects(segment.children) + } + }) + + return processedSegments +} + +const _applyMarkToTextSegment = (text: string, segment: MarkSegment, offset: number = 0) => { + const unknownMarkWarnings: string[] = [] + const { start, end, effects, children: nonOverlappingChildren } = segment + const startIndex = start - offset + let transformedText = text.substring(startIndex, end - offset) + + if (nonOverlappingChildren) { + nonOverlappingChildren.sort(_byDescendingStartIndex).forEach((child) => { + const { text: transformedSegment, warnings } = _applyMarkToTextSegment(transformedText, child, start) + transformedText = spliceText(transformedText, child.start - start, child.end - start, transformedSegment) + unknownMarkWarnings.push(...warnings) + }) + } + + effects + .sort((a, b) => { + return (markSortOffsets[a.type] ?? 0) - (markSortOffsets[b.type] ?? 0) + }) + .forEach((effect) => { + const { type, url, language } = effect + + // @ts-ignore + const handler = _handlers[type] as Function | undefined + if (!handler) { + unknownMarkWarnings.push(`Unknown mark type: ${type}`) + return + } + + transformedText = handler(transformedText, { + url, + language, + }) + }) + + return { text: transformedText, warnings: unknownMarkWarnings } +} + +export const telegramTextMsgToStdMarkdown = (text: string, marks: TelegramMark[] = []) => { + if (marks.length === 0) return { text } + + const segments = marks.map((mark: TelegramMark): MarkSegment => { + const start = mark.offset + const end = mark.offset + mark.length + return { + start, + end, + effects: [ + { + type: mark.type, + url: mark.url, + language: mark.language, + }, + ], + } + }) + + const plainTextSegment = { start: 0, end: text.length, effects: [] } + const nonOverlappingSegments = _splitAnyOverlaps(segments.concat(plainTextSegment)) + const processedSegments = _postProcessNestedEffects(nonOverlappingSegments, (sortedSegments) => { + // This should never be thrown at runtime. If it is, then it means there's a bug in the logic + if (!_areSegmentsNonOverlappingContiguous(text, sortedSegments)) { + throw new Error('Nested effects are not contiguous') + } + }) + + let transformedText = '' + const transformWarnings: string[] = [] + for (const markSegment of processedSegments) { + const { text: textSegment, warnings } = _applyMarkToTextSegment(text, markSegment) + transformWarnings.push(...warnings) + transformedText += textSegment + } + + return { text: transformedText, warnings: transformWarnings } +} diff --git a/integrations/telegram/src/misc/utils.ts b/integrations/telegram/src/misc/utils.ts index 42f1214a788..260773ef43d 100644 --- a/integrations/telegram/src/misc/utils.ts +++ b/integrations/telegram/src/misc/utils.ts @@ -1,14 +1,23 @@ import { axios } from '@botpress/client' -import { Response, RuntimeError } from '@botpress/sdk' +import { IntegrationLogger, Response, RuntimeError } from '@botpress/sdk' import { ok } from 'assert' import _ from 'lodash' -import { Context, Markup, Telegraf, Telegram } from 'telegraf' +import { Context, Markup, Telegraf, Telegram, TelegramError } from 'telegraf' import { PhotoSize, Update, User, Sticker } from 'telegraf/typings/core/types/typegram' +import { telegramTextMsgToStdMarkdown } from './telegram-to-markdown' import { Card, AckFunction, Logger, MessageHandlerProps, BotpressMessage, TelegramMessage } from './types' import * as bp from '.botpress' export const USER_PICTURE_MAX_SIZE_BYTES = 25_000 +export function mapToRuntimeErrorAndThrow(thrown: unknown): never { + if (thrown instanceof TelegramError) { + throw new RuntimeError(thrown.description, thrown) + } + + throw thrown instanceof Error ? new RuntimeError(thrown.message, thrown) : new RuntimeError(String(thrown)) +} + export async function ackMessage(message: TelegramMessage, ack: AckFunction) { await ack({ tags: { id: `${message.message_id}` } }) } @@ -26,21 +35,25 @@ export async function sendCard(payload: Card, client: Telegraf>, case 'say': return Markup.button.callback(item.label, `say:${item.value}`) default: - throw new Error(`Unknown action type: ${item.action}`) + throw new RuntimeError(`Unknown action type: ${item.action}`) } }) if (payload.imageUrl) { - const message = await client.telegram.sendPhoto(chat, payload.imageUrl, { - caption: text, - parse_mode: 'MarkdownV2', - ...Markup.inlineKeyboard(buttons), - }) + const message = await client.telegram + .sendPhoto(chat, payload.imageUrl, { + caption: text, + parse_mode: 'MarkdownV2', + ...Markup.inlineKeyboard(buttons), + }) + .catch(mapToRuntimeErrorAndThrow) await ackMessage(message, ack) } else { - const message = await client.telegram.sendMessage(chat, text, { - parse_mode: 'MarkdownV2', - ...Markup.inlineKeyboard(buttons), - }) + const message = await client.telegram + .sendMessage(chat, text, { + parse_mode: 'MarkdownV2', + ...Markup.inlineKeyboard(buttons), + }) + .catch(mapToRuntimeErrorAndThrow) await ackMessage(message, ack) } } @@ -95,7 +108,7 @@ const getMimeTypeFromExtension = (extension: string): string => { const getDataUriFromImgHref = async (imgHref: string): Promise => { const fileExtension = imgHref.substring(imgHref.lastIndexOf('.') + 1) - const { data } = await axios.default.get(imgHref, { responseType: 'arraybuffer' }) + const { data } = await axios.default.get(imgHref, { responseType: 'arraybuffer' }).catch(mapToRuntimeErrorAndThrow) const base64File = Buffer.from(data, 'binary').toString('base64') @@ -128,7 +141,7 @@ export const getUserPictureDataUri = async ({ }): Promise => { try { const telegraf = new Telegraf(botToken) - const res = await telegraf.telegram.getUserProfilePhotos(telegramUserId) + const res = await telegraf.telegram.getUserProfilePhotos(telegramUserId).catch(mapToRuntimeErrorAndThrow) logger.forBot().debug('Fetched user profile pictures from Telegram') if (!res.photos[0]) { @@ -138,7 +151,7 @@ export const getUserPictureDataUri = async ({ const photoToUse = getBestPhotoSize(res.photos[0]) if (photoToUse) { - const fileLink = await telegraf.telegram.getFileLink(photoToUse.file_id) + const fileLink = await telegraf.telegram.getFileLink(photoToUse.file_id).catch(mapToRuntimeErrorAndThrow) return await getDataUriFromImgHref(fileLink.href) } @@ -152,15 +165,17 @@ export const getUserPictureDataUri = async ({ export const convertTelegramMessageToBotpressMessage = async ({ message, telegram, + logger, }: { message: TelegramMessage telegram: Telegram + logger: IntegrationLogger }): Promise => { if ('photo' in message) { const photo = _.maxBy(message.photo, (photo) => photo.height * photo.width) ok(photo, 'No photo found in message') - const fileUrl = await telegram.getFileLink(photo.file_id) + const fileUrl = await telegram.getFileLink(photo.file_id).catch(mapToRuntimeErrorAndThrow) return { type: 'image', @@ -172,7 +187,7 @@ export const convertTelegramMessageToBotpressMessage = async ({ if ('sticker' in message) { const stickerMessage = message as TelegramMessage & { sticker: Sticker } - const fileUrl = await telegram.getFileLink(stickerMessage.sticker.file_id) + const fileUrl = await telegram.getFileLink(stickerMessage.sticker.file_id).catch(mapToRuntimeErrorAndThrow) return { type: 'image', payload: { @@ -182,7 +197,7 @@ export const convertTelegramMessageToBotpressMessage = async ({ } if ('audio' in message) { - const fileUrl = await telegram.getFileLink(message.audio.file_id) + const fileUrl = await telegram.getFileLink(message.audio.file_id).catch(mapToRuntimeErrorAndThrow) return { type: 'audio', payload: { @@ -192,7 +207,7 @@ export const convertTelegramMessageToBotpressMessage = async ({ } if ('voice' in message) { - const fileUrl = await telegram.getFileLink(message.voice.file_id) + const fileUrl = await telegram.getFileLink(message.voice.file_id).catch(mapToRuntimeErrorAndThrow) return { type: 'audio', payload: { @@ -202,7 +217,7 @@ export const convertTelegramMessageToBotpressMessage = async ({ } if ('video' in message) { - const fileUrl = await telegram.getFileLink(message.video.file_id) + const fileUrl = await telegram.getFileLink(message.video.file_id).catch(mapToRuntimeErrorAndThrow) return { type: 'video', payload: { @@ -212,7 +227,7 @@ export const convertTelegramMessageToBotpressMessage = async ({ } if ('document' in message) { - const fileUrl = await telegram.getFileLink(message.document.file_id) + const fileUrl = await telegram.getFileLink(message.document.file_id).catch(mapToRuntimeErrorAndThrow) return { type: 'file', payload: { @@ -222,9 +237,12 @@ export const convertTelegramMessageToBotpressMessage = async ({ } if ('text' in message) { + const { text, warnings } = telegramTextMsgToStdMarkdown(message.text, message.entities) + warnings?.forEach((warningMsg) => logger.forBot().warn(warningMsg)) + return { type: 'text', - payload: { text: message.text }, + payload: { text }, } } diff --git a/integrations/telegram/tests/types.ts b/integrations/telegram/tests/types.ts new file mode 100644 index 00000000000..732feff85a5 --- /dev/null +++ b/integrations/telegram/tests/types.ts @@ -0,0 +1,6 @@ +export type TestCase = { + input: INPUT + expects: EXPECTED + description: string + skip?: boolean +} diff --git a/integrations/zendesk/integration.definition.ts b/integrations/zendesk/integration.definition.ts index decb69e51eb..7f993bb3be5 100644 --- a/integrations/zendesk/integration.definition.ts +++ b/integrations/zendesk/integration.definition.ts @@ -7,7 +7,7 @@ import { actions, events, configuration, channels, states, user } from './src/de export default new sdk.IntegrationDefinition({ name: 'zendesk', title: 'Zendesk', - version: '2.7.0', + version: '2.8.0', icon: 'icon.svg', description: 'Optimize your support workflow. Trigger workflows from ticket updates as well as manage tickets, access conversations, and engage with customers.', @@ -34,6 +34,18 @@ export default new sdk.IntegrationDefinition({ 'Via Channel to use (example: "whatsapp", "instagram_dm" ), only use values documented by Zendesk, check the "Info" tab at the Zendesk integration configuration page for more details. Leave empty or use an invalid channel type and you will get "API".' ) .optional(), + chatbotName: sdk.z + .string() + .title('Chatbot Name') + .describe('Name of the chatbot that will be used in the Zendesk ticket. Defaults to "Botpress".') + .optional(), + chatbotPhotoUrl: sdk.z + .string() + .title('Chatbot Photo URL') + .describe( + 'Photo URL of the chatbot that will be used in the Zendesk ticket. Must be a publicly-accessible PNG image. Defaults to Botpress logo.' + ) + .optional(), }), }, }, diff --git a/integrations/zendesk/src/actions/hitl.ts b/integrations/zendesk/src/actions/hitl.ts index 3c2bbbfd86f..7869b3d43cb 100644 --- a/integrations/zendesk/src/actions/hitl.ts +++ b/integrations/zendesk/src/actions/hitl.ts @@ -1,27 +1,32 @@ import { buildConversationTranscript } from '@botpress/common' import * as sdk from '@botpress/sdk' -import { getZendeskClient } from '../client' +import { getZendeskClient, type ZendeskClient } from '../client' import * as bp from '.botpress' export const startHitl: bp.IntegrationProps['actions']['startHitl'] = async (props) => { const { ctx, input, client } = props - const zendeskClient = getZendeskClient(ctx.configuration) - - const { user } = await client.getUser({ - id: input.userId, - }) - const zendeskAuthorId = user.tags.id - if (!zendeskAuthorId) { - throw new sdk.RuntimeError(`User ${user.id} not linked in Zendesk`) - } const { viaChannel, priority } = input.hitlSession || {} + const downstreamBotpressUser = await client.getUser({ id: ctx.botUserId }) + const chatbotName = input.hitlSession?.chatbotName ?? downstreamBotpressUser.user.name ?? 'Botpress' + const chatbotPhotoUrl = + input.hitlSession?.chatbotPhotoUrl ?? + downstreamBotpressUser.user.pictureUrl ?? + 'https://app.botpress.dev/favicon/bp.svg' + + const zendeskClient = getZendeskClient(ctx.configuration) + const zendeskBotpressUser = await _retrieveAndUpdateZendeskBotpressUser(props, { + zendeskClient, + chatbotName, + chatbotPhotoUrl, + }) + const ticket = await zendeskClient.createTicket( input.title ?? 'Untitled Ticket', - await _buildTicketBody(props), + await _buildTicketBody(props, { chatbotName }), { - id: zendeskAuthorId, + id: zendeskBotpressUser, }, { priority, @@ -48,9 +53,34 @@ export const startHitl: bp.IntegrationProps['actions']['startHitl'] = async (pro } } -const _buildTicketBody = async ({ input, client, ctx }: bp.ActionProps['startHitl']) => { - const description = input.description?.trim() || 'Someone opened a ticket using your Botpress chatbot.' +const _retrieveAndUpdateZendeskBotpressUser = async ( + { client, ctx }: bp.ActionProps['startHitl'], + { + zendeskClient, + chatbotName, + chatbotPhotoUrl, + }: { zendeskClient: ZendeskClient; chatbotName: string; chatbotPhotoUrl: string } +) => { + await client.updateUser({ + id: ctx.botUserId, + pictureUrl: chatbotPhotoUrl, + name: chatbotName, + }) + + const zendeskUser = await zendeskClient.createOrUpdateUser({ + external_id: ctx.botUserId, + name: chatbotName, + remote_photo_url: chatbotPhotoUrl, + }) + + return String(zendeskUser.id) +} +const _buildTicketBody = async ( + { input, client, ctx }: bp.ActionProps['startHitl'], + { chatbotName }: { chatbotName: string } +) => { + const description = input.description?.trim() || `Someone opened a ticket using your ${chatbotName} chatbot.` const messageHistory = await buildConversationTranscript({ client, ctx, messages: input.messageHistory }) return description + (messageHistory.length ? `\n\n---\n\n${messageHistory}` : '') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e9a3f04ad0..d9094406d0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + patchedDependencies: source-map-js@1.2.1: hash: h25dep36e76b3zca3v6s2554fi @@ -1430,6 +1434,15 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + markdown-it: + specifier: ^14.1.0 + version: 14.1.0 + nanoid: + specifier: ^5.1.5 + version: 5.1.5 + sanitize-html: + specifier: ^2.17.0 + version: 2.17.0 telegraf: specifier: ^4.16.3 version: 4.16.3 @@ -1449,6 +1462,12 @@ importers: '@types/lodash': specifier: ^4.14.191 version: 4.14.195 + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 + '@types/sanitize-html': + specifier: ^2.16.0 + version: 2.16.0 integrations/todoist: dependencies: @@ -2218,7 +2237,7 @@ importers: version: 4.17.21 tsup: specifier: ^8.0.2 - version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) + version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) packages/cognitive: dependencies: @@ -2261,7 +2280,7 @@ importers: version: 11.1.6 tsup: specifier: ^8.0.2 - version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) + version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) packages/common: dependencies: @@ -2434,7 +2453,7 @@ importers: version: 0.3.0(esbuild@0.16.17) tsup: specifier: ^8.0.2 - version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) + version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) packages/sdk-addons: dependencies: @@ -2486,7 +2505,7 @@ importers: version: 9.3.5 tsup: specifier: ^8.0.2 - version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) + version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) packages/zai: dependencies: @@ -2544,7 +2563,7 @@ importers: version: 11.1.6 tsup: specifier: ^8.0.2 - version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) + version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) plugins/analytics: dependencies: @@ -8666,6 +8685,12 @@ packages: resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} dev: false + /@types/sanitize-html@2.16.0: + resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} + dependencies: + htmlparser2: 8.0.2 + dev: true + /@types/semver@7.5.0: resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} dev: true @@ -8870,7 +8895,7 @@ packages: engines: {node: '>=20.0.0'} dependencies: http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 tslib: 2.6.2 transitivePeerDependencies: - supports-color @@ -9086,13 +9111,9 @@ packages: transitivePeerDependencies: - supports-color - /agent-base@7.1.0: - resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} + /agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - dependencies: - debug: 4.4.1 - transitivePeerDependencies: - - supports-color dev: false /agentkeepalive@4.5.0: @@ -10881,11 +10902,9 @@ packages: domelementtype: 2.3.0 domhandler: 5.0.3 entities: 4.5.0 - dev: false /domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - dev: false /domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} @@ -10899,7 +10918,6 @@ packages: engines: {node: '>= 4'} dependencies: domelementtype: 2.3.0 - dev: false /domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -10915,7 +10933,6 @@ packages: dom-serializer: 2.0.0 domelementtype: 2.3.0 domhandler: 5.0.3 - dev: false /dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -11039,7 +11056,6 @@ packages: /entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - dev: false /environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} @@ -11410,7 +11426,6 @@ packages: /escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - dev: true /escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} @@ -12236,7 +12251,7 @@ packages: engines: {node: '>=14'} dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 is-stream: 2.0.1 node-fetch: 2.7.0 transitivePeerDependencies: @@ -12813,7 +12828,6 @@ packages: domhandler: 5.0.3 domutils: 3.1.0 entities: 4.5.0 - dev: false /http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} @@ -12834,7 +12848,7 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} dependencies: - agent-base: 7.1.0 + agent-base: 7.1.4 debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -12858,11 +12872,11 @@ packages: transitivePeerDependencies: - supports-color - /https-proxy-agent@7.0.2: - resolution: {integrity: sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==} + /https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} dependencies: - agent-base: 7.1.0 + agent-base: 7.1.4 debug: 4.4.1 transitivePeerDependencies: - supports-color @@ -14406,9 +14420,8 @@ packages: engines: {node: '>=8'} dev: false - /lru-cache@10.2.0: - resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} - engines: {node: 14 || >=16.14} + /lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} /lru-cache@11.0.2: resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} @@ -15155,6 +15168,12 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + /nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + dev: false + /nanospinner@1.2.2: resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} dependencies: @@ -15642,6 +15661,10 @@ packages: lines-and-columns: 1.2.4 dev: true + /parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + dev: false + /parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} dependencies: @@ -15694,7 +15717,7 @@ packages: resolution: {integrity: sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==} engines: {node: '>=16 || 14 >=14.17'} dependencies: - lru-cache: 10.2.0 + lru-cache: 10.4.3 minipass: 7.1.2 /path-to-regexp@0.1.12: @@ -16029,8 +16052,8 @@ packages: engines: {node: '>=6'} dev: false - /punycode@2.3.0: - resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} + /punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} /pure-rand@6.0.2: @@ -16550,6 +16573,17 @@ packages: engines: {node: '>= 0.10'} dev: false + /sanitize-html@2.17.0: + resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==} + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.4.47 + dev: false + /sax@1.2.4: resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} dev: false @@ -17443,7 +17477,7 @@ packages: engines: {node: '>=0.8'} dependencies: psl: 1.9.0 - punycode: 2.3.0 + punycode: 2.3.1 dev: false /tough-cookie@3.0.1: @@ -17452,7 +17486,7 @@ packages: dependencies: ip-regex: 2.1.0 psl: 1.9.0 - punycode: 2.3.0 + punycode: 2.3.1 dev: false /tr46@0.0.3: @@ -17461,7 +17495,7 @@ packages: /tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} dependencies: - punycode: 2.3.0 + punycode: 2.3.1 dev: true /tree-kill@1.2.2: @@ -17694,6 +17728,45 @@ packages: - ts-node dev: true + /tsup@8.0.2(ts-node@10.9.2)(typescript@5.6.3): + resolution: {integrity: sha512-NY8xtQXdH7hDUAZwcQdY/Vzlw9johQsaqf7iwZ6g1DOUlFYQ5/AtVAjTvihhEyeRlGo4dLRVHtrRaL35M1daqQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + dependencies: + bundle-require: 4.0.2(esbuild@0.19.12) + cac: 6.7.14 + chokidar: 3.5.3 + debug: 4.4.0 + esbuild: 0.19.12 + execa: 5.1.1 + globby: 11.1.0 + joycon: 3.1.1 + postcss-load-config: 4.0.2(ts-node@10.9.2) + resolve-from: 5.0.0 + rollup: 4.24.2 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tree-kill: 1.2.2 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + - ts-node + dev: true + /tsx@4.19.2: resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==} engines: {node: '>=18.0.0'} @@ -18121,7 +18194,7 @@ packages: /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: - punycode: 2.3.0 + punycode: 2.3.1 /urijs@1.19.11: resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} @@ -18817,7 +18890,3 @@ packages: /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: false - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false diff --git a/vitest.config.ts b/vitest.config.ts index 5c16a13c99f..55a69b1fbd3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,8 @@ import { configDefaults, defineConfig } from 'vitest/config' export default defineConfig({ test: { exclude: [...configDefaults.exclude, '**/*.utils.test.ts', '**/e2e/**', '**/llmz/**'], + chaiConfig: { + truncateThreshold: 200, + }, }, })