@wrongstack/telegram 0.286.0 → 0.289.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/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/bot.ts
5
- import { sleep } from "@wrongstack/core/utils";
6
- function redactToken(url, token) {
7
- return url.replace(token, "[REDACTED]");
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
- var TelegramBot = class _TelegramBot {
10
- baseUrl;
11
- /** Base URL with token redacted, safe to use in log calls. */
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
- /** If set, the offset is persisted here after each successful poll. */
34
- offsetStoragePath;
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 callback_query waiters keyed by the `data` string they
44
- // registered. Cleared on stop(). Used by `telegram_approve` to bridge a
45
- // synchronous-looking API onto the async callback event.
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.baseUrl = `https://api.telegram.org/bot${opts.token}`;
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.offsetStoragePath = opts.offsetStoragePath;
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.offsetStoragePath) {
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 key of Array.from(this.callbackWaiters.keys())) {
89
- this.rejectWaiter(key, "shutdown");
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("Telegram: poll lock lost to another instance \u2014 pausing polling and standing by.");
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 res = await fetch(url, {
185
- method: "POST",
186
- headers: { "Content-Type": "application/json" },
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
- const data = await res.json();
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
- if (attempt < 3) {
198
- this.log.debug(`Telegram sendMessage attempt ${attempt} failed, retrying in 1s...`);
199
- await sleep(1e3);
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 res = await fetch(url, {
228
- method: "POST",
229
- headers: { "Content-Type": "application/json" },
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
- const data = await res.json();
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
- if (attempt < 3) await sleep(1e3);
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 url = `${this.baseUrl}/getMe`;
253
- const res = await fetch(url, { signal: ctrl.signal });
254
- const data = await res.json();
255
- if (!data.ok || !data.result) {
256
- return { ok: false, error: data.description ?? "Unknown error" };
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 url = `${this.baseUrl}/getUpdates?offset=${this.offset}&timeout=10`;
279
- const res = await fetch(url, { signal: this.controller.signal });
280
- const data = await res.json();
281
- if (!data.ok) {
282
- if (data.error_code === 409) {
283
- this.conflictStreak++;
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
- const msg = { ...raw, text: raw.text };
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.name === "AbortError") return;
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
- if (this.allowedUsers.size > 0 && userId && !this.allowedUsers.has(userId)) {
318
- this.log.debug(`Ignoring message from user ${userId} (not in allowedUsers)`);
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 (this.allowedChats.size > 0 && !this.allowedChats.has(chatId)) {
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
- * Handle an inbound `callback_query` update: route it to a registered
341
- * waiter (if any), and acknowledge it via `answerCallbackQuery` so the
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
- rejectWaiter(key, fromUser) {
352
- if (key === "") return false;
353
- const waiter = this.callbackWaiters.get(key);
354
- if (!waiter) return false;
355
- clearTimeout(waiter.timer);
356
- this.callbackWaiters.delete(key);
357
- waiter.resolve({ approved: false, fromUser });
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
- if (cq.from) {
363
- const userId = String(cq.from.id);
364
- if (this.allowedUsers.size > 0 && !this.allowedUsers.has(userId)) {
365
- this.log.warn(
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
- if (chatId !== void 0 && this.allowedChats.size > 0 && !this.allowedChats.has(chatId)) {
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 chat ${chatId} (data="${key}") \u2014 possible hijack attempt.`
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
- const waiter = key !== "" ? this.callbackWaiters.get(key) : void 0;
383
- const approved = key.endsWith(":yes");
384
- const fromUser = cq.from?.username ?? cq.from?.first_name ?? "unknown";
385
- await this.answerCallback(cq.id, approved ? "Approved \u2713" : "Denied \u2717", false);
386
- if (waiter) {
387
- clearTimeout(waiter.timer);
388
- this.callbackWaiters.delete(key);
389
- waiter.resolve({ approved, fromUser });
390
- } else {
391
- this.log.debug(`Unmatched callback_query data="${key}" (no pending waiter)`);
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 fetch(`${this.baseUrl}/answerCallbackQuery`, {
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 a waiter for a callback_query whose `data` field equals `key`.
418
- * Resolves with `{ approved, fromUser }` when a matching press arrives, or
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
- awaitCallback(key, timeoutMs) {
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
- if (this.callbackWaiters.delete(key)) {
428
- resolve({ approved: false, fromUser: "timeout" });
429
- }
430
- }, timeoutMs);
431
- this.callbackWaiters.set(key, { resolve, timer });
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.offsetStoragePath) return;
748
+ if (!this.offsetStore) return;
436
749
  try {
437
- const { readFileSync: readFileSync2 } = await import("node:fs");
438
- const raw = readFileSync2(this.offsetStoragePath, "utf8").trim();
439
- const n = Number.parseInt(raw, 10);
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.offsetStoragePath) return;
759
+ if (!this.offsetStore) return;
449
760
  try {
450
- const { writeFileSync: writeFileSync2 } = await import("node:fs");
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
- if (text.length <= maxLen) return text;
459
- const cutoff = maxLen - 30;
460
- if (cutoff <= 0) return `${text.slice(0, maxLen - 1)}\u2026`;
461
- const searchEnd = Math.min(text.length, maxLen);
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, maxLen - 20)}\u2026[+${text.length - maxLen + 20} chars]`;
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 allowed to interact with the bot"
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 the bot is allowed to read from"
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
- ...extensions?.[PLUGIN_NAME] ?? {}
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, defaultChatId) {
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 chatId;
1336
+ let requestedChatId;
837
1337
  let text;
838
1338
  const parts = args.trim().split(/\s+/);
839
1339
  const maybeId = parts[0];
840
- if (/^\d+$/.test(expectDefined(maybeId)) && parts.length > 1) {
841
- chatId = expectDefined(maybeId);
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
- return {
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 res = await bot.sendMessage(chatId, text);
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 { message: "No notifyChatId configured. Set it in the plugin config or pass chat_id explicitly to telegram_send." };
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
- function registerSlashCommands(api, bot, cfg) {
880
- const cmds = [
881
- tgHealthCommand(bot, cfg),
882
- tgSendCommand(bot, cfg.notifyChatId),
883
- tgChatIdCommand(cfg.notifyChatId)
884
- ];
885
- for (const cmd of cmds) api.slashCommands.register(cmd);
886
- return cmds.map((c) => c.name);
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 a Telegram chat. Write the message in natural prose \u2014 a human reads it. Summarize results, state what happened, and include only the key details. Never paste raw JSON, object dumps, or truncated tool output directly into the message field.",
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, _opts) {
969
- const chatId = input.chat_id ?? opts.getDefaultChatId();
970
- if (!chatId) {
971
- throw new Error(
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/tools/telegram-approve.ts
992
- import { randomUUID as randomUUID2 } from "node:crypto";
993
- function makeTelegramApproveTool(opts) {
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
- name: "telegram_approve",
996
- description: "Post a yes/no approval prompt to a Telegram chat with inline keyboard buttons, and wait for the user to tap one. Returns { approved, from } where approved=false on timeout or explicit deny. Use this when you need explicit human confirmation before proceeding (destructive ops, irreversible deploys, ambiguous choices).",
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
- // src/index.ts
1071
- var teardownState = null;
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
- allowedUsers: new Set((cfg.allowedUsers ?? []).map(String)),
1116
- allowedChats: new Set((cfg.allowedChats ?? []).map(String)),
1934
+ ...inboundAllowlist(cfg),
1117
1935
  bufferSize: 50,
1118
1936
  log,
1119
- offsetStoragePath: cfg.offsetStoragePath,
1937
+ offsetStore,
1120
1938
  lock,
1121
1939
  onMessage(msg) {
1122
1940
  api.emitCustom("telegram:message_received", msg);
1123
- const who = msg.userName ?? msg.userId ?? "unknown";
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
- bot.start();
1241
- teardownState = {
1242
- offs,
1243
- toolNames: [sendTool.name, readTool.name, approveTool.name],
1244
- commandNames,
1245
- bot,
1246
- runtimeCfg
1247
- };
1248
- log.info("Telegram plugin ready");
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 state = teardownState;
1252
- if (!state) return;
1253
- teardownState = null;
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