mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(contacts): mark groupmates as friends
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user