mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-08-01 06:39:49 +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,106 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user