feat(server): hono server with character schema, valibot, routes (#848)

Co-authored-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
RainbowBird
2026-01-05 16:25:20 +08:00
committed by RainbowBird
co-authored by Neko Ayaka
parent d064a959cf
commit 3dbf3fafd9
27 changed files with 2381 additions and 66 deletions
+100
View File
@@ -0,0 +1,100 @@
import { createInsertSchema, createSelectSchema } from 'drizzle-valibot'
import { array, literal, number, object, optional, pipe, string, transform, union } from 'valibot'
import * as schema from '../schemas/characters'
export const AvatarModelConfigSchema = object({
vrm: optional(object({
urls: array(string()),
})),
live2d: optional(object({
urls: array(string()),
})),
})
export const CharacterCapabilityConfigSchema = object({
apiKey: string(),
apiBaseUrl: string(),
llm: optional(object({
temperature: number(),
model: string(),
})),
tts: optional(object({
ssml: string(),
voiceId: string(),
speed: number(),
pitch: number(),
})),
vlm: optional(object({
image: string(),
})),
asr: optional(object({
audio: string(),
})),
})
const CharacterCapabilityTypeSchema = union([
literal('llm'),
literal('tts'),
literal('vlm'),
literal('asr'),
])
const AvatarModelTypeSchema = union([
literal('vrm'),
literal('live2d'),
])
export const CharacterSchema = createSelectSchema(schema.character)
export const InsertCharacterSchema = createInsertSchema(schema.character)
export const AvatarModelSchema = createSelectSchema(schema.avatarModel)
export const InsertAvatarModelSchema = createInsertSchema(schema.avatarModel)
export const CharacterCapabilitySchema = createSelectSchema(schema.characterCapabilities)
export const InsertCharacterCapabilitySchema = createInsertSchema(schema.characterCapabilities)
export const CharacterI18nSchema = createSelectSchema(schema.characterI18n)
export const InsertCharacterI18nSchema = createInsertSchema(schema.characterI18n)
export const CharacterPromptSchema = createSelectSchema(schema.characterPrompts)
export const InsertCharacterPromptSchema = createInsertSchema(schema.characterPrompts)
const DateSchema = pipe(
string(),
transform(v => new Date(v)),
)
export const CreateCharacterSchema = object({
character: createInsertSchema(schema.character, {
creatorId: optional(string()),
ownerId: optional(string()),
}),
capabilities: optional(array(createInsertSchema(schema.characterCapabilities, {
characterId: optional(string()),
type: CharacterCapabilityTypeSchema,
config: CharacterCapabilityConfigSchema,
}))),
avatarModels: optional(array(createInsertSchema(schema.avatarModel, {
characterId: optional(string()),
type: AvatarModelTypeSchema,
config: AvatarModelConfigSchema,
}))),
i18n: optional(array(createInsertSchema(schema.characterI18n, {
characterId: optional(string()),
}))),
prompts: optional(array(createInsertSchema(schema.characterPrompts, {
characterId: optional(string()),
}))),
})
export const UpdateCharacterSchema = createInsertSchema(schema.character, {
id: optional(string()),
version: optional(string()),
coverUrl: optional(string()),
creatorId: optional(string()),
ownerId: optional(string()),
characterId: optional(string()),
createdAt: optional(DateSchema),
updatedAt: optional(DateSchema),
})
+65 -50
View File
@@ -1,3 +1,5 @@
import type { HonoEnv } from './types/hono'
import process, { exit } from 'node:process'
import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
@@ -5,82 +7,93 @@ import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger as honoLogger } from 'hono/logger'
import { injeca } from 'injeca'
import { createLoggLogger, injeca } from 'injeca'
import { authGuard, sessionMiddleware } from './middlewares/auth'
import { createCharacterRoutes } from './routes/characters'
import { createAuth } from './services/auth'
import { createCharacterService } from './services/characters'
import { createDrizzle } from './services/db'
import { parsedEnv } from './services/env'
import { ApiError, createInternalError } from './utils/error'
import { getTrustedOrigin } from './utils/origin'
import * as schema from './schemas'
async function createApp() {
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
const resolved = await injeca.resolve({ parsedEnv })
injeca.setLogger(createLoggLogger(useLogger('injeca').useGlobalConfig()))
const logger = useLogger('app').useGlobalConfig()
const db = createDrizzle(resolved.parsedEnv.DATABASE_URL)
const auth = createAuth(db, resolved.parsedEnv)
db.execute('SELECT 1')
.then(() => {
logger.log('Connected to database')
})
.catch((err) => {
logger.withError(err).error('Failed to connect to database')
exit(1)
})
const db = injeca.provide('services:db', {
dependsOn: { env: parsedEnv },
build: ({ dependsOn }) => {
const dbInstance = createDrizzle(dependsOn.env.DATABASE_URL, schema)
dbInstance.execute('SELECT 1')
.then(() => logger.log('Connected to database'))
.catch((err) => {
logger.withError(err).error('Failed to connect to database')
exit(1)
})
return dbInstance
},
})
const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null
session: typeof auth.$Infer.Session.session | null
}
}>()
const auth = injeca.provide('services:auth', {
dependsOn: { db, env: parsedEnv },
build: ({ dependsOn }) => createAuth(dependsOn.db, dependsOn.env),
})
const characterService = injeca.provide('services:characters', {
dependsOn: { db },
build: ({ dependsOn }) => createCharacterService(dependsOn.db),
})
await injeca.start()
const resolved = await injeca.resolve({ auth, characterService })
const authInstance = resolved.auth
const app = new Hono<HonoEnv>()
app.use(
'/api/auth/*', // or replace with "*" to enable cors for all routes
'/api/*',
cors({
origin(origin: string) {
return getTrustedOrigin(origin)
},
origin: origin => getTrustedOrigin(origin),
credentials: true,
}),
)
app.use(honoLogger())
app.use('*', async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (!session) {
c.set('user', null)
c.set('session', null)
await next()
return
}
c.set('user', session.user)
c.set('session', session.session)
await next()
})
app.get('/session', (c) => {
const session = c.get('session')
const user = c.get('user')
if (!user)
return c.body(null, 401)
app.use('*', sessionMiddleware(authInstance))
app.get('/session', authGuard, (c) => {
return c.json({
session,
user,
session: c.get('session'),
user: c.get('user')!,
})
})
// NOTICE: required by better-auth
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
return auth.handler(c.req.raw)
app.on(['POST', 'GET'], '/api/auth/*', c => authInstance.handler(c.req.raw))
app.route('/api/characters', createCharacterRoutes(resolved.characterService))
app.onError((err, c) => {
if (err instanceof ApiError) {
return c.json({
error: err.errorCode,
message: err.message,
details: err.details,
}, err.statusCode)
}
logger.withError(err).error('Unhandled error')
const internalError = createInternalError()
return c.json({
error: internalError.errorCode,
message: internalError.message,
}, internalError.statusCode)
})
logger.withFields({ port: 3000 }).log('Server started')
@@ -88,6 +101,8 @@ async function createApp() {
return app
}
export type AppType = Awaited<ReturnType<typeof createApp>>
// eslint-disable-next-line antfu/no-top-level-await
serve(await createApp())
+40
View File
@@ -0,0 +1,40 @@
import type { MiddlewareHandler } from 'hono'
import type { createAuth } from '../services/auth'
import type { HonoEnv } from '../types/hono'
import { createUnauthorizedError } from '../utils/error'
type AuthInstance = ReturnType<typeof createAuth>
/**
* Session middleware injects the user and session into the Hono context.
* It does not block unauthorized requests.
*/
export function sessionMiddleware(auth: AuthInstance): MiddlewareHandler<HonoEnv> {
return async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (!session) {
c.set('user', null)
c.set('session', null)
return await next()
}
c.set('user', session.user)
c.set('session', session.session)
await next()
}
}
/**
* Auth guard middleware blocks requests if the user is not authenticated.
* Must be used after sessionMiddleware.
*/
export const authGuard: MiddlewareHandler<HonoEnv> = async (c, next) => {
const user = c.get('user')
if (!user) {
throw createUnauthorizedError()
}
await next()
}
+191
View File
@@ -0,0 +1,191 @@
import type { HonoEnv } from '../types/hono'
import { Hono } from 'hono'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createCharacterRoutes } from './characters'
describe('characterRoutes', () => {
let characterService: any
let app: Hono<HonoEnv>
beforeEach(() => {
characterService = {
findById: vi.fn(),
findByOwnerId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
}
auth = {
$Infer: {
Session: {
user: {},
session: {},
},
},
}
const routes = createCharacterRoutes(characterService)
app = new Hono<HonoEnv>()
app.use('*', async (c, next) => {
const user = (c.env as any)?.user
if (user) {
c.set('user', user)
}
await next()
})
app.route('/', routes)
})
it('get / should return unauthorized if no user', async () => {
const res = await app.request('/')
expect(res.status).toBe(401)
})
it('get / should return characters for user', async () => {
const mockUser = { id: 'user-1' }
const mockChars = [{ id: 'char-1' }]
characterService.findByOwnerId.mockResolvedValue(mockChars)
const res = await app.fetch(new Request('http://localhost/'), { user: mockUser } as any)
expect(res.status).toBe(200)
expect(await res.json()).toEqual(mockChars)
})
it('get /:id should return 404 if not found', async () => {
characterService.findById.mockResolvedValue(null)
const res = await app.request('/char-1')
expect(res.status).toBe(404)
})
it('get /:id should return character', async () => {
const mockChar = { id: 'char-1' }
characterService.findById.mockResolvedValue(mockChar)
const res = await app.request('/char-1')
expect(res.status).toBe(200)
expect(await res.json()).toEqual(mockChar)
})
it('post / should return unauthorized if no user', async () => {
const res = await app.request('/', { method: 'POST' })
expect(res.status).toBe(401)
})
it('post / should validate body and create character', async () => {
const mockUser = { id: 'user-1' }
const payload = {
character: { id: 'c1', version: '1', coverUrl: 'url', characterId: 'cid' },
}
characterService.create.mockResolvedValue({ id: 'new-id' })
const res = await app.fetch(new Request('http://localhost/', {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
}), { user: mockUser } as any)
expect(res.status).toBe(201)
})
it('post / should return 400 on invalid body', async () => {
const mockUser = { id: 'user-1' }
const res = await app.fetch(new Request('http://localhost/', {
method: 'POST',
body: JSON.stringify({ invalid: 'data' }),
headers: { 'Content-Type': 'application/json' },
}), { user: mockUser } as any)
expect(res.status).toBe(400)
})
it('patch /:id should return unauthorized if no user', async () => {
const res = await app.request('/c1', { method: 'PATCH' })
expect(res.status).toBe(401)
})
it('patch /:id should return 400 on invalid body', async () => {
const mockUser = { id: 'user-1' }
const res = await app.fetch(new Request('http://localhost/c1', {
method: 'PATCH',
body: JSON.stringify({ version: 123 }),
headers: { 'Content-Type': 'application/json' },
}), { user: mockUser } as any)
expect(res.status).toBe(400)
})
it('patch /:id should return 404 if not found', async () => {
const mockUser = { id: 'user-1' }
characterService.findById.mockResolvedValue(null)
const res = await app.fetch(new Request('http://localhost/c1', {
method: 'PATCH',
body: JSON.stringify({ version: '2' }),
headers: { 'Content-Type': 'application/json' },
}), { user: mockUser } as any)
expect(res.status).toBe(404)
})
it('patch /:id should return 403 if not owner', async () => {
const mockUser = { id: 'user-1' }
characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-2' })
const res = await app.fetch(new Request('http://localhost/c1', {
method: 'PATCH',
body: JSON.stringify({ version: '2' }),
headers: { 'Content-Type': 'application/json' },
}), { user: mockUser } as any)
expect(res.status).toBe(403)
})
it('patch /:id should update if owner', async () => {
const mockUser = { id: 'user-1' }
characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-1' })
characterService.update.mockResolvedValue({ id: 'c1', version: '2' })
const res = await app.fetch(new Request('http://localhost/c1', {
method: 'PATCH',
body: JSON.stringify({ version: '2' }),
headers: { 'Content-Type': 'application/json' },
}), { user: mockUser } as any)
expect(res.status).toBe(200)
})
it('patch /:id should update with empty body', async () => {
const mockUser = { id: 'user-1' }
characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-1' })
characterService.update.mockResolvedValue({ id: 'c1' })
const res = await app.fetch(new Request('http://localhost/c1', {
method: 'PATCH',
body: JSON.stringify({}),
headers: { 'Content-Type': 'application/json' },
}), { user: mockUser } as any)
expect(res.status).toBe(200)
})
it('delete /:id should return unauthorized if no user', async () => {
const res = await app.request('/c1', { method: 'DELETE' })
expect(res.status).toBe(401)
})
it('delete /:id should return 404 if not found', async () => {
const mockUser = { id: 'user-1' }
characterService.findById.mockResolvedValue(null)
const res = await app.fetch(new Request('http://localhost/c1', { method: 'DELETE' }), { user: mockUser } as any)
expect(res.status).toBe(404)
})
it('delete /:id should return 403 if not owner', async () => {
const mockUser = { id: 'user-1' }
characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-2' })
const res = await app.fetch(new Request('http://localhost/c1', { method: 'DELETE' }), { user: mockUser } as any)
expect(res.status).toBe(403)
})
it('delete /:id should delete if owner', async () => {
const mockUser = { id: 'user-1' }
characterService.findById.mockResolvedValue({ id: 'c1', ownerId: 'user-1' })
const res = await app.fetch(new Request('http://localhost/c1', { method: 'DELETE' }), { user: mockUser } as any)
expect(res.status).toBe(204)
})
})
+90
View File
@@ -0,0 +1,90 @@
import type { CharacterService } from '../services/characters'
import type { HonoEnv } from '../types/hono'
import { Hono } from 'hono'
import { safeParse } from 'valibot'
import { CreateCharacterSchema, UpdateCharacterSchema } from '../api/characters.schema'
import { authGuard } from '../middlewares/auth'
import { createBadRequestError, createForbiddenError, createNotFoundError } from '../utils/error'
export function createCharacterRoutes(characterService: CharacterService) {
const app = new Hono<HonoEnv>()
app.use('*', authGuard)
app.get('/', async (c) => {
const user = c.get('user')!
const characters = await characterService.findByOwnerId(user.id)
return c.json(characters)
})
app.get('/:id', async (c) => {
const id = c.req.param('id')
const character = await characterService.findById(id)
if (!character)
throw createNotFoundError()
return c.json(character)
})
app.post('/', async (c) => {
const user = c.get('user')!
const body = await c.req.json()
const result = safeParse(CreateCharacterSchema, body)
if (!result.success) {
throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues)
}
const character = await characterService.create({
...result.output,
character: {
...result.output.character,
ownerId: user.id,
creatorId: user.id,
},
} as any)
return c.json(character, 201)
})
app.patch('/:id', async (c) => {
const user = c.get('user')!
const id = c.req.param('id')
const body = await c.req.json()
const result = safeParse(UpdateCharacterSchema, body)
if (!result.success) {
throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues)
}
const existing = await characterService.findById(id)
if (!existing)
throw createNotFoundError()
if (existing.ownerId !== user.id)
throw createForbiddenError()
const updated = await characterService.update(id, result.output)
return c.json(updated)
})
app.delete('/:id', async (c) => {
const user = c.get('user')!
const id = c.req.param('id')
const existing = await characterService.findById(id)
if (!existing)
throw createNotFoundError()
if (existing.ownerId !== user.id)
throw createForbiddenError()
await characterService.delete(id)
return c.body(null, 204)
})
return app
}
+93
View File
@@ -0,0 +1,93 @@
import { relations } from 'drizzle-orm'
import { boolean, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
})
export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
table => [index('session_userId_idx').on(table.userId)],
)
export const account = pgTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
table => [index('account_userId_idx').on(table.userId)],
)
export const verification = pgTable(
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
table => [index('verification_identifier_idx').on(table.identifier)],
)
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}))
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}))
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}))
+171
View File
@@ -0,0 +1,171 @@
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import type { AvatarModelConfig } from '../types/character-avatar-model'
import type { CharacterCapabilityConfig } from '../types/character-capability'
import { relations } from 'drizzle-orm'
import { jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { user } from './accounts'
export const character = pgTable(
'characters',
{
id: text('id').primaryKey(),
version: text('version').notNull(),
coverUrl: text('cover_url').notNull(),
// TODO: json patch?
creatorId: text('creator_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
ownerId: text('owner_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
characterId: text('character_id').notNull(),
// TODO: Live2d and VRM
// TODO: Memory
// TODO: Skills and MCP
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
export type Character = InferSelectModel<typeof character>
export type NewCharacter = InferInsertModel<typeof character>
export const avatarModel = pgTable(
'avatar_model',
{
id: text('id').primaryKey(),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
type: text('type').notNull().$type<keyof AvatarModelConfig>(),
description: text('description').notNull(),
config: jsonb('config').notNull().$type<AvatarModelConfig[keyof AvatarModelConfig]>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
export type AvatarModel = InferSelectModel<typeof avatarModel>
export type NewAvatarModel = InferInsertModel<typeof avatarModel>
export const characterCapabilities = pgTable(
'character_capabilities',
{
id: text('id').primaryKey(),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
type: text('type').notNull().$type<keyof CharacterCapabilityConfig>(),
config: jsonb('config').notNull().$type<CharacterCapabilityConfig[keyof CharacterCapabilityConfig]>(),
},
)
export type CharacterCapability = InferSelectModel<typeof characterCapabilities>
export type NewCharacterCapability = InferInsertModel<typeof characterCapabilities>
export const characterI18n = pgTable(
'character_i18n',
{
id: text('id').primaryKey(),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
language: text('language').notNull(),
name: text('name').notNull(),
description: text('description').notNull(),
tags: text('tags').array().notNull(),
// TODO: Implement the system prompt
// systemPrompt: text('system_prompt').notNull(),
// TODO: Implement the personality
// personality: text('personality').notNull(),
// TODO: Implement the initial memories
// initialMemories: text('initial_memories').array().notNull(),
// TODO: greetings?
// TODO: notes?
// TODO: metadata?
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
export type CharacterI18n = InferSelectModel<typeof characterI18n>
export type NewCharacterI18n = InferInsertModel<typeof characterI18n>
type PromptType = 'system' | 'personality' | 'greetings'
export const characterPrompts = pgTable(
'character_prompts',
{
id: text('id').primaryKey(),
characterId: text('character_id').notNull().references(() => character.id, { onDelete: 'cascade' }),
language: text('language').notNull(),
type: text('type').notNull().$type<PromptType>(),
content: text('content').notNull(),
},
)
export type CharacterPrompt = InferSelectModel<typeof characterPrompts>
export type NewCharacterPrompt = InferInsertModel<typeof characterPrompts>
export const characterRelations = relations(
character,
({ one, many }) => ({
capabilities: many(characterCapabilities),
avatarModels: many(avatarModel),
i18n: many(characterI18n),
prompts: many(characterPrompts),
owner: one(user, {
fields: [character.ownerId],
references: [user.id],
}),
}),
)
export const avatarModelRelations = relations(
avatarModel,
({ one }) => ({
character: one(character, {
fields: [avatarModel.characterId],
references: [character.id],
}),
}),
)
export const characterCapabilitiesRelations = relations(
characterCapabilities,
({ one }) => ({
character: one(character, {
fields: [characterCapabilities.characterId],
references: [character.id],
}),
}),
)
export const characterI18nRelations = relations(
characterI18n,
({ one }) => ({
character: one(character, {
fields: [characterI18n.characterId],
references: [character.id],
}),
}),
)
export const characterPromptsRelations = relations(
characterPrompts,
({ one }) => ({
character: one(character, {
fields: [characterPrompts.characterId],
references: [character.id],
}),
}),
)
+81
View File
@@ -0,0 +1,81 @@
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { user } from './accounts'
export const media = pgTable(
'media',
{
id: text('id').primaryKey(),
url: text('url').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
export const stickers = pgTable(
'stickers',
{
id: text('id').primaryKey(),
url: text('url').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
export const stickerPacks = pgTable(
'sticker_packs',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
description: text('description').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
)
type ChatType = 'private' | 'bot' | 'group' | 'channel'
export const chats = pgTable(
'chats',
{
id: text('id').primaryKey(),
type: text('type').notNull().$type<ChatType>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
)
export const chatMembers = pgTable(
'chat_members',
{
id: text('id').primaryKey(),
chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
},
)
export const messages = pgTable(
'messages',
{
id: text('id').primaryKey(),
chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
senderId: text('sender_id').notNull(),
content: text('content').notNull(),
mediaIds: text('media_ids').array().notNull(),
stickerIds: text('sticker_ids').array().notNull(),
replyToMessageId: text('reply_message_id'),
forwardFromMessageId: text('forward_from_message_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
deletedAt: timestamp('deleted_at'),
},
)
+3
View File
@@ -0,0 +1,3 @@
export * from './accounts'
export * from './characters'
export * from './chats'
+1 -1
View File
@@ -7,7 +7,7 @@ import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { bearer } from 'better-auth/plugins'
import * as authSchema from '../schemas/auth'
import * as authSchema from '../schemas/accounts'
export function createAuth(db: Database, env: Env) {
return betterAuth({
+156
View File
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createCharacterService } from './characters'
import * as schema from '../schemas/characters'
describe('characterService', () => {
let db: any
let service: ReturnType<typeof createCharacterService>
beforeEach(() => {
db = {
query: {
character: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(),
})),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(),
})),
})),
transaction: vi.fn(async (cb: any) => {
const tx = {
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(),
})),
})),
}
return await cb(tx)
}),
}
service = createCharacterService(db)
})
it('findById should return a character with relations', async () => {
const mockChar = { id: '1', name: 'Test' }
db.query.character.findFirst.mockResolvedValue(mockChar)
const result = await service.findById('1')
expect(result).toEqual(mockChar)
expect(db.query.character.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: expect.any(Object),
with: {
capabilities: true,
avatarModels: true,
i18n: true,
prompts: true,
},
}))
})
it('findByOwnerId should return user characters', async () => {
const mockChars = [{ id: '1' }]
db.query.character.findMany.mockResolvedValue(mockChars)
const result = await service.findByOwnerId('user-1')
expect(result).toEqual(mockChars)
expect(db.query.character.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.any(Object),
with: { i18n: true },
}))
})
it('create should handle full character creation in transaction', async () => {
const characterData = { id: 'char-1', name: 'Test' } as any
const insertedChar = { id: 'char-1' }
db.transaction.mockImplementation(async (cb: any) => {
const tx = {
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn().mockResolvedValue([insertedChar]),
})),
})),
}
return await cb(tx)
})
const result = await service.create({
character: characterData,
capabilities: [{ id: 'cap-1', type: 'llm', config: {} } as any],
})
expect(result).toEqual(insertedChar)
expect(db.transaction).toHaveBeenCalled()
})
it('create should handle all optional relations', async () => {
const characterData = { id: 'char-1' } as any
const insertedChar = { id: 'char-1' }
db.transaction.mockImplementation(async (cb: any) => {
const tx = {
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn().mockResolvedValue([insertedChar]),
})),
})),
}
return await cb(tx)
})
await service.create({
character: characterData,
avatarModels: [{ id: 'am-1' } as any],
i18n: [{ id: 'i18n-1' } as any],
prompts: [{ id: 'p-1' } as any],
})
expect(db.transaction).toHaveBeenCalled()
})
it('update should update character and updatedAt', async () => {
const updateData = { version: '2.0' }
const mockReturning = [{ id: '1', ...updateData }]
// Setup nested mocks for update chain
const returningMock = vi.fn().mockResolvedValue(mockReturning)
const whereMock = vi.fn(() => ({ returning: returningMock }))
const setMock = vi.fn(() => ({ where: whereMock }))
db.update.mockReturnValue({ set: setMock })
const result = await service.update('1', updateData)
expect(result).toEqual(mockReturning)
expect(setMock).toHaveBeenCalledWith(expect.objectContaining({
...updateData,
updatedAt: expect.any(Date),
}))
})
it('delete should remove character', async () => {
const mockReturning = [{ id: '1' }]
const returningMock = vi.fn().mockResolvedValue(mockReturning)
const whereMock = vi.fn(() => ({ returning: returningMock }))
db.delete.mockReturnValue({ where: whereMock })
const result = await service.delete('1')
expect(result).toEqual(mockReturning)
expect(db.delete).toHaveBeenCalledWith(schema.character)
})
})
+84
View File
@@ -0,0 +1,84 @@
import type * as fullSchema from '../schemas'
import type { Database } from './db'
import { eq } from 'drizzle-orm'
import * as schema from '../schemas/characters'
export function createCharacterService(db: Database<typeof fullSchema>) {
return {
async findById(id: string) {
return await db.query.character.findFirst({
where: eq(schema.character.id, id),
with: {
capabilities: true,
avatarModels: true,
i18n: true,
prompts: true,
},
})
},
async findByOwnerId(ownerId: string) {
return await db.query.character.findMany({
where: eq(schema.character.ownerId, ownerId),
with: {
i18n: true,
},
})
},
async create(data: {
character: schema.NewCharacter
capabilities?: Omit<schema.NewCharacterCapability, 'characterId'>[]
avatarModels?: Omit<schema.NewAvatarModel, 'characterId'>[]
i18n?: Omit<schema.NewCharacterI18n, 'characterId'>[]
prompts?: Omit<schema.NewCharacterPrompt, 'characterId'>[]
}) {
return await db.transaction(async (tx) => {
const [inserted] = await tx.insert(schema.character).values(data.character).returning()
if (data.capabilities?.length) {
await tx.insert(schema.characterCapabilities).values(
data.capabilities.map(c => ({ ...c, characterId: inserted.id }) as schema.NewCharacterCapability),
)
}
if (data.avatarModels?.length) {
await tx.insert(schema.avatarModel).values(
data.avatarModels.map(a => ({ ...a, characterId: inserted.id }) as schema.NewAvatarModel),
)
}
if (data.i18n?.length) {
await tx.insert(schema.characterI18n).values(
data.i18n.map(i => ({ ...i, characterId: inserted.id }) as schema.NewCharacterI18n),
)
}
if (data.prompts?.length) {
await tx.insert(schema.characterPrompts).values(
data.prompts.map(p => ({ ...p, characterId: inserted.id }) as schema.NewCharacterPrompt),
)
}
return inserted
})
},
async update(id: string, data: Partial<schema.NewCharacter>) {
return await db.update(schema.character)
.set({ ...data, updatedAt: new Date() })
.where(eq(schema.character.id, id))
.returning()
},
async delete(id: string) {
return await db.delete(schema.character)
.where(eq(schema.character.id, id))
.returning()
},
}
}
export type CharacterService = ReturnType<typeof createCharacterService>
+3 -3
View File
@@ -2,8 +2,8 @@ import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
export type Database = ReturnType<typeof createDrizzle>
export type Database<TSchema extends Record<string, unknown> = Record<string, unknown>> = ReturnType<typeof createDrizzle<TSchema>>
export function createDrizzle(dsn: string) {
return drizzle(postgres(dsn))
export function createDrizzle<TSchema extends Record<string, unknown>>(dsn: string, schema?: TSchema) {
return drizzle(postgres(dsn), { schema })
}
@@ -0,0 +1,9 @@
// TODO: Implement the config for the avatar model
export interface AvatarModelConfig {
vrm: {
urls: string[]
}
live2d: {
urls: string[]
}
}
@@ -0,0 +1,24 @@
interface CharacterCapabilityBaseConfig {
apiKey: string
apiBaseUrl: string
}
// TODO: Implement the config for the character capability
export interface CharacterCapabilityConfig extends CharacterCapabilityBaseConfig {
llm: {
temperature: number
model: string
}
tts: {
ssml: string
voiceId: string
speed: number
pitch: number
}
vlm: {
image: string
}
asr: {
audio: string
}
}
+8
View File
@@ -0,0 +1,8 @@
import type auth from '../scripts/auth'
export interface HonoEnv {
Variables: {
user: typeof auth.$Infer.Session.user | null
session: typeof auth.$Infer.Session.session | null
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status'
export class ApiError extends Error {
constructor(
public readonly statusCode: ContentfulStatusCode,
public readonly errorCode: string,
message: string,
public readonly details?: unknown,
) {
super(message)
this.name = 'ApiError'
}
}
/**
* Creates an internal server error (500)
*/
export function createInternalError(message = 'Internal Server Error', details?: unknown) {
return new ApiError(500, 'INTERNAL_SERVER_ERROR', message, details)
}
/**
* Creates a bad request error (400)
*/
export function createBadRequestError(message: string, errorCode = 'BAD_REQUEST', details?: unknown) {
return new ApiError(400, errorCode, message, details)
}
/**
* Creates an unauthorized error (401)
*/
export function createUnauthorizedError(message = 'Unauthorized', details?: unknown) {
return new ApiError(401, 'UNAUTHORIZED', message, details)
}
/**
* Creates a forbidden error (403)
*/
export function createForbiddenError(message = 'Forbidden', details?: unknown) {
return new ApiError(403, 'FORBIDDEN', message, details)
}
/**
* Creates a not found error (404)
*/
export function createNotFoundError(message = 'Not Found', details?: unknown) {
return new ApiError(404, 'NOT_FOUND', message, details)
}
/**
* Creates a conflict error (409)
*/
export function createConflictError(message: string, details?: unknown) {
return new ApiError(409, 'CONFLICT', message, details)
}