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*`, '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: