@shgroup/dsh-serenity-hooks 1.27.1 → 1.27.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/dsh.plugin.json +1 -1
- package/experiments/{autotrajectory → autopilot-trajectory}/SKILL.md +24 -23
- package/experiments/{autotrajectory/scripts/autotrajectory-exp.ts → autopilot-trajectory/scripts/autopilot-trajectory.ts} +46 -46
- package/lib/autopilot-trajectory.d.ts +226 -0
- package/lib/ccc.d.ts +15 -10
- package/lib/client.js +273 -195
- package/lib/index.js +320 -151
- package/lib/skiff-core.d.ts +9 -0
- package/lib/{skiff-debug-ZVbIqttn.js → skiff-debug-BRlc17GU.js} +100 -14
- package/lib/tools/{autotrajectory-exp.d.ts → autopilot-trajectory.d.ts} +9 -8
- package/lib/{weixin-api-b8HLmcPE.js → weixin-api-DAugVJ-f.js} +126 -3
- package/lib/weixin-api.d.ts +91 -4
- package/lib/weixin-bridge.d.ts +16 -11
- package/lib/{weixin-route-CDdlFn1E.js → weixin-route-EVu56Ytp.js} +57 -8
- package/lib/weixin-route.d.ts +33 -3
- package/package.json +3 -3
- package/lib/autotrajectory.d.ts +0 -193
package/lib/skiff-core.d.ts
CHANGED
|
@@ -31,10 +31,19 @@ export interface SkiffAgentRef {
|
|
|
31
31
|
handle: AgentHandle;
|
|
32
32
|
agent: Agent;
|
|
33
33
|
sessionId: string;
|
|
34
|
+
/** true = 从持久化恢复既有会话(历史延续,非新对话);false = 新建(首次/无持久化) */
|
|
35
|
+
resumed: boolean;
|
|
34
36
|
}
|
|
35
37
|
/**
|
|
36
38
|
* 创建 Skiff agent:标准 DSH agent + cwd=CCC root + 角色模型 +
|
|
37
39
|
* standard preset(平台工具面)+ scoped 系统提示词(基础提示词 + CCC 定义段,全替换 ACC 默认注入)。
|
|
40
|
+
*
|
|
41
|
+
* **固定 sessionId 的 resume-or-create(v1.27.2,微信桥 id collision 修复)**:
|
|
42
|
+
* 指定 sessionId 且磁盘已有持久化 log → `ctx.agents.resume`(DSH 持久化语义:
|
|
43
|
+
* sessionId 即身份,已持久化会话必须 resume——历史 + turn 续号延续,同用户长期记忆保留);
|
|
44
|
+
* 无 log(首次)/持久化未配置 → `ctx.agents.create`(新建)。不指定 sessionId →
|
|
45
|
+
* 随机 id 恒 create(ACP/调试页默认路径)。
|
|
46
|
+
*
|
|
38
47
|
* @param sessionId 指定会话 id(可选;微信桥等外部面用固定 id 实现用户↔会话长期映射——
|
|
39
48
|
* 不传则随机生成 skiff-<role>-<uuid>,ACP/调试页默认路径)
|
|
40
49
|
*/
|
|
@@ -251,6 +251,13 @@ const SKIFF_PRESET = "standard";
|
|
|
251
251
|
/**
|
|
252
252
|
* 创建 Skiff agent:标准 DSH agent + cwd=CCC root + 角色模型 +
|
|
253
253
|
* standard preset(平台工具面)+ scoped 系统提示词(基础提示词 + CCC 定义段,全替换 ACC 默认注入)。
|
|
254
|
+
*
|
|
255
|
+
* **固定 sessionId 的 resume-or-create(v1.27.2,微信桥 id collision 修复)**:
|
|
256
|
+
* 指定 sessionId 且磁盘已有持久化 log → `ctx.agents.resume`(DSH 持久化语义:
|
|
257
|
+
* sessionId 即身份,已持久化会话必须 resume——历史 + turn 续号延续,同用户长期记忆保留);
|
|
258
|
+
* 无 log(首次)/持久化未配置 → `ctx.agents.create`(新建)。不指定 sessionId →
|
|
259
|
+
* 随机 id 恒 create(ACP/调试页默认路径)。
|
|
260
|
+
*
|
|
254
261
|
* @param sessionId 指定会话 id(可选;微信桥等外部面用固定 id 实现用户↔会话长期映射——
|
|
255
262
|
* 不传则随机生成 skiff-<role>-<uuid>,ACP/调试页默认路径)
|
|
256
263
|
*/
|
|
@@ -258,19 +265,12 @@ async function createSkiffAgent(ctx, root, roleName, role, defaultModel, session
|
|
|
258
265
|
if (!ctx.agents) throw new Error("skiff: ctx.agents unavailable");
|
|
259
266
|
const model = role.model?.trim() || defaultModel || "";
|
|
260
267
|
const id = sessionId ?? `skiff-${roleName}-${randomUUID()}`;
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
setup: async (agentCtx) => {
|
|
268
|
-
try {
|
|
269
|
-
await agentCtx.get("agentPresets")?.mount?.(agentCtx, SKIFF_PRESET);
|
|
270
|
-
} catch {}
|
|
271
|
-
},
|
|
272
|
-
...model ? { agentOptions: splitModel(model) } : {}
|
|
273
|
-
});
|
|
268
|
+
const setup = async (agentCtx) => {
|
|
269
|
+
try {
|
|
270
|
+
await agentCtx.get("agentPresets")?.mount?.(agentCtx, SKIFF_PRESET);
|
|
271
|
+
} catch {}
|
|
272
|
+
};
|
|
273
|
+
const handle = await createOrResumeAgent(ctx, id, root, model, setup, sessionId !== void 0);
|
|
274
274
|
const agent = handle.agent;
|
|
275
275
|
let cccPrompt = "";
|
|
276
276
|
try {
|
|
@@ -291,8 +291,94 @@ async function createSkiffAgent(ctx, root, roleName, role, defaultModel, session
|
|
|
291
291
|
return {
|
|
292
292
|
handle,
|
|
293
293
|
agent,
|
|
294
|
-
sessionId: id
|
|
294
|
+
sessionId: id,
|
|
295
|
+
resumed: handle.resumed
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/** 取 live agent(DSH 文档:`ctx.agents.get(id)` 返回 bare Agent;会话 live 时 resume/create 均不可用) */
|
|
299
|
+
function getLiveAgent(ctx, id) {
|
|
300
|
+
try {
|
|
301
|
+
return ctx.agents?.get?.(id);
|
|
302
|
+
} catch {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** live 复用返回(resumed:true 历史延续;dispose 空操作——live agent 不归本模块所有,不能拆) */
|
|
307
|
+
function liveReuseRef(live, setup) {
|
|
308
|
+
return setup(live.ctx).catch(() => {}).then(() => ({
|
|
309
|
+
agent: live,
|
|
310
|
+
dispose: async () => {},
|
|
311
|
+
resumed: true
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* resume-or-create 分派:固定 id → 优先 resume(持久化历史延续),失败降级 create;
|
|
316
|
+
* 随机 id(无固定 sessionId)→ 恒 create。返回 handle + resumed 标志。
|
|
317
|
+
*
|
|
318
|
+
* **v1.27.3 修复(微信桥"重启后不响应"根因):live 会话优先复用**——重启后 DSH 把
|
|
319
|
+
* 关闭时仍 live 的持久化会话恢复为 live(crash/restart 恢复语义)。此时 resume 报
|
|
320
|
+
* "cannot prepare session X while it is live"(session-persistence coordinator 显式
|
|
321
|
+
* 拒绝 live 会话)、create 报 "session X already exists"(磁盘有持久化 log 必拒)→
|
|
322
|
+
* 微信桥 catch 吞错 → 用户消息无回复。唯一可行路径 = `ctx.agents.get(id)` 取 live
|
|
323
|
+
* agent 直接续用(resumed:true;角色提示词重挂与内存注册表登记由 createSkiffAgent 统一做)。
|
|
324
|
+
*/
|
|
325
|
+
async function createOrResumeAgent(ctx, id, root, model, setup, fixedId) {
|
|
326
|
+
if (!fixedId) return {
|
|
327
|
+
...await ctx.agents.create({
|
|
328
|
+
sessionId: id,
|
|
329
|
+
meta: {
|
|
330
|
+
cwd: root,
|
|
331
|
+
agentPreset: SKIFF_PRESET
|
|
332
|
+
},
|
|
333
|
+
setup,
|
|
334
|
+
...model ? { agentOptions: splitModel(model) } : {}
|
|
335
|
+
}),
|
|
336
|
+
resumed: false
|
|
337
|
+
};
|
|
338
|
+
const liveAgent = getLiveAgent(ctx, id);
|
|
339
|
+
if (liveAgent) return liveReuseRef(liveAgent, setup);
|
|
340
|
+
const agentsWithResume = ctx.agents;
|
|
341
|
+
if (typeof agentsWithResume.resume !== "function") return {
|
|
342
|
+
...await ctx.agents.create({
|
|
343
|
+
sessionId: id,
|
|
344
|
+
meta: {
|
|
345
|
+
cwd: root,
|
|
346
|
+
agentPreset: SKIFF_PRESET
|
|
347
|
+
},
|
|
348
|
+
setup,
|
|
349
|
+
...model ? { agentOptions: splitModel(model) } : {}
|
|
350
|
+
}),
|
|
351
|
+
resumed: false
|
|
295
352
|
};
|
|
353
|
+
try {
|
|
354
|
+
return {
|
|
355
|
+
...await agentsWithResume.resume({
|
|
356
|
+
resumeSessionId: id,
|
|
357
|
+
setup,
|
|
358
|
+
...model ? { agentOptions: splitModel(model) } : {}
|
|
359
|
+
}),
|
|
360
|
+
resumed: true
|
|
361
|
+
};
|
|
362
|
+
} catch (err) {
|
|
363
|
+
const liveNow = getLiveAgent(ctx, id);
|
|
364
|
+
if (liveNow) return liveReuseRef(liveNow, setup);
|
|
365
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
366
|
+
const stack = err instanceof Error ? err.stack ?? "" : "";
|
|
367
|
+
console.log(`[serenity-hooks] skiff resume 失败降级 create (id=${id}): ${msg}`);
|
|
368
|
+
if (stack) console.log(`[serenity-hooks] skiff resume stack:\n${stack.slice(0, 1200)}`);
|
|
369
|
+
return {
|
|
370
|
+
...await ctx.agents.create({
|
|
371
|
+
sessionId: id,
|
|
372
|
+
meta: {
|
|
373
|
+
cwd: root,
|
|
374
|
+
agentPreset: SKIFF_PRESET
|
|
375
|
+
},
|
|
376
|
+
setup,
|
|
377
|
+
...model ? { agentOptions: splitModel(model) } : {}
|
|
378
|
+
}),
|
|
379
|
+
resumed: false
|
|
380
|
+
};
|
|
381
|
+
}
|
|
296
382
|
}
|
|
297
383
|
/** 等待 agent 空闲(agent/status → idle);无超时(agent 工作多久等多久,handyman 同款) */
|
|
298
384
|
function waitIdle(ctx, agent) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* autopilot-trajectory.ts — Autopilot Trajectory 一站式管理工具(v1.26.12 实验
|
|
3
|
+
* autotrajectory-exp → v1.27.4 正式化改名)
|
|
3
4
|
*
|
|
4
|
-
* 定位:dsp **只提供工具与知识**,不向 CCC
|
|
5
|
-
*
|
|
5
|
+
* 定位:dsp **只提供工具与知识**,不向 CCC 自动安装任何东西(机制是 CCC 的自选动作)——
|
|
6
|
+
* agent 调本工具(doc/全报告)即懂机制,init/random/check 辅助,
|
|
6
7
|
* 实际执行(写配置/写偏见脚本/标记会话)由 CCC 自己决定、自己用现有工具完成。
|
|
7
8
|
*
|
|
8
9
|
* 实现:薄封装——exec 包内静态脚本(npm files 含 experiments/),脚本是单一真相源。
|
|
@@ -13,12 +14,12 @@
|
|
|
13
14
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
14
15
|
import type { Context } from 'cordis';
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
+
* 定位包内脚本(npm files 分发 experiments/autopilot-trajectory/)。
|
|
17
18
|
* 布局差异:tsdown bundle 后 import.meta.url 指向 lib/index.js(lib → 包根 1 层);
|
|
18
19
|
* vitest 源码直跑时指向 src/tools/x.ts(src/tools → 包根 2 层)——逐级上溯查找,
|
|
19
|
-
* 两种布局都稳(找到 experiments/
|
|
20
|
+
* 两种布局都稳(找到 experiments/autopilot-trajectory/scripts/autopilot-trajectory.ts 即止)。
|
|
20
21
|
*/
|
|
21
22
|
export declare function findExpScript(startDir: string): string | null;
|
|
22
|
-
export declare const
|
|
23
|
-
/** 创建
|
|
24
|
-
export declare function
|
|
23
|
+
export declare const AUTOPILOT_ACTIONS: readonly ["all", "init", "random", "diag", "doc", "check", "status", "guide", "diag-live"];
|
|
24
|
+
/** 创建 autopilot-trajectory 工具(闭包捕获 ctx → diag-live 进程内诊断;v1.26.14 + v1.27.4 改名) */
|
|
25
|
+
export declare function createAutopilotTool(ctx: Context): ReturnType<typeof defineTool>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createDecipheriv, randomBytes } from "node:crypto";
|
|
3
3
|
//#region src/weixin-api.ts
|
|
4
4
|
/**
|
|
5
5
|
* weixin-api.ts — 微信个人号 iLink Bot API 客户端(F4c-3,v1.27.0 实验性)
|
|
@@ -23,15 +23,25 @@ var weixin_api_exports = /* @__PURE__ */ __exportAll({
|
|
|
23
23
|
MessageItemType: () => MessageItemType,
|
|
24
24
|
MessageState: () => MessageState,
|
|
25
25
|
MessageType: () => MessageType,
|
|
26
|
+
TypingStatus: () => TypingStatus,
|
|
27
|
+
aes128EcbDecrypt: () => aes128EcbDecrypt,
|
|
26
28
|
buildClientVersion: () => buildClientVersion,
|
|
29
|
+
buildMediaDownloadUrl: () => buildMediaDownloadUrl,
|
|
30
|
+
downloadMedia: () => downloadMedia,
|
|
27
31
|
fetchQRCode: () => fetchQRCode,
|
|
32
|
+
getConfig: () => getConfig,
|
|
28
33
|
getUpdates: () => getUpdates,
|
|
29
34
|
markdownToPlainText: () => markdownToPlainText,
|
|
35
|
+
parseMediaAesKey: () => parseMediaAesKey,
|
|
30
36
|
pollQRStatus: () => pollQRStatus,
|
|
31
|
-
sendTextMessage: () => sendTextMessage
|
|
37
|
+
sendTextMessage: () => sendTextMessage,
|
|
38
|
+
sendTyping: () => sendTyping,
|
|
39
|
+
sniffImageExt: () => sniffImageExt
|
|
32
40
|
});
|
|
33
41
|
/** iLink API Base URL(腾讯官方) */
|
|
34
42
|
const ILINK_DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
43
|
+
/** CDN Base(媒体上传/下载;P3 媒体期用) */
|
|
44
|
+
const ILINK_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
35
45
|
/** iLink-App-Id(openclaw-weixin 同款) */
|
|
36
46
|
const ILINK_APP_ID = "bot";
|
|
37
47
|
/** 通道版本(对齐 openclaw-weixin 2.1.1;buildClientVersion 编码) */
|
|
@@ -193,6 +203,119 @@ async function sendTextMessage(params) {
|
|
|
193
203
|
timeoutMs: params.timeoutMs ?? 15e3
|
|
194
204
|
});
|
|
195
205
|
}
|
|
206
|
+
/** sendtyping status:1=TYPING(开始)0=CANCEL(结束)。
|
|
207
|
+
* ⚠️ 对齐**官方 openclaw-weixin 参考实现**(index.ts onReplyStart → status 1 /
|
|
208
|
+
* onCleanup → status 0)——早期协议注释"2=CANCEL"为误记,以参考实现为准。 */
|
|
209
|
+
const TypingStatus = {
|
|
210
|
+
TYPING: 1,
|
|
211
|
+
CANCEL: 0
|
|
212
|
+
};
|
|
213
|
+
/** 取对话配置(typing_ticket:sendtyping 的前置——先 getconfig 拿 ticket 再发状态) */
|
|
214
|
+
async function getConfig(params) {
|
|
215
|
+
const rawText = await apiPost({
|
|
216
|
+
baseUrl: params.baseUrl,
|
|
217
|
+
endpoint: "ilink/bot/getconfig",
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
ilink_user_id: params.ilinkUserId,
|
|
220
|
+
context_token: params.contextToken
|
|
221
|
+
}),
|
|
222
|
+
token: params.token,
|
|
223
|
+
timeoutMs: params.timeoutMs ?? 1e4
|
|
224
|
+
});
|
|
225
|
+
return JSON.parse(rawText);
|
|
226
|
+
}
|
|
227
|
+
/** 发送"正在输入"状态(status: 1=开始 0=结束;ticket 来自 getConfig) */
|
|
228
|
+
async function sendTyping(params) {
|
|
229
|
+
await apiPost({
|
|
230
|
+
baseUrl: params.baseUrl,
|
|
231
|
+
endpoint: "ilink/bot/sendtyping",
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
ilink_user_id: params.ilinkUserId,
|
|
234
|
+
typing_ticket: params.typingTicket,
|
|
235
|
+
status: params.status
|
|
236
|
+
}),
|
|
237
|
+
token: params.token,
|
|
238
|
+
timeoutMs: params.timeoutMs ?? 1e4
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/** 构造 CDN 下载 URL(full_url 优先;否则 encrypted_query_param 拼 download 端点;都无 → null) */
|
|
242
|
+
function buildMediaDownloadUrl(media) {
|
|
243
|
+
if (media.full_url) return media.full_url;
|
|
244
|
+
if (media.encrypt_query_param) return `${ILINK_CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(media.encrypt_query_param)}`;
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 解析媒体 AES key(兼容三种编码,对齐参考实现):
|
|
249
|
+
* ① 32+ hex 字符 → hex Buffer(16) ② base64 解码 = 16 字节直接用
|
|
250
|
+
* ③ base64 解码 = 32 字节 → ascii 是 hex 串 → 再 hex → Buffer(16);其他 → null
|
|
251
|
+
*/
|
|
252
|
+
function parseMediaAesKey(keyText) {
|
|
253
|
+
if (!keyText) return null;
|
|
254
|
+
if (/^[0-9a-fA-F]{32,}$/.test(keyText)) {
|
|
255
|
+
const buf = Buffer.from(keyText, "hex");
|
|
256
|
+
return buf.length === 16 ? buf : null;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const decoded = Buffer.from(keyText, "base64");
|
|
260
|
+
if (decoded.length === 16) return decoded;
|
|
261
|
+
if (decoded.length === 32) {
|
|
262
|
+
const hexStr = decoded.toString("ascii");
|
|
263
|
+
if (/^[0-9a-fA-F]{32}$/.test(hexStr)) return Buffer.from(hexStr, "hex");
|
|
264
|
+
}
|
|
265
|
+
} catch {}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
/** AES-128-ECB 解密(无 IV;node:crypto 内置) */
|
|
269
|
+
function aes128EcbDecrypt(data, key) {
|
|
270
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
271
|
+
return Buffer.concat([decipher.update(data), decipher.final()]);
|
|
272
|
+
}
|
|
273
|
+
/** 图片 magic-byte 嗅探(vlm-describe 支持 jpg/png/gif/webp/bmp——比固定 .jpg 可靠) */
|
|
274
|
+
function sniffImageExt(data) {
|
|
275
|
+
if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "jpg";
|
|
276
|
+
if (data.length >= 8 && data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "png";
|
|
277
|
+
if (data.length >= 3 && data[0] === 71 && data[1] === 73 && data[2] === 70) return "gif";
|
|
278
|
+
if (data.length >= 12 && data.subarray(0, 4).toString("ascii") === "RIFF" && data.subarray(8, 12).toString("ascii") === "WEBP") return "webp";
|
|
279
|
+
if (data.length >= 2 && data[0] === 66 && data[1] === 77) return "bmp";
|
|
280
|
+
return "bin";
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 下载 + 解密一条媒体(图片/文件)。失败/超时/无 URL → null(不抛——调用方降级)。
|
|
284
|
+
* key 提取:item.aeskey || item.media.aes_key(对齐参考实现)。
|
|
285
|
+
*/
|
|
286
|
+
async function downloadMedia(params) {
|
|
287
|
+
const mediaItem = params.item[params.mediaType];
|
|
288
|
+
if (!mediaItem) return null;
|
|
289
|
+
const media = mediaItem.media;
|
|
290
|
+
const url = buildMediaDownloadUrl(media ?? {});
|
|
291
|
+
if (!url) return null;
|
|
292
|
+
const itemKey = mediaItem.aeskey;
|
|
293
|
+
const fileName = mediaItem.file_name;
|
|
294
|
+
const key = parseMediaAesKey(itemKey ?? media?.aes_key);
|
|
295
|
+
const controller = new AbortController();
|
|
296
|
+
const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 3e4);
|
|
297
|
+
try {
|
|
298
|
+
const res = await currentFetch(url, { signal: controller.signal });
|
|
299
|
+
if (!res.ok) return null;
|
|
300
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
301
|
+
if (key) try {
|
|
302
|
+
return {
|
|
303
|
+
data: aes128EcbDecrypt(buf, key),
|
|
304
|
+
fileName
|
|
305
|
+
};
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
data: buf,
|
|
311
|
+
fileName
|
|
312
|
+
};
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
} finally {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
196
319
|
/** Markdown → 纯文本(代码块/图片/链接/表格/标题/粗斜体/删除线 7 类) */
|
|
197
320
|
function markdownToPlainText(text) {
|
|
198
321
|
let result = text;
|
|
@@ -208,4 +331,4 @@ function markdownToPlainText(text) {
|
|
|
208
331
|
return result.trim();
|
|
209
332
|
}
|
|
210
333
|
//#endregion
|
|
211
|
-
export { sendTextMessage as
|
|
334
|
+
export { sendTextMessage as a, weixin_api_exports as c, getUpdates as i, downloadMedia as n, sendTyping as o, getConfig as r, sniffImageExt as s, TypingStatus as t };
|
package/lib/weixin-api.d.ts
CHANGED
|
@@ -47,16 +47,50 @@ export declare function pollQRStatus(params: {
|
|
|
47
47
|
qrcode: string;
|
|
48
48
|
timeoutMs?: number;
|
|
49
49
|
}): Promise<QrStatusResult>;
|
|
50
|
+
/** CDN 媒体元数据(下载侧;协议见 docs/weixin-bot-api.md §6) */
|
|
51
|
+
export interface CDNMedia {
|
|
52
|
+
encrypt_query_param?: string;
|
|
53
|
+
aes_key?: string;
|
|
54
|
+
encrypt_type?: number;
|
|
55
|
+
full_url?: string;
|
|
56
|
+
}
|
|
57
|
+
/** 媒体项引用(extractWeixinMedia 产物:kind + **完整消息项**——downloadMedia 用 item[mediaType] 提取) */
|
|
58
|
+
export interface WeixinMediaRef {
|
|
59
|
+
kind: 'image' | 'file';
|
|
60
|
+
fileName?: string;
|
|
61
|
+
item: WeixinMessageItem;
|
|
62
|
+
}
|
|
50
63
|
export interface WeixinMessageItem {
|
|
51
64
|
type?: number;
|
|
52
65
|
msg_id?: string;
|
|
53
66
|
text_item?: {
|
|
54
67
|
text?: string;
|
|
55
68
|
};
|
|
56
|
-
image_item?:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
69
|
+
image_item?: {
|
|
70
|
+
media?: CDNMedia;
|
|
71
|
+
thumb_media?: CDNMedia;
|
|
72
|
+
aeskey?: string;
|
|
73
|
+
url?: string;
|
|
74
|
+
mid_size?: number;
|
|
75
|
+
thumb_size?: number;
|
|
76
|
+
};
|
|
77
|
+
/** 语音项:**text = 微信服务端自带语音转写**(官方 openclaw-weixin 直接读该字段,无需下载/ASR) */
|
|
78
|
+
voice_item?: {
|
|
79
|
+
text?: string;
|
|
80
|
+
media?: CDNMedia;
|
|
81
|
+
playtime?: number;
|
|
82
|
+
};
|
|
83
|
+
file_item?: {
|
|
84
|
+
media?: CDNMedia;
|
|
85
|
+
file_name?: string;
|
|
86
|
+
md5?: string;
|
|
87
|
+
len?: string;
|
|
88
|
+
};
|
|
89
|
+
video_item?: {
|
|
90
|
+
media?: CDNMedia;
|
|
91
|
+
video_size?: number;
|
|
92
|
+
thumb_media?: CDNMedia;
|
|
93
|
+
};
|
|
60
94
|
}
|
|
61
95
|
export interface WeixinMessage {
|
|
62
96
|
seq?: number;
|
|
@@ -115,5 +149,58 @@ export declare function sendTextMessage(params: {
|
|
|
115
149
|
clientId?: string;
|
|
116
150
|
timeoutMs?: number;
|
|
117
151
|
}): Promise<void>;
|
|
152
|
+
/** sendtyping status:1=TYPING(开始)0=CANCEL(结束)。
|
|
153
|
+
* ⚠️ 对齐**官方 openclaw-weixin 参考实现**(index.ts onReplyStart → status 1 /
|
|
154
|
+
* onCleanup → status 0)——早期协议注释"2=CANCEL"为误记,以参考实现为准。 */
|
|
155
|
+
export declare const TypingStatus: {
|
|
156
|
+
readonly TYPING: 1;
|
|
157
|
+
readonly CANCEL: 0;
|
|
158
|
+
};
|
|
159
|
+
export interface GetConfigResp {
|
|
160
|
+
ret?: number;
|
|
161
|
+
errmsg?: string;
|
|
162
|
+
typing_ticket?: string;
|
|
163
|
+
}
|
|
164
|
+
/** 取对话配置(typing_ticket:sendtyping 的前置——先 getconfig 拿 ticket 再发状态) */
|
|
165
|
+
export declare function getConfig(params: {
|
|
166
|
+
baseUrl: string;
|
|
167
|
+
token: string;
|
|
168
|
+
ilinkUserId: string;
|
|
169
|
+
contextToken: string;
|
|
170
|
+
timeoutMs?: number;
|
|
171
|
+
}): Promise<GetConfigResp>;
|
|
172
|
+
/** 发送"正在输入"状态(status: 1=开始 0=结束;ticket 来自 getConfig) */
|
|
173
|
+
export declare function sendTyping(params: {
|
|
174
|
+
baseUrl: string;
|
|
175
|
+
token: string;
|
|
176
|
+
ilinkUserId: string;
|
|
177
|
+
typingTicket: string;
|
|
178
|
+
status: number;
|
|
179
|
+
timeoutMs?: number;
|
|
180
|
+
}): Promise<void>;
|
|
181
|
+
/** 构造 CDN 下载 URL(full_url 优先;否则 encrypted_query_param 拼 download 端点;都无 → null) */
|
|
182
|
+
export declare function buildMediaDownloadUrl(media: CDNMedia): string | null;
|
|
183
|
+
/**
|
|
184
|
+
* 解析媒体 AES key(兼容三种编码,对齐参考实现):
|
|
185
|
+
* ① 32+ hex 字符 → hex Buffer(16) ② base64 解码 = 16 字节直接用
|
|
186
|
+
* ③ base64 解码 = 32 字节 → ascii 是 hex 串 → 再 hex → Buffer(16);其他 → null
|
|
187
|
+
*/
|
|
188
|
+
export declare function parseMediaAesKey(keyText?: string): Buffer | null;
|
|
189
|
+
/** AES-128-ECB 解密(无 IV;node:crypto 内置) */
|
|
190
|
+
export declare function aes128EcbDecrypt(data: Buffer, key: Buffer): Buffer;
|
|
191
|
+
/** 图片 magic-byte 嗅探(vlm-describe 支持 jpg/png/gif/webp/bmp——比固定 .jpg 可靠) */
|
|
192
|
+
export declare function sniffImageExt(data: Buffer): 'jpg' | 'png' | 'gif' | 'webp' | 'bmp' | 'bin';
|
|
193
|
+
/**
|
|
194
|
+
* 下载 + 解密一条媒体(图片/文件)。失败/超时/无 URL → null(不抛——调用方降级)。
|
|
195
|
+
* key 提取:item.aeskey || item.media.aes_key(对齐参考实现)。
|
|
196
|
+
*/
|
|
197
|
+
export declare function downloadMedia(params: {
|
|
198
|
+
item: WeixinMessageItem;
|
|
199
|
+
mediaType: 'image_item' | 'file_item';
|
|
200
|
+
timeoutMs?: number;
|
|
201
|
+
}): Promise<{
|
|
202
|
+
data: Buffer;
|
|
203
|
+
fileName?: string;
|
|
204
|
+
} | null>;
|
|
118
205
|
/** Markdown → 纯文本(代码块/图片/链接/表格/标题/粗斜体/删除线 7 类) */
|
|
119
206
|
export declare function markdownToPlainText(text: string): string;
|
package/lib/weixin-bridge.d.ts
CHANGED
|
@@ -14,23 +14,28 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { Context } from 'cordis';
|
|
16
16
|
import { type WeixinAccountCredential } from './weixin-route.js';
|
|
17
|
+
import { type WeixinMessage } from './weixin-api.js';
|
|
18
|
+
/** 测试辅助:清空 typing_ticket 缓存(生产零调用) */
|
|
19
|
+
export declare function resetWeixinTypingCache(): void;
|
|
17
20
|
/**
|
|
18
21
|
* 处理单条微信消息:路由 → skiff 会话(固定 id 创建/延续)→ 提问 → 回复回写。
|
|
19
22
|
*
|
|
20
23
|
* 会话语义:`weixinSessionIdFor(fromUserId)` 固定可重建——同用户长期同一会话;
|
|
21
24
|
* 进程内已有(getSkiffAgent 命中)→ 延续;无(首次/进程重启)→ createSkiffAgent
|
|
22
|
-
*
|
|
25
|
+
* 固定 id resume-or-create(v1.27.2):磁盘已有持久化 log → resume(历史延续,
|
|
26
|
+
* 重启后记忆保留——真正的"同用户长期延续");无 log(首次)→ create。
|
|
27
|
+
* "新的对话已开始"通知仅真正首次(create)时发送;resume/进程内延续不发。
|
|
28
|
+
*
|
|
29
|
+
* 完善(v1.27.3):
|
|
30
|
+
* - **语音支持**:`voice_item.text` = 微信服务端自带语音转写 → 与文本同路径进对话
|
|
31
|
+
* (无需下载/ASR);语音无转写 → 降级提示"暂时无法解析"
|
|
32
|
+
* - **正在输入**:处理前 sendtyping 1(微信显示"正在输入..."),处理后(含异常)0
|
|
33
|
+
* - **媒体接收(图片/文件)**:桥侧 CDN 下载 + AES 解密 → 落盘 CCC 根
|
|
34
|
+
* `_tmp/weixin-inbound/<userhash>/` → question 注入「存在性 + 路径」(ACC 层只保证
|
|
35
|
+
* 可达性——"让会话知道文件的存在并可以拿到";识别/解析归角色 LLM 决策,不编排)。
|
|
36
|
+
* 降级不静默:下载失败 / 超 20MB → 注入说明进对话;typing 窗口覆盖下载(M7)。
|
|
23
37
|
*/
|
|
24
|
-
export declare function handleIncoming(ctx: Context, root: string,
|
|
25
|
-
from_user_id?: string;
|
|
26
|
-
context_token?: string;
|
|
27
|
-
item_list?: Array<{
|
|
28
|
-
type?: number;
|
|
29
|
-
text_item?: {
|
|
30
|
-
text?: string;
|
|
31
|
-
};
|
|
32
|
-
}>;
|
|
33
|
-
}): Promise<void>;
|
|
38
|
+
export declare function handleIncoming(ctx: Context, root: string, accountId: string, cred: WeixinAccountCredential, msg: Pick<WeixinMessage, 'from_user_id' | 'context_token' | 'item_list'>): Promise<void>;
|
|
34
39
|
/**
|
|
35
40
|
* 启动/重建某 CCC 的桥:读配置 → 对每个 enabled + 有凭据的账号启动轮询循环。
|
|
36
41
|
* 已存在(配置变化热重建)→ 先停旧循环再启动新的。
|
|
@@ -282,7 +282,8 @@ function runLocalStore(root, args) {
|
|
|
282
282
|
* 职责(零 DSH 依赖,可独立单测):
|
|
283
283
|
* - 读取 CCC 的 weixin 配置(.opencode/serenity.json weixin 段——结构/路由/开关)
|
|
284
284
|
* - 读取/写入 CCC localstore 的账号凭据(weixin.accounts.<accountId>.token/.baseUrl/.userId)
|
|
285
|
-
* - 微信用户 → skiff 会话 id 映射(固定可重建:skiff-weixin-<sha256(userid)>.slice(
|
|
285
|
+
* - 微信用户 → skiff 会话 id 映射(固定可重建:skiff-weixin-<sha256(userid)>.slice(1, 17);
|
|
286
|
+
* v1.27.3 错位一位避开损坏 log)
|
|
286
287
|
* - 路由匹配(exact user → 通配 * 兜底)
|
|
287
288
|
*
|
|
288
289
|
* 配置归属(S142 用户拍板):**CCC 级**——serenity.json 结构 + localstore 凭据分离;
|
|
@@ -291,15 +292,19 @@ function runLocalStore(root, args) {
|
|
|
291
292
|
var weixin_route_exports = /* @__PURE__ */ __exportAll({
|
|
292
293
|
WEIXIN_SESSION_PREFIX: () => WEIXIN_SESSION_PREFIX,
|
|
293
294
|
clearWeixinCredential: () => clearWeixinCredential,
|
|
295
|
+
extractWeixinMedia: () => extractWeixinMedia,
|
|
294
296
|
extractWeixinText: () => extractWeixinText,
|
|
297
|
+
hasVoiceItem: () => hasVoiceItem,
|
|
295
298
|
matchWeixinRoute: () => matchWeixinRoute,
|
|
296
299
|
nextWeixinAccountId: () => nextWeixinAccountId,
|
|
297
300
|
readWeixinCredential: () => readWeixinCredential,
|
|
298
301
|
readWeixinSettings: () => readWeixinSettings,
|
|
299
302
|
removeWeixinAccount: () => removeWeixinAccount,
|
|
303
|
+
sanitizeFileName: () => sanitizeFileName,
|
|
300
304
|
saveWeixinRoutes: () => saveWeixinRoutes,
|
|
301
305
|
setWeixinEnabled: () => setWeixinEnabled,
|
|
302
306
|
upsertWeixinAccount: () => upsertWeixinAccount,
|
|
307
|
+
weixinInboundDir: () => weixinInboundDir,
|
|
303
308
|
weixinSessionIdFor: () => weixinSessionIdFor,
|
|
304
309
|
writeWeixinCredential: () => writeWeixinCredential
|
|
305
310
|
});
|
|
@@ -312,9 +317,12 @@ function accountKeyPart(accountId) {
|
|
|
312
317
|
}
|
|
313
318
|
/** 微信桥 skiff 会话 id 前缀(seams 旁路判定;对外面纯净——守卫识别外部面) */
|
|
314
319
|
const WEIXIN_SESSION_PREFIX = "skiff-weixin-";
|
|
315
|
-
/** 微信用户 → 固定会话 id
|
|
320
|
+
/** 微信用户 → 固定会话 id(同用户长期同一会话,记忆延续;多用户天然隔离)。
|
|
321
|
+
* **v1.27.2 错位一位(用户拍板)**:`.slice(0, 16)` → `.slice(1, 17)`——旧规则生成的
|
|
322
|
+
* 固定 id 已与磁盘损坏的持久化 log 绑定(dsh 不可硬删,create 同 id 必撞)→
|
|
323
|
+
* 新规则下同用户生成**全新 id**,避开损坏 log;固定可重建语义不变(同用户恒同 id)。 */
|
|
316
324
|
function weixinSessionIdFor(fromUserId) {
|
|
317
|
-
const digest = createHash("sha256").update(fromUserId).digest("hex").slice(
|
|
325
|
+
const digest = createHash("sha256").update(fromUserId).digest("hex").slice(1, 17);
|
|
318
326
|
return `${WEIXIN_SESSION_PREFIX}${digest}`;
|
|
319
327
|
}
|
|
320
328
|
/**
|
|
@@ -386,18 +394,59 @@ function matchWeixinRoute(routes, fromUserId) {
|
|
|
386
394
|
return null;
|
|
387
395
|
}
|
|
388
396
|
/**
|
|
389
|
-
*
|
|
397
|
+
* 提取消息文本(文本项 + **语音项的服务端转写**):
|
|
398
|
+
* - type 1 TEXT → `text_item.text`
|
|
399
|
+
* - type 3 VOICE → `voice_item.text`——**微信服务端自带语音转写**(官方 openclaw-weixin
|
|
400
|
+
* 直接读该字段并入对话文本,无需下载/ASR;对齐参考实现 index.ts voiceTexts)。
|
|
390
401
|
* @returns 纯文本;无文本 → null
|
|
391
402
|
*/
|
|
392
403
|
function extractWeixinText(msg) {
|
|
393
404
|
const items = msg.item_list;
|
|
394
405
|
if (!Array.isArray(items) || items.length === 0) return null;
|
|
395
|
-
for (const item of items)
|
|
396
|
-
|
|
397
|
-
|
|
406
|
+
for (const item of items) {
|
|
407
|
+
if (item.type === 1 && typeof item.text_item?.text === "string") {
|
|
408
|
+
const text = item.text_item.text.trim();
|
|
409
|
+
if (text !== "") return text;
|
|
410
|
+
}
|
|
411
|
+
if (item.type === 3 && typeof item.voice_item?.text === "string") {
|
|
412
|
+
const text = item.voice_item.text.trim();
|
|
413
|
+
if (text !== "") return text;
|
|
414
|
+
}
|
|
398
415
|
}
|
|
399
416
|
return null;
|
|
400
417
|
}
|
|
418
|
+
/** 是否包含语音项(无转写文本时的降级提示判定——bridge 用) */
|
|
419
|
+
function hasVoiceItem(msg) {
|
|
420
|
+
return (msg.item_list ?? []).some((item) => item.type === 3 && item.voice_item != null);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* 提取消息媒体项(图片 type 2 / 文件 type 4):
|
|
424
|
+
* 只收集**带 media 元数据**的项(下载前置条件);返回 kind + **完整消息项**(downloadMedia 用
|
|
425
|
+
* `item[mediaType]` 取 image_item/file_item)。纯 ACC 层可达性——不做内容处理(v0.2 用户拍板)。
|
|
426
|
+
*/
|
|
427
|
+
function extractWeixinMedia(msg) {
|
|
428
|
+
const out = [];
|
|
429
|
+
for (const item of msg.item_list ?? []) if (item.type === 2 && item.image_item?.media) out.push({
|
|
430
|
+
kind: "image",
|
|
431
|
+
fileName: void 0,
|
|
432
|
+
item
|
|
433
|
+
});
|
|
434
|
+
else if (item.type === 4 && item.file_item?.media) out.push({
|
|
435
|
+
kind: "file",
|
|
436
|
+
fileName: item.file_item.file_name,
|
|
437
|
+
item
|
|
438
|
+
});
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
/** 文件名净化:basename + 去控制字符 + 截断 128(防路径穿越——落盘安全) */
|
|
442
|
+
function sanitizeFileName(name) {
|
|
443
|
+
return (name.split(/[\\/]/).pop() ?? "").replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 128);
|
|
444
|
+
}
|
|
445
|
+
/** 落盘目录:`<CCC 根>/_tmp/weixin-inbound/<userhash>/`(gitignored ✓;agent read 边界内 ✓;按用户分目录) */
|
|
446
|
+
function weixinInboundDir(root, fromUserId) {
|
|
447
|
+
const userHash = createHash("sha256").update(fromUserId).digest("hex").slice(0, 12);
|
|
448
|
+
return join(root, "_tmp", "weixin-inbound", userHash);
|
|
449
|
+
}
|
|
401
450
|
/**
|
|
402
451
|
* 读取 serenity.json 文件(原始;不存在 → 空对象)。
|
|
403
452
|
* 注意:loadSerenityConfig 返回解析对象但无法写回——此处直接文件读写。
|
|
@@ -482,4 +531,4 @@ function setWeixinEnabled(root, enabled) {
|
|
|
482
531
|
return next;
|
|
483
532
|
}
|
|
484
533
|
//#endregion
|
|
485
|
-
export {
|
|
534
|
+
export { readWeixinCredential as a, weixinInboundDir as c, LOCALSTORE_SCOPES as d, checkLocalstoreGitCompliance as f, runLocalStore as h, matchWeixinRoute as i, weixinSessionIdFor as l, readGitTrack as m, extractWeixinText as n, readWeixinSettings as o, localstorePath as p, hasVoiceItem as r, sanitizeFileName as s, extractWeixinMedia as t, weixin_route_exports as u };
|