mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
refactor(http): share system and session routes
This commit is contained in:
1 parent
f949c7a8f9
commit
e563183e62
14 files changed
+535
-400
No files matched your search
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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/*)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user