@xmanrui/dsh-im 4.19.2 → 4.20.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 +78 -0
- package/README.md +78 -0
- package/lib/client.js +594 -217
- package/lib/index.js +279 -276
- package/package.json +9 -1
- package/plugin-src/client/channels/weixin/api.js +35 -17
- package/plugin-src/client/channels/weixin/connection-error.js +68 -0
- package/plugin-src/client/channels/weixin/index.js +36 -12
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/host/channels/shared/startup.mjs +7 -4
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
- package/plugin-src/host/channels/weixin/index.mjs +12 -3
- package/plugin-src/host/channels/weixin/production.mjs +53 -3
- package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
- package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
- package/src/channels/feishu/bridge.mjs +44 -13
- package/src/channels/qq/qq-bridge.mjs +12 -4
- package/src/channels/qq/qq-menu.mjs +11 -8
- package/src/channels/shared/bot-workspace-store.mjs +532 -40
- package/src/channels/shared/command-catalog.mjs +5 -0
- package/src/channels/shared/compact-command.mjs +14 -4
- package/src/channels/shared/control-command.mjs +1 -1
- package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
- package/src/channels/shared/history-command.mjs +1 -1
- package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/model-command.mjs +5 -3
- package/src/channels/shared/workspace-command.mjs +114 -9
- package/src/channels/shared/workspace-session.mjs +55 -5
- package/src/channels/telegram/telegram-runtime.mjs +1 -1
- package/src/channels/wecom/wecom-bridge.mjs +2 -2
- package/src/channels/weixin/connection-error.en.mjs +116 -0
- package/src/channels/weixin/connection-error.mjs +204 -0
- package/src/channels/weixin/diagnostic-details.mjs +40 -0
- package/src/channels/weixin/state-store.mjs +4 -3
- package/src/channels/weixin/weixin-api.mjs +20 -8
- package/src/channels/weixin/weixin-bridge.mjs +3 -2
- package/src/channels/weixin/weixin-controller.mjs +133 -104
- package/src/channels/weixin/weixin-runtime.mjs +35 -24
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import packageInfo from '../../../package.json' with { type: 'json' };
|
|
3
|
+
import { t } from '../shared/i18n.mjs';
|
|
4
|
+
import { normalizeWeixinDiagnosticDetails } from './diagnostic-details.mjs';
|
|
5
|
+
|
|
6
|
+
const MESSAGES = Object.freeze({
|
|
7
|
+
'credential-read-failed': '无法读取现有登录凭据。请检查 DSH 凭据存储。',
|
|
8
|
+
'credential-save-failed': '登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。',
|
|
9
|
+
'account-config-save-failed': '账号配置无法写入本机。请检查 DSH_HOME 目录权限。',
|
|
10
|
+
'runtime-prepare-failed': '无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
|
|
11
|
+
'harness-connect-failed': '插件无法连接当前 Harness,请检查宿主是否已就绪。',
|
|
12
|
+
'harness-timeout': 'Harness 健康检查超时。请确认 dsh web 未阻塞。',
|
|
13
|
+
'harness-auth-required': 'Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。',
|
|
14
|
+
'harness-proxy-auth-required': '本机 Harness 请求被代理要求认证。请让回环地址绕过代理,并检查 NO_PROXY 配置。',
|
|
15
|
+
'harness-loopback-forbidden': 'Harness 异常拒绝了回环地址的健康检查。请检查 HTTP 代理、Harness 源码版本和构建产物。',
|
|
16
|
+
'harness-host-untrusted': 'Harness 的 Host 信任检查拒绝了非回环地址请求。请检查 harnessBaseUrl 与 trustedHosts 配置。',
|
|
17
|
+
'harness-request-forbidden': '健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。',
|
|
18
|
+
'harness-api-not-found': '找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
|
|
19
|
+
'harness-http-failed': 'Harness 健康检查返回服务错误。请查看 DSH 日志。',
|
|
20
|
+
'harness-response-invalid': 'Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。',
|
|
21
|
+
'harness-rpc-rejected': 'Harness 拒绝了健康检查请求。请查看 DSH 日志。',
|
|
22
|
+
'harness-check-unknown-failed': 'Harness 健康检查发生未知错误。请查看 DSH 日志。',
|
|
23
|
+
'connection-start-failed': '消息连接初始化失败。请查看 DSH 日志后重试。',
|
|
24
|
+
|
|
25
|
+
'network-error': '暂时无法访问微信服务。',
|
|
26
|
+
'timeout': '微信服务请求超时。',
|
|
27
|
+
'http-error': '微信服务请求失败(HTTP {status})。',
|
|
28
|
+
'invalid-response': '微信服务返回了无法解析的响应。',
|
|
29
|
+
'invalid-qr': '微信服务没有返回有效二维码。',
|
|
30
|
+
'untrusted-qr': '微信服务返回了不受信任的扫码地址。',
|
|
31
|
+
'invalid-base-url': '微信服务返回了无效的连接地址。',
|
|
32
|
+
'untrusted-base-url': '微信服务返回了不受信任的连接地址。',
|
|
33
|
+
'untrusted-endpoint': '拒绝访问不受信任的微信服务地址。',
|
|
34
|
+
'invalid-login-status': '微信服务返回了无法识别的扫码状态。',
|
|
35
|
+
'incomplete-login': '微信授权成功,但返回的账号凭据不完整。',
|
|
36
|
+
'qr-request-rejected': '微信服务拒绝了二维码申请。',
|
|
37
|
+
'qr-encode-failed': '已取得扫码地址,但本机二维码图片生成失败。',
|
|
38
|
+
'qr-start-failed': '无法生成微信二维码。',
|
|
39
|
+
'provision-attempt-not-found': '这次微信绑定任务已不存在,请重新读取状态或生成二维码。',
|
|
40
|
+
'provision-state-invalid': '这次微信绑定任务当前不能提交配对码,请重新读取状态。',
|
|
41
|
+
'missing-token': '登录凭据缺失,请移除账号后重新扫码。',
|
|
42
|
+
'stale-token': '微信登录凭据已失效,请移除账号后重新扫码。',
|
|
43
|
+
'credential-remove-failed': '无法从 DSH 凭据存储移除微信登录凭据。',
|
|
44
|
+
'account-config-remove-failed': '微信账号配置移除失败。',
|
|
45
|
+
'account-state-read-failed': '无法读取微信账号状态文件。',
|
|
46
|
+
'account-state-write-failed': '无法保存微信账号状态文件。',
|
|
47
|
+
'account-state-cleanup-failed': '微信账号已移除,但本机状态文件清理失败。',
|
|
48
|
+
'workspace-save-failed': '无法保存微信工作区设置。',
|
|
49
|
+
'workspace-cleanup-failed': '微信账号已移除,但工作区设置清理失败。',
|
|
50
|
+
'start-rejected': '微信账号连接启动失败。',
|
|
51
|
+
'updates-rejected': '微信消息同步请求被拒绝。',
|
|
52
|
+
'stop-rejected': '微信服务未确认停止通知。',
|
|
53
|
+
'connection-stop-failed': '微信消息连接停止时发生错误。',
|
|
54
|
+
'status-read-failed': '无法读取微信连接状态,请重新读取;之前的操作可能已完成。',
|
|
55
|
+
'activation-unknown-failed': '微信激活过程中发生未知错误。',
|
|
56
|
+
'weixin-operation-failed': '微信操作发生未知错误。',
|
|
57
|
+
'weixin-startup-config-invalid': '微信配置格式错误,请检查 config.json 和 workspaces.json 后重启 DSH。',
|
|
58
|
+
'weixin-startup-permission-denied': '微信配置访问权限不足,请检查数据目录权限后重启 DSH。',
|
|
59
|
+
'weixin-startup-failed': '微信初始化失败,请查看 DSH 启动日志。',
|
|
60
|
+
'connection-failed': '微信连接未就绪。',
|
|
61
|
+
'rollback-failed': '微信操作失败后,原账号状态恢复失败。',
|
|
62
|
+
'workspace-not-absolute': '请输入工作区绝对路径。',
|
|
63
|
+
'workspace-not-found': '工作区目录不存在。',
|
|
64
|
+
'workspace-not-directory': '工作区路径不是目录。',
|
|
65
|
+
'workspace-bot-not-found': '找不到要修改的机器人。',
|
|
66
|
+
'agent-preset-invalid': 'Agent Preset 无效。',
|
|
67
|
+
'agent-preset-unavailable': 'Agent Preset 不存在或不可用。',
|
|
68
|
+
'model-selection-invalid': '模型无效。',
|
|
69
|
+
'model-selection-unavailable': '模型不存在或不可用。',
|
|
70
|
+
'model-reasoning-unavailable': '当前模型不支持所选思考强度,请重新选择。',
|
|
71
|
+
'context-enhancement-invalid': '上下文增强设置无效。',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const ownedStages = new WeakMap();
|
|
75
|
+
const reportedErrors = new WeakMap();
|
|
76
|
+
const CODE_STAGES = {
|
|
77
|
+
'credential-read-failed': ['credential.read', 'credential-store'],
|
|
78
|
+
'credential-save-failed': ['credential.save', 'credential-store'],
|
|
79
|
+
'credential-remove-failed': ['credential.remove', 'credential-store'],
|
|
80
|
+
'missing-token': ['credential.read', 'credential-store'],
|
|
81
|
+
'account-config-save-failed': ['account.save', 'account-config'],
|
|
82
|
+
'account-config-remove-failed': ['account.remove', 'account-config'],
|
|
83
|
+
'account-state-read-failed': ['state.load', 'account-state'],
|
|
84
|
+
'account-state-write-failed': ['state.write', 'account-state'],
|
|
85
|
+
'account-state-cleanup-failed': ['state.cleanup', 'account-state'],
|
|
86
|
+
'workspace-save-failed': ['workspace.write', 'workspace-config'],
|
|
87
|
+
'workspace-cleanup-failed': ['workspace.cleanup', 'workspace-config'],
|
|
88
|
+
'qr-encode-failed': ['qr.encode'], 'qr-request-rejected': ['qr.begin'],
|
|
89
|
+
'start-rejected': ['connection.start'], 'updates-rejected': ['connection.poll'],
|
|
90
|
+
'stop-rejected': ['connection.stop'], 'rollback-failed': ['rollback'],
|
|
91
|
+
'runtime-prepare-failed': ['runtime.prepare'], 'connection-start-failed': ['connection.start'],
|
|
92
|
+
'status-read-failed': ['status.read'],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function knownWeixinErrorCode(code) { return Object.hasOwn(MESSAGES, code ?? ''); }
|
|
96
|
+
|
|
97
|
+
/** Annotate at the operation that knows the failing resource; do not inspect exception prose. */
|
|
98
|
+
export function weixinStageError(code, cause, stage) {
|
|
99
|
+
if (reportedErrors.has(cause)) return cause;
|
|
100
|
+
const error = new Error('Weixin operation failed', { cause });
|
|
101
|
+
error.code = knownWeixinErrorCode(code) ? code : 'weixin-operation-failed';
|
|
102
|
+
const defaults = CODE_STAGES[error.code] ?? [];
|
|
103
|
+
ownedStages.set(error, { stage: stage ?? defaults[0], resource: defaults[1] });
|
|
104
|
+
return error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function chainOf(error) {
|
|
108
|
+
const chain = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
while (error && typeof error === 'object' && !seen.has(error) && chain.length < 4) {
|
|
111
|
+
seen.add(error);
|
|
112
|
+
chain.push(error);
|
|
113
|
+
error = error.cause;
|
|
114
|
+
}
|
|
115
|
+
return chain;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hintFor(code, details) {
|
|
119
|
+
if (details.rollback === 'failed' || details.rollback === 'unknown') return t('原账号状态尚未确认,请重新读取状态,并反馈诊断信息。');
|
|
120
|
+
if (details.rollback === 'succeeded') return t('操作未完成,已恢复之前的本机状态。请处理上述问题后重试。');
|
|
121
|
+
if (code.startsWith('harness-')) return t('请确认当前 DSH 宿主已就绪,并检查 Harness 与插件实际运行版本及宿主日志。');
|
|
122
|
+
if (details.reason === 'read-only') return t('凭据由只读来源提供,请在启动 DSH 的配置中检查该来源。');
|
|
123
|
+
if (details.reason === 'ENOTFOUND' || details.reason === 'EAI_AGAIN') return t('微信服务域名解析失败,请检查运行 DSH 的机器的网络和 DNS 设置后重试。');
|
|
124
|
+
if (['EACCES', 'EPERM', 'EROFS'].includes(details.reason)) return t('请检查 DSH 数据目录和对应文件的读写权限。');
|
|
125
|
+
if (details.reason === 'ENOSPC') return t('磁盘空间不足,请释放空间后重试。');
|
|
126
|
+
if (details.reason === 'invalid-json' && details.resource) return t('本机文件格式无效,请检查对应配置或状态文件;不要清空登录凭据。');
|
|
127
|
+
if (/CERT|TLS|SSL/.test(details.reason ?? '')) return t('请检查运行 DSH 的机器的系统时间、证书和网络设置。');
|
|
128
|
+
if (code === 'stale-token' || code === 'missing-token') return t('请移除失效接入并重新扫码绑定。');
|
|
129
|
+
if (details.httpStatus === 429) return t('微信服务限流,请稍后重试。');
|
|
130
|
+
if (details.httpStatus === 401 || details.httpStatus === 403) return t('访问被拒绝,请检查服务访问限制;HTTP 状态本身不能确认登录凭据已失效。');
|
|
131
|
+
if (code === 'network-error' || code === 'timeout') return t('请检查运行 DSH 的机器能否访问微信服务,然后重试。');
|
|
132
|
+
return t('请按参考号查看 DSH 日志,并复制诊断信息反馈。');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createWeixinDiagnostics({ logger = console, now = Date.now } = {}) {
|
|
136
|
+
const recent = new Map();
|
|
137
|
+
function write(level, record) {
|
|
138
|
+
try { logger[level]?.(`[dsh-weixin] ${JSON.stringify(record)}`); } catch { /* Diagnostics must not change the operation outcome. */ }
|
|
139
|
+
}
|
|
140
|
+
function report(cause, context = {}) {
|
|
141
|
+
if (reportedErrors.has(cause)) return cause;
|
|
142
|
+
const chain = chainOf(cause);
|
|
143
|
+
const selected = chain.find(error => knownWeixinErrorCode(error.code));
|
|
144
|
+
const code = selected?.code ?? (knownWeixinErrorCode(context.code) ? context.code : 'weixin-operation-failed');
|
|
145
|
+
const staged = chain.map(error => ownedStages.get(error)).find(Boolean) ?? {};
|
|
146
|
+
const defaults = CODE_STAGES[code] ?? [];
|
|
147
|
+
const reason = chain.map(error => normalizeWeixinDiagnosticDetails({ reason: error.code }).reason).find(Boolean)
|
|
148
|
+
?? (chain.some(error => error instanceof SyntaxError) ? 'invalid-json' : undefined);
|
|
149
|
+
const numeric = field => chain.map(error => normalizeWeixinDiagnosticDetails({ [field]: field === 'httpStatus' ? error.status : error[field] })[field]).find(value => value !== undefined);
|
|
150
|
+
const details = normalizeWeixinDiagnosticDetails({
|
|
151
|
+
...context, stage: staged.stage ?? defaults[0] ?? (code.startsWith('harness-') ? 'harness.check' : context.stage),
|
|
152
|
+
resource: staged.resource ?? defaults[1] ?? context.resource,
|
|
153
|
+
reason: reason ?? context.reason, httpStatus: numeric('httpStatus'), providerCode: numeric('providerCode'), pluginVersion: packageInfo.version,
|
|
154
|
+
});
|
|
155
|
+
const botId = /^wx_[a-f0-9]{24}$/.test(context.botId ?? '') ? context.botId : undefined;
|
|
156
|
+
const key = JSON.stringify([botId, details.operation, details.stage, code, details.reason, details.httpStatus, details.providerCode]);
|
|
157
|
+
const previous = recent.get(key);
|
|
158
|
+
const time = now();
|
|
159
|
+
if (context.automatic && previous && time - previous.time < 60_000) {
|
|
160
|
+
previous.retries += 1;
|
|
161
|
+
const wrapped = new Error(previous.publicError.message, { cause });
|
|
162
|
+
wrapped.code = code;
|
|
163
|
+
wrapped.publicError = structuredClone(previous.publicError);
|
|
164
|
+
reportedErrors.set(wrapped, true);
|
|
165
|
+
return wrapped;
|
|
166
|
+
}
|
|
167
|
+
if (previous?.retries) write('warn', { event: 'connection-retries', referenceId: previous.publicError.details.referenceId, retries: previous.retries });
|
|
168
|
+
details.referenceId = `WX-CONN-${randomUUID().replaceAll('-', '').slice(0, 8).toUpperCase()}`;
|
|
169
|
+
details.occurredAt = new Date(time).toISOString();
|
|
170
|
+
details.hint = hintFor(code, details);
|
|
171
|
+
const publicError = { code, message: t(MESSAGES[code], { status: details.httpStatus ?? '?' }), details };
|
|
172
|
+
const wrapped = new Error(publicError.message, { cause });
|
|
173
|
+
wrapped.code = code;
|
|
174
|
+
wrapped.publicError = publicError;
|
|
175
|
+
reportedErrors.set(wrapped, true);
|
|
176
|
+
if (context.automatic) {
|
|
177
|
+
if (recent.size >= 128) recent.delete(recent.keys().next().value);
|
|
178
|
+
recent.set(key, { time, publicError: structuredClone(publicError), retries: 0, botId });
|
|
179
|
+
}
|
|
180
|
+
write(context.warning ? 'warn' : 'error', {
|
|
181
|
+
event: context.warning ? 'connection-warning' : 'connection-failure', code, ...details,
|
|
182
|
+
...(botId ? { botId } : {}), pluginVersion: packageInfo.version, nodeVersion: process.versions.node, platform: process.platform,
|
|
183
|
+
...(/^WX-CONN-[A-F0-9]{8}$/.test(context.parentReferenceId ?? '') ? { parentReferenceId: context.parentReferenceId } : {}),
|
|
184
|
+
...Object.fromEntries(['durationMs', 'timeoutMs'].filter(field => Number.isFinite(cause?.[field]) && cause[field] >= 0).map(field => [field, cause[field]])),
|
|
185
|
+
});
|
|
186
|
+
return wrapped;
|
|
187
|
+
}
|
|
188
|
+
function clear(botId) {
|
|
189
|
+
for (const [key, entry] of recent) {
|
|
190
|
+
if (botId !== undefined && entry.botId !== botId) continue;
|
|
191
|
+
if (entry.retries) write('info', { event: 'connection-retries', referenceId: entry.publicError.details.referenceId, retries: entry.retries });
|
|
192
|
+
recent.delete(key);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function outcome(error, rollback) {
|
|
196
|
+
if (!reportedErrors.has(error)) return;
|
|
197
|
+
const details = normalizeWeixinDiagnosticDetails({ rollback });
|
|
198
|
+
if (!details.rollback) return;
|
|
199
|
+
error.publicError.details.rollback = details.rollback;
|
|
200
|
+
error.publicError.details.hint = hintFor(error.code, error.publicError.details);
|
|
201
|
+
write('info', { event: 'connection-outcome', referenceId: error.publicError.details.referenceId, ...details });
|
|
202
|
+
}
|
|
203
|
+
return { report, clear, outcome };
|
|
204
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Shared by Host and Client: only explicitly admitted diagnostic facts cross the UI boundary.
|
|
2
|
+
const OPERATIONS = new Set([
|
|
3
|
+
'startup', 'connection.restore', 'connection.monitor', 'connection.close', 'connection.status',
|
|
4
|
+
'provision.begin', 'provision.poll', 'provision.verify', 'provision.cancel', 'bot.reconnect', 'bot.delete',
|
|
5
|
+
'bot.workspace.set', 'bot.model.set', 'bot.agent-preset.set', 'bot.context-enhancement.set', 'bot.access-policy.set', 'bot.alias.set',
|
|
6
|
+
]);
|
|
7
|
+
const STAGES = new Set([
|
|
8
|
+
'startup.load', 'qr.begin', 'qr.encode', 'qr.poll', 'qr.verify', 'qr.cancel', 'credential.read', 'credential.save', 'credential.remove',
|
|
9
|
+
'account.save', 'account.remove', 'state.load', 'state.write', 'state.cleanup', 'workspace.write', 'workspace.cleanup',
|
|
10
|
+
'runtime.prepare', 'activation', 'harness.check', 'connection.start', 'connection.poll', 'connection.stop', 'status.read', 'rollback', 'management.request',
|
|
11
|
+
]);
|
|
12
|
+
const REASONS = new Set([
|
|
13
|
+
'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ETIMEDOUT', 'EPIPE',
|
|
14
|
+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET',
|
|
15
|
+
'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN',
|
|
16
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID', 'ERR_SSL_WRONG_VERSION_NUMBER',
|
|
17
|
+
'ENOENT', 'EACCES', 'EPERM', 'ENOSPC', 'EROFS', 'ENOTDIR', 'EISDIR', 'EBUSY', 'EIO', 'EXDEV', 'EMFILE', 'ENFILE', 'EEXIST',
|
|
18
|
+
'invalid-json', 'read-only',
|
|
19
|
+
]);
|
|
20
|
+
const RESOURCES = new Set(['credential-store', 'account-config', 'account-state', 'workspace-config', 'workspace-directory']);
|
|
21
|
+
|
|
22
|
+
export function normalizeWeixinDiagnosticDetails(value) {
|
|
23
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
24
|
+
const result = {};
|
|
25
|
+
for (const [field, allowed] of [['operation', OPERATIONS], ['stage', STAGES], ['reason', REASONS], ['resource', RESOURCES]]) {
|
|
26
|
+
if (allowed.has(value[field])) result[field] = value[field];
|
|
27
|
+
}
|
|
28
|
+
if (/^WX-CONN-[A-F0-9]{8}$/.test(value.referenceId ?? '')) result.referenceId = value.referenceId;
|
|
29
|
+
if (typeof value.occurredAt === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value.occurredAt)
|
|
30
|
+
&& Number.isFinite(Date.parse(value.occurredAt))) result.occurredAt = value.occurredAt;
|
|
31
|
+
if (Number.isInteger(value.httpStatus) && value.httpStatus >= 100 && value.httpStatus <= 599) result.httpStatus = value.httpStatus;
|
|
32
|
+
const provider = typeof value.providerCode === 'number' && Number.isSafeInteger(value.providerCode)
|
|
33
|
+
? String(value.providerCode) : value.providerCode;
|
|
34
|
+
if (typeof provider === 'string' && /^-?\d{1,12}$/.test(provider)) result.providerCode = provider;
|
|
35
|
+
if (typeof value.pluginVersion === 'string' && /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(value.pluginVersion)
|
|
36
|
+
&& value.pluginVersion.length <= 64) result.pluginVersion = value.pluginVersion;
|
|
37
|
+
if (['succeeded', 'failed', 'not-attempted', 'unknown'].includes(value.rollback)) result.rollback = value.rollback;
|
|
38
|
+
if (typeof value.hint === 'string' && value.hint.trim()) result.hint = value.hint.trim().slice(0, 500);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { weixinStageError } from './connection-error.mjs';
|
|
1
2
|
import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
@@ -123,7 +124,7 @@ export class WeixinStateStore {
|
|
|
123
124
|
try {
|
|
124
125
|
this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
125
126
|
} catch (error) {
|
|
126
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
127
|
+
if (error?.code !== 'ENOENT') throw weixinStageError('account-state-read-failed', error);
|
|
127
128
|
this.#state = structuredClone(EMPTY_STATE);
|
|
128
129
|
await this.#persist();
|
|
129
130
|
}
|
|
@@ -271,7 +272,7 @@ export class WeixinStateStore {
|
|
|
271
272
|
try {
|
|
272
273
|
await unlink(this.#path);
|
|
273
274
|
} catch (error) {
|
|
274
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
275
|
+
if (error?.code !== 'ENOENT') throw weixinStageError('account-state-cleanup-failed', error);
|
|
275
276
|
}
|
|
276
277
|
this.#state = structuredClone(EMPTY_STATE);
|
|
277
278
|
}
|
|
@@ -285,6 +286,6 @@ export class WeixinStateStore {
|
|
|
285
286
|
await rename(temporary, this.#path);
|
|
286
287
|
});
|
|
287
288
|
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
288
|
-
await operation;
|
|
289
|
+
await operation.catch(error => { throw weixinStageError('account-state-write-failed', error); });
|
|
289
290
|
}
|
|
290
291
|
}
|
|
@@ -93,9 +93,9 @@ function uncertainWeixinDelivery(cause) {
|
|
|
93
93
|
return preserveArtifactMetadata(error, cause);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
function rejectedProviderResponse(value) {
|
|
96
|
+
export function rejectedProviderResponse(value, fields = ['ret', 'errcode']) {
|
|
97
97
|
if (!value || typeof value !== 'object') return null;
|
|
98
|
-
for (const field of
|
|
98
|
+
for (const field of fields) {
|
|
99
99
|
if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
|
|
100
100
|
return safeProviderCode(value[field]) ?? 'rejected';
|
|
101
101
|
}
|
|
@@ -411,6 +411,7 @@ async function requestJson(fetchImpl, {
|
|
|
411
411
|
}
|
|
412
412
|
|
|
413
413
|
const controller = new AbortController();
|
|
414
|
+
const startedAt = Date.now();
|
|
414
415
|
let timedOut = false;
|
|
415
416
|
const onAbort = () => controller.abort(signal?.reason);
|
|
416
417
|
if (signal?.aborted) throw abortError(signal);
|
|
@@ -441,11 +442,16 @@ async function requestJson(fetchImpl, {
|
|
|
441
442
|
}
|
|
442
443
|
} catch (error) {
|
|
443
444
|
if (signal?.aborted) throw abortError(signal);
|
|
445
|
+
let failure;
|
|
444
446
|
if (timedOut) {
|
|
445
|
-
|
|
447
|
+
failure = new WeixinApiError('timeout', '微信服务请求超时。', { cause: error });
|
|
448
|
+
} else {
|
|
449
|
+
failure = error instanceof WeixinApiError ? error
|
|
450
|
+
: new WeixinApiError('network-error', '暂时无法访问微信服务。', { cause: error });
|
|
446
451
|
}
|
|
447
|
-
|
|
448
|
-
|
|
452
|
+
failure.durationMs = Date.now() - startedAt;
|
|
453
|
+
failure.timeoutMs = timeoutMs;
|
|
454
|
+
throw failure;
|
|
449
455
|
} finally {
|
|
450
456
|
if (timer) clearTimeout(timer);
|
|
451
457
|
signal?.removeEventListener('abort', onAbort);
|
|
@@ -599,6 +605,8 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
599
605
|
signal,
|
|
600
606
|
});
|
|
601
607
|
const qrcode = nonEmptyString(response?.qrcode);
|
|
608
|
+
const providerCode = rejectedProviderResponse(response, ['errcode', 'ret']);
|
|
609
|
+
if (providerCode) throw new WeixinApiError('qr-request-rejected', '微信服务拒绝了二维码申请。', { providerCode });
|
|
602
610
|
if (!qrcode) throw new WeixinApiError('invalid-qr', '微信服务没有返回二维码令牌。');
|
|
603
611
|
return {
|
|
604
612
|
qrcode,
|
|
@@ -764,14 +772,15 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
764
772
|
timeoutMs: 10_000,
|
|
765
773
|
body: { base_info: baseInfo() },
|
|
766
774
|
});
|
|
767
|
-
|
|
768
|
-
|
|
775
|
+
const providerCode = rejectedProviderResponse(response, ['errcode', 'ret']);
|
|
776
|
+
if (providerCode) {
|
|
777
|
+
throw new WeixinApiError(providerCode === '-14' ? 'stale-token' : 'start-rejected', '微信账号连接启动失败。', { providerCode });
|
|
769
778
|
}
|
|
770
779
|
return response;
|
|
771
780
|
},
|
|
772
781
|
|
|
773
782
|
async notifyStop({ baseUrl, token, signal }) {
|
|
774
|
-
|
|
783
|
+
const response = await requestJson(fetchImpl, {
|
|
775
784
|
method: 'POST',
|
|
776
785
|
baseUrl,
|
|
777
786
|
endpoint: 'ilink/bot/msg/notifystop',
|
|
@@ -780,6 +789,9 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
|
|
|
780
789
|
timeoutMs: 10_000,
|
|
781
790
|
body: { base_info: baseInfo() },
|
|
782
791
|
});
|
|
792
|
+
const providerCode = rejectedProviderResponse(response, ['errcode', 'ret']);
|
|
793
|
+
if (providerCode) throw new WeixinApiError('stop-rejected', '微信服务未确认停止通知。', { providerCode });
|
|
794
|
+
return response;
|
|
783
795
|
},
|
|
784
796
|
});
|
|
785
797
|
}
|
|
@@ -567,9 +567,10 @@ export class WeixinHarnessBridge {
|
|
|
567
567
|
].find(Number.isSafeInteger);
|
|
568
568
|
if (quotedAt === undefined) return { unavailableReason: 'not-delivered' };
|
|
569
569
|
const sender = nonEmptyString(reference?.toUserId);
|
|
570
|
-
const
|
|
570
|
+
const key = sender ? conversationKey(sender) : null;
|
|
571
|
+
const sessionId = key ? this.#state.sessionFor(key) : null;
|
|
571
572
|
const session = typeof sessionId === 'string' && sessionId
|
|
572
|
-
? this.#harness.workspaceSession?.(sessionId)
|
|
573
|
+
? this.#harness.workspaceSession?.(sessionId, key)
|
|
573
574
|
: null;
|
|
574
575
|
if (typeof session?.readHistory !== 'function') {
|
|
575
576
|
return { unavailableReason: 'not-delivered' };
|