diff --git a/apps/desktop/preload/apis/chat.ts b/apps/desktop/preload/apis/chat.ts index 24a78b1a..7d617c7b 100644 --- a/apps/desktop/preload/apis/chat.ts +++ b/apps/desktop/preload/apis/chat.ts @@ -12,7 +12,7 @@ interface ChatImportResult { sessionId?: string error?: string importMode?: 'created' | 'incremental' - matchedBy?: 'stable-id' | 'trailing-messages' + matchedBy?: 'source-session-id' | 'stable-id' | 'trailing-messages' newMessageCount?: number duplicateCount?: number diagnostics?: { diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index 6f961641..b67b9c7d 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -43,7 +43,7 @@ interface ChatImportResult { sessionId?: string error?: string importMode?: 'created' | 'incremental' - matchedBy?: 'stable-id' | 'trailing-messages' + matchedBy?: 'source-session-id' | 'stable-id' | 'trailing-messages' newMessageCount?: number duplicateCount?: number diagnostics?: ImportDiagnostics diff --git a/packages/node-runtime/src/export/format-exporter.ts b/packages/node-runtime/src/export/format-exporter.ts index cc85e335..81a5d40d 100644 --- a/packages/node-runtime/src/export/format-exporter.ts +++ b/packages/node-runtime/src/export/format-exporter.ts @@ -106,7 +106,12 @@ function parseOptionalArray(value: string | null): T[] | undefined { } } -function exportAsJson(db: DatabaseAdapter, messages: MessageRow[], sessionName: string): string { +function exportAsJson( + db: DatabaseAdapter, + messages: MessageRow[], + sessionName: string, + sourceSessionId: string +): string { const meta = db .prepare( `SELECT name, @@ -147,6 +152,7 @@ function exportAsJson(db: DatabaseAdapter, messages: MessageRow[], sessionName: groupId: meta.groupId || undefined, groupAvatar: meta.groupAvatar || undefined, ownerId: meta.ownerId || undefined, + sourceSessionId, }, members: members.map((member) => ({ platformId: member.platformId, @@ -219,7 +225,7 @@ export function exportWithFormat( content = exportAsTxt(messages, params.sessionName) break case 'json': - content = exportAsJson(db, messages, params.sessionName) + content = exportAsJson(db, messages, params.sessionName, params.sessionId) break case 'markdown': content = exportAsMarkdown(messages, params.sessionName) diff --git a/packages/node-runtime/src/import/auto-import-matcher.test.ts b/packages/node-runtime/src/import/auto-import-matcher.test.ts index fc88ce6f..c6fcaea9 100644 --- a/packages/node-runtime/src/import/auto-import-matcher.test.ts +++ b/packages/node-runtime/src/import/auto-import-matcher.test.ts @@ -32,13 +32,14 @@ function writeChatLabJsonl( filePath: string, meta: SourceMeta, members: SourceMember[], - messages: ParsedMessage[] + messages: ParsedMessage[], + sourceSessionId?: string ): void { const lines = [ { _type: 'header', chatlab: { version: '0.0.2', exportedAt: 1783840000 }, - meta, + meta: { ...meta, sourceSessionId }, }, ...members.map((member) => ({ _type: 'member', ...member })), ...messages.map((message) => ({ @@ -171,6 +172,77 @@ test('matches a unique private session by stable owner and participant ids', asy ) }) +test('uses a validated ChatLab source session id to disambiguate stable matches', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const meta: SourceMeta = { + name: 'Private chat', + platform: 'wechat', + type: 'private', + ownerId: 'owner', + } + const members = [ + { platformId: 'owner', accountName: 'Owner' }, + { platformId: 'peer', accountName: 'Peer' }, + ] + const messages = [textMessage('peer', 1783860000, 'hello')] + + seedSession(path.join(tempDir, 'source-session.db'), meta, members, messages) + seedSession(path.join(tempDir, 'duplicate-session.db'), meta, members, messages) + writeChatLabJsonl(path.join(tempDir, 'source.jsonl'), meta, members, messages, 'source-session') + + assert.deepEqual( + await resolveAutoImportTarget( + path.join(tempDir, 'source.jsonl'), + createDeps(tempDir, ['source-session', 'duplicate-session']) + ), + { + action: 'incremental', + sessionId: 'source-session', + matchedBy: 'source-session-id', + } + ) +}) + +test('does not trust a ChatLab source session id that fails identity validation', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const sourceMeta: SourceMeta = { + name: 'Private chat', + platform: 'wechat', + type: 'private', + ownerId: 'owner', + } + const sourceMembers = [ + { platformId: 'owner', accountName: 'Owner' }, + { platformId: 'peer', accountName: 'Peer' }, + ] + const messages = [textMessage('peer', 1783860000, 'hello')] + + seedSession(path.join(tempDir, 'matching-a.db'), sourceMeta, sourceMembers, messages) + seedSession(path.join(tempDir, 'matching-b.db'), sourceMeta, sourceMembers, messages) + seedSession( + path.join(tempDir, 'unrelated.db'), + { ...sourceMeta, ownerId: 'different-owner' }, + [ + { platformId: 'different-owner', accountName: 'Other owner' }, + { platformId: 'different-peer', accountName: 'Other peer' }, + ], + [textMessage('different-peer', 1783860000, 'hello')] + ) + writeChatLabJsonl(path.join(tempDir, 'source.jsonl'), sourceMeta, sourceMembers, messages, 'unrelated') + + assert.deepEqual( + await resolveAutoImportTarget( + path.join(tempDir, 'source.jsonl'), + createDeps(tempDir, ['matching-a', 'matching-b', 'unrelated']) + ), + { action: 'create', reason: 'ambiguous' } + ) +}) + test('matches a unique session when its last five business messages appear consecutively in the source', async (t) => { const tempDir = makeTempDir() t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) diff --git a/packages/node-runtime/src/import/auto-import-matcher.ts b/packages/node-runtime/src/import/auto-import-matcher.ts index e99c1ad7..b92aef0c 100644 --- a/packages/node-runtime/src/import/auto-import-matcher.ts +++ b/packages/node-runtime/src/import/auto-import-matcher.ts @@ -6,7 +6,7 @@ import type { ImportProgressCallback } from './streaming-importer' const MATCH_WINDOW_SIZE = 5 -export type AutoImportMatchMethod = 'stable-id' | 'trailing-messages' +export type AutoImportMatchMethod = 'source-session-id' | 'stable-id' | 'trailing-messages' export type AutoImportDecision = | { action: 'incremental'; sessionId: string; matchedBy: AutoImportMatchMethod } @@ -137,11 +137,17 @@ export async function resolveAutoImportTarget( } if (hasStableIdentity) { + if (meta.sourceSessionId && stableMatches.includes(meta.sourceSessionId)) { + return { action: 'incremental', sessionId: meta.sourceSessionId, matchedBy: 'source-session-id' } + } if (stableMatches.length === 1) { return { action: 'incremental', sessionId: stableMatches[0], matchedBy: 'stable-id' } } return { action: 'create', reason: stableMatches.length > 1 ? 'ambiguous' : 'no-match' } } + if (meta.sourceSessionId && trailingMatches.includes(meta.sourceSessionId)) { + return { action: 'incremental', sessionId: meta.sourceSessionId, matchedBy: 'source-session-id' } + } if (trailingMatches.length === 1) { return { action: 'incremental', sessionId: trailingMatches[0], matchedBy: 'trailing-messages' } } diff --git a/packages/parser-native/src/chatlab.rs b/packages/parser-native/src/chatlab.rs index bdeedb3c..50dfb544 100644 --- a/packages/parser-native/src/chatlab.rs +++ b/packages/parser-native/src/chatlab.rs @@ -29,6 +29,7 @@ struct ChatlabMeta { group_id: Option, group_avatar: Option, owner_id: Option, + source_session_id: Option, /// true: members came from the top-level `members` array (full shape); /// false: collected from messages (id/name/nickname only), as in TS. members_from_head: bool, @@ -87,6 +88,7 @@ struct MetaOut { group_id: Option, group_avatar: Option, owner_id: Option, + source_session_id: Option, } fn parse_meta(meta_raw: Option<&[u8]>, file_path: &str) -> ScanResult { @@ -139,6 +141,7 @@ fn parse_meta(meta_raw: Option<&[u8]>, file_path: &str) -> ScanResult { let group_id = optional_str(&meta, "groupId", "meta")?; let group_avatar = optional_str(&meta, "groupAvatar", "meta")?; let owner_id = optional_str(&meta, "ownerId", "meta")?; + let source_session_id = optional_str(&meta, "sourceSessionId", "meta")?; // Filename fallback fires whenever the name resolved to the default, // mirroring `if (meta.name === '未知群聊')` in the TS parser. @@ -153,6 +156,7 @@ fn parse_meta(meta_raw: Option<&[u8]>, file_path: &str) -> ScanResult { group_id, group_avatar, owner_id, + source_session_id, }) } @@ -379,6 +383,7 @@ pub fn parse_chatlab( group_id: meta.group_id, group_avatar: meta.group_avatar, owner_id: meta.owner_id, + source_session_id: meta.source_session_id, members_from_head, }) .map_err(|err| strict(format!("meta serialization failed: {err}")))?; @@ -410,7 +415,7 @@ mod tests { fn parses_full_document() { let doc = r#"{ "chatlab": {"version": "0.0.2", "exportedAt": 1700000000}, - "meta": {"name": "测试群", "platform": "weixin", "type": "group", "groupId": "g1@chatroom", "groupAvatar": "https://x/y.jpg", "ownerId": "u1"}, + "meta": {"name": "测试群", "platform": "weixin", "type": "group", "groupId": "g1@chatroom", "groupAvatar": "https://x/y.jpg", "ownerId": "u1", "sourceSessionId": "source-session"}, "members": [ {"platformId": "u1", "accountName": "Alice", "groupNickname": "小A", "aliases": ["Ally"], "avatar": "https://a", "roles": [{"id": "owner"}, {"id": "admin", "name": "管理"}]}, {"platformId": "u2", "accountName": "Bob"} @@ -428,6 +433,7 @@ mod tests { assert_eq!(m["chatType"], "group"); assert_eq!(m["groupId"], "g1@chatroom"); assert_eq!(m["ownerId"], "u1"); + assert_eq!(m["sourceSessionId"], "source-session"); assert_eq!(m["membersFromHead"], true); assert_eq!(out.members.len(), 2); assert_eq!( diff --git a/packages/parser/src/formats/chatlab-jsonl.ts b/packages/parser/src/formats/chatlab-jsonl.ts index 3723f2da..e7130914 100644 --- a/packages/parser/src/formats/chatlab-jsonl.ts +++ b/packages/parser/src/formats/chatlab-jsonl.ts @@ -49,6 +49,7 @@ interface JsonlHeader { groupId?: string groupAvatar?: string ownerId?: string + sourceSessionId?: string } } @@ -171,6 +172,7 @@ async function* parseChatLabJsonl(options: ParseOptions): AsyncGenerator message.timestamp), @@ -87,6 +88,7 @@ test('exports JSON as ChatLab format that can be parsed for re-import', async () groupId: 'group-1', groupAvatar: 'group-avatar', ownerId: 'alice', + sourceSessionId: 'session-1', }) assert.deepEqual( parsed.members.map((member) => ({