palz-connector 1.6.6 → 1.6.7

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "palz-connector",
3
3
  "name": "Palz Connector Channel",
4
- "version": "1.6.6",
4
+ "version": "1.6.7",
5
5
  "description": "Palz IM 接入 OpenClaw",
6
6
  "channels": [
7
7
  "palz-connector"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palz-connector",
3
- "version": "1.6.6",
3
+ "version": "1.6.7",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "description": "Palz IM 接入 OpenClaw — 模块化架构,基于 OpenClaw Runtime 消息管道",
@@ -5,7 +5,8 @@
5
5
  "clawGatewayUrl": "http://claw-gateway:8080",
6
6
  "activityReportEnabled": false,
7
7
  "sessionTimeout": 1800000,
8
- "controlPort": 8008,
9
8
  "groupContextCache": false,
10
- "showProcess": true
9
+ "showProcess": true,
10
+ "kbEngineUrl": "http://127.0.0.1:8070",
11
+ "controlPort": 8008
11
12
  }
@@ -5,7 +5,8 @@
5
5
  "clawGatewayUrl": "http://claw-gateway:8080",
6
6
  "activityReportEnabled": true,
7
7
  "sessionTimeout": 1800000,
8
- "controlPort": 8008,
9
8
  "groupContextCache": false,
10
- "showProcess": true
9
+ "showProcess": true,
10
+ "kbEngineUrl": "http://uniclaw-kb-engine:8070",
11
+ "controlPort": 8008
11
12
  }
@@ -5,7 +5,8 @@
5
5
  "clawGatewayUrl": "http://claw-gateway:8080",
6
6
  "activityReportEnabled": true,
7
7
  "sessionTimeout": 1800000,
8
- "controlPort": 8008,
9
8
  "groupContextCache": false,
10
- "showProcess": true
9
+ "showProcess": true,
10
+ "kbEngineUrl": "http://uniclaw-kb-engine:8070",
11
+ "controlPort": 8008
11
12
  }
@@ -5,7 +5,8 @@
5
5
  "clawGatewayUrl": "http://claw-gateway:8080",
6
6
  "activityReportEnabled": true,
7
7
  "sessionTimeout": 1800000,
8
- "controlPort": 8008,
9
8
  "groupContextCache": false,
10
- "showProcess": true
9
+ "showProcess": true,
10
+ "kbEngineUrl": "http://uniclaw-kb-engine:8070",
11
+ "controlPort": 8008
11
12
  }
package/src/activity.js CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Palz IM WebSocket 连接成功后的 claw-gateway 活动上报。
2
+ * Palz IM 收到消息后的 claw-gateway 活动上报。
3
3
  */
4
4
  const ACTIVITY_REPORT_TIMEOUT_MS = 2000;
5
5
  const ACTIVITY_REPORT_PATH_PREFIX = "/openclaw-gateway/be/deployments";
6
6
  const warnedMissingConfig = new Set();
7
- const SERVICE_NAME = process.env.OPENCLAW_WORKSPACE_POOL_SERVICE_NAME ?? "";
8
7
  function formatDateTimeWithOffset(date) {
9
8
  const pad = (n, width = 2) => String(n).padStart(width, "0");
10
9
  const offsetMinutes = -date.getTimezoneOffset();
@@ -24,7 +23,7 @@ function resolveActivityUrl(baseUrl, releaseName) {
24
23
  return `${normalizedBase}${ACTIVITY_REPORT_PATH_PREFIX}/${encodeURIComponent(releaseName)}/activity`;
25
24
  }
26
25
  export async function reportPalzActivity(params) {
27
- const { config, connectedAt, runtime, accountId } = params;
26
+ const { config, msg, receivedAt, runtime, accountId } = params;
28
27
  const log = typeof runtime?.log === "function" ? runtime.log : console.log;
29
28
  const error = typeof runtime?.error === "function" ? runtime.error : console.error;
30
29
  const tag = `palz[${accountId ?? config.botId ?? "default"}]`;
@@ -42,17 +41,22 @@ export async function reportPalzActivity(params) {
42
41
  }
43
42
  const payload = {
44
43
  source: "palz-connector",
45
- eventType: "ws_connected",
46
- releaseName,
47
- serviceName: SERVICE_NAME,
48
- connectedAt: formatDateTimeWithOffset(connectedAt),
44
+ eventType: "im_message_received",
45
+ messageId: msg.msg_id,
46
+ conversationId: msg.conversation_id,
47
+ conversationType: msg.conversation_type || "direct",
48
+ userId: msg.owner_id ?? "",
49
+ senderId: msg.sender_id,
50
+ receivedAt: formatDateTimeWithOffset(receivedAt),
49
51
  };
52
+ if (msg.traceparent) {
53
+ payload.traceId = msg.traceparent;
54
+ }
50
55
  const url = resolveActivityUrl(baseUrl, releaseName);
51
56
  const body = JSON.stringify(payload);
52
57
  const controller = new AbortController();
53
58
  const timeout = setTimeout(() => controller.abort(), ACTIVITY_REPORT_TIMEOUT_MS);
54
59
  const startMs = Date.now();
55
- log(`${tag}: [ACTIVITY_REPORT] reporting eventType=ws_connected url=${url} body=${body}`);
56
60
  try {
57
61
  const response = await fetch(url, {
58
62
  method: "POST",
@@ -63,15 +67,15 @@ export async function reportPalzActivity(params) {
63
67
  const elapsedMs = Date.now() - startMs;
64
68
  const responseText = await response.text().catch(() => "");
65
69
  if (!response.ok) {
66
- error(`${tag}: [ACTIVITY_REPORT] failed status=${response.status} elapsed=${elapsedMs}ms response=${responseText.slice(0, 300)}`);
70
+ error(`${tag}: [ACTIVITY_REPORT] failed status=${response.status} elapsed=${elapsedMs}ms msg_id=${msg.msg_id} response=${responseText.slice(0, 300)}`);
67
71
  return;
68
72
  }
69
- log(`${tag}: [ACTIVITY_REPORT] ok status=${response.status} elapsed=${elapsedMs}ms eventType=ws_connected`);
73
+ log(`${tag}: [ACTIVITY_REPORT] ok status=${response.status} elapsed=${elapsedMs}ms msg_id=${msg.msg_id}`);
70
74
  }
71
75
  catch (err) {
72
76
  const elapsedMs = Date.now() - startMs;
73
77
  const reason = err?.name === "AbortError" ? `timeout ${ACTIVITY_REPORT_TIMEOUT_MS}ms` : (err?.message ?? String(err));
74
- error(`${tag}: [ACTIVITY_REPORT] error elapsed=${elapsedMs}ms reason=${reason}`);
78
+ error(`${tag}: [ACTIVITY_REPORT] error elapsed=${elapsedMs}ms msg_id=${msg.msg_id} reason=${reason}`);
75
79
  }
76
80
  finally {
77
81
  clearTimeout(timeout);
package/src/bot.js CHANGED
@@ -9,11 +9,14 @@
9
9
  * - 通过 OpenClaw Runtime 的 dispatchReplyFromConfig 完成 AI 对话
10
10
  * - Session 管理交由 OpenClaw Runtime(resolveAgentRoute)
11
11
  */
12
+ import { hostname as getHostname } from "node:os";
12
13
  import { getPalzRuntime } from "./runtime.js";
13
14
  import { resolvePalzAccount } from "./config.js";
14
15
  import { tryClaimMessage } from "./dedup.js";
15
16
  import { createPalzReplyDispatcher } from "./reply-dispatcher.js";
16
17
  import { resolvePalzMediaList, resolveMediaLocalRoots } from "./media.js";
18
+ import { extractFileUrls, ingestFilesToKbEngine } from "./kb-ingest.js";
19
+ import { consumeKbTaskContext, extractKbTaskCallbackContent, extractKbTaskId, resolveKbTaskContext, } from "./kb-task-callback.js";
17
20
  import { sendToPalzIM } from "./send.js";
18
21
  import { buildPassthroughFromMsg } from "./message-utils.js";
19
22
  import { tracer, trace, context, SpanStatusCode } from "./tracing.js";
@@ -206,10 +209,6 @@ function buildMediaPayload(mediaList) {
206
209
  MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
207
210
  };
208
211
  }
209
- function shellSingleQuote(value) {
210
- const raw = String(value ?? "");
211
- return `'${raw.replace(/'/g, "'\"'\"'")}'`;
212
- }
213
212
  function buildFileSharingSystemPrompt() {
214
213
  return [
215
214
  "## File Sharing",
@@ -223,29 +222,40 @@ function buildFileSharingSystemPrompt() {
223
222
  "Multiple files: one MEDIA: line per file.",
224
223
  ].join("\n");
225
224
  }
226
- function buildKnowledgeRecallSystemPrompt(params) {
227
- const directCommand = "python3 skills/knowledge-mgmt/recall/kb-recall.py topics '<用户问题>' --group-id 'default'";
228
- const hasGroupContext = Boolean(params.groupId && params.ownerId && params.groupOwnerId);
229
- const groupCommand = hasGroupContext
230
- ? [
231
- "python3 skills/knowledge-mgmt/recall/kb-recall.py topics '<用户问题>'",
232
- `--group-id ${shellSingleQuote(params.groupId)}`,
233
- "--conversation-type 'group'",
234
- `--owner-id ${shellSingleQuote(params.ownerId)}`,
235
- `--group-owner-id ${shellSingleQuote(params.groupOwnerId)}`,
236
- `--user-id ${shellSingleQuote(params.ownerId)}`,
237
- ].join(" ")
238
- : "python3 skills/knowledge-mgmt/recall/kb-recall.py topics '<用户问题>' --group-id '<group_id>' --conversation-type 'group' --owner-id '<owner_id>' --group-owner-id '<group_owner_id>' --user-id '<owner_id>'";
225
+ function buildKbIngestNoticeMessage(results) {
226
+ const lines = results.map((result, index) => {
227
+ const details = [
228
+ `task_id=${result.taskId ?? "(unknown)"}`,
229
+ `state=${result.state ?? "(unknown)"}`,
230
+ result.kind ? `kind=${result.kind}` : "",
231
+ ].filter(Boolean);
232
+ return `${index + 1}. ${details.join(", ")}`;
233
+ });
239
234
  return [
240
- "## Knowledge Recall Routing",
241
- "This Palz system prompt is the stable entrypoint for Knowledge Recall, even when workspace startup files were not read.",
242
- "For knowledge, explanatory, advisory, or consultation-style user questions, Recall is default-on. Only skip Recall for clear greetings, casual chat, weather, pure operation requests, ingest-trigger messages, or questions obviously unrelated to stored knowledge.",
243
- "When Recall is triggered, the first tool call for the user question MUST be `kb-recall.py topics`. Do not call `web_search` or `memory_search` before `topics` returns no_match or an unavailable/error state that permits fallback.",
244
- params.isGroup ? `Current Palz recall command: ${groupCommand}` : `Current Palz recall command: ${directCommand}`,
245
- "Replace `<用户问题>` with the current user's actual question text. Follow the runner JSON `next_step`, `repair`, and `answer_contract`; do not manually read wiki/index files or invent citation links.",
246
- "Fallback to web/general knowledge is allowed only after `no_match`, `local_not_found`, `remote_unavailable`, or `remote_error`. In group chat, missing owner/group-owner context must not fallback to direct/default.",
235
+ "[Knowledge Ingest Status]",
236
+ "The current message includes file(s) that have just been submitted for knowledge-base ingestion.",
237
+ "This status is informational only. You may refer to it if relevant, but continue handling the user's current request normally. Do not wait for ingestion to finish, and do not change your own intended actions solely because of this status.",
238
+ "Created ingest task(s):",
239
+ ...lines,
247
240
  ].join("\n");
248
241
  }
242
+ function resolveWorkspacePoolServiceName() {
243
+ const explicit = process.env.OPENCLAW_WORKSPACE_POOL_SERVICE_NAME?.trim();
244
+ if (explicit)
245
+ return explicit;
246
+ const currentHostname = getHostname().trim();
247
+ const parts = currentHostname.split("-").filter(Boolean);
248
+ if (parts.length >= 3)
249
+ return parts.slice(0, 3).join("-");
250
+ return currentHostname || "127.0.0.1";
251
+ }
252
+ function resolveKbIngestCallbackUrl(config) {
253
+ if (typeof config.controlPort === "number" && config.controlPort > 0) {
254
+ const host = resolveWorkspacePoolServiceName();
255
+ return `http://${host}:${config.controlPort}/admin/kb-ingest/callback`;
256
+ }
257
+ return undefined;
258
+ }
249
259
  export async function handlePalzMessage(params) {
250
260
  const { msg } = params;
251
261
  // 在当前 trace context(由 monitor.ts ws_recv span 建立)下创建子 span
@@ -268,7 +278,7 @@ export async function handlePalzMessage(params) {
268
278
  });
269
279
  }
270
280
  async function _handlePalzMessageInner(params) {
271
- const { cfg, msg, runtime, accountId, agentId } = params;
281
+ const { cfg, msg, runtime, accountId, agentId, suppressAck } = params;
272
282
  const log = typeof runtime?.log === "function" ? runtime.log : console.log;
273
283
  const error = typeof runtime?.error === "function" ? runtime.error : console.error;
274
284
  const tag = `palz[${accountId}]`;
@@ -352,25 +362,27 @@ async function _handlePalzMessageInner(params) {
352
362
  }
353
363
  // 立即发送确认消息(在入队之前,不受队列阻塞影响)
354
364
  const groupId = isGroup ? msg.conversation_id : undefined;
355
- try {
356
- log(`${tag}: [ACK] 发送确认消息 conv=${msg.conversation_id} sender=${msg.sender_id}`);
357
- await sendToPalzIM({
358
- config: account.config,
359
- conversationId: msg.conversation_id,
360
- content: "收到,正在思考中…",
361
- conversationType: msg.conversation_type || "direct",
362
- msgId: msg.msg_id,
363
- senderId: msg.sender_id,
364
- msgType: msg.msg_type,
365
- groupId,
366
- lobsterId: msg.lobster_id,
367
- palzMsgType: "thinking",
368
- passthrough: buildPassthroughFromMsg(msg),
369
- });
370
- log(`${tag}: [ACK] 确认消息发送成功`);
371
- }
372
- catch (err) {
373
- log(`${tag}: [ACK] 确认消息发送失败(不影响主流程): ${String(err)}`);
365
+ if (!suppressAck) {
366
+ try {
367
+ log(`${tag}: [ACK] 发送确认消息 conv=${msg.conversation_id} sender=${msg.sender_id}`);
368
+ await sendToPalzIM({
369
+ config: account.config,
370
+ conversationId: msg.conversation_id,
371
+ content: "收到,正在思考中…",
372
+ conversationType: msg.conversation_type || "direct",
373
+ msgId: msg.msg_id,
374
+ senderId: msg.sender_id,
375
+ msgType: msg.msg_type,
376
+ groupId,
377
+ lobsterId: msg.lobster_id,
378
+ palzMsgType: "thinking",
379
+ passthrough: buildPassthroughFromMsg(msg),
380
+ });
381
+ log(`${tag}: [ACK] 确认消息发送成功`);
382
+ }
383
+ catch (err) {
384
+ log(`${tag}: [ACK] 确认消息发送失败(不影响主流程): ${String(err)}`);
385
+ }
374
386
  }
375
387
  // 入队(按 agentId 隔离,不同 agent 并行处理)
376
388
  const queueKey = isGroup
@@ -455,9 +467,37 @@ async function _dispatchPalzMessageInner(params) {
455
467
  span?.addEvent(step4Input);
456
468
  const mediaList = await resolvePalzMediaList(msg.content, log);
457
469
  let mediaPayload = buildMediaPayload(mediaList);
458
- const step4Output = `[STEP 4 输出] mediaList=${JSON.stringify(mediaList.map((m) => ({ path: m.path, contentType: m.contentType })))} mediaPayload=${JSON.stringify(mediaPayload)}`;
470
+ const fileMd5ByUrl = new Map(mediaList
471
+ .filter((m) => m.sourceUrl && m.md5)
472
+ .map((m) => [m.sourceUrl, m.md5]));
473
+ const step4Output = `[STEP 4 输出] mediaList=${JSON.stringify(mediaList.map((m) => ({ path: m.path, contentType: m.contentType, sourceUrl: m.sourceUrl, md5: m.md5 })))} mediaPayload=${JSON.stringify(mediaPayload)}`;
459
474
  log(`${tag}: ${step4Output}`);
460
475
  span?.addEvent(step4Output);
476
+ // STEP 4b: 同步提交知识库入库;单次请求内部 3s 超时,超时只打日志并继续后续流程
477
+ let kbIngestNoticeBody = "";
478
+ const kbEngineUrl = config.kbEngineUrl;
479
+ if (kbEngineUrl) {
480
+ const kbFileUrls = extractFileUrls(msg.content);
481
+ if (kbFileUrls.length > 0) {
482
+ log(`${tag}: [STEP 4b kb-ingest] 触发入库 files=${kbFileUrls.length} md5=${fileMd5ByUrl.size}/${kbFileUrls.length} engine=${kbEngineUrl}`);
483
+ const ingestResults = await ingestFilesToKbEngine({
484
+ kbEngineUrl,
485
+ msg,
486
+ fileUrls: kbFileUrls,
487
+ fileMd5ByUrl,
488
+ accountId,
489
+ agentId,
490
+ callbackUrl: resolveKbIngestCallbackUrl(config),
491
+ log,
492
+ });
493
+ const createdTasks = ingestResults.filter((result) => result.taskCreated === true);
494
+ log(`${tag}: [STEP 4b kb-ingest] 完成 results=${ingestResults.length} task_created=${createdTasks.length}`);
495
+ if (createdTasks.length > 0) {
496
+ kbIngestNoticeBody = buildKbIngestNoticeMessage(createdTasks);
497
+ span?.addEvent(`[STEP 4b kb-ingest] 插入消息提示 task_created=${createdTasks.length}`);
498
+ }
499
+ }
500
+ }
461
501
  // STEP 5: 解析路由
462
502
  const peerKind = isGroup ? "group" : "direct";
463
503
  const routeInput = { cfg: "(cfg)", channel: "palz-connector", accountId, peer: { kind: peerKind, id: peerId } };
@@ -500,6 +540,15 @@ async function _dispatchPalzMessageInner(params) {
500
540
  const step6aOutput = `[STEP 6a 输出] envelope body (len=${body.length}): "${body.slice(0, 200)}"`;
501
541
  log(`${tag}: ${step6aOutput}`);
502
542
  span?.addEvent(step6aOutput);
543
+ const kbIngestNoticeEnvelope = kbIngestNoticeBody
544
+ ? core.channel.reply.formatAgentEnvelope({
545
+ channel: "Palz",
546
+ from: "kb-engine",
547
+ timestamp: new Date(),
548
+ body: kbIngestNoticeBody,
549
+ envelope: envelopeOptions,
550
+ })
551
+ : "";
503
552
  // STEP 6b: 构建 inbound context
504
553
  const palzFrom = `palz:${msg.sender_id}:${msg.conversation_id}`;
505
554
  // 新格式: {conversationType}:{senderId}:{lobsterId}:{conversationId}
@@ -520,12 +569,12 @@ async function _dispatchPalzMessageInner(params) {
520
569
  // 群聊历史:将积攒的未@消息拼入 Body 上下文(仅 groupContextCache=true 时生效)
521
570
  const groupContextCacheEnabled = account.config.groupContextCache !== false;
522
571
  const historyKey = isGroup && groupContextCacheEnabled ? `${effectiveAgentId}:${msg.conversation_id}` : undefined;
523
- let combinedBody = body;
572
+ let combinedBody = kbIngestNoticeEnvelope ? `${kbIngestNoticeEnvelope}\n\n${body}` : body;
524
573
  if (isGroup && historyKey && groupContextCacheEnabled) {
525
574
  log(`${tag}: [STEP 6b 群聊历史] 开始构建, historyKey=${historyKey} bodyLen=${body.length}`);
526
575
  combinedBody = buildGroupHistoryContext({
527
576
  historyKey,
528
- currentMessage: body,
577
+ currentMessage: combinedBody,
529
578
  formatEntry: (entry) => core.channel.reply.formatAgentEnvelope({
530
579
  channel: "Palz",
531
580
  from: entry.sender,
@@ -606,16 +655,7 @@ async function _dispatchPalzMessageInner(params) {
606
655
  log(`${tag}: ${step6bCtx}`);
607
656
  span?.addEvent(step6bCtx);
608
657
  const showProcess = config.showProcess === true;
609
- const groupSystemPromptParts = [buildFileSharingSystemPrompt()];
610
- if (effectiveAgentId === "main") {
611
- groupSystemPromptParts.unshift(buildKnowledgeRecallSystemPrompt({
612
- isGroup,
613
- groupId,
614
- ownerId: typeof msg.owner_id === "string" ? msg.owner_id : undefined,
615
- groupOwnerId: typeof msg.group_owner_id === "string" ? msg.group_owner_id : undefined,
616
- }));
617
- }
618
- const groupSystemPrompt = groupSystemPromptParts.join("\n\n");
658
+ const groupSystemPrompt = buildFileSharingSystemPrompt();
619
659
  const ctx = core.channel.reply.finalizeInboundContext({
620
660
  Body: combinedBody,
621
661
  BodyForAgent: messageBody,
@@ -872,3 +912,51 @@ export function cleanupAgentCaches(agentIdToClean) {
872
912
  }
873
913
  reasoningScannedAgents.delete(agentIdToClean);
874
914
  }
915
+ export async function handleKbTaskCallback(params) {
916
+ const log = params.log ?? (typeof params.runtime?.log === "function" ? params.runtime.log : console.log);
917
+ const error = params.error ?? (typeof params.runtime?.error === "function" ? params.runtime.error : console.error);
918
+ const taskId = extractKbTaskId(params.body);
919
+ if (!taskId) {
920
+ return { handled: false, reason: "missing task_id" };
921
+ }
922
+ const taskContext = resolveKbTaskContext(taskId);
923
+ if (!taskContext) {
924
+ log(`palz-kb-callback: task_id=${taskId} ignored, context not found`);
925
+ return { handled: false, taskId, reason: "task context not found" };
926
+ }
927
+ const content = extractKbTaskCallbackContent(params.body);
928
+ if (!content) {
929
+ log(`palz-kb-callback: task_id=${taskId} ignored, callback content missing`);
930
+ return { handled: false, taskId, reason: "missing callback content" };
931
+ }
932
+ const original = taskContext.msg;
933
+ const callbackMsg = {
934
+ ...original,
935
+ event: "message",
936
+ content,
937
+ msg_id: `kb_callback_${taskId}_${Date.now()}`,
938
+ timestamp: Date.now(),
939
+ stream: false,
940
+ mentioned_bot: true,
941
+ agent_id: taskContext.agentId,
942
+ sender_id: original.sender_id || "kb-engine",
943
+ sender_name: "Knowledge Base Engine",
944
+ };
945
+ log(`palz-kb-callback: dispatching task_id=${taskId} agent=${taskContext.agentId} conv=${callbackMsg.conversation_id}`);
946
+ try {
947
+ consumeKbTaskContext(taskId);
948
+ await handlePalzMessage({
949
+ cfg: params.cfg,
950
+ runtime: params.runtime,
951
+ msg: callbackMsg,
952
+ accountId: taskContext.accountId,
953
+ agentId: taskContext.agentId,
954
+ suppressAck: true,
955
+ });
956
+ return { handled: true, taskId };
957
+ }
958
+ catch (err) {
959
+ error(`palz-kb-callback: dispatch failed task_id=${taskId}: ${String(err)}`);
960
+ throw err;
961
+ }
962
+ }
package/src/channel.js CHANGED
@@ -108,7 +108,8 @@ export const palzPlugin = {
108
108
  log: logInfo,
109
109
  error,
110
110
  });
111
- // 停止服务:断开所有 WS 且不再重连。幂等,可被 HTTP 控制接口或框架层 abort 多次调用。
111
+ // Stop service: disconnect all WS connections and prevent scanner-triggered reconnects.
112
+ // Idempotent because it may be called from the HTTP control port or framework abort.
112
113
  let stopped = false;
113
114
  const doStop = () => {
114
115
  if (stopped)
@@ -118,8 +119,8 @@ export const palzPlugin = {
118
119
  scanner.stop();
119
120
  manager.shutdown();
120
121
  };
121
- // 可选启动 HTTP 控制服务(仅当配置了 controlPort)。停止服务时保留该 HTTP 服务,
122
- // 仅在框架层 abort 时才关闭。
122
+ // Optional HTTP control service. /admin/stop keeps this server alive so callers can
123
+ // inspect /admin/health afterwards; framework abort closes it.
123
124
  let controlServer = null;
124
125
  const controlPort = baseConfig.controlPort;
125
126
  if (typeof controlPort === "number" && controlPort > 0) {
@@ -130,6 +131,8 @@ export const palzPlugin = {
130
131
  const activeUsers = manager.getActiveUsers();
131
132
  return { stopped, activeConnections: activeUsers.length, activeUsers };
132
133
  },
134
+ cfg: ctx.cfg,
135
+ runtime: ctx.runtime,
133
136
  log: logInfo,
134
137
  error,
135
138
  });
package/src/config.js CHANGED
@@ -67,6 +67,7 @@ export function resolvePalzConfig(_cfg, botId) {
67
67
  sessionTimeout: file.sessionTimeout ?? DEFAULT_SESSION_TIMEOUT,
68
68
  groupContextCache: file.groupContextCache !== false,
69
69
  showProcess: file.showProcess === true,
70
+ kbEngineUrl: file.kbEngineUrl || "",
70
71
  controlPort: file.controlPort,
71
72
  };
72
73
  if (!_configLoggedOnce) {
@@ -1,28 +1,47 @@
1
1
  /**
2
- * Palz Connector HTTP 控制服务
2
+ * Palz Connector HTTP control service.
3
3
  *
4
- * 提供一个独立的 HTTP 端口,供外部主动控制插件:
5
- * - POST /admin/stop 断开所有 WS 连接且不再重连(停止服务),幂等
6
- * - GET /admin/health 查询当前状态(是否已停止 / 活跃连接数)
7
- *
8
- * 与 WS 连接 / 消息处理 / reconcile 完全隔离:只持有 onStop / getStatus 回调,
9
- * 不碰任何业务数据;只有配置了端口才启动;启动错误只记日志不影响插件主流程。
10
- *
11
- * 统一响应结构:{ code, err_message, data },code=0 表示成功,非 0 为错误码。
12
- * HTTP status 一律 200,业务结果由 code 表达。
4
+ * Exposes an optional admin port for stopping all WS connections without
5
+ * affecting the normal channel/message code paths.
13
6
  */
14
7
  import http from "node:http";
8
+ import { handleKbTaskCallback } from "./bot.js";
9
+ import { getKbTaskRegistryStatus } from "./kb-task-callback.js";
15
10
  function sendJson(res, body) {
16
11
  const payload = JSON.stringify(body);
17
12
  res.writeHead(200, { "Content-Type": "application/json" });
18
13
  res.end(payload);
19
14
  }
15
+ async function readRequestBody(req) {
16
+ const chunks = [];
17
+ for await (const chunk of req) {
18
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
19
+ }
20
+ return Buffer.concat(chunks).toString("utf-8");
21
+ }
22
+ function parseCallbackBody(req, raw) {
23
+ const contentType = String(req.headers["content-type"] || "").toLowerCase();
24
+ if (!raw.trim())
25
+ return {};
26
+ if (contentType.includes("application/x-www-form-urlencoded")) {
27
+ const form = new URLSearchParams(raw);
28
+ const payload = form.get("payload");
29
+ if (payload)
30
+ return JSON.parse(payload);
31
+ return Object.fromEntries(form.entries());
32
+ }
33
+ if (contentType.includes("multipart/form-data")) {
34
+ const match = raw.match(/name="payload"(?:\r?\n[^\r\n]*)?\r?\n\r?\n([\s\S]*?)\r?\n--/);
35
+ if (match?.[1])
36
+ return JSON.parse(match[1].trim());
37
+ }
38
+ return JSON.parse(raw);
39
+ }
20
40
  export function startControlServer(opts) {
21
41
  const log = opts.log ?? console.log;
22
42
  const error = opts.error ?? console.error;
23
- const server = http.createServer((req, res) => {
43
+ const server = http.createServer(async (req, res) => {
24
44
  const method = (req.method || "GET").toUpperCase();
25
- // 去掉查询串,只取 path
26
45
  const path = (req.url || "/").split("?")[0].replace(/\/+$/, "") || "/";
27
46
  try {
28
47
  if (path === "/admin/stop") {
@@ -47,7 +66,36 @@ export function startControlServer(opts) {
47
66
  return;
48
67
  }
49
68
  const status = opts.getStatus();
50
- sendJson(res, { code: 0, err_message: "", data: { ...status } });
69
+ sendJson(res, {
70
+ code: 0,
71
+ err_message: "",
72
+ data: { ...status, kbTasks: getKbTaskRegistryStatus() },
73
+ });
74
+ return;
75
+ }
76
+ if (path === "/admin/kb-ingest/callback") {
77
+ if (method !== "POST") {
78
+ sendJson(res, { code: 405, err_message: "method not allowed", data: {} });
79
+ return;
80
+ }
81
+ if (!opts.runtime) {
82
+ sendJson(res, { code: 503, err_message: "runtime not available", data: {} });
83
+ return;
84
+ }
85
+ const rawBody = await readRequestBody(req);
86
+ const body = parseCallbackBody(req, rawBody);
87
+ const result = await handleKbTaskCallback({
88
+ cfg: opts.cfg,
89
+ runtime: opts.runtime,
90
+ body,
91
+ log,
92
+ error,
93
+ });
94
+ sendJson(res, {
95
+ code: result.handled ? 0 : 404,
96
+ err_message: result.handled ? "" : (result.reason ?? "not handled"),
97
+ data: result,
98
+ });
51
99
  return;
52
100
  }
53
101
  sendJson(res, { code: 404, err_message: "not found", data: {} });
@@ -60,7 +108,6 @@ export function startControlServer(opts) {
60
108
  catch { }
61
109
  }
62
110
  });
63
- // 端口占用等错误只记日志,不让插件崩溃。
64
111
  server.on("error", (err) => {
65
112
  error(`control-server: server error on port ${opts.port}: ${err?.message ?? err}`);
66
113
  });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Palz Connector → uniclaw-kb-engine 知识库入库
3
+ *
4
+ * 当 IM 消息携带文件时,调用 engine 的 ingest API
5
+ * (POST /uniclaw-kb-engine/api/v1/ingest-tasks),把原始 file_url 直接交给 engine 入库。
6
+ * engine 自行做文件类型路由 / 去重 / 跳过,本模块不过滤文件类型。
7
+ *
8
+ * 本模块的所有调用都不阻塞、不抛出,失败只打日志,绝不影响 AI 对话流程。
9
+ */
10
+ import { registerKbTaskContext } from "./kb-task-callback.js";
11
+ /** 调用 engine ingest API 的超时时间(毫秒)。
12
+ * ingest 为异步受理(暂存+入队即返回),但走 file_url 时 engine 需从 URL
13
+ * 拉取文件再上传到自己的 OSS;超时只记录日志并继续后续 AI 流程。 */
14
+ const INGEST_TIMEOUT_MS = 3000;
15
+ const KB_ENGINE_API_PREFIX = "/uniclaw-kb-engine/api/v1";
16
+ const LEGACY_KB_ENGINE_API_PREFIX = "/api/v1";
17
+ const KB_ENGINE_INGEST_ENDPOINT = "ingest-tasks";
18
+ /**
19
+ * 从消息 content 中提取所有 type:"file" 的 file_url。
20
+ * 取值逻辑与 media.ts 的 resolvePalzMediaList 保持一致。
21
+ */
22
+ export function extractFileUrls(content) {
23
+ if (typeof content === "string" || !Array.isArray(content))
24
+ return [];
25
+ const urls = [];
26
+ for (const part of content) {
27
+ if (part.type === "file") {
28
+ const filePart = part;
29
+ const url = filePart.file_url?.url;
30
+ if (url)
31
+ urls.push(url);
32
+ }
33
+ }
34
+ return urls;
35
+ }
36
+ function resolveKbEngineIngestUrl(kbEngineUrl) {
37
+ const normalizedBase = kbEngineUrl.trim().replace(/\/+$/, "");
38
+ if (normalizedBase.endsWith(KB_ENGINE_API_PREFIX)) {
39
+ return `${normalizedBase}/${KB_ENGINE_INGEST_ENDPOINT}`;
40
+ }
41
+ if (normalizedBase.endsWith(LEGACY_KB_ENGINE_API_PREFIX)) {
42
+ const hostBase = normalizedBase.slice(0, -LEGACY_KB_ENGINE_API_PREFIX.length).replace(/\/+$/, "");
43
+ return `${hostBase}${KB_ENGINE_API_PREFIX}/${KB_ENGINE_INGEST_ENDPOINT}`;
44
+ }
45
+ return `${normalizedBase}${KB_ENGINE_API_PREFIX}/${KB_ENGINE_INGEST_ENDPOINT}`;
46
+ }
47
+ /** 构造单个 file_url 的 ingest payload;缺少 engine 必填字段时返回 null(跳过)。 */
48
+ function buildPayload(msg, fileUrl, fileMd5, callbackUrl, log) {
49
+ const ownerId = (msg.owner_id || "").trim();
50
+ const conversationId = (msg.conversation_id || "").trim();
51
+ const senderId = (msg.sender_id || "").trim();
52
+ const lobsterId = (msg.lobster_id || "").trim();
53
+ const msgId = (msg.msg_id || "").trim();
54
+ const lobsterType = (msg.lobster_type || "").trim();
55
+ const missing = [];
56
+ if (!ownerId)
57
+ missing.push("owner_id");
58
+ if (!conversationId)
59
+ missing.push("conversation_id");
60
+ if (!senderId)
61
+ missing.push("sender_id");
62
+ if (!lobsterId)
63
+ missing.push("lobster_id");
64
+ if (!msgId)
65
+ missing.push("msg_id");
66
+ if (!lobsterType)
67
+ missing.push("lobster_type");
68
+ if (missing.length > 0) {
69
+ log?.(`palz-kb-ingest: [skip] msg_id=${msgId || "(none)"} 缺少必填字段: ${missing.join(",")}`);
70
+ return null;
71
+ }
72
+ const isGroup = (msg.conversation_type || "").toLowerCase() === "group";
73
+ const payload = {
74
+ owner_id: ownerId,
75
+ conversation_id: conversationId,
76
+ sender_id: senderId,
77
+ lobster_id: lobsterId,
78
+ msg_id: msgId,
79
+ lobster_type: lobsterType,
80
+ conversation_type: (msg.conversation_type || "direct").trim(),
81
+ file_url: fileUrl,
82
+ };
83
+ if (fileMd5)
84
+ payload.file_md5 = fileMd5;
85
+ if (callbackUrl)
86
+ payload.callback_url = callbackUrl;
87
+ if (msg.sender_name)
88
+ payload.sender_name = msg.sender_name;
89
+ if (msg.sender_account_type)
90
+ payload.sender_account_type = msg.sender_account_type;
91
+ if (msg.lobster_name)
92
+ payload.lobster_name = msg.lobster_name;
93
+ if (msg.group_name)
94
+ payload.group_name = msg.group_name;
95
+ if (isGroup) {
96
+ const groupId = (msg.group_id || msg.conversation_id || "").trim();
97
+ const groupOwnerId = (msg.group_owner_id || "").trim();
98
+ const groupMembersList = (msg.group_members_list || "").trim();
99
+ const groupMissing = [];
100
+ if (!groupOwnerId)
101
+ groupMissing.push("group_owner_id");
102
+ if (!groupMembersList)
103
+ groupMissing.push("group_members_list");
104
+ if (groupMissing.length > 0) {
105
+ log?.(`palz-kb-ingest: [skip] msg_id=${msgId} 群聊缺少必填字段: ${groupMissing.join(",")}`);
106
+ return null;
107
+ }
108
+ payload.group_id = groupId;
109
+ payload.group_owner_id = groupOwnerId;
110
+ payload.group_members_list = groupMembersList;
111
+ }
112
+ return payload;
113
+ }
114
+ /** 发送单个 ingest 请求,带 3s 超时;失败只打日志,绝不抛出。 */
115
+ async function postIngest(kbEngineUrl, payload, log) {
116
+ const baseResult = {
117
+ fileUrl: payload.file_url,
118
+ fileMd5: payload.file_md5,
119
+ submitted: false,
120
+ taskCreated: false,
121
+ };
122
+ const url = resolveKbEngineIngestUrl(kbEngineUrl);
123
+ const payloadJson = JSON.stringify(payload);
124
+ const form = new FormData();
125
+ form.append("payload", payloadJson);
126
+ // 完整请求体日志,便于排查字段映射 / engine 拒绝原因
127
+ log?.(`palz-kb-ingest: [request] POST ${url} payload=${payloadJson}`);
128
+ const controller = new AbortController();
129
+ const timer = setTimeout(() => controller.abort(), INGEST_TIMEOUT_MS);
130
+ try {
131
+ const resp = await fetch(url, { method: "POST", body: form, signal: controller.signal });
132
+ const rawBody = await resp.text();
133
+ // 完整响应体日志(含 HTTP 状态码与原始 body,无论 JSON 与否)
134
+ log?.(`palz-kb-ingest: [response] msg_id=${payload.msg_id} http=${resp.status} body=${rawBody}`);
135
+ let body = null;
136
+ try {
137
+ body = JSON.parse(rawBody);
138
+ }
139
+ catch {
140
+ /* 非 JSON 响应,已在 [response] 中打出原始 body */
141
+ }
142
+ if (!resp.ok) {
143
+ log?.(`palz-kb-ingest: [warn] HTTP ${resp.status} msg_id=${payload.msg_id} url=${payload.file_url}`);
144
+ return { ...baseResult, error: `HTTP ${resp.status}` };
145
+ }
146
+ if (!body || typeof body.code !== "number") {
147
+ log?.(`palz-kb-ingest: [warn] engine 响应格式异常 msg_id=${payload.msg_id}`);
148
+ return { ...baseResult, error: "invalid engine response" };
149
+ }
150
+ const engineBody = body;
151
+ if (engineBody.code !== 0) {
152
+ const errMessage = engineBody.err_message || `engine code ${engineBody.code}`;
153
+ log?.(`palz-kb-ingest: [warn] engine 拒绝 msg_id=${payload.msg_id} code=${engineBody.code} err=${engineBody.err_message}`);
154
+ return { ...baseResult, error: errMessage };
155
+ }
156
+ const data = engineBody.data ?? {};
157
+ const taskId = data.task_id;
158
+ const state = data.state;
159
+ const taskCreated = data.task_created === true;
160
+ const dup = data.duplicate_submit === true ? " duplicate_submit=true" : "";
161
+ const skipped = data.skipped === true ? ` skipped=true reason=${data.reason ?? "(none)"}` : "";
162
+ log?.(`palz-kb-ingest: [ok] msg_id=${payload.msg_id} task_id=${taskId ?? "(none)"} state=${state ?? "(none)"} task_created=${taskCreated}${dup}${skipped}`);
163
+ return {
164
+ ...baseResult,
165
+ submitted: true,
166
+ taskCreated,
167
+ taskId,
168
+ state,
169
+ kind: data.kind,
170
+ duplicateSubmit: data.duplicate_submit === true,
171
+ skipped: data.skipped === true,
172
+ skipReason: data.reason,
173
+ error: data.error,
174
+ };
175
+ }
176
+ catch (err) {
177
+ const reason = err?.name === "AbortError" ? `超时(${INGEST_TIMEOUT_MS}ms)` : err?.message;
178
+ log?.(`palz-kb-ingest: [error] msg_id=${payload.msg_id} url=${payload.file_url} error=${reason}`);
179
+ return {
180
+ ...baseResult,
181
+ timedOut: err?.name === "AbortError",
182
+ error: reason || "unknown error",
183
+ };
184
+ }
185
+ finally {
186
+ clearTimeout(timer);
187
+ }
188
+ }
189
+ /**
190
+ * 把消息中的文件送入 kb-engine 知识库。
191
+ * 内部吞掉所有错误,仅打日志并返回非创建结果,绝不抛出。
192
+ */
193
+ export async function ingestFilesToKbEngine(params) {
194
+ const { kbEngineUrl, msg, fileUrls, fileMd5ByUrl, accountId, agentId, callbackUrl, log } = params;
195
+ if (!kbEngineUrl || fileUrls.length === 0)
196
+ return [];
197
+ log?.(`palz-kb-ingest: [start] msg_id=${msg.msg_id} files=${fileUrls.length} engine=${kbEngineUrl}`);
198
+ const results = [];
199
+ for (const fileUrl of fileUrls) {
200
+ const fileMd5 = fileMd5ByUrl?.get(fileUrl);
201
+ const payload = buildPayload(msg, fileUrl, fileMd5, callbackUrl, log);
202
+ if (!payload) {
203
+ results.push({
204
+ fileUrl,
205
+ fileMd5,
206
+ submitted: false,
207
+ taskCreated: false,
208
+ error: "payload skipped",
209
+ });
210
+ continue;
211
+ }
212
+ const result = await postIngest(kbEngineUrl, payload, log);
213
+ results.push(result);
214
+ if (result.taskCreated && result.taskId && accountId && agentId) {
215
+ registerKbTaskContext({
216
+ taskId: result.taskId,
217
+ accountId,
218
+ agentId,
219
+ msg,
220
+ fileUrl,
221
+ fileMd5,
222
+ log,
223
+ });
224
+ }
225
+ }
226
+ return results;
227
+ }
@@ -0,0 +1,53 @@
1
+ const DEFAULT_TASK_TTL_MS = 24 * 60 * 60 * 1000;
2
+ const MAX_TASK_CONTEXTS = 5000;
3
+ const taskContexts = new Map();
4
+ function cleanupExpired(now = Date.now()) {
5
+ for (const [taskId, context] of taskContexts) {
6
+ if (context.expiresAt <= now)
7
+ taskContexts.delete(taskId);
8
+ }
9
+ while (taskContexts.size > MAX_TASK_CONTEXTS) {
10
+ const oldest = taskContexts.keys().next().value;
11
+ if (oldest === undefined)
12
+ break;
13
+ taskContexts.delete(oldest);
14
+ }
15
+ }
16
+ export function registerKbTaskContext(params) {
17
+ const taskId = params.taskId.trim();
18
+ if (!taskId)
19
+ return;
20
+ const now = Date.now();
21
+ cleanupExpired(now);
22
+ taskContexts.set(taskId, {
23
+ taskId,
24
+ accountId: params.accountId,
25
+ agentId: params.agentId,
26
+ msg: { ...params.msg },
27
+ fileUrl: params.fileUrl,
28
+ fileMd5: params.fileMd5,
29
+ createdAt: now,
30
+ expiresAt: now + (params.ttlMs ?? DEFAULT_TASK_TTL_MS),
31
+ });
32
+ params.log?.(`palz-kb-callback: registered task_id=${taskId} agent=${params.agentId} conv=${params.msg.conversation_id}`);
33
+ }
34
+ export function resolveKbTaskContext(taskId) {
35
+ cleanupExpired();
36
+ return taskContexts.get(taskId.trim());
37
+ }
38
+ export function consumeKbTaskContext(taskId) {
39
+ const context = resolveKbTaskContext(taskId);
40
+ if (context)
41
+ taskContexts.delete(context.taskId);
42
+ return context;
43
+ }
44
+ export function getKbTaskRegistryStatus() {
45
+ cleanupExpired();
46
+ return { trackedTasks: taskContexts.size };
47
+ }
48
+ export function extractKbTaskId(body) {
49
+ return typeof body.task_id === "string" ? body.task_id.trim() : "";
50
+ }
51
+ export function extractKbTaskCallbackContent(body) {
52
+ return typeof body.content === "string" ? body.content.trim() : "";
53
+ }
package/src/media.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import fs from "fs";
9
9
  import path from "path";
10
10
  import os from "os";
11
+ import crypto from "crypto";
11
12
  import { uploadFileToOss, uploadBufferToOss } from "./oss.js";
12
13
  /** OpenClaw 允许访问的媒体目录 */
13
14
  const MEDIA_DIR = path.join(os.homedir(), ".openclaw", "media");
@@ -62,7 +63,7 @@ function sanitizeFileName(name) {
62
63
  const trimmedStem = cleaned.slice(0, MAX - ext.length);
63
64
  return trimmedStem + ext;
64
65
  }
65
- function saveBufferToMediaDir(buffer, contentType, ext, log, originalName) {
66
+ function saveBufferToMediaDir(buffer, contentType, ext, log, originalName, sourceUrl) {
66
67
  try {
67
68
  fs.mkdirSync(MEDIA_DIR, { recursive: true });
68
69
  let fileName;
@@ -76,8 +77,9 @@ function saveBufferToMediaDir(buffer, contentType, ext, log, originalName) {
76
77
  const filePath = path.join(MEDIA_DIR, fileName);
77
78
  fs.writeFileSync(filePath, buffer);
78
79
  const placeholder = isImageMime(contentType) ? "<media:image>" : `<media:file:${ext}>`;
79
- log?.(`palz-media: [saveToMediaDir] 成功: path=${filePath} size=${buffer.length}bytes mime=${contentType}${originalName ? ` originalName=${originalName}` : ""}`);
80
- return { path: filePath, contentType, placeholder };
80
+ const md5 = crypto.createHash("md5").update(buffer).digest("hex");
81
+ log?.(`palz-media: [saveToMediaDir] 成功: path=${filePath} size=${buffer.length}bytes mime=${contentType} md5=${md5}${originalName ? ` originalName=${originalName}` : ""}`);
82
+ return { path: filePath, contentType, placeholder, sourceUrl, md5 };
81
83
  }
82
84
  catch (err) {
83
85
  log?.(`palz-media: [saveToMediaDir] 失败: error=${err.message}`);
@@ -224,7 +226,7 @@ export async function resolvePalzMediaList(content, log) {
224
226
  log?.(`palz-media: [resolve] 处理第 ${i + 1}/${mediaUrls.length} 个媒体, urlType=${urlType} urlLen=${url.length}`);
225
227
  const fetched = await fetchUrlToBuffer(url, log);
226
228
  if (fetched) {
227
- const info = saveBufferToMediaDir(fetched.buffer, fetched.contentType, fetched.ext, log, fetched.originalName);
229
+ const info = saveBufferToMediaDir(fetched.buffer, fetched.contentType, fetched.ext, log, fetched.originalName, url);
228
230
  if (info) {
229
231
  results.push(info);
230
232
  log?.(`palz-media: [resolve] 第 ${i + 1} 完成: ${JSON.stringify(info)}`);
package/src/monitor.js CHANGED
@@ -167,14 +167,6 @@ export async function monitorPalzProvider(params) {
167
167
  lastPongAt = connectedAt;
168
168
  consecutive4002 = 0;
169
169
  log(`palz[${accountId}]: WebSocket connected, bot_id=${config.botId}`);
170
- reportPalzActivity({
171
- config,
172
- connectedAt: new Date(connectedAt),
173
- runtime,
174
- accountId,
175
- }).catch((err) => {
176
- error(`palz[${accountId}]: [ACTIVITY_REPORT] unhandled error: ${err.message ?? err}`);
177
- });
178
170
  pingInterval = setInterval(() => {
179
171
  if (ws.readyState === WebSocket.OPEN) {
180
172
  const timeSinceLastPong = Date.now() - lastPongAt;
@@ -203,6 +195,7 @@ export async function monitorPalzProvider(params) {
203
195
  }
204
196
  });
205
197
  ws.on("message", (data) => {
198
+ const receivedAt = new Date();
206
199
  const raw = data.toString();
207
200
  try {
208
201
  const msg = JSON.parse(raw);
@@ -215,7 +208,16 @@ export async function monitorPalzProvider(params) {
215
208
  tracer.startActiveSpan("palz.ws_recv", {}, parentCtx, (span) => {
216
209
  try {
217
210
  span.setAttribute("raw_message", raw);
218
- log(`palz[${accountId}]: [WS_RECV] #${messageCount} full_message=${raw.slice(0, 1000)} traceId=${span.spanContext().traceId}`);
211
+ log(`palz[${accountId}]: [WS_RECV] #${messageCount} full_message=${raw} traceId=${span.spanContext().traceId}`);
212
+ reportPalzActivity({
213
+ config,
214
+ msg,
215
+ receivedAt,
216
+ runtime,
217
+ accountId,
218
+ }).catch((err) => {
219
+ error(`palz[${accountId}]: [ACTIVITY_REPORT] unhandled error: ${err.message ?? err}`);
220
+ });
219
221
  // agent_id:从消息中取,缺失/空时默认为 {userId}-main
220
222
  let messageAgentId = typeof msg.agent_id === "string" ? msg.agent_id.trim() : "";
221
223
  if (!messageAgentId) {