/** * Telegram bridge config and pairing helpers * Owns persisted bot/session pairing state, local config storage, authorization policy, and first-user pairing side effects */ import { existsSync } from "node:fs"; import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import type { TelegramAttachmentHandlerConfig } from "./attachment-handlers.ts"; import type { CommandTemplateObjectConfig } from "./command-templates.ts"; function getAgentDir(): string { return process.env.PI_CODING_AGENT_DIR ? resolve(process.env.PI_CODING_AGENT_DIR) : join(homedir(), ".pi", "agent"); } function getConfigPath(): string { return join(getAgentDir(), "telegram.json"); } export type TelegramOutboundCommandTemplateConfig = | string | CommandTemplateObjectConfig; export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig { type?: string; match?: string | string[]; pipe?: TelegramOutboundCommandTemplateConfig[]; output?: string; timeout?: number; } export interface TelegramTopicBinding { chatId: number; threadId?: number; title?: string; updatedAt: number; } export interface TelegramPendingTopicBind extends TelegramTopicBinding { userId: number; username?: string; expiresAt: number; } export interface TelegramConfig { botToken?: string; botUsername?: string; botId?: number; allowedUserId?: number; lastUpdateId?: number; currentTopicBinding?: TelegramTopicBinding; pendingTopicBind?: TelegramPendingTopicBind; attachmentHandlers?: TelegramAttachmentHandlerConfig[]; outboundHandlers?: TelegramOutboundHandlerConfig[]; } export interface TelegramConfigStore { get: () => TelegramConfig; set: (config: TelegramConfig) => void; update: (mutate: (config: TelegramConfig) => void) => void; getBotToken: () => string | undefined; hasBotToken: () => boolean; getAllowedUserId: () => number | undefined; getAttachmentHandlers: () => TelegramAttachmentHandlerConfig[] | undefined; getOutboundHandlers: () => TelegramOutboundHandlerConfig[] | undefined; setAllowedUserId: (userId: number) => void; load: () => Promise; persist: (config?: TelegramConfig) => Promise; } export interface TelegramConfigStoreOptions { initialConfig?: TelegramConfig; agentDir?: string; configPath?: string; } export function normalizeTelegramConfig(config: TelegramConfig): TelegramConfig { if (config.pendingTopicBind && config.pendingTopicBind.expiresAt < Date.now()) { delete config.pendingTopicBind; } return config; } export function getCurrentTopicBinding(config: TelegramConfig): TelegramTopicBinding | undefined { return config.currentTopicBinding; } export async function readTelegramConfig( configPath: string, ): Promise { if (!existsSync(configPath)) return {}; const content = await readFile(configPath, "utf8"); return normalizeTelegramConfig(JSON.parse(content) as TelegramConfig); } export async function writeTelegramConfig( agentDir: string, configPath: string, config: TelegramConfig, ): Promise { await mkdir(agentDir, { recursive: true }); await writeFile(configPath, JSON.stringify(config, null, "\t") + "\n", { encoding: "utf8", mode: 0o600, }); await chmod(configPath, 0o600); } export function createTelegramConfigStore( options: TelegramConfigStoreOptions = {}, ): TelegramConfigStore { let config: TelegramConfig = normalizeTelegramConfig(options.initialConfig ?? {}); const agentDir = options.agentDir ?? getAgentDir(); const configPath = options.configPath ?? getConfigPath(); return { get: () => config, set: (nextConfig) => { config = normalizeTelegramConfig(nextConfig); }, update: (mutate) => { mutate(config); normalizeTelegramConfig(config); }, getBotToken: () => config.botToken, hasBotToken: () => !!config.botToken, getAllowedUserId: () => config.allowedUserId, getAttachmentHandlers: () => config.attachmentHandlers, getOutboundHandlers: () => config.outboundHandlers, setAllowedUserId: (userId) => { config.allowedUserId = userId; }, load: async () => { config = await readTelegramConfig(configPath); }, persist: async (nextConfig = config) => { await writeTelegramConfig(agentDir, configPath, nextConfig); }, }; } export type TelegramAuthorizationState = | { kind: "pair"; userId: number } | { kind: "allow" } | { kind: "deny" }; export interface TelegramUserPairingDeps { allowedUserId?: number; ctx: TContext; setAllowedUserId: (userId: number) => void; persistConfig: () => Promise; updateStatus: (ctx: TContext) => void; } export interface TelegramUserPairingRuntimeDeps { getAllowedUserId: () => number | undefined; setAllowedUserId: (userId: number) => void; persistConfig: () => Promise; updateStatus: (ctx: TContext) => void; } export interface TelegramUserPairingRuntime { pairIfNeeded: (userId: number, ctx: TContext) => Promise; } export function getTelegramAuthorizationState( userId: number, allowedUserId?: number, ): TelegramAuthorizationState { if (allowedUserId === undefined) { return { kind: "pair", userId }; } if (userId === allowedUserId) { return { kind: "allow" }; } return { kind: "deny" }; } export async function pairTelegramUserIfNeeded( userId: number, deps: TelegramUserPairingDeps, ): Promise { const authorization = getTelegramAuthorizationState( userId, deps.allowedUserId, ); if (authorization.kind !== "pair") return false; deps.setAllowedUserId(authorization.userId); await deps.persistConfig(); deps.updateStatus(deps.ctx); return true; } export function createTelegramUserPairingRuntime( deps: TelegramUserPairingRuntimeDeps, ): TelegramUserPairingRuntime { return { pairIfNeeded: (userId, ctx) => pairTelegramUserIfNeeded(userId, { allowedUserId: deps.getAllowedUserId(), ctx, setAllowedUserId: deps.setAllowedUserId, persistConfig: deps.persistConfig, updateStatus: deps.updateStatus, }), }; }