refactor: platform

This commit is contained in:
Acbox
2026-01-15 15:24:40 +08:00
parent a61a1e76b2
commit afc6dc6cb1
15 changed files with 286 additions and 270 deletions
-2
View File
@@ -16,8 +16,6 @@
"db:migrate": "pnpm --filter @memoh/db migrate", "db:migrate": "pnpm --filter @memoh/db migrate",
"db:generate": "pnpm --filter @memoh/db generate", "db:generate": "pnpm --filter @memoh/db generate",
"db:studio": "pnpm --filter @memoh/db studio", "db:studio": "pnpm --filter @memoh/db studio",
"telegram:start": "pnpm --filter @memoh/platform-telegram start",
"telegram:dev": "pnpm --filter @memoh/platform-telegram dev",
"cli": "pnpm --filter @memoh/client start", "cli": "pnpm --filter @memoh/client start",
"docs:dev": "pnpm --filter @memoh/docs dev", "docs:dev": "pnpm --filter @memoh/docs dev",
"docs:build": "pnpm --filter @memoh/docs build", "docs:build": "pnpm --filter @memoh/docs build",
+2
View File
@@ -21,6 +21,8 @@
"@memoh/db": "workspace:*", "@memoh/db": "workspace:*",
"@memoh/memory": "workspace:*", "@memoh/memory": "workspace:*",
"@memoh/shared": "workspace:*", "@memoh/shared": "workspace:*",
"@memoh/platform": "workspace:*",
"@memoh/platform-telegram": "workspace:*",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"elysia": "latest", "elysia": "latest",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
+11 -3
View File
@@ -7,6 +7,7 @@ import {
DeletePlatformModel, DeletePlatformModel,
UpdatePlatformConfigModel, UpdatePlatformConfigModel,
SetPlatformActiveModel, SetPlatformActiveModel,
getPlatformConfigSchema,
} from './model' } from './model'
import { import {
getPlatforms, getPlatforms,
@@ -34,7 +35,6 @@ export const platformModule = new Elysia({
await activePlatform({ await activePlatform({
id: platform.id, id: platform.id,
name: platform.name, name: platform.name,
endpoint: platform.endpoint,
config: platform.config as Record<string, unknown>, config: platform.config as Record<string, unknown>,
active: platform.active, active: platform.active,
}) })
@@ -140,13 +140,21 @@ export const platformModule = new Elysia({
try { try {
const { id } = params const { id } = params
const { config } = body as { config: Record<string, unknown> } const { config } = body as { config: Record<string, unknown> }
const updatedPlatform = await updatePlatformConfig(id, config)
if (!updatedPlatform) { // Get the platform to validate config against its schema
const platform = await getPlatformById(id)
if (!platform) {
return { return {
success: false, success: false,
error: 'Platform not found', error: 'Platform not found',
} }
} }
// Validate config against platform-specific schema
const configSchema = getPlatformConfigSchema(platform.name)
const validatedConfig = configSchema.parse(config) as Record<string, unknown>
const updatedPlatform = await updatePlatformConfig(id, validatedConfig)
return { return {
success: true, success: true,
data: updatedPlatform, data: updatedPlatform,
+52 -1
View File
@@ -1,10 +1,59 @@
import { z } from 'zod' import { z } from 'zod'
// Platform-specific config schemas
export const TelegramConfigSchema = z.object({
botToken: z.string().min(1, 'Bot token is required'),
})
// Registry of platform config schemas
// When adding a new platform, add its config schema here
export const platformConfigSchemas: Record<string, z.ZodSchema> = {
telegram: TelegramConfigSchema,
// Add more platforms here as they are implemented
// discord: DiscordConfigSchema,
// slack: SlackConfigSchema,
}
// Helper function to get config schema for a platform
export const getPlatformConfigSchema = (platformName: string): z.ZodSchema => {
const schema = platformConfigSchemas[platformName]
if (!schema) {
throw new Error(`Unknown platform: ${platformName}. Supported platforms: ${Object.keys(platformConfigSchemas).join(', ')}`)
}
return schema
}
// Base platform schema with dynamic config validation
const PlatformSchema = z.object({ const PlatformSchema = z.object({
name: z.string().min(1, 'Platform name is required'), name: z.string().min(1, 'Platform name is required'),
endpoint: z.string().min(1, 'Endpoint is required'),
config: z.record(z.string(), z.unknown()), config: z.record(z.string(), z.unknown()),
active: z.boolean().optional().default(true), active: z.boolean().optional().default(true),
}).superRefine((data, ctx) => {
// Validate that the platform name is supported
if (!platformConfigSchemas[data.name]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Unknown platform: ${data.name}. Supported platforms: ${Object.keys(platformConfigSchemas).join(', ')}`,
path: ['name'],
})
return
}
// Validate the config against the platform-specific schema
try {
const configSchema = getPlatformConfigSchema(data.name)
configSchema.parse(data.config)
} catch (error) {
if (error instanceof z.ZodError) {
error.issues.forEach((issue: z.ZodIssue) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: issue.message,
path: ['config', ...issue.path],
})
})
}
}
}) })
export type PlatformInput = z.infer<typeof PlatformSchema> export type PlatformInput = z.infer<typeof PlatformSchema>
@@ -32,6 +81,8 @@ export const DeletePlatformModel = {
}), }),
} }
// For updating config, we need to know the platform name to validate
// This will be used with additional validation in the route handler
export const UpdatePlatformConfigModel = { export const UpdatePlatformConfigModel = {
params: z.object({ params: z.object({
id: z.string(), id: z.string(),
+26 -30
View File
@@ -3,15 +3,12 @@ import { platform } from '@memoh/db/schema'
import { Platform } from '@memoh/shared' import { Platform } from '@memoh/shared'
import { eq, sql, desc, asc } from 'drizzle-orm' import { eq, sql, desc, asc } from 'drizzle-orm'
import { calculateOffset, createPaginatedResult, type PaginatedResult } from '../../utils/pagination' import { calculateOffset, createPaginatedResult, type PaginatedResult } from '../../utils/pagination'
import path from 'node:path' import { BasePlatform } from '@memoh/platform'
import { TelegramPlatform } from '@memoh/platform-telegram'
/**
* 平台列表返回类型
*/
type PlatformListItem = { type PlatformListItem = {
id: string id: string
name: string name: string
endpoint: string
config: Record<string, unknown> config: Record<string, unknown>
active: boolean active: boolean
createdAt: Date createdAt: Date
@@ -72,7 +69,6 @@ export const createPlatform = async (data: Omit<Platform, 'id'>) => {
.insert(platform) .insert(platform)
.values({ .values({
name: data.name, name: data.name,
endpoint: data.endpoint,
config: data.config, config: data.config,
active: data.active ?? true, active: data.active ?? true,
}) })
@@ -81,7 +77,6 @@ export const createPlatform = async (data: Omit<Platform, 'id'>) => {
await activePlatform({ await activePlatform({
id: newPlatform.id, id: newPlatform.id,
name: newPlatform.name, name: newPlatform.name,
endpoint: newPlatform.endpoint,
config: newPlatform.config as Record<string, unknown>, config: newPlatform.config as Record<string, unknown>,
active: newPlatform.active, active: newPlatform.active,
}) })
@@ -92,7 +87,6 @@ export const createPlatform = async (data: Omit<Platform, 'id'>) => {
export const updatePlatform = async (id: string, data: Partial<Omit<Platform, 'id'>>) => { export const updatePlatform = async (id: string, data: Partial<Omit<Platform, 'id'>>) => {
const updateData: { const updateData: {
name?: string name?: string
endpoint?: string
config?: Record<string, unknown> config?: Record<string, unknown>
active?: boolean active?: boolean
updatedAt: Date updatedAt: Date
@@ -101,7 +95,6 @@ export const updatePlatform = async (id: string, data: Partial<Omit<Platform, 'i
} }
if (data.name !== undefined) updateData.name = data.name if (data.name !== undefined) updateData.name = data.name
if (data.endpoint !== undefined) updateData.endpoint = data.endpoint
if (data.config !== undefined) updateData.config = data.config if (data.config !== undefined) updateData.config = data.config
if (data.active !== undefined) updateData.active = data.active if (data.active !== undefined) updateData.active = data.active
@@ -135,23 +128,29 @@ export const updatePlatformConfig = async (id: string, config: Record<string, un
// active // active
export const platformConstructors: Record<string, typeof BasePlatform> = {
telegram: TelegramPlatform,
}
export const platforms = new Map<string, BasePlatform>()
export const activePlatform = async (platform: Platform) => { export const activePlatform = async (platform: Platform) => {
await fetch(path.join(platform.endpoint, '/start'), { const Constructor = platformConstructors[platform.name]
method: 'POST', if (!Constructor) {
body: JSON.stringify(platform.config), throw new Error('Platform constructor not found')
headers: { }
'Content-Type': 'application/json', const platformInstance = new Constructor()
}, await platformInstance.start(platform.config)
}) platforms.set(platform.name, platformInstance)
} }
export const inactivePlatform = async (platform: Platform) => { export const inactivePlatform = async (platform: Platform) => {
await fetch(path.join(platform.endpoint, '/stop'), { const platformInstance = platforms.get(platform.name)
method: 'POST', if (!platformInstance) {
headers: { throw new Error('Platform not found')
'Content-Type': 'application/json', }
}, await platformInstance.stop()
}) platforms.delete(platform.name)
} }
export const setActivePlatform = async (id: string, active: boolean) => { export const setActivePlatform = async (id: string, active: boolean) => {
@@ -162,7 +161,6 @@ export const setActivePlatform = async (id: string, active: boolean) => {
const platformData: Platform = { const platformData: Platform = {
id: currentPlatform.id, id: currentPlatform.id,
name: currentPlatform.name, name: currentPlatform.name,
endpoint: currentPlatform.endpoint,
config: currentPlatform.config as Record<string, unknown>, config: currentPlatform.config as Record<string, unknown>,
active: active, active: active,
} }
@@ -187,11 +185,9 @@ export const sendMessageToPlatform = async (name: string, options: {
if (!currentPlatform) { if (!currentPlatform) {
throw new Error('Platform not found') throw new Error('Platform not found')
} }
await fetch(path.join(currentPlatform.endpoint, '/send'), { const platformInstance = platforms.get(currentPlatform.name)
method: 'POST', if (!platformInstance) {
body: JSON.stringify(options), throw new Error('Platform not found')
headers: { }
'Content-Type': 'application/json', await platformInstance.send(options)
},
})
} }
+113 -82
View File
@@ -6,6 +6,7 @@ import { table } from 'table'
import * as platformCore from '../../core/platform' import * as platformCore from '../../core/platform'
import { formatError } from '../../utils' import { formatError } from '../../utils'
import { getApiUrl } from '../../core/client' import { getApiUrl } from '../../core/client'
import { PLATFORM_DEFINITIONS, type PlatformDefinition } from '../../types'
export function platformCommands(program: Command) { export function platformCommands(program: Command) {
program program
@@ -23,11 +24,10 @@ export function platformCommands(program: Command) {
} }
const tableData = [ const tableData = [
['ID', 'Name', 'Endpoint', 'Active', 'Created'], ['ID', 'Name', 'Active', 'Created'],
...platforms.map((item) => [ ...platforms.map((item) => [
item.id.substring(0, 8) + '...', item.id.substring(0, 8) + '...',
item.name, item.name,
item.endpoint,
item.active ? chalk.green('✓ Active') : chalk.dim('✗ Inactive'), item.active ? chalk.green('✓ Active') : chalk.dim('✗ Inactive'),
new Date(item.createdAt).toLocaleDateString(), new Date(item.createdAt).toLocaleDateString(),
]), ]),
@@ -55,83 +55,73 @@ export function platformCommands(program: Command) {
program program
.command('create') .command('create')
.description('Create platform configuration') .description('Create platform configuration')
.option('-n, --name <name>', 'Platform name') .action(async () => {
.option('-e, --endpoint <endpoint>', 'Platform endpoint URL')
.option('-c, --config <config>', 'Platform config (JSON string)')
.option('-a, --active', 'Set platform as active', true)
.action(async (options) => {
try { try {
let { name, endpoint, config, active } = options // Step 1: Select platform type
const { platformType } = await inquirer.prompt([
{
type: 'list',
name: 'platformType',
message: 'Select platform type:',
choices: PLATFORM_DEFINITIONS.map((def) => ({
name: `${def.displayName} - ${def.description}`,
value: def.name,
})),
},
])
if (!name || !endpoint) { const platformDef = PLATFORM_DEFINITIONS.find((def) => def.name === platformType)
const answers = await inquirer.prompt([ if (!platformDef) {
console.error(chalk.red('Invalid platform type'))
process.exit(1)
}
// Step 2: Collect platform-specific config
console.log(chalk.cyan(`\nConfiguring ${platformDef.displayName}...\n`))
const configAnswers: Record<string, unknown> = {}
for (const field of platformDef.configFields) {
const answer = await inquirer.prompt([
{ {
type: 'input', type: field.type || 'input',
name: 'name', name: field.name,
message: 'Platform name:', message: field.message,
when: !name, default: field.default,
validate: (value: string) => { validate: field.validate || ((value: string) => {
if (value.trim()) return true if (field.required && !value?.toString().trim()) {
return 'Platform name is required' return `${field.name} is required`
}, }
}, return true
{ }),
type: 'input',
name: 'endpoint',
message: 'Platform endpoint URL:',
when: !endpoint,
validate: (value: string) => {
if (value.trim()) return true
return 'Endpoint is required'
},
},
{
type: 'input',
name: 'config',
message: 'Platform config (JSON string):',
default: '{}',
when: !config,
},
{
type: 'confirm',
name: 'active',
message: 'Set as active?',
default: true,
when: active === undefined,
}, },
]) ])
configAnswers[field.name] = answer[field.name]
name = name || answers.name
endpoint = endpoint || answers.endpoint
config = config || answers.config
active = active ?? answers.active
} }
// Parse config JSON // Step 3: Confirm active status
let configObj: Record<string, unknown> = {} const { active } = await inquirer.prompt([
if (config) { {
try { type: 'confirm',
configObj = JSON.parse(config) name: 'active',
} catch { message: 'Set as active?',
console.error(chalk.red('Invalid JSON config')) default: true,
process.exit(1) },
} ])
}
const spinner = ora('Creating platform configuration...').start() const spinner = ora('Creating platform configuration...').start()
const platform = await platformCore.createPlatform({ const platform = await platformCore.createPlatform({
name, name: platformType,
endpoint, config: configAnswers,
config: configObj,
active, active,
}) })
spinner.succeed(chalk.green('Platform configuration created successfully')) spinner.succeed(chalk.green('Platform configuration created successfully'))
console.log(chalk.blue(`Name: ${platform.name}`)) console.log(chalk.blue(`\nPlatform: ${platformDef.displayName}`))
console.log(chalk.blue(`Endpoint: ${platform.endpoint}`)) console.log(chalk.blue(`Type: ${platform.name}`))
console.log(chalk.blue(`Active: ${platform.active ? 'Yes' : 'No'}`)) console.log(chalk.blue(`Active: ${platform.active ? 'Yes' : 'No'}`))
console.log(chalk.blue(`ID: ${platform.id}`)) console.log(chalk.blue(`ID: ${platform.id}`))
console.log(chalk.dim(`\nConfig: ${JSON.stringify(platform.config, null, 2)}`))
} catch (error) { } catch (error) {
console.error(chalk.red(formatError(error))) console.error(chalk.red(formatError(error)))
process.exit(1) process.exit(1)
@@ -145,10 +135,11 @@ export function platformCommands(program: Command) {
const spinner = ora('Fetching platform configuration...').start() const spinner = ora('Fetching platform configuration...').start()
try { try {
const platform = await platformCore.getPlatform(id) const platform = await platformCore.getPlatform(id)
const platformDef = PLATFORM_DEFINITIONS.find((def) => def.name === platform.name)
spinner.succeed(chalk.green('Platform Configuration')) spinner.succeed(chalk.green('Platform Configuration'))
console.log(chalk.blue(`ID: ${platform.id}`)) console.log(chalk.blue(`ID: ${platform.id}`))
console.log(chalk.blue(`Name: ${platform.name}`)) console.log(chalk.blue(`Platform: ${platformDef?.displayName || platform.name}`))
console.log(chalk.blue(`Endpoint: ${platform.endpoint}`)) console.log(chalk.blue(`Type: ${platform.name}`))
console.log(chalk.blue(`Active: ${platform.active ? 'Yes' : 'No'}`)) console.log(chalk.blue(`Active: ${platform.active ? 'Yes' : 'No'}`))
console.log(chalk.blue(`Config: ${JSON.stringify(platform.config, null, 2)}`)) console.log(chalk.blue(`Config: ${JSON.stringify(platform.config, null, 2)}`))
console.log(chalk.blue(`Created At: ${new Date(platform.createdAt).toLocaleString('en-US')}`)) console.log(chalk.blue(`Created At: ${new Date(platform.createdAt).toLocaleString('en-US')}`))
@@ -163,8 +154,7 @@ export function platformCommands(program: Command) {
program program
.command('update <id>') .command('update <id>')
.description('Update platform configuration') .description('Update platform configuration')
.option('-n, --name <name>', 'Platform name') .option('-n, --name <name>', 'Platform type (e.g., telegram)')
.option('-e, --endpoint <endpoint>', 'Platform endpoint URL')
.option('-c, --config <config>', 'Platform config (JSON string)') .option('-c, --config <config>', 'Platform config (JSON string)')
.option('-a, --active <active>', 'Set active status (true/false)') .option('-a, --active <active>', 'Set active status (true/false)')
.action(async (id, options) => { .action(async (id, options) => {
@@ -172,7 +162,6 @@ export function platformCommands(program: Command) {
const updates: Record<string, unknown> = {} const updates: Record<string, unknown> = {}
if (options.name) updates.name = options.name if (options.name) updates.name = options.name
if (options.endpoint) updates.endpoint = options.endpoint
if (options.config) { if (options.config) {
try { try {
updates.config = JSON.parse(options.config) updates.config = JSON.parse(options.config)
@@ -193,8 +182,9 @@ export function platformCommands(program: Command) {
const spinner = ora('Updating platform configuration...').start() const spinner = ora('Updating platform configuration...').start()
const platform = await platformCore.updatePlatform(id, updates as any) const platform = await platformCore.updatePlatform(id, updates as any)
spinner.succeed(chalk.green('Platform configuration updated successfully')) spinner.succeed(chalk.green('Platform configuration updated successfully'))
console.log(chalk.blue(`Name: ${platform.name}`)) const platformDef = PLATFORM_DEFINITIONS.find((def) => def.name === platform.name)
console.log(chalk.blue(`Endpoint: ${platform.endpoint}`)) console.log(chalk.blue(`Platform: ${platformDef?.displayName || platform.name}`))
console.log(chalk.blue(`Type: ${platform.name}`))
console.log(chalk.blue(`Active: ${platform.active ? 'Yes' : 'No'}`)) console.log(chalk.blue(`Active: ${platform.active ? 'Yes' : 'No'}`))
} catch (error) { } catch (error) {
console.error(chalk.red(formatError(error))) console.error(chalk.red(formatError(error)))
@@ -204,23 +194,62 @@ export function platformCommands(program: Command) {
program program
.command('update-config <id>') .command('update-config <id>')
.description('Update platform config only') .description('Update platform config interactively or via JSON')
.requiredOption('-c, --config <config>', 'Platform config (JSON string)') .option('-c, --config <config>', 'Platform config (JSON string)')
.action(async (id, options) => { .action(async (id, options) => {
try { try {
let configObj: Record<string, unknown> let configObj: Record<string, unknown>
try {
configObj = JSON.parse(options.config) if (options.config) {
} catch { // Use provided JSON config
console.error(chalk.red('Invalid JSON config')) try {
process.exit(1) configObj = JSON.parse(options.config)
} catch {
console.error(chalk.red('Invalid JSON config'))
process.exit(1)
}
} else {
// Interactive mode - get current platform first
const spinner = ora('Fetching platform...').start()
const platform = await platformCore.getPlatform(id)
spinner.stop()
const platformDef = PLATFORM_DEFINITIONS.find((def) => def.name === platform.name)
if (!platformDef) {
console.error(chalk.red(`Unknown platform type: ${platform.name}`))
process.exit(1)
}
console.log(chalk.cyan(`\nUpdating config for ${platformDef.displayName}...\n`))
configObj = {}
for (const field of platformDef.configFields) {
const currentValue = (platform.config as Record<string, unknown>)[field.name]
const answer = await inquirer.prompt([
{
type: field.type || 'input',
name: field.name,
message: field.message,
default: currentValue || field.default,
validate: field.validate || ((value: string) => {
if (field.required && !value?.toString().trim()) {
return `${field.name} is required`
}
return true
}),
},
])
configObj[field.name] = answer[field.name]
}
} }
const spinner = ora('Updating platform config...').start() const spinner = ora('Updating platform config...').start()
const platform = await platformCore.updatePlatformConfig(id, configObj) const platform = await platformCore.updatePlatformConfig(id, configObj)
spinner.succeed(chalk.green('Platform config updated successfully')) spinner.succeed(chalk.green('Platform config updated successfully'))
console.log(chalk.blue(`Name: ${platform.name}`)) const platformDef = PLATFORM_DEFINITIONS.find((def) => def.name === platform.name)
console.log(chalk.blue(`Config: ${JSON.stringify(platform.config, null, 2)}`)) console.log(chalk.blue(`\nPlatform: ${platformDef?.displayName || platform.name}`))
console.log(chalk.blue(`Type: ${platform.name}`))
console.log(chalk.dim(`Config: ${JSON.stringify(platform.config, null, 2)}`))
} catch (error) { } catch (error) {
console.error(chalk.red(formatError(error))) console.error(chalk.red(formatError(error)))
process.exit(1) process.exit(1)
@@ -263,8 +292,9 @@ export function platformCommands(program: Command) {
try { try {
const platform = await platformCore.activatePlatform(id) const platform = await platformCore.activatePlatform(id)
spinner.succeed(chalk.green('Platform activated successfully')) spinner.succeed(chalk.green('Platform activated successfully'))
console.log(chalk.blue(`Name: ${platform.name}`)) const platformDef = PLATFORM_DEFINITIONS.find((def) => def.name === platform.name)
console.log(chalk.blue(`Endpoint: ${platform.endpoint}`)) console.log(chalk.blue(`Platform: ${platformDef?.displayName || platform.name}`))
console.log(chalk.blue(`Type: ${platform.name}`))
console.log(chalk.blue(`Active: ${platform.active ? chalk.green('Yes') : 'No'}`)) console.log(chalk.blue(`Active: ${platform.active ? chalk.green('Yes') : 'No'}`))
} catch (error) { } catch (error) {
spinner.fail(chalk.red('Operation failed')) spinner.fail(chalk.red('Operation failed'))
@@ -281,8 +311,9 @@ export function platformCommands(program: Command) {
try { try {
const platform = await platformCore.inactivatePlatform(id) const platform = await platformCore.inactivatePlatform(id)
spinner.succeed(chalk.green('Platform deactivated successfully')) spinner.succeed(chalk.green('Platform deactivated successfully'))
console.log(chalk.blue(`Name: ${platform.name}`)) const platformDef = PLATFORM_DEFINITIONS.find((def) => def.name === platform.name)
console.log(chalk.blue(`Endpoint: ${platform.endpoint}`)) console.log(chalk.blue(`Platform: ${platformDef?.displayName || platform.name}`))
console.log(chalk.blue(`Type: ${platform.name}`))
console.log(chalk.blue(`Active: ${platform.active ? 'Yes' : chalk.dim('No')}`)) console.log(chalk.blue(`Active: ${platform.active ? 'Yes' : chalk.dim('No')}`))
} catch (error) { } catch (error) {
spinner.fail(chalk.red('Operation failed')) spinner.fail(chalk.red('Operation failed'))
-3
View File
@@ -3,7 +3,6 @@ import type { Platform, ApiResponse } from '../types'
export interface CreatePlatformParams { export interface CreatePlatformParams {
name: string name: string
endpoint: string
config: Record<string, unknown> config: Record<string, unknown>
active?: boolean active?: boolean
} }
@@ -11,7 +10,6 @@ export interface CreatePlatformParams {
export interface PlatformListItem { export interface PlatformListItem {
id: string id: string
name: string name: string
endpoint: string
config: Record<string, unknown> config: Record<string, unknown>
active: boolean active: boolean
createdAt: string createdAt: string
@@ -48,7 +46,6 @@ export async function createPlatform(params: CreatePlatformParams): Promise<Plat
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
name: params.name, name: params.name,
endpoint: params.endpoint,
config: params.config, config: params.config,
active: params.active ?? true, active: params.active ?? true,
} }
+58 -1
View File
@@ -72,13 +72,70 @@ export interface Schedule {
export interface Platform { export interface Platform {
id: string id: string
name: string name: string
endpoint: string
config: Record<string, unknown> config: Record<string, unknown>
active: boolean active: boolean
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
// Platform configuration definitions
export interface PlatformConfigField {
name: string
message: string
type?: 'input' | 'password' | 'number'
required?: boolean
default?: string | number
validate?: (value: string) => boolean | string
}
export interface PlatformDefinition {
name: string
displayName: string
description: string
configFields: PlatformConfigField[]
}
// Platform configurations
export const PLATFORM_DEFINITIONS: PlatformDefinition[] = [
{
name: 'telegram',
displayName: 'Telegram',
description: 'Telegram Bot Platform',
configFields: [
{
name: 'botToken',
message: 'Bot Token:',
type: 'password',
required: true,
validate: (value: string) => {
if (!value.trim()) return 'Bot token is required'
return true
},
},
],
},
// Future platforms can be added here
// {
// name: 'discord',
// displayName: 'Discord',
// description: 'Discord Bot Platform',
// configFields: [
// {
// name: 'botToken',
// message: 'Bot Token:',
// type: 'password',
// required: true,
// },
// {
// name: 'clientId',
// message: 'Client ID:',
// type: 'input',
// required: true,
// },
// ],
// },
]
export interface MCPConnection { export interface MCPConnection {
id: string id: string
type: string type: string
+1 -1
View File
@@ -3,7 +3,7 @@ import { boolean, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-c
export const platform = pgTable('platform', { export const platform = pgTable('platform', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(), name: text('name').notNull(),
endpoint: text('endpoint').notNull(), // endpoint: text('endpoint').notNull(),
config: jsonb('config').notNull(), config: jsonb('config').notNull(),
active: boolean('active').notNull().default(true), active: boolean('active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(),
+1 -1
View File
@@ -10,7 +10,6 @@
"memoh-tg-bot": "./src/bot.ts" "memoh-tg-bot": "./src/bot.ts"
}, },
"scripts": { "scripts": {
"start": "bun run src/bot.ts",
"dev": "bun run --watch src/bot.ts" "dev": "bun run --watch src/bot.ts"
}, },
"keywords": [], "keywords": [],
@@ -20,6 +19,7 @@
"dependencies": { "dependencies": {
"@memoh/client": "workspace:*", "@memoh/client": "workspace:*",
"@memoh/platform": "workspace:*", "@memoh/platform": "workspace:*",
"@memoh/shared": "workspace:*",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ioredis": "^5.9.1", "ioredis": "^5.9.1",
"telegraf": "^4.16.3", "telegraf": "^4.16.3",
-52
View File
@@ -1,52 +0,0 @@
#!/usr/bin/env bun
/**
* Telegram Bot Standalone Entry Point
*
* This file allows running the Telegram bot as a standalone process
*/
import { TelegramPlatform } from './index'
async function main() {
const botToken = process.env.BOT_TOKEN
const redisUrl = process.env.REDIS_URL
const apiUrl = process.env.API_BASE_URL
if (!botToken) {
console.error('❌ BOT_TOKEN environment variable is required')
process.exit(1)
}
console.log('🚀 Starting Telegram bot...')
console.log(`📡 API URL: ${apiUrl || 'http://localhost:7002'}`)
console.log(`💾 Redis URL: ${redisUrl || 'redis://localhost:6379'}`)
const platform = new TelegramPlatform()
try {
platform.serve()
console.log('✅ Bot is running...')
console.log('Press Ctrl+C to stop')
// Graceful shutdown
process.once('SIGINT', async () => {
console.log('\n🛑 Stopping bot...')
await platform.stop()
process.exit(0)
})
process.once('SIGTERM', async () => {
console.log('\n🛑 Stopping bot...')
await platform.stop()
process.exit(0)
})
} catch (error) {
console.error('❌ Failed to start bot:', error)
process.exit(1)
}
}
main()
+6 -35
View File
@@ -1,65 +1,38 @@
import { Telegraf, type Context } from 'telegraf' import { Telegraf, type Context } from 'telegraf'
import { BasePlatform, SendSchema } from '@memoh/platform' import { BasePlatform, PlatformMessage } from '@memoh/platform'
import { handleLogin, handleLogout, handleWhoami, requireAuth } from './auth' import { handleLogin, handleLogout, handleWhoami, requireAuth } from './auth'
import { chatStreamAsync, type StreamEvent } from '@memoh/client' import { chatStreamAsync, type StreamEvent } from '@memoh/client'
import { getTokenStorage } from './storage' import { getTokenStorage } from './storage'
import z from 'zod'
import Redis from 'ioredis' import Redis from 'ioredis'
import { Platform } from '@memoh/shared'
export interface TelegramPlatformConfig { export interface TelegramPlatformConfig {
botToken: string botToken: string
redisUrl?: string
apiUrl?: string
} }
export class TelegramPlatform extends BasePlatform { export class TelegramPlatform extends BasePlatform {
name = 'telegram' name = 'telegram'
description = 'Telegram Bot platform for Memoh' description = 'Telegram Bot platform'
config = z.object({
botToken: z.string(),
})
port = 7101
private bot?: Telegraf private bot?: Telegraf
redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379') redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379')
// private storage?: TelegramRedisStorage
override async start(config: z.infer<typeof this.config>): Promise<void> { async start({ botToken }: TelegramPlatformConfig): Promise<void> {
const botToken = config.botToken as string
if (!botToken) {
throw new Error('Bot token is required')
}
// // Initialize storage
// this.storage = new TelegramRedisStorage({
// redisUrl: config.redisUrl as string,
// apiUrl: config.apiUrl as string,
// })
// Initialize bot
this.bot = new Telegraf(botToken) this.bot = new Telegraf(botToken)
// Register commands
this.registerCommands() this.registerCommands()
// Start bot
this.bot.launch() this.bot.launch()
console.log('✅ Telegram bot started successfully')
} }
async stop(): Promise<void> { async stop(): Promise<void> {
if (this.bot) { if (this.bot) {
this.bot.stop('SIGTERM') this.bot.stop('SIGTERM')
console.log('🛑 Telegram bot stopped')
} }
// if (this.storage) {
// await this.storage.close()
// console.log('🛑 Redis connection closed')
// }
} }
async send({ userId, message }: z.infer<typeof SendSchema>): Promise<void> { async send({ message, userId }: PlatformMessage): Promise<void> {
const pattern = 'memoh:telegram:*:userId' const pattern = 'memoh:telegram:*:userId'
let cursor = '0' let cursor = '0'
let telegramUserId: string | null = null let telegramUserId: string | null = null
@@ -74,11 +47,9 @@ export class TelegramPlatform extends BasePlatform {
) )
cursor = nextCursor cursor = nextCursor
// 检查每个 key 的值是否匹配目标 userId
for (const key of keys) { for (const key of keys) {
const storedUserId = await this.redis.get(key) const storedUserId = await this.redis.get(key)
if (storedUserId === userId) { if (storedUserId === userId) {
// 从 key 中提取 telegramUserId: memoh:telegram:{telegramUserId}:userId
const match = key.match(/^memoh:telegram:(.+):userId$/) const match = key.match(/^memoh:telegram:(.+):userId$/)
if (match) { if (match) {
telegramUserId = match[1] telegramUserId = match[1]
+6 -58
View File
@@ -1,70 +1,18 @@
import { Elysia } from 'elysia' export interface PlatformMessage {
import { cors } from '@elysiajs/cors' message: string
import { z } from 'zod' userId: string
}
export const SendSchema = z.object({
message: z.string(),
userId: z.string(),
})
export class BasePlatform { export class BasePlatform {
name: string = 'base' name: string = 'base'
description: string = 'Base platform' description: string = 'Base platform'
started: boolean = false started: boolean = false
port: number = 7003
config = z.record(z.string(), z.unknown())
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async start(config: z.infer<typeof this.config>): Promise<void> {} async start(config: unknown): Promise<void> {}
async stop(): Promise<void> {} async stop(): Promise<void> {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async send(data: z.infer<typeof SendSchema>): Promise<void> {} async send(data: PlatformMessage): Promise<void> {}
serve<T extends z.infer<typeof this.config>>(config?: T): void {
new Elysia()
.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
}))
.onStart(() => {
if (config) {
this.started = true
this.start(config)
}
})
.post('/start', async ({ body }) => {
if (!this.started) {
this.start(body)
this.started = true
}
return {
success: true,
}
}, {
body: this.config,
})
.post('/stop', async () => {
if (this.started) {
this.stop()
this.started = false
}
return {
success: true,
}
})
.post('/send', async ({ body }) => {
await this.send(body)
return {
success: true,
}
}, {
body: SendSchema,
})
.listen(this.port)
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
export interface Platform { export interface Platform {
id: string id: string
name: string name: string
endpoint: string // endpoint: string
config: Record<string, unknown> config: Record<string, unknown>
active: boolean active: boolean
} }
+9
View File
@@ -132,6 +132,12 @@ importers:
'@memoh/memory': '@memoh/memory':
specifier: workspace:* specifier: workspace:*
version: link:../memory version: link:../memory
'@memoh/platform':
specifier: workspace:*
version: link:../platform
'@memoh/platform-telegram':
specifier: workspace:*
version: link:../platform-telegram
'@memoh/shared': '@memoh/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
@@ -270,6 +276,9 @@ importers:
'@memoh/platform': '@memoh/platform':
specifier: workspace:* specifier: workspace:*
version: link:../platform version: link:../platform
'@memoh/shared':
specifier: workspace:*
version: link:../shared
dotenv: dotenv:
specifier: ^16.4.7 specifier: ^16.4.7
version: 16.6.1 version: 16.6.1