Merge pull request #48 from luochen211/fix/weixin-long-reply-chunking

fix: chunk long WeChat replies for iLink
This commit is contained in:
xmanrui 2026-08-24 21:53:51 +08:00
commit 36cbbe48e7
10 changed files with 219 additions and 174 deletions

View file

@ -46,7 +46,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| Channel | Setup | Messaging and replies | | 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 | | 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 | | 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 |
| DingTalk | Create a bot by QR code, or bind one with Client ID + Client Secret | DingTalk Stream connection; streaming replies through AI Cards | | 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 | | 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 | | 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 |

View file

@ -49,7 +49,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| 渠道 | 接入方式 | 消息与回复 | | 渠道 | 接入方式 | 消息与回复 |
| --- | --- | --- | | --- | --- | --- |
| 飞书 | 扫码创建机器人,或使用 App ID + App Secret 手动绑定 | 长连接接收消息;通过飞书流式卡片显示思考、工具进度和回答 | | 飞书 | 扫码创建机器人,或使用 App ID + App Secret 手动绑定 | 长连接接收消息;通过飞书流式卡片显示思考、工具进度和回答 |
| 微信 | 使用微信扫码绑定机器人 | 腾讯 iLink 长轮询收发消息 | | 微信 | 使用微信扫码绑定机器人 | 腾讯 iLink 长轮询收发消息;长回复会按 1,800 字符分段发送 |
| 钉钉 | 扫码创建机器人,或使用 Client ID + Client Secret 手动绑定 | 钉钉 Stream 长连接;通过 AI Card 流式显示回答 | | 钉钉 | 扫码创建机器人,或使用 Client ID + Client Secret 手动绑定 | 钉钉 Stream 长连接;通过 AI Card 流式显示回答 |
| 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 | | 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 |
| QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”并以单条 Markdown 回复,群聊被 @ 后只发送最终答案 | | QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”并以单条 Markdown 回复,群聊被 @ 后只发送最终答案 |

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,10 @@ import { join, resolve } from 'node:path';
import { WeixinConfigStore } from '../../../../src/channels/weixin/config-store.mjs'; import { WeixinConfigStore } from '../../../../src/channels/weixin/config-store.mjs';
import { HarnessClient } from '../../../../src/channels/weixin/harness-client.mjs'; import { HarnessClient } from '../../../../src/channels/weixin/harness-client.mjs';
import { WeixinStateStore } from '../../../../src/channels/weixin/state-store.mjs'; import { WeixinStateStore } from '../../../../src/channels/weixin/state-store.mjs';
import { createWeixinApi } from '../../../../src/channels/weixin/weixin-api.mjs'; import {
createWeixinApi,
DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
} from '../../../../src/channels/weixin/weixin-api.mjs';
import { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs'; import { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs';
import { WeixinRuntime } from '../../../../src/channels/weixin/weixin-runtime.mjs'; import { WeixinRuntime } from '../../../../src/channels/weixin/weixin-runtime.mjs';
import { import {
@ -113,7 +116,7 @@ export async function createProductionController(ctx, config = {}, internals = {
harness: workspaceScope.harness, harness: workspaceScope.harness,
state: workspaceScope.state, state: workspaceScope.state,
replyTimeoutMs: config.replyTimeoutMs ?? 600_000, replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
maxMessageChars: config.maxMessageChars ?? 4_000, maxMessageChars: config.maxMessageChars ?? DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
logger: { logger: {
error: (...args) => logger.error?.(`[${botId}]`, ...args), error: (...args) => logger.error?.(`[${botId}]`, ...args),
warn: (...args) => logger.warn?.(`[${botId}]`, ...args), warn: (...args) => logger.warn?.(`[${botId}]`, ...args),

View file

@ -12,6 +12,7 @@ export const WEIXIN_QR_BASE_URL = 'https://ilinkai.weixin.qq.com/';
export const WEIXIN_PROTOCOL_VERSION = '2.4.6'; export const WEIXIN_PROTOCOL_VERSION = '2.4.6';
export const DEFAULT_BOT_TYPE = '3'; export const DEFAULT_BOT_TYPE = '3';
export const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'; export const WEIXIN_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
export const DEFAULT_WEIXIN_MAX_MESSAGE_CHARS = 1_800;
const WEIXIN_CDN_HOST = 'novac2c.cdn.weixin.qq.com'; const WEIXIN_CDN_HOST = 'novac2c.cdn.weixin.qq.com';
@ -744,7 +745,7 @@ export function weixinMessageId(message) {
return nonEmptyString(message?.client_id); return nonEmptyString(message?.client_id);
} }
export function splitWeixinText(text, maxChars = 4_000) { export function splitWeixinText(text, maxChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS) {
if (text.length <= maxChars) return [text]; if (text.length <= maxChars) return [text];
const chunks = []; const chunks = [];
let remaining = text; let remaining = text;

View file

@ -1,4 +1,5 @@
import { import {
DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
extractWeixinFiles, extractWeixinFiles,
extractWeixinImages, extractWeixinImages,
extractWeixinText, extractWeixinText,
@ -184,7 +185,7 @@ export class WeixinHarnessBridge {
status = createWeixinBridgeStatus(), status = createWeixinBridgeStatus(),
logger = console, logger = console,
replyTimeoutMs = 600_000, replyTimeoutMs = 600_000,
maxMessageChars = 4_000, maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
signal, signal,
}) { }) {
if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required'); if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');

View file

@ -1,4 +1,4 @@
import { WeixinApiError } from './weixin-api.mjs'; import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError } from './weixin-api.mjs';
import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs'; import { createWeixinBridgeStatus, WeixinHarnessBridge } from './weixin-bridge.mjs';
import { import {
connectionTestTarget, connectionTestTarget,
@ -109,7 +109,7 @@ export class WeixinRuntime {
state, state,
logger = console, logger = console,
replyTimeoutMs = 600_000, replyTimeoutMs = 600_000,
maxMessageChars = 4_000, maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
startRetryDelaysMs, startRetryDelaysMs,
}) { }) {
if (!api || !config || !token || !harness || !state) { if (!api || !config || !token || !harness || !state) {

View file

@ -63,6 +63,8 @@ test('Weixin production has no per-bot result-file Gate', async (t) => {
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false); assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false); assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
assert.equal(runtimes[0].maxMessageChars, 1_800);
assert.equal(runtimes[1].maxMessageChars, 1_800);
await production.close(); await production.close();
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals); const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);
@ -72,5 +74,6 @@ test('Weixin production has no per-bot result-file Gate', async (t) => {
token: 'host-only', token: 'host-only',
}); });
assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false); assert.equal(Object.hasOwn(runtimes[2], 'outboundArtifactsEnabled'), false);
assert.equal(runtimes[2].maxMessageChars, 1_800);
await productionWithDefault.close(); await productionWithDefault.close();
}); });

View file

@ -150,6 +150,38 @@ test('Weixin bridge hands a native file source to the current Harness turn', asy
assert.deepEqual(sent, ['文件已收到']); assert.deepEqual(sent, ['文件已收到']);
}); });
test('Weixin splits long replies below the iLink text limit', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-long-reply');
const chunks = [];
const answer = '答案'.repeat(1_000);
const bridge = new WeixinHarnessBridge({
api: {
sendText: async ({ text }) => {
chunks.push(text);
return { messageId: `weixin-long-${chunks.length}` };
},
},
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async () => true,
ask: async () => answer,
},
state: fixture.state,
});
const receipt = await bridge.accept(message('weixin-long-reply', '生成一段长回答'));
assert.ok(chunks.length > 1);
assert.ok(chunks.every((chunk) => chunk.length <= 1_800));
assert.equal(chunks.join(''), answer);
assert.equal(receipt.providerMessageIds.length, chunks.length);
assert.equal(bridge.status.messagesReplied, 1);
assert.equal(bridge.status.lastMessageError, null);
});
test('Weixin starts a native-file download before an earlier queued turn finishes', async () => { test('Weixin starts a native-file download before an earlier queued turn finishes', async () => {
const fixture = stateFixture(); const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-prefetch-file'); fixture.sessions.set('p2p:owner-user', 'session-prefetch-file');

View file

@ -85,6 +85,7 @@ test('runtime verifies the token, consumes getUpdates, replies, persists cursor,
const calls = []; const calls = [];
let pollCount = 0; let pollCount = 0;
let askSignal; let askSignal;
const answer = '答'.repeat(2_000);
const stateData = { cursor: '', seen: new Set(), session: null }; const stateData = { cursor: '', seen: new Set(), session: null };
const api = { const api = {
notifyStart: async (request) => calls.push(['start', request.token]), notifyStart: async (request) => calls.push(['start', request.token]),
@ -133,7 +134,7 @@ test('runtime verifies the token, consumes getUpdates, replies, persists cursor,
createSession: async () => 'session-1', createSession: async () => 'session-1',
ask: async (_sessionId, _text, options) => { ask: async (_sessionId, _text, options) => {
askSignal = options.signal; askSignal = options.signal;
return '回答'; return answer;
}, },
}, },
state, state,
@ -145,10 +146,14 @@ test('runtime verifies the token, consumes getUpdates, replies, persists cursor,
await flush(); await flush();
await flush(); await flush();
assert.equal(stateData.cursor, 'cursor-next'); assert.equal(stateData.cursor, 'cursor-next');
assert.deepEqual(calls.slice(0, 2), [ assert.equal(calls[0][0], 'start');
['start', 'bot-token'], assert.deepEqual(calls.slice(1, 3).map((call) => call[1].length), [1_800, 200]);
['send', '回答', 'context-7'], assert.equal(calls.slice(1, 3).map((call) => call[1]).join(''), answer);
assert.deepEqual(calls.slice(1, 3).map((call) => call[2]), [
'context-7',
'context-7',
]); ]);
assert.equal(runtime.status.messagesReplied, 1);
assert.equal(askSignal.aborted, false); assert.equal(askSignal.aborted, false);
await runtime.stop(); await runtime.stop();
assert.equal(askSignal.aborted, true); assert.equal(askSignal.aborted, true);