@grinev/opencode-telegram-bot 0.12.1 → 0.13.0
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/.env.example +6 -0
- package/README.md +2 -0
- package/dist/bot/commands/commands.js +87 -5
- package/dist/bot/commands/new.js +5 -0
- package/dist/bot/commands/projects.js +9 -0
- package/dist/bot/commands/sessions.js +9 -0
- package/dist/bot/index.js +128 -21
- package/dist/bot/middleware/interaction-guard.js +6 -2
- package/dist/bot/streaming/response-streamer.js +284 -0
- package/dist/bot/utils/busy-guard.js +15 -0
- package/dist/bot/utils/finalize-assistant-response.js +23 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +6 -8
- package/dist/bot/utils/thinking-message.js +12 -0
- package/dist/config.js +2 -0
- package/dist/i18n/de.js +5 -0
- package/dist/i18n/en.js +5 -0
- package/dist/i18n/es.js +5 -0
- package/dist/i18n/fr.js +5 -0
- package/dist/i18n/ru.js +5 -0
- package/dist/i18n/zh.js +8 -3
- package/dist/interaction/busy.js +8 -0
- package/dist/interaction/guard.js +42 -6
- package/dist/pinned/manager.js +8 -9
- package/dist/summary/aggregator.js +186 -36
- package/dist/summary/formatter.js +5 -3
- package/dist/summary/tool-message-batcher.js +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
function buildStateKey(sessionId, messageId) {
|
|
3
|
+
return `${sessionId}:${messageId}`;
|
|
4
|
+
}
|
|
5
|
+
function normalizePayload(payload) {
|
|
6
|
+
const normalizedParts = payload.parts
|
|
7
|
+
.map((part) => part.trim())
|
|
8
|
+
.filter((part) => part.length > 0);
|
|
9
|
+
if (normalizedParts.length === 0) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
parts: normalizedParts,
|
|
14
|
+
format: payload.format,
|
|
15
|
+
sendOptions: payload.sendOptions,
|
|
16
|
+
editOptions: payload.editOptions,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function getErrorMessage(error) {
|
|
20
|
+
if (error instanceof Error) {
|
|
21
|
+
return error.message;
|
|
22
|
+
}
|
|
23
|
+
return String(error);
|
|
24
|
+
}
|
|
25
|
+
function getRetryAfterMs(error) {
|
|
26
|
+
const message = getErrorMessage(error);
|
|
27
|
+
if (!/\b429\b/.test(message)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const retryMatch = message.match(/retry after\s+(\d+)/i);
|
|
31
|
+
if (!retryMatch) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const seconds = Number.parseInt(retryMatch[1], 10);
|
|
35
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return seconds * 1000;
|
|
39
|
+
}
|
|
40
|
+
function createSignature(text, format) {
|
|
41
|
+
return `${format}\n${text}`;
|
|
42
|
+
}
|
|
43
|
+
function delay(ms) {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
setTimeout(resolve, ms);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export class ResponseStreamer {
|
|
49
|
+
throttleMs;
|
|
50
|
+
sendText;
|
|
51
|
+
editText;
|
|
52
|
+
deleteText;
|
|
53
|
+
states = new Map();
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.throttleMs = Math.max(0, Math.floor(options.throttleMs));
|
|
56
|
+
this.sendText = options.sendText;
|
|
57
|
+
this.editText = options.editText;
|
|
58
|
+
this.deleteText = options.deleteText;
|
|
59
|
+
}
|
|
60
|
+
enqueue(sessionId, messageId, payload) {
|
|
61
|
+
const normalizedPayload = normalizePayload(payload);
|
|
62
|
+
if (!normalizedPayload) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const state = this.getOrCreateState(sessionId, messageId);
|
|
66
|
+
state.latestPayload = normalizedPayload;
|
|
67
|
+
if (state.isBroken) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.ensureTimer(state);
|
|
71
|
+
}
|
|
72
|
+
async complete(sessionId, messageId, payload, options) {
|
|
73
|
+
const state = this.states.get(buildStateKey(sessionId, messageId));
|
|
74
|
+
if (!state) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (payload) {
|
|
78
|
+
const normalizedPayload = normalizePayload(payload);
|
|
79
|
+
if (normalizedPayload) {
|
|
80
|
+
state.latestPayload = normalizedPayload;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.clearTimer(state);
|
|
84
|
+
await state.task.catch(() => false);
|
|
85
|
+
if (state.isBroken) {
|
|
86
|
+
await this.cleanupBrokenStream(state, "complete_broken_stream");
|
|
87
|
+
this.cancelState(state);
|
|
88
|
+
this.states.delete(state.key);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (state.telegramMessageIds.length === 0) {
|
|
92
|
+
this.cancelState(state);
|
|
93
|
+
this.states.delete(state.key);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
let synced = true;
|
|
97
|
+
if (options?.flushFinal !== false) {
|
|
98
|
+
synced = await this.enqueueTask(state, () => this.flushState(state, "complete"));
|
|
99
|
+
if (!synced && state.isBroken) {
|
|
100
|
+
await this.cleanupBrokenStream(state, "final_sync_failed_cleanup");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.cancelState(state);
|
|
104
|
+
this.states.delete(state.key);
|
|
105
|
+
return synced;
|
|
106
|
+
}
|
|
107
|
+
clearMessage(sessionId, messageId, reason) {
|
|
108
|
+
const key = buildStateKey(sessionId, messageId);
|
|
109
|
+
const state = this.states.get(key);
|
|
110
|
+
if (!state) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.cancelState(state);
|
|
114
|
+
this.states.delete(key);
|
|
115
|
+
logger.debug(`[ResponseStreamer] Cleared message stream: session=${sessionId}, message=${messageId}, reason=${reason}`);
|
|
116
|
+
}
|
|
117
|
+
clearSession(sessionId, reason) {
|
|
118
|
+
for (const state of Array.from(this.states.values())) {
|
|
119
|
+
if (state.sessionId !== sessionId) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
this.cancelState(state);
|
|
123
|
+
this.states.delete(state.key);
|
|
124
|
+
}
|
|
125
|
+
logger.debug(`[ResponseStreamer] Cleared session streams: session=${sessionId}, reason=${reason}`);
|
|
126
|
+
}
|
|
127
|
+
clearAll(reason) {
|
|
128
|
+
for (const state of this.states.values()) {
|
|
129
|
+
this.cancelState(state);
|
|
130
|
+
}
|
|
131
|
+
const count = this.states.size;
|
|
132
|
+
this.states.clear();
|
|
133
|
+
if (count > 0) {
|
|
134
|
+
logger.debug(`[ResponseStreamer] Cleared all streams: count=${count}, reason=${reason}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
getOrCreateState(sessionId, messageId) {
|
|
138
|
+
const key = buildStateKey(sessionId, messageId);
|
|
139
|
+
const existing = this.states.get(key);
|
|
140
|
+
if (existing) {
|
|
141
|
+
return existing;
|
|
142
|
+
}
|
|
143
|
+
const state = {
|
|
144
|
+
key,
|
|
145
|
+
sessionId,
|
|
146
|
+
messageId,
|
|
147
|
+
latestPayload: null,
|
|
148
|
+
lastSentSignatures: [],
|
|
149
|
+
telegramMessageIds: [],
|
|
150
|
+
timer: null,
|
|
151
|
+
task: Promise.resolve(true),
|
|
152
|
+
cancelled: false,
|
|
153
|
+
isBroken: false,
|
|
154
|
+
fatalErrorMessage: null,
|
|
155
|
+
fatalErrorLogged: false,
|
|
156
|
+
};
|
|
157
|
+
this.states.set(key, state);
|
|
158
|
+
return state;
|
|
159
|
+
}
|
|
160
|
+
ensureTimer(state) {
|
|
161
|
+
if (state.timer || state.cancelled) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (this.throttleMs === 0) {
|
|
165
|
+
void this.enqueueTask(state, () => this.flushState(state, "immediate")).catch((error) => {
|
|
166
|
+
logger.error(`[ResponseStreamer] Immediate stream sync failed: session=${state.sessionId}, message=${state.messageId}`, error);
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
state.timer = setTimeout(() => {
|
|
171
|
+
state.timer = null;
|
|
172
|
+
void this.enqueueTask(state, () => this.flushState(state, "throttle_elapsed")).catch((error) => {
|
|
173
|
+
logger.error(`[ResponseStreamer] Throttled stream sync failed: session=${state.sessionId}, message=${state.messageId}`, error);
|
|
174
|
+
});
|
|
175
|
+
}, this.throttleMs);
|
|
176
|
+
}
|
|
177
|
+
clearTimer(state) {
|
|
178
|
+
if (!state.timer) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
clearTimeout(state.timer);
|
|
182
|
+
state.timer = null;
|
|
183
|
+
}
|
|
184
|
+
cancelState(state) {
|
|
185
|
+
state.cancelled = true;
|
|
186
|
+
this.clearTimer(state);
|
|
187
|
+
}
|
|
188
|
+
enqueueTask(state, task) {
|
|
189
|
+
const nextTask = state.task.catch(() => false).then(task);
|
|
190
|
+
state.task = nextTask;
|
|
191
|
+
return nextTask;
|
|
192
|
+
}
|
|
193
|
+
async flushState(state, reason) {
|
|
194
|
+
if (state.cancelled) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
if (state.isBroken) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
while (!state.cancelled) {
|
|
201
|
+
const payload = state.latestPayload;
|
|
202
|
+
if (!payload) {
|
|
203
|
+
return state.telegramMessageIds.length > 0;
|
|
204
|
+
}
|
|
205
|
+
const targetSignatures = payload.parts.map((part) => createSignature(part, payload.format));
|
|
206
|
+
const unchanged = targetSignatures.length === state.lastSentSignatures.length &&
|
|
207
|
+
targetSignatures.every((signature, index) => signature === state.lastSentSignatures[index]);
|
|
208
|
+
if (unchanged) {
|
|
209
|
+
return state.telegramMessageIds.length > 0;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
await this.syncMessages(state, payload, targetSignatures);
|
|
213
|
+
logger.debug(`[ResponseStreamer] Stream synced: session=${state.sessionId}, message=${state.messageId}, reason=${reason}, parts=${payload.parts.length}`);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
const retryAfterMs = getRetryAfterMs(error);
|
|
218
|
+
if (retryAfterMs === null) {
|
|
219
|
+
this.markStreamBroken(state, error, reason);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
const delayMs = Math.max(this.throttleMs, retryAfterMs);
|
|
223
|
+
logger.warn(`[ResponseStreamer] Stream sync rate-limited, retrying in ${delayMs}ms: session=${state.sessionId}, message=${state.messageId}, reason=${reason}`, error);
|
|
224
|
+
await delay(delayMs);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
markStreamBroken(state, error, reason) {
|
|
230
|
+
state.isBroken = true;
|
|
231
|
+
state.fatalErrorMessage = getErrorMessage(error);
|
|
232
|
+
if (state.fatalErrorLogged) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
state.fatalErrorLogged = true;
|
|
236
|
+
logger.error(`[ResponseStreamer] Stream marked as broken: session=${state.sessionId}, message=${state.messageId}, reason=${reason}, error=${state.fatalErrorMessage}`, error);
|
|
237
|
+
}
|
|
238
|
+
async cleanupBrokenStream(state, reason) {
|
|
239
|
+
if (state.telegramMessageIds.length === 0) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
for (let index = state.telegramMessageIds.length - 1; index >= 0; index--) {
|
|
243
|
+
const messageId = state.telegramMessageIds[index];
|
|
244
|
+
if (!messageId) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
await this.deleteText(messageId);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
logger.warn(`[ResponseStreamer] Failed to delete broken stream message: session=${state.sessionId}, message=${state.messageId}, telegramMessageId=${messageId}, reason=${reason}`, error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
state.telegramMessageIds = [];
|
|
255
|
+
state.lastSentSignatures = [];
|
|
256
|
+
logger.debug(`[ResponseStreamer] Cleaned up broken stream messages: session=${state.sessionId}, message=${state.messageId}, reason=${reason}`);
|
|
257
|
+
}
|
|
258
|
+
async syncMessages(state, payload, targetSignatures) {
|
|
259
|
+
for (let index = 0; index < payload.parts.length; index++) {
|
|
260
|
+
const text = payload.parts[index];
|
|
261
|
+
const nextSignature = targetSignatures[index];
|
|
262
|
+
const currentMessageId = state.telegramMessageIds[index];
|
|
263
|
+
if (currentMessageId) {
|
|
264
|
+
if (state.lastSentSignatures[index] === nextSignature) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
await this.editText(currentMessageId, text, payload.format, payload.editOptions);
|
|
268
|
+
state.lastSentSignatures[index] = nextSignature;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const messageId = await this.sendText(text, payload.format, payload.sendOptions);
|
|
272
|
+
state.telegramMessageIds[index] = messageId;
|
|
273
|
+
state.lastSentSignatures[index] = nextSignature;
|
|
274
|
+
}
|
|
275
|
+
for (let index = state.telegramMessageIds.length - 1; index >= payload.parts.length; index--) {
|
|
276
|
+
const messageId = state.telegramMessageIds[index];
|
|
277
|
+
if (messageId) {
|
|
278
|
+
await this.deleteText(messageId);
|
|
279
|
+
}
|
|
280
|
+
state.telegramMessageIds.pop();
|
|
281
|
+
state.lastSentSignatures.pop();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
2
|
+
import { t } from "../../i18n/index.js";
|
|
3
|
+
export function isForegroundBusy() {
|
|
4
|
+
return foregroundSessionState.isBusy();
|
|
5
|
+
}
|
|
6
|
+
export async function replyBusyBlocked(ctx) {
|
|
7
|
+
const message = t("interaction.blocked.finish_current");
|
|
8
|
+
if (ctx.callbackQuery) {
|
|
9
|
+
await ctx.answerCallbackQuery({ text: message }).catch(() => { });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (ctx.chat) {
|
|
13
|
+
await ctx.reply(message).catch(() => { });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export async function finalizeAssistantResponse({ responseStreaming, sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, resolveFormat, getReplyKeyboard, sendText, }) {
|
|
2
|
+
let streamedViaMessages = false;
|
|
3
|
+
if (responseStreaming) {
|
|
4
|
+
const preparedStreamPayload = prepareStreamingPayload(messageText);
|
|
5
|
+
if (preparedStreamPayload) {
|
|
6
|
+
preparedStreamPayload.sendOptions = undefined;
|
|
7
|
+
preparedStreamPayload.editOptions = undefined;
|
|
8
|
+
}
|
|
9
|
+
streamedViaMessages = await responseStreamer.complete(sessionId, messageId, preparedStreamPayload ?? undefined);
|
|
10
|
+
}
|
|
11
|
+
await flushPendingServiceMessages();
|
|
12
|
+
if (streamedViaMessages) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
const parts = formatSummary(messageText);
|
|
16
|
+
const format = resolveFormat();
|
|
17
|
+
for (const part of parts) {
|
|
18
|
+
const keyboard = getReplyKeyboard();
|
|
19
|
+
const options = keyboard ? { reply_markup: keyboard } : undefined;
|
|
20
|
+
await sendText(part, options, format);
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
@@ -38,41 +38,39 @@ export function isTelegramMarkdownParseError(error) {
|
|
|
38
38
|
}
|
|
39
39
|
export async function sendMessageWithMarkdownFallback({ api, chatId, text, options, parseMode, }) {
|
|
40
40
|
if (!parseMode) {
|
|
41
|
-
|
|
42
|
-
return;
|
|
41
|
+
return api.sendMessage(chatId, text, options);
|
|
43
42
|
}
|
|
44
43
|
const markdownOptions = {
|
|
45
44
|
...(options || {}),
|
|
46
45
|
parse_mode: parseMode,
|
|
47
46
|
};
|
|
48
47
|
try {
|
|
49
|
-
await api.sendMessage(chatId, text, markdownOptions);
|
|
48
|
+
return await api.sendMessage(chatId, text, markdownOptions);
|
|
50
49
|
}
|
|
51
50
|
catch (error) {
|
|
52
51
|
if (!isTelegramMarkdownParseError(error)) {
|
|
53
52
|
throw error;
|
|
54
53
|
}
|
|
55
54
|
logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
|
|
56
|
-
|
|
55
|
+
return api.sendMessage(chatId, text, options);
|
|
57
56
|
}
|
|
58
57
|
}
|
|
59
58
|
export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, options, parseMode, }) {
|
|
60
59
|
if (!parseMode) {
|
|
61
|
-
|
|
62
|
-
return;
|
|
60
|
+
return api.editMessageText(chatId, messageId, text, options);
|
|
63
61
|
}
|
|
64
62
|
const markdownOptions = {
|
|
65
63
|
...(options || {}),
|
|
66
64
|
parse_mode: parseMode,
|
|
67
65
|
};
|
|
68
66
|
try {
|
|
69
|
-
await api.editMessageText(chatId, messageId, text, markdownOptions);
|
|
67
|
+
return await api.editMessageText(chatId, messageId, text, markdownOptions);
|
|
70
68
|
}
|
|
71
69
|
catch (error) {
|
|
72
70
|
if (!isTelegramMarkdownParseError(error)) {
|
|
73
71
|
throw error;
|
|
74
72
|
}
|
|
75
73
|
logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
|
|
76
|
-
|
|
74
|
+
return api.editMessageText(chatId, messageId, text, options);
|
|
77
75
|
}
|
|
78
76
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { t } from "../../i18n/index.js";
|
|
2
|
+
export function deliverThinkingMessage(sessionId, batcher, options) {
|
|
3
|
+
if (options.hideThinkingMessages) {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
const message = t("bot.thinking");
|
|
7
|
+
if (options.responseStreaming) {
|
|
8
|
+
batcher.sendTextNow(sessionId, message, "thinking_started_streaming");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
batcher.enqueue(sessionId, message);
|
|
12
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -85,11 +85,13 @@ export const config = {
|
|
|
85
85
|
bot: {
|
|
86
86
|
sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
|
|
87
87
|
projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
|
|
88
|
+
commandsListLimit: getOptionalPositiveIntEnvVar("COMMANDS_LIST_LIMIT", 10),
|
|
88
89
|
taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
|
|
89
90
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
90
91
|
serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
|
|
91
92
|
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
|
|
92
93
|
hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
|
|
94
|
+
responseStreaming: getOptionalBooleanEnvVar("RESPONSE_STREAMING", true),
|
|
93
95
|
messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
|
|
94
96
|
},
|
|
95
97
|
files: {
|
package/dist/i18n/de.js
CHANGED
|
@@ -298,6 +298,11 @@ export const de = {
|
|
|
298
298
|
"commands.executing_prefix": "⚡ Befehl wird ausgeführt:",
|
|
299
299
|
"commands.arguments_empty": "⚠️ Argumente dürfen nicht leer sein. Sende Text oder tippe auf Ausführen.",
|
|
300
300
|
"commands.execute_error": "🔴 OpenCode-Befehl konnte nicht ausgeführt werden.",
|
|
301
|
+
"commands.select_page": "Wähle einen OpenCode-Befehl (Seite {page}):",
|
|
302
|
+
"commands.button.prev_page": "⬅️ Zurück",
|
|
303
|
+
"commands.button.next_page": "Weiter ➡️",
|
|
304
|
+
"commands.page_empty_callback": "Keine Befehle auf dieser Seite",
|
|
305
|
+
"commands.page_load_error_callback": "Diese Seite konnte nicht geladen werden. Bitte versuche es erneut.",
|
|
301
306
|
"cmd.description.rename": "Aktuelle Sitzung umbenennen",
|
|
302
307
|
"cli.usage": "Verwendung:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nHinweise:\n - Ohne Befehl wird standardmäßig `start` verwendet\n - `--mode` wird derzeit nur für `start` unterstützt",
|
|
303
308
|
"cli.placeholder.status": "Befehl `status` ist derzeit ein Platzhalter. Echte Statusprüfungen werden in der Service-Schicht hinzugefügt (Phase 5).",
|
package/dist/i18n/en.js
CHANGED
|
@@ -298,6 +298,11 @@ export const en = {
|
|
|
298
298
|
"commands.executing_prefix": "⚡ Executing command:",
|
|
299
299
|
"commands.arguments_empty": "⚠️ Arguments cannot be empty. Send text or tap Execute.",
|
|
300
300
|
"commands.execute_error": "🔴 Failed to execute OpenCode command.",
|
|
301
|
+
"commands.select_page": "Choose an OpenCode command (page {page}):",
|
|
302
|
+
"commands.button.prev_page": "⬅️ Prev",
|
|
303
|
+
"commands.button.next_page": "Next ➡️",
|
|
304
|
+
"commands.page_empty_callback": "No commands on this page",
|
|
305
|
+
"commands.page_load_error_callback": "Cannot load this page. Please try again.",
|
|
301
306
|
"cmd.description.rename": "Rename current session",
|
|
302
307
|
"cli.usage": "Usage:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nNotes:\n - No command defaults to `start`\n - `--mode` is currently supported for `start` only",
|
|
303
308
|
"cli.placeholder.status": "Command `status` is currently a placeholder. Real status checks will be added in service layer (Phase 5).",
|
package/dist/i18n/es.js
CHANGED
|
@@ -298,6 +298,11 @@ export const es = {
|
|
|
298
298
|
"commands.executing_prefix": "⚡ Ejecutando comando:",
|
|
299
299
|
"commands.arguments_empty": "⚠️ Los argumentos no pueden estar vacíos. Envía texto o toca Ejecutar.",
|
|
300
300
|
"commands.execute_error": "🔴 No se pudo ejecutar el comando de OpenCode.",
|
|
301
|
+
"commands.select_page": "Elige un comando de OpenCode (página {page}):",
|
|
302
|
+
"commands.button.prev_page": "⬅️ Anterior",
|
|
303
|
+
"commands.button.next_page": "Siguiente ➡️",
|
|
304
|
+
"commands.page_empty_callback": "No hay comandos en esta página",
|
|
305
|
+
"commands.page_load_error_callback": "No se pudo cargar esta página. Por favor, inténtalo de nuevo.",
|
|
301
306
|
"cmd.description.rename": "Renombrar la sesión actual",
|
|
302
307
|
"cli.usage": "Uso:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nNotas:\n - Sin comando, el valor por defecto es `start`\n - `--mode` actualmente solo se admite para `start`",
|
|
303
308
|
"cli.placeholder.status": "El comando `status` es actualmente un marcador de posición. Las comprobaciones reales de estado se agregarán en la capa de servicio (Fase 5).",
|
package/dist/i18n/fr.js
CHANGED
|
@@ -298,6 +298,11 @@ export const fr = {
|
|
|
298
298
|
"commands.executing_prefix": "⚡ Exécution de la commande :",
|
|
299
299
|
"commands.arguments_empty": "⚠️ Les arguments ne peuvent pas être vides. Envoyez du texte ou appuyez sur Exécuter.",
|
|
300
300
|
"commands.execute_error": "🔴 Impossible d'exécuter la commande OpenCode.",
|
|
301
|
+
"commands.select_page": "Choisissez une commande OpenCode (page {page}) :",
|
|
302
|
+
"commands.button.prev_page": "⬅️ Précédent",
|
|
303
|
+
"commands.button.next_page": "Suivant ➡️",
|
|
304
|
+
"commands.page_empty_callback": "Aucune commande sur cette page",
|
|
305
|
+
"commands.page_load_error_callback": "Impossible de charger cette page. Veuillez réessayer.",
|
|
301
306
|
"cmd.description.rename": "Renommer la session actuelle",
|
|
302
307
|
"cli.usage": "Utilisation :\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nNotes :\n - Sans commande, `start` est utilisé par défaut\n - `--mode` n'est actuellement pris en charge que pour `start`",
|
|
303
308
|
"cli.placeholder.status": "La commande `status` est actuellement un placeholder. Les vraies vérifications d'état seront ajoutées dans la couche service (Phase 5).",
|
package/dist/i18n/ru.js
CHANGED
|
@@ -298,6 +298,11 @@ export const ru = {
|
|
|
298
298
|
"commands.executing_prefix": "⚡ Выполнение команды:",
|
|
299
299
|
"commands.arguments_empty": "⚠️ Аргументы не могут быть пустыми. Отправьте текст или нажмите Выполнить.",
|
|
300
300
|
"commands.execute_error": "🔴 Не удалось выполнить команду OpenCode.",
|
|
301
|
+
"commands.select_page": "Выберите команду OpenCode (страница {page}):",
|
|
302
|
+
"commands.button.prev_page": "⬅️ Назад",
|
|
303
|
+
"commands.button.next_page": "Вперёд ➡️",
|
|
304
|
+
"commands.page_empty_callback": "На этой странице нет команд",
|
|
305
|
+
"commands.page_load_error_callback": "Не удалось загрузить эту страницу. Пожалуйста, попробуйте снова.",
|
|
301
306
|
"cmd.description.rename": "Переименовать текущую сессию",
|
|
302
307
|
"cli.usage": "Использование:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nЗаметки:\n - Без команды по умолчанию используется `start`\n - `--mode` сейчас поддерживается только для `start`",
|
|
303
308
|
"cli.placeholder.status": "Команда `status` пока работает как заглушка. Реальная проверка статуса появится на этапе service-слоя (Этап 5).",
|
package/dist/i18n/zh.js
CHANGED
|
@@ -298,10 +298,15 @@ export const zh = {
|
|
|
298
298
|
"commands.executing_prefix": "⚡ 执行命令:",
|
|
299
299
|
"commands.arguments_empty": "⚠️ 参数不能为空。请发送文本或点击执行。",
|
|
300
300
|
"commands.execute_error": "🔴 执行 OpenCode 命令失败。",
|
|
301
|
+
"commands.select_page": "请选择一个 OpenCode 命令(第 {page} 页):",
|
|
302
|
+
"commands.button.prev_page": "⬅️ 上一页",
|
|
303
|
+
"commands.button.next_page": "下一页 ➡️",
|
|
304
|
+
"commands.page_empty_callback": "这一页没有命令",
|
|
305
|
+
"commands.page_load_error_callback": "无法加载此页面。请重试。",
|
|
301
306
|
"cmd.description.rename": "重命名当前会话",
|
|
302
|
-
"cli.usage": "
|
|
303
|
-
"cli.placeholder.status": "
|
|
304
|
-
"cli.placeholder.stop": "
|
|
307
|
+
"cli.usage": "用法:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\n注意:\n - 无命令时默认为 `start`\n - `--mode` 当前仅支持 `start`",
|
|
308
|
+
"cli.placeholder.status": "`status` 命令当前为占位符。实际状态检查将在服务层中添加(第5阶段)。",
|
|
309
|
+
"cli.placeholder.stop": "`stop` 命令当前为占位符。实际后台进程停止功能将在服务层中添加(第5阶段)。",
|
|
305
310
|
"cli.placeholder.unavailable": "命令不可用。",
|
|
306
311
|
"cli.error.prefix": "CLI 错误:{message}",
|
|
307
312
|
"cli.args.unknown_command": "未知命令:{value}",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const BUSY_ALLOWED_COMMANDS = ["/abort", "/status", "/help"];
|
|
2
|
+
const BUSY_ALLOWED_COMMAND_SET = new Set(BUSY_ALLOWED_COMMANDS);
|
|
3
|
+
export function isBusyAllowedCommand(command) {
|
|
4
|
+
return Boolean(command && BUSY_ALLOWED_COMMAND_SET.has(command));
|
|
5
|
+
}
|
|
6
|
+
export function allowsBusyInteraction(kind) {
|
|
7
|
+
return kind === "question" || kind === "permission";
|
|
8
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { interactionManager } from "./manager.js";
|
|
2
|
+
import { allowsBusyInteraction, isBusyAllowedCommand } from "./busy.js";
|
|
3
|
+
import { foregroundSessionState } from "../scheduled-task/foreground-state.js";
|
|
2
4
|
function normalizeIncomingCommand(text) {
|
|
3
5
|
const trimmed = text.trim();
|
|
4
6
|
if (!trimmed.startsWith("/")) {
|
|
@@ -40,21 +42,33 @@ function getExpectedInputBlockReason(expectedInput) {
|
|
|
40
42
|
return "expected_text";
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
|
-
function createAllowDecision(inputType, state, command) {
|
|
45
|
+
function createAllowDecision(inputType, state, command, busy) {
|
|
44
46
|
return {
|
|
45
47
|
allow: true,
|
|
46
48
|
inputType,
|
|
47
49
|
state,
|
|
48
50
|
command,
|
|
51
|
+
busy,
|
|
49
52
|
};
|
|
50
53
|
}
|
|
51
|
-
function createBlockDecision(inputType, state, reason, command) {
|
|
54
|
+
function createBlockDecision(inputType, state, reason, command, busy) {
|
|
52
55
|
return {
|
|
53
56
|
allow: false,
|
|
54
57
|
inputType,
|
|
55
58
|
state,
|
|
56
59
|
reason,
|
|
57
60
|
command,
|
|
61
|
+
busy,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function createBusyBlockDecision(inputType, state, reason, command) {
|
|
65
|
+
return {
|
|
66
|
+
allow: false,
|
|
67
|
+
inputType,
|
|
68
|
+
state,
|
|
69
|
+
reason,
|
|
70
|
+
command,
|
|
71
|
+
busy: true,
|
|
58
72
|
};
|
|
59
73
|
}
|
|
60
74
|
function isAllowedRenameCancelCallback(ctx, state) {
|
|
@@ -69,13 +83,35 @@ function isAllowedTaskCallback(ctx, state) {
|
|
|
69
83
|
export function resolveInteractionGuardDecision(ctx) {
|
|
70
84
|
const state = interactionManager.getSnapshot();
|
|
71
85
|
const { inputType, command } = classifyIncomingInput(ctx);
|
|
86
|
+
const isBusy = foregroundSessionState.isBusy();
|
|
87
|
+
if (state && interactionManager.isExpired()) {
|
|
88
|
+
interactionManager.clear("expired");
|
|
89
|
+
return createBlockDecision(inputType, state, "expired", command, isBusy);
|
|
90
|
+
}
|
|
91
|
+
if (isBusy) {
|
|
92
|
+
if (inputType === "command") {
|
|
93
|
+
if (isBusyAllowedCommand(command)) {
|
|
94
|
+
return createAllowDecision(inputType, state, command, true);
|
|
95
|
+
}
|
|
96
|
+
return createBusyBlockDecision(inputType, state, "command_not_allowed", command);
|
|
97
|
+
}
|
|
98
|
+
if (state && allowsBusyInteraction(state.kind)) {
|
|
99
|
+
if (state.expectedInput === "mixed") {
|
|
100
|
+
if (inputType === "callback" || inputType === "text") {
|
|
101
|
+
return createAllowDecision(inputType, state, command, true);
|
|
102
|
+
}
|
|
103
|
+
return createBusyBlockDecision(inputType, state, "expected_text", command);
|
|
104
|
+
}
|
|
105
|
+
if (state.expectedInput === inputType) {
|
|
106
|
+
return createAllowDecision(inputType, state, command, true);
|
|
107
|
+
}
|
|
108
|
+
return createBusyBlockDecision(inputType, state, getExpectedInputBlockReason(state.expectedInput), command);
|
|
109
|
+
}
|
|
110
|
+
return createBusyBlockDecision(inputType, state, "expected_text", command);
|
|
111
|
+
}
|
|
72
112
|
if (!state) {
|
|
73
113
|
return createAllowDecision(inputType, null, command);
|
|
74
114
|
}
|
|
75
|
-
if (interactionManager.isExpired()) {
|
|
76
|
-
interactionManager.clear("expired");
|
|
77
|
-
return createBlockDecision(inputType, state, "expired", command);
|
|
78
|
-
}
|
|
79
115
|
if (inputType === "command") {
|
|
80
116
|
if (command === "/start") {
|
|
81
117
|
return createAllowDecision(inputType, state, command);
|
package/dist/pinned/manager.js
CHANGED
|
@@ -86,10 +86,10 @@ class PinnedMessageManager {
|
|
|
86
86
|
logger.warn("[PinnedManager] Failed to load session history:", error);
|
|
87
87
|
return;
|
|
88
88
|
}
|
|
89
|
-
// Get the maximum context size and cost from session history
|
|
89
|
+
// Get the maximum context size and total cost from session history
|
|
90
90
|
// Context = input + cache.read (cache.read contains previously cached context)
|
|
91
91
|
let maxContextSize = 0;
|
|
92
|
-
let
|
|
92
|
+
let totalCost = 0;
|
|
93
93
|
logger.debug(`[PinnedManager] Processing ${messagesData.length} messages from history`);
|
|
94
94
|
messagesData.forEach(({ info }) => {
|
|
95
95
|
if (info.role === "assistant") {
|
|
@@ -108,14 +108,12 @@ class PinnedMessageManager {
|
|
|
108
108
|
if (contextSize > maxContextSize) {
|
|
109
109
|
maxContextSize = contextSize;
|
|
110
110
|
}
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
maxCost = cost;
|
|
114
|
-
}
|
|
111
|
+
// Accumulate total session cost
|
|
112
|
+
totalCost += cost;
|
|
115
113
|
}
|
|
116
114
|
});
|
|
117
115
|
this.state.tokensUsed = maxContextSize;
|
|
118
|
-
this.state.cost =
|
|
116
|
+
this.state.cost = totalCost;
|
|
119
117
|
this.state.sessionId = sessionId;
|
|
120
118
|
logger.info(`[PinnedManager] Loaded context from history: ${this.state.tokensUsed} tokens, cost: $${this.state.cost.toFixed(2)}`);
|
|
121
119
|
await this.updatePinnedMessage();
|
|
@@ -153,8 +151,9 @@ class PinnedMessageManager {
|
|
|
153
151
|
* Called when cost info is received from SSE events
|
|
154
152
|
*/
|
|
155
153
|
async onCostUpdate(cost) {
|
|
156
|
-
this.state.cost
|
|
157
|
-
|
|
154
|
+
const currentCost = this.state.cost || 0;
|
|
155
|
+
this.state.cost = currentCost + cost;
|
|
156
|
+
logger.debug(`[PinnedManager] Cost added: $${cost.toFixed(2)}, total session: $${(this.state.cost || 0).toFixed(2)}`);
|
|
158
157
|
await this.updatePinnedMessage();
|
|
159
158
|
}
|
|
160
159
|
/**
|