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
+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,
},
},
},
]
+44
View File
@@ -0,0 +1,44 @@
The Line integration enables your chatbot to seamlessly interact with Line, one of the leading messaging platforms. Connect your AI-powered chatbot to Line and unlock powerful communication capabilities to engage with your audience. With this integration, you can automate customer interactions, provide instant support, deliver personalized messages, and handle inquiries seamlessly within the Line messaging environment. Leverage Line's rich features such as text, images, stickers, and location sharing to create dynamic and engaging conversations. Elevate your chatbot's reach and impact by integrating it with Line using the Line Integration for Botpress.
## Migrating from version `1.x.x` to `2.x.x`
### Changes in proactive conversations (and proactive users)
- The process of proactively creating conversations and users has changed. You must now use the actions `getOrCreateConversation` and `getOrCreateUser` to create conversations and users.
### Removal of `markdown` message type
- The `markdown` message type has been removed. Markdown messages must now be sent as regular `text` messages.
## Configuration
1. In your [Line Developers console](https://developers.line.biz/), click on "Providers", select the provider you would like to use or create a new one, and then select or create the Channel you would like to use. If you are creating a new one, select "Messaging API" from the creation menu.
2. For the selected channel, select the Messaging API tab in your channel settings. Scroll to the bottom to "Channel Access Token", and generate a new one or copy an existing one.
3. Paste this into the "Channel Access Token" field in the Botpress Line configuration
4. Go to the channel "Basic settings", scroll to the bottom and copy the "Channel secret".
5. Paste this into the "Channel Secret" in the Botpress Line configuration.
6. Copy the Webhook endpoint url from the Botpress Line configuration.
7. In Line, go to the "Messaging API" tab of the Channel settings, and paste the Botpress Integration Webhook endpoint url into the "Webhook URL", under Webhook settings.
8. Enable the "Use webhook" toggle in Line.
## Content Types Mapping
### From Botpress to LINE
`Text` is mapped to `Text Object`
`Text with Markdown` is mapped to `Text Object`
`Image` is mapped to `Image Object`
`Audio` is mapped to `Audio Object`
`Video` is mapped to `Video Object`
`File` isnt supported in LINE Message Types
`Location` is mapped to `Location Object`
`Carousel` is mapped to `Flex Object`
`Card` is mapped to `Flex Object`
`Dropdown` is mapped to `Flex Object`
`Choice` is mapped to `Flex Object`
### From LINE to Botpress
`Text` is mapped to `Text Object`
The rest of the types arent currently processed by Botpress
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 377.764 377.764"><path fill-rule="evenodd" clip-rule="evenodd" fill="#3ACE01" d="M77.315 0h223.133c42.523 0 77.315 34.792 77.315 77.315v223.133c0 42.523-34.792 77.315-77.315 77.315H77.315C34.792 377.764 0 342.972 0 300.448V77.315C0 34.792 34.792 0 77.315 0z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#FFF" d="M188.515 62.576c76.543 0 138.593 49.687 138.593 110.979 0 21.409-7.576 41.398-20.691 58.351-.649.965-1.497 2.031-2.566 3.209l-.081.088c-4.48 5.36-9.525 10.392-15.072 15.037-38.326 35.425-101.41 77.601-109.736 71.094-7.238-5.656 11.921-33.321-10.183-37.925-1.542-.177-3.08-.367-4.605-.583l-.029-.002v-.002c-64.921-9.223-114.222-54.634-114.222-109.267-.002-61.292 62.049-110.979 138.592-110.979z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#3ACE01" d="M108.103 208.954h27.952c3.976 0 7.228-3.253 7.228-7.229v-.603c0-3.976-3.252-7.228-7.228-7.228h-20.121v-45.779c0-3.976-3.252-7.228-7.228-7.228h-.603c-3.976 0-7.228 3.252-7.228 7.228v53.609c0 3.977 3.252 7.23 7.228 7.23zm173.205-33.603v-.603c0-3.976-3.253-7.228-7.229-7.228h-20.12v-11.445h20.12c3.976 0 7.229-3.252 7.229-7.228v-.603c0-3.976-3.253-7.228-7.229-7.228h-27.952c-3.976 0-7.228 3.252-7.228 7.228v53.609c0 3.976 3.252 7.229 7.228 7.229h27.952c3.976 0 7.229-3.253 7.229-7.229v-.603c0-3.976-3.253-7.228-7.229-7.228h-20.12v-11.445h20.12c3.976.002 7.229-3.251 7.229-7.226zm-53.755 31.448l.002-.003a7.207 7.207 0 0 0 2.09-5.07v-53.609c0-3.976-3.252-7.228-7.229-7.228h-.603c-3.976 0-7.228 3.252-7.228 7.228v31.469l-26.126-35.042c-1.248-2.179-3.598-3.655-6.276-3.655h-.603c-3.976 0-7.229 3.252-7.229 7.228v53.609c0 3.976 3.252 7.229 7.229 7.229h.603c3.976 0 7.228-3.253 7.228-7.229v-32.058l26.314 35.941c.162.252.339.494.53.724l.001.002c.723.986 1.712 1.662 2.814 2.075.847.35 1.773.544 2.742.544h.603a7.162 7.162 0 0 0 3.377-.844c.723-.344 1.332-.788 1.761-1.311zm-71.208 2.155h.603c3.976 0 7.228-3.253 7.228-7.229v-53.609c0-3.976-3.252-7.228-7.228-7.228h-.603c-3.976 0-7.229 3.252-7.229 7.228v53.609c0 3.976 3.253 7.229 7.229 7.229z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+125
View File
@@ -0,0 +1,125 @@
import { z, IntegrationDefinition, messages } from '@botpress/sdk'
import proactiveConversation from 'bp_modules/proactive-conversation'
import proactiveUser from 'bp_modules/proactive-user'
import typingIndicator from 'bp_modules/typing-indicator'
export default new IntegrationDefinition({
name: 'line',
version: '2.0.7',
title: 'Line',
description: 'Interact with customers using a rich set of features.',
icon: 'icon.svg',
readme: 'hub.md',
configuration: {
schema: z.object({
channelAccessToken: z
.string()
.min(1)
.title('Channel Access Token')
.describe('Token used to authenticate the bot with the Line API'),
channelSecret: z
.string()
.min(1)
.title('Channel Secret')
.describe('Secret used to verify the signature of webhook events'),
}),
},
channels: {
channel: {
title: 'Line conversation',
description: 'Channel for a Line conversation',
messages: { ...messages.defaults, bloc: messages.markdownBloc },
message: {
tags: {
msgId: {
title: 'Message ID',
description: 'Line message ID',
},
},
},
conversation: {
tags: {
usrId: {
title: 'User ID',
description: 'Line user ID taking part in the conversation',
},
destId: {
title: 'Destination ID',
description: 'Line user ID of the bot',
},
},
},
},
},
actions: {},
events: {
followed: {
title: 'Followed',
description: 'The bot was followed by someone.',
schema: z.object({
destinationId: z.string().title('Destination ID').describe('The Line user ID of the bot'),
userId: z.string().title('User ID').describe('The Line ID of the user'),
}),
},
},
states: {
conversation: {
type: 'conversation',
schema: z.object({
replyToken: z
.string()
.optional()
.title('Reply Token')
.describe('Token used to reply to a message received as a request on the webhook'),
}),
},
},
user: {
tags: {
usrId: {
title: 'User ID',
description: 'Line user ID',
},
},
},
entities: {
user: {
schema: z
.object({
id: z.string().title('User ID').describe('The Line ID of the user'),
})
.title('User')
.describe('The user object fields'),
title: 'User',
description: 'A Line user',
},
conversation: {
schema: z
.object({
id: z.string().title('User ID').describe('The Line ID of the user in the conversation'),
destinationId: z.string().title('Destination ID').describe('Line user ID of the bot'),
})
.title('Conversation')
.describe('The conversation object fields'),
title: 'Conversation',
description: 'A conversation with a Line user',
},
},
attributes: {
category: 'Communication & Channels',
guideSlug: 'line',
repo: 'botpress',
},
})
.extend(typingIndicator, ({}) => ({ entities: {} }))
.extend(proactiveUser, ({ entities }) => ({
entities: {
user: entities.user,
},
}))
.extend(proactiveConversation, ({ entities }) => ({
entities: {
conversation: entities.conversation,
},
}))
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@botpresshub/line",
"description": "Line integration for Botpress",
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"test": "vitest --run"
},
"dependencies": {
"@botpress/cli": "workspace:*",
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"@line/bot-sdk": "^9.5.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@botpress/cli": "workspace:*",
"@botpress/common": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpresshub/proactive-conversation": "workspace:*",
"@botpresshub/proactive-user": "workspace:*",
"@botpresshub/typing-indicator": "workspace:*",
"@types/mdast": "^4.0.4"
},
"bpDependencies": {
"typing-indicator": "../../interfaces/typing-indicator",
"proactive-user": "../../interfaces/proactive-user",
"proactive-conversation": "../../interfaces/proactive-conversation"
}
}
+527
View File
@@ -0,0 +1,527 @@
import { RuntimeError } from '@botpress/client'
import { transformMarkdown } from '@botpress/common'
import { reporting } from '@botpress/sdk-addons'
import { messagingApi as lineMessagingApi } from '@line/bot-sdk'
import crypto from 'crypto'
import getOrCreateConversation from './proactive-conversation'
import getOrCreateUser from './proactive-user'
import * as bp from '.botpress'
type MessageHandlerProps = bp.AnyMessageProps
type SendOrReplyLineProps = Pick<MessageHandlerProps, 'ctx' | 'conversation' | 'client' | 'ack'>
const DEFAULT_TIMEOUT_MS = 5000
const LOADING_TIMEOUT_MULTIPLE = 5
const MAX_TIMEOUT_SECONDS = 60
const replyOrSendLineMessage = async (props: SendOrReplyLineProps, message: lineMessagingApi.Message) => {
const { ctx, conversation, client, ack } = props
const config = {
channelAccessToken: ctx.configuration.channelAccessToken,
}
const lineClient = new lineMessagingApi.MessagingApiClient(config)
const { state } = await client.getState({
id: conversation.id,
name: 'conversation',
type: 'conversation',
})
const replyToken = state.payload.replyToken
try {
let lineResponse: lineMessagingApi.ReplyMessageResponse | lineMessagingApi.PushMessageResponse
if (replyToken) {
await client.setState({
id: conversation.id,
name: 'conversation',
type: 'conversation',
payload: { replyToken: undefined },
})
lineResponse = await lineClient.replyMessage({
replyToken,
messages: [message],
})
} else {
const usrId = conversation.tags.usrId
if (!usrId) {
throw new RuntimeError('No user id found in conversation tags')
}
lineResponse = await lineClient.pushMessage({
to: usrId,
messages: [message],
})
}
const sentMessage = lineResponse.sentMessages[0]
if (sentMessage) {
await ack({ tags: { msgId: sentMessage.id } })
}
} catch (e: any) {
console.error(`Error: ${e}`)
}
}
const tryTransformMarkdown = (text: string) => {
try {
return transformMarkdown(text)
} catch {
console.error('Failed to transform the markdown. The message will be sent as text without transfoming markdown.')
return text
}
}
const integration = new bp.Integration({
register: async () => {},
unregister: async () => {},
actions: {
startTypingIndicator: async ({ client, ctx, input }) => {
const config = {
channelAccessToken: ctx.configuration.channelAccessToken,
}
const lineClient = new lineMessagingApi.MessagingApiClient(config)
const { conversationId, timeout } = input
const { conversation } = await client.getConversation({ id: conversationId })
const lineUserId = conversation.tags.usrId
if (!lineUserId) {
throw new RuntimeError('No user id found in conversation tags')
}
const timeoutSeconds = Math.ceil((timeout ?? DEFAULT_TIMEOUT_MS) / 1000)
const loadingSeconds = Math.min(
Math.ceil(timeoutSeconds / LOADING_TIMEOUT_MULTIPLE) * LOADING_TIMEOUT_MULTIPLE,
MAX_TIMEOUT_SECONDS
)
await lineClient.showLoadingAnimation({
chatId: lineUserId,
loadingSeconds,
})
return {}
},
stopTypingIndicator: async () => ({}),
getOrCreateConversation,
getOrCreateUser,
},
channels: {
channel: {
messages: {
text: async ({ payload, ctx, conversation, ack, client }) => {
await replyOrSendLineMessage(
{ ctx, conversation, client, ack },
{
type: 'text',
text: tryTransformMarkdown(payload.text),
}
)
},
image: async ({ payload, ctx, conversation, ack, client }) => {
await replyOrSendLineMessage(
{ ctx, conversation, client, ack },
{
type: 'image',
originalContentUrl: payload.imageUrl,
previewImageUrl: payload.imageUrl, // Can use this - later when the upload is ready: https://www.npmjs.com/package/image-thumbnail
}
)
},
// TODO: fix audio, its not working
audio: async ({ payload, ctx, conversation, ack, client }) => {
await replyOrSendLineMessage(
{ ctx, conversation, client, ack },
{
type: 'audio',
originalContentUrl: payload.audioUrl,
duration: -1,
}
)
},
video: async ({ payload, ctx, conversation, ack, client }) => {
//TODO: Upload the thumbnail so it is ready to be passed as URL to Line
await replyOrSendLineMessage(
{ ctx, conversation, client, ack },
{
type: 'video',
originalContentUrl: payload.videoUrl,
previewImageUrl: 'https://example.com/preview.jpg',
}
)
},
file: async () => {
console.error(
'Documents & files are not supported by Line - https://developers.line.biz/en/reference/messaging-api'
)
},
location: async ({ payload, ctx, conversation, ack, client }) => {
await replyOrSendLineMessage(
{ ctx, conversation, client, ack },
{
type: 'location',
title: payload.title ?? '',
latitude: payload.latitude,
longitude: payload.longitude,
address: payload.address ?? '',
}
)
},
carousel: async ({ payload, ctx, conversation, ack, client }) => {
const sections: lineMessagingApi.FlexBubble[] = []
for (let indexS = 0; indexS < payload.items.length; indexS++) {
const item = payload.items[indexS]
if (!item) {
continue //just to pass the build validation
}
const buttons: lineMessagingApi.FlexButton[] = []
for (let indexR = 0; indexR < item.actions.length; indexR++) {
const action = item.actions[indexR]
if (!action) {
continue //just to pass the build validation
}
if (action.action === 'postback') {
buttons.push({
type: 'button',
action: {
type: 'message',
label: action.label.substring(0, 40),
text: action.value.substring(0, 300),
},
})
} else {
buttons.push({
type: 'button',
action: {
type: 'uri',
label: action.label.substring(0, 40),
uri: action.value.substring(0, 1000),
},
})
}
}
sections.push({
type: 'bubble',
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'image',
url: item.imageUrl ?? '',
},
{
type: 'separator',
},
{
type: 'text',
text: item.title,
},
{
type: 'text',
text: item.subtitle ?? '',
},
{
type: 'separator',
},
...buttons,
],
},
})
if (sections.length === 12) {
console.warn(
'Only 12 items are allowed - https://developers.line.biz/en/reference/messaging-api/#f-carousel'
)
break
}
}
await replyOrSendLineMessage(
{ ctx, conversation, client, ack },
{
type: 'flex',
altText: 'this is a flex message',
contents: {
type: 'carousel',
contents: sections,
},
}
)
},
card: async ({ payload, ctx, conversation, ack, client }) => {
const buttons: lineMessagingApi.FlexButton[] = []
for (let index = 0; index < payload.actions.length; index++) {
const action = payload.actions[index]
if (!action) {
continue
}
if (action.action === 'postback') {
buttons.push({
type: 'button', // Add generic styles to the buttons: https://developers.line.biz/en/reference/messaging-api/#button
action: {
type: 'message',
label: action.label.substring(0, 40),
text: action.value.substring(0, 300),
},
})
} else {
buttons.push({
type: 'button',
action: {
type: 'uri',
label: action.label.substring(0, 40),
uri: action.value.substring(0, 1000),
},
})
}
}
await replyOrSendLineMessage(
{ ctx, conversation, client, ack },
{
type: 'flex',
altText: payload.title,
contents: {
type: 'bubble',
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'image',
url: payload.imageUrl ?? '',
},
{
type: 'separator',
},
{
type: 'text',
text: payload.title,
},
{
type: 'text',
text: payload.subtitle ?? '',
},
{
type: 'separator',
},
...buttons,
],
},
},
}
)
},
dropdown: async ({ payload, ctx, conversation, ack, client }) => {
const buttons: lineMessagingApi.FlexButton[] = []
for (let index = 0; index < payload.options.length; index++) {
const choice = payload.options[index]
if (!choice) {
continue
}
buttons.push({
type: 'button', // Add generic styles to the buttons: https://developers.line.biz/en/reference/messaging-api/#button
action: {
type: 'message',
label: choice.label.substring(0, 40),
text: choice.value.substring(0, 300),
},
})
}
await replyOrSendLineMessage(
{ ctx, conversation, client, ack },
{
type: 'flex',
altText: payload.text,
contents: {
type: 'bubble',
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: payload.text,
},
{
type: 'text',
text: payload.text,
},
{
type: 'separator',
},
...buttons,
],
},
},
}
)
},
choice: async ({ payload, ctx, conversation, ack, client }) => {
const buttons: lineMessagingApi.FlexButton[] = []
for (let index = 0; index < payload.options.length; index++) {
const choice = payload.options[index]
if (!choice) {
continue
}
buttons.push({
type: 'button', // Add generic styles to the buttons: https://developers.line.biz/en/reference/messaging-api/#button
action: {
type: 'message',
label: choice.label.substring(0, 40),
text: choice.value.substring(0, 300),
},
})
}
const contents: lineMessagingApi.FlexBubble = {
type: 'bubble',
body: {
type: 'box',
layout: 'vertical',
contents: [
{
type: 'text',
text: payload.text,
},
{
type: 'separator',
},
...buttons,
],
},
}
await replyOrSendLineMessage({ ctx, conversation, client, ack }, {
type: 'flex',
altText: payload.text,
contents,
} satisfies lineMessagingApi.FlexMessage)
},
bloc: () => {
throw new RuntimeError('Not implemented')
},
},
},
},
handler: async ({ req, client, ctx }) => {
console.info('Handler received request')
if (!req.body) {
throw new Error('Handler received an empty body')
}
if (req.body && JSON.parse(req.body).events.length === 0) {
return {
status: 200,
}
}
if (!req.body) {
console.warn('Handler received an empty body')
return
}
const channelSecret = ctx.configuration.channelSecret // Channel secret string
const body = req.body // Request body string
const signature = crypto.createHmac('SHA256', channelSecret).update(body).digest('base64')
// Compare x-line-signature request header and the signature
if (req.headers['x-line-signature'] !== signature) {
console.warn('Wrong Signature')
return {
status: 401,
}
}
const data = JSON.parse(req.body) as LinePayload
for (const event of data.events) {
if (event.type === 'message') {
await handleMessage(event, data.destination, client)
} else if (event.type === 'follow') {
await client.createEvent({
type: 'followed',
payload: {
destinationId: data.destination,
userId: event.source.userId,
},
})
}
}
return
},
})
export default reporting.wrapIntegration(integration)
async function handleMessage(events: LineEvents, destination: string, client: bp.Client) {
const message = events.message
if (message.type) {
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: {
usrId: events.source.userId,
destId: destination,
},
})
await client.setState({
id: conversation.id,
name: 'conversation',
type: 'conversation',
payload: { replyToken: events.replyToken },
})
const { user } = await client.getOrCreateUser({
tags: {
usrId: events.source.userId,
},
})
if (message.type === 'text') {
await client.createMessage({
tags: { msgId: message.id },
type: 'text',
userId: user.id,
conversationId: conversation.id,
payload: { text: message.text },
})
}
}
}
type LinePayload = {
destination: string
events: LineEvents[]
}
type LineEvents = {
type: string
webhookEventId: string
timestamp: string
replyToken: string
mode: string
deliveryContext: {
isRedelivery: boolean
}
source: LineSource
message: LineMessage
}
type LineSource = {
type: string
userId: string
}
type LineMessage = {
type: string
id: string
text: string
}
@@ -0,0 +1,34 @@
import { RuntimeError } from '@botpress/sdk'
import { messagingApi as lineMessagingApi } from '@line/bot-sdk'
import * as bp from '.botpress'
const getOrCreateConversation: bp.IntegrationProps['actions']['getOrCreateConversation'] = async ({
client,
ctx,
input,
}) => {
const usrId = input.conversation.id
const destId = input.conversation.destinationId
if (!usrId) {
throw new RuntimeError('User ID is required')
}
if (!destId) {
throw new RuntimeError('Destination ID is required')
}
const lineClient = new lineMessagingApi.MessagingApiClient({
channelAccessToken: ctx.configuration.channelAccessToken,
})
const profile = await lineClient.getProfile(usrId)
const { conversation } = await client.getOrCreateConversation({
channel: 'channel',
tags: { usrId: `${profile.userId}`, destId },
})
return {
conversationId: conversation.id,
}
}
export default getOrCreateConversation
+24
View File
@@ -0,0 +1,24 @@
import { RuntimeError } from '@botpress/sdk'
import { messagingApi as lineMessagingApi } from '@line/bot-sdk'
import * as bp from '.botpress'
const getOrCreateUser: bp.IntegrationProps['actions']['getOrCreateUser'] = async ({ client, ctx, input }) => {
const userId = input.user.id
if (!userId) {
throw new RuntimeError('User ID is required')
}
const lineClient = new lineMessagingApi.MessagingApiClient({
channelAccessToken: ctx.configuration.channelAccessToken,
})
const profile = await lineClient.getProfile(userId)
const { user } = await client.getOrCreateUser({ tags: { usrId: `${profile.userId}` } })
return {
userId: user.id,
}
}
export default getOrCreateUser
+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