mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
feat(weixin): show typing indicator during turns
This commit is contained in:
parent
ad3c08ace3
commit
cb34e3f8de
10 changed files with 948 additions and 200 deletions
|
|
@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added / 新增
|
||||
|
||||
- 微信机器人现在会在 Harness 处理消息期间显示“正在输入”,并在最终回复、交互问题、错误或任务停止前自动取消;输入状态接口不可用时仍会正常发送最终回答。
|
||||
WeChat bots now show a typing indicator while Harness processes a message and automatically cancel it before final replies, interaction questions, errors, or stopped turns; final answers still work when the typing API is unavailable.
|
||||
|
||||
## [2.4.0] - 2026-08-25
|
||||
|
||||
### Added / 新增
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
|||
| Channel | Setup | Messaging and replies |
|
||||
| --- | --- | --- |
|
||||
| Feishu | Create a bot by QR code, or bind one with App ID + App Secret | Persistent connection for incoming messages; streaming cards show thinking, tool progress, and replies |
|
||||
| WeChat | Scan a QR code to bind a WeChat bot | Tencent iLink long polling for sending and receiving messages; long replies are sent in 1,800-character chunks |
|
||||
| WeChat | Scan a QR code to bind a WeChat bot | Tencent iLink long polling; shows a typing indicator while Harness works, then sends the final reply in 1,800-character chunks |
|
||||
| DingTalk | Create a bot by QR code, or bind one with Client ID + Client Secret | DingTalk Stream connection; streaming replies through AI Cards |
|
||||
| WeCom | Create an intelligent bot by QR code, or bind one with Bot ID + Secret | Official WebSocket connection; native thinking state, tool progress, and streaming replies |
|
||||
| QQ | Create a bot with mobile QQ QR scanning, or bind one with AppID + AppSecret | WebSocket connection; private chats show typing and receive one Markdown reply, while mentioned group chats receive only the final answer |
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
|||
| 渠道 | 接入方式 | 消息与回复 |
|
||||
| --- | --- | --- |
|
||||
| 飞书 | 扫码创建机器人,或使用 App ID + App Secret 手动绑定 | 长连接接收消息;通过飞书流式卡片显示思考、工具进度和回答 |
|
||||
| 微信 | 使用微信扫码绑定机器人 | 腾讯 iLink 长轮询收发消息;长回复会按 1,800 字符分段发送 |
|
||||
| 微信 | 使用微信扫码绑定机器人 | 腾讯 iLink 长轮询收发消息;等待 Harness 回答时显示“正在输入”,最终回复按 1,800 字符分段发送 |
|
||||
| 钉钉 | 扫码创建机器人,或使用 Client ID + Client Secret 手动绑定 | 钉钉 Stream 长连接;通过 AI Card 流式显示回答 |
|
||||
| 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 |
|
||||
| QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”并以单条 Markdown 回复,群聊被 @ 后只发送最终答案 |
|
||||
|
|
|
|||
378
lib/index.js
378
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -637,6 +637,53 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|||
}
|
||||
},
|
||||
|
||||
async getConfig({ baseUrl, token, toUserId, contextToken, signal }) {
|
||||
const recipient = nonEmptyString(toUserId);
|
||||
if (!recipient) throw new TypeError('toUserId is required');
|
||||
const response = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/getconfig',
|
||||
token,
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
body: {
|
||||
ilink_user_id: recipient,
|
||||
...(nonEmptyString(contextToken) ? { context_token: contextToken.trim() } : {}),
|
||||
base_info: baseInfo(),
|
||||
},
|
||||
});
|
||||
if (response?.ret !== undefined && response.ret !== 0) {
|
||||
throw new WeixinApiError('config-rejected', '微信服务拒绝了机器人配置请求。');
|
||||
}
|
||||
return { typingTicket: nonEmptyString(response?.typing_ticket) };
|
||||
},
|
||||
|
||||
async sendTyping({ baseUrl, token, toUserId, typingTicket, status, signal }) {
|
||||
const recipient = nonEmptyString(toUserId);
|
||||
const ticket = nonEmptyString(typingTicket);
|
||||
if (!recipient || !ticket) throw new TypeError('toUserId and typingTicket are required');
|
||||
if (status !== 1 && status !== 2) throw new TypeError('typing status must be 1 or 2');
|
||||
const response = await requestJson(fetchImpl, {
|
||||
method: 'POST',
|
||||
baseUrl,
|
||||
endpoint: 'ilink/bot/sendtyping',
|
||||
token,
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
body: {
|
||||
ilink_user_id: recipient,
|
||||
typing_ticket: ticket,
|
||||
status,
|
||||
base_info: baseInfo(),
|
||||
},
|
||||
});
|
||||
if (response?.ret !== undefined && response.ret !== 0) {
|
||||
throw new WeixinApiError('typing-rejected', '微信服务拒绝了输入状态请求。');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
async sendText({ baseUrl, token, toUserId, text, contextToken, runId, signal }) {
|
||||
const recipient = nonEmptyString(toUserId);
|
||||
const content = nonEmptyString(text);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ import {
|
|||
import { t } from '../shared/i18n.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
|
||||
const DEFAULT_TYPING_KEEPALIVE_MS = 5_000;
|
||||
const TYPING_RETRY_DELAY_MS = 60_000;
|
||||
|
||||
const HELP_TEXT = () => [
|
||||
t('微信已连接 DeepSeek Harness。'),
|
||||
|
|
@ -181,6 +183,7 @@ export class WeixinHarnessBridge {
|
|||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#maxMessageChars;
|
||||
#typingKeepaliveMs;
|
||||
#signal;
|
||||
#queues = new Map();
|
||||
#pendingInteractions = new Map();
|
||||
|
|
@ -190,6 +193,14 @@ export class WeixinHarnessBridge {
|
|||
#commandTasks = new Set();
|
||||
#approvals;
|
||||
#batchInputs = new BatchInputManager();
|
||||
#typingTicket = null;
|
||||
#typingTarget = null;
|
||||
#typingTimer = null;
|
||||
#typingGeneration = 0;
|
||||
#typingTail = Promise.resolve();
|
||||
#typingClosed = false;
|
||||
#typingRetryAt = 0;
|
||||
#typingTicketStale = false;
|
||||
|
||||
constructor({
|
||||
api,
|
||||
|
|
@ -202,11 +213,15 @@ export class WeixinHarnessBridge {
|
|||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
|
||||
typingKeepaliveMs = DEFAULT_TYPING_KEEPALIVE_MS,
|
||||
signal,
|
||||
}) {
|
||||
if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
|
||||
if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
|
||||
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
||||
if (!Number.isFinite(typingKeepaliveMs) || typingKeepaliveMs <= 0) {
|
||||
throw new TypeError('typingKeepaliveMs must be a positive number');
|
||||
}
|
||||
this.#api = api;
|
||||
this.#baseUrl = baseUrl;
|
||||
this.#token = token;
|
||||
|
|
@ -217,6 +232,7 @@ export class WeixinHarnessBridge {
|
|||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#maxMessageChars = maxMessageChars;
|
||||
this.#typingKeepaliveMs = typingKeepaliveMs;
|
||||
this.#signal = signal;
|
||||
this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
|
||||
}
|
||||
|
|
@ -263,6 +279,7 @@ export class WeixinHarnessBridge {
|
|||
return this.#finishBatchResult(
|
||||
message,
|
||||
messageId,
|
||||
key,
|
||||
sender,
|
||||
contextToken,
|
||||
runId,
|
||||
|
|
@ -294,7 +311,7 @@ export class WeixinHarnessBridge {
|
|||
`[dsh-weixin] failed to process a command [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
return this.#send(sender, messageFailureText(failure), contextToken, runId)
|
||||
return this.#sendOutOfBand(key, sender, messageFailureText(failure), contextToken, runId)
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -384,7 +401,7 @@ export class WeixinHarnessBridge {
|
|||
return current;
|
||||
}
|
||||
|
||||
#finishBatchResult(message, messageId, sender, contextToken, runId, result) {
|
||||
#finishBatchResult(message, messageId, key, sender, contextToken, runId, result) {
|
||||
let task;
|
||||
task = Promise.resolve().then(async () => {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
|
|
@ -392,7 +409,7 @@ export class WeixinHarnessBridge {
|
|||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (result.message) {
|
||||
await this.#send(sender, result.message, contextToken, runId);
|
||||
await this.#sendOutOfBand(key, sender, result.message, contextToken, runId);
|
||||
}
|
||||
this.#status.lastError = null;
|
||||
}).catch(async (error) => {
|
||||
|
|
@ -403,7 +420,7 @@ export class WeixinHarnessBridge {
|
|||
`[dsh-weixin] failed to process a batch input message [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
await this.#send(sender, messageFailureText(failure), contextToken, runId)
|
||||
await this.#sendOutOfBand(key, sender, messageFailureText(failure), contextToken, runId)
|
||||
.catch(() => undefined);
|
||||
}).finally(() => {
|
||||
this.#acceptedMessageIds.delete(messageId);
|
||||
|
|
@ -421,9 +438,15 @@ export class WeixinHarnessBridge {
|
|||
)),
|
||||
...this.#approvalTasks,
|
||||
...this.#commandTasks,
|
||||
this.#typingTail,
|
||||
]);
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.#typingClosed = true;
|
||||
await this.#stopTyping({ signal: AbortSignal.timeout(5_000) });
|
||||
}
|
||||
|
||||
async #processFastCommand(
|
||||
message,
|
||||
messageId,
|
||||
|
|
@ -454,7 +477,12 @@ export class WeixinHarnessBridge {
|
|||
]);
|
||||
}
|
||||
for (const reply of result?.messages ?? [result?.message]) {
|
||||
if (reply) await this.#send(sender, reply, contextToken, runId);
|
||||
if (!reply) continue;
|
||||
if (result?.stopped) {
|
||||
await this.#send(sender, reply, contextToken, runId);
|
||||
} else {
|
||||
await this.#sendOutOfBand(key, sender, reply, contextToken, runId);
|
||||
}
|
||||
}
|
||||
this.#status.lastError = null;
|
||||
}
|
||||
|
|
@ -537,12 +565,13 @@ export class WeixinHarnessBridge {
|
|||
return;
|
||||
}
|
||||
|
||||
const content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
let answer;
|
||||
let artifacts = [];
|
||||
await this.#startTyping(sender, contextToken);
|
||||
try {
|
||||
const content = hasImages
|
||||
? await promptContentForMessage(promptMessage, { signal: this.#signal })
|
||||
: undefined;
|
||||
await this.#state.markSeen(messageId);
|
||||
promptRecorded = true;
|
||||
({ answer, artifacts = [] } = await askInWorkspaceSession({
|
||||
|
|
@ -556,13 +585,17 @@ export class WeixinHarnessBridge {
|
|||
timeoutMs: this.#replyTimeoutMs,
|
||||
signal: this.#signal,
|
||||
control: { owner: this, key },
|
||||
onUpdate: () => this.#resumeTyping(key, sender, contextToken),
|
||||
onInteraction: (interaction) => this.#handleInteraction(interaction, {
|
||||
key,
|
||||
actor: sender,
|
||||
contextToken,
|
||||
runId,
|
||||
}),
|
||||
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
||||
onInteractionResolved: async (resolution) => {
|
||||
await this.#handleInteractionResolved(resolution);
|
||||
await this.#resumeTyping(key, sender, contextToken);
|
||||
},
|
||||
files: promptMessage.files,
|
||||
},
|
||||
}));
|
||||
|
|
@ -572,6 +605,7 @@ export class WeixinHarnessBridge {
|
|||
}
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
this.#stopTyping(),
|
||||
this.#cancelPendingInteraction(key),
|
||||
this.#approvals.closeRoute(key),
|
||||
]);
|
||||
|
|
@ -966,6 +1000,7 @@ export class WeixinHarnessBridge {
|
|||
}
|
||||
|
||||
async #send(toUserId, text, contextToken, runId) {
|
||||
await this.#stopTyping();
|
||||
const providerMessageIds = [];
|
||||
for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
|
||||
const result = await this.#api.sendText({
|
||||
|
|
@ -982,6 +1017,164 @@ export class WeixinHarnessBridge {
|
|||
return providerMessageIds;
|
||||
}
|
||||
|
||||
async #sendOutOfBand(key, toUserId, text, contextToken, runId) {
|
||||
try {
|
||||
return await this.#send(toUserId, text, contextToken, runId);
|
||||
} finally {
|
||||
if (this.#queues.has(key)) {
|
||||
await this.#resumeTyping(key, toUserId, contextToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#queueTyping(operation) {
|
||||
const task = this.#typingTail
|
||||
.catch(() => undefined)
|
||||
.then(operation);
|
||||
this.#typingTail = task.catch(() => undefined);
|
||||
return task;
|
||||
}
|
||||
|
||||
async #startTyping(toUserId, contextToken) {
|
||||
const target = nonEmptyString(toUserId);
|
||||
if (!target
|
||||
|| this.#typingClosed
|
||||
|| this.#signal?.aborted
|
||||
|| Date.now() < this.#typingRetryAt
|
||||
|| typeof this.#api.getConfig !== 'function'
|
||||
|| typeof this.#api.sendTyping !== 'function') return false;
|
||||
if (this.#typingTarget === target) return true;
|
||||
|
||||
const generation = ++this.#typingGeneration;
|
||||
try {
|
||||
return await this.#queueTyping(async () => {
|
||||
if (generation !== this.#typingGeneration || this.#typingClosed) return false;
|
||||
let ticket = this.#typingTicket;
|
||||
if (!ticket) {
|
||||
const config = await this.#api.getConfig({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId: target,
|
||||
contextToken,
|
||||
signal: this.#signal,
|
||||
});
|
||||
ticket = nonEmptyString(config?.typingTicket);
|
||||
if (!ticket) {
|
||||
this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
|
||||
return false;
|
||||
}
|
||||
if (generation !== this.#typingGeneration || this.#typingClosed) return false;
|
||||
this.#typingTicket = ticket;
|
||||
this.#typingRetryAt = 0;
|
||||
}
|
||||
|
||||
this.#typingTarget = target;
|
||||
await this.#api.sendTyping({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId: target,
|
||||
typingTicket: ticket,
|
||||
status: 1,
|
||||
signal: this.#signal,
|
||||
});
|
||||
if (generation !== this.#typingGeneration || this.#typingClosed) return false;
|
||||
this.#typingTicketStale = false;
|
||||
this.#typingRetryAt = 0;
|
||||
this.#scheduleTyping(generation);
|
||||
return true;
|
||||
});
|
||||
} catch (error) {
|
||||
if (generation === this.#typingGeneration) {
|
||||
this.#clearTypingTimer();
|
||||
this.#typingTicketStale = Boolean(this.#typingTarget && this.#typingTicket);
|
||||
this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
|
||||
}
|
||||
if (!this.#signal?.aborted && !this.#typingClosed) {
|
||||
this.#logger.warn?.('[dsh-weixin] typing indicator failed:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async #stopTyping({ signal = AbortSignal.timeout(5_000) } = {}) {
|
||||
++this.#typingGeneration;
|
||||
this.#clearTypingTimer();
|
||||
const target = this.#typingTarget;
|
||||
const ticket = this.#typingTicket;
|
||||
const stale = this.#typingTicketStale;
|
||||
this.#typingTarget = null;
|
||||
if (typeof this.#api.sendTyping !== 'function') return false;
|
||||
try {
|
||||
return await this.#queueTyping(async () => {
|
||||
if (!target || !ticket) return false;
|
||||
try {
|
||||
await this.#api.sendTyping({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId: target,
|
||||
typingTicket: ticket,
|
||||
status: 2,
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
if (stale && this.#typingTicket === ticket) this.#typingTicket = null;
|
||||
this.#typingTicketStale = false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!signal?.aborted) {
|
||||
this.#logger.warn?.('[dsh-weixin] typing cancellation failed:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleTyping(generation) {
|
||||
this.#clearTypingTimer();
|
||||
if (generation !== this.#typingGeneration || !this.#typingTarget || this.#typingClosed) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (this.#typingTimer === timer) this.#typingTimer = null;
|
||||
void this.#queueTyping(async () => {
|
||||
if (generation !== this.#typingGeneration || !this.#typingTarget || this.#typingClosed) {
|
||||
return false;
|
||||
}
|
||||
await this.#api.sendTyping({
|
||||
baseUrl: this.#baseUrl,
|
||||
token: this.#token,
|
||||
toUserId: this.#typingTarget,
|
||||
typingTicket: this.#typingTicket,
|
||||
status: 1,
|
||||
signal: this.#signal,
|
||||
});
|
||||
return true;
|
||||
}).then((sent) => {
|
||||
if (sent) this.#scheduleTyping(generation);
|
||||
}).catch((error) => {
|
||||
if (generation !== this.#typingGeneration) return;
|
||||
this.#typingTicketStale = Boolean(this.#typingTarget && this.#typingTicket);
|
||||
this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
|
||||
if (!this.#signal?.aborted && !this.#typingClosed) {
|
||||
this.#logger.warn?.('[dsh-weixin] typing keepalive failed:', error);
|
||||
}
|
||||
});
|
||||
}, this.#typingKeepaliveMs);
|
||||
timer.unref?.();
|
||||
this.#typingTimer = timer;
|
||||
}
|
||||
|
||||
#clearTypingTimer() {
|
||||
if (this.#typingTimer) clearTimeout(this.#typingTimer);
|
||||
this.#typingTimer = null;
|
||||
}
|
||||
|
||||
#resumeTyping(key, toUserId, contextToken) {
|
||||
if (this.#pendingInteractions.has(key) || this.#approvals.hasPending(key)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return this.#startTyping(toUserId, contextToken);
|
||||
}
|
||||
|
||||
async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
|
||||
const sendArtifact = (method, file) => this.#api[method]({
|
||||
baseUrl: this.#baseUrl,
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ export class WeixinRuntime {
|
|||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
this.#monitor = null;
|
||||
await bridge?.close?.();
|
||||
await monitor?.catch(() => undefined);
|
||||
await bridge?.waitForIdle();
|
||||
this.#bridge = null;
|
||||
|
|
|
|||
|
|
@ -208,6 +208,103 @@ test('sendText emits the iLink message envelope without reflecting the token in
|
|||
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
||||
});
|
||||
|
||||
test('getConfig requests a typing ticket for the current Weixin conversation', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
return jsonResponse({ ret: 0, typing_ticket: 'typing-ticket' });
|
||||
},
|
||||
});
|
||||
|
||||
const config = await api.getConfig({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
contextToken: 'message-context',
|
||||
});
|
||||
|
||||
assert.deepEqual(config, { typingTicket: 'typing-ticket' });
|
||||
assert.match(calls[0].url, /\/ilink\/bot\/getconfig$/);
|
||||
assert.equal(calls[0].init.headers.Authorization, 'Bearer host-only-token');
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.equal(body.ilink_user_id, 'wx-user');
|
||||
assert.equal(body.context_token, 'message-context');
|
||||
assert.equal(body.base_info.channel_version, '2.4.6');
|
||||
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
|
||||
});
|
||||
|
||||
test('sendTyping sends start and cancel states with the cached ticket', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: url.toString(), init });
|
||||
return jsonResponse({ ret: 0 });
|
||||
},
|
||||
});
|
||||
|
||||
for (const status of [1, 2]) {
|
||||
await api.sendTyping({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
typingTicket: 'typing-ticket',
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepEqual(calls.map(({ url, init }) => {
|
||||
const body = JSON.parse(init.body);
|
||||
return {
|
||||
path: new URL(url).pathname,
|
||||
user: body.ilink_user_id,
|
||||
ticket: body.typing_ticket,
|
||||
status: body.status,
|
||||
tokenInBody: init.body.includes('host-only-token'),
|
||||
};
|
||||
}), [
|
||||
{
|
||||
path: '/ilink/bot/sendtyping',
|
||||
user: 'wx-user',
|
||||
ticket: 'typing-ticket',
|
||||
status: 1,
|
||||
tokenInBody: false,
|
||||
},
|
||||
{
|
||||
path: '/ilink/bot/sendtyping',
|
||||
user: 'wx-user',
|
||||
ticket: 'typing-ticket',
|
||||
status: 2,
|
||||
tokenInBody: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('typing endpoints reject non-zero iLink return codes', async () => {
|
||||
const api = createWeixinApi({
|
||||
fetchImpl: async () => jsonResponse({ ret: -2, errmsg: 'rejected' }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
api.getConfig({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
}),
|
||||
(error) => error instanceof WeixinApiError && error.code === 'config-rejected',
|
||||
);
|
||||
await assert.rejects(
|
||||
api.sendTyping({
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com',
|
||||
token: 'host-only-token',
|
||||
toUserId: 'wx-user',
|
||||
typingTicket: 'typing-ticket',
|
||||
status: 1,
|
||||
}),
|
||||
(error) => error instanceof WeixinApiError && error.code === 'typing-rejected',
|
||||
);
|
||||
});
|
||||
|
||||
test('sendFile uses the iLink 2.4.6 encrypted CDN flow and sends a native file item', async () => {
|
||||
const calls = [];
|
||||
const api = createWeixinApi({
|
||||
|
|
|
|||
|
|
@ -833,6 +833,337 @@ test('bridge maps the scanning Weixin user to one persistent Harness session and
|
|||
assert.equal(status.messagesReplied, 2);
|
||||
});
|
||||
|
||||
test('Weixin keeps the typing indicator alive until the final reply with one ticket lookup', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-typing');
|
||||
const releaseAnswer = deferred();
|
||||
const events = [];
|
||||
let configCalls = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
getConfig: async ({ toUserId, contextToken }) => {
|
||||
configCalls += 1;
|
||||
events.push(`config:${toUserId}:${contextToken}`);
|
||||
return { typingTicket: 'typing-ticket' };
|
||||
},
|
||||
sendTyping: async ({ status }) => events.push(`typing:${status}`),
|
||||
sendText: async ({ text }) => events.push(`text:${text}`),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
typingKeepaliveMs: 5,
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => releaseAnswer.promise,
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const turn = bridge.accept(message('typing', '慢一点回答'));
|
||||
await eventually(() => events.filter((event) => event === 'typing:1').length >= 2);
|
||||
releaseAnswer.resolve('回答完成');
|
||||
await turn;
|
||||
|
||||
assert.equal(configCalls, 1);
|
||||
assert.equal(events[0], 'config:owner-user:context-typing');
|
||||
assert.equal(events[1], 'typing:1');
|
||||
assert.equal(events.filter((event) => event === 'typing:1').length >= 2, true);
|
||||
assert.deepEqual(events.slice(-2), ['typing:2', 'text:回答完成']);
|
||||
|
||||
const startsAfterFirstTurn = events.filter((event) => event === 'typing:1').length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(
|
||||
events.filter((event) => event === 'typing:1').length,
|
||||
startsAfterFirstTurn,
|
||||
'a completed turn must not leave a keepalive timer behind',
|
||||
);
|
||||
|
||||
const secondTurnStart = events.length;
|
||||
await bridge.accept(message('typing-again', '再次回答'));
|
||||
assert.equal(configCalls, 1, 'the typing ticket should be reused across turns');
|
||||
assert.deepEqual(events.slice(secondTurnStart), [
|
||||
'typing:1',
|
||||
'typing:2',
|
||||
'text:回答完成',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Weixin typing failures never prevent the Harness reply', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-typing-fallback');
|
||||
const sent = [];
|
||||
const warnings = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
getConfig: async () => { throw new Error('typing unavailable'); },
|
||||
sendTyping: async () => assert.fail('typing cannot start without a ticket'),
|
||||
sendText: async ({ text }) => sent.push(text),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => '仍然正常回答',
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn: (...args) => warnings.push(args), error() {} },
|
||||
});
|
||||
|
||||
await bridge.accept(message('typing-fallback', '测试降级'));
|
||||
|
||||
assert.deepEqual(sent, ['仍然正常回答']);
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
|
||||
test('Weixin best-effort cancels after a typing start or keepalive failure', async (suite) => {
|
||||
for (const failAtStart of [1, 2]) {
|
||||
await suite.test(`status 1 attempt ${failAtStart}`, async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', `session-typing-failure-${failAtStart}`);
|
||||
const releaseAnswer = deferred();
|
||||
const askStarted = deferred();
|
||||
const statuses = [];
|
||||
const sent = [];
|
||||
const warnings = [];
|
||||
let starts = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
getConfig: async () => ({ typingTicket: 'typing-ticket' }),
|
||||
sendTyping: async ({ status }) => {
|
||||
statuses.push(status);
|
||||
if (status === 1) {
|
||||
starts += 1;
|
||||
if (starts === failAtStart) throw new Error('typing status failed');
|
||||
}
|
||||
},
|
||||
sendText: async ({ text }) => sent.push(text),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
typingKeepaliveMs: 5,
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async () => {
|
||||
askStarted.resolve();
|
||||
return releaseAnswer.promise;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn: (...args) => warnings.push(args), error() {} },
|
||||
});
|
||||
|
||||
const turn = bridge.accept(message(`typing-failure-${failAtStart}`, '测试输入状态失败'));
|
||||
await askStarted.promise;
|
||||
await eventually(() => warnings.length === 1);
|
||||
releaseAnswer.resolve('最终回答仍然发送');
|
||||
await turn;
|
||||
|
||||
assert.deepEqual(statuses, failAtStart === 1 ? [1, 2] : [1, 1, 2]);
|
||||
assert.deepEqual(sent, ['最终回答仍然发送']);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('Weixin stops typing while a Harness question is visible', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-typing-question');
|
||||
const events = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
getConfig: async () => ({ typingTicket: 'typing-ticket' }),
|
||||
sendTyping: async ({ status }) => events.push(`typing:${status}`),
|
||||
sendText: async ({ text }) => events.push(`text:${text}`),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'typing-question',
|
||||
rpcId: 'typing-question',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'answer', question: '需要你的回答' }],
|
||||
},
|
||||
respond: async () => ({ accepted: true }),
|
||||
});
|
||||
await options.onUpdate({ type: 'text', text: '不应在待回答时恢复' });
|
||||
return '任务结束';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('typing-question', '触发问题'));
|
||||
|
||||
assert.equal(events[0], 'typing:1');
|
||||
assert.equal(events[1], 'typing:2');
|
||||
assert.match(events[2], /^text:DeepSeek Harness 需要你补充信息/);
|
||||
assert.equal(events.filter((event) => event === 'typing:1').length, 1);
|
||||
assert.equal(events.at(-1), 'text:任务结束');
|
||||
});
|
||||
|
||||
test('Weixin resumes typing after the visible Harness question is resolved', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-typing-question-resolved');
|
||||
const events = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
getConfig: async () => ({ typingTicket: 'typing-ticket' }),
|
||||
sendTyping: async ({ status }) => events.push(`typing:${status}`),
|
||||
sendText: async ({ text }) => events.push(`text:${text}`),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (sessionId, _text, options) => {
|
||||
await options.onInteraction({
|
||||
kind: 'question',
|
||||
interactionId: 'typing-question-resolved',
|
||||
rpcId: 'typing-question-resolved',
|
||||
sessionId,
|
||||
payload: {
|
||||
type: 'question/requested',
|
||||
sessionId,
|
||||
questions: [{ id: 'answer', question: '需要你的回答' }],
|
||||
},
|
||||
respond: async () => ({ accepted: true }),
|
||||
});
|
||||
await options.onInteractionResolved({
|
||||
kind: 'question',
|
||||
interactionId: 'typing-question-resolved',
|
||||
});
|
||||
return '继续处理完成';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
await bridge.accept(message('typing-question-resolved', '触发后解决问题'));
|
||||
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.startsWith('typing:')),
|
||||
['typing:1', 'typing:2', 'typing:1', 'typing:2'],
|
||||
);
|
||||
assert.equal(events.at(-1), 'text:继续处理完成');
|
||||
});
|
||||
|
||||
test('Weixin restarts typing when an out-of-band notice races an in-flight keepalive', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-typing-race');
|
||||
const releaseAnswer = deferred();
|
||||
const askStarted = deferred();
|
||||
const releaseHeartbeat = deferred();
|
||||
const events = [];
|
||||
let askOptions;
|
||||
let starts = 0;
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
getConfig: async () => ({ typingTicket: 'typing-ticket' }),
|
||||
sendTyping: async ({ status }) => {
|
||||
events.push(`typing:${status}`);
|
||||
if (status === 1) {
|
||||
starts += 1;
|
||||
if (starts === 2) {
|
||||
await releaseHeartbeat.promise;
|
||||
}
|
||||
}
|
||||
},
|
||||
sendText: async ({ text }) => events.push(`text:${text}`),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
typingKeepaliveMs: 5,
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => {
|
||||
askOptions = options;
|
||||
askStarted.resolve();
|
||||
return releaseAnswer.promise;
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
});
|
||||
|
||||
const turn = bridge.accept(message('typing-race', '执行一个长任务'));
|
||||
await askStarted.promise;
|
||||
await eventually(() => starts === 2);
|
||||
|
||||
const busyNotice = bridge.accept(message('typing-race-batch', '/batch'));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
let startsWhenProgressResolved = 0;
|
||||
const progressUpdate = askOptions.onUpdate({ type: 'text', text: '继续处理' }).then((result) => {
|
||||
startsWhenProgressResolved = starts;
|
||||
return result;
|
||||
});
|
||||
releaseHeartbeat.resolve();
|
||||
await Promise.all([busyNotice, progressUpdate]);
|
||||
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.startsWith('typing:')),
|
||||
['typing:1', 'typing:1', 'typing:2', 'typing:1'],
|
||||
);
|
||||
assert.equal(
|
||||
startsWhenProgressResolved,
|
||||
3,
|
||||
'the concurrent progress update must wait for cancellation and actually restart typing',
|
||||
);
|
||||
|
||||
releaseAnswer.resolve('长任务完成');
|
||||
await turn;
|
||||
assert.deepEqual(
|
||||
events.filter((event) => event.startsWith('typing:')),
|
||||
['typing:1', 'typing:1', 'typing:2', 'typing:1', 'typing:2'],
|
||||
);
|
||||
assert.equal(events.at(-1), 'text:长任务完成');
|
||||
});
|
||||
|
||||
test('Weixin cancels typing with an independent signal when the runtime aborts', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-typing-abort');
|
||||
const controller = new AbortController();
|
||||
const typingCalls = [];
|
||||
const bridge = new WeixinHarnessBridge({
|
||||
api: {
|
||||
getConfig: async () => ({ typingTicket: 'typing-ticket' }),
|
||||
sendTyping: async ({ status, signal }) => typingCalls.push({ status, signal }),
|
||||
sendText: async () => assert.fail('an aborted turn must not send a reply'),
|
||||
},
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
token: 'host-token',
|
||||
ownerUserId: 'owner-user',
|
||||
signal: controller.signal,
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => new Promise((resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
|
||||
}),
|
||||
},
|
||||
state: fixture.state,
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const turn = bridge.accept(message('typing-abort', '启动后关闭'));
|
||||
await eventually(() => typingCalls.some(({ status }) => status === 1));
|
||||
controller.abort(new Error('runtime stopped'));
|
||||
await Promise.all([bridge.close(), turn]);
|
||||
|
||||
assert.deepEqual(typingCalls.map(({ status }) => status), [1, 2]);
|
||||
assert.notEqual(typingCalls[1].signal, controller.signal);
|
||||
assert.equal(typingCalls[1].signal.aborted, false);
|
||||
});
|
||||
|
||||
test('Weixin answers a multi-question interaction before the original turn queue', async () => {
|
||||
const fixture = stateFixture();
|
||||
fixture.sessions.set('p2p:owner-user', 'session-question');
|
||||
|
|
|
|||
|
|
@ -168,6 +168,80 @@ test('runtime sends a connection test to the bound Weixin owner without reply co
|
|||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('runtime cancels typing before notifying iLink that it stopped', async () => {
|
||||
const events = [];
|
||||
const seen = new Set();
|
||||
let polls = 0;
|
||||
const runtime = new WeixinRuntime({
|
||||
api: {
|
||||
notifyStart: async () => events.push({ type: 'notify-start' }),
|
||||
notifyStop: async () => events.push({ type: 'notify-stop' }),
|
||||
getConfig: async () => ({ typingTicket: 'typing-ticket' }),
|
||||
sendTyping: async ({ status, signal }) => events.push({ type: 'typing', status, signal }),
|
||||
sendText: async () => assert.fail('an aborted turn must not send a reply'),
|
||||
getUpdates: async ({ signal }) => {
|
||||
polls += 1;
|
||||
if (polls === 1) {
|
||||
return {
|
||||
ret: 0,
|
||||
msgs: [{
|
||||
seq: 1,
|
||||
message_id: 'runtime-typing-stop',
|
||||
message_type: 1,
|
||||
from_user_id: 'owner-user',
|
||||
context_token: 'runtime-typing-context',
|
||||
item_list: [{ type: 1, text_item: { text: '执行长任务' } }],
|
||||
}],
|
||||
};
|
||||
}
|
||||
return new Promise((_resolve, reject) => {
|
||||
const onAbort = () => reject(signal.reason);
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
},
|
||||
},
|
||||
config: {
|
||||
botId: 'wx_typing_stop',
|
||||
baseUrl: 'https://ilinkai.weixin.qq.com/',
|
||||
ownerUserId: 'owner-user',
|
||||
},
|
||||
token: 'bot-token',
|
||||
harness: {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, _text, options) => new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
|
||||
}),
|
||||
},
|
||||
state: {
|
||||
getUpdatesBuf: () => '',
|
||||
setGetUpdatesBuf: async () => {},
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
sessionFor: () => 'session-runtime-typing-stop',
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
},
|
||||
logger: { warn() {}, error() {} },
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await eventually(
|
||||
() => events.some((event) => event.type === 'typing' && event.status === 1),
|
||||
'the runtime should start the typing indicator',
|
||||
);
|
||||
await runtime.stop();
|
||||
|
||||
const cancellationIndex = events.findIndex(
|
||||
(event) => event.type === 'typing' && event.status === 2,
|
||||
);
|
||||
const stopIndex = events.findIndex((event) => event.type === 'notify-stop');
|
||||
assert.ok(cancellationIndex > -1);
|
||||
assert.ok(cancellationIndex < stopIndex);
|
||||
assert.equal(events[cancellationIndex].signal.aborted, false);
|
||||
});
|
||||
|
||||
test('runtime verifies the token, consumes getUpdates, replies, persists cursor, and aborts on stop', async () => {
|
||||
const calls = [];
|
||||
let pollCount = 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue