chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { IntegrationDefinitionProps, messages } from '@botpress/sdk'
|
||||
|
||||
const _wechatMessageChannels = {
|
||||
text: {
|
||||
...messages.defaults.text,
|
||||
schema: messages.defaults.text.schema.extend({
|
||||
text: messages.defaults.text.schema.shape.text
|
||||
.max(4096)
|
||||
.describe('The text content of the WeChat message (Limit 4096 characters)'),
|
||||
}),
|
||||
},
|
||||
image: messages.defaults.image,
|
||||
video: messages.defaults.video,
|
||||
}
|
||||
|
||||
export const channels = {
|
||||
channel: {
|
||||
title: 'Channel',
|
||||
description: 'WeChat Channel',
|
||||
messages: _wechatMessageChannels,
|
||||
message: {
|
||||
tags: {
|
||||
id: { title: 'ID', description: 'The message ID' },
|
||||
chatId: { title: 'Chat ID', description: 'The message chat ID' },
|
||||
},
|
||||
},
|
||||
conversation: {
|
||||
tags: {
|
||||
id: { title: 'ID', description: "The WeChat conversation ID (This is also the Recipient's UserId)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies IntegrationDefinitionProps['channels']
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './channels'
|
||||
export * from './user'
|
||||
export * from './states'
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type IntegrationDefinitionProps, z } from '@botpress/sdk'
|
||||
|
||||
export const states = {
|
||||
configuration: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
auth: z
|
||||
.object({
|
||||
accessToken: z.string().title('Access Token').describe('The access token for the integration'),
|
||||
expiresAt: z
|
||||
.number()
|
||||
.min(0)
|
||||
.title('Expires At')
|
||||
.describe('The expiry time of the access token represented as a Unix timestamp (seconds)'),
|
||||
})
|
||||
.nullable()
|
||||
.default(null)
|
||||
.title('Auth Parameters')
|
||||
.describe('The parameters used for accessing the WeChat API'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies IntegrationDefinitionProps['states']
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export const user = {
|
||||
tags: {
|
||||
id: { title: 'ID', description: 'The ID of the user' },
|
||||
},
|
||||
} as const satisfies IntegrationDefinitionProps['user']
|
||||
@@ -0,0 +1,93 @@
|
||||
# WeChat Official Account Integration
|
||||
|
||||
Connect your Botpress chatbot to WeChat Official Accounts and engage with your Chinese audience in real-time.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, you need:
|
||||
|
||||
1. **WeChat Official Account** (Service Account or Subscription Account)
|
||||
- Create one at: https://mp.weixin.qq.com/
|
||||
2. **App ID and App Secret** from your WeChat Official Account settings
|
||||
3. **Server Configuration** enabled in WeChat Admin Panel
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Get Your WeChat Credentials
|
||||
|
||||
In your WeChat Official Account admin panel:
|
||||
|
||||
1. Go to **Settings & Development** > **Basic Configuration**
|
||||
2. Copy your **AppID** and **AppSecret**
|
||||
3. Generate a **Token** (any random string, you'll use this in both WeChat and Botpress)
|
||||
|
||||
### 2. Install the Integration in Botpress
|
||||
|
||||
1. Install this integration in your Botpress workspace
|
||||
2. Configure with your credentials:
|
||||
- **WeChat Token**: The token you generated (used for signature verification)
|
||||
- **App ID**: Your WeChat Official Account AppID
|
||||
- **App Secret**: Your WeChat Official Account AppSecret
|
||||
|
||||
### 3. Configure WeChat Webhook
|
||||
|
||||
In your WeChat Official Account admin panel:
|
||||
|
||||
1. Go to **Settings & Development** > **Basic Configuration**
|
||||
2. Click **Enable** Server Configuration
|
||||
3. Set the **URL** to https://wechat.botpress.tech/{your Botpress webhook ID}, where the webhook ID is the string provided after installing the integration.
|
||||
4. Set the **Token** to the same token you used in Botpress configuration
|
||||
5. Click **Submit** - WeChat will verify your server
|
||||
|
||||
## Supported Features
|
||||
|
||||
### Receiving Messages
|
||||
|
||||
Your bot can receive the following message types from WeChat users:
|
||||
|
||||
- **Text messages** - Plain text messages
|
||||
- **Image messages** - Photos sent by users (PicUrl provided)
|
||||
- **Video messages** - Video content
|
||||
- **Link messages** - Shared links with title, description, and URL
|
||||
|
||||
### Sending Messages
|
||||
|
||||
Your bot can send the following message types to WeChat users:
|
||||
|
||||
- **Text messages** - Up to 4,096 characters
|
||||
- **Image messages** - Images (automatically uploaded to WeChat)
|
||||
|
||||
## Limitations
|
||||
|
||||
### WeChat Platform Limitations
|
||||
|
||||
- **Official Account Required**: Personal WeChat accounts cannot be used
|
||||
- **Customer Service API Window**: Can only send messages to users who have messaged you within the last 48 hours
|
||||
- **Message Length**: Text messages limited to 4,096 characters
|
||||
- **No Proactive Messaging**: Cannot initiate conversations; users must message first
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Webhook Verification Fails
|
||||
|
||||
- Ensure your **Token** matches exactly in both Botpress and WeChat
|
||||
- Check that your webhook URL is publicly accessible
|
||||
- Verify the URL ends with your integration webhook path
|
||||
|
||||
### Messages Not Appearing in Botpress
|
||||
|
||||
- Check your WeChat Admin Panel logs for delivery errors
|
||||
- Verify your **App ID** and **App Secret** are correct
|
||||
- Ensure Server Configuration is enabled in WeChat
|
||||
|
||||
### Bot Not Responding
|
||||
|
||||
- Check Botpress logs for errors
|
||||
- Verify the user messaged you within the last 48 hours
|
||||
- Ensure your bot flow is properly configured
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [WeChat Official Account Platform Docs](https://developers.weixin.qq.com/doc/offiaccount/en/Getting_Started/Overview.html)
|
||||
- [WeChat API Reference](https://developers.weixin.qq.com/doc/offiaccount/en/Message_Management/Receiving_standard_messages.html)
|
||||
- [Botpress Documentation](https://botpress.com/docs)
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<path fill="#2DC100" d="M300 255c0 24.854-20.147 45-45 45H45c-24.854 0-45-20.146-45-45V45C0 20.147 20.147 0 45 0h210c24.853 0 45 20.147 45 45v210z"/>
|
||||
|
||||
<g fill="#FFF">
|
||||
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,28 @@
|
||||
import { z, IntegrationDefinition } from '@botpress/sdk'
|
||||
import { channels, states, user } from 'definitions'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: 'wechat',
|
||||
title: 'WeChat',
|
||||
description: 'Engage with your WeChat audience in real-time.',
|
||||
version: '0.1.1',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
configuration: {
|
||||
schema: z.object({
|
||||
appId: z.string().title('App ID').describe('WeChat Official Account App ID'),
|
||||
appSecret: z.string().title('App Secret').describe('WeChat Official Account App Secret'),
|
||||
webhookSigningSecret: z
|
||||
.string()
|
||||
.title('WeChat Token')
|
||||
.describe('WeChat Token used for webhook signature verification'),
|
||||
}),
|
||||
},
|
||||
channels,
|
||||
states,
|
||||
user,
|
||||
attributes: {
|
||||
category: 'Communication & Channels',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@botpresshub/wechat",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"axios": "^1.13.6",
|
||||
"fast-xml-parser": "^5.4.2",
|
||||
"lodash": "^4.17.21",
|
||||
"nanoid": "^5.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@types/lodash": "^4.14.191"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import axios from 'axios'
|
||||
import { Result } from '../types'
|
||||
import { usePromiseToResult } from '../utils'
|
||||
import { WECHAT_API_BASE } from './constants'
|
||||
import { weChatAuthTokenRespSchema } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const MS_PER_SECOND = 1000
|
||||
const SECONDS_PER_MINUTE = 60 as const
|
||||
const TOKEN_EXPIRY_BUFFER = 5 * SECONDS_PER_MINUTE
|
||||
|
||||
type TokenResp = { accessToken: string; expiresAt: number }
|
||||
|
||||
async function _getFreshAccessToken(appId: string, appSecret: string): Promise<Result<TokenResp>> {
|
||||
const tokenIssuedAtMs = Date.now()
|
||||
const respResult = await axios
|
||||
.get(`${WECHAT_API_BASE}/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`)
|
||||
.then(...usePromiseToResult('Failed to acquire a WeChat access token'))
|
||||
if (!respResult.success) return respResult
|
||||
const resp = respResult.data
|
||||
|
||||
const result = weChatAuthTokenRespSchema.safeParse(resp.data)
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: new RuntimeError(`Unexpected access token response received -> ${result.error.message}`),
|
||||
}
|
||||
}
|
||||
|
||||
const { data } = result
|
||||
if ('errcode' in data) {
|
||||
return {
|
||||
success: false,
|
||||
error: new RuntimeError(
|
||||
`Failed to acquire a WeChat access token (Error Code: ${data.errcode}) -> ${data.errmsg}`
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: tokenIssuedAtMs / MS_PER_SECOND + data.expires_in,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function _getCachedAccessToken(client: bp.Client, ctx: bp.Context): Promise<Result<TokenResp>> {
|
||||
const state = await client.getState({
|
||||
type: 'integration',
|
||||
name: 'configuration',
|
||||
id: ctx.integrationId,
|
||||
})
|
||||
|
||||
const { auth = null } = state.state.payload
|
||||
if (auth === null) {
|
||||
return {
|
||||
success: false,
|
||||
error: new RuntimeError('No access token has been cached'),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: auth,
|
||||
}
|
||||
}
|
||||
|
||||
const _applyTokenToCache = async (client: bp.Client, ctx: bp.Context, resp: TokenResp) => {
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'configuration',
|
||||
id: ctx.integrationId,
|
||||
payload: {
|
||||
auth: resp,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const getOrRefreshAccessToken = async ({ client, ctx }: bp.CommonHandlerProps) => {
|
||||
let tokenResult = await _getCachedAccessToken(client, ctx)
|
||||
|
||||
let cacheToken = false
|
||||
if (!tokenResult.success || Date.now() / MS_PER_SECOND >= tokenResult.data.expiresAt - TOKEN_EXPIRY_BUFFER) {
|
||||
const { appId, appSecret } = ctx.configuration
|
||||
tokenResult = await _getFreshAccessToken(appId, appSecret)
|
||||
cacheToken = true
|
||||
}
|
||||
|
||||
if (!tokenResult.success) throw tokenResult.error
|
||||
const tokenResp = tokenResult.data
|
||||
|
||||
if (cacheToken) {
|
||||
await _applyTokenToCache(client, ctx, tokenResp)
|
||||
}
|
||||
|
||||
return tokenResp.accessToken
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import axios, { type AxiosResponse } from 'axios'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const NO_CONTENT = 204
|
||||
|
||||
export async function httpGetAsBuffer(url: string, logger: bp.Logger) {
|
||||
const resp = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
transitional: { forcedJSONParsing: false },
|
||||
})
|
||||
|
||||
const content = resp.data
|
||||
if (content === undefined || content === null) {
|
||||
const emptyValueType = content === null ? 'null' : 'undefined'
|
||||
logger.warn(`Sanity check triggered, '${emptyValueType}' returned from axios`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!(content instanceof Buffer)) {
|
||||
const constructorName = content.constructor?.name ?? '<Unknown>'
|
||||
// If I understood the Axios docs & configured it correctly, this error should never be thrown
|
||||
const errorMsg = `Axios did not convert the response body into a Buffer (Constructor Name: ${constructorName})`
|
||||
throw new RuntimeError(errorMsg)
|
||||
}
|
||||
|
||||
const rawContentType = _getContentType(resp.headers, resp.status, logger)
|
||||
const contentType = rawContentType.replace(/([^;]);.+/, '$1')
|
||||
const charset = _getContentCharset(rawContentType)
|
||||
|
||||
return { data: content, contentType, charset } as const satisfies {
|
||||
data: Buffer
|
||||
contentType: string
|
||||
charset: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Performs an Axios get request and parses the response as json based
|
||||
* on the content-type, otherwise the data is formatted into a buffer. */
|
||||
type JsonOrBufferReturn =
|
||||
| { type: 'JSON'; data: object }
|
||||
| { type: 'Buffer'; buffer: Buffer; contentType: string; charset: string }
|
||||
export async function httpGetAsJsonOrBuffer(url: string, logger: bp.Logger): Promise<JsonOrBufferReturn | null> {
|
||||
const respData = await httpGetAsBuffer(url, logger)
|
||||
if (respData === null) return null
|
||||
|
||||
const { data, contentType, charset } = respData
|
||||
if (contentType.includes('application/json')) {
|
||||
const serializedJSON = _bufferToString(data, charset)
|
||||
return { type: 'JSON', data: JSON.parse(serializedJSON) }
|
||||
}
|
||||
|
||||
return { type: 'Buffer', buffer: data, contentType, charset }
|
||||
}
|
||||
|
||||
type CommonResponseHeadersList = 'Server' | 'Content-Type' | 'Content-Length' | 'Cache-Control' | 'Content-Encoding'
|
||||
type ResponseHeaderValue = string | string[] | null
|
||||
|
||||
/** Extracts a header value from the Axios headers, and simplifies the output type
|
||||
*
|
||||
* @remark This function exists because "AxiosHeaderValue" contains the
|
||||
* "AxiosHeaders" type which creates an indirect circular type reference.
|
||||
* @remark The overloads exist strictly for auto-complete */
|
||||
export function getHeaderValue(headers: AxiosResponse['headers'], key: CommonResponseHeadersList): ResponseHeaderValue
|
||||
export function getHeaderValue(headers: AxiosResponse['headers'], key: string): ResponseHeaderValue
|
||||
export function getHeaderValue(headers: AxiosResponse['headers'], key: string): ResponseHeaderValue {
|
||||
const headerValue = headers instanceof axios.AxiosHeaders ? headers.get(key) : headers[key]
|
||||
if (headerValue === null || headerValue === undefined) return null
|
||||
|
||||
if (headerValue instanceof axios.AxiosHeaders) {
|
||||
throw new RuntimeError("This should never trigger, if it does, IMO it's a bug with the Axios package")
|
||||
}
|
||||
|
||||
return Array.isArray(headerValue) ? headerValue : `${headerValue}`
|
||||
}
|
||||
|
||||
const _getContentType = (headers: AxiosResponse['headers'], status: number, logger: bp.Logger) => {
|
||||
let contentType = getHeaderValue(headers, 'Content-Type')
|
||||
|
||||
if (Array.isArray(contentType)) {
|
||||
// IMO this should never occur, unless WeChat
|
||||
// is doing some weird stuff in their Backend
|
||||
if (contentType.length > 1) {
|
||||
logger.warn(
|
||||
`The 'Content-Type' header has multiple values, using first value. All the values are as follows:\n- ${contentType.join('\n- ')}`
|
||||
)
|
||||
}
|
||||
|
||||
contentType = contentType[0] ?? null
|
||||
}
|
||||
|
||||
if (!contentType) {
|
||||
if (status === NO_CONTENT) return 'text/plain'
|
||||
throw new Error(`The 'Content-Type' header has not been set (Status code: ${status})`)
|
||||
}
|
||||
return contentType
|
||||
}
|
||||
|
||||
const _bufferToString = (buffer: Buffer, charset: string = 'utf8') => new TextDecoder(charset).decode(buffer)
|
||||
|
||||
const _getContentCharset = (contentType: string) => {
|
||||
const pattern = /charset=([^()<>@,;:\"/[\]?.=\s]*)/i
|
||||
const charset = pattern.test(contentType) ? pattern.exec(contentType)?.[1] : null
|
||||
return charset?.toLowerCase() ?? 'utf8'
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import axios from 'axios'
|
||||
import { useHandleCaughtError } from '../utils'
|
||||
import { getOrRefreshAccessToken } from './auth'
|
||||
import { httpGetAsJsonOrBuffer } from './axios-helpers'
|
||||
import { WECHAT_API_BASE } from './constants'
|
||||
import { getValidMediaPropOrThrow } from './helpers'
|
||||
import {
|
||||
type WeChatSendMessageResp,
|
||||
wechatSendMessageRespSchema,
|
||||
wechatUploadMediaRespSchema,
|
||||
wechatVideoUrlRespSchema,
|
||||
} from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WeChatMediaResponse = Promise<{ content: Buffer; contentType: string }>
|
||||
type WeChatTextMessage = { msgtype: 'text'; text: { content: string } }
|
||||
type WeChatImageMessage = { msgtype: 'image'; image: { media_id: string } }
|
||||
type WeChatVideoMessage = { msgtype: 'video'; video: { media_id: string; title?: string; description?: string } }
|
||||
type WeChatOutgoingMessage = WeChatTextMessage | WeChatImageMessage | WeChatVideoMessage
|
||||
|
||||
export class WeChatClient {
|
||||
private constructor(
|
||||
private readonly _accessToken: string,
|
||||
private readonly _logger: bp.Logger
|
||||
) {}
|
||||
|
||||
public getMediaUrl(accessToken: string, mediaId: string): string {
|
||||
return `${WECHAT_API_BASE}/media/get?access_token=${accessToken}&media_id=${mediaId}`
|
||||
}
|
||||
|
||||
public async downloadWeChatMedia(mediaId: string): WeChatMediaResponse {
|
||||
const mediaUrl = this.getMediaUrl(this._accessToken, mediaId)
|
||||
return this._downloadWeChatMediaFromUrl(mediaUrl, true)
|
||||
}
|
||||
|
||||
private async _downloadWeChatMediaFromUrl(url: string, retry: boolean): WeChatMediaResponse {
|
||||
const resp = await httpGetAsJsonOrBuffer(url, this._logger).catch(
|
||||
useHandleCaughtError('Failed to download WeChat media')
|
||||
)
|
||||
if (resp === null) {
|
||||
throw new RuntimeError('Failed to download WeChat media -> No Content')
|
||||
}
|
||||
|
||||
if (resp.type === 'JSON') {
|
||||
if (!retry) {
|
||||
// This solution isn't ideal, I'd rather remove the recursion entirely. But this will suffice for now
|
||||
throw new RuntimeError('Failed to download media from WeChat -> Too many retries')
|
||||
}
|
||||
|
||||
const result = wechatVideoUrlRespSchema.safeParse(resp.data)
|
||||
if (!result.success) {
|
||||
throw new RuntimeError('Received unexpected response when downloading WeChat media')
|
||||
}
|
||||
|
||||
const videoUrl = getValidMediaPropOrThrow('video_url', result.data, 'Failed to download media from WeChat')
|
||||
return this._downloadWeChatMediaFromUrl(videoUrl, false)
|
||||
}
|
||||
|
||||
return { content: resp.buffer, contentType: resp.contentType }
|
||||
}
|
||||
|
||||
public async sendMessage(toUser: string, message: WeChatOutgoingMessage): Promise<WeChatSendMessageResp> {
|
||||
const resp = await axios
|
||||
.post(`${WECHAT_API_BASE}/message/custom/send?access_token=${this._accessToken}`, {
|
||||
touser: toUser,
|
||||
...message,
|
||||
})
|
||||
.catch(useHandleCaughtError('Failed to send WeChat message'))
|
||||
|
||||
const parseResult = wechatSendMessageRespSchema.safeParse(resp.data)
|
||||
if (!parseResult.success) {
|
||||
throw new RuntimeError('Unexpected response structure received when attempting to send message to WeChat')
|
||||
}
|
||||
|
||||
const data = parseResult.data
|
||||
if (data.errorCode && data.errorCode !== 0) {
|
||||
throw new RuntimeError(`Failed to send WeChat message -> (Error code: ${data.errorCode}) ${data.errorMsg}`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
public async uploadMedia(mediaType: 'image' | 'voice' | 'video', mediaBlob: Blob, fileExtension: string) {
|
||||
const formData = new FormData()
|
||||
formData.append('media', mediaBlob, `media.${fileExtension}`)
|
||||
|
||||
const resp = await axios
|
||||
.post(`${WECHAT_API_BASE}/media/upload?access_token=${this._accessToken}&type=${mediaType}`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
.catch(useHandleCaughtError('Failed to upload media to WeChat API'))
|
||||
|
||||
const result = wechatUploadMediaRespSchema.safeParse(resp.data)
|
||||
if (!result.success) {
|
||||
throw new RuntimeError(`Unexpected response received when uploading media to WeChat -> ${result.error.message}`)
|
||||
}
|
||||
return getValidMediaPropOrThrow('media_id', result.data, 'Failed to upload media to WeChat')
|
||||
}
|
||||
|
||||
public static async create(props: bp.CommonHandlerProps) {
|
||||
const token = await getOrRefreshAccessToken(props)
|
||||
return new WeChatClient(token, props.logger)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const WECHAT_API_BASE = 'https://api.weixin.qq.com/cgi-bin'
|
||||
@@ -0,0 +1,72 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { useHandleCaughtError } from '../utils'
|
||||
import { httpGetAsBuffer } from './axios-helpers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const MAX_MEDIA_BYTES = 10 * 1024 * 1024
|
||||
|
||||
/** @remark This file extension map is not exhaustive. */
|
||||
const CONTENT_TYPE_TO_EXTENSION_MAP: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/bmp': 'bmp',
|
||||
}
|
||||
|
||||
type MediaProp = Partial<Record<'media_id' | 'video_url', string | undefined>>
|
||||
export const getValidMediaPropOrThrow = <T extends MediaProp>(
|
||||
targetKey: Extract<keyof Required<T>, string>,
|
||||
data: T & { errcode?: number; errmsg?: string },
|
||||
errorMsg: string
|
||||
) => {
|
||||
const hasErrorCode = data.errcode && data.errcode !== 0
|
||||
const targetValue = data[targetKey] as MediaProp[keyof MediaProp]
|
||||
if (hasErrorCode || !targetValue) {
|
||||
const errorSuffix = hasErrorCode ? `(Error code: ${data.errcode}) ${data.errmsg}` : `missing ${targetKey}`
|
||||
throw new RuntimeError(`${errorMsg} -> ${errorSuffix}`)
|
||||
}
|
||||
|
||||
return targetValue
|
||||
}
|
||||
|
||||
/** Converts the content type to a file extension recognized by WeChat.
|
||||
*
|
||||
* @remark Expects "contentType" to be striped of additional attributes
|
||||
* found in the header, such as the charset, BEFORE being passed in. */
|
||||
export const convertContentTypeToFileExtension = (contentType: string) => {
|
||||
const fileExtension = CONTENT_TYPE_TO_EXTENSION_MAP[contentType]
|
||||
|
||||
if (!fileExtension) {
|
||||
throw new RuntimeError(`Unsupported media content type: ${contentType}`)
|
||||
}
|
||||
|
||||
return fileExtension
|
||||
}
|
||||
|
||||
/** Downloads media from a given URL and converts it into a Blob. While
|
||||
* ensuring it's not too large to be uploaded to WeChat's servers.
|
||||
*
|
||||
* @remark The intended use-case is for media not coming from WeChat,
|
||||
* hense why this function is not directly in the WeChatClient. */
|
||||
export const downloadMediaFromURL = async (mediaUrl: string, logger: bp.Logger) => {
|
||||
const resp = await httpGetAsBuffer(mediaUrl, logger).catch(
|
||||
useHandleCaughtError(`Failed to download media from URL: '${mediaUrl}'`)
|
||||
)
|
||||
|
||||
if (resp === null) {
|
||||
throw new RuntimeError(`Failed to download media from URL: '${mediaUrl}' -> No content`)
|
||||
}
|
||||
|
||||
const { data: mediaBuffer, contentType } = resp
|
||||
const contentLength = mediaBuffer.byteLength
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_MEDIA_BYTES) {
|
||||
throw new RuntimeError(`Media exceeds max size of ${MAX_MEDIA_BYTES} bytes`)
|
||||
}
|
||||
|
||||
const mediaBlob = new Blob([mediaBuffer], { type: contentType })
|
||||
const fileExtension = convertContentTypeToFileExtension(contentType)
|
||||
|
||||
return { mediaBlob, fileExtension }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
const _wechatErrorRespSchema = z.object({
|
||||
errcode: z.coerce.number(),
|
||||
errmsg: z.string(),
|
||||
})
|
||||
|
||||
const MISSING_ACCESS_TOKEN_MSG = 'The WeChat access token is missing from the response'
|
||||
export const weChatAuthTokenRespSchema = z.union([
|
||||
_wechatErrorRespSchema,
|
||||
z.object({
|
||||
access_token: z.string({ required_error: MISSING_ACCESS_TOKEN_MSG }).min(1, MISSING_ACCESS_TOKEN_MSG),
|
||||
expires_in: z.number().min(1).describe('The number of seconds before the access token expires'),
|
||||
}),
|
||||
])
|
||||
|
||||
export const wechatSendMessageRespSchema = _wechatErrorRespSchema
|
||||
.partial()
|
||||
.extend({
|
||||
// NOTE: AFAIK the "sendMessage" response doesn't contain
|
||||
// any Message ID, because WeChat doesn't send one back.
|
||||
// The properties below can likely be safely removed.
|
||||
msgid: z.string().optional(),
|
||||
msg_id: z.string().optional(),
|
||||
message_id: z.string().optional(),
|
||||
})
|
||||
.transform((data) => ({
|
||||
errorCode: data.errcode,
|
||||
errorMsg: data.errmsg,
|
||||
msgId: data.msgid ?? data.msg_id ?? data.message_id,
|
||||
}))
|
||||
export type WeChatSendMessageResp = z.infer<typeof wechatSendMessageRespSchema>
|
||||
|
||||
export const wechatUploadMediaRespSchema = _wechatErrorRespSchema.partial().extend({ media_id: z.string().optional() })
|
||||
export type WeChatUploadMediaResp = z.infer<typeof wechatUploadMediaRespSchema>
|
||||
|
||||
export const wechatVideoUrlRespSchema = _wechatErrorRespSchema.partial().extend({ video_url: z.string().optional() })
|
||||
export type WeChatVideoUrlResp = z.infer<typeof wechatVideoUrlRespSchema>
|
||||
@@ -0,0 +1,191 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
import camelCase from 'lodash/camelCase'
|
||||
import { Result, WebhookResult } from '../types'
|
||||
import { usePromiseToResult } from '../utils'
|
||||
import { WeChatMessage, wechatMessageSchema } from './schemas'
|
||||
import { BaseMessage, createChannelMessage, createMediaMessage } from './utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type BotpressConversation = Awaited<ReturnType<bp.Client['getOrCreateConversation']>>['conversation']
|
||||
export type BotpressUser = Awaited<ReturnType<bp.Client['getOrCreateUser']>>['user']
|
||||
|
||||
export const processInboundChannelMessage = async (props: bp.HandlerProps): Promise<WebhookResult<string>> => {
|
||||
const { client, req } = props
|
||||
|
||||
const requestBody = req.body?.trim() || ''
|
||||
if (requestBody === '') {
|
||||
return { success: false, error: new Error('Received empty webhook payload'), status: 200 }
|
||||
}
|
||||
|
||||
const parseResult = _parseAndValidateMessage(requestBody, props.logger)
|
||||
if (!parseResult.success) return parseResult
|
||||
|
||||
const wechatMessage: WeChatMessage = parseResult.data
|
||||
// Yes, the WeChat sendMessage request uses the username as the DM conversation identifier
|
||||
const wechatConversationId = wechatMessage.fromUserName
|
||||
const wechatUserId = wechatMessage.fromUserName
|
||||
|
||||
const convResult = await client
|
||||
.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: {
|
||||
id: wechatConversationId,
|
||||
},
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
.then(...usePromiseToResult('Failed to create Botpress Conversation'))
|
||||
if (!convResult.success) return convResult
|
||||
const { conversation } = convResult.data
|
||||
|
||||
const userResult = await client
|
||||
.getOrCreateUser({
|
||||
tags: {
|
||||
id: wechatUserId,
|
||||
},
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
.then(...usePromiseToResult('Failed to create Botpress User'))
|
||||
if (!userResult.success) return userResult
|
||||
const { user } = userResult.data
|
||||
|
||||
const messageId = wechatMessage.msgId
|
||||
const baseMessage = {
|
||||
tags: {
|
||||
id: messageId,
|
||||
chatId: wechatConversationId,
|
||||
},
|
||||
userId: user.id,
|
||||
conversationId: conversation.id,
|
||||
} satisfies BaseMessage
|
||||
|
||||
try {
|
||||
switch (wechatMessage.msgType) {
|
||||
case 'text':
|
||||
if (wechatMessage.content) {
|
||||
await createChannelMessage(client, baseMessage, 'text', { text: wechatMessage.content })
|
||||
}
|
||||
break
|
||||
case 'image':
|
||||
case 'video':
|
||||
const mediaMessage = { ...wechatMessage, msgType: wechatMessage.msgType }
|
||||
await createMediaMessage(props, baseMessage, messageId, mediaMessage)
|
||||
break
|
||||
case 'voice':
|
||||
if (wechatMessage.mediaId) {
|
||||
const voiceText = `[Voice Message] MediaId: ${wechatMessage.mediaId}${wechatMessage.recognition ? `\nRecognized: ${wechatMessage.recognition}` : ''}`
|
||||
await createChannelMessage(client, baseMessage, 'text', { text: voiceText })
|
||||
}
|
||||
break
|
||||
case 'location':
|
||||
const locationText = `[Location] ${wechatMessage.label || 'location'}\nCoordinates: (${wechatMessage.locationX || '0'}, ${wechatMessage.locationY || '0'})`
|
||||
await createChannelMessage(client, baseMessage, 'text', { text: locationText })
|
||||
break
|
||||
case 'link':
|
||||
const linkText = `[Link] ${wechatMessage.title || 'Untitled'}\n${wechatMessage.description || ''}\nURL: ${wechatMessage.url || ''}`
|
||||
await createChannelMessage(client, baseMessage, 'text', { text: linkText })
|
||||
break
|
||||
default:
|
||||
props.logger.forBot().error(`Unsupported message type: ${wechatMessage.msgType}`)
|
||||
break
|
||||
}
|
||||
} catch (thrown: unknown) {
|
||||
const errMessage = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
return {
|
||||
success: false,
|
||||
error: new Error(`Failed to process '${wechatMessage.msgType}' message -> ${errMessage}`),
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: 'success' }
|
||||
}
|
||||
|
||||
const _parseAndValidateWeChatXmlMessage = (rawXmlContent: string): Result<WeChatMessage> => {
|
||||
const parser = new XMLParser({ parseTagValue: false })
|
||||
const parseData = _mapWechatMessageKeys(parser.parse(rawXmlContent))
|
||||
const result = z.object({ xml: wechatMessageSchema }).safeParse(parseData)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error(`Unexpected WeChat message body received -> ${result.error.message}`),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.xml,
|
||||
}
|
||||
}
|
||||
|
||||
// This abstraction can be removed once we determine the "fallback" to no longer be necessary
|
||||
const _parseAndValidateMessage = (rawXmlContent: string, logger: bp.Logger): WebhookResult<WeChatMessage> => {
|
||||
const result = _parseAndValidateWeChatXmlMessage(rawXmlContent)
|
||||
|
||||
if (!result.success) {
|
||||
const fbResult = _fallbackParseAndValidateWeChatMessage(rawXmlContent)
|
||||
if (fbResult.success) {
|
||||
logger.error(`Primary message parser failure, fallback used -> ${result.error.message}`)
|
||||
return fbResult
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
status: 200,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const _mapWechatMessageKeys = (parsedWechatMessage: Record<string, any>): Record<string, any> => {
|
||||
const mappedEntries = Object.entries(parsedWechatMessage).map(([key, value]) => {
|
||||
const mappedKey = camelCase(key)
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return [mappedKey, _mapWechatMessageKeys(value)] as const
|
||||
}
|
||||
|
||||
return [mappedKey, value] as const
|
||||
})
|
||||
return Object.fromEntries(mappedEntries)
|
||||
}
|
||||
|
||||
/** Tightly coupled to "_fallbackParseAndValidateWeChatMessage" */
|
||||
const _extractXmlValue = (xml: string, tag: string): string | undefined => {
|
||||
// find pattern of CDATA or plain text: <Tag><![CDATA[value]]></Tag> or <Tag>value</Tag>
|
||||
const valueRegex = new RegExp(`<${tag}>\\s*(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?\\s*</${tag}>`, 'i')
|
||||
const match = xml.match(valueRegex)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
// This is a fallback parser, it can be removed once we're sufficiently confident
|
||||
// that "_parseAndValidateWeChatXmlMessage" works in all expected use-cases.
|
||||
const _fallbackParseAndValidateWeChatMessage = (reqBody: string): Result<WeChatMessage> => {
|
||||
const _wechatMessageKeys = [
|
||||
'MsgId',
|
||||
'MsgType',
|
||||
'ToUserName',
|
||||
'FromUserName',
|
||||
'Content',
|
||||
'PicUrl',
|
||||
'MediaId',
|
||||
'Recognition',
|
||||
'Location_X',
|
||||
'Location_Y',
|
||||
'Label',
|
||||
'Title',
|
||||
'Description',
|
||||
'Url',
|
||||
'CreateTime',
|
||||
]
|
||||
|
||||
const entries = _wechatMessageKeys.map((key) => [camelCase(key), _extractXmlValue(reqBody, key)])
|
||||
const result = wechatMessageSchema.safeParse(Object.fromEntries(entries))
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: new Error(`Unexpected WeChat Message Body received -> ${result.error.message}`) }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { WeChatClient } from '../api/client'
|
||||
import { downloadMediaFromURL } from '../api/helpers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const channels = {
|
||||
channel: {
|
||||
messages: {
|
||||
text: async (props) => _handleTextMessage(props),
|
||||
image: async (props) => _handleImageMessage(props),
|
||||
video: async (props) => _handleVideoMessage(props),
|
||||
},
|
||||
},
|
||||
} satisfies bp.IntegrationProps['channels']
|
||||
|
||||
type WeChatTextMessage = { msgtype: 'text'; text: { content: string } }
|
||||
type WeChatImageMessage = { msgtype: 'image'; image: { media_id: string } }
|
||||
type WeChatVideoMessage = { msgtype: 'video'; video: { media_id: string; title?: string; description?: string } }
|
||||
type WeChatOutgoingMessage = WeChatTextMessage | WeChatImageMessage | WeChatVideoMessage
|
||||
|
||||
const _handleTextMessage = async (props: bp.MessageProps['channel']['text']) => {
|
||||
const { payload, logger } = props
|
||||
try {
|
||||
await _sendMessageToWeChat(props, {
|
||||
msgtype: 'text',
|
||||
text: { content: payload.text },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.forBot().error(`Failed to send text message: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const _handleImageMessage = async (props: bp.MessageProps['channel']['image']) => {
|
||||
const { payload, logger } = props
|
||||
try {
|
||||
const wechatClient = await WeChatClient.create(props)
|
||||
const { mediaBlob, fileExtension } = await downloadMediaFromURL(payload.imageUrl, logger)
|
||||
const mediaId = await wechatClient.uploadMedia('image', mediaBlob, fileExtension)
|
||||
await _sendMessageToWeChat(
|
||||
props,
|
||||
{
|
||||
msgtype: 'image',
|
||||
image: { media_id: mediaId },
|
||||
},
|
||||
wechatClient
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.forBot().error(`Failed to send image message: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const _handleVideoMessage = async (props: bp.MessageProps['channel']['video']) => {
|
||||
const { payload, logger } = props
|
||||
try {
|
||||
await _sendMessageToWeChat(props, {
|
||||
msgtype: 'text',
|
||||
text: { content: `[Video] ${payload.videoUrl}` },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger.forBot().error(`Failed to send video message: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const _sendMessageToWeChat = async (
|
||||
props: bp.AnyMessageProps,
|
||||
message: WeChatOutgoingMessage,
|
||||
wechatClient?: WeChatClient
|
||||
): Promise<void> => {
|
||||
const { conversation, ack } = props
|
||||
const wechatConvoId = conversation.tags?.id
|
||||
if (!wechatConvoId) {
|
||||
throw new RuntimeError('Conversation does not have a WeChat chat ID')
|
||||
}
|
||||
|
||||
wechatClient ??= await WeChatClient.create(props)
|
||||
const sendResponse = await wechatClient.sendMessage(wechatConvoId, message)
|
||||
|
||||
const ackId = sendResponse.msgId ?? `wechat-${nanoid()}`
|
||||
await ack({ tags: { id: ackId } })
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { Merge } from '../types'
|
||||
|
||||
const MS_PER_SECOND = 1000
|
||||
|
||||
export const wechatMessageSchema = z
|
||||
.object({
|
||||
msgId: z.string().min(1),
|
||||
msgType: z.string().min(1),
|
||||
toUserName: z.string().min(1),
|
||||
fromUserName: z.string().min(1),
|
||||
content: z.string().optional(),
|
||||
picUrl: z.string().optional(),
|
||||
mediaId: z.string().optional(),
|
||||
recognition: z.string().optional(),
|
||||
locationX: z.string().optional(),
|
||||
locationY: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
createTime: z.coerce.number().positive().describe('Seconds since UTC Epoch'),
|
||||
})
|
||||
.passthrough()
|
||||
.transform(({ createTime, ...data }) => ({
|
||||
...data,
|
||||
dateCreated: new Date(createTime * MS_PER_SECOND),
|
||||
}))
|
||||
export type WeChatMessage = z.infer<typeof wechatMessageSchema>
|
||||
export type WeChatMediaMessage = Merge<WeChatMessage, { msgType: 'image' | 'video' }>
|
||||
@@ -0,0 +1,89 @@
|
||||
import { WeChatClient } from '../api/client'
|
||||
import { WeChatMediaMessage } from './schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type BaseMessage = {
|
||||
conversationId: string
|
||||
userId: string
|
||||
tags: {
|
||||
id: string
|
||||
chatId: string
|
||||
}
|
||||
}
|
||||
|
||||
type MessageChannels = bp.channels.Channels['channel']['messages']
|
||||
type CommonCreateMessageInput = Pick<Parameters<bp.Client['createMessage']>[0], 'type' | 'payload'>
|
||||
export const createChannelMessage = async <T extends keyof MessageChannels>(
|
||||
client: bp.Client,
|
||||
baseMessage: BaseMessage,
|
||||
type: T,
|
||||
payload: MessageChannels[T]
|
||||
) => {
|
||||
await client.createMessage({
|
||||
...baseMessage,
|
||||
...({
|
||||
type,
|
||||
payload,
|
||||
} satisfies CommonCreateMessageInput),
|
||||
})
|
||||
}
|
||||
|
||||
export const createMediaMessage = async (
|
||||
props: bp.CommonHandlerProps,
|
||||
baseMessage: BaseMessage,
|
||||
messageId: string,
|
||||
wechatMessage: WeChatMediaMessage
|
||||
) => {
|
||||
const { msgType, picUrl, mediaId } = wechatMessage
|
||||
const mediaUrl = await _getOrUploadWeChatMedia(props, {
|
||||
messageId,
|
||||
kind: msgType,
|
||||
picUrl,
|
||||
mediaId,
|
||||
})
|
||||
if (!mediaUrl) {
|
||||
props.logger.forBot().error(`Failed to create message of type: ${msgType}`)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = msgType === 'image' ? { imageUrl: mediaUrl } : { videoUrl: mediaUrl }
|
||||
await createChannelMessage(props.client, baseMessage, msgType, payload)
|
||||
}
|
||||
|
||||
const _getOrUploadWeChatMedia = async (
|
||||
props: bp.CommonHandlerProps,
|
||||
params: {
|
||||
messageId: string
|
||||
mediaId?: string
|
||||
picUrl?: string
|
||||
kind: 'image' | 'video'
|
||||
}
|
||||
) => {
|
||||
const { messageId, mediaId, picUrl, kind } = params
|
||||
const mediaKey = `wechat/media/${kind}/${mediaId || messageId || Date.now()}`
|
||||
|
||||
if (picUrl) {
|
||||
const { file } = await props.client.uploadFile({
|
||||
key: mediaKey,
|
||||
url: picUrl,
|
||||
accessPolicies: ['public_content'],
|
||||
publicContentImmediatelyAccessible: true,
|
||||
})
|
||||
return file.url
|
||||
}
|
||||
|
||||
if (mediaId) {
|
||||
const wechatClient = await WeChatClient.create(props)
|
||||
const { content, contentType } = await wechatClient.downloadWeChatMedia(mediaId)
|
||||
const { file } = await props.client.uploadFile({
|
||||
key: mediaKey,
|
||||
content,
|
||||
contentType,
|
||||
accessPolicies: ['public_content'],
|
||||
publicContentImmediatelyAccessible: true,
|
||||
})
|
||||
return file.url
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { channels } from './channels/outbound'
|
||||
import { register, unregister } from './setup'
|
||||
import { handler } from './webhook/handler'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default new bp.Integration({
|
||||
register,
|
||||
unregister,
|
||||
actions: {},
|
||||
channels,
|
||||
handler,
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const register: bp.IntegrationProps['register'] = async () => {}
|
||||
export const unregister: bp.IntegrationProps['unregister'] = async () => {}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type Merge<A, B> = Omit<A, keyof B> & B
|
||||
|
||||
export type Result<T> = { success: true; data: T } | { success: false; error: Error }
|
||||
|
||||
/** A Result object with a potential status override when a failure occurs */
|
||||
export type WebhookResult<T> = { success: true; data: T } | { success: false; error: Error; status?: number }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { Result } from './types'
|
||||
|
||||
export const usePromiseToResult = (failMessage: string) => {
|
||||
return [
|
||||
<T>(data: T) => {
|
||||
return { success: true, data } satisfies Result<T>
|
||||
},
|
||||
(thrown: unknown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
return { success: false, error: new RuntimeError(`${failMessage} -> ${error.message}`) } satisfies Result<void>
|
||||
},
|
||||
] as const
|
||||
}
|
||||
|
||||
export const useHandleCaughtError = (message: string) => {
|
||||
return (thrown: unknown) => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
throw new RuntimeError(`${message} -> ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Request } from '@botpress/sdk'
|
||||
import { processInboundChannelMessage } from '../channels/inbound'
|
||||
import { verifyWebhookSignature } from './signature'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
if (!verifyWebhookSignature(props)) {
|
||||
return _createTextResponse(403, 'Invalid webhook signature')
|
||||
}
|
||||
|
||||
const reqMethod = props.req.method
|
||||
if (reqMethod === 'GET') {
|
||||
return _handleWebhookChallenge(props.req)
|
||||
} else if (reqMethod === 'POST') {
|
||||
const result = await processInboundChannelMessage(props)
|
||||
if (!result.success) {
|
||||
props.logger.forBot().error(result.error.message)
|
||||
return _createTextResponse(result.status ?? 500, 'Unexpected payload received')
|
||||
}
|
||||
|
||||
return _createTextResponse(200, result.data)
|
||||
}
|
||||
|
||||
props.logger.forBot().warn(`Unhandled request type - ${reqMethod}`)
|
||||
return _createTextResponse(200, 'OK')
|
||||
}
|
||||
|
||||
const _handleWebhookChallenge = (req: Request) => {
|
||||
const query = new URLSearchParams(req.query)
|
||||
const echostr = query.get('echostr') || ''
|
||||
return _createTextResponse(200, echostr)
|
||||
}
|
||||
|
||||
const _createTextResponse = (status: number, body: string) => ({
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body,
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import crypto from 'crypto'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const _computeSignature = (signingSecret: string, timestamp: string, nonce: string): string => {
|
||||
// While I think having a "sort" doesn't make sense, this is simply how "WeChat" implements it
|
||||
const unhashedSignatureTxt = [signingSecret, timestamp, nonce].sort().join('')
|
||||
return crypto.createHash('sha1').update(unhashedSignatureTxt, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
export const verifyWebhookSignature = ({ req, ctx }: bp.HandlerProps): boolean => {
|
||||
const query = new URLSearchParams(req.query)
|
||||
const signature = query.get('signature')
|
||||
const timestamp = query.get('timestamp') || ''
|
||||
const nonce = query.get('nonce') || ''
|
||||
const signingSecret = ctx.configuration.webhookSigningSecret
|
||||
|
||||
const computedSignature = _computeSignature(signingSecret, timestamp, nonce)
|
||||
return signature === computedSignature
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user