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,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']
+3
View File
@@ -0,0 +1,3 @@
export * from './channels'
export * from './user'
export * from './states'
+22
View File
@@ -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']
+7
View File
@@ -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']
+93
View File
@@ -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)
+9
View File
@@ -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',
},
})
+21
View File
@@ -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"
}
}
+100
View File
@@ -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'
}
+107
View File
@@ -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)
}
}
+1
View File
@@ -0,0 +1 @@
export const WECHAT_API_BASE = 'https://api.weixin.qq.com/cgi-bin'
+72
View File
@@ -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 }
}
+38
View File
@@ -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>
+191
View File
@@ -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' }>
+89
View File
@@ -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
}
+12
View File
@@ -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,
})
+4
View File
@@ -0,0 +1,4 @@
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async () => {}
export const unregister: bp.IntegrationProps['unregister'] = async () => {}
+6
View File
@@ -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 }
+21
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}