mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(cli): bot and channel operation
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import inquirer from 'inquirer'
|
||||
import ora from 'ora'
|
||||
import { table } from 'table'
|
||||
import readline from 'node:readline/promises'
|
||||
import { stdin as input, stdout as output } from 'node:process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { apiRequest } from '../core/api'
|
||||
import { readConfig, getBaseURL, TokenInfo } from '../utils/store'
|
||||
import { ensureAuth, getErrorMessage, resolveBotId, BotSummary } from './shared'
|
||||
|
||||
type Bot = BotSummary & {
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type BotListResponse = {
|
||||
items: Bot[]
|
||||
}
|
||||
|
||||
type ModelResponse = {
|
||||
model_id?: string
|
||||
model?: {
|
||||
model_id: string
|
||||
type: 'chat' | 'embedding'
|
||||
}
|
||||
type?: 'chat' | 'embedding'
|
||||
}
|
||||
|
||||
const getModelId = (item: ModelResponse) => item.model?.model_id ?? item.model_id ?? ''
|
||||
const getModelType = (item: ModelResponse) => item.model?.type ?? item.type ?? 'chat'
|
||||
|
||||
const ensureModelsReady = async () => {
|
||||
const token = ensureAuth()
|
||||
const [chatModels, embeddingModels] = await Promise.all([
|
||||
apiRequest<ModelResponse[]>('/models?type=chat', {}, token),
|
||||
apiRequest<ModelResponse[]>('/models?type=embedding', {}, token),
|
||||
])
|
||||
if (chatModels.length === 0 || embeddingModels.length === 0) {
|
||||
console.log(chalk.red('Model configuration incomplete.'))
|
||||
console.log(chalk.yellow('At least one chat model and one embedding model are required.'))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const renderBotsTable = (items: BotSummary[]) => {
|
||||
const rows: string[][] = [['ID', 'Name', 'Type', 'Active', 'Owner']]
|
||||
for (const bot of items) {
|
||||
rows.push([
|
||||
bot.id,
|
||||
bot.display_name || bot.id,
|
||||
bot.type,
|
||||
bot.is_active ? 'yes' : 'no',
|
||||
bot.owner_user_id,
|
||||
])
|
||||
}
|
||||
return table(rows)
|
||||
}
|
||||
|
||||
const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => {
|
||||
const config = readConfig()
|
||||
const baseURL = getBaseURL(config)
|
||||
const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${encodeURIComponent(sessionId)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
}).catch(() => null)
|
||||
if (!resp || !resp.ok || !resp.body) return false
|
||||
|
||||
const stream = resp.body
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let printed = false
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let idx
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim()
|
||||
buffer = buffer.slice(idx + 1)
|
||||
if (!line.startsWith('data:')) continue
|
||||
const payload = line.slice(5).trim()
|
||||
if (!payload || payload === '[DONE]') continue
|
||||
const text = extractTextFromEvent(payload)
|
||||
if (text) {
|
||||
process.stdout.write(text)
|
||||
printed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (printed) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const extractTextFromEvent = (payload: string) => {
|
||||
try {
|
||||
const event = JSON.parse(payload)
|
||||
if (typeof event === 'string') return event
|
||||
if (typeof event?.text === 'string') return event.text
|
||||
if (typeof event?.delta?.content === 'string') return event.delta.content
|
||||
if (typeof event?.content === 'string') return event.content
|
||||
if (typeof event?.data === 'string') return event.data
|
||||
if (typeof event?.data?.text === 'string') return event.data.text
|
||||
if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content
|
||||
return null
|
||||
} catch {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
export const registerBotCommands = (program: Command) => {
|
||||
const bot = program.command('bot').description('Bot management')
|
||||
|
||||
bot
|
||||
.command('list')
|
||||
.description('List bots')
|
||||
.option('--owner <user_id>', 'Filter by owner user ID (admin only)')
|
||||
.action(async (opts) => {
|
||||
const token = ensureAuth()
|
||||
const query = opts.owner ? `?owner_id=${encodeURIComponent(String(opts.owner))}` : ''
|
||||
const resp = await apiRequest<BotListResponse>(`/bots${query}`, {}, token)
|
||||
if (!resp.items.length) {
|
||||
console.log(chalk.yellow('No bots found.'))
|
||||
return
|
||||
}
|
||||
console.log(renderBotsTable(resp.items))
|
||||
})
|
||||
|
||||
bot
|
||||
.command('create')
|
||||
.description('Create a bot')
|
||||
.option('--type <type>', 'Bot type (personal, public)')
|
||||
.option('--name <name>', 'Bot display name')
|
||||
.option('--avatar <url>', 'Bot avatar URL')
|
||||
.option('--active', 'Set bot active')
|
||||
.option('--inactive', 'Set bot inactive')
|
||||
.action(async (opts) => {
|
||||
if (opts.active && opts.inactive) {
|
||||
console.log(chalk.red('Use only one of --active or --inactive.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const token = ensureAuth()
|
||||
let type = opts.type
|
||||
if (!type) {
|
||||
const answer = await inquirer.prompt<{ type: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'type',
|
||||
message: 'Bot type:',
|
||||
choices: ['personal', 'public'],
|
||||
},
|
||||
])
|
||||
type = answer.type
|
||||
}
|
||||
if (!['personal', 'public'].includes(String(type))) {
|
||||
console.log(chalk.red('Bot type must be personal or public.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const name = opts.name ?? (await inquirer.prompt<{ name: string }>([
|
||||
{ type: 'input', name: 'name', message: 'Bot name (optional):', default: '' },
|
||||
])).name
|
||||
const payload: Record<string, unknown> = {
|
||||
type: String(type),
|
||||
}
|
||||
if (String(name).trim()) payload.display_name = String(name).trim()
|
||||
if (opts.avatar) payload.avatar_url = String(opts.avatar).trim()
|
||||
if (opts.active) payload.is_active = true
|
||||
if (opts.inactive) payload.is_active = false
|
||||
const spinner = ora('Creating bot...').start()
|
||||
try {
|
||||
const created = await apiRequest<Bot>('/bots', { method: 'POST', body: JSON.stringify(payload) }, token)
|
||||
spinner.succeed(`Bot created: ${created.display_name || created.id}`)
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to create bot')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
bot
|
||||
.command('update')
|
||||
.description('Update bot info')
|
||||
.argument('[id]')
|
||||
.option('--name <name>', 'Bot display name')
|
||||
.option('--avatar <url>', 'Bot avatar URL')
|
||||
.option('--active', 'Set bot active')
|
||||
.option('--inactive', 'Set bot inactive')
|
||||
.action(async (id, opts) => {
|
||||
if (opts.active && opts.inactive) {
|
||||
console.log(chalk.red('Use only one of --active or --inactive.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const token = ensureAuth()
|
||||
const botId = await resolveBotId(token, id)
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (opts.name) payload.display_name = String(opts.name).trim()
|
||||
if (opts.avatar) payload.avatar_url = String(opts.avatar).trim()
|
||||
if (opts.active) payload.is_active = true
|
||||
if (opts.inactive) payload.is_active = false
|
||||
if (Object.keys(payload).length === 0) {
|
||||
const answers = await inquirer.prompt<{ name: string; avatar: string; status: string }>([
|
||||
{ type: 'input', name: 'name', message: 'Bot name (leave empty to skip):', default: '' },
|
||||
{ type: 'input', name: 'avatar', message: 'Bot avatar URL (leave empty to skip):', default: '' },
|
||||
{
|
||||
type: 'list',
|
||||
name: 'status',
|
||||
message: 'Bot status:',
|
||||
choices: [
|
||||
{ name: 'keep', value: 'keep' },
|
||||
{ name: 'active', value: 'active' },
|
||||
{ name: 'inactive', value: 'inactive' },
|
||||
],
|
||||
},
|
||||
])
|
||||
if (answers.name.trim()) payload.display_name = answers.name.trim()
|
||||
if (answers.avatar.trim()) payload.avatar_url = answers.avatar.trim()
|
||||
if (answers.status === 'active') payload.is_active = true
|
||||
if (answers.status === 'inactive') payload.is_active = false
|
||||
}
|
||||
if (Object.keys(payload).length === 0) {
|
||||
console.log(chalk.red('No updates provided.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const spinner = ora('Updating bot...').start()
|
||||
try {
|
||||
await apiRequest(`/bots/${encodeURIComponent(botId)}`, { method: 'PUT', body: JSON.stringify(payload) }, token)
|
||||
spinner.succeed('Bot updated')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to update bot')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
bot
|
||||
.command('delete')
|
||||
.description('Delete a bot')
|
||||
.argument('[id]')
|
||||
.action(async (id) => {
|
||||
const token = ensureAuth()
|
||||
const botId = await resolveBotId(token, id)
|
||||
const { confirmed } = await inquirer.prompt<{ confirmed: boolean }>([
|
||||
{ type: 'confirm', name: 'confirmed', message: `Delete bot ${botId}?`, default: false },
|
||||
])
|
||||
if (!confirmed) return
|
||||
const spinner = ora('Deleting bot...').start()
|
||||
try {
|
||||
await apiRequest(`/bots/${encodeURIComponent(botId)}`, { method: 'DELETE' }, token)
|
||||
spinner.succeed('Bot deleted')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to delete bot')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
bot
|
||||
.command('chat')
|
||||
.description('Chat with a bot (stream)')
|
||||
.argument('[id]')
|
||||
.option('--session <id>', 'Reuse a session id')
|
||||
.action(async (id, opts) => {
|
||||
await ensureModelsReady()
|
||||
const token = ensureAuth()
|
||||
const botId = await resolveBotId(token, id)
|
||||
const sessionId = String(opts.session || `cli:${randomUUID()}`)
|
||||
const rl = readline.createInterface({ input, output })
|
||||
console.log(chalk.green(`Chatting with ${chalk.bold(botId)} (session ${sessionId}). Type \`exit\` to quit.`))
|
||||
while (true) {
|
||||
const line = (await rl.question(chalk.cyan('> '))).trim()
|
||||
if (!line || line.toLowerCase() === 'exit') {
|
||||
break
|
||||
}
|
||||
try {
|
||||
const ok = await streamChat(line, botId, sessionId, token)
|
||||
if (!ok) {
|
||||
console.log(chalk.red('Chat failed or stream unavailable.'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.log(chalk.red(getErrorMessage(err) || 'Chat failed'))
|
||||
}
|
||||
}
|
||||
rl.close()
|
||||
})
|
||||
|
||||
bot
|
||||
.command('set-model')
|
||||
.description('Enable model for a bot')
|
||||
.argument('[id]')
|
||||
.option('--as <usage>', 'chat | memory | embedding')
|
||||
.option('--model <model_id>', 'Model ID')
|
||||
.action(async (id, opts) => {
|
||||
const token = ensureAuth()
|
||||
const botId = await resolveBotId(token, id)
|
||||
let enableAs = opts.as
|
||||
if (!enableAs) {
|
||||
const answer = await inquirer.prompt<{ usage: string }>([{
|
||||
type: 'list',
|
||||
name: 'usage',
|
||||
message: 'Enable as:',
|
||||
choices: ['chat', 'memory', 'embedding'],
|
||||
}])
|
||||
enableAs = answer.usage
|
||||
}
|
||||
enableAs = String(enableAs).trim()
|
||||
if (!['chat', 'memory', 'embedding'].includes(enableAs)) {
|
||||
console.log(chalk.red('Enable as must be one of chat, memory, embedding.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const models = await apiRequest<ModelResponse[]>('/models', {}, token)
|
||||
const requiredType = enableAs === 'embedding' ? 'embedding' : 'chat'
|
||||
const candidates = models.filter(m => getModelType(m) === requiredType)
|
||||
if (candidates.length === 0) {
|
||||
console.log(chalk.red(`No ${requiredType} models available.`))
|
||||
process.exit(1)
|
||||
}
|
||||
let modelId = opts.model
|
||||
if (!modelId) {
|
||||
const answer = await inquirer.prompt<{ model: string }>([{
|
||||
type: 'list',
|
||||
name: 'model',
|
||||
message: 'Select model:',
|
||||
choices: candidates.map(m => getModelId(m)),
|
||||
}])
|
||||
modelId = answer.model
|
||||
}
|
||||
const selected = candidates.find(m => getModelId(m) === modelId)
|
||||
if (!selected) {
|
||||
console.log(chalk.red('Selected model not found.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (enableAs === 'chat') payload.chat_model_id = getModelId(selected)
|
||||
if (enableAs === 'memory') payload.memory_model_id = getModelId(selected)
|
||||
if (enableAs === 'embedding') payload.embedding_model_id = getModelId(selected)
|
||||
const spinner = ora('Updating bot settings...').start()
|
||||
try {
|
||||
await apiRequest(`/bots/${encodeURIComponent(botId)}/settings`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}, token)
|
||||
spinner.succeed('Model enabled')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to enable model')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import inquirer from 'inquirer'
|
||||
import ora from 'ora'
|
||||
import { table } from 'table'
|
||||
|
||||
import { apiRequest } from '../core/api'
|
||||
import { ensureAuth, getErrorMessage, resolveBotId } from './shared'
|
||||
|
||||
type ChannelFieldSchema = {
|
||||
type: 'string' | 'secret' | 'bool' | 'number' | 'enum'
|
||||
required: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
enum?: string[]
|
||||
example?: unknown
|
||||
}
|
||||
|
||||
type ChannelConfigSchema = {
|
||||
version: number
|
||||
fields: Record<string, ChannelFieldSchema>
|
||||
}
|
||||
|
||||
type ChannelMeta = {
|
||||
type: string
|
||||
display_name: string
|
||||
configless: boolean
|
||||
capabilities: Record<string, boolean>
|
||||
config_schema: ChannelConfigSchema
|
||||
user_config_schema: ChannelConfigSchema
|
||||
}
|
||||
|
||||
type ChannelUserBinding = {
|
||||
id: string
|
||||
channel_type: string
|
||||
user_id: string
|
||||
config: Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type ChannelConfig = {
|
||||
id: string
|
||||
bot_id: string
|
||||
channel_type: string
|
||||
credentials: Record<string, unknown>
|
||||
external_identity: string
|
||||
self_identity: Record<string, unknown>
|
||||
routing: Record<string, unknown>
|
||||
capabilities: Record<string, unknown>
|
||||
status: string
|
||||
verified_at: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const renderChannelsTable = (items: ChannelMeta[]) => {
|
||||
const rows: string[][] = [['Type', 'Name', 'Configless']]
|
||||
for (const item of items) {
|
||||
rows.push([item.type, item.display_name, item.configless ? 'yes' : 'no'])
|
||||
}
|
||||
return table(rows)
|
||||
}
|
||||
|
||||
const fetchChannels = async (token: ReturnType<typeof ensureAuth>) => {
|
||||
return apiRequest<ChannelMeta[]>('/channels', {}, token)
|
||||
}
|
||||
|
||||
const resolveChannelType = async (
|
||||
token: ReturnType<typeof ensureAuth>,
|
||||
preset?: string,
|
||||
options?: { allowConfigless?: boolean }
|
||||
) => {
|
||||
if (preset && preset.trim()) {
|
||||
return preset.trim()
|
||||
}
|
||||
const channels = await fetchChannels(token)
|
||||
const allowConfigless = options?.allowConfigless ?? false
|
||||
const candidates = channels.filter(item => allowConfigless || !item.configless)
|
||||
if (candidates.length === 0) {
|
||||
console.log(chalk.yellow('No configurable channels available.'))
|
||||
process.exit(0)
|
||||
}
|
||||
const { channelType } = await inquirer.prompt<{ channelType: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'channelType',
|
||||
message: 'Select channel type:',
|
||||
choices: candidates.map(item => ({
|
||||
name: `${item.display_name} (${item.type})`,
|
||||
value: item.type,
|
||||
})),
|
||||
},
|
||||
])
|
||||
return channelType
|
||||
}
|
||||
|
||||
const collectFeishuCredentials = async (opts: Record<string, unknown>) => {
|
||||
let appId = typeof opts.app_id === 'string' ? opts.app_id : undefined
|
||||
let appSecret = typeof opts.app_secret === 'string' ? opts.app_secret : undefined
|
||||
let encryptKey = typeof opts.encrypt_key === 'string' ? opts.encrypt_key : undefined
|
||||
let verificationToken = typeof opts.verification_token === 'string' ? opts.verification_token : undefined
|
||||
|
||||
const questions = []
|
||||
if (!appId) questions.push({ type: 'input', name: 'appId', message: 'Feishu App ID:' })
|
||||
if (!appSecret) questions.push({ type: 'password', name: 'appSecret', message: 'Feishu App Secret:' })
|
||||
if (!encryptKey) {
|
||||
questions.push({ type: 'input', name: 'encryptKey', message: 'Encrypt Key (optional):', default: '' })
|
||||
}
|
||||
if (!verificationToken) {
|
||||
questions.push({ type: 'input', name: 'verificationToken', message: 'Verification Token (optional):', default: '' })
|
||||
}
|
||||
const answers = questions.length ? await inquirer.prompt<Record<string, string>>(questions) : {}
|
||||
|
||||
appId = appId ?? answers.appId
|
||||
appSecret = appSecret ?? answers.appSecret
|
||||
encryptKey = encryptKey ?? answers.encryptKey
|
||||
verificationToken = verificationToken ?? answers.verificationToken
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
appId: String(appId).trim(),
|
||||
appSecret: String(appSecret).trim(),
|
||||
}
|
||||
if (String(encryptKey || '').trim()) payload.encryptKey = String(encryptKey).trim()
|
||||
if (String(verificationToken || '').trim()) payload.verificationToken = String(verificationToken).trim()
|
||||
return payload
|
||||
}
|
||||
|
||||
const collectFeishuUserConfig = async (opts: Record<string, unknown>) => {
|
||||
let openId = typeof opts.open_id === 'string' ? opts.open_id : undefined
|
||||
let userId = typeof opts.user_id === 'string' ? opts.user_id : undefined
|
||||
|
||||
if (!openId && !userId) {
|
||||
const answers = await inquirer.prompt<{ kind: 'open_id' | 'user_id'; value: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'kind',
|
||||
message: 'Bind using:',
|
||||
choices: [
|
||||
{ name: 'Open ID', value: 'open_id' },
|
||||
{ name: 'User ID', value: 'user_id' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'value',
|
||||
message: 'Value:',
|
||||
},
|
||||
])
|
||||
if (answers.kind === 'open_id') openId = answers.value
|
||||
if (answers.kind === 'user_id') userId = answers.value
|
||||
}
|
||||
if (!openId && !userId) {
|
||||
console.log(chalk.red('open_id or user_id is required.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const config: Record<string, unknown> = {}
|
||||
if (openId) config.open_id = String(openId).trim()
|
||||
if (userId) config.user_id = String(userId).trim()
|
||||
return config
|
||||
}
|
||||
|
||||
export const registerChannelCommands = (program: Command) => {
|
||||
const channel = program.command('channel').description('Channel management')
|
||||
|
||||
channel
|
||||
.command('list')
|
||||
.description('List available channels')
|
||||
.action(async () => {
|
||||
const token = ensureAuth()
|
||||
const channels = await fetchChannels(token)
|
||||
if (!channels.length) {
|
||||
console.log(chalk.yellow('No channels available.'))
|
||||
return
|
||||
}
|
||||
console.log(renderChannelsTable(channels))
|
||||
})
|
||||
|
||||
channel
|
||||
.command('info')
|
||||
.description('Show channel meta and schema')
|
||||
.argument('[type]')
|
||||
.action(async (type) => {
|
||||
const token = ensureAuth()
|
||||
const channelType = await resolveChannelType(token, type, { allowConfigless: true })
|
||||
const meta = await apiRequest<ChannelMeta>(`/channels/${encodeURIComponent(channelType)}`, {}, token)
|
||||
console.log(JSON.stringify(meta, null, 2))
|
||||
})
|
||||
|
||||
const config = channel.command('config').description('Bot channel configuration')
|
||||
|
||||
config
|
||||
.command('get')
|
||||
.description('Get bot channel config')
|
||||
.argument('[bot_id]')
|
||||
.option('--type <type>', 'Channel type')
|
||||
.action(async (botId, opts) => {
|
||||
const token = ensureAuth()
|
||||
const resolvedBotId = await resolveBotId(token, botId)
|
||||
const channelType = await resolveChannelType(token, opts.type)
|
||||
const resp = await apiRequest<ChannelConfig>(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, {}, token)
|
||||
console.log(JSON.stringify(resp, null, 2))
|
||||
})
|
||||
|
||||
config
|
||||
.command('set')
|
||||
.description('Set bot channel config')
|
||||
.argument('[bot_id]')
|
||||
.option('--type <type>', 'Channel type (feishu)')
|
||||
.option('--app_id <app_id>')
|
||||
.option('--app_secret <app_secret>')
|
||||
.option('--encrypt_key <encrypt_key>')
|
||||
.option('--verification_token <verification_token>')
|
||||
.action(async (botId, opts) => {
|
||||
const token = ensureAuth()
|
||||
const resolvedBotId = await resolveBotId(token, botId)
|
||||
const channelType = await resolveChannelType(token, opts.type)
|
||||
if (channelType !== 'feishu') {
|
||||
console.log(chalk.red(`Channel type ${channelType} is not supported by this command.`))
|
||||
process.exit(1)
|
||||
}
|
||||
const credentials = await collectFeishuCredentials(opts)
|
||||
const spinner = ora('Updating channel config...').start()
|
||||
try {
|
||||
await apiRequest(`/bots/${encodeURIComponent(resolvedBotId)}/channel/${encodeURIComponent(channelType)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ credentials }),
|
||||
}, token)
|
||||
spinner.succeed('Channel config updated')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to update channel config')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
const binding = channel.command('bind').description('User channel binding')
|
||||
|
||||
binding
|
||||
.command('get')
|
||||
.description('Get current user channel binding')
|
||||
.option('--type <type>', 'Channel type')
|
||||
.action(async (opts) => {
|
||||
const token = ensureAuth()
|
||||
const channelType = await resolveChannelType(token, opts.type)
|
||||
const resp = await apiRequest<ChannelUserBinding>(`/users/me/channels/${encodeURIComponent(channelType)}`, {}, token)
|
||||
console.log(JSON.stringify(resp, null, 2))
|
||||
})
|
||||
|
||||
binding
|
||||
.command('set')
|
||||
.description('Set current user channel binding')
|
||||
.option('--type <type>', 'Channel type (feishu)')
|
||||
.option('--open_id <open_id>')
|
||||
.option('--user_id <user_id>')
|
||||
.action(async (opts) => {
|
||||
const token = ensureAuth()
|
||||
const channelType = await resolveChannelType(token, opts.type)
|
||||
if (channelType !== 'feishu') {
|
||||
console.log(chalk.red(`Channel type ${channelType} is not supported by this command.`))
|
||||
process.exit(1)
|
||||
}
|
||||
const configPayload = await collectFeishuUserConfig(opts)
|
||||
const spinner = ora('Updating user binding...').start()
|
||||
try {
|
||||
await apiRequest(`/users/me/channels/${encodeURIComponent(channelType)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config: configPayload }),
|
||||
}, token)
|
||||
spinner.succeed('User binding updated')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to update user binding')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Regular → Executable
+11
-62
@@ -9,6 +9,8 @@ import { stdin as input, stdout as output } from 'node:process'
|
||||
|
||||
import packageJson from '../../package.json'
|
||||
import { apiRequest } from '../core/api'
|
||||
import { registerBotCommands } from './bot'
|
||||
import { registerChannelCommands } from './channel'
|
||||
import {
|
||||
readConfig,
|
||||
writeConfig,
|
||||
@@ -69,11 +71,13 @@ type Settings = {
|
||||
|
||||
type Bot = {
|
||||
id: string
|
||||
name: string
|
||||
name?: string
|
||||
display_name?: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
type?: string
|
||||
owner_user_id: string
|
||||
is_public: boolean
|
||||
is_public?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -88,6 +92,9 @@ program
|
||||
.description('Memoh CLI')
|
||||
.version(packageJson.version)
|
||||
|
||||
registerBotCommands(program)
|
||||
registerChannelCommands(program)
|
||||
|
||||
const ensureAuth = () => {
|
||||
const token = readToken()
|
||||
if (!token?.access_token) {
|
||||
@@ -142,7 +149,7 @@ const resolveBotId = async (token: TokenInfo, preset?: string) => {
|
||||
name: 'botId',
|
||||
message: 'Select a bot to chat with:',
|
||||
choices: bots.map(b => ({
|
||||
name: `${b.name} ${chalk.gray(b.description || '')}`,
|
||||
name: `${b.display_name || b.name || b.id || 'unknown'} ${b.type ? chalk.gray(b.type) : ''}`.trim(),
|
||||
value: b.id,
|
||||
})),
|
||||
},
|
||||
@@ -528,64 +535,6 @@ model
|
||||
}
|
||||
})
|
||||
|
||||
model
|
||||
.command('enable')
|
||||
.description('Enable model for chat/memory/embedding')
|
||||
.option('--as <usage>')
|
||||
.option('--model <model_id>')
|
||||
.action(async (opts) => {
|
||||
const token = ensureAuth()
|
||||
let enableAs = opts.as
|
||||
if (!enableAs) {
|
||||
const answer = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'enable_as',
|
||||
message: 'Enable as:',
|
||||
choices: ['chat', 'memory', 'embedding'],
|
||||
}])
|
||||
enableAs = answer.enable_as
|
||||
}
|
||||
enableAs = String(enableAs).trim()
|
||||
if (!['chat', 'memory', 'embedding'].includes(enableAs)) {
|
||||
console.log(chalk.red('Enable as must be one of chat, memory, embedding.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const models = await apiRequest<ModelResponse[]>('/models', {}, token)
|
||||
const requiredType = enableAs === 'embedding' ? 'embedding' : 'chat'
|
||||
const candidates = models.filter(m => getModelType(m) === requiredType)
|
||||
if (candidates.length === 0) {
|
||||
console.log(chalk.red(`No ${requiredType} models available.`))
|
||||
process.exit(1)
|
||||
}
|
||||
let modelId = opts.model
|
||||
if (!modelId) {
|
||||
const answer = await inquirer.prompt([{
|
||||
type: 'list',
|
||||
name: 'model',
|
||||
message: 'Select model:',
|
||||
choices: candidates.map(m => getModelId(m)),
|
||||
}])
|
||||
modelId = answer.model
|
||||
}
|
||||
const selected = candidates.find(m => getModelId(m) === modelId)
|
||||
if (!selected) {
|
||||
console.log(chalk.red('Selected model not found.'))
|
||||
process.exit(1)
|
||||
}
|
||||
const payload: Partial<Settings> = {}
|
||||
if (enableAs === 'chat') payload.chat_model_id = getModelId(selected)
|
||||
if (enableAs === 'memory') payload.memory_model_id = getModelId(selected)
|
||||
if (enableAs === 'embedding') payload.embedding_model_id = getModelId(selected)
|
||||
const spinner = ora('Updating settings...').start()
|
||||
try {
|
||||
await apiRequest('/settings', { method: 'PUT', body: JSON.stringify(payload) }, token)
|
||||
spinner.succeed('Model enabled')
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(getErrorMessage(err) || 'Failed to enable model')
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
|
||||
const schedule = program.command('schedule').description('Schedule management')
|
||||
|
||||
schedule
|
||||
@@ -877,7 +826,7 @@ const createLocalSession = async (botId: string, token: TokenInfo) => {
|
||||
const postLocalMessage = async (botId: string, sessionId: string, text: string, token: TokenInfo) => {
|
||||
return apiRequest(`/bots/${botId}/cli/sessions/${sessionId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text }),
|
||||
body: JSON.stringify({ message: { text } }),
|
||||
}, token)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import chalk from 'chalk'
|
||||
import inquirer from 'inquirer'
|
||||
import ora from 'ora'
|
||||
|
||||
import { apiRequest } from '../core/api'
|
||||
import { readToken, TokenInfo } from '../utils/store'
|
||||
|
||||
export type BotSummary = {
|
||||
id: string
|
||||
owner_user_id: string
|
||||
type: string
|
||||
display_name: string
|
||||
avatar_url?: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
type BotListResponse = {
|
||||
items: BotSummary[]
|
||||
}
|
||||
|
||||
export const ensureAuth = (): TokenInfo => {
|
||||
const token = readToken()
|
||||
if (!token?.access_token) {
|
||||
console.log(chalk.red('Not logged in. Run `memoh login` first.'))
|
||||
process.exit(1)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
export const getErrorMessage = (err: unknown) => {
|
||||
if (err && typeof err === 'object' && 'message' in err) {
|
||||
const value = (err as { message?: unknown }).message
|
||||
if (typeof value === 'string') return value
|
||||
}
|
||||
return 'Unknown error'
|
||||
}
|
||||
|
||||
export const fetchBots = async (token: TokenInfo) => {
|
||||
const resp = await apiRequest<BotListResponse>('/bots', {}, token)
|
||||
return resp.items
|
||||
}
|
||||
|
||||
export const resolveBotId = async (token: TokenInfo, preset?: string) => {
|
||||
if (preset && preset.trim()) {
|
||||
return preset.trim()
|
||||
}
|
||||
const spinner = ora('Fetching bots...').start()
|
||||
try {
|
||||
const bots = await fetchBots(token)
|
||||
spinner.stop()
|
||||
if (bots.length === 0) {
|
||||
console.log(chalk.yellow('No bots found. Please create a bot first.'))
|
||||
process.exit(0)
|
||||
}
|
||||
const { botId } = await inquirer.prompt<{ botId: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'botId',
|
||||
message: 'Select a bot:',
|
||||
choices: bots.map(bot => ({
|
||||
name: `${bot.display_name || bot.id} ${chalk.gray(bot.type)}`,
|
||||
value: bot.id,
|
||||
})),
|
||||
},
|
||||
])
|
||||
return botId
|
||||
} catch (err: unknown) {
|
||||
spinner.fail(`Failed to fetch bots: ${getErrorMessage(err)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user