@grinev/opencode-telegram-bot 0.12.0 → 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 +150 -22
- 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 +6 -0
- package/dist/i18n/en.js +6 -0
- package/dist/i18n/es.js +6 -0
- package/dist/i18n/fr.js +6 -0
- package/dist/i18n/ru.js +6 -0
- package/dist/i18n/zh.js +9 -3
- package/dist/interaction/busy.js +8 -0
- package/dist/interaction/guard.js +42 -6
- package/dist/pinned/manager.js +22 -3
- package/dist/summary/aggregator.js +195 -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
|
@@ -216,6 +216,7 @@ export const de = {
|
|
|
216
216
|
"pinned.line.project": "Projekt: {project}",
|
|
217
217
|
"pinned.line.model": "Modell: {model}",
|
|
218
218
|
"pinned.line.context": "Kontext: {used} / {limit} ({percent}%)",
|
|
219
|
+
"pinned.line.cost": "Kosten: {cost} ausgegeben",
|
|
219
220
|
"pinned.files.title": "Dateien ({count}):",
|
|
220
221
|
"pinned.files.item": " {path}{diff}",
|
|
221
222
|
"pinned.files.more": " ... und {count} mehr",
|
|
@@ -297,6 +298,11 @@ export const de = {
|
|
|
297
298
|
"commands.executing_prefix": "⚡ Befehl wird ausgeführt:",
|
|
298
299
|
"commands.arguments_empty": "⚠️ Argumente dürfen nicht leer sein. Sende Text oder tippe auf Ausführen.",
|
|
299
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.",
|
|
300
306
|
"cmd.description.rename": "Aktuelle Sitzung umbenennen",
|
|
301
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",
|
|
302
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
|
@@ -216,6 +216,7 @@ export const en = {
|
|
|
216
216
|
"pinned.line.project": "Project: {project}",
|
|
217
217
|
"pinned.line.model": "Model: {model}",
|
|
218
218
|
"pinned.line.context": "Context: {used} / {limit} ({percent}%)",
|
|
219
|
+
"pinned.line.cost": "Cost: {cost} spent",
|
|
219
220
|
"pinned.files.title": "Files ({count}):",
|
|
220
221
|
"pinned.files.item": " {path}{diff}",
|
|
221
222
|
"pinned.files.more": " ... and {count} more",
|
|
@@ -297,6 +298,11 @@ export const en = {
|
|
|
297
298
|
"commands.executing_prefix": "⚡ Executing command:",
|
|
298
299
|
"commands.arguments_empty": "⚠️ Arguments cannot be empty. Send text or tap Execute.",
|
|
299
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.",
|
|
300
306
|
"cmd.description.rename": "Rename current session",
|
|
301
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",
|
|
302
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
|
@@ -216,6 +216,7 @@ export const es = {
|
|
|
216
216
|
"pinned.line.project": "Proyecto: {project}",
|
|
217
217
|
"pinned.line.model": "Modelo: {model}",
|
|
218
218
|
"pinned.line.context": "Contexto: {used} / {limit} ({percent}%)",
|
|
219
|
+
"pinned.line.cost": "Costo: {cost} gastado",
|
|
219
220
|
"pinned.files.title": "Archivos ({count}):",
|
|
220
221
|
"pinned.files.item": " {path}{diff}",
|
|
221
222
|
"pinned.files.more": " ... y {count} más",
|
|
@@ -297,6 +298,11 @@ export const es = {
|
|
|
297
298
|
"commands.executing_prefix": "⚡ Ejecutando comando:",
|
|
298
299
|
"commands.arguments_empty": "⚠️ Los argumentos no pueden estar vacíos. Envía texto o toca Ejecutar.",
|
|
299
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.",
|
|
300
306
|
"cmd.description.rename": "Renombrar la sesión actual",
|
|
301
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`",
|
|
302
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
|
@@ -216,6 +216,7 @@ export const fr = {
|
|
|
216
216
|
"pinned.line.project": "Projet : {project}",
|
|
217
217
|
"pinned.line.model": "Modèle : {model}",
|
|
218
218
|
"pinned.line.context": "Contexte : {used} / {limit} ({percent}%)",
|
|
219
|
+
"pinned.line.cost": "Coût : {cost} dépensé",
|
|
219
220
|
"pinned.files.title": "Fichiers ({count}) :",
|
|
220
221
|
"pinned.files.item": " {path}{diff}",
|
|
221
222
|
"pinned.files.more": " ... et encore {count}",
|
|
@@ -297,6 +298,11 @@ export const fr = {
|
|
|
297
298
|
"commands.executing_prefix": "⚡ Exécution de la commande :",
|
|
298
299
|
"commands.arguments_empty": "⚠️ Les arguments ne peuvent pas être vides. Envoyez du texte ou appuyez sur Exécuter.",
|
|
299
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.",
|
|
300
306
|
"cmd.description.rename": "Renommer la session actuelle",
|
|
301
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`",
|
|
302
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
|
@@ -216,6 +216,7 @@ export const ru = {
|
|
|
216
216
|
"pinned.line.project": "Проект: {project}",
|
|
217
217
|
"pinned.line.model": "Модель: {model}",
|
|
218
218
|
"pinned.line.context": "Контекст: {used} / {limit} ({percent}%)",
|
|
219
|
+
"pinned.line.cost": "Стоимость: {cost} потрачено",
|
|
219
220
|
"pinned.files.title": "Файлы ({count}):",
|
|
220
221
|
"pinned.files.item": " {path}{diff}",
|
|
221
222
|
"pinned.files.more": " ... и еще {count}",
|
|
@@ -297,6 +298,11 @@ export const ru = {
|
|
|
297
298
|
"commands.executing_prefix": "⚡ Выполнение команды:",
|
|
298
299
|
"commands.arguments_empty": "⚠️ Аргументы не могут быть пустыми. Отправьте текст или нажмите Выполнить.",
|
|
299
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": "Не удалось загрузить эту страницу. Пожалуйста, попробуйте снова.",
|
|
300
306
|
"cmd.description.rename": "Переименовать текущую сессию",
|
|
301
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`",
|
|
302
308
|
"cli.placeholder.status": "Команда `status` пока работает как заглушка. Реальная проверка статуса появится на этапе service-слоя (Этап 5).",
|
package/dist/i18n/zh.js
CHANGED
|
@@ -216,6 +216,7 @@ export const zh = {
|
|
|
216
216
|
"pinned.line.project": "项目: {project}",
|
|
217
217
|
"pinned.line.model": "模型: {model}",
|
|
218
218
|
"pinned.line.context": "上下文: {used} / {limit} ({percent}%)",
|
|
219
|
+
"pinned.line.cost": "费用: {cost}",
|
|
219
220
|
"pinned.files.title": "文件({count}):",
|
|
220
221
|
"pinned.files.item": " {path}{diff}",
|
|
221
222
|
"pinned.files.more": " ... 还有 {count} 个",
|
|
@@ -297,10 +298,15 @@ export const zh = {
|
|
|
297
298
|
"commands.executing_prefix": "⚡ 执行命令:",
|
|
298
299
|
"commands.arguments_empty": "⚠️ 参数不能为空。请发送文本或点击执行。",
|
|
299
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": "无法加载此页面。请重试。",
|
|
300
306
|
"cmd.description.rename": "重命名当前会话",
|
|
301
|
-
"cli.usage": "
|
|
302
|
-
"cli.placeholder.status": "
|
|
303
|
-
"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阶段)。",
|
|
304
310
|
"cli.placeholder.unavailable": "命令不可用。",
|
|
305
311
|
"cli.error.prefix": "CLI 错误:{message}",
|
|
306
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);
|