@coinseeker/opencode-telegram-plugin 1.0.2 → 1.0.4
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 -1
- package/dist/telegram-remote.js +347 -58
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,8 +52,9 @@ Keep this file private. Never commit or share your Telegram bot token.
|
|
|
52
52
|
- Root session completion notifications.
|
|
53
53
|
- Background subagent-aware completion: child session messages are suppressed and parent completion waits until children finish.
|
|
54
54
|
- OpenCode question prompts via Telegram inline buttons.
|
|
55
|
+
- Multi-select question prompts with toggle buttons and **Done** submission.
|
|
55
56
|
- Custom free-text answers from Telegram.
|
|
56
|
-
- Permission
|
|
57
|
+
- Permission approve/reject buttons from Telegram.
|
|
57
58
|
- Multi-session-safe Telegram polling through a file-lock leader model.
|
|
58
59
|
- Log file output instead of stdout terminal spam.
|
|
59
60
|
|
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;
|
|
@@ -415,7 +502,7 @@ This chat is now active for OpenCode notifications.`);
|
|
|
415
502
|
logger.error("bot error", { error: String(e) });
|
|
416
503
|
}
|
|
417
504
|
});
|
|
418
|
-
bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c)$/, async (ctx) => {
|
|
505
|
+
bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c|d)$/, async (ctx) => {
|
|
419
506
|
await ctx.answerCallbackQuery();
|
|
420
507
|
const data = ctx.callbackQuery.data;
|
|
421
508
|
const messageId = ctx.callbackQuery.message?.message_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
|
}
|
|
@@ -764,28 +861,158 @@ async function handleSessionUpdated(event, ctx) {
|
|
|
764
861
|
}
|
|
765
862
|
|
|
766
863
|
// src/events/permission-updated.ts
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
864
|
+
var PERMISSION_EXPIRY_MS = 5 * 6e4;
|
|
865
|
+
var CALLBACK_RE = /^p:([^:]+):(o|a|r)$/;
|
|
866
|
+
function isStringArray(value) {
|
|
867
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
868
|
+
}
|
|
869
|
+
function isEventPermissionAsked(event) {
|
|
870
|
+
if (event.type !== "permission.asked") return false;
|
|
871
|
+
const props = event.properties;
|
|
872
|
+
if (!props) return false;
|
|
873
|
+
if (typeof props.id !== "string") return false;
|
|
874
|
+
if (typeof props.sessionID !== "string") return false;
|
|
875
|
+
if (typeof props.permission !== "string") return false;
|
|
876
|
+
if (!isStringArray(props.patterns)) return false;
|
|
877
|
+
if (!isStringArray(props.always)) return false;
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
function buildCallbackData(shortHash, reply) {
|
|
881
|
+
const data = `p:${shortHash}:${reply}`;
|
|
882
|
+
if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
|
|
883
|
+
return data;
|
|
884
|
+
}
|
|
885
|
+
function normalizeUpdated(permission) {
|
|
886
|
+
const pattern = permission.pattern === void 0 ? [] : Array.isArray(permission.pattern) ? permission.pattern : [permission.pattern];
|
|
887
|
+
return {
|
|
888
|
+
requestID: permission.id,
|
|
889
|
+
sessionID: permission.sessionID,
|
|
890
|
+
title: permission.title,
|
|
891
|
+
permission: permission.type,
|
|
892
|
+
patterns: pattern,
|
|
893
|
+
always: [],
|
|
894
|
+
endpoint: "session",
|
|
895
|
+
claimKey: `permission.updated:${permission.id}`
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
function normalizeAsked(permission) {
|
|
899
|
+
return {
|
|
900
|
+
requestID: permission.id,
|
|
901
|
+
sessionID: permission.sessionID,
|
|
902
|
+
title: permission.patterns.join(", ") || permission.permission,
|
|
903
|
+
permission: permission.permission,
|
|
904
|
+
patterns: permission.patterns,
|
|
905
|
+
always: permission.always,
|
|
906
|
+
endpoint: "request",
|
|
907
|
+
claimKey: `permission.asked:${permission.id}`
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
function permissionMessage(permission, sessionTitle) {
|
|
772
911
|
const titleLine = sessionTitle ? `\u{1F4CB} ${sessionTitle}` : `Session: ${permission.sessionID}`;
|
|
773
|
-
const
|
|
912
|
+
const patterns = permission.patterns.length > 0 ? `
|
|
913
|
+
Patterns: ${permission.patterns.join(", ")}` : "";
|
|
914
|
+
const always = permission.always.length > 0 ? `
|
|
915
|
+
Always options: ${permission.always.join(", ")}` : "";
|
|
916
|
+
return `\u2753 Permission requested
|
|
774
917
|
|
|
775
918
|
${titleLine}
|
|
776
919
|
|
|
777
|
-
|
|
778
|
-
Detail: ${permission.title}`;
|
|
920
|
+
Permission: ${permission.permission}
|
|
921
|
+
Detail: ${permission.title}${patterns}${always}`;
|
|
922
|
+
}
|
|
923
|
+
function permissionKeyboard(shortHash) {
|
|
924
|
+
return [
|
|
925
|
+
[{ text: "\u2705 Allow once", callback_data: buildCallbackData(shortHash, "o") }],
|
|
926
|
+
[{ text: "\u267B\uFE0F Always allow", callback_data: buildCallbackData(shortHash, "a") }],
|
|
927
|
+
[{ text: "\u274C Reject", callback_data: buildCallbackData(shortHash, "r") }]
|
|
928
|
+
];
|
|
929
|
+
}
|
|
930
|
+
function replyFromSelection(selection) {
|
|
931
|
+
if (selection === "o") return "once";
|
|
932
|
+
if (selection === "a") return "always";
|
|
933
|
+
if (selection === "r") return "reject";
|
|
934
|
+
return void 0;
|
|
935
|
+
}
|
|
936
|
+
function replyLabel(reply) {
|
|
937
|
+
if (reply === "once") return "Allowed once";
|
|
938
|
+
if (reply === "always") return "Always allowed";
|
|
939
|
+
return "Rejected";
|
|
940
|
+
}
|
|
941
|
+
async function handleNormalizedPermission(permission, ctx) {
|
|
942
|
+
const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: permission.claimKey });
|
|
943
|
+
if (!claimed) return;
|
|
944
|
+
const shortHash = createPermissionShortHash(permission.requestID);
|
|
945
|
+
const sentAt = Date.now();
|
|
946
|
+
const rawSessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
|
|
947
|
+
const sessionTitle = rawSessionTitle === null ? void 0 : rawSessionTitle;
|
|
779
948
|
try {
|
|
780
|
-
await ctx.bot.sendMessage(
|
|
949
|
+
const message = await ctx.bot.sendMessage(permissionMessage(permission, sessionTitle), {
|
|
950
|
+
reply_markup: { inline_keyboard: permissionKeyboard(shortHash) }
|
|
951
|
+
});
|
|
952
|
+
const pending = {
|
|
953
|
+
requestID: permission.requestID,
|
|
954
|
+
sessionID: permission.sessionID,
|
|
955
|
+
title: permission.title,
|
|
956
|
+
permission: permission.permission,
|
|
957
|
+
patterns: permission.patterns,
|
|
958
|
+
always: permission.always,
|
|
959
|
+
sentAt,
|
|
960
|
+
expiresAt: sentAt + PERMISSION_EXPIRY_MS,
|
|
961
|
+
telegramMessageId: message.message_id,
|
|
962
|
+
endpoint: permission.endpoint
|
|
963
|
+
};
|
|
964
|
+
await ctx.pendingPermissions.savePending(shortHash, pending);
|
|
781
965
|
} catch (err) {
|
|
782
966
|
ctx.logger.error("failed to send permission notification", { error: String(err) });
|
|
783
967
|
}
|
|
784
968
|
}
|
|
969
|
+
async function expirePending(ctx, shortHash, pending, messageId) {
|
|
970
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Permission request expired");
|
|
971
|
+
await ctx.pendingPermissions.deletePending(shortHash);
|
|
972
|
+
ctx.logger.info("pending permission expired", { requestID: pending.requestID });
|
|
973
|
+
}
|
|
974
|
+
async function handlePermissionUpdated(event, ctx) {
|
|
975
|
+
await handleNormalizedPermission(normalizeUpdated(event.properties), ctx);
|
|
976
|
+
}
|
|
977
|
+
async function handlePermissionAsked(event, ctx) {
|
|
978
|
+
await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
|
|
979
|
+
}
|
|
980
|
+
function createPermissionDispatcher(ctx) {
|
|
981
|
+
return {
|
|
982
|
+
async handleCallbackQuery(data, messageId) {
|
|
983
|
+
const match = CALLBACK_RE.exec(data);
|
|
984
|
+
if (!match) return;
|
|
985
|
+
const shortHash = match[1];
|
|
986
|
+
const reply = replyFromSelection(match[2]);
|
|
987
|
+
if (!reply) return;
|
|
988
|
+
const pending = await ctx.pendingPermissions.loadPending(shortHash);
|
|
989
|
+
if (!pending) {
|
|
990
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (pending.expiresAt < Date.now()) {
|
|
994
|
+
await expirePending(ctx, shortHash, pending, messageId);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
try {
|
|
998
|
+
await ctx.replyToPermission(pending.requestID, pending.sessionID, reply, pending.endpoint);
|
|
999
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Permission ${replyLabel(reply)}
|
|
1000
|
+
|
|
1001
|
+
${pending.permission}: ${pending.title}`);
|
|
1002
|
+
ctx.logger.info("permission reply sent", { requestID: pending.requestID, sessionID: pending.sessionID, reply });
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send permission reply to opencode");
|
|
1005
|
+
ctx.logger.error("failed to send permission reply", { error: String(err), requestID: pending.requestID });
|
|
1006
|
+
} finally {
|
|
1007
|
+
await ctx.pendingPermissions.deletePending(shortHash);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
785
1012
|
|
|
786
1013
|
// src/events/question-asked.ts
|
|
787
1014
|
var QUESTION_EXPIRY_MS = 5 * 6e4;
|
|
788
|
-
var
|
|
1015
|
+
var CALLBACK_RE2 = /^q:([^:]+):(\d+):(\d+|c|d)$/;
|
|
789
1016
|
function isQuestionOption(value) {
|
|
790
1017
|
return typeof value.label === "string" && typeof value.description === "string";
|
|
791
1018
|
}
|
|
@@ -804,16 +1031,36 @@ function isEventQuestionAsked(event) {
|
|
|
804
1031
|
if (!Array.isArray(props.questions)) return false;
|
|
805
1032
|
return props.questions.every((question) => typeof question === "object" && question !== null && isQuestionInfo(question));
|
|
806
1033
|
}
|
|
807
|
-
function
|
|
1034
|
+
function buildCallbackData2(shortHash, questionIndex, optionIndex) {
|
|
808
1035
|
const data = `q:${shortHash}:${questionIndex}:${optionIndex}`;
|
|
809
1036
|
if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
|
|
810
1037
|
return data;
|
|
811
1038
|
}
|
|
812
1039
|
function callbackDataForQuestion(shortHash, questionIndex, question) {
|
|
813
|
-
const data = question.options.map((_, optionIndex) =>
|
|
814
|
-
if (question.custom !== false) data.push(
|
|
1040
|
+
const data = question.options.map((_, optionIndex) => buildCallbackData2(shortHash, questionIndex, optionIndex));
|
|
1041
|
+
if (question.custom !== false) data.push(buildCallbackData2(shortHash, questionIndex, "c"));
|
|
815
1042
|
return data;
|
|
816
1043
|
}
|
|
1044
|
+
function useSimpleQuestionKeyboard(question) {
|
|
1045
|
+
return question.multiple !== true;
|
|
1046
|
+
}
|
|
1047
|
+
function selectedAnswers(pending, questionIndex) {
|
|
1048
|
+
return pending.answersInProgress[questionIndex] ?? [];
|
|
1049
|
+
}
|
|
1050
|
+
function questionInlineKeyboard(shortHash, questionIndex, question, selected) {
|
|
1051
|
+
const multiple = question.multiple === true;
|
|
1052
|
+
const inlineKeyboard = question.options.map((option, optionIndex) => [{
|
|
1053
|
+
text: multiple && selected.includes(option.label) ? `\u2705 ${option.label}` : option.label,
|
|
1054
|
+
callback_data: buildCallbackData2(shortHash, questionIndex, optionIndex)
|
|
1055
|
+
}]);
|
|
1056
|
+
if (question.custom !== false) {
|
|
1057
|
+
inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }]);
|
|
1058
|
+
}
|
|
1059
|
+
if (multiple) {
|
|
1060
|
+
inlineKeyboard.push([{ text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }]);
|
|
1061
|
+
}
|
|
1062
|
+
return inlineKeyboard;
|
|
1063
|
+
}
|
|
817
1064
|
function questionPromptText(pending, questionIndex) {
|
|
818
1065
|
const question = pending.questions[questionIndex];
|
|
819
1066
|
const prefix = pending.questions.length > 1 ? `Question ${questionIndex + 1}/${pending.questions.length}
|
|
@@ -833,13 +1080,7 @@ function answerSummary(questions, answers) {
|
|
|
833
1080
|
async function editPromptForQuestion(ctx, pending, shortHash, questionIndex) {
|
|
834
1081
|
const messageId = pending.telegramMessageIds[0];
|
|
835
1082
|
const question = pending.questions[questionIndex];
|
|
836
|
-
const inlineKeyboard = question
|
|
837
|
-
text: option.label,
|
|
838
|
-
callback_data: buildCallbackData(shortHash, questionIndex, optionIndex)
|
|
839
|
-
}]);
|
|
840
|
-
if (question.custom !== false) {
|
|
841
|
-
inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData(shortHash, questionIndex, "c") }]);
|
|
842
|
-
}
|
|
1083
|
+
const inlineKeyboard = questionInlineKeyboard(shortHash, questionIndex, question, selectedAnswers(pending, questionIndex));
|
|
843
1084
|
await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), { reply_markup: { inline_keyboard: inlineKeyboard } });
|
|
844
1085
|
}
|
|
845
1086
|
async function completeIfReady(ctx, pending, shortHash) {
|
|
@@ -864,7 +1105,7 @@ ${answerSummary(pending.questions, answers)}`);
|
|
|
864
1105
|
await ctx.pendingQuestions.deletePending(shortHash);
|
|
865
1106
|
}
|
|
866
1107
|
}
|
|
867
|
-
async function
|
|
1108
|
+
async function expirePending2(ctx, shortHash, pending, messageId) {
|
|
868
1109
|
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
|
|
869
1110
|
await ctx.pendingQuestions.deletePending(shortHash);
|
|
870
1111
|
ctx.logger.info("pending question expired", { requestID: pending.requestID });
|
|
@@ -888,12 +1129,9 @@ async function handleQuestionAsked(event, ctx) {
|
|
|
888
1129
|
answersInProgress: request.questions.map(() => null)
|
|
889
1130
|
};
|
|
890
1131
|
try {
|
|
891
|
-
const message = request.questions.length === 1 ? await ctx.bot.sendQuestionWithKeyboard(firstQuestion, callbackDataForQuestion(shortHash, 0, firstQuestion)) : await ctx.bot.sendMessage(questionPromptText(pending, 0), {
|
|
1132
|
+
const message = request.questions.length === 1 && useSimpleQuestionKeyboard(firstQuestion) ? await ctx.bot.sendQuestionWithKeyboard(firstQuestion, callbackDataForQuestion(shortHash, 0, firstQuestion)) : await ctx.bot.sendMessage(questionPromptText(pending, 0), {
|
|
892
1133
|
reply_markup: {
|
|
893
|
-
inline_keyboard:
|
|
894
|
-
text: option.label,
|
|
895
|
-
callback_data: buildCallbackData(shortHash, 0, optionIndex)
|
|
896
|
-
}]).concat(firstQuestion.custom !== false ? [[{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData(shortHash, 0, "c") }]] : [])
|
|
1134
|
+
inline_keyboard: questionInlineKeyboard(shortHash, 0, firstQuestion, [])
|
|
897
1135
|
}
|
|
898
1136
|
});
|
|
899
1137
|
pending.telegramMessageIds = [message.message_id];
|
|
@@ -906,7 +1144,7 @@ async function handleQuestionAsked(event, ctx) {
|
|
|
906
1144
|
function createQuestionDispatcher(ctx) {
|
|
907
1145
|
return {
|
|
908
1146
|
async handleCallbackQuery(data, messageId, chatId, userId) {
|
|
909
|
-
const match =
|
|
1147
|
+
const match = CALLBACK_RE2.exec(data);
|
|
910
1148
|
if (!match) return;
|
|
911
1149
|
const shortHash = match[1];
|
|
912
1150
|
const questionIndex = Number(match[2]);
|
|
@@ -917,22 +1155,38 @@ function createQuestionDispatcher(ctx) {
|
|
|
917
1155
|
return;
|
|
918
1156
|
}
|
|
919
1157
|
if (pending.expiresAt < Date.now()) {
|
|
920
|
-
await
|
|
1158
|
+
await expirePending2(ctx, shortHash, pending, messageId);
|
|
921
1159
|
return;
|
|
922
1160
|
}
|
|
923
1161
|
const question = pending.questions[questionIndex];
|
|
924
1162
|
if (!question) return;
|
|
925
1163
|
if (selection === "c") {
|
|
926
|
-
|
|
1164
|
+
if (question.multiple === true) {
|
|
1165
|
+
await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), { reply_markup: { inline_keyboard: [] } });
|
|
1166
|
+
} else {
|
|
1167
|
+
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u270F\uFE0F Reply to the next message with your custom answer.");
|
|
1168
|
+
}
|
|
927
1169
|
const prompt = await ctx.bot.replyWithForceReply("Type your custom answer", "Type your answer");
|
|
928
1170
|
pending.awaitingCustomFor = { shortHash, questionIndex, chatId, userId, promptMessageId: prompt.message_id };
|
|
929
1171
|
await ctx.pendingQuestions.savePending(shortHash, pending);
|
|
930
1172
|
return;
|
|
931
1173
|
}
|
|
1174
|
+
if (selection === "d") {
|
|
1175
|
+
if (question.multiple !== true) return;
|
|
1176
|
+
pending.answersInProgress[questionIndex] = selectedAnswers(pending, questionIndex);
|
|
1177
|
+
pending.awaitingCustomFor = void 0;
|
|
1178
|
+
await completeIfReady(ctx, pending, shortHash);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
932
1181
|
const option = question.options[Number(selection)];
|
|
933
1182
|
if (!option) return;
|
|
934
1183
|
if (question.multiple === true) {
|
|
935
|
-
|
|
1184
|
+
const current = selectedAnswers(pending, questionIndex);
|
|
1185
|
+
pending.answersInProgress[questionIndex] = current.includes(option.label) ? current.filter((answer) => answer !== option.label) : [...current, option.label];
|
|
1186
|
+
pending.awaitingCustomFor = void 0;
|
|
1187
|
+
await ctx.pendingQuestions.savePending(shortHash, pending);
|
|
1188
|
+
await editPromptForQuestion(ctx, pending, shortHash, questionIndex);
|
|
1189
|
+
return;
|
|
936
1190
|
}
|
|
937
1191
|
pending.answersInProgress[questionIndex] = [option.label];
|
|
938
1192
|
pending.awaitingCustomFor = void 0;
|
|
@@ -944,7 +1198,17 @@ function createQuestionDispatcher(ctx) {
|
|
|
944
1198
|
const awaiting = match.data.awaitingCustomFor;
|
|
945
1199
|
if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
|
|
946
1200
|
if (match.data.expiresAt < Date.now()) {
|
|
947
|
-
await
|
|
1201
|
+
await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const question = match.data.questions[awaiting.questionIndex];
|
|
1205
|
+
if (question?.multiple === true) {
|
|
1206
|
+
const current = selectedAnswers(match.data, awaiting.questionIndex);
|
|
1207
|
+
match.data.answersInProgress[awaiting.questionIndex] = current.includes(text) ? current : [...current, text];
|
|
1208
|
+
match.data.awaitingCustomFor = void 0;
|
|
1209
|
+
await ctx.bot.sendMessage("\u2705 Custom answer added. Tap Done when finished.");
|
|
1210
|
+
await ctx.pendingQuestions.savePending(match.shortHash, match.data);
|
|
1211
|
+
await editPromptForQuestion(ctx, match.data, match.shortHash, awaiting.questionIndex);
|
|
948
1212
|
return;
|
|
949
1213
|
}
|
|
950
1214
|
match.data.answersInProgress[awaiting.questionIndex] = [text];
|
|
@@ -975,7 +1239,7 @@ async function handleQuestionReplied(event, ctx) {
|
|
|
975
1239
|
}
|
|
976
1240
|
|
|
977
1241
|
// src/telegram-remote.ts
|
|
978
|
-
var pluginDir =
|
|
1242
|
+
var pluginDir = dirname4(fileURLToPath(import.meta.url));
|
|
979
1243
|
var TelegramRemote = async (input) => {
|
|
980
1244
|
const logger = createLogger({ namespace: "telegram" });
|
|
981
1245
|
try {
|
|
@@ -984,10 +1248,11 @@ var TelegramRemote = async (input) => {
|
|
|
984
1248
|
const config = loadConfig({ logger, env: process.env });
|
|
985
1249
|
const stateStore = createStateStore();
|
|
986
1250
|
const initialState = await stateStore.read();
|
|
987
|
-
const tokenHash =
|
|
988
|
-
const lockPath =
|
|
989
|
-
const claimsDir =
|
|
1251
|
+
const tokenHash = createHash4("sha256").update(config.botToken).digest("hex").slice(0, 16);
|
|
1252
|
+
const lockPath = join6(tmpdir4(), `opencoder-telegram-${tokenHash}.lock`);
|
|
1253
|
+
const claimsDir = join6(tmpdir4(), `opencoder-telegram-claims-${tokenHash}`);
|
|
990
1254
|
const pendingQuestions = createPendingQuestionStore({ tokenHash });
|
|
1255
|
+
const pendingPermissions = createPendingPermissionStore({ tokenHash });
|
|
991
1256
|
const lockResult = await acquireLock({ lockPath });
|
|
992
1257
|
const isLeader = lockResult.acquired;
|
|
993
1258
|
logger.info(
|
|
@@ -1005,6 +1270,23 @@ var TelegramRemote = async (input) => {
|
|
|
1005
1270
|
throwOnError: true
|
|
1006
1271
|
});
|
|
1007
1272
|
};
|
|
1273
|
+
const replyToPermission = async (requestID, sessionID, reply, endpoint) => {
|
|
1274
|
+
if (endpoint === "request") {
|
|
1275
|
+
await client._client.post({
|
|
1276
|
+
url: `/permission/${encodeURIComponent(requestID)}/reply`,
|
|
1277
|
+
headers: { "Content-Type": "application/json" },
|
|
1278
|
+
body: { reply },
|
|
1279
|
+
throwOnError: true
|
|
1280
|
+
});
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
await client._client.post({
|
|
1284
|
+
url: `/session/${encodeURIComponent(sessionID)}/permissions/${encodeURIComponent(requestID)}`,
|
|
1285
|
+
headers: { "Content-Type": "application/json" },
|
|
1286
|
+
body: { response: reply },
|
|
1287
|
+
throwOnError: true
|
|
1288
|
+
});
|
|
1289
|
+
};
|
|
1008
1290
|
const bot = createTelegramBot({
|
|
1009
1291
|
config,
|
|
1010
1292
|
stateStore,
|
|
@@ -1048,10 +1330,13 @@ var TelegramRemote = async (input) => {
|
|
|
1048
1330
|
serverUrl: input.serverUrl,
|
|
1049
1331
|
tokenHash,
|
|
1050
1332
|
pendingQuestions,
|
|
1051
|
-
|
|
1333
|
+
pendingPermissions,
|
|
1334
|
+
replyToQuestion,
|
|
1335
|
+
replyToPermission
|
|
1052
1336
|
};
|
|
1053
1337
|
if (isLeader) {
|
|
1054
1338
|
bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
|
|
1339
|
+
bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
|
|
1055
1340
|
}
|
|
1056
1341
|
return {
|
|
1057
1342
|
event: async ({ event }) => {
|
|
@@ -1069,6 +1354,10 @@ var TelegramRemote = async (input) => {
|
|
|
1069
1354
|
case "permission.updated":
|
|
1070
1355
|
return handlePermissionUpdated(event, ctx);
|
|
1071
1356
|
default: {
|
|
1357
|
+
if (isEventPermissionAsked(extEvent)) {
|
|
1358
|
+
if (!isLeader) return;
|
|
1359
|
+
return handlePermissionAsked(extEvent, ctx);
|
|
1360
|
+
}
|
|
1072
1361
|
if (isEventSessionError(extEvent)) {
|
|
1073
1362
|
return handleSessionError(extEvent, ctx);
|
|
1074
1363
|
}
|
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.4",
|
|
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",
|