@xmanrui/dsh-im 4.14.0 → 4.16.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 +10 -2
- package/README.md +17 -6
- package/lib/client.js +1980 -616
- package/lib/index.js +303 -293
- package/package.json +6 -2
- package/plugin-src/client/access-policy-settings.js +6 -0
- package/plugin-src/client/channels/feishu/index.js +16 -4
- package/plugin-src/client/channels/wecom-app/api.js +124 -0
- package/plugin-src/client/channels/wecom-app/index.js +612 -0
- package/plugin-src/client/channels/wecom-app/styles.js +25 -0
- package/plugin-src/client/delivery-settings.js +7 -0
- package/plugin-src/client/i18n.js +64 -1
- package/plugin-src/client/index.js +21 -0
- package/plugin-src/client/model-setting.js +135 -69
- package/plugin-src/client/session-channel-logos.js +241 -0
- package/plugin-src/client/styles.js +22 -7
- package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
- package/plugin-src/host/channels/discord/index.mjs +8 -10
- package/plugin-src/host/channels/feishu/index.mjs +8 -15
- package/plugin-src/host/channels/office/index.mjs +9 -5
- package/plugin-src/host/channels/qq/index.mjs +8 -15
- package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
- package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
- package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
- package/plugin-src/host/channels/shared/startup.mjs +47 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
- package/plugin-src/host/channels/slack/index.mjs +8 -10
- package/plugin-src/host/channels/telegram/index.mjs +8 -10
- package/plugin-src/host/channels/wecom/index.mjs +8 -15
- package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
- package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
- package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
- package/plugin-src/host/channels/weixin/index.mjs +8 -15
- package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
- package/plugin-src/host/delivery-adapter.mjs +4 -0
- package/plugin-src/host/delivery-suggestions.mjs +4 -0
- package/plugin-src/host/index.mjs +21 -2
- package/plugin-src/host/session-title-prefix.mjs +122 -0
- package/scripts/verify-model-setting.mjs +54 -0
- package/scripts/verify-session-channel-logos.mjs +59 -0
- package/scripts/verify-session-title-prefix.mjs +149 -0
- package/src/channels/feishu/bridge.mjs +305 -12
- package/src/channels/feishu/feishu-cards.mjs +38 -0
- package/src/channels/feishu/feishu-channel.mjs +88 -20
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/feishu/repair-manager.mjs +3 -2
- package/src/channels/shared/bot-workspace-store.mjs +11 -3
- package/src/channels/shared/command-catalog.mjs +102 -0
- package/src/channels/shared/i18n-en/feishu.mjs +14 -2
- package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
- package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
- package/src/channels/shared/i18n-en.mjs +2 -0
- package/src/channels/shared/model-setting.mjs +43 -8
- package/src/channels/shared/session-channel-labels.mjs +26 -0
- package/src/channels/shared/text-harness-bridge.mjs +2 -26
- package/src/channels/telegram/telegram-api.mjs +18 -6
- package/src/channels/telegram/telegram-runtime.mjs +34 -32
- package/src/channels/wecom/send-error.mjs +31 -0
- package/src/channels/wecom/wecom-bridge.mjs +59 -17
- package/src/channels/wecom-app/callback-server.mjs +471 -0
- package/src/channels/wecom-app/config-store.mjs +240 -0
- package/src/channels/wecom-app/harness-client.mjs +11 -0
- package/src/channels/wecom-app/state-store.mjs +107 -0
- package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
- package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
- package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
- package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveWecomAppIdentity,
|
|
3
|
+
generateCallbackSecret,
|
|
4
|
+
maskCorpId,
|
|
5
|
+
validAgentId,
|
|
6
|
+
validApiBaseUrl,
|
|
7
|
+
validCorpId,
|
|
8
|
+
} from './config-store.mjs';
|
|
9
|
+
import {
|
|
10
|
+
connectionTestMessage,
|
|
11
|
+
connectionTestTargetUnavailable,
|
|
12
|
+
} from '../shared/connection-test.mjs';
|
|
13
|
+
import { t } from '../shared/i18n.mjs';
|
|
14
|
+
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
|
15
|
+
|
|
16
|
+
function cleanString(value) {
|
|
17
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeError(code, message) {
|
|
21
|
+
return Object.freeze({ code, message });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validSecret(value) {
|
|
25
|
+
const secret = cleanString(value);
|
|
26
|
+
return Boolean(secret && /^[A-Za-z0-9_-]{16,128}$/.test(secret));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validCallbackToken(value) {
|
|
30
|
+
const token = cleanString(value);
|
|
31
|
+
return Boolean(token && /^[A-Za-z0-9_-]{3,64}$/.test(token));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validEncodingAesKey(value) {
|
|
35
|
+
const key = cleanString(value);
|
|
36
|
+
return Boolean(key && /^[A-Za-z0-9]{43}$/.test(key));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class WecomAppController {
|
|
40
|
+
#credentials;
|
|
41
|
+
#configStore;
|
|
42
|
+
#createRuntime;
|
|
43
|
+
#deleteState;
|
|
44
|
+
#logger;
|
|
45
|
+
#buildCallbackUrl;
|
|
46
|
+
#runtimes = new Map();
|
|
47
|
+
#errors = new Map();
|
|
48
|
+
#revision = 0;
|
|
49
|
+
#closed = false;
|
|
50
|
+
#transitions = new Map();
|
|
51
|
+
|
|
52
|
+
constructor({
|
|
53
|
+
credentials,
|
|
54
|
+
configStore,
|
|
55
|
+
createRuntime,
|
|
56
|
+
deleteState = async () => {},
|
|
57
|
+
logger = console,
|
|
58
|
+
buildCallbackUrl = null,
|
|
59
|
+
}) {
|
|
60
|
+
if (!credentials || typeof credentials.resolve !== 'function'
|
|
61
|
+
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
|
|
62
|
+
throw new TypeError('WecomAppController requires the DSH credential provider');
|
|
63
|
+
}
|
|
64
|
+
if (!configStore || typeof configStore.list !== 'function'
|
|
65
|
+
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
|
66
|
+
throw new TypeError('WecomAppController requires a config store');
|
|
67
|
+
}
|
|
68
|
+
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
|
69
|
+
this.#credentials = credentials;
|
|
70
|
+
this.#configStore = configStore;
|
|
71
|
+
this.#createRuntime = createRuntime;
|
|
72
|
+
this.#deleteState = deleteState;
|
|
73
|
+
this.#logger = logger;
|
|
74
|
+
this.#buildCallbackUrl = typeof buildCallbackUrl === 'function' ? buildCallbackUrl : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async initialize() {
|
|
78
|
+
if (this.#closed) return this.status();
|
|
79
|
+
for (const config of this.#configStore.list()) {
|
|
80
|
+
await this.#withBotTransition(config.botId, async () => {
|
|
81
|
+
if (this.#runtimes.has(config.botId)) return;
|
|
82
|
+
const secrets = await this.#resolveSecrets(config);
|
|
83
|
+
if (!secrets) {
|
|
84
|
+
this.#errors.set(config.botId, safeError('missing-credentials', t('企业微信应用凭据缺失,请重新绑定。')));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await this.#startRuntime(config, secrets);
|
|
89
|
+
this.#errors.delete(config.botId);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
this.#errors.set(config.botId, safeError('connection-failed', t('企业微信应用连接未就绪,插件会自动重试。')));
|
|
92
|
+
this.#logger.warn?.(`[dsh-im:wecom-app] bot ${config.botId} failed to initialize`, error);
|
|
93
|
+
} finally {
|
|
94
|
+
this.#touch();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return this.status();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async bindApp({
|
|
102
|
+
corpId,
|
|
103
|
+
agentId,
|
|
104
|
+
secret,
|
|
105
|
+
token,
|
|
106
|
+
encodingAESKey,
|
|
107
|
+
apiBaseUrl,
|
|
108
|
+
callbackBaseUrl,
|
|
109
|
+
streamEnabled,
|
|
110
|
+
} = {}) {
|
|
111
|
+
if (this.#closed) throw new Error('Enterprise WeChat app controller is closed');
|
|
112
|
+
const normalizedCorpId = cleanString(corpId);
|
|
113
|
+
const normalizedAgentId = cleanString(agentId);
|
|
114
|
+
const normalizedSecret = cleanString(secret);
|
|
115
|
+
const normalizedToken = cleanString(token);
|
|
116
|
+
const normalizedAesKey = cleanString(encodingAESKey);
|
|
117
|
+
if (!validCorpId(normalizedCorpId)) throw new TypeError(t('请输入正确的企业 ID(例如 ww1234567890abcdef)。'));
|
|
118
|
+
if (!validAgentId(normalizedAgentId)) throw new TypeError(t('请输入正确的应用 AgentId(纯数字)。'));
|
|
119
|
+
if (!validSecret(normalizedSecret)) throw new TypeError(t('应用 Secret 格式不正确。'));
|
|
120
|
+
if (!validCallbackToken(normalizedToken)) throw new TypeError(t('回调 Token 格式不正确。'));
|
|
121
|
+
if (!validEncodingAesKey(normalizedAesKey)) throw new TypeError(t('EncodingAESKey 必须是 43 位字母或数字。'));
|
|
122
|
+
if (!validApiBaseUrl(apiBaseUrl ?? undefined)) throw new TypeError(t('代理地址必须是 http(s) 地址。'));
|
|
123
|
+
if (!validApiBaseUrl(callbackBaseUrl ?? undefined)) throw new TypeError(t('回调基址必须是 http(s) 地址。'));
|
|
124
|
+
const identity = deriveWecomAppIdentity({ corpId: normalizedCorpId, agentId: normalizedAgentId });
|
|
125
|
+
await this.#withBotTransition(identity.botId, async () => {
|
|
126
|
+
if (this.#closed) throw new Error('Enterprise WeChat app controller is closed');
|
|
127
|
+
const previousConfig = this.#configStore.getByCorpAgent(normalizedCorpId, normalizedAgentId);
|
|
128
|
+
const botId = identity.botId;
|
|
129
|
+
const config = {
|
|
130
|
+
botId,
|
|
131
|
+
corpId: normalizedCorpId,
|
|
132
|
+
agentId: normalizedAgentId,
|
|
133
|
+
secretRef: identity.secretRef,
|
|
134
|
+
callbackTokenRef: identity.callbackTokenRef,
|
|
135
|
+
callbackKeyRef: identity.callbackKeyRef,
|
|
136
|
+
callbackSecret: previousConfig?.callbackSecret ?? generateCallbackSecret(),
|
|
137
|
+
apiBaseUrl: cleanString(apiBaseUrl) ?? undefined,
|
|
138
|
+
callbackBaseUrl: cleanString(callbackBaseUrl) ?? undefined,
|
|
139
|
+
streamEnabled: streamEnabled !== false,
|
|
140
|
+
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
141
|
+
connectedAt: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
const previous = await this.#resolveSecrets(config);
|
|
144
|
+
await this.#setSecrets(identity, {
|
|
145
|
+
corpSecret: normalizedSecret,
|
|
146
|
+
token: normalizedToken,
|
|
147
|
+
encodingAESKey: normalizedAesKey,
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
await this.#configStore.save(config);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
await this.#restoreSecrets(identity, previous);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
await this.#startRuntime(config, {
|
|
157
|
+
corpSecret: normalizedSecret,
|
|
158
|
+
token: normalizedToken,
|
|
159
|
+
encodingAESKey: normalizedAesKey,
|
|
160
|
+
});
|
|
161
|
+
this.#errors.delete(botId);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
this.#errors.set(botId, safeError('connection-failed', t('企业微信应用已绑定,但尚未就绪。')));
|
|
164
|
+
this.#logger.warn?.(`[dsh-im:wecom-app] bot ${botId} credential connection failed`, error);
|
|
165
|
+
}
|
|
166
|
+
this.#touch();
|
|
167
|
+
});
|
|
168
|
+
return this.status();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async updateAppSettings(botId, { apiBaseUrl, callbackBaseUrl, streamEnabled } = {}) {
|
|
172
|
+
const config = this.#configStore.get(botId);
|
|
173
|
+
if (!config) throw new Error('Unknown Enterprise WeChat app bot');
|
|
174
|
+
await this.#withBotTransition(botId, async () => {
|
|
175
|
+
const previous = await this.#resolveSecrets(config);
|
|
176
|
+
const patch = {};
|
|
177
|
+
if (apiBaseUrl !== undefined) {
|
|
178
|
+
if (!validApiBaseUrl(apiBaseUrl)) throw new TypeError(t('代理地址必须是 http(s) 地址。'));
|
|
179
|
+
patch.apiBaseUrl = cleanString(apiBaseUrl) ?? null;
|
|
180
|
+
}
|
|
181
|
+
if (callbackBaseUrl !== undefined) {
|
|
182
|
+
if (!validApiBaseUrl(callbackBaseUrl)) throw new TypeError(t('回调基址必须是 http(s) 地址。'));
|
|
183
|
+
patch.callbackBaseUrl = cleanString(callbackBaseUrl) ?? null;
|
|
184
|
+
}
|
|
185
|
+
if (streamEnabled !== undefined) patch.streamEnabled = streamEnabled === true;
|
|
186
|
+
if (Object.keys(patch).length === 0) return;
|
|
187
|
+
try {
|
|
188
|
+
await this.#configStore.update(botId, patch);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await this.#restoreSecrets(config, previous);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
const updated = this.#configStore.get(botId);
|
|
194
|
+
const runtime = this.#runtimes.get(botId);
|
|
195
|
+
if (streamEnabled !== undefined && runtime && typeof runtime.setStreamEnabled === 'function') {
|
|
196
|
+
runtime.setStreamEnabled(patch.streamEnabled);
|
|
197
|
+
}
|
|
198
|
+
if (apiBaseUrl !== undefined) {
|
|
199
|
+
// The API client bakes in the base URL; restart to pick up the change.
|
|
200
|
+
const secrets = await this.#resolveSecrets(updated);
|
|
201
|
+
if (secrets) {
|
|
202
|
+
try {
|
|
203
|
+
await this.#startRuntime(updated, secrets);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
this.#errors.set(botId, safeError('connection-failed', t('企业微信应用已更新,但尚未就绪。')));
|
|
206
|
+
this.#logger.warn?.(`[dsh-im:wecom-app] bot ${botId} restart after settings update failed`, error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
this.#errors.delete(botId);
|
|
211
|
+
this.#touch();
|
|
212
|
+
});
|
|
213
|
+
return this.status();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async resetCallbackSecret(botId) {
|
|
217
|
+
const config = this.#configStore.get(botId);
|
|
218
|
+
if (!config) throw new Error('Unknown Enterprise WeChat app bot');
|
|
219
|
+
await this.#withBotTransition(botId, async () => {
|
|
220
|
+
await this.#configStore.update(botId, { callbackSecret: generateCallbackSecret() });
|
|
221
|
+
const updated = this.#configStore.get(botId);
|
|
222
|
+
const secrets = await this.#resolveSecrets(updated);
|
|
223
|
+
if (secrets && this.#runtimes.has(botId)) {
|
|
224
|
+
try {
|
|
225
|
+
await this.#startRuntime(updated, secrets);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
this.#errors.set(botId, safeError('connection-failed', t('回调密钥已重置,但机器人重启失败,请手动重连。')));
|
|
228
|
+
this.#logger.warn?.(`[dsh-im:wecom-app] bot ${botId} restart after secret reset failed`, error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
this.#touch();
|
|
232
|
+
});
|
|
233
|
+
return this.status();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async reconnectBot(botId) {
|
|
237
|
+
const config = this.#configStore.get(botId);
|
|
238
|
+
if (!config) throw new Error('Unknown Enterprise WeChat app bot');
|
|
239
|
+
await this.#withBotTransition(botId, async () => {
|
|
240
|
+
const secrets = await this.#resolveSecrets(config);
|
|
241
|
+
if (!secrets) throw new Error('Enterprise WeChat app bot credentials are missing');
|
|
242
|
+
try {
|
|
243
|
+
await this.#startRuntime(config, secrets);
|
|
244
|
+
this.#errors.delete(botId);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
this.#errors.set(botId, safeError('connection-failed', t('企业微信应用仍未就绪,请稍后重试。')));
|
|
247
|
+
throw error;
|
|
248
|
+
} finally {
|
|
249
|
+
this.#touch();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return this.status();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async sendConnectionTest(botId) {
|
|
256
|
+
const config = this.#configStore.get(botId);
|
|
257
|
+
if (!config) throw new Error('Unknown Enterprise WeChat app bot');
|
|
258
|
+
return this.#withBotTransition(botId, async () => {
|
|
259
|
+
const runtime = this.#runtimes.get(botId);
|
|
260
|
+
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
|
261
|
+
throw connectionTestTargetUnavailable(t('企业微信应用'));
|
|
262
|
+
}
|
|
263
|
+
return runtime.sendConnectionTest(connectionTestMessage(
|
|
264
|
+
t('企业微信应用({corpId})', { corpId: maskCorpId(config.corpId) }),
|
|
265
|
+
t('企业微信应用'),
|
|
266
|
+
));
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async sendProactiveText(botId, target, text, options = {}) {
|
|
271
|
+
const config = this.#configStore.get(botId);
|
|
272
|
+
if (!config) throw new Error('Unknown Enterprise WeChat app bot');
|
|
273
|
+
return this.#withBotTransition(botId, async () => {
|
|
274
|
+
const runtime = this.#runtimes.get(botId);
|
|
275
|
+
if (!runtime?.status?.ready || typeof runtime.sendProactiveText !== 'function') {
|
|
276
|
+
const error = new Error(t('企业微信应用当前离线'));
|
|
277
|
+
error.code = 'bot-not-connected';
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
return runtime.sendProactiveText(target, text, options);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async deleteBot(botId) {
|
|
285
|
+
const config = this.#configStore.get(botId);
|
|
286
|
+
if (!config) throw new Error('Unknown Enterprise WeChat app bot');
|
|
287
|
+
await this.#withBotTransition(botId, async () => {
|
|
288
|
+
const previous = await this.#resolveSecrets(config);
|
|
289
|
+
await this.#stopRuntime(botId);
|
|
290
|
+
try {
|
|
291
|
+
await this.#credentials.unset(config.secretRef);
|
|
292
|
+
await this.#credentials.unset(config.callbackTokenRef);
|
|
293
|
+
await this.#credentials.unset(config.callbackKeyRef);
|
|
294
|
+
await this.#configStore.remove(botId);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (previous) {
|
|
297
|
+
await this.#setSecrets(config, previous).catch(() => undefined);
|
|
298
|
+
await this.#startRuntime(config, previous).catch(() => undefined);
|
|
299
|
+
}
|
|
300
|
+
throw new Error('Unable to remove the Enterprise WeChat app bot safely.', { cause: error });
|
|
301
|
+
}
|
|
302
|
+
await this.#deleteState({ botId, config }).catch((error) => {
|
|
303
|
+
this.#logger.warn?.(`[dsh-im:wecom-app] bot ${botId} state cleanup failed:`, error);
|
|
304
|
+
});
|
|
305
|
+
this.#errors.delete(botId);
|
|
306
|
+
this.#touch();
|
|
307
|
+
});
|
|
308
|
+
return this.status();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
status() {
|
|
312
|
+
const bots = this.#configStore.list().map((config) => {
|
|
313
|
+
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
|
314
|
+
const connected = runtimeStatus?.ready === true
|
|
315
|
+
&& runtimeStatus.wecomAppConnectionState === 'connected'
|
|
316
|
+
&& runtimeStatus.harnessReachable === true;
|
|
317
|
+
const state = connected ? 'connected'
|
|
318
|
+
: runtimeStatus?.wecomAppConnectionState === 'connecting' ? 'connecting'
|
|
319
|
+
: this.#errors.has(config.botId) || runtimeStatus?.wecomAppConnectionState === 'failed'
|
|
320
|
+
? 'error' : 'offline';
|
|
321
|
+
const callbackUrl = typeof this.#buildCallbackUrl === 'function'
|
|
322
|
+
? this.#buildCallbackUrl(config) ?? null
|
|
323
|
+
: null;
|
|
324
|
+
return {
|
|
325
|
+
botId: config.botId,
|
|
326
|
+
state,
|
|
327
|
+
connected,
|
|
328
|
+
configured: true,
|
|
329
|
+
bot: {
|
|
330
|
+
name: t('企业微信应用'),
|
|
331
|
+
corpIdMasked: maskCorpId(config.corpId),
|
|
332
|
+
agentId: config.agentId,
|
|
333
|
+
apiBaseUrl: config.apiBaseUrl ?? null,
|
|
334
|
+
callbackBaseUrl: config.callbackBaseUrl ?? null,
|
|
335
|
+
streamEnabled: config.streamEnabled !== false,
|
|
336
|
+
callbackUrl,
|
|
337
|
+
},
|
|
338
|
+
health: {
|
|
339
|
+
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
340
|
+
summary: connected ? t('企业微信应用回调通道就绪')
|
|
341
|
+
: state === 'error' ? t('企业微信应用连接未就绪,插件会自动重试') : t('企业微信应用当前离线'),
|
|
342
|
+
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
|
343
|
+
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
|
344
|
+
},
|
|
345
|
+
stats: {
|
|
346
|
+
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
347
|
+
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
348
|
+
},
|
|
349
|
+
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
|
350
|
+
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
const connectedCount = bots.filter((bot) => bot.connected).length;
|
|
354
|
+
return {
|
|
355
|
+
schemaVersion: 1,
|
|
356
|
+
revision: this.#revision,
|
|
357
|
+
state: bots.length === 0 ? 'disconnected'
|
|
358
|
+
: connectedCount === bots.length ? 'connected'
|
|
359
|
+
: connectedCount > 0 ? 'degraded' : 'offline',
|
|
360
|
+
bots,
|
|
361
|
+
totals: { configured: bots.length, connected: connectedCount },
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async close() {
|
|
366
|
+
if (this.#closed) return;
|
|
367
|
+
this.#closed = true;
|
|
368
|
+
await Promise.allSettled([...this.#transitions.values()]);
|
|
369
|
+
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async #startRuntime(config, secrets) {
|
|
373
|
+
if (this.#closed) throw new Error('Enterprise WeChat app controller is closed');
|
|
374
|
+
await this.#stopRuntime(config.botId);
|
|
375
|
+
if (this.#closed) throw new Error('Enterprise WeChat app controller is closed');
|
|
376
|
+
const runtime = await this.#createRuntime({ botId: config.botId, config, secrets });
|
|
377
|
+
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
378
|
+
throw new TypeError('createRuntime returned an invalid Enterprise WeChat app runtime');
|
|
379
|
+
}
|
|
380
|
+
this.#runtimes.set(config.botId, runtime);
|
|
381
|
+
try {
|
|
382
|
+
await runtime.start();
|
|
383
|
+
} catch (error) {
|
|
384
|
+
await runtime.stop().catch(() => undefined);
|
|
385
|
+
this.#runtimes.delete(config.botId);
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async #stopRuntime(botId) {
|
|
391
|
+
const runtime = this.#runtimes.get(botId);
|
|
392
|
+
this.#runtimes.delete(botId);
|
|
393
|
+
await runtime?.stop().catch((error) => {
|
|
394
|
+
this.#logger.warn?.(`[dsh-im:wecom-app] bot ${botId} failed to stop cleanly:`, error);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async #resolveSecrets(config) {
|
|
399
|
+
const corpSecret = await this.#resolveSecret(config.secretRef);
|
|
400
|
+
const token = await this.#resolveSecret(config.callbackTokenRef);
|
|
401
|
+
const encodingAESKey = await this.#resolveSecret(config.callbackKeyRef);
|
|
402
|
+
if (!corpSecret || !token || !encodingAESKey) return null;
|
|
403
|
+
return { corpSecret, token, encodingAESKey };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async #setSecrets(identity, { corpSecret, token, encodingAESKey }) {
|
|
407
|
+
await this.#credentials.set(identity.secretRef, corpSecret);
|
|
408
|
+
await this.#credentials.set(identity.callbackTokenRef, token);
|
|
409
|
+
await this.#credentials.set(identity.callbackKeyRef, encodingAESKey);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async #resolveSecret(ref) {
|
|
413
|
+
const result = await this.#credentials.resolve(ref).catch(() => undefined);
|
|
414
|
+
return cleanString(result?.value);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async #restoreSecrets(identity, previous) {
|
|
418
|
+
if (previous) {
|
|
419
|
+
await this.#setSecrets(identity, previous).catch(() => undefined);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
await this.#credentials.unset(identity.secretRef).catch(() => undefined);
|
|
423
|
+
await this.#credentials.unset(identity.callbackTokenRef).catch(() => undefined);
|
|
424
|
+
await this.#credentials.unset(identity.callbackKeyRef).catch(() => undefined);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#withBotTransition(botId, operation) {
|
|
428
|
+
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
|
429
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
430
|
+
const settled = current.finally(() => {
|
|
431
|
+
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
|
432
|
+
});
|
|
433
|
+
this.#transitions.set(botId, settled);
|
|
434
|
+
return settled;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#touch() {
|
|
438
|
+
this.#revision += 1;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WecomAppApi,
|
|
3
|
+
WecomAppError,
|
|
4
|
+
createUserCrypto,
|
|
5
|
+
splitUtf8ByBytes,
|
|
6
|
+
} from './wecom-app-api.mjs';
|
|
7
|
+
import { WecomAppBridge } from './wecom-app-bridge.mjs';
|
|
8
|
+
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
|
9
|
+
import { t } from '../shared/i18n.mjs';
|
|
10
|
+
|
|
11
|
+
function timeoutError(message) {
|
|
12
|
+
const error = new Error(message);
|
|
13
|
+
error.code = 'connect-timeout';
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createWecomAppRuntimeStatus() {
|
|
18
|
+
return {
|
|
19
|
+
startedAt: null,
|
|
20
|
+
ready: false,
|
|
21
|
+
wecomAppConnectionState: 'idle',
|
|
22
|
+
harnessReachable: false,
|
|
23
|
+
lastCheckedAt: null,
|
|
24
|
+
lastConnectedAt: null,
|
|
25
|
+
lastError: null,
|
|
26
|
+
messagesReceived: 0,
|
|
27
|
+
messagesReplied: 0,
|
|
28
|
+
lastMessageAt: null,
|
|
29
|
+
lastReplyAt: null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One runtime per bound self-built application. Unlike the WeCom smart-robot
|
|
34
|
+
// channel there is no outbound WebSocket: the runtime registers a callback
|
|
35
|
+
// route on the shared WecomAppCallbackServer and replies either passively
|
|
36
|
+
// (stream) or through the cgi-bin message/send API.
|
|
37
|
+
export class WecomAppRuntime {
|
|
38
|
+
#config;
|
|
39
|
+
#secrets;
|
|
40
|
+
#harness;
|
|
41
|
+
#state;
|
|
42
|
+
#contextEnhancement;
|
|
43
|
+
#accessPolicy;
|
|
44
|
+
#logger;
|
|
45
|
+
#callbackServer;
|
|
46
|
+
#replyTimeoutMs;
|
|
47
|
+
#streamEnabled;
|
|
48
|
+
#status = createWecomAppRuntimeStatus();
|
|
49
|
+
#api = null;
|
|
50
|
+
#bridge = null;
|
|
51
|
+
#started = false;
|
|
52
|
+
#controller = new AbortController();
|
|
53
|
+
|
|
54
|
+
constructor({
|
|
55
|
+
config,
|
|
56
|
+
secrets,
|
|
57
|
+
harness,
|
|
58
|
+
state,
|
|
59
|
+
contextEnhancement,
|
|
60
|
+
accessPolicy,
|
|
61
|
+
logger = console,
|
|
62
|
+
callbackServer,
|
|
63
|
+
replyTimeoutMs = 600_000,
|
|
64
|
+
}) {
|
|
65
|
+
if (!config || !secrets || !harness || !state) {
|
|
66
|
+
throw new TypeError('WecomAppRuntime requires config, secrets, Harness, and state');
|
|
67
|
+
}
|
|
68
|
+
if (!callbackServer || typeof callbackServer.registerRoute !== 'function') {
|
|
69
|
+
throw new TypeError('WecomAppRuntime requires the shared WecomAppCallbackServer');
|
|
70
|
+
}
|
|
71
|
+
this.#config = config;
|
|
72
|
+
this.#secrets = secrets;
|
|
73
|
+
this.#harness = harness;
|
|
74
|
+
this.#state = state;
|
|
75
|
+
this.#contextEnhancement = contextEnhancement;
|
|
76
|
+
this.#accessPolicy = accessPolicy;
|
|
77
|
+
this.#logger = logger;
|
|
78
|
+
this.#callbackServer = callbackServer;
|
|
79
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
80
|
+
this.#streamEnabled = config.streamEnabled !== false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get status() {
|
|
84
|
+
return structuredClone(this.#status);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get streamEnabled() {
|
|
88
|
+
return this.#streamEnabled;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setStreamEnabled(enabled) {
|
|
92
|
+
this.#streamEnabled = enabled === true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async start() {
|
|
96
|
+
if (this.#started) return this.status;
|
|
97
|
+
const corpSecret = this.#secrets.corpSecret;
|
|
98
|
+
const token = this.#secrets.token;
|
|
99
|
+
const encodingAESKey = this.#secrets.encodingAESKey;
|
|
100
|
+
if (!corpSecret || !token || !encodingAESKey) {
|
|
101
|
+
this.#status.lastError = 'missing-credentials';
|
|
102
|
+
throw new WecomAppError('missing-credentials', t('企业微信应用凭据不完整,请重新绑定。'));
|
|
103
|
+
}
|
|
104
|
+
this.#status.startedAt = new Date().toISOString();
|
|
105
|
+
this.#status.lastError = null;
|
|
106
|
+
this.#status.wecomAppConnectionState = 'connecting';
|
|
107
|
+
await this.#harness.ensureRunning({ signal: this.#signal() });
|
|
108
|
+
this.#status.harnessReachable = true;
|
|
109
|
+
|
|
110
|
+
this.#api = new WecomAppApi({
|
|
111
|
+
corpId: this.#config.corpId,
|
|
112
|
+
corpSecret,
|
|
113
|
+
agentId: this.#config.agentId,
|
|
114
|
+
apiBaseUrl: this.#config.apiBaseUrl,
|
|
115
|
+
logger: this.#logger,
|
|
116
|
+
signal: undefined,
|
|
117
|
+
});
|
|
118
|
+
this.#bridge = new WecomAppBridge({
|
|
119
|
+
api: this.#api,
|
|
120
|
+
harness: this.#harness,
|
|
121
|
+
state: this.#state,
|
|
122
|
+
status: this.#status,
|
|
123
|
+
contextEnhancement: this.#contextEnhancement,
|
|
124
|
+
accessPolicy: this.#accessPolicy,
|
|
125
|
+
logger: this.#logger,
|
|
126
|
+
replyTimeoutMs: this.#replyTimeoutMs,
|
|
127
|
+
signal: this.#signal(),
|
|
128
|
+
streamRegistry: this.#callbackServer,
|
|
129
|
+
});
|
|
130
|
+
await this.#callbackServer.registerRoute({
|
|
131
|
+
botId: this.#config.botId,
|
|
132
|
+
callbackSecret: this.#config.callbackSecret,
|
|
133
|
+
cryptoFor: () => createUserCrypto({
|
|
134
|
+
token,
|
|
135
|
+
encodingAESKey,
|
|
136
|
+
corpId: this.#config.corpId,
|
|
137
|
+
}),
|
|
138
|
+
streamEnabled: () => this.#streamEnabled,
|
|
139
|
+
onInbound: async ({ kind, message, stream }) => {
|
|
140
|
+
if (kind === 'event') {
|
|
141
|
+
await this.#bridge.acceptEvent(message);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const sink = stream
|
|
145
|
+
? {
|
|
146
|
+
streamId: stream.streamId,
|
|
147
|
+
append: (chunk, options) => this.#callbackServer.appendStream(stream.streamId, chunk, options),
|
|
148
|
+
finish: (error) => this.#callbackServer.finishStream(stream.streamId, { error }),
|
|
149
|
+
refreshes: () => this.#callbackServer.getStream(stream.streamId)?.refreshes ?? 0,
|
|
150
|
+
finished: () => this.#callbackServer.getStream(stream.streamId)?.finished === true,
|
|
151
|
+
}
|
|
152
|
+
: null;
|
|
153
|
+
await this.#bridge.accept(message, { sink });
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
this.#started = true;
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
this.#status.ready = true;
|
|
159
|
+
this.#status.wecomAppConnectionState = 'connected';
|
|
160
|
+
this.#status.lastCheckedAt = now;
|
|
161
|
+
this.#status.lastConnectedAt = now;
|
|
162
|
+
return this.status;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async stop() {
|
|
166
|
+
this.#controller.abort(new DOMException('WecomAppRuntime stopped', 'AbortError'));
|
|
167
|
+
if (this.#bridge) {
|
|
168
|
+
await this.#bridge.close().catch((error) => {
|
|
169
|
+
this.#logger.warn?.('[dsh-im:wecom-app] bridge stop failed:', error);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
this.#bridge = null;
|
|
173
|
+
this.#api = null;
|
|
174
|
+
this.#callbackServer.unregisterRoute(this.#config.botId);
|
|
175
|
+
this.#started = false;
|
|
176
|
+
this.#status.ready = false;
|
|
177
|
+
this.#status.wecomAppConnectionState = 'idle';
|
|
178
|
+
return this.status;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async sendConnectionTest(text) {
|
|
182
|
+
return sendRememberedConnectionTest({
|
|
183
|
+
state: this.#state,
|
|
184
|
+
text,
|
|
185
|
+
channelLabel: t('企业微信应用'),
|
|
186
|
+
send: async ({ toUserId }, content) => {
|
|
187
|
+
if (!this.#started || !this.#api) {
|
|
188
|
+
throw new WecomAppError('not-connected', 'Enterprise WeChat app runtime is not connected');
|
|
189
|
+
}
|
|
190
|
+
await this.#api.sendText({ userId: toUserId, content });
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async sendProactiveText(target, text, { signal } = {}) {
|
|
196
|
+
const chatId = typeof target?.route?.chatId === 'string' ? target.route.chatId.trim() : '';
|
|
197
|
+
if ((target?.kind !== 'user') || !chatId) {
|
|
198
|
+
const error = new TypeError('Invalid Enterprise WeChat app proactive delivery target');
|
|
199
|
+
error.code = 'invalid-target';
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
if (!this.#started || !this.#api) {
|
|
203
|
+
const error = new WecomAppError('bot-not-connected', 'Enterprise WeChat app runtime is not connected');
|
|
204
|
+
error.code = 'bot-not-connected';
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
signal?.throwIfAborted();
|
|
208
|
+
const chunks = splitUtf8ByBytes(text);
|
|
209
|
+
for (const chunk of chunks) {
|
|
210
|
+
if (!chunk) continue;
|
|
211
|
+
await this.#api.sendText({ userId: chatId, content: chunk, signal });
|
|
212
|
+
}
|
|
213
|
+
return { sent: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#signal() {
|
|
217
|
+
// Each start() gets a fresh controller; stop() aborts the latest one.
|
|
218
|
+
if (this.#controller.signal.aborted) this.#controller = new AbortController();
|
|
219
|
+
return this.#controller.signal;
|
|
220
|
+
}
|
|
221
|
+
}
|