mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
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:
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')
|
||||
|
||||
@@ -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}}
|
||||
@@ -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'))
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user