From 6505c58bf3bcfb287ac3a1b8f8edda40cc3b3196 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 22 Dec 2025 08:51:58 +0800 Subject: [PATCH] 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 --- README.md | 8 +++- server/README.md | 12 +++-- server/src/configuration.ts | 34 +++++++------- server/src/index.ts | 44 ++++++++++++------- server/src/telegram/bot.ts | 73 ++++++++++++++++++++++++------- server/src/web/middleware/auth.ts | 4 +- server/src/web/ownerId.ts | 53 ++++++++++++++++++++++ server/src/web/routes/auth.ts | 18 +++++--- telegram-optional-config.md | 57 ++++++++++++++++++++++++ 9 files changed, 242 insertions(+), 61 deletions(-) create mode 100644 server/src/web/ownerId.ts create mode 100644 telegram-optional-config.md diff --git a/README.md b/README.md index 9e9209af..6d68fa0b 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,18 @@ Run Claude Code / Codex / Coding Agent sessions locally and control them remotel 2. Start the server on a machine you control: ```bash -export TELEGRAM_BOT_TOKEN="..." -export ALLOWED_CHAT_IDS="12345678" export CLI_API_TOKEN="shared-secret" export WEBAPP_URL="https://your-domain.example" # required for Telegram Mini App +export TELEGRAM_BOT_TOKEN="..." +export ALLOWED_CHAT_IDS="12345678" hapi server ``` +If you only want the web app + CLI, you can skip TELEGRAM_BOT_TOKEN and ALLOWED_CHAT_IDS. +To enable Telegram later, set TELEGRAM_BOT_TOKEN and WEBAPP_URL, start the server, send `/start` +to the bot to get your chat ID, set ALLOWED_CHAT_IDS, and restart the server. + 3. If the server has no public IP, expose it over HTTPS: - Cloudflare Tunnel docs: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ - Tailscale docs: https://tailscale.com/kb/ diff --git a/server/README.md b/server/README.md index 1cb15b9b..83be3d7b 100644 --- a/server/README.md +++ b/server/README.md @@ -18,13 +18,15 @@ Telegram bot + HTTP API + realtime updates for hapi. ## Configuration Required: +- `CLI_API_TOKEN` - shared secret used by CLI and web login. + +Optional (Telegram): - `TELEGRAM_BOT_TOKEN` - token from @BotFather. - `ALLOWED_CHAT_IDS` - comma-separated chat IDs allowed to use the bot. -- `CLI_API_TOKEN` - shared secret used by CLI and web login. +- `WEBAPP_URL` - public HTTPS URL for Telegram Mini App access. Optional: - `WEBAPP_PORT` - HTTP port (default: 3006). -- `WEBAPP_URL` - public URL for Telegram Mini App button. - `CORS_ORIGINS` - comma-separated origins, or `*`. - `HAPI_HOME` - data directory (default: ~/.hapi). - `DB_PATH` - SQLite database path. @@ -40,6 +42,10 @@ export WEBAPP_URL="https://your-domain.example" hapi server ``` +If you only need web + CLI, you can omit TELEGRAM_BOT_TOKEN and ALLOWED_CHAT_IDS. +To enable Telegram, set TELEGRAM_BOT_TOKEN and WEBAPP_URL, start the server, send `/start` +to the bot to get your chat ID, set ALLOWED_CHAT_IDS, and restart the server. + From source: ```bash bun install @@ -69,7 +75,7 @@ The server is the hub for direct-connect mode. It accepts CLI connections over S ## Security model Access is controlled by: -- Telegram chat ID allowlist. +- Telegram chat ID allowlist (when Telegram is enabled). - `CLI_API_TOKEN` shared secret for CLI and browser access. Transport security depends on HTTPS in front of the server. diff --git a/server/src/configuration.ts b/server/src/configuration.ts index cad700e2..b953a88a 100644 --- a/server/src/configuration.ts +++ b/server/src/configuration.ts @@ -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 diff --git a/server/src/index.ts b/server/src/index.ts index 4f22dda8..afc7bb7e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 | 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 () => { diff --git a/server/src/telegram/bot.ts b/server/src/telegram/bot.ts index 90ef5be2..c9ee7e21 100644 --- a/server/src/telegram/bot.ts +++ b/server/src/telegram/bot.ts @@ -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 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> = 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(configuration.telegramBotToken) + this.bot = new Bot(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 diff --git a/server/src/web/middleware/auth.ts b/server/src/web/middleware/auth.ts index 39f34a4a..f99dfe36 100644 --- a/server/src/web/middleware/auth.ts +++ b/server/src/web/middleware/auth.ts @@ -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 0 ? value : 1 +} + +let cachedOwnerId: number | null = null + +export async function getOrCreateOwnerId(): Promise { + 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 +} diff --git a/server/src/web/routes/auth.ts b/server/src/web/routes/auth.ts index 6ff85184..c5700cf4 100644 --- a/server/src/web/routes/auth.ts +++ b/server/src/web/routes/auth.ts @@ -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 { 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 { return app } - diff --git a/telegram-optional-config.md b/telegram-optional-config.md new file mode 100644 index 00000000..c048a476 --- /dev/null +++ b/telegram-optional-config.md @@ -0,0 +1,57 @@ +--- +name: telegram-optional-config +description: Make Telegram optional; unify owner id auth +--- + +# Plan + +基于你的决策更新计划:绑定流程为“提示 chat id + 重启”,允许列表仅来自 env;统一 `uid` 为 owner id,确保 Web/Telegram 登录语义一致。 + +## Requirements +- 未配置 Telegram 相关 env 时,服务端不退出,Web/CLI 正常可用。 +- Telegram bot 仅在配置 `TELEGRAM_BOT_TOKEN` 时启动;`ALLOWED_CHAT_IDS` 仅来自 env。 +- 绑定流程为:bot 提示 chat id → 用户配置 env → 重启服务。 +- `uid` 统一为 owner id(accessToken 与 telegram 登录一致)。 + +## Scope +- In: 配置解析、启动流程、auth 逻辑、owner id 持久化、文档更新。 +- Out: 自动绑定、DB/动态 allowlist、复杂 UI 设置页。 + +## Files and entry points +- `server/src/configuration.ts` +- `server/src/index.ts` +- `server/src/telegram/bot.ts` +- `server/src/web/routes/auth.ts` +- `server/src/web/jwtSecret.ts`(或新增轻量 owner id 持久化文件) +- `README.md` +- `server/README.md` + +## Data model / API changes +- 增加“owner id”持久化(建议 `dataDir/owner-id.json`),用于统一 auth 的 `uid`。 +- 不新增 Telegram 绑定 API(按“提示 chat id + 重启”流程)。 + +## Action items +[ ] 配置层改为 Telegram 可选:`TELEGRAM_BOT_TOKEN`/`ALLOWED_CHAT_IDS` 允许为空;加入 `telegramEnabled`,`allowedChatIds` 为空数组可接受。 +[ ] 生成并持久化 `ownerId`(数值或 UUID -> 数值映射),`/api/auth` 的 `uid` 始终为 `ownerId`。 +[ ] 启动逻辑按 `telegramEnabled` 分支:未启用仅启动 Web/Socket/SSE,并打印 Telegram disabled 日志。 +[ ] Telegram bot 行为: + - 已启用但 `ALLOWED_CHAT_IDS` 未配置:仅响应 `/start`,提示当前 chat id 与配置示例;不开放其它命令/通知。 + - 已启用且 allowlist 配置:按现有流程运行。 +[ ] `/api/auth` 调整: + - accessToken:验证后直接使用 `ownerId`。 + - telegram:若 Telegram 未启用,返回清晰错误;启用时校验 initData 与 allowlist,但仍签发 `uid = ownerId`。 +[ ] 文档更新:说明 Telegram 配置可选;新增“获取 chat id 并重启绑定”的指引。 + +## Testing and validation +- 仅设置 `CLI_API_TOKEN` 启动:服务正常、Web 登录可用、bot 不启动。 +- 设置 `TELEGRAM_BOT_TOKEN` 且未配 allowlist:`/start` 能提示 chat id,其它命令受限。 +- 完整配置 `TELEGRAM_BOT_TOKEN` + `ALLOWED_CHAT_IDS`:通知与 Mini App 正常。 +- `uid` 在两种登录方式下均为 `ownerId`。 + +## Risks and edge cases +- `ownerId` 生成/持久化失败会导致登录不稳定。 +- 只靠 env allowlist,运维更新需重启,需在文档强调。 +- Telegram 未启用但 Web 侧仍可能尝试 Telegram auth(应给清晰错误)。 + +## Open questions +- None.