@tritard/waterbrother 0.15.10 → 0.15.11
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 +3 -0
- package/package.json +1 -1
- package/src/agent.js +2 -0
- package/src/channels.js +5 -2
- package/src/cli.js +136 -1
- package/src/gateway-state.js +5 -3
- package/src/gateway.js +185 -36
package/README.md
CHANGED
|
@@ -244,6 +244,8 @@ waterbrother channels list
|
|
|
244
244
|
waterbrother channels status
|
|
245
245
|
waterbrother channels show telegram
|
|
246
246
|
waterbrother gateway status
|
|
247
|
+
waterbrother gateway pairings
|
|
248
|
+
waterbrother gateway pair telegram <user-id>
|
|
247
249
|
waterbrother gateway run telegram
|
|
248
250
|
waterbrother onboarding telegram
|
|
249
251
|
waterbrother onboarding discord
|
|
@@ -260,6 +262,7 @@ Current Telegram limitation:
|
|
|
260
262
|
- remote prompts run with `approval=never`
|
|
261
263
|
- status, runtime inspection, and read-oriented prompts work best
|
|
262
264
|
- mutating and approval-heavy work stay local until remote approvals land
|
|
265
|
+
- pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
|
|
263
266
|
|
|
264
267
|
## Release flow
|
|
265
268
|
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -5,6 +5,8 @@ import { createToolRuntime } from "./tools.js";
|
|
|
5
5
|
const BASE_SYSTEM_PROMPT = `You are Waterbrother, a coding assistant running in a local terminal.
|
|
6
6
|
You can inspect and modify files with tools.
|
|
7
7
|
When you use tools:
|
|
8
|
+
- Describe Waterbrother as a bring-your-own-model local coding CLI when you need to describe it.
|
|
9
|
+
- Do not call Waterbrother "Grok-powered", "xAI-powered", or otherwise provider-branded unless the user is explicitly asking about provider configuration or the active model.
|
|
8
10
|
- Be precise and minimal.
|
|
9
11
|
- Prefer read/search before write.
|
|
10
12
|
- For large rewrites, design overhauls, or multi-section HTML/CSS edits, prefer reading the file once and then using write_file to replace the full file instead of chaining many replace_in_file calls.
|
package/src/channels.js
CHANGED
|
@@ -178,16 +178,19 @@ export function buildChannelOnboardingPayload(serviceId) {
|
|
|
178
178
|
prerequisites: [
|
|
179
179
|
"Create a Telegram bot with BotFather",
|
|
180
180
|
"Capture the bot token",
|
|
181
|
-
"
|
|
181
|
+
"Start with manual pairing so first-contact DMs create a pending approval request"
|
|
182
182
|
],
|
|
183
183
|
commands: [
|
|
184
184
|
"waterbrother config set-json channels '{\"telegram\":{\"enabled\":true,\"botToken\":\"YOUR_BOT_TOKEN\",\"pairingMode\":\"manual\",\"allowedUserIds\":[]}}'",
|
|
185
|
+
"waterbrother gateway pairings",
|
|
186
|
+
"waterbrother gateway pair telegram <user-id>",
|
|
185
187
|
"waterbrother channels status",
|
|
186
188
|
"waterbrother gateway status"
|
|
187
189
|
],
|
|
188
190
|
notes: [
|
|
189
191
|
"Start with direct messages only.",
|
|
190
|
-
"
|
|
192
|
+
"Unknown DMs create a pending pairing request you approve locally.",
|
|
193
|
+
"Remote commands include /help, /status, /runtime, /sessions, /resume <id>, /new, and /clear."
|
|
191
194
|
]
|
|
192
195
|
};
|
|
193
196
|
}
|
package/src/cli.js
CHANGED
|
@@ -10,6 +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
14
|
import {
|
|
14
15
|
getDefaultDesignModelForProvider,
|
|
15
16
|
getDefaultModelForProvider,
|
|
@@ -125,6 +126,9 @@ const INTERACTIVE_COMMANDS = [
|
|
|
125
126
|
{ name: "/runtime-profile save <name>", description: "Save current runtime as a named profile" },
|
|
126
127
|
{ name: "/runtime-profile load <name>", description: "Load a named runtime profile" },
|
|
127
128
|
{ name: "/gateway", description: "Show messaging gateway readiness" },
|
|
129
|
+
{ name: "/gateway pairings", description: "List pending Telegram pairing requests" },
|
|
130
|
+
{ name: "/gateway pair <user-id>", description: "Approve a pending Telegram user id" },
|
|
131
|
+
{ name: "/gateway unpair <user-id>", description: "Remove a paired Telegram user id" },
|
|
128
132
|
{ name: "/channels", description: "Show messaging channel readiness" },
|
|
129
133
|
{ name: "/agent", description: "Show active agent profile" },
|
|
130
134
|
{ name: "/agent <profile>", description: "Switch profile: coder|designer|reviewer|planner" },
|
|
@@ -236,6 +240,9 @@ Usage:
|
|
|
236
240
|
waterbrother channels show <service>
|
|
237
241
|
waterbrother gateway status
|
|
238
242
|
waterbrother gateway run telegram
|
|
243
|
+
waterbrother gateway pairings [telegram]
|
|
244
|
+
waterbrother gateway pair [telegram] <user-id>
|
|
245
|
+
waterbrother gateway unpair [telegram] <user-id>
|
|
239
246
|
waterbrother mcp list
|
|
240
247
|
waterbrother commit [--push]
|
|
241
248
|
waterbrother pr [--branch=<name>]
|
|
@@ -3217,6 +3224,83 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
|
|
|
3217
3224
|
return;
|
|
3218
3225
|
}
|
|
3219
3226
|
|
|
3227
|
+
if (sub === "pairings") {
|
|
3228
|
+
const service = String(positional[2] || "telegram").trim().toLowerCase();
|
|
3229
|
+
if (service !== "telegram") {
|
|
3230
|
+
throw new Error("Usage: waterbrother gateway pairings [telegram]");
|
|
3231
|
+
}
|
|
3232
|
+
const state = await loadGatewayState(service);
|
|
3233
|
+
const pending = Object.values(state.pendingPairings || {});
|
|
3234
|
+
const paired = Array.isArray(runtime.channels?.telegram?.allowedUserIds)
|
|
3235
|
+
? runtime.channels.telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean)
|
|
3236
|
+
: [];
|
|
3237
|
+
if (asJson) {
|
|
3238
|
+
printData({ service, pending, paired }, true);
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
console.log(`telegram paired users: ${paired.length ? paired.join(", ") : "none"}`);
|
|
3242
|
+
if (!pending.length) {
|
|
3243
|
+
console.log("telegram pending pairings: none");
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
console.log("telegram pending pairings:");
|
|
3247
|
+
for (const item of pending) {
|
|
3248
|
+
const username = String(item?.username || "").trim();
|
|
3249
|
+
const detail = username ? ` (${username})` : "";
|
|
3250
|
+
console.log(` ${item.userId}${detail}`);
|
|
3251
|
+
}
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
if (sub === "pair" || sub === "unpair") {
|
|
3256
|
+
let service = String(positional[2] || "").trim().toLowerCase();
|
|
3257
|
+
let userId = String(positional[3] || "").trim();
|
|
3258
|
+
if (!userId) {
|
|
3259
|
+
userId = service;
|
|
3260
|
+
service = "telegram";
|
|
3261
|
+
}
|
|
3262
|
+
if (service !== "telegram" || !userId) {
|
|
3263
|
+
throw new Error(`Usage: waterbrother gateway ${sub} [telegram] <user-id>`);
|
|
3264
|
+
}
|
|
3265
|
+
const { userConfig, projectConfig } = await loadConfigLayers(cwd);
|
|
3266
|
+
const targetScope = projectConfig?.channels?.telegram ? "project" : "user";
|
|
3267
|
+
const targetConfig = targetScope === "project" ? { ...projectConfig } : { ...userConfig };
|
|
3268
|
+
const channels = targetConfig.channels && typeof targetConfig.channels === "object" ? { ...targetConfig.channels } : {};
|
|
3269
|
+
const telegram = channels.telegram && typeof channels.telegram === "object" ? { ...channels.telegram } : {};
|
|
3270
|
+
const allowed = new Set(Array.isArray(telegram.allowedUserIds) ? telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean) : []);
|
|
3271
|
+
if (sub === "pair") {
|
|
3272
|
+
allowed.add(userId);
|
|
3273
|
+
telegram.enabled = telegram.enabled !== false;
|
|
3274
|
+
if (!telegram.pairingMode) telegram.pairingMode = "manual";
|
|
3275
|
+
} else {
|
|
3276
|
+
allowed.delete(userId);
|
|
3277
|
+
}
|
|
3278
|
+
telegram.allowedUserIds = [...allowed];
|
|
3279
|
+
channels.telegram = telegram;
|
|
3280
|
+
targetConfig.channels = channels;
|
|
3281
|
+
await saveConfig(targetConfig, { scope: targetScope, cwd });
|
|
3282
|
+
|
|
3283
|
+
const state = await loadGatewayState(service);
|
|
3284
|
+
if (state.pendingPairings?.[userId]) {
|
|
3285
|
+
delete state.pendingPairings[userId];
|
|
3286
|
+
}
|
|
3287
|
+
if (sub === "unpair" && state.peers?.[userId]) {
|
|
3288
|
+
delete state.peers[userId];
|
|
3289
|
+
}
|
|
3290
|
+
await saveGatewayState(service, state);
|
|
3291
|
+
|
|
3292
|
+
const message =
|
|
3293
|
+
sub === "pair"
|
|
3294
|
+
? `Paired Telegram user ${userId} via ${getConfigPath({ scope: targetScope, cwd })}`
|
|
3295
|
+
: `Unpaired Telegram user ${userId} via ${getConfigPath({ scope: targetScope, cwd })}`;
|
|
3296
|
+
if (asJson) {
|
|
3297
|
+
printData({ ok: true, service, userId, scope: targetScope, action: sub, allowedUserIds: telegram.allowedUserIds }, true);
|
|
3298
|
+
} else {
|
|
3299
|
+
console.log(message);
|
|
3300
|
+
}
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3220
3304
|
if (sub === "run") {
|
|
3221
3305
|
const service = String(positional[2] || "").trim().toLowerCase();
|
|
3222
3306
|
if (!service) {
|
|
@@ -3234,7 +3318,7 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
|
|
|
3234
3318
|
return;
|
|
3235
3319
|
}
|
|
3236
3320
|
|
|
3237
|
-
throw new Error("Usage: waterbrother gateway status|run <telegram>");
|
|
3321
|
+
throw new Error("Usage: waterbrother gateway status|run <telegram>|pairings [telegram]|pair [telegram] <user-id>|unpair [telegram] <user-id>");
|
|
3238
3322
|
}
|
|
3239
3323
|
|
|
3240
3324
|
async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
|
|
@@ -6746,6 +6830,57 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
|
|
|
6746
6830
|
continue;
|
|
6747
6831
|
}
|
|
6748
6832
|
|
|
6833
|
+
if (line === "/gateway pairings") {
|
|
6834
|
+
const state = await loadGatewayState("telegram");
|
|
6835
|
+
const pending = Object.values(state.pendingPairings || {});
|
|
6836
|
+
const paired = Array.isArray(context.runtime.channels?.telegram?.allowedUserIds)
|
|
6837
|
+
? context.runtime.channels.telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean)
|
|
6838
|
+
: [];
|
|
6839
|
+
console.log(`telegram paired users: ${paired.length ? paired.join(", ") : "none"}`);
|
|
6840
|
+
if (!pending.length) {
|
|
6841
|
+
console.log("telegram pending pairings: none");
|
|
6842
|
+
} else {
|
|
6843
|
+
console.log("telegram pending pairings:");
|
|
6844
|
+
for (const item of pending) {
|
|
6845
|
+
const username = String(item?.username || "").trim();
|
|
6846
|
+
console.log(` ${item.userId}${username ? ` (${username})` : ""}`);
|
|
6847
|
+
}
|
|
6848
|
+
}
|
|
6849
|
+
continue;
|
|
6850
|
+
}
|
|
6851
|
+
|
|
6852
|
+
if (line.startsWith("/gateway pair ")) {
|
|
6853
|
+
const userId = line.replace("/gateway pair", "").trim();
|
|
6854
|
+
if (!userId) {
|
|
6855
|
+
console.log("Usage: /gateway pair <user-id>");
|
|
6856
|
+
continue;
|
|
6857
|
+
}
|
|
6858
|
+
try {
|
|
6859
|
+
await runGatewayCommand(["gateway", "pair", "telegram", userId], context.runtime, { cwd: context.cwd, asJson: false });
|
|
6860
|
+
const { config } = await loadConfigLayers(context.cwd);
|
|
6861
|
+
context.runtime.channels = config.channels || context.runtime.channels;
|
|
6862
|
+
} catch (error) {
|
|
6863
|
+
console.log(`gateway pair failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6864
|
+
}
|
|
6865
|
+
continue;
|
|
6866
|
+
}
|
|
6867
|
+
|
|
6868
|
+
if (line.startsWith("/gateway unpair ")) {
|
|
6869
|
+
const userId = line.replace("/gateway unpair", "").trim();
|
|
6870
|
+
if (!userId) {
|
|
6871
|
+
console.log("Usage: /gateway unpair <user-id>");
|
|
6872
|
+
continue;
|
|
6873
|
+
}
|
|
6874
|
+
try {
|
|
6875
|
+
await runGatewayCommand(["gateway", "unpair", "telegram", userId], context.runtime, { cwd: context.cwd, asJson: false });
|
|
6876
|
+
const { config } = await loadConfigLayers(context.cwd);
|
|
6877
|
+
context.runtime.channels = config.channels || context.runtime.channels;
|
|
6878
|
+
} catch (error) {
|
|
6879
|
+
console.log(`gateway unpair failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6880
|
+
}
|
|
6881
|
+
continue;
|
|
6882
|
+
}
|
|
6883
|
+
|
|
6749
6884
|
if (line === "/channels") {
|
|
6750
6885
|
for (const status of getChannelStatuses(context.runtime)) {
|
|
6751
6886
|
console.log(formatChannelStatusLine(status));
|
package/src/gateway-state.js
CHANGED
|
@@ -20,11 +20,12 @@ export async function loadGatewayState(serviceId) {
|
|
|
20
20
|
const parsed = JSON.parse(raw);
|
|
21
21
|
return {
|
|
22
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 : {}
|
|
23
|
+
peers: parsed?.peers && typeof parsed.peers === "object" ? parsed.peers : {},
|
|
24
|
+
pendingPairings: parsed?.pendingPairings && typeof parsed.pendingPairings === "object" ? parsed.pendingPairings : {}
|
|
24
25
|
};
|
|
25
26
|
} catch (error) {
|
|
26
27
|
if (error?.code === "ENOENT") {
|
|
27
|
-
return { offset: 0, peers: {} };
|
|
28
|
+
return { offset: 0, peers: {}, pendingPairings: {} };
|
|
28
29
|
}
|
|
29
30
|
throw error;
|
|
30
31
|
}
|
|
@@ -35,7 +36,8 @@ export async function saveGatewayState(serviceId, state) {
|
|
|
35
36
|
const filePath = gatewayStatePath(serviceId);
|
|
36
37
|
const next = {
|
|
37
38
|
offset: Number.isFinite(Number(state?.offset)) ? Math.max(0, Math.floor(Number(state.offset))) : 0,
|
|
38
|
-
peers: state?.peers && typeof state.peers === "object" ? state.peers : {}
|
|
39
|
+
peers: state?.peers && typeof state.peers === "object" ? state.peers : {},
|
|
40
|
+
pendingPairings: state?.pendingPairings && typeof state.pendingPairings === "object" ? state.pendingPairings : {}
|
|
39
41
|
};
|
|
40
42
|
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
41
43
|
await fs.writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
package/src/gateway.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { createSession, saveSession } from "./session-store.js";
|
|
6
|
+
import { createSession, listSessions, loadSession, saveSession } from "./session-store.js";
|
|
7
7
|
import { loadGatewayState, saveGatewayState } from "./gateway-state.js";
|
|
8
8
|
import { getGatewayStatus, getChannelSpec } from "./channels.js";
|
|
9
9
|
|
|
@@ -37,38 +37,94 @@ function stringifyError(error) {
|
|
|
37
37
|
return error.stderr || error.stdout || error.message || String(error);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function escapeTelegramHtml(text) {
|
|
41
|
+
return String(text || "")
|
|
42
|
+
.replace(/&/g, "&")
|
|
43
|
+
.replace(/</g, "<")
|
|
44
|
+
.replace(/>/g, ">");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderTelegramMarkup(text) {
|
|
48
|
+
let value = escapeTelegramHtml(String(text || "").trim());
|
|
49
|
+
value = value.replace(/\*\*([^*\n][^*]*?)\*\*/g, "<b>$1</b>");
|
|
50
|
+
value = value.replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
|
51
|
+
value = value.replace(/^\s*-\s+/gm, "• ");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
function buildRemoteHelp() {
|
|
41
56
|
return [
|
|
42
|
-
"Waterbrother Telegram control",
|
|
57
|
+
"<b>Waterbrother Telegram control</b>",
|
|
43
58
|
"",
|
|
44
|
-
"Commands
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
59
|
+
"<b>Commands</b>",
|
|
60
|
+
"<code>/help</code> show this help",
|
|
61
|
+
"<code>/status</code> show the current linked remote session",
|
|
62
|
+
"<code>/runtime</code> show active provider/model/runtime state",
|
|
63
|
+
"<code>/sessions</code> list recent linked remote sessions",
|
|
64
|
+
"<code>/resume <session-id></code> switch the linked remote session",
|
|
65
|
+
"<code>/new</code> start a fresh remote session",
|
|
66
|
+
"<code>/clear</code> clear the current remote conversation history",
|
|
49
67
|
"",
|
|
50
68
|
"Any other message is sent to the linked Waterbrother session.",
|
|
51
69
|
"",
|
|
52
|
-
"Current limitation
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
70
|
+
"<b>Current limitation</b>",
|
|
71
|
+
"• remote prompts run with <code>approval=never</code>",
|
|
72
|
+
"• read/status flows work best today",
|
|
73
|
+
"• mutating actions stay local until remote approvals land"
|
|
56
74
|
].join("\n");
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
function formatGatewaySessionStatus({ sessionId, userId, username, cwd, runtimeProfile, provider, model }) {
|
|
60
78
|
const bits = [
|
|
61
|
-
"Telegram remote session",
|
|
62
|
-
`user
|
|
63
|
-
`session
|
|
64
|
-
`provider
|
|
65
|
-
`model
|
|
66
|
-
`
|
|
67
|
-
`cwd
|
|
79
|
+
"<b>Telegram remote session</b>",
|
|
80
|
+
`user: <code>${escapeTelegramHtml(userId)}</code>${username ? ` (${escapeTelegramHtml(username)})` : ""}`,
|
|
81
|
+
`session: <code>${escapeTelegramHtml(sessionId)}</code>`,
|
|
82
|
+
`provider: <code>${escapeTelegramHtml(provider)}</code>`,
|
|
83
|
+
`model: <code>${escapeTelegramHtml(model)}</code>`,
|
|
84
|
+
`runtime profile: <code>${escapeTelegramHtml(runtimeProfile || "none")}</code>`,
|
|
85
|
+
`cwd: <code>${escapeTelegramHtml(cwd)}</code>`
|
|
68
86
|
];
|
|
69
87
|
return bits.join("\n");
|
|
70
88
|
}
|
|
71
89
|
|
|
90
|
+
function upsertSessionHistory(existing, sessionId) {
|
|
91
|
+
const next = Array.isArray(existing) ? existing.map((value) => String(value || "").trim()).filter(Boolean) : [];
|
|
92
|
+
const normalized = String(sessionId || "").trim();
|
|
93
|
+
if (!normalized) return next;
|
|
94
|
+
return [normalized, ...next.filter((value) => value !== normalized)].slice(0, 12);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatRuntimeStatus(status = {}) {
|
|
98
|
+
return [
|
|
99
|
+
"<b>Runtime status</b>",
|
|
100
|
+
`provider: <code>${escapeTelegramHtml(status.provider || "unknown")}</code>`,
|
|
101
|
+
`model: <code>${escapeTelegramHtml(status.model || "unknown")}</code>`,
|
|
102
|
+
`design model: <code>${escapeTelegramHtml(status.designModel || "unknown")}</code>`,
|
|
103
|
+
`base URL: <code>${escapeTelegramHtml(status.baseUrl || "")}</code>`,
|
|
104
|
+
`API key configured: <code>${status.apiKeyConfigured ? "yes" : "no"}</code>`
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatSessionListMarkup(currentSessionId, sessions = []) {
|
|
109
|
+
if (!sessions.length) {
|
|
110
|
+
return "<b>Remote sessions</b>\nNo linked remote sessions yet.";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lines = ["<b>Remote sessions</b>"];
|
|
114
|
+
for (const session of sessions) {
|
|
115
|
+
const id = String(session?.id || "").trim();
|
|
116
|
+
if (!id) continue;
|
|
117
|
+
const label = id === currentSessionId ? "current" : "recent";
|
|
118
|
+
const preview = String(session?.lastUserPreview || "").trim();
|
|
119
|
+
const updatedAt = String(session?.updatedAt || "").trim();
|
|
120
|
+
let line = `• <code>${escapeTelegramHtml(id)}</code> <i>(${label})</i>`;
|
|
121
|
+
if (updatedAt) line += `\n updated: <code>${escapeTelegramHtml(updatedAt)}</code>`;
|
|
122
|
+
if (preview) line += `\n last user: ${escapeTelegramHtml(preview).slice(0, 140)}`;
|
|
123
|
+
lines.push(line);
|
|
124
|
+
}
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
72
128
|
class TelegramGateway {
|
|
73
129
|
constructor({ runtime, cwd, io = console }) {
|
|
74
130
|
this.runtime = runtime;
|
|
@@ -100,13 +156,15 @@ class TelegramGateway {
|
|
|
100
156
|
return json.result;
|
|
101
157
|
}
|
|
102
158
|
|
|
103
|
-
async sendMessage(chatId, text, replyToMessageId = null) {
|
|
159
|
+
async sendMessage(chatId, text, replyToMessageId = null, { parseMode = "HTML" } = {}) {
|
|
104
160
|
const chunks = chunkText(text);
|
|
105
161
|
for (const chunk of chunks) {
|
|
106
162
|
await this.callApi("sendMessage", {
|
|
107
163
|
chat_id: chatId,
|
|
108
164
|
text: chunk,
|
|
109
|
-
reply_to_message_id: replyToMessageId || undefined
|
|
165
|
+
reply_to_message_id: replyToMessageId || undefined,
|
|
166
|
+
parse_mode: parseMode || undefined,
|
|
167
|
+
disable_web_page_preview: true
|
|
110
168
|
});
|
|
111
169
|
}
|
|
112
170
|
}
|
|
@@ -129,17 +187,37 @@ class TelegramGateway {
|
|
|
129
187
|
return allowed.includes(userId);
|
|
130
188
|
}
|
|
131
189
|
|
|
190
|
+
async addPendingPairing(message) {
|
|
191
|
+
const userId = String(message?.from?.id || "").trim();
|
|
192
|
+
const username = [message?.from?.username, message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim();
|
|
193
|
+
this.state.pendingPairings[userId] = {
|
|
194
|
+
userId,
|
|
195
|
+
username,
|
|
196
|
+
chatId: String(message.chat.id),
|
|
197
|
+
firstSeenAt: this.state.pendingPairings[userId]?.firstSeenAt || new Date().toISOString(),
|
|
198
|
+
lastSeenAt: new Date().toISOString(),
|
|
199
|
+
lastMessageId: message.message_id
|
|
200
|
+
};
|
|
201
|
+
await saveGatewayState("telegram", this.state);
|
|
202
|
+
}
|
|
203
|
+
|
|
132
204
|
async rejectUnpairedMessage(message) {
|
|
133
205
|
const userId = String(message?.from?.id || "").trim();
|
|
206
|
+
await this.addPendingPairing(message);
|
|
134
207
|
await this.sendMessage(
|
|
135
208
|
message.chat.id,
|
|
136
209
|
[
|
|
137
|
-
"
|
|
138
|
-
`
|
|
210
|
+
"<b>Pairing required</b>",
|
|
211
|
+
`This Telegram account is not paired with Waterbrother yet.`,
|
|
212
|
+
`Your Telegram user id is: <code>${escapeTelegramHtml(userId)}</code>`,
|
|
213
|
+
"",
|
|
214
|
+
"Approve it locally with:",
|
|
215
|
+
`<code>waterbrother gateway pair telegram ${escapeTelegramHtml(userId)}</code>`,
|
|
139
216
|
"",
|
|
140
|
-
"
|
|
217
|
+
"Then keep the gateway running and send your message again."
|
|
141
218
|
].join("\n"),
|
|
142
|
-
message.message_id
|
|
219
|
+
message.message_id,
|
|
220
|
+
{ parseMode: "HTML" }
|
|
143
221
|
);
|
|
144
222
|
}
|
|
145
223
|
|
|
@@ -156,6 +234,7 @@ class TelegramGateway {
|
|
|
156
234
|
...existing,
|
|
157
235
|
chatId: String(message.chat.id),
|
|
158
236
|
username,
|
|
237
|
+
sessions: upsertSessionHistory(existing.sessions, existing.sessionId),
|
|
159
238
|
lastSeenAt: new Date().toISOString(),
|
|
160
239
|
lastMessageId: message.message_id
|
|
161
240
|
};
|
|
@@ -178,10 +257,12 @@ class TelegramGateway {
|
|
|
178
257
|
sessionId: session.id,
|
|
179
258
|
chatId: String(message.chat.id),
|
|
180
259
|
username,
|
|
260
|
+
sessions: upsertSessionHistory([], session.id),
|
|
181
261
|
linkedAt: new Date().toISOString(),
|
|
182
262
|
lastSeenAt: new Date().toISOString(),
|
|
183
263
|
lastMessageId: message.message_id
|
|
184
264
|
};
|
|
265
|
+
delete this.state.pendingPairings[userId];
|
|
185
266
|
await saveGatewayState("telegram", this.state);
|
|
186
267
|
return session.id;
|
|
187
268
|
}
|
|
@@ -205,14 +286,57 @@ class TelegramGateway {
|
|
|
205
286
|
sessionId: session.id,
|
|
206
287
|
chatId: String(message.chat.id),
|
|
207
288
|
username: [message?.from?.username, message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim(),
|
|
289
|
+
sessions: upsertSessionHistory(previous.sessions, session.id),
|
|
208
290
|
linkedAt: new Date().toISOString(),
|
|
209
291
|
lastSeenAt: new Date().toISOString(),
|
|
210
292
|
lastMessageId: message.message_id
|
|
211
293
|
};
|
|
294
|
+
delete this.state.pendingPairings[userId];
|
|
212
295
|
await saveGatewayState("telegram", this.state);
|
|
213
296
|
return session.id;
|
|
214
297
|
}
|
|
215
298
|
|
|
299
|
+
async listPeerSessions(userId) {
|
|
300
|
+
const peer = this.getPeerState(userId);
|
|
301
|
+
const ids = Array.isArray(peer?.sessions) ? peer.sessions.map((value) => String(value || "").trim()).filter(Boolean) : [];
|
|
302
|
+
if (!ids.length && peer?.sessionId) ids.push(String(peer.sessionId));
|
|
303
|
+
if (!ids.length) return [];
|
|
304
|
+
const all = await listSessions(Math.max(20, ids.length + 4));
|
|
305
|
+
const byId = new Map(all.map((session) => [session.id, session]));
|
|
306
|
+
return ids.map((id) => byId.get(id) || { id }).filter(Boolean);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async resumePeerSession(message, sessionId) {
|
|
310
|
+
const userId = String(message?.from?.id || "").trim();
|
|
311
|
+
const normalizedId = String(sessionId || "").trim();
|
|
312
|
+
if (!normalizedId) throw new Error("Usage: /resume <session-id>");
|
|
313
|
+
const session = await loadSession(normalizedId);
|
|
314
|
+
const previous = this.getPeerState(userId) || {};
|
|
315
|
+
this.state.peers[userId] = {
|
|
316
|
+
...previous,
|
|
317
|
+
sessionId: session.id,
|
|
318
|
+
chatId: String(message.chat.id),
|
|
319
|
+
username: [message?.from?.username, message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim(),
|
|
320
|
+
sessions: upsertSessionHistory(previous.sessions, session.id),
|
|
321
|
+
linkedAt: previous.linkedAt || new Date().toISOString(),
|
|
322
|
+
lastSeenAt: new Date().toISOString(),
|
|
323
|
+
lastMessageId: message.message_id
|
|
324
|
+
};
|
|
325
|
+
await saveGatewayState("telegram", this.state);
|
|
326
|
+
return session.id;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async clearRemoteConversation(sessionId) {
|
|
330
|
+
const session = await loadSession(sessionId);
|
|
331
|
+
session.messages = [];
|
|
332
|
+
session.runState = {
|
|
333
|
+
state: "done",
|
|
334
|
+
detail: "",
|
|
335
|
+
updatedAt: new Date().toISOString()
|
|
336
|
+
};
|
|
337
|
+
await saveSession(session);
|
|
338
|
+
}
|
|
339
|
+
|
|
216
340
|
async runWaterbrother(args = []) {
|
|
217
341
|
const childEnv = {
|
|
218
342
|
...process.env
|
|
@@ -280,9 +404,9 @@ class TelegramGateway {
|
|
|
280
404
|
}
|
|
281
405
|
|
|
282
406
|
const sessionId = await this.ensurePeerSession(message);
|
|
407
|
+
const peer = this.getPeerState(userId);
|
|
283
408
|
|
|
284
409
|
if (text === "/status") {
|
|
285
|
-
const peer = this.getPeerState(userId);
|
|
286
410
|
await this.sendMessage(
|
|
287
411
|
message.chat.id,
|
|
288
412
|
formatGatewaySessionStatus({
|
|
@@ -301,16 +425,40 @@ class TelegramGateway {
|
|
|
301
425
|
|
|
302
426
|
if (text === "/runtime") {
|
|
303
427
|
const status = await this.runRuntimeStatus();
|
|
428
|
+
await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (text === "/sessions") {
|
|
433
|
+
const sessions = await this.listPeerSessions(userId);
|
|
434
|
+
await this.sendMessage(message.chat.id, formatSessionListMarkup(sessionId, sessions), message.message_id);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (text.startsWith("/resume")) {
|
|
439
|
+
const nextSessionId = text.replace("/resume", "").trim();
|
|
440
|
+
if (!nextSessionId) {
|
|
441
|
+
await this.sendMessage(
|
|
442
|
+
message.chat.id,
|
|
443
|
+
"Usage: <code>/resume <session-id></code>\nUse <code>/sessions</code> to list recent remote session ids.",
|
|
444
|
+
message.message_id
|
|
445
|
+
);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const resumedId = await this.resumePeerSession(message, nextSessionId);
|
|
304
449
|
await this.sendMessage(
|
|
305
450
|
message.chat.id,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
451
|
+
`Linked remote session: <code>${escapeTelegramHtml(resumedId)}</code>`,
|
|
452
|
+
message.message_id
|
|
453
|
+
);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (text === "/clear") {
|
|
458
|
+
await this.clearRemoteConversation(sessionId);
|
|
459
|
+
await this.sendMessage(
|
|
460
|
+
message.chat.id,
|
|
461
|
+
`Cleared conversation history for <code>${escapeTelegramHtml(sessionId)}</code>.`,
|
|
314
462
|
message.message_id
|
|
315
463
|
);
|
|
316
464
|
return;
|
|
@@ -327,12 +475,13 @@ class TelegramGateway {
|
|
|
327
475
|
|
|
328
476
|
try {
|
|
329
477
|
const content = await this.runPrompt(sessionId, text);
|
|
330
|
-
await this.sendMessage(message.chat.id, content, message.message_id);
|
|
478
|
+
await this.sendMessage(message.chat.id, renderTelegramMarkup(content), message.message_id);
|
|
331
479
|
} catch (error) {
|
|
332
480
|
await this.sendMessage(
|
|
333
481
|
message.chat.id,
|
|
334
|
-
|
|
335
|
-
message.message_id
|
|
482
|
+
`<b>Remote run failed</b>\n${escapeTelegramHtml(stringifyError(error).slice(0, 3000))}`,
|
|
483
|
+
message.message_id,
|
|
484
|
+
{ parseMode: "HTML" }
|
|
336
485
|
);
|
|
337
486
|
}
|
|
338
487
|
}
|