@xmanrui/dsh-im 4.14.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +10 -2
- package/README.md +17 -6
- package/lib/client.js +1980 -616
- package/lib/index.js +303 -293
- package/package.json +6 -2
- package/plugin-src/client/access-policy-settings.js +6 -0
- package/plugin-src/client/channels/feishu/index.js +16 -4
- package/plugin-src/client/channels/wecom-app/api.js +124 -0
- package/plugin-src/client/channels/wecom-app/index.js +612 -0
- package/plugin-src/client/channels/wecom-app/styles.js +25 -0
- package/plugin-src/client/delivery-settings.js +7 -0
- package/plugin-src/client/i18n.js +64 -1
- package/plugin-src/client/index.js +21 -0
- package/plugin-src/client/model-setting.js +135 -69
- package/plugin-src/client/session-channel-logos.js +241 -0
- package/plugin-src/client/styles.js +22 -7
- package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
- package/plugin-src/host/channels/discord/index.mjs +8 -10
- package/plugin-src/host/channels/feishu/index.mjs +8 -15
- package/plugin-src/host/channels/office/index.mjs +9 -5
- package/plugin-src/host/channels/qq/index.mjs +8 -15
- package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
- package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
- package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
- package/plugin-src/host/channels/shared/startup.mjs +47 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
- package/plugin-src/host/channels/slack/index.mjs +8 -10
- package/plugin-src/host/channels/telegram/index.mjs +8 -10
- package/plugin-src/host/channels/wecom/index.mjs +8 -15
- package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
- package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
- package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
- package/plugin-src/host/channels/weixin/index.mjs +8 -15
- package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
- package/plugin-src/host/delivery-adapter.mjs +4 -0
- package/plugin-src/host/delivery-suggestions.mjs +4 -0
- package/plugin-src/host/index.mjs +21 -2
- package/plugin-src/host/session-title-prefix.mjs +122 -0
- package/scripts/verify-model-setting.mjs +54 -0
- package/scripts/verify-session-channel-logos.mjs +59 -0
- package/scripts/verify-session-title-prefix.mjs +149 -0
- package/src/channels/feishu/bridge.mjs +305 -12
- package/src/channels/feishu/feishu-cards.mjs +38 -0
- package/src/channels/feishu/feishu-channel.mjs +88 -20
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/feishu/repair-manager.mjs +3 -2
- package/src/channels/shared/bot-workspace-store.mjs +11 -3
- package/src/channels/shared/command-catalog.mjs +102 -0
- package/src/channels/shared/i18n-en/feishu.mjs +14 -2
- package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
- package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
- package/src/channels/shared/i18n-en.mjs +2 -0
- package/src/channels/shared/model-setting.mjs +43 -8
- package/src/channels/shared/session-channel-labels.mjs +26 -0
- package/src/channels/shared/text-harness-bridge.mjs +2 -26
- package/src/channels/telegram/telegram-api.mjs +18 -6
- package/src/channels/telegram/telegram-runtime.mjs +34 -32
- package/src/channels/wecom/send-error.mjs +31 -0
- package/src/channels/wecom/wecom-bridge.mjs +59 -17
- package/src/channels/wecom-app/callback-server.mjs +471 -0
- package/src/channels/wecom-app/config-store.mjs +240 -0
- package/src/channels/wecom-app/harness-client.mjs +11 -0
- package/src/channels/wecom-app/state-store.mjs +107 -0
- package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
- package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
- package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
- package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
WECOM_APP_STREAM_PLACEHOLDER,
|
|
6
|
+
buildEncryptedReply,
|
|
7
|
+
createUserCrypto,
|
|
8
|
+
isXmlFormat,
|
|
9
|
+
parseWecomAppPlainMessage,
|
|
10
|
+
parseWecomAppXmlBody,
|
|
11
|
+
} from './wecom-app-api.mjs';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_STREAM_TTL_MS = 15 * 60_000;
|
|
14
|
+
const DEFAULT_MAX_STREAMS = 256;
|
|
15
|
+
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
function safeEqualHex(expected, provided) {
|
|
18
|
+
const a = Buffer.from(String(expected ?? ''), 'utf8');
|
|
19
|
+
const b = Buffer.from(String(provided ?? ''), 'utf8');
|
|
20
|
+
if (a.length === 0 || a.length !== b.length) return false;
|
|
21
|
+
return timingSafeEqual(a, b);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeBody(res, status, body, headers = {}) {
|
|
25
|
+
if (res.writableEnded || res.destroyed) return;
|
|
26
|
+
res.writeHead(status, {
|
|
27
|
+
'content-length': String(Buffer.byteLength(body)),
|
|
28
|
+
'cache-control': 'no-store',
|
|
29
|
+
...headers,
|
|
30
|
+
});
|
|
31
|
+
res.end(body);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeText(res, status, text, headers = {}) {
|
|
35
|
+
writeBody(res, status, text, { 'content-type': 'text/plain; charset=utf-8', ...headers });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeEmpty(res) {
|
|
39
|
+
if (res.writableEnded || res.destroyed) return;
|
|
40
|
+
res.statusCode = 200;
|
|
41
|
+
res.end();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// One shared HTTP listener for every wecom-app bot. Each bot registers a route
|
|
45
|
+
// keyed by its full callback path; the per-bot random path segment doubles as
|
|
46
|
+
// the first gate before the WeCom signature check.
|
|
47
|
+
export class WecomAppCallbackServer {
|
|
48
|
+
#host;
|
|
49
|
+
#port;
|
|
50
|
+
#logger;
|
|
51
|
+
#streamTtlMs;
|
|
52
|
+
#maxStreams;
|
|
53
|
+
#maxBodyBytes;
|
|
54
|
+
#server = null;
|
|
55
|
+
#routes = new Map();
|
|
56
|
+
#streams = new Map();
|
|
57
|
+
#streamByMsgId = new Map();
|
|
58
|
+
|
|
59
|
+
constructor({
|
|
60
|
+
host = '127.0.0.1',
|
|
61
|
+
port = 30987,
|
|
62
|
+
logger = console,
|
|
63
|
+
streamTtlMs = DEFAULT_STREAM_TTL_MS,
|
|
64
|
+
maxStreams = DEFAULT_MAX_STREAMS,
|
|
65
|
+
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
66
|
+
} = {}) {
|
|
67
|
+
this.#host = String(host ?? '127.0.0.1');
|
|
68
|
+
this.#port = Number(port);
|
|
69
|
+
if (!Number.isInteger(this.#port) || this.#port < 0 || this.#port > 65_535) {
|
|
70
|
+
throw new TypeError('WecomAppCallbackServer requires an integer port between 0 and 65535');
|
|
71
|
+
}
|
|
72
|
+
this.#logger = logger;
|
|
73
|
+
this.#streamTtlMs = streamTtlMs;
|
|
74
|
+
this.#maxStreams = maxStreams;
|
|
75
|
+
this.#maxBodyBytes = maxBodyBytes;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get listening() {
|
|
79
|
+
return this.#server !== null && this.#server.listening === true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get address() {
|
|
83
|
+
return { host: this.#host, port: this.#port };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The OS-assigned port after listen(0); falls back to the configured port.
|
|
87
|
+
get actualPort() {
|
|
88
|
+
const bound = this.#server?.address?.();
|
|
89
|
+
return typeof bound?.port === 'number' ? bound.port : this.#port;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
routePath({ botId, callbackSecret }) {
|
|
93
|
+
// Keep the callback prefix distinct from the GUI RPC channel path
|
|
94
|
+
// (/wecom-app/...): the reverse-proxy location must forward ONLY the
|
|
95
|
+
// callback prefix to this listener and leave RPC traffic to dsh web.
|
|
96
|
+
return `/wecom-app-callback/${botId}/${callbackSecret}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
registerRoute(route) {
|
|
100
|
+
if (!route?.botId || !route.callbackSecret || typeof route.cryptoFor !== 'function' || typeof route.onInbound !== 'function') {
|
|
101
|
+
throw new TypeError('A wecom-app route requires botId, callbackSecret, cryptoFor, and onInbound');
|
|
102
|
+
}
|
|
103
|
+
// Keyed by the full callback path: WeCom requests are dispatched on it.
|
|
104
|
+
this.#routes.set(this.routePath(route), Object.freeze({ ...route }));
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
unregisterRoute(botId) {
|
|
109
|
+
for (const [path, route] of this.#routes.entries()) {
|
|
110
|
+
if (route.botId === botId) this.#routes.delete(path);
|
|
111
|
+
}
|
|
112
|
+
for (const [streamId, stream] of this.#streams.entries()) {
|
|
113
|
+
if (stream.botId === botId) {
|
|
114
|
+
this.#streams.delete(streamId);
|
|
115
|
+
if (stream.msgid) this.#streamByMsgId.delete(this.#msgKey(stream.botId, stream.msgid));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async start() {
|
|
122
|
+
if (this.#server) return this.address;
|
|
123
|
+
const server = createServer((request, response) => {
|
|
124
|
+
this.#handleRequest(request, response).catch((error) => {
|
|
125
|
+
this.#logger.warn?.('[dsh-im:wecom-app] callback request failed:', error);
|
|
126
|
+
writeText(response, 500, 'internal error');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
server.on('clientError', (_error, socket) => {
|
|
130
|
+
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
131
|
+
});
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
const onceError = (error) => {
|
|
134
|
+
server.removeListener('listening', onceListening);
|
|
135
|
+
reject(error);
|
|
136
|
+
};
|
|
137
|
+
const onceListening = () => {
|
|
138
|
+
server.removeListener('error', onceError);
|
|
139
|
+
resolve();
|
|
140
|
+
};
|
|
141
|
+
server.once('error', onceError);
|
|
142
|
+
server.once('listening', onceListening);
|
|
143
|
+
server.listen(this.#port, this.#host);
|
|
144
|
+
});
|
|
145
|
+
this.#server = server;
|
|
146
|
+
return this.address;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async stop() {
|
|
150
|
+
const server = this.#server;
|
|
151
|
+
this.#server = null;
|
|
152
|
+
this.#routes.clear();
|
|
153
|
+
this.#streams.clear();
|
|
154
|
+
this.#streamByMsgId.clear();
|
|
155
|
+
if (!server) return;
|
|
156
|
+
await new Promise((resolve) => {
|
|
157
|
+
server.close(() => resolve());
|
|
158
|
+
server.closeAllConnections?.();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
routeCount() {
|
|
163
|
+
return this.#routes.size;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Stream dedup and lookup are scoped per bot: two self-built apps can share
|
|
167
|
+
// a MsgId, and a refresh signed by app B must never read app A's stream.
|
|
168
|
+
#msgKey(botId, msgid) {
|
|
169
|
+
return botId + ':' + msgid;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
createStream({ botId, msgid }) {
|
|
173
|
+
this.#pruneStreams();
|
|
174
|
+
if (this.#streams.size >= this.#maxStreams) {
|
|
175
|
+
const oldest = this.#streams.keys().next().value;
|
|
176
|
+
const evicted = this.#streams.get(oldest);
|
|
177
|
+
this.#streams.delete(oldest);
|
|
178
|
+
if (evicted?.msgid) this.#streamByMsgId.delete(this.#msgKey(evicted.botId, evicted.msgid));
|
|
179
|
+
}
|
|
180
|
+
const streamId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
181
|
+
const state = {
|
|
182
|
+
botId,
|
|
183
|
+
streamId,
|
|
184
|
+
msgid: msgid ?? null,
|
|
185
|
+
content: '',
|
|
186
|
+
finished: false,
|
|
187
|
+
error: null,
|
|
188
|
+
refreshes: 0,
|
|
189
|
+
createdAt: Date.now(),
|
|
190
|
+
updatedAt: Date.now(),
|
|
191
|
+
};
|
|
192
|
+
this.#streams.set(streamId, state);
|
|
193
|
+
if (msgid) this.#streamByMsgId.set(this.#msgKey(botId, msgid), streamId);
|
|
194
|
+
return state;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getStream(streamId) {
|
|
198
|
+
return this.#streams.get(streamId) ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
appendStream(streamId, chunk, { replace = false } = {}) {
|
|
202
|
+
const stream = this.#streams.get(streamId);
|
|
203
|
+
if (!stream || stream.finished) return false;
|
|
204
|
+
stream.content = replace ? String(chunk ?? '') : stream.content + String(chunk ?? '');
|
|
205
|
+
stream.updatedAt = Date.now();
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
finishStream(streamId, { error } = {}) {
|
|
210
|
+
const stream = this.#streams.get(streamId);
|
|
211
|
+
if (!stream) return false;
|
|
212
|
+
if (error) stream.error = String(error);
|
|
213
|
+
stream.finished = true;
|
|
214
|
+
stream.updatedAt = Date.now();
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
streamSnapshot(streamId) {
|
|
219
|
+
const stream = this.#streams.get(streamId);
|
|
220
|
+
if (!stream) return null;
|
|
221
|
+
return {
|
|
222
|
+
id: stream.streamId,
|
|
223
|
+
finish: stream.finished,
|
|
224
|
+
content: stream.content,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async #handleRequest(request, response) {
|
|
229
|
+
if (request.method !== 'GET' && request.method !== 'POST') {
|
|
230
|
+
writeText(response, 405, 'method not allowed', { allow: 'GET, POST' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
|
234
|
+
const route = this.#routes.get(url.pathname) ?? null;
|
|
235
|
+
if (!route) {
|
|
236
|
+
writeText(response, 404, 'not found');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const query = url.searchParams;
|
|
240
|
+
if (request.method === 'GET') {
|
|
241
|
+
this.#verifyUrl(route, query, response);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await this.#handlePost(route, query, request, response);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#verifyUrl(route, query, response) {
|
|
248
|
+
const timestamp = query.get('timestamp') ?? '';
|
|
249
|
+
const nonce = query.get('nonce') ?? '';
|
|
250
|
+
const echostr = query.get('echostr') ?? '';
|
|
251
|
+
const signature = query.get('msg_signature') ?? query.get('msgsignature') ?? query.get('signature') ?? '';
|
|
252
|
+
this.#logger.info?.('[dsh-im:wecom-app] URL verification attempt', route.botId, { timestamp, nonce });
|
|
253
|
+
if (!timestamp || !nonce || !echostr || !signature) {
|
|
254
|
+
this.#logger.warn?.('[dsh-im:wecom-app] URL verification rejected: missing query params', route.botId);
|
|
255
|
+
writeText(response, 400, 'missing query params');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
let crypto;
|
|
259
|
+
try {
|
|
260
|
+
crypto = route.cryptoFor();
|
|
261
|
+
} catch (error) {
|
|
262
|
+
this.#logger.warn?.('[dsh-im:wecom-app] route credentials are incomplete:', error);
|
|
263
|
+
writeText(response, 503, 'route not ready');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
if (!safeEqualHex(crypto.computeSignature(timestamp, nonce, echostr), signature)) {
|
|
268
|
+
this.#logger.warn?.('[dsh-im:wecom-app] URL verification signature mismatch', route.botId);
|
|
269
|
+
writeText(response, 401, 'unauthorized');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const plaintext = crypto.decrypt(echostr);
|
|
273
|
+
this.#logger.info?.('[dsh-im:wecom-app] URL verification succeeded', route.botId);
|
|
274
|
+
writeText(response, 200, plaintext);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
this.#logger.warn?.('[dsh-im:wecom-app] URL verification failed:', error);
|
|
277
|
+
writeText(response, 400, 'verification failed');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async #handlePost(route, query, request, response) {
|
|
282
|
+
const body = await this.#readBody(request, response);
|
|
283
|
+
if (body === null) return;
|
|
284
|
+
let envelope = {};
|
|
285
|
+
if (isXmlFormat(body.raw)) {
|
|
286
|
+
const xml = parseWecomAppXmlBody(body.raw);
|
|
287
|
+
envelope = {
|
|
288
|
+
encrypt: xml.Encrypt ?? '',
|
|
289
|
+
signature: xml.MsgSignature ?? query.get('msg_signature') ?? query.get('msgsignature') ?? '',
|
|
290
|
+
timestamp: xml.TimeStamp ?? query.get('timestamp') ?? '',
|
|
291
|
+
nonce: xml.Nonce ?? query.get('nonce') ?? '',
|
|
292
|
+
format: 'xml',
|
|
293
|
+
};
|
|
294
|
+
} else {
|
|
295
|
+
let parsed = {};
|
|
296
|
+
try {
|
|
297
|
+
parsed = JSON.parse(body.raw);
|
|
298
|
+
} catch {
|
|
299
|
+
writeText(response, 400, 'invalid payload');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
envelope = {
|
|
303
|
+
encrypt: parsed.encrypt ?? '',
|
|
304
|
+
signature: parsed.msgsignature ?? parsed.MsgSignature ?? query.get('msg_signature') ?? '',
|
|
305
|
+
timestamp: String(parsed.timestamp ?? parsed.TimeStamp ?? query.get('timestamp') ?? ''),
|
|
306
|
+
nonce: String(parsed.nonce ?? parsed.Nonce ?? query.get('nonce') ?? ''),
|
|
307
|
+
format: 'json',
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if (!envelope.encrypt || !envelope.signature || !envelope.timestamp || !envelope.nonce) {
|
|
311
|
+
writeText(response, 400, 'missing encrypted payload');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.#logger.info?.('[dsh-im:wecom-app] callback POST received', route.botId, { format: envelope.format, bytes: body.raw.length });
|
|
315
|
+
let crypto;
|
|
316
|
+
try {
|
|
317
|
+
crypto = route.cryptoFor();
|
|
318
|
+
} catch (error) {
|
|
319
|
+
this.#logger.warn?.('[dsh-im:wecom-app] route credentials are incomplete:', error);
|
|
320
|
+
writeText(response, 503, 'route not ready');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
let plaintext;
|
|
324
|
+
try {
|
|
325
|
+
if (!safeEqualHex(crypto.computeSignature(envelope.timestamp, envelope.nonce, envelope.encrypt), envelope.signature)) {
|
|
326
|
+
this.#logger.warn?.('[dsh-im:wecom-app] callback signature mismatch', route.botId);
|
|
327
|
+
writeText(response, 401, 'unauthorized');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
plaintext = crypto.decrypt(envelope.encrypt);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
this.#logger.warn?.('[dsh-im:wecom-app] callback signature or decryption failed:', error);
|
|
333
|
+
writeText(response, 401, 'unauthorized');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const message = parseWecomAppPlainMessage(plaintext);
|
|
337
|
+
if (!message) {
|
|
338
|
+
writeText(response, 400, 'invalid message');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
this.#logger.info?.('[dsh-im:wecom-app] callback message accepted', route.botId, { msgtype: message.msgtype ?? null, msgid: message.msgid ?? null });
|
|
342
|
+
this.#dispatch(route, message, envelope, response);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#readBody(request, response) {
|
|
346
|
+
return new Promise((resolve) => {
|
|
347
|
+
const chunks = [];
|
|
348
|
+
let length = 0;
|
|
349
|
+
request.on('data', (chunk) => {
|
|
350
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
351
|
+
length += buffer.length;
|
|
352
|
+
if (length > this.#maxBodyBytes) {
|
|
353
|
+
writeText(response, 413, 'payload too large');
|
|
354
|
+
request.destroy();
|
|
355
|
+
resolve(null);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
chunks.push(buffer);
|
|
359
|
+
});
|
|
360
|
+
request.on('end', () => {
|
|
361
|
+
const raw = Buffer.concat(chunks, length).toString('utf8');
|
|
362
|
+
if (!raw.trim()) {
|
|
363
|
+
writeText(response, 400, 'empty payload');
|
|
364
|
+
resolve(null);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
resolve({ raw });
|
|
368
|
+
});
|
|
369
|
+
request.on('error', () => {
|
|
370
|
+
resolve(null);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#dispatch(route, message, envelope, response) {
|
|
376
|
+
const msgid = typeof message.msgid === 'string' ? message.msgid : null;
|
|
377
|
+
const isStreamRefresh = message.msgtype === 'stream' || message.event === 'stream_refresh';
|
|
378
|
+
if (isStreamRefresh) {
|
|
379
|
+
const streamId = message.stream?.id ?? null;
|
|
380
|
+
const stream = streamId ? this.#streams.get(streamId) : null;
|
|
381
|
+
const snapshot = stream && stream.botId === route.botId ? this.streamSnapshot(streamId) : null;
|
|
382
|
+
if (!snapshot) {
|
|
383
|
+
writeEmpty(response);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
stream.refreshes += 1;
|
|
387
|
+
stream.updatedAt = Date.now();
|
|
388
|
+
this.#replyEncrypted(route, envelope, response, {
|
|
389
|
+
msgtype: 'stream',
|
|
390
|
+
stream: snapshot,
|
|
391
|
+
});
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// WeCom retries an unanswered POST with the same MsgId; reply with the
|
|
395
|
+
// current stream snapshot (including the final frame) so a client that
|
|
396
|
+
// lost an earlier response can still settle on the finished content.
|
|
397
|
+
const retryStreamId = msgid ? this.#streamByMsgId.get(this.#msgKey(route.botId, msgid)) : null;
|
|
398
|
+
if (retryStreamId) {
|
|
399
|
+
const snapshot = this.streamSnapshot(retryStreamId);
|
|
400
|
+
if (snapshot) {
|
|
401
|
+
this.#replyEncrypted(route, envelope, response, { msgtype: 'stream', stream: snapshot });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
writeEmpty(response);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (message.msgtype === 'event') {
|
|
408
|
+
writeEmpty(response);
|
|
409
|
+
route.onInbound({ kind: 'event', message }).catch((error) => {
|
|
410
|
+
this.#logger.warn?.('[dsh-im:wecom-app] event handling failed:', error);
|
|
411
|
+
});
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
let streamState = null;
|
|
415
|
+
let streamEnabled = false;
|
|
416
|
+
try {
|
|
417
|
+
streamEnabled = route.streamEnabled?.() === true;
|
|
418
|
+
} catch {
|
|
419
|
+
streamEnabled = false;
|
|
420
|
+
}
|
|
421
|
+
if (streamEnabled && msgid && (message.msgtype === 'text' || message.msgtype === 'image')) {
|
|
422
|
+
streamState = this.createStream({ botId: route.botId, msgid });
|
|
423
|
+
this.#replyEncrypted(route, envelope, response, {
|
|
424
|
+
msgtype: 'stream',
|
|
425
|
+
stream: {
|
|
426
|
+
id: streamState.streamId,
|
|
427
|
+
finish: false,
|
|
428
|
+
content: WECOM_APP_STREAM_PLACEHOLDER(),
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
} else {
|
|
432
|
+
writeEmpty(response);
|
|
433
|
+
}
|
|
434
|
+
route.onInbound({ kind: 'message', message, stream: streamState }).catch((error) => {
|
|
435
|
+
this.#logger.warn?.('[dsh-im:wecom-app] message handling failed:', error);
|
|
436
|
+
if (streamState) this.finishStream(streamState.streamId, { error: error?.message ?? String(error) });
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#replyEncrypted(route, envelope, response, payload) {
|
|
441
|
+
let body;
|
|
442
|
+
try {
|
|
443
|
+
body = buildEncryptedReply({
|
|
444
|
+
format: envelope.format,
|
|
445
|
+
crypto: route.cryptoFor(),
|
|
446
|
+
plaintext: payload,
|
|
447
|
+
timestamp: envelope.timestamp,
|
|
448
|
+
nonce: envelope.nonce,
|
|
449
|
+
});
|
|
450
|
+
} catch (error) {
|
|
451
|
+
this.#logger.warn?.('[dsh-im:wecom-app] failed to encrypt the passive reply:', error);
|
|
452
|
+
writeEmpty(response);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
writeBody(response, 200, body, {
|
|
456
|
+
'content-type': envelope.format === 'json'
|
|
457
|
+
? 'application/json; charset=utf-8'
|
|
458
|
+
: 'application/xml; charset=utf-8',
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
#pruneStreams() {
|
|
463
|
+
const cutoff = Date.now() - this.#streamTtlMs;
|
|
464
|
+
for (const [streamId, stream] of this.#streams.entries()) {
|
|
465
|
+
if (stream.updatedAt < cutoff) {
|
|
466
|
+
this.#streams.delete(streamId);
|
|
467
|
+
if (stream.msgid) this.#streamByMsgId.delete(this.#msgKey(stream.botId, stream.msgid));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { t } from '../shared/i18n.mjs';
|
|
6
|
+
|
|
7
|
+
const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
|
|
8
|
+
|
|
9
|
+
function cleanString(value) {
|
|
10
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function safeIntegrationId(value) {
|
|
14
|
+
const id = cleanString(value);
|
|
15
|
+
return id && /^wecomapp_[a-f0-9]{24}$/.test(id) ? id : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function safeRef(value, prefix) {
|
|
19
|
+
const ref = cleanString(value);
|
|
20
|
+
return ref && new RegExp('^' + prefix + '[A-F0-9]{24}$').test(ref) ? ref : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A bot identity is stable per (corpId, agentId) pair so re-binding the same
|
|
24
|
+
// application replaces its credentials in place instead of piling up bots.
|
|
25
|
+
export function deriveWecomAppIdentity({ corpId, agentId }) {
|
|
26
|
+
const id = cleanString(corpId);
|
|
27
|
+
const agent = cleanString(agentId);
|
|
28
|
+
if (!id || !agent) throw new TypeError('Enterprise WeChat corpId and agentId are required');
|
|
29
|
+
const digest = createHash('sha256').update(`${id}:${agent}`).digest('hex').slice(0, 24);
|
|
30
|
+
return {
|
|
31
|
+
botId: `wecomapp_${digest}`,
|
|
32
|
+
secretRef: `DSH_WECOM_APP_SECRET_${digest.toUpperCase()}`,
|
|
33
|
+
callbackTokenRef: `DSH_WECOM_APP_TOKEN_${digest.toUpperCase()}`,
|
|
34
|
+
callbackKeyRef: `DSH_WECOM_APP_AESKEY_${digest.toUpperCase()}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function generateCallbackSecret() {
|
|
39
|
+
return randomBytes(16).toString('hex');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function maskCorpId(corpId) {
|
|
43
|
+
const value = cleanString(corpId) ?? '';
|
|
44
|
+
if (!value) return t('企业微信应用');
|
|
45
|
+
if (value.length <= 10) return `${value.slice(0, 3)}•••`;
|
|
46
|
+
return `${value.slice(0, 6)}••••${value.slice(-4)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validCorpId(value) {
|
|
50
|
+
const id = cleanString(value);
|
|
51
|
+
return Boolean(id && /^[A-Za-z][A-Za-z0-9_-]{2,127}$/.test(id));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validAgentId(value) {
|
|
55
|
+
const agent = cleanString(value);
|
|
56
|
+
return Boolean(agent && /^\d{1,32}$/.test(agent) && Number(agent) > 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function validApiBaseUrl(value) {
|
|
60
|
+
const raw = cleanString(value);
|
|
61
|
+
return raw === null || /^https?:\/\//iu.test(raw);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeBot(value) {
|
|
65
|
+
if (!value || typeof value !== 'object') return null;
|
|
66
|
+
const botId = safeIntegrationId(value.botId);
|
|
67
|
+
const corpId = cleanString(value.corpId);
|
|
68
|
+
const agentId = cleanString(value.agentId);
|
|
69
|
+
const secretRef = safeRef(value.secretRef, 'DSH_WECOM_APP_SECRET_');
|
|
70
|
+
const callbackTokenRef = safeRef(value.callbackTokenRef, 'DSH_WECOM_APP_TOKEN_');
|
|
71
|
+
const callbackKeyRef = safeRef(value.callbackKeyRef, 'DSH_WECOM_APP_AESKEY_');
|
|
72
|
+
const callbackSecret = cleanString(value.callbackSecret);
|
|
73
|
+
if (!botId || !corpId || !agentId || !secretRef || !callbackTokenRef || !callbackKeyRef) return null;
|
|
74
|
+
if (!validCorpId(corpId) || !validAgentId(agentId)) return null;
|
|
75
|
+
if (!callbackSecret || !/^[a-f0-9]{32}$/.test(callbackSecret)) return null;
|
|
76
|
+
if (!validApiBaseUrl(value.apiBaseUrl ?? undefined)) return null;
|
|
77
|
+
if (!validApiBaseUrl(value.callbackBaseUrl ?? undefined)) return null;
|
|
78
|
+
const derived = deriveWecomAppIdentity({ corpId, agentId });
|
|
79
|
+
if (derived.botId !== botId || derived.secretRef !== secretRef
|
|
80
|
+
|| derived.callbackTokenRef !== callbackTokenRef || derived.callbackKeyRef !== callbackKeyRef) return null;
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
botId,
|
|
83
|
+
corpId,
|
|
84
|
+
agentId,
|
|
85
|
+
secretRef,
|
|
86
|
+
callbackTokenRef,
|
|
87
|
+
callbackKeyRef,
|
|
88
|
+
callbackSecret,
|
|
89
|
+
apiBaseUrl: cleanString(value.apiBaseUrl) ?? undefined,
|
|
90
|
+
callbackBaseUrl: cleanString(value.callbackBaseUrl) ?? undefined,
|
|
91
|
+
streamEnabled: value.streamEnabled !== false,
|
|
92
|
+
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
|
|
93
|
+
connectedAt: cleanString(value.connectedAt),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeDocument(value) {
|
|
98
|
+
if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
|
|
99
|
+
const bots = value.bots.map(normalizeBot);
|
|
100
|
+
if (bots.some((bot) => bot === null)) return null;
|
|
101
|
+
const ids = new Set();
|
|
102
|
+
const corpAgents = new Set();
|
|
103
|
+
const secrets = new Set();
|
|
104
|
+
const callbackSecrets = new Set();
|
|
105
|
+
for (const bot of bots) {
|
|
106
|
+
const corpAgent = `${bot.corpId}:${bot.agentId}`;
|
|
107
|
+
if (ids.has(bot.botId) || corpAgents.has(corpAgent)
|
|
108
|
+
|| secrets.has(bot.secretRef) || callbackSecrets.has(bot.callbackSecret)) return null;
|
|
109
|
+
ids.add(bot.botId);
|
|
110
|
+
corpAgents.add(corpAgent);
|
|
111
|
+
secrets.add(bot.secretRef);
|
|
112
|
+
callbackSecrets.add(bot.callbackSecret);
|
|
113
|
+
}
|
|
114
|
+
return Object.freeze({ version: 1, bots: Object.freeze(bots) });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class WecomAppConfigStore {
|
|
118
|
+
#path;
|
|
119
|
+
#value = EMPTY_DOCUMENT;
|
|
120
|
+
#writeQueue = Promise.resolve();
|
|
121
|
+
|
|
122
|
+
constructor(path) {
|
|
123
|
+
this.#path = path;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async load() {
|
|
127
|
+
try {
|
|
128
|
+
const normalized = normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
|
|
129
|
+
if (!normalized) throw new Error('dsh-im Enterprise WeChat app config contains invalid bot data');
|
|
130
|
+
this.#value = normalized;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
133
|
+
this.#value = EMPTY_DOCUMENT;
|
|
134
|
+
}
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
list() {
|
|
139
|
+
return structuredClone(this.#value.bots);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get(botId) {
|
|
143
|
+
const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
|
|
144
|
+
return bot ? structuredClone(bot) : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getByCorpAgent(corpId, agentId) {
|
|
148
|
+
const bot = this.#value.bots.find((candidate) => candidate.corpId === corpId && candidate.agentId === agentId);
|
|
149
|
+
return bot ? structuredClone(bot) : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async save(value) {
|
|
153
|
+
const normalized = normalizeBot(value);
|
|
154
|
+
if (!normalized) throw new Error('Refusing to persist incomplete Enterprise WeChat app bot data');
|
|
155
|
+
return this.#mutate((bots) => {
|
|
156
|
+
const corpAgent = `${normalized.corpId}:${normalized.agentId}`;
|
|
157
|
+
const collision = bots.find((bot) =>
|
|
158
|
+
(bot.botId === normalized.botId || `${bot.corpId}:${bot.agentId}` === corpAgent
|
|
159
|
+
|| bot.secretRef === normalized.secretRef || bot.callbackSecret === normalized.callbackSecret)
|
|
160
|
+
&& bot.botId !== normalized.botId);
|
|
161
|
+
if (collision) throw new Error('Duplicate Enterprise WeChat app bot identity');
|
|
162
|
+
const index = bots.findIndex((bot) => bot.botId === normalized.botId);
|
|
163
|
+
if (index === -1) bots.push(normalized);
|
|
164
|
+
else bots[index] = normalized;
|
|
165
|
+
return structuredClone(normalized);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async update(botId, patch = {}) {
|
|
170
|
+
const existing = this.get(botId);
|
|
171
|
+
if (!existing) throw new Error('Unknown Enterprise WeChat app bot');
|
|
172
|
+
const merged = { ...existing };
|
|
173
|
+
if (patch.apiBaseUrl !== undefined) {
|
|
174
|
+
merged.apiBaseUrl = cleanString(patch.apiBaseUrl) ?? null;
|
|
175
|
+
if (!validApiBaseUrl(merged.apiBaseUrl ?? undefined)) {
|
|
176
|
+
throw new Error('代理地址必须是 http(s) 地址');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (patch.callbackBaseUrl !== undefined) {
|
|
180
|
+
merged.callbackBaseUrl = cleanString(patch.callbackBaseUrl) ?? null;
|
|
181
|
+
if (!validApiBaseUrl(merged.callbackBaseUrl ?? undefined)) {
|
|
182
|
+
throw new Error('回调基址必须是 http(s) 地址');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (patch.streamEnabled !== undefined) merged.streamEnabled = patch.streamEnabled === true;
|
|
186
|
+
if (patch.callbackSecret !== undefined) {
|
|
187
|
+
const secret = cleanString(patch.callbackSecret);
|
|
188
|
+
if (!secret || !/^[a-f0-9]{32}$/.test(secret)) throw new Error('回调密钥格式不正确');
|
|
189
|
+
merged.callbackSecret = secret;
|
|
190
|
+
}
|
|
191
|
+
return this.save(merged);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async remove(botId) {
|
|
195
|
+
if (!safeIntegrationId(botId)) throw new TypeError('Invalid Enterprise WeChat app bot ID');
|
|
196
|
+
return this.#mutate((bots) => {
|
|
197
|
+
const index = bots.findIndex((candidate) => candidate.botId === botId);
|
|
198
|
+
if (index === -1) return null;
|
|
199
|
+
const [removed] = bots.splice(index, 1);
|
|
200
|
+
return structuredClone(removed);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async clear() {
|
|
205
|
+
const operation = this.#writeQueue.then(async () => {
|
|
206
|
+
try {
|
|
207
|
+
await unlink(this.#path);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
210
|
+
}
|
|
211
|
+
this.#value = EMPTY_DOCUMENT;
|
|
212
|
+
});
|
|
213
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
214
|
+
await operation;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #mutate(mutator) {
|
|
218
|
+
let result;
|
|
219
|
+
const operation = this.#writeQueue.then(async () => {
|
|
220
|
+
const bots = [...this.#value.bots];
|
|
221
|
+
result = mutator(bots);
|
|
222
|
+
const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
|
|
223
|
+
await this.#write(document);
|
|
224
|
+
this.#value = document;
|
|
225
|
+
});
|
|
226
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
227
|
+
await operation;
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async #write(document) {
|
|
232
|
+
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
|
233
|
+
const temporary = `${this.#path}.tmp`;
|
|
234
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
|
235
|
+
encoding: 'utf8',
|
|
236
|
+
mode: 0o600,
|
|
237
|
+
});
|
|
238
|
+
await rename(temporary, this.#path);
|
|
239
|
+
}
|
|
240
|
+
}
|