@shgroup/dsh-serenity-hooks 1.78.0 → 1.79.1
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/im/channels.d.ts +10 -0
- package/lib/im/feishu-account.d.ts +10 -0
- package/lib/im/feishu-channel.d.ts +23 -0
- package/lib/im/guard.d.ts +29 -4
- package/lib/im/inbound-event.d.ts +7 -2
- package/lib/index.js +311 -30
- package/package.json +1 -1
package/dsh.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "dsh-serenity-hooks",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.79.1",
|
|
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": {
|
package/lib/im/channels.d.ts
CHANGED
|
@@ -64,6 +64,16 @@ export interface ImChannel {
|
|
|
64
64
|
id: string;
|
|
65
65
|
/** 该 CCC 是否启用了本通道(缺省实现:读该通道的 CCC 配置) */
|
|
66
66
|
isEnabled(root: string): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* `isEnabled` 为假时**给人看的原因与怎么改**(可选)。
|
|
69
|
+
*
|
|
70
|
+
* 🔴 为什么它归通道自己而不归工具面(2026-10-20 Q4 真机验收踩到):
|
|
71
|
+
* 工具面先前统一印「enable it in … (weixin.enabled)」,而**只有通道自己知道
|
|
72
|
+
* "怎样才能启用我"** —— 飞书能否用不取决于 `weixin.enabled`,而取决于有没有
|
|
73
|
+
* 一条启用中的飞书账号。⇒ 统一文案在第二个渠道上就成了**教一个不生效的改法**。
|
|
74
|
+
* 缺省(不给)⇒ 用通用文案,说"查该渠道自己的开关/账号表",不点名任何具体字段。
|
|
75
|
+
*/
|
|
76
|
+
notEnabledReason?(root: string): string | undefined;
|
|
67
77
|
/** 别名/裸 id → 通道用户;无法解析返回 null */
|
|
68
78
|
resolveUser(root: string, input: string): ImUserEntry | null;
|
|
69
79
|
/** 已配置别名列表(`users` 动作) */
|
|
@@ -68,6 +68,16 @@ export interface FeishuSdk {
|
|
|
68
68
|
* 缺省 = 动态 import 真包 —— 这样生产路径不需要 `bridge.ts` 也能独立跑。
|
|
69
69
|
*/
|
|
70
70
|
export declare function installFeishuSdk(loader: () => Promise<FeishuSdk>): void;
|
|
71
|
+
/**
|
|
72
|
+
* 取 SDK(未注入 ⇒ 动态 import 真包)。
|
|
73
|
+
*
|
|
74
|
+
* 🔴 **导出**(2026-10-20 Q4):出站通道 `feishu-channel.ts` 也要用它。
|
|
75
|
+
* 让它走**同一个加载器**而不是自己再写一次 `import('@larksuiteoapi/node-sdk')` ——
|
|
76
|
+
* 否则 `installFeishuSdk()` 注入的替身只对入站那半生效,出站那半在测试里会去加载真 SDK
|
|
77
|
+
* (那正是本文件头注记着的实测坑:真 SDK 会打自己的日志证明你没拦住它)。
|
|
78
|
+
* ⇒ **注入点必须唯一**,否则"能不能测"这件事会随调用方向而变。
|
|
79
|
+
*/
|
|
80
|
+
export declare function loadFeishuSdk(): Promise<FeishuSdk>;
|
|
71
81
|
/** 测试辅助:关掉并清空全部长连接(生产零调用) */
|
|
72
82
|
export declare function __closeAllFeishuConnectionsForTest(): void;
|
|
73
83
|
/** 测试辅助:清空去重表(生产零调用) */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ImChannel } from './channels.js';
|
|
2
|
+
/** 解析结果(与微信侧 `WeixinAccountResolution` 同构) */
|
|
3
|
+
type FeishuAccountResolution = {
|
|
4
|
+
ok: true;
|
|
5
|
+
accountId: string;
|
|
6
|
+
appId: string;
|
|
7
|
+
appSecret: string;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
code: string;
|
|
11
|
+
error: string;
|
|
12
|
+
remediation?: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* 解析该 CCC 的飞书发送账号 + 凭据。
|
|
16
|
+
*
|
|
17
|
+
* 顺序与微信侧 `resolveWeixinAccount` **同构**(桥启用 → 有启用账号 → 指定账号存在且是飞书
|
|
18
|
+
* → 凭据已绑),因为"为什么发不出去"的排查路径必须逐字对应——两个渠道给出不同的失败顺序,
|
|
19
|
+
* 用户就得记两套。
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveFeishuAccount(root: string, accountId?: string): FeishuAccountResolution;
|
|
22
|
+
export declare const feishuChannel: ImChannel;
|
|
23
|
+
export {};
|
package/lib/im/guard.d.ts
CHANGED
|
@@ -7,13 +7,21 @@
|
|
|
7
7
|
* 该用机械守卫兜底的场景(与 output-guard 同一套思路:turn-stopping + agent.steer)。
|
|
8
8
|
*
|
|
9
9
|
* 本模块做三件事(全部是机制,不含任何纪律措辞——措辞归 CCC 角色提示词):
|
|
10
|
-
* ① `noteManualOutputSession`:桥每轮把手动模式会话登记进来(含 root/account/user/role
|
|
10
|
+
* ① `noteManualOutputSession`:桥每轮把手动模式会话登记进来(含 root/account/user/role/**channel**)
|
|
11
11
|
* ② 订阅 `tools/post-execute`:观察本轮是否真的成功调用过输出工具(`im-bridge` 的
|
|
12
12
|
* `action=send|send-file`,兼容旧 `msm("weixin-send", …)`)——**只在执行成功时**计入
|
|
13
13
|
* ③ 订阅 `agent/turn-stopping`:本轮结束仍没有成功发送 → `agent.steer` 打回一次(≤2 次),
|
|
14
14
|
* 提示内容 = **标记 + 事实**("本轮未发送,用户什么都没收到"),具体怎么做由 CCC 角色
|
|
15
15
|
* 提示词定义(与 per-message 标记同名 `[serenity:weixin-manual-output]`,模型能对上)。
|
|
16
16
|
*
|
|
17
|
+
* 🔴 **渠道中立(2026-10-20,Q4 出站飞书)**:本模块原先**处处假定微信**——
|
|
18
|
+
* "成功判定"硬比 `channel === 'weixin'`、打回文案硬写 `channel:"weixin"`。
|
|
19
|
+
* 接第二个渠道后那两处会让**飞书会话永远打回**(详见 `isSuccessfulImSend` 的注释)。
|
|
20
|
+
* ⇒ 现在渠道由**登记时**带进来(缺省 `'weixin'`,既有调用点与测试读数不变)。
|
|
21
|
+
* ⚠️ **标记字符串 `[serenity:weixin-manual-output]` 不改名**:CCC 侧的角色提示词按它做判据,
|
|
22
|
+
* 改名会把所有已写好的 CCC 纪律文档打成废纸;它是一个**协议标记**,不是渠道名
|
|
23
|
+
* (与 `weixinSessionIdForTrajectory` 同一个处置,A85)。
|
|
24
|
+
*
|
|
17
25
|
* 为什么不直接替 agent 发送(R↓):用户拍板的语义是"输出权归 agent"——代发会让 agent 误以为
|
|
18
26
|
* 自己发过、也掩盖了"模型没遵守"这一事实;打回让模型自己完成,且留下可观测的日志。
|
|
19
27
|
*
|
|
@@ -28,6 +36,14 @@ interface ManualOutputSession {
|
|
|
28
36
|
accountId: string;
|
|
29
37
|
userId: string;
|
|
30
38
|
role: string;
|
|
39
|
+
/**
|
|
40
|
+
* 这个会话走的是哪个渠道(2026-10-20 Q4)。
|
|
41
|
+
*
|
|
42
|
+
* 🔴 它决定两件事:① 打回文案里那条命令该写哪个 `channel`;
|
|
43
|
+
* ② 判定"本轮是否真的发出去了"时该认哪个 `channel`。
|
|
44
|
+
* ⚠️ 缺省 `'weixin'` 保留既有调用点与测试的读数(B1 判据:微信行为逐字不变)。
|
|
45
|
+
*/
|
|
46
|
+
channel?: string;
|
|
31
47
|
}
|
|
32
48
|
/** 桥:登记手动模式会话(每轮 incoming 都调用,幂等覆盖) */
|
|
33
49
|
export declare function noteManualOutputSession(sessionId: string, session: ManualOutputSession): void;
|
|
@@ -45,13 +61,22 @@ export declare function clearSentThisTurn(sessionId: string): void;
|
|
|
45
61
|
/** 闸门是否装配成功(未装配 → 桥不做兜底:宁可静默也不冒重复发送的风险) */
|
|
46
62
|
export declare function isWeixinOutputGuardActive(): boolean;
|
|
47
63
|
/**
|
|
48
|
-
*
|
|
64
|
+
* 工具调用是否为「本轮成功发出了一条消息」。
|
|
49
65
|
*
|
|
50
66
|
* v1.31.0 起有两种合法形态(标记与闸门同时认,迁移期并存):
|
|
51
|
-
* - `im-bridge({channel:"
|
|
67
|
+
* - `im-bridge({channel:"<渠道>", action:"send"|"send-file", …})` —— ACC 工具(推荐)
|
|
52
68
|
* - `msm("weixin-send", ["send"|"send-file", …])` —— CCC MSM(v1.30.x 通道,逐步退役)
|
|
69
|
+
*
|
|
70
|
+
* 🔴 **原名为 `isSuccessfulWeixinSend`,2026-10-20 改名**(Q4 出站飞书):
|
|
71
|
+
* 它曾硬比 `channel !== 'weixin' ⇒ false`。接第二个渠道后,那条判据的含义变成
|
|
72
|
+
* **"只有微信算发出去了"** —— 而实际后果是:飞书会话里 agent 真的发成功了,
|
|
73
|
+
* 闸门仍判"未发送" ⇒ 打回 ≤2 次,若 CCC 开了 `fallbackOnNoSend` 还会再兜底转发一遍
|
|
74
|
+
* ⇒ **重复投递**。⇒ 判据从"是不是微信"改成"是不是**这个会话所在的那条渠道**",
|
|
75
|
+
* 函数名随之更正(**名字要能对着行为读**,D151/D179 同族)。
|
|
76
|
+
*
|
|
77
|
+
* @param channel 该会话的渠道(缺省 `'weixin'` = 既有调用点与测试的读数不变)
|
|
53
78
|
*/
|
|
54
|
-
export declare function
|
|
79
|
+
export declare function isSuccessfulImSend(exec: unknown, result: unknown, channel?: string): boolean;
|
|
55
80
|
/** 打回消息(机制事实 + 标记;怎么做由 CCC 角色提示词定义) */
|
|
56
81
|
export declare function buildManualOutputRebuke(session: ManualOutputSession): string;
|
|
57
82
|
/** 测试辅助:清空全部状态 */
|
|
@@ -136,15 +136,20 @@ export declare function resolveInboundTarget(ctx: Context, input: {
|
|
|
136
136
|
* @param preset 机器级模式:目标已解析好 ⇒ **不再自行路由**,且**不看本 CCC 的 `enabled`**
|
|
137
137
|
*/
|
|
138
138
|
export declare function handleInboundEvent(ctx: Context, root: string, accountId: string, io: ImAccountIo, event: ImInboundEvent, preset?: {
|
|
139
|
-
target
|
|
139
|
+
target?: ResolvedInboundTarget;
|
|
140
|
+
channel?: string;
|
|
140
141
|
}): Promise<void>;
|
|
141
142
|
/**
|
|
142
143
|
* 手动输出模式的**机制标记**(v1.30.10 引入;v1.30.16 按「ACC 管机制、CCC 管措辞」重构)。
|
|
143
144
|
*
|
|
144
145
|
* 边界(R↓):ACC 只提供**机制与数据**——"桥不转发最终文本"这个事实 + 三个已填好的参数值。
|
|
145
146
|
* **纪律措辞归 CCC**:怎么写、必须怎么做、后果是什么,全部写在轨迹的 `PROMPT.md` 里。
|
|
147
|
+
*
|
|
148
|
+
* 🔴 **渠道不是常量**(2026-10-20 Q4):此前这里硬写 `channel:"weixin"`,
|
|
149
|
+
* 而一条**飞书**会话收到的却是"发往微信"的命令 ⇒ 模型照着做必然失败(目标 id 是 open_id)。
|
|
150
|
+
* ⇒ 渠道由调用方传入;缺省 `'weixin'` 保留既有调用点与测试的读数。
|
|
146
151
|
*/
|
|
147
|
-
export declare function manualOutputMarker(accountId: string, userId: string): string;
|
|
152
|
+
export declare function manualOutputMarker(accountId: string, userId: string, channel?: string): string;
|
|
148
153
|
/**
|
|
149
154
|
* 手动输出模式的**兜底判定**(纯函数,v1.30.17——把决策矩阵从装配里抽出来,可独立测试)。
|
|
150
155
|
*
|
package/lib/index.js
CHANGED
|
@@ -11168,11 +11168,19 @@ function hasEnabledImChannel(root) {
|
|
|
11168
11168
|
} catch {}
|
|
11169
11169
|
return false;
|
|
11170
11170
|
}
|
|
11171
|
-
/**
|
|
11172
|
-
|
|
11171
|
+
/**
|
|
11172
|
+
* 错误码 → 可行动提示(模型视角:说清怎么改)。
|
|
11173
|
+
*
|
|
11174
|
+
* 🔴 `CHANNEL_NOT_CONFIGURED` 的提示**不能写死某一个渠道的字段名**(2026-10-20 Q4 真机验收踩到):
|
|
11175
|
+
* 首版逐字写「enable it in … (weixin.enabled)」,而接第二个渠道后,**飞书能不能用不取决于
|
|
11176
|
+
* 这个开关**(它取决于有没有一条启用中的飞书账号)⇒ 按那句提示去改 `weixin.enabled`
|
|
11177
|
+
* **不会生效**,而错误码又指向"没配置" ⇒ 用户据此改完还是同一句报错。
|
|
11178
|
+
* ⇒ 提示按**渠道是否带自己的启用判据**分岔:带 `channelHint` 的就用它自己的说法。
|
|
11179
|
+
*/
|
|
11180
|
+
function remediationFor(code, channelHint) {
|
|
11173
11181
|
switch (code) {
|
|
11174
11182
|
case "CHANNEL_UNKNOWN": return "Use a registered channel id (see the tool description)";
|
|
11175
|
-
case "CHANNEL_NOT_CONFIGURED": return "This CCC has not configured that channel —
|
|
11183
|
+
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";
|
|
11176
11184
|
case "ACTION_UNKNOWN": return "Use send / send-file / users / status";
|
|
11177
11185
|
case "ACTION_UNSUPPORTED": return "This channel does not support that action";
|
|
11178
11186
|
case "ALIAS_UNKNOWN": return "Use a configured alias (see action=users) or a raw channel user id";
|
|
@@ -11252,7 +11260,7 @@ async function runImBridge(root, req) {
|
|
|
11252
11260
|
if (channelId === "") return fail("CHANNEL_REQUIRED", "channel is required", "Provide channel (e.g. \"weixin\")");
|
|
11253
11261
|
const channel = channels.get(channelId);
|
|
11254
11262
|
if (!channel) return fail("CHANNEL_UNKNOWN", `unknown channel "${channelId}" (registered: ${imChannelIds().join(", ") || "(none)"})`);
|
|
11255
|
-
if (!channel.isEnabled(root)) return fail("CHANNEL_NOT_CONFIGURED", `channel "${channelId}" is not configured for this CCC (${root})
|
|
11263
|
+
if (!channel.isEnabled(root)) return fail("CHANNEL_NOT_CONFIGURED", `channel "${channelId}" is not configured for this CCC (${root})`, channel.notEnabledReason?.(root));
|
|
11256
11264
|
if (action === "users") return {
|
|
11257
11265
|
ok: true,
|
|
11258
11266
|
channel: channelId,
|
|
@@ -18860,30 +18868,40 @@ function sessionIdOf$1(value) {
|
|
|
18860
18868
|
return typeof id === "string" && id !== "" ? id : null;
|
|
18861
18869
|
}
|
|
18862
18870
|
/**
|
|
18863
|
-
*
|
|
18871
|
+
* 工具调用是否为「本轮成功发出了一条消息」。
|
|
18864
18872
|
*
|
|
18865
18873
|
* v1.31.0 起有两种合法形态(标记与闸门同时认,迁移期并存):
|
|
18866
|
-
* - `im-bridge({channel:"
|
|
18874
|
+
* - `im-bridge({channel:"<渠道>", action:"send"|"send-file", …})` —— ACC 工具(推荐)
|
|
18867
18875
|
* - `msm("weixin-send", ["send"|"send-file", …])` —— CCC MSM(v1.30.x 通道,逐步退役)
|
|
18876
|
+
*
|
|
18877
|
+
* 🔴 **原名为 `isSuccessfulWeixinSend`,2026-10-20 改名**(Q4 出站飞书):
|
|
18878
|
+
* 它曾硬比 `channel !== 'weixin' ⇒ false`。接第二个渠道后,那条判据的含义变成
|
|
18879
|
+
* **"只有微信算发出去了"** —— 而实际后果是:飞书会话里 agent 真的发成功了,
|
|
18880
|
+
* 闸门仍判"未发送" ⇒ 打回 ≤2 次,若 CCC 开了 `fallbackOnNoSend` 还会再兜底转发一遍
|
|
18881
|
+
* ⇒ **重复投递**。⇒ 判据从"是不是微信"改成"是不是**这个会话所在的那条渠道**",
|
|
18882
|
+
* 函数名随之更正(**名字要能对着行为读**,D151/D179 同族)。
|
|
18883
|
+
*
|
|
18884
|
+
* @param channel 该会话的渠道(缺省 `'weixin'` = 既有调用点与测试的读数不变)
|
|
18868
18885
|
*/
|
|
18869
|
-
function
|
|
18886
|
+
function isSuccessfulImSend(exec, result, channel = "weixin") {
|
|
18870
18887
|
const e = exec;
|
|
18871
18888
|
if (result?.isError === true) return false;
|
|
18872
18889
|
const args = e?.arguments;
|
|
18873
18890
|
if (e?.name === "im-bridge") {
|
|
18874
|
-
if ((typeof args?.channel === "string" ? args.channel : "") !==
|
|
18891
|
+
if ((typeof args?.channel === "string" ? args.channel : "") !== channel) return false;
|
|
18875
18892
|
const action = typeof args?.action === "string" ? args.action : "";
|
|
18876
18893
|
return action === "send" || action === "send-file";
|
|
18877
18894
|
}
|
|
18878
|
-
if (e?.name === "msm") return args?.name === "weixin-send";
|
|
18895
|
+
if (e?.name === "msm") return channel === "weixin" && args?.name === "weixin-send";
|
|
18879
18896
|
return false;
|
|
18880
18897
|
}
|
|
18881
18898
|
/** 打回消息(机制事实 + 标记;怎么做由 CCC 角色提示词定义) */
|
|
18882
18899
|
function buildManualOutputRebuke(session) {
|
|
18900
|
+
const channel = session.channel ?? "weixin";
|
|
18883
18901
|
return [
|
|
18884
18902
|
"[serenity:weixin-manual-output] no send detected in this turn — the user has received NOTHING.",
|
|
18885
|
-
`ccc=${session.root} account=${session.accountId} user=${session.userId}`,
|
|
18886
|
-
`im-bridge({channel:"
|
|
18903
|
+
`ccc=${session.root} account=${session.accountId} user=${session.userId} channel=${channel}`,
|
|
18904
|
+
`im-bridge({channel:"${channel}", action:"send", account:"${session.accountId}", user:"${session.userId}", text:"<回复>"})`
|
|
18887
18905
|
].join("\n");
|
|
18888
18906
|
}
|
|
18889
18907
|
/**
|
|
@@ -18896,9 +18914,11 @@ function registerWeixinOutputGuard(ctx) {
|
|
|
18896
18914
|
const downstream = await next();
|
|
18897
18915
|
try {
|
|
18898
18916
|
const sessionId = sessionIdOf$1(exec?.agent);
|
|
18899
|
-
if (sessionId && manualSessions.has(sessionId)
|
|
18900
|
-
|
|
18901
|
-
|
|
18917
|
+
if (sessionId && manualSessions.has(sessionId)) {
|
|
18918
|
+
if (isSuccessfulImSend(exec, result, manualSessions.get(sessionId)?.channel ?? "weixin")) {
|
|
18919
|
+
sentThisTurn.add(sessionId);
|
|
18920
|
+
rebukeCounts.delete(sessionId);
|
|
18921
|
+
}
|
|
18902
18922
|
}
|
|
18903
18923
|
} catch {}
|
|
18904
18924
|
return downstream;
|
|
@@ -19056,13 +19076,25 @@ async function resolveInboundTarget(ctx, input) {
|
|
|
19056
19076
|
* @param preset 机器级模式:目标已解析好 ⇒ **不再自行路由**,且**不看本 CCC 的 `enabled`**
|
|
19057
19077
|
*/
|
|
19058
19078
|
async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
19079
|
+
/**
|
|
19080
|
+
* 🔴 **渠道身份必须穿过本层**(2026-10-20,Q4 出站飞书)。
|
|
19081
|
+
*
|
|
19082
|
+
* 为什么缺省值是 `'weixin'` 而不是"必填":本层有 7 处既有测试调用点**逐字不动**
|
|
19083
|
+
* (B1 的判据是"微信行为逐字不变")。⇒ 缺省 = 当时唯一存在的渠道,
|
|
19084
|
+
* 而**新增渠道必须显式传**,这样"漏传"只会退化到旧行为,不会把两条渠道搅混。
|
|
19085
|
+
*
|
|
19086
|
+
* ⚠️ 为什么由**调用方**给而不是从 `accountId` 推:`accountId`(`feishu-1` / `wechat-1`)
|
|
19087
|
+
* 只是**命名习惯**,配置里没有任何字段强制它带渠道前缀 —— 从名字推渠道 =
|
|
19088
|
+
* 让一个会漂的约定承当判据(D167 同族:认族不认名)。
|
|
19089
|
+
*/
|
|
19090
|
+
const channel = preset?.channel ?? "weixin";
|
|
19059
19091
|
const fromUserId = event.userId;
|
|
19060
19092
|
if (!fromUserId) return;
|
|
19061
19093
|
const text = event.text;
|
|
19062
19094
|
const mediaRefs = event.media;
|
|
19063
19095
|
const settings = readWeixinSettings(root);
|
|
19064
19096
|
let resolved;
|
|
19065
|
-
if (preset) resolved = preset.target;
|
|
19097
|
+
if (preset?.target) resolved = preset.target;
|
|
19066
19098
|
else {
|
|
19067
19099
|
if (!settings.enabled) return;
|
|
19068
19100
|
const cccTarget = matchWeixinRoute(settings.routes ?? [], fromUserId);
|
|
@@ -19172,9 +19204,10 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
19172
19204
|
root,
|
|
19173
19205
|
accountId,
|
|
19174
19206
|
userId: fromUserId,
|
|
19175
|
-
role: trajectory
|
|
19207
|
+
role: trajectory,
|
|
19208
|
+
channel
|
|
19176
19209
|
});
|
|
19177
|
-
parts.push(manualOutputMarker(accountId, fromUserId));
|
|
19210
|
+
parts.push(manualOutputMarker(accountId, fromUserId, channel));
|
|
19178
19211
|
} else forgetManualOutputSession(sessionId);
|
|
19179
19212
|
const question = parts.join("\n");
|
|
19180
19213
|
const hookRel = settings.hook;
|
|
@@ -19249,9 +19282,13 @@ async function handleInboundEvent(ctx, root, accountId, io, event, preset) {
|
|
|
19249
19282
|
*
|
|
19250
19283
|
* 边界(R↓):ACC 只提供**机制与数据**——"桥不转发最终文本"这个事实 + 三个已填好的参数值。
|
|
19251
19284
|
* **纪律措辞归 CCC**:怎么写、必须怎么做、后果是什么,全部写在轨迹的 `PROMPT.md` 里。
|
|
19285
|
+
*
|
|
19286
|
+
* 🔴 **渠道不是常量**(2026-10-20 Q4):此前这里硬写 `channel:"weixin"`,
|
|
19287
|
+
* 而一条**飞书**会话收到的却是"发往微信"的命令 ⇒ 模型照着做必然失败(目标 id 是 open_id)。
|
|
19288
|
+
* ⇒ 渠道由调用方传入;缺省 `'weixin'` 保留既有调用点与测试的读数。
|
|
19252
19289
|
*/
|
|
19253
|
-
function manualOutputMarker(accountId, userId) {
|
|
19254
|
-
return ["[serenity:weixin-manual-output]", `im-bridge({channel:"
|
|
19290
|
+
function manualOutputMarker(accountId, userId, channel = "weixin") {
|
|
19291
|
+
return ["[serenity:weixin-manual-output]", `im-bridge({channel:"${channel}", action:"send", account:"${accountId}", user:"${userId}", text:"<回复>"})`].join("\n");
|
|
19255
19292
|
}
|
|
19256
19293
|
/**
|
|
19257
19294
|
* 手动输出模式的**兜底判定**(纯函数,v1.30.17——把决策矩阵从装配里抽出来,可独立测试)。
|
|
@@ -19271,7 +19308,15 @@ function manualOutputFallbackNeeded(input) {
|
|
|
19271
19308
|
const SEEN_LIMIT = 2e3;
|
|
19272
19309
|
/** 已处理过的 message_id(超时重推去重;按账号隔离) */
|
|
19273
19310
|
const seenByAccount = /* @__PURE__ */ new Map();
|
|
19274
|
-
/**
|
|
19311
|
+
/**
|
|
19312
|
+
* 取 SDK(未注入 ⇒ 动态 import 真包)。
|
|
19313
|
+
*
|
|
19314
|
+
* 🔴 **导出**(2026-10-20 Q4):出站通道 `feishu-channel.ts` 也要用它。
|
|
19315
|
+
* 让它走**同一个加载器**而不是自己再写一次 `import('@larksuiteoapi/node-sdk')` ——
|
|
19316
|
+
* 否则 `installFeishuSdk()` 注入的替身只对入站那半生效,出站那半在测试里会去加载真 SDK
|
|
19317
|
+
* (那正是本文件头注记着的实测坑:真 SDK 会打自己的日志证明你没拦住它)。
|
|
19318
|
+
* ⇒ **注入点必须唯一**,否则"能不能测"这件事会随调用方向而变。
|
|
19319
|
+
*/
|
|
19275
19320
|
async function loadFeishuSdk() {
|
|
19276
19321
|
return await import("./lib-TxscFKeo.js").then((m) => /* @__PURE__ */ __toESM(m.default, 1));
|
|
19277
19322
|
}
|
|
@@ -19402,8 +19447,11 @@ async function runFeishuAccountLoop(input, loop) {
|
|
|
19402
19447
|
cccFallback: null
|
|
19403
19448
|
});
|
|
19404
19449
|
if (!target) continue;
|
|
19405
|
-
await handleInboundEvent(ctx, target.root, input.accountId, io, event, {
|
|
19406
|
-
|
|
19450
|
+
await handleInboundEvent(ctx, target.root, input.accountId, io, event, {
|
|
19451
|
+
target,
|
|
19452
|
+
channel: "feishu"
|
|
19453
|
+
});
|
|
19454
|
+
} else await handleInboundEvent(ctx, input.root, input.accountId, io, event, { channel: "feishu" });
|
|
19407
19455
|
} catch (err) {
|
|
19408
19456
|
loop.lastError = err instanceof Error ? err.message : String(err);
|
|
19409
19457
|
}
|
|
@@ -21302,7 +21350,7 @@ function weixinAliasMap(root) {
|
|
|
21302
21350
|
return readWeixinSettings(root).aliases ?? {};
|
|
21303
21351
|
}
|
|
21304
21352
|
/** 读该 CCC 的一个凭据值(空/缺失 → null) */
|
|
21305
|
-
function credentialValue(root, key) {
|
|
21353
|
+
function credentialValue$1(root, key) {
|
|
21306
21354
|
try {
|
|
21307
21355
|
const value = readStore(root, "credential")[key];
|
|
21308
21356
|
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
@@ -21311,7 +21359,7 @@ function credentialValue(root, key) {
|
|
|
21311
21359
|
}
|
|
21312
21360
|
}
|
|
21313
21361
|
/** 把通道层的失败码转成一句可读错误(工具面统一映射为 SEND_FAILED) */
|
|
21314
|
-
function channelError(code, error, remediation) {
|
|
21362
|
+
function channelError$1(code, error, remediation) {
|
|
21315
21363
|
return /* @__PURE__ */ new Error(`${code}: ${error}${remediation ? ` — ${remediation}` : ""}`);
|
|
21316
21364
|
}
|
|
21317
21365
|
const weixinChannel = {
|
|
@@ -21319,13 +21367,20 @@ const weixinChannel = {
|
|
|
21319
21367
|
isEnabled(root) {
|
|
21320
21368
|
return readWeixinSettings(root).enabled === true;
|
|
21321
21369
|
},
|
|
21370
|
+
/**
|
|
21371
|
+
* 微信的启用判据**只有总开关这一半**(有 token 的账号是可选的:条数不限、0 条也能开)。
|
|
21372
|
+
* ⇒ 它的提示可以只提这个开关 —— 这与飞书的**两半**不同,别把两家的提示合成一句。
|
|
21373
|
+
*/
|
|
21374
|
+
notEnabledReason() {
|
|
21375
|
+
return "This CCC has no IM bridge enabled — turn on the IM switch in .opencode/serenity.json (im.enabled, or the legacy weixin.enabled) or in the settings panel";
|
|
21376
|
+
},
|
|
21322
21377
|
resolveUser(root, input) {
|
|
21323
21378
|
const raw = input.trim();
|
|
21324
21379
|
if (raw === "") return null;
|
|
21325
21380
|
const alias = raw.toLowerCase();
|
|
21326
21381
|
const key = weixinAliasMap(root)[alias];
|
|
21327
21382
|
if (key) {
|
|
21328
|
-
const id = credentialValue(root, key);
|
|
21383
|
+
const id = credentialValue$1(root, key);
|
|
21329
21384
|
return id ? {
|
|
21330
21385
|
alias,
|
|
21331
21386
|
id
|
|
@@ -21340,7 +21395,7 @@ const weixinChannel = {
|
|
|
21340
21395
|
listUsers(root) {
|
|
21341
21396
|
const out = [];
|
|
21342
21397
|
for (const [alias, key] of Object.entries(weixinAliasMap(root))) {
|
|
21343
|
-
const id = credentialValue(root, key);
|
|
21398
|
+
const id = credentialValue$1(root, key);
|
|
21344
21399
|
if (id) out.push({
|
|
21345
21400
|
alias,
|
|
21346
21401
|
id
|
|
@@ -21355,7 +21410,7 @@ const weixinChannel = {
|
|
|
21355
21410
|
text: input.text,
|
|
21356
21411
|
...input.accountId ? { accountId: input.accountId } : {}
|
|
21357
21412
|
});
|
|
21358
|
-
if (!result.ok) throw channelError(result.code, result.error, result.remediation);
|
|
21413
|
+
if (!result.ok) throw channelError$1(result.code, result.error, result.remediation);
|
|
21359
21414
|
return {
|
|
21360
21415
|
accountId: result.accountId,
|
|
21361
21416
|
userId: result.userId,
|
|
@@ -21365,9 +21420,9 @@ const weixinChannel = {
|
|
|
21365
21420
|
},
|
|
21366
21421
|
async sendFile(input) {
|
|
21367
21422
|
const settings = readWeixinSettings(input.root);
|
|
21368
|
-
if (!settings.enabled) throw channelError("BRIDGE_DISABLED", `微信桥未启用(ccc=${input.root})`);
|
|
21423
|
+
if (!settings.enabled) throw channelError$1("BRIDGE_DISABLED", `微信桥未启用(ccc=${input.root})`);
|
|
21369
21424
|
const account = resolveWeixinAccount(input.root, input.accountId);
|
|
21370
|
-
if (!account.ok) throw channelError(account.code, account.error, account.remediation);
|
|
21425
|
+
if (!account.ok) throw channelError$1(account.code, account.error, account.remediation);
|
|
21371
21426
|
const sent = await sendFileMessage({
|
|
21372
21427
|
baseUrl: account.cred.baseUrl,
|
|
21373
21428
|
token: account.cred.token,
|
|
@@ -21430,6 +21485,231 @@ const weixinChannel = {
|
|
|
21430
21485
|
}
|
|
21431
21486
|
};
|
|
21432
21487
|
//#endregion
|
|
21488
|
+
//#region src/im/feishu-channel.ts
|
|
21489
|
+
/**
|
|
21490
|
+
* im/feishu-channel.ts — 飞书**出站**通道实现(`im-bridge` 的通道之二,2026-10-20 Q4)
|
|
21491
|
+
*
|
|
21492
|
+
* ## 为什么这个文件现在才出现(先摆事实,免得下次重走)
|
|
21493
|
+
*
|
|
21494
|
+
* 入站那半(P1~P4,2026-10-19)早已完成:`feishu-account.ts` 能建长连接、能收消息、
|
|
21495
|
+
* 能**回**消息(`feishuIo()`)。但那是"收到一条消息后回它",**不是**"agent 主动发一条"。
|
|
21496
|
+
* 而 agent 主动发的唯一入口是 `im-bridge` 工具 → 它查的是 `im/channels.ts` 的通道注册表
|
|
21497
|
+
* ⇒ 那张表里**只有 `weixin` 一个条目** ⇒ `im-bridge({channel:"feishu"})` 此前恒返回
|
|
21498
|
+
* `CHANNEL_UNKNOWN`(尽管发消息的协议形态早就写好并测过)。
|
|
21499
|
+
*
|
|
21500
|
+
* ⇒ 本文件的全部职责:**把已经存在的飞书发送能力,挂到那张注册表上**。
|
|
21501
|
+
* 不是"新做一个能力",是"把两个各自能用的半件接起来"。
|
|
21502
|
+
*
|
|
21503
|
+
* ## 与 `weixin-channel.ts` 的刻意差异(三条,各有理由)
|
|
21504
|
+
*
|
|
21505
|
+
* 1. **`sendFile` 不实现** —— 诚实地说"不支持",工具会返回 `ACTION_UNSUPPORTED`。
|
|
21506
|
+
* 假装支持会得到一条"发出去的文件收不到"的静默故障。
|
|
21507
|
+
* 2. **`resolveUser` 做形状校验(`ou_` 前缀)** —— 见下 🔴。
|
|
21508
|
+
* 3. **不 fire 记录 hook** —— 见下 🔴。
|
|
21509
|
+
*
|
|
21510
|
+
* ## 🔴 别名表的跨渠道危险(这是本文件最要紧的一条判据)
|
|
21511
|
+
*
|
|
21512
|
+
* `im.aliases` 是**一张表服务所有渠道**(`{ "<别名>": "<localstore 凭据键>" }`)。
|
|
21513
|
+
* 若只做"按键查值",把映射到**微信 id** 的那条喂给飞书通道,就会拿别人的 id 跨渠道发消息
|
|
21514
|
+
* —— 而飞书 API 会返回一个**读不懂的错误码**,症状是"飞书发不出去"而不是"我配错了渠道"。
|
|
21515
|
+
* ⇒ 校验**值的形状**(飞书 open_id 以 `ou_` 开头):错配在**解析处**就失败,
|
|
21516
|
+
* 错误码是 `ALIAS_UNKNOWN` + 可行动提示。
|
|
21517
|
+
*
|
|
21518
|
+
* ## 🔴 为什么不 fire 记录 hook
|
|
21519
|
+
*
|
|
21520
|
+
* `settings.hook` 的契约与消费方都是**微信的**:CCC 的 `scripts/weixin-message-hook.ts`
|
|
21521
|
+
* 把事件追加进 `AGENT_SESSIONS/_weixin-logs/<日期>.jsonl`。飞书事件若流进去,
|
|
21522
|
+
* 那个目录就**同时装两种渠道的数据而名字只说一种**(D151/D179 同族:名字开始撒谎)。
|
|
21523
|
+
* ⇒ 本期**不落记录**,并把它登记为待裁(A83)。工具描述里按渠道写清这件事。
|
|
21524
|
+
*/
|
|
21525
|
+
/**
|
|
21526
|
+
* 飞书 open_id 的形状前缀(官方形态:`ou_` + 一串)。
|
|
21527
|
+
*
|
|
21528
|
+
* ⚠️ 这是**形状**判据,不是**合法性**判据 —— 它拦的是"把别的渠道的 id 当飞书 id 用"
|
|
21529
|
+
* 这一类错配,不负责证明该 id 真在飞书侧存在(那只有调用 API 才知道)。
|
|
21530
|
+
*/
|
|
21531
|
+
const FEISHU_USER_ID_PREFIX = "ou_";
|
|
21532
|
+
/** 该账号行是否是飞书账号(`channel` 缺省归一为 weixin —— 与 `normalizeAccountChannel` 同口径) */
|
|
21533
|
+
function isFeishuAccount(row) {
|
|
21534
|
+
return (row.channel ?? "").trim() === "feishu";
|
|
21535
|
+
}
|
|
21536
|
+
/** 读该 CCC 的一个凭据值(空/缺失 → null;与微信通道同款读法) */
|
|
21537
|
+
function credentialValue(root, key) {
|
|
21538
|
+
try {
|
|
21539
|
+
const value = readStore(root, "credential")[key];
|
|
21540
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
21541
|
+
} catch {
|
|
21542
|
+
return null;
|
|
21543
|
+
}
|
|
21544
|
+
}
|
|
21545
|
+
/**
|
|
21546
|
+
* 该 CCC 里**形状对得上飞书**的别名 → id。
|
|
21547
|
+
*
|
|
21548
|
+
* 形状不符的别名**不出现**:出现在 `users` 里就等于告诉模型"这个能用",然后它一定失败。
|
|
21549
|
+
*/
|
|
21550
|
+
function feishuUsers(root) {
|
|
21551
|
+
const aliases = readWeixinSettings(root).aliases ?? {};
|
|
21552
|
+
const out = [];
|
|
21553
|
+
for (const [alias, key] of Object.entries(aliases)) {
|
|
21554
|
+
const id = credentialValue(root, key);
|
|
21555
|
+
if (id && id.startsWith(FEISHU_USER_ID_PREFIX)) out.push({
|
|
21556
|
+
alias,
|
|
21557
|
+
id
|
|
21558
|
+
});
|
|
21559
|
+
}
|
|
21560
|
+
return out;
|
|
21561
|
+
}
|
|
21562
|
+
/**
|
|
21563
|
+
* 解析该 CCC 的飞书发送账号 + 凭据。
|
|
21564
|
+
*
|
|
21565
|
+
* 顺序与微信侧 `resolveWeixinAccount` **同构**(桥启用 → 有启用账号 → 指定账号存在且是飞书
|
|
21566
|
+
* → 凭据已绑),因为"为什么发不出去"的排查路径必须逐字对应——两个渠道给出不同的失败顺序,
|
|
21567
|
+
* 用户就得记两套。
|
|
21568
|
+
*/
|
|
21569
|
+
function resolveFeishuAccount(root, accountId) {
|
|
21570
|
+
const settings = readWeixinSettings(root);
|
|
21571
|
+
if (settings.enabled !== true) return {
|
|
21572
|
+
ok: false,
|
|
21573
|
+
code: "BRIDGE_DISABLED",
|
|
21574
|
+
error: `IM 桥未启用(ccc=${root})`,
|
|
21575
|
+
remediation: "在 .opencode/serenity.json 的 im(或 weixin).enabled 打开"
|
|
21576
|
+
};
|
|
21577
|
+
const accounts = (settings.accounts ?? []).filter((a) => a.enabled !== false && isFeishuAccount(a));
|
|
21578
|
+
if (accounts.length === 0) return {
|
|
21579
|
+
ok: false,
|
|
21580
|
+
code: "NO_ACCOUNT",
|
|
21581
|
+
error: "未配置任何启用中的飞书账号",
|
|
21582
|
+
remediation: "在设置面板「IM 桥」用 App ID + App Secret 添加一个飞书账号"
|
|
21583
|
+
};
|
|
21584
|
+
const id = accountId ?? accounts[0].accountId;
|
|
21585
|
+
if (!accounts.some((a) => a.accountId === id)) return {
|
|
21586
|
+
ok: false,
|
|
21587
|
+
code: "ACCOUNT_NOT_FOUND",
|
|
21588
|
+
error: `飞书账号不存在或未启用: ${id}`,
|
|
21589
|
+
remediation: `可用的飞书账号: ${accounts.map((a) => a.accountId).join(", ")}`
|
|
21590
|
+
};
|
|
21591
|
+
const cred = readFeishuCredential(root, id);
|
|
21592
|
+
if (!cred) return {
|
|
21593
|
+
ok: false,
|
|
21594
|
+
code: "ACCOUNT_NOT_BOUND",
|
|
21595
|
+
error: `飞书账号未绑定(缺 appId 或 appSecret): ${id}`,
|
|
21596
|
+
remediation: "在设置面板「IM 桥」重新添加该飞书账号"
|
|
21597
|
+
};
|
|
21598
|
+
return {
|
|
21599
|
+
ok: true,
|
|
21600
|
+
accountId: id,
|
|
21601
|
+
appId: cred.appId,
|
|
21602
|
+
appSecret: cred.appSecret
|
|
21603
|
+
};
|
|
21604
|
+
}
|
|
21605
|
+
/** 把通道层的失败码转成一句可读错误(工具面统一映射为 SEND_FAILED) */
|
|
21606
|
+
function channelError(code, error, remediation) {
|
|
21607
|
+
return /* @__PURE__ */ new Error(`${code}: ${error}${remediation ? ` — ${remediation}` : ""}`);
|
|
21608
|
+
}
|
|
21609
|
+
const feishuChannel = {
|
|
21610
|
+
id: "feishu",
|
|
21611
|
+
/**
|
|
21612
|
+
* 可见性判据:**本 CCC 有一条能用的飞书账号**。
|
|
21613
|
+
*
|
|
21614
|
+
* 🔴 刻意**不**只看 `settings.enabled`:那个开关是**渠道无关**的,只看它会让
|
|
21615
|
+
* 一个只配了微信的 CCC 也把 `feishu` 报成"已启用" ⇒ `im-bridge` 的
|
|
21616
|
+
* `Channels:` 枚举里出现一个用不了的名字(说明面撒谎,D179)。
|
|
21617
|
+
*/
|
|
21618
|
+
isEnabled(root) {
|
|
21619
|
+
const settings = readWeixinSettings(root);
|
|
21620
|
+
if (settings.enabled !== true) return false;
|
|
21621
|
+
return (settings.accounts ?? []).some((a) => a.enabled !== false && isFeishuAccount(a));
|
|
21622
|
+
},
|
|
21623
|
+
/**
|
|
21624
|
+
* 启用判据不成立时**说清是哪一半不成立**。
|
|
21625
|
+
*
|
|
21626
|
+
* 🔴 两半的改法完全不同(总开关 vs 加一条账号),合并成一句会让人改错那一半
|
|
21627
|
+
* —— 这正是本轮真机验收暴露出来的形态(旧文案只提 `weixin.enabled`)。
|
|
21628
|
+
*/
|
|
21629
|
+
notEnabledReason(root) {
|
|
21630
|
+
if (readWeixinSettings(root).enabled !== true) return "This CCC has no IM bridge enabled — turn on the IM switch in .opencode/serenity.json (im.enabled, or the legacy weixin.enabled) or in the settings panel";
|
|
21631
|
+
return "The IM bridge is on, but this CCC has no enabled feishu account — add one (App ID + App Secret) in the settings panel, or add a row with channel:\"feishu\" to im.accounts";
|
|
21632
|
+
},
|
|
21633
|
+
resolveUser(root, input) {
|
|
21634
|
+
const raw = input.trim();
|
|
21635
|
+
if (raw === "") return null;
|
|
21636
|
+
const alias = raw.toLowerCase();
|
|
21637
|
+
const key = (readWeixinSettings(root).aliases ?? {})[alias];
|
|
21638
|
+
if (key) {
|
|
21639
|
+
const id = credentialValue(root, key);
|
|
21640
|
+
if (!id || !id.startsWith(FEISHU_USER_ID_PREFIX)) return null;
|
|
21641
|
+
return {
|
|
21642
|
+
alias,
|
|
21643
|
+
id
|
|
21644
|
+
};
|
|
21645
|
+
}
|
|
21646
|
+
if (raw.startsWith(FEISHU_USER_ID_PREFIX)) return {
|
|
21647
|
+
alias: raw,
|
|
21648
|
+
id: raw
|
|
21649
|
+
};
|
|
21650
|
+
return null;
|
|
21651
|
+
},
|
|
21652
|
+
listUsers(root) {
|
|
21653
|
+
return feishuUsers(root);
|
|
21654
|
+
},
|
|
21655
|
+
async send(input) {
|
|
21656
|
+
const account = resolveFeishuAccount(input.root, input.accountId);
|
|
21657
|
+
if (!account.ok) throw channelError(account.code, account.error, account.remediation);
|
|
21658
|
+
const create = new (await (loadFeishuSdk())).Client({
|
|
21659
|
+
appId: account.appId,
|
|
21660
|
+
appSecret: account.appSecret
|
|
21661
|
+
}).im?.v1?.message?.create;
|
|
21662
|
+
if (typeof create !== "function") throw channelError("SEND_FAILED", "飞书 SDK 缺少 im.v1.message.create");
|
|
21663
|
+
try {
|
|
21664
|
+
await create({
|
|
21665
|
+
params: { receive_id_type: "open_id" },
|
|
21666
|
+
data: {
|
|
21667
|
+
receive_id: input.userId,
|
|
21668
|
+
msg_type: "text",
|
|
21669
|
+
content: JSON.stringify({ text: markdownToPlainText(input.text) })
|
|
21670
|
+
}
|
|
21671
|
+
});
|
|
21672
|
+
} catch (err) {
|
|
21673
|
+
throw channelError("SEND_FAILED", err instanceof Error ? err.message : String(err));
|
|
21674
|
+
}
|
|
21675
|
+
const settings = readWeixinSettings(input.root);
|
|
21676
|
+
const trajectory = matchWeixinRoute(settings.routes ?? [], input.userId) ?? "";
|
|
21677
|
+
const sessionId = weixinSessionIdForTrajectory(trajectory !== "" ? trajectory : sanitizeFileName$1(input.userId) || "unknown");
|
|
21678
|
+
return {
|
|
21679
|
+
accountId: account.accountId,
|
|
21680
|
+
userId: input.userId,
|
|
21681
|
+
sessionId,
|
|
21682
|
+
role: trajectory
|
|
21683
|
+
};
|
|
21684
|
+
},
|
|
21685
|
+
/**
|
|
21686
|
+
* 通道健康快照(`status` 动作)。
|
|
21687
|
+
*
|
|
21688
|
+
* 与微信通道**同一个数据源**(`weixinBridgeStatus()`)—— 因为长连接的生命周期归
|
|
21689
|
+
* 账号实现(`feishu-account.ts`),不归本文件。本文件只做**出站**,
|
|
21690
|
+
* 它没有自己的连接状态可报。⇒ 如实转述,不编造一个"出站健康度"。
|
|
21691
|
+
*/
|
|
21692
|
+
status(root) {
|
|
21693
|
+
const settings = readWeixinSettings(root);
|
|
21694
|
+
const accounts = weixinBridgeStatus().filter((entry) => entry.ccc === root).flatMap((entry) => entry.accounts.map((account) => ({
|
|
21695
|
+
accountId: account.accountId,
|
|
21696
|
+
lastPollAt: account.lastPollAt,
|
|
21697
|
+
...account.lastError ? { lastError: account.lastError } : {}
|
|
21698
|
+
})));
|
|
21699
|
+
const routes = (settings.routes ?? []).map((route) => ({
|
|
21700
|
+
user: route.user,
|
|
21701
|
+
role: route.trajectory
|
|
21702
|
+
}));
|
|
21703
|
+
return {
|
|
21704
|
+
enabled: settings.enabled === true,
|
|
21705
|
+
autoReplyWithLastMessage: settings.autoReplyWithLastMessage !== false,
|
|
21706
|
+
fallbackOnNoSend: settings.fallbackOnNoSend === true,
|
|
21707
|
+
routes,
|
|
21708
|
+
accounts
|
|
21709
|
+
};
|
|
21710
|
+
}
|
|
21711
|
+
};
|
|
21712
|
+
//#endregion
|
|
21433
21713
|
//#region src/tools/im-bridge.ts
|
|
21434
21714
|
/**
|
|
21435
21715
|
* im/channels.ts(工具面)— IM 消息发送工具(v1.31.0)
|
|
@@ -21463,7 +21743,7 @@ function renderText(value) {
|
|
|
21463
21743
|
function createImBridgeTool() {
|
|
21464
21744
|
return defineTool({
|
|
21465
21745
|
name: "im-bridge",
|
|
21466
|
-
description: `Send messages to family members through an IM channel configured for THIS container
|
|
21746
|
+
description: `Send messages to family members through an IM channel configured for THIS container. This tool only acts on the current container (no target container parameter). It is hidden entirely when this container has no IM channel configured. Channels: ${imChannelIds().join(", ") || "(none registered)"}. Actions: send (text) / send-file (a file inside this container) / users (list configured recipients) / status (channel health). Not every channel supports every action (e.g. send-file may return ACTION_UNSUPPORTED). user accepts a configured alias (e.g. user-a, user-b) or a raw channel user id. Failures return a stable code plus a remediation hint.`,
|
|
21467
21747
|
parameters: {
|
|
21468
21748
|
channel: {
|
|
21469
21749
|
type: "string",
|
|
@@ -22963,6 +23243,7 @@ function apply(ctx, config) {
|
|
|
22963
23243
|
if (report !== null && report.issues.length > 0) console.warn(`[serenity-hooks] ${summarizeHostContract(report)}`);
|
|
22964
23244
|
} catch {}
|
|
22965
23245
|
registerImChannel(weixinChannel);
|
|
23246
|
+
registerImChannel(feishuChannel);
|
|
22966
23247
|
(async () => {
|
|
22967
23248
|
try {
|
|
22968
23249
|
const domain = await openBindingDomain(ctx);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shgroup/dsh-serenity-hooks",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.79.1",
|
|
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 按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 认知轨迹日志与原地重建 + 网页登录入口/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(三档)。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|