From bb9c5b66d3233546003c556cc3e68a939cd0249a Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 24 Dec 2025 14:22:43 +0800 Subject: [PATCH] refactor(telegram): remove interactive features and simplify to notifications only Remove all interactive features from the Telegram bot that are now handled by the Telegram Mini App. The bot now serves a single purpose: sending notifications for permission requests and ready events. Changes: - bot.ts: Remove /help command and message handler, simplify /start to show Mini App link - callbacks.ts: Remove all callback handlers except APPROVE and DENY for permissions - sessionView.ts: Remove detail views and settings, keep only notification formatting - renderer.ts: Remove session/machine list rendering, keep utility functions This reduces the codebase by ~1000 lines (58% reduction) while preserving notification functionality that the Mini App cannot provide. --- server/src/telegram/bot.ts | 111 ++------ server/src/telegram/callbacks.ts | 391 +--------------------------- server/src/telegram/renderer.ts | 258 +----------------- server/src/telegram/sessionView.ts | 405 +++-------------------------- 4 files changed, 71 insertions(+), 1094 deletions(-) diff --git a/server/src/telegram/bot.ts b/server/src/telegram/bot.ts index e8a90972..da724493 100644 --- a/server/src/telegram/bot.ts +++ b/server/src/telegram/bot.ts @@ -1,24 +1,17 @@ /** * Telegram Bot for HAPI * - * Main bot class that initializes grammy, applies middleware, - * and sets up command handlers. + * Simplified bot that only handles notifications (permission requests and ready events). + * All interactive features are handled by the Telegram Mini App. */ import { Bot, Context, NextFunction, InlineKeyboard } from 'grammy' import { SyncEngine, SyncEvent, Session } from '../sync/syncEngine' -import { getSessionName } from './renderer' -import { - handleCallback, - CallbackContext, -} from './callbacks' -import { - formatSessionNotification, - createNotificationKeyboard -} from './sessionView' +import { handleCallback, CallbackContext } from './callbacks' +import { formatSessionNotification, createNotificationKeyboard } from './sessionView' export interface BotContext extends Context { - // Extended context for future use (session state, etc.) + // Extended context for future use } export interface HappyBotConfig { @@ -29,7 +22,7 @@ export interface HappyBotConfig { } /** - * HAPI Telegram Bot + * HAPI Telegram Bot - Notification-only mode */ export class HappyBot { private bot: Bot @@ -40,13 +33,13 @@ export class HappyBot { private readonly allowlistConfigured: boolean // Track last known permission requests per session to detect new ones - private lastKnownRequests: Map> = new Map() // sessionId -> requestIds + private lastKnownRequests: Map> = new Map() // Debounce timers for notifications - private notificationDebounce: Map = new Map() // sessionId -> timer + private notificationDebounce: Map = new Map() // Track ready notifications to avoid spam - private lastReadyNotificationAt: Map = new Map() // sessionId -> timestamp + private lastReadyNotificationAt: Map = new Map() // Unsubscribe function for sync events private unsubscribeSyncEvents: (() => void) | null = null @@ -60,9 +53,9 @@ export class HappyBot { this.bot = new Bot(config.botToken) this.setupMiddleware() this.setupCommands() + if (this.allowlistConfigured) { this.setupCallbacks() - this.setupMessageHandler() } // Subscribe to sync events immediately if engine is available @@ -173,6 +166,7 @@ export class HappyBot { * Setup command handlers */ private setupCommands(): void { + // When allowlist is not configured, show setup instructions if (!this.allowlistConfigured) { this.bot.command('start', async (ctx) => { const chatId = ctx.chat?.id @@ -188,48 +182,25 @@ export class HappyBot { return } - // /start - Status + help - this.bot.command('start', async (ctx) => { - const sessionCount = this.syncEngine?.getActiveSessions().length ?? 0 - const machineCount = this.syncEngine?.getOnlineMachines().length ?? 0 - - await ctx.reply( - `Welcome to HAPI Bot!\n\n` + - `Active Sessions: ${sessionCount}\n` + - `Online Machines: ${machineCount}\n\n` + - `Commands:\n` + - `/app - Open the Mini App\n` + - `/help - Show help\n` - ) - }) - - // /help - Show help information - this.bot.command('help', async (ctx) => { - await ctx.reply( - `HAPI Bot Help\n\n` + - `HAPI Bot is a notification layer for HAPI sessions.\n\n` + - `Commands:\n` + - `/start - Start the bot or show status\n` + - `/app - Open the Mini App\n` + - `/help - Show this help message\n\n` + - `Use the Mini App for:\n` + - `- Session list and full chat UI\n` + - `- Approving/denying permissions\n` + - `- Aborting sessions and changing modes/models\n` + - `- Viewing machines and creating new sessions` - ) - }) - - // /app - Open Telegram Mini App + // /app - Open Telegram Mini App (primary entry point) this.bot.command('app', async (ctx) => { - const keyboard = new InlineKeyboard().webApp('📱 Open App', this.miniAppUrl) + const keyboard = new InlineKeyboard().webApp('Open App', this.miniAppUrl) await ctx.reply('Open HAPI Mini App:', { reply_markup: keyboard }) }) + // /start - Simple welcome with Mini App link + this.bot.command('start', async (ctx) => { + const keyboard = new InlineKeyboard().webApp('Open App', this.miniAppUrl) + await ctx.reply( + 'Welcome to HAPI Bot!\n\n' + + 'Use the Mini App for full session management.', + { reply_markup: keyboard } + ) + }) } /** - * Setup callback query handlers (InlineKeyboard buttons) + * Setup callback query handlers for notification buttons */ private setupCallbacks(): void { this.bot.on('callback_query:data', async (ctx) => { @@ -240,7 +211,6 @@ export class HappyBot { const data = ctx.callbackQuery.data - // Handle other callbacks const callbackContext: CallbackContext = { syncEngine: this.syncEngine, answerCallback: async (text?: string) => { @@ -250,11 +220,6 @@ export class HappyBot { await ctx.editMessageText(text, { reply_markup: keyboard }) - }, - sendMessage: async (text, keyboard) => { - await ctx.reply(text, { - reply_markup: keyboard - }) } } @@ -262,28 +227,6 @@ export class HappyBot { }) } - /** - * Setup text message handler for sending messages to Claude - */ - private setupMessageHandler(): void { - // Handle text messages (non-commands) - this.bot.on('message:text', async (ctx) => { - // Skip if it's a command - if (ctx.message.text.startsWith('/')) return - - if (!this.syncEngine) { - await ctx.reply('Not ready yet. Try again in a moment.') - return - } - - const keyboard = new InlineKeyboard().webApp('📱 Open App', this.miniAppUrl) - await ctx.reply( - 'Chat and session controls are available in the Mini App.', - { reply_markup: keyboard } - ) - }) - } - /** * Handle sync engine events for notifications */ @@ -308,10 +251,7 @@ export class HappyBot { this.sendReadyNotification(event.sessionId).catch((error) => { console.error('[HAPIBot] Failed to send ready notification:', error) }) - return } - - } } @@ -359,7 +299,6 @@ export class HappyBot { } } - /** * Check if session has new permission requests and send notification */ @@ -422,8 +361,8 @@ export class HappyBot { return } - const text = formatSessionNotification(session, 'permission') - const keyboard = createNotificationKeyboard(session) + const text = formatSessionNotification(session) + const keyboard = createNotificationKeyboard(session, this.miniAppUrl) // Send to all allowed chat IDs for (const chatId of this.allowedChatIds) { diff --git a/server/src/telegram/callbacks.ts b/server/src/telegram/callbacks.ts index 78306716..9b0a91f4 100644 --- a/server/src/telegram/callbacks.ts +++ b/server/src/telegram/callbacks.ts @@ -1,126 +1,20 @@ /** * Callback Query Handlers for Telegram * - * Handles InlineKeyboard button interactions for sessions, permissions, etc. + * Handles InlineKeyboard button interactions for permission notifications. + * Simplified to only support APPROVE and DENY actions. */ import { InlineKeyboard } from 'grammy' -import type { Session, Machine, SyncEngine } from '../sync/syncEngine' -import { - createCallbackData, - parseCallbackData, - findSessionByPrefix, - findMachineByPrefix, - formatSessionList, - formatMachineList, - getSessionName, - getSessionStatusEmoji -} from './renderer' -import { - formatSessionDetailView, - createSessionDetailKeyboard as createDetailKeyboard, - formatSettingsView, - createSettingsKeyboard -} from './sessionView' +import type { Session, SyncEngine } from '../sync/syncEngine' +import { parseCallbackData, findSessionByPrefix } from './renderer' -// Callback action types +// Callback action types (simplified - only permission actions) export const ACTIONS = { - // Session actions - VIEW_SESSION: 'vs', - REFRESH_SESSION: 'rs', - BACK_TO_LIST: 'bl', - - // Permission actions APPROVE: 'ap', - APPROVE_EDITS: 'ae', - APPROVE_BYPASS: 'ab', DENY: 'dn', - - // Session control - ABORT: 'at', - SETTINGS: 'st', - BACK_TO_SESSION: 'bs', - - // Settings - Permission Mode - SET_MODE_DEFAULT: 'md', - SET_MODE_EDITS: 'me', - SET_MODE_BYPASS: 'mb', - SET_MODE_PLAN: 'mp', - - // Settings - Model - SET_MODEL_DEFAULT: 'xd', - SET_MODEL_SONNET: 'xs', - SET_MODEL_OPUS: 'xo', - - // Machine actions - VIEW_MACHINE: 'vm', - SPAWN_SESSION: 'sp', - - // Navigation - REFRESH_LIST: 'rl', - REFRESH_MACHINES: 'rm' } as const -/** - * Create session list keyboard - */ -export function createSessionListKeyboard(sessions: Session[]): InlineKeyboard { - const keyboard = new InlineKeyboard() - - // Add view buttons for first 5 sessions (2 per row) - const display = sessions.slice(0, 8) - for (let i = 0; i < display.length; i++) { - const session = display[i] - const name = getSessionName(session) - const emoji = getSessionStatusEmoji(session) - const label = `${emoji} ${name.slice(0, 15)}` - const callback = createCallbackData(ACTIONS.VIEW_SESSION, session.id) - - keyboard.text(label, callback) - - // 2 buttons per row - if (i % 2 === 1) { - keyboard.row() - } - } - - // Add refresh button - keyboard.row() - keyboard.text('🔄 Refresh', createCallbackData(ACTIONS.REFRESH_LIST, 'list')) - - return keyboard -} - -/** - * Create session detail keyboard - * Re-export from sessionView for backward compatibility - */ -export function createSessionDetailKeyboard(session: Session): InlineKeyboard { - return createDetailKeyboard(session) -} - -/** - * Create machine list keyboard - */ -export function createMachineListKeyboard(machines: Machine[]): InlineKeyboard { - const keyboard = new InlineKeyboard() - - // Add spawn session buttons for each machine - for (const machine of machines.slice(0, 6)) { - const name = machine.metadata?.displayName || machine.metadata?.host || 'Unknown' - const label = `📡 ${name.slice(0, 20)}` - const callback = createCallbackData(ACTIONS.SPAWN_SESSION, machine.id) - - keyboard.text(label, callback) - keyboard.row() - } - - // Add refresh button - keyboard.text('🔄 Refresh', createCallbackData(ACTIONS.REFRESH_MACHINES, 'machines')) - - return keyboard -} - /** * Callback handler context */ @@ -128,27 +22,6 @@ export interface CallbackContext { syncEngine: SyncEngine answerCallback: (text?: string) => Promise editMessage: (text: string, keyboard?: InlineKeyboard) => Promise - sendMessage: (text: string, keyboard?: InlineKeyboard) => Promise -} - -/** - * Safely edit message, ignoring "message not modified" errors - */ -async function safeEditMessage( - ctx: CallbackContext, - text: string, - keyboard?: InlineKeyboard -): Promise { - try { - await ctx.editMessage(text, keyboard) - } catch (error: any) { - // Ignore "message is not modified" error (error code 400) - if (error?.error_code === 400 && error?.description?.includes('message is not modified')) { - // Message content is the same, ignore - return - } - throw error - } } async function getSessionOrAnswer( @@ -181,57 +54,6 @@ export async function handleCallback( try { switch (action) { - case ACTIONS.VIEW_SESSION: { - const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix) - if (!session) { - return - } - - // Fetch messages for this session - await syncEngine.fetchMessages(session.id) - const messages = syncEngine.getSessionMessages(session.id) - - const text = formatSessionDetailView(session, messages) - const keyboard = createSessionDetailKeyboard(session) - await safeEditMessage(ctx, text, keyboard) - await ctx.answerCallback() - break - } - - case ACTIONS.REFRESH_SESSION: { - const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix) - if (!session) { - return - } - - await syncEngine.fetchMessages(session.id) - const messages = syncEngine.getSessionMessages(session.id) - - const text = formatSessionDetailView(session, messages) - const keyboard = createSessionDetailKeyboard(session) - await safeEditMessage(ctx, text, keyboard) - await ctx.answerCallback('Refreshed') - break - } - - case ACTIONS.BACK_TO_LIST: { - const sessions = syncEngine.getActiveSessions() - const text = formatSessionList(sessions) - const keyboard = createSessionListKeyboard(sessions) - await safeEditMessage(ctx, text, keyboard) - await ctx.answerCallback() - break - } - - case ACTIONS.REFRESH_LIST: { - const sessions = syncEngine.getActiveSessions() - const text = formatSessionList(sessions) - const keyboard = createSessionListKeyboard(sessions) - await safeEditMessage(ctx, text, keyboard) - await ctx.answerCallback('Refreshed') - break - } - case ACTIONS.APPROVE: { const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix, { requireActive: true }) if (!session) { @@ -247,66 +69,8 @@ export async function handleCallback( await syncEngine.approvePermission(session.id, requestId) await ctx.answerCallback('Approved!') - // Refresh the view - const messages = syncEngine.getSessionMessages(session.id) - const updatedSession = syncEngine.getSession(session.id) - if (updatedSession) { - const text = formatSessionDetailView(updatedSession, messages) - const keyboard = createSessionDetailKeyboard(updatedSession) - await safeEditMessage(ctx, text, keyboard) - } - break - } - - case ACTIONS.APPROVE_EDITS: { - const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix, { requireActive: true }) - if (!session) { - return - } - - const requestId = findRequestByPrefix(session, extra || '') - if (!requestId) { - await ctx.answerCallback('Request not found or already processed') - return - } - - await syncEngine.approvePermission(session.id, requestId, 'acceptEdits') - await ctx.answerCallback('Approved with Accept Edits!') - - // Refresh the view - const messages = syncEngine.getSessionMessages(session.id) - const updatedSession = syncEngine.getSession(session.id) - if (updatedSession) { - const text = formatSessionDetailView(updatedSession, messages) - const keyboard = createSessionDetailKeyboard(updatedSession) - await safeEditMessage(ctx, text, keyboard) - } - break - } - - case ACTIONS.APPROVE_BYPASS: { - const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix, { requireActive: true }) - if (!session) { - return - } - - const requestId = findRequestByPrefix(session, extra || '') - if (!requestId) { - await ctx.answerCallback('Request not found or already processed') - return - } - - await syncEngine.approvePermission(session.id, requestId, 'bypassPermissions') - await ctx.answerCallback('Approved with Bypass!') - - // Refresh the view - const messages = syncEngine.getSessionMessages(session.id) - const updatedSession = syncEngine.getSession(session.id) - if (updatedSession) { - const text = formatSessionDetailView(updatedSession, messages) - const keyboard = createSessionDetailKeyboard(updatedSession) - await safeEditMessage(ctx, text, keyboard) - } + // Update the notification message + await ctx.editMessage('Permission approved.', new InlineKeyboard()) break } @@ -325,145 +89,8 @@ export async function handleCallback( await syncEngine.denyPermission(session.id, requestId) await ctx.answerCallback('Denied') - // Refresh the view - const messages = syncEngine.getSessionMessages(session.id) - const updatedSession = syncEngine.getSession(session.id) - if (updatedSession) { - const text = formatSessionDetailView(updatedSession, messages) - const keyboard = createSessionDetailKeyboard(updatedSession) - await safeEditMessage(ctx, text, keyboard) - } - break - } - - case ACTIONS.ABORT: { - const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix, { requireActive: true }) - if (!session) { - return - } - - await syncEngine.abortSession(session.id) - await ctx.answerCallback('Session aborted') - - // Refresh the view - const messages = syncEngine.getSessionMessages(session.id) - const updatedSession = syncEngine.getSession(session.id) - if (updatedSession) { - const text = formatSessionDetailView(updatedSession, messages) - const keyboard = createSessionDetailKeyboard(updatedSession) - await safeEditMessage(ctx, text, keyboard) - } - break - } - - case ACTIONS.SETTINGS: { - const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix) - if (!session) { - return - } - - const text = formatSettingsView(session) - const keyboard = createSettingsKeyboard(session) - await safeEditMessage(ctx, text, keyboard) - await ctx.answerCallback() - break - } - - case ACTIONS.BACK_TO_SESSION: { - const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix) - if (!session) { - return - } - - const messages = syncEngine.getSessionMessages(session.id) - const text = formatSessionDetailView(session, messages) - const keyboard = createSessionDetailKeyboard(session) - await safeEditMessage(ctx, text, keyboard) - await ctx.answerCallback() - break - } - - case ACTIONS.SET_MODE_DEFAULT: - case ACTIONS.SET_MODE_EDITS: - case ACTIONS.SET_MODE_BYPASS: - case ACTIONS.SET_MODE_PLAN: { - const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix, { requireActive: true }) - if (!session) { - return - } - - const modeMap: Record = { - [ACTIONS.SET_MODE_DEFAULT]: 'default', - [ACTIONS.SET_MODE_EDITS]: 'acceptEdits', - [ACTIONS.SET_MODE_BYPASS]: 'bypassPermissions', - [ACTIONS.SET_MODE_PLAN]: 'plan' - } - const mode = modeMap[action] - - try { - await syncEngine.setPermissionMode(session.id, mode) - await ctx.answerCallback(`Mode set to ${mode}`) - - // Refresh settings view - const updatedSession = syncEngine.getSession(session.id) - if (updatedSession) { - const text = formatSettingsView(updatedSession) - const keyboard = createSettingsKeyboard(updatedSession) - await safeEditMessage(ctx, text, keyboard) - } - } catch (error) { - console.error('[Callback] Failed to set mode:', error) - await ctx.answerCallback('Failed to change mode') - } - break - } - - case ACTIONS.SET_MODEL_DEFAULT: - case ACTIONS.SET_MODEL_SONNET: - case ACTIONS.SET_MODEL_OPUS: { - const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix, { requireActive: true }) - if (!session) { - return - } - - const modelMap: Record = { - [ACTIONS.SET_MODEL_DEFAULT]: 'default', - [ACTIONS.SET_MODEL_SONNET]: 'sonnet', - [ACTIONS.SET_MODEL_OPUS]: 'opus' - } - const model = modelMap[action] - - try { - await syncEngine.setModelMode(session.id, model) - await ctx.answerCallback(`Model set to ${model}`) - - // Refresh settings view - const updatedSession = syncEngine.getSession(session.id) - if (updatedSession) { - const text = formatSettingsView(updatedSession) - const keyboard = createSettingsKeyboard(updatedSession) - await safeEditMessage(ctx, text, keyboard) - } - } catch (error) { - console.error('[Callback] Failed to set model:', error) - await ctx.answerCallback('Failed to change model') - } - break - } - - case ACTIONS.REFRESH_MACHINES: { - const machines = syncEngine.getOnlineMachines() - const text = formatMachineList(machines) - const keyboard = createMachineListKeyboard(machines) - await safeEditMessage(ctx, text, keyboard) - await ctx.answerCallback('Refreshed') - break - } - - case ACTIONS.SPAWN_SESSION: { - // Handled directly in bot.ts setupCallbacks() - // This case shouldn't be reached, but handle gracefully - await ctx.answerCallback('Use /new to create a session') + // Update the notification message + await ctx.editMessage('Permission denied.', new InlineKeyboard()) break } diff --git a/server/src/telegram/renderer.ts b/server/src/telegram/renderer.ts index 281a20be..d0de08df 100644 --- a/server/src/telegram/renderer.ts +++ b/server/src/telegram/renderer.ts @@ -1,11 +1,11 @@ /** - * Message Renderer for Telegram + * Utilities for Telegram Bot * - * Formats session data, messages, and other content for Telegram display. - * Handles truncation to respect Telegram's message limits. + * Helper functions for rendering and callback data handling. + * Simplified to only include utilities needed for notifications. */ -import type { Session, Machine, DecryptedMessage } from '../sync/syncEngine' +import type { Session } from '../sync/syncEngine' // Telegram limits const MAX_MESSAGE_LENGTH = 4096 @@ -19,37 +19,6 @@ export function truncate(text: string, maxLen: number = MAX_MESSAGE_LENGTH - 100 return text.slice(0, maxLen - 3) + '...' } -/** - * Escape special characters for Telegram MarkdownV2 - */ -export function escapeMarkdown(text: string): string { - return text.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&') -} - -/** - * Get status emoji for a session - */ -export function getSessionStatusEmoji(session: Session): string { - const hasRequests = session.agentState?.requests && Object.keys(session.agentState.requests).length > 0 - - if (hasRequests) return '🔔' // Permission needed - if (session.thinking) return '💭' // Thinking - if (session.active) return '🟢' // Active - return '⚪' // Inactive -} - -/** - * Get status text for a session - */ -export function getSessionStatusText(session: Session): string { - const hasRequests = session.agentState?.requests && Object.keys(session.agentState.requests).length > 0 - - if (hasRequests) return 'Permission needed' - if (session.thinking) return 'Thinking' - if (session.active) return 'Active' - return 'Inactive' -} - /** * Get session name (project name or directory name) */ @@ -62,218 +31,6 @@ export function getSessionName(session: Session): string { return 'Unknown' } -/** - * Format session list for display - */ -export function formatSessionList(sessions: Session[]): string { - if (sessions.length === 0) { - return 'No active sessions.\n\nUse /app to open the Mini App and create a new session.' - } - - let message = `Active Sessions (${sessions.length}):\n\n` - - // Sort sessions: permission needed first, then by activity - const sorted = [...sessions].sort((a, b) => { - const aHasReq = a.agentState?.requests && Object.keys(a.agentState.requests).length > 0 - const bHasReq = b.agentState?.requests && Object.keys(b.agentState.requests).length > 0 - - if (aHasReq && !bHasReq) return -1 - if (!aHasReq && bHasReq) return 1 - if (a.thinking && !b.thinking) return -1 - if (!a.thinking && b.thinking) return 1 - if (a.active && !b.active) return -1 - if (!a.active && b.active) return 1 - return b.activeAt - a.activeAt - }) - - // Show up to 10 sessions - const display = sorted.slice(0, 10) - - for (let i = 0; i < display.length; i++) { - const session = display[i] - const num = i + 1 - const emoji = getSessionStatusEmoji(session) - const name = getSessionName(session) - const status = getSessionStatusText(session) - const path = session.metadata?.path || '' - - message += `${num}. ${emoji} ${name}\n` - if (path) { - message += ` ${truncate(path, 50)}\n` - } - message += ` ${status}\n\n` - } - - if (sessions.length > 10) { - message += `... and ${sessions.length - 10} more sessions` - } - - return truncate(message) -} - -/** - * Format session detail for display - */ -export function formatSessionDetail(session: Session, messages: DecryptedMessage[] = []): string { - const name = getSessionName(session) - const status = getSessionStatusText(session) - const statusEmoji = getSessionStatusEmoji(session) - const path = session.metadata?.path || 'Unknown' - const host = session.metadata?.host || 'Unknown' - const mode = session.permissionMode || 'default' - - let message = `${statusEmoji} ${name}\n\n` - message += `Path: ${path}\n` - message += `Host: ${host}\n` - message += `Status: ${status}\n` - message += `Mode: ${mode}\n` - - // Check for permission requests - const requests = session.agentState?.requests - if (requests && Object.keys(requests).length > 0) { - message += '\n--- Permission Request ---\n' - for (const [reqId, req] of Object.entries(requests)) { - message += `Tool: ${req.tool}\n` - if (req.arguments) { - const args = formatToolArguments(req.tool, req.arguments) - if (args) { - message += `${args}\n` - } - } - } - } - - // Show recent messages if available - if (messages.length > 0) { - message += '\n--- Recent Messages ---\n' - const recent = messages.slice(-5) // Last 5 messages - - for (const msg of recent) { - const formatted = formatMessage(msg) - if (formatted) { - message += formatted + '\n' - } - } - } - - // Add summary if available - if (session.metadata?.summary?.text) { - message += '\n--- Summary ---\n' - message += truncate(session.metadata.summary.text, 500) + '\n' - } - - return truncate(message) -} - -/** - * Format tool arguments for display - */ -function formatToolArguments(tool: string, args: any): string { - try { - switch (tool) { - case 'Edit': - case 'Write': - case 'Read': - return `File: ${args.file_path || args.path || 'unknown'}` - case 'Bash': - const cmd = args.command || '' - return `Command: ${truncate(cmd, 100)}` - case 'Task': - return `Task: ${truncate(args.prompt || args.description || '', 100)}` - case 'Grep': - case 'Glob': - return `Pattern: ${args.pattern || ''}` - default: - return '' - } - } catch { - return '' - } -} - -/** - * Format a single message for display - */ -function formatMessage(msg: DecryptedMessage): string { - try { - const content = msg.content - if (!content || typeof content !== 'object') return '' - - const contentObj = content as Record - - const role = typeof contentObj.role === 'string' ? contentObj.role : 'unknown' - const roleEmoji = role === 'user' ? '👤' : role === 'assistant' ? '🤖' : '🔧' - - const inner = contentObj.content - - // Handle different content types - if (inner && typeof inner === 'object') { - const innerObj = inner as Record - if (innerObj.type === 'text') { - const text = typeof innerObj.text === 'string' ? innerObj.text : '' - return `${roleEmoji} ${truncate(text, 200)}` - } - - if (innerObj.type === 'tool_use') { - const toolName = typeof innerObj.name === 'string' ? innerObj.name : 'unknown' - return `🔧 ${toolName}` - } - - if (innerObj.type === 'tool_result') { - return `✓ Tool completed` - } - } - - if (inner && typeof inner === 'object') { - return '' - } - - if (typeof inner === 'string') { - return `${roleEmoji} ${truncate(inner, 200)}` - } - - // Backward-compat fallback for older content shapes - if (typeof (contentObj as { content?: unknown }).content === 'string') { - const text = (contentObj as { content: string }).content - return `${roleEmoji} ${truncate(text, 200)}` - } - - return '' - } catch { - return '' - } -} - -/** - * Format machine list for display - */ -export function formatMachineList(machines: Machine[]): string { - if (machines.length === 0) { - return 'No machines online.\n\nMake sure you have the HAPI daemon running on your machines.' - } - - let message = `Online Machines (${machines.length}):\n\n` - - for (let i = 0; i < machines.length; i++) { - const machine = machines[i] - const num = i + 1 - const name = machine.metadata?.displayName || machine.metadata?.host || 'Unknown' - const platform = machine.metadata?.platform || '' - const version = machine.metadata?.happyCliVersion || '' - - message += `${num}. 🟢 ${name}\n` - if (platform) { - message += ` Platform: ${platform}\n` - } - if (version) { - message += ` Version: ${version}\n` - } - message += '\n' - } - - return truncate(message) -} - /** * Create callback data with size limit * Format: action:sessionIdPrefix:extraData @@ -312,10 +69,3 @@ export function parseCallbackData(data: string): { action: string; sessionPrefix export function findSessionByPrefix(sessions: Session[], prefix: string): Session | undefined { return sessions.find(s => s.id.startsWith(prefix)) } - -/** - * Find machine by ID prefix - */ -export function findMachineByPrefix(machines: Machine[], prefix: string): Machine | undefined { - return machines.find(m => m.id.startsWith(prefix)) -} diff --git a/server/src/telegram/sessionView.ts b/server/src/telegram/sessionView.ts index f6ffc061..5795f68c 100644 --- a/server/src/telegram/sessionView.ts +++ b/server/src/telegram/sessionView.ts @@ -1,140 +1,74 @@ /** - * Session Detail View for Telegram + * Session Notification View for Telegram * - * Provides detailed session information display including: - * - Session metadata (path, host, status, mode) - * - Recent messages with proper formatting - * - Permission request details - * - Session summary + * Provides notification formatting for permission requests. + * All interactive session views are handled by the Telegram Mini App. */ import { InlineKeyboard } from 'grammy' -import type { Session, DecryptedMessage, SyncEngine } from '../sync/syncEngine' -import { configuration } from '../configuration' +import type { Session } from '../sync/syncEngine' import { ACTIONS } from './callbacks' -import { createCallbackData, truncate, getSessionStatusEmoji, getSessionStatusText, getSessionName } from './renderer' +import { createCallbackData, truncate, getSessionName } from './renderer' -// Maximum message display -const MAX_MESSAGES_DISPLAY = 8 -const MAX_MESSAGE_LENGTH = 300 const MAX_TOOL_ARGS_LENGTH = 150 /** - * Format session detail view with full information + * Format a compact session notification for permission requests */ -export function formatSessionDetailView( - session: Session, - messages: DecryptedMessage[] = [] -): string { - const lines: string[] = [] - - // Header +export function formatSessionNotification(session: Session): string { const name = getSessionName(session) - const emoji = getSessionStatusEmoji(session) - lines.push(`${emoji} ${name}`) - lines.push('') + const lines: string[] = ['Permission Request', '', `Session: ${name}`] - // Metadata - if (session.metadata?.path) { - lines.push(`Path: ${session.metadata.path}`) - } - if (session.metadata?.host) { - lines.push(`Host: ${session.metadata.host}`) - } - - // Status line - const status = getSessionStatusText(session) - const mode = formatPermissionMode(session.permissionMode) - const model = formatModelMode(session.modelMode) - lines.push(`Status: ${status} | Mode: ${mode}${model ? ` | ${model}` : ''}`) - lines.push('') - - // Permission requests const requests = session.agentState?.requests - if (requests && Object.keys(requests).length > 0) { - lines.push('--- Permission Request ---') - for (const [reqId, req] of Object.entries(requests)) { + if (requests) { + const reqId = Object.keys(requests)[0] + const req = requests[reqId] + if (req) { lines.push(`Tool: ${req.tool}`) - const argsDisplay = formatToolArgumentsDetailed(req.tool, req.arguments) - if (argsDisplay) { - lines.push(argsDisplay) + const args = formatToolArgumentsDetailed(req.tool, req.arguments) + if (args) { + lines.push(args) } } - lines.push('') } - // Recent messages - if (messages.length > 0) { - lines.push('--- Recent Messages ---') - const recentMessages = messages.slice(-MAX_MESSAGES_DISPLAY) - - for (const msg of recentMessages) { - const formatted = formatMessageDetailed(msg) - if (formatted) { - lines.push(formatted) - } - } - lines.push('') - } - - // Summary - if (session.metadata?.summary?.text) { - lines.push('--- Summary ---') - const summaryText = truncate(session.metadata.summary.text, 500) - lines.push(summaryText) - lines.push('') - } - - // Activity info - if (session.activeAt) { - const lastActive = formatTimeAgo(session.activeAt) - lines.push(`Last active: ${lastActive}`) - } - - return truncate(lines.join('\n'), 4000) + return lines.join('\n') } /** - * Create session detail keyboard with contextual actions + * Create notification keyboard for quick actions */ -export function createSessionDetailKeyboard(session: Session): InlineKeyboard { +export function createNotificationKeyboard(session: Session, miniAppUrl: string): InlineKeyboard { const keyboard = new InlineKeyboard() - const hasRequests = session.agentState?.requests && Object.keys(session.agentState.requests).length > 0 + const requests = session.agentState?.requests ?? null + const hasRequests = Boolean(requests && Object.keys(requests).length > 0) const canControl = session.active - // Permission buttons if there are pending requests if (canControl && hasRequests) { - const requestId = Object.keys(session.agentState!.requests!)[0] + const requestId = Object.keys(requests!)[0] const reqPrefix = requestId.slice(0, 8) keyboard - .text('✅ Allow', createCallbackData(ACTIONS.APPROVE, session.id, reqPrefix)) - .text('✅✅ Edits', createCallbackData(ACTIONS.APPROVE_EDITS, session.id, reqPrefix)) - .text('❌ Deny', createCallbackData(ACTIONS.DENY, session.id, reqPrefix)) + .text('Allow', createCallbackData(ACTIONS.APPROVE, session.id, reqPrefix)) + .text('Deny', createCallbackData(ACTIONS.DENY, session.id, reqPrefix)) keyboard.row() - // Add bypass option - keyboard.text('⚡ Bypass All', createCallbackData(ACTIONS.APPROVE_BYPASS, session.id, reqPrefix)) - keyboard.row() + keyboard.webApp( + 'Details', + buildMiniAppDeepLink(miniAppUrl, `session_${session.id}`) + ) + return keyboard } - // Control buttons - keyboard.text('🔄 Refresh', createCallbackData(ACTIONS.REFRESH_SESSION, session.id)) - if (canControl) { - keyboard.text('⏹ Abort', createCallbackData(ACTIONS.ABORT, session.id)) - } - keyboard.row() - - // Settings and navigation - keyboard - .text('⚙️ Settings', createCallbackData(ACTIONS.SETTINGS, session.id)) - .text('← Back', createCallbackData(ACTIONS.BACK_TO_LIST, 'back')) - + keyboard.webApp( + 'Open Session', + buildMiniAppDeepLink(miniAppUrl, `session_${session.id}`) + ) return keyboard } /** - * Format detailed tool arguments + * Format detailed tool arguments for notification display */ function formatToolArgumentsDetailed(tool: string, args: any): string { if (!args) return '' @@ -205,203 +139,6 @@ function formatToolArgumentsDetailed(tool: string, args: any): string { } } -/** - * Format a message with more detail - */ -function formatMessageDetailed(msg: DecryptedMessage): string { - if (!msg.content || typeof msg.content !== 'object') return '' - - try { - const contentObj = msg.content as Record - const role = typeof contentObj.role === 'string' ? contentObj.role : 'unknown' - const roleEmoji = role === 'user' ? '👤' : role === 'assistant' ? '🤖' : '🔧' - - const inner = contentObj.content - - if (inner && typeof inner === 'object' && !Array.isArray(inner)) { - const innerObj = inner as Record - - if (innerObj.type === 'text') { - const text = typeof innerObj.text === 'string' ? innerObj.text : '' - return `${roleEmoji} ${truncate(text, MAX_MESSAGE_LENGTH)}` - } - - if (innerObj.type === 'tool_use') { - const toolName = typeof innerObj.name === 'string' ? innerObj.name : 'unknown' - let display = `🔧 ${toolName}` - - const input = innerObj.input - if (input && typeof input === 'object') { - const inputObj = input as Record - if (typeof inputObj.file_path === 'string') { - const fileName = inputObj.file_path.split('/').pop() - display += `: ${fileName}` - } else if (typeof inputObj.command === 'string') { - display += `: ${truncate(inputObj.command, 50)}` - } else if (typeof inputObj.pattern === 'string') { - display += `: ${inputObj.pattern}` - } - } - - return display - } - - if (innerObj.type === 'tool_result') { - const isError = Boolean(innerObj.is_error) - return isError ? '❌ Tool failed' : '✓ Tool completed' - } - } - - if (Array.isArray(inner)) { - const first = inner[0] - if (first && typeof first === 'object') { - const firstObj = first as Record - if (firstObj.type === 'text') { - const text = typeof firstObj.text === 'string' ? firstObj.text : '' - return `${roleEmoji} ${truncate(text, MAX_MESSAGE_LENGTH)}` - } - if (firstObj.type === 'tool_use') { - const name = typeof firstObj.name === 'string' ? firstObj.name : 'Tool' - return `🔧 ${name}` - } - } - } - - if (typeof inner === 'string') { - return `${roleEmoji} ${truncate(inner, MAX_MESSAGE_LENGTH)}` - } - - return '' - } catch { - return '' - } -} - -/** - * Format permission mode for display - */ -function formatPermissionMode(mode?: string | null): string { - switch (mode) { - case 'acceptEdits': - return 'Accept Edits' - case 'bypassPermissions': - return 'Bypass' - case 'plan': - return 'Plan' - default: - return 'Default' - } -} - -/** - * Format model mode for display - */ -function formatModelMode(mode?: string | null): string { - switch (mode) { - case 'sonnet': - return 'Sonnet' - case 'opus': - return 'Opus' - default: - return '' - } -} - -/** - * Format timestamp as time ago - */ -function formatTimeAgo(timestamp: number): string { - const now = Date.now() - const diff = now - timestamp - - const seconds = Math.floor(diff / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - const days = Math.floor(hours / 24) - - if (days > 0) return `${days}d ago` - if (hours > 0) return `${hours}h ago` - if (minutes > 0) return `${minutes}m ago` - if (seconds > 10) return `${seconds}s ago` - return 'just now' -} - -/** - * Create a compact session card for notifications - */ -export function formatSessionNotification( - session: Session, - eventType: 'permission' | 'message' | 'status' -): string { - const name = getSessionName(session) - const emoji = getSessionStatusEmoji(session) - - let title = '' - let details = '' - - switch (eventType) { - case 'permission': { - title = 'Permission Request' - const requests = session.agentState?.requests - if (requests) { - const reqId = Object.keys(requests)[0] - const req = requests[reqId] - if (req) { - details = `Tool: ${req.tool}` - const args = formatToolArgumentsDetailed(req.tool, req.arguments) - if (args) { - details += `\n${args}` - } - } - } - break - } - - case 'message': - title = '💬 New Message' - break - - case 'status': - title = `${emoji} Status Update` - details = getSessionStatusText(session) - break - } - - return `${title}\n\nSession: ${name}\n${details}`.trim() -} - -/** - * Create notification keyboard for quick actions - */ -export function createNotificationKeyboard(session: Session): InlineKeyboard { - const keyboard = new InlineKeyboard() - const requests = session.agentState?.requests ?? null - const hasRequests = Boolean(requests && Object.keys(requests).length > 0) - const canControl = session.active - - if (canControl && hasRequests) { - const requestId = Object.keys(requests!)[0] - const reqPrefix = requestId.slice(0, 8) - - keyboard - .text('✅ Allow', createCallbackData(ACTIONS.APPROVE, session.id, reqPrefix)) - .text('❌ Deny', createCallbackData(ACTIONS.DENY, session.id, reqPrefix)) - keyboard.row() - - keyboard.webApp( - 'Details', - buildMiniAppDeepLink(configuration.miniAppUrl, `session_${session.id}`) - ) - return keyboard - } - - keyboard.webApp( - 'Open Session', - buildMiniAppDeepLink(configuration.miniAppUrl, `session_${session.id}`) - ) - return keyboard -} - function buildMiniAppDeepLink(baseUrl: string, startParam: string): string { try { const url = new URL(baseUrl) @@ -412,79 +149,3 @@ function buildMiniAppDeepLink(baseUrl: string, startParam: string): string { return `${baseUrl}${separator}startapp=${encodeURIComponent(startParam)}` } } - -/** - * Format settings view for a session - */ -export function formatSettingsView(session: Session): string { - const name = getSessionName(session) - const currentMode = formatPermissionMode(session.permissionMode) - const currentModel = session.modelMode || 'default' - - const lines = [ - `⚙️ Settings: ${name}`, - '', - `Permission Mode: ${currentMode}`, - `Model: ${currentModel === 'default' ? 'Default' : currentModel === 'sonnet' ? 'Sonnet' : 'Opus'}`, - '', - 'Select options below to change settings.' - ] - - return lines.join('\n') -} - -/** - * Create settings keyboard - */ -export function createSettingsKeyboard(session: Session): InlineKeyboard { - const keyboard = new InlineKeyboard() - if (!session.active) { - keyboard.text('← Back to Session', createCallbackData(ACTIONS.BACK_TO_SESSION, session.id)) - return keyboard - } - - const currentMode = session.permissionMode || 'default' - const currentModel = session.modelMode || 'default' - - // Permission mode row 1 - keyboard.text( - currentMode === 'default' ? '✓ Default' : 'Default', - createCallbackData(ACTIONS.SET_MODE_DEFAULT, session.id) - ) - keyboard.text( - currentMode === 'acceptEdits' ? '✓ Accept Edits' : 'Accept Edits', - createCallbackData(ACTIONS.SET_MODE_EDITS, session.id) - ) - keyboard.row() - - // Permission mode row 2 - keyboard.text( - currentMode === 'bypassPermissions' ? '✓ Bypass' : 'Bypass', - createCallbackData(ACTIONS.SET_MODE_BYPASS, session.id) - ) - keyboard.text( - currentMode === 'plan' ? '✓ Plan' : 'Plan', - createCallbackData(ACTIONS.SET_MODE_PLAN, session.id) - ) - keyboard.row() - - // Model row - keyboard.text( - currentModel === 'sonnet' ? '✓ Sonnet' : 'Sonnet', - createCallbackData(ACTIONS.SET_MODEL_SONNET, session.id) - ) - keyboard.text( - currentModel === 'opus' ? '✓ Opus' : 'Opus', - createCallbackData(ACTIONS.SET_MODEL_OPUS, session.id) - ) - keyboard.row() - - // Actions - keyboard.text('⏹ Abort Session', createCallbackData(ACTIONS.ABORT, session.id)) - keyboard.row() - - // Back button - keyboard.text('← Back to Session', createCallbackData(ACTIONS.BACK_TO_SESSION, session.id)) - - return keyboard -}