diff --git a/packages/http-routes/src/routes/web/merge.test.ts b/packages/http-routes/src/routes/web/merge.test.ts index d79c54d5..db1699b3 100644 --- a/packages/http-routes/src/routes/web/merge.test.ts +++ b/packages/http-routes/src/routes/web/merge.test.ts @@ -82,6 +82,58 @@ test('forwards the session gap threshold when importing merged output', async (t assert.deepEqual(receivedOptions, { sessionGapThreshold: 7200 }) }) +test('preserves the owner id when exporting existing private sessions for merge', async (t) => { + const rootDir = makeTempDir() + t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })) + + const pathProvider = createPathProvider(rootDir) + const dbManager = new DatabaseManager(pathProvider, { nativeBinding, allowMissingRuntimeForTests: true }) + const mergeSessionCache = new MergeSessionCache(pathProvider, { nativeBinding }) + const db = dbManager.openRawSessionDatabase('private-session', { + create: true, + initializeChatTables: true, + }) + db.prepare('INSERT INTO meta (name, platform, type, imported_at, owner_id) VALUES (?, ?, ?, ?, ?)').run( + 'Private chat', + 'qq', + 'private', + 1_700_000_000, + 'owner' + ) + const owner = db.prepare('INSERT INTO member (platform_id, account_name) VALUES (?, ?)').run('owner', 'Owner') + db.prepare('INSERT INTO member (platform_id, account_name) VALUES (?, ?)').run('peer', 'Peer') + db.prepare('INSERT INTO message (sender_id, sender_account_name, ts, type, content) VALUES (?, ?, ?, ?, ?)').run( + owner.lastInsertRowid, + 'Owner', + 1_700_000_001, + 0, + 'hello' + ) + db.close() + + const app = Fastify() + registerMergeRoutes(app, { dbManager, mergeSessionCache }) + await app.ready() + t.after(() => app.close()) + + const exportResponse = await app.inject({ + method: 'POST', + url: '/_web/sessions/export-for-merge', + payload: { sessionIds: ['private-session'] }, + }) + assert.equal(exportResponse.statusCode, 200) + const handles = exportResponse.json().handles as Array<{ handle: string }> + + const mergeResponse = await app.inject({ + method: 'POST', + url: '/_web/merge/execute', + payload: { handles: handles.map(({ handle }) => handle), outputName: 'Merged private chat' }, + }) + + assert.equal(mergeResponse.statusCode, 200) + assert.equal(mergeResponse.json().data.meta.ownerId, 'owner') +}) + test('keeps every distinct platform message when merging a source with its superset', async (t) => { const rootDir = makeTempDir() t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })) diff --git a/packages/http-routes/src/routes/web/merge.ts b/packages/http-routes/src/routes/web/merge.ts index 47399620..7d669f8a 100644 --- a/packages/http-routes/src/routes/web/merge.ts +++ b/packages/http-routes/src/routes/web/merge.ts @@ -240,6 +240,7 @@ export function registerMergeRoutes(server: FastifyInstance, ctx: MergeRoutesCon type: exported.meta.type, groupId: exported.meta.groupId, groupAvatar: exported.meta.groupAvatar, + ownerId: exported.meta.ownerId, }) writer.writeMembers( exported.members.map((m) => ({ diff --git a/packages/node-runtime/src/merger/__tests__/merger.test.ts b/packages/node-runtime/src/merger/__tests__/merger.test.ts index 7dd7e683..4dd0e9e2 100644 --- a/packages/node-runtime/src/merger/__tests__/merger.test.ts +++ b/packages/node-runtime/src/merger/__tests__/merger.test.ts @@ -133,6 +133,61 @@ describe('buildMergedOutput', () => { assert.equal(result.chatLabData.meta.platform, 'mixed') }) + it('preserves the only non-empty owner id and drops conflicting owner ids', () => { + const messages = [{ senderPlatformId: 'peer', timestamp: 100, type: 0, content: 'hello' }] + const members = [{ platformId: 'owner' }, { platformId: 'peer' }] + const sourceWithoutOwner = createMockSource({ name: 'Private', platform: 'qq', type: 'private' }, members, messages) + const sourceWithOwner = createMockSource( + { name: 'Private', platform: 'qq', type: 'private', ownerId: 'owner' } as MergerSourceMeta & { + ownerId: string + }, + members, + messages + ) + + const retained = buildMergedOutput( + [ + { source: sourceWithoutOwner, filename: 'without-owner.json' }, + { source: sourceWithOwner, filename: 'with-owner.json' }, + ], + 'Merged' + ) + assert.equal((retained.chatLabData.meta as { ownerId?: string }).ownerId, 'owner') + + const invalid = buildMergedOutput( + [ + { + source: createMockSource( + { name: 'Private', platform: 'qq', type: 'private', ownerId: 'missing-owner' }, + members, + messages + ), + filename: 'invalid-owner.json', + }, + ], + 'Merged' + ) + assert.equal((invalid.chatLabData.meta as { ownerId?: string }).ownerId, undefined) + + const conflicting = buildMergedOutput( + [ + { source: sourceWithOwner, filename: 'first.json' }, + { + source: createMockSource( + { name: 'Private', platform: 'qq', type: 'private', ownerId: 'different-owner' } as MergerSourceMeta & { + ownerId: string + }, + members, + messages + ), + filename: 'second.json', + }, + ], + 'Merged' + ) + assert.equal((conflicting.chatLabData.meta as { ownerId?: string }).ownerId, undefined) + }) + it('keeps same-platform message IDs separate across unrelated private chats', () => { const first = createMockSource( { name: 'Alice', platform: 'telegram', type: 'private' }, @@ -425,7 +480,8 @@ describe('exportSessionToJson', () => { platform TEXT NOT NULL, type TEXT NOT NULL, group_id TEXT, - group_avatar TEXT + group_avatar TEXT, + owner_id TEXT ); CREATE TABLE member ( id INTEGER PRIMARY KEY, @@ -445,7 +501,7 @@ describe('exportSessionToJson', () => { platform_message_id TEXT, reply_to_message_id TEXT ); - INSERT INTO meta (name, platform, type) VALUES ('Test', 'qq', 'group'); + INSERT INTO meta (name, platform, type, owner_id) VALUES ('Test', 'qq', 'private', 'owner'); INSERT INTO member (id, platform_id, account_name) VALUES (1, 'alice', 'Alice'); INSERT INTO message ( sender_id, sender_account_name, ts, type, content, platform_message_id, reply_to_message_id @@ -455,6 +511,7 @@ describe('exportSessionToJson', () => { try { const exported = exportSessionToJson(new BetterSqliteAdapter(rawDb)) assert.equal(exported.chatlab.version, CHATLAB_FORMAT_VERSION) + assert.equal((exported.meta as { ownerId?: string }).ownerId, 'owner') assert.equal(exported.messages[0].platformMessageId, 'message-1') assert.equal(exported.messages[0].replyToMessageId, 'message-0') } finally { diff --git a/packages/node-runtime/src/merger/index.ts b/packages/node-runtime/src/merger/index.ts index 29e7cbbb..490463a6 100644 --- a/packages/node-runtime/src/merger/index.ts +++ b/packages/node-runtime/src/merger/index.ts @@ -42,6 +42,7 @@ export interface MergerSourceMeta { type: string groupId?: string groupAvatar?: string + ownerId?: string } function createMessageScope(meta: MergerSourceMeta | null, members: MergerMember[], sourceIndex: number): string { @@ -188,6 +189,7 @@ export interface ChatLabMeta { sources: MergeSourceInfo[] groupId?: string groupAvatar?: string + ownerId?: string } export interface ChatLabOutput { @@ -391,6 +393,9 @@ export function buildMergedOutput( const groupAvatar = groupId ? metas.filter(({ meta }) => meta?.groupId === groupId).pop()?.meta?.groupAvatar : undefined + const ownerIds = new Set(metas.map(({ meta }) => meta?.ownerId).filter((id): id is string => Boolean(id))) + const ownerCandidate = uniquePlatforms.length === 1 && ownerIds.size === 1 ? [...ownerIds][0] : undefined + const ownerId = ownerCandidate && memberMap.has(ownerCandidate) ? ownerCandidate : undefined const chatLabData: ChatLabOutput = { chatlab: { @@ -406,6 +411,7 @@ export function buildMergedOutput( sources, groupId, groupAvatar, + ownerId, }, members: Array.from(memberMap.values()), messages: mergedMessages, diff --git a/packages/node-runtime/src/merger/temp-db.ts b/packages/node-runtime/src/merger/temp-db.ts index fb817e2e..02c2c324 100644 --- a/packages/node-runtime/src/merger/temp-db.ts +++ b/packages/node-runtime/src/merger/temp-db.ts @@ -142,6 +142,7 @@ export interface TempDbMeta { type: string groupId?: string groupAvatar?: string + ownerId?: string } export class TempDbReader { @@ -153,7 +154,14 @@ export class TempDbReader { getMeta(): TempDbMeta | null { const row = this.db.prepare('SELECT * FROM meta LIMIT 1').get() as - | { name: string; platform: string; type: string; group_id: string | null; group_avatar: string | null } + | { + name: string + platform: string + type: string + group_id: string | null + group_avatar: string | null + owner_id: string | null + } | undefined if (!row) return null return { @@ -162,6 +170,7 @@ export class TempDbReader { type: row.type, groupId: row.group_id || undefined, groupAvatar: row.group_avatar || undefined, + ownerId: row.owner_id || undefined, } } @@ -274,6 +283,7 @@ function createDataSourceFromReader(reader: TempDbReader): MergerDataSource { type: meta.type, groupId: meta.groupId, groupAvatar: meta.groupAvatar, + ownerId: meta.ownerId, } }, getMembers(): MergerMember[] { @@ -310,7 +320,14 @@ function createDataSourceFromReader(reader: TempDbReader): MergerDataSource { export interface ExportedSession { chatlab: { version: typeof CHATLAB_FORMAT_VERSION; exportedAt: number; generator: string; description: string } - meta: { name: string; platform: string; type: string; groupId?: string; groupAvatar?: string } + meta: { + name: string + platform: string + type: string + groupId?: string + groupAvatar?: string + ownerId?: string + } members: Array<{ platformId: string; accountName: string; groupNickname?: string; avatar?: string }> messages: Array<{ platformMessageId?: string @@ -330,7 +347,14 @@ export interface ExportedSession { */ export function exportSessionToJson(db: DatabaseAdapter): ExportedSession { const meta = db.prepare('SELECT * FROM meta').get() as - | { name: string; platform: string; type: string; group_id?: string; group_avatar?: string } + | { + name: string + platform: string + type: string + group_id?: string + group_avatar?: string + owner_id?: string + } | undefined if (!meta) throw new Error('Cannot read session meta') @@ -373,6 +397,7 @@ export function exportSessionToJson(db: DatabaseAdapter): ExportedSession { type: meta.type, groupId: meta.group_id, groupAvatar: meta.group_avatar, + ownerId: meta.owner_id, }, members: members.map((m) => ({ platformId: m.platform_id,