chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.botpress
+32
View File
@@ -0,0 +1,32 @@
import * as sdk from '@botpress/sdk'
export const actions = {
startDmConversation: {
title: 'Start DM Conversation',
description: 'Initiate a conversation with a user in a DM by email or user ID.',
input: {
schema: sdk.z.object({
teamsUserId: sdk.z
.string()
.title('Teams User ID')
.describe('The ID of any Teams user from tenant to initiate the conversation with, eg: "29:2d5f3..."')
.optional(),
teamsUserEmail: sdk.z
.string()
.title('Teams User Email')
.describe('The Email of any Teams user from tenant to initiate the conversation with')
.optional(),
conversationId: sdk.z
.string()
.title('Botpress Conversation ID')
.describe('The ID of any Botpress conversation from channel "Teams", eg: "conv_01JZ..."'),
}),
},
output: {
schema: sdk.z.object({
userId: sdk.z.string().title('User ID').describe('The ID of the user').optional(),
conversationId: sdk.z.string().title('Conversation ID').describe('The ID of the new conversation'),
}),
},
},
} as const satisfies sdk.IntegrationDefinitionProps['actions']
@@ -0,0 +1,25 @@
import { IntegrationDefinitionProps, messages } from '@botpress/sdk'
export const channels = {
channel: {
title: 'Channel',
description: 'Teams conversation channel',
messages: messages.defaults,
message: {
tags: {
id: {
title: 'ID',
description: 'Teams activity ID',
},
},
},
conversation: {
tags: {
id: {
title: 'ID',
description: 'Teams conversation ID',
},
},
},
},
} satisfies IntegrationDefinitionProps['channels']
+4
View File
@@ -0,0 +1,4 @@
export { states } from './states'
export { actions } from './actions'
export { channels } from './channels'
export { user } from './user'
+36
View File
@@ -0,0 +1,36 @@
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
import type { ConversationReference } from 'botbuilder'
// type testing utils
type Is<A, B> = A extends B ? (B extends A ? true : false) : false
type Expect<_T extends true> = void
export const channelAccountSchema = z.object({
id: z.string().title('ID').describe('Account ID'),
name: z.string().title('Name').describe('Account name'),
})
const _convReferenceSchema = z.object({
activityId: z.string().optional().title('Activity ID').describe('The activity ID'),
user: channelAccountSchema.optional().title('User').describe('User account of the user conversing with the bot'),
locale: z.string().optional().title('Locale').describe('The locale of the conversation'),
bot: channelAccountSchema.title('Bot').describe('Bot account of the bot'),
conversation: z.any().title('Conversation').describe('Conversation reference'), // botbuilder typings are deceiving
channelId: z.string().title('Channel ID').describe('The channel ID of the conversation'),
serviceUrl: z
.string()
.title('Service URL')
.describe('Service endpoint where the operations concerning the conversation are performed'),
})
const _convReferenceStateSchema = _convReferenceSchema.partial()
type ConvReferenceState = z.infer<typeof _convReferenceStateSchema>
// this builds only if the state schema is the same type as ConversationReference from 'botbuilder'
type _Test = Expect<Is<ConvReferenceState, Partial<ConversationReference>>>
export const states = {
conversation: {
type: 'conversation',
schema: _convReferenceStateSchema,
},
} satisfies IntegrationDefinitionProps['states']
+14
View File
@@ -0,0 +1,14 @@
import { IntegrationDefinitionProps } from '@botpress/sdk'
export const user = {
tags: {
id: {
title: 'ID',
description: 'Teams user ID',
},
email: {
title: 'Email',
description: 'Email address',
},
},
} satisfies IntegrationDefinitionProps['user']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+9
View File
@@ -0,0 +1,9 @@
The Microsoft Teams integration enables seamless collaboration between your AI-powered chatbot and Microsoft Teams, a popular workplace communication and collaboration platform. Connect your chatbot to Teams and enhance team productivity by automating tasks, providing instant support, and facilitating streamlined communication. With this integration, your chatbot can interact with users in Teams channels, respond to queries, deliver notifications, and perform actions within the Teams environment. Leverage Teams' robust features such as messaging, file sharing, meetings, and app integrations to create a powerful conversational AI experience. Boost teamwork and efficiency with the Microsoft Teams Integration for Botpress.
## Migrating from version `1.x.x` to `2.x.x`
Version `2.0.0` of the Microsoft Teams integration introduces changes to the channels (most notably the markdown channel). If you are migrating from version `1.x.x` to `2.x.x`, please note the following changes:
- The "markdown" channel was removed in favor of integrating the behaviour into the "text" channel
- The "bloc" channel was implemented and can support up to 50 items per bloc message
- The "dropdown" channel was updated to display an actual dropdown instead of a selection of button options
+1
View File
@@ -0,0 +1 @@
<svg viewBox="-0.12979372698077785 0 32.42343730730004 32" xmlns="http://www.w3.org/2000/svg" width="2500" height="2458"><circle cx="17" cy="6" fill="#7b83eb" r="4.667"/><path d="M16.667 7H12.44l.021.093.002.008.022.086A4.671 4.671 0 0 0 18 10.559V8.333A1.337 1.337 0 0 0 16.667 7z" opacity=".1"/><path d="M15.667 8h-2.884A4.667 4.667 0 0 0 17 10.667V9.333A1.337 1.337 0 0 0 15.667 8z" opacity=".2"/><circle cx="27.5" cy="7.5" fill="#5059c9" r="3.5"/><path d="M30.5 12h-7.861a.64.64 0 0 0-.64.64v8.11a5.121 5.121 0 0 0 3.967 5.084A5.006 5.006 0 0 0 32 20.938V13.5a1.5 1.5 0 0 0-1.5-1.5z" fill="#5059c9"/><path d="M25 13.5V23a7.995 7.995 0 0 1-14.92 4 7.173 7.173 0 0 1-.5-1 8.367 8.367 0 0 1-.33-1A8.24 8.24 0 0 1 9 23v-9.5a1.498 1.498 0 0 1 1.5-1.5h13a1.498 1.498 0 0 1 1.5 1.5z" fill="#7b83eb"/><path d="M15.667 8h-2.884A4.667 4.667 0 0 0 17 10.667V9.333A1.337 1.337 0 0 0 15.667 8z" opacity=".2"/><path d="M18 12v12.67a1.32 1.32 0 0 1-1.04 1.29.966.966 0 0 1-.29.04H9.58a8.367 8.367 0 0 1-.33-1A8.24 8.24 0 0 1 9 23v-9.5a1.498 1.498 0 0 1 1.5-1.5z" opacity=".1"/><path d="M17 12v13.67a.967.967 0 0 1-.04.29A1.32 1.32 0 0 1 15.67 27h-5.59a7.173 7.173 0 0 1-.5-1 8.367 8.367 0 0 1-.33-1A8.24 8.24 0 0 1 9 23v-9.5a1.498 1.498 0 0 1 1.5-1.5z" opacity=".2"/><path d="M17 12v11.67A1.336 1.336 0 0 1 15.67 25H9.25A8.24 8.24 0 0 1 9 23v-9.5a1.498 1.498 0 0 1 1.5-1.5z" opacity=".2"/><path d="M10.5 12A1.498 1.498 0 0 0 9 13.5V23a8.24 8.24 0 0 0 .25 2h5.42A1.336 1.336 0 0 0 16 23.67V12z" opacity=".2"/><path d="M1.333 8h13.334A1.333 1.333 0 0 1 16 9.333v13.334A1.333 1.333 0 0 1 14.667 24H1.333A1.333 1.333 0 0 1 0 22.667V9.333A1.333 1.333 0 0 1 1.333 8z" fill="#4b53bc"/><path d="M11.98 12.975H8.99v8.02H7.028v-8.02H4.02v-1.97h7.96z" fill="#fff"/><path d="M0 0h32v32H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,30 @@
import { IntegrationDefinition, z } from '@botpress/sdk'
import typingIndicator from 'bp_modules/typing-indicator'
import { actions, channels, user, states } from 'definitions'
export default new IntegrationDefinition({
name: 'teams',
version: '2.0.5',
title: 'Microsoft Teams',
description: 'Interact with users, deliver notifications, and perform actions within Microsoft Teams.',
icon: 'icon.svg',
readme: 'hub.md',
configuration: {
schema: z.object({
appId: z.string().min(1).title('App ID').describe('Teams application ID'),
appPassword: z.string().min(1).title('App Password').describe('Teams application password'),
tenantId: z.string().optional().title('Tenant ID').describe('Teams tenant ID'),
}),
},
channels,
user,
actions,
events: {},
states,
attributes: {
category: 'Communication & Channels',
guideSlug: 'teams',
repo: 'botpress',
},
}).extend(typingIndicator, () => ({ entities: {} }))
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@botpresshub/teams",
"description": "Teams integration for Botpress",
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"deploy": "bp deploy",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"dependencies": {
"@azure/identity": "^4.10.2",
"@botpress/client": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"axios": "^1.5.1",
"botbuilder": "^4.23.3",
"dedent": "^1.6.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"sanitize-html": "^2.17.0",
"turndown": "^7.2.2"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/sdk": "workspace:*",
"@types/jsonwebtoken": "^9.0.3",
"@types/mdast": "^4.0.4",
"@types/sanitize-html": "^2.16.0",
"@types/turndown": "^5.0.6"
},
"bpDependencies": {
"typing-indicator": "../../interfaces/typing-indicator"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { startDmConversation } from './start-dm'
import { startTypingIndicator, stopTypingIndicator } from './typing-indicator'
import * as bp from '.botpress'
export default {
startTypingIndicator,
stopTypingIndicator,
startDmConversation,
} satisfies bp.IntegrationProps['actions']
+127
View File
@@ -0,0 +1,127 @@
import { RuntimeError } from '@botpress/client'
import { ConversationParameters, ConversationReference, TeamsChannelAccount, TeamsInfo, TurnContext } from 'botbuilder'
import { MicrosoftClient } from '../microsoft-api'
import { getAdapter, getError } from '../utils'
import * as bp from '.botpress'
export const startDmConversation: bp.IntegrationProps['actions']['startDmConversation'] = async ({
ctx,
client,
input,
}) => {
const adapter = getAdapter(ctx.configuration)
// We need an existing Botpress conversation on Teams because of the serviceUrl
let state
try {
const stateRes = await client.getState({
id: input.conversationId,
name: 'conversation',
type: 'conversation',
})
state = stateRes.state
} catch (thrown) {
const err = getError(thrown)
throw new RuntimeError(
`Failed to retrieve conversation state for the supplied Botpress conversation Id "${input.conversationId}", ` +
`please make sure it's a valid Botpress conversation from channel "Teams" -> ${err.message}`
)
}
const convRef = state.payload as ConversationReference
const { teamsUserEmail } = input
let { teamsUserId } = input
if (!teamsUserId?.length && !teamsUserEmail?.length) {
throw new RuntimeError(
'You must provide either a valid Teams user Id or email to start a DM conversation. ' +
`Received: teamsUserId="${teamsUserId}", teamsUserEmail="${teamsUserEmail}"`
)
}
// ── If we only have an email, call TeamsInfo.getMember to fetch the account ──
if (!teamsUserId?.length && teamsUserEmail?.length) {
try {
const teamsUser = await MicrosoftClient.create(ctx).getUserByEmail(teamsUserEmail)
teamsUserId = teamsUser?.id
} catch (thrown) {
const err = getError(thrown)
throw new RuntimeError(
`Could not look-up Teams user with email "${teamsUserEmail}" ` +
`(bot must be installed where that user is reachable and have "User.Read.All" API Permission at the Azure Portal) → ${err.message}`
)
}
}
const conversationParameters = {
isGroup: false,
members: [
{
id: teamsUserId,
},
],
} as Partial<ConversationParameters>
let newConvRef: Partial<ConversationReference> | undefined
let newConvMember: TeamsChannelAccount | undefined
try {
await adapter.createConversation(convRef, conversationParameters, async (context) => {
newConvRef = TurnContext.getConversationReference(context.activity)
//If the user already spoke to the bot on DM, the Teams user will be available
if (newConvRef.user?.id) {
try {
newConvMember = await TeamsInfo.getMember(context, newConvRef.user.id)
} catch {}
}
})
} catch (thrown) {
const err = getError(thrown)
throw new RuntimeError(
`Failed to create conversation on Teams for Teams user Id "${input.teamsUserId}", ` +
`please make sure it's a valid Teams user Id and belongs to the bot tenant -> ${err.message}`
)
}
if (!newConvRef?.conversation?.id) {
throw new RuntimeError('Failed to get the reference for the new conversation')
}
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: {
id: newConvRef.conversation.id,
},
discriminateByTags: ['id'],
})
await client.setState({
id: conversation.id,
name: 'conversation',
type: 'conversation',
payload: newConvRef,
})
let userId: string | undefined
if (newConvMember) {
userId = (
await client.getOrCreateUser({
tags: {
id: newConvMember.id,
email: newConvMember.email,
},
})
).user.id
await client.addParticipant({
id: conversation.id,
userId,
})
}
return {
userId,
conversationId: conversation.id,
}
}
@@ -0,0 +1,31 @@
import { ActivityTypes } from 'botbuilder'
import { getAdapter, getConversationReference } from '../utils'
import * as bp from '.botpress'
const DEFAULT_TIMEOUT = 5000
export const startTypingIndicator: bp.IntegrationProps['actions']['startTypingIndicator'] = async ({
ctx,
client,
input,
}) => {
const { conversationId, timeout } = input
const expiration = new Date(Date.now() + (timeout ?? DEFAULT_TIMEOUT))
const adapter = getAdapter(ctx.configuration)
const convRef = await getConversationReference({ conversationId, client })
await adapter.continueConversation(convRef, async (turnContext) => {
await turnContext.sendActivity({
type: ActivityTypes.Typing,
expiration,
})
})
return {}
}
export const stopTypingIndicator: bp.IntegrationProps['actions']['stopTypingIndicator'] = async () => {
// Deleting the activity is not supported on all bot framework channels.
// Further testing in Teams will be required to determine if deleting the
// typing indicator is supported, should we want to stop it early.
// Do nothing for now (typing indicator still stops on new message and after timeout)
return {}
}
@@ -0,0 +1,2 @@
export const DROPDOWN_VALUE_KIND = 'dropdown_value' as const
export const DROPDOWN_VALUE_ID = 'choice' as const
@@ -0,0 +1,84 @@
import { Activity, ConversationReference, TurnContext, TeamsInfo, TeamsChannelAccount } from 'botbuilder'
import { transformTeamsHtmlToStdMarkdown } from '../markdown/teams-html-to-markdown'
import { getAdapter, sleep } from '../utils'
import { DROPDOWN_VALUE_ID, DROPDOWN_VALUE_KIND } from './constants'
import * as bp from '.botpress'
export const processInboundChannelMessage = async ({ client, ctx, logger }: bp.HandlerProps, activity: Activity) => {
const convRef: Partial<ConversationReference> = TurnContext.getConversationReference(activity)
const senderChannelAccount = activity.from!
const adapter = getAdapter(ctx.configuration)
const getUserPromise = new Promise<TeamsChannelAccount | undefined>((resolve, reject) => {
void adapter
.continueConversation(convRef, async (tc) => {
const user = await TeamsInfo.getMember(tc, senderChannelAccount.id)
resolve(user)
})
.then(() => resolve(undefined))
.catch(reject)
})
const sender = await Promise.race([getUserPromise, sleep(2000)])
if (sender?.email) {
logger.forBot().info(`Received request from user: ${sender.email}`)
}
switch (activity.type) {
case 'message':
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: {
id: activity.conversation.id,
},
})
await client.setState({
id: conversation.id,
name: 'conversation',
type: 'conversation',
payload: convRef,
})
const { user } = await client.getOrCreateUser({
tags: {
id: activity.from.id,
email: sender?.email,
},
})
const message = _extractMessage(activity)
await client.getOrCreateMessage({
tags: { id: activity.id },
type: 'text',
userId: user.id,
conversationId: conversation.id,
payload: { text: message },
})
break
default:
return
}
}
const _extractMessage = (activity: Activity): string => {
// Handle dropdown value
if (activity.value && activity.value.kind === DROPDOWN_VALUE_KIND) {
return String(activity.value[DROPDOWN_VALUE_ID])
}
// Handle HTML attachment (Any richtext/markdown will convert/show up as HTML)
if (activity.attachments) {
const htmlAttachment = activity.attachments.find((attachment) => attachment.contentType === 'text/html')
if (htmlAttachment && typeof htmlAttachment.content === 'string') {
return transformTeamsHtmlToStdMarkdown(htmlAttachment.content)
}
}
/** Fallback to plain text
*
* @remark Using coalescence operator (??) since messages
* with no text can happen (e.g. image only messages) */
return activity.text ?? ''
}
+277
View File
@@ -0,0 +1,277 @@
import { RuntimeError } from '@botpress/client'
import {
Activity,
ConversationReference,
CardFactory,
CardAction,
ActionTypes,
Attachment,
MessageFactory,
} from 'botbuilder'
import { transformMarkdownToTeamsXml } from '../markdown/markdown-to-teams-xml'
import { getAdapter } from '../utils'
import { DROPDOWN_VALUE_ID, DROPDOWN_VALUE_KIND } from './constants'
import * as bp from '.botpress'
type MessageHandlerProps<T extends keyof bp.MessageProps['channel']> = bp.MessageProps['channel'][T]
type ChoicePayload = MessageHandlerProps<'choice'>['payload']
type ChoiceOption = ChoicePayload['options'][number]
type DropdownPayload = MessageHandlerProps<'dropdown'>['payload']
type DropdownOption = DropdownPayload['options'][number]
type BotpressCard = bp.channels.channel.card.Card
type Action = BotpressCard['actions'][number]
type ActionType = Action['action']
// ====== Message Channel Helpers ======
const _renderTeams = async (
{ ctx, ack, conversation, client, logger }: bp.AnyMessageProps,
activity: Partial<Activity>
) => {
const { configuration } = ctx
const adapter = getAdapter(configuration)
const stateRes = await client.getState({
id: conversation.id,
name: 'conversation',
type: 'conversation',
})
const { state } = stateRes
const convRef = state.payload as ConversationReference
await adapter.continueConversation(convRef, async (turnContext) => {
if (!turnContext.activity.id) {
logger.forBot().warn('No activity id found')
return
}
await turnContext.sendActivity(activity)
await ack({ tags: { id: turnContext.activity.id } })
})
}
const _mapActionType = (action: ActionType): ActionTypes => {
if (action === 'postback') {
return ActionTypes.MessageBack
}
if (action === 'say') {
return ActionTypes.MessageBack
}
if (action === 'url') {
return ActionTypes.OpenUrl
}
return ActionTypes.MessageBack
}
const _mapAction = (action: Action): CardAction => ({
type: _mapActionType(action.action),
title: action.label,
value: action.value,
text: action.label,
displayText: action.label,
})
const _mapChoice = (choice: ChoiceOption): CardAction => ({
type: ActionTypes.MessageBack,
title: choice.label,
displayText: choice.label,
value: choice.value,
text: choice.label,
})
const _makeCard = (card: BotpressCard): Attachment => {
const { actions, imageUrl, subtitle, title } = card
const buttons: CardAction[] = actions.map(_mapAction)
const images = imageUrl ? [{ url: imageUrl }] : []
return CardFactory.heroCard(title, images, buttons, { subtitle })
}
const _distinctByValue = (choice: { value: string }, index: number, arr: { value: string }[]): boolean => {
return arr.findIndex((otherChoice) => otherChoice.value === choice.value) === index
}
const _makeDropdownCard = (text: string, options: DropdownOption[], logger: bp.Logger): Attachment => {
const uniqueChoices = options
.map((option: DropdownOption) => ({
title: option.label,
value: option.value,
}))
// Hotfix: This exists because some client's code was
// duplicating the options everytime the workflow was ran
.filter(_distinctByValue)
if (uniqueChoices.length < options.length) {
// Normally, it's not the responsibility of the integration
// to warn the user of issues in their workflow.
logger
.forBot()
.warn(
`The dropdown options contained duplicates (This is likely due to a misconfiguration in the bot workflow).\nReduced from ${options.length} to ${uniqueChoices.length} unique options.`
)
}
return CardFactory.adaptiveCard({
// documentation here https://learn.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features
body: [
{
type: 'TextBlock',
text,
wrap: true,
weight: 'Bolder',
},
{
id: DROPDOWN_VALUE_ID,
type: 'Input.ChoiceSet',
placeholder: 'Select...',
style: 'compact',
choices: uniqueChoices,
},
],
actions: [
{
type: 'Action.Submit',
title: 'Submit',
data: { kind: DROPDOWN_VALUE_KIND },
},
],
})
}
// ====== Message Channel Handlers ======
const _handleTextMessage = async (props: MessageHandlerProps<'text'>) => {
const { text } = props.payload
const xml = transformMarkdownToTeamsXml(text)
const activity: Partial<Activity> = {
type: 'message',
textFormat: 'xml',
text: xml,
}
await _renderTeams(props, activity)
}
const _handleImageMessage = async (props: MessageHandlerProps<'image'>) => {
const { imageUrl } = props.payload
const activity: Partial<Activity> = {
type: 'message',
attachments: [CardFactory.heroCard('', [{ url: imageUrl }])],
}
await _renderTeams(props, activity)
}
const _handleAudioMessage = async (props: MessageHandlerProps<'audio'>) => {
const { audioUrl } = props.payload
const activity: Partial<Activity> = {
type: 'message',
attachments: [CardFactory.audioCard('', [{ url: audioUrl }])],
}
await _renderTeams(props, activity)
}
const _handleVideoMessage = async (props: MessageHandlerProps<'video'>) => {
const { videoUrl } = props.payload
const activity: Partial<Activity> = {
type: 'message',
attachments: [CardFactory.videoCard('', [{ url: videoUrl }])],
}
await _renderTeams(props, activity)
}
const _handleFileMessage = async (props: MessageHandlerProps<'file'>) => {
const { fileUrl } = props.payload
const activity: Partial<Activity> = { type: 'message', text: fileUrl }
await _renderTeams(props, activity)
}
const _handleLocationMessage = async (props: MessageHandlerProps<'location'>) => {
const { latitude, longitude } = props.payload
const googleMapsLink = `https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`
const activity: Partial<Activity> = { type: 'message', text: googleMapsLink }
await _renderTeams(props, activity)
}
const _handleCarouselMessage = async (props: MessageHandlerProps<'carousel'>) => {
const { items } = props.payload
const activity = MessageFactory.carousel(items.map(_makeCard))
await _renderTeams(props, activity)
}
const _handleCardMessage = async (props: MessageHandlerProps<'card'>) => {
const activity: Partial<Activity> = {
type: 'message',
attachments: [_makeCard(props.payload)],
}
await _renderTeams(props, activity)
}
const _handleChoiceMessage = async (props: MessageHandlerProps<'choice'>) => {
const { options, text } = props.payload
const buttons: CardAction[] = options.map(_mapChoice)
const activity: Partial<Activity> = {
type: 'message',
attachments: [CardFactory.heroCard(text, [], buttons)],
}
await _renderTeams(props, activity)
}
const _handleDropdownMessage = async (props: MessageHandlerProps<'dropdown'>) => {
const { options, text } = props.payload
const activity: Partial<Activity> = {
type: 'message',
attachments: [_makeDropdownCard(text, options, props.logger)],
}
await _renderTeams(props, activity)
}
const _handleBlocMessage = async ({ payload, ...rest }: MessageHandlerProps<'bloc'>) => {
if (payload.items.length > 50) {
throw new RuntimeError('Teams only allows 50 messages to be sent every 1 second(s)')
}
for (const item of payload.items) {
const messageProps = { ...rest, ...item }
switch (messageProps.type) {
case 'text':
await _handleTextMessage(messageProps)
continue
case 'image':
await _handleImageMessage(messageProps)
continue
case 'audio':
await _handleAudioMessage(messageProps)
continue
case 'video':
await _handleVideoMessage(messageProps)
continue
case 'file':
await _handleFileMessage(messageProps)
continue
case 'location':
await _handleLocationMessage(messageProps)
continue
default:
messageProps satisfies never
throw new RuntimeError(`Unsupported message type: ${(messageProps as any)?.type ?? 'Unknown'}`)
}
}
}
export const channels = {
channel: {
messages: {
text: _handleTextMessage,
image: _handleImageMessage,
audio: _handleAudioMessage,
video: _handleVideoMessage,
file: _handleFileMessage,
location: _handleLocationMessage,
carousel: _handleCarouselMessage,
card: _handleCardMessage,
choice: _handleChoiceMessage,
dropdown: _handleDropdownMessage,
bloc: _handleBlocMessage,
},
},
} satisfies bp.IntegrationProps['channels']
+16
View File
@@ -0,0 +1,16 @@
import { reporting } from '@botpress/sdk-addons'
import actions from './actions'
import { channels } from './channels/outbound'
import { register, unregister } from './setup'
import { handler } from './webhooks/handler'
import * as bp from '.botpress'
const integration = new bp.Integration({
register,
unregister,
actions,
channels,
handler,
})
export default reporting.wrapIntegration(integration)
@@ -0,0 +1,348 @@
import { describe, expect, test } from 'vitest'
import type { TestCase } from '../types'
import { transformMarkdownToTeamsXml } from './markdown-to-teams-xml'
import dedent from 'dedent'
type MarkdownToTeamsHtmlTestCase = TestCase<string, string>
const markdownToTeamsHtmlTestCases: MarkdownToTeamsHtmlTestCase[] = [
// ----- Bold -----
{
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',
},
// ----- Italic -----
{
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',
},
// ----- Strikethrough -----
{
input: '~~Strike~~',
expects: '<s>Strike</s>',
description: 'Apply strikethrough style to text',
},
// ----- Code Snippet -----
{
input: '`Code Snippet`',
expects: '<code>Code Snippet</code>',
description: 'Apply code style to text',
},
// ----- Code Blocks -----
{
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 class="language-typescript"><code>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',
},
// ----- Blockquote -----
{
input: '> Blockquote',
expects: '<blockquote>\n\nBlockquote\n</blockquote>',
description: 'Apply blockquote style to text',
},
// ----- Headers -----
{
input: '# Header 1',
expects: '<p class="h1"><span style="font-size:1.7em"><b>Header 1</b></span></p>',
description: 'Apply h1 header style to text',
},
{
input: '## Header 2',
expects: '<p class="h2"><span style="font-size:1.42em"><b>Header 2</b></span></p>',
description: 'Apply h2 header style to text',
},
{
input: '### Header 3',
expects: '<p class="h3"><span style="font-size:1.13em"><b>Header 3</b></span></p>',
description: 'Apply h3 header style to text',
},
{
input: 'Header 1\n===\nHello World',
expects: '<p class="h1"><span style="font-size:1.7em"><b>Header 1</b></span></p>\nHello World',
description: 'Apply alternate h1 header style to text',
},
{
input: 'Header 2\n---\nHello World',
expects: '<p class="h2"><span style="font-size:1.42em"><b>Header 2</b></span></p>\nHello World',
description: 'Apply alternate h2 header style to text',
},
{
input: '#### Header 4',
expects: 'Header 4',
description: "Don't apply h4 header style to text since Teams doesn't support headers larger than h3",
},
{
input: '##### Header 5',
expects: 'Header 5',
description: "Don't apply h5 header style to text since Teams doesn't support headers larger than h3",
},
{
input: '###### Header 6',
expects: 'Header 6',
description: "Don't apply h6 header style to text since Teams doesn't support headers larger than h3",
},
// ----- Horizontal Rule -----
// Note: For e2e tests, text is required before the horizontal rule, otherwise nothing is rendered
{
input: 'Hello World\n\n---',
expects: 'Hello World\n<hr />',
description: 'Insert horizontal rule using dash notation',
},
{
input: 'Hello World\n***',
expects: 'Hello World\n<hr />',
description: 'Insert horizontal rule using asterisk notation',
},
{
input: 'Hello World\n___',
expects: 'Hello World\n<hr />',
description: 'Insert horizontal rule using underscore notation',
},
// ----- Hyperlinks -----
{
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: Teams 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: Teams 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',
},
// ----- Telephone Hyperlinks -----
{
input: '[Phone Number](tel:5141234567)',
expects: 'Phone Number',
description: 'Strip phone number markdown to plain text phone number since Teams does not support "tel" links',
},
{
input: '[Phone Number](tel:5141234567 "Tooltip Title")',
expects: 'Phone Number',
description: 'Strip phone number markdown with title attribute, since Teams does not support "tel" links',
},
{
input: '[Phone Number][id]\n\n[id]: tel:5141234567 "Tooltip Title"',
expects: 'Phone Number',
description: 'Strip phone number markdown using footnote style syntax since Teams does not support "tel" links',
},
// ----- Email Hyperlinks -----
{
input: '[Botpress Email](mailto:test@botpress.com)',
expects: '<a href="mailto:test@botpress.com">Botpress Email</a>',
description: 'Convert email link markdown to mailto email link',
},
{
input: '[Botpress Email](mailto:test@botpress.com "Tooltip Title")',
expects: '<a href="mailto:test@botpress.com" title="Tooltip Title">Botpress Email</a>',
description: 'Convert email link markdown with title attribute to mailto email link',
},
{
input: '[Botpress Email][id]\n\n[id]: mailto:test@botpress.com "Tooltip Title"',
expects: '<a href="mailto:test@botpress.com" title="Tooltip Title">Botpress Email</a>',
description: 'Convert email link markdown using footnote style syntax to mailto email link',
},
// ----- Images -----
{
input: '![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&width=600)',
expects:
'<img src="https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&amp;width=600" alt="Botpress Brand Logo" />',
description: 'Markdown image is converted to html image with alt text attribute',
},
{
input:
'![Botpress Brand Logo](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&amp;width=600 "Title Tooltip Text")',
expects:
'<img src="https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010&amp;width=600" alt="Botpress Brand Logo" title="Title Tooltip Text" />',
description: 'Markdown image is converted to html image with alt text & title attributes',
},
// ----- Lists -----
{
input: '- Item 1\n- Item 2\n- Item 3',
expects: '<ul>\n<li>\nItem 1\n</li>\n<li>\nItem 2\n</li>\n<li>\nItem 3\n</li>\n</ul>',
description: 'Markdown unordered lists convert to html unordered lists',
},
{
input: '1) Item 1\n2) Item 2\n3) Item 3',
expects: '<ol>\n<li>\nItem 1\n</li>\n<li>\nItem 2\n</li>\n<li>\nItem 3\n</li>\n</ol>',
description: 'Markdown ordered lists convert to html ordered lists',
},
{
input: '- [ ] Foo\n- [x] Bar',
expects: '<ul>\n<li>\n☐ Foo\n</li>\n<li>\n☑\uFE0E Bar\n</li>\n</ul>',
description: 'Dashed markdown task lists are converted to lists with checkbox unicode characters',
},
{
input: '1. [ ] Foo\n2. [x] Bar',
expects: '<ol>\n<li>\n☐ Foo\n</li>\n<li>\n☑\uFE0E Bar\n</li>\n</ol>',
description: 'Numbered markdown task lists are converted to lists with checkbox unicode characters',
},
// ----- Tables -----
{
input: '| Item 1 | Item 2 | Item 3 |\n| - | - | - |\n| Value 1 | Value 2 | Value 3 |',
expects: dedent`
<table>
<tr>
<th>Item 1</th>
<th>Item 2</th>
<th>Item 3</th>
</tr>
<tr>
<td>Value 1</td>
<td>Value 2</td>
<td>Value 3</td>
</tr>
</table>`,
description: 'Markdown tables convert to html tables',
},
// ==== Advanced Tests & Edge Cases ====
{
input: '- Item 1\n\t- Item A\n\t- Item B\n- Item 2\n- Item 3',
expects:
'<ul>\n<li>\nItem 1<ul>\n<li>\nItem A\n</li>\n<li>\nItem B\n</li>\n</ul>\n\n</li>\n<li>\nItem 2\n</li>\n<li>\nItem 3\n</li>\n</ul>',
description: 'Nested markdown unordered lists convert to nested html unordered lists',
},
{
input: '1) Item 1\n\t1) Item A\n\t2) Item B\n2) Item 2\n3) Item 3',
expects:
'<ol>\n<li>\nItem 1<ol>\n<li>\nItem A\n</li>\n<li>\nItem B\n</li>\n</ol>\n\n</li>\n<li>\nItem 2\n</li>\n<li>\nItem 3\n</li>\n</ol>',
description: 'Nested markdown ordered lists convert to nested html ordered lists',
},
{
input: '1) Ordered Item 1\n\t- Unordered Item A\n\t- Unordered Item B\n2) Ordered Item 2\n3) Ordered Item 3',
expects:
'<ol>\n<li>\nOrdered Item 1<ul>\n<li>\nUnordered Item A\n</li>\n<li>\nUnordered Item B\n</li>\n</ul>\n\n</li>\n<li>\nOrdered Item 2\n</li>\n<li>\nOrdered Item 3\n</li>\n</ol>',
description:
'Unordered markdown (md) list nested in ordered md list convert to unordered html list nested in ordered html list',
},
{
input: '- Unordered Item 1\n\t1) Ordered Item A\n\t2) Ordered Item B\n- Unordered Item 2\n- Unordered Item 3',
expects:
'<ul>\n<li>\nUnordered Item 1<ol>\n<li>\nOrdered Item A\n</li>\n<li>\nOrdered Item B\n</li>\n</ol>\n\n</li>\n<li>\nUnordered Item 2\n</li>\n<li>\nUnordered Item 3\n</li>\n</ul>',
description:
'Ordered markdown (md) list nested in unordered md list converts to ordered html list nested in unordered html list',
},
{
input: '> Blockquote Layer 1\n> > Blockquote Layer 2\n> > > Blockquote Layer 3',
expects:
'<blockquote>\n\nBlockquote Layer 1<blockquote>\n\nBlockquote Layer 2<blockquote>\n\nBlockquote Layer 3\n</blockquote>\n</blockquote>\n</blockquote>',
// This is supported in Teams based on manual testing (Last tested: 2025-11-20)
description: 'Apply nested blockquotes to text',
},
{
input: '(c) (C) (r) (R) (tm) (TM) +-',
expects: '© © ® ® ™ ™ ±',
description: 'Convert text into their typographic equivalents',
},
{
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<hr />\nBotpress\n<hr />\nWorld\n<hr />',
// NOTE: 3 dashes variant requires an additional newline, otherwise it converts into a size 2 header
description: 'Convert markdown thematic breaks (e.g. 3 dashes, asterisks, or underscores) into horizontal rules',
},
{
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.<br />\nThis is line two.',
description: 'Converts hardbreak into <br />',
},
{
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: '| Item 1 | Item 2 | Item 3 |\n| Value 1 | Value 2 | Value 3 |',
expects: '| Item 1 | Item 2 | Item 3 |\n| Value 1 | Value 2 | Value 3 |',
description: 'Markdown table without the splitter row does not convert to html',
},
{
input: '<label><b>Hello World!</b><br /></label>',
expects: '&lt;label&gt;&lt;b&gt;Hello World!&lt;/b&gt;&lt;br /&gt;&lt;/label&gt;',
description: 'HTML tags are escaped',
},
]
describe('Standard Markdown to Teams HTML Conversion', () => {
markdownToTeamsHtmlTestCases.forEach(({ input, expects, description, skip = false }: MarkdownToTeamsHtmlTestCase) => {
test.skipIf(skip)(description, () => {
const html = transformMarkdownToTeamsXml(input)
expect(html).toBe(expects)
})
})
test('Ensure javascript injection via markdown link is not possible', () => {
const html = transformMarkdownToTeamsXml("[click me](javascript:alert('XSS'))")
expect(html).toBe('click me')
})
test('Ensure javascript injection via markdown link reference is not possible', () => {
const html = transformMarkdownToTeamsXml(
'[click me][id]\n\n[id]: javascript:alert(\'LinkReferenceXSS\') "Tooltip Title"'
)
expect(html).toBe('click me')
})
test('Ensure javascript injection via html anchor tag is not possible', () => {
const html = transformMarkdownToTeamsXml('<a href="javascript:alert(\'XSS\')">click me</a>')
expect(html).toBe('&lt;a href="javascript:alert(\'XSS\')"&gt;click me&lt;/a&gt;')
})
test('Ensure javascript injection via html image tag is not possible', () => {
const html = transformMarkdownToTeamsXml('<img src="image.jpg" alt="alt text" onerror="alert(\'xss\')">')
expect(html).toBe('&lt;img src="image.jpg" alt="alt text" onerror="alert(\'xss\')"&gt;')
})
})
@@ -0,0 +1,167 @@
import { transformMarkdown, type MarkdownHandlers } from '@botpress/common'
import type { Parent } from 'mdast'
import { escapeAndSanitizeHtml, isNaughtyUrl, sanitizeHtml } from './sanitize-utils'
type DefinitionNodeData = {
identifier: string
url: string
label?: string | null
title?: string | null
}
export const defaultHandlers: MarkdownHandlers = {
blockquote: (node, visit) => `<blockquote>\n\n${visit(node)}\n</blockquote>`,
break: () => '<br />\n',
code: (node) => `<pre${node.lang ? ` class="language-${node.lang}"` : ''}><code>${node.value}\n</code></pre>`,
delete: (node, visit) => `<s>${visit(node)}</s>`,
emphasis: (node, visit) => `<em>${visit(node)}</em>`,
footnoteDefinition: (node, visit) => `[${node.identifier}] ${visit(node)}\n`,
footnoteReference: (node) => `[${node.identifier}]`,
heading: (node, visit) => {
// This approach of using font-size with bold is more
// consistent cross-platform than using <h1>, <h2>, <h3>.
// Also, the h1-h3 don't render correctly on Teams Desktop app.
const nodeContent = visit(node)
switch (node.depth) {
case 1:
return `<p class="h1"><span style="font-size:1.7em"><b>${nodeContent}</b></span></p>\n`
case 2:
return `<p class="h2"><span style="font-size:1.42em"><b>${nodeContent}</b></span></p>\n`
case 3:
return `<p class="h3"><span style="font-size:1.13em"><b>${nodeContent}</b></span></p>\n`
default:
return `${nodeContent}\n`
}
},
html: (node) => escapeAndSanitizeHtml(node.value),
image: (node) => _createSanitizedImage(node),
inlineCode: (node) => `<code>${node.value}</code>`,
link: (node, visit) => _createSanitizedHyperlink(node, visit(node)),
linkReference: (node, visit, _parents, _handlers, data) => {
const linkDefinitions = (data.linkDefinitions ?? {}) as Record<string, DefinitionNodeData>
const linkDefinition = linkDefinitions[node.identifier]
const nodeContent = visit(node)
if (!linkDefinition) {
return nodeContent
}
return _createSanitizedHyperlink(linkDefinition, nodeContent)
},
list: (node, visit) => {
const tag = node.ordered ? 'ol' : 'ul'
return `<${tag}>\n${visit(node)}</${tag}>\n`
},
listItem: (node, visit) => {
let checkbox = ''
const isChecked = node.checked ?? null
if (isChecked !== null) {
checkbox = isChecked ? '☑︎ ' : '☐ '
}
return `<li>\n${checkbox}${visit(node)}\n</li>\n`
},
paragraph: (node, visit, parents) => `${visit(node)}${parents.at(-1)?.type === 'root' ? '\n' : ''}`,
strong: (node, visit) => `<strong>${visit(node)}</strong>`,
table: (node, visit) => `<table>\n${visit(node)}</table>`,
tableRow: (node, visit) => `<tr>\n${visit(node)}</tr>\n`,
tableCell: (node, visit) => {
const tag = node.isHeader === true ? 'th' : 'td'
return `<${tag}>${visit(node)}</${tag}>\n`
},
text: (node) => node.value,
thematicBreak: () => '<hr />\n',
definition: (_node) => '',
}
type HyperlinkProps = {
url: string
title?: string | null
}
const _createSanitizedHyperlink = (props: HyperlinkProps, content: string): string => {
const { url, title } = props
if (isNaughtyUrl('a', url)) {
return content
}
return `<a href="${url}"${title ? ` title="${title}"` : ''}>${content}</a>`
}
type ImageProps = {
url: string
alt?: string | null
title?: string | null
}
const _createSanitizedImage = (props: ImageProps): string => {
const { url, alt: altText, title } = props
const optionalAttrs = `${altText ? ` alt="${altText}"` : ''}${title ? ` title="${title}"` : ''}`
if (isNaughtyUrl('img', url)) {
return `<img${optionalAttrs} />`
}
return `<img src="${url}"${optionalAttrs} />`
}
const _extractDefinitions = (parentNode: Parent): Record<string, DefinitionNodeData> => {
let definitions: Record<string, DefinitionNodeData> = {}
for (const node of parentNode.children) {
if ('children' in node) {
const extractedDefs = _extractDefinitions(node)
definitions = { ...definitions, ...extractedDefs }
}
if (node.type === 'definition') {
definitions[node.identifier] = {
identifier: node.identifier,
label: node.label,
url: node.url,
title: node.title,
}
}
}
return definitions
}
export const transformMarkdownToTeamsXml = (markdown: string): string => {
let html = transformMarkdown(markdown, defaultHandlers, (root) => {
const linkDefinitions = _extractDefinitions(root)
return { linkDefinitions }
}).trim()
_replacers.forEach((replacer) => {
html = replacer(html)
})
return sanitizeHtml(html)
}
// ==== Replacer functions ====
function ellipses(input: string): string {
return input.replace(/\.{3}/gim, '…')
}
function copyright(input: string): string {
return input.replace(/\(c\)/gi, '©')
}
function registeredTrademark(input: string): string {
return input.replace(/\(r\)/gi, '®')
}
function trademark(input: string): string {
return input.replace(/\(tm\)/gi, '™')
}
function plusOrMinus(input: string): string {
return input.replace(/\+-/gi, '±')
}
function enDash(input: string): string {
return input.replace(/(?<!-)-{2}(?!-)/gi, '')
}
function emDash(input: string): string {
return input.replace(/(?<!-)-{3}(?!-)/gi, '—')
}
const _replacers = [ellipses, copyright, registeredTrademark, trademark, plusOrMinus, enDash, emDash]
@@ -0,0 +1,119 @@
import SanitizeHTML from 'sanitize-html'
export const defaultSanitizeConfig = Object.assign({}, SanitizeHTML.defaults, {
allowedTags: [
'strong',
'b',
'em',
'i',
's',
'del',
'code',
'pre',
'blockquote',
'a',
'img',
'table',
'thead',
'tbody',
'tfoot',
'tr',
'th',
'td',
'ol',
'ul',
'li',
'hr',
'br',
'span',
'p',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
],
allowedAttributes: {
a: ['href', 'title'],
pre: ['class'],
code: ['class'],
p: ['class'],
span: ['style'],
img: ['src', 'srcset', 'alt', 'title', 'width', 'height'],
},
allowedSchemes: ['http', 'https', 'mailto'],
exclusiveFilter(frame) {
return frame.tag === 'a' && !frame.attribs.href?.trim() ? 'excludeTag' : false
},
} satisfies SanitizeHTML.IOptions)
export const sanitizeHtml = (html: string, options?: Partial<SanitizeHTML.IOptions>) =>
SanitizeHTML(html, options ? Object.assign({}, defaultSanitizeConfig, options) : defaultSanitizeConfig)
/** Avoid false positives with .__proto__, .hasOwnProperty, etc.
*
* @remark Pulled from the "sanitize-html" package */
function has(obj: unknown, key: string) {
return {}.hasOwnProperty.call(obj, key)
}
/** Checks if the provided URL is "naughty" (AKA potentially malicious).
*
* @remark Pulled from the "sanitize-html" package to handle URL scanning
* for XSS with some minor adjustments (Mainly to make TypeScript happy)
* @remark The original function name in the package is "naughtyHref" */
export function isNaughtyUrl(tagName: string, href: string) {
// Browsers ignore character codes of 32 (space) and below in a surprising
// number of situations. Start reading here:
// https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab
href = href.replace(/[\x00-\x20]+/g, '')
// Clobber any comments in URLs, which the browser might
// interpret inside an XML data island, allowing
// a javascript: URL to be snuck through
while (true) {
const firstIndex = href.indexOf('<!--')
if (firstIndex === -1) {
break
}
const lastIndex = href.indexOf('-->', firstIndex + 4)
if (lastIndex === -1) {
break
}
href = href.substring(0, firstIndex) + href.substring(lastIndex + 3)
}
// Case insensitive so we don't get faked out by JAVASCRIPT #1
// Allow more characters after the first so we don't get faked
// out by certain schemes browsers accept
const matches = href.match(/^([a-zA-Z][a-zA-Z0-9.\-+]*):/)
const scheme = matches?.[1]?.toLowerCase()
if (!scheme) {
// Protocol-relative URL starting with any combination of '/' and '\'
if (href.match(/^[/\\]{2}/)) {
return !defaultSanitizeConfig.allowProtocolRelative
}
// No scheme
return false
}
if (has(defaultSanitizeConfig.allowedSchemesByTag, tagName)) {
return defaultSanitizeConfig.allowedSchemesByTag[tagName]?.indexOf(scheme) === -1
}
return !defaultSanitizeConfig.allowedSchemes || defaultSanitizeConfig.allowedSchemes.indexOf(scheme) === -1
}
export const escapeAndSanitizeHtml = (html: string) => {
const escapedHtml = html
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
// And sanitize it for good measure in
// case above replacers missed something
return sanitizeHtml(escapedHtml)
}
@@ -0,0 +1,312 @@
import { describe, expect, test } from 'vitest'
import { TestCase } from '../types'
import { transformTeamsHtmlToStdMarkdown } from './teams-html-to-markdown'
type TeamsHtmlToMarkdownTestCase = TestCase<string, string> | TestCase<string[], string>
const teamsHtmlToMarkdownTestCases: TestCase<string, string>[] = (
[
// ----- Bold -----
{
input: ['<b>Bold</b>', '<strong>Bold</strong>'],
expects: '**Bold**',
description: 'Convert bold tag to markdown',
},
// ----- Italic -----
{
input: ['<i>Italic</i>', '<em>Italic</em>'],
expects: '_Italic_',
description: 'Convert italic tag to markdown',
},
// ----- Strikethrough -----
{
input: ['<s>Strike</s>', '<del>Strike</del>'],
expects: '~~Strike~~',
description: 'Convert strikethrough tag to markdown',
},
// ----- Headings & Paragraph -----
{
input: '<h1>Heading 1</h1>',
expects: '# Heading 1',
description: 'Convert h1 tag to heading markdown',
},
{
input: '<h2>Heading 2</h2>',
expects: '## Heading 2',
description: 'Convert h2 tag to heading markdown',
},
{
input: '<h3>Heading 3</h3>',
expects: '### Heading 3',
description: 'Convert h3 tag to heading markdown',
},
{
input: '<h4>Heading 4</h4>',
expects: '#### Heading 4',
// Though h4 is not supported by MS Teams, this
// converter should still handle it correctly
description: 'Convert h4 tag to heading markdown',
},
{
input: '<h5>Heading 5</h5>',
expects: '##### Heading 5',
// Though h5 is not supported by MS Teams, this
// converter should still handle it correctly
description: 'Convert h5 tag to heading markdown',
},
{
input: '<h6>Heading 6</h6>',
expects: '###### Heading 6',
// Though h6 is not supported by MS Teams, this
// converter should still handle it correctly
description: 'Convert h6 tag to heading markdown',
},
{
input: '<p>Paragraph</p>',
expects: 'Paragraph',
description: 'Convert paragraph tag to markdown',
},
// ----- Horizontal Rule -----
{
input: 'Horizontal Rule<hr />',
expects: 'Horizontal Rule\n\n---',
// Requires 2 new lines before horizontal rule
// markdown otherwise it becomes a heading mark
description: 'Convert horizontal rule tag to markdown',
},
// ----- Inline Code -----
{
input: '<code>Code Snippet</code>',
expects: '`Code Snippet`',
description: 'Convert inline code html to markdown',
},
// ----- Code Blocks -----
{
input: '<pre><code>console.log("Code Block")</code></pre>',
expects: '```\nconsole.log("Code Block")\n```',
description: 'Convert code block html to markdown - Without language',
},
{
input: '<pre class="language-javascript"><code>console.log("Code Block")</code></pre>',
expects: '```javascript\nconsole.log("Code Block")\n```',
description: 'Convert code block html to markdown - With language',
},
// ----- Lists -----
{
input: '<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>',
expects: '- Item 1\n- Item 2\n- Item 3',
description: 'Convert unordered list HTML to markdown list',
},
{
input: '<ol><li>Item 1</li><li>Item 2</li><li>Item 3</li></ol>',
expects: '1. Item 1\n2. Item 2\n3. Item 3',
description: 'Convert ordered list HTML to markdown list',
},
// ----- Tables -----
{
input: [
// With thead/tbody
'<table><thead><tr><th>Header 1</th><th>Header 2</th><th>Header 3</th></tr></thead><tbody><tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td><td>Row 1, Cell 3</td></tr><tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td><td>Row 2, Cell 3</td></tr><tr><td>Row 3, Cell 1</td><td>Row 3, Cell 2</td><td>Row 3, Cell 3</td></tr></tbody></table>',
// Without thead/tbody
'<table><tr><th>Header 1</th><th>Header 2</th><th>Header 3</th></tr><tr><td>Row 1, Cell 1</td><td>Row 1, Cell 2</td><td>Row 1, Cell 3</td></tr><tr><td>Row 2, Cell 1</td><td>Row 2, Cell 2</td><td>Row 2, Cell 3</td></tr><tr><td>Row 3, Cell 1</td><td>Row 3, Cell 2</td><td>Row 3, Cell 3</td></tr></table>',
// Actual MS Teams table created by user
'<p>&nbsp;</p>\n<table>\n<tbody>\n<tr>\n<td>\n<p>Header 1</p>\n</td>\n<td>\n<p>Header 2</p>\n</td>\n<td>\n<p>Header 3</p>\n</td>\n</tr>\n<tr>\n<td>\n<p>Row 1, Cell 1</p>\n</td>\n<td>\n<p>Row 1, Cell 2</p>\n</td>\n<td>\n<p>Row 1, Cell 3</p>\n</td>\n</tr>\n<tr>\n<td>\n<p>Row 2, Cell 1</p>\n</td>\n<td>\n<p>Row 2, Cell 2</p>\n</td>\n<td>\n<p>Row 2, Cell 3</p>\n</td>\n</tr>\n<tr>\n<td>\n<p>Row 3, Cell 1</p>\n</td>\n<td>\n<p>Row 3, Cell 2</p>\n</td>\n<td>\n<p>Row 3, Cell 3</p>\n</td>\n</tr>\n</tbody>\n</table>\n<p>&nbsp;</p>',
],
expects:
'| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Row 1, Cell 1 | Row 1, Cell 2 | Row 1, Cell 3 |\n| Row 2, Cell 1 | Row 2, Cell 2 | Row 2, Cell 3 |\n| Row 3, Cell 1 | Row 3, Cell 2 | Row 3, Cell 3 |',
// NOTE: This converter cannot support "Editable"/"FluidEmbedCard" tables since there is no way to access the content of those cards (Last checked: 2025-11-25)
description: 'Convert table HTML to markdown table',
},
// ----- Blockquote -----
{
input: [
'<blockquote>\n\nBlockquote\n</blockquote>',
// Actual Teams HTML output for blockquote
'<blockquote>\n<p>Blockquote</p>\n</blockquote>\n<p>&nbsp;</p>',
],
expects: '> Blockquote',
description: 'Apply blockquote markdown to text',
},
// ----- Hyperlinks -----
{
input: '<a href="https://www.botpress.com/">Hyperlink</a>',
expects: '[Hyperlink](https://www.botpress.com/)',
description: 'Convert hyperlink html tag without title attribute to markdown',
},
{
input: '<a href="https://www.botpress.com/" title="Test Title">Hyperlink</a>',
expects: '[Hyperlink](https://www.botpress.com/ "Test Title")',
description: 'Convert hyperlink html tag with title attribute to markdown',
},
// ----- Telephone Hyperlinks -----
{
input: '<a href=\"tel:1234567890\">Phone Number</a>',
expects: '[Phone Number](tel:1234567890)',
// Though telephone links are not currently supported by
// MS Teams, this converter should still handle it correctly
description: 'Convert telephone link tag without title attribute to markdown',
},
{
input: '<a href=\"tel:1234567890\" title=\"Telephone Title\">Phone Number</a>',
expects: '[Phone Number](tel:1234567890 "Telephone Title")',
// Though telephone links are not currently supported by
// MS Teams, this converter should still handle it correctly
description: 'Convert telephone link tag with title attribute to markdown',
},
// ----- Email Hyperlinks -----
{
input: '<a href=\"mailto:test@botpress.com\">Test Email Address</a>',
expects: '[Test Email Address](mailto:test@botpress.com)',
description: 'Convert email address link tag without title attribute to markdown',
},
{
input: [
'<a href=\"mailto:test@botpress.com\" title=\"mailto:test@botpress.com\">Test Email Address</a>',
// Actual Teams HTML output for email address
'<p><a href=\"mailto:test@botpress.com\" rel=\"noreferrer noopener\" title=\"mailto:test@botpress.com\" target=\"_blank\">Test Email Address</a></p>',
],
expects: '[Test Email Address](mailto:test@botpress.com "mailto:test@botpress.com")',
description: 'Convert email address link tag with title attribute to markdown',
},
// ----- Image -----
{
input: [
'<img src="https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010" alt="image" />',
// Actual Teams HTML output for image (with "src" & "itemid" attribute values anonymized)
'<p>&nbsp;</p>\n<p><img src="https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010" width="572" height="128" alt="image" itemid="some-graph-api-id"></p>',
],
expects: '![image](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010)',
description: 'Convert image html tag without title attribute to markdown',
},
{
input: [
'<img src="https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010" alt="image" title="Test Title" />',
// Actual Teams HTML output for image (with "src" & "itemid" attribute values anonymized & title attribute added)
'<p>&nbsp;</p>\n<p><img src="https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010" width="572" height="128" alt="image" itemid="some-graph-api-id" title="Test Title"></p>',
],
expects: '![image](https://shop.botpress.com/cdn/shop/files/logo.png?v=1708026010 "Test Title")',
description: 'Convert image html tag with title attribute to markdown',
},
// ----- Unsupported tags -----
{
input: '<p><u>Underline</u></p>',
expects: 'Underline',
// The standard markdown spec does not support underlines
description: 'Do not apply unsupported underline effect',
},
// ===== Advanced test cases =====
{
input: '<b>a</b><s>b</s><b>c</b><s>d</s><b>e</b><s>f</s><b>g</b><s>h</s',
expects: '**a**~~b~~**c**~~d~~**e**~~f~~**g**~~h~~',
description: 'Convert contiguous non-overlapping html effects into separate markdown effects',
},
{
input: '<b>Hello</b> New <s>World</s>',
expects: '**Hello** New ~~World~~',
description: 'Convert non-overlapping effects with gap into separate markdown effects',
},
{
input: '<s><b>Multiple Effects</b></s>',
expects: '~~**Multiple Effects**~~',
description: 'Convert nested effects on the same range correctly',
},
{
input: '<p><strong>Hello </strong><i><strong>New</strong> World</i></p>',
expects: '**Hello** _**New** World_',
description: 'Convert encapsulated nested effects corrrectly',
},
{
input: '<i><s><b>Multiple Effects</b></s></i>',
expects: '_~~**Multiple Effects**~~_',
description: 'Convert multiple effects on the same range into nested markdown effects correctly',
},
{
input: '<b>Unterminated Bold',
expects: '**Unterminated Bold**',
description: 'Convert bold html tag without closing tag to markdown',
},
{
input: '<blockquote>Hello</blockquote>\nNothing\n<blockquote>More Quotes!</blockquote>',
expects: '> Hello\n\nNothing\n\n> More Quotes!',
description: 'Apply blockquote markdown to multiple lines, with non-quote line in between',
},
{
input: '<blockquote>Hello <b>Quote</b> World</blockquote>',
expects: '> Hello **Quote** World',
description:
// An incorrect outcome of this test case would be "> Hello **> Quote**> World"
"Ensure any effect nested within blockquote tag doesn't create multiple blockquote marks (It should only ever be at the start of a line)",
},
{
input: '<blockquote>\n<p>Blockquote 1<br>\nBlockquote 2<br>\nBlockquote 3</p>\n</blockquote>\n<p>&nbsp;</p>',
expects: '> Blockquote 1\n> Blockquote 2\n> Blockquote 3',
description: 'Multiline blockquote produces a blockquote mark for each line',
},
{
input: '<blockquote>\n<p>Quote Line 1<br>\n<br>\nQuote Line 3</p>\n</blockquote>\n<p>&nbsp;</p>',
expects: '> Quote Line 1\n> \n> Quote Line 3',
description: 'Multiline blockquote produces a blockquote mark for each line, with empty lines',
},
{
input: [
'<blockquote><strong>Quote Line 1</strong><br>\n<br>\n<strong>Quote Line 3</strong>\n<p>&nbsp;</p>\n</blockquote>\n<p>&nbsp;</p>',
'<blockquote>\n<p><strong>Quote Line 1</strong><br>\n<br>\n<strong>Quote Line 3</strong></p>\n</blockquote>\n<p>&nbsp;</p>',
],
expects: '> **Quote Line 1**\n> \n> **Quote Line 3**',
description:
'Multiline blockquote produces a blockquote mark for each line, with empty lines and intersecting effect',
},
{
input: '<p><a href=\"https://www.botpress.com/\">Some <strong>Hyperlink</strong></a></p>',
expects: '[Some **Hyperlink**](https://www.botpress.com/)',
description: 'Convert hyperlink with partial bold effect on link text to markdown (without title attribute)',
},
{
input:
'<p><a href=\"https://www.botpress.com/\" title=\"https://www.botpress.com/\">Some <strong>Hyperlink</strong></a></p>',
expects: '[Some **Hyperlink**](https://www.botpress.com/ "https://www.botpress.com/")',
description: 'Convert hyperlink with partial bold effect on link text to markdown (with title attribute)',
},
{
input: '<a href="javascript:console.log(\'XSS Attack\')">Click Me</a>',
expects: 'Click Me',
description: 'Strip hyperlink with XSS logic in href attribute',
},
{
input: '<img src="image.jpg" alt="alt text" onerror="alert(\'xss\')" />',
expects: '![alt text](image.jpg)',
description: 'Strip image with XSS logic in onerror attribute',
},
] as TeamsHtmlToMarkdownTestCase[]
).reduce(
(testCases, testCase) => {
const { input, expects, description } = testCase
if (typeof input === 'string') {
return testCases.concat({
input,
expects,
description,
})
} else if (Array.isArray(input)) {
return testCases.concat(
input.map((inputVariant) => {
return {
input: inputVariant,
expects,
description,
}
})
)
}
return testCases
},
[] as TestCase<string, string>[]
)
describe('Teams Html to Markdown Conversion', () => {
test.each(teamsHtmlToMarkdownTestCases)('$description', ({ input, expects }: TestCase<string, string>) => {
const markdown = transformTeamsHtmlToStdMarkdown(input)
expect(markdown).toBe(expects)
})
})
@@ -0,0 +1,29 @@
import TurndownService from 'turndown'
import { defaultSanitizeConfig, sanitizeHtml } from './sanitize-utils'
import { customPlugin } from './turndown-rules/custom'
import { gfm } from './turndown-rules/gfm'
export function transformTeamsHtmlToStdMarkdown(teamsHtml: string) {
const turndownService = new TurndownService({
codeBlockStyle: 'fenced',
headingStyle: 'atx',
bulletListMarker: '-',
hr: '\n\n---',
preformattedCode: true,
})
gfm(turndownService)
customPlugin(turndownService)
const sanitizedHtml = sanitizeHtml(teamsHtml, {
allowedSchemes: defaultSanitizeConfig.allowedSchemes.concat('tel'),
})
return (
turndownService
.turndown(sanitizedHtml)
// Remove trailing spaces added before line breaks (Side effect of "<br />\n")
.replace(/ \n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/ {2,}/g, ' ')
)
}
@@ -0,0 +1,6 @@
export const isElementOfType = <K extends keyof HTMLElementTagNameMap>(
el: Element,
tagName: K
): el is HTMLElementTagNameMap[K] => {
return el.tagName.toLowerCase() === tagName.toLowerCase()
}
@@ -0,0 +1,19 @@
import TurndownService from 'turndown'
import { isElementOfType } from '../common'
const languageRegExp = /language-(\w+)/
// The "turndown-plugin-gfm" package did not support parsing code blocks with language classes.
export const codeBlockWithLanguage = (turndownService: TurndownService) => {
turndownService.addRule('codeBlockWithLanguage', {
filter: (node: HTMLElement): boolean => {
const firstChild = node.firstElementChild
return isElementOfType(node, 'pre') && firstChild !== null && isElementOfType(firstChild, 'code')
},
replacement(_: string, node: HTMLElement, options: TurndownService.Options) {
const className = node.className ?? ''
const language = className.match(languageRegExp)?.[1] ?? ''
return options.fence + language + '\n' + node.textContent + '\n' + options.fence
},
})
}
@@ -0,0 +1,7 @@
import TurndownService from 'turndown'
import { codeBlockWithLanguage } from './code-block-with-language'
import { stripUnderline } from './underline'
export const customPlugin = (turndownService: TurndownService) => {
turndownService.use([stripUnderline, codeBlockWithLanguage])
}
@@ -0,0 +1,10 @@
import TurndownService from 'turndown'
export const stripUnderline = (turndownService: TurndownService) => {
turndownService.addRule('underline', {
filter: ['u'],
replacement(content) {
return content
},
})
}
@@ -0,0 +1,23 @@
import type TurndownService from 'turndown'
const highlightRegExp = /highlight-(?:text|source)-([a-z0-9]+)/
export const highlightedCodeBlock = (turndownService: TurndownService) => {
turndownService.addRule('highlightedCodeBlock', {
filter(node: HTMLElement): boolean {
const firstChild = node.firstChild
return (
node.nodeName === 'DIV' &&
highlightRegExp.test(node.className) &&
firstChild !== null &&
firstChild.nodeName === 'PRE'
)
},
replacement(_, node, options) {
const className = node.className || ''
const language = (className.match(highlightRegExp) || [null, ''])[1]
return '\n\n' + options.fence + language + '\n' + node.firstChild?.textContent + '\n' + options.fence + '\n\n'
},
})
}
@@ -0,0 +1,15 @@
import TurndownService from 'turndown'
import { highlightedCodeBlock } from './highlighted-code-block'
import { strikethrough } from './strikethrough'
import { tables } from './tables'
import { taskListItems } from './task-list-items'
/** This plugin is mostly pulled from the source code of the "turndown-plugin-gfm" npm package.
* The reason we are not using the package directly is that it doesn't support ES6 imports.
*
* @remark The source code was slightly modified to work with TypeScript.
* @see [NPM Package](https://www.npmjs.com/package/turndown-plugin-gfm)
* @see [Repository](https://github.com/mixmark-io/turndown-plugin-gfm) */
export const gfm = (turndownService: TurndownService) => {
turndownService.use([highlightedCodeBlock, strikethrough, tables, taskListItems])
}
@@ -0,0 +1,10 @@
import TurndownService from 'turndown'
export const strikethrough = (turndownService: TurndownService) => {
turndownService.addRule('strikethrough', {
filter: ['del', 's'],
replacement(content) {
return `~~${content}~~`
},
})
}
@@ -0,0 +1,94 @@
import type TurndownService from 'turndown'
import { isElementOfType } from '../common'
const rules: Record<string, TurndownService.Rule> = {}
rules.tableCell = {
filter: ['th', 'td'],
replacement(content: string, node: HTMLElement) {
return cell(content, node).replace(/\n/g, '')
},
}
rules.tableRow = {
filter: 'tr',
replacement(content: string, node: HTMLElement) {
let borderCells = ''
const alignMap: Record<string, string> = { left: ':--', right: '--:', center: ':-:' }
if (isHeadingRow(node)) {
Array.from(node.children).forEach((child) => {
let border = '---'
const align = (child.getAttribute('align') || '').toLowerCase()
if (align) border = alignMap[align] || border
borderCells += cell(border, child)
})
} else {
}
return '\n' + content + (borderCells ? '\n' + borderCells : '')
},
}
const _isTableWithHeadingRow = (node: Element) => {
if (!isElementOfType(node, 'table')) return false
const firstRow = node.rows[0]
return !!firstRow && isHeadingRow(firstRow)
}
rules.table = {
// Only convert tables with a heading row.
// Tables with no heading row are kept using `keep` (see below).
filter: (node) => _isTableWithHeadingRow(node),
replacement(content) {
// Ensure there are no blank lines
content = content.replace('\n\n', '\n')
return '\n\n' + content + '\n\n'
},
}
rules.tableSection = {
filter: ['thead', 'tbody', 'tfoot'],
replacement(content: string) {
return content
},
}
// A tr is a heading row if:
// - the parent is a THEAD
// - or if its the first child of the TABLE or the first TBODY (possibly
// following a blank THEAD)
// - and every cell is a TH
function isHeadingRow(tr: HTMLElement) {
const parentNode = tr.parentNode
return (
parentNode?.nodeName === 'THEAD' ||
(parentNode?.firstChild === tr && (parentNode.nodeName === 'TABLE' || isFirstTbody(parentNode)))
)
}
function isFirstTbody(element: Node) {
const previousSibling = element.previousSibling
return (
element.nodeName === 'TBODY' &&
(!previousSibling || (previousSibling.nodeName === 'THEAD' && /^\s*$/i.test(previousSibling.textContent ?? '')))
)
}
function cell(content: string, node: Element) {
const childNodes = Array.from(node.parentNode?.childNodes ?? [])
const index = childNodes.indexOf(node)
let prefix = ' '
if (index === 0) prefix = '| '
return prefix + content + ' |'
}
export const tables = (turndownService: TurndownService) => {
turndownService.keep((node) => !isElementOfType(node, 'table'))
for (const [key, rule] of Object.entries(rules)) {
turndownService.addRule(key, rule)
}
}
@@ -0,0 +1,17 @@
import TurndownService from 'turndown'
import { isElementOfType } from '../common'
export const taskListItems = (turndownService: TurndownService) => {
turndownService.addRule('taskListItems', {
filter(node: HTMLElement): boolean {
return isElementOfType(node, 'input') && node.type === 'checkbox' && node.parentNode?.nodeName === 'LI'
},
replacement(_content: string, node: HTMLElement) {
if (!isElementOfType(node, 'input')) {
return ''
}
return `${node.checked ? '[x]' : '[ ]'} `
},
})
}
@@ -0,0 +1,37 @@
import { ClientSecretCredential } from '@azure/identity'
import { Client } from '@microsoft/microsoft-graph-client'
import * as bp from '.botpress'
export class MicrosoftClient {
private _graphClient: Client
private constructor(ctx: bp.Context) {
const { tenantId, appId, appPassword } = ctx.configuration
const credential = new ClientSecretCredential(tenantId as string, appId, appPassword)
this._graphClient = Client.initWithMiddleware({
authProvider: {
getAccessToken: async () => {
const token = await credential.getToken('https://graph.microsoft.com/.default')
return token?.token!
},
},
})
}
public async getUserByEmail(email: string) {
try {
return await this._graphClient.api(`/users/${email}`).get()
} catch (err: any) {
if (err.statusCode === 404) {
throw new Error(`No user found with email: ${email}`)
}
throw err
}
}
public static create(ctx: bp.Context) {
return new MicrosoftClient(ctx)
}
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from '@botpress/sdk'
import { channelAccountSchema } from 'definitions/states'
export const teamsActivitySchema = z
.object({
id: z.string().trim().min(1),
type: z.string(),
from: channelAccountSchema.passthrough().partial().optional(),
conversation: z
.object({
id: z.string().trim().min(1),
})
.passthrough(),
text: z.string().optional(),
})
.passthrough()
+22
View File
@@ -0,0 +1,22 @@
import { RuntimeError } from '@botpress/sdk'
import axios, { isAxiosError } from 'axios'
import * as bp from '.botpress'
export const register: bp.Integration['register'] = async ({ ctx }) => {
const tenant = ctx.configuration.tenantId ?? 'botframework.com'
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: ctx.configuration.appId,
client_secret: ctx.configuration.appPassword,
tenant_id: tenant,
scope: 'https://api.botframework.com/.default',
})
await axios.post(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, params.toString()).catch((e) => {
const message = isAxiosError(e) ? e.response?.data?.error_description : e.message
throw new RuntimeError(`Failed to authenticate with Microsoft Teams: ${message}`)
})
}
export const unregister: bp.Integration['unregister'] = async () => {}
+6
View File
@@ -0,0 +1,6 @@
export type TestCase<INPUT = unknown, EXPECTED = unknown> = {
input: INPUT
expects: EXPECTED
description: string
skip?: boolean
}
+38
View File
@@ -0,0 +1,38 @@
import { BotFrameworkAdapter, ConversationReference } from 'botbuilder'
import * as bp from '.botpress'
type TeamsConfig = bp.configuration.Configuration
export const getAdapter = (config: TeamsConfig) => {
return new BotFrameworkAdapter({
channelAuthTenant: config.tenantId,
appId: config.appId,
appPassword: config.appPassword,
})
}
export const getConversationReference = async ({
conversationId,
client,
}: {
conversationId: string
client: bp.Client
}) => {
const stateRes = await client.getState({
id: conversationId,
name: 'conversation',
type: 'conversation',
})
const { state } = stateRes
return state.payload as ConversationReference
}
export const sleep = (ms: number) => {
return new Promise<undefined>((resolve) => {
setTimeout(() => resolve(undefined), ms)
})
}
export const getError = (thrown: unknown) => {
return thrown instanceof Error ? thrown : new Error(String(thrown))
}
@@ -0,0 +1,26 @@
import { Activity } from 'botbuilder'
import { processInboundChannelMessage } from '../channels/inbound'
import { teamsActivitySchema } from '../schemas'
import { authorizeRequest } from './signature'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async (props) => {
const { req } = props
await authorizeRequest(req)
if (!req.body) {
props.logger.forBot().warn('Handler received an empty body')
return
}
const parsedBody: Object = JSON.parse(req.body)
const result = teamsActivitySchema.safeParse(parsedBody)
if (!result.success) {
props.logger.forBot().error('Invalid activity payload received:', result.error.format())
return
}
const activity = result.data as unknown as Activity
props.logger.forBot().info(`Handler received event of type ${activity.type}`)
await processInboundChannelMessage(props, activity)
}
@@ -0,0 +1,38 @@
import { Request } from '@botpress/sdk'
import jwt, { JwtHeader, SigningKeyCallback } from 'jsonwebtoken'
import { JwksClient } from 'jwks-rsa'
const jwksClient = new JwksClient({
jwksUri: 'https://login.botframework.com/v1/.well-known/keys',
})
const _getKey = (header: JwtHeader, callback: SigningKeyCallback) => {
jwksClient.getSigningKey(header.kid, function (err, key) {
if (key) {
callback(null, key.getPublicKey())
} else if (err) {
callback(err)
} else {
callback(new Error('No key found'))
}
})
}
export const authorizeRequest = async (req: Request) => {
const authorization = req.headers.authorization
const [, token] = authorization?.split(' ') ?? []
if (!token) {
throw new Error('No authorization token found')
}
return new Promise((resolve, reject) => {
jwt.verify(token, _getKey, (err, decoded) => {
if (err) {
reject(err)
} else {
resolve(decoded)
}
})
})
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config