diff --git a/index.js b/index.js index 88d2d00..2a53e41 100755 --- a/index.js +++ b/index.js @@ -70,6 +70,7 @@ reactions: false, threads: false, media: true, + audio: true, nativeCommands: false, blockStreaming: true, // WeCom AI Bot requires stream-style responses. }, diff --git a/wecom-api.js b/wecom-api.js index c47dd42..029f7e9 100755 --- a/wecom-api.js +++ b/wecom-api.js @@ -76,8 +76,88 @@ }); const data = await res.json(); if (data.errcode !== 0) { - logger.error("WeCom API: message/send failed", { error: data.errmsg, code: data.errcode, corpId, agentId, toUser }); - throw new Error(`WeCom message/send failed: ${data.errmsg} (${data.errcode})`); + logger.error("WeCom API: message/send text failed", { error: data.errmsg, code: data.errcode, corpId, agentId, toUser }); + throw new Error(`WeCom message/send text failed: ${data.errmsg} (${data.errcode})`); + } + return data; + } + + async sendImageMessage(corpId, corpSecret, agentId, toUser, mediaId) { + logger.debug("WeCom API: Sending async image message", { corpId, agentId, toUser, mediaId }); + const token = await this.getAccessToken(corpId, corpSecret); + const res = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${token}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + touser: toUser, + msgtype: "image", + agentid: agentId, + image: { media_id: mediaId }, + }), + }); + const data = await res.json(); + if (data.errcode !== 0) { + throw new Error(`WeCom message/send image failed: ${data.errmsg} (${data.errcode})`); + } + return data; + } + + async sendVoiceMessage(corpId, corpSecret, agentId, toUser, mediaId) { + logger.debug("WeCom API: Sending async voice message", { corpId, agentId, toUser, mediaId }); + const token = await this.getAccessToken(corpId, corpSecret); + const res = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${token}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + touser: toUser, + msgtype: "voice", + agentid: agentId, + voice: { media_id: mediaId }, + }), + }); + const data = await res.json(); + if (data.errcode !== 0) { + throw new Error(`WeCom message/send voice failed: ${data.errmsg} (${data.errcode})`); + } + return data; + } + + async sendVideoMessage(corpId, corpSecret, agentId, toUser, mediaId) { + logger.debug("WeCom API: Sending async video message", { corpId, agentId, toUser, mediaId }); + const token = await this.getAccessToken(corpId, corpSecret); + const res = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${token}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + touser: toUser, + msgtype: "video", + agentid: agentId, + video: { media_id: mediaId }, + }), + }); + const data = await res.json(); + if (data.errcode !== 0) { + throw new Error(`WeCom message/send video failed: ${data.errmsg} (${data.errcode})`); + } + return data; + } + + async sendFileMessage(corpId, corpSecret, agentId, toUser, mediaId) { + logger.debug("WeCom API: Sending async file message", { corpId, agentId, toUser, mediaId }); + const token = await this.getAccessToken(corpId, corpSecret); + const res = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${token}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + touser: toUser, + msgtype: "file", + agentid: agentId, + file: { media_id: mediaId }, + }), + }); + const data = await res.json(); + if (data.errcode !== 0) { + throw new Error(`WeCom message/send file failed: ${data.errmsg} (${data.errcode})`); } return data; } diff --git a/wecom-message-processor.js b/wecom-message-processor.js index 37f7c60..1a730ae 100755 --- a/wecom-message-processor.js +++ b/wecom-message-processor.js @@ -487,6 +487,7 @@ logger.info("WeCom Self-Built (Async): deliver called", { kind: info.kind, hasText: !!(payload.text && payload.text.trim()), + hasMediaUrl: !!payload.mediaUrl, textPreview: (payload.text || "").substring(0, 50), }); const text = payload.text || ""; @@ -499,10 +500,45 @@ mediaMatches.push({ fullMatch: match[0], path: mediaPath }); } } + + if (payload.mediaUrl) { + mediaMatches.push({ + fullMatch: "", // It's not embedded in text + path: payload.mediaUrl, + }); + } let processedText = text; + if (mediaMatches.length === 1 && !text.replace(mediaMatches[0].fullMatch, "").trim()) { + // Single media item, no other text. Send active media message. + const media = mediaMatches[0]; + const type = getWecomMediaType(media.path); + try { + logger.info("WeCom Self-Built (Async): uploading single media for active reply", { path: media.path, type }); + const uploadRes = await wecomApi.uploadMedia(account.corpId, account.corpSecret, type, media.path); + const mediaId = uploadRes.media_id; + + // Active API sends + if (type === 'image') { + await wecomApi.sendImageMessage?.(account.corpId, account.corpSecret, message.agentId || account.agentId || 1000002, senderId, mediaId); + } else if (type === 'voice') { + await wecomApi.sendVoiceMessage?.(account.corpId, account.corpSecret, message.agentId || account.agentId || 1000002, senderId, mediaId); + } else if (type === 'video') { + await wecomApi.sendVideoMessage?.(account.corpId, account.corpSecret, message.agentId || account.agentId || 1000002, senderId, mediaId); + } else { + await wecomApi.sendFileMessage?.(account.corpId, account.corpSecret, message.agentId || account.agentId || 1000002, senderId, mediaId); + } + logger.info(`WeCom Self-Built (Async): sent active ${type} message`, { mediaId }); + return; + } catch (err) { + logger.error("Failed to upload/send media for active reply", { error: err.message, path: media.path }); + // Fallback to text below + } + } + for (const media of mediaMatches) { - processedText = processedText.replace(media.fullMatch, `[图片: ${media.path}]`).trim(); + const type = getWecomMediaType(media.path); + processedText = processedText.replace(media.fullMatch, `[${type === 'voice' ? '语音' : type === 'video' ? '视频' : type === 'image' ? '图片' : '文件'}: ${media.path}]`).trim(); } if (processedText.trim() || mediaMatches.length > 0) { @@ -525,50 +561,54 @@ return { passiveReplyXml: initialReplyXml }; } else { // Sync mode: Wait for reply and then send it as passive reply - let capturedReplyXml = null; - let fullText = ""; - let errorOccurred = false; + let capturedReplyXml = null; + let fullText = ""; + let fullMediaUrl = ""; + let errorOccurred = false; - await replyApi.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg: config, - dispatcherOptions: { - deliver: async (payload, info) => { - logger.info("WeCom Self-Built (Sync): deliver called", { - kind: info.kind, - hasText: !!(payload.text && payload.text.trim()), + await replyApi.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload, info) => { + logger.info("WeCom Self-Built (Sync): deliver called", { + kind: info.kind, + hasText: !!(payload.text && payload.text.trim()), + hasMediaUrl: !!payload.mediaUrl, + }); + if (payload.text) { + fullText += payload.text; + } + if (payload.mediaUrl) { + fullMediaUrl = payload.mediaUrl; + } + }, + onError: async (err, info) => { + errorOccurred = true; + logger.error("WeCom Self-Built App (Sync): error during dispatch", { error: err.message, kind: info?.kind }); + const webhook = new WecomWebhook({ token: account.token, encodingAesKey: account.encodingAesKey }); + capturedReplyXml = webhook.buildPassiveReplyXml( + message.fromUser || message.chatId || message.ToUserName, + message.ToUserName, + "text", + "处理消息时出错,请稍后再试。", + Math.floor(Date.now() / 1000), + _nonce, + ); + } + } }); - if (payload.text) { - fullText += payload.text; - } - }, - onError: async (err, info) => { - errorOccurred = true; - logger.error("WeCom Self-Built App (Sync): error during dispatch", { error: err.message, kind: info?.kind }); - const webhook = new WecomWebhook({ token: account.token, encodingAesKey: account.encodingAesKey }); - capturedReplyXml = webhook.buildPassiveReplyXml( - message.fromUser || message.chatId || message.ToUserName, - message.ToUserName, - "text", - "处理消息时出错,请稍后再试。", - Math.floor(Date.now() / 1000), - _nonce, - ); - } - } - }); - - if (!errorOccurred) { - logger.info('WeCom Sync: Final accumulated text', { text: fullText }); - const result = await deliverWecomReply({ - payload: { text: fullText }, - senderId: streamKey, - streamId: undefined, - isSelfBuiltAppRequest, - originalMessage: message, - account, - }); - if (result?.passiveReplyXml) { + + if (!errorOccurred) { + logger.info('WeCom Sync: Final accumulated text', { text: fullText, mediaUrl: fullMediaUrl }); + const result = await deliverWecomReply({ + payload: { text: fullText, mediaUrl: fullMediaUrl }, + senderId: streamKey, + streamId: undefined, + isSelfBuiltAppRequest, + originalMessage: message, + account, + }); if (result?.passiveReplyXml) { capturedReplyXml = result.passiveReplyXml; } } @@ -648,6 +688,7 @@ logger.debug("deliverWecomReply called", { hasText: !!text.trim(), + hasMediaUrl: !!payload.mediaUrl, textPreview: text.substring(0, 50), streamId, senderId, @@ -672,6 +713,13 @@ } } + if (payload.mediaUrl) { + mediaMatches.push({ + fullMatch: "", // It's not embedded in text + path: payload.mediaUrl, + }); + } + let processedText = text; if (isSelfBuiltAppRequest && mediaMatches.length === 1 && !text.replace(mediaMatches[0].fullMatch, "").trim()) { // Single media item, no other text. Use passive media reply.