@rivus/gateway 0.16.2
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 +21 -0
- package/README.md +6 -0
- package/dist/bootstrap/pi-feishu.d.ts +20 -0
- package/dist/bootstrap/pi-feishu.js +671 -0
- package/dist/chunks/background-session-authority.js +230 -0
- package/dist/chunks/background-session-control-input.js +45 -0
- package/dist/chunks/background-session-service.d.ts +390 -0
- package/dist/chunks/index.d.ts +4703 -0
- package/dist/chunks/node-rivus-deployment-manifest.js +1650 -0
- package/dist/chunks/rivus-node-entrypoint.js +4464 -0
- package/dist/chunks/service.js +12112 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +16 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1215 -0
- package/dist/mcp.d.ts +92 -0
- package/dist/mcp.js +455 -0
- package/package.json +64 -0
- package/skills/runtime-management/SKILL.md +65 -0
- package/templates/a-share-briefing-analysis.mjs +93 -0
- package/templates/a-share-briefing-renderer.mjs +257 -0
- package/templates/a-share-index-evidence.mjs +99 -0
- package/templates/a-share-market-briefing.mjs +83 -0
- package/templates/a-share-market-date.mjs +10 -0
- package/templates/a-share-overseas-evidence.mjs +86 -0
- package/templates/a-share-policy-evidence.mjs +145 -0
- package/templates/a-share-provider-response.mjs +21 -0
- package/templates/a-share-sector-evidence.mjs +70 -0
- package/templates/acp-stdio-proxy.mjs +58 -0
- package/templates/current-weather.mjs +117 -0
- package/templates/html-drive-tools.mjs +262 -0
- package/templates/https-response-reader.mjs +36 -0
- package/templates/langfuse-drive-e2e.mjs +175 -0
- package/templates/pi-feishu-deployment.bootstrap.ts +3 -0
- package/templates/pi-feishu.bootstrap.ts +242 -0
- package/templates/rivus-agents.plugin.mjs +290 -0
- package/templates/rivus-langfuse-demo.config.json +37 -0
- package/templates/rivus-starter.plugin.mjs +47 -0
- package/templates/rivus.config.json +114 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readAShareProviderResponse } from "./a-share-provider-response.mjs";
|
|
2
|
+
|
|
3
|
+
const LOOKBACK_MS = 3 * 24 * 60 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
export async function readASharePolicyEvidence({ occurrence }) {
|
|
6
|
+
const occurrenceTime = Date.parse(occurrence);
|
|
7
|
+
const readers = [
|
|
8
|
+
{ read: readPbcHeadlines, source: "中国人民银行" },
|
|
9
|
+
{ read: readCsrcHeadlines, source: "中国证监会" }
|
|
10
|
+
];
|
|
11
|
+
const settled = await Promise.allSettled(readers.map(({ read }) => read()));
|
|
12
|
+
const available = settled.flatMap((result, index) =>
|
|
13
|
+
result.status === "fulfilled" ? [{ items: result.value, source: readers[index].source }] : []
|
|
14
|
+
);
|
|
15
|
+
if (available.length === 0) throw new Error("a-share policy evidence sources are unavailable");
|
|
16
|
+
const items = available
|
|
17
|
+
.flatMap(({ items }) => items)
|
|
18
|
+
.filter(({ publishedAt }) => {
|
|
19
|
+
const publishedTime = Date.parse(publishedAt);
|
|
20
|
+
return publishedTime <= occurrenceTime && publishedTime >= occurrenceTime - LOOKBACK_MS;
|
|
21
|
+
})
|
|
22
|
+
.sort((left, right) => right.publishedAt.localeCompare(left.publishedAt))
|
|
23
|
+
.slice(0, 4);
|
|
24
|
+
const sources = available.map(({ source }) => source);
|
|
25
|
+
const unavailableSources = settled.flatMap((result, index) =>
|
|
26
|
+
result.status === "rejected" ? [readers[index].source] : []
|
|
27
|
+
);
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
items: Object.freeze(items),
|
|
30
|
+
sources: Object.freeze(sources),
|
|
31
|
+
unavailableSources: Object.freeze(unavailableSources)
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readPbcHeadlines() {
|
|
36
|
+
const url = new URL("https://www.pbc.gov.cn/goutongjiaoliu/113456/113469/index.html");
|
|
37
|
+
const body = await readText(url, "中国人民银行");
|
|
38
|
+
const items = [];
|
|
39
|
+
const pattern =
|
|
40
|
+
/<a\s+([^>]*\bistitle="true"[^>]*)>[\s\S]*?<\/a>\s*<\/font>\s*<span[^>]*>\s*(\d{4}-\d{2}-\d{2})\s*<\/span>/giu;
|
|
41
|
+
for (const match of body.matchAll(pattern)) {
|
|
42
|
+
const href = readHtmlAttribute(match[1], "href");
|
|
43
|
+
const title = decodeHtml(readHtmlAttribute(match[1], "title"));
|
|
44
|
+
if (!href || !title || title.length > 200) continue;
|
|
45
|
+
const itemUrl = normalizeSourceUrl(href, url, "www.pbc.gov.cn");
|
|
46
|
+
if (!itemUrl) continue;
|
|
47
|
+
items.push(
|
|
48
|
+
Object.freeze({
|
|
49
|
+
publishedAt: readPbcPublishedAt(itemUrl, match[2]),
|
|
50
|
+
source: "中国人民银行",
|
|
51
|
+
title,
|
|
52
|
+
url: itemUrl
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
if (items.length === 2) break;
|
|
56
|
+
}
|
|
57
|
+
if (items.length === 0) throw new Error("PBC policy evidence contains no valid headlines");
|
|
58
|
+
return Object.freeze(items);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readPbcPublishedAt(itemUrl, displayedDate) {
|
|
62
|
+
const timestamp = new URL(itemUrl).pathname.match(/\/(\d{14})\d*\/index\.html$/u)?.[1];
|
|
63
|
+
if (!timestamp) return `${displayedDate}T23:59:59+08:00`;
|
|
64
|
+
return `${timestamp.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/u, "$1-$2-$3T$4:$5:$6")}+08:00`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readCsrcHeadlines() {
|
|
68
|
+
const url = new URL("https://www.csrc.gov.cn/searchList/a1a078ee0bc54721ab6b148884c784a8");
|
|
69
|
+
url.searchParams.set("_isAgg", "true");
|
|
70
|
+
url.searchParams.set("_isJson", "true");
|
|
71
|
+
url.searchParams.set("_pageSize", "5");
|
|
72
|
+
url.searchParams.set("_template", "index");
|
|
73
|
+
url.searchParams.set("_rangeTimeGte", "");
|
|
74
|
+
url.searchParams.set("_channelName", "");
|
|
75
|
+
url.searchParams.set("page", "1");
|
|
76
|
+
const body = await readText(url, "中国证监会", 256 * 1024);
|
|
77
|
+
let payload;
|
|
78
|
+
try {
|
|
79
|
+
payload = JSON.parse(body);
|
|
80
|
+
} catch (cause) {
|
|
81
|
+
throw new Error("CSRC policy evidence is not valid JSON", { cause });
|
|
82
|
+
}
|
|
83
|
+
const results = Array.isArray(payload?.data?.results) ? payload.data.results : undefined;
|
|
84
|
+
if (!results) throw new Error("CSRC policy evidence contains no result list");
|
|
85
|
+
const items = results
|
|
86
|
+
.flatMap((row) => {
|
|
87
|
+
if (
|
|
88
|
+
typeof row?.title !== "string" ||
|
|
89
|
+
!row.title ||
|
|
90
|
+
row.title.length > 200 ||
|
|
91
|
+
typeof row?.url !== "string" ||
|
|
92
|
+
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(row?.publishedTimeStr ?? "")
|
|
93
|
+
) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const itemUrl = normalizeSourceUrl(row.url, url, "www.csrc.gov.cn");
|
|
97
|
+
if (!itemUrl) return [];
|
|
98
|
+
return [
|
|
99
|
+
Object.freeze({
|
|
100
|
+
publishedAt: `${row.publishedTimeStr.replace(" ", "T")}+08:00`,
|
|
101
|
+
source: "中国证监会",
|
|
102
|
+
title: row.title,
|
|
103
|
+
url: itemUrl
|
|
104
|
+
})
|
|
105
|
+
];
|
|
106
|
+
})
|
|
107
|
+
.slice(0, 2);
|
|
108
|
+
if (items.length === 0) throw new Error("CSRC policy evidence contains no valid headlines");
|
|
109
|
+
return Object.freeze(items);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readText(url, source, maxBytes = 128 * 1024) {
|
|
113
|
+
const body = await readAShareProviderResponse({
|
|
114
|
+
accept: "text/html,application/json",
|
|
115
|
+
maxBytes,
|
|
116
|
+
referer: `${url.origin}/`,
|
|
117
|
+
source: `${source} policy evidence`,
|
|
118
|
+
url
|
|
119
|
+
});
|
|
120
|
+
return body.toString("utf8");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readHtmlAttribute(attributes, name) {
|
|
124
|
+
return attributes.match(new RegExp(`\\b${name}="([^"]+)"`, "iu"))?.[1];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeSourceUrl(value, baseUrl, expectedHostname) {
|
|
128
|
+
let url;
|
|
129
|
+
try {
|
|
130
|
+
url = new URL(value, baseUrl);
|
|
131
|
+
} catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
if (url.protocol !== "https:" || url.hostname !== expectedHostname) return undefined;
|
|
135
|
+
return url.href;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function decodeHtml(value) {
|
|
139
|
+
return value
|
|
140
|
+
.replaceAll("&", "&")
|
|
141
|
+
.replaceAll(""", '"')
|
|
142
|
+
.replaceAll("'", "'")
|
|
143
|
+
.replaceAll("<", "<")
|
|
144
|
+
.replaceAll(">", ">");
|
|
145
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readBoundedHttpsResponse } from "./https-response-reader.mjs";
|
|
2
|
+
|
|
3
|
+
export async function readAShareProviderResponse({ accept, maxBytes, referer, source, url }) {
|
|
4
|
+
let response;
|
|
5
|
+
try {
|
|
6
|
+
response = await readBoundedHttpsResponse(url, {
|
|
7
|
+
headers: {
|
|
8
|
+
accept,
|
|
9
|
+
referer,
|
|
10
|
+
"user-agent": "rivus-a-share-market-briefing/1.1"
|
|
11
|
+
},
|
|
12
|
+
maxBytes
|
|
13
|
+
});
|
|
14
|
+
} catch (cause) {
|
|
15
|
+
throw new Error(`${source} request failed`, { cause });
|
|
16
|
+
}
|
|
17
|
+
if (response.status < 200 || response.status >= 300) {
|
|
18
|
+
throw new Error(`${source} request failed with HTTP ${response.status}`);
|
|
19
|
+
}
|
|
20
|
+
return response.body;
|
|
21
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { dateInShanghai } from "./a-share-market-date.mjs";
|
|
2
|
+
import { readAShareProviderResponse } from "./a-share-provider-response.mjs";
|
|
3
|
+
|
|
4
|
+
const SOURCE = "东方财富行业板块公开行情";
|
|
5
|
+
|
|
6
|
+
export async function readAShareSectorEvidence({ marketDate }) {
|
|
7
|
+
const [leaders, laggards] = await Promise.all([readSectorRanking(true), readSectorRanking(false)]);
|
|
8
|
+
const sectors = [...leaders, ...laggards];
|
|
9
|
+
const asOf = sectors
|
|
10
|
+
.map(({ asOf }) => asOf)
|
|
11
|
+
.sort((left, right) => left.localeCompare(right))
|
|
12
|
+
.at(-1);
|
|
13
|
+
if (!sectors.every((sector) => dateInShanghai(new Date(sector.asOf)) === marketDate)) {
|
|
14
|
+
throw new Error("a-share sector evidence is stale for the market date");
|
|
15
|
+
}
|
|
16
|
+
return Object.freeze({ asOf, laggards, leaders, source: SOURCE });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readSectorRanking(descending) {
|
|
20
|
+
const url = new URL("https://push2delay.eastmoney.com/api/qt/clist/get");
|
|
21
|
+
url.searchParams.set("pn", "1");
|
|
22
|
+
url.searchParams.set("pz", "3");
|
|
23
|
+
url.searchParams.set("po", descending ? "1" : "0");
|
|
24
|
+
url.searchParams.set("np", "1");
|
|
25
|
+
url.searchParams.set("fltt", "2");
|
|
26
|
+
url.searchParams.set("invt", "2");
|
|
27
|
+
url.searchParams.set("fid", "f3");
|
|
28
|
+
url.searchParams.set("fs", "m:90+t:2");
|
|
29
|
+
url.searchParams.set("fields", "f12,f14,f3,f124");
|
|
30
|
+
const body = await readAShareProviderResponse({
|
|
31
|
+
accept: "application/json",
|
|
32
|
+
maxBytes: 64 * 1024,
|
|
33
|
+
referer: "https://quote.eastmoney.com/",
|
|
34
|
+
source: "a-share sector evidence",
|
|
35
|
+
url
|
|
36
|
+
});
|
|
37
|
+
return parseSectorRanking(body.toString("utf8"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseSectorRanking(body) {
|
|
41
|
+
let payload;
|
|
42
|
+
try {
|
|
43
|
+
payload = JSON.parse(body);
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
throw new Error("a-share sector evidence is not valid JSON", { cause });
|
|
46
|
+
}
|
|
47
|
+
const rows = payload?.rc === 0 && Array.isArray(payload?.data?.diff) ? payload.data.diff : undefined;
|
|
48
|
+
if (!rows || rows.length !== 3) throw new Error("a-share sector evidence must contain three ranked industries");
|
|
49
|
+
return Object.freeze(
|
|
50
|
+
rows.map((row) => {
|
|
51
|
+
if (
|
|
52
|
+
typeof row?.f12 !== "string" ||
|
|
53
|
+
!row.f12 ||
|
|
54
|
+
typeof row?.f14 !== "string" ||
|
|
55
|
+
!row.f14 ||
|
|
56
|
+
!Number.isFinite(row?.f3) ||
|
|
57
|
+
!Number.isSafeInteger(row?.f124) ||
|
|
58
|
+
row.f124 <= 0
|
|
59
|
+
) {
|
|
60
|
+
throw new Error("a-share sector evidence contains an invalid industry record");
|
|
61
|
+
}
|
|
62
|
+
return Object.freeze({
|
|
63
|
+
asOf: new Date(row.f124 * 1000).toISOString(),
|
|
64
|
+
changePercent: row.f3,
|
|
65
|
+
id: row.f12,
|
|
66
|
+
name: row.f14
|
|
67
|
+
});
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createAcpAgentServer,
|
|
5
|
+
createAcpPermissionBridge,
|
|
6
|
+
createAcpStdioAgentLoop,
|
|
7
|
+
createJsonAcpSessionStore,
|
|
8
|
+
serveAcpAgentOnStdio
|
|
9
|
+
} from "@rivus/agent/acp";
|
|
10
|
+
|
|
11
|
+
const command = process.env.RIVUS_ACP_SERVER_COMMAND?.trim();
|
|
12
|
+
if (!command) throw new Error("RIVUS_ACP_SERVER_COMMAND is required and must resolve to an explicit executable");
|
|
13
|
+
|
|
14
|
+
const workingDirectory = process.env.RIVUS_ACP_WORKING_DIRECTORY?.trim() || process.cwd();
|
|
15
|
+
const sessionStorePath = process.env.RIVUS_ACP_SESSION_STORE?.trim();
|
|
16
|
+
const permissionBridge = createAcpPermissionBridge();
|
|
17
|
+
const downstream = createAcpStdioAgentLoop({
|
|
18
|
+
arguments: parseArguments(process.env.RIVUS_ACP_SERVER_ARGUMENTS),
|
|
19
|
+
command,
|
|
20
|
+
environment: selectEnvironment(process.env.RIVUS_ACP_SERVER_ENV_KEYS),
|
|
21
|
+
onStderr: (text) => process.stderr.write(text),
|
|
22
|
+
permissionPolicy: permissionBridge.policy,
|
|
23
|
+
...(sessionStorePath ? { sessionStore: createJsonAcpSessionStore({ filePath: sessionStorePath }) } : {}),
|
|
24
|
+
workingDirectory
|
|
25
|
+
});
|
|
26
|
+
const server = createAcpAgentServer({
|
|
27
|
+
agentName: "rivus-acp-proxy",
|
|
28
|
+
loop: downstream.loop,
|
|
29
|
+
permissionBridge,
|
|
30
|
+
workingDirectory
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
await serveAcpAgentOnStdio(server);
|
|
35
|
+
} finally {
|
|
36
|
+
await downstream.dispose();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseArguments(value) {
|
|
40
|
+
if (!value?.trim()) return [];
|
|
41
|
+
const parsed = JSON.parse(value);
|
|
42
|
+
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
|
|
43
|
+
throw new Error("RIVUS_ACP_SERVER_ARGUMENTS must be a JSON array of strings");
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function selectEnvironment(value) {
|
|
49
|
+
const selected = {};
|
|
50
|
+
for (const key of value
|
|
51
|
+
?.split(",")
|
|
52
|
+
.map((item) => item.trim())
|
|
53
|
+
.filter(Boolean) ?? []) {
|
|
54
|
+
const environmentValue = process.env[key];
|
|
55
|
+
if (environmentValue !== undefined) selected[key] = environmentValue;
|
|
56
|
+
}
|
|
57
|
+
return selected;
|
|
58
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readBoundedHttpsResponse } from "./https-response-reader.mjs";
|
|
2
|
+
|
|
3
|
+
export async function readCurrentWeather(input) {
|
|
4
|
+
if (input !== undefined && (typeof input !== "object" || input === null || Array.isArray(input))) {
|
|
5
|
+
throw new Error("current-weather input must be an object");
|
|
6
|
+
}
|
|
7
|
+
const requestedLocation = input?.location;
|
|
8
|
+
if (
|
|
9
|
+
requestedLocation !== undefined &&
|
|
10
|
+
(typeof requestedLocation !== "string" || !requestedLocation.trim() || requestedLocation.length > 100)
|
|
11
|
+
) {
|
|
12
|
+
throw new Error("current-weather location must be a non-empty string up to 100 characters");
|
|
13
|
+
}
|
|
14
|
+
const location = requestedLocation?.trim() || process.env.RIVUS_WEATHER_DEFAULT_LOCATION?.trim() || "北京";
|
|
15
|
+
const geocodingUrl = new URL("https://geocoding-api.open-meteo.com/v1/search");
|
|
16
|
+
geocodingUrl.search = new URLSearchParams({ count: "1", format: "json", language: "zh", name: location }).toString();
|
|
17
|
+
const geocoding = await fetchJson(geocodingUrl, "weather location lookup");
|
|
18
|
+
const place = Array.isArray(geocoding.results) ? geocoding.results[0] : undefined;
|
|
19
|
+
if (
|
|
20
|
+
!place ||
|
|
21
|
+
typeof place.name !== "string" ||
|
|
22
|
+
typeof place.latitude !== "number" ||
|
|
23
|
+
typeof place.longitude !== "number"
|
|
24
|
+
) {
|
|
25
|
+
throw new Error(`current-weather could not resolve location: ${location}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const forecastUrl = new URL("https://api.open-meteo.com/v1/forecast");
|
|
29
|
+
forecastUrl.search = new URLSearchParams({
|
|
30
|
+
current: "temperature_2m,apparent_temperature,weather_code,wind_speed_10m",
|
|
31
|
+
daily: "temperature_2m_max,temperature_2m_min,precipitation_probability_max",
|
|
32
|
+
forecast_days: "1",
|
|
33
|
+
latitude: String(place.latitude),
|
|
34
|
+
longitude: String(place.longitude),
|
|
35
|
+
timezone: "auto"
|
|
36
|
+
}).toString();
|
|
37
|
+
const forecast = await fetchJson(forecastUrl, "weather forecast lookup");
|
|
38
|
+
const current = forecast.current;
|
|
39
|
+
const daily = forecast.daily;
|
|
40
|
+
if (!current || typeof current !== "object" || !daily || typeof daily !== "object") {
|
|
41
|
+
throw new Error("current-weather provider returned an incomplete forecast");
|
|
42
|
+
}
|
|
43
|
+
const weatherCode = readNumber(current.weather_code, "weather_code");
|
|
44
|
+
return {
|
|
45
|
+
apparentTemperatureC: readNumber(current.apparent_temperature, "apparent_temperature"),
|
|
46
|
+
condition: describeWeatherCode(weatherCode),
|
|
47
|
+
date: readFirst(daily.time, "date"),
|
|
48
|
+
location: place.country ? `${place.name}, ${place.country}` : place.name,
|
|
49
|
+
observedAt: readString(current.time, "observed time"),
|
|
50
|
+
precipitationProbabilityPercent: readFirstNumber(daily.precipitation_probability_max, "precipitation probability"),
|
|
51
|
+
temperatureC: readNumber(current.temperature_2m, "temperature"),
|
|
52
|
+
temperatureMaxC: readFirstNumber(daily.temperature_2m_max, "maximum temperature"),
|
|
53
|
+
temperatureMinC: readFirstNumber(daily.temperature_2m_min, "minimum temperature"),
|
|
54
|
+
timezone: readString(forecast.timezone ?? place.timezone, "timezone"),
|
|
55
|
+
weatherCode,
|
|
56
|
+
windSpeedKmh: readNumber(current.wind_speed_10m, "wind speed")
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function fetchJson(url, operation) {
|
|
61
|
+
let response;
|
|
62
|
+
try {
|
|
63
|
+
response = await readBoundedHttpsResponse(url, {
|
|
64
|
+
headers: { accept: "application/json" },
|
|
65
|
+
maxBytes: 1024 * 1024
|
|
66
|
+
});
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new Error(`${operation} failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
69
|
+
cause: error
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (response.status < 200 || response.status >= 300) {
|
|
73
|
+
throw new Error(`${operation} failed with HTTP ${response.status}`);
|
|
74
|
+
}
|
|
75
|
+
let value;
|
|
76
|
+
try {
|
|
77
|
+
value = JSON.parse(response.body.toString("utf8"));
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error(`${operation} returned invalid JSON`);
|
|
80
|
+
}
|
|
81
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
82
|
+
throw new Error(`${operation} returned invalid JSON`);
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readNumber(value, field) {
|
|
88
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`current-weather missing ${field}`);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readString(value, field) {
|
|
93
|
+
if (typeof value !== "string" || !value) throw new Error(`current-weather missing ${field}`);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readFirst(value, field) {
|
|
98
|
+
return readString(Array.isArray(value) ? value[0] : undefined, field);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readFirstNumber(value, field) {
|
|
102
|
+
return readNumber(Array.isArray(value) ? value[0] : undefined, field);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function describeWeatherCode(code) {
|
|
106
|
+
if (code === 0) return "晴";
|
|
107
|
+
if (code === 1 || code === 2) return "晴间多云";
|
|
108
|
+
if (code === 3) return "阴";
|
|
109
|
+
if (code === 45 || code === 48) return "有雾";
|
|
110
|
+
if (code >= 51 && code <= 57) return "毛毛雨";
|
|
111
|
+
if (code >= 61 && code <= 67) return "雨";
|
|
112
|
+
if (code >= 71 && code <= 77) return "雪";
|
|
113
|
+
if (code >= 80 && code <= 82) return "阵雨";
|
|
114
|
+
if (code >= 85 && code <= 86) return "阵雪";
|
|
115
|
+
if (code >= 95 && code <= 99) return "雷暴";
|
|
116
|
+
return "未知";
|
|
117
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const DEFAULT_ARTIFACT_ROOT = ".rivus/deployment/html-artifacts";
|
|
10
|
+
|
|
11
|
+
export function createHtmlArtifactWriter(options = {}) {
|
|
12
|
+
const cwd = options.cwd ?? process.cwd();
|
|
13
|
+
const artifactRoot = resolveArtifactRoot(cwd, options.artifactRoot);
|
|
14
|
+
return {
|
|
15
|
+
async execute(input, context) {
|
|
16
|
+
const html = readCompleteHtml(input);
|
|
17
|
+
const operationId = readOperationId(context);
|
|
18
|
+
const agentDirectory = resolveAgentDirectory(artifactRoot, context?.agentId);
|
|
19
|
+
const artifactId = createHash("sha256")
|
|
20
|
+
.update(JSON.stringify({ agentId: context.agentId, operationId }))
|
|
21
|
+
.digest("hex")
|
|
22
|
+
.slice(0, 32);
|
|
23
|
+
const trustedDirectory = await createTrustedDirectory(cwd, agentDirectory);
|
|
24
|
+
const filePath = resolve(trustedDirectory, `${artifactId}.html`);
|
|
25
|
+
try {
|
|
26
|
+
await writeFile(filePath, html, { encoding: "utf8", flag: "wx" });
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (!isFileAlreadyPresent(error) || (await readFile(filePath, "utf8")) !== html) throw error;
|
|
29
|
+
}
|
|
30
|
+
return Object.freeze({
|
|
31
|
+
artifactId,
|
|
32
|
+
path: relative(cwd, resolve(agentDirectory, `${artifactId}.html`)),
|
|
33
|
+
sha256: createHash("sha256").update(html).digest("hex"),
|
|
34
|
+
size: Buffer.byteLength(html)
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createLarkDriveHtmlUploader(options = {}) {
|
|
41
|
+
const cwd = options.cwd ?? process.cwd();
|
|
42
|
+
const artifactRoot = resolveArtifactRoot(cwd, options.artifactRoot);
|
|
43
|
+
const onCleanupError = options.onCleanupError ?? reportCleanupError;
|
|
44
|
+
const removeDirectory = options.removeDirectory ?? rm;
|
|
45
|
+
const runCommand = options.runCommand ?? runLarkCli;
|
|
46
|
+
return {
|
|
47
|
+
async execute(input, context) {
|
|
48
|
+
readOperationId(context);
|
|
49
|
+
const { artifactId, name } = readUploadInput(input);
|
|
50
|
+
const agentDirectory = resolveAgentDirectory(artifactRoot, context?.agentId);
|
|
51
|
+
const trustedDirectory = await resolveExistingTrustedDirectory(cwd, agentDirectory);
|
|
52
|
+
const filePath = resolve(trustedDirectory, `${artifactId}.html`);
|
|
53
|
+
const bytes = await readRegularFileNoFollow(filePath, artifactId);
|
|
54
|
+
const stagingRoot = resolve(cwd, ".rivus/deployment/upload-staging");
|
|
55
|
+
const trustedStagingRoot = await createTrustedDirectory(cwd, stagingRoot);
|
|
56
|
+
const trustedWorkspace = await realpath(cwd);
|
|
57
|
+
const stagingDirectory = await mkdtemp(join(trustedStagingRoot, "upload-"));
|
|
58
|
+
const stagedPath = resolve(stagingDirectory, name);
|
|
59
|
+
try {
|
|
60
|
+
await writeFile(stagedPath, bytes, { flag: "wx", mode: 0o600 });
|
|
61
|
+
const command = process.env.RIVUS_LARK_CLI_PATH?.trim() || "lark-cli";
|
|
62
|
+
const { stdout } = await runCommand(
|
|
63
|
+
command,
|
|
64
|
+
[
|
|
65
|
+
"drive",
|
|
66
|
+
"+upload",
|
|
67
|
+
"--as",
|
|
68
|
+
"user",
|
|
69
|
+
"--file",
|
|
70
|
+
relative(trustedWorkspace, stagedPath),
|
|
71
|
+
"--name",
|
|
72
|
+
name,
|
|
73
|
+
"--format",
|
|
74
|
+
"json"
|
|
75
|
+
],
|
|
76
|
+
{ cwd, timeout: 120_000 }
|
|
77
|
+
);
|
|
78
|
+
return readUploadResult(stdout, name);
|
|
79
|
+
} finally {
|
|
80
|
+
await cleanupStaging(removeDirectory, onCleanupError, stagingDirectory);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readRegularFileNoFollow(filePath, artifactId) {
|
|
87
|
+
const linkMetadata = await lstat(filePath);
|
|
88
|
+
if (linkMetadata.isSymbolicLink()) throw new Error(`HTML artifact cannot be a symbolic link: ${artifactId}`);
|
|
89
|
+
if (typeof constants.O_NOFOLLOW !== "number") {
|
|
90
|
+
throw new Error("HTML artifact upload requires filesystem no-follow support");
|
|
91
|
+
}
|
|
92
|
+
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
93
|
+
try {
|
|
94
|
+
const metadata = await handle.stat();
|
|
95
|
+
if (!metadata.isFile()) throw new Error(`HTML artifact is not a regular file: ${artifactId}`);
|
|
96
|
+
if (metadata.size > 256 * 1024) throw new Error(`HTML artifact exceeds the 256 KiB limit: ${artifactId}`);
|
|
97
|
+
return await handle.readFile();
|
|
98
|
+
} finally {
|
|
99
|
+
await handle.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function cleanupStaging(removeDirectory, onCleanupError, stagingDirectory) {
|
|
104
|
+
try {
|
|
105
|
+
await removeDirectory(stagingDirectory, { force: true, recursive: true });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
try {
|
|
108
|
+
onCleanupError(error);
|
|
109
|
+
} catch {
|
|
110
|
+
// Cleanup diagnostics must not change the known remote upload outcome.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function reportCleanupError(error) {
|
|
116
|
+
process.emitWarning(
|
|
117
|
+
`Could not remove the private HTML upload staging directory: ${error instanceof Error ? error.message : String(error)}`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runLarkCli(command, args, options) {
|
|
122
|
+
return execFileAsync(command, args, {
|
|
123
|
+
...options,
|
|
124
|
+
env: {
|
|
125
|
+
...process.env,
|
|
126
|
+
LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1",
|
|
127
|
+
LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1"
|
|
128
|
+
},
|
|
129
|
+
maxBuffer: 1024 * 1024
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readCompleteHtml(input) {
|
|
134
|
+
if (!isRecord(input) || typeof input.html !== "string") {
|
|
135
|
+
throw new Error("write-html-artifact requires an html string");
|
|
136
|
+
}
|
|
137
|
+
const html = input.html.trim();
|
|
138
|
+
if (!/^<!doctype html>/i.test(html) || !/<html(?:\s|>)/i.test(html) || !/<\/html>\s*$/i.test(html)) {
|
|
139
|
+
throw new Error("write-html-artifact requires a complete HTML document");
|
|
140
|
+
}
|
|
141
|
+
if (Buffer.byteLength(html) > 256 * 1024) throw new Error("HTML artifact exceeds the 256 KiB limit");
|
|
142
|
+
return html;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readUploadInput(input) {
|
|
146
|
+
if (!isRecord(input) || typeof input.artifactId !== "string" || !/^[a-f0-9]{32}$/.test(input.artifactId)) {
|
|
147
|
+
throw new Error("lark-drive-upload-html requires a valid artifactId");
|
|
148
|
+
}
|
|
149
|
+
if (
|
|
150
|
+
typeof input.name !== "string" ||
|
|
151
|
+
!/^[^/\\]{1,120}\.html$/i.test(input.name) ||
|
|
152
|
+
input.name === ".html" ||
|
|
153
|
+
input.name.includes("..")
|
|
154
|
+
) {
|
|
155
|
+
throw new Error("lark-drive-upload-html requires a safe .html file name");
|
|
156
|
+
}
|
|
157
|
+
return { artifactId: input.artifactId, name: input.name };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readUploadResult(stdout, requestedName) {
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(stdout);
|
|
164
|
+
} catch {
|
|
165
|
+
throw new Error("lark-cli upload returned invalid JSON");
|
|
166
|
+
}
|
|
167
|
+
const result = isRecord(parsed?.data) ? parsed.data : parsed;
|
|
168
|
+
if (!isRecord(result)) throw new Error("lark-cli upload returned an invalid result");
|
|
169
|
+
const fileToken = readFirstString(result, ["file_token", "fileToken", "token"]);
|
|
170
|
+
const url = readFirstString(result, ["url", "web_url", "file_url"]);
|
|
171
|
+
const name = readFirstString(result, ["name", "file_name"]) ?? requestedName;
|
|
172
|
+
if (!fileToken || !url) throw new Error("lark-cli upload result is missing file token or URL");
|
|
173
|
+
return Object.freeze({ fileToken, name, url });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readFirstString(value, keys) {
|
|
177
|
+
for (const key of keys) {
|
|
178
|
+
const candidate = value[key];
|
|
179
|
+
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveArtifactRoot(cwd, artifactRoot = DEFAULT_ARTIFACT_ROOT) {
|
|
185
|
+
const root = resolve(cwd, artifactRoot);
|
|
186
|
+
const relativeRoot = relative(cwd, root);
|
|
187
|
+
if (relativeRoot === ".." || relativeRoot.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
|
|
188
|
+
throw new Error("HTML artifact root must stay inside the workspace");
|
|
189
|
+
}
|
|
190
|
+
return root;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveAgentDirectory(artifactRoot, agentId) {
|
|
194
|
+
if (typeof agentId !== "string" || !agentId.trim()) throw new Error("HTML artifact tool requires an agent id");
|
|
195
|
+
return resolve(artifactRoot, createHash("sha256").update(agentId).digest("hex").slice(0, 16));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function createTrustedDirectory(cwd, directory) {
|
|
199
|
+
return resolveTrustedDirectory(cwd, directory, true);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function resolveExistingTrustedDirectory(cwd, directory) {
|
|
203
|
+
return resolveTrustedDirectory(cwd, directory, false);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function resolveTrustedDirectory(cwd, directory, create) {
|
|
207
|
+
const lexicalWorkspace = resolve(cwd);
|
|
208
|
+
const lexicalDirectory = resolve(directory);
|
|
209
|
+
const lexicalRelativePath = relative(lexicalWorkspace, lexicalDirectory);
|
|
210
|
+
if (lexicalRelativePath === ".." || lexicalRelativePath.startsWith(`..${sep}`)) {
|
|
211
|
+
throw new Error("HTML artifact directory must stay inside the workspace");
|
|
212
|
+
}
|
|
213
|
+
const segments = lexicalRelativePath.split(sep).filter(Boolean);
|
|
214
|
+
let current = lexicalWorkspace;
|
|
215
|
+
for (const segment of segments) {
|
|
216
|
+
current = resolve(current, segment);
|
|
217
|
+
const metadata = await readOrCreateDirectory(current, create);
|
|
218
|
+
if (metadata.isSymbolicLink()) {
|
|
219
|
+
throw new Error("HTML artifact directory cannot contain symbolic links");
|
|
220
|
+
}
|
|
221
|
+
if (!metadata.isDirectory()) throw new Error("HTML artifact path component is not a directory");
|
|
222
|
+
}
|
|
223
|
+
const [workspacePath, directoryPath] = await Promise.all([realpath(lexicalWorkspace), realpath(lexicalDirectory)]);
|
|
224
|
+
const resolvedRelativePath = relative(workspacePath, directoryPath);
|
|
225
|
+
if (resolvedRelativePath === ".." || resolvedRelativePath.startsWith(`..${sep}`)) {
|
|
226
|
+
throw new Error("HTML artifact directory resolved outside the workspace");
|
|
227
|
+
}
|
|
228
|
+
return directoryPath;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function readOrCreateDirectory(directory, create) {
|
|
232
|
+
try {
|
|
233
|
+
return await lstat(directory);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (!isMissingFile(error) || !create) throw error;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
await mkdir(directory);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (!isFileAlreadyPresent(error)) throw error;
|
|
241
|
+
}
|
|
242
|
+
return lstat(directory);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function readOperationId(context) {
|
|
246
|
+
if (typeof context?.operationId !== "string" || !context.operationId.trim()) {
|
|
247
|
+
throw new Error("HTML artifact tool requires a stable operation id");
|
|
248
|
+
}
|
|
249
|
+
return context.operationId;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isRecord(value) {
|
|
253
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isFileAlreadyPresent(error) {
|
|
257
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isMissingFile(error) {
|
|
261
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
262
|
+
}
|