@coinseeker/opencode-telegram-plugin 1.0.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 +21 -0
- package/README.md +61 -0
- package/dist/telegram-remote.d.ts +11 -0
- package/dist/telegram-remote.js +1101 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 coin-seeker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# OpenCode Telegram Plugin
|
|
2
|
+
|
|
3
|
+
Control and monitor OpenCode from Telegram.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Configure the npm package in `~/.config/opencode/opencode.json`:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"plugin": ["@coinseeker/opencode-telegram-plugin"]
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
|
|
16
|
+
|
|
17
|
+
## Configure Telegram
|
|
18
|
+
|
|
19
|
+
Create `~/.config/opencode/telegram-remote/.env`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
mkdir -p ~/.config/opencode/telegram-remote
|
|
23
|
+
chmod 700 ~/.config/opencode/telegram-remote
|
|
24
|
+
cat > ~/.config/opencode/telegram-remote/.env <<'EOF'
|
|
25
|
+
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
|
26
|
+
TELEGRAM_ALLOWED_USER_IDS=123456789,987654321
|
|
27
|
+
# Optional: skip first-message discovery
|
|
28
|
+
# TELEGRAM_CHAT_ID=123456789
|
|
29
|
+
EOF
|
|
30
|
+
chmod 600 ~/.config/opencode/telegram-remote/.env
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Keep this file private. Never commit or share your Telegram bot token.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
1. Create a Telegram bot with [@BotFather](https://t.me/BotFather).
|
|
38
|
+
2. Get your numeric Telegram user ID from [@userinfobot](https://t.me/userinfobot).
|
|
39
|
+
3. Add the token and allowed user IDs to the env file above.
|
|
40
|
+
4. Restart OpenCode.
|
|
41
|
+
5. Send any message to your bot in a private Telegram chat.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- Root session completion notifications.
|
|
46
|
+
- Background subagent-aware completion: child session messages are suppressed and parent completion waits until children finish.
|
|
47
|
+
- OpenCode question prompts via Telegram inline buttons.
|
|
48
|
+
- Custom free-text answers from Telegram.
|
|
49
|
+
- Permission alerts.
|
|
50
|
+
- Multi-session-safe Telegram polling through a file-lock leader model.
|
|
51
|
+
- Log file output instead of stdout terminal spam.
|
|
52
|
+
|
|
53
|
+
## Logs
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
node -e "const os=require('os'); console.log(os.tmpdir() + '/opencoder-telegram.log')"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Source
|
|
60
|
+
|
|
61
|
+
https://github.com/coin-seeker/opencode-telegram-plugin
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
|
|
3
|
+
declare const TelegramRemote: Plugin;
|
|
4
|
+
declare const id = "opencoder-telegram-remote";
|
|
5
|
+
declare const server: Plugin;
|
|
6
|
+
declare const _default: {
|
|
7
|
+
id: string;
|
|
8
|
+
server: Plugin;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export { TelegramRemote, _default as default, id, server };
|
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCoder Telegram Remote Plugin
|
|
3
|
+
* https://github.com/coin-seeker/opencode-telegram-plugin
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// src/telegram-remote.ts
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
9
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
10
|
+
import { createHash as createHash3 } from "crypto";
|
|
11
|
+
|
|
12
|
+
// src/lib/logger.ts
|
|
13
|
+
import { appendFile } from "fs/promises";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
var DEFAULT_BUFFER_LIMIT = 4096;
|
|
16
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
|
|
17
|
+
function safeJson(data) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.stringify(data);
|
|
20
|
+
} catch {
|
|
21
|
+
return '{"serialization":"failed"}';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function createLogger(opts = {}) {
|
|
25
|
+
const filePath = opts.filePath ?? `${tmpdir()}/opencoder-telegram.log`;
|
|
26
|
+
const namespace = opts.namespace ?? "default";
|
|
27
|
+
const bufferLimit = opts.bufferLimit ?? DEFAULT_BUFFER_LIMIT;
|
|
28
|
+
const flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
29
|
+
let buffer = "";
|
|
30
|
+
let closed = false;
|
|
31
|
+
let flushing = Promise.resolve();
|
|
32
|
+
const timer = setInterval(() => {
|
|
33
|
+
void flushBuffer();
|
|
34
|
+
}, flushIntervalMs);
|
|
35
|
+
timer.unref();
|
|
36
|
+
async function flushBuffer() {
|
|
37
|
+
if (buffer.length === 0) return flushing;
|
|
38
|
+
const chunk = buffer;
|
|
39
|
+
buffer = "";
|
|
40
|
+
flushing = flushing.then(async () => {
|
|
41
|
+
try {
|
|
42
|
+
await appendFile(filePath, chunk, "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return flushing;
|
|
47
|
+
}
|
|
48
|
+
function write(level, msg, data) {
|
|
49
|
+
if (closed) return;
|
|
50
|
+
const json = data === void 0 ? "" : ` ${safeJson(data)}`;
|
|
51
|
+
buffer += `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] [${process.pid}] [${namespace}] ${msg}${json}
|
|
52
|
+
`;
|
|
53
|
+
if (level === "error" || buffer.length >= bufferLimit) {
|
|
54
|
+
void flushBuffer();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
debug(msg, data) {
|
|
59
|
+
write("debug", msg, data);
|
|
60
|
+
},
|
|
61
|
+
info(msg, data) {
|
|
62
|
+
write("info", msg, data);
|
|
63
|
+
},
|
|
64
|
+
warn(msg, data) {
|
|
65
|
+
write("warn", msg, data);
|
|
66
|
+
},
|
|
67
|
+
error(msg, data) {
|
|
68
|
+
write("error", msg, data);
|
|
69
|
+
},
|
|
70
|
+
async flush() {
|
|
71
|
+
await flushBuffer();
|
|
72
|
+
},
|
|
73
|
+
async close() {
|
|
74
|
+
if (closed) return;
|
|
75
|
+
closed = true;
|
|
76
|
+
clearInterval(timer);
|
|
77
|
+
await flushBuffer();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/lib/lock.ts
|
|
83
|
+
import { open, readFile, stat, unlink } from "fs/promises";
|
|
84
|
+
import { hostname } from "os";
|
|
85
|
+
var DEFAULT_TTL_MS = 5 * 60 * 1e3;
|
|
86
|
+
function hasCode(err, code) {
|
|
87
|
+
return "code" in err && err.code === code;
|
|
88
|
+
}
|
|
89
|
+
function parseLockData(text) {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(text);
|
|
92
|
+
if (typeof parsed.pid === "number" && typeof parsed.hostname === "string" && typeof parsed.createdAt === "string") {
|
|
93
|
+
return { pid: parsed.pid, hostname: parsed.hostname, createdAt: parsed.createdAt };
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function isPidAlive(pid) {
|
|
101
|
+
try {
|
|
102
|
+
process.kill(pid, 0);
|
|
103
|
+
return true;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err instanceof Error && hasCode(err, "ESRCH")) return false;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function createLock(lockPath, pid) {
|
|
110
|
+
const file = await open(lockPath, "wx");
|
|
111
|
+
const acquiredAt = /* @__PURE__ */ new Date();
|
|
112
|
+
const data = { pid, hostname: hostname(), createdAt: acquiredAt.toISOString() };
|
|
113
|
+
try {
|
|
114
|
+
await file.writeFile(JSON.stringify(data), "utf8");
|
|
115
|
+
} finally {
|
|
116
|
+
await file.close();
|
|
117
|
+
}
|
|
118
|
+
let released = false;
|
|
119
|
+
return {
|
|
120
|
+
path: lockPath,
|
|
121
|
+
acquiredAt,
|
|
122
|
+
async release() {
|
|
123
|
+
if (released) return;
|
|
124
|
+
released = true;
|
|
125
|
+
try {
|
|
126
|
+
await unlink(lockPath);
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async function inspectExisting(lockPath, ttlMs) {
|
|
133
|
+
let ownerPid;
|
|
134
|
+
let dead = false;
|
|
135
|
+
try {
|
|
136
|
+
const text = await readFile(lockPath, "utf8");
|
|
137
|
+
const data = parseLockData(text);
|
|
138
|
+
if (data) {
|
|
139
|
+
ownerPid = data.pid;
|
|
140
|
+
dead = !isPidAlive(data.pid);
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
return { stale: true, reason: "unreadable lock" };
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const fileStat = await stat(lockPath);
|
|
147
|
+
const expired = Date.now() - fileStat.mtimeMs > ttlMs;
|
|
148
|
+
if (dead) return { stale: true, ownerPid, reason: "dead owner" };
|
|
149
|
+
if (expired) return { stale: true, ownerPid, reason: "expired lock" };
|
|
150
|
+
return { stale: false, ownerPid, reason: "lock held" };
|
|
151
|
+
} catch {
|
|
152
|
+
return { stale: true, ownerPid, reason: "missing lock" };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function acquireLock(opts) {
|
|
156
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
157
|
+
const pid = opts.pid ?? process.pid;
|
|
158
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
159
|
+
try {
|
|
160
|
+
return { acquired: true, handle: await createLock(opts.lockPath, pid) };
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (!(err instanceof Error) || !hasCode(err, "EEXIST")) {
|
|
163
|
+
return { acquired: false, reason: err instanceof Error ? err.message : String(err) };
|
|
164
|
+
}
|
|
165
|
+
const existing = await inspectExisting(opts.lockPath, ttlMs);
|
|
166
|
+
if (!existing.stale || attempt === 1) {
|
|
167
|
+
return { acquired: false, reason: existing.reason, ownerPid: existing.ownerPid };
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
await unlink(opts.lockPath);
|
|
171
|
+
} catch {
|
|
172
|
+
return { acquired: false, reason: "failed to remove stale lock", ownerPid: existing.ownerPid };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { acquired: false, reason: "lock acquisition failed" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/lib/state-store.ts
|
|
180
|
+
import { mkdir, readFile as readFile2, rename, writeFile } from "fs/promises";
|
|
181
|
+
import { homedir } from "os";
|
|
182
|
+
import { dirname, join } from "path";
|
|
183
|
+
function hasCode2(err, code) {
|
|
184
|
+
return "code" in err && err.code === code;
|
|
185
|
+
}
|
|
186
|
+
function parseState(text) {
|
|
187
|
+
const parsed = JSON.parse(text);
|
|
188
|
+
const state = {};
|
|
189
|
+
if (typeof parsed.chatId === "number") state.chatId = parsed.chatId;
|
|
190
|
+
if (typeof parsed.updatedAt === "string") state.updatedAt = parsed.updatedAt;
|
|
191
|
+
if (typeof parsed.discoveredBy === "number") state.discoveredBy = parsed.discoveredBy;
|
|
192
|
+
return state;
|
|
193
|
+
}
|
|
194
|
+
function createStateStore(opts = {}) {
|
|
195
|
+
const filePath = opts.filePath ?? join(homedir(), ".config/opencode/telegram-remote/state.json");
|
|
196
|
+
return {
|
|
197
|
+
async read() {
|
|
198
|
+
try {
|
|
199
|
+
return parseState(await readFile2(filePath, "utf8"));
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err instanceof Error && hasCode2(err, "ENOENT")) return {};
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
async write(patch) {
|
|
206
|
+
const existing = await this.read();
|
|
207
|
+
const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
208
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
209
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
210
|
+
await writeFile(tmpPath, JSON.stringify(next, null, 2), "utf8");
|
|
211
|
+
try {
|
|
212
|
+
await rename(tmpPath, filePath);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (!(err instanceof Error) || !hasCode2(err, "ENOENT")) throw err;
|
|
215
|
+
await writeFile(tmpPath, JSON.stringify(next, null, 2), "utf8");
|
|
216
|
+
await rename(tmpPath, filePath);
|
|
217
|
+
}
|
|
218
|
+
return next;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/lib/pending-questions.ts
|
|
224
|
+
import { createHash } from "crypto";
|
|
225
|
+
import { mkdir as mkdir2, readFile as readFile3, readdir, rename as rename2, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
|
|
226
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
227
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
228
|
+
function hasCode3(err, code) {
|
|
229
|
+
return "code" in err && err.code === code;
|
|
230
|
+
}
|
|
231
|
+
function pendingFilePath(dir, shortHash) {
|
|
232
|
+
return join2(dir, `${shortHash}.json`);
|
|
233
|
+
}
|
|
234
|
+
function parsePending(text) {
|
|
235
|
+
const parsed = JSON.parse(text);
|
|
236
|
+
if (typeof parsed.requestID !== "string") throw new Error("Invalid pending question: requestID");
|
|
237
|
+
if (typeof parsed.sessionID !== "string") throw new Error("Invalid pending question: sessionID");
|
|
238
|
+
if (!Array.isArray(parsed.questions)) throw new Error("Invalid pending question: questions");
|
|
239
|
+
if (!Array.isArray(parsed.telegramMessageIds)) throw new Error("Invalid pending question: telegramMessageIds");
|
|
240
|
+
if (!Array.isArray(parsed.answersInProgress)) throw new Error("Invalid pending question: answersInProgress");
|
|
241
|
+
return parsed;
|
|
242
|
+
}
|
|
243
|
+
async function listPendingFiles(dir) {
|
|
244
|
+
try {
|
|
245
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
246
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (err instanceof Error && hasCode3(err, "ENOENT")) return [];
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function shortHashFromFileName(fileName) {
|
|
253
|
+
return fileName.slice(0, -".json".length);
|
|
254
|
+
}
|
|
255
|
+
function createPendingQuestionStore(opts) {
|
|
256
|
+
const dir = opts.baseDir ?? join2(tmpdir2(), `opencoder-telegram-pending-questions-${opts.tokenHash}`);
|
|
257
|
+
return {
|
|
258
|
+
dir,
|
|
259
|
+
async savePending(shortHash, data) {
|
|
260
|
+
const filePath = pendingFilePath(dir, shortHash);
|
|
261
|
+
await mkdir2(dirname2(filePath), { recursive: true });
|
|
262
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
263
|
+
await writeFile2(tmpPath, JSON.stringify(data, null, 2), "utf8");
|
|
264
|
+
await rename2(tmpPath, filePath);
|
|
265
|
+
},
|
|
266
|
+
async loadPending(shortHash) {
|
|
267
|
+
try {
|
|
268
|
+
return parsePending(await readFile3(pendingFilePath(dir, shortHash), "utf8"));
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (err instanceof Error && hasCode3(err, "ENOENT")) return void 0;
|
|
271
|
+
throw err;
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
async deletePending(shortHash) {
|
|
275
|
+
try {
|
|
276
|
+
await unlink2(pendingFilePath(dir, shortHash));
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (!(err instanceof Error) || !hasCode3(err, "ENOENT")) throw err;
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
async sweepExpired() {
|
|
282
|
+
const expired = [];
|
|
283
|
+
for (const fileName of await listPendingFiles(dir)) {
|
|
284
|
+
const shortHash = shortHashFromFileName(fileName);
|
|
285
|
+
const data = await this.loadPending(shortHash);
|
|
286
|
+
if (data && data.expiresAt < Date.now()) {
|
|
287
|
+
expired.push(data);
|
|
288
|
+
await this.deletePending(shortHash);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return expired;
|
|
292
|
+
},
|
|
293
|
+
async findByRequestID(requestID) {
|
|
294
|
+
for (const fileName of await listPendingFiles(dir)) {
|
|
295
|
+
const shortHash = shortHashFromFileName(fileName);
|
|
296
|
+
const data = await this.loadPending(shortHash);
|
|
297
|
+
if (data?.requestID === requestID) return { shortHash, data };
|
|
298
|
+
}
|
|
299
|
+
return void 0;
|
|
300
|
+
},
|
|
301
|
+
async findAwaitingCustom(chatId, userId) {
|
|
302
|
+
for (const fileName of await listPendingFiles(dir)) {
|
|
303
|
+
const shortHash = shortHashFromFileName(fileName);
|
|
304
|
+
const data = await this.loadPending(shortHash);
|
|
305
|
+
const awaiting = data?.awaitingCustomFor;
|
|
306
|
+
if (awaiting && awaiting.chatId === chatId && awaiting.userId === userId) return { shortHash, data };
|
|
307
|
+
}
|
|
308
|
+
return void 0;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function createQuestionShortHash(requestID) {
|
|
313
|
+
return createHash("sha256").update(requestID).digest("base64url").slice(0, 10);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/lib/env-loader.ts
|
|
317
|
+
import { existsSync } from "fs";
|
|
318
|
+
import { homedir as homedir2 } from "os";
|
|
319
|
+
import { join as join3 } from "path";
|
|
320
|
+
import dotenv from "dotenv";
|
|
321
|
+
function loadPluginEnv(opts) {
|
|
322
|
+
const paths = [
|
|
323
|
+
join3(opts.pluginDir, "../../.env"),
|
|
324
|
+
join3(opts.pluginDir, "..", ".env"),
|
|
325
|
+
join3(opts.pluginDir, ".env"),
|
|
326
|
+
join3(homedir2(), ".config/opencode/telegram-remote/.env")
|
|
327
|
+
];
|
|
328
|
+
const loadedFrom = [];
|
|
329
|
+
const values = {};
|
|
330
|
+
for (const envPath of paths) {
|
|
331
|
+
if (!existsSync(envPath)) continue;
|
|
332
|
+
const result = dotenv.config({ path: envPath, override: false });
|
|
333
|
+
if (result.parsed) {
|
|
334
|
+
loadedFrom.push(envPath);
|
|
335
|
+
for (const [key, value] of Object.entries(result.parsed)) {
|
|
336
|
+
if (!(key in values)) values[key] = value;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { loadedFrom, values };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// src/config.ts
|
|
344
|
+
function parseAllowedUserIds(value) {
|
|
345
|
+
if (!value || value.trim() === "") {
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
return value.split(",").map((id2) => id2.trim()).filter((id2) => id2 !== "").map((id2) => Number.parseInt(id2, 10)).filter((id2) => !Number.isNaN(id2));
|
|
349
|
+
}
|
|
350
|
+
function loadConfig(opts) {
|
|
351
|
+
const { logger, env } = opts;
|
|
352
|
+
const botToken = env.TELEGRAM_BOT_TOKEN;
|
|
353
|
+
const allowedUserIdsStr = env.TELEGRAM_ALLOWED_USER_IDS;
|
|
354
|
+
const chatIdStr = env.TELEGRAM_CHAT_ID;
|
|
355
|
+
if (!botToken || botToken.trim() === "") {
|
|
356
|
+
logger.error("missing TELEGRAM_BOT_TOKEN");
|
|
357
|
+
throw new Error("Missing required environment variable: TELEGRAM_BOT_TOKEN");
|
|
358
|
+
}
|
|
359
|
+
const allowedUserIds = parseAllowedUserIds(allowedUserIdsStr);
|
|
360
|
+
if (allowedUserIds.length === 0) {
|
|
361
|
+
logger.error("missing or invalid TELEGRAM_ALLOWED_USER_IDS");
|
|
362
|
+
throw new Error("Missing or invalid TELEGRAM_ALLOWED_USER_IDS");
|
|
363
|
+
}
|
|
364
|
+
let chatId;
|
|
365
|
+
if (chatIdStr && chatIdStr.trim() !== "") {
|
|
366
|
+
const parsed = Number.parseInt(chatIdStr.trim(), 10);
|
|
367
|
+
if (!Number.isNaN(parsed)) {
|
|
368
|
+
chatId = parsed;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
logger.info("config loaded", { allowedUserCount: allowedUserIds.length, hasChatId: chatId !== void 0 });
|
|
372
|
+
return {
|
|
373
|
+
botToken,
|
|
374
|
+
allowedUserIds,
|
|
375
|
+
chatId
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/bot.ts
|
|
380
|
+
import { Bot, GrammyError } from "grammy";
|
|
381
|
+
function createTelegramBot(opts) {
|
|
382
|
+
const { config, stateStore, logger, polling } = opts;
|
|
383
|
+
const bot = new Bot(config.botToken);
|
|
384
|
+
let activeChatId = opts.initialChatId;
|
|
385
|
+
let questionDispatcher;
|
|
386
|
+
if (polling) {
|
|
387
|
+
bot.use(async (ctx, next) => {
|
|
388
|
+
const userId = ctx.from?.id;
|
|
389
|
+
if (!userId || !config.allowedUserIds.includes(userId)) {
|
|
390
|
+
logger.warn("unauthorized access attempt", { userId });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (ctx.chat?.type !== "private") return;
|
|
394
|
+
if (ctx.chat?.id) {
|
|
395
|
+
const newChatId = ctx.chat.id;
|
|
396
|
+
if (activeChatId !== newChatId) {
|
|
397
|
+
activeChatId = newChatId;
|
|
398
|
+
await stateStore.write({ chatId: newChatId, discoveredBy: process.pid });
|
|
399
|
+
logger.info("chat_id discovered", { chatId: newChatId });
|
|
400
|
+
await ctx.reply(`\u2705 Chat connected!
|
|
401
|
+
|
|
402
|
+
Your chat_id: ${newChatId}
|
|
403
|
+
|
|
404
|
+
This chat is now active for OpenCode notifications.`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
await next();
|
|
408
|
+
});
|
|
409
|
+
bot.catch((err) => {
|
|
410
|
+
const e = err.error;
|
|
411
|
+
if (e instanceof GrammyError && e.error_code === 409) {
|
|
412
|
+
logger.info("polling conflict (409) - another process took over", { description: e.description });
|
|
413
|
+
} else {
|
|
414
|
+
logger.error("bot error", { error: String(e) });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c)$/, async (ctx) => {
|
|
418
|
+
await ctx.answerCallbackQuery();
|
|
419
|
+
const data = ctx.callbackQuery.data;
|
|
420
|
+
const messageId = ctx.callbackQuery.message?.message_id;
|
|
421
|
+
const chatId = ctx.chat?.id;
|
|
422
|
+
const userId = ctx.from?.id;
|
|
423
|
+
if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0) return;
|
|
424
|
+
await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
|
|
425
|
+
});
|
|
426
|
+
bot.on("message:text", async (ctx) => {
|
|
427
|
+
const replyToMessageId = ctx.message.reply_to_message?.message_id;
|
|
428
|
+
const chatId = ctx.chat.id;
|
|
429
|
+
const userId = ctx.from?.id;
|
|
430
|
+
if (!questionDispatcher || replyToMessageId === void 0 || userId === void 0) return;
|
|
431
|
+
await questionDispatcher.handleTextReply(ctx.message.text, chatId, userId, replyToMessageId);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
const requireChatId = async (action) => {
|
|
435
|
+
if (activeChatId) return activeChatId;
|
|
436
|
+
const state = await stateStore.read();
|
|
437
|
+
if (state.chatId) {
|
|
438
|
+
activeChatId = state.chatId;
|
|
439
|
+
return state.chatId;
|
|
440
|
+
}
|
|
441
|
+
throw new Error(`No active chat for ${action}. Send any message to the bot first.`);
|
|
442
|
+
};
|
|
443
|
+
return {
|
|
444
|
+
async start() {
|
|
445
|
+
if (!polling) {
|
|
446
|
+
logger.info("pass-through mode - skipping bot.start()");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
await bot.start({
|
|
450
|
+
drop_pending_updates: true,
|
|
451
|
+
onStart: () => {
|
|
452
|
+
logger.info("polling started");
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
},
|
|
456
|
+
async stop() {
|
|
457
|
+
if (polling) {
|
|
458
|
+
try {
|
|
459
|
+
await bot.stop();
|
|
460
|
+
} catch (err) {
|
|
461
|
+
logger.warn("bot.stop() error", { error: String(err) });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
async sendMessage(text, options) {
|
|
466
|
+
const chatId = await requireChatId("sendMessage");
|
|
467
|
+
const result = await bot.api.sendMessage(chatId, text, options);
|
|
468
|
+
return { message_id: result.message_id };
|
|
469
|
+
},
|
|
470
|
+
async sendQuestionWithKeyboard(question, callbackData) {
|
|
471
|
+
const inlineKeyboard = question.options.map((option, index) => [{
|
|
472
|
+
text: option.label,
|
|
473
|
+
callback_data: callbackData[index] ?? ""
|
|
474
|
+
}]);
|
|
475
|
+
if (callbackData[question.options.length]) {
|
|
476
|
+
inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: callbackData[question.options.length] }]);
|
|
477
|
+
}
|
|
478
|
+
const header = question.header ? `\u2753 ${question.header}` : "\u2753 Question";
|
|
479
|
+
return this.sendMessage(`${header}
|
|
480
|
+
|
|
481
|
+
${question.question}`, { reply_markup: { inline_keyboard: inlineKeyboard } });
|
|
482
|
+
},
|
|
483
|
+
async editMessage(messageId, text) {
|
|
484
|
+
const chatId = await requireChatId("editMessage");
|
|
485
|
+
await bot.api.editMessageText(chatId, messageId, text);
|
|
486
|
+
},
|
|
487
|
+
async editMessageText(messageId, text, options) {
|
|
488
|
+
const chatId = await requireChatId("editMessageText");
|
|
489
|
+
await bot.api.editMessageText(chatId, messageId, text, options);
|
|
490
|
+
},
|
|
491
|
+
async editMessageRemoveKeyboard(messageId, finalText) {
|
|
492
|
+
await this.editMessageText(messageId, finalText, { reply_markup: { inline_keyboard: [] } });
|
|
493
|
+
},
|
|
494
|
+
async replyWithForceReply(text, placeholder) {
|
|
495
|
+
return this.sendMessage(text, {
|
|
496
|
+
reply_markup: {
|
|
497
|
+
force_reply: true,
|
|
498
|
+
input_field_placeholder: placeholder
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
},
|
|
502
|
+
async deleteMessage(messageId) {
|
|
503
|
+
const chatId = await requireChatId("deleteMessage");
|
|
504
|
+
await bot.api.deleteMessage(chatId, messageId);
|
|
505
|
+
},
|
|
506
|
+
async getActiveChatId() {
|
|
507
|
+
if (activeChatId) return activeChatId;
|
|
508
|
+
const state = await stateStore.read();
|
|
509
|
+
return state.chatId;
|
|
510
|
+
},
|
|
511
|
+
setQuestionDispatcher(dispatcher) {
|
|
512
|
+
questionDispatcher = dispatcher;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/services/session-title-service.ts
|
|
518
|
+
var SessionTitleService = class {
|
|
519
|
+
sessions = /* @__PURE__ */ new Map();
|
|
520
|
+
setSessionInfo(info) {
|
|
521
|
+
const existing = this.sessions.get(info.id);
|
|
522
|
+
this.sessions.set(info.id, {
|
|
523
|
+
title: info.title || null,
|
|
524
|
+
parentID: info.parentID ?? null,
|
|
525
|
+
status: existing?.status,
|
|
526
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
setSessionTitle(sessionId, title) {
|
|
530
|
+
const existing = this.sessions.get(sessionId);
|
|
531
|
+
this.sessions.set(sessionId, {
|
|
532
|
+
title,
|
|
533
|
+
parentID: existing?.parentID ?? null,
|
|
534
|
+
status: existing?.status,
|
|
535
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
setSessionStatus(sessionId, status) {
|
|
539
|
+
const existing = this.sessions.get(sessionId);
|
|
540
|
+
this.sessions.set(sessionId, {
|
|
541
|
+
title: existing?.title ?? null,
|
|
542
|
+
parentID: existing?.parentID ?? null,
|
|
543
|
+
status,
|
|
544
|
+
idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
getSessionTitle(sessionId) {
|
|
548
|
+
return this.sessions.get(sessionId)?.title ?? null;
|
|
549
|
+
}
|
|
550
|
+
getParentID(sessionId) {
|
|
551
|
+
return this.sessions.get(sessionId)?.parentID;
|
|
552
|
+
}
|
|
553
|
+
getSessionStatus(sessionId) {
|
|
554
|
+
return this.sessions.get(sessionId)?.status;
|
|
555
|
+
}
|
|
556
|
+
hasUnfinishedDescendants(parentID) {
|
|
557
|
+
for (const [sessionID, session] of this.sessions.entries()) {
|
|
558
|
+
if (session.parentID !== parentID) continue;
|
|
559
|
+
if (session.status !== "idle") return true;
|
|
560
|
+
if (this.hasUnfinishedDescendants(sessionID)) return true;
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
deferIdleNotification(sessionId) {
|
|
565
|
+
const existing = this.sessions.get(sessionId);
|
|
566
|
+
this.sessions.set(sessionId, {
|
|
567
|
+
title: existing?.title ?? null,
|
|
568
|
+
parentID: existing?.parentID ?? null,
|
|
569
|
+
status: existing?.status ?? "idle",
|
|
570
|
+
idleNotificationPending: true
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
hasDeferredIdleNotification(sessionId) {
|
|
574
|
+
return this.sessions.get(sessionId)?.idleNotificationPending ?? false;
|
|
575
|
+
}
|
|
576
|
+
clearDeferredIdleNotification(sessionId) {
|
|
577
|
+
const existing = this.sessions.get(sessionId);
|
|
578
|
+
if (!existing) return;
|
|
579
|
+
this.sessions.set(sessionId, {
|
|
580
|
+
...existing,
|
|
581
|
+
idleNotificationPending: false
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// src/lib/claim.ts
|
|
587
|
+
import { mkdir as mkdir3, open as open2, readdir as readdir2, stat as stat2, unlink as unlink3 } from "fs/promises";
|
|
588
|
+
import { join as join4 } from "path";
|
|
589
|
+
import { createHash as createHash2 } from "crypto";
|
|
590
|
+
var DEFAULT_TTL_MS2 = 6e4;
|
|
591
|
+
var sweptDirs = /* @__PURE__ */ new Set();
|
|
592
|
+
function hasCode4(err, code) {
|
|
593
|
+
return "code" in err && err.code === code;
|
|
594
|
+
}
|
|
595
|
+
function claimPath(claimsDir, key) {
|
|
596
|
+
const hash = createHash2("sha256").update(key).digest("hex").slice(0, 16);
|
|
597
|
+
return join4(claimsDir, `${hash}.claim`);
|
|
598
|
+
}
|
|
599
|
+
async function sweep(claimsDir, ttlMs) {
|
|
600
|
+
if (sweptDirs.has(claimsDir)) return;
|
|
601
|
+
sweptDirs.add(claimsDir);
|
|
602
|
+
try {
|
|
603
|
+
const entries = await readdir2(claimsDir, { withFileTypes: true });
|
|
604
|
+
await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".claim")).map(async (entry) => {
|
|
605
|
+
const filePath = join4(claimsDir, entry.name);
|
|
606
|
+
try {
|
|
607
|
+
const fileStat = await stat2(filePath);
|
|
608
|
+
if (Date.now() - fileStat.mtimeMs > ttlMs * 2) {
|
|
609
|
+
await unlink3(filePath);
|
|
610
|
+
}
|
|
611
|
+
} catch {
|
|
612
|
+
}
|
|
613
|
+
}));
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async function createClaim(filePath) {
|
|
618
|
+
const file = await open2(filePath, "wx");
|
|
619
|
+
try {
|
|
620
|
+
await file.writeFile((/* @__PURE__ */ new Date()).toISOString(), "utf8");
|
|
621
|
+
} finally {
|
|
622
|
+
await file.close();
|
|
623
|
+
}
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
async function claimOnce(opts) {
|
|
627
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS2;
|
|
628
|
+
await mkdir3(opts.claimsDir, { recursive: true });
|
|
629
|
+
await sweep(opts.claimsDir, ttlMs);
|
|
630
|
+
const filePath = claimPath(opts.claimsDir, opts.key);
|
|
631
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
632
|
+
try {
|
|
633
|
+
return await createClaim(filePath);
|
|
634
|
+
} catch (err) {
|
|
635
|
+
if (!(err instanceof Error) || !hasCode4(err, "EEXIST")) throw err;
|
|
636
|
+
try {
|
|
637
|
+
const fileStat = await stat2(filePath);
|
|
638
|
+
if (Date.now() - fileStat.mtimeMs <= ttlMs || attempt === 1) return false;
|
|
639
|
+
await unlink3(filePath);
|
|
640
|
+
} catch (statErr) {
|
|
641
|
+
if (statErr instanceof Error && hasCode4(statErr, "ENOENT")) continue;
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/lib/abort-tracker.ts
|
|
650
|
+
var ABORT_TTL_MS = 5e3;
|
|
651
|
+
var sessionAborts = /* @__PURE__ */ new Map();
|
|
652
|
+
var lastGlobalAbortAt = 0;
|
|
653
|
+
function noteAbort(sessionID) {
|
|
654
|
+
const now = Date.now();
|
|
655
|
+
if (sessionID) {
|
|
656
|
+
sessionAborts.set(sessionID, now);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
lastGlobalAbortAt = now;
|
|
660
|
+
}
|
|
661
|
+
function shouldSuppressIdle(sessionID) {
|
|
662
|
+
const now = Date.now();
|
|
663
|
+
const sessionAbortAt = sessionAborts.get(sessionID) ?? 0;
|
|
664
|
+
const abortAt = Math.max(sessionAbortAt, lastGlobalAbortAt);
|
|
665
|
+
if (abortAt === 0) return false;
|
|
666
|
+
if (now - abortAt <= ABORT_TTL_MS) {
|
|
667
|
+
sessionAborts.delete(sessionID);
|
|
668
|
+
if (abortAt === lastGlobalAbortAt) lastGlobalAbortAt = 0;
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
sessionAborts.delete(sessionID);
|
|
672
|
+
if (now - lastGlobalAbortAt > ABORT_TTL_MS) lastGlobalAbortAt = 0;
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// src/events/session-idle.ts
|
|
677
|
+
async function resolveParentID(sessionId, ctx) {
|
|
678
|
+
const cachedParentID = ctx.sessionTitleService.getParentID(sessionId);
|
|
679
|
+
if (cachedParentID !== void 0) return cachedParentID;
|
|
680
|
+
try {
|
|
681
|
+
const result = await ctx.client.session.get({ path: { id: sessionId } });
|
|
682
|
+
if (result.data) {
|
|
683
|
+
ctx.sessionTitleService.setSessionInfo(result.data);
|
|
684
|
+
return ctx.sessionTitleService.getParentID(sessionId);
|
|
685
|
+
}
|
|
686
|
+
ctx.logger.warn("session parentID cache miss fetch returned no data", { sessionId });
|
|
687
|
+
return void 0;
|
|
688
|
+
} catch (err) {
|
|
689
|
+
ctx.logger.warn("session parentID cache miss fetch failed", { sessionId, error: String(err) });
|
|
690
|
+
return void 0;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
async function sendIdleNotification(sessionId, ctx) {
|
|
694
|
+
if (shouldSuppressIdle(sessionId)) {
|
|
695
|
+
ctx.logger.info("idle suppressed - session was aborted", { sessionId });
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `session.idle:${sessionId}`, ttlMs: 5e3 });
|
|
699
|
+
if (!claimed) return;
|
|
700
|
+
const title = ctx.sessionTitleService.getSessionTitle(sessionId);
|
|
701
|
+
const message = title ? `Agent has finished: ${title}` : "Agent has finished.";
|
|
702
|
+
try {
|
|
703
|
+
await ctx.bot.sendMessage(message);
|
|
704
|
+
ctx.sessionTitleService.clearDeferredIdleNotification(sessionId);
|
|
705
|
+
ctx.logger.info("idle notification sent", { sessionId, title });
|
|
706
|
+
} catch (err) {
|
|
707
|
+
ctx.logger.error("failed to send idle notification", { error: String(err) });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async function flushDeferredParentIfReady(parentID, ctx) {
|
|
711
|
+
if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
|
|
712
|
+
if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
|
|
713
|
+
ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
|
|
714
|
+
await sendIdleNotification(parentID, ctx);
|
|
715
|
+
}
|
|
716
|
+
async function handleSessionIdle(event, ctx) {
|
|
717
|
+
const sessionId = event.properties.sessionID;
|
|
718
|
+
ctx.sessionTitleService.setSessionStatus(sessionId, "idle");
|
|
719
|
+
const parentID = await resolveParentID(sessionId, ctx);
|
|
720
|
+
if (typeof parentID === "string") {
|
|
721
|
+
ctx.logger.info("suppressing child session idle notification", { sessionId, parentID });
|
|
722
|
+
await flushDeferredParentIfReady(parentID, ctx);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (parentID === void 0) {
|
|
726
|
+
ctx.logger.warn("session parentID unknown; sending idle notification", { sessionId });
|
|
727
|
+
}
|
|
728
|
+
if (ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) {
|
|
729
|
+
ctx.sessionTitleService.deferIdleNotification(sessionId);
|
|
730
|
+
ctx.logger.info("deferring parent idle notification - child sessions still running", { sessionId });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
await sendIdleNotification(sessionId, ctx);
|
|
734
|
+
}
|
|
735
|
+
async function handleSessionStatus(event, ctx) {
|
|
736
|
+
const sessionId = event.properties.sessionID;
|
|
737
|
+
const statusType = event.properties.status.type;
|
|
738
|
+
ctx.sessionTitleService.setSessionStatus(sessionId, statusType);
|
|
739
|
+
if (statusType === "idle") {
|
|
740
|
+
await handleSessionIdle(event, ctx);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/events/session-error.ts
|
|
745
|
+
function isEventSessionError(event) {
|
|
746
|
+
return event.type === "session.error";
|
|
747
|
+
}
|
|
748
|
+
async function handleSessionError(event, ctx) {
|
|
749
|
+
if (event.properties.error?.name !== "MessageAbortedError") return;
|
|
750
|
+
noteAbort(event.properties.sessionID);
|
|
751
|
+
ctx.logger.info("session abort recorded", { sessionId: event.properties.sessionID ?? "global" });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/events/session-created.ts
|
|
755
|
+
async function handleSessionCreated(event, ctx) {
|
|
756
|
+
ctx.sessionTitleService.setSessionInfo(event.properties.info);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// src/events/session-updated.ts
|
|
760
|
+
async function handleSessionUpdated(event, ctx) {
|
|
761
|
+
const info = event.properties.info;
|
|
762
|
+
ctx.sessionTitleService.setSessionInfo(info);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/events/permission-updated.ts
|
|
766
|
+
async function handlePermissionUpdated(event, ctx) {
|
|
767
|
+
const permission = event.properties;
|
|
768
|
+
const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `permission.updated:${permission.id}` });
|
|
769
|
+
if (!claimed) return;
|
|
770
|
+
const sessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
|
|
771
|
+
const titleLine = sessionTitle ? `\u{1F4CB} ${sessionTitle}` : `Session: ${permission.sessionID}`;
|
|
772
|
+
const message = `\u2753 Permission requested
|
|
773
|
+
|
|
774
|
+
${titleLine}
|
|
775
|
+
|
|
776
|
+
Type: ${permission.type}
|
|
777
|
+
Detail: ${permission.title}`;
|
|
778
|
+
try {
|
|
779
|
+
await ctx.bot.sendMessage(message);
|
|
780
|
+
} catch (err) {
|
|
781
|
+
ctx.logger.error("failed to send permission notification", { error: String(err) });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/events/question-asked.ts
|
|
786
|
+
var QUESTION_EXPIRY_MS = 5 * 6e4;
|
|
787
|
+
var CALLBACK_RE = /^q:([^:]+):(\d+):(\d+|c)$/;
|
|
788
|
+
function isQuestionOption(value) {
|
|
789
|
+
return typeof value.label === "string" && typeof value.description === "string";
|
|
790
|
+
}
|
|
791
|
+
function isQuestionInfo(value) {
|
|
792
|
+
if (typeof value.question !== "string") return false;
|
|
793
|
+
if (typeof value.header !== "string") return false;
|
|
794
|
+
if (!Array.isArray(value.options)) return false;
|
|
795
|
+
return value.options.every((option) => typeof option === "object" && option !== null && isQuestionOption(option));
|
|
796
|
+
}
|
|
797
|
+
function isEventQuestionAsked(event) {
|
|
798
|
+
if (event.type !== "question.asked") return false;
|
|
799
|
+
const props = event.properties;
|
|
800
|
+
if (!props) return false;
|
|
801
|
+
if (typeof props.id !== "string") return false;
|
|
802
|
+
if (typeof props.sessionID !== "string") return false;
|
|
803
|
+
if (!Array.isArray(props.questions)) return false;
|
|
804
|
+
return props.questions.every((question) => typeof question === "object" && question !== null && isQuestionInfo(question));
|
|
805
|
+
}
|
|
806
|
+
function buildCallbackData(shortHash, questionIndex, optionIndex) {
|
|
807
|
+
const data = `q:${shortHash}:${questionIndex}:${optionIndex}`;
|
|
808
|
+
if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
|
|
809
|
+
return data;
|
|
810
|
+
}
|
|
811
|
+
function callbackDataForQuestion(shortHash, questionIndex, question) {
|
|
812
|
+
const data = question.options.map((_, optionIndex) => buildCallbackData(shortHash, questionIndex, optionIndex));
|
|
813
|
+
if (question.custom !== false) data.push(buildCallbackData(shortHash, questionIndex, "c"));
|
|
814
|
+
return data;
|
|
815
|
+
}
|
|
816
|
+
function questionPromptText(pending, questionIndex) {
|
|
817
|
+
const question = pending.questions[questionIndex];
|
|
818
|
+
const prefix = pending.questions.length > 1 ? `Question ${questionIndex + 1}/${pending.questions.length}
|
|
819
|
+
|
|
820
|
+
` : "";
|
|
821
|
+
const allQuestions = pending.questions.length > 1 ? `All questions:
|
|
822
|
+
${pending.questions.map((q, i) => `${i + 1}. ${q.header}: ${q.question}`).join("\n")}
|
|
823
|
+
|
|
824
|
+
` : "";
|
|
825
|
+
return `${allQuestions}${prefix}\u2753 ${question.header}
|
|
826
|
+
|
|
827
|
+
${question.question}`;
|
|
828
|
+
}
|
|
829
|
+
function answerSummary(questions, answers) {
|
|
830
|
+
return answers.map((answer, index) => `${index + 1}. ${questions[index]?.header ?? "Question"}: ${answer.join(", ") || "(empty)"}`).join("\n");
|
|
831
|
+
}
|
|
832
|
+
async function editPromptForQuestion(ctx, pending, shortHash, questionIndex) {
|
|
833
|
+
const messageId = pending.telegramMessageIds[0];
|
|
834
|
+
const question = pending.questions[questionIndex];
|
|
835
|
+
const inlineKeyboard = question.options.map((option, optionIndex) => [{
|
|
836
|
+
text: option.label,
|
|
837
|
+
callback_data: buildCallbackData(shortHash, questionIndex, optionIndex)
|
|
838
|
+
}]);
|
|
839
|
+
if (question.custom !== false) {
|
|
840
|
+
inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData(shortHash, questionIndex, "c") }]);
|
|
841
|
+
}
|
|
842
|
+
await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), { reply_markup: { inline_keyboard: inlineKeyboard } });
|
|
843
|
+
}
|
|
844
|
+
async function completeIfReady(ctx, pending, shortHash) {
|
|
845
|
+
const nextIndex = pending.answersInProgress.findIndex((answer) => answer === void 0);
|
|
846
|
+
if (nextIndex >= 0) {
|
|
847
|
+
pending.currentQuestionIndex = nextIndex;
|
|
848
|
+
await ctx.pendingQuestions.savePending(shortHash, pending);
|
|
849
|
+
await editPromptForQuestion(ctx, pending, shortHash, nextIndex);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const answers = pending.answersInProgress.map((answer) => answer ?? []);
|
|
853
|
+
const messageId = pending.telegramMessageIds[0];
|
|
854
|
+
try {
|
|
855
|
+
await ctx.replyToQuestion(pending.requestID, answers);
|
|
856
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Answered:
|
|
857
|
+
${answerSummary(pending.questions, answers)}`);
|
|
858
|
+
ctx.logger.info("question reply sent", { requestID: pending.requestID, sessionID: pending.sessionID });
|
|
859
|
+
} catch (err) {
|
|
860
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send answer to opencode");
|
|
861
|
+
ctx.logger.error("failed to send question reply", { error: String(err), requestID: pending.requestID });
|
|
862
|
+
} finally {
|
|
863
|
+
await ctx.pendingQuestions.deletePending(shortHash);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
async function expirePending(ctx, shortHash, pending, messageId) {
|
|
867
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
|
|
868
|
+
await ctx.pendingQuestions.deletePending(shortHash);
|
|
869
|
+
ctx.logger.info("pending question expired", { requestID: pending.requestID });
|
|
870
|
+
}
|
|
871
|
+
async function handleQuestionAsked(event, ctx) {
|
|
872
|
+
const request = event.properties;
|
|
873
|
+
if (request.questions.length === 0) return;
|
|
874
|
+
const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `question.asked:${request.id}`, ttlMs: 5e3 });
|
|
875
|
+
if (!claimed) return;
|
|
876
|
+
const shortHash = createQuestionShortHash(request.id);
|
|
877
|
+
const firstQuestion = request.questions[0];
|
|
878
|
+
const sentAt = Date.now();
|
|
879
|
+
const pending = {
|
|
880
|
+
requestID: request.id,
|
|
881
|
+
sessionID: request.sessionID,
|
|
882
|
+
questions: request.questions,
|
|
883
|
+
sentAt,
|
|
884
|
+
expiresAt: sentAt + QUESTION_EXPIRY_MS,
|
|
885
|
+
telegramMessageIds: [],
|
|
886
|
+
currentQuestionIndex: 0,
|
|
887
|
+
answersInProgress: request.questions.map(() => void 0)
|
|
888
|
+
};
|
|
889
|
+
try {
|
|
890
|
+
const message = request.questions.length === 1 ? await ctx.bot.sendQuestionWithKeyboard(firstQuestion, callbackDataForQuestion(shortHash, 0, firstQuestion)) : await ctx.bot.sendMessage(questionPromptText(pending, 0), {
|
|
891
|
+
reply_markup: {
|
|
892
|
+
inline_keyboard: firstQuestion.options.map((option, optionIndex) => [{
|
|
893
|
+
text: option.label,
|
|
894
|
+
callback_data: buildCallbackData(shortHash, 0, optionIndex)
|
|
895
|
+
}]).concat(firstQuestion.custom !== false ? [[{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData(shortHash, 0, "c") }]] : [])
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
pending.telegramMessageIds = [message.message_id];
|
|
899
|
+
await ctx.pendingQuestions.savePending(shortHash, pending);
|
|
900
|
+
ctx.logger.info("question prompt sent", { requestID: request.id, sessionID: request.sessionID, count: request.questions.length });
|
|
901
|
+
} catch (err) {
|
|
902
|
+
ctx.logger.error("failed to send question prompt", { error: String(err), requestID: request.id });
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function createQuestionDispatcher(ctx) {
|
|
906
|
+
return {
|
|
907
|
+
async handleCallbackQuery(data, messageId, chatId, userId) {
|
|
908
|
+
const match = CALLBACK_RE.exec(data);
|
|
909
|
+
if (!match) return;
|
|
910
|
+
const shortHash = match[1];
|
|
911
|
+
const questionIndex = Number(match[2]);
|
|
912
|
+
const selection = match[3];
|
|
913
|
+
const pending = await ctx.pendingQuestions.loadPending(shortHash);
|
|
914
|
+
if (!pending) {
|
|
915
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "This question has expired.");
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (pending.expiresAt < Date.now()) {
|
|
919
|
+
await expirePending(ctx, shortHash, pending, messageId);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const question = pending.questions[questionIndex];
|
|
923
|
+
if (!question) return;
|
|
924
|
+
if (selection === "c") {
|
|
925
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u270F\uFE0F Reply to the next message with your custom answer.");
|
|
926
|
+
const prompt = await ctx.bot.replyWithForceReply("Type your custom answer", "Type your answer");
|
|
927
|
+
pending.awaitingCustomFor = { shortHash, questionIndex, chatId, userId, promptMessageId: prompt.message_id };
|
|
928
|
+
await ctx.pendingQuestions.savePending(shortHash, pending);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const option = question.options[Number(selection)];
|
|
932
|
+
if (!option) return;
|
|
933
|
+
if (question.multiple === true) {
|
|
934
|
+
ctx.logger.info("multiple-choice question handled as single-select", { requestID: pending.requestID, questionIndex });
|
|
935
|
+
}
|
|
936
|
+
pending.answersInProgress[questionIndex] = [option.label];
|
|
937
|
+
pending.awaitingCustomFor = void 0;
|
|
938
|
+
await completeIfReady(ctx, pending, shortHash);
|
|
939
|
+
},
|
|
940
|
+
async handleTextReply(text, chatId, userId, replyToMessageId) {
|
|
941
|
+
const match = await ctx.pendingQuestions.findAwaitingCustom(chatId, userId);
|
|
942
|
+
if (!match) return;
|
|
943
|
+
const awaiting = match.data.awaitingCustomFor;
|
|
944
|
+
if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
|
|
945
|
+
if (match.data.expiresAt < Date.now()) {
|
|
946
|
+
await expirePending(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
match.data.answersInProgress[awaiting.questionIndex] = [text];
|
|
950
|
+
match.data.awaitingCustomFor = void 0;
|
|
951
|
+
await ctx.bot.sendMessage("\u2705 Custom answer sent.");
|
|
952
|
+
await completeIfReady(ctx, match.data, match.shortHash);
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/events/question-replied.ts
|
|
958
|
+
function isEventQuestionReplied(event) {
|
|
959
|
+
if (event.type !== "question.replied") return false;
|
|
960
|
+
const props = event.properties;
|
|
961
|
+
return Boolean(props && typeof props.requestID === "string" && typeof props.sessionID === "string");
|
|
962
|
+
}
|
|
963
|
+
async function handleQuestionReplied(event, ctx) {
|
|
964
|
+
const found = await ctx.pendingQuestions.findByRequestID(event.properties.requestID);
|
|
965
|
+
if (!found) return;
|
|
966
|
+
const messageId = found.data.telegramMessageIds[0];
|
|
967
|
+
try {
|
|
968
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u2705 Already answered in opencode.");
|
|
969
|
+
} catch (err) {
|
|
970
|
+
ctx.logger.error("failed to edit externally answered question", { error: String(err), requestID: event.properties.requestID });
|
|
971
|
+
} finally {
|
|
972
|
+
await ctx.pendingQuestions.deletePending(found.shortHash);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// src/telegram-remote.ts
|
|
977
|
+
var pluginDir = dirname3(fileURLToPath(import.meta.url));
|
|
978
|
+
var TelegramRemote = async (input) => {
|
|
979
|
+
const logger = createLogger({ namespace: "telegram" });
|
|
980
|
+
try {
|
|
981
|
+
const envResult = loadPluginEnv({ pluginDir });
|
|
982
|
+
logger.info("env loaded", { from: envResult.loadedFrom });
|
|
983
|
+
const config = loadConfig({ logger, env: process.env });
|
|
984
|
+
const stateStore = createStateStore();
|
|
985
|
+
const initialState = await stateStore.read();
|
|
986
|
+
const tokenHash = createHash3("sha256").update(config.botToken).digest("hex").slice(0, 16);
|
|
987
|
+
const lockPath = join5(tmpdir3(), `opencoder-telegram-${tokenHash}.lock`);
|
|
988
|
+
const claimsDir = join5(tmpdir3(), `opencoder-telegram-claims-${tokenHash}`);
|
|
989
|
+
const pendingQuestions = createPendingQuestionStore({ tokenHash });
|
|
990
|
+
const lockResult = await acquireLock({ lockPath });
|
|
991
|
+
const isLeader = lockResult.acquired;
|
|
992
|
+
logger.info(
|
|
993
|
+
`lock ${isLeader ? "acquired - leader mode" : "held by other - pass-through mode"}`,
|
|
994
|
+
isLeader ? {} : { reason: lockResult.reason }
|
|
995
|
+
);
|
|
996
|
+
logger.info("server url", { url: input.serverUrl.toString(), href: input.serverUrl.href, origin: input.serverUrl.origin });
|
|
997
|
+
const sessionTitleService = new SessionTitleService();
|
|
998
|
+
const client = input.client;
|
|
999
|
+
const replyToQuestion = async (requestID, answers) => {
|
|
1000
|
+
await client._client.post({
|
|
1001
|
+
url: `/question/${encodeURIComponent(requestID)}/reply`,
|
|
1002
|
+
headers: { "Content-Type": "application/json" },
|
|
1003
|
+
body: { answers },
|
|
1004
|
+
throwOnError: true
|
|
1005
|
+
});
|
|
1006
|
+
};
|
|
1007
|
+
const bot = createTelegramBot({
|
|
1008
|
+
config,
|
|
1009
|
+
stateStore,
|
|
1010
|
+
logger,
|
|
1011
|
+
initialChatId: initialState.chatId ?? config.chatId,
|
|
1012
|
+
polling: isLeader
|
|
1013
|
+
});
|
|
1014
|
+
if (isLeader) {
|
|
1015
|
+
bot.start().catch((err) => {
|
|
1016
|
+
logger.error("bot polling stopped", { error: String(err) });
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
const cleanup = async () => {
|
|
1020
|
+
try {
|
|
1021
|
+
await bot.stop();
|
|
1022
|
+
} catch {
|
|
1023
|
+
}
|
|
1024
|
+
if (lockResult.acquired) {
|
|
1025
|
+
await lockResult.handle.release();
|
|
1026
|
+
}
|
|
1027
|
+
await logger.close();
|
|
1028
|
+
};
|
|
1029
|
+
process.once("SIGINT", () => {
|
|
1030
|
+
void cleanup().then(() => process.exit(0));
|
|
1031
|
+
});
|
|
1032
|
+
process.once("SIGTERM", () => {
|
|
1033
|
+
void cleanup().then(() => process.exit(0));
|
|
1034
|
+
});
|
|
1035
|
+
process.once("beforeExit", () => {
|
|
1036
|
+
void cleanup();
|
|
1037
|
+
});
|
|
1038
|
+
const ctx = {
|
|
1039
|
+
client: input.client,
|
|
1040
|
+
bot,
|
|
1041
|
+
sessionTitleService,
|
|
1042
|
+
stateStore,
|
|
1043
|
+
config,
|
|
1044
|
+
logger,
|
|
1045
|
+
claimsDir,
|
|
1046
|
+
pluginDir,
|
|
1047
|
+
serverUrl: input.serverUrl,
|
|
1048
|
+
tokenHash,
|
|
1049
|
+
pendingQuestions,
|
|
1050
|
+
replyToQuestion
|
|
1051
|
+
};
|
|
1052
|
+
if (isLeader) {
|
|
1053
|
+
bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
event: async ({ event }) => {
|
|
1057
|
+
const extEvent = event;
|
|
1058
|
+
switch (event.type) {
|
|
1059
|
+
case "session.idle":
|
|
1060
|
+
return handleSessionIdle(event, ctx);
|
|
1061
|
+
case "session.status":
|
|
1062
|
+
logger.info("session.status received", { statusType: event.properties.status.type });
|
|
1063
|
+
return handleSessionStatus(event, ctx);
|
|
1064
|
+
case "session.created":
|
|
1065
|
+
return handleSessionCreated(event, ctx);
|
|
1066
|
+
case "session.updated":
|
|
1067
|
+
return handleSessionUpdated(event, ctx);
|
|
1068
|
+
case "permission.updated":
|
|
1069
|
+
return handlePermissionUpdated(event, ctx);
|
|
1070
|
+
default: {
|
|
1071
|
+
if (isEventSessionError(extEvent)) {
|
|
1072
|
+
return handleSessionError(extEvent, ctx);
|
|
1073
|
+
}
|
|
1074
|
+
if (isEventQuestionAsked(extEvent)) {
|
|
1075
|
+
if (!isLeader) return;
|
|
1076
|
+
return handleQuestionAsked(extEvent, ctx);
|
|
1077
|
+
}
|
|
1078
|
+
if (isEventQuestionReplied(extEvent)) {
|
|
1079
|
+
return handleQuestionReplied(extEvent, ctx);
|
|
1080
|
+
}
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
logger.error("plugin initialization failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1088
|
+
await logger.close();
|
|
1089
|
+
return { event: async () => {
|
|
1090
|
+
} };
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
var id = "opencoder-telegram-remote";
|
|
1094
|
+
var server = TelegramRemote;
|
|
1095
|
+
var telegram_remote_default = { id, server: TelegramRemote };
|
|
1096
|
+
export {
|
|
1097
|
+
TelegramRemote,
|
|
1098
|
+
telegram_remote_default as default,
|
|
1099
|
+
id,
|
|
1100
|
+
server
|
|
1101
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coinseeker/opencode-telegram-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Control and monitor OpenCode from Telegram with notifications, question replies, and subagent-aware completion.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/telegram-remote.js",
|
|
7
|
+
"types": "dist/telegram-remote.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/telegram-remote.js",
|
|
11
|
+
"types": "./dist/telegram-remote.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"build": "tsc --noEmit && tsup",
|
|
21
|
+
"dev": "tsup --watch",
|
|
22
|
+
"predev": "npm run build",
|
|
23
|
+
"prepack": "npm run build",
|
|
24
|
+
"prepublishOnly": "npm run typecheck && npm test",
|
|
25
|
+
"test": "tsx --test \"src/**/*.test.ts\""
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/coin-seeker/opencode-telegram-plugin.git",
|
|
30
|
+
"directory": "plugin"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/coin-seeker/opencode-telegram-plugin#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/coin-seeker/opencode-telegram-plugin/issues"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"opencode",
|
|
38
|
+
"opencode-plugin",
|
|
39
|
+
"telegram",
|
|
40
|
+
"notifications",
|
|
41
|
+
"remote-control"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"dotenv": "^16.4.7",
|
|
46
|
+
"grammy": "^1.34.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@opencode-ai/plugin": "1.15.7",
|
|
50
|
+
"@opencode-ai/sdk": "1.15.7",
|
|
51
|
+
"@types/node": "^22.10.5",
|
|
52
|
+
"tsup": "8.5.0",
|
|
53
|
+
"tsx": "^4.19.2",
|
|
54
|
+
"typescript": "5.8.3"
|
|
55
|
+
}
|
|
56
|
+
}
|