@tormentalabs/opencode-telegram-plugin 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # opencode-telegram-plugin
2
+
3
+ Telegram bot plugin for [OpenCode](https://opencode.ai) — remote control and independent sessions from your phone.
4
+
5
+ ## Features
6
+
7
+ - **Remote Control** — attach to your active TUI session, see streaming responses in real-time, send prompts, and approve/deny permission requests via inline buttons
8
+ - **Independent Sessions** — create standalone sessions for async work from your phone
9
+ - **Live Streaming** — AI responses stream into Telegram with throttled in-place message edits
10
+ - **Permission Handling** — tool permission prompts appear as inline keyboards (Approve / Deny)
11
+ - **Tool Status** — see which tools are executing in real-time
12
+ - **Multi-session** — switch between sessions, list active sessions, create new ones
13
+ - **Config Management** — built-in `/telegram` slash command for setup without leaving OpenCode
14
+
15
+ ## Quick Start
16
+
17
+ ### 1. Install the plugin
18
+
19
+ **Local install** (recommended for development):
20
+
21
+ Add to your `opencode.json`:
22
+
23
+ ```json
24
+ {
25
+ "plugin": ["/path/to/opencode-telegram-plugin"]
26
+ }
27
+ ```
28
+
29
+ Then install dependencies:
30
+
31
+ ```bash
32
+ cd /path/to/opencode-telegram-plugin
33
+ bun install
34
+ ```
35
+
36
+ **From npm** (after publishing):
37
+
38
+ ```json
39
+ {
40
+ "plugin": ["opencode-telegram-plugin"]
41
+ }
42
+ ```
43
+
44
+ ### 2. Create a Telegram bot
45
+
46
+ 1. Message [@BotFather](https://t.me/BotFather) on Telegram
47
+ 2. Send `/newbot` and follow the prompts
48
+ 3. Copy the bot token
49
+
50
+ ### 3. Configure the token
51
+
52
+ **Option A — Using the `/telegram` slash command** (recommended):
53
+
54
+ Launch OpenCode and run:
55
+
56
+ ```
57
+ /telegram set-token 123456789:ABCdef-GHIjkl_MNOpqr
58
+ ```
59
+
60
+ Then restart OpenCode.
61
+
62
+ **Option B — Environment variable**:
63
+
64
+ ```bash
65
+ export TELEGRAM_BOT_TOKEN="123456789:ABCdef-GHIjkl_MNOpqr"
66
+ ```
67
+
68
+ **Option C — Config file** (manually):
69
+
70
+ Create `~/.config/opencode/telegram.json`:
71
+
72
+ ```json
73
+ {
74
+ "botToken": "123456789:ABCdef-GHIjkl_MNOpqr"
75
+ }
76
+ ```
77
+
78
+ ### 4. (Optional) Restrict access
79
+
80
+ Restrict the bot to your Telegram user ID only. To get your ID, message [@jsondumpbot](https://t.me/jsondumpbot) on Telegram — your ID is in the `from.id` field of the response.
81
+
82
+ ```
83
+ /telegram set-users 123456789
84
+ ```
85
+
86
+ ### 5. Start using it
87
+
88
+ 1. Launch OpenCode — the bot starts automatically
89
+ 2. Open your bot in Telegram and send `/start`
90
+ 3. Send a message — it's relayed as a prompt to OpenCode
91
+ 4. Watch the streaming response appear with live edits
92
+
93
+ ## Configuration
94
+
95
+ Configuration is resolved by layering **env vars** over the **config file** over **defaults**. Env vars always take priority.
96
+
97
+ ### Config file
98
+
99
+ Located at `~/.config/opencode/telegram.json`:
100
+
101
+ ```json
102
+ {
103
+ "botToken": "123456789:ABCdef...",
104
+ "allowedUsers": "111111,222222",
105
+ "editIntervalMs": 2500,
106
+ "autoAttach": true
107
+ }
108
+ ```
109
+
110
+ ### Environment variables
111
+
112
+ | Variable | Description | Default |
113
+ |----------|-------------|---------|
114
+ | `TELEGRAM_BOT_TOKEN` | Telegram Bot API token | — |
115
+ | `TELEGRAM_ALLOWED_USERS` | Comma-separated user IDs (empty = all) | `""` |
116
+ | `TELEGRAM_EDIT_INTERVAL_MS` | Min interval between message edits (ms) | `2500` |
117
+ | `TELEGRAM_AUTO_ATTACH` | Auto-attach to active session on `/start` | `true` |
118
+
119
+ ## `/telegram` Slash Command
120
+
121
+ Manage configuration from within OpenCode without editing files manually.
122
+
123
+ | Command | Description |
124
+ |---------|-------------|
125
+ | `/telegram set-token <TOKEN>` | Save bot token from @BotFather |
126
+ | `/telegram remove-token` | Remove saved bot token |
127
+ | `/telegram set-users <id1,id2,...>` | Restrict bot to specific Telegram user IDs |
128
+ | `/telegram remove-users` | Remove user restriction (allow all) |
129
+ | `/telegram set-interval <ms>` | Set edit throttle interval (default: 2500) |
130
+ | `/telegram auto-attach <on\|off>` | Toggle auto-attach on `/start` |
131
+ | `/telegram status` | Show resolved config (file + env combined) |
132
+ | `/telegram show` | Show raw config file contents |
133
+ | `/telegram path` | Show config file location |
134
+ | `/telegram help` | Show help |
135
+
136
+ Changes to the config file require an **OpenCode restart** to take effect.
137
+
138
+ ## Telegram Bot Commands
139
+
140
+ Once the bot is running, these commands are available in Telegram:
141
+
142
+ | Command | Description |
143
+ |---------|-------------|
144
+ | `/start` | Initialize bot, auto-attach to active session |
145
+ | `/attach` | Attach to an active TUI session (shows picker) |
146
+ | `/detach` | Detach from the current session |
147
+ | `/new` | Create an independent session |
148
+ | `/sessions` | List all sessions |
149
+ | `/switch` | Switch to a different session |
150
+ | `/model` | List available models with favorites marked |
151
+ | `/model <provider/model-id>` | Set a specific model for this chat |
152
+ | `/model reset` | Reset to the default model |
153
+ | `/effort` | Show current effort level |
154
+ | `/effort <low\|medium\|high>` | Set reasoning effort level |
155
+ | `/status` | Show current connection status |
156
+ | `/abort` | Abort the current session |
157
+ | `/help` | Show help |
158
+
159
+ ## How It Works
160
+
161
+ ### Modes
162
+
163
+ - **Attached** (default) — mirrors an active TUI session. You see what the TUI sees, and your messages are sent as prompts to that session.
164
+ - **Independent** — a standalone session created via `/new`. Runs separately from the TUI.
165
+ - **Detached** — no active session. Messages are ignored until you `/attach` or `/new`.
166
+
167
+ ### Streaming
168
+
169
+ AI responses are streamed to Telegram using a state machine:
170
+
171
+ ```
172
+ IDLE → PENDING_SEND → SENT → EDITING → FINAL
173
+ ```
174
+
175
+ - First chunk is sent as a new message
176
+ - Subsequent chunks edit the message in-place (throttled to avoid rate limits)
177
+ - Long responses are automatically split into multiple messages (entity-aware HTML chunking at 4096 chars)
178
+ - Markdown from the AI is converted to Telegram-safe HTML
179
+
180
+ ### Permissions
181
+
182
+ When OpenCode requests tool permissions, an inline keyboard appears in Telegram:
183
+
184
+ ```
185
+ 🔐 Permission requested: bash
186
+ Command: git status
187
+ [✅ Approve] [❌ Deny]
188
+ ```
189
+
190
+ Tapping a button responds to the permission request in OpenCode.
191
+
192
+ ## Project Structure
193
+
194
+ ```
195
+ src/
196
+ ├── index.ts # Plugin entry — lifecycle, event dispatcher, /telegram command
197
+ ├── config.ts # Config file management (~/.config/opencode/telegram.json)
198
+ ├── bot.ts # grammY bot creation, middleware, command registration
199
+ ├── handlers/
200
+ │ ├── commands.ts # Telegram bot command handlers
201
+ │ ├── messages.ts # Text message → prompt relay
202
+ │ └── callbacks.ts # Inline button callback resolution
203
+ ├── hooks/
204
+ │ ├── message.ts # message.updated → stream to Telegram
205
+ │ ├── session.ts # Session lifecycle notifications
206
+ │ ├── permission.ts # Permission prompts → inline keyboards
207
+ │ └── tool.ts # Tool execution status updates
208
+ ├── state/
209
+ │ ├── store.ts # Per-chat state, stream tracker, callback registry
210
+ │ ├── mode.ts # Session mode manager
211
+ │ └── mapping.ts # Persistent chat ↔ session mapping
212
+ └── utils/
213
+ ├── format.ts # Markdown → Telegram HTML conversion
214
+ ├── chunk.ts # Entity-aware message splitting
215
+ ├── throttle.ts # Edit rate limiter
216
+ ├── safeSend.ts # Error-classified Telegram API wrapper
217
+ └── typing.ts # Typing indicator manager
218
+ ```
219
+
220
+ ## License
221
+
222
+ [GPL-3.0-or-later](LICENSE)
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@tormentalabs/opencode-telegram-plugin",
3
+ "version": "0.2.0",
4
+ "description": "Telegram bot plugin for OpenCode — remote control and independent sessions from your phone",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "files": [
8
+ "src/",
9
+ "LICENSE",
10
+ "README.md"
11
+ ],
12
+ "license": "GPL-3.0-or-later",
13
+ "keywords": [
14
+ "opencode",
15
+ "telegram",
16
+ "bot",
17
+ "plugin",
18
+ "remote-control",
19
+ "ai",
20
+ "coding-assistant"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/marco-jardim/opencode-telegram-plugin.git"
25
+ },
26
+ "author": "marco-jardim",
27
+ "dependencies": {
28
+ "grammy": "^1.35.0",
29
+ "@grammyjs/auto-retry": "^2.0.0"
30
+ },
31
+ "peerDependencies": {
32
+ "@opencode-ai/plugin": ">=1.0.0"
33
+ }
34
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { Bot } from "grammy";
2
+ import { autoRetry } from "@grammyjs/auto-retry";
3
+
4
+ import {
5
+ startCommand,
6
+ helpCommand,
7
+ attachCommand,
8
+ detachCommand,
9
+ newCommand,
10
+ sessionsCommand,
11
+ switchCommand,
12
+ modelCommand,
13
+ effortCommand,
14
+ statusCommand,
15
+ abortCommand,
16
+ setClient as setCommandsClient,
17
+ } from "./handlers/commands.js";
18
+ import {
19
+ handleTextMessage,
20
+ setClient as setMessagesClient,
21
+ } from "./handlers/messages.js";
22
+ import {
23
+ handleCallback,
24
+ setClient as setCallbacksClient,
25
+ } from "./handlers/callbacks.js";
26
+ import { cleanExpiredCallbacks } from "./state/store.js";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Configuration
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const CLEANUP_INTERVAL_MS = 120_000; // 2 minutes
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Bot factory
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export interface CreateBotOptions {
39
+ /** Telegram Bot API token. */
40
+ token: string;
41
+ /** Comma-separated list of allowed Telegram user IDs (empty = allow all). */
42
+ allowedUsers: string;
43
+ /** Optional error logger; defaults to console.error if not provided. */
44
+ onError?: (message: string, error: unknown) => void;
45
+ }
46
+
47
+ /**
48
+ * Create and configure the grammY bot instance.
49
+ *
50
+ * This does **not** start polling — call `bot.start()` separately with an
51
+ * `AbortSignal` so the lifecycle is controlled by the plugin entry point.
52
+ */
53
+ export function createBot(opts: CreateBotOptions): Bot {
54
+ const { token, allowedUsers, onError } = opts;
55
+ const logError = onError ?? ((msg, err) => console.error(msg, err));
56
+ const bot = new Bot(token);
57
+
58
+ // ── Auto-retry plugin (handles 429 / 500 transparently) ─────────────────
59
+ bot.api.config.use(autoRetry());
60
+
61
+ // ── Global error handler — prevents unhandled errors from killing the bot
62
+ bot.catch((err) => {
63
+ logError("[telegram-plugin] Unhandled error in middleware:", err.error);
64
+ });
65
+
66
+ // ── Middleware: private-chat only ───────────────────────────────────────
67
+ bot.use(async (ctx, next) => {
68
+ if (ctx.chat?.type !== "private") return; // silently drop group messages
69
+ await next();
70
+ });
71
+
72
+ // ── Middleware: user whitelist ──────────────────────────────────────────
73
+ const allowedSet = parseAllowedUsers(allowedUsers);
74
+ if (allowedSet.size > 0) {
75
+ bot.use(async (ctx, next) => {
76
+ const userId = ctx.from?.id;
77
+ if (userId === undefined || !allowedSet.has(userId)) return;
78
+ await next();
79
+ });
80
+ }
81
+
82
+ // ── Commands ───────────────────────────────────────────────────────────
83
+ bot.command("start", startCommand);
84
+ bot.command("help", helpCommand);
85
+ bot.command("attach", attachCommand);
86
+ bot.command("detach", detachCommand);
87
+ bot.command("new", newCommand);
88
+ bot.command("sessions", sessionsCommand);
89
+ bot.command("switch", switchCommand);
90
+ bot.command("model", modelCommand);
91
+ bot.command("effort", effortCommand);
92
+ bot.command("status", statusCommand);
93
+ bot.command("abort", abortCommand);
94
+
95
+ // ── Callback queries ──────────────────────────────────────────────────
96
+ bot.on("callback_query:data", handleCallback);
97
+
98
+ // ── Text messages (must be registered after commands) ──────────────────
99
+ bot.on("message:text", handleTextMessage);
100
+
101
+ // ── Periodic cleanup of expired callbacks ─────────────────────────────
102
+ const cleanupTimer = setInterval(cleanExpiredCallbacks, CLEANUP_INTERVAL_MS);
103
+ // Ensure the timer doesn't prevent the Node process from exiting.
104
+ if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
105
+ cleanupTimer.unref();
106
+ }
107
+
108
+ return bot;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Client injection
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Inject the OpenCode SDK client into all handler modules.
117
+ *
118
+ * Must be called once before the bot starts processing updates.
119
+ */
120
+ export function injectClient(client: unknown): void {
121
+ setCommandsClient(client);
122
+ setMessagesClient(client);
123
+ setCallbacksClient(client);
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Helpers
128
+ // ---------------------------------------------------------------------------
129
+
130
+ function parseAllowedUsers(raw: string): Set<number> {
131
+ const set = new Set<number>();
132
+ if (!raw) return set;
133
+
134
+ for (const part of raw.split(",")) {
135
+ const trimmed = part.trim();
136
+ if (!trimmed) continue;
137
+ const n = Number(trimmed);
138
+ if (Number.isFinite(n)) {
139
+ set.add(n);
140
+ }
141
+ }
142
+ return set;
143
+ }
package/src/config.ts ADDED
@@ -0,0 +1,218 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, dirname } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface TelegramConfig {
11
+ botToken: string | null;
12
+ allowedUsers: string | null;
13
+ editIntervalMs: number | null;
14
+ autoAttach: boolean | null;
15
+ }
16
+
17
+ type ConfigKey = keyof TelegramConfig;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Paths
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const CONFIG_DIR = join(homedir(), ".config", "opencode");
24
+ const CONFIG_FILE = join(CONFIG_DIR, "telegram.json");
25
+
26
+ export function getConfigPath(): string {
27
+ return CONFIG_FILE;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Read
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Read the config file from disk. Returns an empty config on any error. */
35
+ export function readConfigFile(): Partial<TelegramConfig> {
36
+ try {
37
+ if (!existsSync(CONFIG_FILE)) return {};
38
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
39
+ const parsed: unknown = JSON.parse(raw);
40
+ if (parsed === null || typeof parsed !== "object") return {};
41
+ const obj = parsed as Record<string, unknown>;
42
+
43
+ const result: Partial<TelegramConfig> = {};
44
+ if (typeof obj.botToken === "string") result.botToken = obj.botToken;
45
+ if (typeof obj.allowedUsers === "string") result.allowedUsers = obj.allowedUsers;
46
+ if (typeof obj.editIntervalMs === "number" && Number.isFinite(obj.editIntervalMs)) {
47
+ result.editIntervalMs = obj.editIntervalMs;
48
+ }
49
+ if (typeof obj.autoAttach === "boolean") result.autoAttach = obj.autoAttach;
50
+ return result;
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Write
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Merge partial config into the file (atomic write). */
61
+ export function writeConfigFile(updates: Partial<TelegramConfig>): void {
62
+ const existing = readConfigFile();
63
+ const merged = { ...existing, ...updates };
64
+
65
+ // Remove null/undefined entries so the file stays clean
66
+ const clean: Record<string, unknown> = {};
67
+ for (const [k, v] of Object.entries(merged)) {
68
+ if (v !== null && v !== undefined) {
69
+ clean[k] = v;
70
+ }
71
+ }
72
+
73
+ mkdirSync(CONFIG_DIR, { recursive: true });
74
+
75
+ const data = JSON.stringify(clean, null, 2) + "\n";
76
+ const tmpPath = CONFIG_FILE + "." + randomBytes(4).toString("hex") + ".tmp";
77
+ try {
78
+ writeFileSync(tmpPath, data, "utf-8");
79
+ renameSync(tmpPath, CONFIG_FILE);
80
+ } catch (err) {
81
+ // Clean up temp file on failure
82
+ try {
83
+ if (existsSync(tmpPath)) {
84
+ const { unlinkSync } = require("node:fs") as typeof import("node:fs");
85
+ unlinkSync(tmpPath);
86
+ }
87
+ } catch { /* ignore */ }
88
+ throw err;
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Delete a key
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export function deleteConfigKey(key: ConfigKey): void {
97
+ const existing = readConfigFile();
98
+ delete existing[key];
99
+ writeConfigFile(existing);
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Resolve config: file < env vars
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export interface ResolvedConfig {
107
+ botToken: string | null;
108
+ allowedUsers: string;
109
+ editIntervalMs: number;
110
+ autoAttach: boolean;
111
+ /** Where the bot token came from */
112
+ tokenSource: "env" | "config" | "none";
113
+ }
114
+
115
+ /**
116
+ * Resolve final config by layering env vars over the config file.
117
+ *
118
+ * Priority: env vars > config file > defaults.
119
+ */
120
+ export function resolveConfig(): ResolvedConfig {
121
+ const file = readConfigFile();
122
+
123
+ // Bot token: env > file
124
+ const envToken = process.env["TELEGRAM_BOT_TOKEN"];
125
+ let botToken: string | null = null;
126
+ let tokenSource: ResolvedConfig["tokenSource"] = "none";
127
+ if (envToken) {
128
+ botToken = envToken;
129
+ tokenSource = "env";
130
+ } else if (file.botToken) {
131
+ botToken = file.botToken;
132
+ tokenSource = "config";
133
+ }
134
+
135
+ // Allowed users: env > file > ""
136
+ const allowedUsers =
137
+ process.env["TELEGRAM_ALLOWED_USERS"] ?? file.allowedUsers ?? "";
138
+
139
+ // Edit interval: env > file > 2500
140
+ const envInterval = Number(process.env["TELEGRAM_EDIT_INTERVAL_MS"]);
141
+ let editIntervalMs = 2500;
142
+ if (Number.isFinite(envInterval) && envInterval > 0) {
143
+ editIntervalMs = envInterval;
144
+ } else if (
145
+ file.editIntervalMs !== null &&
146
+ file.editIntervalMs !== undefined &&
147
+ Number.isFinite(file.editIntervalMs) &&
148
+ file.editIntervalMs > 0
149
+ ) {
150
+ editIntervalMs = file.editIntervalMs;
151
+ }
152
+
153
+ // Auto-attach: env > file > true
154
+ const envAutoAttach = process.env["TELEGRAM_AUTO_ATTACH"];
155
+ let autoAttach = true;
156
+ if (envAutoAttach !== undefined) {
157
+ autoAttach = envAutoAttach !== "false";
158
+ } else if (file.autoAttach !== null && file.autoAttach !== undefined) {
159
+ autoAttach = file.autoAttach;
160
+ }
161
+
162
+ return { botToken, allowedUsers, editIntervalMs, autoAttach, tokenSource };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Status summary (for /telegram status)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export function getConfigStatus(): string {
170
+ const file = readConfigFile();
171
+ const resolved = resolveConfig();
172
+
173
+ const lines: string[] = [];
174
+ lines.push("**Telegram Plugin Configuration**\n");
175
+
176
+ // Token
177
+ if (resolved.botToken) {
178
+ const masked = resolved.botToken.slice(0, 6) + "..." + resolved.botToken.slice(-4);
179
+ lines.push(`- **Bot Token**: \`${masked}\` (from ${resolved.tokenSource})`);
180
+ } else {
181
+ lines.push("- **Bot Token**: _not set_");
182
+ }
183
+
184
+ // Allowed users
185
+ if (resolved.allowedUsers) {
186
+ lines.push(`- **Allowed Users**: \`${resolved.allowedUsers}\``);
187
+ } else {
188
+ lines.push("- **Allowed Users**: _all users_ (no restriction)");
189
+ }
190
+
191
+ // Edit interval
192
+ lines.push(`- **Edit Interval**: ${resolved.editIntervalMs}ms`);
193
+
194
+ // Auto-attach
195
+ lines.push(`- **Auto-Attach**: ${resolved.autoAttach ? "enabled" : "disabled"}`);
196
+
197
+ // Config file
198
+ lines.push(`\n**Config file**: \`${CONFIG_FILE}\``);
199
+ if (existsSync(CONFIG_FILE)) {
200
+ const keys = Object.keys(file);
201
+ lines.push(` - Exists with ${keys.length} key(s): ${keys.map(k => `\`${k}\``).join(", ") || "_empty_"}`);
202
+ } else {
203
+ lines.push(" - Does not exist yet");
204
+ }
205
+
206
+ // Env var overrides
207
+ const envOverrides: string[] = [];
208
+ if (process.env["TELEGRAM_BOT_TOKEN"]) envOverrides.push("TELEGRAM_BOT_TOKEN");
209
+ if (process.env["TELEGRAM_ALLOWED_USERS"]) envOverrides.push("TELEGRAM_ALLOWED_USERS");
210
+ if (process.env["TELEGRAM_EDIT_INTERVAL_MS"]) envOverrides.push("TELEGRAM_EDIT_INTERVAL_MS");
211
+ if (process.env["TELEGRAM_AUTO_ATTACH"]) envOverrides.push("TELEGRAM_AUTO_ATTACH");
212
+
213
+ if (envOverrides.length > 0) {
214
+ lines.push(`\n**Active env overrides**: ${envOverrides.map(e => `\`${e}\``).join(", ")}`);
215
+ }
216
+
217
+ return lines.join("\n");
218
+ }