palz-connector 1.2.8 → 1.3.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/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * - IM 通信协议保持不变(WebSocket 接收消息,HTTP POST 发送回复)
8
8
  */
9
9
 
10
+ import { initTracing } from "./src/tracing.js";
10
11
  import { palzPlugin } from "./src/channel.js";
11
12
  import { setPalzRuntime } from "./src/runtime.js";
12
13
 
@@ -21,6 +22,7 @@ const plugin = {
21
22
  },
22
23
  register(api: any) {
23
24
  const log = typeof api.runtime?.log === "function" ? api.runtime.log : console.log;
25
+ initTracing();
24
26
  log("palz-connector: register() called, saving runtime and registering channel");
25
27
  setPalzRuntime(api.runtime);
26
28
  api.registerChannel({ plugin: palzPlugin });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "palz-connector",
3
3
  "name": "Palz Connector Channel",
4
- "version": "1.2.8",
4
+ "version": "1.3.0",
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.2.8",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Palz IM 接入 OpenClaw — 模块化架构,基于 OpenClaw Runtime 消息管道",
@@ -16,6 +16,12 @@
16
16
  }
17
17
  },
18
18
  "dependencies": {
19
+ "@opentelemetry/api": "^1.9.1",
20
+ "@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
21
+ "@opentelemetry/resources": "^2.6.1",
22
+ "@opentelemetry/sdk-node": "^0.214.0",
23
+ "@opentelemetry/sdk-trace-node": "^2.6.1",
24
+ "@opentelemetry/semantic-conventions": "^1.40.0",
19
25
  "ali-oss": "^6.21.0",
20
26
  "ws": "^8.18.0"
21
27
  }
package/src/bot.ts CHANGED
@@ -14,7 +14,8 @@ import { getPalzRuntime } from "./runtime.js";
14
14
  import { resolvePalzAccount } from "./config.js";
15
15
  import { tryClaimMessage } from "./dedup.js";
16
16
  import { createPalzReplyDispatcher } from "./reply-dispatcher.js";
17
- import { resolvePalzMediaList } from "./media.js";
17
+ import { resolvePalzMediaList, resolveMediaLocalRoots } from "./media.js";
18
+ import { tracer, trace, context, SpanStatusCode } from "./tracing.js";
18
19
  import type { PalzMessageEvent, OpenAIContent, ContentPart, TextContentPart, PalzMediaInfo } from "./types.js";
19
20
 
20
21
  // ============ group_id 解析 ============
@@ -173,6 +174,28 @@ export interface HandlePalzMessageParams {
173
174
  }
174
175
 
175
176
  export async function handlePalzMessage(params: HandlePalzMessageParams): Promise<void> {
177
+ const { msg } = params;
178
+
179
+ // 在当前 trace context(由 monitor.ts ws_recv span 建立)下创建子 span
180
+ return tracer.startActiveSpan("palz.handleMessage", async (span) => {
181
+ span.setAttribute("msg_id", msg.msg_id);
182
+ span.setAttribute("sender_id", msg.sender_id);
183
+ span.setAttribute("conversation_type", msg.conversation_type);
184
+ span.setAttribute("agent_id", msg.agent_id || "main");
185
+ span.setAttribute("is_group", msg.conversation_type === "group");
186
+
187
+ try {
188
+ await _handlePalzMessageInner(params);
189
+ } catch (e) {
190
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) });
191
+ throw e;
192
+ } finally {
193
+ span.end();
194
+ }
195
+ });
196
+ }
197
+
198
+ async function _handlePalzMessageInner(params: HandlePalzMessageParams): Promise<void> {
176
199
  const { cfg, msg, runtime, accountId } = params;
177
200
  const log = typeof runtime?.log === "function" ? runtime.log : console.log;
178
201
  const error = typeof runtime?.error === "function" ? runtime.error : console.error;
@@ -181,11 +204,16 @@ export async function handlePalzMessage(params: HandlePalzMessageParams): Promis
181
204
  const isGroup = msg.conversation_type === "group";
182
205
  const effectiveAgentId = msg.agent_id || "main";
183
206
 
184
- log(`${tag}: [STEP 1/6 入站过滤] msg_id=${msg.msg_id} sender=${msg.sender_id} conv=${msg.conversation_id} type=${msg.conversation_type} agent=${effectiveAgentId}`);
207
+ const step1Filter = `[STEP 1/6 入站过滤] msg_id=${msg.msg_id} sender=${msg.sender_id} conv=${msg.conversation_id} type=${msg.conversation_type} agent=${effectiveAgentId}`;
208
+ log(`${tag}: ${step1Filter}`);
209
+ const span = trace.getActiveSpan();
210
+ span?.addEvent(step1Filter);
185
211
 
186
212
  const content = msg.content;
187
213
  if (!content) {
188
- log(`${tag}: [STEP 1 跳过] 原因=content为空`);
214
+ const skip = `[STEP 1 跳过] 原因=content为空`;
215
+ log(`${tag}: ${skip}`);
216
+ span?.addEvent(skip);
189
217
  return;
190
218
  }
191
219
 
@@ -197,10 +225,14 @@ export async function handlePalzMessage(params: HandlePalzMessageParams): Promis
197
225
  ? content.filter((p: ContentPart) => p.type === "file").length
198
226
  : 0;
199
227
 
200
- log(`${tag}: [STEP 1 解析] plainText="${plainText}" (len=${plainText.length}) hasMedia=${hasMedia} mediaCount=${mediaCount}`);
228
+ const step1Parse = `[STEP 1 解析] plainText="${plainText}" (len=${plainText.length}) hasMedia=${hasMedia} mediaCount=${mediaCount}`;
229
+ log(`${tag}: ${step1Parse}`);
230
+ span?.addEvent(step1Parse);
201
231
 
202
232
  if (!plainText && !hasMedia) {
203
- log(`${tag}: [STEP 1 跳过] 原因=无文本且无媒体`);
233
+ const skip = `[STEP 1 跳过] 原因=无文本且无媒体`;
234
+ log(`${tag}: ${skip}`);
235
+ span?.addEvent(skip);
204
236
  return;
205
237
  }
206
238
 
@@ -215,9 +247,11 @@ export async function handlePalzMessage(params: HandlePalzMessageParams): Promis
215
247
  const historyKey = `${effectiveAgentId}:${msg.conversation_id}`;
216
248
  const senderName = msg.sender_name || msg.sender_id;
217
249
  log(`${tag}: [STEP 1 群聊历史] 未@机器人, 准备记录历史 historyKey=${historyKey} mentioned_bot=${msg.mentioned_bot} conversation_type=${msg.conversation_type}`);
250
+ span?.addEvent(`[STEP 1 群聊历史] 未@机器人, 准备记录历史 historyKey=${historyKey} mentioned_bot=${msg.mentioned_bot} conversation_type=${msg.conversation_type}`);
218
251
  // 解析媒体文件(图片/文档等),缓存到本地
219
252
  const historyMediaList = await resolvePalzMediaList(msg.content, log);
220
253
  log(`${tag}: [STEP 1 群聊历史] 媒体解析完成: mediaCount=${historyMediaList.length}`);
254
+ span?.addEvent(`[STEP 1 群聊历史] 媒体解析完成: mediaCount=${historyMediaList.length}`);
221
255
  const bodyWithPlaceholder = historyMediaList.length > 0
222
256
  ? `${senderName}: ${plainText} ${historyMediaList.map(m => m.placeholder).join(' ')}`
223
257
  : `${senderName}: ${plainText}`;
@@ -233,36 +267,82 @@ export async function handlePalzMessage(params: HandlePalzMessageParams): Promis
233
267
  limit: DEFAULT_GROUP_HISTORY_LIMIT,
234
268
  log,
235
269
  });
236
- log(`${tag}: [STEP 1 跳过] 原因=群聊中未@机器人, 已记录到历史 historyKey=${historyKey} mediaCount=${historyMediaList.length}`);
270
+ const skipHistory = `[STEP 1 跳过] 原因=群聊中未@机器人, 已记录到历史 historyKey=${historyKey} mediaCount=${historyMediaList.length}`;
271
+ log(`${tag}: ${skipHistory}`);
272
+ span?.addEvent(skipHistory);
237
273
  return;
238
274
  }
239
275
  if (isGroup && !wasMentioned && !groupContextCacheEnabled) {
240
- log(`${tag}: [STEP 1 群聊直通] groupContextCache=false, 未@消息也将发送给AI mentioned_bot=${msg.mentioned_bot}`);
276
+ const passThrough = `[STEP 1 群聊直通] groupContextCache=false, 未@消息也将发送给AI mentioned_bot=${msg.mentioned_bot}`;
277
+ log(`${tag}: ${passThrough}`);
278
+ span?.addEvent(passThrough);
241
279
  }
242
280
 
243
281
  // 去重(按 agentId + conversationId 隔离,同群多 bot 场景)
244
282
  const claimed = tryClaimMessage(msg.msg_id, effectiveAgentId, msg.conversation_id);
245
- log(`${tag}: [STEP 2/6 去重] msg_id=${msg.msg_id} agent=${effectiveAgentId} conv=${msg.conversation_id} claimed=${claimed}`);
246
- if (!claimed) return;
283
+ const step2 = `[STEP 2/6 去重] msg_id=${msg.msg_id} agent=${effectiveAgentId} conv=${msg.conversation_id} claimed=${claimed}`;
284
+ log(`${tag}: ${step2}`);
285
+ span?.addEvent(step2);
286
+ if (!claimed) {
287
+ span?.addEvent(`[STEP 2 跳过] 原因=消息已被处理(去重)`);
288
+ return;
289
+ }
247
290
 
248
291
  // 入队(按 agentId 隔离,不同 agent 并行处理)
249
292
  const queueKey = isGroup
250
293
  ? `${effectiveAgentId}:${msg.conversation_id}`
251
294
  : `${effectiveAgentId}:${msg.sender_id}:${msg.conversation_id}`;
252
- log(`${tag}: [STEP 3/6 入队] queueKey="${queueKey}" isGroup=${isGroup}`);
295
+ const step3 = `[STEP 3/6 入队] queueKey="${queueKey}" isGroup=${isGroup}`;
296
+ log(`${tag}: ${step3}`);
297
+ span?.addEvent(step3);
253
298
 
299
+ // 捕获当前 trace context,enqueue 异步回调中恢复
300
+ const capturedCtx = context.active();
301
+
302
+ const enqueuedAt = Date.now();
254
303
  enqueue(queueKey, async () => {
255
304
  const startMs = Date.now();
305
+ const queueWaitMs = startMs - enqueuedAt;
306
+ if (queueWaitMs > 100) {
307
+ const waitLog = `[队列等待] msg_id=${msg.msg_id} queueKey=${queueKey} waitMs=${queueWaitMs}`;
308
+ log(`${tag}: ${waitLog}`);
309
+ span?.addEvent(waitLog);
310
+ }
256
311
  try {
257
- await dispatchPalzMessage({ cfg, msg, runtime, accountId });
258
- log(`${tag}: [完成] msg_id=${msg.msg_id} 总耗时=${Date.now() - startMs}ms`);
312
+ await context.with(capturedCtx, () =>
313
+ dispatchPalzMessage({ cfg, msg, runtime, accountId }),
314
+ );
315
+ const doneLog = `[完成] msg_id=${msg.msg_id} 总耗时=${Date.now() - startMs}ms queueWait=${queueWaitMs}ms`;
316
+ log(`${tag}: ${doneLog}`);
317
+ span?.addEvent(doneLog);
259
318
  } catch (err) {
260
- error(`${tag}: [失败] msg_id=${msg.msg_id} 总耗时=${Date.now() - startMs}ms error=${String(err)}`);
319
+ const failLog = `[失败] msg_id=${msg.msg_id} 总耗时=${Date.now() - startMs}ms queueWait=${queueWaitMs}ms error=${String(err)}`;
320
+ error(`${tag}: ${failLog}`);
321
+ span?.addEvent(failLog);
261
322
  }
262
323
  });
263
324
  }
264
325
 
265
326
  async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<void> {
327
+ const { msg, runtime } = params;
328
+ const log = typeof runtime?.log === "function" ? runtime.log : console.log;
329
+
330
+ return tracer.startActiveSpan("palz.dispatch", async (span) => {
331
+ try {
332
+ span.setAttribute("msg_id", msg.msg_id);
333
+ span.setAttribute("conversation_id", msg.conversation_id);
334
+ await _dispatchPalzMessageInner(params);
335
+ } catch (e) {
336
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) });
337
+ log(`palz: [dispatch span error] ${String(e)}`);
338
+ throw e;
339
+ } finally {
340
+ span.end();
341
+ }
342
+ });
343
+ }
344
+
345
+ async function _dispatchPalzMessageInner(params: HandlePalzMessageParams): Promise<void> {
266
346
  const { cfg, msg, runtime, accountId } = params;
267
347
  const log = typeof runtime?.log === "function" ? runtime.log : console.log;
268
348
  const tag = `palz[${accountId}]`;
@@ -287,15 +367,22 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
287
367
  const mediaCount = Array.isArray(msg.content)
288
368
  ? msg.content.filter((p: ContentPart) => p.type === "file").length
289
369
  : 0;
290
- log(`${tag}: [STEP 4/6 媒体解析] 输入: contentType=${typeof msg.content === "string" ? "string" : "array"} mediaCount=${mediaCount}`);
370
+ const span = trace.getActiveSpan();
371
+ const step4Input = `[STEP 4/6 媒体解析] 输入: contentType=${typeof msg.content === "string" ? "string" : "array"} mediaCount=${mediaCount}`;
372
+ log(`${tag}: ${step4Input}`);
373
+ span?.addEvent(step4Input);
291
374
  const mediaList = await resolvePalzMediaList(msg.content, log);
292
375
  let mediaPayload = buildMediaPayload(mediaList);
293
- log(`${tag}: [STEP 4 输出] mediaList=${JSON.stringify(mediaList.map((m) => ({ path: m.path, contentType: m.contentType })))} mediaPayload=${JSON.stringify(mediaPayload)}`);
376
+ const step4Output = `[STEP 4 输出] mediaList=${JSON.stringify(mediaList.map((m) => ({ path: m.path, contentType: m.contentType })))} mediaPayload=${JSON.stringify(mediaPayload)}`;
377
+ log(`${tag}: ${step4Output}`);
378
+ span?.addEvent(step4Output);
294
379
 
295
380
  // STEP 5: 解析路由
296
381
  const peerKind = isGroup ? "group" : "direct";
297
382
  const routeInput = { cfg: "(cfg)", channel: "palz-connector", accountId, peer: { kind: peerKind, id: peerId } };
298
- log(`${tag}: [STEP 5/6 路由解析] 输入: ${JSON.stringify(routeInput)} agent_id=${msg.agent_id ?? "(auto)"}`);
383
+ const step5Input = `[STEP 5/6 路由解析] 输入: ${JSON.stringify(routeInput)} agent_id=${msg.agent_id ?? "(auto)"}`;
384
+ log(`${tag}: ${step5Input}`);
385
+ span?.addEvent(step5Input);
299
386
  const route = core.channel.routing.resolveAgentRoute({
300
387
  cfg,
301
388
  channel: "palz-connector",
@@ -309,17 +396,23 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
309
396
  const oldAgentId = route.agentId;
310
397
  route.agentId = effectiveAgentId;
311
398
  route.sessionKey = route.sessionKey.replace(`agent:${oldAgentId}:`, `agent:${effectiveAgentId}:`);
312
- log(`${tag}: [STEP 5 覆盖] agentId: ${oldAgentId} -> ${effectiveAgentId}, sessionKey=${route.sessionKey} (${msg.agent_id ? "IM指定" : "默认main"})`);
399
+ const step5Override = `[STEP 5 覆盖] agentId: ${oldAgentId} -> ${effectiveAgentId}, sessionKey=${route.sessionKey} (${msg.agent_id ? "IM指定" : "默认main"})`;
400
+ log(`${tag}: ${step5Override}`);
401
+ span?.addEvent(step5Override);
313
402
  }
314
403
 
315
- log(`${tag}: [STEP 5 输出] agentId=${route.agentId} sessionKey=${route.sessionKey} accountId=${route.accountId} matchedBy=${route.matchedBy}`);
404
+ const step5Output = `[STEP 5 输出] agentId=${route.agentId} sessionKey=${route.sessionKey} accountId=${route.accountId} matchedBy=${route.matchedBy}`;
405
+ log(`${tag}: ${step5Output}`);
406
+ span?.addEvent(step5Output);
316
407
 
317
408
  // STEP 6a: 构建 envelope body
318
409
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
319
410
  const messageBody = `${senderName}: ${plainText}`;
320
411
  // 群聊 from 带上 conversation_id 以区分不同用户
321
412
  const envelopeFrom = isGroup ? `${msg.conversation_id}:${msg.sender_id}` : msg.sender_id;
322
- log(`${tag}: [STEP 6a/6 envelope构建] 输入: channel=Palz from=${envelopeFrom} messageBody="${messageBody.slice(0, 120)}" envelopeOptions=${JSON.stringify(envelopeOptions)}`);
413
+ const step6aInput = `[STEP 6a/6 envelope构建] 输入: channel=Palz from=${envelopeFrom} messageBody="${messageBody.slice(0, 120)}" envelopeOptions=${JSON.stringify(envelopeOptions)}`;
414
+ log(`${tag}: ${step6aInput}`);
415
+ span?.addEvent(step6aInput);
323
416
  const body = core.channel.reply.formatAgentEnvelope({
324
417
  channel: "Palz",
325
418
  from: envelopeFrom,
@@ -327,7 +420,9 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
327
420
  body: messageBody,
328
421
  envelope: envelopeOptions,
329
422
  });
330
- log(`${tag}: [STEP 6a 输出] envelope body (len=${body.length}): "${body.slice(0, 200)}"`);
423
+ const step6aOutput = `[STEP 6a 输出] envelope body (len=${body.length}): "${body.slice(0, 200)}"`;
424
+ log(`${tag}: ${step6aOutput}`);
425
+ span?.addEvent(step6aOutput);
331
426
 
332
427
  // STEP 6b: 构建 inbound context
333
428
  const palzFrom = `palz:${msg.sender_id}`;
@@ -343,7 +438,9 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
343
438
  authorizers: [{ configured: false, allowed: true }],
344
439
  })
345
440
  : undefined;
346
- log(`${tag}: [STEP 6b 命令授权] needsCommandAuth=${needsCommandAuth} commandAuthorized=${commandAuthorized}`);
441
+ const step6bAuth = `[STEP 6b 命令授权] needsCommandAuth=${needsCommandAuth} commandAuthorized=${commandAuthorized}`;
442
+ log(`${tag}: ${step6bAuth}`);
443
+ span?.addEvent(step6bAuth);
347
444
 
348
445
  const chatType = isGroup ? "group" : "direct";
349
446
 
@@ -388,7 +485,9 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
388
485
  timestamp: entry.timestamp,
389
486
  }))
390
487
  : undefined;
391
- log(`${tag}: [STEP 6b InboundHistory] count=${inboundHistory?.length ?? 0}`);
488
+ const step6bHistory = `[STEP 6b InboundHistory] count=${inboundHistory?.length ?? 0}`;
489
+ log(`${tag}: ${step6bHistory}`);
490
+ span?.addEvent(step6bHistory);
392
491
 
393
492
  // 构建 UntrustedContext:将 IM 消息的关键字段注入到 AI agent 的上下文中
394
493
  const untrustedContext: string[] = [
@@ -407,7 +506,9 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
407
506
  if (msg.owner_name) {
408
507
  untrustedContext.push(`owner_name: ${msg.owner_name}`);
409
508
  }
410
- log(`${tag}: [STEP 6b UntrustedContext] entries=${untrustedContext.length} values=${JSON.stringify(untrustedContext)}`);
509
+ const step6bCtx = `[STEP 6b UntrustedContext] entries=${untrustedContext.length} values=${JSON.stringify(untrustedContext)}`;
510
+ log(`${tag}: ${step6bCtx}`);
511
+ span?.addEvent(step6bCtx);
411
512
 
412
513
  const ctx = core.channel.reply.finalizeInboundContext({
413
514
  Body: combinedBody,
@@ -432,9 +533,22 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
432
533
  CommandAuthorized: commandAuthorized,
433
534
  OriginatingChannel: "palz-connector",
434
535
  OriginatingTo: palzTo,
536
+ GroupSystemPrompt: [
537
+ "## File Sharing",
538
+ "When you create or generate a file that the user requested (documents, images, presentations, spreadsheets, archives, etc.), you MUST include a MEDIA: token in your reply so the file gets delivered to the user.",
539
+ "Always write your normal text reply first (description, summary, or explanation), then append the MEDIA: line(s) at the end.",
540
+ "Format: place `MEDIA: <absolute-file-path>` on its own line at the end of your reply.",
541
+ "Example:",
542
+ " Here is the presentation you requested. It includes 5 slides covering the main topics.",
543
+ " MEDIA: /path/to/workspace/output.pptx",
544
+ "Without the MEDIA: line, the user only sees text and cannot download the file.",
545
+ "Multiple files: one MEDIA: line per file.",
546
+ ].join("\n"),
435
547
  ...mediaPayload,
436
548
  });
437
- log(`${tag}: [STEP 6b 输出] finalized context keys=[${Object.keys(ctx).join(",")}] CommandAuthorized=${ctx.CommandAuthorized}`);
549
+ const step6bOutput = `[STEP 6b 输出] finalized context keys=[${Object.keys(ctx).join(",")}] CommandAuthorized=${ctx.CommandAuthorized}`;
550
+ log(`${tag}: ${step6bOutput}`);
551
+ span?.addEvent(step6bOutput);
438
552
  ctx.metadata = {
439
553
  ...ctx.metadata,
440
554
  traceId: msg.msg_id,
@@ -449,7 +563,9 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
449
563
  enableStreaming: useStream,
450
564
  msgId: msg.msg_id,
451
565
  };
452
- log(`${tag}: [STEP 6c 创建dispatcher] 输入: ${JSON.stringify(dispatcherParams)}`);
566
+ const step6c = `[STEP 6c 创建dispatcher] 输入: ${JSON.stringify(dispatcherParams)}`;
567
+ log(`${tag}: ${step6c}`);
568
+ span?.addEvent(step6c);
453
569
 
454
570
  const { dispatcher, replyOptions, markDispatchIdle } = createPalzReplyDispatcher({
455
571
  cfg,
@@ -463,12 +579,15 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
463
579
  msgId: msg.msg_id,
464
580
  msgType: msg.msg_type,
465
581
  groupId,
582
+ mediaLocalRoots: resolveMediaLocalRoots(effectiveAgentId),
466
583
  });
467
584
 
468
585
  // STEP 6d: 分发消息给 AI
469
586
  // channel registry 守卫已在 index.ts 中通过 defineProperty 安装,
470
587
  // 每次读取 state.registry 时会自动注入 palz-connector channel。
471
- log(`${tag}: [STEP 6d AI分发] 开始 session=${route.sessionKey} stream=${useStream}`);
588
+ const step6dStart = `[STEP 6d AI分发] 开始 session=${route.sessionKey} stream=${useStream}`;
589
+ log(`${tag}: ${step6dStart}`);
590
+ span?.addEvent(step6dStart);
472
591
  const dispatchStartMs = Date.now();
473
592
 
474
593
  const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
@@ -489,7 +608,31 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
489
608
  }
490
609
 
491
610
  const dispatchElapsedMs = Date.now() - dispatchStartMs;
492
- log(
493
- `${tag}: [STEP 6d 输出] queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)} 耗时=${dispatchElapsedMs}ms`,
494
- );
611
+ const step6dOutput = `[STEP 6d 输出] queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)} 耗时=${dispatchElapsedMs}ms`;
612
+ log(`${tag}: ${step6dOutput}`);
613
+ span?.addEvent(step6dOutput);
614
+ span?.setAttribute("dispatch_elapsed_ms", dispatchElapsedMs);
615
+ if (!queuedFinal && (!counts || Object.keys(counts).length === 0)) {
616
+ const noReply = `[STEP 6d 警告] AI 未产生回复 session=${route.sessionKey}`;
617
+ log(`${tag}: ${noReply}`);
618
+ span?.addEvent(noReply);
619
+ }
620
+
621
+ // 解析 sessionFile 路径,写入 trace
622
+ try {
623
+ const agentId = route.agentId;
624
+ const storePath = core.agent.session.resolveStorePath(undefined, { agentId });
625
+ const store = core.agent.session.loadSessionStore(storePath, { skipCache: true });
626
+ const normalizedKey = route.sessionKey.trim().toLowerCase();
627
+ const entry = store[normalizedKey] ?? store[route.sessionKey];
628
+ if (entry?.sessionId) {
629
+ const sessionFile = core.agent.session.resolveSessionFilePath(entry.sessionId, entry, { agentId });
630
+ const traceLog = `[TRACE] sessionFile=${sessionFile} sessionKey=${route.sessionKey} agentId=${agentId}`;
631
+ log(`${tag}: ${traceLog}`);
632
+ span?.setAttribute("session_file", sessionFile);
633
+ span?.addEvent(traceLog);
634
+ }
635
+ } catch (err) {
636
+ log(`${tag}: [TRACE] 解析 sessionFile 失败: ${String(err)}`);
637
+ }
495
638
  }
package/src/media.ts CHANGED
@@ -18,6 +18,23 @@ import { uploadFileToOss, uploadBufferToOss } from "./oss.js";
18
18
 
19
19
  /** OpenClaw 允许访问的媒体目录 */
20
20
  const MEDIA_DIR = path.join(os.homedir(), ".openclaw", "media");
21
+ const STATE_DIR = path.join(os.homedir(), ".openclaw");
22
+
23
+ /**
24
+ * 根据 agentId 构造媒体文件搜索根路径列表,
25
+ * 与 OpenClaw 的 getAgentScopedMediaLocalRoots 逻辑对齐。
26
+ */
27
+ export function resolveMediaLocalRoots(agentId?: string): string[] {
28
+ const roots = [MEDIA_DIR, path.join(STATE_DIR, "workspace"), path.join(STATE_DIR, "sandboxes")];
29
+ if (agentId?.trim()) {
30
+ // workspace-{agentId} 形式(非 default agent,或 main 作为非 default 时)
31
+ const workspaceDirById = path.join(STATE_DIR, `workspace-${agentId}`);
32
+ if (!roots.includes(workspaceDirById)) {
33
+ roots.push(workspaceDirById);
34
+ }
35
+ }
36
+ return roots;
37
+ }
21
38
 
22
39
  function saveBufferToMediaDir(
23
40
  buffer: Buffer,
@@ -205,11 +222,13 @@ export async function resolvePalzMediaList(
205
222
  export async function loadMediaAsOssUrl(
206
223
  mediaUrl: string,
207
224
  log?: (...args: any[]) => void,
225
+ localRoots?: readonly string[],
208
226
  ): Promise<string | null> {
209
227
  log?.(`palz-media: [loadAsOssUrl] 输入: url=${mediaUrl.slice(0, 200)}`);
210
228
 
211
229
  if (
212
230
  mediaUrl.startsWith("https://oss.csaiagent.com/") ||
231
+ mediaUrl.startsWith("https://claw.csaiagent.com") ||
213
232
  mediaUrl.startsWith("https://cstv-data.oss-cn-beijing.aliyuncs.com/")
214
233
  ) {
215
234
  log?.(`palz-media: [loadAsOssUrl] 已是OSS链接, 直接返回`);
@@ -241,7 +260,25 @@ export async function loadMediaAsOssUrl(
241
260
 
242
261
  // 本地文件路径(绝对或相对)→ 上传到 OSS
243
262
  const rawPath = mediaUrl.replace(/^MEDIA:/, "");
244
- let filePath = path.isAbsolute(rawPath) ? rawPath : path.resolve(rawPath);
263
+ let filePath = path.isAbsolute(rawPath) ? rawPath : "";
264
+
265
+ // 相对路径:在 localRoots 中逐个查找
266
+ if (!filePath && localRoots && localRoots.length > 0) {
267
+ for (const root of localRoots) {
268
+ const candidate = path.resolve(root, rawPath);
269
+ if (fs.existsSync(candidate)) {
270
+ log?.(`palz-media: [loadAsOssUrl] 在localRoot找到: root=${root} → ${candidate}`);
271
+ filePath = candidate;
272
+ break;
273
+ }
274
+ }
275
+ }
276
+
277
+ // 兜底:cwd 解析
278
+ if (!filePath) {
279
+ filePath = path.resolve(rawPath);
280
+ }
281
+
245
282
  if (!fs.existsSync(filePath)) {
246
283
  const fallback = path.join(MEDIA_DIR, path.basename(filePath));
247
284
  if (fs.existsSync(fallback)) {
package/src/monitor.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import WebSocket from "ws";
9
9
  import { handlePalzMessage } from "./bot.js";
10
+ import { tracer, extractTraceparentContext, SpanStatusCode } from "./tracing.js";
10
11
  import type { PalzConfig, PalzMessageEvent } from "./types.js";
11
12
 
12
13
  export interface MonitorPalzParams {
@@ -97,8 +98,26 @@ export async function monitorPalzProvider(params: MonitorPalzParams): Promise<vo
97
98
  return;
98
99
  }
99
100
  messageCount++;
100
- log(`palz[${accountId}]: [WS_RECV] #${messageCount} full_message=${raw.slice(0, 1000)}`);
101
- handlePalzMessage({ cfg, msg, runtime, accountId });
101
+
102
+ // IM 上游的 traceparent 恢复 trace context
103
+ const parentCtx = extractTraceparentContext(msg.traceparent);
104
+
105
+ tracer.startActiveSpan("palz.ws_recv", {}, parentCtx, (span) => {
106
+ try {
107
+ span.setAttribute("raw_message", raw);
108
+
109
+ log(`palz[${accountId}]: [WS_RECV] #${messageCount} full_message=${raw.slice(0, 1000)} traceId=${span.spanContext().traceId}`);
110
+
111
+ handlePalzMessage({ cfg, msg, runtime, accountId }).catch((err: any) => {
112
+ error(`palz[${accountId}]: [WS_RECV] handlePalzMessage unhandled error: ${err.message ?? err}`);
113
+ });
114
+ } catch (e) {
115
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) });
116
+ throw e;
117
+ } finally {
118
+ span.end();
119
+ }
120
+ });
102
121
  } catch (err: any) {
103
122
  error(`palz[${accountId}]: [WS_RECV] parse error: ${err.message}, raw=${raw.slice(0, 500)}`);
104
123
  }
package/src/oss.ts CHANGED
@@ -52,12 +52,13 @@ export async function uploadFileToOss(
52
52
  try {
53
53
  const client = getOssClient();
54
54
  const ext = path.extname(localPath) || ".png";
55
- const objectName = `${OSS_UPLOAD_PREFIX}/${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
55
+ const baseName = path.basename(localPath, ext);
56
+ const objectName = `${OSS_UPLOAD_PREFIX}/${baseName}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
56
57
 
57
58
  const fileBuffer = fs.readFileSync(localPath);
58
59
  await client.put(objectName, fileBuffer);
59
60
 
60
- const fileUrl = `https://${OSS_CUSTOM_DOMAIN}/${objectName}`;
61
+ const fileUrl = `https://${OSS_CUSTOM_DOMAIN}/${objectName.split('/').map(s => encodeURIComponent(s)).join('/')}`;
61
62
  log?.(`palz-oss: [upload] 成功: objectName=${objectName} url=${fileUrl}`);
62
63
  return fileUrl;
63
64
  } catch (err: any) {
package/src/outbound.ts CHANGED
@@ -37,7 +37,7 @@ export const palzOutbound = {
37
37
  },
38
38
 
39
39
  sendMedia: async (ctx: any) => {
40
- const { cfg, to, text, mediaUrl, accountId } = ctx;
40
+ const { cfg, to, text, mediaUrl, accountId, mediaLocalRoots } = ctx;
41
41
  const log = typeof ctx.log === "function" ? ctx.log : console.log;
42
42
 
43
43
  const account = resolvePalzAccount({ cfg, accountId });
@@ -52,7 +52,7 @@ export const palzOutbound = {
52
52
  }
53
53
 
54
54
  if (mediaUrl) {
55
- const ossUrl = await loadMediaAsOssUrl(mediaUrl, log);
55
+ const ossUrl = await loadMediaAsOssUrl(mediaUrl, log, mediaLocalRoots);
56
56
  if (ossUrl) {
57
57
  contentParts.push({ type: "file", file_url: { url: ossUrl } });
58
58
  log(`palz-outbound: [sendMedia] 媒体转换成功: ossUrl=${ossUrl}`);
@@ -43,6 +43,7 @@ export interface CreatePalzReplyDispatcherParams {
43
43
  msgId: string;
44
44
  msgType?: string;
45
45
  groupId?: string;
46
+ mediaLocalRoots?: readonly string[];
46
47
  }
47
48
 
48
49
  export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParams) {
@@ -59,6 +60,7 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
59
60
  msgId,
60
61
  msgType,
61
62
  groupId,
63
+ mediaLocalRoots,
62
64
  } = params;
63
65
 
64
66
  const log = typeof runtime?.log === "function" ? runtime.log : console.log;
@@ -125,7 +127,7 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
125
127
 
126
128
  for (let i = 0; i < mediaUrls.length; i++) {
127
129
  log(`${tag}: [DELIVER 媒体] ${i + 1}/${mediaUrls.length} url=${mediaUrls[i].slice(0, 200)}`);
128
- const ossUrl = await loadMediaAsOssUrl(mediaUrls[i], log);
130
+ const ossUrl = await loadMediaAsOssUrl(mediaUrls[i], log, mediaLocalRoots);
129
131
  if (ossUrl) {
130
132
  contentParts.push({ type: "file", file_url: { url: ossUrl } });
131
133
  log(`${tag}: [DELIVER 媒体转换成功] ${i + 1}/${mediaUrls.length} ossUrl=${ossUrl}`);
package/src/send.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * 支持一次性发送和流式发送两种模式。
6
6
  */
7
7
 
8
+ import { tracer, trace, SpanStatusCode, buildTraceparentHeader } from "./tracing.js";
8
9
  import type { SendToIMParams, OpenAIContent } from "./types.js";
9
10
 
10
11
  let msgSeq = 0;
@@ -14,9 +15,24 @@ function nextMsgId(): string {
14
15
  }
15
16
 
16
17
  export async function sendToPalzIM(params: SendToIMParams): Promise<any> {
18
+ return tracer.startActiveSpan("palz.sendToIM", async (span) => {
19
+ try {
20
+ const result = await _sendToPalzIMInner(params);
21
+ return result;
22
+ } catch (e) {
23
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) });
24
+ throw e;
25
+ } finally {
26
+ span.end();
27
+ }
28
+ });
29
+ }
30
+
31
+ async function _sendToPalzIMInner(params: SendToIMParams): Promise<any> {
17
32
  const { config, conversationId, content, conversationType, msgId, senderId, stream, msgType, groupId } = params;
18
33
  const url = `${config.apiBaseUrl}/bot/send`;
19
34
  const resolvedMsgId = msgId || nextMsgId();
35
+ const span = trace.getActiveSpan();
20
36
 
21
37
  const reqBody: Record<string, unknown> = {
22
38
  bot_id: config.botId,
@@ -46,14 +62,21 @@ export async function sendToPalzIM(params: SendToIMParams): Promise<any> {
46
62
  }
47
63
 
48
64
  const reqBodyStr = JSON.stringify(reqBody);
49
- console.log(
50
- `palz-send: [HTTP_REQ] POST ${url} body_length=${reqBodyStr.length}\n request_body=${reqBodyStr}`,
51
- );
65
+ const reqLog = `[HTTP_REQ] POST ${url} body_length=${reqBodyStr.length}\n request_body=${reqBodyStr}`;
66
+ console.log(`palz-send: ${reqLog}`);
67
+ span?.addEvent(reqLog);
68
+
69
+ // 构建请求 headers,注入 Traceparent
70
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
71
+ const traceparent = buildTraceparentHeader();
72
+ if (traceparent) {
73
+ headers["Traceparent"] = traceparent;
74
+ }
52
75
 
53
76
  const startMs = Date.now();
54
77
  const response = await fetch(url, {
55
78
  method: "POST",
56
- headers: { "Content-Type": "application/json" },
79
+ headers,
57
80
  body: reqBodyStr,
58
81
  });
59
82
  const elapsedMs = Date.now() - startMs;
@@ -65,15 +88,15 @@ export async function sendToPalzIM(params: SendToIMParams): Promise<any> {
65
88
  } catch {}
66
89
 
67
90
  if (!response.ok) {
68
- console.error(
69
- `palz-send: [HTTP_RES] FAILED status=${response.status} elapsed=${elapsedMs}ms\n response_headers=${JSON.stringify(Object.fromEntries(response.headers.entries()))}\n response_body=${rawText.slice(0, 500)}`,
70
- );
91
+ const failLog = `[HTTP_RES] FAILED status=${response.status} elapsed=${elapsedMs}ms\n response_headers=${JSON.stringify(Object.fromEntries(response.headers.entries()))}\n response_body=${rawText.slice(0, 500)}`;
92
+ console.error(`palz-send: ${failLog}`);
93
+ span?.addEvent(failLog);
71
94
  throw new Error(`Palz send failed: ${response.status} ${rawText}`);
72
95
  }
73
96
 
74
- console.log(
75
- `palz-send: [HTTP_RES] OK status=${response.status} elapsed=${elapsedMs}ms msg_id=${resolvedMsgId}\n response_body=${rawText.slice(0, 500)}`,
76
- );
97
+ const okLog = `[HTTP_RES] OK status=${response.status} elapsed=${elapsedMs}ms msg_id=${resolvedMsgId}${traceparent ? ` Traceparent=${traceparent}` : ""}\n response_body=${rawText.slice(0, 500)}`;
98
+ console.log(`palz-send: ${okLog}`);
99
+ span?.addEvent(okLog);
77
100
  return body;
78
101
  }
79
102
 
package/src/tracing.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * OpenTelemetry Tracing 初始化 + 工具函数
3
+ *
4
+ * 接入阿里云 ARMS 分布式链路追踪。
5
+ * - initTracing(): 初始化 SDK,在 index.ts register() 最前面调用
6
+ * - tracer: 全局 tracer 实例
7
+ * - extractTraceparentContext(): 从 W3C traceparent 字符串恢复上游 context
8
+ * - buildTraceparentHeader(): 构建当前 span 的 W3C traceparent 字符串
9
+ */
10
+
11
+ import { NodeSDK } from "@opentelemetry/sdk-node";
12
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
13
+ import { resourceFromAttributes } from "@opentelemetry/resources";
14
+ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
15
+ import {
16
+ trace,
17
+ context,
18
+ SpanContext,
19
+ TraceFlags,
20
+ ROOT_CONTEXT,
21
+ SpanStatusCode,
22
+ } from "@opentelemetry/api";
23
+
24
+ export { trace, context, SpanStatusCode, ROOT_CONTEXT };
25
+ export type { SpanContext };
26
+
27
+ // 阿里云 ARMS 接入点
28
+ const ARMS_ENDPOINT =
29
+ "http://tracing-analysis-dc-bj.aliyuncs.com/adapt_cd5l20bzv2@591056c1ce22f09_cd5l20bzv2@53df7ad2afe8301/api/otlp/traces";
30
+
31
+ const helmEnv = process.env.HELM_ENV || "local";
32
+ const serviceName = `palz-connector-${helmEnv}`;
33
+
34
+ let sdk: NodeSDK | null = null;
35
+
36
+ export function initTracing(): void {
37
+ if (sdk) return;
38
+
39
+ sdk = new NodeSDK({
40
+ resource: resourceFromAttributes({
41
+ [ATTR_SERVICE_NAME]: serviceName,
42
+ }),
43
+ traceExporter: new OTLPTraceExporter({
44
+ url: ARMS_ENDPOINT,
45
+ }),
46
+ });
47
+
48
+ sdk.start();
49
+ console.log(
50
+ `[tracing] initialized: service=${serviceName} endpoint=${ARMS_ENDPOINT}`,
51
+ );
52
+ }
53
+
54
+ export const tracer = trace.getTracer("palz-connector");
55
+
56
+ /**
57
+ * 解析 W3C traceparent 字符串,返回携带上游 span context 的 OTel Context。
58
+ *
59
+ * traceparent 格式: {version}-{traceId}-{spanId}-{traceFlags}
60
+ * 例: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
61
+ */
62
+ const TRACEPARENT_RE =
63
+ /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
64
+
65
+ export function extractTraceparentContext(
66
+ traceparent: string | undefined,
67
+ ): typeof ROOT_CONTEXT {
68
+ if (!traceparent) return ROOT_CONTEXT;
69
+
70
+ const match = TRACEPARENT_RE.exec(traceparent.trim());
71
+ if (!match) {
72
+ console.warn(`[tracing] invalid traceparent: ${traceparent}`);
73
+ return ROOT_CONTEXT;
74
+ }
75
+
76
+ const spanContext: SpanContext = {
77
+ traceId: match[2],
78
+ spanId: match[3],
79
+ traceFlags: parseInt(match[4], 16) as TraceFlags,
80
+ isRemote: true,
81
+ };
82
+
83
+ return trace.setSpanContext(ROOT_CONTEXT, spanContext);
84
+ }
85
+
86
+ /**
87
+ * 构建当前活跃 span 的 W3C traceparent 字符串。
88
+ * 用于在 HTTP 回复 header 中注入 Traceparent。
89
+ */
90
+ export function buildTraceparentHeader(): string | undefined {
91
+ const span = trace.getActiveSpan();
92
+ if (!span) return undefined;
93
+
94
+ const ctx = span.spanContext();
95
+ if (!ctx.traceId || ctx.traceId === "00000000000000000000000000000000") {
96
+ return undefined;
97
+ }
98
+
99
+ const flags = ctx.traceFlags.toString(16).padStart(2, "0");
100
+ return `00-${ctx.traceId}-${ctx.spanId}-${flags}`;
101
+ }
package/src/types.ts CHANGED
@@ -37,6 +37,8 @@ export interface PalzMessageEvent {
37
37
  owner_name?: string;
38
38
  /** 群组 ID,群聊时 IM 可直接下发;若未提供则从 conversation_id 中解析 */
39
39
  group_id?: string;
40
+ /** W3C Trace Context traceparent,由 IM 上游传递 */
41
+ traceparent?: string;
40
42
  }
41
43
 
42
44
  // ============ 配置 ============