mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
Merge pull request #48 from luochen211/fix/weixin-long-reply-chunking
fix: chunk long WeChat replies for iLink
This commit is contained in:
commit
36cbbe48e7
10 changed files with 219 additions and 174 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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 回复,群聊被 @ 后只发送最终答案 |
|
||||||
|
|
|
||||||
324
lib/index.js
324
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue