@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
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const PLUGIN_VERSION: string;
|
|
2
|
+
/** npm package name, used for the upgrade spec and registry lookup. */
|
|
3
|
+
export declare const PLUGIN_PACKAGE_NAME = "@syengup/friday-channel-next";
|
|
4
|
+
/** Plugin id as registered with OpenClaw (used to read the install record). */
|
|
5
|
+
export declare const PLUGIN_ID = "friday-next";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin self-version.
|
|
3
|
+
*
|
|
4
|
+
* Resolved at module load by reading this package's own package.json relative to
|
|
5
|
+
* the compiled module URL. `tsc` does NOT copy package.json into `dist/`, and a
|
|
6
|
+
* JSON `import` would rewrite to a non-existent `dist/package.json`, so we read
|
|
7
|
+
* the real file from disk and walk a couple of candidate paths. Falls back to a
|
|
8
|
+
* hardcoded constant if the file can't be located (keep in sync with package.json).
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
/** Keep in sync with package.json "version" as a last-resort fallback. */
|
|
13
|
+
const FALLBACK_VERSION = "0.1.28";
|
|
14
|
+
function resolvePluginVersion() {
|
|
15
|
+
// dist layout: <root>/dist/src/version.js → ../../package.json = <root>/package.json
|
|
16
|
+
// source layout (vitest/jiti): <root>/src/version.ts → ../package.json = <root>/package.json
|
|
17
|
+
const candidates = ["../../package.json", "../package.json"];
|
|
18
|
+
for (const rel of candidates) {
|
|
19
|
+
try {
|
|
20
|
+
const path = fileURLToPath(new URL(rel, import.meta.url));
|
|
21
|
+
const raw = readFileSync(path, "utf8");
|
|
22
|
+
const pkg = JSON.parse(raw);
|
|
23
|
+
if (pkg.name === "@syengup/friday-channel-next" && typeof pkg.version === "string" && pkg.version) {
|
|
24
|
+
return pkg.version;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// try next candidate
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return FALLBACK_VERSION;
|
|
32
|
+
}
|
|
33
|
+
export const PLUGIN_VERSION = resolvePluginVersion();
|
|
34
|
+
/** npm package name, used for the upgrade spec and registry lookup. */
|
|
35
|
+
export const PLUGIN_PACKAGE_NAME = "@syengup/friday-channel-next";
|
|
36
|
+
/** Plugin id as registered with OpenClaw (used to read the install record). */
|
|
37
|
+
export const PLUGIN_ID = "friday-next";
|
package/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
resolveFridayDeviceIdForSessionKey,
|
|
16
16
|
} from "./src/friday-session.js";
|
|
17
17
|
import { setFridayAgentForwardRuntime } from "./src/agent-forward-runtime.js";
|
|
18
|
+
import { setUpgradeRuntime } from "./src/upgrade-runtime.js";
|
|
18
19
|
import { getOpenClawAgentRunContext } from "./src/agent-run-context-bridge.js";
|
|
19
20
|
import { accumulateRunUsage } from "./src/agent/run-usage-accumulator.js";
|
|
20
21
|
import { createFridayNextLogger } from "./src/logging.js";
|
|
@@ -86,6 +87,7 @@ export default defineChannelPluginEntry({
|
|
|
86
87
|
setRuntime: setFridayNextRuntime,
|
|
87
88
|
registerFull: (api: OpenClawPluginApi) => {
|
|
88
89
|
setFridayAgentForwardRuntime(api);
|
|
90
|
+
setUpgradeRuntime(api);
|
|
89
91
|
const sameApi = lastApiRoutesRegistered?.deref() === api;
|
|
90
92
|
if (!sameApi) {
|
|
91
93
|
lastApiRoutesRegistered = new WeakRef(api);
|
package/package.json
CHANGED
|
@@ -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");
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
7
|
+
|
|
8
|
+
vi.mock("node:dns/promises", () => ({
|
|
9
|
+
default: { lookup: vi.fn() },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import dns from "node:dns/promises";
|
|
13
|
+
import { handleLinkPreview } from "./link-preview.js";
|
|
14
|
+
import { resetLinkPreviewCacheForTest, type LinkPreviewPayload } from "../../link-preview/preview-service.js";
|
|
15
|
+
import { setAttachmentsDirForTest } from "./files.js";
|
|
16
|
+
import { clearFridayNextRuntime, setFridayNextRuntime } from "../../runtime.js";
|
|
17
|
+
|
|
18
|
+
const lookupMock = vi.mocked(dns.lookup);
|
|
19
|
+
|
|
20
|
+
class MockRes extends EventEmitter {
|
|
21
|
+
statusCode = 0;
|
|
22
|
+
headers: Record<string, string> = {};
|
|
23
|
+
body = "";
|
|
24
|
+
setHeader(name: string, value: string): void {
|
|
25
|
+
this.headers[name.toLowerCase()] = value;
|
|
26
|
+
}
|
|
27
|
+
end(body?: string): void {
|
|
28
|
+
if (body) this.body += body;
|
|
29
|
+
this.emit("finish");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeReq(query: string | null, token: string | null = "tok"): IncomingMessage {
|
|
34
|
+
return {
|
|
35
|
+
method: "GET",
|
|
36
|
+
url: query == null ? "/friday-next/link-preview" : `/friday-next/link-preview?url=${encodeURIComponent(query)}`,
|
|
37
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
38
|
+
} as unknown as IncomingMessage;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function invoke(req: IncomingMessage): Promise<MockRes> {
|
|
42
|
+
const res = new MockRes();
|
|
43
|
+
await handleLinkPreview(req, res as unknown as ServerResponse);
|
|
44
|
+
return res;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PAGE_HTML = `<html><head>
|
|
48
|
+
<meta property="og:title" content="Hello Page">
|
|
49
|
+
<meta property="og:description" content="A description">
|
|
50
|
+
<meta property="og:site_name" content="Example Site">
|
|
51
|
+
<meta property="og:image" content="https://example.com/cover.png">
|
|
52
|
+
</head></html>`;
|
|
53
|
+
|
|
54
|
+
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0]);
|
|
55
|
+
|
|
56
|
+
let tmpDir: string;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
resetLinkPreviewCacheForTest();
|
|
60
|
+
lookupMock.mockReset();
|
|
61
|
+
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }] as never);
|
|
62
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "link-preview-test-"));
|
|
63
|
+
setAttachmentsDirForTest(tmpDir);
|
|
64
|
+
setFridayNextRuntime({
|
|
65
|
+
config: { loadConfig: () => ({ gateway: { auth: { token: "tok" } }, channels: {} }) },
|
|
66
|
+
} as never);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.unstubAllGlobals();
|
|
71
|
+
setAttachmentsDirForTest(null);
|
|
72
|
+
clearFridayNextRuntime();
|
|
73
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("handleLinkPreview", () => {
|
|
77
|
+
it("405 on non-GET", async () => {
|
|
78
|
+
const res = new MockRes();
|
|
79
|
+
await handleLinkPreview({ method: "POST", url: "/friday-next/link-preview", headers: {} } as never, res as never);
|
|
80
|
+
expect(res.statusCode).toBe(405);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("401 without bearer token", async () => {
|
|
84
|
+
const res = await invoke(makeReq("https://example.com/", null));
|
|
85
|
+
expect(res.statusCode).toBe(401);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("400 when url param is missing or not http(s)", async () => {
|
|
89
|
+
expect((await invoke(makeReq(null))).statusCode).toBe(400);
|
|
90
|
+
expect((await invoke(makeReq("ftp://example.com/x"))).statusCode).toBe(400);
|
|
91
|
+
expect((await invoke(makeReq("not a url"))).statusCode).toBe(400);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("403 blocked_url for private targets", async () => {
|
|
95
|
+
const res = await invoke(makeReq("http://192.168.1.1/admin"));
|
|
96
|
+
expect(res.statusCode).toBe(403);
|
|
97
|
+
expect(JSON.parse(res.body).error).toBe("blocked_url");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("200 with full preview payload and re-hosted cover image", async () => {
|
|
101
|
+
vi.stubGlobal(
|
|
102
|
+
"fetch",
|
|
103
|
+
vi.fn(async (input: URL | string) => {
|
|
104
|
+
const url = String(input);
|
|
105
|
+
if (url.includes("cover.png")) {
|
|
106
|
+
return new Response(PNG_BYTES, { status: 200, headers: { "content-type": "image/png" } });
|
|
107
|
+
}
|
|
108
|
+
return new Response(PAGE_HTML, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } });
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const res = await invoke(makeReq("https://example.com/article"));
|
|
112
|
+
expect(res.statusCode).toBe(200);
|
|
113
|
+
const body = JSON.parse(res.body) as { ok: boolean; preview: LinkPreviewPayload };
|
|
114
|
+
expect(body.ok).toBe(true);
|
|
115
|
+
expect(body.preview.title).toBe("Hello Page");
|
|
116
|
+
expect(body.preview.description).toBe("A description");
|
|
117
|
+
expect(body.preview.siteName).toBe("Example Site");
|
|
118
|
+
expect(body.preview.url).toBe("https://example.com/article");
|
|
119
|
+
expect(body.preview.finalUrl).toBe("https://example.com/article");
|
|
120
|
+
expect(body.preview.imageUrl).toMatch(/^\/friday-next\/files\/.+\.png$/);
|
|
121
|
+
expect(typeof body.preview.fetchedAt).toBe("number");
|
|
122
|
+
// 封面图确实落盘
|
|
123
|
+
const token = decodeURIComponent(body.preview.imageUrl!.split("/").pop()!);
|
|
124
|
+
expect(fs.existsSync(path.join(tmpDir, token))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("200 with imageUrl null when the cover download fails", async () => {
|
|
128
|
+
vi.stubGlobal(
|
|
129
|
+
"fetch",
|
|
130
|
+
vi.fn(async (input: URL | string) => {
|
|
131
|
+
const url = String(input);
|
|
132
|
+
if (url.includes("cover.png")) return new Response("nope", { status: 404 });
|
|
133
|
+
return new Response(PAGE_HTML, { status: 200, headers: { "content-type": "text/html" } });
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const res = await invoke(makeReq("https://example.com/article"));
|
|
137
|
+
expect(res.statusCode).toBe(200);
|
|
138
|
+
expect(JSON.parse(res.body).preview.imageUrl).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("falls back siteName to hostname when og:site_name is absent", async () => {
|
|
142
|
+
const html = `<meta property="og:title" content="T">`;
|
|
143
|
+
vi.stubGlobal(
|
|
144
|
+
"fetch",
|
|
145
|
+
vi.fn(async () => new Response(html, { status: 200, headers: { "content-type": "text/html" } })),
|
|
146
|
+
);
|
|
147
|
+
const res = await invoke(makeReq("https://example.com/x"));
|
|
148
|
+
expect(JSON.parse(res.body).preview.siteName).toBe("example.com");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("200 minimal hostname card when a reachable page has no OG metadata", async () => {
|
|
152
|
+
// 可达但无 OG/title → 退到 hostname 卡片(不再折叠)。
|
|
153
|
+
vi.stubGlobal(
|
|
154
|
+
"fetch",
|
|
155
|
+
vi.fn(async () => new Response("<html><body>plain</body></html>", { status: 200, headers: { "content-type": "text/html" } })),
|
|
156
|
+
);
|
|
157
|
+
const res = await invoke(makeReq("https://example.com/bare"));
|
|
158
|
+
expect(res.statusCode).toBe(200);
|
|
159
|
+
expect(JSON.parse(res.body).preview.title).toBe("example.com");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("ICO favicon re-hosted into iconUrl", async () => {
|
|
163
|
+
const ICO_BYTES = new Uint8Array([0x00, 0x00, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0]);
|
|
164
|
+
vi.stubGlobal(
|
|
165
|
+
"fetch",
|
|
166
|
+
vi.fn(async (input: URL | string) => {
|
|
167
|
+
const url = String(input);
|
|
168
|
+
if (url.includes("favicon")) {
|
|
169
|
+
return new Response(ICO_BYTES, { status: 200, headers: { "content-type": "image/x-icon" } });
|
|
170
|
+
}
|
|
171
|
+
return new Response(`<meta property="og:title" content="Titled">`, { status: 200, headers: { "content-type": "text/html" } });
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
const res = await invoke(makeReq("https://example.com/p"));
|
|
175
|
+
const preview = JSON.parse(res.body).preview as LinkPreviewPayload;
|
|
176
|
+
expect(preview.iconUrl).toMatch(/^\/friday-next\/files\/.+\.ico$/);
|
|
177
|
+
const token = decodeURIComponent(preview.iconUrl!.split("/").pop()!);
|
|
178
|
+
expect(fs.existsSync(path.join(tmpDir, token))).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("200 minimal card for a bot-blocked page whose favicon.ico is reachable (zhihu-style)", async () => {
|
|
182
|
+
const ICO_BYTES = new Uint8Array([0x00, 0x00, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0, 0]);
|
|
183
|
+
vi.stubGlobal(
|
|
184
|
+
"fetch",
|
|
185
|
+
vi.fn(async (input: URL | string) => {
|
|
186
|
+
const url = String(input);
|
|
187
|
+
if (url.endsWith("/favicon.ico")) {
|
|
188
|
+
return new Response(ICO_BYTES, { status: 200, headers: { "content-type": "image/vnd.microsoft.icon" } });
|
|
189
|
+
}
|
|
190
|
+
return new Response("blocked", { status: 403, headers: { "content-type": "text/html" } }); // page blocks bots
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
const res = await invoke(makeReq("https://www.zhihu.com/question/123"));
|
|
194
|
+
expect(res.statusCode).toBe(200);
|
|
195
|
+
const preview = JSON.parse(res.body).preview as LinkPreviewPayload;
|
|
196
|
+
expect(preview.title).toBe("www.zhihu.com");
|
|
197
|
+
expect(preview.iconUrl).toMatch(/^\/friday-next\/files\/.+\.ico$/);
|
|
198
|
+
expect(preview.imageUrl).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("502 fetch_failed for a dead domain (page and favicon both fail)", async () => {
|
|
202
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 404 })));
|
|
203
|
+
const res = await invoke(makeReq("https://dead.example.com/x"));
|
|
204
|
+
expect(res.statusCode).toBe(502);
|
|
205
|
+
expect(JSON.parse(res.body).error).toBe("fetch_failed");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("502 fetch_failed on non-2xx and non-HTML responses", async () => {
|
|
209
|
+
vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 500 })));
|
|
210
|
+
expect((await invoke(makeReq("https://example.com/down"))).statusCode).toBe(502);
|
|
211
|
+
|
|
212
|
+
resetLinkPreviewCacheForTest();
|
|
213
|
+
vi.stubGlobal(
|
|
214
|
+
"fetch",
|
|
215
|
+
vi.fn(async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } })),
|
|
216
|
+
);
|
|
217
|
+
expect((await invoke(makeReq("https://example.com/api"))).statusCode).toBe(502);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("serves the second request from cache without refetching", async () => {
|
|
221
|
+
const fetchMock = vi.fn(async () => new Response(`<meta property="og:title" content="Cached">`, {
|
|
222
|
+
status: 200,
|
|
223
|
+
headers: { "content-type": "text/html" },
|
|
224
|
+
}));
|
|
225
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
226
|
+
await invoke(makeReq("https://example.com/cached"));
|
|
227
|
+
const afterFirst = fetchMock.mock.calls.length;
|
|
228
|
+
const second = await invoke(makeReq("https://example.com/cached"));
|
|
229
|
+
expect(second.statusCode).toBe(200);
|
|
230
|
+
expect(JSON.parse(second.body).preview.title).toBe("Cached");
|
|
231
|
+
expect(fetchMock).toHaveBeenCalledTimes(afterFirst); // cached → no extra network
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("negative-caches failures", async () => {
|
|
235
|
+
const fetchMock = vi.fn(async () => new Response("nope", { status: 500 }));
|
|
236
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
237
|
+
await invoke(makeReq("https://example.com/flaky"));
|
|
238
|
+
const afterFirst = fetchMock.mock.calls.length;
|
|
239
|
+
await invoke(makeReq("https://example.com/flaky"));
|
|
240
|
+
expect(fetchMock).toHaveBeenCalledTimes(afterFirst); // negative-cached → no refetch
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
+
import { getLinkPreview, type LinkPreviewError } from "../../link-preview/preview-service.js";
|
|
11
|
+
import { extractBearerToken } from "../middleware/auth.js";
|
|
12
|
+
|
|
13
|
+
const ERROR_STATUS: Record<LinkPreviewError, number> = {
|
|
14
|
+
invalid_url: 400,
|
|
15
|
+
blocked_url: 403,
|
|
16
|
+
no_metadata: 422,
|
|
17
|
+
fetch_failed: 502,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function handleLinkPreview(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({ ok: false, error: "unauthorized" }));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const url = new URL(req.url ?? "/", "http://localhost").searchParams.get("url")?.trim();
|
|
35
|
+
if (!url) {
|
|
36
|
+
res.statusCode = 400;
|
|
37
|
+
res.setHeader("Content-Type", "application/json");
|
|
38
|
+
res.end(JSON.stringify({ ok: false, error: "invalid_url" }));
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result = await getLinkPreview(url);
|
|
43
|
+
res.statusCode = result.ok ? 200 : ERROR_STATUS[result.error];
|
|
44
|
+
res.setHeader("Content-Type", "application/json");
|
|
45
|
+
res.end(JSON.stringify(result));
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|