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

applyInitialSettingsPreset(config.bot.initialSettingsPreset);

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

export 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 {
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<string, unknown>;
}

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

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

Expand Down
51 changes: 49 additions & 2 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down Expand Up @@ -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");

Expand Down
Loading