diff --git a/.env.example b/.env.example index dccb58b3..9020c6a1 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,18 @@ OPENCODE_MODEL_ID=big-pickle # raw = show assistant replies as plain text # MESSAGE_FORMAT_MODE=markdown +# Initial runtime settings preset (optional) +# A JSON object that seeds the bot's default /settings values on first run. +# Keys not yet persisted in settings.json are initialised to these values; +# any setting the user has already changed via /settings is left untouched. +# Supported keys: ttsMode ("off"|"all"|"auto"), compactOutputMode (bool), +# showThinkingContent (bool), showAssistantRunFooter (bool), +# responseStreamingMode ("edit"|"draft"), sendDiffFileAttachments (bool) +# Unknown keys and type mismatches are warned and ignored; invalid JSON falls +# back to built-in defaults without crashing the bot. +# Example — hide the run footer and enable compact mode by default: +# INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true} + # Directory Browser Roots (optional) # Comma-separated list of paths that /open is allowed to browse. # Supports ~ for home directory. Defaults to ~ when not set. diff --git a/README.md b/README.md index 3edd6ee7..48357bf0 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ When installed via npm, the configuration wizard handles the initial setup. The | `TRACK_BACKGROUND_SESSIONS` | Track detached/non-current sessions in the current selected project/worktree and send short notifications | No | `true` | | `RESPONSE_STREAM_THROTTLE_MS` | Stream update throttle in milliseconds for assistant, thinking, and tool message edits | No | `1000` | | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` | +| `INITIAL_SETTINGS_PRESET` | JSON object that seeds default `/settings` values on first run (keys not yet persisted); see [Runtime Settings](#runtime-settings) | No | `{}` | | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` | | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — | | `STT_API_KEY` | API key for your STT provider | No | — | @@ -258,10 +259,17 @@ Runtime preferences are changed from `/settings` and stored in `settings.json`: - Compact output mode - Thinking content display +- Assistant run footer display - Diff file attachments - Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages - Audio replies: `off`, `all`, or `auto` when TTS is configured +You can seed the initial defaults for any of these settings without hard-coding them in your Docker image by setting `INITIAL_SETTINGS_PRESET` to a JSON object. Only keys not yet persisted in `settings.json` are affected — settings the user has already changed via `/settings` are left untouched: + +```env +INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto"} +``` + ### Reverse Proxy (Optional) For environments that block `api.telegram.org` but allow your own HTTPS endpoint (corporate networks, restricted regions), you can route Bot API traffic through a reverse proxy you control. This is an alternative to the SOCKS/HTTP forward proxy configured with `TELEGRAM_PROXY_URL`. diff --git a/src/app/stores/settings-store.ts b/src/app/stores/settings-store.ts index 4c470042..0ebaef29 100644 --- a/src/app/stores/settings-store.ts +++ b/src/app/stores/settings-store.ts @@ -8,6 +8,7 @@ import type { ScheduledTaskSessionIgnoreInfo, Settings, } from "../types/settings.js"; +import { config } from "../../config.js"; import { getRuntimePaths } from "../../runtime/paths.js"; import { logger } from "../../utils/logger.js"; @@ -228,6 +229,79 @@ export function __resetSettingsForTests(): void { settingsWriteQueue = Promise.resolve(); } +const VALID_TTS_MODES: readonly TtsMode[] = ["off", "all", "auto"]; +const VALID_STREAMING_MODES: readonly ResponseStreamingMode[] = ["edit", "draft"]; + +function applyInitialSettingsPreset(preset: Record): void { + const knownKeys = new Set([ + "ttsMode", + "compactOutputMode", + "showThinkingContent", + "showAssistantRunFooter", + "responseStreamingMode", + "sendDiffFileAttachments", + ]); + + for (const [key, value] of Object.entries(preset)) { + if (!knownKeys.has(key)) { + logger.warn( + `[SettingsManager] INITIAL_SETTINGS_PRESET: unknown key "${key}" — ignoring.`, + ); + continue; + } + if (key === "ttsMode") { + if (typeof value !== "string" || !VALID_TTS_MODES.includes(value as TtsMode)) { + logger.warn( + `[SettingsManager] INITIAL_SETTINGS_PRESET: invalid value for "ttsMode"; expected one of ${VALID_TTS_MODES.join(", ")} — ignoring.`, + ); + continue; + } + if (currentSettings.ttsMode === undefined) { + currentSettings.ttsMode = value as TtsMode; + } + } else if (key === "responseStreamingMode") { + if ( + typeof value !== "string" || + !VALID_STREAMING_MODES.includes(value as ResponseStreamingMode) + ) { + logger.warn( + `[SettingsManager] INITIAL_SETTINGS_PRESET: invalid value for "responseStreamingMode"; expected one of ${VALID_STREAMING_MODES.join(", ")} — ignoring.`, + ); + continue; + } + if (currentSettings.responseStreamingMode === undefined) { + currentSettings.responseStreamingMode = value as ResponseStreamingMode; + } + } else { + // Boolean settings: compactOutputMode, showThinkingContent, showAssistantRunFooter, sendDiffFileAttachments + if (typeof value !== "boolean") { + logger.warn( + `[SettingsManager] INITIAL_SETTINGS_PRESET: "${key}" must be a boolean — ignoring.`, + ); + continue; + } + switch (key) { + case "compactOutputMode": + if (currentSettings.compactOutputMode === undefined) + currentSettings.compactOutputMode = value; + break; + case "showThinkingContent": + if (currentSettings.showThinkingContent === undefined) + currentSettings.showThinkingContent = value; + break; + case "showAssistantRunFooter": + if (currentSettings.showAssistantRunFooter === undefined) + currentSettings.showAssistantRunFooter = value; + break; + case "sendDiffFileAttachments": + if (currentSettings.sendDiffFileAttachments === undefined) + currentSettings.sendDiffFileAttachments = value; + break; + } + } + } +} + export async function loadSettings(): Promise { const loadedSettings = (await readSettingsFile()) as Settings & { serverProcess?: unknown; @@ -259,6 +333,8 @@ export async function loadSettings(): Promise { currentSettings.scheduledTaskSessionIgnores = cloneScheduledTaskSessionIgnores(loadedSettings.scheduledTaskSessionIgnores) ?? []; + applyInitialSettingsPreset(config.bot.initialSettingsPreset); + if (requiresRewrite) { void writeSettingsFile(currentSettings); } diff --git a/src/config.ts b/src/config.ts index 229efce7..6e6af007 100644 --- a/src/config.ts +++ b/src/config.ts @@ -76,6 +76,29 @@ function getOptionalMessageFormatModeEnvVar( return defaultValue; } +function parseInitialSettingsPreset(): Record { + const raw = getEnvVar("INITIAL_SETTINGS_PRESET", false).trim(); + if (!raw) { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + console.warn( + "[config] INITIAL_SETTINGS_PRESET contains invalid JSON — ignoring preset.", + ); + return {}; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + console.warn( + "[config] INITIAL_SETTINGS_PRESET must be a JSON object — ignoring preset.", + ); + return {}; + } + return parsed as Record; +} + const VALID_TTS_PROVIDERS: TtsProvider[] = ["openai", "google", "elevenlabs"]; function getOptionalTtsProviderEnvVar(key: string, defaultValue: TtsProvider): TtsProvider { @@ -167,6 +190,7 @@ export const config = { locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"), trackBackgroundSessions: getOptionalBooleanEnvVar("TRACK_BACKGROUND_SESSIONS", true), messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"), + initialSettingsPreset: parseInitialSettingsPreset(), }, files: { maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10), diff --git a/tests/app/stores/settings-store.test.ts b/tests/app/stores/settings-store.test.ts index 1a2c6d25..0b1fb009 100644 --- a/tests/app/stores/settings-store.test.ts +++ b/tests/app/stores/settings-store.test.ts @@ -78,6 +78,71 @@ describe("app/stores/settings-store", () => { expect(getShowAssistantRunFooter()).toBe(true); }); + it("applies INITIAL_SETTINGS_PRESET for settings not yet persisted", async () => { + vi.resetModules(); + vi.stubEnv( + "INITIAL_SETTINGS_PRESET", + '{"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto","responseStreamingMode":"draft","sendDiffFileAttachments":false,"showThinkingContent":false}', + ); + + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + + expect(store.getShowAssistantRunFooter()).toBe(false); + expect(store.getCompactOutputMode()).toBe(true); + expect(store.getTtsMode()).toBe("auto"); + expect(store.getResponseStreamingMode()).toBe("draft"); + expect(store.getSendDiffFileAttachments()).toBe(false); + expect(store.getShowThinkingContent()).toBe(false); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("does not overwrite a persisted setting with INITIAL_SETTINGS_PRESET", async () => { + await writeFile( + path.join(tempHome, "settings.json"), + JSON.stringify({ showAssistantRunFooter: true }), + ); + vi.resetModules(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", '{"showAssistantRunFooter":false}'); + vi.stubEnv("OPENCODE_TELEGRAM_HOME", tempHome); + + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + + expect(store.getShowAssistantRunFooter()).toBe(true); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("ignores unknown keys in INITIAL_SETTINGS_PRESET without crashing", async () => { + vi.resetModules(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", '{"unknownKey":true,"compactOutputMode":true}'); + + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + + expect(store.getCompactOutputMode()).toBe(true); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("ignores a preset key with the wrong type without crashing", async () => { + vi.resetModules(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", '{"compactOutputMode":"yes"}'); + + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + + expect(store.getCompactOutputMode()).toBe(false); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + it("loads thinking content setting from settings.json", async () => { await writeFile(path.join(tempHome, "settings.json"), JSON.stringify({ showThinkingContent: false })); diff --git a/tests/config.test.ts b/tests/config.test.ts index 5dbe5555..8abfc632 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -72,6 +72,52 @@ describe("config boolean env parsing", () => { expect(config.bot.messageFormatMode).toBe("markdown"); }); + it("returns an empty preset when INITIAL_SETTINGS_PRESET is not set", async () => { + vi.stubEnv("INITIAL_SETTINGS_PRESET", ""); + + const config = await loadConfig(); + + expect(config.bot.initialSettingsPreset).toEqual({}); + }); + + it("parses a valid INITIAL_SETTINGS_PRESET JSON object", async () => { + vi.stubEnv( + "INITIAL_SETTINGS_PRESET", + '{"showAssistantRunFooter":false,"compactOutputMode":true}', + ); + + const config = await loadConfig(); + + expect(config.bot.initialSettingsPreset).toEqual({ + showAssistantRunFooter: false, + compactOutputMode: true, + }); + }); + + it("returns an empty preset when INITIAL_SETTINGS_PRESET contains invalid JSON", async () => { + vi.stubEnv("INITIAL_SETTINGS_PRESET", "{not valid json}"); + + const config = await loadConfig(); + + expect(config.bot.initialSettingsPreset).toEqual({}); + }); + + it("returns an empty preset when INITIAL_SETTINGS_PRESET is a JSON array", async () => { + vi.stubEnv("INITIAL_SETTINGS_PRESET", '["not","an","object"]'); + + const config = await loadConfig(); + + expect(config.bot.initialSettingsPreset).toEqual({}); + }); + + it("returns an empty preset when INITIAL_SETTINGS_PRESET is a JSON scalar", async () => { + vi.stubEnv("INITIAL_SETTINGS_PRESET", "true"); + + const config = await loadConfig(); + + expect(config.bot.initialSettingsPreset).toEqual({}); + }); + it("parses supported locale from BOT_LOCALE", async () => { vi.stubEnv("BOT_LOCALE", "fr");