lazyclaw 6.0.1 → 6.2.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.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/cli.mjs +10 -21
- package/commands/channels.mjs +106 -107
- package/commands/chat.mjs +81 -45
- package/commands/daemon.mjs +15 -0
- package/commands/gateway.mjs +194 -0
- package/commands/providers.mjs +17 -38
- package/commands/service.mjs +113 -0
- package/commands/setup.mjs +55 -54
- package/commands/setup_channels.mjs +207 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/routes/_deps.mjs +6 -0
- package/daemon/routes/conversation.mjs +68 -4
- package/daemon/routes/ops.mjs +9 -29
- package/dotenv_min.mjs +28 -0
- package/first_run.mjs +9 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +2 -3
- package/providers/codex_cli.mjs +17 -1
- package/providers/compat_vendors.mjs +161 -0
- package/providers/gemini_cli.mjs +19 -3
- package/providers/model_catalogue.mjs +142 -9
- package/providers/probe.mjs +28 -0
- package/providers/registry.mjs +31 -162
- package/tui/editor.mjs +18 -2
- package/tui/pickers.mjs +3 -3
- package/tui/provider_families.mjs +8 -6
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
package/channels/matrix.mjs
CHANGED
|
@@ -158,7 +158,7 @@ export class MatrixChannel extends Channel {
|
|
|
158
158
|
if (typeof opts.since === 'string') this._since = opts.since;
|
|
159
159
|
this._timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : LONG_POLL_MS;
|
|
160
160
|
const poll = opts.poll !== false; // default true
|
|
161
|
-
if (poll) this._startPollLoop({ logger: this._logger });
|
|
161
|
+
if (poll) this._startPollLoop({ logger: this._logger, onDead: opts.onDead });
|
|
162
162
|
return this;
|
|
163
163
|
}
|
|
164
164
|
|
|
@@ -191,8 +191,9 @@ export class MatrixChannel extends Channel {
|
|
|
191
191
|
// base.mjs's bucket gate reads req.token || req.key, so the sender
|
|
192
192
|
// id rides under `key`: an authToken gate compares against it and a
|
|
193
193
|
// rate-limit gate keys per-sender. We keep senderId for downstream
|
|
194
|
-
// handler context
|
|
195
|
-
|
|
194
|
+
// handler context, and the globally-unique event_id for daemon-
|
|
195
|
+
// side dedup.
|
|
196
|
+
gateInput: { key: evt.senderId, senderId: evt.senderId, messageId: evt.eventId },
|
|
196
197
|
});
|
|
197
198
|
} catch (err) {
|
|
198
199
|
if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
|
|
@@ -253,6 +254,7 @@ export class MatrixChannel extends Channel {
|
|
|
253
254
|
threadId,
|
|
254
255
|
text,
|
|
255
256
|
senderId: gateInput && gateInput.senderId != null ? gateInput.senderId : null,
|
|
257
|
+
messageId: gateInput && gateInput.messageId != null ? gateInput.messageId : null,
|
|
256
258
|
});
|
|
257
259
|
}
|
|
258
260
|
|
|
@@ -320,8 +322,16 @@ export class MatrixChannel extends Channel {
|
|
|
320
322
|
// backs off rather than crashing the daemon. The in-flight AbortController
|
|
321
323
|
// is held so stop() can abort the ~30s held-open request for prompt
|
|
322
324
|
// shutdown.
|
|
323
|
-
_startPollLoop({ logger }) {
|
|
325
|
+
_startPollLoop({ logger, onDead }) {
|
|
324
326
|
let stopped = false;
|
|
327
|
+
// Abnormal loop death must be VISIBLE to a live process: a gateway whose
|
|
328
|
+
// matrix poll silently exits looks healthy while being deaf on that
|
|
329
|
+
// channel. Default: throw from a fresh tick so the process crash
|
|
330
|
+
// handlers fire (and a service manager restarts it).
|
|
331
|
+
const dead = (err) => {
|
|
332
|
+
if (typeof onDead === 'function') return onDead(err);
|
|
333
|
+
setImmediate(() => { throw err; });
|
|
334
|
+
};
|
|
325
335
|
const loop = async () => {
|
|
326
336
|
while (!stopped) {
|
|
327
337
|
try {
|
|
@@ -335,12 +345,20 @@ export class MatrixChannel extends Channel {
|
|
|
335
345
|
}
|
|
336
346
|
} catch (err) {
|
|
337
347
|
if (stopped) break;
|
|
338
|
-
if (err?.name === 'AbortError' || err?.code === 'MATRIX_ABORTED')
|
|
348
|
+
if (err?.name === 'AbortError' || err?.code === 'MATRIX_ABORTED') {
|
|
349
|
+
// Aborted while NOT stopping = some other actor killed the
|
|
350
|
+
// in-flight sync — that's a dead listener, not a clean exit.
|
|
351
|
+
logger(`[matrix] sync aborted outside shutdown — stopping listener\n`);
|
|
352
|
+
this._fatal = err;
|
|
353
|
+
dead(err);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
339
356
|
// A dead/forbidden token will never recover — stop the listener
|
|
340
357
|
// and surface it rather than spinning forever on a 500ms back-off.
|
|
341
358
|
if (err?.code === 'MATRIX_AUTH_FATAL') {
|
|
342
359
|
logger(`[matrix] FATAL: ${err.message} — stopping listener\n`);
|
|
343
360
|
this._fatal = err;
|
|
361
|
+
dead(err);
|
|
344
362
|
break;
|
|
345
363
|
}
|
|
346
364
|
logger(`[matrix] poll error: ${err?.message || err}\n`);
|
package/channels/slack.mjs
CHANGED
|
@@ -19,17 +19,10 @@
|
|
|
19
19
|
// Phase 8 spec can point the adapter at a local mock HTTP server.
|
|
20
20
|
|
|
21
21
|
import { Channel, ChannelGated } from './base.mjs';
|
|
22
|
+
import { SlackError, readSlackEnv, validateEnv } from './slack_env.mjs';
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export class SlackError extends Error {
|
|
26
|
-
constructor(message, code, missing) {
|
|
27
|
-
super(message);
|
|
28
|
-
this.name = 'SlackError';
|
|
29
|
-
this.code = code || 'SLACK_ERR';
|
|
30
|
-
if (Array.isArray(missing)) this.missing = missing;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
24
|
+
// Re-exported for compatibility (split out under the file-size ratchet).
|
|
25
|
+
export { SlackError, readSlackEnv } from './slack_env.mjs';
|
|
33
26
|
|
|
34
27
|
// Decide whether a Socket Mode event should drive a handler call.
|
|
35
28
|
// Pulled out of dispatchEvent so we can unit-test the filter without
|
|
@@ -57,34 +50,6 @@ export function shouldDispatchEvent(event, { selfUserId = null, selfBotId = null
|
|
|
57
50
|
return true;
|
|
58
51
|
}
|
|
59
52
|
|
|
60
|
-
export function readSlackEnv(env = process.env) {
|
|
61
|
-
const out = {
|
|
62
|
-
botToken: env.SLACK_BOT_TOKEN || null,
|
|
63
|
-
appToken: env.SLACK_APP_TOKEN || null,
|
|
64
|
-
signingSecret: env.SLACK_SIGNING_SECRET || null,
|
|
65
|
-
apiBase: env.SLACK_API_BASE || DEFAULT_API_BASE,
|
|
66
|
-
};
|
|
67
|
-
return out;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function validateEnv(env, { requireInbound = false } = {}) {
|
|
71
|
-
const missing = [];
|
|
72
|
-
if (!env.botToken) missing.push('SLACK_BOT_TOKEN');
|
|
73
|
-
else if (!env.botToken.startsWith('xoxb-')) {
|
|
74
|
-
throw new SlackError('SLACK_BOT_TOKEN must start with "xoxb-"', 'SLACK_BAD_TOKEN', ['SLACK_BOT_TOKEN']);
|
|
75
|
-
}
|
|
76
|
-
if (requireInbound) {
|
|
77
|
-
if (!env.appToken) missing.push('SLACK_APP_TOKEN');
|
|
78
|
-
else if (!env.appToken.startsWith('xapp-')) {
|
|
79
|
-
throw new SlackError('SLACK_APP_TOKEN must start with "xapp-"', 'SLACK_BAD_TOKEN', ['SLACK_APP_TOKEN']);
|
|
80
|
-
}
|
|
81
|
-
if (!env.signingSecret) missing.push('SLACK_SIGNING_SECRET');
|
|
82
|
-
}
|
|
83
|
-
if (missing.length) {
|
|
84
|
-
throw new SlackError(`missing Slack env vars: ${missing.join(', ')}`, 'SLACK_MISSING_ENV', missing);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
53
|
export class SlackChannel extends Channel {
|
|
89
54
|
constructor(opts = {}) {
|
|
90
55
|
super('slack');
|
|
@@ -112,10 +77,10 @@ export class SlackChannel extends Channel {
|
|
|
112
77
|
// handler that decided to stay silent — e.g. the listener dropping
|
|
113
78
|
// an empty-after-mention-strip inbound — doesn't leak a "(empty
|
|
114
79
|
// reply)" placeholder into the channel.
|
|
115
|
-
async _simulateInbound(text, threadId) {
|
|
80
|
+
async _simulateInbound(text, threadId, senderId = null, messageId = null) {
|
|
116
81
|
let reply;
|
|
117
82
|
try {
|
|
118
|
-
reply = await this._processInbound({ threadId, text, gateInput: {} });
|
|
83
|
+
reply = await this._processInbound({ threadId, text, gateInput: { key: senderId, senderId, messageId } });
|
|
119
84
|
} catch (err) {
|
|
120
85
|
if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
|
|
121
86
|
await this.send(threadId, `(gated: ${err.message})`);
|
|
@@ -128,6 +93,30 @@ export class SlackChannel extends Channel {
|
|
|
128
93
|
await this.send(threadId, reply);
|
|
129
94
|
}
|
|
130
95
|
|
|
96
|
+
// The base _processInbound forwards { channel, threadId, text }; we enrich
|
|
97
|
+
// the event with senderId (the Slack user id) so the listener bridge can
|
|
98
|
+
// pass it to the daemon's /inbound pairing gate — without it, Slack is the
|
|
99
|
+
// one channel that can never be pairing-gated. Mirrors telegram/matrix;
|
|
100
|
+
// adds a field, never drops one.
|
|
101
|
+
async _processInbound({ threadId, text, gateInput }) {
|
|
102
|
+
if (this._gate) {
|
|
103
|
+
const verdict = this._gate.check(gateInput || {});
|
|
104
|
+
if (!verdict.ok) {
|
|
105
|
+
const err = new Error(verdict.reason || 'denied');
|
|
106
|
+
err.code = 'CHANNEL_GATED';
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!this._handler) throw new Error(`channel "${this.name}" has no handler`);
|
|
111
|
+
return await this._handler({
|
|
112
|
+
channel: this.name,
|
|
113
|
+
threadId,
|
|
114
|
+
text,
|
|
115
|
+
senderId: gateInput && gateInput.senderId != null ? gateInput.senderId : null,
|
|
116
|
+
messageId: gateInput && gateInput.messageId != null ? gateInput.messageId : null,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
131
120
|
// Translate a target spec like `slack:#deploys` or `slack:U012345` into
|
|
132
121
|
// a Slack `channel` string. Threads are addressed by a `threadId` of
|
|
133
122
|
// shape `<channel>:<thread_ts>` or plain channel/user id.
|
|
@@ -184,7 +173,11 @@ export class SlackChannel extends Channel {
|
|
|
184
173
|
// opts.logger?: (line: string) => void — diagnostic sink (stderr in
|
|
185
174
|
// the CLI, no-op in tests).
|
|
186
175
|
// opts.maxReconnects?: number — cap reconnect attempts (default ∞).
|
|
187
|
-
|
|
176
|
+
// opts.onDead?: (err) => void — called when the reconnect chain gives up
|
|
177
|
+
// for good. Default THROWS on the next tick so an always-on process
|
|
178
|
+
// crashes loudly (and a service manager restarts it) instead of sitting
|
|
179
|
+
// alive with a permanently dead socket.
|
|
180
|
+
async _connectSocketMode({ logger = () => {}, maxReconnects = Infinity, onDead } = {}) {
|
|
188
181
|
validateEnv(this._env, { requireInbound: true });
|
|
189
182
|
if (typeof globalThis.WebSocket !== 'function') {
|
|
190
183
|
throw new SlackError(
|
|
@@ -284,6 +277,7 @@ export class SlackChannel extends Channel {
|
|
|
284
277
|
// get app_mention events. Either way we have channel + ts.
|
|
285
278
|
const text = typeof event.text === 'string' ? event.text : '';
|
|
286
279
|
const channel = event.channel;
|
|
280
|
+
const senderId = event.user != null ? String(event.user) : null; // the human who sent it
|
|
287
281
|
const sourceTs = event.ts; // the message we react to
|
|
288
282
|
const replyTs = event.thread_ts || event.ts; // the thread root for replies
|
|
289
283
|
if (!channel || !sourceTs) return;
|
|
@@ -292,6 +286,10 @@ export class SlackChannel extends Channel {
|
|
|
292
286
|
return;
|
|
293
287
|
}
|
|
294
288
|
const threadId = `${channel}:${replyTs}`;
|
|
289
|
+
// Native message id for daemon-side dedup: ts is unique per channel,
|
|
290
|
+
// so channel:ts is unique bot-wide (and identical for the app_mention
|
|
291
|
+
// and message events of the same message — exactly what dedup wants).
|
|
292
|
+
const messageId = `${channel}:${sourceTs}`;
|
|
295
293
|
logger(`[slack] inbound ${event.type} from ${channel} (${text.length} chars)\n`);
|
|
296
294
|
|
|
297
295
|
// Immediate acknowledgement. _ackInbound is silent when
|
|
@@ -300,7 +298,7 @@ export class SlackChannel extends Channel {
|
|
|
300
298
|
const eyesOk = await this._ackInbound(channel, sourceTs, logger);
|
|
301
299
|
|
|
302
300
|
try {
|
|
303
|
-
await this._simulateInbound(text, threadId);
|
|
301
|
+
await this._simulateInbound(text, threadId, senderId, messageId);
|
|
304
302
|
if (eyesOk) {
|
|
305
303
|
// Swap the "working" reaction for a "done" one so the user can
|
|
306
304
|
// tell at a glance which messages have been answered.
|
|
@@ -316,17 +314,51 @@ export class SlackChannel extends Channel {
|
|
|
316
314
|
}
|
|
317
315
|
};
|
|
318
316
|
|
|
317
|
+
const dead = (err) => {
|
|
318
|
+
if (typeof onDead === 'function') return onDead(err);
|
|
319
|
+
// Surface the permanent failure instead of staying alive-but-deaf:
|
|
320
|
+
// throwing from a fresh tick reaches the process crash handlers.
|
|
321
|
+
setImmediate(() => { throw err; });
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Reschedule after ANY reconnect failure — including apps.connections.open
|
|
325
|
+
// rejecting — so one bad negotiation can't permanently kill the chain.
|
|
326
|
+
const scheduleReconnect = () => {
|
|
327
|
+
if (closed) return;
|
|
328
|
+
attempts++;
|
|
329
|
+
if (attempts > maxReconnects) {
|
|
330
|
+
logger(`[slack] giving up after ${attempts - 1} reconnect attempts\n`);
|
|
331
|
+
dead(new SlackError(`socket-mode reconnect gave up after ${attempts - 1} attempts`, 'SLACK_SOCKET_DEAD'));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const backoff = Math.min(30000, 1000 * Math.pow(2, Math.min(attempts, 5)));
|
|
335
|
+
logger(`[slack] reconnecting in ${backoff}ms (attempt ${attempts})\n`);
|
|
336
|
+
setTimeout(() => {
|
|
337
|
+
if (closed) return;
|
|
338
|
+
connectOnce().catch((e) => {
|
|
339
|
+
logger(`[slack] reconnect failed: ${e?.message || e}\n`);
|
|
340
|
+
scheduleReconnect();
|
|
341
|
+
});
|
|
342
|
+
}, backoff);
|
|
343
|
+
};
|
|
344
|
+
|
|
319
345
|
const connectOnce = () => new Promise((resolve, reject) => {
|
|
346
|
+
// Settle exactly once: a socket that closes (or errors) before 'open'
|
|
347
|
+
// REJECTS instead of leaving the promise pending forever — the initial
|
|
348
|
+
// caller surfaces it as a start failure; the reconnect path's .catch
|
|
349
|
+
// schedules the next attempt.
|
|
350
|
+
let settled = false;
|
|
320
351
|
let wsUrl;
|
|
321
352
|
openConnection()
|
|
322
353
|
.then((u) => { wsUrl = u; })
|
|
323
|
-
.catch(reject)
|
|
354
|
+
.catch((e) => { settled = true; reject(e); })
|
|
324
355
|
.then(() => {
|
|
325
356
|
if (!wsUrl) return;
|
|
326
357
|
logger(`[slack] socket-mode dialing wss gateway\n`);
|
|
327
358
|
ws = new WebSocket(wsUrl);
|
|
328
359
|
ws.addEventListener('open', () => {
|
|
329
360
|
attempts = 0;
|
|
361
|
+
settled = true;
|
|
330
362
|
logger(`[slack] socket-mode connected\n`);
|
|
331
363
|
resolve();
|
|
332
364
|
});
|
|
@@ -362,15 +394,16 @@ export class SlackChannel extends Channel {
|
|
|
362
394
|
});
|
|
363
395
|
ws.addEventListener('close', () => {
|
|
364
396
|
logger(`[slack] socket closed\n`);
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
397
|
+
if (!settled) {
|
|
398
|
+
// Closed before 'open': settle the pending connect attempt.
|
|
399
|
+
// The caller's .catch (initial start OR scheduleReconnect)
|
|
400
|
+
// decides what happens next — never double-schedule here.
|
|
401
|
+
settled = true;
|
|
402
|
+
reject(new SlackError('socket closed before open', 'SLACK_SOCKET_CLOSED_EARLY'));
|
|
369
403
|
return;
|
|
370
404
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
setTimeout(() => { if (!closed) connectOnce().catch((e) => logger(`[slack] reconnect failed: ${e?.message || e}\n`)); }, backoff);
|
|
405
|
+
if (closed) return;
|
|
406
|
+
scheduleReconnect();
|
|
374
407
|
});
|
|
375
408
|
ws.addEventListener('error', (ev) => {
|
|
376
409
|
// The 'error' event fires before 'close'; we let 'close' drive
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// channels/slack_env.mjs — Slack env reading + validation, split out of
|
|
2
|
+
// slack.mjs (file-size ratchet) so the adapter file has room for the
|
|
3
|
+
// socket-mode transport. Re-exported from slack.mjs for compatibility.
|
|
4
|
+
|
|
5
|
+
const DEFAULT_API_BASE = 'https://slack.com/api';
|
|
6
|
+
|
|
7
|
+
export class SlackError extends Error {
|
|
8
|
+
constructor(message, code, missing) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'SlackError';
|
|
11
|
+
this.code = code || 'SLACK_ERR';
|
|
12
|
+
if (Array.isArray(missing)) this.missing = missing;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readSlackEnv(env = process.env) {
|
|
17
|
+
const out = {
|
|
18
|
+
botToken: env.SLACK_BOT_TOKEN || null,
|
|
19
|
+
appToken: env.SLACK_APP_TOKEN || null,
|
|
20
|
+
signingSecret: env.SLACK_SIGNING_SECRET || null,
|
|
21
|
+
apiBase: env.SLACK_API_BASE || DEFAULT_API_BASE,
|
|
22
|
+
};
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateEnv(env, { requireInbound = false } = {}) {
|
|
27
|
+
const missing = [];
|
|
28
|
+
if (!env.botToken) missing.push('SLACK_BOT_TOKEN');
|
|
29
|
+
else if (!env.botToken.startsWith('xoxb-')) {
|
|
30
|
+
throw new SlackError('SLACK_BOT_TOKEN must start with "xoxb-"', 'SLACK_BAD_TOKEN', ['SLACK_BOT_TOKEN']);
|
|
31
|
+
}
|
|
32
|
+
if (requireInbound) {
|
|
33
|
+
// Socket Mode (the inbound path) authenticates the WebSocket with the
|
|
34
|
+
// app-level token; SLACK_SIGNING_SECRET is only needed for the HTTP Events
|
|
35
|
+
// API (request-signature verification), which this adapter does not use, so
|
|
36
|
+
// it is NOT required here — requiring it blocked socket-mode setups.
|
|
37
|
+
if (!env.appToken) missing.push('SLACK_APP_TOKEN');
|
|
38
|
+
else if (!env.appToken.startsWith('xapp-')) {
|
|
39
|
+
throw new SlackError('SLACK_APP_TOKEN must start with "xapp-"', 'SLACK_BAD_TOKEN', ['SLACK_APP_TOKEN']);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (missing.length) {
|
|
43
|
+
throw new SlackError(`missing Slack env vars: ${missing.join(', ')}`, 'SLACK_MISSING_ENV', missing);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/channels/telegram.mjs
CHANGED
|
@@ -130,6 +130,7 @@ export class TelegramChannel extends Channel {
|
|
|
130
130
|
// backoff between iterations (default 0 — no extra sleep).
|
|
131
131
|
pollIntervalMs: typeof opts.pollIntervalMs === 'number' ? opts.pollIntervalMs : 0,
|
|
132
132
|
logger: this._logger,
|
|
133
|
+
onDead: opts.onDead,
|
|
133
134
|
});
|
|
134
135
|
}
|
|
135
136
|
return this;
|
|
@@ -157,8 +158,20 @@ export class TelegramChannel extends Channel {
|
|
|
157
158
|
// base.mjs's bucket gate reads req.token || req.key, so the sender
|
|
158
159
|
// id rides under `key`: an authToken gate compares against it and a
|
|
159
160
|
// rate-limit gate keys per-sender. We also keep senderId for
|
|
160
|
-
// downstream handler context.
|
|
161
|
-
|
|
161
|
+
// downstream handler context, plus a dedup id for the daemon.
|
|
162
|
+
// PREFER update_id: it is stable when getUpdates redelivers the same
|
|
163
|
+
// update (offset is memory-only, so a restart replays the batch) but
|
|
164
|
+
// DISTINCT for an edit — an edited_message carries the ORIGINAL
|
|
165
|
+
// message_id, so keying on message_id would replay the stale answer
|
|
166
|
+
// instead of processing the edit. chatId-scoped message_id is only
|
|
167
|
+
// the fallback for direct _simulateInbound callers without update_id.
|
|
168
|
+
gateInput: {
|
|
169
|
+
key: evt.senderId,
|
|
170
|
+
senderId: evt.senderId,
|
|
171
|
+
messageId: evt.updateId != null
|
|
172
|
+
? `${evt.chatId}:u${evt.updateId}`
|
|
173
|
+
: (evt.messageId ? `${evt.chatId}:${evt.messageId}` : null),
|
|
174
|
+
},
|
|
162
175
|
});
|
|
163
176
|
} catch (err) {
|
|
164
177
|
if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
|
|
@@ -201,6 +214,7 @@ export class TelegramChannel extends Channel {
|
|
|
201
214
|
threadId,
|
|
202
215
|
text,
|
|
203
216
|
senderId: gateInput && gateInput.senderId != null ? gateInput.senderId : null,
|
|
217
|
+
messageId: gateInput && gateInput.messageId != null ? gateInput.messageId : null,
|
|
204
218
|
});
|
|
205
219
|
}
|
|
206
220
|
|
|
@@ -248,6 +262,12 @@ export class TelegramChannel extends Channel {
|
|
|
248
262
|
const timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : 15000;
|
|
249
263
|
const controller = new AbortController();
|
|
250
264
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
265
|
+
// opts.track exposes this call's AbortController so stop() can cut a
|
|
266
|
+
// held-open long-poll short instead of waiting out the ~60s window
|
|
267
|
+
// (which can overrun a service manager's SIGTERM grace and get us
|
|
268
|
+
// SIGKILLed mid-drain). Only getUpdates opts in — aborting an in-flight
|
|
269
|
+
// send would drop a reply.
|
|
270
|
+
if (opts.track) this._inflightAbort = controller;
|
|
251
271
|
let res;
|
|
252
272
|
try {
|
|
253
273
|
res = await fetch(url, {
|
|
@@ -263,6 +283,11 @@ export class TelegramChannel extends Channel {
|
|
|
263
283
|
throw new TelegramError(`telegram ${method} transport error: ${err?.message || err}`, 'TELEGRAM_TRANSPORT');
|
|
264
284
|
} finally {
|
|
265
285
|
clearTimeout(timer);
|
|
286
|
+
if (opts.track) this._inflightAbort = null;
|
|
287
|
+
}
|
|
288
|
+
if (res.status === 401 || res.status === 403) {
|
|
289
|
+
// A dead/revoked token never recovers — fatal, not retryable.
|
|
290
|
+
throw new TelegramError(`telegram ${method} auth failed: HTTP ${res.status} (check TELEGRAM_BOT_TOKEN)`, 'TELEGRAM_AUTH_FATAL');
|
|
266
291
|
}
|
|
267
292
|
if (!res.ok) {
|
|
268
293
|
throw new TelegramError(`telegram ${method} failed: HTTP ${res.status}`, 'TELEGRAM_HTTP_FAIL');
|
|
@@ -280,15 +305,30 @@ export class TelegramChannel extends Channel {
|
|
|
280
305
|
// hands the batch to _processBatch which advances the ack offset only
|
|
281
306
|
// for updates it processes successfully. Errors are logged and the loop
|
|
282
307
|
// backs off rather than crashing the daemon.
|
|
283
|
-
_startPollLoop({ pollIntervalMs, logger }) {
|
|
308
|
+
_startPollLoop({ pollIntervalMs, logger, onDead }) {
|
|
284
309
|
let stopped = false;
|
|
310
|
+
// Mirror matrix: abnormal loop death is surfaced (default: throw on a
|
|
311
|
+
// fresh tick -> crash handlers -> service-manager restart), never silent.
|
|
312
|
+
const dead = (err) => {
|
|
313
|
+
if (typeof onDead === 'function') return onDead(err);
|
|
314
|
+
setImmediate(() => { throw err; });
|
|
315
|
+
};
|
|
285
316
|
const loop = async () => {
|
|
286
317
|
while (!stopped) {
|
|
287
318
|
try {
|
|
288
319
|
const updates = await this._fetchUpdates();
|
|
289
320
|
await this._processBatch(updates, () => stopped);
|
|
290
321
|
} catch (err) {
|
|
291
|
-
|
|
322
|
+
// A dead/revoked token never recovers — stop and surface instead
|
|
323
|
+
// of hammering the API on a 500ms back-off forever.
|
|
324
|
+
if (!stopped && err?.code === 'TELEGRAM_AUTH_FATAL') {
|
|
325
|
+
logger(`[telegram] FATAL: ${err.message} — stopping listener\n`);
|
|
326
|
+
this._fatal = err;
|
|
327
|
+
dead(err);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
// A shutdown abort is a clean exit, not an error worth logging.
|
|
331
|
+
if (!stopped) logger(`[telegram] poll error: ${err?.message || err}\n`);
|
|
292
332
|
// On a transport/API error, back off a beat so we don't spin
|
|
293
333
|
// hot against a failing endpoint even when pollIntervalMs is 0.
|
|
294
334
|
if (!stopped) await new Promise((r) => setTimeout(r, Math.max(pollIntervalMs, 500)));
|
|
@@ -299,11 +339,14 @@ export class TelegramChannel extends Channel {
|
|
|
299
339
|
if (pollIntervalMs > 0) await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
300
340
|
}
|
|
301
341
|
};
|
|
302
|
-
// Fire and forget — stop() flips `stopped
|
|
342
|
+
// Fire and forget — stop() flips `stopped`, aborts the held-open
|
|
343
|
+
// getUpdates (prompt shutdown instead of waiting out the ~60s
|
|
344
|
+
// long-poll), and the loop exits.
|
|
303
345
|
const promise = loop();
|
|
304
346
|
this._pollHandle = {
|
|
305
347
|
stop: async () => {
|
|
306
348
|
stopped = true;
|
|
349
|
+
try { this._inflightAbort?.abort(); } catch { /* already settled */ }
|
|
307
350
|
try { await promise; } catch { /* best-effort */ }
|
|
308
351
|
},
|
|
309
352
|
};
|
|
@@ -347,7 +390,7 @@ export class TelegramChannel extends Channel {
|
|
|
347
390
|
const json = await this._apiCall(
|
|
348
391
|
'getUpdates',
|
|
349
392
|
{ offset: this._offset, timeout: LONG_POLL_SECONDS },
|
|
350
|
-
{ timeoutMs: (LONG_POLL_SECONDS + 10) * 1000 }
|
|
393
|
+
{ timeoutMs: (LONG_POLL_SECONDS + 10) * 1000, track: true }
|
|
351
394
|
);
|
|
352
395
|
return Array.isArray(json.result) ? json.result : [];
|
|
353
396
|
}
|
package/cli.mjs
CHANGED
|
@@ -311,27 +311,16 @@ async function main() {
|
|
|
311
311
|
}
|
|
312
312
|
case 'channels': {
|
|
313
313
|
const sub = (rest.positional[0] || 'list').toLowerCase();
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (sub === 'remove' || sub === 'uninstall') {
|
|
325
|
-
const name = rest.positional[1];
|
|
326
|
-
if (!name) { process.stderr.write('usage: lazyclaw channels remove <@lazyclaw/channel-name>\n'); process.exit(2); }
|
|
327
|
-
await loader.remove(name);
|
|
328
|
-
process.stdout.write(`removed ${name}\n`);
|
|
329
|
-
break;
|
|
330
|
-
}
|
|
331
|
-
// list
|
|
332
|
-
const rows = listInstalled(cfgDir);
|
|
333
|
-
if (!rows.length) { process.stdout.write('no channel plugins installed\n'); break; }
|
|
334
|
-
for (const r of rows) process.stdout.write(`${r.name}\t${r.version}\n`);
|
|
314
|
+
await (await import('./commands/channels.mjs')).cmdChannels(sub, rest.positional.slice(1), rest.flags);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case 'service': {
|
|
318
|
+
const sub = rest.positional[0];
|
|
319
|
+
await (await import('./commands/service.mjs')).cmdService(sub, rest.positional.slice(1), rest.flags);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case 'gateway': {
|
|
323
|
+
await (await import('./commands/gateway.mjs')).cmdGateway(rest.flags);
|
|
335
324
|
break;
|
|
336
325
|
}
|
|
337
326
|
case 'daemon': {
|