@coinseeker/opencode-telegram-plugin 1.0.6 → 1.0.8

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;
97
- }
98
- return null;
99
- }
100
- function isPidAlive(pid) {
101
- try {
102
- process.kill(pid, 0);
103
- return true;
104
- } catch (err) {
105
- if (err instanceof Error && hasCode(err, "ESRCH")) return false;
106
- return true;
208
+ // src/config.ts
209
+ function parseAllowedUserIds(value) {
210
+ if (!value || value.trim() === "") {
211
+ return [];
107
212
  }
213
+ return value.split(",").map((id2) => id2.trim()).filter((id2) => id2 !== "").map((id2) => Number.parseInt(id2, 10)).filter((id2) => !Number.isNaN(id2));
108
214
  }
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();
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");
117
223
  }
118
- let released = false;
119
- return {
120
- path: lockPath,
121
- acquiredAt,
122
- async release() {
123
- if (released) return;
124
- released = true;
125
- try {
126
- await unlink(lockPath);
127
- } catch {
128
- }
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");
228
+ }
229
+ let chatId;
230
+ if (chatIdStr && chatIdStr.trim() !== "") {
231
+ const parsed = Number.parseInt(chatIdStr.trim(), 10);
232
+ if (!Number.isNaN(parsed)) {
233
+ chatId = parsed;
129
234
  }
235
+ }
236
+ logger.info("config loaded", { allowedUserCount: allowedUserIds.length, hasChatId: chatId !== void 0 });
237
+ return {
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,387 +390,530 @@ 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 };
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");
428
398
  }
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));
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;
436
409
  }
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 });
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
-
469
- // src/lib/question-format.ts
470
- function optionDescriptionText(question) {
471
- const options = question.options.map((option, index) => {
472
- const description = option.description.trim();
473
- return description ? `${index + 1}. ${option.label}
474
- > ${description}` : `${index + 1}. ${option.label}`;
475
- });
476
- return options.length > 0 ? `
448
+ ${titleLine}
477
449
 
478
- ${options.join("\n")}` : "";
450
+ Permission: ${permission.permission}
451
+ Detail: ${permission.title}${patterns}${always}`;
479
452
  }
480
- function questionText(question) {
481
- const header = question.header ? `\u2753 ${question.header}` : "\u2753 Question";
482
- return `${header}
483
-
484
- ${question.question}${optionDescriptionText(question)}`;
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
+ ];
485
459
  }
486
- function pendingQuestionText(questions, questionIndex) {
487
- const question = questions[questionIndex];
488
- const prefix = questions.length > 1 ? `Question ${questionIndex + 1}/${questions.length}
489
-
490
- ` : "";
491
- const allQuestions = questions.length > 1 ? `All questions:
492
- ${questions.map((q, i) => `${i + 1}. ${q.header}: ${q.question}`).join("\n")}
493
-
494
- ` : "";
495
- return `${allQuestions}${prefix}${questionText(question)}`;
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;
496
465
  }
497
-
498
- // src/bot.ts
499
- function createTelegramBot(opts) {
500
- const { config, stateStore, logger, polling } = opts;
501
- const bot = new Bot(config.botToken);
502
- let activeChatId = opts.initialChatId;
503
- let questionDispatcher;
504
- let permissionDispatcher;
505
- if (polling) {
506
- bot.use(async (ctx, next) => {
507
- const userId = ctx.from?.id;
508
- if (!userId || !config.allowedUserIds.includes(userId)) {
509
- logger.warn("unauthorized access attempt", { userId });
510
- return;
511
- }
512
- if (ctx.chat?.type !== "private") return;
513
- if (ctx.chat?.id) {
514
- const newChatId = ctx.chat.id;
515
- if (activeChatId !== newChatId) {
516
- activeChatId = newChatId;
517
- await stateStore.write({ chatId: newChatId, discoveredBy: process.pid });
518
- logger.info("chat_id discovered", { chatId: newChatId });
519
- await ctx.reply(
520
- `\u2705 Chat connected!
521
-
522
- Your chat_id: ${newChatId}
523
-
524
- This chat is now active for OpenCode notifications.`
525
- );
526
- }
527
- }
528
- await next();
529
- });
530
- bot.catch((err) => {
531
- const e = err.error;
532
- if (e instanceof GrammyError && e.error_code === 409) {
533
- logger.info("polling conflict (409) - another process took over", {
534
- description: e.description
535
- });
536
- } else {
537
- logger.error("bot error", { error: String(e) });
538
- }
539
- });
540
- bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c|d)$/, async (ctx) => {
541
- await ctx.answerCallbackQuery();
542
- const data = ctx.callbackQuery.data;
543
- const messageId = ctx.callbackQuery.message?.message_id;
544
- const chatId = ctx.chat?.id;
545
- const userId = ctx.from?.id;
546
- if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0)
547
- return;
548
- await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
549
- });
550
- bot.callbackQuery(/^p:([^:]+):(o|a|r)$/, async (ctx) => {
551
- await ctx.answerCallbackQuery();
552
- const data = ctx.callbackQuery.data;
553
- const messageId = ctx.callbackQuery.message?.message_id;
554
- if (!permissionDispatcher || messageId === void 0) return;
555
- await permissionDispatcher.handleCallbackQuery(data, messageId);
556
- });
557
- bot.on("message:text", async (ctx) => {
558
- const replyToMessageId = ctx.message.reply_to_message?.message_id;
559
- const chatId = ctx.chat.id;
560
- const userId = ctx.from?.id;
561
- if (!questionDispatcher || replyToMessageId === void 0 || userId === void 0) return;
562
- await questionDispatcher.handleTextReply(ctx.message.text, chatId, userId, replyToMessageId);
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) }
563
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) });
564
497
  }
565
- const requireChatId = async (action) => {
566
- if (activeChatId) return activeChatId;
567
- const state = await stateStore.read();
568
- if (state.chatId) {
569
- activeChatId = state.chatId;
570
- return state.chatId;
571
- }
572
- throw new Error(`No active chat for ${action}. Send any message to the bot first.`);
573
- };
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) {
574
511
  return {
575
- async start() {
576
- if (!polling) {
577
- 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.");
578
521
  return;
579
522
  }
580
- await bot.start({
581
- drop_pending_updates: true,
582
- onStart: () => {
583
- logger.info("polling started");
584
- }
585
- });
586
- },
587
- async stop() {
588
- if (polling) {
589
- try {
590
- await bot.stop();
591
- } catch (err) {
592
- logger.warn("bot.stop() error", { error: String(err) });
593
- }
523
+ if (pending.expiresAt < Date.now()) {
524
+ await expirePending(ctx, shortHash, pending, messageId);
525
+ return;
594
526
  }
595
- },
596
- async sendMessage(text, options) {
597
- const chatId = await requireChatId("sendMessage");
598
- const result = await bot.api.sendMessage(chatId, text, options);
599
- return { message_id: result.message_id };
600
- },
601
- async sendQuestionWithKeyboard(question, callbackData) {
602
- const inlineKeyboard = question.options.map((option, index) => [
603
- {
604
- text: option.label,
605
- callback_data: callbackData[index] ?? ""
606
- }
607
- ]);
608
- if (callbackData[question.options.length]) {
609
- inlineKeyboard.push([
610
- { text: "\u270F\uFE0F Custom answer", callback_data: callbackData[question.options.length] }
611
- ]);
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);
612
538
  }
613
- return this.sendMessage(questionText(question), {
614
- reply_markup: { inline_keyboard: inlineKeyboard }
615
- });
616
- },
617
- async editMessage(messageId, text) {
618
- const chatId = await requireChatId("editMessage");
619
- await bot.api.editMessageText(chatId, messageId, text);
539
+ }
540
+ };
541
+ }
542
+
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);
620
586
  },
621
- async editMessageText(messageId, text, options) {
622
- const chatId = await requireChatId("editMessageText");
623
- 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
+ }
624
594
  },
625
- async editMessageRemoveKeyboard(messageId, finalText) {
626
- 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
+ }
627
601
  },
628
- async replyWithForceReply(text, placeholder) {
629
- return this.sendMessage(text, {
630
- reply_markup: {
631
- force_reply: true,
632
- 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);
633
610
  }
634
- });
635
- },
636
- async deleteMessage(messageId) {
637
- const chatId = await requireChatId("deleteMessage");
638
- await bot.api.deleteMessage(chatId, messageId);
639
- },
640
- async getActiveChatId() {
641
- if (activeChatId) return activeChatId;
642
- const state = await stateStore.read();
643
- return state.chatId;
611
+ }
612
+ return expired;
644
613
  },
645
- setQuestionDispatcher(dispatcher) {
646
- 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;
647
621
  },
648
- setPermissionDispatcher(dispatcher) {
649
- permissionDispatcher = dispatcher;
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;
650
630
  }
651
631
  };
652
632
  }
633
+ function createQuestionShortHash(requestID) {
634
+ return createHash3("sha256").update(requestID).digest("base64url").slice(0, 10);
635
+ }
653
636
 
654
- // src/services/session-title-service.ts
655
- var SessionTitleService = class {
656
- sessions = /* @__PURE__ */ new Map();
657
- setSessionInfo(info) {
658
- const existing = this.sessions.get(info.id);
659
- this.sessions.set(info.id, {
660
- title: info.title || null,
661
- parentID: info.parentID ?? null,
662
- status: existing?.status,
663
- idleNotificationPending: existing?.idleNotificationPending ?? false
664
- });
665
- }
666
- setSessionTitle(sessionId, title) {
667
- const existing = this.sessions.get(sessionId);
668
- this.sessions.set(sessionId, {
669
- title,
670
- parentID: existing?.parentID ?? null,
671
- status: existing?.status,
672
- idleNotificationPending: existing?.idleNotificationPending ?? false
673
- });
674
- }
675
- setSessionStatus(sessionId, status) {
676
- const existing = this.sessions.get(sessionId);
677
- this.sessions.set(sessionId, {
678
- title: existing?.title ?? null,
679
- parentID: existing?.parentID ?? null,
680
- status,
681
- idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
682
- });
683
- }
684
- getSessionTitle(sessionId) {
685
- return this.sessions.get(sessionId)?.title ?? null;
686
- }
687
- getParentID(sessionId) {
688
- return this.sessions.get(sessionId)?.parentID;
689
- }
690
- getSessionStatus(sessionId) {
691
- return this.sessions.get(sessionId)?.status;
692
- }
693
- hasUnfinishedDescendants(parentID) {
694
- for (const [sessionID, session] of this.sessions.entries()) {
695
- if (session.parentID !== parentID) continue;
696
- if (session.status !== "idle") return true;
697
- if (this.hasUnfinishedDescendants(sessionID)) return true;
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";
642
+ }
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
+ );
650
+ }
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
+ );
661
+ }
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;
667
+ }
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)
698
687
  }
699
- return false;
688
+ ]);
689
+ if (question.custom !== false) {
690
+ inlineKeyboard.push([
691
+ { text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }
692
+ ]);
700
693
  }
701
- deferIdleNotification(sessionId) {
702
- const existing = this.sessions.get(sessionId);
703
- this.sessions.set(sessionId, {
704
- title: existing?.title ?? null,
705
- parentID: existing?.parentID ?? null,
706
- status: existing?.status ?? "idle",
707
- idleNotificationPending: true
708
- });
694
+ if (multiple) {
695
+ inlineKeyboard.push([
696
+ { text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }
697
+ ]);
709
698
  }
710
- hasDeferredIdleNotification(sessionId) {
711
- return this.sessions.get(sessionId)?.idleNotificationPending ?? false;
699
+ return inlineKeyboard;
700
+ }
701
+ function questionPromptText(pending, questionIndex) {
702
+ return pendingQuestionText(pending.questions, questionIndex);
703
+ }
704
+ function answerSummary(questions, answers) {
705
+ return answers.map(
706
+ (answer, index) => `${index + 1}. ${questions[index]?.header ?? "Question"}: ${answer.join(", ") || "(empty)"}`
707
+ ).join("\n");
708
+ }
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;
712
729
  }
713
- clearDeferredIdleNotification(sessionId) {
714
- const existing = this.sessions.get(sessionId);
715
- if (!existing) return;
716
- this.sessions.set(sessionId, {
717
- ...existing,
718
- idleNotificationPending: false
730
+ const answers = pending.answersInProgress.map((answer) => answer ?? []);
731
+ const messageId = pending.telegramMessageIds[0];
732
+ try {
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
719
742
  });
743
+ } catch (err) {
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);
720
751
  }
721
- };
722
-
723
- // src/lib/claim.ts
724
- import { mkdir as mkdir4, open as open2, readdir as readdir3, stat as stat2, unlink as unlink4 } from "fs/promises";
725
- import { join as join5 } from "path";
726
- import { createHash as createHash3 } from "crypto";
727
- var DEFAULT_TTL_MS2 = 6e4;
728
- var sweptDirs = /* @__PURE__ */ new Set();
729
- function hasCode5(err, code) {
730
- return "code" in err && err.code === code;
731
752
  }
732
- function claimPath(claimsDir, key) {
733
- const hash = createHash3("sha256").update(key).digest("hex").slice(0, 16);
734
- return join5(claimsDir, `${hash}.claim`);
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 });
735
757
  }
736
- async function sweep(claimsDir, ttlMs) {
737
- if (sweptDirs.has(claimsDir)) return;
738
- sweptDirs.add(claimsDir);
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
+ };
739
780
  try {
740
- const entries = await readdir3(claimsDir, { withFileTypes: true });
741
- await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".claim")).map(async (entry) => {
742
- const filePath = join5(claimsDir, entry.name);
743
- try {
744
- const fileStat = await stat2(filePath);
745
- if (Date.now() - fileStat.mtimeMs > ttlMs * 2) {
746
- await unlink4(filePath);
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
+ });
796
+ } catch (err) {
797
+ ctx.logger.error("failed to send question prompt", {
798
+ error: String(err),
799
+ requestID: request.id
800
+ });
801
+ }
802
+ }
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
+ );
747
832
  }
748
- } catch {
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;
749
876
  }
750
- }));
751
- } catch {
752
- }
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
+ };
753
893
  }
754
- async function createClaim(filePath) {
755
- const file = await open2(filePath, "wx");
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];
756
905
  try {
757
- await file.writeFile((/* @__PURE__ */ new Date()).toISOString(), "utf8");
906
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u2705 Already answered in opencode.");
907
+ } catch (err) {
908
+ ctx.logger.error("failed to edit externally answered question", { error: String(err), requestID: event.properties.requestID });
758
909
  } finally {
759
- await file.close();
910
+ await ctx.pendingQuestions.deletePending(found.shortHash);
760
911
  }
761
- return true;
762
912
  }
763
- async function claimOnce(opts) {
764
- const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS2;
765
- await mkdir4(opts.claimsDir, { recursive: true });
766
- await sweep(opts.claimsDir, ttlMs);
767
- const filePath = claimPath(opts.claimsDir, opts.key);
768
- for (let attempt = 0; attempt < 2; attempt += 1) {
769
- try {
770
- return await createClaim(filePath);
771
- } catch (err) {
772
- if (!(err instanceof Error) || !hasCode5(err, "EEXIST")) throw err;
773
- try {
774
- const fileStat = await stat2(filePath);
775
- if (Date.now() - fileStat.mtimeMs <= ttlMs || attempt === 1) return false;
776
- await unlink4(filePath);
777
- } catch (statErr) {
778
- if (statErr instanceof Error && hasCode5(statErr, "ENOENT")) continue;
779
- return false;
780
- }
781
- }
782
- }
783
- return false;
913
+
914
+ // src/events/session-created.ts
915
+ async function handleSessionCreated(event, ctx) {
916
+ ctx.sessionTitleService.setSessionInfo(event.properties.info);
784
917
  }
785
918
 
786
919
  // src/lib/abort-tracker.ts
@@ -810,6 +943,94 @@ function shouldSuppressIdle(sessionID) {
810
943
  return false;
811
944
  }
812
945
 
946
+ // src/events/session-error.ts
947
+ function isEventSessionError(event) {
948
+ return event.type === "session.error";
949
+ }
950
+ async function handleSessionError(event, ctx) {
951
+ if (event.properties.error?.name !== "MessageAbortedError") return;
952
+ noteAbort(event.properties.sessionID);
953
+ ctx.logger.info("session abort recorded", { sessionId: event.properties.sessionID ?? "global" });
954
+ }
955
+
956
+ // src/events/start-work.ts
957
+ var CALLBACK_RE3 = /^sw:(.+)$/;
958
+ var START_WORK_COMMAND = "start-work";
959
+ var START_WORK_RE = /(?:^|[\s`])\/start-work(?:\s+([^\n`]+))?/g;
960
+ var StartWorkCommandStore = class {
961
+ commands = /* @__PURE__ */ new Map();
962
+ updateFromText(sessionID, text) {
963
+ const command = extractStartWorkCommand(sessionID, text);
964
+ if (!command) return void 0;
965
+ this.commands.set(sessionID, command.arguments);
966
+ return command;
967
+ }
968
+ get(sessionID) {
969
+ const args = this.commands.get(sessionID);
970
+ if (args === void 0) return void 0;
971
+ return { sessionID, arguments: args };
972
+ }
973
+ delete(sessionID) {
974
+ this.commands.delete(sessionID);
975
+ }
976
+ };
977
+ function extractStartWorkCommand(sessionID, text) {
978
+ let latestArgs;
979
+ for (const match of text.matchAll(START_WORK_RE)) {
980
+ const args = (match[1] ?? "").trim();
981
+ if (args) latestArgs = args;
982
+ }
983
+ if (latestArgs === void 0) return void 0;
984
+ return { sessionID, arguments: latestArgs };
985
+ }
986
+ function startWorkCallbackData(sessionID) {
987
+ const data = `sw:${encodeURIComponent(sessionID)}`;
988
+ return Buffer.byteLength(data, "utf8") <= 64 ? data : void 0;
989
+ }
990
+ function startWorkKeyboard(sessionID) {
991
+ const callbackData = startWorkCallbackData(sessionID);
992
+ if (!callbackData) return void 0;
993
+ return [[{ text: "\u25B6\uFE0F Run /start-work", callback_data: callbackData }]];
994
+ }
995
+ function createStartWorkDispatcher(ctx) {
996
+ return {
997
+ async handleCallbackQuery(data, messageId) {
998
+ const match = CALLBACK_RE3.exec(data);
999
+ if (!match) return;
1000
+ const sessionID = decodeURIComponent(match[1]);
1001
+ const command = ctx.startWorkCommands.get(sessionID);
1002
+ if (!command) {
1003
+ await ctx.bot.editMessageRemoveKeyboard(
1004
+ messageId,
1005
+ `\u26A0\uFE0F No /start-work command was detected for this session.
1006
+
1007
+ Session: ${sessionID}`
1008
+ );
1009
+ return;
1010
+ }
1011
+ try {
1012
+ await ctx.runSessionCommand(sessionID, START_WORK_COMMAND, command.arguments);
1013
+ await ctx.bot.editMessageRemoveKeyboard(
1014
+ messageId,
1015
+ `\u25B6\uFE0F Sent /start-work ${command.arguments} to opencode.
1016
+
1017
+ Session: ${sessionID}`
1018
+ );
1019
+ ctx.startWorkCommands.delete(sessionID);
1020
+ ctx.logger.info("start-work command sent", { sessionID });
1021
+ } catch (err) {
1022
+ await ctx.bot.editMessageRemoveKeyboard(
1023
+ messageId,
1024
+ `\u26A0\uFE0F Failed to send /start-work to opencode.
1025
+
1026
+ Session: ${sessionID}`
1027
+ );
1028
+ ctx.logger.error("failed to send start-work command", { sessionID, error: String(err) });
1029
+ }
1030
+ }
1031
+ };
1032
+ }
1033
+
813
1034
  // src/events/session-idle.ts
814
1035
  var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
815
1036
  function sleep(ms) {
@@ -849,12 +1070,24 @@ async function sendIdleNotification(sessionId, ctx) {
849
1070
  ctx.logger.info("idle suppressed - session was aborted", { sessionId });
850
1071
  return;
851
1072
  }
852
- const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `session.idle:${sessionId}`, ttlMs: 5e3 });
1073
+ const claimed = await claimOnce({
1074
+ claimsDir: ctx.claimsDir,
1075
+ key: `session.idle:${sessionId}`,
1076
+ ttlMs: 5e3
1077
+ });
853
1078
  if (!claimed) return;
854
1079
  const title = ctx.sessionTitleService.getSessionTitle(sessionId);
1080
+ const startWorkCommand = ctx.startWorkCommands.get(sessionId);
855
1081
  const message = title ? `Agent has finished: ${title}` : "Agent has finished.";
1082
+ const keyboard = startWorkCommand ? startWorkKeyboard(sessionId) : void 0;
1083
+ const text = startWorkCommand ? `${message}
1084
+
1085
+ Plan is ready. Tap below to run /start-work ${startWorkCommand.arguments}.` : message;
856
1086
  try {
857
- await ctx.bot.sendMessage(message);
1087
+ await ctx.bot.sendMessage(
1088
+ text,
1089
+ keyboard ? { reply_markup: { inline_keyboard: keyboard } } : void 0
1090
+ );
858
1091
  ctx.sessionTitleService.clearDeferredIdleNotification(sessionId);
859
1092
  ctx.logger.info("idle notification sent", { sessionId, title });
860
1093
  } catch (err) {
@@ -866,7 +1099,9 @@ async function flushDeferredParentIfReady(parentID, ctx) {
866
1099
  if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
867
1100
  if (ctx.sessionTitleService.getSessionStatus(parentID) !== "idle") {
868
1101
  ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
869
- ctx.logger.info("clearing deferred parent idle notification - parent resumed", { sessionId: parentID });
1102
+ ctx.logger.info("clearing deferred parent idle notification - parent resumed", {
1103
+ sessionId: parentID
1104
+ });
870
1105
  return;
871
1106
  }
872
1107
  ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
@@ -876,7 +1111,9 @@ async function deferParentIdleIfDescendantsRunning(sessionId, ctx) {
876
1111
  await hydrateDescendants(sessionId, ctx);
877
1112
  if (!ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) return false;
878
1113
  ctx.sessionTitleService.deferIdleNotification(sessionId);
879
- ctx.logger.info("deferring parent idle notification - child sessions still running", { sessionId });
1114
+ ctx.logger.info("deferring parent idle notification - child sessions still running", {
1115
+ sessionId
1116
+ });
880
1117
  return true;
881
1118
  }
882
1119
  async function handleSessionIdle(event, ctx) {
@@ -896,7 +1133,9 @@ async function handleSessionIdle(event, ctx) {
896
1133
  }
897
1134
  await sleep(ctx.idleRecheckDelayMs ?? ROOT_IDLE_RECHECK_DELAY_MS);
898
1135
  if (ctx.sessionTitleService.getSessionStatus(sessionId) !== "idle") {
899
- ctx.logger.info("idle notification skipped - session resumed during recheck delay", { sessionId });
1136
+ ctx.logger.info("idle notification skipped - session resumed during recheck delay", {
1137
+ sessionId
1138
+ });
900
1139
  return;
901
1140
  }
902
1141
  if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
@@ -913,456 +1152,331 @@ async function handleSessionStatus(event, ctx) {
913
1152
  }
914
1153
  }
915
1154
 
916
- // src/events/session-error.ts
917
- function isEventSessionError(event) {
918
- return event.type === "session.error";
919
- }
920
- async function handleSessionError(event, ctx) {
921
- if (event.properties.error?.name !== "MessageAbortedError") return;
922
- noteAbort(event.properties.sessionID);
923
- ctx.logger.info("session abort recorded", { sessionId: event.properties.sessionID ?? "global" });
924
- }
925
-
926
- // src/events/session-created.ts
927
- async function handleSessionCreated(event, ctx) {
928
- ctx.sessionTitleService.setSessionInfo(event.properties.info);
929
- }
930
-
931
1155
  // src/events/session-updated.ts
932
1156
  async function handleSessionUpdated(event, ctx) {
933
1157
  const info = event.properties.info;
934
1158
  ctx.sessionTitleService.setSessionInfo(info);
935
1159
  }
936
1160
 
937
- // src/events/permission-updated.ts
938
- var PERMISSION_EXPIRY_MS = 5 * 6e4;
939
- var CALLBACK_RE = /^p:([^:]+):(o|a|r)$/;
940
- function isStringArray(value) {
941
- return Array.isArray(value) && value.every((item) => typeof item === "string");
942
- }
943
- function isEventPermissionAsked(event) {
944
- if (event.type !== "permission.asked") return false;
945
- const props = event.properties;
946
- if (!props) return false;
947
- if (typeof props.id !== "string") return false;
948
- if (typeof props.sessionID !== "string") return false;
949
- if (typeof props.permission !== "string") return false;
950
- if (!isStringArray(props.patterns)) return false;
951
- if (!isStringArray(props.always)) return false;
952
- return true;
953
- }
954
- function buildCallbackData(shortHash, reply) {
955
- const data = `p:${shortHash}:${reply}`;
956
- if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
957
- return data;
958
- }
959
- function normalizeUpdated(permission) {
960
- const pattern = permission.pattern === void 0 ? [] : Array.isArray(permission.pattern) ? permission.pattern : [permission.pattern];
961
- return {
962
- requestID: permission.id,
963
- sessionID: permission.sessionID,
964
- title: permission.title,
965
- permission: permission.type,
966
- patterns: pattern,
967
- always: [],
968
- endpoint: "session",
969
- claimKey: `permission.updated:${permission.id}`
970
- };
971
- }
972
- function normalizeAsked(permission) {
973
- return {
974
- requestID: permission.id,
975
- sessionID: permission.sessionID,
976
- title: permission.patterns.join(", ") || permission.permission,
977
- permission: permission.permission,
978
- patterns: permission.patterns,
979
- always: permission.always,
980
- endpoint: "request",
981
- claimKey: `permission.asked:${permission.id}`
982
- };
983
- }
984
- function permissionMessage(permission, sessionTitle) {
985
- const titleLine = sessionTitle ? `\u{1F4CB} ${sessionTitle}` : `Session: ${permission.sessionID}`;
986
- const patterns = permission.patterns.length > 0 ? `
987
- Patterns: ${permission.patterns.join(", ")}` : "";
988
- const always = permission.always.length > 0 ? `
989
- Always options: ${permission.always.join(", ")}` : "";
990
- return `\u2753 Permission requested
991
-
992
- ${titleLine}
993
-
994
- Permission: ${permission.permission}
995
- Detail: ${permission.title}${patterns}${always}`;
996
- }
997
- function permissionKeyboard(shortHash) {
998
- return [
999
- [{ text: "\u2705 Allow once", callback_data: buildCallbackData(shortHash, "o") }],
1000
- [{ text: "\u267B\uFE0F Always allow", callback_data: buildCallbackData(shortHash, "a") }],
1001
- [{ text: "\u274C Reject", callback_data: buildCallbackData(shortHash, "r") }]
1161
+ // src/lib/env-loader.ts
1162
+ import { existsSync } from "fs";
1163
+ import { homedir } from "os";
1164
+ import { join as join4 } from "path";
1165
+ import dotenv from "dotenv";
1166
+ function loadPluginEnv(opts) {
1167
+ const paths = [
1168
+ join4(opts.pluginDir, "../../.env"),
1169
+ join4(opts.pluginDir, "..", ".env"),
1170
+ join4(opts.pluginDir, ".env"),
1171
+ join4(opts.homeDir ?? homedir(), ".config/opencode/telegram-remote/.env")
1002
1172
  ];
1173
+ const loadedFrom = [];
1174
+ const values = {};
1175
+ for (const envPath of paths) {
1176
+ if (!existsSync(envPath)) continue;
1177
+ const result = dotenv.config({ path: envPath, override: false });
1178
+ if (result.parsed) {
1179
+ loadedFrom.push(envPath);
1180
+ for (const [key, value] of Object.entries(result.parsed)) {
1181
+ if (!(key in values)) values[key] = value;
1182
+ }
1183
+ }
1184
+ }
1185
+ return { loadedFrom, values };
1003
1186
  }
1004
- function replyFromSelection(selection) {
1005
- if (selection === "o") return "once";
1006
- if (selection === "a") return "always";
1007
- if (selection === "r") return "reject";
1008
- return void 0;
1187
+
1188
+ // src/lib/lock.ts
1189
+ import { open as open2, readFile as readFile3, stat as stat2, unlink as unlink4 } from "fs/promises";
1190
+ import { hostname } from "os";
1191
+ var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
1192
+ function hasCode4(err, code) {
1193
+ return "code" in err && err.code === code;
1009
1194
  }
1010
- function replyLabel(reply) {
1011
- if (reply === "once") return "Allowed once";
1012
- if (reply === "always") return "Always allowed";
1013
- return "Rejected";
1195
+ function parseLockData(text) {
1196
+ try {
1197
+ const parsed = JSON.parse(text);
1198
+ if (typeof parsed.pid === "number" && typeof parsed.hostname === "string" && typeof parsed.createdAt === "string") {
1199
+ return { pid: parsed.pid, hostname: parsed.hostname, createdAt: parsed.createdAt };
1200
+ }
1201
+ } catch {
1202
+ return null;
1203
+ }
1204
+ return null;
1014
1205
  }
1015
- async function handleNormalizedPermission(permission, ctx) {
1016
- const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: permission.claimKey });
1017
- if (!claimed) return;
1018
- const shortHash = createPermissionShortHash(permission.requestID);
1019
- const sentAt = Date.now();
1020
- const rawSessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
1021
- const sessionTitle = rawSessionTitle === null ? void 0 : rawSessionTitle;
1206
+ function isPidAlive(pid) {
1022
1207
  try {
1023
- const message = await ctx.bot.sendMessage(permissionMessage(permission, sessionTitle), {
1024
- reply_markup: { inline_keyboard: permissionKeyboard(shortHash) }
1025
- });
1026
- const pending = {
1027
- requestID: permission.requestID,
1028
- sessionID: permission.sessionID,
1029
- title: permission.title,
1030
- permission: permission.permission,
1031
- patterns: permission.patterns,
1032
- always: permission.always,
1033
- sentAt,
1034
- expiresAt: sentAt + PERMISSION_EXPIRY_MS,
1035
- telegramMessageId: message.message_id,
1036
- endpoint: permission.endpoint
1037
- };
1038
- await ctx.pendingPermissions.savePending(shortHash, pending);
1208
+ process.kill(pid, 0);
1209
+ return true;
1039
1210
  } catch (err) {
1040
- ctx.logger.error("failed to send permission notification", { error: String(err) });
1211
+ if (err instanceof Error && hasCode4(err, "ESRCH")) return false;
1212
+ return true;
1041
1213
  }
1042
1214
  }
1043
- async function expirePending(ctx, shortHash, pending, messageId) {
1044
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Permission request expired");
1045
- await ctx.pendingPermissions.deletePending(shortHash);
1046
- ctx.logger.info("pending permission expired", { requestID: pending.requestID });
1047
- }
1048
- async function handlePermissionUpdated(event, ctx) {
1049
- await handleNormalizedPermission(normalizeUpdated(event.properties), ctx);
1050
- }
1051
- async function handlePermissionAsked(event, ctx) {
1052
- await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
1053
- }
1054
- function createPermissionDispatcher(ctx) {
1215
+ async function createLock(lockPath, pid) {
1216
+ const file = await open2(lockPath, "wx");
1217
+ const acquiredAt = /* @__PURE__ */ new Date();
1218
+ const data = { pid, hostname: hostname(), createdAt: acquiredAt.toISOString() };
1219
+ try {
1220
+ await file.writeFile(JSON.stringify(data), "utf8");
1221
+ } finally {
1222
+ await file.close();
1223
+ }
1224
+ let released = false;
1055
1225
  return {
1056
- async handleCallbackQuery(data, messageId) {
1057
- const match = CALLBACK_RE.exec(data);
1058
- if (!match) return;
1059
- const shortHash = match[1];
1060
- const reply = replyFromSelection(match[2]);
1061
- if (!reply) return;
1062
- const pending = await ctx.pendingPermissions.loadPending(shortHash);
1063
- if (!pending) {
1064
- await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
1065
- return;
1066
- }
1067
- if (pending.expiresAt < Date.now()) {
1068
- await expirePending(ctx, shortHash, pending, messageId);
1069
- return;
1070
- }
1226
+ path: lockPath,
1227
+ acquiredAt,
1228
+ async release() {
1229
+ if (released) return;
1230
+ released = true;
1071
1231
  try {
1072
- await ctx.replyToPermission(pending.requestID, pending.sessionID, reply, pending.endpoint);
1073
- await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Permission ${replyLabel(reply)}
1074
-
1075
- ${pending.permission}: ${pending.title}`);
1076
- ctx.logger.info("permission reply sent", { requestID: pending.requestID, sessionID: pending.sessionID, reply });
1077
- } catch (err) {
1078
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send permission reply to opencode");
1079
- ctx.logger.error("failed to send permission reply", { error: String(err), requestID: pending.requestID });
1080
- } finally {
1081
- await ctx.pendingPermissions.deletePending(shortHash);
1232
+ await unlink4(lockPath);
1233
+ } catch {
1082
1234
  }
1083
1235
  }
1084
1236
  };
1085
1237
  }
1086
-
1087
- // src/events/question-asked.ts
1088
- var QUESTION_EXPIRY_MS = 5 * 6e4;
1089
- var CALLBACK_RE2 = /^q:([^:]+):(\d+):(\d+|c|d)$/;
1090
- function isQuestionOption(value) {
1091
- return typeof value.label === "string" && typeof value.description === "string";
1092
- }
1093
- function isQuestionInfo(value) {
1094
- if (typeof value.question !== "string") return false;
1095
- if (typeof value.header !== "string") return false;
1096
- if (!Array.isArray(value.options)) return false;
1097
- return value.options.every(
1098
- (option) => typeof option === "object" && option !== null && isQuestionOption(option)
1099
- );
1100
- }
1101
- function isEventQuestionAsked(event) {
1102
- if (event.type !== "question.asked") return false;
1103
- const props = event.properties;
1104
- if (!props) return false;
1105
- if (typeof props.id !== "string") return false;
1106
- if (typeof props.sessionID !== "string") return false;
1107
- if (!Array.isArray(props.questions)) return false;
1108
- return props.questions.every(
1109
- (question) => typeof question === "object" && question !== null && isQuestionInfo(question)
1110
- );
1111
- }
1112
- function buildCallbackData2(shortHash, questionIndex, optionIndex) {
1113
- const data = `q:${shortHash}:${questionIndex}:${optionIndex}`;
1114
- if (Buffer.byteLength(data, "utf8") > 64)
1115
- throw new Error("Telegram callback_data exceeds 64 bytes");
1116
- return data;
1117
- }
1118
- function callbackDataForQuestion(shortHash, questionIndex, question) {
1119
- const data = question.options.map(
1120
- (_, optionIndex) => buildCallbackData2(shortHash, questionIndex, optionIndex)
1121
- );
1122
- if (question.custom !== false) data.push(buildCallbackData2(shortHash, questionIndex, "c"));
1123
- return data;
1124
- }
1125
- function useSimpleQuestionKeyboard(question) {
1126
- return question.multiple !== true;
1127
- }
1128
- function selectedAnswers(pending, questionIndex) {
1129
- return pending.answersInProgress[questionIndex] ?? [];
1130
- }
1131
- function questionInlineKeyboard(shortHash, questionIndex, question, selected) {
1132
- const multiple = question.multiple === true;
1133
- const inlineKeyboard = question.options.map((option, optionIndex) => [
1134
- {
1135
- text: multiple && selected.includes(option.label) ? `\u2705 ${option.label}` : option.label,
1136
- callback_data: buildCallbackData2(shortHash, questionIndex, optionIndex)
1238
+ async function inspectExisting(lockPath, ttlMs) {
1239
+ let ownerPid;
1240
+ let dead = false;
1241
+ try {
1242
+ const text = await readFile3(lockPath, "utf8");
1243
+ const data = parseLockData(text);
1244
+ if (data) {
1245
+ ownerPid = data.pid;
1246
+ dead = !isPidAlive(data.pid);
1137
1247
  }
1138
- ]);
1139
- if (question.custom !== false) {
1140
- inlineKeyboard.push([
1141
- { text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }
1142
- ]);
1248
+ } catch {
1249
+ return { stale: true, reason: "unreadable lock" };
1143
1250
  }
1144
- if (multiple) {
1145
- inlineKeyboard.push([
1146
- { text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }
1147
- ]);
1251
+ try {
1252
+ const fileStat = await stat2(lockPath);
1253
+ const expired = Date.now() - fileStat.mtimeMs > ttlMs;
1254
+ if (dead) return { stale: true, ownerPid, reason: "dead owner" };
1255
+ if (expired) return { stale: true, ownerPid, reason: "expired lock" };
1256
+ return { stale: false, ownerPid, reason: "lock held" };
1257
+ } catch {
1258
+ return { stale: true, ownerPid, reason: "missing lock" };
1148
1259
  }
1149
- return inlineKeyboard;
1150
- }
1151
- function questionPromptText(pending, questionIndex) {
1152
- return pendingQuestionText(pending.questions, questionIndex);
1153
- }
1154
- function answerSummary(questions, answers) {
1155
- return answers.map(
1156
- (answer, index) => `${index + 1}. ${questions[index]?.header ?? "Question"}: ${answer.join(", ") || "(empty)"}`
1157
- ).join("\n");
1158
- }
1159
- async function editPromptForQuestion(ctx, pending, shortHash, questionIndex) {
1160
- const messageId = pending.telegramMessageIds[0];
1161
- const question = pending.questions[questionIndex];
1162
- const inlineKeyboard = questionInlineKeyboard(
1163
- shortHash,
1164
- questionIndex,
1165
- question,
1166
- selectedAnswers(pending, questionIndex)
1167
- );
1168
- await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), {
1169
- reply_markup: { inline_keyboard: inlineKeyboard }
1170
- });
1171
1260
  }
1172
- async function completeIfReady(ctx, pending, shortHash) {
1173
- const nextIndex = pending.answersInProgress.findIndex((answer) => answer === null);
1174
- if (nextIndex >= 0) {
1175
- pending.currentQuestionIndex = nextIndex;
1176
- await ctx.pendingQuestions.savePending(shortHash, pending);
1177
- await editPromptForQuestion(ctx, pending, shortHash, nextIndex);
1178
- return;
1261
+ async function acquireLock(opts) {
1262
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS2;
1263
+ const pid = opts.pid ?? process.pid;
1264
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1265
+ try {
1266
+ return { acquired: true, handle: await createLock(opts.lockPath, pid) };
1267
+ } catch (err) {
1268
+ if (!(err instanceof Error) || !hasCode4(err, "EEXIST")) {
1269
+ return { acquired: false, reason: err instanceof Error ? err.message : String(err) };
1270
+ }
1271
+ const existing = await inspectExisting(opts.lockPath, ttlMs);
1272
+ if (!existing.stale || attempt === 1) {
1273
+ return { acquired: false, reason: existing.reason, ownerPid: existing.ownerPid };
1274
+ }
1275
+ try {
1276
+ await unlink4(opts.lockPath);
1277
+ } catch {
1278
+ return { acquired: false, reason: "failed to remove stale lock", ownerPid: existing.ownerPid };
1279
+ }
1280
+ }
1179
1281
  }
1180
- const answers = pending.answersInProgress.map((answer) => answer ?? []);
1181
- const messageId = pending.telegramMessageIds[0];
1282
+ return { acquired: false, reason: "lock acquisition failed" };
1283
+ }
1284
+
1285
+ // src/lib/logger.ts
1286
+ import { appendFile } from "fs/promises";
1287
+ import { tmpdir as tmpdir3 } from "os";
1288
+ var DEFAULT_BUFFER_LIMIT = 4096;
1289
+ var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
1290
+ function safeJson(data) {
1182
1291
  try {
1183
- await ctx.replyToQuestion(pending.requestID, answers);
1184
- await ctx.bot.editMessageRemoveKeyboard(
1185
- messageId,
1186
- `\u2705 Answered:
1187
- ${answerSummary(pending.questions, answers)}`
1188
- );
1189
- ctx.logger.info("question reply sent", {
1190
- requestID: pending.requestID,
1191
- sessionID: pending.sessionID
1192
- });
1193
- } catch (err) {
1194
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send answer to opencode");
1195
- ctx.logger.error("failed to send question reply", {
1196
- error: String(err),
1197
- requestID: pending.requestID
1198
- });
1199
- } finally {
1200
- await ctx.pendingQuestions.deletePending(shortHash);
1292
+ return JSON.stringify(data);
1293
+ } catch {
1294
+ return '{"serialization":"failed"}';
1201
1295
  }
1202
1296
  }
1203
- async function expirePending2(ctx, shortHash, pending, messageId) {
1204
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
1205
- await ctx.pendingQuestions.deletePending(shortHash);
1206
- ctx.logger.info("pending question expired", { requestID: pending.requestID });
1207
- }
1208
- async function handleQuestionAsked(event, ctx) {
1209
- const request = event.properties;
1210
- if (request.questions.length === 0) return;
1211
- const claimed = await claimOnce({
1212
- claimsDir: ctx.claimsDir,
1213
- key: `question.asked:${request.id}`,
1214
- ttlMs: 5e3
1215
- });
1216
- if (!claimed) return;
1217
- const shortHash = createQuestionShortHash(request.id);
1218
- const firstQuestion = request.questions[0];
1219
- const sentAt = Date.now();
1220
- const pending = {
1221
- requestID: request.id,
1222
- sessionID: request.sessionID,
1223
- questions: request.questions,
1224
- sentAt,
1225
- expiresAt: sentAt + QUESTION_EXPIRY_MS,
1226
- telegramMessageIds: [],
1227
- currentQuestionIndex: 0,
1228
- answersInProgress: request.questions.map(() => null)
1229
- };
1230
- try {
1231
- const message = request.questions.length === 1 && useSimpleQuestionKeyboard(firstQuestion) ? await ctx.bot.sendQuestionWithKeyboard(
1232
- firstQuestion,
1233
- callbackDataForQuestion(shortHash, 0, firstQuestion)
1234
- ) : await ctx.bot.sendMessage(questionPromptText(pending, 0), {
1235
- reply_markup: {
1236
- inline_keyboard: questionInlineKeyboard(shortHash, 0, firstQuestion, [])
1297
+ function createLogger(opts = {}) {
1298
+ const filePath = opts.filePath ?? `${tmpdir3()}/opencoder-telegram.log`;
1299
+ const namespace = opts.namespace ?? "default";
1300
+ const bufferLimit = opts.bufferLimit ?? DEFAULT_BUFFER_LIMIT;
1301
+ const flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
1302
+ let buffer = "";
1303
+ let closed = false;
1304
+ let flushing = Promise.resolve();
1305
+ const timer = setInterval(() => {
1306
+ void flushBuffer();
1307
+ }, flushIntervalMs);
1308
+ timer.unref();
1309
+ async function flushBuffer() {
1310
+ if (buffer.length === 0) return flushing;
1311
+ const chunk = buffer;
1312
+ buffer = "";
1313
+ flushing = flushing.then(async () => {
1314
+ try {
1315
+ await appendFile(filePath, chunk, "utf8");
1316
+ } catch {
1237
1317
  }
1238
1318
  });
1239
- pending.telegramMessageIds = [message.message_id];
1240
- await ctx.pendingQuestions.savePending(shortHash, pending);
1241
- ctx.logger.info("question prompt sent", {
1242
- requestID: request.id,
1243
- sessionID: request.sessionID,
1244
- count: request.questions.length
1245
- });
1246
- } catch (err) {
1247
- ctx.logger.error("failed to send question prompt", {
1248
- error: String(err),
1249
- requestID: request.id
1250
- });
1319
+ return flushing;
1320
+ }
1321
+ function write(level, msg, data) {
1322
+ if (closed) return;
1323
+ const json = data === void 0 ? "" : ` ${safeJson(data)}`;
1324
+ buffer += `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] [${process.pid}] [${namespace}] ${msg}${json}
1325
+ `;
1326
+ if (level === "error" || buffer.length >= bufferLimit) {
1327
+ void flushBuffer();
1328
+ }
1251
1329
  }
1330
+ return {
1331
+ debug(msg, data) {
1332
+ write("debug", msg, data);
1333
+ },
1334
+ info(msg, data) {
1335
+ write("info", msg, data);
1336
+ },
1337
+ warn(msg, data) {
1338
+ write("warn", msg, data);
1339
+ },
1340
+ error(msg, data) {
1341
+ write("error", msg, data);
1342
+ },
1343
+ async flush() {
1344
+ await flushBuffer();
1345
+ },
1346
+ async close() {
1347
+ if (closed) return;
1348
+ closed = true;
1349
+ clearInterval(timer);
1350
+ await flushBuffer();
1351
+ }
1352
+ };
1252
1353
  }
1253
- function createQuestionDispatcher(ctx) {
1354
+
1355
+ // src/lib/state-store.ts
1356
+ import { mkdir as mkdir4, readFile as readFile4, rename as rename3, writeFile as writeFile3 } from "fs/promises";
1357
+ import { homedir as homedir2 } from "os";
1358
+ import { dirname as dirname3, join as join5 } from "path";
1359
+ function hasCode5(err, code) {
1360
+ return "code" in err && err.code === code;
1361
+ }
1362
+ function parseState(text) {
1363
+ const parsed = JSON.parse(text);
1364
+ const state = {};
1365
+ if (typeof parsed.chatId === "number") state.chatId = parsed.chatId;
1366
+ if (typeof parsed.updatedAt === "string") state.updatedAt = parsed.updatedAt;
1367
+ if (typeof parsed.discoveredBy === "number") state.discoveredBy = parsed.discoveredBy;
1368
+ return state;
1369
+ }
1370
+ function createStateStore(opts = {}) {
1371
+ const filePath = opts.filePath ?? join5(homedir2(), ".config/opencode/telegram-remote/state.json");
1254
1372
  return {
1255
- async handleCallbackQuery(data, messageId, chatId, userId) {
1256
- const match = CALLBACK_RE2.exec(data);
1257
- if (!match) return;
1258
- const shortHash = match[1];
1259
- const questionIndex = Number(match[2]);
1260
- const selection = match[3];
1261
- const pending = await ctx.pendingQuestions.loadPending(shortHash);
1262
- if (!pending) {
1263
- await ctx.bot.editMessageRemoveKeyboard(messageId, "This question has expired.");
1264
- return;
1265
- }
1266
- if (pending.expiresAt < Date.now()) {
1267
- await expirePending2(ctx, shortHash, pending, messageId);
1268
- return;
1269
- }
1270
- const question = pending.questions[questionIndex];
1271
- if (!question) return;
1272
- if (selection === "c") {
1273
- if (question.multiple === true) {
1274
- await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), {
1275
- reply_markup: { inline_keyboard: [] }
1276
- });
1277
- } else {
1278
- await ctx.bot.editMessageRemoveKeyboard(
1279
- messageId,
1280
- "\u270F\uFE0F Reply to the next message with your custom answer."
1281
- );
1282
- }
1283
- const prompt = await ctx.bot.replyWithForceReply(
1284
- "Type your custom answer",
1285
- "Type your answer"
1286
- );
1287
- pending.awaitingCustomFor = {
1288
- shortHash,
1289
- questionIndex,
1290
- chatId,
1291
- userId,
1292
- promptMessageId: prompt.message_id
1293
- };
1294
- await ctx.pendingQuestions.savePending(shortHash, pending);
1295
- return;
1296
- }
1297
- if (selection === "d") {
1298
- if (question.multiple !== true) return;
1299
- pending.answersInProgress[questionIndex] = selectedAnswers(pending, questionIndex);
1300
- pending.awaitingCustomFor = void 0;
1301
- await completeIfReady(ctx, pending, shortHash);
1302
- return;
1303
- }
1304
- const option = question.options[Number(selection)];
1305
- if (!option) return;
1306
- if (question.multiple === true) {
1307
- const current = selectedAnswers(pending, questionIndex);
1308
- pending.answersInProgress[questionIndex] = current.includes(option.label) ? current.filter((answer) => answer !== option.label) : [...current, option.label];
1309
- pending.awaitingCustomFor = void 0;
1310
- await ctx.pendingQuestions.savePending(shortHash, pending);
1311
- await editPromptForQuestion(ctx, pending, shortHash, questionIndex);
1312
- return;
1373
+ async read() {
1374
+ try {
1375
+ return parseState(await readFile4(filePath, "utf8"));
1376
+ } catch (err) {
1377
+ if (err instanceof Error && hasCode5(err, "ENOENT")) return {};
1378
+ throw err;
1313
1379
  }
1314
- pending.answersInProgress[questionIndex] = [option.label];
1315
- pending.awaitingCustomFor = void 0;
1316
- await completeIfReady(ctx, pending, shortHash);
1317
1380
  },
1318
- async handleTextReply(text, chatId, userId, replyToMessageId) {
1319
- const match = await ctx.pendingQuestions.findAwaitingCustom(chatId, userId);
1320
- if (!match) return;
1321
- const awaiting = match.data.awaitingCustomFor;
1322
- if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
1323
- if (match.data.expiresAt < Date.now()) {
1324
- await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
1325
- return;
1326
- }
1327
- const question = match.data.questions[awaiting.questionIndex];
1328
- if (question?.multiple === true) {
1329
- const current = selectedAnswers(match.data, awaiting.questionIndex);
1330
- match.data.answersInProgress[awaiting.questionIndex] = current.includes(text) ? current : [...current, text];
1331
- match.data.awaitingCustomFor = void 0;
1332
- await ctx.bot.sendMessage("\u2705 Custom answer added. Tap Done when finished.");
1333
- await ctx.pendingQuestions.savePending(match.shortHash, match.data);
1334
- await editPromptForQuestion(ctx, match.data, match.shortHash, awaiting.questionIndex);
1335
- return;
1381
+ async write(patch) {
1382
+ const existing = await this.read();
1383
+ const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1384
+ await mkdir4(dirname3(filePath), { recursive: true });
1385
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
1386
+ await writeFile3(tmpPath, JSON.stringify(next, null, 2), "utf8");
1387
+ try {
1388
+ await rename3(tmpPath, filePath);
1389
+ } catch (err) {
1390
+ if (!(err instanceof Error) || !hasCode5(err, "ENOENT")) throw err;
1391
+ await writeFile3(tmpPath, JSON.stringify(next, null, 2), "utf8");
1392
+ await rename3(tmpPath, filePath);
1336
1393
  }
1337
- match.data.answersInProgress[awaiting.questionIndex] = [text];
1338
- match.data.awaitingCustomFor = void 0;
1339
- await ctx.bot.sendMessage("\u2705 Custom answer sent.");
1340
- await completeIfReady(ctx, match.data, match.shortHash);
1394
+ return next;
1341
1395
  }
1342
1396
  };
1343
1397
  }
1344
1398
 
1345
- // src/events/question-replied.ts
1346
- function isEventQuestionReplied(event) {
1347
- if (event.type !== "question.replied") return false;
1348
- const props = event.properties;
1349
- return Boolean(props && typeof props.requestID === "string" && typeof props.sessionID === "string");
1350
- }
1351
- async function handleQuestionReplied(event, ctx) {
1352
- const found = await ctx.pendingQuestions.findByRequestID(event.properties.requestID);
1353
- if (!found) return;
1354
- const messageId = found.data.telegramMessageIds[0];
1355
- try {
1356
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u2705 Already answered in opencode.");
1357
- } catch (err) {
1358
- ctx.logger.error("failed to edit externally answered question", { error: String(err), requestID: event.properties.requestID });
1359
- } finally {
1360
- await ctx.pendingQuestions.deletePending(found.shortHash);
1399
+ // src/services/session-title-service.ts
1400
+ var SessionTitleService = class {
1401
+ sessions = /* @__PURE__ */ new Map();
1402
+ setSessionInfo(info) {
1403
+ const existing = this.sessions.get(info.id);
1404
+ this.sessions.set(info.id, {
1405
+ title: info.title || null,
1406
+ parentID: info.parentID ?? null,
1407
+ status: existing?.status,
1408
+ idleNotificationPending: existing?.idleNotificationPending ?? false
1409
+ });
1361
1410
  }
1362
- }
1411
+ setSessionTitle(sessionId, title) {
1412
+ const existing = this.sessions.get(sessionId);
1413
+ this.sessions.set(sessionId, {
1414
+ title,
1415
+ parentID: existing?.parentID ?? null,
1416
+ status: existing?.status,
1417
+ idleNotificationPending: existing?.idleNotificationPending ?? false
1418
+ });
1419
+ }
1420
+ setSessionStatus(sessionId, status) {
1421
+ const existing = this.sessions.get(sessionId);
1422
+ this.sessions.set(sessionId, {
1423
+ title: existing?.title ?? null,
1424
+ parentID: existing?.parentID ?? null,
1425
+ status,
1426
+ idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
1427
+ });
1428
+ }
1429
+ getSessionTitle(sessionId) {
1430
+ return this.sessions.get(sessionId)?.title ?? null;
1431
+ }
1432
+ getParentID(sessionId) {
1433
+ return this.sessions.get(sessionId)?.parentID;
1434
+ }
1435
+ getSessionStatus(sessionId) {
1436
+ return this.sessions.get(sessionId)?.status;
1437
+ }
1438
+ hasUnfinishedDescendants(parentID) {
1439
+ for (const [sessionID, session] of this.sessions.entries()) {
1440
+ if (session.parentID !== parentID) continue;
1441
+ if (session.status !== "idle") return true;
1442
+ if (this.hasUnfinishedDescendants(sessionID)) return true;
1443
+ }
1444
+ return false;
1445
+ }
1446
+ deferIdleNotification(sessionId) {
1447
+ const existing = this.sessions.get(sessionId);
1448
+ this.sessions.set(sessionId, {
1449
+ title: existing?.title ?? null,
1450
+ parentID: existing?.parentID ?? null,
1451
+ status: existing?.status ?? "idle",
1452
+ idleNotificationPending: true
1453
+ });
1454
+ }
1455
+ hasDeferredIdleNotification(sessionId) {
1456
+ return this.sessions.get(sessionId)?.idleNotificationPending ?? false;
1457
+ }
1458
+ clearDeferredIdleNotification(sessionId) {
1459
+ const existing = this.sessions.get(sessionId);
1460
+ if (!existing) return;
1461
+ this.sessions.set(sessionId, {
1462
+ ...existing,
1463
+ idleNotificationPending: false
1464
+ });
1465
+ }
1466
+ };
1363
1467
 
1364
1468
  // src/telegram-remote.ts
1365
1469
  var pluginDir = dirname4(fileURLToPath(import.meta.url));
1470
+ function getTextPartFromMessagePartUpdated(event) {
1471
+ if (event.type !== "message.part.updated") return void 0;
1472
+ const part = event.properties?.part;
1473
+ if (!part || typeof part !== "object") return void 0;
1474
+ const candidate = part;
1475
+ if (candidate.type !== "text" || typeof candidate.sessionID !== "string" || typeof candidate.text !== "string") {
1476
+ return void 0;
1477
+ }
1478
+ return { type: "text", sessionID: candidate.sessionID, text: candidate.text };
1479
+ }
1366
1480
  var TelegramRemote = async (input) => {
1367
1481
  const logger = createLogger({ namespace: "telegram" });
1368
1482
  try {
@@ -1376,13 +1490,18 @@ var TelegramRemote = async (input) => {
1376
1490
  const claimsDir = join6(tmpdir4(), `opencoder-telegram-claims-${tokenHash}`);
1377
1491
  const pendingQuestions = createPendingQuestionStore({ tokenHash });
1378
1492
  const pendingPermissions = createPendingPermissionStore({ tokenHash });
1493
+ const startWorkCommands = new StartWorkCommandStore();
1379
1494
  const lockResult = await acquireLock({ lockPath });
1380
1495
  const isLeader = lockResult.acquired;
1381
1496
  logger.info(
1382
1497
  `lock ${isLeader ? "acquired - leader mode" : "held by other - pass-through mode"}`,
1383
1498
  isLeader ? {} : { reason: lockResult.reason }
1384
1499
  );
1385
- logger.info("server url", { url: input.serverUrl.toString(), href: input.serverUrl.href, origin: input.serverUrl.origin });
1500
+ logger.info("server url", {
1501
+ url: input.serverUrl.toString(),
1502
+ href: input.serverUrl.href,
1503
+ origin: input.serverUrl.origin
1504
+ });
1386
1505
  const sessionTitleService = new SessionTitleService();
1387
1506
  const client = input.client;
1388
1507
  const replyToQuestion = async (requestID, answers) => {
@@ -1410,6 +1529,13 @@ var TelegramRemote = async (input) => {
1410
1529
  throwOnError: true
1411
1530
  });
1412
1531
  };
1532
+ const runSessionCommand = async (sessionID, command, args) => {
1533
+ await input.client.session.command({
1534
+ path: { id: sessionID },
1535
+ body: { command, arguments: args },
1536
+ throwOnError: true
1537
+ });
1538
+ };
1413
1539
  const bot = createTelegramBot({
1414
1540
  config,
1415
1541
  stateStore,
@@ -1454,12 +1580,15 @@ var TelegramRemote = async (input) => {
1454
1580
  tokenHash,
1455
1581
  pendingQuestions,
1456
1582
  pendingPermissions,
1583
+ startWorkCommands,
1457
1584
  replyToQuestion,
1458
- replyToPermission
1585
+ replyToPermission,
1586
+ runSessionCommand
1459
1587
  };
1460
1588
  if (isLeader) {
1461
1589
  bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
1462
1590
  bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
1591
+ bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
1463
1592
  }
1464
1593
  return {
1465
1594
  event: async ({ event }) => {
@@ -1477,6 +1606,17 @@ var TelegramRemote = async (input) => {
1477
1606
  case "permission.updated":
1478
1607
  return handlePermissionUpdated(event, ctx);
1479
1608
  default: {
1609
+ const textPart = getTextPartFromMessagePartUpdated(extEvent);
1610
+ if (textPart) {
1611
+ const command = startWorkCommands.updateFromText(textPart.sessionID, textPart.text);
1612
+ if (command) {
1613
+ logger.info("start-work command detected", {
1614
+ sessionID: command.sessionID,
1615
+ arguments: command.arguments
1616
+ });
1617
+ }
1618
+ return;
1619
+ }
1480
1620
  if (isEventPermissionAsked(extEvent)) {
1481
1621
  if (!isLeader) return;
1482
1622
  return handlePermissionAsked(extEvent, ctx);
@@ -1497,7 +1637,9 @@ var TelegramRemote = async (input) => {
1497
1637
  }
1498
1638
  };
1499
1639
  } catch (err) {
1500
- logger.error("plugin initialization failed", { error: err instanceof Error ? err.message : String(err) });
1640
+ logger.error("plugin initialization failed", {
1641
+ error: err instanceof Error ? err.message : String(err)
1642
+ });
1501
1643
  await logger.close();
1502
1644
  return { event: async () => {
1503
1645
  } };