@tritard/waterbrother 0.16.6 → 0.16.7
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/README.md +4 -2
- package/package.json +1 -1
- package/src/channels.js +8 -3
- package/src/cli.js +20 -8
- package/src/gateway-state.js +35 -11
- package/src/gateway.js +315 -58
package/README.md
CHANGED
|
@@ -258,9 +258,11 @@ Rollout order:
|
|
|
258
258
|
3. approvals over messaging
|
|
259
259
|
4. only then group DM collaboration
|
|
260
260
|
|
|
261
|
-
Current Telegram
|
|
261
|
+
Current Telegram behavior:
|
|
262
262
|
- remote prompts run with `approval=never`
|
|
263
|
-
-
|
|
263
|
+
- replies are rendered as Telegram-safe HTML, with code blocks and stripped raw Markdown symbols
|
|
264
|
+
- the gateway sends typing activity and edits one in-progress message into the final reply before falling back to chunked follow-up messages
|
|
265
|
+
- pending pairings are explicit and expire automatically after 12 hours unless approved
|
|
264
266
|
- mutating and approval-heavy work stay local until remote approvals land
|
|
265
267
|
- pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
|
|
266
268
|
|
package/package.json
CHANGED
package/src/channels.js
CHANGED
|
@@ -55,7 +55,8 @@ function normalizeTelegramConfig(value = {}) {
|
|
|
55
55
|
const common = normalizeCommonChannelConfig(source);
|
|
56
56
|
return {
|
|
57
57
|
...common,
|
|
58
|
-
botToken: String(source.botToken || "").trim()
|
|
58
|
+
botToken: String(source.botToken || "").trim(),
|
|
59
|
+
pairingExpiryMinutes: Number.isFinite(Number(source.pairingExpiryMinutes)) ? Math.max(1, Math.floor(Number(source.pairingExpiryMinutes))) : 720
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -130,6 +131,7 @@ function summarizeChannel(serviceId, config = {}) {
|
|
|
130
131
|
pairingMode: config.pairingMode || "manual",
|
|
131
132
|
defaultRuntimeProfile: config.defaultRuntimeProfile || "",
|
|
132
133
|
linkedSessionId: config.linkedSessionId || "",
|
|
134
|
+
pairingExpiryMinutes: Number.isFinite(Number(config.pairingExpiryMinutes)) ? Math.max(1, Math.floor(Number(config.pairingExpiryMinutes))) : 720,
|
|
133
135
|
missing,
|
|
134
136
|
requiredFields: [...spec.requiredFields],
|
|
135
137
|
envKeys: [...spec.envKeys]
|
|
@@ -178,7 +180,8 @@ export function buildChannelOnboardingPayload(serviceId) {
|
|
|
178
180
|
prerequisites: [
|
|
179
181
|
"Create a Telegram bot with BotFather",
|
|
180
182
|
"Capture the bot token",
|
|
181
|
-
"Start with manual pairing so first-contact DMs create a pending approval request"
|
|
183
|
+
"Start with manual pairing so first-contact DMs create a pending approval request",
|
|
184
|
+
"Telegram replies are rendered as clean HTML with live typing and edit-in-place delivery"
|
|
182
185
|
],
|
|
183
186
|
commands: [
|
|
184
187
|
"waterbrother config set-json channels '{\"telegram\":{\"enabled\":true,\"botToken\":\"YOUR_BOT_TOKEN\",\"pairingMode\":\"manual\",\"allowedUserIds\":[]}}'",
|
|
@@ -190,7 +193,9 @@ export function buildChannelOnboardingPayload(serviceId) {
|
|
|
190
193
|
notes: [
|
|
191
194
|
"Start with direct messages only.",
|
|
192
195
|
"Unknown DMs create a pending pairing request you approve locally.",
|
|
193
|
-
"
|
|
196
|
+
"Pending pairings expire automatically after 12 hours unless approved.",
|
|
197
|
+
"Remote commands include /help, /status, /runtime, /sessions, /resume <id>, /new, and /clear.",
|
|
198
|
+
"Telegram uses typing indicators and edit-in-place responses before falling back to chunked follow-up messages."
|
|
194
199
|
]
|
|
195
200
|
};
|
|
196
201
|
}
|
package/src/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ import { getConfigPath, loadConfigLayers, resolveRuntimeConfig, saveConfig } fro
|
|
|
10
10
|
import { createChatCompletion, createJsonCompletion, listModels } from "./model-client.js";
|
|
11
11
|
import { buildChannelOnboardingPayload, getChannelStatuses, getGatewayStatus } from "./channels.js";
|
|
12
12
|
import { runGatewayService } from "./gateway.js";
|
|
13
|
-
import { loadGatewayState, saveGatewayState } from "./gateway-state.js";
|
|
13
|
+
import { DEFAULT_PENDING_PAIRING_TTL_MINUTES, loadGatewayState, prunePendingPairings, saveGatewayState } from "./gateway-state.js";
|
|
14
14
|
import {
|
|
15
15
|
getDefaultDesignModelForProvider,
|
|
16
16
|
getDefaultModelForProvider,
|
|
@@ -3229,13 +3229,18 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
|
|
|
3229
3229
|
if (service !== "telegram") {
|
|
3230
3230
|
throw new Error("Usage: waterbrother gateway pairings [telegram]");
|
|
3231
3231
|
}
|
|
3232
|
-
const
|
|
3232
|
+
const ttlMinutes = Number.isFinite(Number(runtime.channels?.telegram?.pairingExpiryMinutes))
|
|
3233
|
+
? Math.max(1, Math.floor(Number(runtime.channels.telegram.pairingExpiryMinutes)))
|
|
3234
|
+
: DEFAULT_PENDING_PAIRING_TTL_MINUTES;
|
|
3235
|
+
const loadedState = await loadGatewayState(service);
|
|
3236
|
+
const { state, pruned } = prunePendingPairings(loadedState, ttlMinutes);
|
|
3237
|
+
if (pruned > 0) await saveGatewayState(service, state);
|
|
3233
3238
|
const pending = Object.values(state.pendingPairings || {});
|
|
3234
3239
|
const paired = Array.isArray(runtime.channels?.telegram?.allowedUserIds)
|
|
3235
3240
|
? runtime.channels.telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean)
|
|
3236
3241
|
: [];
|
|
3237
3242
|
if (asJson) {
|
|
3238
|
-
printData({ service, pending, paired }, true);
|
|
3243
|
+
printData({ service, pending, paired, pairingExpiryMinutes: ttlMinutes }, true);
|
|
3239
3244
|
return;
|
|
3240
3245
|
}
|
|
3241
3246
|
console.log(`telegram paired users: ${paired.length ? paired.join(", ") : "none"}`);
|
|
@@ -3243,11 +3248,12 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
|
|
|
3243
3248
|
console.log("telegram pending pairings: none");
|
|
3244
3249
|
return;
|
|
3245
3250
|
}
|
|
3246
|
-
console.log(
|
|
3251
|
+
console.log(`telegram pending pairings (expire after ${ttlMinutes} minutes):`);
|
|
3247
3252
|
for (const item of pending) {
|
|
3248
3253
|
const username = String(item?.username || "").trim();
|
|
3249
3254
|
const detail = username ? ` (${username})` : "";
|
|
3250
|
-
|
|
3255
|
+
const lastSeen = String(item?.lastSeenAt || item?.firstSeenAt || "").trim();
|
|
3256
|
+
console.log(` ${item.userId}${detail}${lastSeen ? ` [last seen ${lastSeen}]` : ""}`);
|
|
3251
3257
|
}
|
|
3252
3258
|
return;
|
|
3253
3259
|
}
|
|
@@ -6876,7 +6882,12 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
|
|
|
6876
6882
|
}
|
|
6877
6883
|
|
|
6878
6884
|
if (line === "/gateway pairings") {
|
|
6879
|
-
const
|
|
6885
|
+
const ttlMinutes = Number.isFinite(Number(context.runtime.channels?.telegram?.pairingExpiryMinutes))
|
|
6886
|
+
? Math.max(1, Math.floor(Number(context.runtime.channels.telegram.pairingExpiryMinutes)))
|
|
6887
|
+
: DEFAULT_PENDING_PAIRING_TTL_MINUTES;
|
|
6888
|
+
const loadedState = await loadGatewayState("telegram");
|
|
6889
|
+
const { state, pruned } = prunePendingPairings(loadedState, ttlMinutes);
|
|
6890
|
+
if (pruned > 0) await saveGatewayState("telegram", state);
|
|
6880
6891
|
const pending = Object.values(state.pendingPairings || {});
|
|
6881
6892
|
const paired = Array.isArray(context.runtime.channels?.telegram?.allowedUserIds)
|
|
6882
6893
|
? context.runtime.channels.telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean)
|
|
@@ -6885,10 +6896,11 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
|
|
|
6885
6896
|
if (!pending.length) {
|
|
6886
6897
|
console.log("telegram pending pairings: none");
|
|
6887
6898
|
} else {
|
|
6888
|
-
console.log(
|
|
6899
|
+
console.log(`telegram pending pairings (expire after ${ttlMinutes} minutes):`);
|
|
6889
6900
|
for (const item of pending) {
|
|
6890
6901
|
const username = String(item?.username || "").trim();
|
|
6891
|
-
|
|
6902
|
+
const lastSeen = String(item?.lastSeenAt || item?.firstSeenAt || "").trim();
|
|
6903
|
+
console.log(` ${item.userId}${username ? ` (${username})` : ""}${lastSeen ? ` [last seen ${lastSeen}]` : ""}`);
|
|
6892
6904
|
}
|
|
6893
6905
|
}
|
|
6894
6906
|
continue;
|
package/src/gateway-state.js
CHANGED
|
@@ -3,26 +3,54 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
const GATEWAY_DIR = path.join(os.homedir(), ".waterbrother", "gateway");
|
|
6
|
+
export const DEFAULT_PENDING_PAIRING_TTL_MINUTES = 720;
|
|
6
7
|
|
|
7
8
|
function gatewayStatePath(serviceId) {
|
|
8
9
|
return path.join(GATEWAY_DIR, `${String(serviceId || "").trim().toLowerCase()}.json`);
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
function normalizeGatewayState(parsed = {}) {
|
|
13
|
+
return {
|
|
14
|
+
offset: Number.isFinite(Number(parsed?.offset)) ? Math.max(0, Math.floor(Number(parsed.offset))) : 0,
|
|
15
|
+
peers: parsed?.peers && typeof parsed.peers === "object" ? parsed.peers : {},
|
|
16
|
+
pendingPairings: parsed?.pendingPairings && typeof parsed.pendingPairings === "object" ? parsed.pendingPairings : {}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
export async function ensureGatewayStateDir() {
|
|
12
21
|
await fs.mkdir(GATEWAY_DIR, { recursive: true });
|
|
13
22
|
}
|
|
14
23
|
|
|
24
|
+
export function prunePendingPairings(state, ttlMinutes = DEFAULT_PENDING_PAIRING_TTL_MINUTES, now = Date.now()) {
|
|
25
|
+
const current = normalizeGatewayState(state);
|
|
26
|
+
const ttlMs = Math.max(1, Number.isFinite(Number(ttlMinutes)) ? Math.floor(Number(ttlMinutes)) : DEFAULT_PENDING_PAIRING_TTL_MINUTES) * 60 * 1000;
|
|
27
|
+
const pendingPairings = {};
|
|
28
|
+
let pruned = 0;
|
|
29
|
+
|
|
30
|
+
for (const [userId, item] of Object.entries(current.pendingPairings || {})) {
|
|
31
|
+
const lastSeenMs = Date.parse(item?.lastSeenAt || item?.firstSeenAt || "");
|
|
32
|
+
if (!Number.isFinite(lastSeenMs) || now - lastSeenMs <= ttlMs) {
|
|
33
|
+
pendingPairings[userId] = item;
|
|
34
|
+
} else {
|
|
35
|
+
pruned += 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
state: {
|
|
41
|
+
...current,
|
|
42
|
+
pendingPairings
|
|
43
|
+
},
|
|
44
|
+
pruned
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
15
48
|
export async function loadGatewayState(serviceId) {
|
|
16
49
|
await ensureGatewayStateDir();
|
|
17
50
|
const filePath = gatewayStatePath(serviceId);
|
|
18
51
|
try {
|
|
19
52
|
const raw = await fs.readFile(filePath, "utf8");
|
|
20
|
-
|
|
21
|
-
return {
|
|
22
|
-
offset: Number.isFinite(Number(parsed?.offset)) ? Math.max(0, Math.floor(Number(parsed.offset))) : 0,
|
|
23
|
-
peers: parsed?.peers && typeof parsed.peers === "object" ? parsed.peers : {},
|
|
24
|
-
pendingPairings: parsed?.pendingPairings && typeof parsed.pendingPairings === "object" ? parsed.pendingPairings : {}
|
|
25
|
-
};
|
|
53
|
+
return normalizeGatewayState(JSON.parse(raw));
|
|
26
54
|
} catch (error) {
|
|
27
55
|
if (error?.code === "ENOENT") {
|
|
28
56
|
return { offset: 0, peers: {}, pendingPairings: {} };
|
|
@@ -34,11 +62,7 @@ export async function loadGatewayState(serviceId) {
|
|
|
34
62
|
export async function saveGatewayState(serviceId, state) {
|
|
35
63
|
await ensureGatewayStateDir();
|
|
36
64
|
const filePath = gatewayStatePath(serviceId);
|
|
37
|
-
const next =
|
|
38
|
-
offset: Number.isFinite(Number(state?.offset)) ? Math.max(0, Math.floor(Number(state.offset))) : 0,
|
|
39
|
-
peers: state?.peers && typeof state.peers === "object" ? state.peers : {},
|
|
40
|
-
pendingPairings: state?.pendingPairings && typeof state.pendingPairings === "object" ? state.pendingPairings : {}
|
|
41
|
-
};
|
|
65
|
+
const next = normalizeGatewayState(state);
|
|
42
66
|
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
43
67
|
await fs.writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
44
68
|
await fs.rename(tmpPath, filePath);
|
package/src/gateway.js
CHANGED
|
@@ -4,7 +4,7 @@ import process from "node:process";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { createSession, listSessions, loadSession, saveSession } from "./session-store.js";
|
|
7
|
-
import { loadGatewayState, saveGatewayState } from "./gateway-state.js";
|
|
7
|
+
import { DEFAULT_PENDING_PAIRING_TTL_MINUTES, loadGatewayState, prunePendingPairings, saveGatewayState } from "./gateway-state.js";
|
|
8
8
|
import { getGatewayStatus, getChannelSpec } from "./channels.js";
|
|
9
9
|
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
@@ -12,18 +12,64 @@ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
|
|
|
12
12
|
const BIN_PATH = path.join(PACKAGE_ROOT, "bin", "waterbrother.js");
|
|
13
13
|
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
|
14
14
|
const TELEGRAM_MESSAGE_LIMIT = 3900;
|
|
15
|
+
const TELEGRAM_MAX_RETRIES = 4;
|
|
16
|
+
const TELEGRAM_TYPING_INTERVAL_MS = 4000;
|
|
17
|
+
const TELEGRAM_PREVIEW_TEXT = "<i>Working…</i>";
|
|
18
|
+
const TELEGRAM_COMMANDS = [
|
|
19
|
+
{ command: "help", description: "Show Telegram control help" },
|
|
20
|
+
{ command: "status", description: "Show the linked remote session" },
|
|
21
|
+
{ command: "runtime", description: "Show active runtime status" },
|
|
22
|
+
{ command: "sessions", description: "List recent remote sessions" },
|
|
23
|
+
{ command: "new", description: "Start a fresh remote session" },
|
|
24
|
+
{ command: "clear", description: "Clear current remote conversation" }
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function sleep(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stringifyError(error) {
|
|
32
|
+
if (!error) return "unknown error";
|
|
33
|
+
if (typeof error === "string") return error;
|
|
34
|
+
return error.stderr || error.stdout || error.message || String(error);
|
|
35
|
+
}
|
|
15
36
|
|
|
16
|
-
function
|
|
37
|
+
function escapeTelegramHtml(text) {
|
|
38
|
+
return String(text || "")
|
|
39
|
+
.replace(/&/g, "&")
|
|
40
|
+
.replace(/</g, "<")
|
|
41
|
+
.replace(/>/g, ">");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stripTelegramMarkup(text) {
|
|
45
|
+
return String(text || "")
|
|
46
|
+
.replace(/<pre><code>/g, "")
|
|
47
|
+
.replace(/<\/code><\/pre>/g, "")
|
|
48
|
+
.replace(/<code>/g, "`")
|
|
49
|
+
.replace(/<\/code>/g, "`")
|
|
50
|
+
.replace(/<b>/g, "")
|
|
51
|
+
.replace(/<\/b>/g, "")
|
|
52
|
+
.replace(/<i>/g, "")
|
|
53
|
+
.replace(/<\/i>/g, "")
|
|
54
|
+
.replace(/<s>/g, "")
|
|
55
|
+
.replace(/<\/s>/g, "")
|
|
56
|
+
.replace(/<a [^>]*href="([^"]+)"[^>]*>(.*?)<\/a>/g, "$2 ($1)")
|
|
57
|
+
.replace(/<[^>]+>/g, "")
|
|
58
|
+
.replace(/</g, "<")
|
|
59
|
+
.replace(/>/g, ">")
|
|
60
|
+
.replace(/&/g, "&");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function splitLongPlainText(text, maxLength = TELEGRAM_MESSAGE_LIMIT) {
|
|
17
64
|
const value = String(text || "").trim();
|
|
18
65
|
if (!value) return [""];
|
|
19
66
|
const chunks = [];
|
|
20
67
|
let rest = value;
|
|
21
68
|
while (rest.length > maxLength) {
|
|
22
|
-
let splitIndex = rest.lastIndexOf("\n", maxLength);
|
|
23
|
-
if (splitIndex < Math.floor(maxLength * 0.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (splitIndex < Math.floor(maxLength * 0.5)) splitIndex = maxLength;
|
|
69
|
+
let splitIndex = rest.lastIndexOf("\n\n", maxLength);
|
|
70
|
+
if (splitIndex < Math.floor(maxLength * 0.55)) splitIndex = rest.lastIndexOf("\n", maxLength);
|
|
71
|
+
if (splitIndex < Math.floor(maxLength * 0.55)) splitIndex = rest.lastIndexOf(" ", maxLength);
|
|
72
|
+
if (splitIndex < Math.floor(maxLength * 0.45)) splitIndex = maxLength;
|
|
27
73
|
chunks.push(rest.slice(0, splitIndex).trim());
|
|
28
74
|
rest = rest.slice(splitIndex).trim();
|
|
29
75
|
}
|
|
@@ -31,27 +77,101 @@ function chunkText(text, maxLength = TELEGRAM_MESSAGE_LIMIT) {
|
|
|
31
77
|
return chunks.filter(Boolean);
|
|
32
78
|
}
|
|
33
79
|
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
80
|
+
function splitCodeBlock(text, maxLength = TELEGRAM_MESSAGE_LIMIT) {
|
|
81
|
+
const lines = String(text || "").split("\n");
|
|
82
|
+
const chunks = [];
|
|
83
|
+
let current = [];
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
const next = [...current, line].join("\n");
|
|
86
|
+
if (next.length > maxLength && current.length) {
|
|
87
|
+
chunks.push(current.join("\n"));
|
|
88
|
+
current = [line];
|
|
89
|
+
} else {
|
|
90
|
+
current.push(line);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (current.length) chunks.push(current.join("\n"));
|
|
94
|
+
return chunks.filter(Boolean);
|
|
38
95
|
}
|
|
39
96
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
97
|
+
function tokenizeMarkdown(text) {
|
|
98
|
+
const source = String(text || "").replace(/\r\n/g, "\n");
|
|
99
|
+
if (!source.trim()) return [];
|
|
100
|
+
const tokens = [];
|
|
101
|
+
let index = 0;
|
|
102
|
+
while (index < source.length) {
|
|
103
|
+
const fenceStart = source.indexOf("```", index);
|
|
104
|
+
if (fenceStart === -1) {
|
|
105
|
+
const tail = source.slice(index);
|
|
106
|
+
if (tail.trim()) tokens.push({ type: "text", value: tail });
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
const before = source.slice(index, fenceStart);
|
|
110
|
+
if (before.trim()) tokens.push({ type: "text", value: before });
|
|
111
|
+
const languageEnd = source.indexOf("\n", fenceStart + 3);
|
|
112
|
+
const language = languageEnd === -1 ? "" : source.slice(fenceStart + 3, languageEnd).trim();
|
|
113
|
+
const codeStart = languageEnd === -1 ? fenceStart + 3 : languageEnd + 1;
|
|
114
|
+
const fenceEnd = source.indexOf("```", codeStart);
|
|
115
|
+
if (fenceEnd === -1) {
|
|
116
|
+
const tail = source.slice(fenceStart);
|
|
117
|
+
if (tail.trim()) tokens.push({ type: "text", value: tail });
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
const code = source.slice(codeStart, fenceEnd).replace(/^\n+|\n+$/g, "");
|
|
121
|
+
tokens.push({ type: "code", language, value: code });
|
|
122
|
+
index = fenceEnd + 3;
|
|
123
|
+
}
|
|
124
|
+
return tokens;
|
|
45
125
|
}
|
|
46
126
|
|
|
47
|
-
function
|
|
48
|
-
let value = escapeTelegramHtml(String(text || "")
|
|
127
|
+
function renderInlineTelegramHtml(text) {
|
|
128
|
+
let value = escapeTelegramHtml(String(text || ""));
|
|
129
|
+
value = value.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
|
49
130
|
value = value.replace(/\*\*([^*\n][^*]*?)\*\*/g, "<b>$1</b>");
|
|
131
|
+
value = value.replace(/__([^_\n][^_]*?)__/g, "<b>$1</b>");
|
|
132
|
+
value = value.replace(/(^|[^*])\*([^*\n][^*]*?)\*(?!\*)/g, "$1<i>$2</i>");
|
|
133
|
+
value = value.replace(/(^|[^_])_([^_\n][^_]*?)_(?!_)/g, "$1<i>$2</i>");
|
|
134
|
+
value = value.replace(/~~([^~\n][^~]*?)~~/g, "<s>$1</s>");
|
|
50
135
|
value = value.replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
|
51
|
-
value = value.replace(/^\s*-\s+/gm, "• ");
|
|
52
136
|
return value;
|
|
53
137
|
}
|
|
54
138
|
|
|
139
|
+
function normalizeTextBlock(text) {
|
|
140
|
+
return String(text || "")
|
|
141
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
|
|
142
|
+
.replace(/^\s*[-*]\s+/gm, "• ")
|
|
143
|
+
.replace(/^\s*\d+\.\s+/gm, (match) => `${match.trim()} `)
|
|
144
|
+
.replace(/^>\s?/gm, "")
|
|
145
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
146
|
+
.trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderTelegramChunks(text, maxLength = TELEGRAM_MESSAGE_LIMIT) {
|
|
150
|
+
const tokens = tokenizeMarkdown(text);
|
|
151
|
+
if (!tokens.length) return [escapeTelegramHtml(String(text || "").trim()) || "(no content)"];
|
|
152
|
+
|
|
153
|
+
const rendered = [];
|
|
154
|
+
for (const token of tokens) {
|
|
155
|
+
if (token.type === "code") {
|
|
156
|
+
const source = token.value || "";
|
|
157
|
+
const codeChunks = splitCodeBlock(source, Math.max(1200, maxLength - 32));
|
|
158
|
+
for (const part of codeChunks) {
|
|
159
|
+
rendered.push(`<pre><code>${escapeTelegramHtml(part)}</code></pre>`);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const normalized = normalizeTextBlock(token.value);
|
|
165
|
+
if (!normalized) continue;
|
|
166
|
+
const textChunks = splitLongPlainText(normalized, maxLength - 64);
|
|
167
|
+
for (const part of textChunks) {
|
|
168
|
+
rendered.push(renderInlineTelegramHtml(part));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return rendered.length ? rendered : ["(no content)"];
|
|
173
|
+
}
|
|
174
|
+
|
|
55
175
|
function buildRemoteHelp() {
|
|
56
176
|
return [
|
|
57
177
|
"<b>Waterbrother Telegram control</b>",
|
|
@@ -125,6 +245,25 @@ function formatSessionListMarkup(currentSessionId, sessions = []) {
|
|
|
125
245
|
return lines.join("\n");
|
|
126
246
|
}
|
|
127
247
|
|
|
248
|
+
function extractRetryDelayMs(error, attempt) {
|
|
249
|
+
const retryAfter = Number(error?.retryAfterSeconds);
|
|
250
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
251
|
+
return retryAfter * 1000;
|
|
252
|
+
}
|
|
253
|
+
const base = 400 * (2 ** attempt);
|
|
254
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
255
|
+
return Math.min(5000, base + jitter);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function shouldRetryTelegramError(error) {
|
|
259
|
+
if (!error) return false;
|
|
260
|
+
if (error.permanent) return false;
|
|
261
|
+
if (error.isParseModeError) return false;
|
|
262
|
+
if (error.isNetworkError) return true;
|
|
263
|
+
const status = Number(error.status || 0);
|
|
264
|
+
return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
|
|
265
|
+
}
|
|
266
|
+
|
|
128
267
|
class TelegramGateway {
|
|
129
268
|
constructor({ runtime, cwd, io = console }) {
|
|
130
269
|
this.runtime = runtime;
|
|
@@ -143,30 +282,75 @@ class TelegramGateway {
|
|
|
143
282
|
return `${TELEGRAM_API_BASE}/bot${this.botToken}`;
|
|
144
283
|
}
|
|
145
284
|
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
285
|
+
get pairingExpiryMinutes() {
|
|
286
|
+
const raw = Number(this.channel.pairingExpiryMinutes);
|
|
287
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_PENDING_PAIRING_TTL_MINUTES;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async callApi(method, payload = {}, { allowPlainFallback = false } = {}) {
|
|
291
|
+
let lastError = null;
|
|
292
|
+
for (let attempt = 0; attempt < TELEGRAM_MAX_RETRIES; attempt += 1) {
|
|
293
|
+
try {
|
|
294
|
+
const response = await fetch(`${this.apiBase}/${method}`, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: { "content-type": "application/json" },
|
|
297
|
+
body: JSON.stringify(payload)
|
|
298
|
+
});
|
|
299
|
+
const json = await response.json().catch(() => ({}));
|
|
300
|
+
if (!response.ok || json?.ok === false) {
|
|
301
|
+
const error = new Error(`Telegram API ${method} failed: ${json?.description || response.statusText || response.status}`);
|
|
302
|
+
error.status = response.status;
|
|
303
|
+
error.description = json?.description || "";
|
|
304
|
+
error.retryAfterSeconds = Number(json?.parameters?.retry_after || 0) || 0;
|
|
305
|
+
error.isParseModeError = /parse entities|can't parse/i.test(error.description);
|
|
306
|
+
if (allowPlainFallback && error.isParseModeError && payload.parse_mode) {
|
|
307
|
+
const fallbackPayload = { ...payload };
|
|
308
|
+
delete fallbackPayload.parse_mode;
|
|
309
|
+
fallbackPayload.text = stripTelegramMarkup(String(payload.text || ""));
|
|
310
|
+
return this.callApi(method, fallbackPayload, { allowPlainFallback: false });
|
|
311
|
+
}
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
return json.result;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const wrapped = error instanceof Error ? error : new Error(String(error));
|
|
317
|
+
if (wrapped.name === "TypeError" && !wrapped.status) {
|
|
318
|
+
wrapped.isNetworkError = true;
|
|
319
|
+
}
|
|
320
|
+
lastError = wrapped;
|
|
321
|
+
if (!shouldRetryTelegramError(wrapped) || attempt === TELEGRAM_MAX_RETRIES - 1) {
|
|
322
|
+
throw wrapped;
|
|
323
|
+
}
|
|
324
|
+
await sleep(extractRetryDelayMs(wrapped, attempt));
|
|
325
|
+
}
|
|
155
326
|
}
|
|
156
|
-
|
|
327
|
+
throw lastError || new Error(`Telegram API ${method} failed`);
|
|
157
328
|
}
|
|
158
329
|
|
|
159
|
-
async sendMessage(chatId, text, replyToMessageId = null, { parseMode = "HTML" } = {}) {
|
|
160
|
-
const chunks =
|
|
330
|
+
async sendMessage(chatId, text, replyToMessageId = null, { parseMode = "HTML", disableWebPagePreview = true } = {}) {
|
|
331
|
+
const chunks = parseMode === "HTML" ? renderTelegramChunks(text) : splitLongPlainText(text);
|
|
332
|
+
const sent = [];
|
|
161
333
|
for (const chunk of chunks) {
|
|
162
|
-
await this.callApi("sendMessage", {
|
|
334
|
+
const result = await this.callApi("sendMessage", {
|
|
163
335
|
chat_id: chatId,
|
|
164
336
|
text: chunk,
|
|
165
337
|
reply_to_message_id: replyToMessageId || undefined,
|
|
166
338
|
parse_mode: parseMode || undefined,
|
|
167
|
-
disable_web_page_preview:
|
|
168
|
-
});
|
|
339
|
+
disable_web_page_preview: disableWebPagePreview
|
|
340
|
+
}, { allowPlainFallback: parseMode === "HTML" });
|
|
341
|
+
sent.push(result);
|
|
169
342
|
}
|
|
343
|
+
return sent;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async editMessage(chatId, messageId, text, { parseMode = "HTML", disableWebPagePreview = true } = {}) {
|
|
347
|
+
return this.callApi("editMessageText", {
|
|
348
|
+
chat_id: chatId,
|
|
349
|
+
message_id: messageId,
|
|
350
|
+
text,
|
|
351
|
+
parse_mode: parseMode || undefined,
|
|
352
|
+
disable_web_page_preview: disableWebPagePreview
|
|
353
|
+
}, { allowPlainFallback: parseMode === "HTML" });
|
|
170
354
|
}
|
|
171
355
|
|
|
172
356
|
async getMe() {
|
|
@@ -181,12 +365,29 @@ class TelegramGateway {
|
|
|
181
365
|
});
|
|
182
366
|
}
|
|
183
367
|
|
|
368
|
+
async registerCommands() {
|
|
369
|
+
await this.callApi("setMyCommands", {
|
|
370
|
+
commands: TELEGRAM_COMMANDS
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
184
374
|
isAuthorizedUser(from) {
|
|
185
375
|
const userId = String(from?.id || "").trim();
|
|
186
376
|
const allowed = Array.isArray(this.channel.allowedUserIds) ? this.channel.allowedUserIds.map((value) => String(value)) : [];
|
|
187
377
|
return allowed.includes(userId);
|
|
188
378
|
}
|
|
189
379
|
|
|
380
|
+
prunePairings() {
|
|
381
|
+
const { state, pruned } = prunePendingPairings(this.state, this.pairingExpiryMinutes);
|
|
382
|
+
this.state = state;
|
|
383
|
+
return pruned;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async persistState() {
|
|
387
|
+
this.prunePairings();
|
|
388
|
+
await saveGatewayState("telegram", this.state);
|
|
389
|
+
}
|
|
390
|
+
|
|
190
391
|
async addPendingPairing(message) {
|
|
191
392
|
const userId = String(message?.from?.id || "").trim();
|
|
192
393
|
const username = [message?.from?.username, message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim();
|
|
@@ -198,7 +399,7 @@ class TelegramGateway {
|
|
|
198
399
|
lastSeenAt: new Date().toISOString(),
|
|
199
400
|
lastMessageId: message.message_id
|
|
200
401
|
};
|
|
201
|
-
await
|
|
402
|
+
await this.persistState();
|
|
202
403
|
}
|
|
203
404
|
|
|
204
405
|
async rejectUnpairedMessage(message) {
|
|
@@ -208,12 +409,13 @@ class TelegramGateway {
|
|
|
208
409
|
message.chat.id,
|
|
209
410
|
[
|
|
210
411
|
"<b>Pairing required</b>",
|
|
211
|
-
|
|
412
|
+
"This Telegram account is not paired with Waterbrother yet.",
|
|
212
413
|
`Your Telegram user id is: <code>${escapeTelegramHtml(userId)}</code>`,
|
|
213
414
|
"",
|
|
214
415
|
"Approve it locally with:",
|
|
215
416
|
`<code>waterbrother gateway pair telegram ${escapeTelegramHtml(userId)}</code>`,
|
|
216
417
|
"",
|
|
418
|
+
`Pending pairing requests expire after <code>${escapeTelegramHtml(this.pairingExpiryMinutes)}</code> minutes.`,
|
|
217
419
|
"Then keep the gateway running and send your message again."
|
|
218
420
|
].join("\n"),
|
|
219
421
|
message.message_id,
|
|
@@ -238,7 +440,7 @@ class TelegramGateway {
|
|
|
238
440
|
lastSeenAt: new Date().toISOString(),
|
|
239
441
|
lastMessageId: message.message_id
|
|
240
442
|
};
|
|
241
|
-
await
|
|
443
|
+
await this.persistState();
|
|
242
444
|
return existing.sessionId;
|
|
243
445
|
}
|
|
244
446
|
|
|
@@ -263,7 +465,7 @@ class TelegramGateway {
|
|
|
263
465
|
lastMessageId: message.message_id
|
|
264
466
|
};
|
|
265
467
|
delete this.state.pendingPairings[userId];
|
|
266
|
-
await
|
|
468
|
+
await this.persistState();
|
|
267
469
|
return session.id;
|
|
268
470
|
}
|
|
269
471
|
|
|
@@ -292,7 +494,7 @@ class TelegramGateway {
|
|
|
292
494
|
lastMessageId: message.message_id
|
|
293
495
|
};
|
|
294
496
|
delete this.state.pendingPairings[userId];
|
|
295
|
-
await
|
|
497
|
+
await this.persistState();
|
|
296
498
|
return session.id;
|
|
297
499
|
}
|
|
298
500
|
|
|
@@ -322,7 +524,7 @@ class TelegramGateway {
|
|
|
322
524
|
lastSeenAt: new Date().toISOString(),
|
|
323
525
|
lastMessageId: message.message_id
|
|
324
526
|
};
|
|
325
|
-
await
|
|
527
|
+
await this.persistState();
|
|
326
528
|
return session.id;
|
|
327
529
|
}
|
|
328
530
|
|
|
@@ -381,9 +583,70 @@ class TelegramGateway {
|
|
|
381
583
|
return JSON.parse(stdout);
|
|
382
584
|
}
|
|
383
585
|
|
|
586
|
+
async startTypingLoop(chatId) {
|
|
587
|
+
let stopped = false;
|
|
588
|
+
const tick = async () => {
|
|
589
|
+
while (!stopped) {
|
|
590
|
+
try {
|
|
591
|
+
await this.callApi("sendChatAction", {
|
|
592
|
+
chat_id: chatId,
|
|
593
|
+
action: "typing"
|
|
594
|
+
});
|
|
595
|
+
} catch {
|
|
596
|
+
// Best-effort only.
|
|
597
|
+
}
|
|
598
|
+
await sleep(TELEGRAM_TYPING_INTERVAL_MS);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
const promise = tick();
|
|
602
|
+
return async () => {
|
|
603
|
+
stopped = true;
|
|
604
|
+
await promise.catch(() => {});
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async sendProgressMessage(chatId, replyToMessageId) {
|
|
609
|
+
const [message] = await this.sendMessage(chatId, TELEGRAM_PREVIEW_TEXT, replyToMessageId, { parseMode: "HTML" });
|
|
610
|
+
return message || null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async deliverPromptResult(chatId, replyToMessageId, previewMessage, content) {
|
|
614
|
+
const chunks = renderTelegramChunks(content);
|
|
615
|
+
if (!chunks.length) {
|
|
616
|
+
if (previewMessage?.message_id) {
|
|
617
|
+
await this.editMessage(chatId, previewMessage.message_id, "(no content)");
|
|
618
|
+
} else {
|
|
619
|
+
await this.sendMessage(chatId, "(no content)", replyToMessageId);
|
|
620
|
+
}
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (previewMessage?.message_id) {
|
|
625
|
+
await this.editMessage(chatId, previewMessage.message_id, chunks[0]);
|
|
626
|
+
for (const chunk of chunks.slice(1)) {
|
|
627
|
+
await this.sendMessage(chatId, chunk, null, { parseMode: "HTML" });
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
633
|
+
await this.sendMessage(chatId, chunk, index === 0 ? replyToMessageId : null, { parseMode: "HTML" });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async deliverPromptFailure(chatId, replyToMessageId, previewMessage, error) {
|
|
638
|
+
const text = `<b>Remote run failed</b>\n${escapeTelegramHtml(stringifyError(error).slice(0, 3000))}`;
|
|
639
|
+
if (previewMessage?.message_id) {
|
|
640
|
+
await this.editMessage(chatId, previewMessage.message_id, text, { parseMode: "HTML" });
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
await this.sendMessage(chatId, text, replyToMessageId, { parseMode: "HTML" });
|
|
644
|
+
}
|
|
645
|
+
|
|
384
646
|
async handleTextMessage(message) {
|
|
385
647
|
if (!message?.chat || message.chat.type !== "private") return;
|
|
386
648
|
if (!message?.text) return;
|
|
649
|
+
this.prunePairings();
|
|
387
650
|
if (!this.isAuthorizedUser(message.from)) {
|
|
388
651
|
await this.rejectUnpairedMessage(message);
|
|
389
652
|
return;
|
|
@@ -399,7 +662,7 @@ class TelegramGateway {
|
|
|
399
662
|
|
|
400
663
|
if (text === "/new") {
|
|
401
664
|
const sessionId = await this.startFreshSession(message);
|
|
402
|
-
await this.sendMessage(message.chat.id, `Started new remote session:
|
|
665
|
+
await this.sendMessage(message.chat.id, `Started new remote session: <code>${escapeTelegramHtml(sessionId)}</code>`, message.message_id);
|
|
403
666
|
return;
|
|
404
667
|
}
|
|
405
668
|
|
|
@@ -464,25 +727,16 @@ class TelegramGateway {
|
|
|
464
727
|
return;
|
|
465
728
|
}
|
|
466
729
|
|
|
730
|
+
const stopTyping = await this.startTypingLoop(message.chat.id);
|
|
731
|
+
let previewMessage = null;
|
|
467
732
|
try {
|
|
468
|
-
await this.
|
|
469
|
-
chat_id: message.chat.id,
|
|
470
|
-
action: "typing"
|
|
471
|
-
});
|
|
472
|
-
} catch {
|
|
473
|
-
// Best-effort only.
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
try {
|
|
733
|
+
previewMessage = await this.sendProgressMessage(message.chat.id, message.message_id);
|
|
477
734
|
const content = await this.runPrompt(sessionId, text);
|
|
478
|
-
await this.
|
|
735
|
+
await this.deliverPromptResult(message.chat.id, message.message_id, previewMessage, content);
|
|
479
736
|
} catch (error) {
|
|
480
|
-
await this.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
message.message_id,
|
|
484
|
-
{ parseMode: "HTML" }
|
|
485
|
-
);
|
|
737
|
+
await this.deliverPromptFailure(message.chat.id, message.message_id, previewMessage, error);
|
|
738
|
+
} finally {
|
|
739
|
+
await stopTyping();
|
|
486
740
|
}
|
|
487
741
|
}
|
|
488
742
|
|
|
@@ -501,7 +755,10 @@ class TelegramGateway {
|
|
|
501
755
|
}
|
|
502
756
|
|
|
503
757
|
this.state = await loadGatewayState("telegram");
|
|
758
|
+
this.prunePairings();
|
|
504
759
|
const me = await this.getMe();
|
|
760
|
+
await this.registerCommands();
|
|
761
|
+
await this.persistState();
|
|
505
762
|
this.io.log?.(`Telegram gateway connected as @${me.username || me.id}`);
|
|
506
763
|
|
|
507
764
|
for (;;) {
|
|
@@ -513,7 +770,7 @@ class TelegramGateway {
|
|
|
513
770
|
}
|
|
514
771
|
await this.handleUpdate(update);
|
|
515
772
|
}
|
|
516
|
-
await
|
|
773
|
+
await this.persistState();
|
|
517
774
|
}
|
|
518
775
|
}
|
|
519
776
|
}
|