@shgroup/dsh-serenity-hooks 1.69.0 → 1.69.2
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 +7 -4
- package/dsh.plugin.json +1 -1
- package/lib/{ccc-roots-RMzGU-CJ.js → ccc-roots-RzasspMr.js} +1 -1
- package/lib/ccc.d.ts +0 -63
- package/lib/client.js +4 -338
- package/lib/index.d.ts +0 -28
- package/lib/index.js +42 -1239
- package/lib/{message-source-Catc7q1z.js → message-source-DWWECjGB.js} +1 -1
- package/lib/ports.d.ts +3 -5
- package/lib/{settings-section-B1YzjFz6.js → settings-section-0TPYWug9.js} +3 -29
- package/lib/settings-section.d.ts +0 -22
- package/lib/{skiff-role-Boc4SKct.js → skiff-role-cwxAt0gE.js} +29 -16
- package/lib/skiff-role.d.ts +18 -3
- package/lib/weixin-route-D_DvgPqy.js +558 -0
- package/package.json +1 -1
- package/lib/acp-auth.d.ts +0 -273
- package/lib/acp-face.d.ts +0 -244
- package/lib/localstore-ops-ylQoPz1Q.js +0 -282
- package/lib/weixin-route-BoSC-uPp.js +0 -281
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
|
|
2
|
+
import { s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS } from "./ccc-C33QWoWZ.js";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
//#region src/localstore-ops.ts
|
|
7
|
+
/**
|
|
8
|
+
* localstore-ops.ts — localstore 纯逻辑层(零 DSH 依赖,可独立单测)
|
|
9
|
+
*
|
|
10
|
+
* S134 重设计(v1.16.7):存储从 ~/.serenity/ 迁到 **CCC 根根目录 localstore.json**
|
|
11
|
+
* (JSON 格式——方便 MSM 直接 read + JSON.parse 读取,零解析依赖)。
|
|
12
|
+
*
|
|
13
|
+
* git 提交策略(可靠机制 × 用户自由):
|
|
14
|
+
* - 配置:.opencode/serenity.json `localstore.gitTrack`: "allow"(可提交)| "deny"(禁提交)
|
|
15
|
+
* - **缺省 deny(没配就是不提交)**;且 deny 的保证**不依赖 dsh 运行**——
|
|
16
|
+
* 写入时自动确保 .gitignore 含 localstore.json(物理保证:即使 dsh 不在、
|
|
17
|
+
* 用户手动 git commit 也不会误提交),container_git 检查为第二道防线(拒绝 + 提示)
|
|
18
|
+
* - allow:放行(文件可提交,用户自行管理 .gitignore)
|
|
19
|
+
*
|
|
20
|
+
* 存储结构(JSON 顶层分节):
|
|
21
|
+
* { "credentials": { "HOME_GITLAB_TOKEN": "xxx" }, "<config节>": { "<key>": "v" } }
|
|
22
|
+
* credentials 为保留节(credential 命名空间,key 大写蛇形);其余节归 config 命名空间
|
|
23
|
+
* (path = section.key,如 cheap-worker.models)。
|
|
24
|
+
*/
|
|
25
|
+
const LOCALSTORE_SCOPES = ["credential", "config"];
|
|
26
|
+
/** 存储文件名(CCC 根根目录) */
|
|
27
|
+
const LOCALSTORE_FILENAME = "localstore.json";
|
|
28
|
+
/** 凭据保留节名(JSON 顶层) */
|
|
29
|
+
const CREDENTIALS_SECTION = "credentials";
|
|
30
|
+
/** 凭据 key 规范:大写蛇形,如 HOME_GITLAB_TOKEN */
|
|
31
|
+
const CREDENTIAL_KEY_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
32
|
+
/** 配置节名规范:小写字母数字连字符 */
|
|
33
|
+
const CONFIG_SECTION_RE = /^[a-z][a-z0-9-]*$/;
|
|
34
|
+
/** 配置 key 规范(节内):小驼峰,如 defaultModel */
|
|
35
|
+
const CONFIG_KEY_RE = /^[a-z][a-zA-Z0-9_]*$/;
|
|
36
|
+
/** 存储文件绝对路径(CCC 根根目录) */
|
|
37
|
+
function localstorePath(root) {
|
|
38
|
+
return join(root, LOCALSTORE_FILENAME);
|
|
39
|
+
}
|
|
40
|
+
/** git 策略:serenity.json localstore.gitTrack,缺省 deny(不提交) */
|
|
41
|
+
function readGitTrack(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
42
|
+
return loadSerenityConfig(root, paths).localstore?.gitTrack === "allow" ? "allow" : "deny";
|
|
43
|
+
}
|
|
44
|
+
/** .gitignore 是否已覆盖 localstore 文件(非空非注释行含文件名即算) */
|
|
45
|
+
function isLocalstoreGitignored(root) {
|
|
46
|
+
const gi = join(root, ".gitignore");
|
|
47
|
+
if (!existsSync(gi)) return false;
|
|
48
|
+
return readFileSync(gi, "utf-8").split("\n").some((l) => {
|
|
49
|
+
const t = l.trim();
|
|
50
|
+
return t !== "" && !t.startsWith("#") && t.includes("localstore.json");
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 确保 .gitignore 含 localstore 文件(deny 时的物理保证,不依赖 dsh 运行):
|
|
55
|
+
* 写入 localstore 时调用——deny(含缺省)且 .gitignore 未覆盖 → 自动追加一行。
|
|
56
|
+
* allow → 不写入(放行,文件可提交)。
|
|
57
|
+
*/
|
|
58
|
+
function ensureLocalstoreGitignored(root) {
|
|
59
|
+
if (readGitTrack(root) === "allow") return { status: "allow" };
|
|
60
|
+
if (isLocalstoreGitignored(root)) return { status: "ignored" };
|
|
61
|
+
const gi = join(root, ".gitignore");
|
|
62
|
+
const existing = existsSync(gi) ? readFileSync(gi, "utf-8") : "";
|
|
63
|
+
const line = `${LOCALSTORE_FILENAME} # ACC localstore (localstore.gitTrack defaults to deny — do not commit)`;
|
|
64
|
+
writeFileSync(gi, (existing.endsWith("\n") || existing === "" ? existing : existing + "\n") + line + "\n", "utf-8");
|
|
65
|
+
return { status: "appended" };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* container_git 联动检查(第二道防线):文件存在 && deny && .gitignore 未覆盖 → 不通过。
|
|
69
|
+
* 调用方(container_git commit)据此拒绝提交;status 可输出 warning。
|
|
70
|
+
*/
|
|
71
|
+
function checkLocalstoreGitCompliance(root) {
|
|
72
|
+
if (!existsSync(localstorePath(root))) return { ok: true };
|
|
73
|
+
if (readGitTrack(root) === "allow") return { ok: true };
|
|
74
|
+
if (isLocalstoreGitignored(root)) return { ok: true };
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
reason: `localstore.json must not be committed (localstore.gitTrack=deny default) but .gitignore does not cover it — add ${LOCALSTORE_FILENAME} to .gitignore (or set localstore.gitTrack=allow to explicitly permit)`
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** 已告警的坏 localstore 路径(进程级去重;F-07:凭据文件损坏不得静默) */
|
|
81
|
+
const warnedBrokenStores = /* @__PURE__ */ new Set();
|
|
82
|
+
/** 读取全文件(顶层分节);文件不存在 → 空;**坏 JSON → 空 + 响亮告警**(每路径一次) */
|
|
83
|
+
function readAll(root) {
|
|
84
|
+
const p = localstorePath(root);
|
|
85
|
+
if (!existsSync(p)) return {};
|
|
86
|
+
try {
|
|
87
|
+
const v = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""));
|
|
88
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
89
|
+
return {};
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (!warnedBrokenStores.has(p)) {
|
|
92
|
+
warnedBrokenStores.add(p);
|
|
93
|
+
console.warn(`[serenity-hooks] ✗ localstore 解析失败,已按空存储继续(其中凭据/配置对工具表现为"未设置",请修 JSON): ${p} — ${String(err?.message ?? err)}`);
|
|
94
|
+
}
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** 写回全文件(2 空格缩进 + 尾换行,方便 MSM 直接读取) */
|
|
99
|
+
function writeAll(root, data) {
|
|
100
|
+
writeFileSync(localstorePath(root), JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
101
|
+
}
|
|
102
|
+
/** 读取命名空间全部条目:credential → 扁平;config → 分节(剔除 credentials 保留节) */
|
|
103
|
+
function readStore(root, scope) {
|
|
104
|
+
const all = readAll(root);
|
|
105
|
+
if (scope === "credential") return all[CREDENTIALS_SECTION] ?? {};
|
|
106
|
+
const { [CREDENTIALS_SECTION]: _cred, ...rest } = all;
|
|
107
|
+
return rest;
|
|
108
|
+
}
|
|
109
|
+
/** 校验凭据 key 合法(大写蛇形);不合法抛错 */
|
|
110
|
+
function assertCredentialKey(key) {
|
|
111
|
+
if (!CREDENTIAL_KEY_RE.test(key)) throw new Error(`credential key "${key}" must match UPPER_SNAKE ^[A-Z][A-Z0-9_]*$ (e.g. HOME_GITLAB_TOKEN)`);
|
|
112
|
+
}
|
|
113
|
+
/** 校验 config 路径(节.key);不合法抛错 */
|
|
114
|
+
function assertConfigPath(path) {
|
|
115
|
+
const idx = path.indexOf(".");
|
|
116
|
+
if (idx <= 0 || idx === path.length - 1) throw new Error(`config path "${path}" must be section.key (e.g. cheap-worker.models)`);
|
|
117
|
+
const section = path.slice(0, idx);
|
|
118
|
+
const key = path.slice(idx + 1);
|
|
119
|
+
if (!CONFIG_SECTION_RE.test(section)) throw new Error(`config section "${section}" must match ^[a-z][a-z0-9-]*$`);
|
|
120
|
+
if (!CONFIG_KEY_RE.test(key)) throw new Error(`config key "${key}" must match lowerCamel ^[a-z][a-zA-Z0-9_]*$ (e.g. defaultModel)`);
|
|
121
|
+
return {
|
|
122
|
+
section,
|
|
123
|
+
key
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** 写入单个条目(自动建文件;deny 默认时同步确保 .gitignore 物理保证) */
|
|
127
|
+
function writeEntry(root, scope, name, value) {
|
|
128
|
+
const all = readAll(root);
|
|
129
|
+
if (scope === "credential") {
|
|
130
|
+
assertCredentialKey(name);
|
|
131
|
+
all[CREDENTIALS_SECTION] ??= {};
|
|
132
|
+
all[CREDENTIALS_SECTION][name] = value;
|
|
133
|
+
} else {
|
|
134
|
+
const { section, key } = assertConfigPath(name);
|
|
135
|
+
all[section] ??= {};
|
|
136
|
+
all[section][key] = value;
|
|
137
|
+
}
|
|
138
|
+
writeAll(root, all);
|
|
139
|
+
ensureLocalstoreGitignored(root);
|
|
140
|
+
}
|
|
141
|
+
/** 删除单个条目(不存在返回 false);空节自动移除 */
|
|
142
|
+
function unsetEntry(root, scope, name) {
|
|
143
|
+
const all = readAll(root);
|
|
144
|
+
if (scope === "credential") {
|
|
145
|
+
assertCredentialKey(name);
|
|
146
|
+
const sec = all[CREDENTIALS_SECTION];
|
|
147
|
+
if (!sec || !(name in sec)) return false;
|
|
148
|
+
delete sec[name];
|
|
149
|
+
if (Object.keys(sec).length === 0) delete all[CREDENTIALS_SECTION];
|
|
150
|
+
} else {
|
|
151
|
+
const { section, key } = assertConfigPath(name);
|
|
152
|
+
const sec = all[section];
|
|
153
|
+
if (!sec || !(key in sec)) return false;
|
|
154
|
+
delete sec[key];
|
|
155
|
+
if (Object.keys(sec).length === 0) delete all[section];
|
|
156
|
+
}
|
|
157
|
+
writeAll(root, all);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
/** 读取单个条目值;不存在返回 null */
|
|
161
|
+
function getEntry(root, scope, name) {
|
|
162
|
+
const all = readAll(root);
|
|
163
|
+
if (scope === "credential") {
|
|
164
|
+
assertCredentialKey(name);
|
|
165
|
+
return all[CREDENTIALS_SECTION]?.[name] ?? null;
|
|
166
|
+
}
|
|
167
|
+
const { section, key } = assertConfigPath(name);
|
|
168
|
+
return all[section]?.[key] ?? null;
|
|
169
|
+
}
|
|
170
|
+
/** 列出 key(凭据只返回 key 名,不返回值) */
|
|
171
|
+
function listKeys(root, scope) {
|
|
172
|
+
const data = readStore(root, scope);
|
|
173
|
+
if (scope === "credential") return Object.keys(data);
|
|
174
|
+
return Object.entries(data).flatMap(([section, entries]) => Object.keys(entries).map((key) => `${section}.${key}`));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* doc 说明文本:输出存储位置/格式/key 规范/git 策略/读写方法。
|
|
178
|
+
* agent 据此可直接用 fs 工具(read/write)自己读写凭据/配置。
|
|
179
|
+
*/
|
|
180
|
+
function docText(root) {
|
|
181
|
+
const path = localstorePath(root);
|
|
182
|
+
return [
|
|
183
|
+
"# localstore — ACC local credential/config storage (standard, S134 redesign)",
|
|
184
|
+
"",
|
|
185
|
+
"One tool manages two namespaces: credential (credentials) + config (preferences).",
|
|
186
|
+
`Stored at the CCC root in ${path} (JSON format; MSMs can read + JSON.parse directly).`,
|
|
187
|
+
"",
|
|
188
|
+
"## Git commit policy",
|
|
189
|
+
"- Config: .opencode/serenity.json `localstore.gitTrack`: `\"allow\"` (may commit) | `\"deny\"` (must not commit; .dsh fallback)",
|
|
190
|
+
"- **Default deny (unset = not committed)**; when deny, writes automatically ensure .gitignore contains localstore.json",
|
|
191
|
+
" (physical guarantee, independent of dsh runtime); container_git commit checks and refuses",
|
|
192
|
+
"- To commit: set `\"localstore\": { \"gitTrack\": \"allow\" }` and remove localstore.json from .gitignore",
|
|
193
|
+
"",
|
|
194
|
+
"## Format (JSON top-level sections)",
|
|
195
|
+
"```json",
|
|
196
|
+
"{",
|
|
197
|
+
" \"credentials\": {",
|
|
198
|
+
" \"HOME_GITLAB_TOKEN\": \"xxx\"",
|
|
199
|
+
" },",
|
|
200
|
+
" \"cheap-worker\": {",
|
|
201
|
+
" \"models\": [\"minimax-cn-coding-plan/MiniMax-M3\"]",
|
|
202
|
+
" }",
|
|
203
|
+
"}",
|
|
204
|
+
"```",
|
|
205
|
+
"- credentials is a reserved section (credentials, UPPER_SNAKE keys); other sections = config (path = section.key)",
|
|
206
|
+
"",
|
|
207
|
+
"## Key conventions",
|
|
208
|
+
"- credential key: ^[A-Z][A-Z0-9_]*$ (e.g. HOME_GITLAB_TOKEN)",
|
|
209
|
+
"- config path: section.key (section ^[a-z][a-z0-9-]*$, key lowerCamel ^[a-z][a-zA-Z0-9_]*$, e.g. cheap-worker.models)",
|
|
210
|
+
"",
|
|
211
|
+
"## Reading (agents may use fs tools directly)",
|
|
212
|
+
`- Read ${path} with the read tool → JSON.parse`,
|
|
213
|
+
"- Or use localstore get <name> [--scope credential|config]",
|
|
214
|
+
"",
|
|
215
|
+
"## Writing",
|
|
216
|
+
"- Recommended: localstore set <name> <value> [--scope ...] (auto-creates file / preserves other entries / syncs .gitignore)",
|
|
217
|
+
"- Or modify the file directly with write/edit, keeping the JSON valid",
|
|
218
|
+
"",
|
|
219
|
+
"## Security boundary",
|
|
220
|
+
"- list/show return only key names for credentials, never values",
|
|
221
|
+
"- Credential values should be used internally by the agent; never write them into conversation or logs",
|
|
222
|
+
"- Default deny: the file is not committed to git (.gitignore physical guarantee + container_git check fallback)",
|
|
223
|
+
""
|
|
224
|
+
].join("\n");
|
|
225
|
+
}
|
|
226
|
+
/** 运行 localstore 操作(纯逻辑;返回 JSON 值供工具 render) */
|
|
227
|
+
function runLocalStore(root, args) {
|
|
228
|
+
const scope = args.scope === "config" ? "config" : "credential";
|
|
229
|
+
switch (args.action) {
|
|
230
|
+
case "list": return {
|
|
231
|
+
scope,
|
|
232
|
+
keys: listKeys(root, scope)
|
|
233
|
+
};
|
|
234
|
+
case "get": {
|
|
235
|
+
if (!args.name) throw new Error("get requires name");
|
|
236
|
+
const value = getEntry(root, scope, args.name);
|
|
237
|
+
if (value === null) throw new Error(`not found: ${args.name} (scope=${scope})`);
|
|
238
|
+
return {
|
|
239
|
+
scope,
|
|
240
|
+
name: args.name,
|
|
241
|
+
value,
|
|
242
|
+
source: scope
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
case "set": {
|
|
246
|
+
if (!args.name) throw new Error("set requires name");
|
|
247
|
+
if (args.value === void 0) throw new Error("set requires value");
|
|
248
|
+
writeEntry(root, scope, args.name, args.value);
|
|
249
|
+
const git = checkLocalstoreGitCompliance(root);
|
|
250
|
+
return {
|
|
251
|
+
scope,
|
|
252
|
+
name: args.name,
|
|
253
|
+
set: true,
|
|
254
|
+
path: localstorePath(root),
|
|
255
|
+
gitTrack: readGitTrack(root),
|
|
256
|
+
gitOk: git.ok,
|
|
257
|
+
git: git.reason ? { warning: git.reason } : null
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
case "unset": {
|
|
261
|
+
if (!args.name) throw new Error("unset requires name");
|
|
262
|
+
const removed = unsetEntry(root, scope, args.name);
|
|
263
|
+
return {
|
|
264
|
+
scope,
|
|
265
|
+
name: args.name,
|
|
266
|
+
removed
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
case "show": {
|
|
270
|
+
if (!args.name) throw new Error("show requires name");
|
|
271
|
+
const exists = getEntry(root, scope, args.name) !== null;
|
|
272
|
+
return {
|
|
273
|
+
scope,
|
|
274
|
+
name: args.name,
|
|
275
|
+
exists,
|
|
276
|
+
path: localstorePath(root)
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
case "doc": return { doc: docText(root) };
|
|
280
|
+
default: throw new Error(`Unknown subcommand: ${args.action} (available: list/get/set/unset/show/doc)`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/weixin-route.ts
|
|
285
|
+
/**
|
|
286
|
+
* weixin-route.ts — 微信桥 CCC 配置层纯逻辑(F4c-3,v1.27.0 实验性)
|
|
287
|
+
*
|
|
288
|
+
* 职责(零 DSH 依赖,可独立单测):
|
|
289
|
+
* - 读取 CCC 的 weixin 配置(.opencode/serenity.json weixin 段——结构/路由/开关)
|
|
290
|
+
* - 读取/写入 CCC localstore 的账号凭据(weixin.accounts.<accountId>.token/.baseUrl/.userId)
|
|
291
|
+
* - 微信用户 → skiff 会话 id 映射(固定可重建:skiff-weixin-<sha256(userid)>.slice(1, 17);
|
|
292
|
+
* v1.27.3 错位一位避开损坏 log)
|
|
293
|
+
* - 路由匹配(exact user → 通配 * 兜底)
|
|
294
|
+
*
|
|
295
|
+
* 配置归属(S142 用户拍板):**CCC 级**——serenity.json 结构 + localstore 凭据分离;
|
|
296
|
+
* 不绑定具体 role(用户自选路由目标)。
|
|
297
|
+
*/
|
|
298
|
+
var weixin_route_exports = /* @__PURE__ */ __exportAll({
|
|
299
|
+
clearWeixinCredential: () => clearWeixinCredential,
|
|
300
|
+
extractWeixinMedia: () => extractWeixinMedia,
|
|
301
|
+
extractWeixinText: () => extractWeixinText,
|
|
302
|
+
hasVoiceItem: () => hasVoiceItem,
|
|
303
|
+
matchWeixinRoute: () => matchWeixinRoute,
|
|
304
|
+
nextWeixinAccountId: () => nextWeixinAccountId,
|
|
305
|
+
readWeixinCredential: () => readWeixinCredential,
|
|
306
|
+
readWeixinSettings: () => readWeixinSettings,
|
|
307
|
+
removeWeixinAccount: () => removeWeixinAccount,
|
|
308
|
+
sanitizeFileName: () => sanitizeFileName,
|
|
309
|
+
saveWeixinRoutes: () => saveWeixinRoutes,
|
|
310
|
+
setWeixinEnabled: () => setWeixinEnabled,
|
|
311
|
+
upsertWeixinAccount: () => upsertWeixinAccount,
|
|
312
|
+
weixinInboundDir: () => weixinInboundDir,
|
|
313
|
+
weixinSessionIdFor: () => weixinSessionIdFor,
|
|
314
|
+
writeWeixinCredential: () => writeWeixinCredential
|
|
315
|
+
});
|
|
316
|
+
/** localstore 凭据键前缀(**credential scope**——token 是凭据,走 UPPER_SNAKE 键;
|
|
317
|
+
* 格式 WEIXIN_<ACCOUNT_ID>_TOKEN / _BASEURL / _USERID(accountId 归一为大写蛇形)) */
|
|
318
|
+
const LS_PREFIX = "WEIXIN_";
|
|
319
|
+
/** accountId → UPPER_SNAKE 片段(wechat-1 → WECHAT_1) */
|
|
320
|
+
function accountKeyPart(accountId) {
|
|
321
|
+
return accountId.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
322
|
+
}
|
|
323
|
+
/** 微信桥 skiff 会话 id 前缀(seams 旁路判定;对外面纯净——守卫识别外部面) */
|
|
324
|
+
const WEIXIN_SESSION_PREFIX = "skiff-weixin-";
|
|
325
|
+
/** 微信用户 → 固定会话 id(同用户长期同一会话,记忆延续;多用户天然隔离)。
|
|
326
|
+
* **v1.27.2 错位一位(用户拍板)**:`.slice(0, 16)` → `.slice(1, 17)`——旧规则生成的
|
|
327
|
+
* 固定 id 已与磁盘损坏的持久化 log 绑定(dsh 不可硬删,create 同 id 必撞)→
|
|
328
|
+
* 新规则下同用户生成**全新 id**,避开损坏 log;固定可重建语义不变(同用户恒同 id)。 */
|
|
329
|
+
function weixinSessionIdFor(fromUserId) {
|
|
330
|
+
const digest = createHash("sha256").update(fromUserId).digest("hex").slice(1, 17);
|
|
331
|
+
return `${WEIXIN_SESSION_PREFIX}${digest}`;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 读取 CCC 微信桥配置(未配置/未启用 → 归一默认)。
|
|
335
|
+
* @returns 归一化 WeixinSettings(accounts/routes 数组清洗;空段 → 空数组)
|
|
336
|
+
*/
|
|
337
|
+
function readWeixinSettings(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
338
|
+
try {
|
|
339
|
+
const w = loadSerenityConfig(root, paths).weixin;
|
|
340
|
+
if (!w || typeof w !== "object") return {
|
|
341
|
+
enabled: false,
|
|
342
|
+
accounts: [],
|
|
343
|
+
routes: []
|
|
344
|
+
};
|
|
345
|
+
return {
|
|
346
|
+
enabled: w.enabled === true,
|
|
347
|
+
botType: typeof w.botType === "string" && w.botType !== "" ? w.botType : void 0,
|
|
348
|
+
hook: typeof w.hook === "string" && w.hook.trim() !== "" ? w.hook.trim() : void 0,
|
|
349
|
+
autoReplyWithLastMessage: w.autoReplyWithLastMessage !== false,
|
|
350
|
+
fallbackOnNoSend: w.fallbackOnNoSend === true,
|
|
351
|
+
accounts: Array.isArray(w.accounts) ? w.accounts.filter((a) => typeof a === "object" && a !== null && typeof a.accountId === "string" && a.accountId !== "").map((a) => ({
|
|
352
|
+
accountId: a.accountId,
|
|
353
|
+
name: typeof a.name === "string" ? a.name : void 0,
|
|
354
|
+
enabled: a.enabled !== false
|
|
355
|
+
})) : [],
|
|
356
|
+
routes: Array.isArray(w.routes) ? w.routes.filter((r) => typeof r === "object" && r !== null && typeof r.user === "string" && r.user !== "" && typeof r.role === "string" && r.role !== "") : [],
|
|
357
|
+
aliases: readAliases(w)
|
|
358
|
+
};
|
|
359
|
+
} catch {
|
|
360
|
+
return {
|
|
361
|
+
enabled: false,
|
|
362
|
+
accounts: [],
|
|
363
|
+
routes: []
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* 别名表清洗(v1.56.10):键归一为小写、值必须是非空字符串;非法项静默丢弃。
|
|
369
|
+
* 空/缺省 → `{}`(此时别名解析不可用,只能用裸 iLink 用户 id)。
|
|
370
|
+
*/
|
|
371
|
+
function readAliases(w) {
|
|
372
|
+
const raw = w.aliases;
|
|
373
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
|
|
374
|
+
const out = {};
|
|
375
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
376
|
+
const alias = k.trim().toLowerCase();
|
|
377
|
+
if (alias === "" || typeof v !== "string" || v.trim() === "") continue;
|
|
378
|
+
out[alias] = v.trim();
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
/** localstore 凭据键(credential scope: WEIXIN_<ACCOUNT_ID>_<FIELD>) */
|
|
383
|
+
function lsKey(accountId, field) {
|
|
384
|
+
return `${LS_PREFIX}${accountKeyPart(accountId)}_${field}`;
|
|
385
|
+
}
|
|
386
|
+
/** 读取账号凭据(token 缺失 → null——账号未绑定) */
|
|
387
|
+
function readWeixinCredential(root, accountId) {
|
|
388
|
+
const token = getEntry(root, "credential", lsKey(accountId, "TOKEN"));
|
|
389
|
+
if (!token) return null;
|
|
390
|
+
const baseUrl = getEntry(root, "credential", lsKey(accountId, "BASEURL"));
|
|
391
|
+
const userId = getEntry(root, "credential", lsKey(accountId, "USERID"));
|
|
392
|
+
return {
|
|
393
|
+
token,
|
|
394
|
+
baseUrl: baseUrl ?? "",
|
|
395
|
+
userId: userId ?? void 0
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/** 写入账号凭据(扫码 confirmed 后;token 必写,baseUrl/userId 可选) */
|
|
399
|
+
function writeWeixinCredential(root, accountId, cred) {
|
|
400
|
+
writeEntry(root, "credential", lsKey(accountId, "TOKEN"), cred.token);
|
|
401
|
+
if (cred.baseUrl) writeEntry(root, "credential", lsKey(accountId, "BASEURL"), cred.baseUrl);
|
|
402
|
+
if (cred.userId) writeEntry(root, "credential", lsKey(accountId, "USERID"), cred.userId);
|
|
403
|
+
}
|
|
404
|
+
/** 清除账号凭据(移除账号时) */
|
|
405
|
+
function clearWeixinCredential(root, accountId) {
|
|
406
|
+
unsetEntry(root, "credential", lsKey(accountId, "TOKEN"));
|
|
407
|
+
unsetEntry(root, "credential", lsKey(accountId, "BASEURL"));
|
|
408
|
+
unsetEntry(root, "credential", lsKey(accountId, "USERID"));
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* 路由匹配:exact user 优先,通配 `*` 兜底。
|
|
412
|
+
* @returns 命中的 role 名;未命中 → null(无路由 → 消息不回复/忽略)
|
|
413
|
+
*/
|
|
414
|
+
function matchWeixinRoute(routes, fromUserId) {
|
|
415
|
+
if (!Array.isArray(routes) || routes.length === 0) return null;
|
|
416
|
+
for (const r of routes) if (r.user === fromUserId) return r.role;
|
|
417
|
+
for (const r of routes) if (r.user === "*") return r.role;
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* 提取消息文本(文本项 + **语音项的服务端转写**):
|
|
422
|
+
* - type 1 TEXT → `text_item.text`
|
|
423
|
+
* - type 3 VOICE → `voice_item.text`——**微信服务端自带语音转写**(官方 openclaw-weixin
|
|
424
|
+
* 直接读该字段并入对话文本,无需下载/ASR;对齐参考实现 index.ts voiceTexts)。
|
|
425
|
+
* @returns 纯文本;无文本 → null
|
|
426
|
+
*/
|
|
427
|
+
function extractWeixinText(msg) {
|
|
428
|
+
const items = msg.item_list;
|
|
429
|
+
if (!Array.isArray(items) || items.length === 0) return null;
|
|
430
|
+
for (const item of items) {
|
|
431
|
+
if (item.type === 1 && typeof item.text_item?.text === "string") {
|
|
432
|
+
const text = item.text_item.text.trim();
|
|
433
|
+
if (text !== "") return text;
|
|
434
|
+
}
|
|
435
|
+
if (item.type === 3 && typeof item.voice_item?.text === "string") {
|
|
436
|
+
const text = item.voice_item.text.trim();
|
|
437
|
+
if (text !== "") return text;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
/** 是否包含语音项(无转写文本时的降级提示判定——bridge 用) */
|
|
443
|
+
function hasVoiceItem(msg) {
|
|
444
|
+
return (msg.item_list ?? []).some((item) => item.type === 3 && item.voice_item != null);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* 提取消息媒体项(图片 type 2 / 文件 type 4):
|
|
448
|
+
* 只收集**带 media 元数据**的项(下载前置条件);返回 kind + **完整消息项**(downloadMedia 用
|
|
449
|
+
* `item[mediaType]` 取 image_item/file_item)。纯 ACC 层可达性——不做内容处理(v0.2 用户拍板)。
|
|
450
|
+
*/
|
|
451
|
+
function extractWeixinMedia(msg) {
|
|
452
|
+
const out = [];
|
|
453
|
+
for (const item of msg.item_list ?? []) if (item.type === 2 && item.image_item?.media) out.push({
|
|
454
|
+
kind: "image",
|
|
455
|
+
fileName: void 0,
|
|
456
|
+
item
|
|
457
|
+
});
|
|
458
|
+
else if (item.type === 4 && item.file_item?.media) out.push({
|
|
459
|
+
kind: "file",
|
|
460
|
+
fileName: item.file_item.file_name,
|
|
461
|
+
item
|
|
462
|
+
});
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
/** 文件名净化:basename + 去控制字符 + 截断 128(防路径穿越——落盘安全) */
|
|
466
|
+
function sanitizeFileName(name) {
|
|
467
|
+
return (name.split(/[\\/]/).pop() ?? "").replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 128);
|
|
468
|
+
}
|
|
469
|
+
/** 落盘目录:`<CCC 根>/_tmp/weixin-inbound/<userhash>/`(gitignored ✓;agent read 边界内 ✓;按用户分目录) */
|
|
470
|
+
function weixinInboundDir(root, fromUserId) {
|
|
471
|
+
const userHash = createHash("sha256").update(fromUserId).digest("hex").slice(0, 12);
|
|
472
|
+
return join(root, "_tmp", "weixin-inbound", userHash);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* 读取 serenity.json 文件(原始;不存在 → 空对象)。
|
|
476
|
+
* 注意:loadSerenityConfig 返回解析对象但无法写回——此处直接文件读写。
|
|
477
|
+
*/
|
|
478
|
+
function readRawConfig(root, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
479
|
+
for (const candidate of paths) {
|
|
480
|
+
const p = resolve(root, candidate);
|
|
481
|
+
if (existsSync(p)) try {
|
|
482
|
+
const v = JSON.parse(readFileSync(p, "utf-8").replace(/^\uFEFF/, ""));
|
|
483
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
484
|
+
} catch {
|
|
485
|
+
return {};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return {};
|
|
489
|
+
}
|
|
490
|
+
/** 写入 serenity.json(保留既有字段;weixin 段整体替换) */
|
|
491
|
+
function writeWeixinSection(root, weixin, paths = DEFAULT_SERENITY_CONFIG_PATHS) {
|
|
492
|
+
const target = resolve(root, paths[0] ?? ".opencode/serenity.json");
|
|
493
|
+
const raw = readRawConfig(root, paths);
|
|
494
|
+
raw.weixin = weixin;
|
|
495
|
+
const dir = resolve(target, "..");
|
|
496
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
497
|
+
writeFileSync(target, JSON.stringify(raw, null, 2) + "\n", "utf-8");
|
|
498
|
+
}
|
|
499
|
+
/** 添加/更新账号(serenity.json 结构部分;凭据单独写 localstore) */
|
|
500
|
+
function upsertWeixinAccount(root, account) {
|
|
501
|
+
const settings = readWeixinSettings(root);
|
|
502
|
+
const accounts = settings.accounts ?? [];
|
|
503
|
+
const idx = accounts.findIndex((a) => a.accountId === account.accountId);
|
|
504
|
+
if (idx >= 0) accounts[idx] = {
|
|
505
|
+
...accounts[idx],
|
|
506
|
+
...account
|
|
507
|
+
};
|
|
508
|
+
else accounts.push(account);
|
|
509
|
+
const next = {
|
|
510
|
+
...settings,
|
|
511
|
+
accounts
|
|
512
|
+
};
|
|
513
|
+
writeWeixinSection(root, next);
|
|
514
|
+
return next;
|
|
515
|
+
}
|
|
516
|
+
/** 移除账号(serenity.json 结构 + localstore 凭据;同时停桥由调用方 syncCccBridge) */
|
|
517
|
+
function removeWeixinAccount(root, accountId) {
|
|
518
|
+
const settings = readWeixinSettings(root);
|
|
519
|
+
const accounts = (settings.accounts ?? []).filter((a) => a.accountId !== accountId);
|
|
520
|
+
const next = {
|
|
521
|
+
...settings,
|
|
522
|
+
accounts
|
|
523
|
+
};
|
|
524
|
+
writeWeixinSection(root, next);
|
|
525
|
+
clearWeixinCredential(root, accountId);
|
|
526
|
+
return next;
|
|
527
|
+
}
|
|
528
|
+
/** 保存路由表(整体替换) */
|
|
529
|
+
function saveWeixinRoutes(root, routes) {
|
|
530
|
+
const next = {
|
|
531
|
+
...readWeixinSettings(root),
|
|
532
|
+
routes
|
|
533
|
+
};
|
|
534
|
+
writeWeixinSection(root, next);
|
|
535
|
+
return next;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* 生成下一个可用账号 id(`wechat-N`,N 自增取**最小未占用**):
|
|
539
|
+
* 多账号支持(S142 用户反馈 ①"要支持添加多个账号,每个都是扫码")——
|
|
540
|
+
* 移除中间账号后不冲突(wechat-2 被删 → 新账号复用 wechat-2 而非跳到 4)。
|
|
541
|
+
*/
|
|
542
|
+
function nextWeixinAccountId(settings) {
|
|
543
|
+
const used = new Set((settings.accounts ?? []).map((a) => a.accountId));
|
|
544
|
+
let n = 1;
|
|
545
|
+
while (used.has(`wechat-${n}`)) n++;
|
|
546
|
+
return `wechat-${n}`;
|
|
547
|
+
}
|
|
548
|
+
/** 切换总开关 */
|
|
549
|
+
function setWeixinEnabled(root, enabled) {
|
|
550
|
+
const next = {
|
|
551
|
+
...readWeixinSettings(root),
|
|
552
|
+
enabled
|
|
553
|
+
};
|
|
554
|
+
writeWeixinSection(root, next);
|
|
555
|
+
return next;
|
|
556
|
+
}
|
|
557
|
+
//#endregion
|
|
558
|
+
export { readWeixinCredential as a, weixinInboundDir as c, LOCALSTORE_SCOPES as d, checkLocalstoreGitCompliance as f, runLocalStore as g, readStore 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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shgroup/dsh-serenity-hooks",
|
|
3
|
-
"version": "1.69.
|
|
3
|
+
"version": "1.69.2",
|
|
4
4
|
"description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:14 个工具(container_fs/cognitive_trajectory/dashboard/container_git/msm/praxis/ontology/serenity-skill-creator/cheap-worker/localstore/container_admin;另三个 im-bridge/acc-diag/diagram 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 认知轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/官方 ACP 面/认知轨迹唤醒注册表。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档)。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|