diff --git a/integrations/wechat-entree/definitions/channels.ts b/integrations/wechat-entree/definitions/channels.ts
new file mode 100644
index 00000000..93cb3cd5
--- /dev/null
+++ b/integrations/wechat-entree/definitions/channels.ts
@@ -0,0 +1,36 @@
+import { messages } from '@botpress/sdk'
+
+const _textMessageDefinition = {
+ ...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)'),
+ }),
+}
+
+// ============== FOR FUTURE USE ==============
+// const _imageMessageDefinition = {
+// ...messages.defaults.image,
+// schema: messages.defaults.image.schema.extend({
+// caption: z.string().optional().describe('The caption/description of the image'),
+// }),
+// }
+
+// const _audioMessageDefinition = {
+// ...messages.defaults.audio,
+// schema: messages.defaults.audio.schema.extend({
+// caption: z.string().optional().describe('The caption/transcription of the audio message'),
+// }),
+// }
+// =============================================
+
+const _imageMessageDefinition = {
+ ...messages.defaults.image,
+}
+
+export const wechatMessageChannels = {
+ text: _textMessageDefinition,
+ image: _imageMessageDefinition,
+ video: messages.defaults.video,
+}
diff --git a/integrations/wechat-entree/hub.md b/integrations/wechat-entree/hub.md
new file mode 100644
index 00000000..bb5dace5
--- /dev/null
+++ b/integrations/wechat-entree/hub.md
@@ -0,0 +1,132 @@
+# 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 your Botpress webhook URL (provided after integration installation)
+4. Set the **Token** to the same token you used in Botpress configuration
+5. Select **Message Encryption Method**: Plain Text (recommended for testing)
+6. 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)
+- **Voice messages** - Audio messages with optional speech recognition
+- **Video messages** - Video content
+- **Location messages** - User location with latitude, longitude, and label
+- **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)
+- **Audio/Voice messages** - Audio files (automatically uploaded to WeChat)
+- **Video messages** - Video files (automatically uploaded to WeChat)
+- **Card messages** - Rich cards with title, image, and link (sent as WeChat News)
+- **Carousel messages** - Multiple cards (up to 8 articles)
+- **Choice/Dropdown messages** - Formatted as text with numbered options
+
+## How It Works
+
+1. **User sends message** → WeChat forwards it to your Botpress webhook
+2. **Botpress processes** → Your bot logic analyzes and generates response
+3. **Bot sends reply** → Via WeChat Customer Service API (asynchronous)
+
+This integration uses WeChat's **Customer Service API** which allows:
+
+- ✅ Sending messages anytime (not limited to 5-second window)
+- ✅ Multiple messages per user interaction
+- ✅ Asynchronous bot processing (perfect for AI/LLM responses)
+
+## 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 Frequency**: Rate limits apply (typically 100 messages/second)
+- **Media Upload**: Images/videos/audio must be uploaded to WeChat servers first (handled automatically)
+- **Message Length**: Text messages limited to 4,096 characters
+- **Carousel Limit**: Maximum 8 articles per carousel message
+
+### Integration Limitations
+
+- **No Proactive Messaging**: Cannot initiate conversations; users must message first
+- **Interactive Elements**: WeChat doesn't support buttons/quick replies natively (rendered as text with numbers)
+- **File Messages**: Generic file sending not fully supported by WeChat Customer Service API
+
+## 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
+
+## Changelog
+
+### v1.0.2 (Current)
+
+- ✅ Full message type support (text, image, audio, video, location, link)
+- ✅ WeChat Customer Service API integration for sending messages
+- ✅ Automatic media upload to WeChat servers
+- ✅ Signature verification for secure webhook handling
+- ✅ Rich message support (cards, carousels)
+- ✅ Comprehensive error handling and logging
+
+## 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)
diff --git a/integrations/wechat-entree/icon.svg b/integrations/wechat-entree/icon.svg
new file mode 100644
index 00000000..417a59c9
--- /dev/null
+++ b/integrations/wechat-entree/icon.svg
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/integrations/wechat-entree/integration.definition.ts b/integrations/wechat-entree/integration.definition.ts
new file mode 100644
index 00000000..15e583a3
--- /dev/null
+++ b/integrations/wechat-entree/integration.definition.ts
@@ -0,0 +1,49 @@
+import { z, IntegrationDefinition } from '@botpress/sdk'
+import { wechatMessageChannels } from './definitions/channels'
+export default new IntegrationDefinition({
+ name: 'plus/WeChat',
+ version: '1.0.2',
+ title: 'WeChat',
+ description: 'Engage with your WeChat audience in real-time.',
+ icon: 'icon.svg',
+ readme: 'hub.md',
+ configuration: {
+ schema: z.object({
+ wechatToken: z
+ .string()
+ .trim()
+ .min(1)
+ .describe('Token used for WeChat signature verification')
+ .title('WeChat Token'),
+ appId: z.string().trim().min(1).describe('WeChat Official Account App ID').title('App ID'),
+ appSecret: z.string().trim().min(1).describe('WeChat Official Account App Secret').title('App Secret'),
+ }),
+ },
+ 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 conversation ID' },
+ fromUserId: { title: 'WeChat User ID', description: 'The conversation WeChat user ID' },
+ chatId: { title: 'Chat ID', description: 'The conversation chat ID' },
+ },
+ },
+ },
+ },
+ actions: {},
+ events: {},
+ user: {
+ tags: {
+ id: { title: 'ID', description: 'The ID of the user' },
+ },
+ },
+})
diff --git a/integrations/wechat-entree/package.json b/integrations/wechat-entree/package.json
new file mode 100644
index 00000000..175acc6f
--- /dev/null
+++ b/integrations/wechat-entree/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@bp-templates/empty-integration",
+ "integrationName": "plus/WeChat",
+ "scripts": {
+ "check:type": "tsc --noEmit"
+ },
+ "private": true,
+ "dependencies": {
+ "@botpress/client": "1.27.1",
+ "@botpress/sdk": "4.21.0",
+ "prettier": "^3.6.2"
+ },
+ "devDependencies": {
+ "@botpress/cli": "4.27.4",
+ "@botpress/sdk": "4.21.0",
+ "@types/node": "^22.16.4",
+ "typescript": "^5.6.3"
+ }
+}
diff --git a/integrations/wechat-entree/src/index.ts b/integrations/wechat-entree/src/index.ts
new file mode 100644
index 00000000..5de107b0
--- /dev/null
+++ b/integrations/wechat-entree/src/index.ts
@@ -0,0 +1,222 @@
+import { handleImageMessage, handleTextMessage, handleVideoMessage } from './misc/message-handlers'
+import { downloadWeChatMedia, getAccessToken } from './misc/wechat-api'
+import { handleWechatSignatureVerificaation } from './wechat-handler'
+import * as bp from '.botpress'
+
+const integration = new bp.Integration({
+ register: async () => {},
+ unregister: async () => {},
+ actions: {},
+ channels: {
+ channel: {
+ messages: {
+ //messages to be handled
+ text: handleTextMessage,
+ image: handleImageMessage,
+ video: handleVideoMessage,
+ },
+ },
+ },
+ handler: async ({ req, client, ctx }) => {
+ // Extract signature params for verification (query/path/headers)
+ let signature: string | undefined
+ let timestamp: string | undefined
+ let nonce: string | undefined
+ let echostr: string | undefined
+
+ if (req.query) {
+ const query = typeof req.query === 'string' ? new URLSearchParams(req.query) : null
+ if (query) {
+ signature = query.get('signature') || undefined
+ timestamp = query.get('timestamp') || undefined
+ nonce = query.get('nonce') || undefined
+ echostr = query.get('echostr') || undefined
+ }
+ }
+
+ // Get signature params from the request path
+ if (!signature && req.path && req.path.includes('?')) {
+ const url = new URL(req.path, 'http://localhost')
+ signature = url.searchParams.get('signature') || undefined
+ timestamp = url.searchParams.get('timestamp') || undefined
+ nonce = url.searchParams.get('nonce') || undefined
+ echostr = url.searchParams.get('echostr') || undefined
+ }
+ // Get signature params from the headers
+ if (!signature && req.headers) {
+ signature = req.headers['x-wechat-signature'] || undefined
+ timestamp = req.headers['x-wechat-timestamp'] || undefined
+ nonce = req.headers['x-wechat-nonce'] || undefined
+ }
+
+ // Handle WeChat signature verification
+ const method = (req.method ?? 'POST').toUpperCase()
+ const result = handleWechatSignatureVerificaation({
+ wechatToken: ctx.configuration.wechatToken,
+ method,
+ signature,
+ timestamp,
+ nonce,
+ echostr,
+ body: req.body,
+ })
+
+ if (req.method === 'GET' && echostr) {
+ // this is where the issue exist and we add a "|" to resolve the problem and in the proxy, it will be removed
+ return {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ body: echostr + '|',
+ }
+ }
+
+ // Parse the message and create the conversation and user on botpress
+ if (result.message) {
+ const wechatMessage = result.message
+ const wechatConversationId = wechatMessage.FromUserName
+ const wechatUserId = wechatMessage.FromUserName
+ const messageId = wechatMessage.MsgId || wechatMessage.CreateTime
+
+ const { conversation } = await client.getOrCreateConversation({
+ channel: 'channel',
+ tags: {
+ id: wechatConversationId,
+ fromUserId: wechatUserId,
+ chatId: wechatConversationId,
+ },
+ discriminateByTags: ['id'],
+ })
+
+ const { user } = await client.getOrCreateUser({
+ tags: {
+ id: wechatUserId,
+ },
+ discriminateByTags: ['id'],
+ })
+
+ const messageTags = {
+ id: messageId || '',
+ chatId: wechatConversationId,
+ }
+ const baseMessage = {
+ tags: messageTags,
+ userId: user.id,
+ conversationId: conversation.id,
+ }
+
+ const createMessage = async (type: 'text', payload: bp.MessageProps['channel']['text']['payload']) =>
+ client.createMessage({
+ ...baseMessage,
+ type,
+ payload,
+ })
+ const createImageMessage = async (payload: bp.MessageProps['channel']['image']['payload']) =>
+ client.createMessage({
+ ...baseMessage,
+ type: 'image',
+ payload,
+ })
+ const createVideoMessage = async (payload: bp.MessageProps['channel']['video']['payload']) =>
+ client.createMessage({
+ ...baseMessage,
+ type: 'video',
+ payload,
+ })
+
+ const getOrUploadWechatMedia = async (params: {
+ // upload the media to the botpress file cloud
+ mediaId?: string
+ picUrl?: string
+ kind: 'image' | 'video'
+ }): Promise => {
+ const { mediaId, picUrl, kind } = params
+ const mediaKey = `wechat/media/${kind}/${mediaId || messageId || Date.now()}`
+
+ if (picUrl) {
+ const { file } = await client.uploadFile({
+ key: mediaKey,
+ url: picUrl,
+ accessPolicies: ['public_content'],
+ publicContentImmediatelyAccessible: true,
+ })
+ return file.url
+ }
+
+ if (mediaId) {
+ const accessToken = await getAccessToken(ctx.configuration.appId, ctx.configuration.appSecret)
+ const { content, contentType } = await downloadWeChatMedia(accessToken, mediaId)
+ const { file } = await client.uploadFile({
+ key: mediaKey,
+ content,
+ contentType,
+ accessPolicies: ['public_content'],
+ publicContentImmediatelyAccessible: true,
+ })
+ return file.url
+ }
+
+ return undefined
+ }
+
+ switch (wechatMessage.MsgType) {
+ case 'text':
+ if (wechatMessage.Content) {
+ await createMessage('text', { text: wechatMessage.Content })
+ }
+ break
+ case 'image': {
+ const imageUrl = await getOrUploadWechatMedia({
+ kind: 'image',
+ picUrl: wechatMessage.PicUrl,
+ mediaId: wechatMessage.MediaId,
+ })
+ if (imageUrl) {
+ await createImageMessage({ imageUrl })
+ }
+ break
+ }
+ case 'video': {
+ const videoUrl = await getOrUploadWechatMedia({ kind: 'video', mediaId: wechatMessage.MediaId })
+ if (videoUrl) {
+ await createVideoMessage({ videoUrl })
+ }
+ break
+ }
+ case 'voice': // saved into the botpress file cloud
+ if (wechatMessage.MediaId) {
+ await createMessage('text', {
+ text: `[Voice Message] MediaId: ${wechatMessage.MediaId}${
+ wechatMessage.Recognition ? `\nRecognized: ${wechatMessage.Recognition}` : ''
+ }`,
+ })
+ }
+ break
+ case 'location':
+ await createMessage('text', {
+ text: `[Location] ${wechatMessage.Label || 'location'}\nCoordinates: (${wechatMessage.Location_X || '0'}, ${wechatMessage.Location_Y || '0'})`,
+ })
+ break
+ case 'link':
+ await createMessage('text', {
+ text: `[Link] ${wechatMessage.Title || 'Untitled'}\n${wechatMessage.Description || ''}\nURL: ${
+ wechatMessage.Url || ''
+ }`,
+ })
+ break
+ }
+ }
+
+ // Return success response for POST requests
+ return {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/plain',
+ },
+ body: 'success',
+ }
+ },
+})
+
+export default integration
diff --git a/integrations/wechat-entree/src/misc/message-handlers.ts b/integrations/wechat-entree/src/misc/message-handlers.ts
new file mode 100644
index 00000000..cbf2d08a
--- /dev/null
+++ b/integrations/wechat-entree/src/misc/message-handlers.ts
@@ -0,0 +1,210 @@
+// For outgoing messages to WeChat
+
+import { RuntimeError } from '@botpress/client'
+import * as bp from '.botpress'
+import { getAccessToken, WECHAT_API_BASE } from './wechat-api'
+
+export type MessageHandlerProps = bp.MessageProps['channel'][T]
+
+const DEFAULT_FETCH_TIMEOUT_MS = 15000
+const MAX_MEDIA_BYTES = 10 * 1024 * 1024
+
+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
+
+type WeChatSendResponse = { errcode?: number; errmsg?: string; msgid?: string; msg_id?: string; message_id?: string }
+type SendMessageProps = {
+ ctx: bp.Context
+ conversation: { tags: Record }
+ ack: (props: { tags: { id: string } }) => Promise
+ message: WeChatOutgoingMessage
+ accessToken?: string
+}
+// wechat require download media and upload media to WeChat, so we need to timeout the request if something goes wrong
+function fetchTimeout(url: string, init: RequestInit = {}, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS): Promise {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
+
+ return fetch(url, { ...init, signal: controller.signal }).finally(() => {
+ clearTimeout(timeoutId)
+ })
+}
+
+function createAckId(prefix: string): string {
+ const randomPart = Math.random().toString(36).slice(2, 10)
+ return `${prefix}-${Date.now()}-${randomPart}`
+}
+
+function getAckIdFromSendResponse(response: WeChatSendResponse, fallbackPrefix: string): string {
+ return response.msgid || response.msg_id || response.message_id || createAckId(fallbackPrefix)
+}
+
+// Upload media to WeChat cloud (returns media_id)
+// for image and video messages
+async function uploadWeChatMedia(
+ accessToken: string,
+ mediaUrl: string,
+ mediaType: 'image' | 'voice' | 'video'
+): Promise {
+ const mediaResponse = await fetchTimeout(mediaUrl)
+ if (!mediaResponse.ok) {
+ throw new RuntimeError(
+ `Failed to download media from URL: ${mediaUrl} (${mediaResponse.status} ${mediaResponse.statusText})`
+ )
+ }
+
+ const contentLengthHeader = mediaResponse.headers.get('content-length')
+ const contentLength = contentLengthHeader ? Number(contentLengthHeader) : Number.NaN
+
+ if (Number.isFinite(contentLength) && contentLength > 0) {
+ if (contentLength > MAX_MEDIA_BYTES) {
+ throw new RuntimeError(`Media exceeds max size of ${MAX_MEDIA_BYTES} bytes`)
+ }
+ }
+
+ const mediaBuffer = await mediaResponse.arrayBuffer()
+ const contentTypeHeader = mediaResponse.headers.get('content-type')
+ const contentType = typeof contentTypeHeader === 'string' ? contentTypeHeader : ''
+ const mediaBlob = new Blob([mediaBuffer], contentType ? { type: contentType } : undefined)
+
+ // fix media type to file extension otherwise wechat will not accept the media
+ const extensionByContentType: Record = {
+ 'image/jpeg': 'jpg',
+ 'image/jpg': 'jpg',
+ 'image/png': 'png',
+ 'image/gif': 'gif',
+ 'image/webp': 'webp',
+ 'image/bmp': 'bmp',
+ }
+
+ const baseContentType = (contentType.split(';')[0] || '').trim()
+ const fileExtension = extensionByContentType[baseContentType] || 'jpg'
+
+ const formData = new FormData()
+ formData.append('media', mediaBlob, `media.${fileExtension}`)
+
+ //post the media to WeChat cloud
+ const uploadUrl = `${WECHAT_API_BASE}/media/upload?access_token=${accessToken}&type=${mediaType}`
+ const uploadResponse = await fetchTimeout(uploadUrl, {
+ method: 'POST',
+ body: formData,
+ })
+ if (!uploadResponse.ok) {
+ throw new RuntimeError(`Failed to upload media to WeChat: ${uploadResponse.status} ${uploadResponse.statusText}`)
+ }
+
+ const uploadData = (await uploadResponse.json()) as { media_id?: string; errcode?: number; errmsg?: string }
+
+ if (uploadData.errcode && uploadData.errcode !== 0) {
+ throw new RuntimeError(`Failed to upload media to WeChat: ${uploadData.errmsg} (code: ${uploadData.errcode})`)
+ }
+
+ if (!uploadData.media_id) {
+ throw new RuntimeError('Failed to upload media to WeChat: missing media_id')
+ }
+
+ return uploadData.media_id
+}
+
+// send message to WeChat user, with no 5 seconds limit
+async function sendWeChatMessage(
+ accessToken: string,
+ toUser: string,
+ message: WeChatOutgoingMessage
+): Promise {
+ const url = `${WECHAT_API_BASE}/message/custom/send?access_token=${accessToken}`
+
+ const response = await fetchTimeout(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ touser: toUser,
+ ...message,
+ }),
+ })
+ if (!response.ok) {
+ throw new RuntimeError(`Failed to send WeChat message: ${response.status} ${response.statusText}`)
+ }
+
+ const data = (await response.json()) as WeChatSendResponse
+
+ if (data.errcode && data.errcode !== 0) {
+ throw new RuntimeError(`Failed to send WeChat message: ${data.errmsg} (code: ${data.errcode})`)
+ }
+
+ return data
+}
+
+// get chat ID (WeChat user OpenID) from conversation tags
+function getWeChatChatId(conversation: { tags: Record }): string {
+ const chatId = conversation.tags?.id || conversation.tags?.chatId
+ if (!chatId) {
+ throw new RuntimeError('Conversation does not have a WeChat chat ID')
+ }
+ return chatId
+}
+
+// Acknowledge message - ack is a function that takes tags
+async function acknowledgeMessage(
+ ack: (props: { tags: { id: string } }) => Promise,
+ messageId: string
+): Promise {
+ await ack({ tags: { id: messageId } })
+}
+
+async function sendMessage({ ctx, conversation, ack, message, accessToken }: SendMessageProps): Promise {
+ const chatId = getWeChatChatId(conversation)
+ const token = accessToken || (await getAccessToken(ctx.configuration.appId, ctx.configuration.appSecret))
+ const sendResponse = await sendWeChatMessage(token, chatId, message)
+
+ // Botpress ack expects a unique tag; fall back when API doesn't return a message id.
+ const ackId = getAckIdFromSendResponse(sendResponse, 'wechat')
+ await acknowledgeMessage(ack, ackId)
+}
+
+export const handleTextMessage = async (props: MessageHandlerProps<'text'>) => {
+ const { payload, ctx, conversation, ack } = props
+ const { text } = payload
+ await sendMessage({
+ ctx,
+ conversation,
+ ack,
+ message: {
+ msgtype: 'text',
+ text: { content: text },
+ },
+ })
+}
+
+export const handleImageMessage = async ({ payload, ctx, conversation, ack }: MessageHandlerProps<'image'>) => {
+ // handle image messages with tencent cloud url
+ const accessToken = await getAccessToken(ctx.configuration.appId, ctx.configuration.appSecret)
+
+ const mediaId = await uploadWeChatMedia(accessToken, payload.imageUrl, 'image')
+ await sendMessage({
+ ctx,
+ conversation,
+ ack,
+ accessToken,
+ message: {
+ msgtype: 'image',
+ image: { media_id: mediaId },
+ },
+ })
+}
+
+export const handleVideoMessage = async ({ payload, ctx, conversation, ack }: MessageHandlerProps<'video'>) => {
+ // not implemented yet
+ // Send video URL as text (simplified - no media upload)
+ await sendMessage({
+ ctx,
+ conversation,
+ ack,
+ message: {
+ msgtype: 'text',
+ text: { content: `[Video] ${payload.videoUrl}` },
+ },
+ })
+}
diff --git a/integrations/wechat-entree/src/misc/wechat-api.ts b/integrations/wechat-entree/src/misc/wechat-api.ts
new file mode 100644
index 00000000..ecc123be
--- /dev/null
+++ b/integrations/wechat-entree/src/misc/wechat-api.ts
@@ -0,0 +1,51 @@
+import { RuntimeError } from '@botpress/client'
+
+// WeChat API base URL
+export const WECHAT_API_BASE = 'https://api.weixin.qq.com/cgi-bin'
+
+// Get access token from WeChat API
+export async function getAccessToken(appId: string, appSecret: string): Promise {
+ const url = `${WECHAT_API_BASE}/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`
+ const response = await fetch(url)
+ const data = (await response.json()) as { access_token?: string; errcode?: number; errmsg?: string }
+
+ if (data.errcode) {
+ throw new RuntimeError(`Failed to get WeChat access token: ${data.errmsg}`)
+ }
+
+ return data.access_token!
+}
+
+export function getMediaUrl(accessToken: string, mediaId: string): string {
+ return `${WECHAT_API_BASE}/media/get?access_token=${accessToken}&media_id=${mediaId}`
+}
+
+export async function downloadWeChatMedia(
+ accessToken: string,
+ mediaId: string
+): Promise<{ content: ArrayBuffer; contentType?: string }> {
+ const mediaUrl = getMediaUrl(accessToken, mediaId)
+ return await downloadWeChatMediaFromUrl(mediaUrl)
+}
+
+async function downloadWeChatMediaFromUrl(url: string): Promise<{ content: ArrayBuffer; contentType?: string }> {
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new RuntimeError(`Failed to download WeChat media: ${response.status} ${response.statusText}`)
+ }
+
+ const contentType = response.headers.get('content-type') || undefined
+ if (contentType?.includes('application/json')) {
+ const data = (await response.json()) as { video_url?: string; errcode?: number; errmsg?: string }
+ if (data.errcode) {
+ throw new RuntimeError(`Failed to download WeChat media: ${data.errmsg}`)
+ }
+ if (!data.video_url) {
+ throw new RuntimeError('Failed to download WeChat media: missing video_url')
+ }
+ return await downloadWeChatMediaFromUrl(data.video_url)
+ }
+
+ const content = await response.arrayBuffer()
+ return { content, contentType }
+}
diff --git a/integrations/wechat-entree/src/wechat-handler.ts b/integrations/wechat-entree/src/wechat-handler.ts
new file mode 100644
index 00000000..42a5df7c
--- /dev/null
+++ b/integrations/wechat-entree/src/wechat-handler.ts
@@ -0,0 +1,174 @@
+import crypto from 'crypto'
+
+// ============== SHA1 Signature Verification ==============
+// wechat really requires the signature to be in the order of token, timestamp, nonce
+function computeWeChatSignature(...args: string[]): string {
+ const sortedList = [...args].sort()
+
+ const sha1 = crypto.createHash('sha1')
+ sortedList.forEach((item) => sha1.update(item, 'utf8'))
+
+ return sha1.digest('hex')
+}
+
+// ============== XML Parsing ================================================
+// Example of the XML we receive from WeChat:
+//
+//
+//
+// 1460537339
+//
+//
+// 6272960105994287618
+//
+
+interface WeChatMessage {
+ ToUserName: string
+ FromUserName: string
+ CreateTime: string
+ MsgType: string
+ MsgId?: string
+ Content?: string // for text messages
+ PicUrl?: string // for image messages
+ MediaId?: string // for image/voice/video messages
+ Recognition?: string // for voice messages (speech recognition)
+ Location_X?: string // for location messages
+ Location_Y?: string
+ Label?: string
+ Title?: string // for link messages
+ Description?: string
+ Url?: string
+}
+
+function parseWeChatMessageXml(webData: string): WeChatMessage | null {
+ if (!webData || webData.length === 0) {
+ return null
+ }
+ // parse the message from the xml
+ const extractValue = (xml: string, tag: string): string | undefined => {
+ // find pattern of CDATA or plain text: or value
+ const valueRegex = new RegExp(`<${tag}>\\s*(?:)?\\s*${tag}>`, 'i')
+ const match = xml.match(valueRegex)
+ return match?.[1]
+ }
+
+ const toUserName = extractValue(webData, 'ToUserName')
+ const fromUserName = extractValue(webData, 'FromUserName')
+ const msgType = extractValue(webData, 'MsgType')
+
+ // Drop malformed XML early to avoid empty IDs downstream.
+ if (!toUserName || !fromUserName || !msgType) {
+ return null
+ }
+
+ const msg: WeChatMessage = {
+ ToUserName: toUserName,
+ FromUserName: fromUserName,
+ CreateTime: extractValue(webData, 'CreateTime') || '',
+ MsgType: msgType,
+ MsgId: extractValue(webData, 'MsgId'),
+ Content: extractValue(webData, 'Content'),
+ PicUrl: extractValue(webData, 'PicUrl'),
+ MediaId: extractValue(webData, 'MediaId'),
+ Recognition: extractValue(webData, 'Recognition'),
+ Location_X: extractValue(webData, 'Location_X'),
+ Location_Y: extractValue(webData, 'Location_Y'),
+ Label: extractValue(webData, 'Label'),
+ Title: extractValue(webData, 'Title'),
+ Description: extractValue(webData, 'Description'),
+ Url: extractValue(webData, 'Url'),
+ }
+
+ return msg
+}
+
+// ============== Main Handler ==============
+export interface WeChatVerification {
+ wechatToken: string
+ method: string
+ signature?: string
+ timestamp?: string
+ nonce?: string
+ echostr?: string
+ body?: string
+}
+
+export interface ResponseToWeChat {
+ status: number
+ contentType: string
+ body: string
+ message?: WeChatMessage
+}
+
+export function handleWechatSignatureVerificaation(params: WeChatVerification): ResponseToWeChat {
+ const { wechatToken, method, signature, timestamp, nonce, echostr, body } = params
+ const normalizedMethod = method.toUpperCase()
+
+ // ========== Handle GET (Webhook Verification) ==========
+ if (normalizedMethod === 'GET') {
+ // WeChat signature is SHA1(sorted(token, timestamp, nonce)).
+ const hashcode = computeWeChatSignature(wechatToken, timestamp || '', nonce || '')
+ if (hashcode === signature) {
+ return {
+ status: 200,
+ contentType: 'text/plain',
+ body: echostr || '',
+ }
+ } else {
+ //if credentials are incorrect, return empty body, wechat will not be verified
+ return {
+ status: 200,
+ contentType: 'text/plain',
+ body: '', // empty body, wechat will not be verified
+ }
+ }
+ }
+
+ // ========== Handle POST (Message Receive) ==========
+ if (normalizedMethod === 'POST') {
+ if (!signature || !timestamp || !nonce) {
+ return {
+ status: 401,
+ contentType: 'text/plain',
+ body: '',
+ }
+ }
+
+ // WeChat signature is SHA1(sorted(token, timestamp, nonce)).
+ const hashcode = computeWeChatSignature(wechatToken, timestamp, nonce)
+ if (hashcode !== signature) {
+ return {
+ status: 403,
+ contentType: 'text/plain',
+ body: '',
+ }
+ }
+
+ // Parse the incoming XML
+ const recMsg = parseWeChatMessageXml(body || '')
+
+ if (!recMsg) {
+ return {
+ status: 200,
+ contentType: 'text/plain',
+ body: 'success',
+ }
+ }
+
+ // Return the parsed message to be handled by Botpress
+ // We return 'success' to tell WeChat we received the message
+ return {
+ status: 200,
+ contentType: 'text/plain',
+ body: 'success',
+ message: recMsg,
+ }
+ }
+
+ // Other methods not allowed
+ return {
+ status: 405,
+ contentType: 'text/plain',
+ body: 'Method not allowed',
+ }
+}
diff --git a/integrations/wechat-entree/tsconfig.json b/integrations/wechat-entree/tsconfig.json
new file mode 100644
index 00000000..462fc49c
--- /dev/null
+++ b/integrations/wechat-entree/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "lib": ["es2022"],
+ "module": "commonjs",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "moduleResolution": "node",
+ "allowUnusedLabels": false,
+ "allowUnreachableCode": false,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": true,
+ "noImplicitReturns": true,
+ "noUncheckedIndexedAccess": true,
+ "noUnusedParameters": true,
+ "target": "es2017",
+ "baseUrl": ".",
+ "outDir": "dist",
+ "checkJs": false,
+ "exactOptionalPropertyTypes": false,
+ "resolveJsonModule": true,
+ "noPropertyAccessFromIndexSignature": false,
+ "noUnusedLocals": false
+ },
+ "include": [".botpress/**/*", "src/**/*", "*.ts", "*.json"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 761f9947..8dc37bff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -161,6 +161,28 @@ importers:
specifier: ^5.8.2
version: 5.9.3
+ integrations/wechat-entree:
+ dependencies:
+ '@botpress/client':
+ specifier: 1.27.1
+ version: 1.27.1
+ '@botpress/sdk':
+ specifier: 4.21.0
+ version: 4.21.0(@bpinternal/zui@1.2.2)(esbuild@0.25.12)
+ prettier:
+ specifier: ^3.6.2
+ version: 3.6.2
+ devDependencies:
+ '@botpress/cli':
+ specifier: 4.27.4
+ version: 4.27.4(@bpinternal/zui@1.2.2)
+ '@types/node':
+ specifier: ^22.16.4
+ version: 22.19.7
+ typescript:
+ specifier: ^5.6.3
+ version: 5.9.3
+
integrations/zoom:
dependencies:
'@botpress/client':
@@ -367,6 +389,11 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
+ '@botpress/cli@4.27.4':
+ resolution: {integrity: sha512-dC+T3dzUgxilLWsE38VaRLndd5uevKAqRBsppgtrBBO3vsSzzIwA5jhZ1p4Gh6xiydKdvQ6cy6soSO/6+q+t8Q==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
'@botpress/client@0.41.0':
resolution: {integrity: sha512-FAIY8M12D1jZV9K1bN2zPorFF7Nw3RZyLn9DzTEuwP958Ms2wv1pW3VH4xaCoXVIQr61amsjmFY5gEeTSyhLQA==}
@@ -390,6 +417,10 @@ packages:
resolution: {integrity: sha512-c6EDjUphVehm6WIZse4qTHaYL0oudObrdGnRg6r2040lNIF+nFl7e24EvbO5fB4pxp1/ApoQMPJaPA5nJVOVLg==}
engines: {node: '>=18.0.0'}
+ '@botpress/client@1.27.2':
+ resolution: {integrity: sha512-fTP42EDRnhRZDdRFAUQlMUDQ0CxMYchnhDsT/4Luubz6n8gKea9eBz5t0UngCzD98Eat5D+UbnfkAR89IQDWpA==}
+ engines: {node: '>=18.0.0'}
+
'@botpress/sdk@4.13.0':
resolution: {integrity: sha512-qhw5r/0XvXVk99l461O6vFpKqX3spcp9zNLtiz2/yUiz2ECoMGiT74zv32nMDZH9EIYNIiz/458WRhKQ6AH6dA==}
engines: {node: '>=18.0.0'}
@@ -420,6 +451,16 @@ packages:
esbuild:
optional: true
+ '@botpress/sdk@4.21.0':
+ resolution: {integrity: sha512-Jj6FgVFI6uE5vrgf6FcZhbDXRlQkgRti54Zodm+V49vQx/Uym3Ro1ifZmcfcrNWfp8HcJ840yEcKnLJK5f3GtQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ '@bpinternal/zui': 1.2.1
+ esbuild: ^0.16.12
+ peerDependenciesMeta:
+ esbuild:
+ optional: true
+
'@botpress/sdk@4.9.0':
resolution: {integrity: sha512-c9IVd0Xn9y35bPqOds5OU+XCepIoSKzftNhCskKxdg26/MYUntFSM1N/nJ0RmsuhVuK/RP5vEy2JxHQWOa0VAg==}
engines: {node: '>=18.0.0'}
@@ -1501,6 +1542,9 @@ packages:
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
+ '@types/node@22.19.7':
+ resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
+
'@types/node@24.10.0':
resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==}
@@ -4166,6 +4210,9 @@ packages:
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -4945,6 +4992,44 @@ snapshots:
- encoding
- utf-8-validate
+ '@botpress/cli@4.27.4(@bpinternal/zui@1.2.2)':
+ dependencies:
+ '@apidevtools/json-schema-ref-parser': 11.9.3
+ '@botpress/chat': 0.5.4
+ '@botpress/client': 1.27.2
+ '@botpress/sdk': 4.21.0(@bpinternal/zui@1.2.2)(esbuild@0.25.12)
+ '@bpinternal/const': 0.1.2
+ '@bpinternal/tunnel': 0.1.25
+ '@bpinternal/verel': 0.2.0
+ '@bpinternal/yargs-extra': 0.0.3
+ '@parcel/watcher': 2.5.1
+ '@stoplight/spectral-core': 1.20.0
+ '@stoplight/spectral-functions': 1.10.1
+ '@stoplight/spectral-parsers': 1.0.5
+ '@types/lodash': 4.17.20
+ '@types/verror': 1.10.11
+ axios: 1.13.2
+ bluebird: 3.7.2
+ boxen: 5.1.2
+ chalk: 4.1.2
+ dotenv: 16.6.1
+ esbuild: 0.25.12
+ handlebars: 4.7.8
+ latest-version: 5.1.0
+ lodash: 4.17.21
+ prettier: 3.6.2
+ prompts: 2.4.2
+ semver: 7.7.3
+ uuid: 9.0.1
+ verror: 1.10.1
+ yn: 4.0.0
+ transitivePeerDependencies:
+ - '@bpinternal/zui'
+ - bufferutil
+ - debug
+ - encoding
+ - utf-8-validate
+
'@botpress/client@0.41.0':
dependencies:
axios: 1.13.1
@@ -5000,6 +5085,15 @@ snapshots:
transitivePeerDependencies:
- debug
+ '@botpress/client@1.27.2':
+ dependencies:
+ axios: 1.13.2
+ axios-retry: 4.5.0(axios@1.13.2)
+ browser-or-node: 2.1.1
+ qs: 6.14.0
+ transitivePeerDependencies:
+ - debug
+
'@botpress/sdk@4.13.0(@bpinternal/zui@1.2.2)(esbuild@0.16.17)':
dependencies:
'@botpress/client': 1.16.1
@@ -5031,6 +5125,17 @@ snapshots:
transitivePeerDependencies:
- debug
+ '@botpress/sdk@4.21.0(@bpinternal/zui@1.2.2)(esbuild@0.25.12)':
+ dependencies:
+ '@botpress/client': 1.27.2
+ '@bpinternal/zui': 1.2.2
+ browser-or-node: 2.1.1
+ semver: 7.7.3
+ optionalDependencies:
+ esbuild: 0.25.12
+ transitivePeerDependencies:
+ - debug
+
'@botpress/sdk@4.9.0(@bpinternal/zui@1.2.2)(esbuild@0.16.17)':
dependencies:
'@botpress/client': 1.15.2
@@ -5930,7 +6035,7 @@ snapshots:
dependencies:
'@stoplight/json': 3.21.7
'@stoplight/path': 1.3.2
- '@stoplight/types': 13.6.0
+ '@stoplight/types': 13.20.0
'@types/urijs': 1.19.26
dependency-graph: 0.11.0
fast-memoize: 2.5.2
@@ -6087,7 +6192,7 @@ snapshots:
'@types/es-aggregate-error@1.0.6':
dependencies:
- '@types/node': 18.19.130
+ '@types/node': 22.19.7
'@types/estree@1.0.8': {}
@@ -6102,7 +6207,7 @@ snapshots:
'@types/keyv@3.1.4':
dependencies:
- '@types/node': 18.19.130
+ '@types/node': 22.19.7
'@types/lodash@4.17.20': {}
@@ -6114,6 +6219,10 @@ snapshots:
dependencies:
undici-types: 5.26.5
+ '@types/node@22.19.7':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/node@24.10.0':
dependencies:
undici-types: 7.16.0
@@ -6122,7 +6231,7 @@ snapshots:
'@types/responselike@1.0.3':
dependencies:
- '@types/node': 18.19.130
+ '@types/node': 22.19.7
'@types/triple-beam@1.3.5': {}
@@ -6132,7 +6241,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
- '@types/node': 18.19.130
+ '@types/node': 22.19.7
'@types/yargs-parser@21.0.3': {}
@@ -9084,6 +9193,8 @@ snapshots:
undici-types@5.26.5: {}
+ undici-types@6.21.0: {}
+
undici-types@7.16.0: {}
undici@5.29.0: