mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user