diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a4901f27..2b48a6b8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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' diff --git a/packages/core/src/owner/index.ts b/packages/core/src/owner/index.ts new file mode 100644 index 00000000..4138f1d8 --- /dev/null +++ b/packages/core/src/owner/index.ts @@ -0,0 +1,9 @@ +export { + NAME_MATCH_PLATFORMS, + isNameMatchPlatform, + normalizeOwnerName, + collectCandidateNames, + mergeConfirmedNames, + matchOwnerProfile, +} from './owner-matching' +export type { OwnerMatchCandidate, OwnerMatchResult } from './owner-matching' diff --git a/packages/core/src/owner/owner-matching.test.ts b/packages/core/src/owner/owner-matching.test.ts new file mode 100644 index 00000000..12be6703 --- /dev/null +++ b/packages/core/src/owner/owner-matching.test.ts @@ -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 { + 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) + }) +}) diff --git a/packages/core/src/owner/owner-matching.ts b/packages/core/src/owner/owner-matching.ts new file mode 100644 index 00000000..964b57ee --- /dev/null +++ b/packages/core/src/owner/owner-matching.ts @@ -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 = 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() + 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() + 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' } +} diff --git a/packages/node-runtime/src/preferences.ts b/packages/node-runtime/src/preferences.ts index c9605bf0..e612c109 100644 --- a/packages/node-runtime/src/preferences.ts +++ b/packages/node-runtime/src/preferences.ts @@ -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, } } diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 31cdf138..2335984a 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -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 + /** Platform-level owner identity, keyed by platform (e.g. 'whatsapp'). */ + ownerProfilesByPlatform: Record + /** Sessions where the user chose "do not remind me"; suppresses the owner prompt UI only. */ + ownerPromptDismissedSessionIds: string[] } export interface UiConfig {