fix(import): enforce reserved system sender contract

Restrict the ChatLab SYSTEM sender to system and recall events, keep bundled validators synchronized, and canonicalize its identity consistently in Node and Browser runtimes. Preserve ordinary participants named SYSTEM in non-ChatLab formats.
This commit is contained in:
digua committed 2026-07-26 01:43:43 +08:00
1 parent dc20320041
commit cf3fd2ab85
11 files changed
+534 -29

No files matched your search

@@ -39,6 +39,39 @@ function writeChatLabJsonl(filePath: string): void {
fs.writeFileSync(filePath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8')
}
function writeSystemChatLabJsonl(filePath: string): void {
const lines = [
{
_type: 'header',
chatlab: { version: '0.0.2', exportedAt: 1780330900 },
meta: { name: 'System Events', platform: 'custom', type: 'group' },
},
{
_type: 'member',
platformId: 'alice',
accountName: 'Alice',
},
{
_type: 'message',
sender: 'alice',
accountName: 'Alice',
timestamp: 1780330832,
type: 0,
content: 'hello',
},
{
_type: 'message',
sender: 'SYSTEM',
accountName: 'System',
timestamp: 1780330833,
type: 80,
content: 'Bob joined the group',
},
]
fs.writeFileSync(filePath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8')
}
function writeChatLabJson(filePath: string): void {
fs.writeFileSync(
filePath,
@@ -132,6 +165,39 @@ test('preserves ChatLab JSON member aliases during incremental import', async (t
assert.deepEqual(JSON.parse(row?.aliases ?? '[]'), ['Ally'])
})
test('canonicalizes reserved SYSTEM senders during incremental ChatLab imports', async (t) => {
const tempDir = makeTempDir()
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
const dbPath = path.join(tempDir, 'session.db')
const filePath = path.join(tempDir, 'system-events.jsonl')
seedSessionDb(dbPath)
writeSystemChatLabJsonl(filePath)
const result = await incrementalImport('session', filePath, createDeps(dbPath))
assert.equal(result.success, true)
assert.equal(result.newMessageCount, 2)
assert.equal(result.session?.memberCount, 1)
const db = openBetterSqliteDatabase(dbPath, { readonly: true, nativeBinding })
const member = db.prepare('SELECT account_name, group_nickname FROM member WHERE platform_id = ?').get('SYSTEM') as
| { account_name: string | null; group_nickname: string | null }
| undefined
const message = db
.prepare(
`SELECT msg.sender_account_name, msg.sender_group_nickname
FROM message msg
JOIN member m ON m.id = msg.sender_id
WHERE m.platform_id = ?`
)
.get('SYSTEM') as { sender_account_name: string | null; sender_group_nickname: string | null } | undefined
db.close()
assert.deepEqual(member, { account_name: '系统消息', group_nickname: '系统消息' })
assert.deepEqual(message, { sender_account_name: '系统消息', sender_group_nickname: '系统消息' })
})
test('does not deduplicate messages that only share timestamp, sender and content', async (t) => {
const tempDir = makeTempDir()
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
@@ -13,7 +13,7 @@ import { generateSessionIndex, generateIncrementalSessionIndex } from '@openchat
import { streamParseFile, detectFormat, getFormatFeatureById, type ParseProgress } from '@openchatlab/parser'
import { insertFtsEntries, hasFtsTable } from '../fts'
import { generateFallbackMessageKey, registerMessageAndCheckDuplicate } from './message-deduplicator'
import type { ImportProgressCallback } from './streaming-importer'
import { normalizeSystemMemberName, SYSTEM_MEMBER_NAME, type ImportProgressCallback } from './streaming-importer'
// ==================== Public interfaces ====================
@@ -313,10 +313,12 @@ export async function incrementalImport(
if (memberUpdateMode === 'none') return
for (const m of members) {
const existed = memberIdMap.has(m.platformId)
const accountName = normalizeSystemMemberName(formatFeature.id, m.platformId, m.accountName)
const groupNickname = normalizeSystemMemberName(formatFeature.id, m.platformId, m.groupNickname)
upsertMember.run(
m.platformId,
m.accountName || null,
m.groupNickname || null,
accountName || null,
groupNickname || null,
m.aliases ? JSON.stringify(m.aliases) : '[]',
m.avatar || null,
m.roles ? JSON.stringify(m.roles) : '[]'
@@ -356,12 +358,22 @@ export async function incrementalImport(
continue
}
const senderAccountName = normalizeSystemMemberName(
formatFeature.id,
msg.senderPlatformId,
msg.senderAccountName
)
const senderGroupNickname = normalizeSystemMemberName(
formatFeature.id,
msg.senderPlatformId,
msg.senderGroupNickname
)
let memberId = memberIdMap.get(msg.senderPlatformId)
if (!memberId) {
insertMemberMinimal.run(
msg.senderPlatformId,
msg.senderAccountName || null,
msg.senderGroupNickname || null,
senderAccountName || null,
senderGroupNickname || null,
null
)
const row = getMemberId.get(msg.senderPlatformId) as { id: number } | undefined
@@ -375,8 +387,8 @@ export async function incrementalImport(
const msgResult = insertMessage.run(
memberId,
msg.senderAccountName || null,
msg.senderGroupNickname || null,
senderAccountName || null,
senderGroupNickname || null,
timestamp,
msg.type,
msg.content || null,
@@ -447,7 +459,9 @@ export async function incrementalImport(
FROM message`
)
.get() as { totalCount: number; firstTimestamp: number; lastTimestamp: number }
const memberCountRow = db.prepare('SELECT COUNT(*) as count FROM member').get() as { count: number }
const memberCountRow = db
.prepare(`SELECT COUNT(*) as count FROM member WHERE COALESCE(account_name, '') != ?`)
.get(SYSTEM_MEMBER_NAME) as { count: number }
// Post-import hook (e.g. overview cache)
try {
@@ -6,6 +6,7 @@ import test from 'node:test'
import Database from 'better-sqlite3'
import { CHAT_DB_TABLES } from '@openchatlab/core'
import { BetterSqliteAdapter } from '../better-sqlite3-adapter'
import { computeAndSetOverviewCache } from '../cache/session-cache'
import { TEMP_DB_SCHEMA } from '../merger/temp-db'
import { analyzeNewImport, streamingImport, streamParseFileInfo } from './streaming-importer'
@@ -128,6 +129,36 @@ function writeDuplicateChatLabExport(root: string): string {
return filePath
}
function writeSystemChatLabExport(root: string): string {
const filePath = path.join(root, 'system-chat.json')
fs.writeFileSync(
filePath,
JSON.stringify({
chatlab: { version: '0.0.2', exportedAt: 1_711_468_800 },
meta: { name: 'System Test', platform: 'custom', type: 'group', ownerId: 'alice' },
members: [{ platformId: 'alice', accountName: 'Alice' }],
messages: [
{ sender: 'alice', accountName: 'Alice', timestamp: 1_711_468_800, type: 0, content: 'hello' },
{
sender: 'SYSTEM',
accountName: 'System',
timestamp: 1_711_468_801,
type: 80,
content: 'Bob joined the group',
},
],
}),
'utf8'
)
return filePath
}
function writeWhatsAppSystemParticipantExport(root: string): string {
const filePath = path.join(root, 'WhatsApp-SYSTEM.txt')
fs.writeFileSync(filePath, '2024/01/02 03:04 - SYSTEM: hello\n', 'utf8')
return filePath
}
function writeLargeChatLabJsonl(root: string): string {
const filePath = path.join(root, 'large-chat.jsonl')
const lines = [
@@ -276,6 +307,114 @@ test('streamingImport applies incremental-equivalent deduplication on first impo
assert.equal(row.count, 3)
})
test('streamingImport canonicalizes reserved SYSTEM senders and excludes them from overview counts', async (t) => {
const root = makeTempDir()
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const filePath = writeSystemChatLabExport(root)
const dbPath = path.join(root, 'system-test.db')
const result = await streamingImport(
filePath,
{
openDatabase() {
const db = new Database(dbPath, { nativeBinding })
db.exec(CHAT_DB_TABLES)
return new BetterSqliteAdapter(db)
},
deleteDatabase() {
for (const suffix of ['', '-wal', '-shm']) {
try {
fs.unlinkSync(dbPath + suffix)
} catch {
/* ignore */
}
}
},
onProgress() {
/* noop for this focused importer test */
},
},
undefined,
'system-test'
)
assert.equal(result.success, true)
const rawDb = new Database(dbPath, { nativeBinding })
const db = new BetterSqliteAdapter(rawDb)
const systemMember = db
.prepare('SELECT account_name, group_nickname FROM member WHERE platform_id = ?')
.get('SYSTEM') as { account_name: string | null; group_nickname: string | null } | undefined
const systemMessage = db
.prepare(
`SELECT msg.sender_account_name, msg.sender_group_nickname
FROM message msg
JOIN member m ON m.id = msg.sender_id
WHERE m.platform_id = ?`
)
.get('SYSTEM') as { sender_account_name: string | null; sender_group_nickname: string | null } | undefined
const overview = computeAndSetOverviewCache(db, 'system-test', path.join(root, 'cache'))
rawDb.close()
assert.deepEqual(systemMember, { account_name: '系统消息', group_nickname: '系统消息' })
assert.deepEqual(systemMessage, { sender_account_name: '系统消息', sender_group_nickname: '系统消息' })
assert.equal(overview.totalMembers, 1)
assert.equal(overview.totalMessages, 1)
})
test('streamingImport preserves an ordinary WhatsApp participant named SYSTEM', async (t) => {
const root = makeTempDir()
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const filePath = writeWhatsAppSystemParticipantExport(root)
const dbPath = path.join(root, 'whatsapp-system-participant.db')
const result = await streamingImport(
filePath,
{
openDatabase() {
const db = new Database(dbPath, { nativeBinding })
db.exec(CHAT_DB_TABLES)
return new BetterSqliteAdapter(db)
},
deleteDatabase() {
for (const suffix of ['', '-wal', '-shm']) {
try {
fs.unlinkSync(dbPath + suffix)
} catch {
/* ignore */
}
}
},
onProgress() {
/* noop for this focused importer test */
},
},
undefined,
'whatsapp-system-participant'
)
assert.equal(result.success, true)
const rawDb = new Database(dbPath, { readonly: true, nativeBinding })
const member = rawDb
.prepare('SELECT account_name, group_nickname FROM member WHERE platform_id = ?')
.get('SYSTEM') as { account_name: string | null; group_nickname: string | null } | undefined
const message = rawDb
.prepare(
`SELECT msg.sender_account_name, msg.sender_group_nickname, msg.type
FROM message msg
JOIN member m ON m.id = msg.sender_id
WHERE m.platform_id = ?`
)
.get('SYSTEM') as
| { sender_account_name: string | null; sender_group_nickname: string | null; type: number }
| undefined
rawDb.close()
assert.deepEqual(member, { account_name: 'SYSTEM', group_nickname: null })
assert.deepEqual(message, { sender_account_name: 'SYSTEM', sender_group_nickname: null, type: 0 })
})
test('analyzeNewImport honors an explicitly selected parser format', async (t) => {
const root = makeTempDir()
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
@@ -85,6 +85,9 @@ export interface StreamImportDeps {
const BATCH_COMMIT_SIZE = 50000
const CHECKPOINT_INTERVAL = 200000
const SYSTEM_SENDER_ID = 'SYSTEM'
export const SYSTEM_MEMBER_NAME = '系统消息'
const RESERVED_SYSTEM_SENDER_FORMATS = new Set(['chatlab', 'chatlab-jsonl'])
/**
* Let the event loop process pending I/O before a long synchronous step.
@@ -107,9 +110,20 @@ type CreateMessageSkipCounter =
type PreparedCreateMessage = { message: DedupMessage } | { skipCounter: CreateMessageSkipCounter }
function prepareMessageForCreate(message: ParsedMessage): PreparedCreateMessage {
export function normalizeSystemMemberName(
formatId: string,
platformId: string,
name: string | undefined
): string | undefined {
return RESERVED_SYSTEM_SENDER_FORMATS.has(formatId) && platformId === SYSTEM_SENDER_ID ? SYSTEM_MEMBER_NAME : name
}
function prepareMessageForCreate(
message: ParsedMessage,
senderAccountName = message.senderAccountName
): PreparedCreateMessage {
if (!message.senderPlatformId) return { skipCounter: 'skippedNoSenderId' }
if (!message.senderAccountName) return { skipCounter: 'skippedNoAccountName' }
if (!senderAccountName) return { skipCounter: 'skippedNoAccountName' }
if (message.timestamp === undefined || message.timestamp === null || isNaN(message.timestamp)) {
return { skipCounter: 'skippedInvalidTimestamp' }
}
@@ -388,10 +402,12 @@ async function streamImportSingle(
callbackStats.totalMembersReceived += members.length
logger?.info(`Received member batch: ${members.length} members`)
for (const member of members) {
const accountName = normalizeSystemMemberName(formatFeature.id, member.platformId, member.accountName)
const groupNickname = normalizeSystemMemberName(formatFeature.id, member.platformId, member.groupNickname)
insertMember.run(
member.platformId,
member.accountName || null,
member.groupNickname || null,
accountName || null,
groupNickname || null,
member.aliases ? JSON.stringify(member.aliases) : '[]',
member.avatar || null,
member.roles ? JSON.stringify(member.roles) : '[]'
@@ -417,7 +433,17 @@ async function streamImportSingle(
let nicknameChangeCount = 0
for (const msg of messages) {
const prepared = prepareMessageForCreate(msg)
const senderAccountName = normalizeSystemMemberName(
formatFeature.id,
msg.senderPlatformId,
msg.senderAccountName
)
const senderGroupNickname = normalizeSystemMemberName(
formatFeature.id,
msg.senderPlatformId,
msg.senderGroupNickname
)
const prepared = prepareMessageForCreate(msg, senderAccountName)
if ('skipCounter' in prepared) {
callbackStats[prepared.skipCounter]++
continue
@@ -434,8 +460,8 @@ async function streamImportSingle(
if (!memberIdMap.has(msg.senderPlatformId)) {
insertMember.run(
msg.senderPlatformId,
msg.senderAccountName || null,
msg.senderGroupNickname || null,
senderAccountName || null,
senderGroupNickname || null,
'[]',
null,
'[]'
@@ -455,8 +481,8 @@ async function streamImportSingle(
t0 = Date.now()
insertMessage.run(
senderId,
msg.senderAccountName || null,
msg.senderGroupNickname || null,
senderAccountName || null,
senderGroupNickname || null,
dedupMessage.timestamp,
dedupMessage.type,
dedupMessage.content,
@@ -468,8 +494,8 @@ async function streamImportSingle(
totalMessageCount++
t0 = Date.now()
trackNickname(accountNameTracker, msg.senderPlatformId, msg.senderAccountName, msg.timestamp)
trackNickname(groupNicknameTracker, msg.senderPlatformId, msg.senderGroupNickname, msg.timestamp)
trackNickname(accountNameTracker, msg.senderPlatformId, senderAccountName, msg.timestamp)
trackNickname(groupNicknameTracker, msg.senderPlatformId, senderGroupNickname, msg.timestamp)
nicknameTrackTime += Date.now() - t0
// nicknameChangeCount is approximate but sufficient for logging
nicknameChangeCount += accountNameTracker.get(msg.senderPlatformId)?.history.length === 1 ? 0 : 0
@@ -241,6 +241,13 @@ test('accepts the reserved SYSTEM sender without a member entry', async () => {
type: 80,
content: 'Alice joined the group',
},
{
sender: 'SYSTEM',
accountName: 'System',
timestamp: 1_711_468_801,
type: 81,
content: 'Alice recalled a message',
},
],
}),
'utf8'
@@ -255,6 +262,37 @@ test('accepts the reserved SYSTEM sender without a member entry', async () => {
}
})
test('rejects the reserved SYSTEM sender on a regular member message', async () => {
const root = makeTempDir()
const filePath = path.join(root, 'invalid-system-sender.json')
fs.writeFileSync(
filePath,
JSON.stringify({
chatlab: { version: CHATLAB_FORMAT_VERSION, exportedAt: 1_711_468_800 },
meta: { name: 'Group chat', platform: 'custom', type: 'group' },
members: [{ platformId: 'alice', accountName: 'Alice' }],
messages: [
{
sender: 'SYSTEM',
accountName: 'Alice',
timestamp: 1_711_468_800,
type: 0,
content: 'This message belongs to a real member',
},
],
}),
'utf8'
)
try {
const report = await validateChatLabFile(filePath)
assert.equal(report.valid, false)
assert.ok(report.issues.some((issue) => issue.code === 'INVALID_SYSTEM_SENDER_TYPE'))
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('requires explicit members in ChatLab JSON', async () => {
const root = makeTempDir()
const filePath = path.join(root, 'missing-members.json')
+17 -3
View File
@@ -14,6 +14,7 @@ const KNOWN_MESSAGE_TYPES = new Set<number>(
Object.values(MessageType).filter((value): value is number => typeof value === 'number')
)
const SYSTEM_SENDER_ID = 'SYSTEM'
const SYSTEM_SENDER_MESSAGE_TYPES = new Set<number>([MessageType.SYSTEM, MessageType.RECALL])
export type ChatLabValidationFormat = 'json' | 'jsonl' | 'unknown'
export type ChatLabValidationSeverity = 'error' | 'warning'
@@ -288,8 +289,9 @@ function validateMessage(state: ValidationState, value: unknown, path: string, l
return
}
if (validateRequiredString(state, value.sender, `${path}.sender`, location)) {
state.senderIds.add(value.sender)
const sender = validateRequiredString(state, value.sender, `${path}.sender`, location) ? value.sender : undefined
if (sender !== undefined) {
state.senderIds.add(sender)
}
validateRequiredString(state, value.accountName, `${path}.accountName`, location)
@@ -306,11 +308,23 @@ function validateMessage(state: ValidationState, value: unknown, path: string, l
state.lastTimestamp = value.timestamp
}
if (typeof value.type !== 'number' || !Number.isInteger(value.type) || !KNOWN_MESSAGE_TYPES.has(value.type)) {
const messageType =
typeof value.type === 'number' && Number.isInteger(value.type) && KNOWN_MESSAGE_TYPES.has(value.type)
? value.type
: undefined
if (messageType === undefined) {
addIssue(state, 'error', 'INVALID_MESSAGE_TYPE', 'Expected a supported numeric ChatLab message type.', {
...location,
path: `${path}.type`,
})
} else if (sender === SYSTEM_SENDER_ID && !SYSTEM_SENDER_MESSAGE_TYPES.has(messageType)) {
addIssue(
state,
'error',
'INVALID_SYSTEM_SENDER_TYPE',
'The reserved SYSTEM sender is only valid for system and recall messages.',
childLocation(location, `${path}.sender`)
)
}
if (!hasOwn(value, 'content') || (typeof value.content !== 'string' && value.content !== null)) {
@@ -125,6 +125,24 @@ function chatJsonl(name: string, sender: string, timestamp: number): string {
].join('\n')
}
function chatWithSystemMessage() {
return {
chatlab: { version: '0.0.2', exportedAt: 1 },
meta: { name: 'System Test', platform: 'custom', type: 'group', ownerId: 'alice' },
members: [{ platformId: 'alice', accountName: 'Alice' }],
messages: [
{ sender: 'alice', accountName: 'Alice', timestamp: 1, type: 0, content: 'hello' },
{
sender: 'SYSTEM',
accountName: 'System',
timestamp: 2,
type: 80,
content: 'Bob joined the group',
},
],
}
}
describe('BrowserSessionRuntime', () => {
it('parses imports before acquiring the workspace lease and keeps persistence inside it', async () => {
const sqlite3 = await sqlite3InitModule()
@@ -206,6 +224,85 @@ describe('BrowserSessionRuntime', () => {
database.dispose()
})
it('canonicalizes reserved SYSTEM senders and excludes them from catalog member counts', async () => {
const sqlite3 = await sqlite3InitModule()
const database = new MemoryWorkspaceDatabase(sqlite3)
const runtime = new BrowserSessionRuntime(database, {
createSessionId: () => 'system-session',
now: () => 100,
})
const result = await runtime.importSource(source('system.json', chatWithSystemMessage()), {
formatId: 'chatlab',
})
assert.deepEqual(result, {
sessionId: 'system-session',
formatId: 'chatlab',
messageCount: 2,
memberCount: 1,
skippedCount: 0,
})
assert.equal((await runtime.getSession('system-session'))?.memberCount, 1)
const sessionDb = database.getDatabase(sessionDatabaseFilename('system-session'))
assert.deepEqual(
sessionDb?.prepare('SELECT account_name, group_nickname FROM member WHERE platform_id = ?').get('SYSTEM'),
{ account_name: '系统消息', group_nickname: '系统消息' }
)
assert.deepEqual(
sessionDb
?.prepare(
`SELECT msg.sender_account_name, msg.sender_group_nickname
FROM message msg
JOIN member m ON m.id = msg.sender_id
WHERE m.platform_id = ?`
)
.get('SYSTEM'),
{ sender_account_name: '系统消息', sender_group_nickname: '系统消息' }
)
database.dispose()
})
it('preserves an ordinary WhatsApp participant named SYSTEM', async () => {
const sqlite3 = await sqlite3InitModule()
const database = new MemoryWorkspaceDatabase(sqlite3)
const runtime = new BrowserSessionRuntime(database, {
createSessionId: () => 'whatsapp-system-participant',
now: () => 100,
})
const fixture = source('WhatsApp-SYSTEM.txt', '2024/01/02 03:04 - SYSTEM: hello\n')
const result = await runtime.importSource(fixture)
assert.deepEqual(result, {
sessionId: 'whatsapp-system-participant',
formatId: 'whatsapp-native-txt',
messageCount: 1,
memberCount: 1,
skippedCount: 0,
})
assert.equal((await runtime.getSession('whatsapp-system-participant'))?.memberCount, 1)
const sessionDb = database.getDatabase(sessionDatabaseFilename('whatsapp-system-participant'))
assert.deepEqual(
sessionDb?.prepare('SELECT account_name, group_nickname FROM member WHERE platform_id = ?').get('SYSTEM'),
{ account_name: 'SYSTEM', group_nickname: null }
)
assert.deepEqual(
sessionDb
?.prepare(
`SELECT msg.sender_account_name, msg.sender_group_nickname, msg.type
FROM message msg
JOIN member m ON m.id = msg.sender_id
WHERE m.platform_id = ?`
)
.get('SYSTEM'),
{ sender_account_name: 'SYSTEM', sender_group_nickname: null, type: 0 }
)
database.dispose()
})
it('detects and imports a WhatsApp TXT source through the shared browser parser', async () => {
const sqlite3 = await sqlite3InitModule()
const database = new MemoryWorkspaceDatabase(sqlite3)
@@ -135,6 +135,9 @@ const SUPPORTED_FORMATS: BrowserImportFormatInfo[] = [
extensions: ['.json'],
},
]
const SYSTEM_SENDER_ID = 'SYSTEM'
const SYSTEM_MEMBER_NAME = '系统消息'
const RESERVED_SYSTEM_SENDER_FORMATS = new Set(['chatlab', 'chatlab-jsonl'])
export class BrowserSessionRuntime {
private readonly catalog: BrowserSessionCatalog
@@ -184,11 +187,13 @@ export class BrowserSessionRuntime {
onLog: options.onLog,
})
options.checkCancelled?.()
const usesReservedSystemSender = RESERVED_SYSTEM_SENDER_FORMATS.has(formatId)
const messages: BrowserImportParseResult['messages'] = []
for (const message of parsed.messages) {
const timestamp = normalizeImportTimestamp(message.timestamp)
if (timestamp === null) continue
messages.push(timestamp === message.timestamp ? message : { ...message, timestamp })
const normalizedMessage = usesReservedSystemSender ? normalizeSystemMessage(message) : message
messages.push(timestamp === normalizedMessage.timestamp ? normalizedMessage : { ...normalizedMessage, timestamp })
}
const skippedInvalidTimestampCount = parsed.messages.length - messages.length
if (messages.length === 0) {
@@ -201,7 +206,11 @@ export class BrowserSessionRuntime {
data: { formatId, skippedCount: skippedInvalidTimestampCount },
})
}
const members = mergeInferredMembers(parsed.members, messages)
const parsedMembers = usesReservedSystemSender ? parsed.members.map(normalizeSystemMember) : parsed.members
const members = mergeInferredMembers(parsedMembers, messages)
const memberCount = usesReservedSystemSender
? members.filter((member) => member.platformId !== SYSTEM_SENDER_ID).length
: members.length
const sessionId = this.createSessionId()
validateSessionId(sessionId)
@@ -214,7 +223,7 @@ export class BrowserSessionRuntime {
type: parsed.meta.type,
importedAt,
messageCount: messages.length,
memberCount: members.length,
memberCount,
groupId: parsed.meta.groupId ?? null,
groupAvatar: parsed.meta.groupAvatar ?? null,
ownerId: parsed.meta.ownerId ?? null,
@@ -240,6 +249,7 @@ export class BrowserSessionRuntime {
db.exec(CHAT_DB_INDEXES)
return {
...result,
memberCount,
skippedCount: result.skippedCount + skippedInvalidTimestampCount,
}
})
@@ -460,6 +470,24 @@ function normalizeImportTimestamp(value: unknown): number | null {
return Number.isFinite(timestamp) ? timestamp : null
}
function normalizeSystemMember(
member: BrowserImportParseResult['members'][number]
): BrowserImportParseResult['members'][number] {
if (member.platformId !== SYSTEM_SENDER_ID) return member
return { ...member, accountName: SYSTEM_MEMBER_NAME, groupNickname: SYSTEM_MEMBER_NAME }
}
function normalizeSystemMessage(
message: BrowserImportParseResult['messages'][number]
): BrowserImportParseResult['messages'][number] {
if (message.senderPlatformId !== SYSTEM_SENDER_ID) return message
return {
...message,
senderAccountName: SYSTEM_MEMBER_NAME,
senderGroupNickname: SYSTEM_MEMBER_NAME,
}
}
function mergeInferredMembers(
members: BrowserImportParseResult['members'],
messages: BrowserImportParseResult['messages']
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// Generated from the canonical ChatLab validator. Do not edit directly.
/* eslint-disable */
import{existsSync as U}from"node:fs";import z from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as k}from"node:fs/promises";import{extname as V}from"node:path";import{createInterface as D}from"node:readline";var E="0.0.2",S=["0.0.1",E];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 v=100,M=1e11,G=new Set(Object.values(h).filter(e=>typeof e=="number"));function A(e,r){return{format:e,fileSize:r,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 o(e,r,n,t,a={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length<v&&e.issues.push({severity:r,code:n,message:t,...a})}function c(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function d(e,r){return Object.prototype.hasOwnProperty.call(e,r)}function g(e,r){return{...e,path:r}}function u(e,r,n,t){return typeof r=="string"&&r.trim().length>0?!0:(o(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",g(t,n)),!1)}function m(e,r,n,t,a){d(r,n)&&typeof r[n]!="string"&&o(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",g(a,t))}function y(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&r<M?!0:(o(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",g(t,n)),!1)}function x(e,r,n,t,a=""){if(e.stats.headerCount++,!c(r))o(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...t,path:`${a}chatlab`});else{let i=`${a}chatlab.version`;typeof r.version!="string"?o(e,"error","INVALID_VERSION","Expected a string format version.",{...t,path:i}):(e.version=r.version,C(r.version)||o(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${S.join(", ")}.`,{...t,path:i})),y(e,r.exportedAt,`${a}chatlab.exportedAt`,t),m(e,r,"generator",`${a}chatlab.generator`,t),m(e,r,"description",`${a}chatlab.description`,t)}if(!c(n)){o(e,"error","INVALID_META","Expected a meta object.",{...t,path:`${a}meta`});return}u(e,n.name,`${a}meta.name`,t),u(e,n.platform,`${a}meta.platform`,t),n.type!=="group"&&n.type!=="private"&&o(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...t,path:`${a}meta.type`}),m(e,n,"groupId",`${a}meta.groupId`,t),m(e,n,"groupAvatar",`${a}meta.groupAvatar`,t),d(n,"ownerId")&&u(e,n.ownerId,`${a}meta.ownerId`,t)&&e.ownerIds.add(n.ownerId),m(e,n,"sourceSessionId",`${a}meta.sourceSessionId`,t)}function N(e,r,n,t){if(e.stats.memberCount++,!c(r)){o(e,"error","INVALID_MEMBER","Expected a member object.",g(t,n));return}u(e,r.platformId,`${n}.platformId`,t)&&(e.memberIds.has(r.platformId)?o(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...t,path:`${n}.platformId`}):e.memberIds.add(r.platformId)),u(e,r.accountName,`${n}.accountName`,t),m(e,r,"groupNickname",`${n}.groupNickname`,t),m(e,r,"avatar",`${n}.avatar`,t),d(r,"aliases")&&(!Array.isArray(r.aliases)||r.aliases.some(a=>typeof a!="string"))&&o(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),d(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((a,i)=>{let p=`${n}.roles[${i}]`;if(!c(a)){o(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:p});return}u(e,a.id,`${p}.id`,t),m(e,a,"name",`${p}.name`,t)}):o(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!c(r)){o(e,"error","INVALID_MESSAGE","Expected a message object.",g(t,n));return}u(e,r.sender,`${n}.sender`,t)&&e.senderIds.add(r.sender),u(e,r.accountName,`${n}.accountName`,t),y(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp<e.lastTimestamp&&o(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",g(t,`${n}.timestamp`)),e.lastTimestamp=r.timestamp),(typeof r.type!="number"||!Number.isInteger(r.type)||!G.has(r.type))&&o(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...t,path:`${n}.type`}),(!d(r,"content")||typeof r.content!="string"&&r.content!==null)&&o(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...t,path:`${n}.content`}),m(e,r,"groupNickname",`${n}.groupNickname`,t),d(r,"platformMessageId")&&u(e,r.platformMessageId,`${n}.platformMessageId`,t)&&(e.messageIds.has(r.platformMessageId)?o(e,"error","DUPLICATE_MESSAGE_ID","platformMessageId must be unique.",{...t,Line truncated
`)}if(!L)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=z.resolve(L);if(!U(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 r=await T(e);f(r.valid?{ok:!0,command:"validate",data:r,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:r,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${r.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),r.valid||(process.exitCode=1)}catch(r){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:r instanceof Error?r.message:String(r)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}}
import{existsSync as j}from"node:fs";import H from"node:path";import{createReadStream as P}from"node:fs";import{readFile as k,stat as V}from"node:fs/promises";import{extname as D}from"node:path";import{createInterface as M}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 v=100,G=1e11,F=new Set(Object.values(h).filter(e=>typeof e=="number")),y="SYSTEM",$=new Set([80,81]);function A(e,r){return{format:e,fileSize:r,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 o(e,r,n,t,i={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length<v&&e.issues.push({severity:r,code:n,message:t,...i})}function p(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function d(e,r){return Object.prototype.hasOwnProperty.call(e,r)}function c(e,r){return{...e,path:r}}function u(e,r,n,t){return typeof r=="string"&&r.trim().length>0?!0:(o(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",c(t,n)),!1)}function m(e,r,n,t,i){d(r,n)&&typeof r[n]!="string"&&o(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",c(i,t))}function x(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&r<G?!0:(o(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",c(t,n)),!1)}function N(e,r,n,t,i=""){if(e.stats.headerCount++,!p(r))o(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...t,path:`${i}chatlab`});else{let a=`${i}chatlab.version`;typeof r.version!="string"?o(e,"error","INVALID_VERSION","Expected a string format version.",{...t,path:a}):(e.version=r.version,C(r.version)||o(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${S.join(", ")}.`,{...t,path:a})),x(e,r.exportedAt,`${i}chatlab.exportedAt`,t),m(e,r,"generator",`${i}chatlab.generator`,t),m(e,r,"description",`${i}chatlab.description`,t)}if(!p(n)){o(e,"error","INVALID_META","Expected a meta object.",{...t,path:`${i}meta`});return}u(e,n.name,`${i}meta.name`,t),u(e,n.platform,`${i}meta.platform`,t),n.type!=="group"&&n.type!=="private"&&o(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...t,path:`${i}meta.type`}),m(e,n,"groupId",`${i}meta.groupId`,t),m(e,n,"groupAvatar",`${i}meta.groupAvatar`,t),d(n,"ownerId")&&u(e,n.ownerId,`${i}meta.ownerId`,t)&&e.ownerIds.add(n.ownerId),m(e,n,"sourceSessionId",`${i}meta.sourceSessionId`,t)}function T(e,r,n,t){if(e.stats.memberCount++,!p(r)){o(e,"error","INVALID_MEMBER","Expected a member object.",c(t,n));return}u(e,r.platformId,`${n}.platformId`,t)&&(e.memberIds.has(r.platformId)?o(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...t,path:`${n}.platformId`}):e.memberIds.add(r.platformId)),u(e,r.accountName,`${n}.accountName`,t),m(e,r,"groupNickname",`${n}.groupNickname`,t),m(e,r,"avatar",`${n}.avatar`,t),d(r,"aliases")&&(!Array.isArray(r.aliases)||r.aliases.some(i=>typeof i!="string"))&&o(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),d(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((i,a)=>{let g=`${n}.roles[${a}]`;if(!p(i)){o(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:g});return}u(e,i.id,`${g}.id`,t),m(e,i,"name",`${g}.name`,t)}):o(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!p(r)){o(e,"error","INVALID_MESSAGE","Expected a message object.",c(t,n));return}let i=u(e,r.sender,`${n}.sender`,t)?r.sender:void 0;i!==void 0&&e.senderIds.add(i),u(e,r.accountName,`${n}.accountName`,t),x(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp<e.lastTimestamp&&o(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",c(t,`${n}.timestamp`)),e.lastTimestamp=r.timestamp);let a=typeof r.type=="number"&&Number.isInteger(r.type)&&F.has(r.type)?r.type:void 0;a===void 0?o(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...t,path:`${n}.type`}):i===y&&!$.has(a)&&o(e,"error","INVALID_SYSTEM_SENDER_TYPE","The reserved SYSTEM sender is only valid for system and recall messages.",c(t,`${n}.sender`)),(!d(r,"content")||typeof r.content!="string"&&r.content!==null)&&o(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...t,path:`${n}.content`}),m(e,rLine truncated
`)}if(!w)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=H.resolve(w);if(!j(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 r=await L(e);f(r.valid?{ok:!0,command:"validate",data:r,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:r,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${r.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),r.valid||(process.exitCode=1)}catch(r){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:r instanceof Error?r.message:String(r)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}}
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// Generated from the canonical ChatLab validator. Do not edit directly.
/* eslint-disable */
import{existsSync as U}from"node:fs";import z from"node:path";import{createReadStream as O}from"node:fs";import{readFile as P,stat as k}from"node:fs/promises";import{extname as V}from"node:path";import{createInterface as D}from"node:readline";var E="0.0.2",S=["0.0.1",E];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 v=100,M=1e11,G=new Set(Object.values(h).filter(e=>typeof e=="number"));function A(e,r){return{format:e,fileSize:r,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 o(e,r,n,t,a={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length<v&&e.issues.push({severity:r,code:n,message:t,...a})}function c(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function d(e,r){return Object.prototype.hasOwnProperty.call(e,r)}function g(e,r){return{...e,path:r}}function u(e,r,n,t){return typeof r=="string"&&r.trim().length>0?!0:(o(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",g(t,n)),!1)}function m(e,r,n,t,a){d(r,n)&&typeof r[n]!="string"&&o(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",g(a,t))}function y(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&r<M?!0:(o(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",g(t,n)),!1)}function x(e,r,n,t,a=""){if(e.stats.headerCount++,!c(r))o(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...t,path:`${a}chatlab`});else{let i=`${a}chatlab.version`;typeof r.version!="string"?o(e,"error","INVALID_VERSION","Expected a string format version.",{...t,path:i}):(e.version=r.version,C(r.version)||o(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${S.join(", ")}.`,{...t,path:i})),y(e,r.exportedAt,`${a}chatlab.exportedAt`,t),m(e,r,"generator",`${a}chatlab.generator`,t),m(e,r,"description",`${a}chatlab.description`,t)}if(!c(n)){o(e,"error","INVALID_META","Expected a meta object.",{...t,path:`${a}meta`});return}u(e,n.name,`${a}meta.name`,t),u(e,n.platform,`${a}meta.platform`,t),n.type!=="group"&&n.type!=="private"&&o(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...t,path:`${a}meta.type`}),m(e,n,"groupId",`${a}meta.groupId`,t),m(e,n,"groupAvatar",`${a}meta.groupAvatar`,t),d(n,"ownerId")&&u(e,n.ownerId,`${a}meta.ownerId`,t)&&e.ownerIds.add(n.ownerId),m(e,n,"sourceSessionId",`${a}meta.sourceSessionId`,t)}function N(e,r,n,t){if(e.stats.memberCount++,!c(r)){o(e,"error","INVALID_MEMBER","Expected a member object.",g(t,n));return}u(e,r.platformId,`${n}.platformId`,t)&&(e.memberIds.has(r.platformId)?o(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...t,path:`${n}.platformId`}):e.memberIds.add(r.platformId)),u(e,r.accountName,`${n}.accountName`,t),m(e,r,"groupNickname",`${n}.groupNickname`,t),m(e,r,"avatar",`${n}.avatar`,t),d(r,"aliases")&&(!Array.isArray(r.aliases)||r.aliases.some(a=>typeof a!="string"))&&o(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),d(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((a,i)=>{let p=`${n}.roles[${i}]`;if(!c(a)){o(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:p});return}u(e,a.id,`${p}.id`,t),m(e,a,"name",`${p}.name`,t)}):o(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!c(r)){o(e,"error","INVALID_MESSAGE","Expected a message object.",g(t,n));return}u(e,r.sender,`${n}.sender`,t)&&e.senderIds.add(r.sender),u(e,r.accountName,`${n}.accountName`,t),y(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp<e.lastTimestamp&&o(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",g(t,`${n}.timestamp`)),e.lastTimestamp=r.timestamp),(typeof r.type!="number"||!Number.isInteger(r.type)||!G.has(r.type))&&o(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...t,path:`${n}.type`}),(!d(r,"content")||typeof r.content!="string"&&r.content!==null)&&o(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...t,path:`${n}.content`}),m(e,r,"groupNickname",`${n}.groupNickname`,t),d(r,"platformMessageId")&&u(e,r.platformMessageId,`${n}.platformMessageId`,t)&&(e.messageIds.has(r.platformMessageId)?o(e,"error","DUPLICATE_MESSAGE_ID","platformMessageId must be unique.",{...t,Line truncated
`)}if(!L)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=z.resolve(L);if(!U(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 r=await T(e);f(r.valid?{ok:!0,command:"validate",data:r,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:r,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${r.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),r.valid||(process.exitCode=1)}catch(r){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:r instanceof Error?r.message:String(r)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}}
import{existsSync as j}from"node:fs";import H from"node:path";import{createReadStream as P}from"node:fs";import{readFile as k,stat as V}from"node:fs/promises";import{extname as D}from"node:path";import{createInterface as M}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 v=100,G=1e11,F=new Set(Object.values(h).filter(e=>typeof e=="number")),y="SYSTEM",$=new Set([80,81]);function A(e,r){return{format:e,fileSize:r,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 o(e,r,n,t,i={}){r==="error"?e.errorCount++:e.warningCount++,e.issues.length<v&&e.issues.push({severity:r,code:n,message:t,...i})}function p(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function d(e,r){return Object.prototype.hasOwnProperty.call(e,r)}function c(e,r){return{...e,path:r}}function u(e,r,n,t){return typeof r=="string"&&r.trim().length>0?!0:(o(e,"error","INVALID_REQUIRED_STRING","Expected a non-empty string.",c(t,n)),!1)}function m(e,r,n,t,i){d(r,n)&&typeof r[n]!="string"&&o(e,"error","INVALID_OPTIONAL_STRING","Expected a string.",c(i,t))}function x(e,r,n,t){return typeof r=="number"&&Number.isInteger(r)&&r>=0&&r<G?!0:(o(e,"error","INVALID_UNIX_SECONDS","Expected a non-negative integer Unix timestamp in seconds, not milliseconds.",c(t,n)),!1)}function N(e,r,n,t,i=""){if(e.stats.headerCount++,!p(r))o(e,"error","INVALID_CHATLAB_HEADER","Expected a chatlab object.",{...t,path:`${i}chatlab`});else{let a=`${i}chatlab.version`;typeof r.version!="string"?o(e,"error","INVALID_VERSION","Expected a string format version.",{...t,path:a}):(e.version=r.version,C(r.version)||o(e,"error","UNSUPPORTED_VERSION",`Supported ChatLab format versions: ${S.join(", ")}.`,{...t,path:a})),x(e,r.exportedAt,`${i}chatlab.exportedAt`,t),m(e,r,"generator",`${i}chatlab.generator`,t),m(e,r,"description",`${i}chatlab.description`,t)}if(!p(n)){o(e,"error","INVALID_META","Expected a meta object.",{...t,path:`${i}meta`});return}u(e,n.name,`${i}meta.name`,t),u(e,n.platform,`${i}meta.platform`,t),n.type!=="group"&&n.type!=="private"&&o(e,"error","INVALID_CHAT_TYPE",'Expected chat type "group" or "private".',{...t,path:`${i}meta.type`}),m(e,n,"groupId",`${i}meta.groupId`,t),m(e,n,"groupAvatar",`${i}meta.groupAvatar`,t),d(n,"ownerId")&&u(e,n.ownerId,`${i}meta.ownerId`,t)&&e.ownerIds.add(n.ownerId),m(e,n,"sourceSessionId",`${i}meta.sourceSessionId`,t)}function T(e,r,n,t){if(e.stats.memberCount++,!p(r)){o(e,"error","INVALID_MEMBER","Expected a member object.",c(t,n));return}u(e,r.platformId,`${n}.platformId`,t)&&(e.memberIds.has(r.platformId)?o(e,"error","DUPLICATE_MEMBER_ID","Member platformId must be unique.",{...t,path:`${n}.platformId`}):e.memberIds.add(r.platformId)),u(e,r.accountName,`${n}.accountName`,t),m(e,r,"groupNickname",`${n}.groupNickname`,t),m(e,r,"avatar",`${n}.avatar`,t),d(r,"aliases")&&(!Array.isArray(r.aliases)||r.aliases.some(i=>typeof i!="string"))&&o(e,"error","INVALID_ALIASES","Expected aliases to be an array of strings.",{...t,path:`${n}.aliases`}),d(r,"roles")&&(Array.isArray(r.roles)?r.roles.forEach((i,a)=>{let g=`${n}.roles[${a}]`;if(!p(i)){o(e,"error","INVALID_ROLE","Expected a role object.",{...t,path:g});return}u(e,i.id,`${g}.id`,t),m(e,i,"name",`${g}.name`,t)}):o(e,"error","INVALID_ROLES","Expected roles to be an array.",{...t,path:`${n}.roles`}))}function _(e,r,n,t){if(e.stats.messageCount++,!p(r)){o(e,"error","INVALID_MESSAGE","Expected a message object.",c(t,n));return}let i=u(e,r.sender,`${n}.sender`,t)?r.sender:void 0;i!==void 0&&e.senderIds.add(i),u(e,r.accountName,`${n}.accountName`,t),x(e,r.timestamp,`${n}.timestamp`,t)&&(e.lastTimestamp!==void 0&&r.timestamp<e.lastTimestamp&&o(e,"warning","OUT_OF_ORDER_TIMESTAMP","Messages should be ordered by timestamp ascending.",c(t,`${n}.timestamp`)),e.lastTimestamp=r.timestamp);let a=typeof r.type=="number"&&Number.isInteger(r.type)&&F.has(r.type)?r.type:void 0;a===void 0?o(e,"error","INVALID_MESSAGE_TYPE","Expected a supported numeric ChatLab message type.",{...t,path:`${n}.type`}):i===y&&!$.has(a)&&o(e,"error","INVALID_SYSTEM_SENDER_TYPE","The reserved SYSTEM sender is only valid for system and recall messages.",c(t,`${n}.sender`)),(!d(r,"content")||typeof r.content!="string"&&r.content!==null)&&o(e,"error","INVALID_MESSAGE_CONTENT","Expected content to be a string or null.",{...t,path:`${n}.content`}),m(e,rLine truncated
`)}if(!w)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=H.resolve(w);if(!j(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 r=await L(e);f(r.valid?{ok:!0,command:"validate",data:r,meta:{apiVersion:1,standalone:!0}}:{ok:!1,command:"validate",data:r,error:{code:"INVALID_CHATLAB_FORMAT",message:`Validation found ${r.errorCount} error(s).`,hint:"Fix every error before importing the file into ChatLab."},meta:{apiVersion:1,standalone:!0}}),r.valid||(process.exitCode=1)}catch(r){f({ok:!1,command:"validate",error:{code:"VALIDATION_FAILED",message:r instanceof Error?r.message:String(r)},meta:{apiVersion:1,standalone:!0}}),process.exitCode=1}}
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
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',
]
let tempDir = ''
function createDocument(sender: string, type: number): string {
return JSON.stringify({
chatlab: { version: '0.0.2', exportedAt: 1_711_468_800 },
meta: { name: 'Group chat', platform: 'custom', type: 'group' },
members: [{ platformId: 'alice', accountName: 'Alice' }],
messages: [
{
sender,
accountName: sender === 'SYSTEM' ? 'System' : 'Alice',
timestamp: 1_711_468_800,
type,
content: 'Test message',
},
],
})
}
function runValidator(validatorPath: string, documentPath: string) {
const result = spawnSync(process.execPath, [path.join(ROOT_DIR, validatorPath), documentPath], {
cwd: ROOT_DIR,
encoding: 'utf8',
})
assert.equal(result.error, undefined)
return {
status: result.status,
output: JSON.parse(result.stdout.trim()) as {
ok: boolean
data?: { issues: Array<{ code: string }> }
},
}
}
before(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-skill-validator-test-'))
})
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')
const result = runValidator(validatorPath, documentPath)
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')
const result = runValidator(validatorPath, 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'))
})
}
})