@sunnoy/wecom 2.0.0 → 2.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -157,8 +157,10 @@ export function upsertAgentIdOnlyEntry(cfg, agentId) {
157
157
  const nextList = [...currentList];
158
158
 
159
159
  // Keep "main" as the explicit default when creating agents.list for the first time.
160
+ // Include heartbeat: {} so main inherits agents.defaults.heartbeat even when
161
+ // dynamic agents also have explicit heartbeat entries (hasExplicitHeartbeatAgents).
160
162
  if (nextList.length === 0) {
161
- nextList.push({ id: "main" });
163
+ nextList.push({ id: "main", heartbeat: {} });
162
164
  existingIds.add("main");
163
165
  changed = true;
164
166
  }
@@ -185,7 +187,7 @@ export async function ensureDynamicAgentListed(agentId, templateDir) {
185
187
 
186
188
  const runtime = getRuntime();
187
189
  const configRuntime = runtime?.config;
188
- if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) {
190
+ if (!configRuntime?.writeConfigFile) {
189
191
  return;
190
192
  }
191
193
 
@@ -196,10 +198,26 @@ export async function ensureDynamicAgentListed(agentId, templateDir) {
196
198
  return;
197
199
  }
198
200
 
199
- // Upsert into memory only. Writing to config file is dangerous and can wipe user settings.
201
+ // Upsert into in-memory config so the running gateway sees it immediately.
200
202
  const changed = upsertAgentIdOnlyEntry(openclawConfig, normalizedId);
201
203
  if (changed) {
202
204
  logger.info("WeCom: dynamic agent added to in-memory agents.list", { agentId: normalizedId });
205
+
206
+ // Persist to disk so `openclaw agents list` (separate process) can see
207
+ // the dynamic agent and it survives gateway restarts.
208
+ // Write the mutated in-memory config directly (same pattern as logoutAccount).
209
+ // NOTE: loadConfig() returns runtimeConfigSnapshot in gateway mode — the same
210
+ // object we already mutated above — so a read-modify-write pattern silently
211
+ // skips the write (diskChanged=false). Writing directly avoids this.
212
+ try {
213
+ await configRuntime.writeConfigFile(openclawConfig);
214
+ logger.info("WeCom: dynamic agent persisted to config file", { agentId: normalizedId });
215
+ } catch (writeErr) {
216
+ logger.warn("WeCom: failed to persist dynamic agent to config file", {
217
+ agentId: normalizedId,
218
+ error: writeErr?.message || String(writeErr),
219
+ });
220
+ }
203
221
  }
204
222
 
205
223
  // Always attempt seeding so recreated/cleaned dynamic agents can recover
@@ -64,6 +64,10 @@ const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moldbot", ".moltbot"];
64
64
  const MAX_REPLY_MSG_ITEMS = 10;
65
65
  const MAX_REPLY_IMAGE_BYTES = 10 * 1024 * 1024;
66
66
  const REASONING_STREAM_THROTTLE_MS = 800;
67
+ const VISIBLE_STREAM_THROTTLE_MS = 800;
68
+ // Reserve headroom below the SDK's per-reqId queue limit (100) so the final
69
+ // reply always has room.
70
+ const MAX_INTERMEDIATE_STREAM_MESSAGES = 85;
67
71
  // Match MEDIA:/FILE: directives at line start, optionally preceded by markdown list markers.
68
72
  const REPLY_MEDIA_DIRECTIVE_PATTERN = /^\s*(?:[-*•]\s+|\d+\.\s+)?(?:MEDIA|FILE)\s*:/im;
69
73
  const WECOM_REPLY_MEDIA_GUIDANCE_HEADER = "[WeCom reply media rule]";
@@ -79,6 +83,11 @@ function withTimeout(promise, timeoutMs, message) {
79
83
  timer = setTimeout(() => reject(new Error(message ?? `Timed out after ${timeoutMs}ms`)), timeoutMs);
80
84
  });
81
85
 
86
+ // Suppress unhandled rejection from the original promise if the timeout wins
87
+ // the race. Without this, a later rejection from the underlying SDK call
88
+ // becomes an unhandled promise rejection.
89
+ promise.catch(() => {});
90
+
82
91
  return Promise.race([promise, timeout]).finally(() => {
83
92
  if (timer) {
84
93
  clearTimeout(timer);
@@ -1026,12 +1035,21 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1026
1035
  const state = { accumulatedText: "", reasoningText: "", streamId, replyMediaUrls: [] };
1027
1036
  setMessageState(messageId, state);
1028
1037
 
1029
- // Throttle reasoning stream updates to avoid exceeding the SDK's per-reqId queue limit (100).
1038
+ // Throttle reasoning and visible text stream updates to avoid exceeding
1039
+ // the SDK's per-reqId reply queue limit (100).
1040
+ let streamMessagesSent = 0;
1030
1041
  let lastReasoningSendAt = 0;
1031
1042
  let pendingReasoningTimer = null;
1043
+ let lastVisibleSendAt = 0;
1044
+ let pendingVisibleTimer = null;
1045
+
1046
+ const canSendIntermediate = () => streamMessagesSent < MAX_INTERMEDIATE_STREAM_MESSAGES;
1047
+
1032
1048
  const sendReasoningUpdate = async () => {
1049
+ if (!canSendIntermediate()) return;
1033
1050
  lastReasoningSendAt = Date.now();
1034
1051
  try {
1052
+ streamMessagesSent++;
1035
1053
  await sendWsReply({
1036
1054
  wsClient,
1037
1055
  frame,
@@ -1049,12 +1067,42 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1049
1067
  }
1050
1068
  };
1051
1069
 
1052
- const cleanupState = () => {
1053
- deleteMessageState(messageId);
1070
+ const sendVisibleUpdate = async () => {
1071
+ if (!canSendIntermediate()) return;
1072
+ lastVisibleSendAt = Date.now();
1073
+ try {
1074
+ streamMessagesSent++;
1075
+ await sendWsReply({
1076
+ wsClient,
1077
+ frame,
1078
+ streamId: state.streamId,
1079
+ text: buildWsStreamContent({
1080
+ reasoningText: state.reasoningText,
1081
+ visibleText: state.accumulatedText,
1082
+ finish: false,
1083
+ }),
1084
+ finish: false,
1085
+ accountId: account.accountId,
1086
+ });
1087
+ } catch (error) {
1088
+ logger.warn(`[WS] Visible stream send failed (non-fatal): ${error.message}`);
1089
+ }
1090
+ };
1091
+
1092
+ const cancelPendingTimers = () => {
1054
1093
  if (pendingReasoningTimer) {
1055
1094
  clearTimeout(pendingReasoningTimer);
1056
1095
  pendingReasoningTimer = null;
1057
1096
  }
1097
+ if (pendingVisibleTimer) {
1098
+ clearTimeout(pendingVisibleTimer);
1099
+ pendingVisibleTimer = null;
1100
+ }
1101
+ };
1102
+
1103
+ const cleanupState = () => {
1104
+ deleteMessageState(messageId);
1105
+ cancelPendingTimers();
1058
1106
  };
1059
1107
 
1060
1108
  if (account.sendThinkingMessage !== false) {
@@ -1168,18 +1216,19 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1168
1216
 
1169
1217
  state.accumulatedText += chunk;
1170
1218
  if (info.kind !== "final") {
1171
- await sendWsReply({
1172
- wsClient,
1173
- frame,
1174
- streamId: state.streamId,
1175
- text: buildWsStreamContent({
1176
- reasoningText: state.reasoningText,
1177
- visibleText: state.accumulatedText,
1178
- finish: false,
1179
- }),
1180
- finish: false,
1181
- accountId: account.accountId,
1182
- });
1219
+ // Throttle visible text stream updates to avoid exceeding
1220
+ // the SDK queue limit together with reasoning updates.
1221
+ const elapsed = Date.now() - lastVisibleSendAt;
1222
+ if (elapsed < VISIBLE_STREAM_THROTTLE_MS) {
1223
+ if (!pendingVisibleTimer) {
1224
+ pendingVisibleTimer = setTimeout(async () => {
1225
+ pendingVisibleTimer = null;
1226
+ await sendVisibleUpdate();
1227
+ }, VISIBLE_STREAM_THROTTLE_MS - elapsed);
1228
+ }
1229
+ return;
1230
+ }
1231
+ await sendVisibleUpdate();
1183
1232
  }
1184
1233
  },
1185
1234
  onError: (error, info) => {
@@ -1190,6 +1239,10 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1190
1239
  },
1191
1240
  );
1192
1241
 
1242
+ // Cancel pending throttled timers before the final reply to prevent
1243
+ // non-final updates from being sent after finish=true.
1244
+ cancelPendingTimers();
1245
+
1193
1246
  const preparedReplyMedia = await prepareReplyMediaOutputs({
1194
1247
  payload: { mediaUrls: state.replyMediaUrls },
1195
1248
  runtime,
@@ -1213,22 +1266,26 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1213
1266
  logger.warn("[WS] Agent API is not configured; passive non-image media delivery was skipped");
1214
1267
  }
1215
1268
 
1216
- if (finalWsText || msgItem.length > 0) {
1269
+ // If dispatch returned no content at all (e.g. upstream empty_stream),
1270
+ // send a fallback so the user isn't left waiting in silence.
1271
+ const effectiveFinalText = finalWsText || (msgItem.length === 0 ? "模型暂时无法响应,请稍后重试。" : "");
1272
+ if (effectiveFinalText || msgItem.length > 0) {
1217
1273
  logger.info("[WS] Sending passive final reply", {
1218
1274
  accountId: account.accountId,
1219
1275
  agentId: route.agentId,
1220
1276
  streamId: state.streamId,
1221
- textLength: finalWsText.length,
1277
+ textLength: effectiveFinalText.length,
1222
1278
  imageItemCount: msgItem.length,
1223
1279
  deferredAgentMediaCount: preparedReplyMedia.agentMedia.length,
1224
1280
  mirroredAgentImageCount: preparedReplyMedia.mirroredAgentMedia.length,
1281
+ emptyStreamFallback: !finalWsText,
1225
1282
  });
1226
1283
  try {
1227
1284
  await sendWsReply({
1228
1285
  wsClient,
1229
1286
  frame,
1230
1287
  streamId: state.streamId,
1231
- text: finalWsText,
1288
+ text: effectiveFinalText,
1232
1289
  finish: true,
1233
1290
  msgItem,
1234
1291
  accountId: account.accountId,
@@ -1241,7 +1298,7 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1241
1298
  senderId,
1242
1299
  });
1243
1300
  enqueuePendingReply(account.accountId, {
1244
- text: finalWsText,
1301
+ text: effectiveFinalText,
1245
1302
  senderId,
1246
1303
  chatId,
1247
1304
  isGroupChat,