Files
baoyu-skills/skills/baoyu-post-to-wechat/scripts/wechat-http.ts
T
Jim Liu 宝玉 3b29f3c57c 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>
2026-05-21 00:59:36 -05:00

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();
});
}