feat(contacts): mark groupmates as friends

This commit is contained in:
digua committed 2026-06-27 20:02:23 +08:00
1 parent 928ac6064b
commit ba500bd462
15 files changed
+477 -20

No files matched your search

@@ -294,7 +294,7 @@ function toContactItem(
const aliases = [...acc.aliases].filter((alias) => alias !== acc.displayName)
const searchText = [acc.displayName, acc.platformId, ...aliases].join(' ').toLowerCase()
return {
const item: ContactItem = {
key: acc.key,
platform: acc.platform,
platformId: acc.platformId,
@@ -321,6 +321,8 @@ function toContactItem(
searchText,
lastInteractionTs: acc.lastInteractionTs,
}
if (pool === 'friend') item.friendSource = 'private'
return item
}
export function createEmptyContactsDiagnostics(): ContactsDiagnostics {
@@ -0,0 +1,74 @@
import fs from 'node:fs'
import path from 'node:path'
import { appLogger } from '../../logging/app-logger'
const CONTACT_OVERRIDES_FILE_NAME = 'contact-overrides.json'
const CONTACT_OVERRIDES_TMP_PREFIX = 'contact-overrides.tmp-'
const CONTACT_OVERRIDES_VERSION = 1
export interface ManualFriendOverride {
key: string
createdAt: number
updatedAt: number
}
export interface ContactOverridesFile {
version: 1
manualFriends: Record<string, ManualFriendOverride>
}
export interface ContactOverrideMutationResult {
success: boolean
}
export function createEmptyContactOverridesFile(): ContactOverridesFile {
return {
version: CONTACT_OVERRIDES_VERSION,
manualFriends: {},
}
}
export function getContactOverridesPath(snapshotDir: string): string {
return path.join(snapshotDir, CONTACT_OVERRIDES_FILE_NAME)
}
export function readContactOverrides(snapshotDir: string): ContactOverridesFile {
const filePath = getContactOverridesPath(snapshotDir)
if (!fs.existsSync(filePath)) return createEmptyContactOverridesFile()
try {
return normalizeContactOverrides(JSON.parse(fs.readFileSync(filePath, 'utf-8')))
} catch (error) {
appLogger.warn('contacts', 'contact overrides file is unreadable', error)
return createEmptyContactOverridesFile()
}
}
export function writeContactOverrides(snapshotDir: string, overrides: ContactOverridesFile): void {
if (!fs.existsSync(snapshotDir)) fs.mkdirSync(snapshotDir, { recursive: true })
const tmpPath = path.join(snapshotDir, `${CONTACT_OVERRIDES_TMP_PREFIX}${process.pid}-${Date.now()}`)
fs.writeFileSync(tmpPath, JSON.stringify(normalizeContactOverrides(overrides), null, 2), 'utf-8')
fs.renameSync(tmpPath, getContactOverridesPath(snapshotDir))
}
function normalizeContactOverrides(value: unknown): ContactOverridesFile {
const input = isRecord(value) ? value : {}
const manualFriendsInput = isRecord(input.manualFriends) ? input.manualFriends : {}
const manualFriends: Record<string, ManualFriendOverride> = {}
for (const [key, entry] of Object.entries(manualFriendsInput)) {
if (!key || !isRecord(entry)) continue
const createdAt = Number.isFinite(entry.createdAt) ? Number(entry.createdAt) : Date.now()
const updatedAt = Number.isFinite(entry.updatedAt) ? Number(entry.updatedAt) : createdAt
manualFriends[key] = { key, createdAt, updatedAt }
}
return {
version: CONTACT_OVERRIDES_VERSION,
manualFriends,
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -540,6 +540,78 @@ test('returns paginated lightweight contacts from a snapshot with full-snapshot
assert.equal('sourceSessions' in response.contacts[0], false)
})
test('applies manual friend overrides to paginated lists, stats, search, and detail without changing the snapshot', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
const service = createContactsService({
adapter: env.adapter,
systemDir: env.dir,
runner: () => Promise.reject(new Error('not used')),
})
const snapshot = makeRuntimeSnapshot('sig-1', 1000)
snapshot.contacts = [
makeContact({ key: 'weixin:bob', displayName: 'Bob', pool: 'non_friend', score: 1, aliases: ['Builder'] }),
makeContact({ key: 'weixin:alice', displayName: 'Alice', pool: 'friend', score: 0.4 }),
makeContact({ key: 'weixin:carol', displayName: 'Carol', pool: 'non_friend', score: 0.3 }),
]
service.replaceSnapshotForTests!(snapshot)
assert.deepEqual((service as any).markContactAsFriend('weixin:bob', { acceptStale: true }), { success: true })
const friends = service.getContactsPage({ acceptStale: true, pool: 'friend', page: 1, pageSize: 10 })
assert.deepEqual(
friends.contacts.map((contact) => contact.key),
['weixin:alice', 'weixin:bob']
)
assert.deepEqual(friends.stats, { friendsTotal: 2, nonFriendsTotal: 1 })
assert.equal((friends.contacts[0] as any).friendSource, 'private')
assert.equal((friends.contacts[1] as any).friendSource, 'manual')
assert.equal(friends.contacts[1].pool, 'friend')
assert.equal(friends.contacts[1].isFriend, true)
const groupmates = service.getContactsPage({ acceptStale: true, pool: 'non_friend', page: 1, pageSize: 10 })
assert.deepEqual(
groupmates.contacts.map((contact) => contact.key),
['weixin:carol']
)
const search = service.getContactsPage({
acceptStale: true,
pool: 'friend',
page: 1,
pageSize: 10,
query: 'build',
})
assert.deepEqual(
search.contacts.map((contact) => contact.key),
['weixin:bob']
)
const detail = service.getContactDetail('weixin:bob', { acceptStale: true })
assert.equal(detail.contact?.pool, 'friend')
assert.equal(detail.contact?.isFriend, true)
assert.equal((detail.contact as any).friendSource, 'manual')
assert.equal(snapshot.contacts[0]?.pool, 'non_friend')
assert.deepEqual((service as any).unmarkContactAsFriend('weixin:bob'), { success: true })
const friendsAfterUnmark = service.getContactsPage({ acceptStale: true, pool: 'friend', page: 1, pageSize: 10 })
assert.deepEqual(
friendsAfterUnmark.contacts.map((contact) => contact.key),
['weixin:alice']
)
const groupmatesAfterUnmark = service.getContactsPage({
acceptStale: true,
pool: 'non_friend',
page: 1,
pageSize: 10,
})
assert.deepEqual(
groupmatesAfterUnmark.contacts.map((contact) => contact.key),
['weixin:bob', 'weixin:carol']
)
assert.equal((groupmatesAfterUnmark.contacts[0] as any).friendSource, undefined)
})
test('returns full contact detail from the selected time range snapshot', (t) => {
const env = new TestEnv()
t.after(() => env.cleanup())
@@ -20,6 +20,12 @@ import {
type ContactsComputeProgress,
type ContactsSnapshot,
} from './compute'
import {
readContactOverrides,
writeContactOverrides,
type ContactOverrideMutationResult,
type ContactOverridesFile,
} from './overrides'
import { buildContactsSignature } from './signature'
import { cleanupContactsSnapshotTempFiles, readContactsSnapshot, writeContactsSnapshot } from './snapshot'
import { normalizeContactsTimeRangePreset, resolveContactsTimeRange } from './time-range'
@@ -63,6 +69,8 @@ export interface ContactsService {
getContacts(options?: ContactsServiceOptions): ContactsResponse
getContactsPage(options?: ContactsServiceOptions): ContactsResponse
getContactDetail(key: string, options?: ContactsServiceOptions): ContactDetailResponse
markContactAsFriend(key: string, options?: ContactsServiceOptions): ContactOverrideMutationResult
unmarkContactAsFriend(key: string, options?: ContactsServiceOptions): ContactOverrideMutationResult
startRecompute(options?: ContactsServiceOptions): ContactsResponse
invalidateContactsCache(): void
close(): Promise<void>
@@ -120,7 +128,8 @@ class DefaultContactsService implements ContactsService {
const snapshot = this.getSnapshot(timeRangePreset)
const status = this.getCacheStatus(signature, timeRangePreset)
const includeSnapshot = status === 'fresh' || (status === 'stale' && options.acceptStale === true)
const contact = includeSnapshot ? (snapshot?.contacts.find((item) => item.key === key) ?? null) : null
const contacts = includeSnapshot ? this.getContactsWithOverrides(snapshot?.contacts ?? []) : []
const contact = contacts.find((item) => item.key === key) ?? null
return {
contact: contact ? sanitizeContactItem(contact) : null,
algorithmVersion: includeSnapshot
@@ -132,6 +141,33 @@ class DefaultContactsService implements ContactsService {
}
}
markContactAsFriend(key: string, options: ContactsServiceOptions = {}): ContactOverrideMutationResult {
const timeRangePreset = normalizeContactsTimeRangePreset(options.timeRangePreset)
const snapshot = this.getSnapshot(timeRangePreset)
const contact = snapshot?.contacts.find((item) => item.key === key)
if (!contact) throw createContactNotFoundError(key)
if (contact.pool === 'friend') return { success: true }
const now = this.now()
const overrides = this.readOverrides()
const existing = overrides.manualFriends[key]
overrides.manualFriends[key] = {
key,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
}
this.writeOverrides(overrides, 'mark contact as friend')
return { success: true }
}
unmarkContactAsFriend(key: string): ContactOverrideMutationResult {
const overrides = this.readOverrides()
if (!overrides.manualFriends[key]) return { success: true }
delete overrides.manualFriends[key]
this.writeOverrides(overrides, 'unmark contact as friend')
return { success: true }
}
startRecompute(options: ContactsServiceOptions = {}): ContactsResponse {
const timeRangePreset = normalizeContactsTimeRangePreset(options.timeRangePreset)
const signature = buildContactsSignature(this.deps.adapter, timeRangePreset)
@@ -264,16 +300,18 @@ class DefaultContactsService implements ContactsService {
const snapshot = this.getSnapshot(timeRangePreset)
const status = this.getCacheStatus(signature, timeRangePreset)
const includeSnapshot = status === 'fresh' || (status === 'stale' && options.acceptStale === true)
const allContacts = includeSnapshot ? (snapshot?.contacts ?? []) : []
const allContacts = includeSnapshot ? this.getContactsWithOverrides(snapshot?.contacts ?? []) : []
const stats = buildContactsStats(allContacts)
const page = normalizePositiveInt(options.page, 1)
const pageSize = normalizePageSize(options.pageSize)
const query = options.query?.trim().toLowerCase() ?? ''
const filteredContacts = allContacts.filter((contact) => {
if (options.pool && contact.pool !== options.pool) return false
if (query && !contact.searchText.includes(query)) return false
return true
})
const filteredContacts = allContacts
.filter((contact) => {
if (options.pool && contact.pool !== options.pool) return false
if (query && !contact.searchText.includes(query)) return false
return true
})
.sort((a, b) => compareContactsForResponse(a, b, options.pool))
const offset = (page - 1) * pageSize
const pageContacts = filteredContacts.slice(offset, offset + pageSize).map(toContactListItem)
return {
@@ -319,6 +357,65 @@ class DefaultContactsService implements ContactsService {
private now(): number {
return this.deps.now?.() ?? Date.now()
}
private getContactsWithOverrides(contacts: ContactItem[]): ContactItem[] {
return applyContactOverrides(contacts, this.readOverrides())
}
private readOverrides(): ContactOverridesFile {
return readContactOverrides(this.snapshotDir)
}
private writeOverrides(overrides: ContactOverridesFile, action: string): void {
try {
writeContactOverrides(this.snapshotDir, overrides)
appLogger.info('contacts', `contact override saved: ${action}`, {
manualFriendCount: Object.keys(overrides.manualFriends).length,
})
} catch (error) {
appLogger.error('contacts', `failed to save contact override: ${action}`, error)
throw error
}
}
}
function applyContactOverrides(contacts: ContactItem[], overrides: ContactOverridesFile): ContactItem[] {
return contacts.map((contact) => {
if (contact.pool === 'friend') {
return {
...contact,
isFriend: true,
pool: 'friend',
friendSource: 'private',
}
}
if (overrides.manualFriends[contact.key]) {
return {
...contact,
isFriend: true,
pool: 'friend',
friendSource: 'manual',
}
}
const { friendSource: _friendSource, ...rest } = contact
return {
...rest,
isFriend: false,
pool: 'non_friend',
}
})
}
function compareContactsForResponse(a: ContactItem, b: ContactItem, pool: ContactPool | undefined): number {
if (pool === 'friend') {
const rankDiff = friendSortRank(a) - friendSortRank(b)
if (rankDiff !== 0) return rankDiff
}
return b.score - a.score || a.displayName.localeCompare(b.displayName)
}
function friendSortRank(contact: ContactItem): number {
return contact.friendSource === 'manual' ? 1 : 0
}
function buildContactsStats(contacts: ContactItem[]): { friendsTotal: number; nonFriendsTotal: number } {
@@ -355,9 +452,17 @@ function sanitizeContactItem(contact: ContactItem): ContactItem {
lastInteractionTs: contact.lastInteractionTs,
}
if (contact.sessionId) item.sessionId = contact.sessionId
if (contact.friendSource) item.friendSource = contact.friendSource
return item
}
function createContactNotFoundError(key: string): Error {
return Object.assign(new Error(`Contact not found: ${key}`), {
statusCode: 404,
code: 'CONTACT_NOT_FOUND',
})
}
function sanitizeContactsDiagnostics(diagnostics: ContactsDiagnostics): ContactsDiagnostics {
return {
privateSessionCount: diagnostics.privateSessionCount,