@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.
- package/README.en.md +2 -1
- package/README.md +9 -5
- package/lib/client.js +1729 -614
- 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 +15 -3
- 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 +63 -0
- package/plugin-src/client/index.js +18 -0
- package/plugin-src/client/model-setting.js +135 -69
- 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 +7 -2
- package/scripts/verify-model-setting.mjs +54 -0
- package/src/channels/feishu/bridge.mjs +295 -9
- package/src/channels/feishu/feishu-cards.mjs +38 -0
- package/src/channels/feishu/feishu-channel.mjs +88 -20
- 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 +12 -0
- 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/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
|
@@ -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)
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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 (
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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 =
|
|
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
|
+
}
|