mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(server): extract standalone HTTP API from Electron
Create packages/server/src/http/ with platform-agnostic HTTP API: - errors.ts: error codes and response helpers - auth.ts: Bearer token auth using @openchatlab/config - server.ts: Fastify factory with auth hook and error handler - routes/system.ts: status and schema endpoints - routes/sessions.ts: 7 session endpoints using DatabaseManager + core - index.ts: server lifecycle (startHttpServer/stopHttpServer) Add token field to @openchatlab/config apiConfigSchema. Add fastify dependency to packages/server.
This commit is contained in:
1 parent
b66ac5d287
commit
9afabedca8
10 files changed
+623
-3
No files matched your search
@@ -20,6 +20,7 @@ export const dataConfigSchema = z.object({
|
||||
export const apiConfigSchema = z.object({
|
||||
port: z.number().int().min(1).max(65535).default(3210),
|
||||
host: z.string().default('127.0.0.1'),
|
||||
token: z.string().default(''),
|
||||
})
|
||||
|
||||
export const localeConfigSchema = z.object({
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
"rebuild-sqlite": "npm rebuild better-sqlite3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openchatlab/core": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@openchatlab/config": "workspace:*",
|
||||
"@openchatlab/core": "workspace:*",
|
||||
"@openchatlab/node-runtime": "workspace:*",
|
||||
"@openchatlab/shared-types": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@openchatlab/tools": "workspace:*",
|
||||
"commander": "^13.1.0"
|
||||
"commander": "^13.1.0",
|
||||
"fastify": "^5.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* ChatLab HTTP API — Bearer Token authentication hook
|
||||
*
|
||||
* 从 electron/main/api/auth.ts 迁移,使用 @openchatlab/config 读取 token。
|
||||
*/
|
||||
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify'
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
import { unauthorized, errorResponse } from './errors'
|
||||
|
||||
let cachedToken: string | null = null
|
||||
|
||||
/**
|
||||
* 设置 auth hook 使用的 token(由 server 启动时注入)
|
||||
*/
|
||||
export function setAuthToken(token: string): void {
|
||||
cachedToken = token
|
||||
}
|
||||
|
||||
function safeTokenCompare(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a)
|
||||
const bufB = Buffer.from(b)
|
||||
if (bufA.length !== bufB.length) return false
|
||||
return timingSafeEqual(bufA, bufB)
|
||||
}
|
||||
|
||||
export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
if (!cachedToken) return
|
||||
|
||||
const authHeader = request.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
const err = unauthorized()
|
||||
reply.code(err.statusCode).send(errorResponse(err))
|
||||
return
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7)
|
||||
|
||||
if (!safeTokenCompare(token, cachedToken)) {
|
||||
const err = unauthorized()
|
||||
reply.code(err.statusCode).send(errorResponse(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* ChatLab HTTP API — Error codes and factory functions
|
||||
*
|
||||
* 从 electron/main/api/errors.ts 迁移,完全平台无关。
|
||||
*/
|
||||
|
||||
export enum ApiErrorCode {
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
SESSION_NOT_FOUND = 'SESSION_NOT_FOUND',
|
||||
INVALID_FORMAT = 'INVALID_FORMAT',
|
||||
INVALID_PAYLOAD = 'INVALID_PAYLOAD',
|
||||
SQL_READONLY_VIOLATION = 'SQL_READONLY_VIOLATION',
|
||||
SQL_EXECUTION_ERROR = 'SQL_EXECUTION_ERROR',
|
||||
EXPORT_TOO_LARGE = 'EXPORT_TOO_LARGE',
|
||||
BODY_TOO_LARGE = 'BODY_TOO_LARGE',
|
||||
SERVER_ERROR = 'SERVER_ERROR',
|
||||
}
|
||||
|
||||
const HTTP_STATUS: Record<ApiErrorCode, number> = {
|
||||
[ApiErrorCode.UNAUTHORIZED]: 401,
|
||||
[ApiErrorCode.SESSION_NOT_FOUND]: 404,
|
||||
[ApiErrorCode.INVALID_FORMAT]: 400,
|
||||
[ApiErrorCode.INVALID_PAYLOAD]: 400,
|
||||
[ApiErrorCode.SQL_READONLY_VIOLATION]: 400,
|
||||
[ApiErrorCode.SQL_EXECUTION_ERROR]: 400,
|
||||
[ApiErrorCode.EXPORT_TOO_LARGE]: 400,
|
||||
[ApiErrorCode.BODY_TOO_LARGE]: 413,
|
||||
[ApiErrorCode.SERVER_ERROR]: 500,
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: ApiErrorCode
|
||||
statusCode: number
|
||||
|
||||
constructor(code: ApiErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
this.statusCode = HTTP_STATUS[code]
|
||||
}
|
||||
}
|
||||
|
||||
export function unauthorized(message = 'Invalid or missing token'): ApiError {
|
||||
return new ApiError(ApiErrorCode.UNAUTHORIZED, message)
|
||||
}
|
||||
|
||||
export function sessionNotFound(id: string): ApiError {
|
||||
return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${id}`)
|
||||
}
|
||||
|
||||
export function invalidPayload(message: string): ApiError {
|
||||
return new ApiError(ApiErrorCode.INVALID_PAYLOAD, message)
|
||||
}
|
||||
|
||||
export function sqlReadonlyViolation(): ApiError {
|
||||
return new ApiError(ApiErrorCode.SQL_READONLY_VIOLATION, 'Only SELECT queries are allowed')
|
||||
}
|
||||
|
||||
export function sqlExecutionError(message: string): ApiError {
|
||||
return new ApiError(ApiErrorCode.SQL_EXECUTION_ERROR, message)
|
||||
}
|
||||
|
||||
export function exportTooLarge(count: number, limit: number): ApiError {
|
||||
return new ApiError(
|
||||
ApiErrorCode.EXPORT_TOO_LARGE,
|
||||
`Message count ${count} exceeds export limit ${limit}. Use paginated /messages API instead.`
|
||||
)
|
||||
}
|
||||
|
||||
export function serverError(message = 'Internal server error'): ApiError {
|
||||
return new ApiError(ApiErrorCode.SERVER_ERROR, message)
|
||||
}
|
||||
|
||||
export function successResponse<T>(data: T, meta?: Record<string, unknown>) {
|
||||
return {
|
||||
success: true as const,
|
||||
data,
|
||||
meta: {
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
version: '0.0.2',
|
||||
...meta,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function errorResponse(error: ApiError) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* ChatLab HTTP API — Server lifecycle manager
|
||||
*
|
||||
* 独立于 Electron 的 HTTP API 服务入口。
|
||||
* 使用 DatabaseManager + @openchatlab/core 直接访问数据。
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as crypto from 'crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { loadConfig, getConfigDir } from '@openchatlab/config'
|
||||
import type { ChatLabConfig } from '@openchatlab/config'
|
||||
import { NodePathProvider, DatabaseManager } from '@openchatlab/node-runtime'
|
||||
import { createServer } from './server'
|
||||
import { setAuthToken } from './auth'
|
||||
import { registerSystemRoutes } from './routes/system'
|
||||
import { registerSessionRoutes } from './routes/sessions'
|
||||
|
||||
let server: FastifyInstance | null = null
|
||||
let dbManager: DatabaseManager | null = null
|
||||
|
||||
export interface HttpServerOptions {
|
||||
port?: number
|
||||
host?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
function resolveNativeBinding(): string | undefined {
|
||||
if (process.versions.electron) return undefined
|
||||
const nativePath = path.resolve(__dirname, '../../native/better_sqlite3.node')
|
||||
if (fs.existsSync(nativePath)) return nativePath
|
||||
return undefined
|
||||
}
|
||||
|
||||
function ensureToken(config: ChatLabConfig): string {
|
||||
if (config.api.token) return config.api.token
|
||||
|
||||
const token = `clb_${crypto.randomBytes(32).toString('hex')}`
|
||||
const configDir = getConfigDir()
|
||||
const configPath = path.join(configDir, 'config.toml')
|
||||
try {
|
||||
let content = ''
|
||||
if (fs.existsSync(configPath)) {
|
||||
content = fs.readFileSync(configPath, 'utf-8')
|
||||
}
|
||||
if (!content.includes('[api]')) {
|
||||
content += `\n[api]\ntoken = "${token}"\n`
|
||||
} else {
|
||||
content = content.replace(/\[api\]/, `[api]\ntoken = "${token}"`)
|
||||
}
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
fs.writeFileSync(configPath, content, 'utf-8')
|
||||
} catch {
|
||||
// 写入失败时仍使用生成的 token 运行本次会话
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动独立 HTTP API 服务
|
||||
*/
|
||||
export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
port: number
|
||||
host: string
|
||||
token: string
|
||||
}> {
|
||||
if (server) {
|
||||
throw new Error('HTTP server is already running')
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
const port = options?.port ?? config.api.port
|
||||
const host = options?.host ?? config.api.host
|
||||
const token = options?.token ?? ensureToken(config)
|
||||
|
||||
const dataDir = config.data.dir || undefined
|
||||
const pathProvider = new NodePathProvider(dataDir)
|
||||
pathProvider.ensureAllDirs()
|
||||
const nativeBinding = resolveNativeBinding()
|
||||
dbManager = new DatabaseManager(pathProvider, { nativeBinding })
|
||||
|
||||
setAuthToken(token)
|
||||
|
||||
server = createServer()
|
||||
registerSystemRoutes(server, dbManager)
|
||||
registerSessionRoutes(server, dbManager)
|
||||
|
||||
await server.listen({ port, host })
|
||||
|
||||
return { port, host, token }
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 HTTP API 服务
|
||||
*/
|
||||
export async function stopHttpServer(): Promise<void> {
|
||||
if (!server) return
|
||||
|
||||
try {
|
||||
await server.close()
|
||||
} finally {
|
||||
if (dbManager) {
|
||||
dbManager.closeAll()
|
||||
dbManager = null
|
||||
}
|
||||
server = null
|
||||
}
|
||||
}
|
||||
|
||||
export { createServer } from './server'
|
||||
export { registerSystemRoutes } from './routes/system'
|
||||
export { registerSessionRoutes } from './routes/sessions'
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* ChatLab HTTP API — Session routes
|
||||
*
|
||||
* 从 electron/main/api/routes/sessions.ts 迁移。
|
||||
* 使用 DatabaseManager + @openchatlab/core 替代 workerManager。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import type { DatabaseManager } from '@openchatlab/node-runtime'
|
||||
import {
|
||||
getSessionMeta,
|
||||
getSessionOverview,
|
||||
queryMessages,
|
||||
getMembers,
|
||||
getMembersDetailed,
|
||||
getMemberActivity,
|
||||
getMessageTypeStats,
|
||||
executeReadonlySql,
|
||||
} from '@openchatlab/core'
|
||||
import {
|
||||
successResponse,
|
||||
errorResponse,
|
||||
sessionNotFound,
|
||||
exportTooLarge,
|
||||
sqlExecutionError,
|
||||
ApiError,
|
||||
} from '../errors'
|
||||
|
||||
const EXPORT_MESSAGE_LIMIT = 100_000
|
||||
|
||||
function ensureDb(dbManager: DatabaseManager, sessionId: string) {
|
||||
const db = dbManager.open(sessionId)
|
||||
if (!db) throw sessionNotFound(sessionId)
|
||||
return db
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(server: FastifyInstance, dbManager: DatabaseManager): void {
|
||||
// GET /api/v1/sessions — List all sessions
|
||||
server.get('/api/v1/sessions', async () => {
|
||||
const sessionIds = dbManager.listSessionIds()
|
||||
const sessions = sessionIds
|
||||
.map((id) => {
|
||||
const db = dbManager.open(id)
|
||||
if (!db) return null
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) return null
|
||||
const overview = getSessionOverview(db)
|
||||
return {
|
||||
id,
|
||||
name: meta.name,
|
||||
platform: meta.platform,
|
||||
type: meta.type,
|
||||
groupId: meta.groupId || undefined,
|
||||
messageCount: overview.totalMessages,
|
||||
memberCount: overview.totalMembers,
|
||||
firstTimestamp: overview.firstMessageTs,
|
||||
lastTimestamp: overview.lastMessageTs,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
return successResponse(sessions)
|
||||
})
|
||||
|
||||
// GET /api/v1/sessions/:id — Single session detail
|
||||
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) throw sessionNotFound(request.params.id)
|
||||
const overview = getSessionOverview(db)
|
||||
return successResponse({
|
||||
id: request.params.id,
|
||||
name: meta.name,
|
||||
platform: meta.platform,
|
||||
type: meta.type,
|
||||
groupId: meta.groupId || undefined,
|
||||
messageCount: overview.totalMessages,
|
||||
memberCount: overview.totalMembers,
|
||||
firstTimestamp: overview.firstMessageTs,
|
||||
lastTimestamp: overview.lastMessageTs,
|
||||
})
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
}>('/api/v1/sessions/:id/messages', async (request) => {
|
||||
const { id } = request.params
|
||||
const db = ensureDb(dbManager, 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 result = queryMessages(db, {
|
||||
keyword: keyword || undefined,
|
||||
startTs: startTime ? parseInt(startTime, 10) : undefined,
|
||||
endTs: endTime ? parseInt(endTime, 10) : undefined,
|
||||
senderId: senderId ? parseInt(senderId, 10) : undefined,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
|
||||
return successResponse({
|
||||
messages: result.messages,
|
||||
total: result.total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: result.totalPages,
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/v1/sessions/:id/members — Member list
|
||||
server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const members = getMembers(db)
|
||||
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 db = ensureDb(dbManager, 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,
|
||||
})
|
||||
})
|
||||
|
||||
// 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
|
||||
const db = ensureDb(dbManager, 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 = executeReadonlySql(db, sql)
|
||||
return successResponse(result)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'SQL execution error'
|
||||
if (message.includes('SELECT') || message.includes('只读') || message.includes('readonly')) {
|
||||
const apiErr = new ApiError('SQL_READONLY_VIOLATION' as ApiError['code'], 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 db = ensureDb(dbManager, id)
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) throw sessionNotFound(id)
|
||||
const overview = getSessionOverview(db)
|
||||
|
||||
if (overview.totalMessages > EXPORT_MESSAGE_LIMIT) {
|
||||
const err = exportTooLarge(overview.totalMessages, 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 chatLabFormat = {
|
||||
chatlab: {
|
||||
version: '0.0.2',
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
generator: 'ChatLab API',
|
||||
},
|
||||
meta: {
|
||||
name: meta.name,
|
||||
platform: meta.platform,
|
||||
type: meta.type,
|
||||
groupId: meta.groupId || undefined,
|
||||
},
|
||||
members: members.map((m) => ({
|
||||
platformId: m.platformId,
|
||||
accountName: m.accountName || m.platformId,
|
||||
groupNickname: m.groupNickname || undefined,
|
||||
})),
|
||||
messages: allMessages.messages.map((msg) => ({
|
||||
sender: msg.senderPlatformId,
|
||||
accountName: msg.senderName || undefined,
|
||||
timestamp: msg.timestamp,
|
||||
type: msg.type,
|
||||
content: msg.content || null,
|
||||
})),
|
||||
}
|
||||
|
||||
return successResponse(chatLabFormat)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* ChatLab HTTP API — System routes
|
||||
*
|
||||
* GET /api/v1/status Service status
|
||||
* GET /api/v1/schema ChatLab Format JSON Schema
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import type { DatabaseManager } from '@openchatlab/node-runtime'
|
||||
import { successResponse } from '../errors'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pkg = require('../../../package.json') as { version: string }
|
||||
|
||||
export function registerSystemRoutes(server: FastifyInstance, dbManager: DatabaseManager): void {
|
||||
server.get('/api/v1/status', async () => {
|
||||
let sessionCount = 0
|
||||
try {
|
||||
sessionCount = dbManager.listSessionIds().length
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return successResponse({
|
||||
name: 'ChatLab API',
|
||||
version: pkg.version,
|
||||
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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* ChatLab HTTP API — Fastify server factory
|
||||
*
|
||||
* 从 electron/main/api/server.ts 迁移,完全平台无关。
|
||||
*/
|
||||
|
||||
import Fastify, { type FastifyInstance, type FastifyError } from 'fastify'
|
||||
import { authHook } from './auth'
|
||||
import { ApiError, ApiErrorCode, errorResponse, serverError } from './errors'
|
||||
|
||||
const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
export function createServer(): FastifyInstance {
|
||||
const server = Fastify({
|
||||
logger: false,
|
||||
bodyLimit: JSON_BODY_LIMIT,
|
||||
})
|
||||
|
||||
server.addHook('onRequest', authHook)
|
||||
|
||||
server.setErrorHandler((error: FastifyError, _request, reply) => {
|
||||
if (error instanceof ApiError) {
|
||||
reply.code(error.statusCode).send(errorResponse(error))
|
||||
return
|
||||
}
|
||||
|
||||
if (error.statusCode === 413) {
|
||||
const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit')
|
||||
reply.code(413).send(errorResponse(bodyErr))
|
||||
return
|
||||
}
|
||||
|
||||
const err = serverError(error.message)
|
||||
reply.code(err.statusCode).send(errorResponse(err))
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
@@ -6,3 +6,4 @@
|
||||
*/
|
||||
|
||||
export { run } from './cli'
|
||||
export { startHttpServer, stopHttpServer } from './http'
|
||||
Generated
+4
@@ -182,6 +182,9 @@ importers:
|
||||
commander:
|
||||
specifier: ^13.1.0
|
||||
version: 13.1.0
|
||||
fastify:
|
||||
specifier: ^5.8.4
|
||||
version: 5.8.4
|
||||
devDependencies:
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
@@ -4373,6 +4376,7 @@ packages:
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
|
||||
Reference in new issue
Block a user