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.
This commit is contained in:
weishu
2025-12-24 14:23:01 +08:00
parent 6d401fb9ec
commit bb9c5b66d3
4 changed files with 71 additions and 1094 deletions
+25 -86
View File
@@ -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<BotContext>
@@ -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<string, Set<string>> = new Map() // sessionId -> requestIds
private lastKnownRequests: Map<string, Set<string>> = new Map()
// Debounce timers for notifications
private notificationDebounce: Map<string, NodeJS.Timeout> = new Map() // sessionId -> timer
private notificationDebounce: Map<string, NodeJS.Timeout> = new Map()
// Track ready notifications to avoid spam
private lastReadyNotificationAt: Map<string, number> = new Map() // sessionId -> timestamp
private lastReadyNotificationAt: Map<string, number> = new Map()
// Unsubscribe function for sync events
private unsubscribeSyncEvents: (() => void) | null = null
@@ -60,9 +53,9 @@ export class HappyBot {
this.bot = new Bot<BotContext>(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) {
+9 -382
View File
@@ -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<void>
editMessage: (text: string, keyboard?: InlineKeyboard) => Promise<void>
sendMessage: (text: string, keyboard?: InlineKeyboard) => Promise<void>
}
/**
* Safely edit message, ignoring "message not modified" errors
*/
async function safeEditMessage(
ctx: CallbackContext,
text: string,
keyboard?: InlineKeyboard
): Promise<void> {
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<string, 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'> = {
[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<string, 'default' | 'sonnet' | 'opus'> = {
[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
}
+4 -254
View File
@@ -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<string, unknown>
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<string, unknown>
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))
}
+33 -372
View File
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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
}