@coinseeker/opencode-telegram-plugin 1.0.5 → 1.0.7

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.
@@ -4,328 +4,318 @@
4
4
  */
5
5
 
6
6
  // src/telegram-remote.ts
7
- import { fileURLToPath } from "url";
8
- import { dirname as dirname4, join as join6 } from "path";
9
- import { tmpdir as tmpdir4 } from "os";
10
7
  import { createHash as createHash4 } from "crypto";
8
+ import { tmpdir as tmpdir4 } from "os";
9
+ import { dirname as dirname4, join as join6 } from "path";
10
+ import { fileURLToPath } from "url";
11
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
- }
12
+ // src/bot.ts
13
+ import { Bot, GrammyError } from "grammy";
14
+
15
+ // src/lib/question-format.ts
16
+ function optionDescriptionText(question) {
17
+ const options = question.options.map((option, index) => {
18
+ const description = option.description.trim();
19
+ return description ? `${index + 1}. ${option.label}
20
+ \uC124\uBA85: ${description}` : `${index + 1}. ${option.label}`;
21
+ });
22
+ return options.length > 0 ? `
23
+
24
+ Options:
25
+
26
+ ${options.join("\n\n")}` : "";
23
27
  }
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 {
28
+ function questionText(question, progress) {
29
+ const title = question.header || "Question";
30
+ const header = progress ? `\u2753 ${progress} \xB7 ${title}` : `\u2753 ${title}`;
31
+ return `${header}
32
+
33
+ ${question.question}${optionDescriptionText(question)}`;
34
+ }
35
+ function pendingQuestionText(questions, questionIndex) {
36
+ const question = questions[questionIndex];
37
+ const progress = questions.length > 1 ? `Question ${questionIndex + 1}/${questions.length}` : void 0;
38
+ return questionText(question, progress);
39
+ }
40
+
41
+ // src/bot.ts
42
+ function createTelegramBot(opts) {
43
+ const { config, stateStore, logger, polling } = opts;
44
+ const bot = new Bot(config.botToken);
45
+ let activeChatId = opts.initialChatId;
46
+ let questionDispatcher;
47
+ let permissionDispatcher;
48
+ let startWorkDispatcher;
49
+ if (polling) {
50
+ bot.use(async (ctx, next) => {
51
+ const userId = ctx.from?.id;
52
+ if (!userId || !config.allowedUserIds.includes(userId)) {
53
+ logger.warn("unauthorized access attempt", { userId });
54
+ return;
55
+ }
56
+ if (ctx.chat?.type !== "private") return;
57
+ if (ctx.chat?.id) {
58
+ const newChatId = ctx.chat.id;
59
+ if (activeChatId !== newChatId) {
60
+ activeChatId = newChatId;
61
+ await stateStore.write({ chatId: newChatId, discoveredBy: process.pid });
62
+ logger.info("chat_id discovered", { chatId: newChatId });
63
+ await ctx.reply(
64
+ `\u2705 Chat connected!
65
+
66
+ Your chat_id: ${newChatId}
67
+
68
+ This chat is now active for OpenCode notifications.`
69
+ );
70
+ }
44
71
  }
72
+ await next();
73
+ });
74
+ bot.catch((err) => {
75
+ const e = err.error;
76
+ if (e instanceof GrammyError && e.error_code === 409) {
77
+ logger.info("polling conflict (409) - another process took over", {
78
+ description: e.description
79
+ });
80
+ } else {
81
+ logger.error("bot error", { error: String(e) });
82
+ }
83
+ });
84
+ bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c|d)$/, async (ctx) => {
85
+ await ctx.answerCallbackQuery();
86
+ const data = ctx.callbackQuery.data;
87
+ const messageId = ctx.callbackQuery.message?.message_id;
88
+ const chatId = ctx.chat?.id;
89
+ const userId = ctx.from?.id;
90
+ if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0)
91
+ return;
92
+ await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
93
+ });
94
+ bot.callbackQuery(/^p:([^:]+):(o|a|r)$/, async (ctx) => {
95
+ await ctx.answerCallbackQuery();
96
+ const data = ctx.callbackQuery.data;
97
+ const messageId = ctx.callbackQuery.message?.message_id;
98
+ if (!permissionDispatcher || messageId === void 0) return;
99
+ await permissionDispatcher.handleCallbackQuery(data, messageId);
100
+ });
101
+ bot.callbackQuery(/^sw:(.+)$/, async (ctx) => {
102
+ await ctx.answerCallbackQuery();
103
+ const data = ctx.callbackQuery.data;
104
+ const messageId = ctx.callbackQuery.message?.message_id;
105
+ if (!startWorkDispatcher || messageId === void 0) return;
106
+ await startWorkDispatcher.handleCallbackQuery(data, messageId);
107
+ });
108
+ bot.on("message:text", async (ctx) => {
109
+ const replyToMessageId = ctx.message.reply_to_message?.message_id;
110
+ const chatId = ctx.chat.id;
111
+ const userId = ctx.from?.id;
112
+ if (!questionDispatcher || replyToMessageId === void 0 || userId === void 0) return;
113
+ await questionDispatcher.handleTextReply(ctx.message.text, chatId, userId, replyToMessageId);
45
114
  });
46
- return flushing;
47
115
  }
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();
116
+ const requireChatId = async (action) => {
117
+ if (activeChatId) return activeChatId;
118
+ const state = await stateStore.read();
119
+ if (state.chatId) {
120
+ activeChatId = state.chatId;
121
+ return state.chatId;
55
122
  }
56
- }
123
+ throw new Error(`No active chat for ${action}. Send any message to the bot first.`);
124
+ };
57
125
  return {
58
- debug(msg, data) {
59
- write("debug", msg, data);
126
+ async start() {
127
+ if (!polling) {
128
+ logger.info("pass-through mode - skipping bot.start()");
129
+ return;
130
+ }
131
+ await bot.start({
132
+ drop_pending_updates: true,
133
+ onStart: () => {
134
+ logger.info("polling started");
135
+ }
136
+ });
60
137
  },
61
- info(msg, data) {
62
- write("info", msg, data);
138
+ async stop() {
139
+ if (polling) {
140
+ try {
141
+ await bot.stop();
142
+ } catch (err) {
143
+ logger.warn("bot.stop() error", { error: String(err) });
144
+ }
145
+ }
63
146
  },
64
- warn(msg, data) {
65
- write("warn", msg, data);
147
+ async sendMessage(text, options) {
148
+ const chatId = await requireChatId("sendMessage");
149
+ const result = await bot.api.sendMessage(chatId, text, options);
150
+ return { message_id: result.message_id };
66
151
  },
67
- error(msg, data) {
68
- write("error", msg, data);
152
+ async sendQuestionWithKeyboard(question, callbackData) {
153
+ const inlineKeyboard = question.options.map((option, index) => [
154
+ {
155
+ text: option.label,
156
+ callback_data: callbackData[index] ?? ""
157
+ }
158
+ ]);
159
+ if (callbackData[question.options.length]) {
160
+ inlineKeyboard.push([
161
+ { text: "\u270F\uFE0F Custom answer", callback_data: callbackData[question.options.length] }
162
+ ]);
163
+ }
164
+ return this.sendMessage(questionText(question), {
165
+ reply_markup: { inline_keyboard: inlineKeyboard }
166
+ });
69
167
  },
70
- async flush() {
71
- await flushBuffer();
168
+ async editMessage(messageId, text) {
169
+ const chatId = await requireChatId("editMessage");
170
+ await bot.api.editMessageText(chatId, messageId, text);
72
171
  },
73
- async close() {
74
- if (closed) return;
75
- closed = true;
76
- clearInterval(timer);
77
- await flushBuffer();
172
+ async editMessageText(messageId, text, options) {
173
+ const chatId = await requireChatId("editMessageText");
174
+ await bot.api.editMessageText(chatId, messageId, text, options);
175
+ },
176
+ async editMessageRemoveKeyboard(messageId, finalText) {
177
+ await this.editMessageText(messageId, finalText, { reply_markup: { inline_keyboard: [] } });
178
+ },
179
+ async replyWithForceReply(text, placeholder) {
180
+ return this.sendMessage(text, {
181
+ reply_markup: {
182
+ force_reply: true,
183
+ input_field_placeholder: placeholder
184
+ }
185
+ });
186
+ },
187
+ async deleteMessage(messageId) {
188
+ const chatId = await requireChatId("deleteMessage");
189
+ await bot.api.deleteMessage(chatId, messageId);
190
+ },
191
+ async getActiveChatId() {
192
+ if (activeChatId) return activeChatId;
193
+ const state = await stateStore.read();
194
+ return state.chatId;
195
+ },
196
+ setQuestionDispatcher(dispatcher) {
197
+ questionDispatcher = dispatcher;
198
+ },
199
+ setPermissionDispatcher(dispatcher) {
200
+ permissionDispatcher = dispatcher;
201
+ },
202
+ setStartWorkDispatcher(dispatcher) {
203
+ startWorkDispatcher = dispatcher;
78
204
  }
79
205
  };
80
206
  }
81
207
 
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;
208
+ // src/config.ts
209
+ function parseAllowedUserIds(value) {
210
+ if (!value || value.trim() === "") {
211
+ return [];
97
212
  }
98
- return null;
213
+ return value.split(",").map((id2) => id2.trim()).filter((id2) => id2 !== "").map((id2) => Number.parseInt(id2, 10)).filter((id2) => !Number.isNaN(id2));
99
214
  }
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;
215
+ function loadConfig(opts) {
216
+ const { logger, env } = opts;
217
+ const botToken = env.TELEGRAM_BOT_TOKEN;
218
+ const allowedUserIdsStr = env.TELEGRAM_ALLOWED_USER_IDS;
219
+ const chatIdStr = env.TELEGRAM_CHAT_ID;
220
+ if (!botToken || botToken.trim() === "") {
221
+ logger.error("missing TELEGRAM_BOT_TOKEN");
222
+ throw new Error("Missing required environment variable: TELEGRAM_BOT_TOKEN");
107
223
  }
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();
224
+ const allowedUserIds = parseAllowedUserIds(allowedUserIdsStr);
225
+ if (allowedUserIds.length === 0) {
226
+ logger.error("missing or invalid TELEGRAM_ALLOWED_USER_IDS");
227
+ throw new Error("Missing or invalid TELEGRAM_ALLOWED_USER_IDS");
117
228
  }
118
- let released = false;
229
+ let chatId;
230
+ if (chatIdStr && chatIdStr.trim() !== "") {
231
+ const parsed = Number.parseInt(chatIdStr.trim(), 10);
232
+ if (!Number.isNaN(parsed)) {
233
+ chatId = parsed;
234
+ }
235
+ }
236
+ logger.info("config loaded", { allowedUserCount: allowedUserIds.length, hasChatId: chatId !== void 0 });
119
237
  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
- }
238
+ botToken,
239
+ allowedUserIds,
240
+ chatId
130
241
  };
131
242
  }
132
- async function inspectExisting(lockPath, ttlMs) {
133
- let ownerPid;
134
- let dead = false;
243
+
244
+ // src/lib/claim.ts
245
+ import { mkdir, open, readdir, stat, unlink } from "fs/promises";
246
+ import { join } from "path";
247
+ import { createHash } from "crypto";
248
+ var DEFAULT_TTL_MS = 6e4;
249
+ var sweptDirs = /* @__PURE__ */ new Set();
250
+ function hasCode(err, code) {
251
+ return "code" in err && err.code === code;
252
+ }
253
+ function claimPath(claimsDir, key) {
254
+ const hash = createHash("sha256").update(key).digest("hex").slice(0, 16);
255
+ return join(claimsDir, `${hash}.claim`);
256
+ }
257
+ async function sweep(claimsDir, ttlMs) {
258
+ if (sweptDirs.has(claimsDir)) return;
259
+ sweptDirs.add(claimsDir);
135
260
  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
- }
261
+ const entries = await readdir(claimsDir, { withFileTypes: true });
262
+ await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".claim")).map(async (entry) => {
263
+ const filePath = join(claimsDir, entry.name);
264
+ try {
265
+ const fileStat = await stat(filePath);
266
+ if (Date.now() - fileStat.mtimeMs > ttlMs * 2) {
267
+ await unlink(filePath);
268
+ }
269
+ } catch {
270
+ }
271
+ }));
142
272
  } catch {
143
- return { stale: true, reason: "unreadable lock" };
144
273
  }
274
+ }
275
+ async function createClaim(filePath) {
276
+ const file = await open(filePath, "wx");
145
277
  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" };
278
+ await file.writeFile((/* @__PURE__ */ new Date()).toISOString(), "utf8");
279
+ } finally {
280
+ await file.close();
153
281
  }
282
+ return true;
154
283
  }
155
- async function acquireLock(opts) {
284
+ async function claimOnce(opts) {
156
285
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
157
- const pid = opts.pid ?? process.pid;
286
+ await mkdir(opts.claimsDir, { recursive: true });
287
+ await sweep(opts.claimsDir, ttlMs);
288
+ const filePath = claimPath(opts.claimsDir, opts.key);
158
289
  for (let attempt = 0; attempt < 2; attempt += 1) {
159
290
  try {
160
- return { acquired: true, handle: await createLock(opts.lockPath, pid) };
291
+ return await createClaim(filePath);
161
292
  } 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
- }
293
+ if (!(err instanceof Error) || !hasCode(err, "EEXIST")) throw err;
169
294
  try {
170
- await unlink(opts.lockPath);
171
- } catch {
172
- return { acquired: false, reason: "failed to remove stale lock", ownerPid: existing.ownerPid };
295
+ const fileStat = await stat(filePath);
296
+ if (Date.now() - fileStat.mtimeMs <= ttlMs || attempt === 1) return false;
297
+ await unlink(filePath);
298
+ } catch (statErr) {
299
+ if (statErr instanceof Error && hasCode(statErr, "ENOENT")) continue;
300
+ return false;
173
301
  }
174
302
  }
175
303
  }
176
- return { acquired: false, reason: "lock acquisition failed" };
304
+ return false;
177
305
  }
178
306
 
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";
307
+ // src/lib/pending-permissions.ts
308
+ import { createHash as createHash2 } from "crypto";
309
+ import { mkdir as mkdir2, readFile, readdir as readdir2, rename, unlink as unlink2, writeFile } from "fs/promises";
310
+ import { tmpdir } from "os";
311
+ import { dirname, join as join2 } from "path";
183
312
  function hasCode2(err, code) {
184
313
  return "code" in err && err.code === code;
185
314
  }
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
315
  function pendingFilePath(dir, shortHash) {
232
316
  return join2(dir, `${shortHash}.json`);
233
317
  }
234
318
  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
- parsed.answersInProgress = parsed.answersInProgress.map((answer) => answer ?? null);
242
- return parsed;
243
- }
244
- async function listPendingFiles(dir) {
245
- try {
246
- const entries = await readdir(dir, { withFileTypes: true });
247
- return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name);
248
- } catch (err) {
249
- if (err instanceof Error && hasCode3(err, "ENOENT")) return [];
250
- throw err;
251
- }
252
- }
253
- function shortHashFromFileName(fileName) {
254
- return fileName.slice(0, -".json".length);
255
- }
256
- function createPendingQuestionStore(opts) {
257
- const dir = opts.baseDir ?? join2(tmpdir2(), `opencoder-telegram-pending-questions-${opts.tokenHash}`);
258
- return {
259
- dir,
260
- async savePending(shortHash, data) {
261
- const filePath = pendingFilePath(dir, shortHash);
262
- await mkdir2(dirname2(filePath), { recursive: true });
263
- const tmpPath = `${filePath}.tmp.${process.pid}`;
264
- await writeFile2(tmpPath, JSON.stringify(data, null, 2), "utf8");
265
- await rename2(tmpPath, filePath);
266
- },
267
- async loadPending(shortHash) {
268
- try {
269
- return parsePending(await readFile3(pendingFilePath(dir, shortHash), "utf8"));
270
- } catch (err) {
271
- if (err instanceof Error && hasCode3(err, "ENOENT")) return void 0;
272
- throw err;
273
- }
274
- },
275
- async deletePending(shortHash) {
276
- try {
277
- await unlink2(pendingFilePath(dir, shortHash));
278
- } catch (err) {
279
- if (!(err instanceof Error) || !hasCode3(err, "ENOENT")) throw err;
280
- }
281
- },
282
- async sweepExpired() {
283
- const expired = [];
284
- for (const fileName of await listPendingFiles(dir)) {
285
- const shortHash = shortHashFromFileName(fileName);
286
- const data = await this.loadPending(shortHash);
287
- if (data && data.expiresAt < Date.now()) {
288
- expired.push(data);
289
- await this.deletePending(shortHash);
290
- }
291
- }
292
- return expired;
293
- },
294
- async findByRequestID(requestID) {
295
- for (const fileName of await listPendingFiles(dir)) {
296
- const shortHash = shortHashFromFileName(fileName);
297
- const data = await this.loadPending(shortHash);
298
- if (data?.requestID === requestID) return { shortHash, data };
299
- }
300
- return void 0;
301
- },
302
- async findAwaitingCustom(chatId, userId) {
303
- for (const fileName of await listPendingFiles(dir)) {
304
- const shortHash = shortHashFromFileName(fileName);
305
- const data = await this.loadPending(shortHash);
306
- const awaiting = data?.awaitingCustomFor;
307
- if (awaiting && awaiting.chatId === chatId && awaiting.userId === userId) return { shortHash, data };
308
- }
309
- return void 0;
310
- }
311
- };
312
- }
313
- function createQuestionShortHash(requestID) {
314
- return createHash("sha256").update(requestID).digest("base64url").slice(0, 10);
315
- }
316
-
317
- // src/lib/pending-permissions.ts
318
- import { createHash as createHash2 } from "crypto";
319
- import { mkdir as mkdir3, readFile as readFile4, readdir as readdir2, rename as rename3, unlink as unlink3, writeFile as writeFile3 } from "fs/promises";
320
- import { tmpdir as tmpdir3 } from "os";
321
- import { dirname as dirname3, join as join3 } from "path";
322
- function hasCode4(err, code) {
323
- return "code" in err && err.code === code;
324
- }
325
- function pendingFilePath2(dir, shortHash) {
326
- return join3(dir, `${shortHash}.json`);
327
- }
328
- function parsePending2(text) {
329
319
  const parsed = JSON.parse(text);
330
320
  if (typeof parsed.requestID !== "string") throw new Error("Invalid pending permission: requestID");
331
321
  if (typeof parsed.sessionID !== "string") throw new Error("Invalid pending permission: sessionID");
@@ -336,47 +326,47 @@ function parsePending2(text) {
336
326
  if (parsed.endpoint !== "request" && parsed.endpoint !== "session") throw new Error("Invalid pending permission: endpoint");
337
327
  return parsed;
338
328
  }
339
- async function listPendingFiles2(dir) {
329
+ async function listPendingFiles(dir) {
340
330
  try {
341
331
  const entries = await readdir2(dir, { withFileTypes: true });
342
332
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name);
343
333
  } catch (err) {
344
- if (err instanceof Error && hasCode4(err, "ENOENT")) return [];
334
+ if (err instanceof Error && hasCode2(err, "ENOENT")) return [];
345
335
  throw err;
346
336
  }
347
337
  }
348
- function shortHashFromFileName2(fileName) {
338
+ function shortHashFromFileName(fileName) {
349
339
  return fileName.slice(0, -".json".length);
350
340
  }
351
341
  function createPendingPermissionStore(opts) {
352
- const dir = opts.baseDir ?? join3(tmpdir3(), `opencoder-telegram-pending-permissions-${opts.tokenHash}`);
342
+ const dir = opts.baseDir ?? join2(tmpdir(), `opencoder-telegram-pending-permissions-${opts.tokenHash}`);
353
343
  return {
354
344
  dir,
355
345
  async savePending(shortHash, data) {
356
- const filePath = pendingFilePath2(dir, shortHash);
357
- await mkdir3(dirname3(filePath), { recursive: true });
346
+ const filePath = pendingFilePath(dir, shortHash);
347
+ await mkdir2(dirname(filePath), { recursive: true });
358
348
  const tmpPath = `${filePath}.tmp.${process.pid}`;
359
- await writeFile3(tmpPath, JSON.stringify(data, null, 2), "utf8");
360
- await rename3(tmpPath, filePath);
349
+ await writeFile(tmpPath, JSON.stringify(data, null, 2), "utf8");
350
+ await rename(tmpPath, filePath);
361
351
  },
362
352
  async loadPending(shortHash) {
363
353
  try {
364
- return parsePending2(await readFile4(pendingFilePath2(dir, shortHash), "utf8"));
354
+ return parsePending(await readFile(pendingFilePath(dir, shortHash), "utf8"));
365
355
  } catch (err) {
366
- if (err instanceof Error && hasCode4(err, "ENOENT")) return void 0;
356
+ if (err instanceof Error && hasCode2(err, "ENOENT")) return void 0;
367
357
  throw err;
368
358
  }
369
359
  },
370
360
  async deletePending(shortHash) {
371
361
  try {
372
- await unlink3(pendingFilePath2(dir, shortHash));
362
+ await unlink2(pendingFilePath(dir, shortHash));
373
363
  } catch (err) {
374
- if (!(err instanceof Error) || !hasCode4(err, "ENOENT")) throw err;
364
+ if (!(err instanceof Error) || !hasCode2(err, "ENOENT")) throw err;
375
365
  }
376
366
  },
377
367
  async findByRequestID(requestID) {
378
- for (const fileName of await listPendingFiles2(dir)) {
379
- const shortHash = shortHashFromFileName2(fileName);
368
+ for (const fileName of await listPendingFiles(dir)) {
369
+ const shortHash = shortHashFromFileName(fileName);
380
370
  const data = await this.loadPending(shortHash);
381
371
  if (data?.requestID === requestID) return { shortHash, data };
382
372
  }
@@ -384,8 +374,8 @@ function createPendingPermissionStore(opts) {
384
374
  },
385
375
  async sweepExpired() {
386
376
  const expired = [];
387
- for (const fileName of await listPendingFiles2(dir)) {
388
- const shortHash = shortHashFromFileName2(fileName);
377
+ for (const fileName of await listPendingFiles(dir)) {
378
+ const shortHash = shortHashFromFileName(fileName);
389
379
  const data = await this.loadPending(shortHash);
390
380
  if (data && data.expiresAt < Date.now()) {
391
381
  expired.push(data);
@@ -400,478 +390,557 @@ function createPermissionShortHash(requestID) {
400
390
  return createHash2("sha256").update(requestID).digest("base64url").slice(0, 10);
401
391
  }
402
392
 
403
- // src/lib/env-loader.ts
404
- import { existsSync } from "fs";
405
- import { homedir as homedir2 } from "os";
406
- import { join as join4 } from "path";
407
- import dotenv from "dotenv";
408
- function loadPluginEnv(opts) {
409
- const paths = [
410
- join4(opts.pluginDir, "../../.env"),
411
- join4(opts.pluginDir, "..", ".env"),
412
- join4(opts.pluginDir, ".env"),
413
- join4(opts.homeDir ?? homedir2(), ".config/opencode/telegram-remote/.env")
414
- ];
415
- const loadedFrom = [];
416
- const values = {};
417
- for (const envPath of paths) {
418
- if (!existsSync(envPath)) continue;
419
- const result = dotenv.config({ path: envPath, override: false });
420
- if (result.parsed) {
421
- loadedFrom.push(envPath);
422
- for (const [key, value] of Object.entries(result.parsed)) {
423
- if (!(key in values)) values[key] = value;
424
- }
425
- }
426
- }
427
- return { loadedFrom, values };
428
- }
429
-
430
- // src/config.ts
431
- function parseAllowedUserIds(value) {
432
- if (!value || value.trim() === "") {
433
- return [];
434
- }
435
- return value.split(",").map((id2) => id2.trim()).filter((id2) => id2 !== "").map((id2) => Number.parseInt(id2, 10)).filter((id2) => !Number.isNaN(id2));
393
+ // src/events/permission-updated.ts
394
+ var PERMISSION_EXPIRY_MS = 5 * 6e4;
395
+ var CALLBACK_RE = /^p:([^:]+):(o|a|r)$/;
396
+ function isStringArray(value) {
397
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
436
398
  }
437
- function loadConfig(opts) {
438
- const { logger, env } = opts;
439
- const botToken = env.TELEGRAM_BOT_TOKEN;
440
- const allowedUserIdsStr = env.TELEGRAM_ALLOWED_USER_IDS;
441
- const chatIdStr = env.TELEGRAM_CHAT_ID;
442
- if (!botToken || botToken.trim() === "") {
443
- logger.error("missing TELEGRAM_BOT_TOKEN");
444
- throw new Error("Missing required environment variable: TELEGRAM_BOT_TOKEN");
445
- }
446
- const allowedUserIds = parseAllowedUserIds(allowedUserIdsStr);
447
- if (allowedUserIds.length === 0) {
448
- logger.error("missing or invalid TELEGRAM_ALLOWED_USER_IDS");
449
- throw new Error("Missing or invalid TELEGRAM_ALLOWED_USER_IDS");
450
- }
451
- let chatId;
452
- if (chatIdStr && chatIdStr.trim() !== "") {
453
- const parsed = Number.parseInt(chatIdStr.trim(), 10);
454
- if (!Number.isNaN(parsed)) {
455
- chatId = parsed;
456
- }
457
- }
458
- logger.info("config loaded", { allowedUserCount: allowedUserIds.length, hasChatId: chatId !== void 0 });
399
+ function isEventPermissionAsked(event) {
400
+ if (event.type !== "permission.asked") return false;
401
+ const props = event.properties;
402
+ if (!props) return false;
403
+ if (typeof props.id !== "string") return false;
404
+ if (typeof props.sessionID !== "string") return false;
405
+ if (typeof props.permission !== "string") return false;
406
+ if (!isStringArray(props.patterns)) return false;
407
+ if (!isStringArray(props.always)) return false;
408
+ return true;
409
+ }
410
+ function buildCallbackData(shortHash, reply) {
411
+ const data = `p:${shortHash}:${reply}`;
412
+ if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
413
+ return data;
414
+ }
415
+ function normalizeUpdated(permission) {
416
+ const pattern = permission.pattern === void 0 ? [] : Array.isArray(permission.pattern) ? permission.pattern : [permission.pattern];
459
417
  return {
460
- botToken,
461
- allowedUserIds,
462
- chatId
418
+ requestID: permission.id,
419
+ sessionID: permission.sessionID,
420
+ title: permission.title,
421
+ permission: permission.type,
422
+ patterns: pattern,
423
+ always: [],
424
+ endpoint: "session",
425
+ claimKey: `permission.updated:${permission.id}`
463
426
  };
464
427
  }
428
+ function normalizeAsked(permission) {
429
+ return {
430
+ requestID: permission.id,
431
+ sessionID: permission.sessionID,
432
+ title: permission.patterns.join(", ") || permission.permission,
433
+ permission: permission.permission,
434
+ patterns: permission.patterns,
435
+ always: permission.always,
436
+ endpoint: "request",
437
+ claimKey: `permission.asked:${permission.id}`
438
+ };
439
+ }
440
+ function permissionMessage(permission, sessionTitle) {
441
+ const titleLine = sessionTitle ? `\u{1F4CB} ${sessionTitle}` : `Session: ${permission.sessionID}`;
442
+ const patterns = permission.patterns.length > 0 ? `
443
+ Patterns: ${permission.patterns.join(", ")}` : "";
444
+ const always = permission.always.length > 0 ? `
445
+ Always options: ${permission.always.join(", ")}` : "";
446
+ return `\u2753 Permission requested
465
447
 
466
- // src/bot.ts
467
- import { Bot, GrammyError } from "grammy";
468
- function createTelegramBot(opts) {
469
- const { config, stateStore, logger, polling } = opts;
470
- const bot = new Bot(config.botToken);
471
- let activeChatId = opts.initialChatId;
472
- let questionDispatcher;
473
- let permissionDispatcher;
474
- if (polling) {
475
- bot.use(async (ctx, next) => {
476
- const userId = ctx.from?.id;
477
- if (!userId || !config.allowedUserIds.includes(userId)) {
478
- logger.warn("unauthorized access attempt", { userId });
479
- return;
480
- }
481
- if (ctx.chat?.type !== "private") return;
482
- if (ctx.chat?.id) {
483
- const newChatId = ctx.chat.id;
484
- if (activeChatId !== newChatId) {
485
- activeChatId = newChatId;
486
- await stateStore.write({ chatId: newChatId, discoveredBy: process.pid });
487
- logger.info("chat_id discovered", { chatId: newChatId });
488
- await ctx.reply(`\u2705 Chat connected!
489
-
490
- Your chat_id: ${newChatId}
448
+ ${titleLine}
491
449
 
492
- This chat is now active for OpenCode notifications.`);
493
- }
494
- }
495
- await next();
496
- });
497
- bot.catch((err) => {
498
- const e = err.error;
499
- if (e instanceof GrammyError && e.error_code === 409) {
500
- logger.info("polling conflict (409) - another process took over", { description: e.description });
501
- } else {
502
- logger.error("bot error", { error: String(e) });
503
- }
504
- });
505
- bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c|d)$/, async (ctx) => {
506
- await ctx.answerCallbackQuery();
507
- const data = ctx.callbackQuery.data;
508
- const messageId = ctx.callbackQuery.message?.message_id;
509
- const chatId = ctx.chat?.id;
510
- const userId = ctx.from?.id;
511
- if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0) return;
512
- await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
513
- });
514
- bot.callbackQuery(/^p:([^:]+):(o|a|r)$/, async (ctx) => {
515
- await ctx.answerCallbackQuery();
516
- const data = ctx.callbackQuery.data;
517
- const messageId = ctx.callbackQuery.message?.message_id;
518
- if (!permissionDispatcher || messageId === void 0) return;
519
- await permissionDispatcher.handleCallbackQuery(data, messageId);
520
- });
521
- bot.on("message:text", async (ctx) => {
522
- const replyToMessageId = ctx.message.reply_to_message?.message_id;
523
- const chatId = ctx.chat.id;
524
- const userId = ctx.from?.id;
525
- if (!questionDispatcher || replyToMessageId === void 0 || userId === void 0) return;
526
- await questionDispatcher.handleTextReply(ctx.message.text, chatId, userId, replyToMessageId);
450
+ Permission: ${permission.permission}
451
+ Detail: ${permission.title}${patterns}${always}`;
452
+ }
453
+ function permissionKeyboard(shortHash) {
454
+ return [
455
+ [{ text: "\u2705 Allow once", callback_data: buildCallbackData(shortHash, "o") }],
456
+ [{ text: "\u267B\uFE0F Always allow", callback_data: buildCallbackData(shortHash, "a") }],
457
+ [{ text: "\u274C Reject", callback_data: buildCallbackData(shortHash, "r") }]
458
+ ];
459
+ }
460
+ function replyFromSelection(selection) {
461
+ if (selection === "o") return "once";
462
+ if (selection === "a") return "always";
463
+ if (selection === "r") return "reject";
464
+ return void 0;
465
+ }
466
+ function replyLabel(reply) {
467
+ if (reply === "once") return "Allowed once";
468
+ if (reply === "always") return "Always allowed";
469
+ return "Rejected";
470
+ }
471
+ async function handleNormalizedPermission(permission, ctx) {
472
+ const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: permission.claimKey });
473
+ if (!claimed) return;
474
+ const shortHash = createPermissionShortHash(permission.requestID);
475
+ const sentAt = Date.now();
476
+ const rawSessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
477
+ const sessionTitle = rawSessionTitle === null ? void 0 : rawSessionTitle;
478
+ try {
479
+ const message = await ctx.bot.sendMessage(permissionMessage(permission, sessionTitle), {
480
+ reply_markup: { inline_keyboard: permissionKeyboard(shortHash) }
527
481
  });
482
+ const pending = {
483
+ requestID: permission.requestID,
484
+ sessionID: permission.sessionID,
485
+ title: permission.title,
486
+ permission: permission.permission,
487
+ patterns: permission.patterns,
488
+ always: permission.always,
489
+ sentAt,
490
+ expiresAt: sentAt + PERMISSION_EXPIRY_MS,
491
+ telegramMessageId: message.message_id,
492
+ endpoint: permission.endpoint
493
+ };
494
+ await ctx.pendingPermissions.savePending(shortHash, pending);
495
+ } catch (err) {
496
+ ctx.logger.error("failed to send permission notification", { error: String(err) });
528
497
  }
529
- const requireChatId = async (action) => {
530
- if (activeChatId) return activeChatId;
531
- const state = await stateStore.read();
532
- if (state.chatId) {
533
- activeChatId = state.chatId;
534
- return state.chatId;
535
- }
536
- throw new Error(`No active chat for ${action}. Send any message to the bot first.`);
537
- };
498
+ }
499
+ async function expirePending(ctx, shortHash, pending, messageId) {
500
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Permission request expired");
501
+ await ctx.pendingPermissions.deletePending(shortHash);
502
+ ctx.logger.info("pending permission expired", { requestID: pending.requestID });
503
+ }
504
+ async function handlePermissionUpdated(event, ctx) {
505
+ await handleNormalizedPermission(normalizeUpdated(event.properties), ctx);
506
+ }
507
+ async function handlePermissionAsked(event, ctx) {
508
+ await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
509
+ }
510
+ function createPermissionDispatcher(ctx) {
538
511
  return {
539
- async start() {
540
- if (!polling) {
541
- logger.info("pass-through mode - skipping bot.start()");
512
+ async handleCallbackQuery(data, messageId) {
513
+ const match = CALLBACK_RE.exec(data);
514
+ if (!match) return;
515
+ const shortHash = match[1];
516
+ const reply = replyFromSelection(match[2]);
517
+ if (!reply) return;
518
+ const pending = await ctx.pendingPermissions.loadPending(shortHash);
519
+ if (!pending) {
520
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
542
521
  return;
543
522
  }
544
- await bot.start({
545
- drop_pending_updates: true,
546
- onStart: () => {
547
- logger.info("polling started");
548
- }
549
- });
550
- },
551
- async stop() {
552
- if (polling) {
553
- try {
554
- await bot.stop();
555
- } catch (err) {
556
- logger.warn("bot.stop() error", { error: String(err) });
557
- }
523
+ if (pending.expiresAt < Date.now()) {
524
+ await expirePending(ctx, shortHash, pending, messageId);
525
+ return;
558
526
  }
559
- },
560
- async sendMessage(text, options) {
561
- const chatId = await requireChatId("sendMessage");
562
- const result = await bot.api.sendMessage(chatId, text, options);
563
- return { message_id: result.message_id };
564
- },
565
- async sendQuestionWithKeyboard(question, callbackData) {
566
- const inlineKeyboard = question.options.map((option, index) => [{
567
- text: option.label,
568
- callback_data: callbackData[index] ?? ""
569
- }]);
570
- if (callbackData[question.options.length]) {
571
- inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: callbackData[question.options.length] }]);
527
+ try {
528
+ await ctx.replyToPermission(pending.requestID, pending.sessionID, reply, pending.endpoint);
529
+ await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Permission ${replyLabel(reply)}
530
+
531
+ ${pending.permission}: ${pending.title}`);
532
+ ctx.logger.info("permission reply sent", { requestID: pending.requestID, sessionID: pending.sessionID, reply });
533
+ } catch (err) {
534
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send permission reply to opencode");
535
+ ctx.logger.error("failed to send permission reply", { error: String(err), requestID: pending.requestID });
536
+ } finally {
537
+ await ctx.pendingPermissions.deletePending(shortHash);
572
538
  }
573
- const header = question.header ? `\u2753 ${question.header}` : "\u2753 Question";
574
- return this.sendMessage(`${header}
539
+ }
540
+ };
541
+ }
575
542
 
576
- ${question.question}`, { reply_markup: { inline_keyboard: inlineKeyboard } });
577
- },
578
- async editMessage(messageId, text) {
579
- const chatId = await requireChatId("editMessage");
580
- await bot.api.editMessageText(chatId, messageId, text);
543
+ // src/lib/pending-questions.ts
544
+ import { createHash as createHash3 } from "crypto";
545
+ import { mkdir as mkdir3, readFile as readFile2, readdir as readdir3, rename as rename2, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
546
+ import { tmpdir as tmpdir2 } from "os";
547
+ import { dirname as dirname2, join as join3 } from "path";
548
+ function hasCode3(err, code) {
549
+ return "code" in err && err.code === code;
550
+ }
551
+ function pendingFilePath2(dir, shortHash) {
552
+ return join3(dir, `${shortHash}.json`);
553
+ }
554
+ function parsePending2(text) {
555
+ const parsed = JSON.parse(text);
556
+ if (typeof parsed.requestID !== "string") throw new Error("Invalid pending question: requestID");
557
+ if (typeof parsed.sessionID !== "string") throw new Error("Invalid pending question: sessionID");
558
+ if (!Array.isArray(parsed.questions)) throw new Error("Invalid pending question: questions");
559
+ if (!Array.isArray(parsed.telegramMessageIds)) throw new Error("Invalid pending question: telegramMessageIds");
560
+ if (!Array.isArray(parsed.answersInProgress)) throw new Error("Invalid pending question: answersInProgress");
561
+ parsed.answersInProgress = parsed.answersInProgress.map((answer) => answer ?? null);
562
+ return parsed;
563
+ }
564
+ async function listPendingFiles2(dir) {
565
+ try {
566
+ const entries = await readdir3(dir, { withFileTypes: true });
567
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name);
568
+ } catch (err) {
569
+ if (err instanceof Error && hasCode3(err, "ENOENT")) return [];
570
+ throw err;
571
+ }
572
+ }
573
+ function shortHashFromFileName2(fileName) {
574
+ return fileName.slice(0, -".json".length);
575
+ }
576
+ function createPendingQuestionStore(opts) {
577
+ const dir = opts.baseDir ?? join3(tmpdir2(), `opencoder-telegram-pending-questions-${opts.tokenHash}`);
578
+ return {
579
+ dir,
580
+ async savePending(shortHash, data) {
581
+ const filePath = pendingFilePath2(dir, shortHash);
582
+ await mkdir3(dirname2(filePath), { recursive: true });
583
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
584
+ await writeFile2(tmpPath, JSON.stringify(data, null, 2), "utf8");
585
+ await rename2(tmpPath, filePath);
581
586
  },
582
- async editMessageText(messageId, text, options) {
583
- const chatId = await requireChatId("editMessageText");
584
- await bot.api.editMessageText(chatId, messageId, text, options);
587
+ async loadPending(shortHash) {
588
+ try {
589
+ return parsePending2(await readFile2(pendingFilePath2(dir, shortHash), "utf8"));
590
+ } catch (err) {
591
+ if (err instanceof Error && hasCode3(err, "ENOENT")) return void 0;
592
+ throw err;
593
+ }
585
594
  },
586
- async editMessageRemoveKeyboard(messageId, finalText) {
587
- await this.editMessageText(messageId, finalText, { reply_markup: { inline_keyboard: [] } });
595
+ async deletePending(shortHash) {
596
+ try {
597
+ await unlink3(pendingFilePath2(dir, shortHash));
598
+ } catch (err) {
599
+ if (!(err instanceof Error) || !hasCode3(err, "ENOENT")) throw err;
600
+ }
588
601
  },
589
- async replyWithForceReply(text, placeholder) {
590
- return this.sendMessage(text, {
591
- reply_markup: {
592
- force_reply: true,
593
- input_field_placeholder: placeholder
602
+ async sweepExpired() {
603
+ const expired = [];
604
+ for (const fileName of await listPendingFiles2(dir)) {
605
+ const shortHash = shortHashFromFileName2(fileName);
606
+ const data = await this.loadPending(shortHash);
607
+ if (data && data.expiresAt < Date.now()) {
608
+ expired.push(data);
609
+ await this.deletePending(shortHash);
594
610
  }
595
- });
596
- },
597
- async deleteMessage(messageId) {
598
- const chatId = await requireChatId("deleteMessage");
599
- await bot.api.deleteMessage(chatId, messageId);
600
- },
601
- async getActiveChatId() {
602
- if (activeChatId) return activeChatId;
603
- const state = await stateStore.read();
604
- return state.chatId;
611
+ }
612
+ return expired;
605
613
  },
606
- setQuestionDispatcher(dispatcher) {
607
- questionDispatcher = dispatcher;
614
+ async findByRequestID(requestID) {
615
+ for (const fileName of await listPendingFiles2(dir)) {
616
+ const shortHash = shortHashFromFileName2(fileName);
617
+ const data = await this.loadPending(shortHash);
618
+ if (data?.requestID === requestID) return { shortHash, data };
619
+ }
620
+ return void 0;
608
621
  },
609
- setPermissionDispatcher(dispatcher) {
610
- permissionDispatcher = dispatcher;
611
- }
612
- };
613
- }
614
-
615
- // src/services/session-title-service.ts
616
- var SessionTitleService = class {
617
- sessions = /* @__PURE__ */ new Map();
618
- setSessionInfo(info) {
619
- const existing = this.sessions.get(info.id);
620
- this.sessions.set(info.id, {
621
- title: info.title || null,
622
- parentID: info.parentID ?? null,
623
- status: existing?.status,
624
- idleNotificationPending: existing?.idleNotificationPending ?? false
625
- });
626
- }
627
- setSessionTitle(sessionId, title) {
628
- const existing = this.sessions.get(sessionId);
629
- this.sessions.set(sessionId, {
630
- title,
631
- parentID: existing?.parentID ?? null,
632
- status: existing?.status,
633
- idleNotificationPending: existing?.idleNotificationPending ?? false
634
- });
635
- }
636
- setSessionStatus(sessionId, status) {
637
- const existing = this.sessions.get(sessionId);
638
- this.sessions.set(sessionId, {
639
- title: existing?.title ?? null,
640
- parentID: existing?.parentID ?? null,
641
- status,
642
- idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
643
- });
644
- }
645
- getSessionTitle(sessionId) {
646
- return this.sessions.get(sessionId)?.title ?? null;
647
- }
648
- getParentID(sessionId) {
649
- return this.sessions.get(sessionId)?.parentID;
650
- }
651
- getSessionStatus(sessionId) {
652
- return this.sessions.get(sessionId)?.status;
653
- }
654
- hasUnfinishedDescendants(parentID) {
655
- for (const [sessionID, session] of this.sessions.entries()) {
656
- if (session.parentID !== parentID) continue;
657
- if (session.status !== "idle") return true;
658
- if (this.hasUnfinishedDescendants(sessionID)) return true;
659
- }
660
- return false;
661
- }
662
- deferIdleNotification(sessionId) {
663
- const existing = this.sessions.get(sessionId);
664
- this.sessions.set(sessionId, {
665
- title: existing?.title ?? null,
666
- parentID: existing?.parentID ?? null,
667
- status: existing?.status ?? "idle",
668
- idleNotificationPending: true
669
- });
670
- }
671
- hasDeferredIdleNotification(sessionId) {
672
- return this.sessions.get(sessionId)?.idleNotificationPending ?? false;
673
- }
674
- clearDeferredIdleNotification(sessionId) {
675
- const existing = this.sessions.get(sessionId);
676
- if (!existing) return;
677
- this.sessions.set(sessionId, {
678
- ...existing,
679
- idleNotificationPending: false
680
- });
681
- }
682
- };
622
+ async findAwaitingCustom(chatId, userId) {
623
+ for (const fileName of await listPendingFiles2(dir)) {
624
+ const shortHash = shortHashFromFileName2(fileName);
625
+ const data = await this.loadPending(shortHash);
626
+ const awaiting = data?.awaitingCustomFor;
627
+ if (awaiting && awaiting.chatId === chatId && awaiting.userId === userId) return { shortHash, data };
628
+ }
629
+ return void 0;
630
+ }
631
+ };
632
+ }
633
+ function createQuestionShortHash(requestID) {
634
+ return createHash3("sha256").update(requestID).digest("base64url").slice(0, 10);
635
+ }
683
636
 
684
- // src/lib/claim.ts
685
- import { mkdir as mkdir4, open as open2, readdir as readdir3, stat as stat2, unlink as unlink4 } from "fs/promises";
686
- import { join as join5 } from "path";
687
- import { createHash as createHash3 } from "crypto";
688
- var DEFAULT_TTL_MS2 = 6e4;
689
- var sweptDirs = /* @__PURE__ */ new Set();
690
- function hasCode5(err, code) {
691
- return "code" in err && err.code === code;
637
+ // src/events/question-asked.ts
638
+ var QUESTION_EXPIRY_MS = 5 * 6e4;
639
+ var CALLBACK_RE2 = /^q:([^:]+):(\d+):(\d+|c|d)$/;
640
+ function isQuestionOption(value) {
641
+ return typeof value.label === "string" && typeof value.description === "string";
692
642
  }
693
- function claimPath(claimsDir, key) {
694
- const hash = createHash3("sha256").update(key).digest("hex").slice(0, 16);
695
- return join5(claimsDir, `${hash}.claim`);
643
+ function isQuestionInfo(value) {
644
+ if (typeof value.question !== "string") return false;
645
+ if (typeof value.header !== "string") return false;
646
+ if (!Array.isArray(value.options)) return false;
647
+ return value.options.every(
648
+ (option) => typeof option === "object" && option !== null && isQuestionOption(option)
649
+ );
696
650
  }
697
- async function sweep(claimsDir, ttlMs) {
698
- if (sweptDirs.has(claimsDir)) return;
699
- sweptDirs.add(claimsDir);
700
- try {
701
- const entries = await readdir3(claimsDir, { withFileTypes: true });
702
- await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".claim")).map(async (entry) => {
703
- const filePath = join5(claimsDir, entry.name);
704
- try {
705
- const fileStat = await stat2(filePath);
706
- if (Date.now() - fileStat.mtimeMs > ttlMs * 2) {
707
- await unlink4(filePath);
708
- }
709
- } catch {
710
- }
711
- }));
712
- } catch {
713
- }
651
+ function isEventQuestionAsked(event) {
652
+ if (event.type !== "question.asked") return false;
653
+ const props = event.properties;
654
+ if (!props) return false;
655
+ if (typeof props.id !== "string") return false;
656
+ if (typeof props.sessionID !== "string") return false;
657
+ if (!Array.isArray(props.questions)) return false;
658
+ return props.questions.every(
659
+ (question) => typeof question === "object" && question !== null && isQuestionInfo(question)
660
+ );
714
661
  }
715
- async function createClaim(filePath) {
716
- const file = await open2(filePath, "wx");
717
- try {
718
- await file.writeFile((/* @__PURE__ */ new Date()).toISOString(), "utf8");
719
- } finally {
720
- await file.close();
721
- }
722
- return true;
662
+ function buildCallbackData2(shortHash, questionIndex, optionIndex) {
663
+ const data = `q:${shortHash}:${questionIndex}:${optionIndex}`;
664
+ if (Buffer.byteLength(data, "utf8") > 64)
665
+ throw new Error("Telegram callback_data exceeds 64 bytes");
666
+ return data;
723
667
  }
724
- async function claimOnce(opts) {
725
- const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS2;
726
- await mkdir4(opts.claimsDir, { recursive: true });
727
- await sweep(opts.claimsDir, ttlMs);
728
- const filePath = claimPath(opts.claimsDir, opts.key);
729
- for (let attempt = 0; attempt < 2; attempt += 1) {
730
- try {
731
- return await createClaim(filePath);
732
- } catch (err) {
733
- if (!(err instanceof Error) || !hasCode5(err, "EEXIST")) throw err;
734
- try {
735
- const fileStat = await stat2(filePath);
736
- if (Date.now() - fileStat.mtimeMs <= ttlMs || attempt === 1) return false;
737
- await unlink4(filePath);
738
- } catch (statErr) {
739
- if (statErr instanceof Error && hasCode5(statErr, "ENOENT")) continue;
740
- return false;
741
- }
668
+ function callbackDataForQuestion(shortHash, questionIndex, question) {
669
+ const data = question.options.map(
670
+ (_, optionIndex) => buildCallbackData2(shortHash, questionIndex, optionIndex)
671
+ );
672
+ if (question.custom !== false) data.push(buildCallbackData2(shortHash, questionIndex, "c"));
673
+ return data;
674
+ }
675
+ function useSimpleQuestionKeyboard(question) {
676
+ return question.multiple !== true;
677
+ }
678
+ function selectedAnswers(pending, questionIndex) {
679
+ return pending.answersInProgress[questionIndex] ?? [];
680
+ }
681
+ function questionInlineKeyboard(shortHash, questionIndex, question, selected) {
682
+ const multiple = question.multiple === true;
683
+ const inlineKeyboard = question.options.map((option, optionIndex) => [
684
+ {
685
+ text: multiple && selected.includes(option.label) ? `\u2705 ${option.label}` : option.label,
686
+ callback_data: buildCallbackData2(shortHash, questionIndex, optionIndex)
742
687
  }
688
+ ]);
689
+ if (question.custom !== false) {
690
+ inlineKeyboard.push([
691
+ { text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }
692
+ ]);
743
693
  }
744
- return false;
745
- }
746
-
747
- // src/lib/abort-tracker.ts
748
- var ABORT_TTL_MS = 5e3;
749
- var sessionAborts = /* @__PURE__ */ new Map();
750
- var lastGlobalAbortAt = 0;
751
- function noteAbort(sessionID) {
752
- const now = Date.now();
753
- if (sessionID) {
754
- sessionAborts.set(sessionID, now);
755
- return;
694
+ if (multiple) {
695
+ inlineKeyboard.push([
696
+ { text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }
697
+ ]);
756
698
  }
757
- lastGlobalAbortAt = now;
699
+ return inlineKeyboard;
758
700
  }
759
- function shouldSuppressIdle(sessionID) {
760
- const now = Date.now();
761
- const sessionAbortAt = sessionAborts.get(sessionID) ?? 0;
762
- const abortAt = Math.max(sessionAbortAt, lastGlobalAbortAt);
763
- if (abortAt === 0) return false;
764
- if (now - abortAt <= ABORT_TTL_MS) {
765
- sessionAborts.delete(sessionID);
766
- if (abortAt === lastGlobalAbortAt) lastGlobalAbortAt = 0;
767
- return true;
768
- }
769
- sessionAborts.delete(sessionID);
770
- if (now - lastGlobalAbortAt > ABORT_TTL_MS) lastGlobalAbortAt = 0;
771
- return false;
701
+ function questionPromptText(pending, questionIndex) {
702
+ return pendingQuestionText(pending.questions, questionIndex);
772
703
  }
773
-
774
- // src/events/session-idle.ts
775
- var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
776
- function sleep(ms) {
777
- return new Promise((resolve) => setTimeout(resolve, ms));
704
+ function answerSummary(questions, answers) {
705
+ return answers.map(
706
+ (answer, index) => `${index + 1}. ${questions[index]?.header ?? "Question"}: ${answer.join(", ") || "(empty)"}`
707
+ ).join("\n");
778
708
  }
779
- async function resolveParentID(sessionId, ctx) {
780
- const cachedParentID = ctx.sessionTitleService.getParentID(sessionId);
781
- if (cachedParentID !== void 0) return cachedParentID;
709
+ async function editPromptForQuestion(ctx, pending, shortHash, questionIndex) {
710
+ const messageId = pending.telegramMessageIds[0];
711
+ const question = pending.questions[questionIndex];
712
+ const inlineKeyboard = questionInlineKeyboard(
713
+ shortHash,
714
+ questionIndex,
715
+ question,
716
+ selectedAnswers(pending, questionIndex)
717
+ );
718
+ await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), {
719
+ reply_markup: { inline_keyboard: inlineKeyboard }
720
+ });
721
+ }
722
+ async function completeIfReady(ctx, pending, shortHash) {
723
+ const nextIndex = pending.answersInProgress.findIndex((answer) => answer === null);
724
+ if (nextIndex >= 0) {
725
+ pending.currentQuestionIndex = nextIndex;
726
+ await ctx.pendingQuestions.savePending(shortHash, pending);
727
+ await editPromptForQuestion(ctx, pending, shortHash, nextIndex);
728
+ return;
729
+ }
730
+ const answers = pending.answersInProgress.map((answer) => answer ?? []);
731
+ const messageId = pending.telegramMessageIds[0];
782
732
  try {
783
- const result = await ctx.client.session.get({ path: { id: sessionId } });
784
- if (result.data) {
785
- ctx.sessionTitleService.setSessionInfo(result.data);
786
- return ctx.sessionTitleService.getParentID(sessionId);
787
- }
788
- ctx.logger.warn("session parentID cache miss fetch returned no data", { sessionId });
789
- return void 0;
733
+ await ctx.replyToQuestion(pending.requestID, answers);
734
+ await ctx.bot.editMessageRemoveKeyboard(
735
+ messageId,
736
+ `\u2705 Answered:
737
+ ${answerSummary(pending.questions, answers)}`
738
+ );
739
+ ctx.logger.info("question reply sent", {
740
+ requestID: pending.requestID,
741
+ sessionID: pending.sessionID
742
+ });
790
743
  } catch (err) {
791
- ctx.logger.warn("session parentID cache miss fetch failed", { sessionId, error: String(err) });
792
- return void 0;
744
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send answer to opencode");
745
+ ctx.logger.error("failed to send question reply", {
746
+ error: String(err),
747
+ requestID: pending.requestID
748
+ });
749
+ } finally {
750
+ await ctx.pendingQuestions.deletePending(shortHash);
793
751
  }
794
752
  }
795
- async function hydrateDescendants(sessionId, ctx, seen = /* @__PURE__ */ new Set()) {
796
- if (seen.has(sessionId)) return;
797
- seen.add(sessionId);
753
+ async function expirePending2(ctx, shortHash, pending, messageId) {
754
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
755
+ await ctx.pendingQuestions.deletePending(shortHash);
756
+ ctx.logger.info("pending question expired", { requestID: pending.requestID });
757
+ }
758
+ async function handleQuestionAsked(event, ctx) {
759
+ const request = event.properties;
760
+ if (request.questions.length === 0) return;
761
+ const claimed = await claimOnce({
762
+ claimsDir: ctx.claimsDir,
763
+ key: `question.asked:${request.id}`,
764
+ ttlMs: 5e3
765
+ });
766
+ if (!claimed) return;
767
+ const shortHash = createQuestionShortHash(request.id);
768
+ const firstQuestion = request.questions[0];
769
+ const sentAt = Date.now();
770
+ const pending = {
771
+ requestID: request.id,
772
+ sessionID: request.sessionID,
773
+ questions: request.questions,
774
+ sentAt,
775
+ expiresAt: sentAt + QUESTION_EXPIRY_MS,
776
+ telegramMessageIds: [],
777
+ currentQuestionIndex: 0,
778
+ answersInProgress: request.questions.map(() => null)
779
+ };
798
780
  try {
799
- const result = await ctx.client.session.children({ path: { id: sessionId } });
800
- for (const child of result.data ?? []) {
801
- ctx.sessionTitleService.setSessionInfo(child);
802
- await hydrateDescendants(child.id, ctx, seen);
803
- }
781
+ const message = request.questions.length === 1 && useSimpleQuestionKeyboard(firstQuestion) ? await ctx.bot.sendQuestionWithKeyboard(
782
+ firstQuestion,
783
+ callbackDataForQuestion(shortHash, 0, firstQuestion)
784
+ ) : await ctx.bot.sendMessage(questionPromptText(pending, 0), {
785
+ reply_markup: {
786
+ inline_keyboard: questionInlineKeyboard(shortHash, 0, firstQuestion, [])
787
+ }
788
+ });
789
+ pending.telegramMessageIds = [message.message_id];
790
+ await ctx.pendingQuestions.savePending(shortHash, pending);
791
+ ctx.logger.info("question prompt sent", {
792
+ requestID: request.id,
793
+ sessionID: request.sessionID,
794
+ count: request.questions.length
795
+ });
804
796
  } catch (err) {
805
- ctx.logger.warn("session children fetch failed", { sessionId, error: String(err) });
797
+ ctx.logger.error("failed to send question prompt", {
798
+ error: String(err),
799
+ requestID: request.id
800
+ });
806
801
  }
807
802
  }
808
- async function sendIdleNotification(sessionId, ctx) {
809
- if (shouldSuppressIdle(sessionId)) {
810
- ctx.logger.info("idle suppressed - session was aborted", { sessionId });
811
- return;
812
- }
813
- const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `session.idle:${sessionId}`, ttlMs: 5e3 });
814
- if (!claimed) return;
815
- const title = ctx.sessionTitleService.getSessionTitle(sessionId);
816
- const message = title ? `Agent has finished: ${title}` : "Agent has finished.";
803
+ function createQuestionDispatcher(ctx) {
804
+ return {
805
+ async handleCallbackQuery(data, messageId, chatId, userId) {
806
+ const match = CALLBACK_RE2.exec(data);
807
+ if (!match) return;
808
+ const shortHash = match[1];
809
+ const questionIndex = Number(match[2]);
810
+ const selection = match[3];
811
+ const pending = await ctx.pendingQuestions.loadPending(shortHash);
812
+ if (!pending) {
813
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "This question has expired.");
814
+ return;
815
+ }
816
+ if (pending.expiresAt < Date.now()) {
817
+ await expirePending2(ctx, shortHash, pending, messageId);
818
+ return;
819
+ }
820
+ const question = pending.questions[questionIndex];
821
+ if (!question) return;
822
+ if (selection === "c") {
823
+ if (question.multiple === true) {
824
+ await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), {
825
+ reply_markup: { inline_keyboard: [] }
826
+ });
827
+ } else {
828
+ await ctx.bot.editMessageRemoveKeyboard(
829
+ messageId,
830
+ "\u270F\uFE0F Reply to the next message with your custom answer."
831
+ );
832
+ }
833
+ const prompt = await ctx.bot.replyWithForceReply(
834
+ "Type your custom answer",
835
+ "Type your answer"
836
+ );
837
+ pending.awaitingCustomFor = {
838
+ shortHash,
839
+ questionIndex,
840
+ chatId,
841
+ userId,
842
+ promptMessageId: prompt.message_id
843
+ };
844
+ await ctx.pendingQuestions.savePending(shortHash, pending);
845
+ return;
846
+ }
847
+ if (selection === "d") {
848
+ if (question.multiple !== true) return;
849
+ pending.answersInProgress[questionIndex] = selectedAnswers(pending, questionIndex);
850
+ pending.awaitingCustomFor = void 0;
851
+ await completeIfReady(ctx, pending, shortHash);
852
+ return;
853
+ }
854
+ const option = question.options[Number(selection)];
855
+ if (!option) return;
856
+ if (question.multiple === true) {
857
+ const current = selectedAnswers(pending, questionIndex);
858
+ pending.answersInProgress[questionIndex] = current.includes(option.label) ? current.filter((answer) => answer !== option.label) : [...current, option.label];
859
+ pending.awaitingCustomFor = void 0;
860
+ await ctx.pendingQuestions.savePending(shortHash, pending);
861
+ await editPromptForQuestion(ctx, pending, shortHash, questionIndex);
862
+ return;
863
+ }
864
+ pending.answersInProgress[questionIndex] = [option.label];
865
+ pending.awaitingCustomFor = void 0;
866
+ await completeIfReady(ctx, pending, shortHash);
867
+ },
868
+ async handleTextReply(text, chatId, userId, replyToMessageId) {
869
+ const match = await ctx.pendingQuestions.findAwaitingCustom(chatId, userId);
870
+ if (!match) return;
871
+ const awaiting = match.data.awaitingCustomFor;
872
+ if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
873
+ if (match.data.expiresAt < Date.now()) {
874
+ await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
875
+ return;
876
+ }
877
+ const question = match.data.questions[awaiting.questionIndex];
878
+ if (question?.multiple === true) {
879
+ const current = selectedAnswers(match.data, awaiting.questionIndex);
880
+ match.data.answersInProgress[awaiting.questionIndex] = current.includes(text) ? current : [...current, text];
881
+ match.data.awaitingCustomFor = void 0;
882
+ await ctx.bot.sendMessage("\u2705 Custom answer added. Tap Done when finished.");
883
+ await ctx.pendingQuestions.savePending(match.shortHash, match.data);
884
+ await editPromptForQuestion(ctx, match.data, match.shortHash, awaiting.questionIndex);
885
+ return;
886
+ }
887
+ match.data.answersInProgress[awaiting.questionIndex] = [text];
888
+ match.data.awaitingCustomFor = void 0;
889
+ await ctx.bot.sendMessage("\u2705 Custom answer sent.");
890
+ await completeIfReady(ctx, match.data, match.shortHash);
891
+ }
892
+ };
893
+ }
894
+
895
+ // src/events/question-replied.ts
896
+ function isEventQuestionReplied(event) {
897
+ if (event.type !== "question.replied") return false;
898
+ const props = event.properties;
899
+ return Boolean(props && typeof props.requestID === "string" && typeof props.sessionID === "string");
900
+ }
901
+ async function handleQuestionReplied(event, ctx) {
902
+ const found = await ctx.pendingQuestions.findByRequestID(event.properties.requestID);
903
+ if (!found) return;
904
+ const messageId = found.data.telegramMessageIds[0];
817
905
  try {
818
- await ctx.bot.sendMessage(message);
819
- ctx.sessionTitleService.clearDeferredIdleNotification(sessionId);
820
- ctx.logger.info("idle notification sent", { sessionId, title });
906
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u2705 Already answered in opencode.");
821
907
  } catch (err) {
822
- ctx.logger.error("failed to send idle notification", { error: String(err) });
823
- }
824
- }
825
- async function flushDeferredParentIfReady(parentID, ctx) {
826
- if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
827
- if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
828
- if (ctx.sessionTitleService.getSessionStatus(parentID) !== "idle") {
829
- ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
830
- ctx.logger.info("clearing deferred parent idle notification - parent resumed", { sessionId: parentID });
831
- return;
908
+ ctx.logger.error("failed to edit externally answered question", { error: String(err), requestID: event.properties.requestID });
909
+ } finally {
910
+ await ctx.pendingQuestions.deletePending(found.shortHash);
832
911
  }
833
- ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
834
- await sendIdleNotification(parentID, ctx);
835
912
  }
836
- async function deferParentIdleIfDescendantsRunning(sessionId, ctx) {
837
- await hydrateDescendants(sessionId, ctx);
838
- if (!ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) return false;
839
- ctx.sessionTitleService.deferIdleNotification(sessionId);
840
- ctx.logger.info("deferring parent idle notification - child sessions still running", { sessionId });
841
- return true;
913
+
914
+ // src/events/session-created.ts
915
+ async function handleSessionCreated(event, ctx) {
916
+ ctx.sessionTitleService.setSessionInfo(event.properties.info);
842
917
  }
843
- async function handleSessionIdle(event, ctx) {
844
- const sessionId = event.properties.sessionID;
845
- ctx.sessionTitleService.setSessionStatus(sessionId, "idle");
846
- const parentID = await resolveParentID(sessionId, ctx);
847
- if (typeof parentID === "string") {
848
- ctx.logger.info("suppressing child session idle notification", { sessionId, parentID });
849
- await flushDeferredParentIfReady(parentID, ctx);
850
- return;
851
- }
852
- if (parentID === void 0) {
853
- ctx.logger.warn("session parentID unknown; sending idle notification", { sessionId });
854
- }
855
- if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
856
- return;
857
- }
858
- await sleep(ctx.idleRecheckDelayMs ?? ROOT_IDLE_RECHECK_DELAY_MS);
859
- if (ctx.sessionTitleService.getSessionStatus(sessionId) !== "idle") {
860
- ctx.logger.info("idle notification skipped - session resumed during recheck delay", { sessionId });
861
- return;
862
- }
863
- if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
918
+
919
+ // src/lib/abort-tracker.ts
920
+ var ABORT_TTL_MS = 5e3;
921
+ var sessionAborts = /* @__PURE__ */ new Map();
922
+ var lastGlobalAbortAt = 0;
923
+ function noteAbort(sessionID) {
924
+ const now = Date.now();
925
+ if (sessionID) {
926
+ sessionAborts.set(sessionID, now);
864
927
  return;
865
928
  }
866
- await sendIdleNotification(sessionId, ctx);
929
+ lastGlobalAbortAt = now;
867
930
  }
868
- async function handleSessionStatus(event, ctx) {
869
- const sessionId = event.properties.sessionID;
870
- const statusType = event.properties.status.type;
871
- ctx.sessionTitleService.setSessionStatus(sessionId, statusType);
872
- if (statusType === "idle") {
873
- await handleSessionIdle(event, ctx);
931
+ function shouldSuppressIdle(sessionID) {
932
+ const now = Date.now();
933
+ const sessionAbortAt = sessionAborts.get(sessionID) ?? 0;
934
+ const abortAt = Math.max(sessionAbortAt, lastGlobalAbortAt);
935
+ if (abortAt === 0) return false;
936
+ if (now - abortAt <= ABORT_TTL_MS) {
937
+ sessionAborts.delete(sessionID);
938
+ if (abortAt === lastGlobalAbortAt) lastGlobalAbortAt = 0;
939
+ return true;
874
940
  }
941
+ sessionAborts.delete(sessionID);
942
+ if (now - lastGlobalAbortAt > ABORT_TTL_MS) lastGlobalAbortAt = 0;
943
+ return false;
875
944
  }
876
945
 
877
946
  // src/events/session-error.ts
@@ -884,394 +953,477 @@ async function handleSessionError(event, ctx) {
884
953
  ctx.logger.info("session abort recorded", { sessionId: event.properties.sessionID ?? "global" });
885
954
  }
886
955
 
887
- // src/events/session-created.ts
888
- async function handleSessionCreated(event, ctx) {
889
- ctx.sessionTitleService.setSessionInfo(event.properties.info);
890
- }
891
-
892
- // src/events/session-updated.ts
893
- async function handleSessionUpdated(event, ctx) {
894
- const info = event.properties.info;
895
- ctx.sessionTitleService.setSessionInfo(info);
896
- }
897
-
898
- // src/events/permission-updated.ts
899
- var PERMISSION_EXPIRY_MS = 5 * 6e4;
900
- var CALLBACK_RE = /^p:([^:]+):(o|a|r)$/;
901
- function isStringArray(value) {
902
- return Array.isArray(value) && value.every((item) => typeof item === "string");
903
- }
904
- function isEventPermissionAsked(event) {
905
- if (event.type !== "permission.asked") return false;
906
- const props = event.properties;
907
- if (!props) return false;
908
- if (typeof props.id !== "string") return false;
909
- if (typeof props.sessionID !== "string") return false;
910
- if (typeof props.permission !== "string") return false;
911
- if (!isStringArray(props.patterns)) return false;
912
- if (!isStringArray(props.always)) return false;
913
- return true;
914
- }
915
- function buildCallbackData(shortHash, reply) {
916
- const data = `p:${shortHash}:${reply}`;
917
- if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
918
- return data;
919
- }
920
- function normalizeUpdated(permission) {
921
- const pattern = permission.pattern === void 0 ? [] : Array.isArray(permission.pattern) ? permission.pattern : [permission.pattern];
922
- return {
923
- requestID: permission.id,
924
- sessionID: permission.sessionID,
925
- title: permission.title,
926
- permission: permission.type,
927
- patterns: pattern,
928
- always: [],
929
- endpoint: "session",
930
- claimKey: `permission.updated:${permission.id}`
931
- };
932
- }
933
- function normalizeAsked(permission) {
934
- return {
935
- requestID: permission.id,
936
- sessionID: permission.sessionID,
937
- title: permission.patterns.join(", ") || permission.permission,
938
- permission: permission.permission,
939
- patterns: permission.patterns,
940
- always: permission.always,
941
- endpoint: "request",
942
- claimKey: `permission.asked:${permission.id}`
943
- };
944
- }
945
- function permissionMessage(permission, sessionTitle) {
946
- const titleLine = sessionTitle ? `\u{1F4CB} ${sessionTitle}` : `Session: ${permission.sessionID}`;
947
- const patterns = permission.patterns.length > 0 ? `
948
- Patterns: ${permission.patterns.join(", ")}` : "";
949
- const always = permission.always.length > 0 ? `
950
- Always options: ${permission.always.join(", ")}` : "";
951
- return `\u2753 Permission requested
952
-
953
- ${titleLine}
954
-
955
- Permission: ${permission.permission}
956
- Detail: ${permission.title}${patterns}${always}`;
957
- }
958
- function permissionKeyboard(shortHash) {
959
- return [
960
- [{ text: "\u2705 Allow once", callback_data: buildCallbackData(shortHash, "o") }],
961
- [{ text: "\u267B\uFE0F Always allow", callback_data: buildCallbackData(shortHash, "a") }],
962
- [{ text: "\u274C Reject", callback_data: buildCallbackData(shortHash, "r") }]
963
- ];
964
- }
965
- function replyFromSelection(selection) {
966
- if (selection === "o") return "once";
967
- if (selection === "a") return "always";
968
- if (selection === "r") return "reject";
969
- return void 0;
970
- }
971
- function replyLabel(reply) {
972
- if (reply === "once") return "Allowed once";
973
- if (reply === "always") return "Always allowed";
974
- return "Rejected";
975
- }
976
- async function handleNormalizedPermission(permission, ctx) {
977
- const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: permission.claimKey });
978
- if (!claimed) return;
979
- const shortHash = createPermissionShortHash(permission.requestID);
980
- const sentAt = Date.now();
981
- const rawSessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
982
- const sessionTitle = rawSessionTitle === null ? void 0 : rawSessionTitle;
983
- try {
984
- const message = await ctx.bot.sendMessage(permissionMessage(permission, sessionTitle), {
985
- reply_markup: { inline_keyboard: permissionKeyboard(shortHash) }
986
- });
987
- const pending = {
988
- requestID: permission.requestID,
989
- sessionID: permission.sessionID,
990
- title: permission.title,
991
- permission: permission.permission,
992
- patterns: permission.patterns,
993
- always: permission.always,
994
- sentAt,
995
- expiresAt: sentAt + PERMISSION_EXPIRY_MS,
996
- telegramMessageId: message.message_id,
997
- endpoint: permission.endpoint
998
- };
999
- await ctx.pendingPermissions.savePending(shortHash, pending);
1000
- } catch (err) {
1001
- ctx.logger.error("failed to send permission notification", { error: String(err) });
1002
- }
1003
- }
1004
- async function expirePending(ctx, shortHash, pending, messageId) {
1005
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Permission request expired");
1006
- await ctx.pendingPermissions.deletePending(shortHash);
1007
- ctx.logger.info("pending permission expired", { requestID: pending.requestID });
1008
- }
1009
- async function handlePermissionUpdated(event, ctx) {
1010
- await handleNormalizedPermission(normalizeUpdated(event.properties), ctx);
1011
- }
1012
- async function handlePermissionAsked(event, ctx) {
1013
- await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
1014
- }
1015
- function createPermissionDispatcher(ctx) {
956
+ // src/events/start-work.ts
957
+ var CALLBACK_RE3 = /^sw:(.+)$/;
958
+ var START_WORK_COMMAND = "start-work";
959
+ function startWorkCallbackData(sessionID) {
960
+ const data = `sw:${encodeURIComponent(sessionID)}`;
961
+ return Buffer.byteLength(data, "utf8") <= 64 ? data : void 0;
962
+ }
963
+ function startWorkKeyboard(sessionID) {
964
+ const callbackData = startWorkCallbackData(sessionID);
965
+ if (!callbackData) return void 0;
966
+ return [[{ text: "\u25B6\uFE0F Run /start-work", callback_data: callbackData }]];
967
+ }
968
+ function createStartWorkDispatcher(ctx) {
1016
969
  return {
1017
970
  async handleCallbackQuery(data, messageId) {
1018
- const match = CALLBACK_RE.exec(data);
971
+ const match = CALLBACK_RE3.exec(data);
1019
972
  if (!match) return;
1020
- const shortHash = match[1];
1021
- const reply = replyFromSelection(match[2]);
1022
- if (!reply) return;
1023
- const pending = await ctx.pendingPermissions.loadPending(shortHash);
1024
- if (!pending) {
1025
- await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
1026
- return;
1027
- }
1028
- if (pending.expiresAt < Date.now()) {
1029
- await expirePending(ctx, shortHash, pending, messageId);
1030
- return;
1031
- }
973
+ const sessionID = decodeURIComponent(match[1]);
1032
974
  try {
1033
- await ctx.replyToPermission(pending.requestID, pending.sessionID, reply, pending.endpoint);
1034
- await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Permission ${replyLabel(reply)}
975
+ await ctx.runSessionCommand(sessionID, START_WORK_COMMAND);
976
+ await ctx.bot.editMessageRemoveKeyboard(
977
+ messageId,
978
+ `\u25B6\uFE0F Sent /start-work to opencode.
1035
979
 
1036
- ${pending.permission}: ${pending.title}`);
1037
- ctx.logger.info("permission reply sent", { requestID: pending.requestID, sessionID: pending.sessionID, reply });
980
+ Session: ${sessionID}`
981
+ );
982
+ ctx.logger.info("start-work command sent", { sessionID });
1038
983
  } catch (err) {
1039
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send permission reply to opencode");
1040
- ctx.logger.error("failed to send permission reply", { error: String(err), requestID: pending.requestID });
1041
- } finally {
1042
- await ctx.pendingPermissions.deletePending(shortHash);
984
+ await ctx.bot.editMessageRemoveKeyboard(
985
+ messageId,
986
+ `\u26A0\uFE0F Failed to send /start-work to opencode.
987
+
988
+ Session: ${sessionID}`
989
+ );
990
+ ctx.logger.error("failed to send start-work command", { sessionID, error: String(err) });
1043
991
  }
1044
992
  }
1045
993
  };
1046
994
  }
1047
995
 
1048
- // src/events/question-asked.ts
1049
- var QUESTION_EXPIRY_MS = 5 * 6e4;
1050
- var CALLBACK_RE2 = /^q:([^:]+):(\d+):(\d+|c|d)$/;
1051
- function isQuestionOption(value) {
1052
- return typeof value.label === "string" && typeof value.description === "string";
1053
- }
1054
- function isQuestionInfo(value) {
1055
- if (typeof value.question !== "string") return false;
1056
- if (typeof value.header !== "string") return false;
1057
- if (!Array.isArray(value.options)) return false;
1058
- return value.options.every((option) => typeof option === "object" && option !== null && isQuestionOption(option));
996
+ // src/events/session-idle.ts
997
+ var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
998
+ function sleep(ms) {
999
+ return new Promise((resolve) => setTimeout(resolve, ms));
1059
1000
  }
1060
- function isEventQuestionAsked(event) {
1061
- if (event.type !== "question.asked") return false;
1062
- const props = event.properties;
1063
- if (!props) return false;
1064
- if (typeof props.id !== "string") return false;
1065
- if (typeof props.sessionID !== "string") return false;
1066
- if (!Array.isArray(props.questions)) return false;
1067
- return props.questions.every((question) => typeof question === "object" && question !== null && isQuestionInfo(question));
1001
+ async function resolveParentID(sessionId, ctx) {
1002
+ const cachedParentID = ctx.sessionTitleService.getParentID(sessionId);
1003
+ if (cachedParentID !== void 0) return cachedParentID;
1004
+ try {
1005
+ const result = await ctx.client.session.get({ path: { id: sessionId } });
1006
+ if (result.data) {
1007
+ ctx.sessionTitleService.setSessionInfo(result.data);
1008
+ return ctx.sessionTitleService.getParentID(sessionId);
1009
+ }
1010
+ ctx.logger.warn("session parentID cache miss fetch returned no data", { sessionId });
1011
+ return void 0;
1012
+ } catch (err) {
1013
+ ctx.logger.warn("session parentID cache miss fetch failed", { sessionId, error: String(err) });
1014
+ return void 0;
1015
+ }
1068
1016
  }
1069
- function buildCallbackData2(shortHash, questionIndex, optionIndex) {
1070
- const data = `q:${shortHash}:${questionIndex}:${optionIndex}`;
1071
- if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
1072
- return data;
1017
+ async function hydrateDescendants(sessionId, ctx, seen = /* @__PURE__ */ new Set()) {
1018
+ if (seen.has(sessionId)) return;
1019
+ seen.add(sessionId);
1020
+ try {
1021
+ const result = await ctx.client.session.children({ path: { id: sessionId } });
1022
+ for (const child of result.data ?? []) {
1023
+ ctx.sessionTitleService.setSessionInfo(child);
1024
+ await hydrateDescendants(child.id, ctx, seen);
1025
+ }
1026
+ } catch (err) {
1027
+ ctx.logger.warn("session children fetch failed", { sessionId, error: String(err) });
1028
+ }
1073
1029
  }
1074
- function callbackDataForQuestion(shortHash, questionIndex, question) {
1075
- const data = question.options.map((_, optionIndex) => buildCallbackData2(shortHash, questionIndex, optionIndex));
1076
- if (question.custom !== false) data.push(buildCallbackData2(shortHash, questionIndex, "c"));
1077
- return data;
1030
+ async function sendIdleNotification(sessionId, ctx) {
1031
+ if (shouldSuppressIdle(sessionId)) {
1032
+ ctx.logger.info("idle suppressed - session was aborted", { sessionId });
1033
+ return;
1034
+ }
1035
+ const claimed = await claimOnce({
1036
+ claimsDir: ctx.claimsDir,
1037
+ key: `session.idle:${sessionId}`,
1038
+ ttlMs: 5e3
1039
+ });
1040
+ if (!claimed) return;
1041
+ const title = ctx.sessionTitleService.getSessionTitle(sessionId);
1042
+ const message = title ? `Agent has finished: ${title}
1043
+
1044
+ If this was a plan builder session, tap below to run /start-work.` : "Agent has finished.\n\nIf this was a plan builder session, tap below to run /start-work.";
1045
+ const keyboard = startWorkKeyboard(sessionId);
1046
+ try {
1047
+ await ctx.bot.sendMessage(
1048
+ message,
1049
+ keyboard ? { reply_markup: { inline_keyboard: keyboard } } : void 0
1050
+ );
1051
+ ctx.sessionTitleService.clearDeferredIdleNotification(sessionId);
1052
+ ctx.logger.info("idle notification sent", { sessionId, title });
1053
+ } catch (err) {
1054
+ ctx.logger.error("failed to send idle notification", { error: String(err) });
1055
+ }
1078
1056
  }
1079
- function useSimpleQuestionKeyboard(question) {
1080
- return question.multiple !== true;
1057
+ async function flushDeferredParentIfReady(parentID, ctx) {
1058
+ if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
1059
+ if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
1060
+ if (ctx.sessionTitleService.getSessionStatus(parentID) !== "idle") {
1061
+ ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
1062
+ ctx.logger.info("clearing deferred parent idle notification - parent resumed", {
1063
+ sessionId: parentID
1064
+ });
1065
+ return;
1066
+ }
1067
+ ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
1068
+ await sendIdleNotification(parentID, ctx);
1081
1069
  }
1082
- function selectedAnswers(pending, questionIndex) {
1083
- return pending.answersInProgress[questionIndex] ?? [];
1070
+ async function deferParentIdleIfDescendantsRunning(sessionId, ctx) {
1071
+ await hydrateDescendants(sessionId, ctx);
1072
+ if (!ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) return false;
1073
+ ctx.sessionTitleService.deferIdleNotification(sessionId);
1074
+ ctx.logger.info("deferring parent idle notification - child sessions still running", {
1075
+ sessionId
1076
+ });
1077
+ return true;
1084
1078
  }
1085
- function questionInlineKeyboard(shortHash, questionIndex, question, selected) {
1086
- const multiple = question.multiple === true;
1087
- const inlineKeyboard = question.options.map((option, optionIndex) => [{
1088
- text: multiple && selected.includes(option.label) ? `\u2705 ${option.label}` : option.label,
1089
- callback_data: buildCallbackData2(shortHash, questionIndex, optionIndex)
1090
- }]);
1091
- if (question.custom !== false) {
1092
- inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }]);
1079
+ async function handleSessionIdle(event, ctx) {
1080
+ const sessionId = event.properties.sessionID;
1081
+ ctx.sessionTitleService.setSessionStatus(sessionId, "idle");
1082
+ const parentID = await resolveParentID(sessionId, ctx);
1083
+ if (typeof parentID === "string") {
1084
+ ctx.logger.info("suppressing child session idle notification", { sessionId, parentID });
1085
+ await flushDeferredParentIfReady(parentID, ctx);
1086
+ return;
1093
1087
  }
1094
- if (multiple) {
1095
- inlineKeyboard.push([{ text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }]);
1088
+ if (parentID === void 0) {
1089
+ ctx.logger.warn("session parentID unknown; sending idle notification", { sessionId });
1090
+ }
1091
+ if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
1092
+ return;
1093
+ }
1094
+ await sleep(ctx.idleRecheckDelayMs ?? ROOT_IDLE_RECHECK_DELAY_MS);
1095
+ if (ctx.sessionTitleService.getSessionStatus(sessionId) !== "idle") {
1096
+ ctx.logger.info("idle notification skipped - session resumed during recheck delay", {
1097
+ sessionId
1098
+ });
1099
+ return;
1100
+ }
1101
+ if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
1102
+ return;
1103
+ }
1104
+ await sendIdleNotification(sessionId, ctx);
1105
+ }
1106
+ async function handleSessionStatus(event, ctx) {
1107
+ const sessionId = event.properties.sessionID;
1108
+ const statusType = event.properties.status.type;
1109
+ ctx.sessionTitleService.setSessionStatus(sessionId, statusType);
1110
+ if (statusType === "idle") {
1111
+ await handleSessionIdle(event, ctx);
1096
1112
  }
1097
- return inlineKeyboard;
1098
1113
  }
1099
- function questionPromptText(pending, questionIndex) {
1100
- const question = pending.questions[questionIndex];
1101
- const prefix = pending.questions.length > 1 ? `Question ${questionIndex + 1}/${pending.questions.length}
1102
-
1103
- ` : "";
1104
- const allQuestions = pending.questions.length > 1 ? `All questions:
1105
- ${pending.questions.map((q, i) => `${i + 1}. ${q.header}: ${q.question}`).join("\n")}
1106
-
1107
- ` : "";
1108
- return `${allQuestions}${prefix}\u2753 ${question.header}
1109
1114
 
1110
- ${question.question}`;
1115
+ // src/events/session-updated.ts
1116
+ async function handleSessionUpdated(event, ctx) {
1117
+ const info = event.properties.info;
1118
+ ctx.sessionTitleService.setSessionInfo(info);
1111
1119
  }
1112
- function answerSummary(questions, answers) {
1113
- return answers.map((answer, index) => `${index + 1}. ${questions[index]?.header ?? "Question"}: ${answer.join(", ") || "(empty)"}`).join("\n");
1120
+
1121
+ // src/lib/env-loader.ts
1122
+ import { existsSync } from "fs";
1123
+ import { homedir } from "os";
1124
+ import { join as join4 } from "path";
1125
+ import dotenv from "dotenv";
1126
+ function loadPluginEnv(opts) {
1127
+ const paths = [
1128
+ join4(opts.pluginDir, "../../.env"),
1129
+ join4(opts.pluginDir, "..", ".env"),
1130
+ join4(opts.pluginDir, ".env"),
1131
+ join4(opts.homeDir ?? homedir(), ".config/opencode/telegram-remote/.env")
1132
+ ];
1133
+ const loadedFrom = [];
1134
+ const values = {};
1135
+ for (const envPath of paths) {
1136
+ if (!existsSync(envPath)) continue;
1137
+ const result = dotenv.config({ path: envPath, override: false });
1138
+ if (result.parsed) {
1139
+ loadedFrom.push(envPath);
1140
+ for (const [key, value] of Object.entries(result.parsed)) {
1141
+ if (!(key in values)) values[key] = value;
1142
+ }
1143
+ }
1144
+ }
1145
+ return { loadedFrom, values };
1114
1146
  }
1115
- async function editPromptForQuestion(ctx, pending, shortHash, questionIndex) {
1116
- const messageId = pending.telegramMessageIds[0];
1117
- const question = pending.questions[questionIndex];
1118
- const inlineKeyboard = questionInlineKeyboard(shortHash, questionIndex, question, selectedAnswers(pending, questionIndex));
1119
- await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), { reply_markup: { inline_keyboard: inlineKeyboard } });
1147
+
1148
+ // src/lib/lock.ts
1149
+ import { open as open2, readFile as readFile3, stat as stat2, unlink as unlink4 } from "fs/promises";
1150
+ import { hostname } from "os";
1151
+ var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
1152
+ function hasCode4(err, code) {
1153
+ return "code" in err && err.code === code;
1120
1154
  }
1121
- async function completeIfReady(ctx, pending, shortHash) {
1122
- const nextIndex = pending.answersInProgress.findIndex((answer) => answer === null);
1123
- if (nextIndex >= 0) {
1124
- pending.currentQuestionIndex = nextIndex;
1125
- await ctx.pendingQuestions.savePending(shortHash, pending);
1126
- await editPromptForQuestion(ctx, pending, shortHash, nextIndex);
1127
- return;
1155
+ function parseLockData(text) {
1156
+ try {
1157
+ const parsed = JSON.parse(text);
1158
+ if (typeof parsed.pid === "number" && typeof parsed.hostname === "string" && typeof parsed.createdAt === "string") {
1159
+ return { pid: parsed.pid, hostname: parsed.hostname, createdAt: parsed.createdAt };
1160
+ }
1161
+ } catch {
1162
+ return null;
1128
1163
  }
1129
- const answers = pending.answersInProgress.map((answer) => answer ?? []);
1130
- const messageId = pending.telegramMessageIds[0];
1164
+ return null;
1165
+ }
1166
+ function isPidAlive(pid) {
1131
1167
  try {
1132
- await ctx.replyToQuestion(pending.requestID, answers);
1133
- await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Answered:
1134
- ${answerSummary(pending.questions, answers)}`);
1135
- ctx.logger.info("question reply sent", { requestID: pending.requestID, sessionID: pending.sessionID });
1168
+ process.kill(pid, 0);
1169
+ return true;
1136
1170
  } catch (err) {
1137
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send answer to opencode");
1138
- ctx.logger.error("failed to send question reply", { error: String(err), requestID: pending.requestID });
1171
+ if (err instanceof Error && hasCode4(err, "ESRCH")) return false;
1172
+ return true;
1173
+ }
1174
+ }
1175
+ async function createLock(lockPath, pid) {
1176
+ const file = await open2(lockPath, "wx");
1177
+ const acquiredAt = /* @__PURE__ */ new Date();
1178
+ const data = { pid, hostname: hostname(), createdAt: acquiredAt.toISOString() };
1179
+ try {
1180
+ await file.writeFile(JSON.stringify(data), "utf8");
1139
1181
  } finally {
1140
- await ctx.pendingQuestions.deletePending(shortHash);
1182
+ await file.close();
1141
1183
  }
1184
+ let released = false;
1185
+ return {
1186
+ path: lockPath,
1187
+ acquiredAt,
1188
+ async release() {
1189
+ if (released) return;
1190
+ released = true;
1191
+ try {
1192
+ await unlink4(lockPath);
1193
+ } catch {
1194
+ }
1195
+ }
1196
+ };
1142
1197
  }
1143
- async function expirePending2(ctx, shortHash, pending, messageId) {
1144
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
1145
- await ctx.pendingQuestions.deletePending(shortHash);
1146
- ctx.logger.info("pending question expired", { requestID: pending.requestID });
1198
+ async function inspectExisting(lockPath, ttlMs) {
1199
+ let ownerPid;
1200
+ let dead = false;
1201
+ try {
1202
+ const text = await readFile3(lockPath, "utf8");
1203
+ const data = parseLockData(text);
1204
+ if (data) {
1205
+ ownerPid = data.pid;
1206
+ dead = !isPidAlive(data.pid);
1207
+ }
1208
+ } catch {
1209
+ return { stale: true, reason: "unreadable lock" };
1210
+ }
1211
+ try {
1212
+ const fileStat = await stat2(lockPath);
1213
+ const expired = Date.now() - fileStat.mtimeMs > ttlMs;
1214
+ if (dead) return { stale: true, ownerPid, reason: "dead owner" };
1215
+ if (expired) return { stale: true, ownerPid, reason: "expired lock" };
1216
+ return { stale: false, ownerPid, reason: "lock held" };
1217
+ } catch {
1218
+ return { stale: true, ownerPid, reason: "missing lock" };
1219
+ }
1147
1220
  }
1148
- async function handleQuestionAsked(event, ctx) {
1149
- const request = event.properties;
1150
- if (request.questions.length === 0) return;
1151
- const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `question.asked:${request.id}`, ttlMs: 5e3 });
1152
- if (!claimed) return;
1153
- const shortHash = createQuestionShortHash(request.id);
1154
- const firstQuestion = request.questions[0];
1155
- const sentAt = Date.now();
1156
- const pending = {
1157
- requestID: request.id,
1158
- sessionID: request.sessionID,
1159
- questions: request.questions,
1160
- sentAt,
1161
- expiresAt: sentAt + QUESTION_EXPIRY_MS,
1162
- telegramMessageIds: [],
1163
- currentQuestionIndex: 0,
1164
- answersInProgress: request.questions.map(() => null)
1165
- };
1221
+ async function acquireLock(opts) {
1222
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS2;
1223
+ const pid = opts.pid ?? process.pid;
1224
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1225
+ try {
1226
+ return { acquired: true, handle: await createLock(opts.lockPath, pid) };
1227
+ } catch (err) {
1228
+ if (!(err instanceof Error) || !hasCode4(err, "EEXIST")) {
1229
+ return { acquired: false, reason: err instanceof Error ? err.message : String(err) };
1230
+ }
1231
+ const existing = await inspectExisting(opts.lockPath, ttlMs);
1232
+ if (!existing.stale || attempt === 1) {
1233
+ return { acquired: false, reason: existing.reason, ownerPid: existing.ownerPid };
1234
+ }
1235
+ try {
1236
+ await unlink4(opts.lockPath);
1237
+ } catch {
1238
+ return { acquired: false, reason: "failed to remove stale lock", ownerPid: existing.ownerPid };
1239
+ }
1240
+ }
1241
+ }
1242
+ return { acquired: false, reason: "lock acquisition failed" };
1243
+ }
1244
+
1245
+ // src/lib/logger.ts
1246
+ import { appendFile } from "fs/promises";
1247
+ import { tmpdir as tmpdir3 } from "os";
1248
+ var DEFAULT_BUFFER_LIMIT = 4096;
1249
+ var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
1250
+ function safeJson(data) {
1166
1251
  try {
1167
- const message = request.questions.length === 1 && useSimpleQuestionKeyboard(firstQuestion) ? await ctx.bot.sendQuestionWithKeyboard(firstQuestion, callbackDataForQuestion(shortHash, 0, firstQuestion)) : await ctx.bot.sendMessage(questionPromptText(pending, 0), {
1168
- reply_markup: {
1169
- inline_keyboard: questionInlineKeyboard(shortHash, 0, firstQuestion, [])
1252
+ return JSON.stringify(data);
1253
+ } catch {
1254
+ return '{"serialization":"failed"}';
1255
+ }
1256
+ }
1257
+ function createLogger(opts = {}) {
1258
+ const filePath = opts.filePath ?? `${tmpdir3()}/opencoder-telegram.log`;
1259
+ const namespace = opts.namespace ?? "default";
1260
+ const bufferLimit = opts.bufferLimit ?? DEFAULT_BUFFER_LIMIT;
1261
+ const flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
1262
+ let buffer = "";
1263
+ let closed = false;
1264
+ let flushing = Promise.resolve();
1265
+ const timer = setInterval(() => {
1266
+ void flushBuffer();
1267
+ }, flushIntervalMs);
1268
+ timer.unref();
1269
+ async function flushBuffer() {
1270
+ if (buffer.length === 0) return flushing;
1271
+ const chunk = buffer;
1272
+ buffer = "";
1273
+ flushing = flushing.then(async () => {
1274
+ try {
1275
+ await appendFile(filePath, chunk, "utf8");
1276
+ } catch {
1170
1277
  }
1171
1278
  });
1172
- pending.telegramMessageIds = [message.message_id];
1173
- await ctx.pendingQuestions.savePending(shortHash, pending);
1174
- ctx.logger.info("question prompt sent", { requestID: request.id, sessionID: request.sessionID, count: request.questions.length });
1175
- } catch (err) {
1176
- ctx.logger.error("failed to send question prompt", { error: String(err), requestID: request.id });
1279
+ return flushing;
1280
+ }
1281
+ function write(level, msg, data) {
1282
+ if (closed) return;
1283
+ const json = data === void 0 ? "" : ` ${safeJson(data)}`;
1284
+ buffer += `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] [${process.pid}] [${namespace}] ${msg}${json}
1285
+ `;
1286
+ if (level === "error" || buffer.length >= bufferLimit) {
1287
+ void flushBuffer();
1288
+ }
1177
1289
  }
1290
+ return {
1291
+ debug(msg, data) {
1292
+ write("debug", msg, data);
1293
+ },
1294
+ info(msg, data) {
1295
+ write("info", msg, data);
1296
+ },
1297
+ warn(msg, data) {
1298
+ write("warn", msg, data);
1299
+ },
1300
+ error(msg, data) {
1301
+ write("error", msg, data);
1302
+ },
1303
+ async flush() {
1304
+ await flushBuffer();
1305
+ },
1306
+ async close() {
1307
+ if (closed) return;
1308
+ closed = true;
1309
+ clearInterval(timer);
1310
+ await flushBuffer();
1311
+ }
1312
+ };
1178
1313
  }
1179
- function createQuestionDispatcher(ctx) {
1314
+
1315
+ // src/lib/state-store.ts
1316
+ import { mkdir as mkdir4, readFile as readFile4, rename as rename3, writeFile as writeFile3 } from "fs/promises";
1317
+ import { homedir as homedir2 } from "os";
1318
+ import { dirname as dirname3, join as join5 } from "path";
1319
+ function hasCode5(err, code) {
1320
+ return "code" in err && err.code === code;
1321
+ }
1322
+ function parseState(text) {
1323
+ const parsed = JSON.parse(text);
1324
+ const state = {};
1325
+ if (typeof parsed.chatId === "number") state.chatId = parsed.chatId;
1326
+ if (typeof parsed.updatedAt === "string") state.updatedAt = parsed.updatedAt;
1327
+ if (typeof parsed.discoveredBy === "number") state.discoveredBy = parsed.discoveredBy;
1328
+ return state;
1329
+ }
1330
+ function createStateStore(opts = {}) {
1331
+ const filePath = opts.filePath ?? join5(homedir2(), ".config/opencode/telegram-remote/state.json");
1180
1332
  return {
1181
- async handleCallbackQuery(data, messageId, chatId, userId) {
1182
- const match = CALLBACK_RE2.exec(data);
1183
- if (!match) return;
1184
- const shortHash = match[1];
1185
- const questionIndex = Number(match[2]);
1186
- const selection = match[3];
1187
- const pending = await ctx.pendingQuestions.loadPending(shortHash);
1188
- if (!pending) {
1189
- await ctx.bot.editMessageRemoveKeyboard(messageId, "This question has expired.");
1190
- return;
1191
- }
1192
- if (pending.expiresAt < Date.now()) {
1193
- await expirePending2(ctx, shortHash, pending, messageId);
1194
- return;
1195
- }
1196
- const question = pending.questions[questionIndex];
1197
- if (!question) return;
1198
- if (selection === "c") {
1199
- if (question.multiple === true) {
1200
- await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), { reply_markup: { inline_keyboard: [] } });
1201
- } else {
1202
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u270F\uFE0F Reply to the next message with your custom answer.");
1203
- }
1204
- const prompt = await ctx.bot.replyWithForceReply("Type your custom answer", "Type your answer");
1205
- pending.awaitingCustomFor = { shortHash, questionIndex, chatId, userId, promptMessageId: prompt.message_id };
1206
- await ctx.pendingQuestions.savePending(shortHash, pending);
1207
- return;
1208
- }
1209
- if (selection === "d") {
1210
- if (question.multiple !== true) return;
1211
- pending.answersInProgress[questionIndex] = selectedAnswers(pending, questionIndex);
1212
- pending.awaitingCustomFor = void 0;
1213
- await completeIfReady(ctx, pending, shortHash);
1214
- return;
1215
- }
1216
- const option = question.options[Number(selection)];
1217
- if (!option) return;
1218
- if (question.multiple === true) {
1219
- const current = selectedAnswers(pending, questionIndex);
1220
- pending.answersInProgress[questionIndex] = current.includes(option.label) ? current.filter((answer) => answer !== option.label) : [...current, option.label];
1221
- pending.awaitingCustomFor = void 0;
1222
- await ctx.pendingQuestions.savePending(shortHash, pending);
1223
- await editPromptForQuestion(ctx, pending, shortHash, questionIndex);
1224
- return;
1333
+ async read() {
1334
+ try {
1335
+ return parseState(await readFile4(filePath, "utf8"));
1336
+ } catch (err) {
1337
+ if (err instanceof Error && hasCode5(err, "ENOENT")) return {};
1338
+ throw err;
1225
1339
  }
1226
- pending.answersInProgress[questionIndex] = [option.label];
1227
- pending.awaitingCustomFor = void 0;
1228
- await completeIfReady(ctx, pending, shortHash);
1229
1340
  },
1230
- async handleTextReply(text, chatId, userId, replyToMessageId) {
1231
- const match = await ctx.pendingQuestions.findAwaitingCustom(chatId, userId);
1232
- if (!match) return;
1233
- const awaiting = match.data.awaitingCustomFor;
1234
- if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
1235
- if (match.data.expiresAt < Date.now()) {
1236
- await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
1237
- return;
1238
- }
1239
- const question = match.data.questions[awaiting.questionIndex];
1240
- if (question?.multiple === true) {
1241
- const current = selectedAnswers(match.data, awaiting.questionIndex);
1242
- match.data.answersInProgress[awaiting.questionIndex] = current.includes(text) ? current : [...current, text];
1243
- match.data.awaitingCustomFor = void 0;
1244
- await ctx.bot.sendMessage("\u2705 Custom answer added. Tap Done when finished.");
1245
- await ctx.pendingQuestions.savePending(match.shortHash, match.data);
1246
- await editPromptForQuestion(ctx, match.data, match.shortHash, awaiting.questionIndex);
1247
- return;
1341
+ async write(patch) {
1342
+ const existing = await this.read();
1343
+ const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1344
+ await mkdir4(dirname3(filePath), { recursive: true });
1345
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
1346
+ await writeFile3(tmpPath, JSON.stringify(next, null, 2), "utf8");
1347
+ try {
1348
+ await rename3(tmpPath, filePath);
1349
+ } catch (err) {
1350
+ if (!(err instanceof Error) || !hasCode5(err, "ENOENT")) throw err;
1351
+ await writeFile3(tmpPath, JSON.stringify(next, null, 2), "utf8");
1352
+ await rename3(tmpPath, filePath);
1248
1353
  }
1249
- match.data.answersInProgress[awaiting.questionIndex] = [text];
1250
- match.data.awaitingCustomFor = void 0;
1251
- await ctx.bot.sendMessage("\u2705 Custom answer sent.");
1252
- await completeIfReady(ctx, match.data, match.shortHash);
1354
+ return next;
1253
1355
  }
1254
1356
  };
1255
1357
  }
1256
1358
 
1257
- // src/events/question-replied.ts
1258
- function isEventQuestionReplied(event) {
1259
- if (event.type !== "question.replied") return false;
1260
- const props = event.properties;
1261
- return Boolean(props && typeof props.requestID === "string" && typeof props.sessionID === "string");
1262
- }
1263
- async function handleQuestionReplied(event, ctx) {
1264
- const found = await ctx.pendingQuestions.findByRequestID(event.properties.requestID);
1265
- if (!found) return;
1266
- const messageId = found.data.telegramMessageIds[0];
1267
- try {
1268
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u2705 Already answered in opencode.");
1269
- } catch (err) {
1270
- ctx.logger.error("failed to edit externally answered question", { error: String(err), requestID: event.properties.requestID });
1271
- } finally {
1272
- await ctx.pendingQuestions.deletePending(found.shortHash);
1359
+ // src/services/session-title-service.ts
1360
+ var SessionTitleService = class {
1361
+ sessions = /* @__PURE__ */ new Map();
1362
+ setSessionInfo(info) {
1363
+ const existing = this.sessions.get(info.id);
1364
+ this.sessions.set(info.id, {
1365
+ title: info.title || null,
1366
+ parentID: info.parentID ?? null,
1367
+ status: existing?.status,
1368
+ idleNotificationPending: existing?.idleNotificationPending ?? false
1369
+ });
1273
1370
  }
1274
- }
1371
+ setSessionTitle(sessionId, title) {
1372
+ const existing = this.sessions.get(sessionId);
1373
+ this.sessions.set(sessionId, {
1374
+ title,
1375
+ parentID: existing?.parentID ?? null,
1376
+ status: existing?.status,
1377
+ idleNotificationPending: existing?.idleNotificationPending ?? false
1378
+ });
1379
+ }
1380
+ setSessionStatus(sessionId, status) {
1381
+ const existing = this.sessions.get(sessionId);
1382
+ this.sessions.set(sessionId, {
1383
+ title: existing?.title ?? null,
1384
+ parentID: existing?.parentID ?? null,
1385
+ status,
1386
+ idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
1387
+ });
1388
+ }
1389
+ getSessionTitle(sessionId) {
1390
+ return this.sessions.get(sessionId)?.title ?? null;
1391
+ }
1392
+ getParentID(sessionId) {
1393
+ return this.sessions.get(sessionId)?.parentID;
1394
+ }
1395
+ getSessionStatus(sessionId) {
1396
+ return this.sessions.get(sessionId)?.status;
1397
+ }
1398
+ hasUnfinishedDescendants(parentID) {
1399
+ for (const [sessionID, session] of this.sessions.entries()) {
1400
+ if (session.parentID !== parentID) continue;
1401
+ if (session.status !== "idle") return true;
1402
+ if (this.hasUnfinishedDescendants(sessionID)) return true;
1403
+ }
1404
+ return false;
1405
+ }
1406
+ deferIdleNotification(sessionId) {
1407
+ const existing = this.sessions.get(sessionId);
1408
+ this.sessions.set(sessionId, {
1409
+ title: existing?.title ?? null,
1410
+ parentID: existing?.parentID ?? null,
1411
+ status: existing?.status ?? "idle",
1412
+ idleNotificationPending: true
1413
+ });
1414
+ }
1415
+ hasDeferredIdleNotification(sessionId) {
1416
+ return this.sessions.get(sessionId)?.idleNotificationPending ?? false;
1417
+ }
1418
+ clearDeferredIdleNotification(sessionId) {
1419
+ const existing = this.sessions.get(sessionId);
1420
+ if (!existing) return;
1421
+ this.sessions.set(sessionId, {
1422
+ ...existing,
1423
+ idleNotificationPending: false
1424
+ });
1425
+ }
1426
+ };
1275
1427
 
1276
1428
  // src/telegram-remote.ts
1277
1429
  var pluginDir = dirname4(fileURLToPath(import.meta.url));
@@ -1294,7 +1446,11 @@ var TelegramRemote = async (input) => {
1294
1446
  `lock ${isLeader ? "acquired - leader mode" : "held by other - pass-through mode"}`,
1295
1447
  isLeader ? {} : { reason: lockResult.reason }
1296
1448
  );
1297
- logger.info("server url", { url: input.serverUrl.toString(), href: input.serverUrl.href, origin: input.serverUrl.origin });
1449
+ logger.info("server url", {
1450
+ url: input.serverUrl.toString(),
1451
+ href: input.serverUrl.href,
1452
+ origin: input.serverUrl.origin
1453
+ });
1298
1454
  const sessionTitleService = new SessionTitleService();
1299
1455
  const client = input.client;
1300
1456
  const replyToQuestion = async (requestID, answers) => {
@@ -1322,6 +1478,13 @@ var TelegramRemote = async (input) => {
1322
1478
  throwOnError: true
1323
1479
  });
1324
1480
  };
1481
+ const runSessionCommand = async (sessionID, command) => {
1482
+ await input.client.session.command({
1483
+ path: { id: sessionID },
1484
+ body: { command, arguments: "" },
1485
+ throwOnError: true
1486
+ });
1487
+ };
1325
1488
  const bot = createTelegramBot({
1326
1489
  config,
1327
1490
  stateStore,
@@ -1367,11 +1530,13 @@ var TelegramRemote = async (input) => {
1367
1530
  pendingQuestions,
1368
1531
  pendingPermissions,
1369
1532
  replyToQuestion,
1370
- replyToPermission
1533
+ replyToPermission,
1534
+ runSessionCommand
1371
1535
  };
1372
1536
  if (isLeader) {
1373
1537
  bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
1374
1538
  bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
1539
+ bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
1375
1540
  }
1376
1541
  return {
1377
1542
  event: async ({ event }) => {
@@ -1409,7 +1574,9 @@ var TelegramRemote = async (input) => {
1409
1574
  }
1410
1575
  };
1411
1576
  } catch (err) {
1412
- logger.error("plugin initialization failed", { error: err instanceof Error ? err.message : String(err) });
1577
+ logger.error("plugin initialization failed", {
1578
+ error: err instanceof Error ? err.message : String(err)
1579
+ });
1413
1580
  await logger.close();
1414
1581
  return { event: async () => {
1415
1582
  } };