dlinkclaw-openclaw 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.en.md +46 -0
- package/README.md +15 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1 -0
- package/dist/package.json +95 -0
- package/dist/src/account.d.ts +54 -0
- package/dist/src/account.js +394 -0
- package/dist/src/channel.d.ts +17 -0
- package/dist/src/channel.js +153 -0
- package/dist/src/client/client-manager.d.ts +4 -0
- package/dist/src/client/client-manager.js +10 -0
- package/dist/src/client/const.d.ts +37 -0
- package/dist/src/client/const.js +155 -0
- package/dist/src/client/crypto.d.ts +47 -0
- package/dist/src/client/crypto.js +260 -0
- package/dist/src/client/dl-client.d.ts +117 -0
- package/dist/src/client/dl-client.js +588 -0
- package/dist/src/client/dl-http.d.ts +27 -0
- package/dist/src/client/dl-http.js +223 -0
- package/dist/src/client/media-handler.d.ts +21 -0
- package/dist/src/client/media-handler.js +196 -0
- package/dist/src/client/message.d.ts +96 -0
- package/dist/src/client/message.js +1211 -0
- package/dist/src/client/timeout.d.ts +20 -0
- package/dist/src/client/timeout.js +36 -0
- package/dist/src/client/types.d.ts +226 -0
- package/dist/src/client/types.js +1 -0
- package/dist/src/monitor.d.ts +18 -0
- package/dist/src/monitor.js +322 -0
- package/dist/src/outbound.d.ts +6 -0
- package/dist/src/outbound.js +78 -0
- package/dist/src/policy.d.ts +42 -0
- package/dist/src/policy.js +216 -0
- package/dist/src/runtime.d.ts +9 -0
- package/dist/src/runtime.js +65 -0
- package/dist/src/services/gateway-rpc.d.ts +37 -0
- package/dist/src/services/gateway-rpc.js +37 -0
- package/dist/src/services/index.d.ts +41 -0
- package/dist/src/services/index.js +245 -0
- package/dist/src/services/services.d.ts +169 -0
- package/dist/src/services/services.js +1160 -0
- package/dist/src/services/types.d.ts +391 -0
- package/dist/src/services/types.js +5 -0
- package/dist/src/services/ws-client.d.ts +186 -0
- package/dist/src/services/ws-client.js +896 -0
- package/dist/src/utils.d.ts +20 -0
- package/dist/src/utils.js +128 -0
- package/ext_capabilities/workrelay-speak/CLONE_GUIDE.md +129 -0
- package/ext_capabilities/workrelay-speak/HARDWARE.md +84 -0
- package/ext_capabilities/workrelay-speak/QWEN_GUIDE.md +151 -0
- package/ext_capabilities/workrelay-speak/README.md +79 -0
- package/ext_capabilities/workrelay-speak/__pycache__/patch-kokoro-en.cpython-312.pyc +0 -0
- package/ext_capabilities/workrelay-speak/install.sh +329 -0
- package/ext_capabilities/workrelay-speak/install.sh.bak-20260814 +376 -0
- package/ext_capabilities/workrelay-speak/manifest.json +63 -0
- package/ext_capabilities/workrelay-speak/patch-kokoro-en.py +85 -0
- package/ext_capabilities/workrelay-speak/status.sh +111 -0
- package/ext_capabilities/workrelay-speak/workrelay-tts.sh +118 -0
- package/ext_capabilities/workrelay-speak-luxtts/README.md +44 -0
- package/ext_capabilities/workrelay-speak-luxtts/install.sh +330 -0
- package/ext_capabilities/workrelay-speak-luxtts/manifest.json +63 -0
- package/ext_capabilities/workrelay-speak-luxtts/samples/45ea2e2c-0863-4966-a739-8bff8817f4c0.wav +0 -0
- package/ext_capabilities/workrelay-speak-luxtts/samples/800e5af4-cba5-441e-8003-6d36f35409e6.wav +0 -0
- package/ext_capabilities/workrelay-speak-luxtts/samples/85fafe01-d4d6-4a48-890e-9ae113f21905.wav +0 -0
- package/ext_capabilities/workrelay-speak-luxtts/samples/reference_text.txt +4 -0
- package/ext_capabilities/workrelay-speak-luxtts/samples/stone-sample-6.4s.wav +0 -0
- package/ext_capabilities/workrelay-speak-luxtts/status.sh +115 -0
- package/ext_capabilities/workrelay-speak-luxtts/workrelay-tts.sh +98 -0
- package/ext_capabilities/workrelay-speak-qwen/README.md +62 -0
- package/ext_capabilities/workrelay-speak-qwen/install.sh +349 -0
- package/ext_capabilities/workrelay-speak-qwen/manifest.json +63 -0
- package/ext_capabilities/workrelay-speak-qwen/status.sh +106 -0
- package/ext_capabilities/workrelay-speak-qwen/workrelay-tts.sh +98 -0
- package/ext_capabilities/workrelay-transcribe/README.md +76 -0
- package/ext_capabilities/workrelay-transcribe/__pycache__/transcribe.cpython-312.pyc +0 -0
- package/ext_capabilities/workrelay-transcribe/__pycache__/transcribe_server.cpython-312.pyc +0 -0
- package/ext_capabilities/workrelay-transcribe/install.sh +227 -0
- package/ext_capabilities/workrelay-transcribe/manifest.json +42 -0
- package/ext_capabilities/workrelay-transcribe/status.sh +63 -0
- package/ext_capabilities/workrelay-transcribe/transcribe.py +56 -0
- package/ext_capabilities/workrelay-transcribe/transcribe_server.py +116 -0
- package/ext_capabilities/workrelay-transcribe/voicebox-transcribe.sh +88 -0
- package/openclaw.plugin.json +142 -0
- package/package.json +95 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import { generateSignature, getDeviceId } from './crypto.js';
|
|
5
|
+
import { FILE_LIMITS, IMAGE_EXTS } from './const.js';
|
|
6
|
+
import mime from 'mime-types';
|
|
7
|
+
import FormData from 'form-data';
|
|
8
|
+
function getFileLimit(filePath) {
|
|
9
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
10
|
+
return IMAGE_EXTS.has(ext) ? FILE_LIMITS.image.maxSize : FILE_LIMITS.attachment.maxSize;
|
|
11
|
+
}
|
|
12
|
+
export class DLHttp {
|
|
13
|
+
opts;
|
|
14
|
+
deviceId;
|
|
15
|
+
client;
|
|
16
|
+
logger;
|
|
17
|
+
constructor(opts, logger, timeout = 30000) {
|
|
18
|
+
this.opts = { ...opts };
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
this.deviceId = getDeviceId();
|
|
21
|
+
this.client = axios.create({
|
|
22
|
+
baseURL: opts.baseUrl,
|
|
23
|
+
timeout: timeout,
|
|
24
|
+
headers: {
|
|
25
|
+
"Res-Type": "json",
|
|
26
|
+
"Device-Id": this.deviceId,
|
|
27
|
+
"Api-Key": opts.apiKey,
|
|
28
|
+
"User-Type": "2",
|
|
29
|
+
"User-From": "8",
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
this.client.interceptors.request.use((config) => {
|
|
33
|
+
// ========== 【请求发送前 打印日志】 ==========
|
|
34
|
+
const params = config.params || config.data;
|
|
35
|
+
this.logger.debug ?? ("======================================");
|
|
36
|
+
this.logger.debug ?? (`🚀 发送请求: ${config.method?.toUpperCase()}, ${config.baseURL}${config.url}`);
|
|
37
|
+
this.logger.debug ?? (`请求参数:", ${JSON.stringify(params)}`);
|
|
38
|
+
this.logger.debug ?? (`"🧾 请求头:\r\n${config.headers}`);
|
|
39
|
+
this.logger.debug ?? ("======================================\n");
|
|
40
|
+
return config;
|
|
41
|
+
}, (error) => {
|
|
42
|
+
this.logger.debug ?? (`❌ 请求发送失败:${error}`);
|
|
43
|
+
return Promise.reject(error);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
randomString(len) {
|
|
47
|
+
const l = len || 32;
|
|
48
|
+
const $chars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678";
|
|
49
|
+
const maxPos = $chars.length;
|
|
50
|
+
let pwd = "";
|
|
51
|
+
for (let i = 0; i < l; i++) {
|
|
52
|
+
pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
|
|
53
|
+
}
|
|
54
|
+
return pwd;
|
|
55
|
+
}
|
|
56
|
+
;
|
|
57
|
+
genSign(data) {
|
|
58
|
+
const timestamp = Date.now().toString();
|
|
59
|
+
const echostr = this.randomString(20);
|
|
60
|
+
const params = {
|
|
61
|
+
version: "1",
|
|
62
|
+
echostr: echostr,
|
|
63
|
+
deviceId: this.deviceId,
|
|
64
|
+
timestamp: timestamp,
|
|
65
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
66
|
+
};
|
|
67
|
+
if (data) {
|
|
68
|
+
for (const key in data) {
|
|
69
|
+
const value = data[key] || '';
|
|
70
|
+
if (value.length > 0) {
|
|
71
|
+
params[key] = value.trim();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const signature = generateSignature(params, this.opts.secret);
|
|
76
|
+
return { params, signature };
|
|
77
|
+
}
|
|
78
|
+
response(res) {
|
|
79
|
+
if (res.status != 200) {
|
|
80
|
+
throw new Error(`Request Get Error:${String(res.statusText)}`);
|
|
81
|
+
}
|
|
82
|
+
const result = res.data;
|
|
83
|
+
if (result.success) {
|
|
84
|
+
return result.data;
|
|
85
|
+
}
|
|
86
|
+
throw new Error(String(result.msg));
|
|
87
|
+
}
|
|
88
|
+
async get(url, data) {
|
|
89
|
+
const { params, signature } = this.genSign(data);
|
|
90
|
+
const res = await this.client.get(url, {
|
|
91
|
+
"headers": {
|
|
92
|
+
"Sign": signature,
|
|
93
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
94
|
+
},
|
|
95
|
+
"params": params,
|
|
96
|
+
"responseType": 'json'
|
|
97
|
+
});
|
|
98
|
+
return this.response(res);
|
|
99
|
+
}
|
|
100
|
+
async post(url, data) {
|
|
101
|
+
const { params, signature } = this.genSign(data);
|
|
102
|
+
const res = await this.client.post(url, params, {
|
|
103
|
+
"headers": {
|
|
104
|
+
"Sign": signature,
|
|
105
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
106
|
+
},
|
|
107
|
+
"responseType": "json"
|
|
108
|
+
});
|
|
109
|
+
return this.response(res);
|
|
110
|
+
}
|
|
111
|
+
async upload(url, files, data) {
|
|
112
|
+
if (!files.length) {
|
|
113
|
+
throw new Error('No files to upload.');
|
|
114
|
+
}
|
|
115
|
+
let fileCount = 0; // 👈 用计数器代替 getLengthSync
|
|
116
|
+
const { params, signature } = this.genSign(data);
|
|
117
|
+
const form = new FormData();
|
|
118
|
+
for (const filePath of files) {
|
|
119
|
+
try {
|
|
120
|
+
// 1. 校验文件是否存在
|
|
121
|
+
if (!fs.existsSync(filePath)) {
|
|
122
|
+
this.logger.warn(`The file does not exist.: ${filePath}`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// 2. 校验文件大小
|
|
126
|
+
const stat = await fs.promises.stat(filePath);
|
|
127
|
+
const maxSize = getFileLimit(filePath);
|
|
128
|
+
if (stat.size > maxSize) {
|
|
129
|
+
const mb = maxSize / 1024 / 1024;
|
|
130
|
+
this.logger.warn(`The file is too large and has been skipped.: ${filePath} (超过 ${mb}MB)`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// 3. 流式添加到 form
|
|
134
|
+
const fileName = path.basename(filePath);
|
|
135
|
+
const contentType = mime.lookup(filePath) || 'application/octet-stream';
|
|
136
|
+
form.append('files', fs.createReadStream(filePath), {
|
|
137
|
+
filename: fileName,
|
|
138
|
+
contentType,
|
|
139
|
+
});
|
|
140
|
+
fileCount++;
|
|
141
|
+
this.logger.info(`Files have been added.: ${fileName} (${(stat.size / 1024 / 1024).toFixed(2)}MB)`);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
this.logger.error(`Failed to read the file: ${filePath}, error=${e}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// 所有文件都失败的情况
|
|
148
|
+
// if (!form.getLengthSync()) {
|
|
149
|
+
// throw new Error('所有文件均无效,无法上传');
|
|
150
|
+
// }
|
|
151
|
+
if (fileCount === 0) {
|
|
152
|
+
throw new Error('All files are invalid and cannot be uploaded.');
|
|
153
|
+
}
|
|
154
|
+
// 添加业务参数
|
|
155
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
156
|
+
form.append(key, value);
|
|
157
|
+
}
|
|
158
|
+
// 发送请求
|
|
159
|
+
const res = await this.client.post(url, form, {
|
|
160
|
+
headers: {
|
|
161
|
+
...form.getHeaders(),
|
|
162
|
+
"Sign": signature // form-data 自动生成 boundary
|
|
163
|
+
},
|
|
164
|
+
responseType: 'json',
|
|
165
|
+
maxContentLength: Infinity, // axios 不限制响应大小
|
|
166
|
+
maxBodyLength: Infinity, // axios 不限制请求大小
|
|
167
|
+
timeout: 5 * 60 * 1000, // 5 分钟超时(大文件慢网)
|
|
168
|
+
});
|
|
169
|
+
return this.response(res);
|
|
170
|
+
}
|
|
171
|
+
async downloadFileRaw(url, { timeout = 10000, botType }) {
|
|
172
|
+
this.logger.info(`Downloading file...${url}`);
|
|
173
|
+
const config = {
|
|
174
|
+
timeout,
|
|
175
|
+
headers: {
|
|
176
|
+
'Content-Type': "application/json",
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
if (botType === 'dLink') {
|
|
180
|
+
const { params, signature } = this.genSign();
|
|
181
|
+
config.params = params;
|
|
182
|
+
config.headers = {
|
|
183
|
+
...config.headers,
|
|
184
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
185
|
+
'Sign': signature,
|
|
186
|
+
'Api-Key': this.opts.apiKey,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const http = axios.create(config);
|
|
190
|
+
try {
|
|
191
|
+
const response = await http.get(url, {
|
|
192
|
+
responseType: 'arraybuffer'
|
|
193
|
+
});
|
|
194
|
+
// 从 Content-Disposition 头中解析文件名
|
|
195
|
+
const contentDisposition = response.headers['content-disposition'];
|
|
196
|
+
let filename;
|
|
197
|
+
if (contentDisposition) {
|
|
198
|
+
// 优先匹配 filename*=UTF-8''xxx 格式(RFC 5987)
|
|
199
|
+
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;\s]+)/i);
|
|
200
|
+
if (utf8Match) {
|
|
201
|
+
filename = decodeURIComponent(utf8Match[1]);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
// 匹配 filename="xxx" 或 filename=xxx 格式
|
|
205
|
+
const match = contentDisposition.match(/filename="?([^";\s]+)"?/i);
|
|
206
|
+
if (match) {
|
|
207
|
+
filename = decodeURIComponent(match[1]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (botType === 'dLink' && (!filename || filename.length === 0)) {
|
|
212
|
+
const httpUrl = new URL(decodeURIComponent(url));
|
|
213
|
+
const uri = decodeURIComponent(httpUrl.pathname);
|
|
214
|
+
filename = uri.substring(uri.lastIndexOf('/') + 1);
|
|
215
|
+
}
|
|
216
|
+
this.logger.info(`File[${filename ?? ''}] downloaded successfully`);
|
|
217
|
+
return { buffer: Buffer.from(response.data), filename };
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
throw new Error(`File download failed.! error:${String(error)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { OpenClawConfig, PluginLogger, PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { DLClient } from "./dl-client.js";
|
|
3
|
+
import { BotType, DLResolvedAccount } from "./types.js";
|
|
4
|
+
import { InboundMediaFacts } from "openclaw/plugin-sdk/channel-inbound";
|
|
5
|
+
/**
|
|
6
|
+
* 下载并保存所有文件到本地,每个文件的下载带超时保护
|
|
7
|
+
*/
|
|
8
|
+
export declare function downloadAndSaveFiles(params: {
|
|
9
|
+
client: DLClient;
|
|
10
|
+
fileUrls: string[];
|
|
11
|
+
fileAesKeys?: Map<string, string>;
|
|
12
|
+
account: DLResolvedAccount;
|
|
13
|
+
clawConfig: OpenClawConfig;
|
|
14
|
+
core: PluginRuntime;
|
|
15
|
+
channelId: string;
|
|
16
|
+
streamId: string;
|
|
17
|
+
botType: BotType;
|
|
18
|
+
logger: PluginLogger;
|
|
19
|
+
/** 2026-08-05 语音消息:为 true 时强制按音频处理(webm → .weba,contentType=audio/webm) */
|
|
20
|
+
forceAudio?: boolean;
|
|
21
|
+
}): Promise<Array<InboundMediaFacts>>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 图片格式检测辅助函数(基于 file-type 包)
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
5
|
+
import { DEFAULT_MEDIA_MAX_MB, FILE_DOWNLOAD_TIMEOUT_MS } from "./const.js";
|
|
6
|
+
import { withTimeout } from "./timeout.js";
|
|
7
|
+
import { dlinkDecryptFile, ewxDecryptFile, md5 } from "./crypto.js";
|
|
8
|
+
import mime from 'mime-types';
|
|
9
|
+
/**
|
|
10
|
+
* 检测 Buffer 的内容类型
|
|
11
|
+
*/
|
|
12
|
+
async function detectFileContentType(data, filename) {
|
|
13
|
+
if (!data || data.length === 0) {
|
|
14
|
+
return 'application/octet-stream';
|
|
15
|
+
}
|
|
16
|
+
// 1.根据文件名精准判断文本类型(最准)
|
|
17
|
+
if (filename) {
|
|
18
|
+
// 2026-08-05 修复:原来 mime.lookup 结果被丢弃(未 return),导致 .weba 语音被 file-type 误判为 video/webm
|
|
19
|
+
// 现在文件名扩展名能识别时优先返回(.weba → audio/webm)
|
|
20
|
+
const mimeByExt = mime.lookup(filename);
|
|
21
|
+
if (mimeByExt) {
|
|
22
|
+
return mimeByExt;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// 2.通过Buffer识别文件类型
|
|
26
|
+
const fileTypeResult = await fileTypeFromBuffer(data);
|
|
27
|
+
if (fileTypeResult?.mime) {
|
|
28
|
+
return fileTypeResult.mime;
|
|
29
|
+
}
|
|
30
|
+
// 3. 二进制未识别 → 判断是否为【纯文本文件】(txt/md/json等)
|
|
31
|
+
try {
|
|
32
|
+
const text = data.toString('utf8');
|
|
33
|
+
if (typeof text === 'string') {
|
|
34
|
+
// 简单判断是否包含不可打印字符(排除乱码二进制)
|
|
35
|
+
const isBinary = /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(text);
|
|
36
|
+
if (!isBinary) {
|
|
37
|
+
return 'text/plain';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
return 'application/octet-stream';
|
|
43
|
+
}
|
|
44
|
+
function detectDocument(contentType) {
|
|
45
|
+
if (contentType.includes('json') ||
|
|
46
|
+
contentType.includes('msword ') ||
|
|
47
|
+
contentType.includes('pdf') ||
|
|
48
|
+
contentType.includes('sql') ||
|
|
49
|
+
(contentType.includes('/vnd.') && !contentType.includes("vnd.rar")) ||
|
|
50
|
+
contentType.startsWith("text/") ||
|
|
51
|
+
contentType.endsWith('/epub+zip') ||
|
|
52
|
+
contentType.endsWith('/x-mobipocket-ebook')) {
|
|
53
|
+
return 'document';
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
function detectZipPackage(contentType) {
|
|
58
|
+
if (contentType.endsWith('/zip') ||
|
|
59
|
+
contentType.endsWith('/vnd.rar') ||
|
|
60
|
+
contentType.endsWith('/x-rar-compressed') ||
|
|
61
|
+
contentType.endsWith('/x-7z-compressed') ||
|
|
62
|
+
contentType.endsWith("/gzip") ||
|
|
63
|
+
contentType.endsWith('/x-tar') ||
|
|
64
|
+
contentType.endsWith('/x-bzip2') ||
|
|
65
|
+
contentType.endsWith('/x-xz') ||
|
|
66
|
+
contentType.endsWith('/x-compress')) {
|
|
67
|
+
return 'document';
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
function detectScript(contentType) {
|
|
72
|
+
if (contentType.endsWith('/javascript') ||
|
|
73
|
+
contentType.endsWith('/typescript')) {
|
|
74
|
+
return 'document';
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* 下载并保存所有文件到本地,每个文件的下载带超时保护
|
|
80
|
+
*/
|
|
81
|
+
export async function downloadAndSaveFiles(params) {
|
|
82
|
+
const { client, fileUrls, fileAesKeys, account, clawConfig, core, channelId, streamId, botType, logger, forceAudio } = params;
|
|
83
|
+
const { config } = account;
|
|
84
|
+
const mediaList = [];
|
|
85
|
+
for (const fileUrl of fileUrls) {
|
|
86
|
+
try {
|
|
87
|
+
let buffer;
|
|
88
|
+
let contentType;
|
|
89
|
+
let originalFilename;
|
|
90
|
+
let kind;
|
|
91
|
+
const mediaMaxMb = config.mediaMaxMb || clawConfig.agents?.defaults?.mediaMaxMb || DEFAULT_MEDIA_MAX_MB;
|
|
92
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
93
|
+
const key = fileAesKeys?.get(md5(fileUrl));
|
|
94
|
+
try {
|
|
95
|
+
logger.info(`account[${account.accountId}] Downloading file from: ${fileUrl}, aseKey: ${key}`);
|
|
96
|
+
const res = await withTimeout(downloadFile(client, {
|
|
97
|
+
url: fileUrl,
|
|
98
|
+
aesKey: key,
|
|
99
|
+
channelId,
|
|
100
|
+
logger,
|
|
101
|
+
botType
|
|
102
|
+
}), FILE_DOWNLOAD_TIMEOUT_MS, `${channelId} account[${account.accountId}] Manual file download timed out: ${fileUrl}`);
|
|
103
|
+
buffer = res.buffer;
|
|
104
|
+
contentType = await detectFileContentType(buffer, res.filename);
|
|
105
|
+
originalFilename = res.filename;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
logger.error(`${channelId} account[${account.accountId}] Failed to Manual download file: ${String(err)}`);
|
|
109
|
+
logger.info(`${channelId} account[${account.accountId}] use runtime api download..`);
|
|
110
|
+
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: fileUrl }), FILE_DOWNLOAD_TIMEOUT_MS, `${channelId} account[${account.accountId}] use runtime api download timed out: ${fileUrl}`);
|
|
111
|
+
buffer = fetched.buffer;
|
|
112
|
+
contentType = fetched.contentType ?? "application/octet-stream";
|
|
113
|
+
originalFilename = fetched.fileName;
|
|
114
|
+
}
|
|
115
|
+
if (contentType.startsWith('image/')) {
|
|
116
|
+
kind = 'image';
|
|
117
|
+
}
|
|
118
|
+
else if (contentType.startsWith('audio/')) {
|
|
119
|
+
kind = 'audio';
|
|
120
|
+
}
|
|
121
|
+
else if (contentType.startsWith('video/')) {
|
|
122
|
+
kind = 'video';
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
kind = detectDocument(contentType);
|
|
126
|
+
if (!kind) {
|
|
127
|
+
kind = detectZipPackage(contentType);
|
|
128
|
+
}
|
|
129
|
+
if (!kind) {
|
|
130
|
+
kind = detectScript(contentType);
|
|
131
|
+
}
|
|
132
|
+
if (!kind) {
|
|
133
|
+
kind = 'unknown';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!kind) {
|
|
137
|
+
logger.warn(`禁止接收该类型[${contentType}]文件`);
|
|
138
|
+
client.publishToServer({ streamId, content: `[tag_error_not_allow_file_type]:${contentType}-${originalFilename}!`, kind: 'block', finish: true });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes, originalFilename, 'The received file is saved in. [user directory]/.openclaw/media/inbound');
|
|
142
|
+
// 2026-08-05 语音消息:forceAudio 时强制按音频处理
|
|
143
|
+
// 背景1:webm 容器即使只有 Opus 音频轨,file-type 也识别为 video/webm,
|
|
144
|
+
// openclaw 运行时 detectMime 优先用嗅探结果,导致 source_modality=video。
|
|
145
|
+
// 背景2:SenseAudio STT 不认 .weba 扩展名(返回 500),但认 .webm。
|
|
146
|
+
// 处理:保持 .webm 文件名(SenseAudio 能转写),仅把 contentType 强制为 audio/webm
|
|
147
|
+
// (OpenClaw 的 source_modality 看 contentType,不看扩展名)。
|
|
148
|
+
// 不重命名文件,避免 .weba 导致 SenseAudio 500。
|
|
149
|
+
let savedPath = saved.path;
|
|
150
|
+
let savedContentType = saved.contentType;
|
|
151
|
+
if (forceAudio) {
|
|
152
|
+
savedContentType = 'audio/webm';
|
|
153
|
+
logger.info(`${channelId} account[${account.accountId}] voice file contentType forced to audio/webm: ${savedPath}`);
|
|
154
|
+
}
|
|
155
|
+
mediaList.push({ path: savedPath, contentType: savedContentType, messageId: streamId });
|
|
156
|
+
//logger.info(`${channelId} account[${account.accountId}] file saved to ${saved.path}, contentType=${saved.contentType}`);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
logger.error(`${channelId} account[${account.accountId}] Failed to download file: ${String(e)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
logger.info(`${channelId} account[${account.accountId}], mediaList = ${JSON.stringify(mediaList)}`);
|
|
163
|
+
return mediaList;
|
|
164
|
+
}
|
|
165
|
+
const downloadFile = async (client, params) => {
|
|
166
|
+
const { url, aesKey, channelId, logger, botType } = params;
|
|
167
|
+
logger.debug ?? (`${channelId} downloadFile: url=${url}, hasAesKey=${!!aesKey}`);
|
|
168
|
+
logger.info('Downloading and decrypting file...');
|
|
169
|
+
try {
|
|
170
|
+
// 下载加密的文件数据
|
|
171
|
+
const { buffer, filename } = await client.downloadFileRaw(url, botType);
|
|
172
|
+
// 如果没有提供 aesKey,直接返回原始数据
|
|
173
|
+
if (!aesKey || aesKey.length === 0) {
|
|
174
|
+
logger.warn(`${channelId} No aesKey provided, returning raw file data`);
|
|
175
|
+
return { buffer: buffer, filename };
|
|
176
|
+
}
|
|
177
|
+
// 使用独立的解密模块进行 AES-256-CBC 解密
|
|
178
|
+
let decryptedBuffer;
|
|
179
|
+
switch (botType) {
|
|
180
|
+
case "ewx":
|
|
181
|
+
decryptedBuffer = ewxDecryptFile(buffer, aesKey);
|
|
182
|
+
break;
|
|
183
|
+
case "qq":
|
|
184
|
+
case "dd":
|
|
185
|
+
case "feishu":
|
|
186
|
+
case "dLink":
|
|
187
|
+
decryptedBuffer = dlinkDecryptFile(buffer, aesKey);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
logger.info(`${channelId} File downloaded and decrypted successfully`);
|
|
191
|
+
return { buffer: decryptedBuffer, filename };
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw new Error(`解密失败!,${error}`);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { OpenClawConfig, PluginLogger, PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { BotType, DLKind, DLMessagePayload, DLResolvedAccount, FromPerson, ToAgent } from "./types.js";
|
|
3
|
+
import { DLClient } from "./dl-client.js";
|
|
4
|
+
import { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
|
5
|
+
import { InboundMediaFacts } from "openclaw/plugin-sdk/channel-inbound";
|
|
6
|
+
export interface ParsedMessageContent {
|
|
7
|
+
textParts: string[];
|
|
8
|
+
imageUrls: string[];
|
|
9
|
+
imageAesKeys: Map<string, string>;
|
|
10
|
+
voiceUrls: string[];
|
|
11
|
+
voiceAesKeys: Map<string, string>;
|
|
12
|
+
videoUrls: string[];
|
|
13
|
+
videoAesKeys: Map<string, string>;
|
|
14
|
+
fileUrls: string[];
|
|
15
|
+
fileAesKeys: Map<string, string>;
|
|
16
|
+
quoteContent: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
export interface Accumulators {
|
|
19
|
+
key: string;
|
|
20
|
+
expireTime: number;
|
|
21
|
+
accumulatedPayload: string;
|
|
22
|
+
originalFields: Omit<DLMessagePayload, 'payload' | 'finish' | 'streamId'>;
|
|
23
|
+
}
|
|
24
|
+
export type AccumulatorsStatus = Omit<Accumulators, 'expireTime' | 'relayTime' | 'originalFields'>;
|
|
25
|
+
/**
|
|
26
|
+
* 主动回复参数
|
|
27
|
+
*/
|
|
28
|
+
export type AutoReplyOpts = {
|
|
29
|
+
toAgent: ToAgent;
|
|
30
|
+
fromUser: FromPerson;
|
|
31
|
+
botType: BotType;
|
|
32
|
+
chatId: string;
|
|
33
|
+
isGroup: boolean;
|
|
34
|
+
relayId: string;
|
|
35
|
+
notifyUrl?: string;
|
|
36
|
+
contactId?: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* mqtt接收到消息后预处理
|
|
40
|
+
* @param msg
|
|
41
|
+
* @param logger
|
|
42
|
+
*/
|
|
43
|
+
export declare const messagePreviewHandler: (msg: DLMessagePayload, accountId: string, logger: PluginLogger) => AccumulatorsStatus;
|
|
44
|
+
/**
|
|
45
|
+
* 消息出站时构建回复消息
|
|
46
|
+
* @param param0
|
|
47
|
+
* @returns
|
|
48
|
+
*/
|
|
49
|
+
export declare const buildDLReplyMessage: ({ streamId, content, finish, kind, runId }: {
|
|
50
|
+
streamId: string;
|
|
51
|
+
content: string;
|
|
52
|
+
finish: boolean;
|
|
53
|
+
kind: DLKind;
|
|
54
|
+
runId?: string;
|
|
55
|
+
}) => DLMessagePayload | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* 解析消息内容(支持单条消息、图文混排和引用消息)
|
|
58
|
+
* @returns 提取的文本数组、图片URL数组和引用消息内容
|
|
59
|
+
*/
|
|
60
|
+
export declare function parseMessageContent(payload: string): ParsedMessageContent | undefined;
|
|
61
|
+
export declare function buildMessageContext(params: {
|
|
62
|
+
core: PluginRuntime;
|
|
63
|
+
channelId: string;
|
|
64
|
+
account: DLResolvedAccount;
|
|
65
|
+
clawConfig: OpenClawConfig;
|
|
66
|
+
msg: DLMessagePayload;
|
|
67
|
+
imageList: Array<InboundMediaFacts>;
|
|
68
|
+
voiceList: Array<InboundMediaFacts>;
|
|
69
|
+
videoList: Array<InboundMediaFacts>;
|
|
70
|
+
fileList: Array<InboundMediaFacts>;
|
|
71
|
+
textBody?: string;
|
|
72
|
+
quoteContent?: string;
|
|
73
|
+
commandAuthorized?: boolean;
|
|
74
|
+
chatHistories: Map<string, HistoryEntry[]>;
|
|
75
|
+
historyLimit: number;
|
|
76
|
+
logger: PluginLogger;
|
|
77
|
+
client: DLClient;
|
|
78
|
+
streamId: string;
|
|
79
|
+
relayId: string;
|
|
80
|
+
}): Promise<any>;
|
|
81
|
+
/**
|
|
82
|
+
* 路由消息到核心处理流程并处理回复
|
|
83
|
+
*/
|
|
84
|
+
export declare function routeAndDispatchMessage(ctx: {
|
|
85
|
+
ctxPayload: ReturnType<typeof buildMessageContext>;
|
|
86
|
+
core: PluginRuntime;
|
|
87
|
+
cfg: OpenClawConfig;
|
|
88
|
+
client: DLClient;
|
|
89
|
+
channelId: string;
|
|
90
|
+
accountId: string;
|
|
91
|
+
streamId: string;
|
|
92
|
+
accStatus: AccumulatorsStatus;
|
|
93
|
+
commandAuthorized?: boolean;
|
|
94
|
+
chatOpts?: AutoReplyOpts;
|
|
95
|
+
logger: PluginLogger;
|
|
96
|
+
}): Promise<void>;
|