mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
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:
+25
-86
@@ -1,24 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Telegram Bot for HAPI
|
* Telegram Bot for HAPI
|
||||||
*
|
*
|
||||||
* Main bot class that initializes grammy, applies middleware,
|
* Simplified bot that only handles notifications (permission requests and ready events).
|
||||||
* and sets up command handlers.
|
* All interactive features are handled by the Telegram Mini App.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Bot, Context, NextFunction, InlineKeyboard } from 'grammy'
|
import { Bot, Context, NextFunction, InlineKeyboard } from 'grammy'
|
||||||
import { SyncEngine, SyncEvent, Session } from '../sync/syncEngine'
|
import { SyncEngine, SyncEvent, Session } from '../sync/syncEngine'
|
||||||
import { getSessionName } from './renderer'
|
import { handleCallback, CallbackContext } from './callbacks'
|
||||||
import {
|
import { formatSessionNotification, createNotificationKeyboard } from './sessionView'
|
||||||
handleCallback,
|
|
||||||
CallbackContext,
|
|
||||||
} from './callbacks'
|
|
||||||
import {
|
|
||||||
formatSessionNotification,
|
|
||||||
createNotificationKeyboard
|
|
||||||
} from './sessionView'
|
|
||||||
|
|
||||||
export interface BotContext extends Context {
|
export interface BotContext extends Context {
|
||||||
// Extended context for future use (session state, etc.)
|
// Extended context for future use
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HappyBotConfig {
|
export interface HappyBotConfig {
|
||||||
@@ -29,7 +22,7 @@ export interface HappyBotConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HAPI Telegram Bot
|
* HAPI Telegram Bot - Notification-only mode
|
||||||
*/
|
*/
|
||||||
export class HappyBot {
|
export class HappyBot {
|
||||||
private bot: Bot<BotContext>
|
private bot: Bot<BotContext>
|
||||||
@@ -40,13 +33,13 @@ export class HappyBot {
|
|||||||
private readonly allowlistConfigured: boolean
|
private readonly allowlistConfigured: boolean
|
||||||
|
|
||||||
// Track last known permission requests per session to detect new ones
|
// 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
|
// 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
|
// 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
|
// Unsubscribe function for sync events
|
||||||
private unsubscribeSyncEvents: (() => void) | null = null
|
private unsubscribeSyncEvents: (() => void) | null = null
|
||||||
@@ -60,9 +53,9 @@ export class HappyBot {
|
|||||||
this.bot = new Bot<BotContext>(config.botToken)
|
this.bot = new Bot<BotContext>(config.botToken)
|
||||||
this.setupMiddleware()
|
this.setupMiddleware()
|
||||||
this.setupCommands()
|
this.setupCommands()
|
||||||
|
|
||||||
if (this.allowlistConfigured) {
|
if (this.allowlistConfigured) {
|
||||||
this.setupCallbacks()
|
this.setupCallbacks()
|
||||||
this.setupMessageHandler()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to sync events immediately if engine is available
|
// Subscribe to sync events immediately if engine is available
|
||||||
@@ -173,6 +166,7 @@ export class HappyBot {
|
|||||||
* Setup command handlers
|
* Setup command handlers
|
||||||
*/
|
*/
|
||||||
private setupCommands(): void {
|
private setupCommands(): void {
|
||||||
|
// When allowlist is not configured, show setup instructions
|
||||||
if (!this.allowlistConfigured) {
|
if (!this.allowlistConfigured) {
|
||||||
this.bot.command('start', async (ctx) => {
|
this.bot.command('start', async (ctx) => {
|
||||||
const chatId = ctx.chat?.id
|
const chatId = ctx.chat?.id
|
||||||
@@ -188,48 +182,25 @@ export class HappyBot {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// /start - Status + help
|
// /app - Open Telegram Mini App (primary entry point)
|
||||||
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
|
|
||||||
this.bot.command('app', async (ctx) => {
|
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 })
|
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 {
|
private setupCallbacks(): void {
|
||||||
this.bot.on('callback_query:data', async (ctx) => {
|
this.bot.on('callback_query:data', async (ctx) => {
|
||||||
@@ -240,7 +211,6 @@ export class HappyBot {
|
|||||||
|
|
||||||
const data = ctx.callbackQuery.data
|
const data = ctx.callbackQuery.data
|
||||||
|
|
||||||
// Handle other callbacks
|
|
||||||
const callbackContext: CallbackContext = {
|
const callbackContext: CallbackContext = {
|
||||||
syncEngine: this.syncEngine,
|
syncEngine: this.syncEngine,
|
||||||
answerCallback: async (text?: string) => {
|
answerCallback: async (text?: string) => {
|
||||||
@@ -250,11 +220,6 @@ export class HappyBot {
|
|||||||
await ctx.editMessageText(text, {
|
await ctx.editMessageText(text, {
|
||||||
reply_markup: keyboard
|
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
|
* Handle sync engine events for notifications
|
||||||
*/
|
*/
|
||||||
@@ -308,10 +251,7 @@ export class HappyBot {
|
|||||||
this.sendReadyNotification(event.sessionId).catch((error) => {
|
this.sendReadyNotification(event.sessionId).catch((error) => {
|
||||||
console.error('[HAPIBot] Failed to send ready notification:', 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
|
* Check if session has new permission requests and send notification
|
||||||
*/
|
*/
|
||||||
@@ -422,8 +361,8 @@ export class HappyBot {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = formatSessionNotification(session, 'permission')
|
const text = formatSessionNotification(session)
|
||||||
const keyboard = createNotificationKeyboard(session)
|
const keyboard = createNotificationKeyboard(session, this.miniAppUrl)
|
||||||
|
|
||||||
// Send to all allowed chat IDs
|
// Send to all allowed chat IDs
|
||||||
for (const chatId of this.allowedChatIds) {
|
for (const chatId of this.allowedChatIds) {
|
||||||
|
|||||||
@@ -1,126 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Callback Query Handlers for Telegram
|
* 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 { InlineKeyboard } from 'grammy'
|
||||||
import type { Session, Machine, SyncEngine } from '../sync/syncEngine'
|
import type { Session, SyncEngine } from '../sync/syncEngine'
|
||||||
import {
|
import { parseCallbackData, findSessionByPrefix } from './renderer'
|
||||||
createCallbackData,
|
|
||||||
parseCallbackData,
|
|
||||||
findSessionByPrefix,
|
|
||||||
findMachineByPrefix,
|
|
||||||
formatSessionList,
|
|
||||||
formatMachineList,
|
|
||||||
getSessionName,
|
|
||||||
getSessionStatusEmoji
|
|
||||||
} from './renderer'
|
|
||||||
import {
|
|
||||||
formatSessionDetailView,
|
|
||||||
createSessionDetailKeyboard as createDetailKeyboard,
|
|
||||||
formatSettingsView,
|
|
||||||
createSettingsKeyboard
|
|
||||||
} from './sessionView'
|
|
||||||
|
|
||||||
// Callback action types
|
// Callback action types (simplified - only permission actions)
|
||||||
export const ACTIONS = {
|
export const ACTIONS = {
|
||||||
// Session actions
|
|
||||||
VIEW_SESSION: 'vs',
|
|
||||||
REFRESH_SESSION: 'rs',
|
|
||||||
BACK_TO_LIST: 'bl',
|
|
||||||
|
|
||||||
// Permission actions
|
|
||||||
APPROVE: 'ap',
|
APPROVE: 'ap',
|
||||||
APPROVE_EDITS: 'ae',
|
|
||||||
APPROVE_BYPASS: 'ab',
|
|
||||||
DENY: 'dn',
|
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
|
} 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
|
* Callback handler context
|
||||||
*/
|
*/
|
||||||
@@ -128,27 +22,6 @@ export interface CallbackContext {
|
|||||||
syncEngine: SyncEngine
|
syncEngine: SyncEngine
|
||||||
answerCallback: (text?: string) => Promise<void>
|
answerCallback: (text?: string) => Promise<void>
|
||||||
editMessage: (text: string, keyboard?: InlineKeyboard) => 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(
|
async function getSessionOrAnswer(
|
||||||
@@ -181,57 +54,6 @@ export async function handleCallback(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
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: {
|
case ACTIONS.APPROVE: {
|
||||||
const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix, { requireActive: true })
|
const session = await getSessionOrAnswer(ctx, syncEngine, sessionPrefix, { requireActive: true })
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -247,66 +69,8 @@ export async function handleCallback(
|
|||||||
await syncEngine.approvePermission(session.id, requestId)
|
await syncEngine.approvePermission(session.id, requestId)
|
||||||
await ctx.answerCallback('Approved!')
|
await ctx.answerCallback('Approved!')
|
||||||
|
|
||||||
// Refresh the view
|
// Update the notification message
|
||||||
const messages = syncEngine.getSessionMessages(session.id)
|
await ctx.editMessage('Permission approved.', new InlineKeyboard())
|
||||||
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)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,145 +89,8 @@ export async function handleCallback(
|
|||||||
await syncEngine.denyPermission(session.id, requestId)
|
await syncEngine.denyPermission(session.id, requestId)
|
||||||
await ctx.answerCallback('Denied')
|
await ctx.answerCallback('Denied')
|
||||||
|
|
||||||
// Refresh the view
|
// Update the notification message
|
||||||
const messages = syncEngine.getSessionMessages(session.id)
|
await ctx.editMessage('Permission denied.', new InlineKeyboard())
|
||||||
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')
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Message Renderer for Telegram
|
* Utilities for Telegram Bot
|
||||||
*
|
*
|
||||||
* Formats session data, messages, and other content for Telegram display.
|
* Helper functions for rendering and callback data handling.
|
||||||
* Handles truncation to respect Telegram's message limits.
|
* Simplified to only include utilities needed for notifications.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Session, Machine, DecryptedMessage } from '../sync/syncEngine'
|
import type { Session } from '../sync/syncEngine'
|
||||||
|
|
||||||
// Telegram limits
|
// Telegram limits
|
||||||
const MAX_MESSAGE_LENGTH = 4096
|
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) + '...'
|
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)
|
* Get session name (project name or directory name)
|
||||||
*/
|
*/
|
||||||
@@ -62,218 +31,6 @@ export function getSessionName(session: Session): string {
|
|||||||
return 'Unknown'
|
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
|
* Create callback data with size limit
|
||||||
* Format: action:sessionIdPrefix:extraData
|
* 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 {
|
export function findSessionByPrefix(sessions: Session[], prefix: string): Session | undefined {
|
||||||
return sessions.find(s => s.id.startsWith(prefix))
|
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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,140 +1,74 @@
|
|||||||
/**
|
/**
|
||||||
* Session Detail View for Telegram
|
* Session Notification View for Telegram
|
||||||
*
|
*
|
||||||
* Provides detailed session information display including:
|
* Provides notification formatting for permission requests.
|
||||||
* - Session metadata (path, host, status, mode)
|
* All interactive session views are handled by the Telegram Mini App.
|
||||||
* - Recent messages with proper formatting
|
|
||||||
* - Permission request details
|
|
||||||
* - Session summary
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { InlineKeyboard } from 'grammy'
|
import { InlineKeyboard } from 'grammy'
|
||||||
import type { Session, DecryptedMessage, SyncEngine } from '../sync/syncEngine'
|
import type { Session } from '../sync/syncEngine'
|
||||||
import { configuration } from '../configuration'
|
|
||||||
import { ACTIONS } from './callbacks'
|
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
|
const MAX_TOOL_ARGS_LENGTH = 150
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format session detail view with full information
|
* Format a compact session notification for permission requests
|
||||||
*/
|
*/
|
||||||
export function formatSessionDetailView(
|
export function formatSessionNotification(session: Session): string {
|
||||||
session: Session,
|
|
||||||
messages: DecryptedMessage[] = []
|
|
||||||
): string {
|
|
||||||
const lines: string[] = []
|
|
||||||
|
|
||||||
// Header
|
|
||||||
const name = getSessionName(session)
|
const name = getSessionName(session)
|
||||||
const emoji = getSessionStatusEmoji(session)
|
const lines: string[] = ['Permission Request', '', `Session: ${name}`]
|
||||||
lines.push(`${emoji} ${name}`)
|
|
||||||
lines.push('')
|
|
||||||
|
|
||||||
// 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
|
const requests = session.agentState?.requests
|
||||||
if (requests && Object.keys(requests).length > 0) {
|
if (requests) {
|
||||||
lines.push('--- Permission Request ---')
|
const reqId = Object.keys(requests)[0]
|
||||||
for (const [reqId, req] of Object.entries(requests)) {
|
const req = requests[reqId]
|
||||||
|
if (req) {
|
||||||
lines.push(`Tool: ${req.tool}`)
|
lines.push(`Tool: ${req.tool}`)
|
||||||
const argsDisplay = formatToolArgumentsDetailed(req.tool, req.arguments)
|
const args = formatToolArgumentsDetailed(req.tool, req.arguments)
|
||||||
if (argsDisplay) {
|
if (args) {
|
||||||
lines.push(argsDisplay)
|
lines.push(args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.push('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent messages
|
return lines.join('\n')
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 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
|
const canControl = session.active
|
||||||
|
|
||||||
// Permission buttons if there are pending requests
|
|
||||||
if (canControl && hasRequests) {
|
if (canControl && hasRequests) {
|
||||||
const requestId = Object.keys(session.agentState!.requests!)[0]
|
const requestId = Object.keys(requests!)[0]
|
||||||
const reqPrefix = requestId.slice(0, 8)
|
const reqPrefix = requestId.slice(0, 8)
|
||||||
|
|
||||||
keyboard
|
keyboard
|
||||||
.text('✅ Allow', createCallbackData(ACTIONS.APPROVE, session.id, reqPrefix))
|
.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('❌ Deny', createCallbackData(ACTIONS.DENY, session.id, reqPrefix))
|
|
||||||
keyboard.row()
|
keyboard.row()
|
||||||
|
|
||||||
// Add bypass option
|
keyboard.webApp(
|
||||||
keyboard.text('⚡ Bypass All', createCallbackData(ACTIONS.APPROVE_BYPASS, session.id, reqPrefix))
|
'Details',
|
||||||
keyboard.row()
|
buildMiniAppDeepLink(miniAppUrl, `session_${session.id}`)
|
||||||
|
)
|
||||||
|
return keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Control buttons
|
keyboard.webApp(
|
||||||
keyboard.text('🔄 Refresh', createCallbackData(ACTIONS.REFRESH_SESSION, session.id))
|
'Open Session',
|
||||||
if (canControl) {
|
buildMiniAppDeepLink(miniAppUrl, `session_${session.id}`)
|
||||||
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'))
|
|
||||||
|
|
||||||
return keyboard
|
return keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format detailed tool arguments
|
* Format detailed tool arguments for notification display
|
||||||
*/
|
*/
|
||||||
function formatToolArgumentsDetailed(tool: string, args: any): string {
|
function formatToolArgumentsDetailed(tool: string, args: any): string {
|
||||||
if (!args) return ''
|
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 {
|
function buildMiniAppDeepLink(baseUrl: string, startParam: string): string {
|
||||||
try {
|
try {
|
||||||
const url = new URL(baseUrl)
|
const url = new URL(baseUrl)
|
||||||
@@ -412,79 +149,3 @@ function buildMiniAppDeepLink(baseUrl: string, startParam: string): string {
|
|||||||
return `${baseUrl}${separator}startapp=${encodeURIComponent(startParam)}`
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user