appback-remoteagent 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 +39 -0
- package/LICENSE +21 -0
- package/README.md +371 -0
- package/bin/remoteagent.js +2 -0
- package/dist/adapters/claude-adapter.js +78 -0
- package/dist/adapters/codex-adapter.js +241 -0
- package/dist/adapters/provider-adapter.js +1 -0
- package/dist/adapters/shell-adapter.js +44 -0
- package/dist/adapters/windows-shell.js +111 -0
- package/dist/bot.js +2135 -0
- package/dist/config.js +170 -0
- package/dist/index.js +534 -0
- package/dist/secret-helper.js +24 -0
- package/dist/services/agent-memory-service.js +737 -0
- package/dist/services/bot-management-service.js +626 -0
- package/dist/services/bridge-service.js +807 -0
- package/dist/services/local-ui-service.js +533 -0
- package/dist/services/provider-setup-service.js +284 -0
- package/dist/services/remote-shell-service.js +97 -0
- package/dist/store/file-store.js +690 -0
- package/dist/telegram-fetch.js +85 -0
- package/dist/types.js +1 -0
- package/docs/ARCHITECTURE.md +170 -0
- package/docs/COKACDIR_NOTES.md +79 -0
- package/docs/ERROR_NORMALIZATION.md +46 -0
- package/docs/MINI_APP.md +112 -0
- package/docs/MVP.md +108 -0
- package/docs/OPERATIONS.md +181 -0
- package/docs/RELEASING.md +87 -0
- package/docs/SESSION_DIRECTORY_PLAN.md +506 -0
- package/package.json +47 -0
- package/scripts/bump-version.sh +23 -0
- package/scripts/finish-claude-login.sh +48 -0
- package/scripts/install-claude.sh +6 -0
- package/scripts/install-codex.sh +8 -0
- package/scripts/install.ps1 +51 -0
- package/scripts/install.sh +101 -0
- package/scripts/mock-adapter.sh +7 -0
- package/scripts/restart-after-bot-op.sh +118 -0
- package/scripts/selftest-telegram-update.mjs +359 -0
- package/scripts/start-claude-login.sh +4 -0
- package/scripts/start.ps1 +39 -0
- package/scripts/start.sh +54 -0
- package/scripts/stop.ps1 +40 -0
- package/scripts/stop.sh +39 -0
- package/tsconfig.json +20 -0
package/dist/bot.js
ADDED
|
@@ -0,0 +1,2135 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { Bot, GrammyError, HttpError } from "grammy";
|
|
8
|
+
import { config } from "./config.js";
|
|
9
|
+
import { ProviderSetupService } from "./services/provider-setup-service.js";
|
|
10
|
+
import { RemoteShellService } from "./services/remote-shell-service.js";
|
|
11
|
+
import { AgentMemoryService } from "./services/agent-memory-service.js";
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const HELP_TEXT = [
|
|
14
|
+
"Commands:",
|
|
15
|
+
"/start [codex|claude]",
|
|
16
|
+
"/help",
|
|
17
|
+
"/list [-a]",
|
|
18
|
+
"/new",
|
|
19
|
+
"/switch <session>",
|
|
20
|
+
"/batch start|send|cancel|status",
|
|
21
|
+
"/attach codex <thread_id>",
|
|
22
|
+
"/attach claude <session_id>",
|
|
23
|
+
"/model [name]",
|
|
24
|
+
"/stop",
|
|
25
|
+
"/sandbox codex <read-only|workspace-write|danger-full-access>",
|
|
26
|
+
"/status",
|
|
27
|
+
"/option [retry <count>|timeout <seconds>|intent <count>]",
|
|
28
|
+
"/state [clear|note <text>]",
|
|
29
|
+
"/artifacts list|cleanup <days>",
|
|
30
|
+
"/secret set|list|remove",
|
|
31
|
+
"/docs pin|find|list|remove",
|
|
32
|
+
"/bots",
|
|
33
|
+
"/bot add <token>",
|
|
34
|
+
"/bot doctor",
|
|
35
|
+
"/bot main <number|@username|id>",
|
|
36
|
+
"/bot remove <username|id>",
|
|
37
|
+
"/bot reload",
|
|
38
|
+
"/install codex|claude",
|
|
39
|
+
"/login codex",
|
|
40
|
+
"/login claude [token]",
|
|
41
|
+
"/reset",
|
|
42
|
+
"/! <command>",
|
|
43
|
+
"/!cmd <command>",
|
|
44
|
+
"/!bash <command>",
|
|
45
|
+
].join("\n");
|
|
46
|
+
const REPORT_PREFIX = "REPORT:";
|
|
47
|
+
const REPORT_PROTOCOL_PROMPT = [
|
|
48
|
+
"RemoteAgent execution protocol:",
|
|
49
|
+
"Start the first line of every reply with exactly one of:",
|
|
50
|
+
"REPORT:progress",
|
|
51
|
+
"REPORT:result",
|
|
52
|
+
"REPORT:blocked",
|
|
53
|
+
"Use REPORT:progress only after you completed a real chunk of work and will continue automatically.",
|
|
54
|
+
"Use REPORT:result only when the requested work for this turn is actually finished.",
|
|
55
|
+
"Use REPORT:blocked only when you cannot continue without user input or an external fix.",
|
|
56
|
+
"If you are waiting on sudo, login, permission changes, API keys, SSH access, or any manual user/admin step, you must use REPORT:blocked.",
|
|
57
|
+
"Do not say 'I will continue' or 'I can continue after you do X' unless the first line is REPORT:blocked.",
|
|
58
|
+
"After the first line, write only the user-facing report.",
|
|
59
|
+
"Do not stop at intent like 'I will' or 'I am going to'. Do the work first, then report progress/result, or report blocked.",
|
|
60
|
+
"Do not claim that a Telegram message or file was sent unless RemoteAgent explicitly confirmed that delivery step.",
|
|
61
|
+
"If REPORT:result claims code, DB, deploy, commit, push, file delivery, or verification work is complete, include concrete evidence such as file paths, commands, logs, commit IDs, digests, or line references.",
|
|
62
|
+
"If you want RemoteAgent to send a file, include a separate line exactly like: TELEGRAM_FILE: /absolute/path/to/file",
|
|
63
|
+
"Do not use removed local reporting scripts for Telegram delivery.",
|
|
64
|
+
"Do not use raw Telegram tokens or TELEGRAM_* environment variables.",
|
|
65
|
+
"Telegram sends are allowed only when the user explicitly instructed that flow and the bot token is retrieved through `node \"$REMOTEAGENT_SECRET_BIN\" get <KEY>`. Never print or persist the token.",
|
|
66
|
+
"Do not claim Telegram message delivery unless RemoteAgent handled `TELEGRAM_FILE` delivery or you include concrete API response evidence from an explicit secret-managed Telegram send.",
|
|
67
|
+
"REMOTEAGENT_SESSION_ID and REMOTEAGENT_PUBLIC_SESSION_ID are available during provider execution. For cron, persist the literal public session id in the cron command instead of assuming the env will still exist later.",
|
|
68
|
+
].join("\n");
|
|
69
|
+
const RECOGNIZED_COMMANDS = new Set([
|
|
70
|
+
"start",
|
|
71
|
+
"help",
|
|
72
|
+
"list",
|
|
73
|
+
"new",
|
|
74
|
+
"switch",
|
|
75
|
+
"batch",
|
|
76
|
+
"attach",
|
|
77
|
+
"model",
|
|
78
|
+
"stop",
|
|
79
|
+
"sandbox",
|
|
80
|
+
"status",
|
|
81
|
+
"option",
|
|
82
|
+
"state",
|
|
83
|
+
"artifacts",
|
|
84
|
+
"secret",
|
|
85
|
+
"docs",
|
|
86
|
+
"bots",
|
|
87
|
+
"bot",
|
|
88
|
+
"install",
|
|
89
|
+
"login",
|
|
90
|
+
"reset",
|
|
91
|
+
]);
|
|
92
|
+
const TELEGRAM_STALE_UPDATE_GRACE_SECONDS = 10;
|
|
93
|
+
const TELEGRAM_PROCESS_STARTED_AT_SECONDS = Math.floor(Date.now() / 1000);
|
|
94
|
+
const REPORT_CONTINUE_PROMPT = [
|
|
95
|
+
"Continue the same task now.",
|
|
96
|
+
"Do more concrete work before replying again.",
|
|
97
|
+
"Do not restate the plan unless it changed because of a real finding.",
|
|
98
|
+
"Reply again with exactly one first line: REPORT:progress or REPORT:result or REPORT:blocked.",
|
|
99
|
+
].join("\n");
|
|
100
|
+
class AutoContinueController {
|
|
101
|
+
stopGatePath;
|
|
102
|
+
stops = new Set();
|
|
103
|
+
stopInProgress = new Set();
|
|
104
|
+
stopDedupUntil = new Map();
|
|
105
|
+
stopDedupMs = 10_000;
|
|
106
|
+
suppressUntil = new Map();
|
|
107
|
+
suppressMs = 60_000;
|
|
108
|
+
constructor(stopGatePath) {
|
|
109
|
+
this.stopGatePath = stopGatePath;
|
|
110
|
+
this.loadStopGates();
|
|
111
|
+
}
|
|
112
|
+
requestStop(botId, chatId, sessionId) {
|
|
113
|
+
for (const key of this.keys(botId, chatId, sessionId)) {
|
|
114
|
+
this.stops.add(key);
|
|
115
|
+
this.suppressUntil.set(key, Date.now() + this.suppressMs);
|
|
116
|
+
}
|
|
117
|
+
this.persistStopGates();
|
|
118
|
+
}
|
|
119
|
+
requestSessionStop(sessionId) {
|
|
120
|
+
const key = this.sessionKey(sessionId);
|
|
121
|
+
this.stops.add(key);
|
|
122
|
+
this.suppressUntil.set(key, Date.now() + this.suppressMs);
|
|
123
|
+
this.persistStopGates();
|
|
124
|
+
}
|
|
125
|
+
beginStop(botId, chatId, sessionId) {
|
|
126
|
+
const key = this.primaryKey(botId, chatId, sessionId);
|
|
127
|
+
const dedupUntil = this.stopDedupUntil.get(key);
|
|
128
|
+
if (dedupUntil && Date.now() < dedupUntil) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (dedupUntil) {
|
|
132
|
+
this.stopDedupUntil.delete(key);
|
|
133
|
+
}
|
|
134
|
+
if (this.stopInProgress.has(key)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
this.stopInProgress.add(key);
|
|
138
|
+
this.requestStop(botId, chatId, sessionId);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
finishStop(botId, chatId, sessionId) {
|
|
142
|
+
const key = this.primaryKey(botId, chatId, sessionId);
|
|
143
|
+
this.stopInProgress.delete(key);
|
|
144
|
+
this.stopDedupUntil.set(key, Date.now() + this.stopDedupMs);
|
|
145
|
+
}
|
|
146
|
+
clear(botId, chatId, sessionId) {
|
|
147
|
+
for (const key of this.keys(botId, chatId, sessionId)) {
|
|
148
|
+
this.stops.delete(key);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
isStopRequested(botId, chatId, sessionId) {
|
|
152
|
+
return this.keys(botId, chatId, sessionId).some((key) => this.stops.has(key));
|
|
153
|
+
}
|
|
154
|
+
isSuppressingNewWork(botId, chatId, sessionId) {
|
|
155
|
+
for (const key of this.keys(botId, chatId, sessionId)) {
|
|
156
|
+
if (this.isSuppressingKey(key)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
isSuppressingKey(key) {
|
|
163
|
+
const until = this.suppressUntil.get(key);
|
|
164
|
+
if (!until) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (Date.now() > until) {
|
|
168
|
+
this.suppressUntil.delete(key);
|
|
169
|
+
this.persistStopGates();
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
keys(botId, chatId, sessionId) {
|
|
175
|
+
const keys = [this.chatKey(botId, chatId)];
|
|
176
|
+
if (sessionId) {
|
|
177
|
+
keys.push(this.sessionKey(sessionId));
|
|
178
|
+
}
|
|
179
|
+
return keys;
|
|
180
|
+
}
|
|
181
|
+
primaryKey(botId, chatId, sessionId) {
|
|
182
|
+
return sessionId ? this.sessionKey(sessionId) : this.chatKey(botId, chatId);
|
|
183
|
+
}
|
|
184
|
+
chatKey(botId, chatId) {
|
|
185
|
+
return `${botId}:${chatId}`;
|
|
186
|
+
}
|
|
187
|
+
sessionKey(sessionId) {
|
|
188
|
+
return `session:${sessionId}`;
|
|
189
|
+
}
|
|
190
|
+
loadStopGates() {
|
|
191
|
+
try {
|
|
192
|
+
const raw = fsSync.readFileSync(this.stopGatePath, "utf8");
|
|
193
|
+
const parsed = JSON.parse(raw);
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
for (const [key, until] of Object.entries(parsed)) {
|
|
196
|
+
if (Number.isFinite(until) && until > now) {
|
|
197
|
+
this.suppressUntil.set(key, until);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Missing or invalid gate files should not prevent the bot from starting.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
persistStopGates() {
|
|
206
|
+
const entries = Object.fromEntries(this.suppressUntil.entries());
|
|
207
|
+
try {
|
|
208
|
+
fsSync.mkdirSync(path.dirname(this.stopGatePath), { recursive: true });
|
|
209
|
+
const tmpPath = `${this.stopGatePath}.tmp`;
|
|
210
|
+
fsSync.writeFileSync(tmpPath, JSON.stringify(entries, null, 2), "utf8");
|
|
211
|
+
fsSync.renameSync(tmpPath, this.stopGatePath);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Stop should still work in memory even if persisting the gate fails.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export function createBot(token, bridge, botManagement, botInfo) {
|
|
219
|
+
const bot = new Bot(token, { botInfo });
|
|
220
|
+
const autoContinue = new AutoContinueController(path.join(config.dataDir, "stop-gates.json"));
|
|
221
|
+
const shellService = new RemoteShellService(config.commandTimeoutMs);
|
|
222
|
+
const memoryService = new AgentMemoryService(config.dataDir);
|
|
223
|
+
const sourceBotToken = token;
|
|
224
|
+
const setupService = new ProviderSetupService(config.setupCommandTimeoutMs, (provider) => bridge.listAvailableProviders().includes(provider), {
|
|
225
|
+
codex: config.codexInstallCommand,
|
|
226
|
+
claude: config.claudeInstallCommand,
|
|
227
|
+
}, config.claudeLoginStartCommand, config.claudeLoginFinishCommand);
|
|
228
|
+
const messageBatcher = new TelegramMessageBatcher(config.telegramMessageBatchMs, async (target, botId, chatId, text) => {
|
|
229
|
+
await bridge.logSystem(botId, chatId, `Telegram text dispatch (${text.length} chars).`);
|
|
230
|
+
await runWithPendingAnimation(target.botToken, target.telegramChatId, async (helpers) => {
|
|
231
|
+
return {
|
|
232
|
+
chunks: await routeTelegramWorkLoop(bridge, botId, chatId, text, "Telegram text request", helpers, autoContinue, memoryService),
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
const getBotId = () => bot.botInfo?.username ?? String(bot.botInfo?.id ?? token);
|
|
237
|
+
const stopPreviousSessionForRebind = async (botId, chatId, reason) => {
|
|
238
|
+
const previous = await bridge.status(botId, chatId).catch(() => undefined);
|
|
239
|
+
if (!previous) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
autoContinue.requestSessionStop(previous.session.sessionId);
|
|
243
|
+
messageBatcher.cancelPending(botId, chatId);
|
|
244
|
+
messageBatcher.cancelManual(botId, chatId);
|
|
245
|
+
await bridge.stopSessionRun(previous.session.sessionId, botId, chatId, reason);
|
|
246
|
+
};
|
|
247
|
+
const reply = async (ctx, text, extra) => {
|
|
248
|
+
if (!ctx.chat) {
|
|
249
|
+
throw new Error("Telegram chat context is missing.");
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return await sendTelegramMessage(token, ctx.chat.id, text, extra);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (isTelegramForbiddenError(error)) {
|
|
256
|
+
console.warn(`[telegram-delivery] bot=${getBotId()} chat=${ctx.chat.id} skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
257
|
+
return { message_id: 0 };
|
|
258
|
+
}
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
bot.use(async (ctx, next) => {
|
|
263
|
+
const updateKind = Object.keys(ctx.update).join(",");
|
|
264
|
+
const text = ctx.message?.text ?? ctx.editedMessage?.text ?? ctx.channelPost?.text ?? "";
|
|
265
|
+
const safeText = sanitizeLoggedTelegramText(text);
|
|
266
|
+
const messageDate = ctx.message?.date ?? ctx.editedMessage?.date ?? ctx.channelPost?.date;
|
|
267
|
+
if (messageDate && messageDate < TELEGRAM_PROCESS_STARTED_AT_SECONDS - TELEGRAM_STALE_UPDATE_GRACE_SECONDS) {
|
|
268
|
+
console.log(`[tg-update-stale] bot=${getBotId()} kind=${updateKind} chat=${ctx.chat?.id ?? "?"} date=${messageDate} text=${JSON.stringify(safeText).slice(0, 240)}`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
console.log(`[tg-update] bot=${getBotId()} kind=${updateKind} chat=${ctx.chat?.id ?? "?"} text=${JSON.stringify(safeText).slice(0, 240)}`);
|
|
272
|
+
if (ctx.chat) {
|
|
273
|
+
await bridge.rememberTelegramContact({
|
|
274
|
+
transport: "telegram",
|
|
275
|
+
botId: getBotId(),
|
|
276
|
+
botUsername: bot.botInfo?.username,
|
|
277
|
+
chatId: String(ctx.chat.id),
|
|
278
|
+
chatType: ctx.chat.type,
|
|
279
|
+
ownerUserId: ctx.from ? String(ctx.from.id) : undefined,
|
|
280
|
+
username: "username" in ctx.chat && typeof ctx.chat.username === "string" ? ctx.chat.username : undefined,
|
|
281
|
+
firstName: "first_name" in ctx.chat && typeof ctx.chat.first_name === "string" ? ctx.chat.first_name : undefined,
|
|
282
|
+
lastName: "last_name" in ctx.chat && typeof ctx.chat.last_name === "string" ? ctx.chat.last_name : undefined,
|
|
283
|
+
title: "title" in ctx.chat && typeof ctx.chat.title === "string" ? ctx.chat.title : undefined,
|
|
284
|
+
lastSeenAt: new Date().toISOString(),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
await next();
|
|
288
|
+
});
|
|
289
|
+
bot.command("start", async (ctx) => {
|
|
290
|
+
const botId = getBotId();
|
|
291
|
+
const chatId = String(ctx.chat.id);
|
|
292
|
+
const { args, rest } = parseCommand(ctx.message?.text, 1);
|
|
293
|
+
const first = args[0]?.trim();
|
|
294
|
+
if (rest?.trim()) {
|
|
295
|
+
await reply(ctx, "Usage: `/start` or `/start codex` or `/start claude`", {
|
|
296
|
+
parse_mode: "Markdown",
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (first && !["codex", "claude"].includes(first.toLowerCase())) {
|
|
301
|
+
await reply(ctx, "Usage: `/start` or `/start codex` or `/start claude`", {
|
|
302
|
+
parse_mode: "Markdown",
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const explicitProvider = first ? first.toLowerCase() : undefined;
|
|
307
|
+
const provider = await bridge.resolveStartProvider(explicitProvider);
|
|
308
|
+
await stopPreviousSessionForRebind(botId, chatId, "Chat started a new session; previous session execution was stopped.");
|
|
309
|
+
const mapping = await bridge.startSession(botId, chatId, provider);
|
|
310
|
+
await reply(ctx, `Started a fresh ${provider} session.
|
|
311
|
+
|
|
312
|
+
${bridge.formatStatus(mapping)}`);
|
|
313
|
+
});
|
|
314
|
+
bot.command("help", async (ctx) => {
|
|
315
|
+
await reply(ctx, HELP_TEXT);
|
|
316
|
+
});
|
|
317
|
+
const replySessionList = async (ctx) => {
|
|
318
|
+
if (!ctx.chat) {
|
|
319
|
+
throw new Error("Telegram chat context is missing.");
|
|
320
|
+
}
|
|
321
|
+
const botId = getBotId();
|
|
322
|
+
const chatId = String(ctx.chat.id);
|
|
323
|
+
const { args } = parseCommand(ctx.message?.text, 1);
|
|
324
|
+
const showAll = args[0] === "-a" || args[0] === "--all";
|
|
325
|
+
const [mapping, sessions] = await Promise.all([
|
|
326
|
+
bridge.status(botId, chatId),
|
|
327
|
+
bridge.listSessions(),
|
|
328
|
+
]);
|
|
329
|
+
const botSummary = await botManagement.formatCurrentBotSummary(botId);
|
|
330
|
+
const sessionList = showAll
|
|
331
|
+
? await bridge.formatSessionListDetailed(sessions, mapping?.session.sessionId, await bridge.listActiveSessionIds())
|
|
332
|
+
: bridge.formatSessionList(sessions, mapping?.session.sessionId);
|
|
333
|
+
for (const chunk of flattenChunks([`${sessionList}\n\n${botSummary}`], 3900)) {
|
|
334
|
+
await reply(ctx, chunk);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
bot.command("list", async (ctx) => {
|
|
338
|
+
await replySessionList(ctx);
|
|
339
|
+
});
|
|
340
|
+
bot.command("new", async (ctx) => {
|
|
341
|
+
const botId = getBotId();
|
|
342
|
+
const chatId = String(ctx.chat.id);
|
|
343
|
+
const { rest } = parseCommand(ctx.message?.text, 0);
|
|
344
|
+
await stopPreviousSessionForRebind(botId, chatId, "Chat created a new session; previous session execution was stopped.");
|
|
345
|
+
const mapping = await bridge.createSession(botId, chatId, rest);
|
|
346
|
+
await reply(ctx, `Created and bound a new ${mapping.session.mode} session.\n\n${bridge.formatCurrentSession(mapping)}`);
|
|
347
|
+
});
|
|
348
|
+
bot.command("switch", async (ctx) => {
|
|
349
|
+
const botId = getBotId();
|
|
350
|
+
const chatId = String(ctx.chat.id);
|
|
351
|
+
const { args } = parseCommand(ctx.message?.text, 1);
|
|
352
|
+
const sessionId = args[0];
|
|
353
|
+
if (!sessionId) {
|
|
354
|
+
await reply(ctx, "Usage: `/switch <session>`", {
|
|
355
|
+
parse_mode: "Markdown",
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const previous = await bridge.status(botId, chatId).catch(() => undefined);
|
|
360
|
+
const mapping = await bridge.switchSession(botId, chatId, sessionId);
|
|
361
|
+
if (previous && previous.session.sessionId !== mapping.session.sessionId) {
|
|
362
|
+
autoContinue.requestSessionStop(previous.session.sessionId);
|
|
363
|
+
messageBatcher.cancelPending(botId, chatId);
|
|
364
|
+
messageBatcher.cancelManual(botId, chatId);
|
|
365
|
+
await bridge.stopSessionRun(previous.session.sessionId, botId, chatId, "Chat switched to another session; previous session execution was stopped.");
|
|
366
|
+
}
|
|
367
|
+
await reply(ctx, `Switched this chat to session ${sessionId}.\n\n${bridge.formatCurrentSession(mapping)}`);
|
|
368
|
+
});
|
|
369
|
+
bot.command("batch", async (ctx) => {
|
|
370
|
+
const botId = getBotId();
|
|
371
|
+
const chatId = String(ctx.chat.id);
|
|
372
|
+
const { args } = parseCommand(ctx.message?.text, 1);
|
|
373
|
+
const action = args[0]?.toLowerCase();
|
|
374
|
+
if (!action || !["start", "send", "done", "cancel", "status"].includes(action)) {
|
|
375
|
+
await reply(ctx, "Usage: `/batch start`, `/batch send`, `/batch cancel`, or `/batch status`", {
|
|
376
|
+
parse_mode: "Markdown",
|
|
377
|
+
});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (action === "start") {
|
|
381
|
+
messageBatcher.startManual(botId, chatId);
|
|
382
|
+
await reply(ctx, "Batch collection started. Send the log fragments, then run `/batch send`.", {
|
|
383
|
+
parse_mode: "Markdown",
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (action === "send" || action === "done") {
|
|
388
|
+
const result = await messageBatcher.sendManual({ botToken: token, telegramChatId: ctx.chat.id }, botId, chatId);
|
|
389
|
+
if (!result.found) {
|
|
390
|
+
await reply(ctx, "No active batch. Run `/batch start` first.", { parse_mode: "Markdown" });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (result.count === 0) {
|
|
394
|
+
await reply(ctx, "Batch was empty.");
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (action === "cancel") {
|
|
399
|
+
const result = messageBatcher.cancelManual(botId, chatId);
|
|
400
|
+
await reply(ctx, result.found ? `Canceled batch with ${result.count} collected message(s).` : "No active batch.");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const result = messageBatcher.manualStatus(botId, chatId);
|
|
404
|
+
await reply(ctx, result.found ? `Batch collection is active with ${result.count} message(s).` : "No active batch.");
|
|
405
|
+
});
|
|
406
|
+
bot.command("attach", async (ctx) => {
|
|
407
|
+
const botId = getBotId();
|
|
408
|
+
const chatId = String(ctx.chat.id);
|
|
409
|
+
const { args } = parseCommand(ctx.message?.text, 2);
|
|
410
|
+
const provider = args[0]?.toLowerCase();
|
|
411
|
+
const sessionId = args[1];
|
|
412
|
+
if (!provider || !["codex", "claude"].includes(provider) || !sessionId) {
|
|
413
|
+
await reply(ctx, "Usage: `/attach codex <thread_id>` or `/attach claude <session_id>`", {
|
|
414
|
+
parse_mode: "Markdown",
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const mapping = await bridge.attachPair(botId, chatId, provider, sessionId);
|
|
419
|
+
await reply(ctx, `Attached this chat to existing ${provider} session ${sessionId}.
|
|
420
|
+
|
|
421
|
+
${bridge.formatStatus(mapping)}`);
|
|
422
|
+
});
|
|
423
|
+
bot.command("model", async (ctx) => {
|
|
424
|
+
const botId = getBotId();
|
|
425
|
+
const chatId = String(ctx.chat.id);
|
|
426
|
+
const { args, rest } = parseCommand(ctx.message?.text, 1);
|
|
427
|
+
const model = args[0]?.trim();
|
|
428
|
+
if (rest?.trim()) {
|
|
429
|
+
await reply(ctx, "Usage: `/model` or `/model <name|number>`", {
|
|
430
|
+
parse_mode: "Markdown",
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (!model) {
|
|
435
|
+
await reply(ctx, await bridge.formatModelSelection(botId, chatId), {
|
|
436
|
+
parse_mode: "Markdown",
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const mapping = await bridge.setModel(botId, chatId, model);
|
|
441
|
+
await reply(ctx, `Set ${mapping.session.mode} model to ${model}.\n\n${bridge.formatStatus(mapping)}`);
|
|
442
|
+
});
|
|
443
|
+
bot.command("stop", async (ctx) => {
|
|
444
|
+
const botId = getBotId();
|
|
445
|
+
const chatId = String(ctx.chat.id);
|
|
446
|
+
const mapping = await bridge.status(botId, chatId);
|
|
447
|
+
const sessionId = mapping?.session.sessionId;
|
|
448
|
+
autoContinue.requestStop(botId, chatId, sessionId);
|
|
449
|
+
const pendingBatch = messageBatcher.cancelPending(botId, chatId);
|
|
450
|
+
const manualBatch = messageBatcher.cancelManual(botId, chatId);
|
|
451
|
+
if (!autoContinue.beginStop(botId, chatId, sessionId)) {
|
|
452
|
+
const batchCount = pendingBatch.count + manualBatch.count;
|
|
453
|
+
if (batchCount > 0) {
|
|
454
|
+
await bridge.logSystem(botId, chatId, `Duplicate stop discarded ${batchCount} queued message(s).`);
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const result = await bridge.stopActiveRun(botId, chatId);
|
|
460
|
+
await bridge.logSystem(botId, chatId, "Stop requested for auto-continue.");
|
|
461
|
+
const batchCount = pendingBatch.count + manualBatch.count;
|
|
462
|
+
await reply(ctx, result.stopped
|
|
463
|
+
? `Stop requested. Active work for ${result.sessionPublicId ?? "this session"} was interrupted, further automatic continuation will stop, and ${batchCount} queued message(s) were discarded.`
|
|
464
|
+
: `Stop requested. No active provider process was running, but further automatic continuation will stop, and ${batchCount} queued message(s) were discarded.`);
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
autoContinue.finishStop(botId, chatId, sessionId);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
bot.command("status", async (ctx) => {
|
|
471
|
+
const botId = getBotId();
|
|
472
|
+
const chatId = String(ctx.chat.id);
|
|
473
|
+
const mapping = await bridge.status(botId, chatId);
|
|
474
|
+
await reply(ctx, bridge.formatStatus(mapping));
|
|
475
|
+
});
|
|
476
|
+
bot.command("option", async (ctx) => {
|
|
477
|
+
await ensureOwnerControlAccess(ctx);
|
|
478
|
+
const botId = getBotId();
|
|
479
|
+
const chatId = String(ctx.chat.id);
|
|
480
|
+
const { args } = parseCommand(ctx.message?.text, 2);
|
|
481
|
+
const option = args[0]?.toLowerCase();
|
|
482
|
+
const value = args[1];
|
|
483
|
+
if (!option) {
|
|
484
|
+
await reply(ctx, formatRuntimeOptions());
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (option !== "retry" && option !== "timeout" && option !== "intent") {
|
|
488
|
+
await reply(ctx, "Usage: `/option retry <count>`, `/option timeout <seconds>`, or `/option intent <count>`\n\n`retry` controls automatic continuation turns. `timeout` controls one provider execution limit. `intent` controls retries for untagged intent-only provider replies.", {
|
|
489
|
+
parse_mode: "Markdown",
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (!value) {
|
|
494
|
+
const current = option === "retry"
|
|
495
|
+
? `Current automatic continuation retry limit: ${formatRetryLimit(config.telegramAutoProgressMaxTurns)}\n\nUsage: \`/option retry <count>\``
|
|
496
|
+
: option === "intent"
|
|
497
|
+
? `Current untagged intent retry limit: ${formatRetryLimit(config.telegramUntaggedIntentRetries)}\n\nUsage: \`/option intent <count>\``
|
|
498
|
+
: `Current provider execution timeout: ${formatTimeoutSeconds(config.commandTimeoutMs)}\n\nUsage: \`/option timeout <seconds>\``;
|
|
499
|
+
await reply(ctx, current, {
|
|
500
|
+
parse_mode: "Markdown",
|
|
501
|
+
});
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const parsed = Number.parseInt(value, 10);
|
|
505
|
+
if (!Number.isInteger(parsed) || parsed < 0 || String(parsed) !== value.trim()) {
|
|
506
|
+
await reply(ctx, option === "retry"
|
|
507
|
+
? "Invalid retry count. Use `0` or a positive integer, for example `/option retry 6`."
|
|
508
|
+
: option === "intent"
|
|
509
|
+
? "Invalid intent retry count. Use `0` or a positive integer, for example `/option intent 4`."
|
|
510
|
+
: "Invalid timeout. Use seconds as a positive integer, for example `/option timeout 600`.", {
|
|
511
|
+
parse_mode: "Markdown",
|
|
512
|
+
});
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (option === "retry") {
|
|
516
|
+
config.telegramAutoProgressMaxTurns = parsed;
|
|
517
|
+
await upsertInstalledEnvValue("TELEGRAM_AUTO_PROGRESS_MAX_TURNS", String(parsed));
|
|
518
|
+
await bridge.logSystem(botId, chatId, `Runtime option TELEGRAM_AUTO_PROGRESS_MAX_TURNS set to ${parsed}.`);
|
|
519
|
+
await reply(ctx, `Set automatic continuation retry limit to ${formatRetryLimit(parsed)}.\n\nSaved: TELEGRAM_AUTO_PROGRESS_MAX_TURNS=${parsed}`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (option === "intent") {
|
|
523
|
+
config.telegramUntaggedIntentRetries = parsed;
|
|
524
|
+
await upsertInstalledEnvValue("TELEGRAM_UNTAGGED_INTENT_RETRIES", String(parsed));
|
|
525
|
+
await bridge.logSystem(botId, chatId, `Runtime option TELEGRAM_UNTAGGED_INTENT_RETRIES set to ${parsed}.`);
|
|
526
|
+
await reply(ctx, `Set untagged intent retry limit to ${formatRetryLimit(parsed)}.\n\nSaved: TELEGRAM_UNTAGGED_INTENT_RETRIES=${parsed}`);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (parsed < 10) {
|
|
530
|
+
await reply(ctx, "Invalid timeout. Use at least 10 seconds, for example `/option timeout 600`.", {
|
|
531
|
+
parse_mode: "Markdown",
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
config.commandTimeoutMs = parsed * 1000;
|
|
536
|
+
await upsertInstalledEnvValue("COMMAND_TIMEOUT_MS", String(config.commandTimeoutMs));
|
|
537
|
+
await bridge.logSystem(botId, chatId, `Runtime option COMMAND_TIMEOUT_MS set to ${config.commandTimeoutMs}.`);
|
|
538
|
+
await reply(ctx, `Set provider execution timeout to ${formatTimeoutSeconds(config.commandTimeoutMs)}.\n\nSaved: COMMAND_TIMEOUT_MS=${config.commandTimeoutMs}`);
|
|
539
|
+
});
|
|
540
|
+
bot.command("state", async (ctx) => {
|
|
541
|
+
const botId = getBotId();
|
|
542
|
+
const chatId = String(ctx.chat.id);
|
|
543
|
+
const { args, rest } = parseCommand(ctx.message?.text, 1);
|
|
544
|
+
const action = args[0]?.toLowerCase() || "status";
|
|
545
|
+
const mapping = await bridge.status(botId, chatId);
|
|
546
|
+
if (!mapping) {
|
|
547
|
+
await reply(ctx, "No paired session for this chat yet.");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (action === "status") {
|
|
551
|
+
await reply(ctx, await memoryService.formatSessionState(mapping.session));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (action === "clear") {
|
|
555
|
+
await memoryService.clearSessionState(mapping.session, "Cleared by /state clear.");
|
|
556
|
+
await reply(ctx, `Cleared session state for ${mapping.session.publicId}.`);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (action === "note") {
|
|
560
|
+
const note = rest?.trim();
|
|
561
|
+
if (!note) {
|
|
562
|
+
await reply(ctx, "Usage: `/state note <내용>`", { parse_mode: "Markdown" });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
await memoryService.addSessionNote(mapping.session, note);
|
|
566
|
+
await reply(ctx, `Saved state note for ${mapping.session.publicId}.`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
await reply(ctx, "Usage: `/state`, `/state clear`, or `/state note <내용>`", { parse_mode: "Markdown" });
|
|
570
|
+
});
|
|
571
|
+
bot.command("artifacts", async (ctx) => {
|
|
572
|
+
await ensureOwnerControlAccess(ctx);
|
|
573
|
+
const botId = getBotId();
|
|
574
|
+
const chatId = String(ctx.chat.id);
|
|
575
|
+
const { args } = parseCommand(ctx.message?.text, 2);
|
|
576
|
+
const action = args[0]?.toLowerCase() || "list";
|
|
577
|
+
const mapping = await bridge.status(botId, chatId).catch(() => undefined);
|
|
578
|
+
if (action === "list") {
|
|
579
|
+
await reply(ctx, await memoryService.listArtifacts(mapping?.session));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (action === "cleanup") {
|
|
583
|
+
const days = Number.parseInt(args[1] ?? "", 10);
|
|
584
|
+
if (!Number.isFinite(days) || days < 1) {
|
|
585
|
+
await reply(ctx, "Usage: `/artifacts cleanup <days>`", { parse_mode: "Markdown" });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
await reply(ctx, await memoryService.cleanupArtifacts(days));
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
await reply(ctx, "Usage: `/artifacts list` or `/artifacts cleanup <days>`", { parse_mode: "Markdown" });
|
|
592
|
+
});
|
|
593
|
+
bot.command("secret", async (ctx) => {
|
|
594
|
+
await ensureOwnerControlAccess(ctx);
|
|
595
|
+
const { args, rest } = parseCommand(ctx.message?.text, 2);
|
|
596
|
+
const action = args[0]?.toLowerCase();
|
|
597
|
+
const key = args[1]?.trim().toUpperCase();
|
|
598
|
+
if (action === "list") {
|
|
599
|
+
await reply(ctx, await memoryService.listSecrets());
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (action === "set") {
|
|
603
|
+
if (!key || !rest?.trim()) {
|
|
604
|
+
await reply(ctx, formatSecretHelp(), { parse_mode: "Markdown" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
await memoryService.setSecret(key, rest.trim());
|
|
608
|
+
await reply(ctx, `Stored secret key ${key}. Value is hidden from agents and chat output.`);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (action === "remove") {
|
|
612
|
+
if (!key) {
|
|
613
|
+
await reply(ctx, formatSecretHelp(), { parse_mode: "Markdown" });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const removed = await memoryService.removeSecret(key);
|
|
617
|
+
await reply(ctx, removed ? `Removed secret key ${key}.` : `Secret key was not found: ${key}`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
await reply(ctx, formatSecretHelp(), { parse_mode: "Markdown" });
|
|
621
|
+
});
|
|
622
|
+
bot.command("docs", async (ctx) => {
|
|
623
|
+
const { args, rest } = parseCommand(ctx.message?.text, 2);
|
|
624
|
+
const action = args[0]?.toLowerCase() || "list";
|
|
625
|
+
const keyword = args[1]?.trim();
|
|
626
|
+
if (action === "list") {
|
|
627
|
+
await reply(ctx, await memoryService.listDocuments());
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (action === "find") {
|
|
631
|
+
if (!keyword) {
|
|
632
|
+
await reply(ctx, "Usage: `/docs find <keyword>`", { parse_mode: "Markdown" });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
await reply(ctx, await memoryService.findDocuments(keyword));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (action === "pin") {
|
|
639
|
+
await ensureOwnerControlAccess(ctx);
|
|
640
|
+
if (!keyword || !rest?.trim()) {
|
|
641
|
+
await reply(ctx, "Usage: `/docs pin <keyword> <path-or-folder> [note]`", { parse_mode: "Markdown" });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
await memoryService.pinDocument(keyword, rest.trim());
|
|
645
|
+
await reply(ctx, `Pinned docs keyword ${keyword} -> ${rest.trim()}`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (action === "remove") {
|
|
649
|
+
await ensureOwnerControlAccess(ctx);
|
|
650
|
+
if (!keyword) {
|
|
651
|
+
await reply(ctx, "Usage: `/docs remove <keyword>`", { parse_mode: "Markdown" });
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const removed = await memoryService.removeDocumentPin(keyword);
|
|
655
|
+
await reply(ctx, removed ? `Removed docs keyword ${keyword}.` : `Docs keyword was not found: ${keyword}`);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
await reply(ctx, "Usage: `/docs list`, `/docs find <keyword>`, `/docs pin <keyword> <path>`, or `/docs remove <keyword>`", { parse_mode: "Markdown" });
|
|
659
|
+
});
|
|
660
|
+
bot.command("bots", async (ctx) => {
|
|
661
|
+
await ensureOwnerControlAccess(ctx);
|
|
662
|
+
const pendingNotice = await botManagement.getPendingOperationNotice();
|
|
663
|
+
if (pendingNotice?.pending) {
|
|
664
|
+
await reply(ctx, `${pendingNotice.message}\n\n${await botManagement.listBots()}`);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
await reply(ctx, await botManagement.listBots());
|
|
668
|
+
});
|
|
669
|
+
bot.command("bot", async (ctx) => {
|
|
670
|
+
await ensureOwnerControlAccess(ctx);
|
|
671
|
+
const sourceBotId = getBotId();
|
|
672
|
+
const { args, rest } = parseCommand(ctx.message?.text, 2);
|
|
673
|
+
const action = args[0]?.toLowerCase();
|
|
674
|
+
if (!action || !["add", "doctor", "main", "remove", "reload"].includes(action)) {
|
|
675
|
+
await reply(ctx, "Usage: `/bot add <token>`, `/bot doctor`, `/bot main <number|@username|id>`, `/bot remove <username|id>`, or `/bot reload`", {
|
|
676
|
+
parse_mode: "Markdown",
|
|
677
|
+
});
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (action === "add") {
|
|
681
|
+
const rawSecond = args[1]?.trim();
|
|
682
|
+
const token = rawSecond ?? rest?.trim() ?? "";
|
|
683
|
+
if (!token) {
|
|
684
|
+
await reply(ctx, "Usage: `/bot add <token>`", {
|
|
685
|
+
parse_mode: "Markdown",
|
|
686
|
+
});
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const result = await botManagement.addBot(token, sourceBotId, sourceBotToken, ctx.chat.id);
|
|
690
|
+
await reply(ctx, result.message);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (action === "doctor") {
|
|
694
|
+
const result = await botManagement.doctorBots(sourceBotId, sourceBotToken, ctx.chat.id);
|
|
695
|
+
await reply(ctx, result.message);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (action === "main") {
|
|
699
|
+
const result = await botManagement.setMainBot(rest?.trim() ?? args[1]?.trim() ?? "");
|
|
700
|
+
await reply(ctx, result.message);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (action === "remove") {
|
|
704
|
+
const result = await botManagement.removeBot(rest?.trim() ?? "", sourceBotId, sourceBotToken, ctx.chat.id);
|
|
705
|
+
await reply(ctx, result.message);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const result = await botManagement.reloadBots(sourceBotId, sourceBotToken, ctx.chat.id);
|
|
709
|
+
await reply(ctx, result.message);
|
|
710
|
+
});
|
|
711
|
+
bot.command("install", async (ctx) => {
|
|
712
|
+
await ensureOwnerControlAccess(ctx);
|
|
713
|
+
const { args } = parseCommand(ctx.message?.text, 1);
|
|
714
|
+
const provider = args[0]?.toLowerCase();
|
|
715
|
+
if (!provider || !["codex", "claude"].includes(provider)) {
|
|
716
|
+
await reply(ctx, "Usage: `/install codex` or `/install claude`", { parse_mode: "Markdown" });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
await runWithPendingAnimation(token, ctx.chat.id, async () => {
|
|
720
|
+
const result = await setupService.install(provider);
|
|
721
|
+
if (result.after) {
|
|
722
|
+
await bridge.rememberDefaultStartMode(provider);
|
|
723
|
+
}
|
|
724
|
+
return { chunks: flattenChunks([result.output], 3900) };
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
bot.command("login", async (ctx) => {
|
|
728
|
+
await ensureOwnerControlAccess(ctx);
|
|
729
|
+
const { args, rest } = parseCommand(ctx.message?.text, 1);
|
|
730
|
+
const provider = args[0]?.toLowerCase();
|
|
731
|
+
if (provider === "codex") {
|
|
732
|
+
await runWithPendingAnimation(token, ctx.chat.id, async () => {
|
|
733
|
+
const output = await setupService.startCodexLogin();
|
|
734
|
+
await bridge.rememberDefaultStartMode("codex");
|
|
735
|
+
return { chunks: flattenChunks([output], 3900) };
|
|
736
|
+
});
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (provider !== "claude") {
|
|
740
|
+
await reply(ctx, "Usage: `/login codex` or `/login claude` or `/login claude <token>`", { parse_mode: "Markdown" });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
await runWithPendingAnimation(token, ctx.chat.id, async () => {
|
|
744
|
+
const output = rest?.trim()
|
|
745
|
+
? await setupService.finishClaudeLogin(rest)
|
|
746
|
+
: await setupService.startClaudeLogin();
|
|
747
|
+
await bridge.rememberDefaultStartMode("claude");
|
|
748
|
+
return { chunks: flattenChunks([output], 3900) };
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
bot.command("sandbox", async (ctx) => {
|
|
752
|
+
const botId = getBotId();
|
|
753
|
+
const chatId = String(ctx.chat.id);
|
|
754
|
+
const { args } = parseCommand(ctx.message?.text, 2);
|
|
755
|
+
const provider = args[0]?.toLowerCase();
|
|
756
|
+
const sandboxMode = args[1]?.toLowerCase();
|
|
757
|
+
if (provider !== "codex" || !sandboxMode || !isCodexSandboxMode(sandboxMode)) {
|
|
758
|
+
await reply(ctx, "Usage: `/sandbox codex <read-only|workspace-write|danger-full-access>`", {
|
|
759
|
+
parse_mode: "Markdown",
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const mapping = await bridge.setCodexSandboxMode(botId, chatId, sandboxMode);
|
|
764
|
+
await reply(ctx, `Set Codex sandbox to ${sandboxMode}.\n\n${bridge.formatStatus(mapping)}`);
|
|
765
|
+
});
|
|
766
|
+
bot.command("reset", async (ctx) => {
|
|
767
|
+
const botId = getBotId();
|
|
768
|
+
const chatId = String(ctx.chat.id);
|
|
769
|
+
await stopPreviousSessionForRebind(botId, chatId, "Chat binding was reset; previous session execution was stopped.");
|
|
770
|
+
await bridge.reset(botId, chatId);
|
|
771
|
+
await reply(ctx, "Cleared all pairings for this chat.");
|
|
772
|
+
});
|
|
773
|
+
bot.on("message", async (ctx) => {
|
|
774
|
+
const botId = getBotId();
|
|
775
|
+
const chatId = String(ctx.chat.id);
|
|
776
|
+
const photo = ctx.message.photo?.at(-1);
|
|
777
|
+
const document = ctx.message.document;
|
|
778
|
+
const voice = ctx.message.voice;
|
|
779
|
+
const audio = ctx.message.audio;
|
|
780
|
+
const text = ctx.message.text?.trim();
|
|
781
|
+
if (text && isRecognizedSlashCommand(text, botId)) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (text && isUnsupportedSlashCommand(text, botId)) {
|
|
785
|
+
await reply(ctx, `Unsupported command: ${text.split(/\s+/, 1)[0]}\nRun /help for available commands.`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const mapping = await bridge.status(botId, chatId).catch(() => undefined);
|
|
789
|
+
if (autoContinue.isSuppressingNewWork(botId, chatId, mapping?.session.sessionId)) {
|
|
790
|
+
await bridge.logSystem(botId, chatId, "Telegram message ignored during stop cooldown.");
|
|
791
|
+
await reply(ctx, "Stopped. Ignored this message so the previous work does not restart. Send a new message again in a moment to start fresh.");
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (photo) {
|
|
795
|
+
const downloaded = await downloadTelegramFile(token, botId, chatId, photo.file_id, "telegram-photo.jpg");
|
|
796
|
+
await memoryService.recordArtifact({
|
|
797
|
+
session: mapping?.session,
|
|
798
|
+
botId,
|
|
799
|
+
chatId,
|
|
800
|
+
kind: "image",
|
|
801
|
+
filePath: downloaded.path,
|
|
802
|
+
fileName: "telegram-photo.jpg",
|
|
803
|
+
mimeType: "image/jpeg",
|
|
804
|
+
});
|
|
805
|
+
const message = await formatTelegramAttachmentPrompt("image", downloaded.path, ctx.message.caption?.trim());
|
|
806
|
+
await bridge.logSystem(botId, chatId, `Telegram image received: ${downloaded.path}`);
|
|
807
|
+
await runWithPendingAnimation(token, ctx.chat.id, async (helpers) => {
|
|
808
|
+
return {
|
|
809
|
+
chunks: await routeTelegramWorkLoop(bridge, botId, chatId, message, "Telegram image request", helpers, autoContinue, memoryService, sanitizeAttachmentResponseBlocks),
|
|
810
|
+
};
|
|
811
|
+
});
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (document) {
|
|
815
|
+
const attachment = classifyTelegramDocument(document.mime_type, document.file_name);
|
|
816
|
+
const downloaded = await downloadTelegramFile(token, botId, chatId, document.file_id, document.file_name);
|
|
817
|
+
await memoryService.recordArtifact({
|
|
818
|
+
session: mapping?.session,
|
|
819
|
+
botId,
|
|
820
|
+
chatId,
|
|
821
|
+
kind: attachment.kind,
|
|
822
|
+
filePath: downloaded.path,
|
|
823
|
+
fileName: document.file_name,
|
|
824
|
+
mimeType: document.mime_type,
|
|
825
|
+
});
|
|
826
|
+
const message = await formatTelegramAttachmentPrompt(attachment.kind, downloaded.path, ctx.message.caption?.trim(), {
|
|
827
|
+
fileName: document.file_name,
|
|
828
|
+
mimeType: document.mime_type,
|
|
829
|
+
isFallback: attachment.isFallback,
|
|
830
|
+
});
|
|
831
|
+
await bridge.logSystem(botId, chatId, `Telegram ${attachment.kind} received: ${downloaded.path}`);
|
|
832
|
+
await runWithPendingAnimation(token, ctx.chat.id, async (helpers) => {
|
|
833
|
+
return {
|
|
834
|
+
chunks: await routeTelegramWorkLoop(bridge, botId, chatId, message, `Telegram ${attachment.kind} request`, helpers, autoContinue, memoryService, sanitizeAttachmentResponseBlocks),
|
|
835
|
+
};
|
|
836
|
+
});
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (voice || audio) {
|
|
840
|
+
const attachmentKind = voice ? "voice message" : "audio file";
|
|
841
|
+
const downloaded = await downloadTelegramFile(token, botId, chatId, voice ? voice.file_id : audio.file_id, voice ? "telegram-voice.ogg" : audio?.file_name);
|
|
842
|
+
await memoryService.recordArtifact({
|
|
843
|
+
session: mapping?.session,
|
|
844
|
+
botId,
|
|
845
|
+
chatId,
|
|
846
|
+
kind: attachmentKind,
|
|
847
|
+
filePath: downloaded.path,
|
|
848
|
+
fileName: voice ? "telegram-voice.ogg" : audio?.file_name,
|
|
849
|
+
mimeType: voice?.mime_type ?? audio?.mime_type,
|
|
850
|
+
});
|
|
851
|
+
const message = await formatTelegramAttachmentPrompt(attachmentKind, downloaded.path, ctx.message.caption?.trim());
|
|
852
|
+
await bridge.logSystem(botId, chatId, `Telegram ${attachmentKind} received: ${downloaded.path}`);
|
|
853
|
+
await runWithPendingAnimation(token, ctx.chat.id, async (helpers) => {
|
|
854
|
+
return {
|
|
855
|
+
chunks: await routeTelegramWorkLoop(bridge, botId, chatId, message, `Telegram ${attachmentKind} request`, helpers, autoContinue, memoryService, sanitizeAttachmentResponseBlocks),
|
|
856
|
+
};
|
|
857
|
+
});
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (!text) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (isRemoteShellMessage(text)) {
|
|
864
|
+
const shellRequest = parseRemoteShellRequest(text);
|
|
865
|
+
if (!shellRequest) {
|
|
866
|
+
await reply(ctx, "Usage: `/! <command>`, `/!cmd <command>`, or `/!bash <command>`", {
|
|
867
|
+
parse_mode: "Markdown",
|
|
868
|
+
});
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const chatSession = await ensureRemoteShellAccess(ctx, bridge, botId, chatId);
|
|
872
|
+
await bridge.logSystem(botId, chatId, `Remote shell request (${shellRequest.kind}): ${shellRequest.command}`);
|
|
873
|
+
await runWithPendingAnimation(token, ctx.chat.id, async () => {
|
|
874
|
+
const result = await shellService.execute(shellRequest.command, chatSession.session.workspace, shellRequest.kind);
|
|
875
|
+
await bridge.logSystem(botId, chatId, `Remote shell finished (${result.shell}, exit ${result.code ?? "unknown"}).`);
|
|
876
|
+
return {
|
|
877
|
+
chunks: flattenChunks([formatRemoteShellResult(result, shellRequest.command, chatSession.session.workspace)], 3900),
|
|
878
|
+
parseMode: "HTML",
|
|
879
|
+
};
|
|
880
|
+
});
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
await bridge.logSystem(botId, chatId, `Telegram text received (${text.length} chars).`);
|
|
884
|
+
await messageBatcher.enqueue({ botToken: token, telegramChatId: ctx.chat.id }, botId, chatId, text);
|
|
885
|
+
});
|
|
886
|
+
bot.catch((error) => {
|
|
887
|
+
const ctx = error.ctx;
|
|
888
|
+
console.error(`Telegram update ${ctx.update.update_id} failed`);
|
|
889
|
+
if (isTelegramForbiddenError(error.error)) {
|
|
890
|
+
console.warn(`[telegram-delivery] bot=${getBotId()} chat=${ctx.chat?.id ?? "?"} skipped: ${error.error instanceof Error ? error.error.message : String(error.error)}`);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (error.error instanceof GrammyError) {
|
|
894
|
+
console.error("Telegram API error:", error.error.description);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (error.error instanceof HttpError) {
|
|
898
|
+
console.error("Network error:", error.error);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
console.error("Unhandled error:", error.error);
|
|
902
|
+
if (ctx.chat) {
|
|
903
|
+
void sendTelegramMessage(token, ctx.chat.id, error.error instanceof Error ? error.error.message : "An unexpected error occurred.").catch(() => undefined);
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
return bot;
|
|
907
|
+
}
|
|
908
|
+
class TelegramMessageBatcher {
|
|
909
|
+
delayMs;
|
|
910
|
+
onBatch;
|
|
911
|
+
pending = new Map();
|
|
912
|
+
manual = new Map();
|
|
913
|
+
constructor(delayMs, onBatch) {
|
|
914
|
+
this.delayMs = delayMs;
|
|
915
|
+
this.onBatch = onBatch;
|
|
916
|
+
}
|
|
917
|
+
async enqueue(target, botId, chatId, text) {
|
|
918
|
+
const key = this.key(botId, chatId);
|
|
919
|
+
const manualBatch = this.manual.get(key);
|
|
920
|
+
if (manualBatch) {
|
|
921
|
+
manualBatch.messages.push(text);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (this.delayMs === 0) {
|
|
925
|
+
await this.run(target, botId, chatId, text);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const existing = this.pending.get(key);
|
|
929
|
+
if (existing) {
|
|
930
|
+
existing.target = target;
|
|
931
|
+
existing.messages.push(text);
|
|
932
|
+
clearTimeout(existing.timer);
|
|
933
|
+
existing.timer = setTimeout(() => {
|
|
934
|
+
void this.flush(key);
|
|
935
|
+
}, this.delayMs);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
this.pending.set(key, {
|
|
939
|
+
target,
|
|
940
|
+
botId,
|
|
941
|
+
chatId,
|
|
942
|
+
messages: [text],
|
|
943
|
+
timer: setTimeout(() => {
|
|
944
|
+
void this.flush(key);
|
|
945
|
+
}, this.delayMs),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
startManual(botId, chatId) {
|
|
949
|
+
const key = this.key(botId, chatId);
|
|
950
|
+
const pendingBatch = this.pending.get(key);
|
|
951
|
+
if (pendingBatch) {
|
|
952
|
+
clearTimeout(pendingBatch.timer);
|
|
953
|
+
this.pending.delete(key);
|
|
954
|
+
}
|
|
955
|
+
this.manual.set(key, { messages: pendingBatch?.messages ?? [] });
|
|
956
|
+
}
|
|
957
|
+
async sendManual(target, botId, chatId) {
|
|
958
|
+
const key = this.key(botId, chatId);
|
|
959
|
+
const batch = this.manual.get(key);
|
|
960
|
+
if (!batch) {
|
|
961
|
+
return { found: false, count: 0 };
|
|
962
|
+
}
|
|
963
|
+
this.manual.delete(key);
|
|
964
|
+
if (batch.messages.length === 0) {
|
|
965
|
+
return { found: true, count: 0 };
|
|
966
|
+
}
|
|
967
|
+
await this.run(target, botId, chatId, batch.messages.join("\n"));
|
|
968
|
+
return { found: true, count: batch.messages.length };
|
|
969
|
+
}
|
|
970
|
+
cancelManual(botId, chatId) {
|
|
971
|
+
const key = this.key(botId, chatId);
|
|
972
|
+
const batch = this.manual.get(key);
|
|
973
|
+
if (!batch) {
|
|
974
|
+
return { found: false, count: 0 };
|
|
975
|
+
}
|
|
976
|
+
this.manual.delete(key);
|
|
977
|
+
return { found: true, count: batch.messages.length };
|
|
978
|
+
}
|
|
979
|
+
cancelPending(botId, chatId) {
|
|
980
|
+
const key = this.key(botId, chatId);
|
|
981
|
+
const batch = this.pending.get(key);
|
|
982
|
+
if (!batch) {
|
|
983
|
+
return { found: false, count: 0 };
|
|
984
|
+
}
|
|
985
|
+
clearTimeout(batch.timer);
|
|
986
|
+
this.pending.delete(key);
|
|
987
|
+
return { found: true, count: batch.messages.length };
|
|
988
|
+
}
|
|
989
|
+
manualStatus(botId, chatId) {
|
|
990
|
+
const batch = this.manual.get(this.key(botId, chatId));
|
|
991
|
+
return batch ? { found: true, count: batch.messages.length } : { found: false, count: 0 };
|
|
992
|
+
}
|
|
993
|
+
async flush(key) {
|
|
994
|
+
const batch = this.pending.get(key);
|
|
995
|
+
if (!batch) {
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
this.pending.delete(key);
|
|
999
|
+
clearTimeout(batch.timer);
|
|
1000
|
+
await this.run(batch.target, batch.botId, batch.chatId, batch.messages.join("\n"));
|
|
1001
|
+
}
|
|
1002
|
+
async run(target, botId, chatId, text) {
|
|
1003
|
+
try {
|
|
1004
|
+
await this.onBatch(target, botId, chatId, text);
|
|
1005
|
+
}
|
|
1006
|
+
catch (error) {
|
|
1007
|
+
const message = error instanceof Error ? error.message : "An unexpected error occurred.";
|
|
1008
|
+
console.error(`[telegram-batch] bot=${botId} chat=${chatId} failed: ${message}`, error);
|
|
1009
|
+
await sendTelegramMessage(target.botToken, target.telegramChatId, message).catch(() => undefined);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
key(botId, chatId) {
|
|
1013
|
+
return `${botId}:${chatId}`;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function sanitizeLoggedTelegramText(text) {
|
|
1017
|
+
const trimmed = text.trim();
|
|
1018
|
+
if (/^\/bot\s+add\s+/i.test(trimmed)) {
|
|
1019
|
+
return "/bot add [redacted]";
|
|
1020
|
+
}
|
|
1021
|
+
if (/^\/login\s+claude\s+/i.test(trimmed)) {
|
|
1022
|
+
return "/login claude [redacted]";
|
|
1023
|
+
}
|
|
1024
|
+
if (/^\/secret\s+set\s+/i.test(trimmed)) {
|
|
1025
|
+
const parts = trimmed.split(/\s+/);
|
|
1026
|
+
return `/secret set ${parts[2] ?? "[key]"} [redacted]`;
|
|
1027
|
+
}
|
|
1028
|
+
return text;
|
|
1029
|
+
}
|
|
1030
|
+
async function runWithPendingAnimation(botToken, chatId, task) {
|
|
1031
|
+
let typingStopped = false;
|
|
1032
|
+
let typingTimer;
|
|
1033
|
+
const typingStartedAt = Date.now();
|
|
1034
|
+
const pulseTyping = () => {
|
|
1035
|
+
if (typingStopped) {
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
void sendTelegramChatAction(botToken, chatId, "typing").catch((error) => {
|
|
1039
|
+
if (isTelegramForbiddenError(error)) {
|
|
1040
|
+
console.warn(`[telegram-delivery] chat=${chatId} skipped typing action: ${error instanceof Error ? error.message : String(error)}`);
|
|
1041
|
+
typingStopped = true;
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
console.warn(`[telegram-chat-action] chat=${chatId} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1045
|
+
});
|
|
1046
|
+
const elapsedMs = Date.now() - typingStartedAt;
|
|
1047
|
+
const nextDelayMs = elapsedMs < 60_000 ? 4_000 : 30_000;
|
|
1048
|
+
typingTimer = setTimeout(pulseTyping, nextDelayMs);
|
|
1049
|
+
typingTimer.unref?.();
|
|
1050
|
+
};
|
|
1051
|
+
pulseTyping();
|
|
1052
|
+
try {
|
|
1053
|
+
const helpers = {
|
|
1054
|
+
reportProgress: async (chunks, parseMode) => {
|
|
1055
|
+
const normalized = await normalizeTelegramDelivery(chunks);
|
|
1056
|
+
const progressChunks = flattenChunks(normalized.chunks, 3900);
|
|
1057
|
+
if (progressChunks.length === 0 && normalized.documents.length === 0) {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const extra = parseMode ? { parse_mode: parseMode } : undefined;
|
|
1061
|
+
for (const chunk of progressChunks) {
|
|
1062
|
+
await sendTelegramMessage(botToken, chatId, chunk, extra);
|
|
1063
|
+
}
|
|
1064
|
+
if (normalized.documents.length > 0) {
|
|
1065
|
+
await sendTelegramDocuments(botToken, chatId, normalized.documents);
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1068
|
+
};
|
|
1069
|
+
const result = await task(helpers);
|
|
1070
|
+
const normalized = await normalizeTelegramDelivery(result.chunks);
|
|
1071
|
+
const chunks = flattenChunks(normalized.chunks, 3900);
|
|
1072
|
+
const extra = result.parseMode ? { parse_mode: result.parseMode } : undefined;
|
|
1073
|
+
if (chunks.length === 0 && normalized.documents.length === 0) {
|
|
1074
|
+
await sendTelegramMessage(botToken, chatId, "Response was empty.");
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
for (const chunk of chunks) {
|
|
1078
|
+
await sendTelegramMessage(botToken, chatId, chunk, extra);
|
|
1079
|
+
}
|
|
1080
|
+
if (normalized.documents.length > 0) {
|
|
1081
|
+
await sendTelegramDocuments(botToken, chatId, normalized.documents);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
if (error instanceof SilentTelegramAbort) {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const message = error instanceof Error ? error.message : "An unexpected error occurred.";
|
|
1089
|
+
console.error(`[telegram-pending] chat=${chatId} failed: ${message}`, error);
|
|
1090
|
+
await sendTelegramMessage(botToken, chatId, message).catch(() => undefined);
|
|
1091
|
+
}
|
|
1092
|
+
finally {
|
|
1093
|
+
typingStopped = true;
|
|
1094
|
+
if (typingTimer) {
|
|
1095
|
+
clearTimeout(typingTimer);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, helpers, autoContinue, memoryService, transform = (blocks) => blocks) {
|
|
1100
|
+
const currentSession = await bridge.status(botId, chatId);
|
|
1101
|
+
const sessionId = currentSession?.session.sessionId;
|
|
1102
|
+
if (currentSession) {
|
|
1103
|
+
await memoryService.recordInstruction(currentSession.session, message);
|
|
1104
|
+
}
|
|
1105
|
+
const managedContext = currentSession
|
|
1106
|
+
? await memoryService.formatProviderContext(currentSession.session)
|
|
1107
|
+
: "";
|
|
1108
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1109
|
+
let prompt = appendManagedContext(appendReportProtocol(message), managedContext);
|
|
1110
|
+
const maxTurns = config.telegramAutoProgressMaxTurns;
|
|
1111
|
+
const emptyResponseRetries = config.telegramEmptyResponseRetries;
|
|
1112
|
+
const retryableErrorRetries = config.telegramRetryableErrorRetries;
|
|
1113
|
+
const retryableErrorDelayMs = config.telegramRetryableErrorDelayMs;
|
|
1114
|
+
const untaggedIntentRetries = config.telegramUntaggedIntentRetries;
|
|
1115
|
+
let emptyResponseRetryCount = 0;
|
|
1116
|
+
let retryableErrorCount = 0;
|
|
1117
|
+
let untaggedIntentRetryCount = 0;
|
|
1118
|
+
let missingEvidenceRetryCount = 0;
|
|
1119
|
+
let deliveredProgressCount = 0;
|
|
1120
|
+
const ensureStillBound = async (phase) => {
|
|
1121
|
+
if (!sessionId) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (await bridge.isChatBoundToSession(botId, chatId, sessionId)) {
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
autoContinue.requestSessionStop(sessionId);
|
|
1128
|
+
await bridge.stopSessionRun(sessionId, botId, chatId, `Telegram work loop stopped during ${phase} because the chat is now bound to another session.`);
|
|
1129
|
+
throw new SilentTelegramAbort(`Session ${currentSession?.session.publicId ?? sessionId} is no longer bound to this chat.`);
|
|
1130
|
+
};
|
|
1131
|
+
for (let turn = 1;; turn += 1) {
|
|
1132
|
+
if (typeof maxTurns === "number" && maxTurns > 0 && turn > maxTurns) {
|
|
1133
|
+
const limitMessage = `Automatic continue limit (${maxTurns}) reached before a final result.`;
|
|
1134
|
+
await bridge.logSystem(botId, chatId, limitMessage);
|
|
1135
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1136
|
+
return [limitMessage];
|
|
1137
|
+
}
|
|
1138
|
+
if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
|
|
1139
|
+
const stopMessage = "Automatic continuation stopped.";
|
|
1140
|
+
await bridge.logSystem(botId, chatId, stopMessage);
|
|
1141
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1142
|
+
return [stopMessage];
|
|
1143
|
+
}
|
|
1144
|
+
await ensureStillBound(`turn ${turn} start`);
|
|
1145
|
+
const turnLabel = `${label} turn ${turn}`;
|
|
1146
|
+
await bridge.logSystem(botId, chatId, `${turnLabel} started.`);
|
|
1147
|
+
try {
|
|
1148
|
+
const responses = sessionId
|
|
1149
|
+
? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt)
|
|
1150
|
+
: await bridge.routeMessage(botId, chatId, prompt);
|
|
1151
|
+
await ensureStillBound(`${turnLabel} response`);
|
|
1152
|
+
const parsed = parseReportResponses(bridge.formatResponses(responses), transform);
|
|
1153
|
+
await bridge.logSystem(botId, chatId, `${turnLabel} returned ${parsed.kind}.`);
|
|
1154
|
+
emptyResponseRetryCount = 0;
|
|
1155
|
+
retryableErrorCount = 0;
|
|
1156
|
+
if (parsed.kind === "progress") {
|
|
1157
|
+
untaggedIntentRetryCount = 0;
|
|
1158
|
+
missingEvidenceRetryCount = 0;
|
|
1159
|
+
deliveredProgressCount += 1;
|
|
1160
|
+
if (currentSession) {
|
|
1161
|
+
const progress = await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
|
|
1162
|
+
if (progress.repeated) {
|
|
1163
|
+
const repeatedMessage = [
|
|
1164
|
+
"Repeated progress detected. The same work pattern has appeared 3 or more times.",
|
|
1165
|
+
"Automatic continuation stopped so the task can be inspected instead of looping.",
|
|
1166
|
+
].join("\n");
|
|
1167
|
+
await bridge.logSystem(botId, chatId, repeatedMessage);
|
|
1168
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1169
|
+
return [repeatedMessage];
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
await ensureStillBound(`${turnLabel} progress delivery`);
|
|
1173
|
+
await helpers.reportProgress(parsed.chunks);
|
|
1174
|
+
if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
|
|
1175
|
+
const stopMessage = "Automatic continuation stopped after the latest progress report.";
|
|
1176
|
+
await bridge.logSystem(botId, chatId, stopMessage);
|
|
1177
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1178
|
+
return [stopMessage];
|
|
1179
|
+
}
|
|
1180
|
+
prompt = appendManagedContext(REPORT_CONTINUE_PROMPT, managedContext);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (parsed.kind === "result") {
|
|
1184
|
+
untaggedIntentRetryCount = 0;
|
|
1185
|
+
const resultText = parsed.chunks.join("\n");
|
|
1186
|
+
const evidenceIssue = classifyMissingResultEvidence(resultText);
|
|
1187
|
+
if (evidenceIssue && missingEvidenceRetryCount < 1) {
|
|
1188
|
+
missingEvidenceRetryCount += 1;
|
|
1189
|
+
const retryMessage = `${turnLabel} returned a result without required evidence: ${evidenceIssue}`;
|
|
1190
|
+
await bridge.logSystem(botId, chatId, retryMessage);
|
|
1191
|
+
prompt = appendManagedContext(formatMissingEvidenceRetryPrompt(resultText, evidenceIssue), managedContext);
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
if (evidenceIssue) {
|
|
1195
|
+
const blockedMessage = [
|
|
1196
|
+
"Provider reported a completed result without concrete evidence after a retry.",
|
|
1197
|
+
`Reason: ${evidenceIssue}`,
|
|
1198
|
+
"Automatic continuation stopped so the work is not accepted on an unsupported claim.",
|
|
1199
|
+
].join("\n");
|
|
1200
|
+
await bridge.logSystem(botId, chatId, blockedMessage);
|
|
1201
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1202
|
+
return [blockedMessage];
|
|
1203
|
+
}
|
|
1204
|
+
await ensureStillBound(`${turnLabel} final delivery`);
|
|
1205
|
+
if (currentSession) {
|
|
1206
|
+
await memoryService.completeTask(currentSession.session, parsed.chunks.join("\n"));
|
|
1207
|
+
}
|
|
1208
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1209
|
+
return parsed.chunks;
|
|
1210
|
+
}
|
|
1211
|
+
if (parsed.kind === "blocked") {
|
|
1212
|
+
untaggedIntentRetryCount = 0;
|
|
1213
|
+
missingEvidenceRetryCount = 0;
|
|
1214
|
+
await ensureStillBound(`${turnLabel} final delivery`);
|
|
1215
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1216
|
+
return parsed.chunks;
|
|
1217
|
+
}
|
|
1218
|
+
if (looksLikeUntaggedIntentOnlyResponse(parsed.chunks.join("\n")) && untaggedIntentRetryCount < untaggedIntentRetries) {
|
|
1219
|
+
untaggedIntentRetryCount += 1;
|
|
1220
|
+
const retryMessage = `${turnLabel} returned an untagged intent-only response; asking provider to do concrete work before replying.`;
|
|
1221
|
+
await bridge.logSystem(botId, chatId, retryMessage);
|
|
1222
|
+
prompt = appendManagedContext(formatUntaggedIntentRetryPrompt(parsed.chunks.join("\n")), managedContext);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
await bridge.logSystem(botId, chatId, `${turnLabel} returned an untagged response; treating it as final output.`);
|
|
1226
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1227
|
+
return parsed.chunks;
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
if (error instanceof SilentTelegramAbort) {
|
|
1231
|
+
throw error;
|
|
1232
|
+
}
|
|
1233
|
+
const messageText = error instanceof Error ? error.message : "An unexpected error occurred.";
|
|
1234
|
+
const retryable = classifyRetryableProviderIssue(messageText, retryableErrorDelayMs);
|
|
1235
|
+
if (isProviderTimeoutError(messageText)) {
|
|
1236
|
+
const timeoutMessage = formatProviderTimeoutFinalMessage(messageText);
|
|
1237
|
+
console.warn(`[telegram-route] bot=${botId} chat=${chatId} ${turnLabel} timed out: ${messageText}`);
|
|
1238
|
+
await bridge.logSystem(botId, chatId, `${turnLabel} timed out: ${messageText}`);
|
|
1239
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1240
|
+
return [timeoutMessage];
|
|
1241
|
+
}
|
|
1242
|
+
if (isEmptyResponseError(messageText) && emptyResponseRetryCount < emptyResponseRetries) {
|
|
1243
|
+
emptyResponseRetryCount += 1;
|
|
1244
|
+
const retryMessage = `${turnLabel} returned an empty response; retrying automatic continuation (${emptyResponseRetryCount}/${emptyResponseRetries}).`;
|
|
1245
|
+
console.warn(`[telegram-route] bot=${botId} chat=${chatId} ${retryMessage}`);
|
|
1246
|
+
await bridge.logSystem(botId, chatId, retryMessage);
|
|
1247
|
+
prompt = appendManagedContext(REPORT_CONTINUE_PROMPT, managedContext);
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if (retryable && retryableErrorCount < retryableErrorRetries) {
|
|
1251
|
+
retryableErrorCount += 1;
|
|
1252
|
+
const retryMessage = formatRetryableProviderRetryMessage(retryable, retryableErrorCount, retryableErrorRetries);
|
|
1253
|
+
console.warn(`[telegram-route] bot=${botId} chat=${chatId} ${turnLabel} retrying: ${messageText}`);
|
|
1254
|
+
await bridge.logSystem(botId, chatId, `${turnLabel} retrying after temporary provider issue: ${messageText}`);
|
|
1255
|
+
await helpers.reportProgress([retryMessage]);
|
|
1256
|
+
if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
|
|
1257
|
+
const stopMessage = "Automatic continuation stopped after the latest retry notice.";
|
|
1258
|
+
await bridge.logSystem(botId, chatId, stopMessage);
|
|
1259
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1260
|
+
return [stopMessage];
|
|
1261
|
+
}
|
|
1262
|
+
await sleep(retryable.retryAfterMs);
|
|
1263
|
+
prompt = appendManagedContext(REPORT_CONTINUE_PROMPT, managedContext);
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
console.error(`[telegram-route] bot=${botId} chat=${chatId} ${turnLabel} failed: ${messageText}`, error);
|
|
1267
|
+
await bridge.logSystem(botId, chatId, `${turnLabel} failed: ${messageText}`);
|
|
1268
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1269
|
+
if (retryable) {
|
|
1270
|
+
return [formatRetryableProviderFinalMessage(retryable)];
|
|
1271
|
+
}
|
|
1272
|
+
if (isEmptyResponseError(messageText) && deliveredProgressCount > 0) {
|
|
1273
|
+
return [
|
|
1274
|
+
"The last progress report was delivered, but the follow-up provider response came back empty. Automatic continuation stopped here.",
|
|
1275
|
+
"Send a new message such as `continue` to resume the same session from the latest state.",
|
|
1276
|
+
];
|
|
1277
|
+
}
|
|
1278
|
+
throw error;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
class SilentTelegramAbort extends Error {
|
|
1283
|
+
constructor(message) {
|
|
1284
|
+
super(message);
|
|
1285
|
+
this.name = "SilentTelegramAbort";
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
function appendReportProtocol(message) {
|
|
1289
|
+
return `${message}\n\n${REPORT_PROTOCOL_PROMPT}`;
|
|
1290
|
+
}
|
|
1291
|
+
function appendManagedContext(message, managedContext) {
|
|
1292
|
+
if (!managedContext.trim()) {
|
|
1293
|
+
return message;
|
|
1294
|
+
}
|
|
1295
|
+
return `${managedContext}\n\nUser request:\n${message}`;
|
|
1296
|
+
}
|
|
1297
|
+
function parseReportResponses(formattedBlocks, transform) {
|
|
1298
|
+
if (formattedBlocks.length === 0) {
|
|
1299
|
+
return { kind: "unknown", chunks: [] };
|
|
1300
|
+
}
|
|
1301
|
+
const parsedBlocks = formattedBlocks.map((block) => {
|
|
1302
|
+
const lines = block.split(/\r?\n/);
|
|
1303
|
+
const reportLineIndex = lines.findIndex((line, index) => index <= 1 && /^REPORT:(progress|result|blocked)$/i.test(line.trim()));
|
|
1304
|
+
if (reportLineIndex < 0) {
|
|
1305
|
+
return {
|
|
1306
|
+
kind: "unknown",
|
|
1307
|
+
text: block.trim(),
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
const header = lines.slice(0, reportLineIndex).join("\n").trim();
|
|
1311
|
+
const reportLine = lines[reportLineIndex].trim();
|
|
1312
|
+
const match = /^REPORT:(progress|result|blocked)$/i.exec(reportLine);
|
|
1313
|
+
let kind = match?.[1]?.toLowerCase() ?? "unknown";
|
|
1314
|
+
const body = lines.slice(reportLineIndex + 1).join("\n").trim();
|
|
1315
|
+
if ((kind === "progress" || kind === "result") && looksLikeBlockedBody(body)) {
|
|
1316
|
+
kind = "blocked";
|
|
1317
|
+
}
|
|
1318
|
+
return {
|
|
1319
|
+
kind,
|
|
1320
|
+
text: body ? [header, body].filter(Boolean).join("\n") : "",
|
|
1321
|
+
};
|
|
1322
|
+
});
|
|
1323
|
+
const kind = parsedBlocks[0]?.kind ?? "unknown";
|
|
1324
|
+
const chunks = transform(parsedBlocks.map((item) => item.text));
|
|
1325
|
+
return { kind, chunks };
|
|
1326
|
+
}
|
|
1327
|
+
function looksLikeUntaggedIntentOnlyResponse(text) {
|
|
1328
|
+
const normalized = text.trim();
|
|
1329
|
+
if (!normalized) {
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
const hasConcreteEvidence = [
|
|
1333
|
+
/REPORT:/i,
|
|
1334
|
+
/(완료|통과|실패|확인 결과|검증 결과|원인|근거|수정했습니다|배포했습니다|커밋|푸시)/,
|
|
1335
|
+
/\b(git status|git diff|npm run|node --check|docker|journalctl|grep|rg)\b/i,
|
|
1336
|
+
/`[^`]+`/,
|
|
1337
|
+
/:\d{1,5}\b/,
|
|
1338
|
+
].some((pattern) => pattern.test(normalized));
|
|
1339
|
+
if (hasConcreteEvidence) {
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
return [
|
|
1343
|
+
/(하겠습니다|진행하겠습니다|확인하겠습니다|수정하겠습니다|검증하겠습니다|대조하겠습니다|보겠습니다)/,
|
|
1344
|
+
/(진행해서|확인해서|수정해서|검증해서).*(하겠습니다|진행하겠습니다)/,
|
|
1345
|
+
/\b(I will|I'll|I am going to|going to|will continue|will check|will verify)\b/i,
|
|
1346
|
+
].some((pattern) => pattern.test(normalized));
|
|
1347
|
+
}
|
|
1348
|
+
function formatUntaggedIntentRetryPrompt(lastResponse) {
|
|
1349
|
+
return [
|
|
1350
|
+
"The previous response did not follow the REPORT protocol and only stated intent without concrete evidence.",
|
|
1351
|
+
"Do not repeat the plan or say what you will do.",
|
|
1352
|
+
"Do concrete work now before replying again.",
|
|
1353
|
+
"Reply with exactly one first line: REPORT:progress, REPORT:result, or REPORT:blocked.",
|
|
1354
|
+
"If you cannot continue, use REPORT:blocked and state the exact blocker.",
|
|
1355
|
+
"",
|
|
1356
|
+
"Previous invalid response:",
|
|
1357
|
+
lastResponse.trim(),
|
|
1358
|
+
].join("\n");
|
|
1359
|
+
}
|
|
1360
|
+
function classifyMissingResultEvidence(text) {
|
|
1361
|
+
const normalized = text.trim();
|
|
1362
|
+
if (!normalized) {
|
|
1363
|
+
return undefined;
|
|
1364
|
+
}
|
|
1365
|
+
if (!looksLikeCompletedWorkClaim(normalized)) {
|
|
1366
|
+
return undefined;
|
|
1367
|
+
}
|
|
1368
|
+
if (hasConcreteResultEvidence(normalized)) {
|
|
1369
|
+
return undefined;
|
|
1370
|
+
}
|
|
1371
|
+
return "REPORT:result claims completed work but does not include concrete evidence.";
|
|
1372
|
+
}
|
|
1373
|
+
function looksLikeCompletedWorkClaim(text) {
|
|
1374
|
+
return [
|
|
1375
|
+
/(수정|반영|배포|커밋|푸시|전송|생성|삭제|추가|적용|구현|저장|업데이트|등록|제거|정리|마이그레이션|검증|테스트|빌드).{0,24}(완료|했습니다|됐습니다|성공|통과)/,
|
|
1376
|
+
/(완료했습니다|완료됐습니다|끝났습니다|처리했습니다)/,
|
|
1377
|
+
/\b(fixed|implemented|deployed|committed|pushed|sent|created|deleted|updated|added|removed|migrated|verified|passed|completed|built)\b/i,
|
|
1378
|
+
].some((pattern) => pattern.test(text));
|
|
1379
|
+
}
|
|
1380
|
+
function hasConcreteResultEvidence(text) {
|
|
1381
|
+
return [
|
|
1382
|
+
/```/,
|
|
1383
|
+
/`[^`]+`/,
|
|
1384
|
+
/\b[0-9a-f]{7,40}\b/i,
|
|
1385
|
+
/sha256:[0-9a-f]{20,}/i,
|
|
1386
|
+
/\b(HTTP\s+\d{3}|exit\s+\d+|active|passed|failed)\b/i,
|
|
1387
|
+
/\b(npm run|git status|git diff|node --check|docker|journalctl|curl|psql|grep|rg|bash)\b/i,
|
|
1388
|
+
/\/[A-Za-z0-9._/-]{3,}/,
|
|
1389
|
+
/\b[A-Za-z0-9._/-]+\.(?:js|ts|tsx|jsx|sql|md|json|yml|yaml|sh|py|css|html|txt|log)\b/,
|
|
1390
|
+
/:\d{1,5}\b/,
|
|
1391
|
+
/(근거|검증|변경 파일|커밋|푸시|배포|로그|명령|출력|파일|라인|경로|상태)\s*:/,
|
|
1392
|
+
].some((pattern) => pattern.test(text));
|
|
1393
|
+
}
|
|
1394
|
+
function formatMissingEvidenceRetryPrompt(lastResponse, issue) {
|
|
1395
|
+
return [
|
|
1396
|
+
"The previous REPORT:result was not accepted by RemoteAgent.",
|
|
1397
|
+
issue,
|
|
1398
|
+
"RemoteAgent does not inspect code or decide whether the work is correct.",
|
|
1399
|
+
"You, the provider, must either provide concrete evidence for the completed work or change the reply to REPORT:progress or REPORT:blocked.",
|
|
1400
|
+
"Do not repeat a bare completion claim.",
|
|
1401
|
+
"Reply with exactly one first line: REPORT:progress, REPORT:result, or REPORT:blocked.",
|
|
1402
|
+
"",
|
|
1403
|
+
"Accepted evidence examples: file paths, line references, commands and outputs, log paths, commit IDs, image digests, deployment status, or explicit verification output.",
|
|
1404
|
+
"",
|
|
1405
|
+
"Previous unsupported result:",
|
|
1406
|
+
lastResponse.trim(),
|
|
1407
|
+
].join("\n");
|
|
1408
|
+
}
|
|
1409
|
+
function isEmptyResponseError(message) {
|
|
1410
|
+
return /empty response/i.test(message);
|
|
1411
|
+
}
|
|
1412
|
+
function looksLikeBlockedBody(text) {
|
|
1413
|
+
if (!text.trim()) {
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
const blockedPatterns = [
|
|
1417
|
+
/\b(sudo|usermod|setfacl|chmod|chown|relogin|re-login|new login session)\b/i,
|
|
1418
|
+
/\b(waiting on|need you to|you need to|please run|please do|manual step|admin step|external fix)\b/i,
|
|
1419
|
+
/\b(permission denied|permission change|ssh access|api key|login required|authentication required)\b/i,
|
|
1420
|
+
/적용되면.*(다시|이어서|계속)/i,
|
|
1421
|
+
/해주시면.*(다시|이어서|계속)/i,
|
|
1422
|
+
/권한.*(필요|없)/i,
|
|
1423
|
+
/로그인 세션.*필요/i,
|
|
1424
|
+
/관리자.*조치/i,
|
|
1425
|
+
];
|
|
1426
|
+
return blockedPatterns.some((pattern) => pattern.test(text));
|
|
1427
|
+
}
|
|
1428
|
+
function classifyRetryableProviderIssue(message, retryAfterMs) {
|
|
1429
|
+
if (/selected model is at capacity/i.test(message)) {
|
|
1430
|
+
return { kind: "capacity", retryAfterMs };
|
|
1431
|
+
}
|
|
1432
|
+
if (isEmptyResponseError(message)) {
|
|
1433
|
+
return { kind: "empty-response", retryAfterMs };
|
|
1434
|
+
}
|
|
1435
|
+
return undefined;
|
|
1436
|
+
}
|
|
1437
|
+
function formatRetryableProviderRetryMessage(issue, attempt, maxAttempts) {
|
|
1438
|
+
const waitSeconds = Math.max(1, Math.round(issue.retryAfterMs / 1000));
|
|
1439
|
+
switch (issue.kind) {
|
|
1440
|
+
case "capacity":
|
|
1441
|
+
return `선택한 모델이 capacity 상태라 ${waitSeconds}초 후 다시 시도합니다. (${attempt}/${maxAttempts})`;
|
|
1442
|
+
case "empty-response":
|
|
1443
|
+
return `후속 응답이 비어 있어 ${waitSeconds}초 후 다시 시도합니다. (${attempt}/${maxAttempts})`;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
function formatRetryableProviderFinalMessage(issue) {
|
|
1447
|
+
switch (issue.kind) {
|
|
1448
|
+
case "capacity":
|
|
1449
|
+
return "선택한 모델이 capacity 상태라 자동 재시도를 모두 사용했습니다. 잠시 후 다시 시도하거나 `/model`로 다른 모델을 선택해 주세요.";
|
|
1450
|
+
case "empty-response":
|
|
1451
|
+
return "후속 응답이 반복해서 비어 자동 재시도를 중단했습니다. 같은 세션에서 다시 시도해 주세요.";
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
function isProviderTimeoutError(message) {
|
|
1455
|
+
return /\b(Codex|Claude)\s+timed out after\s+\d+s without returning a final reply/i.test(message);
|
|
1456
|
+
}
|
|
1457
|
+
function formatProviderTimeoutFinalMessage(message) {
|
|
1458
|
+
const match = /\b(Codex|Claude)\s+timed out after\s+(\d+)s/i.exec(message);
|
|
1459
|
+
const provider = match?.[1] ?? "Provider";
|
|
1460
|
+
const seconds = match?.[2] ?? String(Math.round(config.commandTimeoutMs / 1000));
|
|
1461
|
+
return [
|
|
1462
|
+
`${provider} 실행이 ${seconds}초 안에 최종 응답을 반환하지 않아 중단했습니다.`,
|
|
1463
|
+
"",
|
|
1464
|
+
"같은 요청을 즉시 재시도하지 않았습니다. 이미 실행 프로세스가 timeout으로 종료된 상태라 반복 재시도하면 같은 루프가 생깁니다.",
|
|
1465
|
+
"",
|
|
1466
|
+
"긴 작업이면 먼저 timeout을 늘린 뒤 다시 요청하세요.",
|
|
1467
|
+
"예: /option timeout 600",
|
|
1468
|
+
].join("\n");
|
|
1469
|
+
}
|
|
1470
|
+
async function sleep(ms) {
|
|
1471
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1472
|
+
}
|
|
1473
|
+
async function ensureOwnerControlAccess(ctx) {
|
|
1474
|
+
if (ctx.chat?.type !== "private") {
|
|
1475
|
+
throw new Error("This command is available only in private 1:1 chats.");
|
|
1476
|
+
}
|
|
1477
|
+
if (!config.telegramOwnerId) {
|
|
1478
|
+
throw new Error("This command is disabled until TELEGRAM_OWNER_ID is configured.");
|
|
1479
|
+
}
|
|
1480
|
+
if (String(ctx.from?.id ?? "") !== config.telegramOwnerId) {
|
|
1481
|
+
throw new Error("This command is available only to the configured bot owner.");
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
async function ensureRemoteShellAccess(ctx, bridge, botId, chatId) {
|
|
1485
|
+
if (ctx.chat?.type !== "private") {
|
|
1486
|
+
throw new Error("Remote shell is available only in private 1:1 chats.");
|
|
1487
|
+
}
|
|
1488
|
+
if (!config.telegramOwnerId) {
|
|
1489
|
+
throw new Error("Remote shell is disabled until TELEGRAM_OWNER_ID is configured.");
|
|
1490
|
+
}
|
|
1491
|
+
if (String(ctx.from?.id ?? "") !== config.telegramOwnerId) {
|
|
1492
|
+
throw new Error("Remote shell is available only to the configured bot owner.");
|
|
1493
|
+
}
|
|
1494
|
+
const chatSession = await bridge.status(botId, chatId);
|
|
1495
|
+
if (!chatSession?.session.codex) {
|
|
1496
|
+
throw new Error("Remote shell requires an attached Codex session.");
|
|
1497
|
+
}
|
|
1498
|
+
if (chatSession.session.codex.sandboxMode !== "danger-full-access") {
|
|
1499
|
+
throw new Error("Remote shell is allowed only when Codex sandbox is set to danger-full-access.");
|
|
1500
|
+
}
|
|
1501
|
+
return chatSession;
|
|
1502
|
+
}
|
|
1503
|
+
function parseCommand(text, headCount) {
|
|
1504
|
+
const trimmed = text?.trim();
|
|
1505
|
+
if (!trimmed) {
|
|
1506
|
+
return { args: [] };
|
|
1507
|
+
}
|
|
1508
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
1509
|
+
if (firstSpace === -1) {
|
|
1510
|
+
return { args: [] };
|
|
1511
|
+
}
|
|
1512
|
+
let remaining = trimmed.slice(firstSpace + 1).trim();
|
|
1513
|
+
if (!remaining) {
|
|
1514
|
+
return { args: [] };
|
|
1515
|
+
}
|
|
1516
|
+
if (headCount === 0) {
|
|
1517
|
+
return {
|
|
1518
|
+
args: [],
|
|
1519
|
+
rest: remaining || undefined,
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
const args = [];
|
|
1523
|
+
for (let index = 0; index < headCount && remaining; index += 1) {
|
|
1524
|
+
const nextSpace = remaining.indexOf(" ");
|
|
1525
|
+
if (nextSpace === -1) {
|
|
1526
|
+
args.push(remaining);
|
|
1527
|
+
remaining = "";
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
args.push(remaining.slice(0, nextSpace));
|
|
1531
|
+
remaining = remaining.slice(nextSpace + 1).trim();
|
|
1532
|
+
}
|
|
1533
|
+
return {
|
|
1534
|
+
args,
|
|
1535
|
+
rest: remaining || undefined,
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
function formatSecretHelp() {
|
|
1539
|
+
return [
|
|
1540
|
+
"Secret command guide",
|
|
1541
|
+
"",
|
|
1542
|
+
"Store sensitive values without exposing them to agents or chat output.",
|
|
1543
|
+
"",
|
|
1544
|
+
"Commands:",
|
|
1545
|
+
"```text",
|
|
1546
|
+
"/secret set KEY value",
|
|
1547
|
+
"/secret list",
|
|
1548
|
+
"/secret remove KEY",
|
|
1549
|
+
"```",
|
|
1550
|
+
"",
|
|
1551
|
+
"Example:",
|
|
1552
|
+
"```text",
|
|
1553
|
+
"/secret set GIFTISHOW_AUTH_KEY REAL...",
|
|
1554
|
+
"/secret set GIFTISHOW_TOKEN_KEY xNC...",
|
|
1555
|
+
"```",
|
|
1556
|
+
"",
|
|
1557
|
+
"Then tell the agent:",
|
|
1558
|
+
"```text",
|
|
1559
|
+
"기프티쇼 인증 정보는 secret에 저장했어.",
|
|
1560
|
+
"GIFTISHOW_AUTH_KEY, GIFTISHOW_TOKEN_KEY를 사용해서 설정/검증해줘.",
|
|
1561
|
+
"```",
|
|
1562
|
+
"",
|
|
1563
|
+
"Agents only see the key names. They can read values with:",
|
|
1564
|
+
"```text",
|
|
1565
|
+
"node \"$REMOTEAGENT_SECRET_BIN\" get <KEY>",
|
|
1566
|
+
"```",
|
|
1567
|
+
].join("\n");
|
|
1568
|
+
}
|
|
1569
|
+
function formatRuntimeOptions() {
|
|
1570
|
+
return [
|
|
1571
|
+
"Runtime options",
|
|
1572
|
+
`- retry: ${formatRetryLimit(config.telegramAutoProgressMaxTurns)} (TELEGRAM_AUTO_PROGRESS_MAX_TURNS)`,
|
|
1573
|
+
`- timeout: ${formatTimeoutSeconds(config.commandTimeoutMs)} (COMMAND_TIMEOUT_MS)`,
|
|
1574
|
+
`- intent: ${formatRetryLimit(config.telegramUntaggedIntentRetries)} (TELEGRAM_UNTAGGED_INTENT_RETRIES)`,
|
|
1575
|
+
"",
|
|
1576
|
+
"Usage:",
|
|
1577
|
+
"/option retry <count>",
|
|
1578
|
+
"/option timeout <seconds>",
|
|
1579
|
+
"/option intent <count>",
|
|
1580
|
+
"",
|
|
1581
|
+
"`retry 0` disables the automatic continuation limit.",
|
|
1582
|
+
"`intent 0` disables untagged intent-only response retries.",
|
|
1583
|
+
].join("\n");
|
|
1584
|
+
}
|
|
1585
|
+
function formatRetryLimit(value) {
|
|
1586
|
+
return value === 0 ? "unlimited" : `${value}`;
|
|
1587
|
+
}
|
|
1588
|
+
function formatTimeoutSeconds(valueMs) {
|
|
1589
|
+
return `${Math.round(valueMs / 1000)}s`;
|
|
1590
|
+
}
|
|
1591
|
+
async function upsertInstalledEnvValue(key, value) {
|
|
1592
|
+
if (!/^[A-Z0-9_]+$/.test(key)) {
|
|
1593
|
+
throw new Error(`Invalid environment key: ${key}`);
|
|
1594
|
+
}
|
|
1595
|
+
const envPath = path.join(config.dataDir, ".env");
|
|
1596
|
+
await fs.mkdir(path.dirname(envPath), { recursive: true });
|
|
1597
|
+
const existing = await fs.readFile(envPath, "utf8").catch(() => "");
|
|
1598
|
+
const lines = existing ? existing.split(/\r?\n/) : [];
|
|
1599
|
+
let updated = false;
|
|
1600
|
+
const next = lines.map((line) => {
|
|
1601
|
+
if (line.startsWith(`${key}=`)) {
|
|
1602
|
+
updated = true;
|
|
1603
|
+
return `${key}=${value}`;
|
|
1604
|
+
}
|
|
1605
|
+
return line;
|
|
1606
|
+
});
|
|
1607
|
+
if (!updated) {
|
|
1608
|
+
next.push(`${key}=${value}`);
|
|
1609
|
+
}
|
|
1610
|
+
await fs.writeFile(envPath, `${next.join("\n").replace(/\n+$/u, "")}\n`, "utf8");
|
|
1611
|
+
}
|
|
1612
|
+
function chunkMessage(text, size) {
|
|
1613
|
+
if (text.length <= size) {
|
|
1614
|
+
return [text];
|
|
1615
|
+
}
|
|
1616
|
+
const chunks = [];
|
|
1617
|
+
let remaining = text;
|
|
1618
|
+
while (remaining.length > size) {
|
|
1619
|
+
const slice = remaining.slice(0, size);
|
|
1620
|
+
const breakAt = slice.lastIndexOf("\n");
|
|
1621
|
+
const index = breakAt > size * 0.5 ? breakAt : size;
|
|
1622
|
+
chunks.push(remaining.slice(0, index));
|
|
1623
|
+
remaining = remaining.slice(index).trimStart();
|
|
1624
|
+
}
|
|
1625
|
+
if (remaining) {
|
|
1626
|
+
chunks.push(remaining);
|
|
1627
|
+
}
|
|
1628
|
+
return chunks;
|
|
1629
|
+
}
|
|
1630
|
+
function flattenChunks(blocks, size) {
|
|
1631
|
+
return blocks.flatMap((block) => chunkMessage(block, size));
|
|
1632
|
+
}
|
|
1633
|
+
function isCodexSandboxMode(value) {
|
|
1634
|
+
return ["read-only", "workspace-write", "danger-full-access"].includes(value);
|
|
1635
|
+
}
|
|
1636
|
+
function isRemoteShellMessage(text) {
|
|
1637
|
+
return text.startsWith("/!");
|
|
1638
|
+
}
|
|
1639
|
+
function isRecognizedSlashCommand(text, botId) {
|
|
1640
|
+
if (!text.startsWith("/")) {
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
const token = text.slice(1).split(/\s+/, 1)[0]?.trim();
|
|
1644
|
+
if (!token) {
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
if (token.includes("/")) {
|
|
1648
|
+
return false;
|
|
1649
|
+
}
|
|
1650
|
+
const [name, mention] = token.split("@", 2);
|
|
1651
|
+
if (!name) {
|
|
1652
|
+
return false;
|
|
1653
|
+
}
|
|
1654
|
+
if (mention && mention.toLowerCase() !== botId.toLowerCase()) {
|
|
1655
|
+
return false;
|
|
1656
|
+
}
|
|
1657
|
+
return RECOGNIZED_COMMANDS.has(name.toLowerCase());
|
|
1658
|
+
}
|
|
1659
|
+
function isUnsupportedSlashCommand(text, botId) {
|
|
1660
|
+
if (!text.startsWith("/") || isRemoteShellMessage(text)) {
|
|
1661
|
+
return false;
|
|
1662
|
+
}
|
|
1663
|
+
const token = text.slice(1).split(/\s+/, 1)[0]?.trim();
|
|
1664
|
+
if (!token || token.includes("/")) {
|
|
1665
|
+
return false;
|
|
1666
|
+
}
|
|
1667
|
+
const [, mention] = token.split("@", 2);
|
|
1668
|
+
return !mention || mention.toLowerCase() === botId.toLowerCase();
|
|
1669
|
+
}
|
|
1670
|
+
function parseRemoteShellRequest(text) {
|
|
1671
|
+
const body = text.slice(2).trim();
|
|
1672
|
+
if (!body) {
|
|
1673
|
+
return undefined;
|
|
1674
|
+
}
|
|
1675
|
+
if (body.startsWith("cmd ")) {
|
|
1676
|
+
const command = body.slice(4).trim();
|
|
1677
|
+
return command ? { kind: "cmd", command } : undefined;
|
|
1678
|
+
}
|
|
1679
|
+
if (body.startsWith("bash ")) {
|
|
1680
|
+
const command = body.slice(5).trim();
|
|
1681
|
+
return command ? { kind: "bash", command } : undefined;
|
|
1682
|
+
}
|
|
1683
|
+
return { kind: "native", command: body };
|
|
1684
|
+
}
|
|
1685
|
+
function formatRemoteShellResult(result, command, cwd) {
|
|
1686
|
+
const parts = [
|
|
1687
|
+
`<b>[SHELL | ${escapeHtml(result.shell)} | exit ${escapeHtml(String(result.code ?? "unknown"))}]</b>`,
|
|
1688
|
+
`cwd: ${escapeHtml(cwd)}`,
|
|
1689
|
+
`$ ${escapeHtml(command)}`,
|
|
1690
|
+
];
|
|
1691
|
+
if (result.stdout) {
|
|
1692
|
+
parts.push("<pre>");
|
|
1693
|
+
parts.push(escapeHtml(result.stdout));
|
|
1694
|
+
parts.push("</pre>");
|
|
1695
|
+
}
|
|
1696
|
+
if (result.stderr) {
|
|
1697
|
+
parts.push("<b>[stderr]</b>");
|
|
1698
|
+
parts.push("<pre>");
|
|
1699
|
+
parts.push(escapeHtml(result.stderr));
|
|
1700
|
+
parts.push("</pre>");
|
|
1701
|
+
}
|
|
1702
|
+
if (!result.stdout && !result.stderr) {
|
|
1703
|
+
parts.push("<pre>(no output)</pre>");
|
|
1704
|
+
}
|
|
1705
|
+
return parts.join("\n");
|
|
1706
|
+
}
|
|
1707
|
+
function escapeHtml(text) {
|
|
1708
|
+
return text
|
|
1709
|
+
.replaceAll("&", "&")
|
|
1710
|
+
.replaceAll("<", "<")
|
|
1711
|
+
.replaceAll(">", ">");
|
|
1712
|
+
}
|
|
1713
|
+
function sanitizeAttachmentResponseBlocks(blocks) {
|
|
1714
|
+
const sanitized = blocks
|
|
1715
|
+
.map((block) => sanitizeAttachmentResponseText(block))
|
|
1716
|
+
.filter((block) => block.trim().length > 0);
|
|
1717
|
+
return sanitized.length > 0 ? sanitized : ["첨부는 받았습니다. 내부 경로는 숨기고 있습니다. 필요한 분석을 한 줄로 다시 보내 주세요."];
|
|
1718
|
+
}
|
|
1719
|
+
function sanitizeAttachmentResponseText(text) {
|
|
1720
|
+
const lines = text.split(/\r?\n/);
|
|
1721
|
+
const filtered = lines.filter((line) => {
|
|
1722
|
+
const trimmed = line.trim();
|
|
1723
|
+
if (!trimmed) {
|
|
1724
|
+
return true;
|
|
1725
|
+
}
|
|
1726
|
+
if (/^A Telegram .* was saved locally\.$/.test(trimmed)) {
|
|
1727
|
+
return false;
|
|
1728
|
+
}
|
|
1729
|
+
if (trimmed === "Do not repeat internal metadata such as local file paths unless it is strictly necessary.") {
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
if (trimmed.startsWith("Treat this caption as the user's instruction:")) {
|
|
1733
|
+
return false;
|
|
1734
|
+
}
|
|
1735
|
+
if (trimmed.startsWith("Attachment path for tool use:")) {
|
|
1736
|
+
return false;
|
|
1737
|
+
}
|
|
1738
|
+
if (trimmed === "File content preview:") {
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
if (trimmed.startsWith("/home/")) {
|
|
1742
|
+
return false;
|
|
1743
|
+
}
|
|
1744
|
+
if (trimmed.includes(".remoteagent/uploads/telegram/")) {
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
return true;
|
|
1748
|
+
});
|
|
1749
|
+
return filtered.join("\n").trim();
|
|
1750
|
+
}
|
|
1751
|
+
async function formatTelegramAttachmentPrompt(kind, filePath, caption, metadata) {
|
|
1752
|
+
const parts = [
|
|
1753
|
+
`A Telegram ${kind} was saved locally.`,
|
|
1754
|
+
"Do not repeat internal metadata such as local file paths unless it is strictly necessary.",
|
|
1755
|
+
metadata?.fileName ? `Original filename: ${metadata.fileName}` : undefined,
|
|
1756
|
+
metadata?.mimeType ? `Telegram MIME type: ${metadata.mimeType}` : undefined,
|
|
1757
|
+
metadata?.isFallback ? "This attachment was accepted through the generic fallback path. Inspect it from the saved file path and decide the right handling." : undefined,
|
|
1758
|
+
caption
|
|
1759
|
+
? `Treat this caption as the user's instruction: ${caption}`
|
|
1760
|
+
: "If the user gave no caption, inspect the attachment and respond briefly with the useful result.",
|
|
1761
|
+
`Attachment path for tool use: ${filePath}`,
|
|
1762
|
+
].filter(Boolean);
|
|
1763
|
+
const inlineText = await readInlineTextPreview(kind, filePath);
|
|
1764
|
+
if (inlineText) {
|
|
1765
|
+
parts.push("File content preview:");
|
|
1766
|
+
parts.push(inlineText);
|
|
1767
|
+
}
|
|
1768
|
+
return parts.join("\n");
|
|
1769
|
+
}
|
|
1770
|
+
function classifyTelegramDocument(mimeType, fileName) {
|
|
1771
|
+
const lowerName = fileName?.toLowerCase() ?? "";
|
|
1772
|
+
if (mimeType?.startsWith("image/")) {
|
|
1773
|
+
return { kind: "image document", isFallback: false };
|
|
1774
|
+
}
|
|
1775
|
+
if (mimeType === "application/pdf" || lowerName.endsWith(".pdf")) {
|
|
1776
|
+
return { kind: "PDF document", isFallback: false };
|
|
1777
|
+
}
|
|
1778
|
+
if (isWordDocument(mimeType, lowerName)) {
|
|
1779
|
+
return { kind: "Word document", isFallback: false };
|
|
1780
|
+
}
|
|
1781
|
+
if (isSpreadsheetDocument(mimeType, lowerName)) {
|
|
1782
|
+
return { kind: "Spreadsheet document", isFallback: false };
|
|
1783
|
+
}
|
|
1784
|
+
if (mimeType?.startsWith("text/")
|
|
1785
|
+
|| mimeType === "application/markdown"
|
|
1786
|
+
|| lowerName.endsWith(".txt")
|
|
1787
|
+
|| lowerName.endsWith(".md")
|
|
1788
|
+
|| lowerName.endsWith(".markdown")) {
|
|
1789
|
+
return {
|
|
1790
|
+
kind: lowerName.endsWith(".md") || lowerName.endsWith(".markdown") || mimeType === "text/markdown" || mimeType === "application/markdown"
|
|
1791
|
+
? "Markdown document"
|
|
1792
|
+
: "text document",
|
|
1793
|
+
isFallback: false,
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
if (isArchiveDocument(mimeType, lowerName)) {
|
|
1797
|
+
return { kind: "archive document", isFallback: false };
|
|
1798
|
+
}
|
|
1799
|
+
return { kind: "generic file", isFallback: true };
|
|
1800
|
+
}
|
|
1801
|
+
function isWordDocument(mimeType, lowerName) {
|
|
1802
|
+
if (mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
1803
|
+
|| mimeType === "application/msword") {
|
|
1804
|
+
return true;
|
|
1805
|
+
}
|
|
1806
|
+
return lowerName.endsWith(".docx") || lowerName.endsWith(".doc");
|
|
1807
|
+
}
|
|
1808
|
+
function isSpreadsheetDocument(mimeType, lowerName) {
|
|
1809
|
+
if (mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
1810
|
+
|| mimeType === "application/vnd.ms-excel"
|
|
1811
|
+
|| mimeType === "application/vnd.ms-excel.sheet.macroenabled.12") {
|
|
1812
|
+
return true;
|
|
1813
|
+
}
|
|
1814
|
+
return lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls") || lowerName.endsWith(".xlsm");
|
|
1815
|
+
}
|
|
1816
|
+
function isArchiveDocument(mimeType, lowerName) {
|
|
1817
|
+
const archiveMimeTypes = new Set([
|
|
1818
|
+
"application/zip",
|
|
1819
|
+
"application/x-zip-compressed",
|
|
1820
|
+
"application/x-tar",
|
|
1821
|
+
"application/gzip",
|
|
1822
|
+
"application/x-gzip",
|
|
1823
|
+
"application/x-7z-compressed",
|
|
1824
|
+
"application/vnd.rar",
|
|
1825
|
+
"application/x-rar-compressed",
|
|
1826
|
+
"application/x-bzip2",
|
|
1827
|
+
"application/x-xz",
|
|
1828
|
+
]);
|
|
1829
|
+
if (mimeType && archiveMimeTypes.has(mimeType)) {
|
|
1830
|
+
return true;
|
|
1831
|
+
}
|
|
1832
|
+
return [
|
|
1833
|
+
".zip",
|
|
1834
|
+
".tar",
|
|
1835
|
+
".tar.gz",
|
|
1836
|
+
".tgz",
|
|
1837
|
+
".gz",
|
|
1838
|
+
".7z",
|
|
1839
|
+
".rar",
|
|
1840
|
+
".bz2",
|
|
1841
|
+
".xz",
|
|
1842
|
+
].some((extension) => lowerName.endsWith(extension));
|
|
1843
|
+
}
|
|
1844
|
+
async function readInlineTextPreview(kind, filePath) {
|
|
1845
|
+
if (kind === "Word document") {
|
|
1846
|
+
return readWordDocumentPreview(filePath);
|
|
1847
|
+
}
|
|
1848
|
+
if (kind === "Spreadsheet document") {
|
|
1849
|
+
return readSpreadsheetDocumentPreview(filePath);
|
|
1850
|
+
}
|
|
1851
|
+
if (!["text document", "Markdown document"].includes(kind)) {
|
|
1852
|
+
return undefined;
|
|
1853
|
+
}
|
|
1854
|
+
const maxChars = 20_000;
|
|
1855
|
+
const text = await fs.readFile(filePath, "utf8").catch(() => undefined);
|
|
1856
|
+
if (!text) {
|
|
1857
|
+
return undefined;
|
|
1858
|
+
}
|
|
1859
|
+
if (text.length <= maxChars) {
|
|
1860
|
+
return text;
|
|
1861
|
+
}
|
|
1862
|
+
return `${text.slice(0, maxChars)}\n\n[truncated: ${text.length - maxChars} more chars in local file]`;
|
|
1863
|
+
}
|
|
1864
|
+
async function readWordDocumentPreview(filePath) {
|
|
1865
|
+
if (!filePath.toLowerCase().endsWith(".docx")) {
|
|
1866
|
+
return undefined;
|
|
1867
|
+
}
|
|
1868
|
+
const python = `
|
|
1869
|
+
import re
|
|
1870
|
+
import sys
|
|
1871
|
+
import zipfile
|
|
1872
|
+
import xml.etree.ElementTree as ET
|
|
1873
|
+
|
|
1874
|
+
MAX_CHARS = 20000
|
|
1875
|
+
path = sys.argv[1]
|
|
1876
|
+
with zipfile.ZipFile(path) as archive:
|
|
1877
|
+
xml_bytes = archive.read("word/document.xml")
|
|
1878
|
+
root = ET.fromstring(xml_bytes)
|
|
1879
|
+
text = "".join(node.text or "" for node in root.iter() if node.tag.endswith("}t"))
|
|
1880
|
+
text = re.sub(r"\\s+", " ", text).strip()
|
|
1881
|
+
if len(text) > MAX_CHARS:
|
|
1882
|
+
text = text[:MAX_CHARS] + f"\\n\\n[truncated: {len(text) - MAX_CHARS} more chars in local file]"
|
|
1883
|
+
print(text)
|
|
1884
|
+
`.trim();
|
|
1885
|
+
const { stdout } = await execFileAsync("python3", ["-c", python, filePath], { maxBuffer: 1024 * 1024 });
|
|
1886
|
+
const preview = stdout.trim();
|
|
1887
|
+
return preview || undefined;
|
|
1888
|
+
}
|
|
1889
|
+
async function readSpreadsheetDocumentPreview(filePath) {
|
|
1890
|
+
const lowerPath = filePath.toLowerCase();
|
|
1891
|
+
if (!lowerPath.endsWith(".xlsx") && !lowerPath.endsWith(".xlsm")) {
|
|
1892
|
+
return undefined;
|
|
1893
|
+
}
|
|
1894
|
+
const python = `
|
|
1895
|
+
import re
|
|
1896
|
+
import sys
|
|
1897
|
+
import zipfile
|
|
1898
|
+
import xml.etree.ElementTree as ET
|
|
1899
|
+
|
|
1900
|
+
MAX_CHARS = 20000
|
|
1901
|
+
MAX_ROWS = 120
|
|
1902
|
+
MAX_CELLS = 500
|
|
1903
|
+
path = sys.argv[1]
|
|
1904
|
+
ns = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
|
1905
|
+
shared_strings = []
|
|
1906
|
+
rows = []
|
|
1907
|
+
cell_count = 0
|
|
1908
|
+
|
|
1909
|
+
with zipfile.ZipFile(path) as archive:
|
|
1910
|
+
if "xl/sharedStrings.xml" in archive.namelist():
|
|
1911
|
+
shared_root = ET.fromstring(archive.read("xl/sharedStrings.xml"))
|
|
1912
|
+
for si in shared_root.findall("a:si", ns):
|
|
1913
|
+
parts = [node.text or "" for node in si.iter() if node.tag.endswith("}t")]
|
|
1914
|
+
shared_strings.append("".join(parts))
|
|
1915
|
+
|
|
1916
|
+
worksheet_names = sorted(
|
|
1917
|
+
name for name in archive.namelist()
|
|
1918
|
+
if name.startswith("xl/worksheets/") and name.endswith(".xml")
|
|
1919
|
+
)
|
|
1920
|
+
|
|
1921
|
+
for worksheet_name in worksheet_names:
|
|
1922
|
+
sheet_root = ET.fromstring(archive.read(worksheet_name))
|
|
1923
|
+
for row in sheet_root.findall(".//a:sheetData/a:row", ns):
|
|
1924
|
+
values = []
|
|
1925
|
+
for cell in row.findall("a:c", ns):
|
|
1926
|
+
cell_type = cell.get("t")
|
|
1927
|
+
if cell_type == "inlineStr":
|
|
1928
|
+
value = "".join(node.text or "" for node in cell.iter() if node.tag.endswith("}t"))
|
|
1929
|
+
else:
|
|
1930
|
+
value_node = cell.find("a:v", ns)
|
|
1931
|
+
if value_node is None or value_node.text is None:
|
|
1932
|
+
continue
|
|
1933
|
+
raw_value = value_node.text
|
|
1934
|
+
if cell_type == "s":
|
|
1935
|
+
try:
|
|
1936
|
+
value = shared_strings[int(raw_value)]
|
|
1937
|
+
except Exception:
|
|
1938
|
+
value = raw_value
|
|
1939
|
+
else:
|
|
1940
|
+
value = raw_value
|
|
1941
|
+
value = re.sub(r"\\s+", " ", value).strip()
|
|
1942
|
+
if not value:
|
|
1943
|
+
continue
|
|
1944
|
+
values.append(value)
|
|
1945
|
+
cell_count += 1
|
|
1946
|
+
if cell_count >= MAX_CELLS:
|
|
1947
|
+
break
|
|
1948
|
+
if values:
|
|
1949
|
+
rows.append("\t".join(values))
|
|
1950
|
+
if len(rows) >= MAX_ROWS or cell_count >= MAX_CELLS:
|
|
1951
|
+
break
|
|
1952
|
+
if len(rows) >= MAX_ROWS or cell_count >= MAX_CELLS:
|
|
1953
|
+
break
|
|
1954
|
+
|
|
1955
|
+
text = "\\n".join(rows).strip()
|
|
1956
|
+
if len(text) > MAX_CHARS:
|
|
1957
|
+
text = text[:MAX_CHARS] + f"\\n\\n[truncated: {len(text) - MAX_CHARS} more chars in local file]"
|
|
1958
|
+
print(text)
|
|
1959
|
+
`.trim();
|
|
1960
|
+
const { stdout } = await execFileAsync("python3", ["-c", python, filePath], { maxBuffer: 1024 * 1024 });
|
|
1961
|
+
const preview = stdout.trim();
|
|
1962
|
+
return preview || undefined;
|
|
1963
|
+
}
|
|
1964
|
+
async function downloadTelegramFile(botToken, botId, chatId, fileId, preferredName) {
|
|
1965
|
+
const file = await callTelegramApi(botToken, "getFile", {
|
|
1966
|
+
file_id: fileId,
|
|
1967
|
+
});
|
|
1968
|
+
if (!file.file_path) {
|
|
1969
|
+
throw new Error("Telegram did not return a file path for the attachment.");
|
|
1970
|
+
}
|
|
1971
|
+
const directory = path.join(config.dataDir, "uploads", "telegram", safePathSegment(botId), safePathSegment(chatId));
|
|
1972
|
+
await fs.mkdir(directory, { recursive: true });
|
|
1973
|
+
const extension = path.extname(file.file_path) || path.extname(preferredName ?? "") || ".bin";
|
|
1974
|
+
const basename = path.basename(preferredName ?? file.file_path, path.extname(preferredName ?? file.file_path));
|
|
1975
|
+
const outputPath = path.join(directory, `${Date.now()}-${safePathSegment(basename)}-${randomUUID()}${extension}`);
|
|
1976
|
+
const fileUrl = new URL(file.file_path, `https://api.telegram.org/file/bot${botToken}/`).toString();
|
|
1977
|
+
const { stderr } = await execFileAsync("curl", [
|
|
1978
|
+
"-fL",
|
|
1979
|
+
"-sS",
|
|
1980
|
+
"--max-time",
|
|
1981
|
+
"60",
|
|
1982
|
+
"-o",
|
|
1983
|
+
outputPath,
|
|
1984
|
+
fileUrl,
|
|
1985
|
+
]);
|
|
1986
|
+
if (stderr?.trim()) {
|
|
1987
|
+
console.error(`curl stderr for Telegram file download: ${stderr.trim()}`);
|
|
1988
|
+
}
|
|
1989
|
+
return { path: outputPath };
|
|
1990
|
+
}
|
|
1991
|
+
function safePathSegment(value) {
|
|
1992
|
+
const safe = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
1993
|
+
return safe || "file";
|
|
1994
|
+
}
|
|
1995
|
+
async function normalizeTelegramDelivery(chunks) {
|
|
1996
|
+
const documents = new Map();
|
|
1997
|
+
const normalizedChunks = await Promise.all(chunks.map(async (chunk) => {
|
|
1998
|
+
const lines = chunk.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
1999
|
+
const kept = [];
|
|
2000
|
+
for (const line of lines) {
|
|
2001
|
+
const match = /^TELEGRAM_FILE:\s*(.+?)\s*$/i.exec(line.trim());
|
|
2002
|
+
if (!match) {
|
|
2003
|
+
kept.push(line);
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
const candidatePath = match[1];
|
|
2007
|
+
if (await isReadableTelegramDocument(candidatePath)) {
|
|
2008
|
+
documents.set(candidatePath, { path: candidatePath });
|
|
2009
|
+
}
|
|
2010
|
+
else {
|
|
2011
|
+
kept.push(`Telegram file was requested but is missing: ${candidatePath}`);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
return kept.join("\n").trim();
|
|
2015
|
+
}));
|
|
2016
|
+
const nonEmptyChunks = normalizedChunks.filter(Boolean);
|
|
2017
|
+
if (documents.size === 0 && nonEmptyChunks.some((chunk) => mentionsTelegramDeliveryClaim(chunk))) {
|
|
2018
|
+
throw new Error("\ubaa8\ub378\uc774 \ud154\ub808\uadf8\ub7a8 \uc804\uc1a1 \uc644\ub8cc\ub97c \uc8fc\uc7a5\ud588\uc9c0\ub9cc, RemoteAgent\uac00 \ud655\uc778\ud55c \ud30c\uc77c \uc804\uc1a1 \uc9c0\uc2dc(`TELEGRAM_FILE: /absolute/path/to/file`)\ub294 \ud3ec\ud568\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ud30c\uc77c\uc744 \ubcf4\ub0b4\ub824\uba74 \ud574\ub2f9 \ud615\uc2dd\uc73c\ub85c \uc808\ub300 \uacbd\ub85c\ub97c \uba85\uc2dc\ud574\uc57c \ud569\ub2c8\ub2e4.");
|
|
2019
|
+
}
|
|
2020
|
+
return {
|
|
2021
|
+
chunks: nonEmptyChunks,
|
|
2022
|
+
documents: [...documents.values()],
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
async function isReadableTelegramDocument(filePath) {
|
|
2026
|
+
if (!path.isAbsolute(filePath)) {
|
|
2027
|
+
return false;
|
|
2028
|
+
}
|
|
2029
|
+
try {
|
|
2030
|
+
const stat = await fs.stat(filePath);
|
|
2031
|
+
return stat.isFile();
|
|
2032
|
+
}
|
|
2033
|
+
catch {
|
|
2034
|
+
return false;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
function mentionsTelegramDeliveryClaim(text) {
|
|
2038
|
+
return /(telegram).*(sent|delivered|delivery)|((sent|delivered|delivery).*(telegram))/i.test(text);
|
|
2039
|
+
}
|
|
2040
|
+
async function sendTelegramDocuments(botToken, chatId, documents) {
|
|
2041
|
+
for (const document of documents) {
|
|
2042
|
+
await sendTelegramDocument(botToken, chatId, document);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
async function sendTelegramDocument(botToken, chatId, document) {
|
|
2046
|
+
const resolvedPath = path.resolve(document.path);
|
|
2047
|
+
if (!(await isReadableTelegramDocument(resolvedPath))) {
|
|
2048
|
+
throw new Error(`Telegram document is missing or unreadable: ${resolvedPath}`);
|
|
2049
|
+
}
|
|
2050
|
+
const args = [
|
|
2051
|
+
"-sS",
|
|
2052
|
+
"--max-time",
|
|
2053
|
+
"120",
|
|
2054
|
+
"-F",
|
|
2055
|
+
`chat_id=${chatId}`,
|
|
2056
|
+
"-F",
|
|
2057
|
+
`document=@${resolvedPath}`,
|
|
2058
|
+
`https://api.telegram.org/bot${botToken}/sendDocument`,
|
|
2059
|
+
];
|
|
2060
|
+
if (document.caption?.trim()) {
|
|
2061
|
+
args.splice(args.length - 1, 0, "-F", `caption=${document.caption.trim()}`);
|
|
2062
|
+
}
|
|
2063
|
+
const { stdout, stderr } = await execFileAsync("curl", args);
|
|
2064
|
+
if (stderr?.trim()) {
|
|
2065
|
+
console.error(`curl stderr for sendDocument: ${stderr.trim()}`);
|
|
2066
|
+
}
|
|
2067
|
+
const payload = JSON.parse(stdout);
|
|
2068
|
+
if (!payload.ok || !payload.result) {
|
|
2069
|
+
throw new Error(payload.description || "Telegram API sendDocument failed.");
|
|
2070
|
+
}
|
|
2071
|
+
return payload.result;
|
|
2072
|
+
}
|
|
2073
|
+
async function sendTelegramMessage(botToken, chatId, text, extra) {
|
|
2074
|
+
const startedAt = Date.now();
|
|
2075
|
+
try {
|
|
2076
|
+
return await callTelegramApi(botToken, "sendMessage", {
|
|
2077
|
+
chat_id: String(chatId),
|
|
2078
|
+
text,
|
|
2079
|
+
parse_mode: extra?.parse_mode,
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
finally {
|
|
2083
|
+
const elapsedMs = Date.now() - startedAt;
|
|
2084
|
+
if (elapsedMs >= 3000) {
|
|
2085
|
+
console.warn(`[telegram-sendMessage-slow] chat=${chatId} elapsedMs=${elapsedMs} chars=${text.length}`);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
async function sendTelegramChatAction(botToken, chatId, action) {
|
|
2090
|
+
await callTelegramApi(botToken, "sendChatAction", {
|
|
2091
|
+
chat_id: String(chatId),
|
|
2092
|
+
action,
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
async function callTelegramApi(botToken, method, params) {
|
|
2096
|
+
const args = [
|
|
2097
|
+
"-sS",
|
|
2098
|
+
"--max-time",
|
|
2099
|
+
"35",
|
|
2100
|
+
`https://api.telegram.org/bot${botToken}/${method}`,
|
|
2101
|
+
];
|
|
2102
|
+
for (const [key, value] of Object.entries(params)) {
|
|
2103
|
+
if (value !== undefined) {
|
|
2104
|
+
args.push("--data-urlencode", `${key}=${value}`);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
const { stdout, stderr } = await execFileAsync("curl", args);
|
|
2108
|
+
if (stderr?.trim()) {
|
|
2109
|
+
console.error(`curl stderr for ${method}: ${stderr.trim()}`);
|
|
2110
|
+
}
|
|
2111
|
+
const payload = JSON.parse(stdout);
|
|
2112
|
+
if (!payload.ok) {
|
|
2113
|
+
throw new TelegramApiError(method, payload.description || `Telegram API ${method} failed.`);
|
|
2114
|
+
}
|
|
2115
|
+
return payload.result;
|
|
2116
|
+
}
|
|
2117
|
+
class TelegramApiError extends Error {
|
|
2118
|
+
method;
|
|
2119
|
+
description;
|
|
2120
|
+
constructor(method, description) {
|
|
2121
|
+
super(description);
|
|
2122
|
+
this.method = method;
|
|
2123
|
+
this.description = description;
|
|
2124
|
+
this.name = "TelegramApiError";
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
function isTelegramForbiddenError(error) {
|
|
2128
|
+
if (error instanceof GrammyError) {
|
|
2129
|
+
return error.error_code === 403 || /^Forbidden:/i.test(error.description);
|
|
2130
|
+
}
|
|
2131
|
+
if (error instanceof TelegramApiError) {
|
|
2132
|
+
return /^Forbidden:/i.test(error.description);
|
|
2133
|
+
}
|
|
2134
|
+
return error instanceof Error && /^Forbidden:/i.test(error.message);
|
|
2135
|
+
}
|