@xmanrui/dsh-im 0.1.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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/THIRD_PARTY_NOTICES.md +15 -0
  4. package/bin/dsh-im.mjs +99 -0
  5. package/cordis.patch.yml +3 -0
  6. package/lib/client.js +6211 -0
  7. package/lib/index.js +13339 -0
  8. package/package.json +73 -0
  9. package/plugin-src/client/build.mjs +38 -0
  10. package/plugin-src/client/channel-logos.js +107 -0
  11. package/plugin-src/client/channels/dingtalk/api.js +203 -0
  12. package/plugin-src/client/channels/dingtalk/index.js +709 -0
  13. package/plugin-src/client/channels/dingtalk/styles.js +140 -0
  14. package/plugin-src/client/channels/discord/api.js +11 -0
  15. package/plugin-src/client/channels/discord/index.js +25 -0
  16. package/plugin-src/client/channels/discord/styles.js +19 -0
  17. package/plugin-src/client/channels/feishu/api.js +325 -0
  18. package/plugin-src/client/channels/feishu/index.js +986 -0
  19. package/plugin-src/client/channels/feishu/styles.js +505 -0
  20. package/plugin-src/client/channels/qq/api.js +119 -0
  21. package/plugin-src/client/channels/qq/index.js +402 -0
  22. package/plugin-src/client/channels/qq/styles.js +19 -0
  23. package/plugin-src/client/channels/shared/token-api.js +88 -0
  24. package/plugin-src/client/channels/shared/token-channel.js +277 -0
  25. package/plugin-src/client/channels/telegram/api.js +11 -0
  26. package/plugin-src/client/channels/telegram/index.js +25 -0
  27. package/plugin-src/client/channels/telegram/styles.js +19 -0
  28. package/plugin-src/client/channels/wecom/api.js +119 -0
  29. package/plugin-src/client/channels/wecom/index.js +402 -0
  30. package/plugin-src/client/channels/wecom/styles.js +19 -0
  31. package/plugin-src/client/channels/weixin/api.js +156 -0
  32. package/plugin-src/client/channels/weixin/index.js +577 -0
  33. package/plugin-src/client/channels/weixin/styles.js +120 -0
  34. package/plugin-src/client/credential-binding.js +117 -0
  35. package/plugin-src/client/index.js +192 -0
  36. package/plugin-src/client/lifecycle.js +86 -0
  37. package/plugin-src/client/styles.js +192 -0
  38. package/plugin-src/host/build.mjs +24 -0
  39. package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
  40. package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
  43. package/plugin-src/host/channels/discord/index.mjs +30 -0
  44. package/plugin-src/host/channels/discord/production.mjs +17 -0
  45. package/plugin-src/host/channels/discord/rpc.mjs +21 -0
  46. package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
  47. package/plugin-src/host/channels/feishu/controller.mjs +172 -0
  48. package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
  49. package/plugin-src/host/channels/feishu/index.mjs +73 -0
  50. package/plugin-src/host/channels/feishu/production.mjs +138 -0
  51. package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
  52. package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
  53. package/plugin-src/host/channels/qq/index.mjs +30 -0
  54. package/plugin-src/host/channels/qq/production.mjs +109 -0
  55. package/plugin-src/host/channels/qq/rpc.mjs +146 -0
  56. package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
  57. package/plugin-src/host/channels/shared/production.mjs +104 -0
  58. package/plugin-src/host/channels/shared/rpc.mjs +119 -0
  59. package/plugin-src/host/channels/telegram/index.mjs +30 -0
  60. package/plugin-src/host/channels/telegram/production.mjs +17 -0
  61. package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
  62. package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
  63. package/plugin-src/host/channels/wecom/index.mjs +36 -0
  64. package/plugin-src/host/channels/wecom/production.mjs +113 -0
  65. package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
  66. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
  67. package/plugin-src/host/channels/weixin/index.mjs +39 -0
  68. package/plugin-src/host/channels/weixin/production.mjs +119 -0
  69. package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
  70. package/plugin-src/host/index.mjs +44 -0
  71. package/plugin-src/host/rpc-authority.mjs +11 -0
  72. package/scripts/verify-package.mjs +97 -0
  73. package/src/channels/dingtalk/config-store.mjs +282 -0
  74. package/src/channels/dingtalk/device-auth.mjs +237 -0
  75. package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
  77. package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
  78. package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
  79. package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
  80. package/src/channels/dingtalk/harness-client.mjs +299 -0
  81. package/src/channels/dingtalk/state-store.mjs +212 -0
  82. package/src/channels/discord/config-store.mjs +24 -0
  83. package/src/channels/discord/discord-api.mjs +153 -0
  84. package/src/channels/discord/discord-bridge.mjs +15 -0
  85. package/src/channels/discord/discord-controller.mjs +16 -0
  86. package/src/channels/discord/discord-runtime.mjs +460 -0
  87. package/src/channels/discord/harness-client.mjs +3 -0
  88. package/src/channels/discord/state-store.mjs +3 -0
  89. package/src/channels/feishu/bridge.mjs +216 -0
  90. package/src/channels/feishu/config.mjs +71 -0
  91. package/src/channels/feishu/feishu-app.mjs +51 -0
  92. package/src/channels/feishu/feishu-channel.mjs +153 -0
  93. package/src/channels/feishu/feishu-runtime.mjs +222 -0
  94. package/src/channels/feishu/harness-client.mjs +268 -0
  95. package/src/channels/feishu/message-utils.mjs +51 -0
  96. package/src/channels/feishu/multi-bot-controller.mjs +686 -0
  97. package/src/channels/feishu/plugin-config-store.mjs +204 -0
  98. package/src/channels/feishu/plugin-controller.mjs +248 -0
  99. package/src/channels/feishu/registration-manager.mjs +345 -0
  100. package/src/channels/feishu/state-store.mjs +71 -0
  101. package/src/channels/qq/config-store.mjs +171 -0
  102. package/src/channels/qq/harness-client.mjs +3 -0
  103. package/src/channels/qq/qq-bridge.mjs +177 -0
  104. package/src/channels/qq/qq-controller.mjs +452 -0
  105. package/src/channels/qq/qq-runtime.mjs +208 -0
  106. package/src/channels/qq/qr-auth.mjs +24 -0
  107. package/src/channels/qq/state-store.mjs +96 -0
  108. package/src/channels/shared/conversation-state-store.mjs +107 -0
  109. package/src/channels/shared/editable-message-stream.mjs +81 -0
  110. package/src/channels/shared/text-harness-bridge.mjs +180 -0
  111. package/src/channels/shared/token-bot-controller.mjs +298 -0
  112. package/src/channels/shared/token-config-store.mjs +180 -0
  113. package/src/channels/telegram/config-store.mjs +24 -0
  114. package/src/channels/telegram/harness-client.mjs +3 -0
  115. package/src/channels/telegram/state-store.mjs +3 -0
  116. package/src/channels/telegram/telegram-api.mjs +120 -0
  117. package/src/channels/telegram/telegram-bridge.mjs +15 -0
  118. package/src/channels/telegram/telegram-controller.mjs +16 -0
  119. package/src/channels/telegram/telegram-runtime.mjs +278 -0
  120. package/src/channels/wecom/config-store.mjs +170 -0
  121. package/src/channels/wecom/harness-client.mjs +3 -0
  122. package/src/channels/wecom/qr-auth.mjs +95 -0
  123. package/src/channels/wecom/state-store.mjs +90 -0
  124. package/src/channels/wecom/wecom-bridge.mjs +243 -0
  125. package/src/channels/wecom/wecom-controller.mjs +474 -0
  126. package/src/channels/wecom/wecom-runtime.mjs +209 -0
  127. package/src/channels/weixin/config-store.mjs +182 -0
  128. package/src/channels/weixin/harness-client.mjs +259 -0
  129. package/src/channels/weixin/state-store.mjs +112 -0
  130. package/src/channels/weixin/weixin-api.mjs +319 -0
  131. package/src/channels/weixin/weixin-bridge.mjs +173 -0
  132. package/src/channels/weixin/weixin-controller.mjs +545 -0
  133. package/src/channels/weixin/weixin-runtime.mjs +204 -0
@@ -0,0 +1,124 @@
1
+ const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]);
2
+
3
+ function retryDelays(value) {
4
+ if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS];
5
+ const valid = value.filter((delay) => Number.isFinite(delay) && delay >= 0);
6
+ return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS];
7
+ }
8
+
9
+ export class ConnectionSupervisor {
10
+ #controller;
11
+ #harness;
12
+ #logger;
13
+ #retryDelays;
14
+ #healthyIntervalMs;
15
+ #setTimeout;
16
+ #clearTimeout;
17
+ #timer = null;
18
+ #running = null;
19
+ #retryIndex = 0;
20
+ #closed = false;
21
+ #started = false;
22
+ #ready;
23
+ #resolveReady;
24
+
25
+ constructor({
26
+ controller,
27
+ harness,
28
+ logger = console,
29
+ retryDelaysMs,
30
+ healthyIntervalMs = 15_000,
31
+ setTimeoutImpl = setTimeout,
32
+ clearTimeoutImpl = clearTimeout,
33
+ }) {
34
+ if (!controller || typeof controller.initialize !== 'function' || typeof controller.status !== 'function') {
35
+ throw new TypeError('ConnectionSupervisor requires a controller');
36
+ }
37
+ if (!harness || typeof harness.ensureRunning !== 'function') {
38
+ throw new TypeError('ConnectionSupervisor requires a Harness client');
39
+ }
40
+ this.#controller = controller;
41
+ this.#harness = harness;
42
+ this.#logger = logger;
43
+ this.#retryDelays = retryDelays(retryDelaysMs);
44
+ this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0
45
+ ? healthyIntervalMs : 15_000;
46
+ this.#setTimeout = setTimeoutImpl;
47
+ this.#clearTimeout = clearTimeoutImpl;
48
+ this.#ready = new Promise((resolve) => {
49
+ this.#resolveReady = resolve;
50
+ });
51
+ }
52
+
53
+ get ready() {
54
+ return this.#ready;
55
+ }
56
+
57
+ start() {
58
+ if (this.#started || this.#closed) return this;
59
+ this.#started = true;
60
+ this.#schedule(0);
61
+ return this;
62
+ }
63
+
64
+ async close() {
65
+ if (this.#closed) return;
66
+ this.#closed = true;
67
+ if (this.#timer !== null) this.#clearTimeout(this.#timer);
68
+ this.#timer = null;
69
+ await this.#running?.catch(() => undefined);
70
+ this.#resolveReady?.(null);
71
+ this.#resolveReady = null;
72
+ }
73
+
74
+ #schedule(delayMs) {
75
+ if (this.#closed) return;
76
+ this.#timer = this.#setTimeout(() => {
77
+ this.#timer = null;
78
+ void this.#run();
79
+ }, delayMs);
80
+ this.#timer?.unref?.();
81
+ }
82
+
83
+ async #run() {
84
+ if (this.#closed || this.#running) return;
85
+ const operation = this.#reconcile();
86
+ this.#running = operation;
87
+ try {
88
+ await operation;
89
+ } finally {
90
+ if (this.#running === operation) this.#running = null;
91
+ }
92
+ }
93
+
94
+ async #reconcile() {
95
+ try {
96
+ await this.#harness.ensureRunning();
97
+ if (this.#closed) return;
98
+ const status = await this.#controller.initialize();
99
+ if (this.#closed) return;
100
+ this.#resolveReady?.(status);
101
+ this.#resolveReady = null;
102
+ const { configured, connected } = status.totals;
103
+ if (connected < configured) {
104
+ const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
105
+ this.#retryIndex += 1;
106
+ this.#logger.warn?.(`[dsh-im:wecom] ${connected}/${configured} bots connected; retrying in ${delay}ms`);
107
+ this.#schedule(delay);
108
+ return;
109
+ }
110
+ this.#retryIndex = 0;
111
+ this.#schedule(this.#healthyIntervalMs);
112
+ } catch (error) {
113
+ if (this.#closed) return;
114
+ const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
115
+ this.#retryIndex += 1;
116
+ this.#logger.warn?.(`[dsh-im:wecom] connection reconciliation failed; retrying in ${delay}ms`, error);
117
+ this.#schedule(delay);
118
+ }
119
+ }
120
+ }
121
+
122
+ export function createConnectionSupervisor(options) {
123
+ return new ConnectionSupervisor(options);
124
+ }
@@ -0,0 +1,36 @@
1
+ import { createProductionController } from './production.mjs';
2
+ import { installWecomRpc } from './rpc.mjs';
3
+
4
+ export const name = 'dsh-im-wecom-host';
5
+ export const inject = ['connection', 'credentials', 'webServer'];
6
+
7
+ export async function apply(ctx, config = {}) {
8
+ if (config?.controller) {
9
+ return installWecomRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
10
+ }
11
+ const production = await createProductionController(ctx, config, config.internals);
12
+ const disposeRpc = installWecomRpc(
13
+ ctx,
14
+ production.controller,
15
+ config.rpcOptions,
16
+ config.rpcAuthority,
17
+ );
18
+ ctx.effect(() => async () => production.close(), 'dsh-im: close Enterprise WeChat bot connections');
19
+ return disposeRpc;
20
+ }
21
+
22
+ export function createWecomHostPlugin(config) {
23
+ return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
24
+ }
25
+
26
+ export { createConnectionSupervisor, ConnectionSupervisor } from './connection-supervisor.mjs';
27
+ export { createProductionController } from './production.mjs';
28
+ export {
29
+ WECOM_ENDPOINTS,
30
+ WECOM_RPC_CHANNEL,
31
+ WECOM_RPC_ENDPOINTS,
32
+ createWecomRpcHandler,
33
+ installWecomRpc,
34
+ } from './rpc.mjs';
35
+ export { WecomController } from '../../../../src/channels/wecom/wecom-controller.mjs';
36
+ export { WecomRuntime } from '../../../../src/channels/wecom/wecom-runtime.mjs';
@@ -0,0 +1,113 @@
1
+ import { unlink } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ import { WecomConfigStore } from '../../../../src/channels/wecom/config-store.mjs';
6
+ import { WecomHarnessClient } from '../../../../src/channels/wecom/harness-client.mjs';
7
+ import { WecomQrAuth } from '../../../../src/channels/wecom/qr-auth.mjs';
8
+ import { WecomStateStore } from '../../../../src/channels/wecom/state-store.mjs';
9
+ import { WecomController } from '../../../../src/channels/wecom/wecom-controller.mjs';
10
+ import { WecomRuntime } from '../../../../src/channels/wecom/wecom-runtime.mjs';
11
+ import { createConnectionSupervisor } from './connection-supervisor.mjs';
12
+
13
+ function harnessOrigin(webServer, configured) {
14
+ if (configured !== undefined) return new URL(configured);
15
+ const port = webServer?.port;
16
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
17
+ throw new Error('dsh-im Enterprise WeChat requires an initialized DSH webServer port');
18
+ }
19
+ return new URL(`http://127.0.0.1:${port}`);
20
+ }
21
+
22
+ function pluginPaths(config) {
23
+ const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
24
+ const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-wecom'));
25
+ return {
26
+ config: resolve(config.configPath ?? join(root, 'config.json')),
27
+ bots: resolve(config.botsDir ?? join(root, 'bots')),
28
+ };
29
+ }
30
+
31
+ export async function createProductionController(ctx, config = {}, internals = {}) {
32
+ if (!ctx?.credentials) throw new TypeError('dsh-im Enterprise WeChat requires ctx.credentials');
33
+ if (!ctx?.webServer) throw new TypeError('dsh-im Enterprise WeChat requires ctx.webServer');
34
+
35
+ const ConfigStore = internals.ConfigStore ?? WecomConfigStore;
36
+ const StateStore = internals.StateStore ?? WecomStateStore;
37
+ const Harness = internals.HarnessClient ?? WecomHarnessClient;
38
+ const Controller = internals.Controller ?? WecomController;
39
+ const Runtime = internals.Runtime ?? WecomRuntime;
40
+ const QrAuth = internals.QrAuth ?? WecomQrAuth;
41
+ const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor;
42
+ const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:wecom') : (ctx.logger ?? console);
43
+ const paths = pluginPaths(config);
44
+ const configStore = await new ConfigStore(paths.config).load();
45
+ const qrAuth = internals.qrAuth ?? new QrAuth({
46
+ source: config.qrSource ?? 'deepseek-harness',
47
+ platform: config.qrPlatform,
48
+ });
49
+ const stateStores = new Map();
50
+ const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
51
+ const stateFor = async (botId) => {
52
+ let state = stateStores.get(botId);
53
+ if (!state) {
54
+ state = await new StateStore(statePath(botId)).load();
55
+ stateStores.set(botId, state);
56
+ }
57
+ return state;
58
+ };
59
+ const harness = new Harness({
60
+ baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
61
+ workspace: resolve(config.workspace ?? process.cwd()),
62
+ agentPreset: config.agentPreset ?? 'standard',
63
+ autostart: false,
64
+ dshBin: config.dshBin ?? 'dsh',
65
+ });
66
+ const controller = new Controller({
67
+ qrAuth,
68
+ credentials: ctx.credentials,
69
+ configStore,
70
+ logger,
71
+ createRuntime: async ({ botId, config: botConfig, secret }) => new Runtime({
72
+ config: botConfig,
73
+ secret,
74
+ harness,
75
+ state: await stateFor(botId),
76
+ replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
77
+ connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
78
+ maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
79
+ logger: {
80
+ error: (...args) => logger.error?.(`[${botId}]`, ...args),
81
+ warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
82
+ info: (...args) => logger.info?.(`[${botId}]`, ...args),
83
+ debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
84
+ },
85
+ }),
86
+ deleteState: async ({ botId }) => {
87
+ const state = stateStores.get(botId);
88
+ stateStores.delete(botId);
89
+ if (state && typeof state.remove === 'function') return state.remove();
90
+ try {
91
+ await unlink(statePath(botId));
92
+ } catch (error) {
93
+ if (error?.code !== 'ENOENT') throw error;
94
+ }
95
+ },
96
+ });
97
+ const supervisor = createSupervisor({
98
+ controller,
99
+ harness,
100
+ logger,
101
+ retryDelaysMs: config.retryDelaysMs,
102
+ healthyIntervalMs: config.healthyIntervalMs,
103
+ }).start();
104
+ return {
105
+ controller,
106
+ ready: supervisor.ready,
107
+ async close() {
108
+ await supervisor.close();
109
+ await controller.close();
110
+ harness.stopManagedProcess();
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,151 @@
1
+ import QRCode from 'qrcode';
2
+ import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
+
4
+ export const WECOM_RPC_CHANNEL = '/wecom';
5
+ export const WECOM_ENDPOINTS = Object.freeze({
6
+ status: 'connection.status',
7
+ beginProvisioning: 'provision.begin',
8
+ pollProvisioning: 'provision.poll',
9
+ cancelProvisioning: 'provision.cancel',
10
+ bindCredentials: 'bot.bind-credentials',
11
+ reconnectBot: 'bot.reconnect',
12
+ deleteBot: 'bot.delete',
13
+ });
14
+ export const WECOM_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_ENDPOINTS));
15
+
16
+ const FORBIDDEN_PUBLIC_KEYS = new Set([
17
+ 'secret', 'secretRef', 'scode', 'remoteBotId', 'verificationUrl', 'bot_info', 'botid',
18
+ ]);
19
+
20
+ function isRecord(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
22
+ }
23
+
24
+ function exactKeys(value, allowed) {
25
+ return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key));
26
+ }
27
+
28
+ function validId(value) {
29
+ return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
30
+ }
31
+
32
+ function validCredential(value, maxLength) {
33
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
34
+ }
35
+
36
+ function payloadFailure(endpoint, payload) {
37
+ if (!isRecord(payload)) return 'Payload must be an object.';
38
+ if (endpoint === WECOM_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
39
+ if (endpoint === WECOM_ENDPOINTS.beginProvisioning) {
40
+ return exactKeys(payload, ['locale']) && (payload.locale === undefined || payload.locale === 'zh-CN')
41
+ ? null : 'provision.begin received unsupported fields.';
42
+ }
43
+ if ([WECOM_ENDPOINTS.pollProvisioning, WECOM_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
44
+ return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
45
+ ? null : `${endpoint} requires an attemptId.`;
46
+ }
47
+ if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
48
+ return exactKeys(payload, ['botId', 'secret'])
49
+ && validCredential(payload.botId, 512)
50
+ && validCredential(payload.secret, 1024)
51
+ ? null : 'bot.bind-credentials requires Bot ID and Secret.';
52
+ }
53
+ if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
54
+ return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.';
55
+ }
56
+ if (endpoint === WECOM_ENDPOINTS.deleteBot) {
57
+ return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
58
+ ? null : 'bot.delete requires a botId and confirm=true.';
59
+ }
60
+ return 'Unknown Enterprise WeChat endpoint.';
61
+ }
62
+
63
+ function sanitizePublic(value) {
64
+ if (Array.isArray(value)) return value.map(sanitizePublic);
65
+ if (!isRecord(value)) return value;
66
+ const safe = {};
67
+ for (const [key, child] of Object.entries(value)) {
68
+ if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child);
69
+ }
70
+ return safe;
71
+ }
72
+
73
+ async function qrDataUrl(value) {
74
+ return QRCode.toDataURL(value, {
75
+ type: 'image/png', errorCorrectionLevel: 'M', margin: 2, width: 320,
76
+ });
77
+ }
78
+
79
+ async function withEncodedQr(value, encodeQr) {
80
+ if (!value || typeof value.verificationUrl !== 'string') return sanitizePublic(value);
81
+ return sanitizePublic({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) });
82
+ }
83
+
84
+ async function publicStatus(status, encodeQr) {
85
+ const value = structuredClone(status);
86
+ if (value?.provisioning) value.provisioning = await withEncodedQr(value.provisioning, encodeQr);
87
+ return sanitizePublic(value);
88
+ }
89
+
90
+ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
91
+ for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'bindCredentials', 'reconnectBot', 'deleteBot']) {
92
+ if (typeof controller?.[method] !== 'function') {
93
+ throw new TypeError(`A complete Enterprise WeChat controller is required (${method})`);
94
+ }
95
+ }
96
+ const qrCache = new Map();
97
+ const cachedEncode = (url) => {
98
+ let encoded = qrCache.get(url);
99
+ if (!encoded) {
100
+ if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value);
101
+ encoded = Promise.resolve().then(() => encodeQr(url));
102
+ qrCache.set(url, encoded);
103
+ }
104
+ return encoded;
105
+ };
106
+ return async (endpoint, payload, signal) => {
107
+ if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
108
+ if (!WECOM_RPC_ENDPOINTS.includes(endpoint)) {
109
+ return { ok: false, error: { code: 'bad-request', message: 'Unknown Enterprise WeChat endpoint.' } };
110
+ }
111
+ const invalid = payloadFailure(endpoint, payload);
112
+ if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } };
113
+ try {
114
+ let value;
115
+ if (endpoint === WECOM_ENDPOINTS.status) value = await publicStatus(await controller.status(), cachedEncode);
116
+ else if (endpoint === WECOM_ENDPOINTS.beginProvisioning) {
117
+ value = await withEncodedQr(await controller.startProvisioning(), cachedEncode);
118
+ } else if (endpoint === WECOM_ENDPOINTS.pollProvisioning) {
119
+ const current = await controller.registrationStatus(payload.attemptId);
120
+ if (!current) return { ok: false, error: { code: 'bad-request', message: 'The provisioning attempt no longer exists.' } };
121
+ value = await withEncodedQr(current, cachedEncode);
122
+ } else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) {
123
+ value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId));
124
+ } else if (endpoint === WECOM_ENDPOINTS.bindCredentials) {
125
+ value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
126
+ } else if (endpoint === WECOM_ENDPOINTS.reconnectBot) {
127
+ value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
128
+ } else {
129
+ value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
130
+ }
131
+ return signal?.aborted
132
+ ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
133
+ : { ok: true, value };
134
+ } catch {
135
+ return signal?.aborted
136
+ ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
137
+ : { ok: false, error: { code: 'wecom-operation-failed', message: '企业微信操作失败,请稍后重试。' } };
138
+ }
139
+ };
140
+ }
141
+
142
+ export function installWecomRpc(ctx, controller, options, authority) {
143
+ if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
144
+ throw new TypeError('DSH Host Connection RPC is required');
145
+ }
146
+ return ctx.connection.rpc.handle(
147
+ WECOM_RPC_CHANNEL,
148
+ createWecomRpcHandler(controller, options),
149
+ { authority: resolveRpcAuthority(authority) },
150
+ );
151
+ }
@@ -0,0 +1,127 @@
1
+ const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]);
2
+
3
+ function retryDelays(value) {
4
+ if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS];
5
+ const valid = value.filter((delay) => Number.isFinite(delay) && delay >= 0);
6
+ return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS];
7
+ }
8
+
9
+ export class ConnectionSupervisor {
10
+ #controller;
11
+ #harness;
12
+ #logger;
13
+ #retryDelays;
14
+ #healthyIntervalMs;
15
+ #setTimeout;
16
+ #clearTimeout;
17
+ #timer = null;
18
+ #running = null;
19
+ #retryIndex = 0;
20
+ #closed = false;
21
+ #started = false;
22
+ #ready;
23
+ #resolveReady;
24
+
25
+ constructor({
26
+ controller,
27
+ harness,
28
+ logger = console,
29
+ retryDelaysMs,
30
+ healthyIntervalMs = 15_000,
31
+ setTimeoutImpl = setTimeout,
32
+ clearTimeoutImpl = clearTimeout,
33
+ }) {
34
+ if (!controller || typeof controller.initialize !== 'function' || typeof controller.status !== 'function') {
35
+ throw new TypeError('ConnectionSupervisor requires a controller');
36
+ }
37
+ if (!harness || typeof harness.ensureRunning !== 'function') {
38
+ throw new TypeError('ConnectionSupervisor requires a Harness client');
39
+ }
40
+ this.#controller = controller;
41
+ this.#harness = harness;
42
+ this.#logger = logger;
43
+ this.#retryDelays = retryDelays(retryDelaysMs);
44
+ this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0
45
+ ? healthyIntervalMs
46
+ : 15_000;
47
+ this.#setTimeout = setTimeoutImpl;
48
+ this.#clearTimeout = clearTimeoutImpl;
49
+ this.#ready = new Promise((resolve) => {
50
+ this.#resolveReady = resolve;
51
+ });
52
+ }
53
+
54
+ get ready() {
55
+ return this.#ready;
56
+ }
57
+
58
+ start() {
59
+ if (this.#started || this.#closed) return this;
60
+ this.#started = true;
61
+ this.#schedule(0);
62
+ return this;
63
+ }
64
+
65
+ async close() {
66
+ if (this.#closed) return;
67
+ this.#closed = true;
68
+ if (this.#timer !== null) this.#clearTimeout(this.#timer);
69
+ this.#timer = null;
70
+ await this.#running?.catch(() => undefined);
71
+ this.#resolveReady?.(null);
72
+ this.#resolveReady = null;
73
+ }
74
+
75
+ #schedule(delayMs) {
76
+ if (this.#closed) return;
77
+ this.#timer = this.#setTimeout(() => {
78
+ this.#timer = null;
79
+ void this.#run();
80
+ }, delayMs);
81
+ this.#timer?.unref?.();
82
+ }
83
+
84
+ async #run() {
85
+ if (this.#closed || this.#running) return;
86
+ const operation = this.#reconcile();
87
+ this.#running = operation;
88
+ try {
89
+ await operation;
90
+ } finally {
91
+ if (this.#running === operation) this.#running = null;
92
+ }
93
+ }
94
+
95
+ async #reconcile() {
96
+ try {
97
+ await this.#harness.ensureRunning();
98
+ if (this.#closed) return;
99
+ const status = await this.#controller.initialize();
100
+ if (this.#closed) return;
101
+ this.#resolveReady?.(status);
102
+ this.#resolveReady = null;
103
+ const { configured, connected } = status.totals;
104
+ if (connected < configured) {
105
+ const delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
106
+ this.#retryIndex += 1;
107
+ this.#logger.warn?.(
108
+ `[dsh-weixin] ${connected}/${configured} accounts connected; retrying in ${delayMs}ms`,
109
+ );
110
+ this.#schedule(delayMs);
111
+ return;
112
+ }
113
+ this.#retryIndex = 0;
114
+ this.#schedule(this.#healthyIntervalMs);
115
+ } catch (error) {
116
+ if (this.#closed) return;
117
+ const delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
118
+ this.#retryIndex += 1;
119
+ this.#logger.warn?.(`[dsh-weixin] connection reconciliation failed; retrying in ${delayMs}ms`, error);
120
+ this.#schedule(delayMs);
121
+ }
122
+ }
123
+ }
124
+
125
+ export function createConnectionSupervisor(options) {
126
+ return new ConnectionSupervisor(options);
127
+ }
@@ -0,0 +1,39 @@
1
+ import { createProductionController } from './production.mjs';
2
+ import { installWeixinRpc } from './rpc.mjs';
3
+
4
+ export const name = 'dsh-weixin-host';
5
+ export const inject = ['connection', 'credentials', 'webServer'];
6
+
7
+ export async function apply(ctx, config = {}) {
8
+ if (config?.controller) {
9
+ return installWeixinRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
10
+ }
11
+
12
+ const production = await createProductionController(ctx, config, config.internals);
13
+ const disposeRpc = installWeixinRpc(
14
+ ctx,
15
+ production.controller,
16
+ config.rpcOptions,
17
+ config.rpcAuthority,
18
+ );
19
+ ctx.effect(() => async () => {
20
+ await production.close();
21
+ }, 'dsh-weixin: close account connections');
22
+ return disposeRpc;
23
+ }
24
+
25
+ export function createWeixinHostPlugin(config) {
26
+ return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
27
+ }
28
+
29
+ export { createConnectionSupervisor, ConnectionSupervisor } from './connection-supervisor.mjs';
30
+ export { createProductionController } from './production.mjs';
31
+ export {
32
+ WEIXIN_ENDPOINTS,
33
+ WEIXIN_RPC_CHANNEL,
34
+ WEIXIN_RPC_ENDPOINTS,
35
+ createWeixinRpcHandler,
36
+ installWeixinRpc,
37
+ } from './rpc.mjs';
38
+ export { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs';
39
+ export { WeixinRuntime } from '../../../../src/channels/weixin/weixin-runtime.mjs';