@wrongstack/telegram 0.287.0 → 0.291.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -18
- package/dist/api-client.d.ts +119 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/bot-queue.d.ts +27 -0
- package/dist/bot-queue.d.ts.map +1 -0
- package/dist/bot.d.ts +50 -67
- package/dist/bot.d.ts.map +1 -1
- package/dist/config-classifier.d.ts +64 -0
- package/dist/config-classifier.d.ts.map +1 -0
- package/dist/config.d.ts +45 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/inbox-cursor-store.d.ts +54 -0
- package/dist/inbox-cursor-store.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1254 -415
- package/dist/index.js.map +4 -4
- package/dist/offset-store.d.ts +40 -0
- package/dist/offset-store.d.ts.map +1 -0
- package/dist/outbound-queue.d.ts +52 -0
- package/dist/outbound-queue.d.ts.map +1 -0
- package/dist/security/outbound.d.ts +25 -0
- package/dist/security/outbound.d.ts.map +1 -0
- package/dist/slash-commands/index.d.ts +8 -2
- package/dist/slash-commands/index.d.ts.map +1 -1
- package/dist/tools/telegram-approve.d.ts +15 -3
- package/dist/tools/telegram-approve.d.ts.map +1 -1
- package/dist/tools/telegram-send.d.ts +6 -4
- package/dist/tools/telegram-send.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,15 +1,266 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { expectDefined as expectDefined2 } from "@wrongstack/core";
|
|
3
3
|
|
|
4
|
-
// src/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
// src/api-client.ts
|
|
5
|
+
var TelegramApiClientError = class extends Error {
|
|
6
|
+
kind;
|
|
7
|
+
method;
|
|
8
|
+
constructor(kind, method, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.kind = kind;
|
|
11
|
+
this.method = method;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var TelegramNetworkError = class extends TelegramApiClientError {
|
|
15
|
+
detail;
|
|
16
|
+
aborted;
|
|
17
|
+
constructor(method, detail, aborted = false) {
|
|
18
|
+
super("network", method, `Telegram network error during ${method}: ${detail}`);
|
|
19
|
+
this.name = "TelegramNetworkError";
|
|
20
|
+
this.detail = detail;
|
|
21
|
+
this.aborted = aborted;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
var TelegramHttpError = class extends TelegramApiClientError {
|
|
25
|
+
status;
|
|
26
|
+
constructor(method, status, statusText) {
|
|
27
|
+
const suffix = statusText ? ` ${statusText}` : "";
|
|
28
|
+
super("http", method, `Telegram HTTP error during ${method}: ${status}${suffix}`);
|
|
29
|
+
this.name = "TelegramHttpError";
|
|
30
|
+
this.status = status;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
var TelegramResponseParseError = class extends TelegramApiClientError {
|
|
34
|
+
constructor(method, detail) {
|
|
35
|
+
super("parse", method, `Telegram response parse error during ${method}: ${detail}`);
|
|
36
|
+
this.name = "TelegramResponseParseError";
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var TelegramBotApiError = class extends TelegramApiClientError {
|
|
40
|
+
errorCode;
|
|
41
|
+
httpStatus;
|
|
42
|
+
description;
|
|
43
|
+
retryAfterSeconds;
|
|
44
|
+
migrateToChatId;
|
|
45
|
+
constructor(method, opts) {
|
|
46
|
+
const code = opts.errorCode === void 0 ? "unknown" : String(opts.errorCode);
|
|
47
|
+
super("api", method, `Telegram API error ${code} during ${method}: ${opts.description}`);
|
|
48
|
+
this.name = "TelegramBotApiError";
|
|
49
|
+
this.errorCode = opts.errorCode;
|
|
50
|
+
this.httpStatus = opts.httpStatus;
|
|
51
|
+
this.description = opts.description;
|
|
52
|
+
this.retryAfterSeconds = opts.retryAfterSeconds;
|
|
53
|
+
this.migrateToChatId = opts.migrateToChatId;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var BACKOFF_BASE_MS = 1e3;
|
|
57
|
+
var BACKOFF_MAX_MS = 3e4;
|
|
58
|
+
function classifyRetry(err, attempt) {
|
|
59
|
+
if (attempt >= 3) return { retry: false, delayMs: 0 };
|
|
60
|
+
if (err instanceof TelegramHttpError) {
|
|
61
|
+
if (err.status === 429 || err.status === 409 || err.status >= 500) {
|
|
62
|
+
const delayMs2 = Math.min(
|
|
63
|
+
Math.ceil(BACKOFF_BASE_MS * 2 ** (attempt - 1) * (1 + Math.random() * 0.2)),
|
|
64
|
+
BACKOFF_MAX_MS
|
|
65
|
+
);
|
|
66
|
+
return { retry: true, delayMs: delayMs2 };
|
|
67
|
+
}
|
|
68
|
+
return { retry: false, delayMs: 0 };
|
|
69
|
+
}
|
|
70
|
+
if (err instanceof TelegramResponseParseError) return { retry: false, delayMs: 0 };
|
|
71
|
+
if (err instanceof TelegramNetworkError && err.aborted) return { retry: false, delayMs: 0 };
|
|
72
|
+
if (err instanceof TelegramBotApiError) {
|
|
73
|
+
const code = err.errorCode;
|
|
74
|
+
if (code !== void 0 && code >= 400 && code < 500 && code !== 429 && code !== 409) {
|
|
75
|
+
return { retry: false, delayMs: 0 };
|
|
76
|
+
}
|
|
77
|
+
if (code === 429) {
|
|
78
|
+
const baseDelay = err.retryAfterSeconds !== void 0 ? err.retryAfterSeconds * 1e3 : BACKOFF_BASE_MS * 2 ** (attempt - 1);
|
|
79
|
+
const delayMs2 = Math.min(Math.ceil(baseDelay * (1 + Math.random() * 0.3)), BACKOFF_MAX_MS);
|
|
80
|
+
return { retry: true, delayMs: delayMs2 };
|
|
81
|
+
}
|
|
82
|
+
if (code === 409) {
|
|
83
|
+
const delayMs2 = Math.min(BACKOFF_BASE_MS * 2 ** (attempt - 1), BACKOFF_MAX_MS);
|
|
84
|
+
return { retry: true, delayMs: delayMs2 };
|
|
85
|
+
}
|
|
86
|
+
if (code !== void 0 && code >= 500) {
|
|
87
|
+
const delayMs2 = Math.min(
|
|
88
|
+
Math.ceil(BACKOFF_BASE_MS * 2 ** (attempt - 1) * (1 + Math.random() * 0.2)),
|
|
89
|
+
BACKOFF_MAX_MS
|
|
90
|
+
);
|
|
91
|
+
return { retry: true, delayMs: delayMs2 };
|
|
92
|
+
}
|
|
93
|
+
if (code === void 0) {
|
|
94
|
+
const delayMs2 = Math.min(BACKOFF_BASE_MS * 2 ** (attempt - 1), BACKOFF_MAX_MS);
|
|
95
|
+
return { retry: true, delayMs: delayMs2 };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const delayMs = Math.min(
|
|
99
|
+
Math.ceil(BACKOFF_BASE_MS * 2 ** (attempt - 1) * (1 + Math.random() * 0.3)),
|
|
100
|
+
BACKOFF_MAX_MS
|
|
101
|
+
);
|
|
102
|
+
return { retry: true, delayMs };
|
|
8
103
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
104
|
+
function buildTelegramBotApiBaseUrl(token, apiRoot = "https://api.telegram.org") {
|
|
105
|
+
return `${apiRoot.replace(/\/+$/, "")}/bot${token}`;
|
|
106
|
+
}
|
|
107
|
+
function isRecord(value) {
|
|
108
|
+
return typeof value === "object" && value !== null;
|
|
109
|
+
}
|
|
110
|
+
function abortableSleep(ms, signal) {
|
|
111
|
+
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
if (signal.aborted) {
|
|
114
|
+
reject(new DOMException("The operation was aborted", "AbortError"));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
cleanup();
|
|
120
|
+
resolve();
|
|
121
|
+
}, ms);
|
|
122
|
+
const onAbort = () => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
cleanup();
|
|
125
|
+
reject(new DOMException("The operation was aborted", "AbortError"));
|
|
126
|
+
};
|
|
127
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function errorDetail(error) {
|
|
131
|
+
if (error instanceof Error) return error.message;
|
|
132
|
+
return String(error);
|
|
133
|
+
}
|
|
134
|
+
function composedSignal(signal, deadlineMs) {
|
|
135
|
+
if (deadlineMs !== void 0 && signal) {
|
|
136
|
+
return AbortSignal.any([signal, AbortSignal.timeout(deadlineMs)]);
|
|
137
|
+
}
|
|
138
|
+
if (deadlineMs !== void 0) return AbortSignal.timeout(deadlineMs);
|
|
139
|
+
return signal;
|
|
140
|
+
}
|
|
141
|
+
var TelegramApiClient = class {
|
|
12
142
|
safeBaseUrl;
|
|
143
|
+
token;
|
|
144
|
+
baseUrl;
|
|
145
|
+
fetchOverride;
|
|
146
|
+
constructor(opts) {
|
|
147
|
+
this.token = opts.token;
|
|
148
|
+
this.baseUrl = buildTelegramBotApiBaseUrl(opts.token, opts.apiRoot);
|
|
149
|
+
this.safeBaseUrl = this.redact(this.baseUrl);
|
|
150
|
+
this.fetchOverride = opts.fetch;
|
|
151
|
+
}
|
|
152
|
+
getMe(opts) {
|
|
153
|
+
return this.request("getMe", { signal: composedSignal(opts?.signal) });
|
|
154
|
+
}
|
|
155
|
+
getUpdates(opts) {
|
|
156
|
+
const query = new URLSearchParams({
|
|
157
|
+
offset: String(opts.offset),
|
|
158
|
+
timeout: String(opts.timeoutSeconds)
|
|
159
|
+
});
|
|
160
|
+
return this.request("getUpdates", {
|
|
161
|
+
query,
|
|
162
|
+
signal: composedSignal(opts.signal, opts.deadlineMs)
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
sendMessage(chatId, text, opts) {
|
|
166
|
+
return this.request("sendMessage", {
|
|
167
|
+
body: {
|
|
168
|
+
chat_id: String(chatId),
|
|
169
|
+
text,
|
|
170
|
+
disable_web_page_preview: true
|
|
171
|
+
},
|
|
172
|
+
signal: composedSignal(opts?.signal)
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
sendMessageWithKeyboard(chatId, text, buttons, opts) {
|
|
176
|
+
return this.request("sendMessage", {
|
|
177
|
+
body: {
|
|
178
|
+
chat_id: String(chatId),
|
|
179
|
+
text,
|
|
180
|
+
disable_web_page_preview: true,
|
|
181
|
+
reply_markup: {
|
|
182
|
+
inline_keyboard: [
|
|
183
|
+
buttons.map((button) => ({
|
|
184
|
+
text: button.text,
|
|
185
|
+
callback_data: button.callback_data
|
|
186
|
+
}))
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
signal: composedSignal(opts?.signal)
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
answerCallbackQuery(callbackQueryId, text, showAlert, opts) {
|
|
194
|
+
return this.request("answerCallbackQuery", {
|
|
195
|
+
body: {
|
|
196
|
+
callback_query_id: callbackQueryId,
|
|
197
|
+
text,
|
|
198
|
+
show_alert: showAlert
|
|
199
|
+
},
|
|
200
|
+
signal: composedSignal(opts?.signal)
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async request(method, opts) {
|
|
204
|
+
const query = opts?.query?.toString();
|
|
205
|
+
const url = `${this.baseUrl}/${method}${query ? `?${query}` : ""}`;
|
|
206
|
+
const init = {
|
|
207
|
+
method: opts?.body ? "POST" : "GET"
|
|
208
|
+
};
|
|
209
|
+
if (opts?.signal) init.signal = opts.signal;
|
|
210
|
+
if (opts?.body) {
|
|
211
|
+
init.headers = { "Content-Type": "application/json" };
|
|
212
|
+
init.body = JSON.stringify(opts.body);
|
|
213
|
+
}
|
|
214
|
+
let response;
|
|
215
|
+
try {
|
|
216
|
+
const fetchImpl = this.fetchOverride ?? globalThis.fetch;
|
|
217
|
+
response = await fetchImpl(url, init);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const detail = this.redact(errorDetail(error));
|
|
220
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
221
|
+
throw new TelegramNetworkError(method, detail, aborted);
|
|
222
|
+
}
|
|
223
|
+
let decoded;
|
|
224
|
+
try {
|
|
225
|
+
decoded = await response.json();
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
throw new TelegramHttpError(method, response.status, this.redact(response.statusText));
|
|
229
|
+
}
|
|
230
|
+
throw new TelegramResponseParseError(method, this.redact(errorDetail(error)));
|
|
231
|
+
}
|
|
232
|
+
if (!isRecord(decoded) || typeof decoded.ok !== "boolean") {
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
throw new TelegramHttpError(method, response.status, this.redact(response.statusText));
|
|
235
|
+
}
|
|
236
|
+
throw new TelegramResponseParseError(method, "expected a Bot API response envelope");
|
|
237
|
+
}
|
|
238
|
+
const envelope = decoded;
|
|
239
|
+
if (!envelope.ok) {
|
|
240
|
+
throw new TelegramBotApiError(method, {
|
|
241
|
+
errorCode: envelope.error_code,
|
|
242
|
+
httpStatus: response.status,
|
|
243
|
+
description: this.redact(envelope.description ?? "Unknown Bot API error"),
|
|
244
|
+
retryAfterSeconds: envelope.parameters?.retry_after,
|
|
245
|
+
migrateToChatId: envelope.parameters?.migrate_to_chat_id
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
throw new TelegramHttpError(method, response.status, this.redact(response.statusText));
|
|
250
|
+
}
|
|
251
|
+
if (envelope.result === void 0 || envelope.result === null) {
|
|
252
|
+
throw new TelegramResponseParseError(method, "successful response did not include result");
|
|
253
|
+
}
|
|
254
|
+
return envelope.result;
|
|
255
|
+
}
|
|
256
|
+
redact(value) {
|
|
257
|
+
return value.replaceAll(this.token, "[REDACTED]");
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// src/bot.ts
|
|
262
|
+
var TelegramBot = class _TelegramBot {
|
|
263
|
+
api;
|
|
13
264
|
pollIntervalMs;
|
|
14
265
|
allowedUsers;
|
|
15
266
|
allowedChats;
|
|
@@ -30,8 +281,8 @@ var TelegramBot = class _TelegramBot {
|
|
|
30
281
|
static CONFLICT_BACKOFF_AFTER = 3;
|
|
31
282
|
static CONFLICT_POLL_MS = 6e4;
|
|
32
283
|
_startedAt = null;
|
|
33
|
-
/**
|
|
34
|
-
|
|
284
|
+
/** Typed offset store for atomic polling-cursor persistence. */
|
|
285
|
+
offsetStore;
|
|
35
286
|
/** Single-poller election across wstack instances sharing this token. */
|
|
36
287
|
lock;
|
|
37
288
|
standbyRetryMs;
|
|
@@ -40,26 +291,25 @@ var TelegramBot = class _TelegramBot {
|
|
|
40
291
|
// Circular buffer for incoming messages
|
|
41
292
|
bufferMax;
|
|
42
293
|
buffer = [];
|
|
43
|
-
// Pending
|
|
44
|
-
//
|
|
45
|
-
//
|
|
294
|
+
// Pending approval requests keyed by request identity, not raw callback
|
|
295
|
+
// data. Each request binds both yes/no actions to its originating session,
|
|
296
|
+
// target chat, intended users, prompt message, and expiry.
|
|
46
297
|
callbackWaiters = /* @__PURE__ */ new Map();
|
|
47
298
|
constructor(opts) {
|
|
48
|
-
this.
|
|
49
|
-
this.safeBaseUrl = redactToken(this.baseUrl, opts.token);
|
|
299
|
+
this.api = new TelegramApiClient({ token: opts.token });
|
|
50
300
|
this.pollIntervalMs = opts.pollIntervalSec * 1e3;
|
|
51
301
|
this.allowedUsers = opts.allowedUsers;
|
|
52
302
|
this.allowedChats = opts.allowedChats;
|
|
53
303
|
this.bufferMax = opts.bufferSize;
|
|
54
304
|
this.log = opts.log;
|
|
55
305
|
this.onMessage = opts.onMessage;
|
|
56
|
-
this.
|
|
306
|
+
this.offsetStore = opts.offsetStore;
|
|
57
307
|
this.lock = opts.lock;
|
|
58
308
|
this.standbyRetryMs = opts.standbyRetryMs ?? 15e3;
|
|
59
309
|
if (this.lock) {
|
|
60
310
|
this.lock.onLost = () => this.handleLockLost();
|
|
61
311
|
}
|
|
62
|
-
if (this.
|
|
312
|
+
if (this.offsetStore) {
|
|
63
313
|
void this.loadOffset();
|
|
64
314
|
}
|
|
65
315
|
}
|
|
@@ -85,8 +335,11 @@ var TelegramBot = class _TelegramBot {
|
|
|
85
335
|
clearTimeout(this.standbyTimer);
|
|
86
336
|
this.standbyTimer = null;
|
|
87
337
|
}
|
|
88
|
-
for (const
|
|
89
|
-
this.
|
|
338
|
+
for (const requestId of Array.from(this.callbackWaiters.keys())) {
|
|
339
|
+
this.settleApproval(requestId, "cancelled", {
|
|
340
|
+
approved: false,
|
|
341
|
+
fromUser: "shutdown"
|
|
342
|
+
});
|
|
90
343
|
}
|
|
91
344
|
this.lock?.release();
|
|
92
345
|
this.log.info("Telegram bot stopped");
|
|
@@ -116,7 +369,7 @@ var TelegramBot = class _TelegramBot {
|
|
|
116
369
|
this.standbyAnnounced = false;
|
|
117
370
|
this.log.info("Telegram: poll lock acquired \u2014 taking over polling.");
|
|
118
371
|
} else {
|
|
119
|
-
this.log.info(`Telegram bot polling started (${this.safeBaseUrl})`);
|
|
372
|
+
this.log.info(`Telegram bot polling started (${this.api.safeBaseUrl})`);
|
|
120
373
|
}
|
|
121
374
|
this.schedulePoll();
|
|
122
375
|
}
|
|
@@ -127,7 +380,9 @@ var TelegramBot = class _TelegramBot {
|
|
|
127
380
|
clearTimeout(this.pollTimer);
|
|
128
381
|
this.pollTimer = null;
|
|
129
382
|
}
|
|
130
|
-
this.log.warn(
|
|
383
|
+
this.log.warn(
|
|
384
|
+
"Telegram: poll lock lost to another instance \u2014 pausing polling and standing by."
|
|
385
|
+
);
|
|
131
386
|
this.standbyAnnounced = true;
|
|
132
387
|
this.standbyTimer = setTimeout(() => this.acquireAndPoll(), this.standbyRetryMs);
|
|
133
388
|
this.standbyTimer.unref?.();
|
|
@@ -170,34 +425,30 @@ var TelegramBot = class _TelegramBot {
|
|
|
170
425
|
// ------------------------------------------------------------------
|
|
171
426
|
// Outgoing — send a message
|
|
172
427
|
// ------------------------------------------------------------------
|
|
173
|
-
async sendMessage(chatId, text) {
|
|
174
|
-
const url = `${this.baseUrl}/sendMessage`;
|
|
175
|
-
const body = JSON.stringify({
|
|
176
|
-
chat_id: String(chatId),
|
|
177
|
-
text,
|
|
178
|
-
disable_web_page_preview: true
|
|
179
|
-
});
|
|
428
|
+
async sendMessage(chatId, text, signal) {
|
|
180
429
|
this.log.debug(`Sending Telegram message to ${chatId} (${text.length} chars)`);
|
|
181
430
|
let lastErr;
|
|
182
431
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
183
432
|
try {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
body,
|
|
188
|
-
signal: AbortSignal.timeout(1e4)
|
|
433
|
+
const timeout = AbortSignal.timeout(1e4);
|
|
434
|
+
const result = await this.api.sendMessage(chatId, text, {
|
|
435
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
189
436
|
});
|
|
190
|
-
|
|
191
|
-
if (!data.ok) {
|
|
192
|
-
throw new Error(`Telegram API error ${data.error_code}: ${data.description}`);
|
|
193
|
-
}
|
|
194
|
-
return data;
|
|
437
|
+
return { ok: true, result };
|
|
195
438
|
} catch (err) {
|
|
196
439
|
lastErr = err;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
440
|
+
const decision = classifyRetry(err, attempt);
|
|
441
|
+
if (!decision.retry) {
|
|
442
|
+
if (attempt > 1)
|
|
443
|
+
this.log.debug(
|
|
444
|
+
`Telegram sendMessage terminal error on attempt ${attempt}, not retrying`
|
|
445
|
+
);
|
|
446
|
+
break;
|
|
200
447
|
}
|
|
448
|
+
this.log.debug(
|
|
449
|
+
`Telegram sendMessage attempt ${attempt} failed, retrying in ${decision.delayMs}ms...`
|
|
450
|
+
);
|
|
451
|
+
await abortableSleep(decision.delayMs, signal);
|
|
201
452
|
}
|
|
202
453
|
}
|
|
203
454
|
throw lastErr;
|
|
@@ -211,33 +462,26 @@ var TelegramBot = class _TelegramBot {
|
|
|
211
462
|
* yes/no prompt. The keyboard payload is opaque to the bot — callers
|
|
212
463
|
* pass already-encoded `callback_data` strings (≤ 64 bytes each).
|
|
213
464
|
*/
|
|
214
|
-
async sendMessageWithKeyboard(chatId, text, buttons) {
|
|
215
|
-
const url = `${this.baseUrl}/sendMessage`;
|
|
216
|
-
const body = JSON.stringify({
|
|
217
|
-
chat_id: String(chatId),
|
|
218
|
-
text,
|
|
219
|
-
disable_web_page_preview: true,
|
|
220
|
-
reply_markup: {
|
|
221
|
-
inline_keyboard: [buttons.map((b) => ({ text: b.text, callback_data: b.callback_data }))]
|
|
222
|
-
}
|
|
223
|
-
});
|
|
465
|
+
async sendMessageWithKeyboard(chatId, text, buttons, signal) {
|
|
224
466
|
let lastErr;
|
|
225
467
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
226
468
|
try {
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
body,
|
|
231
|
-
signal: AbortSignal.timeout(1e4)
|
|
469
|
+
const timeout = AbortSignal.timeout(1e4);
|
|
470
|
+
const result = await this.api.sendMessageWithKeyboard(chatId, text, buttons, {
|
|
471
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
232
472
|
});
|
|
233
|
-
|
|
234
|
-
if (!data.ok) {
|
|
235
|
-
throw new Error(`Telegram API error ${data.error_code}: ${data.description}`);
|
|
236
|
-
}
|
|
237
|
-
return data;
|
|
473
|
+
return { ok: true, result };
|
|
238
474
|
} catch (err) {
|
|
239
475
|
lastErr = err;
|
|
240
|
-
|
|
476
|
+
const decision = classifyRetry(err, attempt);
|
|
477
|
+
if (!decision.retry) {
|
|
478
|
+
if (attempt > 1)
|
|
479
|
+
this.log.debug(
|
|
480
|
+
`Telegram sendMessageWithKeyboard terminal error on attempt ${attempt}, not retrying`
|
|
481
|
+
);
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
await abortableSleep(decision.delayMs, signal);
|
|
241
485
|
}
|
|
242
486
|
}
|
|
243
487
|
throw lastErr;
|
|
@@ -245,18 +489,18 @@ var TelegramBot = class _TelegramBot {
|
|
|
245
489
|
// ------------------------------------------------------------------
|
|
246
490
|
// Health
|
|
247
491
|
// ------------------------------------------------------------------
|
|
248
|
-
async health() {
|
|
492
|
+
async health(signal) {
|
|
249
493
|
const ctrl = new AbortController();
|
|
250
494
|
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
251
495
|
try {
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
return { ok: true, username: data.result.username };
|
|
496
|
+
const timeout = AbortSignal.timeout(5e3);
|
|
497
|
+
const deadline = AbortSignal.any([ctrl.signal, timeout]);
|
|
498
|
+
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
499
|
+
const user = await this.api.getMe({ signal: combined });
|
|
500
|
+
return { ok: true, username: user.username };
|
|
259
501
|
} catch (err) {
|
|
502
|
+
if (err instanceof TelegramBotApiError) return { ok: false, error: err.description };
|
|
503
|
+
if (err instanceof TelegramNetworkError) return { ok: false, error: err.detail };
|
|
260
504
|
return { ok: false, error: err.message };
|
|
261
505
|
} finally {
|
|
262
506
|
clearTimeout(timer);
|
|
@@ -275,23 +519,13 @@ var TelegramBot = class _TelegramBot {
|
|
|
275
519
|
}
|
|
276
520
|
async poll() {
|
|
277
521
|
try {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (this.conflictStreak === _TelegramBot.CONFLICT_BACKOFF_AFTER) {
|
|
285
|
-
this.log.warn(
|
|
286
|
-
this.lock ? "Telegram: another consumer outside this machine is polling this bot token (HTTP 409) \u2014 backing off to 60s polls. Check other machines/bots using this token, or a registered webhook (deleteWebhook)." : "Telegram: another instance is polling this bot token (HTTP 409) \u2014 backing off to 60s polls until it stops."
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
this.log.debug(`Telegram getUpdates failed: ${data.description}`);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
522
|
+
const updates = await this.api.getUpdates({
|
|
523
|
+
offset: this.offset,
|
|
524
|
+
timeoutSeconds: 10,
|
|
525
|
+
deadlineMs: 15e3,
|
|
526
|
+
signal: this.controller.signal
|
|
527
|
+
});
|
|
293
528
|
this.conflictStreak = 0;
|
|
294
|
-
const updates = data.result ?? [];
|
|
295
529
|
for (const upd of updates) {
|
|
296
530
|
this.offset = upd.update_id + 1;
|
|
297
531
|
if (upd.callback_query) {
|
|
@@ -300,26 +534,48 @@ var TelegramBot = class _TelegramBot {
|
|
|
300
534
|
}
|
|
301
535
|
const raw = upd.message ?? upd.edited_message;
|
|
302
536
|
if (!raw?.text) continue;
|
|
303
|
-
|
|
304
|
-
this.processMessage(msg);
|
|
305
|
-
}
|
|
306
|
-
if (this.offsetStoragePath && this.offset > 0) {
|
|
307
|
-
void this.saveOffset();
|
|
537
|
+
this.processMessage({ ...raw, text: raw.text });
|
|
308
538
|
}
|
|
539
|
+
if (this.offsetStore && updates.length > 0) void this.saveOffset();
|
|
309
540
|
} catch (err) {
|
|
310
|
-
if (err
|
|
541
|
+
if (err instanceof TelegramNetworkError && err.aborted) return;
|
|
542
|
+
if (err instanceof TelegramBotApiError && err.errorCode === 409) {
|
|
543
|
+
this.conflictStreak++;
|
|
544
|
+
if (this.conflictStreak === _TelegramBot.CONFLICT_BACKOFF_AFTER) {
|
|
545
|
+
this.log.warn(
|
|
546
|
+
this.lock ? "Telegram: another consumer outside this machine is polling this bot token (HTTP 409) \u2014 backing off to 60s polls. Check other machines/bots using this token, or a registered webhook (deleteWebhook)." : "Telegram: another instance is polling this bot token (HTTP 409) \u2014 backing off to 60s polls until it stops."
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
this.log.debug(`Telegram getUpdates failed: ${err.description}`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
311
552
|
this.log.debug(`Telegram poll error: ${err.message}`);
|
|
312
553
|
}
|
|
313
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Apply the inbound identity policy to every update type. A non-empty set is
|
|
557
|
+
* a mandatory constraint: missing identity fails closed instead of bypassing
|
|
558
|
+
* the allowlist. An empty set leaves that identity dimension unrestricted.
|
|
559
|
+
*/
|
|
560
|
+
inboundDenialReason(userId, chatId) {
|
|
561
|
+
if (this.allowedChats.size > 0 && (chatId === void 0 || !this.allowedChats.has(chatId))) {
|
|
562
|
+
return "chat";
|
|
563
|
+
}
|
|
564
|
+
if (this.allowedUsers.size > 0 && (userId === void 0 || !this.allowedUsers.has(userId))) {
|
|
565
|
+
return "user";
|
|
566
|
+
}
|
|
567
|
+
return void 0;
|
|
568
|
+
}
|
|
314
569
|
processMessage(msg) {
|
|
315
570
|
const chatId = String(msg.chat.id);
|
|
316
571
|
const userId = msg.from ? String(msg.from.id) : void 0;
|
|
317
|
-
|
|
318
|
-
|
|
572
|
+
const denialReason = this.inboundDenialReason(userId, chatId);
|
|
573
|
+
if (denialReason === "user") {
|
|
574
|
+
this.log.debug(`Ignoring message from user ${userId ?? "unknown"} (not in allowedUsers)`);
|
|
319
575
|
void this.sendMessage(chatId, "\u26D4 You are not authorized to interact with this bot.");
|
|
320
576
|
return;
|
|
321
577
|
}
|
|
322
|
-
if (
|
|
578
|
+
if (denialReason === "chat") {
|
|
323
579
|
this.log.debug(`Ignoring message from chat ${chatId} (not in allowedChats)`);
|
|
324
580
|
return;
|
|
325
581
|
}
|
|
@@ -337,59 +593,74 @@ var TelegramBot = class _TelegramBot {
|
|
|
337
593
|
this.onMessage(incoming);
|
|
338
594
|
}
|
|
339
595
|
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
* client stops spinning. Telegram requires the answer within 10 s.
|
|
343
|
-
*/
|
|
344
|
-
/**
|
|
345
|
-
* Resolve any pending waiter for `key` with a `{ approved: false, fromUser }`
|
|
346
|
-
* value, regardless of why the callback was rejected (allowlist, shutdown,
|
|
347
|
-
* etc.). Returns true if a waiter was found and resolved, false otherwise.
|
|
348
|
-
* This helper centralizes the race-safe `delete → resolve` pattern in one
|
|
349
|
-
* place so the deny and shutdown paths don't drift out of sync.
|
|
596
|
+
* Resolve a pending approval request exactly once and record its terminal
|
|
597
|
+
* state before removing it from the live registry.
|
|
350
598
|
*/
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
clearTimeout(
|
|
356
|
-
|
|
357
|
-
|
|
599
|
+
settleApproval(requestId, state, result) {
|
|
600
|
+
const request = this.callbackWaiters.get(requestId);
|
|
601
|
+
if (request?.state !== "pending") return false;
|
|
602
|
+
request.state = state;
|
|
603
|
+
clearTimeout(request.timer);
|
|
604
|
+
if (request.signal && request.abortHandler) {
|
|
605
|
+
request.signal.removeEventListener("abort", request.abortHandler);
|
|
606
|
+
}
|
|
607
|
+
request.pendingCallbacks.length = 0;
|
|
608
|
+
this.callbackWaiters.delete(requestId);
|
|
609
|
+
request.resolve(result);
|
|
358
610
|
return true;
|
|
359
611
|
}
|
|
360
612
|
async dispatchCallback(cq) {
|
|
361
613
|
const key = cq.data ?? "";
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
`Ignoring callback_query from non-allowlisted user ${userId} (data="${key}") \u2014 possible hijack attempt.`
|
|
367
|
-
);
|
|
368
|
-
await this.answerCallback(cq.id, "\u26D4 Not authorized", true);
|
|
369
|
-
this.rejectWaiter(key, "blocked");
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
614
|
+
const action = /^approve:([^:]+):(yes|no)$/.exec(key);
|
|
615
|
+
const requestId = action?.[1];
|
|
616
|
+
const request = requestId ? this.callbackWaiters.get(requestId) : void 0;
|
|
617
|
+
const userId = cq.from?.id !== void 0 ? String(cq.from.id) : void 0;
|
|
373
618
|
const chatId = cq.message?.chat.id !== void 0 ? String(cq.message.chat.id) : void 0;
|
|
374
|
-
|
|
619
|
+
const denialReason = this.inboundDenialReason(userId, chatId);
|
|
620
|
+
if (denialReason) {
|
|
621
|
+
const identity = denialReason === "user" ? userId ?? "unknown" : chatId ?? "unknown";
|
|
375
622
|
this.log.warn(
|
|
376
|
-
`Ignoring callback_query from non-allowlisted
|
|
623
|
+
`Ignoring callback_query from non-allowlisted ${denialReason} ${identity} (data="${key}") \u2014 possible hijack attempt.`
|
|
377
624
|
);
|
|
378
625
|
await this.answerCallback(cq.id, "\u26D4 Not authorized", true);
|
|
379
|
-
this.rejectWaiter(key, "blocked");
|
|
380
626
|
return;
|
|
381
627
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
this.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
628
|
+
if (!request || !requestId || !action) {
|
|
629
|
+
await this.answerCallback(cq.id, "Approval request unavailable", true);
|
|
630
|
+
this.log.debug(`Unmatched callback_query data="${key}" (no pending approval request)`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (Date.now() >= request.expiresAt) {
|
|
634
|
+
await this.answerCallback(cq.id, "Approval request expired", true);
|
|
635
|
+
this.settleApproval(requestId, "expired", { approved: false, fromUser: "timeout" });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (request.promptMessageId === void 0) {
|
|
639
|
+
request.pendingCallbacks.push(cq);
|
|
640
|
+
return;
|
|
392
641
|
}
|
|
642
|
+
const messageId = cq.message?.message_id;
|
|
643
|
+
const chatType = cq.message?.chat.type;
|
|
644
|
+
const wrongIdentity = userId === void 0 || chatId !== request.expectedChatId || !request.expectedUserIds.has(userId) || messageId !== request.promptMessageId || chatType !== "private" && !request.allowGroup;
|
|
645
|
+
if (wrongIdentity) {
|
|
646
|
+
this.log.warn(
|
|
647
|
+
`Ignoring callback_query that does not match approval request ${request.requestId} in session ${request.sessionId}.`
|
|
648
|
+
);
|
|
649
|
+
await this.answerCallback(cq.id, "\u26D4 Not authorized for this approval", true);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const approved = action[2] === "yes";
|
|
653
|
+
const fromUser = cq.from?.username ?? cq.from?.first_name ?? `user:${userId}`;
|
|
654
|
+
const resolved = this.settleApproval(requestId, "resolved", {
|
|
655
|
+
approved,
|
|
656
|
+
fromUser,
|
|
657
|
+
fromUserId: cq.from?.id
|
|
658
|
+
});
|
|
659
|
+
await this.answerCallback(
|
|
660
|
+
cq.id,
|
|
661
|
+
resolved ? approved ? "Approved \u2713" : "Denied \u2717" : "Approval request unavailable",
|
|
662
|
+
!resolved
|
|
663
|
+
);
|
|
393
664
|
}
|
|
394
665
|
/**
|
|
395
666
|
* POST /answerCallbackQuery for a callback. Best-effort: failures are
|
|
@@ -399,14 +670,7 @@ var TelegramBot = class _TelegramBot {
|
|
|
399
670
|
*/
|
|
400
671
|
async answerCallback(callbackQueryId, text, showAlert) {
|
|
401
672
|
try {
|
|
402
|
-
await
|
|
403
|
-
method: "POST",
|
|
404
|
-
headers: { "Content-Type": "application/json" },
|
|
405
|
-
body: JSON.stringify({
|
|
406
|
-
callback_query_id: callbackQueryId,
|
|
407
|
-
text,
|
|
408
|
-
show_alert: showAlert
|
|
409
|
-
}),
|
|
673
|
+
await this.api.answerCallbackQuery(callbackQueryId, text, showAlert, {
|
|
410
674
|
signal: AbortSignal.timeout(5e3)
|
|
411
675
|
});
|
|
412
676
|
} catch (err) {
|
|
@@ -414,51 +678,99 @@ var TelegramBot = class _TelegramBot {
|
|
|
414
678
|
}
|
|
415
679
|
}
|
|
416
680
|
/**
|
|
417
|
-
* Register
|
|
418
|
-
*
|
|
419
|
-
* with `{ approved: false, fromUser: 'timeout' }` after `timeoutMs`.
|
|
420
|
-
*
|
|
421
|
-
* Callers are responsible for not registering the same key twice — a
|
|
422
|
-
* second `awaitCallback` for an in-flight key is undefined.
|
|
681
|
+
* Register one approval request before its prompt is sent. The returned
|
|
682
|
+
* promise owns the request's only timer and resolves on one terminal event.
|
|
423
683
|
*/
|
|
424
|
-
|
|
684
|
+
awaitApproval(input) {
|
|
685
|
+
if (input.expectedUserIds.length === 0) {
|
|
686
|
+
throw new Error("Telegram approval requires at least one expected user ID.");
|
|
687
|
+
}
|
|
688
|
+
if (this.callbackWaiters.has(input.requestId)) {
|
|
689
|
+
throw new Error(`Telegram approval request ${input.requestId} is already pending.`);
|
|
690
|
+
}
|
|
425
691
|
return new Promise((resolve) => {
|
|
692
|
+
const delayMs = Math.max(0, input.expiresAt - Date.now());
|
|
426
693
|
const timer = setTimeout(() => {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
694
|
+
this.settleApproval(input.requestId, "expired", {
|
|
695
|
+
approved: false,
|
|
696
|
+
fromUser: "timeout"
|
|
697
|
+
});
|
|
698
|
+
}, delayMs);
|
|
699
|
+
const request = {
|
|
700
|
+
requestId: input.requestId,
|
|
701
|
+
sessionId: input.sessionId,
|
|
702
|
+
expectedChatId: String(input.expectedChatId),
|
|
703
|
+
expectedUserIds: new Set(input.expectedUserIds.map(String)),
|
|
704
|
+
allowGroup: input.allowGroup,
|
|
705
|
+
pendingCallbacks: [],
|
|
706
|
+
expiresAt: input.expiresAt,
|
|
707
|
+
state: "pending",
|
|
708
|
+
resolve,
|
|
709
|
+
timer,
|
|
710
|
+
signal: input.signal
|
|
711
|
+
};
|
|
712
|
+
if (input.signal) {
|
|
713
|
+
request.abortHandler = () => {
|
|
714
|
+
this.settleApproval(input.requestId, "cancelled", {
|
|
715
|
+
approved: false,
|
|
716
|
+
fromUser: "aborted"
|
|
717
|
+
});
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
this.callbackWaiters.set(input.requestId, request);
|
|
721
|
+
if (input.signal?.aborted) {
|
|
722
|
+
request.abortHandler?.();
|
|
723
|
+
} else if (input.signal && request.abortHandler) {
|
|
724
|
+
input.signal.addEventListener("abort", request.abortHandler, { once: true });
|
|
725
|
+
}
|
|
432
726
|
});
|
|
433
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* Attach the Bot API response's prompt message ID to an existing request.
|
|
730
|
+
* Any callback that arrived during the send is replayed against the fully
|
|
731
|
+
* bound identity without allocating a second waiter or timer.
|
|
732
|
+
*/
|
|
733
|
+
bindApprovalPrompt(requestId, promptMessageId) {
|
|
734
|
+
const request = this.callbackWaiters.get(requestId);
|
|
735
|
+
if (request?.state !== "pending" || request.promptMessageId !== void 0) return false;
|
|
736
|
+
request.promptMessageId = promptMessageId;
|
|
737
|
+
const pending = request.pendingCallbacks.splice(0);
|
|
738
|
+
for (const callback of pending) {
|
|
739
|
+
void this.dispatchCallback(callback);
|
|
740
|
+
}
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
/** Cancel a request that cannot reach a valid terminal callback. */
|
|
744
|
+
cancelApproval(requestId, fromUser = "cancelled") {
|
|
745
|
+
return this.settleApproval(requestId, "cancelled", { approved: false, fromUser });
|
|
746
|
+
}
|
|
434
747
|
async loadOffset() {
|
|
435
|
-
if (!this.
|
|
748
|
+
if (!this.offsetStore) return;
|
|
436
749
|
try {
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (Number.isFinite(n) && n >= 0) {
|
|
441
|
-
this.offset = n;
|
|
750
|
+
const saved = this.offsetStore.read();
|
|
751
|
+
if (saved !== null) {
|
|
752
|
+
this.offset = saved;
|
|
442
753
|
this.log.debug(`Telegram polling offset restored: ${this.offset}`);
|
|
443
754
|
}
|
|
444
755
|
} catch {
|
|
445
756
|
}
|
|
446
757
|
}
|
|
447
758
|
async saveOffset() {
|
|
448
|
-
if (!this.
|
|
759
|
+
if (!this.offsetStore) return;
|
|
449
760
|
try {
|
|
450
|
-
|
|
451
|
-
writeFileSync2(this.offsetStoragePath, String(this.offset), "utf8");
|
|
761
|
+
this.offsetStore.write(this.offset);
|
|
452
762
|
} catch (err) {
|
|
453
763
|
this.log.debug(`Failed to persist Telegram offset: ${err}`);
|
|
454
764
|
}
|
|
455
765
|
}
|
|
456
766
|
};
|
|
767
|
+
var MAX_TELEGRAM_MESSAGE_LENGTH = 4096;
|
|
457
768
|
function truncateForTelegram(text, maxLen = 4e3) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
769
|
+
const effectiveMaxLen = Math.min(maxLen, MAX_TELEGRAM_MESSAGE_LENGTH);
|
|
770
|
+
if (text.length <= effectiveMaxLen) return text;
|
|
771
|
+
const cutoff = effectiveMaxLen - 30;
|
|
772
|
+
if (cutoff <= 0) return `${text.slice(0, effectiveMaxLen - 1)}\u2026`;
|
|
773
|
+
const searchEnd = Math.min(text.length, effectiveMaxLen);
|
|
462
774
|
const paraIdx = text.lastIndexOf("\n\n", searchEnd);
|
|
463
775
|
if (paraIdx > cutoff) {
|
|
464
776
|
return `${text.slice(0, paraIdx)}
|
|
@@ -486,20 +798,25 @@ function truncateForTelegram(text, maxLen = 4e3) {
|
|
|
486
798
|
if (spaceIdx > cutoff) {
|
|
487
799
|
return `${text.slice(0, spaceIdx)} \u2026`;
|
|
488
800
|
}
|
|
489
|
-
return `${text.slice(0,
|
|
801
|
+
return `${text.slice(0, effectiveMaxLen - 20)}\u2026[+${text.length - effectiveMaxLen + 20} chars]`;
|
|
490
802
|
}
|
|
491
803
|
|
|
492
804
|
// src/config.ts
|
|
493
805
|
var PLUGIN_NAME = "telegram";
|
|
806
|
+
var INBOUND_MODES = ["disabled", "paired", "allowlist", "public"];
|
|
494
807
|
var DEFAULT_CONFIG = {
|
|
808
|
+
inboundMode: "disabled",
|
|
495
809
|
allowedUsers: [],
|
|
496
810
|
allowedChats: [],
|
|
811
|
+
allowedOutboundChats: [],
|
|
497
812
|
pollIntervalSec: 2,
|
|
498
813
|
notifyOnSessionEnd: false,
|
|
499
814
|
longToolThresholdMs: 3e4,
|
|
500
815
|
notifyOnDelegate: true,
|
|
501
816
|
maxMessageLength: 4e3,
|
|
502
|
-
singleInstanceLock: true
|
|
817
|
+
singleInstanceLock: true,
|
|
818
|
+
outboundQueuePerChat: 32,
|
|
819
|
+
outboundQueueConcurrency: 4
|
|
503
820
|
};
|
|
504
821
|
var telegramConfigSchema = {
|
|
505
822
|
type: "object",
|
|
@@ -509,15 +826,26 @@ var telegramConfigSchema = {
|
|
|
509
826
|
oneOf: [{ type: "string" }, { type: "integer" }],
|
|
510
827
|
description: "Default chat ID for outgoing notifications"
|
|
511
828
|
},
|
|
829
|
+
inboundMode: {
|
|
830
|
+
type: "string",
|
|
831
|
+
enum: [...INBOUND_MODES],
|
|
832
|
+
default: "disabled",
|
|
833
|
+
description: "Inbound access: disabled, paired to notifyChatId, restricted by allowlists, or explicitly public"
|
|
834
|
+
},
|
|
512
835
|
allowedUsers: {
|
|
513
836
|
type: "array",
|
|
514
837
|
items: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
|
515
|
-
description: "User IDs
|
|
838
|
+
description: "User IDs accepted when inboundMode is allowlist"
|
|
516
839
|
},
|
|
517
840
|
allowedChats: {
|
|
518
841
|
type: "array",
|
|
519
842
|
items: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
|
520
|
-
description: "Chat IDs
|
|
843
|
+
description: "Chat IDs accepted when inboundMode is allowlist"
|
|
844
|
+
},
|
|
845
|
+
allowedOutboundChats: {
|
|
846
|
+
type: "array",
|
|
847
|
+
items: { oneOf: [{ type: "string" }, { type: "integer" }] },
|
|
848
|
+
description: "Additional trusted targets for outbound Telegram sends"
|
|
521
849
|
},
|
|
522
850
|
pollIntervalSec: {
|
|
523
851
|
type: "integer",
|
|
@@ -532,6 +860,18 @@ var telegramConfigSchema = {
|
|
|
532
860
|
singleInstanceLock: {
|
|
533
861
|
type: "boolean",
|
|
534
862
|
description: "Elect a single getUpdates poller per bot token across wstack instances (default true)"
|
|
863
|
+
},
|
|
864
|
+
outboundQueuePerChat: {
|
|
865
|
+
type: "integer",
|
|
866
|
+
minimum: 1,
|
|
867
|
+
maximum: 1e3,
|
|
868
|
+
description: "Per-chat pending outbound-message cap (default 32)"
|
|
869
|
+
},
|
|
870
|
+
outboundQueueConcurrency: {
|
|
871
|
+
type: "integer",
|
|
872
|
+
minimum: 1,
|
|
873
|
+
maximum: 64,
|
|
874
|
+
description: "Maximum concurrent outbound sends across all chats (default 4)"
|
|
535
875
|
}
|
|
536
876
|
},
|
|
537
877
|
required: ["botToken"]
|
|
@@ -543,15 +883,50 @@ function readTelegramConfig(api) {
|
|
|
543
883
|
const legacyPlugins = pluginEntries;
|
|
544
884
|
const legacyOpts = legacyPlugins && !Array.isArray(legacyPlugins) ? legacyPlugins[PLUGIN_NAME] : void 0;
|
|
545
885
|
const entryOpts = pluginOptionsFromEntries(pluginEntries);
|
|
886
|
+
const extensionOpts = extensions?.[PLUGIN_NAME];
|
|
546
887
|
const opts = {
|
|
547
888
|
...legacyOpts ?? entryOpts,
|
|
548
|
-
...
|
|
889
|
+
...extensionOpts ?? {}
|
|
549
890
|
};
|
|
891
|
+
const inboundMode = resolveInboundMode(opts, {
|
|
892
|
+
configured: legacyOpts !== void 0 || entryOpts !== void 0 || extensionOpts !== void 0,
|
|
893
|
+
warn: api.log?.warn.bind(api.log)
|
|
894
|
+
});
|
|
550
895
|
return {
|
|
551
896
|
...DEFAULT_CONFIG,
|
|
552
|
-
...opts
|
|
897
|
+
...opts,
|
|
898
|
+
inboundMode
|
|
553
899
|
};
|
|
554
900
|
}
|
|
901
|
+
function resolveInboundMode(opts, migration) {
|
|
902
|
+
if (opts.inboundMode !== void 0) {
|
|
903
|
+
if (!INBOUND_MODES.includes(opts.inboundMode)) {
|
|
904
|
+
throw new Error(
|
|
905
|
+
`Invalid telegram inboundMode "${String(opts.inboundMode)}". Expected one of: ${INBOUND_MODES.join(", ")}.`
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
if (opts.inboundMode === "allowlist" && !hasEntries(opts.allowedUsers) && !hasEntries(opts.allowedChats)) {
|
|
909
|
+
throw new Error(
|
|
910
|
+
'Telegram inboundMode "allowlist" requires at least one allowedUsers or allowedChats entry.'
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
if (opts.inboundMode === "paired" && opts.notifyChatId === void 0) {
|
|
914
|
+
throw new Error('Telegram inboundMode "paired" requires notifyChatId.');
|
|
915
|
+
}
|
|
916
|
+
return opts.inboundMode;
|
|
917
|
+
}
|
|
918
|
+
if (hasEntries(opts.allowedUsers) || hasEntries(opts.allowedChats)) return "allowlist";
|
|
919
|
+
const inferredMode = opts.notifyChatId === void 0 ? "disabled" : "paired";
|
|
920
|
+
if (migration.configured) {
|
|
921
|
+
migration.warn?.(
|
|
922
|
+
`Telegram inbound access no longer defaults to public when allowedUsers and allowedChats are empty; inferred inboundMode "${inferredMode}". Set inboundMode "public" explicitly to preserve legacy allow-all behavior.`
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
return inferredMode;
|
|
926
|
+
}
|
|
927
|
+
function hasEntries(values) {
|
|
928
|
+
return Array.isArray(values) && values.length > 0;
|
|
929
|
+
}
|
|
555
930
|
function pluginOptionsFromEntries(entries) {
|
|
556
931
|
if (!Array.isArray(entries)) return void 0;
|
|
557
932
|
const found = entries.find(
|
|
@@ -788,6 +1163,130 @@ var PollLock = class {
|
|
|
788
1163
|
}
|
|
789
1164
|
};
|
|
790
1165
|
|
|
1166
|
+
// src/offset-store.ts
|
|
1167
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1168
|
+
import {
|
|
1169
|
+
closeSync,
|
|
1170
|
+
fsyncSync,
|
|
1171
|
+
mkdirSync as mkdirSync2,
|
|
1172
|
+
openSync,
|
|
1173
|
+
readFileSync as readFileSync2,
|
|
1174
|
+
renameSync as renameSync2,
|
|
1175
|
+
unlinkSync as unlinkSync2,
|
|
1176
|
+
writeSync
|
|
1177
|
+
} from "node:fs";
|
|
1178
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
1179
|
+
import { wstackGlobalRoot as wstackGlobalRoot2 } from "@wrongstack/core/utils";
|
|
1180
|
+
function offsetPathForToken(token, globalRoot = wstackGlobalRoot2()) {
|
|
1181
|
+
const hash = createHash2("sha256").update(token).digest("hex").slice(0, 12);
|
|
1182
|
+
return join2(globalRoot, "telegram", `offset-${hash}.json`);
|
|
1183
|
+
}
|
|
1184
|
+
var OffsetStore = class {
|
|
1185
|
+
path;
|
|
1186
|
+
constructor(opts = {}) {
|
|
1187
|
+
if (opts.path !== void 0) {
|
|
1188
|
+
this.path = opts.path;
|
|
1189
|
+
} else if (opts.token) {
|
|
1190
|
+
this.path = offsetPathForToken(opts.token, opts.globalRoot);
|
|
1191
|
+
} else {
|
|
1192
|
+
this.path = "";
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
/** The derived path for diagnostics. */
|
|
1196
|
+
get storePath() {
|
|
1197
|
+
return this.path;
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Read the persisted offset. Returns null when the file is missing, empty,
|
|
1201
|
+
* or contains a value that is not a valid non-negative integer.
|
|
1202
|
+
*/
|
|
1203
|
+
read() {
|
|
1204
|
+
if (!this.path) return null;
|
|
1205
|
+
let raw;
|
|
1206
|
+
try {
|
|
1207
|
+
raw = readFileSync2(this.path, "utf8").trim();
|
|
1208
|
+
} catch {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
if (raw.length === 0) return null;
|
|
1212
|
+
try {
|
|
1213
|
+
const parsed = JSON.parse(raw);
|
|
1214
|
+
if (typeof parsed !== "number" || !Number.isFinite(parsed) || parsed < 0 || parsed % 1 !== 0) {
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
return parsed;
|
|
1218
|
+
} catch {
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Persist an offset value using an atomic write (temp file + rename).
|
|
1224
|
+
* Creates the parent directory on first call.
|
|
1225
|
+
*/
|
|
1226
|
+
write(offset) {
|
|
1227
|
+
if (!this.path || offset < 0) return;
|
|
1228
|
+
mkdirSync2(dirname2(this.path), { recursive: true });
|
|
1229
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
1230
|
+
const fd = openSync(tmp, "w");
|
|
1231
|
+
try {
|
|
1232
|
+
writeSync(fd, JSON.stringify(offset));
|
|
1233
|
+
fsyncSync(fd);
|
|
1234
|
+
} finally {
|
|
1235
|
+
closeSync(fd);
|
|
1236
|
+
}
|
|
1237
|
+
try {
|
|
1238
|
+
renameSync2(tmp, this.path);
|
|
1239
|
+
} catch {
|
|
1240
|
+
try {
|
|
1241
|
+
unlinkSync2(tmp);
|
|
1242
|
+
} catch {
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
// src/security/outbound.ts
|
|
1249
|
+
import { DefaultSecretScrubber, ToolValidationError } from "@wrongstack/core";
|
|
1250
|
+
var TELEGRAM_APPROVAL_CAPABILITY = "net.outbound.telegram.approval";
|
|
1251
|
+
var secretScrubber = new DefaultSecretScrubber();
|
|
1252
|
+
var RAW_TELEGRAM_BOT_TOKEN = /(?<![A-Za-z0-9])\d{5,15}:[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/g;
|
|
1253
|
+
function normalizeChatId(value) {
|
|
1254
|
+
return String(value).trim();
|
|
1255
|
+
}
|
|
1256
|
+
function resolveTelegramOutboundTarget(requestedChatId, policy) {
|
|
1257
|
+
const defaultChatId = policy.getDefaultChatId();
|
|
1258
|
+
const target = requestedChatId ?? defaultChatId;
|
|
1259
|
+
if (target === void 0 || normalizeChatId(target) === "") {
|
|
1260
|
+
throw new ToolValidationError({
|
|
1261
|
+
field: "chat_id",
|
|
1262
|
+
message: "No chat_id provided and no allowed Telegram target is configured. Pair notifyChatId or configure allowedOutboundChats."
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
1266
|
+
if (defaultChatId !== void 0 && normalizeChatId(defaultChatId) !== "") {
|
|
1267
|
+
allowed.add(normalizeChatId(defaultChatId));
|
|
1268
|
+
}
|
|
1269
|
+
for (const chatId of policy.getAllowedOutboundChatIds?.() ?? []) {
|
|
1270
|
+
const normalized = normalizeChatId(chatId);
|
|
1271
|
+
if (normalized !== "") allowed.add(normalized);
|
|
1272
|
+
}
|
|
1273
|
+
if (!allowed.has(normalizeChatId(target))) {
|
|
1274
|
+
throw new ToolValidationError({
|
|
1275
|
+
field: "chat_id",
|
|
1276
|
+
message: "Telegram outbound target is not paired or included in allowedOutboundChats."
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
return typeof target === "string" ? target.trim() : target;
|
|
1280
|
+
}
|
|
1281
|
+
function scrubTelegramOutboundText(text) {
|
|
1282
|
+
const shared = secretScrubber.scrub(text);
|
|
1283
|
+
const withoutBareBotTokens = shared.replace(
|
|
1284
|
+
RAW_TELEGRAM_BOT_TOKEN,
|
|
1285
|
+
"[REDACTED:telegram_bot_token]"
|
|
1286
|
+
);
|
|
1287
|
+
return redactSecrets(withoutBareBotTokens);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
791
1290
|
// src/slash-commands/index.ts
|
|
792
1291
|
import { expectDefined } from "@wrongstack/core";
|
|
793
1292
|
function tgHealthCommand(bot, cfg) {
|
|
@@ -816,7 +1315,8 @@ allowlist health, and notification settings.`,
|
|
|
816
1315
|
}
|
|
817
1316
|
};
|
|
818
1317
|
}
|
|
819
|
-
function tgSendCommand(bot,
|
|
1318
|
+
function tgSendCommand(bot, policyOrDefault, outbound) {
|
|
1319
|
+
const policy = typeof policyOrDefault === "object" && policyOrDefault !== null ? policyOrDefault : { getDefaultChatId: () => policyOrDefault };
|
|
820
1320
|
return {
|
|
821
1321
|
name: "send",
|
|
822
1322
|
description: "Send a message to a Telegram chat",
|
|
@@ -833,23 +1333,27 @@ Examples:
|
|
|
833
1333
|
if (!args.trim()) {
|
|
834
1334
|
return { message: "Usage: /telegram:send [chat_id] <message>" };
|
|
835
1335
|
}
|
|
836
|
-
let
|
|
1336
|
+
let requestedChatId;
|
|
837
1337
|
let text;
|
|
838
1338
|
const parts = args.trim().split(/\s+/);
|
|
839
1339
|
const maybeId = parts[0];
|
|
840
|
-
if (
|
|
841
|
-
|
|
1340
|
+
if (/^-?\d+$/.test(expectDefined(maybeId)) && parts.length > 1) {
|
|
1341
|
+
requestedChatId = expectDefined(maybeId);
|
|
842
1342
|
text = parts.slice(1).join(" ");
|
|
843
|
-
} else if (defaultChatId) {
|
|
844
|
-
chatId = defaultChatId;
|
|
845
|
-
text = args.trim();
|
|
846
1343
|
} else {
|
|
847
|
-
|
|
848
|
-
message: "No chat_id provided and no default notifyChatId configured.\nUsage: /telegram:send <chat_id> <message>"
|
|
849
|
-
};
|
|
1344
|
+
text = args.trim();
|
|
850
1345
|
}
|
|
851
1346
|
try {
|
|
852
|
-
const
|
|
1347
|
+
const chatId = resolveTelegramOutboundTarget(requestedChatId, policy);
|
|
1348
|
+
const scrubbed = scrubTelegramOutboundText(text);
|
|
1349
|
+
const truncated = truncateForTelegram(scrubbed, policy.getMaxMessageLength?.() ?? 4e3);
|
|
1350
|
+
if (outbound) {
|
|
1351
|
+
const res2 = await outbound.sendManual(chatId, truncated);
|
|
1352
|
+
return {
|
|
1353
|
+
message: `\u2705 Message sent to ${chatId} (msg_id=${res2.result?.message_id ?? "?"})`
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
const res = await bot.sendMessage(chatId, truncated);
|
|
853
1357
|
return {
|
|
854
1358
|
message: `\u2705 Message sent to ${chatId} (msg_id=${res.result?.message_id ?? "?"})`
|
|
855
1359
|
};
|
|
@@ -872,20 +1376,356 @@ and the \`telegram_send\` tool when no chat_id is specified.`,
|
|
|
872
1376
|
if (chatIdStr) {
|
|
873
1377
|
return { message: `Configured notifyChatId: ${chatIdStr}` };
|
|
874
1378
|
}
|
|
875
|
-
return {
|
|
1379
|
+
return {
|
|
1380
|
+
message: "No notifyChatId configured. Set it in the plugin config or pass chat_id explicitly to telegram_send."
|
|
1381
|
+
};
|
|
876
1382
|
}
|
|
877
1383
|
};
|
|
878
1384
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1385
|
+
|
|
1386
|
+
// src/tools/telegram-approve.ts
|
|
1387
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1388
|
+
function makeTelegramApproveTool(opts) {
|
|
1389
|
+
return {
|
|
1390
|
+
name: "telegram_approve",
|
|
1391
|
+
description: "Post a scrubbed yes/no prompt only to the paired Telegram chat or an explicitly allowed outbound chat, then wait for a button press. Returns approval state plus immutable user_id and display_name; false means timeout, rejection, or explicit deny. This narrow capability requests remote approval but does not itself authorize or perform the proposed operation.",
|
|
1392
|
+
usageHint: 'telegram_approve(prompt: "Delete build artifacts?", details: "Frees 2.3 GB. Cannot be undone.", timeout_ms: 60000)',
|
|
1393
|
+
category: "Telegram",
|
|
1394
|
+
inputSchema: {
|
|
1395
|
+
type: "object",
|
|
1396
|
+
properties: {
|
|
1397
|
+
prompt: {
|
|
1398
|
+
type: "string",
|
|
1399
|
+
maxLength: 200,
|
|
1400
|
+
description: "Short label for what is being approved. Shown as the prompt heading."
|
|
1401
|
+
},
|
|
1402
|
+
details: {
|
|
1403
|
+
type: "string",
|
|
1404
|
+
maxLength: 1e3,
|
|
1405
|
+
description: "Optional context under the heading."
|
|
1406
|
+
},
|
|
1407
|
+
chat_id: {
|
|
1408
|
+
oneOf: [{ type: "string" }, { type: "integer" }],
|
|
1409
|
+
description: "Chat to post the prompt to. Uses the plugin default when omitted."
|
|
1410
|
+
},
|
|
1411
|
+
timeout_ms: {
|
|
1412
|
+
type: "integer",
|
|
1413
|
+
minimum: 1e3,
|
|
1414
|
+
maximum: 6e5,
|
|
1415
|
+
description: "How long to wait before auto-denying. Default 60 000 ms, max 600 000 ms (10 min)."
|
|
1416
|
+
}
|
|
1417
|
+
},
|
|
1418
|
+
required: ["prompt"]
|
|
1419
|
+
},
|
|
1420
|
+
permission: "auto",
|
|
1421
|
+
mutating: true,
|
|
1422
|
+
riskTier: "standard",
|
|
1423
|
+
capabilities: [TELEGRAM_APPROVAL_CAPABILITY],
|
|
1424
|
+
timeoutMs: 61e4,
|
|
1425
|
+
async execute(input, ctx, toolOpts) {
|
|
1426
|
+
const chatId = resolveTelegramOutboundTarget(input.chat_id, opts);
|
|
1427
|
+
const timeoutMs = Math.min(Math.max(input.timeout_ms ?? 6e4, 1e3), 6e5);
|
|
1428
|
+
const configuredUserIds = opts.getAllowedUserIds?.().map(String) ?? [];
|
|
1429
|
+
const isGroup = String(chatId).startsWith("-");
|
|
1430
|
+
if (isGroup && (opts.allowGroupApprovals !== true || configuredUserIds.length === 0)) {
|
|
1431
|
+
throw new Error("Telegram group approvals require explicit per-user configuration.");
|
|
1432
|
+
}
|
|
1433
|
+
const expectedUserIds = configuredUserIds.length > 0 ? configuredUserIds : [String(chatId)];
|
|
1434
|
+
const requestId = randomUUID2().slice(0, 16);
|
|
1435
|
+
const yesKey = `approve:${requestId}:yes`;
|
|
1436
|
+
const noKey = `approve:${requestId}:no`;
|
|
1437
|
+
const prompt = scrubTelegramOutboundText(input.prompt);
|
|
1438
|
+
const details = input.details ? truncateForTelegram(scrubTelegramOutboundText(input.details), 800) : void 0;
|
|
1439
|
+
const heading = `\u26A0\uFE0F ${prompt}`;
|
|
1440
|
+
const detailsLine = details ? `
|
|
1441
|
+
|
|
1442
|
+
${details}` : "";
|
|
1443
|
+
const text = `${heading}${detailsLine}
|
|
1444
|
+
|
|
1445
|
+
_Reply by tapping a button. Auto-denies in ${Math.round(timeoutMs / 1e3)}s._`;
|
|
1446
|
+
opts.log.info(`telegram_approve \u2192 chat_id=${chatId} (${prompt.length} prompt chars)`);
|
|
1447
|
+
const approval = opts.bot.awaitApproval({
|
|
1448
|
+
requestId,
|
|
1449
|
+
sessionId: ctx?.session.id ?? "unknown-session",
|
|
1450
|
+
expectedChatId: chatId,
|
|
1451
|
+
expectedUserIds,
|
|
1452
|
+
allowGroup: isGroup && opts.allowGroupApprovals === true,
|
|
1453
|
+
expiresAt: Date.now() + timeoutMs,
|
|
1454
|
+
signal: toolOpts?.signal
|
|
1455
|
+
});
|
|
1456
|
+
let promptMessageId;
|
|
1457
|
+
try {
|
|
1458
|
+
const sent = await opts.bot.sendMessageWithKeyboard(chatId, text, [
|
|
1459
|
+
{ text: "\u2705 Approve", callback_data: yesKey },
|
|
1460
|
+
{ text: "\u274C Deny", callback_data: noKey }
|
|
1461
|
+
], toolOpts?.signal);
|
|
1462
|
+
promptMessageId = sent.result?.message_id;
|
|
1463
|
+
if (promptMessageId === void 0) {
|
|
1464
|
+
throw new Error("Telegram approval prompt response did not include a message ID.");
|
|
1465
|
+
}
|
|
1466
|
+
if (!opts.bot.bindApprovalPrompt(requestId, promptMessageId)) {
|
|
1467
|
+
throw new Error("Telegram approval request ended before its prompt could be bound.");
|
|
1468
|
+
}
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
opts.bot.cancelApproval(requestId, "send-failed");
|
|
1471
|
+
await approval;
|
|
1472
|
+
opts.log.debug(`telegram_approve send failed: ${err.message}`);
|
|
1473
|
+
throw err;
|
|
1474
|
+
}
|
|
1475
|
+
const result = await approval;
|
|
1476
|
+
return {
|
|
1477
|
+
approved: result.approved,
|
|
1478
|
+
user_id: result.fromUserId,
|
|
1479
|
+
display_name: result.fromUser,
|
|
1480
|
+
from: result.fromUser,
|
|
1481
|
+
prompt_message_id: promptMessageId
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
887
1485
|
}
|
|
888
1486
|
|
|
1487
|
+
// src/outbound-queue.ts
|
|
1488
|
+
var DEFAULT_MAX_PER_CHAT = 32;
|
|
1489
|
+
var DEFAULT_MAX_CONCURRENCY = 4;
|
|
1490
|
+
var OutboundQueue = class {
|
|
1491
|
+
#opts;
|
|
1492
|
+
#lanes = /* @__PURE__ */ new Map();
|
|
1493
|
+
#active = 0;
|
|
1494
|
+
#notificationScheduleQueued = false;
|
|
1495
|
+
#stopped = false;
|
|
1496
|
+
#nextId = 0;
|
|
1497
|
+
#enqueued = 0;
|
|
1498
|
+
#sent = 0;
|
|
1499
|
+
#dropped = 0;
|
|
1500
|
+
#failed = 0;
|
|
1501
|
+
#resolvers = /* @__PURE__ */ new Map();
|
|
1502
|
+
constructor(opts) {
|
|
1503
|
+
const maxPerChat = opts.maxPerChat ?? DEFAULT_MAX_PER_CHAT;
|
|
1504
|
+
const maxConcurrency = opts.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
|
1505
|
+
this.#opts = {
|
|
1506
|
+
maxPerChat,
|
|
1507
|
+
maxConcurrency,
|
|
1508
|
+
send: opts.send,
|
|
1509
|
+
log: opts.log
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Enqueue an outbound send. For manual entries the returned promise
|
|
1514
|
+
* resolves with the send result (or rejects with the send error / the
|
|
1515
|
+
* overflow error). For notification entries the returned promise resolves
|
|
1516
|
+
* as soon as the queue accepts the entry, so callers don't block;
|
|
1517
|
+
* downstream drain failures are logged and counted but do not propagate.
|
|
1518
|
+
*/
|
|
1519
|
+
enqueue(entry) {
|
|
1520
|
+
if (this.#stopped) {
|
|
1521
|
+
return Promise.reject(new Error("Outbound queue is stopped"));
|
|
1522
|
+
}
|
|
1523
|
+
const internal = { ...entry, id: this.#mintId() };
|
|
1524
|
+
const key = String(entry.chatId);
|
|
1525
|
+
let lane = this.#lanes.get(key);
|
|
1526
|
+
if (!lane) {
|
|
1527
|
+
lane = { pending: [], running: false };
|
|
1528
|
+
this.#lanes.set(key, lane);
|
|
1529
|
+
}
|
|
1530
|
+
if (entry.kind === "notification") {
|
|
1531
|
+
if (lane.pending.length >= this.#opts.maxPerChat) {
|
|
1532
|
+
const dropped = lane.pending.shift();
|
|
1533
|
+
if (dropped) {
|
|
1534
|
+
this.#dropped += 1;
|
|
1535
|
+
const droppedResolver = this.#resolvers.get(dropped.id);
|
|
1536
|
+
if (droppedResolver) {
|
|
1537
|
+
this.#resolvers.delete(dropped.id);
|
|
1538
|
+
droppedResolver.resolve(void 0);
|
|
1539
|
+
}
|
|
1540
|
+
this.#opts.log?.debug(
|
|
1541
|
+
`Telegram outbound queue dropped a notification for chat ${dropped.chatId} (per-chat limit ${this.#opts.maxPerChat})`
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
} else if (lane.pending.length + (lane.running ? 1 : 0) >= this.#opts.maxPerChat) {
|
|
1546
|
+
return Promise.reject(
|
|
1547
|
+
new Error(
|
|
1548
|
+
`Telegram outbound queue per-chat limit reached for chat ${entry.chatId} (max ${this.#opts.maxPerChat})`
|
|
1549
|
+
)
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
lane.pending.push(internal);
|
|
1553
|
+
this.#enqueued += 1;
|
|
1554
|
+
if (entry.kind === "notification") {
|
|
1555
|
+
this.#scheduleNotifications();
|
|
1556
|
+
return Promise.resolve(void 0);
|
|
1557
|
+
}
|
|
1558
|
+
return new Promise((resolve, reject) => {
|
|
1559
|
+
this.#resolvers.set(internal.id, { resolve, reject });
|
|
1560
|
+
this.#schedule();
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
/** Stats snapshot for `/telegram-health` and the P3.1 metrics surface. */
|
|
1564
|
+
stats() {
|
|
1565
|
+
let pending = this.#active;
|
|
1566
|
+
for (const lane of this.#lanes.values()) pending += lane.pending.length;
|
|
1567
|
+
return {
|
|
1568
|
+
enqueued: this.#enqueued,
|
|
1569
|
+
sent: this.#sent,
|
|
1570
|
+
dropped: this.#dropped,
|
|
1571
|
+
failed: this.#failed,
|
|
1572
|
+
inflight: this.#active,
|
|
1573
|
+
pending
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Stop accepting new entries. Returns a promise that resolves once all
|
|
1578
|
+
* currently in-flight sends have settled and every per-chat lane is
|
|
1579
|
+
* empty. Pending entries are rejected so their callers don't hang.
|
|
1580
|
+
*/
|
|
1581
|
+
async stop() {
|
|
1582
|
+
this.#stopped = true;
|
|
1583
|
+
for (const lane of this.#lanes.values()) {
|
|
1584
|
+
for (const entry of lane.pending.splice(0)) {
|
|
1585
|
+
this.#dropped += 1;
|
|
1586
|
+
const resolver = this.#resolvers.get(entry.id);
|
|
1587
|
+
if (resolver) {
|
|
1588
|
+
this.#resolvers.delete(entry.id);
|
|
1589
|
+
resolver.reject(new Error("Outbound queue stopped before send"));
|
|
1590
|
+
}
|
|
1591
|
+
this.#opts.log?.debug(
|
|
1592
|
+
`Telegram outbound queue stopped, dropped pending ${entry.kind} for chat ${entry.chatId}`
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
while (this.#active > 0) {
|
|
1597
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
#mintId() {
|
|
1601
|
+
this.#nextId += 1;
|
|
1602
|
+
return this.#nextId;
|
|
1603
|
+
}
|
|
1604
|
+
#scheduleNotifications() {
|
|
1605
|
+
if (this.#notificationScheduleQueued) return;
|
|
1606
|
+
this.#notificationScheduleQueued = true;
|
|
1607
|
+
queueMicrotask(() => {
|
|
1608
|
+
queueMicrotask(() => {
|
|
1609
|
+
this.#notificationScheduleQueued = false;
|
|
1610
|
+
this.#schedule();
|
|
1611
|
+
});
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
#schedule() {
|
|
1615
|
+
if (this.#stopped) return;
|
|
1616
|
+
while (this.#active < this.#opts.maxConcurrency) {
|
|
1617
|
+
const entry = this.#nextReady();
|
|
1618
|
+
if (!entry) return;
|
|
1619
|
+
this.#active += 1;
|
|
1620
|
+
void this.#run(entry);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
#nextReady() {
|
|
1624
|
+
for (const lane of this.#lanes.values()) {
|
|
1625
|
+
if (!lane.running && lane.pending.length > 0) {
|
|
1626
|
+
lane.running = true;
|
|
1627
|
+
return lane.pending.shift();
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return void 0;
|
|
1631
|
+
}
|
|
1632
|
+
async #run(entry) {
|
|
1633
|
+
const key = String(entry.chatId);
|
|
1634
|
+
const lane = this.#lanes.get(key);
|
|
1635
|
+
if (!lane) {
|
|
1636
|
+
const resolver = this.#resolvers.get(entry.id);
|
|
1637
|
+
if (resolver) {
|
|
1638
|
+
this.#resolvers.delete(entry.id);
|
|
1639
|
+
resolver.resolve(void 0);
|
|
1640
|
+
}
|
|
1641
|
+
this.#active -= 1;
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
try {
|
|
1645
|
+
const result = await this.#opts.send(entry.chatId, entry.text);
|
|
1646
|
+
this.#sent += 1;
|
|
1647
|
+
const resolver = this.#resolvers.get(entry.id);
|
|
1648
|
+
if (resolver) {
|
|
1649
|
+
this.#resolvers.delete(entry.id);
|
|
1650
|
+
resolver.resolve(result);
|
|
1651
|
+
}
|
|
1652
|
+
} catch (err) {
|
|
1653
|
+
this.#failed += 1;
|
|
1654
|
+
const resolver = this.#resolvers.get(entry.id);
|
|
1655
|
+
if (resolver) {
|
|
1656
|
+
this.#resolvers.delete(entry.id);
|
|
1657
|
+
resolver.reject(err);
|
|
1658
|
+
} else if (entry.kind === "notification") {
|
|
1659
|
+
this.#opts.log?.debug(
|
|
1660
|
+
`Telegram outbound queue notification failed for chat ${entry.chatId}: ${err.message}`
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
} finally {
|
|
1664
|
+
this.#active -= 1;
|
|
1665
|
+
lane.running = false;
|
|
1666
|
+
this.#schedule();
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1671
|
+
// src/bot-queue.ts
|
|
1672
|
+
var TelegramBotOutbound = class {
|
|
1673
|
+
#queue;
|
|
1674
|
+
#bot;
|
|
1675
|
+
#log;
|
|
1676
|
+
#stopped = false;
|
|
1677
|
+
constructor(opts) {
|
|
1678
|
+
this.#bot = opts.bot;
|
|
1679
|
+
this.#log = opts.log;
|
|
1680
|
+
this.#queue = new OutboundQueue({
|
|
1681
|
+
maxPerChat: opts.maxPerChat,
|
|
1682
|
+
maxConcurrency: opts.maxConcurrency,
|
|
1683
|
+
send: (chatId, text) => this.#bot.sendMessage(chatId, text).then((res) => {
|
|
1684
|
+
if (!res.ok) {
|
|
1685
|
+
throw new Error(`Telegram outbound send returned ok=false for chat ${chatId}`);
|
|
1686
|
+
}
|
|
1687
|
+
return res;
|
|
1688
|
+
}),
|
|
1689
|
+
log: opts.log
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
/** Manual send (telegram_send tool, /telegram:send): never silently dropped. */
|
|
1693
|
+
async sendManual(chatId, text) {
|
|
1694
|
+
if (this.#stopped) {
|
|
1695
|
+
throw new Error("Telegram outbound queue is stopped");
|
|
1696
|
+
}
|
|
1697
|
+
return await this.#queue.enqueue({
|
|
1698
|
+
chatId,
|
|
1699
|
+
text,
|
|
1700
|
+
kind: "manual"
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Notification send (session ended, long tool, delegate): fire-and-forget.
|
|
1705
|
+
* The returned promise resolves as soon as the queue accepts the entry;
|
|
1706
|
+
* downstream send failures are logged and counted but not surfaced.
|
|
1707
|
+
*/
|
|
1708
|
+
enqueueNotification(chatId, text) {
|
|
1709
|
+
if (this.#stopped) {
|
|
1710
|
+
this.#log.debug(`Telegram outbound queue ignored notification for chat ${chatId}: stopped`);
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
const entry = { chatId, text, kind: "notification" };
|
|
1714
|
+
this.#queue.enqueue(entry).catch((err) => {
|
|
1715
|
+
this.#log.debug(
|
|
1716
|
+
`Telegram outbound notification enqueue rejected for chat ${chatId}: ${err.message}`
|
|
1717
|
+
);
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
stats() {
|
|
1721
|
+
return this.#queue.stats();
|
|
1722
|
+
}
|
|
1723
|
+
async stop() {
|
|
1724
|
+
this.#stopped = true;
|
|
1725
|
+
await this.#queue.stop();
|
|
1726
|
+
}
|
|
1727
|
+
};
|
|
1728
|
+
|
|
889
1729
|
// src/tools/telegram-read.ts
|
|
890
1730
|
function makeTelegramReadTool(opts) {
|
|
891
1731
|
return {
|
|
@@ -942,10 +1782,11 @@ function makeTelegramReadTool(opts) {
|
|
|
942
1782
|
}
|
|
943
1783
|
|
|
944
1784
|
// src/tools/telegram-send.ts
|
|
1785
|
+
import { ToolCapabilities } from "@wrongstack/core";
|
|
945
1786
|
function makeTelegramSendTool(opts) {
|
|
946
1787
|
return {
|
|
947
1788
|
name: "telegram_send",
|
|
948
|
-
description: "Send a message to
|
|
1789
|
+
description: "Send a scrubbed message to the paired Telegram chat or an explicitly allowed outbound chat. Write natural prose for a human reader; summarize results and never paste raw JSON, object dumps, credentials, or truncated tool output.",
|
|
949
1790
|
usageHint: 'telegram_send(chat_id: "123456789", message: "Build completed \u2014 12 tests passed, 0 failed. Deploying to staging now.")',
|
|
950
1791
|
category: "Telegram",
|
|
951
1792
|
inputSchema: {
|
|
@@ -964,17 +1805,14 @@ function makeTelegramSendTool(opts) {
|
|
|
964
1805
|
},
|
|
965
1806
|
permission: "confirm",
|
|
966
1807
|
mutating: true,
|
|
1808
|
+
capabilities: [ToolCapabilities.NET_OUTBOUND],
|
|
967
1809
|
timeoutMs: 15e3,
|
|
968
|
-
async execute(input, _ctx,
|
|
969
|
-
const chatId = input.chat_id
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
"No chat_id provided and no default notifyChatId configured. Set notifyChatId in plugin config or pass chat_id."
|
|
973
|
-
);
|
|
974
|
-
}
|
|
975
|
-
const truncated = truncateForTelegram(input.message, opts.maxMessageLength);
|
|
1810
|
+
async execute(input, _ctx, toolOpts) {
|
|
1811
|
+
const chatId = resolveTelegramOutboundTarget(input.chat_id, opts);
|
|
1812
|
+
const scrubbed = scrubTelegramOutboundText(input.message);
|
|
1813
|
+
const truncated = truncateForTelegram(scrubbed, opts.maxMessageLength);
|
|
976
1814
|
opts.log.info(`telegram_send \u2192 chat_id=${chatId} (${truncated.length} chars)`);
|
|
977
|
-
const res = await opts.bot.sendMessage(chatId, truncated);
|
|
1815
|
+
const res = toolOpts?.signal ? await opts.bot.sendMessage(chatId, truncated, toolOpts.signal) : await opts.bot.sendMessage(chatId, truncated);
|
|
978
1816
|
return {
|
|
979
1817
|
ok: res.ok,
|
|
980
1818
|
message_id: res.result?.message_id,
|
|
@@ -988,91 +1826,63 @@ function makeTelegramSendTool(opts) {
|
|
|
988
1826
|
};
|
|
989
1827
|
}
|
|
990
1828
|
|
|
991
|
-
// src/
|
|
992
|
-
|
|
993
|
-
|
|
1829
|
+
// src/index.ts
|
|
1830
|
+
var teardownState = null;
|
|
1831
|
+
var DENY_ALL_INBOUND = "__wrongstack_telegram_inbound_disabled__";
|
|
1832
|
+
function inboundAllowlist(cfg) {
|
|
1833
|
+
if (cfg.inboundMode === "public") {
|
|
1834
|
+
return { allowedUsers: /* @__PURE__ */ new Set(), allowedChats: /* @__PURE__ */ new Set() };
|
|
1835
|
+
}
|
|
1836
|
+
if (cfg.inboundMode === "paired") {
|
|
1837
|
+
const pairedUsers = new Set((cfg.allowedUsers ?? []).map(String));
|
|
1838
|
+
return {
|
|
1839
|
+
allowedUsers: pairedUsers.size > 0 ? pairedUsers : /* @__PURE__ */ new Set([String(expectDefined2(cfg.notifyChatId))]),
|
|
1840
|
+
allowedChats: /* @__PURE__ */ new Set([String(expectDefined2(cfg.notifyChatId))])
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
if (cfg.inboundMode === "allowlist") {
|
|
1844
|
+
return {
|
|
1845
|
+
allowedUsers: new Set((cfg.allowedUsers ?? []).map(String)),
|
|
1846
|
+
allowedChats: new Set((cfg.allowedChats ?? []).map(String))
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
994
1849
|
return {
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
usageHint: 'telegram_approve(prompt: "Delete build artifacts?", details: "Frees 2.3 GB. Cannot be undone.", timeout_ms: 60000)',
|
|
998
|
-
category: "Telegram",
|
|
999
|
-
inputSchema: {
|
|
1000
|
-
type: "object",
|
|
1001
|
-
properties: {
|
|
1002
|
-
prompt: {
|
|
1003
|
-
type: "string",
|
|
1004
|
-
maxLength: 200,
|
|
1005
|
-
description: "Short label for what is being approved. Shown as the prompt heading."
|
|
1006
|
-
},
|
|
1007
|
-
details: {
|
|
1008
|
-
type: "string",
|
|
1009
|
-
maxLength: 1e3,
|
|
1010
|
-
description: "Optional context under the heading."
|
|
1011
|
-
},
|
|
1012
|
-
chat_id: {
|
|
1013
|
-
oneOf: [{ type: "string" }, { type: "integer" }],
|
|
1014
|
-
description: "Chat to post the prompt to. Uses the plugin default when omitted."
|
|
1015
|
-
},
|
|
1016
|
-
timeout_ms: {
|
|
1017
|
-
type: "integer",
|
|
1018
|
-
minimum: 1e3,
|
|
1019
|
-
maximum: 6e5,
|
|
1020
|
-
description: "How long to wait before auto-denying. Default 60 000 ms, max 600 000 ms (10 min)."
|
|
1021
|
-
}
|
|
1022
|
-
},
|
|
1023
|
-
required: ["prompt"]
|
|
1024
|
-
},
|
|
1025
|
-
permission: "auto",
|
|
1026
|
-
mutating: false,
|
|
1027
|
-
timeoutMs: 61e4,
|
|
1028
|
-
async execute(input, _ctx, _toolOpts) {
|
|
1029
|
-
const chatId = input.chat_id ?? opts.getDefaultChatId();
|
|
1030
|
-
if (!chatId) {
|
|
1031
|
-
throw new Error(
|
|
1032
|
-
"No chat_id provided and no default notifyChatId configured. Set notifyChatId in plugin config or pass chat_id."
|
|
1033
|
-
);
|
|
1034
|
-
}
|
|
1035
|
-
const timeoutMs = Math.min(Math.max(input.timeout_ms ?? 6e4, 1e3), 6e5);
|
|
1036
|
-
const token = randomUUID2().slice(0, 16);
|
|
1037
|
-
const yesKey = `approve:${token}:yes`;
|
|
1038
|
-
const noKey = `approve:${token}:no`;
|
|
1039
|
-
const heading = `\u26A0\uFE0F ${input.prompt}`;
|
|
1040
|
-
const detailsLine = input.details ? `
|
|
1041
|
-
|
|
1042
|
-
${truncateForTelegram(input.details, 800)}` : "";
|
|
1043
|
-
const text = `${heading}${detailsLine}
|
|
1044
|
-
|
|
1045
|
-
_Reply by tapping a button. Auto-denies in ${Math.round(timeoutMs / 1e3)}s._`;
|
|
1046
|
-
opts.log.info(`telegram_approve \u2192 chat_id=${chatId} prompt="${input.prompt.slice(0, 80)}" token=${token}`);
|
|
1047
|
-
let promptMessageId;
|
|
1048
|
-
try {
|
|
1049
|
-
const sent = await opts.bot.sendMessageWithKeyboard(chatId, text, [
|
|
1050
|
-
{ text: "\u2705 Approve", callback_data: yesKey },
|
|
1051
|
-
{ text: "\u274C Deny", callback_data: noKey }
|
|
1052
|
-
]);
|
|
1053
|
-
promptMessageId = sent.result?.message_id;
|
|
1054
|
-
} catch (err) {
|
|
1055
|
-
opts.log.debug(`telegram_approve send failed: ${err.message}`);
|
|
1056
|
-
}
|
|
1057
|
-
const result = await Promise.race([
|
|
1058
|
-
opts.bot.awaitCallback(yesKey, timeoutMs),
|
|
1059
|
-
opts.bot.awaitCallback(noKey, timeoutMs)
|
|
1060
|
-
]);
|
|
1061
|
-
return {
|
|
1062
|
-
approved: result.approved,
|
|
1063
|
-
from: result.fromUser,
|
|
1064
|
-
prompt_message_id: promptMessageId
|
|
1065
|
-
};
|
|
1066
|
-
}
|
|
1850
|
+
allowedUsers: /* @__PURE__ */ new Set([DENY_ALL_INBOUND]),
|
|
1851
|
+
allowedChats: /* @__PURE__ */ new Set([DENY_ALL_INBOUND])
|
|
1067
1852
|
};
|
|
1068
1853
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1854
|
+
function runCleanups(cleanups, log) {
|
|
1855
|
+
while (cleanups.length > 0) {
|
|
1856
|
+
const cleanup = cleanups.pop();
|
|
1857
|
+
try {
|
|
1858
|
+
cleanup?.();
|
|
1859
|
+
} catch (err) {
|
|
1860
|
+
log.debug(`Telegram cleanup failed: ${err.message}`);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
function disposeRuntime(log) {
|
|
1865
|
+
const state = teardownState;
|
|
1866
|
+
teardownState = null;
|
|
1867
|
+
if (state) runCleanups(state.cleanups, log);
|
|
1868
|
+
}
|
|
1869
|
+
function registerCommand(api, command, cleanups) {
|
|
1870
|
+
api.slashCommands.register(command);
|
|
1871
|
+
cleanups.push(() => {
|
|
1872
|
+
api.slashCommands.unregister(`${PLUGIN_NAME}:${command.name}`);
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1072
1875
|
function telegramFromConfig(cfg) {
|
|
1073
1876
|
const ext = cfg.extensions?.[PLUGIN_NAME] ?? {};
|
|
1074
1877
|
return {
|
|
1075
1878
|
notifyChatId: ext.notifyChatId !== void 0 ? String(ext.notifyChatId) : void 0,
|
|
1879
|
+
allowedOutboundChats: Array.isArray(ext.allowedOutboundChats) ? ext.allowedOutboundChats.filter(
|
|
1880
|
+
(chatId) => typeof chatId === "string" || typeof chatId === "number"
|
|
1881
|
+
) : [],
|
|
1882
|
+
allowedUserIds: Array.isArray(ext.allowedUsers) ? ext.allowedUsers.filter(
|
|
1883
|
+
(userId) => typeof userId === "string" || typeof userId === "number"
|
|
1884
|
+
) : [],
|
|
1885
|
+
allowGroupApprovals: ext.allowGroupApprovals === true,
|
|
1076
1886
|
notifyOnSessionEnd: ext.notifyOnSessionEnd === true,
|
|
1077
1887
|
notifyOnDelegate: ext.notifyOnDelegate !== false,
|
|
1078
1888
|
// default true
|
|
@@ -1092,172 +1902,200 @@ var plugin = {
|
|
|
1092
1902
|
},
|
|
1093
1903
|
configSchema: telegramConfigSchema,
|
|
1094
1904
|
defaultConfig: {
|
|
1905
|
+
allowedOutboundChats: [],
|
|
1095
1906
|
pollIntervalSec: 2,
|
|
1096
1907
|
notifyOnSessionEnd: false,
|
|
1097
1908
|
longToolThresholdMs: 3e4,
|
|
1098
1909
|
maxMessageLength: 4e3
|
|
1099
1910
|
},
|
|
1100
1911
|
async setup(api) {
|
|
1101
|
-
const cfg = readTelegramConfig(api);
|
|
1102
1912
|
const log = api.log;
|
|
1913
|
+
disposeRuntime(log);
|
|
1914
|
+
const cfg = readTelegramConfig(api);
|
|
1103
1915
|
log.info("Starting Telegram plugin...");
|
|
1916
|
+
const rawCfg = cfg;
|
|
1104
1917
|
const runtimeCfg = {
|
|
1105
1918
|
notifyChatId: cfg.notifyChatId,
|
|
1919
|
+
allowedOutboundChats: [...cfg.allowedOutboundChats ?? []],
|
|
1920
|
+
allowedUserIds: [...cfg.allowedUsers ?? []],
|
|
1921
|
+
allowGroupApprovals: rawCfg.allowGroupApprovals === true,
|
|
1106
1922
|
notifyOnSessionEnd: cfg.notifyOnSessionEnd ?? false,
|
|
1107
1923
|
notifyOnDelegate: cfg.notifyOnDelegate ?? true,
|
|
1108
1924
|
longToolThresholdMs: cfg.longToolThresholdMs ?? 3e4,
|
|
1109
|
-
maxMessageLength: cfg.maxMessageLength ?? 4e3
|
|
1925
|
+
maxMessageLength: cfg.maxMessageLength ?? 4e3,
|
|
1926
|
+
outboundQueuePerChat: cfg.outboundQueuePerChat ?? 32,
|
|
1927
|
+
outboundQueueConcurrency: cfg.outboundQueueConcurrency ?? 4
|
|
1110
1928
|
};
|
|
1111
1929
|
const lock = cfg.singleInstanceLock === false ? void 0 : new PollLock(lockPathForToken(cfg.botToken), { log });
|
|
1930
|
+
const offsetStore = cfg.offsetStoragePath === "" ? void 0 : new OffsetStore({ token: cfg.botToken, path: cfg.offsetStoragePath });
|
|
1112
1931
|
const bot = new TelegramBot({
|
|
1113
1932
|
token: cfg.botToken,
|
|
1114
1933
|
pollIntervalSec: cfg.pollIntervalSec ?? 2,
|
|
1115
|
-
|
|
1116
|
-
allowedChats: new Set((cfg.allowedChats ?? []).map(String)),
|
|
1934
|
+
...inboundAllowlist(cfg),
|
|
1117
1935
|
bufferSize: 50,
|
|
1118
1936
|
log,
|
|
1119
|
-
|
|
1937
|
+
offsetStore,
|
|
1120
1938
|
lock,
|
|
1121
1939
|
onMessage(msg) {
|
|
1122
1940
|
api.emitCustom("telegram:message_received", msg);
|
|
1123
|
-
|
|
1124
|
-
log.info(`\u{1F4E8} Telegram: ${who} (chat=${msg.chatId}): ${msg.text.slice(0, 200)}`);
|
|
1941
|
+
log.info(`\u{1F4E8} Telegram message received (${Math.min(bot.bufferCount, 50)} unread)`);
|
|
1125
1942
|
}
|
|
1126
1943
|
});
|
|
1127
|
-
const sendTool = makeTelegramSendTool({
|
|
1128
|
-
bot,
|
|
1129
|
-
getDefaultChatId: () => runtimeCfg.notifyChatId,
|
|
1130
|
-
maxMessageLength: runtimeCfg.maxMessageLength,
|
|
1131
|
-
log
|
|
1132
|
-
});
|
|
1133
|
-
const readTool = makeTelegramReadTool({ bot });
|
|
1134
|
-
const approveTool = makeTelegramApproveTool({
|
|
1135
|
-
bot,
|
|
1136
|
-
getDefaultChatId: () => runtimeCfg.notifyChatId,
|
|
1137
|
-
maxMessageLength: runtimeCfg.maxMessageLength,
|
|
1138
|
-
log
|
|
1139
|
-
});
|
|
1140
|
-
api.tools.register(sendTool);
|
|
1141
|
-
api.tools.register(readTool);
|
|
1142
|
-
api.tools.register(approveTool);
|
|
1143
|
-
const offs = [];
|
|
1144
|
-
const unregisterPrompt = api.registerSystemPromptContributor(async () => {
|
|
1145
|
-
const msgs = bot.getMessages({ limit: 5 });
|
|
1146
|
-
if (msgs.length === 0) return [];
|
|
1147
|
-
const blocks = [
|
|
1148
|
-
{
|
|
1149
|
-
type: "text",
|
|
1150
|
-
text: [
|
|
1151
|
-
"## Telegram Inbox",
|
|
1152
|
-
`You have ${bot.bufferCount} unread Telegram message(s).`,
|
|
1153
|
-
"Read them with `telegram_read` and reply with `telegram_send`.",
|
|
1154
|
-
"",
|
|
1155
|
-
"Recent messages:",
|
|
1156
|
-
...msgs.map((m) => {
|
|
1157
|
-
const who = m.userName ?? `user_${m.userId ?? "unknown"}`;
|
|
1158
|
-
const ts = new Date(m.timestamp).toLocaleTimeString();
|
|
1159
|
-
return `- [${ts}] **${who}** (chat=${m.chatId}): ${m.text.slice(0, 200)}`;
|
|
1160
|
-
}),
|
|
1161
|
-
""
|
|
1162
|
-
].join("\n")
|
|
1163
|
-
}
|
|
1164
|
-
];
|
|
1165
|
-
return blocks;
|
|
1166
|
-
});
|
|
1167
|
-
offs.push(unregisterPrompt);
|
|
1168
|
-
const commandNames = registerSlashCommands(api, bot, cfg);
|
|
1169
|
-
offs.push(
|
|
1170
|
-
api.events.on("session.ended", (event) => {
|
|
1171
|
-
if (!runtimeCfg.notifyOnSessionEnd || !runtimeCfg.notifyChatId) return;
|
|
1172
|
-
const payload = {
|
|
1173
|
-
id: event.id,
|
|
1174
|
-
inputTokens: event.usage.input,
|
|
1175
|
-
outputTokens: event.usage.output,
|
|
1176
|
-
cacheRead: event.usage.cacheRead,
|
|
1177
|
-
cacheWrite: event.usage.cacheWrite
|
|
1178
|
-
};
|
|
1179
|
-
const msg = truncateForTelegram(
|
|
1180
|
-
formatSessionEnded(payload),
|
|
1181
|
-
runtimeCfg.maxMessageLength
|
|
1182
|
-
);
|
|
1183
|
-
void bot.sendMessage(expectDefined2(runtimeCfg.notifyChatId), msg).catch((err) => {
|
|
1184
|
-
log.debug(`Failed to send session end notification: ${err.message}`);
|
|
1185
|
-
});
|
|
1186
|
-
})
|
|
1187
|
-
);
|
|
1188
|
-
offs.push(
|
|
1189
|
-
api.events.on("tool.executed", (event) => {
|
|
1190
|
-
if (!runtimeCfg.notifyChatId || runtimeCfg.longToolThresholdMs <= 0 || event.durationMs < runtimeCfg.longToolThresholdMs) return;
|
|
1191
|
-
const payload = {
|
|
1192
|
-
name: event.name,
|
|
1193
|
-
ok: event.ok,
|
|
1194
|
-
durationMs: event.durationMs,
|
|
1195
|
-
output: event.output
|
|
1196
|
-
};
|
|
1197
|
-
const msg = truncateForTelegram(
|
|
1198
|
-
formatToolExecuted(payload),
|
|
1199
|
-
runtimeCfg.maxMessageLength
|
|
1200
|
-
);
|
|
1201
|
-
void bot.sendMessage(expectDefined2(runtimeCfg.notifyChatId), msg).catch((err) => {
|
|
1202
|
-
log.debug(`Failed to send tool notification: ${err.message}`);
|
|
1203
|
-
});
|
|
1204
|
-
})
|
|
1205
|
-
);
|
|
1206
|
-
offs.push(
|
|
1207
|
-
api.events.on("delegate.completed", (event) => {
|
|
1208
|
-
if (!runtimeCfg.notifyOnDelegate || !runtimeCfg.notifyChatId) return;
|
|
1209
|
-
const msg = truncateForTelegram(
|
|
1210
|
-
formatDelegateCompleted(event),
|
|
1211
|
-
runtimeCfg.maxMessageLength
|
|
1212
|
-
);
|
|
1213
|
-
void bot.sendMessage(expectDefined2(runtimeCfg.notifyChatId), msg).catch((err) => {
|
|
1214
|
-
log.debug(`Failed to send delegate notification: ${err.message}`);
|
|
1215
|
-
});
|
|
1216
|
-
})
|
|
1217
|
-
);
|
|
1218
|
-
const unlistenConfig = api.onConfigChange((next, _prev) => {
|
|
1219
|
-
const fresh = telegramFromConfig(next);
|
|
1220
|
-
runtimeCfg.notifyChatId = fresh.notifyChatId;
|
|
1221
|
-
runtimeCfg.notifyOnSessionEnd = fresh.notifyOnSessionEnd;
|
|
1222
|
-
runtimeCfg.notifyOnDelegate = fresh.notifyOnDelegate;
|
|
1223
|
-
runtimeCfg.longToolThresholdMs = fresh.longToolThresholdMs;
|
|
1224
|
-
runtimeCfg.maxMessageLength = fresh.maxMessageLength;
|
|
1225
|
-
log.debug("Telegram notification settings updated from config", {
|
|
1226
|
-
notifyOnSessionEnd: runtimeCfg.notifyOnSessionEnd,
|
|
1227
|
-
notifyOnDelegate: runtimeCfg.notifyOnDelegate,
|
|
1228
|
-
longToolThresholdMs: runtimeCfg.longToolThresholdMs,
|
|
1229
|
-
notifyChatId: runtimeCfg.notifyChatId ?? "not set"
|
|
1230
|
-
});
|
|
1231
|
-
});
|
|
1232
|
-
offs.push(unlistenConfig);
|
|
1233
1944
|
const probe = await bot.health();
|
|
1234
1945
|
if (!probe.ok) {
|
|
1946
|
+
bot.stop();
|
|
1235
1947
|
throw new Error(
|
|
1236
1948
|
`Telegram plugin startup failed: ${probe.error ?? "unknown error"}. Verify botToken in extensions.telegram (token from @BotFather, format "<id>:<35+ chars>").`
|
|
1237
1949
|
);
|
|
1238
1950
|
}
|
|
1239
1951
|
log.info(`Telegram self-test ok: @${probe.username ?? "unknown"} (api.telegram.org reachable)`);
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1952
|
+
const cleanups = [];
|
|
1953
|
+
try {
|
|
1954
|
+
cleanups.push(() => bot.stop());
|
|
1955
|
+
const outbound = new TelegramBotOutbound({
|
|
1956
|
+
bot,
|
|
1957
|
+
log,
|
|
1958
|
+
maxPerChat: runtimeCfg.outboundQueuePerChat,
|
|
1959
|
+
maxConcurrency: runtimeCfg.outboundQueueConcurrency
|
|
1960
|
+
});
|
|
1961
|
+
cleanups.push(() => {
|
|
1962
|
+
void outbound.stop();
|
|
1963
|
+
});
|
|
1964
|
+
const sendTool = makeTelegramSendTool({
|
|
1965
|
+
bot,
|
|
1966
|
+
getDefaultChatId: () => runtimeCfg.notifyChatId,
|
|
1967
|
+
getAllowedOutboundChatIds: () => runtimeCfg.allowedOutboundChats,
|
|
1968
|
+
maxMessageLength: runtimeCfg.maxMessageLength,
|
|
1969
|
+
log
|
|
1970
|
+
});
|
|
1971
|
+
const readTool = makeTelegramReadTool({ bot });
|
|
1972
|
+
const approveTool = makeTelegramApproveTool({
|
|
1973
|
+
bot,
|
|
1974
|
+
getDefaultChatId: () => runtimeCfg.notifyChatId,
|
|
1975
|
+
getAllowedOutboundChatIds: () => runtimeCfg.allowedOutboundChats,
|
|
1976
|
+
getAllowedUserIds: () => runtimeCfg.allowedUserIds,
|
|
1977
|
+
allowGroupApprovals: runtimeCfg.allowGroupApprovals,
|
|
1978
|
+
maxMessageLength: runtimeCfg.maxMessageLength,
|
|
1979
|
+
log
|
|
1980
|
+
});
|
|
1981
|
+
for (const tool of [sendTool, readTool, approveTool]) {
|
|
1982
|
+
api.tools.register(tool);
|
|
1983
|
+
cleanups.push(() => {
|
|
1984
|
+
api.tools.unregister(tool.name);
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
const unregisterPrompt = api.registerSystemPromptContributor(async () => {
|
|
1988
|
+
const unreadCount = Math.min(bot.bufferCount, 50);
|
|
1989
|
+
if (unreadCount === 0) return [];
|
|
1990
|
+
return [
|
|
1991
|
+
{
|
|
1992
|
+
type: "text",
|
|
1993
|
+
text: [
|
|
1994
|
+
"## Telegram Inbox",
|
|
1995
|
+
`You have ${unreadCount} unread Telegram message(s).`,
|
|
1996
|
+
"Use `telegram_read` to retrieve them when needed."
|
|
1997
|
+
].join("\n")
|
|
1998
|
+
}
|
|
1999
|
+
];
|
|
2000
|
+
});
|
|
2001
|
+
cleanups.push(unregisterPrompt);
|
|
2002
|
+
for (const command of [
|
|
2003
|
+
tgHealthCommand(bot, cfg),
|
|
2004
|
+
tgSendCommand(
|
|
2005
|
+
bot,
|
|
2006
|
+
{
|
|
2007
|
+
getDefaultChatId: () => runtimeCfg.notifyChatId,
|
|
2008
|
+
getAllowedOutboundChatIds: () => runtimeCfg.allowedOutboundChats,
|
|
2009
|
+
getMaxMessageLength: () => runtimeCfg.maxMessageLength
|
|
2010
|
+
},
|
|
2011
|
+
outbound
|
|
2012
|
+
),
|
|
2013
|
+
tgChatIdCommand(cfg.notifyChatId)
|
|
2014
|
+
]) {
|
|
2015
|
+
registerCommand(api, command, cleanups);
|
|
2016
|
+
}
|
|
2017
|
+
cleanups.push(
|
|
2018
|
+
api.events.on("session.ended", (event) => {
|
|
2019
|
+
if (!runtimeCfg.notifyOnSessionEnd || !runtimeCfg.notifyChatId) return;
|
|
2020
|
+
const payload = {
|
|
2021
|
+
id: scrubTelegramOutboundText(event.id),
|
|
2022
|
+
inputTokens: event.usage.input,
|
|
2023
|
+
outputTokens: event.usage.output,
|
|
2024
|
+
cacheRead: event.usage.cacheRead,
|
|
2025
|
+
cacheWrite: event.usage.cacheWrite
|
|
2026
|
+
};
|
|
2027
|
+
const msg = truncateForTelegram(
|
|
2028
|
+
scrubTelegramOutboundText(formatSessionEnded(payload)),
|
|
2029
|
+
runtimeCfg.maxMessageLength
|
|
2030
|
+
);
|
|
2031
|
+
outbound.enqueueNotification(expectDefined2(runtimeCfg.notifyChatId), msg);
|
|
2032
|
+
})
|
|
2033
|
+
);
|
|
2034
|
+
cleanups.push(
|
|
2035
|
+
api.events.on("tool.executed", (event) => {
|
|
2036
|
+
if (!runtimeCfg.notifyChatId || runtimeCfg.longToolThresholdMs <= 0 || event.durationMs < runtimeCfg.longToolThresholdMs)
|
|
2037
|
+
return;
|
|
2038
|
+
const payload = {
|
|
2039
|
+
name: event.name,
|
|
2040
|
+
ok: event.ok,
|
|
2041
|
+
durationMs: event.durationMs,
|
|
2042
|
+
output: event.output === void 0 ? void 0 : scrubTelegramOutboundText(event.output)
|
|
2043
|
+
};
|
|
2044
|
+
const msg = truncateForTelegram(
|
|
2045
|
+
scrubTelegramOutboundText(formatToolExecuted(payload)),
|
|
2046
|
+
runtimeCfg.maxMessageLength
|
|
2047
|
+
);
|
|
2048
|
+
outbound.enqueueNotification(expectDefined2(runtimeCfg.notifyChatId), msg);
|
|
2049
|
+
})
|
|
2050
|
+
);
|
|
2051
|
+
cleanups.push(
|
|
2052
|
+
api.events.on("delegate.completed", (event) => {
|
|
2053
|
+
if (!runtimeCfg.notifyOnDelegate || !runtimeCfg.notifyChatId) return;
|
|
2054
|
+
const safeEvent = {
|
|
2055
|
+
...event,
|
|
2056
|
+
target: scrubTelegramOutboundText(event.target),
|
|
2057
|
+
task: scrubTelegramOutboundText(event.task),
|
|
2058
|
+
status: event.status === void 0 ? void 0 : scrubTelegramOutboundText(event.status),
|
|
2059
|
+
summary: scrubTelegramOutboundText(event.summary)
|
|
2060
|
+
};
|
|
2061
|
+
const msg = truncateForTelegram(
|
|
2062
|
+
scrubTelegramOutboundText(formatDelegateCompleted(safeEvent)),
|
|
2063
|
+
runtimeCfg.maxMessageLength
|
|
2064
|
+
);
|
|
2065
|
+
outbound.enqueueNotification(expectDefined2(runtimeCfg.notifyChatId), msg);
|
|
2066
|
+
})
|
|
2067
|
+
);
|
|
2068
|
+
const unlistenConfig = api.onConfigChange((next, _prev) => {
|
|
2069
|
+
const fresh = telegramFromConfig(next);
|
|
2070
|
+
runtimeCfg.notifyChatId = fresh.notifyChatId;
|
|
2071
|
+
runtimeCfg.allowedOutboundChats = fresh.allowedOutboundChats;
|
|
2072
|
+
runtimeCfg.allowedUserIds = fresh.allowedUserIds;
|
|
2073
|
+
runtimeCfg.allowGroupApprovals = fresh.allowGroupApprovals;
|
|
2074
|
+
runtimeCfg.notifyOnSessionEnd = fresh.notifyOnSessionEnd;
|
|
2075
|
+
runtimeCfg.notifyOnDelegate = fresh.notifyOnDelegate;
|
|
2076
|
+
runtimeCfg.longToolThresholdMs = fresh.longToolThresholdMs;
|
|
2077
|
+
runtimeCfg.maxMessageLength = fresh.maxMessageLength;
|
|
2078
|
+
log.debug("Telegram notification settings updated from config", {
|
|
2079
|
+
notifyOnSessionEnd: runtimeCfg.notifyOnSessionEnd,
|
|
2080
|
+
notifyOnDelegate: runtimeCfg.notifyOnDelegate,
|
|
2081
|
+
longToolThresholdMs: runtimeCfg.longToolThresholdMs,
|
|
2082
|
+
notifyChatId: runtimeCfg.notifyChatId ?? "not set"
|
|
2083
|
+
});
|
|
2084
|
+
});
|
|
2085
|
+
cleanups.push(unlistenConfig);
|
|
2086
|
+
bot.start();
|
|
2087
|
+
teardownState = { bot, outbound, cleanups };
|
|
2088
|
+
log.info("Telegram plugin ready");
|
|
2089
|
+
} catch (err) {
|
|
2090
|
+
teardownState = null;
|
|
2091
|
+
runCleanups(cleanups, log);
|
|
2092
|
+
throw err;
|
|
2093
|
+
}
|
|
1249
2094
|
},
|
|
1250
2095
|
async teardown(api) {
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
state.bot.stop();
|
|
1255
|
-
for (const off of state.offs) off();
|
|
1256
|
-
for (const name of state.toolNames) api.tools.unregister(name);
|
|
1257
|
-
for (const name of state.commandNames) {
|
|
1258
|
-
api.slashCommands.unregister(`${PLUGIN_NAME}:${name}`);
|
|
1259
|
-
}
|
|
1260
|
-
api.log.info("Telegram plugin torn down");
|
|
2096
|
+
const hadRuntime = teardownState !== null;
|
|
2097
|
+
disposeRuntime(api.log);
|
|
2098
|
+
if (hadRuntime) api.log.info("Telegram plugin torn down");
|
|
1261
2099
|
},
|
|
1262
2100
|
async health() {
|
|
1263
2101
|
const state = teardownState;
|
|
@@ -1268,6 +2106,7 @@ var plugin = {
|
|
|
1268
2106
|
};
|
|
1269
2107
|
var src_default = plugin;
|
|
1270
2108
|
export {
|
|
1271
|
-
src_default as default
|
|
2109
|
+
src_default as default,
|
|
2110
|
+
teardownState
|
|
1272
2111
|
};
|
|
1273
2112
|
//# sourceMappingURL=index.js.map
|