@coinseeker/opencode-telegram-plugin 1.0.12 → 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 +2 -0
- package/dist/telegram-remote.js +722 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
@@ -1293,17 +1351,439 @@ async function handleSessionUpdated(event, ctx) {
|
|
|
1293
1351
|
ctx.sessionTitleService.setSessionInfo(info);
|
|
1294
1352
|
}
|
|
1295
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
|
+
|
|
1296
1776
|
// src/lib/env-loader.ts
|
|
1297
1777
|
import { existsSync } from "fs";
|
|
1298
1778
|
import { homedir } from "os";
|
|
1299
|
-
import { join as
|
|
1779
|
+
import { join as join6 } from "path";
|
|
1300
1780
|
import dotenv from "dotenv";
|
|
1301
1781
|
function loadPluginEnv(opts) {
|
|
1302
1782
|
const paths = [
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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")
|
|
1307
1787
|
];
|
|
1308
1788
|
const loadedFrom = [];
|
|
1309
1789
|
const values = {};
|
|
@@ -1321,7 +1801,7 @@ function loadPluginEnv(opts) {
|
|
|
1321
1801
|
}
|
|
1322
1802
|
|
|
1323
1803
|
// src/lib/lock.ts
|
|
1324
|
-
import { open as open2, readFile as
|
|
1804
|
+
import { open as open2, readFile as readFile5, stat as stat3, unlink as unlink5 } from "fs/promises";
|
|
1325
1805
|
import { hostname } from "os";
|
|
1326
1806
|
var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
|
|
1327
1807
|
function hasCode5(err, code) {
|
|
@@ -1374,7 +1854,7 @@ async function inspectExisting(lockPath, ttlMs) {
|
|
|
1374
1854
|
let ownerPid;
|
|
1375
1855
|
let dead = false;
|
|
1376
1856
|
try {
|
|
1377
|
-
const text = await
|
|
1857
|
+
const text = await readFile5(lockPath, "utf8");
|
|
1378
1858
|
const data = parseLockData(text);
|
|
1379
1859
|
if (data) {
|
|
1380
1860
|
ownerPid = data.pid;
|
|
@@ -1384,7 +1864,7 @@ async function inspectExisting(lockPath, ttlMs) {
|
|
|
1384
1864
|
return { stale: true, reason: "unreadable lock" };
|
|
1385
1865
|
}
|
|
1386
1866
|
try {
|
|
1387
|
-
const fileStat = await
|
|
1867
|
+
const fileStat = await stat3(lockPath);
|
|
1388
1868
|
const expired = Date.now() - fileStat.mtimeMs > ttlMs;
|
|
1389
1869
|
if (dead) return { stale: true, ownerPid, reason: "dead owner" };
|
|
1390
1870
|
if (expired) return { stale: true, ownerPid, reason: "expired lock" };
|
|
@@ -1487,11 +1967,146 @@ function createLogger(opts = {}) {
|
|
|
1487
1967
|
};
|
|
1488
1968
|
}
|
|
1489
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
|
+
|
|
1490
2105
|
// src/lib/state-store.ts
|
|
1491
|
-
import { mkdir as
|
|
2106
|
+
import { mkdir as mkdir6, readFile as readFile7, rename as rename5, writeFile as writeFile5 } from "fs/promises";
|
|
1492
2107
|
import { homedir as homedir2 } from "os";
|
|
1493
|
-
import { dirname as
|
|
1494
|
-
function
|
|
2108
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
2109
|
+
function hasCode7(err, code) {
|
|
1495
2110
|
return "code" in err && err.code === code;
|
|
1496
2111
|
}
|
|
1497
2112
|
function parseState(text) {
|
|
@@ -1503,28 +2118,28 @@ function parseState(text) {
|
|
|
1503
2118
|
return state;
|
|
1504
2119
|
}
|
|
1505
2120
|
function createStateStore(opts = {}) {
|
|
1506
|
-
const filePath = opts.filePath ??
|
|
2121
|
+
const filePath = opts.filePath ?? join8(homedir2(), ".config/opencode/telegram-remote/state.json");
|
|
1507
2122
|
return {
|
|
1508
2123
|
async read() {
|
|
1509
2124
|
try {
|
|
1510
|
-
return parseState(await
|
|
2125
|
+
return parseState(await readFile7(filePath, "utf8"));
|
|
1511
2126
|
} catch (err) {
|
|
1512
|
-
if (err instanceof Error &&
|
|
2127
|
+
if (err instanceof Error && hasCode7(err, "ENOENT")) return {};
|
|
1513
2128
|
throw err;
|
|
1514
2129
|
}
|
|
1515
2130
|
},
|
|
1516
2131
|
async write(patch) {
|
|
1517
2132
|
const existing = await this.read();
|
|
1518
2133
|
const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1519
|
-
await
|
|
2134
|
+
await mkdir6(dirname5(filePath), { recursive: true });
|
|
1520
2135
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
1521
|
-
await
|
|
2136
|
+
await writeFile5(tmpPath, JSON.stringify(next, null, 2), "utf8");
|
|
1522
2137
|
try {
|
|
1523
|
-
await
|
|
2138
|
+
await rename5(tmpPath, filePath);
|
|
1524
2139
|
} catch (err) {
|
|
1525
|
-
if (!(err instanceof Error) || !
|
|
1526
|
-
await
|
|
1527
|
-
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);
|
|
1528
2143
|
}
|
|
1529
2144
|
return next;
|
|
1530
2145
|
}
|
|
@@ -1532,7 +2147,7 @@ function createStateStore(opts = {}) {
|
|
|
1532
2147
|
}
|
|
1533
2148
|
|
|
1534
2149
|
// src/services/session-title-service.ts
|
|
1535
|
-
function
|
|
2150
|
+
function agentFromSession2(info) {
|
|
1536
2151
|
const candidate = info;
|
|
1537
2152
|
return typeof candidate.agent === "string" ? candidate.agent : void 0;
|
|
1538
2153
|
}
|
|
@@ -1543,9 +2158,11 @@ var SessionTitleService = class {
|
|
|
1543
2158
|
this.sessions.set(info.id, {
|
|
1544
2159
|
title: info.title || null,
|
|
1545
2160
|
parentID: info.parentID ?? null,
|
|
1546
|
-
agent:
|
|
2161
|
+
agent: agentFromSession2(info) ?? existing?.agent,
|
|
1547
2162
|
status: existing?.status,
|
|
1548
|
-
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
2163
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false,
|
|
2164
|
+
lastSeenAt: Date.now(),
|
|
2165
|
+
serverUrl: existing?.serverUrl
|
|
1549
2166
|
});
|
|
1550
2167
|
}
|
|
1551
2168
|
setSessionTitle(sessionId, title) {
|
|
@@ -1555,7 +2172,9 @@ var SessionTitleService = class {
|
|
|
1555
2172
|
parentID: existing?.parentID,
|
|
1556
2173
|
agent: existing?.agent,
|
|
1557
2174
|
status: existing?.status,
|
|
1558
|
-
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
2175
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false,
|
|
2176
|
+
lastSeenAt: Date.now(),
|
|
2177
|
+
serverUrl: existing?.serverUrl
|
|
1559
2178
|
});
|
|
1560
2179
|
}
|
|
1561
2180
|
setSessionAgent(sessionId, agent) {
|
|
@@ -1565,7 +2184,9 @@ var SessionTitleService = class {
|
|
|
1565
2184
|
parentID: existing?.parentID,
|
|
1566
2185
|
agent,
|
|
1567
2186
|
status: existing?.status,
|
|
1568
|
-
idleNotificationPending: existing?.idleNotificationPending ?? false
|
|
2187
|
+
idleNotificationPending: existing?.idleNotificationPending ?? false,
|
|
2188
|
+
lastSeenAt: Date.now(),
|
|
2189
|
+
serverUrl: existing?.serverUrl
|
|
1569
2190
|
});
|
|
1570
2191
|
}
|
|
1571
2192
|
setSessionStatus(sessionId, status) {
|
|
@@ -1575,9 +2196,48 @@ var SessionTitleService = class {
|
|
|
1575
2196
|
parentID: existing?.parentID,
|
|
1576
2197
|
agent: existing?.agent,
|
|
1577
2198
|
status,
|
|
1578
|
-
idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
|
|
2199
|
+
idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false,
|
|
2200
|
+
lastSeenAt: Date.now(),
|
|
2201
|
+
serverUrl: existing?.serverUrl
|
|
1579
2202
|
});
|
|
1580
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
|
+
}
|
|
1581
2241
|
getSessionTitle(sessionId) {
|
|
1582
2242
|
return this.sessions.get(sessionId)?.title ?? null;
|
|
1583
2243
|
}
|
|
@@ -1605,7 +2265,9 @@ var SessionTitleService = class {
|
|
|
1605
2265
|
parentID: existing?.parentID,
|
|
1606
2266
|
agent: existing?.agent,
|
|
1607
2267
|
status: existing?.status ?? "idle",
|
|
1608
|
-
idleNotificationPending: true
|
|
2268
|
+
idleNotificationPending: true,
|
|
2269
|
+
lastSeenAt: existing?.lastSeenAt ?? Date.now(),
|
|
2270
|
+
serverUrl: existing?.serverUrl
|
|
1609
2271
|
});
|
|
1610
2272
|
}
|
|
1611
2273
|
hasDeferredIdleNotification(sessionId) {
|
|
@@ -1622,7 +2284,7 @@ var SessionTitleService = class {
|
|
|
1622
2284
|
};
|
|
1623
2285
|
|
|
1624
2286
|
// src/telegram-remote.ts
|
|
1625
|
-
var pluginDir =
|
|
2287
|
+
var pluginDir = dirname6(fileURLToPath(import.meta.url));
|
|
1626
2288
|
async function postToServer(serverUrl, path, body) {
|
|
1627
2289
|
const url = new URL(path, serverUrl);
|
|
1628
2290
|
const response = await fetch(url, {
|
|
@@ -1658,8 +2320,10 @@ var TelegramRemote = async (input) => {
|
|
|
1658
2320
|
const stateStore = createStateStore();
|
|
1659
2321
|
const initialState = await stateStore.read();
|
|
1660
2322
|
const tokenHash = createHash5("sha256").update(config.botToken).digest("hex").slice(0, 16);
|
|
1661
|
-
const
|
|
1662
|
-
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}`);
|
|
1663
2327
|
const pendingQuestions = createPendingQuestionStore({ tokenHash });
|
|
1664
2328
|
const pendingPermissions = createPendingPermissionStore({ tokenHash });
|
|
1665
2329
|
const pendingStartWorks = createPendingStartWorkStore({ tokenHash });
|
|
@@ -1782,6 +2446,26 @@ var TelegramRemote = async (input) => {
|
|
|
1782
2446
|
bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
|
|
1783
2447
|
bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
|
|
1784
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
|
+
}
|
|
1785
2469
|
}
|
|
1786
2470
|
return {
|
|
1787
2471
|
event: async ({ event }) => {
|
|
@@ -1793,13 +2477,17 @@ var TelegramRemote = async (input) => {
|
|
|
1793
2477
|
logger.info("session.status received", { statusType: event.properties.status.type });
|
|
1794
2478
|
return handleSessionStatus(event, ctx);
|
|
1795
2479
|
case "session.created":
|
|
2480
|
+
ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
|
|
1796
2481
|
return handleSessionCreated(event, ctx);
|
|
1797
2482
|
case "session.updated":
|
|
2483
|
+
ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
|
|
1798
2484
|
return handleSessionUpdated(event, ctx);
|
|
1799
2485
|
case "message.updated": {
|
|
1800
2486
|
const messageAgent = getSessionAgentFromMessage(event);
|
|
1801
|
-
if (messageAgent)
|
|
2487
|
+
if (messageAgent) {
|
|
1802
2488
|
ctx.sessionTitleService.setSessionAgent(messageAgent.sessionID, messageAgent.agent);
|
|
2489
|
+
ctx.sessionTitleService.setServerUrl(messageAgent.sessionID, input.serverUrl.href);
|
|
2490
|
+
}
|
|
1803
2491
|
return;
|
|
1804
2492
|
}
|
|
1805
2493
|
case "permission.updated":
|
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",
|