@xmanrui/dsh-im 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +23 -6
- package/README.md +23 -6
- package/assets/screenshot-menu-card.png +0 -0
- package/lib/client.js +140 -7
- package/lib/index.js +240 -192
- package/package.json +1 -1
- package/plugin-src/client/i18n.js +4 -0
- package/plugin-src/client/index.js +72 -11
- package/plugin-src/client/loopback-recovery.js +75 -0
- package/plugin-src/client/styles.js +10 -0
- package/plugin-src/host/build.mjs +3 -0
- package/plugin-src/host/channels/shared/rpc.mjs +11 -0
- package/plugin-src/host/channels/weixin/production.mjs +5 -2
- package/plugin-src/host/lark-sdk-handshake-patch.mjs +181 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -2
- package/src/channels/feishu/bridge.mjs +1411 -164
- package/src/channels/feishu/feishu-cards.mjs +667 -56
- package/src/channels/feishu/feishu-runtime.mjs +129 -43
- package/src/channels/qq/qq-bridge.mjs +4 -2
- package/src/channels/shared/control-command.mjs +2 -2
- package/src/channels/shared/harness-client.mjs +31 -2
- package/src/channels/shared/i18n-en/feishu.mjs +133 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +8 -0
- package/src/channels/shared/i18n-en/shared-b.mjs +43 -0
- package/src/channels/shared/model-command.mjs +413 -31
- package/src/channels/shared/text-harness-bridge.mjs +4 -2
- package/src/channels/shared/workspace-command.mjs +4 -4
- package/src/channels/wecom/wecom-bridge.mjs +4 -2
- package/src/channels/weixin/weixin-api.mjs +2 -1
- package/src/channels/weixin/weixin-bridge.mjs +6 -3
- package/src/channels/weixin/weixin-runtime.mjs +2 -2
package/package.json
CHANGED
|
@@ -55,6 +55,10 @@ const EN = Object.freeze({
|
|
|
55
55
|
'本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
|
|
56
56
|
'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
|
|
57
57
|
'帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
|
|
58
|
+
'请改用 localhost 重新打开': 'Reopen with localhost',
|
|
59
|
+
'页面会在当前端口重新打开,机器人配置不会改变。': 'The page will reopen on the current port. Your bot configuration will not change.',
|
|
60
|
+
'使用 localhost 重新打开': 'Reopen with localhost',
|
|
61
|
+
'当前地址与浏览器的本机请求校验不兼容。请使用上方按钮改用 localhost 重新打开。': 'This address is incompatible with the browser’s local-request checks. Use the button above to reopen with localhost.',
|
|
58
62
|
'微信': 'WeChat',
|
|
59
63
|
'飞书': 'Feishu',
|
|
60
64
|
'钉钉': 'DingTalk',
|
|
@@ -42,6 +42,10 @@ import { WHATSAPP_RPC_CHANNEL } from './channels/whatsapp/api.js';
|
|
|
42
42
|
import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
|
|
43
43
|
import { installWhatsappStyles } from './channels/whatsapp/styles.js';
|
|
44
44
|
import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
|
|
45
|
+
import {
|
|
46
|
+
createLoopbackAwareRpcCalls,
|
|
47
|
+
replacePageLocation,
|
|
48
|
+
} from './loopback-recovery.js';
|
|
45
49
|
import { installImStyles } from './styles.js';
|
|
46
50
|
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
|
|
47
51
|
|
|
@@ -122,6 +126,22 @@ function ChannelLogo({ channel }) {
|
|
|
122
126
|
return h(OfficeLogo);
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
export function LoopbackRecoveryNotice({ recovery, onNavigate = replacePageLocation }) {
|
|
130
|
+
return h('div', {
|
|
131
|
+
className: 'dim-loopbackRecovery',
|
|
132
|
+
role: 'alert',
|
|
133
|
+
},
|
|
134
|
+
h('div', { className: 'dim-loopbackRecoveryCopy' },
|
|
135
|
+
h('strong', null, '请改用 localhost 重新打开'),
|
|
136
|
+
h('p', null, '页面会在当前端口重新打开,机器人配置不会改变。'),
|
|
137
|
+
h('code', null, recovery.origin)),
|
|
138
|
+
h('button', {
|
|
139
|
+
type: 'button',
|
|
140
|
+
className: 'dim-loopbackRecoveryAction',
|
|
141
|
+
onClick: () => onNavigate(recovery.url),
|
|
142
|
+
}, '使用 localhost 重新打开'));
|
|
143
|
+
}
|
|
144
|
+
|
|
125
145
|
export function IMSettingsTab({
|
|
126
146
|
dingtalkRpcCall,
|
|
127
147
|
discordRpcCall,
|
|
@@ -134,10 +154,44 @@ export function IMSettingsTab({
|
|
|
134
154
|
whatsappRpcCall,
|
|
135
155
|
officeRpcCall,
|
|
136
156
|
workspaceDirectoryPicker,
|
|
157
|
+
browserLocation = globalThis.location,
|
|
158
|
+
navigateToRecoveryUrl = replacePageLocation,
|
|
137
159
|
}) {
|
|
138
160
|
const [selected, setSelected] = React.useState('weixin');
|
|
161
|
+
const [loopbackRecovery, setLoopbackRecovery] = React.useState(null);
|
|
139
162
|
const githubTooltipId = React.useId();
|
|
140
163
|
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
|
|
164
|
+
const reportLoopbackRecovery = React.useCallback((recovery) => {
|
|
165
|
+
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
|
|
166
|
+
}, []);
|
|
167
|
+
const rpcCalls = React.useMemo(() => createLoopbackAwareRpcCalls({
|
|
168
|
+
dingtalkRpcCall,
|
|
169
|
+
discordRpcCall,
|
|
170
|
+
feishuRpcCall,
|
|
171
|
+
qqRpcCall,
|
|
172
|
+
slackRpcCall,
|
|
173
|
+
telegramRpcCall,
|
|
174
|
+
wecomRpcCall,
|
|
175
|
+
weixinRpcCall,
|
|
176
|
+
whatsappRpcCall,
|
|
177
|
+
officeRpcCall,
|
|
178
|
+
}, {
|
|
179
|
+
location: browserLocation,
|
|
180
|
+
onRecovery: reportLoopbackRecovery,
|
|
181
|
+
}), [
|
|
182
|
+
browserLocation,
|
|
183
|
+
dingtalkRpcCall,
|
|
184
|
+
discordRpcCall,
|
|
185
|
+
feishuRpcCall,
|
|
186
|
+
officeRpcCall,
|
|
187
|
+
qqRpcCall,
|
|
188
|
+
reportLoopbackRecovery,
|
|
189
|
+
slackRpcCall,
|
|
190
|
+
telegramRpcCall,
|
|
191
|
+
wecomRpcCall,
|
|
192
|
+
weixinRpcCall,
|
|
193
|
+
whatsappRpcCall,
|
|
194
|
+
]);
|
|
141
195
|
return h(WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker },
|
|
142
196
|
h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
|
|
143
197
|
h('header', { className: 'dim-title' },
|
|
@@ -184,25 +238,32 @@ export function IMSettingsTab({
|
|
|
184
238
|
role: 'tabpanel',
|
|
185
239
|
id: `dim-panel-${active.id}`,
|
|
186
240
|
'aria-labelledby': `dim-tab-${active.id}`,
|
|
187
|
-
},
|
|
188
|
-
|
|
241
|
+
},
|
|
242
|
+
loopbackRecovery
|
|
243
|
+
? h(LoopbackRecoveryNotice, {
|
|
244
|
+
recovery: loopbackRecovery,
|
|
245
|
+
onNavigate: navigateToRecoveryUrl,
|
|
246
|
+
})
|
|
247
|
+
: null,
|
|
248
|
+
active.id === 'weixin'
|
|
249
|
+
? h(WeixinSettingsTab, { rpcCall: rpcCalls.weixinRpcCall })
|
|
189
250
|
: active.id === 'feishu'
|
|
190
|
-
? h(FeishuSettingsTab, { rpcCall: feishuRpcCall })
|
|
251
|
+
? h(FeishuSettingsTab, { rpcCall: rpcCalls.feishuRpcCall })
|
|
191
252
|
: active.id === 'dingtalk'
|
|
192
|
-
? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })
|
|
253
|
+
? h(DingtalkSettingsTab, { rpcCall: rpcCalls.dingtalkRpcCall })
|
|
193
254
|
: active.id === 'wecom'
|
|
194
|
-
? h(WecomSettingsTab, { rpcCall: wecomRpcCall })
|
|
255
|
+
? h(WecomSettingsTab, { rpcCall: rpcCalls.wecomRpcCall })
|
|
195
256
|
: active.id === 'qq'
|
|
196
|
-
? h(QqSettingsTab, { rpcCall: qqRpcCall })
|
|
257
|
+
? h(QqSettingsTab, { rpcCall: rpcCalls.qqRpcCall })
|
|
197
258
|
: active.id === 'slack'
|
|
198
|
-
? h(SlackSettingsTab, { rpcCall: slackRpcCall })
|
|
259
|
+
? h(SlackSettingsTab, { rpcCall: rpcCalls.slackRpcCall })
|
|
199
260
|
: active.id === 'telegram'
|
|
200
|
-
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
|
|
261
|
+
? h(TelegramSettingsTab, { rpcCall: rpcCalls.telegramRpcCall })
|
|
201
262
|
: active.id === 'discord'
|
|
202
|
-
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
|
|
263
|
+
? h(DiscordSettingsTab, { rpcCall: rpcCalls.discordRpcCall })
|
|
203
264
|
: active.id === 'whatsapp'
|
|
204
|
-
? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
|
|
205
|
-
: h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
|
|
265
|
+
? h(WhatsappSettingsTab, { rpcCall: rpcCalls.whatsappRpcCall })
|
|
266
|
+
: h(OfficeSettingsTab, { rpcCall: rpcCalls.officeRpcCall })),
|
|
206
267
|
),
|
|
207
268
|
));
|
|
208
269
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const TRANSPORT_FORBIDDEN = /^transport failure for \/[A-Za-z0-9._~-]+\/[A-Za-z0-9_$./~-]+: HTTP 403$/;
|
|
2
|
+
|
|
3
|
+
export const LOOPBACK_RECOVERY_ERROR_CODE = 'loopback-recovery-required';
|
|
4
|
+
export const LOOPBACK_RECOVERY_ERROR_MESSAGE =
|
|
5
|
+
'当前地址与浏览器的本机请求校验不兼容。请使用上方按钮改用 localhost 重新打开。';
|
|
6
|
+
|
|
7
|
+
function isIpv4Loopback(hostname) {
|
|
8
|
+
const parts = hostname.split('.');
|
|
9
|
+
return parts.length === 4
|
|
10
|
+
&& parts[0] === '127'
|
|
11
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return a safe localhost navigation target for the known loopback transport failure.
|
|
16
|
+
*/
|
|
17
|
+
export function createLoopbackRecovery(error, location) {
|
|
18
|
+
if (!TRANSPORT_FORBIDDEN.test(error?.message ?? '')) return null;
|
|
19
|
+
if (typeof location?.href !== 'string') return null;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const current = new URL(location.href);
|
|
23
|
+
if (current.protocol !== 'http:' || !isIpv4Loopback(current.hostname)) return null;
|
|
24
|
+
current.hostname = 'localhost';
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
url: current.href,
|
|
27
|
+
origin: current.origin,
|
|
28
|
+
});
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Decorate one IM RPC caller with a narrowly scoped localhost recovery signal.
|
|
36
|
+
*/
|
|
37
|
+
export function createLoopbackAwareRpcCall(rpcCall, {
|
|
38
|
+
location,
|
|
39
|
+
onRecovery,
|
|
40
|
+
} = {}) {
|
|
41
|
+
if (typeof rpcCall !== 'function') throw new TypeError('rpcCall must be a function');
|
|
42
|
+
return async (...args) => {
|
|
43
|
+
try {
|
|
44
|
+
return await rpcCall(...args);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const recovery = createLoopbackRecovery(error, location);
|
|
47
|
+
if (!recovery) throw error;
|
|
48
|
+
onRecovery?.(recovery);
|
|
49
|
+
const presented = new Error(LOOPBACK_RECOVERY_ERROR_MESSAGE);
|
|
50
|
+
presented.code = LOOPBACK_RECOVERY_ERROR_CODE;
|
|
51
|
+
presented.cause = error;
|
|
52
|
+
presented.recoveryUrl = recovery.url;
|
|
53
|
+
throw presented;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Apply the same recovery behavior to every RPC caller in the combined settings page.
|
|
60
|
+
*/
|
|
61
|
+
export function createLoopbackAwareRpcCalls(rpcCalls, options) {
|
|
62
|
+
return Object.freeze(Object.fromEntries(
|
|
63
|
+
Object.entries(rpcCalls).map(([name, rpcCall]) => [
|
|
64
|
+
name,
|
|
65
|
+
typeof rpcCall === 'function'
|
|
66
|
+
? createLoopbackAwareRpcCall(rpcCall, options)
|
|
67
|
+
: rpcCall,
|
|
68
|
+
]),
|
|
69
|
+
));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Navigate without leaving the known-broken loopback address in browser history. */
|
|
73
|
+
export function replacePageLocation(url, location = globalThis.location) {
|
|
74
|
+
location?.replace?.(url);
|
|
75
|
+
}
|
|
@@ -57,6 +57,14 @@ const CSS = String.raw`
|
|
|
57
57
|
.dim-channelNote { overflow: hidden; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; line-height: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
|
58
58
|
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-border-l1, #eef0f3); }
|
|
59
59
|
.dim-panel { min-width: 0; container-type: inline-size; }
|
|
60
|
+
.dim-loopbackRecovery { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 14px; padding: 14px 16px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 12px; color: var(--dsw-alias-label-primary, #1f2329); background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d97706) 8%, var(--dsw-alias-bg-layer-1, #fff)); }
|
|
61
|
+
.dim-loopbackRecoveryCopy { min-width: 0; }
|
|
62
|
+
.dim-loopbackRecoveryCopy strong { display: block; font-size: 14px; line-height: 20px; font-weight: 650; }
|
|
63
|
+
.dim-loopbackRecoveryCopy p { margin: 3px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
|
|
64
|
+
.dim-loopbackRecoveryCopy code { display: block; overflow: hidden; margin-top: 5px; color: var(--dsw-alias-label-secondary, #646a73); font: 11px/16px ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
|
65
|
+
.dim-loopbackRecoveryAction { flex: none; min-height: 34px; display: inline-flex; align-items: center; justify-content: center; padding: 0 12px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; cursor: pointer; }
|
|
66
|
+
.dim-loopbackRecoveryAction:hover { border-color: #0958d9; background: #0958d9; }
|
|
67
|
+
.dim-loopbackRecoveryAction:focus-visible { outline: 2px solid color-mix(in srgb, #1677ff 62%, white); outline-offset: 2px; }
|
|
60
68
|
.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page, .dim-panel .dwecom-page, .dim-panel .dsl-page, .dim-panel .dwa-page { width: 100%; max-width: none; padding: 0 0 24px; }
|
|
61
69
|
.dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; }
|
|
62
70
|
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; justify-content: stretch; gap: 8px; }
|
|
@@ -266,6 +274,8 @@ const CSS = String.raw`
|
|
|
266
274
|
.dim-title p { white-space: normal; }
|
|
267
275
|
.dim-githubTooltip { right: auto; left: 0; }
|
|
268
276
|
.dim-rail { grid-template-columns: minmax(0, 1fr); }
|
|
277
|
+
.dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
|
|
278
|
+
.dim-loopbackRecoveryAction { width: 100%; }
|
|
269
279
|
.dim-directoryPickerBackdrop { padding: 10px; }
|
|
270
280
|
.dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
|
|
271
281
|
.dim-directoryPickerHeader { padding: 18px 17px 14px; }
|
|
@@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
|
|
5
5
|
import { build } from 'esbuild';
|
|
6
6
|
|
|
7
|
+
import { larkSdkHandshakePatch } from './lark-sdk-handshake-patch.mjs';
|
|
8
|
+
|
|
7
9
|
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
|
8
10
|
const packageRoot = resolve(sourceDirectory, '../..');
|
|
9
11
|
const outputPath = resolve(packageRoot, 'lib/index.js');
|
|
@@ -25,6 +27,7 @@ await build({
|
|
|
25
27
|
target: ['node22'],
|
|
26
28
|
mainFields: ['module', 'main'],
|
|
27
29
|
external,
|
|
30
|
+
plugins: [larkSdkHandshakePatch],
|
|
28
31
|
outfile: outputPath,
|
|
29
32
|
banner: {
|
|
30
33
|
js: [
|
|
@@ -23,6 +23,11 @@ const ENDPOINTS = Object.freeze(Object.values(TOKEN_BOT_ENDPOINTS));
|
|
|
23
23
|
const FORBIDDEN_PUBLIC_KEYS = new Set([
|
|
24
24
|
'token', 'botToken', 'tokenRef', 'platformId', 'secret', 'secretRef',
|
|
25
25
|
]);
|
|
26
|
+
const TELEGRAM_NETWORK_ERRORS = new Set([
|
|
27
|
+
'telegram-transport-error',
|
|
28
|
+
'telegram-timeout',
|
|
29
|
+
'telegram-response-invalid',
|
|
30
|
+
]);
|
|
26
31
|
|
|
27
32
|
function isRecord(value) {
|
|
28
33
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -88,6 +93,12 @@ function operationError(channel, error) {
|
|
|
88
93
|
if (error?.code === 'telegram-401' || error?.code === 'discord-401') {
|
|
89
94
|
return { code: 'invalid-token', message: `${channel} Bot Token 无效,请重新填写。` };
|
|
90
95
|
}
|
|
96
|
+
if (channel === 'Telegram' && TELEGRAM_NETWORK_ERRORS.has(error?.code)) {
|
|
97
|
+
return {
|
|
98
|
+
code: 'telegram-network-error',
|
|
99
|
+
message: '无法访问 Telegram Bot API。请检查网络或代理设置;Node.js 22.21+ 可设置 NODE_USE_ENV_PROXY=1,并配置 HTTPS_PROXY、HTTP_PROXY 和 NO_PROXY,然后重启 dsh web。',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
91
102
|
if (error?.code === 'discord-intents') {
|
|
92
103
|
return { code: 'discord-intents', message: error.message };
|
|
93
104
|
}
|
|
@@ -5,7 +5,10 @@ import { join, resolve } from 'node:path';
|
|
|
5
5
|
import { WeixinConfigStore } from '../../../../src/channels/weixin/config-store.mjs';
|
|
6
6
|
import { HarnessClient } from '../../../../src/channels/weixin/harness-client.mjs';
|
|
7
7
|
import { WeixinStateStore } from '../../../../src/channels/weixin/state-store.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
createWeixinApi,
|
|
10
|
+
DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
|
|
11
|
+
} from '../../../../src/channels/weixin/weixin-api.mjs';
|
|
9
12
|
import { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs';
|
|
10
13
|
import { WeixinRuntime } from '../../../../src/channels/weixin/weixin-runtime.mjs';
|
|
11
14
|
import {
|
|
@@ -113,7 +116,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
113
116
|
harness: workspaceScope.harness,
|
|
114
117
|
state: workspaceScope.state,
|
|
115
118
|
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
|
116
|
-
maxMessageChars: config.maxMessageChars ??
|
|
119
|
+
maxMessageChars: config.maxMessageChars ?? DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
|
|
117
120
|
logger: {
|
|
118
121
|
error: (...args) => logger.error?.(`[${botId}]`, ...args),
|
|
119
122
|
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const PATCHES = [
|
|
4
|
+
{
|
|
5
|
+
label: 'WSClient pending-socket state',
|
|
6
|
+
before: ` this.wsConfig = new WSConfig();
|
|
7
|
+
this.reconnectGeneration = 0;
|
|
8
|
+
this.isConnecting = false;`,
|
|
9
|
+
after: ` this.wsConfig = new WSConfig();
|
|
10
|
+
this.reconnectGeneration = 0;
|
|
11
|
+
this.pendingWsInstance = null;
|
|
12
|
+
this.isConnecting = false;`,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
label: 'WSClient pending-socket registration',
|
|
16
|
+
before: ` if (!wsInstance) {
|
|
17
|
+
return Promise.resolve(false);
|
|
18
|
+
}
|
|
19
|
+
return new Promise((resolve) => {`,
|
|
20
|
+
after: ` if (!wsInstance) {
|
|
21
|
+
return Promise.resolve(false);
|
|
22
|
+
}
|
|
23
|
+
this.pendingWsInstance = wsInstance;
|
|
24
|
+
return new Promise((resolve) => {`,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: 'WSClient pending-socket settlement',
|
|
28
|
+
before: ` if (timer)
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
resolve(ok);`,
|
|
31
|
+
after: ` if (timer)
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
if (this.pendingWsInstance === wsInstance)
|
|
34
|
+
this.pendingWsInstance = null;
|
|
35
|
+
resolve(ok);`,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: 'WSClient handshake-timeout listener cleanup',
|
|
39
|
+
before: ` this.logger.error('[ws]', \`handshake timeout after \${this.handshakeTimeoutMs}ms\`);
|
|
40
|
+
wsInstance.removeAllListeners();`,
|
|
41
|
+
after: ` this.logger.error('[ws]', \`handshake timeout after \${this.handshakeTimeoutMs}ms\`);
|
|
42
|
+
wsInstance.removeAllListeners('open');`,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
label: 'WSClient reconnect generation fences',
|
|
46
|
+
before: ` const tryConnect = () => __awaiter(this, void 0, void 0, function* () {
|
|
47
|
+
this.reconnectInfo.lastConnectTime = Date.now();
|
|
48
|
+
const pullResult = yield this.pullConnectConfig();
|
|
49
|
+
if (!pullResult.ok)
|
|
50
|
+
return pullResult;
|
|
51
|
+
const connected = yield this.connect();
|
|
52
|
+
if (!connected)
|
|
53
|
+
return { ok: false, retryable: true };
|
|
54
|
+
this.communicate();
|
|
55
|
+
return { ok: true };
|
|
56
|
+
});`,
|
|
57
|
+
after: ` const tryConnect = () => __awaiter(this, void 0, void 0, function* () {
|
|
58
|
+
if (currentGeneration !== this.reconnectGeneration)
|
|
59
|
+
return { ok: false, retryable: false, cancelled: true };
|
|
60
|
+
this.reconnectInfo.lastConnectTime = Date.now();
|
|
61
|
+
const pullResult = yield this.pullConnectConfig();
|
|
62
|
+
if (currentGeneration !== this.reconnectGeneration)
|
|
63
|
+
return { ok: false, retryable: false, cancelled: true };
|
|
64
|
+
if (!pullResult.ok)
|
|
65
|
+
return pullResult;
|
|
66
|
+
const connected = yield this.connect();
|
|
67
|
+
if (currentGeneration !== this.reconnectGeneration)
|
|
68
|
+
return { ok: false, retryable: false, cancelled: true };
|
|
69
|
+
if (!connected)
|
|
70
|
+
return { ok: false, retryable: true };
|
|
71
|
+
this.communicate();
|
|
72
|
+
return { ok: true };
|
|
73
|
+
});`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
label: 'WSClient initial-connect cancellation fence',
|
|
77
|
+
before: ` try {
|
|
78
|
+
result = yield tryConnect();
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
this.isConnecting = false;
|
|
82
|
+
}
|
|
83
|
+
if (result.ok) {`,
|
|
84
|
+
after: ` try {
|
|
85
|
+
result = yield tryConnect();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
if (currentGeneration === this.reconnectGeneration) {
|
|
89
|
+
this.isConnecting = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (currentGeneration !== this.reconnectGeneration || result.cancelled) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (result.ok) {`,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
label: 'WSClient pending-socket close',
|
|
99
|
+
before: ` const wsInstance = this.wsConfig.getWSInstance();
|
|
100
|
+
if (wsInstance) {`,
|
|
101
|
+
after: ` const pendingWsInstance = this.pendingWsInstance;
|
|
102
|
+
if (pendingWsInstance) {
|
|
103
|
+
this.pendingWsInstance = null;
|
|
104
|
+
pendingWsInstance.removeAllListeners('open');
|
|
105
|
+
try {
|
|
106
|
+
if (force) {
|
|
107
|
+
pendingWsInstance.terminate();
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
pendingWsInstance.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch ( /* best effort */_a) { /* best effort */ }
|
|
114
|
+
}
|
|
115
|
+
const wsInstance = this.wsConfig.getWSInstance();
|
|
116
|
+
if (wsInstance) {`,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
label: 'WSClient idempotent start guard',
|
|
120
|
+
before: ` const { eventDispatcher } = params;
|
|
121
|
+
if (!eventDispatcher) {
|
|
122
|
+
this.logger.warn('[ws]', 'client need to start with a eventDispatcher');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Clear any terminal-error state left over from a previous session so`,
|
|
126
|
+
after: ` const { eventDispatcher } = params;
|
|
127
|
+
if (!eventDispatcher) {
|
|
128
|
+
this.logger.warn('[ws]', 'client need to start with a eventDispatcher');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const liveWsInstance = this.wsConfig.getWSInstance();
|
|
132
|
+
if (this.terminalError) {
|
|
133
|
+
this.isConnecting = false;
|
|
134
|
+
}
|
|
135
|
+
if (this.isConnecting ||
|
|
136
|
+
(liveWsInstance && liveWsInstance.readyState !== WebSocket.CLOSED)) {
|
|
137
|
+
this.logger.debug('[ws]', 'start ignored because client is already connecting or connected');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Clear any terminal-error state left over from a previous session so`,
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
function replaceExactlyOnce(source, patch, sourcePath) {
|
|
145
|
+
const matches = source.split(patch.before).length - 1;
|
|
146
|
+
if (matches !== 1) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`${sourcePath}: expected exactly one reviewed ${patch.label} marker, found ${matches}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return source.replace(patch.before, patch.after);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Patch @larksuiteoapi/node-sdk 1.73.0's WSClient lifecycle.
|
|
156
|
+
*
|
|
157
|
+
* The vendor client cannot normally close a socket until its WebSocket
|
|
158
|
+
* handshake has opened. Its timeout also removes the socket's `error`
|
|
159
|
+
* listener before terminating it, and the initial connection path can resume
|
|
160
|
+
* after close() and start a zombie reconnect loop. Track and safely terminate
|
|
161
|
+
* the pending socket, retain its error listener, and fence every async initial
|
|
162
|
+
* connection stage with the reconnect generation already maintained by the
|
|
163
|
+
* SDK. Every reviewed source fragment must match exactly once so an SDK source
|
|
164
|
+
* change fails the build instead of silently losing the compatibility fix.
|
|
165
|
+
*/
|
|
166
|
+
export function patchLarkSdkHandshakeSource(source, sourcePath = 'Lark SDK') {
|
|
167
|
+
return PATCHES.reduce(
|
|
168
|
+
(patched, patch) => replaceExactlyOnce(patched, patch, sourcePath),
|
|
169
|
+
source,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const larkSdkHandshakePatch = {
|
|
174
|
+
name: 'dsh-lark-sdk-websocket-lifecycle-fix',
|
|
175
|
+
setup(build) {
|
|
176
|
+
build.onLoad({ filter: /@larksuiteoapi[\\/]node-sdk[\\/](es|lib)[\\/]index\.js$/ }, async ({ path }) => ({
|
|
177
|
+
contents: patchLarkSdkHandshakeSource(await readFile(path, 'utf8'), path),
|
|
178
|
+
loader: 'js',
|
|
179
|
+
}));
|
|
180
|
+
},
|
|
181
|
+
};
|
|
@@ -57,8 +57,10 @@ const HELP_TEXT_LINES = [
|
|
|
57
57
|
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
58
58
|
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
|
|
59
59
|
'/models 按序号列出所有可用模型',
|
|
60
|
-
'/
|
|
61
|
-
'
|
|
60
|
+
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
|
|
61
|
+
'/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
|
|
62
|
+
'/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
|
|
63
|
+
'示例:先发 /models,再发 /model 2 [推理等级ID]',
|
|
62
64
|
'/presetlist 按序号列出可用 Agent Preset',
|
|
63
65
|
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
|
64
66
|
'纯数字 ID:/preset id:<ID>',
|