ppxans-harness 2.6.0 → 3.2.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/LICENSE +201 -201
- package/README.md +195 -195
- package/bin/ppx-channels.js +2 -2
- package/bin/ppx-serve.js +5 -5
- package/bin/ppx-setup.js +124 -0
- package/bin/ppx-web.js +140 -0
- package/bin/ppx.js +2 -2
- package/config/identity.md +6 -6
- package/config/ishiki.md +16 -16
- package/config/ppx.json +6 -124
- package/config/ppx.json.example +143 -0
- package/package.json +16 -6
- package/skills/.usage.json +6 -0
- package/skills/agent-professional-training/SKILL.md +94 -0
- package/skills/agent-professional-training/trajectories/2026-09-11-dpo-pairs.jsonl +2 -0
- package/skills/agent-professional-training/trajectories/2026-09-11-round2-trajectory.json +15 -0
- package/skills/agent-professional-training/trajectories/2026-09-13-first-run-trajectory.json +17 -0
- package/skills/brainstorm/SKILL.md +24 -0
- package/skills/cupid-lover-comms/SKILL.md +37 -0
- package/skills/debug/SKILL.md +26 -0
- package/skills/plan/SKILL.md +25 -0
- package/skills/ponytail/SKILL.md +25 -0
- package/skills/ppx-memory/SKILL.md +91 -0
- package/skills/ppx-memory/scripts/cli.js +192 -0
- package/skills/ppx-memory/scripts/experience.js +133 -0
- package/skills/ppx-memory/scripts/fact-store.js +842 -0
- package/skills/ppx-memory/scripts/l0.js +52 -0
- package/skills/ppx-memory/scripts/l2.js +146 -0
- package/skills/ppx-memory/scripts/l3.js +112 -0
- package/skills/ppx-memory/scripts/memory-ticker.js +238 -0
- package/skills/ppx-memory/scripts/pii.js +42 -0
- package/skills/ppx-memory/scripts/schema.js +80 -0
- package/skills/ppx-memory/scripts/session.js +398 -0
- package/skills/ppx-memory/scripts/similarity.js +43 -0
- package/skills/ppx-memory/scripts/store.js +116 -0
- package/skills/ppx-memory/scripts/wal.js +38 -0
- package/skills/ppx-selfheal/SKILL.md +24 -0
- package/skills/ppx-selfheal/scripts/cli.js +80 -0
- package/skills/ppx-selfheal/scripts/healer.js +184 -0
- package/skills/ppx-selfheal/scripts/logger.js +17 -0
- package/skills/ppx-selfheal/scripts/store.js +116 -0
- package/skills/prompt-depth-kit/SKILL.md +28 -0
- package/skills/session-naming/SKILL.md +36 -0
- package/skills/verify/SKILL.md +25 -0
- package/src/agent/index.js +1352 -717
- package/src/agent/prompts.js +46 -2
- package/src/aml-server.js +197 -151
- package/src/ans/eviction.js +123 -143
- package/src/ans/guard.js +159 -120
- package/src/ans/lifecycle.js +96 -93
- package/src/ans/proactive.js +112 -129
- package/src/ans/reward.js +95 -111
- package/src/ans/values.js +15 -15
- package/src/audit/audit-chain.js +43 -7
- package/src/audit/verifier.js +157 -120
- package/src/bus/circuit-breaker.js +9 -1
- package/src/bus/runtime-bus.js +107 -93
- package/src/channels/base.js +57 -34
- package/src/channels/feishu.js +118 -126
- package/src/channels/http.js +1090 -651
- package/src/channels/index.js +111 -110
- package/src/channels/log.js +29 -29
- package/src/channels/wechat-crypto.js +73 -74
- package/src/channels/wechat.js +191 -197
- package/src/channels/workspace.js +94 -0
- package/src/channels-cli.js +126 -124
- package/src/cli.js +129 -120
- package/src/commands/index.js +142 -0
- package/src/config/channels.js +137 -170
- package/src/config/index.js +277 -224
- package/src/config/placeholder.js +31 -0
- package/src/config/providers.js +148 -182
- package/src/config/settings.js +156 -182
- package/src/core/policy.js +69 -18
- package/src/core/trace.js +8 -6
- package/src/edit/editblock.js +266 -0
- package/src/edit/snapshot.js +67 -0
- package/src/evidence/index.js +153 -0
- package/src/evolve/playbook.js +9 -10
- package/src/hooks/index.js +113 -0
- package/src/llm/client.js +4 -3
- package/src/llm/dsml.js +74 -74
- package/src/llm/embedder.js +41 -35
- package/src/llm/fence.js +52 -52
- package/src/llm/index.js +4 -4
- package/src/llm/local-embedder.js +94 -0
- package/src/llm/presets.js +113 -0
- package/src/llm/pricing.js +93 -0
- package/src/llm/retry.js +73 -73
- package/src/llm/router.js +92 -89
- package/src/mcp/admin.js +44 -10
- package/src/mcp/client.js +487 -477
- package/src/mcp/http.js +23 -37
- package/src/mcp/index.js +116 -116
- package/src/mcp/server.js +23 -18
- package/src/mcp/tasks.js +9 -14
- package/src/memory/asset-hub.js +6 -11
- package/src/memory/canvas.js +2 -5
- package/src/memory/compaction.js +28 -28
- package/src/memory/experience.js +133 -122
- package/src/memory/fact-store.js +914 -698
- package/src/memory/failure-episode.js +20 -11
- package/src/memory/fork.js +17 -8
- package/src/memory/index.js +8 -6
- package/src/memory/l0.js +53 -52
- package/src/memory/l2.js +145 -130
- package/src/memory/l3.js +111 -111
- package/src/memory/legion-board.js +71 -0
- package/src/memory/memory-ticker.js +239 -240
- package/src/memory/session.js +398 -397
- package/src/memory/sqlite-store.js +581 -0
- package/src/mode/blackboard.js +49 -49
- package/src/mode/graph.js +42 -41
- package/src/mode/index.js +64 -64
- package/src/mode/legion.js +54 -51
- package/src/mode/plan-exec.js +50 -50
- package/src/mode/router.js +28 -40
- package/src/orchestrator/agent-worker.js +69 -69
- package/src/orchestrator/dag.js +90 -83
- package/src/orchestrator/experts.js +76 -0
- package/src/orchestrator/index.js +1 -1
- package/src/orchestrator/legion.js +179 -187
- package/src/orchestrator/supervisor.js +6 -8
- package/src/permissions/index.js +378 -0
- package/src/persona/index.js +28 -29
- package/src/plugin/builtin.js +313 -215
- package/src/plugin/context.js +80 -79
- package/src/plugin/index.js +62 -62
- package/src/plugin/v3.js +73 -0
- package/src/protocol/index.js +148 -0
- package/src/repomap/index.js +309 -0
- package/src/review/index.js +393 -0
- package/src/seam/registry.js +3 -0
- package/src/seam/shell.js +55 -55
- package/src/security/injection.js +79 -0
- package/src/selfheal/evolve.js +67 -67
- package/src/selfheal/healer.js +184 -167
- package/src/selfheal/run.js +9 -9
- package/src/server.js +63 -60
- package/src/services/diagnose.js +180 -0
- package/src/services/learning-service.js +9 -0
- package/src/services/memory-health.js +34 -6
- package/src/services/memory-service.js +42 -9
- package/src/services/triage.js +138 -0
- package/src/session/parts.js +76 -0
- package/src/session/projection.js +73 -0
- package/src/session/rollout.js +54 -0
- package/src/session/turn.js +137 -0
- package/src/skills/lint.js +72 -0
- package/src/skills/loader.js +231 -150
- package/src/skills/search.js +58 -0
- package/src/skills/verify.js +95 -100
- package/src/tools/advanced.js +388 -352
- package/src/tools/builtin.js +384 -297
- package/src/tools/catalog.js +283 -159
- package/src/tools/command-guard.js +112 -112
- package/src/tools/custom.js +47 -47
- package/src/tools/delegate.js +383 -297
- package/src/tools/document.js +254 -253
- package/src/tools/git.js +151 -0
- package/src/tools/governance.js +47 -20
- package/src/tools/index.js +16 -11
- package/src/tools/methods.js +178 -178
- package/src/tools/ocr.js +59 -59
- package/src/tools/sandbox-worker.js +40 -0
- package/src/tools/sandbox.js +92 -0
- package/src/tools/seam.js +162 -125
- package/src/tools/selfmod.js +196 -176
- package/src/tools/v3.js +225 -0
- package/src/tools/vad.js +176 -0
- package/src/tools/voice.js +238 -0
- package/src/utils/async.js +14 -0
- package/src/utils/config-file.js +53 -0
- package/src/utils/crashguard.js +88 -0
- package/src/utils/http.js +53 -0
- package/src/utils/id.js +8 -0
- package/src/utils/json-state.js +33 -0
- package/src/utils/logger.js +17 -17
- package/src/utils/ndjson.js +25 -0
- package/src/utils/pii.js +42 -42
- package/src/utils/rate-limit.js +50 -0
- package/src/utils/schema.js +80 -0
- package/src/utils/similarity.js +43 -0
- package/src/utils/store.js +170 -108
- package/src/utils/text.js +15 -15
- package/src/utils/trace.js +153 -153
- package/src/utils/wal.js +39 -0
- package/src/utils/winutf8.js +16 -15
- package/src/wiki/index.js +170 -0
|
@@ -15,6 +15,7 @@ export class CircuitBreaker {
|
|
|
15
15
|
this._state = "closed"; // closed | open | half_open
|
|
16
16
|
this._failures = []; // 窗口内失败时间戳
|
|
17
17
|
this._openedAt = 0;
|
|
18
|
+
this._probeAt = 0; // half_open 探测放行时刻 (探测超时兜底用)
|
|
18
19
|
this._calls = 0;
|
|
19
20
|
this._opens = 0;
|
|
20
21
|
this._probeFailures = 0;
|
|
@@ -36,12 +37,19 @@ export class CircuitBreaker {
|
|
|
36
37
|
if (this._state === "open") {
|
|
37
38
|
if (now - this._openedAt >= this.cooldownMs) {
|
|
38
39
|
this._state = "half_open";
|
|
40
|
+
this._probeAt = now;
|
|
39
41
|
return { allowed: true, probe: true }; // 探测放行
|
|
40
42
|
}
|
|
41
43
|
if (this.failPolicy === "fail-closed") return { allowed: false, reason: "circuit-open" };
|
|
42
44
|
return { allowed: true, degraded: true }; // fail-open: 放行但标记降级
|
|
43
45
|
}
|
|
44
|
-
// half_open: 只放一个探测,
|
|
46
|
+
// half_open: 只放一个探测, 其余短路。
|
|
47
|
+
// 2026-09-18 修复 (P2): 探测方若从不回报 after() (调用方遗漏/崩溃), 原实现永久卡死在
|
|
48
|
+
// half_open 全拒绝。兜底: 探测放行超过一个冷却期仍无回报, 允许重新探测。
|
|
49
|
+
if (now - this._probeAt >= this.cooldownMs) {
|
|
50
|
+
this._probeAt = now;
|
|
51
|
+
return { allowed: true, probe: true, reprobe: true };
|
|
52
|
+
}
|
|
45
53
|
return { allowed: false, reason: "circuit-half-open" };
|
|
46
54
|
}
|
|
47
55
|
|
package/src/bus/runtime-bus.js
CHANGED
|
@@ -1,94 +1,108 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// -
|
|
5
|
-
// -
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
this.
|
|
11
|
-
this.
|
|
12
|
-
this.
|
|
13
|
-
this.
|
|
14
|
-
this.
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
this.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
1
|
+
import { debug } from "../utils/logger.js";
|
|
2
|
+
// src/bus/runtime-bus.js - ②循环系: 全局 Runtime 总线 (RC1 §2 动态行为层地基)
|
|
3
|
+
// 在 session.js 会话事件日志之上新增一层"全局总线", 打通模块间函数直连的接缝:
|
|
4
|
+
// - Event: 事实广播 (发生了什么), 只读, 谁都能订阅
|
|
5
|
+
// - Command: 意图 (要执行什么), 带 id + 处理器, 返回 Result
|
|
6
|
+
// - State: 全局运行时状态槽 (可读写, 供①/⑦读倾向, 供观测)
|
|
7
|
+
// 零依赖, 纯 Node 原生。设计对齐 RC1 §5.2 总线消息类型。
|
|
8
|
+
export class RuntimeBus {
|
|
9
|
+
constructor({ name = "runtime", historyLimit = 200 } = {}) {
|
|
10
|
+
this.name = name;
|
|
11
|
+
this._events = new Map(); // type -> Set<handler>
|
|
12
|
+
this._handlers = new Map(); // verb -> handler
|
|
13
|
+
this._state = new Map(); // 状态槽
|
|
14
|
+
this._history = []; // 最近事件 (可观测/审计)
|
|
15
|
+
this._historyLimit = historyLimit;
|
|
16
|
+
this._seq = 0; // 全局序列
|
|
17
|
+
this._interceptors = []; // 命令前置拦截器 (⑧免疫可挂闸门)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------- Event (事实广播, 只读) ----------
|
|
21
|
+
emit(type, payload = {}, meta = {}) {
|
|
22
|
+
this._seq++;
|
|
23
|
+
const ev = { seq: this._seq, ts: Date.now(), type, payload, meta };
|
|
24
|
+
this._history.push(ev);
|
|
25
|
+
if (this._history.length > this._historyLimit) this._history.shift();
|
|
26
|
+
const set = this._events.get(type);
|
|
27
|
+
if (set) for (const fn of set) { try { fn(ev); } catch (e) { debug(`[bus/runtime-bus] 已忽略异常: ${e && e.message ? e.message : e}`); } }
|
|
28
|
+
// 2026-09-18 修复 (P1): on("*") 通配订阅此前从不分发 (emit 只查精确 type),
|
|
29
|
+
// trace() 与一切 "*" 观察者永久空转。现补齐通配分发 (精确 set 之后)。
|
|
30
|
+
if (type !== "*") {
|
|
31
|
+
const wild = this._events.get("*");
|
|
32
|
+
if (wild) for (const fn of wild) { try { fn(ev); } catch (e) { debug(`[bus/runtime-bus] 已忽略异常: ${e && e.message ? e.message : e}`); } }
|
|
33
|
+
}
|
|
34
|
+
return ev;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
on(type, handler) {
|
|
38
|
+
if (!this._events.has(type)) this._events.set(type, new Set());
|
|
39
|
+
this._events.get(type).add(handler);
|
|
40
|
+
return () => this.off(type, handler);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
off(type, handler) {
|
|
44
|
+
const set = this._events.get(type);
|
|
45
|
+
if (set) set.delete(handler);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------- Command (意图 + 处理器, 返回 Result) ----------
|
|
49
|
+
command(verb, payload = {}, { timeoutMs = 0 } = {}) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const handler = this._handlers.get(verb);
|
|
52
|
+
if (!handler) return resolve({ ok: false, error: `no-command-handler:${verb}`, payload });
|
|
53
|
+
let done = false;
|
|
54
|
+
let timer = null;
|
|
55
|
+
// finish 时清掉超时定时器: 否则每个 command 都把事件循环挂住 timeoutMs
|
|
56
|
+
const finish = (r) => {
|
|
57
|
+
if (done) return;
|
|
58
|
+
done = true;
|
|
59
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
60
|
+
resolve(r);
|
|
61
|
+
};
|
|
62
|
+
const id = `${verb}:${this._seq++}`;
|
|
63
|
+
const cmd = { id, ts: Date.now(), verb, payload };
|
|
64
|
+
// 拦截器链 (⑧免疫: 可在执行前做权限/风险校验, 拒绝则短路)
|
|
65
|
+
const run = async (i) => {
|
|
66
|
+
if (i >= this._interceptors.length) {
|
|
67
|
+
try { finish(await handler(cmd)); }
|
|
68
|
+
catch (e) { finish({ ok: false, error: String(e?.message || e), cmdId: id }); }
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await this._interceptors[i](cmd, () => run(i + 1));
|
|
73
|
+
} catch (e) {
|
|
74
|
+
finish({ ok: false, error: String(e?.message || e), cmdId: id, blocked: true });
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
run(0);
|
|
78
|
+
if (timeoutMs > 0) timer = setTimeout(() => finish({ ok: false, error: "command-timeout", cmdId: id }), timeoutMs);
|
|
79
|
+
this.emit("command", { id, verb, payload });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
register(verb, handler) { this._handlers.set(verb, handler); return this; }
|
|
84
|
+
|
|
85
|
+
// 命令前置拦截器 (⑧免疫闸门挂载点)
|
|
86
|
+
intercept(fn) { this._interceptors.push(fn); return () => { const i = this._interceptors.indexOf(fn); if (i >= 0) this._interceptors.splice(i, 1); }; }
|
|
87
|
+
|
|
88
|
+
// ---------- State (运行时状态槽) ----------
|
|
89
|
+
get(key, fallback) {
|
|
90
|
+
if (!this._state.has(key)) return fallback;
|
|
91
|
+
return this._state.get(key);
|
|
92
|
+
}
|
|
93
|
+
set(key, value) { this._state.set(key, value); this._state._rev = (this._state._rev || 0) + 1; this.emit("state", { key, value }); return value; }
|
|
94
|
+
has(key) { return this._state.has(key); }
|
|
95
|
+
|
|
96
|
+
// ---------- 观测/审计 ----------
|
|
97
|
+
// 最近事件 (含 command), 供 trace/调试/审计拉取
|
|
98
|
+
recent(n = 50) { return this._history.slice(-n); }
|
|
99
|
+
// 挂总线之旅: 返回可断开回放的订阅器 (调试用)
|
|
100
|
+
trace() {
|
|
101
|
+
const out = [];
|
|
102
|
+
const off = this.on("*", (ev) => out.push(ev));
|
|
103
|
+
return { events: out, stop: off };
|
|
104
|
+
}
|
|
105
|
+
clearHistory() { this._history = []; }
|
|
106
|
+
}
|
|
107
|
+
|
|
94
108
|
export default RuntimeBus;
|
package/src/channels/base.js
CHANGED
|
@@ -1,35 +1,58 @@
|
|
|
1
|
-
// src/channels/base.js - 通道基类
|
|
2
|
-
// 统一通道接口: 每个通道实现 connect/send/disconnect
|
|
3
|
-
// 可选能力: test() 连通性探测 (配置是否可用), mount(server) 把 webhook 路由挂到 HTTP server
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
this.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
async
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return { ok:
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
1
|
+
// src/channels/base.js - 通道基类
|
|
2
|
+
// 统一通道接口: 每个通道实现 connect/send/disconnect
|
|
3
|
+
// 可选能力: test() 连通性探测 (配置是否可用), mount(server) 把 webhook 路由挂到 HTTP server
|
|
4
|
+
import { readBody, sendJson } from "../utils/http.js";
|
|
5
|
+
|
|
6
|
+
export class Channel {
|
|
7
|
+
constructor(name, agent) {
|
|
8
|
+
this.name = name;
|
|
9
|
+
this.agent = agent;
|
|
10
|
+
this.connected = false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async connect() { throw new Error(`${this.name}: connect() 未实现`); }
|
|
14
|
+
async send(to, text) { throw new Error(`${this.name}: send() 未实现`); }
|
|
15
|
+
async disconnect() { this.connected = false; }
|
|
16
|
+
|
|
17
|
+
// 连通性测试: 默认尝试 connect (验证配置能建立连接), 返回 { ok, detail }
|
|
18
|
+
// 需要网络探测的通道可覆盖为更细的校验 (如验证 token)
|
|
19
|
+
async test() {
|
|
20
|
+
try {
|
|
21
|
+
await this.connect();
|
|
22
|
+
return { ok: true, detail: `${this.name} 配置有效` };
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return { ok: false, detail: e.message };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 把 webhook 路由挂到 HTTP server (若通道是 webhook 型); 默认不挂载
|
|
29
|
+
mount(/* httpServer */) { return null; }
|
|
30
|
+
|
|
31
|
+
// ---- webhook 型通道的公共辅助 (2026-09-18 重构: feishu/wechat 原各抄一份) ----
|
|
32
|
+
|
|
33
|
+
// 路由注册器: 优先注册到主 HTTP 通道的统一路由表 (单一 request handler 分发, 无多 listener 竞态);
|
|
34
|
+
// 未提供主通道时退化为挂到 server 的 request 事件 (仅路径匹配, 调用方自行防双响应)
|
|
35
|
+
_registrar(server, httpChannel) {
|
|
36
|
+
if (httpChannel && typeof httpChannel.registerWebhook === "function") {
|
|
37
|
+
return (path, fn) => httpChannel.registerWebhook(path, fn);
|
|
38
|
+
}
|
|
39
|
+
return (path, fn) => { if (server && typeof server.on === "function") server.on("request", fn); };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 读满请求体 (webhook 回调用, 无大小限制 —— 各通道自行校验内容)
|
|
43
|
+
_readBody(req) {
|
|
44
|
+
return readBody(req);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 统一的 JSON 响应
|
|
48
|
+
_sendJson(res, code, obj) {
|
|
49
|
+
sendJson(res, code, obj);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 收到消息 → 调 agent 处理 → 回发
|
|
53
|
+
async handleMessage(from, text) {
|
|
54
|
+
const reply = await this.agent.chat(text);
|
|
55
|
+
await this.send(from, reply);
|
|
56
|
+
return reply;
|
|
57
|
+
}
|
|
35
58
|
}
|
package/src/channels/feishu.js
CHANGED
|
@@ -1,127 +1,119 @@
|
|
|
1
|
-
// src/channels/feishu.js - 飞书通道适配器 (零依赖, 原生 fetch)
|
|
2
|
-
// 需要配置: app_id, app_secret, verify_token (config/channels.yaml)
|
|
3
|
-
// 用法: 飞书开放平台配置事件订阅 URL 指向本服务的 /feishu/webhook
|
|
4
|
-
import { Channel } from "./base.js";
|
|
5
|
-
|
|
6
|
-
export class FeishuChannel extends Channel {
|
|
7
|
-
constructor(agent, { appId, appSecret, verifyToken, webhookPath = "/feishu/webhook" } = {}) {
|
|
8
|
-
super("feishu", agent);
|
|
9
|
-
this.appId = appId || process.env.FEISHU_APP_ID || "";
|
|
10
|
-
this.appSecret = appSecret || process.env.FEISHU_APP_SECRET || "";
|
|
11
|
-
this.verifyToken = verifyToken || process.env.FEISHU_VERIFY_TOKEN || "";
|
|
12
|
-
this.webhookPath = webhookPath;
|
|
13
|
-
this.tenantToken = null;
|
|
14
|
-
this.tokenExpire = 0;
|
|
15
|
-
this.lastUser = null; // 最近收到消息的 open_id, 供主动提醒(广播)回发
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// 主动提醒广播 (to="*") 时回最近联系人; 无记录则报缺接收人
|
|
19
|
-
_resolveTarget(to) {
|
|
20
|
-
const target = !to || to === "*" ? this.lastUser : to;
|
|
21
|
-
return target || null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async _getTenantToken() {
|
|
25
|
-
if (this.tenantToken && Date.now() < this.tokenExpire) return this.tenantToken;
|
|
26
|
-
const r = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
27
|
-
method: "POST",
|
|
28
|
-
headers: { "Content-Type": "application/json" },
|
|
29
|
-
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
|
|
30
|
-
signal: AbortSignal.timeout(15000), // v1.0.8: 网络挂起不永久阻塞
|
|
31
|
-
});
|
|
32
|
-
const data = await r.json();
|
|
33
|
-
if (data.code !== 0) throw new Error(`飞书 token 失败: ${data.msg}`);
|
|
34
|
-
this.tenantToken = data.tenant_access_token;
|
|
35
|
-
this.tokenExpire = Date.now() + (data.expire - 60) * 1000;
|
|
36
|
-
return this.tenantToken;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async connect() {
|
|
40
|
-
if (!this.appId || !this.appSecret) {
|
|
41
|
-
throw new Error("飞书通道未配置: 需要 app_id + app_secret (config/channels.yaml 或环境变量)");
|
|
42
|
-
}
|
|
43
|
-
await this._getTenantToken(); // 验证凭证
|
|
44
|
-
this.connected = true;
|
|
45
|
-
return this;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// 连通性测试: 用已配置凭证换取 tenant token (真实网络验证)
|
|
49
|
-
async test() {
|
|
50
|
-
if (!this.appId || !this.appSecret) {
|
|
51
|
-
return { ok: false, detail: "未配置 app_id + app_secret" };
|
|
52
|
-
}
|
|
53
|
-
try {
|
|
54
|
-
const token = await this._getTenantToken();
|
|
55
|
-
return { ok: true, detail: `飞书凭证有效 (tenant_token 获取成功, ${String(token).slice(0, 6)}...)` };
|
|
56
|
-
} catch (e) {
|
|
57
|
-
return { ok: false, detail: e.message };
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// 把 /feishu/webhook 挂到 HTTP server (webhook 型通道)
|
|
62
|
-
// v1.0.8: 注册到 HttpChannel 的 webhook 路由 (单一 request handler 分发, 无多 listener 竞态);
|
|
63
|
-
// 校验飞书事件订阅头 X-Lark-Request-Token
|
|
64
|
-
mount(server, httpChannel = null) {
|
|
65
|
-
const register =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
120
|
-
body: JSON.stringify({ receive_id: target, msg_type: "text", content: JSON.stringify({ text }) }),
|
|
121
|
-
signal: AbortSignal.timeout(15000), // v1.0.8: 网络挂起不永久阻塞
|
|
122
|
-
});
|
|
123
|
-
const data = await r.json();
|
|
124
|
-
if (data.code !== 0) throw new Error(`飞书发送失败: ${data.msg}`);
|
|
125
|
-
return data;
|
|
126
|
-
}
|
|
1
|
+
// src/channels/feishu.js - 飞书通道适配器 (零依赖, 原生 fetch)
|
|
2
|
+
// 需要配置: app_id, app_secret, verify_token (config/channels.yaml)
|
|
3
|
+
// 用法: 飞书开放平台配置事件订阅 URL 指向本服务的 /feishu/webhook
|
|
4
|
+
import { Channel } from "./base.js";
|
|
5
|
+
|
|
6
|
+
export class FeishuChannel extends Channel {
|
|
7
|
+
constructor(agent, { appId, appSecret, verifyToken, webhookPath = "/feishu/webhook" } = {}) {
|
|
8
|
+
super("feishu", agent);
|
|
9
|
+
this.appId = appId || process.env.FEISHU_APP_ID || "";
|
|
10
|
+
this.appSecret = appSecret || process.env.FEISHU_APP_SECRET || "";
|
|
11
|
+
this.verifyToken = verifyToken || process.env.FEISHU_VERIFY_TOKEN || "";
|
|
12
|
+
this.webhookPath = webhookPath;
|
|
13
|
+
this.tenantToken = null;
|
|
14
|
+
this.tokenExpire = 0;
|
|
15
|
+
this.lastUser = null; // 最近收到消息的 open_id, 供主动提醒(广播)回发
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 主动提醒广播 (to="*") 时回最近联系人; 无记录则报缺接收人
|
|
19
|
+
_resolveTarget(to) {
|
|
20
|
+
const target = !to || to === "*" ? this.lastUser : to;
|
|
21
|
+
return target || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async _getTenantToken() {
|
|
25
|
+
if (this.tenantToken && Date.now() < this.tokenExpire) return this.tenantToken;
|
|
26
|
+
const r = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret }),
|
|
30
|
+
signal: AbortSignal.timeout(15000), // v1.0.8: 网络挂起不永久阻塞
|
|
31
|
+
});
|
|
32
|
+
const data = await r.json();
|
|
33
|
+
if (data.code !== 0) throw new Error(`飞书 token 失败: ${data.msg}`);
|
|
34
|
+
this.tenantToken = data.tenant_access_token;
|
|
35
|
+
this.tokenExpire = Date.now() + (data.expire - 60) * 1000;
|
|
36
|
+
return this.tenantToken;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async connect() {
|
|
40
|
+
if (!this.appId || !this.appSecret) {
|
|
41
|
+
throw new Error("飞书通道未配置: 需要 app_id + app_secret (config/channels.yaml 或环境变量)");
|
|
42
|
+
}
|
|
43
|
+
await this._getTenantToken(); // 验证凭证
|
|
44
|
+
this.connected = true;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 连通性测试: 用已配置凭证换取 tenant token (真实网络验证)
|
|
49
|
+
async test() {
|
|
50
|
+
if (!this.appId || !this.appSecret) {
|
|
51
|
+
return { ok: false, detail: "未配置 app_id + app_secret" };
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const token = await this._getTenantToken();
|
|
55
|
+
return { ok: true, detail: `飞书凭证有效 (tenant_token 获取成功, ${String(token).slice(0, 6)}...)` };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return { ok: false, detail: e.message };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 把 /feishu/webhook 挂到 HTTP server (webhook 型通道)
|
|
62
|
+
// v1.0.8: 注册到 HttpChannel 的 webhook 路由 (单一 request handler 分发, 无多 listener 竞态);
|
|
63
|
+
// 校验飞书事件订阅头 X-Lark-Request-Token
|
|
64
|
+
mount(server, httpChannel = null) {
|
|
65
|
+
const register = this._registrar(server, httpChannel);
|
|
66
|
+
register(this.webhookPath, async (req, res) => {
|
|
67
|
+
const body = await this._readBody(req);
|
|
68
|
+
// 飞书事件订阅用请求头 X-Lark-Request-Token 携带 verify_token (body 内 token 极少存在, 仅作纵深)
|
|
69
|
+
if (this.verifyToken && req.headers["x-lark-request-token"] !== this.verifyToken) {
|
|
70
|
+
return this._sendJson(res, 403, { error: "invalid token" });
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
return this._sendJson(res, 200, await this.handleWebhook(body));
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return this._sendJson(res, 500, { error: e.message });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 处理飞书 webhook 事件 (事件订阅回调)
|
|
81
|
+
async handleWebhook(body) {
|
|
82
|
+
// 校验 verify_token (纵深防御; 主校验在 mount 的 X-Lark-Request-Token 头)
|
|
83
|
+
const data = typeof body === "string" ? JSON.parse(body) : body;
|
|
84
|
+
if (data.token && data.token !== this.verifyToken) {
|
|
85
|
+
return { code: 1, msg: "invalid token" };
|
|
86
|
+
}
|
|
87
|
+
// URL 验证 (飞书首次配置时)
|
|
88
|
+
if (data.type === "url_verification") {
|
|
89
|
+
return { challenge: data.challenge };
|
|
90
|
+
}
|
|
91
|
+
// 事件: 消息
|
|
92
|
+
const event = data.event;
|
|
93
|
+
if (event && event.type === "im.message.receive_v1" && event.message) {
|
|
94
|
+
const text = event.message.content ? JSON.parse(event.message.content).text || "" : "";
|
|
95
|
+
const openId = event.sender?.sender_id?.open_id || "";
|
|
96
|
+
if (openId) this.lastUser = openId; // 记录最近联系人, 供主动提醒回发
|
|
97
|
+
if (text) {
|
|
98
|
+
const reply = await this.agent.chat(text);
|
|
99
|
+
await this.send(openId || "*", reply);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { code: 0 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async send(to, text) {
|
|
106
|
+
const target = this._resolveTarget(to);
|
|
107
|
+
if (!target) throw new Error("飞书主动推送缺接收人: 先收到过一条用户消息记录 open_id, 或用 send(openId, ...)");
|
|
108
|
+
const token = await this._getTenantToken();
|
|
109
|
+
const r = await fetch("https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id", {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
|
112
|
+
body: JSON.stringify({ receive_id: target, msg_type: "text", content: JSON.stringify({ text }) }),
|
|
113
|
+
signal: AbortSignal.timeout(15000), // v1.0.8: 网络挂起不永久阻塞
|
|
114
|
+
});
|
|
115
|
+
const data = await r.json();
|
|
116
|
+
if (data.code !== 0) throw new Error(`飞书发送失败: ${data.msg}`);
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
127
119
|
}
|