Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/services/im-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 联调可先运行:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,24 @@ export class WecomAibotWssRuntime {
}

private sendMessage(chatId: string, body: Readonly<Record<string, JsonValue>>): Promise<ImSendAcceptance> {
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<Record<string, JsonValue>> }>,
): Promise<ImSendAcceptance> {
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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>): void {
const headers = frame.headers;
if (headers === null || typeof headers !== 'object') return;
const requestId = (headers as Record<string, unknown>).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<string, unknown>): boolean {
const headers = frame.headers;
return (
Expand Down
62 changes: 62 additions & 0 deletions services/im-gateway/test/wecom-aibot-wss-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Loading