@coinseeker/opencode-telegram-plugin 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/telegram-remote.js +1125 -1046
- package/package.json +1 -1
package/dist/telegram-remote.js
CHANGED
|
@@ -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/
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
if (
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
71
|
-
await
|
|
168
|
+
async editMessage(messageId, text) {
|
|
169
|
+
const chatId = await requireChatId("editMessage");
|
|
170
|
+
await bot.api.editMessageText(chatId, messageId, text);
|
|
72
171
|
},
|
|
73
|
-
async
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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/
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
284
|
+
async function claimOnce(opts) {
|
|
156
285
|
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
157
|
-
|
|
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
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
304
|
+
return false;
|
|
177
305
|
}
|
|
178
306
|
|
|
179
|
-
// src/lib/
|
|
180
|
-
import {
|
|
181
|
-
import {
|
|
182
|
-
import {
|
|
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
|
|
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 &&
|
|
334
|
+
if (err instanceof Error && hasCode2(err, "ENOENT")) return [];
|
|
345
335
|
throw err;
|
|
346
336
|
}
|
|
347
337
|
}
|
|
348
|
-
function
|
|
338
|
+
function shortHashFromFileName(fileName) {
|
|
349
339
|
return fileName.slice(0, -".json".length);
|
|
350
340
|
}
|
|
351
341
|
function createPendingPermissionStore(opts) {
|
|
352
|
-
const dir = opts.baseDir ??
|
|
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 =
|
|
357
|
-
await
|
|
346
|
+
const filePath = pendingFilePath(dir, shortHash);
|
|
347
|
+
await mkdir2(dirname(filePath), { recursive: true });
|
|
358
348
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
359
|
-
await
|
|
360
|
-
await
|
|
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
|
|
354
|
+
return parsePending(await readFile(pendingFilePath(dir, shortHash), "utf8"));
|
|
365
355
|
} catch (err) {
|
|
366
|
-
if (err instanceof Error &&
|
|
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
|
|
362
|
+
await unlink2(pendingFilePath(dir, shortHash));
|
|
373
363
|
} catch (err) {
|
|
374
|
-
if (!(err instanceof Error) || !
|
|
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
|
|
379
|
-
const shortHash =
|
|
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
|
|
388
|
-
const shortHash =
|
|
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/
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
if (!
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
450
|
+
Permission: ${permission.permission}
|
|
451
|
+
Detail: ${permission.title}${patterns}${always}`;
|
|
479
452
|
}
|
|
480
|
-
function
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
{
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
626
|
-
|
|
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
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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/
|
|
655
|
-
var
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
711
|
-
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
733
|
-
|
|
734
|
-
|
|
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
|
|
737
|
-
|
|
738
|
-
|
|
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
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
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;
|
|
749
863
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
864
|
+
pending.answersInProgress[questionIndex] = [option.label];
|
|
865
|
+
pending.awaitingCustomFor = void 0;
|
|
866
|
+
await completeIfReady(ctx, pending, shortHash);
|
|
867
|
+
},
|
|
868
|
+
async handleTextReply(text, chatId, userId, replyToMessageId) {
|
|
869
|
+
const match = await ctx.pendingQuestions.findAwaitingCustom(chatId, userId);
|
|
870
|
+
if (!match) return;
|
|
871
|
+
const awaiting = match.data.awaitingCustomFor;
|
|
872
|
+
if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
|
|
873
|
+
if (match.data.expiresAt < Date.now()) {
|
|
874
|
+
await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const question = match.data.questions[awaiting.questionIndex];
|
|
878
|
+
if (question?.multiple === true) {
|
|
879
|
+
const current = selectedAnswers(match.data, awaiting.questionIndex);
|
|
880
|
+
match.data.answersInProgress[awaiting.questionIndex] = current.includes(text) ? current : [...current, text];
|
|
881
|
+
match.data.awaitingCustomFor = void 0;
|
|
882
|
+
await ctx.bot.sendMessage("\u2705 Custom answer added. Tap Done when finished.");
|
|
883
|
+
await ctx.pendingQuestions.savePending(match.shortHash, match.data);
|
|
884
|
+
await editPromptForQuestion(ctx, match.data, match.shortHash, awaiting.questionIndex);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
match.data.answersInProgress[awaiting.questionIndex] = [text];
|
|
888
|
+
match.data.awaitingCustomFor = void 0;
|
|
889
|
+
await ctx.bot.sendMessage("\u2705 Custom answer sent.");
|
|
890
|
+
await completeIfReady(ctx, match.data, match.shortHash);
|
|
891
|
+
}
|
|
892
|
+
};
|
|
753
893
|
}
|
|
754
|
-
|
|
755
|
-
|
|
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
|
|
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
|
|
910
|
+
await ctx.pendingQuestions.deletePending(found.shortHash);
|
|
760
911
|
}
|
|
761
|
-
return true;
|
|
762
912
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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,56 @@ 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
|
+
function startWorkCallbackData(sessionID) {
|
|
960
|
+
const data = `sw:${encodeURIComponent(sessionID)}`;
|
|
961
|
+
return Buffer.byteLength(data, "utf8") <= 64 ? data : void 0;
|
|
962
|
+
}
|
|
963
|
+
function startWorkKeyboard(sessionID) {
|
|
964
|
+
const callbackData = startWorkCallbackData(sessionID);
|
|
965
|
+
if (!callbackData) return void 0;
|
|
966
|
+
return [[{ text: "\u25B6\uFE0F Run /start-work", callback_data: callbackData }]];
|
|
967
|
+
}
|
|
968
|
+
function createStartWorkDispatcher(ctx) {
|
|
969
|
+
return {
|
|
970
|
+
async handleCallbackQuery(data, messageId) {
|
|
971
|
+
const match = CALLBACK_RE3.exec(data);
|
|
972
|
+
if (!match) return;
|
|
973
|
+
const sessionID = decodeURIComponent(match[1]);
|
|
974
|
+
try {
|
|
975
|
+
await ctx.runSessionCommand(sessionID, START_WORK_COMMAND);
|
|
976
|
+
await ctx.bot.editMessageRemoveKeyboard(
|
|
977
|
+
messageId,
|
|
978
|
+
`\u25B6\uFE0F Sent /start-work to opencode.
|
|
979
|
+
|
|
980
|
+
Session: ${sessionID}`
|
|
981
|
+
);
|
|
982
|
+
ctx.logger.info("start-work command sent", { sessionID });
|
|
983
|
+
} catch (err) {
|
|
984
|
+
await ctx.bot.editMessageRemoveKeyboard(
|
|
985
|
+
messageId,
|
|
986
|
+
`\u26A0\uFE0F Failed to send /start-work to opencode.
|
|
987
|
+
|
|
988
|
+
Session: ${sessionID}`
|
|
989
|
+
);
|
|
990
|
+
ctx.logger.error("failed to send start-work command", { sessionID, error: String(err) });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
813
996
|
// src/events/session-idle.ts
|
|
814
997
|
var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
|
|
815
998
|
function sleep(ms) {
|
|
@@ -849,12 +1032,22 @@ async function sendIdleNotification(sessionId, ctx) {
|
|
|
849
1032
|
ctx.logger.info("idle suppressed - session was aborted", { sessionId });
|
|
850
1033
|
return;
|
|
851
1034
|
}
|
|
852
|
-
const claimed = await claimOnce({
|
|
1035
|
+
const claimed = await claimOnce({
|
|
1036
|
+
claimsDir: ctx.claimsDir,
|
|
1037
|
+
key: `session.idle:${sessionId}`,
|
|
1038
|
+
ttlMs: 5e3
|
|
1039
|
+
});
|
|
853
1040
|
if (!claimed) return;
|
|
854
1041
|
const title = ctx.sessionTitleService.getSessionTitle(sessionId);
|
|
855
|
-
const message = title ? `Agent has finished: ${title}
|
|
1042
|
+
const message = title ? `Agent has finished: ${title}
|
|
1043
|
+
|
|
1044
|
+
If this was a plan builder session, tap below to run /start-work.` : "Agent has finished.\n\nIf this was a plan builder session, tap below to run /start-work.";
|
|
1045
|
+
const keyboard = startWorkKeyboard(sessionId);
|
|
856
1046
|
try {
|
|
857
|
-
await ctx.bot.sendMessage(
|
|
1047
|
+
await ctx.bot.sendMessage(
|
|
1048
|
+
message,
|
|
1049
|
+
keyboard ? { reply_markup: { inline_keyboard: keyboard } } : void 0
|
|
1050
|
+
);
|
|
858
1051
|
ctx.sessionTitleService.clearDeferredIdleNotification(sessionId);
|
|
859
1052
|
ctx.logger.info("idle notification sent", { sessionId, title });
|
|
860
1053
|
} catch (err) {
|
|
@@ -866,7 +1059,9 @@ async function flushDeferredParentIfReady(parentID, ctx) {
|
|
|
866
1059
|
if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
|
|
867
1060
|
if (ctx.sessionTitleService.getSessionStatus(parentID) !== "idle") {
|
|
868
1061
|
ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
|
|
869
|
-
ctx.logger.info("clearing deferred parent idle notification - parent resumed", {
|
|
1062
|
+
ctx.logger.info("clearing deferred parent idle notification - parent resumed", {
|
|
1063
|
+
sessionId: parentID
|
|
1064
|
+
});
|
|
870
1065
|
return;
|
|
871
1066
|
}
|
|
872
1067
|
ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
|
|
@@ -876,7 +1071,9 @@ async function deferParentIdleIfDescendantsRunning(sessionId, ctx) {
|
|
|
876
1071
|
await hydrateDescendants(sessionId, ctx);
|
|
877
1072
|
if (!ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) return false;
|
|
878
1073
|
ctx.sessionTitleService.deferIdleNotification(sessionId);
|
|
879
|
-
ctx.logger.info("deferring parent idle notification - child sessions still running", {
|
|
1074
|
+
ctx.logger.info("deferring parent idle notification - child sessions still running", {
|
|
1075
|
+
sessionId
|
|
1076
|
+
});
|
|
880
1077
|
return true;
|
|
881
1078
|
}
|
|
882
1079
|
async function handleSessionIdle(event, ctx) {
|
|
@@ -896,7 +1093,9 @@ async function handleSessionIdle(event, ctx) {
|
|
|
896
1093
|
}
|
|
897
1094
|
await sleep(ctx.idleRecheckDelayMs ?? ROOT_IDLE_RECHECK_DELAY_MS);
|
|
898
1095
|
if (ctx.sessionTitleService.getSessionStatus(sessionId) !== "idle") {
|
|
899
|
-
ctx.logger.info("idle notification skipped - session resumed during recheck delay", {
|
|
1096
|
+
ctx.logger.info("idle notification skipped - session resumed during recheck delay", {
|
|
1097
|
+
sessionId
|
|
1098
|
+
});
|
|
900
1099
|
return;
|
|
901
1100
|
}
|
|
902
1101
|
if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
|
|
@@ -913,453 +1112,318 @@ async function handleSessionStatus(event, ctx) {
|
|
|
913
1112
|
}
|
|
914
1113
|
}
|
|
915
1114
|
|
|
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
1115
|
// src/events/session-updated.ts
|
|
932
1116
|
async function handleSessionUpdated(event, ctx) {
|
|
933
1117
|
const info = event.properties.info;
|
|
934
1118
|
ctx.sessionTitleService.setSessionInfo(info);
|
|
935
1119
|
}
|
|
936
1120
|
|
|
937
|
-
// src/
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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") }]
|
|
1121
|
+
// src/lib/env-loader.ts
|
|
1122
|
+
import { existsSync } from "fs";
|
|
1123
|
+
import { homedir } from "os";
|
|
1124
|
+
import { join as join4 } from "path";
|
|
1125
|
+
import dotenv from "dotenv";
|
|
1126
|
+
function loadPluginEnv(opts) {
|
|
1127
|
+
const paths = [
|
|
1128
|
+
join4(opts.pluginDir, "../../.env"),
|
|
1129
|
+
join4(opts.pluginDir, "..", ".env"),
|
|
1130
|
+
join4(opts.pluginDir, ".env"),
|
|
1131
|
+
join4(opts.homeDir ?? homedir(), ".config/opencode/telegram-remote/.env")
|
|
1002
1132
|
];
|
|
1133
|
+
const loadedFrom = [];
|
|
1134
|
+
const values = {};
|
|
1135
|
+
for (const envPath of paths) {
|
|
1136
|
+
if (!existsSync(envPath)) continue;
|
|
1137
|
+
const result = dotenv.config({ path: envPath, override: false });
|
|
1138
|
+
if (result.parsed) {
|
|
1139
|
+
loadedFrom.push(envPath);
|
|
1140
|
+
for (const [key, value] of Object.entries(result.parsed)) {
|
|
1141
|
+
if (!(key in values)) values[key] = value;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return { loadedFrom, values };
|
|
1003
1146
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
if (reply === "once") return "Allowed once";
|
|
1012
|
-
if (reply === "always") return "Always allowed";
|
|
1013
|
-
return "Rejected";
|
|
1147
|
+
|
|
1148
|
+
// src/lib/lock.ts
|
|
1149
|
+
import { open as open2, readFile as readFile3, stat as stat2, unlink as unlink4 } from "fs/promises";
|
|
1150
|
+
import { hostname } from "os";
|
|
1151
|
+
var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
|
|
1152
|
+
function hasCode4(err, code) {
|
|
1153
|
+
return "code" in err && err.code === code;
|
|
1014
1154
|
}
|
|
1015
|
-
|
|
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;
|
|
1155
|
+
function parseLockData(text) {
|
|
1022
1156
|
try {
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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);
|
|
1039
|
-
} catch (err) {
|
|
1040
|
-
ctx.logger.error("failed to send permission notification", { error: String(err) });
|
|
1157
|
+
const parsed = JSON.parse(text);
|
|
1158
|
+
if (typeof parsed.pid === "number" && typeof parsed.hostname === "string" && typeof parsed.createdAt === "string") {
|
|
1159
|
+
return { pid: parsed.pid, hostname: parsed.hostname, createdAt: parsed.createdAt };
|
|
1160
|
+
}
|
|
1161
|
+
} catch {
|
|
1162
|
+
return null;
|
|
1041
1163
|
}
|
|
1164
|
+
return null;
|
|
1042
1165
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
}
|
|
1051
|
-
async function handlePermissionAsked(event, ctx) {
|
|
1052
|
-
await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
|
|
1166
|
+
function isPidAlive(pid) {
|
|
1167
|
+
try {
|
|
1168
|
+
process.kill(pid, 0);
|
|
1169
|
+
return true;
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
if (err instanceof Error && hasCode4(err, "ESRCH")) return false;
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1053
1174
|
}
|
|
1054
|
-
function
|
|
1175
|
+
async function createLock(lockPath, pid) {
|
|
1176
|
+
const file = await open2(lockPath, "wx");
|
|
1177
|
+
const acquiredAt = /* @__PURE__ */ new Date();
|
|
1178
|
+
const data = { pid, hostname: hostname(), createdAt: acquiredAt.toISOString() };
|
|
1179
|
+
try {
|
|
1180
|
+
await file.writeFile(JSON.stringify(data), "utf8");
|
|
1181
|
+
} finally {
|
|
1182
|
+
await file.close();
|
|
1183
|
+
}
|
|
1184
|
+
let released = false;
|
|
1055
1185
|
return {
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
-
}
|
|
1186
|
+
path: lockPath,
|
|
1187
|
+
acquiredAt,
|
|
1188
|
+
async release() {
|
|
1189
|
+
if (released) return;
|
|
1190
|
+
released = true;
|
|
1071
1191
|
try {
|
|
1072
|
-
await
|
|
1073
|
-
|
|
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);
|
|
1192
|
+
await unlink4(lockPath);
|
|
1193
|
+
} catch {
|
|
1082
1194
|
}
|
|
1083
1195
|
}
|
|
1084
1196
|
};
|
|
1085
1197
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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)
|
|
1198
|
+
async function inspectExisting(lockPath, ttlMs) {
|
|
1199
|
+
let ownerPid;
|
|
1200
|
+
let dead = false;
|
|
1201
|
+
try {
|
|
1202
|
+
const text = await readFile3(lockPath, "utf8");
|
|
1203
|
+
const data = parseLockData(text);
|
|
1204
|
+
if (data) {
|
|
1205
|
+
ownerPid = data.pid;
|
|
1206
|
+
dead = !isPidAlive(data.pid);
|
|
1137
1207
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
inlineKeyboard.push([
|
|
1141
|
-
{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }
|
|
1142
|
-
]);
|
|
1208
|
+
} catch {
|
|
1209
|
+
return { stale: true, reason: "unreadable lock" };
|
|
1143
1210
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1211
|
+
try {
|
|
1212
|
+
const fileStat = await stat2(lockPath);
|
|
1213
|
+
const expired = Date.now() - fileStat.mtimeMs > ttlMs;
|
|
1214
|
+
if (dead) return { stale: true, ownerPid, reason: "dead owner" };
|
|
1215
|
+
if (expired) return { stale: true, ownerPid, reason: "expired lock" };
|
|
1216
|
+
return { stale: false, ownerPid, reason: "lock held" };
|
|
1217
|
+
} catch {
|
|
1218
|
+
return { stale: true, ownerPid, reason: "missing lock" };
|
|
1148
1219
|
}
|
|
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
1220
|
}
|
|
1172
|
-
async function
|
|
1173
|
-
const
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1221
|
+
async function acquireLock(opts) {
|
|
1222
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS2;
|
|
1223
|
+
const pid = opts.pid ?? process.pid;
|
|
1224
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1225
|
+
try {
|
|
1226
|
+
return { acquired: true, handle: await createLock(opts.lockPath, pid) };
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
if (!(err instanceof Error) || !hasCode4(err, "EEXIST")) {
|
|
1229
|
+
return { acquired: false, reason: err instanceof Error ? err.message : String(err) };
|
|
1230
|
+
}
|
|
1231
|
+
const existing = await inspectExisting(opts.lockPath, ttlMs);
|
|
1232
|
+
if (!existing.stale || attempt === 1) {
|
|
1233
|
+
return { acquired: false, reason: existing.reason, ownerPid: existing.ownerPid };
|
|
1234
|
+
}
|
|
1235
|
+
try {
|
|
1236
|
+
await unlink4(opts.lockPath);
|
|
1237
|
+
} catch {
|
|
1238
|
+
return { acquired: false, reason: "failed to remove stale lock", ownerPid: existing.ownerPid };
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1179
1241
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1242
|
+
return { acquired: false, reason: "lock acquisition failed" };
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// src/lib/logger.ts
|
|
1246
|
+
import { appendFile } from "fs/promises";
|
|
1247
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
1248
|
+
var DEFAULT_BUFFER_LIMIT = 4096;
|
|
1249
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 2e3;
|
|
1250
|
+
function safeJson(data) {
|
|
1182
1251
|
try {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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);
|
|
1252
|
+
return JSON.stringify(data);
|
|
1253
|
+
} catch {
|
|
1254
|
+
return '{"serialization":"failed"}';
|
|
1201
1255
|
}
|
|
1202
1256
|
}
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
const
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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, [])
|
|
1257
|
+
function createLogger(opts = {}) {
|
|
1258
|
+
const filePath = opts.filePath ?? `${tmpdir3()}/opencoder-telegram.log`;
|
|
1259
|
+
const namespace = opts.namespace ?? "default";
|
|
1260
|
+
const bufferLimit = opts.bufferLimit ?? DEFAULT_BUFFER_LIMIT;
|
|
1261
|
+
const flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
1262
|
+
let buffer = "";
|
|
1263
|
+
let closed = false;
|
|
1264
|
+
let flushing = Promise.resolve();
|
|
1265
|
+
const timer = setInterval(() => {
|
|
1266
|
+
void flushBuffer();
|
|
1267
|
+
}, flushIntervalMs);
|
|
1268
|
+
timer.unref();
|
|
1269
|
+
async function flushBuffer() {
|
|
1270
|
+
if (buffer.length === 0) return flushing;
|
|
1271
|
+
const chunk = buffer;
|
|
1272
|
+
buffer = "";
|
|
1273
|
+
flushing = flushing.then(async () => {
|
|
1274
|
+
try {
|
|
1275
|
+
await appendFile(filePath, chunk, "utf8");
|
|
1276
|
+
} catch {
|
|
1237
1277
|
}
|
|
1238
1278
|
});
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
requestID: request.id
|
|
1250
|
-
});
|
|
1279
|
+
return flushing;
|
|
1280
|
+
}
|
|
1281
|
+
function write(level, msg, data) {
|
|
1282
|
+
if (closed) return;
|
|
1283
|
+
const json = data === void 0 ? "" : ` ${safeJson(data)}`;
|
|
1284
|
+
buffer += `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] [${process.pid}] [${namespace}] ${msg}${json}
|
|
1285
|
+
`;
|
|
1286
|
+
if (level === "error" || buffer.length >= bufferLimit) {
|
|
1287
|
+
void flushBuffer();
|
|
1288
|
+
}
|
|
1251
1289
|
}
|
|
1290
|
+
return {
|
|
1291
|
+
debug(msg, data) {
|
|
1292
|
+
write("debug", msg, data);
|
|
1293
|
+
},
|
|
1294
|
+
info(msg, data) {
|
|
1295
|
+
write("info", msg, data);
|
|
1296
|
+
},
|
|
1297
|
+
warn(msg, data) {
|
|
1298
|
+
write("warn", msg, data);
|
|
1299
|
+
},
|
|
1300
|
+
error(msg, data) {
|
|
1301
|
+
write("error", msg, data);
|
|
1302
|
+
},
|
|
1303
|
+
async flush() {
|
|
1304
|
+
await flushBuffer();
|
|
1305
|
+
},
|
|
1306
|
+
async close() {
|
|
1307
|
+
if (closed) return;
|
|
1308
|
+
closed = true;
|
|
1309
|
+
clearInterval(timer);
|
|
1310
|
+
await flushBuffer();
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1252
1313
|
}
|
|
1253
|
-
|
|
1314
|
+
|
|
1315
|
+
// src/lib/state-store.ts
|
|
1316
|
+
import { mkdir as mkdir4, readFile as readFile4, rename as rename3, writeFile as writeFile3 } from "fs/promises";
|
|
1317
|
+
import { homedir as homedir2 } from "os";
|
|
1318
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
1319
|
+
function hasCode5(err, code) {
|
|
1320
|
+
return "code" in err && err.code === code;
|
|
1321
|
+
}
|
|
1322
|
+
function parseState(text) {
|
|
1323
|
+
const parsed = JSON.parse(text);
|
|
1324
|
+
const state = {};
|
|
1325
|
+
if (typeof parsed.chatId === "number") state.chatId = parsed.chatId;
|
|
1326
|
+
if (typeof parsed.updatedAt === "string") state.updatedAt = parsed.updatedAt;
|
|
1327
|
+
if (typeof parsed.discoveredBy === "number") state.discoveredBy = parsed.discoveredBy;
|
|
1328
|
+
return state;
|
|
1329
|
+
}
|
|
1330
|
+
function createStateStore(opts = {}) {
|
|
1331
|
+
const filePath = opts.filePath ?? join5(homedir2(), ".config/opencode/telegram-remote/state.json");
|
|
1254
1332
|
return {
|
|
1255
|
-
async
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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;
|
|
1333
|
+
async read() {
|
|
1334
|
+
try {
|
|
1335
|
+
return parseState(await readFile4(filePath, "utf8"));
|
|
1336
|
+
} catch (err) {
|
|
1337
|
+
if (err instanceof Error && hasCode5(err, "ENOENT")) return {};
|
|
1338
|
+
throw err;
|
|
1313
1339
|
}
|
|
1314
|
-
pending.answersInProgress[questionIndex] = [option.label];
|
|
1315
|
-
pending.awaitingCustomFor = void 0;
|
|
1316
|
-
await completeIfReady(ctx, pending, shortHash);
|
|
1317
1340
|
},
|
|
1318
|
-
async
|
|
1319
|
-
const
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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;
|
|
1341
|
+
async write(patch) {
|
|
1342
|
+
const existing = await this.read();
|
|
1343
|
+
const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1344
|
+
await mkdir4(dirname3(filePath), { recursive: true });
|
|
1345
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
1346
|
+
await writeFile3(tmpPath, JSON.stringify(next, null, 2), "utf8");
|
|
1347
|
+
try {
|
|
1348
|
+
await rename3(tmpPath, filePath);
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
if (!(err instanceof Error) || !hasCode5(err, "ENOENT")) throw err;
|
|
1351
|
+
await writeFile3(tmpPath, JSON.stringify(next, null, 2), "utf8");
|
|
1352
|
+
await rename3(tmpPath, filePath);
|
|
1336
1353
|
}
|
|
1337
|
-
|
|
1338
|
-
match.data.awaitingCustomFor = void 0;
|
|
1339
|
-
await ctx.bot.sendMessage("\u2705 Custom answer sent.");
|
|
1340
|
-
await completeIfReady(ctx, match.data, match.shortHash);
|
|
1354
|
+
return next;
|
|
1341
1355
|
}
|
|
1342
1356
|
};
|
|
1343
1357
|
}
|
|
1344
1358
|
|
|
1345
|
-
// src/
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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);
|
|
1359
|
+
// src/services/session-title-service.ts
|
|
1360
|
+
var SessionTitleService = class {
|
|
1361
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1362
|
+
setSessionInfo(info) {
|
|
1363
|
+
const existing = this.sessions.get(info.id);
|
|
1364
|
+
this.sessions.set(info.id, {
|
|
1365
|
+
title: info.title || null,
|
|
1366
|
+
parentID: info.parentID ?? null,
|
|
1367
|
+
status: existing?.status,
|
|
1368
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
1369
|
+
});
|
|
1361
1370
|
}
|
|
1362
|
-
|
|
1371
|
+
setSessionTitle(sessionId, title) {
|
|
1372
|
+
const existing = this.sessions.get(sessionId);
|
|
1373
|
+
this.sessions.set(sessionId, {
|
|
1374
|
+
title,
|
|
1375
|
+
parentID: existing?.parentID ?? null,
|
|
1376
|
+
status: existing?.status,
|
|
1377
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
setSessionStatus(sessionId, status) {
|
|
1381
|
+
const existing = this.sessions.get(sessionId);
|
|
1382
|
+
this.sessions.set(sessionId, {
|
|
1383
|
+
title: existing?.title ?? null,
|
|
1384
|
+
parentID: existing?.parentID ?? null,
|
|
1385
|
+
status,
|
|
1386
|
+
idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
getSessionTitle(sessionId) {
|
|
1390
|
+
return this.sessions.get(sessionId)?.title ?? null;
|
|
1391
|
+
}
|
|
1392
|
+
getParentID(sessionId) {
|
|
1393
|
+
return this.sessions.get(sessionId)?.parentID;
|
|
1394
|
+
}
|
|
1395
|
+
getSessionStatus(sessionId) {
|
|
1396
|
+
return this.sessions.get(sessionId)?.status;
|
|
1397
|
+
}
|
|
1398
|
+
hasUnfinishedDescendants(parentID) {
|
|
1399
|
+
for (const [sessionID, session] of this.sessions.entries()) {
|
|
1400
|
+
if (session.parentID !== parentID) continue;
|
|
1401
|
+
if (session.status !== "idle") return true;
|
|
1402
|
+
if (this.hasUnfinishedDescendants(sessionID)) return true;
|
|
1403
|
+
}
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
deferIdleNotification(sessionId) {
|
|
1407
|
+
const existing = this.sessions.get(sessionId);
|
|
1408
|
+
this.sessions.set(sessionId, {
|
|
1409
|
+
title: existing?.title ?? null,
|
|
1410
|
+
parentID: existing?.parentID ?? null,
|
|
1411
|
+
status: existing?.status ?? "idle",
|
|
1412
|
+
idleNotificationPending: true
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
hasDeferredIdleNotification(sessionId) {
|
|
1416
|
+
return this.sessions.get(sessionId)?.idleNotificationPending ?? false;
|
|
1417
|
+
}
|
|
1418
|
+
clearDeferredIdleNotification(sessionId) {
|
|
1419
|
+
const existing = this.sessions.get(sessionId);
|
|
1420
|
+
if (!existing) return;
|
|
1421
|
+
this.sessions.set(sessionId, {
|
|
1422
|
+
...existing,
|
|
1423
|
+
idleNotificationPending: false
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1363
1427
|
|
|
1364
1428
|
// src/telegram-remote.ts
|
|
1365
1429
|
var pluginDir = dirname4(fileURLToPath(import.meta.url));
|
|
@@ -1382,7 +1446,11 @@ var TelegramRemote = async (input) => {
|
|
|
1382
1446
|
`lock ${isLeader ? "acquired - leader mode" : "held by other - pass-through mode"}`,
|
|
1383
1447
|
isLeader ? {} : { reason: lockResult.reason }
|
|
1384
1448
|
);
|
|
1385
|
-
logger.info("server url", {
|
|
1449
|
+
logger.info("server url", {
|
|
1450
|
+
url: input.serverUrl.toString(),
|
|
1451
|
+
href: input.serverUrl.href,
|
|
1452
|
+
origin: input.serverUrl.origin
|
|
1453
|
+
});
|
|
1386
1454
|
const sessionTitleService = new SessionTitleService();
|
|
1387
1455
|
const client = input.client;
|
|
1388
1456
|
const replyToQuestion = async (requestID, answers) => {
|
|
@@ -1410,6 +1478,13 @@ var TelegramRemote = async (input) => {
|
|
|
1410
1478
|
throwOnError: true
|
|
1411
1479
|
});
|
|
1412
1480
|
};
|
|
1481
|
+
const runSessionCommand = async (sessionID, command) => {
|
|
1482
|
+
await input.client.session.command({
|
|
1483
|
+
path: { id: sessionID },
|
|
1484
|
+
body: { command, arguments: "" },
|
|
1485
|
+
throwOnError: true
|
|
1486
|
+
});
|
|
1487
|
+
};
|
|
1413
1488
|
const bot = createTelegramBot({
|
|
1414
1489
|
config,
|
|
1415
1490
|
stateStore,
|
|
@@ -1455,11 +1530,13 @@ var TelegramRemote = async (input) => {
|
|
|
1455
1530
|
pendingQuestions,
|
|
1456
1531
|
pendingPermissions,
|
|
1457
1532
|
replyToQuestion,
|
|
1458
|
-
replyToPermission
|
|
1533
|
+
replyToPermission,
|
|
1534
|
+
runSessionCommand
|
|
1459
1535
|
};
|
|
1460
1536
|
if (isLeader) {
|
|
1461
1537
|
bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
|
|
1462
1538
|
bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
|
|
1539
|
+
bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
|
|
1463
1540
|
}
|
|
1464
1541
|
return {
|
|
1465
1542
|
event: async ({ event }) => {
|
|
@@ -1497,7 +1574,9 @@ var TelegramRemote = async (input) => {
|
|
|
1497
1574
|
}
|
|
1498
1575
|
};
|
|
1499
1576
|
} catch (err) {
|
|
1500
|
-
logger.error("plugin initialization failed", {
|
|
1577
|
+
logger.error("plugin initialization failed", {
|
|
1578
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1579
|
+
});
|
|
1501
1580
|
await logger.close();
|
|
1502
1581
|
return { event: async () => {
|
|
1503
1582
|
} };
|