chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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: {},
|
||||
}))
|
||||
@@ -0,0 +1,4 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
|
||||
"{{ webhookUrl }}/oauth/wizard/start?state={{ webhookId }}"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: '',
|
||||
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:
|
||||
'',
|
||||
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('<a href=“javascript:alert(‘XSS’)”>click me</a>')
|
||||
})
|
||||
|
||||
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('<img src=“image.jpg” alt=“alt text” onerror=“alert(‘xss’)”>')
|
||||
})
|
||||
})
|
||||
|
||||
type MarkdownToTelegramHtmlWithExtractedImagesTestCase = TestCase<string, MixedPayloads>
|
||||
|
||||
const extractedImagesTestCases: MarkdownToTelegramHtmlWithExtractedImagesTestCase[] = [
|
||||
{
|
||||
input:
|
||||
'',
|
||||
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:
|
||||
'\n\n',
|
||||
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',
|
||||
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:
|
||||
'\nText in the middle\n',
|
||||
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:
|
||||
'\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\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 \nHello \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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type TestCase<INPUT = unknown, EXPECTED = unknown> = {
|
||||
input: INPUT
|
||||
expects: EXPECTED
|
||||
description: string
|
||||
skip?: boolean
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import config from '../../vitest.config'
|
||||
export default defineConfig({
|
||||
...config,
|
||||
test: {
|
||||
...config.test,
|
||||
chaiConfig: {
|
||||
truncateThreshold: 200,
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user