lazyclaw 4.2.1 → 4.3.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.
@@ -0,0 +1,362 @@
1
+ // Telegram channel adapter.
2
+ //
3
+ // Reads one secret from the constructor or the environment ONLY (never
4
+ // from goal files, never logged):
5
+ // TELEGRAM_BOT_TOKEN 123456:ABC-… — used both to address every Bot
6
+ // API method and to authorize calls.
7
+ //
8
+ // The Bot API addresses methods by embedding the token in the path:
9
+ // https://api.telegram.org/bot<TOKEN>/<method>
10
+ // so a single token covers both inbound (getUpdates long-poll) and
11
+ // outbound (sendMessage). Outbound (`send(threadId, text)`) only needs
12
+ // that token. Inbound arrives via long-polling — `start()` (with the
13
+ // default `poll: true`) spins up `_pollLoop()` which calls `getUpdates`
14
+ // and funnels every message through `_simulateInbound(update)`.
15
+ //
16
+ // `start({ poll: false })` validates the token and registers the handler
17
+ // without bringing up the poll loop, so unit tests can drive
18
+ // `_simulateInbound` / `send` directly. The default poll path is intended
19
+ // to be driven by a future `telegram listen` subcommand (not yet wired in
20
+ // the CLI).
21
+ //
22
+ // LAZYCLAW_TELEGRAM_API_BASE (or opts.apiBase) overrides the Bot API
23
+ // base URL so the Phase 21 spec can point the adapter at a local mock
24
+ // HTTP server.
25
+
26
+ import { Channel, ChannelGated } from './base.mjs';
27
+
28
+ const DEFAULT_API_BASE = 'https://api.telegram.org';
29
+ const THREAD_PREFIX = 'telegram';
30
+ // Server-side long-poll window for getUpdates (seconds). Telegram holds
31
+ // the request open up to this long when no updates are pending, so an
32
+ // idle bot makes ~1 request per LONG_POLL_SECONDS instead of spinning.
33
+ const LONG_POLL_SECONDS = 50;
34
+
35
+ export class TelegramError extends Error {
36
+ constructor(message, code) {
37
+ super(message);
38
+ this.name = 'TelegramError';
39
+ this.code = code || 'TELEGRAM_ERR';
40
+ }
41
+ }
42
+
43
+ // Resolve the Bot API base URL, preferring an explicit override, then
44
+ // the env, then the public default. Trailing slashes are trimmed so we
45
+ // can join paths without doubling separators.
46
+ export function readTelegramEnv(env = process.env) {
47
+ return {
48
+ token: env.TELEGRAM_BOT_TOKEN || null,
49
+ apiBase: env.LAZYCLAW_TELEGRAM_API_BASE || DEFAULT_API_BASE,
50
+ };
51
+ }
52
+
53
+ // Normalize a raw Telegram update into the channel event shape the
54
+ // router consumes. Returns null for updates we don't handle (no text,
55
+ // no chat) so the caller can skip them without special-casing.
56
+ //
57
+ // Telegram delivers several message containers (message, edited_message,
58
+ // channel_post, edited_channel_post); we accept the first one present so
59
+ // edits and channel posts route the same way as fresh DMs.
60
+ export function normalizeUpdate(update) {
61
+ if (!update || typeof update !== 'object') return null;
62
+ const msg =
63
+ update.message ||
64
+ update.edited_message ||
65
+ update.channel_post ||
66
+ update.edited_channel_post ||
67
+ null;
68
+ if (!msg || typeof msg !== 'object') return null;
69
+ const text = typeof msg.text === 'string' ? msg.text : '';
70
+ const chatId = msg.chat && msg.chat.id != null ? String(msg.chat.id) : null;
71
+ if (!chatId) return null;
72
+ const senderId = msg.from && msg.from.id != null ? String(msg.from.id) : null;
73
+ return {
74
+ text,
75
+ chatId,
76
+ senderId,
77
+ messageId: msg.message_id != null ? String(msg.message_id) : null,
78
+ updateId: update.update_id != null ? Number(update.update_id) : null,
79
+ // threadId encodes the chat the reply must go back to.
80
+ threadId: `${THREAD_PREFIX}:${chatId}`,
81
+ };
82
+ }
83
+
84
+ export class TelegramChannel extends Channel {
85
+ constructor(opts = {}) {
86
+ super('telegram');
87
+ this._env = { ...readTelegramEnv(), ...opts };
88
+ // A pairing allowlist of Telegram user ids (strings). When set, only
89
+ // senders on the list reach the handler; everything else is dropped
90
+ // silently (no handler call, no reply leak to an unpaired chat).
91
+ const allow = opts.allowlist || opts.allowedSenders || null;
92
+ this._allowlist = Array.isArray(allow) ? new Set(allow.map((id) => String(id))) : null;
93
+ this._pollHandle = null; // { stop() } once the loop is running
94
+ this._offset = 0; // getUpdates offset cursor (ack via +1)
95
+ // Diagnostic sink. Defaults to a no-op until start() wires one up so
96
+ // _simulateInbound can log internal errors without leaking them to the
97
+ // chat. Replaced (never appended) on every start().
98
+ this._logger = () => {};
99
+ }
100
+
101
+ // Begin accepting messages. With the default `poll: true` this spins
102
+ // up the long-poll loop; tests pass `poll: false` to keep the adapter
103
+ // pure and drive `_simulateInbound` / `send` directly.
104
+ //
105
+ // opts (beyond the base gate):
106
+ // poll?: boolean — start the getUpdates loop (default true)
107
+ // pollIntervalMs?: number — extra backoff between getUpdates calls
108
+ // (default 0). The loop already paces itself
109
+ // off the held-open ~50s long-poll request,
110
+ // so no per-turn sleep is needed when idle;
111
+ // this only adds a small inter-poll delay /
112
+ // error backoff when set.
113
+ // logger?: (line) => void — diagnostic sink (stderr in CLI, no-op in tests)
114
+ async start(handler, opts = {}) {
115
+ if (!this._env.token) {
116
+ throw new TelegramError(
117
+ 'cannot start Telegram channel without a token — set TELEGRAM_BOT_TOKEN or pass { token }',
118
+ 'TELEGRAM_MISSING_TOKEN'
119
+ );
120
+ }
121
+ await super.start(handler, opts);
122
+ // Wire the diagnostic sink regardless of poll mode so _simulateInbound
123
+ // can log internal handler errors (never echoed to the chat).
124
+ this._logger = typeof opts.logger === 'function' ? opts.logger : () => {};
125
+ const poll = opts.poll !== false; // default true
126
+ if (poll) {
127
+ this._startPollLoop({
128
+ // Telegram caps the held-open getUpdates at ~50s; the loop paces
129
+ // itself off that long-poll, so pollIntervalMs is only a small
130
+ // backoff between iterations (default 0 — no extra sleep).
131
+ pollIntervalMs: typeof opts.pollIntervalMs === 'number' ? opts.pollIntervalMs : 0,
132
+ logger: this._logger,
133
+ });
134
+ }
135
+ return this;
136
+ }
137
+
138
+ // Called by the poll loop (or tests) for every inbound update routed
139
+ // to this bot. The handler returns the bot's reply; the adapter posts
140
+ // it back to the originating chat. A null / empty-string reply skips
141
+ // the send entirely so a handler that decides to stay silent doesn't
142
+ // leak a placeholder into the chat. Unpaired senders are dropped
143
+ // before the handler runs (no reply, no handler call).
144
+ async _simulateInbound(update) {
145
+ const evt = normalizeUpdate(update);
146
+ if (!evt) return;
147
+ if (this._allowlist && (!evt.senderId || !this._allowlist.has(evt.senderId))) {
148
+ // Not paired — drop silently. We deliberately do NOT reply so an
149
+ // unknown chat can't be used to probe the bot.
150
+ return;
151
+ }
152
+ let reply;
153
+ try {
154
+ reply = await this._processInbound({
155
+ threadId: evt.threadId,
156
+ text: evt.text,
157
+ // base.mjs's bucket gate reads req.token || req.key, so the sender
158
+ // id rides under `key`: an authToken gate compares against it and a
159
+ // rate-limit gate keys per-sender. We also keep senderId for
160
+ // downstream handler context.
161
+ gateInput: { key: evt.senderId, senderId: evt.senderId },
162
+ });
163
+ } catch (err) {
164
+ if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
165
+ // A gate denial is an expected, user-facing condition; the reason
166
+ // ('rate_limited' / 'unauthorized') is safe to surface.
167
+ await this.send(evt.threadId, `(gated: ${err.message})`);
168
+ return;
169
+ }
170
+ // An unexpected handler/transport error may carry internal detail
171
+ // (stack, secrets in messages). Reply a generic notice to the chat
172
+ // and log the full error to the diagnostic sink only.
173
+ this._logger(`[telegram] handler error: ${err?.stack || err?.message || err}\n`);
174
+ try {
175
+ await this.send(evt.threadId, '(internal error)');
176
+ } catch (sendErr) {
177
+ this._logger(`[telegram] failed to deliver error notice: ${sendErr?.message || sendErr}\n`);
178
+ }
179
+ return;
180
+ }
181
+ if (reply == null || (typeof reply === 'string' && reply.trim() === '')) return;
182
+ await this.send(evt.threadId, reply);
183
+ }
184
+
185
+ // The base _processInbound forwards { channel, threadId, text }; we
186
+ // enrich the event the router sees with senderId so memory / pairing
187
+ // hooks downstream can key on the human. Override stays in lockstep
188
+ // with base.mjs's contract — it only adds fields, never drops them.
189
+ async _processInbound({ threadId, text, gateInput }) {
190
+ if (this._gate) {
191
+ const verdict = this._gate.check(gateInput || {});
192
+ if (!verdict.ok) {
193
+ const err = new Error(verdict.reason || 'denied');
194
+ err.code = 'CHANNEL_GATED';
195
+ throw err;
196
+ }
197
+ }
198
+ if (!this._handler) throw new Error(`channel "${this.name}" has no handler`);
199
+ return await this._handler({
200
+ channel: this.name,
201
+ threadId,
202
+ text,
203
+ senderId: gateInput && gateInput.senderId != null ? gateInput.senderId : null,
204
+ });
205
+ }
206
+
207
+ // Deliver a reply. threadId encodes the chat as `telegram:<chat_id>`
208
+ // (the shape normalizeUpdate emits); a bare chat id is also accepted
209
+ // so callers can address a chat directly.
210
+ async send(threadId, text, opts = {}) {
211
+ if (!this._env.token) throw new TelegramError('cannot send without a Telegram token', 'TELEGRAM_NO_TOKEN');
212
+ const chatId = this._decodeChatId(threadId);
213
+ if (!chatId) throw new TelegramError(`cannot resolve chat_id from threadId "${threadId}"`, 'TELEGRAM_BAD_THREAD');
214
+ const body = {
215
+ chat_id: chatId,
216
+ text: String(text),
217
+ ...(opts && opts.parse_mode ? { parse_mode: String(opts.parse_mode) } : {}),
218
+ ...(opts && opts.reply_to_message_id ? { reply_to_message_id: opts.reply_to_message_id } : {}),
219
+ };
220
+ const json = await this._apiCall('sendMessage', body);
221
+ return json;
222
+ }
223
+
224
+ // Translate a `telegram:<chat_id>` threadId (or a bare chat id) into a
225
+ // Telegram chat_id string. Returns null when nothing usable is found.
226
+ _decodeChatId(threadId) {
227
+ if (threadId == null) return null;
228
+ const s = String(threadId);
229
+ if (s.startsWith(`${THREAD_PREFIX}:`)) {
230
+ const rest = s.slice(THREAD_PREFIX.length + 1);
231
+ return rest || null;
232
+ }
233
+ return s || null;
234
+ }
235
+
236
+ // POST a Bot API method. The token lives in the path (`/bot<TOKEN>/<method>`)
237
+ // per the Telegram convention, and the body is JSON. Throws
238
+ // TelegramError on transport or API-level failure.
239
+ //
240
+ // `opts.timeoutMs` aborts the request after the given wall-clock budget.
241
+ // getUpdates passes a budget just above its server-side long-poll
242
+ // (LONG_POLL_SECONDS) so the held-open request isn't cut short, while
243
+ // still bounding a hung connection. Defaults to a short budget for the
244
+ // quick request/response methods (sendMessage, etc.).
245
+ async _apiCall(method, body, opts = {}) {
246
+ const base = String(this._env.apiBase).replace(/\/$/, '');
247
+ const url = `${base}/bot${this._env.token}/${method}`;
248
+ const timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : 15000;
249
+ const controller = new AbortController();
250
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
251
+ let res;
252
+ try {
253
+ res = await fetch(url, {
254
+ method: 'POST',
255
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
256
+ body: JSON.stringify(body || {}),
257
+ signal: controller.signal,
258
+ });
259
+ } catch (err) {
260
+ if (err?.name === 'AbortError') {
261
+ throw new TelegramError(`telegram ${method} timed out after ${timeoutMs}ms`, 'TELEGRAM_TIMEOUT');
262
+ }
263
+ throw new TelegramError(`telegram ${method} transport error: ${err?.message || err}`, 'TELEGRAM_TRANSPORT');
264
+ } finally {
265
+ clearTimeout(timer);
266
+ }
267
+ if (!res.ok) {
268
+ throw new TelegramError(`telegram ${method} failed: HTTP ${res.status}`, 'TELEGRAM_HTTP_FAIL');
269
+ }
270
+ const json = await res.json().catch(() => ({}));
271
+ if (!json || json.ok !== true) {
272
+ throw new TelegramError(`telegram ${method} failed: ${json?.description || 'unknown'}`, 'TELEGRAM_API_FAIL');
273
+ }
274
+ return json;
275
+ }
276
+
277
+ // Spin up the long-poll loop. Each iteration issues a single held-open
278
+ // getUpdates (the ~50s server-side long-poll is what paces the idle
279
+ // loop — there is no per-turn sleep unless pollIntervalMs is set), then
280
+ // hands the batch to _processBatch which advances the ack offset only
281
+ // for updates it processes successfully. Errors are logged and the loop
282
+ // backs off rather than crashing the daemon.
283
+ _startPollLoop({ pollIntervalMs, logger }) {
284
+ let stopped = false;
285
+ const loop = async () => {
286
+ while (!stopped) {
287
+ try {
288
+ const updates = await this._fetchUpdates();
289
+ await this._processBatch(updates, () => stopped);
290
+ } catch (err) {
291
+ logger(`[telegram] poll error: ${err?.message || err}\n`);
292
+ // On a transport/API error, back off a beat so we don't spin
293
+ // hot against a failing endpoint even when pollIntervalMs is 0.
294
+ if (!stopped) await new Promise((r) => setTimeout(r, Math.max(pollIntervalMs, 500)));
295
+ }
296
+ if (stopped) break;
297
+ // The held-open long-poll already paces the idle loop; only sleep
298
+ // when the caller asked for an explicit inter-poll delay.
299
+ if (pollIntervalMs > 0) await new Promise((r) => setTimeout(r, pollIntervalMs));
300
+ }
301
+ };
302
+ // Fire and forget — stop() flips `stopped` and the loop exits.
303
+ const promise = loop();
304
+ this._pollHandle = {
305
+ stop: async () => {
306
+ stopped = true;
307
+ try { await promise; } catch { /* best-effort */ }
308
+ },
309
+ };
310
+ }
311
+
312
+ // Dispatch one getUpdates batch. The ack offset is advanced ONLY past
313
+ // updates that were processed without throwing: if _simulateInbound
314
+ // throws (e.g. send() fails to deliver the reply), we leave the cursor
315
+ // on that update so Telegram re-delivers it on the next poll instead of
316
+ // silently dropping the reply. Updates within a batch are strictly
317
+ // ordered, so we commit the highest contiguously-succeeded update_id.
318
+ // `isStopped` lets the poll loop bail mid-batch on shutdown.
319
+ async _processBatch(updates, isStopped = () => false) {
320
+ if (!Array.isArray(updates)) return;
321
+ let committed = null; // highest update_id safely processed so far
322
+ for (const update of updates) {
323
+ if (isStopped()) break;
324
+ try {
325
+ await this._simulateInbound(update);
326
+ } catch (err) {
327
+ // Processing failed for this update — do NOT ack it (or anything
328
+ // after it). The loop will re-fetch from the un-advanced offset.
329
+ this._logger(`[telegram] update ${update?.update_id} failed: ${err?.message || err}\n`);
330
+ break;
331
+ }
332
+ const updateId = update && update.update_id != null ? Number(update.update_id) : null;
333
+ if (updateId != null) committed = updateId;
334
+ }
335
+ // Commit the ack only after the batch (or its successful prefix) is
336
+ // done, so a crash mid-batch never advances past unprocessed updates.
337
+ if (committed != null && committed + 1 > this._offset) this._offset = committed + 1;
338
+ }
339
+
340
+ // One getUpdates call. Uses Telegram's server-side long-poll (timeout in
341
+ // seconds) so an idle bot holds a single request open instead of
342
+ // hammering the API ~60 req/min. The fetch network timeout is set just
343
+ // above the long-poll so the held-open request isn't aborted early.
344
+ // Returns the array of raw updates (possibly empty). Kept separate from
345
+ // the loop so it stays unit-testable.
346
+ async _fetchUpdates() {
347
+ const json = await this._apiCall(
348
+ 'getUpdates',
349
+ { offset: this._offset, timeout: LONG_POLL_SECONDS },
350
+ { timeoutMs: (LONG_POLL_SECONDS + 10) * 1000 }
351
+ );
352
+ return Array.isArray(json.result) ? json.result : [];
353
+ }
354
+
355
+ async stop() {
356
+ if (this._pollHandle && typeof this._pollHandle.stop === 'function') {
357
+ try { await this._pollHandle.stop(); } catch { /* best-effort */ }
358
+ }
359
+ this._pollHandle = null;
360
+ await super.stop();
361
+ }
362
+ }