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
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
export class BotManagementService {
|
|
9
|
+
dataDir;
|
|
10
|
+
serviceName;
|
|
11
|
+
restartHelperPath;
|
|
12
|
+
envPath;
|
|
13
|
+
pendingPath;
|
|
14
|
+
backupsDir;
|
|
15
|
+
constructor(dataDir, serviceName, restartHelperPath) {
|
|
16
|
+
this.dataDir = dataDir;
|
|
17
|
+
this.serviceName = serviceName;
|
|
18
|
+
this.restartHelperPath = restartHelperPath;
|
|
19
|
+
this.envPath = path.join(this.dataDir, ".env");
|
|
20
|
+
this.pendingPath = path.join(this.dataDir, "pending-bot-operation.json");
|
|
21
|
+
this.backupsDir = path.join(this.dataDir, "backups");
|
|
22
|
+
}
|
|
23
|
+
async listBots() {
|
|
24
|
+
const env = await this.readEnvConfig();
|
|
25
|
+
const bots = this.zipBots(env.tokens, env.usernames);
|
|
26
|
+
if (bots.length === 0) {
|
|
27
|
+
return "No Telegram bots are configured.";
|
|
28
|
+
}
|
|
29
|
+
return this.formatBots(bots, env.mainBotId);
|
|
30
|
+
}
|
|
31
|
+
async formatCurrentBotSummary(currentBotId) {
|
|
32
|
+
const env = await this.readEnvConfig();
|
|
33
|
+
const bots = this.zipBots(env.tokens, env.usernames);
|
|
34
|
+
const current = this.resolveBotSelector(bots, currentBotId);
|
|
35
|
+
const main = this.resolveMainBot(bots, env.mainBotId);
|
|
36
|
+
const currentLabel = current ? `@${current.username} (${current.id})` : currentBotId;
|
|
37
|
+
const mainLabel = main ? `@${main.username} (${main.id})` : "not configured";
|
|
38
|
+
const role = current && main && current.id === main.id ? "main" : "sub";
|
|
39
|
+
return [
|
|
40
|
+
`bot: ${currentLabel}${current ? ` [${role}]` : ""}`,
|
|
41
|
+
`mainBot: ${mainLabel}`,
|
|
42
|
+
`botCount: ${bots.length}`,
|
|
43
|
+
"sleep: awake",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
async getPendingOperationNotice() {
|
|
47
|
+
const pending = await this.readPendingOperation();
|
|
48
|
+
if (!pending) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
pending: pending.status === "pending",
|
|
53
|
+
message: this.formatPendingOperationMessage(pending),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async addBot(token, sourceBotId, sourceBotToken, chatId) {
|
|
57
|
+
await this.ensureSupported();
|
|
58
|
+
await this.assertNoPendingOperation();
|
|
59
|
+
const trimmed = token.trim();
|
|
60
|
+
if (!trimmed) {
|
|
61
|
+
throw new Error("Usage: /bot add <token>");
|
|
62
|
+
}
|
|
63
|
+
const target = await this.fetchBotIdentity(trimmed);
|
|
64
|
+
const env = await this.readEnvConfig();
|
|
65
|
+
const bots = this.zipBots(env.tokens, env.usernames);
|
|
66
|
+
const existing = bots.find((bot) => bot.token === trimmed
|
|
67
|
+
|| bot.id === target.id
|
|
68
|
+
|| bot.username.toLowerCase() === target.username.toLowerCase());
|
|
69
|
+
if (existing) {
|
|
70
|
+
throw new Error(`@${target.username} is already configured.`);
|
|
71
|
+
}
|
|
72
|
+
const tokens = [...env.tokens, trimmed];
|
|
73
|
+
const usernames = [...env.usernames, target.username];
|
|
74
|
+
const mainBotId = this.resolveMainBotId(this.zipBots(tokens, usernames), env.mainBotId);
|
|
75
|
+
const backupEnvPath = await this.backupEnv();
|
|
76
|
+
const pending = {
|
|
77
|
+
version: 1,
|
|
78
|
+
action: "add",
|
|
79
|
+
status: "pending",
|
|
80
|
+
requestedAt: new Date().toISOString(),
|
|
81
|
+
chatId,
|
|
82
|
+
replyToken: sourceBotToken,
|
|
83
|
+
notifyViaUsername: sourceBotId,
|
|
84
|
+
sourceBotId,
|
|
85
|
+
target: {
|
|
86
|
+
id: target.id,
|
|
87
|
+
username: target.username,
|
|
88
|
+
},
|
|
89
|
+
backupEnvPath,
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
await this.writePendingOperation(pending);
|
|
93
|
+
await this.writeEnvConfig(env.lines, tokens, usernames, mainBotId);
|
|
94
|
+
await this.launchRestartJob();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await this.restoreBackup(backupEnvPath).catch(() => undefined);
|
|
98
|
+
await this.clearPendingOperation().catch(() => undefined);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
message: [
|
|
103
|
+
`Applying bot add for @${target.username} (${target.id}).`,
|
|
104
|
+
"The runtime will restart once and then report the result here.",
|
|
105
|
+
].join("\n\n"),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async removeBot(selector, sourceBotId, sourceBotToken, chatId) {
|
|
109
|
+
await this.ensureSupported();
|
|
110
|
+
await this.assertNoPendingOperation();
|
|
111
|
+
const trimmed = selector.trim();
|
|
112
|
+
if (!trimmed) {
|
|
113
|
+
throw new Error("Usage: /bot remove <username|id>");
|
|
114
|
+
}
|
|
115
|
+
const env = await this.readEnvConfig();
|
|
116
|
+
const bots = this.zipBots(env.tokens, env.usernames);
|
|
117
|
+
const target = this.resolveBotSelector(bots, trimmed);
|
|
118
|
+
if (!target) {
|
|
119
|
+
throw new Error(`Bot was not found: ${trimmed}`);
|
|
120
|
+
}
|
|
121
|
+
if (bots.length <= 1) {
|
|
122
|
+
throw new Error("Cannot remove the last configured bot.");
|
|
123
|
+
}
|
|
124
|
+
const remainingBots = bots.filter((value) => value.token !== target.token);
|
|
125
|
+
const tokens = remainingBots.map((value) => value.token);
|
|
126
|
+
const usernames = remainingBots.map((value) => value.username);
|
|
127
|
+
const currentMain = this.resolveMainBot(bots, env.mainBotId);
|
|
128
|
+
const nextMainBotId = currentMain?.token === target.token
|
|
129
|
+
? this.promoteMainBotAfterRemoval(bots, target)?.id.toString()
|
|
130
|
+
: this.resolveMainBotId(remainingBots, env.mainBotId);
|
|
131
|
+
const replyToken = tokens.includes(sourceBotToken) ? sourceBotToken : tokens[0];
|
|
132
|
+
const notifyViaUsername = remainingBots.find((bot) => bot.token === replyToken)?.username;
|
|
133
|
+
const backupEnvPath = await this.backupEnv();
|
|
134
|
+
const pending = {
|
|
135
|
+
version: 1,
|
|
136
|
+
action: "remove",
|
|
137
|
+
status: "pending",
|
|
138
|
+
requestedAt: new Date().toISOString(),
|
|
139
|
+
chatId,
|
|
140
|
+
replyToken,
|
|
141
|
+
notifyViaUsername,
|
|
142
|
+
sourceBotId,
|
|
143
|
+
target: {
|
|
144
|
+
id: target.id,
|
|
145
|
+
username: target.username,
|
|
146
|
+
},
|
|
147
|
+
backupEnvPath,
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
await this.writePendingOperation(pending);
|
|
151
|
+
await this.writeEnvConfig(env.lines, tokens, usernames, nextMainBotId);
|
|
152
|
+
await this.launchRestartJob();
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
await this.restoreBackup(backupEnvPath).catch(() => undefined);
|
|
156
|
+
await this.clearPendingOperation().catch(() => undefined);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
const promoted = nextMainBotId ? remainingBots.find((bot) => String(bot.id) === nextMainBotId) : undefined;
|
|
160
|
+
const mainLine = promoted && currentMain?.token === target.token
|
|
161
|
+
? `Main bot will be promoted to @${promoted.username} (${promoted.id}).`
|
|
162
|
+
: undefined;
|
|
163
|
+
const notifyLine = replyToken === sourceBotToken || !notifyViaUsername
|
|
164
|
+
? "The runtime will restart once and then report the result here."
|
|
165
|
+
: `The runtime will restart once and then report the result through @${notifyViaUsername}.`;
|
|
166
|
+
return {
|
|
167
|
+
message: [
|
|
168
|
+
`Applying bot removal for @${target.username} (${target.id}).`,
|
|
169
|
+
mainLine,
|
|
170
|
+
notifyLine,
|
|
171
|
+
].filter(Boolean).join("\n\n"),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async setMainBot(selector) {
|
|
175
|
+
await this.ensureSupported();
|
|
176
|
+
await this.assertNoPendingOperation();
|
|
177
|
+
const trimmed = selector.trim();
|
|
178
|
+
if (!trimmed) {
|
|
179
|
+
throw new Error("Usage: /bot main <number|@username|id>");
|
|
180
|
+
}
|
|
181
|
+
const env = await this.readEnvConfig();
|
|
182
|
+
const bots = this.zipBots(env.tokens, env.usernames);
|
|
183
|
+
const target = this.resolveBotSelector(bots, trimmed);
|
|
184
|
+
if (!target) {
|
|
185
|
+
throw new Error(`Bot was not found: ${trimmed}`);
|
|
186
|
+
}
|
|
187
|
+
await this.writeEnvConfig(env.lines, env.tokens, env.usernames, String(target.id));
|
|
188
|
+
return {
|
|
189
|
+
message: [
|
|
190
|
+
`Set main bot to @${target.username} (${target.id}).`,
|
|
191
|
+
"",
|
|
192
|
+
this.formatBots(bots, String(target.id)),
|
|
193
|
+
].join("\n"),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
async doctorBots(sourceBotId, sourceBotToken, chatId) {
|
|
197
|
+
await this.ensureSupported();
|
|
198
|
+
await this.assertNoPendingOperation();
|
|
199
|
+
const env = await this.readEnvConfig();
|
|
200
|
+
const bots = this.zipBots(env.tokens, env.usernames);
|
|
201
|
+
if (bots.length === 0) {
|
|
202
|
+
return { message: "No Telegram bots are configured." };
|
|
203
|
+
}
|
|
204
|
+
const alive = [];
|
|
205
|
+
const dead = [];
|
|
206
|
+
const transient = [];
|
|
207
|
+
for (const bot of bots) {
|
|
208
|
+
try {
|
|
209
|
+
await this.fetchBotIdentity(bot.token);
|
|
210
|
+
alive.push(bot);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
214
|
+
if (this.isDeadBotError(reason)) {
|
|
215
|
+
dead.push(bot);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
transient.push({ bot, reason });
|
|
219
|
+
alive.push(bot);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (dead.length === 0) {
|
|
224
|
+
return {
|
|
225
|
+
message: [
|
|
226
|
+
"Bot doctor completed. No dead bots were removed.",
|
|
227
|
+
`alive: ${alive.length}`,
|
|
228
|
+
transient.length > 0 ? `temporary issues: ${transient.length}` : undefined,
|
|
229
|
+
...transient.map(({ bot, reason }) => `- @${bot.username} (${bot.id}): ${reason}`),
|
|
230
|
+
].filter(Boolean).join("\n"),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (alive.length === 0) {
|
|
234
|
+
throw new Error("Bot doctor found every configured bot dead. Refusing to remove the last usable bot.");
|
|
235
|
+
}
|
|
236
|
+
const tokens = alive.map((bot) => bot.token);
|
|
237
|
+
const usernames = alive.map((bot) => bot.username);
|
|
238
|
+
const mainBotId = this.resolveMainBotId(alive, env.mainBotId);
|
|
239
|
+
const replyToken = tokens.includes(sourceBotToken) ? sourceBotToken : tokens[0];
|
|
240
|
+
const notifyViaUsername = alive.find((bot) => bot.token === replyToken)?.username;
|
|
241
|
+
const backupEnvPath = await this.backupEnv();
|
|
242
|
+
const pending = {
|
|
243
|
+
version: 1,
|
|
244
|
+
action: "doctor",
|
|
245
|
+
status: "pending",
|
|
246
|
+
requestedAt: new Date().toISOString(),
|
|
247
|
+
chatId,
|
|
248
|
+
replyToken,
|
|
249
|
+
notifyViaUsername,
|
|
250
|
+
sourceBotId,
|
|
251
|
+
targets: dead.map((bot) => ({
|
|
252
|
+
id: bot.id,
|
|
253
|
+
username: bot.username,
|
|
254
|
+
})),
|
|
255
|
+
backupEnvPath,
|
|
256
|
+
};
|
|
257
|
+
try {
|
|
258
|
+
await this.writePendingOperation(pending);
|
|
259
|
+
await this.writeEnvConfig(env.lines, tokens, usernames, mainBotId);
|
|
260
|
+
await this.launchRestartJob();
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
await this.restoreBackup(backupEnvPath).catch(() => undefined);
|
|
264
|
+
await this.clearPendingOperation().catch(() => undefined);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
message: [
|
|
269
|
+
`Bot doctor removed ${dead.length} dead bot(s).`,
|
|
270
|
+
...dead.map((bot) => `- @${bot.username} (${bot.id})`),
|
|
271
|
+
transient.length > 0 ? `Temporary issues were kept: ${transient.length}` : undefined,
|
|
272
|
+
"The runtime will restart once and then report the result.",
|
|
273
|
+
].filter(Boolean).join("\n"),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
async reloadBots(sourceBotId, sourceBotToken, chatId) {
|
|
277
|
+
await this.ensureSupported();
|
|
278
|
+
await this.assertNoPendingOperation();
|
|
279
|
+
const backupEnvPath = await this.backupEnv();
|
|
280
|
+
const pending = {
|
|
281
|
+
version: 1,
|
|
282
|
+
action: "reload",
|
|
283
|
+
status: "pending",
|
|
284
|
+
requestedAt: new Date().toISOString(),
|
|
285
|
+
chatId,
|
|
286
|
+
replyToken: sourceBotToken,
|
|
287
|
+
notifyViaUsername: sourceBotId,
|
|
288
|
+
sourceBotId,
|
|
289
|
+
backupEnvPath,
|
|
290
|
+
};
|
|
291
|
+
try {
|
|
292
|
+
await this.writePendingOperation(pending);
|
|
293
|
+
await this.launchRestartJob();
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
await this.clearPendingOperation().catch(() => undefined);
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
message: "Restarting the runtime now. I will report the result here after it comes back.",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
async reportPendingOperationResult() {
|
|
304
|
+
const pending = await this.readPendingOperation();
|
|
305
|
+
if (!pending) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const env = await this.readEnvConfig().catch(() => undefined);
|
|
309
|
+
const bots = env ? this.zipBots(env.tokens, env.usernames) : [];
|
|
310
|
+
const mainBotId = this.resolveMainBotId(bots, env?.mainBotId);
|
|
311
|
+
const listLines = bots.length > 0
|
|
312
|
+
? this.formatBotListLines(bots, mainBotId).map((line) => `- ${line.replace(/^\d+\.\s*/, "")}`)
|
|
313
|
+
: ["- none"];
|
|
314
|
+
const lines = pending.status === "rolled_back"
|
|
315
|
+
? [
|
|
316
|
+
"Bot configuration failed and was rolled back.",
|
|
317
|
+
`Action: ${pending.action}`,
|
|
318
|
+
pending.target ? `Target: @${pending.target.username} (${pending.target.id})` : undefined,
|
|
319
|
+
this.formatPendingTargets(pending),
|
|
320
|
+
pending.reason ? `Reason: ${pending.reason}` : undefined,
|
|
321
|
+
"Current configured bots:",
|
|
322
|
+
...listLines,
|
|
323
|
+
]
|
|
324
|
+
: [
|
|
325
|
+
"Bot configuration applied successfully.",
|
|
326
|
+
`Action: ${pending.action}`,
|
|
327
|
+
pending.target ? `Target: @${pending.target.username} (${pending.target.id})` : undefined,
|
|
328
|
+
this.formatPendingTargets(pending),
|
|
329
|
+
"Current configured bots:",
|
|
330
|
+
...listLines,
|
|
331
|
+
];
|
|
332
|
+
try {
|
|
333
|
+
await this.sendTelegramMessage(pending.replyToken, pending.chatId, lines.filter(Boolean).join("\n"));
|
|
334
|
+
await this.clearPendingOperation();
|
|
335
|
+
await fs.rm(pending.backupEnvPath, { force: true }).catch(() => undefined);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
console.error("Failed to report pending bot operation result:", error);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async assertNoPendingOperation() {
|
|
342
|
+
const pending = await this.readPendingOperation();
|
|
343
|
+
if (!pending || pending.status !== "pending") {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
throw new Error(this.formatPendingOperationMessage(pending));
|
|
347
|
+
}
|
|
348
|
+
formatPendingOperationMessage(pending) {
|
|
349
|
+
const actionLine = `Action: ${pending.action}`;
|
|
350
|
+
const targetLine = pending.target
|
|
351
|
+
? `Target: @${pending.target.username} (${pending.target.id})`
|
|
352
|
+
: undefined;
|
|
353
|
+
const targetsLine = this.formatPendingTargets(pending);
|
|
354
|
+
if (pending.status === "rolled_back") {
|
|
355
|
+
return [
|
|
356
|
+
"The last bot configuration change failed and was rolled back.",
|
|
357
|
+
actionLine,
|
|
358
|
+
targetLine,
|
|
359
|
+
targetsLine,
|
|
360
|
+
pending.reason ? `Reason: ${pending.reason}` : undefined,
|
|
361
|
+
].filter(Boolean).join("\n");
|
|
362
|
+
}
|
|
363
|
+
return [
|
|
364
|
+
"A bot configuration change is still being applied.",
|
|
365
|
+
actionLine,
|
|
366
|
+
targetLine,
|
|
367
|
+
targetsLine,
|
|
368
|
+
"Wait for the \"Bot configuration applied successfully.\" message, then try again.",
|
|
369
|
+
].filter(Boolean).join("\n");
|
|
370
|
+
}
|
|
371
|
+
formatPendingTargets(pending) {
|
|
372
|
+
if (!pending.targets || pending.targets.length === 0) {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
return `Targets: ${pending.targets.map((target) => `@${target.username} (${target.id})`).join(", ")}`;
|
|
376
|
+
}
|
|
377
|
+
async ensureSupported() {
|
|
378
|
+
if (process.platform === "win32") {
|
|
379
|
+
throw new Error("Bot management restart is not supported on this Windows runtime.");
|
|
380
|
+
}
|
|
381
|
+
await fs.access(this.restartHelperPath).catch(() => {
|
|
382
|
+
throw new Error(`Restart helper is missing: ${this.restartHelperPath}`);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async launchRestartJob() {
|
|
386
|
+
const unitName = `remoteagent-bot-op-${Date.now()}`;
|
|
387
|
+
try {
|
|
388
|
+
const { stderr } = await execFileAsync("sudo", [
|
|
389
|
+
"-n",
|
|
390
|
+
"systemd-run",
|
|
391
|
+
"--unit",
|
|
392
|
+
unitName,
|
|
393
|
+
"--collect",
|
|
394
|
+
"--service-type=exec",
|
|
395
|
+
this.restartHelperPath,
|
|
396
|
+
this.serviceName,
|
|
397
|
+
this.dataDir,
|
|
398
|
+
]);
|
|
399
|
+
const output = stderr?.trim();
|
|
400
|
+
if (output) {
|
|
401
|
+
console.error("bot restart helper stderr:", output);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
if (!this.shouldFallbackToUserRestart(error)) {
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const child = spawn(this.restartHelperPath, [this.serviceName, this.dataDir], {
|
|
411
|
+
detached: true,
|
|
412
|
+
stdio: "ignore",
|
|
413
|
+
});
|
|
414
|
+
child.unref();
|
|
415
|
+
}
|
|
416
|
+
shouldFallbackToUserRestart(error) {
|
|
417
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
418
|
+
return /sudo: a password is required/i.test(message)
|
|
419
|
+
|| /command not found/i.test(message)
|
|
420
|
+
|| /systemd-run/i.test(message);
|
|
421
|
+
}
|
|
422
|
+
isDeadBotError(reason) {
|
|
423
|
+
return /unauthorized/i.test(reason)
|
|
424
|
+
|| /not found/i.test(reason)
|
|
425
|
+
|| /invalid token/i.test(reason);
|
|
426
|
+
}
|
|
427
|
+
async fetchBotIdentity(token) {
|
|
428
|
+
const url = `https://api.telegram.org/bot${token}/getMe`;
|
|
429
|
+
const { stdout, stderr } = await execFileAsync("curl", ["-sS", "--max-time", "20", url]);
|
|
430
|
+
if (stderr?.trim()) {
|
|
431
|
+
console.error("curl stderr for getMe:", stderr.trim());
|
|
432
|
+
}
|
|
433
|
+
const payload = JSON.parse(stdout);
|
|
434
|
+
if (!payload.ok || !payload.result?.id || !payload.result.username) {
|
|
435
|
+
throw new Error(payload.description || "Telegram getMe failed for the supplied bot token.");
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
index: 0,
|
|
439
|
+
token,
|
|
440
|
+
id: payload.result.id,
|
|
441
|
+
username: payload.result.username,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
async readEnvConfig() {
|
|
445
|
+
const text = await fs.readFile(this.envPath, "utf8");
|
|
446
|
+
const lines = text.split(/\r?\n/);
|
|
447
|
+
const tokenLine = lines.find((line) => line.startsWith("TELEGRAM_BOT_TOKENS="));
|
|
448
|
+
const singleTokenLine = lines.find((line) => line.startsWith("TELEGRAM_BOT_TOKEN="));
|
|
449
|
+
const usernameLine = lines.find((line) => line.startsWith("TELEGRAM_BOT_USERNAMES="));
|
|
450
|
+
const mainBotLine = lines.find((line) => line.startsWith("TELEGRAM_MAIN_BOT_ID="));
|
|
451
|
+
const tokens = tokenLine
|
|
452
|
+
? this.parseCsv(tokenLine.slice("TELEGRAM_BOT_TOKENS=".length))
|
|
453
|
+
: singleTokenLine
|
|
454
|
+
? [singleTokenLine.slice("TELEGRAM_BOT_TOKEN=".length).trim()].filter(Boolean)
|
|
455
|
+
: [];
|
|
456
|
+
const usernames = usernameLine ? this.parseCsv(usernameLine.slice("TELEGRAM_BOT_USERNAMES=".length)) : [];
|
|
457
|
+
return {
|
|
458
|
+
lines,
|
|
459
|
+
tokens,
|
|
460
|
+
usernames,
|
|
461
|
+
mainBotId: mainBotLine?.slice("TELEGRAM_MAIN_BOT_ID=".length).trim() || undefined,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
async writeEnvConfig(originalLines, tokens, usernames, mainBotId) {
|
|
465
|
+
const normalizedUsernames = this.normalizeUsernames(tokens, usernames);
|
|
466
|
+
const normalizedMainBotId = this.resolveMainBotId(this.zipBots(tokens, normalizedUsernames), mainBotId);
|
|
467
|
+
const nextLines = [];
|
|
468
|
+
let hasMulti = false;
|
|
469
|
+
let hasSingle = false;
|
|
470
|
+
let hasUsernames = false;
|
|
471
|
+
let hasMainBot = false;
|
|
472
|
+
for (const line of originalLines) {
|
|
473
|
+
if (line.startsWith("TELEGRAM_BOT_TOKENS=")) {
|
|
474
|
+
nextLines.push(`TELEGRAM_BOT_TOKENS=${tokens.join(",")}`);
|
|
475
|
+
hasMulti = true;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (line.startsWith("TELEGRAM_BOT_TOKEN=")) {
|
|
479
|
+
nextLines.push(`TELEGRAM_BOT_TOKEN=${tokens[0] ?? ""}`);
|
|
480
|
+
hasSingle = true;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (line.startsWith("TELEGRAM_BOT_USERNAMES=")) {
|
|
484
|
+
nextLines.push(`TELEGRAM_BOT_USERNAMES=${normalizedUsernames.join(",")}`);
|
|
485
|
+
hasUsernames = true;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (line.startsWith("TELEGRAM_MAIN_BOT_ID=")) {
|
|
489
|
+
if (normalizedMainBotId) {
|
|
490
|
+
nextLines.push(`TELEGRAM_MAIN_BOT_ID=${normalizedMainBotId}`);
|
|
491
|
+
}
|
|
492
|
+
hasMainBot = true;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
nextLines.push(line);
|
|
496
|
+
}
|
|
497
|
+
if (!hasMulti) {
|
|
498
|
+
nextLines.unshift(`TELEGRAM_BOT_TOKENS=${tokens.join(",")}`);
|
|
499
|
+
}
|
|
500
|
+
if (!hasSingle) {
|
|
501
|
+
nextLines.unshift(`TELEGRAM_BOT_TOKEN=${tokens[0] ?? ""}`);
|
|
502
|
+
}
|
|
503
|
+
if (!hasUsernames) {
|
|
504
|
+
nextLines.unshift(`TELEGRAM_BOT_USERNAMES=${normalizedUsernames.join(",")}`);
|
|
505
|
+
}
|
|
506
|
+
if (!hasMainBot && normalizedMainBotId) {
|
|
507
|
+
nextLines.unshift(`TELEGRAM_MAIN_BOT_ID=${normalizedMainBotId}`);
|
|
508
|
+
}
|
|
509
|
+
const output = `${nextLines.filter((line, index, all) => !(index === all.length - 1 && line === "")).join("\n")}\n`;
|
|
510
|
+
await fs.writeFile(this.envPath, output, "utf8");
|
|
511
|
+
}
|
|
512
|
+
normalizeUsernames(tokens, usernames) {
|
|
513
|
+
return tokens.map((token, index) => usernames[index]?.trim() || `bot_${this.tokenId(token)}`);
|
|
514
|
+
}
|
|
515
|
+
zipBots(tokens, usernames) {
|
|
516
|
+
const normalizedUsernames = this.normalizeUsernames(tokens, usernames);
|
|
517
|
+
return tokens.map((token, index) => ({
|
|
518
|
+
index,
|
|
519
|
+
token,
|
|
520
|
+
id: this.tokenId(token),
|
|
521
|
+
username: normalizedUsernames[index],
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
resolveBotSelector(bots, selector) {
|
|
525
|
+
const normalized = selector.trim();
|
|
526
|
+
if (/^\d+$/.test(normalized)) {
|
|
527
|
+
const index = Number.parseInt(normalized, 10);
|
|
528
|
+
if (index >= 1 && index <= bots.length) {
|
|
529
|
+
return bots[index - 1];
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const withoutAt = normalized.startsWith("@") ? normalized.slice(1) : normalized;
|
|
533
|
+
return bots.find((bot) => bot.username.toLowerCase() === withoutAt.toLowerCase()
|
|
534
|
+
|| String(bot.id) === withoutAt
|
|
535
|
+
|| bot.token === normalized);
|
|
536
|
+
}
|
|
537
|
+
formatBots(bots, mainBotId) {
|
|
538
|
+
if (bots.length === 0) {
|
|
539
|
+
return "No Telegram bots are configured.";
|
|
540
|
+
}
|
|
541
|
+
return [
|
|
542
|
+
`Configured bots (${bots.length})`,
|
|
543
|
+
...this.formatBotListLines(bots, mainBotId),
|
|
544
|
+
].join("\n");
|
|
545
|
+
}
|
|
546
|
+
formatBotListLines(bots, explicitMainBotId) {
|
|
547
|
+
const mainBotId = this.resolveMainBotId(bots, explicitMainBotId);
|
|
548
|
+
return bots.map((bot, index) => {
|
|
549
|
+
const suffix = String(bot.id) === mainBotId ? " [main]" : "";
|
|
550
|
+
return `${index + 1}. @${bot.username} (${bot.id})${suffix}`;
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
resolveMainBot(bots, mainBotId) {
|
|
554
|
+
if (bots.length === 0) {
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
const explicit = mainBotId ? bots.find((bot) => String(bot.id) === mainBotId) : undefined;
|
|
558
|
+
return explicit ?? bots[0];
|
|
559
|
+
}
|
|
560
|
+
resolveMainBotId(bots, mainBotId) {
|
|
561
|
+
return this.resolveMainBot(bots, mainBotId)?.id.toString();
|
|
562
|
+
}
|
|
563
|
+
promoteMainBotAfterRemoval(bots, removed) {
|
|
564
|
+
const remaining = bots.filter((bot) => bot.token !== removed.token);
|
|
565
|
+
return remaining[removed.index] ?? remaining[0];
|
|
566
|
+
}
|
|
567
|
+
async listConfiguredBots() {
|
|
568
|
+
const env = await this.readEnvConfig();
|
|
569
|
+
return this.zipBots(env.tokens, env.usernames);
|
|
570
|
+
}
|
|
571
|
+
tokenId(token) {
|
|
572
|
+
return Number.parseInt(token.split(":", 1)[0] ?? "0", 10);
|
|
573
|
+
}
|
|
574
|
+
parseCsv(value) {
|
|
575
|
+
return value
|
|
576
|
+
.split(/[\r\n,]+/)
|
|
577
|
+
.map((entry) => entry.trim())
|
|
578
|
+
.filter(Boolean);
|
|
579
|
+
}
|
|
580
|
+
async backupEnv() {
|
|
581
|
+
await fs.mkdir(this.backupsDir, { recursive: true });
|
|
582
|
+
const backupPath = path.join(this.backupsDir, `telegram-bots-${Date.now()}.env.bak`);
|
|
583
|
+
await fs.copyFile(this.envPath, backupPath);
|
|
584
|
+
return backupPath;
|
|
585
|
+
}
|
|
586
|
+
async restoreBackup(backupPath) {
|
|
587
|
+
await fs.copyFile(backupPath, this.envPath);
|
|
588
|
+
}
|
|
589
|
+
async writePendingOperation(operation) {
|
|
590
|
+
await fs.writeFile(this.pendingPath, JSON.stringify(operation, null, 2), "utf8");
|
|
591
|
+
}
|
|
592
|
+
async readPendingOperation() {
|
|
593
|
+
const raw = await fs.readFile(this.pendingPath, "utf8").catch(() => undefined);
|
|
594
|
+
if (!raw?.trim()) {
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
return JSON.parse(raw);
|
|
598
|
+
}
|
|
599
|
+
async clearPendingOperation() {
|
|
600
|
+
await fs.rm(this.pendingPath, { force: true }).catch(() => undefined);
|
|
601
|
+
}
|
|
602
|
+
async sendTelegramMessage(token, chatId, text) {
|
|
603
|
+
const url = `https://api.telegram.org/bot${token}/sendMessage`;
|
|
604
|
+
const payload = JSON.stringify({
|
|
605
|
+
chat_id: chatId,
|
|
606
|
+
text,
|
|
607
|
+
});
|
|
608
|
+
const { stdout, stderr } = await execFileAsync("curl", [
|
|
609
|
+
"-sS",
|
|
610
|
+
"--max-time",
|
|
611
|
+
"20",
|
|
612
|
+
"-H",
|
|
613
|
+
"Content-Type: application/json",
|
|
614
|
+
"-d",
|
|
615
|
+
payload,
|
|
616
|
+
url,
|
|
617
|
+
]);
|
|
618
|
+
if (stderr?.trim()) {
|
|
619
|
+
console.error("curl stderr for sendMessage:", stderr.trim());
|
|
620
|
+
}
|
|
621
|
+
const parsed = JSON.parse(stdout);
|
|
622
|
+
if (!parsed.ok) {
|
|
623
|
+
throw new Error(parsed.description || "sendMessage failed.");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|