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 +18 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +34 -0
- package/dist/src/channel.d.ts +31 -0
- package/dist/src/channel.js +7 -1
- package/dist/src/monitor.js +229 -120
- package/dist/src/mqtt-client.d.ts +12 -3
- package/dist/src/mqtt-client.js +30 -14
- package/dist/src/outbound.d.ts +25 -15
- package/dist/src/outbound.js +72 -31
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +7 -0
- package/dist/src/tos-upload.d.ts +31 -0
- package/dist/src/tos-upload.js +158 -0
- package/dist/src/types.d.ts +6 -6
- package/package.json +1 -1
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,
|
|
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;
|
package/dist/src/channel.d.ts
CHANGED
|
@@ -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: {
|
package/dist/src/channel.js
CHANGED
|
@@ -15,7 +15,7 @@ export const elysPlugin = {
|
|
|
15
15
|
chatTypes: ["direct"],
|
|
16
16
|
polls: false,
|
|
17
17
|
threads: false,
|
|
18
|
-
media:
|
|
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) => {
|
package/dist/src/monitor.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createWriteStream } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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}
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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]
|
|
117
|
+
log(`[elys] TOS upload failed for ${mediaUrl}:`, err);
|
|
64
118
|
}
|
|
65
119
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
253
|
+
let mediaDir = null;
|
|
224
254
|
async function downloadToTemp(url, log) {
|
|
225
|
-
if (!
|
|
226
|
-
|
|
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(
|
|
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
|
|
2
|
-
export type CommandHandler = (cmd: CommandMessage, signal: AbortSignal) => Promise<
|
|
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,
|
|
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
|
}
|
package/dist/src/mqtt-client.js
CHANGED
|
@@ -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, 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 (
|
|
136
|
-
msg.media_url =
|
|
137
|
-
if (
|
|
138
|
-
msg.media_urls =
|
|
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
|
-
|
|
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.
|
|
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 = {
|
package/dist/src/outbound.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/src/outbound.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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",
|
|
83
|
+
return { channel: "elys", messageId: `push_${Date.now()}` };
|
|
43
84
|
},
|
|
44
85
|
};
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/runtime.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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
|
|
44
|
-
type: "
|
|
45
|
-
|
|
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
|
}
|