Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Post articles and image-text content to WeChat Official Account via API or Chrome CDP, with markdown-to-WeChat HTML conversion.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/wechat-remote-publish.ts
1import { spawn, type ChildProcessByStdio } from "node:child_process";2import net from "node:net";3import type { Readable } from "node:stream";45import type { StrictHostKeyChecking } from "./wechat-extend-config.ts";6import type { WechatClient } from "./wechat-http.ts";7import { createSocksClient } from "./wechat-socks-http.ts";89export interface RemotePublishConfig {10host: string;11user?: string;12port?: number;13identityFile?: string;14knownHostsFile?: string;15strictHostKeyChecking?: StrictHostKeyChecking;16connectTimeout?: number;17proxyJump?: string;18}1920export interface NormalizedRemotePublishConfig {21host: string;22user: string;23port: number;24identityFile?: string;25knownHostsFile?: string;26strictHostKeyChecking?: StrictHostKeyChecking;27connectTimeout?: number;28proxyJump?: string;29}3031export interface SshTunnel {32port: number;33client: WechatClient;34close: () => Promise<void>;35}3637export interface StartSshTunnelOptions {38readyTimeoutMs?: number;39killTimeoutMs?: number;40}4142const DEFAULT_USER = "root";43const DEFAULT_PORT = 22;44const DEFAULT_READY_TIMEOUT_MS = 10_000;45const DEFAULT_KILL_TIMEOUT_MS = 3_000;46const SSH_LOOPBACK_HOST = "127.0.0.1";4748export function normalizeRemoteConfig(config: RemotePublishConfig): NormalizedRemotePublishConfig {49if (!config.host || !config.host.trim()) {50throw new Error("Remote publish host is required (set remote_publish_host or --remote-host).");51}5253const port = config.port ?? DEFAULT_PORT;54if (!Number.isInteger(port) || port < 1 || port > 65535) {55throw new Error(`Invalid remote publish port: ${config.port}`);56}5758if (config.connectTimeout !== undefined) {59if (!Number.isInteger(config.connectTimeout) || config.connectTimeout <= 0) {60throw new Error(`Invalid remote_publish_connect_timeout: ${config.connectTimeout}`);61}62}6364if (65config.strictHostKeyChecking !== undefined &&66config.strictHostKeyChecking !== "yes" &&67config.strictHostKeyChecking !== "no" &&68config.strictHostKeyChecking !== "accept-new"69) {70throw new Error(`Invalid remote_publish_strict_host_key_checking: ${config.strictHostKeyChecking}`);71}7273return {74host: config.host.trim(),75user: (config.user ?? DEFAULT_USER).trim() || DEFAULT_USER,76port,77identityFile: config.identityFile,78knownHostsFile: config.knownHostsFile,79strictHostKeyChecking: config.strictHostKeyChecking,80connectTimeout: config.connectTimeout,81proxyJump: config.proxyJump,82};83}8485export function buildSshArgs(config: NormalizedRemotePublishConfig, socksPort: number): string[] {86if (!Number.isInteger(socksPort) || socksPort < 1 || socksPort > 65535) {87throw new Error(`Invalid SOCKS port: ${socksPort}`);88}8990const args: string[] = [91"-N",92"-T",93"-D", `${SSH_LOOPBACK_HOST}:${socksPort}`,94"-o", "ExitOnForwardFailure=yes",95"-o", "ServerAliveInterval=30",96"-o", "ServerAliveCountMax=3",97"-p", String(config.port),98];99100if (config.identityFile) {101args.push("-i", config.identityFile);102}103if (config.knownHostsFile) {104args.push("-o", `UserKnownHostsFile=${config.knownHostsFile}`);105}106if (config.strictHostKeyChecking) {107args.push("-o", `StrictHostKeyChecking=${config.strictHostKeyChecking}`);108}109if (config.connectTimeout !== undefined) {110args.push("-o", `ConnectTimeout=${config.connectTimeout}`);111}112if (config.proxyJump) {113args.push("-J", config.proxyJump);114}115116args.push(`${config.user}@${config.host}`);117return args;118}119120export async function findFreePort(): Promise<number> {121return new Promise<number>((resolve, reject) => {122const server = net.createServer();123server.unref();124server.on("error", reject);125server.listen(0, SSH_LOOPBACK_HOST, () => {126const address = server.address();127if (!address || typeof address === "string") {128server.close(() => reject(new Error("Failed to acquire free port")));129return;130}131const port = address.port;132server.close((err) => {133if (err) reject(err);134else resolve(port);135});136});137});138}139140export async function waitForSocksReady(port: number, timeoutMs: number): Promise<void> {141const deadline = Date.now() + timeoutMs;142let lastError: unknown = undefined;143144while (Date.now() < deadline) {145try {146await tryConnect(port);147return;148} catch (err) {149lastError = err;150await sleep(150);151}152}153throw new Error(`SOCKS proxy on ${SSH_LOOPBACK_HOST}:${port} not ready within ${timeoutMs}ms${lastError ? `: ${(lastError as Error).message}` : ""}`);154}155156function tryConnect(port: number): Promise<void> {157return new Promise((resolve, reject) => {158const socket = net.connect({ host: SSH_LOOPBACK_HOST, port });159socket.once("connect", () => {160socket.destroy();161resolve();162});163socket.once("error", (err) => {164socket.destroy();165reject(err);166});167});168}169170function sleep(ms: number): Promise<void> {171return new Promise((resolve) => setTimeout(resolve, ms));172}173174export async function startSshTunnel(175config: NormalizedRemotePublishConfig,176options: StartSshTunnelOptions = {},177): Promise<SshTunnel> {178const readyTimeout = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;179const killTimeout = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;180181const port = await findFreePort();182const args = buildSshArgs(config, port);183184console.error(`[wechat-remote-publish] Starting SSH SOCKS5 tunnel: ssh ${args.join(" ")}`);185const child = spawn("ssh", args, {186stdio: ["ignore", "pipe", "pipe"],187}) as ChildProcessByStdio<null, Readable, Readable>;188189const stderrChunks: string[] = [];190child.stderr.on("data", (chunk: Buffer) => {191stderrChunks.push(chunk.toString("utf-8"));192});193194let earlyExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;195child.once("exit", (code, signal) => {196earlyExit = { code, signal };197});198199try {200await waitForSocksReady(port, readyTimeout);201} catch (err) {202await killChild(child, killTimeout);203const stderrTail = stderrChunks.join("").trim().split("\n").slice(-5).join("\n");204const suffix = stderrTail ? `\nssh stderr (tail):\n${stderrTail}` : "";205const exitSuffix = earlyExit206? `\nssh exited early with code=${earlyExit.code} signal=${earlyExit.signal}`207: "";208throw new Error(`${(err as Error).message}${exitSuffix}${suffix}`);209}210211const client = createSocksClient({ host: SSH_LOOPBACK_HOST, port });212213const signalHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = [];214let closed = false;215const close = async (): Promise<void> => {216if (closed) return;217closed = true;218for (const { signal, handler } of signalHandlers) {219process.off(signal, handler);220}221await killChild(child, killTimeout);222};223224for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {225const handler = () => {226void close();227};228process.once(signal, handler);229signalHandlers.push({ signal, handler });230}231232return { port, client, close };233}234235async function killChild(child: ChildProcessByStdio<null, Readable, Readable>, killTimeoutMs: number): Promise<void> {236if (child.exitCode !== null || child.signalCode !== null) {237return;238}239const exited = new Promise<void>((resolve) => {240child.once("exit", () => resolve());241});242try {243child.kill("SIGTERM");244} catch {245/* already dead */246}247248const timer = setTimeout(() => {249try {250child.kill("SIGKILL");251} catch {252/* already dead */253}254}, killTimeoutMs);255256try {257await exited;258} finally {259clearTimeout(timer);260}261}262263export async function withSshTunnel<T>(264config: NormalizedRemotePublishConfig,265fn: (client: WechatClient) => Promise<T>,266options?: StartSshTunnelOptions,267): Promise<T> {268const tunnel = await startSshTunnel(config, options);269try {270return await fn(tunnel.client);271} finally {272await tunnel.close();273}274}275