palz-connector 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "palz-connector",
3
3
  "name": "Palz Connector Channel",
4
- "version": "1.1.0",
4
+ "version": "1.1.1",
5
5
  "description": "Palz IM 接入 OpenClaw",
6
6
  "channels": [
7
7
  "palz-connector"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palz-connector",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Palz IM 接入 OpenClaw — 模块化架构,基于 OpenClaw Runtime 消息管道",
@@ -16,6 +16,7 @@
16
16
  }
17
17
  },
18
18
  "dependencies": {
19
+ "ali-oss": "^6.21.0",
19
20
  "ws": "^8.18.0"
20
21
  }
21
22
  }
package/src/media.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Palz Connector 媒体处理
3
3
  *
4
- * 将 IM 消息中的 base64 图片提取并保存到临时文件,
4
+ * 将 IM 消息中的图片上传到 OSS,返回公网 URL,
5
5
  * 供 OpenClaw Runtime 作为媒体附件处理。
6
6
  */
7
7
 
@@ -9,80 +9,45 @@ import fs from "fs";
9
9
  import path from "path";
10
10
  import os from "os";
11
11
  import type { OpenAIContent, ContentPart, ImageUrlContentPart, PalzMediaInfo } from "./types.js";
12
+ import { uploadFileToOss, uploadBufferToOss } from "./oss.js";
12
13
 
13
- function mimeToExt(mime: string): string {
14
- const map: Record<string, string> = {
15
- "image/jpeg": ".jpg",
16
- "image/png": ".png",
17
- "image/gif": ".gif",
18
- "image/webp": ".webp",
19
- "image/bmp": ".bmp",
20
- };
21
- return map[mime] || ".png";
22
- }
23
-
24
- /**
25
- * 从 base64 data URL 提取图片并保存到临时文件。
26
- */
27
- async function saveDataUrlToTempFile(
28
- dataUrl: string,
29
- log?: (...args: any[]) => void,
30
- ): Promise<PalzMediaInfo | null> {
31
- const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
32
- if (!match) {
33
- log?.(`palz-media: [saveDataUrl] 不匹配 data URL 格式, url前60字符="${dataUrl.slice(0, 60)}"`);
34
- return null;
35
- }
36
-
37
- const mimeType = match[1];
38
- const base64Data = match[2];
39
- const ext = mimeToExt(mimeType);
40
- const tmpDir = path.join(os.tmpdir(), "palz-media");
41
- fs.mkdirSync(tmpDir, { recursive: true });
42
- const filePath = path.join(tmpDir, `palz_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`);
43
-
44
- try {
45
- const buf = Buffer.from(base64Data, "base64");
46
- fs.writeFileSync(filePath, buf);
47
- log?.(`palz-media: [saveDataUrl] 输入: mime=${mimeType} base64Len=${base64Data.length} → 输出: path=${filePath} fileSize=${buf.length}bytes`);
48
- return { path: filePath, contentType: mimeType, placeholder: "<media:image>" };
49
- } catch (err: any) {
50
- log?.(`palz-media: [saveDataUrl] 失败: mime=${mimeType} error=${err.message}`);
51
- return null;
52
- }
53
- }
14
+ /** OpenClaw 允许访问的媒体目录 */
15
+ const MEDIA_DIR = path.join(os.homedir(), ".openclaw", "media");
54
16
 
55
17
  /**
56
- * HTTP URL 下载图片并保存到临时文件。
18
+ * Buffer 保存到 OpenClaw 媒体目录,返回 PalzMediaInfo。
57
19
  */
58
- async function downloadToTempFile(
59
- url: string,
20
+ function saveBufferToMediaDir(
21
+ buffer: Buffer,
22
+ contentType: string,
23
+ ext: string,
60
24
  log?: (...args: any[]) => void,
61
- ): Promise<PalzMediaInfo | null> {
62
- log?.(`palz-media: [download] 输入: url=${url.slice(0, 200)}`);
25
+ ): PalzMediaInfo | null {
63
26
  try {
64
- const resp = await fetch(url);
65
- if (!resp.ok) {
66
- log?.(`palz-media: [download] 失败: status=${resp.status} url=${url.slice(0, 200)}`);
67
- return null;
68
- }
69
- const contentType = resp.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
70
- const buffer = Buffer.from(await resp.arrayBuffer());
71
- const ext = mimeToExt(contentType);
72
- const tmpDir = path.join(os.tmpdir(), "palz-media");
73
- fs.mkdirSync(tmpDir, { recursive: true });
74
- const filePath = path.join(tmpDir, `palz_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`);
27
+ fs.mkdirSync(MEDIA_DIR, { recursive: true });
28
+ const filePath = path.join(MEDIA_DIR, `palz_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`);
75
29
  fs.writeFileSync(filePath, buffer);
76
- log?.(`palz-media: [download] 输出: path=${filePath} contentType=${contentType} fileSize=${buffer.length}bytes`);
30
+ log?.(`palz-media: [saveToMediaDir] 成功: path=${filePath} size=${buffer.length}bytes mime=${contentType}`);
77
31
  return { path: filePath, contentType, placeholder: "<media:image>" };
78
32
  } catch (err: any) {
79
- log?.(`palz-media: [download] 异常: url=${url.slice(0, 200)} error=${err.message}`);
33
+ log?.(`palz-media: [saveToMediaDir] 失败: error=${err.message}`);
80
34
  return null;
81
35
  }
82
36
  }
83
37
 
38
+ function mimeToExt(mime: string): string {
39
+ const map: Record<string, string> = {
40
+ "image/jpeg": ".jpg",
41
+ "image/png": ".png",
42
+ "image/gif": ".gif",
43
+ "image/webp": ".webp",
44
+ "image/bmp": ".bmp",
45
+ };
46
+ return map[mime] || ".png";
47
+ }
48
+
84
49
  /**
85
- * 从 OpenAI Content 中提取所有图片,保存到临时文件。
50
+ * 从 OpenAI Content 中提取所有图片,上传到 OSS 并返回公网 URL。
86
51
  */
87
52
  export async function resolvePalzMediaList(
88
53
  content: OpenAIContent,
@@ -106,9 +71,30 @@ export async function resolvePalzMediaList(
106
71
  let info: PalzMediaInfo | null = null;
107
72
 
108
73
  if (url.startsWith("data:")) {
109
- info = await saveDataUrlToTempFile(url, log);
74
+ // data URL 解码 → 保存到 OpenClaw 媒体目录
75
+ const match = url.match(/^data:(image\/[^;]+);base64,(.+)$/);
76
+ if (match) {
77
+ const mimeType = match[1];
78
+ const base64Data = match[2];
79
+ const ext = mimeToExt(mimeType);
80
+ const buffer = Buffer.from(base64Data, "base64");
81
+ info = saveBufferToMediaDir(buffer, mimeType, ext, log);
82
+ }
110
83
  } else if (url.startsWith("http://") || url.startsWith("https://")) {
111
- info = await downloadToTempFile(url, log);
84
+ // HTTP URL 下载 → 保存到 OpenClaw 媒体目录
85
+ try {
86
+ const resp = await fetch(url);
87
+ if (resp.ok) {
88
+ const contentType = resp.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
89
+ const buffer = Buffer.from(await resp.arrayBuffer());
90
+ const ext = mimeToExt(contentType);
91
+ info = saveBufferToMediaDir(buffer, contentType, ext, log);
92
+ } else {
93
+ log?.(`palz-media: [resolve] HTTP下载失败: status=${resp.status}`);
94
+ }
95
+ } catch (err: any) {
96
+ log?.(`palz-media: [resolve] HTTP下载异常: ${err.message}`);
97
+ }
112
98
  }
113
99
 
114
100
  if (info) {
@@ -124,59 +110,76 @@ export async function resolvePalzMediaList(
124
110
  }
125
111
 
126
112
  /**
127
- * 将本地文件路径或 HTTP URL 转为 data URL(用于出站消息)。
113
+ * 将本地文件路径、data URL 或 HTTP URL 转为 OSS 公网链接(用于出站消息)。
114
+ * 替代 loadMediaAsDataUrl,避免 base64 传输。
128
115
  */
129
- export async function loadMediaAsDataUrl(
116
+ export async function loadMediaAsOssUrl(
130
117
  mediaUrl: string,
131
118
  log?: (...args: any[]) => void,
132
119
  ): Promise<string | null> {
133
- log?.(`palz-media: [loadAsDataUrl] 输入: url=${mediaUrl.slice(0, 200)}`);
120
+ log?.(`palz-media: [loadAsOssUrl] 输入: url=${mediaUrl.slice(0, 200)}`);
134
121
 
135
- if (mediaUrl.startsWith("data:")) {
136
- log?.(`palz-media: [loadAsDataUrl] 已是data URL, 直接返回`);
122
+ // 已经是 OSS 链接,直接返回
123
+ if (mediaUrl.startsWith("https://oss.csaiagent.com/") || mediaUrl.startsWith("https://cstv-data.oss-cn-beijing.aliyuncs.com/")) {
124
+ log?.(`palz-media: [loadAsOssUrl] 已是OSS链接, 直接返回`);
137
125
  return mediaUrl;
138
126
  }
139
127
 
140
- const filePath = mediaUrl.replace(/^MEDIA:/, "");
141
- if (filePath.startsWith("/")) {
128
+ // data URL 解码 → 上传到 OSS
129
+ if (mediaUrl.startsWith("data:")) {
130
+ const match = mediaUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
131
+ if (!match) {
132
+ log?.(`palz-media: [loadAsOssUrl] data URL 格式不匹配`);
133
+ return null;
134
+ }
135
+ const mimeType = match[1];
136
+ const base64Data = match[2];
137
+ const ext = mimeToExt(mimeType);
138
+ try {
139
+ const buffer = Buffer.from(base64Data, "base64");
140
+ const ossUrl = await uploadBufferToOss(buffer, ext, log);
141
+ log?.(`palz-media: [loadAsOssUrl] data URL → OSS: mime=${mimeType} bufSize=${buffer.length} ossUrl=${ossUrl}`);
142
+ return ossUrl;
143
+ } catch (err: any) {
144
+ log?.(`palz-media: [loadAsOssUrl] data URL上传OSS失败: ${err.message}`);
145
+ return null;
146
+ }
147
+ }
148
+
149
+ // 本地文件路径(绝对或相对)→ 上传到 OSS
150
+ const rawPath = mediaUrl.replace(/^MEDIA:/, "");
151
+ const filePath = path.isAbsolute(rawPath) ? rawPath : path.resolve(rawPath);
152
+ if (fs.existsSync(filePath)) {
142
153
  try {
143
- const buffer = fs.readFileSync(filePath);
144
- const ext = path.extname(filePath).toLowerCase();
145
- const mimeMap: Record<string, string> = {
146
- ".jpg": "image/jpeg",
147
- ".jpeg": "image/jpeg",
148
- ".png": "image/png",
149
- ".gif": "image/gif",
150
- ".webp": "image/webp",
151
- };
152
- const mimeType = mimeMap[ext] || "image/png";
153
- const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
154
- log?.(`palz-media: [loadAsDataUrl] 输出: 本地文件=${filePath} size=${buffer.length}bytes mime=${mimeType} dataUrlLen=${dataUrl.length}`);
155
- return dataUrl;
154
+ const ossUrl = await uploadFileToOss(filePath, log);
155
+ log?.(`palz-media: [loadAsOssUrl] 本地文件 → OSS: path=${filePath} ossUrl=${ossUrl}`);
156
+ return ossUrl;
156
157
  } catch (err: any) {
157
- log?.(`palz-media: [loadAsDataUrl] 读取本地文件失败: ${filePath} error=${err.message}`);
158
+ log?.(`palz-media: [loadAsOssUrl] 本地文件上传OSS失败: ${filePath} error=${err.message}`);
158
159
  return null;
159
160
  }
160
161
  }
161
162
 
163
+ // HTTP URL → 下载 → 上传到 OSS
162
164
  if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
163
165
  try {
164
166
  const resp = await fetch(mediaUrl);
165
167
  if (!resp.ok) {
166
- log?.(`palz-media: [loadAsDataUrl] HTTP下载失败: status=${resp.status}`);
168
+ log?.(`palz-media: [loadAsOssUrl] HTTP下载失败: status=${resp.status}`);
167
169
  return null;
168
170
  }
169
- const mimeType = resp.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
171
+ const contentType = resp.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
170
172
  const buffer = Buffer.from(await resp.arrayBuffer());
171
- const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
172
- log?.(`palz-media: [loadAsDataUrl] 输出: HTTP url下载完成 size=${buffer.length}bytes mime=${mimeType} dataUrlLen=${dataUrl.length}`);
173
- return dataUrl;
173
+ const ext = mimeToExt(contentType);
174
+ const ossUrl = await uploadBufferToOss(buffer, ext, log);
175
+ log?.(`palz-media: [loadAsOssUrl] HTTP → OSS: size=${buffer.length} mime=${contentType} ossUrl=${ossUrl}`);
176
+ return ossUrl;
174
177
  } catch (err: any) {
175
- log?.(`palz-media: [loadAsDataUrl] HTTP下载异常: url=${mediaUrl.slice(0, 200)} error=${err.message}`);
178
+ log?.(`palz-media: [loadAsOssUrl] HTTP下载上传OSS异常: ${err.message}`);
176
179
  return null;
177
180
  }
178
181
  }
179
182
 
180
- log?.(`palz-media: [loadAsDataUrl] 无法识别的URL格式`);
183
+ log?.(`palz-media: [loadAsOssUrl] 无法识别的URL格式`);
181
184
  return null;
182
185
  }
package/src/oss.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Palz Connector OSS 工具
3
+ *
4
+ * 阿里云 OSS 上传/下载,AK/SK 通过环境变量读取:
5
+ * OSS_ACCESS_KEY_ID
6
+ * OSS_ACCESS_KEY_SECRET
7
+ *
8
+ * 上传后返回公网可访问的 URL。
9
+ */
10
+
11
+ import OSS from "ali-oss";
12
+ import path from "path";
13
+ import fs from "fs";
14
+
15
+ const OSS_REGION = "oss-cn-beijing";
16
+ const OSS_BUCKET = "cstv-data";
17
+ const OSS_CUSTOM_DOMAIN = "oss.csaiagent.com";
18
+ const OSS_UPLOAD_PREFIX = "palz-media";
19
+
20
+ let _client: OSS | null = null;
21
+
22
+ function getOssClient(): OSS {
23
+ if (_client) return _client;
24
+
25
+ const accessKeyId = process.env.OSS_ACCESS_KEY_ID;
26
+ const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET;
27
+
28
+ if (!accessKeyId || !accessKeySecret) {
29
+ throw new Error(
30
+ "OSS credentials not configured. Set OSS_ACCESS_KEY_ID and OSS_ACCESS_KEY_SECRET environment variables.",
31
+ );
32
+ }
33
+
34
+ _client = new OSS({
35
+ region: OSS_REGION,
36
+ accessKeyId,
37
+ accessKeySecret,
38
+ bucket: OSS_BUCKET,
39
+ });
40
+
41
+ return _client;
42
+ }
43
+
44
+ /**
45
+ * 上传本地文件到 OSS,返回公网 URL。
46
+ */
47
+ export async function uploadFileToOss(
48
+ localPath: string,
49
+ log?: (...args: any[]) => void,
50
+ ): Promise<string | null> {
51
+ log?.(`palz-oss: [upload] 输入: localPath=${localPath}`);
52
+ try {
53
+ const client = getOssClient();
54
+ const ext = path.extname(localPath) || ".png";
55
+ const objectName = `${OSS_UPLOAD_PREFIX}/${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
56
+
57
+ const fileBuffer = fs.readFileSync(localPath);
58
+ await client.put(objectName, fileBuffer);
59
+
60
+ const fileUrl = `https://${OSS_CUSTOM_DOMAIN}/${objectName}`;
61
+ log?.(`palz-oss: [upload] 成功: objectName=${objectName} url=${fileUrl}`);
62
+ return fileUrl;
63
+ } catch (err: any) {
64
+ log?.(`palz-oss: [upload] 失败: localPath=${localPath} error=${err.message}`);
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 上传 Buffer 到 OSS,返回公网 URL。
71
+ */
72
+ export async function uploadBufferToOss(
73
+ buffer: Buffer,
74
+ ext: string = ".png",
75
+ log?: (...args: any[]) => void,
76
+ ): Promise<string | null> {
77
+ log?.(`palz-oss: [uploadBuffer] 输入: bufferSize=${buffer.length} ext=${ext}`);
78
+ try {
79
+ const client = getOssClient();
80
+ const objectName = `${OSS_UPLOAD_PREFIX}/${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
81
+
82
+ await client.put(objectName, buffer);
83
+
84
+ const fileUrl = `https://${OSS_CUSTOM_DOMAIN}/${objectName}`;
85
+ log?.(`palz-oss: [uploadBuffer] 成功: objectName=${objectName} url=${fileUrl}`);
86
+ return fileUrl;
87
+ } catch (err: any) {
88
+ log?.(`palz-oss: [uploadBuffer] 失败: error=${err.message}`);
89
+ return null;
90
+ }
91
+ }
package/src/outbound.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { resolvePalzAccount } from "./config.js";
9
9
  import { sendToPalzIM } from "./send.js";
10
- import { loadMediaAsDataUrl } from "./media.js";
10
+ import { loadMediaAsOssUrl } from "./media.js";
11
11
  import { parsePalzTarget } from "./targets.js";
12
12
  import type { ContentPart, TextContentPart, OpenAIContent } from "./types.js";
13
13
 
@@ -51,10 +51,10 @@ export const palzOutbound = {
51
51
  }
52
52
 
53
53
  if (mediaUrl) {
54
- const dataUrl = await loadMediaAsDataUrl(mediaUrl, log);
55
- if (dataUrl) {
56
- contentParts.push({ type: "image_url", image_url: { url: dataUrl } });
57
- log(`palz-outbound: [sendMedia] 媒体转换成功: dataUrlLen=${dataUrl.length}`);
54
+ const ossUrl = await loadMediaAsOssUrl(mediaUrl, log);
55
+ if (ossUrl) {
56
+ contentParts.push({ type: "image_url", image_url: { url: ossUrl } });
57
+ log(`palz-outbound: [sendMedia] 媒体转换成功: ossUrl=${ossUrl}`);
58
58
  } else {
59
59
  contentParts.push({ type: "text", text: `\n📎 ${mediaUrl}` });
60
60
  log(`palz-outbound: [sendMedia] 媒体转换失败, 使用文本链接替代`);
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { getPalzRuntime } from "./runtime.js";
12
- import { loadMediaAsDataUrl } from "./media.js";
12
+ import { loadMediaAsOssUrl } from "./media.js";
13
13
  import { sendToPalzIM } from "./send.js";
14
14
  import type { PalzConfig, StreamChunkOpts, ContentPart, OpenAIContent } from "./types.js";
15
15
 
@@ -119,10 +119,10 @@ export function createPalzReplyDispatcher(params: CreatePalzReplyDispatcherParam
119
119
 
120
120
  for (let i = 0; i < mediaUrls.length; i++) {
121
121
  log(`${tag}: [DELIVER 媒体] ${i + 1}/${mediaUrls.length} url=${mediaUrls[i].slice(0, 200)}`);
122
- const dataUrl = await loadMediaAsDataUrl(mediaUrls[i], log);
123
- if (dataUrl) {
124
- contentParts.push({ type: "image_url", image_url: { url: dataUrl } });
125
- log(`${tag}: [DELIVER 媒体转换成功] ${i + 1}/${mediaUrls.length} dataUrlLen=${dataUrl.length}`);
122
+ const ossUrl = await loadMediaAsOssUrl(mediaUrls[i], log);
123
+ if (ossUrl) {
124
+ contentParts.push({ type: "image_url", image_url: { url: ossUrl } });
125
+ log(`${tag}: [DELIVER 媒体转换成功] ${i + 1}/${mediaUrls.length} ossUrl=${ossUrl}`);
126
126
  } else {
127
127
  contentParts.push({ type: "text", text: `\n📎 ${mediaUrls[i]}` });
128
128
  log(`${tag}: [DELIVER 媒体转换失败] ${i + 1}/${mediaUrls.length} 使用文本链接替代`);