@grinev/opencode-telegram-bot 0.15.0 → 0.16.1
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 +16 -0
- package/README.md +22 -1
- package/dist/app/start-bot-app.js +82 -7
- package/dist/bot/assistant-run-state.js +60 -0
- package/dist/bot/commands/abort.js +2 -0
- package/dist/bot/commands/commands.js +10 -0
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/open.js +314 -0
- package/dist/bot/commands/projects.js +5 -38
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/handlers/inline-menu.js +9 -1
- package/dist/bot/handlers/prompt.js +11 -0
- package/dist/bot/index.js +187 -100
- package/dist/bot/streaming/response-streamer.js +26 -17
- package/dist/bot/utils/assistant-rendering.js +117 -0
- package/dist/bot/utils/assistant-run-footer.js +9 -0
- package/dist/bot/utils/browser-roots.js +140 -0
- package/dist/bot/utils/file-tree.js +92 -0
- package/dist/bot/utils/finalize-assistant-response.js +18 -24
- package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
- package/dist/bot/utils/switch-project.js +48 -0
- package/dist/bot/utils/telegram-text.js +95 -1
- package/dist/cli/args.js +36 -7
- package/dist/cli.js +133 -15
- package/dist/config.js +4 -0
- package/dist/i18n/de.js +15 -10
- package/dist/i18n/en.js +15 -10
- package/dist/i18n/es.js +15 -10
- package/dist/i18n/fr.js +15 -10
- package/dist/i18n/ru.js +15 -10
- package/dist/i18n/zh.js +15 -10
- package/dist/index.js +2 -0
- package/dist/project/manager.js +2 -1
- package/dist/scheduled-task/runtime.js +8 -0
- package/dist/service/manager.js +244 -0
- package/dist/service/runtime.js +19 -0
- package/dist/service/types.js +1 -0
- package/dist/summary/aggregator.js +17 -1
- package/dist/summary/formatter.js +2 -88
- package/dist/telegram/render/block-fallback.js +28 -0
- package/dist/telegram/render/block-parser.js +295 -0
- package/dist/telegram/render/block-renderer.js +457 -0
- package/dist/telegram/render/chunker.js +281 -0
- package/dist/telegram/render/inline-renderer.js +128 -0
- package/dist/telegram/render/markdown-normalizer.js +94 -0
- package/dist/telegram/render/pipeline.js +9 -0
- package/dist/telegram/render/types.js +1 -0
- package/dist/telegram/render/validator.js +160 -0
- package/dist/utils/logger.js +200 -73
- package/package.json +6 -2
package/dist/i18n/zh.js
CHANGED
|
@@ -324,16 +324,6 @@ export const zh = {
|
|
|
324
324
|
"commands.page_empty_callback": "这一页没有命令",
|
|
325
325
|
"commands.page_load_error_callback": "无法加载此页面。请重试。",
|
|
326
326
|
"cmd.description.rename": "重命名当前会话",
|
|
327
|
-
"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`",
|
|
328
|
-
"cli.placeholder.status": "`status` 命令当前为占位符。实际状态检查将在服务层中添加(第5阶段)。",
|
|
329
|
-
"cli.placeholder.stop": "`stop` 命令当前为占位符。实际后台进程停止功能将在服务层中添加(第5阶段)。",
|
|
330
|
-
"cli.placeholder.unavailable": "命令不可用。",
|
|
331
|
-
"cli.error.prefix": "CLI 错误:{message}",
|
|
332
|
-
"cli.args.unknown_command": "未知命令:{value}",
|
|
333
|
-
"cli.args.mode_requires_value": "选项 --mode 需要一个值:sources|installed",
|
|
334
|
-
"cli.args.invalid_mode": "无效的 --mode 值:{value}。期望 sources|installed",
|
|
335
|
-
"cli.args.unknown_option": "未知选项:{value}",
|
|
336
|
-
"cli.args.mode_only_start": "选项 --mode 仅支持 start 命令",
|
|
337
327
|
"legacy.models.fetch_error": "🔴 获取模型列表失败。请使用 /status 检查服务器状态。",
|
|
338
328
|
"legacy.models.empty": "📋 没有可用模型。请在 OpenCode 中配置 providers。",
|
|
339
329
|
"legacy.models.header": "📋 可用模型:\n\n",
|
|
@@ -345,4 +335,19 @@ export const zh = {
|
|
|
345
335
|
"stt.not_configured": "🎤 语音识别尚未配置。\n\n在 .env 中设置 STT_API_URL 和 STT_API_KEY 以启用。",
|
|
346
336
|
"stt.error": "🔴 识别音频失败:{error}",
|
|
347
337
|
"stt.empty_result": "🎤 音频消息中未检测到语音。",
|
|
338
|
+
"cmd.description.open": "通过浏览目录添加项目",
|
|
339
|
+
"open.back": "⬆️ 上级",
|
|
340
|
+
"open.roots": "📋 返回根目录",
|
|
341
|
+
"open.prev_page": "⬅️ 上一页",
|
|
342
|
+
"open.next_page": "下一页 ➡️",
|
|
343
|
+
"open.select_current": "✅ 选择此文件夹",
|
|
344
|
+
"open.select_root": "📂 选择要浏览的根目录:",
|
|
345
|
+
"open.access_denied": "⛔ 访问被拒绝:路径超出允许的目录范围",
|
|
346
|
+
"open.scan_error": "🔴 无法浏览目录:{error}",
|
|
347
|
+
"open.open_error": "🔴 无法打开目录浏览器。",
|
|
348
|
+
"open.selected": "✅ 项目已添加:{project}\n\n📋 使用 /sessions 或 /new 开始工作。",
|
|
349
|
+
"open.select_error": "🔴 添加项目失败。",
|
|
350
|
+
"open.no_subfolders": "📭 无子文件夹",
|
|
351
|
+
"open.subfolder_count": "{count} 个子文件夹",
|
|
352
|
+
"open.subfolders_count": "{count} 个子文件夹",
|
|
348
353
|
};
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@ async function main() {
|
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
14
|
setRuntimeMode(modeResult.mode);
|
|
15
|
+
const { initializeLogger } = await import("./utils/logger.js");
|
|
16
|
+
await initializeLogger();
|
|
15
17
|
const { startBotApp } = await import("./app/start-bot-app.js");
|
|
16
18
|
await startBotApp();
|
|
17
19
|
}
|
package/dist/project/manager.js
CHANGED
|
@@ -80,7 +80,8 @@ export async function getProjectById(id) {
|
|
|
80
80
|
}
|
|
81
81
|
export async function getProjectByWorktree(worktree) {
|
|
82
82
|
const projects = await getProjects();
|
|
83
|
-
const
|
|
83
|
+
const key = worktreeKey(worktree);
|
|
84
|
+
const project = projects.find((p) => worktreeKey(p.worktree) === key);
|
|
84
85
|
if (!project) {
|
|
85
86
|
throw new Error(`Project with worktree ${worktree} not found`);
|
|
86
87
|
}
|
|
@@ -122,6 +122,14 @@ export class ScheduledTaskRuntime {
|
|
|
122
122
|
this.flushInProgress = false;
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
shutdown() {
|
|
126
|
+
for (const timer of this.timersByTaskId.values()) {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
this.timersByTaskId.clear();
|
|
130
|
+
this.runningTaskIds.clear();
|
|
131
|
+
this.initialized = false;
|
|
132
|
+
}
|
|
125
133
|
__resetForTests() {
|
|
126
134
|
for (const timer of this.timersByTaskId.values()) {
|
|
127
135
|
clearTimeout(timer);
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsPromises from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { exec, spawn } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { getRuntimePaths } from "../runtime/paths.js";
|
|
7
|
+
import { buildServiceChildEnv } from "./runtime.js";
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const SERVICE_STATE_FILE_NAME = "bot-service.json";
|
|
10
|
+
const PROCESS_EXIT_POLL_MS = 100;
|
|
11
|
+
const DEFAULT_STOP_TIMEOUT_MS = 5000;
|
|
12
|
+
function sanitizeTimestampForFile(timestamp) {
|
|
13
|
+
return timestamp.replace(/:/g, "-").replace("T", "_");
|
|
14
|
+
}
|
|
15
|
+
function createServiceLogFilePath(logsDirPath) {
|
|
16
|
+
const timestamp = sanitizeTimestampForFile(new Date().toISOString().slice(0, 19));
|
|
17
|
+
return path.join(logsDirPath, `bot-service-${timestamp}.log`);
|
|
18
|
+
}
|
|
19
|
+
function isValidServiceState(value) {
|
|
20
|
+
if (!value || typeof value !== "object") {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const candidate = value;
|
|
24
|
+
return (typeof candidate.pid === "number" &&
|
|
25
|
+
Number.isInteger(candidate.pid) &&
|
|
26
|
+
candidate.pid > 0 &&
|
|
27
|
+
typeof candidate.startedAt === "string" &&
|
|
28
|
+
candidate.startedAt.length > 0 &&
|
|
29
|
+
typeof candidate.logFilePath === "string" &&
|
|
30
|
+
candidate.logFilePath.length > 0 &&
|
|
31
|
+
candidate.mode === "daemon");
|
|
32
|
+
}
|
|
33
|
+
async function writeFileAtomically(filePath, content) {
|
|
34
|
+
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
|
35
|
+
const tempFilePath = `${filePath}.${process.pid}.tmp`;
|
|
36
|
+
await fsPromises.writeFile(tempFilePath, content, "utf-8");
|
|
37
|
+
await fsPromises.rename(tempFilePath, filePath);
|
|
38
|
+
}
|
|
39
|
+
async function readServiceStateFile(filePath) {
|
|
40
|
+
try {
|
|
41
|
+
const content = await fsPromises.readFile(filePath, "utf-8");
|
|
42
|
+
const parsed = JSON.parse(content);
|
|
43
|
+
if (!isValidServiceState(parsed)) {
|
|
44
|
+
await clearServiceStateFile(filePath);
|
|
45
|
+
return { service: null, cleanupReason: "invalid" };
|
|
46
|
+
}
|
|
47
|
+
return { service: parsed, cleanupReason: null };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code === "ENOENT") {
|
|
51
|
+
return { service: null, cleanupReason: null };
|
|
52
|
+
}
|
|
53
|
+
if (error instanceof SyntaxError) {
|
|
54
|
+
await clearServiceStateFile(filePath);
|
|
55
|
+
return { service: null, cleanupReason: "invalid" };
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function isProcessAlive(pid) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
70
|
+
const startTime = Date.now();
|
|
71
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
72
|
+
if (!isProcessAlive(pid)) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, PROCESS_EXIT_POLL_MS));
|
|
76
|
+
}
|
|
77
|
+
return !isProcessAlive(pid);
|
|
78
|
+
}
|
|
79
|
+
function getServiceEntryScriptPath() {
|
|
80
|
+
const scriptPath = process.argv[1];
|
|
81
|
+
if (!scriptPath || scriptPath.trim().length === 0) {
|
|
82
|
+
throw new Error("Failed to resolve CLI entry script path.");
|
|
83
|
+
}
|
|
84
|
+
return path.resolve(scriptPath);
|
|
85
|
+
}
|
|
86
|
+
async function stopWindowsProcess(pid, timeoutMs) {
|
|
87
|
+
try {
|
|
88
|
+
await execAsync(`taskkill /PID ${pid} /T`);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Continue with forced stop if the process is still alive.
|
|
92
|
+
}
|
|
93
|
+
if (await waitForProcessExit(pid, timeoutMs)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await execAsync(`taskkill /F /PID ${pid} /T`);
|
|
97
|
+
await waitForProcessExit(pid, timeoutMs);
|
|
98
|
+
}
|
|
99
|
+
async function stopUnixProcess(pid, timeoutMs) {
|
|
100
|
+
process.kill(pid, "SIGTERM");
|
|
101
|
+
if (await waitForProcessExit(pid, timeoutMs)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
process.kill(pid, "SIGKILL");
|
|
105
|
+
await waitForProcessExit(pid, timeoutMs);
|
|
106
|
+
}
|
|
107
|
+
export function getServiceStateFilePath() {
|
|
108
|
+
return path.join(getRuntimePaths().runDirPath, SERVICE_STATE_FILE_NAME);
|
|
109
|
+
}
|
|
110
|
+
export async function clearServiceStateFile(filePath = getServiceStateFilePath()) {
|
|
111
|
+
try {
|
|
112
|
+
await fsPromises.unlink(filePath);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code !== "ENOENT") {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export async function getBotServiceStatus() {
|
|
121
|
+
const stateFilePath = getServiceStateFilePath();
|
|
122
|
+
const { service, cleanupReason } = await readServiceStateFile(stateFilePath);
|
|
123
|
+
if (!service) {
|
|
124
|
+
return {
|
|
125
|
+
status: "stopped",
|
|
126
|
+
service: null,
|
|
127
|
+
cleanupReason,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (!isProcessAlive(service.pid)) {
|
|
131
|
+
await clearServiceStateFile(stateFilePath);
|
|
132
|
+
return {
|
|
133
|
+
status: "stopped",
|
|
134
|
+
service: null,
|
|
135
|
+
cleanupReason: "stale",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
status: "running",
|
|
140
|
+
service,
|
|
141
|
+
cleanupReason,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export async function startBotDaemon(mode) {
|
|
145
|
+
const currentStatus = await getBotServiceStatus();
|
|
146
|
+
if (currentStatus.status === "running" && currentStatus.service) {
|
|
147
|
+
return {
|
|
148
|
+
success: false,
|
|
149
|
+
service: currentStatus.service,
|
|
150
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
151
|
+
alreadyRunning: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const runtimePaths = getRuntimePaths();
|
|
155
|
+
await Promise.all([
|
|
156
|
+
fsPromises.mkdir(runtimePaths.runDirPath, { recursive: true }),
|
|
157
|
+
fsPromises.mkdir(runtimePaths.logsDirPath, { recursive: true }),
|
|
158
|
+
]);
|
|
159
|
+
const stateFilePath = getServiceStateFilePath();
|
|
160
|
+
const logFilePath = createServiceLogFilePath(runtimePaths.logsDirPath);
|
|
161
|
+
const logFileDescriptor = fs.openSync(logFilePath, "a");
|
|
162
|
+
try {
|
|
163
|
+
const childArgs = [getServiceEntryScriptPath(), "start"];
|
|
164
|
+
if (mode) {
|
|
165
|
+
childArgs.push("--mode", mode);
|
|
166
|
+
}
|
|
167
|
+
const childProcess = spawn(process.execPath, childArgs, {
|
|
168
|
+
detached: true,
|
|
169
|
+
stdio: ["ignore", logFileDescriptor, logFileDescriptor],
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
env: buildServiceChildEnv(process.env, stateFilePath),
|
|
172
|
+
});
|
|
173
|
+
if (!childProcess.pid) {
|
|
174
|
+
throw new Error("Failed to start background bot process.");
|
|
175
|
+
}
|
|
176
|
+
childProcess.unref();
|
|
177
|
+
const serviceState = {
|
|
178
|
+
pid: childProcess.pid,
|
|
179
|
+
startedAt: new Date().toISOString(),
|
|
180
|
+
logFilePath,
|
|
181
|
+
mode: "daemon",
|
|
182
|
+
};
|
|
183
|
+
await writeFileAtomically(stateFilePath, `${JSON.stringify(serviceState, null, 2)}\n`);
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
service: serviceState,
|
|
187
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
await clearServiceStateFile(stateFilePath);
|
|
192
|
+
return {
|
|
193
|
+
success: false,
|
|
194
|
+
service: null,
|
|
195
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
196
|
+
error: error instanceof Error ? error.message : String(error),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
fs.closeSync(logFileDescriptor);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export async function stopBotDaemon(timeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
|
|
204
|
+
const currentStatus = await getBotServiceStatus();
|
|
205
|
+
if (currentStatus.status !== "running" || !currentStatus.service) {
|
|
206
|
+
return {
|
|
207
|
+
success: true,
|
|
208
|
+
service: null,
|
|
209
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
210
|
+
alreadyStopped: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const { pid } = currentStatus.service;
|
|
214
|
+
try {
|
|
215
|
+
if (process.platform === "win32") {
|
|
216
|
+
await stopWindowsProcess(pid, timeoutMs);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
await stopUnixProcess(pid, timeoutMs);
|
|
220
|
+
}
|
|
221
|
+
if (isProcessAlive(pid)) {
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
service: currentStatus.service,
|
|
225
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
226
|
+
error: `Failed to stop background bot process PID=${pid}.`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
await clearServiceStateFile();
|
|
230
|
+
return {
|
|
231
|
+
success: true,
|
|
232
|
+
service: currentStatus.service,
|
|
233
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
service: currentStatus.service,
|
|
240
|
+
cleanupReason: currentStatus.cleanupReason,
|
|
241
|
+
error: error instanceof Error ? error.message : String(error),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const SERVICE_CHILD_ENV_KEY = "OPENCODE_TELEGRAM_SERVICE_CHILD";
|
|
2
|
+
const SERVICE_STATE_PATH_ENV_KEY = "OPENCODE_TELEGRAM_SERVICE_STATE_PATH";
|
|
3
|
+
export function buildServiceChildEnv(baseEnv, stateFilePath) {
|
|
4
|
+
return {
|
|
5
|
+
...baseEnv,
|
|
6
|
+
[SERVICE_CHILD_ENV_KEY]: "1",
|
|
7
|
+
[SERVICE_STATE_PATH_ENV_KEY]: stateFilePath,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export function isServiceChildProcess() {
|
|
11
|
+
return process.env[SERVICE_CHILD_ENV_KEY] === "1";
|
|
12
|
+
}
|
|
13
|
+
export function getServiceStateFilePathFromEnv() {
|
|
14
|
+
const value = process.env[SERVICE_STATE_PATH_ENV_KEY];
|
|
15
|
+
if (!value || value.trim().length === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -43,6 +43,7 @@ class SummaryAggregator {
|
|
|
43
43
|
onSessionCompactedCallback = null;
|
|
44
44
|
onSessionErrorCallback = null;
|
|
45
45
|
onSessionRetryCallback = null;
|
|
46
|
+
onSessionIdleCallback = null;
|
|
46
47
|
onPermissionCallback = null;
|
|
47
48
|
onSessionDiffCallback = null;
|
|
48
49
|
onFileChangeCallback = null;
|
|
@@ -105,6 +106,9 @@ class SummaryAggregator {
|
|
|
105
106
|
setOnSessionRetry(callback) {
|
|
106
107
|
this.onSessionRetryCallback = callback;
|
|
107
108
|
}
|
|
109
|
+
setOnSessionIdle(callback) {
|
|
110
|
+
this.onSessionIdleCallback = callback;
|
|
111
|
+
}
|
|
108
112
|
setOnPermission(callback) {
|
|
109
113
|
this.onPermissionCallback = callback;
|
|
110
114
|
}
|
|
@@ -643,7 +647,13 @@ class SummaryAggregator {
|
|
|
643
647
|
this.onCostCallback(assistantInfo.cost);
|
|
644
648
|
}
|
|
645
649
|
if (this.onCompleteCallback && finalText.length > 0) {
|
|
646
|
-
this.onCompleteCallback(this.currentSessionId, messageID, finalText
|
|
650
|
+
this.onCompleteCallback(this.currentSessionId, messageID, finalText, {
|
|
651
|
+
agent: assistantMessage.agent,
|
|
652
|
+
providerID: assistantMessage.providerID,
|
|
653
|
+
modelID: assistantMessage.modelID,
|
|
654
|
+
createdAt: time?.created,
|
|
655
|
+
completedAt: time?.completed,
|
|
656
|
+
});
|
|
647
657
|
}
|
|
648
658
|
this.textMessageStates.delete(messageID);
|
|
649
659
|
this.messages.delete(messageID);
|
|
@@ -1038,6 +1048,12 @@ class SummaryAggregator {
|
|
|
1038
1048
|
logger.info(`[Aggregator] Session became idle: ${sessionID}`);
|
|
1039
1049
|
// Stop typing indicator when session goes idle
|
|
1040
1050
|
this.stopTypingIndicator();
|
|
1051
|
+
if (this.onSessionIdleCallback) {
|
|
1052
|
+
const callback = this.onSessionIdleCallback;
|
|
1053
|
+
setImmediate(() => {
|
|
1054
|
+
callback(sessionID);
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1041
1057
|
}
|
|
1042
1058
|
handleSessionCompacted(event) {
|
|
1043
1059
|
const properties = event.properties;
|
|
@@ -4,6 +4,7 @@ import { config } from "../config.js";
|
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
5
|
import { t } from "../i18n/index.js";
|
|
6
6
|
import { getCurrentProject } from "../settings/manager.js";
|
|
7
|
+
import { normalizeMarkdownForTelegramRendering } from "../telegram/render/markdown-normalizer.js";
|
|
7
8
|
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
8
9
|
const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
|
|
9
10
|
function truncateWithEllipsis(text, maxLength) {
|
|
@@ -59,87 +60,6 @@ function splitText(text, maxLength, options) {
|
|
|
59
60
|
}
|
|
60
61
|
return parts;
|
|
61
62
|
}
|
|
62
|
-
function isCodeFenceLine(line) {
|
|
63
|
-
return line.trimStart().startsWith("```");
|
|
64
|
-
}
|
|
65
|
-
function isHorizontalRuleLine(line) {
|
|
66
|
-
const normalized = line.trim();
|
|
67
|
-
if (!normalized) {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
return /^([-*_])(?:\s*\1){2,}$/.test(normalized);
|
|
71
|
-
}
|
|
72
|
-
function isHeadingLine(line) {
|
|
73
|
-
return /^\s{0,3}#{1,6}\s+\S/.test(line);
|
|
74
|
-
}
|
|
75
|
-
function normalizeHeadingLine(line) {
|
|
76
|
-
const match = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*$/);
|
|
77
|
-
if (!match) {
|
|
78
|
-
return line;
|
|
79
|
-
}
|
|
80
|
-
return `**${match[1]}**`;
|
|
81
|
-
}
|
|
82
|
-
function normalizeChecklistLine(line) {
|
|
83
|
-
const match = line.match(/^(\s*)(?:[-+*]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/);
|
|
84
|
-
if (!match) {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
const marker = match[2].toLowerCase() === "x" ? "✅" : "🔲";
|
|
88
|
-
return `${match[1]}${marker} ${match[3]}`;
|
|
89
|
-
}
|
|
90
|
-
function preprocessMarkdownForTelegram(text) {
|
|
91
|
-
const lines = text.split("\n");
|
|
92
|
-
const output = [];
|
|
93
|
-
let inCodeFence = false;
|
|
94
|
-
let inQuote = false;
|
|
95
|
-
for (let index = 0; index < lines.length; index++) {
|
|
96
|
-
const line = lines[index];
|
|
97
|
-
if (isCodeFenceLine(line)) {
|
|
98
|
-
inCodeFence = !inCodeFence;
|
|
99
|
-
inQuote = false;
|
|
100
|
-
output.push(line);
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
if (inCodeFence) {
|
|
104
|
-
output.push(line);
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
if (!line.trim()) {
|
|
108
|
-
inQuote = false;
|
|
109
|
-
output.push(line);
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (isHeadingLine(line)) {
|
|
113
|
-
output.push(normalizeHeadingLine(line));
|
|
114
|
-
inQuote = false;
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
if (isHorizontalRuleLine(line)) {
|
|
118
|
-
output.push("──────────");
|
|
119
|
-
inQuote = false;
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
const trimmedLeft = line.trimStart();
|
|
123
|
-
if (trimmedLeft.startsWith(">")) {
|
|
124
|
-
inQuote = true;
|
|
125
|
-
const quoteContent = trimmedLeft.replace(/^>\s?/, "");
|
|
126
|
-
const normalizedChecklistInQuote = normalizeChecklistLine(quoteContent);
|
|
127
|
-
output.push(normalizedChecklistInQuote ? `> ${normalizedChecklistInQuote.trimStart()}` : trimmedLeft);
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
const normalizedChecklist = normalizeChecklistLine(line);
|
|
131
|
-
if (normalizedChecklist) {
|
|
132
|
-
output.push(inQuote ? `> ${normalizedChecklist.trimStart()}` : normalizedChecklist);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
if (inQuote) {
|
|
136
|
-
output.push(`> ${trimmedLeft}`);
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
output.push(line);
|
|
140
|
-
}
|
|
141
|
-
return output.join("\n");
|
|
142
|
-
}
|
|
143
63
|
export function normalizePathForDisplay(filePath) {
|
|
144
64
|
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
145
65
|
const project = getCurrentProject();
|
|
@@ -164,18 +84,12 @@ export function normalizePathForDisplay(filePath) {
|
|
|
164
84
|
export function formatSummary(text) {
|
|
165
85
|
return formatSummaryWithMode(text, config.bot.messageFormatMode);
|
|
166
86
|
}
|
|
167
|
-
export function getAssistantParseMode() {
|
|
168
|
-
if (config.bot.messageFormatMode === "markdown") {
|
|
169
|
-
return "MarkdownV2";
|
|
170
|
-
}
|
|
171
|
-
return undefined;
|
|
172
|
-
}
|
|
173
87
|
export function escapePlainTextForTelegramMarkdownV2(text) {
|
|
174
88
|
return text.replace(MARKDOWN_V2_RESERVED_CHARS, "\\$1");
|
|
175
89
|
}
|
|
176
90
|
function formatMarkdownForTelegram(text) {
|
|
177
91
|
try {
|
|
178
|
-
const preprocessed =
|
|
92
|
+
const preprocessed = normalizeMarkdownForTelegramRendering(text);
|
|
179
93
|
return escapeMarkdownV2PipesOutsideCode(convert(preprocessed, "keep"));
|
|
180
94
|
}
|
|
181
95
|
catch (error) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { logger } from "../../utils/logger.js";
|
|
2
|
+
import * as blockRenderer from "./block-renderer.js";
|
|
3
|
+
const BLOCK_RENDER_ORDER = ["full", "simplified", "line-by-line", "plain"];
|
|
4
|
+
export function renderTelegramBlockWithFallback(block) {
|
|
5
|
+
let lastError;
|
|
6
|
+
const failures = [];
|
|
7
|
+
for (const mode of BLOCK_RENDER_ORDER) {
|
|
8
|
+
try {
|
|
9
|
+
const renderedBlock = blockRenderer.renderTelegramBlock(block, mode);
|
|
10
|
+
if (failures.length > 0) {
|
|
11
|
+
logger.debug("[TelegramRender] Block fallback applied", {
|
|
12
|
+
blockType: block.type,
|
|
13
|
+
selectedMode: renderedBlock.mode,
|
|
14
|
+
failures,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return renderedBlock;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
lastError = error;
|
|
21
|
+
failures.push({
|
|
22
|
+
mode,
|
|
23
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw lastError instanceof Error ? lastError : new Error("Failed to render Telegram block");
|
|
28
|
+
}
|