feat(owner): add platform-level owner profile types and matching

Add deterministic owner matching in core (exact platformId first, then
normalized-name fallback limited to whatsapp/line/instagram), plus
OwnerProfile preferences fields shared across Electron and CLI Web.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
diguaandClaude Fable 5 committed 2026-06-13 13:16:52 +08:00
1 parent 82a6be4fcb
commit 7589515eeb
6 files changed
+362

No files matched your search

+11
View File
@@ -254,6 +254,17 @@ export {
} from './ai'
export type { StreamParserEvent, ThinkingLevel, ThinkingCompat } from './ai'
// Owner profile matching(跨会话"我是谁"识别)
export {
NAME_MATCH_PLATFORMS,
isNameMatchPlatform,
normalizeOwnerName,
collectCandidateNames,
mergeConfirmedNames,
matchOwnerProfile,
} from './owner'
export type { OwnerMatchCandidate, OwnerMatchResult } from './owner'
// Import utilities
export { generateMessageKey, buildMemberIdMap } from './import'
+9
View File
@@ -0,0 +1,9 @@
export {
NAME_MATCH_PLATFORMS,
isNameMatchPlatform,
normalizeOwnerName,
collectCandidateNames,
mergeConfirmedNames,
matchOwnerProfile,
} from './owner-matching'
export type { OwnerMatchCandidate, OwnerMatchResult } from './owner-matching'
@@ -0,0 +1,174 @@
/**
* Unit tests for owner profile matching (deterministic "who am I" resolution).
*
* Run: npx tsx --test packages/core/src/owner/owner-matching.test.ts
*/
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import type { OwnerProfile } from '@openchatlab/shared-types'
import {
normalizeOwnerName,
collectCandidateNames,
mergeConfirmedNames,
matchOwnerProfile,
isNameMatchPlatform,
} from './owner-matching'
function makeProfile(overrides?: Partial<OwnerProfile>): OwnerProfile {
return {
platformId: 'Alice',
displayName: 'Alice',
confirmedNames: ['Alice'],
matchMode: 'name',
updatedAt: 1700000000,
...overrides,
}
}
describe('normalizeOwnerName', () => {
it('trims and collapses whitespace', () => {
assert.equal(normalizeOwnerName(' Alice Smith '), 'alice smith')
})
it('removes invisible direction/control characters (LRM/RLM/zero-width/BOM)', () => {
assert.equal(normalizeOwnerName('\u200EAlice\u200F'), 'alice')
assert.equal(normalizeOwnerName('\u202AAlice\u202C'), 'alice')
assert.equal(normalizeOwnerName('A\u200Blice'), 'alice')
assert.equal(normalizeOwnerName('\uFEFFAlice'), 'alice')
})
it('applies NFKC normalization (full-width to half-width)', () => {
assert.equal(normalizeOwnerName('Alice 123'), 'alice 123')
})
it('lowercases for case-insensitive comparison', () => {
assert.equal(normalizeOwnerName('ALICE'), 'alice')
})
it('keeps CJK names intact', () => {
assert.equal(normalizeOwnerName(' 王小明 '), '王小明')
})
})
describe('collectCandidateNames', () => {
it('collects platformId, accountName, groupNickname, aliases and displayName', () => {
const names = collectCandidateNames({
platformId: 'u001',
accountName: 'Alice',
groupNickname: 'Ali',
aliases: ['A.', 'Allie'],
displayName: 'Ali',
})
assert.deepEqual(names, ['u001', 'Alice', 'Ali', 'A.', 'Allie'])
})
it('skips empty and whitespace-only values', () => {
const names = collectCandidateNames({
platformId: 'u001',
accountName: '',
groupNickname: ' ',
aliases: [''],
displayName: null,
})
assert.deepEqual(names, ['u001'])
})
})
describe('mergeConfirmedNames', () => {
it('merges member names into existing list without duplicates, preserving order', () => {
const merged = mergeConfirmedNames(['Alice', 'Ali'], {
platformId: 'Alice',
accountName: 'Alice Smith',
groupNickname: 'Ali',
aliases: ['Allie'],
})
assert.deepEqual(merged, ['Alice', 'Ali', 'Alice Smith', 'Allie'])
})
it('keeps original strings without normalization', () => {
const merged = mergeConfirmedNames([], { platformId: ' Alice ' })
assert.deepEqual(merged, [' Alice '])
})
})
describe('matchOwnerProfile', () => {
it('matches exact platformId on any platform', () => {
const result = matchOwnerProfile('weixin', makeProfile({ platformId: 'wx_123', confirmedNames: [] }), [
{ platformId: 'wx_123', accountName: 'Me' },
{ platformId: 'wx_456', accountName: 'Other' },
])
assert.deepEqual(result, { type: 'exact', platformId: 'wx_123' })
})
it('falls back to name matching only on allowlisted platforms', () => {
const members = [
{ platformId: 'Alice Smith', accountName: 'Alice Smith' },
{ platformId: 'Bob', accountName: 'Bob' },
]
const profile = makeProfile({ platformId: 'Alice', confirmedNames: ['alice smith'] })
assert.deepEqual(matchOwnerProfile('whatsapp', profile, members), {
type: 'name',
platformId: 'Alice Smith',
})
// weixin has stable IDs; no name fallback
assert.deepEqual(matchOwnerProfile('weixin', profile, members), { type: 'none' })
// unknown platform never uses name fallback
assert.deepEqual(matchOwnerProfile('unknown', profile, members), { type: 'none' })
})
it('matches names with invisible characters and width differences', () => {
const result = matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), [
{ platformId: '\u200EAlice\u200F' },
{ platformId: 'Bob' },
])
assert.deepEqual(result, { type: 'name', platformId: '\u200EAlice\u200F' })
})
it('matches via aliases and groupNickname', () => {
const result = matchOwnerProfile('line', makeProfile({ platformId: 'Allie W', confirmedNames: ['Allie'] }), [
{ platformId: 'Alice', accountName: 'Alice', aliases: ['Allie'] },
{ platformId: 'Bob' },
])
assert.deepEqual(result, { type: 'name', platformId: 'Alice' })
})
it('returns ambiguous when multiple members match', () => {
const result = matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), [
{ platformId: 'u1', accountName: 'Alice' },
{ platformId: 'u2', groupNickname: 'Alice' },
])
assert.equal(result.type, 'ambiguous')
assert.deepEqual(result.type === 'ambiguous' ? result.platformIds : [], ['u1', 'u2'])
})
it('returns none when nothing matches or confirmed names are empty', () => {
const members = [{ platformId: 'Bob' }]
assert.deepEqual(matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), members), {
type: 'none',
})
assert.deepEqual(matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: [' '] }), members), {
type: 'none',
})
})
it('does not use substring containment', () => {
const result = matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), [
{ platformId: 'Alice Smith' },
])
assert.deepEqual(result, { type: 'none' })
})
})
describe('isNameMatchPlatform', () => {
it('only allows whatsapp, line and instagram', () => {
assert.equal(isNameMatchPlatform('whatsapp'), true)
assert.equal(isNameMatchPlatform('line'), true)
assert.equal(isNameMatchPlatform('instagram'), true)
assert.equal(isNameMatchPlatform('weixin'), false)
assert.equal(isNameMatchPlatform('qq'), false)
assert.equal(isNameMatchPlatform('telegram'), false)
assert.equal(isNameMatchPlatform('unknown'), false)
})
})
+127
View File
@@ -0,0 +1,127 @@
/**
* Owner profile matching — platform-independent "who am I" resolution.
*
* Given a platform-level owner profile (stored in preferences) and the member
* list of a session, decide which member is the owner. Matching is strictly
* deterministic: exact platformId first, then normalized-name equality for an
* allowlisted set of platforms whose exports lack stable native IDs.
* No fuzzy matching, no heuristics, no LLM inference.
*/
import type { OwnerProfile } from '@openchatlab/shared-types'
/**
* Platforms whose text exports use display names as platformId, so a
* normalized-name fallback is safe and necessary. All other platforms
* (including 'unknown') match by exact platformId only.
*/
export const NAME_MATCH_PLATFORMS: ReadonlySet<string> = new Set(['whatsapp', 'line', 'instagram'])
export function isNameMatchPlatform(platform: string): boolean {
return NAME_MATCH_PLATFORMS.has(platform)
}
/** Minimal member shape needed for owner matching. */
export interface OwnerMatchCandidate {
platformId: string
accountName?: string | null
groupNickname?: string | null
aliases?: string[] | null
/** Computed display name, if the caller already derived one. */
displayName?: string | null
}
export type OwnerMatchResult =
| { type: 'exact'; platformId: string }
| { type: 'name'; platformId: string }
| { type: 'none' }
| { type: 'ambiguous'; platformIds: string[] }
// Invisible direction/control characters that chat exports commonly embed
// around names (LRM/RLM, directional embeddings/isolates, zero-width chars, BOM).
const INVISIBLE_CHARS_RE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g
/**
* Deterministic name normalization for matching:
* strip invisible chars → NFKC → collapse whitespace → trim → lowercase.
* Stored names keep their original form; normalize only at compare time.
*/
export function normalizeOwnerName(name: string): string {
return name.replace(INVISIBLE_CHARS_RE, '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase()
}
/** Collect all candidate name strings of a member (original, non-normalized, deduped, non-empty). */
export function collectCandidateNames(member: OwnerMatchCandidate): string[] {
const names = [
member.platformId,
member.accountName ?? '',
member.groupNickname ?? '',
...(member.aliases ?? []),
member.displayName ?? '',
]
const result: string[] = []
const seen = new Set<string>()
for (const name of names) {
if (!name || !name.trim()) continue
if (seen.has(name)) continue
seen.add(name)
result.push(name)
}
return result
}
/**
* Merge a member's candidate names into an existing confirmedNames list.
* Keeps original strings; dedupes by exact string equality, preserving order.
*/
export function mergeConfirmedNames(existing: string[], member: OwnerMatchCandidate): string[] {
const result: string[] = []
const seen = new Set<string>()
for (const name of [...existing, ...collectCandidateNames(member)]) {
if (!name || !name.trim()) continue
if (seen.has(name)) continue
seen.add(name)
result.push(name)
}
return result
}
/**
* Match a platform owner profile against a session's members.
*
* 1. Exact platformId match wins immediately.
* 2. Name fallback only for NAME_MATCH_PLATFORMS: a member matches when any
* of its normalized candidate names equals any normalized confirmed name.
* 3. Exactly one member must match; zero → none, multiple → ambiguous.
*/
export function matchOwnerProfile(
platform: string,
profile: OwnerProfile,
members: OwnerMatchCandidate[]
): OwnerMatchResult {
const exact = members.find((m) => m.platformId === profile.platformId)
if (exact) {
return { type: 'exact', platformId: exact.platformId }
}
if (!isNameMatchPlatform(platform)) {
return { type: 'none' }
}
const confirmed = new Set(profile.confirmedNames.map(normalizeOwnerName).filter((n) => n.length > 0))
if (confirmed.size === 0) {
return { type: 'none' }
}
const matched = members.filter((m) =>
collectCandidateNames(m).some((name) => confirmed.has(normalizeOwnerName(name)))
)
if (matched.length === 1) {
return { type: 'name', platformId: matched[0].platformId }
}
if (matched.length > 1) {
return { type: 'ambiguous', platformIds: matched.map((m) => m.platformId) }
}
return { type: 'none' }
}
+4
View File
@@ -68,6 +68,8 @@ const DEFAULTS: Preferences = {
},
filterHistory: [],
thinkingLevels: {},
ownerProfilesByPlatform: {},
ownerPromptDismissedSessionIds: [],
}
export class PreferencesManager {
@@ -152,6 +154,8 @@ export class PreferencesManager {
wordFilter: partial.wordFilter ? { ...DEFAULTS.wordFilter, ...partial.wordFilter } : { ...DEFAULTS.wordFilter },
filterHistory: partial.filterHistory ?? DEFAULTS.filterHistory,
thinkingLevels: partial.thinkingLevels ?? DEFAULTS.thinkingLevels,
ownerProfilesByPlatform: partial.ownerProfilesByPlatform ?? DEFAULTS.ownerProfilesByPlatform,
ownerPromptDismissedSessionIds: partial.ownerPromptDismissedSessionIds ?? DEFAULTS.ownerPromptDismissedSessionIds,
}
}
+37
View File
@@ -185,6 +185,39 @@ export interface FilterHistoryItem {
selectedSessionIds?: number[]
}
export type OwnerMatchMode = 'platform_id' | 'name'
/**
* Platform-level owner identity ("who am I" on this chat platform).
* Stored in preferences.json and shared across sessions of the same platform.
*/
export interface OwnerProfile {
platformId: string
displayName: string
/** Original (non-normalized) names confirmed by the user; normalization happens at match time. */
confirmedNames: string[]
matchMode: OwnerMatchMode
updatedAt: number
}
export type ApplyOwnerProfileReason = 'no_profile' | 'no_match' | 'ambiguous' | 'already_set' | 'missing_session'
export interface ApplyOwnerProfileResult {
applied: boolean
ownerId?: string
reason?: ApplyOwnerProfileReason
/** Whether the user chose "do not remind me" for this session (UI hint only). */
dismissed: boolean
}
export interface SetOwnerAndApplyProfileResult {
sessionId: string
platform: string
ownerId: string
/** Other same-platform sessions auto-filled by the updated profile. */
updatedSessionIds: string[]
}
export interface Preferences {
pinnedSessionIds: string[]
aiPreprocessConfig: AIPreprocessConfig
@@ -199,6 +232,10 @@ export interface Preferences {
filterHistory: FilterHistoryItem[]
/** Per-model thinking level, keyed by `${configId}:${modelId}`. */
thinkingLevels: Record<string, string>
/** Platform-level owner identity, keyed by platform (e.g. 'whatsapp'). */
ownerProfilesByPlatform: Record<string, OwnerProfile>
/** Sessions where the user chose "do not remind me"; suppresses the owner prompt UI only. */
ownerPromptDismissedSessionIds: string[]
}
export interface UiConfig {