From 208dda895618ab4dc8dd3a6c7698cfd2551ac6d8 Mon Sep 17 00:00:00 2001 From: Acbox Date: Fri, 6 Feb 2026 19:24:47 +0800 Subject: [PATCH] feat(cli): bot and channel operation --- packages/cli/src/cli/bot.ts | 356 ++++++++++++++++++++++++++++++++ packages/cli/src/cli/channel.ts | 276 +++++++++++++++++++++++++ packages/cli/src/cli/index.ts | 73 +------ packages/cli/src/cli/shared.ts | 72 +++++++ 4 files changed, 715 insertions(+), 62 deletions(-) create mode 100644 packages/cli/src/cli/bot.ts create mode 100644 packages/cli/src/cli/channel.ts mode change 100644 => 100755 packages/cli/src/cli/index.ts create mode 100644 packages/cli/src/cli/shared.ts diff --git a/packages/cli/src/cli/bot.ts b/packages/cli/src/cli/bot.ts new file mode 100644 index 00000000..6a967783 --- /dev/null +++ b/packages/cli/src/cli/bot.ts @@ -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 + 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('/models?type=chat', {}, token), + apiRequest('/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 ', '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(`/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 ', 'Bot type (personal, public)') + .option('--name ', 'Bot display name') + .option('--avatar ', '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 = { + 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('/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 ', 'Bot display name') + .option('--avatar ', '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 = {} + 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 ', '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 ', 'chat | memory | embedding') + .option('--model ', '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('/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 = {} + 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) + } + }) +} + diff --git a/packages/cli/src/cli/channel.ts b/packages/cli/src/cli/channel.ts new file mode 100644 index 00000000..02d7d675 --- /dev/null +++ b/packages/cli/src/cli/channel.ts @@ -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 +} + +type ChannelMeta = { + type: string + display_name: string + configless: boolean + capabilities: Record + config_schema: ChannelConfigSchema + user_config_schema: ChannelConfigSchema +} + +type ChannelUserBinding = { + id: string + channel_type: string + user_id: string + config: Record + created_at: string + updated_at: string +} + +type ChannelConfig = { + id: string + bot_id: string + channel_type: string + credentials: Record + external_identity: string + self_identity: Record + routing: Record + capabilities: Record + 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) => { + return apiRequest('/channels', {}, token) +} + +const resolveChannelType = async ( + token: ReturnType, + 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) => { + 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>(questions) : {} + + appId = appId ?? answers.appId + appSecret = appSecret ?? answers.appSecret + encryptKey = encryptKey ?? answers.encryptKey + verificationToken = verificationToken ?? answers.verificationToken + + const payload: Record = { + 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) => { + 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 = {} + 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(`/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 ', '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(`/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 ', 'Channel type (feishu)') + .option('--app_id ') + .option('--app_secret ') + .option('--encrypt_key ') + .option('--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 ', 'Channel type') + .action(async (opts) => { + const token = ensureAuth() + const channelType = await resolveChannelType(token, opts.type) + const resp = await apiRequest(`/users/me/channels/${encodeURIComponent(channelType)}`, {}, token) + console.log(JSON.stringify(resp, null, 2)) + }) + + binding + .command('set') + .description('Set current user channel binding') + .option('--type ', 'Channel type (feishu)') + .option('--open_id ') + .option('--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) + } + }) +} + diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts old mode 100644 new mode 100755 index 90dec763..da2ee40a --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -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 ') - .option('--model ') - .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('/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 = {} - 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) } diff --git a/packages/cli/src/cli/shared.ts b/packages/cli/src/cli/shared.ts new file mode 100644 index 00000000..48510115 --- /dev/null +++ b/packages/cli/src/cli/shared.ts @@ -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('/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) + } +} +