Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |
Expand All @@ -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`.
Expand Down
76 changes: 76 additions & 0 deletions src/app/stores/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, unknown>): 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<void> {
const loadedSettings = (await readSettingsFile()) as Settings & {
serverProcess?: unknown;
Expand Down Expand Up @@ -259,6 +333,8 @@ export async function loadSettings(): Promise<void> {
currentSettings.scheduledTaskSessionIgnores =
cloneScheduledTaskSessionIgnores(loadedSettings.scheduledTaskSessionIgnores) ?? [];

applyInitialSettingsPreset(config.bot.initialSettingsPreset);

if (requiresRewrite) {
void writeSettingsFile(currentSettings);
}
Expand Down
24 changes: 24 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,29 @@ function getOptionalMessageFormatModeEnvVar(
return defaultValue;
}

function parseInitialSettingsPreset(): Record<string, unknown> {
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<string, unknown>;
}

const VALID_TTS_PROVIDERS: TtsProvider[] = ["openai", "google", "elevenlabs"];

function getOptionalTtsProviderEnvVar(key: string, defaultValue: TtsProvider): TtsProvider {
Expand Down Expand Up @@ -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),
Expand Down
65 changes: 65 additions & 0 deletions tests/app/stores/settings-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));

Expand Down
46 changes: 46 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down