openclaw-elys 1.8.4 → 1.10.6

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
@@ -147,6 +147,24 @@ OpenClaw 支持 block streaming,需要在 `~/.openclaw/openclaw.json` 中配
147
147
  }
148
148
  ```
149
149
 
150
+ #### Push(上行,主动推送)
151
+
152
+ 定时任务/Cron 触发的主动消息,不关联任何 command。
153
+
154
+ ```json
155
+ {
156
+ "id": "push_xxx",
157
+ "type": "push",
158
+ "timestamp": 1709827200,
159
+ "text": "这是定时任务的结果",
160
+ "media_url": "https://example.com/generated.png",
161
+ "media_urls": ["https://example.com/1.png"]
162
+ }
163
+ ```
164
+
165
+ - 与 `stream` 的区别:`push` 没有对应的 command_id,是 AI 主动发起的消息
166
+ - 场景:用户设置了"每天早上给我一句励志名言"等定时任务
167
+
150
168
  ### Supported Media Types / 支持的媒体类型
151
169
 
152
170
  | 类型 | MIME type | 说明 |
package/dist/index.d.ts CHANGED
@@ -3,8 +3,9 @@ export { elysPlugin } from "./src/channel.js";
3
3
  export { monitorElysProvider } from "./src/monitor.js";
4
4
  export { registerDevice } from "./src/register.js";
5
5
  export { ElysDeviceMQTTClient } from "./src/mqtt-client.js";
6
+ export { TOSUploader } from "./src/tos-upload.js";
6
7
  export { loadCredentials, saveCredentials, deleteCredentials, loadGatewayUrl, } from "./src/config.js";
7
- export type { DeviceCredentials, CommandMessage, AckMessage, ResultMessage, ElysConfig, } from "./src/types.js";
8
+ export type { DeviceCredentials, CommandMessage, AckMessage, StreamMessage, PushMessage, ElysConfig, } from "./src/types.js";
8
9
  declare const plugin: {
9
10
  id: string;
10
11
  name: string;
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ export { elysPlugin } from "./src/channel.js";
4
4
  export { monitorElysProvider } from "./src/monitor.js";
5
5
  export { registerDevice } from "./src/register.js";
6
6
  export { ElysDeviceMQTTClient } from "./src/mqtt-client.js";
7
+ export { TOSUploader } from "./src/tos-upload.js";
7
8
  export { loadCredentials, saveCredentials, deleteCredentials, loadGatewayUrl, } from "./src/config.js";
8
9
  const plugin = {
9
10
  id: "openclaw-elys",
@@ -13,6 +14,39 @@ const plugin = {
13
14
  register(api) {
14
15
  setElysRuntime(api.runtime);
15
16
  api.registerChannel({ plugin: elysPlugin });
17
+ // Diagnostic: intercept registry changes
18
+ try {
19
+ const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
20
+ const g = globalThis;
21
+ const state = g[REGISTRY_STATE];
22
+ if (state && !state.__elysProxied) {
23
+ state.__elysProxied = true;
24
+ const origRegistry = state.registry;
25
+ let registryRef = origRegistry;
26
+ Object.defineProperty(state, "registry", {
27
+ get() {
28
+ return registryRef;
29
+ },
30
+ set(newVal) {
31
+ const oldChannels = registryRef?.channels?.length ?? 0;
32
+ const newChannels = newVal?.channels?.length ?? 0;
33
+ const newHasElys = newVal?.channels?.some((e) => e.plugin.id === "elys") ?? false;
34
+ const newElysOutbound = newVal?.channels?.find((e) => e.plugin.id === "elys")?.plugin?.outbound;
35
+ const newElysSendText = !!newElysOutbound?.sendText;
36
+ console.log(`[elys-diag] REGISTRY SET! oldChannels=${oldChannels} newChannels=${newChannels} ` +
37
+ `newHasElys=${newHasElys} newElysSendText=${newElysSendText} ` +
38
+ `newChannelIds=${newVal?.channels?.map((c) => c.plugin.id).join(",") ?? "none"} ` +
39
+ `stack=${new Error().stack?.split("\n").slice(1, 6).join(" | ")}`);
40
+ registryRef = newVal;
41
+ },
42
+ configurable: true,
43
+ enumerable: true,
44
+ });
45
+ }
46
+ }
47
+ catch (err) {
48
+ console.log(`[elys-diag] proxy setup failed: ${err}`);
49
+ }
16
50
  },
17
51
  };
18
52
  export default plugin;
@@ -57,17 +57,48 @@ export declare const elysPlugin: {
57
57
  gatewayUrl: string;
58
58
  };
59
59
  };
60
+ messaging: {
61
+ targetResolver: {
62
+ hint: string;
63
+ looksLikeId: (raw: string) => boolean;
64
+ };
65
+ };
60
66
  outbound: {
61
67
  deliveryMode: "direct";
62
68
  textChunkLimit: number;
69
+ sendPayload: (ctx: {
70
+ cfg: Record<string, unknown>;
71
+ to: string;
72
+ text: string;
73
+ accountId?: string | null;
74
+ payload: {
75
+ text?: string;
76
+ mediaUrl?: string;
77
+ mediaUrls?: string[];
78
+ [key: string]: unknown;
79
+ };
80
+ }) => Promise<{
81
+ channel: string;
82
+ messageId: string;
83
+ }>;
63
84
  sendText: (ctx: {
64
85
  cfg: Record<string, unknown>;
65
86
  to: string;
66
87
  text: string;
67
88
  accountId?: string | null;
68
89
  }) => Promise<{
90
+ channel: string;
69
91
  messageId: string;
92
+ }>;
93
+ sendMedia: (ctx: {
94
+ cfg: Record<string, unknown>;
95
+ to: string;
96
+ text: string;
97
+ mediaUrl: string;
98
+ accountId?: string | null;
99
+ }) => Promise<{
70
100
  channel: string;
101
+ messageId: string;
71
102
  }>;
72
103
  };
73
104
  gateway: {
@@ -15,7 +15,7 @@ export const elysPlugin = {
15
15
  chatTypes: ["direct"],
16
16
  polls: false,
17
17
  threads: false,
18
- media: false,
18
+ media: true,
19
19
  reactions: false,
20
20
  edit: false,
21
21
  reply: true,
@@ -44,6 +44,12 @@ export const elysPlugin = {
44
44
  gatewayUrl: account.gatewayUrl,
45
45
  }),
46
46
  },
47
+ messaging: {
48
+ targetResolver: {
49
+ hint: "Use a device ID like d_xxxx",
50
+ looksLikeId: (raw) => /^d_[a-f0-9]+$/i.test(raw.trim()),
51
+ },
52
+ },
47
53
  outbound: elysOutbound,
48
54
  gateway: {
49
55
  startAccount: async (ctx) => {
@@ -1,12 +1,12 @@
1
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";
2
+ import { access } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
5
4
  import { pipeline } from "node:stream/promises";
6
5
  import { loadCredentials } from "./config.js";
7
6
  import { registerDevice } from "./register.js";
8
7
  import { ElysDeviceMQTTClient } from "./mqtt-client.js";
9
- import { getElysRuntime } from "./runtime.js";
8
+ import { getElysRuntime, setSharedMqttClient } from "./runtime.js";
9
+ import { TOSUploader } from "./tos-upload.js";
10
10
  /**
11
11
  * The main monitor loop for the Elys channel.
12
12
  * Ensures device is registered, then connects to MQTT and dispatches
@@ -32,6 +32,8 @@ export async function monitorElysProvider(opts) {
32
32
  }
33
33
  // 2. Connect MQTT
34
34
  const mqttClient = new ElysDeviceMQTTClient(credentials, log);
35
+ // 2.5. Initialize TOS uploader for media uploads
36
+ const tosUploader = new TOSUploader(gatewayUrl, credentials.deviceToken, log);
35
37
  // 3. Set up command handler using PluginRuntime (same pattern as feishu)
36
38
  const core = getElysRuntime();
37
39
  const dispatchReplyFromConfig = core?.channel?.reply?.dispatchReplyFromConfig;
@@ -39,138 +41,166 @@ export async function monitorElysProvider(opts) {
39
41
  const finalizeCtx = core?.channel?.reply?.finalizeInboundContext;
40
42
  log(`[elys] pluginRuntime available: ${!!core}, dispatchReplyFromConfig: ${!!dispatchReplyFromConfig}, createDispatcher: ${!!createDispatcher}, finalizeCtx: ${!!finalizeCtx}`);
41
43
  const commandHandler = async (cmd, signal) => {
42
- log(`[elys] executing command: ${cmd.command}`, cmd.args, `media_url=${cmd.media_url ?? "none"}`, `media_urls=${JSON.stringify(cmd.media_urls ?? [])}`);
44
+ log(`[elys] executing command: ${cmd.command} args=${JSON.stringify(cmd.args)} media_url=${cmd.media_url ?? "none"} media_urls=${JSON.stringify(cmd.media_urls ?? [])}`);
43
45
  if (dispatchReplyFromConfig && finalizeCtx && createDispatcher) {
44
- try {
45
- let seq = 0;
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.media_urls?.length
50
- ? cmd.media_urls
51
- : cmd.media_url
52
- ? [cmd.media_url]
53
- : [];
54
- const downloadedPaths = [];
55
- const downloadedTypes = [];
56
- for (const url of rawMediaUrls) {
46
+ let seq = 0;
47
+ let sentDone = false;
48
+ // Download inbound media (user-sent) to local temp files
49
+ // OpenClaw expects local file paths in MediaPath/MediaUrl, not remote URLs
50
+ const rawMediaUrls = cmd.media_urls?.length
51
+ ? cmd.media_urls
52
+ : cmd.media_url
53
+ ? [cmd.media_url]
54
+ : [];
55
+ const downloadedPaths = [];
56
+ const downloadedTypes = [];
57
+ for (const url of rawMediaUrls) {
58
+ try {
59
+ const localPath = await downloadToTemp(url, log);
60
+ downloadedPaths.push(localPath);
61
+ downloadedTypes.push(cmd.media_type ?? guessMediaType(url));
62
+ }
63
+ catch (err) {
64
+ log(`[elys] failed to download media ${url}:`, err);
65
+ }
66
+ }
67
+ const inboundCtx = finalizeCtx({
68
+ Body: formatCommandAsText(cmd),
69
+ BodyForAgent: formatCommandAsText(cmd),
70
+ RawBody: formatCommandAsText(cmd),
71
+ From: credentials.deviceId,
72
+ To: credentials.deviceId,
73
+ Surface: "elys",
74
+ Provider: "elys",
75
+ ChatType: "direct",
76
+ MessageSid: cmd.id,
77
+ AccountId: "default",
78
+ SessionKey: `elys:${credentials.deviceId}`,
79
+ CommandAuthorized: true,
80
+ OriginatingChannel: "elys",
81
+ OriginatingTo: credentials.deviceId,
82
+ // Inbound media as local file paths
83
+ ...(downloadedPaths.length > 0 && {
84
+ MediaPath: downloadedPaths[0],
85
+ MediaUrl: downloadedPaths[0],
86
+ MediaPaths: downloadedPaths,
87
+ MediaUrls: downloadedPaths,
88
+ MediaType: downloadedTypes[0],
89
+ MediaTypes: downloadedTypes,
90
+ }),
91
+ });
92
+ // Deliver callback: always send stream chunks via MQTT
93
+ const deliver = async (payload, info) => {
94
+ log(`[elys] deliver: kind=${info.kind} text=${(payload.text ?? "").slice(0, 120)} mediaUrl=${payload.mediaUrl ?? "none"}`);
95
+ let mediaUrl = payload.mediaUrl?.trim();
96
+ let mediaUrls = payload.mediaUrls?.filter((u) => u?.trim());
97
+ // Extract MEDIA: paths from text (OpenClaw embeds them as "MEDIA: /path/to/file")
98
+ let text = payload.text ?? "";
99
+ if (!mediaUrl && text.includes("MEDIA:")) {
100
+ const extracted = extractMediaPaths(text);
101
+ if (extracted.paths.length > 0) {
102
+ mediaUrl = extracted.paths[0];
103
+ if (extracted.paths.length > 1) {
104
+ mediaUrls = [...(mediaUrls ?? []), ...extracted.paths.slice(1)];
105
+ }
106
+ text = extracted.cleanText;
107
+ }
108
+ }
109
+ // Upload local media files to TOS (resolve relative paths first)
110
+ if (mediaUrl && isLocalPath(mediaUrl)) {
57
111
  try {
58
- const localPath = await downloadToTemp(url, log);
59
- downloadedPaths.push(localPath);
60
- downloadedTypes.push(cmd.media_type ?? guessMediaType(url));
112
+ const resolved = await resolveMediaPath(mediaUrl);
113
+ log(`[elys] resolved media path: ${mediaUrl} → ${resolved}`);
114
+ mediaUrl = await tosUploader.uploadFile(resolved);
61
115
  }
62
116
  catch (err) {
63
- log(`[elys] failed to download media ${url}:`, err);
117
+ log(`[elys] TOS upload failed for ${mediaUrl}:`, err);
64
118
  }
65
119
  }
66
- const inboundCtx = finalizeCtx({
67
- Body: formatCommandAsText(cmd),
68
- BodyForAgent: formatCommandAsText(cmd),
69
- RawBody: formatCommandAsText(cmd),
70
- From: credentials.deviceId,
71
- To: credentials.deviceId,
72
- Surface: "elys",
73
- Provider: "elys",
74
- ChatType: "direct",
75
- MessageSid: cmd.id,
76
- AccountId: "default",
77
- SessionKey: `elys:${credentials.deviceId}`,
78
- CommandAuthorized: true,
79
- OriginatingChannel: "elys",
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
- }),
90
- });
91
- // Deliver callback: stream chunks back via MQTT
92
- const wantStream = cmd.stream === true;
93
- const deliver = async (payload, info) => {
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;
101
- if (wantStream || info.kind === "final") {
102
- seq++;
103
- const done = info.kind === "final";
104
- mqttClient.publishStreamChunk(cmd.id, payload.text ?? "", seq, done, media);
105
- }
106
- if (hasMedia) {
107
- log(`[elys] media: ${mediaUrl ?? mediaUrls?.join(", ")}`);
108
- }
109
- if (info.kind === "block") {
110
- log(`[elys] stream chunk #${seq}: ${(payload.text ?? "").slice(0, 80)}...`);
111
- }
112
- else if (info.kind === "final") {
113
- log(`[elys] final reply delivered`);
120
+ if (mediaUrls?.length) {
121
+ mediaUrls = await Promise.all(mediaUrls.map(async (u) => {
122
+ if (isLocalPath(u)) {
123
+ try {
124
+ const resolved = await resolveMediaPath(u);
125
+ return await tosUploader.uploadFile(resolved);
126
+ }
127
+ catch (err) {
128
+ log(`[elys] TOS upload failed for ${u}:`, err);
129
+ return u;
130
+ }
114
131
  }
132
+ return u;
133
+ }));
134
+ }
135
+ const hasMedia = Boolean(mediaUrl || mediaUrls?.length);
136
+ const media = hasMedia ? { mediaUrl, mediaUrls } : undefined;
137
+ const done = info.kind === "final";
138
+ if (text || hasMedia) {
139
+ seq++;
140
+ mqttClient.publishStreamChunk(cmd.id, text, seq, done, {
141
+ ...(media ?? {}),
142
+ ...(done && { status: "success" }),
143
+ });
144
+ if (done)
145
+ sentDone = true;
146
+ if (hasMedia) {
147
+ log(`[elys] media: ${mediaUrl ?? mediaUrls?.join(", ")}`);
115
148
  }
116
- else if (info.kind === "final") {
117
- if (wantStream) {
118
- seq++;
119
- mqttClient.publishStreamChunk(cmd.id, "", seq, true);
120
- }
121
- log(`[elys] final reply delivered (empty)`);
149
+ if (done) {
150
+ log(`[elys] final reply delivered`);
151
+ }
152
+ else {
153
+ log(`[elys] stream chunk #${seq}: ${text.slice(0, 80)}...`);
122
154
  }
123
- };
124
- // Create dispatcher + dispatch (same pattern as feishu built-in channel)
125
- const { dispatcher, replyOptions, markDispatchIdle } = createDispatcher({
126
- deliver,
127
- onError: (err, info) => {
128
- log(`[elys] dispatch error (${info.kind}):`, err);
129
- },
130
- });
131
- try {
132
- await dispatchReplyFromConfig({
133
- ctx: inboundCtx,
134
- cfg: opts.config,
135
- dispatcher,
136
- replyOptions,
137
- });
138
155
  }
139
- finally {
140
- dispatcher.markComplete();
141
- await dispatcher.waitForIdle().catch(() => { });
142
- markDispatchIdle();
156
+ else if (done) {
157
+ seq++;
158
+ mqttClient.publishStreamChunk(cmd.id, "", seq, true, { status: "success" });
159
+ sentDone = true;
160
+ log(`[elys] final reply delivered (empty)`);
143
161
  }
144
- return {
145
- id: cmd.id,
146
- type: "result",
147
- timestamp: Math.floor(Date.now() / 1000),
148
- status: "success",
149
- result: { text: fullText || "done" },
150
- };
162
+ };
163
+ // Create dispatcher + dispatch (same pattern as feishu built-in channel)
164
+ const { dispatcher, replyOptions, markDispatchIdle } = createDispatcher({
165
+ deliver,
166
+ onError: (err, info) => {
167
+ log(`[elys] dispatch error (${info.kind}):`, err);
168
+ },
169
+ });
170
+ try {
171
+ await dispatchReplyFromConfig({
172
+ ctx: inboundCtx,
173
+ cfg: opts.config,
174
+ dispatcher,
175
+ replyOptions,
176
+ });
151
177
  }
152
178
  catch (err) {
153
179
  log(`[elys] dispatch error:`, err);
154
- return {
155
- id: cmd.id,
156
- type: "result",
157
- timestamp: Math.floor(Date.now() / 1000),
158
- status: "error",
159
- error: err instanceof Error ? err.message : String(err),
160
- };
180
+ const errMsg = err instanceof Error ? err.message : String(err);
181
+ seq++;
182
+ mqttClient.publishStreamChunk(cmd.id, errMsg, seq, true, { status: "error", error: errMsg });
183
+ sentDone = true;
184
+ }
185
+ finally {
186
+ dispatcher.markComplete();
187
+ await dispatcher.waitForIdle().catch(() => { });
188
+ markDispatchIdle();
161
189
  }
190
+ // Safety: ensure done=true is always sent
191
+ if (!sentDone) {
192
+ seq++;
193
+ mqttClient.publishStreamChunk(cmd.id, "", seq, true, { status: "success" });
194
+ log(`[elys] sent final done=true (safety)`);
195
+ }
196
+ return;
162
197
  }
163
198
  // Fallback: echo the command back (no pluginRuntime available)
164
199
  log(`[elys] no pluginRuntime — using fallback echo handler`);
165
- return {
166
- id: cmd.id,
167
- type: "result",
168
- timestamp: Math.floor(Date.now() / 1000),
169
- status: "success",
170
- result: { text: `command received: ${cmd.command}` },
171
- };
200
+ mqttClient.publishStreamChunk(cmd.id, `command received: ${cmd.command}`, 1, true, { status: "success" });
172
201
  };
173
202
  mqttClient.setCommandHandler(commandHandler);
203
+ setSharedMqttClient(mqttClient);
174
204
  await mqttClient.connect(opts.abortSignal);
175
205
  // 4. Keep alive until abort
176
206
  if (opts.abortSignal) {
@@ -220,15 +250,20 @@ function extFromMimeOrUrl(url, mime) {
220
250
  }
221
251
  return ".bin";
222
252
  }
223
- let tempDir = null;
253
+ let mediaDir = null;
224
254
  async function downloadToTemp(url, log) {
225
- if (!tempDir) {
226
- tempDir = await mkdtemp(join(tmpdir(), "elys-media-"));
255
+ if (!mediaDir) {
256
+ // Use OpenClaw's media directory (~/.openclaw/media/) so that
257
+ // the image loader's allowlist check passes.
258
+ const { homedir } = await import("node:os");
259
+ const openclawMediaDir = join(homedir(), ".openclaw", "media");
260
+ await import("node:fs/promises").then((fs) => fs.mkdir(openclawMediaDir, { recursive: true }));
261
+ mediaDir = openclawMediaDir;
227
262
  }
228
263
  const mime = guessMediaType(url);
229
264
  const ext = extFromMimeOrUrl(url, mime);
230
265
  const filename = `media_${Date.now()}${ext}`;
231
- const filePath = join(tempDir, filename);
266
+ const filePath = join(mediaDir, filename);
232
267
  log(`[elys] downloading media: ${url} → ${filePath}`);
233
268
  const resp = await fetch(url);
234
269
  if (!resp.ok || !resp.body) {
@@ -240,3 +275,77 @@ async function downloadToTemp(url, log) {
240
275
  log(`[elys] downloaded media: ${filePath}`);
241
276
  return filePath;
242
277
  }
278
+ function isLocalPath(p) {
279
+ return p.startsWith("/") || p.startsWith("./") || p.startsWith("../");
280
+ }
281
+ /**
282
+ * Resolve OpenClaw state directory.
283
+ * Follows the same logic as OpenClaw core: OPENCLAW_STATE_DIR env or ~/.openclaw.
284
+ */
285
+ function resolveOpenClawStateDir() {
286
+ if (process.env.OPENCLAW_STATE_DIR) {
287
+ return process.env.OPENCLAW_STATE_DIR;
288
+ }
289
+ const { homedir } = require("node:os");
290
+ // Support --profile via OPENCLAW_PROFILE env (e.g. ~/.openclaw-dev)
291
+ const profile = process.env.OPENCLAW_PROFILE;
292
+ if (profile) {
293
+ return join(homedir(), `.openclaw-${profile}`);
294
+ }
295
+ return join(homedir(), ".openclaw");
296
+ }
297
+ /**
298
+ * Resolve a relative media path to an absolute path.
299
+ * OpenClaw skills output paths relative to the agent workspace.
300
+ * We search all workspace* dirs under the state dir, plus media/ and sandboxes/.
301
+ */
302
+ async function resolveMediaPath(p) {
303
+ if (p.startsWith("/"))
304
+ return p;
305
+ const { readdir } = await import("node:fs/promises");
306
+ const stateDir = resolveOpenClawStateDir();
307
+ const filename = p.replace(/^\.\//, "").replace(/^\.\.\//, "");
308
+ // Search candidate dirs: all workspace* dirs, media, sandboxes, agents
309
+ const candidates = [];
310
+ try {
311
+ const entries = await readdir(stateDir, { withFileTypes: true });
312
+ for (const e of entries) {
313
+ if (e.isDirectory() && (e.name.startsWith("workspace") ||
314
+ e.name === "media" ||
315
+ e.name === "sandboxes" ||
316
+ e.name === "agents")) {
317
+ candidates.push(join(stateDir, e.name));
318
+ }
319
+ }
320
+ }
321
+ catch {
322
+ // ignore
323
+ }
324
+ // Also try CWD
325
+ candidates.push(process.cwd());
326
+ for (const dir of candidates) {
327
+ const fullPath = join(dir, filename);
328
+ try {
329
+ await access(fullPath);
330
+ return fullPath;
331
+ }
332
+ catch {
333
+ // not found, try next
334
+ }
335
+ }
336
+ return resolve(p);
337
+ }
338
+ /**
339
+ * Extract MEDIA: file paths from text.
340
+ * OpenClaw embeds media references as "MEDIA: /path/to/file.png" in response text.
341
+ */
342
+ function extractMediaPaths(text) {
343
+ const mediaRegex = /MEDIA:\s*(\S+)/g;
344
+ const paths = [];
345
+ let match;
346
+ while ((match = mediaRegex.exec(text)) !== null) {
347
+ paths.push(match[1]);
348
+ }
349
+ const cleanText = text.replace(/MEDIA:\s*\S+/g, "").trim();
350
+ return { paths, cleanText };
351
+ }
@@ -1,5 +1,5 @@
1
- import type { DeviceCredentials, CommandMessage, ResultMessage } from "./types.js";
2
- export type CommandHandler = (cmd: CommandMessage, signal: AbortSignal) => Promise<ResultMessage>;
1
+ import type { DeviceCredentials, CommandMessage } from "./types.js";
2
+ export type CommandHandler = (cmd: CommandMessage, signal: AbortSignal) => Promise<void>;
3
3
  export interface MQTTClientOptions {
4
4
  /** Debounce window in ms. Rapid messages within this window are merged. Default: 500 */
5
5
  debounceMs?: number;
@@ -37,9 +37,11 @@ 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, media?: {
40
+ publishStreamChunk(commandId: string, chunk: string, seq: number, done: boolean, opts?: {
41
41
  mediaUrl?: string;
42
42
  mediaUrls?: string[];
43
+ status?: "success" | "error";
44
+ error?: string;
43
45
  }): void;
44
46
  private onMessage;
45
47
  private flushDebounce;
@@ -49,6 +51,13 @@ export declare class ElysDeviceMQTTClient {
49
51
  private mergeCommands;
50
52
  private executeCommand;
51
53
  private cleanupDedup;
54
+ /** Send a proactive push message (not a reply to any command) */
55
+ publishPush(text: string, opts?: {
56
+ mediaUrl?: string;
57
+ mediaUrls?: string[];
58
+ }): void;
59
+ /** Check if connected */
60
+ get connected(): boolean;
52
61
  private publishAck;
53
62
  private publish;
54
63
  }
@@ -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, media) {
126
+ publishStreamChunk(commandId, chunk, seq, done, opts) {
127
127
  const msg = {
128
128
  id: commandId,
129
129
  type: "stream",
@@ -132,10 +132,14 @@ export class ElysDeviceMQTTClient {
132
132
  seq,
133
133
  done,
134
134
  };
135
- if (media?.mediaUrl)
136
- msg.media_url = media.mediaUrl;
137
- if (media?.mediaUrls?.length)
138
- msg.media_urls = media.mediaUrls;
135
+ if (opts?.mediaUrl)
136
+ msg.media_url = opts.mediaUrl;
137
+ if (opts?.mediaUrls?.length)
138
+ msg.media_urls = opts.mediaUrls;
139
+ if (opts?.status)
140
+ msg.status = opts.status;
141
+ if (opts?.error)
142
+ msg.error = opts.error;
139
143
  this.publish(msg);
140
144
  }
141
145
  // ─── Inbound message pipeline: dedup → ack → debounce → abort → execute ───
@@ -245,7 +249,7 @@ export class ElysDeviceMQTTClient {
245
249
  if (!this.commandHandler)
246
250
  return;
247
251
  try {
248
- const result = await Promise.race([
252
+ await Promise.race([
249
253
  this.commandHandler(cmd, signal),
250
254
  new Promise((_, reject) => {
251
255
  const timer = setTimeout(() => {
@@ -254,7 +258,6 @@ export class ElysDeviceMQTTClient {
254
258
  signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
255
259
  }),
256
260
  ]);
257
- this.publish(result);
258
261
  }
259
262
  catch (err) {
260
263
  if (signal.aborted) {
@@ -262,13 +265,7 @@ export class ElysDeviceMQTTClient {
262
265
  return;
263
266
  }
264
267
  const errMsg = err instanceof Error ? err.message : String(err);
265
- this.publish({
266
- id: cmd.id,
267
- type: "result",
268
- timestamp: Math.floor(Date.now() / 1000),
269
- status: "error",
270
- error: errMsg,
271
- });
268
+ this.publishStreamChunk(cmd.id, errMsg, 1, true, { status: "error", error: errMsg });
272
269
  }
273
270
  }
274
271
  cleanupDedup() {
@@ -279,6 +276,25 @@ export class ElysDeviceMQTTClient {
279
276
  }
280
277
  }
281
278
  }
279
+ /** Send a proactive push message (not a reply to any command) */
280
+ publishPush(text, opts) {
281
+ const msg = {
282
+ id: `push_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
283
+ type: "push",
284
+ timestamp: Math.floor(Date.now() / 1000),
285
+ text,
286
+ };
287
+ if (opts?.mediaUrl)
288
+ msg.media_url = opts.mediaUrl;
289
+ if (opts?.mediaUrls?.length)
290
+ msg.media_urls = opts.mediaUrls;
291
+ this.publish(msg);
292
+ this.log(`[elys] published push message: ${msg.id}`);
293
+ }
294
+ /** Check if connected */
295
+ get connected() {
296
+ return this.client?.connected ?? false;
297
+ }
282
298
  // ─── Outbound helpers ───
283
299
  publishAck(commandId) {
284
300
  const msg = {
@@ -1,28 +1,38 @@
1
- /**
2
- * Send a text result back to the gateway via MQTT upstream.
3
- * This is used by the outbound adapter when OpenClaw agent generates a response.
4
- *
5
- * In this channel the outbound is handled primarily through the MQTT result messages
6
- * inside the monitor's command handler. This outbound adapter is the HTTP fallback
7
- * for cases where we need to push a proactive message.
8
- */
9
- export declare function sendTextToGateway(params: {
10
- gatewayUrl: string;
11
- text: string;
12
- deviceId?: string;
13
- }): Promise<{
14
- messageId: string;
15
- }>;
16
1
  export declare const elysOutbound: {
17
2
  deliveryMode: "direct";
18
3
  textChunkLimit: number;
4
+ sendPayload: (ctx: {
5
+ cfg: Record<string, unknown>;
6
+ to: string;
7
+ text: string;
8
+ accountId?: string | null;
9
+ payload: {
10
+ text?: string;
11
+ mediaUrl?: string;
12
+ mediaUrls?: string[];
13
+ [key: string]: unknown;
14
+ };
15
+ }) => Promise<{
16
+ channel: string;
17
+ messageId: string;
18
+ }>;
19
19
  sendText: (ctx: {
20
20
  cfg: Record<string, unknown>;
21
21
  to: string;
22
22
  text: string;
23
23
  accountId?: string | null;
24
24
  }) => Promise<{
25
+ channel: string;
25
26
  messageId: string;
27
+ }>;
28
+ sendMedia: (ctx: {
29
+ cfg: Record<string, unknown>;
30
+ to: string;
31
+ text: string;
32
+ mediaUrl: string;
33
+ accountId?: string | null;
34
+ }) => Promise<{
26
35
  channel: string;
36
+ messageId: string;
27
37
  }>;
28
38
  };
@@ -1,44 +1,85 @@
1
1
  import { loadCredentials } from "./config.js";
2
+ import { getSharedMqttClient } from "./runtime.js";
3
+ import { TOSUploader } from "./tos-upload.js";
4
+ let cachedUploader = null;
5
+ function isLocalPath(p) {
6
+ return p.startsWith("/") || p.startsWith("./") || p.startsWith("../");
7
+ }
2
8
  /**
3
- * Send a text result back to the gateway via MQTT upstream.
4
- * This is used by the outbound adapter when OpenClaw agent generates a response.
5
- *
6
- * In this channel the outbound is handled primarily through the MQTT result messages
7
- * inside the monitor's command handler. This outbound adapter is the HTTP fallback
8
- * for cases where we need to push a proactive message.
9
+ * Upload local media paths to TOS, returns remote URLs.
9
10
  */
10
- export async function sendTextToGateway(params) {
11
- const credentials = loadCredentials();
12
- if (!credentials) {
13
- throw new Error("Elys plugin: device not registered");
14
- }
15
- const deviceId = params.deviceId ?? credentials.deviceId;
16
- const resp = await fetch(`${params.gatewayUrl}/api/v1/message/send`, {
17
- method: "POST",
18
- headers: { "Content-Type": "application/json" },
19
- body: JSON.stringify({
20
- device_id: deviceId,
21
- id: `msg_${Date.now()}`,
22
- command: "elys.reply",
23
- args: { text: params.text },
24
- }),
25
- });
26
- if (!resp.ok) {
27
- throw new Error(`Failed to send message: ${resp.status}`);
11
+ async function resolveMediaUrls(gatewayUrl, deviceToken, urls) {
12
+ if (urls.length === 0)
13
+ return [];
14
+ if (!cachedUploader) {
15
+ cachedUploader = new TOSUploader(gatewayUrl, deviceToken);
28
16
  }
29
- return { messageId: `msg_${Date.now()}` };
17
+ return Promise.all(urls.map(async (u) => {
18
+ if (isLocalPath(u)) {
19
+ try {
20
+ return await cachedUploader.uploadFile(u);
21
+ }
22
+ catch {
23
+ return u;
24
+ }
25
+ }
26
+ return u;
27
+ }));
30
28
  }
31
29
  export const elysOutbound = {
32
30
  deliveryMode: "direct",
33
31
  textChunkLimit: 4000,
32
+ sendPayload: async (ctx) => {
33
+ const mqttClient = getSharedMqttClient();
34
+ if (!mqttClient || !mqttClient.connected) {
35
+ throw new Error("Elys plugin: MQTT client not connected");
36
+ }
37
+ const credentials = loadCredentials();
38
+ const elysCfg = ctx.cfg?.channels?.elys;
39
+ const gatewayUrl = (elysCfg?.gatewayUrl ?? "http://localhost:8080").replace(/\/+$/, "");
40
+ const deviceToken = credentials?.deviceToken ?? "";
41
+ const text = ctx.payload.text ?? ctx.text ?? "";
42
+ // Collect media URLs
43
+ let mediaUrls = ctx.payload.mediaUrls?.length
44
+ ? [...ctx.payload.mediaUrls]
45
+ : ctx.payload.mediaUrl
46
+ ? [ctx.payload.mediaUrl]
47
+ : [];
48
+ // Upload local paths to TOS
49
+ if (deviceToken) {
50
+ mediaUrls = await resolveMediaUrls(gatewayUrl, deviceToken, mediaUrls);
51
+ }
52
+ mqttClient.publishPush(text, {
53
+ mediaUrl: mediaUrls[0],
54
+ mediaUrls: mediaUrls.length > 1 ? mediaUrls : undefined,
55
+ });
56
+ return { channel: "elys", messageId: `push_${Date.now()}` };
57
+ },
34
58
  sendText: async (ctx) => {
59
+ const mqttClient = getSharedMqttClient();
60
+ if (!mqttClient || !mqttClient.connected) {
61
+ throw new Error("Elys plugin: MQTT client not connected");
62
+ }
63
+ mqttClient.publishPush(ctx.text);
64
+ return { channel: "elys", messageId: `push_${Date.now()}` };
65
+ },
66
+ sendMedia: async (ctx) => {
67
+ const mqttClient = getSharedMqttClient();
68
+ if (!mqttClient || !mqttClient.connected) {
69
+ throw new Error("Elys plugin: MQTT client not connected");
70
+ }
71
+ const credentials = loadCredentials();
35
72
  const elysCfg = ctx.cfg?.channels?.elys;
36
- const gatewayUrl = elysCfg?.gatewayUrl ?? "http://localhost:8080";
37
- const result = await sendTextToGateway({
38
- gatewayUrl,
39
- text: ctx.text,
40
- deviceId: ctx.to,
73
+ const gatewayUrl = (elysCfg?.gatewayUrl ?? "http://localhost:8080").replace(/\/+$/, "");
74
+ const deviceToken = credentials?.deviceToken ?? "";
75
+ let mediaUrl = ctx.mediaUrl;
76
+ if (deviceToken && mediaUrl && isLocalPath(mediaUrl)) {
77
+ const resolved = await resolveMediaUrls(gatewayUrl, deviceToken, [mediaUrl]);
78
+ mediaUrl = resolved[0] ?? mediaUrl;
79
+ }
80
+ mqttClient.publishPush(ctx.text ?? "", {
81
+ mediaUrl,
41
82
  });
42
- return { channel: "elys", ...result };
83
+ return { channel: "elys", messageId: `push_${Date.now()}` };
43
84
  },
44
85
  };
@@ -1,2 +1,5 @@
1
1
  export declare function setElysRuntime(next: unknown): void;
2
2
  export declare function getElysRuntime(): any;
3
+ import type { ElysDeviceMQTTClient } from "./mqtt-client.js";
4
+ export declare function setSharedMqttClient(client: ElysDeviceMQTTClient | null): void;
5
+ export declare function getSharedMqttClient(): ElysDeviceMQTTClient | null;
@@ -8,3 +8,10 @@ export function setElysRuntime(next) {
8
8
  export function getElysRuntime() {
9
9
  return runtime;
10
10
  }
11
+ let sharedMqttClient = null;
12
+ export function setSharedMqttClient(client) {
13
+ sharedMqttClient = client;
14
+ }
15
+ export function getSharedMqttClient() {
16
+ return sharedMqttClient;
17
+ }
@@ -0,0 +1,31 @@
1
+ export interface STSTokenResponse {
2
+ access_key_id: string;
3
+ access_key_secret: string;
4
+ security_token: string;
5
+ expiration: string;
6
+ bucket: string;
7
+ endpoint: string;
8
+ region: string;
9
+ cdn_host?: string;
10
+ upload_prefix: string;
11
+ }
12
+ /**
13
+ * TOS uploader that caches STS tokens and uploads media files.
14
+ * Uses Volcengine TOS native signing (TOS4-HMAC-SHA256), not AWS S3 signing.
15
+ */
16
+ export declare class TOSUploader {
17
+ private gatewayUrl;
18
+ private deviceToken;
19
+ private cachedToken;
20
+ private log;
21
+ constructor(gatewayUrl: string, deviceToken: string, log?: (...args: unknown[]) => void);
22
+ /**
23
+ * Upload a local file to TOS, returns the public URL (CDN or bucket URL).
24
+ */
25
+ uploadFile(localPath: string): Promise<string>;
26
+ private getToken;
27
+ /**
28
+ * Upload to TOS using PutObject with TOS V4 signing (TOS4-HMAC-SHA256).
29
+ */
30
+ private putObject;
31
+ }
@@ -0,0 +1,158 @@
1
+ import { createHmac, createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { extname } from "node:path";
4
+ const MIME_MAP = {
5
+ ".jpg": "image/jpeg",
6
+ ".jpeg": "image/jpeg",
7
+ ".png": "image/png",
8
+ ".gif": "image/gif",
9
+ ".webp": "image/webp",
10
+ ".mp4": "video/mp4",
11
+ ".mp3": "audio/mpeg",
12
+ ".wav": "audio/wav",
13
+ ".pdf": "application/pdf",
14
+ };
15
+ /**
16
+ * TOS uploader that caches STS tokens and uploads media files.
17
+ * Uses Volcengine TOS native signing (TOS4-HMAC-SHA256), not AWS S3 signing.
18
+ */
19
+ export class TOSUploader {
20
+ gatewayUrl;
21
+ deviceToken;
22
+ cachedToken = null;
23
+ log;
24
+ constructor(gatewayUrl, deviceToken, log) {
25
+ this.gatewayUrl = gatewayUrl.replace(/\/+$/, "");
26
+ this.deviceToken = deviceToken;
27
+ this.log = log ?? console.log;
28
+ }
29
+ /**
30
+ * Upload a local file to TOS, returns the public URL (CDN or bucket URL).
31
+ */
32
+ async uploadFile(localPath) {
33
+ const token = await this.getToken();
34
+ const data = await readFile(localPath);
35
+ const ext = extname(localPath).toLowerCase();
36
+ const contentType = MIME_MAP[ext] ?? "application/octet-stream";
37
+ const filename = `${Date.now()}${ext}`;
38
+ const objectKey = `${token.upload_prefix}${filename}`;
39
+ await this.putObject(token, objectKey, data, contentType);
40
+ // Return CDN URL if configured, otherwise bucket URL
41
+ if (token.cdn_host) {
42
+ return `https://${token.cdn_host}/${objectKey}`;
43
+ }
44
+ return `https://${token.bucket}.${token.endpoint}/${objectKey}`;
45
+ }
46
+ async getToken() {
47
+ // Return cached token if still valid (with 5 min buffer)
48
+ if (this.cachedToken) {
49
+ const bufferMs = 5 * 60 * 1000;
50
+ if (Date.now() < this.cachedToken.expiresAt - bufferMs) {
51
+ return this.cachedToken.token;
52
+ }
53
+ }
54
+ const url = `${this.gatewayUrl}/api/v1/device/sts-token`;
55
+ const resp = await fetch(url, {
56
+ headers: { Authorization: `Bearer ${this.deviceToken}` },
57
+ });
58
+ if (!resp.ok) {
59
+ throw new Error(`STS token request failed: ${resp.status} ${await resp.text()}`);
60
+ }
61
+ const token = (await resp.json());
62
+ const expiresAt = new Date(token.expiration).getTime();
63
+ this.cachedToken = { token, expiresAt };
64
+ this.log(`[elys] STS token acquired, expires: ${token.expiration}`);
65
+ return token;
66
+ }
67
+ /**
68
+ * Upload to TOS using PutObject with TOS V4 signing (TOS4-HMAC-SHA256).
69
+ */
70
+ async putObject(token, objectKey, body, contentType) {
71
+ const host = `${token.bucket}.${token.endpoint}`;
72
+ const url = `https://${host}/${objectKey}`;
73
+ const now = new Date();
74
+ const headers = {
75
+ "host": host,
76
+ "content-type": contentType,
77
+ "x-tos-content-sha256": "UNSIGNED-PAYLOAD",
78
+ "x-tos-date": toTosDate(now),
79
+ "x-tos-acl": "public-read",
80
+ };
81
+ if (token.security_token) {
82
+ headers["x-tos-security-token"] = token.security_token;
83
+ }
84
+ const authorization = signTosV4("PUT", `/${objectKey}`, headers, token.access_key_id, token.access_key_secret, token.region, "tos", now);
85
+ headers["Authorization"] = authorization;
86
+ this.log(`[elys] TOS PutObject: ${url}`);
87
+ const resp = await fetch(url, {
88
+ method: "PUT",
89
+ headers,
90
+ body: new Uint8Array(body),
91
+ });
92
+ if (!resp.ok) {
93
+ const text = await resp.text();
94
+ throw new Error(`TOS PutObject failed: ${resp.status} ${text}`);
95
+ }
96
+ this.log(`[elys] uploaded to TOS: ${objectKey}`);
97
+ }
98
+ }
99
+ // ─── TOS V4 Signing (TOS4-HMAC-SHA256) ───
100
+ const TOS_ALGORITHM = "TOS4-HMAC-SHA256";
101
+ function sha256Hex(data) {
102
+ return createHash("sha256").update(data).digest("hex");
103
+ }
104
+ function hmacSha256(key, data) {
105
+ return createHmac("sha256", key).update(data).digest();
106
+ }
107
+ function toTosDate(d) {
108
+ return d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
109
+ }
110
+ function toDateStamp(d) {
111
+ return toTosDate(d).slice(0, 8);
112
+ }
113
+ function signTosV4(method, path, headers, accessKeyId, secretAccessKey, region, service, now) {
114
+ const dateStamp = toDateStamp(now);
115
+ const tosDate = toTosDate(now);
116
+ // TOS only signs "host" and "x-tos-*" headers
117
+ const signedHeaderKeys = Object.keys(headers)
118
+ .map((k) => k.toLowerCase())
119
+ .filter((k) => k === "host" || k.startsWith("x-tos-"))
120
+ .sort();
121
+ const signedHeaders = signedHeaderKeys.join(";");
122
+ // Canonical headers
123
+ const canonicalHeaders = signedHeaderKeys
124
+ .map((k) => `${k}:${headers[k]?.trim() ?? headers[Object.keys(headers).find((h) => h.toLowerCase() === k)]?.trim()}`)
125
+ .join("\n") + "\n";
126
+ // Canonical request (payload hash = UNSIGNED-PAYLOAD)
127
+ const canonicalRequest = [
128
+ method,
129
+ encodeURIPath(path),
130
+ "", // no query string
131
+ canonicalHeaders,
132
+ signedHeaders,
133
+ "UNSIGNED-PAYLOAD",
134
+ ].join("\n");
135
+ // Credential scope: date/region/service/request
136
+ const credentialScope = `${dateStamp}/${region}/${service}/request`;
137
+ // String to sign
138
+ const stringToSign = [
139
+ TOS_ALGORITHM,
140
+ tosDate,
141
+ credentialScope,
142
+ sha256Hex(canonicalRequest),
143
+ ].join("\n");
144
+ // Signing key (no "AWS4" prefix — TOS uses raw secret)
145
+ const kDate = hmacSha256(secretAccessKey, dateStamp);
146
+ const kRegion = hmacSha256(kDate, region);
147
+ const kService = hmacSha256(kRegion, service);
148
+ const kSigning = hmacSha256(kService, "request");
149
+ // Signature
150
+ const signature = hmacSha256(kSigning, stringToSign).toString("hex");
151
+ return `${TOS_ALGORITHM} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
152
+ }
153
+ function encodeURIPath(p) {
154
+ return p
155
+ .split("/")
156
+ .map((s) => encodeURIComponent(s))
157
+ .join("/");
158
+ }
@@ -17,7 +17,7 @@ export interface ResolvedElysAccount {
17
17
  }
18
18
  export interface MQTTBaseMessage {
19
19
  id: string;
20
- type: "command" | "ack" | "result" | "stream";
20
+ type: "command" | "ack" | "result" | "stream" | "push";
21
21
  timestamp: number;
22
22
  }
23
23
  export interface CommandMessage extends MQTTBaseMessage {
@@ -37,14 +37,14 @@ export interface StreamMessage extends MQTTBaseMessage {
37
37
  chunk: string;
38
38
  done: boolean;
39
39
  seq: number;
40
+ status?: "success" | "error";
41
+ error?: string;
40
42
  media_url?: string;
41
43
  media_urls?: string[];
42
44
  }
43
- export interface ResultMessage extends MQTTBaseMessage {
44
- type: "result";
45
- status: "success" | "error";
46
- result?: Record<string, unknown>;
47
- error?: string;
45
+ export interface PushMessage extends MQTTBaseMessage {
46
+ type: "push";
47
+ text?: string;
48
48
  media_url?: string;
49
49
  media_urls?: string[];
50
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-elys",
3
- "version": "1.8.4",
3
+ "version": "1.10.6",
4
4
  "description": "OpenClaw Elys channel plugin — connects to Elys App",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",