diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index 19e97d14..fe843727 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -7,10 +7,13 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' import Database from 'better-sqlite3' import Fastify, { type FastifyInstance } from 'fastify' import type { DatabaseAdapter, PathProvider, PreparedStatement, RunResult } from '@openchatlab/core' -import type { DatabaseManager, SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import { PreferencesManager, type DatabaseManager, type SessionRuntimeAdapter } from '@openchatlab/node-runtime' import type { HttpRouteContext } from '../context' import { registerSharedRoutes } from '../register' import { registerRestSessionRoutes } from './sessions' @@ -293,4 +296,64 @@ describe('registerSharedRoutes smoke tests', () => { assert.deepEqual(resp.json(), { success: true }) assert.equal(dbs.has('chat-1'), false) }) + + it('owner profile routes select, apply and dismiss across sessions', async () => { + const prefDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-owner-routes-')) + const current = createSessionDb() + current.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'`) + const other = createSessionDb() + other.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'`) + const dismissedOnly = createSessionDb() + dismissedOnly.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'; DELETE FROM member`) + + const dbs = new Map([ + ['chat-1', current], + ['chat-2', other], + ['chat-3', dismissedOnly], + ]) + const ctx = createTestContext(dbs) + ctx.preferencesManager = new PreferencesManager(prefDir) + const routeApp = Fastify() + registerSessionRoutes(routeApp, ctx) + await routeApp.ready() + + try { + // Dismiss the prompt for chat-3 (no matching member there) + const dismissResp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-3/owner/dismiss-prompt' }) + assert.equal(dismissResp.statusCode, 200) + assert.deepEqual(dismissResp.json(), { success: true }) + + // apply-profile before any profile exists reports no_profile and the dismissed flag + const earlyApply = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-3/owner/apply-profile' }) + assert.equal(earlyApply.statusCode, 200) + assert.deepEqual(earlyApply.json(), { applied: false, reason: 'no_profile', dismissed: true }) + + // Manual selection writes owner, saves profile and batch-applies to chat-2 + const selectResp = await routeApp.inject({ + method: 'POST', + url: '/_web/sessions/chat-1/owner/select', + payload: { ownerPlatformId: 'alice' }, + }) + assert.equal(selectResp.statusCode, 200) + const selectBody = selectResp.json() + assert.equal(selectBody.ownerId, 'alice') + assert.equal(selectBody.platform, 'whatsapp') + assert.deepEqual(selectBody.updatedSessionIds, ['chat-2']) + const currentMeta = current.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string } + const otherMeta = other.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string } + assert.equal(currentMeta.owner_id, 'alice') + assert.equal(otherMeta.owner_id, 'alice') + + // apply-profile on an already-owned session reports already_set + const applyResp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-2/owner/apply-profile' }) + assert.equal(applyResp.statusCode, 200) + assert.deepEqual(applyResp.json(), { applied: false, ownerId: 'alice', reason: 'already_set', dismissed: false }) + } finally { + await routeApp.close() + current.close() + other.close() + dismissedOnly.close() + fs.rmSync(prefDir, { recursive: true, force: true }) + } + }) }) diff --git a/packages/http-routes/src/routes/web/sessions.ts b/packages/http-routes/src/routes/web/sessions.ts index 2ddc7e81..0555514a 100644 --- a/packages/http-routes/src/routes/web/sessions.ts +++ b/packages/http-routes/src/routes/web/sessions.ts @@ -1,10 +1,17 @@ import type { FastifyInstance } from 'fastify' import type { HttpRouteContext } from '../../context' -import { sessionService } from '@openchatlab/node-runtime' +import { sessionService, ownerProfileService, PreferencesManager } from '@openchatlab/node-runtime' export function registerSessionRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { const { sessionAdapter: adapter } = ctx + // Lazy: only owner-profile routes need preferences.json access + let preferencesInstance: PreferencesManager | null = null + const preferences = () => { + preferencesInstance ??= ctx.preferencesManager ?? new PreferencesManager(ctx.pathProvider.getSystemDir()) + return preferencesInstance + } + server.get('/_web/sessions', async () => { return sessionService.listAnalysisSessions(adapter) }) @@ -42,4 +49,29 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: HttpRouteCon return { success: true } } ) + + // Try to auto-apply the stored platform owner profile to this session. + server.post<{ Params: { id: string } }>('/_web/sessions/:id/owner/apply-profile', async (request) => { + return ownerProfileService.tryApplyOwnerProfile(adapter, preferences(), request.params.id) + }) + + // Manually select owner: writes meta.owner_id, updates the platform profile, + // and batch-applies it to other unowned same-platform sessions. + server.post<{ Params: { id: string }; Body: { ownerPlatformId: string } }>( + '/_web/sessions/:id/owner/select', + async (request) => { + return ownerProfileService.setOwnerAndApplyProfile( + adapter, + preferences(), + request.params.id, + request.body.ownerPlatformId + ) + } + ) + + // Suppress the owner prompt for this session (UI-only). + server.post<{ Params: { id: string } }>('/_web/sessions/:id/owner/dismiss-prompt', async (request) => { + ownerProfileService.dismissOwnerPrompt(preferences(), request.params.id) + return { success: true } + }) } diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index fbd34fe5..876a090f 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -404,6 +404,7 @@ export * as memberService from './services/member-service' 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 { createDatabaseManagerAdapter, MergeSessionCache } from './services' export type { SessionRuntimeAdapter, @@ -412,4 +413,7 @@ export type { MembersPaginatedDTO, LlmConfig, SummaryServiceDeps, + ApplyOwnerProfileReason, + ApplyOwnerProfileResult, + SetOwnerAndApplyProfileResult, } from './services' diff --git a/packages/node-runtime/src/services/index.ts b/packages/node-runtime/src/services/index.ts index 30cb6365..c7804f7d 100644 --- a/packages/node-runtime/src/services/index.ts +++ b/packages/node-runtime/src/services/index.ts @@ -23,6 +23,19 @@ export { } from './member-service' export type { MembersPaginatedDTO } from './member-service' +// Owner profile service +export { + tryApplyOwnerProfile, + setOwnerAndApplyProfile, + dismissOwnerPrompt, + clearSessionOwner, +} from './owner-profile-service' +export type { + ApplyOwnerProfileReason, + ApplyOwnerProfileResult, + SetOwnerAndApplyProfileResult, +} from './owner-profile-service' + // Session index service export { generateIndex, diff --git a/packages/node-runtime/src/services/owner-profile-service.test.ts b/packages/node-runtime/src/services/owner-profile-service.test.ts new file mode 100644 index 00000000..a75b4cd0 --- /dev/null +++ b/packages/node-runtime/src/services/owner-profile-service.test.ts @@ -0,0 +1,314 @@ +/** + * Integration tests for the owner profile service, against real SQLite + * session databases and a real preferences.json file. + * + * Run: pnpm test -- packages/node-runtime/src/services/owner-profile-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, getSessionMeta } from '@openchatlab/core' +import type { DatabaseAdapter } from '@openchatlab/core' +import { openBetterSqliteDatabase } from '../better-sqlite3-adapter' +import { PreferencesManager } from '../preferences' +import type { SessionRuntimeAdapter } from './adapters' +import { + tryApplyOwnerProfile, + setOwnerAndApplyProfile, + dismissOwnerPrompt, + clearSessionOwner, +} from './owner-profile-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-owner-profile-')) +} + +interface SeedMember { + platformId: string + accountName?: string + groupNickname?: string + aliases?: string[] +} + +interface SeedSession { + id: string + platform: string + type?: 'group' | 'private' + ownerId?: string | null + members: SeedMember[] +} + +class TestEnv { + readonly dir: string + readonly preferences: PreferencesManager + readonly adapter: SessionRuntimeAdapter + private dbPaths = new Map() + private openDbs: DatabaseAdapter[] = [] + + constructor() { + this.dir = makeTempDir() + this.preferences = new PreferencesManager(this.dir) + 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 ?? 'group', + 1780000000, + session.ownerId ?? null + ) + for (const member of session.members) { + db.prepare(`INSERT INTO member (platform_id, account_name, group_nickname, aliases) VALUES (?, ?, ?, ?)`).run( + member.platformId, + member.accountName ?? null, + member.groupNickname ?? null, + JSON.stringify(member.aliases ?? []) + ) + } + db.close() + this.dbPaths.set(session.id, dbPath) + } + + ownerOf(sessionId: string): string | null { + const db = this.adapter.ensureReadonly(sessionId) + return getSessionMeta(db)?.ownerId ?? null + } + + cleanup(): void { + for (const db of this.openDbs) { + try { + db.close() + } catch { + // already closed + } + } + fs.rmSync(this.dir, { recursive: true, force: true }) + } +} + +test('setOwnerAndApplyProfile writes owner, saves platform profile and merges names', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 's1', + platform: 'whatsapp', + members: [ + { platformId: 'Alice', accountName: 'Alice', groupNickname: 'Ali', aliases: ['Allie'] }, + { platformId: 'Bob' }, + ], + }) + + const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice') + assert.equal(result.ownerId, 'Alice') + assert.equal(result.platform, 'whatsapp') + assert.deepEqual(result.updatedSessionIds, []) + assert.equal(env.ownerOf('s1'), 'Alice') + + env.preferences.invalidateCache() + const profile = env.preferences.load().ownerProfilesByPlatform['whatsapp'] + assert.ok(profile) + assert.equal(profile.platformId, 'Alice') + assert.equal(profile.matchMode, 'name') + assert.deepEqual(profile.confirmedNames, ['Alice', 'Ali', 'Allie']) +}) + +test('setOwnerAndApplyProfile batch-applies to unowned same-platform sessions only', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 'current', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] }) + // Name fallback match (different platformId, same normalized name) + env.seed({ id: 'name-match', platform: 'whatsapp', members: [{ platformId: 'alice' }, { platformId: 'Carol' }] }) + // Already owned: never overridden + env.seed({ id: 'owned', platform: 'whatsapp', ownerId: 'Bob', members: [{ platformId: 'Alice' }] }) + // Different platform: untouched + env.seed({ id: 'other-platform', platform: 'telegram', members: [{ platformId: 'Alice' }] }) + // Ambiguous: two members normalize to the same confirmed name + env.seed({ + id: 'ambiguous', + platform: 'whatsapp', + members: [{ platformId: 'alice' }, { platformId: 'u2', accountName: 'ALICE' }], + }) + // No match + env.seed({ id: 'no-match', platform: 'whatsapp', members: [{ platformId: 'Dave' }] }) + + const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'current', 'Alice') + assert.deepEqual(result.updatedSessionIds, ['name-match']) + assert.equal(env.ownerOf('name-match'), 'alice') + assert.equal(env.ownerOf('owned'), 'Bob') + assert.equal(env.ownerOf('other-platform'), null) + assert.equal(env.ownerOf('ambiguous'), null) + assert.equal(env.ownerOf('no-match'), null) +}) + +test('re-selecting a different member replaces the profile instead of merging names', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] }) + + setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice') + setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Bob') + + env.preferences.invalidateCache() + const profile = env.preferences.load().ownerProfilesByPlatform['whatsapp'] + assert.equal(profile.platformId, 'Bob') + assert.deepEqual(profile.confirmedNames, ['Bob']) +}) + +test('setOwnerAndApplyProfile rejects a platformId that is not a session member', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + assert.throws(() => setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Nobody'), /Member not found/) +}) + +test('tryApplyOwnerProfile applies stored profile and reports reasons', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 'source', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + env.seed({ id: 'unowned', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] }) + env.seed({ id: 'no-profile', platform: 'telegram', members: [{ platformId: 'Alice' }] }) + + // No profile saved yet + assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'unowned'), { + applied: false, + reason: 'no_profile', + dismissed: false, + }) + + setOwnerAndApplyProfile(env.adapter, env.preferences, 'source', 'Alice') + // 'unowned' was already auto-filled by batch apply + assert.equal(env.ownerOf('unowned'), 'Alice') + assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'unowned'), { + applied: false, + ownerId: 'Alice', + reason: 'already_set', + dismissed: false, + }) + + // New session imported later: profile applies on demand + env.seed({ id: 'later', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Carol' }] }) + const applied = tryApplyOwnerProfile(env.adapter, env.preferences, 'later') + assert.deepEqual(applied, { applied: true, ownerId: 'Alice', dismissed: false }) + assert.equal(env.ownerOf('later'), 'Alice') + + assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'no-profile'), { + applied: false, + reason: 'no_profile', + dismissed: false, + }) + assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 'missing').reason, 'missing_session') +}) + +test('dismissOwnerPrompt persists and is cleared by manual owner selection', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + + dismissOwnerPrompt(env.preferences, 's1') + dismissOwnerPrompt(env.preferences, 's1') + env.preferences.invalidateCache() + assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, ['s1']) + assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 's1').dismissed, true) + + setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice') + env.preferences.invalidateCache() + assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, []) +}) + +test('batch apply removes auto-filled sessions from the dismissed list', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 'current', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + env.seed({ id: 'other', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] }) + + dismissOwnerPrompt(env.preferences, 'other') + const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'current', 'Alice') + assert.deepEqual(result.updatedSessionIds, ['other']) + + env.preferences.invalidateCache() + assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, []) +}) + +test('clearSessionOwner clears the session owner but keeps the platform profile', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice') + assert.equal(env.ownerOf('s1'), 'Alice') + + clearSessionOwner(env.adapter, 's1') + assert.equal(env.ownerOf('s1'), null) + + env.preferences.invalidateCache() + assert.ok(env.preferences.load().ownerProfilesByPlatform['whatsapp']) +}) + +test('exact platformId match works on platforms without name fallback', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 'source', platform: 'weixin', members: [{ platformId: 'wx_me', accountName: 'Me' }] }) + env.seed({ + id: 'other', + platform: 'weixin', + members: [ + { platformId: 'wx_me', accountName: 'Renamed' }, + { platformId: 'wx_other', accountName: 'Me' }, + ], + }) + + const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'source', 'wx_me') + assert.equal(result.platform, 'weixin') + assert.deepEqual(result.updatedSessionIds, ['other']) + assert.equal(env.ownerOf('other'), 'wx_me') + + env.preferences.invalidateCache() + assert.equal(env.preferences.load().ownerProfilesByPlatform['weixin'].matchMode, 'platform_id') + + // Name-only coincidence must NOT match on weixin + env.seed({ id: 'name-only', platform: 'weixin', members: [{ platformId: 'wx_x', accountName: 'Me' }] }) + assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 'name-only').reason, 'no_match') +}) diff --git a/packages/node-runtime/src/services/owner-profile-service.ts b/packages/node-runtime/src/services/owner-profile-service.ts new file mode 100644 index 00000000..96292fec --- /dev/null +++ b/packages/node-runtime/src/services/owner-profile-service.ts @@ -0,0 +1,177 @@ +/** + * Shared owner profile service. + * + * Manages the platform-level "who am I" identity: writes per-session + * meta.owner_id, maintains ownerProfilesByPlatform in preferences.json, and + * applies a confirmed profile to other unowned sessions of the same platform. + * Used by both CLI Web routes and Electron IPC. + */ + +import { + getSessionMeta, + getMembersWithAliases, + isChatSessionDb, + updateSessionOwnerId as coreUpdateSessionOwnerId, + matchOwnerProfile, + mergeConfirmedNames, + isNameMatchPlatform, +} from '@openchatlab/core' +import type { MemberWithAliases } from '@openchatlab/core' +import type { + OwnerProfile, + ApplyOwnerProfileReason, + ApplyOwnerProfileResult, + SetOwnerAndApplyProfileResult, +} from '@openchatlab/shared-types' +import type { PreferencesManager } from '../preferences' +import type { SessionRuntimeAdapter } from './adapters' + +export type { ApplyOwnerProfileReason, ApplyOwnerProfileResult, SetOwnerAndApplyProfileResult } + +function memberDisplayName(member: MemberWithAliases): string { + return member.groupNickname || member.accountName || member.platformId +} + +function isDismissed(preferences: PreferencesManager, sessionId: string): boolean { + return preferences.load().ownerPromptDismissedSessionIds.includes(sessionId) +} + +/** + * Try to apply the stored platform owner profile to one session. + * Writes meta.owner_id only on a unique (exact or name) match. + * Never overrides an existing owner. + */ +export function tryApplyOwnerProfile( + adapter: SessionRuntimeAdapter, + preferences: PreferencesManager, + sessionId: string +): ApplyOwnerProfileResult { + const dismissed = isDismissed(preferences, sessionId) + + const db = adapter.openReadonly(sessionId) + if (!db || !isChatSessionDb(db)) { + return { applied: false, reason: 'missing_session', dismissed } + } + const meta = getSessionMeta(db) + if (!meta) { + return { applied: false, reason: 'missing_session', dismissed } + } + if (meta.ownerId) { + return { applied: false, ownerId: meta.ownerId, reason: 'already_set', dismissed } + } + + const profile = preferences.load().ownerProfilesByPlatform[meta.platform] + if (!profile) { + return { applied: false, reason: 'no_profile', dismissed } + } + + const result = matchOwnerProfile(meta.platform, profile, getMembersWithAliases(db)) + if (result.type === 'exact' || result.type === 'name') { + const writable = adapter.ensureWritable(sessionId) + coreUpdateSessionOwnerId(writable, result.platformId) + return { applied: true, ownerId: result.platformId, dismissed } + } + return { applied: false, reason: result.type === 'ambiguous' ? 'ambiguous' : 'no_match', dismissed } +} + +/** + * Manually set the owner of one session, update the platform profile, and + * batch-apply the profile to other unowned sessions of the same platform. + * + * Re-selecting the same identity merges new names into confirmedNames; + * selecting a different member replaces the profile (names start fresh, + * so the previous person's names cannot cause wrong matches). + */ +export function setOwnerAndApplyProfile( + adapter: SessionRuntimeAdapter, + preferences: PreferencesManager, + sessionId: string, + ownerPlatformId: string +): SetOwnerAndApplyProfileResult { + const db = adapter.ensureWritable(sessionId) + const meta = getSessionMeta(db) + if (!meta) { + throw Object.assign(new Error(`Session has no meta: ${sessionId}`), { statusCode: 404 }) + } + + const members = getMembersWithAliases(db) + const selected = members.find((m) => m.platformId === ownerPlatformId) + if (!selected) { + throw Object.assign(new Error(`Member not found in session: ${ownerPlatformId}`), { statusCode: 400 }) + } + + coreUpdateSessionOwnerId(db, ownerPlatformId) + + const prefs = preferences.load() + const existing = prefs.ownerProfilesByPlatform[meta.platform] + const baseNames = existing && existing.platformId === ownerPlatformId ? existing.confirmedNames : [] + const profile: OwnerProfile = { + platformId: ownerPlatformId, + displayName: memberDisplayName(selected), + confirmedNames: mergeConfirmedNames(baseNames, { ...selected, displayName: memberDisplayName(selected) }), + matchMode: isNameMatchPlatform(meta.platform) ? 'name' : 'platform_id', + updatedAt: Date.now(), + } + + const updatedSessionIds = applyProfileToOtherSessions(adapter, meta.platform, profile, sessionId) + + const noLongerDismissed = new Set([sessionId, ...updatedSessionIds]) + preferences.save({ + ownerProfilesByPlatform: { ...prefs.ownerProfilesByPlatform, [meta.platform]: profile }, + ownerPromptDismissedSessionIds: prefs.ownerPromptDismissedSessionIds.filter((id) => !noLongerDismissed.has(id)), + }) + + return { sessionId, platform: meta.platform, ownerId: ownerPlatformId, updatedSessionIds } +} + +/** + * Apply a profile to all other unowned sessions of the same platform. + * Unique match only; sessions with an existing owner are never touched. + */ +function applyProfileToOtherSessions( + adapter: SessionRuntimeAdapter, + platform: string, + profile: OwnerProfile, + excludeSessionId: string +): string[] { + const updated: string[] = [] + for (const id of adapter.listSessionIds()) { + if (id === excludeSessionId) continue + try { + const db = adapter.openReadonly(id) + if (!db || !isChatSessionDb(db)) continue + const meta = getSessionMeta(db) + if (!meta || meta.platform !== platform || meta.ownerId) continue + + const result = matchOwnerProfile(platform, profile, getMembersWithAliases(db)) + if (result.type === 'exact' || result.type === 'name') { + coreUpdateSessionOwnerId(adapter.ensureWritable(id), result.platformId) + updated.push(id) + } + } catch (err) { + console.warn(`[OwnerProfile] Failed to apply profile to session ${id}:`, err) + } + } + return updated +} + +/** + * Suppress the owner prompt for one session. UI-only: does not imply owner + * knowledge and does not block later automatic profile application. + */ +export function dismissOwnerPrompt(preferences: PreferencesManager, sessionId: string): void { + const prefs = preferences.load() + if (prefs.ownerPromptDismissedSessionIds.includes(sessionId)) return + preferences.save({ + ownerPromptDismissedSessionIds: [...prefs.ownerPromptDismissedSessionIds, sessionId], + }) +} + +/** + * Clear the owner of one session only. The platform profile is kept and may + * re-apply automatically later if it still uniquely matches. + */ +export function clearSessionOwner(adapter: SessionRuntimeAdapter, sessionId: string): void { + const db = adapter.ensureWritable(sessionId) + coreUpdateSessionOwnerId(db, null) +}