fix: add Telegram notification context (#768)

This commit is contained in:
SSU-WEI HUANG
2026-06-02 13:08:32 +08:00
committed by GitHub
parent bd13fac7ae
commit 39fba5292c
3 changed files with 221 additions and 9 deletions
+27 -5
View File
@@ -6,9 +6,9 @@
*/
import { Bot, Context, InlineKeyboard } from 'grammy'
import { SyncEngine, Session } from '../sync/syncEngine'
import { SyncEngine, Session, type Machine } from '../sync/syncEngine'
import { handleCallback, CallbackContext } from './callbacks'
import { formatSessionNotification, createNotificationKeyboard } from './sessionView'
import { formatReadyNotification, formatSessionNotification, createNotificationKeyboard } from './sessionView'
import { getAgentName } from '../notifications/sessionInfo'
import type { NotificationChannel, TaskNotification } from '../notifications/notificationTypes'
import type { Store } from '../store'
@@ -184,6 +184,28 @@ export class HappyBot implements NotificationChannel {
return stored?.namespace ?? null
}
private getSessionMachine(session: Session): Machine | undefined {
if (!this.syncEngine) {
return undefined
}
const machineId = session.metadata?.machineId
if (machineId) {
const machine = this.syncEngine.getMachineByNamespace(machineId, session.namespace)
if (machine) {
return machine
}
}
const host = session.metadata?.host
if (!host) {
return undefined
}
return this.syncEngine.getMachinesByNamespace(session.namespace)
.find((machine) => machine.metadata?.host === host)
}
/**
* Send a notification when agent is ready for input.
*/
@@ -192,7 +214,7 @@ export class HappyBot implements NotificationChannel {
return
}
const agentName = getAgentName(session)
const text = formatReadyNotification(session, this.getSessionMachine(session))
const url = buildMiniAppDeepLink(this.publicUrl, `session_${session.id}`)
const keyboard = new InlineKeyboard()
.webApp('Open Session', url)
@@ -206,7 +228,7 @@ export class HappyBot implements NotificationChannel {
try {
await this.bot.api.sendMessage(
chatId,
`It's ready!\n\n${agentName} is waiting for your command`,
text,
{ reply_markup: keyboard }
)
} catch (error) {
@@ -223,7 +245,7 @@ export class HappyBot implements NotificationChannel {
return
}
const text = formatSessionNotification(session)
const text = formatSessionNotification(session, this.getSessionMachine(session))
const keyboard = createNotificationKeyboard(session, this.publicUrl)
const chatIds = this.getBoundChatIds(session.namespace)
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'bun:test'
import type { Machine, Session } from '../sync/syncEngine'
import { formatReadyNotification, formatSessionNotification } from './sessionView'
function createSession(overrides: Partial<Session> = {}): Session {
return {
id: 'session-1234567890',
namespace: 'default',
seq: 1,
createdAt: 0,
updatedAt: 0,
active: true,
activeAt: 0,
metadata: {
path: '/home/alice/infra',
host: 'devbox.local',
homeDir: '/home/alice',
name: 'rotate HAPI secrets',
machineId: 'machine-1',
flavor: 'codex'
},
metadataVersion: 1,
agentState: null,
agentStateVersion: 0,
thinking: false,
thinkingAt: 0,
model: null,
modelReasoningEffort: null,
effort: null,
...overrides
}
}
function createMachine(overrides: Partial<Machine> = {}): Machine {
return {
id: 'machine-1',
namespace: 'default',
seq: 1,
createdAt: 0,
updatedAt: 0,
active: true,
activeAt: 0,
metadata: {
host: 'devbox.local',
platform: 'linux',
happyCliVersion: '0.1.0',
displayName: 'Work Laptop'
},
metadataVersion: 1,
runnerState: null,
runnerStateVersion: 0,
...overrides
}
}
describe('Telegram session notifications', () => {
it('adds session, machine, and path context to ready notifications', () => {
expect(formatReadyNotification(createSession(), createMachine())).toBe([
'Ready: rotate HAPI secrets on Work Laptop',
'',
'Codex is waiting for your command',
'Session: rotate HAPI secrets',
'Path: ~/infra'
].join('\n'))
})
it('keeps the previous ready notification text when context is unavailable', () => {
const session = createSession({ metadata: null })
expect(formatReadyNotification(session)).toBe([
"It's ready!",
'',
'Agent is waiting for your command'
].join('\n'))
})
it('adds session, machine, and path context to permission notifications without losing tool details', () => {
const session = createSession({
agentState: {
requests: {
req1: {
tool: 'Bash',
arguments: { command: 'bun test' },
createdAt: 1
}
}
}
})
expect(formatSessionNotification(session, createMachine())).toBe([
'Action required: rotate HAPI secrets on Work Laptop',
'',
'Codex requests permission',
'Session: rotate HAPI secrets',
'Path: ~/infra',
'Tool: Bash',
'Command: bun test'
].join('\n'))
})
})
+94 -4
View File
@@ -6,18 +6,51 @@
*/
import { InlineKeyboard } from 'grammy'
import type { Session } from '../sync/syncEngine'
import type { Machine, Session } from '../sync/syncEngine'
import { ACTIONS } from './callbacks'
import { createCallbackData, truncate, getSessionName } from './renderer'
import { getAgentName } from '../notifications/sessionInfo'
const MAX_TOOL_ARGS_LENGTH = 150
type NotificationContext = {
hasContext: boolean
heading: string
details: string[]
}
/**
* Format a compact notification when the agent is ready for input.
*/
export function formatReadyNotification(session: Session, machine?: Machine): string {
const agentName = getAgentName(session)
const context = buildNotificationContext(session, machine)
if (!context.hasContext) {
return `It's ready!\n\n${agentName} is waiting for your command`
}
return [
`Ready: ${context.heading}`,
'',
`${agentName} is waiting for your command`,
...context.details
].join('\n')
}
/**
* Format a compact session notification for permission requests
*/
export function formatSessionNotification(session: Session): string {
const name = getSessionName(session)
const lines: string[] = ['Permission Request', '', `Session: ${name}`]
export function formatSessionNotification(session: Session, machine?: Machine): string {
const context = buildNotificationContext(session, machine)
const lines: string[] = context.hasContext
? [
`Action required: ${context.heading}`,
'',
`${getAgentName(session)} requests permission`,
...context.details
]
: ['Permission Request', '', `Session: ${getSessionName(session)}`]
const requests = session.agentState?.requests
if (requests) {
@@ -35,6 +68,63 @@ export function formatSessionNotification(session: Session): string {
return lines.join('\n')
}
function buildNotificationContext(session: Session, machine?: Machine): NotificationContext {
const sessionName = getContextSessionName(session)
const machineName = getMachineName(session, machine)
const path = formatSessionPath(session)
const heading = formatHeading(sessionName, machineName)
const details: string[] = []
if (sessionName) {
details.push(`Session: ${sessionName}`)
}
if (path) {
details.push(`Path: ${path}`)
}
return {
hasContext: Boolean(heading || details.length > 0),
heading: heading || getSessionName(session),
details
}
}
function getContextSessionName(session: Session): string | null {
if (session.metadata?.name) return session.metadata.name
if (session.metadata?.summary?.text) return session.metadata.summary.text
if (session.metadata?.path) return getSessionName(session)
return null
}
function getMachineName(session: Session, machine?: Machine): string | null {
const name = machine?.metadata?.displayName
?? machine?.metadata?.host
?? session.metadata?.host
?? null
const trimmed = name?.trim()
return trimmed ? trimmed : null
}
function formatHeading(sessionName: string | null, machineName: string | null): string {
if (sessionName && machineName) return `${sessionName} on ${machineName}`
if (sessionName) return sessionName
if (machineName) return machineName
return ''
}
function formatSessionPath(session: Session): string | null {
const path = session.metadata?.path?.trim()
if (!path) return null
const homeDir = session.metadata?.homeDir?.trim()
if (!homeDir) return path
if (path === homeDir) return '~'
if (path.startsWith(`${homeDir}/`)) {
return `~/${path.slice(homeDir.length + 1)}`
}
return path
}
/**
* Create notification keyboard for quick actions
*/