diff --git a/skills/baoyu-post-to-wechat/scripts/bun.lock b/skills/baoyu-post-to-wechat/scripts/bun.lock index 0cf535f..e9e8630 100644 --- a/skills/baoyu-post-to-wechat/scripts/bun.lock +++ b/skills/baoyu-post-to-wechat/scripts/bun.lock @@ -9,7 +9,7 @@ "baoyu-chrome-cdp": "^0.1.0", "baoyu-md": "^0.1.0", "jimp": "^1.6.0", - "socks-proxy-agent": "^10.0.0", + "socks": "^2.8.9", }, }, }, @@ -88,8 +88,6 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], - "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], @@ -286,8 +284,6 @@ "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], - "socks-proxy-agent": ["socks-proxy-agent@10.0.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA=="], - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], diff --git a/skills/baoyu-post-to-wechat/scripts/package.json b/skills/baoyu-post-to-wechat/scripts/package.json index 3669e69..26f2e50 100644 --- a/skills/baoyu-post-to-wechat/scripts/package.json +++ b/skills/baoyu-post-to-wechat/scripts/package.json @@ -7,6 +7,6 @@ "baoyu-chrome-cdp": "^0.1.0", "baoyu-md": "^0.1.0", "jimp": "^1.6.0", - "socks-proxy-agent": "^10.0.0" + "socks": "^2.8.9" } } diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-api.ts b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts index 93a870c..5b7674e 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-api.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-api.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -import type http from "node:http"; import { loadWechatExtendConfig, resolveAccount, @@ -16,7 +15,7 @@ import { needsWechatBodyImageProcessing, } from "./wechat-image-processor.ts"; import { loadUploadAsset } from "./wechat-image-loader.ts"; -import { wechatHttp, buildMultipart } from "./wechat-http.ts"; +import { wechatHttp, buildMultipart, type WechatClient } from "./wechat-http.ts"; import { type RemotePublishConfig, normalizeRemoteConfig, @@ -75,9 +74,13 @@ const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg"; const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material"; const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add"; -async function fetchAccessToken(appId: string, appSecret: string, agent?: http.Agent): Promise { +async function fetchAccessToken( + appId: string, + appSecret: string, + client: WechatClient = wechatHttp, +): Promise { const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`; - const res = await wechatHttp(url, { agent }); + const res = await client(url); if (res.status < 200 || res.status >= 300) { throw new Error(`Failed to fetch access token: ${res.status}`); } @@ -101,7 +104,7 @@ async function uploadImage( accessToken: string, baseDir?: string, uploadType: "body" | "material" = "body", - agent?: http.Agent, + client: WechatClient = wechatHttp, ): Promise { const asset = await loadUploadAsset(imagePath, baseDir); let uploadAsset = asset; @@ -126,7 +129,7 @@ async function uploadImage( uploadAsset.contentType, accessToken, uploadType, - agent, + client, ); // media/uploadimg 接口只返回 URL,material/add_material 返回 media_id @@ -147,7 +150,7 @@ async function uploadToWechat( contentType: string, accessToken: string, uploadType: "body" | "material", - agent?: http.Agent, + client: WechatClient = wechatHttp, ): Promise { const multipart = buildMultipart([ { name: "media", filename, contentType, data: fileBuffer }, @@ -155,11 +158,10 @@ async function uploadToWechat( const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL; const url = `${uploadUrl}?type=image&access_token=${accessToken}`; - const res = await wechatHttp(url, { + const res = await client(url, { method: "POST", headers: { "Content-Type": multipart.contentType }, body: multipart.body, - agent, }); const data = await res.json(); @@ -177,7 +179,7 @@ async function uploadImagesInHtml( contentImages: ImageInfo[] = [], articleType: ArticleType = "news", collectNewsCoverFallback: boolean = false, - agent?: http.Agent, + client: WechatClient = wechatHttp, ): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> { const imgRegex = /]*\ssrc=["']([^"']+)["'][^>]*>/gi; const matches = [...html.matchAll(imgRegex)]; @@ -198,7 +200,7 @@ async function uploadImagesInHtml( if (src.startsWith("https://mmbiz.qpic.cn")) { if (collectNewsCoverFallback && !firstCoverMediaId) { try { - const coverResp = await uploadImage(src, accessToken, baseDir, "material", agent); + const coverResp = await uploadImage(src, accessToken, baseDir, "material", client); firstCoverMediaId = coverResp.media_id; } catch (err) { console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err); @@ -214,7 +216,7 @@ async function uploadImagesInHtml( try { let resp = uploadedBySource.get(imagePath); if (!resp) { - resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent); + resp = await uploadImage(imagePath, accessToken, baseDir, "body", client); uploadedBySource.set(imagePath, resp); } const newTag = fullTag @@ -225,7 +227,7 @@ async function uploadImagesInHtml( if (shouldUploadMaterial) { let materialResp = uploadedBySource.get(`${imagePath}:material`); if (!materialResp) { - materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent); + materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client); uploadedBySource.set(`${imagePath}:material`, materialResp); } if (articleType === "newspic" && materialResp.media_id) { @@ -249,7 +251,7 @@ async function uploadImagesInHtml( try { let resp = uploadedBySource.get(imagePath); if (!resp) { - resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent); + resp = await uploadImage(imagePath, accessToken, baseDir, "body", client); uploadedBySource.set(imagePath, resp); } @@ -259,7 +261,7 @@ async function uploadImagesInHtml( if (shouldUploadMaterial) { let materialResp = uploadedBySource.get(`${imagePath}:material`); if (!materialResp) { - materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent); + materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client); uploadedBySource.set(`${imagePath}:material`, materialResp); } if (articleType === "newspic" && materialResp.media_id) { @@ -280,7 +282,7 @@ async function uploadImagesInHtml( async function publishToDraft( options: ArticleOptions, accessToken: string, - agent?: http.Agent, + client: WechatClient = wechatHttp, ): Promise { const url = `${DRAFT_URL}?access_token=${accessToken}`; @@ -317,11 +319,10 @@ async function publishToDraft( if (options.digest) article.digest = options.digest; } - const res = await wechatHttp(url, { + const res = await client(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ articles: [article] }), - agent, }); const data = await res.json(); @@ -732,9 +733,9 @@ async function main(): Promise { const useRemote = args.remote || resolved.default_publish_method === "remote-api"; const method = useRemote ? "remote-api" : "api"; - const publishWithAgent = async (agent?: http.Agent): Promise => { + const publishWith = async (client: WechatClient): Promise => { console.error("[wechat-api] Fetching access token..."); - const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, agent); + const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, client); console.error("[wechat-api] Uploading body images..."); const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml( @@ -744,7 +745,7 @@ async function main(): Promise { contentImages, args.articleType, needNewsCoverFallback, - agent, + client, ); htmlContent = processedHtml; @@ -752,7 +753,7 @@ async function main(): Promise { if (coverPath) { console.error(`[wechat-api] Uploading cover: ${coverPath}`); - const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", agent); + const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", client); thumbMediaId = coverResp.media_id; console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`); } else if (firstCoverMediaId && args.articleType === "news") { @@ -779,7 +780,7 @@ async function main(): Promise { imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined, needOpenComment: resolved.need_open_comment, onlyFansCanComment: resolved.only_fans_can_comment, - }, accessToken, agent); + }, accessToken, client); console.log(JSON.stringify({ success: true, @@ -797,11 +798,11 @@ async function main(): Promise { console.error( `[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`, ); - await withSshTunnel(remoteConfig, async (agent) => { - await publishWithAgent(agent); + await withSshTunnel(remoteConfig, async (client) => { + await publishWith(client); }); } else { - await publishWithAgent(); + await publishWith(wechatHttp); } } diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts index c54e00c..5664b09 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.test.ts @@ -228,7 +228,7 @@ test("resolveAccount lets account-level remote_publish_* override globals", asyn assert.equal(secondary.remote_publish_proxy_jump, "jump.example.com"); }); -test("resolveAccount drops invalid remote_publish_port and strict_host_key_checking values", async (t) => { +test("loadWechatExtendConfig throws on invalid remote_publish_port", async (t) => { const cwdRoot = await makeTempDir("wechat-extend-cwd-"); const homeRoot = await makeTempDir("wechat-extend-home-"); @@ -241,17 +241,51 @@ test("resolveAccount drops invalid remote_publish_port and strict_host_key_check [ "remote_publish_host: example.com", "remote_publish_port: 99999", + ].join("\n"), + ); + + assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_port: 99999/); +}); + +test("loadWechatExtendConfig throws on invalid remote_publish_connect_timeout", async (t) => { + const cwdRoot = await makeTempDir("wechat-extend-cwd-"); + const homeRoot = await makeTempDir("wechat-extend-home-"); + + useCwd(t, cwdRoot); + useHome(t, homeRoot); + useXdgConfigHome(t, undefined); + + await writeExtendFile( + cwdRoot, + [ + "remote_publish_host: example.com", "remote_publish_connect_timeout: 0", + ].join("\n"), + ); + + assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_connect_timeout: 0/); +}); + +test("loadWechatExtendConfig throws on invalid remote_publish_strict_host_key_checking", async (t) => { + const cwdRoot = await makeTempDir("wechat-extend-cwd-"); + const homeRoot = await makeTempDir("wechat-extend-home-"); + + useCwd(t, cwdRoot); + useHome(t, homeRoot); + useXdgConfigHome(t, undefined); + + await writeExtendFile( + cwdRoot, + [ + "remote_publish_host: example.com", "remote_publish_strict_host_key_checking: maybe", ].join("\n"), ); - const config = loadWechatExtendConfig(); - const resolved = resolveAccount(config); - assert.equal(resolved.remote_publish_host, "example.com"); - assert.equal(resolved.remote_publish_port, undefined); - assert.equal(resolved.remote_publish_connect_timeout, undefined); - assert.equal(resolved.remote_publish_strict_host_key_checking, undefined); + assert.throws( + () => loadWechatExtendConfig(), + /Invalid remote_publish_strict_host_key_checking: maybe/, + ); }); test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => { diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts index d9f8014..dd45e72 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-extend-config.ts @@ -72,24 +72,32 @@ function toBool01(v: string): number { return v === "1" || v === "true" ? 1 : 0; } -function toOptionalPort(v: string): number | undefined { +function homeDir(): string { + return process.env.HOME || process.env.USERPROFILE || os.homedir(); +} + +function parsePort(key: string, v: string): number { const n = Number.parseInt(v, 10); - if (!Number.isFinite(n) || n < 1 || n > 65535) return undefined; + if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 65535) { + throw new Error(`Invalid ${key}: ${v} (expected integer 1-65535)`); + } return n; } -function toOptionalPositiveInt(v: string): number | undefined { +function parsePositiveInt(key: string, v: string): number { const n = Number.parseInt(v, 10); - if (!Number.isFinite(n) || n <= 0) return undefined; + if (!Number.isFinite(n) || String(n) !== v.trim() || n <= 0) { + throw new Error(`Invalid ${key}: ${v} (expected positive integer)`); + } return n; } -function toStrictHostKeyChecking(v: string): StrictHostKeyChecking | undefined { +function parseStrictHostKeyChecking(key: string, v: string): StrictHostKeyChecking { const lower = v.toLowerCase(); if (lower === "yes" || lower === "no" || lower === "accept-new") { return lower; } - return undefined; + throw new Error(`Invalid ${key}: ${v} (expected yes|no|accept-new)`); } function parseWechatExtend(content: string): WechatExtendConfig { @@ -154,11 +162,11 @@ function parseWechatExtend(content: string): WechatExtendConfig { case "chrome_profile_path": config.chrome_profile_path = val; break; case "remote_publish_host": config.remote_publish_host = val; break; case "remote_publish_user": config.remote_publish_user = val; break; - case "remote_publish_port": config.remote_publish_port = toOptionalPort(val); break; + case "remote_publish_port": config.remote_publish_port = parsePort("remote_publish_port", val); break; case "remote_publish_identity_file": config.remote_publish_identity_file = val; break; case "remote_publish_known_hosts_file": config.remote_publish_known_hosts_file = val; break; - case "remote_publish_strict_host_key_checking": config.remote_publish_strict_host_key_checking = toStrictHostKeyChecking(val); break; - case "remote_publish_connect_timeout": config.remote_publish_connect_timeout = toOptionalPositiveInt(val); break; + case "remote_publish_strict_host_key_checking": config.remote_publish_strict_host_key_checking = parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", val); break; + case "remote_publish_connect_timeout": config.remote_publish_connect_timeout = parsePositiveInt("remote_publish_connect_timeout", val); break; case "remote_publish_proxy_jump": config.remote_publish_proxy_jump = val; break; } } @@ -179,14 +187,14 @@ function parseWechatExtend(content: string): WechatExtendConfig { chrome_profile_path: a.chrome_profile_path || undefined, remote_publish_host: a.remote_publish_host || undefined, remote_publish_user: a.remote_publish_user || undefined, - remote_publish_port: a.remote_publish_port ? toOptionalPort(a.remote_publish_port) : undefined, + remote_publish_port: a.remote_publish_port ? parsePort("remote_publish_port", a.remote_publish_port) : undefined, remote_publish_identity_file: a.remote_publish_identity_file || undefined, remote_publish_known_hosts_file: a.remote_publish_known_hosts_file || undefined, remote_publish_strict_host_key_checking: a.remote_publish_strict_host_key_checking - ? toStrictHostKeyChecking(a.remote_publish_strict_host_key_checking) + ? parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", a.remote_publish_strict_host_key_checking) : undefined, remote_publish_connect_timeout: a.remote_publish_connect_timeout - ? toOptionalPositiveInt(a.remote_publish_connect_timeout) + ? parsePositiveInt("remote_publish_connect_timeout", a.remote_publish_connect_timeout) : undefined, remote_publish_proxy_jump: a.remote_publish_proxy_jump || undefined, })); @@ -199,18 +207,19 @@ export function loadWechatExtendConfig(): WechatExtendConfig { const paths = [ path.join(process.cwd(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"), path.join( - process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), + process.env.XDG_CONFIG_HOME || path.join(homeDir(), ".config"), "baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md" ), - path.join(os.homedir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"), + path.join(homeDir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"), ]; for (const p of paths) { + let content: string; try { - const content = fs.readFileSync(p, "utf-8"); - return parseWechatExtend(content); + content = fs.readFileSync(p, "utf-8"); } catch { continue; } + return parseWechatExtend(content); } return {}; } @@ -348,7 +357,7 @@ function resolveCredentialSource( export function loadCredentials(account?: ResolvedAccount): LoadedCredentials { const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env"); - const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env"); + const homeEnvPath = path.join(homeDir(), ".baoyu-skills", ".env"); const cwdEnv = loadEnvFile(cwdEnvPath); const homeEnv = loadEnvFile(homeEnvPath); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-http.test.ts b/skills/baoyu-post-to-wechat/scripts/wechat-http.test.ts index eb52b2e..7670902 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-http.test.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-http.test.ts @@ -136,17 +136,3 @@ test("wechatHttp accepts a multipart body produced by buildMultipart", async (t) 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) { - addRequestCalls++; - return originalAddRequest(...callArgs); - }; - - await wechatHttp(`${baseUrl}/`, { agent: baseAgent }); - assert.ok(addRequestCalls > 0, "expected the supplied agent to handle the request"); - baseAgent.destroy(); -}); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-http.ts b/skills/baoyu-post-to-wechat/scripts/wechat-http.ts index 44ae3cd..b1f7e85 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-http.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-http.ts @@ -1,12 +1,7 @@ -import http from "node:http"; -import https from "node:https"; -import { URL } from "node:url"; - export interface WechatHttpInit { method?: string; headers?: Record; body?: string | Buffer; - agent?: http.Agent | https.Agent | false; } export interface WechatHttpResponse { @@ -18,6 +13,11 @@ export interface WechatHttpResponse { json(): Promise; } +export type WechatClient = ( + url: string, + init?: WechatHttpInit, +) => Promise; + export interface MultipartFilePart { name: string; filename: string; @@ -51,56 +51,47 @@ export function buildMultipart(parts: MultipartFilePart[]): MultipartBody { }; } -export async function wechatHttp(url: string, init: WechatHttpInit = {}): Promise { - 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 = { ...(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, - async buffer() { - return bodyBuffer; - }, - async text() { - return bodyBuffer.toString("utf-8"); - }, - async json() { - return JSON.parse(bodyBuffer.toString("utf-8")) as T; - }, - }); - }); - res.on("error", reject); - }); - req.on("error", reject); - if (body) req.write(body); - req.end(); +function headersToRecord(headers: Headers): Record { + const out: Record = {}; + headers.forEach((value, key) => { + const existing = out[key]; + if (existing === undefined) { + out[key] = value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + out[key] = [existing, value]; + } }); + return out; } + +export const wechatHttp: WechatClient = async (url, init = {}) => { + const method = init.method ?? (init.body !== undefined ? "POST" : "GET"); + const headers: Record = { ...(init.headers ?? {}) }; + + let body: BodyInit | undefined; + if (init.body !== undefined) { + body = Buffer.isBuffer(init.body) + ? new Uint8Array(init.body.buffer, init.body.byteOffset, init.body.byteLength) + : init.body; + } + + const res = await fetch(url, { method, headers, body }); + const buf = Buffer.from(await res.arrayBuffer()); + + return { + status: res.status, + statusText: res.statusText, + headers: headersToRecord(res.headers), + async buffer() { + return buf; + }, + async text() { + return buf.toString("utf-8"); + }, + async json() { + return JSON.parse(buf.toString("utf-8")) as T; + }, + }; +}; diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.ts b/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.ts index 1dfda69..366d5da 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-remote-publish.ts @@ -1,9 +1,10 @@ import { spawn, type ChildProcessByStdio } from "node:child_process"; import net from "node:net"; -import { SocksProxyAgent } from "socks-proxy-agent"; import type { Readable } from "node:stream"; import type { StrictHostKeyChecking } from "./wechat-extend-config.ts"; +import type { WechatClient } from "./wechat-http.ts"; +import { createSocksClient } from "./wechat-socks-http.ts"; export interface RemotePublishConfig { host: string; @@ -29,7 +30,7 @@ export interface NormalizedRemotePublishConfig { export interface SshTunnel { port: number; - agent: SocksProxyAgent; + client: WechatClient; close: () => Promise; } @@ -207,7 +208,7 @@ export async function startSshTunnel( throw new Error(`${(err as Error).message}${exitSuffix}${suffix}`); } - const agent = new SocksProxyAgent(`socks5h://${SSH_LOOPBACK_HOST}:${port}`); + const client = createSocksClient({ host: SSH_LOOPBACK_HOST, port }); const signalHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = []; let closed = false; @@ -228,7 +229,7 @@ export async function startSshTunnel( signalHandlers.push({ signal, handler }); } - return { port, agent, close }; + return { port, client, close }; } async function killChild(child: ChildProcessByStdio, killTimeoutMs: number): Promise { @@ -261,12 +262,12 @@ async function killChild(child: ChildProcessByStdio, k export async function withSshTunnel( config: NormalizedRemotePublishConfig, - fn: (agent: SocksProxyAgent) => Promise, + fn: (client: WechatClient) => Promise, options?: StartSshTunnelOptions, ): Promise { const tunnel = await startSshTunnel(config, options); try { - return await fn(tunnel.agent); + return await fn(tunnel.client); } finally { await tunnel.close(); } diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-socks-http.test.ts b/skills/baoyu-post-to-wechat/scripts/wechat-socks-http.test.ts new file mode 100644 index 0000000..9fcd1a6 --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-socks-http.test.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import http from "node:http"; +import net from "node:net"; +import test, { type TestContext } from "node:test"; + +import { createSocksClient } from "./wechat-socks-http.ts"; + +interface EchoServer { + baseUrl: string; + port: number; + received: Array<{ method: string; url: string; headers: http.IncomingHttpHeaders; body: Buffer }>; +} + +async function startEchoServer(t: TestContext): Promise { + const received: EchoServer["received"] = []; + 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, url: req.url })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("echo server bind failed"); + const port = address.port; + t.after(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + return { baseUrl: `http://127.0.0.1:${port}`, port, received }; +} + +interface FakeSocks5 { + port: number; + connectionCount: () => number; + destinations: () => Array<{ host: string; port: number }>; +} + +async function startFakeSocks5(t: TestContext): Promise { + let connectionCount = 0; + const destinations: Array<{ host: string; port: number }> = []; + + const server = net.createServer((client) => { + connectionCount++; + let phase: "greeting" | "request" | "tunnel" = "greeting"; + let buf = Buffer.alloc(0); + let upstream: net.Socket | undefined; + + const tryParse = () => { + if (phase === "greeting") { + if (buf.length < 2) return; + const nMethods = buf[1]!; + if (buf.length < 2 + nMethods) return; + buf = buf.subarray(2 + nMethods); + client.write(Buffer.from([0x05, 0x00])); + phase = "request"; + } + if (phase === "request") { + if (buf.length < 5) return; + if (buf[0] !== 0x05 || buf[1] !== 0x01) { + client.destroy(); + return; + } + const atyp = buf[3]; + let addrEnd: number; + let host: string; + if (atyp === 0x01) { + if (buf.length < 4 + 4 + 2) return; + host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`; + addrEnd = 4 + 4; + } else if (atyp === 0x03) { + const dlen = buf[4]!; + if (buf.length < 4 + 1 + dlen + 2) return; + host = buf.subarray(5, 5 + dlen).toString("ascii"); + addrEnd = 4 + 1 + dlen; + } else { + client.destroy(); + return; + } + const port = (buf[addrEnd]! << 8) | buf[addrEnd + 1]!; + destinations.push({ host, port }); + const totalLen = addrEnd + 2; + const remaining = buf.subarray(totalLen); + buf = Buffer.alloc(0); + + upstream = net.connect({ host, port }, () => { + client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + phase = "tunnel"; + if (remaining.length > 0) { + upstream!.write(remaining); + } + }); + upstream.on("data", (data: Buffer) => { + client.write(data); + }); + upstream.on("end", () => { + try { client.end(); } catch { /* noop */ } + }); + upstream.on("error", () => { + try { client.destroy(); } catch { /* noop */ } + }); + } + }; + + client.on("data", (chunk: Buffer) => { + if (phase === "tunnel") { + upstream?.write(chunk); + return; + } + buf = Buffer.concat([buf, chunk]); + tryParse(); + }); + client.on("end", () => { + try { upstream?.end(); } catch { /* noop */ } + }); + client.on("error", () => { + try { upstream?.destroy(); } catch { /* noop */ } + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("socks server bind failed"); + const port = address.port; + t.after(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + return { + port, + connectionCount: () => connectionCount, + destinations: () => destinations, + }; +} + +test("createSocksClient routes plain HTTP through the SOCKS5 proxy", async (t) => { + const echo = await startEchoServer(t); + const socks = await startFakeSocks5(t); + + const client = createSocksClient({ host: "127.0.0.1", port: socks.port }); + + const res = await client(`${echo.baseUrl}/cgi-bin/token?appid=AID`); + + assert.equal(res.status, 200); + const data = await res.json<{ ok: boolean; url: string }>(); + assert.equal(data.ok, true); + assert.equal(data.url, "/cgi-bin/token?appid=AID"); + + assert.equal( + socks.connectionCount(), + 1, + "SOCKS5 proxy must have received exactly one connection (proves bytes were routed through it)", + ); + + const dests = socks.destinations(); + assert.equal(dests.length, 1); + assert.equal(dests[0]!.host, "127.0.0.1"); + assert.equal(dests[0]!.port, echo.port); + + assert.equal(echo.received.length, 1); + assert.equal(echo.received[0]!.method, "GET"); + assert.equal(echo.received[0]!.url, "/cgi-bin/token?appid=AID"); +}); + +test("createSocksClient sends POST body through the SOCKS5 proxy", async (t) => { + const echo = await startEchoServer(t); + const socks = await startFakeSocks5(t); + + const client = createSocksClient({ host: "127.0.0.1", port: socks.port }); + + const body = JSON.stringify({ articles: [{ title: "hi" }] }); + const res = await client(`${echo.baseUrl}/cgi-bin/draft/add?access_token=T`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + + assert.equal(res.status, 200); + assert.equal(socks.connectionCount(), 1); + assert.equal(echo.received.length, 1); + assert.equal(echo.received[0]!.method, "POST"); + assert.equal(echo.received[0]!.headers["content-type"], "application/json"); + assert.equal(echo.received[0]!.headers["content-length"], String(Buffer.byteLength(body))); + assert.equal(echo.received[0]!.body.toString("utf-8"), body); +}); + +test("createSocksClient rejects invalid proxy ports", () => { + assert.throws( + () => createSocksClient({ host: "127.0.0.1", port: 0 }), + /Invalid SOCKS proxy port/, + ); + assert.throws( + () => createSocksClient({ host: "127.0.0.1", port: 70_000 }), + /Invalid SOCKS proxy port/, + ); + assert.throws( + () => createSocksClient({ host: "", port: 1080 }), + /SOCKS proxy host required/, + ); +}); diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-socks-http.ts b/skills/baoyu-post-to-wechat/scripts/wechat-socks-http.ts new file mode 100644 index 0000000..9763a1f --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/wechat-socks-http.ts @@ -0,0 +1,229 @@ +import net from "node:net"; +import tls from "node:tls"; +import { URL } from "node:url"; +import { SocksClient } from "socks"; + +import type { + WechatClient, + WechatHttpInit, + WechatHttpResponse, +} from "./wechat-http.ts"; + +export interface SocksProxyEndpoint { + host: string; + port: number; +} + +export function createSocksClient(proxy: SocksProxyEndpoint): WechatClient { + if (!proxy.host) throw new Error("SOCKS proxy host required"); + if (!Number.isInteger(proxy.port) || proxy.port < 1 || proxy.port > 65535) { + throw new Error(`Invalid SOCKS proxy port: ${proxy.port}`); + } + + return async (url, init = {}) => { + return wechatHttpViaSocks(url, init, proxy); + }; +} + +async function wechatHttpViaSocks( + rawUrl: string, + init: WechatHttpInit, + proxy: SocksProxyEndpoint, +): Promise { + const url = new URL(rawUrl); + const isHttps = url.protocol === "https:"; + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(`Unsupported protocol for SOCKS client: ${url.protocol}`); + } + const targetPort = url.port ? Number(url.port) : isHttps ? 443 : 80; + + const { socket: tcpSocket } = await SocksClient.createConnection({ + proxy: { host: proxy.host, port: proxy.port, type: 5 }, + command: "connect", + destination: { host: url.hostname, port: targetPort }, + }); + + let stream: net.Socket | tls.TLSSocket; + if (isHttps) { + const tlsSocket = tls.connect({ socket: tcpSocket, servername: url.hostname }); + await new Promise((resolve, reject) => { + const onSecure = () => { + tlsSocket.removeListener("error", onError); + resolve(); + }; + const onError = (err: Error) => { + tlsSocket.removeListener("secureConnect", onSecure); + try { + tlsSocket.destroy(); + } catch { + /* noop */ + } + try { + tcpSocket.destroy(); + } catch { + /* noop */ + } + reject(err); + }; + tlsSocket.once("secureConnect", onSecure); + tlsSocket.once("error", onError); + }); + stream = tlsSocket; + } else { + stream = tcpSocket; + } + + try { + return await sendRequestAndReadResponse(stream, url, init); + } finally { + try { + stream.destroy(); + } catch { + /* noop */ + } + } +} + +async function sendRequestAndReadResponse( + stream: net.Socket | tls.TLSSocket, + url: URL, + init: WechatHttpInit, +): Promise { + const method = init.method ?? (init.body !== undefined ? "POST" : "GET"); + const body = + init.body === undefined + ? undefined + : Buffer.isBuffer(init.body) + ? init.body + : Buffer.from(init.body, "utf-8"); + + const userHeaders = init.headers ?? {}; + const headerMap = new Map(); + for (const [k, v] of Object.entries(userHeaders)) { + headerMap.set(k.toLowerCase(), `${k}: ${v}`); + } + if (!headerMap.has("host")) headerMap.set("host", `Host: ${url.host}`); + if (!headerMap.has("user-agent")) { + headerMap.set("user-agent", "User-Agent: baoyu-skills-wechat-api"); + } + headerMap.set("connection", "Connection: close"); + if (body && !headerMap.has("content-length")) { + headerMap.set("content-length", `Content-Length: ${body.length}`); + } + + const path = `${url.pathname || "/"}${url.search}`; + const requestHeader = Buffer.from( + `${method} ${path} HTTP/1.1\r\n` + + Array.from(headerMap.values()).join("\r\n") + + "\r\n\r\n", + "utf-8", + ); + + await writeAll(stream, requestHeader); + if (body) await writeAll(stream, body); + + const raw = await readToEnd(stream); + return parseHttpResponse(raw); +} + +function writeAll(stream: net.Socket | tls.TLSSocket, data: Buffer): Promise { + return new Promise((resolve, reject) => { + stream.write(data, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function readToEnd(stream: net.Socket | tls.TLSSocket): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.once("end", () => resolve(Buffer.concat(chunks))); + stream.once("error", reject); + }); +} + +function parseHttpResponse(raw: Buffer): WechatHttpResponse { + const headerEnd = raw.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + throw new Error("Malformed HTTP response: missing header terminator"); + } + const headerText = raw.subarray(0, headerEnd).toString("utf-8"); + let bodyBytes = raw.subarray(headerEnd + 4); + + const lines = headerText.split("\r\n"); + const statusLine = lines.shift() ?? ""; + const statusMatch = statusLine.match(/^HTTP\/[\d.]+\s+(\d+)(?:\s+(.*))?$/); + if (!statusMatch) { + throw new Error(`Malformed HTTP status line: ${statusLine}`); + } + const status = Number.parseInt(statusMatch[1]!, 10); + const statusText = statusMatch[2] ?? ""; + + const headers: Record = {}; + const lowercaseHeaders: Record = {}; + for (const line of lines) { + const colon = line.indexOf(":"); + if (colon < 0) continue; + const key = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + const lower = key.toLowerCase(); + const existing = headers[lower]; + if (existing === undefined) { + headers[lower] = value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + headers[lower] = [existing, value]; + } + lowercaseHeaders[lower] = value; + } + + const transferEncoding = (lowercaseHeaders["transfer-encoding"] ?? "").toLowerCase(); + if (transferEncoding.split(",").map((s) => s.trim()).includes("chunked")) { + bodyBytes = dechunk(bodyBytes); + } else if (lowercaseHeaders["content-length"] !== undefined) { + const length = Number.parseInt(lowercaseHeaders["content-length"]!, 10); + if (Number.isFinite(length) && length >= 0) { + bodyBytes = bodyBytes.subarray(0, length); + } + } + + return { + status, + statusText, + headers, + async buffer() { + return bodyBytes; + }, + async text() { + return bodyBytes.toString("utf-8"); + }, + async json() { + return JSON.parse(bodyBytes.toString("utf-8")) as T; + }, + }; +} + +function dechunk(raw: Buffer): Buffer { + const parts: Buffer[] = []; + let offset = 0; + while (offset < raw.length) { + const lineEnd = raw.indexOf("\r\n", offset); + if (lineEnd < 0) break; + const sizeText = raw.subarray(offset, lineEnd).toString("ascii").split(";")[0]!.trim(); + const size = Number.parseInt(sizeText, 16); + if (!Number.isFinite(size) || size < 0) { + throw new Error(`Invalid chunked-encoding size: ${sizeText}`); + } + offset = lineEnd + 2; + if (size === 0) break; + if (offset + size > raw.length) { + throw new Error("Chunked-encoding body truncated"); + } + parts.push(raw.subarray(offset, offset + size)); + offset += size + 2; + } + return Buffer.concat(parts); +}