@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/LICENSE +674 -0
- package/README.md +222 -0
- package/package.json +34 -0
- package/src/bot.ts +143 -0
- package/src/config.ts +218 -0
- package/src/handlers/callbacks.ts +209 -0
- package/src/handlers/commands.ts +562 -0
- package/src/handlers/messages.ts +163 -0
- package/src/hooks/message.ts +448 -0
- package/src/hooks/permission.ts +81 -0
- package/src/hooks/session.ts +126 -0
- package/src/hooks/tool.ts +99 -0
- package/src/index.ts +395 -0
- package/src/state/mapping.ts +112 -0
- package/src/state/mode.ts +40 -0
- package/src/state/store.ts +167 -0
- package/src/utils/chunk.ts +186 -0
- package/src/utils/format.ts +120 -0
- package/src/utils/safeSend.ts +99 -0
- package/src/utils/throttle.ts +128 -0
- package/src/utils/typing.ts +30 -0
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
|
+
}
|