feat: make Telegram optional and unify user authentication with owner ID

- Make TELEGRAM_BOT_TOKEN and ALLOWED_CHAT_IDS optional environment variables
- Add telegramEnabled flag to conditionally initialize the bot on startup
- Introduce persistent owner ID for unified user identity across web and Telegram auth
- Update Telegram bot to accept configuration in constructor instead of using global config
- Handle empty allowlist by showing chat ID prompt on /start command
- Use owner ID instead of Telegram user ID for API authentication
- Add conditional Telegram support checks in auth routes with clear error messages
- Update documentation to explain optional Telegram configuration and binding workflow
- Rename telegramUserId to userId in auth middleware for clarity
This commit is contained in:
weishu
2025-12-22 08:52:51 +08:00
parent 7c86f83d5c
commit 6505c58bf3
9 changed files with 242 additions and 61 deletions
+16 -18
View File
@@ -2,9 +2,11 @@
* Configuration for hapi-server (Direct Connect)
*
* Required environment variables:
* - CLI_API_TOKEN: Shared secret for hapi CLI authentication
*
* Optional Telegram environment variables:
* - TELEGRAM_BOT_TOKEN: Telegram Bot API token from @BotFather
* - ALLOWED_CHAT_IDS: Comma-separated list of allowed Telegram chat IDs
* - CLI_API_TOKEN: Shared secret for hapi CLI authentication
*/
import { existsSync, mkdirSync } from 'node:fs'
@@ -13,11 +15,14 @@ import { join } from 'node:path'
class Configuration {
/** Telegram Bot API token */
public readonly telegramBotToken: string
public readonly telegramBotToken: string | null
/** List of allowed Telegram chat IDs (security whitelist) */
public readonly allowedChatIds: number[]
/** Telegram bot enabled status (token present) */
public readonly telegramEnabled: boolean
/** CLI auth token (shared secret) */
public readonly cliApiToken: string
@@ -37,26 +42,19 @@ class Configuration {
public readonly corsOrigins: string[]
constructor() {
// Required: Telegram Bot Token
// Optional: Telegram Bot Token
const botToken = process.env.TELEGRAM_BOT_TOKEN
if (!botToken) {
throw new Error('TELEGRAM_BOT_TOKEN environment variable is required')
}
this.telegramBotToken = botToken
this.telegramBotToken = botToken ?? null
this.telegramEnabled = Boolean(this.telegramBotToken)
// Required: Allowed Chat IDs
// Optional: Allowed Chat IDs
const chatIdsStr = process.env.ALLOWED_CHAT_IDS
if (!chatIdsStr) {
throw new Error('ALLOWED_CHAT_IDS environment variable is required (comma-separated list)')
}
this.allowedChatIds = chatIdsStr
.split(',')
.map(id => parseInt(id.trim(), 10))
.filter(id => !isNaN(id))
if (this.allowedChatIds.length === 0) {
throw new Error('ALLOWED_CHAT_IDS must contain at least one valid chat ID')
}
? chatIdsStr
.split(',')
.map(id => parseInt(id.trim(), 10))
.filter(id => !isNaN(id))
: []
// Required: CLI API token (shared secret)
const cliApiToken = process.env.CLI_API_TOKEN
+29 -15
View File
@@ -1,12 +1,11 @@
/**
* HAPI Telegram Bot - Main Entry Point
* HAPI Server - Main Entry Point
*
* This is a Telegram Bot client for HAPI that provides:
* - Session list and detail views
* - Message viewing and sending
* - Permission approval workflows
* - Session control (abort, mode switching, model selection)
* - New session creation on remote machines
* Provides:
* - Web app + HTTP API
* - Socket.IO for CLI connections
* - SSE updates for the web UI
* - Optional Telegram bot for notifications and Mini App entrypoint
*/
import { getConfiguration } from './configuration'
@@ -26,12 +25,18 @@ let webServer: BunServer<WebSocketData> | null = null
let sseManager: SSEManager | null = null
async function main() {
console.log('HAPI Bot starting...')
console.log('HAPI Server starting...')
// Load configuration (will throw if required env vars missing)
const config = getConfiguration()
console.log(`Mini App: ${config.miniAppUrl} (port ${config.webappPort})`)
console.log(`Allowed chat IDs: ${config.allowedChatIds.join(', ')}`)
console.log(`[Server] Mini App: ${config.miniAppUrl} (port ${config.webappPort})`)
if (!config.telegramEnabled) {
console.log('[Server] Telegram: disabled (missing TELEGRAM_BOT_TOKEN)')
} else if (config.allowedChatIds.length === 0) {
console.log('[Server] Telegram: enabled (allowlist empty; /start shows chat ID)')
} else {
console.log(`[Server] Telegram: enabled (chat IDs: ${config.allowedChatIds.join(', ')})`)
}
const store = new Store(config.dbPath)
const jwtSecret = await getOrCreateJwtSecret()
@@ -48,8 +53,15 @@ async function main() {
syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager)
// Initialize Telegram bot
happyBot = new HappyBot({ syncEngine })
// Initialize Telegram bot (optional)
if (config.telegramEnabled && config.telegramBotToken) {
happyBot = new HappyBot({
syncEngine,
botToken: config.telegramBotToken,
allowedChatIds: config.allowedChatIds,
miniAppUrl: config.miniAppUrl
})
}
// Start HTTP server for Telegram Mini App
webServer = await startWebServer({
@@ -59,10 +71,12 @@ async function main() {
socketEngine: socketServer.engine
})
// Start the bot
await happyBot.start()
// Start the bot if configured
if (happyBot) {
await happyBot.start()
}
console.log('\nHAPI Bot is ready!')
console.log('\nHAPI Server is ready!')
// Handle shutdown
const shutdown = async () => {
+57 -16
View File
@@ -6,7 +6,6 @@
*/
import { Bot, Context, NextFunction, InlineKeyboard } from 'grammy'
import { configuration } from '../configuration'
import { SyncEngine, SyncEvent, Session } from '../sync/syncEngine'
import { getSessionName, truncate } from './renderer'
import {
@@ -24,6 +23,9 @@ export interface BotContext extends Context {
export interface HappyBotConfig {
syncEngine: SyncEngine
botToken: string
allowedChatIds: number[]
miniAppUrl: string
}
/**
@@ -33,6 +35,9 @@ export class HappyBot {
private bot: Bot<BotContext>
private syncEngine: SyncEngine | null = null
private isRunning = false
private readonly allowedChatIds: number[]
private readonly miniAppUrl: string
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
@@ -48,12 +53,17 @@ export class HappyBot {
constructor(config: HappyBotConfig) {
this.syncEngine = config.syncEngine
this.allowedChatIds = config.allowedChatIds
this.miniAppUrl = config.miniAppUrl
this.allowlistConfigured = this.allowedChatIds.length > 0
this.bot = new Bot<BotContext>(configuration.telegramBotToken)
this.bot = new Bot<BotContext>(config.botToken)
this.setupMiddleware()
this.setupCommands()
this.setupCallbacks()
this.setupMessageHandler()
if (this.allowlistConfigured) {
this.setupCallbacks()
this.setupMessageHandler()
}
// Subscribe to sync events immediately if engine is available
if (this.syncEngine) {
@@ -134,9 +144,21 @@ export class HappyBot {
// Security middleware: only allow configured chat IDs
this.bot.use(async (ctx: BotContext, next: NextFunction) => {
const chatId = ctx.chat?.id
if (!chatId || !configuration.allowedChatIds.includes(chatId)) {
console.log(`[HAPIBot] Rejected message from unauthorized chat: ${chatId}`)
return // Silently ignore unauthorized users
if (this.allowlistConfigured) {
if (!chatId || !this.allowedChatIds.includes(chatId)) {
console.log(`[HAPIBot] Rejected message from unauthorized chat: ${chatId}`)
return // Silently ignore unauthorized users
}
await next()
return
}
const messageText = ctx.message?.text ?? ''
if (!messageText.startsWith('/start')) {
if (chatId) {
console.log(`[HAPIBot] Allowlist empty; ignoring chat: ${chatId}`)
}
return
}
await next()
})
@@ -151,6 +173,21 @@ export class HappyBot {
* Setup command handlers
*/
private setupCommands(): void {
if (!this.allowlistConfigured) {
this.bot.command('start', async (ctx) => {
const chatId = ctx.chat?.id
const chatIdDisplay = chatId ? String(chatId) : 'unknown'
const example = chatId ? `ALLOWED_CHAT_IDS="${chatId}"` : 'ALLOWED_CHAT_IDS="12345678"'
await ctx.reply(
`HAPI bot is not fully configured yet.\n\n` +
`Your chat ID is: ${chatIdDisplay}\n` +
`Set ${example} and restart the server.`
)
})
return
}
// /start - Status + help
this.bot.command('start', async (ctx) => {
const sessionCount = this.syncEngine?.getActiveSessions().length ?? 0
@@ -185,7 +222,7 @@ export class HappyBot {
// /app - Open Telegram Mini App
this.bot.command('app', async (ctx) => {
const keyboard = new InlineKeyboard().webApp('📱 Open App', configuration.miniAppUrl)
const keyboard = new InlineKeyboard().webApp('📱 Open App', this.miniAppUrl)
await ctx.reply('Open HAPI Mini App:', { reply_markup: keyboard })
})
@@ -239,7 +276,7 @@ export class HappyBot {
return
}
const keyboard = new InlineKeyboard().webApp('📱 Open App', configuration.miniAppUrl)
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 }
@@ -251,6 +288,10 @@ export class HappyBot {
* Handle sync engine events for notifications
*/
private handleSyncEvent(event: SyncEvent): void {
if (!this.allowlistConfigured) {
return
}
if (event.type === 'session-updated' && event.sessionId) {
const session = this.syncEngine?.getSession(event.sessionId)
if (session) {
@@ -320,11 +361,11 @@ export class HappyBot {
const name = getSessionName(session)
const url = buildMiniAppDeepLink(configuration.miniAppUrl, `session_${sessionId}`)
const url = buildMiniAppDeepLink(this.miniAppUrl, `session_${sessionId}`)
const keyboard = new InlineKeyboard()
.webApp('📱 Open Session', url)
for (const chatId of configuration.allowedChatIds) {
for (const chatId of this.allowedChatIds) {
await this.bot.api.sendMessage(
chatId,
`${name} is ready\n\nClaude is waiting for your next message.`,
@@ -340,11 +381,11 @@ export class HappyBot {
}
const name = getSessionName(session)
const url = buildMiniAppDeepLink(configuration.miniAppUrl, `session_${sessionId}`)
const url = buildMiniAppDeepLink(this.miniAppUrl, `session_${sessionId}`)
const keyboard = new InlineKeyboard()
.webApp('📱 Details', url)
for (const chatId of configuration.allowedChatIds) {
for (const chatId of this.allowedChatIds) {
await this.bot.api.sendMessage(
chatId,
`🔄 ${name} switched to ${mode}`,
@@ -360,13 +401,13 @@ export class HappyBot {
}
const name = getSessionName(session)
const url = buildMiniAppDeepLink(configuration.miniAppUrl, `session_${sessionId}`)
const url = buildMiniAppDeepLink(this.miniAppUrl, `session_${sessionId}`)
const keyboard = new InlineKeyboard()
.webApp('📱 Open Session', url)
const body = truncate(previewText, 600)
for (const chatId of configuration.allowedChatIds) {
for (const chatId of this.allowedChatIds) {
await this.bot.api.sendMessage(
chatId,
`💬 ${name}\n\n${body}`,
@@ -470,7 +511,7 @@ export class HappyBot {
const keyboard = createNotificationKeyboard(session)
// Send to all allowed chat IDs
for (const chatId of configuration.allowedChatIds) {
for (const chatId of this.allowedChatIds) {
try {
await this.bot.api.sendMessage(chatId, text, {
reply_markup: keyboard
+2 -2
View File
@@ -4,7 +4,7 @@ import { jwtVerify } from 'jose'
export type WebAppEnv = {
Variables: {
telegramUserId: number
userId: number
}
}
@@ -36,7 +36,7 @@ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler<W
return c.json({ error: 'Invalid token payload' }, 401)
}
c.set('telegramUserId', parsed.data.uid)
c.set('userId', parsed.data.uid)
await next()
return
} catch {
+53
View File
@@ -0,0 +1,53 @@
import { existsSync } from 'node:fs'
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import { dirname, join } from 'node:path'
import { z } from 'zod'
import { configuration } from '../configuration'
const ownerIdFileSchema = z.object({
ownerId: z.number()
})
function generateOwnerId(): number {
const bytes = randomBytes(6)
let value = 0
for (const byte of bytes) {
value = (value << 8) + byte
}
return value > 0 ? value : 1
}
let cachedOwnerId: number | null = null
export async function getOrCreateOwnerId(): Promise<number> {
if (cachedOwnerId !== null) {
return cachedOwnerId
}
const ownerIdFile = join(configuration.dataDir, 'owner-id.json')
if (existsSync(ownerIdFile)) {
await chmod(ownerIdFile, 0o600).catch(() => {})
const raw = await readFile(ownerIdFile, 'utf8')
const parsed = ownerIdFileSchema.parse(JSON.parse(raw))
if (!Number.isSafeInteger(parsed.ownerId) || parsed.ownerId <= 0) {
throw new Error(`Invalid ownerId in ${ownerIdFile}`)
}
cachedOwnerId = parsed.ownerId
return parsed.ownerId
}
const ownerId = generateOwnerId()
const dir = dirname(ownerIdFile)
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true, mode: 0o700 })
}
const payload = { ownerId }
await writeFile(ownerIdFile, JSON.stringify(payload, null, 4), { mode: 0o600 })
await chmod(ownerIdFile, 0o600).catch(() => {})
cachedOwnerId = ownerId
return ownerId
}
+13 -5
View File
@@ -3,6 +3,7 @@ import { SignJWT } from 'jose'
import { z } from 'zod'
import { configuration } from '../../configuration'
import { validateTelegramInitData } from '../telegramInitData'
import { getOrCreateOwnerId } from '../ownerId'
import type { WebAppEnv } from '../middleware/auth'
const telegramAuthSchema = z.object({
@@ -35,21 +36,29 @@ export function createAuthRoutes(jwtSecret: Uint8Array): Hono<WebAppEnv> {
if (parsed.data.accessToken !== configuration.cliApiToken) {
return c.json({ error: 'Invalid access token' }, 401)
}
// Use first allowed chat ID as the shared user identity
userId = configuration.allowedChatIds[0]
userId = await getOrCreateOwnerId()
firstName = 'Web User'
} else {
if (!configuration.telegramEnabled || !configuration.telegramBotToken) {
return c.json({ error: 'Telegram authentication is disabled. Configure TELEGRAM_BOT_TOKEN.' }, 503)
}
if (configuration.allowedChatIds.length === 0) {
return c.json({ error: 'Telegram allowlist is empty. Configure ALLOWED_CHAT_IDS and restart.' }, 403)
}
// Telegram initData authentication
const result = validateTelegramInitData(parsed.data.initData, configuration.telegramBotToken)
if (!result.ok) {
return c.json({ error: result.error }, 401)
}
userId = result.user.id
if (!configuration.isChatIdAllowed(userId)) {
const telegramUserId = result.user.id
if (!configuration.isChatIdAllowed(telegramUserId)) {
return c.json({ error: 'User not allowed' }, 403)
}
userId = await getOrCreateOwnerId()
username = result.user.username
firstName = result.user.first_name
lastName = result.user.last_name
@@ -74,4 +83,3 @@ export function createAuthRoutes(jwtSecret: Uint8Array): Hono<WebAppEnv> {
return app
}