diff --git a/.env.example b/.env.example index b3916907..bc33ac91 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) +# The preset is validated at startup: invalid JSON, a non-object value, unknown +# keys, or wrong value types abort startup with a clear error (fail fast). +# 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..eb5857fe 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,75 @@ 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)) { + throw new Error( + `INITIAL_SETTINGS_PRESET: unknown key "${key}". Supported keys: ${[...knownKeys].join(", ")}.`, + ); + } + if (key === "ttsMode") { + if (typeof value !== "string" || !VALID_TTS_MODES.includes(value as TtsMode)) { + throw new Error( + `INITIAL_SETTINGS_PRESET: invalid value for "ttsMode"; expected one of ${VALID_TTS_MODES.join(", ")}.`, + ); + } + if (currentSettings.ttsMode === undefined) { + currentSettings.ttsMode = value as TtsMode; + } + } else if (key === "responseStreamingMode") { + if ( + typeof value !== "string" || + !VALID_STREAMING_MODES.includes(value as ResponseStreamingMode) + ) { + throw new Error( + `INITIAL_SETTINGS_PRESET: invalid value for "responseStreamingMode"; expected one of ${VALID_STREAMING_MODES.join(", ")}.`, + ); + } + if (currentSettings.responseStreamingMode === undefined) { + currentSettings.responseStreamingMode = value as ResponseStreamingMode; + } + } else { + // Boolean settings: compactOutputMode, showThinkingContent, showAssistantRunFooter, sendDiffFileAttachments + if (typeof value !== "boolean") { + throw new Error( + `INITIAL_SETTINGS_PRESET: "${key}" must be a boolean.`, + ); + } + 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 +329,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 12051fc6..ccb98f1b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -76,6 +76,27 @@ function getOptionalMessageFormatModeEnvVar( return defaultValue; } +export function parseInitialSettingsPreset(): Record { + const raw = getEnvVar("INITIAL_SETTINGS_PRESET", false).trim(); + if (!raw) { + return {}; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error( + "INITIAL_SETTINGS_PRESET contains invalid JSON. Fix or unset the variable.", + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error( + "INITIAL_SETTINGS_PRESET must be a JSON object.", + ); + } + return parsed as Record; +} + const VALID_TTS_PROVIDERS: TtsProvider[] = ["openai", "google", "elevenlabs", "edge"]; function getOptionalTtsProviderEnvVar(key: string, defaultValue: TtsProvider): TtsProvider { @@ -167,6 +188,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..7f660ff1 100644 --- a/tests/app/stores/settings-store.test.ts +++ b/tests/app/stores/settings-store.test.ts @@ -23,6 +23,7 @@ describe("app/stores/settings-store", () => { let tempHome: string; beforeEach(async () => { + delete process.env.INITIAL_SETTINGS_PRESET; tempHome = await mkdtemp(path.join(os.tmpdir(), "opencode-telegram-settings-store-")); process.env.OPENCODE_TELEGRAM_HOME = tempHome; setRuntimeMode("installed"); @@ -78,6 +79,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("throws on unknown keys in INITIAL_SETTINGS_PRESET", async () => { + vi.resetModules(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", '{"unknownKey":true,"compactOutputMode":true}'); + + await expect((async () => { + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + })()).rejects.toThrow(/unknown key "unknownKey"/); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("throws when a preset key has the wrong type", async () => { + vi.resetModules(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", '{"compactOutputMode":"yes"}'); + + await expect((async () => { + const store = await import("../../../src/app/stores/settings-store.js"); + await store.loadSettings(); + })()).rejects.toThrow(/"compactOutputMode" must be a boolean/); + + 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 106f03ec..c5033ce4 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,8 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -async function loadConfig() { +async function loadConfigModule() { vi.resetModules(); - const module = await import("../src/config.js"); + return import("../src/config.js"); +} + +async function loadConfig() { + const module = await loadConfigModule(); return module.config; } @@ -72,6 +76,49 @@ 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("throws when INITIAL_SETTINGS_PRESET contains invalid JSON", async () => { + const { parseInitialSettingsPreset } = await loadConfigModule(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", "{not valid json}"); + + expect(() => parseInitialSettingsPreset()).toThrow(/invalid JSON/); + }); + + it("throws when INITIAL_SETTINGS_PRESET is a JSON array", async () => { + const { parseInitialSettingsPreset } = await loadConfigModule(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", '["not","an","object"]'); + + expect(() => parseInitialSettingsPreset()).toThrow(/must be a JSON object/); + }); + + it("throws when INITIAL_SETTINGS_PRESET is a JSON scalar", async () => { + const { parseInitialSettingsPreset } = await loadConfigModule(); + vi.stubEnv("INITIAL_SETTINGS_PRESET", "true"); + + expect(() => parseInitialSettingsPreset()).toThrow(/must be a JSON object/); + }); + it("parses supported locale from BOT_LOCALE", async () => { vi.stubEnv("BOT_LOCALE", "fr");