mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-31 14:19:47 +08:00
feat(baoyu-post-to-wechat): add remote-api publishing via SSH SOCKS5 tunnel
Re-implements PR #156 (remote WeChat API publishing for IP-allowlist constraints) with a fully local code path: spawn a background `ssh -N -D` SOCKS5 dynamic forward, wrap a `SocksProxyAgent`, and thread it through the existing token/upload/draft flow. No Python helper, no SCP, no remote files, no `AppSecret` ever leaving the local process. Reusing `uploadImagesInHtml` (which replaces the matched `<img>` fullTag rather than substring-replacing `src`) naturally avoids the original PR's `html.replace(src, url)` HTML-replacement bug. Changes: - New `wechat-remote-publish.ts`: typed-whitelist SSH args, free-port allocation, SOCKS readiness polling, signal-handler-backed cleanup. - New `wechat-http.ts`: minimal `https.request`-backed HTTP helper so a `SocksProxyAgent` can be passed through (undici `fetch` ignores agent). - New `wechat-image-loader.ts`: extracts `loadUploadAsset` for reuse. - `wechat-api.ts`: thread optional `agent?` through token/upload/draft; add `--remote*` CLI flags; dispatch via `withSshTunnel` when remote mode is selected by flag or `default_publish_method: remote-api`. - `wechat-extend-config.ts`: add typed `remote_publish_*` keys with account-over-global fallback and value validation. - Docs: SKILL.md, references/multi-account.md, references/config/ first-time-setup.md, README.md, README.zh.md. - Tests: 17 new node:test cases covering config parsing, SSH arg whitelisting, free-port allocation, multipart assembly, and HTTP agent threading. Co-authored-by: Dame5211 <1079825614@qq.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { buildMultipart, wechatHttp } from "./wechat-http.ts";
|
||||
|
||||
interface ReceivedRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
async function startEchoServer(t: TestContext): Promise<{ baseUrl: string; received: ReceivedRequest[] }> {
|
||||
const received: ReceivedRequest[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
received.push({
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, echo: { url: req.url, method: req.method } }));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to start echo server");
|
||||
}
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
t.after(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
return { baseUrl, received };
|
||||
}
|
||||
|
||||
test("wechatHttp performs a GET and passes through query string", async (t) => {
|
||||
const { baseUrl, received } = await startEchoServer(t);
|
||||
const res = await wechatHttp(`${baseUrl}/cgi-bin/token?grant_type=client_credential&appid=AID`);
|
||||
assert.equal(res.status, 200);
|
||||
const data = await res.json<{ ok: boolean; echo: { url: string; method: string } }>();
|
||||
assert.equal(data.ok, true);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]!.method, "GET");
|
||||
assert.equal(received[0]!.url, "/cgi-bin/token?grant_type=client_credential&appid=AID");
|
||||
assert.equal(received[0]!.body.length, 0);
|
||||
});
|
||||
|
||||
test("wechatHttp POST sends JSON body with content-length header", async (t) => {
|
||||
const { baseUrl, received } = await startEchoServer(t);
|
||||
const body = JSON.stringify({ articles: [{ title: "hi" }] });
|
||||
const res = await wechatHttp(`${baseUrl}/cgi-bin/draft/add?access_token=T`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]!.method, "POST");
|
||||
assert.equal(received[0]!.headers["content-type"], "application/json");
|
||||
assert.equal(received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
|
||||
assert.equal(received[0]!.body.toString("utf-8"), body);
|
||||
});
|
||||
|
||||
test("wechatHttp .text() returns the raw response body", async (t) => {
|
||||
const { baseUrl } = await startEchoServer(t);
|
||||
const res = await wechatHttp(`${baseUrl}/hello`);
|
||||
const text = await res.text();
|
||||
assert.match(text, /"ok":true/);
|
||||
});
|
||||
|
||||
test("wechatHttp .buffer() returns a Buffer of the response body", async (t) => {
|
||||
const { baseUrl } = await startEchoServer(t);
|
||||
const res = await wechatHttp(`${baseUrl}/`);
|
||||
const buf = await res.buffer();
|
||||
assert.ok(Buffer.isBuffer(buf));
|
||||
assert.ok(buf.length > 0);
|
||||
});
|
||||
|
||||
test("buildMultipart produces a parsable multipart payload", () => {
|
||||
const fileData = Buffer.from("ZZZ");
|
||||
const { contentType, body } = buildMultipart([
|
||||
{
|
||||
name: "media",
|
||||
filename: "image.png",
|
||||
contentType: "image/png",
|
||||
data: fileData,
|
||||
},
|
||||
]);
|
||||
|
||||
const boundaryMatch = contentType.match(/^multipart\/form-data; boundary=(.+)$/);
|
||||
assert.ok(boundaryMatch, `expected boundary in Content-Type, got ${contentType}`);
|
||||
const boundary = boundaryMatch![1]!;
|
||||
const text = body.toString("binary");
|
||||
assert.ok(text.startsWith(`--${boundary}\r\n`), "body must start with opening boundary");
|
||||
assert.ok(
|
||||
text.endsWith(`--${boundary}--\r\n`),
|
||||
"body must end with closing boundary",
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/Content-Disposition: form-data; name="media"; filename="image\.png"\r\n/,
|
||||
);
|
||||
assert.match(text, /Content-Type: image\/png\r\n/);
|
||||
// The raw file bytes must appear verbatim after a blank line.
|
||||
assert.ok(
|
||||
text.includes("\r\n\r\nZZZ\r\n"),
|
||||
"body must contain the raw file bytes after the part headers",
|
||||
);
|
||||
});
|
||||
|
||||
test("wechatHttp accepts a multipart body produced by buildMultipart", async (t) => {
|
||||
const { baseUrl, received } = await startEchoServer(t);
|
||||
const fileData = Buffer.from("HELLO");
|
||||
const multipart = buildMultipart([
|
||||
{ name: "media", filename: "x.png", contentType: "image/png", data: fileData },
|
||||
]);
|
||||
const res = await wechatHttp(`${baseUrl}/cgi-bin/media/uploadimg?access_token=T`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": multipart.contentType },
|
||||
body: multipart.body,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]!.method, "POST");
|
||||
assert.match(received[0]!.headers["content-type"]!, /^multipart\/form-data; boundary=/);
|
||||
assert.ok(received[0]!.body.includes(Buffer.from("HELLO")));
|
||||
});
|
||||
|
||||
test("wechatHttp forwards the agent option to http.request", async (t) => {
|
||||
const { baseUrl } = await startEchoServer(t);
|
||||
const baseAgent = new http.Agent({ keepAlive: false });
|
||||
let addRequestCalls = 0;
|
||||
const originalAddRequest = baseAgent.addRequest.bind(baseAgent);
|
||||
baseAgent.addRequest = function (...callArgs: Parameters<typeof originalAddRequest>) {
|
||||
addRequestCalls++;
|
||||
return originalAddRequest(...callArgs);
|
||||
};
|
||||
|
||||
await wechatHttp(`${baseUrl}/`, { agent: baseAgent });
|
||||
assert.ok(addRequestCalls > 0, "expected the supplied agent to handle the request");
|
||||
baseAgent.destroy();
|
||||
});
|
||||
Reference in New Issue
Block a user