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
@@ -0,0 +1,17 @@
import * as sdk from '@botpress/sdk'
import * as bp from '../../.botpress'
import { getTwilioClient } from '../twilio'
export const getOrCreateUser: bp.IntegrationProps['actions']['getOrCreateUser'] = async ({ client, ctx, input }) => {
const userPhone = input.user.userPhone
if (!userPhone) {
throw new sdk.RuntimeError('Could not create a user: missing channel or userId')
}
const twilioClient = getTwilioClient(ctx)
const phone = await twilioClient.lookups.phoneNumbers(userPhone).fetch()
const { user } = await client.getOrCreateUser({ tags: { userPhone: phone.phoneNumber } })
return { userId: user.id }
}
+11
View File
@@ -0,0 +1,11 @@
import * as bp from '../../.botpress'
import { getOrCreateUser } from './get-or-create-user'
import { startConversation } from './start-conversation'
import { startTypingIndicator, stopTypingIndicator } from './typing-indicator'
export const actions = {
getOrCreateUser,
startConversation,
startTypingIndicator,
stopTypingIndicator,
} satisfies bp.IntegrationProps['actions']
@@ -0,0 +1,28 @@
import * as sdk from '@botpress/sdk'
import * as bp from '../../.botpress'
import { getTwilioClient } from '../twilio'
export const startConversation: bp.IntegrationProps['actions']['startConversation'] = async ({
client,
ctx,
input,
}) => {
const userPhone = input.conversation.userPhone
const activePhone = input.conversation.activePhone
if (!activePhone || !activePhone) {
throw new sdk.RuntimeError('Could not create conversation: missing channel, channelId or userId')
}
const twilioClient = getTwilioClient(ctx)
const phone = await twilioClient.lookups.phoneNumbers(userPhone).fetch()
const { conversation } = await client.getOrCreateConversation({
tags: { activePhone, userPhone: phone.phoneNumber },
channel: 'channel',
})
return {
conversationId: conversation.id,
}
}
@@ -0,0 +1,65 @@
import * as bp from '../../.botpress'
import { getTwilioClient } from '../twilio'
import { getPhoneNumbers, getTwilioChannelType } from '../utils'
async function sendTypingIndicator({
client,
ctx,
conversationId,
messageId,
}: {
client: bp.Client
ctx: bp.Context
conversationId: string
messageId: string
}): Promise<void> {
const { conversation } = await client.getConversation({ id: conversationId })
const { to } = getPhoneNumbers(conversation)
const channelType = getTwilioChannelType(to)
// Typing indicators only supported for WhatsApp
if (channelType !== 'whatsapp') {
return
}
const { message } = await client.getMessage({ id: messageId })
const twilioMessageSid = message.tags?.id
if (!twilioMessageSid) {
return
}
const twilioClient = getTwilioClient(ctx)
// The Twilio SDK doesn't expose the v2 Indicators API natively yet (Its in beta),
// so we use client.request() to call the REST endpoint directly
await twilioClient.request({
method: 'post',
uri: 'https://messaging.twilio.com/v2/Indicators/Typing.json',
data: {
messageId: twilioMessageSid,
channel: 'whatsapp',
},
})
}
export const startTypingIndicator: bp.IntegrationProps['actions']['startTypingIndicator'] = async ({
client,
ctx,
input,
logger,
}) => {
try {
const { conversationId, messageId } = input
await sendTypingIndicator({ client, ctx, conversationId, messageId })
} catch (error) {
const thrown = error instanceof Error ? error : new Error(String(error))
logger.forBot().warn(`Failed to send typing indicator: ${thrown.message ?? '[Unknown error]'}`)
}
return {}
}
export const stopTypingIndicator: bp.IntegrationProps['actions']['stopTypingIndicator'] = async () => {
// Twilio's typing indicator automatically stops after 25 seconds or when a message is sent.
return {}
}
+128
View File
@@ -0,0 +1,128 @@
import { isApiError } from '@botpress/client'
import { posthogHelper } from '@botpress/common'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import { transformMarkdownForTwilio } from './markdown-to-twilio'
import { getTwilioClient } from './twilio'
import { getPhoneNumbers, getTwilioChannelType, renderCard, renderChoiceMessage } from './utils'
import * as bp from '.botpress'
type Channels = bp.Integration['channels']
type Messages = Channels[keyof Channels]['messages']
type MessageHandler = Messages[keyof Messages]
type MessageHandlerProps = Parameters<MessageHandler>[0]
type SendMessageProps = Pick<MessageHandlerProps, 'ctx' | 'conversation' | 'ack' | 'logger'> & {
mediaUrl?: string
text?: string
}
function renderLocation({
title,
address,
latitude,
longitude,
}: {
latitude: number
longitude: number
title?: string
address?: string
}): string {
const messageParts: string[] = []
if (title) {
messageParts.push(title, '')
}
if (address) {
messageParts.push(address, '')
}
messageParts.push(`https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`)
return messageParts.join('\n')
}
async function sendMessage({ ctx, conversation, ack, mediaUrl, text, logger }: SendMessageProps) {
const twilioClient = getTwilioClient(ctx)
const { to, from } = getPhoneNumbers(conversation)
const twilioChannel = getTwilioChannelType(to)
let body = text
if (body !== undefined) {
try {
body = transformMarkdownForTwilio(body, twilioChannel)
} catch (thrown) {
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
logger.forBot().debug('Failed to transform markdown - Error:', errMsg)
const distinctId = isApiError(thrown) ? thrown.id : undefined
await posthogHelper.sendPosthogEvent(
{
distinctId: distinctId ?? 'no id',
event: 'unhandled_markdown',
properties: { errMsg },
},
{ integrationName: INTEGRATION_NAME, integrationVersion: INTEGRATION_VERSION, key: bp.secrets.POSTHOG_KEY }
)
}
}
const { sid } = await twilioClient.messages.create({ to, from, mediaUrl: mediaUrl ? [mediaUrl] : undefined, body })
await ack({ tags: { id: sid } })
}
export const channels = {
channel: {
messages: {
text: async (props) => void (await sendMessage({ ...props, text: props.payload.text })),
image: async (props) => void (await sendMessage({ ...props, mediaUrl: props.payload.imageUrl })),
audio: async (props) => void (await sendMessage({ ...props, mediaUrl: props.payload.audioUrl })),
video: async (props) => void (await sendMessage({ ...props, mediaUrl: props.payload.videoUrl })),
file: async (props) => void (await sendMessage({ ...props, text: props.payload.fileUrl })),
location: async (props) => void (await sendMessage({ ...props, text: renderLocation(props.payload) })),
carousel: async (props) => {
const {
payload: { items },
} = props
const total = items.length
for (const [i, card] of items.entries()) {
await sendMessage({ ...props, text: renderCard(card, `${i + 1}/${total}`), mediaUrl: card.imageUrl })
}
},
card: async (props) => {
const { payload: card } = props
await sendMessage({ ...props, text: renderCard(card), mediaUrl: card.imageUrl })
},
dropdown: async (props) => {
await sendMessage({ ...props, text: renderChoiceMessage(props.payload) })
},
choice: async (props) => {
await sendMessage({ ...props, text: renderChoiceMessage(props.payload) })
},
bloc: async (props) => {
for (const item of props.payload.items) {
switch (item.type) {
case 'text':
await sendMessage({ ...props, text: item.payload.text })
break
case 'markdown':
await sendMessage({ ...props, text: item.payload.markdown })
break
case 'image':
await sendMessage({ ...props, mediaUrl: item.payload.imageUrl })
break
case 'video':
await sendMessage({ ...props, mediaUrl: item.payload.videoUrl })
break
case 'audio':
await sendMessage({ ...props, mediaUrl: item.payload.audioUrl })
break
case 'file':
await sendMessage({ ...props, text: item.payload.fileUrl })
break
case 'location':
await sendMessage({ ...props, text: renderLocation(item.payload) })
break
default:
break
}
}
},
},
},
} satisfies bp.IntegrationProps['channels']
+104
View File
@@ -0,0 +1,104 @@
import queryString from 'query-string'
import { downloadTwilioMedia, getMessageTypeAndPayload, getTwilioMediaMetadata } from './utils'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async ({ req, client, ctx, logger }) => {
if (!req.body) {
console.warn('Handler received an empty body')
return
}
const data = queryString.parse(req.body)
// Twilio webhook data structure for media messages:
// - NumMedia: Number of media files (e.g., "1", "2")
// - MediaUrl0, MediaUrl1, etc.: URLs to the media files
// - MediaContentType0, MediaContentType1, etc.: MIME types of the media files
// - Body: Text message (may be empty if only media is sent)
// - From: Sender's phone number
// - To: Recipient's phone number (your Twilio number)
// - MessageSid: Unique identifier for the message
const userPhone = data.From
if (typeof userPhone !== 'string') {
throw new Error('Handler received an invalid user phone number')
}
const activePhone = data.To
if (typeof activePhone !== 'string') {
throw new Error('Handler received an invalid active phone number')
}
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: {
userPhone,
activePhone,
},
})
const { user } = await client.getOrCreateUser({
tags: {
userPhone,
},
})
const messageSid = data.MessageSid
if (typeof messageSid !== 'string') {
throw new Error('Handler received an invalid message sid')
}
const text = data.Body
const numMedia = parseInt((data.NumMedia as string) || '0', 10)
// If there's media, create appropriate message types for each media
if (numMedia > 0) {
for (let i = 0; i < numMedia; i++) {
const mediaUrl = data[`MediaUrl${i}` as keyof typeof data]
// Guard condition: skip invalid media URLs
if (!mediaUrl || typeof mediaUrl !== 'string') {
logger.forBot().error(`Missing or invalid media URL for media ${i + 1}`)
continue
}
try {
// Get media metadata first
const metadata = await getTwilioMediaMetadata(mediaUrl, ctx)
// Download media if configuration is enabled, otherwise use original URL
let finalMediaUrl = mediaUrl
if (ctx.configuration.downloadMedia) {
finalMediaUrl = await downloadTwilioMedia(mediaUrl, client, ctx)
}
// Determine message type based on MIME type and create payload
const { messageType, payload } = getMessageTypeAndPayload(finalMediaUrl, metadata.mimeType, metadata.fileName)
await client.createMessage({
tags: { id: `${messageSid}_media_${i}` },
type: messageType,
userId: user.id,
conversationId: conversation.id,
payload,
})
} catch (error) {
logger.forBot().error(`Failed to create message for media ${i + 1}:`, error)
}
}
}
// Create text message if text is present (regardless of whether media was also sent)
if (typeof text === 'string' && text.trim()) {
await client.createMessage({
tags: { id: messageSid },
type: 'text',
userId: user.id,
conversationId: conversation.id,
payload: { text },
})
}
}
+15
View File
@@ -0,0 +1,15 @@
import { reporting } from '@botpress/sdk-addons'
import { actions } from './actions'
import { channels } from './channels'
import { handler } from './handler'
import * as bp from '.botpress'
const integration = new bp.Integration({
register: async () => {},
unregister: async () => {},
actions,
channels,
handler,
})
export default reporting.wrapIntegration(integration)
@@ -0,0 +1,171 @@
import { test, expect } from 'vitest'
import { transformMarkdownForTwilio } from './markdown-to-twilio'
const FIXED_SIZE_SPACE_CHAR = '\u2002' // 'En space' yields better results for identation in WhatsApp messages
type Test = Record<string, { input: string; expected: string }>
const messengerTests: Test = {
Code: { input: '`code`', expected: '`code`\n' },
Code_snippet: { input: '```\n{\n\ta: null\n}\n```', expected: '```\n{\n\ta: null\n}\n```\n' },
Strong_with_asterisk: { input: '**bold-asterisk**', expected: '*bold-asterisk*\n' },
Strong_with_underscore: { input: '__bold-underscore__', expected: '*bold-underscore*\n' },
Emphasis_with_asterisk: { input: '*italic-asterisk*', expected: '_italic-asterisk_\n' },
Emphasis_with_underscore: { input: '_italic-underscore_', expected: '_italic-underscore_\n' },
Strong_emphasis: { input: '**_strong-emphasis_**', expected: '*_strong-emphasis_*\n' },
Strong_delete: { input: '**~strong-delete~**', expected: '*~strong-delete~*\n' },
Emphasis_delete: { input: '_~emphasis-delete~_', expected: '_~emphasis-delete~_\n' },
Strong_emphasis_delete: { input: '**_~strong-emphasis-delete~_**', expected: '*_~strong-emphasis-delete~_*\n' },
Delete: { input: '~~strikethrough~~', expected: '~strikethrough~\n' },
Strong_in_list: {
input: '- first\n- **strong_second**',
expected: '- first\n- *strong_second*\n',
},
Emphasize_in_table: {
input: '| 1 | 2 |\n| - | - |\n| a | _b_ |',
expected: '| 1 | 2 |\n| a | _b_ |\n',
},
}
const whatsappTests: Test = {
...messengerTests,
Code_snippet: { input: '```\n{\n\ta: null\n}\n```', expected: '```{\n\ta: null\n}```\n' },
}
test.each(Object.entries(messengerTests))(
'[Messenger] Test %s',
(_testName: string, testValues: { input: string; expected: string }): void => {
const actual = transformMarkdownForTwilio(testValues.input, 'messenger')
expect(actual).toBe(testValues.expected)
}
)
test.each(Object.entries(whatsappTests))(
'[WhatsApp] Test %s',
(_testName: string, testValues: { input: string; expected: string }): void => {
const actual = transformMarkdownForTwilio(testValues.input, 'whatsapp')
expect(actual).toBe(testValues.expected)
}
)
const bigInput = `# H1
## H2
### H3
**bold-asterisk**
*italic-asterisk*
<!-- This comment will not appear in rendered output -->
__bold-underscore__
_italic-underscore_
> blockquote
1. orderedListItem1
2. item2
- unorderedListItem1
- item2
\`code\`
horizontal
---
rule
[title](https://www.example.com)
![image](https://tinyurl.com/mrv4bmyk)
![test](https://tinyurl.com/mrv4bmyk)
| 1 | 2 |
| - | - |
| a | b |
\`\`\`
{
a: null
}
\`\`\`
footnote[^1]
[^1]: the footnote
term
: definition
~~strikethrough~~
- [x] taskListItem1
- [ ] item2
emoji direct 😂
`
const expectedForBigInputMessenger = `H1
H2
H3
*bold-asterisk*
_italic-asterisk_
*bold-underscore*
_italic-underscore_
Quote: “blockquote”
1. orderedListItem1
2. item2
- unorderedListItem1
- item2
\`code\`
horizontal
---
rule
https://www.example.com
https://tinyurl.com/mrv4bmyk
https://tinyurl.com/mrv4bmyk
| 1 | 2 |
| a | b |
\`\`\`
{
a: null
}
\`\`\`
footnote[1]
term
: definition
~strikethrough~
☑︎ taskListItem1
☐ item2
emoji direct 😂
[1] the footnote
`
const expectedForBigInputWhatsApp = `H1
H2
H3
*bold-asterisk*
_italic-asterisk_
*bold-underscore*
_italic-underscore_
Quote: “blockquote”
1. orderedListItem1
2. item2
- unorderedListItem1
- item2
\`code\`
horizontal
---
rule
https://www.example.com
https://tinyurl.com/mrv4bmyk
https://tinyurl.com/mrv4bmyk
| 1 | 2 |
| a | b |
\`\`\`{
a: null
}\`\`\`
footnote[1]
term\n: definition
~strikethrough~
☑︎ taskListItem1
☐ item2
emoji direct 😂
[1] the footnote
`
test('[Messenger] Multi-line multi markup test', () => {
const actual = transformMarkdownForTwilio(bigInput, 'messenger')
expect(actual).toBe(expectedForBigInputMessenger)
})
test('[WhatsApp] Multi-line multi markup test', () => {
const actual = transformMarkdownForTwilio(bigInput, 'whatsapp')
expect(actual).toBe(expectedForBigInputWhatsApp)
})
@@ -0,0 +1,31 @@
import { transformMarkdown, MarkdownHandlers, stripAllHandlers } from '@botpress/common'
import { TwilioChannel } from './twilio'
const messengerHandlers: MarkdownHandlers = {
...stripAllHandlers,
code: (node, _visit) => `\`\`\`\n${node.value}\n\`\`\`\n`,
delete: (node, visit) => `~${visit(node)}~`,
emphasis: (node, visit) => `_${visit(node)}_`,
inlineCode: (node, _visit) => `\`${node.value}\``,
strong: (node, visit) => `*${visit(node)}*`,
}
const whatsappHandlers: MarkdownHandlers = {
...stripAllHandlers,
code: (node, _visit) => `\`\`\`${node.value}\`\`\`\n`,
delete: (node, visit) => `~${visit(node)}~`,
emphasis: (node, visit) => `_${visit(node)}_`,
inlineCode: (node, _visit) => `\`${node.value}\``,
strong: (node, visit) => `*${visit(node)}*`,
}
const markdownHandlersByChannelType: Map<TwilioChannel, MarkdownHandlers> = new Map([
['rcs', stripAllHandlers],
['sms/mms', stripAllHandlers],
['messenger', messengerHandlers],
['whatsapp', whatsappHandlers],
])
export function transformMarkdownForTwilio(text: string, channel: TwilioChannel) {
return transformMarkdown(text, markdownHandlersByChannelType.get(channel))
}
+8
View File
@@ -0,0 +1,8 @@
import { Twilio } from 'twilio'
import * as bp from '.botpress'
export type TwilioChannel = 'sms/mms' | 'rcs' | 'whatsapp' | 'messenger'
export function getTwilioClient(ctx: bp.Context): Twilio {
return new Twilio(ctx.configuration.accountSID, ctx.configuration.authToken)
}
+29
View File
@@ -0,0 +1,29 @@
import * as sdk from '@botpress/sdk'
import * as bp from '.botpress'
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
export type CreateMessageInput = Parameters<Client['createMessage']>[0]
export type CreateMessageInputType = CreateMessageInput['type']
export type CreateMessageInputPayload = CreateMessageInput['payload']
// Channel message payload types
export type Choice = bp.channels.channel.choice.Choice
export type Card = bp.channels.channel.card.Card
+210
View File
@@ -0,0 +1,210 @@
import { RuntimeError } from '@botpress/client'
import axios from 'axios'
import * as crypto from 'crypto'
import { TwilioChannel } from './twilio'
import { Card, Choice, Conversation, CreateMessageInputPayload, CreateMessageInputType } from './types'
import * as bp from '.botpress'
/**
* Gets phone numbers from conversation tags
*/
export function getPhoneNumbers(conversation: Conversation) {
const to = conversation.tags?.userPhone
const from = conversation.tags?.activePhone
if (!to) {
throw new Error('Invalid to phone number')
}
if (!from) {
throw new Error('Invalid from phone number')
}
return { to, from }
}
/**
* Determines the Twilio channel type based on user phone format
*/
export function getTwilioChannelType(user: string): TwilioChannel {
if (user.startsWith('whatsapp')) {
return 'whatsapp'
}
if (user.startsWith('messenger')) {
return 'messenger'
}
if (user.startsWith('rcs')) {
return 'rcs'
}
return 'sms/mms'
}
/**
* Gets Twilio media metadata (MIME type, file size, filename)
*/
export async function getTwilioMediaMetadata(
mediaUrl: string,
ctx: bp.Context
): Promise<{ mimeType: string; fileSize: number; fileName?: string }> {
try {
const headResponse = await axios.head(mediaUrl, {
auth: {
username: ctx.configuration.accountSID,
password: ctx.configuration.authToken,
},
})
const mimeType = headResponse.headers['content-type'] || 'application/octet-stream'
const fileSize = parseInt(headResponse.headers['content-length'] || '0', 10)
// Try to extract filename from content-disposition header
let fileName: string | undefined
const contentDisposition = headResponse.headers['content-disposition']
if (contentDisposition) {
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^"]+)"?/i)
const rawFileName = match?.[1]
if (rawFileName) {
fileName = decodeURIComponent(rawFileName)
}
}
return {
mimeType,
fileSize,
fileName,
}
} catch (error) {
throw new RuntimeError(
`Failed to get Twilio media metadata: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
/**
* Downloads Twilio media and stores it in the Botpress file API
*/
export async function downloadTwilioMedia(mediaUrl: string, client: bp.Client, ctx: bp.Context): Promise<string> {
try {
// Get file metadata
const { mimeType, fileSize } = await getTwilioMediaMetadata(mediaUrl, ctx)
// Generate a unique ID from the URL
const buffer = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(mediaUrl))
const uniqueId = Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 24)
// Create file in Botpress file API
const { file } = await client.upsertFile({
key: `twilio-media_${uniqueId}`,
expiresAt: getMediaExpiry(ctx),
contentType: mimeType,
accessPolicies: ['public_content'],
publicContentImmediatelyAccessible: true,
size: fileSize,
tags: {
source: 'integration',
integration: 'twilio',
channel: 'channel',
originUrl: mediaUrl,
},
})
// Download the media from Twilio
const downloadResponse = await axios.get(mediaUrl, {
responseType: 'stream',
auth: {
username: ctx.configuration.accountSID,
password: ctx.configuration.authToken,
},
})
// Upload to Botpress file API
await axios.put(file.uploadUrl, downloadResponse.data, {
headers: {
'Content-Type': mimeType,
'Content-Length': fileSize,
'x-amz-tagging': 'public=true',
},
maxBodyLength: fileSize,
})
return file.url
} catch (error) {
throw new RuntimeError(
`Failed to download Twilio media: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
/**
* Gets media expiry time based on configuration
*/
export function getMediaExpiry(ctx: bp.Context): string | undefined {
const expiryDelayHours = ctx.configuration?.downloadedMediaExpiry || 0
if (expiryDelayHours === 0) {
return undefined
}
const expiresAt = new Date(Date.now() + expiryDelayHours * 60 * 60 * 1000)
return expiresAt.toISOString()
}
/**
* Determines the appropriate message type and payload based on MIME type
*/
export function getMessageTypeAndPayload(
mediaUrl: string,
contentType: string | null | undefined,
fileName?: string
): { messageType: CreateMessageInputType; payload: CreateMessageInputPayload } {
const mimeType = contentType?.toLowerCase() || ''
if (mimeType.startsWith('image/')) {
return {
messageType: 'image',
payload: { imageUrl: mediaUrl },
}
}
if (mimeType.startsWith('audio/')) {
return {
messageType: 'audio',
payload: { audioUrl: mediaUrl },
}
}
if (mimeType.startsWith('video/')) {
return {
messageType: 'video',
payload: { videoUrl: mediaUrl },
}
}
// Default to file for other types (documents, etc.)
return {
messageType: 'file',
payload: {
fileUrl: mediaUrl,
title: fileName || contentType || 'file',
},
}
}
/**
* Renders choice/dropdown message as text for SMS
*/
export function renderChoiceMessage(payload: Choice): string {
return `${payload.text || ''}\n\n${payload.options
.map(({ label }: { label: string }, idx: number) => `${idx + 1}. ${label}`)
.join('\n')}`
}
/**
* Renders card as text for SMS
*/
export function renderCard(card: Card, total?: string): string {
return `${total ? `${total}: ` : ''}${card.title}\n\n${card.subtitle || ''}\n\n${card.actions
.map(({ label }: { label: string }, idx: number) => `${idx + 1}. ${label}`)
.join('\n')}`
}