@tritard/waterbrother 0.16.6 → 0.16.8

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 CHANGED
@@ -29,6 +29,7 @@ It is Vercel-ready via `vercel.json` (clean URLs, no build step required).
29
29
  - First-run onboarding wizard in terminal
30
30
  - asks for provider first
31
31
  - asks for API key when the selected provider requires one
32
+ - tries a live provider model list after credentials, then falls back to the built-in catalog
32
33
  - prompts for default model and agent profile
33
34
  - Multi-provider chat integration through provider adapters and model registry
34
35
  - Vision command for local images: `waterbrother vision <image-path> <prompt>`
@@ -258,9 +259,11 @@ Rollout order:
258
259
  3. approvals over messaging
259
260
  4. only then group DM collaboration
260
261
 
261
- Current Telegram limitation:
262
+ Current Telegram behavior:
262
263
  - remote prompts run with `approval=never`
263
- - status, runtime inspection, and read-oriented prompts work best
264
+ - replies are rendered as Telegram-safe HTML, with code blocks and stripped raw Markdown symbols
265
+ - the gateway sends typing activity and edits one in-progress message into the final reply before falling back to chunked follow-up messages
266
+ - pending pairings are explicit and expire automatically after 12 hours unless approved
264
267
  - mutating and approval-heavy work stay local until remote approvals land
265
268
  - pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
266
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.16.6",
3
+ "version": "0.16.8",
4
4
  "description": "Waterbrother: bring-your-own-model coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
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
- "Remote commands include /help, /status, /runtime, /sessions, /resume <id>, /new, and /clear."
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,
@@ -2830,16 +2830,65 @@ async function chooseProviderInteractive(defaultProvider = 'xai') {
2830
2830
  return selected?.value || defaultProvider;
2831
2831
  }
2832
2832
 
2833
- async function chooseModelInteractive(defaultModel, provider = '') {
2834
- const catalog = MODEL_CATALOG.filter((model) => !provider || model.provider === provider);
2835
- const defaultIndex = Math.max(0, catalog.findIndex((model) => model.id === defaultModel));
2836
- const options = catalog.map((model, index) => ({
2837
- value: model.id,
2838
- label: `${model.id}${index === defaultIndex ? ' (default)' : ''}`
2833
+ async function chooseModelInteractive(defaultModel, provider = '', { apiKey = '', baseUrl = '', allowRemote = true } = {}) {
2834
+ const normalizedProvider = normalizeProvider(provider);
2835
+ let remoteModels = [];
2836
+ if (allowRemote && normalizedProvider) {
2837
+ try {
2838
+ remoteModels = await listModels({
2839
+ provider: normalizedProvider,
2840
+ apiKey,
2841
+ baseUrl
2842
+ });
2843
+ } catch {
2844
+ remoteModels = [];
2845
+ }
2846
+ }
2847
+
2848
+ const seen = new Set();
2849
+ const options = [];
2850
+
2851
+ for (const model of remoteModels) {
2852
+ const value = String(model?.id || "").trim();
2853
+ if (!value || seen.has(value)) continue;
2854
+ seen.add(value);
2855
+ options.push({
2856
+ value,
2857
+ label: model?.notes ? `${value} - ${model.notes}` : value
2858
+ });
2859
+ }
2860
+
2861
+ for (const model of MODEL_CATALOG) {
2862
+ if (normalizedProvider && model.provider !== normalizedProvider) continue;
2863
+ const value = String(model?.id || "").trim();
2864
+ if (!value || seen.has(value)) continue;
2865
+ seen.add(value);
2866
+ options.push({
2867
+ value,
2868
+ label: model?.notes ? `${value} - ${model.notes}` : value
2869
+ });
2870
+ }
2871
+
2872
+ if (!options.length) {
2873
+ options.push({
2874
+ value: defaultModel,
2875
+ label: `${defaultModel} (default)`
2876
+ });
2877
+ }
2878
+
2879
+ const defaultIndex = Math.max(0, options.findIndex((model) => model.value === defaultModel));
2880
+ const titledOptions = options.map((model, index) => ({
2881
+ value: model.value,
2882
+ label: `${model.label}${index === defaultIndex ? " (default)" : ""}`
2839
2883
  }));
2884
+
2840
2885
  const selected = await chooseFromInteractiveMenu({
2841
- title: 'Select default model:',
2842
- options,
2886
+ title: remoteModels.length
2887
+ ? `Select default model for ${normalizedProvider} (live list):`
2888
+ : normalizedProvider
2889
+ ? `Select default model for ${normalizedProvider}:`
2890
+ : "Select default model:",
2891
+ options: titledOptions,
2843
2892
  defaultIndex
2844
2893
  });
2845
2894
  return selected?.value || defaultModel;
@@ -2927,7 +2976,11 @@ async function runOnboardingWizard(config, { cwd }) {
2927
2976
  }
2928
2977
 
2929
2978
  next.baseUrl = providerSpec?.defaultBaseUrl || next.baseUrl || '';
2930
- next.model = await chooseModelInteractive(next.model || getDefaultModelForProvider(next.provider), next.provider);
2979
+ next.model = await chooseModelInteractive(next.model || getDefaultModelForProvider(next.provider), next.provider, {
2980
+ apiKey: next.apiKey,
2981
+ baseUrl: next.baseUrl,
2982
+ allowRemote: providerSpec?.supportsRemoteModelList !== false
2983
+ });
2931
2984
  next.agentProfile = await chooseAgentProfileInteractive(next.agentProfile || 'coder');
2932
2985
  if (!next.approvalMode) next.approvalMode = 'on-request';
2933
2986
  if (!next.designModel) next.designModel = getDefaultDesignModelForProvider(next.provider);
@@ -3229,13 +3282,18 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
3229
3282
  if (service !== "telegram") {
3230
3283
  throw new Error("Usage: waterbrother gateway pairings [telegram]");
3231
3284
  }
3232
- const state = await loadGatewayState(service);
3285
+ const ttlMinutes = Number.isFinite(Number(runtime.channels?.telegram?.pairingExpiryMinutes))
3286
+ ? Math.max(1, Math.floor(Number(runtime.channels.telegram.pairingExpiryMinutes)))
3287
+ : DEFAULT_PENDING_PAIRING_TTL_MINUTES;
3288
+ const loadedState = await loadGatewayState(service);
3289
+ const { state, pruned } = prunePendingPairings(loadedState, ttlMinutes);
3290
+ if (pruned > 0) await saveGatewayState(service, state);
3233
3291
  const pending = Object.values(state.pendingPairings || {});
3234
3292
  const paired = Array.isArray(runtime.channels?.telegram?.allowedUserIds)
3235
3293
  ? runtime.channels.telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean)
3236
3294
  : [];
3237
3295
  if (asJson) {
3238
- printData({ service, pending, paired }, true);
3296
+ printData({ service, pending, paired, pairingExpiryMinutes: ttlMinutes }, true);
3239
3297
  return;
3240
3298
  }
3241
3299
  console.log(`telegram paired users: ${paired.length ? paired.join(", ") : "none"}`);
@@ -3243,11 +3301,12 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
3243
3301
  console.log("telegram pending pairings: none");
3244
3302
  return;
3245
3303
  }
3246
- console.log("telegram pending pairings:");
3304
+ console.log(`telegram pending pairings (expire after ${ttlMinutes} minutes):`);
3247
3305
  for (const item of pending) {
3248
3306
  const username = String(item?.username || "").trim();
3249
3307
  const detail = username ? ` (${username})` : "";
3250
- console.log(` ${item.userId}${detail}`);
3308
+ const lastSeen = String(item?.lastSeenAt || item?.firstSeenAt || "").trim();
3309
+ console.log(` ${item.userId}${detail}${lastSeen ? ` [last seen ${lastSeen}]` : ""}`);
3251
3310
  }
3252
3311
  return;
3253
3312
  }
@@ -6876,7 +6935,12 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
6876
6935
  }
6877
6936
 
6878
6937
  if (line === "/gateway pairings") {
6879
- const state = await loadGatewayState("telegram");
6938
+ const ttlMinutes = Number.isFinite(Number(context.runtime.channels?.telegram?.pairingExpiryMinutes))
6939
+ ? Math.max(1, Math.floor(Number(context.runtime.channels.telegram.pairingExpiryMinutes)))
6940
+ : DEFAULT_PENDING_PAIRING_TTL_MINUTES;
6941
+ const loadedState = await loadGatewayState("telegram");
6942
+ const { state, pruned } = prunePendingPairings(loadedState, ttlMinutes);
6943
+ if (pruned > 0) await saveGatewayState("telegram", state);
6880
6944
  const pending = Object.values(state.pendingPairings || {});
6881
6945
  const paired = Array.isArray(context.runtime.channels?.telegram?.allowedUserIds)
6882
6946
  ? context.runtime.channels.telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean)
@@ -6885,10 +6949,11 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
6885
6949
  if (!pending.length) {
6886
6950
  console.log("telegram pending pairings: none");
6887
6951
  } else {
6888
- console.log("telegram pending pairings:");
6952
+ console.log(`telegram pending pairings (expire after ${ttlMinutes} minutes):`);
6889
6953
  for (const item of pending) {
6890
6954
  const username = String(item?.username || "").trim();
6891
- console.log(` ${item.userId}${username ? ` (${username})` : ""}`);
6955
+ const lastSeen = String(item?.lastSeenAt || item?.firstSeenAt || "").trim();
6956
+ console.log(` ${item.userId}${username ? ` (${username})` : ""}${lastSeen ? ` [last seen ${lastSeen}]` : ""}`);
6892
6957
  }
6893
6958
  }
6894
6959
  continue;
@@ -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
- const parsed = JSON.parse(raw);
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 chunkText(text, maxLength = TELEGRAM_MESSAGE_LIMIT) {
37
+ function escapeTelegramHtml(text) {
38
+ return String(text || "")
39
+ .replace(/&/g, "&amp;")
40
+ .replace(/</g, "&lt;")
41
+ .replace(/>/g, "&gt;");
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(/&lt;/g, "<")
59
+ .replace(/&gt;/g, ">")
60
+ .replace(/&amp;/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.6)) {
24
- splitIndex = rest.lastIndexOf(" ", maxLength);
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 stringifyError(error) {
35
- if (!error) return "unknown error";
36
- if (typeof error === "string") return error;
37
- return error.stderr || error.stdout || error.message || String(error);
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 escapeTelegramHtml(text) {
41
- return String(text || "")
42
- .replace(/&/g, "&amp;")
43
- .replace(/</g, "&lt;")
44
- .replace(/>/g, "&gt;");
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 renderTelegramMarkup(text) {
48
- let value = escapeTelegramHtml(String(text || "").trim());
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
- async callApi(method, payload = {}) {
147
- const response = await fetch(`${this.apiBase}/${method}`, {
148
- method: "POST",
149
- headers: { "content-type": "application/json" },
150
- body: JSON.stringify(payload)
151
- });
152
- const json = await response.json().catch(() => ({}));
153
- if (!response.ok || json?.ok === false) {
154
- throw new Error(`Telegram API ${method} failed: ${json?.description || response.statusText || response.status}`);
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
- return json.result;
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 = chunkText(text);
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: true
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 saveGatewayState("telegram", this.state);
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
- `This Telegram account is not paired with Waterbrother yet.`,
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 saveGatewayState("telegram", this.state);
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 saveGatewayState("telegram", this.state);
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 saveGatewayState("telegram", this.state);
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 saveGatewayState("telegram", this.state);
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: ${sessionId}`, message.message_id);
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.callApi("sendChatAction", {
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.sendMessage(message.chat.id, renderTelegramMarkup(content), message.message_id);
735
+ await this.deliverPromptResult(message.chat.id, message.message_id, previewMessage, content);
479
736
  } catch (error) {
480
- await this.sendMessage(
481
- message.chat.id,
482
- `<b>Remote run failed</b>\n${escapeTelegramHtml(stringifyError(error).slice(0, 3000))}`,
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 saveGatewayState("telegram", this.state);
773
+ await this.persistState();
517
774
  }
518
775
  }
519
776
  }