🚀 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.3
to
1.2.5
+257
dist/src/socket/chat.js
/**
* LightClaw — Chat(会话元信息)处理器
*
* 收敛 EVENT_CHAT_REQUEST 各子类型的处理逻辑,与 handlers.ts 中的
* 消息 / 历史 / Agents 等事件处理保持平级解耦,避免 handlers.ts 膨胀。
*
* 涉及的持久化文件:
* `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json`
* └─ 以 ChatMeta[] 形式存储该用户在该 Agent 下的会话元信息列表
* (不包含消息正文,正文以 jsonl 形式另外存储)
*
* 四个处理器(对应 data.type):
* - list: 读取 chats.json 返回 { chats };文件不存在 / 解析失败
* 时返回空数组,不抛错,避免阻塞前端。
* - create: 新建一条会话元数据,chatId = randomUUID(),插入列表最前。
* 文件或目录不存在时由 writeChatsFile 递归创建。
* - update: 根据 chatId 修改 title(并置 titleLocked=true + 刷新 updatedAt);
* 文件或条目不存在时回 error,不隐式创建。
* - delete: 根据 chatId 从列表剔除,原子写回;文件或条目不存在时回 error。
*
* 全部响应均通过 EVENT_CHAT_RESPONSE + ReliableEmitter 发出(带 ACK + 重试)。
* 错误处理统一走 createChatErrorSender 生成的回调,保证日志 / 响应结构一致。
*/
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import { CHANNEL_KEY, EVENT_CHAT_RESPONSE } from '../config.js';
import { generateMsgId } from '../dedup.js';
import { readChatsFile, readChatsFileOrInitDefault, resolveChatsFilePath, writeChatsFile } from '../utils/common.js';
/**
* 生成统一的错误响应发送器。
*
* 背景:list / create / update / delete 四个 handler 的错误处理结构完全一致:
* 1. 打同一格式日志:`[CHANNEL_KEY] Chat <type> error: <msg>`
* 2. 通过 EVENT_CHAT_RESPONSE 回同一组基础字段(msgId/from/to/type/agentId/error)
* 仅差异项:
* - `type` 不同
* - `chatId` 是否需要回显(update/delete 需要)
* - list 出错时前端期望一个兜底的空列表 `chats: []`,避免 UI 处理 undefined
*
* 因此将其封装为模块级工厂,handler 开头调用一次即可,避免重复闭包。
*
* @param ctx 共享上下文(见 ChatErrorSenderContext)
* @param extra 额外字段(如 `chatId`、兜底 `chats: []`),会与 error 一并合并到响应体
* @returns ChatErrorSender — 调用时传入错误信息即可
*/
function createChatErrorSender(ctx, extra = {}) {
const { responseMsgId, botClientId, userId, agentId, type, reliableEmitter, log } = ctx;
return (error) => {
log?.error(`[${CHANNEL_KEY}] Chat ${type} error: ${error}`);
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
msgId: responseMsgId,
from: botClientId,
to: userId,
type,
agentId,
timestamp: Date.now(),
...extra,
error,
}, responseMsgId);
};
}
/**
* 处理 EVENT_CHAT_REQUEST(type=list):返回该用户在指定 Agent 下的会话列表。
*
* 行为:
* 1. 解析当前用户 + Agent 的 chats.json 绝对路径
* 2. 调用 readChatsFileOrInitDefault 读取:文件不存在时**主动落盘一条 chatId=''
* 的默认会话兜底条目**,保证前端拉列表永远拿到至少一项;格式异常时返回 []
* 3. 通过 EVENT_CHAT_RESPONSE 回 { chats };若意外抛异常则回 { chats: [], error }
*
* 为什么 list 路径要主动兜底?
* - 用户首次连接拉列表 → 拿到默认对话 → UI 展示一致,不会出现
* “先空态、首条消息后又凭空多出一项”的诡异闪烁;
* - 兜底是幂等的:若 chats.json 已存在则等价于纯读取,无副作用;
* - 与 ensureSessionInHistory 的被动兜底形成双保险,覆盖“前端跳过 list
* 直接发消息”的极端场景。
*
* 为什么 create/update/delete/history:request 仍走纯只读 readChatsFile?
* - 它们要么是用户主动操作(隐式创建语义已在 create 中处理),
* 要么是只读查询(不应有写盘副作用),由 list 路径统一负责兜底即可。
*/
export function handleChatList(params) {
const { userId, agentId, botClientId, reliableEmitter, log } = params;
// 当前用户在当前 Agent 下的会话记录保存路径,即 chats.json 的绝对路径
const chatsPath = resolveChatsFilePath(agentId, userId);
// 响应 msgId:同时做 ReliableEmitter 的 ack key,成功 / 失败分支共用同一枚
const responseMsgId = generateMsgId();
// list 出错时,用一个空数组兑底,避免 UI 处理 undefined
const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'list', reliableEmitter, log }, { chats: [] });
try {
// 主动兜底读取:文件不存在则落盘一条 chatId='' 的默认会话;存在则等价于纯只读
const chats = readChatsFileOrInitDefault(chatsPath);
log?.info(`[${CHANNEL_KEY}] Chat list: userId=${userId} agentId=${agentId} count=${chats.length}`);
// 正常分支:统一经 ReliableEmitter 发送,依赖其 ACK + 重试保障送达
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
msgId: responseMsgId,
from: botClientId,
to: userId,
type: 'list',
agentId,
chats,
timestamp: Date.now(),
}, responseMsgId);
}
catch (err) {
// 兼容 Error / 非 Error 投掷(如字符串 throw),统一拼接为可读消息
sendError(err instanceof Error ? err.message : String(err));
}
}
/**
* 处理 EVENT_CHAT_REQUEST(type=create):新建一条会话元数据。
*
* 行为:
* 1. 解析 `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json`
* 2. 文件不存在 → 以空列表起步(writeChatsFile 会递归建目录 + 原子落盘)
* 3. 文件存在 → 读取并在头部插入新会话(新建的默认置顶于列表最前)
* 4. 新会话默认 `title = '新会话'`,`chatId = randomUUID()`,createdAt = updatedAt = now
* 5. 通过 EVENT_CHAT_RESPONSE 返回 { chat: 新建项 }
*/
export function handleChatCreate(params) {
const { userId, agentId, botClientId, reliableEmitter, log } = params;
const chatsPath = resolveChatsFilePath(agentId, userId);
// 响应 msgId:成功 / 失败分支共用,侜助前端根据 msgId 反查原请求
const responseMsgId = generateMsgId();
const sendError = createChatErrorSender({
responseMsgId,
botClientId,
userId,
agentId,
type: 'create',
reliableEmitter,
log,
});
try {
// 读取已有列表:文件不存在则返回 [] —— create 是隐式创建语义
const existing = readChatsFile(chatsPath);
const now = Date.now();
const newChat = {
chatId: randomUUID(),
title: '新会话',
// 新建时 titleLocked=false:首条消息后可被自动标题生成策略改写
titleLocked: false,
createdAt: now,
updatedAt: now,
pinned: false,
// sessionIdHistory:同一 chat 历史上使用过的 sessionId(支持会话分支 / 重置)
sessionIdHistory: [],
};
// 新会话插入到最前,writeChatsFile 会在目录不存在时逐级创建(tmp → rename 原子落盘)
const nextChats = [newChat, ...existing];
writeChatsFile(chatsPath, nextChats);
log?.info(`[${CHANNEL_KEY}] Chat create: userId=${userId} agentId=${agentId} chatId=${newChat.chatId}`);
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
msgId: responseMsgId,
from: botClientId,
to: userId,
type: 'create',
agentId,
chats: [newChat],
timestamp: Date.now(),
}, responseMsgId);
}
catch (err) {
sendError(err instanceof Error ? err.message : String(err));
}
}
export function handleChatUpdate(params) {
const { userId, agentId, chatId, title, botClientId, reliableEmitter, log } = params;
const chatsPath = resolveChatsFilePath(agentId, userId);
const responseMsgId = generateMsgId();
// update 错误响应需要回显 chatId,便于前端关联请求
const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'update', reliableEmitter, log }, { chatId });
// title 可能为纯空白字符,trim 后再判断,避免“空标题”落盘
const nextTitle = title?.trim();
if (!nextTitle) {
sendError('Missing title');
return;
}
// 文件必须存在,update 不负责创建(语义上:无源数据则无从更新)
if (!fs.existsSync(chatsPath)) {
sendError(`Chats file not found: ${chatsPath}`);
return;
}
try {
const chats = readChatsFile(chatsPath);
// O(n) 线性查找;chats.json 一般单用户单 Agent 规模有限,无需 Map 索引
const idx = chats.findIndex((c) => c.chatId === chatId);
if (idx < 0) {
sendError(`Chat not found: chatId=${chatId}`);
return;
}
// 更新:title + updatedAt + titleLocked
// titleLocked=true:用户主动改名后锁定,防止被自动标题生成策略覆盖
const updated = {
...chats[idx],
title: nextTitle,
titleLocked: true,
updatedAt: Date.now(),
};
// 浅拷贝后原位替换,保留其他项的顺序(列表排序给前端决定,后端不托管)
const nextChats = [...chats];
nextChats[idx] = updated;
// 原子落盘:writeChatsFile 内部使用 tmp + rename 保证不损坏原文件
writeChatsFile(chatsPath, nextChats);
log?.info(`[${CHANNEL_KEY}] Chat update: userId=${userId} agentId=${agentId} chatId=${chatId} title="${nextTitle}"`);
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
msgId: responseMsgId,
from: botClientId,
to: userId,
type: 'update',
agentId,
chats: [updated],
timestamp: Date.now(),
}, responseMsgId);
}
catch (err) {
sendError(err instanceof Error ? err.message : String(err));
}
}
export function handleChatDelete(params) {
const { userId, agentId, chatId, botClientId, reliableEmitter, log } = params;
const chatsPath = resolveChatsFilePath(agentId, userId);
const responseMsgId = generateMsgId();
// delete 错误响应需要回显 chatId,便于前端关联请求
const sendError = createChatErrorSender({ responseMsgId, botClientId, userId, agentId, type: 'delete', reliableEmitter, log }, { chatId });
// 文件必须存在,delete 不负责创建(无文件 == 无可删的项,返回明确错误)
if (!fs.existsSync(chatsPath)) {
sendError(`Chats file not found: ${chatsPath}`);
return;
}
try {
const chats = readChatsFile(chatsPath);
// 先确认条目存在再写盘,避免“无效删除”仍触发一次磁盘 IO
const idx = chats.findIndex((c) => c.chatId === chatId);
if (idx < 0) {
sendError(`Chat not found: chatId=${chatId}`);
return;
}
// 剔除目标项,保留其余顺序(不重排,不影响前端缓存的列表索引)
const nextChats = chats.filter((c) => c.chatId !== chatId);
// 原子落盘:writeChatsFile 内部使用 tmp + rename 保证不损坏原文件
writeChatsFile(chatsPath, nextChats);
log?.info(`[${CHANNEL_KEY}] Chat delete: userId=${userId} agentId=${agentId} chatId=${chatId} remaining=${nextChats.length}`);
reliableEmitter.emitWithAck(EVENT_CHAT_RESPONSE, {
msgId: responseMsgId,
from: botClientId,
to: userId,
type: 'delete',
agentId,
chats: chats.filter((c) => c.chatId === chatId),
timestamp: Date.now(),
}, responseMsgId);
}
catch (err) {
sendError(err instanceof Error ? err.message : String(err));
}
}
+160
-39
/**
* LightClaw — ChannelPlugin 主定义
* ============================================================================
* channel.ts —— Lightclaw Channel Plugin 主入口
* ============================================================================
*
* 使用 SDK 标准的 createChatChannelPlugin 构建器,
* 参照官方 Discord/Slack channel 实现。
* 本文件通过 OpenClaw 的 Plugin SDK 创建并导出一个「聊天类 Channel 插件」
* (`lightclawPlugin`),用于把 Lightclaw(AI 助手 Server)接入到 OpenClaw
* 网关体系中,作为一个标准的消息通道(Channel)使用。
*
* 结构:
* base: 来自 shared.ts 的 createLightclawPluginBase + 扩展字段
* security: DM 安全策略
* outbound: 出站消息适配器
* 该插件主要负责以下几件事:
* 1. messaging:目标字符串(user:xxx / channel:xxx)的规范化与识别;
* 2. status :暴露插件/账户运行时状态,供 OpenClaw 状态面板展示;
* 3. gateway :账户级别的长连接生命周期管理(启动/停止/登出);
* 4. outbound :出站消息(文本 / 媒体)的投递策略与发送实现。
*
* 架构概览:
* OpenClaw ──启动账户──▶ startAccount() ──▶ startGateway() ──▶ Socket.IO WS
* ◀──状态回写── ctx.setStatus() ◀── onReady/onEvent/onDisconnect/onError
*
* ============================================================================
*/
import { createChatChannelPlugin } from 'openclaw/plugin-sdk/core';
// ---- 通用工具:文本分片、默认账户 ID 解析 ------------------------------------
import { chunkText, defaultAccountId } from './utils/index.js';
// ---- 常量:通道 Key、默认账户 ID、文本分片上限 -------------------------------
import { CHANNEL_KEY, DEFAULT_ACCOUNT_ID, TEXT_CHUNK_LIMIT } from './config.js';
// ---- 出站发送:文本 / 媒体两类消息的实际发送实现 -----------------------------
import { sendText, sendMedia } from './outbound.js';
// ---- Gateway:账户级别的 Socket.IO 长连接控制器 ------------------------------
import { startGateway } from './gateway.js';
// ---- 共享 base:抽取出的通用插件基础配置(避免与其他插件重复) ---------------
import { createLightclawPluginBase } from './shared.js';
// ---- Setup 适配器:首次配置 / 登录流程 ---------------------------------------
import { lightclawSetupAdapter } from './setup-core.js';
// ---- 目标解析:对 target 字符串做 normalize 和 "是否像 ID" 的预判 -------------
import { normalizeTarget, looksLikeId } from './messaging.js';
// ============================================================
// ChannelPlugin 定义(使用 createChatChannelPlugin)
// ============================================================
/**
* Lightclaw 聊天通道插件。
*
* 通过 `createChatChannelPlugin` 工厂创建;泛型 `ResolvedAssistantAccount`
* 指定该通道使用的账户类型(OpenClaw 会据此在各回调中注入强类型 account)。
*
* 导出后由 OpenClaw 主程序在启动阶段加载并注册到 Channel 管理器中。
*/
export const lightclawPlugin = createChatChannelPlugin({
// ========================================================================
// base:插件的核心能力声明(messaging / status / gateway ...)
// ========================================================================
base: {
// ---- 来自 shared.ts 的基础定义 ----
// 展开共享的 base:包含 setup、channelKey、日志前缀等公用配置
...createLightclawPluginBase({
setup: lightclawSetupAdapter,
}),
// ---- 消息目标解析 ----
// ----------------------------------------------------------------------
// messaging:消息目标(target)的解析与规范化
// ----------------------------------------------------------------------
messaging: {
// 对原始目标字符串进行标准化处理,如去除空格、统一格式、转换编码等,返回标准化后的字符串
/**
* 对原始目标字符串进行标准化处理。
*
* 例如:去除前后空格、统一大小写、把 `@xxx` 转为 `user:xxx` 等,
* 返回规范化后的字符串,供后续路由匹配使用。
*/
normalizeTarget: (target) => {
return normalizeTarget(target);
},
/**
* 目标解析器:在消息路由阶段帮助框架判断 target 的"形态"。
*/
targetResolver: {
// 判断一个原始字符串是否看起来像一个 ID,用于在解析前快速分流处理逻辑
/**
* 判断一个原始字符串是否看起来像一个 ID(而不是昵称/关键词等),
* 用于在解析前快速分流处理逻辑,避免不必要的远端查询。
*
* @param id 用户输入的原始字符串
* @param normalized 经过 normalizeTarget 规范化后的字符串(可选)
*/
looksLikeId: (id, normalized) => {

@@ -44,4 +85,10 @@ return looksLikeId(id, normalized);

},
// ---- 状态面板 ----
// ----------------------------------------------------------------------
// status:运行时状态快照的构建与默认值
// - defaultRuntime :账户 runtime 的初始值(未启动时回显)
// - buildChannelSummary:整个通道层级的概要状态(所有账户汇总之上的顶层)
// - buildAccountSnapshot:单个账户的详细快照(CLI/面板展示的主体数据)
// ----------------------------------------------------------------------
status: {
/** 账户 runtime 的初始状态:未连接、未运行、无错误 */
defaultRuntime: {

@@ -54,2 +101,6 @@ accountId: DEFAULT_ACCOUNT_ID,

},
/**
* 构建通道级别的汇总状态。
* 由 OpenClaw 在查询 `status` 命令时调用。
*/
buildChannelSummary: ({ snapshot }) => ({

@@ -62,2 +113,9 @@ configured: snapshot.configured ?? false,

}),
/**
* 构建单个账户的快照。
*
* @param account 账户元数据(accountId / name / apiKey / enabled 等)
* @param runtime 账户运行时状态(由 gateway 回调实时更新)
* @param cfg 当前 OpenClaw 完整配置,用于在 account 缺失时回退取默认
*/
buildAccountSnapshot: ({ account, runtime, cfg }) => ({

@@ -67,2 +125,3 @@ accountId: account?.accountId ?? defaultAccountId(cfg),

enabled: account?.enabled ?? false,
// configured 表示"是否已配置 apiKey",是前端判断"是否可启动"的依据
configured: Boolean(account?.apiKey),

@@ -75,11 +134,24 @@ running: runtime?.running ?? false,

},
// ---- Gateway 生命周期(核心!) ----
// ----------------------------------------------------------------------
// gateway:账户级生命周期(启动 WS 长连接 / 登出清理凭证)
// ----------------------------------------------------------------------
gateway: {
/**
* 启动账户:建立 WS 长连接到 AI 助手 Server
* OpenClaw 会在加载配置后为每个 enabled 的账户调用此方法
* 启动账户:建立 WS 长连接到 AI 助手 Server。
*
* OpenClaw 会在加载配置后,为每个 `enabled` 的账户调用此方法。
* 方法内部通过 `startGateway` 维持一个带自动重连的 Socket.IO 实例,
* 并通过一组回调把连接状态反向写回 OpenClaw 的 runtime 状态。
*
* @param ctx 插件上下文:
* - account 当前账户解析结果
* - abortSignal 外部取消信号(用户执行 stop 时触发)
* - log 插件作用域的 logger
* - cfg OpenClaw 完整配置
* - getStatus/setStatus 读写当前 runtime 快照
*/
startAccount: async (ctx) => {
const { account, abortSignal, log, cfg } = ctx;
log?.info(`[${CHANNEL_KEY}:${account.accountId}] Starting gateway...`);
const preLogFix = `[${CHANNEL_KEY}:${account.accountId}]`;
log?.info(`${preLogFix} Starting gateway...`);
// 启动并维持一个 Socket.IO Gateway 实例

@@ -91,5 +163,9 @@ await startGateway({

log,
/** WS 连接就绪回调 */
/**
* WS 连接就绪回调:
* 标记 running=true / connected=true,并用当前时间戳初始化
* lastConnectedAt 与 lastEventAt(后者是 health-monitor 的检测基准)。
*/
onReady: () => {
log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway ready: WS connected`);
log?.info(`${preLogFix} Gateway ready: WS connected`);
const now = Date.now();

@@ -105,5 +181,9 @@ ctx.setStatus({

},
/** WS 连接断开回调 */
/**
* WS 连接断开回调:
* 仅把 connected 置为 false;running 保持 true,表示 gateway
* 仍在尝试重连。真正的停止由 abortSignal 触发。
*/
onDisconnect: () => {
log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway ready: WS disconnect`);
log?.info(`${preLogFix} Gateway ready: WS disconnect`);
ctx.setStatus({

@@ -114,5 +194,8 @@ ...ctx.getStatus(),

},
/** 错误回调:记录错误信息,不中断重连逻辑(由 gateway 内部处理) */
/**
* 错误回调:记录错误信息到 runtime.lastError。
* 不中断重连逻辑(重连由 gateway 内部自愈机制处理)。
*/
onError: (error) => {
log?.error(`[${CHANNEL_KEY}:${account.accountId}] Gateway error: ${error.message}`);
log?.error(`${preLogFix} Gateway error: ${error.message}`);
ctx.setStatus({

@@ -125,7 +208,8 @@ ...ctx.getStatus(),

* 入站事件回调:每次收到消息时调用。
* 刷新 lastEventAt 和 lastInboundAt,
* 通知框架 health-monitor 该连接仍处于活跃状态。
*
* 通过刷新 `lastEventAt` 和 `lastInboundAt`,通知框架
* health-monitor 该连接仍处于活跃状态,避免被判定为 stale 而重连。
*/
onEvent: () => {
log?.info(`[${CHANNEL_KEY}:${account.accountId}] Gateway Ready: WS message enter`);
log?.info(`${preLogFix} Gateway Ready: WS message enter`);
ctx.setStatus({

@@ -139,17 +223,22 @@ ...ctx.getStatus(),

},
/** 登出账户:清除凭证 */
/**
* 登出账户:从配置中清除该账户的凭证(apiKey)。
* 清理 `lightclawbot.accounts[accountId].apiKey`
*
* 注意:本方法只负责清除凭证字段,不停止 gateway(停止由框架调度)。
*
* @returns { ok, cleared } cleared=true 表示确实清理了字段
*/
logoutAccount: async ({ accountId, cfg }) => {
// 拷贝 cfg,避免直接改引用导致上游感知不到变化
const nextCfg = { ...cfg };
const section = nextCfg.channels?.[CHANNEL_KEY];
const lightclawbotConfig = nextCfg?.channels?.[CHANNEL_KEY];
let cleared = false;
if (section) {
if (accountId === DEFAULT_ACCOUNT_ID && section.apiKeys) {
delete section.apiKeys;
if (lightclawbotConfig) {
// 命名账户:凭证挂在 accounts[accountId] 下
const accounts = lightclawbotConfig.accounts;
if (accounts?.[accountId]?.apiKey) {
delete accounts[accountId].apiKey;
cleared = true;
}
const accounts = section.accounts;
if (accounts?.[accountId]?.apiKeys) {
delete accounts[accountId].apiKeys;
cleared = true;
}
}

@@ -160,9 +249,26 @@ return { ok: true, cleared };

},
// ---- 出站消息适配器 ----
// ========================================================================
// outbound:出站消息投递配置
// - base :投递策略(模式、分片器、目标解析)
// - attachedResults :实际发送函数(sendText / sendMedia)
// ========================================================================
outbound: {
base: {
/** 投递模式:direct = 直接发送,不走队列/合并 */
deliveryMode: 'direct',
/** 文本分片器:按 Markdown 语义边界做智能切分 */
chunker: chunkText,
/** 分片模式:markdown 模式会保留代码块、列表等结构完整性 */
chunkerMode: 'markdown',
/** 单条消息文本长度上限(超过会被 chunker 切成多条发送) */
textChunkLimit: TEXT_CHUNK_LIMIT,
/**
* 解析投递目标(resolveTarget):
*
* 决定一次 outbound 消息最终要发送给谁(to)。
* 优先级:
* 1. 显式 `to` 参数(用户 / 上层明确指定);
* 2. implicit 模式下,从 `allowFrom` 列表中挑第一个非通配符条目;
* 3. 都没有则返回错误,提示用户用 `--to` 指定或配置 allowFrom。
*/
resolveTarget: ({ to, allowFrom, mode }) => {

@@ -186,7 +292,22 @@ // 优先使用显式 to;implicit 模式下回退到 allowFrom 第一个非通配符条目

},
/**
* attachedResults:绑定到"工具调用结果投递"这条链路的发送实现。
*
* OpenClaw 在 assistant 产出工具结果需要推送给外部时,会根据
* `channel` 字段路由到这里,调用 sendText / sendMedia 完成实际投递。
*/
attachedResults: {
/** 通道标识,需与 CHANNEL_KEY 一致,供 OpenClaw 路由匹配 */
channel: CHANNEL_KEY,
/**
* 发送纯文本消息。
* 参数由框架透传:to 目标、text 内容、accountId 账户、replyToId 回复锚点、cfg 完整配置。
*/
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
return sendText({ to, text, accountId, replyToId, cfg });
},
/**
* 发送带媒体附件的消息(图片 / 文件等)。
* text 为附带的文本说明,mediaUrl 为媒体资源 URL。
*/
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {

@@ -193,0 +314,0 @@ return sendMedia({ to, text, mediaUrl, accountId, replyToId, cfg });

@@ -150,2 +150,6 @@ /**

export const EVENT_SESSIONS_RESPONSE = 'sessions:response';
export const EVENT_AGENTS_REQUEST = 'agents:request';
export const EVENT_AGENTS_RESPONSE = 'agents:response';
export const EVENT_CHAT_REQUEST = 'chat:request';
export const EVENT_CHAT_RESPONSE = 'chat:response';
// ============================================================

@@ -152,0 +156,0 @@ // 文件下载信令(kind 字段值)

+17
-5

@@ -136,6 +136,15 @@ /**

const emit = (data) => {
// 统一保证 chatId 字段存在:全部 EVENT_MESSAGE_PRIVATE 出站消息都通过
// `extra.chatId` 携带,调用方未传入时默认为 ''(默认会话)。
// 同时保留调用方传入的其他 extra 字段(如 transferData)。
const existingExtra = data.extra ?? {};
const chatId = typeof existingExtra.chatId === 'string' ? existingExtra.chatId : '';
const payload = {
...data,
extra: { ...existingExtra, chatId },
};
// 完整发送日志由 ReliableEmitter 打印(含 idempotencyKey)
reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, data, data.msgId).then((ok) => {
reliableEmitter.emitWithAck(EVENT_MESSAGE_PRIVATE, payload, payload.msgId).then((ok) => {
if (!ok)
log?.error(`[${CHANNEL_KEY}] Message delivery failed after retries: msgId=${data.msgId}`);
log?.error(`[${CHANNEL_KEY}] Message delivery failed after retries: msgId=${payload.msgId}`);
});

@@ -153,3 +162,3 @@ return true;

*/
const sendReply = (targetId, text, replyToMsgId, agentId) => {
const sendReply = (targetId, text, replyToMsgId, chatId, agentId) => {
log?.info(`[${CHANNEL_KEY}] sendReply: ${text} to ${targetId} (replyTo: ${replyToMsgId || 'none'})`);

@@ -164,2 +173,3 @@ return emit({

agentId,
extra: { chatId: chatId ?? '' },
});

@@ -177,3 +187,3 @@ };

*/
const sendFiles = (targetId, text, files, replyToMsgId, agentId) => {
const sendFiles = (targetId, text, files, replyToMsgId, chatId, agentId) => {
return emit({

@@ -188,2 +198,3 @@ msgId: generateMsgId(),

agentId,
extra: { chatId: chatId ?? '' },
});

@@ -209,3 +220,3 @@ };

try {
emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId);
emitter.sendReply(msg.senderId, "消息处理异常,请稍后重试。", msg.messageId, msg.chatId);
emitter.emit({

@@ -220,2 +231,3 @@ msgId: generateMsgId(),

agentId: msg.agentId,
extra: { chatId: msg.chatId ?? '' },
});

@@ -222,0 +234,0 @@ }

@@ -23,2 +23,2 @@ /**

// ── Session Reader (核心 API) ──
export { readSessionHistory, readSessionHistoryTail, readCronHistory, readSessionHistoryWithCron, listSessions, } from "./session-reader.js";
export { readSessionHistory, readSessionHistoryTail, readCronHistory, readSessionHistoryWithCron, readSessionHistoriesByIds, listSessions, } from "./session-reader.js";

@@ -12,6 +12,5 @@ /**

import fs from "node:fs";
import { resolveTranscriptPath } from "./session-store.js";
import { resolveTranscriptPath, loadSessionStore, resolveTranscriptPathBySessionId } from "./session-store.js";
import { isSystemInjectedUserMessage, normalizeMessage } from "./message-parser.js";
import { listCronSessions, classifySessionKey, extractCronJobId, extractCronJobIdsFromTranscript, findCronSessionsByJobIds, } from "./cron-utils.js";
import { loadSessionStore } from "./session-store.js";
// ============================================================

@@ -230,2 +229,58 @@ // 核心读取:单个 Session

// ============================================================
// 多 SessionId 合并读取(跨 reset 历史回看)
// ============================================================
/**
* 给定一组 sessionId(按时间序),合并读取它们各自 jsonl 中的消息。
*
* 适用场景:
* chats.json 的 sessionIdHistory 保存了某 chat 历史上用过的所有 sessionId
* (含已被 reset 归档的旧 ID 和当前在用的 ID)。本函数据此把多份 jsonl
* 合并为一份按时间正序的消息流,让前端能跨 reset 看到完整对话历史。
*
* 实现细节:
* - 通过 resolveTranscriptPathBySessionId 直接按 sessionId 定位文件,
* 既能命中活跃文件 <sessionId>.jsonl,也能命中归档文件
* <sessionId>.jsonl.reset.* / .deleted.*;
* - 单份 jsonl 内已按写入顺序自然有序,不同 sessionId 之间通过 timestamp
* 做最终归并排序;
* - 仅返回最后 limit 条(保持与 readSessionHistory 一致的行为)。
*
* @param sessionIds - 该 chat 用过的所有 sessionId(顺序由调用方保证:
* 通常 = chats.json[chatId].sessionIdHistory)
* @param opts - 与 readSessionHistory 一致;agentId 用于路径解析
* @returns 按 timestamp 升序合并的消息列表(最多 limit 条)
*/
export function readSessionHistoriesByIds(sessionIds, opts) {
const limit = opts?.limit ?? 200;
const chatOnly = opts?.chatOnly ?? false;
if (!Array.isArray(sessionIds) || sessionIds.length === 0)
return [];
const all = [];
// 去重 + 保持顺序:同一 sessionId 出现多次只读一次
const seen = new Set();
for (const sid of sessionIds) {
if (!sid || seen.has(sid))
continue;
seen.add(sid);
const filePath = resolveTranscriptPathBySessionId(sid, opts?.agentId);
if (!filePath)
continue;
try {
const raw = fs.readFileSync(filePath, "utf-8");
// 单文件先读到上限:避免单段过大撑爆内存;最终还要按 limit 截断
const msgs = parseTranscriptLines(raw, { limit, chatOnly });
for (const m of msgs)
all.push(m);
}
catch {
// 单段读取失败不阻断其他段
}
}
if (all.length === 0)
return [];
// 多段合并:按 timestamp 升序;无 timestamp 的消息按相对顺序兜底
all.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
return all.length > limit ? all.slice(-limit) : all;
}
// ============================================================
// 内部工具函数

@@ -232,0 +287,0 @@ // ============================================================

@@ -32,2 +32,3 @@ /**

const id = agentId?.trim() || "main";
// 例如:~/.openclaw/agents/main/sessions
return path.join(home, "agents", id, "sessions");

@@ -49,2 +50,3 @@ }

export function loadSessionStore(agentId) {
// ~/.openclaw/agents/main/sessions/sessions.json
const storePath = path.join(resolveSessionsDir(agentId), "sessions.json");

@@ -174,1 +176,42 @@ // const storePath = path.join(currentDir, "../../sessions/sessions.json").replace("file:", "");

}
/**
* 直接根据 sessionId 解析 transcript 文件路径(不依赖 sessions.json 索引)。
*
* 适用场景:
* chats.json 中的 sessionIdHistory 保存了某 chat 历史上用过的所有 sessionId,
* 这些 sessionId 在 reset 之后会被框架从 sessions.json 索引中移除(旧条目失效),
* 因此无法再通过 sessionKey 反查;只能直接按 sessionId 在磁盘上定位 jsonl。
*
* 解析顺序:
* 1. <sessionsDir>/<sessionId>.jsonl —— 当前在用的 transcript
* 2. <sessionsDir>/<sessionId>.jsonl.reset.* / .deleted.* —— 归档 transcript
* 3. storeDir 兜底(同 resolveTranscriptPath,离线分析场景)
*
* 找不到则返回 null。
*/
export function resolveTranscriptPathBySessionId(sessionId, agentId) {
if (!sessionId || !sessionId.trim())
return null;
const sessionsDir = resolveSessionsDir(agentId);
const jsonlFileName = `${sessionId}.jsonl`;
// 1. 标准目录下的活跃 transcript
const defaultPath = path.join(sessionsDir, jsonlFileName);
if (fs.existsSync(defaultPath))
return defaultPath;
// 2. 同目录归档文件
const archivedPath = _findArchivedTranscript(sessionsDir, jsonlFileName);
if (archivedPath)
return archivedPath;
// 3. storeDir 兜底(离线分析场景)
// 注意:getStoreDir() 仅在 loadSessionStore() 成功后才有值,调用方需确保前置条件
const storeDir = getStoreDir();
if (storeDir && storeDir !== sessionsDir) {
const siblingPath = path.join(storeDir, jsonlFileName);
if (fs.existsSync(siblingPath))
return siblingPath;
const archivedInStore = _findArchivedTranscript(storeDir, jsonlFileName);
if (archivedInStore)
return archivedInStore;
}
return null;
}

@@ -74,2 +74,9 @@ /**

result = result.replace(/用户发送了文件:\s*.+?\s*\([^)]+\)\s*/g, "");
// 移除 OpenClaw 媒体消息的结构化包装块:
// [Image] / [Video] / [Audio] / [Document] / [Image 1] 等媒体类型标记行
result = result.replace(/^\[[A-Za-z][\w ]*\]\s*$/gm, "");
// "User text:" 单独一行的 label(其后紧跟用户真实输入,需保留输入本身)
result = result.replace(/^User text:\s*$/gm, "");
// "Description:\n<AI 对媒体的自动描述>" — 位于消息尾部,直接吃到文本末尾
result = result.replace(/^Description:[\s\S]*$/m, "");
return result.trim();

@@ -76,0 +83,0 @@ }

@@ -11,3 +11,3 @@ /**

import { emitSignal } from './utils/common.js';
import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID, LOCALFILE_SCHEME } from './config.js';
import { CHANNEL_KEY, MEDIA_MAX_BYTES, resolveEffectiveApiKey, setSessionApiKey, DEFAULT_AGENT_ID, LOCALFILE_SCHEME, } from './config.js';
import { getLightclawRuntime } from './runtime.js';

@@ -49,19 +49,25 @@ import { createChannelReplyPipeline } from 'openclaw/plugin-sdk/channel-reply-pipeline';

// route: {"agentId":"main","channel":"lightclawbot","accountId":"default","sessionKey":"agent:main:lightclawbot:direct:100013456706","mainSessionKey":"agent:main:main","lastRoutePolicy":"session","matchedBy":"default"}
// 若前端指定了合法的 agentId,则用 buildAgentSessionKey 覆盖 sessionKey 和 agentId,
// 实现 Agent 间隔离(sessionKey 含 agentId,不同 Agent 的会话历史完全独立)
const route = resolvedAgentId
? {
...baseRoute,
agentId: resolvedAgentId,
sessionKey: pluginRuntime.channel.routing.buildAgentSessionKey({
agentId: resolvedAgentId,
channel: CHANNEL_KEY,
accountId: baseRoute.accountId,
peer: { kind: 'direct', id: msg.senderId },
dmScope: 'per-channel-peer',
}),
mainSessionKey: `agent:${resolvedAgentId}:main`,
}
: baseRoute;
log?.info(`[CHANNEL_KEY]: route= ${JSON.stringify(route)}`);
// 框架生成的基础 sessionKey 形如:
// agent:<agentId>:<channel>:direct:<userId>
// 当前端带上 chatId(多会话场景)时,需要在末尾追加 `:<chatId>`,
// 形成:agent:<agentId>:<channel>:direct:<userId>:<chatId>,
const chatIdSuffix = msg?.chatId?.trim();
const appendChatId = (key) => (chatIdSuffix ? `${key}:${chatIdSuffix}` : key);
// 用 buildAgentSessionKey 基于 resolvedAgentId 重建 sessionKey 和 agentId,
// 实现 Agent 间隔离(sessionKey 含 agentId,不同 Agent 的会话历史完全独立)。
// resolvedAgentId 由 DEFAULT_AGENT_ID 兜底,必定有值,故无需再判空。
const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
agentId: resolvedAgentId,
channel: CHANNEL_KEY,
accountId: baseRoute.accountId,
peer: { kind: 'direct', id: msg.senderId },
dmScope: 'per-channel-peer',
});
const route = {
...baseRoute,
agentId: resolvedAgentId,
sessionKey: appendChatId(baseSessionKey),
mainSessionKey: `agent:${resolvedAgentId}:main`,
};
log?.info(`[CHANNEL_KEY]: route= ${JSON.stringify(route)}, chatId=${chatIdSuffix ?? '-'}`);
// ---- 步骤 3:权限检查 ----

@@ -82,4 +88,12 @@ // 根据 allowFrom 白名单和 dmPolicy 策略决定是否允许此用户发送命令

const replyMsgId = generateMsgId();
const signalCtx = { emitter, targetId, replyMsgId, originalMsgId: msg.messageId, agentId: resolvedAgentId };
const signalCtx = {
emitter,
targetId,
replyMsgId,
originalMsgId: msg.messageId,
agentId: resolvedAgentId,
chatId: msg.chatId ?? '',
};
emitSignal(signalCtx, 'typing_start');
// 注:SignalContext.chatId 是上下文字段,由 emitSignal 内部规整到 extra.chatId 输出
// 3. 处理文件附件(files[] → 本地存储 + 公网 URL)

@@ -257,3 +271,3 @@ const localMediaPaths = [];

if (!hasEmittedContent()) {
emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId);
emitter.sendReply(targetId, '抱歉,处理您的消息时出现了问题,请稍后重试', msg.messageId, msg.chatId);
}

@@ -260,0 +274,0 @@ }

@@ -15,8 +15,10 @@ /**

*/
import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID, EVENT_SESSIONS_REQUEST, EVENT_SESSIONS_RESPONSE, KIND_FILE_DOWNLOAD, FILE_DOWNLOAD_STATUS } from '../config.js';
import { CHANNEL_KEY, EVENT_MESSAGE_PRIVATE, EVENT_HISTORY_REQUEST, EVENT_HISTORY_RESPONSE, DEFAULT_HISTORY_LIMIT, DEFAULT_AGENT_ID, EVENT_AGENTS_REQUEST, EVENT_AGENTS_RESPONSE, KIND_FILE_DOWNLOAD, FILE_DOWNLOAD_STATUS, EVENT_CHAT_REQUEST, } from '../config.js';
import { isDuplicate, debounceHistoryRequest, generateMsgId } from '../dedup.js';
import { getLightclawRuntime } from '../runtime.js';
import { readSessionHistoryWithCron, listSessions } from '../history/index.js';
import { readSessionHistoryWithCron, readSessionHistoriesByIds, loadSessionStore } from '../history/index.js';
import { handleChatList, handleChatCreate, handleChatUpdate, handleChatDelete } from './chat.js';
import { uploadFileToServer } from '../file-storage.js';
import { guessMimeByExt } from '../media.js';
import { ensureSessionInHistory, readChatsFile, resolveChatsFilePath } from '../utils/common.js';
import * as fs from 'node:fs';

@@ -76,2 +78,3 @@ import * as path from 'node:path';

// /stop 及自然语言 abort 不做旁路,统一交给 openclaw fast-abort。
const chatId = extractChatId(data);
handleMessage({

@@ -84,3 +87,29 @@ senderId: data.from,

agentId: data.agentId, // 透传前端指定的 agentId
chatId, // 透传前端指定的 chatId(多会话分桶用)
});
// ④ 异步登记当前生效的 sessionId 到 chats.json[chatId].sessionIdHistory。
//
// 为什么用 setImmediate?
// - 首条消息到达时,框架还未创建 session,sessions.json 查不到该 sessionKey;
// - /reset 等场景下,框架会在派发消息前 rotate sessionId,本回合的当前 sessionId
// 是 rotate 之后的新 ID;
// - 把登记放到下一个事件循环 tick,给框架留出时序窗口(创建/rotate session);
// - 与主消息流解耦,登记失败不影响 handleMessage 主流程。
//
// 即使本次未登记成功(首条消息时 sessions.json 还没条目),下一条消息进来时
// 仍会再次触发本逻辑,幂等的 ensureSessionInHistory 会补登记,最终一致。
setImmediate(() => {
try {
recordSessionInHistory({
userId: data.from,
agentId: data.agentId,
chatId,
accountId: account.accountId,
log,
});
}
catch (err) {
log?.warn(`[${CHANNEL_KEY}] recordSessionInHistory(message:private) error: ${err instanceof Error ? err.message : String(err)}`);
}
});
});

@@ -130,3 +159,10 @@ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// - 前端传了合法 agentId → 用它;否则降级到 baseRoute.agentId(框架路由解析出的默认值)
const sessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
//
// 框架生成的基础 sessionKey 形如:
// agent:<agentId>:<channel>:direct:<userId>
// 当前端带上 chatId(多会话场景)时,需要在基础 sessionKey 末尾追加 `:<chatId>`,
// 形成:agent:<agentId>:<channel>:direct:<userId>:<chatId>,
// 以区分同一用户下的不同会话历史文件。框架本身不感知 chatId,
// 故在此处显式拼接,保持与写入端(chat 维度的 sessionId)一致。
const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
agentId: resolvedAgentId,

@@ -138,12 +174,73 @@ channel: CHANNEL_KEY,

});
log?.info(`[${CHANNEL_KEY}] userId=${data.from},当前的sessionKey=${sessionKey}`);
// 从本地持久化存储中读取该 session 的历史消息
// includeCron: true 表示同时读取定时任务产生的消息(如每日推送)
const messages = readSessionHistoryWithCron(sessionKey, {
limit: data.limit ?? DEFAULT_HISTORY_LIMIT,
chatOnly: data.chatOnly ?? true,
includeCron: true,
agentId: resolvedAgentId,
});
log?.info(`[${CHANNEL_KEY}] History request: userId=${data.from} sessionKey=${sessionKey} found=${messages.length}`);
const chatIdSuffix = extractChatId(data);
const sessionKey = chatIdSuffix ? `${baseSessionKey}:${chatIdSuffix}` : baseSessionKey;
log?.info(`[${CHANNEL_KEY}] userId=${data.from},chatId=${chatIdSuffix || '-'},当前的sessionKey=${sessionKey}`);
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ① 兜底登记当前 sessionId 到 sessionIdHistory
//
// 为什么在拉历史时也做登记?—— 兜底覆盖一个边界场景:用户 reset 后没再
// 发过消息,只是重新打开聊天界面拉历史。此时 message:private 的登记
// 路径不会被触发,仅靠这里能补登记,确保历史不丢。
//
// 必须先于读历史,因为读历史依赖 sessionIdHistory 来合并多份 jsonl,
// 当前 sessionId 也要一起读到。
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
try {
const store = loadSessionStore(resolvedAgentId);
const currentSessionId = store[sessionKey]?.sessionId;
if (currentSessionId) {
ensureSessionInHistory(resolvedAgentId, data.from, chatIdSuffix, currentSessionId);
}
}
catch (err) {
log?.warn(`[${CHANNEL_KEY}] ensureSessionInHistory(history:request) error: ${err instanceof Error ? err.message : String(err)}`);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ② 读取历史消息:优先按 sessionIdHistory 合并多份 jsonl(跨 reset 历史回看)
//
// - chats.json 命中(含 chatId === '' 的默认对话兜底条目):
// 把该 chat 用过的所有 sessionId(含归档的旧 sessionId)的 jsonl
// 全部读出来按 timestamp 合并;
// - 否则:退化到原来的"按 sessionKey 读当前 jsonl + cron 合并"路径,
// 保持向后兼容(chats.json 不存在 / 解析异常等极端场景)。
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const limit = data.limit ?? DEFAULT_HISTORY_LIMIT;
const chatOnly = data.chatOnly ?? true;
let messages = [];
let readMode = 'single-session-with-cron';
try {
// 查询侧使用与登记侧同一个 chatId(已在 extractChatId 统一归一)。
const targetChatId = chatIdSuffix;
const chatsPath = resolveChatsFilePath(resolvedAgentId, data.from);
// 这里使用**纯只读**的 readChatsFile:
// - 拉历史是只读接口,不应该在用户还没发任何消息前就把
// chats.json 写到磁盘(避免凭空出现一个默认对话);
// - 如果是默认对话首次消息后拉历史,前面的 ensureSessionInHistory
// 已经走 readChatsFileOrInitDefault 打点过 chats.json,这里能读到。
const matched = readChatsFile(chatsPath).find((c) => c.chatId === targetChatId);
const historyIds = matched?.sessionIdHistory ?? [];
if (historyIds.length > 0) {
messages = readSessionHistoriesByIds(historyIds, {
limit,
chatOnly,
agentId: resolvedAgentId,
});
readMode = 'multi-session';
}
}
catch (err) {
log?.warn(`[${CHANNEL_KEY}] readSessionHistoriesByIds fallback to single-session: ${err instanceof Error ? err.message : String(err)}`);
}
if (messages.length === 0 && readMode !== 'multi-session') {
// 退路 / 兜底:仅当未走多 sessionId 合并路径时,退化到按当前 sessionKey 读(含 cron 合并)。
// 已走 multi-session 但消息为空,意味着 chats.json 里 sessionIdHistory 全部 jsonl 都没内容,
// 这是合理的"空历史"状态,不应再退回 single-session 路径,避免重复 cron 拼接的副作用。
messages = readSessionHistoryWithCron(sessionKey, {
limit,
chatOnly,
includeCron: true,
agentId: resolvedAgentId,
});
}
log?.info(`[${CHANNEL_KEY}] History request: userId=${data.from} sessionKey=${sessionKey} mode=${readMode} found=${messages.length}`);
// 过滤掉内容为空的消息(既无文字也无附件),避免客户端渲染空气泡

@@ -176,27 +273,167 @@ const historyMsgId = generateMsgId();

});
socket.on(EVENT_SESSIONS_REQUEST, (data) => {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 事件:Agents 列表请求(agents:request)
// 职责:读取 openclaw.json 中 agents.list 并返回给客户端(支持热更新,不缓存)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
socket.on(EVENT_AGENTS_REQUEST, (data, ack) => {
// 立即回复 ACK,告知服务端请求已收到
ack?.();
if (data.from === botClientId)
return;
// 通知框架收到入站事件(更新 lastEventAt,防止 stale-socket 误判)
onEvent?.();
try {
const sessions = listSessions();
const sessionsMsgId = generateMsgId();
reliableEmitter.emitWithAck(EVENT_SESSIONS_RESPONSE, {
requestId: data.requestId,
sessions,
msgId: sessionsMsgId,
}, sessionsMsgId);
// 获取插件运行时,读取最新配置
const pluginRuntime = getLightclawRuntime();
const currentCfg = pluginRuntime.config.loadConfig();
const agentsList = currentCfg.agents?.list ?? [];
log?.info(`[${CHANNEL_KEY}] Agents request: count=${agentsList.length}`);
const agentsMsgId = generateMsgId();
reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, {
msgId: agentsMsgId,
to: data.from,
from: botClientId,
agents: agentsList,
timestamp: Date.now(),
}, agentsMsgId);
}
catch (err) {
log?.error(`[${CHANNEL_KEY}] Sessions list error: ${err}`);
const sessionsErrMsgId = generateMsgId();
reliableEmitter.emitWithAck(EVENT_SESSIONS_RESPONSE, {
requestId: data.requestId,
sessions: [],
log?.error(`[${CHANNEL_KEY}] Agents request error: ${err}`);
const agentsErrMsgId = generateMsgId();
reliableEmitter.emitWithAck(EVENT_AGENTS_RESPONSE, {
msgId: agentsErrMsgId,
from: botClientId,
to: data.from,
agents: [],
error: err instanceof Error ? err.message : String(err),
msgId: sessionsErrMsgId,
}, sessionsErrMsgId);
timestamp: Date.now(),
}, agentsErrMsgId);
}
});
socket.on(EVENT_CHAT_REQUEST, (data, ack) => {
ack?.();
log?.info(`[${CHANNEL_KEY}] Received chat request from ${data.from}: ${data.type}`);
if (!data?.from) {
log?.warn(`[${CHANNEL_KEY}] Chat request missing userId, ignoring`);
return;
}
// 回环防御
if (data.from === botClientId)
return;
onEvent?.();
switch (data.type) {
case 'list':
handleChatList({
userId: data.from,
agentId: data.agentId,
botClientId,
reliableEmitter,
log,
});
break;
case 'create':
handleChatCreate({
userId: data.from,
agentId: data.agentId,
botClientId,
reliableEmitter,
log,
});
break;
case 'update':
handleChatUpdate({
userId: data.from,
agentId: data.agentId,
chatId: data.chatId,
title: data.title,
botClientId,
reliableEmitter,
log,
});
break;
case 'delete':
handleChatDelete({
userId: data.from,
agentId: data.agentId,
chatId: data.chatId,
botClientId,
reliableEmitter,
log,
});
break;
}
});
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 内部工具:从 PrivateMessageData / HistoryRequestData 中统一提取 chatId
//
// 抽离原因:原代码同一字段在 message:private(||)、setImmediate(??)、
// history:request(?.trim())三处使用了三种不同写法,虽然在协议正常时结果一致,
// 但语义有偏差且日后极易踩坑。统一为 extractChatId 后:
// - 入参可能是 undefined / null / 非字符串 / 含空白字符串 / 合法字符串;
// - 一律 trim 后归一为字符串;undefined / null / 非字符串 → '';
// - 返回值要么是 ''(默认对话标识),要么是非空合法 chatId。
// 这样写入侧(chats.json[chatId].sessionIdHistory)与读取侧(按 chatId 查询)
// 始终拿到同一个值,杜绝因写法差异引起的"找不到 chat 条目"问题。
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function extractChatId(data) {
const raw = data?.extra?.chatId;
if (typeof raw !== 'string')
return '';
return raw.trim();
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 内部工具:把当前 chat 在用的 sessionId 登记到 chats.json[chatId].sessionIdHistory
//
// 设计要点:
// 1. 解析 sessionKey —— 与 history:request 分支保持一致:
// agent:<agentId>:<channel>:direct:<userId>[:<chatId>]
// 若无 chatId 直接早返(无 chatId 的会话不在 chats.json 中登记)。
// 2. 通过 agentId 合法性校验,避免前端伪造 agentId 污染他人 chats.json。
// 3. 从 sessions.json 索引读出当前 sessionKey 对应的 sessionId
// —— 框架在派发消息前完成 mint/rotate,本时刻读到的就是"当前生效"的 sessionId。
// 4. 调用幂等的 ensureSessionInHistory:已存在则跳过;新出现则末尾追加并落盘。
//
// 调用时机:
// - EVENT_MESSAGE_PRIVATE:在 setImmediate 中异步调用,确保框架已建好/rotate 好 session;
// - EVENT_HISTORY_REQUEST:在 sessionKey 解析后同步调用,作为兜底登记入口。
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function recordSessionInHistory(params) {
const { userId, agentId, chatId, accountId, log } = params;
// 取最新配置,复用 history 分支同款 agentId 校验逻辑(防止伪造 agentId)
const pluginRuntime = getLightclawRuntime();
const currentCfg = pluginRuntime.config.loadConfig();
const currentCfgTyped = currentCfg;
const validAgentIds = currentCfgTyped.agents?.list?.map((a) => a.id) ?? [DEFAULT_AGENT_ID];
const resolvedAgentId = agentId && validAgentIds.includes(agentId) ? agentId : DEFAULT_AGENT_ID;
// 计算 sessionKey(必须与 history:request 一致,否则查不到 sessions.json 条目)
const baseRoute = pluginRuntime.channel.routing.resolveAgentRoute({
cfg: currentCfg,
channel: CHANNEL_KEY,
accountId,
peer: { kind: 'direct', id: userId },
});
const baseSessionKey = pluginRuntime.channel.routing.buildAgentSessionKey({
agentId: resolvedAgentId,
channel: CHANNEL_KEY,
accountId: baseRoute.accountId,
peer: { kind: 'direct', id: userId },
dmScope: 'per-channel-peer',
});
// 与 history:request 保持完全一致的 sessionKey 拼接规则:
// - 空 chatId → 不追加后缀,直接用 baseSessionKey(默认对话场景)
// - 非空 chatId → 追加 `:<chatId>` 后缀(多会话场景)
// 这是写入侧已建立的约定,框架对无 chatId 会话写到 baseSessionKey 上。
const sessionKey = chatId ? `${baseSessionKey}:${chatId}` : baseSessionKey;
// 查 sessions.json 拿当前生效 sessionId;首条消息时可能查不到 → 跳过,下一条消息会补登记
const store = loadSessionStore(resolvedAgentId);
const currentSessionId = store[sessionKey]?.sessionId;
if (!currentSessionId) {
log?.info(`[${CHANNEL_KEY}] recordSessionInHistory: sessionId not found yet, skip. sessionKey=${sessionKey}`);
return;
}
// 登记 sessionId 到 chats.json,确保历史记录能被查到;已存在则跳过,新增则末尾追加并落盘
ensureSessionInHistory(resolvedAgentId, userId, chatId, currentSessionId);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 文件下载处理器(kind=file:download, status=download_req)

@@ -229,3 +466,6 @@ //

kind: KIND_FILE_DOWNLOAD,
extra: { transferData: { transferId, status: FILE_DOWNLOAD_STATUS.ERROR, error } },
extra: {
chatId: extractChatId(data),
transferData: { transferId, status: FILE_DOWNLOAD_STATUS.ERROR, error },
},
}, msgId);

@@ -265,2 +505,3 @@ log?.error(`[${CHANNEL_KEY}] file:download(error) sent: transferId=${transferId}, error=${error}`);

extra: {
chatId: extractChatId(data),
transferData: {

@@ -294,2 +535,3 @@ transferId,

extra: {
chatId: extractChatId(data),
transferData: {

@@ -296,0 +538,0 @@ transferId,

@@ -113,7 +113,7 @@ /**

if (files.length > 0) {
emitter.sendFiles(targetId, enrichedText, files, originalMsgId);
emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId);
emittedUserVisible = true;
}
else if (enrichedText.trim()) {
emitter.sendReply(targetId, enrichedText, originalMsgId);
emitter.sendReply(targetId, enrichedText, originalMsgId, signalCtx.chatId);
emittedUserVisible = true;

@@ -260,3 +260,3 @@ }

`toolStart=${toolStartCount} assistantMsg=${assistantMessageCount}. Cause: ${cause}`);
emitter.sendReply(targetId, "NO_REPLY", originalMsgId);
emitter.sendReply(targetId, "NO_REPLY", originalMsgId, signalCtx.chatId);
}

@@ -263,0 +263,0 @@ else {

@@ -0,2 +1,5 @@

import * as fs from 'node:fs';
import * as path from 'node:path';
import { X_PRODUCT } from '../config.js';
import { resolveOpenClawHome } from '../history/session-store.js';
/**

@@ -56,3 +59,3 @@ * 将长文本按指定长度拆分为多个文本块。

export function emitSignal(ctx, kind, content = '', extra) {
const { emitter, targetId, replyMsgId, originalMsgId, agentId } = ctx;
const { emitter, targetId, replyMsgId, originalMsgId, agentId, chatId } = ctx;
return emitter.emit({

@@ -68,3 +71,152 @@ msgId: replyMsgId,

agentId,
extra: { chatId: chatId ?? '' },
});
}
/**
* 构建 chats.json 的绝对路径:
* `~/.openclaw/agents/<agentId>/chats/<userId>/chats.json`
*/
export function resolveChatsFilePath(agentId, userId) {
const home = resolveOpenClawHome();
return path.join(home, 'agents', agentId, 'chats', userId, 'chats.json');
}
/**
* 读取 chats.json 并返回 chats 列表(**纯只读**,无任何文件落盘副作用)。
*
* - 文件不存在 → 返回空数组
* - JSON 解析失败或结构不合法 → 返回空数组
* - 合法记录按 `updatedAt` 倒序排序,未提供则按 `createdAt` 兜底
*
*/
export function readChatsFile(filePath) {
if (!fs.existsSync(filePath))
return [];
const raw = fs.readFileSync(filePath, 'utf-8');
if (!raw.trim())
return [];
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.chats))
return [];
// 过滤出字段合法的记录
const valid = parsed.chats.filter((c) => !!c &&
typeof c.chatId === 'string' &&
typeof c.title === 'string' &&
typeof c.createdAt === 'number' &&
typeof c.updatedAt === 'number');
// 按 updatedAt 倒序,便于前端直接渲染
return valid.sort((a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt));
}
/**
* 读取 chats.json;若文件不存在则**兜底创建一条 chatId='' 的默认对话**并落盘。
*
* 与 {@link readChatsFile} 的差异:本函数有写盘副作用,专供"必须保证存在默认对话
* 兜底条目"的调用方(典型场景:sessionId 登记入口 ensureSessionInHistory)。
*
* 拆分原因:原 readChatsFile 兼任只读与兜底创建两种语义,导致 history:request 等
* 纯查询路径在用户尚未发任何消息前就把 chats.json 写到磁盘上,违反单一职责。
*
* 行为:
* - 文件不存在 → 落盘 [{ chatId: '', title: '默认会话', ... }] 并返回;
* - 文件存在但解析失败 / 结构不合法 → 沿用 readChatsFile 的容错(返回 [],不创建);
* - 文件存在但内容合法 → 等价于 readChatsFile。
*/
export function readChatsFileOrInitDefault(filePath) {
if (!fs.existsSync(filePath)) {
const now = Date.now();
const defaultChat = {
chatId: '',
title: '默认会话',
createdAt: now,
updatedAt: now,
titleLocked: false,
pinned: false,
sessionIdHistory: [],
};
writeChatsFile(filePath, [defaultChat]);
return [defaultChat];
}
return readChatsFile(filePath);
}
/**
* 将 chats 列表原子写入 `chats.json`。
*
* 写入策略:
* 1. 目录不存在时逐级创建(recursive: true)
* 2. 先写入同目录下的 `.tmp` 文件,再 rename 覆盖目标,
* 避免写入过程中进程崩溃导致 json 文件损坏
* 3. schema 固定 `version: 1`,为未来平滑升级预留字段
*
* @param filePath 目标 chats.json 绝对路径
* @param chats 完整的 ChatMeta 列表(调用方自行保证顺序与合法性)
*/
export function writeChatsFile(filePath, chats) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const payload = { version: 1, chats };
const tmpPath = `${filePath}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
fs.renameSync(tmpPath, filePath);
}
/**
* 把当前生效的 sessionId 幂等地登记到 `chats.json` 中对应 chatId 的 `sessionIdHistory` 数组。
*
* 语义说明:
* - sessionIdHistory 记录该 chat 见过的所有 sessionId(含当前在用),按时间序末尾追加;
* - 同一 sessionId 若已存在则跳过(幂等,不会重复 push);
* - 列表最后一项 ≈ 该 chat 当前在用的 sessionId(前提是调用方在每次消息进入时都调用本函数)。
*
* 关于 chatId 为空('')的处理:
* - 空 chatId 是**合法**的"默认对话"会话标识——`readChatsFile` 在 chats.json
* 不存在时会主动写入一条 `chatId: ''` 的兜底记录;
* - 因此本函数对空 chatId 同样登记,目标条目就是 `chatId === ''` 那一条;
* - 若 chats.json 文件不存在 → 通过 readChatsFile 自动创建兜底记录;
* - 若 chats.json 已存在但缺失目标 chatId 条目 → 跳过登记并告警(避免污染他人数据)。
*
* 容错策略:
* - chatId 为 undefined/null → 跳过;空字符串 '' 视为合法(默认会话);
* - sessionId 为空 → 跳过;
* - 任意 IO/解析异常 → 静默吞错 + 日志,绝不抛出影响主流程。
*
* @param agentId - Agent ID(chats/<agentId> 目录段)
* @param userId - 用户 ID(chats/<agentId>/chats/<userId> 目录段)
* @param chatId - 会话 ID(ChatMeta.chatId);undefined/null 时跳过;空字符串视为默认会话
* @param sessionId - 当前生效的 sessionId(来自 sessions.json 索引);为空时直接 return
* @returns 是否实际写盘(true=本次新增了一个 sessionId;false=跳过 / 已存在 / 出错)
*/
export function ensureSessionInHistory(agentId, userId, chatId, sessionId) {
// chatId 必须是 string(含空字符串 '' —— 默认会话的合法标识);undefined/null 跳过
if (typeof chatId !== 'string')
return false;
if (!sessionId || !sessionId.trim())
return false;
const chatsPath = resolveChatsFilePath(agentId, userId);
try {
// 登记入口需要兜底:默认对话首次消息时 chats.json 尚不存在,
// 走 readChatsFileOrInitDefault 让其落盘一条 chatId='' 的默认对话,
// 然后正常走"查找+追加"流程。
const chats = readChatsFileOrInitDefault(chatsPath);
const idx = chats.findIndex((c) => c.chatId === chatId);
if (idx < 0) {
return false;
}
const target = chats[idx];
const history = Array.isArray(target.sessionIdHistory) ? target.sessionIdHistory : [];
if (history.includes(sessionId)) {
// 已存在,幂等跳过,不写盘
return false;
}
// 末尾追加,写回。
// 把默认对话/具名对话无端"置顶",扰乱前端按 updatedAt 倒序的列表排序。
const updated = {
...target,
sessionIdHistory: [...history, sessionId],
};
const nextChats = [...chats];
nextChats[idx] = updated;
writeChatsFile(chatsPath, nextChats);
return true;
}
catch (err) {
return false;
}
}

@@ -7,2 +7,5 @@ /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */

const { randomFillSync } = require('crypto');
const {
types: { isUint8Array }
} = require('util');

@@ -204,4 +207,6 @@ const PerMessageDeflate = require('./permessage-deflate');

buf.write(data, 2);
} else if (isUint8Array(data)) {
buf.set(data, 2);
} else {
buf.set(data, 2);
throw new TypeError('Second argument must be a string or a Uint8Array');
}

@@ -208,0 +213,0 @@ }

{
"name": "ws",
"version": "8.20.0",
"version": "8.20.1",
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",

@@ -5,0 +5,0 @@ "keywords": [

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

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