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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/palz-connector.prod.config.json +2 -2
- package/src/bot.ts +201 -52
- package/src/channel.ts +1 -1
- package/src/dedup.ts +11 -6
- package/src/media.ts +177 -69
- package/src/outbound.ts +8 -6
- package/src/reply-dispatcher.ts +1 -1
- package/src/targets.ts +11 -3
- package/src/types.ts +7 -4
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"enabled": true,
|
|
3
|
-
"streamUrl": "wss://claw-server.
|
|
4
|
-
"apiBaseUrl": "https://claw-server.
|
|
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
|
-
|
|
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
|
|
93
|
-
Array.isArray(content) &&
|
|
94
|
-
|
|
95
|
-
|
|
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})
|
|
187
|
+
log(`${tag}: [STEP 1 解析] plainText="${plainText}" (len=${plainText.length}) hasMedia=${hasMedia} mediaCount=${mediaCount}`);
|
|
99
188
|
|
|
100
|
-
if (!plainText && !
|
|
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 =
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 = `${
|
|
167
|
-
|
|
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:
|
|
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
|
-
|
|
303
|
+
// 群聊:To 指向群,DM:To 指向用户会话
|
|
304
|
+
const palzTo = isGroup ? `chat:${msg.conversation_id}` : `${msg.sender_id}:${msg.conversation_id}`;
|
|
180
305
|
|
|
181
|
-
//
|
|
182
|
-
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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:
|
|
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:
|
|
362
|
+
ChatType: chatType,
|
|
363
|
+
GroupSubject: isGroup ? msg.conversation_id : undefined,
|
|
226
364
|
SenderId: msg.sender_id,
|
|
227
|
-
SenderName:
|
|
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:
|
|
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
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 [
|
|
14
|
-
if (now - ts > MESSAGE_DEDUP_TTL) processedMessages.delete(
|
|
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
|
-
|
|
27
|
-
processedMessages.
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
62
|
-
log?.(`palz-media: [resolve] 输入: parts=${content.length} imageParts=${imageParts.length}`);
|
|
157
|
+
const mediaUrls: string[] = [];
|
|
63
158
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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?.(
|
|
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
|
-
|
|
123
|
-
|
|
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:(
|
|
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?.(
|
|
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
|
-
|
|
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?.(
|
|
255
|
+
log?.(
|
|
256
|
+
`palz-media: [loadAsOssUrl] 本地文件 → OSS: path=${filePath} ossUrl=${ossUrl}`,
|
|
257
|
+
);
|
|
156
258
|
return ossUrl;
|
|
157
259
|
} catch (err: any) {
|
|
158
|
-
log?.(
|
|
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 =
|
|
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
|
|
278
|
+
const urlExt = extFromUrl(mediaUrl);
|
|
279
|
+
const ext = urlExt || mimeToExt(contentType);
|
|
174
280
|
const ossUrl = await uploadBufferToOss(buffer, ext, log);
|
|
175
|
-
log?.(
|
|
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]
|
|
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: "
|
|
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() };
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -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: "
|
|
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:._-]
|
|
14
|
+
return /^[\p{L}\p{N}\w:._-]+$/u.test(trimmed);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* 从 "to" 地址中解析出 senderId 和 conversationId。
|
|
19
|
-
* 格式:
|
|
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
|
|
9
|
-
type: "
|
|
10
|
-
|
|
8
|
+
export type FileUrlContentPart = {
|
|
9
|
+
type: "file";
|
|
10
|
+
file_url: { url: string };
|
|
11
11
|
};
|
|
12
|
-
export type ContentPart = TextContentPart |
|
|
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
|
// ============ 配置 ============
|