dingtalk-dws-mcp 1.0.2 → 1.1.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/README.md +19 -7
- package/dist/src/notify/bind.d.ts +17 -2
- package/dist/src/notify/bind.js +55 -13
- package/dist/src/{patrol → patrol-setup}/setup.d.ts +23 -2
- package/dist/src/patrol-setup/setup.js +386 -0
- package/dist/src/prompts.js +3 -5
- package/dist/src/service.d.ts +1 -1
- package/dist/src/service.js +58 -31
- package/dist/src/tools/registry.js +104 -6
- package/package.json +9 -5
- package/dist/src/patrol/setup.js +0 -213
package/README.md
CHANGED
|
@@ -4,18 +4,30 @@
|
|
|
4
4
|
|
|
5
5
|
Agent:`instructions` / tool `description` / `prompts` / **Resources(`guides/*.md`)**。长期巡检:`dingtalk_patrol_setup` 备料 → 传装到主机 → 停。
|
|
6
6
|
|
|
7
|
+
本目录为唯一工程根:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
packages/mcp/dingtalk-dws-mcp/
|
|
11
|
+
src/ # MCP server(含 patrol-setup;build 时从 lib 同步 notify-delivery)
|
|
12
|
+
lib/notify-delivery/ # L0 投递(仓内唯一源;不单独发 npm)
|
|
13
|
+
host-patrol/ # 主机巡检 CLI 源码(pack:deploy 用;不进 MCP npm tarball)
|
|
14
|
+
guides/ # MCP Resources
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
部署产物仍使用 `deploy/linux-x64/runtime/patrol/`(与 `install.sh` 路径约定一致);勿与源码目录 `host-patrol/`、`src/patrol-setup/` 混淆。
|
|
18
|
+
|
|
7
19
|
## 构建
|
|
8
20
|
|
|
9
21
|
```bash
|
|
10
|
-
cd packages/
|
|
11
|
-
|
|
12
|
-
npm run pack:deploy
|
|
22
|
+
cd packages/mcp/dingtalk-dws-mcp
|
|
23
|
+
npm install && npm run build
|
|
24
|
+
npm run pack:deploy # 可选:生成 deploy/linux-x64 巡检包(仅 monorepo)
|
|
13
25
|
```
|
|
14
26
|
|
|
15
|
-
`
|
|
16
|
-
|
|
17
|
-
`
|
|
27
|
+
`npm publish` 的 `files` 仅含 `dist/src` + `guides` + README/LICENSE;**不会**把 `host-patrol/`、`lib/`、`deploy/` 打进 registry。
|
|
28
|
+
|
|
29
|
+
`pack:deploy` **仅 monorepo**(需要 `host-patrol/`、`lib/`、`scripts/`)。npx/registry 没有这些目录;`patrol_bundle.available=false` 属预期。默认不打便携 Node;空气间隙设 `DINGTALK_PATROL_INCLUDE_NODE=1`。可选 `DINGTALK_PATROL_NODE_URL`、`DINGTALK_PATROL_INCLUDE_DWS=0`。Windows 可从 `dingtalk-workspace-cli` 抽出 linux ELF `dws`(或设 `DINGTALK_PATROL_DWS_PATH`)。传装跟 `copy_plan`;可选 `deploy.sh` 为直连 SSH helper。
|
|
18
30
|
|
|
19
|
-
配置示例(npx):[`mcp/dingtalk-dws-mcp/mcp.example.json`](../../../mcp/dingtalk-dws-mcp/mcp.example.json)。npm:`dingtalk-dws-mcp@1.0
|
|
31
|
+
配置示例(npx):[`mcp/dingtalk-dws-mcp/mcp.example.json`](../../../mcp/dingtalk-dws-mcp/mcp.example.json)。npm:`dingtalk-dws-mcp@1.1.0`。
|
|
20
32
|
|
|
21
33
|
规格(开发者):[RFC-0007](../../../docs/rfc/RFC-0007-dingtalk-dws-mcp.md)、[RFC-0008](../../../docs/rfc/RFC-0008-dingtalk-notify-patrol.md)。
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import type { AppConfig, RouteName, RouteType, TargetCandidate } from "../types.js";
|
|
2
|
+
/** Persisted bind record. Both fields required. */
|
|
3
|
+
export type BoundTarget = {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
};
|
|
7
|
+
/** Unified route view for doctor / list_routes / send. Bound iff name != null. */
|
|
8
|
+
export type RouteBindingView = {
|
|
9
|
+
name: string | null;
|
|
10
|
+
id: string | null;
|
|
11
|
+
};
|
|
2
12
|
export declare function candidateRef(candidate: TargetCandidate): string;
|
|
3
|
-
|
|
4
|
-
export declare function
|
|
13
|
+
/** Read valid bindings only. Bare strings / partial objects are invalid → omitted (unbound). No migration. */
|
|
14
|
+
export declare function readBoundTargets(config: AppConfig): Partial<Record<RouteName, BoundTarget>>;
|
|
15
|
+
export declare function readBoundTarget(config: AppConfig, route: RouteName): BoundTarget | null;
|
|
16
|
+
export declare function writeTarget(config: AppConfig, route: RouteName, target: BoundTarget): void;
|
|
5
17
|
export declare function resolveRouteTarget(config: AppConfig, route: RouteName): string;
|
|
18
|
+
export declare function describeRouteBinding(config: AppConfig, route: RouteName): RouteBindingView;
|
|
19
|
+
export declare function describeRoutesMap(config: AppConfig, routes: readonly RouteName[]): Record<string, RouteBindingView>;
|
|
6
20
|
export declare function routeType(route: RouteName): RouteType;
|
|
21
|
+
/** Internal: bound iff display name is present (same rule as API surfaces). */
|
|
7
22
|
export declare function isRouteConfigured(config: AppConfig, route: RouteName): boolean;
|
package/dist/src/notify/bind.js
CHANGED
|
@@ -5,21 +5,47 @@ import { DingtalkDwsError } from "../errors.js";
|
|
|
5
5
|
export function candidateRef(candidate) {
|
|
6
6
|
return `candidate_${createHash("sha256").update(`${candidate.type}\n${candidate.target_id}`).digest("hex")}`;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
function parseBoundTarget(raw) {
|
|
9
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
10
|
+
return null;
|
|
11
|
+
const record = raw;
|
|
12
|
+
const id = typeof record.id === "string" ? record.id.trim() : "";
|
|
13
|
+
const name = typeof record.name === "string" ? record.name.trim() : "";
|
|
14
|
+
if (!id || !name)
|
|
15
|
+
return null;
|
|
16
|
+
return { id, name };
|
|
17
|
+
}
|
|
18
|
+
/** Read valid bindings only. Bare strings / partial objects are invalid → omitted (unbound). No migration. */
|
|
19
|
+
export function readBoundTargets(config) {
|
|
9
20
|
try {
|
|
10
21
|
const parsed = JSON.parse(fs.readFileSync(config.targetsPath, "utf8"));
|
|
11
|
-
if (parsed
|
|
12
|
-
return
|
|
22
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
23
|
+
return {};
|
|
24
|
+
const out = {};
|
|
25
|
+
for (const route of ["notify_group", "notify_user"]) {
|
|
26
|
+
const bound = parseBoundTarget(parsed[route]);
|
|
27
|
+
if (bound)
|
|
28
|
+
out[route] = bound;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
13
31
|
}
|
|
14
32
|
catch {
|
|
15
|
-
|
|
33
|
+
return {};
|
|
16
34
|
}
|
|
17
|
-
return {};
|
|
18
35
|
}
|
|
19
|
-
export function
|
|
36
|
+
export function readBoundTarget(config, route) {
|
|
37
|
+
return readBoundTargets(config)[route] ?? null;
|
|
38
|
+
}
|
|
39
|
+
export function writeTarget(config, route, target) {
|
|
40
|
+
const id = target.id.trim();
|
|
41
|
+
const name = target.name.trim();
|
|
42
|
+
if (!id || !name) {
|
|
43
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "bind requires non-empty id and name");
|
|
44
|
+
}
|
|
20
45
|
fs.mkdirSync(path.dirname(config.targetsPath), { recursive: true, mode: 0o700 });
|
|
21
|
-
|
|
22
|
-
targets
|
|
46
|
+
// Rewrite from valid bindings only — invalid on-disk slots are dropped, not upgraded.
|
|
47
|
+
const targets = readBoundTargets(config);
|
|
48
|
+
targets[route] = { id, name };
|
|
23
49
|
fs.writeFileSync(config.targetsPath, `${JSON.stringify(targets, null, 2)}\n`, { mode: 0o600 });
|
|
24
50
|
}
|
|
25
51
|
export function resolveRouteTarget(config, route) {
|
|
@@ -29,16 +55,32 @@ export function resolveRouteTarget(config, route) {
|
|
|
29
55
|
}
|
|
30
56
|
return "webhook";
|
|
31
57
|
}
|
|
32
|
-
const target =
|
|
58
|
+
const target = readBoundTarget(config, route);
|
|
33
59
|
if (!target)
|
|
34
60
|
throw new DingtalkDwsError("INVALID_ARGUMENT", `Route ${route} has no bound target`);
|
|
35
|
-
return target;
|
|
61
|
+
return target.id;
|
|
62
|
+
}
|
|
63
|
+
export function describeRouteBinding(config, route) {
|
|
64
|
+
if (config.adapter === "webhook") {
|
|
65
|
+
if (route !== "notify_group" || !config.webhookUrl)
|
|
66
|
+
return { name: null, id: null };
|
|
67
|
+
return { name: "webhook", id: null };
|
|
68
|
+
}
|
|
69
|
+
const bound = readBoundTarget(config, route);
|
|
70
|
+
if (!bound)
|
|
71
|
+
return { name: null, id: null };
|
|
72
|
+
return { name: bound.name, id: bound.id };
|
|
73
|
+
}
|
|
74
|
+
export function describeRoutesMap(config, routes) {
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const route of routes)
|
|
77
|
+
out[route] = describeRouteBinding(config, route);
|
|
78
|
+
return out;
|
|
36
79
|
}
|
|
37
80
|
export function routeType(route) {
|
|
38
81
|
return route === "notify_group" ? "group" : "user";
|
|
39
82
|
}
|
|
83
|
+
/** Internal: bound iff display name is present (same rule as API surfaces). */
|
|
40
84
|
export function isRouteConfigured(config, route) {
|
|
41
|
-
|
|
42
|
-
return route === "notify_group" && Boolean(config.webhookUrl);
|
|
43
|
-
return Boolean(readTargets(config)[route]);
|
|
85
|
+
return describeRouteBinding(config, route).name != null;
|
|
44
86
|
}
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import type { AppConfig, RouteName } from "../types.js";
|
|
2
|
-
|
|
2
|
+
declare const DELIVER_POLICIES: readonly ["always", "on_status_change", "only_statuses"];
|
|
3
|
+
type DeliverPolicyKind = (typeof DELIVER_POLICIES)[number];
|
|
4
|
+
declare const TERMINATION_POLICIES: readonly ["none", "status_match", "manual"];
|
|
5
|
+
type TerminationPolicyKind = (typeof TERMINATION_POLICIES)[number];
|
|
6
|
+
export type PatrolSetupIntent = "always" | "long_monitor" | "stop_on_critical" | "on_event";
|
|
7
|
+
export type PatrolSetupDeliver = {
|
|
8
|
+
policy?: DeliverPolicyKind;
|
|
9
|
+
statuses?: string[];
|
|
10
|
+
};
|
|
11
|
+
export type PatrolSetupTermination = {
|
|
12
|
+
policy?: TerminationPolicyKind;
|
|
13
|
+
status?: string;
|
|
14
|
+
on?: "first" | "until";
|
|
15
|
+
terminate_on_delivery_failure?: boolean;
|
|
16
|
+
};
|
|
17
|
+
/** Agent-filled args (not mcp.json forms). Delivery credentials come from MCP env -> env.sh. */
|
|
3
18
|
export type PatrolSetupInput = {
|
|
4
19
|
name: string;
|
|
5
20
|
cron?: string;
|
|
6
21
|
probe_command?: string;
|
|
7
22
|
probe_script?: string;
|
|
8
23
|
route?: RouteName;
|
|
24
|
+
/** Recipe alias for Agent. Explicit deliver/termination override the alias. */
|
|
25
|
+
intent?: PatrolSetupIntent;
|
|
26
|
+
watch_path?: string;
|
|
27
|
+
deliver?: PatrolSetupDeliver;
|
|
28
|
+
termination?: PatrolSetupTermination;
|
|
9
29
|
/** Soft mode: allow missing bind/bundle (tests / preview). Always stages only. */
|
|
10
30
|
dry_run?: boolean;
|
|
11
31
|
};
|
|
@@ -21,7 +41,8 @@ export declare function readBundleInfo(env?: NodeJS.ProcessEnv): {
|
|
|
21
41
|
};
|
|
22
42
|
/**
|
|
23
43
|
* Stage offline patrol materials only. Does not SSH/scp.
|
|
24
|
-
*
|
|
44
|
+
* Prefer copy_plan.artifact (one .tgz). Two-dir copy is fallback when tar packing fails.
|
|
25
45
|
*/
|
|
26
46
|
export declare function runPatrolSetup(config: AppConfig, input: PatrolSetupInput, env?: NodeJS.ProcessEnv): Promise<Record<string, unknown>>;
|
|
27
47
|
export declare function bundleFingerprintForDoctor(env?: NodeJS.ProcessEnv): Record<string, unknown>;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { DEFAULT_PATROL_MESSAGE_BODY, DEFAULT_PATROL_MESSAGE_TITLE, } from "../notify-delivery/index.js";
|
|
6
|
+
import { DingtalkDwsError } from "../errors.js";
|
|
7
|
+
import { isRouteConfigured, readBoundTarget } from "../notify/bind.js";
|
|
8
|
+
import { mcpPackageRoot, readPackageVersion } from "../version.js";
|
|
9
|
+
const OBSERVATION_STATUSES = ["ok", "warning", "critical", "unknown"];
|
|
10
|
+
const DELIVER_POLICIES = ["always", "on_status_change", "only_statuses"];
|
|
11
|
+
const TERMINATION_POLICIES = ["none", "status_match", "manual"];
|
|
12
|
+
const DEFAULT_CRON = "*/15 * * * *";
|
|
13
|
+
const DEFAULT_PROBE = `#!/usr/bin/env bash
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
printf '%s\\n' '{"status":"ok","summary":"【进度】示例巡检正常\\n【监控】请替换为真实检查","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}'
|
|
16
|
+
`;
|
|
17
|
+
export function resolveBundleRoot(env = process.env) {
|
|
18
|
+
const override = env.DINGTALK_PATROL_BUNDLE_ROOT?.trim();
|
|
19
|
+
if (override)
|
|
20
|
+
return path.resolve(override);
|
|
21
|
+
return path.join(mcpPackageRoot(), "deploy", "linux-x64");
|
|
22
|
+
}
|
|
23
|
+
export function readBundleInfo(env = process.env) {
|
|
24
|
+
const bundle_root = resolveBundleRoot(env);
|
|
25
|
+
const manifestPath = path.join(bundle_root, "manifest.json");
|
|
26
|
+
const ready = fs.existsSync(manifestPath) && fs.existsSync(path.join(bundle_root, "runtime", "patrol"));
|
|
27
|
+
if (!ready) {
|
|
28
|
+
return {
|
|
29
|
+
available: false,
|
|
30
|
+
bundle_root,
|
|
31
|
+
fingerprint: null,
|
|
32
|
+
server_version: null,
|
|
33
|
+
includes_node: false,
|
|
34
|
+
includes_dws: false,
|
|
35
|
+
message: `Patrol bundle missing at ${bundle_root}. ` +
|
|
36
|
+
`pack:deploy is monorepo-only (clone agent-compose, cd packages/mcp/dingtalk-dws-mcp, npm run pack:deploy). ` +
|
|
37
|
+
`npx installs do not include host-patrol/ or deploy artifacts.`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
42
|
+
return {
|
|
43
|
+
available: true,
|
|
44
|
+
bundle_root,
|
|
45
|
+
fingerprint: typeof m.fingerprint === "string" ? m.fingerprint : null,
|
|
46
|
+
server_version: typeof m.server_version === "string" ? m.server_version : null,
|
|
47
|
+
includes_node: Boolean(m.includes_node),
|
|
48
|
+
includes_dws: Boolean(m.includes_dws),
|
|
49
|
+
message: "bundle ready",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return {
|
|
54
|
+
available: false,
|
|
55
|
+
bundle_root,
|
|
56
|
+
fingerprint: null,
|
|
57
|
+
server_version: null,
|
|
58
|
+
includes_node: false,
|
|
59
|
+
includes_dws: false,
|
|
60
|
+
message: `Invalid manifest at ${manifestPath}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function shellQuote(value) {
|
|
65
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
66
|
+
}
|
|
67
|
+
function writeLf(filePath, content, mode) {
|
|
68
|
+
const body = content.replace(/\r\n/g, "\n");
|
|
69
|
+
if (mode === undefined)
|
|
70
|
+
fs.writeFileSync(filePath, body, "utf8");
|
|
71
|
+
else
|
|
72
|
+
fs.writeFileSync(filePath, body, { mode });
|
|
73
|
+
}
|
|
74
|
+
/** Sync local notify credentials (already verified by doctor) into target env.sh. Not user OAuth.
|
|
75
|
+
* Never copy laptop DINGTALK_DWS_COMMAND / config.dwsCommand — that path is Agent-host local
|
|
76
|
+
* (Windows .exe or Linux absolute) and is wrong on the patrol target. Target resolves `dws` via
|
|
77
|
+
* install runtime PATH (bundled linux ELF) or system dws. Optional target-only override:
|
|
78
|
+
* DINGTALK_PATROL_HOST_DWS (path that exists on the target machine).
|
|
79
|
+
*/
|
|
80
|
+
function writeEnvFile(filePath, config, env) {
|
|
81
|
+
const lines = ["# Generated by dingtalk_patrol_setup — delivery credentials for target host"];
|
|
82
|
+
const put = (key, value) => {
|
|
83
|
+
if (value)
|
|
84
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
85
|
+
};
|
|
86
|
+
put("DINGTALK_CLIENT_ID", config.clientId ?? env.DINGTALK_CLIENT_ID);
|
|
87
|
+
put("DINGTALK_CLIENT_SECRET", config.clientSecret ?? env.DINGTALK_CLIENT_SECRET);
|
|
88
|
+
put("DINGTALK_ROBOT_CODE", config.robotCode ?? env.DINGTALK_ROBOT_CODE);
|
|
89
|
+
put("DINGTALK_WEBHOOK_URL", config.webhookUrl ?? env.DINGTALK_WEBHOOK_URL);
|
|
90
|
+
put("DINGTALK_WEBHOOK_SECRET", config.webhookSecret ?? env.DINGTALK_WEBHOOK_SECRET);
|
|
91
|
+
const hostDws = env.DINGTALK_PATROL_HOST_DWS?.trim();
|
|
92
|
+
if (hostDws)
|
|
93
|
+
put("DINGTALK_DWS_COMMAND", hostDws);
|
|
94
|
+
writeLf(filePath, `${lines.join("\n")}\n`, 0o600);
|
|
95
|
+
}
|
|
96
|
+
function pickRoute(config, preferred) {
|
|
97
|
+
if (config.adapter === "webhook")
|
|
98
|
+
return "notify_group";
|
|
99
|
+
if (preferred) {
|
|
100
|
+
if (!isRouteConfigured(config, preferred)) {
|
|
101
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", `Route ${preferred} is not bound`);
|
|
102
|
+
}
|
|
103
|
+
return preferred;
|
|
104
|
+
}
|
|
105
|
+
if (isRouteConfigured(config, "notify_group"))
|
|
106
|
+
return "notify_group";
|
|
107
|
+
if (isRouteConfigured(config, "notify_user"))
|
|
108
|
+
return "notify_user";
|
|
109
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "Bind a notify target first (or use webhook)");
|
|
110
|
+
}
|
|
111
|
+
const INTENT_PRESETS = {
|
|
112
|
+
always: { deliver: { policy: "always" }, termination: { policy: "none" } },
|
|
113
|
+
long_monitor: {
|
|
114
|
+
deliver: { policy: "on_status_change", statuses: ["warning", "critical"] },
|
|
115
|
+
termination: { policy: "none" },
|
|
116
|
+
},
|
|
117
|
+
stop_on_critical: {
|
|
118
|
+
deliver: { policy: "always" },
|
|
119
|
+
termination: { policy: "status_match", status: "critical", on: "first" },
|
|
120
|
+
},
|
|
121
|
+
on_event: { deliver: { policy: "always" }, termination: { policy: "none" } },
|
|
122
|
+
};
|
|
123
|
+
function policiesFromIntent(intent) {
|
|
124
|
+
return INTENT_PRESETS[intent];
|
|
125
|
+
}
|
|
126
|
+
function resolvePolicies(input) {
|
|
127
|
+
const intent = input.intent ?? "always";
|
|
128
|
+
const preset = policiesFromIntent(intent);
|
|
129
|
+
return {
|
|
130
|
+
intent,
|
|
131
|
+
deliver: normalizeDeliver(input.deliver ?? preset.deliver),
|
|
132
|
+
termination: normalizeTermination(input.termination ?? preset.termination),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function writeJobArchive(options) {
|
|
136
|
+
const installSrc = path.join(options.bundleRoot, "install.sh");
|
|
137
|
+
if (!fs.existsSync(installSrc))
|
|
138
|
+
return null;
|
|
139
|
+
const packDir = fs.mkdtempSync(path.join(os.tmpdir(), "patrol-job-"));
|
|
140
|
+
try {
|
|
141
|
+
writeLf(path.join(packDir, "install.sh"), fs.readFileSync(installSrc, "utf8"), 0o755);
|
|
142
|
+
const manifest = path.join(options.bundleRoot, "manifest.json");
|
|
143
|
+
if (fs.existsSync(manifest))
|
|
144
|
+
fs.copyFileSync(manifest, path.join(packDir, "manifest.json"));
|
|
145
|
+
const runtime = path.join(options.bundleRoot, "runtime");
|
|
146
|
+
const includes_runtime = fs.existsSync(runtime);
|
|
147
|
+
if (includes_runtime)
|
|
148
|
+
fs.cpSync(runtime, path.join(packDir, "runtime"), { recursive: true });
|
|
149
|
+
fs.cpSync(options.stagingRoot, path.join(packDir, "staging"), { recursive: true });
|
|
150
|
+
fs.mkdirSync(path.dirname(options.outFile), { recursive: true });
|
|
151
|
+
if (fs.existsSync(options.outFile))
|
|
152
|
+
fs.unlinkSync(options.outFile);
|
|
153
|
+
const entries = ["install.sh", "staging"];
|
|
154
|
+
if (fs.existsSync(path.join(packDir, "manifest.json")))
|
|
155
|
+
entries.push("manifest.json");
|
|
156
|
+
if (includes_runtime)
|
|
157
|
+
entries.push("runtime");
|
|
158
|
+
const dest = options.outFile.replace(/\\/g, "/");
|
|
159
|
+
const cwd = packDir.replace(/\\/g, "/");
|
|
160
|
+
execFileSync("tar", ["-czf", dest, "-C", cwd, ...entries], { windowsHide: true });
|
|
161
|
+
return { bytes: fs.statSync(options.outFile).size, includes_runtime };
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
fs.rmSync(packDir, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function isObservationStatus(value) {
|
|
171
|
+
return OBSERVATION_STATUSES.includes(value);
|
|
172
|
+
}
|
|
173
|
+
function normalizeDeliver(input) {
|
|
174
|
+
const policy = input?.policy ?? "always";
|
|
175
|
+
if (!DELIVER_POLICIES.includes(policy)) {
|
|
176
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.policy must be always | on_status_change | only_statuses");
|
|
177
|
+
}
|
|
178
|
+
const statuses = (input?.statuses ?? []).filter((s) => typeof s === "string" && isObservationStatus(s));
|
|
179
|
+
if (input?.statuses && input.statuses.length !== statuses.length) {
|
|
180
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.statuses must be ok|warning|critical|unknown");
|
|
181
|
+
}
|
|
182
|
+
if (policy === "only_statuses" && statuses.length === 0) {
|
|
183
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.statuses is required when deliver.policy is only_statuses");
|
|
184
|
+
}
|
|
185
|
+
return statuses.length ? { policy, statuses } : { policy };
|
|
186
|
+
}
|
|
187
|
+
function normalizeTermination(input) {
|
|
188
|
+
const policy = input?.policy ?? "none";
|
|
189
|
+
if (!TERMINATION_POLICIES.includes(policy)) {
|
|
190
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.policy must be none | status_match | manual");
|
|
191
|
+
}
|
|
192
|
+
if (policy !== "status_match") {
|
|
193
|
+
return { policy };
|
|
194
|
+
}
|
|
195
|
+
const status = input?.status;
|
|
196
|
+
if (!status || !isObservationStatus(status)) {
|
|
197
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.status must be ok|warning|critical|unknown");
|
|
198
|
+
}
|
|
199
|
+
const on = input?.on === "until" ? "until" : "first";
|
|
200
|
+
return {
|
|
201
|
+
policy,
|
|
202
|
+
status,
|
|
203
|
+
on,
|
|
204
|
+
terminate_on_delivery_failure: input?.terminate_on_delivery_failure === true,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function yamlBlockDeliver(deliver) {
|
|
208
|
+
const lines = ["deliver:", ` policy: ${deliver.policy}`];
|
|
209
|
+
if (deliver.statuses?.length) {
|
|
210
|
+
lines.push(` statuses: [${deliver.statuses.map((s) => JSON.stringify(s)).join(", ")}]`);
|
|
211
|
+
}
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
}
|
|
214
|
+
function yamlBlockTermination(termination) {
|
|
215
|
+
const lines = ["termination:", ` policy: ${termination.policy}`];
|
|
216
|
+
if (termination.policy === "status_match" && termination.status) {
|
|
217
|
+
lines.push(` status: ${termination.status}`);
|
|
218
|
+
lines.push(` on: ${termination.on ?? "first"}`);
|
|
219
|
+
if (termination.terminate_on_delivery_failure) {
|
|
220
|
+
lines.push(" terminate_on_delivery_failure: true");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return lines.join("\n");
|
|
224
|
+
}
|
|
225
|
+
function buildYaml(options) {
|
|
226
|
+
const delivery = [` type: ${options.deliveryType}`];
|
|
227
|
+
if (options.target)
|
|
228
|
+
delivery.push(` ${options.target.field}: ${JSON.stringify(options.target.id)}`);
|
|
229
|
+
const body = DEFAULT_PATROL_MESSAGE_BODY.split("\n").map((l) => ` ${l}`).join("\n");
|
|
230
|
+
const trigger = options.watchPath
|
|
231
|
+
? `trigger:
|
|
232
|
+
type: event
|
|
233
|
+
watch:
|
|
234
|
+
path: ${JSON.stringify(options.watchPath)}`
|
|
235
|
+
: `cron: ${JSON.stringify(options.cron ?? "*/15 * * * *")}`;
|
|
236
|
+
return `name: ${options.name}
|
|
237
|
+
${trigger}
|
|
238
|
+
probe:
|
|
239
|
+
exec:
|
|
240
|
+
command: ${JSON.stringify(options.probeCommand)}
|
|
241
|
+
timeout_sec: 60
|
|
242
|
+
delivery:
|
|
243
|
+
${delivery.join("\n")}
|
|
244
|
+
${yamlBlockDeliver(options.deliver)}
|
|
245
|
+
${yamlBlockTermination(options.termination)}
|
|
246
|
+
template:
|
|
247
|
+
title: ${JSON.stringify(DEFAULT_PATROL_MESSAGE_TITLE)}
|
|
248
|
+
body: |
|
|
249
|
+
${body}
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Stage offline patrol materials only. Does not SSH/scp.
|
|
254
|
+
* Prefer copy_plan.artifact (one .tgz). Two-dir copy is fallback when tar packing fails.
|
|
255
|
+
*/
|
|
256
|
+
export async function runPatrolSetup(config, input, env = process.env) {
|
|
257
|
+
const name = input.name.trim();
|
|
258
|
+
if (!name || name.includes("/") || name.includes("\\") || name.includes("..")) {
|
|
259
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "name must be a single path segment");
|
|
260
|
+
}
|
|
261
|
+
if (!config.adapter) {
|
|
262
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "Set DINGTALK_CLIENT_ID/SECRET or DINGTALK_WEBHOOK_URL in MCP env");
|
|
263
|
+
}
|
|
264
|
+
const { intent, deliver, termination } = resolvePolicies(input);
|
|
265
|
+
if (intent === "on_event" && !input.watch_path?.trim()) {
|
|
266
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "watch_path is required for intent=on_event (host file or directory; EventTrigger uses fs.watch, not cron)");
|
|
267
|
+
}
|
|
268
|
+
const watchPath = intent === "on_event" ? input.watch_path.trim() : undefined;
|
|
269
|
+
const cron = watchPath ? undefined : (input.cron?.trim() || DEFAULT_CRON);
|
|
270
|
+
const bundle = readBundleInfo(env);
|
|
271
|
+
if (!bundle.available && !input.dry_run) {
|
|
272
|
+
throw new DingtalkDwsError("ADAPTER_UNAVAILABLE", bundle.message);
|
|
273
|
+
}
|
|
274
|
+
const route = pickRoute(config, input.route);
|
|
275
|
+
const deliveryType = config.adapter;
|
|
276
|
+
let target;
|
|
277
|
+
let targetName;
|
|
278
|
+
if (deliveryType === "dws") {
|
|
279
|
+
const bound = readBoundTarget(config, route);
|
|
280
|
+
if (!bound && !input.dry_run) {
|
|
281
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", `Bind ${route} before setup`);
|
|
282
|
+
}
|
|
283
|
+
if (bound) {
|
|
284
|
+
targetName = bound.name;
|
|
285
|
+
target = route === "notify_group"
|
|
286
|
+
? { field: "open_conversation_id", id: bound.id }
|
|
287
|
+
: { field: "user_id", id: bound.id };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const stagingRoot = path.join(env.DINGTALK_PATROL_STAGING_DIR?.trim()
|
|
291
|
+
? path.resolve(env.DINGTALK_PATROL_STAGING_DIR)
|
|
292
|
+
: path.join(os.tmpdir(), "dingtalk-patrol-staging"), name);
|
|
293
|
+
fs.mkdirSync(path.join(stagingRoot, "probes"), { recursive: true, mode: 0o700 });
|
|
294
|
+
const probeCommand = input.probe_command?.trim() || "./probes/example.sh";
|
|
295
|
+
const probeFile = path.join(stagingRoot, "probes", path.basename(probeCommand) === probeCommand ? "example.sh" : path.basename(probeCommand));
|
|
296
|
+
const script = input.probe_script ?? DEFAULT_PROBE;
|
|
297
|
+
writeLf(probeFile, script.endsWith("\n") ? script : `${script}\n`, 0o755);
|
|
298
|
+
const yamlPath = path.join(stagingRoot, "patrol.yaml");
|
|
299
|
+
const envPath = path.join(stagingRoot, "env.sh");
|
|
300
|
+
writeLf(yamlPath, buildYaml({
|
|
301
|
+
name,
|
|
302
|
+
cron,
|
|
303
|
+
watchPath,
|
|
304
|
+
probeCommand: probeCommand.includes("/") || probeCommand.includes("\\")
|
|
305
|
+
? probeCommand
|
|
306
|
+
: `./probes/${path.basename(probeFile)}`,
|
|
307
|
+
deliveryType,
|
|
308
|
+
target,
|
|
309
|
+
deliver,
|
|
310
|
+
termination,
|
|
311
|
+
}));
|
|
312
|
+
writeEnvFile(envPath, config, env);
|
|
313
|
+
const artifactPath = path.join(path.dirname(stagingRoot), `${name}.tgz`);
|
|
314
|
+
const archive = bundle.available
|
|
315
|
+
? writeJobArchive({ bundleRoot: bundle.bundle_root, stagingRoot, outFile: artifactPath })
|
|
316
|
+
: null;
|
|
317
|
+
const response = {
|
|
318
|
+
ok: true,
|
|
319
|
+
name,
|
|
320
|
+
cron,
|
|
321
|
+
route,
|
|
322
|
+
intent,
|
|
323
|
+
watch_path: watchPath,
|
|
324
|
+
trigger: watchPath ? { type: "event", watch: { path: watchPath } } : { type: "cron", cron },
|
|
325
|
+
deliver,
|
|
326
|
+
termination,
|
|
327
|
+
next_action: "install_on_host",
|
|
328
|
+
staging_dir: stagingRoot,
|
|
329
|
+
bundle_root: bundle.bundle_root,
|
|
330
|
+
bundle_fingerprint: bundle.fingerprint,
|
|
331
|
+
includes_node: bundle.includes_node,
|
|
332
|
+
includes_dws: bundle.includes_dws,
|
|
333
|
+
install_hint: archive
|
|
334
|
+
? `Copy one file ${artifactPath} to the host, tar -xzf, then ./install.sh ./staging. LAN hosts: local SCP. Do not send RFC1918 IPs via ACCESS.`
|
|
335
|
+
: "Copy bundle_root and staging_dir to the host, then run install.sh <staging_on_host>",
|
|
336
|
+
copy_plan: archive
|
|
337
|
+
? {
|
|
338
|
+
artifact: artifactPath,
|
|
339
|
+
bytes: archive.bytes,
|
|
340
|
+
includes_runtime: archive.includes_runtime,
|
|
341
|
+
remote_install: "tar -xzf <job.tgz> && ./install.sh ./staging",
|
|
342
|
+
}
|
|
343
|
+
: {
|
|
344
|
+
bundle_root: bundle.bundle_root,
|
|
345
|
+
staging_dir: stagingRoot,
|
|
346
|
+
remote_install: "install.sh <staging_on_host>",
|
|
347
|
+
},
|
|
348
|
+
server_version: readPackageVersion(),
|
|
349
|
+
paths: { yaml: yamlPath, env: envPath, probe: probeFile, artifact: archive ? artifactPath : undefined },
|
|
350
|
+
};
|
|
351
|
+
if (targetName)
|
|
352
|
+
response.target_name = targetName;
|
|
353
|
+
if (deliveryType === "dws" && bundle.available && !bundle.includes_dws) {
|
|
354
|
+
response.warning =
|
|
355
|
+
"bundle includes_dws=false: target needs system dws on PATH, or re-pack with linux ELF (DINGTALK_PATROL_DWS_PATH)";
|
|
356
|
+
}
|
|
357
|
+
const serialized = JSON.stringify(response);
|
|
358
|
+
for (const secret of [config.clientSecret, config.webhookSecret, env.DINGTALK_CLIENT_SECRET, env.DINGTALK_WEBHOOK_SECRET]) {
|
|
359
|
+
if (secret && serialized.includes(secret)) {
|
|
360
|
+
throw new DingtalkDwsError("INTERNAL_ERROR", "refusing to return secrets");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return response;
|
|
364
|
+
}
|
|
365
|
+
export function bundleFingerprintForDoctor(env = process.env) {
|
|
366
|
+
const info = readBundleInfo(env);
|
|
367
|
+
let next_action = null;
|
|
368
|
+
if (!info.available) {
|
|
369
|
+
next_action =
|
|
370
|
+
"Host patrol bundle missing. In this repo: npm run pack:deploy and set DINGTALK_PATROL_BUNDLE_ROOT. npx installs have no deploy artifacts — do not dry_run as a substitute for packing.";
|
|
371
|
+
}
|
|
372
|
+
else if (!info.includes_dws) {
|
|
373
|
+
next_action =
|
|
374
|
+
"Bundle has no linux dws ELF. Re-run pack:deploy on a machine with dingtalk-workspace-cli (or set DINGTALK_PATROL_DWS_PATH). Target otherwise needs system dws.";
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
available: info.available,
|
|
378
|
+
fingerprint: info.fingerprint,
|
|
379
|
+
server_version: info.server_version ?? readPackageVersion(),
|
|
380
|
+
includes_node: info.includes_node,
|
|
381
|
+
includes_dws: info.includes_dws,
|
|
382
|
+
path: info.bundle_root,
|
|
383
|
+
message: info.message,
|
|
384
|
+
next_action,
|
|
385
|
+
};
|
|
386
|
+
}
|
package/dist/src/prompts.js
CHANGED
|
@@ -2,12 +2,10 @@ const NOTIFY_RECIPE = `One-shot DingTalk notify (user-selected recipe):
|
|
|
2
2
|
1. dingtalk_doctor → follow next_action
|
|
3
3
|
2. Managed: search → bind → dingtalk_send (route, title, content)
|
|
4
4
|
3. Webhook: dingtalk_send with route=notify_group only`;
|
|
5
|
-
const PATROL_RECIPE = `Host
|
|
5
|
+
const PATROL_RECIPE = `Host Job Runtime (RFC-0008; user-selected recipe):
|
|
6
6
|
1. dingtalk_doctor (credentials in MCP env only)
|
|
7
|
-
2. Managed:
|
|
8
|
-
3.
|
|
9
|
-
4. Copy bundle_root + staging_dir to the host (host-execution if available, else SSH with user-provided IP/user/password or key), run install.sh on the host
|
|
10
|
-
5. Stop — do not download Node on the laptop`;
|
|
7
|
+
2. Managed: search → bind if needed. Search empty (next_action=ask_user): stop and ask the user; do not guess another name.
|
|
8
|
+
3. dingtalk_patrol_setup once (intent from conversation; on_event needs watch_path). Follow copy_plan. Stop — no session loop or dingtalk_send.`;
|
|
11
9
|
export function listPromptDefinitions(_surface) {
|
|
12
10
|
return [
|
|
13
11
|
{
|
package/dist/src/service.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CommandRunner } from "./internal/exec.js";
|
|
2
2
|
import type { AdapterKind, AppConfig, RouteName, RouteType, SendInput } from "./types.js";
|
|
3
3
|
import { IdempotencyStore } from "./notify/idempotency-store.js";
|
|
4
|
-
import { type PatrolSetupInput } from "./patrol/setup.js";
|
|
4
|
+
import { type PatrolSetupInput } from "./patrol-setup/setup.js";
|
|
5
5
|
export declare class DingtalkDwsService {
|
|
6
6
|
private readonly config;
|
|
7
7
|
private readonly store;
|
package/dist/src/service.js
CHANGED
|
@@ -4,10 +4,10 @@ import { sendWebhook, validateWebhookConfig } from "./delivery/webhook.js";
|
|
|
4
4
|
import { DingtalkDwsError } from "./errors.js";
|
|
5
5
|
import { dwsAuthLogin, dwsAuthStatus, probeDws } from "./internal/dws-auth-command.js";
|
|
6
6
|
import { sendRobotMessage } from "./internal/dws-robot-command.js";
|
|
7
|
-
import { candidateRef,
|
|
7
|
+
import { candidateRef, describeRouteBinding, describeRoutesMap, resolveRouteTarget, routeType, writeTarget, } from "./notify/bind.js";
|
|
8
8
|
import { validateSendContent } from "./notify/content.js";
|
|
9
9
|
import { sha256 } from "./notify/idempotency-store.js";
|
|
10
|
-
import { bundleFingerprintForDoctor, runPatrolSetup } from "./patrol/setup.js";
|
|
10
|
+
import { bundleFingerprintForDoctor, runPatrolSetup } from "./patrol-setup/setup.js";
|
|
11
11
|
import { readPackageVersion } from "./version.js";
|
|
12
12
|
export class DingtalkDwsService {
|
|
13
13
|
config;
|
|
@@ -41,12 +41,23 @@ export class DingtalkDwsService {
|
|
|
41
41
|
const dwsReady = probe.installed && auth.authenticated === true;
|
|
42
42
|
let notifyReady = false;
|
|
43
43
|
let notifyMessage = this.config.adapterError ?? "Notify credentials are not configured";
|
|
44
|
+
const routeViews = this.config.adapter === "webhook"
|
|
45
|
+
? describeRoutesMap(this.config, ["notify_group"])
|
|
46
|
+
: this.config.adapter === "dws"
|
|
47
|
+
? describeRoutesMap(this.config, ["notify_group", "notify_user"])
|
|
48
|
+
: {};
|
|
44
49
|
if (this.config.adapter === "dws") {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
const credsOk = Boolean(this.config.clientId && this.config.clientSecret);
|
|
51
|
+
const routeOk = Object.values(routeViews).some((view) => view.name != null);
|
|
52
|
+
notifyReady = credsOk && routeOk;
|
|
53
|
+
if (!credsOk)
|
|
54
|
+
notifyMessage = "Configure robot credentials and bind at least one route";
|
|
55
|
+
else if (!routeOk) {
|
|
56
|
+
notifyMessage =
|
|
57
|
+
"Bind notify_group or notify_user with {id,name}; call list_routes to verify the group/user name";
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
notifyMessage = "Managed notify is ready";
|
|
50
61
|
}
|
|
51
62
|
else if (this.config.adapter === "webhook") {
|
|
52
63
|
const validation = validateWebhookConfig(this.config);
|
|
@@ -88,17 +99,9 @@ export class DingtalkDwsService {
|
|
|
88
99
|
},
|
|
89
100
|
notify: {
|
|
90
101
|
adapter: this.config.adapter,
|
|
91
|
-
credentials_configured: this.config.adapter === "webhook"
|
|
92
|
-
? Boolean(this.config.webhookUrl)
|
|
93
|
-
: Boolean(this.config.clientId && this.config.clientSecret),
|
|
94
102
|
ready: notifyReady,
|
|
95
103
|
message: notifyMessage,
|
|
96
|
-
routes:
|
|
97
|
-
? { notify_group: { configured: isRouteConfigured(this.config, "notify_group") } }
|
|
98
|
-
: {
|
|
99
|
-
notify_group: { configured: isRouteConfigured(this.config, "notify_group") },
|
|
100
|
-
notify_user: { configured: isRouteConfigured(this.config, "notify_user") },
|
|
101
|
-
},
|
|
104
|
+
routes: routeViews,
|
|
102
105
|
},
|
|
103
106
|
next_action,
|
|
104
107
|
};
|
|
@@ -128,15 +131,17 @@ export class DingtalkDwsService {
|
|
|
128
131
|
const names = this.config.adapter === "webhook"
|
|
129
132
|
? ["notify_group"]
|
|
130
133
|
: ["notify_group", "notify_user"];
|
|
131
|
-
const routes = names.map((name) => ({
|
|
132
|
-
name,
|
|
133
|
-
type: routeType(name),
|
|
134
|
-
configured: isRouteConfigured(this.config, name),
|
|
135
|
-
description: name === "notify_group" ? "Notify a DingTalk group" : "Notify a DingTalk user",
|
|
136
|
-
}));
|
|
137
134
|
return {
|
|
138
135
|
adapter: this.config.adapter,
|
|
139
|
-
routes
|
|
136
|
+
routes: names.map((route) => {
|
|
137
|
+
const binding = describeRouteBinding(this.config, route);
|
|
138
|
+
return {
|
|
139
|
+
route,
|
|
140
|
+
type: routeType(route),
|
|
141
|
+
name: binding.name,
|
|
142
|
+
id: binding.id,
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
140
145
|
};
|
|
141
146
|
}
|
|
142
147
|
/** Adapter used to shape the public MCP tool surface. */
|
|
@@ -153,12 +158,22 @@ export class DingtalkDwsService {
|
|
|
153
158
|
for (const type of types) {
|
|
154
159
|
candidates.push(...await searchTargets(this.config, type, input.query, input.limit ?? 10, this.runner, this.env));
|
|
155
160
|
}
|
|
161
|
+
const mapped = candidates.map((candidate) => ({
|
|
162
|
+
type: candidate.type,
|
|
163
|
+
name: candidate.name,
|
|
164
|
+
candidate_ref: candidateRef(candidate),
|
|
165
|
+
}));
|
|
156
166
|
return {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
167
|
+
query: input.query,
|
|
168
|
+
type: input.type ?? "all",
|
|
169
|
+
candidates: mapped,
|
|
170
|
+
next_action: mapped.length === 0 ? "ask_user" : "bind",
|
|
171
|
+
...(mapped.length === 0
|
|
172
|
+
? {
|
|
173
|
+
message: `No DingTalk target matched ${JSON.stringify(input.query)}. ` +
|
|
174
|
+
"Ask the user for the exact display name shown in DingTalk. Do not search a guessed name or bind a similar candidate.",
|
|
175
|
+
}
|
|
176
|
+
: {}),
|
|
162
177
|
};
|
|
163
178
|
}
|
|
164
179
|
async configureRouteTarget(input) {
|
|
@@ -172,21 +187,29 @@ export class DingtalkDwsService {
|
|
|
172
187
|
for (const type of types) {
|
|
173
188
|
candidates.push(...await searchTargets(this.config, type, input.query, input.candidate_ref ? 20 : 2, this.runner, this.env));
|
|
174
189
|
}
|
|
190
|
+
if (candidates.length === 0) {
|
|
191
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", `No DingTalk target matched ${JSON.stringify(input.query)}. Ask the user for the exact display name. Do not bind a similar candidate.`);
|
|
192
|
+
}
|
|
175
193
|
const candidate = input.candidate_ref
|
|
176
194
|
? candidates.find((item) => candidateRef(item) === input.candidate_ref)
|
|
177
195
|
: candidates.length === 1 ? candidates[0] : undefined;
|
|
178
196
|
if (!candidate) {
|
|
179
|
-
throw new DingtalkDwsError("INVALID_ARGUMENT",
|
|
197
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", `Multiple matches for ${JSON.stringify(input.query)}; pass candidate_ref after the user picks one. Do not guess.`);
|
|
180
198
|
}
|
|
181
199
|
const route = input.route ?? (candidate.type === "group" ? "notify_group" : "notify_user");
|
|
182
200
|
if (routeType(route) !== candidate.type) {
|
|
183
201
|
throw new DingtalkDwsError("INVALID_ARGUMENT", "candidate type does not match route");
|
|
184
202
|
}
|
|
185
|
-
|
|
203
|
+
const displayName = candidate.name.trim();
|
|
204
|
+
if (!displayName) {
|
|
205
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "candidate name is required to bind a transparent route");
|
|
206
|
+
}
|
|
207
|
+
writeTarget(this.config, route, { id: candidate.target_id, name: displayName });
|
|
186
208
|
return {
|
|
187
209
|
route,
|
|
188
210
|
type: candidate.type,
|
|
189
|
-
name:
|
|
211
|
+
name: displayName,
|
|
212
|
+
id: candidate.target_id,
|
|
190
213
|
candidate_ref: candidateRef(candidate),
|
|
191
214
|
};
|
|
192
215
|
}
|
|
@@ -198,6 +221,7 @@ export class DingtalkDwsService {
|
|
|
198
221
|
validateSendContent(input, this.config);
|
|
199
222
|
const title = input.title.trim();
|
|
200
223
|
const content = input.content.trim();
|
|
224
|
+
const target = describeRouteBinding(this.config, input.route);
|
|
201
225
|
if (input.dedupe_key) {
|
|
202
226
|
const key = sha256(`${input.route}\n${input.dedupe_key}`);
|
|
203
227
|
const claim = this.store.claim(key, Date.now(), this.config.suppressionWindowSeconds);
|
|
@@ -205,6 +229,7 @@ export class DingtalkDwsService {
|
|
|
205
229
|
return {
|
|
206
230
|
status: "suppressed",
|
|
207
231
|
route: input.route,
|
|
232
|
+
target: { name: target.name, id: target.id },
|
|
208
233
|
message_id: claim.messageId ?? null,
|
|
209
234
|
deduplicated: true,
|
|
210
235
|
};
|
|
@@ -222,6 +247,7 @@ export class DingtalkDwsService {
|
|
|
222
247
|
return {
|
|
223
248
|
status: "sent",
|
|
224
249
|
route: input.route,
|
|
250
|
+
target: { name: target.name, id: target.id },
|
|
225
251
|
message_id: result.messageId ?? null,
|
|
226
252
|
deduplicated: false,
|
|
227
253
|
sent_at: new Date().toISOString(),
|
|
@@ -240,6 +266,7 @@ export class DingtalkDwsService {
|
|
|
240
266
|
return {
|
|
241
267
|
status: "sent",
|
|
242
268
|
route: input.route,
|
|
269
|
+
target: { name: target.name, id: target.id },
|
|
243
270
|
message_id: result.messageId ?? null,
|
|
244
271
|
deduplicated: false,
|
|
245
272
|
sent_at: new Date().toISOString(),
|
|
@@ -16,7 +16,7 @@ export function buildInstructions(surface) {
|
|
|
16
16
|
const lines = [
|
|
17
17
|
"DingTalk robot notify + dws readiness (Agent surface of the dws ecosystem). Office stays on local dws.",
|
|
18
18
|
"Office via local dws: before following upstream Skill recipes, read this server's MCP Resources overlays when they apply.",
|
|
19
|
-
"Constraint:
|
|
19
|
+
"Constraint: host Job Runtime (RFC-0008) must not use session loops or repeated dingtalk_send — Agent calls dingtalk_patrol_setup once, follows copy_plan, then stops. Do not download Node in-session. dingtalk_send is one-shot on this machine only. MCP env holds credentials only.",
|
|
20
20
|
"Call dingtalk_doctor first; connected ≠ ready.",
|
|
21
21
|
];
|
|
22
22
|
if (surface.adapter === null) {
|
|
@@ -26,7 +26,7 @@ export function buildInstructions(surface) {
|
|
|
26
26
|
lines.push("Webhook tools: doctor + send (notify_group only).");
|
|
27
27
|
}
|
|
28
28
|
else {
|
|
29
|
-
lines.push("Managed tools: search → bind → send. Bind targets are for this server only.");
|
|
29
|
+
lines.push("Managed tools: search → bind → send. Bind targets are for this server only. Empty search (next_action=ask_user): tell the user and stop — do not retry with a guessed name or bind a similar person.");
|
|
30
30
|
}
|
|
31
31
|
lines.push("Never put credentials or secrets in message content.");
|
|
32
32
|
if (surface.authTools) {
|
|
@@ -58,7 +58,7 @@ export function createToolDefinitions(surface) {
|
|
|
58
58
|
if (surface.directoryTools) {
|
|
59
59
|
tools.push({
|
|
60
60
|
name: "dingtalk_search_targets",
|
|
61
|
-
description: "Find groups/users by
|
|
61
|
+
description: "Find groups/users by the name the user gave. Returns candidate_ref for bind. If candidates is empty, next_action is ask_user: report that to the user and stop. Do not invent another query or bind a similarly named person.",
|
|
62
62
|
inputSchema: {
|
|
63
63
|
type: "object",
|
|
64
64
|
properties: {
|
|
@@ -72,7 +72,7 @@ export function createToolDefinitions(surface) {
|
|
|
72
72
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
73
73
|
}, {
|
|
74
74
|
name: "dingtalk_configure_route_target",
|
|
75
|
-
description: "Bind one search result to notify_group or notify_user
|
|
75
|
+
description: "Bind one search result to notify_group or notify_user. Use candidate_ref from search. If search was empty, do not call this with a different guessed query.",
|
|
76
76
|
inputSchema: {
|
|
77
77
|
type: "object",
|
|
78
78
|
properties: {
|
|
@@ -90,7 +90,7 @@ export function createToolDefinitions(surface) {
|
|
|
90
90
|
if (surface.listRoutes) {
|
|
91
91
|
tools.push({
|
|
92
92
|
name: "dingtalk_list_routes",
|
|
93
|
-
description: "List
|
|
93
|
+
description: "List notify_group / notify_user bindings with target name and id so you can verify the destination before send.",
|
|
94
94
|
inputSchema: emptySchema,
|
|
95
95
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
96
96
|
});
|
|
@@ -119,7 +119,7 @@ export function createToolDefinitions(surface) {
|
|
|
119
119
|
// Always available: stage host patrol materials (Agent transfers + remote install).
|
|
120
120
|
tools.push({
|
|
121
121
|
name: "dingtalk_patrol_setup",
|
|
122
|
-
description: "Stage
|
|
122
|
+
description: "Stage host Job Runtime materials only. Does not SSH. Prefer intent=always|long_monitor|stop_on_critical|on_event. on_event needs watch_path (file/dir on the host; fs.watch, not cron). Follow copy_plan. Call once, then stop. Not dingtalk_send. Never returns secrets.",
|
|
123
123
|
inputSchema: {
|
|
124
124
|
type: "object",
|
|
125
125
|
properties: {
|
|
@@ -128,6 +128,36 @@ export function createToolDefinitions(surface) {
|
|
|
128
128
|
route: { type: "string", enum: ["notify_group", "notify_user"] },
|
|
129
129
|
probe_command: { type: "string" },
|
|
130
130
|
probe_script: { type: "string" },
|
|
131
|
+
intent: {
|
|
132
|
+
type: "string",
|
|
133
|
+
enum: ["always", "long_monitor", "stop_on_critical", "on_event"],
|
|
134
|
+
description: "Recipe alias. on_event requires watch_path (EventTrigger). Explicit deliver/termination override this.",
|
|
135
|
+
},
|
|
136
|
+
watch_path: {
|
|
137
|
+
type: "string",
|
|
138
|
+
description: "Host file or directory to watch when intent=on_event. Not a cron interval.",
|
|
139
|
+
},
|
|
140
|
+
deliver: {
|
|
141
|
+
type: "object",
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
properties: {
|
|
144
|
+
policy: { type: "string", enum: ["always", "on_status_change", "only_statuses"] },
|
|
145
|
+
statuses: {
|
|
146
|
+
type: "array",
|
|
147
|
+
items: { type: "string", enum: ["ok", "warning", "critical", "unknown"] },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
termination: {
|
|
152
|
+
type: "object",
|
|
153
|
+
additionalProperties: false,
|
|
154
|
+
properties: {
|
|
155
|
+
policy: { type: "string", enum: ["none", "status_match", "manual"] },
|
|
156
|
+
status: { type: "string", enum: ["ok", "warning", "critical", "unknown"] },
|
|
157
|
+
on: { type: "string", enum: ["first", "until"] },
|
|
158
|
+
terminate_on_delivery_failure: { type: "boolean" },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
131
161
|
dry_run: { type: "boolean", description: "Allow missing bind/bundle (preview/tests); still writes staging" },
|
|
132
162
|
},
|
|
133
163
|
required: ["name"],
|
|
@@ -147,6 +177,70 @@ function requireNonEmptyString(input, key) {
|
|
|
147
177
|
}
|
|
148
178
|
return value.trim();
|
|
149
179
|
}
|
|
180
|
+
function asObject(value, key) {
|
|
181
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
182
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", `${key} must be an object`);
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
function parseSetupIntent(value) {
|
|
187
|
+
if (value === undefined)
|
|
188
|
+
return undefined;
|
|
189
|
+
if (value === "always" || value === "long_monitor" || value === "stop_on_critical" || value === "on_event") {
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "intent must be always | long_monitor | stop_on_critical | on_event");
|
|
193
|
+
}
|
|
194
|
+
function parseSetupDeliver(value) {
|
|
195
|
+
if (value === undefined)
|
|
196
|
+
return undefined;
|
|
197
|
+
const rec = asObject(value, "deliver");
|
|
198
|
+
const out = {};
|
|
199
|
+
if (rec.policy !== undefined) {
|
|
200
|
+
if (rec.policy !== "always" && rec.policy !== "on_status_change" && rec.policy !== "only_statuses") {
|
|
201
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.policy must be always | on_status_change | only_statuses");
|
|
202
|
+
}
|
|
203
|
+
out.policy = rec.policy;
|
|
204
|
+
}
|
|
205
|
+
if (rec.statuses !== undefined) {
|
|
206
|
+
if (!Array.isArray(rec.statuses) || rec.statuses.some((s) => typeof s !== "string")) {
|
|
207
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "deliver.statuses must be a string array");
|
|
208
|
+
}
|
|
209
|
+
out.statuses = rec.statuses;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
function parseSetupTermination(value) {
|
|
214
|
+
if (value === undefined)
|
|
215
|
+
return undefined;
|
|
216
|
+
const rec = asObject(value, "termination");
|
|
217
|
+
const out = {};
|
|
218
|
+
if (rec.policy !== undefined) {
|
|
219
|
+
if (rec.policy !== "none" && rec.policy !== "status_match" && rec.policy !== "manual") {
|
|
220
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.policy must be none | status_match | manual");
|
|
221
|
+
}
|
|
222
|
+
out.policy = rec.policy;
|
|
223
|
+
}
|
|
224
|
+
if (rec.status !== undefined) {
|
|
225
|
+
if (typeof rec.status !== "string") {
|
|
226
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.status must be a string");
|
|
227
|
+
}
|
|
228
|
+
out.status = rec.status;
|
|
229
|
+
}
|
|
230
|
+
if (rec.on !== undefined) {
|
|
231
|
+
if (rec.on !== "first" && rec.on !== "until") {
|
|
232
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.on must be first or until");
|
|
233
|
+
}
|
|
234
|
+
out.on = rec.on;
|
|
235
|
+
}
|
|
236
|
+
if (rec.terminate_on_delivery_failure !== undefined) {
|
|
237
|
+
if (typeof rec.terminate_on_delivery_failure !== "boolean") {
|
|
238
|
+
throw new DingtalkDwsError("INVALID_ARGUMENT", "termination.terminate_on_delivery_failure must be a boolean");
|
|
239
|
+
}
|
|
240
|
+
out.terminate_on_delivery_failure = rec.terminate_on_delivery_failure;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
150
244
|
function optionalPositiveInt(value, key) {
|
|
151
245
|
if (value === undefined)
|
|
152
246
|
return undefined;
|
|
@@ -259,6 +353,10 @@ export async function callTool(service, name, args, surface) {
|
|
|
259
353
|
: parseRouteName(input.route, ["notify_group", "notify_user"]),
|
|
260
354
|
probe_command: optionalString("probe_command"),
|
|
261
355
|
probe_script: optionalString("probe_script"),
|
|
356
|
+
intent: parseSetupIntent(input.intent),
|
|
357
|
+
watch_path: optionalString("watch_path"),
|
|
358
|
+
deliver: parseSetupDeliver(input.deliver),
|
|
359
|
+
termination: parseSetupTermination(input.termination),
|
|
262
360
|
dry_run: optionalBool("dry_run"),
|
|
263
361
|
});
|
|
264
362
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dingtalk-dws-mcp",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "DingTalk robot notification MCP with dws readiness handshake",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -18,14 +18,18 @@
|
|
|
18
18
|
"dingtalk-dws-mcp": "dist/src/index.js"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"
|
|
21
|
+
"sync:delivery": "node scripts/sync-notify-delivery.mjs",
|
|
22
|
+
"build": "npm run sync:delivery && tsc -p tsconfig.json",
|
|
22
23
|
"prepack": "npm run build",
|
|
23
|
-
"prepublishOnly": "npm run build && npm test",
|
|
24
|
+
"prepublishOnly": "npm run build && npm run test:mcp",
|
|
24
25
|
"start": "node dist/src/index.js",
|
|
25
26
|
"pack:deploy": "node scripts/pack-deploy.mjs",
|
|
26
|
-
"test": "npm run build && vitest run",
|
|
27
|
+
"test:mcp": "npm run build && vitest run",
|
|
28
|
+
"test:lib": "npm --prefix lib/notify-delivery test",
|
|
29
|
+
"test:host-patrol": "npm --prefix host-patrol test",
|
|
30
|
+
"test": "npm run test:mcp && npm run test:lib && npm run test:host-patrol",
|
|
27
31
|
"test:watch": "vitest",
|
|
28
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
32
|
+
"typecheck": "npm run sync:delivery && tsc -p tsconfig.json --noEmit"
|
|
29
33
|
},
|
|
30
34
|
"keywords": [
|
|
31
35
|
"mcp",
|
package/dist/src/patrol/setup.js
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as os from "node:os";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { DEFAULT_PATROL_MESSAGE_BODY, DEFAULT_PATROL_MESSAGE_TITLE, } from "../notify-delivery/index.js";
|
|
5
|
-
import { DingtalkDwsError } from "../errors.js";
|
|
6
|
-
import { isRouteConfigured, readTargets } from "../notify/bind.js";
|
|
7
|
-
import { mcpPackageRoot, readPackageVersion } from "../version.js";
|
|
8
|
-
const DEFAULT_CRON = "*/15 * * * *";
|
|
9
|
-
const DEFAULT_PROBE = `#!/usr/bin/env bash
|
|
10
|
-
set -euo pipefail
|
|
11
|
-
printf '%s\\n' '{"status":"ok","summary":"【进度】示例探针正常\\n【监控】请替换为真实检查","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}'
|
|
12
|
-
`;
|
|
13
|
-
export function resolveBundleRoot(env = process.env) {
|
|
14
|
-
const override = env.DINGTALK_PATROL_BUNDLE_ROOT?.trim();
|
|
15
|
-
if (override)
|
|
16
|
-
return path.resolve(override);
|
|
17
|
-
return path.join(mcpPackageRoot(), "deploy", "linux-x64");
|
|
18
|
-
}
|
|
19
|
-
export function readBundleInfo(env = process.env) {
|
|
20
|
-
const bundle_root = resolveBundleRoot(env);
|
|
21
|
-
const manifestPath = path.join(bundle_root, "manifest.json");
|
|
22
|
-
const ready = fs.existsSync(manifestPath) && fs.existsSync(path.join(bundle_root, "runtime", "patrol"));
|
|
23
|
-
if (!ready) {
|
|
24
|
-
return {
|
|
25
|
-
available: false,
|
|
26
|
-
bundle_root,
|
|
27
|
-
fingerprint: null,
|
|
28
|
-
server_version: null,
|
|
29
|
-
includes_node: false,
|
|
30
|
-
includes_dws: false,
|
|
31
|
-
message: `Patrol bundle missing at ${bundle_root}. Run npm run pack:deploy.`,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
36
|
-
return {
|
|
37
|
-
available: true,
|
|
38
|
-
bundle_root,
|
|
39
|
-
fingerprint: typeof m.fingerprint === "string" ? m.fingerprint : null,
|
|
40
|
-
server_version: typeof m.server_version === "string" ? m.server_version : null,
|
|
41
|
-
includes_node: Boolean(m.includes_node),
|
|
42
|
-
includes_dws: Boolean(m.includes_dws),
|
|
43
|
-
message: "bundle ready",
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return {
|
|
48
|
-
available: false,
|
|
49
|
-
bundle_root,
|
|
50
|
-
fingerprint: null,
|
|
51
|
-
server_version: null,
|
|
52
|
-
includes_node: false,
|
|
53
|
-
includes_dws: false,
|
|
54
|
-
message: `Invalid manifest at ${manifestPath}`,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
function shellQuote(value) {
|
|
59
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
60
|
-
}
|
|
61
|
-
function writeLf(filePath, content, mode) {
|
|
62
|
-
const body = content.replace(/\r\n/g, "\n");
|
|
63
|
-
if (mode === undefined)
|
|
64
|
-
fs.writeFileSync(filePath, body, "utf8");
|
|
65
|
-
else
|
|
66
|
-
fs.writeFileSync(filePath, body, { mode });
|
|
67
|
-
}
|
|
68
|
-
/** Sync local notify credentials (already verified by doctor) into target env.sh. Not user OAuth.
|
|
69
|
-
* Never copy laptop DINGTALK_DWS_COMMAND / config.dwsCommand — that path is Agent-host local
|
|
70
|
-
* (Windows .exe or Linux absolute) and is wrong on the patrol target. Target resolves `dws` via
|
|
71
|
-
* install runtime PATH (bundled linux ELF) or system dws. Optional target-only override:
|
|
72
|
-
* DINGTALK_PATROL_HOST_DWS (path that exists on the target machine).
|
|
73
|
-
*/
|
|
74
|
-
function writeEnvFile(filePath, config, env) {
|
|
75
|
-
const lines = ["# Generated by dingtalk_patrol_setup — delivery credentials for target host"];
|
|
76
|
-
const put = (key, value) => {
|
|
77
|
-
if (value)
|
|
78
|
-
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
79
|
-
};
|
|
80
|
-
put("DINGTALK_CLIENT_ID", config.clientId ?? env.DINGTALK_CLIENT_ID);
|
|
81
|
-
put("DINGTALK_CLIENT_SECRET", config.clientSecret ?? env.DINGTALK_CLIENT_SECRET);
|
|
82
|
-
put("DINGTALK_ROBOT_CODE", config.robotCode ?? env.DINGTALK_ROBOT_CODE);
|
|
83
|
-
put("DINGTALK_WEBHOOK_URL", config.webhookUrl ?? env.DINGTALK_WEBHOOK_URL);
|
|
84
|
-
put("DINGTALK_WEBHOOK_SECRET", config.webhookSecret ?? env.DINGTALK_WEBHOOK_SECRET);
|
|
85
|
-
const hostDws = env.DINGTALK_PATROL_HOST_DWS?.trim();
|
|
86
|
-
if (hostDws)
|
|
87
|
-
put("DINGTALK_DWS_COMMAND", hostDws);
|
|
88
|
-
writeLf(filePath, `${lines.join("\n")}\n`, 0o600);
|
|
89
|
-
}
|
|
90
|
-
function pickRoute(config, preferred) {
|
|
91
|
-
if (config.adapter === "webhook")
|
|
92
|
-
return "notify_group";
|
|
93
|
-
if (preferred) {
|
|
94
|
-
if (!isRouteConfigured(config, preferred)) {
|
|
95
|
-
throw new DingtalkDwsError("INVALID_ARGUMENT", `Route ${preferred} is not bound`);
|
|
96
|
-
}
|
|
97
|
-
return preferred;
|
|
98
|
-
}
|
|
99
|
-
if (isRouteConfigured(config, "notify_group"))
|
|
100
|
-
return "notify_group";
|
|
101
|
-
if (isRouteConfigured(config, "notify_user"))
|
|
102
|
-
return "notify_user";
|
|
103
|
-
throw new DingtalkDwsError("INVALID_ARGUMENT", "Bind a notify target first (or use webhook)");
|
|
104
|
-
}
|
|
105
|
-
function buildYaml(options) {
|
|
106
|
-
const delivery = [` type: ${options.deliveryType}`];
|
|
107
|
-
if (options.target)
|
|
108
|
-
delivery.push(` ${options.target.field}: ${JSON.stringify(options.target.id)}`);
|
|
109
|
-
const body = DEFAULT_PATROL_MESSAGE_BODY.split("\n").map((l) => ` ${l}`).join("\n");
|
|
110
|
-
return `name: ${options.name}
|
|
111
|
-
cron: ${JSON.stringify(options.cron)}
|
|
112
|
-
probe:
|
|
113
|
-
exec:
|
|
114
|
-
command: ${JSON.stringify(options.probeCommand)}
|
|
115
|
-
timeout_sec: 60
|
|
116
|
-
delivery:
|
|
117
|
-
${delivery.join("\n")}
|
|
118
|
-
template:
|
|
119
|
-
title: ${JSON.stringify(DEFAULT_PATROL_MESSAGE_TITLE)}
|
|
120
|
-
body: |
|
|
121
|
-
${body}
|
|
122
|
-
`;
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Stage offline patrol materials only. Does not SSH/scp.
|
|
126
|
-
* Agent copies bundle_root + staging_dir to the host (host-execution or SSH), then runs install.sh there.
|
|
127
|
-
*/
|
|
128
|
-
export async function runPatrolSetup(config, input, env = process.env) {
|
|
129
|
-
const name = input.name.trim();
|
|
130
|
-
if (!name || name.includes("/") || name.includes("\\") || name.includes("..")) {
|
|
131
|
-
throw new DingtalkDwsError("INVALID_ARGUMENT", "name must be a single path segment");
|
|
132
|
-
}
|
|
133
|
-
if (!config.adapter) {
|
|
134
|
-
throw new DingtalkDwsError("INVALID_ARGUMENT", "Set DINGTALK_CLIENT_ID/SECRET or DINGTALK_WEBHOOK_URL in MCP env");
|
|
135
|
-
}
|
|
136
|
-
const cron = input.cron?.trim() || DEFAULT_CRON;
|
|
137
|
-
const bundle = readBundleInfo(env);
|
|
138
|
-
if (!bundle.available && !input.dry_run) {
|
|
139
|
-
throw new DingtalkDwsError("ADAPTER_UNAVAILABLE", bundle.message);
|
|
140
|
-
}
|
|
141
|
-
const route = pickRoute(config, input.route);
|
|
142
|
-
const deliveryType = config.adapter;
|
|
143
|
-
let target;
|
|
144
|
-
if (deliveryType === "dws") {
|
|
145
|
-
if (!isRouteConfigured(config, route) && !input.dry_run) {
|
|
146
|
-
throw new DingtalkDwsError("INVALID_ARGUMENT", `Bind ${route} before setup`);
|
|
147
|
-
}
|
|
148
|
-
const id = readTargets(config)[route];
|
|
149
|
-
if (id) {
|
|
150
|
-
target = route === "notify_group"
|
|
151
|
-
? { field: "open_conversation_id", id }
|
|
152
|
-
: { field: "user_id", id };
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
const stagingRoot = path.join(env.DINGTALK_PATROL_STAGING_DIR?.trim()
|
|
156
|
-
? path.resolve(env.DINGTALK_PATROL_STAGING_DIR)
|
|
157
|
-
: path.join(os.tmpdir(), "dingtalk-patrol-staging"), name);
|
|
158
|
-
fs.mkdirSync(path.join(stagingRoot, "probes"), { recursive: true, mode: 0o700 });
|
|
159
|
-
const probeCommand = input.probe_command?.trim() || "./probes/example.sh";
|
|
160
|
-
const probeFile = path.join(stagingRoot, "probes", path.basename(probeCommand) === probeCommand ? "example.sh" : path.basename(probeCommand));
|
|
161
|
-
const script = input.probe_script ?? DEFAULT_PROBE;
|
|
162
|
-
writeLf(probeFile, script.endsWith("\n") ? script : `${script}\n`, 0o755);
|
|
163
|
-
const yamlPath = path.join(stagingRoot, "patrol.yaml");
|
|
164
|
-
const envPath = path.join(stagingRoot, "env.sh");
|
|
165
|
-
writeLf(yamlPath, buildYaml({
|
|
166
|
-
name,
|
|
167
|
-
cron,
|
|
168
|
-
probeCommand: probeCommand.includes("/") || probeCommand.includes("\\")
|
|
169
|
-
? probeCommand
|
|
170
|
-
: `./probes/${path.basename(probeFile)}`,
|
|
171
|
-
deliveryType,
|
|
172
|
-
target,
|
|
173
|
-
}));
|
|
174
|
-
writeEnvFile(envPath, config, env);
|
|
175
|
-
const response = {
|
|
176
|
-
ok: true,
|
|
177
|
-
name,
|
|
178
|
-
cron,
|
|
179
|
-
route,
|
|
180
|
-
next_action: "install_on_host",
|
|
181
|
-
staging_dir: stagingRoot,
|
|
182
|
-
bundle_root: bundle.bundle_root,
|
|
183
|
-
bundle_fingerprint: bundle.fingerprint,
|
|
184
|
-
includes_node: bundle.includes_node,
|
|
185
|
-
includes_dws: bundle.includes_dws,
|
|
186
|
-
install_hint: "Copy bundle_root (runtime+install.sh) and staging_dir to the host, then run: install.sh <staging>",
|
|
187
|
-
server_version: readPackageVersion(),
|
|
188
|
-
paths: { yaml: yamlPath, env: envPath, probe: probeFile },
|
|
189
|
-
};
|
|
190
|
-
if (deliveryType === "dws" && bundle.available && !bundle.includes_dws) {
|
|
191
|
-
response.warning =
|
|
192
|
-
"bundle includes_dws=false: target needs system dws on PATH, or re-pack with linux ELF (DINGTALK_PATROL_DWS_PATH)";
|
|
193
|
-
}
|
|
194
|
-
const serialized = JSON.stringify(response);
|
|
195
|
-
for (const secret of [config.clientSecret, config.webhookSecret, env.DINGTALK_CLIENT_SECRET, env.DINGTALK_WEBHOOK_SECRET]) {
|
|
196
|
-
if (secret && serialized.includes(secret)) {
|
|
197
|
-
throw new DingtalkDwsError("INTERNAL_ERROR", "refusing to return secrets");
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
return response;
|
|
201
|
-
}
|
|
202
|
-
export function bundleFingerprintForDoctor(env = process.env) {
|
|
203
|
-
const info = readBundleInfo(env);
|
|
204
|
-
return {
|
|
205
|
-
available: info.available,
|
|
206
|
-
fingerprint: info.fingerprint,
|
|
207
|
-
server_version: info.server_version ?? readPackageVersion(),
|
|
208
|
-
includes_node: info.includes_node,
|
|
209
|
-
includes_dws: info.includes_dws,
|
|
210
|
-
path: info.bundle_root,
|
|
211
|
-
message: info.message,
|
|
212
|
-
};
|
|
213
|
-
}
|