@shgroup/dsh-serenity-hooks 1.90.0 → 1.91.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/dsh.plugin.json +1 -1
- package/lib/channels-CuCsUfcY.js +602 -0
- package/lib/im/bridge.d.ts +2 -2
- package/lib/im/guard.d.ts +7 -1
- package/lib/im/hook.d.ts +14 -6
- package/lib/im/inbound-event.d.ts +14 -14
- package/lib/index.js +195 -595
- package/lib/kernel/ccc.d.ts +0 -34
- package/lib/{route-3OzOAjec.js → route-DxIKWeN9.js} +0 -2
- package/lib/{session-Bb4GSSFJ.js → session-D5cEPEUY.js} +1 -1
- package/lib/tools/msm/service.d.ts +34 -1
- package/lib/trajectory/session.d.ts +1 -1
- package/package.json +1 -1
- package/lib/settings-DAorSseV.js +0 -145
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-serenity-hooks",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.91.0",
|
|
4
4
|
"main": "lib/index.js",
|
|
5
5
|
"description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「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 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 认知轨迹日志与原地重建 + 网页登录入口/IM 桥/认知轨迹唤醒注册表。轨迹声明 = 轨迹目录下的 trajectory.json(约束面 + 旧标识 uid + CRO 机器绑定 cro.host;旧名 trajectory-constraint.json / skiff.json 仍在读取族里);轨迹提示词 = 轨迹目录下的 PROMPT.md(一条轨迹「是谁、按什么章程工作」,所有轨迹通用,追加在容器层与质量层之后,不替换它们)。适配 DSH 0.1.7-rc.1 / 0.2.0-rc.1 / 0.2.1-rc.1(三档,deepseek-ai/deepseek-harness)。",
|
|
6
6
|
"engines": {
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import { f as hostSettings } from "./ccc-roots-WfktvbN8.js";
|
|
2
|
+
import { c as readWeixinSettings, o as readImSettings } from "./route-DxIKWeN9.js";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
//#region src/surface/settings.ts
|
|
6
|
+
/**
|
|
7
|
+
* 设置面板命名空间 = **profile 条目 id**(v0.1.7 起;与旧 `settings.yaml` 段名逐字相同)。
|
|
8
|
+
*/
|
|
9
|
+
const SERENITY_SETTINGS_NS = "serenity-hooks";
|
|
10
|
+
/** 进程级默认(无 Config 可读时的兜底;与 Config 各字段的缺省一致) */
|
|
11
|
+
function defaultSimpleSettings() {
|
|
12
|
+
return {
|
|
13
|
+
gatewayEnabled: false,
|
|
14
|
+
rebuildThresholdK: 400,
|
|
15
|
+
croEnabled: true,
|
|
16
|
+
diagramEnabled: false,
|
|
17
|
+
humanChannel: defaultHumanChannelSettings()
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* volatile 字段的**结构化解包**(v1.47.2;本文件读取语义的第一道)。
|
|
22
|
+
*
|
|
23
|
+
* ## 为什么必须有它(两个事实的合成,缺一即错)
|
|
24
|
+
* ① **0.1.7 的面板层字段必须标 `.volatile()`** —— 否则本插件命名空间整个不进宿主的
|
|
25
|
+
* `settings.describe()`(`volatileForm()` 只认 `meta.volatile`)⇒ **设置面板那一节消失**。
|
|
26
|
+
* ② **标了之后,运行期拿到的是引用包装,不是裸值** —— schemastery 的 `Schema.resolve()` 对
|
|
27
|
+
* `meta.volatile` **无条件**包 `createVolatile(value)`,且该分支排在"缺省值回退"那一段
|
|
28
|
+
* **之前** ⇒ **"用户没设过"的字段也是恒真对象**(`.get()` 返回 `undefined`),**不是 `undefined`**。
|
|
29
|
+
*
|
|
30
|
+
* ⇒ 若这里不解包,下面十行的 `??` 链会**短路在这层包装上** ⇒ 网关 / 轨迹约束 会被
|
|
31
|
+
* **无意打开**(比"面板不显示"更坏:静默改变对外暴露面)。
|
|
32
|
+
*
|
|
33
|
+
* ## 为什么用结构判定
|
|
34
|
+
* 可观察形状就是"有 `get()`"(cosmokit 的 `isVolatile` 也这么判);本模块不引它的运行时依赖。
|
|
35
|
+
*
|
|
36
|
+
* @param value - volatile 引用 | 裸值 | undefined
|
|
37
|
+
* @returns 解包后的裸值(引用 ⇒ `.get()`;裸值原样;缺省 ⇒ `undefined`)
|
|
38
|
+
*/
|
|
39
|
+
function unwrapVolatile(value) {
|
|
40
|
+
if (value === void 0 || value === null) return void 0;
|
|
41
|
+
if (typeof value.get === "function") return value.get();
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
/** human-channel 的内建缺省(与 `Config` 的 schema 缺省**同源**:这里是读取面,那里是解析面) */
|
|
45
|
+
function defaultHumanChannelSettings() {
|
|
46
|
+
return {
|
|
47
|
+
accounts: [],
|
|
48
|
+
subscriptions: [],
|
|
49
|
+
relayCcc: null,
|
|
50
|
+
sendingAllow: ["*"]
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** 取"有内容的字符串"(首尾空白剥掉;空白串 ⇒ `undefined`)—— 面板空文本框会写 `''`,那是"没填"不是"填了个空名" */
|
|
54
|
+
function nonEmptyText(value) {
|
|
55
|
+
if (typeof value !== "string") return void 0;
|
|
56
|
+
const trimmed = value.trim();
|
|
57
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `humanChannel` 片段 → 运行时归一形态。**优先级:显式值 > 内建缺省**(与 `simpleSettingsFromConfig` 同族)。
|
|
61
|
+
*
|
|
62
|
+
* 🔴 三处"缺省 vs 显式"的语义差别,别写反:
|
|
63
|
+
* ① `sending.allow: []` ⇒ **`[]`**("谁都不许"),**不是**回退到 `['*']` —— 故用 `??`
|
|
64
|
+
* (只对 `undefined`/`null` 回退)。⚠️ **别拿 `??`→`||` 当变异探针**:空数组在 JS 里是
|
|
65
|
+
* **真值** ⇒ 两种写法在这个输入上**恰好同效**,那是**非变异**(会得出"测试不承重"的假结论)。
|
|
66
|
+
* ② `relay.ccc` 空白 ⇒ `null`("没填"与"没这一项"同义),**不是**把空白当 CCC 名;
|
|
67
|
+
* ③ 账号 `enabled` 缺省 **true**(老账号表里 `enabled` 不写就是开)。
|
|
68
|
+
*
|
|
69
|
+
* 🔴 **旧 CCC 侧 `weixin.*` 不在这里、也不该在这里**:机器级面**只**认 `humanChannel`
|
|
70
|
+
* (硬切无别名,同 D61 先例)⇒ 传进来的旧键**一律不进结构**(回归钉见
|
|
71
|
+
* `tests/human-channel-config.test.ts`;CCC 侧旧读取面的退役在 P0-2~P0-4)。
|
|
72
|
+
* @param fragment - `Config.humanChannel`(可能整个不存在 —— 那是**合法**初始态)
|
|
73
|
+
* @returns 归一形态(永不 `undefined`)
|
|
74
|
+
*/
|
|
75
|
+
function humanChannelSettingsFromConfig(fragment) {
|
|
76
|
+
const d = defaultHumanChannelSettings();
|
|
77
|
+
if (!fragment) return d;
|
|
78
|
+
return {
|
|
79
|
+
accounts: (fragment.accounts ?? []).map((a) => ({
|
|
80
|
+
id: nonEmptyText(a?.id),
|
|
81
|
+
channel: nonEmptyText(a?.channel),
|
|
82
|
+
enabled: a?.enabled !== false,
|
|
83
|
+
token: a?.token,
|
|
84
|
+
userId: a?.userId
|
|
85
|
+
})),
|
|
86
|
+
subscriptions: (fragment.subscriptions ?? []).map((s) => ({
|
|
87
|
+
account: nonEmptyText(s?.account),
|
|
88
|
+
user: nonEmptyText(s?.user),
|
|
89
|
+
ccc: nonEmptyText(s?.ccc),
|
|
90
|
+
role: nonEmptyText(s?.role)
|
|
91
|
+
})),
|
|
92
|
+
relayCcc: nonEmptyText(fragment.relay?.ccc) ?? d.relayCcc,
|
|
93
|
+
sendingAllow: fragment.sending?.allow ?? d.sendingAllow
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Config(宿主 Loader 校验后的解析值)→ 扁平简单配置。**本文件唯一的读取语义**。
|
|
98
|
+
*
|
|
99
|
+
* 优先级:**面板层(扁平键)> 部署层(嵌套段)> 内建缺省**。
|
|
100
|
+
* @param config - 插件 Config(`fiber.config` 或 apply 收到的 config)
|
|
101
|
+
* @returns 扁平开关集合
|
|
102
|
+
*/
|
|
103
|
+
function simpleSettingsFromConfig(config) {
|
|
104
|
+
const d = defaultSimpleSettings();
|
|
105
|
+
return {
|
|
106
|
+
gatewayEnabled: unwrapVolatile(config.gatewayEnabled) ?? config.gateway?.enabled ?? d.gatewayEnabled,
|
|
107
|
+
rebuildThresholdK: unwrapVolatile(config.rebuildThresholdK) ?? config.rebuild?.thresholdK ?? d.rebuildThresholdK,
|
|
108
|
+
croEnabled: unwrapVolatile(config.croEnabled) ?? d.croEnabled,
|
|
109
|
+
diagramEnabled: unwrapVolatile(config.diagramEnabled) ?? config.diagram?.enabled ?? d.diagramEnabled,
|
|
110
|
+
humanChannel: humanChannelSettingsFromConfig(config.humanChannel)
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** 运行时源(`registerSettingsSection` 装配:读本插件 fiber 的**当前** Config) */
|
|
114
|
+
let simpleSource = null;
|
|
115
|
+
/**
|
|
116
|
+
* 读取当前简单配置(各功能 gateway/rebuild/轨迹约束/… 的启动与运行时判断都经此)。
|
|
117
|
+
*
|
|
118
|
+
* 源 = 本插件 fiber 的 `config`(宿主在配置变更时走 `fiber.update()` ⇒ `config` 始终最新,
|
|
119
|
+
* **不必**依赖 apply 重跑,也不必缓存)。
|
|
120
|
+
* @returns 扁平开关集合
|
|
121
|
+
*/
|
|
122
|
+
function readSimpleSettings() {
|
|
123
|
+
return simpleSource ? simpleSource() : defaultSimpleSettings();
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 装配简单配置读取面(v1.21 分层;**v0.1.7 起不再注册任何 section**)。
|
|
127
|
+
*
|
|
128
|
+
* 两件事:
|
|
129
|
+
* 1. 把"本插件自己的 Config"接成简单配置源 —— 读 `ctx.fiber.config`(宿主 `fiber.update()`
|
|
130
|
+
* 保证最新),拿不到 fiber 时退回 apply 收到的 `config`。
|
|
131
|
+
* 2. 关掉宿主按 Config schema **自动生成**的通用页(`settings.configure({ auto: false })`)——
|
|
132
|
+
* 门面是我们的自定义页 `client/SettingsSection.tsx`(同一个命名空间,同一份数据)。
|
|
133
|
+
*
|
|
134
|
+
* ⚠️ 两个失败都不阻断插件装载(面板是附属能力,绝不能成为启动单点)。
|
|
135
|
+
* @param ctx - 插件上下文
|
|
136
|
+
* @param config - apply 收到的 Config(fiber.config 不可用时的兜底)
|
|
137
|
+
*/
|
|
138
|
+
function registerSettingsSection(ctx, config) {
|
|
139
|
+
const fiber = ctx.fiber;
|
|
140
|
+
const readConfig = () => fiber?.config ?? config;
|
|
141
|
+
simpleSource = () => simpleSettingsFromConfig(readConfig());
|
|
142
|
+
const settings = hostSettings(ctx);
|
|
143
|
+
try {
|
|
144
|
+
settings?.configure?.({ auto: false }, fiber);
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/im/log-scan.ts
|
|
149
|
+
/**
|
|
150
|
+
* im/log-scan.ts — 读聊天记录落盘,汇总出「与谁有过往来」(`contacts` 的数据源)
|
|
151
|
+
*
|
|
152
|
+
* ## 这个文件解决什么
|
|
153
|
+
*
|
|
154
|
+
* owner 2026-10-22:「配路由需要的是**账户到轨迹**,轨迹 CCC 知道,**账户 CCC 不知道**,
|
|
155
|
+
* 需要有个能力让 CCC 查询用户配好的账户」。这里的"账户"= **通道用户**(对方是谁),
|
|
156
|
+
* 不是 IM 账号(我们自己登录的身份,`status` 早就能查)。
|
|
157
|
+
*
|
|
158
|
+
* 那个 id(微信 `xxx@im.wechat` / 飞书 `ou_…`)不在任何配置文件里 ——
|
|
159
|
+
* 它**只在聊天记录里出现过**。⇒ 本文件把记录读成联系人表。
|
|
160
|
+
*
|
|
161
|
+
* ## 🔴 两条硬边界
|
|
162
|
+
*
|
|
163
|
+
* 1. **只读**:本文件不写任何东西。真相源仍是那些 jsonl(append-only 事件流),
|
|
164
|
+
* 本文件只是它的一次投影 —— ⛔ 不产生第二真相源。
|
|
165
|
+
* 2. **不猜渠道**:事件的 `channel` 字段为准;缺它(存量事件)⇒ 按
|
|
166
|
+
* `accountId → im.accounts[].channel` 回填,仍取不到才落 `weixin`
|
|
167
|
+
* (那是该字段存在之前唯一的渠道)。⛔ **绝不按 userId 的形状猜**
|
|
168
|
+
* (`ou_` 前缀 / `@im.wechat` 后缀都是命名习惯,不是判据 —— D167 认族不认名)。
|
|
169
|
+
*/
|
|
170
|
+
/** 日志根目录族(**新旧两代并存**:`_im-logs/<channel>/` 与存量 `_weixin-logs/`) */
|
|
171
|
+
const IM_LOG_DIR_NEW = "_im-logs";
|
|
172
|
+
const IM_LOG_DIR_LEGACY = "_weixin-logs";
|
|
173
|
+
/** 某 CCC 下**存在**的日志目录(相对根),新代在前 */
|
|
174
|
+
function imLogRoots(root) {
|
|
175
|
+
const out = [];
|
|
176
|
+
const newBase = join(root, "AGENT_SESSIONS", IM_LOG_DIR_NEW);
|
|
177
|
+
const legacy = join(root, "AGENT_SESSIONS", IM_LOG_DIR_LEGACY);
|
|
178
|
+
if (existsSync(newBase)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_NEW));
|
|
179
|
+
if (existsSync(legacy)) out.push(join("AGENT_SESSIONS", IM_LOG_DIR_LEGACY));
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
/** 收集某个日志根下所有 `*.jsonl`(含 `_im-logs/<channel>/` 一层;按文件名排序) */
|
|
183
|
+
function jsonlFilesIn(root, rel) {
|
|
184
|
+
const abs = join(root, rel);
|
|
185
|
+
const out = [];
|
|
186
|
+
let entries = [];
|
|
187
|
+
try {
|
|
188
|
+
entries = readdirSync(abs, { withFileTypes: true }).map((d) => d.name);
|
|
189
|
+
} catch {
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
for (const name of entries.sort()) {
|
|
193
|
+
if (name.startsWith(".")) continue;
|
|
194
|
+
if (name.endsWith(".jsonl")) {
|
|
195
|
+
out.push(join(rel, name));
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (rel === join("AGENT_SESSIONS", "_im-logs")) {
|
|
199
|
+
const sub = join(rel, name);
|
|
200
|
+
try {
|
|
201
|
+
for (const child of readdirSync(join(root, sub)).sort()) if (child.endsWith(".jsonl")) out.push(join(sub, child));
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
/** 从文件名 / 所在目录猜渠道(**只作为回填的最后一级**,不是判据) */
|
|
208
|
+
function channelFromPath(rel) {
|
|
209
|
+
const parts = rel.split("/");
|
|
210
|
+
const idx = parts.indexOf(IM_LOG_DIR_NEW);
|
|
211
|
+
const candidate = idx >= 0 && parts.length > idx + 2 ? parts[idx + 1] : void 0;
|
|
212
|
+
return candidate !== void 0 && candidate !== "" ? candidate : null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* 扫描本 CCC 的全部聊天记录,按 `(channel, userId)` 汇总。
|
|
216
|
+
*
|
|
217
|
+
* @param root CCC 根
|
|
218
|
+
* @param accountChannel 存量事件缺 `channel` 时的回填表(`accountId → channel`)
|
|
219
|
+
* @returns 汇总结果 + **扫描域** + 读取错误(三者都不可省)
|
|
220
|
+
*/
|
|
221
|
+
function scanImContacts(root, accountChannel = {}) {
|
|
222
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
223
|
+
const sources = [];
|
|
224
|
+
const errors = [];
|
|
225
|
+
for (const rel of imLogRoots(root)) for (const fileRel of jsonlFilesIn(root, rel)) {
|
|
226
|
+
sources.push(fileRel);
|
|
227
|
+
let text = "";
|
|
228
|
+
try {
|
|
229
|
+
text = readFileSync(join(root, fileRel), "utf8");
|
|
230
|
+
} catch (err) {
|
|
231
|
+
errors.push(`${fileRel}: ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const pathChannel = channelFromPath(fileRel);
|
|
235
|
+
for (const line of text.split("\n")) {
|
|
236
|
+
const trimmed = line.trim();
|
|
237
|
+
if (trimmed === "") continue;
|
|
238
|
+
let ev;
|
|
239
|
+
try {
|
|
240
|
+
ev = JSON.parse(trimmed);
|
|
241
|
+
} catch {
|
|
242
|
+
errors.push(`${fileRel}: 有 1 行不是合法 JSON(已跳过)`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const userId = typeof ev.userId === "string" ? ev.userId.trim() : "";
|
|
246
|
+
if (userId === "") continue;
|
|
247
|
+
const accountId = typeof ev.accountId === "string" ? ev.accountId.trim() : "";
|
|
248
|
+
const channel = (typeof ev.channel === "string" && ev.channel.trim() !== "" ? ev.channel.trim() : null) ?? pathChannel ?? (accountId !== "" ? accountChannel[accountId] ?? null : null) ?? "weixin";
|
|
249
|
+
const key = `${channel}\u0000${userId}`;
|
|
250
|
+
let entry = byKey.get(key);
|
|
251
|
+
if (!entry) {
|
|
252
|
+
entry = {
|
|
253
|
+
channel,
|
|
254
|
+
userId,
|
|
255
|
+
accountIds: [],
|
|
256
|
+
roleAtEvent: null,
|
|
257
|
+
firstSeen: null,
|
|
258
|
+
lastSeen: null,
|
|
259
|
+
messages: 0
|
|
260
|
+
};
|
|
261
|
+
byKey.set(key, entry);
|
|
262
|
+
}
|
|
263
|
+
if (accountId !== "" && !entry.accountIds.includes(accountId)) entry.accountIds.push(accountId);
|
|
264
|
+
if (typeof ev.role === "string" && ev.role.trim() !== "") entry.roleAtEvent = ev.role.trim();
|
|
265
|
+
const ts = typeof ev.ts === "number" && Number.isFinite(ev.ts) ? ev.ts : null;
|
|
266
|
+
if (ts !== null) {
|
|
267
|
+
if (entry.firstSeen === null || ts < entry.firstSeen) entry.firstSeen = ts;
|
|
268
|
+
if (entry.lastSeen === null || ts > entry.lastSeen) entry.lastSeen = ts;
|
|
269
|
+
}
|
|
270
|
+
entry.messages += 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
contacts: [...byKey.values()].sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0)),
|
|
275
|
+
sources,
|
|
276
|
+
errors
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/im/channels.ts
|
|
281
|
+
/**
|
|
282
|
+
* im/channels.ts — IM 发送能力层(v1.31.0,S142 用户洞察)
|
|
283
|
+
*
|
|
284
|
+
* 用户原话:「既然微信桥是我们 ACC 提供的,那么 weixin-send 应该是我们 ACC 提供的能力,
|
|
285
|
+
* 当用户配置了微信桥则可用,不配置则不可见」+「设定一个复杂的多层工具,叫 im-bridge,
|
|
286
|
+
* 里面有微信,然后保留后续可能兼容别的 im 的空间」。
|
|
287
|
+
*
|
|
288
|
+
* 分层(E↑):
|
|
289
|
+
* `tools/im/channels.ts`(工具面:参数 → 结果渲染)
|
|
290
|
+
* → **本模块**(通道注册表 + 可见性判据 + 动作分发 + 错误码翻译)
|
|
291
|
+
* → 通道实现(`im/weixin-channel.ts`:协议/凭据/账号/记录)
|
|
292
|
+
*
|
|
293
|
+
* 边界(R↓):
|
|
294
|
+
* - **本模块零宿主依赖**(不 import cordis/agent)——可见性判定与分发都是纯逻辑,可独立单测。
|
|
295
|
+
* - **通道只提供机制**:本模块不认识"微信";新增 IM 只需注册一个通道实现(R3 家族式扩展)。
|
|
296
|
+
* - **只能操作本会话 CCC**(R4):`root` 由调用方(工具面)从 agent cwd 解析后传入,
|
|
297
|
+
* 本模块不接受"目标 CCC"参数——跨 CCC 发送不在能力面。
|
|
298
|
+
* - **记录归通道**(R5):通道实现负责让每次发送都落既有 hook。
|
|
299
|
+
*/
|
|
300
|
+
/** 文本长度上限(微信侧单条实践上限;超限拒绝而非静默截断——与 HTTP 入口同值) */
|
|
301
|
+
const IM_SEND_MAX_TEXT = 4e3;
|
|
302
|
+
/** 文件大小上限(20MB,与媒体接收上限一致) */
|
|
303
|
+
const IM_FILE_MAX_BYTES = 20971520;
|
|
304
|
+
const channels = /* @__PURE__ */ new Map();
|
|
305
|
+
/** 注册通道(index.ts apply 调用;重复 id 覆盖——测试与热重载友好) */
|
|
306
|
+
function registerImChannel(channel) {
|
|
307
|
+
channels.set(channel.id, channel);
|
|
308
|
+
}
|
|
309
|
+
/** 已注册通道 id(工具 `channel` 参数枚举) */
|
|
310
|
+
function imChannelIds() {
|
|
311
|
+
return [...channels.keys()];
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* 可见性判据:本 CCC 是否**有任何可用的 IM 账号**。
|
|
315
|
+
* 未启用 → 工具面对该 agent **不可见**(`tools.restrict` 移除,而非调用时报错)。
|
|
316
|
+
*
|
|
317
|
+
* ## 🔴 2026-10-22 判据修正(owner 令:「把开关升级为有任意账号即可,而不是盯着微信」)
|
|
318
|
+
*
|
|
319
|
+
* **旧判据** = 遍历已注册通道、任一 `channel.isEnabled(root)` 为真即可见。
|
|
320
|
+
* **旧判据的缺陷**(实测于一个只配飞书的 CCC):`feishuChannel.isEnabled()` 首行是
|
|
321
|
+
* `if (settings.enabled !== true) return false` —— 总开关一关,**后面的账号判断根本不执行**
|
|
322
|
+
* ⇒ 账号明明配了、`enabled:true`,却因为一个**渠道无关的总开关**被永久判成"未启用"
|
|
323
|
+
* ⇒ `im-bridge` 在模型清单里彻底消失,而配置面看上去是对的(`accounts[]` 里躺着一条 feishu)。
|
|
324
|
+
* 这正是 D179 的形态:**配置说它有,行为说它没有**,且零报错。
|
|
325
|
+
*
|
|
326
|
+
* **新判据** = **账号即事实**:`readImSettings(root).accounts` 里存在
|
|
327
|
+
* 「`enabled !== false` 的账号」即视为可见。理由三条:
|
|
328
|
+
* ① **可见性问的是"这个 CCC 有没有 IM 能力"**,而能力的载体是**账号**,不是总开关;
|
|
329
|
+
* ② 总开关的语义是"**要不要让桥跑起来**",把它当成可见性上限,会让"配好了但暂时不想让它跑"
|
|
330
|
+
* 这种合法状态变成"工具凭空消失";
|
|
331
|
+
* ③ 判**按账号判**后,微信与飞书不再各有一套"总开关 + 账号"的双重判据 ⇒ 消除同族漂移。
|
|
332
|
+
*
|
|
333
|
+
* ⚠️ **不放宽的部分**:没有账号 ⇒ 仍然不可见(`accounts` 为空即 false)。
|
|
334
|
+
* ⚠️ 单个通道自己的 `isEnabled` 仍是**发送/启停**判据(那是通道自己的事),
|
|
335
|
+
* 只是**不再兼任**工具可见性判据 —— 两件事被这次修正分开了。
|
|
336
|
+
*
|
|
337
|
+
* @param root 本会话 CCC 根(由调用方解析)
|
|
338
|
+
*/
|
|
339
|
+
function hasEnabledImChannel(root) {
|
|
340
|
+
let settings;
|
|
341
|
+
try {
|
|
342
|
+
settings = readImSettings(root);
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
return (settings.accounts ?? []).some((a) => a.enabled !== false);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* 可见性判据(**轨迹级**,2026-10-23 owner 令):
|
|
350
|
+
* 这个 dsh 会话所属的认知轨迹,是否**被 IM 路由表点名**(= 它才是那个"接线人")。
|
|
351
|
+
*
|
|
352
|
+
* ## 它解决的问题(owner 原话的**去标识改写**,原文含私有角色名,按 D145 不入仓库)
|
|
353
|
+
*
|
|
354
|
+
* > 「很多轨迹发消息给我,但是我没空回,它们就会等待我,其实并不是,我希望他们继续工作;
|
|
355
|
+
* > 所以只有 IM 桥绑定了任意账户的认知轨迹才拥有 `im-bridge` 这个 tool,
|
|
356
|
+
* > 这样可以确保只有一个接线角色和我联系,其它轨迹和它联系即可,它会自行回复它们的」
|
|
357
|
+
*
|
|
358
|
+
* ⇒ 旧判据是 **CCC 级**({@link hasEnabledImChannel}:本 CCC 有任一启用账号 ⇒ 全体可见)。
|
|
359
|
+
* 它的后果不是"少一个工具",而是**一条错误的工作流**:每条轨迹都以为自己该直接对 owner 说话,
|
|
360
|
+
* 于是发完就**停下来等人回**——而 owner 根本没空逐条回。**工具在,就把等待也带上了。**
|
|
361
|
+
*
|
|
362
|
+
* ## 判据(两条,缺一不可)
|
|
363
|
+
*
|
|
364
|
+
* 1. 本 CCC 有可用账号({@link hasEnabledImChannel})—— 没有账号时路由表也没有意义;
|
|
365
|
+
* 2. 本会话**绑定的轨迹编码**出现在 `routes[].trajectory` 里。
|
|
366
|
+
*
|
|
367
|
+
* ⚠️ **为什么不看 `routes[].user`**:那是"对端是谁"(owner 的通道 id),
|
|
368
|
+
* 判"这条轨迹能不能说话"只跟**轨迹**有关,与哪个对端无关。
|
|
369
|
+
*
|
|
370
|
+
* ⚠️ **`*` 通配也算点名**:`{ user:"*", trajectory:"<某轨迹>" }` 这条路由的含义就是
|
|
371
|
+
* "**没人认领的消息都归那条轨迹**" ⇒ 它确实是一条被授权的接线轨迹,必须算可见。
|
|
372
|
+
* 这与 `matchWeixinRoute` 的通配兜底语义**同源**,不另立规则。
|
|
373
|
+
*
|
|
374
|
+
* ## 拿不到轨迹编码时怎么办(fail-closed,与 `exclusive` 同向)
|
|
375
|
+
*
|
|
376
|
+
* 会话 → 轨迹编码要读绑定。**取不到就判不可见**:
|
|
377
|
+
* - 与 `exclusive`(未声明 ⇒ 隐藏)同向;
|
|
378
|
+
* - 反向(取不到就放行)会让"绑定坏了"表现成"工具还在",
|
|
379
|
+
* 而那正是本判据要防的形态(D179:配置说它有、行为说它没有,且零报错)。
|
|
380
|
+
* ⚠️ 代价明示:绑定读取若短暂失败,该轮会少一个工具;下一轮绑定恢复即回。
|
|
381
|
+
* 这比"误给一条轨迹发消息的权限"便宜 —— 后者会让它**停下来等 owner**。
|
|
382
|
+
*
|
|
383
|
+
* @param root CCC 根
|
|
384
|
+
* @param trajectoryCode 本会话所属认知轨迹的编码(**由调用方解析**,见 `trajectory/policy/profile.ts`)
|
|
385
|
+
* @returns true = 该轨迹接线,`im-bridge` 可见
|
|
386
|
+
*/
|
|
387
|
+
function isImWiredTrajectory(root, trajectoryCode) {
|
|
388
|
+
if (typeof trajectoryCode !== "string" || trajectoryCode.trim() === "") return false;
|
|
389
|
+
if (!hasEnabledImChannel(root)) return false;
|
|
390
|
+
let settings;
|
|
391
|
+
try {
|
|
392
|
+
settings = readImSettings(root);
|
|
393
|
+
} catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
const wanted = trajectoryCode.trim();
|
|
397
|
+
for (const route of settings.routes ?? []) {
|
|
398
|
+
const target = typeof route.trajectory === "string" ? route.trajectory.trim() : "";
|
|
399
|
+
if (target !== "" && target === wanted) return true;
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* 错误码 → 可行动提示(模型视角:说清怎么改)。
|
|
405
|
+
*
|
|
406
|
+
* 🔴 `CHANNEL_NOT_CONFIGURED` 的提示**不能写死某一个渠道的字段名**(2026-10-20 Q4 真机验收踩到):
|
|
407
|
+
* 首版逐字写「enable it in … (weixin.enabled)」,而接第二个渠道后,**飞书能不能用不取决于
|
|
408
|
+
* 这个开关**(它取决于有没有一条启用中的飞书账号)⇒ 按那句提示去改 `weixin.enabled`
|
|
409
|
+
* **不会生效**,而错误码又指向"没配置" ⇒ 用户据此改完还是同一句报错。
|
|
410
|
+
* ⇒ 提示按**渠道是否带自己的启用判据**分岔:带 `channelHint` 的就用它自己的说法。
|
|
411
|
+
*/
|
|
412
|
+
function remediationFor(code, channelHint) {
|
|
413
|
+
switch (code) {
|
|
414
|
+
case "CHANNEL_UNKNOWN": return "Use a registered channel id (see the tool description)";
|
|
415
|
+
case "CHANNEL_NOT_CONFIGURED": return channelHint ?? "This CCC has not configured that channel — check .opencode/serenity.json (the channel's own enabled flag / accounts) or the settings panel";
|
|
416
|
+
case "ACTION_UNKNOWN": return "Use send / send-file / users / status";
|
|
417
|
+
case "ACTION_UNSUPPORTED": return "This channel does not support that action";
|
|
418
|
+
case "ALIAS_UNKNOWN": return "Use a configured alias (see action=users) or a raw channel user id";
|
|
419
|
+
case "TEXT_REQUIRED": return "Provide text";
|
|
420
|
+
case "TEXT_TOO_LONG": return `Shorten the text to <= ${IM_SEND_MAX_TEXT} characters`;
|
|
421
|
+
case "USER_REQUIRED": return "Provide user";
|
|
422
|
+
case "FILE_REQUIRED": return "Provide file (a path inside this CCC)";
|
|
423
|
+
case "FILE_ESCAPE": return "Use a path inside this CCC";
|
|
424
|
+
case "FILE_NOT_FOUND": return "Check the path exists inside this CCC";
|
|
425
|
+
case "FILE_TOO_LARGE": return `Use a file <= ${Math.round(IM_FILE_MAX_BYTES / 1048576)} MB`;
|
|
426
|
+
default: return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function fail(code, error, remediation) {
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
code,
|
|
433
|
+
error,
|
|
434
|
+
...remediation ?? remediationFor(code) ? { remediation: remediation ?? remediationFor(code) } : {}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** CCC 内文件读取(路径逃逸/缺失/超限一律拒绝,返回稳定码) */
|
|
438
|
+
function readCccFile(root, relOrAbs) {
|
|
439
|
+
const abs = isAbsolute(relOrAbs) ? resolve(relOrAbs) : resolve(root, relOrAbs);
|
|
440
|
+
const rel = relative(resolve(root), abs);
|
|
441
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return {
|
|
442
|
+
ok: false,
|
|
443
|
+
code: "FILE_ESCAPE",
|
|
444
|
+
error: `file escapes the CCC root: ${relOrAbs}`
|
|
445
|
+
};
|
|
446
|
+
if (!existsSync(abs)) return {
|
|
447
|
+
ok: false,
|
|
448
|
+
code: "FILE_NOT_FOUND",
|
|
449
|
+
error: `file not found: ${relOrAbs}`
|
|
450
|
+
};
|
|
451
|
+
let size = 0;
|
|
452
|
+
try {
|
|
453
|
+
size = statSync(abs).size;
|
|
454
|
+
} catch (err) {
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
code: "FILE_NOT_FOUND",
|
|
458
|
+
error: `cannot stat file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (size > 20971520) return {
|
|
462
|
+
ok: false,
|
|
463
|
+
code: "FILE_TOO_LARGE",
|
|
464
|
+
error: `file is ${size} bytes (> ${IM_FILE_MAX_BYTES})`
|
|
465
|
+
};
|
|
466
|
+
try {
|
|
467
|
+
return {
|
|
468
|
+
ok: true,
|
|
469
|
+
data: readFileSync(abs),
|
|
470
|
+
fileName: abs.split("/").pop() ?? "file"
|
|
471
|
+
};
|
|
472
|
+
} catch (err) {
|
|
473
|
+
return {
|
|
474
|
+
ok: false,
|
|
475
|
+
code: "FILE_NOT_FOUND",
|
|
476
|
+
error: `cannot read file: ${relOrAbs} (${err instanceof Error ? err.message : String(err)})`
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* 执行一次 IM 动作(工具面唯一入口)。
|
|
482
|
+
*
|
|
483
|
+
* 顺序:通道解析 → 动作校验 → 用户解析(别名 → id)→ 输入校验 → 调用通道实现。
|
|
484
|
+
* 任何一步失败都返回**稳定错误码 + 可行动提示**(不抛错——工具面统一渲染)。
|
|
485
|
+
*
|
|
486
|
+
* @param root 本会话 CCC 根(调用方从 agent cwd 解析;本函数不接受目标 CCC)
|
|
487
|
+
* @param req 工具参数
|
|
488
|
+
*/
|
|
489
|
+
async function runImBridge(root, req) {
|
|
490
|
+
const channelId = (req.channel ?? "").trim();
|
|
491
|
+
const action = (req.action ?? "").trim();
|
|
492
|
+
if (channelId === "") return fail("CHANNEL_REQUIRED", "channel is required", "Provide channel (e.g. \"weixin\")");
|
|
493
|
+
const channel = channels.get(channelId);
|
|
494
|
+
if (!channel) return fail("CHANNEL_UNKNOWN", `unknown channel "${channelId}" (registered: ${imChannelIds().join(", ") || "(none)"})`);
|
|
495
|
+
if (!channel.isEnabled(root)) return fail("CHANNEL_NOT_CONFIGURED", `channel "${channelId}" is not configured for this CCC (${root})`, channel.notEnabledReason?.(root));
|
|
496
|
+
if (action === "users") return {
|
|
497
|
+
ok: true,
|
|
498
|
+
channel: channelId,
|
|
499
|
+
action,
|
|
500
|
+
detail: { users: channel.listUsers(root) }
|
|
501
|
+
};
|
|
502
|
+
if (action === "contacts") {
|
|
503
|
+
if (!channel.listContacts) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" has no record source for contacts`);
|
|
504
|
+
const settings = readWeixinSettings(root);
|
|
505
|
+
const accountChannel = {};
|
|
506
|
+
for (const account of settings.accounts ?? []) if (typeof account.accountId === "string" && account.accountId !== "") accountChannel[account.accountId] = typeof account.channel === "string" && account.channel !== "" ? account.channel : "weixin";
|
|
507
|
+
const scanned = scanImContacts(root, accountChannel);
|
|
508
|
+
const mine = scanned.contacts.filter((c) => c.channel === channelId);
|
|
509
|
+
/** 当前路由表(`user → trajectory`);同一 user 多条时后者覆盖 */
|
|
510
|
+
const routeMap = /* @__PURE__ */ new Map();
|
|
511
|
+
for (const route of settings.routes ?? []) if (typeof route.user === "string" && route.user !== "") routeMap.set(route.user, typeof route.trajectory === "string" ? route.trajectory : "");
|
|
512
|
+
const contacts = mine.map((c) => ({
|
|
513
|
+
channel: c.channel,
|
|
514
|
+
userId: c.userId,
|
|
515
|
+
accountIds: c.accountIds,
|
|
516
|
+
route: routeMap.get(c.userId) ?? null,
|
|
517
|
+
firstSeen: c.firstSeen === null ? null : new Date(c.firstSeen).toISOString(),
|
|
518
|
+
lastSeen: c.lastSeen === null ? null : new Date(c.lastSeen).toISOString(),
|
|
519
|
+
messages: c.messages
|
|
520
|
+
}));
|
|
521
|
+
const seenUsers = new Set(mine.map((c) => c.userId));
|
|
522
|
+
return {
|
|
523
|
+
ok: true,
|
|
524
|
+
channel: channelId,
|
|
525
|
+
action,
|
|
526
|
+
detail: {
|
|
527
|
+
contacts,
|
|
528
|
+
unrouted: mine.filter((c) => !routeMap.has(c.userId)).map((c) => c.userId),
|
|
529
|
+
orphanRoutes: [...routeMap.keys()].filter((user) => !seenUsers.has(user)),
|
|
530
|
+
sources: scanned.sources,
|
|
531
|
+
...scanned.errors.length > 0 ? { errors: scanned.errors } : {}
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
if (action === "status") return {
|
|
536
|
+
ok: true,
|
|
537
|
+
channel: channelId,
|
|
538
|
+
action,
|
|
539
|
+
detail: channel.status(root)
|
|
540
|
+
};
|
|
541
|
+
if (action === "send") {
|
|
542
|
+
const rawUser = (req.user ?? "").trim();
|
|
543
|
+
if (rawUser === "") return fail("USER_REQUIRED", "user is required");
|
|
544
|
+
const text = req.text ?? "";
|
|
545
|
+
if (text === "") return fail("TEXT_REQUIRED", "text is required");
|
|
546
|
+
if (text.length > 4e3) return fail("TEXT_TOO_LONG", `text is ${text.length} characters (> ${IM_SEND_MAX_TEXT})`);
|
|
547
|
+
const resolved = channel.resolveUser(root, rawUser);
|
|
548
|
+
if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
|
|
549
|
+
try {
|
|
550
|
+
return {
|
|
551
|
+
ok: true,
|
|
552
|
+
channel: channelId,
|
|
553
|
+
action,
|
|
554
|
+
detail: {
|
|
555
|
+
...await channel.send({
|
|
556
|
+
root,
|
|
557
|
+
userId: resolved.id,
|
|
558
|
+
text,
|
|
559
|
+
accountId: req.account
|
|
560
|
+
}),
|
|
561
|
+
user: resolved.alias ?? resolved.id
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
} catch (err) {
|
|
565
|
+
return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (action === "send-file") {
|
|
569
|
+
if (!channel.sendFile) return fail("ACTION_UNSUPPORTED", `channel "${channelId}" does not support send-file`);
|
|
570
|
+
const rawUser = (req.user ?? "").trim();
|
|
571
|
+
if (rawUser === "") return fail("USER_REQUIRED", "user is required");
|
|
572
|
+
const fileArg = (req.file ?? "").trim();
|
|
573
|
+
if (fileArg === "") return fail("FILE_REQUIRED", "file is required");
|
|
574
|
+
const resolved = channel.resolveUser(root, rawUser);
|
|
575
|
+
if (!resolved) return fail("ALIAS_UNKNOWN", `unknown user "${rawUser}"`);
|
|
576
|
+
const file = readCccFile(root, fileArg);
|
|
577
|
+
if (!file.ok) return fail(file.code, file.error);
|
|
578
|
+
try {
|
|
579
|
+
return {
|
|
580
|
+
ok: true,
|
|
581
|
+
channel: channelId,
|
|
582
|
+
action,
|
|
583
|
+
detail: {
|
|
584
|
+
...await channel.sendFile({
|
|
585
|
+
root,
|
|
586
|
+
userId: resolved.id,
|
|
587
|
+
data: file.data,
|
|
588
|
+
fileName: file.fileName,
|
|
589
|
+
caption: req.caption,
|
|
590
|
+
accountId: req.account
|
|
591
|
+
}),
|
|
592
|
+
user: resolved.alias ?? resolved.id
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
} catch (err) {
|
|
596
|
+
return fail("SEND_FAILED", err instanceof Error ? err.message : String(err));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return fail("ACTION_UNKNOWN", `unknown action "${action}"`);
|
|
600
|
+
}
|
|
601
|
+
//#endregion
|
|
602
|
+
export { SERENITY_SETTINGS_NS as a, runImBridge as i, isImWiredTrajectory as n, readSimpleSettings as o, registerImChannel as r, registerSettingsSection as s, imChannelIds as t };
|
package/lib/im/bridge.d.ts
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
import type { Context } from 'cordis';
|
|
27
27
|
import { type WeixinAccountCredential } from './route.js';
|
|
28
28
|
import { type WeixinMessage } from './ilink.js';
|
|
29
|
-
import { resolveInboundTarget,
|
|
30
|
-
export {
|
|
29
|
+
import { resolveInboundTarget, type ResolvedInboundTarget } from './inbound-event.js';
|
|
30
|
+
export { resolveInboundTarget };
|
|
31
31
|
export type { ResolvedInboundTarget };
|
|
32
32
|
/** 记下最近一次入站带来的 `context_token`(**只存非空**;空串会把好票覆盖成坏票) */
|
|
33
33
|
export declare function rememberContextToken(accountId: string, userId: string, token: string | undefined): void;
|
package/lib/im/guard.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* im/guard.ts — 手动输出模式的**机械闸门**(v1.30.16,S142 用户"LLM 不听")
|
|
3
3
|
*
|
|
4
|
+
* 🔴 **2026-10-24(D238)后本模块从"边缘"变成"主路径"**:`autoReplyWithLastMessage` 那条开关
|
|
5
|
+
* 已整条退役 ⇒ **所有会话都是手动输出**(桥永不自动回发)⇒ 本闸门**每轮恒生效**。
|
|
6
|
+
* 由此得到一条必配件判据(D234):打回前**必须**先确认 `im-bridge` 对该轨迹可见 ——
|
|
7
|
+
* 否则"该轨迹没有发送权"会被无解地打回成"忘了发"。见 `isImWiredTrajectory` 的使用处。
|
|
8
|
+
* 下面第 4 行起的"问题(实证)"是**当时**的缘起叙述,其中提到的开关现已不存在。
|
|
9
|
+
*
|
|
4
10
|
* 问题(实证):`weixin.autoReplyWithLastMessage: false` 时桥不回发最终文本,输出权归 agent
|
|
5
11
|
* (必须自己调 `im-bridge` 工具,v1.30.x 为 `msm("weixin-send", …)`)。提示词层面已说明,
|
|
6
12
|
* 但用户实测 **LLM 仍经常不发**——结果用户什么都收不到(静默)。这类"提示词软约束失效"正是 ACC
|
|
@@ -92,7 +98,7 @@ export declare function isWeixinOutputGuardActive(): boolean;
|
|
|
92
98
|
* 🔴 **原名为 `isSuccessfulWeixinSend`,2026-10-20 改名**(Q4 出站飞书):
|
|
93
99
|
* 它曾硬比 `channel !== 'weixin' ⇒ false`。接第二个渠道后,那条判据的含义变成
|
|
94
100
|
* **"只有微信算发出去了"** —— 而实际后果是:飞书会话里 agent 真的发成功了,
|
|
95
|
-
* 闸门仍判"未发送" ⇒ 打回 ≤2
|
|
101
|
+
* 闸门仍判"未发送" ⇒ 打回 ≤2 次,且当时的 `fallbackOnNoSend` 还会再兜底转发一遍
|
|
96
102
|
* ⇒ **重复投递**。⇒ 判据从"是不是微信"改成"是不是**这个会话所在的那条渠道**",
|
|
97
103
|
* 函数名随之更正(**名字要能对着行为读**,D151/D179 同族)。
|
|
98
104
|
*
|