mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-20 01:19:46 +08:00
3b29f3c57c
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>
107 lines
3.1 KiB
TypeScript
107 lines
3.1 KiB
TypeScript
import http from "node:http";
|
|
import https from "node:https";
|
|
import { URL } from "node:url";
|
|
|
|
export interface WechatHttpInit {
|
|
method?: string;
|
|
headers?: Record<string, string>;
|
|
body?: string | Buffer;
|
|
agent?: http.Agent | https.Agent | false;
|
|
}
|
|
|
|
export interface WechatHttpResponse {
|
|
status: number;
|
|
statusText: string;
|
|
headers: Record<string, string | string[] | undefined>;
|
|
buffer(): Promise<Buffer>;
|
|
text(): Promise<string>;
|
|
json<T = unknown>(): Promise<T>;
|
|
}
|
|
|
|
export interface MultipartFilePart {
|
|
name: string;
|
|
filename: string;
|
|
contentType: string;
|
|
data: Buffer;
|
|
}
|
|
|
|
export interface MultipartBody {
|
|
contentType: string;
|
|
body: Buffer;
|
|
}
|
|
|
|
export function buildMultipart(parts: MultipartFilePart[]): MultipartBody {
|
|
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
|
const chunks: Buffer[] = [];
|
|
|
|
for (const part of parts) {
|
|
const header =
|
|
`--${boundary}\r\n` +
|
|
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n` +
|
|
`Content-Type: ${part.contentType}\r\n\r\n`;
|
|
chunks.push(Buffer.from(header, "utf-8"));
|
|
chunks.push(part.data);
|
|
chunks.push(Buffer.from("\r\n", "utf-8"));
|
|
}
|
|
chunks.push(Buffer.from(`--${boundary}--\r\n`, "utf-8"));
|
|
|
|
return {
|
|
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
body: Buffer.concat(chunks),
|
|
};
|
|
}
|
|
|
|
export async function wechatHttp(url: string, init: WechatHttpInit = {}): Promise<WechatHttpResponse> {
|
|
const parsed = new URL(url);
|
|
const isHttps = parsed.protocol === "https:";
|
|
const transport = isHttps ? https : http;
|
|
|
|
const body = init.body == null
|
|
? undefined
|
|
: Buffer.isBuffer(init.body) ? init.body : Buffer.from(init.body, "utf-8");
|
|
|
|
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
|
if (body && headers["Content-Length"] === undefined && headers["content-length"] === undefined) {
|
|
headers["Content-Length"] = String(body.length);
|
|
}
|
|
|
|
const requestOptions: https.RequestOptions = {
|
|
method: init.method ?? (body ? "POST" : "GET"),
|
|
hostname: parsed.hostname,
|
|
port: parsed.port ? Number(parsed.port) : undefined,
|
|
path: `${parsed.pathname}${parsed.search}`,
|
|
headers,
|
|
};
|
|
if (init.agent !== undefined) {
|
|
requestOptions.agent = init.agent;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const req = transport.request(requestOptions, (res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
res.on("end", () => {
|
|
const bodyBuffer = Buffer.concat(chunks);
|
|
resolve({
|
|
status: res.statusCode ?? 0,
|
|
statusText: res.statusMessage ?? "",
|
|
headers: res.headers as Record<string, string | string[] | undefined>,
|
|
async buffer() {
|
|
return bodyBuffer;
|
|
},
|
|
async text() {
|
|
return bodyBuffer.toString("utf-8");
|
|
},
|
|
async json<T = unknown>() {
|
|
return JSON.parse(bodyBuffer.toString("utf-8")) as T;
|
|
},
|
|
});
|
|
});
|
|
res.on("error", reject);
|
|
});
|
|
req.on("error", reject);
|
|
if (body) req.write(body);
|
|
req.end();
|
|
});
|
|
}
|