🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

lightclawbot

Package Overview
Dependencies
Maintainers
1
Versions
30
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

lightclawbot - npm Package Compare versions

Comparing version
1.2.8
to
1.2.9
+22
-0
dist/src/history/message-parser.js

@@ -213,2 +213,24 @@ /**

files = deduplicateFiles(allFiles);
// 修复:当用户消息有 MediaPath/MediaPaths(OpenClaw 存在附件文件)
// 但 files 里没有 uri 时,补上 localfile:// 格式的 uri
if (!files || files.some((f) => !f.uri)) {
const mediaPaths = Array.isArray(msg.MediaPaths)
? msg.MediaPaths
: (typeof msg.MediaPath === "string" ? [msg.MediaPath] : []);
if (mediaPaths.length > 0) {
const existingFiles = files ?? [];
const needInit = existingFiles.length === 0;
const updatedFiles = needInit
? mediaPaths.map((p, i) => ({
name: existingFiles[i]?.name ?? p.split("/").pop() ?? `file-${i}`,
mimeType: existingFiles[i]?.mimeType,
uri: `localfile://${p}`,
}))
: existingFiles.map((f, i) => ({
...f,
uri: f.uri ?? (mediaPaths[i] ? `localfile://${mediaPaths[i]}` : undefined),
}));
files = updatedFiles;
}
}
}

@@ -215,0 +237,0 @@ else {

+53
-2

@@ -87,13 +87,48 @@ /**

const msgId = generateOutboundMsgId();
// 构造标准消息体,格式与 gateway 入站消息保持一致
const fromId = entry?.botClientId ?? getBotClientId(resolvedAccountId) ?? '';
// 构造标准消息体,格式与 gateway 入站消息保持一致。
//
// 协议关键字段说明:
// - kind: 'stream_chunk'
// outbound 由 LLM 工具调用结果(如 message 工具发图/发文)触发,
// 归属于"流式回复信号链"的一部分,前端依赖 kind 推导消息状态
// (stream_chunk → 'updating')。若缺省,前端 statusMap 会兜底到
// typing_stop=='success',导致内容被独立成一条"已完成回复"气泡,
// 与上一段 LLM 文本(已 typing_stop)错位拼接。详见 stream-reply-sink。
// - extra.chatId: ''
// 协议规定所有 EVENT_MESSAGE_PRIVATE 必须携带 extra.chatId(多会话分桶)。
// outbound 触发时通常无明确 chatId 上下文(cron / message 工具 / REST 等),
// 按协议约定填空字符串落到默认会话。
// - idempotencyKey: 由 ReliableEmitter 在 emitWithAck 内部注入,此处无需显式构造。
const message = {
msgId,
// 优先使用 entry 中缓存的 botClientId,entry 不存在时从 registry 直接查询
from: entry?.botClientId ?? getBotClientId(resolvedAccountId) ?? '',
from: fromId,
to: target,
content: text,
timestamp: Date.now(),
kind: 'stream_chunk',
// undefined 表示非回复消息,避免发送多余字段
replyToMsgId: replyToId ?? undefined,
extra: { chatId: '' },
};
// 自闭合 typing_stop 帧:与 stream_chunk 共用同一个 msgId / chatId,
// content 必须为空字符串(前端 addOrUpdateMessage 会按 oldContent + content 拼接,
// 非空 content 会让 caption 文本被复制一遍)。
//
// 设计理由:
// outbound 通道(如 message 工具调用结果)由 OpenClaw 框架以「独立 messageId」投递,
// 不会与上一段 LLM 流式回复合并。仅发 stream_chunk 而无 typing_stop,
// 会导致前端这条独立气泡永远停留在 'updating' 状态(loading 转圈)。
// 故每条 outbound 消息都自带一帧 typing_stop 收尾,让该气泡正常进入 'success'。
const buildClosingFrame = () => ({
msgId,
from: fromId,
to: target,
content: '',
timestamp: Date.now(),
kind: 'typing_stop',
replyToMsgId: replyToId ?? undefined,
extra: { chatId: '' },
});
if (entry) {

@@ -109,2 +144,12 @@ // 策略 1:Socket 已连接,优先走可靠发送(emitWithAck + 自动重试)

});
// 紧跟一帧 typing_stop 闭合该 msgId 的流式状态。
// ReliableEmitter 内部按 emit 顺序生成 idempotencyKey(字典序 == emit 顺序),
// 即便网络层 batch 抖动导致 packet 乱序到达,前端也能按字典序合并出
// 「stream_chunk 在前 → typing_stop 在后」的稳定终态。
const closingFrame = buildClosingFrame();
emitter.emitWithAck(EVENT_MESSAGE_PRIVATE, closingFrame, msgId).then((ok) => {
if (!ok) {
logger.warn(`[${CHANNEL_KEY}] outbound closing frame ack timeout: msgId=${msgId}`);
}
});
logger.info(`[${CHANNEL_KEY}] outbound sent via reliable WS: to=${target} msgId=${msgId}`);

@@ -116,2 +161,4 @@ return msgId;

entry.socket.emit(EVENT_MESSAGE_PRIVATE, message);
// 同样补一帧 typing_stop;裸 emit 无 ack,无法保证送达,best-effort 即可。
entry.socket.emit(EVENT_MESSAGE_PRIVATE, buildClosingFrame());
logger.info(`[${CHANNEL_KEY}] outbound sent via WS (fallback): to=${target} msgId=${msgId}`);

@@ -131,2 +178,6 @@ return msgId;

if (buffered) {
// 顺序 push 一帧 typing_stop,flush 时随业务消息一起按 FIFO 发出。
// bufferMessage 失败(队列满)则跳过闭合帧 —— 主消息都没缓冲住,
// 闭合帧也没意义;前端会因永远收不到 stream_chunk 而不展示这条气泡。
bufferMessage(resolvedAccountId, buildClosingFrame());
logger.info(`[${CHANNEL_KEY}] outbound buffered (WS reconnecting): to=${target} msgId=${msgId}`);

@@ -133,0 +184,0 @@ return msgId;

+98
-37

@@ -36,2 +36,52 @@ /**

}
/** 已知的图片扩展名集合,用于判断是否走 files 渲染(而非 markdown 文本链接)。 */
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico"]);
/**
* 判断 URL 指向的资源是否为图片(基于文件扩展名)。
*
* 优先匹配 COS 公网 URL 中的 `filePath=xxx` query 参数;
* 回退到 URL 末段。判断失败一律按"非图片"处理(保留文本链接,前端兼容性更好)。
*/
function isImageUrl(url) {
let candidate = url;
const filePathMatch = url.match(/filePath=([^&]+)/);
if (filePathMatch) {
try {
candidate = decodeURIComponent(filePathMatch[1]);
}
catch {
candidate = filePathMatch[1];
}
}
const lastDot = candidate.lastIndexOf(".");
if (lastDot < 0)
return false;
const ext = candidate.slice(lastDot + 1).split(/[?#]/)[0].toLowerCase();
return IMAGE_EXTS.has(ext);
}
/**
* 对 mediaUrls 入口去重:同一文件常以裸路径 `/path/to/x.png` 和带前缀
* `file:///path/to/x.png` 两种形式同时出现(如 browser screenshot toolResult),
* 不归一就会让前端同时渲染两份"全部文件"卡片(即用户看到的"两个全部文件按钮")。
*
* 归一规则:
* - 去掉 `file://` 前缀做"路径键"比较;
* - 保留首次出现的原始字符串(保留 `file://` 与否取决于 mediaList 顺序),
* 避免破坏后续 `existsSync` / `mediaUrlsToFiles` 对路径形态的处理。
*
* 不做内容级去重(buffer hash),因为 data URL 与本地路径同图的归一代价较高,
* 且配合"图片不再重复拼 markdown"已能消除可见的重复渲染。
*/
function dedupMediaUrls(urls) {
const seen = new Set();
const result = [];
for (const url of urls) {
const key = url.startsWith("file://") ? url.slice(7) : url;
if (seen.has(key))
continue;
seen.add(key);
result.push(url);
}
return result;
}
export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {

@@ -241,13 +291,25 @@ const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts;

/**
* 处理 payload 中的 mediaUrls:COS 上传 → 拼 Markdown 链接 → 推送给用户。
* 处理 payload 中的 mediaUrls:去重 → COS 上传 → 与文本同帧推送给用户。
* 仅在 mediaList 非空时调用。
*
* 群聊模式:通过 stream_chunk 信号帧推送 Markdown 图片/文件链接(带群聊 extra)。
* 私聊模式:通过 emitter.sendFiles / emitter.sendReply 推送。
* 私聊 / 群聊统一通过 `emitSignal('stream_chunk', ...)` 走信号链,
* 复用本轮 replyMsgId(与 stream_chunk 文本同 msgId 合并到同一气泡),
* 同时携带 kind / agentId / extra.chatId / chatKind 等协议字段,
* 避免前端因 kind 缺失而把媒体帧兜底为 typing_stop 完成态独立气泡。
*
* 协议约定:
* - 图片由顶层 `files` 字段独家承载(前端文件卡片渲染),
* 文本里**不再**额外拼 `![](url)` markdown,避免"两张图"重复渲染。
* - 非图片文件(pdf / 压缩包等)保留 `📎 [filename](url)` 文本链接,
* 方便用户复制公网 URL,同时仍透传 files 给前端文件卡片。
*/
const handleMediaFinal = async (replyText, mediaList) => {
const files = await mediaUrlsToFiles(mediaList, log);
// 去重:mediaUrls 常包含同一文件的多种引用(裸路径 / file:// 前缀),
// 归一后再交给下游处理,避免 files 数组与 publicUrls 数组各自重复一次。
const dedupedMediaList = dedupMediaUrls(mediaList);
const files = await mediaUrlsToFiles(dedupedMediaList, log);
const storageConfig = { apiKey: effectiveApiKey };
const publicUrls = [];
for (const mediaUrl of mediaList) {
/** 仅非图片文件需要在文本里给出公网链接,图片由 files 渲染。 */
const nonImagePublicLinks = [];
for (const mediaUrl of dedupedMediaList) {
try {

@@ -259,4 +321,11 @@ const localPath = mediaUrl.startsWith("file://") ? mediaUrl.slice(7) : mediaUrl;

const result = await uploadFileToServer(localPath, storageConfig);
publicUrls.push(result.url || "");
log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${result.url}`);
const publicUrl = result.url || "";
log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${publicUrl}`);
// 仅非图片文件保留文本链接(图片由 files 渲染,避免重复)
if (publicUrl && !isImageUrl(publicUrl)) {
const match = publicUrl.match(/filePath=([^&]+)/);
const filePath = match ? decodeURIComponent(match[1]) : "";
const fileName = filePath.split("/").pop() || "file";
nonImagePublicLinks.push(`📎 [${fileName}](${publicUrl})`);
}
}

@@ -270,37 +339,29 @@ }

let enrichedText = replyText;
if (publicUrls.length > 0) {
// 判断是否为图片类型(根据 URL 中的文件扩展名)
const urlSection = publicUrls
.map((url, i) => {
const match = url.match(/filePath=([^&]+)/);
const filePath = match ? decodeURIComponent(match[1]) : "";
const fileName = filePath.split("/").pop() || `file${publicUrls.length > 1 ? ` (${i + 1})` : ""}`;
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext);
// 图片使用 Markdown 图片语法,非图片使用链接语法
return isImage ? `![${fileName}](${url})` : `📎 [${fileName}](${url})`;
})
.join("\n");
if (nonImagePublicLinks.length > 0) {
const urlSection = nonImagePublicLinks.join("\n");
enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection;
}
// ── 群聊模式:通过 stream_chunk 推送(带群聊 extra,前端能正确识别) ──
// 没有任何可见内容(既无文本又无文件)→ 直接返回,避免发空帧
if (!enrichedText.trim() && files.length === 0) {
return;
}
// ── 群聊模式:通过 stream_chunk 推送(带群聊 extra) ──
// 群聊额外把文本累加到 streamedText,让 markComplete 的 aggregatedText 能算上这段。
if (isGroupMode) {
if (enrichedText.trim()) {
const delta = `\n\n${enrichedText}`;
const delta = enrichedText.trim() ? `\n\n${enrichedText}` : "";
if (delta)
streamedText += delta;
emitSignal(signalCtx, "stream_chunk", delta, groupSignalExtra, buildGroupDataExtra('delta'));
emittedUserVisible = true;
log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${publicUrls.length} media as stream_chunk`);
}
emitSignal(signalCtx, "stream_chunk", delta, { ...(files.length > 0 ? { files } : {}), ...groupSignalExtra }, buildGroupDataExtra('delta'));
emittedUserVisible = true;
log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${files.length} file(s), ` +
`nonImageLinks=${nonImagePublicLinks.length}`);
return;
}
// ── 私聊模式:通过 sendFiles / sendReply 推送 ──
if (files.length > 0) {
emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId);
emittedUserVisible = true;
}
else if (enrichedText.trim()) {
emitter.sendReply(targetId, enrichedText, originalMsgId, signalCtx.chatId);
emittedUserVisible = true;
}
// ── 私聊模式:同样通过 stream_chunk 走信号链 ──
// 关键:复用 signalCtx.replyMsgId → 与本轮 stream_chunk 文本同 msgId 合并,
// 且自动携带 kind/agentId/extra.chatId,前端不再兜底为 typing_stop。
emitSignal(signalCtx, "stream_chunk", enrichedText, files.length > 0 ? { files } : undefined);
emittedUserVisible = true;
log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal: pushed ${files.length} file(s) via stream_chunk, ` +
`textLen=${enrichedText.length} nonImageLinks=${nonImagePublicLinks.length}`);
};

@@ -307,0 +368,0 @@ // ────────────────────────────────────────

@@ -48,3 +48,3 @@ /**

name: UPLOAD_TOOL_NAME,
description: "将 AI 生成的本机文件注册为可下载的引用,返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。" +
description: "重要:将 AI 生成的本机文件注册为可下载的引用,要求必须立马返回 `[<文件名称>]localfile://<绝对路径>` 格式的 Markdown 下载链接;用户可以下载。" +
"当需要把生成的文件(图片、文档、代码包等)交付给用户时,必须使用此工具。" +

@@ -51,0 +51,0 @@ "文件必须先写入磁盘,然后传入绝对路径。单次最多 5 个文件,超出请分批调用。" +

@@ -56,3 +56,7 @@ import * as fs from 'node:fs';

* - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。
* - topLevelExtra 用于透传顶层 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。
* - topLevelExtra 用于透传顶层 kind 相关字段:
* · tool_start 的 toolName / toolPhase
* · tool_end 的 toolIsError
* · 群聊场景的 chatKind
* · 媒体附件的 files(与文本同帧投递,确保 kind/agentId/extra 齐全)
* - innerExtra 用于在 PrivateMessageData.extra 内追加自定义字段(如 usage 帧的 usage 对象)。

@@ -59,0 +63,0 @@ * chatId 始终由本函数管理,调用方无需也不能在 innerExtra 中传 chatId(会被覆盖)。

{
"name": "lightclawbot",
"version": "1.2.8",
"version": "1.2.9",
"description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging",

@@ -5,0 +5,0 @@ "type": "module",