@zeyiy/openclaw-channel 0.3.4
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 +18 -0
- package/README.md +129 -0
- package/README.zh-CN.md +128 -0
- package/dist/channel.d.ts +51 -0
- package/dist/channel.js +74 -0
- package/dist/clients.d.ts +6 -0
- package/dist/clients.js +103 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +168 -0
- package/dist/inbound.d.ts +3 -0
- package/dist/inbound.js +461 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +52 -0
- package/dist/media.d.ts +12 -0
- package/dist/media.js +206 -0
- package/dist/polyfills.d.ts +9 -0
- package/dist/polyfills.js +22 -0
- package/dist/portal.d.ts +11 -0
- package/dist/portal.js +531 -0
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +67 -0
- package/dist/targets.d.ts +6 -0
- package/dist/targets.js +21 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +131 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.js +31 -0
- package/openclaw.plugin.json +116 -0
- package/package.json +74 -0
package/dist/media.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { File } from "node:buffer";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { appendFileSync } from "node:fs";
|
|
4
|
+
import { readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { basename, extname } from "node:path";
|
|
6
|
+
import { getRecvAndGroupID } from "./targets";
|
|
7
|
+
/** 写入 /tmp/openim-debug.log,用于排查消息发送问题 */
|
|
8
|
+
function debugLog(msg) {
|
|
9
|
+
const ts = new Date().toISOString();
|
|
10
|
+
try {
|
|
11
|
+
appendFileSync("/tmp/openim-debug.log", `${ts} ${msg}\n`);
|
|
12
|
+
}
|
|
13
|
+
catch { }
|
|
14
|
+
}
|
|
15
|
+
function isUrl(input) {
|
|
16
|
+
return /^https?:\/\//i.test(input.trim());
|
|
17
|
+
}
|
|
18
|
+
function toLocalPath(input) {
|
|
19
|
+
const raw = input.trim();
|
|
20
|
+
if (raw.startsWith("file://"))
|
|
21
|
+
return decodeURIComponent(raw.slice("file://".length));
|
|
22
|
+
return raw;
|
|
23
|
+
}
|
|
24
|
+
function guessMime(pathOrName, fallback = "application/octet-stream") {
|
|
25
|
+
const ext = extname(pathOrName).toLowerCase();
|
|
26
|
+
const table = {
|
|
27
|
+
".jpg": "image/jpeg",
|
|
28
|
+
".jpeg": "image/jpeg",
|
|
29
|
+
".png": "image/png",
|
|
30
|
+
".gif": "image/gif",
|
|
31
|
+
".bmp": "image/bmp",
|
|
32
|
+
".webp": "image/webp",
|
|
33
|
+
".mp4": "video/mp4",
|
|
34
|
+
".mov": "video/quicktime",
|
|
35
|
+
".mkv": "video/x-matroska",
|
|
36
|
+
".avi": "video/x-msvideo",
|
|
37
|
+
".pdf": "application/pdf",
|
|
38
|
+
".txt": "text/plain",
|
|
39
|
+
".json": "application/json",
|
|
40
|
+
".zip": "application/zip",
|
|
41
|
+
".doc": "application/msword",
|
|
42
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
43
|
+
".xls": "application/vnd.ms-excel",
|
|
44
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
45
|
+
};
|
|
46
|
+
return table[ext] || fallback;
|
|
47
|
+
}
|
|
48
|
+
function inferNameFromUrl(url, fallback) {
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(url);
|
|
51
|
+
const name = basename(u.pathname || "");
|
|
52
|
+
return name || fallback;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function readLocalAsFile(pathInput, forcedName) {
|
|
59
|
+
const filePath = toLocalPath(pathInput);
|
|
60
|
+
const st = await stat(filePath);
|
|
61
|
+
const data = await readFile(filePath);
|
|
62
|
+
const fileName = forcedName?.trim() || basename(filePath) || `file-${Date.now()}`;
|
|
63
|
+
const mime = guessMime(fileName);
|
|
64
|
+
const file = new File([data], fileName, { type: mime });
|
|
65
|
+
return { file, filePath, fileName, size: st.size, mime };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 发送文本消息到指定目标(用户或群组)。
|
|
69
|
+
*
|
|
70
|
+
* 群聊场景下,会自动识别文本中的 @提及(支持 <@ID> 和 @ID 两种格式),
|
|
71
|
+
* 将其转换为 OpenIM 的 at-text 消息(contentType=106),使接收方能正确
|
|
72
|
+
* 识别 @提及并触发 requireMention 回复机制。
|
|
73
|
+
*/
|
|
74
|
+
export async function sendTextToTarget(client, target, text) {
|
|
75
|
+
const recvID = target.kind === "user" ? target.id : "";
|
|
76
|
+
const groupID = target.kind === "group" ? target.id : "";
|
|
77
|
+
let message;
|
|
78
|
+
if (target.kind === "group") {
|
|
79
|
+
// 统一 <@ID> 和 @ID 两种格式,收集去重后的被 @ 用户 ID
|
|
80
|
+
const atIDs = new Set();
|
|
81
|
+
const normalizedText = text.replace(/<@(\d{6,})>/g, (_m, id) => { atIDs.add(id); return `@${id}`; });
|
|
82
|
+
for (const m of normalizedText.matchAll(/@(\d{6,})/g))
|
|
83
|
+
atIDs.add(m[1]);
|
|
84
|
+
if (atIDs.size > 0) {
|
|
85
|
+
const atUserIDList = [...atIDs];
|
|
86
|
+
// 查询被 @ 用户的群昵称,用于客户端高亮显示
|
|
87
|
+
let atUsersInfo = atUserIDList.map((id) => ({ atUserID: id, groupNickname: id }));
|
|
88
|
+
try {
|
|
89
|
+
const membersRes = await client.sdk.getSpecifiedGroupMembersInfo({ groupID: target.id, userIDList: atUserIDList });
|
|
90
|
+
if (membersRes?.data) {
|
|
91
|
+
const nickMap = new Map(membersRes.data.map((m) => [m.userID, m.nickname || m.groupNickname || m.userID]));
|
|
92
|
+
atUsersInfo = atUserIDList.map((id) => ({ atUserID: id, groupNickname: nickMap.get(id) || id }));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
debugLog(`[send] 查询群成员昵称失败: ${e}`);
|
|
97
|
+
}
|
|
98
|
+
// 创建 at-text 消息(contentType=106)
|
|
99
|
+
try {
|
|
100
|
+
const created = await client.sdk.createTextAtMessage({ text: normalizedText, atUserIDList, atUsersInfo });
|
|
101
|
+
message = created?.data;
|
|
102
|
+
debugLog(`[send] at消息 group=${target.id} atUsers=${JSON.stringify(atUsersInfo)}`);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
debugLog(`[send] createTextAtMessage 失败,回退为普通文本: ${e}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// 非 at 场景或 at 消息创建失败时,回退为普通文本消息
|
|
110
|
+
if (!message) {
|
|
111
|
+
const created = await client.sdk.createTextMessage(text);
|
|
112
|
+
message = created?.data;
|
|
113
|
+
if (!message)
|
|
114
|
+
throw new Error("createTextMessage failed");
|
|
115
|
+
}
|
|
116
|
+
await client.sdk.sendMessage({ recvID, groupID, message });
|
|
117
|
+
}
|
|
118
|
+
export async function sendImageToTarget(client, target, image) {
|
|
119
|
+
const input = image.trim();
|
|
120
|
+
if (!input)
|
|
121
|
+
throw new Error("image is empty");
|
|
122
|
+
let message;
|
|
123
|
+
if (isUrl(input)) {
|
|
124
|
+
const name = inferNameFromUrl(input, "image.jpg");
|
|
125
|
+
const pic = {
|
|
126
|
+
uuid: randomUUID(),
|
|
127
|
+
type: guessMime(name, "image/jpeg"),
|
|
128
|
+
size: 0,
|
|
129
|
+
width: 0,
|
|
130
|
+
height: 0,
|
|
131
|
+
url: input,
|
|
132
|
+
};
|
|
133
|
+
const created = await client.sdk.createImageMessageByURL({
|
|
134
|
+
sourcePicture: pic,
|
|
135
|
+
bigPicture: { ...pic },
|
|
136
|
+
snapshotPicture: { ...pic },
|
|
137
|
+
sourcePath: name,
|
|
138
|
+
});
|
|
139
|
+
message = created?.data;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const local = await readLocalAsFile(input);
|
|
143
|
+
const pic = {
|
|
144
|
+
uuid: randomUUID(),
|
|
145
|
+
type: local.mime,
|
|
146
|
+
size: local.size,
|
|
147
|
+
width: 0,
|
|
148
|
+
height: 0,
|
|
149
|
+
url: "",
|
|
150
|
+
};
|
|
151
|
+
const created = await client.sdk.createImageMessageByFile({
|
|
152
|
+
sourcePicture: pic,
|
|
153
|
+
bigPicture: { ...pic },
|
|
154
|
+
snapshotPicture: { ...pic },
|
|
155
|
+
sourcePath: local.filePath,
|
|
156
|
+
file: local.file,
|
|
157
|
+
});
|
|
158
|
+
message = created?.data;
|
|
159
|
+
}
|
|
160
|
+
if (!message)
|
|
161
|
+
throw new Error("createImageMessage failed");
|
|
162
|
+
const { recvID, groupID } = getRecvAndGroupID(target);
|
|
163
|
+
await client.sdk.sendMessage({ recvID, groupID, message });
|
|
164
|
+
}
|
|
165
|
+
export async function sendVideoToTarget(client, target, video, name) {
|
|
166
|
+
const input = video.trim();
|
|
167
|
+
if (!input)
|
|
168
|
+
throw new Error("video is empty");
|
|
169
|
+
// Product policy: do not send OpenIM video messages; send videos as file messages.
|
|
170
|
+
await sendFileToTarget(client, target, input, name);
|
|
171
|
+
}
|
|
172
|
+
export async function sendFileToTarget(client, target, filePathOrUrl, name) {
|
|
173
|
+
const input = filePathOrUrl.trim();
|
|
174
|
+
if (!input)
|
|
175
|
+
throw new Error("file is empty");
|
|
176
|
+
let message;
|
|
177
|
+
if (isUrl(input)) {
|
|
178
|
+
const fileName = name?.trim() || inferNameFromUrl(input, "file.bin");
|
|
179
|
+
const created = await client.sdk.createFileMessageByURL({
|
|
180
|
+
filePath: fileName,
|
|
181
|
+
fileName,
|
|
182
|
+
uuid: randomUUID(),
|
|
183
|
+
sourceUrl: input,
|
|
184
|
+
fileSize: 0,
|
|
185
|
+
fileType: guessMime(fileName),
|
|
186
|
+
});
|
|
187
|
+
message = created?.data;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const local = await readLocalAsFile(input, name);
|
|
191
|
+
const created = await client.sdk.createFileMessageByFile({
|
|
192
|
+
filePath: local.filePath,
|
|
193
|
+
fileName: local.fileName,
|
|
194
|
+
uuid: randomUUID(),
|
|
195
|
+
sourceUrl: "",
|
|
196
|
+
fileSize: local.size,
|
|
197
|
+
fileType: local.mime,
|
|
198
|
+
file: local.file,
|
|
199
|
+
});
|
|
200
|
+
message = created?.data;
|
|
201
|
+
}
|
|
202
|
+
if (!message)
|
|
203
|
+
throw new Error("createFileMessage failed");
|
|
204
|
+
const { recvID, groupID } = getRecvAndGroupID(target);
|
|
205
|
+
await client.sdk.sendMessage({ recvID, groupID, message });
|
|
206
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
class NodeFileReaderPolyfill {
|
|
3
|
+
result = null;
|
|
4
|
+
error = null;
|
|
5
|
+
onload = null;
|
|
6
|
+
onerror = null;
|
|
7
|
+
readAsArrayBuffer(blob) {
|
|
8
|
+
void blob
|
|
9
|
+
.arrayBuffer()
|
|
10
|
+
.then((buffer) => {
|
|
11
|
+
this.result = buffer;
|
|
12
|
+
this.onload?.({ target: this });
|
|
13
|
+
})
|
|
14
|
+
.catch((err) => {
|
|
15
|
+
this.error = err instanceof Error ? err : new Error(String(err));
|
|
16
|
+
this.onerror?.(err);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (typeof globalThis.FileReader === "undefined") {
|
|
21
|
+
globalThis.FileReader = NodeFileReaderPolyfill;
|
|
22
|
+
}
|
package/dist/portal.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal Bridge — WebSocket client that connects to agent-portal cloud service.
|
|
3
|
+
*
|
|
4
|
+
* Establishes a persistent WS connection to agent-portal using botId as the unique identifier.
|
|
5
|
+
* Handles requests from portal to manage local openclaw agents, files, and models.
|
|
6
|
+
* Lifecycle is tied to the OpenIM account: starts/stops alongside the account.
|
|
7
|
+
*/
|
|
8
|
+
import type { OpenIMAccountConfig } from "./types";
|
|
9
|
+
export declare function startPortalBridge(api: any, config: OpenIMAccountConfig): void;
|
|
10
|
+
export declare function stopPortalBridge(api: any, accountId: string): void;
|
|
11
|
+
export declare function stopAllPortalBridges(api: any): void;
|