@qingtui/openclaw-plugin 0.0.0-stage → 1.1.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/README.md +98 -2
- package/dist/index.js +65 -0
- package/dist/setup-entry.js +5 -0
- package/dist/src/channel.js +112 -0
- package/dist/src/client.js +416 -0
- package/dist/src/config.js +164 -0
- package/dist/src/event.js +81 -0
- package/dist/src/inbound.js +301 -0
- package/dist/src/logger.js +1 -0
- package/dist/src/service.js +115 -0
- package/dist/src/setup-wizard.js +94 -0
- package/dist/src/stream.js +244 -0
- package/dist/src/target.js +35 -0
- package/openclaw.plugin.json +72 -0
- package/package.json +51 -5
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nudge(轻推)平台连接与消息 API 客户端。
|
|
3
|
+
*
|
|
4
|
+
* 长连接由 @qingtui/socket-sdk 承载(socket.io 封装,源码在
|
|
5
|
+
* app-qingtui-msg-proxy/sdk/js/nudge-socket):
|
|
6
|
+
* - src/service.ts 的后台 service 启动时为每个已配置账号调
|
|
7
|
+
* connectNudgeSocket 建连,关停时 disconnectNudgeSocket 断开;进程内
|
|
8
|
+
* 按 accountId 维护连接池(多机器人各自一条连接,凭证与生命周期互不影响)
|
|
9
|
+
* - 出站(channel.ts outbound、inbound.ts delivery、stream.ts)经
|
|
10
|
+
* nudgeChatApi / nudgeStreamApi 按 accountId 复用对应连接,经代理的
|
|
11
|
+
* api-request 透传,响应统一为 {success, data|error}
|
|
12
|
+
* - 代理只透传 JSON:白名单拉取、媒体上传等 REST 调用直接用 Node fetch
|
|
13
|
+
* 访问开放平台(地址来自该账号建连时传入的 openUrl)
|
|
14
|
+
*
|
|
15
|
+
* 动态白名单缓存 fail-closed:同步成功前缓存为空 = 全部拒绝。
|
|
16
|
+
*/
|
|
17
|
+
import { QingtuiSocketClient, } from "@qingtui/socket-sdk";
|
|
18
|
+
import { loadWebMediaRaw, optimizeImageToJpeg, } from "openclaw/plugin-sdk/web-media";
|
|
19
|
+
import { buildTargetedMessage, decodeNudgeTarget, encodeNudgeTarget, } from "./target.js";
|
|
20
|
+
export { encodeNudgeTarget };
|
|
21
|
+
/** accountId -> 连接上下文(多机器人各自独立) */
|
|
22
|
+
const connections = new Map();
|
|
23
|
+
/** 账号 id -> 连接池键(channel.ts 已规范化,这里只做缺省兜底) */
|
|
24
|
+
function resolveSocketKey(accountId) {
|
|
25
|
+
return accountId?.trim() || "default";
|
|
26
|
+
}
|
|
27
|
+
/** 建立某账号到轻推消息代理的连接;重复调用会先断开该账号旧连接。 */
|
|
28
|
+
export async function connectNudgeSocket(accountId, options, handlers = {}) {
|
|
29
|
+
disconnectNudgeSocket(accountId);
|
|
30
|
+
const client = new QingtuiSocketClient({
|
|
31
|
+
appid: options.appid,
|
|
32
|
+
appsecret: options.appsecret,
|
|
33
|
+
serverUrl: options.serverUrl,
|
|
34
|
+
// SDK 默认 5 次重连耗尽即永久放弃(不再自愈,只能重启 service),
|
|
35
|
+
// 长时间服务端中断会让连接死透 —— 默认无限重试兜底。
|
|
36
|
+
maxReconnectAttempts: Infinity,
|
|
37
|
+
...options.socket,
|
|
38
|
+
});
|
|
39
|
+
if (handlers.onMessage)
|
|
40
|
+
client.on("message", handlers.onMessage);
|
|
41
|
+
if (handlers.onAuthenticated)
|
|
42
|
+
client.on("authenticated", handlers.onAuthenticated);
|
|
43
|
+
if (handlers.onDisconnected)
|
|
44
|
+
client.on("disconnected", handlers.onDisconnected);
|
|
45
|
+
if (handlers.onReconnected)
|
|
46
|
+
client.on("reconnected", handlers.onReconnected);
|
|
47
|
+
if (handlers.onError)
|
|
48
|
+
client.on("error", handlers.onError);
|
|
49
|
+
if (handlers.onHeartbeatTimeout)
|
|
50
|
+
client.on("heartbeatTimeout", handlers.onHeartbeatTimeout);
|
|
51
|
+
// 先保存句柄再连接:connect() 在首次 connect_error 时 reject,
|
|
52
|
+
// 但 SDK 内部仍会按指数退避自动重连 —— 句柄必须在重连成功后就可用,
|
|
53
|
+
// 否则恢复后出站仍报“未连接”。调用方决定是否把首次失败当作致命错误。
|
|
54
|
+
connections.set(resolveSocketKey(accountId), {
|
|
55
|
+
client,
|
|
56
|
+
openUrl: options.openUrl,
|
|
57
|
+
creds: { appid: options.appid, appsecret: options.appsecret },
|
|
58
|
+
});
|
|
59
|
+
await client.connect();
|
|
60
|
+
return client;
|
|
61
|
+
}
|
|
62
|
+
/** 断开指定账号的连接;不传 accountId 时断开全部 */
|
|
63
|
+
export function disconnectNudgeSocket(accountId) {
|
|
64
|
+
if (accountId?.trim()) {
|
|
65
|
+
const key = resolveSocketKey(accountId);
|
|
66
|
+
connections.get(key)?.client.destroy();
|
|
67
|
+
connections.delete(key);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const connection of connections.values())
|
|
71
|
+
connection.client.destroy();
|
|
72
|
+
connections.clear();
|
|
73
|
+
}
|
|
74
|
+
function requireConnection(accountId) {
|
|
75
|
+
const key = resolveSocketKey(accountId);
|
|
76
|
+
const connection = connections.get(key);
|
|
77
|
+
if (connection?.client.getStatus() !== "connected") {
|
|
78
|
+
throw new Error(`openclaw-nudge: 账号 "${key}" 到轻推消息代理的 socket 未连接` +
|
|
79
|
+
"(gateway 的 qingtui-inbound service 未运行或已断线)");
|
|
80
|
+
}
|
|
81
|
+
return connection;
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// 开放平台 REST 调用(绕过 socket 代理)
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/** /v1/token 下发的 token 有效期 7200 秒;提前 5 分钟视为过期 */
|
|
87
|
+
const REST_TOKEN_TTL_MS = (7200 - 300) * 1000;
|
|
88
|
+
/**
|
|
89
|
+
* 解析开放平台 JSON 响应:HTTP 非 2xx 或 errcode 非零(轻推错误约定)即抛错。
|
|
90
|
+
*/
|
|
91
|
+
async function readOpenApiJson(res, what) {
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
throw new Error(`openclaw-nudge: ${what} HTTP 失败: ${res.status} ${res.statusText}`);
|
|
94
|
+
}
|
|
95
|
+
const data = (await res.json());
|
|
96
|
+
if (data.errcode !== undefined && data.errcode !== 0) {
|
|
97
|
+
throw new Error(`openclaw-nudge: ${what}失败: ${String(data.errmsg ?? "unknown")} (errcode: ${String(data.errcode)})`);
|
|
98
|
+
}
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
/** 经 REST /v1/token 获取 access_token(带进程内缓存,按账号隔离) */
|
|
102
|
+
async function fetchRestAccessToken(connection) {
|
|
103
|
+
const cached = connection.restToken;
|
|
104
|
+
if (cached && cached.expiresAt > Date.now())
|
|
105
|
+
return cached.value;
|
|
106
|
+
const url = `${connection.openUrl}/v1/token?grant_type=client_credential` +
|
|
107
|
+
`&appid=${encodeURIComponent(connection.creds.appid)}` +
|
|
108
|
+
`&secret=${encodeURIComponent(connection.creds.appsecret)}`;
|
|
109
|
+
const data = await readOpenApiJson(await fetch(url), "获取 access_token ");
|
|
110
|
+
const token = data.access_token;
|
|
111
|
+
if (typeof token !== "string" || !token) {
|
|
112
|
+
throw new Error("openclaw-nudge: access_token 响应缺少 access_token");
|
|
113
|
+
}
|
|
114
|
+
connection.restToken = {
|
|
115
|
+
value: token,
|
|
116
|
+
expiresAt: Date.now() + REST_TOKEN_TTL_MS,
|
|
117
|
+
};
|
|
118
|
+
return token;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 带 access_token 执行 REST 调用:优先复用 socket 认证/刷新下发的 token,
|
|
122
|
+
* 不可用或调用失败时回退 REST /v1/token 获取的 token 重试一次。
|
|
123
|
+
* 白名单拉取和媒体上传共用这一策略。
|
|
124
|
+
*/
|
|
125
|
+
async function withAccessToken(connection, call) {
|
|
126
|
+
const socketToken = connection.client.getAccessToken();
|
|
127
|
+
if (socketToken) {
|
|
128
|
+
try {
|
|
129
|
+
return await call(socketToken);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// socket token 可能过期或不被该接口接受,回退 REST token
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return await call(await fetchRestAccessToken(connection));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
// REST token 也失败时作废缓存,下次重新获取
|
|
140
|
+
connection.restToken = undefined;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// 动态 allowFrom(nudge 侧可见范围白名单同步,按账号隔离)
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
/**
|
|
148
|
+
* 入站准入是同步读:每条消息不能现拉 nudge 接口,因此由 service 在建连后
|
|
149
|
+
* 按账号后台定时同步到本缓存。生效白名单只来自本缓存(无静态配置)。
|
|
150
|
+
* 首次同步成功前缓存为空 —— fail-closed。
|
|
151
|
+
*/
|
|
152
|
+
const dynamicAllowFromByAccount = new Map();
|
|
153
|
+
/** 同步读:指定账号当前缓存的白名单(同步失败时保持旧值) */
|
|
154
|
+
export function getDynamicAllowFrom(accountId) {
|
|
155
|
+
return dynamicAllowFromByAccount.get(resolveSocketKey(accountId)) ?? [];
|
|
156
|
+
}
|
|
157
|
+
/** 清空缓存(service 停止时调用,回到 fail-closed 初始态);不传账号时清全部 */
|
|
158
|
+
export function resetDynamicAllowFrom(accountId) {
|
|
159
|
+
if (accountId?.trim()) {
|
|
160
|
+
dynamicAllowFromByAccount.delete(resolveSocketKey(accountId));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
dynamicAllowFromByAccount.clear();
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* 拉取应用可见范围内的用户 open_id 与群聊 channel_id(合并在同一数组):
|
|
167
|
+
* GET /v1/app/followersandchannels?access_token=...
|
|
168
|
+
* 对应 Java 侧 AppApi.getFollowersAndInChannels,DM 与群聊共用这份白名单。
|
|
169
|
+
*/
|
|
170
|
+
async function fetchFollowersAndChannels(openUrl, accessToken) {
|
|
171
|
+
const url = `${openUrl}/v1/app/followersandchannels?access_token=${encodeURIComponent(accessToken)}`;
|
|
172
|
+
const data = await readOpenApiJson(await fetch(url), "获取 followers ");
|
|
173
|
+
const followers = data.followers;
|
|
174
|
+
if (!Array.isArray(followers)) {
|
|
175
|
+
throw new Error("openclaw-nudge: followers 响应缺少 followers 数组");
|
|
176
|
+
}
|
|
177
|
+
return followers.filter((v) => typeof v === "string" && !!v);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* 拉取并刷新指定账号的动态白名单缓存。
|
|
181
|
+
* 返回本次同步的条目数;该账号 socket 未连接或请求期间连接已失效时返回
|
|
182
|
+
* null(静默跳过)。
|
|
183
|
+
* 拉取异常向外抛(调用方记日志),旧缓存保持不变。
|
|
184
|
+
*/
|
|
185
|
+
export async function refreshDynamicAllowFrom(accountId) {
|
|
186
|
+
const key = resolveSocketKey(accountId);
|
|
187
|
+
const connection = connections.get(key);
|
|
188
|
+
if (connection?.client.getStatus() !== "connected")
|
|
189
|
+
return null;
|
|
190
|
+
const ids = await withAccessToken(connection, (token) => fetchFollowersAndChannels(connection.openUrl, token));
|
|
191
|
+
// 请求期间连接可能已被断开(service 停止并清缓存)或被同账号重连替换:
|
|
192
|
+
// 只有发起请求的连接仍是当前连接时才写缓存,避免旧结果复活已清空的白名单
|
|
193
|
+
if (connections.get(key) !== connection)
|
|
194
|
+
return null;
|
|
195
|
+
const unique = [...new Set(ids)];
|
|
196
|
+
dynamicAllowFromByAccount.set(key, unique);
|
|
197
|
+
return unique.length;
|
|
198
|
+
}
|
|
199
|
+
function assertSendOk(response, what) {
|
|
200
|
+
if (response?.success === false) {
|
|
201
|
+
const errcode = response.error?.errcode ?? "?";
|
|
202
|
+
const errmsg = response.error?.errmsg ?? "unknown error";
|
|
203
|
+
throw new Error(`openclaw-nudge: ${what}发送失败: ${errmsg} (errcode: ${errcode})`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** 从开放平台响应里尽力提取消息 id(字段名随消息类型而异) */
|
|
207
|
+
function extractMessageId(data) {
|
|
208
|
+
if (!data || typeof data !== "object")
|
|
209
|
+
return "";
|
|
210
|
+
const record = data;
|
|
211
|
+
const id = record.msg_id ?? record.message_id ?? record.id;
|
|
212
|
+
return typeof id === "string" || typeof id === "number" ? String(id) : "";
|
|
213
|
+
}
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// 媒体加载与上传(轻推 /v1/media/upload multipart/form-data)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
/** 轻推限制:图片 ≤300KB(JPG/JPEG/PNG),文件 ≤30MB,media_id 暂无过期 */
|
|
218
|
+
const IMAGE_MAX_BYTES = 300 * 1024;
|
|
219
|
+
const FILE_MAX_BYTES = 30 * 1024 * 1024;
|
|
220
|
+
const IMAGE_EXTS = new Set(["jpg", "jpeg", "png"]);
|
|
221
|
+
function fileExtension(fileName) {
|
|
222
|
+
const dot = fileName.lastIndexOf(".");
|
|
223
|
+
return dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* 经宿主媒体加载器读取媒体(远程 URL 走宿主 SSRF 策略,本地路径限定在
|
|
227
|
+
* 宿主授权根目录内),并按轻推限制决定发图片还是文件:
|
|
228
|
+
* - JPG/PNG 且 ≤300KB:图片
|
|
229
|
+
* - JPG/PNG 但超限:先尝试压缩成 ≤300KB 的 JPEG,失败则按文件发送
|
|
230
|
+
* - 其他类型:文件(≤30MB,超限由加载器拒绝)
|
|
231
|
+
*/
|
|
232
|
+
async function prepareMedia(mediaUrl, access) {
|
|
233
|
+
const loaded = await loadWebMediaRaw(mediaUrl, {
|
|
234
|
+
maxBytes: FILE_MAX_BYTES,
|
|
235
|
+
localRoots: access?.localRoots,
|
|
236
|
+
readFile: access?.readFile,
|
|
237
|
+
workspaceDir: access?.workspaceDir,
|
|
238
|
+
});
|
|
239
|
+
const fileName = loaded.fileName || "file";
|
|
240
|
+
const ext = fileExtension(fileName);
|
|
241
|
+
const asFile = {
|
|
242
|
+
buffer: loaded.buffer,
|
|
243
|
+
fileName,
|
|
244
|
+
mediaType: "file",
|
|
245
|
+
contentType: loaded.contentType || "application/octet-stream",
|
|
246
|
+
};
|
|
247
|
+
if (loaded.kind !== "image" || !IMAGE_EXTS.has(ext))
|
|
248
|
+
return asFile;
|
|
249
|
+
if (loaded.buffer.length <= IMAGE_MAX_BYTES) {
|
|
250
|
+
return {
|
|
251
|
+
buffer: loaded.buffer,
|
|
252
|
+
fileName,
|
|
253
|
+
mediaType: "image",
|
|
254
|
+
contentType: ext === "png" ? "image/png" : "image/jpeg",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const optimized = await optimizeImageToJpeg(loaded.buffer, IMAGE_MAX_BYTES, {
|
|
259
|
+
contentType: loaded.contentType,
|
|
260
|
+
fileName,
|
|
261
|
+
});
|
|
262
|
+
if (optimized.buffer.length <= IMAGE_MAX_BYTES) {
|
|
263
|
+
return {
|
|
264
|
+
buffer: optimized.buffer,
|
|
265
|
+
fileName: `${fileName.replace(/\.[^.]+$/, "")}.jpg`,
|
|
266
|
+
mediaType: "image",
|
|
267
|
+
contentType: "image/jpeg",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// 图片处理器不可用或压缩失败:按文件发送,保证内容送达
|
|
273
|
+
}
|
|
274
|
+
return asFile;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* 上传多媒体文件到轻推开放平台,返回 media_id。
|
|
278
|
+
* socket 代理的 api-request 只透传 JSON,无法承载 multipart,因此直接用
|
|
279
|
+
* Node fetch + FormData 访问该账号的开放平台地址。
|
|
280
|
+
*/
|
|
281
|
+
async function uploadMedia(connection, media) {
|
|
282
|
+
return withAccessToken(connection, async (accessToken) => {
|
|
283
|
+
const uploadUrl = `${connection.openUrl}/v1/media/upload?access_token=${encodeURIComponent(accessToken)}&type=${media.mediaType}`;
|
|
284
|
+
const formData = new FormData();
|
|
285
|
+
// Buffer 扩展 Uint8Array<ArrayBufferLike>,Blob 要求 BlobPart 不接受
|
|
286
|
+
// SharedArrayBuffer;经 Uint8Array 包装确保类型安全
|
|
287
|
+
formData.append("media", new Blob([new Uint8Array(media.buffer)], { type: media.contentType }), media.fileName);
|
|
288
|
+
const result = await readOpenApiJson(await fetch(uploadUrl, { method: "POST", body: formData }), "上传媒体");
|
|
289
|
+
const mediaId = result.media_id;
|
|
290
|
+
if (typeof mediaId !== "string" || !mediaId) {
|
|
291
|
+
throw new Error("openclaw-nudge: 上传媒体响应缺少 media_id");
|
|
292
|
+
}
|
|
293
|
+
return mediaId;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// 出站 API
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
export const nudgeChatApi = {
|
|
300
|
+
/**
|
|
301
|
+
* 发送文本消息(OpenClaw 回复 -> nudge,按账号选连接)。
|
|
302
|
+
* 群聊且携带 replyToId 时升级为引用消息(type="ref"),引用入站提问消息。
|
|
303
|
+
*/
|
|
304
|
+
async sendMessage(params) {
|
|
305
|
+
const { client } = requireConnection(params.accountId);
|
|
306
|
+
const target = decodeNudgeTarget(params.to);
|
|
307
|
+
// 群聊有引用目标时走代理的 v1/message/ref/send/channel
|
|
308
|
+
// (代理仓 MessageType.REF 分支 → sendRefMessage);单聊或无
|
|
309
|
+
// replyToId 时仍走普通文本。
|
|
310
|
+
const isRef = target.sendType === "channel" && !!params.replyToId;
|
|
311
|
+
const response = await client.sendMessage({
|
|
312
|
+
type: isRef ? "ref" : "text",
|
|
313
|
+
sendType: target.sendType,
|
|
314
|
+
message: buildTargetedMessage(target, isRef
|
|
315
|
+
? { ref_msg_id: params.replyToId, content: params.text }
|
|
316
|
+
: { content: params.text }),
|
|
317
|
+
});
|
|
318
|
+
assertSendOk(response, isRef ? "群聊引用消息" : "文本消息");
|
|
319
|
+
return { id: extractMessageId(response.data), chatId: target.id };
|
|
320
|
+
},
|
|
321
|
+
/**
|
|
322
|
+
* 发送媒体文件(OpenClaw 回复 -> nudge):
|
|
323
|
+
* 1. 宿主媒体加载器读取(受 SSRF/本地根目录策略约束)并按平台限制分类
|
|
324
|
+
* 2. multipart POST /v1/media/upload 拿 media_id
|
|
325
|
+
* 3. 经 socket 代理 sendMessage(type: "image"|"file") 发送 media_id
|
|
326
|
+
* 4. 有附带文字时再单独发一条普通文本(平台图片/文件消息只认 media_id)
|
|
327
|
+
*
|
|
328
|
+
* 返回媒体消息的 id。文字发送失败时抛错,此时媒体已送达。
|
|
329
|
+
*/
|
|
330
|
+
async sendFile(params) {
|
|
331
|
+
const connection = requireConnection(params.accountId);
|
|
332
|
+
const target = decodeNudgeTarget(params.to);
|
|
333
|
+
const media = await prepareMedia(params.mediaUrl, params.mediaAccess);
|
|
334
|
+
const mediaId = await uploadMedia(connection, media);
|
|
335
|
+
const response = await connection.client.sendMessage({
|
|
336
|
+
type: media.mediaType,
|
|
337
|
+
sendType: target.sendType,
|
|
338
|
+
message: buildTargetedMessage(target, { media_id: mediaId }),
|
|
339
|
+
});
|
|
340
|
+
assertSendOk(response, `${media.mediaType === "image" ? "图片" : "文件"}消息`);
|
|
341
|
+
const result = { id: extractMessageId(response.data), chatId: target.id };
|
|
342
|
+
const caption = params.text?.trim();
|
|
343
|
+
if (caption) {
|
|
344
|
+
await nudgeChatApi.sendMessage({
|
|
345
|
+
accountId: params.accountId,
|
|
346
|
+
to: params.to,
|
|
347
|
+
text: caption,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return result;
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
export const nudgeStreamApi = {
|
|
354
|
+
/**
|
|
355
|
+
* 创建流式消息占位符(“正在生成”空消息)。
|
|
356
|
+
* 代理把 type:"streamPlaceholder" 分发到轻推
|
|
357
|
+
* POST /v1/message/text/stream/send/placeholder。
|
|
358
|
+
* chatType 映射:群聊 -> "2",轻应用单聊 -> "5"。
|
|
359
|
+
*/
|
|
360
|
+
async createPlaceholder(ctx) {
|
|
361
|
+
const { client } = requireConnection(ctx.accountId);
|
|
362
|
+
const target = decodeNudgeTarget(ctx.to);
|
|
363
|
+
const response = await client.sendMessage({
|
|
364
|
+
type: "streamPlaceholder",
|
|
365
|
+
sendType: target.sendType,
|
|
366
|
+
message: {
|
|
367
|
+
chatType: target.sendType === "channel" ? "2" : "5",
|
|
368
|
+
launchMsgId: ctx.launchMsgId,
|
|
369
|
+
launchAccountId: ctx.launchAccountId,
|
|
370
|
+
toObjectId: target.id,
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
assertSendOk(response, "流式占位");
|
|
374
|
+
const data = response.data;
|
|
375
|
+
const msgId = data?.msgId;
|
|
376
|
+
const appMsgId = data?.appMsgId;
|
|
377
|
+
if (typeof msgId !== "string" ||
|
|
378
|
+
!msgId ||
|
|
379
|
+
typeof appMsgId !== "string" ||
|
|
380
|
+
!appMsgId) {
|
|
381
|
+
throw new Error("openclaw-nudge: 流式占位响应缺少 msgId/appMsgId");
|
|
382
|
+
}
|
|
383
|
+
return { msgId, appMsgId };
|
|
384
|
+
},
|
|
385
|
+
/**
|
|
386
|
+
* 发送一个流式分片(message 为增量文本,代理侧按占位符追加进分块流)。
|
|
387
|
+
*
|
|
388
|
+
* status: 1=生成中 2=结束 5=异常。代理按
|
|
389
|
+
* `${placeholderMsgId}_${launchAccountId}` 维护 ReadableStream,
|
|
390
|
+
* 超过 30 秒(STREAM_MESSAGE_TIMEOUT)没有新分片会被代理自动关流;
|
|
391
|
+
* 任一 status=1 分片都会重置该计时。status=2 时代理会先补发剩余内容
|
|
392
|
+
* 再关流并清缓存,因此结束分片可以直接携带最后一段增量。
|
|
393
|
+
*/
|
|
394
|
+
async sendChunk(params) {
|
|
395
|
+
const { client } = requireConnection(params.ctx.accountId);
|
|
396
|
+
const target = decodeNudgeTarget(params.ctx.to);
|
|
397
|
+
const response = await client.sendMessage({
|
|
398
|
+
type: "stream",
|
|
399
|
+
sendType: target.sendType,
|
|
400
|
+
// 代理的 STREAM 分支优先读 options.sendType,显式带上避免歧义
|
|
401
|
+
options: { sendType: target.sendType },
|
|
402
|
+
message: {
|
|
403
|
+
...(target.sendType === "channel"
|
|
404
|
+
? { channelId: target.id }
|
|
405
|
+
: { toUser: target.id }),
|
|
406
|
+
status: params.status,
|
|
407
|
+
placeholderMsgId: params.placeholder.msgId,
|
|
408
|
+
placeholderAppMsgId: params.placeholder.appMsgId,
|
|
409
|
+
launchMsgId: params.ctx.launchMsgId,
|
|
410
|
+
launchAccountId: params.ctx.launchAccountId,
|
|
411
|
+
message: params.message,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
assertSendOk(response, "流式分片");
|
|
415
|
+
},
|
|
416
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId, patchScopedAccountConfig, } from "openclaw/plugin-sdk/setup";
|
|
2
|
+
/** 渠道 ID:channels.<id>、路由与 manifest channels 使用;不同于插件 ID openclaw-nudge */
|
|
3
|
+
export const NUDGE_CHANNEL_ID = "qingtui";
|
|
4
|
+
/** 轻推服务地址缺省值(生产);QA 环境为 https://qaqtopen.qingtui.com */
|
|
5
|
+
export const DEFAULT_NUDGE_SERVER_URL = "https://open.qingtui.com";
|
|
6
|
+
/** 读取渠道分节;非对象或不存在时返回 undefined */
|
|
7
|
+
function readChannelSection(cfg) {
|
|
8
|
+
const section = cfg.channels?.[NUDGE_CHANNEL_ID];
|
|
9
|
+
if (!section || typeof section !== "object")
|
|
10
|
+
return undefined;
|
|
11
|
+
// 边界窄化:这里只确认分节是对象,各字段在使用点逐个 typeof 校验
|
|
12
|
+
return section;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 读取某账号的配置分节。
|
|
16
|
+
* 所有账号(含 default)统一读 accounts.<id>;渠道顶层不再有凭证键。
|
|
17
|
+
*/
|
|
18
|
+
export function readNudgeAccountConfig(cfg, accountId) {
|
|
19
|
+
const section = readChannelSection(cfg);
|
|
20
|
+
if (!section)
|
|
21
|
+
return undefined;
|
|
22
|
+
const normalized = normalizeAccountId(accountId ?? DEFAULT_ACCOUNT_ID);
|
|
23
|
+
const entry = section.accounts?.[normalized];
|
|
24
|
+
return entry && typeof entry === "object"
|
|
25
|
+
? entry
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 账号写盘统一落 accounts.<id>(含 default -> accounts.default):
|
|
30
|
+
* scopeDefaultToAccounts 覆盖 patchScopedAccountConfig 的 default 写顶层
|
|
31
|
+
* 历史行为;ensure*Enabled 保证重跑向导后禁用态恢复。
|
|
32
|
+
*/
|
|
33
|
+
export function patchNudgeAccountConfig(params) {
|
|
34
|
+
return patchScopedAccountConfig({
|
|
35
|
+
cfg: params.cfg,
|
|
36
|
+
channelKey: NUDGE_CHANNEL_ID,
|
|
37
|
+
accountId: params.accountId,
|
|
38
|
+
patch: params.patch,
|
|
39
|
+
scopeDefaultToAccounts: true,
|
|
40
|
+
ensureChannelEnabled: true,
|
|
41
|
+
ensureAccountEnabled: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** 写入渠道级轻推服务地址(向导用;所有账号共用) */
|
|
45
|
+
export function patchNudgeServerUrl(cfg, serverUrl) {
|
|
46
|
+
return {
|
|
47
|
+
...cfg,
|
|
48
|
+
channels: {
|
|
49
|
+
...cfg.channels,
|
|
50
|
+
[NUDGE_CHANNEL_ID]: { ...readChannelSection(cfg), serverUrl },
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** 渠道总开关:缺省开启,仅显式 enabled:false 关闭 */
|
|
55
|
+
export function isNudgeChannelEnabled(cfg) {
|
|
56
|
+
return readChannelSection(cfg)?.enabled !== false;
|
|
57
|
+
}
|
|
58
|
+
/** 单个账号是否已配置(appid + appsecret 齐备且账号未被禁用;不看渠道开关) */
|
|
59
|
+
export function isNudgeAccountConfigured(cfg, accountId) {
|
|
60
|
+
const account = readNudgeAccountConfig(cfg, accountId);
|
|
61
|
+
return (account?.enabled !== false &&
|
|
62
|
+
typeof account?.appid === "string" &&
|
|
63
|
+
account.appid.length > 0 &&
|
|
64
|
+
typeof account?.appsecret === "string" &&
|
|
65
|
+
account.appsecret.length > 0);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 枚举账号 id:命名账号来自 accounts 分节;一个都没有(未配置)时
|
|
69
|
+
* 回退 ["default"](向导首账号,不代表已有可用凭证)。
|
|
70
|
+
*/
|
|
71
|
+
export function listNudgeAccountIds(cfg) {
|
|
72
|
+
const section = readChannelSection(cfg);
|
|
73
|
+
const ids = new Set();
|
|
74
|
+
if (section?.accounts) {
|
|
75
|
+
for (const key of Object.keys(section.accounts)) {
|
|
76
|
+
const normalized = normalizeAccountId(key);
|
|
77
|
+
if (normalized)
|
|
78
|
+
ids.add(normalized);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (ids.size === 0)
|
|
82
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
83
|
+
return [...ids].sort((a, b) => a.localeCompare(b));
|
|
84
|
+
}
|
|
85
|
+
/** 默认账号:有 default 用 default(单账号语义),否则字典序第一个命名账号 */
|
|
86
|
+
export function resolveDefaultNudgeAccountId(cfg) {
|
|
87
|
+
const ids = listNudgeAccountIds(cfg);
|
|
88
|
+
return ids.includes(DEFAULT_ACCOUNT_ID) ? DEFAULT_ACCOUNT_ID : ids[0];
|
|
89
|
+
}
|
|
90
|
+
/** 渠道是否已配置(任意账号凭证可用);向导状态展示用 */
|
|
91
|
+
export function isNudgeChannelConfigured(cfg) {
|
|
92
|
+
return listNudgeAccountIds(cfg).some((accountId) => isNudgeAccountConfigured(cfg, accountId));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 渠道级流式回复开关:channels.qingtui.streaming === false 时关闭,
|
|
96
|
+
* 缺省开启(还须是单聊且事件携带 account_id 才能真正开流,见 inbound.ts)。
|
|
97
|
+
*/
|
|
98
|
+
export function isNudgeStreamingEnabled(cfg) {
|
|
99
|
+
return readChannelSection(cfg)?.streaming !== false;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 规范化服务地址:要求 http(s) 绝对地址,去掉末尾斜杠;非法时返回 undefined。
|
|
103
|
+
* 向导校验与配置读取共用,避免拼接 REST 路径时出现双斜杠。
|
|
104
|
+
*/
|
|
105
|
+
export function normalizeNudgeUrl(value) {
|
|
106
|
+
if (typeof value !== "string" || !value.trim())
|
|
107
|
+
return undefined;
|
|
108
|
+
try {
|
|
109
|
+
const url = new URL(value.trim());
|
|
110
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
111
|
+
return undefined;
|
|
112
|
+
return url.toString().replace(/\/+$/, "");
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** 已显式配置的轻推服务地址(向导回显用);未配置或非法时 undefined */
|
|
119
|
+
export function readConfiguredNudgeServerUrl(cfg) {
|
|
120
|
+
return normalizeNudgeUrl(readChannelSection(cfg)?.serverUrl);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Socket 代理地址(channels.qingtui.serverUrl),缺省生产地址。
|
|
124
|
+
* 不能依赖 SDK 自带的 localhost 默认值。
|
|
125
|
+
*/
|
|
126
|
+
export function resolveNudgeServerUrl(cfg) {
|
|
127
|
+
return readConfiguredNudgeServerUrl(cfg) ?? DEFAULT_NUDGE_SERVER_URL;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 开放平台 REST 地址(/v1/token、/v1/app/followersandchannels、
|
|
131
|
+
* /v1/media/upload):优先 openUrl,其次 serverUrl(同一环境两者同域),
|
|
132
|
+
* 最后生产缺省值。
|
|
133
|
+
*/
|
|
134
|
+
export function resolveNudgeOpenUrl(cfg) {
|
|
135
|
+
return (normalizeNudgeUrl(readChannelSection(cfg)?.openUrl) ??
|
|
136
|
+
resolveNudgeServerUrl(cfg));
|
|
137
|
+
}
|
|
138
|
+
/** 按账号解析出已校验凭证;缺失/类型不对即抛错(调用点:channel config、inbound) */
|
|
139
|
+
export function resolveAccount(cfg, accountId) {
|
|
140
|
+
const resolvedId = accountId
|
|
141
|
+
? normalizeAccountId(accountId)
|
|
142
|
+
: resolveDefaultNudgeAccountId(cfg);
|
|
143
|
+
const account = readNudgeAccountConfig(cfg, resolvedId);
|
|
144
|
+
const appid = account?.appid;
|
|
145
|
+
const appsecret = account?.appsecret;
|
|
146
|
+
if (typeof appid !== "string" || !appid) {
|
|
147
|
+
throw new Error(`openclaw-nudge: appid is required (account "${resolvedId}")`);
|
|
148
|
+
}
|
|
149
|
+
if (typeof appsecret !== "string" || !appsecret) {
|
|
150
|
+
throw new Error(`openclaw-nudge: appsecret is required (account "${resolvedId}")`);
|
|
151
|
+
}
|
|
152
|
+
return { accountId: resolvedId, appid, appsecret };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* 需要建连的账号(service 逐账号建连用):渠道总开关关闭时返回空列表,
|
|
156
|
+
* 否则为凭证齐备且未禁用的账号。
|
|
157
|
+
*/
|
|
158
|
+
export function listConfiguredNudgeAccounts(cfg) {
|
|
159
|
+
if (!isNudgeChannelEnabled(cfg))
|
|
160
|
+
return [];
|
|
161
|
+
return listNudgeAccountIds(cfg)
|
|
162
|
+
.filter((accountId) => isNudgeAccountConfigured(cfg, accountId))
|
|
163
|
+
.map((accountId) => resolveAccount(cfg, accountId));
|
|
164
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
function nonEmptyString(value) {
|
|
2
|
+
return typeof value === "string" && value ? value : undefined;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* 把 socket 推送的原始负载归一化成统一事件。
|
|
6
|
+
* 只处理携带文本的消息事件(message / app_mention);其他事件类型(成员
|
|
7
|
+
* 变更等)、残缺负载以及判定为群聊却缺少 channel_id 的事件返回 null
|
|
8
|
+
* (SDK 已 autoAck,直接忽略)。
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeNudgeEvent(raw) {
|
|
11
|
+
if (!raw || typeof raw !== "object")
|
|
12
|
+
return null;
|
|
13
|
+
if (raw.type !== "event_callback")
|
|
14
|
+
return null;
|
|
15
|
+
const event = raw.event;
|
|
16
|
+
if (!event || typeof event !== "object")
|
|
17
|
+
return null;
|
|
18
|
+
if (event.type !== "message" && event.type !== "app_mention")
|
|
19
|
+
return null;
|
|
20
|
+
const id = nonEmptyString(raw.event_id);
|
|
21
|
+
const text = nonEmptyString(event.text);
|
|
22
|
+
if (!id || !text)
|
|
23
|
+
return null;
|
|
24
|
+
// 发送者标识:优先 open_id(单聊/普通消息),缺失时回退 account_id
|
|
25
|
+
// (群聊 app_mention 事件不携带 open_id,account_id = 发送者用户 id)
|
|
26
|
+
const from = nonEmptyString(event.open_id ?? event.account_id);
|
|
27
|
+
if (!from)
|
|
28
|
+
return null;
|
|
29
|
+
// 群聊类型推断:显式 chat_type="group",或带 channel_id 的 app_mention
|
|
30
|
+
const channelId = nonEmptyString(event.channel_id);
|
|
31
|
+
const isGroup = event.chat_type === "group" ||
|
|
32
|
+
(event.type === "app_mention" && channelId !== undefined);
|
|
33
|
+
// 群聊必须有 channel_id:准入按群 id 匹配、回复发往群 id,缺失时若回退
|
|
34
|
+
// 发送者 id 会把回复编码成 channel:<用户id> 发错目标,直接丢弃
|
|
35
|
+
if (isGroup && !channelId)
|
|
36
|
+
return null;
|
|
37
|
+
// 群聊只响应 @机器人(app_mention),普通群消息不触发回复
|
|
38
|
+
if (isGroup && event.type !== "app_mention")
|
|
39
|
+
return null;
|
|
40
|
+
const eventTime = raw.event_time;
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
timestamp: typeof eventTime === "number" ? eventTime * 1000 : undefined,
|
|
44
|
+
from,
|
|
45
|
+
chatType: isGroup ? "group" : "direct",
|
|
46
|
+
chatId: isGroup && channelId ? channelId : from,
|
|
47
|
+
text,
|
|
48
|
+
// 平台目前不下发 event.msg_id,顶层 event_id 即提问消息 id;
|
|
49
|
+
// 将来下发 msg_id 时优先使用
|
|
50
|
+
msgId: nonEmptyString(event.msg_id) ?? id,
|
|
51
|
+
senderAccountId: nonEmptyString(event.account_id),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// event_id 去重(服务端 ACK 重推兜底:1s 间隔最多 3 次)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
const DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
58
|
+
/**
|
|
59
|
+
* 按账号维度的事件去重器。键为 `accountId:eventId`:不同机器人各自收到的
|
|
60
|
+
* 推送互不影响。Map 保持插入顺序,过期清理从头扫到第一个未过期项即停。
|
|
61
|
+
*/
|
|
62
|
+
export function createEventDeduper(ttlMs = DEDUPE_TTL_MS) {
|
|
63
|
+
const seen = new Map();
|
|
64
|
+
return {
|
|
65
|
+
isDuplicate(accountId, eventId, now = Date.now()) {
|
|
66
|
+
for (const [key, seenAt] of seen) {
|
|
67
|
+
if (now - seenAt <= ttlMs)
|
|
68
|
+
break;
|
|
69
|
+
seen.delete(key);
|
|
70
|
+
}
|
|
71
|
+
const key = `${accountId}:${eventId}`;
|
|
72
|
+
if (seen.has(key))
|
|
73
|
+
return true;
|
|
74
|
+
seen.set(key, now);
|
|
75
|
+
return false;
|
|
76
|
+
},
|
|
77
|
+
clear() {
|
|
78
|
+
seen.clear();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|