@shgroup/dsh-serenity-hooks 1.50.0 → 1.51.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.
@@ -18,9 +18,15 @@
18
18
  * `ccc:<root>|<accountId>`(那时身份里带了 root ⇒ 跨 CCC 不可能撞车,也就不会误判成"同一账号")。
19
19
  *
20
20
  * ## 边界
21
- * 本模块**只做归属判定**(谁能起、谁被谁占着),**不碰**:轮询循环、凭据解析、路由投递。
22
- * 那些留在 `weixin-bridge.ts`(渠道实现)—— 归属是机制,实现是渠道。
21
+ * 本模块做三件事(**都属机制**,与"哪个渠道"无关):
22
+ * ① **归属判定**(P0-2:谁能起 poller、谁被谁占着)—— 见下 §归属判定
23
+ * ② **订阅表查询 + 入站路由决策**(P0-3:`(账号, 用户) → (CCC, 角色)`)—— 见 §订阅表
24
+ * ③ **机器级留痕**(P0-3:未投递 / 目标不存在 / 表残缺)—— 见 §留痕。
25
+ * 🔴 为什么这些必须机器级:它们**没有目标 CCC** ⇒ 落进任何 CCC 都是"编一个"目标。
26
+ * **不碰**:轮询循环、凭据解析、会话投递 —— 那些是**渠道实现**,留在 `weixin-bridge.ts`。
27
+ * ⇒ 本模块**零 DSH 依赖**(只有 `node:crypto` / `node:fs`),可独立单测。
23
28
  */
29
+ import type { HumanChannelSubscriptionSettings } from './settings-section.js';
24
30
  /** 一个"想为该账号起 poller"的请求(来自某 CCC) */
25
31
  export interface PollerRequest {
26
32
  /** 发起方 CCC 根(绝对路径) */
@@ -87,3 +93,126 @@ export declare function releasePollersOf(root: string): number;
87
93
  export declare function releaseAllPollers(): void;
88
94
  /** 测试专用:复位进程级归属表(生产零调用) */
89
95
  export declare function __resetPollersForTest(): void;
96
+ /** 订阅表里**完好**的一行(四个字段全部非空) */
97
+ export interface SubscriptionHit {
98
+ account: string;
99
+ user: string;
100
+ ccc: string;
101
+ role: string;
102
+ }
103
+ /**
104
+ * 订阅表里的**残缺行**(缺字段)。
105
+ * 🔴 它**不是**"未命中"(见上);且**与是否命中无关** —— 表里只要有残缺行就要出声,
106
+ * 否则"命中了一条完好的"会让人以为整张表是干净的。
107
+ */
108
+ export interface SubscriptionDefect {
109
+ /** 行序(0 基)—— 报错要指得出是**哪一行** */
110
+ index: number;
111
+ /** 缺哪些字段(`account` / `user` / `ccc` / `role`) */
112
+ missing: string[];
113
+ /** 行里**已有**的字段(残缺行的可见部分:让人认得出是哪一行) */
114
+ present: Record<string, string>;
115
+ }
116
+ export interface SubscriptionLookup {
117
+ /** 命中的行(未命中 ⇒ `null`) */
118
+ hit: SubscriptionHit | null;
119
+ /** 表里残缺的行(**与是否命中无关**,一律要出声) */
120
+ defects: SubscriptionDefect[];
121
+ /** 表里**完好**的行数(供调用方区分"表是空的"与"表里没有你") */
122
+ usable: number;
123
+ }
124
+ /**
125
+ * 订阅表查询(**纯函数**)。
126
+ *
127
+ * 匹配两段式(**刻意对齐**旧 `matchWeixinRoute` 的 exact → `*` 兜底):
128
+ * ① `account` **精确** ∧ `user` **精确**
129
+ * ② `account` **精确** ∧ `user === '*'`(通配兜底)
130
+ * 🔴 `account` **不设通配**:它是机器级账号的**显式身份**(表里写的就是账号 id);
131
+ * 给账号也开 `*` 会让"这条订阅到底覆盖哪个 bot"变得不可读。
132
+ * 🔴 `user` 保留 `*`:这是**有意的**——存量 CCC 级 `routes` 用的就是 `{user:'*'}`,
133
+ * 迁移(P0-4)要能**逐字平移**它,否则"迁移"会悄悄改变行为。
134
+ *
135
+ * @param subscriptions 机器级订阅表(`readSimpleSettings().humanChannel.subscriptions`)
136
+ * @param account 收到的**账号 id**(poller 身份;不是 token)
137
+ * @param user 发信人 id
138
+ */
139
+ export declare function lookupSubscription(subscriptions: readonly HumanChannelSubscriptionSettings[], account: string, user: string): SubscriptionLookup;
140
+ /** 入站投递目标**从哪来**(判据用;也是"这条消息为什么被投到这里"的可重建依据) */
141
+ export type InboundRouteSource = 'machine' | 'ccc-fallback';
142
+ /** 未投递的原因(两档,措辞刻意不同 —— 见 `planInboundRoute`) */
143
+ export type InboundSkipReason = 'no-subscription' | 'no-match';
144
+ export interface InboundRoutePlan {
145
+ /** 投递目标(`null` ⇒ **不投递**) */
146
+ target: {
147
+ ccc: string;
148
+ role: string;
149
+ } | null;
150
+ /** 目标出处(`null` ⇔ `target === null`) */
151
+ source: InboundRouteSource | null;
152
+ /** 未投递的原因(`target !== null` ⇒ `null`) */
153
+ skipReason: InboundSkipReason | null;
154
+ /** 订阅表里残缺的行(**与是否命中无关**,一律要出声) */
155
+ defects: SubscriptionDefect[];
156
+ }
157
+ /**
158
+ * 入站路由决策(**纯函数**)。
159
+ *
160
+ * 顺序:**机器级订阅表优先** → 未命中时用 CCC 级回退。
161
+ *
162
+ * 🔴 **回退是过渡期的"不打断在用的微信"**(方案 §11.3 **Q5**):机器级账号表为空时,
163
+ * 桥仍按 CCC 起、路由也仍认 CCC 级 `routes`。机器级一旦配起来,回退传 `null`
164
+ * ⇒ **未命中 = 不投递**(不是"投给默认角色"——那正是方案 §3.1 明令禁止的)。
165
+ * @param input.subscriptions 机器级订阅表
166
+ * @param input.account 收到的账号 id
167
+ * @param input.user 发信人 id
168
+ * @param input.fallback CCC 级回退目标(`{ccc, role}`);无 ⇒ `null` / 省略
169
+ */
170
+ export declare function planInboundRoute(input: {
171
+ subscriptions: readonly HumanChannelSubscriptionSettings[];
172
+ account: string;
173
+ user: string;
174
+ fallback?: {
175
+ ccc: string;
176
+ role: string;
177
+ } | null;
178
+ }): InboundRoutePlan;
179
+ /**
180
+ * 留痕落点:`$DSH_HOME/human-channel-audit.jsonl`(缺省 `~/.dsh/human-channel-audit.jsonl`)。
181
+ *
182
+ * 🔴 **为什么是机器级而不是某个 CCC 的 `_weixin-logs/`**:本文件记的事件(未投递 /
183
+ * 目标不存在 / 表残缺)**恰恰是"没有目标 CCC"的那些** —— 落进任何一个 CCC 都是**编一个**目标。
184
+ * (投递成功的入站事件仍落**被路由到的那个 CCC** 的 `_weixin-logs/`,方案 §3.6 不变。)
185
+ * env `SERENITY_HUMAN_CHANNEL_AUDIT` 覆盖(测试注入;与 `SERENITY_HOOKS_CONFIG` 同款推导)。
186
+ */
187
+ export declare function humanChannelAuditPath(): string;
188
+ /**
189
+ * 留痕事件。
190
+ * 🔴 **刻意不记消息正文**:方案 §3.1 要的是"**谁**发的、**为什么**没投" —— 正文既不必要,
191
+ * 又会把人说的话抄进一个机器级文件(本该只落在他自己 CCC 的日志里)。
192
+ */
193
+ export type HumanChannelAuditEvent = {
194
+ kind: 'inbound-unrouted';
195
+ account: string;
196
+ user: string;
197
+ reason: InboundSkipReason;
198
+ detail: string;
199
+ } | {
200
+ kind: 'inbound-target-missing';
201
+ account: string;
202
+ user: string;
203
+ ccc: string;
204
+ role: string;
205
+ detail: string;
206
+ } | {
207
+ kind: 'subscription-defect';
208
+ account: string;
209
+ index: number;
210
+ missing: string[];
211
+ present: Record<string, string>;
212
+ };
213
+ /**
214
+ * 追加一条留痕。**永不抛** ——
215
+ * 留痕失败绝不能反过来打断投递(它是**旁路**,与 `invokeWeixinHook` 的"旁路容忍"同款)。
216
+ * 🔴 **不静默的是它记的那件事**,不是它自己:所以调用方仍要**另行 `console` 出声**。
217
+ */
218
+ export declare function appendHumanChannelAudit(event: HumanChannelAuditEvent): void;
package/lib/index.d.ts CHANGED
@@ -156,6 +156,13 @@ export interface Config {
156
156
  visionPatch?: {
157
157
  enabled?: boolean;
158
158
  };
159
+ /**
160
+ * diagram 工具(实验性 demo;2026-09-29 owner 具名令 **D104**):标签格式源码 → 进程内
161
+ * 光栅化成 PNG(**不借浏览器**)。缺省关;部署层同义键 = 面板层 `diagramEnabled`。
162
+ */
163
+ diagram?: {
164
+ enabled?: boolean;
165
+ };
159
166
  /** human-channel(人机信道;**机器级**;2026-09-26 owner 定案)—— 见 `HumanChannelConfig` */
160
167
  humanChannel?: HumanChannelConfig;
161
168
  /** F1 双端口网关总开关(面板层;部署层同义键 = `gateway.enabled`) */
@@ -176,6 +183,8 @@ export interface Config {
176
183
  croEnabled?: Volatile<boolean | undefined>;
177
184
  /** 无人值守代理回复总闸(面板层;无部署层同义键;缺省关) */
178
185
  unattendedEnabled?: Volatile<boolean | undefined>;
186
+ /** **diagram 工具总闸**(面板层;部署层同义键 = `diagram.enabled`;缺省关;D104) */
187
+ diagramEnabled?: Volatile<boolean | undefined>;
179
188
  }
180
189
  /** schemastery 的 schema 类型(别名只为可读;**名字取自全局命名空间**,见上方 TS2742 说明) */
181
190
  type SchemaOf<S, T = S> = Schemastery<S, T>;
@@ -234,6 +243,11 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
234
243
  }>>, Schemastery.ObjectT<NoInfer<{
235
244
  enabled: z<boolean, boolean, "defined">;
236
245
  }>>, "plain">;
246
+ diagram: z<Schemastery.ObjectS<NoInfer<{
247
+ enabled: z<boolean, boolean, "defined">;
248
+ }>>, Schemastery.ObjectT<NoInfer<{
249
+ enabled: z<boolean, boolean, "defined">;
250
+ }>>, "plain">;
237
251
  humanChannel: z<Schemastery.ObjectS<NoInfer<{
238
252
  accounts: z<HumanChannelAccountConfig[], HumanChannelAccountConfig[], "defined">;
239
253
  subscriptions: z<HumanChannelSubscriptionConfig[], HumanChannelSubscriptionConfig[], "defined">;
@@ -270,6 +284,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
270
284
  publicAskEnabled: z<boolean, boolean, "volatile">;
271
285
  croEnabled: z<boolean, boolean, "volatile">;
272
286
  unattendedEnabled: z<boolean, boolean, "volatile">;
287
+ diagramEnabled: z<boolean, boolean, "volatile">;
273
288
  }>>, Schemastery.ObjectT<NoInfer<{
274
289
  serenityConfigPaths: z<string[], string[], "defined">;
275
290
  tools: z<boolean, boolean, "defined">;
@@ -321,6 +336,11 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
321
336
  }>>, Schemastery.ObjectT<NoInfer<{
322
337
  enabled: z<boolean, boolean, "defined">;
323
338
  }>>, "plain">;
339
+ diagram: z<Schemastery.ObjectS<NoInfer<{
340
+ enabled: z<boolean, boolean, "defined">;
341
+ }>>, Schemastery.ObjectT<NoInfer<{
342
+ enabled: z<boolean, boolean, "defined">;
343
+ }>>, "plain">;
324
344
  humanChannel: z<Schemastery.ObjectS<NoInfer<{
325
345
  accounts: z<HumanChannelAccountConfig[], HumanChannelAccountConfig[], "defined">;
326
346
  subscriptions: z<HumanChannelSubscriptionConfig[], HumanChannelSubscriptionConfig[], "defined">;
@@ -357,6 +377,7 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
357
377
  publicAskEnabled: z<boolean, boolean, "volatile">;
358
378
  croEnabled: z<boolean, boolean, "volatile">;
359
379
  unattendedEnabled: z<boolean, boolean, "volatile">;
380
+ diagramEnabled: z<boolean, boolean, "volatile">;
360
381
  }>>, "plain">;
361
382
  export declare function apply(ctx: Context, config: Config): void;
362
383
  export {};