openclaw-elys 1.7.7 → 1.8.1

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 CHANGED
@@ -52,6 +52,120 @@ One command to revoke device, clean up config, and remove the plugin:
52
52
  npx openclaw-elys@latest uninstall
53
53
  ```
54
54
 
55
+ ## Streaming / 流式输出
56
+
57
+ OpenClaw 支持 block streaming,需要在 `~/.openclaw/openclaw.json` 中配置:
58
+
59
+ ```json
60
+ {
61
+ "agents": {
62
+ "defaults": {
63
+ "blockStreamingDefault": "on"
64
+ }
65
+ },
66
+ "channels": {
67
+ "elys": {
68
+ "blockStreaming": true
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ 配置后需要**完全重启 OpenClaw**(非热重载)才能生效。
75
+
76
+ 发送消息时设置 `stream: true` 可以获取流式输出(多个 chunk),否则只返回最终结果。
77
+
78
+ ## MQTT Protocol / 通信协议
79
+
80
+ ### Topics
81
+
82
+ | 方向 | Topic | 说明 |
83
+ |------|-------|------|
84
+ | 下行(命令) | `elys/down/{device_id}` | Gateway → Plugin |
85
+ | 上行(响应) | `elys/up/{device_id}` | Plugin → Gateway |
86
+
87
+ ### Message Types
88
+
89
+ #### Command(下行)
90
+
91
+ ```json
92
+ {
93
+ "id": "cmd_xxx",
94
+ "type": "command",
95
+ "timestamp": 1709827200,
96
+ "command": "chat",
97
+ "args": { "text": "你好" },
98
+ "stream": true,
99
+ "media_url": "https://example.com/photo.jpg",
100
+ "media_urls": ["https://example.com/1.jpg", "https://example.com/2.jpg"],
101
+ "media_type": "image/jpeg"
102
+ }
103
+ ```
104
+
105
+ - `stream`: 是否请求流式响应(可选,默认 false)
106
+ - `media_url`: 单个媒体 URL(可选)
107
+ - `media_urls`: 多个媒体 URL(可选)
108
+ - `media_type`: 媒体 MIME 类型(可选,默认 `application/octet-stream`)
109
+ - `args.text` 和 `media_url` 可以同时存在(文字 + 附件)
110
+
111
+ #### Ack(上行)
112
+
113
+ ```json
114
+ { "id": "cmd_xxx", "type": "ack", "timestamp": 1709827200 }
115
+ ```
116
+
117
+ #### Stream(上行,流式响应)
118
+
119
+ ```json
120
+ {
121
+ "id": "cmd_xxx",
122
+ "type": "stream",
123
+ "timestamp": 1709827200,
124
+ "chunk": "这是一段回复文字",
125
+ "seq": 1,
126
+ "done": false,
127
+ "media_url": "https://example.com/generated.png",
128
+ "media_urls": ["https://example.com/1.png", "https://example.com/2.png"]
129
+ }
130
+ ```
131
+
132
+ - `seq`: 序号,从 1 开始递增
133
+ - `done`: 最后一个 chunk 设为 true
134
+ - `media_url`/`media_urls`: AI 生成的媒体(可选)
135
+
136
+ #### Result(上行,最终结果)
137
+
138
+ ```json
139
+ {
140
+ "id": "cmd_xxx",
141
+ "type": "result",
142
+ "timestamp": 1709827200,
143
+ "status": "success",
144
+ "result": { "text": "完整回复内容" },
145
+ "media_url": "https://example.com/output.png",
146
+ "media_urls": ["https://example.com/1.png"]
147
+ }
148
+ ```
149
+
150
+ ### Supported Media Types / 支持的媒体类型
151
+
152
+ | 类型 | MIME type | 说明 |
153
+ |------|-----------|------|
154
+ | 图片 | `image/jpeg`, `image/png`, `image/webp` | 照片、截图 |
155
+ | GIF | `image/gif` | 动图 |
156
+ | 视频 | `video/mp4`, `video/quicktime` | 视频 |
157
+ | 音频 | `audio/mpeg`, `audio/ogg`, `audio/wav` | 语音/音频 |
158
+ | 文件 | `application/pdf`, `application/octet-stream` | 通用文件 |
159
+
160
+ 不传 `media_type` 时默认为 `application/octet-stream`。
161
+
162
+ ### Behavior / 行为
163
+
164
+ - **Debounce**:500ms 内的多条消息会合并为一条发送给 AI
165
+ - **Sequential Queue**:推理中收到新消息会排队等待当前推理完成后再处理(与 Discord/Slack 一致)
166
+ - **Dedup**:QoS 1 重传的消息会自动去重(基于 command ID)
167
+ - **Auto ID**:Gateway 会自动为空 ID 的命令生成 `cmd_` 前缀的唯一 ID
168
+
55
169
  ## License
56
170
 
57
171
  MIT
@@ -1,3 +1,8 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { pipeline } from "node:stream/promises";
1
6
  import { loadCredentials } from "./config.js";
2
7
  import { registerDevice } from "./register.js";
3
8
  import { ElysDeviceMQTTClient } from "./mqtt-client.js";
@@ -39,6 +44,25 @@ export async function monitorElysProvider(opts) {
39
44
  try {
40
45
  let seq = 0;
41
46
  let fullText = "";
47
+ // Download inbound media (user-sent) to local temp files
48
+ // OpenClaw expects local file paths in MediaPath/MediaUrl, not remote URLs
49
+ const rawMediaUrls = cmd.mediaUrls?.length
50
+ ? cmd.mediaUrls
51
+ : cmd.mediaUrl
52
+ ? [cmd.mediaUrl]
53
+ : [];
54
+ const downloadedPaths = [];
55
+ const downloadedTypes = [];
56
+ for (const url of rawMediaUrls) {
57
+ try {
58
+ const localPath = await downloadToTemp(url, log);
59
+ downloadedPaths.push(localPath);
60
+ downloadedTypes.push(cmd.mediaType ?? guessMediaType(url));
61
+ }
62
+ catch (err) {
63
+ log(`[elys] failed to download media ${url}:`, err);
64
+ }
65
+ }
42
66
  const inboundCtx = finalizeCtx({
43
67
  Body: formatCommandAsText(cmd),
44
68
  BodyForAgent: formatCommandAsText(cmd),
@@ -54,19 +78,36 @@ export async function monitorElysProvider(opts) {
54
78
  CommandAuthorized: true,
55
79
  OriginatingChannel: "elys",
56
80
  OriginatingTo: credentials.deviceId,
81
+ // Inbound media as local file paths
82
+ ...(downloadedPaths.length > 0 && {
83
+ MediaPath: downloadedPaths[0],
84
+ MediaUrl: downloadedPaths[0],
85
+ MediaPaths: downloadedPaths,
86
+ MediaUrls: downloadedPaths,
87
+ MediaType: downloadedTypes[0],
88
+ MediaTypes: downloadedTypes,
89
+ }),
57
90
  });
58
91
  // Deliver callback: stream chunks back via MQTT
59
92
  const wantStream = cmd.stream === true;
60
93
  const deliver = async (payload, info) => {
61
- if (payload.text) {
62
- fullText += payload.text;
94
+ const mediaUrl = payload.mediaUrl?.trim();
95
+ const mediaUrls = payload.mediaUrls?.filter((u) => u?.trim());
96
+ const hasMedia = Boolean(mediaUrl || mediaUrls?.length);
97
+ const media = hasMedia ? { mediaUrl, mediaUrls } : undefined;
98
+ if (payload.text || hasMedia) {
99
+ if (payload.text)
100
+ fullText += payload.text;
63
101
  if (wantStream || info.kind === "final") {
64
102
  seq++;
65
103
  const done = info.kind === "final";
66
- mqttClient.publishStreamChunk(cmd.id, payload.text, seq, done);
104
+ mqttClient.publishStreamChunk(cmd.id, payload.text ?? "", seq, done, media);
105
+ }
106
+ if (hasMedia) {
107
+ log(`[elys] media: ${mediaUrl ?? mediaUrls?.join(", ")}`);
67
108
  }
68
109
  if (info.kind === "block") {
69
- log(`[elys] stream chunk #${seq}: ${payload.text.slice(0, 80)}...`);
110
+ log(`[elys] stream chunk #${seq}: ${(payload.text ?? "").slice(0, 80)}...`);
70
111
  }
71
112
  else if (info.kind === "final") {
72
113
  log(`[elys] final reply delivered`);
@@ -150,3 +191,52 @@ function formatCommandAsText(cmd) {
150
191
  }
151
192
  return parts.join(" ");
152
193
  }
194
+ const MIME_BY_EXT = {
195
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
196
+ ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
197
+ ".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo",
198
+ ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav",
199
+ ".pdf": "application/pdf",
200
+ };
201
+ function guessMediaType(url) {
202
+ const pathname = new URL(url).pathname.toLowerCase();
203
+ for (const [ext, mime] of Object.entries(MIME_BY_EXT)) {
204
+ if (pathname.includes(ext))
205
+ return mime;
206
+ }
207
+ return "application/octet-stream";
208
+ }
209
+ function extFromMimeOrUrl(url, mime) {
210
+ for (const [ext, m] of Object.entries(MIME_BY_EXT)) {
211
+ if (m === mime)
212
+ return ext;
213
+ }
214
+ const pathname = new URL(url).pathname;
215
+ const dot = pathname.lastIndexOf(".");
216
+ if (dot >= 0) {
217
+ const ext = pathname.slice(dot).split(/[?#!/]/)[0];
218
+ if (ext.length <= 6)
219
+ return ext;
220
+ }
221
+ return ".bin";
222
+ }
223
+ let tempDir = null;
224
+ async function downloadToTemp(url, log) {
225
+ if (!tempDir) {
226
+ tempDir = await mkdtemp(join(tmpdir(), "elys-media-"));
227
+ }
228
+ const mime = guessMediaType(url);
229
+ const ext = extFromMimeOrUrl(url, mime);
230
+ const filename = `media_${Date.now()}${ext}`;
231
+ const filePath = join(tempDir, filename);
232
+ log(`[elys] downloading media: ${url} → ${filePath}`);
233
+ const resp = await fetch(url);
234
+ if (!resp.ok || !resp.body) {
235
+ throw new Error(`HTTP ${resp.status} downloading ${url}`);
236
+ }
237
+ const { Readable } = await import("node:stream");
238
+ const nodeStream = Readable.fromWeb(resp.body);
239
+ await pipeline(nodeStream, createWriteStream(filePath));
240
+ log(`[elys] downloaded media: ${filePath}`);
241
+ return filePath;
242
+ }
@@ -37,7 +37,10 @@ export declare class ElysDeviceMQTTClient {
37
37
  connect(abortSignal?: AbortSignal): Promise<void>;
38
38
  disconnect(): void;
39
39
  /** Send a stream chunk (for streaming AI responses) */
40
- publishStreamChunk(commandId: string, chunk: string, seq: number, done: boolean): void;
40
+ publishStreamChunk(commandId: string, chunk: string, seq: number, done: boolean, media?: {
41
+ mediaUrl?: string;
42
+ mediaUrls?: string[];
43
+ }): void;
41
44
  private onMessage;
42
45
  private flushDebounce;
43
46
  private startCommand;
@@ -123,7 +123,7 @@ export class ElysDeviceMQTTClient {
123
123
  }
124
124
  }
125
125
  /** Send a stream chunk (for streaming AI responses) */
126
- publishStreamChunk(commandId, chunk, seq, done) {
126
+ publishStreamChunk(commandId, chunk, seq, done, media) {
127
127
  const msg = {
128
128
  id: commandId,
129
129
  type: "stream",
@@ -132,6 +132,10 @@ export class ElysDeviceMQTTClient {
132
132
  seq,
133
133
  done,
134
134
  };
135
+ if (media?.mediaUrl)
136
+ msg.mediaUrl = media.mediaUrl;
137
+ if (media?.mediaUrls?.length)
138
+ msg.mediaUrls = media.mediaUrls;
135
139
  this.publish(msg);
136
140
  }
137
141
  // ─── Inbound message pipeline: dedup → ack → debounce → abort → execute ───
@@ -25,6 +25,9 @@ export interface CommandMessage extends MQTTBaseMessage {
25
25
  command: string;
26
26
  args?: Record<string, unknown>;
27
27
  stream?: boolean;
28
+ mediaUrl?: string;
29
+ mediaUrls?: string[];
30
+ mediaType?: string;
28
31
  }
29
32
  export interface AckMessage extends MQTTBaseMessage {
30
33
  type: "ack";
@@ -34,10 +37,14 @@ export interface StreamMessage extends MQTTBaseMessage {
34
37
  chunk: string;
35
38
  done: boolean;
36
39
  seq: number;
40
+ mediaUrl?: string;
41
+ mediaUrls?: string[];
37
42
  }
38
43
  export interface ResultMessage extends MQTTBaseMessage {
39
44
  type: "result";
40
45
  status: "success" | "error";
41
46
  result?: Record<string, unknown>;
42
47
  error?: string;
48
+ mediaUrl?: string;
49
+ mediaUrls?: string[];
43
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-elys",
3
- "version": "1.7.7",
3
+ "version": "1.8.1",
4
4
  "description": "OpenClaw Elys channel plugin — connects to Elys App",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",