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.
- package/README.md +155 -0
- package/agents.mjs +19 -3
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/cli.mjs +381 -60
- package/daemon.mjs +98 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +2 -1
- package/mas/mention_router.mjs +75 -4
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +232 -0
- package/mas/tool_runner.mjs +24 -2
- package/mas/tools/skill_view.mjs +43 -0
- package/package.json +3 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
|
@@ -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
|
+
}
|