@wecom/wecom-openclaw-plugin 2026.4.2-beta.0 → 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.
Files changed (215) hide show
  1. package/README.md +395 -10
  2. package/dist/index.d.ts +2 -4
  3. package/dist/index.js +58 -0
  4. package/dist/src/accounts.d.ts +57 -0
  5. package/dist/src/accounts.js +222 -0
  6. package/dist/src/agent/api-client.d.ts +94 -0
  7. package/dist/src/agent/api-client.js +292 -0
  8. package/dist/src/agent/handler.d.ts +63 -0
  9. package/dist/src/agent/handler.js +551 -0
  10. package/dist/src/agent/index.d.ts +5 -0
  11. package/dist/src/agent/index.js +5 -0
  12. package/dist/src/agent/webhook.d.ts +24 -0
  13. package/dist/src/agent/webhook.js +228 -0
  14. package/dist/src/agent/xml.d.ts +21 -0
  15. package/dist/src/agent/xml.js +41 -0
  16. package/dist/src/channel.js +579 -0
  17. package/dist/src/chat-queue.d.ts +31 -0
  18. package/dist/src/chat-queue.js +45 -0
  19. package/dist/{esm/types/src → src}/const.d.ts +46 -0
  20. package/dist/{esm/src → src}/const.js +77 -30
  21. package/dist/{esm/src → src}/dm-policy.js +6 -9
  22. package/dist/src/dynamic-agent.d.ts +37 -0
  23. package/dist/src/dynamic-agent.js +60 -0
  24. package/dist/src/dynamic-routing.d.ts +65 -0
  25. package/dist/src/dynamic-routing.js +58 -0
  26. package/dist/{esm/src → src}/group-policy.js +8 -22
  27. package/dist/src/http.d.ts +26 -0
  28. package/dist/src/http.js +93 -0
  29. package/dist/{esm/types/src → src}/interface.d.ts +0 -2
  30. package/dist/src/interface.js +4 -0
  31. package/dist/src/mcp/index.js +6 -0
  32. package/dist/{esm/src → src}/mcp/interceptors/biz-error.js +2 -6
  33. package/dist/{esm/src → src}/mcp/interceptors/index.js +6 -10
  34. package/dist/{esm/src → src}/mcp/interceptors/msg-media.js +6 -10
  35. package/dist/{esm/src → src}/mcp/interceptors/smartpage-create.js +2 -6
  36. package/dist/{esm/src → src}/mcp/interceptors/smartpage-export.js +2 -6
  37. package/dist/src/mcp/interceptors/types.js +7 -0
  38. package/dist/{esm/src → src}/mcp/schema.js +1 -4
  39. package/dist/{esm/src → src}/mcp/tool.js +4 -8
  40. package/dist/{esm/src → src}/mcp/transport.js +15 -16
  41. package/dist/{esm/src → src}/media-handler.js +6 -10
  42. package/dist/{esm/src → src}/media-uploader.js +64 -10
  43. package/dist/{esm/src → src}/message-parser.js +1 -4
  44. package/dist/{esm/types/src → src}/message-sender.d.ts +18 -0
  45. package/dist/{esm/src → src}/message-sender.js +45 -11
  46. package/dist/{esm/src → src}/monitor.js +145 -292
  47. package/dist/{esm/src → src}/onboarding.js +22 -26
  48. package/dist/{esm/types/src → src}/openclaw-compat.d.ts +49 -0
  49. package/dist/{esm/src → src}/openclaw-compat.js +133 -27
  50. package/dist/{esm/src → src}/reqid-store.js +4 -7
  51. package/dist/{esm/src → src}/runtime.js +2 -5
  52. package/dist/src/shared/command-auth.d.ts +23 -0
  53. package/dist/src/shared/command-auth.js +73 -0
  54. package/dist/src/shared/xml-parser.d.ts +46 -0
  55. package/dist/src/shared/xml-parser.js +210 -0
  56. package/dist/{esm/src → src}/state-dir-resolve.js +3 -7
  57. package/dist/{esm/src → src}/state-manager.js +84 -16
  58. package/dist/src/target.d.ts +35 -0
  59. package/dist/src/target.js +67 -0
  60. package/dist/src/template-card-manager.d.ts +55 -0
  61. package/dist/src/template-card-manager.js +205 -0
  62. package/dist/{esm/src → src}/template-card-parser.js +17 -8
  63. package/dist/{esm/src → src}/timeout.js +2 -5
  64. package/dist/src/types/account.d.ts +29 -0
  65. package/dist/src/types/account.js +4 -0
  66. package/dist/src/types/config.d.ts +91 -0
  67. package/dist/src/types/config.js +7 -0
  68. package/dist/src/types/constants.d.ts +42 -0
  69. package/dist/src/types/constants.js +42 -0
  70. package/dist/src/types/index.d.ts +7 -0
  71. package/dist/src/types/index.js +5 -0
  72. package/dist/src/types/message.d.ts +238 -0
  73. package/dist/src/types/message.js +5 -0
  74. package/dist/{esm/types/src → src}/utils.d.ts +41 -0
  75. package/dist/{esm/src → src}/utils.js +24 -10
  76. package/dist/src/version.d.ts +2 -0
  77. package/dist/src/version.js +24 -0
  78. package/dist/src/webhook/command-auth.d.ts +47 -0
  79. package/dist/src/webhook/command-auth.js +96 -0
  80. package/dist/src/webhook/gateway.d.ts +36 -0
  81. package/dist/src/webhook/gateway.js +235 -0
  82. package/dist/src/webhook/handler.d.ts +18 -0
  83. package/dist/src/webhook/handler.js +420 -0
  84. package/dist/src/webhook/helpers.d.ts +156 -0
  85. package/dist/src/webhook/helpers.js +833 -0
  86. package/dist/src/webhook/http.d.ts +26 -0
  87. package/dist/src/webhook/http.js +94 -0
  88. package/dist/src/webhook/index.d.ts +11 -0
  89. package/dist/src/webhook/index.js +14 -0
  90. package/dist/src/webhook/media.d.ts +29 -0
  91. package/dist/src/webhook/media.js +88 -0
  92. package/dist/src/webhook/monitor.d.ts +59 -0
  93. package/dist/src/webhook/monitor.js +1161 -0
  94. package/dist/src/webhook/state.d.ts +220 -0
  95. package/dist/src/webhook/state.js +491 -0
  96. package/dist/src/webhook/target.d.ts +41 -0
  97. package/dist/src/webhook/target.js +132 -0
  98. package/dist/src/webhook/types.d.ts +347 -0
  99. package/dist/src/webhook/types.js +33 -0
  100. package/dist/src/webhook/video-frame.d.ts +13 -0
  101. package/dist/src/webhook/video-frame.js +30 -0
  102. package/package.json +15 -7
  103. package/dist/cjs/index.js +0 -45
  104. package/dist/cjs/index.js.map +0 -1
  105. package/dist/cjs/src/channel.js +0 -339
  106. package/dist/cjs/src/channel.js.map +0 -1
  107. package/dist/cjs/src/const.js +0 -137
  108. package/dist/cjs/src/const.js.map +0 -1
  109. package/dist/cjs/src/dm-policy.js +0 -89
  110. package/dist/cjs/src/dm-policy.js.map +0 -1
  111. package/dist/cjs/src/group-policy.js +0 -133
  112. package/dist/cjs/src/group-policy.js.map +0 -1
  113. package/dist/cjs/src/mcp/interceptors/biz-error.js +0 -70
  114. package/dist/cjs/src/mcp/interceptors/biz-error.js.map +0 -1
  115. package/dist/cjs/src/mcp/interceptors/index.js +0 -73
  116. package/dist/cjs/src/mcp/interceptors/index.js.map +0 -1
  117. package/dist/cjs/src/mcp/interceptors/msg-media.js +0 -159
  118. package/dist/cjs/src/mcp/interceptors/msg-media.js.map +0 -1
  119. package/dist/cjs/src/mcp/interceptors/smartpage-create.js +0 -157
  120. package/dist/cjs/src/mcp/interceptors/smartpage-create.js.map +0 -1
  121. package/dist/cjs/src/mcp/interceptors/smartpage-export.js +0 -93
  122. package/dist/cjs/src/mcp/interceptors/smartpage-export.js.map +0 -1
  123. package/dist/cjs/src/mcp/schema.js +0 -104
  124. package/dist/cjs/src/mcp/schema.js.map +0 -1
  125. package/dist/cjs/src/mcp/tool.js +0 -208
  126. package/dist/cjs/src/mcp/tool.js.map +0 -1
  127. package/dist/cjs/src/mcp/transport.js +0 -417
  128. package/dist/cjs/src/mcp/transport.js.map +0 -1
  129. package/dist/cjs/src/media-handler.js +0 -129
  130. package/dist/cjs/src/media-handler.js.map +0 -1
  131. package/dist/cjs/src/media-uploader.js +0 -284
  132. package/dist/cjs/src/media-uploader.js.map +0 -1
  133. package/dist/cjs/src/message-parser.js +0 -134
  134. package/dist/cjs/src/message-parser.js.map +0 -1
  135. package/dist/cjs/src/message-sender.js +0 -89
  136. package/dist/cjs/src/message-sender.js.map +0 -1
  137. package/dist/cjs/src/monitor.js +0 -991
  138. package/dist/cjs/src/monitor.js.map +0 -1
  139. package/dist/cjs/src/onboarding.js +0 -182
  140. package/dist/cjs/src/onboarding.js.map +0 -1
  141. package/dist/cjs/src/openclaw-compat.js +0 -400
  142. package/dist/cjs/src/openclaw-compat.js.map +0 -1
  143. package/dist/cjs/src/reqid-store.js +0 -88
  144. package/dist/cjs/src/reqid-store.js.map +0 -1
  145. package/dist/cjs/src/runtime.js +0 -16
  146. package/dist/cjs/src/runtime.js.map +0 -1
  147. package/dist/cjs/src/state-dir-resolve.js +0 -35
  148. package/dist/cjs/src/state-dir-resolve.js.map +0 -1
  149. package/dist/cjs/src/state-manager.js +0 -158
  150. package/dist/cjs/src/state-manager.js.map +0 -1
  151. package/dist/cjs/src/template-card-parser.js +0 -641
  152. package/dist/cjs/src/template-card-parser.js.map +0 -1
  153. package/dist/cjs/src/timeout.js +0 -42
  154. package/dist/cjs/src/timeout.js.map +0 -1
  155. package/dist/cjs/src/utils.js +0 -60
  156. package/dist/cjs/src/utils.js.map +0 -1
  157. package/dist/cjs/src/version.js +0 -26
  158. package/dist/cjs/src/version.js.map +0 -1
  159. package/dist/esm/index.js +0 -41
  160. package/dist/esm/index.js.map +0 -1
  161. package/dist/esm/src/channel.js +0 -337
  162. package/dist/esm/src/channel.js.map +0 -1
  163. package/dist/esm/src/const.js.map +0 -1
  164. package/dist/esm/src/dm-policy.js.map +0 -1
  165. package/dist/esm/src/group-policy.js.map +0 -1
  166. package/dist/esm/src/mcp/interceptors/biz-error.js.map +0 -1
  167. package/dist/esm/src/mcp/interceptors/index.js.map +0 -1
  168. package/dist/esm/src/mcp/interceptors/msg-media.js.map +0 -1
  169. package/dist/esm/src/mcp/interceptors/smartpage-create.js.map +0 -1
  170. package/dist/esm/src/mcp/interceptors/smartpage-export.js.map +0 -1
  171. package/dist/esm/src/mcp/schema.js.map +0 -1
  172. package/dist/esm/src/mcp/tool.js.map +0 -1
  173. package/dist/esm/src/mcp/transport.js.map +0 -1
  174. package/dist/esm/src/media-handler.js.map +0 -1
  175. package/dist/esm/src/media-uploader.js.map +0 -1
  176. package/dist/esm/src/message-parser.js.map +0 -1
  177. package/dist/esm/src/message-sender.js.map +0 -1
  178. package/dist/esm/src/monitor.js.map +0 -1
  179. package/dist/esm/src/onboarding.js.map +0 -1
  180. package/dist/esm/src/openclaw-compat.js.map +0 -1
  181. package/dist/esm/src/reqid-store.js.map +0 -1
  182. package/dist/esm/src/runtime.js.map +0 -1
  183. package/dist/esm/src/state-dir-resolve.js.map +0 -1
  184. package/dist/esm/src/state-manager.js.map +0 -1
  185. package/dist/esm/src/template-card-parser.js.map +0 -1
  186. package/dist/esm/src/timeout.js.map +0 -1
  187. package/dist/esm/src/utils.js.map +0 -1
  188. package/dist/esm/src/version.js +0 -23
  189. package/dist/esm/src/version.js.map +0 -1
  190. package/dist/esm/types/index.d.ts +0 -9
  191. package/dist/esm/types/src/version.d.ts +0 -2
  192. /package/dist/{esm/types/src → src}/channel.d.ts +0 -0
  193. /package/dist/{esm/types/src → src}/dm-policy.d.ts +0 -0
  194. /package/dist/{esm/types/src → src}/group-policy.d.ts +0 -0
  195. /package/dist/{esm/types/src → src}/mcp/index.d.ts +0 -0
  196. /package/dist/{esm/types/src → src}/mcp/interceptors/biz-error.d.ts +0 -0
  197. /package/dist/{esm/types/src → src}/mcp/interceptors/index.d.ts +0 -0
  198. /package/dist/{esm/types/src → src}/mcp/interceptors/msg-media.d.ts +0 -0
  199. /package/dist/{esm/types/src → src}/mcp/interceptors/smartpage-create.d.ts +0 -0
  200. /package/dist/{esm/types/src → src}/mcp/interceptors/smartpage-export.d.ts +0 -0
  201. /package/dist/{esm/types/src → src}/mcp/interceptors/types.d.ts +0 -0
  202. /package/dist/{esm/types/src → src}/mcp/schema.d.ts +0 -0
  203. /package/dist/{esm/types/src → src}/mcp/tool.d.ts +0 -0
  204. /package/dist/{esm/types/src → src}/mcp/transport.d.ts +0 -0
  205. /package/dist/{esm/types/src → src}/media-handler.d.ts +0 -0
  206. /package/dist/{esm/types/src → src}/media-uploader.d.ts +0 -0
  207. /package/dist/{esm/types/src → src}/message-parser.d.ts +0 -0
  208. /package/dist/{esm/types/src → src}/monitor.d.ts +0 -0
  209. /package/dist/{esm/types/src → src}/onboarding.d.ts +0 -0
  210. /package/dist/{esm/types/src → src}/reqid-store.d.ts +0 -0
  211. /package/dist/{esm/types/src → src}/runtime.d.ts +0 -0
  212. /package/dist/{esm/types/src → src}/state-dir-resolve.d.ts +0 -0
  213. /package/dist/{esm/types/src → src}/state-manager.d.ts +0 -0
  214. /package/dist/{esm/types/src → src}/template-card-parser.d.ts +0 -0
  215. /package/dist/{esm/types/src → src}/timeout.d.ts +0 -0
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Webhook 模式状态管理
3
+ *
4
+ * 从 @mocrane/wecom monitor/state.ts 完整迁移。
5
+ * 包含 StreamStore(流状态存储)、ActiveReplyStore(主动回复地址存储)、MonitorState(全局容器)。
6
+ */
7
+ import crypto from "node:crypto";
8
+ // ============================================================================
9
+ // 常量
10
+ // ============================================================================
11
+ export const LIMITS = {
12
+ STREAM_TTL_MS: 10 * 60 * 1000,
13
+ ACTIVE_REPLY_TTL_MS: 60 * 60 * 1000,
14
+ DEFAULT_DEBOUNCE_MS: 500,
15
+ STREAM_MAX_BYTES: 20_480,
16
+ REQUEST_TIMEOUT_MS: 15_000,
17
+ };
18
+ // ============================================================================
19
+ // StreamStore
20
+ // ============================================================================
21
+ /**
22
+ * **StreamStore (流状态会话存储)**
23
+ *
24
+ * 管理企业微信回调的流式会话状态、消息去重和防抖聚合逻辑。
25
+ * 负责维护 msgid 到 streamId 的映射,以及临时缓存待处理的 Pending 消息。
26
+ */
27
+ export class StreamStore {
28
+ streams = new Map();
29
+ msgidToStreamId = new Map();
30
+ pendingInbounds = new Map();
31
+ conversationState = new Map();
32
+ streamIdToBatchKey = new Map();
33
+ batchStreamIdToAckStreamIds = new Map();
34
+ onFlush;
35
+ /**
36
+ * **setFlushHandler (设置防抖刷新回调)**
37
+ *
38
+ * 当防抖计时器结束时调用的处理函数。通常用于触发 Agent 进行消息处理。
39
+ * @param handler 回调函数,接收聚合后的 PendingInbound 对象
40
+ */
41
+ setFlushHandler(handler) {
42
+ this.onFlush = handler;
43
+ }
44
+ /**
45
+ * **createStream (创建流会话)**
46
+ *
47
+ * 初始化一个新的流式会话状态。
48
+ * @param params.msgid (可选) 企业微信消息 ID,用于后续去重映射
49
+ * @returns 生成的 streamId (Hex 字符串)
50
+ */
51
+ createStream(params) {
52
+ const streamId = crypto.randomBytes(16).toString("hex");
53
+ if (params.msgid) {
54
+ this.msgidToStreamId.set(String(params.msgid), streamId);
55
+ }
56
+ this.streams.set(streamId, {
57
+ streamId,
58
+ msgid: params.msgid,
59
+ conversationKey: params.conversationKey,
60
+ batchKey: params.batchKey,
61
+ createdAt: Date.now(),
62
+ updatedAt: Date.now(),
63
+ started: false,
64
+ finished: false,
65
+ content: ""
66
+ });
67
+ if (params.batchKey) {
68
+ this.streamIdToBatchKey.set(streamId, params.batchKey);
69
+ }
70
+ return streamId;
71
+ }
72
+ /**
73
+ * **getStream (获取流状态)**
74
+ *
75
+ * 根据 streamId 获取当前的会话状态。
76
+ * @param streamId 流会话 ID
77
+ */
78
+ getStream(streamId) {
79
+ return this.streams.get(streamId);
80
+ }
81
+ /**
82
+ * **getStreamByMsgId (通过 msgid 查找流 ID)**
83
+ *
84
+ * 用于消息去重:检查该 msgid 是否已经关联由正在进行或已完成的流会话。
85
+ * @param msgid 企业微信消息 ID
86
+ */
87
+ getStreamByMsgId(msgid) {
88
+ return this.msgidToStreamId.get(String(msgid));
89
+ }
90
+ /** 手动设置 msgid → streamId 映射 */
91
+ setStreamIdForMsgId(msgid, streamId) {
92
+ const key = String(msgid).trim();
93
+ const value = String(streamId).trim();
94
+ if (!key || !value)
95
+ return;
96
+ this.msgidToStreamId.set(key, value);
97
+ }
98
+ /**
99
+ * 将“回执流”(ack stream) 关联到某个“批次流”(batch stream)。
100
+ * 用于:当用户连发多条消息被合并排队时,让后续消息的 stream 最终也能更新为可理解的提示,而不是永久停留在“已合并排队…”。
101
+ */
102
+ addAckStreamForBatch(params) {
103
+ const batchStreamId = params.batchStreamId.trim();
104
+ const ackStreamId = params.ackStreamId.trim();
105
+ if (!batchStreamId || !ackStreamId)
106
+ return;
107
+ const list = this.batchStreamIdToAckStreamIds.get(batchStreamId) ?? [];
108
+ list.push(ackStreamId);
109
+ this.batchStreamIdToAckStreamIds.set(batchStreamId, list);
110
+ }
111
+ /**
112
+ * 取出并清空某个批次流关联的所有回执流。
113
+ */
114
+ drainAckStreamsForBatch(batchStreamId) {
115
+ const key = batchStreamId.trim();
116
+ if (!key)
117
+ return [];
118
+ const list = this.batchStreamIdToAckStreamIds.get(key) ?? [];
119
+ this.batchStreamIdToAckStreamIds.delete(key);
120
+ return list;
121
+ }
122
+ /**
123
+ * **updateStream (更新流状态)**
124
+ *
125
+ * 原子更新流状态,并自动刷新 updatedAt 时间戳。
126
+ * @param streamId 流会话 ID
127
+ * @param mutator 状态修改函数
128
+ */
129
+ updateStream(streamId, mutator) {
130
+ const state = this.streams.get(streamId);
131
+ if (state) {
132
+ mutator(state);
133
+ state.updatedAt = Date.now();
134
+ }
135
+ }
136
+ /**
137
+ * **markStarted (标记流开始)**
138
+ *
139
+ * 标记该流会话已经开始处理(通常在 Agent 启动后调用)。
140
+ */
141
+ markStarted(streamId) {
142
+ this.updateStream(streamId, (s) => { s.started = true; });
143
+ }
144
+ /**
145
+ * **markFinished (标记流结束)**
146
+ *
147
+ * 标记该流会话已完成,不再接收内容更新。
148
+ */
149
+ markFinished(streamId) {
150
+ this.updateStream(streamId, (s) => { s.finished = true; });
151
+ }
152
+ /**
153
+ * **addPendingMessage (添加待处理消息 / 防抖聚合)**
154
+ *
155
+ * 将收到的消息加入待处理队列。如果相同 pendingKey 已存在,则是防抖聚合;否则创建新条目。
156
+ * 会自动设置或重置防抖定时器。
157
+ *
158
+ * @param params 消息参数
159
+ * @returns { streamId, isNew } isNew=true 表示这是新的一组消息,需初始化 ActiveReply
160
+ */
161
+ addPendingMessage(params) {
162
+ const { conversationKey, target, msg, msgContent, nonce, timestamp, debounceMs } = params;
163
+ const effectiveDebounceMs = debounceMs ?? LIMITS.DEFAULT_DEBOUNCE_MS;
164
+ const state = this.conversationState.get(conversationKey);
165
+ if (!state) {
166
+ // 第一批次(active)
167
+ const batchKey = conversationKey;
168
+ const streamId = this.createStream({ msgid: msg.msgid, conversationKey, batchKey });
169
+ const pending = {
170
+ streamId,
171
+ conversationKey,
172
+ batchKey,
173
+ target,
174
+ msg,
175
+ contents: [msgContent],
176
+ msgids: msg.msgid ? [msg.msgid] : [],
177
+ nonce,
178
+ timestamp,
179
+ createdAt: Date.now(),
180
+ timeout: setTimeout(() => {
181
+ this.requestFlush(batchKey);
182
+ }, effectiveDebounceMs)
183
+ };
184
+ this.pendingInbounds.set(batchKey, pending);
185
+ this.conversationState.set(conversationKey, { activeBatchKey: batchKey, queue: [], nextSeq: 1 });
186
+ return { streamId, status: "active_new" };
187
+ }
188
+ // 合并规则(排队语义):
189
+ // - 初始批次(batchKey===conversationKey)不接收合并:避免 1/2 都刷出同一份最终答案。
190
+ // - 如果 active 批次是“排队批次”(batchKey!=conversationKey)且还没开始处理(started=false),
191
+ // 则允许把后续消息合并进该 active 批次(典型:1 很快结束,2 变 active 但还没开始跑,3 合并到 2)。
192
+ const activeBatchKey = state.activeBatchKey;
193
+ const activeIsInitial = activeBatchKey === conversationKey;
194
+ const activePending = this.pendingInbounds.get(activeBatchKey);
195
+ if (activePending && !activeIsInitial) {
196
+ const activeStream = this.streams.get(activePending.streamId);
197
+ const activeStarted = Boolean(activeStream?.started);
198
+ if (!activeStarted) {
199
+ activePending.contents.push(msgContent);
200
+ if (msg.msgid) {
201
+ activePending.msgids.push(msg.msgid);
202
+ // 注意:不把该 msgid 映射到 active streamId(避免该消息最终也刷出同一份完整答案)
203
+ }
204
+ if (activePending.timeout)
205
+ clearTimeout(activePending.timeout);
206
+ activePending.timeout = setTimeout(() => {
207
+ this.requestFlush(activeBatchKey);
208
+ }, effectiveDebounceMs);
209
+ return { streamId: activePending.streamId, status: "active_merged" };
210
+ }
211
+ }
212
+ // active 批次已经开始处理;后续消息进入队列批次(queued),并允许在队列批次内做防抖聚合。
213
+ const queuedBatchKey = state.queue[0];
214
+ if (queuedBatchKey) {
215
+ const existingQueued = this.pendingInbounds.get(queuedBatchKey);
216
+ if (existingQueued) {
217
+ existingQueued.contents.push(msgContent);
218
+ if (msg.msgid) {
219
+ existingQueued.msgids.push(msg.msgid);
220
+ // 注意:不把该 msgid 映射到 queued streamId(避免该消息最终也刷出同一份完整答案)
221
+ }
222
+ if (existingQueued.timeout)
223
+ clearTimeout(existingQueued.timeout);
224
+ existingQueued.timeout = setTimeout(() => {
225
+ this.requestFlush(queuedBatchKey);
226
+ }, effectiveDebounceMs);
227
+ return { streamId: existingQueued.streamId, status: "queued_merged" };
228
+ }
229
+ }
230
+ // 创建新的 queued 批次(会话只保留 1 个“下一批次”,后续消息继续合并到该批次)
231
+ const seq = state.nextSeq++;
232
+ const batchKey = `${conversationKey}#q${seq}`;
233
+ state.queue = [batchKey];
234
+ const streamId = this.createStream({ msgid: msg.msgid, conversationKey, batchKey });
235
+ const pending = {
236
+ streamId,
237
+ conversationKey,
238
+ batchKey,
239
+ target,
240
+ msg,
241
+ contents: [msgContent],
242
+ msgids: msg.msgid ? [msg.msgid] : [],
243
+ nonce,
244
+ timestamp,
245
+ createdAt: Date.now(),
246
+ timeout: setTimeout(() => {
247
+ this.requestFlush(batchKey);
248
+ }, effectiveDebounceMs)
249
+ };
250
+ this.pendingInbounds.set(batchKey, pending);
251
+ this.conversationState.set(conversationKey, state);
252
+ return { streamId, status: "queued_new" };
253
+ }
254
+ /**
255
+ * 请求刷新:如果该批次当前为 active,则立即 flush;否则标记 ready,等待前序批次完成后再 flush。
256
+ */
257
+ requestFlush(batchKey) {
258
+ const pending = this.pendingInbounds.get(batchKey);
259
+ if (!pending)
260
+ return;
261
+ const state = this.conversationState.get(pending.conversationKey);
262
+ const isActive = state?.activeBatchKey === batchKey;
263
+ if (!isActive) {
264
+ if (pending.timeout) {
265
+ clearTimeout(pending.timeout);
266
+ pending.timeout = null;
267
+ }
268
+ pending.readyToFlush = true;
269
+ return;
270
+ }
271
+ this.flushPending(batchKey);
272
+ }
273
+ /**
274
+ * **flushPending (触发消息处理)**
275
+ *
276
+ * 内部方法:防抖时间结束后,将聚合的消息一次性推送给 flushHandler。
277
+ */
278
+ flushPending(pendingKey) {
279
+ const pending = this.pendingInbounds.get(pendingKey);
280
+ if (!pending)
281
+ return;
282
+ this.pendingInbounds.delete(pendingKey);
283
+ if (pending.timeout) {
284
+ clearTimeout(pending.timeout);
285
+ pending.timeout = null;
286
+ }
287
+ pending.readyToFlush = false;
288
+ if (this.onFlush) {
289
+ this.onFlush(pending);
290
+ }
291
+ }
292
+ /**
293
+ * 在一个 stream 完成后推进会话队列:将 queued 批次提升为 active,并在需要时触发 flush。
294
+ */
295
+ onStreamFinished(streamId) {
296
+ const batchKey = this.streamIdToBatchKey.get(streamId);
297
+ const state = batchKey ? this.streams.get(streamId) : undefined;
298
+ const conversationKey = state?.conversationKey;
299
+ if (!batchKey || !conversationKey)
300
+ return;
301
+ const conv = this.conversationState.get(conversationKey);
302
+ if (!conv)
303
+ return;
304
+ if (conv.activeBatchKey !== batchKey)
305
+ return;
306
+ const next = conv.queue.shift();
307
+ if (!next) {
308
+ // 队列为空:会话已空闲。删除状态,避免后续消息被误判为“排队但永远不触发”。
309
+ this.conversationState.delete(conversationKey);
310
+ return;
311
+ }
312
+ conv.activeBatchKey = next;
313
+ this.conversationState.set(conversationKey, conv);
314
+ const pending = this.pendingInbounds.get(next);
315
+ if (!pending)
316
+ return;
317
+ if (pending.readyToFlush) {
318
+ this.flushPending(next);
319
+ }
320
+ // 否则等待该批次自己的 debounce timer 到期后 requestFlush(next) 执行
321
+ }
322
+ /**
323
+ * **prune (清理过期状态)**
324
+ *
325
+ * 清理过期的流会话、msgid 映射以及残留的 Pending 消息。
326
+ * @param now 当前时间戳 (毫秒)
327
+ */
328
+ prune(now = Date.now()) {
329
+ const streamCutoff = now - LIMITS.STREAM_TTL_MS;
330
+ // 清理过期的流会话
331
+ for (const [id, state] of this.streams.entries()) {
332
+ if (state.updatedAt < streamCutoff) {
333
+ this.streams.delete(id);
334
+ if (state.msgid) {
335
+ // 如果 msgid 映射仍指向该 stream,则一并移除
336
+ if (this.msgidToStreamId.get(state.msgid) === id) {
337
+ this.msgidToStreamId.delete(state.msgid);
338
+ }
339
+ }
340
+ }
341
+ }
342
+ // 清理悬空的 msgid 映射 (Double check)
343
+ for (const [msgid, id] of this.msgidToStreamId.entries()) {
344
+ if (!this.streams.has(id)) {
345
+ this.msgidToStreamId.delete(msgid);
346
+ }
347
+ }
348
+ // 清理超时的 Pending 消息 (通常由 timeout 清理,此处作为兜底)
349
+ for (const [key, pending] of this.pendingInbounds.entries()) {
350
+ if (now - pending.createdAt > LIMITS.STREAM_TTL_MS) {
351
+ if (pending.timeout)
352
+ clearTimeout(pending.timeout);
353
+ this.pendingInbounds.delete(key);
354
+ }
355
+ }
356
+ // 清理 conversationState:active 已不存在且队列为空的会话
357
+ for (const [convKey, conv] of this.conversationState.entries()) {
358
+ const activeExists = this.pendingInbounds.has(conv.activeBatchKey) || Array.from(this.streamIdToBatchKey.values()).includes(conv.activeBatchKey);
359
+ const hasQueue = conv.queue.length > 0;
360
+ if (!activeExists && !hasQueue) {
361
+ this.conversationState.delete(convKey);
362
+ }
363
+ }
364
+ }
365
+ }
366
+ /**
367
+ * **ActiveReplyStore (主动回复地址存储)**
368
+ *
369
+ * 管理企业微信回调中的 `response_url` (用于被动回复转主动推送) 和 `proxyUrl`。
370
+ * 支持 'once' (一次性) 或 'multi' (多次) 使用策略。
371
+ */
372
+ export class ActiveReplyStore {
373
+ policy;
374
+ activeReplies = new Map();
375
+ /**
376
+ * @param policy 使用策略: "once" (默认,销毁式) 或 "multi"
377
+ */
378
+ constructor(policy = "once") {
379
+ this.policy = policy;
380
+ }
381
+ /**
382
+ * **store (存储回复地址)**
383
+ *
384
+ * 关联 streamId 与 response_url。
385
+ */
386
+ store(streamId, responseUrl, proxyUrl) {
387
+ const url = responseUrl?.trim();
388
+ if (!url)
389
+ return;
390
+ this.activeReplies.set(streamId, { response_url: url, proxyUrl, createdAt: Date.now() });
391
+ }
392
+ /**
393
+ * **getUrl (获取回复地址)**
394
+ *
395
+ * 获取指定 streamId 关联的 response_url。
396
+ */
397
+ getUrl(streamId) {
398
+ return this.activeReplies.get(streamId)?.response_url;
399
+ }
400
+ /**
401
+ * 获取关联的代理 URL
402
+ */
403
+ getProxyUrl(streamId) {
404
+ return this.activeReplies.get(streamId)?.proxyUrl;
405
+ }
406
+ /**
407
+ * **use (消耗回复地址)**
408
+ *
409
+ * 使用存储的 response_url 执行操作。
410
+ * - 如果策略是 "once",第二次调用会抛错。
411
+ * - 自动更新使用时间 (usedAt)。
412
+ *
413
+ * @param streamId 流会话 ID
414
+ * @param fn 执行函数,接收 { responseUrl, proxyUrl }
415
+ */
416
+ async use(streamId, fn) {
417
+ const state = this.activeReplies.get(streamId);
418
+ if (!state?.response_url) {
419
+ return; // 无 URL 可用,安全跳过
420
+ }
421
+ if (this.policy === "once" && state.usedAt) {
422
+ throw new Error(`response_url already used for stream ${streamId} (Policy: once)`);
423
+ }
424
+ try {
425
+ await fn({ responseUrl: state.response_url, proxyUrl: state.proxyUrl });
426
+ state.usedAt = Date.now();
427
+ }
428
+ catch (err) {
429
+ state.lastError = err instanceof Error ? err.message : String(err);
430
+ throw err;
431
+ }
432
+ }
433
+ /**
434
+ * **prune (清理过期地址)**
435
+ *
436
+ * 清理超过 TTL 的 active reply 记录。
437
+ */
438
+ prune(now = Date.now()) {
439
+ const cutoff = now - LIMITS.ACTIVE_REPLY_TTL_MS;
440
+ for (const [id, state] of this.activeReplies.entries()) {
441
+ if (state.createdAt < cutoff) {
442
+ this.activeReplies.delete(id);
443
+ }
444
+ }
445
+ }
446
+ }
447
+ /**
448
+ * **MonitorState (全局监控状态容器)**
449
+ *
450
+ * 模块单例,统一管理 StreamStore 和 ActiveReplyStore 实例。
451
+ * 提供生命周期方法 (startPruning / stopPruning) 以自动清理过期数据。
452
+ */
453
+ export class WebhookMonitorState {
454
+ /** 主要的流状态存储 */
455
+ streamStore = new StreamStore();
456
+ /** 主动回复地址存储 */
457
+ activeReplyStore = new ActiveReplyStore("multi");
458
+ pruneInterval;
459
+ /**
460
+ * **startPruning (启动自动清理)**
461
+ *
462
+ * 启动定时器,定期清理过期的流和回复地址。应在插件有活跃 Target 时调用。
463
+ * @param intervalMs 清理间隔 (默认 60s)
464
+ */
465
+ startPruning(intervalMs = 60_000) {
466
+ if (this.pruneInterval)
467
+ return;
468
+ this.pruneInterval = setInterval(() => {
469
+ const now = Date.now();
470
+ this.streamStore.prune(now);
471
+ this.activeReplyStore.prune(now);
472
+ }, intervalMs);
473
+ }
474
+ /**
475
+ * **stopPruning (停止自动清理)**
476
+ *
477
+ * 停止定时器。应在插件无活跃 Target 时调用以释放资源。
478
+ */
479
+ stopPruning() {
480
+ if (this.pruneInterval) {
481
+ clearInterval(this.pruneInterval);
482
+ this.pruneInterval = undefined;
483
+ }
484
+ }
485
+ }
486
+ /**
487
+ * **monitorState (全局单例)**
488
+ *
489
+ * 导出全局唯一的 MonitorState 实例,供整个应用共享状态。
490
+ */
491
+ export const monitorState = new WebhookMonitorState();
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Webhook Target 管理
3
+ *
4
+ * 从 @mocrane/wecom monitor.ts 部分迁移(仅 Webhook Target 相关)。
5
+ * 维护全局已注册 Target 列表,提供注册/注销/查询功能。
6
+ *
7
+ * Target 按路径索引:Map<path, WecomWebhookTarget[]>
8
+ * 同一路径可能注册多个账号(老路径兼容模式),通过签名验证匹配到正确账号。
9
+ */
10
+ import type { WecomWebhookTarget } from "./types.js";
11
+ /**
12
+ * 注册 Webhook Target(多条路径)
13
+ *
14
+ * 为每条路径分别注册 Target,返回一个注销函数(一次性注销所有路径)。
15
+ */
16
+ export declare function registerWecomWebhookTarget(target: WecomWebhookTarget, paths: string[]): () => void;
17
+ /**
18
+ * 获取全局 Target 注册表
19
+ *
20
+ * 返回完整的 Map<path, Target[]>,供 handler.ts 路由匹配使用。
21
+ */
22
+ export declare function getWebhookTargetsMap(): ReadonlyMap<string, WecomWebhookTarget[]>;
23
+ /**
24
+ * 获取所有已注册的 Webhook Target(扁平列表)
25
+ *
26
+ * 用于无法精确匹配路径时的逐一签名验证场景。
27
+ */
28
+ export declare function getRegisteredTargets(): WecomWebhookTarget[];
29
+ /**
30
+ * 判断是否有活跃 Target
31
+ */
32
+ export declare function hasActiveTargets(): boolean;
33
+ /**
34
+ * 从 URL 中解析 accountId(多账号路径)
35
+ *
36
+ * 支持路径格式:
37
+ * - /plugins/wecom/bot/{accountId}
38
+ * - /wecom/bot/{accountId}
39
+ * - /wecom/{accountId}
40
+ */
41
+ export declare function parseWebhookPath(url: string): string | undefined;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Webhook Target 管理
3
+ *
4
+ * 从 @mocrane/wecom monitor.ts 部分迁移(仅 Webhook Target 相关)。
5
+ * 维护全局已注册 Target 列表,提供注册/注销/查询功能。
6
+ *
7
+ * Target 按路径索引:Map<path, WecomWebhookTarget[]>
8
+ * 同一路径可能注册多个账号(老路径兼容模式),通过签名验证匹配到正确账号。
9
+ */
10
+ // ============================================================================
11
+ // 全局 Target 注册表(按路径索引)
12
+ // ============================================================================
13
+ /** 已注册的 Webhook Target(按路径索引) */
14
+ const webhookTargets = new Map();
15
+ // ============================================================================
16
+ // 路径工具函数
17
+ // ============================================================================
18
+ /**
19
+ * 标准化 Webhook 路径
20
+ *
21
+ * 统一格式:以 `/` 开头且不以 `/` 结尾。
22
+ */
23
+ function normalizeWebhookPath(raw) {
24
+ const trimmed = raw.trim();
25
+ if (!trimmed)
26
+ return "/";
27
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
28
+ if (withSlash.length > 1 && withSlash.endsWith("/"))
29
+ return withSlash.slice(0, -1);
30
+ return withSlash;
31
+ }
32
+ /**
33
+ * 去除重复路径
34
+ */
35
+ function uniquePaths(paths) {
36
+ return Array.from(new Set(paths.map((p) => normalizeWebhookPath(p)).filter(Boolean)));
37
+ }
38
+ // ============================================================================
39
+ // 注册 / 注销
40
+ // ============================================================================
41
+ /**
42
+ * 注册 Webhook Target(单条路径)
43
+ *
44
+ * 将 Target 加入指定路径的列表中,返回注销函数。
45
+ */
46
+ function registerTargetForPath(path, target) {
47
+ const key = normalizeWebhookPath(path);
48
+ const normalizedTarget = { ...target, path: key };
49
+ const existing = webhookTargets.get(key) ?? [];
50
+ webhookTargets.set(key, [...existing, normalizedTarget]);
51
+ return () => {
52
+ const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
53
+ if (updated.length > 0)
54
+ webhookTargets.set(key, updated);
55
+ else
56
+ webhookTargets.delete(key);
57
+ };
58
+ }
59
+ /**
60
+ * 注册 Webhook Target(多条路径)
61
+ *
62
+ * 为每条路径分别注册 Target,返回一个注销函数(一次性注销所有路径)。
63
+ */
64
+ export function registerWecomWebhookTarget(target, paths) {
65
+ const unregisters = [];
66
+ for (const path of uniquePaths(paths)) {
67
+ unregisters.push(registerTargetForPath(path, target));
68
+ }
69
+ return () => {
70
+ for (const unregister of unregisters) {
71
+ unregister();
72
+ }
73
+ };
74
+ }
75
+ /**
76
+ * 获取全局 Target 注册表
77
+ *
78
+ * 返回完整的 Map<path, Target[]>,供 handler.ts 路由匹配使用。
79
+ */
80
+ export function getWebhookTargetsMap() {
81
+ return webhookTargets;
82
+ }
83
+ /**
84
+ * 获取所有已注册的 Webhook Target(扁平列表)
85
+ *
86
+ * 用于无法精确匹配路径时的逐一签名验证场景。
87
+ */
88
+ export function getRegisteredTargets() {
89
+ const seen = new Set();
90
+ const result = [];
91
+ for (const list of webhookTargets.values()) {
92
+ for (const target of list) {
93
+ if (!seen.has(target)) {
94
+ seen.add(target);
95
+ result.push(target);
96
+ }
97
+ }
98
+ }
99
+ return result;
100
+ }
101
+ /**
102
+ * 判断是否有活跃 Target
103
+ */
104
+ export function hasActiveTargets() {
105
+ return webhookTargets.size > 0;
106
+ }
107
+ /**
108
+ * 从 URL 中解析 accountId(多账号路径)
109
+ *
110
+ * 支持路径格式:
111
+ * - /plugins/wecom/bot/{accountId}
112
+ * - /wecom/bot/{accountId}
113
+ * - /wecom/{accountId}
114
+ */
115
+ export function parseWebhookPath(url) {
116
+ const patterns = [
117
+ /\/plugins\/wecom\/bot\/([^/?]+)/,
118
+ /\/wecom\/bot\/([^/?]+)/,
119
+ /\/wecom\/([^/?]+)/,
120
+ ];
121
+ for (const pattern of patterns) {
122
+ const match = url.match(pattern);
123
+ if (match?.[1]) {
124
+ const segment = match[1];
125
+ // 排除已知的非 accountId 路径段
126
+ if (segment === "bot")
127
+ continue;
128
+ return segment;
129
+ }
130
+ }
131
+ return undefined;
132
+ }