mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor(skills): unify multilingual skill entrypoints
This commit is contained in:
1 parent
7310b0670f
commit
59520a2e61
26 files changed
+96
-490
No files matched your search
@@ -8,22 +8,24 @@ ChatLab 可以让 Codex、Claude Code、HermesAgent 等 AI Agent 在本地查询
|
||||
|
||||
## 开始使用
|
||||
|
||||
需要 Node.js 22.19 或更高版本,并已在 ChatLab 中导入聊天记录。安装 ChatLab CLI 和中文 Skill:
|
||||
需要 Node.js 22.19 或更高版本,并已在 ChatLab 中导入聊天记录。安装 ChatLab CLI 和分析 Skill:
|
||||
|
||||
```bash
|
||||
npm install -g chatlab-cli
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-analyze-cn -g
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-analyze -g
|
||||
```
|
||||
|
||||
各语言共用同一个技能,可以直接用中文提出需求。曾安装 `chatlab-analyze-cn` 时,请先保留自定义改动,再安装上面的统一版本,并通过原安装工具移除旧入口,避免重复显示。
|
||||
|
||||
安装后,可以在 Codex、Claude Code、Cursor 等外部 Agent 中直接说:
|
||||
|
||||
```text
|
||||
chatlab-analyze-cn 帮我分析我和小红的聊天记录
|
||||
chatlab-analyze 帮我分析我和小红的聊天记录
|
||||
```
|
||||
|
||||
这个技能会引导 Agent 先执行 `clb manifest` 获取机读命令清单,再用安全的 `--format agent/json` 查询聊天记录。
|
||||
|
||||
`chatlab-analyze-cn` 始终只读。如果你希望 Agent 导入新的聊天导出文件,请改用独立的 `chatlab-import-cn` Skill;它会先预览导入计划,再自动新建或增量导入,详见[导入聊天记录指南](/cn/usage/how-to-import)。
|
||||
`chatlab-analyze` 始终只读。如果你希望 Agent 导入新的聊天导出文件,请改用独立的 `chatlab-import` Skill;它会先预览导入计划,再自动新建或增量导入,详见[导入聊天记录指南](/cn/usage/how-to-import)。
|
||||
|
||||
## 命令总览
|
||||
|
||||
@@ -65,7 +67,13 @@ agent/json 模式下 stdout 只包含一个 JSON 响应信封,日志一律走
|
||||
"ok": true,
|
||||
"command": "messages.search",
|
||||
"data": { "text": "returned: 2\n\n--- 2026/6/1 ---\n[#1*] 09:00 老王: 今年五一去旅游吧..." },
|
||||
"meta": { "totalHits": 2, "returnedHits": 2, "hasMore": false, "preprocess": { "desensitized": true }, "apiVersion": 1 }
|
||||
"meta": {
|
||||
"totalHits": 2,
|
||||
"returnedHits": 2,
|
||||
"hasMore": false,
|
||||
"preprocess": { "desensitized": true },
|
||||
"apiVersion": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ title: AI 协作工作流
|
||||
| --- | --- | --- |
|
||||
| `AGENTS.md` | 跨任务稳定约定、项目入口、验证命令 | 保持精简,只保留会影响决策的信息 |
|
||||
| `docs/cn/contributing/` | 公开开发细则 | 按任务引用,公开 PR 可独立理解 |
|
||||
| `skills/*/SKILL.md` | 供外部 Agent 使用的分析、导入、转换流程 | 每个技能一个职责,中英文独立分发并保持语义一致 |
|
||||
| `skills/*/SKILL.md` | 供外部 Agent 使用的分析、导入、转换流程 | 每个技能一个统一名称,使用简短双语描述与英文正文;回答遵循用户语言 |
|
||||
| `skills/*/references/`、`scripts/` | 格式契约与严格验证器 | 按需读取;确定性校验交给脚本 |
|
||||
| `.github/workflows/` | CI、构建、发布及产物清理 | 保留真实质量门禁,不承载模型提示词 |
|
||||
| 可选的本地规则和私有技能 | 个人协作偏好与维护任务 | 不覆盖用户当前意图,不作为公开贡献前提 |
|
||||
|
||||
@@ -16,24 +16,26 @@ outline: deep
|
||||
npm install -g chatlab-cli
|
||||
```
|
||||
|
||||
再在支持 Skills 的 Agent 环境中安装中文版转换 Skill:
|
||||
再在支持 Skills 的 Agent 环境中安装转换 Skill:
|
||||
|
||||
```bash
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-convert-cn -g
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-convert -g
|
||||
```
|
||||
|
||||
各语言共用同一个技能,可以直接用中文提出需求。曾安装 `chatlab-convert-cn` 时,请先保留自定义改动,再安装上面的统一版本,并通过原安装工具移除旧入口,避免重复显示。
|
||||
|
||||
## 开始转换
|
||||
|
||||
把聊天导出的准确路径告诉 Agent:
|
||||
|
||||
```text
|
||||
使用 $chatlab-convert-cn,把 "/你的/聊天记录路径" 转换并导入 ChatLab。
|
||||
使用 $chatlab-convert,把 "/你的/聊天记录路径" 转换并导入 ChatLab。
|
||||
```
|
||||
|
||||
如果只希望生成转换结果,不立即导入:
|
||||
|
||||
```text
|
||||
使用 $chatlab-convert-cn,把 "/你的/聊天记录路径" 转换为 ChatLab 格式,只转换,不导入。
|
||||
使用 $chatlab-convert,把 "/你的/聊天记录路径" 转换为 ChatLab 格式,只转换,不导入。
|
||||
```
|
||||
|
||||
Skill 会自动选择电脑上已有的 Node.js、Python 或 Shell 工具,完成以下流程:
|
||||
@@ -59,6 +61,4 @@ clb import "/转换后的文件.jsonl" --dry-run --json
|
||||
|
||||
只有严格验证和导入预览都成功,并且源消息数与输出消息数核对一致,才会报告转换成功。转换脚本和输出文件都会保留,方便以后重新转换更新后的聊天记录。
|
||||
|
||||
::: warning 隐私提醒
|
||||
请使用能够访问本机文件和终端的 Agent 执行转换,不要把完整聊天文件上传到在线对话框。即使需要排查特殊文本格式,也应只检查最小范围并隐藏消息正文。
|
||||
:::
|
||||
::: warning 隐私提醒请使用能够访问本机文件和终端的 Agent 执行转换,不要把完整聊天文件上传到在线对话框。即使需要排查特殊文本格式,也应只检查最小范围并隐藏消息正文。:::
|
||||
@@ -19,17 +19,19 @@ ChatLab 支持多种方式导入聊天记录。
|
||||
|
||||
适合已经使用 Codex、Claude Code 等各类 AI Agent,希望让 Agent 代为执行导入的用户。
|
||||
|
||||
需要 Node.js 22.19 或更高版本。安装 ChatLab CLI 和中文 Skill:
|
||||
需要 Node.js 22.19 或更高版本。安装 ChatLab CLI 和导入 Skill:
|
||||
|
||||
```bash
|
||||
npm install -g chatlab-cli
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-import-cn -g
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-import -g
|
||||
```
|
||||
|
||||
各语言共用同一个技能,可以直接用中文提出需求。曾安装 `chatlab-import-cn` 时,请先保留自定义改动,再安装上面的统一版本,并通过原安装工具移除旧入口,避免重复显示。
|
||||
|
||||
然后直接告诉 Agent:
|
||||
|
||||
```text
|
||||
chatlab-import-cn 帮我把 /absolute/path/to/chat-export.json 导入 ChatLab
|
||||
chatlab-import 帮我把 /absolute/path/to/chat-export.json 导入 ChatLab
|
||||
```
|
||||
|
||||
Agent 会先在后台预览,预览成功后自动新建或增量导入,无需再次确认。
|
||||
|
||||
@@ -8,13 +8,15 @@ ChatLab lets AI agents such as Codex, Claude Code, and HermesAgent query and ana
|
||||
|
||||
## Get started
|
||||
|
||||
Node.js 22.19 or newer is required, and chat records must already be imported into ChatLab. Install ChatLab CLI and the English Skill:
|
||||
Node.js 22.19 or newer is required, and chat records must already be imported into ChatLab. Install ChatLab CLI and the analysis skill:
|
||||
|
||||
```bash
|
||||
npm install -g chatlab-cli
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-analyze -g
|
||||
```
|
||||
|
||||
The same skill works across languages; ask in your preferred language.
|
||||
|
||||
After installation, ask Codex, Claude Code, Cursor, or another external agent:
|
||||
|
||||
```text
|
||||
@@ -65,7 +67,13 @@ In agent/json mode stdout contains exactly one JSON envelope; logs go to stderr:
|
||||
"ok": true,
|
||||
"command": "messages.search",
|
||||
"data": { "text": "returned: 2\n\n--- 2026/6/1 ---\n[#1*] 09:00 Wang: how about a trip on May Day..." },
|
||||
"meta": { "totalHits": 2, "returnedHits": 2, "hasMore": false, "preprocess": { "desensitized": true }, "apiVersion": 1 }
|
||||
"meta": {
|
||||
"totalHits": 2,
|
||||
"returnedHits": 2,
|
||||
"hasMore": false,
|
||||
"preprocess": { "desensitized": true },
|
||||
"apiVersion": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ Install the conversion skill in a skill-compatible agent environment:
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-convert -g
|
||||
```
|
||||
|
||||
The same skill works across languages; ask in your preferred language.
|
||||
|
||||
## Convert an export
|
||||
|
||||
Give the agent the exact export path:
|
||||
@@ -59,6 +61,4 @@ clb import "/absolute/path/to/converted.jsonl" --dry-run --json
|
||||
|
||||
Success requires strict validation, a successful import preview, and matching source/output message counts. The converter and outputs remain available so a newer export can be converted again.
|
||||
|
||||
::: warning Privacy
|
||||
Use an agent that can work with local files and a local terminal. Do not upload a complete chat export to an online chat box. When an unusual text format requires sampling, inspect the smallest possible range and mask message bodies.
|
||||
:::
|
||||
::: warning Privacy Use an agent that can work with local files and a local terminal. Do not upload a complete chat export to an online chat box. When an unusual text format requires sampling, inspect the smallest possible range and mask message bodies. :::
|
||||
@@ -19,13 +19,15 @@ The homepage also supports incremental imports. When a new file matches an impor
|
||||
|
||||
Use this when you already work with Codex, Claude Code, or another AI agent and want the agent to handle the import for you.
|
||||
|
||||
Node.js 22.19 or newer is required. Install ChatLab CLI and the English skill:
|
||||
Node.js 22.19 or newer is required. Install ChatLab CLI and the import skill:
|
||||
|
||||
```bash
|
||||
npm install -g chatlab-cli
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-import -g
|
||||
```
|
||||
|
||||
The same skill works across languages; ask in your preferred language.
|
||||
|
||||
Then ask the agent:
|
||||
|
||||
```text
|
||||
|
||||
@@ -16,24 +16,26 @@ outline: deep
|
||||
npm install -g chatlab-cli
|
||||
```
|
||||
|
||||
再於支援 Skills 的 Agent 環境安裝中文版轉換 Skill:
|
||||
再於支援 Skills 的 Agent 環境安裝轉換 Skill:
|
||||
|
||||
```bash
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-convert-cn -g
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-convert -g
|
||||
```
|
||||
|
||||
各語言共用同一個技能,可以直接用繁體中文提出需求。曾安裝 `chatlab-convert-cn` 時,請先保留自訂修改,再安裝上面的統一版本,並透過原安裝工具移除舊入口,避免重複顯示。
|
||||
|
||||
## 開始轉換
|
||||
|
||||
把聊天匯出的準確路徑告訴 Agent:
|
||||
|
||||
```text
|
||||
使用 $chatlab-convert-cn,把「/你的/聊天記錄路徑」轉換並匯入 ChatLab。
|
||||
使用 $chatlab-convert,把「/你的/聊天記錄路徑」轉換並匯入 ChatLab。
|
||||
```
|
||||
|
||||
如果只希望產生轉換結果,不立即匯入:
|
||||
|
||||
```text
|
||||
使用 $chatlab-convert-cn,把「/你的/聊天記錄路徑」轉換為 ChatLab 格式,只轉換,不匯入。
|
||||
使用 $chatlab-convert,把「/你的/聊天記錄路徑」轉換為 ChatLab 格式,只轉換,不匯入。
|
||||
```
|
||||
|
||||
Skill 會自動選擇電腦上已有的 Node.js、Python 或 Shell 工具,完成以下流程:
|
||||
@@ -59,6 +61,4 @@ clb import "/轉換後的檔案.jsonl" --dry-run --json
|
||||
|
||||
只有嚴格驗證和匯入預覽都成功,而且來源訊息數與輸出訊息數核對一致,才會回報轉換成功。轉換腳本和輸出檔案都會保留,方便之後重新轉換更新後的聊天記錄。
|
||||
|
||||
::: warning 隱私提醒
|
||||
請使用能存取本機檔案和終端的 Agent 執行轉換,不要把完整聊天檔案上傳到線上對話框。即使需要排查特殊文字格式,也應只檢查最小範圍並隱藏訊息正文。
|
||||
:::
|
||||
::: warning 隱私提醒請使用能存取本機檔案和終端的 Agent 執行轉換,不要把完整聊天檔案上傳到線上對話框。即使需要排查特殊文字格式,也應只檢查最小範圍並隱藏訊息正文。:::
|
||||
@@ -19,17 +19,19 @@ ChatLab 支援多種方式匯入聊天記錄。
|
||||
|
||||
適合已經使用 Codex、Claude Code 等各類 AI Agent,希望讓 Agent 代為執行匯入的使用者。
|
||||
|
||||
需要 Node.js 22.19 或更高版本。安裝 ChatLab CLI 和中文 Skill:
|
||||
需要 Node.js 22.19 或更高版本。安裝 ChatLab CLI 和匯入 Skill:
|
||||
|
||||
```bash
|
||||
npm install -g chatlab-cli
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-import-cn -g
|
||||
npx skills add ChatLab/ChatLab --skill chatlab-import -g
|
||||
```
|
||||
|
||||
各語言共用同一個技能,可以直接用繁體中文提出需求。曾安裝 `chatlab-import-cn` 時,請先保留自訂修改,再安裝上面的統一版本,並透過原安裝工具移除舊入口,避免重複顯示。
|
||||
|
||||
然後直接告訴 Agent:
|
||||
|
||||
```text
|
||||
chatlab-import-cn 幫我把 /absolute/path/to/chat-export.json 匯入 ChatLab
|
||||
chatlab-import 幫我把 /absolute/path/to/chat-export.json 匯入 ChatLab
|
||||
```
|
||||
|
||||
Agent 會先在背景預覽,預覽成功後自動建立或增量匯入,無需再次確認。
|
||||
|
||||
@@ -105,11 +105,9 @@ try {
|
||||
{ cwd: rootDir, stdio: 'inherit' }
|
||||
)
|
||||
|
||||
for (const skillName of ['chatlab-convert', 'chatlab-convert-cn']) {
|
||||
const scriptsDir = path.join(rootDir, 'skills', skillName, 'scripts')
|
||||
mkdirSync(scriptsDir, { recursive: true })
|
||||
copyFileSync(outputPath, path.join(scriptsDir, 'validate-chatlab.mjs'))
|
||||
}
|
||||
const scriptsDir = path.join(rootDir, 'skills', 'chatlab-convert', 'scripts')
|
||||
mkdirSync(scriptsDir, { recursive: true })
|
||||
copyFileSync(outputPath, path.join(scriptsDir, 'validate-chatlab.mjs'))
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: chatlab-analyze-cn
|
||||
description: 通过 clb CLI 分析本地 ChatLab 聊天记录。当用户要求外部 Agent 检查对话、查找证据、总结话题、比较成员,或基于已导入的 ChatLab 数据分析指定人物或群组关系时使用。
|
||||
---
|
||||
|
||||
# ChatLab 聊天分析
|
||||
|
||||
通过只读 `clb` CLI 查询和分析已经导入 ChatLab 的聊天记录。
|
||||
|
||||
导入新文件时,可用则使用 `chatlab-import-cn`;否则参考 `clb import --help`,在写入前预览准确的导入计划。
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 1. 准备查询
|
||||
|
||||
每个任务读取一次当前命令契约;只有目标尚未确定时才列出会话:
|
||||
|
||||
```bash
|
||||
clb manifest
|
||||
clb sessions list --format json
|
||||
```
|
||||
|
||||
复用对话中已确定的会话、成员和时间范围。仅在候选项和上下文仍无法消除关键歧义时询问。CLI 不可用时说明缺少的能力;安装需要授权。
|
||||
|
||||
### 2. 从专用命令开始
|
||||
|
||||
先使用能直接回答问题的最简单命令:
|
||||
|
||||
```bash
|
||||
clb messages search "<keyword>" --session <session-id> --format agent
|
||||
clb messages between --member me --member <member> --session <session-id> --last 90d --format agent
|
||||
clb topics list --session <session-id> --last 30d --format agent
|
||||
```
|
||||
|
||||
读取消息文本时使用 `--format agent`;侦察会话、成员、数量和 `--no-content` 搜索等结构时使用 `--format json`。
|
||||
|
||||
### 3. 补充上下文或统计
|
||||
|
||||
只有第一步结果不足时再深入:
|
||||
|
||||
```bash
|
||||
clb messages context --id 1021 --session <session-id> --window 10 --format agent
|
||||
clb stats keywords --session <session-id> --member <member> --last 90d --top 20 --format json
|
||||
```
|
||||
|
||||
仍需证据且 `meta.hasMore` 为 true 时,保持相同查询条件并使用 `--cursor <meta.nextCursor>` 继续。问题已回答即可停止;查询不完整时说明覆盖范围,不把单页当成全集。
|
||||
|
||||
### 4. 最后才使用 SQL
|
||||
|
||||
只有专用命令无法回答时,才使用只读 SQL:
|
||||
|
||||
```bash
|
||||
clb schema --session <session-id> --format json
|
||||
clb sql "SELECT COUNT(*) AS n FROM message" --session <session-id> --format json
|
||||
```
|
||||
|
||||
## 隐私与回答
|
||||
|
||||
- 查询保持隐私预处理启用,不使用 `--raw` 或修改数据、配置。另有明确导入请求时按导入流程处理。
|
||||
- 绝不泄露完整聊天记录。ChatLab 的安全输出会应用用户配置的隐私预处理。
|
||||
- 用 `[#1021]`、`[#1021*]` 或 `[#1021-1024]` 引用证据;`messages context --id` 只接受单个 ID,合并范围只用于展示。
|
||||
- 先直接回答,说明实际查询的会话和时间范围,再区分观察到的事实与解释。
|
||||
- 分析关系时不要过度推断情绪意图。仅在修正方式明确时遵循 `error.hint`。
|
||||
- 优先使用用户当前使用的中文变体回答,命令、参数、JSON 字段和证据标记保持原样。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: 'ChatLab 聊天分析'
|
||||
short_description: '通过 ChatLab CLI 安全查询并分析本地聊天记录'
|
||||
default_prompt: '使用 $chatlab-analyze-cn 分析我在 ChatLab 中的本地聊天记录。'
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
name: chatlab-analyze
|
||||
description: Analyze local ChatLab chat records through the clb CLI. Use when the user asks an external agent to inspect conversations, find evidence, summarize topics, compare members, or analyze a named relationship from imported ChatLab data.
|
||||
description: >-
|
||||
Query and analyze imported ChatLab chats with clb: find evidence, summarize topics, and compare activity or relationships. 查询和分析 ChatLab 已导入聊天记录:查找证据、总结话题、比较活跃度或关系。
|
||||
---
|
||||
|
||||
# ChatLab Analyze
|
||||
|
||||
Query and analyze records already imported into ChatLab through the read-only `clb` CLI.
|
||||
|
||||
Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged.
|
||||
|
||||
For importing a new chat export, use `chatlab-import` when available; otherwise follow `clb import --help` and preview the exact import before writing.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: 'ChatLab Analyze'
|
||||
short_description: 'Safely query and analyze local chats with ChatLab CLI'
|
||||
display_name: 'ChatLab Analyze · 聊天分析'
|
||||
short_description: 'Query and analyze local chats · 查询和分析本地聊天记录'
|
||||
default_prompt: 'Use $chatlab-analyze to analyze my local ChatLab chat records.'
|
||||
@@ -1,135 +0,0 @@
|
||||
---
|
||||
name: chatlab-convert-cn
|
||||
description: 编写可复用的本地脚本,将不受支持的聊天导出转换并验证为 ChatLab JSONL 或 JSON。用于未知格式适配,不用于聊天分析或直接导入已支持的文件。
|
||||
---
|
||||
|
||||
# ChatLab 聊天转换
|
||||
|
||||
在不修改源文件的前提下转换暂不支持的聊天导出。优先交付可复用的转换脚本和经过验证的 JSONL,而不是一次性改写文件。
|
||||
|
||||
## 不可省略的规则
|
||||
|
||||
- 始终以只读方式处理源文件。脚本、样本、解压文件和输出写入独立工作目录;用户指定输出位置时遵循该位置。
|
||||
- 聊天数据只留在本机。不要上传到网络服务,也不要在 AI 可见的终端输出中打印消息正文。
|
||||
- 先检查结构:字段名、列名、值类型、数量、时间戳形态和脱敏样本。诊断输出中的消息正文替换为类型和长度。
|
||||
- 绝不静默跳过损坏或不支持的源记录。应携带源行号/索引失败,或保留为消息类型 `99` 并明确统计映射数量。
|
||||
- 未经允许不要安装依赖或系统工具。优先使用已有 Node.js/Python 标准库;Shell 只用于检查和编排。
|
||||
- 用户没有明确要求导入时,不要写入 ChatLab。用户明确说“转换并导入”,则所有门禁通过后无需二次确认。
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 1. 确认源文件和 CLI
|
||||
|
||||
从请求和对话中确定源路径,仍有歧义时才询问。命令中的路径加引号;环境能力每个任务检查一次,任务内复用结果。
|
||||
|
||||
```bash
|
||||
clb --help
|
||||
clb validate --help
|
||||
clb formats
|
||||
clb import "/absolute/path/to/source" --dry-run --json
|
||||
```
|
||||
|
||||
如果 dry-run 已识别源文件,说明无需转换。只有用户要求导入时才继续,可用则使用 `chatlab-import-cn`,否则按下文预览和写入步骤执行。
|
||||
|
||||
`clb validate` 不可用时,直接使用 `scripts/validate-chatlab.mjs` 继续转换,不因推荐安装而暂停。内置验证器需要 Node.js 22.19 或更高版本;旧 CLI 可能仍支持 `formats` 和导入 dry-run,按实际可用能力执行。完全没有 CLI 时,说明无法检查原生格式支持和导入就绪状态。
|
||||
|
||||
两个严格验证器均无法运行时,尽可能完成转换器并将产物标记为尚未验证;不能用肉眼检查替代严格验证。只有缺失的运行时或 CLI 阻塞用户要求的结果时,才请求安装授权。
|
||||
|
||||
### 2. 在不暴露正文的前提下检查结构
|
||||
|
||||
先检查文件元信息、编码、压缩包目录、JSON 字段、CSV 表头、XML/HTML 元素名或数据库表结构。不要运行会打印原始消息列的 `cat`、无限制 `head` 或 SQL 查询。
|
||||
|
||||
确实需要样本值时,编写一个简短的本地检查脚本,只输出:
|
||||
|
||||
- 字段名和数据类型;
|
||||
- 记录数和空值数;
|
||||
- 时间戳与标识符形态;
|
||||
- `<TEXT length=42>` 这样的正文掩码;
|
||||
- 少量结构不同的记录。
|
||||
|
||||
压缩包要解压到独立工作目录,并拒绝逃逸该目录的条目。数据库必须只读打开。
|
||||
|
||||
### 3. 确定字段映射
|
||||
|
||||
编写转换器前,阅读 [references/chatlab-format.md](references/chatlab-format.md) 的字段契约与所选输出格式。记录源字段到 ChatLab 字段的映射,包括:
|
||||
|
||||
- 会话边界以及群聊/私聊类型;
|
||||
- 稳定的成员身份和本人身份;
|
||||
- 时间戳单位与时区;
|
||||
- 消息类型、正文、原始消息 ID 和回复关系;
|
||||
- 源记录总数,以及无法完全表达的记录。
|
||||
|
||||
只有当歧义会改变身份、会话边界、时间含义或本人归属时才询问用户;这些字段不能猜测。
|
||||
|
||||
一个 ChatLab 文件只能表示一个会话。如果源文件包含多个会话,要使用防冲突且确定性的文件名分别输出,绝不能误合并。
|
||||
|
||||
### 4. 编写确定性的转换器
|
||||
|
||||
默认输出 JSONL。只有源文件较小、天然结构化,并且可以明确安全地放入内存时才使用 JSON。
|
||||
|
||||
使用已安装且最适合当前任务的 Node.js 或 Python;不按源格式强制指定语言。
|
||||
|
||||
转换器必须:
|
||||
|
||||
- 通过参数接收输入、输出路径,不写死本机路径;
|
||||
- 保持源文件不变,并拒绝覆盖源文件;
|
||||
- 先写临时输出,成功后再重命名;
|
||||
- 保留源顺序,或按时间戳加稳定的源序号排序;
|
||||
- 优先保留源 ID;没有消息 ID 时省略可选字段,只有回复关联需要时才生成确定性 ID;
|
||||
- 缺少成员 ID 时根据稳定身份字段确定性生成,绝不能使用随机值;
|
||||
- 大文件流式处理,进度输出不得包含消息正文;
|
||||
- 解析失败时以非零状态退出,并输出源记录数、输出数和跳过数汇总。
|
||||
|
||||
`skipped` 默认为零。把未知消息映射为类型 `99` 属于保留,不计为跳过。
|
||||
|
||||
### 5. 先证明小样本
|
||||
|
||||
大文件或映射尚不确定时,先转换并验证受限样本;小文件可以直接全量转换验证。有 CLI 时运行:
|
||||
|
||||
```bash
|
||||
clb validate "/absolute/path/to/sample.jsonl" --json
|
||||
```
|
||||
|
||||
没有 CLI 时,定位本 `SKILL.md` 所在目录,并使用随 Skill 分发的验证器:
|
||||
|
||||
```bash
|
||||
node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/sample.jsonl"
|
||||
```
|
||||
|
||||
修复全部 error 后才能全量转换。逐项检查 warning 并说明为什么可以接受,不能自动忽略。
|
||||
|
||||
### 6. 全量转换并双重验证
|
||||
|
||||
运行完整转换,然后使用 CLI 或内置验证器验证每一个输出文件。有 CLI 时继续执行导入 dry-run:
|
||||
|
||||
```bash
|
||||
clb validate "/absolute/path/to/converted.jsonl" --json
|
||||
clb import "/absolute/path/to/converted.jsonl" --dry-run --json
|
||||
```
|
||||
|
||||
没有 CLI 时运行:
|
||||
|
||||
```bash
|
||||
node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/converted.jsonl"
|
||||
```
|
||||
|
||||
按验证能力区分结果,不能混用结论:
|
||||
|
||||
- **格式验证通过**:CLI 或内置严格验证器返回 `ok: true`,转换器输出消息数等于验证器消息数,源消息数等于输出消息数加上用户明确接受的跳过数,且没有隐藏源解析错误;
|
||||
- **导入验证通过**:在格式验证通过的基础上,`clb import --dry-run --json` 也返回 `ok: true`。
|
||||
|
||||
没有 CLI 时只能报告“格式验证通过”,并提醒用户之后可以安装 CLI 完成 dry-run,或将结果文件拖入 ChatLab;不能声称已经通过导入验证。
|
||||
|
||||
### 7. 仅在用户要求时导入
|
||||
|
||||
如果用户只要求转换,在当前环境可完成的验证后停止。报告输出路径、脚本路径、会话数、成员数、消息数、跳过数、警告、映射限制和实际达到的验证级别,不要引用消息正文。
|
||||
|
||||
如果用户明确要求导入,使用同一个已验证文件移除 `--dry-run`:
|
||||
|
||||
```bash
|
||||
clb import "/absolute/path/to/converted.jsonl" --json
|
||||
```
|
||||
|
||||
如果此时仍没有 CLI,说明自动导入需要 `chatlab-cli`,建议安装并征得用户同意;不能把“格式验证通过”当成已经导入。
|
||||
|
||||
存在多个输出时,逐个预览并独立导入。报告每个结果的会话 ID 和数量;不要删除转换脚本或转换结果。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: 'ChatLab 聊天转换'
|
||||
short_description: '将暂不支持的聊天记录转换并验证为 ChatLab 标准格式'
|
||||
default_prompt: '使用 $chatlab-convert-cn 将这个不受支持的聊天导出转换为经过验证的 ChatLab JSONL 文件。'
|
||||
@@ -1,144 +0,0 @@
|
||||
# ChatLab 转换协议
|
||||
|
||||
生成 ChatLab Format `0.0.2` 时遵循本协议。这里只保留转换器需要的内容;生成文件以 ChatLab CLI 或本 Skill 内置严格验证器的结果为准:
|
||||
|
||||
```bash
|
||||
clb validate "/absolute/path/to/output.jsonl" --json
|
||||
node "/absolute/path/to/chatlab-convert-cn/scripts/validate-chatlab.mjs" "/absolute/path/to/output.jsonl"
|
||||
```
|
||||
|
||||
两条命令按环境选择其一,不需要重复运行;内置验证器只验证格式,不代替 `clb import --dry-run --json` 的导入验证。
|
||||
|
||||
## 核心约束
|
||||
|
||||
- 一个输出文件只表示一个会话。
|
||||
- 使用 UTF-8。时间统一使用整数型 Unix **秒级**时间戳,绝不能使用毫秒。
|
||||
- `meta.type` 只能是 `group` 或 `private`。平台使用稳定的小写标识,例如 `qq`、`weixin` 或源产品名称。
|
||||
- `sender` 和 `ownerId` 引用成员的 `platformId`。
|
||||
- 保留每一条源记录。无法识别的消息映射为类型 `99`,不能丢弃。
|
||||
- 有原始成员 ID、消息 ID 时原样保留;即使源数据是数字,输出 ID 也使用字符串。
|
||||
- `platformMessageId` 可省略,但出现时必须唯一;`replyToMessageId` 引用其中一个消息 ID。
|
||||
- 消息按 `timestamp` 升序排列;时间相同时保留稳定的源序号。
|
||||
- `accountName` 表示账号名称,`groupNickname` 表示该会话中的昵称。
|
||||
|
||||
## 推荐的 JSONL 结构
|
||||
|
||||
每行写一个紧凑 JSON 对象。第一个非注释数据行是唯一的 `header`,随后写全部 `member`,最后写 `message`。
|
||||
|
||||
```jsonl
|
||||
{"_type":"header","chatlab":{"version":"0.0.2","exportedAt":1711468800,"generator":"local-converter"},"meta":{"name":"示例群聊","platform":"example","type":"group","groupId":"group-1","ownerId":"member-1"}}
|
||||
{"_type":"member","platformId":"member-1","accountName":"小红","groupNickname":"小红","aliases":["红红"],"roles":[{"id":"owner"}]}
|
||||
{"_type":"member","platformId":"member-2","accountName":"小明"}
|
||||
{"_type":"message","platformMessageId":"message-1","sender":"member-1","accountName":"小红","groupNickname":"小红","timestamp":1711468800,"type":0,"content":"你好"}
|
||||
{"_type":"message","platformMessageId":"message-2","replyToMessageId":"message-1","sender":"member-2","accountName":"小明","timestamp":1711468810,"type":25,"content":"收到"}
|
||||
```
|
||||
|
||||
允许空行和以 `#` 开头的注释行,但转换器通常不应生成它们。只有源数据完全没有成员信息时才省略成员行;只要写了成员行,就必须在第一条消息前写完所有发送者。
|
||||
|
||||
## JSON 结构
|
||||
|
||||
只有完整结果可以安全地放入内存时才使用 JSON。`members` 和 `messages` 都是必需数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"chatlab": {
|
||||
"version": "0.0.2",
|
||||
"exportedAt": 1711468800,
|
||||
"generator": "local-converter"
|
||||
},
|
||||
"meta": {
|
||||
"name": "示例私聊",
|
||||
"platform": "example",
|
||||
"type": "private",
|
||||
"ownerId": "member-1"
|
||||
},
|
||||
"members": [
|
||||
{ "platformId": "member-1", "accountName": "小红" },
|
||||
{ "platformId": "member-2", "accountName": "小明" }
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"sender": "member-1",
|
||||
"accountName": "小红",
|
||||
"timestamp": 1711468800,
|
||||
"type": 0,
|
||||
"content": "你好"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 字段
|
||||
|
||||
### 文件头
|
||||
|
||||
| 路径 | 必需 | 约束 |
|
||||
| --------------------- | ---- | ---------------------- |
|
||||
| `chatlab.version` | 是 | 固定为 `0.0.2` |
|
||||
| `chatlab.exportedAt` | 是 | 整数型 Unix 秒级时间戳 |
|
||||
| `chatlab.generator` | 否 | 转换器名称 |
|
||||
| `chatlab.description` | 否 | 不含隐私的简短说明 |
|
||||
| `meta.name` | 是 | 会话显示名称 |
|
||||
| `meta.platform` | 是 | 稳定的平台标识 |
|
||||
| `meta.type` | 是 | `group` 或 `private` |
|
||||
| `meta.groupId` | 否 | 原始群组/会话 ID |
|
||||
| `meta.groupAvatar` | 否 | Data URL 或网络 URL |
|
||||
| `meta.ownerId` | 否 | 导出者的 `platformId` |
|
||||
|
||||
### 成员
|
||||
|
||||
| 字段 | 必需 | 约束 |
|
||||
| --------------- | ---- | ------------------------------------------------ |
|
||||
| `platformId` | 是 | 稳定且唯一的字符串身份 |
|
||||
| `accountName` | 是 | 账号显示名称 |
|
||||
| `groupNickname` | 否 | 当前会话中的显示名称 |
|
||||
| `aliases` | 否 | 字符串数组 |
|
||||
| `avatar` | 否 | Data URL 或网络 URL |
|
||||
| `roles` | 否 | 如 `[{"id":"owner"}]`、`[{"id":"admin"}]` 的数组 |
|
||||
|
||||
### 消息
|
||||
|
||||
| 字段 | 必需 | 约束 |
|
||||
| ------------------- | ---- | ------------------------------------- |
|
||||
| `sender` | 是 | 发送者的 `platformId` |
|
||||
| `accountName` | 是 | 发送时的账号名称 |
|
||||
| `groupNickname` | 否 | 发送时的群昵称 |
|
||||
| `timestamp` | 是 | 整数型 Unix 秒级时间戳 |
|
||||
| `type` | 是 | 使用下表中的数字 |
|
||||
| `content` | 是 | 字符串或 `null`;尽量保留有用的源表示 |
|
||||
| `platformMessageId` | 否 | 唯一的原始消息 ID |
|
||||
| `replyToMessageId` | 否 | 被回复消息的原始 ID |
|
||||
|
||||
## 消息类型
|
||||
|
||||
| 值 | 含义 | 值 | 含义 |
|
||||
| ---- | ------------- | ---- | --------- |
|
||||
| `0` | 文本 | `1` | 图片 |
|
||||
| `2` | 语音 | `3` | 视频 |
|
||||
| `4` | 文件 | `5` | 表情/贴纸 |
|
||||
| `7` | 链接/卡片 | `8` | 位置 |
|
||||
| `20` | 红包 | `21` | 转账 |
|
||||
| `22` | 拍一拍/戳一戳 | `23` | 通话 |
|
||||
| `24` | 分享 | `25` | 回复 |
|
||||
| `26` | 转发消息 | `27` | 名片 |
|
||||
| `80` | 系统事件 | `81` | 撤回消息 |
|
||||
| `99` | 其他/未知 | | |
|
||||
|
||||
媒体消息没有内嵌正文时,在 `content` 中保留有用的占位符或源文件相对路径;只有源数据确实没有任何表示时才使用 `null`。
|
||||
|
||||
## 身份兜底规则
|
||||
|
||||
优先使用源平台不可变的用户 ID。确实缺少时,根据同一平台、同一会话内的稳定身份字段确定性生成,例如对规范化账号身份计算 SHA-256,并添加不敏感的前缀。成员顺序可能变化时不能只使用数组下标,绝不能使用随机值。
|
||||
|
||||
有原始消息 ID 时保留;没有时通常省略 `platformMessageId`。只有回复关系必须依赖消息 ID 时才生成确定性 ID,并纳入会话身份、发送者、秒级时间戳、稳定源序号和正文摘要,确保重复执行结果一致。
|
||||
|
||||
## 必须核对的数量
|
||||
|
||||
转换器必须在不输出正文的前提下报告:
|
||||
|
||||
- 源会话数和源消息记录数;
|
||||
- 输出会话数、成员数和消息数;
|
||||
- 映射为类型 `99` 的记录数;
|
||||
- 跳过数和失败数。
|
||||
|
||||
跳过数和失败数默认为零。只有源消息数等于输出消息数加用户明确接受的跳过数、严格验证通过,并且 `clb import <output> --dry-run --json` 成功,转换才算完成。
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Generated from the canonical ChatLab validator. Do not edit directly.
|
||||
/* eslint-disable */
|
||||
import{existsSync as H}from"node:fs";import j from"node:path";import{createReadStream as P}from"node:fs";import{readFile as v,stat as k}from"node:fs/promises";import{extname as D}from"node:path";import{createInterface as V}from"node:readline";var R="0.0.2",S=["0.0.1",R];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var G=100,M=1e11,F=new Set(Object.values(h).filter(e=>typeof e=="number")),E="SYSTEM",$=new Set([80,81]);function y(e,t){return{format:e,fileSize:t,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,t,r,n,o={}){t==="error"?e.errorCount++:e.warningCount++,e.issues.length<G&&e.issues.push({severity:t,code:r,message:n,...o})}function d(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function c(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function p(e,t){return{...e,path:t}}function m(e,t,r,n){return typeof t=="string"&&t.trim().length>0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",p(n,r)),!1)}function u(e,t,r,n,o){c(t,r)&&typeof t[r]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",p(o,n))}function x(e,t,r,n){return typeof t=="number"&&Number.isInteger(t)&&t>=0&&t<M?!0:(a(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",p(n,r)),!1)}function T(e,t,r,n,o=""){if(e.stats.headerCount++,!d(t))a(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...n,path:`${o}chatlab`});else{let i=`${o}chatlab.version`;typeof t.version!="string"?a(e,"error","INVALID_VERSION","Expected a string format version.",{...n,path:i}):(e.version=t.version,C(t.version)||a(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${S.join(", ")}.`,{...n,path:i})),x(e,t.exportedAt,`${o}chatlab.exportedAt`,n),u(e,t,"generator",`${o}chatlab.generator`,n),u(e,t,"description",`${o}chatlab.description`,n)}if(!d(r)){a(e,"error","INVALID_META","Expected a meta object.",{...n,path:`${o}meta`});return}m(e,r.name,`${o}meta.name`,n),m(e,r.platform,`${o}meta.platform`,n),r.type!=="group"&&r.type!=="private"&&a(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...n,path:`${o}meta.type`}),u(e,r,"groupId",`${o}meta.groupId`,n),u(e,r,"groupAvatar",`${o}meta.groupAvatar`,n),c(r,"ownerId")&&m(e,r.ownerId,`${o}meta.ownerId`,n)&&e.ownerIds.add(r.ownerId),u(e,r,"sourceSessionId",`${o}meta.sourceSessionId`,n)}function _(e,t,r,n){if(e.stats.memberCount++,!d(t)){a(e,"error","INVALID_MEMBER","Expected a member object.",p(n,r));return}m(e,t.platformId,`${r}.platformId`,n)&&(e.memberIds.has(t.platformId)?a(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...n,path:`${r}.platformId`}):e.memberIds.add(t.platformId)),m(e,t.accountName,`${r}.accountName`,n),u(e,t,"groupNickname",`${r}.groupNickname`,n),u(e,t,"avatar",`${r}.avatar`,n),c(t,"aliases")&&(!Array.isArray(t.aliases)||t.aliases.some(o=>typeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...n,path:`${r}.aliases`}),c(t,"roles")&&(Array.isArray(t.roles)?t.roles.forEach((o,i)=>{let g=`${r}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...n,path:g});return}m(e,o.id,`${g}.id`,n),u(e,o,"name",`${g}.name`,n)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...n,path:`${r}.roles`}))}function N(e,t,r,n){if(e.stats.messageCount++,!d(t)){a(e,"error","INVALID_MESSAGE","Expected a message object.",p(n,r));return}let o=m(e,t.sender,`${r}.sender`,n)?t.sender:void 0;o!==void 0&&e.senderIds.add(o),m(e,t.accountName,`${r}.accountName`,n),x(e,t.timestamp,`${r}.timestamp`,n)&&(e.lastTimestamp!==void 0&&t.timestamp<e.lastTimestamp&&a(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",p(n,`${r}.timestamp`)),e.lastTimestamp=t.timestamp);let i=typeof t.type=="number"&&Number.isInteger(t.type)&&F.has(t.type)?t.type:void 0;i===void 0?a(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...n,path:`${r}.type`}):o===E&&!$.has(i)&&a(e,"error","INVALID_SYSTEM_SENDER_TYPE","The reserved SYSTEM sender is only valid for system and recall messages.",p(n,`${r}.sender`)),(!c(t,"content")||typeof t.content!="string"&&t.content!==null)&&a(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...n,path:`${r}.content`}),u(e,tLine truncated
|
||||
`)}if(!O)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs <file>"},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=j.resolve(O);if(!H(e))f({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let t=await L(e);f(t.valid?{ok:!0,command:"validate",data:t,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:t,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${t.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),t.valid||(process.exitCode=1)}catch(t){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:t instanceof Error?t.message:String(t)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}}
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
name: chatlab-convert
|
||||
description: Convert unsupported local chat exports into validated ChatLab JSONL or JSON with a reproducible local script. Use for unknown-format adaptation, not analysis or direct import of already supported files.
|
||||
description: >-
|
||||
Convert unsupported local chat exports into validated ChatLab JSONL or JSON with a reusable local script. 用可复用的本地脚本,将不受支持的聊天导出转换并验证为 ChatLab JSONL 或 JSON。
|
||||
---
|
||||
|
||||
# ChatLab Convert
|
||||
|
||||
Convert an unsupported local export without modifying the source. Prefer a reproducible script and a validated JSONL result over a one-off rewritten file.
|
||||
|
||||
Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged.
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
- Keep every source file read-only. Write scripts, samples, extracted archives, and output to a separate working directory, using the user's output location when specified.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: 'ChatLab Convert'
|
||||
short_description: 'Convert unsupported chat exports into ChatLab format'
|
||||
display_name: 'ChatLab Convert · 聊天转换'
|
||||
short_description: 'Convert unsupported chat exports · 转换不受支持的聊天格式'
|
||||
default_prompt: 'Use $chatlab-convert to convert this unsupported chat export into a validated ChatLab JSONL file.'
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// Generated from the canonical ChatLab validator. Do not edit directly.
|
||||
/* eslint-disable */
|
||||
import{existsSync as H}from"node:fs";import j from"node:path";import{createReadStream as P}from"node:fs";import{readFile as v,stat as k}from"node:fs/promises";import{extname as D}from"node:path";import{createInterface as V}from"node:readline";var R="0.0.2",S=["0.0.1",R];function C(e){return S.includes(e)}var h=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(h||{});var G=100,M=1e11,F=new Set(Object.values(h).filter(e=>typeof e=="number")),E="SYSTEM",$=new Set([80,81]);function y(e,t){return{format:e,fileSize:t,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,t,r,n,o={}){t==="error"?e.errorCount++:e.warningCount++,e.issues.length<G&&e.issues.push({severity:t,code:r,message:n,...o})}function d(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function c(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function p(e,t){return{...e,path:t}}function m(e,t,r,n){return typeof t=="string"&&t.trim().length>0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",p(n,r)),!1)}function u(e,t,r,n,o){c(t,r)&&typeof t[r]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",p(o,n))}function x(e,t,r,n){return typeof t=="number"&&Number.isInteger(t)&&t>=0&&t<M?!0:(a(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",p(n,r)),!1)}function T(e,t,r,n,o=""){if(e.stats.headerCount++,!d(t))a(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...n,path:`${o}chatlab`});else{let i=`${o}chatlab.version`;typeof t.version!="string"?a(e,"error","INVALID_VERSION","Expected a string format version.",{...n,path:i}):(e.version=t.version,C(t.version)||a(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${S.join(", ")}.`,{...n,path:i})),x(e,t.exportedAt,`${o}chatlab.exportedAt`,n),u(e,t,"generator",`${o}chatlab.generator`,n),u(e,t,"description",`${o}chatlab.description`,n)}if(!d(r)){a(e,"error","INVALID_META","Expected a meta object.",{...n,path:`${o}meta`});return}m(e,r.name,`${o}meta.name`,n),m(e,r.platform,`${o}meta.platform`,n),r.type!=="group"&&r.type!=="private"&&a(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...n,path:`${o}meta.type`}),u(e,r,"groupId",`${o}meta.groupId`,n),u(e,r,"groupAvatar",`${o}meta.groupAvatar`,n),c(r,"ownerId")&&m(e,r.ownerId,`${o}meta.ownerId`,n)&&e.ownerIds.add(r.ownerId),u(e,r,"sourceSessionId",`${o}meta.sourceSessionId`,n)}function _(e,t,r,n){if(e.stats.memberCount++,!d(t)){a(e,"error","INVALID_MEMBER","Expected a member object.",p(n,r));return}m(e,t.platformId,`${r}.platformId`,n)&&(e.memberIds.has(t.platformId)?a(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...n,path:`${r}.platformId`}):e.memberIds.add(t.platformId)),m(e,t.accountName,`${r}.accountName`,n),u(e,t,"groupNickname",`${r}.groupNickname`,n),u(e,t,"avatar",`${r}.avatar`,n),c(t,"aliases")&&(!Array.isArray(t.aliases)||t.aliases.some(o=>typeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...n,path:`${r}.aliases`}),c(t,"roles")&&(Array.isArray(t.roles)?t.roles.forEach((o,i)=>{let g=`${r}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...n,path:g});return}m(e,o.id,`${g}.id`,n),u(e,o,"name",`${g}.name`,n)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...n,path:`${r}.roles`}))}function N(e,t,r,n){if(e.stats.messageCount++,!d(t)){a(e,"error","INVALID_MESSAGE","Expected a message object.",p(n,r));return}let o=m(e,t.sender,`${r}.sender`,n)?t.sender:void 0;o!==void 0&&e.senderIds.add(o),m(e,t.accountName,`${r}.accountName`,n),x(e,t.timestamp,`${r}.timestamp`,n)&&(e.lastTimestamp!==void 0&&t.timestamp<e.lastTimestamp&&a(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",p(n,`${r}.timestamp`)),e.lastTimestamp=t.timestamp);let i=typeof t.type=="number"&&Number.isInteger(t.type)&&F.has(t.type)?t.type:void 0;i===void 0?a(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...n,path:`${r}.type`}):o===E&&!$.has(i)&&a(e,"error","INVALID_SYSTEM_SENDER_TYPE","The reserved SYSTEM sender is only valid for system and recall messages.",p(n,`${r}.sender`)),(!c(t,"content")||typeof t.content!="string"&&t.content!==null)&&a(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...n,path:`${r}.content`}),u(e,tLine truncated
|
||||
`)}if(!O)f({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs <file>"},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=j.resolve(O);if(!H(e))f({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let t=await L(e);f(t.valid?{ok:!0,command:"validate",data:t,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:t,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${t.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),t.valid||(process.exitCode=1)}catch(t){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:t instanceof Error?t.message:String(t)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}}
|
||||
import{existsSync as K}from"node:fs";import q from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as L}from"node:fs/promises";import{extname as k}from"node:path";import{createInterface as D}from"node:readline";var x="0.0.2",C=["0.0.1",x];function f(e){return C.includes(e)}var y=(s=>(s[s.TEXT=0]="TEXT",s[s.IMAGE=1]="IMAGE",s[s.VOICE=2]="VOICE",s[s.VIDEO=3]="VIDEO",s[s.FILE=4]="FILE",s[s.EMOJI=5]="EMOJI",s[s.LINK=7]="LINK",s[s.LOCATION=8]="LOCATION",s[s.RED_PACKET=20]="RED_PACKET",s[s.TRANSFER=21]="TRANSFER",s[s.POKE=22]="POKE",s[s.CALL=23]="CALL",s[s.SHARE=24]="SHARE",s[s.REPLY=25]="REPLY",s[s.FORWARD=26]="FORWARD",s[s.CONTACT=27]="CONTACT",s[s.SYSTEM=80]="SYSTEM",s[s.RECALL=81]="RECALL",s[s.OTHER=99]="OTHER",s))(y||{});var G=100,V=1e11,F=new Set(Object.values(y).filter(e=>typeof e=="number")),A="SYSTEM",$=new Set([80,81]);function I(e,t){return{format:e,fileSize:t,errorCount:0,warningCount:0,issues:[],stats:{headerCount:0,memberCount:0,messageCount:0,uniqueSenderCount:0,commentLineCount:0,blankLineCount:0},memberIds:new Set,ownerIds:new Set,senderIds:new Set,messageIds:new Set,replyTargetIds:new Set}}function a(e,t,r,n,o={}){t==="error"?e.errorCount++:e.warningCount++,e.issues.length<G&&e.issues.push({severity:t,code:r,message:n,...o})}function d(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function l(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function p(e,t){return{...e,path:t}}function m(e,t,r,n){return typeof t=="string"&&t.trim().length>0?!0:(a(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",p(n,r)),!1)}function u(e,t,r,n,o){l(t,r)&&typeof t[r]!="string"&&a(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",p(o,n))}function T(e,t,r,n){return typeof t=="number"&&Number.isInteger(t)&&t>=0&&t<V?!0:(a(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",p(n,r)),!1)}function v(e,t,r,n,o=""){if(e.stats.headerCount++,!d(t))a(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...n,path:`${o}chatlab`});else{let i=`${o}chatlab.version`;typeof t.version!="string"?a(e,"error","INVALID_VERSION","Expected a string format version.",{...n,path:i}):(e.version=t.version,f(t.version)||a(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${C.join(", ")}.`,{...n,path:i})),T(e,t.exportedAt,`${o}chatlab.exportedAt`,n),u(e,t,"generator",`${o}chatlab.generator`,n),u(e,t,"description",`${o}chatlab.description`,n)}if(!d(r)){a(e,"error","INVALID_META","Expected a meta object.",{...n,path:`${o}meta`});return}m(e,r.name,`${o}meta.name`,n),m(e,r.platform,`${o}meta.platform`,n),r.type!=="group"&&r.type!=="private"&&a(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...n,path:`${o}meta.type`}),u(e,r,"groupId",`${o}meta.groupId`,n),u(e,r,"groupAvatar",`${o}meta.groupAvatar`,n),l(r,"ownerId")&&m(e,r.ownerId,`${o}meta.ownerId`,n)&&e.ownerIds.add(r.ownerId),u(e,r,"sourceSessionId",`${o}meta.sourceSessionId`,n)}function E(e,t,r,n){if(e.stats.memberCount++,!d(t)){a(e,"error","INVALID_MEMBER","Expected a member object.",p(n,r));return}m(e,t.platformId,`${r}.platformId`,n)&&(e.memberIds.has(t.platformId)?a(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...n,path:`${r}.platformId`}):e.memberIds.add(t.platformId)),m(e,t.accountName,`${r}.accountName`,n),u(e,t,"groupNickname",`${r}.groupNickname`,n),u(e,t,"avatar",`${r}.avatar`,n),l(t,"aliases")&&(!Array.isArray(t.aliases)||t.aliases.some(o=>typeof o!="string"))&&a(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...n,path:`${r}.aliases`}),l(t,"roles")&&(Array.isArray(t.roles)?t.roles.forEach((o,i)=>{let g=`${r}.roles[${i}]`;if(!d(o)){a(e,"error","INVALID_ROLE","Expected a role object.",{...n,path:g});return}m(e,o.id,`${g}.id`,n),u(e,o,"name",`${g}.name`,n)}):a(e,"error","INVALID_ROLES","Expected roles to be an array.",{...n,path:`${r}.roles`}))}function _(e,t,r,n){if(e.stats.messageCount++,!d(t)){a(e,"error","INVALID_MESSAGE","Expected a message object.",p(n,r));return}let o=m(e,t.sender,`${r}.sender`,n)?t.sender:void 0;o!==void 0&&e.senderIds.add(o),m(e,t.accountName,`${r}.accountName`,n),T(e,t.timestamp,`${r}.timestamp`,n)&&(e.lastTimestamp!==void 0&&t.timestamp<e.lastTimestamp&&a(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",p(n,`${r}.timestamp`)),e.lastTimestamp=t.timestamp);let i=typeof t.type=="number"&&Number.isInteger(t.type)&&F.has(t.type)?t.type:void 0;i===void 0?a(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...n,path:`${r}.type`}):o===A&&!$.has(i)&&a(e,"error","INVALID_SYSTEM_SENDER_TYPE","The reserved SYSTEM sender is only valid for system and recall messages.",p(n,`${r}.sender`)),(!l(t,"content")||typeof t.content!="string"&&t.content!==null)&&a(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...n,path:`${r}.content`}),u(e,tLine truncated
|
||||
`)}if(!w)S({ok:!1,command:"validate",error:{code:"MISSING_FILE",message:"Usage: node validate-chatlab.mjs <file>"},meta:{apiVersion:1,standalone:!0}}),process.exitCode=2;else{let e=q.resolve(w);if(!K(e))S({ok:!1,command:"validate",error:{code:"FILE_NOT_FOUND",message:`File not found: ${e}`,hint:"Pass an absolute path or a path relative to the current working directory."},meta:{apiVersion:1,standalone:!0}}),process.exitCode=3;else try{let t=await N(e);S(t.valid?{ok:!0,command:"validate",data:t,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:t,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${t.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),t.valid||(process.exitCode=1)}catch(t){S({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:t instanceof Error?t.message:String(t)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}}
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
name: chatlab-import-cn
|
||||
description: 通过 clb 预览并导入本地聊天导出文件,支持重新导入和增量更新。用于导入请求,不用于只读分析或未知格式转换。
|
||||
---
|
||||
|
||||
# ChatLab 聊天导入
|
||||
|
||||
通过 `clb` CLI 导入本地聊天导出文件。始终先预览准确的写入计划。将用户明确提出的导入请求视为授权,预览成功后继续执行,不再请求二次确认。
|
||||
|
||||
分析已有记录时,可用则使用 `chatlab-analyze-cn`;源格式不受支持时,可用则使用 `chatlab-convert-cn`。受支持格式的导入不依赖这两个技能。
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 检查 CLI,并确认唯一、准确的文件路径:
|
||||
|
||||
```bash
|
||||
clb --help
|
||||
```
|
||||
|
||||
如果 CLI 未安装,告诉用户运行 `npm install -g chatlab-cli`。未经批准不要自行安装软件。复用用户指定的文件和目标,路径仍有歧义时才询问。每条命令中的路径都要加引号。
|
||||
|
||||
2. 只读预览导入计划:
|
||||
|
||||
```bash
|
||||
clb import "/absolute/path/to/chat-export.json" --dry-run --json
|
||||
```
|
||||
|
||||
如果用户选择了已有会话,在预览和正式导入中都加入 `--session-id <session-id>`。
|
||||
|
||||
3. 只总结新建或更新模式、目标会话 ID、扫描消息数、新增消息数、重复消息数,以及匹配方式或新建原因。不要引用消息正文。
|
||||
|
||||
4. 确认 `ok: true`,从 `data` 读取计划后决定:
|
||||
|
||||
- 用户只要求预览或检查时,到此停止,不写入数据。
|
||||
- `importMode` 为 `incremental` 且 `newMessageCount` 为 `0` 时,告诉用户会话已经是最新状态,然后停止。
|
||||
- `importMode` 为 `incremental` 且存在新增消息时,无需再次确认,自动继续。
|
||||
- `importMode` 为 `created` 时,自动继续。如果 `createReason` 为 `ambiguous`,通过新建会话保护已有会话,并在最终结果中说明原因。
|
||||
- 结果不足以确定安全操作时,停止并说明缺少的信息。
|
||||
|
||||
5. 根据上一步可以写入时,使用相同文件、目标和格式移除 `--dry-run`:
|
||||
|
||||
```bash
|
||||
clb import "/absolute/path/to/chat-export.json" --json
|
||||
```
|
||||
|
||||
根据最终 JSON 信封报告结果会话 ID、导入模式、新增消息数和重复消息数。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 绝不跳过预览、直接编辑 ChatLab 数据库、删除或合并会话。
|
||||
- 绝不泄露完整聊天导出文件或消息正文。
|
||||
- 绝不编造文件路径、解析格式或会话 ID。
|
||||
- 仅在修正方式明确时遵循 `error.hint`。
|
||||
- `FILE_NOT_FOUND`、`INVALID_SESSION_ID` 优先从上下文修正,无法确定时询问;`UNRECOGNIZED_FORMAT` 查看 `clb formats`;`IMPORT_IN_PROGRESS` 在现有导入完成后重试一次,仍阻塞则报告,不无限轮询。
|
||||
- 优先使用用户当前使用的中文变体回答,命令、参数、JSON 字段和错误码保持原样。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: 'ChatLab 聊天导入'
|
||||
short_description: '通过 ChatLab CLI 安全预览并导入本地聊天记录'
|
||||
default_prompt: '使用 $chatlab-import-cn 安全预览并将这个聊天导出文件导入 ChatLab。'
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
name: chatlab-import
|
||||
description: Preview and import local chat exports into ChatLab with clb, including re-imports and incremental updates. Use for import requests, not read-only analysis or unsupported-format conversion.
|
||||
description: >-
|
||||
Preview and import supported local chat exports into ChatLab, including incremental updates. 预览并导入 ChatLab 支持的本地聊天导出,支持增量更新。
|
||||
---
|
||||
|
||||
# ChatLab Import
|
||||
|
||||
Import local chat exports through the `clb` CLI. Always preview the exact write. Treat an explicit request to import as authorization to continue after a successful preview; do not ask for a second confirmation.
|
||||
|
||||
Follow the user's requested response language; otherwise use the conversation language, preserving the user's Chinese variant when applicable. Keep commands, field names, IDs, and evidence markers unchanged.
|
||||
|
||||
For analysis of imported records, use `chatlab-analyze` when available. For an unsupported source, use `chatlab-convert` when available; neither skill is required for a supported import.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: 'ChatLab Import'
|
||||
short_description: 'Preview and import chat exports with ChatLab CLI'
|
||||
display_name: 'ChatLab Import · 聊天导入'
|
||||
short_description: 'Preview and import chat exports · 预览并导入聊天记录'
|
||||
default_prompt: 'Use $chatlab-import to safely preview and import this chat export into ChatLab.'
|
||||
@@ -7,10 +7,7 @@ import { after, before, describe, it } from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const VALIDATOR_PATHS = [
|
||||
'skills/chatlab-convert/scripts/validate-chatlab.mjs',
|
||||
'skills/chatlab-convert-cn/scripts/validate-chatlab.mjs',
|
||||
]
|
||||
const VALIDATOR_PATH = path.join(ROOT_DIR, 'skills/chatlab-convert/scripts/validate-chatlab.mjs')
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
@@ -31,8 +28,8 @@ function createDocument(sender: string, type: number): string {
|
||||
})
|
||||
}
|
||||
|
||||
function runValidator(validatorPath: string, documentPath: string) {
|
||||
const result = spawnSync(process.execPath, [path.join(ROOT_DIR, validatorPath), documentPath], {
|
||||
function runValidator(documentPath: string) {
|
||||
const result = spawnSync(process.execPath, [VALIDATOR_PATH, documentPath], {
|
||||
cwd: ROOT_DIR,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
@@ -54,30 +51,25 @@ after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('standalone ChatLab skill validators', () => {
|
||||
for (const validatorPath of VALIDATOR_PATHS) {
|
||||
it(`${validatorPath} accepts reserved SYSTEM messages`, () => {
|
||||
const documentPath = path.join(tempDir, `${path.basename(path.dirname(path.dirname(validatorPath)))}-system.json`)
|
||||
fs.writeFileSync(documentPath, createDocument('SYSTEM', 80), 'utf8')
|
||||
describe('standalone ChatLab skill validator', () => {
|
||||
it('accepts reserved SYSTEM messages', () => {
|
||||
const documentPath = path.join(tempDir, 'system.json')
|
||||
fs.writeFileSync(documentPath, createDocument('SYSTEM', 80), 'utf8')
|
||||
|
||||
const result = runValidator(validatorPath, documentPath)
|
||||
const result = runValidator(documentPath)
|
||||
|
||||
assert.equal(result.status, 0)
|
||||
assert.equal(result.output.ok, true)
|
||||
})
|
||||
assert.equal(result.status, 0)
|
||||
assert.equal(result.output.ok, true)
|
||||
})
|
||||
|
||||
it(`${validatorPath} rejects SYSTEM as a regular message sender`, () => {
|
||||
const documentPath = path.join(
|
||||
tempDir,
|
||||
`${path.basename(path.dirname(path.dirname(validatorPath)))}-invalid-system.json`
|
||||
)
|
||||
fs.writeFileSync(documentPath, createDocument('SYSTEM', 0), 'utf8')
|
||||
it('rejects SYSTEM as a regular message sender', () => {
|
||||
const documentPath = path.join(tempDir, 'invalid-system.json')
|
||||
fs.writeFileSync(documentPath, createDocument('SYSTEM', 0), 'utf8')
|
||||
|
||||
const result = runValidator(validatorPath, documentPath)
|
||||
const result = runValidator(documentPath)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.equal(result.output.ok, false)
|
||||
assert.ok(result.output.data?.issues.some((issue) => issue.code === 'INVALID_SYSTEM_SENDER_TYPE'))
|
||||
})
|
||||
}
|
||||
assert.equal(result.status, 1)
|
||||
assert.equal(result.output.ok, false)
|
||||
assert.ok(result.output.data?.issues.some((issue) => issue.code === 'INVALID_SYSTEM_SENDER_TYPE'))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user