@coinseeker/opencode-telegram-plugin 1.0.11 → 1.0.13
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 +4 -2
- package/dist/telegram-remote.js +765 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,11 +15,11 @@ Configure the npm package in `~/.config/opencode/opencode.json`:
|
|
|
15
15
|
|
|
16
16
|
```json
|
|
17
17
|
{
|
|
18
|
-
"plugin": ["@coinseeker/opencode-telegram-plugin@1.0.
|
|
18
|
+
"plugin": ["@coinseeker/opencode-telegram-plugin@1.0.12"]
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.
|
|
22
|
+
Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.12`.
|
|
23
23
|
|
|
24
24
|
Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
|
|
25
25
|
|
|
@@ -59,6 +59,8 @@ Keep this file private. Never commit or share your Telegram bot token.
|
|
|
59
59
|
- Permission approve/reject buttons from Telegram.
|
|
60
60
|
- Multi-session-safe Telegram polling through a file-lock leader model.
|
|
61
61
|
- Log file output instead of stdout terminal spam.
|
|
62
|
+
- Remote session listing via `/sessions`, `/status N`, `/start_work N`, `/help` slash commands.
|
|
63
|
+
- Safety-gated remote `/start-work` execution: verifies agent=plan, idle status, incomplete plan, and no active boulder before dispatching.
|
|
62
64
|
|
|
63
65
|
## Logs
|
|
64
66
|
|
package/dist/telegram-remote.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
// src/telegram-remote.ts
|
|
7
7
|
import { createHash as createHash5 } from "crypto";
|
|
8
|
-
import { tmpdir as tmpdir5 } from "os";
|
|
9
|
-
import { dirname as
|
|
8
|
+
import { homedir as homedir3, tmpdir as tmpdir5 } from "os";
|
|
9
|
+
import { dirname as dirname6, join as join9 } from "path";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
11
|
|
|
12
12
|
// src/bot.ts
|
|
@@ -46,6 +46,11 @@ function createTelegramBot(opts) {
|
|
|
46
46
|
let questionDispatcher;
|
|
47
47
|
let permissionDispatcher;
|
|
48
48
|
let startWorkDispatcher;
|
|
49
|
+
let sessionsDispatcher;
|
|
50
|
+
let statusDispatcher;
|
|
51
|
+
let startWorkCommandDispatcher;
|
|
52
|
+
let helpDispatcher;
|
|
53
|
+
let managerObj;
|
|
49
54
|
if (polling) {
|
|
50
55
|
bot.use(async (ctx, next) => {
|
|
51
56
|
const userId = ctx.from?.id;
|
|
@@ -105,6 +110,36 @@ This chat is now active for OpenCode notifications.`
|
|
|
105
110
|
if (!startWorkDispatcher || messageId === void 0) return;
|
|
106
111
|
await startWorkDispatcher.handleCallbackQuery(data, messageId);
|
|
107
112
|
});
|
|
113
|
+
bot.command("sessions", async (ctx) => {
|
|
114
|
+
if (!sessionsDispatcher) return;
|
|
115
|
+
const chatId = ctx.chat?.id;
|
|
116
|
+
const userId = ctx.from?.id;
|
|
117
|
+
if (chatId === void 0 || userId === void 0) return;
|
|
118
|
+
await sessionsDispatcher({ chatId, userId, bot: managerObj });
|
|
119
|
+
});
|
|
120
|
+
bot.command("status", async (ctx) => {
|
|
121
|
+
if (!statusDispatcher) return;
|
|
122
|
+
const chatId = ctx.chat?.id;
|
|
123
|
+
const userId = ctx.from?.id;
|
|
124
|
+
if (chatId === void 0 || userId === void 0) return;
|
|
125
|
+
const args = ctx.match.trim().split(/\s+/).filter(Boolean);
|
|
126
|
+
await statusDispatcher({ chatId, userId, bot: managerObj, args });
|
|
127
|
+
});
|
|
128
|
+
bot.command("start_work", async (ctx) => {
|
|
129
|
+
if (!startWorkCommandDispatcher) return;
|
|
130
|
+
const chatId = ctx.chat?.id;
|
|
131
|
+
const userId = ctx.from?.id;
|
|
132
|
+
if (chatId === void 0 || userId === void 0) return;
|
|
133
|
+
const args = ctx.match.trim().split(/\s+/).filter(Boolean);
|
|
134
|
+
await startWorkCommandDispatcher({ chatId, userId, bot: managerObj, args });
|
|
135
|
+
});
|
|
136
|
+
bot.command("help", async (ctx) => {
|
|
137
|
+
if (!helpDispatcher) return;
|
|
138
|
+
const chatId = ctx.chat?.id;
|
|
139
|
+
const userId = ctx.from?.id;
|
|
140
|
+
if (chatId === void 0 || userId === void 0) return;
|
|
141
|
+
await helpDispatcher({ chatId, userId, bot: managerObj });
|
|
142
|
+
});
|
|
108
143
|
bot.on("message:text", async (ctx) => {
|
|
109
144
|
const replyToMessageId = ctx.message.reply_to_message?.message_id;
|
|
110
145
|
const chatId = ctx.chat.id;
|
|
@@ -122,12 +157,22 @@ This chat is now active for OpenCode notifications.`
|
|
|
122
157
|
}
|
|
123
158
|
throw new Error(`No active chat for ${action}. Send any message to the bot first.`);
|
|
124
159
|
};
|
|
125
|
-
|
|
160
|
+
managerObj = {
|
|
126
161
|
async start() {
|
|
127
162
|
if (!polling) {
|
|
128
163
|
logger.info("pass-through mode - skipping bot.start()");
|
|
129
164
|
return;
|
|
130
165
|
}
|
|
166
|
+
try {
|
|
167
|
+
await bot.api.setMyCommands([
|
|
168
|
+
{ command: "sessions", description: "\uD65C\uC131 \uC138\uC158 \uBAA9\uB85D (top 20)" },
|
|
169
|
+
{ command: "status", description: "\uC138\uC158 \uC0C1\uD0DC \uC870\uD68C (/status N)" },
|
|
170
|
+
{ command: "start_work", description: "plan-ready \uC138\uC158 \uC2E4\uD589 (/start_work N)" },
|
|
171
|
+
{ command: "help", description: "\uBA85\uB839 \uB3C4\uC6C0\uB9D0" }
|
|
172
|
+
]);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
logger.warn("setMyCommands failed", { error: String(err) });
|
|
175
|
+
}
|
|
131
176
|
await bot.start({
|
|
132
177
|
drop_pending_updates: true,
|
|
133
178
|
onStart: () => {
|
|
@@ -201,8 +246,21 @@ This chat is now active for OpenCode notifications.`
|
|
|
201
246
|
},
|
|
202
247
|
setStartWorkDispatcher(dispatcher) {
|
|
203
248
|
startWorkDispatcher = dispatcher;
|
|
249
|
+
},
|
|
250
|
+
setSessionsDispatcher(dispatcher) {
|
|
251
|
+
sessionsDispatcher = dispatcher;
|
|
252
|
+
},
|
|
253
|
+
setStatusDispatcher(dispatcher) {
|
|
254
|
+
statusDispatcher = dispatcher;
|
|
255
|
+
},
|
|
256
|
+
setStartWorkCommandDispatcher(dispatcher) {
|
|
257
|
+
startWorkCommandDispatcher = dispatcher;
|
|
258
|
+
},
|
|
259
|
+
setHelpDispatcher(dispatcher) {
|
|
260
|
+
helpDispatcher = dispatcher;
|
|
204
261
|
}
|
|
205
262
|
};
|
|
263
|
+
return managerObj;
|
|
206
264
|
}
|
|
207
265
|
|
|
208
266
|
// src/config.ts
|
|
@@ -508,6 +566,46 @@ async function handlePermissionUpdated(event, ctx) {
|
|
|
508
566
|
async function handlePermissionAsked(event, ctx) {
|
|
509
567
|
await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
|
|
510
568
|
}
|
|
569
|
+
function isEventPermissionReplied(event) {
|
|
570
|
+
if (event.type !== "permission.replied") return false;
|
|
571
|
+
const props = event.properties;
|
|
572
|
+
if (!props) return false;
|
|
573
|
+
if (typeof props.sessionID !== "string") return false;
|
|
574
|
+
const hasId = typeof props.permissionID === "string" || typeof props.requestID === "string";
|
|
575
|
+
return hasId;
|
|
576
|
+
}
|
|
577
|
+
function externalReplyLabel(value) {
|
|
578
|
+
if (value === "once") return "Allowed once in opencode";
|
|
579
|
+
if (value === "always") return "Always allowed in opencode";
|
|
580
|
+
if (value === "reject") return "Rejected in opencode";
|
|
581
|
+
return "Already answered in opencode";
|
|
582
|
+
}
|
|
583
|
+
async function handlePermissionReplied(event, ctx) {
|
|
584
|
+
const requestID = event.properties.requestID ?? event.properties.permissionID;
|
|
585
|
+
if (!requestID) return;
|
|
586
|
+
const found = await ctx.pendingPermissions.findByRequestID(requestID);
|
|
587
|
+
if (!found) return;
|
|
588
|
+
const label = externalReplyLabel(event.properties.reply ?? event.properties.response);
|
|
589
|
+
try {
|
|
590
|
+
await ctx.bot.editMessageRemoveKeyboard(
|
|
591
|
+
found.data.telegramMessageId,
|
|
592
|
+
`\u2705 ${label}
|
|
593
|
+
|
|
594
|
+
${found.data.permission}: ${found.data.title}`
|
|
595
|
+
);
|
|
596
|
+
ctx.logger.info("permission externally replied - cleared pending", {
|
|
597
|
+
requestID,
|
|
598
|
+
sessionID: event.properties.sessionID
|
|
599
|
+
});
|
|
600
|
+
} catch (err) {
|
|
601
|
+
ctx.logger.error("failed to edit externally replied permission", {
|
|
602
|
+
error: String(err),
|
|
603
|
+
requestID
|
|
604
|
+
});
|
|
605
|
+
} finally {
|
|
606
|
+
await ctx.pendingPermissions.deletePending(found.shortHash);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
511
609
|
function createPermissionDispatcher(ctx) {
|
|
512
610
|
return {
|
|
513
611
|
async handleCallbackQuery(data, messageId) {
|
|
@@ -1253,17 +1351,439 @@ async function handleSessionUpdated(event, ctx) {
|
|
|
1253
1351
|
ctx.sessionTitleService.setSessionInfo(info);
|
|
1254
1352
|
}
|
|
1255
1353
|
|
|
1354
|
+
// src/lib/html-escape.ts
|
|
1355
|
+
function escapeHtml(input) {
|
|
1356
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1357
|
+
}
|
|
1358
|
+
function truncateForTelegram(input, maxChars, ellipsis = "\u2026") {
|
|
1359
|
+
const single = input.replace(/\s+/g, " ").trim();
|
|
1360
|
+
if (single.length <= maxChars) return single;
|
|
1361
|
+
if (maxChars <= 0) return "";
|
|
1362
|
+
if (ellipsis.length >= maxChars) return ellipsis.slice(0, maxChars);
|
|
1363
|
+
return single.slice(0, maxChars - ellipsis.length) + ellipsis;
|
|
1364
|
+
}
|
|
1365
|
+
function stripCodeFences(input) {
|
|
1366
|
+
return input.replace(/```[^\r\n`]*\r?\n([\s\S]*?)```/g, "$1").replace(/```([\s\S]*?)```/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/\s+/g, " ").trim();
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// src/events/sessions-command.ts
|
|
1370
|
+
var MAX_BODY_CHARS = 3900;
|
|
1371
|
+
var MAX_TITLE_CHARS = 55;
|
|
1372
|
+
var MAX_SESSIONS = 20;
|
|
1373
|
+
function createSessionsDispatcher(deps) {
|
|
1374
|
+
return async ({ chatId, bot }) => {
|
|
1375
|
+
const sessions = deps.sessionTitleService.getRootSessionsByRecency(MAX_SESSIONS);
|
|
1376
|
+
if (sessions.length === 0) {
|
|
1377
|
+
await bot.sendMessage("\uD65C\uC131 \uC138\uC158\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.", { parse_mode: "HTML" });
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
const capturedAt = Date.now();
|
|
1381
|
+
const entries = sessions.map((session, i) => {
|
|
1382
|
+
const entry = {
|
|
1383
|
+
index: i + 1,
|
|
1384
|
+
sessionId: session.sessionId,
|
|
1385
|
+
title: session.title ?? "",
|
|
1386
|
+
capturedAt
|
|
1387
|
+
};
|
|
1388
|
+
if (session.agent !== void 0) entry.agent = session.agent;
|
|
1389
|
+
if (session.status !== void 0) entry.status = session.status;
|
|
1390
|
+
if (session.serverUrl !== void 0) entry.serverUrl = session.serverUrl;
|
|
1391
|
+
return entry;
|
|
1392
|
+
});
|
|
1393
|
+
await deps.snapshotStore.saveSnapshot(chatId, entries);
|
|
1394
|
+
const lines = entries.map((entry) => {
|
|
1395
|
+
const agent = entry.agent ? escapeHtml(entry.agent) : "?";
|
|
1396
|
+
const title = truncateForTelegram(escapeHtml(entry.title), MAX_TITLE_CHARS);
|
|
1397
|
+
const status = entry.status ?? "unknown";
|
|
1398
|
+
return `${entry.index}. [${agent}] ${title} \u2014 ${status}`;
|
|
1399
|
+
});
|
|
1400
|
+
let body = lines.join("\n");
|
|
1401
|
+
if (body.length > MAX_BODY_CHARS) {
|
|
1402
|
+
body = body.slice(0, MAX_BODY_CHARS) + "\u2026";
|
|
1403
|
+
}
|
|
1404
|
+
const text = `<b>\uD65C\uC131 \uC138\uC158 (top ${entries.length})</b>
|
|
1405
|
+
${body}
|
|
1406
|
+
|
|
1407
|
+
<i>/status N \uB610\uB294 /start_work N \uC73C\uB85C \uC870\uC791</i>`;
|
|
1408
|
+
await bot.sendMessage(text, { parse_mode: "HTML" });
|
|
1409
|
+
deps.logger.info("sessions listed", { chatId, count: entries.length });
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// src/lib/plan-readiness.ts
|
|
1414
|
+
import { access, readFile as readFile4, readdir as readdir5, stat as stat2 } from "fs/promises";
|
|
1415
|
+
import { join as join5 } from "path";
|
|
1416
|
+
async function checkPlanReadiness(args) {
|
|
1417
|
+
const { projectRoot } = args;
|
|
1418
|
+
const omoDir = join5(projectRoot, ".omo");
|
|
1419
|
+
const plansDir = join5(omoDir, "plans");
|
|
1420
|
+
const boulderPath = join5(omoDir, "boulder.json");
|
|
1421
|
+
try {
|
|
1422
|
+
await access(omoDir);
|
|
1423
|
+
} catch {
|
|
1424
|
+
return {
|
|
1425
|
+
ready: false,
|
|
1426
|
+
reason: "no-omo-dir",
|
|
1427
|
+
detail: `${omoDir} does not exist`
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
try {
|
|
1431
|
+
await access(boulderPath);
|
|
1432
|
+
return {
|
|
1433
|
+
ready: false,
|
|
1434
|
+
reason: "boulder-active",
|
|
1435
|
+
detail: `${boulderPath} exists`
|
|
1436
|
+
};
|
|
1437
|
+
} catch {
|
|
1438
|
+
}
|
|
1439
|
+
let planFiles = [];
|
|
1440
|
+
try {
|
|
1441
|
+
const entries = await readdir5(plansDir);
|
|
1442
|
+
planFiles = entries.filter((e) => e.endsWith(".md"));
|
|
1443
|
+
} catch {
|
|
1444
|
+
return {
|
|
1445
|
+
ready: false,
|
|
1446
|
+
reason: "no-plans",
|
|
1447
|
+
detail: `${plansDir} not found or empty`
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
if (planFiles.length === 0) {
|
|
1451
|
+
return {
|
|
1452
|
+
ready: false,
|
|
1453
|
+
reason: "no-plans",
|
|
1454
|
+
detail: `No .md files in ${plansDir}`
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
const stats = await Promise.all(
|
|
1458
|
+
planFiles.map(async (f) => {
|
|
1459
|
+
const full = join5(plansDir, f);
|
|
1460
|
+
const s = await stat2(full);
|
|
1461
|
+
return { path: full, name: f, mtime: s.mtime.getTime() };
|
|
1462
|
+
})
|
|
1463
|
+
);
|
|
1464
|
+
stats.sort((a, b) => b.mtime - a.mtime);
|
|
1465
|
+
const latest = stats[0];
|
|
1466
|
+
const content = await readFile4(latest.path, "utf8");
|
|
1467
|
+
const totalMatches = content.match(/^- \[[ xX]\]/gm) ?? [];
|
|
1468
|
+
const completedMatches = content.match(/^- \[[xX]\]/gm) ?? [];
|
|
1469
|
+
const total = totalMatches.length;
|
|
1470
|
+
const completed = completedMatches.length;
|
|
1471
|
+
if (total === 0) {
|
|
1472
|
+
return {
|
|
1473
|
+
ready: false,
|
|
1474
|
+
reason: "plan-empty",
|
|
1475
|
+
detail: `${latest.name}: no checkboxes found`
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
if (completed >= total) {
|
|
1479
|
+
return {
|
|
1480
|
+
ready: false,
|
|
1481
|
+
reason: "all-plans-complete",
|
|
1482
|
+
detail: `${latest.name}: ${completed}/${total} complete`
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
return {
|
|
1486
|
+
ready: true,
|
|
1487
|
+
planPath: latest.path,
|
|
1488
|
+
planName: latest.name.replace(/\.md$/, ""),
|
|
1489
|
+
total,
|
|
1490
|
+
completed
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
async function recheckSessionIdle(client, sessionId) {
|
|
1494
|
+
const result = await client.session.status();
|
|
1495
|
+
const statuses = result.data ?? {};
|
|
1496
|
+
const sessionStatus = statuses[sessionId];
|
|
1497
|
+
return sessionStatus?.type === "idle";
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// src/events/status-command.ts
|
|
1501
|
+
var SNIPPET_MAX_CHARS = 80;
|
|
1502
|
+
var MESSAGES_LIMIT = 10;
|
|
1503
|
+
var EMPTY_MESSAGE = "\uBA54\uC2DC\uC9C0 \uC5C6\uC74C";
|
|
1504
|
+
function resolveProjectRoot(session) {
|
|
1505
|
+
if (!session.directory) throw new Error("session directory missing");
|
|
1506
|
+
return session.directory;
|
|
1507
|
+
}
|
|
1508
|
+
function extractTextFromParts(parts) {
|
|
1509
|
+
const pieces = [];
|
|
1510
|
+
for (const part of parts) {
|
|
1511
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
1512
|
+
pieces.push(part.text);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return pieces.join(" ");
|
|
1516
|
+
}
|
|
1517
|
+
function buildSnippet(envelope) {
|
|
1518
|
+
if (!envelope) return EMPTY_MESSAGE;
|
|
1519
|
+
try {
|
|
1520
|
+
const raw = extractTextFromParts(envelope.parts);
|
|
1521
|
+
const cleaned = stripCodeFences(raw);
|
|
1522
|
+
const truncated = truncateForTelegram(cleaned, SNIPPET_MAX_CHARS);
|
|
1523
|
+
if (!truncated) return EMPTY_MESSAGE;
|
|
1524
|
+
return escapeHtml(truncated);
|
|
1525
|
+
} catch {
|
|
1526
|
+
return EMPTY_MESSAGE;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
function findLastByRole(messages, role) {
|
|
1530
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
1531
|
+
const msg = messages[i];
|
|
1532
|
+
if (msg && msg.info.role === role) return msg;
|
|
1533
|
+
}
|
|
1534
|
+
return void 0;
|
|
1535
|
+
}
|
|
1536
|
+
function planReadinessKorean(result) {
|
|
1537
|
+
if (result.ready) {
|
|
1538
|
+
return `${result.completed}/${result.total} (${result.planName})`;
|
|
1539
|
+
}
|
|
1540
|
+
switch (result.reason) {
|
|
1541
|
+
case "no-omo-dir":
|
|
1542
|
+
return "`.omo/` \uC5C6\uC74C";
|
|
1543
|
+
case "no-plans":
|
|
1544
|
+
return "plan \uD30C\uC77C \uC5C6\uC74C";
|
|
1545
|
+
case "plan-empty":
|
|
1546
|
+
return "\uCCB4\uD06C\uBC15\uC2A4 \uC5C6\uC74C";
|
|
1547
|
+
case "all-plans-complete": {
|
|
1548
|
+
const match = result.detail.match(/(\d+)\/(\d+)/);
|
|
1549
|
+
if (match) return `${match[1]}/${match[2]} \uC644\uB8CC`;
|
|
1550
|
+
return "\uC644\uB8CC";
|
|
1551
|
+
}
|
|
1552
|
+
case "boulder-active":
|
|
1553
|
+
return "boulder \uD65C\uC131";
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
function planLine(result) {
|
|
1557
|
+
if (result.ready) {
|
|
1558
|
+
return `<b>\uD50C\uB79C \uC9C4\uD589\uB3C4</b>: ${result.completed}/${result.total} (${escapeHtml(result.planName)})`;
|
|
1559
|
+
}
|
|
1560
|
+
return `<b>\uD50C\uB79C \uC0C1\uD0DC</b>: ${planReadinessKorean(result)}`;
|
|
1561
|
+
}
|
|
1562
|
+
function boulderLine(result) {
|
|
1563
|
+
const active = !result.ready && result.reason === "boulder-active";
|
|
1564
|
+
return active ? "<b>Boulder</b>: \uD65C\uC131" : "<b>Boulder</b>: \uC5C6\uC74C";
|
|
1565
|
+
}
|
|
1566
|
+
function createStatusDispatcher(deps) {
|
|
1567
|
+
return async ({ chatId, bot, args }) => {
|
|
1568
|
+
const rawN = args[0];
|
|
1569
|
+
if (rawN === void 0 || rawN === "") {
|
|
1570
|
+
await bot.sendMessage("\uC0AC\uC6A9\uBC95: /status <\uBC88\uD638>. \uBA3C\uC800 /sessions \uB85C \uBAA9\uB85D \uD655\uC778", {
|
|
1571
|
+
parse_mode: "HTML"
|
|
1572
|
+
});
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
const n = Number(rawN);
|
|
1576
|
+
if (Number.isNaN(n)) {
|
|
1577
|
+
await bot.sendMessage(`\uC798\uBABB\uB41C \uC785\uB825: ${escapeHtml(rawN)}\uC740 \uC22B\uC790\uC5EC\uC57C \uD569\uB2C8\uB2E4`, {
|
|
1578
|
+
parse_mode: "HTML"
|
|
1579
|
+
});
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const snapshot = await deps.snapshotStore.loadSnapshot(chatId);
|
|
1583
|
+
if (!snapshot) {
|
|
1584
|
+
await bot.sendMessage("\uC138\uC158 \uBAA9\uB85D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 /sessions \uB97C \uC2E4\uD589\uD558\uC138\uC694.", {
|
|
1585
|
+
parse_mode: "HTML"
|
|
1586
|
+
});
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
const entry = snapshot.find((e) => e.index === n);
|
|
1590
|
+
if (!entry) {
|
|
1591
|
+
await bot.sendMessage(`${n}\uBC88 \uC138\uC158 \uC5C6\uC74C. \uD604\uC7AC \uBAA9\uB85D \uD06C\uAE30: ${snapshot.length}`, {
|
|
1592
|
+
parse_mode: "HTML"
|
|
1593
|
+
});
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
const [getResult, statusResult, messagesResult] = await Promise.all([
|
|
1597
|
+
deps.client.session.get({ path: { id: entry.sessionId } }),
|
|
1598
|
+
deps.client.session.status(),
|
|
1599
|
+
deps.client.session.messages({
|
|
1600
|
+
path: { id: entry.sessionId },
|
|
1601
|
+
query: { limit: MESSAGES_LIMIT }
|
|
1602
|
+
})
|
|
1603
|
+
]);
|
|
1604
|
+
const session = getResult.data;
|
|
1605
|
+
const responseStatus = getResult.response?.status;
|
|
1606
|
+
if (!session || responseStatus === 404) {
|
|
1607
|
+
await bot.sendMessage("\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
|
|
1608
|
+
parse_mode: "HTML"
|
|
1609
|
+
});
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
const statusMap = statusResult.data ?? {};
|
|
1613
|
+
const sessionStatus = statusMap[entry.sessionId]?.type ?? "unknown";
|
|
1614
|
+
const messages = messagesResult.data ?? [];
|
|
1615
|
+
const projectRoot = resolveProjectRoot(session);
|
|
1616
|
+
const planReady = await checkPlanReadiness({ projectRoot });
|
|
1617
|
+
const userSnippet = buildSnippet(findLastByRole(messages, "user"));
|
|
1618
|
+
const assistantSnippet = buildSnippet(findLastByRole(messages, "assistant"));
|
|
1619
|
+
const title = escapeHtml(session.title ?? "");
|
|
1620
|
+
const agent = entry.agent ? escapeHtml(entry.agent) : "?";
|
|
1621
|
+
const text = [
|
|
1622
|
+
`<b>\uC138\uC158 #${n}</b>: ${title}`,
|
|
1623
|
+
`\uC5D0\uC774\uC804\uD2B8: ${agent}`,
|
|
1624
|
+
`\uC0C1\uD0DC: ${escapeHtml(sessionStatus)}`,
|
|
1625
|
+
``,
|
|
1626
|
+
`<b>\uB9C8\uC9C0\uB9C9 \uBA54\uC2DC\uC9C0</b>`,
|
|
1627
|
+
`\uC720\uC800: ${userSnippet}`,
|
|
1628
|
+
`\uC5D0\uC774\uC804\uD2B8: ${assistantSnippet}`,
|
|
1629
|
+
``,
|
|
1630
|
+
planLine(planReady),
|
|
1631
|
+
``,
|
|
1632
|
+
boulderLine(planReady)
|
|
1633
|
+
].join("\n");
|
|
1634
|
+
await bot.sendMessage(text, { parse_mode: "HTML" });
|
|
1635
|
+
deps.logger.info("status shown", {
|
|
1636
|
+
chatId,
|
|
1637
|
+
sessionId: entry.sessionId,
|
|
1638
|
+
snapshotIndex: n
|
|
1639
|
+
});
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// src/events/start-work-command.ts
|
|
1644
|
+
function agentFromSession(session) {
|
|
1645
|
+
const candidate = session;
|
|
1646
|
+
return typeof candidate.agent === "string" ? candidate.agent : void 0;
|
|
1647
|
+
}
|
|
1648
|
+
function resolveProjectRoot2(session) {
|
|
1649
|
+
return session.directory;
|
|
1650
|
+
}
|
|
1651
|
+
function readinessMessage(reason) {
|
|
1652
|
+
switch (reason) {
|
|
1653
|
+
case "no-omo-dir":
|
|
1654
|
+
return ".omo/ \uB514\uB809\uD1A0\uB9AC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. plan \uC791\uC131\uC774 \uC120\uD589\uB418\uC5B4\uC57C \uD569\uB2C8\uB2E4";
|
|
1655
|
+
case "no-plans":
|
|
1656
|
+
return ".omo/plans/ \uC5D0 plan \uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4";
|
|
1657
|
+
case "plan-empty":
|
|
1658
|
+
return "plan \uD30C\uC77C\uC5D0 \uCCB4\uD06C\uBC15\uC2A4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4 (\uD5E4\uB354\uB9CC \uC874\uC7AC)";
|
|
1659
|
+
case "all-plans-complete":
|
|
1660
|
+
return "plan \uC758 \uBAA8\uB4E0 task \uAC00 \uC644\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uC0C8 plan \uC791\uC131 \uD544\uC694";
|
|
1661
|
+
case "boulder-active":
|
|
1662
|
+
return ".omo/boulder.json \uC774 \uC774\uBBF8 \uC874\uC7AC\uD569\uB2C8\uB2E4. \uAE30\uC874 \uC791\uC5C5\uC774 \uC9C4\uD589 \uC911\uC774\uAC70\uB098 archive \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4";
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
function isSessionNotFoundError(err) {
|
|
1666
|
+
const httpError = err;
|
|
1667
|
+
return httpError.status === 404 || httpError.statusCode === 404 || httpError.response?.status === 404 || err.message.includes("404");
|
|
1668
|
+
}
|
|
1669
|
+
async function sendHtml(bot, text) {
|
|
1670
|
+
await bot.sendMessage(text, { parse_mode: "HTML" });
|
|
1671
|
+
}
|
|
1672
|
+
async function sendPlain(bot, text) {
|
|
1673
|
+
await bot.sendMessage(text);
|
|
1674
|
+
}
|
|
1675
|
+
function createStartWorkCommandDispatcher(deps) {
|
|
1676
|
+
return async ({ chatId, bot, args }) => {
|
|
1677
|
+
const rawIndex = args[0]?.trim();
|
|
1678
|
+
if (!rawIndex) {
|
|
1679
|
+
await sendPlain(bot, "\uC0AC\uC6A9\uBC95: /start_work <\uBC88\uD638>. \uBA3C\uC800 /sessions \uB85C \uBAA9\uB85D \uD655\uC778");
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const index = Number(rawIndex);
|
|
1683
|
+
if (Number.isNaN(index)) {
|
|
1684
|
+
await sendPlain(bot, `\uC798\uBABB\uB41C \uC785\uB825: ${rawIndex}\uC740 \uC22B\uC790\uC5EC\uC57C \uD569\uB2C8\uB2E4`);
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
const snapshot = await deps.snapshotStore.loadSnapshot(chatId);
|
|
1688
|
+
if (snapshot === null) {
|
|
1689
|
+
await sendPlain(bot, "\uC138\uC158 \uBAA9\uB85D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 /sessions \uC2E4\uD589");
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
const entry = snapshot.find((candidate) => candidate.index === index);
|
|
1693
|
+
if (!entry) {
|
|
1694
|
+
await sendPlain(bot, `${index}\uBC88 \uC138\uC158 \uC5C6\uC74C (\uBAA9\uB85D \uD06C\uAE30: ${snapshot.length})`);
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
const sessionId = entry.sessionId;
|
|
1698
|
+
let session;
|
|
1699
|
+
try {
|
|
1700
|
+
const result = await deps.client.session.get({ path: { id: sessionId } });
|
|
1701
|
+
if (!result.data) {
|
|
1702
|
+
await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
session = result.data;
|
|
1706
|
+
} catch (err) {
|
|
1707
|
+
if (err instanceof Error && isSessionNotFoundError(err)) {
|
|
1708
|
+
await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
await sendPlain(bot, `\uC138\uC158 \uD655\uC778 \uC2E4\uD328: ${String(err)}`);
|
|
1712
|
+
deps.logger.error("start-work session lookup failed", { sessionId, error: String(err) });
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
const agent = deps.sessionTitleService.getSessionAgent(sessionId) ?? agentFromSession(session);
|
|
1716
|
+
if (agent !== "plan") {
|
|
1717
|
+
await sendPlain(
|
|
1718
|
+
bot,
|
|
1719
|
+
`${index}\uBC88 \uC138\uC158\uC758 \uC5D0\uC774\uC804\uD2B8\uB294 'plan' \uC774 \uC544\uB2D9\uB2C8\uB2E4 (\uD604\uC7AC: ${agent ?? "unknown"}). /start_work \uB294 plan \uC138\uC158\uC5D0\uC11C\uB9CC \uAC00\uB2A5\uD569\uB2C8\uB2E4`
|
|
1720
|
+
);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const idle = await recheckSessionIdle(deps.client, sessionId);
|
|
1724
|
+
if (!idle) {
|
|
1725
|
+
await sendPlain(bot, `${index}\uBC88 \uC138\uC158\uC774 idle \uC0C1\uD0DC\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uC791\uC5C5 \uC644\uB8CC\uB97C \uAE30\uB2E4\uB9AC\uC138\uC694`);
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
const readiness = await checkPlanReadiness({ projectRoot: resolveProjectRoot2(session) });
|
|
1729
|
+
if (!readiness.ready) {
|
|
1730
|
+
await sendPlain(bot, readinessMessage(readiness.reason));
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
try {
|
|
1734
|
+
const serverUrl = deps.sessionTitleService.getServerUrl(sessionId);
|
|
1735
|
+
await deps.runSessionCommand(sessionId, "start-work", serverUrl);
|
|
1736
|
+
await sendHtml(
|
|
1737
|
+
bot,
|
|
1738
|
+
`${index}\uBC88 \uC138\uC158\uC5D0 opencode /start-work \uC2AC\uB798\uC2DC \uCEE4\uB9E8\uB4DC \uC804\uC1A1 \uC644\uB8CC. (${escapeHtml(entry.title)})`
|
|
1739
|
+
);
|
|
1740
|
+
deps.logger.info("start-work dispatched", { chatId, sessionId, index });
|
|
1741
|
+
} catch (err) {
|
|
1742
|
+
await sendHtml(bot, "opencode /start-work \uC804\uC1A1 \uC2E4\uD328: " + String(err));
|
|
1743
|
+
deps.logger.error("start-work dispatch failed", { sessionId, error: String(err) });
|
|
1744
|
+
}
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// src/events/help-command.ts
|
|
1749
|
+
var HELP_TEXT = `<b>OpenCode Telegram Plugin \u2014 \uBA85\uB839 \uB3C4\uC6C0\uB9D0</b>
|
|
1750
|
+
|
|
1751
|
+
<b>/sessions</b>
|
|
1752
|
+
\uD65C\uC131 root \uC138\uC158 \uBAA9\uB85D\uC744 \uBC88\uD638\uC640 \uD568\uAED8 \uD45C\uC2DC (\uCD5C\uADFC\uD65C\uB3D9\uC21C top 20).
|
|
1753
|
+
|
|
1754
|
+
<b>/status <\uBC88\uD638></b>
|
|
1755
|
+
\uD574\uB2F9 \uC138\uC158\uC758 \uC5D0\uC774\uC804\uD2B8/\uC0C1\uD0DC/\uB9C8\uC9C0\uB9C9 \uBA54\uC2DC\uC9C0 \uC2A4\uB2C8\uD3AB/\uD50C\uB79C \uC9C4\uD589\uB3C4/boulder \uC0C1\uD0DC \uD45C\uC2DC.
|
|
1756
|
+
|
|
1757
|
+
<b>/start_work <\uBC88\uD638></b>
|
|
1758
|
+
\uD574\uB2F9 \uC138\uC158\uC5D0 opencode <code>/start-work</code> \uC2AC\uB798\uC2DC \uCEE4\uB9E8\uB4DC \uC804\uC1A1.
|
|
1759
|
+
\uC548\uC804 \uAC8C\uC774\uD2B8: agent='plan' AND status=idle AND .omo/plans \uC5D0 \uBBF8\uC644\uB8CC plan \uC874\uC7AC AND .omo/boulder.json \uBD80\uC7AC.
|
|
1760
|
+
\uC870\uAC74 \uBBF8\uCDA9\uC871\uC2DC \uAD6C\uCCB4\uC801 \uC0AC\uC720 \uC548\uB0B4.
|
|
1761
|
+
(Telegram \uBD07 \uBA85\uB839\uC740 <code>/start_work</code>, \uB0B4\uBD80 \uD2B8\uB9AC\uAC70 \uB300\uC0C1\uC740 opencode \uC758 <code>/start-work</code>)
|
|
1762
|
+
|
|
1763
|
+
<b>/help</b>
|
|
1764
|
+
\uC774 \uB3C4\uC6C0\uB9D0 \uD45C\uC2DC.
|
|
1765
|
+
|
|
1766
|
+
<b>\uC81C\uC57D</b>
|
|
1767
|
+
\uBC88\uD638\uB294 <code>/sessions</code> \uB9C8\uC9C0\uB9C9 \uD638\uCD9C\uC758 \uC2A4\uB0C5\uC0F7\uC5D0 \uC885\uC18D (TTL 1\uC2DC\uAC04).
|
|
1768
|
+
leader \uD504\uB85C\uC138\uC2A4\uAC00 \uAD00\uCC30\uD55C \uC138\uC158\uB9CC \uD45C\uC2DC \u2014 \uB2E4\uB978 OpenCode \uD504\uB85C\uC138\uC2A4\uC758 \uC138\uC158\uC740 \uBCF4\uC774\uC9C0 \uC54A\uC744 \uC218 \uC788\uC74C.`;
|
|
1769
|
+
function createHelpDispatcher(deps) {
|
|
1770
|
+
return async ({ chatId, bot }) => {
|
|
1771
|
+
await bot.sendMessage(HELP_TEXT, { parse_mode: "HTML" });
|
|
1772
|
+
deps.logger.info("help shown", { chatId });
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1256
1776
|
// src/lib/env-loader.ts
|
|
1257
1777
|
import { existsSync } from "fs";
|
|
1258
1778
|
import { homedir } from "os";
|
|
1259
|
-
import { join as
|
|
1779
|
+
import { join as join6 } from "path";
|
|
1260
1780
|
import dotenv from "dotenv";
|
|
1261
1781
|
function loadPluginEnv(opts) {
|
|
1262
1782
|
const paths = [
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1783
|
+
join6(opts.pluginDir, "../../.env"),
|
|
1784
|
+
join6(opts.pluginDir, "..", ".env"),
|
|
1785
|
+
join6(opts.pluginDir, ".env"),
|
|
1786
|
+
join6(opts.homeDir ?? homedir(), ".config/opencode/telegram-remote/.env")
|
|
1267
1787
|
];
|
|
1268
1788
|
const loadedFrom = [];
|
|
1269
1789
|
const values = {};
|
|
@@ -1281,7 +1801,7 @@ function loadPluginEnv(opts) {
|
|
|
1281
1801
|
}
|
|
1282
1802
|
|
|
1283
1803
|
// src/lib/lock.ts
|
|
1284
|
-
import { open as open2, readFile as
|
|
1804
|
+
import { open as open2, readFile as readFile5, stat as stat3, unlink as unlink5 } from "fs/promises";
|
|
1285
1805
|
import { hostname } from "os";
|
|
1286
1806
|
var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
|
|
1287
1807
|
function hasCode5(err, code) {
|
|
@@ -1334,7 +1854,7 @@ async function inspectExisting(lockPath, ttlMs) {
|
|
|
1334
1854
|
let ownerPid;
|
|
1335
1855
|
let dead = false;
|
|
1336
1856
|
try {
|
|
1337
|
-
const text = await
|
|
1857
|
+
const text = await readFile5(lockPath, "utf8");
|
|
1338
1858
|
const data = parseLockData(text);
|
|
1339
1859
|
if (data) {
|
|
1340
1860
|
ownerPid = data.pid;
|
|
@@ -1344,7 +1864,7 @@ async function inspectExisting(lockPath, ttlMs) {
|
|
|
1344
1864
|
return { stale: true, reason: "unreadable lock" };
|
|
1345
1865
|
}
|
|
1346
1866
|
try {
|
|
1347
|
-
const fileStat = await
|
|
1867
|
+
const fileStat = await stat3(lockPath);
|
|
1348
1868
|
const expired = Date.now() - fileStat.mtimeMs > ttlMs;
|
|
1349
1869
|
if (dead) return { stale: true, ownerPid, reason: "dead owner" };
|
|
1350
1870
|
if (expired) return { stale: true, ownerPid, reason: "expired lock" };
|
|
@@ -1447,11 +1967,146 @@ function createLogger(opts = {}) {
|
|
|
1447
1967
|
};
|
|
1448
1968
|
}
|
|
1449
1969
|
|
|
1970
|
+
// src/lib/session-snapshot.ts
|
|
1971
|
+
import { chmod, mkdir as mkdir5, readFile as readFile6, rename as rename4, unlink as unlink6, writeFile as writeFile4 } from "fs/promises";
|
|
1972
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
1973
|
+
var TTL_MS = 60 * 60 * 1e3;
|
|
1974
|
+
function hasCode6(err, code) {
|
|
1975
|
+
return err instanceof Error && "code" in err && err.code === code;
|
|
1976
|
+
}
|
|
1977
|
+
function isSnapshotFile(value) {
|
|
1978
|
+
if (!value || typeof value !== "object") return false;
|
|
1979
|
+
const v = value;
|
|
1980
|
+
if (v.version !== 1) return false;
|
|
1981
|
+
if (typeof v.chatId !== "number") return false;
|
|
1982
|
+
if (typeof v.createdAt !== "number") return false;
|
|
1983
|
+
if (!Array.isArray(v.entries)) return false;
|
|
1984
|
+
for (const entry of v.entries) {
|
|
1985
|
+
if (!entry || typeof entry !== "object") return false;
|
|
1986
|
+
const e = entry;
|
|
1987
|
+
if (typeof e.index !== "number") return false;
|
|
1988
|
+
if (typeof e.sessionId !== "string") return false;
|
|
1989
|
+
if (typeof e.title !== "string") return false;
|
|
1990
|
+
if (typeof e.capturedAt !== "number") return false;
|
|
1991
|
+
if (e.agent !== void 0 && typeof e.agent !== "string") return false;
|
|
1992
|
+
if (e.status !== void 0 && typeof e.status !== "string") return false;
|
|
1993
|
+
if (e.serverUrl !== void 0 && typeof e.serverUrl !== "string") return false;
|
|
1994
|
+
}
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1997
|
+
function normalizeEntry(entry) {
|
|
1998
|
+
const out = {
|
|
1999
|
+
index: entry.index,
|
|
2000
|
+
sessionId: entry.sessionId,
|
|
2001
|
+
title: entry.title,
|
|
2002
|
+
capturedAt: entry.capturedAt
|
|
2003
|
+
};
|
|
2004
|
+
if (entry.agent !== void 0) out.agent = entry.agent;
|
|
2005
|
+
if (entry.status !== void 0) out.status = entry.status;
|
|
2006
|
+
if (entry.serverUrl !== void 0) out.serverUrl = entry.serverUrl;
|
|
2007
|
+
return out;
|
|
2008
|
+
}
|
|
2009
|
+
function createSnapshotStore(opts) {
|
|
2010
|
+
const { configDir, tokenHash, logger } = opts;
|
|
2011
|
+
const snapshotsDir = join7(configDir, "snapshots");
|
|
2012
|
+
const writeLocks = /* @__PURE__ */ new Map();
|
|
2013
|
+
function snapshotFilePath(chatId) {
|
|
2014
|
+
return join7(snapshotsDir, `${tokenHash}-${chatId}.json`);
|
|
2015
|
+
}
|
|
2016
|
+
async function performSave(chatId, entries) {
|
|
2017
|
+
const filePath = snapshotFilePath(chatId);
|
|
2018
|
+
const parent = dirname4(filePath);
|
|
2019
|
+
await mkdir5(parent, { recursive: true });
|
|
2020
|
+
try {
|
|
2021
|
+
await chmod(parent, 448);
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
logger.error("snapshot: failed to chmod parent dir", {
|
|
2024
|
+
path: parent,
|
|
2025
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
const payload = {
|
|
2029
|
+
version: 1,
|
|
2030
|
+
chatId,
|
|
2031
|
+
createdAt: Date.now(),
|
|
2032
|
+
entries: entries.map(normalizeEntry)
|
|
2033
|
+
};
|
|
2034
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
2035
|
+
await writeFile4(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
|
2036
|
+
try {
|
|
2037
|
+
await rename4(tmpPath, filePath);
|
|
2038
|
+
} catch (err) {
|
|
2039
|
+
try {
|
|
2040
|
+
await unlink6(tmpPath);
|
|
2041
|
+
} catch {
|
|
2042
|
+
}
|
|
2043
|
+
throw err;
|
|
2044
|
+
}
|
|
2045
|
+
await chmod(filePath, 384);
|
|
2046
|
+
}
|
|
2047
|
+
async function saveSnapshot(chatId, entries) {
|
|
2048
|
+
const prev = writeLocks.get(chatId) ?? Promise.resolve();
|
|
2049
|
+
const next = prev.catch(() => void 0).then(() => performSave(chatId, entries));
|
|
2050
|
+
const tracked = next.catch(() => void 0);
|
|
2051
|
+
writeLocks.set(chatId, tracked);
|
|
2052
|
+
try {
|
|
2053
|
+
await next;
|
|
2054
|
+
} finally {
|
|
2055
|
+
if (writeLocks.get(chatId) === tracked) {
|
|
2056
|
+
writeLocks.delete(chatId);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
async function loadSnapshot(chatId) {
|
|
2061
|
+
const filePath = snapshotFilePath(chatId);
|
|
2062
|
+
let text;
|
|
2063
|
+
try {
|
|
2064
|
+
text = await readFile6(filePath, "utf8");
|
|
2065
|
+
} catch (err) {
|
|
2066
|
+
if (hasCode6(err, "ENOENT")) return null;
|
|
2067
|
+
logger.error("snapshot: failed to read file", {
|
|
2068
|
+
path: filePath,
|
|
2069
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2070
|
+
});
|
|
2071
|
+
return null;
|
|
2072
|
+
}
|
|
2073
|
+
let parsed;
|
|
2074
|
+
try {
|
|
2075
|
+
parsed = JSON.parse(text);
|
|
2076
|
+
} catch (err) {
|
|
2077
|
+
logger.error("snapshot: corrupted JSON", {
|
|
2078
|
+
path: filePath,
|
|
2079
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2080
|
+
});
|
|
2081
|
+
return null;
|
|
2082
|
+
}
|
|
2083
|
+
if (!isSnapshotFile(parsed)) {
|
|
2084
|
+
logger.error("snapshot: invalid shape", { path: filePath });
|
|
2085
|
+
return null;
|
|
2086
|
+
}
|
|
2087
|
+
if (parsed.createdAt + TTL_MS < Date.now()) {
|
|
2088
|
+
try {
|
|
2089
|
+
await unlink6(filePath);
|
|
2090
|
+
} catch (err) {
|
|
2091
|
+
if (!hasCode6(err, "ENOENT")) {
|
|
2092
|
+
logger.error("snapshot: failed to unlink expired file", {
|
|
2093
|
+
path: filePath,
|
|
2094
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
return null;
|
|
2099
|
+
}
|
|
2100
|
+
return parsed.entries.map(normalizeEntry);
|
|
2101
|
+
}
|
|
2102
|
+
return { saveSnapshot, loadSnapshot, snapshotFilePath };
|
|
2103
|
+
}
|
|
2104
|
+
|
|
1450
2105
|
// src/lib/state-store.ts
|
|
1451
|
-
import { mkdir as
|
|
2106
|
+
import { mkdir as mkdir6, readFile as readFile7, rename as rename5, writeFile as writeFile5 } from "fs/promises";
|
|
1452
2107
|
import { homedir as homedir2 } from "os";
|
|
1453
|
-
import { dirname as
|
|
1454
|
-
function
|
|
2108
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
2109
|
+
function hasCode7(err, code) {
|
|
1455
2110
|
return "code" in err && err.code === code;
|
|
1456
2111
|
}
|
|
1457
2112
|
function parseState(text) {
|
|
@@ -1463,28 +2118,28 @@ function parseState(text) {
|
|
|
1463
2118
|
return state;
|
|
1464
2119
|
}
|
|
1465
2120
|
function createStateStore(opts = {}) {
|
|
1466
|
-
const filePath = opts.filePath ??
|
|
2121
|
+
const filePath = opts.filePath ?? join8(homedir2(), ".config/opencode/telegram-remote/state.json");
|
|
1467
2122
|
return {
|
|
1468
2123
|
async read() {
|
|
1469
2124
|
try {
|
|
1470
|
-
return parseState(await
|
|
2125
|
+
return parseState(await readFile7(filePath, "utf8"));
|
|
1471
2126
|
} catch (err) {
|
|
1472
|
-
if (err instanceof Error &&
|
|
2127
|
+
if (err instanceof Error && hasCode7(err, "ENOENT")) return {};
|
|
1473
2128
|
throw err;
|
|
1474
2129
|
}
|
|
1475
2130
|
},
|
|
1476
2131
|
async write(patch) {
|
|
1477
2132
|
const existing = await this.read();
|
|
1478
2133
|
const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1479
|
-
await
|
|
2134
|
+
await mkdir6(dirname5(filePath), { recursive: true });
|
|
1480
2135
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
1481
|
-
await
|
|
2136
|
+
await writeFile5(tmpPath, JSON.stringify(next, null, 2), "utf8");
|
|
1482
2137
|
try {
|
|
1483
|
-
await
|
|
2138
|
+
await rename5(tmpPath, filePath);
|
|
1484
2139
|
} catch (err) {
|
|
1485
|
-
if (!(err instanceof Error) || !
|
|
1486
|
-
await
|
|
1487
|
-
await
|
|
2140
|
+
if (!(err instanceof Error) || !hasCode7(err, "ENOENT")) throw err;
|
|
2141
|
+
await writeFile5(tmpPath, JSON.stringify(next, null, 2), "utf8");
|
|
2142
|
+
await rename5(tmpPath, filePath);
|
|
1488
2143
|
}
|
|
1489
2144
|
return next;
|
|
1490
2145
|
}
|
|
@@ -1492,7 +2147,7 @@ function createStateStore(opts = {}) {
|
|
|
1492
2147
|
}
|
|
1493
2148
|
|
|
1494
2149
|
// src/services/session-title-service.ts
|
|
1495
|
-
function
|
|
2150
|
+
function agentFromSession2(info) {
|
|
1496
2151
|
const candidate = info;
|
|
1497
2152
|
return typeof candidate.agent === "string" ? candidate.agent : void 0;
|
|
1498
2153
|
}
|
|
@@ -1503,9 +2158,11 @@ var SessionTitleService = class {
|
|
|
1503
2158
|
this.sessions.set(info.id, {
|
|
1504
2159
|
title: info.title || null,
|
|
1505
2160
|
parentID: info.parentID ?? null,
|
|
1506
|
-
agent:
|
|
2161
|
+
agent: agentFromSession2(info) ?? existing?.agent,
|
|
1507
2162
|
status: existing?.status,
|
|
1508
|
-
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
2163
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false,
|
|
2164
|
+
lastSeenAt: Date.now(),
|
|
2165
|
+
serverUrl: existing?.serverUrl
|
|
1509
2166
|
});
|
|
1510
2167
|
}
|
|
1511
2168
|
setSessionTitle(sessionId, title) {
|
|
@@ -1515,7 +2172,9 @@ var SessionTitleService = class {
|
|
|
1515
2172
|
parentID: existing?.parentID,
|
|
1516
2173
|
agent: existing?.agent,
|
|
1517
2174
|
status: existing?.status,
|
|
1518
|
-
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
2175
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false,
|
|
2176
|
+
lastSeenAt: Date.now(),
|
|
2177
|
+
serverUrl: existing?.serverUrl
|
|
1519
2178
|
});
|
|
1520
2179
|
}
|
|
1521
2180
|
setSessionAgent(sessionId, agent) {
|
|
@@ -1525,7 +2184,9 @@ var SessionTitleService = class {
|
|
|
1525
2184
|
parentID: existing?.parentID,
|
|
1526
2185
|
agent,
|
|
1527
2186
|
status: existing?.status,
|
|
1528
|
-
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
2187
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false,
|
|
2188
|
+
lastSeenAt: Date.now(),
|
|
2189
|
+
serverUrl: existing?.serverUrl
|
|
1529
2190
|
});
|
|
1530
2191
|
}
|
|
1531
2192
|
setSessionStatus(sessionId, status) {
|
|
@@ -1535,9 +2196,48 @@ var SessionTitleService = class {
|
|
|
1535
2196
|
parentID: existing?.parentID,
|
|
1536
2197
|
agent: existing?.agent,
|
|
1537
2198
|
status,
|
|
1538
|
-
idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
|
|
2199
|
+
idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false,
|
|
2200
|
+
lastSeenAt: Date.now(),
|
|
2201
|
+
serverUrl: existing?.serverUrl
|
|
1539
2202
|
});
|
|
1540
2203
|
}
|
|
2204
|
+
setServerUrl(sessionId, serverUrl) {
|
|
2205
|
+
const existing = this.sessions.get(sessionId);
|
|
2206
|
+
if (existing?.serverUrl) return;
|
|
2207
|
+
const lastSeenAt = existing?.lastSeenAt ?? Date.now();
|
|
2208
|
+
this.sessions.set(sessionId, {
|
|
2209
|
+
...existing ?? {
|
|
2210
|
+
title: null,
|
|
2211
|
+
parentID: void 0,
|
|
2212
|
+
idleNotificationPending: false,
|
|
2213
|
+
lastSeenAt
|
|
2214
|
+
},
|
|
2215
|
+
lastSeenAt,
|
|
2216
|
+
serverUrl
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
getServerUrl(sessionId) {
|
|
2220
|
+
return this.sessions.get(sessionId)?.serverUrl;
|
|
2221
|
+
}
|
|
2222
|
+
getRootSessionsByRecency(limit) {
|
|
2223
|
+
const results = [];
|
|
2224
|
+
for (const [sessionId, entry] of this.sessions.entries()) {
|
|
2225
|
+
if (entry.parentID !== null) continue;
|
|
2226
|
+
results.push({
|
|
2227
|
+
sessionId,
|
|
2228
|
+
title: entry.title,
|
|
2229
|
+
agent: entry.agent,
|
|
2230
|
+
status: entry.status,
|
|
2231
|
+
serverUrl: entry.serverUrl
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
results.sort((a, b) => {
|
|
2235
|
+
const lastSeenA = this.sessions.get(a.sessionId)?.lastSeenAt ?? 0;
|
|
2236
|
+
const lastSeenB = this.sessions.get(b.sessionId)?.lastSeenAt ?? 0;
|
|
2237
|
+
return lastSeenB - lastSeenA;
|
|
2238
|
+
});
|
|
2239
|
+
return results.slice(0, limit);
|
|
2240
|
+
}
|
|
1541
2241
|
getSessionTitle(sessionId) {
|
|
1542
2242
|
return this.sessions.get(sessionId)?.title ?? null;
|
|
1543
2243
|
}
|
|
@@ -1565,7 +2265,9 @@ var SessionTitleService = class {
|
|
|
1565
2265
|
parentID: existing?.parentID,
|
|
1566
2266
|
agent: existing?.agent,
|
|
1567
2267
|
status: existing?.status ?? "idle",
|
|
1568
|
-
idleNotificationPending: true
|
|
2268
|
+
idleNotificationPending: true,
|
|
2269
|
+
lastSeenAt: existing?.lastSeenAt ?? Date.now(),
|
|
2270
|
+
serverUrl: existing?.serverUrl
|
|
1569
2271
|
});
|
|
1570
2272
|
}
|
|
1571
2273
|
hasDeferredIdleNotification(sessionId) {
|
|
@@ -1582,7 +2284,7 @@ var SessionTitleService = class {
|
|
|
1582
2284
|
};
|
|
1583
2285
|
|
|
1584
2286
|
// src/telegram-remote.ts
|
|
1585
|
-
var pluginDir =
|
|
2287
|
+
var pluginDir = dirname6(fileURLToPath(import.meta.url));
|
|
1586
2288
|
async function postToServer(serverUrl, path, body) {
|
|
1587
2289
|
const url = new URL(path, serverUrl);
|
|
1588
2290
|
const response = await fetch(url, {
|
|
@@ -1618,8 +2320,10 @@ var TelegramRemote = async (input) => {
|
|
|
1618
2320
|
const stateStore = createStateStore();
|
|
1619
2321
|
const initialState = await stateStore.read();
|
|
1620
2322
|
const tokenHash = createHash5("sha256").update(config.botToken).digest("hex").slice(0, 16);
|
|
1621
|
-
const
|
|
1622
|
-
const
|
|
2323
|
+
const configDir = join9(homedir3(), ".config/opencode/telegram-remote");
|
|
2324
|
+
const snapshotStore = createSnapshotStore({ configDir, tokenHash, logger });
|
|
2325
|
+
const lockPath = join9(tmpdir5(), `opencoder-telegram-${tokenHash}.lock`);
|
|
2326
|
+
const claimsDir = join9(tmpdir5(), `opencoder-telegram-claims-${tokenHash}`);
|
|
1623
2327
|
const pendingQuestions = createPendingQuestionStore({ tokenHash });
|
|
1624
2328
|
const pendingPermissions = createPendingPermissionStore({ tokenHash });
|
|
1625
2329
|
const pendingStartWorks = createPendingStartWorkStore({ tokenHash });
|
|
@@ -1742,6 +2446,26 @@ var TelegramRemote = async (input) => {
|
|
|
1742
2446
|
bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
|
|
1743
2447
|
bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
|
|
1744
2448
|
bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
|
|
2449
|
+
bot.setSessionsDispatcher(createSessionsDispatcher({ sessionTitleService, snapshotStore, logger }));
|
|
2450
|
+
bot.setStatusDispatcher(createStatusDispatcher({ snapshotStore, sessionTitleService, client: input.client, logger }));
|
|
2451
|
+
bot.setStartWorkCommandDispatcher(createStartWorkCommandDispatcher({
|
|
2452
|
+
snapshotStore,
|
|
2453
|
+
sessionTitleService,
|
|
2454
|
+
client: input.client,
|
|
2455
|
+
runSessionCommand,
|
|
2456
|
+
logger
|
|
2457
|
+
}));
|
|
2458
|
+
bot.setHelpDispatcher(createHelpDispatcher({ logger }));
|
|
2459
|
+
try {
|
|
2460
|
+
const sessions = await input.client.session.list();
|
|
2461
|
+
for (const s of sessions.data ?? []) {
|
|
2462
|
+
sessionTitleService.setSessionInfo(s);
|
|
2463
|
+
sessionTitleService.setServerUrl(s.id, input.serverUrl.href);
|
|
2464
|
+
}
|
|
2465
|
+
logger.info("cold-start cache primed", { count: (sessions.data ?? []).length });
|
|
2466
|
+
} catch (err) {
|
|
2467
|
+
logger.error("cold-start priming failed", { error: String(err) });
|
|
2468
|
+
}
|
|
1745
2469
|
}
|
|
1746
2470
|
return {
|
|
1747
2471
|
event: async ({ event }) => {
|
|
@@ -1753,13 +2477,17 @@ var TelegramRemote = async (input) => {
|
|
|
1753
2477
|
logger.info("session.status received", { statusType: event.properties.status.type });
|
|
1754
2478
|
return handleSessionStatus(event, ctx);
|
|
1755
2479
|
case "session.created":
|
|
2480
|
+
ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
|
|
1756
2481
|
return handleSessionCreated(event, ctx);
|
|
1757
2482
|
case "session.updated":
|
|
2483
|
+
ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
|
|
1758
2484
|
return handleSessionUpdated(event, ctx);
|
|
1759
2485
|
case "message.updated": {
|
|
1760
2486
|
const messageAgent = getSessionAgentFromMessage(event);
|
|
1761
|
-
if (messageAgent)
|
|
2487
|
+
if (messageAgent) {
|
|
1762
2488
|
ctx.sessionTitleService.setSessionAgent(messageAgent.sessionID, messageAgent.agent);
|
|
2489
|
+
ctx.sessionTitleService.setServerUrl(messageAgent.sessionID, input.serverUrl.href);
|
|
2490
|
+
}
|
|
1763
2491
|
return;
|
|
1764
2492
|
}
|
|
1765
2493
|
case "permission.updated":
|
|
@@ -1784,6 +2512,9 @@ var TelegramRemote = async (input) => {
|
|
|
1784
2512
|
if (isEventQuestionReplied(extEvent)) {
|
|
1785
2513
|
return handleQuestionReplied(extEvent, ctx);
|
|
1786
2514
|
}
|
|
2515
|
+
if (isEventPermissionReplied(extEvent)) {
|
|
2516
|
+
return handlePermissionReplied(extEvent, ctx);
|
|
2517
|
+
}
|
|
1787
2518
|
return;
|
|
1788
2519
|
}
|
|
1789
2520
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coinseeker/opencode-telegram-plugin",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "Control and monitor OpenCode from Telegram with notifications, question replies, and subagent-aware completion.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/telegram-remote.js",
|