palz-connector 1.2.1 → 1.2.3

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.2.1",
4
+ "version": "1.2.3",
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.1",
3
+ "version": "1.2.3",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Palz IM 接入 OpenClaw — 模块化架构,基于 OpenClaw Runtime 消息管道",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "enabled": true,
3
- "streamUrl": "wss://claw-server.uniclaw.tech/ws/bot",
4
- "apiBaseUrl": "https://claw-server.uniclaw.tech/api",
3
+ "streamUrl": "wss://claw-server.csagentai.com/ws/bot",
4
+ "apiBaseUrl": "https://claw-server.csagentai.com/api",
5
5
  "sessionTimeout": 1800000
6
6
  }
package/src/bot.ts CHANGED
@@ -46,6 +46,91 @@ function createChatQueue() {
46
46
 
47
47
  const enqueue = createChatQueue();
48
48
 
49
+ // ============ 群聊历史记录 ============
50
+
51
+ interface HistoryEntry {
52
+ sender: string;
53
+ body: string;
54
+ timestamp: number;
55
+ messageId: string;
56
+ }
57
+
58
+ const DEFAULT_GROUP_HISTORY_LIMIT = 50;
59
+ const MAX_HISTORY_KEYS = 3000;
60
+
61
+ /** 群聊历史缓存,key = historyKey(agentId:conversationId) */
62
+ const chatHistories = new Map<string, HistoryEntry[]>();
63
+
64
+ function recordGroupHistoryEntry(params: {
65
+ historyKey: string;
66
+ entry: HistoryEntry;
67
+ limit: number;
68
+ log?: (...args: any[]) => void;
69
+ }): void {
70
+ const log = params.log ?? console.log;
71
+ if (params.limit <= 0) {
72
+ log(`[HISTORY record] 跳过: limit=${params.limit} historyKey=${params.historyKey}`);
73
+ return;
74
+ }
75
+ const history = chatHistories.get(params.historyKey) ?? [];
76
+ const beforeLen = history.length;
77
+ history.push(params.entry);
78
+ while (history.length > params.limit) {
79
+ history.shift();
80
+ }
81
+ // 刷新插入顺序(LRU)
82
+ if (chatHistories.has(params.historyKey)) {
83
+ chatHistories.delete(params.historyKey);
84
+ }
85
+ chatHistories.set(params.historyKey, history);
86
+ log(`[HISTORY record] historyKey=${params.historyKey} before=${beforeLen} after=${history.length} limit=${params.limit} sender=${params.entry.sender} msgId=${params.entry.messageId} body="${params.entry.body.slice(0, 80)}" totalKeys=${chatHistories.size}`);
87
+ // 超过最大 key 数量时淘汰最老的
88
+ if (chatHistories.size > MAX_HISTORY_KEYS) {
89
+ const keysToDelete = chatHistories.size - MAX_HISTORY_KEYS;
90
+ const iterator = chatHistories.keys();
91
+ for (let i = 0; i < keysToDelete; i++) {
92
+ const key = iterator.next().value;
93
+ if (key !== undefined) chatHistories.delete(key);
94
+ }
95
+ log(`[HISTORY evict] 淘汰了 ${keysToDelete} 个 key, 剩余 ${chatHistories.size}`);
96
+ }
97
+ }
98
+
99
+ function buildGroupHistoryContext(params: {
100
+ historyKey: string;
101
+ currentMessage: string;
102
+ formatEntry: (entry: HistoryEntry) => string;
103
+ log?: (...args: any[]) => void;
104
+ }): string {
105
+ const log = params.log ?? console.log;
106
+ const entries = chatHistories.get(params.historyKey) ?? [];
107
+ log(`[HISTORY build] historyKey=${params.historyKey} entriesCount=${entries.length} currentMsgLen=${params.currentMessage.length}`);
108
+ if (entries.length === 0) {
109
+ log(`[HISTORY build] 无历史记录, 返回原始消息`);
110
+ return params.currentMessage;
111
+ }
112
+ for (let i = 0; i < entries.length; i++) {
113
+ log(`[HISTORY build] entry[${i}] sender=${entries[i].sender} msgId=${entries[i].messageId} body="${entries[i].body.slice(0, 80)}" ts=${entries[i].timestamp}`);
114
+ }
115
+ const historyText = entries.map(params.formatEntry).join("\n");
116
+ const combined = [
117
+ "[Chat messages since your last reply - for context]",
118
+ historyText,
119
+ "",
120
+ "[Current message - respond to this]",
121
+ params.currentMessage,
122
+ ].join("\n");
123
+ log(`[HISTORY build] 拼接完成: historyTextLen=${historyText.length} combinedLen=${combined.length}`);
124
+ return combined;
125
+ }
126
+
127
+ function clearGroupHistory(historyKey: string, log?: (...args: any[]) => void): void {
128
+ const _log = log ?? console.log;
129
+ const entries = chatHistories.get(historyKey) ?? [];
130
+ _log(`[HISTORY clear] historyKey=${historyKey} clearedEntries=${entries.length}`);
131
+ chatHistories.set(historyKey, []);
132
+ }
133
+
49
134
  // ============ 媒体 payload 构建 ============
50
135
 
51
136
  function buildMediaPayload(
@@ -80,7 +165,10 @@ export async function handlePalzMessage(params: HandlePalzMessageParams): Promis
80
165
  const error = typeof runtime?.error === "function" ? runtime.error : console.error;
81
166
  const tag = `palz[${accountId}]`;
82
167
 
83
- log(`${tag}: [STEP 1/6 入站过滤] msg_id=${msg.msg_id} sender=${msg.sender_id} conv=${msg.conversation_id}`);
168
+ const isGroup = msg.conversation_type === "group";
169
+ const effectiveAgentId = msg.agent_id || "main";
170
+
171
+ log(`${tag}: [STEP 1/6 入站过滤] msg_id=${msg.msg_id} sender=${msg.sender_id} conv=${msg.conversation_id} type=${msg.conversation_type} agent=${effectiveAgentId}`);
84
172
 
85
173
  const content = msg.content;
86
174
  if (!content) {
@@ -89,27 +177,52 @@ export async function handlePalzMessage(params: HandlePalzMessageParams): Promis
89
177
  }
90
178
 
91
179
  const plainText = extractPlainText(content).trim();
92
- const hasImages =
93
- Array.isArray(content) && content.some((p: ContentPart) => p.type === "image_url");
94
- const imageCount = Array.isArray(content)
95
- ? content.filter((p: ContentPart) => p.type === "image_url").length
180
+ const hasMedia =
181
+ Array.isArray(content) &&
182
+ content.some((p: ContentPart) => p.type === "file");
183
+ const mediaCount = Array.isArray(content)
184
+ ? content.filter((p: ContentPart) => p.type === "file").length
96
185
  : 0;
97
186
 
98
- log(`${tag}: [STEP 1 解析] plainText="${plainText}" (len=${plainText.length}) hasImages=${hasImages} imageCount=${imageCount}`);
187
+ log(`${tag}: [STEP 1 解析] plainText="${plainText}" (len=${plainText.length}) hasMedia=${hasMedia} mediaCount=${mediaCount}`);
99
188
 
100
- if (!plainText && !hasImages) {
101
- log(`${tag}: [STEP 1 跳过] 原因=无文本且无图片`);
189
+ if (!plainText && !hasMedia) {
190
+ log(`${tag}: [STEP 1 跳过] 原因=无文本且无媒体`);
191
+ return;
192
+ }
193
+
194
+ // 群聊 @提及检测
195
+ const wasMentioned = isGroup ? (msg.mentioned_bot === true) : true;
196
+ if (isGroup && !wasMentioned) {
197
+ // 未@机器人:记录到群聊历史,下次被@时作为上下文
198
+ const historyKey = `${effectiveAgentId}:${msg.conversation_id}`;
199
+ const senderName = msg.sender_name || msg.sender_id;
200
+ log(`${tag}: [STEP 1 群聊历史] 未@机器人, 准备记录历史 historyKey=${historyKey} mentioned_bot=${msg.mentioned_bot} conversation_type=${msg.conversation_type}`);
201
+ recordGroupHistoryEntry({
202
+ historyKey,
203
+ entry: {
204
+ sender: msg.sender_id,
205
+ body: `${senderName}: ${plainText}`,
206
+ timestamp: Date.now(),
207
+ messageId: msg.msg_id,
208
+ },
209
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
210
+ log,
211
+ });
212
+ log(`${tag}: [STEP 1 跳过] 原因=群聊中未@机器人, 已记录到历史 historyKey=${historyKey}`);
102
213
  return;
103
214
  }
104
215
 
105
- // 去重
106
- const claimed = tryClaimMessage(msg.msg_id);
107
- log(`${tag}: [STEP 2/6 去重] msg_id=${msg.msg_id} claimed=${claimed}`);
216
+ // 去重(按 agentId + conversationId 隔离,同群多 bot 场景)
217
+ const claimed = tryClaimMessage(msg.msg_id, effectiveAgentId, msg.conversation_id);
218
+ log(`${tag}: [STEP 2/6 去重] msg_id=${msg.msg_id} agent=${effectiveAgentId} conv=${msg.conversation_id} claimed=${claimed}`);
108
219
  if (!claimed) return;
109
220
 
110
- // 入队
111
- const queueKey = `${msg.sender_id}:${msg.conversation_id}`;
112
- log(`${tag}: [STEP 3/6 入队] queueKey="${queueKey}"`);
221
+ // 入队(按 agentId 隔离,不同 agent 并行处理)
222
+ const queueKey = isGroup
223
+ ? `${effectiveAgentId}:${msg.conversation_id}`
224
+ : `${effectiveAgentId}:${msg.sender_id}:${msg.conversation_id}`;
225
+ log(`${tag}: [STEP 3/6 入队] queueKey="${queueKey}" isGroup=${isGroup}`);
113
226
 
114
227
  enqueue(queueKey, async () => {
115
228
  const startMs = Date.now();
@@ -130,24 +243,33 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
130
243
  const account = resolvePalzAccount({ cfg, accountId });
131
244
  const config = account.config;
132
245
 
246
+ const isGroup = msg.conversation_type === "group";
133
247
  const plainText = extractPlainText(msg.content).trim();
134
248
  const useStream = msg.stream === true;
135
- const peerId = `${msg.sender_id}:${msg.conversation_id}`;
249
+ const senderName = msg.sender_name || msg.sender_id;
250
+
251
+ // 群聊:peerId = chat:conversation_id(整群共享 session,与 palzTo 格式一致)
252
+ // DM:peerId = sender_id:conversation_id(每用户每会话独立 session)
253
+ const peerId = isGroup ? `chat:${msg.conversation_id}` : `${msg.sender_id}:${msg.conversation_id}`;
136
254
 
137
255
  // STEP 4: 解析媒体
138
- log(`${tag}: [STEP 4/6 媒体解析] 输入: contentType=${typeof msg.content === "string" ? "string" : "array"} imageCount=${Array.isArray(msg.content) ? msg.content.filter((p: ContentPart) => p.type === "image_url").length : 0}`);
256
+ const mediaCount = Array.isArray(msg.content)
257
+ ? msg.content.filter((p: ContentPart) => p.type === "file").length
258
+ : 0;
259
+ log(`${tag}: [STEP 4/6 媒体解析] 输入: contentType=${typeof msg.content === "string" ? "string" : "array"} mediaCount=${mediaCount}`);
139
260
  const mediaList = await resolvePalzMediaList(msg.content, log);
140
261
  const mediaPayload = buildMediaPayload(mediaList);
141
262
  log(`${tag}: [STEP 4 输出] mediaList=${JSON.stringify(mediaList.map((m) => ({ path: m.path, contentType: m.contentType })))} mediaPayload=${JSON.stringify(mediaPayload)}`);
142
263
 
143
264
  // STEP 5: 解析路由
144
- const routeInput = { cfg: "(cfg)", channel: "palz-connector", accountId, peer: { kind: "direct", id: peerId } };
265
+ const peerKind = isGroup ? "group" : "direct";
266
+ const routeInput = { cfg: "(cfg)", channel: "palz-connector", accountId, peer: { kind: peerKind, id: peerId } };
145
267
  log(`${tag}: [STEP 5/6 路由解析] 输入: ${JSON.stringify(routeInput)} agent_id=${msg.agent_id ?? "(auto)"}`);
146
268
  const route = core.channel.routing.resolveAgentRoute({
147
269
  cfg,
148
270
  channel: "palz-connector",
149
271
  accountId,
150
- peer: { kind: "direct", id: peerId },
272
+ peer: { kind: peerKind, id: peerId },
151
273
  });
152
274
 
153
275
  // IM 指定 agent_id 时走指定 agent,否则强制走 main
@@ -163,11 +285,13 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
163
285
 
164
286
  // STEP 6a: 构建 envelope body
165
287
  const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
166
- const messageBody = `${msg.sender_id}: ${plainText}`;
167
- log(`${tag}: [STEP 6a/6 envelope构建] 输入: channel=Palz from=${msg.sender_id} messageBody="${messageBody.slice(0, 120)}" envelopeOptions=${JSON.stringify(envelopeOptions)}`);
288
+ const messageBody = `${senderName}: ${plainText}`;
289
+ // 群聊 from 带上 conversation_id 以区分不同用户
290
+ const envelopeFrom = isGroup ? `${msg.conversation_id}:${msg.sender_id}` : msg.sender_id;
291
+ log(`${tag}: [STEP 6a/6 envelope构建] 输入: channel=Palz from=${envelopeFrom} messageBody="${messageBody.slice(0, 120)}" envelopeOptions=${JSON.stringify(envelopeOptions)}`);
168
292
  const body = core.channel.reply.formatAgentEnvelope({
169
293
  channel: "Palz",
170
- from: msg.sender_id,
294
+ from: envelopeFrom,
171
295
  timestamp: new Date(msg.timestamp || Date.now()),
172
296
  body: messageBody,
173
297
  envelope: envelopeOptions,
@@ -176,10 +300,11 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
176
300
 
177
301
  // STEP 6b: 构建 inbound context
178
302
  const palzFrom = `palz:${msg.sender_id}`;
179
- const palzTo = `${msg.sender_id}:${msg.conversation_id}`;
303
+ // 群聊:To 指向群,DM:To 指向用户会话
304
+ const palzTo = isGroup ? `chat:${msg.conversation_id}` : `${msg.sender_id}:${msg.conversation_id}`;
180
305
 
181
- // Palz DM 场景:检测是否包含斜杠命令,并计算命令授权。
182
- // DM 用户默认视为已授权(与飞书插件 dmPolicy=open 时的行为一致)。
306
+ // 命令授权:DM 默认允许,群聊也默认允许(可后续扩展 allowlist)
307
+ const wasMentioned = isGroup ? (msg.mentioned_bot === true) : true;
183
308
  const needsCommandAuth = core.channel.commands.shouldComputeCommandAuthorized(plainText, cfg);
184
309
  const commandAuthorized = needsCommandAuth
185
310
  ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
@@ -189,54 +314,71 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
189
314
  : undefined;
190
315
  log(`${tag}: [STEP 6b 命令授权] needsCommandAuth=${needsCommandAuth} commandAuthorized=${commandAuthorized}`);
191
316
 
192
- const rawCtx = {
193
- Body: `(envelope, len=${body.length})`,
194
- BodyForAgent: messageBody.slice(0, 100),
195
- RawBody: plainText.slice(0, 100),
196
- CommandBody: plainText.slice(0, 100),
197
- From: palzFrom,
198
- To: palzTo,
199
- SessionKey: route.sessionKey,
200
- AccountId: route.accountId,
201
- ChatType: "direct",
202
- SenderId: msg.sender_id,
203
- SenderName: msg.sender_id,
204
- Provider: "palz-connector",
205
- Surface: "palz-connector",
206
- MessageSid: msg.msg_id,
207
- Timestamp: Date.now(),
208
- WasMentioned: true,
209
- CommandAuthorized: commandAuthorized,
210
- OriginatingChannel: "palz-connector",
211
- OriginatingTo: palzTo,
212
- ...mediaPayload,
213
- };
214
- log(`${tag}: [STEP 6b inbound context] 输入: ${JSON.stringify(rawCtx)}`);
317
+ const chatType = isGroup ? "group" : "direct";
318
+
319
+ // 群聊历史:将积攒的未@消息拼入 Body 上下文
320
+ const historyKey = isGroup ? `${effectiveAgentId}:${msg.conversation_id}` : undefined;
321
+ let combinedBody = body;
322
+ if (isGroup && historyKey) {
323
+ log(`${tag}: [STEP 6b 群聊历史] 开始构建, historyKey=${historyKey} bodyLen=${body.length}`);
324
+ combinedBody = buildGroupHistoryContext({
325
+ historyKey,
326
+ currentMessage: body,
327
+ formatEntry: (entry) =>
328
+ core.channel.reply.formatAgentEnvelope({
329
+ channel: "Palz",
330
+ from: entry.sender,
331
+ timestamp: new Date(entry.timestamp),
332
+ body: entry.body,
333
+ envelope: envelopeOptions,
334
+ }),
335
+ log,
336
+ });
337
+ log(`${tag}: [STEP 6b 群聊历史] 构建完成, historyKey=${historyKey} bodyLen=${body.length} combinedBodyLen=${combinedBody.length} hasHistory=${combinedBody.length !== body.length}`);
338
+ // log(`${tag}: [STEP 6b 群聊历史] combinedBody=\n${combinedBody}`);
339
+ }
340
+
341
+ // 构建 InboundHistory(结构化历史数据,Runtime 会注入到系统提示中)
342
+ const inboundHistory =
343
+ isGroup && historyKey
344
+ ? (chatHistories.get(historyKey) ?? []).map((entry) => ({
345
+ sender: entry.sender,
346
+ body: entry.body,
347
+ timestamp: entry.timestamp,
348
+ }))
349
+ : undefined;
350
+ log(`${tag}: [STEP 6b InboundHistory] count=${inboundHistory?.length ?? 0}`);
215
351
 
216
352
  const ctx = core.channel.reply.finalizeInboundContext({
217
- Body: body,
353
+ Body: combinedBody,
218
354
  BodyForAgent: messageBody,
355
+ InboundHistory: inboundHistory,
219
356
  RawBody: plainText,
220
357
  CommandBody: plainText,
221
358
  From: palzFrom,
222
359
  To: palzTo,
223
360
  SessionKey: route.sessionKey,
224
361
  AccountId: route.accountId,
225
- ChatType: "direct",
362
+ ChatType: chatType,
363
+ GroupSubject: isGroup ? msg.conversation_id : undefined,
226
364
  SenderId: msg.sender_id,
227
- SenderName: msg.sender_id,
365
+ SenderName: senderName,
228
366
  Provider: "palz-connector",
229
367
  Surface: "palz-connector",
230
368
  MessageSid: msg.msg_id,
231
369
  Timestamp: Date.now(),
232
- WasMentioned: true,
370
+ WasMentioned: wasMentioned,
233
371
  CommandAuthorized: commandAuthorized,
234
372
  OriginatingChannel: "palz-connector",
235
373
  OriginatingTo: palzTo,
236
374
  ...mediaPayload,
237
375
  });
238
376
  log(`${tag}: [STEP 6b 输出] finalized context keys=[${Object.keys(ctx).join(",")}] CommandAuthorized=${ctx.CommandAuthorized}`);
239
-
377
+ ctx.metadata = {
378
+ ...ctx.metadata,
379
+ traceId: msg.msg_id,
380
+ source: "palz-connector"
381
+ };
240
382
  // STEP 6c: 创建回复分发器
241
383
  const dispatcherParams = {
242
384
  accountId,
@@ -261,6 +403,8 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
261
403
  });
262
404
 
263
405
  // STEP 6d: 分发消息给 AI
406
+ // channel registry 守卫已在 index.ts 中通过 defineProperty 安装,
407
+ // 每次读取 state.registry 时会自动注入 palz-connector channel。
264
408
  log(`${tag}: [STEP 6d AI分发] 开始 session=${route.sessionKey} stream=${useStream}`);
265
409
  const dispatchStartMs = Date.now();
266
410
 
@@ -276,6 +420,11 @@ async function dispatchPalzMessage(params: HandlePalzMessageParams): Promise<voi
276
420
  }),
277
421
  });
278
422
 
423
+ // AI 回复完成后清空群聊历史(已拼入上下文,避免下次重复)
424
+ if (isGroup && historyKey) {
425
+ clearGroupHistory(historyKey, log);
426
+ }
427
+
279
428
  const dispatchElapsedMs = Date.now() - dispatchStartMs;
280
429
  log(
281
430
  `${tag}: [STEP 6d 输出] queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)} 耗时=${dispatchElapsedMs}ms`,
package/src/channel.ts CHANGED
@@ -25,7 +25,7 @@ export const palzPlugin = {
25
25
  },
26
26
 
27
27
  capabilities: {
28
- chatTypes: ["direct"],
28
+ chatTypes: ["direct", "channel"],
29
29
  polls: false,
30
30
  threads: false,
31
31
  media: true,
package/src/dedup.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * 消息去重
3
3
  *
4
- * 基于 msg_id 防止重复处理,TTL 5 分钟。
4
+ * 基于 agentId + conversationId + msg_id 防止重复处理,TTL 5 分钟。
5
+ * 同一条消息可以被不同 agent 各自处理一次(同群多 bot 场景)。
5
6
  */
6
7
 
7
8
  const processedMessages = new Map<string, number>();
@@ -10,8 +11,8 @@ const MESSAGE_DEDUP_TTL = 5 * 60 * 1000;
10
11
  function cleanup(): void {
11
12
  const before = processedMessages.size;
12
13
  const now = Date.now();
13
- for (const [msgId, ts] of processedMessages) {
14
- if (now - ts > MESSAGE_DEDUP_TTL) processedMessages.delete(msgId);
14
+ for (const [key, ts] of processedMessages) {
15
+ if (now - ts > MESSAGE_DEDUP_TTL) processedMessages.delete(key);
15
16
  }
16
17
  console.log(`palz-dedup: cleanup ${before} → ${processedMessages.size} entries`);
17
18
  }
@@ -20,11 +21,15 @@ function cleanup(): void {
20
21
  * 尝试标记消息为已处理。
21
22
  * 返回 true 表示此消息之前未处理过(可以继续处理),
22
23
  * 返回 false 表示重复消息(应跳过)。
24
+ *
25
+ * agentId 用于同群多 bot 场景:同一条 msg_id 可被不同 agent 各自处理。
26
+ * conversationId 防止不同会话间 msg_id 碰撞导致误去重。
23
27
  */
24
- export function tryClaimMessage(msgId: string): boolean {
28
+ export function tryClaimMessage(msgId: string, agentId: string = "main", conversationId: string = ""): boolean {
25
29
  if (!msgId) return true;
26
- if (processedMessages.has(msgId)) return false;
27
- processedMessages.set(msgId, Date.now());
30
+ const key = `${agentId}:${conversationId}:${msgId}`;
31
+ if (processedMessages.has(key)) return false;
32
+ processedMessages.set(key, Date.now());
28
33
  if (processedMessages.size >= 200) cleanup();
29
34
  return true;
30
35
  }
package/src/media.ts CHANGED
@@ -1,22 +1,24 @@
1
1
  /**
2
2
  * Palz Connector 媒体处理
3
3
  *
4
- * 将 IM 消息中的图片上传到 OSS,返回公网 URL,
4
+ * 将 IM 消息中的图片/文件上传到 OSS,返回公网 URL,
5
5
  * 供 OpenClaw Runtime 作为媒体附件处理。
6
+ * 支持图片、PDF、DOCX、MD 等各类文件。
6
7
  */
7
8
 
8
9
  import fs from "fs";
9
10
  import path from "path";
10
11
  import os from "os";
11
- import type { OpenAIContent, ContentPart, ImageUrlContentPart, PalzMediaInfo } from "./types.js";
12
+ import type {
13
+ OpenAIContent,
14
+ FileUrlContentPart,
15
+ PalzMediaInfo,
16
+ } from "./types.js";
12
17
  import { uploadFileToOss, uploadBufferToOss } from "./oss.js";
13
18
 
14
19
  /** OpenClaw 允许访问的媒体目录 */
15
20
  const MEDIA_DIR = path.join(os.homedir(), ".openclaw", "media");
16
21
 
17
- /**
18
- * 将 Buffer 保存到 OpenClaw 媒体目录,返回 PalzMediaInfo。
19
- */
20
22
  function saveBufferToMediaDir(
21
23
  buffer: Buffer,
22
24
  contentType: string,
@@ -25,29 +27,123 @@ function saveBufferToMediaDir(
25
27
  ): PalzMediaInfo | null {
26
28
  try {
27
29
  fs.mkdirSync(MEDIA_DIR, { recursive: true });
28
- const filePath = path.join(MEDIA_DIR, `palz_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`);
30
+ const filePath = path.join(
31
+ MEDIA_DIR,
32
+ `palz_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`,
33
+ );
29
34
  fs.writeFileSync(filePath, buffer);
30
- log?.(`palz-media: [saveToMediaDir] 成功: path=${filePath} size=${buffer.length}bytes mime=${contentType}`);
31
- return { path: filePath, contentType, placeholder: "<media:image>" };
35
+ const placeholder = isImageMime(contentType) ? "<media:image>" : `<media:file:${ext}>`;
36
+ log?.(
37
+ `palz-media: [saveToMediaDir] 成功: path=${filePath} size=${buffer.length}bytes mime=${contentType}`,
38
+ );
39
+ return { path: filePath, contentType, placeholder };
32
40
  } catch (err: any) {
33
41
  log?.(`palz-media: [saveToMediaDir] 失败: error=${err.message}`);
34
42
  return null;
35
43
  }
36
44
  }
37
45
 
46
+ function isImageMime(mime: string): boolean {
47
+ return mime.startsWith("image/");
48
+ }
49
+
50
+ const MIME_TO_EXT: Record<string, string> = {
51
+ "image/jpeg": ".jpg",
52
+ "image/png": ".png",
53
+ "image/gif": ".gif",
54
+ "image/webp": ".webp",
55
+ "image/bmp": ".bmp",
56
+ "image/svg+xml": ".svg",
57
+ "application/pdf": ".pdf",
58
+ "application/msword": ".doc",
59
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
60
+ "application/vnd.ms-excel": ".xls",
61
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
62
+ "application/vnd.ms-powerpoint": ".ppt",
63
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
64
+ "text/markdown": ".md",
65
+ "text/plain": ".txt",
66
+ "text/csv": ".csv",
67
+ "text/html": ".html",
68
+ "application/json": ".json",
69
+ "application/zip": ".zip",
70
+ "application/x-tar": ".tar",
71
+ "application/gzip": ".gz",
72
+ "audio/mpeg": ".mp3",
73
+ "audio/wav": ".wav",
74
+ "audio/ogg": ".ogg",
75
+ "video/mp4": ".mp4",
76
+ "video/webm": ".webm",
77
+ "application/octet-stream": ".bin",
78
+ };
79
+
38
80
  function mimeToExt(mime: string): string {
39
- const map: Record<string, string> = {
40
- "image/jpeg": ".jpg",
41
- "image/png": ".png",
42
- "image/gif": ".gif",
43
- "image/webp": ".webp",
44
- "image/bmp": ".bmp",
45
- };
46
- return map[mime] || ".png";
81
+ return MIME_TO_EXT[mime] || ".bin";
82
+ }
83
+
84
+ function extFromUrl(url: string): string {
85
+ try {
86
+ const pathname = new URL(url).pathname;
87
+ const ext = path.extname(pathname).toLowerCase();
88
+ if (ext && ext.length <= 10) return ext;
89
+ } catch {}
90
+ return "";
91
+ }
92
+
93
+ function extToMime(ext: string): string {
94
+ for (const [mime, e] of Object.entries(MIME_TO_EXT)) {
95
+ if (e === ext) return mime;
96
+ }
97
+ return "application/octet-stream";
98
+ }
99
+
100
+ /**
101
+ * 从 URL 获取文件(data URL / HTTP URL),返回 buffer + contentType + ext。
102
+ */
103
+ async function fetchUrlToBuffer(
104
+ url: string,
105
+ log?: (...args: any[]) => void,
106
+ ): Promise<{ buffer: Buffer; contentType: string; ext: string } | null> {
107
+ if (url.startsWith("data:")) {
108
+ const match = url.match(/^data:([^;]+);base64,(.+)$/);
109
+ if (!match) {
110
+ log?.(`palz-media: [fetchUrl] data URL 格式不匹配`);
111
+ return null;
112
+ }
113
+ const mimeType = match[1];
114
+ const base64Data = match[2];
115
+ const ext = mimeToExt(mimeType);
116
+ const buffer = Buffer.from(base64Data, "base64");
117
+ return { buffer, contentType: mimeType, ext };
118
+ }
119
+
120
+ if (url.startsWith("http://") || url.startsWith("https://")) {
121
+ try {
122
+ const resp = await fetch(url);
123
+ if (!resp.ok) {
124
+ log?.(`palz-media: [fetchUrl] HTTP下载失败: status=${resp.status}`);
125
+ return null;
126
+ }
127
+ const contentType =
128
+ resp.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream";
129
+ const buffer = Buffer.from(await resp.arrayBuffer());
130
+ const urlExt = extFromUrl(url);
131
+ const ext = urlExt || mimeToExt(contentType);
132
+ const finalContentType =
133
+ contentType === "application/octet-stream" && urlExt ? extToMime(urlExt) : contentType;
134
+ return { buffer, contentType: finalContentType, ext };
135
+ } catch (err: any) {
136
+ log?.(`palz-media: [fetchUrl] HTTP下载异常: ${err.message}`);
137
+ return null;
138
+ }
139
+ }
140
+
141
+ log?.(`palz-media: [fetchUrl] 无法识别的URL格式: ${url.slice(0, 200)}`);
142
+ return null;
47
143
  }
48
144
 
49
145
  /**
50
- * 从 OpenAI Content 中提取所有图片,上传到 OSS 并返回公网 URL。
146
+ * 从 OpenAI Content 中提取所有 type:"file" 媒体,下载并保存到本地。
51
147
  */
52
148
  export async function resolvePalzMediaList(
53
149
  content: OpenAIContent,
@@ -58,60 +154,53 @@ export async function resolvePalzMediaList(
58
154
  return [];
59
155
  }
60
156
 
61
- const imageParts = content.filter((p: ContentPart): p is ImageUrlContentPart => p.type === "image_url" && (p as ImageUrlContentPart).image_url?.url !== undefined);
62
- log?.(`palz-media: [resolve] 输入: parts=${content.length} imageParts=${imageParts.length}`);
157
+ const mediaUrls: string[] = [];
63
158
 
64
- const results: PalzMediaInfo[] = [];
65
- for (let i = 0; i < imageParts.length; i++) {
66
- const part = imageParts[i];
67
- const url = part.image_url!.url;
68
- const urlType = url.startsWith("data:") ? "data-url" : url.startsWith("http") ? "http-url" : "unknown";
69
- log?.(`palz-media: [resolve] 处理第 ${i + 1}/${imageParts.length} 个图片, type=${urlType} urlLen=${url.length}`);
70
-
71
- let info: PalzMediaInfo | null = null;
72
-
73
- if (url.startsWith("data:")) {
74
- // data URL → 解码 → 保存到 OpenClaw 媒体目录
75
- const match = url.match(/^data:(image\/[^;]+);base64,(.+)$/);
76
- if (match) {
77
- const mimeType = match[1];
78
- const base64Data = match[2];
79
- const ext = mimeToExt(mimeType);
80
- const buffer = Buffer.from(base64Data, "base64");
81
- info = saveBufferToMediaDir(buffer, mimeType, ext, log);
82
- }
83
- } else if (url.startsWith("http://") || url.startsWith("https://")) {
84
- // HTTP URL → 下载 → 保存到 OpenClaw 媒体目录
85
- try {
86
- const resp = await fetch(url);
87
- if (resp.ok) {
88
- const contentType = resp.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
89
- const buffer = Buffer.from(await resp.arrayBuffer());
90
- const ext = mimeToExt(contentType);
91
- info = saveBufferToMediaDir(buffer, contentType, ext, log);
92
- } else {
93
- log?.(`palz-media: [resolve] HTTP下载失败: status=${resp.status}`);
94
- }
95
- } catch (err: any) {
96
- log?.(`palz-media: [resolve] HTTP下载异常: ${err.message}`);
159
+ for (const part of content) {
160
+ if (part.type === "file") {
161
+ const filePart = part as FileUrlContentPart;
162
+ if (filePart.file_url?.url) {
163
+ mediaUrls.push(filePart.file_url.url);
97
164
  }
98
165
  }
166
+ }
167
+
168
+ log?.(
169
+ `palz-media: [resolve] 输入: parts=${content.length} fileParts=${mediaUrls.length}`,
170
+ );
99
171
 
100
- if (info) {
101
- results.push(info);
102
- log?.(`palz-media: [resolve] ${i + 1} 完成: ${JSON.stringify(info)}`);
103
- } else {
104
- log?.(`palz-media: [resolve] 第 ${i + 1} 失败`);
172
+ const results: PalzMediaInfo[] = [];
173
+ for (let i = 0; i < mediaUrls.length; i++) {
174
+ const url = mediaUrls[i];
175
+ const urlType = url.startsWith("data:")
176
+ ? "data-url"
177
+ : url.startsWith("http")
178
+ ? "http-url"
179
+ : "unknown";
180
+ log?.(
181
+ `palz-media: [resolve] 处理第 ${i + 1}/${mediaUrls.length} 个媒体, urlType=${urlType} urlLen=${url.length}`,
182
+ );
183
+
184
+ const fetched = await fetchUrlToBuffer(url, log);
185
+ if (fetched) {
186
+ const info = saveBufferToMediaDir(fetched.buffer, fetched.contentType, fetched.ext, log);
187
+ if (info) {
188
+ results.push(info);
189
+ log?.(`palz-media: [resolve] 第 ${i + 1} 完成: ${JSON.stringify(info)}`);
190
+ continue;
191
+ }
105
192
  }
193
+ log?.(`palz-media: [resolve] 第 ${i + 1} 失败`);
106
194
  }
107
195
 
108
- log?.(`palz-media: [resolve] 输出: 共解析 ${results.length}/${imageParts.length} 个媒体文件`);
196
+ log?.(
197
+ `palz-media: [resolve] 输出: 共解析 ${results.length}/${mediaUrls.length} 个媒体文件`,
198
+ );
109
199
  return results;
110
200
  }
111
201
 
112
202
  /**
113
203
  * 将本地文件路径、data URL 或 HTTP URL 转为 OSS 公网链接(用于出站消息)。
114
- * 替代 loadMediaAsDataUrl,避免 base64 传输。
115
204
  */
116
205
  export async function loadMediaAsOssUrl(
117
206
  mediaUrl: string,
@@ -119,15 +208,17 @@ export async function loadMediaAsOssUrl(
119
208
  ): Promise<string | null> {
120
209
  log?.(`palz-media: [loadAsOssUrl] 输入: url=${mediaUrl.slice(0, 200)}`);
121
210
 
122
- // 已经是 OSS 链接,直接返回
123
- if (mediaUrl.startsWith("https://oss.csaiagent.com/") || mediaUrl.startsWith("https://cstv-data.oss-cn-beijing.aliyuncs.com/")) {
211
+ if (
212
+ mediaUrl.startsWith("https://oss.csaiagent.com/") ||
213
+ mediaUrl.startsWith("https://cstv-data.oss-cn-beijing.aliyuncs.com/")
214
+ ) {
124
215
  log?.(`palz-media: [loadAsOssUrl] 已是OSS链接, 直接返回`);
125
216
  return mediaUrl;
126
217
  }
127
218
 
128
219
  // data URL → 解码 → 上传到 OSS
129
220
  if (mediaUrl.startsWith("data:")) {
130
- const match = mediaUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
221
+ const match = mediaUrl.match(/^data:([^;]+);base64,(.+)$/);
131
222
  if (!match) {
132
223
  log?.(`palz-media: [loadAsOssUrl] data URL 格式不匹配`);
133
224
  return null;
@@ -138,7 +229,9 @@ export async function loadMediaAsOssUrl(
138
229
  try {
139
230
  const buffer = Buffer.from(base64Data, "base64");
140
231
  const ossUrl = await uploadBufferToOss(buffer, ext, log);
141
- log?.(`palz-media: [loadAsOssUrl] data URL → OSS: mime=${mimeType} bufSize=${buffer.length} ossUrl=${ossUrl}`);
232
+ log?.(
233
+ `palz-media: [loadAsOssUrl] data URL → OSS: mime=${mimeType} bufSize=${buffer.length} ossUrl=${ossUrl}`,
234
+ );
142
235
  return ossUrl;
143
236
  } catch (err: any) {
144
237
  log?.(`palz-media: [loadAsOssUrl] data URL上传OSS失败: ${err.message}`);
@@ -148,14 +241,25 @@ export async function loadMediaAsOssUrl(
148
241
 
149
242
  // 本地文件路径(绝对或相对)→ 上传到 OSS
150
243
  const rawPath = mediaUrl.replace(/^MEDIA:/, "");
151
- const filePath = path.isAbsolute(rawPath) ? rawPath : path.resolve(rawPath);
244
+ let filePath = path.isAbsolute(rawPath) ? rawPath : path.resolve(rawPath);
245
+ if (!fs.existsSync(filePath)) {
246
+ const fallback = path.join(MEDIA_DIR, path.basename(filePath));
247
+ if (fs.existsSync(fallback)) {
248
+ log?.(`palz-media: [loadAsOssUrl] 路径fallback: ${filePath} → ${fallback}`);
249
+ filePath = fallback;
250
+ }
251
+ }
152
252
  if (fs.existsSync(filePath)) {
153
253
  try {
154
254
  const ossUrl = await uploadFileToOss(filePath, log);
155
- log?.(`palz-media: [loadAsOssUrl] 本地文件 → OSS: path=${filePath} ossUrl=${ossUrl}`);
255
+ log?.(
256
+ `palz-media: [loadAsOssUrl] 本地文件 → OSS: path=${filePath} ossUrl=${ossUrl}`,
257
+ );
156
258
  return ossUrl;
157
259
  } catch (err: any) {
158
- log?.(`palz-media: [loadAsOssUrl] 本地文件上传OSS失败: ${filePath} error=${err.message}`);
260
+ log?.(
261
+ `palz-media: [loadAsOssUrl] 本地文件上传OSS失败: ${filePath} error=${err.message}`,
262
+ );
159
263
  return null;
160
264
  }
161
265
  }
@@ -168,11 +272,15 @@ export async function loadMediaAsOssUrl(
168
272
  log?.(`palz-media: [loadAsOssUrl] HTTP下载失败: status=${resp.status}`);
169
273
  return null;
170
274
  }
171
- const contentType = resp.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
275
+ const contentType =
276
+ resp.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream";
172
277
  const buffer = Buffer.from(await resp.arrayBuffer());
173
- const ext = mimeToExt(contentType);
278
+ const urlExt = extFromUrl(mediaUrl);
279
+ const ext = urlExt || mimeToExt(contentType);
174
280
  const ossUrl = await uploadBufferToOss(buffer, ext, log);
175
- log?.(`palz-media: [loadAsOssUrl] HTTP → OSS: size=${buffer.length} mime=${contentType} ossUrl=${ossUrl}`);
281
+ log?.(
282
+ `palz-media: [loadAsOssUrl] HTTP → OSS: size=${buffer.length} mime=${contentType} ossUrl=${ossUrl}`,
283
+ );
176
284
  return ossUrl;
177
285
  } catch (err: any) {
178
286
  log?.(`palz-media: [loadAsOssUrl] HTTP下载上传OSS异常: ${err.message}`);
package/src/outbound.ts CHANGED
@@ -20,14 +20,15 @@ export const palzOutbound = {
20
20
  log(`palz-outbound: [sendText] 输入: to="${to}" accountId="${accountId}" textLen=${text?.length || 0} text="${(text || "").slice(0, 120)}"`);
21
21
 
22
22
  const account = resolvePalzAccount({ cfg, accountId });
23
- const { senderId, conversationId } = parsePalzTarget(to);
24
- log(`palz-outbound: [sendText] 解析: senderId="${senderId}" conversationId="${conversationId}" botId=${account.config.botId}`);
23
+ const { senderId, conversationId, conversationType } = parsePalzTarget(to);
24
+ log(`palz-outbound: [sendText] 解析: senderId="${senderId}" conversationId="${conversationId}" conversationType="${conversationType}" botId=${account.config.botId}`);
25
25
 
26
26
  const result = await sendToPalzIM({
27
27
  config: account.config,
28
28
  conversationId,
29
29
  content: text,
30
30
  senderId,
31
+ conversationType,
31
32
  });
32
33
 
33
34
  const output = { channel: "palz-connector", messageId: Date.now().toString() };
@@ -38,11 +39,11 @@ export const palzOutbound = {
38
39
  sendMedia: async (ctx: any) => {
39
40
  const { cfg, to, text, mediaUrl, accountId } = ctx;
40
41
  const log = typeof ctx.log === "function" ? ctx.log : console.log;
41
- log(`palz-outbound: [sendMedia] 输入: to="${to}" accountId="${accountId}" textLen=${text?.length || 0} mediaUrl="${(mediaUrl || "").slice(0, 200)}"`);
42
42
 
43
43
  const account = resolvePalzAccount({ cfg, accountId });
44
- const { senderId, conversationId } = parsePalzTarget(to);
45
- log(`palz-outbound: [sendMedia] 解析: senderId="${senderId}" conversationId="${conversationId}" botId=${account.config.botId}`);
44
+ const { senderId, conversationId, conversationType } = parsePalzTarget(to);
45
+ log(`palz-outbound: [sendMedia] 输入: to="${to}" accountId="${accountId}" textLen=${text?.length || 0} mediaUrl="${(mediaUrl || "").slice(0, 200)}"`);
46
+ log(`palz-outbound: [sendMedia] 解析: senderId="${senderId}" conversationId="${conversationId}" conversationType="${conversationType}" botId=${account.config.botId}`);
46
47
 
47
48
  const contentParts: ContentPart[] = [];
48
49
 
@@ -53,7 +54,7 @@ export const palzOutbound = {
53
54
  if (mediaUrl) {
54
55
  const ossUrl = await loadMediaAsOssUrl(mediaUrl, log);
55
56
  if (ossUrl) {
56
- contentParts.push({ type: "image_url", image_url: { url: ossUrl } });
57
+ contentParts.push({ type: "file", file_url: { url: ossUrl } });
57
58
  log(`palz-outbound: [sendMedia] 媒体转换成功: ossUrl=${ossUrl}`);
58
59
  } else {
59
60
  contentParts.push({ type: "text", text: `\n📎 ${mediaUrl}` });
@@ -73,6 +74,7 @@ export const palzOutbound = {
73
74
  conversationId,
74
75
  content,
75
76
  senderId,
77
+ conversationType,
76
78
  });
77
79
 
78
80
  const output = { channel: "palz-connector", messageId: Date.now().toString() };
@@ -121,7 +121,7 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
121
121
  log(`${tag}: [DELIVER 媒体] ${i + 1}/${mediaUrls.length} url=${mediaUrls[i].slice(0, 200)}`);
122
122
  const ossUrl = await loadMediaAsOssUrl(mediaUrls[i], log);
123
123
  if (ossUrl) {
124
- contentParts.push({ type: "image_url", image_url: { url: ossUrl } });
124
+ contentParts.push({ type: "file", file_url: { url: ossUrl } });
125
125
  log(`${tag}: [DELIVER 媒体转换成功] ${i + 1}/${mediaUrls.length} ossUrl=${ossUrl}`);
126
126
  } else {
127
127
  contentParts.push({ type: "text", text: `\n📎 ${mediaUrls[i]}` });
package/src/targets.ts CHANGED
@@ -11,23 +11,31 @@ export function normalizePalzTarget(raw: string): string | undefined {
11
11
 
12
12
  export function looksLikePalzId(raw: string): boolean {
13
13
  const trimmed = raw.trim().replace(/^(palz-connector|palz):/i, "");
14
- return /^[\w:._-]+$/.test(trimmed);
14
+ return /^[\p{L}\p{N}\w:._-]+$/u.test(trimmed);
15
15
  }
16
16
 
17
17
  /**
18
18
  * 从 "to" 地址中解析出 senderId 和 conversationId。
19
- * 格式: "{senderId}:{conversationId}"
19
+ * 格式:
20
+ * DM: "{senderId}:{conversationId}"
21
+ * 群聊: "chat:{conversationId}"(senderId 为空,群消息不需要指定 senderId)
20
22
  */
21
23
  export function parsePalzTarget(to: string): {
22
24
  senderId?: string;
23
25
  conversationId: string;
26
+ conversationType: string;
24
27
  } {
28
+ // 群聊目标:chat:xxx → senderId 留空
29
+ if (to.startsWith("chat:")) {
30
+ return { conversationId: to.slice(5), conversationType: "group" };
31
+ }
25
32
  const parts = to.split(":");
26
33
  if (parts.length >= 2) {
27
34
  return {
28
35
  senderId: parts[0],
29
36
  conversationId: parts.slice(1).join(":"),
37
+ conversationType: "direct",
30
38
  };
31
39
  }
32
- return { conversationId: to };
40
+ return { conversationId: to, conversationType: "direct" };
33
41
  }
package/src/types.ts CHANGED
@@ -5,11 +5,11 @@
5
5
  // ============ IM 消息格式(OpenAI Content 协议) ============
6
6
 
7
7
  export type TextContentPart = { type: "text"; text: string };
8
- export type ImageUrlContentPart = {
9
- type: "image_url";
10
- image_url: { url: string; detail?: string };
8
+ export type FileUrlContentPart = {
9
+ type: "file";
10
+ file_url: { url: string };
11
11
  };
12
- export type ContentPart = TextContentPart | ImageUrlContentPart;
12
+ export type ContentPart = TextContentPart | FileUrlContentPart;
13
13
  export type OpenAIContent = string | ContentPart[];
14
14
 
15
15
  // ============ Palz IM 消息事件 ============
@@ -17,6 +17,7 @@ export type OpenAIContent = string | ContentPart[];
17
17
  export interface PalzMessageEvent {
18
18
  event: string;
19
19
  sender_id: string;
20
+ sender_name?: string;
20
21
  receiver_id: string;
21
22
  conversation_id: string;
22
23
  conversation_type: string;
@@ -26,6 +27,8 @@ export interface PalzMessageEvent {
26
27
  timestamp: number;
27
28
  /** 可选,指定 OpenClaw agent ID,覆盖默认路由 */
28
29
  agent_id?: string;
30
+ /** 群聊中是否 @了机器人 */
31
+ mentioned_bot?: boolean;
29
32
  }
30
33
 
31
34
  // ============ 配置 ============