refactor(http): share system and session routes

This commit is contained in:
digua committed 2026-07-16 20:09:18 +08:00
1 parent f949c7a8f9
commit e563183e62
14 files changed
+535 -400

No files matched your search

+10 -4
View File
@@ -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<void> {
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' })
@@ -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<RestSessionDetail | null> {
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<RestSessionOverview | null> => {
const session = await getSession(sessionId)
if (!session) return null
const [timeRange, memberActivity, typeDistribution] = await Promise.all([
worker.getTimeRange(sessionId),
worker.getMemberActivity(sessionId) as Promise<DesktopMemberActivity[]>,
worker.getMessageTypeDistribution(sessionId) as Promise<DesktopMessageTypeStat[]>,
])
const messageTypeDistribution: Record<string, number> = {}
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<RestSessionExportData | null> => {
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,
}
},
}
}
-187
View File
@@ -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<string, number> = {}
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)
})
}
-91
View File
@@ -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' },
},
},
},
},
})
})
}
+2 -1
View File
@@ -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:*",
+3
View File
@@ -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
+11
View File
@@ -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'
+8 -2
View File
@@ -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/*)
+13
View File
@@ -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'
@@ -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()
})
@@ -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> = T | Promise<T>
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<string, number>
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<number>
listSessions(): Awaitable<RestSessionSummary[]>
getSession(sessionId: string): Awaitable<RestSessionDetail | null>
queryMessages(sessionId: string, query: RestMessageQuery): Awaitable<RestMessagePage | null>
getMembers(sessionId: string): Awaitable<unknown[] | null>
getOverview(sessionId: string): Awaitable<RestSessionOverview | null>
executeReadonlySql(sessionId: string, sql: string): Awaitable<unknown | null>
getExportData(sessionId: string, limit: number): Awaitable<RestSessionExportData | null>
}
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<string, number> = {}
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,
}
},
}
}
+35 -110
View File
@@ -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<string, number> = {}
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,
@@ -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({
+7 -3
View File
@@ -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<number>
}
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
}