@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.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/bin/dsh-im.mjs +99 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +6211 -0
- package/lib/index.js +13339 -0
- package/package.json +73 -0
- package/plugin-src/client/build.mjs +38 -0
- package/plugin-src/client/channel-logos.js +107 -0
- package/plugin-src/client/channels/dingtalk/api.js +203 -0
- package/plugin-src/client/channels/dingtalk/index.js +709 -0
- package/plugin-src/client/channels/dingtalk/styles.js +140 -0
- package/plugin-src/client/channels/discord/api.js +11 -0
- package/plugin-src/client/channels/discord/index.js +25 -0
- package/plugin-src/client/channels/discord/styles.js +19 -0
- package/plugin-src/client/channels/feishu/api.js +325 -0
- package/plugin-src/client/channels/feishu/index.js +986 -0
- package/plugin-src/client/channels/feishu/styles.js +505 -0
- package/plugin-src/client/channels/qq/api.js +119 -0
- package/plugin-src/client/channels/qq/index.js +402 -0
- package/plugin-src/client/channels/qq/styles.js +19 -0
- package/plugin-src/client/channels/shared/token-api.js +88 -0
- package/plugin-src/client/channels/shared/token-channel.js +277 -0
- package/plugin-src/client/channels/telegram/api.js +11 -0
- package/plugin-src/client/channels/telegram/index.js +25 -0
- package/plugin-src/client/channels/telegram/styles.js +19 -0
- package/plugin-src/client/channels/wecom/api.js +119 -0
- package/plugin-src/client/channels/wecom/index.js +402 -0
- package/plugin-src/client/channels/wecom/styles.js +19 -0
- package/plugin-src/client/channels/weixin/api.js +156 -0
- package/plugin-src/client/channels/weixin/index.js +577 -0
- package/plugin-src/client/channels/weixin/styles.js +120 -0
- package/plugin-src/client/credential-binding.js +117 -0
- package/plugin-src/client/index.js +192 -0
- package/plugin-src/client/lifecycle.js +86 -0
- package/plugin-src/client/styles.js +192 -0
- package/plugin-src/host/build.mjs +24 -0
- package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
- package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
- package/plugin-src/host/channels/discord/index.mjs +30 -0
- package/plugin-src/host/channels/discord/production.mjs +17 -0
- package/plugin-src/host/channels/discord/rpc.mjs +21 -0
- package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
- package/plugin-src/host/channels/feishu/controller.mjs +172 -0
- package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
- package/plugin-src/host/channels/feishu/index.mjs +73 -0
- package/plugin-src/host/channels/feishu/production.mjs +138 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
- package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/qq/index.mjs +30 -0
- package/plugin-src/host/channels/qq/production.mjs +109 -0
- package/plugin-src/host/channels/qq/rpc.mjs +146 -0
- package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
- package/plugin-src/host/channels/shared/production.mjs +104 -0
- package/plugin-src/host/channels/shared/rpc.mjs +119 -0
- package/plugin-src/host/channels/telegram/index.mjs +30 -0
- package/plugin-src/host/channels/telegram/production.mjs +17 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
- package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/wecom/index.mjs +36 -0
- package/plugin-src/host/channels/wecom/production.mjs +113 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
- package/plugin-src/host/channels/weixin/index.mjs +39 -0
- package/plugin-src/host/channels/weixin/production.mjs +119 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
- package/plugin-src/host/index.mjs +44 -0
- package/plugin-src/host/rpc-authority.mjs +11 -0
- package/scripts/verify-package.mjs +97 -0
- package/src/channels/dingtalk/config-store.mjs +282 -0
- package/src/channels/dingtalk/device-auth.mjs +237 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
- package/src/channels/dingtalk/harness-client.mjs +299 -0
- package/src/channels/dingtalk/state-store.mjs +212 -0
- package/src/channels/discord/config-store.mjs +24 -0
- package/src/channels/discord/discord-api.mjs +153 -0
- package/src/channels/discord/discord-bridge.mjs +15 -0
- package/src/channels/discord/discord-controller.mjs +16 -0
- package/src/channels/discord/discord-runtime.mjs +460 -0
- package/src/channels/discord/harness-client.mjs +3 -0
- package/src/channels/discord/state-store.mjs +3 -0
- package/src/channels/feishu/bridge.mjs +216 -0
- package/src/channels/feishu/config.mjs +71 -0
- package/src/channels/feishu/feishu-app.mjs +51 -0
- package/src/channels/feishu/feishu-channel.mjs +153 -0
- package/src/channels/feishu/feishu-runtime.mjs +222 -0
- package/src/channels/feishu/harness-client.mjs +268 -0
- package/src/channels/feishu/message-utils.mjs +51 -0
- package/src/channels/feishu/multi-bot-controller.mjs +686 -0
- package/src/channels/feishu/plugin-config-store.mjs +204 -0
- package/src/channels/feishu/plugin-controller.mjs +248 -0
- package/src/channels/feishu/registration-manager.mjs +345 -0
- package/src/channels/feishu/state-store.mjs +71 -0
- package/src/channels/qq/config-store.mjs +171 -0
- package/src/channels/qq/harness-client.mjs +3 -0
- package/src/channels/qq/qq-bridge.mjs +177 -0
- package/src/channels/qq/qq-controller.mjs +452 -0
- package/src/channels/qq/qq-runtime.mjs +208 -0
- package/src/channels/qq/qr-auth.mjs +24 -0
- package/src/channels/qq/state-store.mjs +96 -0
- package/src/channels/shared/conversation-state-store.mjs +107 -0
- package/src/channels/shared/editable-message-stream.mjs +81 -0
- package/src/channels/shared/text-harness-bridge.mjs +180 -0
- package/src/channels/shared/token-bot-controller.mjs +298 -0
- package/src/channels/shared/token-config-store.mjs +180 -0
- package/src/channels/telegram/config-store.mjs +24 -0
- package/src/channels/telegram/harness-client.mjs +3 -0
- package/src/channels/telegram/state-store.mjs +3 -0
- package/src/channels/telegram/telegram-api.mjs +120 -0
- package/src/channels/telegram/telegram-bridge.mjs +15 -0
- package/src/channels/telegram/telegram-controller.mjs +16 -0
- package/src/channels/telegram/telegram-runtime.mjs +278 -0
- package/src/channels/wecom/config-store.mjs +170 -0
- package/src/channels/wecom/harness-client.mjs +3 -0
- package/src/channels/wecom/qr-auth.mjs +95 -0
- package/src/channels/wecom/state-store.mjs +90 -0
- package/src/channels/wecom/wecom-bridge.mjs +243 -0
- package/src/channels/wecom/wecom-controller.mjs +474 -0
- package/src/channels/wecom/wecom-runtime.mjs +209 -0
- package/src/channels/weixin/config-store.mjs +182 -0
- package/src/channels/weixin/harness-client.mjs +259 -0
- package/src/channels/weixin/state-store.mjs +112 -0
- package/src/channels/weixin/weixin-api.mjs +319 -0
- package/src/channels/weixin/weixin-bridge.mjs +173 -0
- package/src/channels/weixin/weixin-controller.mjs +545 -0
- package/src/channels/weixin/weixin-runtime.mjs +204 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { createDingtalkApi } from './dingtalk-api.mjs';
|
|
2
|
+
import {
|
|
3
|
+
createDingtalkBridgeStatus,
|
|
4
|
+
DingtalkHarnessBridge,
|
|
5
|
+
} from './dingtalk-bridge.mjs';
|
|
6
|
+
|
|
7
|
+
function nonEmptyString(value) {
|
|
8
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function approvedSenderIds(config) {
|
|
12
|
+
const entries = Array.isArray(config?.approvedSenders)
|
|
13
|
+
? config.approvedSenders
|
|
14
|
+
: config?.approvedSenders instanceof Set
|
|
15
|
+
? [...config.approvedSenders]
|
|
16
|
+
: [];
|
|
17
|
+
return new Set(entries.map((entry) => nonEmptyString(
|
|
18
|
+
typeof entry === 'string' ? entry : entry?.staffId,
|
|
19
|
+
)).filter(Boolean));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function approvedSenderCount(config) {
|
|
23
|
+
return approvedSenderIds(config).size;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function streamIsOpen(client) {
|
|
27
|
+
return client?.connected === true || client?.socket?.readyState === 1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function abortable(promise, signal) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
if (signal.aborted) {
|
|
33
|
+
reject(signal.reason);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const onAbort = () => reject(signal.reason);
|
|
37
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
38
|
+
Promise.resolve(promise).then(
|
|
39
|
+
(value) => {
|
|
40
|
+
signal.removeEventListener('abort', onAbort);
|
|
41
|
+
resolve(value);
|
|
42
|
+
},
|
|
43
|
+
(error) => {
|
|
44
|
+
signal.removeEventListener('abort', onAbort);
|
|
45
|
+
reject(error);
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function waitForStreamOpen(client, pollIntervalMs, signal) {
|
|
52
|
+
while (true) {
|
|
53
|
+
signal?.throwIfAborted();
|
|
54
|
+
if (streamIsOpen(client)) return;
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
signal?.removeEventListener('abort', onAbort);
|
|
58
|
+
resolve();
|
|
59
|
+
}, pollIntervalMs);
|
|
60
|
+
const onAbort = () => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
63
|
+
};
|
|
64
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function connectStream(client, timeoutMs, pollIntervalMs, signal) {
|
|
70
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
71
|
+
const connectSignal = AbortSignal.any([signal, timeoutSignal]);
|
|
72
|
+
let connectSettled = false;
|
|
73
|
+
const connectTask = Promise.resolve()
|
|
74
|
+
.then(() => client.connect())
|
|
75
|
+
.finally(() => { connectSettled = true; });
|
|
76
|
+
try {
|
|
77
|
+
await abortable(connectTask, connectSignal);
|
|
78
|
+
await waitForStreamOpen(client, pollIntervalMs, connectSignal);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (connectSignal.aborted) {
|
|
81
|
+
if (!connectSettled) {
|
|
82
|
+
void connectTask.then(() => client.disconnect()).catch(() => undefined);
|
|
83
|
+
}
|
|
84
|
+
if (signal.aborted) throw signal.reason;
|
|
85
|
+
throw new Error(`DingTalk Stream handshake timed out after ${timeoutMs}ms`);
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function defaultStreamFactory({ clientId, clientSecret }) {
|
|
92
|
+
const { DWClient, TOPIC_ROBOT } = await import('dingtalk-stream');
|
|
93
|
+
return {
|
|
94
|
+
client: new DWClient({
|
|
95
|
+
clientId,
|
|
96
|
+
clientSecret,
|
|
97
|
+
endpoint: 'https://api.dingtalk.com',
|
|
98
|
+
autoReconnect: false,
|
|
99
|
+
keepAlive: true,
|
|
100
|
+
debug: false,
|
|
101
|
+
}),
|
|
102
|
+
topic: TOPIC_ROBOT,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createDingtalkRuntimeStatus({
|
|
107
|
+
pendingSenders = [],
|
|
108
|
+
approvedSenders = 0,
|
|
109
|
+
} = {}) {
|
|
110
|
+
return {
|
|
111
|
+
startedAt: null,
|
|
112
|
+
ready: false,
|
|
113
|
+
dingtalkStreamState: 'idle',
|
|
114
|
+
harnessReachable: false,
|
|
115
|
+
lastConnectedAt: null,
|
|
116
|
+
lastCheckedAt: null,
|
|
117
|
+
lastCallbackAt: null,
|
|
118
|
+
authorizationMode: 'sender-staff-id-approval',
|
|
119
|
+
approvedSenderCount: approvedSenders,
|
|
120
|
+
...createDingtalkBridgeStatus({ pendingSenders }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class DingtalkRuntime {
|
|
125
|
+
#config;
|
|
126
|
+
#clientSecret;
|
|
127
|
+
#harness;
|
|
128
|
+
#state;
|
|
129
|
+
#logger;
|
|
130
|
+
#replyTimeoutMs;
|
|
131
|
+
#maxMessageChars;
|
|
132
|
+
#connectTimeoutMs;
|
|
133
|
+
#connectPollIntervalMs;
|
|
134
|
+
#api;
|
|
135
|
+
#streamFactory;
|
|
136
|
+
#status;
|
|
137
|
+
#client = null;
|
|
138
|
+
#bridge = null;
|
|
139
|
+
#topic = null;
|
|
140
|
+
#starting = null;
|
|
141
|
+
#connectionMonitor = null;
|
|
142
|
+
#abortController = null;
|
|
143
|
+
#callbackTasks = new Set();
|
|
144
|
+
|
|
145
|
+
constructor({
|
|
146
|
+
config,
|
|
147
|
+
clientSecret,
|
|
148
|
+
harness,
|
|
149
|
+
state,
|
|
150
|
+
logger = console,
|
|
151
|
+
replyTimeoutMs = 600_000,
|
|
152
|
+
maxMessageChars = 4_000,
|
|
153
|
+
connectTimeoutMs = 15_000,
|
|
154
|
+
connectPollIntervalMs = 25,
|
|
155
|
+
api = createDingtalkApi(),
|
|
156
|
+
streamFactory = defaultStreamFactory,
|
|
157
|
+
}) {
|
|
158
|
+
if (!config || !nonEmptyString(config.clientId) || !nonEmptyString(clientSecret)) {
|
|
159
|
+
throw new TypeError('DingtalkRuntime requires app credentials');
|
|
160
|
+
}
|
|
161
|
+
if (!harness || !state) throw new TypeError('DingtalkRuntime requires Harness and state');
|
|
162
|
+
if (typeof streamFactory !== 'function') throw new TypeError('streamFactory must be a function');
|
|
163
|
+
this.#config = config;
|
|
164
|
+
this.#clientSecret = clientSecret.trim();
|
|
165
|
+
this.#harness = harness;
|
|
166
|
+
this.#state = state;
|
|
167
|
+
this.#logger = logger;
|
|
168
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
169
|
+
this.#maxMessageChars = maxMessageChars;
|
|
170
|
+
this.#connectTimeoutMs = connectTimeoutMs;
|
|
171
|
+
this.#connectPollIntervalMs = connectPollIntervalMs;
|
|
172
|
+
this.#api = api;
|
|
173
|
+
this.#streamFactory = streamFactory;
|
|
174
|
+
this.#status = createDingtalkRuntimeStatus({
|
|
175
|
+
pendingSenders: this.#pendingSenders(),
|
|
176
|
+
approvedSenders: approvedSenderCount(config),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
get status() {
|
|
181
|
+
if (this.#bridge) {
|
|
182
|
+
const bridgeStatus = this.#bridge.status;
|
|
183
|
+
Object.assign(this.#status, bridgeStatus);
|
|
184
|
+
} else {
|
|
185
|
+
this.#status.pendingSenders = this.#pendingSenders();
|
|
186
|
+
}
|
|
187
|
+
return structuredClone(this.#status);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
pendingSender(requestId) {
|
|
191
|
+
return typeof this.#state.pendingSender === 'function'
|
|
192
|
+
? this.#state.pendingSender(requestId)
|
|
193
|
+
: null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pendingSenders() {
|
|
197
|
+
return this.#pendingSenders();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async start() {
|
|
201
|
+
if (this.#client && this.#status.ready) return this.status;
|
|
202
|
+
if (this.#starting) return this.#starting;
|
|
203
|
+
this.#starting = this.#start().finally(() => {
|
|
204
|
+
this.#starting = null;
|
|
205
|
+
});
|
|
206
|
+
return this.#starting;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async #start() {
|
|
210
|
+
await this.stop();
|
|
211
|
+
const abortController = new AbortController();
|
|
212
|
+
this.#abortController = abortController;
|
|
213
|
+
const { signal } = abortController;
|
|
214
|
+
this.#status.startedAt = new Date().toISOString();
|
|
215
|
+
this.#status.dingtalkStreamState = 'connecting';
|
|
216
|
+
this.#status.lastError = null;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await this.#harness.ensureRunning({ signal });
|
|
220
|
+
this.#status.harnessReachable = true;
|
|
221
|
+
if (typeof this.#state.removePendingSenderByStaffId === 'function') {
|
|
222
|
+
for (const staffId of approvedSenderIds(this.#config)) {
|
|
223
|
+
await this.#state.removePendingSenderByStaffId(staffId);
|
|
224
|
+
}
|
|
225
|
+
this.#status.pendingSenders = this.#pendingSenders();
|
|
226
|
+
}
|
|
227
|
+
this.#bridge = new DingtalkHarnessBridge({
|
|
228
|
+
api: this.#api,
|
|
229
|
+
clientId: this.#config.clientId,
|
|
230
|
+
clientSecret: this.#clientSecret,
|
|
231
|
+
approvedSenders: this.#config.approvedSenders,
|
|
232
|
+
harness: this.#harness,
|
|
233
|
+
state: this.#state,
|
|
234
|
+
status: this.#status,
|
|
235
|
+
logger: this.#logger,
|
|
236
|
+
replyTimeoutMs: this.#replyTimeoutMs,
|
|
237
|
+
maxMessageChars: this.#maxMessageChars,
|
|
238
|
+
signal,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const created = await this.#streamFactory({
|
|
242
|
+
clientId: this.#config.clientId,
|
|
243
|
+
clientSecret: this.#clientSecret,
|
|
244
|
+
});
|
|
245
|
+
signal.throwIfAborted();
|
|
246
|
+
this.#client = created?.client ?? created;
|
|
247
|
+
this.#topic = created?.topic ?? created?.TOPIC_ROBOT ?? '/v1.0/im/bot/messages/get';
|
|
248
|
+
if (!this.#client
|
|
249
|
+
|| typeof this.#client.registerCallbackListener !== 'function'
|
|
250
|
+
|| typeof this.#client.connect !== 'function'
|
|
251
|
+
|| typeof this.#client.disconnect !== 'function'
|
|
252
|
+
|| typeof this.#client.socketCallBackResponse !== 'function') {
|
|
253
|
+
throw new TypeError('streamFactory returned an invalid DingTalk Stream client');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const client = this.#client;
|
|
257
|
+
const bridge = this.#bridge;
|
|
258
|
+
client.registerCallbackListener(this.#topic, (response) => {
|
|
259
|
+
if (this.#client !== client || this.#bridge !== bridge) return;
|
|
260
|
+
const callbackMessageId = nonEmptyString(response?.headers?.messageId);
|
|
261
|
+
if (callbackMessageId) {
|
|
262
|
+
try {
|
|
263
|
+
client.socketCallBackResponse(callbackMessageId, { success: true });
|
|
264
|
+
} catch {
|
|
265
|
+
this.#logger.warn?.('[dsh-dingtalk] unable to acknowledge an inbound callback');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const task = Promise.resolve().then(async () => {
|
|
270
|
+
if (this.#bridge !== bridge) return;
|
|
271
|
+
let message;
|
|
272
|
+
try {
|
|
273
|
+
message = typeof response?.data === 'string'
|
|
274
|
+
? JSON.parse(response.data)
|
|
275
|
+
: response?.data;
|
|
276
|
+
} catch {
|
|
277
|
+
this.#status.lastError = '钉钉消息格式无效。';
|
|
278
|
+
this.#logger.warn?.('[dsh-dingtalk] ignored an invalid callback payload');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (!message || typeof message !== 'object') return;
|
|
282
|
+
this.#status.lastCallbackAt = Date.now();
|
|
283
|
+
await bridge.accept(message);
|
|
284
|
+
}).catch(() => {
|
|
285
|
+
if (signal.aborted || this.#bridge !== bridge) return;
|
|
286
|
+
this.#status.lastError = '钉钉消息处理失败。';
|
|
287
|
+
this.#logger.error?.('[dsh-dingtalk] callback processing failed');
|
|
288
|
+
}).finally(() => this.#callbackTasks.delete(task));
|
|
289
|
+
this.#callbackTasks.add(task);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await connectStream(
|
|
293
|
+
client,
|
|
294
|
+
this.#connectTimeoutMs,
|
|
295
|
+
this.#connectPollIntervalMs,
|
|
296
|
+
signal,
|
|
297
|
+
);
|
|
298
|
+
this.#status.ready = true;
|
|
299
|
+
this.#status.dingtalkStreamState = 'connected';
|
|
300
|
+
this.#status.lastConnectedAt = Date.now();
|
|
301
|
+
this.#status.lastCheckedAt = Date.now();
|
|
302
|
+
this.#status.lastError = null;
|
|
303
|
+
this.#connectionMonitor = setInterval(() => {
|
|
304
|
+
const connected = streamIsOpen(client);
|
|
305
|
+
this.#status.ready = connected;
|
|
306
|
+
this.#status.dingtalkStreamState = connected ? 'connected' : 'reconnecting';
|
|
307
|
+
this.#status.lastCheckedAt = Date.now();
|
|
308
|
+
if (connected) this.#status.lastError = null;
|
|
309
|
+
}, 1_000);
|
|
310
|
+
this.#connectionMonitor.unref?.();
|
|
311
|
+
return this.status;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const aborted = signal.aborted;
|
|
314
|
+
this.#status.ready = false;
|
|
315
|
+
this.#status.dingtalkStreamState = aborted ? 'idle' : 'failed';
|
|
316
|
+
this.#status.lastError = aborted ? null : (error?.message ?? String(error));
|
|
317
|
+
await this.stop({ preserveError: !aborted });
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async stop({ preserveError = false } = {}) {
|
|
323
|
+
const lastError = preserveError ? this.#status.lastError : null;
|
|
324
|
+
const abortController = this.#abortController;
|
|
325
|
+
this.#abortController = null;
|
|
326
|
+
abortController?.abort(new DOMException('DingTalk runtime stopped', 'AbortError'));
|
|
327
|
+
if (this.#connectionMonitor) clearInterval(this.#connectionMonitor);
|
|
328
|
+
this.#connectionMonitor = null;
|
|
329
|
+
this.#status.ready = false;
|
|
330
|
+
|
|
331
|
+
const client = this.#client;
|
|
332
|
+
this.#client = null;
|
|
333
|
+
this.#topic = null;
|
|
334
|
+
if (client) {
|
|
335
|
+
try {
|
|
336
|
+
await client.disconnect();
|
|
337
|
+
} catch {
|
|
338
|
+
this.#logger.warn?.('[dsh-dingtalk] DingTalk Stream disconnect failed');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
await Promise.allSettled([...this.#callbackTasks]);
|
|
342
|
+
this.#callbackTasks.clear();
|
|
343
|
+
if (this.#bridge) await this.#bridge.waitForIdle();
|
|
344
|
+
this.#bridge = null;
|
|
345
|
+
this.#status.dingtalkStreamState = preserveError ? 'failed' : 'idle';
|
|
346
|
+
this.#status.lastError = lastError;
|
|
347
|
+
return this.status;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
#pendingSenders() {
|
|
351
|
+
return typeof this.#state.pendingSenders === 'function'
|
|
352
|
+
? this.#state.pendingSenders()
|
|
353
|
+
: [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export const DingTalkRuntime = DingtalkRuntime;
|
|
358
|
+
export const createDingTalkRuntimeStatus = createDingtalkRuntimeStatus;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
function sleep(ms, signal) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
if (signal?.aborted) {
|
|
7
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const timer = setTimeout(() => {
|
|
11
|
+
signal?.removeEventListener('abort', onAbort);
|
|
12
|
+
resolve();
|
|
13
|
+
}, ms);
|
|
14
|
+
const onAbort = () => {
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
17
|
+
};
|
|
18
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assistantMessageText(event) {
|
|
23
|
+
return (event?.data?.message?.content ?? [])
|
|
24
|
+
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
|
25
|
+
.map((part) => part.text)
|
|
26
|
+
.join('\n')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class HarnessReplyTracker {
|
|
31
|
+
#promptRpcId;
|
|
32
|
+
#lastSeq;
|
|
33
|
+
#openTurn = null;
|
|
34
|
+
#targetTurn = null;
|
|
35
|
+
#stepText = new Map();
|
|
36
|
+
#latestText = '';
|
|
37
|
+
#finished = false;
|
|
38
|
+
#reason = null;
|
|
39
|
+
|
|
40
|
+
constructor({ promptRpcId, afterSeq = -1 }) {
|
|
41
|
+
this.#promptRpcId = promptRpcId;
|
|
42
|
+
this.#lastSeq = afterSeq;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get finished() {
|
|
46
|
+
return this.#finished;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get answer() {
|
|
50
|
+
return this.#latestText.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get reason() {
|
|
54
|
+
return this.#reason;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
consume(entries) {
|
|
58
|
+
let update = null;
|
|
59
|
+
const ordered = [...entries]
|
|
60
|
+
.map((entry) => entry?.event ?? entry)
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1));
|
|
63
|
+
|
|
64
|
+
for (const event of ordered) {
|
|
65
|
+
const seq = event.seq ?? -1;
|
|
66
|
+
if (seq <= this.#lastSeq) continue;
|
|
67
|
+
this.#lastSeq = seq;
|
|
68
|
+
|
|
69
|
+
if (event.type === 'turn/start') this.#openTurn = event.data?.turn ?? null;
|
|
70
|
+
|
|
71
|
+
if (event.type === 'user/message' && event.data?.source?.rpcId === this.#promptRpcId) {
|
|
72
|
+
this.#targetTurn = this.#openTurn;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (this.#targetTurn === null) continue;
|
|
76
|
+
|
|
77
|
+
if (event.type === 'turn/end') {
|
|
78
|
+
if (event.data?.turn !== this.#targetTurn) continue;
|
|
79
|
+
this.#finished = true;
|
|
80
|
+
this.#reason = event.data?.reason ?? null;
|
|
81
|
+
this.#openTurn = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (event.data?.turn !== this.#targetTurn) continue;
|
|
85
|
+
|
|
86
|
+
if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') {
|
|
87
|
+
const step = event.data?.step ?? 0;
|
|
88
|
+
const index = event.data.chunk.index ?? 0;
|
|
89
|
+
const key = `${step}:${index}`;
|
|
90
|
+
this.#stepText.set(key, (this.#stepText.get(key) ?? '') + event.data.chunk.text);
|
|
91
|
+
const prefix = `${step}:`;
|
|
92
|
+
const text = [...this.#stepText.entries()]
|
|
93
|
+
.filter(([partKey]) => partKey.startsWith(prefix))
|
|
94
|
+
.sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1]))
|
|
95
|
+
.map(([, part]) => part)
|
|
96
|
+
.join('\n')
|
|
97
|
+
.trim();
|
|
98
|
+
if (text && text !== this.#latestText) {
|
|
99
|
+
this.#latestText = text;
|
|
100
|
+
update = { type: 'text', text };
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (event.type === 'assistant/message') {
|
|
106
|
+
const text = assistantMessageText(event);
|
|
107
|
+
if (text && text !== this.#latestText) {
|
|
108
|
+
this.#latestText = text;
|
|
109
|
+
update = { type: 'text', text };
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (event.type === 'tool/call') {
|
|
115
|
+
update = { type: 'tool', name: event.data?.name ?? '工具' };
|
|
116
|
+
} else if (event.type === 'tool/result') {
|
|
117
|
+
update = { type: 'status', text: '正在整理结果…' };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return update;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export class HarnessRpcError extends Error {
|
|
125
|
+
constructor(method, error) {
|
|
126
|
+
super(`${method}: ${error?.message ?? 'unknown Harness RPC error'}`);
|
|
127
|
+
this.name = 'HarnessRpcError';
|
|
128
|
+
this.method = method;
|
|
129
|
+
this.code = error?.code ?? 'internal';
|
|
130
|
+
this.details = error?.details ?? {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class HarnessClient {
|
|
135
|
+
#baseUrl;
|
|
136
|
+
#workspace;
|
|
137
|
+
#agentPreset;
|
|
138
|
+
#autostart;
|
|
139
|
+
#dshBin;
|
|
140
|
+
#fetch;
|
|
141
|
+
#managedProcess = null;
|
|
142
|
+
|
|
143
|
+
constructor({
|
|
144
|
+
baseUrl,
|
|
145
|
+
workspace,
|
|
146
|
+
agentPreset = 'standard',
|
|
147
|
+
autostart = false,
|
|
148
|
+
dshBin = 'dsh',
|
|
149
|
+
fetchImpl = fetch,
|
|
150
|
+
}) {
|
|
151
|
+
this.#baseUrl = new URL(baseUrl);
|
|
152
|
+
this.#workspace = workspace;
|
|
153
|
+
this.#agentPreset = agentPreset;
|
|
154
|
+
this.#autostart = autostart;
|
|
155
|
+
this.#dshBin = dshBin;
|
|
156
|
+
this.#fetch = fetchImpl;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) {
|
|
160
|
+
const rpcId = options.rpcId ?? `dingtalk-${randomUUID()}`;
|
|
161
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
162
|
+
const signal = options.signal
|
|
163
|
+
? AbortSignal.any([options.signal, timeoutSignal])
|
|
164
|
+
: timeoutSignal;
|
|
165
|
+
const response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: { 'content-type': 'application/json' },
|
|
168
|
+
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
|
169
|
+
signal,
|
|
170
|
+
});
|
|
171
|
+
if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`);
|
|
172
|
+
const body = await response.json();
|
|
173
|
+
if (body?.type !== 'server-response' || body?.rpcId !== rpcId) {
|
|
174
|
+
throw new Error(`Harness returned an invalid response for ${method}`);
|
|
175
|
+
}
|
|
176
|
+
if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error);
|
|
177
|
+
return body.result.value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async health(options = {}) {
|
|
181
|
+
await this.rpc('host.describe', {}, 5_000, options);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async ensureRunning(options = {}) {
|
|
186
|
+
try {
|
|
187
|
+
return await this.health(options);
|
|
188
|
+
} catch (firstError) {
|
|
189
|
+
if (!this.#autostart) throw firstError;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!this.#managedProcess || this.#managedProcess.exitCode !== null) {
|
|
193
|
+
const port = this.#baseUrl.port || (this.#baseUrl.protocol === 'https:' ? '443' : '80');
|
|
194
|
+
this.#managedProcess = spawn(this.#dshBin, [
|
|
195
|
+
'web', '--host', this.#baseUrl.hostname, '--port', port,
|
|
196
|
+
], {
|
|
197
|
+
cwd: this.#workspace,
|
|
198
|
+
env: process.env,
|
|
199
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
200
|
+
});
|
|
201
|
+
this.#managedProcess.on('error', (error) => {
|
|
202
|
+
console.error('[dsh-dingtalk] failed to start Harness:', error.message);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const deadline = Date.now() + 60_000;
|
|
207
|
+
let lastError;
|
|
208
|
+
while (Date.now() < deadline) {
|
|
209
|
+
await sleep(1_000, options.signal);
|
|
210
|
+
try {
|
|
211
|
+
return await this.health(options);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
lastError = error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async workspaceId(options = {}) {
|
|
220
|
+
const { items } = await this.rpc('workspace.list', {}, 30_000, options);
|
|
221
|
+
const existing = items.find((item) => item.path === this.#workspace);
|
|
222
|
+
if (existing) return existing.workspaceId;
|
|
223
|
+
const created = await this.rpc('workspace.create', { path: this.#workspace }, 30_000, options);
|
|
224
|
+
return created.workspace.workspaceId;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async createSession(options = {}) {
|
|
228
|
+
await this.ensureRunning(options);
|
|
229
|
+
const workspaceId = await this.workspaceId(options);
|
|
230
|
+
const created = await this.rpc('session.create', {
|
|
231
|
+
workspaceId,
|
|
232
|
+
agentPreset: this.#agentPreset,
|
|
233
|
+
}, 30_000, options);
|
|
234
|
+
return created.sessionId;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async sessionExists(sessionId, options = {}) {
|
|
238
|
+
try {
|
|
239
|
+
await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options);
|
|
240
|
+
return true;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false;
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async ask(sessionId, text, options = {}) {
|
|
248
|
+
if (typeof options === 'number') options = { timeoutMs: options };
|
|
249
|
+
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
250
|
+
const signal = options.signal;
|
|
251
|
+
const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
|
|
252
|
+
await this.ensureRunning({ signal });
|
|
253
|
+
const before = await this.rpc(
|
|
254
|
+
'session.history',
|
|
255
|
+
{ sessionId, maxMessages: 1 },
|
|
256
|
+
30_000,
|
|
257
|
+
{ signal },
|
|
258
|
+
);
|
|
259
|
+
const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1));
|
|
260
|
+
const promptRpcId = `dingtalk-${randomUUID()}`;
|
|
261
|
+
const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq });
|
|
262
|
+
|
|
263
|
+
await this.rpc('session.prompt', {
|
|
264
|
+
sessionId,
|
|
265
|
+
mode: 'queue',
|
|
266
|
+
content: [{ type: 'text', text }],
|
|
267
|
+
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
268
|
+
}, 30_000, { rpcId: promptRpcId, signal });
|
|
269
|
+
|
|
270
|
+
const deadline = Date.now() + timeoutMs;
|
|
271
|
+
while (Date.now() < deadline) {
|
|
272
|
+
await sleep(300, signal);
|
|
273
|
+
const history = await this.rpc(
|
|
274
|
+
'session.history',
|
|
275
|
+
{ sessionId, maxMessages: 50 },
|
|
276
|
+
30_000,
|
|
277
|
+
{ signal },
|
|
278
|
+
);
|
|
279
|
+
const update = tracker.consume(history.events ?? []);
|
|
280
|
+
if (update && onUpdate) {
|
|
281
|
+
try {
|
|
282
|
+
await onUpdate(update);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.warn('[dsh-dingtalk] ignored a progress update failure:', error.message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!tracker.finished) continue;
|
|
288
|
+
if (tracker.answer) return tracker.answer;
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
stopManagedProcess() {
|
|
297
|
+
if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM');
|
|
298
|
+
}
|
|
299
|
+
}
|