/** * Configuration: loads .env, validates required values, resolves paths. * * The bot drives the official Grok Build CLI over ACP (`grok agent stdio`) and * authenticates with your xAI account sign-in (`grok login`, `~/.grok/auth.json`), * or an optional `XAI_API_KEY` on headless hosts. */ import { config as loadDotenv } from "dotenv"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { CANONICAL_DIR, expandHome, resolveInstanceDir as resolveNamedInstanceDir, } from "./app/instance.js"; export { CANONICAL_DIR, expandHome }; /** Absolute path to the installed bot code (one level above src/). For a global * npm install this lives inside node_modules — code lives here, never user data. */ export const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); /** * Directory holding THIS instance's `.env`, `logs/` and `data/`. Resolution * (first match wins): * 1. `--instance ` argv — set by the installed background service, * 2. `--name` / `GROK_TG_NAME` — named instance under `~/.grok/tg/instances/`, * 3. `GROK_TG_DIR` env — an explicit override, * 4. `GROK_TG_CWD` or cwd IF that folder already contains a `.env`, * 5. the canonical `~/.grok/tg` home — the path-independent default. */ export const INSTANCE_DIR = resolveNamedInstanceDir({ argv: process.argv, envDir: process.env.GROK_TG_DIR, nameEnv: process.env.GROK_TG_NAME, cwdHint: process.env.GROK_TG_CWD, }); /** Absolute path to the `.env` this instance loads (and that `setup` writes). */ export const ENV_PATH = join(INSTANCE_DIR, ".env"); // Load .env from the resolved instance directory. Keep the parsed values as // well: a machine-wide TELEGRAM_BOT_TOKEN may belong to a sibling bot (Codex, // Kiro, etc.) and must never override this Grok instance's identity. const instanceEnv = loadDotenv({ path: ENV_PATH }).parsed ?? {}; function bool(v: string | undefined, def: boolean): boolean { if (v === undefined || v === "") return def; return ["1", "true", "yes", "on"].includes(v.toLowerCase()); } function num(v: string | undefined, def: number): number { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : def; } /** Like num() but allows 0 (e.g. to disable retries). Rejects negatives. */ function nonNegNum(v: string | undefined, def: number): number { if (v === undefined || v === "") return def; const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : def; } /** Comma-separated list (spaces around commas ok). Used for ALLOWED_USERS, PROJECT_ROOTS, etc. */ function list(v: string | undefined): string[] { return (v || "") .split(",") .map((s) => s.trim().replace(/^["']|["']$/g, "")) .filter(Boolean); } /** * Parse ALLOWED_USERS. Blank/unset → allow everyone. Non-blank → only numeric * Telegram user ids (invalid tokens dropped). If every token is invalid, deny * everyone (fail closed) rather than treating as open. */ export function parseAllowedUsers(raw: string | undefined): { ids: Set; allowAll: boolean; dropped: string[]; } { if (raw === undefined || raw.trim() === "") { return { ids: new Set(), allowAll: true, dropped: [] }; } const tokens = list(raw); const dropped: string[] = []; const ids = new Set(); for (const t of tokens) { // Telegram user ids are positive integers (string form). if (/^\d+$/.test(t)) ids.add(t); else dropped.push(t); } return { ids, allowAll: false, dropped }; } export interface AppConfig { token: string; /** * Telegram user ids allowed to use the bot (private + groups). Populated from * comma-separated ALLOWED_USERS. See {@link allowAllUsers}. */ allowedUsers: Set; /** * True only when ALLOWED_USERS was blank/unset. When false, only ids in * {@link allowedUsers} may use the bot (even if the set is empty after * filtering invalid tokens — fail closed). */ allowAllUsers: boolean; grokCliPath: string; workspace: string; /** Optional xAI API key for headless hosts. When set, exported to the agent * as XAI_API_KEY. Otherwise the agent uses the `grok login` token in * ~/.grok/auth.json. */ grokApiKey?: string; /** Optional Grok API base URL (default https://api.x.ai/v1). */ grokBaseUrl?: string; /** Default model for new sessions (e.g. grok-4-1-fast). */ grokModel: string; /** Optional max output tokens (exported as GROK_MAX_TOKENS). */ grokMaxTokens?: number; /** Cap on tool-execution rounds per headless turn (grok --max-tool-rounds). */ maxToolRounds: number; /** Custom sub-agent name to hint in prompts (informational; Grok has no * --agent flag headlessly). */ agent?: string; trustAllTools: boolean; /** * Auto-approve ACP `session/request_permission` prompts (prefer "allow for * this session"). Defaults true — Telegram bots shouldn't block on tool * approvals. Set false (and GROK_TRUST_ALL_TOOLS=false) for interactive * Approve/Deny buttons. */ autoApprovePermissions: boolean; /** * Auto-approve Grok plan-mode exit (no Approve/Changes/Abandon buttons). * Default true so unattended/24/7 bots never wait on a TUI. Set false for * interactive review in Telegram. */ autoApprovePlan: boolean; /** GROK_SANDBOX profile (workspace-safe, strict, off, …). */ sandboxProfile?: string; /** GROK_MEMORY setting forwarded to the agent process. */ grokMemory?: string; /** `--agent-profile` for `grok agent`. */ agentProfile?: string; /** `--plugin-dir` for `grok agent`. */ pluginDir?: string; projectRoots: string[]; streamThrottleMs: number; messageBatchMs: number; showToolCalls: boolean; showEditDiffs: boolean; diffMaxLines: number; sendAgentImages: boolean; agentImagesMax: number; docMaxChars: number; logLevel: string; sessionsDir: string; projectRoot: string; logsDir: string; logFile: string; /** Emit a `restarted` event / clear running turns when asked (kept for the * self-healing + reauth flows; there is no persistent daemon with Grok). */ grokAutoRestart: boolean; dataDir: string; promptIdleMs: number; quietNotifications: boolean; promptRetryAttempts: number; autoForkOnError: boolean; autoForkContextPct: number; resumeOnStreamError: boolean; sttApiUrl?: string; sttApiKey?: string; sttModel: string; sttLanguage?: string; mcpProbeTimeoutMs: number; mcpProbeConcurrency: number; showSubagents: boolean; showProgress: boolean; progressFallback: boolean; notifyOtherSessions: boolean; autoUpdate: boolean; updateCheckMs: number; singleInstance: boolean; /** * After a successful Done, ask Grok for 1–3 follow-up suggestions (JSON) and * attach them as inline buttons on the Done message. Default true. */ suggestionsEnabled: boolean; /** * Auto-queue any suggestion whose need score is ≥ this percent (0–100). * 0 disables auto-approve (buttons only). Default 95. * Multiple hits are merged into one numbered multi-step prompt. */ suggestionsAutoApprovePct: number; /** * After a successful user turn (queue empty), optionally run one self-recheck * pass before Done + suggestions. Skipped when no files changed or when a * quiet AI decision refuses. Default true. Typo alias: SLEF_RECHECK. */ selfRecheckEnabled: boolean; /** * Optional override for the recheck turn body when the AI decides recheck is * needed (SELF_RECHECK_PROMPT). Supports {{USER}} and {{DONE}}. Empty → use * the AI-written recheck prompt (or built-in default if the AI left it blank). */ selfRecheckPrompt: string; /** * Max wait for quiet meta ACP prompts (self-recheck decision, suggestions). * On timeout the session prompt is cancelled so Done is not blocked forever. * QUIET_PROMPT_TIMEOUT_MS, default 90s. */ quietPromptTimeoutMs: number; /** * Telegram forum supergroup id for project topics (TOPIC_GROUP_ID). When set * and the bot is admin, the bot manages topics: AI Chat + optional one topic * per catalog project. Empty/undefined disables forum management. */ topicGroupId?: number; /** * Auto-create a forum topic for each catalog project (TOPIC_AUTO_CREATE). * Default true when TOPIC_GROUP_ID is set. */ topicAutoCreateProjects: boolean; /** Display name for the default AI chat topic (TOPIC_AI_CHAT_NAME). */ topicAiChatName: string; /** * Sibling Telegram bot usernames the agent may call via telegram bridge * `bot_command` / `list_bots` (ALLOWED_TELEGRAM_BOTS, comma-separated, with * or without @). Empty = feature off. */ allowedTelegramBots: string[]; /** * Optional command catalogs per bot (TELEGRAM_BOT_COMMANDS). Keys are * usernames (no @); values are command names without slash + optional * description. Shown by list_bots / first-prompt directive. */ telegramBotCommands: Record>; /** Hard timeout waiting for a sibling bot's reply (TELEGRAM_BOT_REPLY_TIMEOUT_MS). */ telegramBotReplyTimeoutMs: number; /** * After the last message/edit from the triggered bot, wait this many ms of * silence before treating the reply as finished (TELEGRAM_BOT_SETTLE_MS). * Streaming bots that edit one message need this "typing done" equivalent. */ telegramBotSettleMs: number; } export function loadConfig(): AppConfig { // Telegram long polling permits one consumer per token. Prefer the token in // this bot's own instance file over a globally inherited environment value, // otherwise a Grok process can accidentally poll as a sibling bot. const token = (instanceEnv.TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN || "").trim(); if (!token) { throw new Error( "TELEGRAM_BOT_TOKEN is missing. Copy .env.example to .env and set it (run `npm run setup`).", ); } const workspaceRaw = process.env.GROK_WORKSPACE?.trim() || process.cwd(); const workspace = resolve(expandHome(workspaceRaw)); // Default project roots: the workspace parent + home directory. const roots = list(process.env.PROJECT_ROOTS).map((p) => resolve(expandHome(p))); if (roots.length === 0) { roots.push(dirname(workspace), homedir()); } const dataDir = process.env.DATA_DIR?.trim() ? resolve(expandHome(process.env.DATA_DIR.trim())) : join(INSTANCE_DIR, "data"); // The bot owns its sessions on disk (Grok itself keeps them in SQLite): one // `.json` + `.jsonl` + `.lock` per session, mirroring the layout // the session store / history parser / tail watcher already understand. const sessionsDir = process.env.SESSIONS_DIR?.trim() ? resolve(expandHome(process.env.SESSIONS_DIR.trim())) : join(dataDir, "sessions"); const logsDir = process.env.LOG_DIR?.trim() ? resolve(expandHome(process.env.LOG_DIR.trim())) : join(INSTANCE_DIR, "logs"); const logFile = process.env.LOG_FILE?.trim() ? resolve(expandHome(process.env.LOG_FILE.trim())) : join(logsDir, "grok-telegram-bot.log"); const allowedParsed = parseAllowedUsers(process.env.ALLOWED_USERS); if (allowedParsed.dropped.length > 0) { // Avoid importing logger at top (config loads early); stderr is fine at boot. console.warn( `[config] ALLOWED_USERS ignored non-numeric token(s): ${allowedParsed.dropped.join(", ")}`, ); } const cfg: AppConfig = { token, allowedUsers: allowedParsed.ids, allowAllUsers: allowedParsed.allowAll, grokCliPath: resolveGrokPath(process.env.GROK_CLI_PATH?.trim()), workspace, grokApiKey: process.env.XAI_API_KEY?.trim() || process.env.GROK_API_KEY?.trim() || undefined, grokBaseUrl: process.env.GROK_BASE_URL?.trim() || undefined, grokModel: process.env.GROK_MODEL?.trim() || "grok-4.5", grokMaxTokens: process.env.GROK_MAX_TOKENS ? num(process.env.GROK_MAX_TOKENS, 0) || undefined : undefined, maxToolRounds: num(process.env.GROK_MAX_TOOL_ROUNDS, 400), agent: process.env.GROK_AGENT?.trim() || undefined, trustAllTools: bool(process.env.GROK_TRUST_ALL_TOOLS, true), // Default true: auto-approve with session-scope when the agent still asks. autoApprovePermissions: bool(process.env.AUTO_APPROVE_PERMISSIONS, true), autoApprovePlan: bool(process.env.AUTO_APPROVE_PLAN, true), sandboxProfile: process.env.GROK_SANDBOX?.trim() || undefined, grokMemory: process.env.GROK_MEMORY?.trim() || undefined, agentProfile: process.env.GROK_AGENT_PROFILE?.trim() || undefined, pluginDir: process.env.GROK_PLUGIN_DIR?.trim() || undefined, projectRoots: [...new Set(roots)], streamThrottleMs: num(process.env.STREAM_THROTTLE_MS, 1500), messageBatchMs: nonNegNum(process.env.MESSAGE_BATCH_MS, 800), showToolCalls: bool(process.env.SHOW_TOOL_CALLS, true), showEditDiffs: bool(process.env.SHOW_EDIT_DIFFS, true), diffMaxLines: num(process.env.DIFF_MAX_LINES, 120), sendAgentImages: bool(process.env.SEND_AGENT_IMAGES, true), agentImagesMax: num(process.env.AGENT_IMAGES_MAX, 8), docMaxChars: nonNegNum(process.env.DOC_MAX_CHARS, 100_000), logLevel: process.env.LOG_LEVEL?.trim() || "info", sessionsDir, projectRoot: PROJECT_ROOT, logsDir, logFile, grokAutoRestart: bool(process.env.GROK_AUTO_RESTART, true), promptIdleMs: num(process.env.PROMPT_IDLE_TIMEOUT_MS, 900_000), quietNotifications: bool(process.env.QUIET_NOTIFICATIONS, true), promptRetryAttempts: nonNegNum(process.env.PROMPT_RETRY_ATTEMPTS, 5), autoForkOnError: bool(process.env.AUTO_FORK_ON_ERROR, true), autoForkContextPct: nonNegNum(process.env.AUTO_FORK_CONTEXT_PCT, 85), resumeOnStreamError: bool(process.env.RESUME_ON_STREAM_ERROR, true), dataDir, sttApiUrl: process.env.STT_API_URL?.trim() || undefined, sttApiKey: process.env.STT_API_KEY?.trim() || undefined, sttModel: process.env.STT_MODEL?.trim() || "whisper-1", sttLanguage: process.env.STT_LANGUAGE?.trim() || undefined, mcpProbeTimeoutMs: num(process.env.MCP_PROBE_TIMEOUT_MS, 8000), mcpProbeConcurrency: num(process.env.MCP_PROBE_CONCURRENCY, 6), showSubagents: bool(process.env.SHOW_SUBAGENTS, true), showProgress: bool(process.env.SHOW_PROGRESS, true), progressFallback: bool(process.env.PROGRESS_FALLBACK, true), notifyOtherSessions: bool(process.env.NOTIFY_OTHER_SESSIONS, true), autoUpdate: bool(process.env.AUTO_UPDATE, true), updateCheckMs: num(process.env.UPDATE_CHECK_MS, 3_600_000), singleInstance: bool(process.env.GROK_TG_SINGLE_INSTANCE, true), // Post-turn follow-ups: default on; auto-run suggestions scoring ≥ 95%. suggestionsEnabled: bool(process.env.SUGGESTIONS_ENABLED, true), suggestionsAutoApprovePct: clampPct(process.env.SUGGESTIONS_AUTO_APPROVE_PCT, 95), // One-shot post-turn self-recheck before Done/suggestions (default on). // Accept typo SLEF_RECHECK as alias. selfRecheckEnabled: bool( process.env.SELF_RECHECK ?? process.env.SLEF_RECHECK, true, ), selfRecheckPrompt: (process.env.SELF_RECHECK_PROMPT ?? "").trim(), quietPromptTimeoutMs: num(process.env.QUIET_PROMPT_TIMEOUT_MS, 90_000), topicGroupId: parseTopicGroupId( process.env.TOPIC_GROUP_ID ?? process.env.FORUM_GROUP_ID, ), topicAutoCreateProjects: bool(process.env.TOPIC_AUTO_CREATE, true), topicAiChatName: (process.env.TOPIC_AI_CHAT_NAME ?? "AI Chat").trim() || "AI Chat", allowedTelegramBots: parseTelegramBotUsernames(process.env.ALLOWED_TELEGRAM_BOTS), telegramBotCommands: parseTelegramBotCommands(process.env.TELEGRAM_BOT_COMMANDS), telegramBotReplyTimeoutMs: num(process.env.TELEGRAM_BOT_REPLY_TIMEOUT_MS, 45_000), telegramBotSettleMs: num(process.env.TELEGRAM_BOT_SETTLE_MS, 2_000), }; return cfg; } /** Normalize comma-separated bot usernames (strip @, lowercase, unique). */ export function parseTelegramBotUsernames(raw: string | undefined): string[] { const seen = new Set(); const out: string[] = []; for (const t of list(raw)) { const u = t.replace(/^@/, "").toLowerCase(); if (!u || seen.has(u)) continue; // Telegram usernames: 5–32 characters (letter first, then alnum/underscore). if (!/^[a-z][a-z0-9_]{4,31}$/i.test(u)) continue; seen.add(u); out.push(u); } return out; } /** * Parse optional command catalogs. * * Formats (both supported): * 1) Compact: `helperbot:status,help,ping;otherbot:start|Start the bot,info` * - `;` separates bots, `:` separates username from commands * - `,` separates commands; optional `cmd|description` * 2) JSON object: `{"helperbot":["status","help"],"otherbot":[{"command":"start","description":"…"}]}` */ export function parseTelegramBotCommands( raw: string | undefined, ): Record> { const out: Record> = {}; if (raw === undefined || raw.trim() === "") return out; const trimmed = raw.trim(); if (trimmed.startsWith("{")) { try { const parsed = JSON.parse(trimmed) as unknown; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { for (const [key, val] of Object.entries(parsed as Record)) { const u = key.replace(/^@/, "").toLowerCase(); if (!u) continue; const cmds = normalizeCommandList(val); if (cmds.length) out[u] = cmds; } } } catch { /* fall through to compact parser */ } if (Object.keys(out).length > 0) return out; } for (const botPart of trimmed.split(";")) { const piece = botPart.trim(); if (!piece) continue; const colon = piece.indexOf(":"); if (colon <= 0) continue; const u = piece.slice(0, colon).replace(/^@/, "").toLowerCase().trim(); if (!u) continue; const rest = piece.slice(colon + 1).trim(); if (!rest) continue; const cmds = normalizeCommandList(rest.split(",").map((s) => s.trim()).filter(Boolean)); if (cmds.length) out[u] = cmds; } return out; } function normalizeCommandList( val: unknown, ): Array<{ command: string; description?: string }> { const items: unknown[] = Array.isArray(val) ? val : typeof val === "string" ? val.split(",").map((s) => s.trim()).filter(Boolean) : []; const seen = new Set(); const out: Array<{ command: string; description?: string }> = []; for (const item of items) { let command = ""; let description: string | undefined; if (typeof item === "string") { const pipe = item.indexOf("|"); if (pipe >= 0) { command = item.slice(0, pipe).trim(); description = item.slice(pipe + 1).trim() || undefined; } else { command = item.trim(); } } else if (item && typeof item === "object") { const rec = item as Record; command = String(rec.command ?? rec.cmd ?? rec.name ?? "").trim(); const d = String(rec.description ?? rec.desc ?? rec.help ?? "").trim(); if (d) description = d; } command = command.replace(/^\//, "").toLowerCase(); if (!command || !/^[a-z0-9_]{1,32}$/.test(command) || seen.has(command)) continue; seen.add(command); out.push(description ? { command, description: description.slice(0, 120) } : { command }); if (out.length >= 40) break; } return out; } /** Parse a Telegram chat/group id (may be negative for supergroups). */ function parseTopicGroupId(v: string | undefined): number | undefined { if (v === undefined || v.trim() === "") return undefined; const n = Number(v.trim()); if (!Number.isFinite(n) || n === 0) return undefined; return Math.trunc(n); } /** Parse 0–100 percentage; blank → default. */ function clampPct(v: string | undefined, def: number): number { if (v === undefined || v === "") return def; const n = Number(v); if (!Number.isFinite(n)) return def; return Math.max(0, Math.min(100, Math.round(n))); } /** Resolve the `grok` binary path. The official installer puts it in * ~/.grok/bin; also try common PATH locations before a bare `grok`. */ function resolveGrokPath(explicit?: string): string { if (explicit) return expandHome(explicit); const home = homedir(); const exe = process.platform === "win32" ? "grok.exe" : "grok"; const candidates = [ join(home, ".grok", "bin", exe), join(home, ".local", "bin", "grok"), "/usr/local/bin/grok", "/opt/homebrew/bin/grok", ]; for (const c of candidates) { if (existsSync(c)) return c; } // Fall back to PATH lookup. return "grok"; } export function isAbsolutePath(p: string): boolean { return isAbsolute(p); }