diff --git a/docs/services/im-gateway.md b/docs/services/im-gateway.md index a96f4bc9..7a4bea73 100644 --- a/docs/services/im-gateway.md +++ b/docs/services/im-gateway.md @@ -86,7 +86,9 @@ Gateway 端口绑定到宿主机 loopback,避免设备 API 绕过公网 HTTPS 最高间隔 30 秒。停机时会停止心跳并取消重连。异常回退时移除全部 `WECOM_AIBOT_*` 变量并重启 Gateway;已存在的 微信公众号账号和 HTTP 路由不受影响。WSS Runtime 可发送 Markdown 和按钮交互模板卡片,并等待企业微信的即时受理结果; 模板卡片按钮 key 只携带短期加密动作令牌,收到 `aibot_event_callback` 后会先解析已绑定的外部身份,再交给 -`ActionApplication` 做动作、过期和幂等校验。企业微信后台必须具备模板卡片交互回调能力;真实机器人点击和送达回执仍需独立联调。 +`ActionApplication` 做动作、过期和幂等校验。动作受理成功后,Runtime 会在企业微信要求的 5 秒窗口内通过 +`aibot_respond_update_msg` 把卡片更新为“正在同步到设备”,避免重复操作。企业微信后台必须具备模板卡片交互回调能力; +真实机器人点击和送达回执仍需独立联调。 监听器使用 HTTP,公网 HTTPS 必须由宿主机上的 Cloudflare Tunnel 或反向代理终止 TLS。Quick Tunnel 联调可先运行: diff --git a/services/im-gateway/src/infrastructure/wecom/wecom-aibot-wss-runtime.ts b/services/im-gateway/src/infrastructure/wecom/wecom-aibot-wss-runtime.ts index 4901afed..1e5efba7 100644 --- a/services/im-gateway/src/infrastructure/wecom/wecom-aibot-wss-runtime.ts +++ b/services/im-gateway/src/infrastructure/wecom/wecom-aibot-wss-runtime.ts @@ -211,11 +211,24 @@ export class WecomAibotWssRuntime { } private sendMessage(chatId: string, body: Readonly>): Promise { + const requestId = this.nextRequestId(); + return this.sendRequest(requestId, { + cmd: 'aibot_send_msg', + body: { + chatid: chatId, + ...body, + }, + }); + } + + private sendRequest( + requestId: string, + frame: Readonly<{ readonly cmd: string; readonly body: Readonly> }>, + ): Promise { const socket = this.socket; - if (!this.healthy || socket === undefined || this.closed) { + if (!this.healthy || socket === undefined || this.closed || this.outboundRequests.has(requestId)) { return Promise.resolve({ accepted: false, retryable: true, errorCode: 'wecom_aibot_unavailable' }); } - const requestId = this.nextRequestId(); return new Promise((resolve) => { const timeout = setTimeout(() => { if (!this.outboundRequests.delete(requestId)) return; @@ -225,12 +238,9 @@ export class WecomAibotWssRuntime { try { socket.send( JSON.stringify({ - cmd: 'aibot_send_msg', + cmd: frame.cmd, headers: { req_id: requestId }, - body: { - chatid: chatId, - ...body, - }, + body: frame.body, }), ); } catch { @@ -285,12 +295,34 @@ export class WecomAibotWssRuntime { if (this.settleOutboundResponse(frame)) return; if (!this.healthy || (frame.cmd !== 'aibot_msg_callback' && frame.cmd !== 'aibot_event_callback')) return; try { - await this.options.postEvent(await this.options.adapter.normalizeInbound(frame.body)); + const event = await this.options.adapter.normalizeInbound(frame.body); + await this.options.postEvent(event); + if (frame.cmd === 'aibot_event_callback' && event.type === 'action.triggered') { + this.updateAcceptedTemplateCard(frame); + } } catch { // Untrusted platform input must not terminate the connection loop. } } + private updateAcceptedTemplateCard(frame: Record): void { + const headers = frame.headers; + if (headers === null || typeof headers !== 'object') return; + const requestId = (headers as Record).req_id; + if (typeof requestId !== 'string' || requestId.trim() === '') return; + void this.sendRequest(requestId, { + cmd: 'aibot_respond_update_msg', + body: { + response_type: 'update_template_card', + template_card: { + card_type: 'button_interaction', + main_title: { title: '操作已受理', desc: '正在同步到设备' }, + button_list: [], + }, + }, + }); + } + private isSubscriptionResponse(frame: Record): boolean { const headers = frame.headers; return ( diff --git a/services/im-gateway/test/wecom-aibot-wss-runtime.test.mjs b/services/im-gateway/test/wecom-aibot-wss-runtime.test.mjs index 9844d21a..f891cd55 100644 --- a/services/im-gateway/test/wecom-aibot-wss-runtime.test.mjs +++ b/services/im-gateway/test/wecom-aibot-wss-runtime.test.mjs @@ -129,6 +129,68 @@ test('WeCom AI Bot WSS runtime posts template card click events', async (context assert.equal(events[0].type, 'action.triggered'); assert.equal(events[0].externalIdentityId, 'identity-fixture'); assert.deepEqual(events[0].payload, { token: 'v1.token.fixture', action: 'acknowledge' }); + assert.deepEqual(socket.sent[1], { + cmd: 'aibot_respond_update_msg', + headers: { req_id: 'callback-fixture' }, + body: { + response_type: 'update_template_card', + template_card: { + card_type: 'button_interaction', + main_title: { title: '操作已受理', desc: '正在同步到设备' }, + button_list: [], + }, + }, + }); + socket.emit('message', { + data: JSON.stringify({ headers: { req_id: 'callback-fixture' }, errcode: 0 }), + }); + await runtime.close(); +}); + +test('WeCom AI Bot WSS runtime leaves a card actionable when the action is rejected', async (context) => { + const socket = new FakeWebSocket(); + const runtime = new WecomAibotWssRuntime({ + adapter: new WecomAibotInboundAdapter({ + channelAccountId: 'channel-wecom', + botId: 'bot-fixture', + resolveExternalIdentityId: async () => 'identity-fixture', + }), + botId: 'bot-fixture', + secret: 'secret-fixture', + postEvent: async () => { + throw new Error('action rejected'); + }, + createWebSocket: () => socket, + nextRequestId: () => 'request-fixture', + }); + context.after(() => runtime.close()); + + runtime.start(); + socket.emit('open', {}); + socket.emit('message', { + data: JSON.stringify({ headers: { req_id: 'request-fixture' }, errcode: 0 }), + }); + socket.emit('message', { + data: JSON.stringify({ + cmd: 'aibot_event_callback', + headers: { req_id: 'callback-fixture' }, + body: { + msgid: 'card-click-fixture', + create_time: 1_786_665_600, + aibotid: 'bot-fixture', + from: { userid: 'userid-fixture' }, + chattype: 'single', + msgtype: 'event', + event: { + eventtype: 'template_card_event', + event_key: 'voicelife-action:v1:v1.token.fixture:acknowledge:', + }, + }, + }), + }); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + + assert.equal(socket.sent.length, 1); await runtime.close(); });