@xmanrui/dsh-im 4.15.0 → 4.16.1

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.
Files changed (64) hide show
  1. package/README.en.md +2 -1
  2. package/README.md +9 -5
  3. package/lib/client.js +1731 -612
  4. package/lib/index.js +303 -293
  5. package/package.json +10 -2
  6. package/plugin-src/client/access-policy-settings.js +6 -0
  7. package/plugin-src/client/channels/feishu/index.js +15 -3
  8. package/plugin-src/client/channels/wecom-app/api.js +124 -0
  9. package/plugin-src/client/channels/wecom-app/index.js +612 -0
  10. package/plugin-src/client/channels/wecom-app/styles.js +25 -0
  11. package/plugin-src/client/delivery-settings.js +7 -0
  12. package/plugin-src/client/i18n.js +63 -0
  13. package/plugin-src/client/index.js +18 -0
  14. package/plugin-src/client/model-setting.js +135 -69
  15. package/plugin-src/client/styles.js +22 -7
  16. package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
  17. package/plugin-src/host/channels/discord/index.mjs +8 -10
  18. package/plugin-src/host/channels/feishu/index.mjs +8 -15
  19. package/plugin-src/host/channels/office/index.mjs +9 -5
  20. package/plugin-src/host/channels/qq/index.mjs +8 -15
  21. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  22. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
  23. package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
  24. package/plugin-src/host/channels/shared/startup.mjs +47 -0
  25. package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
  26. package/plugin-src/host/channels/slack/index.mjs +8 -10
  27. package/plugin-src/host/channels/telegram/index.mjs +8 -10
  28. package/plugin-src/host/channels/wecom/index.mjs +8 -15
  29. package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
  30. package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
  31. package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
  32. package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
  33. package/plugin-src/host/channels/weixin/index.mjs +8 -15
  34. package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
  35. package/plugin-src/host/delivery-adapter.mjs +4 -0
  36. package/plugin-src/host/delivery-suggestions.mjs +4 -0
  37. package/plugin-src/host/index.mjs +7 -2
  38. package/scripts/verify-model-setting.mjs +54 -0
  39. package/src/channels/dingtalk/dingtalk-api.mjs +41 -48
  40. package/src/channels/dingtalk/dingtalk-bridge.mjs +5 -1
  41. package/src/channels/feishu/bridge.mjs +295 -9
  42. package/src/channels/feishu/feishu-cards.mjs +38 -0
  43. package/src/channels/feishu/feishu-channel.mjs +88 -20
  44. package/src/channels/shared/bot-workspace-store.mjs +11 -3
  45. package/src/channels/shared/command-catalog.mjs +102 -0
  46. package/src/channels/shared/i18n-en/feishu.mjs +12 -0
  47. package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
  48. package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
  49. package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
  50. package/src/channels/shared/i18n-en.mjs +2 -0
  51. package/src/channels/shared/model-setting.mjs +43 -8
  52. package/src/channels/shared/text-harness-bridge.mjs +2 -26
  53. package/src/channels/telegram/telegram-api.mjs +18 -6
  54. package/src/channels/telegram/telegram-runtime.mjs +34 -32
  55. package/src/channels/wecom/send-error.mjs +31 -0
  56. package/src/channels/wecom/wecom-bridge.mjs +59 -17
  57. package/src/channels/wecom-app/callback-server.mjs +471 -0
  58. package/src/channels/wecom-app/config-store.mjs +240 -0
  59. package/src/channels/wecom-app/harness-client.mjs +11 -0
  60. package/src/channels/wecom-app/state-store.mjs +107 -0
  61. package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
  62. package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
  63. package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
  64. package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
@@ -0,0 +1,225 @@
1
+ import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
2
+ import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
3
+ import { resolveRpcAuthority } from '../../rpc-authority.mjs';
4
+ import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
5
+ import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
6
+ import { SET_MODEL_ENDPOINT, validModelPayload } from '../shared/model-setting-rpc.mjs';
7
+ import {
8
+ connectionTestTargetUnavailable,
9
+ publicConnectionTestResult,
10
+ } from '../../../../src/channels/shared/connection-test.mjs';
11
+
12
+ export const WECOM_APP_RPC_CHANNEL = '/wecom-app';
13
+ export const WECOM_APP_ENDPOINTS = Object.freeze({
14
+ status: 'connection.status',
15
+ bindApp: 'bot.bind',
16
+ updateSettings: 'bot.settings.update',
17
+ resetCallbackSecret: 'bot.callback-secret.reset',
18
+ reconnectBot: 'bot.reconnect',
19
+ deleteBot: 'bot.delete',
20
+ setWorkspace: SET_WORKSPACE_ENDPOINT,
21
+ setModel: SET_MODEL_ENDPOINT,
22
+ setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
23
+ setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
24
+ setAccessPolicy: SET_ACCESS_POLICY_ENDPOINT,
25
+ });
26
+ export const WECOM_APP_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_APP_ENDPOINTS));
27
+
28
+ const FORBIDDEN_PUBLIC_KEYS = new Set([
29
+ 'secret', 'secretRef', 'token', 'encodingAESKey', 'callbackTokenRef', 'callbackKeyRef',
30
+ 'callbackSecret', 'corpSecret', 'bot_info',
31
+ ]);
32
+
33
+ function isRecord(value) {
34
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
35
+ }
36
+
37
+ function exactKeys(value, allowed) {
38
+ return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key));
39
+ }
40
+
41
+ function validId(value) {
42
+ return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
43
+ }
44
+
45
+ function validCredential(value, maxLength) {
46
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
47
+ }
48
+
49
+ function payloadFailure(endpoint, payload) {
50
+ if (!isRecord(payload)) return 'Payload must be an object.';
51
+ if (endpoint === WECOM_APP_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
52
+ if (endpoint === WECOM_APP_ENDPOINTS.bindApp) {
53
+ const allowed = ['corpId', 'agentId', 'secret', 'token', 'encodingAESKey', 'apiBaseUrl', 'callbackBaseUrl', 'streamEnabled'];
54
+ if (!exactKeys(payload, allowed)) return 'bot.bind received unsupported fields.';
55
+ if (!validCredential(payload.corpId, 128) || !validCredential(payload.agentId, 32)
56
+ || !validCredential(payload.secret, 256) || !validCredential(payload.token, 128)
57
+ || !validCredential(payload.encodingAESKey, 128)) {
58
+ return 'bot.bind requires corpId, agentId, secret, token, and encodingAESKey.';
59
+ }
60
+ if (payload.apiBaseUrl !== undefined && payload.apiBaseUrl !== null && typeof payload.apiBaseUrl !== 'string') {
61
+ return 'bot.bind received an invalid apiBaseUrl.';
62
+ }
63
+ if (payload.callbackBaseUrl !== undefined && payload.callbackBaseUrl !== null && typeof payload.callbackBaseUrl !== 'string') {
64
+ return 'bot.bind received an invalid callbackBaseUrl.';
65
+ }
66
+ if (payload.streamEnabled !== undefined && payload.streamEnabled !== true && payload.streamEnabled !== false) {
67
+ return 'bot.bind received an invalid streamEnabled flag.';
68
+ }
69
+ return null;
70
+ }
71
+ if (endpoint === WECOM_APP_ENDPOINTS.updateSettings) {
72
+ if (!exactKeys(payload, ['botId', 'apiBaseUrl', 'callbackBaseUrl', 'streamEnabled']) || !validId(payload.botId)) {
73
+ return 'bot.settings.update requires a botId and settings fields.';
74
+ }
75
+ if (payload.apiBaseUrl !== undefined && payload.apiBaseUrl !== null && typeof payload.apiBaseUrl !== 'string') {
76
+ return 'bot.settings.update received an invalid apiBaseUrl.';
77
+ }
78
+ if (payload.callbackBaseUrl !== undefined && payload.callbackBaseUrl !== null && typeof payload.callbackBaseUrl !== 'string') {
79
+ return 'bot.settings.update received an invalid callbackBaseUrl.';
80
+ }
81
+ if (payload.streamEnabled !== undefined && payload.streamEnabled !== true && payload.streamEnabled !== false) {
82
+ return 'bot.settings.update received an invalid streamEnabled flag.';
83
+ }
84
+ return null;
85
+ }
86
+ if (endpoint === WECOM_APP_ENDPOINTS.resetCallbackSecret) {
87
+ return exactKeys(payload, ['botId']) && validId(payload.botId)
88
+ ? null : 'bot.callback-secret.reset requires a botId.';
89
+ }
90
+ if (endpoint === WECOM_APP_ENDPOINTS.reconnectBot) {
91
+ return exactKeys(payload, ['botId', 'sendTest'])
92
+ && validId(payload.botId)
93
+ && (payload.sendTest === undefined || payload.sendTest === true)
94
+ ? null : 'bot.reconnect requires a botId and optional sendTest=true.';
95
+ }
96
+ if (endpoint === WECOM_APP_ENDPOINTS.deleteBot) {
97
+ return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
98
+ ? null : 'bot.delete requires a botId and confirm=true.';
99
+ }
100
+ if (endpoint === WECOM_APP_ENDPOINTS.setWorkspace) {
101
+ return validWorkspacePayload(payload) ? null : '请输入工作区绝对路径。';
102
+ }
103
+ if (endpoint === WECOM_APP_ENDPOINTS.setModel) {
104
+ return validModelPayload(payload) ? null : '请选择有效模型。';
105
+ }
106
+ if (endpoint === WECOM_APP_ENDPOINTS.setAgentPreset) {
107
+ return validAgentPresetPayload(payload) ? null : '请选择 Agent Preset。';
108
+ }
109
+ if (endpoint === WECOM_APP_ENDPOINTS.setContextEnhancement) {
110
+ return validContextEnhancementPayload(payload) ? null : '请提交有效的上下文增强设置。';
111
+ }
112
+ if (endpoint === WECOM_APP_ENDPOINTS.setAccessPolicy) {
113
+ return validAccessPolicyPayload(payload) ? null : '请提交有效的访问设置。';
114
+ }
115
+ return 'Unknown Enterprise WeChat app endpoint.';
116
+ }
117
+
118
+ function sanitizePublic(value) {
119
+ if (Array.isArray(value)) return value.map(sanitizePublic);
120
+ if (!isRecord(value)) return value;
121
+ const safe = {};
122
+ for (const [key, child] of Object.entries(value)) {
123
+ if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child);
124
+ }
125
+ return safe;
126
+ }
127
+
128
+ function publicStatus(status) {
129
+ return sanitizePublic(structuredClone(status));
130
+ }
131
+
132
+ export function createWecomAppRpcHandler(controller) {
133
+ for (const method of ['status', 'bindApp', 'updateAppSettings', 'resetCallbackSecret', 'reconnectBot', 'deleteBot']) {
134
+ if (typeof controller?.[method] !== 'function') {
135
+ throw new TypeError(`A complete Enterprise WeChat app controller is required (${method})`);
136
+ }
137
+ }
138
+ return async (endpoint, payload, signal) => {
139
+ if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.', details: {} } };
140
+ if (!WECOM_APP_RPC_ENDPOINTS.includes(endpoint)) {
141
+ return { ok: false, error: { code: 'bad-request', message: 'Unknown Enterprise WeChat app endpoint.', details: {} } };
142
+ }
143
+ const invalid = payloadFailure(endpoint, payload);
144
+ if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } };
145
+ try {
146
+ let value;
147
+ if (endpoint === WECOM_APP_ENDPOINTS.status) value = await publicStatus(await controller.status());
148
+ else if (endpoint === WECOM_APP_ENDPOINTS.bindApp) {
149
+ value = await publicStatus(await controller.bindApp(payload));
150
+ } else if (endpoint === WECOM_APP_ENDPOINTS.updateSettings) {
151
+ value = await publicStatus(await controller.updateAppSettings(payload.botId, {
152
+ apiBaseUrl: payload.apiBaseUrl,
153
+ callbackBaseUrl: payload.callbackBaseUrl,
154
+ streamEnabled: payload.streamEnabled,
155
+ }));
156
+ } else if (endpoint === WECOM_APP_ENDPOINTS.resetCallbackSecret) {
157
+ value = await publicStatus(await controller.resetCallbackSecret(payload.botId));
158
+ } else if (endpoint === WECOM_APP_ENDPOINTS.reconnectBot) {
159
+ const snapshot = await controller.reconnectBot(payload.botId);
160
+ if (signal?.aborted) {
161
+ return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.', details: {} } };
162
+ }
163
+ let testMessage;
164
+ if (payload.sendTest === true) {
165
+ const connected = snapshot?.bots?.some(
166
+ (bot) => bot?.botId === payload.botId && bot?.connected === true,
167
+ );
168
+ if (!connected || typeof controller.sendConnectionTest !== 'function') {
169
+ testMessage = publicConnectionTestResult(connectionTestTargetUnavailable('企业微信应用'));
170
+ } else {
171
+ try {
172
+ await controller.sendConnectionTest(payload.botId);
173
+ testMessage = publicConnectionTestResult();
174
+ } catch (error) {
175
+ testMessage = publicConnectionTestResult(error);
176
+ }
177
+ }
178
+ }
179
+ value = await publicStatus({ ...snapshot, ...(testMessage ? { testMessage } : {}) });
180
+ } else if (endpoint === WECOM_APP_ENDPOINTS.setWorkspace) {
181
+ if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
182
+ value = await publicStatus(await controller.updateWorkspace(payload.botId, payload.workspace));
183
+ } else if (endpoint === WECOM_APP_ENDPOINTS.setModel) {
184
+ if (typeof controller.updateModel !== 'function') throw new Error('Model update is unavailable');
185
+ value = await publicStatus(await controller.updateModel(payload.botId, payload.model));
186
+ } else if (endpoint === WECOM_APP_ENDPOINTS.setContextEnhancement) {
187
+ if (typeof controller.updateContextEnhancement !== 'function') throw new Error('Context enhancement update is unavailable');
188
+ value = await controller.updateContextEnhancement(
189
+ payload.botId, payload.config, (status) => publicStatus(status),
190
+ );
191
+ } else if (endpoint === WECOM_APP_ENDPOINTS.setAccessPolicy) {
192
+ if (typeof controller.updateAccessPolicy !== 'function') throw new Error('Access policy update is unavailable');
193
+ value = await controller.updateAccessPolicy(
194
+ payload.botId, payload.policy, (status) => publicStatus(status),
195
+ );
196
+ } else if (endpoint === WECOM_APP_ENDPOINTS.setAgentPreset) {
197
+ if (typeof controller.updateAgentPreset !== 'function') throw new Error('Agent Preset update is unavailable');
198
+ value = await publicStatus(await controller.updateAgentPreset(payload.botId, payload.agentPreset));
199
+ } else {
200
+ value = await publicStatus(await controller.deleteBot(payload.botId));
201
+ }
202
+ return signal?.aborted
203
+ ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.', details: {} } }
204
+ : { ok: true, value };
205
+ } catch (error) {
206
+ const workspaceError = publicWorkspaceError(error);
207
+ return signal?.aborted
208
+ ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.', details: {} } }
209
+ : { ok: false, error: workspaceError
210
+ ? { ...workspaceError, details: {} }
211
+ : { code: 'wecom-app-operation-failed', message: '企业微信应用操作失败,请稍后重试。', details: {} } };
212
+ }
213
+ };
214
+ }
215
+
216
+ export function installWecomAppRpc(ctx, controller, options, authority) {
217
+ if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
218
+ throw new TypeError('DSH Host Connection RPC is required');
219
+ }
220
+ return ctx.connection.rpc.handle(
221
+ WECOM_APP_RPC_CHANNEL,
222
+ createWecomAppRpcHandler(controller, options),
223
+ { authority: resolveRpcAuthority(authority) },
224
+ );
225
+ }
@@ -1,5 +1,6 @@
1
1
  import { createProductionController } from './production.mjs';
2
- import { installWeixinRpc } from './rpc.mjs';
2
+ import { createWeixinRpcHandler, installWeixinRpc, WEIXIN_RPC_CHANNEL } from './rpc.mjs';
3
+ import { installProductionChannel } from '../shared/startup.mjs';
3
4
 
4
5
  export const name = 'dsh-weixin-host';
5
6
  export const inject = ['connection', 'credentials', 'typertGateway'];
@@ -9,20 +10,12 @@ export async function apply(ctx, config = {}) {
9
10
  return installWeixinRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
10
11
  }
11
12
 
12
- const production = await createProductionController(ctx, config, config.internals);
13
- const unregisterDelivery = config.deliveryService && production.deliveryAdapter
14
- ? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
15
- const disposeRpc = installWeixinRpc(
16
- ctx,
17
- production.controller,
18
- config.rpcOptions,
19
- config.rpcAuthority,
20
- );
21
- ctx.effect(() => async () => {
22
- await unregisterDelivery?.();
23
- await production.close();
24
- }, 'dsh-weixin: close account connections');
25
- return disposeRpc;
13
+ return installProductionChannel(ctx, config, {
14
+ channel: 'weixin',
15
+ rpcChannel: WEIXIN_RPC_CHANNEL,
16
+ createProduction: () => createProductionController(ctx, config, config.internals),
17
+ createHandler: controller => createWeixinRpcHandler(controller, config.rpcOptions),
18
+ });
26
19
  }
27
20
 
28
21
  export function createWeixinHostPlugin(config) {
@@ -1,5 +1,6 @@
1
1
  import { createProductionController } from './production.mjs';
2
- import { installWhatsappRpc } from './rpc.mjs';
2
+ import { createWhatsappRpcHandler, installWhatsappRpc, WHATSAPP_RPC_CHANNEL } from './rpc.mjs';
3
+ import { installProductionChannel } from '../shared/startup.mjs';
3
4
 
4
5
  export const name = 'dsh-im-whatsapp-host';
5
6
  export const inject = ['connection', 'typertGateway'];
@@ -8,20 +9,12 @@ export async function apply(ctx, config = {}) {
8
9
  if (config?.controller) {
9
10
  return installWhatsappRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
10
11
  }
11
- const production = await createProductionController(ctx, config, config.internals ?? {});
12
- const unregisterDelivery = config.deliveryService && production.deliveryAdapter
13
- ? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
14
- const disposeRpc = installWhatsappRpc(
15
- ctx,
16
- production.controller,
17
- config.rpcOptions,
18
- config.rpcAuthority,
19
- );
20
- ctx.effect(() => async () => {
21
- await unregisterDelivery?.();
22
- await production.close();
23
- }, 'dsh-im: close WhatsApp Web connections');
24
- return disposeRpc;
12
+ return installProductionChannel(ctx, config, {
13
+ channel: 'whatsapp',
14
+ rpcChannel: WHATSAPP_RPC_CHANNEL,
15
+ createProduction: () => createProductionController(ctx, config, config.internals ?? {}),
16
+ createHandler: controller => createWhatsappRpcHandler(controller, config.rpcOptions),
17
+ });
25
18
  }
26
19
 
27
20
  export function createWhatsappHostPlugin(config) {
@@ -9,6 +9,7 @@ const CHANNELS = new Set([
9
9
  'feishu',
10
10
  'dingtalk',
11
11
  'wecom',
12
+ 'wecom-app',
12
13
  'qq',
13
14
  'slack',
14
15
  'telegram',
@@ -82,6 +83,9 @@ function normalizeRoute(channel, kind, route) {
82
83
  case 'wecom':
83
84
  oneOf(kind, ['user', 'group']);
84
85
  return routeWithStrings(route, ['chatId']);
86
+ case 'wecom-app':
87
+ oneOf(kind, ['user']);
88
+ return routeWithStrings(route, ['chatId']);
85
89
  case 'qq':
86
90
  oneOf(kind, ['user', 'group']);
87
91
  return routeWithStrings(route, kind === 'user' ? ['userOpenId'] : ['groupOpenId']);
@@ -3,6 +3,7 @@ const CHANNELS = new Set([
3
3
  'feishu',
4
4
  'dingtalk',
5
5
  'wecom',
6
+ 'wecom-app',
6
7
  'qq',
7
8
  'slack',
8
9
  'telegram',
@@ -97,6 +98,8 @@ export function deliverySuggestionFromConversationKey(channel, key) {
97
98
  ['direct', 'user', 'chatId'],
98
99
  ['group', 'group', 'chatId'],
99
100
  ]);
101
+ case 'wecom-app':
102
+ return simpleSuggestion(key, [['p2p', 'user', 'chatId']]);
100
103
  case 'qq':
101
104
  return simpleSuggestion(key, [
102
105
  ['c2c', 'user', 'userOpenId'],
@@ -124,6 +127,7 @@ export function privateDeliverySuggestionFromConversationKey(channel, key) {
124
127
  feishu: 'p2p',
125
128
  dingtalk: 'p2p',
126
129
  wecom: 'direct',
130
+ 'wecom-app': 'p2p',
127
131
  qq: 'c2c',
128
132
  slack: 'direct',
129
133
  telegram: 'direct',
@@ -6,6 +6,7 @@ import { apply as applyQq } from './channels/qq/index.mjs';
6
6
  import { apply as applySlack } from './channels/slack/index.mjs';
7
7
  import { apply as applyTelegram } from './channels/telegram/index.mjs';
8
8
  import { apply as applyWecom } from './channels/wecom/index.mjs';
9
+ import { apply as applyWecomApp } from './channels/wecom-app/index.mjs';
9
10
  import { apply as applyWeixin } from './channels/weixin/index.mjs';
10
11
  import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
11
12
  import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
@@ -45,6 +46,7 @@ export function createImHostPlugin(internals = {}) {
45
46
  const startWeixin = internals.applyWeixin ?? applyWeixin;
46
47
  const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
47
48
  const startWecom = internals.applyWecom ?? applyWecom;
49
+ const startWecomApp = internals.applyWecomApp ?? applyWecomApp;
48
50
  const startQq = internals.applyQq ?? applyQq;
49
51
  const startSlack = internals.applySlack ?? applySlack;
50
52
  const startTelegram = internals.applyTelegram ?? applyTelegram;
@@ -56,6 +58,7 @@ export function createImHostPlugin(internals = {}) {
56
58
  ['weixin', startWeixin],
57
59
  ['dingtalk', startDingtalk],
58
60
  ['wecom', startWecom],
61
+ ['wecomApp', startWecomApp],
59
62
  ['qq', startQq],
60
63
  ['slack', startSlack],
61
64
  ['telegram', startTelegram],
@@ -145,14 +148,16 @@ export function createImHostPlugin(internals = {}) {
145
148
  }
146
149
  }
147
150
  const failures = [];
148
- for (const [channel, start] of channels) {
151
+ // Each channel mounts its management RPC before awaiting initialization.
152
+ // Start them together so a slow channel cannot leave later routes absent.
153
+ await Promise.all(channels.map(async ([channel, start]) => {
149
154
  try {
150
155
  await start(ctx, channelConfig(config, channel, deliveryService));
151
156
  } catch (error) {
152
157
  failures.push(error);
153
158
  logger.error?.(`[dsh-im] failed to activate ${channel}; continuing with the remaining channels`, error);
154
159
  }
155
- }
160
+ }));
156
161
  if (failures.length === channels.length) {
157
162
  throw new AggregateError(failures, 'dsh-im failed to activate every channel');
158
163
  }
@@ -0,0 +1,54 @@
1
+ // Exercise the actual settings card in Chromium without a running Host or IM account.
2
+ // Usage: node scripts/verify-model-setting.mjs [output-directory]
3
+ import { execFile } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+ import { promisify } from 'node:util';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { build } from 'esbuild';
11
+
12
+ const browser = process.env.CHROME_PATH ?? [
13
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
14
+ '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome',
15
+ ].find(existsSync);
16
+ if (!browser) throw new Error('Set CHROME_PATH to a Chromium/Chrome executable.');
17
+ const output = process.argv[2] ? resolve(process.argv[2]) : await mkdtemp(join(tmpdir(), 'dsh-im-model-ui-'));
18
+ await mkdir(output, { recursive: true });
19
+ const built = await build({
20
+ entryPoints: [resolve(import.meta.dirname, '../test/browser/model-setting.fixture.js')],
21
+ bundle: true, write: false, format: 'iife', platform: 'browser', target: 'chrome100',
22
+ define: { 'process.env.NODE_ENV': '"development"' },
23
+ });
24
+ const htmlPath = join(output, 'preview.html');
25
+ await writeFile(htmlPath, `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>DSH-IM Model Settings</title>
26
+ <style>
27
+ body{margin:0;padding:24px;background:#fff;color:#1f2329;font:14px/20px -apple-system,BlinkMacSystemFont,sans-serif}#app{max-width:760px;margin:auto}#result{max-width:760px;margin:18px auto;font:11px/17px monospace;white-space:pre-wrap;color:#687380}
28
+ @media(max-width:600px){body{padding:12px}}
29
+ @media(prefers-color-scheme:dark){:root{color-scheme:dark;--dsw-alias-bg-layer-1:#202125;--dsw-alias-bg-layer-3:#282a30;--dsw-specific-menu:#282a30;--dsw-alias-bg-module-platform:#25272c;--dsw-alias-label-primary:#e9ebef;--dsw-alias-label-secondary:#b1b5bf;--dsw-alias-label-tertiary:#9a9faa;--dsw-alias-border-l1:#34363c;--dsw-alias-border-l2:#41434c;--dsw-alias-border-l3:#7b9ff9;--dsw-alias-interactive-bg-hover:#353842}body{background:#202125;color:#e9ebef}}
30
+ </style><body><div id="app"></div><pre id="result">Running browser checks…</pre><script>${built.outputFiles[0].text.replace(/<\/script/giu, '<\\/script')}</script></body></html>`);
31
+ for (const [name, size, extra, query] of [
32
+ ['desktop', '1100,1100', [], ''],
33
+ ['mobile', '390,1250', [], '?mobile'],
34
+ ['dark-en', '1100,1100', ['--force-dark-mode'], '?en'],
35
+ ]) {
36
+ const profile = await mkdtemp(join(tmpdir(), 'dsh-im-model-browser-'));
37
+ const { stdout, stderr } = await promisify(execFile)(browser, [
38
+ '--headless', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
39
+ `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check',
40
+ `--window-size=${size}`, '--force-device-scale-factor=1', '--virtual-time-budget=8000',
41
+ ...extra, `--screenshot=${join(output, `${name}.png`)}`, '--dump-dom', pathToFileURL(htmlPath).href + query,
42
+ ], { maxBuffer: 8 * 1024 * 1024, timeout: 15_000, killSignal: 'SIGKILL' }).catch((error) => {
43
+ // Some desktop Chromium builds finish dump-dom and the screenshot but
44
+ // hang during shutdown. Only accept a fully returned fixture result.
45
+ if (error.killed && /<body\b[^>]*\bdata-result="(?:passed|failed)"/u.test(error.stdout ?? '')) return error;
46
+ throw error;
47
+ }).finally(() => rm(profile, { recursive: true, force: true }));
48
+ await writeFile(join(output, `${name}.html`), stdout);
49
+ await writeFile(join(output, `${name}.log`), stderr);
50
+ const result = stdout.match(/<pre id="result">([\s\S]*?)<\/pre>/u)?.[1] ?? 'No browser result';
51
+ if (!stdout.includes('data-result="passed"')) throw new Error(`${name}: ${result}`);
52
+ console.log(`${name}: ${result}`);
53
+ }
54
+ console.log(`Previews: ${output}`);
@@ -337,10 +337,13 @@ function cardMarkdown(text, target) {
337
337
  }
338
338
 
339
339
  function cardData(text, flowStatus, target) {
340
+ const markdown = cardMarkdown(text, target);
341
+ // The shared template uses msgContent for processing, finished, and
342
+ // failed cards. Switching its order to staticMsgContent hides the reply.
340
343
  return {
341
344
  cardParamMap: {
342
345
  flowStatus,
343
- msgContent: cardMarkdown(text, target),
346
+ msgContent: markdown,
344
347
  staticMsgContent: '',
345
348
  sys_full_json_obj: JSON.stringify({ order: ['msgContent'] }),
346
349
  config: JSON.stringify({ autoLayout: true }),
@@ -922,13 +925,17 @@ export function createDingtalkApi({
922
925
 
923
926
  let delivered = false;
924
927
  try {
925
- await cardRequest('v1.0/card/instances', {
928
+ // Deliver the thinking copy in the same request that first shows the
929
+ // card. Creating an empty instance and filling it after deliver (the
930
+ // previous three-request sequence) leaves a blank bubble visible in
931
+ // DingTalk for the time between deliver and the follow-up PUT, and
932
+ // leaves a permanently blank card if that PUT ever fails.
933
+ await cardRequest('v1.0/card/instances/createAndDeliver', {
926
934
  body: {
935
+ ...cardDeliverBody(cardInstanceId, normalizedTarget, appKey),
927
936
  cardTemplateId: DINGTALK_AI_CARD_TEMPLATE_ID,
928
937
  outTrackId: cardInstanceId,
929
- cardData: {
930
- cardParamMap: { config: JSON.stringify({ autoLayout: true }) },
931
- },
938
+ cardData: cardData(content, '2', normalizedTarget),
932
939
  callbackType: 'STREAM',
933
940
  cardAtUserIds: normalizedTarget.atUserIds
934
941
  ? Object.keys(normalizedTarget.atUserIds)
@@ -938,37 +945,31 @@ export function createDingtalkApi({
938
945
  },
939
946
  headers,
940
947
  signal,
941
- action: 'AI Card 创建',
942
- });
943
- await cardRequest('v1.0/card/instances/deliver', {
944
- body: cardDeliverBody(cardInstanceId, normalizedTarget, appKey),
945
- headers,
946
- signal,
947
- action: 'AI Card 投放',
948
+ action: 'AI Card 创建并投放',
948
949
  });
949
950
  delivered = true;
950
- await cardRequest('v1.0/card/instances', {
951
- method: 'PUT',
952
- body: { outTrackId: cardInstanceId, cardData: cardData(content, '2', normalizedTarget) },
953
- headers,
954
- signal,
955
- action: 'AI Card 启动',
956
- });
957
- await cardRequest('v1.0/card/streaming', {
958
- method: 'PUT',
959
- body: {
960
- outTrackId: cardInstanceId,
961
- guid: randomUUID(),
962
- key: 'msgContent',
963
- content: cardMarkdown(content, normalizedTarget).replace(/\n+$/, ''),
964
- isFull: true,
965
- isFinalize: false,
966
- isError: false,
967
- },
968
- headers,
969
- signal,
970
- action: 'AI Card 启动',
971
- });
951
+ try {
952
+ await cardRequest('v1.0/card/streaming', {
953
+ method: 'PUT',
954
+ body: {
955
+ outTrackId: cardInstanceId,
956
+ guid: randomUUID(),
957
+ key: 'msgContent',
958
+ content: cardMarkdown(content, normalizedTarget).replace(/\n+$/, ''),
959
+ isFull: true,
960
+ isFinalize: false,
961
+ isError: false,
962
+ },
963
+ headers,
964
+ signal,
965
+ action: 'AI Card 启动',
966
+ });
967
+ } catch (error) {
968
+ if (signal?.aborted) throw error;
969
+ // The card is already visible with the thinking copy from
970
+ // createAndDeliver above. Keep the instance so finishAiCard can
971
+ // still replace it in place instead of sending a second message.
972
+ }
972
973
  } catch (error) {
973
974
  if (delivered) {
974
975
  const cleanupSignal = AbortSignal.timeout(5_000);
@@ -1018,6 +1019,8 @@ export function createDingtalkApi({
1018
1019
  const token = await accessToken({ clientId, clientSecret, signal });
1019
1020
  const headers = { 'x-acs-dingtalk-access-token': token };
1020
1021
  const normalizedContent = cardMarkdown(content, target);
1022
+ // Close the streaming widget before persisting the template's
1023
+ // finished state and full answer in msgContent.
1021
1024
  await cardRequest('v1.0/card/streaming', {
1022
1025
  method: 'PUT',
1023
1026
  body: {
@@ -1033,8 +1036,7 @@ export function createDingtalkApi({
1033
1036
  signal,
1034
1037
  action: 'AI Card 完成',
1035
1038
  });
1036
- let completed = true;
1037
- const completionRequest = {
1039
+ await cardRequest('v1.0/card/instances', {
1038
1040
  method: 'PUT',
1039
1041
  body: {
1040
1042
  outTrackId: instanceId,
@@ -1043,18 +1045,9 @@ export function createDingtalkApi({
1043
1045
  },
1044
1046
  headers,
1045
1047
  signal,
1046
- action: 'AI Card 收口',
1047
- };
1048
- try {
1049
- await cardRequest('v1.0/card/instances', completionRequest);
1050
- } catch {
1051
- try {
1052
- await cardRequest('v1.0/card/instances', completionRequest);
1053
- } catch {
1054
- completed = false;
1055
- }
1056
- }
1057
- return { delivered: true, completed };
1048
+ action: 'AI Card 完成状态',
1049
+ });
1050
+ return { delivered: true, completed: true };
1058
1051
  },
1059
1052
 
1060
1053
  failAiCard: failCard,
@@ -1336,6 +1336,8 @@ export class DingtalkHarnessBridge {
1336
1336
  providerMessageIds: cardStream.providerMessageIds,
1337
1337
  });
1338
1338
  } else {
1339
+ // Failed streams close the card with a notice pointing to a
1340
+ // follow-up message. Deliver the answer through that fallback.
1339
1341
  textReceipt = createDeliveryReceipt({
1340
1342
  deliveryId: messageId,
1341
1343
  presentation: 'dingtalk-text',
@@ -1411,7 +1413,9 @@ export class DingtalkHarnessBridge {
1411
1413
  ? `${errorText}\n\n${batchFailureMessage}`
1412
1414
  : errorText;
1413
1415
  const streamed = cardStarted && await cardStream.finish(visibleError);
1414
- if (!streamed) await this.#send(sessionWebhook, visibleError, this.#atUsersFor(message));
1416
+ if (!streamed) {
1417
+ await this.#send(sessionWebhook, visibleError, this.#atUsersFor(message));
1418
+ }
1415
1419
  } catch {
1416
1420
  this.#logger.error?.('[dsh-dingtalk] failed to send the safe error reply');
1417
1421
  }