openclaw-elys 1.4.8 → 1.6.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.
@@ -31,7 +31,7 @@ export async function monitorElysProvider(opts) {
31
31
  const dispatchReply = channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher;
32
32
  const finalizeCtx = channelRuntime?.reply?.finalizeInboundContext;
33
33
  log(`[elys] channelRuntime available: ${!!channelRuntime}, dispatchReply: ${!!dispatchReply}, finalizeCtx: ${!!finalizeCtx}`);
34
- const commandHandler = async (cmd) => {
34
+ const commandHandler = async (cmd, signal) => {
35
35
  log(`[elys] executing command: ${cmd.command}`, cmd.args);
36
36
  // If we have the full OpenClaw channelRuntime, use the standard dispatch path
37
37
  if (dispatchReply && finalizeCtx) {
@@ -59,13 +59,15 @@ export async function monitorElysProvider(opts) {
59
59
  ctx: inboundCtx,
60
60
  cfg: opts.config,
61
61
  dispatcherOptions: {
62
- // Single deliver callback with kind discriminator (matches OpenClaw API)
63
62
  deliver: async (payload, info) => {
63
+ // Skip publishing if this command was aborted by a newer one
64
+ if (signal.aborted)
65
+ return;
64
66
  if (payload.text) {
65
67
  fullText += payload.text;
66
68
  seq++;
67
69
  const done = info.kind === "final";
68
- mqttClient.publishStreamChunk(cmd.id, payload.text, seq, done);
70
+ mqttClient.publishStreamChunk(cmd.id, payload.text, seq, done, cmd.reply_to);
69
71
  if (info.kind === "block") {
70
72
  log(`[elys] stream chunk #${seq}: ${payload.text.slice(0, 80)}...`);
71
73
  }
@@ -75,7 +77,7 @@ export async function monitorElysProvider(opts) {
75
77
  }
76
78
  else if (info.kind === "final") {
77
79
  seq++;
78
- mqttClient.publishStreamChunk(cmd.id, "", seq, true);
80
+ mqttClient.publishStreamChunk(cmd.id, "", seq, true, cmd.reply_to);
79
81
  log(`[elys] final reply delivered (empty)`);
80
82
  }
81
83
  },
@@ -1,8 +1,20 @@
1
1
  import type { DeviceCredentials, CommandMessage, ResultMessage } from "./types.js";
2
- export type CommandHandler = (cmd: CommandMessage) => Promise<ResultMessage>;
2
+ export type CommandHandler = (cmd: CommandMessage, signal: AbortSignal) => Promise<ResultMessage>;
3
+ export interface MQTTClientOptions {
4
+ /** Debounce window in ms. Rapid messages within this window are merged. Default: 500 */
5
+ debounceMs?: number;
6
+ /** Command execution timeout in ms. Default: 120000 (2 min) */
7
+ commandTimeoutMs?: number;
8
+ }
3
9
  /**
4
10
  * Manages the MQTT connection to EMQX for a single device.
5
11
  * Subscribes to elys/down/{device_id}, publishes to elys/up/{device_id}.
12
+ *
13
+ * Features:
14
+ * - Debounce: rapid messages within a window are merged into one
15
+ * - Dedup: QoS 1 retransmissions are silently skipped
16
+ * - Abort: new command aborts the currently running one
17
+ * - Timeout: commands that run too long are killed
6
18
  */
7
19
  export declare class ElysDeviceMQTTClient {
8
20
  private client;
@@ -11,15 +23,25 @@ export declare class ElysDeviceMQTTClient {
11
23
  private log;
12
24
  private consecutiveFailures;
13
25
  private static readonly MAX_FAILURES_BEFORE_REVOKE_WARNING;
14
- private commandQueue;
15
- constructor(credentials: DeviceCredentials, log?: (...args: unknown[]) => void);
26
+ private readonly debounceMs;
27
+ private debounceTimer;
28
+ private debounceBuffer;
29
+ private recentCommandIds;
30
+ private static readonly DEDUP_TTL_MS;
31
+ private currentAbort;
32
+ private readonly commandTimeoutMs;
33
+ constructor(credentials: DeviceCredentials, log?: (...args: unknown[]) => void, options?: MQTTClientOptions);
16
34
  setCommandHandler(handler: CommandHandler): void;
17
35
  connect(abortSignal?: AbortSignal): Promise<void>;
18
36
  disconnect(): void;
19
37
  /** Send a stream chunk (for streaming AI responses) */
20
- publishStreamChunk(commandId: string, chunk: string, seq: number, done: boolean): void;
21
- private handleMessage;
38
+ publishStreamChunk(commandId: string, chunk: string, seq: number, done: boolean, replyTo?: string): void;
39
+ private onMessage;
40
+ private flushDebounce;
41
+ /** Merge multiple buffered commands into one. Joins text args with newline. */
42
+ private mergeCommands;
43
+ private executeCommand;
44
+ private cleanupDedup;
22
45
  private publishAck;
23
- private publishResult;
24
46
  private publish;
25
47
  }
@@ -2,6 +2,12 @@ import mqtt from "mqtt";
2
2
  /**
3
3
  * Manages the MQTT connection to EMQX for a single device.
4
4
  * Subscribes to elys/down/{device_id}, publishes to elys/up/{device_id}.
5
+ *
6
+ * Features:
7
+ * - Debounce: rapid messages within a window are merged into one
8
+ * - Dedup: QoS 1 retransmissions are silently skipped
9
+ * - Abort: new command aborts the currently running one
10
+ * - Timeout: commands that run too long are killed
5
11
  */
6
12
  export class ElysDeviceMQTTClient {
7
13
  client = null;
@@ -10,10 +16,21 @@ export class ElysDeviceMQTTClient {
10
16
  log;
11
17
  consecutiveFailures = 0;
12
18
  static MAX_FAILURES_BEFORE_REVOKE_WARNING = 3;
13
- commandQueue = Promise.resolve();
14
- constructor(credentials, log) {
19
+ // Debounce
20
+ debounceMs;
21
+ debounceTimer = null;
22
+ debounceBuffer = [];
23
+ // Dedup (command id → receive timestamp)
24
+ recentCommandIds = new Map();
25
+ static DEDUP_TTL_MS = 60_000;
26
+ // Abort & timeout
27
+ currentAbort = null;
28
+ commandTimeoutMs;
29
+ constructor(credentials, log, options) {
15
30
  this.credentials = credentials;
16
31
  this.log = log ?? console.log;
32
+ this.debounceMs = options?.debounceMs ?? 500;
33
+ this.commandTimeoutMs = options?.commandTimeoutMs ?? 120_000;
17
34
  }
18
35
  setCommandHandler(handler) {
19
36
  this.commandHandler = handler;
@@ -27,7 +44,7 @@ export class ElysDeviceMQTTClient {
27
44
  password: deviceToken,
28
45
  reconnectPeriod: 5000,
29
46
  keepalive: 30,
30
- rejectUnauthorized: true, // verify TLS cert
47
+ rejectUnauthorized: true,
31
48
  });
32
49
  this.client.on("connect", () => {
33
50
  this.log(`[elys] MQTT connected as ${deviceId}`);
@@ -43,10 +60,7 @@ export class ElysDeviceMQTTClient {
43
60
  });
44
61
  });
45
62
  this.client.on("message", (_topic, payload) => {
46
- // Serial queue: commands are processed one at a time in arrival order
47
- this.commandQueue = this.commandQueue.then(() => this.handleMessage(payload), () => this.handleMessage(payload)).catch((err) => {
48
- this.log("[elys] error handling message:", err);
49
- });
63
+ this.onMessage(payload);
50
64
  });
51
65
  this.client.on("reconnect", () => {
52
66
  this.consecutiveFailures++;
@@ -60,7 +74,6 @@ export class ElysDeviceMQTTClient {
60
74
  }
61
75
  });
62
76
  this.client.on("error", (err) => {
63
- // MQTT auth failures indicate revoked credentials
64
77
  if (err.message.includes("Not authorized") || err.message.includes("Connection refused")) {
65
78
  this.log(`[elys] ERROR: MQTT authentication failed — device credentials may have been revoked. ` +
66
79
  `Please re-register: npx openclaw-elys setup <gateway_url> <register_token>`);
@@ -72,13 +85,11 @@ export class ElysDeviceMQTTClient {
72
85
  this.client.on("close", () => {
73
86
  this.log("[elys] MQTT connection closed");
74
87
  });
75
- // Handle abort signal for graceful shutdown
76
88
  if (abortSignal) {
77
89
  abortSignal.addEventListener("abort", () => {
78
90
  this.disconnect();
79
91
  });
80
92
  }
81
- // Wait for initial connection
82
93
  return new Promise((resolve, reject) => {
83
94
  const onConnect = () => {
84
95
  cleanup();
@@ -97,6 +108,11 @@ export class ElysDeviceMQTTClient {
97
108
  });
98
109
  }
99
110
  disconnect() {
111
+ if (this.debounceTimer) {
112
+ clearTimeout(this.debounceTimer);
113
+ this.debounceTimer = null;
114
+ }
115
+ this.currentAbort?.abort();
100
116
  if (this.client) {
101
117
  this.client.end(true);
102
118
  this.client = null;
@@ -104,7 +120,7 @@ export class ElysDeviceMQTTClient {
104
120
  }
105
121
  }
106
122
  /** Send a stream chunk (for streaming AI responses) */
107
- publishStreamChunk(commandId, chunk, seq, done) {
123
+ publishStreamChunk(commandId, chunk, seq, done, replyTo) {
108
124
  const msg = {
109
125
  id: commandId,
110
126
  type: "stream",
@@ -113,35 +129,118 @@ export class ElysDeviceMQTTClient {
113
129
  seq,
114
130
  done,
115
131
  };
116
- this.publish(msg);
132
+ this.publish(msg, replyTo);
117
133
  }
118
- async handleMessage(payload) {
119
- const raw = JSON.parse(payload.toString());
134
+ // ─── Inbound message pipeline: dedup → ack → debounce → abort → execute ───
135
+ onMessage(payload) {
136
+ let raw;
137
+ try {
138
+ raw = JSON.parse(payload.toString());
139
+ }
140
+ catch {
141
+ this.log("[elys] failed to parse MQTT message");
142
+ return;
143
+ }
120
144
  if (raw.type !== "command") {
121
145
  this.log(`[elys] ignoring non-command message type: ${raw.type}`);
122
146
  return;
123
147
  }
148
+ // 1. Dedup — skip QoS 1 retransmissions
149
+ if (this.recentCommandIds.has(raw.id)) {
150
+ this.log(`[elys] dedup: skipping duplicate command ${raw.id}`);
151
+ return;
152
+ }
153
+ this.recentCommandIds.set(raw.id, Date.now());
154
+ this.cleanupDedup();
124
155
  this.log(`[elys] received command: ${raw.command} (id: ${raw.id})`);
125
- // Send ACK immediately
156
+ // 2. ACK immediately (goes to shared upstream topic)
126
157
  this.publishAck(raw.id);
127
- // Execute command
128
- if (this.commandHandler) {
129
- try {
130
- const result = await this.commandHandler(raw);
131
- this.publishResult(result);
158
+ // 3. Debounce — buffer and wait for more messages
159
+ this.debounceBuffer.push(raw);
160
+ if (this.debounceTimer) {
161
+ clearTimeout(this.debounceTimer);
162
+ }
163
+ this.debounceTimer = setTimeout(() => {
164
+ this.flushDebounce();
165
+ }, this.debounceMs);
166
+ }
167
+ flushDebounce() {
168
+ const buffered = this.debounceBuffer;
169
+ this.debounceBuffer = [];
170
+ this.debounceTimer = null;
171
+ if (buffered.length === 0)
172
+ return;
173
+ // 4. Abort previous running command
174
+ this.currentAbort?.abort();
175
+ this.currentAbort = new AbortController();
176
+ const merged = this.mergeCommands(buffered);
177
+ // 5. Execute (fire-and-forget, errors handled inside)
178
+ this.executeCommand(merged, this.currentAbort.signal).catch((err) => {
179
+ this.log("[elys] unexpected error in executeCommand:", err);
180
+ });
181
+ }
182
+ /** Merge multiple buffered commands into one. Joins text args with newline. */
183
+ mergeCommands(cmds) {
184
+ if (cmds.length === 1)
185
+ return cmds[0];
186
+ const last = cmds[cmds.length - 1];
187
+ const texts = cmds
188
+ .map((c) => c.args?.text ?? "")
189
+ .filter(Boolean);
190
+ if (texts.length > 1) {
191
+ this.log(`[elys] debounce: merged ${cmds.length} commands`);
192
+ return {
193
+ ...last,
194
+ args: { ...last.args, text: texts.join("\n") },
195
+ };
196
+ }
197
+ // Can't merge non-text commands, use the latest one
198
+ this.log(`[elys] debounce: using latest of ${cmds.length} commands`);
199
+ return last;
200
+ }
201
+ async executeCommand(cmd, signal) {
202
+ if (!this.commandHandler)
203
+ return;
204
+ const replyTo = cmd.reply_to;
205
+ try {
206
+ const result = await Promise.race([
207
+ this.commandHandler(cmd, signal),
208
+ new Promise((_, reject) => {
209
+ const timer = setTimeout(() => {
210
+ reject(new Error(`command timed out after ${this.commandTimeoutMs / 1000}s`));
211
+ }, this.commandTimeoutMs);
212
+ // Clean up timer if command completes or is aborted
213
+ signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
214
+ }),
215
+ ]);
216
+ if (!signal.aborted) {
217
+ this.publish(result, replyTo);
132
218
  }
133
- catch (err) {
134
- const errMsg = err instanceof Error ? err.message : String(err);
135
- this.publishResult({
136
- id: raw.id,
137
- type: "result",
138
- timestamp: Date.now() / 1000,
139
- status: "error",
140
- error: errMsg,
141
- });
219
+ }
220
+ catch (err) {
221
+ if (signal.aborted) {
222
+ this.log(`[elys] command ${cmd.id} aborted`);
223
+ return;
142
224
  }
225
+ const errMsg = err instanceof Error ? err.message : String(err);
226
+ this.publish({
227
+ id: cmd.id,
228
+ type: "result",
229
+ timestamp: Math.floor(Date.now() / 1000),
230
+ status: "error",
231
+ error: errMsg,
232
+ }, replyTo);
143
233
  }
144
234
  }
235
+ cleanupDedup() {
236
+ const now = Date.now();
237
+ for (const [id, ts] of this.recentCommandIds) {
238
+ if (now - ts > ElysDeviceMQTTClient.DEDUP_TTL_MS) {
239
+ this.recentCommandIds.delete(id);
240
+ }
241
+ }
242
+ }
243
+ // ─── Outbound helpers ───
145
244
  publishAck(commandId) {
146
245
  const msg = {
147
246
  id: commandId,
@@ -150,13 +249,10 @@ export class ElysDeviceMQTTClient {
150
249
  };
151
250
  this.publish(msg);
152
251
  }
153
- publishResult(msg) {
154
- this.publish(msg);
155
- }
156
- publish(msg) {
252
+ publish(msg, replyTo) {
157
253
  if (!this.client)
158
254
  return;
159
- const topic = `elys/up/${this.credentials.deviceId}`;
255
+ const topic = replyTo || `elys/up/${this.credentials.deviceId}`;
160
256
  this.client.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
161
257
  if (err) {
162
258
  this.log(`[elys] failed to publish to ${topic}:`, err);
@@ -24,6 +24,8 @@ export interface CommandMessage extends MQTTBaseMessage {
24
24
  type: "command";
25
25
  command: string;
26
26
  args?: Record<string, unknown>;
27
+ /** If set, device should publish result/stream to this topic instead of elys/up/{device_id} */
28
+ reply_to?: string;
27
29
  }
28
30
  export interface AckMessage extends MQTTBaseMessage {
29
31
  type: "ack";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-elys",
3
- "version": "1.4.8",
3
+ "version": "1.6.0",
4
4
  "description": "OpenClaw Elys channel plugin — connects to Elys App",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",