@coinseeker/opencode-telegram-plugin 1.0.3 → 1.0.5
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 +336 -49
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,10 +15,12 @@ Configure the npm package in `~/.config/opencode/opencode.json`:
|
|
|
15
15
|
|
|
16
16
|
```json
|
|
17
17
|
{
|
|
18
|
-
"plugin": ["@coinseeker/opencode-telegram-plugin"]
|
|
18
|
+
"plugin": ["@coinseeker/opencode-telegram-plugin@1.0.5"]
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.5`.
|
|
23
|
+
|
|
22
24
|
Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
|
|
23
25
|
|
|
24
26
|
## Configure Telegram
|
|
@@ -54,7 +56,7 @@ Keep this file private. Never commit or share your Telegram bot token.
|
|
|
54
56
|
- OpenCode question prompts via Telegram inline buttons.
|
|
55
57
|
- Multi-select question prompts with toggle buttons and **Done** submission.
|
|
56
58
|
- Custom free-text answers from Telegram.
|
|
57
|
-
- Permission
|
|
59
|
+
- Permission approve/reject buttons from Telegram.
|
|
58
60
|
- Multi-session-safe Telegram polling through a file-lock leader model.
|
|
59
61
|
- Log file output instead of stdout terminal spam.
|
|
60
62
|
|
package/dist/telegram-remote.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
// src/telegram-remote.ts
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
|
-
import { dirname as
|
|
9
|
-
import { tmpdir as
|
|
10
|
-
import { createHash as
|
|
8
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
9
|
+
import { tmpdir as tmpdir4 } from "os";
|
|
10
|
+
import { createHash as createHash4 } from "crypto";
|
|
11
11
|
|
|
12
12
|
// src/lib/logger.ts
|
|
13
13
|
import { appendFile } from "fs/promises";
|
|
@@ -314,17 +314,103 @@ function createQuestionShortHash(requestID) {
|
|
|
314
314
|
return createHash("sha256").update(requestID).digest("base64url").slice(0, 10);
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// src/lib/pending-permissions.ts
|
|
318
|
+
import { createHash as createHash2 } from "crypto";
|
|
319
|
+
import { mkdir as mkdir3, readFile as readFile4, readdir as readdir2, rename as rename3, unlink as unlink3, writeFile as writeFile3 } from "fs/promises";
|
|
320
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
321
|
+
import { dirname as dirname3, join as join3 } from "path";
|
|
322
|
+
function hasCode4(err, code) {
|
|
323
|
+
return "code" in err && err.code === code;
|
|
324
|
+
}
|
|
325
|
+
function pendingFilePath2(dir, shortHash) {
|
|
326
|
+
return join3(dir, `${shortHash}.json`);
|
|
327
|
+
}
|
|
328
|
+
function parsePending2(text) {
|
|
329
|
+
const parsed = JSON.parse(text);
|
|
330
|
+
if (typeof parsed.requestID !== "string") throw new Error("Invalid pending permission: requestID");
|
|
331
|
+
if (typeof parsed.sessionID !== "string") throw new Error("Invalid pending permission: sessionID");
|
|
332
|
+
if (typeof parsed.title !== "string") throw new Error("Invalid pending permission: title");
|
|
333
|
+
if (typeof parsed.permission !== "string") throw new Error("Invalid pending permission: permission");
|
|
334
|
+
if (!Array.isArray(parsed.patterns)) throw new Error("Invalid pending permission: patterns");
|
|
335
|
+
if (!Array.isArray(parsed.always)) throw new Error("Invalid pending permission: always");
|
|
336
|
+
if (parsed.endpoint !== "request" && parsed.endpoint !== "session") throw new Error("Invalid pending permission: endpoint");
|
|
337
|
+
return parsed;
|
|
338
|
+
}
|
|
339
|
+
async function listPendingFiles2(dir) {
|
|
340
|
+
try {
|
|
341
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
342
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (err instanceof Error && hasCode4(err, "ENOENT")) return [];
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function shortHashFromFileName2(fileName) {
|
|
349
|
+
return fileName.slice(0, -".json".length);
|
|
350
|
+
}
|
|
351
|
+
function createPendingPermissionStore(opts) {
|
|
352
|
+
const dir = opts.baseDir ?? join3(tmpdir3(), `opencoder-telegram-pending-permissions-${opts.tokenHash}`);
|
|
353
|
+
return {
|
|
354
|
+
dir,
|
|
355
|
+
async savePending(shortHash, data) {
|
|
356
|
+
const filePath = pendingFilePath2(dir, shortHash);
|
|
357
|
+
await mkdir3(dirname3(filePath), { recursive: true });
|
|
358
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
359
|
+
await writeFile3(tmpPath, JSON.stringify(data, null, 2), "utf8");
|
|
360
|
+
await rename3(tmpPath, filePath);
|
|
361
|
+
},
|
|
362
|
+
async loadPending(shortHash) {
|
|
363
|
+
try {
|
|
364
|
+
return parsePending2(await readFile4(pendingFilePath2(dir, shortHash), "utf8"));
|
|
365
|
+
} catch (err) {
|
|
366
|
+
if (err instanceof Error && hasCode4(err, "ENOENT")) return void 0;
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
async deletePending(shortHash) {
|
|
371
|
+
try {
|
|
372
|
+
await unlink3(pendingFilePath2(dir, shortHash));
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (!(err instanceof Error) || !hasCode4(err, "ENOENT")) throw err;
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
async findByRequestID(requestID) {
|
|
378
|
+
for (const fileName of await listPendingFiles2(dir)) {
|
|
379
|
+
const shortHash = shortHashFromFileName2(fileName);
|
|
380
|
+
const data = await this.loadPending(shortHash);
|
|
381
|
+
if (data?.requestID === requestID) return { shortHash, data };
|
|
382
|
+
}
|
|
383
|
+
return void 0;
|
|
384
|
+
},
|
|
385
|
+
async sweepExpired() {
|
|
386
|
+
const expired = [];
|
|
387
|
+
for (const fileName of await listPendingFiles2(dir)) {
|
|
388
|
+
const shortHash = shortHashFromFileName2(fileName);
|
|
389
|
+
const data = await this.loadPending(shortHash);
|
|
390
|
+
if (data && data.expiresAt < Date.now()) {
|
|
391
|
+
expired.push(data);
|
|
392
|
+
await this.deletePending(shortHash);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return expired;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function createPermissionShortHash(requestID) {
|
|
400
|
+
return createHash2("sha256").update(requestID).digest("base64url").slice(0, 10);
|
|
401
|
+
}
|
|
402
|
+
|
|
317
403
|
// src/lib/env-loader.ts
|
|
318
404
|
import { existsSync } from "fs";
|
|
319
405
|
import { homedir as homedir2 } from "os";
|
|
320
|
-
import { join as
|
|
406
|
+
import { join as join4 } from "path";
|
|
321
407
|
import dotenv from "dotenv";
|
|
322
408
|
function loadPluginEnv(opts) {
|
|
323
409
|
const paths = [
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
410
|
+
join4(opts.pluginDir, "../../.env"),
|
|
411
|
+
join4(opts.pluginDir, "..", ".env"),
|
|
412
|
+
join4(opts.pluginDir, ".env"),
|
|
413
|
+
join4(opts.homeDir ?? homedir2(), ".config/opencode/telegram-remote/.env")
|
|
328
414
|
];
|
|
329
415
|
const loadedFrom = [];
|
|
330
416
|
const values = {};
|
|
@@ -384,6 +470,7 @@ function createTelegramBot(opts) {
|
|
|
384
470
|
const bot = new Bot(config.botToken);
|
|
385
471
|
let activeChatId = opts.initialChatId;
|
|
386
472
|
let questionDispatcher;
|
|
473
|
+
let permissionDispatcher;
|
|
387
474
|
if (polling) {
|
|
388
475
|
bot.use(async (ctx, next) => {
|
|
389
476
|
const userId = ctx.from?.id;
|
|
@@ -424,6 +511,13 @@ This chat is now active for OpenCode notifications.`);
|
|
|
424
511
|
if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0) return;
|
|
425
512
|
await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
|
|
426
513
|
});
|
|
514
|
+
bot.callbackQuery(/^p:([^:]+):(o|a|r)$/, async (ctx) => {
|
|
515
|
+
await ctx.answerCallbackQuery();
|
|
516
|
+
const data = ctx.callbackQuery.data;
|
|
517
|
+
const messageId = ctx.callbackQuery.message?.message_id;
|
|
518
|
+
if (!permissionDispatcher || messageId === void 0) return;
|
|
519
|
+
await permissionDispatcher.handleCallbackQuery(data, messageId);
|
|
520
|
+
});
|
|
427
521
|
bot.on("message:text", async (ctx) => {
|
|
428
522
|
const replyToMessageId = ctx.message.reply_to_message?.message_id;
|
|
429
523
|
const chatId = ctx.chat.id;
|
|
@@ -511,6 +605,9 @@ ${question.question}`, { reply_markup: { inline_keyboard: inlineKeyboard } });
|
|
|
511
605
|
},
|
|
512
606
|
setQuestionDispatcher(dispatcher) {
|
|
513
607
|
questionDispatcher = dispatcher;
|
|
608
|
+
},
|
|
609
|
+
setPermissionDispatcher(dispatcher) {
|
|
610
|
+
permissionDispatcher = dispatcher;
|
|
514
611
|
}
|
|
515
612
|
};
|
|
516
613
|
}
|
|
@@ -585,29 +682,29 @@ var SessionTitleService = class {
|
|
|
585
682
|
};
|
|
586
683
|
|
|
587
684
|
// src/lib/claim.ts
|
|
588
|
-
import { mkdir as
|
|
589
|
-
import { join as
|
|
590
|
-
import { createHash as
|
|
685
|
+
import { mkdir as mkdir4, open as open2, readdir as readdir3, stat as stat2, unlink as unlink4 } from "fs/promises";
|
|
686
|
+
import { join as join5 } from "path";
|
|
687
|
+
import { createHash as createHash3 } from "crypto";
|
|
591
688
|
var DEFAULT_TTL_MS2 = 6e4;
|
|
592
689
|
var sweptDirs = /* @__PURE__ */ new Set();
|
|
593
|
-
function
|
|
690
|
+
function hasCode5(err, code) {
|
|
594
691
|
return "code" in err && err.code === code;
|
|
595
692
|
}
|
|
596
693
|
function claimPath(claimsDir, key) {
|
|
597
|
-
const hash =
|
|
598
|
-
return
|
|
694
|
+
const hash = createHash3("sha256").update(key).digest("hex").slice(0, 16);
|
|
695
|
+
return join5(claimsDir, `${hash}.claim`);
|
|
599
696
|
}
|
|
600
697
|
async function sweep(claimsDir, ttlMs) {
|
|
601
698
|
if (sweptDirs.has(claimsDir)) return;
|
|
602
699
|
sweptDirs.add(claimsDir);
|
|
603
700
|
try {
|
|
604
|
-
const entries = await
|
|
701
|
+
const entries = await readdir3(claimsDir, { withFileTypes: true });
|
|
605
702
|
await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".claim")).map(async (entry) => {
|
|
606
|
-
const filePath =
|
|
703
|
+
const filePath = join5(claimsDir, entry.name);
|
|
607
704
|
try {
|
|
608
705
|
const fileStat = await stat2(filePath);
|
|
609
706
|
if (Date.now() - fileStat.mtimeMs > ttlMs * 2) {
|
|
610
|
-
await
|
|
707
|
+
await unlink4(filePath);
|
|
611
708
|
}
|
|
612
709
|
} catch {
|
|
613
710
|
}
|
|
@@ -626,20 +723,20 @@ async function createClaim(filePath) {
|
|
|
626
723
|
}
|
|
627
724
|
async function claimOnce(opts) {
|
|
628
725
|
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS2;
|
|
629
|
-
await
|
|
726
|
+
await mkdir4(opts.claimsDir, { recursive: true });
|
|
630
727
|
await sweep(opts.claimsDir, ttlMs);
|
|
631
728
|
const filePath = claimPath(opts.claimsDir, opts.key);
|
|
632
729
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
633
730
|
try {
|
|
634
731
|
return await createClaim(filePath);
|
|
635
732
|
} catch (err) {
|
|
636
|
-
if (!(err instanceof Error) || !
|
|
733
|
+
if (!(err instanceof Error) || !hasCode5(err, "EEXIST")) throw err;
|
|
637
734
|
try {
|
|
638
735
|
const fileStat = await stat2(filePath);
|
|
639
736
|
if (Date.now() - fileStat.mtimeMs <= ttlMs || attempt === 1) return false;
|
|
640
|
-
await
|
|
737
|
+
await unlink4(filePath);
|
|
641
738
|
} catch (statErr) {
|
|
642
|
-
if (statErr instanceof Error &&
|
|
739
|
+
if (statErr instanceof Error && hasCode5(statErr, "ENOENT")) continue;
|
|
643
740
|
return false;
|
|
644
741
|
}
|
|
645
742
|
}
|
|
@@ -675,6 +772,10 @@ function shouldSuppressIdle(sessionID) {
|
|
|
675
772
|
}
|
|
676
773
|
|
|
677
774
|
// src/events/session-idle.ts
|
|
775
|
+
var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
|
|
776
|
+
function sleep(ms) {
|
|
777
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
778
|
+
}
|
|
678
779
|
async function resolveParentID(sessionId, ctx) {
|
|
679
780
|
const cachedParentID = ctx.sessionTitleService.getParentID(sessionId);
|
|
680
781
|
if (cachedParentID !== void 0) return cachedParentID;
|
|
@@ -691,6 +792,19 @@ async function resolveParentID(sessionId, ctx) {
|
|
|
691
792
|
return void 0;
|
|
692
793
|
}
|
|
693
794
|
}
|
|
795
|
+
async function hydrateDescendants(sessionId, ctx, seen = /* @__PURE__ */ new Set()) {
|
|
796
|
+
if (seen.has(sessionId)) return;
|
|
797
|
+
seen.add(sessionId);
|
|
798
|
+
try {
|
|
799
|
+
const result = await ctx.client.session.children({ path: { id: sessionId } });
|
|
800
|
+
for (const child of result.data ?? []) {
|
|
801
|
+
ctx.sessionTitleService.setSessionInfo(child);
|
|
802
|
+
await hydrateDescendants(child.id, ctx, seen);
|
|
803
|
+
}
|
|
804
|
+
} catch (err) {
|
|
805
|
+
ctx.logger.warn("session children fetch failed", { sessionId, error: String(err) });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
694
808
|
async function sendIdleNotification(sessionId, ctx) {
|
|
695
809
|
if (shouldSuppressIdle(sessionId)) {
|
|
696
810
|
ctx.logger.info("idle suppressed - session was aborted", { sessionId });
|
|
@@ -711,9 +825,21 @@ async function sendIdleNotification(sessionId, ctx) {
|
|
|
711
825
|
async function flushDeferredParentIfReady(parentID, ctx) {
|
|
712
826
|
if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
|
|
713
827
|
if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
|
|
828
|
+
if (ctx.sessionTitleService.getSessionStatus(parentID) !== "idle") {
|
|
829
|
+
ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
|
|
830
|
+
ctx.logger.info("clearing deferred parent idle notification - parent resumed", { sessionId: parentID });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
714
833
|
ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
|
|
715
834
|
await sendIdleNotification(parentID, ctx);
|
|
716
835
|
}
|
|
836
|
+
async function deferParentIdleIfDescendantsRunning(sessionId, ctx) {
|
|
837
|
+
await hydrateDescendants(sessionId, ctx);
|
|
838
|
+
if (!ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) return false;
|
|
839
|
+
ctx.sessionTitleService.deferIdleNotification(sessionId);
|
|
840
|
+
ctx.logger.info("deferring parent idle notification - child sessions still running", { sessionId });
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
717
843
|
async function handleSessionIdle(event, ctx) {
|
|
718
844
|
const sessionId = event.properties.sessionID;
|
|
719
845
|
ctx.sessionTitleService.setSessionStatus(sessionId, "idle");
|
|
@@ -726,9 +852,15 @@ async function handleSessionIdle(event, ctx) {
|
|
|
726
852
|
if (parentID === void 0) {
|
|
727
853
|
ctx.logger.warn("session parentID unknown; sending idle notification", { sessionId });
|
|
728
854
|
}
|
|
729
|
-
if (
|
|
730
|
-
|
|
731
|
-
|
|
855
|
+
if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
await sleep(ctx.idleRecheckDelayMs ?? ROOT_IDLE_RECHECK_DELAY_MS);
|
|
859
|
+
if (ctx.sessionTitleService.getSessionStatus(sessionId) !== "idle") {
|
|
860
|
+
ctx.logger.info("idle notification skipped - session resumed during recheck delay", { sessionId });
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
|
|
732
864
|
return;
|
|
733
865
|
}
|
|
734
866
|
await sendIdleNotification(sessionId, ctx);
|
|
@@ -764,28 +896,158 @@ async function handleSessionUpdated(event, ctx) {
|
|
|
764
896
|
}
|
|
765
897
|
|
|
766
898
|
// src/events/permission-updated.ts
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
899
|
+
var PERMISSION_EXPIRY_MS = 5 * 6e4;
|
|
900
|
+
var CALLBACK_RE = /^p:([^:]+):(o|a|r)$/;
|
|
901
|
+
function isStringArray(value) {
|
|
902
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
903
|
+
}
|
|
904
|
+
function isEventPermissionAsked(event) {
|
|
905
|
+
if (event.type !== "permission.asked") return false;
|
|
906
|
+
const props = event.properties;
|
|
907
|
+
if (!props) return false;
|
|
908
|
+
if (typeof props.id !== "string") return false;
|
|
909
|
+
if (typeof props.sessionID !== "string") return false;
|
|
910
|
+
if (typeof props.permission !== "string") return false;
|
|
911
|
+
if (!isStringArray(props.patterns)) return false;
|
|
912
|
+
if (!isStringArray(props.always)) return false;
|
|
913
|
+
return true;
|
|
914
|
+
}
|
|
915
|
+
function buildCallbackData(shortHash, reply) {
|
|
916
|
+
const data = `p:${shortHash}:${reply}`;
|
|
917
|
+
if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
|
|
918
|
+
return data;
|
|
919
|
+
}
|
|
920
|
+
function normalizeUpdated(permission) {
|
|
921
|
+
const pattern = permission.pattern === void 0 ? [] : Array.isArray(permission.pattern) ? permission.pattern : [permission.pattern];
|
|
922
|
+
return {
|
|
923
|
+
requestID: permission.id,
|
|
924
|
+
sessionID: permission.sessionID,
|
|
925
|
+
title: permission.title,
|
|
926
|
+
permission: permission.type,
|
|
927
|
+
patterns: pattern,
|
|
928
|
+
always: [],
|
|
929
|
+
endpoint: "session",
|
|
930
|
+
claimKey: `permission.updated:${permission.id}`
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
function normalizeAsked(permission) {
|
|
934
|
+
return {
|
|
935
|
+
requestID: permission.id,
|
|
936
|
+
sessionID: permission.sessionID,
|
|
937
|
+
title: permission.patterns.join(", ") || permission.permission,
|
|
938
|
+
permission: permission.permission,
|
|
939
|
+
patterns: permission.patterns,
|
|
940
|
+
always: permission.always,
|
|
941
|
+
endpoint: "request",
|
|
942
|
+
claimKey: `permission.asked:${permission.id}`
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function permissionMessage(permission, sessionTitle) {
|
|
772
946
|
const titleLine = sessionTitle ? `\u{1F4CB} ${sessionTitle}` : `Session: ${permission.sessionID}`;
|
|
773
|
-
const
|
|
947
|
+
const patterns = permission.patterns.length > 0 ? `
|
|
948
|
+
Patterns: ${permission.patterns.join(", ")}` : "";
|
|
949
|
+
const always = permission.always.length > 0 ? `
|
|
950
|
+
Always options: ${permission.always.join(", ")}` : "";
|
|
951
|
+
return `\u2753 Permission requested
|
|
774
952
|
|
|
775
953
|
${titleLine}
|
|
776
954
|
|
|
777
|
-
|
|
778
|
-
Detail: ${permission.title}`;
|
|
955
|
+
Permission: ${permission.permission}
|
|
956
|
+
Detail: ${permission.title}${patterns}${always}`;
|
|
957
|
+
}
|
|
958
|
+
function permissionKeyboard(shortHash) {
|
|
959
|
+
return [
|
|
960
|
+
[{ text: "\u2705 Allow once", callback_data: buildCallbackData(shortHash, "o") }],
|
|
961
|
+
[{ text: "\u267B\uFE0F Always allow", callback_data: buildCallbackData(shortHash, "a") }],
|
|
962
|
+
[{ text: "\u274C Reject", callback_data: buildCallbackData(shortHash, "r") }]
|
|
963
|
+
];
|
|
964
|
+
}
|
|
965
|
+
function replyFromSelection(selection) {
|
|
966
|
+
if (selection === "o") return "once";
|
|
967
|
+
if (selection === "a") return "always";
|
|
968
|
+
if (selection === "r") return "reject";
|
|
969
|
+
return void 0;
|
|
970
|
+
}
|
|
971
|
+
function replyLabel(reply) {
|
|
972
|
+
if (reply === "once") return "Allowed once";
|
|
973
|
+
if (reply === "always") return "Always allowed";
|
|
974
|
+
return "Rejected";
|
|
975
|
+
}
|
|
976
|
+
async function handleNormalizedPermission(permission, ctx) {
|
|
977
|
+
const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: permission.claimKey });
|
|
978
|
+
if (!claimed) return;
|
|
979
|
+
const shortHash = createPermissionShortHash(permission.requestID);
|
|
980
|
+
const sentAt = Date.now();
|
|
981
|
+
const rawSessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
|
|
982
|
+
const sessionTitle = rawSessionTitle === null ? void 0 : rawSessionTitle;
|
|
779
983
|
try {
|
|
780
|
-
await ctx.bot.sendMessage(
|
|
984
|
+
const message = await ctx.bot.sendMessage(permissionMessage(permission, sessionTitle), {
|
|
985
|
+
reply_markup: { inline_keyboard: permissionKeyboard(shortHash) }
|
|
986
|
+
});
|
|
987
|
+
const pending = {
|
|
988
|
+
requestID: permission.requestID,
|
|
989
|
+
sessionID: permission.sessionID,
|
|
990
|
+
title: permission.title,
|
|
991
|
+
permission: permission.permission,
|
|
992
|
+
patterns: permission.patterns,
|
|
993
|
+
always: permission.always,
|
|
994
|
+
sentAt,
|
|
995
|
+
expiresAt: sentAt + PERMISSION_EXPIRY_MS,
|
|
996
|
+
telegramMessageId: message.message_id,
|
|
997
|
+
endpoint: permission.endpoint
|
|
998
|
+
};
|
|
999
|
+
await ctx.pendingPermissions.savePending(shortHash, pending);
|
|
781
1000
|
} catch (err) {
|
|
782
1001
|
ctx.logger.error("failed to send permission notification", { error: String(err) });
|
|
783
1002
|
}
|
|
784
1003
|
}
|
|
1004
|
+
async function expirePending(ctx, shortHash, pending, messageId) {
|
|
1005
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Permission request expired");
|
|
1006
|
+
await ctx.pendingPermissions.deletePending(shortHash);
|
|
1007
|
+
ctx.logger.info("pending permission expired", { requestID: pending.requestID });
|
|
1008
|
+
}
|
|
1009
|
+
async function handlePermissionUpdated(event, ctx) {
|
|
1010
|
+
await handleNormalizedPermission(normalizeUpdated(event.properties), ctx);
|
|
1011
|
+
}
|
|
1012
|
+
async function handlePermissionAsked(event, ctx) {
|
|
1013
|
+
await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
|
|
1014
|
+
}
|
|
1015
|
+
function createPermissionDispatcher(ctx) {
|
|
1016
|
+
return {
|
|
1017
|
+
async handleCallbackQuery(data, messageId) {
|
|
1018
|
+
const match = CALLBACK_RE.exec(data);
|
|
1019
|
+
if (!match) return;
|
|
1020
|
+
const shortHash = match[1];
|
|
1021
|
+
const reply = replyFromSelection(match[2]);
|
|
1022
|
+
if (!reply) return;
|
|
1023
|
+
const pending = await ctx.pendingPermissions.loadPending(shortHash);
|
|
1024
|
+
if (!pending) {
|
|
1025
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (pending.expiresAt < Date.now()) {
|
|
1029
|
+
await expirePending(ctx, shortHash, pending, messageId);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
try {
|
|
1033
|
+
await ctx.replyToPermission(pending.requestID, pending.sessionID, reply, pending.endpoint);
|
|
1034
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Permission ${replyLabel(reply)}
|
|
1035
|
+
|
|
1036
|
+
${pending.permission}: ${pending.title}`);
|
|
1037
|
+
ctx.logger.info("permission reply sent", { requestID: pending.requestID, sessionID: pending.sessionID, reply });
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send permission reply to opencode");
|
|
1040
|
+
ctx.logger.error("failed to send permission reply", { error: String(err), requestID: pending.requestID });
|
|
1041
|
+
} finally {
|
|
1042
|
+
await ctx.pendingPermissions.deletePending(shortHash);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
785
1047
|
|
|
786
1048
|
// src/events/question-asked.ts
|
|
787
1049
|
var QUESTION_EXPIRY_MS = 5 * 6e4;
|
|
788
|
-
var
|
|
1050
|
+
var CALLBACK_RE2 = /^q:([^:]+):(\d+):(\d+|c|d)$/;
|
|
789
1051
|
function isQuestionOption(value) {
|
|
790
1052
|
return typeof value.label === "string" && typeof value.description === "string";
|
|
791
1053
|
}
|
|
@@ -804,14 +1066,14 @@ function isEventQuestionAsked(event) {
|
|
|
804
1066
|
if (!Array.isArray(props.questions)) return false;
|
|
805
1067
|
return props.questions.every((question) => typeof question === "object" && question !== null && isQuestionInfo(question));
|
|
806
1068
|
}
|
|
807
|
-
function
|
|
1069
|
+
function buildCallbackData2(shortHash, questionIndex, optionIndex) {
|
|
808
1070
|
const data = `q:${shortHash}:${questionIndex}:${optionIndex}`;
|
|
809
1071
|
if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
|
|
810
1072
|
return data;
|
|
811
1073
|
}
|
|
812
1074
|
function callbackDataForQuestion(shortHash, questionIndex, question) {
|
|
813
|
-
const data = question.options.map((_, optionIndex) =>
|
|
814
|
-
if (question.custom !== false) data.push(
|
|
1075
|
+
const data = question.options.map((_, optionIndex) => buildCallbackData2(shortHash, questionIndex, optionIndex));
|
|
1076
|
+
if (question.custom !== false) data.push(buildCallbackData2(shortHash, questionIndex, "c"));
|
|
815
1077
|
return data;
|
|
816
1078
|
}
|
|
817
1079
|
function useSimpleQuestionKeyboard(question) {
|
|
@@ -824,13 +1086,13 @@ function questionInlineKeyboard(shortHash, questionIndex, question, selected) {
|
|
|
824
1086
|
const multiple = question.multiple === true;
|
|
825
1087
|
const inlineKeyboard = question.options.map((option, optionIndex) => [{
|
|
826
1088
|
text: multiple && selected.includes(option.label) ? `\u2705 ${option.label}` : option.label,
|
|
827
|
-
callback_data:
|
|
1089
|
+
callback_data: buildCallbackData2(shortHash, questionIndex, optionIndex)
|
|
828
1090
|
}]);
|
|
829
1091
|
if (question.custom !== false) {
|
|
830
|
-
inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data:
|
|
1092
|
+
inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }]);
|
|
831
1093
|
}
|
|
832
1094
|
if (multiple) {
|
|
833
|
-
inlineKeyboard.push([{ text: "\u2705 Done", callback_data:
|
|
1095
|
+
inlineKeyboard.push([{ text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }]);
|
|
834
1096
|
}
|
|
835
1097
|
return inlineKeyboard;
|
|
836
1098
|
}
|
|
@@ -878,7 +1140,7 @@ ${answerSummary(pending.questions, answers)}`);
|
|
|
878
1140
|
await ctx.pendingQuestions.deletePending(shortHash);
|
|
879
1141
|
}
|
|
880
1142
|
}
|
|
881
|
-
async function
|
|
1143
|
+
async function expirePending2(ctx, shortHash, pending, messageId) {
|
|
882
1144
|
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
|
|
883
1145
|
await ctx.pendingQuestions.deletePending(shortHash);
|
|
884
1146
|
ctx.logger.info("pending question expired", { requestID: pending.requestID });
|
|
@@ -917,7 +1179,7 @@ async function handleQuestionAsked(event, ctx) {
|
|
|
917
1179
|
function createQuestionDispatcher(ctx) {
|
|
918
1180
|
return {
|
|
919
1181
|
async handleCallbackQuery(data, messageId, chatId, userId) {
|
|
920
|
-
const match =
|
|
1182
|
+
const match = CALLBACK_RE2.exec(data);
|
|
921
1183
|
if (!match) return;
|
|
922
1184
|
const shortHash = match[1];
|
|
923
1185
|
const questionIndex = Number(match[2]);
|
|
@@ -928,7 +1190,7 @@ function createQuestionDispatcher(ctx) {
|
|
|
928
1190
|
return;
|
|
929
1191
|
}
|
|
930
1192
|
if (pending.expiresAt < Date.now()) {
|
|
931
|
-
await
|
|
1193
|
+
await expirePending2(ctx, shortHash, pending, messageId);
|
|
932
1194
|
return;
|
|
933
1195
|
}
|
|
934
1196
|
const question = pending.questions[questionIndex];
|
|
@@ -971,7 +1233,7 @@ function createQuestionDispatcher(ctx) {
|
|
|
971
1233
|
const awaiting = match.data.awaitingCustomFor;
|
|
972
1234
|
if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
|
|
973
1235
|
if (match.data.expiresAt < Date.now()) {
|
|
974
|
-
await
|
|
1236
|
+
await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
|
|
975
1237
|
return;
|
|
976
1238
|
}
|
|
977
1239
|
const question = match.data.questions[awaiting.questionIndex];
|
|
@@ -1012,7 +1274,7 @@ async function handleQuestionReplied(event, ctx) {
|
|
|
1012
1274
|
}
|
|
1013
1275
|
|
|
1014
1276
|
// src/telegram-remote.ts
|
|
1015
|
-
var pluginDir =
|
|
1277
|
+
var pluginDir = dirname4(fileURLToPath(import.meta.url));
|
|
1016
1278
|
var TelegramRemote = async (input) => {
|
|
1017
1279
|
const logger = createLogger({ namespace: "telegram" });
|
|
1018
1280
|
try {
|
|
@@ -1021,10 +1283,11 @@ var TelegramRemote = async (input) => {
|
|
|
1021
1283
|
const config = loadConfig({ logger, env: process.env });
|
|
1022
1284
|
const stateStore = createStateStore();
|
|
1023
1285
|
const initialState = await stateStore.read();
|
|
1024
|
-
const tokenHash =
|
|
1025
|
-
const lockPath =
|
|
1026
|
-
const claimsDir =
|
|
1286
|
+
const tokenHash = createHash4("sha256").update(config.botToken).digest("hex").slice(0, 16);
|
|
1287
|
+
const lockPath = join6(tmpdir4(), `opencoder-telegram-${tokenHash}.lock`);
|
|
1288
|
+
const claimsDir = join6(tmpdir4(), `opencoder-telegram-claims-${tokenHash}`);
|
|
1027
1289
|
const pendingQuestions = createPendingQuestionStore({ tokenHash });
|
|
1290
|
+
const pendingPermissions = createPendingPermissionStore({ tokenHash });
|
|
1028
1291
|
const lockResult = await acquireLock({ lockPath });
|
|
1029
1292
|
const isLeader = lockResult.acquired;
|
|
1030
1293
|
logger.info(
|
|
@@ -1042,6 +1305,23 @@ var TelegramRemote = async (input) => {
|
|
|
1042
1305
|
throwOnError: true
|
|
1043
1306
|
});
|
|
1044
1307
|
};
|
|
1308
|
+
const replyToPermission = async (requestID, sessionID, reply, endpoint) => {
|
|
1309
|
+
if (endpoint === "request") {
|
|
1310
|
+
await client._client.post({
|
|
1311
|
+
url: `/permission/${encodeURIComponent(requestID)}/reply`,
|
|
1312
|
+
headers: { "Content-Type": "application/json" },
|
|
1313
|
+
body: { reply },
|
|
1314
|
+
throwOnError: true
|
|
1315
|
+
});
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
await client._client.post({
|
|
1319
|
+
url: `/session/${encodeURIComponent(sessionID)}/permissions/${encodeURIComponent(requestID)}`,
|
|
1320
|
+
headers: { "Content-Type": "application/json" },
|
|
1321
|
+
body: { response: reply },
|
|
1322
|
+
throwOnError: true
|
|
1323
|
+
});
|
|
1324
|
+
};
|
|
1045
1325
|
const bot = createTelegramBot({
|
|
1046
1326
|
config,
|
|
1047
1327
|
stateStore,
|
|
@@ -1085,10 +1365,13 @@ var TelegramRemote = async (input) => {
|
|
|
1085
1365
|
serverUrl: input.serverUrl,
|
|
1086
1366
|
tokenHash,
|
|
1087
1367
|
pendingQuestions,
|
|
1088
|
-
|
|
1368
|
+
pendingPermissions,
|
|
1369
|
+
replyToQuestion,
|
|
1370
|
+
replyToPermission
|
|
1089
1371
|
};
|
|
1090
1372
|
if (isLeader) {
|
|
1091
1373
|
bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
|
|
1374
|
+
bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
|
|
1092
1375
|
}
|
|
1093
1376
|
return {
|
|
1094
1377
|
event: async ({ event }) => {
|
|
@@ -1106,6 +1389,10 @@ var TelegramRemote = async (input) => {
|
|
|
1106
1389
|
case "permission.updated":
|
|
1107
1390
|
return handlePermissionUpdated(event, ctx);
|
|
1108
1391
|
default: {
|
|
1392
|
+
if (isEventPermissionAsked(extEvent)) {
|
|
1393
|
+
if (!isLeader) return;
|
|
1394
|
+
return handlePermissionAsked(extEvent, ctx);
|
|
1395
|
+
}
|
|
1109
1396
|
if (isEventSessionError(extEvent)) {
|
|
1110
1397
|
return handleSessionError(extEvent, ctx);
|
|
1111
1398
|
}
|
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.5",
|
|
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",
|