From e563183e624fe54dbbca76981bcb8180b74eaf4c Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 16 Jul 2026 14:05:40 +0800 Subject: [PATCH] refactor(http): share system and session routes --- apps/desktop/main/api/index.ts | 14 +- .../desktop/main/api/rest-session-provider.ts | 141 +++++++++++++ apps/desktop/main/api/routes/sessions.ts | 187 ---------------- apps/desktop/main/api/routes/system.ts | 91 -------- packages/http-routes/package.json | 3 +- packages/http-routes/src/context.ts | 3 + packages/http-routes/src/index.ts | 11 + packages/http-routes/src/register.ts | 10 +- packages/http-routes/src/rest.ts | 13 ++ .../src/routes/rest-session-provider.test.ts | 101 +++++++++ .../src/routes/rest-session-provider.ts | 199 ++++++++++++++++++ packages/http-routes/src/routes/sessions.ts | 145 +++---------- .../src/routes/shared-routes.test.ts | 7 +- packages/http-routes/src/routes/system.ts | 10 +- 14 files changed, 535 insertions(+), 400 deletions(-) create mode 100644 apps/desktop/main/api/rest-session-provider.ts delete mode 100644 apps/desktop/main/api/routes/sessions.ts delete mode 100644 apps/desktop/main/api/routes/system.ts create mode 100644 packages/http-routes/src/rest.ts create mode 100644 packages/http-routes/src/routes/rest-session-provider.test.ts create mode 100644 packages/http-routes/src/routes/rest-session-provider.ts diff --git a/apps/desktop/main/api/index.ts b/apps/desktop/main/api/index.ts index 219b9581..e7b7b877 100644 --- a/apps/desktop/main/api/index.ts +++ b/apps/desktop/main/api/index.ts @@ -6,12 +6,14 @@ */ import type { FastifyInstance } from 'fastify' +import { app } from 'electron' +import { registerSystemRoutes, registerRestSessionRoutes } from '@openchatlab/http-routes/rest' import { createServer } from './server' -import { registerSystemRoutes } from './routes/system' -import { registerSessionRoutes } from './routes/sessions' import { registerImportRoutes } from './routes/import' +import { createDesktopRestSessionProvider } from './rest-session-provider' import { apiLogger } from './logger' import type { ConfigManager, ApiServerConfig } from '@openchatlab/sync' +import { getDesktopAppVersion } from '../runtime/compat' let server: FastifyInstance | null = null let startedAt: number | null = null @@ -64,8 +66,12 @@ export async function start(): Promise { const port = preferredPort + offset try { server = createServer() - registerSystemRoutes(server) - registerSessionRoutes(server) + const restSessionProvider = createDesktopRestSessionProvider() + registerSystemRoutes(server, { + getVersion: () => getDesktopAppVersion(app.getVersion()), + countSessions: restSessionProvider.countSessions, + }) + registerRestSessionRoutes(server, restSessionProvider) registerImportRoutes(server) await server.listen({ port, host: '127.0.0.1' }) diff --git a/apps/desktop/main/api/rest-session-provider.ts b/apps/desktop/main/api/rest-session-provider.ts new file mode 100644 index 00000000..4cc6ef94 --- /dev/null +++ b/apps/desktop/main/api/rest-session-provider.ts @@ -0,0 +1,141 @@ +import type { + RestMessageQuery, + RestSessionDetail, + RestSessionExportData, + RestSessionOverview, + RestSessionProvider, + RestSessionSummary, +} from '@openchatlab/http-routes/rest' +import * as worker from '../worker/workerManager' + +interface DesktopSessionData { + id: string + name: string + platform: string + type: string + groupId?: string | null + messageCount: number + memberCount: number + firstTimestamp?: number | null + lastTimestamp?: number | null + firstMessageTs?: number | null + lastMessageTs?: number | null + lastPlatformMessageId?: string | null + importedAt?: number +} + +interface DesktopMemberActivity { + platformId: string + name: string + messageCount: number + percentage: number +} + +interface DesktopMessageTypeStat { + type: number + count: number +} + +function toSummary(session: DesktopSessionData): RestSessionSummary { + return { + id: session.id, + name: session.name, + platform: session.platform, + type: session.type, + groupId: session.groupId || undefined, + messageCount: session.messageCount, + memberCount: session.memberCount, + firstTimestamp: session.firstTimestamp ?? session.firstMessageTs ?? null, + lastTimestamp: session.lastTimestamp ?? session.lastMessageTs ?? null, + importedAt: session.importedAt, + } +} + +function toDetail(session: DesktopSessionData): RestSessionDetail { + return { + ...toSummary(session), + lastPlatformMessageId: session.lastPlatformMessageId ?? null, + importedAt: session.importedAt ?? 0, + } +} + +async function getSession(sessionId: string): Promise { + const session = (await worker.getSession(sessionId)) as DesktopSessionData | null + return session ? toDetail(session) : null +} + +async function getMessages(sessionId: string, query: RestMessageQuery) { + const session = await getSession(sessionId) + if (!session) return null + + const filter = + query.startTime !== undefined || query.endTime !== undefined + ? { startTs: query.startTime, endTs: query.endTime } + : undefined + const result = await worker.searchMessages( + sessionId, + query.keyword ? [query.keyword] : [], + filter, + query.limit, + query.offset, + query.senderId + ) + return { ...result, totalPages: Math.ceil(result.total / query.limit) } +} + +export function createDesktopRestSessionProvider(): RestSessionProvider { + return { + countSessions: async () => (await worker.getAllSessions()).length, + listSessions: async () => ((await worker.getAllSessions()) as DesktopSessionData[]).map(toSummary), + getSession, + queryMessages: getMessages, + getMembers: async (sessionId) => { + if (!(await getSession(sessionId))) return null + return worker.getMembers(sessionId) + }, + getOverview: async (sessionId): Promise => { + const session = await getSession(sessionId) + if (!session) return null + + const [timeRange, memberActivity, typeDistribution] = await Promise.all([ + worker.getTimeRange(sessionId), + worker.getMemberActivity(sessionId) as Promise, + worker.getMessageTypeDistribution(sessionId) as Promise, + ]) + const messageTypeDistribution: Record = {} + for (const item of typeDistribution) { + messageTypeDistribution[String(item.type)] = item.count + } + + return { + messageCount: session.messageCount, + memberCount: session.memberCount, + timeRange: timeRange ?? { start: 0, end: 0 }, + messageTypeDistribution, + topMembers: memberActivity.slice(0, 10).map((member) => ({ + platformId: member.platformId, + name: member.name, + messageCount: member.messageCount, + percentage: member.percentage, + })), + } + }, + executeReadonlySql: async (sessionId, sql) => { + if (!(await getSession(sessionId))) return null + return worker.executeRawSQL(sessionId, sql) + }, + getExportData: async (sessionId, limit): Promise => { + const session = await getSession(sessionId) + if (!session) return null + + const [members, messagePage] = await Promise.all([ + worker.getMembers(sessionId), + worker.searchMessages(sessionId, [], undefined, limit, 0), + ]) + return { + members, + messages: messagePage.messages, + } + }, + } +} diff --git a/apps/desktop/main/api/routes/sessions.ts b/apps/desktop/main/api/routes/sessions.ts deleted file mode 100644 index 09e0c507..00000000 --- a/apps/desktop/main/api/routes/sessions.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * ChatLab API — Session and export routes - */ - -import type { FastifyInstance } from 'fastify' -import { - successResponse, - sessionNotFound, - exportTooLarge, - sqlExecutionError, - ApiError, - errorResponse, -} from '@openchatlab/http-routes/errors' -import * as worker from '../../worker/workerManager' - -const EXPORT_MESSAGE_LIMIT = 100_000 - -async function ensureSession(sessionId: string) { - const session = await worker.getSession(sessionId) - if (!session) throw sessionNotFound(sessionId) - return session -} - -export function registerSessionRoutes(server: FastifyInstance): void { - // GET /api/v1/sessions — List all sessions - server.get('/api/v1/sessions', async () => { - const sessions = await worker.getAllSessions() - return successResponse(sessions) - }) - - // GET /api/v1/sessions/:id — Single session detail - server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => { - const session = await ensureSession(request.params.id) - return successResponse(session) - }) - - // GET /api/v1/sessions/:id/messages — Query messages (paginated) - server.get<{ - Params: { id: string } - Querystring: { - page?: string - limit?: string - startTime?: string - endTime?: string - keyword?: string - senderId?: string - type?: string - } - }>('/api/v1/sessions/:id/messages', async (request) => { - const { id } = request.params - await ensureSession(id) - - const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1) - const limit = Math.min(1000, Math.max(1, parseInt(request.query.limit || '100', 10) || 100)) - const offset = (page - 1) * limit - - const { startTime, endTime, keyword, senderId } = request.query - - const filter: any = {} - if (startTime) filter.startTs = parseInt(startTime, 10) - if (endTime) filter.endTs = parseInt(endTime, 10) - const hasFilter = filter.startTs || filter.endTs - - const keywords = keyword ? [keyword] : [] - const senderIdNum = senderId ? parseInt(senderId, 10) : undefined - - const result = await worker.searchMessages(id, keywords, hasFilter ? filter : undefined, limit, offset, senderIdNum) - - return successResponse({ - messages: result.messages, - total: result.total, - page, - limit, - totalPages: Math.ceil(result.total / limit), - }) - }) - - // GET /api/v1/sessions/:id/members — Member list - server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => { - await ensureSession(request.params.id) - const members = await worker.getMembers(request.params.id) - return successResponse(members) - }) - - // GET /api/v1/sessions/:id/stats/overview — Overview statistics - server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/stats/overview', async (request) => { - const { id } = request.params - const session = await ensureSession(id) - - const [timeRange, memberActivity, typeDistribution] = await Promise.all([ - worker.getTimeRange(id), - worker.getMemberActivity(id), - worker.getMessageTypeDistribution(id), - ]) - - const typeMap: Record = {} - for (const item of typeDistribution) { - typeMap[String(item.type)] = item.count - } - - const topMembers = memberActivity.slice(0, 10).map((m: any) => ({ - platformId: m.platformId, - name: m.name, - messageCount: m.messageCount, - percentage: m.percentage, - })) - - return successResponse({ - messageCount: session.messageCount, - memberCount: session.memberCount, - timeRange: timeRange || { start: 0, end: 0 }, - messageTypeDistribution: typeMap, - topMembers, - }) - }) - - // POST /api/v1/sessions/:id/sql — Execute SQL (read-only) - server.post<{ Params: { id: string }; Body: { sql: string } }>('/api/v1/sessions/:id/sql', async (request, reply) => { - const { id } = request.params - await ensureSession(id) - - const { sql } = request.body || {} - if (!sql || typeof sql !== 'string') { - const err = sqlExecutionError('Missing sql parameter') - return reply.code(err.statusCode).send(errorResponse(err)) - } - - try { - const result = await worker.executeRawSQL(id, sql) - return successResponse(result) - } catch (err: any) { - const message = err.message || 'SQL execution error' - if (message.includes('SELECT') || message.includes('只读') || message.includes('readonly')) { - const apiErr = new ApiError('SQL_READONLY_VIOLATION' as any, message) - apiErr.statusCode = 400 - return reply.code(400).send(errorResponse(apiErr)) - } - const apiErr = sqlExecutionError(message) - return reply.code(apiErr.statusCode).send(errorResponse(apiErr)) - } - }) - - // GET /api/v1/sessions/:id/export — Export ChatLab Format JSON - server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/export', async (request, reply) => { - const { id } = request.params - const session = await ensureSession(id) - - if (session.messageCount > EXPORT_MESSAGE_LIMIT) { - const err = exportTooLarge(session.messageCount, EXPORT_MESSAGE_LIMIT) - return reply.code(err.statusCode).send(errorResponse(err)) - } - - const [members, messagesResult] = await Promise.all([ - worker.getMembers(id), - worker.searchMessages(id, [], undefined, EXPORT_MESSAGE_LIMIT, 0), - ]) - - const chatLabFormat = { - chatlab: { - version: '0.0.2', - exportedAt: Math.floor(Date.now() / 1000), - generator: 'ChatLab API', - }, - meta: { - name: session.name, - platform: session.platform, - type: session.type, - groupId: session.groupId || undefined, - }, - members: members.map((m: any) => ({ - platformId: m.platformId, - accountName: m.accountName || m.platformId, - groupNickname: m.groupNickname || undefined, - aliases: Array.isArray(m.aliases) && m.aliases.length > 0 ? m.aliases : undefined, - })), - messages: messagesResult.messages.map((msg: any) => ({ - sender: msg.senderPlatformId, - accountName: msg.senderName || undefined, - timestamp: msg.timestamp, - type: msg.type, - content: msg.content || null, - })), - } - - return successResponse(chatLabFormat) - }) -} diff --git a/apps/desktop/main/api/routes/system.ts b/apps/desktop/main/api/routes/system.ts deleted file mode 100644 index f4cd37e1..00000000 --- a/apps/desktop/main/api/routes/system.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * ChatLab API — System routes - * GET /api/v1/status Service status - * GET /api/v1/schema ChatLab Format JSON Schema - */ - -import type { FastifyInstance } from 'fastify' -import { app } from 'electron' -import { successResponse } from '@openchatlab/http-routes/errors' -import * as worker from '../../worker/workerManager' -import { getDesktopAppVersion } from '../../runtime/compat' - -export function registerSystemRoutes(server: FastifyInstance): void { - server.get('/api/v1/status', async () => { - let sessionCount = 0 - try { - const sessions = await worker.getAllSessions() - sessionCount = sessions.length - } catch { - // ignore when worker not ready - } - - return successResponse({ - name: 'ChatLab API', - version: getDesktopAppVersion(app.getVersion()), - uptime: Math.floor(process.uptime()), - sessionCount, - }) - }) - - server.get('/api/v1/schema', async () => { - return successResponse({ - format: 'ChatLab Format', - version: '0.0.2', - spec: { - chatlab: { - type: 'object', - required: ['version'], - properties: { - version: { type: 'string' }, - exportedAt: { type: 'number' }, - generator: { type: 'string' }, - }, - }, - meta: { - type: 'object', - required: ['name', 'platform', 'type'], - properties: { - name: { type: 'string' }, - platform: { - type: 'string', - enum: ['qq', 'wechat', 'telegram', 'discord', 'line', 'whatsapp', 'instagram', 'unknown'], - }, - type: { type: 'string', enum: ['group', 'private'] }, - groupId: { type: 'string' }, - }, - }, - members: { - type: 'array', - items: { - type: 'object', - required: ['platformId', 'accountName'], - properties: { - platformId: { type: 'string' }, - accountName: { type: 'string' }, - groupNickname: { type: 'string' }, - avatar: { type: 'string' }, - }, - }, - }, - messages: { - type: 'array', - items: { - type: 'object', - required: ['sender', 'timestamp', 'type'], - properties: { - platformMessageId: { type: 'string' }, - sender: { type: 'string' }, - accountName: { type: 'string' }, - groupNickname: { type: 'string' }, - timestamp: { type: 'number' }, - type: { type: 'number' }, - content: { type: ['string', 'null'] }, - replyToMessageId: { type: 'string' }, - }, - }, - }, - }, - }) - }) -} diff --git a/packages/http-routes/package.json b/packages/http-routes/package.json index 7f0916f9..016b4b3c 100644 --- a/packages/http-routes/package.json +++ b/packages/http-routes/package.json @@ -9,7 +9,8 @@ "exports": { ".": "./src/index.ts", "./auth": "./src/auth.ts", - "./errors": "./src/errors.ts" + "./errors": "./src/errors.ts", + "./rest": "./src/rest.ts" }, "dependencies": { "@openchatlab/config": "workspace:*", diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts index 87524bf5..75036aae 100644 --- a/packages/http-routes/src/context.ts +++ b/packages/http-routes/src/context.ts @@ -30,6 +30,7 @@ import type { GlobalInsightService, } from '@openchatlab/node-runtime' import type { RuntimeIdentity } from '@openchatlab/node-runtime' +import type { RestSessionProvider } from './routes/rest-session-provider' export interface HttpRouteContext { dbManager: DatabaseManager @@ -38,6 +39,8 @@ export interface HttpRouteContext { getVersion: () => string runtimeIdentity?: RuntimeIdentity + /** Public REST session data access. Electron External API injects a Worker-backed provider. */ + restSessionProvider?: RestSessionProvider /** Native binding path for better-sqlite3 (CLI native copy / Electron-ABI desktop copy) */ nativeBinding?: string diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts index fc0a7094..52f73593 100644 --- a/packages/http-routes/src/index.ts +++ b/packages/http-routes/src/index.ts @@ -36,7 +36,18 @@ export type { AiToolExecutionDeps } from './ai/tool-executor' // Individual route registration for granular testing or selective registration export { registerSystemRoutes } from './routes/system' +export type { SystemRouteContext } from './routes/system' export { registerRestSessionRoutes } from './routes/sessions' +export { createDatabaseRestSessionProvider } from './routes/rest-session-provider' +export type { + RestMessageQuery, + RestMessagePage, + RestSessionDetail, + RestSessionExportData, + RestSessionOverview, + RestSessionProvider, + RestSessionSummary, +} from './routes/rest-session-provider' export { registerSessionRoutes } from './routes/web/sessions' export { registerMemberRoutes } from './routes/web/members' export { registerPreferencesRoutes } from './routes/web/preferences' diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts index 40dfbdf4..255867e4 100644 --- a/packages/http-routes/src/register.ts +++ b/packages/http-routes/src/register.ts @@ -10,6 +10,7 @@ import type { HttpRouteContext } from './context' import { PreferencesManager } from '@openchatlab/node-runtime' import { registerSystemRoutes } from './routes/system' import { registerRestSessionRoutes } from './routes/sessions' +import { createDatabaseRestSessionProvider } from './routes/rest-session-provider' import { registerImportRoutes } from './routes/imports' import { registerSessionRoutes } from './routes/web/sessions' import { registerMemberRoutes } from './routes/web/members' @@ -54,8 +55,13 @@ export function registerSharedRoutes( : { ...ctx, preferencesManager: new PreferencesManager(ctx.pathProvider.getSystemDir()) } // REST API (/api/v1/*) - registerSystemRoutes(server, resolvedCtx) - registerRestSessionRoutes(server, resolvedCtx) + const restSessionProvider = + resolvedCtx.restSessionProvider ?? createDatabaseRestSessionProvider(resolvedCtx.dbManager) + registerSystemRoutes(server, { + getVersion: resolvedCtx.getVersion, + countSessions: restSessionProvider.countSessions, + }) + registerRestSessionRoutes(server, restSessionProvider) registerImportRoutes(server, resolvedCtx) // Web UI API (/_web/*) diff --git a/packages/http-routes/src/rest.ts b/packages/http-routes/src/rest.ts new file mode 100644 index 00000000..ef27159e --- /dev/null +++ b/packages/http-routes/src/rest.ts @@ -0,0 +1,13 @@ +export type { + RestMessageQuery, + RestMessagePage, + RestSessionDetail, + RestSessionExportData, + RestSessionOverview, + RestSessionProvider, + RestSessionSummary, +} from './routes/rest-session-provider' +export { createDatabaseRestSessionProvider } from './routes/rest-session-provider' +export { registerSystemRoutes } from './routes/system' +export type { SystemRouteContext } from './routes/system' +export { registerRestSessionRoutes } from './routes/sessions' diff --git a/packages/http-routes/src/routes/rest-session-provider.test.ts b/packages/http-routes/src/routes/rest-session-provider.test.ts new file mode 100644 index 00000000..5fbcca82 --- /dev/null +++ b/packages/http-routes/src/routes/rest-session-provider.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import Fastify from 'fastify' +import type { RestSessionProvider } from './rest-session-provider' +import { registerRestSessionRoutes } from './sessions' +import { registerSystemRoutes } from './system' + +const session = { + id: 'worker-session', + name: 'Worker Session', + platform: 'wechat', + type: 'group', + groupId: 'group-1', + messageCount: 2, + memberCount: 1, + firstTimestamp: 100, + lastTimestamp: 200, + lastPlatformMessageId: 'message-2', + importedAt: 300, +} + +function createAsyncProvider(): RestSessionProvider { + return { + countSessions: async () => 1, + listSessions: async () => [session], + getSession: async (sessionId) => (sessionId === session.id ? session : null), + queryMessages: async (sessionId) => + sessionId === session.id + ? { + messages: [{ id: 1, content: 'hello' }], + total: 2, + } + : null, + getMembers: async (sessionId) => (sessionId === session.id ? [{ platformId: 'alice', aliases: ['Alice'] }] : null), + getOverview: async (sessionId) => + sessionId === session.id + ? { + messageCount: 2, + memberCount: 1, + timeRange: { start: 100, end: 200 }, + messageTypeDistribution: { '0': 2 }, + topMembers: [{ platformId: 'alice', name: 'Alice', messageCount: 2, percentage: 100 }], + } + : null, + executeReadonlySql: async (sessionId) => (sessionId === session.id ? { columns: ['count'], rows: [[2]] } : null), + getExportData: async (sessionId) => + sessionId === session.id + ? { + members: [{ platformId: 'alice', accountName: 'Alice', aliases: ['A'] }], + messages: [ + { + senderPlatformId: 'alice', + senderName: 'Alice', + timestamp: 100, + type: 0, + content: 'hello', + }, + ], + } + : null, + } +} + +test('REST routes share one contract with an async Worker-backed provider', async () => { + const app = Fastify() + const provider = createAsyncProvider() + registerSystemRoutes(app, { getVersion: () => '0.31.2', countSessions: provider.countSessions }) + registerRestSessionRoutes(app, provider) + await app.ready() + + const [status, sessions, detail, messages, members, overview, sql, exported] = await Promise.all([ + app.inject({ method: 'GET', url: '/api/v1/status' }), + app.inject({ method: 'GET', url: '/api/v1/sessions' }), + app.inject({ method: 'GET', url: `/api/v1/sessions/${session.id}` }), + app.inject({ method: 'GET', url: `/api/v1/sessions/${session.id}/messages?limit=1` }), + app.inject({ method: 'GET', url: `/api/v1/sessions/${session.id}/members` }), + app.inject({ method: 'GET', url: `/api/v1/sessions/${session.id}/stats/overview` }), + app.inject({ method: 'POST', url: `/api/v1/sessions/${session.id}/sql`, payload: { sql: 'SELECT 2' } }), + app.inject({ method: 'GET', url: `/api/v1/sessions/${session.id}/export` }), + ]) + + assert.equal(status.json().data.sessionCount, 1) + assert.deepEqual(sessions.json().data, [session]) + assert.deepEqual(detail.json().data, session) + assert.equal(messages.json().data.totalPages, 2) + assert.deepEqual(members.json().data[0].aliases, ['Alice']) + assert.equal(overview.json().data.messageCount, 2) + assert.deepEqual(sql.json().data.rows, [[2]]) + assert.deepEqual(exported.json().data.members[0].aliases, ['A']) + assert.equal(exported.json().data.messages[0].sender, 'alice') + + const missingSql = await app.inject({ + method: 'POST', + url: '/api/v1/sessions/missing/sql', + payload: { sql: 'SELECT 1' }, + }) + assert.equal(missingSql.statusCode, 404) + assert.equal(missingSql.json().error.code, 'SESSION_NOT_FOUND') + + await app.close() +}) diff --git a/packages/http-routes/src/routes/rest-session-provider.ts b/packages/http-routes/src/routes/rest-session-provider.ts new file mode 100644 index 00000000..adc2c5fd --- /dev/null +++ b/packages/http-routes/src/routes/rest-session-provider.ts @@ -0,0 +1,199 @@ +import { + executeReadonlySql, + getLastPlatformMessageId, + getMemberActivity, + getMembersWithAliases, + getMessageTypeStats, + getSessionInfo, + getSessionMeta, + getSessionOverview, + queryMessages, +} from '@openchatlab/core' +import type { DatabaseManager } from '@openchatlab/node-runtime' + +type Awaitable = T | Promise + +export interface RestSessionSummary { + id: string + name: string + platform: string + type: string + groupId?: string + messageCount: number + memberCount: number + firstTimestamp: number | null + lastTimestamp: number | null + importedAt?: number +} + +export interface RestSessionDetail extends RestSessionSummary { + lastPlatformMessageId: string | null + importedAt: number +} + +export interface RestMessageQuery { + keyword?: string + startTime?: number + endTime?: number + senderId?: number + limit: number + offset: number +} + +export interface RestMessagePage { + messages: unknown[] + total: number + totalPages?: number +} + +export interface RestSessionOverview { + messageCount: number + memberCount: number + timeRange: { start: number; end: number } + messageTypeDistribution: Record + topMembers: Array<{ + platformId: string + name: string + messageCount: number + percentage: number + }> +} + +export interface RestExportMember { + platformId: string + accountName?: string | null + groupNickname?: string | null + aliases?: string[] +} + +export interface RestExportMessage { + senderPlatformId: string + senderName?: string | null + timestamp: number + type: number + content?: string | null +} + +export interface RestSessionExportData { + members: RestExportMember[] + messages: RestExportMessage[] +} + +export interface RestSessionProvider { + countSessions(): Awaitable + listSessions(): Awaitable + getSession(sessionId: string): Awaitable + queryMessages(sessionId: string, query: RestMessageQuery): Awaitable + getMembers(sessionId: string): Awaitable + getOverview(sessionId: string): Awaitable + executeReadonlySql(sessionId: string, sql: string): Awaitable + getExportData(sessionId: string, limit: number): Awaitable +} + +function getSummary(dbManager: DatabaseManager, sessionId: string): RestSessionSummary | null { + const db = dbManager.open(sessionId) + if (!db) return null + const info = getSessionInfo(db) + if (!info) return null + + return { + id: sessionId, + name: info.name, + platform: info.platform, + type: info.type, + groupId: info.groupId || undefined, + messageCount: info.messageCount, + memberCount: info.memberCount, + firstTimestamp: info.firstMessageTs, + lastTimestamp: info.lastMessageTs, + importedAt: info.importedAt, + } +} + +function getDetail(dbManager: DatabaseManager, sessionId: string): RestSessionDetail | null { + const summary = getSummary(dbManager, sessionId) + if (!summary) return null + const db = dbManager.open(sessionId) + if (!db) return null + + return { + ...summary, + lastPlatformMessageId: getLastPlatformMessageId(db), + importedAt: summary.importedAt ?? 0, + } +} + +export function createDatabaseRestSessionProvider(dbManager: DatabaseManager): RestSessionProvider { + return { + countSessions: () => dbManager.listSessionIds().length, + listSessions: () => + dbManager + .listSessionIds() + .map((sessionId) => getSummary(dbManager, sessionId)) + .filter((session): session is RestSessionSummary => session !== null), + getSession: (sessionId) => getDetail(dbManager, sessionId), + queryMessages: (sessionId, query) => { + const db = dbManager.open(sessionId) + if (!db) return null + return queryMessages(db, { + keyword: query.keyword, + startTs: query.startTime, + endTs: query.endTime, + senderId: query.senderId, + limit: query.limit, + offset: query.offset, + }) + }, + getMembers: (sessionId) => { + const db = dbManager.open(sessionId) + return db ? getMembersWithAliases(db) : null + }, + getOverview: (sessionId) => { + const db = dbManager.open(sessionId) + if (!db) return null + + const overview = getSessionOverview(db) + const memberActivity = getMemberActivity(db) + const typeDistribution = getMessageTypeStats(db) + const messageTypeDistribution: Record = {} + for (const item of typeDistribution) { + messageTypeDistribution[String(item.type)] = item.count + } + + return { + messageCount: overview.totalMessages, + memberCount: overview.totalMembers, + timeRange: { + start: overview.firstMessageTs ?? 0, + end: overview.lastMessageTs ?? 0, + }, + messageTypeDistribution, + topMembers: memberActivity.slice(0, 10).map((member) => ({ + platformId: member.platformId, + name: member.name, + messageCount: member.messageCount, + percentage: member.percentage, + })), + } + }, + executeReadonlySql: (sessionId, sql) => { + const db = dbManager.open(sessionId) + return db ? executeReadonlySql(db, sql) : null + }, + getExportData: (sessionId, limit) => { + const db = dbManager.open(sessionId) + if (!db) return null + const session = getDetail(dbManager, sessionId) + const meta = getSessionMeta(db) + if (!session || !meta) return null + + const members = getMembersWithAliases(db) + const messages = queryMessages(db, { limit, offset: 0 }).messages + + return { + members, + messages, + } + }, + } +} diff --git a/packages/http-routes/src/routes/sessions.ts b/packages/http-routes/src/routes/sessions.ts index 5bd28a37..a3dee6a6 100644 --- a/packages/http-routes/src/routes/sessions.ts +++ b/packages/http-routes/src/routes/sessions.ts @@ -2,75 +2,24 @@ * ChatLab HTTP API — REST Session routes (/api/v1/sessions/*) * * Public REST API for external tools, scripts, and integrations. - * Uses DatabaseManager + @openchatlab/core for data access. + * Uses an injected provider so Electron can keep database work in its Worker. */ import type { FastifyInstance } from 'fastify' -import type { HttpRouteContext } from '../context' -import { - getSessionInfo, - getSessionMeta, - getSessionOverview, - queryMessages, - getMembers, - getMembersDetailed, - getMemberActivity, - getMessageTypeStats, - executeReadonlySql, - getLastPlatformMessageId, -} from '@openchatlab/core' import { successResponse, errorResponse, sessionNotFound, exportTooLarge, sqlExecutionError, ApiError } from '../errors' +import type { RestSessionProvider } from './rest-session-provider' const EXPORT_MESSAGE_LIMIT = 100_000 -function ensureDb(ctx: HttpRouteContext, sessionId: string) { - const db = ctx.dbManager.open(sessionId) - if (!db) throw sessionNotFound(sessionId) - return db -} - -export function registerRestSessionRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { +export function registerRestSessionRoutes(server: FastifyInstance, provider: RestSessionProvider): void { server.get('/api/v1/sessions', async () => { - const sessionIds = ctx.dbManager.listSessionIds() - const sessions = sessionIds - .map((id) => { - const db = ctx.dbManager.open(id) - if (!db) return null - const info = getSessionInfo(db) - if (!info) return null - return { - id, - name: info.name, - platform: info.platform, - type: info.type, - groupId: info.groupId || undefined, - messageCount: info.messageCount, - memberCount: info.memberCount, - firstTimestamp: info.firstMessageTs, - lastTimestamp: info.lastMessageTs, - } - }) - .filter(Boolean) - return successResponse(sessions) + return successResponse(await provider.listSessions()) }) server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => { - const db = ensureDb(ctx, request.params.id) - const info = getSessionInfo(db) - if (!info) throw sessionNotFound(request.params.id) - return successResponse({ - id: request.params.id, - name: info.name, - platform: info.platform, - type: info.type, - groupId: info.groupId || undefined, - messageCount: info.messageCount, - memberCount: info.memberCount, - firstTimestamp: info.firstMessageTs, - lastTimestamp: info.lastMessageTs, - lastPlatformMessageId: getLastPlatformMessageId(db), - importedAt: info.importedAt, - }) + const session = await provider.getSession(request.params.id) + if (!session) throw sessionNotFound(request.params.id) + return successResponse(session) }) server.get<{ @@ -85,7 +34,6 @@ export function registerRestSessionRoutes(server: FastifyInstance, ctx: HttpRout } }>('/api/v1/sessions/:id/messages', async (request) => { const { id } = request.params - const db = ensureDb(ctx, id) const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1) const limit = Math.min(1000, Math.max(1, parseInt(request.query.limit || '100', 10) || 100)) @@ -93,65 +41,39 @@ export function registerRestSessionRoutes(server: FastifyInstance, ctx: HttpRout const { startTime, endTime, keyword, senderId } = request.query - const result = queryMessages(db, { + const result = await provider.queryMessages(id, { keyword: keyword || undefined, - startTs: startTime ? parseInt(startTime, 10) : undefined, - endTs: endTime ? parseInt(endTime, 10) : undefined, + startTime: startTime ? parseInt(startTime, 10) : undefined, + endTime: endTime ? parseInt(endTime, 10) : undefined, senderId: senderId ? parseInt(senderId, 10) : undefined, limit, offset, }) + if (!result) throw sessionNotFound(id) return successResponse({ messages: result.messages, total: result.total, page, limit, - totalPages: result.totalPages, + totalPages: result.totalPages ?? Math.ceil(result.total / limit), }) }) server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => { - const db = ensureDb(ctx, request.params.id) - const members = getMembers(db) + const members = await provider.getMembers(request.params.id) + if (!members) throw sessionNotFound(request.params.id) return successResponse(members) }) server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/stats/overview', async (request) => { - const { id } = request.params - const db = ensureDb(ctx, id) - - const overview = getSessionOverview(db) - const memberActivity = getMemberActivity(db) - const typeDistribution = getMessageTypeStats(db) - - const typeMap: Record = {} - for (const item of typeDistribution) { - typeMap[String(item.type)] = item.count - } - - const topMembers = memberActivity.slice(0, 10).map((m) => ({ - platformId: m.platformId, - name: m.name, - messageCount: m.messageCount, - percentage: m.percentage, - })) - - return successResponse({ - messageCount: overview.totalMessages, - memberCount: overview.totalMembers, - timeRange: { - start: overview.firstMessageTs ?? 0, - end: overview.lastMessageTs ?? 0, - }, - messageTypeDistribution: typeMap, - topMembers, - }) + const overview = await provider.getOverview(request.params.id) + if (!overview) throw sessionNotFound(request.params.id) + return successResponse(overview) }) server.post<{ Params: { id: string }; Body: { sql: string } }>('/api/v1/sessions/:id/sql', async (request, reply) => { const { id } = request.params - const db = ensureDb(ctx, id) const { sql } = request.body || {} if (!sql || typeof sql !== 'string') { @@ -160,7 +82,11 @@ export function registerRestSessionRoutes(server: FastifyInstance, ctx: HttpRout } try { - const result = executeReadonlySql(db, sql) + const result = await provider.executeReadonlySql(id, sql) + if (result === null) { + const err = sessionNotFound(id) + return reply.code(err.statusCode).send(errorResponse(err)) + } return successResponse(result) } catch (err: unknown) { const message = err instanceof Error ? err.message : 'SQL execution error' @@ -176,18 +102,16 @@ export function registerRestSessionRoutes(server: FastifyInstance, ctx: HttpRout server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/export', async (request, reply) => { const { id } = request.params - const db = ensureDb(ctx, id) - const meta = getSessionMeta(db) - if (!meta) throw sessionNotFound(id) - const overview = getSessionOverview(db) + const session = await provider.getSession(id) + if (!session) throw sessionNotFound(id) - if (overview.totalMessages > EXPORT_MESSAGE_LIMIT) { - const err = exportTooLarge(overview.totalMessages, EXPORT_MESSAGE_LIMIT) + if (session.messageCount > EXPORT_MESSAGE_LIMIT) { + const err = exportTooLarge(session.messageCount, EXPORT_MESSAGE_LIMIT) return reply.code(err.statusCode).send(errorResponse(err)) } - const members = getMembersDetailed(db) - const allMessages = queryMessages(db, { limit: EXPORT_MESSAGE_LIMIT, offset: 0 }) + const exportData = await provider.getExportData(id, EXPORT_MESSAGE_LIMIT) + if (!exportData) throw sessionNotFound(id) const chatLabFormat = { chatlab: { @@ -196,17 +120,18 @@ export function registerRestSessionRoutes(server: FastifyInstance, ctx: HttpRout generator: 'ChatLab API', }, meta: { - name: meta.name, - platform: meta.platform, - type: meta.type, - groupId: meta.groupId || undefined, + name: session.name, + platform: session.platform, + type: session.type, + groupId: session.groupId || undefined, }, - members: members.map((m) => ({ + members: exportData.members.map((m) => ({ platformId: m.platformId, accountName: m.accountName || m.platformId, groupNickname: m.groupNickname || undefined, + aliases: Array.isArray(m.aliases) && m.aliases.length > 0 ? m.aliases : undefined, })), - messages: allMessages.messages.map((msg) => ({ + messages: exportData.messages.map((msg) => ({ sender: msg.senderPlatformId, accountName: msg.senderName || undefined, timestamp: msg.timestamp, diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index 59a771e5..140695f3 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -17,6 +17,7 @@ import { PreferencesManager, type DatabaseManager, type SessionRuntimeAdapter } import type { HttpRouteContext } from '../context' import { registerSharedRoutes } from '../register' import { registerRestSessionRoutes } from './sessions' +import { createDatabaseRestSessionProvider } from './rest-session-provider' import { registerAutomationRoutes } from './web/automation' import { registerSessionRoutes } from './web/sessions' @@ -231,7 +232,8 @@ describe('registerSharedRoutes smoke tests', () => { it('GET /api/v1/sessions/:id/messages applies query filters and pagination', async () => { const db = createSessionDb() const routeApp = Fastify() - registerRestSessionRoutes(routeApp, createTestContext(new Map([['chat-1', db]]))) + const ctx = createTestContext(new Map([['chat-1', db]])) + registerRestSessionRoutes(routeApp, createDatabaseRestSessionProvider(ctx.dbManager)) await routeApp.ready() const resp = await routeApp.inject({ @@ -255,7 +257,8 @@ describe('registerSharedRoutes smoke tests', () => { it('POST /api/v1/sessions/:id/sql rejects write statements and keeps data unchanged', async () => { const db = createSessionDb() const routeApp = Fastify() - registerRestSessionRoutes(routeApp, createTestContext(new Map([['chat-1', db]]))) + const ctx = createTestContext(new Map([['chat-1', db]])) + registerRestSessionRoutes(routeApp, createDatabaseRestSessionProvider(ctx.dbManager)) await routeApp.ready() const resp = await routeApp.inject({ diff --git a/packages/http-routes/src/routes/system.ts b/packages/http-routes/src/routes/system.ts index 6a17b016..92bfbca2 100644 --- a/packages/http-routes/src/routes/system.ts +++ b/packages/http-routes/src/routes/system.ts @@ -6,14 +6,18 @@ */ import type { FastifyInstance } from 'fastify' -import type { HttpRouteContext } from '../context' import { successResponse } from '../errors' -export function registerSystemRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { +export interface SystemRouteContext { + getVersion: () => string + countSessions: () => number | Promise +} + +export function registerSystemRoutes(server: FastifyInstance, ctx: SystemRouteContext): void { server.get('/api/v1/status', async () => { let sessionCount = 0 try { - sessionCount = ctx.dbManager.listSessionIds().length + sessionCount = await ctx.countSessions() } catch { // ignore }