@wecom/wecom-openclaw-plugin 2026.4.2 → 2026.4.3-beta.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.md +395 -10
- package/dist/index.d.ts +2 -4
- package/dist/index.js +58 -0
- package/dist/src/accounts.d.ts +57 -0
- package/dist/src/accounts.js +222 -0
- package/dist/src/agent/api-client.d.ts +94 -0
- package/dist/src/agent/api-client.js +292 -0
- package/dist/src/agent/handler.d.ts +63 -0
- package/dist/src/agent/handler.js +551 -0
- package/dist/src/agent/index.d.ts +5 -0
- package/dist/src/agent/index.js +5 -0
- package/dist/src/agent/webhook.d.ts +24 -0
- package/dist/src/agent/webhook.js +228 -0
- package/dist/src/agent/xml.d.ts +21 -0
- package/dist/src/agent/xml.js +41 -0
- package/dist/src/channel.js +579 -0
- package/dist/src/chat-queue.d.ts +31 -0
- package/dist/src/chat-queue.js +45 -0
- package/dist/{esm/types/src → src}/const.d.ts +46 -0
- package/dist/{esm/src → src}/const.js +77 -30
- package/dist/{esm/src → src}/dm-policy.js +6 -9
- package/dist/src/dynamic-agent.d.ts +37 -0
- package/dist/src/dynamic-agent.js +60 -0
- package/dist/src/dynamic-routing.d.ts +65 -0
- package/dist/src/dynamic-routing.js +58 -0
- package/dist/{esm/src → src}/group-policy.js +8 -22
- package/dist/src/http.d.ts +26 -0
- package/dist/src/http.js +93 -0
- package/dist/{esm/types/src → src}/interface.d.ts +0 -2
- package/dist/src/interface.js +4 -0
- package/dist/src/mcp/index.js +6 -0
- package/dist/{esm/src → src}/mcp/interceptors/biz-error.js +2 -6
- package/dist/{esm/src → src}/mcp/interceptors/index.js +6 -10
- package/dist/{esm/src → src}/mcp/interceptors/msg-media.js +6 -10
- package/dist/{esm/src → src}/mcp/interceptors/smartpage-create.js +2 -6
- package/dist/{esm/src → src}/mcp/interceptors/smartpage-export.js +2 -6
- package/dist/src/mcp/interceptors/types.js +7 -0
- package/dist/{esm/src → src}/mcp/schema.js +1 -4
- package/dist/{esm/src → src}/mcp/tool.js +4 -8
- package/dist/{esm/src → src}/mcp/transport.js +15 -16
- package/dist/{esm/src → src}/media-handler.js +6 -10
- package/dist/{esm/src → src}/media-uploader.js +64 -10
- package/dist/{esm/src → src}/message-parser.js +1 -4
- package/dist/{esm/types/src → src}/message-sender.d.ts +18 -0
- package/dist/{esm/src → src}/message-sender.js +45 -11
- package/dist/{esm/src → src}/monitor.js +145 -292
- package/dist/{esm/src → src}/onboarding.js +22 -26
- package/dist/{esm/types/src → src}/openclaw-compat.d.ts +49 -0
- package/dist/{esm/src → src}/openclaw-compat.js +133 -27
- package/dist/{esm/src → src}/reqid-store.js +4 -7
- package/dist/{esm/src → src}/runtime.js +2 -5
- package/dist/src/shared/command-auth.d.ts +23 -0
- package/dist/src/shared/command-auth.js +73 -0
- package/dist/src/shared/xml-parser.d.ts +46 -0
- package/dist/src/shared/xml-parser.js +210 -0
- package/dist/{esm/src → src}/state-dir-resolve.js +3 -7
- package/dist/{esm/src → src}/state-manager.js +84 -16
- package/dist/src/target.d.ts +35 -0
- package/dist/src/target.js +67 -0
- package/dist/src/template-card-manager.d.ts +55 -0
- package/dist/src/template-card-manager.js +205 -0
- package/dist/{esm/src → src}/template-card-parser.js +17 -8
- package/dist/{esm/src → src}/timeout.js +2 -5
- package/dist/src/types/account.d.ts +29 -0
- package/dist/src/types/account.js +4 -0
- package/dist/src/types/config.d.ts +91 -0
- package/dist/src/types/config.js +7 -0
- package/dist/src/types/constants.d.ts +42 -0
- package/dist/src/types/constants.js +42 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.js +5 -0
- package/dist/src/types/message.d.ts +238 -0
- package/dist/src/types/message.js +5 -0
- package/dist/{esm/types/src → src}/utils.d.ts +41 -0
- package/dist/{esm/src → src}/utils.js +24 -10
- package/dist/src/version.d.ts +2 -0
- package/dist/src/version.js +24 -0
- package/dist/src/webhook/command-auth.d.ts +47 -0
- package/dist/src/webhook/command-auth.js +96 -0
- package/dist/src/webhook/gateway.d.ts +36 -0
- package/dist/src/webhook/gateway.js +235 -0
- package/dist/src/webhook/handler.d.ts +18 -0
- package/dist/src/webhook/handler.js +420 -0
- package/dist/src/webhook/helpers.d.ts +156 -0
- package/dist/src/webhook/helpers.js +833 -0
- package/dist/src/webhook/http.d.ts +26 -0
- package/dist/src/webhook/http.js +94 -0
- package/dist/src/webhook/index.d.ts +11 -0
- package/dist/src/webhook/index.js +14 -0
- package/dist/src/webhook/media.d.ts +29 -0
- package/dist/src/webhook/media.js +88 -0
- package/dist/src/webhook/monitor.d.ts +59 -0
- package/dist/src/webhook/monitor.js +1161 -0
- package/dist/src/webhook/state.d.ts +220 -0
- package/dist/src/webhook/state.js +491 -0
- package/dist/src/webhook/target.d.ts +41 -0
- package/dist/src/webhook/target.js +132 -0
- package/dist/src/webhook/types.d.ts +347 -0
- package/dist/src/webhook/types.js +33 -0
- package/dist/src/webhook/video-frame.d.ts +13 -0
- package/dist/src/webhook/video-frame.js +30 -0
- package/package.json +15 -7
- package/dist/cjs/index.js +0 -45
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/src/channel.js +0 -339
- package/dist/cjs/src/channel.js.map +0 -1
- package/dist/cjs/src/const.js +0 -137
- package/dist/cjs/src/const.js.map +0 -1
- package/dist/cjs/src/dm-policy.js +0 -89
- package/dist/cjs/src/dm-policy.js.map +0 -1
- package/dist/cjs/src/group-policy.js +0 -133
- package/dist/cjs/src/group-policy.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/biz-error.js +0 -70
- package/dist/cjs/src/mcp/interceptors/biz-error.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/index.js +0 -73
- package/dist/cjs/src/mcp/interceptors/index.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/msg-media.js +0 -159
- package/dist/cjs/src/mcp/interceptors/msg-media.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/smartpage-create.js +0 -157
- package/dist/cjs/src/mcp/interceptors/smartpage-create.js.map +0 -1
- package/dist/cjs/src/mcp/interceptors/smartpage-export.js +0 -93
- package/dist/cjs/src/mcp/interceptors/smartpage-export.js.map +0 -1
- package/dist/cjs/src/mcp/schema.js +0 -104
- package/dist/cjs/src/mcp/schema.js.map +0 -1
- package/dist/cjs/src/mcp/tool.js +0 -208
- package/dist/cjs/src/mcp/tool.js.map +0 -1
- package/dist/cjs/src/mcp/transport.js +0 -417
- package/dist/cjs/src/mcp/transport.js.map +0 -1
- package/dist/cjs/src/media-handler.js +0 -129
- package/dist/cjs/src/media-handler.js.map +0 -1
- package/dist/cjs/src/media-uploader.js +0 -284
- package/dist/cjs/src/media-uploader.js.map +0 -1
- package/dist/cjs/src/message-parser.js +0 -134
- package/dist/cjs/src/message-parser.js.map +0 -1
- package/dist/cjs/src/message-sender.js +0 -89
- package/dist/cjs/src/message-sender.js.map +0 -1
- package/dist/cjs/src/monitor.js +0 -991
- package/dist/cjs/src/monitor.js.map +0 -1
- package/dist/cjs/src/onboarding.js +0 -182
- package/dist/cjs/src/onboarding.js.map +0 -1
- package/dist/cjs/src/openclaw-compat.js +0 -400
- package/dist/cjs/src/openclaw-compat.js.map +0 -1
- package/dist/cjs/src/reqid-store.js +0 -88
- package/dist/cjs/src/reqid-store.js.map +0 -1
- package/dist/cjs/src/runtime.js +0 -16
- package/dist/cjs/src/runtime.js.map +0 -1
- package/dist/cjs/src/state-dir-resolve.js +0 -35
- package/dist/cjs/src/state-dir-resolve.js.map +0 -1
- package/dist/cjs/src/state-manager.js +0 -158
- package/dist/cjs/src/state-manager.js.map +0 -1
- package/dist/cjs/src/template-card-parser.js +0 -641
- package/dist/cjs/src/template-card-parser.js.map +0 -1
- package/dist/cjs/src/timeout.js +0 -42
- package/dist/cjs/src/timeout.js.map +0 -1
- package/dist/cjs/src/utils.js +0 -60
- package/dist/cjs/src/utils.js.map +0 -1
- package/dist/cjs/src/version.js +0 -26
- package/dist/cjs/src/version.js.map +0 -1
- package/dist/esm/index.js +0 -41
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/src/channel.js +0 -337
- package/dist/esm/src/channel.js.map +0 -1
- package/dist/esm/src/const.js.map +0 -1
- package/dist/esm/src/dm-policy.js.map +0 -1
- package/dist/esm/src/group-policy.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/biz-error.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/index.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/msg-media.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/smartpage-create.js.map +0 -1
- package/dist/esm/src/mcp/interceptors/smartpage-export.js.map +0 -1
- package/dist/esm/src/mcp/schema.js.map +0 -1
- package/dist/esm/src/mcp/tool.js.map +0 -1
- package/dist/esm/src/mcp/transport.js.map +0 -1
- package/dist/esm/src/media-handler.js.map +0 -1
- package/dist/esm/src/media-uploader.js.map +0 -1
- package/dist/esm/src/message-parser.js.map +0 -1
- package/dist/esm/src/message-sender.js.map +0 -1
- package/dist/esm/src/monitor.js.map +0 -1
- package/dist/esm/src/onboarding.js.map +0 -1
- package/dist/esm/src/openclaw-compat.js.map +0 -1
- package/dist/esm/src/reqid-store.js.map +0 -1
- package/dist/esm/src/runtime.js.map +0 -1
- package/dist/esm/src/state-dir-resolve.js.map +0 -1
- package/dist/esm/src/state-manager.js.map +0 -1
- package/dist/esm/src/template-card-parser.js.map +0 -1
- package/dist/esm/src/timeout.js.map +0 -1
- package/dist/esm/src/utils.js.map +0 -1
- package/dist/esm/src/version.js +0 -23
- package/dist/esm/src/version.js.map +0 -1
- package/dist/esm/types/index.d.ts +0 -9
- package/dist/esm/types/src/version.d.ts +0 -2
- /package/dist/{esm/types/src → src}/channel.d.ts +0 -0
- /package/dist/{esm/types/src → src}/dm-policy.d.ts +0 -0
- /package/dist/{esm/types/src → src}/group-policy.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/index.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/biz-error.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/index.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/msg-media.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/smartpage-create.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/smartpage-export.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/interceptors/types.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/schema.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/tool.d.ts +0 -0
- /package/dist/{esm/types/src → src}/mcp/transport.d.ts +0 -0
- /package/dist/{esm/types/src → src}/media-handler.d.ts +0 -0
- /package/dist/{esm/types/src → src}/media-uploader.d.ts +0 -0
- /package/dist/{esm/types/src → src}/message-parser.d.ts +0 -0
- /package/dist/{esm/types/src → src}/monitor.d.ts +0 -0
- /package/dist/{esm/types/src → src}/onboarding.d.ts +0 -0
- /package/dist/{esm/types/src → src}/reqid-store.d.ts +0 -0
- /package/dist/{esm/types/src → src}/runtime.d.ts +0 -0
- /package/dist/{esm/types/src → src}/state-dir-resolve.d.ts +0 -0
- /package/dist/{esm/types/src → src}/state-manager.d.ts +0 -0
- /package/dist/{esm/types/src → src}/template-card-parser.d.ts +0 -0
- /package/dist/{esm/types/src → src}/timeout.d.ts +0 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Gateway 生命周期管理
|
|
3
|
+
*
|
|
4
|
+
* 从 @mocrane/wecom gateway-monitor.ts 部分迁移(仅 Webhook 部分)。
|
|
5
|
+
* 负责:初始化状态、注册 Target、启停管理。
|
|
6
|
+
*
|
|
7
|
+
* 关键设计:
|
|
8
|
+
* - MonitorState 是全局单例(monitorState),所有账号共享同一个 StreamStore 和 ActiveReplyStore
|
|
9
|
+
* - Target 注册/注销不影响 monitorState 生命周期,只控制 pruneTimer 的启停
|
|
10
|
+
* - 每个账号注册多条路径(兼容历史路径 + 推荐路径 + 多账号路径)
|
|
11
|
+
* - 按 accountId 管理各自的 unregister,stop 时只注销该账号的 Target
|
|
12
|
+
*/
|
|
13
|
+
import { PRUNE_INTERVAL_MS, WEBHOOK_PATHS } from "./types.js";
|
|
14
|
+
import { monitorState } from "./state.js";
|
|
15
|
+
import { registerWecomWebhookTarget, hasActiveTargets } from "./target.js";
|
|
16
|
+
import { startAgentForStream } from "./monitor.js";
|
|
17
|
+
import { hasMultiAccounts } from "../accounts.js";
|
|
18
|
+
import { DEFAULT_ACCOUNT_ID } from "../openclaw-compat.js";
|
|
19
|
+
import { getWeComRuntime } from "../runtime.js";
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// 全局状态
|
|
22
|
+
// ============================================================================
|
|
23
|
+
/** 按 accountId 管理各账号的 Target 注销函数 */
|
|
24
|
+
const accountUnregisters = new Map();
|
|
25
|
+
/** FlushHandler 是否已设置(只需设置一次,因为 monitorState 是单例) */
|
|
26
|
+
let flushHandlerInstalled = false;
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// 路径解析
|
|
29
|
+
// ============================================================================
|
|
30
|
+
/**
|
|
31
|
+
* 去除重复路径
|
|
32
|
+
*/
|
|
33
|
+
function uniquePaths(paths) {
|
|
34
|
+
return Array.from(new Set(paths.map((p) => p.trim()).filter(Boolean)));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 解析 Bot Target 注册路径
|
|
38
|
+
*
|
|
39
|
+
* 多账号场景(matrixMode)下:注册带 accountId 后缀的路径 + 兼容老路径
|
|
40
|
+
* 单账号场景下:只注册基础路径
|
|
41
|
+
*
|
|
42
|
+
* 参考 lh 版 resolveBotRegistrationPaths
|
|
43
|
+
*/
|
|
44
|
+
function resolveBotRegistrationPaths(params) {
|
|
45
|
+
if (params.matrixMode) {
|
|
46
|
+
return uniquePaths([
|
|
47
|
+
`${WEBHOOK_PATHS.BOT_PLUGIN}/${params.accountId}`,
|
|
48
|
+
`${WEBHOOK_PATHS.BOT_ALT}/${params.accountId}`,
|
|
49
|
+
// 兼容老路径:不带 accountId 后缀,签名验证会自动匹配到正确账号
|
|
50
|
+
WEBHOOK_PATHS.BOT_PLUGIN,
|
|
51
|
+
WEBHOOK_PATHS.BOT,
|
|
52
|
+
WEBHOOK_PATHS.BOT_ALT,
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
// 单账号模式:同时注册 /default 路径以支持显式指定
|
|
56
|
+
return uniquePaths([
|
|
57
|
+
WEBHOOK_PATHS.BOT_PLUGIN,
|
|
58
|
+
WEBHOOK_PATHS.BOT,
|
|
59
|
+
WEBHOOK_PATHS.BOT_ALT,
|
|
60
|
+
`${WEBHOOK_PATHS.BOT_PLUGIN}/${DEFAULT_ACCOUNT_ID}`,
|
|
61
|
+
`${WEBHOOK_PATHS.BOT_ALT}/${DEFAULT_ACCOUNT_ID}`,
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// 公共 API
|
|
66
|
+
// ============================================================================
|
|
67
|
+
/**
|
|
68
|
+
* 获取当前的 MonitorState 实例(全局单例)
|
|
69
|
+
*
|
|
70
|
+
* 供 monitor.ts 等内部模块调用以访问 StreamStore 和 ActiveReplyStore。
|
|
71
|
+
*/
|
|
72
|
+
export function getMonitorState() {
|
|
73
|
+
return monitorState;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 启动 Webhook Gateway
|
|
77
|
+
*
|
|
78
|
+
* 1. 验证 Webhook 配置
|
|
79
|
+
* 2. 确保 pruneTimer 启动
|
|
80
|
+
* 3. 设置 FlushHandler(仅首次)
|
|
81
|
+
* 4. 解析并注册多条 Webhook 路径
|
|
82
|
+
*/
|
|
83
|
+
export function startWebhookGateway(ctx) {
|
|
84
|
+
const { account, config, runtime } = ctx;
|
|
85
|
+
const log = ctx.log ?? {
|
|
86
|
+
info: (msg) => runtime.log(msg),
|
|
87
|
+
error: (msg) => runtime.error(msg),
|
|
88
|
+
};
|
|
89
|
+
// 1. 验证必要配置(receiveId 非必填,可为空)
|
|
90
|
+
if (!account.token || !account.encodingAESKey) {
|
|
91
|
+
const missing = [];
|
|
92
|
+
if (!account.token)
|
|
93
|
+
missing.push("token");
|
|
94
|
+
if (!account.encodingAESKey)
|
|
95
|
+
missing.push("encodingAESKey");
|
|
96
|
+
const errorMsg = `[webhook] Webhook 配置不完整,缺少: ${missing.join(", ")}`;
|
|
97
|
+
log.error(errorMsg);
|
|
98
|
+
ctx.setStatus?.({
|
|
99
|
+
accountId: ctx.accountId,
|
|
100
|
+
running: false,
|
|
101
|
+
configured: false,
|
|
102
|
+
lastError: errorMsg,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
log.info(`[webhook] 启动 Webhook Gateway (accountId=${ctx.accountId})`);
|
|
107
|
+
// 2. 确保 pruneTimer 启动(幂等:如果已在运行,不会重复启动)
|
|
108
|
+
monitorState.startPruning(PRUNE_INTERVAL_MS);
|
|
109
|
+
// 3. 设置 FlushHandler(仅首次,因为 monitorState 是全局单例)
|
|
110
|
+
if (!flushHandlerInstalled) {
|
|
111
|
+
monitorState.streamStore.setFlushHandler((pending) => void flushPending(pending));
|
|
112
|
+
flushHandlerInstalled = true;
|
|
113
|
+
}
|
|
114
|
+
// 4. 构造 Target 上下文
|
|
115
|
+
const runtimeEnv = {
|
|
116
|
+
log: (msg) => runtime.log(msg),
|
|
117
|
+
error: (msg) => runtime.error(msg),
|
|
118
|
+
};
|
|
119
|
+
// 判断是否为多账号模式
|
|
120
|
+
const matrixMode = hasMultiAccounts(ctx.config);
|
|
121
|
+
const target = {
|
|
122
|
+
account,
|
|
123
|
+
config,
|
|
124
|
+
runtime: runtimeEnv,
|
|
125
|
+
core: (ctx.channelRuntime ?? runtime), // PluginRuntime 实例
|
|
126
|
+
path: `${WEBHOOK_PATHS.BOT_PLUGIN}/${ctx.accountId}`, // 主路径(用于日志和状态显示)
|
|
127
|
+
statusSink: ctx.setStatus
|
|
128
|
+
? (patch) => ctx.setStatus?.({ accountId: ctx.accountId, ...patch })
|
|
129
|
+
: undefined,
|
|
130
|
+
};
|
|
131
|
+
// 5. 解析注册路径
|
|
132
|
+
const paths = resolveBotRegistrationPaths({
|
|
133
|
+
accountId: ctx.accountId,
|
|
134
|
+
matrixMode,
|
|
135
|
+
});
|
|
136
|
+
// 6. 注册 Target(返回注销函数)
|
|
137
|
+
// 如果该账号之前已注册(例如 reload),先注销
|
|
138
|
+
const existingUnregister = accountUnregisters.get(ctx.accountId);
|
|
139
|
+
if (existingUnregister) {
|
|
140
|
+
existingUnregister();
|
|
141
|
+
}
|
|
142
|
+
const unregister = registerWecomWebhookTarget(target, paths);
|
|
143
|
+
accountUnregisters.set(ctx.accountId, unregister);
|
|
144
|
+
log.info(`[webhook] Webhook Target 已注册 (accountId=${ctx.accountId}, paths=[${paths.join(", ")}])`);
|
|
145
|
+
// 7. 更新状态
|
|
146
|
+
ctx.setStatus?.({
|
|
147
|
+
accountId: ctx.accountId,
|
|
148
|
+
running: true,
|
|
149
|
+
configured: true,
|
|
150
|
+
webhookPath: paths[0],
|
|
151
|
+
lastStartAt: Date.now(),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 停止 Webhook Gateway
|
|
156
|
+
*
|
|
157
|
+
* 1. 注销该账号的 Target(不影响其他账号)
|
|
158
|
+
* 2. 如果没有任何活跃 Target,停止清理定时器
|
|
159
|
+
*/
|
|
160
|
+
export function stopWebhookGateway(ctx) {
|
|
161
|
+
const log = ctx.log ?? {
|
|
162
|
+
info: (msg) => ctx.runtime.log(msg),
|
|
163
|
+
error: (msg) => ctx.runtime.error(msg),
|
|
164
|
+
};
|
|
165
|
+
log.info(`[webhook] 停止 Webhook Gateway (accountId=${ctx.accountId})`);
|
|
166
|
+
// 1. 注销该账号的 Target
|
|
167
|
+
const unregister = accountUnregisters.get(ctx.accountId);
|
|
168
|
+
if (unregister) {
|
|
169
|
+
unregister();
|
|
170
|
+
accountUnregisters.delete(ctx.accountId);
|
|
171
|
+
}
|
|
172
|
+
// 2. 如果没有任何活跃 Target,停止 pruneTimer
|
|
173
|
+
if (!hasActiveTargets()) {
|
|
174
|
+
monitorState.stopPruning();
|
|
175
|
+
}
|
|
176
|
+
// 3. 更新状态
|
|
177
|
+
ctx.setStatus?.({
|
|
178
|
+
accountId: ctx.accountId,
|
|
179
|
+
running: false,
|
|
180
|
+
lastStopAt: Date.now(),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// flushPending 中间层(对齐原版 monitor.ts:1151-1192)
|
|
185
|
+
// ============================================================================
|
|
186
|
+
/**
|
|
187
|
+
* **flushPending (刷新待处理消息 / 核心 Agent 触发点)**
|
|
188
|
+
*
|
|
189
|
+
* 当防抖计时器结束时被调用。
|
|
190
|
+
* 核心逻辑:
|
|
191
|
+
* 1. 聚合所有 pending 的消息内容(用于上下文)。
|
|
192
|
+
* 2. 获取 PluginRuntime。
|
|
193
|
+
* 3. 标记 Stream 为 Started。
|
|
194
|
+
* 4. 调用 `startAgentForStream` 启动 Agent 流程。
|
|
195
|
+
* 5. 处理异常并更新 Stream 状态为 Error。
|
|
196
|
+
*/
|
|
197
|
+
async function flushPending(pending) {
|
|
198
|
+
const { streamId, target, msg, contents, msgids, conversationKey, batchKey } = pending;
|
|
199
|
+
const { streamStore } = monitorState;
|
|
200
|
+
// Merge all message contents (each is already formatted by buildInboundBody)
|
|
201
|
+
const mergedContents = contents.filter(c => c.trim()).join("\n").trim();
|
|
202
|
+
let core = null;
|
|
203
|
+
try {
|
|
204
|
+
core = getWeComRuntime();
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
target.runtime.log?.(`[webhook] flush pending: runtime not ready: ${String(err)}`);
|
|
208
|
+
streamStore.markFinished(streamId);
|
|
209
|
+
target.runtime.log?.(`[webhook] queue: runtime not ready,结束批次并推进 streamId=${streamId}`);
|
|
210
|
+
streamStore.onStreamFinished(streamId);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (core) {
|
|
214
|
+
streamStore.markStarted(streamId);
|
|
215
|
+
const enrichedTarget = { ...target, core };
|
|
216
|
+
target.runtime.log?.(`[webhook] flush pending: start batch streamId=${streamId} batchKey=${batchKey} conversationKey=${conversationKey} mergedCount=${contents.length}`);
|
|
217
|
+
// Pass the first msg (with its media structure), and mergedContents for multi-message context
|
|
218
|
+
startAgentForStream({
|
|
219
|
+
target: enrichedTarget,
|
|
220
|
+
accountId: target.account.accountId,
|
|
221
|
+
msg,
|
|
222
|
+
streamId,
|
|
223
|
+
mergedContents: contents.length > 1 ? mergedContents : undefined,
|
|
224
|
+
mergedMsgids: msgids.length > 1 ? msgids : undefined,
|
|
225
|
+
}).catch((err) => {
|
|
226
|
+
streamStore.updateStream(streamId, (state) => {
|
|
227
|
+
state.error = err instanceof Error ? err.message : String(err);
|
|
228
|
+
state.content = state.content || `Error: ${state.error}`;
|
|
229
|
+
state.finished = true;
|
|
230
|
+
});
|
|
231
|
+
target.runtime.error?.(`[webhook] Agent 处理失败 (streamId=${streamId}): ${String(err)}`);
|
|
232
|
+
streamStore.onStreamFinished(streamId);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook HTTP 请求处理
|
|
3
|
+
*
|
|
4
|
+
* 从 @mocrane/wecom monitor.ts handleWecomWebhookRequest 部分迁移 + 重构。
|
|
5
|
+
* 负责:
|
|
6
|
+
* 1. GET/POST 请求分流
|
|
7
|
+
* 2. 签名验证(调用 crypto 模块)
|
|
8
|
+
* 3. 消息解密
|
|
9
|
+
* 4. 按消息类型分发到 monitor 层
|
|
10
|
+
*/
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
+
/**
|
|
13
|
+
* Webhook HTTP 请求总入口
|
|
14
|
+
*
|
|
15
|
+
* 处理企微 Bot Webhook 的 GET(URL 验证)和 POST(消息回调)请求。
|
|
16
|
+
* 返回 true 表示已处理,false 表示不匹配(交给其他 handler)。
|
|
17
|
+
*/
|
|
18
|
+
export declare function handleWecomWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook HTTP 请求处理
|
|
3
|
+
*
|
|
4
|
+
* 从 @mocrane/wecom monitor.ts handleWecomWebhookRequest 部分迁移 + 重构。
|
|
5
|
+
* 负责:
|
|
6
|
+
* 1. GET/POST 请求分流
|
|
7
|
+
* 2. 签名验证(调用 crypto 模块)
|
|
8
|
+
* 3. 消息解密
|
|
9
|
+
* 4. 按消息类型分发到 monitor 层
|
|
10
|
+
*/
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import { getRegisteredTargets, getWebhookTargetsMap, parseWebhookPath } from "./target.js";
|
|
13
|
+
import { resolveWecomEgressProxyUrl } from "../utils.js";
|
|
14
|
+
import { handleInboundMessage, handleStreamRefresh, handleEnterChat, handleTemplateCardEvent, } from "./monitor.js";
|
|
15
|
+
import { hasActiveTargets } from "./target.js";
|
|
16
|
+
import { resolveWecomSenderUserId, } from "./helpers.js";
|
|
17
|
+
import { WecomCrypto } from "@wecom/aibot-node-sdk";
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// 辅助函数
|
|
20
|
+
// ============================================================================
|
|
21
|
+
/** 解析 URL 查询参数 */
|
|
22
|
+
function parseQuery(url) {
|
|
23
|
+
const idx = url.indexOf("?");
|
|
24
|
+
if (idx < 0)
|
|
25
|
+
return {};
|
|
26
|
+
const params = new URLSearchParams(url.slice(idx + 1));
|
|
27
|
+
const result = {};
|
|
28
|
+
for (const [key, value] of params) {
|
|
29
|
+
result[key] = value;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 从查询参数中提取签名字段
|
|
35
|
+
*
|
|
36
|
+
* 企微不同场景下签名参数名不一致,按优先级依次尝试:
|
|
37
|
+
* msg_signature → msgsignature → signature
|
|
38
|
+
*/
|
|
39
|
+
function resolveSignatureParam(query) {
|
|
40
|
+
return query.msg_signature ?? query.msgsignature ?? query.signature ?? "";
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 判断入站消息是否应该被处理(对齐原版 shouldProcessBotInboundMessage)
|
|
44
|
+
*
|
|
45
|
+
* 仅允许"真实用户消息"进入 Bot 会话:
|
|
46
|
+
* - 发送者缺失 → 丢弃(避免 unknown 会话串会话)
|
|
47
|
+
* - 发送者是 sys → 丢弃(避免系统回调触发 AI 自动回复)
|
|
48
|
+
* - 群消息缺失 chatid → 丢弃(避免 group:unknown 串群)
|
|
49
|
+
*/
|
|
50
|
+
function shouldProcessBotInboundMessage(msg) {
|
|
51
|
+
const senderUserId = resolveWecomSenderUserId(msg)?.trim();
|
|
52
|
+
if (!senderUserId) {
|
|
53
|
+
return { shouldProcess: false, reason: "missing_sender" };
|
|
54
|
+
}
|
|
55
|
+
if (senderUserId.toLowerCase() === "sys") {
|
|
56
|
+
return { shouldProcess: false, reason: "system_sender" };
|
|
57
|
+
}
|
|
58
|
+
// 企微 Bot 回调中 chattype 是扁平字段(非嵌套在 chat_info 内)
|
|
59
|
+
const chatType = String(msg.chattype ?? "").trim().toLowerCase();
|
|
60
|
+
if (chatType === "group") {
|
|
61
|
+
const chatId = msg.chatid?.trim();
|
|
62
|
+
if (!chatId) {
|
|
63
|
+
return { shouldProcess: false, reason: "missing_chatid", senderUserId };
|
|
64
|
+
}
|
|
65
|
+
return { shouldProcess: true, reason: "user_message", senderUserId, chatId };
|
|
66
|
+
}
|
|
67
|
+
return { shouldProcess: true, reason: "user_message", senderUserId, chatId: senderUserId };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 从 Target 配置中提取预期的 Bot Identity 集合
|
|
71
|
+
*
|
|
72
|
+
* 用于 aibotid 校验:即使签名匹配,也要确认消息来自预期的 Bot。
|
|
73
|
+
*
|
|
74
|
+
* 配置来源(对齐用户 YAML 配置):
|
|
75
|
+
* - 单账号模式:channels.wecom.botId
|
|
76
|
+
* - 多账号模式:channels.wecom.accounts.xxx.botId
|
|
77
|
+
*
|
|
78
|
+
* 解析后的 botId 已在 account.botId 中,直接读取即可。
|
|
79
|
+
* 同时兼容 config 中可能存在的 aibotid(原版字段名)。
|
|
80
|
+
*/
|
|
81
|
+
function resolveBotIdentitySet(target) {
|
|
82
|
+
const ids = new Set();
|
|
83
|
+
// account.botId — 从 YAML 配置中解析出的 botId(单账号/多账号均可)
|
|
84
|
+
const botId = target.account.botId?.trim();
|
|
85
|
+
if (botId)
|
|
86
|
+
ids.add(botId);
|
|
87
|
+
// config.botId — 与 account.botId 相同来源(兜底)
|
|
88
|
+
const configBotId = target.account.config.botId?.trim();
|
|
89
|
+
if (configBotId)
|
|
90
|
+
ids.add(configBotId);
|
|
91
|
+
return ids;
|
|
92
|
+
}
|
|
93
|
+
/** POST body 最大允许字节数 (1 MB) */
|
|
94
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
95
|
+
/**
|
|
96
|
+
* 读取 HTTP 请求 body(带大小限制保护)
|
|
97
|
+
*
|
|
98
|
+
* 超过 maxBytes 时会主动销毁请求并拒绝,防止大包攻击。
|
|
99
|
+
*/
|
|
100
|
+
function readBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let total = 0;
|
|
104
|
+
req.on("data", (chunk) => {
|
|
105
|
+
total += chunk.length;
|
|
106
|
+
if (total > maxBytes) {
|
|
107
|
+
resolve({ ok: false, error: "payload too large" });
|
|
108
|
+
req.destroy();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
chunks.push(chunk);
|
|
112
|
+
});
|
|
113
|
+
req.on("end", () => {
|
|
114
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
115
|
+
if (!raw.trim()) {
|
|
116
|
+
resolve({ ok: false, error: "empty payload" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
resolve({ ok: true, value: raw });
|
|
120
|
+
});
|
|
121
|
+
req.on("error", (err) => {
|
|
122
|
+
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** 构造加密 JSON 响应(返回对象,不做 stringify) */
|
|
127
|
+
function encryptResponse(target, responseData, timestamp, nonce) {
|
|
128
|
+
const plaintext = JSON.stringify(responseData);
|
|
129
|
+
const wc = new WecomCrypto(target.account.token, target.account.encodingAESKey, target.account.receiveId);
|
|
130
|
+
const { encrypt, signature } = wc.encrypt(plaintext, timestamp, nonce);
|
|
131
|
+
return { encrypt, msgsignature: signature, timestamp, nonce };
|
|
132
|
+
}
|
|
133
|
+
/** 发送 JSON 响应 (Content-Type: application/json) */
|
|
134
|
+
function sendJson(res, statusCode, data) {
|
|
135
|
+
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
136
|
+
res.end(JSON.stringify(data));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 发送加密回复响应 (Content-Type: text/plain)
|
|
140
|
+
*
|
|
141
|
+
* 企微官方参考实现要求加密 JSON 以 text/plain 返回。
|
|
142
|
+
*/
|
|
143
|
+
function sendEncryptedReply(res, data) {
|
|
144
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
145
|
+
res.end(JSON.stringify(data));
|
|
146
|
+
}
|
|
147
|
+
/** 发送纯文本响应 */
|
|
148
|
+
function sendText(res, statusCode, text) {
|
|
149
|
+
res.writeHead(statusCode, { "Content-Type": "text/plain charset=utf-8" });
|
|
150
|
+
res.end(text);
|
|
151
|
+
}
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// 路径解析
|
|
154
|
+
// ============================================================================
|
|
155
|
+
/**
|
|
156
|
+
* 标准化 Webhook 路径(不含 query string)
|
|
157
|
+
*/
|
|
158
|
+
function normalizeRequestPath(url) {
|
|
159
|
+
const idx = url.indexOf("?");
|
|
160
|
+
const pathname = idx >= 0 ? url.slice(0, idx) : url;
|
|
161
|
+
const trimmed = pathname.trim();
|
|
162
|
+
if (!trimmed || trimmed === "/")
|
|
163
|
+
return "/";
|
|
164
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
165
|
+
if (withSlash.length > 1 && withSlash.endsWith("/"))
|
|
166
|
+
return withSlash.slice(0, -1);
|
|
167
|
+
return withSlash;
|
|
168
|
+
}
|
|
169
|
+
/** 按 accountId 去重 Target 列表(同一 account 注册多条路径时只保留第一个) */
|
|
170
|
+
function deduplicateByAccountId(targets) {
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
const result = [];
|
|
173
|
+
for (const target of targets) {
|
|
174
|
+
if (!seen.has(target.account.accountId)) {
|
|
175
|
+
seen.add(target.account.accountId);
|
|
176
|
+
result.push(target);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 从已注册的 Target 中匹配签名
|
|
183
|
+
*
|
|
184
|
+
* 匹配策略:
|
|
185
|
+
* 1. 如果路径中有 accountId,优先精确匹配
|
|
186
|
+
* 2. 用 filter 收集所有签名匹配的 Target
|
|
187
|
+
* 3. 检查冲突:0 个 = not_found,1 个 = matched,>1 个 = conflict
|
|
188
|
+
*
|
|
189
|
+
* 与原版保持一致:检查 target.account.token 存在性,防止空 token 的误匹配。
|
|
190
|
+
*/
|
|
191
|
+
function findMatchingTarget(requestPath, signature, timestamp, nonce, encrypt, pathAccountId) {
|
|
192
|
+
// 收集所有候选 Target(路径匹配 + 全局兜底)
|
|
193
|
+
const targetsMap = getWebhookTargetsMap();
|
|
194
|
+
const normalizedPath = normalizeRequestPath(requestPath);
|
|
195
|
+
const pathTargets = targetsMap.get(normalizedPath);
|
|
196
|
+
// 如果路径中有 accountId,优先精确匹配
|
|
197
|
+
if (pathAccountId && pathTargets) {
|
|
198
|
+
const byAccountId = pathTargets.find((t) => t.account.accountId === pathAccountId);
|
|
199
|
+
if (byAccountId?.account?.token) {
|
|
200
|
+
const wc = new WecomCrypto(byAccountId.account.token, byAccountId.account.encodingAESKey, byAccountId.account.receiveId);
|
|
201
|
+
const ok = wc.verifySignature(signature, timestamp, nonce, encrypt);
|
|
202
|
+
if (ok)
|
|
203
|
+
return { status: "matched", target: byAccountId };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// 收集候选列表(路径匹配优先,否则全局遍历)
|
|
207
|
+
const candidates = (pathTargets && pathTargets.length > 0)
|
|
208
|
+
? pathTargets
|
|
209
|
+
: getRegisteredTargets();
|
|
210
|
+
// filter 语义:收集所有签名匹配的 Target
|
|
211
|
+
const signatureMatches = candidates.filter((target) => {
|
|
212
|
+
if (!target?.account?.token)
|
|
213
|
+
return false;
|
|
214
|
+
const wc = new WecomCrypto(target.account.token, target.account.encodingAESKey, target.account.receiveId);
|
|
215
|
+
return wc.verifySignature(signature, timestamp, nonce, encrypt);
|
|
216
|
+
});
|
|
217
|
+
// 按 accountId 去重(同一 account 注册多条路径时,不应被误判为冲突)
|
|
218
|
+
const uniqueMatches = deduplicateByAccountId(signatureMatches);
|
|
219
|
+
if (uniqueMatches.length === 1) {
|
|
220
|
+
return { status: "matched", target: uniqueMatches[0] };
|
|
221
|
+
}
|
|
222
|
+
const candidateAccountIds = (uniqueMatches.length > 0 ? uniqueMatches : candidates)
|
|
223
|
+
.map((t) => t.account.accountId);
|
|
224
|
+
if (uniqueMatches.length === 0) {
|
|
225
|
+
return { status: "not_found", candidateAccountIds };
|
|
226
|
+
}
|
|
227
|
+
// uniqueMatches.length > 1 → 多账号冲突
|
|
228
|
+
return { status: "conflict", candidateAccountIds };
|
|
229
|
+
}
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// 主入口
|
|
232
|
+
// ============================================================================
|
|
233
|
+
/**
|
|
234
|
+
* Webhook HTTP 请求总入口
|
|
235
|
+
*
|
|
236
|
+
* 处理企微 Bot Webhook 的 GET(URL 验证)和 POST(消息回调)请求。
|
|
237
|
+
* 返回 true 表示已处理,false 表示不匹配(交给其他 handler)。
|
|
238
|
+
*/
|
|
239
|
+
export async function handleWecomWebhookRequest(req, res) {
|
|
240
|
+
// ── 入站诊断日志(不输出敏感参数内容,仅输出是否存在)──────────────
|
|
241
|
+
const reqId = crypto.randomUUID().slice(0, 8);
|
|
242
|
+
const url = req.url ?? "/";
|
|
243
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
244
|
+
const remote = req.socket?.remoteAddress ?? "unknown";
|
|
245
|
+
const ua = String(req.headers["user-agent"] ?? "");
|
|
246
|
+
const cl = String(req.headers["content-length"] ?? "");
|
|
247
|
+
const query = parseQuery(url);
|
|
248
|
+
const hasTimestamp = Boolean(query.timestamp);
|
|
249
|
+
const hasNonce = Boolean(query.nonce);
|
|
250
|
+
const hasEchostr = Boolean(query.echostr);
|
|
251
|
+
const signature = resolveSignatureParam(query);
|
|
252
|
+
const hasSig = Boolean(signature);
|
|
253
|
+
console.log(`[wecom] inbound(http): reqId=${reqId} path=${url.split("?")[0]} method=${method} remote=${remote} ua=${ua ? `"${ua}"` : "N/A"} contentLength=${cl || "N/A"} query={timestamp:${hasTimestamp},nonce:${hasNonce},echostr:${hasEchostr},signature:${hasSig}}`);
|
|
254
|
+
if (!hasActiveTargets()) {
|
|
255
|
+
console.log(`[wecom] inbound(http): reqId=${reqId} skipped — no active targets`);
|
|
256
|
+
return false; // 无注册 Target,不处理
|
|
257
|
+
}
|
|
258
|
+
const pathAccountId = parseWebhookPath(url);
|
|
259
|
+
// ── GET 请求:URL 验证 ──────────────────────────────────────────
|
|
260
|
+
if (method === "GET") {
|
|
261
|
+
const { timestamp, nonce, echostr } = query;
|
|
262
|
+
const msgSignature = resolveSignatureParam(query);
|
|
263
|
+
if (!msgSignature || !timestamp || !nonce || !echostr) {
|
|
264
|
+
sendText(res, 400, "missing required query parameters");
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
const matchResult = findMatchingTarget(url, msgSignature, timestamp, nonce, echostr, pathAccountId);
|
|
268
|
+
if (matchResult.status !== "matched") {
|
|
269
|
+
console.log(`[wecom] inbound(http): reqId=${reqId} GET route_failure reason=${matchResult.status} candidates=[${matchResult.candidateAccountIds.join(",")}]`);
|
|
270
|
+
sendText(res, 403, "signature verification failed");
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
const target = matchResult.target;
|
|
274
|
+
target.runtime.log?.(`[webhook] GET URL 验证成功 (account=${target.account.accountId})`);
|
|
275
|
+
try {
|
|
276
|
+
const wc = new WecomCrypto(target.account.token, target.account.encodingAESKey, target.account.receiveId);
|
|
277
|
+
const plaintext = wc.decrypt(echostr);
|
|
278
|
+
sendText(res, 200, plaintext);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
target.runtime.log?.(`[webhook] echostr 解密失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
282
|
+
sendText(res, 403, "decryption failed");
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
// ── POST 请求:消息回调 ──────────────────────────────────────────
|
|
287
|
+
if (method === "POST") {
|
|
288
|
+
const { timestamp, nonce } = query;
|
|
289
|
+
const msgSignature = resolveSignatureParam(query);
|
|
290
|
+
if (!msgSignature || !timestamp || !nonce) {
|
|
291
|
+
sendJson(res, 400, { error: "missing required query parameters" });
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
const bodyResult = await readBody(req);
|
|
295
|
+
if (!bodyResult.ok) {
|
|
296
|
+
sendJson(res, 400, { error: bodyResult.error });
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
const bodyStr = bodyResult.value;
|
|
300
|
+
let body;
|
|
301
|
+
try {
|
|
302
|
+
body = JSON.parse(bodyStr);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
// 兼容 encrypt / Encrypt 两种大小写(企微不同场景下字段名不一致)
|
|
309
|
+
const encrypt = String(body.encrypt ?? body.Encrypt ?? "");
|
|
310
|
+
// POST body 诊断日志(不输出 encrypt 内容)
|
|
311
|
+
console.log(`[wecom] inbound(bot): reqId=${reqId} rawJsonBytes=${Buffer.byteLength(bodyStr, "utf8")} hasEncrypt=${Boolean(encrypt)} encryptLen=${encrypt.length}`);
|
|
312
|
+
if (!encrypt) {
|
|
313
|
+
sendJson(res, 400, { error: "missing encrypt field" });
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
// 多账号签名匹配
|
|
317
|
+
const matchResult = findMatchingTarget(url, msgSignature, timestamp, nonce, encrypt, pathAccountId);
|
|
318
|
+
if (matchResult.status !== "matched") {
|
|
319
|
+
const reason = matchResult.status === "conflict"
|
|
320
|
+
? "wecom_account_conflict"
|
|
321
|
+
: "wecom_account_not_found";
|
|
322
|
+
const detail = matchResult.status === "conflict"
|
|
323
|
+
? "Bot callback account conflict: multiple accounts matched signature."
|
|
324
|
+
: "Bot callback account not found: signature verification failed.";
|
|
325
|
+
console.log(`[wecom] inbound(bot): reqId=${reqId} route_failure reason=${reason} path=${url.split("?")[0]} candidates=[${matchResult.candidateAccountIds.join(",")}]`);
|
|
326
|
+
sendText(res, 403, detail);
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
const target = matchResult.target;
|
|
330
|
+
target.runtime.log?.(`[webhook] POST 签名验证成功 (account=${target.account.accountId})`);
|
|
331
|
+
// 更新状态:最后接收消息时间
|
|
332
|
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
333
|
+
// 消息解密
|
|
334
|
+
let message;
|
|
335
|
+
try {
|
|
336
|
+
const wc = new WecomCrypto(target.account.token, target.account.encodingAESKey, target.account.receiveId);
|
|
337
|
+
const plaintext = wc.decrypt(encrypt);
|
|
338
|
+
message = JSON.parse(plaintext);
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
target.runtime.log?.(`[webhook] 消息解密失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
342
|
+
// 解密失败返回 400 + 可读错误信息(与原版一致,方便排查 EncodingAESKey 配置错误)
|
|
343
|
+
sendText(res, 400, "decrypt failed - 解密失败,请检查 EncodingAESKey");
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
// aibotid 身份校验(安全兜底:即使签名匹配,也校验消息中的 aibotid)
|
|
347
|
+
const expectedBotIds = resolveBotIdentitySet(target);
|
|
348
|
+
if (expectedBotIds.size > 0) {
|
|
349
|
+
const inboundAibotId = String(message.aibotid ?? "").trim();
|
|
350
|
+
if (!inboundAibotId || !expectedBotIds.has(inboundAibotId)) {
|
|
351
|
+
target.runtime.error?.(`[webhook] aibotid_mismatch: accountId=${target.account.accountId} expected=${Array.from(expectedBotIds).join(",")} actual=${inboundAibotId || "N/A"}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
target.runtime.log?.(`[webhook] 收到消息 (type=${message.msgtype}, msgid=${message.msgid ?? "N/A"}, account=${target.account.accountId})`);
|
|
355
|
+
// 获取出口代理 URL(对齐原版 resolveWecomEgressProxyUrl)
|
|
356
|
+
const proxyUrl = resolveWecomEgressProxyUrl(target.config);
|
|
357
|
+
// 按消息类型分发
|
|
358
|
+
try {
|
|
359
|
+
const responseData = await dispatchMessage(target, message, timestamp, nonce, proxyUrl);
|
|
360
|
+
if (responseData) {
|
|
361
|
+
const encrypted = encryptResponse(target, responseData, timestamp, nonce);
|
|
362
|
+
sendEncryptedReply(res, encrypted);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
// 空响应也使用加密包装
|
|
366
|
+
const encrypted = encryptResponse(target, {}, timestamp, nonce);
|
|
367
|
+
sendEncryptedReply(res, encrypted);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
target.runtime.error?.(`[webhook] 消息处理异常: ${err instanceof Error ? err.message : String(err)}`);
|
|
372
|
+
// 对齐原版:尽量返回 200 避免企微重试风暴,同时给一个可见的错误文本
|
|
373
|
+
const errorResponse = {
|
|
374
|
+
msgtype: "text",
|
|
375
|
+
text: { content: "服务内部错误:Bot 处理异常,请稍后重试。" },
|
|
376
|
+
};
|
|
377
|
+
const encrypted = encryptResponse(target, errorResponse, timestamp, nonce);
|
|
378
|
+
sendEncryptedReply(res, encrypted);
|
|
379
|
+
}
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
// ============================================================================
|
|
385
|
+
// 消息分发
|
|
386
|
+
// ============================================================================
|
|
387
|
+
/**
|
|
388
|
+
* 根据消息类型分发到对应的处理函数
|
|
389
|
+
*/
|
|
390
|
+
async function dispatchMessage(target, message, timestamp, nonce, proxyUrl) {
|
|
391
|
+
const msgtype = message.msgtype;
|
|
392
|
+
// stream_refresh 轮询
|
|
393
|
+
if (msgtype === "stream") {
|
|
394
|
+
return handleStreamRefresh(target, message);
|
|
395
|
+
}
|
|
396
|
+
// 事件处理
|
|
397
|
+
if (msgtype === "event") {
|
|
398
|
+
const eventType = String(message.event?.eventtype ?? "").toLowerCase();
|
|
399
|
+
if (eventType === "enter_chat") {
|
|
400
|
+
return handleEnterChat(target, message);
|
|
401
|
+
}
|
|
402
|
+
if (eventType === "template_card_event") {
|
|
403
|
+
return handleTemplateCardEvent(target, message, timestamp, nonce, proxyUrl);
|
|
404
|
+
}
|
|
405
|
+
target.runtime.log?.(`[webhook] 未处理的事件类型: ${eventType}`);
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
// 普通消息(text / image / file / voice / video / mixed)
|
|
409
|
+
if (["text", "image", "file", "voice", "video", "mixed"].includes(msgtype)) {
|
|
410
|
+
// 过滤非真实用户消息(与原版 shouldProcessBotInboundMessage 对齐)
|
|
411
|
+
const filterResult = shouldProcessBotInboundMessage(message);
|
|
412
|
+
if (!filterResult.shouldProcess) {
|
|
413
|
+
target.runtime.log?.(`[webhook] 消息过滤: msgtype=${msgtype} reason=${filterResult.reason} from=${resolveWecomSenderUserId(message) ?? "N/A"} chatType=${String(message.chattype ?? "N/A")}`);
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
return handleInboundMessage(target, message, timestamp, nonce, proxyUrl, filterResult);
|
|
417
|
+
}
|
|
418
|
+
target.runtime.log?.(`[webhook] 未知消息类型: ${msgtype}`);
|
|
419
|
+
return null;
|
|
420
|
+
}
|