chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.botpress
@@ -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,
}
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+29
View File
@@ -0,0 +1,29 @@
<iframe src="https://www.youtube.com/embed/w0-UGm4mu74"></iframe>
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.
## Configuration
In order to receive a bot token, you will need to message the telegram BotFather account at [telegram.me/BotFather](https://telegram.me/BotFather).
1. Message '/start' to telegram.me/BotFather.
2. Message '/newbot' to initiate new bot token creation.
3. Send a message with the title of your new bot.
4. Send a message with the username of your new bot. Please make sure it ends with 'bot'.
5. The BotFather account will respond with message containing your bot token.
6. Paste the bot token in the "Bot Token" field in the Botpress Telegram configuration.
+1
View File
@@ -0,0 +1 @@
<svg fill="none" height="2500" width="2500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><path d="M250 500c138.071 0 250-111.929 250-250S388.071 0 250 0 0 111.929 0 250s111.929 250 250 250z" fill="#34aadf"/><path d="M104.047 247.832s125-51.3 168.352-69.364c16.619-7.225 72.977-30.347 72.977-30.347s26.012-10.115 23.844 14.451c-.723 10.116-6.503 45.52-12.283 83.815-8.671 54.191-18.064 113.439-18.064 113.439s-1.445 16.619-13.728 19.509-32.515-10.115-36.127-13.006c-2.891-2.167-54.191-34.682-72.977-50.578-5.058-4.335-10.838-13.005.722-23.121 26.012-23.844 57.081-53.468 75.867-72.254 8.671-8.671 17.341-28.902-18.786-4.336-51.3 35.405-101.878 68.642-101.878 68.642s-11.561 7.225-33.237.722c-21.677-6.502-46.966-15.173-46.966-15.173s-17.34-10.838 12.284-22.399z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 793 B

@@ -0,0 +1,77 @@
import { z, IntegrationDefinition } from '@botpress/sdk'
import typingIndicator from './bp_modules/typing-indicator'
import { telegramMessageChannels } from './definitions/channels'
export default new IntegrationDefinition({
name: 'telegram',
version: '1.0.9',
title: 'Telegram',
description: 'Engage with your audience in real-time.',
icon: 'icon.svg',
readme: 'hub.md',
configuration: {
identifier: {
linkTemplateScript: 'linkTemplate.vrl',
},
schema: z.object({
botToken: z
.string()
.min(1)
.secret()
.hidden()
.optional()
.title('Bot Token')
.describe('Legacy Telegram bot token. New installations store this in integration state.'),
typingIndicatorEmoji: z
.boolean()
.default(false)
.title('Typing Indicator Emoji')
.describe('Temporarily add an emoji reaction to received messages to indicate when bot is processing message'),
}),
},
states: {
credentials: {
type: 'integration',
schema: z.object({
botToken: z.string().title('Bot Token').min(1).secret().describe('The Telegram bot token'),
}),
},
},
channels: {
channel: {
title: 'Channel',
description: 'Telegram Channel',
messages: telegramMessageChannels,
message: {
tags: {
id: { title: 'ID', description: 'The message id' },
chatId: { title: 'Chat ID', description: 'The message Chat id' },
},
},
conversation: {
tags: {
id: { title: 'ID', description: 'The conversation ID' },
fromUserId: { title: 'From User ID', description: 'The conversation From User id' },
fromUserUsername: { title: 'From User UserName', description: 'The converstation from user username' },
fromUserName: { title: 'From User Name', description: 'The conversation from user name' },
chatId: { title: 'Chat ID', description: 'The conversation Chat id' },
},
},
},
},
actions: {},
events: {},
user: {
tags: {
id: { title: 'ID', description: 'The id of the user' },
},
},
attributes: {
category: 'Communication & Channels',
guideSlug: 'telegram',
repo: 'botpress',
},
}).extend(typingIndicator, () => ({
entities: {},
}))
+4
View File
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@botpresshub/telegram",
"description": "Telegram integration for Botpress",
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"dependencies": {
"@botpress/client": "workspace:*",
"@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": {
"@botpress/cli": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpresshub/typing-indicator": "workspace:*",
"@types/lodash": "^4.14.191",
"@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.0"
},
"bpDependencies": {
"typing-indicator": "../../interfaces/typing-indicator"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { RuntimeError } from '@botpress/sdk'
import * as bp from '.botpress'
export const getStoredBotToken = async (
client: bp.Client,
integrationId: string,
legacyToken?: string
): Promise<string> => {
const stateResult = await client
.getState({ type: 'integration', name: 'credentials', id: integrationId })
.catch((thrown: unknown) => {
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
if (err.message.toLowerCase().includes('not found')) {
return null
}
throw err
})
const botToken = stateResult?.state.payload.botToken ?? legacyToken
if (typeof botToken !== 'string' || botToken.trim().length === 0) {
throw new RuntimeError('Bot token is missing or invalid. Please complete the wizard setup again.')
}
return botToken
}
+208
View File
@@ -0,0 +1,208 @@
import { isOAuthWizardUrl } from '@botpress/common/src/oauth-wizard'
import { reporting } from '@botpress/sdk-addons'
import { ok } from 'assert/strict'
import { Telegraf } from 'telegraf'
import type { User } from 'telegraf/typings/core/types/typegram'
import { getStoredBotToken } from './botToken'
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,
convertTelegramMessageToBotpressMessage,
wrapHandler,
getMessageId,
mapToRuntimeErrorAndThrow,
} from './misc/utils'
import { handler as wizardHandler } from './wizard'
import * as bp from '.botpress'
const integration = new bp.Integration({
register: async ({ webhookUrl, ctx, client }) => {
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(botToken)
await telegraf.telegram
.setWebhook(webhookUrl)
.catch(mapToRuntimeErrorAndThrow('Fail to set webhook. Check your bot token'))
},
unregister: async ({ ctx, client }) => {
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(botToken)
await telegraf.telegram
.deleteWebhook({ drop_pending_updates: true })
.catch(mapToRuntimeErrorAndThrow('Fail to delete webhook'))
},
actions: {
startTypingIndicator: async ({ input, ctx, client }) => {
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(botToken)
const { conversation } = await client.getConversation({ id: input.conversationId })
const { message } = await client.getMessage({ id: input.messageId })
const chat = getChat(conversation)
const messageId = getMessageId(message)
await telegraf.telegram.sendChatAction(chat, 'typing').catch(mapToRuntimeErrorAndThrow('Fail to start typing'))
if (ctx.configuration.typingIndicatorEmoji === false) {
return {}
}
await telegraf.telegram
.setMessageReaction(chat, messageId, [{ type: 'emoji', emoji: '👀' }])
.catch(mapToRuntimeErrorAndThrow('Fail to set message reaction'))
return {}
},
stopTypingIndicator: async ({ input, ctx, client }) => {
if (ctx.configuration.typingIndicatorEmoji === false) {
return {}
}
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(botToken)
const { conversation } = await client.getConversation({ id: input.conversationId })
const { message } = await client.getMessage({ id: input.messageId })
const chat = getChat(conversation)
const messageId = getMessageId(message)
await telegraf.telegram
.setMessageReaction(chat, messageId, [])
.catch(mapToRuntimeErrorAndThrow('Fail to set message reaction'))
return {}
},
},
channels: {
channel: {
messages: {
text: handleTextMessage,
image: handleImageMessage,
audio: handleAudioMessage,
video: handleVideoMessage,
file: handleFileMessage,
location: handleLocationMessage,
card: handleCardMessage,
carousel: handleCarouselMessage,
dropdown: handleDropdownMessage,
choice: handleChoiceMessage,
bloc: handleBlocMessage,
},
},
},
handler: async (props) => {
if (isOAuthWizardUrl(props.req.path)) {
return await wizardHandler(props)
}
if (props.req.path.startsWith('/oauth')) {
return { status: 404, body: 'Not Found' }
}
return await wrapHandler(async ({ req, client, ctx, logger }) => {
logger.forBot().debug('Handler received request from Telegram with payload:', req.body)
ok(req.body, 'Handler received an empty body, so the message was ignored')
const data = JSON.parse(req.body)
ok(!data.my_chat_member, 'Handler received a chat member update, so the message was ignored')
ok(!data.channel_post, 'Handler received a channel post, so the message was ignored')
ok(!data.edited_channel_post, 'Handler received an edited channel post, so the message was ignored')
ok(!data.edited_message, 'Handler received an edited message, so the message was ignored')
ok(data.message, 'Handler received a non-message update, so the event was ignored')
const message = data.message as TelegramMessage
const telegramConversationId = message.chat.id
const telegramUserId = message.from?.id
const messageId = message.message_id
ok(!message.from?.is_bot, 'Handler received a message from a bot, so the message was ignored')
ok(telegramConversationId, 'Handler received message with empty "chat.id" value')
ok(telegramUserId, 'Handler received message with empty "from.id" value')
ok(messageId, 'Handler received an empty message id')
const fromUser = message.from as User
const userName = getUserNameFromTelegramUser(fromUser)
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: {
id: telegramConversationId.toString(),
fromUserId: telegramUserId.toString(),
fromUserUsername: fromUser.username,
fromUserName: userName,
chatId: telegramConversationId.toString(),
},
discriminateByTags: ['id'],
})
const { user } = await client.getOrCreateUser({
tags: {
id: telegramUserId.toString(),
},
...(userName && { name: userName }),
discriminateByTags: ['id'],
})
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const userFieldsToUpdate = {
pictureUrl: !user.pictureUrl
? await getUserPictureDataUri({
botToken,
telegramUserId,
logger,
})
: undefined,
name: user.name !== userName ? userName : undefined,
}
if (userFieldsToUpdate.pictureUrl || userFieldsToUpdate.name) {
await client.updateUser({
...user,
tags: {
id: user.tags.id,
},
...(userFieldsToUpdate.pictureUrl && { pictureUrl: userFieldsToUpdate.pictureUrl }),
...(userFieldsToUpdate.name && { name: userFieldsToUpdate.name }),
})
}
const telegraf = new Telegraf(botToken)
const bpMessage = await convertTelegramMessageToBotpressMessage({
message,
telegram: telegraf.telegram,
logger,
})
logger.forBot().debug(`Received message from user ${telegramUserId}: ${JSON.stringify(message, null, 2)}`)
await client.createMessage({
tags: {
id: messageId.toString(),
chatId: telegramConversationId.toString(),
},
...bpMessage,
userId: user.id,
conversationId: conversation.id,
})
})(props)
},
})
export default reporting.wrapIntegration(integration)
@@ -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<string, string> | TestCase<string, MarkdownToTelegramHtmlResult>
const markdownToTelegramHtmlTestCases: MarkdownToTelegramHtmlTestCase[] = [
// ==== Testing each mark type ====
{
input: '**Bold**',
expects: '<strong>Bold</strong>',
description: 'Apply bold style to text',
},
{
input: '__Bold__',
expects: '<strong>Bold</strong>',
description: 'Alternative apply bold style to text',
},
{
input: '*Italic*',
expects: '<em>Italic</em>',
description: 'Apply italic style to text',
},
{
input: '_Italic_',
expects: '<em>Italic</em>',
description: 'Alternative apply italic style to text',
},
{
input: '~~Strike~~',
expects: '<s>Strike</s>',
description: 'Apply strikethrough style to text',
},
{
input: '||Spoiler||',
expects: '<tg-spoiler>Spoiler</tg-spoiler>',
description: 'Apply spoiler style to text',
skip: true, // Why? - Feature is not yet implemented
},
{
input: '`Code Snippet`',
expects: '<code>Code Snippet</code>',
description: 'Apply code style to text',
},
{
input: '```\nconsole.log("Code Block")\n```',
expects: '<pre><code>console.log("Code Block")\n</code></pre>',
description: 'Apply code block style to text - Without language',
},
{
input: '```typescript\nconsole.log("Code Block")\n```',
expects: '<pre><code class="language-typescript">console.log("Code Block")\n</code></pre>',
description: 'Apply code block style to text - With language',
},
{
input: '\tconsole.log("Indented Code Block")',
expects: '<pre><code>console.log("Indented Code Block")\n</code></pre>',
description: 'Apply alternative code block style to text using indentation',
},
{
input: '> Blockquote',
expects: '<blockquote>\n\nBlockquote\n</blockquote>',
description: 'Apply blockquote style to text',
},
{
input: '[Hyperlink](https://www.botpress.com/)',
expects: '<a href="https://www.botpress.com/">Hyperlink</a>',
description: 'Convert hyperlink markup to html link',
},
{
input: '[Hyperlink](https://www.botpress.com/ "Tooltip Title")',
expects: '<a href="https://www.botpress.com/" title="Tooltip Title">Hyperlink</a>',
// 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: '<a href="https://www.botpress.com/" title="Tooltip Title">Hyperlink</a>',
// 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: '<a href="https://www.botpress.com/">https://www.botpress.com/</a>',
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.com<a href="https://www.botpress.com/" title="Tooltip Title">Hyperlink</a>',
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:
'<blockquote>\n\nBlockquote Layer 1\n<blockquote>\n\nBlockquote Layer 2\n<blockquote>\n\nBlockquote Layer 3\n</blockquote>\n</blockquote>\n</blockquote>',
// 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: '<strong><s>Bold-Strike</s></strong>',
description: 'Multiple nested effects all get applied',
},
{
input: '`**Code-Bold**`',
expects: '<code>**Code-Bold**</code>',
description: 'Markdown nested within a code snippet does not get converted to HTML',
},
{
input: '```\n**CodeBlock-Bold**\n```',
expects: '<pre><code>**CodeBlock-Bold**\n</code></pre>',
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: '<em>cut**off</em>**',
description: 'Markdown that gets cutoff (bold in this case) by another markdown does not convert to html',
},
{
input: '**Hello**\n**World**',
expects: '<strong>Hello</strong>\n<strong>World</strong>',
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('<a href="javascript:alert(\'XSS\')">click me</a>')
expect(html).toBe('&lt;a href=“javascript:alert(XSS)”&gt;click me&lt;/a&gt;')
})
test('Ensure javascript injection via html image handler is not possible', () => {
const { html } = stdMarkdownToTelegramHtml('<img src="image.jpg" alt="alt text" onerror="alert(\'xss\')">')
expect(html).toBe('&lt;img src=“image.jpg” alt=“alt text” onerror=“alert(xss)”&gt;')
})
})
type MarkdownToTelegramHtmlWithExtractedImagesTestCase = TestCase<string, MixedPayloads>
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 <strong>World</strong>\nHello <em>World</em>\nHello <code>World</code>\nHello <a href="https://example.com">World</a>\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<pre><code>console.log('Hello, World!')\n</code></pre>",
},
],
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)
})
})
@@ -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 `</${token.tag}>`
})
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 = `<img-marker id="${nanoid()}" />`
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, "<tg-spoiler>$1</tg-spoiler>") // Telegram Spoilers will be implemented in a later version
.replace(/<br\s?\/?>/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
}, [])
}
@@ -0,0 +1,270 @@
import { RuntimeError } from '@botpress/client'
import { Markup, Telegraf, Telegram } from 'telegraf'
import { getStoredBotToken } from '../botToken'
import { markdownHtmlToTelegramPayloads, stdMarkdownToTelegramHtml } from './markdown-to-telegram-html'
import { TelegramMessage } from './types'
import { ackMessage, getChat, mapToRuntimeErrorAndThrow, sendCard } from './utils'
import * as bp from '.botpress'
export type MessageHandlerProps<T extends keyof bp.MessageProps['channel']> = 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('Fail to send message'))
await ackMessage(message, ack)
}
type SendMediaMethod = (
chatId: number | string,
media: string,
extra?: { caption?: string }
) => Promise<TelegramMessage>
const sendContentOrFallback = async <P extends MessageHandlerProps<keyof bp.MessageProps['channel']>>({
props,
url,
send,
fallback,
}: {
props: P
url: string
send: (telegram: Telegram) => SendMediaMethod
fallback?: () => Promise<void>
}) => {
const { ctx, conversation, ack, logger, payload, client } = props
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(botToken)
const chat = getChat(conversation)
const sendFn = send(telegraf.telegram)
const opts = 'caption' in payload ? { caption: payload.caption } : undefined
logger.forBot().debug(`calling ${sendFn.name} to Telegram chat ${chat}: ${url}`)
let message: TelegramMessage
try {
message = await sendFn
.call(telegraf.telegram, chat, url, opts)
.catch(mapToRuntimeErrorAndThrow(`Failed to ${sendFn.name}`))
} catch (err) {
if (fallback) {
await fallback()
return
}
logger
.forBot()
.warn(
`Telegram could not send the media using ${sendFn.name}, sending it as a plain text link instead: ${String(err)}`
)
const text = opts?.caption ? `${opts.caption}\n${url}` : url
message = await telegraf.telegram
.sendMessage(chat, text)
.catch(mapToRuntimeErrorAndThrow('Fail to send media link fallback'))
}
await ackMessage(message, ack)
}
export const handleTextMessage = async (props: MessageHandlerProps<'text'>) => {
const { payload, ctx, conversation, ack, logger, client } = props
const { text } = payload
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(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(telegraf, ack, chat, html)
return
}
const payloads = markdownHtmlToTelegramPayloads(html, extractedData.images)
for (const payload of payloads) {
if (payload.type === 'text') {
await sendHtmlTextMessage(telegraf, ack, chat, payload.text)
} else {
await handleImageMessage({ ...props, payload: { imageUrl: payload.imageUrl }, type: 'image' })
}
}
}
export const handleImageMessage = async (props: MessageHandlerProps<'image'>) => {
await sendContentOrFallback({
props,
url: props.payload.imageUrl,
send: (t) => t.sendPhoto,
})
}
export const handleAudioMessage = async (props: MessageHandlerProps<'audio'>) => {
// If voice fails, retry as audio; if that also fails, fall back to a plain text link.
await sendContentOrFallback({
props,
url: props.payload.audioUrl,
send: (t) => t.sendVoice,
fallback: () =>
sendContentOrFallback({
props,
url: props.payload.audioUrl,
send: (t) => t.sendAudio,
}),
})
}
export const handleVideoMessage = async (props: MessageHandlerProps<'video'>) => {
await sendContentOrFallback({
props,
url: props.payload.videoUrl,
send: (t) => t.sendVideo,
})
}
export const handleFileMessage = async (props: MessageHandlerProps<'file'>) => {
await sendContentOrFallback({
props,
url: props.payload.fileUrl,
send: (t) => t.sendDocument,
})
}
export const handleLocationMessage = async ({
payload,
ctx,
conversation,
ack,
logger,
client,
}: MessageHandlerProps<'location'>) => {
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(botToken)
const chat = getChat(conversation)
logger.forBot().debug(`Sending location message to Telegram chat ${chat}:`, {
latitude: payload.latitude,
longitude: payload.longitude,
})
const message = await telegraf.telegram
.sendLocation(chat, payload.latitude, payload.longitude)
.catch(mapToRuntimeErrorAndThrow('Fail to send location'))
await ackMessage(message, ack)
}
export const handleCardMessage = async ({
payload,
ctx,
conversation,
ack,
logger,
client,
}: MessageHandlerProps<'card'>) => {
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(botToken)
const chat = getChat(conversation)
logger.forBot().debug(`Sending card message to Telegram chat ${chat}:`, payload)
await sendCard(payload, telegraf, chat, ack)
}
export const handleCarouselMessage = async ({
payload,
ctx,
conversation,
ack,
logger,
client,
}: MessageHandlerProps<'carousel'>) => {
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(botToken)
const chat = getChat(conversation)
logger.forBot().debug(`Sending carousel message to Telegram chat ${chat}:`, payload)
for (const item of payload.items) {
await sendCard(item, telegraf, chat, ack)
}
}
export const handleDropdownMessage = async ({
payload,
ctx,
conversation,
ack,
logger,
client,
}: MessageHandlerProps<'dropdown'>) => {
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(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 telegraf.telegram
.sendMessage(chat, payload.text, Markup.keyboard(buttons).oneTime())
.catch(mapToRuntimeErrorAndThrow('Fail to send message'))
await ackMessage(message, ack)
}
export const handleChoiceMessage = async ({
payload,
ctx,
conversation,
ack,
logger,
client,
}: MessageHandlerProps<'choice'>) => {
const botToken = await getStoredBotToken(client, ctx.integrationId, ctx.configuration.botToken)
const telegraf = new Telegraf(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 telegraf.telegram
.sendMessage(chat, payload.text, Markup.keyboard(buttons).oneTime())
.catch(mapToRuntimeErrorAndThrow('Fail to send message'))
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'}`)
}
}
}
@@ -0,0 +1,3 @@
export const spliceText = (text: string, start: number, end: number, replacement: string) => {
return text.substring(0, start) + replacement + text.substring(end)
}
@@ -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)
}
)
})
@@ -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<string, number> = {
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, unknown>): string => {
let startWhitespace: string | undefined = undefined
let endWhitespace: string | undefined = undefined
const matchAllResult = text.matchAll(/(?<start>^\s+)|(?<end>\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, unknown>) => string
const _handlers: Record<string, MarkHandler> = {
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<string, unknown>) => `\`\`\`${data?.language || ''}\n${text}\n\`\`\``,
blockquote: (text: string) => `> ${text.replace(/\n/g, '\n> ')}`,
text_link: _applyWhitespaceSensitiveMark(
(text: string, data: Record<string, unknown>) => `[${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 }
}
+33
View File
@@ -0,0 +1,33 @@
import * as sdk from '@botpress/sdk'
import * as Typegram from 'telegraf/typings/core/types/typegram'
import * as bp from '.botpress'
export type Card = bp.channels.channel.card.Card
export type TelegramMessage = Typegram.Message
export type MessageTypes = keyof typeof bp.channels.channel
export type BotpressMessage<T extends MessageTypes = MessageTypes> = T extends MessageTypes
? {
type: T
payload: bp.channels.channel.Messages[T]
}
: never
export type Handler = bp.IntegrationProps['handler']
export type HandlerProps = bp.HandlerProps
export type IntegrationCtx = bp.Context
export type Logger = bp.Logger
export type Client = bp.Client
export type Conversation = bp.ClientResponses['getConversation']['conversation']
export type Message = bp.ClientResponses['getMessage']['message']
export type User = bp.ClientResponses['getUser']['user']
export type Event = bp.ClientResponses['getEvent']['event']
export type EventDefinition = sdk.EventDefinition
export type ActionDefinition = sdk.ActionDefinition
export type ChannelDefinition = sdk.ChannelDefinition
export type MessageDefinition = sdk.MessageDefinition
export type ActionProps = bp.AnyActionProps
export type MessageHandlerProps = bp.AnyMessageProps
export type AckFunction = bp.AnyAckFunction
+295
View File
@@ -0,0 +1,295 @@
import { axios } from '@botpress/client'
import { IntegrationLogger, Response, RuntimeError } from '@botpress/sdk'
import { ok } from 'assert'
import _ from 'lodash'
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 const mapToRuntimeErrorAndThrow =
(message: string) =>
(thrown: unknown): never => {
if (thrown instanceof TelegramError) {
throw new RuntimeError(`${message}: ${thrown.description}`, thrown)
}
throw thrown instanceof Error
? new RuntimeError(`${message}: ${thrown.message}`, thrown)
: new RuntimeError(`${message}: ${thrown}`)
}
export async function ackMessage(message: TelegramMessage, ack: AckFunction) {
await ack({ tags: { id: `${message.message_id}` } })
}
export async function sendCard(payload: Card, client: Telegraf<Context<Update>>, chat: string, ack: AckFunction) {
const text = `*${payload.title}*${payload.subtitle ? '\n' + payload.subtitle : ''}`
const buttons = payload.actions
.filter((item) => item.value && item.label)
.map((item) => {
switch (item.action) {
case 'url':
return Markup.button.url(item.label, item.value)
case 'postback':
return Markup.button.callback(item.label, `postback:${item.value}`)
case 'say':
return Markup.button.callback(item.label, `say:${item.value}`)
default:
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),
})
.catch(mapToRuntimeErrorAndThrow('Fail to send photo'))
await ackMessage(message, ack)
} else {
const message = await client.telegram
.sendMessage(chat, text, {
parse_mode: 'MarkdownV2',
...Markup.inlineKeyboard(buttons),
})
.catch(mapToRuntimeErrorAndThrow('Fail to send message'))
await ackMessage(message, ack)
}
}
export function getChat(conversation: MessageHandlerProps['conversation']): string {
const chat = conversation.tags.chatId
if (!chat) {
throw new RuntimeError(`No chat found for conversation ${conversation.id}`)
}
return chat
}
export function getMessageId(message: MessageHandlerProps['message']): number {
const messageId = message.tags.id
if (!messageId) {
throw new RuntimeError(`No message ID found for message ${message.id}`)
}
return Number(messageId)
}
export const getUserNameFromTelegramUser = (telegramUser: User) => {
if (telegramUser.first_name && telegramUser.last_name) {
return `${telegramUser.first_name} ${telegramUser.last_name}`
} else if (telegramUser.username) {
return telegramUser.username
}
return telegramUser.first_name
}
const getMimeTypeFromExtension = (extension: string): string => {
switch (extension.toLowerCase()) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'bmp':
return 'image/bmp'
case 'svg':
return 'image/svg+xml'
default:
return 'application/octet-stream'
}
}
const getDataUriFromImgHref = async (imgHref: string): Promise<string> => {
const fileExtension = imgHref.substring(imgHref.lastIndexOf('.') + 1)
const { data } = await axios.default
.get(imgHref, { responseType: 'arraybuffer' })
.catch(mapToRuntimeErrorAndThrow('Fail to get image'))
const base64File = Buffer.from(data, 'binary').toString('base64')
return `data:${getMimeTypeFromExtension(fileExtension)};base64,${base64File}`
}
const getBestPhotoSize = (photos: PhotoSize[]): PhotoSize | null => {
let bestPhoto = null
for (const photo of photos) {
const isSizeBelowLimit = !!photo.file_size && photo.file_size < USER_PICTURE_MAX_SIZE_BYTES
const isSizeAbovePrevious = !!photo.file_size && !!bestPhoto?.file_size && photo.file_size > bestPhoto.file_size
if (isSizeBelowLimit && (!bestPhoto || isSizeAbovePrevious)) {
bestPhoto = photo
}
}
return bestPhoto
}
export const getUserPictureDataUri = async ({
botToken,
telegramUserId,
logger,
}: {
botToken: string
telegramUserId: number
logger: Logger
}): Promise<string | null> => {
try {
const telegraf = new Telegraf(botToken)
const res = await telegraf.telegram
.getUserProfilePhotos(telegramUserId)
.catch(mapToRuntimeErrorAndThrow('Fail to get user profile photos'))
logger.forBot().debug('Fetched user profile pictures from Telegram')
if (!res.photos[0]) {
logger.forBot().debug('No picture found to update the user profile picture')
return null
}
const photoToUse = getBestPhotoSize(res.photos[0])
if (photoToUse) {
const fileLink = await telegraf.telegram
.getFileLink(photoToUse.file_id)
.catch(mapToRuntimeErrorAndThrow('Fail to get file link'))
return await getDataUriFromImgHref(fileLink.href)
}
return null
} catch (error) {
logger.forBot().error("Couldn't convert Telegram profile picture to base64 Data URI: ", error)
return null
}
}
export const convertTelegramMessageToBotpressMessage = async ({
message,
telegram,
logger,
}: {
message: TelegramMessage
telegram: Telegram
logger: IntegrationLogger
}): Promise<BotpressMessage> => {
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).catch(mapToRuntimeErrorAndThrow('Fail to get file link'))
return {
type: 'image',
payload: {
imageUrl: fileUrl.toString(),
},
}
}
if ('sticker' in message) {
const stickerMessage = message as TelegramMessage & { sticker: Sticker }
const fileUrl = await telegram
.getFileLink(stickerMessage.sticker.file_id)
.catch(mapToRuntimeErrorAndThrow('Fail to get file link'))
return {
type: 'image',
payload: {
imageUrl: fileUrl.toString(),
},
}
}
if ('audio' in message) {
const fileUrl = await telegram
.getFileLink(message.audio.file_id)
.catch(mapToRuntimeErrorAndThrow('Fail to get file link'))
return {
type: 'audio',
payload: {
audioUrl: fileUrl.toString(),
},
}
}
if ('voice' in message) {
const fileUrl = await telegram
.getFileLink(message.voice.file_id)
.catch(mapToRuntimeErrorAndThrow('Fail to get file link'))
return {
type: 'audio',
payload: {
audioUrl: fileUrl.toString(),
},
}
}
if ('video' in message) {
const fileUrl = await telegram
.getFileLink(message.video.file_id)
.catch(mapToRuntimeErrorAndThrow('Fail to get file link'))
return {
type: 'video',
payload: {
videoUrl: fileUrl.toString(),
},
}
}
if ('document' in message) {
const fileUrl = await telegram
.getFileLink(message.document.file_id)
.catch(mapToRuntimeErrorAndThrow('Fail to get file link'))
return {
type: 'file',
payload: {
fileUrl: fileUrl.toString(),
},
}
}
if ('text' in message) {
const { text, warnings } = telegramTextMsgToStdMarkdown(message.text, message.entities)
warnings?.forEach((warningMsg) => logger.forBot().warn(warningMsg))
return {
type: 'text',
payload: { text },
}
}
if ('location' in message) {
return {
type: 'location',
payload: {
latitude: message.location.latitude,
longitude: message.location.longitude,
},
}
}
throw new RuntimeError(`Unsupported message type from Telegram: ${JSON.stringify(message)}`)
}
type Handler = bp.IntegrationProps['handler']
export const wrapHandler =
(handler: Handler): Handler =>
async (args: bp.HandlerProps): Promise<Response | void> => {
try {
return await handler({
...args,
})
} catch (thrown) {
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
args.logger.forBot().error('Assertion Error:', err.message)
return { status: 200 }
}
}
+84
View File
@@ -0,0 +1,84 @@
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import { z } from '@botpress/sdk'
import { Telegraf } from 'telegraf'
import * as bp from '.botpress'
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
const _tokenSchema = z.object({
botToken: z.string().min(1).secret().title('Bot Token').describe('The bot token from @BotFather'),
})
const _tokenForm = {
pageTitle: 'Connect Telegram',
htmlOrMarkdownPageContents:
'Paste the bot token from <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer">@BotFather</a>.<br>' +
'Create a new bot with the <code>/newbot</code> command if you do not have one yet.',
schema: _tokenSchema,
nextStepId: 'finalize',
}
export const handler = async (props: bp.HandlerProps) => {
const wizard = new oauthWizard.OAuthWizardBuilder(props)
.addStep({ id: 'start', handler: _startHandler })
.addStep({ id: 'finalize', handler: _finalizeHandler })
.build()
return await wizard.handleRequest()
}
const _startHandler: WizardHandler = ({ responses }) => {
return responses.displayForm(_tokenForm)
}
const _finalizeHandler: WizardHandler = async ({
formValues,
client,
ctx,
logger,
responses,
setIntegrationIdentifier,
}) => {
if (!formValues) {
return responses.redirectToStep('start')
}
const parsed = _tokenSchema.safeParse(formValues)
if (!parsed.success) {
return responses.displayForm({
..._tokenForm,
errors: parsed.error,
previousValues: formValues as z.input<typeof _tokenSchema>,
})
}
const { botToken } = parsed.data
let botUsername: string
try {
const telegraf = new Telegraf(botToken)
const bot = await telegraf.telegram.getMe()
botUsername = bot.username ?? String(bot.id)
} catch (thrown: unknown) {
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
logger.forBot().debug('Token validation failed via getMe():', err)
return responses.displayForm({
..._tokenForm,
// not a real ZodError instance, displayForm only reads errors.issues
errors: {
issues: [{ code: 'custom', path: ['botToken'], message: 'Invalid bot token. Please check and try again.' }],
} as unknown as z.ZodError<z.input<typeof _tokenSchema>>,
previousValues: { botToken },
})
}
await client.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: { botToken },
})
setIntegrationIdentifier(botUsername)
return responses.endWizard({ success: true })
}
+6
View File
@@ -0,0 +1,6 @@
export type TestCase<INPUT = unknown, EXPECTED = unknown> = {
input: INPUT
expects: EXPECTED
description: string
skip?: boolean
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist",
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
import config from '../../vitest.config'
export default defineConfig({
...config,
test: {
...config.test,
chaiConfig: {
truncateThreshold: 200,
},
},
})