fix(merge): preserve valid session identity metadata

Carry owner metadata through merge exports when every source agrees and the owner resolves to a merged member. Drop conflicting or unmappable owner identifiers.
This commit is contained in:
digua committed 2026-08-05 22:16:24 +08:00
1 parent 9ac7f1abeb
commit f0d4fdeeb3
5 files changed
+146 -5

No files matched your search

@@ -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 }))
@@ -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) => ({
@@ -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 {
@@ -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,
+28 -3
View File
@@ -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,