diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65b47f0..8ccb7da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + # npm 10 bundled with Node 22 crashes resolving Vitest's peer dependencies. + # https://github.com/npm/cli/issues/9787 + - run: npm install --global npm@11.18.0 - run: npm install - run: npm run build - run: npx vitest run diff --git a/.github/workflows/wechat-codex.yml b/.github/workflows/wechat-codex.yml new file mode 100644 index 0000000..6468d5b --- /dev/null +++ b/.github/workflows/wechat-codex.yml @@ -0,0 +1,33 @@ +name: WeChat Codex skill + +on: + pull_request: + paths: ['skills/wechat-codex/**', '.github/workflows/wechat-codex.yml', 'README.md', 'README.EN.MD'] + push: + branches: [main] + paths: ['skills/wechat-codex/**', '.github/workflows/wechat-codex.yml'] + +permissions: + contents: read + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: node --test skills/wechat-codex/tests/*.test.mjs + - run: python3 -m unittest discover -s skills/wechat-codex/tests -p 'test_*.py' + - name: Scaffold and install the standalone bridge + run: | + python3 skills/wechat-codex/scripts/setup.py --target "$RUNNER_TEMP/wechat-codex" + npm --prefix "$RUNNER_TEMP/wechat-codex" ci --ignore-scripts --no-audit --no-fund + node --check "$RUNNER_TEMP/wechat-codex/codex-wechat.mjs" diff --git a/.gitignore b/.gitignore index bafd0ad..2de21a7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ tmp/ next-env.d.ts target/ package-lock.json +!skills/wechat-codex/assets/bridge/package-lock.json __pycache__/ *.pyc *.egg-info/ diff --git a/README.EN.MD b/README.EN.MD index a20ad41..ffe7ecf 100644 --- a/README.EN.MD +++ b/README.EN.MD @@ -128,6 +128,37 @@ pi install npm:@wechatbot/pi-agent See [pi-agent/README.md](pi-agent/README.md) for details. +## 🤖 Codex WeChat Skill + +[wechat-codex](skills/wechat-codex/SKILL.md) connects WeChat to your local Codex CLI. Scan the QR code, send text tasks, continue the same session, and receive results in WeChat. It reuses your Codex login without requiring Pi, OpenClaw, or SDK core changes. + +Requires **Node.js 22+, Python 3, and an authenticated Codex CLI** supporting `exec`, `exec resume`, and `--ignore-user-config`. The bridge currently targets **macOS / Linux**. Keep the host running and connected to the internet. + +First-time skill installation, from the repository root: + +```bash +skill_target="${CODEX_HOME:-$HOME/.codex}/skills/wechat-codex" +test ! -e "$skill_target" && mkdir -p "$(dirname "$skill_target")" && cp -R skills/wechat-codex "$skill_target" +``` + +In a Codex task that discovers the skill, ask: + +> Use $wechat-codex to connect Codex to WeChat. + +The skill checks prerequisites, prepares a standalone bridge directory, displays the QR code, and verifies login and replies. You still scan and confirm on your phone. To run the bridge directly: + +```bash +python3 skills/wechat-codex/scripts/setup.py --target ./wechat-codex-local +npm --prefix ./wechat-codex-local ci --ignore-scripts --no-audit --no-fund +node ./wechat-codex-local/codex-wechat.mjs +``` + +WeChat commands: `/help`, `/status`, `/new`, `/cancel`, `/stop`. Tasks use the bridge's `codex-workspace` directory by default; set `WECHAT_CODEX_WORKDIR` to select a project. + +The first task creates an **independent Codex session**. Follow-ups resume its exact ID, shown by `/status`; open it locally with `codex resume `. In Codex App environments exposing task-navigation tools, the skill can also open the session by ID. Avoid simultaneous local and WeChat submissions to the same session. + +This version supports text input/replies, paired-sender filtering, one task at a time, a ten-minute execution limit, and bounded login retries. It uses the `workspace-write` sandbox; operations requiring additional approval cannot be approved through the bridge. WeChat credentials stay in memory. Restarting requires a new scan and starts a new conversation; Codex's saved history remains on disk. Claude Code is not included in this skill yet. + ## ✨ Features All SDKs share the following capabilities: @@ -209,6 +240,7 @@ graph TD | [golang/README.md](golang/README.md) | Go SDK Documentation | | [rust/README.md](rust/README.md) | Rust SDK Documentation | | [pi-agent/README.md](pi-agent/README.md) | Pi Extension (WeChat ↔ Pi Bridge) | +| [skills/wechat-codex/SKILL.md](skills/wechat-codex/SKILL.md) | Codex WeChat skill, runtime scripts, and troubleshooting | ## 🌐 Website @@ -233,6 +265,7 @@ wechatbot/ │ └── src/ # 6 modules ├── pi-agent/ # Pi Extension (WeChat ↔ Pi Bridge) │ └── src/ # Extension entry & WeChat client +├── skills/wechat-codex/ # Codex WeChat skill and standalone bridge template └── docs/ # Shared documentation ├── protocol.md # iLink API Protocol Spec └── architecture.md # Architecture & SDK Comparison diff --git a/README.md b/README.md index e93eec0..cc165f0 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ graph TD | [golang/README.md](golang/README.md) | Go SDK 文档 | | [rust/README.md](rust/README.md) | Rust SDK 文档 | | [pi-agent/README.md](pi-agent/README.md) | Pi 扩展文档(微信 ↔ Pi 桥接) | +| [skills/wechat-codex/SKILL.md](skills/wechat-codex/SKILL.md) | Codex 微信接入 Skill、运行脚本与排障 | ## 🌐 网站 @@ -172,6 +173,37 @@ pi install npm:@wechatbot/pi-agent /wechat # 显示二维码 → 微信扫码 → 连接成功! ``` +## 🤖 Codex 微信 Skill + +[wechat-codex](skills/wechat-codex/SKILL.md) 将微信连接到本机 Codex CLI:扫码后可以发送文字任务、继续同一个会话,并接收执行结果。它复用已有的 Codex 登录,不需要安装 Pi 或 OpenClaw,也不修改 SDK 核心。 + +要求 **Node.js 22+、Python 3、已登录的 Codex CLI**(支持 `exec`、`exec resume` 和 `--ignore-user-config`)。桥接进程目前面向 **macOS / Linux**,电脑需要保持开机和联网。 + +在仓库根目录首次安装 skill: + +```bash +skill_target="${CODEX_HOME:-$HOME/.codex}/skills/wechat-codex" +test ! -e "$skill_target" && mkdir -p "$(dirname "$skill_target")" && cp -R skills/wechat-codex "$skill_target" +``` + +然后在能够发现该 skill 的 Codex 任务中说: + +> 用 $wechat-codex 把 Codex 接入微信。 + +Skill 会检查环境、准备独立桥接目录、展示二维码,并核实登录和消息回传。扫码确认需要本人完成。也可以直接运行桥接: + +```bash +python3 skills/wechat-codex/scripts/setup.py --target ./wechat-codex-local +npm --prefix ./wechat-codex-local ci --ignore-scripts --no-audit --no-fund +node ./wechat-codex-local/codex-wechat.mjs +``` + +微信命令:`/help`、`/status`、`/new`、`/cancel`、`/stop`。默认在桥接目录的 `codex-workspace` 中执行任务,可用 `WECHAT_CODEX_WORKDIR` 指定项目路径。 + +第一条任务创建**独立的 Codex 会话**,后续消息通过明确的 session ID 续接;`/status` 显示 ID,可在本机用 `codex resume ` 查看。Skill 在支持任务导航工具的 Codex App 中也可以按 ID 打开会话。请避免微信和本地同时向同一会话提交任务。 + +当前支持文字输入与回复、配对账号限制、单任务执行、10 分钟超时和有界登录重试。使用 `workspace-write` 沙箱,额外审批不可通过该桥接完成。微信凭证只在内存中;重启需重新扫码并创建新对话,Codex 自身的历史记录仍保存在本地。Claude Code 暂未包含在此 skill 中。 + ## 🔧 预编译 Echo Bot 不想写代码?可以直接下载预编译的 Echo Bot 体验。详见 [GitHub Releases](https://github.com/corespeed-io/wechatbot/releases)。 @@ -203,6 +235,7 @@ wechatbot/ │ └── src/ # 6 个模块 ├── pi-agent/ # Pi 扩展(微信 ↔ Pi 桥接) │ └── src/ # 扩展入口 & 微信客户端 +├── skills/wechat-codex/ # Codex 微信 Skill 与独立桥接模板 └── docs/ # 共享文档 ├── protocol.md # iLink API 协议规范 └── architecture.md # 架构 & SDK 对比 diff --git a/skills/wechat-codex/LICENSE b/skills/wechat-codex/LICENSE new file mode 100644 index 0000000..40ef3ef --- /dev/null +++ b/skills/wechat-codex/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CoreSpeed and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/wechat-codex/SKILL.md b/skills/wechat-codex/SKILL.md new file mode 100644 index 0000000..fc972b2 --- /dev/null +++ b/skills/wechat-codex/SKILL.md @@ -0,0 +1,69 @@ +--- +name: wechat-codex +description: Connect WeChat to a local Codex CLI session, display its login QR code, verify message delivery, locate or open the linked session, and troubleshoot or stop the bridge. Use for requests such as 把 Codex 接入微信 or 查看微信连接状态. +--- + +# WeChat ↔ Codex + +Operate a local bridge backed by `@wechatbot/wechatbot` and the user's installed Codex CLI. A skill orchestrates setup and troubleshooting; a running Node process handles ongoing messages. Do not substitute Pi, Claude, or OpenClaw for a requested Codex connection. + +## Start or reconnect + +1. Look for the bridge already used in the current task. Read its status before installing or restarting anything. Keep a healthy existing connection running. One WeChat account should have one polling instance; don't restart merely to apply a skill update. +2. Check `node --version` (22+), `codex --version`, `codex login status`, and relevant flags in `codex exec --help`. Reuse existing Codex authentication. If login is missing, let the user complete `codex login`; do not ask them to paste tokens into chat. The bundled cancellation implementation targets macOS/Linux; do not claim Windows support without adapting and testing process handling. +3. Use the user's chosen project as the Codex working directory. If none is chosen, use the bridge's dedicated `codex-workspace` directory and say so. This starts an independent CLI session, not the current App conversation. Only attach an existing session when the user identifies it, and first ensure it has no other writer. +4. Scaffold into a task-owned directory, preferably an `outputs/` directory for a deliverable: + + ```sh + python3 /scripts/setup.py --target + npm --prefix ci --ignore-scripts --no-audit --no-fund + ``` + + `setup.py` refuses to overwrite existing files. Dependencies and bridge code belong in the target, never run from or install dependencies into the skill directory. +5. Launch with a long-lived PTY so a WeChat pairing-code prompt can be completed if needed. Keep the host awake and retain the process handle; this skill does not install a system service: + + ```sh + node /codex-wechat.mjs + ``` + + Optional `WECHAT_CODEX_WORKDIR` selects the task workspace; `WECHAT_CODEX_BIN` selects an explicitly located Codex binary. Verify `QR_READY`, then display `/codex-login-qr.png` as a local image. The user scans and confirms. Never claim success just because a QR exists. Login timeouts retry at most three times; terminal failures need diagnosis rather than an unbounded restart loop. +6. Verify `LOGIN_OK` / `ONLINE`. Ask the user to send one short task, then check `REPLY_OK` or the status file's successful reply count. A Codex-only smoke test is not a WeChat round-trip test. Report exactly which stages passed. + +## Status and finding the session + +```sh +python3 /scripts/status.py --target +``` + +The helper reads only bridge metadata. `processAlive` means only that a PID exists; `fresh` checks the heartbeat. Even fresh `online` status describes the last reported state, not a new network probe. Report the last failure when present. + +The first task creates a Codex session; the runner resumes only that exact ID afterward. `/status` displays the ID. `/new` resets it for the next task. The bundled bridge starts a new conversation after process restart; don't promise cross-restart conversation recovery. + +To view a session: + +- In Codex App, use `read_thread` with the exact ID, then `navigate_to_codex_page` when the user wants it opened. A CLI-created session can be readable even when absent from the sidebar list. Do not declare it invisible merely because `list_threads` omits it. +- In a terminal, use `codex resume `. The picker may need `--all --include-non-interactive` on versions supporting those flags. Avoid simultaneous App/terminal and WeChat writes to the same session. +- For an older bridge without status metadata, search only the first `session_meta` line of local session files, restricted to the exact bridge working directory and relevant dates. Treat the newest match as a candidate until verified; do not read or export unrelated session histories. + +## Commands and boundaries + +Text tasks are sent to Codex and replies return to the authenticated WeChat sender. Commands: `/help`, `/status`, `/new`, `/cancel`, `/stop`. Busy requests are rejected rather than silently queued. The runtime deduplicates message IDs, bounds task duration, and restricts messages to the account that scanned in. + +The default uses `workspace-write` and no interactive approvals. Explain failed operations; don't bypass the sandbox. `--ignore-user-config` avoids automatically importing unrelated MCP configuration while retaining CLI authentication. This does not mean the entire machine is inaccessible or all global context is disabled. + +WeChat credentials stay in process memory. Codex persists its own sessions normally. Runtime metadata contains the session ID and status, not tokens or message bodies. Keep generated QR codes, runtime files, account identifiers and private sessions out of shared skill bundles and PRs. + +Stop via `/stop` or signal the verified bridge process. Do not kill processes using a broad `pkill node` or `pkill codex` pattern. If a bridge is healthy, status requests should be read-only. + +## Troubleshooting learned from live use + +- `QR_READY` followed by a login `TimeoutError`: QR-status polling failed, not proof of bad Codex authentication. Bounded login retries issue a fresh QR; show the latest one. +- `ONLINE` with no replies: check successful reply count, task errors, sender matching, and Codex auth separately. Don't print credential contents to diagnose. +- Session missing from sidebar: use its exact ID and read/open it directly before proposing a different architecture. +- If the user wants a public contribution, package this bridge and workflow as an example/skill. Local installation alone does not imply permission to publish a PR. + +Sources: [WeChatBot SDK](https://www.wechatbot.dev/zh/nodejs), [Codex non-interactive mode](https://learn.chatgpt.com/docs/non-interactive-mode), [Tencent's OpenClaw Weixin plugin](https://github.com/Tencent/openclaw-weixin). The Tencent plugin is an alternative integration path, not a dependency of this bridge. + +## Maintainer validation + +From the repository root, run `node --test skills/wechat-codex/tests/*.test.mjs` and `python3 -m unittest discover -s skills/wechat-codex/tests -p 'test_*.py'`. Tests use temporary workspaces and fake Codex processes; they do not authenticate to WeChat or send messages. A real scan and reply remains a separate manual integration check. diff --git a/skills/wechat-codex/assets/bridge/.gitignore b/skills/wechat-codex/assets/bridge/.gitignore new file mode 100644 index 0000000..0ccc633 --- /dev/null +++ b/skills/wechat-codex/assets/bridge/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.runtime/ +codex-workspace/ +*-login-qr.png diff --git a/skills/wechat-codex/assets/bridge/codex-runner.mjs b/skills/wechat-codex/assets/bridge/codex-runner.mjs new file mode 100644 index 0000000..7f7428a --- /dev/null +++ b/skills/wechat-codex/assets/bridge/codex-runner.mjs @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +export class CodexRunner { + constructor({ cwd, runtimeDir, executable = 'codex', timeoutMs = 10 * 60_000 }) { + Object.assign(this, { cwd, runtimeDir, executable, timeoutMs }); + this.threadId = null; + this.child = null; + this.running = false; + } + cancel() { + if (!this.child) return false; + this.cancelled = true; + this.terminate(); + return true; + } + terminate() { + const child = this.child; + if (!child?.pid) return; + try { process.kill(-child.pid, 'SIGTERM'); } catch {} + this.killTimer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 3000); + this.killTimer.unref(); + } + reset() { + if (this.running) throw new Error('请先取消当前任务。'); + this.threadId = null; + } + async run(prompt) { + if (this.running) throw new Error('Codex 正在处理上一条消息,请稍后再发。'); + this.running = true; + this.cancelled = false; + let timedOut = false; + let dir; + try { + await mkdir(this.runtimeDir, { recursive: true, mode: 0o700 }); + dir = await mkdtemp(join(this.runtimeDir, 'run-')); + const output = join(dir, 'reply.txt'); + const args = ['exec', '--ignore-user-config', '--skip-git-repo-check', + '--sandbox', 'workspace-write', '-c', 'approval_policy="never"', + '-C', this.cwd]; + if (this.threadId) args.push('resume', this.threadId); + args.push('--json', '-o', output, '-'); + const child = spawn(this.executable, args, { cwd: this.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'] }); + this.child = child; + child.stdout.setEncoding('utf8'); + const timer = setTimeout(() => { timedOut = true; this.terminate(); }, this.timeoutMs); + let buffer = ''; + let lastError = ''; + child.stdout.on('data', chunk => { + buffer += chunk; + let index; + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index); buffer = buffer.slice(index + 1); + try { + const event = JSON.parse(line); + if (event.type === 'thread.started') this.threadId = event.thread_id; + if (event.type === 'turn.failed') lastError = event.error?.message ?? 'Codex 执行失败'; + } catch {} + } + }); + child.stderr.resume(); + child.stdin.on('error', () => {}); + const completed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', code => resolve(code)); + }); + child.stdin.end(prompt); + let code; + try { code = await completed; } finally { clearTimeout(timer); } + if (this.cancelled) throw new Error('任务已取消。'); + if (timedOut) throw new Error('任务超过执行时限,已停止;可以拆分任务后重试。'); + if (code !== 0) throw new Error(lastError || 'Codex 执行失败,请在电脑上检查登录状态或额度。'); + const reply = (await readFile(output, 'utf8')).trim(); + if (!reply) throw new Error('Codex 没有返回文本。'); + return reply; + } finally { + clearTimeout(this.killTimer); + this.child = null; + this.running = false; + if (dir) await rm(dir, { recursive: true, force: true }); + } + } +} diff --git a/skills/wechat-codex/assets/bridge/codex-wechat.mjs b/skills/wechat-codex/assets/bridge/codex-wechat.mjs new file mode 100644 index 0000000..d9d472b --- /dev/null +++ b/skills/wechat-codex/assets/bridge/codex-wechat.mjs @@ -0,0 +1,103 @@ +import { WeChatBot } from '@wechatbot/wechatbot'; +import QRCode from 'qrcode'; +import { fileURLToPath } from 'node:url'; +import { mkdir } from 'node:fs/promises'; +import { CodexRunner } from './codex-runner.mjs'; +import { loginWithRetry } from './login-retry.mjs'; +import { resolve } from 'node:path'; +import { createStatus } from './runtime-status.mjs'; +import { acceptMessage } from './message-gate.mjs'; + +const cwd = process.env.WECHAT_CODEX_WORKDIR ? resolve(process.env.WECHAT_CODEX_WORKDIR) : fileURLToPath(new URL('./codex-workspace/', import.meta.url)); +const runtimeDir = fileURLToPath(new URL('./.runtime/', import.meta.url)); +await mkdir(cwd, { recursive: true }); +const runner = new CodexRunner({ cwd, runtimeDir, executable: process.env.WECHAT_CODEX_BIN || 'codex' }); +const bot = new WeChatBot({ storage: 'memory', logLevel: 'silent' }); +const seen = new Set(); +let owner; +let busy = false; +let stopping = false; +let handled = 0; +const status = createStatus(runtimeDir, () => ({ cwd, threadId: runner.threadId, busy, successfulReplies: handled })); +const help = '已接入本机 Codex。直接发送任务即可,例如:写一个 Python 猜数字游戏并测试。\n\n/status:状态\n/new:新对话\n/cancel:取消当前任务\n/stop:停止连接\n/help:帮助\n\n当前支持文字,文件修改使用专用 codex-workspace 目录。'; + +async function shutdown() { + if (stopping) return; + stopping = true; + status.update('stopping'); + runner.cancel(); + bot.stop(); + console.log('STOPPED'); + setTimeout(() => process.exit(0), 4000).unref(); +} + +bot.on('error', error => { status.update('degraded', error.name); console.error('WECHAT_ERROR', error.name); }); +bot.on('poll:start', () => { status.update('online', null); console.log('ONLINE:微信已连接 Codex,等待文字任务。'); }); +bot.onMessage(async msg => { + if (stopping || !acceptMessage(msg, owner, seen)) return; + if (msg.type !== 'text') { + await bot.reply(msg, '当前版本支持文字任务,请用文字发送。').catch(() => {}); + return; + } + const text = (msg.text ?? '').trim(); + try { + if (text === '/help' || text === '帮助') return await bot.reply(msg, help); + if (text === '/status') return await bot.reply(msg, `Codex ${busy ? '正在执行任务' : '空闲'};已完成 ${handled} 次任务。\n工作目录:${cwd}\n会话:${runner.threadId || '尚未开始'}`); + if (text === '/cancel') return await bot.reply(msg, runner.cancel() ? '正在取消当前任务。' : '当前没有运行中的任务。'); + if (text === '/stop') { await bot.reply(msg, '已停止微信 Codex 连接。'); return await shutdown(); } + if (text === '/new') { + if (busy) return await bot.reply(msg, '请先 /cancel 取消当前任务,再开始新对话。'); + runner.reset(); status.update('online', null); return await bot.reply(msg, '已开始新对话,工作目录中的文件保留。'); + } + if (!text) return await bot.reply(msg, '当前支持文字任务。请发送文字,或 /help 查看说明。'); + if (text.length > 16000) return await bot.reply(msg, '这条消息太长,请拆成几段任务发送。'); + if (busy) return await bot.reply(msg, 'Codex 正在执行上一条任务;完成后再发,或 /cancel 取消。'); + busy = true; + status.update('busy', null); + let typing; + try { + await bot.reply(msg, '收到,Codex 开始处理。'); + await bot.sendTyping(msg.userId).catch(() => {}); + typing = setInterval(() => void bot.sendTyping(msg.userId).catch(() => {}), 8000); + const answer = await runner.run(text); + await bot.reply(msg, answer); + handled++; + status.update('online', null); + console.log(`SESSION_ID ${runner.threadId}`); + console.log(`REPLY_OK count=${handled}`); + } catch (error) { + status.update('task_failed', error.name); + if (!stopping) await bot.reply(msg, `任务未完成:${error.message}`); + console.error('TASK_FAILED'); + } finally { + clearInterval(typing); + await bot.stopTyping(msg.userId).catch(() => {}); + busy = false; + status.update(); + } + } catch { status.update('degraded', 'ReplyError'); console.error('WECHAT_REPLY_FAILED'); } +}); + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); +const credentials = await loginWithRetry(() => bot.login({ callbacks: { + onQrUrl: url => { + void QRCode.toFile(fileURLToPath(new URL('./codex-login-qr.png', import.meta.url)), url, { width: 420, margin: 4 }) + .then(() => { status.update('awaiting_scan', null); console.log('QR_READY:codex-login-qr.png'); }) + .catch(() => { console.error('QR_FAILED'); process.exit(1); }); + }, + onScanned: () => { status.update('awaiting_confirmation', null); console.log('SCANNED:请在微信确认。'); }, + onExpired: () => { status.update('qr_expired'); console.log('QR_EXPIRED:正在刷新。'); }, +}}), { + stopped: () => stopping, + notify: (attempt, reason) => { status.update('login_retry', reason); console.log(`LOGIN_RETRY ${attempt}/3 reason=${reason}:网络请求失败,正在重新获取二维码。`); }, +}).catch(error => { + status.update('failed', error.name); + console.error(`LOGIN_FAILED reason=${error.name}:本轮登录未完成,请重新启动。`); + process.exitCode = 1; + return null; +}); +if (!credentials) process.exit(process.exitCode ?? 0); +owner = credentials.userId; +console.log('LOGIN_OK'); +await bot.start(); diff --git a/skills/wechat-codex/assets/bridge/login-retry.mjs b/skills/wechat-codex/assets/bridge/login-retry.mjs new file mode 100644 index 0000000..2800be0 --- /dev/null +++ b/skills/wechat-codex/assets/bridge/login-retry.mjs @@ -0,0 +1,15 @@ +import { setTimeout as delay } from 'node:timers/promises'; + +export async function loginWithRetry(login, { stopped = () => false, notify = () => {}, wait = delay } = {}) { + for (let attempt = 1; attempt <= 4; attempt++) { + if (stopped()) return null; + try { return await login(); } + catch (error) { + const transient = ['TimeoutError', 'AbortError', 'TransportError'].includes(error.name); + if (stopped()) return null; + if (!transient || attempt === 4) throw error; + notify(attempt, error.name); + await wait(Math.min(2000 * attempt, 6000)); + } + } +} diff --git a/skills/wechat-codex/assets/bridge/message-gate.mjs b/skills/wechat-codex/assets/bridge/message-gate.mjs new file mode 100644 index 0000000..f5eb57c --- /dev/null +++ b/skills/wechat-codex/assets/bridge/message-gate.mjs @@ -0,0 +1,11 @@ +// Only paired direct messages may invoke Codex, including control commands. +export function acceptMessage(message, owner, seen) { + if (!owner || message.userId !== owner || message.raw?.group_id) return false; + const id = message.raw?.message_id ?? (message.raw?.client_id || undefined); + if (id != null) { + if (seen.has(id)) return false; + seen.add(id); + if (seen.size > 1000) seen.delete(seen.values().next().value); + } + return true; +} diff --git a/skills/wechat-codex/assets/bridge/package-lock.json b/skills/wechat-codex/assets/bridge/package-lock.json new file mode 100644 index 0000000..1e10322 --- /dev/null +++ b/skills/wechat-codex/assets/bridge/package-lock.json @@ -0,0 +1,333 @@ +{ + "name": "wechat-codex-bridge", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wechat-codex-bridge", + "version": "0.1.0", + "dependencies": { + "@wechatbot/wechatbot": "2.2.0", + "qrcode": "1.5.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@wechatbot/wechatbot": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@wechatbot/wechatbot/-/wechatbot-2.2.0.tgz", + "integrity": "sha512-M3+RbTrwr5pW2GQC8RwGWh2JTZkSUHJFIjgs6R4UlBb4IaTOoTu6CmEW3G7MEGwU0ScswXCAN/iYfk1w4/UYeg==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/skills/wechat-codex/assets/bridge/package.json b/skills/wechat-codex/assets/bridge/package.json new file mode 100644 index 0000000..605a6ae --- /dev/null +++ b/skills/wechat-codex/assets/bridge/package.json @@ -0,0 +1,9 @@ +{ + "name": "wechat-codex-bridge", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { "node": ">=22" }, + "scripts": { "start": "node codex-wechat.mjs" }, + "dependencies": { "@wechatbot/wechatbot": "2.2.0", "qrcode": "1.5.4" } +} diff --git a/skills/wechat-codex/assets/bridge/runtime-status.mjs b/skills/wechat-codex/assets/bridge/runtime-status.mjs new file mode 100644 index 0000000..63dfd3a --- /dev/null +++ b/skills/wechat-codex/assets/bridge/runtime-status.mjs @@ -0,0 +1,44 @@ +import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; + +export function createStatus(dir, snapshot = () => ({})) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const lock = join(dir, 'bridge.lock'); + const path = join(dir, 'status.json'); + function acquire() { + writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 }); + } + try { acquire(); } + catch (error) { + if (error.code !== 'EEXIST') throw error; + const pid = Number(readFileSync(lock, 'utf8')); + if (!Number.isInteger(pid) || pid <= 0) throw new Error('Invalid bridge lock; inspect before restarting.'); + let alive = true; + try { process.kill(pid, 0); } catch (e) { if (e.code === 'ESRCH') alive = false; else throw e; } + if (alive) throw new Error('A bridge process is already using this directory.'); + unlinkSync(lock); + acquire(); + } + let state = 'starting'; + let lastError = null; + function update(next = state, error = lastError) { + state = next; lastError = error; + const value = { pid: process.pid, state, lastError, ...snapshot(), updatedAt: new Date().toISOString() }; + const temp = `${path}.${process.pid}.tmp`; + writeFileSync(temp, JSON.stringify(value, null, 2), { mode: 0o600 }); + renameSync(temp, path); + } + update(); + const heartbeat = setInterval(() => update(), 15_000); + heartbeat.unref(); + let closed = false; + function close() { + if (closed) return; + closed = true; + clearInterval(heartbeat); + try { update(state === 'failed' ? 'failed' : 'stopped'); } catch {} + try { if (readFileSync(lock, 'utf8') === String(process.pid)) unlinkSync(lock); } catch {} + } + process.once('exit', close); + return { update, close }; +} diff --git a/skills/wechat-codex/scripts/setup.py b/skills/wechat-codex/scripts/setup.py new file mode 100644 index 0000000..783c197 --- /dev/null +++ b/skills/wechat-codex/scripts/setup.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Copy the self-contained bridge into a new task-owned directory.""" +import argparse +import shutil +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--target', required=True, type=Path) +args = parser.parse_args() +target = args.target.expanduser().resolve() +assets = Path(__file__).resolve().parents[1] / 'assets' / 'bridge' +files = [p for p in assets.rglob('*') if p.is_file()] +conflicts = [str(p.relative_to(assets)) for p in files if (target / p.relative_to(assets)).exists()] +if conflicts: + parser.error('Refusing to overwrite existing files: ' + ', '.join(conflicts)) +for source in files: + dest = target / source.relative_to(assets) + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, dest) +print(f'Bridge prepared: {target}') +print('Next: npm ci --ignore-scripts --no-audit --no-fund in that directory') diff --git a/skills/wechat-codex/scripts/status.py b/skills/wechat-codex/scripts/status.py new file mode 100644 index 0000000..dc0b8d4 --- /dev/null +++ b/skills/wechat-codex/scripts/status.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Inspect bridge metadata without reading credentials or conversation bodies.""" +import argparse +import json +import os +import time +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--target', required=True, type=Path) +args = parser.parse_args() +p = args.target.expanduser().resolve() / '.runtime' / 'status.json' +if not p.exists(): + print(json.dumps({'state': 'unknown', 'reason': 'No bridge status metadata; inspect its process/logs.'})) + raise SystemExit(0) +try: + data = json.loads(p.read_text()) +except (OSError, ValueError) as error: + print(json.dumps({'state': 'unknown', 'reason': type(error).__name__})) + raise SystemExit(1) +alive = False +pid = data.get('pid') +if isinstance(pid, int) and pid > 0: + try: + os.kill(pid, 0) + alive = True + except PermissionError: + alive = True + except ProcessLookupError: + pass +data['processAlive'] = alive +data['fresh'] = alive and time.time() - p.stat().st_mtime < 45 +print(json.dumps(data, ensure_ascii=False, indent=2)) diff --git a/skills/wechat-codex/tests/bridge.test.mjs b/skills/wechat-codex/tests/bridge.test.mjs new file mode 100644 index 0000000..9752c72 --- /dev/null +++ b/skills/wechat-codex/tests/bridge.test.mjs @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, readFile, chmod, readdir, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { CodexRunner } from '../assets/bridge/codex-runner.mjs'; +import { acceptMessage } from '../assets/bridge/message-gate.mjs'; +import { loginWithRetry } from '../assets/bridge/login-retry.mjs'; +import { createStatus } from '../assets/bridge/runtime-status.mjs'; + +async function fixture(t, timeoutMs = 5000) { + const dir = await mkdtemp(join(tmpdir(), 'wechat-codex-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const executable = join(dir, 'fake codex.mjs'); + await writeFile(executable, `#!/usr/bin/env node +import {writeFileSync} from 'node:fs'; +let prompt=''; for await(const chunk of process.stdin) prompt+=chunk; +const args=process.argv.slice(2); +if(prompt==='hang') { setInterval(()=>{},1000); } +else if(prompt==='fail') { + console.log(JSON.stringify({type:'turn.failed',error:{message:'synthetic failure'}})); + process.exitCode=1; +} else { + if(args.includes('resume') && args[args.indexOf('resume')+1]!=='test-thread') process.exit(2); + console.log(JSON.stringify({type:'thread.started',thread_id:'test-thread'})); + if(prompt!=='empty') writeFileSync(args[args.indexOf('-o')+1],JSON.stringify({prompt,args})); + else writeFileSync(args[args.indexOf('-o')+1],''); +} +`); + await chmod(executable, 0o700); + return { dir, runner: new CodexRunner({ cwd: dir, runtimeDir: join(dir, 'runtime'), executable, timeoutMs }) }; +} + +test('passes prompts via stdin, resumes the exact session, and resets', async t => { + const { dir, runner } = await fixture(t); + const prompt = '你好\n$(touch NEVER_EXECUTE) `echo nope`'; + const first = JSON.parse(await runner.run(prompt)); + assert.equal(first.prompt, prompt); + assert.equal(first.args.includes(prompt), false); + assert.equal(existsSync(join(dir, 'NEVER_EXECUTE')), false); + assert.equal(first.args[first.args.indexOf('--sandbox') + 1], 'workspace-write'); + assert.ok(first.args.includes('--ignore-user-config')); + assert.equal(runner.threadId, 'test-thread'); + const second = JSON.parse(await runner.run('follow-up')); + assert.equal(second.args[second.args.indexOf('resume') + 1], 'test-thread'); + assert.equal(second.args.includes('--last'), false); + runner.reset(); + assert.equal(runner.threadId, null); + assert.equal(JSON.parse(await runner.run('fresh')).args.includes('resume'), false); + assert.deepEqual(await readdir(join(dir, 'runtime')), []); +}); + +test('rejects simultaneous calls before spawn and cancels a running process', async t => { + const { runner } = await fixture(t); + const running = runner.run('hang'); + const rejected = assert.rejects(running, /已取消/); + await assert.rejects(runner.run('second'), /上一条消息/); + assert.throws(() => runner.reset(), /取消/); + for (let i = 0; !runner.child && i < 200; i++) await delay(5); + assert.equal(runner.cancel(), true); + await rejected; + assert.equal(runner.child, null); + assert.equal(runner.running, false); + assert.equal(runner.cancel(), false); +}); + +test('times out and releases the runner', async t => { + const { runner } = await fixture(t, 150); + await assert.rejects(runner.run('hang'), /执行时限/); + assert.equal(runner.running, false); +}); + +test('propagates failures and empty replies, cleaning temporary output', async t => { + const { dir, runner } = await fixture(t); + await assert.rejects(runner.run('fail'), /synthetic failure/); + await assert.rejects(runner.run('empty'), /没有返回文本/); + runner.executable = join(dir, 'missing-binary'); + await assert.rejects(runner.run('hello'), { code: 'ENOENT' }); + assert.equal(runner.running, false); + assert.deepEqual(await readdir(join(dir, 'runtime')), []); +}); + +test('accepts only the paired sender in DMs and bounds duplicate history', () => { + const seen = new Set(); + const msg = { userId: 'owner', raw: { message_id: 0 } }; + assert.equal(acceptMessage(msg, undefined, seen), false); + assert.equal(acceptMessage({ ...msg, userId: 'other' }, 'owner', seen), false); + assert.equal(acceptMessage({ ...msg, raw: { group_id: 'group' } }, 'owner', seen), false); + assert.equal(seen.size, 0); + assert.equal(acceptMessage(msg, 'owner', seen), true); + assert.equal(acceptMessage(msg, 'owner', seen), false); + const noId = { userId: 'owner', raw: { client_id: '' } }; + assert.equal(acceptMessage(noId, 'owner', seen), true); + assert.equal(acceptMessage(noId, 'owner', seen), true); + for (let id = 1; id <= 1001; id++) acceptMessage({ ...msg, raw: { message_id: id } }, 'owner', seen); + assert.equal(seen.size, 1000); +}); + +test('retries only transient login errors, with a finite limit', async () => { + let calls = 0; + const transient = () => Object.assign(new Error('timeout'), { name: 'TimeoutError' }); + assert.equal(await loginWithRetry(async () => { + if (++calls < 3) throw transient(); + return 'credentials'; + }, { wait: async () => {} }), 'credentials'); + assert.equal(calls, 3); + calls = 0; + await assert.rejects(loginWithRetry(async () => { calls++; throw transient(); }, { wait: async () => {} }), { name: 'TimeoutError' }); + assert.equal(calls, 4); + calls = 0; + await assert.rejects(loginWithRetry(async () => { calls++; throw new Error('auth'); }), /auth/); + assert.equal(calls, 1); + assert.equal(await loginWithRetry(async () => assert.fail(), { stopped: () => true }), null); +}); + +test('locks the runtime and writes observable session status', async t => { + const dir = await mkdtemp(join(tmpdir(), 'wechat-status-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const status = createStatus(dir, () => ({ threadId: 'test-thread', successfulReplies: 2 })); + t.after(() => status.close()); + assert.throws(() => createStatus(dir), /already using/); + status.update('online', null); + const stored = JSON.parse(await readFile(join(dir, 'status.json'), 'utf8')); + assert.equal(stored.threadId, 'test-thread'); + assert.equal(stored.successfulReplies, 2); + status.update('failed', 'TimeoutError'); + status.close(); + assert.equal(existsSync(join(dir, 'bridge.lock')), false); + assert.equal(JSON.parse(await readFile(join(dir, 'status.json'))).state, 'failed'); +}); diff --git a/skills/wechat-codex/tests/test_scripts.py b/skills/wechat-codex/tests/test_scripts.py new file mode 100644 index 0000000..8543a1e --- /dev/null +++ b/skills/wechat-codex/tests/test_scripts.py @@ -0,0 +1,47 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SKILL = Path(__file__).resolve().parents[1] + + +class HelpersTest(unittest.TestCase): + def run_script(self, name, target): + return subprocess.run([sys.executable, str(SKILL / 'scripts' / name), '--target', str(target)], capture_output=True, text=True) + + def test_setup_and_refuse_overwrite(self): + with tempfile.TemporaryDirectory() as temp: + target = Path(temp) / 'bridge with spaces' + self.assertEqual(self.run_script('setup.py', target).returncode, 0) + self.assertTrue((target / 'package-lock.json').exists()) + self.assertTrue((target / 'message-gate.mjs').exists()) + (target / 'package.json').write_text('existing custom content') + second = self.run_script('setup.py', target) + self.assertNotEqual(second.returncode, 0) + self.assertIn('Refusing to overwrite', second.stderr) + self.assertEqual((target / 'package.json').read_text(), 'existing custom content') + + def test_status_missing_fresh_stale_and_malformed(self): + with tempfile.TemporaryDirectory() as temp: + target = Path(temp) + self.assertEqual(json.loads(self.run_script('status.py', target).stdout)['state'], 'unknown') + runtime = target / '.runtime' + runtime.mkdir() + status = runtime / 'status.json' + status.write_text(json.dumps({'pid': os.getpid(), 'state': 'online', 'threadId': 'test-thread'})) + value = json.loads(self.run_script('status.py', target).stdout) + self.assertTrue(value['processAlive']) + self.assertTrue(value['fresh']) + self.assertEqual(value['threadId'], 'test-thread') + os.utime(status, (1, 1)) + self.assertFalse(json.loads(self.run_script('status.py', target).stdout)['fresh']) + status.write_text('{bad json') + self.assertNotEqual(self.run_script('status.py', target).returncode, 0) + + +if __name__ == '__main__': + unittest.main()