@syengup/friday-channel-next 0.1.26 → 0.1.28
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/dist/index.js +2 -0
- package/dist/src/http/handlers/files.js +1 -0
- package/dist/src/http/handlers/health.d.ts +1 -0
- package/dist/src/http/handlers/health.js +2 -0
- package/dist/src/http/handlers/link-preview.d.ts +9 -0
- package/dist/src/http/handlers/link-preview.js +41 -0
- package/dist/src/http/handlers/messages.d.ts +5 -0
- package/dist/src/http/handlers/messages.js +19 -8
- package/dist/src/http/handlers/plugin-info.d.ts +11 -0
- package/dist/src/http/handlers/plugin-info.js +32 -0
- package/dist/src/http/handlers/plugin-upgrade.d.ts +11 -0
- package/dist/src/http/handlers/plugin-upgrade.js +94 -0
- package/dist/src/http/handlers/sse.js +2 -0
- package/dist/src/http/handlers/status.js +2 -0
- package/dist/src/http/server.js +15 -0
- package/dist/src/link-preview/og-parse.d.ts +21 -0
- package/dist/src/link-preview/og-parse.js +232 -0
- package/dist/src/link-preview/preview-service.d.ts +31 -0
- package/dist/src/link-preview/preview-service.js +216 -0
- package/dist/src/link-preview/ssrf-guard.d.ts +43 -0
- package/dist/src/link-preview/ssrf-guard.js +223 -0
- package/dist/src/plugin-install-info.d.ts +15 -0
- package/dist/src/plugin-install-info.js +87 -0
- package/dist/src/upgrade-runtime.d.ts +39 -0
- package/dist/src/upgrade-runtime.js +27 -0
- package/dist/src/version.d.ts +5 -0
- package/dist/src/version.js +37 -0
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/http/handlers/files.ts +1 -0
- package/src/http/handlers/health.ts +3 -0
- package/src/http/handlers/link-preview.test.ts +242 -0
- package/src/http/handlers/link-preview.ts +47 -0
- package/src/http/handlers/messages.test.ts +75 -1
- package/src/http/handlers/messages.ts +19 -7
- package/src/http/handlers/plugin-info.ts +51 -0
- package/src/http/handlers/plugin-upgrade.ts +112 -0
- package/src/http/handlers/sse.ts +2 -0
- package/src/http/handlers/status.ts +2 -0
- package/src/http/server.ts +18 -0
- package/src/link-preview/og-parse.test.ts +168 -0
- package/src/link-preview/og-parse.ts +249 -0
- package/src/link-preview/preview-service.ts +247 -0
- package/src/link-preview/ssrf-guard.test.ts +234 -0
- package/src/link-preview/ssrf-guard.ts +229 -0
- package/src/plugin-install-info.test.ts +28 -0
- package/src/plugin-install-info.ts +95 -0
- package/src/upgrade-runtime.ts +69 -0
- package/src/version.ts +41 -0
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getFridayNextRuntime } from "./src/runtime.js";
|
|
|
8
8
|
import { sseEmitter } from "./src/sse/emitter.js";
|
|
9
9
|
import { forwardAgentEventRaw, getLastRegisteredFridayDeviceId, resolveFridayDeviceIdForSessionKey, } from "./src/friday-session.js";
|
|
10
10
|
import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
|
|
11
|
+
import { setUpgradeRuntime } from "./src/upgrade-runtime.js";
|
|
11
12
|
import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
|
|
12
13
|
import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
|
|
13
14
|
import { createFridayNextLogger } from "./src/logging.js";
|
|
@@ -78,6 +79,7 @@ export default defineChannelPluginEntry({
|
|
|
78
79
|
setRuntime: setFridayNextRuntime,
|
|
79
80
|
registerFull: (api) => {
|
|
80
81
|
setFridayAgentForwardRuntime(api);
|
|
82
|
+
setUpgradeRuntime(api);
|
|
81
83
|
const sameApi = lastApiRoutesRegistered?.deref() === api;
|
|
82
84
|
if (!sameApi) {
|
|
83
85
|
lastApiRoutesRegistered = new WeakRef(api);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
2
2
|
import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
|
|
3
3
|
import { createFridayNextLogger } from "../../logging.js";
|
|
4
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
4
5
|
const REQUIRED_NODE_CAPS = ["location", "canvas"];
|
|
5
6
|
const REQUIRED_NODE_COMMANDS = [
|
|
6
7
|
"location.get",
|
|
@@ -36,6 +37,7 @@ export async function handleHealth(req, res) {
|
|
|
36
37
|
timestamp: Date.now(),
|
|
37
38
|
deviceId,
|
|
38
39
|
nodeDeviceId,
|
|
40
|
+
pluginVersion: PLUGIN_VERSION,
|
|
39
41
|
};
|
|
40
42
|
const log = createFridayNextLogger("health");
|
|
41
43
|
if (nodeDeviceId) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/link-preview?url=<percent-encoded http(s) URL>
|
|
3
|
+
*
|
|
4
|
+
* Returns Open Graph metadata for a page link so the app can render a preview card without
|
|
5
|
+
* ever contacting the third-party site itself. Cover images are re-hosted under
|
|
6
|
+
* /friday-next/files/ by the preview service.
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
9
|
+
export declare function handleLinkPreview(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /friday-next/link-preview?url=<percent-encoded http(s) URL>
|
|
3
|
+
*
|
|
4
|
+
* Returns Open Graph metadata for a page link so the app can render a preview card without
|
|
5
|
+
* ever contacting the third-party site itself. Cover images are re-hosted under
|
|
6
|
+
* /friday-next/files/ by the preview service.
|
|
7
|
+
*/
|
|
8
|
+
import { getLinkPreview } from "../../link-preview/preview-service.js";
|
|
9
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
10
|
+
const ERROR_STATUS = {
|
|
11
|
+
invalid_url: 400,
|
|
12
|
+
blocked_url: 403,
|
|
13
|
+
no_metadata: 422,
|
|
14
|
+
fetch_failed: 502,
|
|
15
|
+
};
|
|
16
|
+
export async function handleLinkPreview(req, res) {
|
|
17
|
+
if (req.method !== "GET") {
|
|
18
|
+
res.statusCode = 405;
|
|
19
|
+
res.setHeader("Content-Type", "application/json");
|
|
20
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (!extractBearerToken(req)) {
|
|
24
|
+
res.statusCode = 401;
|
|
25
|
+
res.setHeader("Content-Type", "application/json");
|
|
26
|
+
res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
const url = new URL(req.url ?? "/", "http://localhost").searchParams.get("url")?.trim();
|
|
30
|
+
if (!url) {
|
|
31
|
+
res.statusCode = 400;
|
|
32
|
+
res.setHeader("Content-Type", "application/json");
|
|
33
|
+
res.end(JSON.stringify({ ok: false, error: "invalid_url" }));
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const result = await getLinkPreview(url);
|
|
37
|
+
res.statusCode = result.ok ? 200 : ERROR_STATUS[result.error];
|
|
38
|
+
res.setHeader("Content-Type", "application/json");
|
|
39
|
+
res.end(JSON.stringify(result));
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -47,4 +47,9 @@ export interface FridayMessagePayload {
|
|
|
47
47
|
reasoningLevel?: string;
|
|
48
48
|
thinkingLevel?: string;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* 把可选文本与媒体引用拼成给 agent 的最终 body。纯附件(文本为空)场景下
|
|
52
|
+
* 不能带前导空行,否则 agent 收到的是 `\n\n[media…]`——故拆成可测纯函数。
|
|
53
|
+
*/
|
|
54
|
+
export declare function composeBodyWithMediaRefs(text: string, mediaRefs: string[]): string;
|
|
50
55
|
export declare function handleMessages(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -262,6 +262,16 @@ async function resolveRunMetadataFromRuntimeSession(runtime, sessionKey) {
|
|
|
262
262
|
}
|
|
263
263
|
return null;
|
|
264
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* 把可选文本与媒体引用拼成给 agent 的最终 body。纯附件(文本为空)场景下
|
|
267
|
+
* 不能带前导空行,否则 agent 收到的是 `\n\n[media…]`——故拆成可测纯函数。
|
|
268
|
+
*/
|
|
269
|
+
export function composeBodyWithMediaRefs(text, mediaRefs) {
|
|
270
|
+
const trimmed = text.trim();
|
|
271
|
+
if (mediaRefs.length === 0)
|
|
272
|
+
return trimmed;
|
|
273
|
+
return trimmed ? `${trimmed}\n\n${mediaRefs.join("\n")}` : mediaRefs.join("\n");
|
|
274
|
+
}
|
|
265
275
|
async function buildBodyForAgentWithAttachments(text, attachmentIds) {
|
|
266
276
|
if (attachmentIds.length === 0)
|
|
267
277
|
return text.trim();
|
|
@@ -275,9 +285,7 @@ async function buildBodyForAgentWithAttachments(text, attachmentIds) {
|
|
|
275
285
|
mediaRefs.push(`[media attached: file://${saved.path}]`);
|
|
276
286
|
}
|
|
277
287
|
}
|
|
278
|
-
|
|
279
|
-
return text.trim();
|
|
280
|
-
return `${text.trim()}\n\n${mediaRefs.join("\n")}`;
|
|
288
|
+
return composeBodyWithMediaRefs(text, mediaRefs);
|
|
281
289
|
}
|
|
282
290
|
export async function handleMessages(req, res) {
|
|
283
291
|
if (req.method !== "POST") {
|
|
@@ -320,14 +328,17 @@ export async function handleMessages(req, res) {
|
|
|
320
328
|
res.end(JSON.stringify({ error: "Missing required field: deviceId" }));
|
|
321
329
|
return true;
|
|
322
330
|
}
|
|
323
|
-
|
|
324
|
-
|
|
331
|
+
// 允许"只发附件、不发文本":text 与 attachments 不能同时为空,但任一非空即放行。
|
|
332
|
+
const hasText = Boolean(text && text.trim());
|
|
333
|
+
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
|
|
334
|
+
if (!hasText && !hasAttachments) {
|
|
335
|
+
log("BAD_REQUEST", normalizedDeviceId, undefined, "missing text and attachments", "warn");
|
|
325
336
|
res.statusCode = 400;
|
|
326
337
|
res.setHeader("Content-Type", "application/json");
|
|
327
|
-
res.end(JSON.stringify({ error: "Missing required field: text" }));
|
|
338
|
+
res.end(JSON.stringify({ error: "Missing required field: text or attachments" }));
|
|
328
339
|
return true;
|
|
329
340
|
}
|
|
330
|
-
const trimmedText = text.trim();
|
|
341
|
+
const trimmedText = (text ?? "").trim();
|
|
331
342
|
touchFridayInbound();
|
|
332
343
|
const isSlashCommand = trimmedText.startsWith("/");
|
|
333
344
|
const runId = crypto.randomUUID();
|
|
@@ -363,7 +374,7 @@ export async function handleMessages(req, res) {
|
|
|
363
374
|
registerFridaySessionDeviceMapping(appSessionKey, normalizedDeviceId);
|
|
364
375
|
sseEmitter.trackDeviceForRun(normalizedDeviceId, runId);
|
|
365
376
|
registerRunRoute({ runId, deviceId: normalizedDeviceId, sessionKey: baseSessionKey });
|
|
366
|
-
const bodyForAgent = await buildBodyForAgentWithAttachments(
|
|
377
|
+
const bodyForAgent = await buildBodyForAgentWithAttachments(trimmedText, attachments);
|
|
367
378
|
const msgContext = {
|
|
368
379
|
Body: trimmedText,
|
|
369
380
|
BodyForAgent: bodyForAgent,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export interface PluginInfoResult {
|
|
3
|
+
currentVersion: string;
|
|
4
|
+
latestVersion: string | null;
|
|
5
|
+
installSource: string;
|
|
6
|
+
/** True when the install is npm-managed (the only auto-upgradable source). */
|
|
7
|
+
canAutoUpgrade: boolean;
|
|
8
|
+
/** True when a newer version is published AND the install can be auto-upgraded. */
|
|
9
|
+
upgradable: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function handlePluginInfo(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
2
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
3
|
+
import { fetchLatestVersion, getInstallSource, semverGreater, } from "../../plugin-install-info.js";
|
|
4
|
+
export async function handlePluginInfo(req, res) {
|
|
5
|
+
if (req.method !== "GET") {
|
|
6
|
+
res.statusCode = 405;
|
|
7
|
+
res.setHeader("Content-Type", "application/json");
|
|
8
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (!extractBearerToken(req)) {
|
|
12
|
+
res.statusCode = 401;
|
|
13
|
+
res.setHeader("Content-Type", "application/json");
|
|
14
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const installSource = getInstallSource();
|
|
18
|
+
const canAutoUpgrade = installSource === "npm";
|
|
19
|
+
const latestVersion = await fetchLatestVersion(Date.now());
|
|
20
|
+
const upgradable = canAutoUpgrade && semverGreater(latestVersion, PLUGIN_VERSION);
|
|
21
|
+
const result = {
|
|
22
|
+
currentVersion: PLUGIN_VERSION,
|
|
23
|
+
latestVersion,
|
|
24
|
+
installSource,
|
|
25
|
+
canAutoUpgrade,
|
|
26
|
+
upgradable,
|
|
27
|
+
};
|
|
28
|
+
res.statusCode = 200;
|
|
29
|
+
res.setHeader("Content-Type", "application/json");
|
|
30
|
+
res.end(JSON.stringify(result));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
/**
|
|
3
|
+
* POST /friday-next/plugin/upgrade
|
|
4
|
+
*
|
|
5
|
+
* Runs `openclaw plugins install @syengup/friday-channel-next@latest --force`
|
|
6
|
+
* (registry-aware, updates the install record), responds 202, then triggers a
|
|
7
|
+
* safe gateway restart so the new version loads. Only npm-installed plugins are
|
|
8
|
+
* eligible — dev (load.paths / source==="path") installs return 409 to protect
|
|
9
|
+
* the dev environment from duplicate npm installs.
|
|
10
|
+
*/
|
|
11
|
+
export declare function handlePluginUpgrade(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
2
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
3
|
+
import { PLUGIN_PACKAGE_NAME, PLUGIN_VERSION } from "../../version.js";
|
|
4
|
+
import { getInstallSource } from "../../plugin-install-info.js";
|
|
5
|
+
import { getUpgradeRuntime } from "../../upgrade-runtime.js";
|
|
6
|
+
const UPGRADE_TIMEOUT_MS = 120_000;
|
|
7
|
+
/** Give the 202 response time to flush before the restart kills the process. */
|
|
8
|
+
const RESTART_DELAY_MS = 500;
|
|
9
|
+
/**
|
|
10
|
+
* POST /friday-next/plugin/upgrade
|
|
11
|
+
*
|
|
12
|
+
* Runs `openclaw plugins install @syengup/friday-channel-next@latest --force`
|
|
13
|
+
* (registry-aware, updates the install record), responds 202, then triggers a
|
|
14
|
+
* safe gateway restart so the new version loads. Only npm-installed plugins are
|
|
15
|
+
* eligible — dev (load.paths / source==="path") installs return 409 to protect
|
|
16
|
+
* the dev environment from duplicate npm installs.
|
|
17
|
+
*/
|
|
18
|
+
export async function handlePluginUpgrade(req, res) {
|
|
19
|
+
if (req.method !== "POST") {
|
|
20
|
+
res.statusCode = 405;
|
|
21
|
+
res.setHeader("Content-Type", "application/json");
|
|
22
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (!extractBearerToken(req)) {
|
|
26
|
+
res.statusCode = 401;
|
|
27
|
+
res.setHeader("Content-Type", "application/json");
|
|
28
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const log = createFridayNextLogger("upgrade");
|
|
32
|
+
const installSource = getInstallSource();
|
|
33
|
+
if (installSource !== "npm") {
|
|
34
|
+
res.statusCode = 409;
|
|
35
|
+
res.setHeader("Content-Type", "application/json");
|
|
36
|
+
res.end(JSON.stringify({
|
|
37
|
+
error: "auto-upgrade not available",
|
|
38
|
+
detail: `install source is "${installSource}"; only npm installs can be auto-upgraded`,
|
|
39
|
+
installSource,
|
|
40
|
+
}));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
const rt = getUpgradeRuntime();
|
|
44
|
+
if (!rt) {
|
|
45
|
+
res.statusCode = 500;
|
|
46
|
+
res.setHeader("Content-Type", "application/json");
|
|
47
|
+
res.end(JSON.stringify({ error: "upgrade runtime unavailable" }));
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const spec = `${PLUGIN_PACKAGE_NAME}@latest`;
|
|
51
|
+
log.info(`Starting plugin upgrade: ${spec} (from ${PLUGIN_VERSION})`);
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await rt.runCommandWithTimeout(["openclaw", "plugins", "install", spec, "--force"], UPGRADE_TIMEOUT_MS);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
58
|
+
log.error(`plugin upgrade command failed to spawn: ${msg}`);
|
|
59
|
+
res.statusCode = 500;
|
|
60
|
+
res.setHeader("Content-Type", "application/json");
|
|
61
|
+
res.end(JSON.stringify({ error: "upgrade command failed", detail: msg }));
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (result.code !== 0) {
|
|
65
|
+
const stderrTail = (result.stderr ?? "").slice(-2000);
|
|
66
|
+
log.error(`plugin upgrade exited code=${result.code}: ${stderrTail}`);
|
|
67
|
+
res.statusCode = 500;
|
|
68
|
+
res.setHeader("Content-Type", "application/json");
|
|
69
|
+
res.end(JSON.stringify({
|
|
70
|
+
error: "upgrade command exited non-zero",
|
|
71
|
+
code: result.code,
|
|
72
|
+
detail: stderrTail,
|
|
73
|
+
}));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
log.info("Plugin upgrade install succeeded; scheduling gateway restart");
|
|
77
|
+
// Respond first so the app receives confirmation before the restart drops the
|
|
78
|
+
// connection, then trigger the safe restart after a short flush delay.
|
|
79
|
+
res.statusCode = 202;
|
|
80
|
+
res.setHeader("Content-Type", "application/json");
|
|
81
|
+
res.end(JSON.stringify({ status: "upgrading", from: PLUGIN_VERSION }));
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
void rt
|
|
84
|
+
.mutateConfigFile({
|
|
85
|
+
afterWrite: { mode: "restart", reason: "friday-next 插件自动升级后重启" },
|
|
86
|
+
mutate: () => { },
|
|
87
|
+
})
|
|
88
|
+
.catch((err) => {
|
|
89
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
90
|
+
log.error(`gateway restart trigger failed: ${msg}`);
|
|
91
|
+
});
|
|
92
|
+
}, RESTART_DELAY_MS).unref?.();
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
@@ -3,6 +3,7 @@ import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
|
3
3
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
4
4
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
5
5
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
6
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
6
7
|
function parseLastEventId(req, url) {
|
|
7
8
|
const query = Number.parseInt(url.searchParams.get("lastEventId") ?? "", 10);
|
|
8
9
|
if (Number.isFinite(query))
|
|
@@ -48,6 +49,7 @@ export async function handleSseStream(req, res) {
|
|
|
48
49
|
deviceId: normalized,
|
|
49
50
|
serverTime: Date.now(),
|
|
50
51
|
lastSeq,
|
|
52
|
+
pluginVersion: PLUGIN_VERSION,
|
|
51
53
|
},
|
|
52
54
|
}, deviceId, true);
|
|
53
55
|
const lastEventId = parseLastEventId(req, url);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getActiveRunIds } from "../../agent/active-runs.js";
|
|
2
2
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
3
3
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
4
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
4
5
|
export async function handleStatus(req, res) {
|
|
5
6
|
if (req.method !== "GET") {
|
|
6
7
|
res.statusCode = 405;
|
|
@@ -21,6 +22,7 @@ export async function handleStatus(req, res) {
|
|
|
21
22
|
ok: true,
|
|
22
23
|
channel: "friday-next",
|
|
23
24
|
version: "v2",
|
|
25
|
+
pluginVersion: PLUGIN_VERSION,
|
|
24
26
|
connections: sseEmitter.getConnectionCount(),
|
|
25
27
|
activeRuns,
|
|
26
28
|
activeRunCount: activeRuns.length,
|
package/dist/src/http/server.js
CHANGED
|
@@ -18,7 +18,10 @@ import { handleHistorySessions } from "./handlers/history-sessions.js";
|
|
|
18
18
|
import { handleHistoryMessages } from "./handlers/history-messages.js";
|
|
19
19
|
import { handleHistorySetTitle } from "./handlers/history-set-title.js";
|
|
20
20
|
import { handleStatus } from "./handlers/status.js";
|
|
21
|
+
import { handleLinkPreview } from "./handlers/link-preview.js";
|
|
21
22
|
import { handleHealth } from "./handlers/health.js";
|
|
23
|
+
import { handlePluginInfo } from "./handlers/plugin-info.js";
|
|
24
|
+
import { handlePluginUpgrade } from "./handlers/plugin-upgrade.js";
|
|
22
25
|
import { applyCorsHeaders } from "./middleware/cors.js";
|
|
23
26
|
import { resolveFridayNextConfig } from "../config.js";
|
|
24
27
|
import { getHostOpenClawConfigSnapshot } from "../host-config.js";
|
|
@@ -83,10 +86,22 @@ async function handleFridayNextRoute(req, res) {
|
|
|
83
86
|
if ((req.method === "PUT" || req.method === "POST") && pathname === "/friday-next/sessions/title") {
|
|
84
87
|
return await handleHistorySetTitle(req, res);
|
|
85
88
|
}
|
|
89
|
+
// Route: GET /friday-next/link-preview?url=... (Open Graph metadata for preview cards)
|
|
90
|
+
if (req.method === "GET" && pathname === "/friday-next/link-preview") {
|
|
91
|
+
return await handleLinkPreview(req, res);
|
|
92
|
+
}
|
|
86
93
|
// Route: GET /friday-next/health?deviceId=...&nodeDeviceId=...&selfHeal=true
|
|
87
94
|
if (req.method === "GET" && pathname === "/friday-next/health") {
|
|
88
95
|
return await handleHealth(req, res);
|
|
89
96
|
}
|
|
97
|
+
// Route: GET /friday-next/plugin/info (current/latest version + upgradability)
|
|
98
|
+
if (req.method === "GET" && pathname === "/friday-next/plugin/info") {
|
|
99
|
+
return await handlePluginInfo(req, res);
|
|
100
|
+
}
|
|
101
|
+
// Route: POST /friday-next/plugin/upgrade (npm install @latest + safe gateway restart)
|
|
102
|
+
if (req.method === "POST" && pathname === "/friday-next/plugin/upgrade") {
|
|
103
|
+
return await handlePluginUpgrade(req, res);
|
|
104
|
+
}
|
|
90
105
|
// Not found
|
|
91
106
|
return false;
|
|
92
107
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Graph metadata extraction via regex — no HTML parser dependency.
|
|
3
|
+
*
|
|
4
|
+
* Good enough for the link-preview card use case: og:* meta tags are flat, attribute-ordered
|
|
5
|
+
* variants are handled generically, and pages where this fails simply degrade to "no card".
|
|
6
|
+
*/
|
|
7
|
+
export interface OpenGraphResult {
|
|
8
|
+
title: string | null;
|
|
9
|
+
description: string | null;
|
|
10
|
+
imageUrl: string | null;
|
|
11
|
+
siteName: string | null;
|
|
12
|
+
/** Favicon URL parsed from `<link rel="...icon...">`, resolved absolute. */
|
|
13
|
+
iconUrl: string | null;
|
|
14
|
+
}
|
|
15
|
+
export declare function decodeHtmlEntities(s: string): string;
|
|
16
|
+
export declare function parseOpenGraph(html: string, baseUrl: string): OpenGraphResult;
|
|
17
|
+
/**
|
|
18
|
+
* Pick the best `<link rel="...icon...">` href. Prefers a high-res `apple-touch-icon`, then a
|
|
19
|
+
* regular `icon` / `shortcut icon`. Skips `mask-icon` (monochrome SVG). Returns absolute http(s).
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseFaviconUrl(html: string, baseUrl: string): string | null;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Graph metadata extraction via regex — no HTML parser dependency.
|
|
3
|
+
*
|
|
4
|
+
* Good enough for the link-preview card use case: og:* meta tags are flat, attribute-ordered
|
|
5
|
+
* variants are handled generically, and pages where this fails simply degrade to "no card".
|
|
6
|
+
*/
|
|
7
|
+
const MAX_PARSE_BYTES = 512 * 1024;
|
|
8
|
+
const META_TAG_RE = /<meta\b[^>]*>/gi;
|
|
9
|
+
const TITLE_TAG_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
|
10
|
+
const LINK_TAG_RE = /<link\b[^>]*>/gi;
|
|
11
|
+
/** Extract one attribute value from a tag, tolerating single/double/no quotes and any order. */
|
|
12
|
+
function attributeValue(tag, name) {
|
|
13
|
+
const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
|
|
14
|
+
const m = tag.match(re);
|
|
15
|
+
if (!m)
|
|
16
|
+
return null;
|
|
17
|
+
return m[1] ?? m[2] ?? m[3] ?? "";
|
|
18
|
+
}
|
|
19
|
+
const NAMED_ENTITIES = {
|
|
20
|
+
amp: "&",
|
|
21
|
+
lt: "<",
|
|
22
|
+
gt: ">",
|
|
23
|
+
quot: '"',
|
|
24
|
+
apos: "'",
|
|
25
|
+
nbsp: " ",
|
|
26
|
+
ndash: "–",
|
|
27
|
+
mdash: "—",
|
|
28
|
+
hellip: "…",
|
|
29
|
+
middot: "·",
|
|
30
|
+
copy: "©",
|
|
31
|
+
reg: "®",
|
|
32
|
+
trade: "™",
|
|
33
|
+
lsquo: "‘",
|
|
34
|
+
rsquo: "’",
|
|
35
|
+
ldquo: "“",
|
|
36
|
+
rdquo: "”",
|
|
37
|
+
laquo: "«",
|
|
38
|
+
raquo: "»",
|
|
39
|
+
};
|
|
40
|
+
export function decodeHtmlEntities(s) {
|
|
41
|
+
return s.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (whole, body) => {
|
|
42
|
+
if (body.startsWith("#x") || body.startsWith("#X")) {
|
|
43
|
+
const code = Number.parseInt(body.slice(2), 16);
|
|
44
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
|
45
|
+
}
|
|
46
|
+
if (body.startsWith("#")) {
|
|
47
|
+
const code = Number.parseInt(body.slice(1), 10);
|
|
48
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
|
49
|
+
}
|
|
50
|
+
return NAMED_ENTITIES[body.toLowerCase()] ?? whole;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function cleanText(raw) {
|
|
54
|
+
if (raw == null)
|
|
55
|
+
return null;
|
|
56
|
+
const text = decodeHtmlEntities(raw).replace(/\s+/g, " ").trim();
|
|
57
|
+
return text || null;
|
|
58
|
+
}
|
|
59
|
+
/** Resolve og:image (possibly relative) against the final page URL; only http(s) survives. */
|
|
60
|
+
function resolveImageUrl(raw, baseUrl) {
|
|
61
|
+
if (!raw)
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(raw.trim(), baseUrl);
|
|
65
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
66
|
+
return null;
|
|
67
|
+
return url.toString();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function parseOpenGraph(html, baseUrl) {
|
|
74
|
+
const slice = html.length > MAX_PARSE_BYTES ? html.slice(0, MAX_PARSE_BYTES) : html;
|
|
75
|
+
// First occurrence wins per key (matches browser/crawler behavior).
|
|
76
|
+
const og = {};
|
|
77
|
+
const tw = {};
|
|
78
|
+
let metaDescription = null;
|
|
79
|
+
for (const match of slice.matchAll(META_TAG_RE)) {
|
|
80
|
+
const tag = match[0];
|
|
81
|
+
const key = (attributeValue(tag, "property") ?? attributeValue(tag, "name"))?.trim().toLowerCase();
|
|
82
|
+
if (!key)
|
|
83
|
+
continue;
|
|
84
|
+
const content = attributeValue(tag, "content");
|
|
85
|
+
if (content == null || !content.trim())
|
|
86
|
+
continue;
|
|
87
|
+
if (key.startsWith("og:")) {
|
|
88
|
+
const ogKey = key.slice(3);
|
|
89
|
+
if (!(ogKey in og))
|
|
90
|
+
og[ogKey] = content;
|
|
91
|
+
}
|
|
92
|
+
else if (key.startsWith("twitter:")) {
|
|
93
|
+
const twKey = key.slice(8);
|
|
94
|
+
if (!(twKey in tw))
|
|
95
|
+
tw[twKey] = content;
|
|
96
|
+
}
|
|
97
|
+
else if (key === "description" && metaDescription == null) {
|
|
98
|
+
metaDescription = content;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const ld = parseJsonLd(slice);
|
|
102
|
+
const pageTitle = slice.match(TITLE_TAG_RE)?.[1] ?? null;
|
|
103
|
+
// Title chain: standard tags first, then server-rendered body title (h1 / article-title class)
|
|
104
|
+
// BEFORE the generic <title> — many SPA/news shells put a useless <title> ("搜索资讯页") in the
|
|
105
|
+
// head while the real headline lives in the body.
|
|
106
|
+
const title = cleanText(og["title"]) ??
|
|
107
|
+
cleanText(tw["title"]) ??
|
|
108
|
+
cleanText(ld.title) ??
|
|
109
|
+
cleanText(parseBodyTitle(slice)) ??
|
|
110
|
+
cleanText(pageTitle);
|
|
111
|
+
const description = cleanText(og["description"]) ??
|
|
112
|
+
cleanText(tw["description"]) ??
|
|
113
|
+
cleanText(ld.description) ??
|
|
114
|
+
cleanText(metaDescription);
|
|
115
|
+
const imageUrl = resolveImageUrl(og["image"] ?? null, baseUrl) ??
|
|
116
|
+
resolveImageUrl(tw["image"] ?? null, baseUrl) ??
|
|
117
|
+
resolveImageUrl(ld.image, baseUrl) ??
|
|
118
|
+
resolveImageUrl(parseBodyCoverImage(slice), baseUrl);
|
|
119
|
+
return {
|
|
120
|
+
title,
|
|
121
|
+
description,
|
|
122
|
+
imageUrl,
|
|
123
|
+
siteName: cleanText(og["site_name"] ?? tw["site"] ?? null),
|
|
124
|
+
iconUrl: parseFaviconUrl(slice, baseUrl),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const JSON_LD_RE = /<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
128
|
+
/** Extract title/description/image from JSON-LD blocks (schema.org Article/NewsArticle/etc.). */
|
|
129
|
+
function parseJsonLd(html) {
|
|
130
|
+
for (const match of html.matchAll(JSON_LD_RE)) {
|
|
131
|
+
let data;
|
|
132
|
+
try {
|
|
133
|
+
data = JSON.parse(match[1].trim());
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// JSON-LD may be a single object, an array, or a @graph container.
|
|
139
|
+
const nodes = Array.isArray(data)
|
|
140
|
+
? data
|
|
141
|
+
: isRecord(data) && Array.isArray(data["@graph"])
|
|
142
|
+
? data["@graph"]
|
|
143
|
+
: [data];
|
|
144
|
+
for (const node of nodes) {
|
|
145
|
+
if (!isRecord(node))
|
|
146
|
+
continue;
|
|
147
|
+
const title = asString(node.headline) ?? asString(node.name);
|
|
148
|
+
const description = asString(node.description);
|
|
149
|
+
const image = firstImage(node.image) ?? asString(node.thumbnailUrl);
|
|
150
|
+
if (title || description || image) {
|
|
151
|
+
return { title: title ?? null, description: description ?? null, image: image ?? null };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { title: null, description: null, image: null };
|
|
156
|
+
}
|
|
157
|
+
function isRecord(v) {
|
|
158
|
+
return typeof v === "object" && v !== null;
|
|
159
|
+
}
|
|
160
|
+
function asString(v) {
|
|
161
|
+
return typeof v === "string" && v.trim() ? v : null;
|
|
162
|
+
}
|
|
163
|
+
/** JSON-LD `image` is a string, an array, or an ImageObject `{ url }`. */
|
|
164
|
+
function firstImage(v) {
|
|
165
|
+
if (typeof v === "string")
|
|
166
|
+
return v;
|
|
167
|
+
if (Array.isArray(v)) {
|
|
168
|
+
for (const item of v) {
|
|
169
|
+
const found = firstImage(item);
|
|
170
|
+
if (found)
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (isRecord(v))
|
|
176
|
+
return asString(v.url);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
// Common server-rendered article-title class names (whitelist keeps false positives down vs. any
|
|
180
|
+
// class containing "title", e.g. a sidebar "related-titles" block).
|
|
181
|
+
const BODY_TITLE_CLASS_RE = /class\s*=\s*["'][^"']*\b(?:article-title|post-title|entry-title|news-title|content-title|headline|title-text)\b[^"']*["'][^>]*>\s*([^<]{4,200}?)\s*</i;
|
|
182
|
+
const H1_RE = /<h1\b[^>]*>\s*([\s\S]{4,200}?)\s*<\/h1>/i;
|
|
183
|
+
/** Server-rendered headline fallback: first <h1>, else an element with a known article-title class. */
|
|
184
|
+
function parseBodyTitle(html) {
|
|
185
|
+
const h1 = html.match(H1_RE)?.[1];
|
|
186
|
+
if (h1) {
|
|
187
|
+
const text = stripTags(h1).trim();
|
|
188
|
+
if (text.length >= 4)
|
|
189
|
+
return text;
|
|
190
|
+
}
|
|
191
|
+
return html.match(BODY_TITLE_CLASS_RE)?.[1] ?? null;
|
|
192
|
+
}
|
|
193
|
+
// Cover image embedded in inline JSON (e.g. QQ's `"imgUrl":"http:\/\/...cover..."`). The URL may be
|
|
194
|
+
// extensionless; the re-host step's magic-byte sniff is the safety net against non-image matches.
|
|
195
|
+
const JSON_COVER_RE = /"(?:imgUrl|imageUrl|coverUrl|coverImage|cover|ogImage|thumbnail|picUrl)"\s*:\s*"(https?:(?:\\?\/){2}[^"]+?)"/i;
|
|
196
|
+
/** Cover image from inline JSON when no og/twitter/json-ld image is present. */
|
|
197
|
+
function parseBodyCoverImage(html) {
|
|
198
|
+
const raw = html.match(JSON_COVER_RE)?.[1];
|
|
199
|
+
if (!raw)
|
|
200
|
+
return null;
|
|
201
|
+
return raw.replace(/\\\//g, "/"); // unescape JSON `\/`
|
|
202
|
+
}
|
|
203
|
+
function stripTags(s) {
|
|
204
|
+
return s.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ");
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Pick the best `<link rel="...icon...">` href. Prefers a high-res `apple-touch-icon`, then a
|
|
208
|
+
* regular `icon` / `shortcut icon`. Skips `mask-icon` (monochrome SVG). Returns absolute http(s).
|
|
209
|
+
*/
|
|
210
|
+
export function parseFaviconUrl(html, baseUrl) {
|
|
211
|
+
let appleTouch = null;
|
|
212
|
+
let regular = null;
|
|
213
|
+
for (const match of html.matchAll(LINK_TAG_RE)) {
|
|
214
|
+
const tag = match[0];
|
|
215
|
+
const rel = attributeValue(tag, "rel")?.trim().toLowerCase();
|
|
216
|
+
if (!rel || !rel.includes("icon") || rel.includes("mask-icon"))
|
|
217
|
+
continue;
|
|
218
|
+
const href = attributeValue(tag, "href");
|
|
219
|
+
if (!href)
|
|
220
|
+
continue;
|
|
221
|
+
const resolved = resolveImageUrl(href, baseUrl);
|
|
222
|
+
if (!resolved)
|
|
223
|
+
continue;
|
|
224
|
+
if (rel.includes("apple-touch-icon")) {
|
|
225
|
+
appleTouch ??= resolved;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
regular ??= resolved;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return appleTouch ?? regular;
|
|
232
|
+
}
|