mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(node-runtime): add contacts aggregation service
This commit is contained in:
1 parent
39a97cae95
commit
9c8faeba09
6 files changed
+1095
-1
No files matched your search
@@ -364,6 +364,8 @@ export * as sessionIndexService from './services/session-index-service'
|
||||
export * as summaryService from './services/summary-service'
|
||||
export * as exportService from './services/export-service'
|
||||
export * as ownerProfileService from './services/owner-profile-service'
|
||||
export * as contactsService from './services/contacts-service'
|
||||
export * as contactsOverrides from './services/contacts-overrides'
|
||||
// Semantic index (Phase 1 vector search) — independent of legacy ai/rag
|
||||
export * as semanticIndex from './semantic-index'
|
||||
export {
|
||||
@@ -391,7 +393,15 @@ export type {
|
||||
SemanticIndexMode,
|
||||
} from './semantic-index'
|
||||
|
||||
export { createDatabaseManagerAdapter, MergeSessionCache, pushImport } from './services'
|
||||
export {
|
||||
CONTACTS_ALGORITHM_VERSION,
|
||||
ContactsOverridesManager,
|
||||
MergeSessionCache,
|
||||
buildContactOverrideKey,
|
||||
createContactsService,
|
||||
createDatabaseManagerAdapter,
|
||||
pushImport,
|
||||
} from './services'
|
||||
export type {
|
||||
SessionRuntimeAdapter,
|
||||
AnalysisSessionDTO,
|
||||
@@ -402,6 +412,11 @@ export type {
|
||||
ApplyOwnerProfileReason,
|
||||
ApplyOwnerProfileResult,
|
||||
SetOwnerAndApplyProfileResult,
|
||||
BuildContactOverrideKeyInput,
|
||||
ContactsOverrides,
|
||||
ContactsService,
|
||||
ContactsServiceDeps,
|
||||
ContactsServiceOptions,
|
||||
PushImportPayload,
|
||||
PushImportResult,
|
||||
PushImportOutcome,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Tests for contacts override persistence.
|
||||
*
|
||||
* Run: pnpm test -- packages/node-runtime/src/services/contacts-overrides.test.ts
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { initAppLogger } from '../logging/app-logger'
|
||||
import { ContactsOverridesManager, buildContactOverrideKey } from './contacts-overrides'
|
||||
|
||||
function makeTempDir(): string {
|
||||
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
|
||||
return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-overrides-'))
|
||||
}
|
||||
|
||||
function readJson(filePath: string): unknown {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
test('loads empty overrides when contacts-overrides.json is missing', (t) => {
|
||||
const dir = makeTempDir()
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
||||
|
||||
const manager = new ContactsOverridesManager(dir)
|
||||
|
||||
assert.equal(manager.getFilePath(), path.join(dir, 'contacts-overrides.json'))
|
||||
assert.deepEqual(manager.load(), {})
|
||||
assert.equal(manager.getSignaturePart(), '-')
|
||||
})
|
||||
|
||||
test('saveOverride writes valid JSON and deleteOverride removes entries', (t) => {
|
||||
const dir = makeTempDir()
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
||||
const manager = new ContactsOverridesManager(dir)
|
||||
|
||||
const saved = manager.saveOverride('weixin:wxid_a', { lockedTier: 'core' })
|
||||
assert.equal(saved.lockedTier, 'core')
|
||||
assert.equal(typeof saved.updatedAt, 'number')
|
||||
|
||||
assert.deepEqual(readJson(manager.getFilePath()), {
|
||||
'weixin:wxid_a': saved,
|
||||
})
|
||||
|
||||
manager.deleteOverride('weixin:wxid_a')
|
||||
assert.deepEqual(readJson(manager.getFilePath()), {})
|
||||
})
|
||||
|
||||
test('buildContactOverrideKey uses stable ids and session scope for name-match platforms', () => {
|
||||
assert.equal(buildContactOverrideKey({ platform: 'weixin', platformId: 'wxid_a' }), 'weixin:wxid_a')
|
||||
assert.equal(
|
||||
buildContactOverrideKey({ platform: 'whatsapp', platformId: 'Alice', sessionId: 'session-1', matchMode: 'name' }),
|
||||
'whatsapp:session-1:Alice'
|
||||
)
|
||||
assert.equal(
|
||||
buildContactOverrideKey({ platform: 'line', platformId: 'Bob', sessionId: 'line-session' }),
|
||||
'line:line-session:Bob'
|
||||
)
|
||||
assert.throws(() => buildContactOverrideKey({ platform: 'instagram', platformId: 'Carol' }), /sessionId is required/)
|
||||
})
|
||||
|
||||
test('corrupt override file is backed up and loads empty overrides', (t) => {
|
||||
const dir = makeTempDir()
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
||||
initAppLogger(path.join(dir, 'logs'))
|
||||
const filePath = path.join(dir, 'contacts-overrides.json')
|
||||
fs.writeFileSync(filePath, '{ broken', 'utf-8')
|
||||
|
||||
const manager = new ContactsOverridesManager(dir)
|
||||
assert.deepEqual(manager.load(), {})
|
||||
|
||||
const backupsDir = path.join(dir, 'backups')
|
||||
const backups = fs.readdirSync(backupsDir).filter((name) => name.startsWith('contacts-overrides-corrupt-'))
|
||||
assert.equal(backups.length, 1)
|
||||
assert.equal(fs.readFileSync(path.join(backupsDir, backups[0]), 'utf-8'), '{ broken')
|
||||
|
||||
const log = fs.readFileSync(path.join(dir, 'logs', 'app.log'), 'utf-8')
|
||||
assert.match(log, /contacts overrides file is corrupt/)
|
||||
})
|
||||
|
||||
test('atomic write leaves complete JSON and no temp file behind', (t) => {
|
||||
const dir = makeTempDir()
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }))
|
||||
const manager = new ContactsOverridesManager(dir)
|
||||
|
||||
manager.saveOverride('qq:10086', { lockedTier: 'core' })
|
||||
manager.saveOverride('qq:10010', { lockedTier: 'friend' })
|
||||
|
||||
const raw = fs.readFileSync(manager.getFilePath(), 'utf-8')
|
||||
assert.doesNotThrow(() => JSON.parse(raw))
|
||||
assert.deepEqual(Object.keys(readJson(manager.getFilePath()) as Record<string, unknown>).sort(), [
|
||||
'qq:10010',
|
||||
'qq:10086',
|
||||
])
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(dir).filter((name) => name.includes('contacts-overrides') && name.endsWith('.tmp')),
|
||||
[]
|
||||
)
|
||||
assert.notEqual(manager.getSignaturePart(), '-')
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { isNameMatchPlatform } from '@openchatlab/core'
|
||||
import type {
|
||||
ChatPlatform,
|
||||
ContactOverride,
|
||||
ContactOverridePatch,
|
||||
ContactTier,
|
||||
OwnerMatchMode,
|
||||
} from '@openchatlab/shared-types'
|
||||
import { appLogger } from '../logging/app-logger'
|
||||
|
||||
export type ContactsOverrides = Record<string, ContactOverride>
|
||||
|
||||
export interface BuildContactOverrideKeyInput {
|
||||
platform: ChatPlatform
|
||||
platformId: string
|
||||
sessionId?: string
|
||||
matchMode?: OwnerMatchMode
|
||||
}
|
||||
|
||||
const OVERRIDES_FILE = 'contacts-overrides.json'
|
||||
const VALID_CONTACT_TIERS: ReadonlySet<ContactTier> = new Set([
|
||||
'core',
|
||||
'friend',
|
||||
'acquaintance',
|
||||
'high_interaction',
|
||||
'medium_interaction',
|
||||
'low_interaction',
|
||||
])
|
||||
|
||||
export function buildContactOverrideKey(input: BuildContactOverrideKeyInput): string {
|
||||
const platform = input.platform.trim()
|
||||
const platformId = input.platformId.trim()
|
||||
if (!platform) throw new Error('platform is required')
|
||||
if (!platformId) throw new Error('platformId is required')
|
||||
|
||||
const sessionScoped = input.matchMode === 'name' || isNameMatchPlatform(platform)
|
||||
if (!sessionScoped) return `${platform}:${platformId}`
|
||||
|
||||
const sessionId = input.sessionId?.trim()
|
||||
if (!sessionId) throw new Error('sessionId is required for session-scoped contact override keys')
|
||||
return `${platform}:${sessionId}:${platformId}`
|
||||
}
|
||||
|
||||
export class ContactsOverridesManager {
|
||||
private readonly filePath: string
|
||||
|
||||
constructor(systemDir: string) {
|
||||
this.filePath = path.join(systemDir, OVERRIDES_FILE)
|
||||
}
|
||||
|
||||
getFilePath(): string {
|
||||
return this.filePath
|
||||
}
|
||||
|
||||
load(): ContactsOverrides {
|
||||
if (!fs.existsSync(this.filePath)) return {}
|
||||
|
||||
let raw = ''
|
||||
try {
|
||||
raw = fs.readFileSync(this.filePath, 'utf-8')
|
||||
return normalizeOverrides(JSON.parse(raw))
|
||||
} catch (error) {
|
||||
this.backupCorruptFile(raw)
|
||||
appLogger.warn('contacts', 'contacts overrides file is corrupt', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
saveOverride(key: string, patch: ContactOverridePatch): ContactOverride {
|
||||
const overrides = this.load()
|
||||
const current = overrides[key] ?? {}
|
||||
const next = normalizeOverride({
|
||||
...current,
|
||||
...patch,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
overrides[key] = next
|
||||
this.writeOverrides(overrides)
|
||||
return next
|
||||
}
|
||||
|
||||
deleteOverride(key: string): void {
|
||||
const overrides = this.load()
|
||||
delete overrides[key]
|
||||
this.writeOverrides(overrides)
|
||||
}
|
||||
|
||||
getSignaturePart(): string {
|
||||
try {
|
||||
const stat = fs.statSync(this.filePath)
|
||||
return `${Math.floor(stat.mtimeMs)}:${stat.size}`
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
private writeOverrides(overrides: ContactsOverrides): void {
|
||||
const dir = path.dirname(this.filePath)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
|
||||
try {
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf-8')
|
||||
fs.renameSync(tempPath, this.filePath)
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true })
|
||||
} catch {
|
||||
// ignore cleanup failures; the original write error is the actionable one
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private backupCorruptFile(raw: string): void {
|
||||
try {
|
||||
const backupDir = path.join(path.dirname(this.filePath), 'backups')
|
||||
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true })
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const backupPath = path.join(backupDir, `contacts-overrides-corrupt-${timestamp}.json`)
|
||||
fs.writeFileSync(backupPath, raw, 'utf-8')
|
||||
} catch (error) {
|
||||
appLogger.warn('contacts', 'failed to back up corrupt contacts overrides file', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOverrides(value: unknown): ContactsOverrides {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
|
||||
const result: ContactsOverrides = {}
|
||||
for (const [key, override] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!key.trim()) continue
|
||||
if (!override || typeof override !== 'object' || Array.isArray(override)) continue
|
||||
result[key] = normalizeOverride(override as ContactOverride)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function normalizeOverride(override: ContactOverride): ContactOverride {
|
||||
const normalized: ContactOverride = {}
|
||||
|
||||
if (override.lockedTier === null || isValidContactTier(override.lockedTier)) {
|
||||
normalized.lockedTier = override.lockedTier
|
||||
}
|
||||
if (typeof override.updatedAt === 'number' && Number.isFinite(override.updatedAt)) {
|
||||
normalized.updatedAt = override.updatedAt
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function isValidContactTier(value: unknown): value is ContactTier {
|
||||
return typeof value === 'string' && VALID_CONTACT_TIERS.has(value as ContactTier)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Integration tests for the cross-session contacts service.
|
||||
*
|
||||
* Run: pnpm test -- packages/node-runtime/src/services/contacts-service.test.ts
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { CHAT_DB_SCHEMA } from '@openchatlab/core'
|
||||
import type { DatabaseAdapter } from '@openchatlab/core'
|
||||
import { openBetterSqliteDatabase } from '../better-sqlite3-adapter'
|
||||
import type { SessionRuntimeAdapter } from './adapters'
|
||||
import { createContactsService } from './contacts-service'
|
||||
|
||||
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
|
||||
|
||||
function makeTempDir(): string {
|
||||
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
|
||||
return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-service-'))
|
||||
}
|
||||
|
||||
interface SeedMember {
|
||||
id: number
|
||||
platformId: string
|
||||
accountName?: string
|
||||
groupNickname?: string
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
interface SeedMessage {
|
||||
id: number
|
||||
senderId: number
|
||||
ts: number
|
||||
content?: string
|
||||
platformMessageId?: string | null
|
||||
replyToMessageId?: string | null
|
||||
}
|
||||
|
||||
interface SeedSession {
|
||||
id: string
|
||||
platform: string
|
||||
type: 'private' | 'group'
|
||||
ownerId?: string | null
|
||||
members: SeedMember[]
|
||||
messages?: SeedMessage[]
|
||||
}
|
||||
|
||||
class TestEnv {
|
||||
readonly dir = makeTempDir()
|
||||
readonly adapter: SessionRuntimeAdapter
|
||||
private dbPaths = new Map<string, string>()
|
||||
private openDbs: DatabaseAdapter[] = []
|
||||
|
||||
constructor() {
|
||||
const open = (sessionId: string, readonly: boolean): DatabaseAdapter | null => {
|
||||
const dbPath = this.dbPaths.get(sessionId)
|
||||
if (!dbPath) return null
|
||||
const db = openBetterSqliteDatabase(dbPath, { readonly, nativeBinding })
|
||||
this.openDbs.push(db)
|
||||
return db
|
||||
}
|
||||
|
||||
this.adapter = {
|
||||
listSessionIds: () => [...this.dbPaths.keys()],
|
||||
openReadonly: (id) => open(id, true),
|
||||
openWritable: (id) => open(id, false),
|
||||
closeSession: () => {},
|
||||
getDbPath: (id) => this.dbPaths.get(id) ?? '',
|
||||
deleteSessionFile: () => false,
|
||||
ensureReadonly: (id) => {
|
||||
const db = open(id, true)
|
||||
if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 })
|
||||
return db
|
||||
},
|
||||
ensureWritable: (id) => {
|
||||
const db = open(id, false)
|
||||
if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 })
|
||||
return db
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
seed(session: SeedSession): void {
|
||||
const dbPath = path.join(this.dir, `${session.id}.db`)
|
||||
const db = openBetterSqliteDatabase(dbPath, { nativeBinding })
|
||||
db.exec(CHAT_DB_SCHEMA)
|
||||
db.prepare(`INSERT INTO meta (name, platform, type, imported_at, owner_id) VALUES (?, ?, ?, ?, ?)`).run(
|
||||
session.id,
|
||||
session.platform,
|
||||
session.type,
|
||||
1780000000,
|
||||
session.ownerId ?? null
|
||||
)
|
||||
for (const member of session.members) {
|
||||
db.prepare(
|
||||
`INSERT INTO member (id, platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
member.id,
|
||||
member.platformId,
|
||||
member.accountName ?? member.platformId,
|
||||
member.groupNickname ?? null,
|
||||
member.avatar ?? null
|
||||
)
|
||||
}
|
||||
for (const message of session.messages ?? []) {
|
||||
db.prepare(
|
||||
`INSERT INTO message
|
||||
(id, sender_id, ts, type, content, platform_message_id, reply_to_message_id)
|
||||
VALUES (?, ?, ?, 0, ?, ?, ?)`
|
||||
).run(
|
||||
message.id,
|
||||
message.senderId,
|
||||
message.ts,
|
||||
message.content ?? `message ${message.id}`,
|
||||
message.platformMessageId ?? `m-${message.id}`,
|
||||
message.replyToMessageId ?? null
|
||||
)
|
||||
}
|
||||
db.close()
|
||||
this.dbPaths.set(session.id, dbPath)
|
||||
}
|
||||
|
||||
dbPath(sessionId: string): string {
|
||||
const dbPath = this.dbPaths.get(sessionId)
|
||||
assert.ok(dbPath)
|
||||
return dbPath
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
for (const db of this.openDbs) {
|
||||
try {
|
||||
db.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
}
|
||||
fs.rmSync(this.dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function privateMessages(count: number, startId: number, startTs: number): SeedMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: startId + index,
|
||||
senderId: index % 2 === 0 ? 1 : 2,
|
||||
ts: startTs + index,
|
||||
}))
|
||||
}
|
||||
|
||||
test('aggregates stable-id contacts across private and group sessions', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
env.seed({
|
||||
id: 'private-a',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', accountName: 'Me' },
|
||||
{ id: 2, platformId: 'alice', accountName: 'Alice', avatar: 'alice.png' },
|
||||
],
|
||||
messages: privateMessages(60, 1, 1704103200),
|
||||
})
|
||||
env.seed({
|
||||
id: 'private-b',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', accountName: 'Me' },
|
||||
{ id: 2, platformId: 'alice', accountName: 'Alice B' },
|
||||
],
|
||||
messages: privateMessages(5, 1, 1706781600),
|
||||
})
|
||||
env.seed({
|
||||
id: 'group-a',
|
||||
platform: 'weixin',
|
||||
type: 'group',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', accountName: 'Me' },
|
||||
{ id: 2, platformId: 'alice', accountName: 'Alice' },
|
||||
{ id: 3, platformId: 'bob', accountName: 'Bob' },
|
||||
],
|
||||
messages: [
|
||||
{ id: 1, senderId: 1, ts: 1704103200, platformMessageId: 'owner-1' },
|
||||
{ id: 2, senderId: 2, ts: 1704103201, platformMessageId: 'alice-1', replyToMessageId: 'owner-1' },
|
||||
{ id: 3, senderId: 3, ts: 1704103800, platformMessageId: 'bob-1' },
|
||||
],
|
||||
})
|
||||
|
||||
const result = createContactsService({ adapter: env.adapter, systemDir: env.dir }).getContacts()
|
||||
const byKey = new Map(result.contacts.map((contact) => [contact.key, contact]))
|
||||
const alice = byKey.get('weixin:alice')
|
||||
const bob = byKey.get('weixin:bob')
|
||||
|
||||
assert.ok(alice)
|
||||
assert.equal(alice.isFriend, true)
|
||||
assert.equal(alice.pool, 'friend')
|
||||
assert.equal(alice.scoreBreakdown.privateMessageCount, 65)
|
||||
assert.equal(alice.scoreBreakdown.activePrivateMonths, 2)
|
||||
assert.equal(alice.scoreBreakdown.commonGroupCount, 1)
|
||||
assert.equal(alice.avatar, 'alice.png')
|
||||
assert.deepEqual(alice.sourceSessions.map((source) => source.id).sort(), ['group-a', 'private-a', 'private-b'])
|
||||
|
||||
assert.ok(bob)
|
||||
assert.equal(bob.isFriend, false)
|
||||
assert.equal(bob.pool, 'non_friend')
|
||||
assert.equal(bob.scoreBreakdown.commonGroupCount, 1)
|
||||
assert.equal(bob.sourceSessions.length, 1)
|
||||
|
||||
assert.equal(result.diagnostics.privateSessionCount, 2)
|
||||
assert.equal(result.diagnostics.contactsEnabled, false)
|
||||
assert.equal(result.cache.status, 'fresh')
|
||||
})
|
||||
|
||||
test('records diagnostics for missing owner, unresolved owner, and ambiguous private sessions', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
env.seed({
|
||||
id: 'missing-owner',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: null,
|
||||
members: [{ id: 1, platformId: 'alice' }],
|
||||
})
|
||||
env.seed({
|
||||
id: 'unresolved-owner',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [{ id: 1, platformId: 'alice' }],
|
||||
})
|
||||
env.seed({
|
||||
id: 'ambiguous-private',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner' },
|
||||
{ id: 2, platformId: 'alice' },
|
||||
{ id: 3, platformId: 'bob' },
|
||||
],
|
||||
})
|
||||
|
||||
const result = createContactsService({ adapter: env.adapter, systemDir: env.dir }).getContacts()
|
||||
|
||||
assert.equal(result.contacts.length, 0)
|
||||
assert.equal(result.diagnostics.privateSessionCount, 3)
|
||||
assert.equal(result.diagnostics.skippedMissingOwnerSessions, 1)
|
||||
assert.equal(result.diagnostics.skippedUnresolvedOwnerSessions, 1)
|
||||
assert.equal(result.diagnostics.skippedAmbiguousPrivateSessions, 1)
|
||||
})
|
||||
|
||||
test('keeps name-match platform contacts session-scoped and applies manual overrides only to existing contacts', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
|
||||
for (const id of ['whatsapp-a', 'whatsapp-b']) {
|
||||
env.seed({
|
||||
id,
|
||||
platform: 'whatsapp',
|
||||
type: 'private',
|
||||
ownerId: 'Me',
|
||||
members: [
|
||||
{ id: 1, platformId: 'Me' },
|
||||
{ id: 2, platformId: 'Alice' },
|
||||
],
|
||||
messages: privateMessages(10, 1, 1704103200),
|
||||
})
|
||||
}
|
||||
|
||||
const service = createContactsService({ adapter: env.adapter, systemDir: env.dir })
|
||||
service.setContactOverride('whatsapp:whatsapp-a:Alice', { lockedTier: 'core' })
|
||||
service.setContactOverride('whatsapp:ghost:Alice', { lockedTier: 'core' })
|
||||
const result = service.getContacts()
|
||||
const keys = result.contacts.map((contact) => contact.key).sort()
|
||||
|
||||
assert.deepEqual(keys, ['whatsapp:whatsapp-a:Alice', 'whatsapp:whatsapp-b:Alice'])
|
||||
const locked = result.contacts.find((contact) => contact.key === 'whatsapp:whatsapp-a:Alice')
|
||||
const unlocked = result.contacts.find((contact) => contact.key === 'whatsapp:whatsapp-b:Alice')
|
||||
assert.ok(locked)
|
||||
assert.equal(locked.tier, 'core')
|
||||
assert.equal(locked.lockedTier, 'core')
|
||||
assert.ok(unlocked)
|
||||
assert.equal(unlocked.lockedTier, null)
|
||||
})
|
||||
|
||||
test('returns stale cached contacts when signature changes and acceptStale is true', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
let now = 1000
|
||||
|
||||
env.seed({
|
||||
id: 'private-a',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner' },
|
||||
{ id: 2, platformId: 'alice' },
|
||||
],
|
||||
messages: privateMessages(5, 1, 1704103200),
|
||||
})
|
||||
|
||||
const service = createContactsService({ adapter: env.adapter, systemDir: env.dir, now: () => now })
|
||||
const first = service.getContacts()
|
||||
assert.equal(first.cache.status, 'fresh')
|
||||
assert.equal(first.cache.computedAt, 1000)
|
||||
|
||||
now = 2000
|
||||
fs.utimesSync(env.dbPath('private-a'), new Date(), new Date(Date.now() + 5000))
|
||||
|
||||
const stale = service.getContacts({ acceptStale: true })
|
||||
assert.equal(stale.cache.status, 'stale')
|
||||
assert.equal(stale.cache.computedAt, 1000)
|
||||
assert.deepEqual(
|
||||
stale.contacts.map((contact) => contact.key),
|
||||
first.contacts.map((contact) => contact.key)
|
||||
)
|
||||
|
||||
const fresh = service.getContacts()
|
||||
assert.equal(fresh.cache.status, 'fresh')
|
||||
assert.equal(fresh.cache.computedAt, 2000)
|
||||
})
|
||||
|
||||
test('recomputes contacts after owner_id changes in a session database', (t) => {
|
||||
const env = new TestEnv()
|
||||
t.after(() => env.cleanup())
|
||||
let now = 1000
|
||||
|
||||
env.seed({
|
||||
id: 'private-a',
|
||||
platform: 'weixin',
|
||||
type: 'private',
|
||||
ownerId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner' },
|
||||
{ id: 2, platformId: 'alice' },
|
||||
],
|
||||
messages: privateMessages(5, 1, 1704103200),
|
||||
})
|
||||
|
||||
const service = createContactsService({ adapter: env.adapter, systemDir: env.dir, now: () => now })
|
||||
const first = service.getContacts()
|
||||
assert.deepEqual(
|
||||
first.contacts.map((contact) => contact.key),
|
||||
['weixin:alice']
|
||||
)
|
||||
|
||||
now = 2000
|
||||
env.adapter.ensureWritable('private-a').prepare('UPDATE meta SET owner_id = ?').run('alice')
|
||||
|
||||
const second = service.getContacts()
|
||||
assert.equal(second.cache.status, 'fresh')
|
||||
assert.equal(second.cache.computedAt, 2000)
|
||||
assert.deepEqual(
|
||||
second.contacts.map((contact) => contact.key),
|
||||
['weixin:owner']
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,445 @@
|
||||
import { ChatType } from '@openchatlab/shared-types'
|
||||
import type {
|
||||
ChatPlatform,
|
||||
ContactItem,
|
||||
ContactOverridePatch,
|
||||
ContactsCacheState,
|
||||
ContactsDiagnostics,
|
||||
ContactsResponse,
|
||||
ContactSourceSession,
|
||||
ContactTier,
|
||||
} from '@openchatlab/shared-types'
|
||||
import {
|
||||
MIN_PRIVATE_SESSIONS_FOR_CONTACTS,
|
||||
applyContactOverride,
|
||||
assignFriendTiers,
|
||||
assignNonFriendTiers,
|
||||
computeFriendScores,
|
||||
computeNonFriendScores,
|
||||
getGroupContactFacts,
|
||||
getPrivateContactFacts,
|
||||
getSessionMeta,
|
||||
isChatSessionDb,
|
||||
isNameMatchPlatform,
|
||||
resolveOwnerMember,
|
||||
} from '@openchatlab/core'
|
||||
import type { ContactMemberRef, SessionMeta } from '@openchatlab/core'
|
||||
import { getDbFileVersion } from '../cache/analytics-cache'
|
||||
import { appLogger } from '../logging/app-logger'
|
||||
import type { SessionRuntimeAdapter } from './adapters'
|
||||
import { ContactsOverridesManager, buildContactOverrideKey } from './contacts-overrides'
|
||||
|
||||
export const CONTACTS_ALGORITHM_VERSION = 'contacts-v1'
|
||||
|
||||
export interface ContactsServiceOptions {
|
||||
forceRecompute?: boolean
|
||||
acceptStale?: boolean
|
||||
}
|
||||
|
||||
export interface ContactsServiceDeps {
|
||||
adapter: SessionRuntimeAdapter
|
||||
systemDir: string
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export interface ContactsService {
|
||||
getContacts(options?: ContactsServiceOptions): ContactsResponse
|
||||
setContactOverride(key: string, patch: ContactOverridePatch): void
|
||||
deleteContactOverride(key: string): void
|
||||
invalidateContactsCache(): void
|
||||
}
|
||||
|
||||
interface CachedContacts {
|
||||
contacts: ContactItem[]
|
||||
diagnostics: ContactsDiagnostics
|
||||
algorithmVersion: string
|
||||
signature: string
|
||||
computedAt: number
|
||||
}
|
||||
|
||||
interface ContactAccumulator {
|
||||
key: string
|
||||
platform: ChatPlatform
|
||||
platformId: string
|
||||
sessionScoped: boolean
|
||||
sessionId?: string
|
||||
displayName: string
|
||||
aliases: Set<string>
|
||||
avatar: string | null
|
||||
isFriend: boolean
|
||||
privateMessageCount: number
|
||||
activePrivateMonths: Set<string>
|
||||
commonGroupSessionIds: Set<string>
|
||||
coOccurrenceCount: number
|
||||
coOccurrenceRawScore: number
|
||||
replyInteractionCount: number
|
||||
repliesFromOwnerToContact: number
|
||||
repliesFromContactToOwner: number
|
||||
sourceSessions: ContactSourceSession[]
|
||||
lastInteractionTs: number | null
|
||||
}
|
||||
|
||||
interface BuildContactsResult {
|
||||
contacts: ContactItem[]
|
||||
diagnostics: ContactsDiagnostics
|
||||
}
|
||||
|
||||
export function createContactsService(deps: ContactsServiceDeps): ContactsService {
|
||||
return new DefaultContactsService(deps)
|
||||
}
|
||||
|
||||
class DefaultContactsService implements ContactsService {
|
||||
private readonly overrides: ContactsOverridesManager
|
||||
private cache: CachedContacts | null = null
|
||||
|
||||
constructor(private readonly deps: ContactsServiceDeps) {
|
||||
this.overrides = new ContactsOverridesManager(deps.systemDir)
|
||||
}
|
||||
|
||||
getContacts(options: ContactsServiceOptions = {}): ContactsResponse {
|
||||
const signature = this.buildSignature()
|
||||
if (!options.forceRecompute && this.cache) {
|
||||
if (this.cache.signature === signature) return this.toResponse(this.cache, 'fresh')
|
||||
if (options.acceptStale) return this.toResponse(this.cache, 'stale', 'signature_changed')
|
||||
}
|
||||
|
||||
const computedAt = this.now()
|
||||
const result = this.computeContacts()
|
||||
this.cache = {
|
||||
...result,
|
||||
algorithmVersion: CONTACTS_ALGORITHM_VERSION,
|
||||
signature,
|
||||
computedAt,
|
||||
}
|
||||
appLogger.info('contacts', 'contacts recomputed', {
|
||||
contactCount: result.contacts.length,
|
||||
privateSessionCount: result.diagnostics.privateSessionCount,
|
||||
skippedFailedSessions: result.diagnostics.skippedFailedSessions,
|
||||
})
|
||||
return this.toResponse(this.cache, 'fresh')
|
||||
}
|
||||
|
||||
setContactOverride(key: string, patch: ContactOverridePatch): void {
|
||||
this.overrides.saveOverride(key, patch)
|
||||
this.invalidateContactsCache()
|
||||
appLogger.info('contacts', 'contact override saved', { key })
|
||||
}
|
||||
|
||||
deleteContactOverride(key: string): void {
|
||||
this.overrides.deleteOverride(key)
|
||||
this.invalidateContactsCache()
|
||||
appLogger.info('contacts', 'contact override deleted', { key })
|
||||
}
|
||||
|
||||
invalidateContactsCache(): void {
|
||||
this.cache = null
|
||||
}
|
||||
|
||||
private computeContacts(): BuildContactsResult {
|
||||
const diagnostics = createEmptyDiagnostics()
|
||||
const accumulators = new Map<string, ContactAccumulator>()
|
||||
|
||||
for (const sessionId of this.deps.adapter.listSessionIds()) {
|
||||
try {
|
||||
const db = this.deps.adapter.openReadonly(sessionId)
|
||||
if (!db || !isChatSessionDb(db)) continue
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) continue
|
||||
if (meta.type === ChatType.PRIVATE) diagnostics.privateSessionCount++
|
||||
if (meta.type !== ChatType.PRIVATE && meta.type !== ChatType.GROUP) continue
|
||||
|
||||
if (!meta.ownerId?.trim()) {
|
||||
diagnostics.skippedMissingOwnerSessions++
|
||||
continue
|
||||
}
|
||||
|
||||
const owner = resolveOwnerMember(db)
|
||||
if (!owner) {
|
||||
diagnostics.skippedUnresolvedOwnerSessions++
|
||||
continue
|
||||
}
|
||||
|
||||
if (meta.type === ChatType.PRIVATE) {
|
||||
this.collectPrivateSession(accumulators, diagnostics, sessionId, meta, owner.id, db)
|
||||
} else {
|
||||
this.collectGroupSession(accumulators, sessionId, meta, owner.id, db)
|
||||
}
|
||||
} catch (error) {
|
||||
diagnostics.skippedFailedSessions++
|
||||
appLogger.error('contacts', `failed to process contact session: ${sessionId}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.contactsEnabled = diagnostics.privateSessionCount > MIN_PRIVATE_SESSIONS_FOR_CONTACTS
|
||||
const contacts = this.buildContactItems([...accumulators.values()], diagnostics)
|
||||
return { contacts, diagnostics }
|
||||
}
|
||||
|
||||
private collectPrivateSession(
|
||||
accumulators: Map<string, ContactAccumulator>,
|
||||
diagnostics: ContactsDiagnostics,
|
||||
sessionId: string,
|
||||
meta: SessionMeta,
|
||||
ownerMemberId: number,
|
||||
db: Parameters<typeof getPrivateContactFacts>[0]
|
||||
): void {
|
||||
const facts = getPrivateContactFacts(db, ownerMemberId)
|
||||
if (facts.type === 'missing') return
|
||||
if (facts.type === 'ambiguous') {
|
||||
diagnostics.skippedAmbiguousPrivateSessions++
|
||||
return
|
||||
}
|
||||
|
||||
const acc = getOrCreateAccumulator(accumulators, sessionId, meta, facts.contact)
|
||||
acc.isFriend = true
|
||||
acc.privateMessageCount += facts.privateMessageCount
|
||||
for (const month of facts.activeMonths) acc.activePrivateMonths.add(month)
|
||||
updateLastInteraction(acc, facts.lastMessageTs)
|
||||
acc.sourceSessions.push({
|
||||
id: sessionId,
|
||||
name: meta.name,
|
||||
platform: meta.platform,
|
||||
type: ChatType.PRIVATE,
|
||||
messageCount: facts.privateMessageCount,
|
||||
privateMessageCount: facts.privateMessageCount,
|
||||
lastMessageTs: facts.lastMessageTs,
|
||||
})
|
||||
}
|
||||
|
||||
private collectGroupSession(
|
||||
accumulators: Map<string, ContactAccumulator>,
|
||||
sessionId: string,
|
||||
meta: SessionMeta,
|
||||
ownerMemberId: number,
|
||||
db: Parameters<typeof getGroupContactFacts>[0]
|
||||
): void {
|
||||
for (const facts of getGroupContactFacts(db, ownerMemberId)) {
|
||||
const acc = getOrCreateAccumulator(accumulators, sessionId, meta, facts.contact)
|
||||
acc.commonGroupSessionIds.add(sessionId)
|
||||
acc.coOccurrenceCount += facts.coOccurrenceCount
|
||||
acc.coOccurrenceRawScore += facts.coOccurrenceRawScore
|
||||
acc.replyInteractionCount += facts.replyInteractionCount
|
||||
acc.repliesFromOwnerToContact += facts.repliesFromOwnerToContact
|
||||
acc.repliesFromContactToOwner += facts.repliesFromContactToOwner
|
||||
updateLastInteraction(acc, facts.lastInteractionTs)
|
||||
acc.sourceSessions.push({
|
||||
id: sessionId,
|
||||
name: meta.name,
|
||||
platform: meta.platform,
|
||||
type: ChatType.GROUP,
|
||||
messageCount: facts.messageCount,
|
||||
coOccurrenceCount: facts.coOccurrenceCount,
|
||||
coOccurrenceRawScore: facts.coOccurrenceRawScore,
|
||||
replyInteractionCount: facts.replyInteractionCount,
|
||||
repliesFromOwnerToContact: facts.repliesFromOwnerToContact,
|
||||
repliesFromContactToOwner: facts.repliesFromContactToOwner,
|
||||
lastInteractionTs: facts.lastInteractionTs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private buildContactItems(accumulators: ContactAccumulator[], diagnostics: ContactsDiagnostics): ContactItem[] {
|
||||
const overrides = this.overrides.load()
|
||||
const friendInputs = accumulators
|
||||
.filter((acc) => acc.isFriend)
|
||||
.map((acc) => ({
|
||||
acc,
|
||||
privateMessageCount: acc.privateMessageCount,
|
||||
activeMonths: [...acc.activePrivateMonths],
|
||||
commonGroupCount: acc.commonGroupSessionIds.size,
|
||||
}))
|
||||
const nonFriendInputs = accumulators
|
||||
.filter((acc) => !acc.isFriend)
|
||||
.map((acc) => ({
|
||||
acc,
|
||||
coOccurrenceRawScore: acc.coOccurrenceRawScore,
|
||||
commonGroupCount: acc.commonGroupSessionIds.size,
|
||||
replyInteractionCount: acc.replyInteractionCount,
|
||||
coOccurrenceCount: acc.coOccurrenceCount,
|
||||
}))
|
||||
|
||||
const friendScores = computeFriendScores(friendInputs)
|
||||
const friendTierInputs = friendInputs.map((input) => ({
|
||||
input,
|
||||
score: friendScores.get(input)?.score ?? 0,
|
||||
privateMessageCount: input.privateMessageCount,
|
||||
}))
|
||||
const friendTiers = assignFriendTiers(friendTierInputs)
|
||||
|
||||
const nonFriendScores = computeNonFriendScores(nonFriendInputs)
|
||||
const nonFriendTierInputs = nonFriendInputs.map((input) => ({
|
||||
input,
|
||||
score: nonFriendScores.get(input)?.score ?? 0,
|
||||
coOccurrenceCount: input.coOccurrenceCount,
|
||||
replyInteractionCount: input.replyInteractionCount,
|
||||
}))
|
||||
const nonFriendTiers = assignNonFriendTiers(nonFriendTierInputs)
|
||||
|
||||
const contacts: ContactItem[] = []
|
||||
|
||||
// 评分和分层按好友/非好友分池计算;最终再应用手动锁定,保证用户选择拥有最高优先级。
|
||||
for (const tierInput of friendTierInputs) {
|
||||
const { input } = tierInput
|
||||
const score = friendScores.get(input) ?? { score: 0, scoreBreakdown: {} }
|
||||
const algorithmTier = friendTiers.tiers.get(tierInput) ?? 'acquaintance'
|
||||
contacts.push(this.toContactItem(input.acc, 'friend', algorithmTier, score, overrides[input.acc.key]))
|
||||
}
|
||||
|
||||
for (const tierInput of nonFriendTierInputs) {
|
||||
const { input } = tierInput
|
||||
const score = nonFriendScores.get(input) ?? { score: 0, scoreBreakdown: {} }
|
||||
const algorithmTier = nonFriendTiers.tiers.get(tierInput) ?? 'low_interaction'
|
||||
const item = this.toContactItem(input.acc, 'non_friend', algorithmTier, score, overrides[input.acc.key])
|
||||
if (item.tier === 'low_interaction') diagnostics.hiddenLowInteractionNonFriends++
|
||||
contacts.push(item)
|
||||
}
|
||||
|
||||
return contacts.sort((a, b) => b.score - a.score || a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
|
||||
private toContactItem(
|
||||
acc: ContactAccumulator,
|
||||
pool: 'friend' | 'non_friend',
|
||||
algorithmTier: ContactTier,
|
||||
scoring: { score: number; scoreBreakdown: ContactItem['scoreBreakdown'] },
|
||||
override: Parameters<typeof applyContactOverride>[1]
|
||||
): ContactItem {
|
||||
const applied = applyContactOverride(algorithmTier, override)
|
||||
const aliases = [...acc.aliases].filter((alias) => alias !== acc.displayName)
|
||||
const searchText = [acc.displayName, acc.platformId, ...aliases].join(' ').toLowerCase()
|
||||
|
||||
return {
|
||||
key: acc.key,
|
||||
platform: acc.platform,
|
||||
platformId: acc.platformId,
|
||||
sessionScoped: acc.sessionScoped,
|
||||
sessionId: acc.sessionId,
|
||||
displayName: acc.displayName,
|
||||
aliases,
|
||||
avatar: acc.avatar,
|
||||
isFriend: acc.isFriend,
|
||||
pool,
|
||||
tier: applied.tier,
|
||||
algorithmTier: applied.algorithmTier,
|
||||
lockedTier: applied.lockedTier,
|
||||
score: scoring.score,
|
||||
scoreBreakdown: {
|
||||
...scoring.scoreBreakdown,
|
||||
privateMessageCount: acc.privateMessageCount || scoring.scoreBreakdown.privateMessageCount,
|
||||
activePrivateMonths: acc.activePrivateMonths.size || scoring.scoreBreakdown.activePrivateMonths,
|
||||
commonGroupCount: acc.commonGroupSessionIds.size,
|
||||
coOccurrenceCount: acc.coOccurrenceCount,
|
||||
coOccurrenceRawScore: acc.coOccurrenceRawScore,
|
||||
replyInteractionCount: acc.replyInteractionCount,
|
||||
repliesFromOwnerToContact: acc.repliesFromOwnerToContact,
|
||||
repliesFromContactToOwner: acc.repliesFromContactToOwner,
|
||||
},
|
||||
sourceSessions: acc.sourceSessions,
|
||||
searchText,
|
||||
lastInteractionTs: acc.lastInteractionTs,
|
||||
}
|
||||
}
|
||||
|
||||
private buildSignature(): string {
|
||||
const parts = [`algorithm:${CONTACTS_ALGORITHM_VERSION}`, `overrides:${this.overrides.getSignaturePart()}`]
|
||||
for (const sessionId of [...this.deps.adapter.listSessionIds()].sort()) {
|
||||
const dbPath = this.deps.adapter.getDbPath(sessionId)
|
||||
parts.push(`${sessionId}:${getDbFileVersion(dbPath)}`)
|
||||
}
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
cached: CachedContacts,
|
||||
status: ContactsCacheState['status'],
|
||||
staleReason?: string
|
||||
): ContactsResponse {
|
||||
return {
|
||||
contacts: cached.contacts,
|
||||
diagnostics: cached.diagnostics,
|
||||
algorithmVersion: cached.algorithmVersion,
|
||||
cache: {
|
||||
status,
|
||||
computedAt: cached.computedAt,
|
||||
signature: cached.signature,
|
||||
staleReason,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
return this.deps.now?.() ?? Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyDiagnostics(): ContactsDiagnostics {
|
||||
return {
|
||||
privateSessionCount: 0,
|
||||
contactsEnabled: false,
|
||||
skippedMissingOwnerSessions: 0,
|
||||
skippedUnresolvedOwnerSessions: 0,
|
||||
skippedAmbiguousPrivateSessions: 0,
|
||||
skippedInvalidPlatformIdMembers: 0,
|
||||
skippedFailedSessions: 0,
|
||||
hiddenLowInteractionNonFriends: 0,
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateAccumulator(
|
||||
accumulators: Map<string, ContactAccumulator>,
|
||||
sessionId: string,
|
||||
meta: SessionMeta,
|
||||
contact: ContactMemberRef
|
||||
): ContactAccumulator {
|
||||
const sessionScoped = isNameMatchPlatform(meta.platform)
|
||||
const key = buildContactOverrideKey({
|
||||
platform: meta.platform,
|
||||
platformId: contact.platformId,
|
||||
sessionId,
|
||||
matchMode: sessionScoped ? 'name' : 'platform_id',
|
||||
})
|
||||
const existing = accumulators.get(key)
|
||||
if (existing) {
|
||||
mergeContactIdentity(existing, contact)
|
||||
return existing
|
||||
}
|
||||
|
||||
const created: ContactAccumulator = {
|
||||
key,
|
||||
platform: meta.platform,
|
||||
platformId: contact.platformId,
|
||||
sessionScoped,
|
||||
sessionId: sessionScoped ? sessionId : undefined,
|
||||
displayName: contact.name || contact.platformId,
|
||||
aliases: new Set([contact.platformId, contact.name].filter(Boolean)),
|
||||
avatar: contact.avatar,
|
||||
isFriend: false,
|
||||
privateMessageCount: 0,
|
||||
activePrivateMonths: new Set(),
|
||||
commonGroupSessionIds: new Set(),
|
||||
coOccurrenceCount: 0,
|
||||
coOccurrenceRawScore: 0,
|
||||
replyInteractionCount: 0,
|
||||
repliesFromOwnerToContact: 0,
|
||||
repliesFromContactToOwner: 0,
|
||||
sourceSessions: [],
|
||||
lastInteractionTs: null,
|
||||
}
|
||||
accumulators.set(key, created)
|
||||
return created
|
||||
}
|
||||
|
||||
function mergeContactIdentity(acc: ContactAccumulator, contact: ContactMemberRef): void {
|
||||
if (contact.name) acc.aliases.add(contact.name)
|
||||
acc.aliases.add(contact.platformId)
|
||||
if ((!acc.displayName || acc.displayName === acc.platformId) && contact.name) {
|
||||
acc.displayName = contact.name
|
||||
}
|
||||
if (!acc.avatar && contact.avatar) acc.avatar = contact.avatar
|
||||
}
|
||||
|
||||
function updateLastInteraction(acc: ContactAccumulator, ts: number | null): void {
|
||||
if (ts === null) return
|
||||
acc.lastInteractionTs = Math.max(acc.lastInteractionTs ?? 0, ts)
|
||||
}
|
||||
@@ -55,6 +55,14 @@ export type { LlmConfig, SummaryServiceDeps } from './summary-service'
|
||||
// Export service
|
||||
export { exportMarkdown } from './export-service'
|
||||
|
||||
// Contacts overrides
|
||||
export { ContactsOverridesManager, buildContactOverrideKey } from './contacts-overrides'
|
||||
export type { BuildContactOverrideKeyInput, ContactsOverrides } from './contacts-overrides'
|
||||
|
||||
// Contacts service
|
||||
export { CONTACTS_ALGORITHM_VERSION, createContactsService } from './contacts-service'
|
||||
export type { ContactsService, ContactsServiceDeps, ContactsServiceOptions } from './contacts-service'
|
||||
|
||||
// Merge cache
|
||||
export { MergeSessionCache } from './merge-cache'
|
||||
|
||||
|
||||
Reference in new issue
Block a user