appback-remoteagent 0.13.16 → 0.13.17
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 +7 -0
- package/README.md +4 -0
- package/dist/bot.js +17 -0
- package/dist/config.js +7 -0
- package/dist/index.js +128 -52
- package/dist/services/bot-management-service.js +75 -8
- package/dist/services/bot-polling-state-service.js +139 -0
- package/dist/telegram-command-menu.js +2 -0
- package/docs/BOT_SLEEP.md +78 -0
- package/docs/OPERATIONS.md +4 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -8,6 +8,13 @@ TELEGRAM_EMPTY_RESPONSE_RETRIES=1
|
|
|
8
8
|
TELEGRAM_RETRYABLE_ERROR_RETRIES=2
|
|
9
9
|
TELEGRAM_RETRYABLE_ERROR_DELAY_MS=5000
|
|
10
10
|
TELEGRAM_UNTAGGED_INTENT_RETRIES=2
|
|
11
|
+
TELEGRAM_SCHEDULER_TICK_MS=1000
|
|
12
|
+
TELEGRAM_TIERED_POLLING_MIN_BOTS=4
|
|
13
|
+
TELEGRAM_ACTIVE_POLL_INTERVAL_MS=3000
|
|
14
|
+
TELEGRAM_IDLE_POLL_INTERVAL_MS=30000
|
|
15
|
+
TELEGRAM_COLD_POLL_INTERVAL_MS=300000
|
|
16
|
+
TELEGRAM_ACTIVE_IDLE_MS=1800000
|
|
17
|
+
TELEGRAM_COLD_IDLE_MS=21600000
|
|
11
18
|
ARTIFACT_CLEANUP_ENABLED=true
|
|
12
19
|
ARTIFACT_RETENTION_DAYS=30
|
|
13
20
|
ARTIFACT_CLEANUP_INTERVAL_MS=86400000
|
package/README.md
CHANGED
|
@@ -115,6 +115,8 @@ Current command surface implemented in `src/bot.ts`:
|
|
|
115
115
|
| `/bot main <number\|@username\|id>` | Selects the main bot. If no valid main is configured, the oldest configured bot is used |
|
|
116
116
|
| `/bot remove <username\|id>` | Removes a configured Telegram bot, restarts the runtime, and confirms the result after restart |
|
|
117
117
|
| `/bot reload` | Restarts the runtime and confirms the result after restart |
|
|
118
|
+
| `/sleep [bot]` | Puts a sub bot into deep sleep without removing its configuration |
|
|
119
|
+
| `/wake <bot>` | Wakes a sleeping sub bot |
|
|
118
120
|
| `/install codex\|claude` | Runs the configured provider install or update command for the bot owner |
|
|
119
121
|
| `/login codex` | Starts the Codex device-auth login flow and returns a browser URL when available |
|
|
120
122
|
| `/login claude [token]` | Starts or finishes the configured Claude Code login flow for the bot owner |
|
|
@@ -125,6 +127,8 @@ Current command surface implemented in `src/bot.ts`:
|
|
|
125
127
|
| `/batch cancel` | Discards the current batch |
|
|
126
128
|
| `/batch status` | Shows current batch state |
|
|
127
129
|
|
|
130
|
+
Bot sleep keeps long-idle sub bots configured while stopping their polling load. See [docs/BOT_SLEEP.md](./docs/BOT_SLEEP.md).
|
|
131
|
+
|
|
128
132
|
### 2. Terminal control
|
|
129
133
|
|
|
130
134
|
Remote shell control is available through:
|
package/dist/bot.js
CHANGED
|
@@ -36,6 +36,8 @@ const HELP_TEXT = [
|
|
|
36
36
|
"/bot main <number|@username|id>",
|
|
37
37
|
"/bot remove <username|id>",
|
|
38
38
|
"/bot reload",
|
|
39
|
+
"/sleep [bot]",
|
|
40
|
+
"/wake <bot>",
|
|
39
41
|
"/install codex|claude",
|
|
40
42
|
"/login codex",
|
|
41
43
|
"/login claude [token]",
|
|
@@ -84,6 +86,8 @@ const RECOGNIZED_COMMANDS = new Set([
|
|
|
84
86
|
"docs",
|
|
85
87
|
"bots",
|
|
86
88
|
"bot",
|
|
89
|
+
"sleep",
|
|
90
|
+
"wake",
|
|
87
91
|
"install",
|
|
88
92
|
"login",
|
|
89
93
|
"reset",
|
|
@@ -734,6 +738,19 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
734
738
|
const result = await botManagement.reloadBots(sourceBotId, sourceBotToken, ctx.chat.id);
|
|
735
739
|
await reply(ctx, result.message);
|
|
736
740
|
});
|
|
741
|
+
bot.command("sleep", async (ctx) => {
|
|
742
|
+
await ensureOwnerControlAccess(ctx);
|
|
743
|
+
const sourceBotId = getBotId();
|
|
744
|
+
const { rest } = parseCommand(ctx.message?.text, 2);
|
|
745
|
+
const result = await botManagement.sleepBot(rest?.trim() ?? "", sourceBotId);
|
|
746
|
+
await reply(ctx, result.message);
|
|
747
|
+
});
|
|
748
|
+
bot.command("wake", async (ctx) => {
|
|
749
|
+
await ensureOwnerControlAccess(ctx);
|
|
750
|
+
const { rest } = parseCommand(ctx.message?.text, 2);
|
|
751
|
+
const result = await botManagement.wakeBot(rest?.trim() ?? "");
|
|
752
|
+
await reply(ctx, result.message);
|
|
753
|
+
});
|
|
737
754
|
bot.command("install", async (ctx) => {
|
|
738
755
|
await ensureOwnerControlAccess(ctx);
|
|
739
756
|
const { args } = parseCommand(ctx.message?.text, 1);
|
package/dist/config.js
CHANGED
|
@@ -134,6 +134,13 @@ export const config = {
|
|
|
134
134
|
telegramPollingBackoffMinMs: readTimeout("TELEGRAM_POLLING_BACKOFF_MIN_MS", 60_000),
|
|
135
135
|
telegramPollingBackoffMaxMs: readTimeout("TELEGRAM_POLLING_BACKOFF_MAX_MS", 900_000),
|
|
136
136
|
telegramPollingMaxConcurrency: readTimeout("TELEGRAM_POLLING_MAX_CONCURRENCY", 3),
|
|
137
|
+
telegramSchedulerTickMs: readTimeout("TELEGRAM_SCHEDULER_TICK_MS", 1000),
|
|
138
|
+
telegramTieredPollingMinBots: readTimeout("TELEGRAM_TIERED_POLLING_MIN_BOTS", 4),
|
|
139
|
+
telegramActivePollIntervalMs: readTimeout("TELEGRAM_ACTIVE_POLL_INTERVAL_MS", 3000),
|
|
140
|
+
telegramIdlePollIntervalMs: readTimeout("TELEGRAM_IDLE_POLL_INTERVAL_MS", 30_000),
|
|
141
|
+
telegramColdPollIntervalMs: readTimeout("TELEGRAM_COLD_POLL_INTERVAL_MS", 300_000),
|
|
142
|
+
telegramActiveIdleMs: readTimeout("TELEGRAM_ACTIVE_IDLE_MS", 1_800_000),
|
|
143
|
+
telegramColdIdleMs: readTimeout("TELEGRAM_COLD_IDLE_MS", 21_600_000),
|
|
137
144
|
telegramOwnerId: readOptional("TELEGRAM_OWNER_ID"),
|
|
138
145
|
telegramMessageBatchMs: readNonNegativeTimeout("TELEGRAM_MESSAGE_BATCH_MS", 1500),
|
|
139
146
|
telegramTypingIntervalMs: readTimeout("TELEGRAM_TYPING_INTERVAL_MS", 10_000),
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { BridgeService } from "./services/bridge-service.js";
|
|
|
14
14
|
import { BotManagementService } from "./services/bot-management-service.js";
|
|
15
15
|
import { LocalUiService } from "./services/local-ui-service.js";
|
|
16
16
|
import { AgentMemoryService } from "./services/agent-memory-service.js";
|
|
17
|
+
import { BotPollingStateService } from "./services/bot-polling-state-service.js";
|
|
17
18
|
import { terminateAllSpawnedExecutions } from "./adapters/windows-shell.js";
|
|
18
19
|
import { setTelegramCommandMenu } from "./telegram-command-menu.js";
|
|
19
20
|
const execFileAsync = promisify(execFile);
|
|
@@ -23,10 +24,12 @@ let processLockPath;
|
|
|
23
24
|
let telegramTransportStatusPath;
|
|
24
25
|
const telegramTransportStatuses = {};
|
|
25
26
|
let telegramPollingLimiter;
|
|
27
|
+
let botPollingState;
|
|
26
28
|
async function main() {
|
|
27
29
|
processLockPath = await acquireProcessLock(config.dataDir);
|
|
28
30
|
telegramTransportStatusPath = path.join(config.dataDir, "telegram-transport.json");
|
|
29
31
|
telegramPollingLimiter = new AsyncSemaphore(config.telegramPollingMaxConcurrency);
|
|
32
|
+
botPollingState = new BotPollingStateService(config.dataDir);
|
|
30
33
|
registerProcessLifecycle();
|
|
31
34
|
const store = new FileStore(config.dataDir, config.defaultMode);
|
|
32
35
|
await store.init();
|
|
@@ -49,7 +52,7 @@ async function main() {
|
|
|
49
52
|
const availableProviders = ["codex", "claude"].filter((provider) => isProviderInstalled(provider));
|
|
50
53
|
console.log(`Available providers: ${availableProviders.length > 0 ? availableProviders.join(", ") : "none"}`);
|
|
51
54
|
const bridge = new BridgeService(store, adapters, config.defaultWorkspace, config.workspaceRoot, isProviderInstalled, config.defaultMode, config.codexSandboxMode);
|
|
52
|
-
const botManagement = new BotManagementService(config.dataDir, config.botRestartServiceName, config.botRestartHelperPath);
|
|
55
|
+
const botManagement = new BotManagementService(config.dataDir, config.botRestartServiceName, config.botRestartHelperPath, botPollingState);
|
|
53
56
|
startArtifactCleanupSchedule(new AgentMemoryService(config.dataDir));
|
|
54
57
|
if (config.localUiEnabled) {
|
|
55
58
|
const localUi = new LocalUiService(bridge, config.localUiHost, config.localUiPort);
|
|
@@ -81,7 +84,7 @@ async function main() {
|
|
|
81
84
|
await botManagement.reportPendingOperationResult().catch((error) => {
|
|
82
85
|
console.error("Failed to report pending bot operation result:", error);
|
|
83
86
|
});
|
|
84
|
-
await
|
|
87
|
+
await startManualPollingScheduler(bots);
|
|
85
88
|
}
|
|
86
89
|
function startArtifactCleanupSchedule(memoryService) {
|
|
87
90
|
if (!config.artifactCleanupEnabled) {
|
|
@@ -132,36 +135,78 @@ main().catch((error) => {
|
|
|
132
135
|
releaseProcessLockSync();
|
|
133
136
|
process.exitCode = 1;
|
|
134
137
|
});
|
|
135
|
-
async function
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
138
|
+
async function startManualPollingScheduler(bots) {
|
|
139
|
+
const pollingBots = bots;
|
|
140
|
+
const runtimeStates = new Map();
|
|
141
|
+
const botIds = pollingBots.map((bot) => String(bot.botInfo.id));
|
|
142
|
+
await botPollingState.prune(botIds);
|
|
143
|
+
const explicitMainBotId = process.env.TELEGRAM_MAIN_BOT_ID?.trim();
|
|
144
|
+
const mainBotId = explicitMainBotId && botIds.includes(explicitMainBotId)
|
|
145
|
+
? explicitMainBotId
|
|
146
|
+
: botIds[0];
|
|
147
|
+
for (const [index, bot] of pollingBots.entries()) {
|
|
148
|
+
const botId = String(bot.botInfo.id);
|
|
149
|
+
runtimeStates.set(botId, {
|
|
150
|
+
offset: 0,
|
|
151
|
+
inFlight: false,
|
|
152
|
+
consecutiveFailures: 0,
|
|
153
|
+
lastFailureLogAt: 0,
|
|
154
|
+
});
|
|
155
|
+
const initialDelayMs = Math.min(30_000, index * 3_000 + stableJitterMs(bot.botInfo.username));
|
|
156
|
+
await botPollingState.recordPoll(botId, {
|
|
157
|
+
username: bot.botInfo.username,
|
|
158
|
+
nextPollAt: new Date(Date.now() + initialDelayMs).toISOString(),
|
|
159
|
+
});
|
|
160
|
+
console.log(`Scheduled polling for @${bot.botInfo.username} in ${formatDuration(initialDelayMs)}.`);
|
|
145
161
|
}
|
|
146
162
|
while (true) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
consecutiveFailures: 0,
|
|
154
|
-
lastRecoveredAt: new Date().toISOString(),
|
|
155
|
-
}).catch((error) => {
|
|
156
|
-
console.error(`Failed to write Telegram transport recovery status for @${pollingBot.botInfo.username}:`, error);
|
|
157
|
-
});
|
|
158
|
-
consecutiveFailures = 0;
|
|
159
|
-
lastFailureLogAt = 0;
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
for (const bot of pollingBots) {
|
|
165
|
+
const botId = String(bot.botInfo.id);
|
|
166
|
+
const runtime = runtimeStates.get(botId);
|
|
167
|
+
if (!runtime || runtime.inFlight) {
|
|
168
|
+
continue;
|
|
160
169
|
}
|
|
161
|
-
|
|
170
|
+
const state = await botPollingState.get(botId, bot.botInfo.username);
|
|
171
|
+
const isMain = botId === mainBotId;
|
|
172
|
+
if (!isMain && state.sleepMode === "deep") {
|
|
162
173
|
continue;
|
|
163
174
|
}
|
|
164
|
-
const
|
|
175
|
+
const nextPollAt = state.nextPollAt ? Date.parse(state.nextPollAt) : 0;
|
|
176
|
+
if (Number.isFinite(nextPollAt) && nextPollAt > now) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
runtime.inFlight = true;
|
|
180
|
+
void pollTelegramBot(bot, runtime, {
|
|
181
|
+
isMain,
|
|
182
|
+
totalBots: pollingBots.length,
|
|
183
|
+
lastMessageAt: state.lastMessageAt,
|
|
184
|
+
}).finally(() => {
|
|
185
|
+
runtime.inFlight = false;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
await sleep(config.telegramSchedulerTickMs);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function pollTelegramBot(pollingBot, runtime, options) {
|
|
192
|
+
const botId = String(pollingBot.botInfo.id);
|
|
193
|
+
try {
|
|
194
|
+
const payload = await telegramPollingLimiter.run(() => getUpdatesViaCurl(pollingBot.token, runtime.offset));
|
|
195
|
+
if (runtime.consecutiveFailures > 0) {
|
|
196
|
+
console.warn(`Telegram polling recovered for @${pollingBot.botInfo.username} after ${runtime.consecutiveFailures} failure(s).`);
|
|
197
|
+
await writeTelegramTransportStatus(pollingBot.botInfo.username, {
|
|
198
|
+
status: "ok",
|
|
199
|
+
consecutiveFailures: 0,
|
|
200
|
+
lastRecoveredAt: new Date().toISOString(),
|
|
201
|
+
}).catch((error) => {
|
|
202
|
+
console.error(`Failed to write Telegram transport recovery status for @${pollingBot.botInfo.username}:`, error);
|
|
203
|
+
});
|
|
204
|
+
runtime.consecutiveFailures = 0;
|
|
205
|
+
runtime.lastFailureLogAt = 0;
|
|
206
|
+
}
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
const orderedUpdates = orderUpdatesForDispatch(payload.result);
|
|
209
|
+
if (orderedUpdates.length > 0) {
|
|
165
210
|
const stopUpdates = orderedUpdates.filter((update) => isStopCommandUpdate(update));
|
|
166
211
|
if (stopUpdates.length > 0) {
|
|
167
212
|
for (const update of stopUpdates) {
|
|
@@ -176,38 +221,69 @@ async function startManualPolling(bot, index) {
|
|
|
176
221
|
}
|
|
177
222
|
else {
|
|
178
223
|
for (const update of orderedUpdates) {
|
|
179
|
-
|
|
224
|
+
void pollingBot.handleUpdates([update]).catch((error) => {
|
|
180
225
|
console.error(`Telegram update ${update.update_id} handler failed for @${pollingBot.botInfo.username}:`, error);
|
|
181
|
-
}).finally(() => {
|
|
182
|
-
activeHandlers.delete(handler);
|
|
183
226
|
});
|
|
184
|
-
activeHandlers.add(handler);
|
|
185
227
|
}
|
|
186
228
|
}
|
|
187
|
-
offset =
|
|
229
|
+
runtime.offset = orderedUpdates[orderedUpdates.length - 1].update_id + 1;
|
|
188
230
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
231
|
+
const lastMessageAt = orderedUpdates.some(hasMessagePayload) ? new Date(now).toISOString() : options.lastMessageAt;
|
|
232
|
+
const nextPollAt = now + computePolicyPollIntervalMs(options.isMain, options.totalBots, lastMessageAt, now);
|
|
233
|
+
await botPollingState.recordPoll(botId, {
|
|
234
|
+
username: pollingBot.botInfo.username,
|
|
235
|
+
lastPollAt: new Date(now).toISOString(),
|
|
236
|
+
lastUpdateAt: orderedUpdates.length > 0 ? new Date(now).toISOString() : undefined,
|
|
237
|
+
lastMessageAt,
|
|
238
|
+
nextPollAt: new Date(nextPollAt).toISOString(),
|
|
239
|
+
consecutiveFailures: 0,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
runtime.consecutiveFailures += 1;
|
|
244
|
+
const issue = summarizeTelegramTransportError(error);
|
|
245
|
+
const delayMs = Math.max(nextPollingBackoffMs(runtime.consecutiveFailures, pollingBot.botInfo.username), getRetryAfterMs(error) ?? 0);
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
if (runtime.consecutiveFailures === 1 || now - runtime.lastFailureLogAt >= 60_000) {
|
|
248
|
+
runtime.lastFailureLogAt = now;
|
|
249
|
+
console.error(`Polling failed for @${pollingBot.botInfo.username}: ${issue}. `
|
|
250
|
+
+ `consecutiveFailures=${runtime.consecutiveFailures}; nextRetryIn=${formatDuration(delayMs)}.`);
|
|
209
251
|
}
|
|
252
|
+
await writeTelegramTransportStatus(pollingBot.botInfo.username, {
|
|
253
|
+
status: "degraded",
|
|
254
|
+
consecutiveFailures: runtime.consecutiveFailures,
|
|
255
|
+
lastIssue: issue,
|
|
256
|
+
lastFailureAt: new Date().toISOString(),
|
|
257
|
+
nextRetryAt: new Date(Date.now() + delayMs).toISOString(),
|
|
258
|
+
}).catch((statusError) => {
|
|
259
|
+
console.error(`Failed to write Telegram transport failure status for @${pollingBot.botInfo.username}:`, statusError);
|
|
260
|
+
});
|
|
261
|
+
await botPollingState.recordPoll(botId, {
|
|
262
|
+
username: pollingBot.botInfo.username,
|
|
263
|
+
consecutiveFailures: runtime.consecutiveFailures,
|
|
264
|
+
lastPollAt: new Date(now).toISOString(),
|
|
265
|
+
nextPollAt: new Date(now + delayMs).toISOString(),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function computePolicyPollIntervalMs(isMain, totalBots, lastMessageAt, now) {
|
|
270
|
+
if (isMain || totalBots < config.telegramTieredPollingMinBots) {
|
|
271
|
+
return config.telegramActivePollIntervalMs;
|
|
210
272
|
}
|
|
273
|
+
const lastMessageTime = lastMessageAt ? Date.parse(lastMessageAt) : undefined;
|
|
274
|
+
const idleMs = lastMessageTime && Number.isFinite(lastMessageTime)
|
|
275
|
+
? now - lastMessageTime
|
|
276
|
+
: Number.POSITIVE_INFINITY;
|
|
277
|
+
if (idleMs <= config.telegramActiveIdleMs) {
|
|
278
|
+
return config.telegramActivePollIntervalMs;
|
|
279
|
+
}
|
|
280
|
+
if (idleMs <= config.telegramColdIdleMs) {
|
|
281
|
+
return config.telegramIdlePollIntervalMs;
|
|
282
|
+
}
|
|
283
|
+
return config.telegramColdPollIntervalMs;
|
|
284
|
+
}
|
|
285
|
+
function hasMessagePayload(update) {
|
|
286
|
+
return Boolean(update.message || update.edited_message || update.channel_post);
|
|
211
287
|
}
|
|
212
288
|
function orderUpdatesForDispatch(updates) {
|
|
213
289
|
return [...updates].sort((left, right) => {
|
|
@@ -4,18 +4,21 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
+
import { BotPollingStateService } from "./bot-polling-state-service.js";
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
export class BotManagementService {
|
|
9
10
|
dataDir;
|
|
10
11
|
serviceName;
|
|
11
12
|
restartHelperPath;
|
|
13
|
+
pollingState;
|
|
12
14
|
envPath;
|
|
13
15
|
pendingPath;
|
|
14
16
|
backupsDir;
|
|
15
|
-
constructor(dataDir, serviceName, restartHelperPath) {
|
|
17
|
+
constructor(dataDir, serviceName, restartHelperPath, pollingState = new BotPollingStateService(dataDir)) {
|
|
16
18
|
this.dataDir = dataDir;
|
|
17
19
|
this.serviceName = serviceName;
|
|
18
20
|
this.restartHelperPath = restartHelperPath;
|
|
21
|
+
this.pollingState = pollingState;
|
|
19
22
|
this.envPath = path.join(this.dataDir, ".env");
|
|
20
23
|
this.pendingPath = path.join(this.dataDir, "pending-bot-operation.json");
|
|
21
24
|
this.backupsDir = path.join(this.dataDir, "backups");
|
|
@@ -26,7 +29,7 @@ export class BotManagementService {
|
|
|
26
29
|
if (bots.length === 0) {
|
|
27
30
|
return "No Telegram bots are configured.";
|
|
28
31
|
}
|
|
29
|
-
return this.formatBots(bots, env.mainBotId);
|
|
32
|
+
return this.formatBots(bots, env.mainBotId, await this.pollingState.list());
|
|
30
33
|
}
|
|
31
34
|
async formatCurrentBotSummary(currentBotId) {
|
|
32
35
|
const env = await this.readEnvConfig();
|
|
@@ -36,13 +39,48 @@ export class BotManagementService {
|
|
|
36
39
|
const currentLabel = current ? `@${current.username} (${current.id})` : currentBotId;
|
|
37
40
|
const mainLabel = main ? `@${main.username} (${main.id})` : "not configured";
|
|
38
41
|
const role = current && main && current.id === main.id ? "main" : "sub";
|
|
42
|
+
const states = await this.pollingState.list();
|
|
43
|
+
const currentState = current ? states[String(current.id)] : undefined;
|
|
39
44
|
return [
|
|
40
45
|
`bot: ${currentLabel}${current ? ` [${role}]` : ""}`,
|
|
41
46
|
`mainBot: ${mainLabel}`,
|
|
42
47
|
`botCount: ${bots.length}`,
|
|
43
|
-
|
|
48
|
+
`sleep: ${this.pollingState.formatMode(String(current?.id ?? currentBotId), role === "main", currentState)}`,
|
|
44
49
|
].join("\n");
|
|
45
50
|
}
|
|
51
|
+
async sleepBot(selector, currentBotId) {
|
|
52
|
+
const env = await this.readEnvConfig();
|
|
53
|
+
const bots = this.zipBots(env.tokens, env.usernames);
|
|
54
|
+
if (bots.length === 0) {
|
|
55
|
+
throw new Error("No Telegram bots are configured.");
|
|
56
|
+
}
|
|
57
|
+
const mainBotId = this.resolveMainBotId(bots, env.mainBotId);
|
|
58
|
+
const target = selector.trim()
|
|
59
|
+
? this.resolveBotSelector(bots, selector)
|
|
60
|
+
: this.resolveBotSelector(bots, currentBotId);
|
|
61
|
+
if (!target) {
|
|
62
|
+
throw new Error("Usage: /sleep <bot>");
|
|
63
|
+
}
|
|
64
|
+
if (String(target.id) === mainBotId) {
|
|
65
|
+
throw new Error(`@${target.username} is the main bot and cannot enter deep sleep.`);
|
|
66
|
+
}
|
|
67
|
+
await this.pollingState.setSleepMode(String(target.id), "deep", target.username);
|
|
68
|
+
return {
|
|
69
|
+
message: `Set @${target.username} to deep sleep.\n\nIt remains configured. RemoteAgent will stop polling it until /wake is used from an awake bot.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async wakeBot(selector) {
|
|
73
|
+
const env = await this.readEnvConfig();
|
|
74
|
+
const bots = this.zipBots(env.tokens, env.usernames);
|
|
75
|
+
const target = this.resolveBotSelector(bots, selector);
|
|
76
|
+
if (!target) {
|
|
77
|
+
throw new Error("Usage: /wake <bot>");
|
|
78
|
+
}
|
|
79
|
+
await this.pollingState.setSleepMode(String(target.id), "awake", target.username);
|
|
80
|
+
return {
|
|
81
|
+
message: `Woke @${target.username}. It will be polled again.`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
46
84
|
async getPendingOperationNotice() {
|
|
47
85
|
const pending = await this.readPendingOperation();
|
|
48
86
|
if (!pending) {
|
|
@@ -551,22 +589,51 @@ export class BotManagementService {
|
|
|
551
589
|
|| String(bot.id) === withoutAt
|
|
552
590
|
|| bot.token === normalized);
|
|
553
591
|
}
|
|
554
|
-
formatBots(bots, mainBotId) {
|
|
592
|
+
formatBots(bots, mainBotId, states = {}) {
|
|
555
593
|
if (bots.length === 0) {
|
|
556
594
|
return "No Telegram bots are configured.";
|
|
557
595
|
}
|
|
558
596
|
return [
|
|
559
597
|
`Configured bots (${bots.length})`,
|
|
560
|
-
...this.formatBotListLines(bots, mainBotId),
|
|
598
|
+
...this.formatBotListLines(bots, mainBotId, states),
|
|
561
599
|
].join("\n");
|
|
562
600
|
}
|
|
563
|
-
formatBotListLines(bots, explicitMainBotId) {
|
|
601
|
+
formatBotListLines(bots, explicitMainBotId, states = {}) {
|
|
564
602
|
const mainBotId = this.resolveMainBotId(bots, explicitMainBotId);
|
|
565
603
|
return bots.map((bot, index) => {
|
|
566
|
-
const
|
|
567
|
-
|
|
604
|
+
const botId = String(bot.id);
|
|
605
|
+
const isMain = botId === mainBotId;
|
|
606
|
+
const state = states[botId];
|
|
607
|
+
const details = [
|
|
608
|
+
`[${isMain ? "main" : "sub"}]`,
|
|
609
|
+
this.pollingState.formatMode(botId, isMain, state),
|
|
610
|
+
state?.lastMessageAt ? `lastMessage=${this.formatAge(state.lastMessageAt)}` : undefined,
|
|
611
|
+
state?.lastPollAt ? `lastPoll=${this.formatAge(state.lastPollAt)}` : undefined,
|
|
612
|
+
state?.nextPollAt && state.sleepMode !== "deep" ? `nextPoll=${this.formatAge(state.nextPollAt)}` : undefined,
|
|
613
|
+
state?.consecutiveFailures ? `failures=${state.consecutiveFailures}` : undefined,
|
|
614
|
+
].filter(Boolean).join(" ");
|
|
615
|
+
return `${index + 1}. @${bot.username} (${bot.id}) ${details}`;
|
|
568
616
|
});
|
|
569
617
|
}
|
|
618
|
+
formatAge(value) {
|
|
619
|
+
const timestamp = Date.parse(value);
|
|
620
|
+
if (!Number.isFinite(timestamp)) {
|
|
621
|
+
return value;
|
|
622
|
+
}
|
|
623
|
+
const diffMs = timestamp - Date.now();
|
|
624
|
+
const absMs = Math.abs(diffMs);
|
|
625
|
+
const suffix = diffMs > 0 ? "from now" : "ago";
|
|
626
|
+
if (absMs < 60_000) {
|
|
627
|
+
return `${Math.max(0, Math.round(absMs / 1000))}s ${suffix}`;
|
|
628
|
+
}
|
|
629
|
+
if (absMs < 3_600_000) {
|
|
630
|
+
return `${Math.round(absMs / 60_000)}m ${suffix}`;
|
|
631
|
+
}
|
|
632
|
+
if (absMs < 86_400_000) {
|
|
633
|
+
return `${Math.round(absMs / 3_600_000)}h ${suffix}`;
|
|
634
|
+
}
|
|
635
|
+
return `${Math.round(absMs / 86_400_000)}d ${suffix}`;
|
|
636
|
+
}
|
|
570
637
|
resolveMainBot(bots, mainBotId) {
|
|
571
638
|
if (bots.length === 0) {
|
|
572
639
|
return undefined;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const DEFAULT_STATE = {
|
|
4
|
+
version: 1,
|
|
5
|
+
updatedAt: "",
|
|
6
|
+
bots: {},
|
|
7
|
+
};
|
|
8
|
+
export class BotPollingStateService {
|
|
9
|
+
filePath;
|
|
10
|
+
cache;
|
|
11
|
+
pendingWrite;
|
|
12
|
+
writeInFlight = Promise.resolve();
|
|
13
|
+
constructor(dataDir) {
|
|
14
|
+
this.filePath = path.join(dataDir, "bot-polling-state.json");
|
|
15
|
+
}
|
|
16
|
+
async get(botId, username) {
|
|
17
|
+
const state = await this.read();
|
|
18
|
+
const current = state.bots[botId] ?? {
|
|
19
|
+
botId,
|
|
20
|
+
username,
|
|
21
|
+
sleepMode: "awake",
|
|
22
|
+
consecutiveFailures: 0,
|
|
23
|
+
};
|
|
24
|
+
if (username && current.username !== username) {
|
|
25
|
+
current.username = username;
|
|
26
|
+
state.bots[botId] = current;
|
|
27
|
+
this.scheduleWrite();
|
|
28
|
+
}
|
|
29
|
+
return current;
|
|
30
|
+
}
|
|
31
|
+
async list() {
|
|
32
|
+
return { ...(await this.read()).bots };
|
|
33
|
+
}
|
|
34
|
+
async setSleepMode(botId, sleepMode, username) {
|
|
35
|
+
const state = await this.read();
|
|
36
|
+
const current = state.bots[botId] ?? {
|
|
37
|
+
botId,
|
|
38
|
+
username,
|
|
39
|
+
sleepMode,
|
|
40
|
+
consecutiveFailures: 0,
|
|
41
|
+
};
|
|
42
|
+
current.sleepMode = sleepMode;
|
|
43
|
+
if (username) {
|
|
44
|
+
current.username = username;
|
|
45
|
+
}
|
|
46
|
+
if (sleepMode === "awake") {
|
|
47
|
+
current.nextPollAt = new Date().toISOString();
|
|
48
|
+
}
|
|
49
|
+
state.bots[botId] = current;
|
|
50
|
+
await this.writeNow();
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
53
|
+
async recordPoll(botId, patch) {
|
|
54
|
+
const state = await this.read();
|
|
55
|
+
const current = state.bots[botId] ?? {
|
|
56
|
+
botId,
|
|
57
|
+
sleepMode: "awake",
|
|
58
|
+
consecutiveFailures: 0,
|
|
59
|
+
};
|
|
60
|
+
state.bots[botId] = {
|
|
61
|
+
...current,
|
|
62
|
+
...patch,
|
|
63
|
+
botId,
|
|
64
|
+
};
|
|
65
|
+
this.scheduleWrite();
|
|
66
|
+
}
|
|
67
|
+
async prune(validBotIds) {
|
|
68
|
+
const valid = new Set(validBotIds);
|
|
69
|
+
const state = await this.read();
|
|
70
|
+
let changed = false;
|
|
71
|
+
for (const botId of Object.keys(state.bots)) {
|
|
72
|
+
if (!valid.has(botId)) {
|
|
73
|
+
delete state.bots[botId];
|
|
74
|
+
changed = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (changed) {
|
|
78
|
+
await this.writeNow();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
formatMode(botId, isMain, state) {
|
|
82
|
+
if (isMain) {
|
|
83
|
+
return "awake";
|
|
84
|
+
}
|
|
85
|
+
return state?.sleepMode === "deep" ? "deep sleep" : "awake";
|
|
86
|
+
}
|
|
87
|
+
async flush() {
|
|
88
|
+
if (this.pendingWrite) {
|
|
89
|
+
clearTimeout(this.pendingWrite);
|
|
90
|
+
this.pendingWrite = undefined;
|
|
91
|
+
}
|
|
92
|
+
await this.writeNow();
|
|
93
|
+
}
|
|
94
|
+
async read() {
|
|
95
|
+
if (this.cache) {
|
|
96
|
+
return this.cache;
|
|
97
|
+
}
|
|
98
|
+
const raw = await fs.readFile(this.filePath, "utf8").catch(() => "");
|
|
99
|
+
if (!raw.trim()) {
|
|
100
|
+
this.cache = { ...DEFAULT_STATE, bots: {} };
|
|
101
|
+
return this.cache;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(raw);
|
|
105
|
+
this.cache = {
|
|
106
|
+
version: 1,
|
|
107
|
+
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
|
|
108
|
+
bots: parsed.bots && typeof parsed.bots === "object" ? parsed.bots : {},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
this.cache = { ...DEFAULT_STATE, bots: {} };
|
|
113
|
+
}
|
|
114
|
+
return this.cache;
|
|
115
|
+
}
|
|
116
|
+
scheduleWrite() {
|
|
117
|
+
if (this.pendingWrite) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.pendingWrite = setTimeout(() => {
|
|
121
|
+
this.pendingWrite = undefined;
|
|
122
|
+
void this.writeNow().catch((error) => {
|
|
123
|
+
console.error("Failed to write bot polling state:", error);
|
|
124
|
+
});
|
|
125
|
+
}, 1000);
|
|
126
|
+
this.pendingWrite.unref();
|
|
127
|
+
}
|
|
128
|
+
async writeNow() {
|
|
129
|
+
const state = await this.read();
|
|
130
|
+
state.updatedAt = new Date().toISOString();
|
|
131
|
+
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
132
|
+
this.writeInFlight = this.writeInFlight.then(async () => {
|
|
133
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
134
|
+
await fs.writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
135
|
+
await fs.rename(tmpPath, this.filePath);
|
|
136
|
+
});
|
|
137
|
+
await this.writeInFlight;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -13,6 +13,8 @@ export const TELEGRAM_COMMAND_MENU = [
|
|
|
13
13
|
{ command: "batch", description: "Collect and send a multi-message batch" },
|
|
14
14
|
{ command: "bots", description: "List configured Telegram bots" },
|
|
15
15
|
{ command: "bot", description: "Manage Telegram bots" },
|
|
16
|
+
{ command: "sleep", description: "Deep sleep a sub bot" },
|
|
17
|
+
{ command: "wake", description: "Wake a sleeping sub bot" },
|
|
16
18
|
{ command: "install", description: "Install or update Codex or Claude" },
|
|
17
19
|
{ command: "login", description: "Run provider login flow" },
|
|
18
20
|
{ command: "reset", description: "Clear this chat binding" },
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Telegram Bot Sleep
|
|
2
|
+
|
|
3
|
+
## Why this exists
|
|
4
|
+
|
|
5
|
+
RemoteAgent can run multiple Telegram bots in one runtime.
|
|
6
|
+
Each active bot needs Telegram polling.
|
|
7
|
+
|
|
8
|
+
Operationally, up to three active polling bots has been stable enough.
|
|
9
|
+
When more than three bots are active, polling pressure and Telegram transport errors become more likely.
|
|
10
|
+
|
|
11
|
+
Sleep exists to reduce polling load without deleting bot configuration.
|
|
12
|
+
|
|
13
|
+
## Definitions
|
|
14
|
+
|
|
15
|
+
- main bot: the control bot that stays reachable
|
|
16
|
+
- sub bot: any configured bot that is not the main bot
|
|
17
|
+
- awake: the bot is polled normally
|
|
18
|
+
- deep sleep: the bot is configured but not polled
|
|
19
|
+
|
|
20
|
+
Sleep is not removal.
|
|
21
|
+
Removing a bot deletes it from runtime configuration.
|
|
22
|
+
Sleeping a bot keeps the bot registered and preserves its sessions.
|
|
23
|
+
|
|
24
|
+
## Policy
|
|
25
|
+
|
|
26
|
+
1. The main bot must not enter deep sleep.
|
|
27
|
+
2. Sub bots may enter deep sleep.
|
|
28
|
+
3. The main bot can wake a sleeping sub bot.
|
|
29
|
+
4. Bot doctor and sleep are separate features.
|
|
30
|
+
5. Bot doctor may remove permanently invalid Telegram bots.
|
|
31
|
+
6. Sleep must never remove a bot token or session state.
|
|
32
|
+
7. User-facing list output should show bot role and sleep state.
|
|
33
|
+
|
|
34
|
+
## Polling Strategy
|
|
35
|
+
|
|
36
|
+
The intended strategy is tiered:
|
|
37
|
+
|
|
38
|
+
1. recently used bots poll normally
|
|
39
|
+
2. idle bots poll less often
|
|
40
|
+
3. manually sleeping bots do not poll
|
|
41
|
+
|
|
42
|
+
This lets frequently used agents stay responsive while long-idle agents stop consuming polling capacity.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
Command surface:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
/sleep <bot>
|
|
50
|
+
/wake <bot>
|
|
51
|
+
/bot main <bot>
|
|
52
|
+
/bots
|
|
53
|
+
/list
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Expected behavior:
|
|
57
|
+
|
|
58
|
+
- `/sleep <bot>` puts a sub bot into deep sleep
|
|
59
|
+
- `/sleep` without a target may sleep the current bot only when it is not the main bot
|
|
60
|
+
- `/wake <bot>` wakes a sleeping sub bot
|
|
61
|
+
- `/bots` shows main/sub and awake/sleeping state
|
|
62
|
+
- `/list` shows session state and bot state where relevant
|
|
63
|
+
|
|
64
|
+
## Non-goals
|
|
65
|
+
|
|
66
|
+
- Do not infer sleep from natural-language provider replies.
|
|
67
|
+
- Do not treat sleep as `/bot remove`.
|
|
68
|
+
- Do not let Codex or Claude decide which bot should be removed.
|
|
69
|
+
- Do not create a separate sleep-only bot registry that can drift from configured bots.
|
|
70
|
+
|
|
71
|
+
## Implementation Notes
|
|
72
|
+
|
|
73
|
+
Sleep state should be keyed by stable Telegram bot id, not by display index.
|
|
74
|
+
|
|
75
|
+
Display indexes are only for user convenience.
|
|
76
|
+
Internal actions must resolve to bot id.
|
|
77
|
+
|
|
78
|
+
The runtime should still keep one always-awake control path through the main bot.
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -35,6 +35,10 @@ Current production bot ownership is intentionally split:
|
|
|
35
35
|
Do not run the same Telegram bot token from multiple runtimes at the same time.
|
|
36
36
|
Bot polling conflicts are treated as incidents, not harmless warnings.
|
|
37
37
|
|
|
38
|
+
When a runtime has more than three configured Telegram bots, polling pressure can become operationally visible.
|
|
39
|
+
Bot sleep is the planned mitigation: keep one main bot reachable and let long-idle sub bots stop polling without removing their configuration.
|
|
40
|
+
See [BOT_SLEEP.md](./BOT_SLEEP.md).
|
|
41
|
+
|
|
38
42
|
## Workspace policy
|
|
39
43
|
|
|
40
44
|
Default fresh sessions should not use a broad parent folder like `/home/au2223/projects` as their direct working directory.
|