@syengup/friday-channel-next 0.1.25 → 0.1.27
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/health.d.ts +1 -0
- package/dist/src/http/handlers/health.js +2 -0
- package/dist/src/http/handlers/history-messages.js +50 -9
- 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 +10 -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/health.ts +3 -0
- package/src/http/handlers/history-messages.test.ts +33 -0
- package/src/http/handlers/history-messages.ts +46 -8
- 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 +12 -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
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
3
3
|
import { loadNodePairingModule } from "../../agent/node-pairing-bridge.js";
|
|
4
4
|
import { createFridayNextLogger } from "../../logging.js";
|
|
5
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
5
6
|
|
|
6
7
|
const REQUIRED_NODE_CAPS = ["location", "canvas"];
|
|
7
8
|
const REQUIRED_NODE_COMMANDS = [
|
|
@@ -34,6 +35,7 @@ export interface HealthCheckResult {
|
|
|
34
35
|
timestamp: number;
|
|
35
36
|
deviceId: string;
|
|
36
37
|
nodeDeviceId: string;
|
|
38
|
+
pluginVersion: string;
|
|
37
39
|
nodePairing?: HealthComponentStatus;
|
|
38
40
|
repairActions?: RepairAction[];
|
|
39
41
|
}
|
|
@@ -64,6 +66,7 @@ export async function handleHealth(req: IncomingMessage, res: ServerResponse): P
|
|
|
64
66
|
timestamp: Date.now(),
|
|
65
67
|
deviceId,
|
|
66
68
|
nodeDeviceId,
|
|
69
|
+
pluginVersion: PLUGIN_VERSION,
|
|
67
70
|
};
|
|
68
71
|
|
|
69
72
|
const log = createFridayNextLogger("health");
|
|
@@ -170,6 +170,39 @@ describe("handleHistoryMessages", () => {
|
|
|
170
170
|
expect(body.messages[0].text).toBe("from app");
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
it("resolves user [media attached: file://] markers into downloadable /friday-next/files URLs", async () => {
|
|
174
|
+
// The server-local source the marker points at (only exists on the gateway host).
|
|
175
|
+
const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "friday-inbound-"));
|
|
176
|
+
const srcFile = path.join(srcDir, "ce1ff405-28ad-48b9-b4a7-4f2228d77649.jpg");
|
|
177
|
+
fs.writeFileSync(srcFile, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]));
|
|
178
|
+
const file = writeTranscript("media.jsonl", [
|
|
179
|
+
{
|
|
180
|
+
type: "message",
|
|
181
|
+
id: "u1",
|
|
182
|
+
message: {
|
|
183
|
+
role: "user",
|
|
184
|
+
content: `你见过这个可乐吗?\n\n[media attached: file://${srcFile}]`,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
]);
|
|
188
|
+
setForward({ "agent:main:main": { sessionId: "s", sessionFile: file } });
|
|
189
|
+
|
|
190
|
+
const res = new MockRes();
|
|
191
|
+
await handleHistoryMessages(
|
|
192
|
+
makeReq("/friday-next/history/messages?sessionKey=agent:main:main", AUTH),
|
|
193
|
+
res as any,
|
|
194
|
+
);
|
|
195
|
+
expect(res.statusCode).toBe(200);
|
|
196
|
+
const body = JSON.parse(res.body);
|
|
197
|
+
const userMsg = body.messages.find((m: any) => m.role === "user");
|
|
198
|
+
expect(userMsg.images?.length).toBe(1);
|
|
199
|
+
// The raw server-local file:// path must be resolved to a gateway-served URL the
|
|
200
|
+
// app can actually download — otherwise the attachment bubble is lost on history sync.
|
|
201
|
+
expect(userMsg.images[0].url.startsWith("file://")).toBe(false);
|
|
202
|
+
expect(userMsg.images[0].url.startsWith("/friday-next/files/")).toBe(true);
|
|
203
|
+
fs.rmSync(srcDir, { recursive: true, force: true });
|
|
204
|
+
});
|
|
205
|
+
|
|
173
206
|
it("falls back to getSessionMessages when the transcript is not on disk", async () => {
|
|
174
207
|
setForward({}); // no entry → disk read yields nothing
|
|
175
208
|
setRuntime(async () => ({
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
14
15
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
15
16
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
16
17
|
import { normalizeHistoryMessages } from "../../history/normalize-message.js";
|
|
@@ -28,6 +29,26 @@ type SubagentSessionApi = {
|
|
|
28
29
|
}) => Promise<{ messages?: unknown[] }>;
|
|
29
30
|
};
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* For an `images[].url` produced from a `[media attached: …]` marker: returns the
|
|
34
|
+
* server-local filesystem path to resolve (`file://…` or a bare absolute path), or
|
|
35
|
+
* null to leave the image untouched. Already-served `/friday-next/files/…` URLs and
|
|
36
|
+
* remote `http(s)://` / `data:` URLs are NOT local paths — never feed them to
|
|
37
|
+
* `resolveMediaAttachment` (it would mis-treat them as paths and break valid URLs).
|
|
38
|
+
*/
|
|
39
|
+
function serverLocalPathForImageUrl(url: string): string | null {
|
|
40
|
+
if (url.startsWith("file://")) {
|
|
41
|
+
try {
|
|
42
|
+
return fileURLToPath(url);
|
|
43
|
+
} catch {
|
|
44
|
+
return url.slice("file://".length);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (url.startsWith("/friday-next/files/")) return null;
|
|
48
|
+
if (url.startsWith("/")) return url;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
function resolveSubagentApi(): SubagentSessionApi | undefined {
|
|
32
53
|
try {
|
|
33
54
|
const runtime = getFridayNextRuntime();
|
|
@@ -95,15 +116,32 @@ export async function handleHistoryMessages(
|
|
|
95
116
|
// (copies the file into the plugin's attachments/ dir — the same mechanism the
|
|
96
117
|
// live deliver path uses), then drop the raw paths from the wire.
|
|
97
118
|
for (const message of messages) {
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
119
|
+
if (message.mediaPaths?.length) {
|
|
120
|
+
const resolved = message.mediaPaths
|
|
121
|
+
.map((p) => resolveMediaAttachment(p))
|
|
122
|
+
.filter((r): r is NonNullable<typeof r> => Boolean(r))
|
|
123
|
+
.map((r) => ({ url: r.url, filename: r.fileName }));
|
|
124
|
+
if (resolved.length) {
|
|
125
|
+
message.images = [...(message.images ?? []), ...resolved];
|
|
126
|
+
}
|
|
127
|
+
delete message.mediaPaths;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// User attachments arrive as `[media attached: file://<server-path>]` markers,
|
|
131
|
+
// which normalize-message extracts into images[].url as a RAW server-local path.
|
|
132
|
+
// Unlike MEDIA: paths (resolved above), these were never copied into the file
|
|
133
|
+
// store, so the app would try to load a path that only exists on the gateway host
|
|
134
|
+
// and the attachment bubble is lost on history sync. Resolve them the same way.
|
|
135
|
+
if (message.images?.length) {
|
|
136
|
+
message.images = message.images.map((img) => {
|
|
137
|
+
if (!img.url || img.data) return img;
|
|
138
|
+
const local = serverLocalPathForImageUrl(img.url);
|
|
139
|
+
if (!local) return img;
|
|
140
|
+
const resolved = resolveMediaAttachment(local);
|
|
141
|
+
if (!resolved) return img;
|
|
142
|
+
return { ...img, url: resolved.url, filename: img.filename ?? resolved.fileName };
|
|
143
|
+
});
|
|
105
144
|
}
|
|
106
|
-
delete message.mediaPaths;
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
const sessionId = resolveSessionId(sessionKey);
|
|
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import { PassThrough } from "node:stream";
|
|
4
4
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
5
|
-
import { handleMessages } from "./messages.js";
|
|
5
|
+
import { handleMessages, composeBodyWithMediaRefs } from "./messages.js";
|
|
6
6
|
import { clearFridayNextRuntime, setFridayNextRuntime } from "../../runtime.js";
|
|
7
7
|
import {
|
|
8
8
|
__resetMockFridayDispatchForTests,
|
|
@@ -23,6 +23,24 @@ class MockRes extends EventEmitter {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
describe("composeBodyWithMediaRefs", () => {
|
|
27
|
+
it("returns trimmed text alone when no media refs", () => {
|
|
28
|
+
expect(composeBodyWithMediaRefs(" hi ", [])).toBe("hi");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("joins text and media refs with a blank line", () => {
|
|
32
|
+
expect(composeBodyWithMediaRefs("hi", ["[media attached: file:///a]"])).toBe(
|
|
33
|
+
"hi\n\n[media attached: file:///a]",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("omits the leading blank line when text is empty (attachment-only)", () => {
|
|
38
|
+
expect(composeBodyWithMediaRefs("", ["[media attached: file:///a]", "[media attached: file:///b]"])).toBe(
|
|
39
|
+
"[media attached: file:///a]\n[media attached: file:///b]",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
26
44
|
describe("handleMessages dispatch context (owner fields)", () => {
|
|
27
45
|
afterEach(() => {
|
|
28
46
|
clearFridayNextRuntime();
|
|
@@ -70,6 +88,62 @@ describe("handleMessages dispatch context (owner fields)", () => {
|
|
|
70
88
|
expect(capturedCtx!.From).toBe(want);
|
|
71
89
|
});
|
|
72
90
|
|
|
91
|
+
it("accepts attachment-only messages (empty text + attachments) and dispatches", async () => {
|
|
92
|
+
setFridayNextRuntime({
|
|
93
|
+
config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
|
|
94
|
+
} as never);
|
|
95
|
+
|
|
96
|
+
let dispatched = false;
|
|
97
|
+
const dispatchCalled = new Promise<void>((resolve) => {
|
|
98
|
+
__setMockFridayDispatchForTests(() => {
|
|
99
|
+
dispatched = true;
|
|
100
|
+
resolve();
|
|
101
|
+
return Promise.resolve();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const req = new PassThrough() as unknown as IncomingMessage;
|
|
106
|
+
req.method = "POST";
|
|
107
|
+
req.headers = { authorization: "Bearer tok" };
|
|
108
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
109
|
+
const p = handleMessages(req, res);
|
|
110
|
+
|
|
111
|
+
req.end(
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
deviceId: "AA11",
|
|
114
|
+
text: "",
|
|
115
|
+
attachments: ["att-1"],
|
|
116
|
+
sessionKey: "default",
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await p;
|
|
121
|
+
await dispatchCalled;
|
|
122
|
+
|
|
123
|
+
expect((res as unknown as MockRes).statusCode).toBe(202);
|
|
124
|
+
expect(dispatched).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rejects messages with neither text nor attachments", async () => {
|
|
128
|
+
setFridayNextRuntime({
|
|
129
|
+
config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
|
|
130
|
+
} as never);
|
|
131
|
+
|
|
132
|
+
const req = new PassThrough() as unknown as IncomingMessage;
|
|
133
|
+
req.method = "POST";
|
|
134
|
+
req.headers = { authorization: "Bearer tok" };
|
|
135
|
+
const res = new MockRes() as unknown as ServerResponse;
|
|
136
|
+
const p = handleMessages(req, res);
|
|
137
|
+
|
|
138
|
+
req.end(
|
|
139
|
+
JSON.stringify({ deviceId: "AA11", text: " ", attachments: [], sessionKey: "default" }),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
await p;
|
|
143
|
+
|
|
144
|
+
expect((res as unknown as MockRes).statusCode).toBe(400);
|
|
145
|
+
});
|
|
146
|
+
|
|
73
147
|
it("adds fridayNext mediaKind metadata for audio deliver payload", async () => {
|
|
74
148
|
setFridayNextRuntime({
|
|
75
149
|
config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
|
|
@@ -362,6 +362,16 @@ export interface FridayMessagePayload {
|
|
|
362
362
|
thinkingLevel?: string;
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
/**
|
|
366
|
+
* 把可选文本与媒体引用拼成给 agent 的最终 body。纯附件(文本为空)场景下
|
|
367
|
+
* 不能带前导空行,否则 agent 收到的是 `\n\n[media…]`——故拆成可测纯函数。
|
|
368
|
+
*/
|
|
369
|
+
export function composeBodyWithMediaRefs(text: string, mediaRefs: string[]): string {
|
|
370
|
+
const trimmed = text.trim();
|
|
371
|
+
if (mediaRefs.length === 0) return trimmed;
|
|
372
|
+
return trimmed ? `${trimmed}\n\n${mediaRefs.join("\n")}` : mediaRefs.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
365
375
|
async function buildBodyForAgentWithAttachments(text: string, attachmentIds: string[]): Promise<string> {
|
|
366
376
|
if (attachmentIds.length === 0) return text.trim();
|
|
367
377
|
|
|
@@ -376,8 +386,7 @@ async function buildBodyForAgentWithAttachments(text: string, attachmentIds: str
|
|
|
376
386
|
}
|
|
377
387
|
}
|
|
378
388
|
|
|
379
|
-
|
|
380
|
-
return `${text.trim()}\n\n${mediaRefs.join("\n")}`;
|
|
389
|
+
return composeBodyWithMediaRefs(text, mediaRefs);
|
|
381
390
|
}
|
|
382
391
|
|
|
383
392
|
export async function handleMessages(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
@@ -428,15 +437,18 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
428
437
|
return true;
|
|
429
438
|
}
|
|
430
439
|
|
|
431
|
-
|
|
432
|
-
|
|
440
|
+
// 允许"只发附件、不发文本":text 与 attachments 不能同时为空,但任一非空即放行。
|
|
441
|
+
const hasText = Boolean(text && text.trim());
|
|
442
|
+
const hasAttachments = Array.isArray(attachments) && attachments.length > 0;
|
|
443
|
+
if (!hasText && !hasAttachments) {
|
|
444
|
+
log("BAD_REQUEST", normalizedDeviceId, undefined, "missing text and attachments", "warn");
|
|
433
445
|
res.statusCode = 400;
|
|
434
446
|
res.setHeader("Content-Type", "application/json");
|
|
435
|
-
res.end(JSON.stringify({ error: "Missing required field: text" }));
|
|
447
|
+
res.end(JSON.stringify({ error: "Missing required field: text or attachments" }));
|
|
436
448
|
return true;
|
|
437
449
|
}
|
|
438
450
|
|
|
439
|
-
const trimmedText = text.trim();
|
|
451
|
+
const trimmedText = (text ?? "").trim();
|
|
440
452
|
touchFridayInbound();
|
|
441
453
|
|
|
442
454
|
const isSlashCommand = trimmedText.startsWith("/");
|
|
@@ -492,7 +504,7 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
492
504
|
sseEmitter.trackDeviceForRun(normalizedDeviceId, runId);
|
|
493
505
|
registerRunRoute({ runId, deviceId: normalizedDeviceId, sessionKey: baseSessionKey });
|
|
494
506
|
|
|
495
|
-
const bodyForAgent = await buildBodyForAgentWithAttachments(
|
|
507
|
+
const bodyForAgent = await buildBodyForAgentWithAttachments(trimmedText, attachments);
|
|
496
508
|
|
|
497
509
|
const msgContext = {
|
|
498
510
|
Body: trimmedText,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
3
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
4
|
+
import {
|
|
5
|
+
fetchLatestVersion,
|
|
6
|
+
getInstallSource,
|
|
7
|
+
semverGreater,
|
|
8
|
+
} from "../../plugin-install-info.js";
|
|
9
|
+
|
|
10
|
+
export interface PluginInfoResult {
|
|
11
|
+
currentVersion: string;
|
|
12
|
+
latestVersion: string | null;
|
|
13
|
+
installSource: string;
|
|
14
|
+
/** True when the install is npm-managed (the only auto-upgradable source). */
|
|
15
|
+
canAutoUpgrade: boolean;
|
|
16
|
+
/** True when a newer version is published AND the install can be auto-upgraded. */
|
|
17
|
+
upgradable: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function handlePluginInfo(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
21
|
+
if (req.method !== "GET") {
|
|
22
|
+
res.statusCode = 405;
|
|
23
|
+
res.setHeader("Content-Type", "application/json");
|
|
24
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (!extractBearerToken(req)) {
|
|
28
|
+
res.statusCode = 401;
|
|
29
|
+
res.setHeader("Content-Type", "application/json");
|
|
30
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const installSource = getInstallSource();
|
|
35
|
+
const canAutoUpgrade = installSource === "npm";
|
|
36
|
+
const latestVersion = await fetchLatestVersion(Date.now());
|
|
37
|
+
const upgradable = canAutoUpgrade && semverGreater(latestVersion, PLUGIN_VERSION);
|
|
38
|
+
|
|
39
|
+
const result: PluginInfoResult = {
|
|
40
|
+
currentVersion: PLUGIN_VERSION,
|
|
41
|
+
latestVersion,
|
|
42
|
+
installSource,
|
|
43
|
+
canAutoUpgrade,
|
|
44
|
+
upgradable,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
res.statusCode = 200;
|
|
48
|
+
res.setHeader("Content-Type", "application/json");
|
|
49
|
+
res.end(JSON.stringify(result));
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
3
|
+
import { createFridayNextLogger } from "../../logging.js";
|
|
4
|
+
import { PLUGIN_PACKAGE_NAME, PLUGIN_VERSION } from "../../version.js";
|
|
5
|
+
import { getInstallSource } from "../../plugin-install-info.js";
|
|
6
|
+
import { getUpgradeRuntime } from "../../upgrade-runtime.js";
|
|
7
|
+
|
|
8
|
+
const UPGRADE_TIMEOUT_MS = 120_000;
|
|
9
|
+
/** Give the 202 response time to flush before the restart kills the process. */
|
|
10
|
+
const RESTART_DELAY_MS = 500;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* POST /friday-next/plugin/upgrade
|
|
14
|
+
*
|
|
15
|
+
* Runs `openclaw plugins install @syengup/friday-channel-next@latest --force`
|
|
16
|
+
* (registry-aware, updates the install record), responds 202, then triggers a
|
|
17
|
+
* safe gateway restart so the new version loads. Only npm-installed plugins are
|
|
18
|
+
* eligible — dev (load.paths / source==="path") installs return 409 to protect
|
|
19
|
+
* the dev environment from duplicate npm installs.
|
|
20
|
+
*/
|
|
21
|
+
export async function handlePluginUpgrade(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
22
|
+
if (req.method !== "POST") {
|
|
23
|
+
res.statusCode = 405;
|
|
24
|
+
res.setHeader("Content-Type", "application/json");
|
|
25
|
+
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (!extractBearerToken(req)) {
|
|
29
|
+
res.statusCode = 401;
|
|
30
|
+
res.setHeader("Content-Type", "application/json");
|
|
31
|
+
res.end(JSON.stringify({ error: "Unauthorized: bearer token mismatch" }));
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const log = createFridayNextLogger("upgrade");
|
|
36
|
+
const installSource = getInstallSource();
|
|
37
|
+
if (installSource !== "npm") {
|
|
38
|
+
res.statusCode = 409;
|
|
39
|
+
res.setHeader("Content-Type", "application/json");
|
|
40
|
+
res.end(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
error: "auto-upgrade not available",
|
|
43
|
+
detail: `install source is "${installSource}"; only npm installs can be auto-upgraded`,
|
|
44
|
+
installSource,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const rt = getUpgradeRuntime();
|
|
51
|
+
if (!rt) {
|
|
52
|
+
res.statusCode = 500;
|
|
53
|
+
res.setHeader("Content-Type", "application/json");
|
|
54
|
+
res.end(JSON.stringify({ error: "upgrade runtime unavailable" }));
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const spec = `${PLUGIN_PACKAGE_NAME}@latest`;
|
|
59
|
+
log.info(`Starting plugin upgrade: ${spec} (from ${PLUGIN_VERSION})`);
|
|
60
|
+
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await rt.runCommandWithTimeout(
|
|
64
|
+
["openclaw", "plugins", "install", spec, "--force"],
|
|
65
|
+
UPGRADE_TIMEOUT_MS,
|
|
66
|
+
);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
69
|
+
log.error(`plugin upgrade command failed to spawn: ${msg}`);
|
|
70
|
+
res.statusCode = 500;
|
|
71
|
+
res.setHeader("Content-Type", "application/json");
|
|
72
|
+
res.end(JSON.stringify({ error: "upgrade command failed", detail: msg }));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (result.code !== 0) {
|
|
77
|
+
const stderrTail = (result.stderr ?? "").slice(-2000);
|
|
78
|
+
log.error(`plugin upgrade exited code=${result.code}: ${stderrTail}`);
|
|
79
|
+
res.statusCode = 500;
|
|
80
|
+
res.setHeader("Content-Type", "application/json");
|
|
81
|
+
res.end(
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
error: "upgrade command exited non-zero",
|
|
84
|
+
code: result.code,
|
|
85
|
+
detail: stderrTail,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
log.info("Plugin upgrade install succeeded; scheduling gateway restart");
|
|
92
|
+
|
|
93
|
+
// Respond first so the app receives confirmation before the restart drops the
|
|
94
|
+
// connection, then trigger the safe restart after a short flush delay.
|
|
95
|
+
res.statusCode = 202;
|
|
96
|
+
res.setHeader("Content-Type", "application/json");
|
|
97
|
+
res.end(JSON.stringify({ status: "upgrading", from: PLUGIN_VERSION }));
|
|
98
|
+
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
void rt
|
|
101
|
+
.mutateConfigFile({
|
|
102
|
+
afterWrite: { mode: "restart", reason: "friday-next 插件自动升级后重启" },
|
|
103
|
+
mutate: () => {},
|
|
104
|
+
})
|
|
105
|
+
.catch((err: unknown) => {
|
|
106
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
107
|
+
log.error(`gateway restart trigger failed: ${msg}`);
|
|
108
|
+
});
|
|
109
|
+
}, RESTART_DELAY_MS).unref?.();
|
|
110
|
+
|
|
111
|
+
return true;
|
|
112
|
+
}
|
package/src/http/handlers/sse.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
|
4
4
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
5
5
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
6
6
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
7
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
7
8
|
|
|
8
9
|
function parseLastEventId(req: IncomingMessage, url: URL): number {
|
|
9
10
|
const query = Number.parseInt(url.searchParams.get("lastEventId") ?? "", 10);
|
|
@@ -56,6 +57,7 @@ export async function handleSseStream(req: IncomingMessage, res: ServerResponse)
|
|
|
56
57
|
deviceId: normalized,
|
|
57
58
|
serverTime: Date.now(),
|
|
58
59
|
lastSeq,
|
|
60
|
+
pluginVersion: PLUGIN_VERSION,
|
|
59
61
|
},
|
|
60
62
|
},
|
|
61
63
|
deviceId,
|
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import { getActiveRunIds } from "../../agent/active-runs.js";
|
|
3
3
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
4
4
|
import { extractBearerToken } from "../middleware/auth.js";
|
|
5
|
+
import { PLUGIN_VERSION } from "../../version.js";
|
|
5
6
|
|
|
6
7
|
export async function handleStatus(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
7
8
|
if (req.method !== "GET") {
|
|
@@ -24,6 +25,7 @@ export async function handleStatus(req: IncomingMessage, res: ServerResponse): P
|
|
|
24
25
|
ok: true,
|
|
25
26
|
channel: "friday-next",
|
|
26
27
|
version: "v2",
|
|
28
|
+
pluginVersion: PLUGIN_VERSION,
|
|
27
29
|
connections: sseEmitter.getConnectionCount(),
|
|
28
30
|
activeRuns,
|
|
29
31
|
activeRunCount: activeRuns.length,
|
package/src/http/server.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { handleHistoryMessages } from "./handlers/history-messages.js";
|
|
|
21
21
|
import { handleHistorySetTitle } from "./handlers/history-set-title.js";
|
|
22
22
|
import { handleStatus } from "./handlers/status.js";
|
|
23
23
|
import { handleHealth } from "./handlers/health.js";
|
|
24
|
+
import { handlePluginInfo } from "./handlers/plugin-info.js";
|
|
25
|
+
import { handlePluginUpgrade } from "./handlers/plugin-upgrade.js";
|
|
24
26
|
import { applyCorsHeaders } from "./middleware/cors.js";
|
|
25
27
|
import { resolveFridayNextConfig } from "../config.js";
|
|
26
28
|
import { getHostOpenClawConfigSnapshot } from "../host-config.js";
|
|
@@ -109,6 +111,16 @@ async function handleFridayNextRoute(
|
|
|
109
111
|
return await handleHealth(req, res);
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
// Route: GET /friday-next/plugin/info (current/latest version + upgradability)
|
|
115
|
+
if (req.method === "GET" && pathname === "/friday-next/plugin/info") {
|
|
116
|
+
return await handlePluginInfo(req, res);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Route: POST /friday-next/plugin/upgrade (npm install @latest + safe gateway restart)
|
|
120
|
+
if (req.method === "POST" && pathname === "/friday-next/plugin/upgrade") {
|
|
121
|
+
return await handlePluginUpgrade(req, res);
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
// Not found
|
|
113
125
|
return false;
|
|
114
126
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { semverGreater } from "./plugin-install-info.js";
|
|
3
|
+
|
|
4
|
+
describe("semverGreater", () => {
|
|
5
|
+
it("returns true when a is a higher patch/minor/major", () => {
|
|
6
|
+
expect(semverGreater("0.1.27", "0.1.26")).toBe(true);
|
|
7
|
+
expect(semverGreater("0.2.0", "0.1.99")).toBe(true);
|
|
8
|
+
expect(semverGreater("1.0.0", "0.9.9")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns false when equal or lower", () => {
|
|
12
|
+
expect(semverGreater("0.1.27", "0.1.27")).toBe(false);
|
|
13
|
+
expect(semverGreater("0.1.26", "0.1.27")).toBe(false);
|
|
14
|
+
expect(semverGreater("0.1.0", "0.1.0")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("tolerates a leading v and pre-release/build suffixes", () => {
|
|
18
|
+
expect(semverGreater("v0.1.27", "0.1.26")).toBe(true);
|
|
19
|
+
expect(semverGreater("0.1.27-beta.1", "0.1.26")).toBe(true);
|
|
20
|
+
expect(semverGreater("0.1.27", "0.1.27-rc.1")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns false for null/undefined inputs", () => {
|
|
24
|
+
expect(semverGreater(null, "0.1.0")).toBe(false);
|
|
25
|
+
expect(semverGreater("0.1.0", null)).toBe(false);
|
|
26
|
+
expect(semverGreater(undefined, undefined)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the plugin upgrade feature: read this plugin's install record
|
|
3
|
+
* (source: "npm" vs "path"/dev), compare semver, and look up the latest version
|
|
4
|
+
* published to the npm registry (cached).
|
|
5
|
+
*/
|
|
6
|
+
import { getUpgradeRuntime } from "./upgrade-runtime.js";
|
|
7
|
+
import { PLUGIN_ID, PLUGIN_PACKAGE_NAME } from "./version.js";
|
|
8
|
+
|
|
9
|
+
/** Install source from the OpenClaw config `plugins.installs[<id>].source`. */
|
|
10
|
+
export type InstallSource = "npm" | "path" | "archive" | "clawhub" | "git" | "marketplace" | "unknown";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read the install source for this plugin from the live config snapshot.
|
|
14
|
+
* Returns "unknown" when the record can't be resolved (e.g. runtime not captured).
|
|
15
|
+
* Only "npm" is auto-upgradable; "path" means a dev (load.paths) install which must
|
|
16
|
+
* never be npm-upgraded (would duplicate-install and break agent media sends).
|
|
17
|
+
*/
|
|
18
|
+
export function getInstallSource(): InstallSource {
|
|
19
|
+
const rt = getUpgradeRuntime();
|
|
20
|
+
if (!rt) return "unknown";
|
|
21
|
+
try {
|
|
22
|
+
const cfg = rt.currentConfig() as {
|
|
23
|
+
plugins?: { installs?: Record<string, { source?: string } | undefined> };
|
|
24
|
+
} | undefined;
|
|
25
|
+
const source = cfg?.plugins?.installs?.[PLUGIN_ID]?.source;
|
|
26
|
+
if (
|
|
27
|
+
source === "npm" ||
|
|
28
|
+
source === "path" ||
|
|
29
|
+
source === "archive" ||
|
|
30
|
+
source === "clawhub" ||
|
|
31
|
+
source === "git" ||
|
|
32
|
+
source === "marketplace"
|
|
33
|
+
) {
|
|
34
|
+
return source;
|
|
35
|
+
}
|
|
36
|
+
return "unknown";
|
|
37
|
+
} catch {
|
|
38
|
+
return "unknown";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Compare dotted numeric versions. Returns true if `a` is strictly greater than `b`. */
|
|
43
|
+
export function semverGreater(a: string | null | undefined, b: string | null | undefined): boolean {
|
|
44
|
+
if (!a || !b) return false;
|
|
45
|
+
const pa = parseSemver(a);
|
|
46
|
+
const pb = parseSemver(b);
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
if (pa[i] > pb[i]) return true;
|
|
49
|
+
if (pa[i] < pb[i]) return false;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseSemver(v: string): [number, number, number] {
|
|
55
|
+
// Strip a leading "v" and any pre-release/build suffix, keep major.minor.patch.
|
|
56
|
+
const core = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
|
|
57
|
+
const parts = core.split(".").map((p) => Number.parseInt(p, 10));
|
|
58
|
+
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let cachedLatest: { version: string | null; fetchedAt: number } | null = null;
|
|
62
|
+
const LATEST_TTL_MS = 10 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
/** Fetch the latest published version from the npm registry (cached ~10min). Null on failure. */
|
|
65
|
+
export async function fetchLatestVersion(nowMs: number): Promise<string | null> {
|
|
66
|
+
if (cachedLatest && nowMs - cachedLatest.fetchedAt < LATEST_TTL_MS) {
|
|
67
|
+
return cachedLatest.version;
|
|
68
|
+
}
|
|
69
|
+
let version: string | null = null;
|
|
70
|
+
try {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(
|
|
75
|
+
`https://registry.npmjs.org/${PLUGIN_PACKAGE_NAME}/latest`,
|
|
76
|
+
{ signal: controller.signal, headers: { Accept: "application/json" } },
|
|
77
|
+
);
|
|
78
|
+
if (res.ok) {
|
|
79
|
+
const body = (await res.json()) as { version?: string };
|
|
80
|
+
if (typeof body.version === "string" && body.version) version = body.version;
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
version = null;
|
|
87
|
+
}
|
|
88
|
+
cachedLatest = { version, fetchedAt: nowMs };
|
|
89
|
+
return version;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Vitest-only */
|
|
93
|
+
export function resetLatestVersionCacheForTest(): void {
|
|
94
|
+
cachedLatest = null;
|
|
95
|
+
}
|