mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(owner): add owner profile service and shared web routes
Shared node-runtime service writes session owner, maintains the
per-platform profile in preferences.json, and batch-applies it to
other unowned same-platform sessions (unique match only, never
overriding an existing owner). Exposed via /_web/sessions/:id/owner/
{apply-profile,select,dismiss-prompt} for both Electron and CLI Web.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7589515eeb
commit
a75d17e310
6 files changed
+605
-2
No files matched your search
@@ -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<string, DatabaseAdapter>([
|
||||
['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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>()
|
||||
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')
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user