@xmanrui/dsh-im 4.15.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.
Files changed (62) hide show
  1. package/README.en.md +2 -1
  2. package/README.md +9 -5
  3. package/lib/client.js +1729 -614
  4. package/lib/index.js +303 -293
  5. package/package.json +6 -2
  6. package/plugin-src/client/access-policy-settings.js +6 -0
  7. package/plugin-src/client/channels/feishu/index.js +15 -3
  8. package/plugin-src/client/channels/wecom-app/api.js +124 -0
  9. package/plugin-src/client/channels/wecom-app/index.js +612 -0
  10. package/plugin-src/client/channels/wecom-app/styles.js +25 -0
  11. package/plugin-src/client/delivery-settings.js +7 -0
  12. package/plugin-src/client/i18n.js +63 -0
  13. package/plugin-src/client/index.js +18 -0
  14. package/plugin-src/client/model-setting.js +135 -69
  15. package/plugin-src/client/styles.js +22 -7
  16. package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
  17. package/plugin-src/host/channels/discord/index.mjs +8 -10
  18. package/plugin-src/host/channels/feishu/index.mjs +8 -15
  19. package/plugin-src/host/channels/office/index.mjs +9 -5
  20. package/plugin-src/host/channels/qq/index.mjs +8 -15
  21. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  22. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
  23. package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
  24. package/plugin-src/host/channels/shared/startup.mjs +47 -0
  25. package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
  26. package/plugin-src/host/channels/slack/index.mjs +8 -10
  27. package/plugin-src/host/channels/telegram/index.mjs +8 -10
  28. package/plugin-src/host/channels/wecom/index.mjs +8 -15
  29. package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
  30. package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
  31. package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
  32. package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
  33. package/plugin-src/host/channels/weixin/index.mjs +8 -15
  34. package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
  35. package/plugin-src/host/delivery-adapter.mjs +4 -0
  36. package/plugin-src/host/delivery-suggestions.mjs +4 -0
  37. package/plugin-src/host/index.mjs +7 -2
  38. package/scripts/verify-model-setting.mjs +54 -0
  39. package/src/channels/feishu/bridge.mjs +295 -9
  40. package/src/channels/feishu/feishu-cards.mjs +38 -0
  41. package/src/channels/feishu/feishu-channel.mjs +88 -20
  42. package/src/channels/shared/bot-workspace-store.mjs +11 -3
  43. package/src/channels/shared/command-catalog.mjs +102 -0
  44. package/src/channels/shared/i18n-en/feishu.mjs +12 -0
  45. package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
  46. package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
  47. package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
  48. package/src/channels/shared/i18n-en.mjs +2 -0
  49. package/src/channels/shared/model-setting.mjs +43 -8
  50. package/src/channels/shared/text-harness-bridge.mjs +2 -26
  51. package/src/channels/telegram/telegram-api.mjs +18 -6
  52. package/src/channels/telegram/telegram-runtime.mjs +34 -32
  53. package/src/channels/wecom/send-error.mjs +31 -0
  54. package/src/channels/wecom/wecom-bridge.mjs +59 -17
  55. package/src/channels/wecom-app/callback-server.mjs +471 -0
  56. package/src/channels/wecom-app/config-store.mjs +240 -0
  57. package/src/channels/wecom-app/harness-client.mjs +11 -0
  58. package/src/channels/wecom-app/state-store.mjs +107 -0
  59. package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
  60. package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
  61. package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
  62. package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
@@ -1,6 +1,7 @@
1
1
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
2
2
  import { generateReqId } from '@wecom/aibot-node-sdk';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { wecomSendDiagnostic, wecomSendError } from './send-error.mjs';
4
5
  import {
5
6
  parseWecomMenu, wecomList, wecomMenu, wecomMenuText, wecomSettings, wecomTemplateCard,
6
7
  } from './wecom-cards.mjs';
@@ -54,7 +55,6 @@ import {
54
55
  createDeliveryReceipt,
55
56
  } from '../shared/semantic/delivery.mjs';
56
57
  import {
57
- channelDeliveryFailure,
58
58
  clearLastMessageFailure,
59
59
  messageFailureText,
60
60
  setLastMessageFailure,
@@ -616,6 +616,7 @@ export class WecomHarnessBridge {
616
616
  }
617
617
 
618
618
  async #showMain(frame, { welcome = false } = {}) {
619
+ const previousFailure = this.#status.lastMessageError;
619
620
  const key = conversationKey(frame);
620
621
  const workspace = this.#harness.currentWorkspace?.();
621
622
  const sessionId = this.#state.sessionFor(key);
@@ -650,10 +651,21 @@ export class WecomHarnessBridge {
650
651
  presetLabel: settings?.agentPresetCatalog?.items.find((item) => item.id === currentPreset)?.label,
651
652
  });
652
653
  await this.#sendMenu(frame, settingsMenu, { active: welcome });
653
- if (welcome) return;
654
- await this.#sendMenu(frame, wecomMenu({ workspace,
655
- workspaces: (paths?.paths ?? (workspace ? [workspace] : [])).map((path) => [path, `/workspace ${path}`]),
656
- }), { active: true });
654
+ if (!welcome) {
655
+ await this.#sendMenu(frame, wecomMenu({ workspace,
656
+ workspaces: (paths?.paths ?? (workspace ? [workspace] : [])).map((path) => [path, `/workspace ${path}`]),
657
+ }), { active: true });
658
+ }
659
+ this.#clearMenuFailure(previousFailure);
660
+ }
661
+
662
+ #clearMenuFailure(previousFailure) {
663
+ // Do not clear a model failure or one recorded by a concurrent request.
664
+ if (previousFailure?.reason === 'WECOM_MENU_DELIVERY'
665
+ && this.#status.lastMessageError === previousFailure) {
666
+ clearLastMessageFailure(this.#status);
667
+ this.#status.lastError = null;
668
+ }
657
669
  }
658
670
 
659
671
  #rememberMenu(frame, menu) {
@@ -675,6 +687,8 @@ export class WecomHarnessBridge {
675
687
  const body = bodyOf(frame);
676
688
  const chatId = body.chattype === 'group' ? body.chatid : body.from.userid;
677
689
  const card = this.#rememberMenu(frame, menu);
690
+ const operation = welcome ? 'replyWelcome'
691
+ : active || this.#cardFrames.has(frame) ? 'sendMessage' : 'replyTemplateCard';
678
692
  try {
679
693
  if (welcome) {
680
694
  await this.#client.replyWelcome(frame, { msgtype: 'template_card', template_card: card });
@@ -683,11 +697,20 @@ export class WecomHarnessBridge {
683
697
  } else {
684
698
  await this.#client.replyTemplateCard(frame, card);
685
699
  }
686
- } catch (error) {
687
- this.#menus.delete(card.task_id);
688
- this.#logger.warn?.('[dsh-im:wecom] menu delivery failed; using text:', error);
700
+ } catch (cause) {
701
+ const error = wecomSendError(cause, operation);
702
+ // A timed-out card may already be visible and must remain usable.
703
+ if (error.code !== 'channel-delivery-uncertain') this.#menus.delete(card.task_id);
704
+ // Only a definite card rejection can safely fall back to text.
705
+ if (error.code !== 'channel-delivery-failed' || error.providerCode === undefined) throw error;
706
+ this.#logger.warn?.('[dsh-im:wecom] menu delivery failed; using text:', wecomSendDiagnostic(error));
689
707
  if (welcome) {
690
- await this.#client.replyWelcome(frame, { msgtype: 'text', text: { content: wecomMenuText(menu) } });
708
+ const content = wecomMenuText(menu);
709
+ try {
710
+ await this.#client.replyWelcome(frame, { msgtype: 'text', text: { content } });
711
+ } catch (cause) {
712
+ throw wecomSendError(cause, 'replyWelcome');
713
+ }
691
714
  } else {
692
715
  await this.#sendImmediate(frame, chatId, wecomMenuText(menu));
693
716
  }
@@ -705,7 +728,10 @@ export class WecomHarnessBridge {
705
728
  task = this.#processEvent(frame).catch((error) => {
706
729
  if (this.#signal?.aborted) return;
707
730
  this.#status.lastError = error?.message ?? String(error);
708
- this.#logger.warn?.('[dsh-im:wecom] menu event failed:', error);
731
+ const failure = setLastMessageFailure(this.#status, error, {
732
+ reason: error?.wecomOperation ? 'wecom-menu-delivery' : undefined,
733
+ });
734
+ this.#logger.warn?.(`[dsh-im:wecom] menu event failed [${failure.referenceId}]`, wecomSendDiagnostic(error));
709
735
  }).finally(() => {
710
736
  this.#eventIds.delete(id);
711
737
  this.#commandTasks.delete(task);
@@ -802,6 +828,7 @@ export class WecomHarnessBridge {
802
828
  const menu = parseWecomMenu(text);
803
829
  const options = { signal: this.#signal };
804
830
  if (menu) {
831
+ const previousFailure = this.#status.lastMessageError;
805
832
  let content;
806
833
  if (menu.section === 'main') {
807
834
  await this.#showMain(frame);
@@ -846,6 +873,7 @@ export class WecomHarnessBridge {
846
873
  content = wecomList({ title, entries, description, ...menu });
847
874
  }
848
875
  await this.#sendMenu(frame, content);
876
+ this.#clearMenuFailure(previousFailure);
849
877
  return { messages: [] };
850
878
  }
851
879
  const command = text.toLowerCase();
@@ -960,10 +988,15 @@ export class WecomHarnessBridge {
960
988
  ).catch((error) => {
961
989
  if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
962
990
  this.#status.lastError = error?.message ?? String(error);
963
- const failure = setLastMessageFailure(this.#status, error);
991
+ const failure = setLastMessageFailure(this.#status, error, {
992
+ reason: parseWecomMenu(commandText) && error?.wecomOperation ? 'wecom-menu-delivery' : undefined,
993
+ });
964
994
  this.#logger.error?.(
965
995
  `[dsh-im:wecom] failed to process a command [${failure.referenceId}]`,
996
+ wecomSendDiagnostic(error),
966
997
  );
998
+ if (this.#cardFrames.has(frame) && error?.wecomOperation
999
+ && error.code !== 'channel-delivery-failed') return;
967
1000
  return this.#sendImmediate(frame, chatId, messageFailureText(failure))
968
1001
  .catch(() => undefined);
969
1002
  }).finally(() => {
@@ -1149,6 +1182,7 @@ export class WecomHarnessBridge {
1149
1182
  async #processFastCommand(frame, messageId, chatId, key, message, runner) {
1150
1183
  this.#signal?.throwIfAborted();
1151
1184
  if (this.#state.hasSeen(messageId)) return;
1185
+ const previousFailure = this.#status.lastMessageError;
1152
1186
  await this.#state.markSeen(messageId);
1153
1187
  this.#status.messagesReceived += 1;
1154
1188
  this.#status.lastMessageAt = new Date().toISOString();
@@ -1171,17 +1205,24 @@ export class WecomHarnessBridge {
1171
1205
  for (const reply of result?.messages ?? [result?.message]) {
1172
1206
  if (reply) await this.#sendImmediate(frame, chatId, reply);
1173
1207
  }
1174
- this.#status.lastError = null;
1208
+ if (!this.#status.lastMessageError || this.#status.lastMessageError === previousFailure) {
1209
+ this.#status.lastError = null;
1210
+ }
1175
1211
  }
1176
1212
 
1177
1213
  async #sendActive(chatId, text) {
1178
1214
  const providerMessageIds = [];
1179
1215
  for (const chunk of splitUtf8(text)) {
1180
1216
  this.#signal?.throwIfAborted();
1181
- const result = await this.#client.sendMessage(
1182
- chatId,
1183
- { msgtype: 'markdown', markdown: { content: chunk } },
1184
- );
1217
+ let result;
1218
+ try {
1219
+ result = await this.#client.sendMessage(
1220
+ chatId,
1221
+ { msgtype: 'markdown', markdown: { content: chunk } },
1222
+ );
1223
+ } catch (error) {
1224
+ throw wecomSendError(error, 'sendMessage');
1225
+ }
1185
1226
  const messageId = providerMessageId(result);
1186
1227
  if (messageId) providerMessageIds.push(messageId);
1187
1228
  }
@@ -1434,7 +1475,7 @@ export class WecomHarnessBridge {
1434
1475
  });
1435
1476
  }
1436
1477
  } catch (error) {
1437
- textSendError = channelDeliveryFailure(error);
1478
+ textSendError = wecomSendError(error, 'sendMessage');
1438
1479
  this.#logger.warn?.(
1439
1480
  '[dsh-im:wecom] final text delivery failed; continuing with result files:',
1440
1481
  error,
@@ -1491,6 +1532,7 @@ export class WecomHarnessBridge {
1491
1532
  });
1492
1533
  this.#logger.error?.(
1493
1534
  `[dsh-im:wecom] failed to process an inbound message [${failure.referenceId}]`,
1535
+ wecomSendDiagnostic(error),
1494
1536
  );
1495
1537
  const errorText = messageFailureText(failure);
1496
1538
  const visibleError = batchFailureMessage
@@ -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
+ }