opencode-plugin-teleprompt 0.2.1 → 0.2.2
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 +28 -8
- package/dist/opencode/events.d.ts +43 -1
- package/dist/opencode/events.js +374 -7
- package/dist/opencode/events.js.map +1 -1
- package/dist/opencode/permissions.d.ts +8 -2
- package/dist/opencode/permissions.js +95 -8
- package/dist/opencode/permissions.js.map +1 -1
- package/dist/runtime/controller.d.ts +38 -3
- package/dist/runtime/controller.js +517 -24
- package/dist/runtime/controller.js.map +1 -1
- package/dist/state/store.js +52 -0
- package/dist/state/store.js.map +1 -1
- package/dist/summary/format.js +1 -1
- package/dist/summary/format.js.map +1 -1
- package/dist/telegram/api.d.ts +32 -3
- package/dist/telegram/api.js +49 -6
- package/dist/telegram/api.js.map +1 -1
- package/dist/telegram/callback.d.ts +47 -0
- package/dist/telegram/callback.js +195 -0
- package/dist/telegram/callback.js.map +1 -0
- package/dist/telegram/parser.d.ts +3 -2
- package/dist/telegram/parser.js +129 -0
- package/dist/telegram/parser.js.map +1 -1
- package/dist/telegram/poller.d.ts +4 -2
- package/dist/telegram/poller.js +23 -6
- package/dist/telegram/poller.js.map +1 -1
- package/dist/types.d.ts +84 -1
- package/package.json +3 -1
|
@@ -2,13 +2,14 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { appendFileSync } from "node:fs";
|
|
4
4
|
import { getCurrentOrCreateSessionID } from "../opencode/binding.js";
|
|
5
|
-
import { replyPermission, toPendingPermission,
|
|
5
|
+
import { formatPermissionRequestMessage, formatQuestionRequestMessage, normalizeAnswers, rejectQuestion, replyPermission, replyQuestion, toPendingPermission, toPendingQuestion, } from "../opencode/permissions.js";
|
|
6
6
|
import { createTelegramUserMessageID, submitPrompt } from "../opencode/submit.js";
|
|
7
7
|
import { formatSummaryForTelegram } from "../summary/format.js";
|
|
8
8
|
import { LeaseManager } from "../state/lease.js";
|
|
9
9
|
import { BridgeStore } from "../state/store.js";
|
|
10
10
|
import { TelegramApi } from "../telegram/api.js";
|
|
11
11
|
import { TelegramPoller } from "../telegram/poller.js";
|
|
12
|
+
import { buildPermissionKeyboard, buildQuestionKeyboard, } from "../telegram/callback.js";
|
|
12
13
|
import { SessionEventStream } from "../opencode/events.js";
|
|
13
14
|
import { createShutdownGuard } from "./shutdown.js";
|
|
14
15
|
import { PLUGIN_VERSION } from "../version.js";
|
|
@@ -133,20 +134,25 @@ export class BridgeController {
|
|
|
133
134
|
pollTask;
|
|
134
135
|
eventStream;
|
|
135
136
|
eventRestartTimer;
|
|
137
|
+
// Real-time text accumulator: messageID -> accumulated text from
|
|
138
|
+
// message.part.updated events. More reliable than the SDK's session.messages()
|
|
139
|
+
// or session.message() fetch (which can return 500s) or the TUI's local
|
|
140
|
+
// state.part() (which only has parts the TUI itself rendered).
|
|
141
|
+
streamedText = new Map();
|
|
136
142
|
shutdownOnce = createShutdownGuard();
|
|
137
143
|
processingQueue = false;
|
|
138
144
|
lastEscAt = 0;
|
|
139
145
|
sessionCredentials;
|
|
140
146
|
activePromptCheckInFlight = false;
|
|
141
147
|
completionInFlight = false;
|
|
142
|
-
constructor(api, config, storePath, deps) {
|
|
148
|
+
constructor(api, config, storePath, deps, telegram) {
|
|
143
149
|
this.api = api;
|
|
144
150
|
this.config = config;
|
|
145
151
|
this.storePath = storePath;
|
|
146
152
|
this.deps = { ...DEFAULT_DEPS, ...deps };
|
|
147
153
|
this.instanceID = this.deps.randomID();
|
|
148
154
|
this.client = api.client;
|
|
149
|
-
this.telegram = new TelegramApi(config.botToken || "__missing_token__");
|
|
155
|
+
this.telegram = telegram ?? new TelegramApi(config.botToken || "__missing_token__");
|
|
150
156
|
this.store = new BridgeStore(storePath, config.channelID ?? "");
|
|
151
157
|
this.lease = new LeaseManager(this.instanceID, this.deps.now, config.leaseTtlMs);
|
|
152
158
|
}
|
|
@@ -159,6 +165,20 @@ export class BridgeController {
|
|
|
159
165
|
catch {
|
|
160
166
|
// ignore
|
|
161
167
|
}
|
|
168
|
+
try {
|
|
169
|
+
appendFileSync("/tmp/teleprompt-bridge.log", `[${new Date().toISOString()}] ${message}\n`, "utf8");
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// ignore
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async safeSendTelegram(label, text, options) {
|
|
176
|
+
try {
|
|
177
|
+
await this.telegram.sendMessage(this.config.channelID, text, options);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
this.log(`${label} failed: ${String(error)} (textLength=${text.length})`);
|
|
181
|
+
}
|
|
162
182
|
}
|
|
163
183
|
async init() {
|
|
164
184
|
this.data = await this.store.load();
|
|
@@ -212,9 +232,61 @@ export class BridgeController {
|
|
|
212
232
|
activePrompt: undefined,
|
|
213
233
|
promptQueue: [],
|
|
214
234
|
pendingPermissions: {},
|
|
235
|
+
pendingQuestions: {},
|
|
236
|
+
questionAnswers: {},
|
|
215
237
|
});
|
|
216
238
|
await this.persist(released);
|
|
217
239
|
}
|
|
240
|
+
async handleDiag(command) {
|
|
241
|
+
const lines = [];
|
|
242
|
+
try {
|
|
243
|
+
const me = await this.getTelegramApi().getMe();
|
|
244
|
+
lines.push(`Bot: ${me.id}${me.username ? ` @${me.username}` : ""}`);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
lines.push(`Bot info: failed — ${String(err)}`);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const chat = await this.getTelegramApi().getChat(this.requireChannelID());
|
|
251
|
+
lines.push(`Chat: ${chat.type}${chat.title ? ` "${chat.title}"` : ""}`);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
lines.push(`Chat info: failed — ${String(err)}`);
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const me = await this.getTelegramApi().getMe();
|
|
258
|
+
const member = await this.getTelegramApi().getChatMember(this.requireChannelID(), me.id);
|
|
259
|
+
const status = String(member.status ?? "unknown");
|
|
260
|
+
lines.push(`Role: ${status}`);
|
|
261
|
+
const permKeys = ["can_edit_messages", "can_post_messages", "can_delete_messages",
|
|
262
|
+
"can_restrict_members", "can_promote_members", "can_invite_users",
|
|
263
|
+
"can_pin_messages", "is_anonymous", "can_manage_chat"];
|
|
264
|
+
const enabled = permKeys.filter(k => member[k] === true);
|
|
265
|
+
if (enabled.length > 0) {
|
|
266
|
+
lines.push(`Perms: ${enabled.join(", ")}`);
|
|
267
|
+
}
|
|
268
|
+
if (status === "administrator" && !enabled.includes("can_edit_messages")) {
|
|
269
|
+
lines.push("⚠️ Bot lacks 'can_edit_messages' — callback queries won't work in channels");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
lines.push(`Bot member: failed — ${String(err)}`);
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const wh = await this.getTelegramApi().getWebhookInfo();
|
|
277
|
+
lines.push(`Webhook: ${wh.url || "(none)"}`);
|
|
278
|
+
lines.push(`Pending: ${wh.pending_update_count}`);
|
|
279
|
+
if (wh.last_error_message) {
|
|
280
|
+
lines.push(`Webhook error: ${wh.last_error_message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
lines.push(`Webhook: failed — ${String(err)}`);
|
|
285
|
+
}
|
|
286
|
+
lines.push(`Prefix: /${this.config.prefix}`);
|
|
287
|
+
lines.push(`Poll timeout: ${this.config.pollTimeoutSec}s`);
|
|
288
|
+
await this.telegram.sendMessage(this.config.channelID, lines.join("\n"), { replyToMessageID: command.messageID });
|
|
289
|
+
}
|
|
218
290
|
async statusLine() {
|
|
219
291
|
const state = await this.syncState();
|
|
220
292
|
const owner = state.lease?.ownerInstanceID ?? "none";
|
|
@@ -246,19 +318,34 @@ export class BridgeController {
|
|
|
246
318
|
return lines.join("\n");
|
|
247
319
|
}
|
|
248
320
|
async handleTelegramCommand(command) {
|
|
321
|
+
if (command.callbackQueryID) {
|
|
322
|
+
await this.handleTelegramCallback(command);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await this.handleTelegramMessage(command);
|
|
326
|
+
}
|
|
327
|
+
async handleTelegramMessage(command) {
|
|
249
328
|
this.log(`handleTelegramCommand: kind=${command.command.kind}, updateID=${command.updateID}, messageID=${command.messageID}`);
|
|
250
329
|
const state = await this.syncState();
|
|
251
330
|
const isOwner = this.lease.isOwner(state);
|
|
252
|
-
const alwaysAllowed = new Set(["status", "who", "health", "reclaim", "version"]);
|
|
331
|
+
const alwaysAllowed = new Set(["status", "diag", "who", "health", "reclaim", "version"]);
|
|
253
332
|
if (!isOwner && !alwaysAllowed.has(command.command.kind)) {
|
|
254
333
|
this.log(`handleTelegramCommand: rejected because not owner (isOwner=${isOwner}, kind=${command.command.kind})`);
|
|
255
334
|
await this.telegram.sendMessage(this.config.channelID, "Bridge is currently owned by another OpenCode instance. Use /tp:reclaim first.", { replyToMessageID: command.messageID });
|
|
256
335
|
return;
|
|
257
336
|
}
|
|
337
|
+
if (command.command.kind === "question") {
|
|
338
|
+
await this.handleQuestionCommand(command);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
258
341
|
if (command.command.kind === "status") {
|
|
259
342
|
await this.telegram.sendMessage(this.config.channelID, await this.statusLine(), { replyToMessageID: command.messageID });
|
|
260
343
|
return;
|
|
261
344
|
}
|
|
345
|
+
if (command.command.kind === "diag") {
|
|
346
|
+
await this.handleDiag(command);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
262
349
|
if (command.command.kind === "version") {
|
|
263
350
|
await this.telegram.sendMessage(this.config.channelID, this.versionLine(), { replyToMessageID: command.messageID });
|
|
264
351
|
return;
|
|
@@ -540,9 +627,15 @@ export class BridgeController {
|
|
|
540
627
|
const summary = await this.buildSummary(sessionID, assistantMessageID, diffMessageID, directParts);
|
|
541
628
|
const completedAt = this.deps.now();
|
|
542
629
|
const elapsed = completedAt - (state.activePrompt.startedAt ?? state.activePrompt.createdAt);
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
630
|
+
const hasContent = summary.text.trim().length > 0 || summary.changedFiles.length > 0;
|
|
631
|
+
if (hasContent) {
|
|
632
|
+
const resultText = formatSummaryForTelegram(summary, this.config.summaryMaxChars);
|
|
633
|
+
const combinedMessage = `✅ completed in ${formatAgeMs(Math.max(0, elapsed))}\n\n${resultText}`;
|
|
634
|
+
await this.telegram.sendMessage(this.config.channelID, combinedMessage, { replyToMessageID: state.activePrompt.telegramMessageID });
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
this.log(`completeActivePrompt: assistant produced no text and no file changes; skipping Telegram notification for userMessageID=${state.activePrompt.userMessageID}`);
|
|
638
|
+
}
|
|
546
639
|
await this.persist(this.appendPromptHistory({
|
|
547
640
|
...state,
|
|
548
641
|
activePrompt: undefined,
|
|
@@ -560,6 +653,14 @@ export class BridgeController {
|
|
|
560
653
|
this.completionInFlight = false;
|
|
561
654
|
}
|
|
562
655
|
}
|
|
656
|
+
async onMessagePartUpdated(input) {
|
|
657
|
+
if (input.part.type !== "text")
|
|
658
|
+
return;
|
|
659
|
+
const text = input.part.text;
|
|
660
|
+
if (typeof text !== "string")
|
|
661
|
+
return;
|
|
662
|
+
this.streamedText.set(input.messageID, text);
|
|
663
|
+
}
|
|
563
664
|
async onUserMessage(sessionID, userMessageID) {
|
|
564
665
|
const state = await this.syncState();
|
|
565
666
|
if (!this.lease.isOwner(state))
|
|
@@ -583,10 +684,18 @@ export class BridgeController {
|
|
|
583
684
|
}
|
|
584
685
|
async onPermissionAsked(input) {
|
|
585
686
|
const state = await this.syncState();
|
|
586
|
-
if (!this.lease.isOwner(state))
|
|
687
|
+
if (!this.lease.isOwner(state)) {
|
|
688
|
+
this.log(`onPermissionAsked: dropped because not owner (id=${input.id})`);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (state.bound.sessionID !== input.sessionID) {
|
|
692
|
+
this.log(`onPermissionAsked: dropped because session mismatch (bound=${state.bound.sessionID}, event=${input.sessionID})`);
|
|
587
693
|
return;
|
|
588
|
-
|
|
694
|
+
}
|
|
695
|
+
if (state.pendingPermissions[input.id]) {
|
|
696
|
+
this.log(`onPermissionAsked: dropped because already pending (id=${input.id})`);
|
|
589
697
|
return;
|
|
698
|
+
}
|
|
590
699
|
const pending = toPendingPermission(input);
|
|
591
700
|
const next = {
|
|
592
701
|
...state,
|
|
@@ -597,8 +706,31 @@ export class BridgeController {
|
|
|
597
706
|
};
|
|
598
707
|
await this.persist(next);
|
|
599
708
|
const activeReplyTo = state.activePrompt?.telegramMessageID;
|
|
600
|
-
await this.
|
|
601
|
-
|
|
709
|
+
await this.safeSendTelegram("onPermissionAsked: waiting", "waiting-permission", activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
|
|
710
|
+
let detailMessage = "";
|
|
711
|
+
let keyboard = { inline_keyboard: [] };
|
|
712
|
+
try {
|
|
713
|
+
detailMessage = formatPermissionRequestMessage(this.config.prefix, pending);
|
|
714
|
+
keyboard = buildPermissionKeyboard(pending.requestID);
|
|
715
|
+
}
|
|
716
|
+
catch (formatError) {
|
|
717
|
+
this.log(`onPermissionAsked: format/build failed: ${String(formatError)}`);
|
|
718
|
+
await this.safeSendTelegram("onPermissionAsked: detail (fallback)", `OpenCode permission request (id=${pending.requestID}) — formatting failed.`, activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
await this.safeSendTelegram("onPermissionAsked: detail", detailMessage, {
|
|
722
|
+
replyToMessageID: activeReplyTo,
|
|
723
|
+
replyMarkup: keyboard,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
async onPermissionReplied(requestID) {
|
|
727
|
+
const state = await this.syncState();
|
|
728
|
+
if (!this.lease.isOwner(state))
|
|
729
|
+
return;
|
|
730
|
+
if (!state.pendingPermissions[requestID])
|
|
731
|
+
return;
|
|
732
|
+
const { [requestID]: _removed, ...rest } = state.pendingPermissions;
|
|
733
|
+
await this.persist({ ...state, pendingPermissions: rest });
|
|
602
734
|
}
|
|
603
735
|
async handlePermissionReply(requestID, action, replyToMessageID) {
|
|
604
736
|
const state = await this.syncState();
|
|
@@ -611,7 +743,7 @@ export class BridgeController {
|
|
|
611
743
|
await this.telegram.sendMessage(this.config.channelID, `Permission request not found: ${requestID}`, replyToMessageID ? { replyToMessageID } : undefined);
|
|
612
744
|
return;
|
|
613
745
|
}
|
|
614
|
-
await replyPermission(this.client, pending.sessionID, requestID, action);
|
|
746
|
+
await replyPermission(this.client, pending.sessionID, requestID, action, this.api.state.path.directory);
|
|
615
747
|
const { [requestID]: _removed, ...rest } = state.pendingPermissions;
|
|
616
748
|
await this.persist({
|
|
617
749
|
...state,
|
|
@@ -619,6 +751,280 @@ export class BridgeController {
|
|
|
619
751
|
});
|
|
620
752
|
await this.telegram.sendMessage(this.config.channelID, `Permission ${requestID} -> ${action}`, replyToMessageID ? { replyToMessageID } : undefined);
|
|
621
753
|
}
|
|
754
|
+
async handleTelegramCallback(callback) {
|
|
755
|
+
this.log(`handleTelegramCallback: kind=${callback.command.kind}, action=${callback.command.action}, requestID=${callback.command.requestID}, messageID=${callback.messageID}`);
|
|
756
|
+
try {
|
|
757
|
+
await this.telegram.answerCallbackQuery(callback.callbackQueryID);
|
|
758
|
+
}
|
|
759
|
+
catch (err) {
|
|
760
|
+
this.log(`handleTelegramCallback: answerCallbackQuery failed: ${String(err)}`);
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
if (callback.command.kind === "permission") {
|
|
764
|
+
await this.handlePermissionReply(callback.command.requestID, callback.command.action);
|
|
765
|
+
await this.telegram.editMessageReplyMarkup(this.requireChannelID(), callback.messageID, undefined);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (callback.command.kind === "question") {
|
|
769
|
+
await this.handleCallbackQuestion(callback);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
this.log(`handleTelegramCallback: dispatch failed: ${String(err)}`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
async handleCallbackQuestion(callback) {
|
|
778
|
+
const command = callback.command;
|
|
779
|
+
if (command.action === "reject") {
|
|
780
|
+
await this.handleQuestionReject(command.requestID, callback.messageID);
|
|
781
|
+
await this.telegram.editMessageReplyMarkup(this.requireChannelID(), callback.messageID, undefined);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (command.action === "reply") {
|
|
785
|
+
const state = await this.syncState();
|
|
786
|
+
const pending = state.pendingQuestions[command.requestID];
|
|
787
|
+
if (!pending) {
|
|
788
|
+
await this.telegram.sendMessage(this.config.channelID, `Question request not found: ${command.requestID}`, { replyToMessageID: callback.messageID });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const qIndex = command.questionIndex ?? 0;
|
|
792
|
+
const optIndex = command.optionIndex ?? -1;
|
|
793
|
+
const info = pending.questions[qIndex];
|
|
794
|
+
const option = info?.options[optIndex];
|
|
795
|
+
if (!info || !option) {
|
|
796
|
+
await this.telegram.sendMessage(this.config.channelID, `Question option no longer valid for request ${command.requestID}.`, { replyToMessageID: callback.messageID });
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const answers = pending.questions.map((_, idx) => {
|
|
800
|
+
if (idx === qIndex) {
|
|
801
|
+
return { questionIndex: idx, labels: [option.label] };
|
|
802
|
+
}
|
|
803
|
+
return { questionIndex: idx, labels: [] };
|
|
804
|
+
});
|
|
805
|
+
await this.handleQuestionReply(pending, answers, callback.messageID);
|
|
806
|
+
await this.telegram.editMessageReplyMarkup(this.requireChannelID(), callback.messageID, undefined);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (command.action === "toggle") {
|
|
810
|
+
await this.handleQuestionToggle(command.requestID, command.questionIndex ?? 0, command.optionIndex ?? -1, callback.messageID, callback.channelID);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (command.action === "confirm") {
|
|
814
|
+
await this.handleQuestionConfirm(command.requestID, command.questionIndex ?? 0, callback.messageID);
|
|
815
|
+
await this.telegram.editMessageReplyMarkup(this.requireChannelID(), callback.messageID, undefined);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
async handleQuestionToggle(requestID, questionIndex, optionIndex, messageID, channelID) {
|
|
820
|
+
const state = await this.syncState();
|
|
821
|
+
if (!this.lease.isOwner(state))
|
|
822
|
+
return;
|
|
823
|
+
const pending = state.pendingQuestions[requestID];
|
|
824
|
+
if (!pending) {
|
|
825
|
+
await this.telegram.sendMessage(channelID, `Question request not found: ${requestID}`, { replyToMessageID: messageID });
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const info = pending.questions[questionIndex];
|
|
829
|
+
if (!info)
|
|
830
|
+
return;
|
|
831
|
+
const option = info.options[optionIndex];
|
|
832
|
+
if (!option)
|
|
833
|
+
return;
|
|
834
|
+
const previous = state.questionAnswers[requestID] ?? [];
|
|
835
|
+
const updated = pending.questions.map((_, idx) => {
|
|
836
|
+
const existing = previous.find((a) => a.questionIndex === idx);
|
|
837
|
+
if (idx !== questionIndex) {
|
|
838
|
+
return existing ?? { questionIndex: idx, labels: [] };
|
|
839
|
+
}
|
|
840
|
+
const labels = existing ? [...existing.labels] : [];
|
|
841
|
+
const at = labels.indexOf(option.label);
|
|
842
|
+
if (at >= 0)
|
|
843
|
+
labels.splice(at, 1);
|
|
844
|
+
else
|
|
845
|
+
labels.push(option.label);
|
|
846
|
+
return { questionIndex: idx, labels };
|
|
847
|
+
});
|
|
848
|
+
await this.persist({
|
|
849
|
+
...state,
|
|
850
|
+
questionAnswers: {
|
|
851
|
+
...state.questionAnswers,
|
|
852
|
+
[requestID]: updated,
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
const selected = updated[questionIndex]?.labels ?? [];
|
|
856
|
+
const keyboard = buildQuestionKeyboard({
|
|
857
|
+
requestID,
|
|
858
|
+
questionIndex,
|
|
859
|
+
options: info.options,
|
|
860
|
+
selectedLabels: selected,
|
|
861
|
+
multiple: info.multiple === true,
|
|
862
|
+
});
|
|
863
|
+
try {
|
|
864
|
+
await this.telegram.editMessageReplyMarkup(channelID, messageID, keyboard);
|
|
865
|
+
}
|
|
866
|
+
catch (err) {
|
|
867
|
+
this.log(`handleQuestionToggle: editMessageReplyMarkup failed: ${String(err)}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
async handleQuestionConfirm(requestID, questionIndex, messageID) {
|
|
871
|
+
const state = await this.syncState();
|
|
872
|
+
if (!this.lease.isOwner(state))
|
|
873
|
+
return;
|
|
874
|
+
const pending = state.pendingQuestions[requestID];
|
|
875
|
+
if (!pending) {
|
|
876
|
+
await this.telegram.sendMessage(this.config.channelID, `Question request not found: ${requestID}`, { replyToMessageID: messageID });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const previous = state.questionAnswers[requestID] ?? [];
|
|
880
|
+
const answers = pending.questions.map((_, idx) => {
|
|
881
|
+
const found = previous.find((a) => a.questionIndex === idx);
|
|
882
|
+
return found ?? { questionIndex: idx, labels: [] };
|
|
883
|
+
});
|
|
884
|
+
if (answers[questionIndex]?.labels.length === 0) {
|
|
885
|
+
await this.telegram.sendMessage(this.config.channelID, "Pick at least one option before confirming.", { replyToMessageID: messageID });
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
await this.handleQuestionReply(pending, answers, messageID);
|
|
889
|
+
}
|
|
890
|
+
async handleQuestionCommand(command) {
|
|
891
|
+
const cmd = command.command;
|
|
892
|
+
if (cmd.action === "reject") {
|
|
893
|
+
await this.handleQuestionReject(cmd.requestID, command.messageID);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (cmd.action === "reply") {
|
|
897
|
+
if (!cmd.answers)
|
|
898
|
+
return;
|
|
899
|
+
const state = await this.syncState();
|
|
900
|
+
const pending = state.pendingQuestions[cmd.requestID];
|
|
901
|
+
if (!pending) {
|
|
902
|
+
await this.telegram.sendMessage(this.config.channelID, `Question request not found: ${cmd.requestID}`, { replyToMessageID: command.messageID });
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
await this.handleQuestionReply(pending, cmd.answers, command.messageID);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
async handleQuestionReply(pending, answers, replyToMessageID) {
|
|
910
|
+
const state = await this.syncState();
|
|
911
|
+
if (!this.lease.isOwner(state)) {
|
|
912
|
+
await this.telegram.sendMessage(this.config.channelID, "Cannot answer question: this instance is not the current bridge owner.", replyToMessageID ? { replyToMessageID } : undefined);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const normalized = normalizeAnswers(pending, answers);
|
|
916
|
+
try {
|
|
917
|
+
await replyQuestion(this.client, this.api.state.path.directory, pending.requestID, normalized);
|
|
918
|
+
}
|
|
919
|
+
catch (err) {
|
|
920
|
+
await this.telegram.sendMessage(this.config.channelID, `Question reply failed: ${String(err)}`, replyToMessageID ? { replyToMessageID } : undefined);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const { [pending.requestID]: _q, ...remaining } = state.pendingQuestions;
|
|
924
|
+
const { [pending.requestID]: _a, ...remainingAnswers } = state.questionAnswers;
|
|
925
|
+
await this.persist({
|
|
926
|
+
...state,
|
|
927
|
+
pendingQuestions: remaining,
|
|
928
|
+
questionAnswers: remainingAnswers,
|
|
929
|
+
});
|
|
930
|
+
const summary = normalized
|
|
931
|
+
.map((labels, idx) => `Q${idx + 1}: ${labels.join(", ") || "(skipped)"}`)
|
|
932
|
+
.join("\n");
|
|
933
|
+
await this.telegram.sendMessage(this.config.channelID, `Question ${pending.requestID} -> replied\n${summary}`, replyToMessageID ? { replyToMessageID } : undefined);
|
|
934
|
+
}
|
|
935
|
+
async handleQuestionReject(requestID, replyToMessageID) {
|
|
936
|
+
const state = await this.syncState();
|
|
937
|
+
if (!this.lease.isOwner(state)) {
|
|
938
|
+
await this.telegram.sendMessage(this.config.channelID, "Cannot reject question: this instance is not the current bridge owner.", replyToMessageID ? { replyToMessageID } : undefined);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const pending = state.pendingQuestions[requestID];
|
|
942
|
+
if (!pending) {
|
|
943
|
+
await this.telegram.sendMessage(this.config.channelID, `Question request not found: ${requestID}`, replyToMessageID ? { replyToMessageID } : undefined);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
await rejectQuestion(this.client, this.api.state.path.directory, requestID);
|
|
948
|
+
}
|
|
949
|
+
catch (err) {
|
|
950
|
+
await this.telegram.sendMessage(this.config.channelID, `Question reject failed: ${String(err)}`, replyToMessageID ? { replyToMessageID } : undefined);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const { [requestID]: _q, ...remaining } = state.pendingQuestions;
|
|
954
|
+
const { [requestID]: _a, ...remainingAnswers } = state.questionAnswers;
|
|
955
|
+
await this.persist({
|
|
956
|
+
...state,
|
|
957
|
+
pendingQuestions: remaining,
|
|
958
|
+
questionAnswers: remainingAnswers,
|
|
959
|
+
});
|
|
960
|
+
await this.telegram.sendMessage(this.config.channelID, `Question ${requestID} -> rejected`, replyToMessageID ? { replyToMessageID } : undefined);
|
|
961
|
+
}
|
|
962
|
+
async onQuestionAsked(input) {
|
|
963
|
+
const state = await this.syncState();
|
|
964
|
+
if (!this.lease.isOwner(state)) {
|
|
965
|
+
this.log(`onQuestionAsked: dropped because not owner (id=${input.id})`);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (state.bound.sessionID !== input.sessionID) {
|
|
969
|
+
this.log(`onQuestionAsked: dropped because session mismatch (bound=${state.bound.sessionID}, event=${input.sessionID})`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (state.pendingQuestions[input.id]) {
|
|
973
|
+
this.log(`onQuestionAsked: dropped because already pending (id=${input.id})`);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const pending = toPendingQuestion(input);
|
|
977
|
+
const next = {
|
|
978
|
+
...state,
|
|
979
|
+
pendingQuestions: {
|
|
980
|
+
...state.pendingQuestions,
|
|
981
|
+
[pending.requestID]: pending,
|
|
982
|
+
},
|
|
983
|
+
};
|
|
984
|
+
await this.persist(next);
|
|
985
|
+
const activeReplyTo = state.activePrompt?.telegramMessageID;
|
|
986
|
+
await this.safeSendTelegram("onQuestionAsked: waiting", "waiting-question", activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
|
|
987
|
+
for (let i = 0; i < pending.questions.length; i++) {
|
|
988
|
+
const info = pending.questions[i];
|
|
989
|
+
let message = "";
|
|
990
|
+
let keyboard = { inline_keyboard: [] };
|
|
991
|
+
try {
|
|
992
|
+
message = formatQuestionRequestMessage(pending, i);
|
|
993
|
+
keyboard = buildQuestionKeyboard({
|
|
994
|
+
requestID: pending.requestID,
|
|
995
|
+
questionIndex: i,
|
|
996
|
+
options: info.options,
|
|
997
|
+
selectedLabels: [],
|
|
998
|
+
multiple: info.multiple === true,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
catch (formatError) {
|
|
1002
|
+
this.log(`onQuestionAsked: format/build failed for question ${i}: ${String(formatError)}`);
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
await this.safeSendTelegram(`onQuestionAsked: detail[${i}]`, message, {
|
|
1006
|
+
replyToMessageID: activeReplyTo,
|
|
1007
|
+
replyMarkup: keyboard,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
async onQuestionReplied(input) {
|
|
1012
|
+
const state = await this.syncState();
|
|
1013
|
+
if (!this.lease.isOwner(state))
|
|
1014
|
+
return;
|
|
1015
|
+
if (!state.pendingQuestions[input.id])
|
|
1016
|
+
return;
|
|
1017
|
+
const { [input.id]: _q, ...remaining } = state.pendingQuestions;
|
|
1018
|
+
const { [input.id]: _a, ...remainingAnswers } = state.questionAnswers;
|
|
1019
|
+
await this.persist({
|
|
1020
|
+
...state,
|
|
1021
|
+
pendingQuestions: remaining,
|
|
1022
|
+
questionAnswers: remainingAnswers,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
async onQuestionRejected(input) {
|
|
1026
|
+
await this.onQuestionReplied(input);
|
|
1027
|
+
}
|
|
622
1028
|
async handleModelCommand(target, preset, replyToMessageID) {
|
|
623
1029
|
const state = await this.requireState();
|
|
624
1030
|
if (state.bound.status !== "online" || !state.bound.sessionID) {
|
|
@@ -703,17 +1109,64 @@ export class BridgeController {
|
|
|
703
1109
|
}
|
|
704
1110
|
}
|
|
705
1111
|
async buildSummary(sessionID, assistantMessageID, userMessageID, directParts) {
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
return
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
1112
|
+
const textFromParts = (parts) => {
|
|
1113
|
+
if (!parts)
|
|
1114
|
+
return "";
|
|
1115
|
+
return parts
|
|
1116
|
+
.filter((part) => {
|
|
1117
|
+
return part.type === "text" && typeof part.text === "string";
|
|
1118
|
+
})
|
|
1119
|
+
.map((part) => part.text)
|
|
1120
|
+
.join("")
|
|
1121
|
+
.trim();
|
|
1122
|
+
};
|
|
1123
|
+
// Source 0: streamed text accumulator (from message.part.updated events).
|
|
1124
|
+
// This is the most reliable because it accumulates as the model generates
|
|
1125
|
+
// text and bypasses both the SDK fetch (which can 500) and the TUI state
|
|
1126
|
+
// (which may be empty for bridge-originated messages).
|
|
1127
|
+
let text = this.streamedText.get(assistantMessageID)?.trim() ?? "";
|
|
1128
|
+
let source = text ? "streamed" : "none";
|
|
1129
|
+
// Source 1: parts passed in from the polling loop (may be undefined or
|
|
1130
|
+
// partial depending on the server's response shape for session.messages()).
|
|
1131
|
+
if (!text) {
|
|
1132
|
+
text = textFromParts(directParts);
|
|
1133
|
+
if (text)
|
|
1134
|
+
source = "direct";
|
|
1135
|
+
}
|
|
1136
|
+
// Source 2: TUI plugin's local state (fast, but only has parts the TUI
|
|
1137
|
+
// itself has rendered, so may be empty for bridge-originated messages).
|
|
1138
|
+
if (!text) {
|
|
1139
|
+
try {
|
|
1140
|
+
text = textFromParts(this.api.state.part(assistantMessageID));
|
|
1141
|
+
if (text)
|
|
1142
|
+
source = "tui";
|
|
1143
|
+
}
|
|
1144
|
+
catch (err) {
|
|
1145
|
+
this.log(`buildSummary: api.state.part failed: ${String(err)}`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
// Source 3: dedicated single-message SDK fetch. The v1 SDK's URL template
|
|
1149
|
+
// is /session/{id}/message/{messageID}, so we pass `id` and `messageID`
|
|
1150
|
+
// flat (NOT under a `path` wrapper) so the SDK's path substitution works.
|
|
1151
|
+
if (!text) {
|
|
1152
|
+
try {
|
|
1153
|
+
const response = await this.client.session.message({ id: sessionID, messageID: assistantMessageID }, { responseStyle: "data", throwOnError: true });
|
|
1154
|
+
const parts = response?.parts;
|
|
1155
|
+
text = textFromParts(parts);
|
|
1156
|
+
if (text)
|
|
1157
|
+
source = "sdk";
|
|
1158
|
+
this.log(`buildSummary: SDK fetch returned ${parts?.length ?? 0} parts, text length=${text.length}`);
|
|
1159
|
+
}
|
|
1160
|
+
catch (err) {
|
|
1161
|
+
this.log(`buildSummary: SDK message fetch failed: ${String(err)}`);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
this.log(`buildSummary: assistantMessageID=${assistantMessageID} text source=${source} length=${text.length} streamedMapSize=${this.streamedText.size}`);
|
|
1165
|
+
// Drop the entry from the accumulator so we don't leak memory.
|
|
1166
|
+
this.streamedText.delete(assistantMessageID);
|
|
714
1167
|
let changedFiles = [];
|
|
715
1168
|
try {
|
|
716
|
-
const diffResponse = await this.client.session.message({ sessionID, messageID: userMessageID
|
|
1169
|
+
const diffResponse = await this.client.session.message({ id: sessionID, messageID: userMessageID }, { responseStyle: "data", throwOnError: true });
|
|
717
1170
|
const diff = diffResponse?.diff;
|
|
718
1171
|
changedFiles = (diff || []).map((item) => item.file);
|
|
719
1172
|
}
|
|
@@ -721,7 +1174,7 @@ export class BridgeController {
|
|
|
721
1174
|
changedFiles = [];
|
|
722
1175
|
}
|
|
723
1176
|
return {
|
|
724
|
-
text
|
|
1177
|
+
text,
|
|
725
1178
|
changedFiles,
|
|
726
1179
|
};
|
|
727
1180
|
}
|
|
@@ -1100,6 +1553,7 @@ export class BridgeController {
|
|
|
1100
1553
|
startPolling() {
|
|
1101
1554
|
if (this.pollTask)
|
|
1102
1555
|
return;
|
|
1556
|
+
this.log("startPolling: beginning Telegram long-polling");
|
|
1103
1557
|
const telegram = this.getTelegramApi();
|
|
1104
1558
|
this.pollAbort = new AbortController();
|
|
1105
1559
|
const poller = new TelegramPoller(telegram, this.requireChannelID(), this.config.prefix, this.config.pollTimeoutSec, {
|
|
@@ -1116,7 +1570,11 @@ export class BridgeController {
|
|
|
1116
1570
|
message: `Teleprompt: received ${count} Telegram updates`,
|
|
1117
1571
|
});
|
|
1118
1572
|
},
|
|
1573
|
+
onLog: (message) => {
|
|
1574
|
+
this.log(message);
|
|
1575
|
+
},
|
|
1119
1576
|
onError: (error) => {
|
|
1577
|
+
this.log(`Telegram polling error: ${String(error)}`);
|
|
1120
1578
|
this.api.ui.toast({
|
|
1121
1579
|
variant: "warning",
|
|
1122
1580
|
message: `Telegram polling error: ${String(error)}`,
|
|
@@ -1131,16 +1589,23 @@ export class BridgeController {
|
|
|
1131
1589
|
this.eventRestartTimer = undefined;
|
|
1132
1590
|
}
|
|
1133
1591
|
await this.eventStream?.stop();
|
|
1134
|
-
this.eventStream = new SessionEventStream(this.client, sessionID, {
|
|
1592
|
+
this.eventStream = new SessionEventStream(this.client, sessionID, this.api.state.path.directory, {
|
|
1135
1593
|
onAssistantCompleted: (sid, assistantID, parentID) => this.onAssistantCompleted(sid, assistantID, parentID),
|
|
1136
1594
|
onPermissionAsked: (event) => this.onPermissionAsked(event),
|
|
1595
|
+
onPermissionReplied: (requestID) => this.onPermissionReplied(requestID),
|
|
1596
|
+
onQuestionAsked: (event) => this.onQuestionAsked(event),
|
|
1597
|
+
onQuestionReplied: (event) => this.onQuestionReplied(event),
|
|
1598
|
+
onQuestionRejected: (event) => this.onQuestionRejected(event),
|
|
1137
1599
|
onSessionError: (event) => this.onSessionError(event),
|
|
1138
1600
|
onUserMessage: (sid, msgID) => this.onUserMessage(sid, msgID),
|
|
1601
|
+
onMessagePartUpdated: (input) => this.onMessagePartUpdated(input),
|
|
1139
1602
|
onStreamError: (error) => this.onEventStreamError(sessionID, error),
|
|
1140
|
-
});
|
|
1603
|
+
}, (msg) => this.log(`eventStream: ${msg}`));
|
|
1141
1604
|
this.eventStream.start();
|
|
1605
|
+
this.streamedText.clear();
|
|
1142
1606
|
}
|
|
1143
1607
|
async onEventStreamError(sessionID, error) {
|
|
1608
|
+
this.log(`onEventStreamError: ${String(error)}`);
|
|
1144
1609
|
this.eventStream = undefined;
|
|
1145
1610
|
this.api.ui.toast({
|
|
1146
1611
|
variant: "warning",
|
|
@@ -1254,6 +1719,7 @@ export class BridgeController {
|
|
|
1254
1719
|
this.pollTask = undefined;
|
|
1255
1720
|
await this.eventStream?.stop();
|
|
1256
1721
|
this.eventStream = undefined;
|
|
1722
|
+
this.streamedText.clear();
|
|
1257
1723
|
if (!clearJobs)
|
|
1258
1724
|
return;
|
|
1259
1725
|
const state = await this.requireState();
|
|
@@ -1262,6 +1728,8 @@ export class BridgeController {
|
|
|
1262
1728
|
activePrompt: undefined,
|
|
1263
1729
|
promptQueue: [],
|
|
1264
1730
|
pendingPermissions: {},
|
|
1731
|
+
pendingQuestions: {},
|
|
1732
|
+
questionAnswers: {},
|
|
1265
1733
|
});
|
|
1266
1734
|
}
|
|
1267
1735
|
async requireState() {
|
|
@@ -1291,6 +1759,8 @@ export class BridgeController {
|
|
|
1291
1759
|
activePrompt: undefined,
|
|
1292
1760
|
promptQueue: [],
|
|
1293
1761
|
pendingPermissions: {},
|
|
1762
|
+
pendingQuestions: {},
|
|
1763
|
+
questionAnswers: {},
|
|
1294
1764
|
promptHistory: [],
|
|
1295
1765
|
recentPrompts: [],
|
|
1296
1766
|
};
|
|
@@ -1450,5 +1920,28 @@ export class BridgeController {
|
|
|
1450
1920
|
at: this.deps.now(),
|
|
1451
1921
|
}));
|
|
1452
1922
|
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Test-only hooks. Exposed as a single object so production code never
|
|
1925
|
+
* reaches for these methods and tests can drive the controller
|
|
1926
|
+
* deterministically without spinning up the full TUI lifecycle.
|
|
1927
|
+
*/
|
|
1928
|
+
get __test() {
|
|
1929
|
+
return {
|
|
1930
|
+
onPermissionAsked: (input) => this.onPermissionAsked(input),
|
|
1931
|
+
onQuestionAsked: (input) => this.onQuestionAsked(input),
|
|
1932
|
+
onQuestionReplied: (input) => this.onQuestionReplied(input),
|
|
1933
|
+
onQuestionRejected: (input) => this.onQuestionRejected(input),
|
|
1934
|
+
handleTelegramCallback: (cb) => this.handleTelegramCallback(cb),
|
|
1935
|
+
handleTelegramMessage: (msg) => this.handleTelegramMessage(msg),
|
|
1936
|
+
handleQuestionToggle: (...args) => this.handleQuestionToggle(...args),
|
|
1937
|
+
startEventStream: (sessionID) => this.startEventStream(sessionID),
|
|
1938
|
+
setState: async (next) => {
|
|
1939
|
+
this.data = next;
|
|
1940
|
+
await this.store.save(next);
|
|
1941
|
+
},
|
|
1942
|
+
syncState: () => this.syncState(),
|
|
1943
|
+
getStorePath: () => this.storePath,
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1453
1946
|
}
|
|
1454
1947
|
//# sourceMappingURL=controller.js.map
|