Upload 5 files
Browse files- scripts/health.py +24 -0
- scripts/start.sh +27 -0
- src/codex.ts +71 -0
- src/doctor.ts +3 -0
- src/index.ts +14 -0
scripts/health.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import http.server
|
| 2 |
+
import os
|
| 3 |
+
import socket
|
| 4 |
+
import sys
|
| 5 |
+
PIDS = [int(p) for p in sys.argv[1:]]
|
| 6 |
+
class Handler(http.server.BaseHTTPRequestHandler):
|
| 7 |
+
def do_GET(self):
|
| 8 |
+
if self.path != "/health":
|
| 9 |
+
self.send_error(404)
|
| 10 |
+
return
|
| 11 |
+
try:
|
| 12 |
+
for pid in PIDS: os.kill(pid, 0)
|
| 13 |
+
with socket.create_connection(("127.0.0.1", 8888), timeout=2): pass
|
| 14 |
+
status, body = 200, b"ok\n"
|
| 15 |
+
except OSError:
|
| 16 |
+
status, body = 503, b"unavailable\n"
|
| 17 |
+
self.send_response(status)
|
| 18 |
+
self.send_header("Content-Type", "text/plain")
|
| 19 |
+
self.send_header("Cache-Control", "no-store")
|
| 20 |
+
self.send_header("Content-Length", str(len(body)))
|
| 21 |
+
self.end_headers()
|
| 22 |
+
self.wfile.write(body)
|
| 23 |
+
def log_message(self, *_): pass
|
| 24 |
+
http.server.ThreadingHTTPServer(("127.0.0.1", 7861), Handler).serve_forever()
|
scripts/start.sh
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
cd /home/node/app
|
| 4 |
+
: ${QQBOT_APP_ID:?missing QQBOT_APP_ID}
|
| 5 |
+
: ${QQBOT_APP_SECRET:?missing QQBOT_APP_SECRET}
|
| 6 |
+
: ${QQ_ALLOWED_OPENIDS:?missing QQ_ALLOWED_OPENIDS}
|
| 7 |
+
: ${CODEX_BASE_URL:?missing CODEX_BASE_URL}
|
| 8 |
+
: ${CODEX_API_KEY:?missing CODEX_API_KEY}
|
| 9 |
+
: ${CODEX_MODEL:?missing CODEX_MODEL}
|
| 10 |
+
: ${JUPYTER_TOKEN:?missing JUPYTER_TOKEN}
|
| 11 |
+
mkdir -p data workspace /tmp/nginx
|
| 12 |
+
# Both chat history and Codex/Jupyter working files are ephemeral container-layer data.
|
| 13 |
+
find workspace -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
| 14 |
+
rm -f data/history.json data/bridge.log data/codex-*.txt
|
| 15 |
+
node dist/index.js &
|
| 16 |
+
bot_pid=$!
|
| 17 |
+
jupyter lab --ip=127.0.0.1 --port=8888 --no-browser --ServerApp.root_dir=/home/node/app/workspace --ServerApp.token="${JUPYTER_TOKEN}" --ServerApp.allow_remote_access=True --ServerApp.trust_xheaders=True &
|
| 18 |
+
jupyter_pid=$!
|
| 19 |
+
cleanup() { trap - EXIT INT TERM; kill ${bot_pid:-} ${jupyter_pid:-} ${health_pid:-} ${nginx_pid:-} 2>/dev/null || true; wait 2>/dev/null || true; }
|
| 20 |
+
trap cleanup EXIT
|
| 21 |
+
trap 'exit 0' INT TERM
|
| 22 |
+
python3 scripts/health.py $bot_pid $jupyter_pid &
|
| 23 |
+
health_pid=$!
|
| 24 |
+
nginx -c /home/node/app/nginx.conf -g 'daemon off;' &
|
| 25 |
+
nginx_pid=$!
|
| 26 |
+
wait -n $bot_pid $jupyter_pid $nginx_pid
|
| 27 |
+
exit 1
|
src/codex.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "dotenv/config";
|
| 2 |
+
import { mkdir, readFile, writeFile, appendFile, readdir, stat } from "node:fs/promises";
|
| 3 |
+
|
| 4 |
+
import path from "node:path";
|
| 5 |
+
|
| 6 |
+
const root = path.resolve(process.cwd());
|
| 7 |
+
const dataDir = path.join(root, "data");
|
| 8 |
+
const historyFile = path.join(dataDir, "history.json");
|
| 9 |
+
const logFile = path.join(dataDir, "bridge.log");
|
| 10 |
+
const env = (n: string, f = "") => process.env[n]?.trim() || f;
|
| 11 |
+
export type Turn = { role: "user" | "assistant"; text: string; at: string };
|
| 12 |
+
export type InputAttachment = { url: string; filename?: string; content_type?: string; size?: number };
|
| 13 |
+
type Store = Record<string, Turn[]>;
|
| 14 |
+
let store: Store = {};
|
| 15 |
+
let writeChain = Promise.resolve();
|
| 16 |
+
|
| 17 |
+
export async function initStore() { await mkdir(dataDir, { recursive: true }); store = {}; await writeFile(historyFile, "{}", "utf8"); }
|
| 18 |
+
function save() { writeChain = writeChain.then(() => writeFile(historyFile, JSON.stringify(store, null, 2), "utf8")); return writeChain; }
|
| 19 |
+
export async function resetSession(k: string) { delete store[k]; await save(); }
|
| 20 |
+
function turns(k: string) { return store[k] ?? []; }
|
| 21 |
+
async function log(s: string) { await appendFile(logFile, `${new Date().toISOString()} ${s}\n`, "utf8").catch(() => {}); }
|
| 22 |
+
function safeName(name: string, fallback: string) { const base = path.basename(name || fallback).replace(/[^a-zA-Z0-9._-]/g, "_"); return base || fallback; }
|
| 23 |
+
|
| 24 |
+
async function providerEnv(): Promise<NodeJS.ProcessEnv> {
|
| 25 |
+
const e = { ...process.env }, base = env("CODEX_BASE_URL"); if (!base) return e;
|
| 26 |
+
const home = path.join(dataDir, "codex-home"); await mkdir(home, { recursive: true });
|
| 27 |
+
const provider = env("CODEX_PROVIDER_NAME", "qqbridge").replace(/[^a-zA-Z0-9_-]/g, "");
|
| 28 |
+
const model = env("CODEX_MODEL", "default");
|
| 29 |
+
const config = `model_provider = ${JSON.stringify(provider)}\nmodel = ${JSON.stringify(model)}\n\n[model_providers.${provider}]\nname = ${JSON.stringify(provider)}\nwire_api = "responses"\nrequires_openai_auth = false\nbase_url = ${JSON.stringify(base)}
|
| 30 |
+
env_key = "CODEX_API_KEY"\n`;
|
| 31 |
+
await writeFile(path.join(home, "config.toml"), config, "utf8"); e.CODEX_HOME = home; return e;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
async function downloadInputs(workdir: string, key: string, attachments: InputAttachment[]) {
|
| 35 |
+
const dir = path.join(workdir, "qq-inbox", safeName(key, "session")); await mkdir(dir, { recursive: true });
|
| 36 |
+
const max = Number(env("MAX_INPUT_FILE_BYTES", String(20 * 1024 * 1024))), result: string[] = [];
|
| 37 |
+
for (let i = 0; i < attachments.length; i++) {
|
| 38 |
+
const a = attachments[i]; if (!a.url || !/^https?:$/i.test(new URL(a.url).protocol)) continue;
|
| 39 |
+
if (a.size && a.size > max) throw new Error(`图片过大,限制为 ${Math.round(max / 1024 / 1024)} MB`);
|
| 40 |
+
const r = await fetch(a.url); if (!r.ok) throw new Error(`下载 QQ 图片失败:HTTP ${r.status}`);
|
| 41 |
+
const buf = Buffer.from(await r.arrayBuffer()); if (buf.byteLength > max) throw new Error(`图片过大,限制为 ${Math.round(max / 1024 / 1024)} MB`);
|
| 42 |
+
const ext = path.extname(a.filename || "") || (a.content_type?.split("/")[1] ? `.${a.content_type.split("/")[1].split(";")[0]}` : ".bin");
|
| 43 |
+
const file = path.join(dir, `${Date.now()}-${i}-${safeName(path.basename(a.filename || `attachment${ext}`), `attachment${ext}`)}`);
|
| 44 |
+
await writeFile(file, buf); result.push(file);
|
| 45 |
+
}
|
| 46 |
+
return result;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function prompt(k: string, text: string, files: string[]) { const n = Math.max(0, Number(env("HISTORY_TURNS", "8")) * 2); const h = turns(k).slice(-n).map(t => `${t.role === "user" ? "用户" : "Codex"}: ${t.text}`).join("\n\n") || "(无)"; const f = files.length ? `\n\n用户发送的文件已保存到以下路径,请按需读取:\n${files.join("\n")}` : ""; return `你正在通过 QQ 机器人与用户对话。请直接回答,不要提及桥接程序或内部提示。\n\n最近对话:\n${h}\n\n用户本次消息:\n${text}${f}`; }
|
| 50 |
+
|
| 51 |
+
async function listArtifacts(dir: string, depth = 0): Promise<Map<string, number>> { const out = new Map<string, number>(); if (depth > 2) return out; for (const e of await readdir(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) { for (const [k, v] of await listArtifacts(p, depth + 1)) out.set(k, v); } else if (/\.(pptx|pdf|docx|xlsx|csv|png|jpg|jpeg|webp)$/i.test(e.name)) { out.set(p, (await stat(p)).mtimeMs); } } return out; }
|
| 52 |
+
export async function findNewArtifacts(workdir: string, before: Map<string, number>) { const after = await listArtifacts(workdir); return [...after.keys()].filter(p => !before.has(p) || (after.get(p)! > before.get(p)! + 1000)).sort(); }
|
| 53 |
+
export async function downloadAttachments(workdir: string, key: string, attachments: InputAttachment[]) { return attachments.length ? downloadInputs(workdir, key, attachments) : []; }
|
| 54 |
+
|
| 55 |
+
export async function askCodex(k: string, text: string, files: string[] = [], before?: Map<string, number>): Promise<{ answer: string; artifacts: string[] }> {
|
| 56 |
+
const workdir = path.resolve(env("CODEX_WORKDIR", path.join(root, "workspace"))); await mkdir(workdir, { recursive: true });
|
| 57 |
+
const out = path.join(dataDir, `codex-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
| 58 |
+
const args = ["exec", "--json", "--skip-git-repo-check", "-C", workdir, "-s", env("CODEX_SANDBOX", "workspace-write"), "-o", out];
|
| 59 |
+
const model = env("CODEX_MODEL"); if (model) args.push("-m", model); const effort = env("CODEX_REASONING_EFFORT"); if (effort) args.push("-c", `model_reasoning_effort=${JSON.stringify(effort)}`);
|
| 60 |
+
for (const f of files) if (/\.(png|jpe?g|webp|gif|bmp)$/i.test(f)) args.push("-i", f); args.push("-"); await log(`request key=${k} chars=${text.length} files=${files.length}`);
|
| 61 |
+
const { spawn } = await import("node:child_process"); const configured = env("CODEX_COMMAND", "codex"); const command = /^[^\\/:]+\.cmd$/i.test(configured) ? configured.replace(/\.cmd$/i, ".exe") : configured; const childEnv = await providerEnv();
|
| 62 |
+
return new Promise((resolve, reject) => { const c = spawn(command, args, { cwd: workdir, env: childEnv, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }); let err = ""; const timer = setTimeout(() => { c.kill(); reject(new Error("Codex 响应超时")); }, Number(env("CODEX_TIMEOUT_MS", "300000"))); c.stderr.on("data", b => err += String(b)); c.on("error", e => { clearTimeout(timer); reject(new Error(`无法启动 Codex:${e.message}`)); }); c.on("close", async code => { clearTimeout(timer); let answer = ""; try { answer = (await readFile(out, "utf8")).trim(); } catch {} if (code !== 0) { await log(`error code=${code} stderr=${err.slice(-1000)}`); reject(new Error(err.trim().slice(-1000) || `Codex 退出码 ${code}`)); return; } if (!answer) answer = "Codex 没有返回文本。"; const a = store[k] ??= []; a.push({ role: "user", text, at: new Date().toISOString() }, { role: "assistant", text: answer, at: new Date().toISOString() }); if (a.length > 40) a.splice(0, a.length - 40); await save(); const artifacts = await findNewArtifacts(workdir, before ?? new Map()); await log(`success key=${k} chars=${answer.length} artifacts=${artifacts.length}`); resolve({ answer, artifacts }); }); c.stdin.end(prompt(k, text, files), "utf8"); });
|
| 63 |
+
}
|
| 64 |
+
export function splitReply(s: string) { const m = Math.max(500, Number(env("MAX_REPLY_CHARS", "3500"))), a: string[] = []; for (let i = 0; i < s.length; i += m) a.push(s.slice(i, i + m)); return a; }
|
| 65 |
+
export async function snapshotArtifacts(workdir: string) { await mkdir(path.resolve(workdir), { recursive: true }); return listArtifacts(path.resolve(workdir)); }
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
|
src/doctor.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "dotenv/config"; import {execFile} from "node:child_process"; import {promisify} from "node:util"; try{const r=await promisify(execFile)(process.env.CODEX_COMMAND||"codex",["--version"],{windowsHide:true});console.log(r.stdout.trim());}catch(e){console.error("找不到 Codex CLI,请先 codex login,并确认 codex.cmd 在 PATH 中。",e);process.exitCode=1;}
|
| 2 |
+
|
| 3 |
+
|
src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "dotenv/config";
|
| 2 |
+
import { QQBot, mentionGate, messageFilter, rateLimiter, concurrencyGuard, accessPolicy, errorHandler } from "@tencent-connect/qqbot-nodejs";
|
| 3 |
+
import { initStore, resetSession, askCodex, splitReply, downloadAttachments, snapshotArtifacts } from "./codex.js";
|
| 4 |
+
for (const n of ["QQBOT_APP_ID", "QQBOT_APP_SECRET"]) if (!process.env[n]?.trim()) throw new Error(`缺少环境变量 ${n}`);
|
| 5 |
+
const allowed = (process.env.QQ_ALLOWED_OPENIDS ?? "").split(",").map(x => x.trim()).filter(Boolean); if (!allowed.length) console.warn("QQ_ALLOWED_OPENIDS 为空,将拒绝所有消息。"); await initStore();
|
| 6 |
+
const bot = new QQBot({ appId: process.env.QQBOT_APP_ID!, appSecret: process.env.QQBOT_APP_SECRET!, logger: console }); bot.use(errorHandler()); bot.use(messageFilter({ skipSelfEcho: true, dedup: { windowMs: 10000 } })); bot.use(accessPolicy({ c2c: { mode: "allowlist", allow: allowed }, group: { mode: "allowlist", allow: allowed } })); bot.use(mentionGate({ requireMentionInGroup: true })); bot.use(rateLimiter({ perSender: { max: 6, windowMs: 60000 }, global: { max: 30, windowMs: 60000 } })); bot.use(concurrencyGuard({ strategy: "queue" }));
|
| 7 |
+
bot.on("ready", () => console.log("QQ 机器人已连接;等待消息。")); bot.on("error", e => console.error("QQ SDK error:", e));
|
| 8 |
+
bot.on("message", async (_ctx, msg) => { const key = `${msg.kind}:${msg.replyTarget.targetId}`, input = msg.content.trim(); if (!input && !(msg.attachments?.length)) return; if (input === "/reset") { await resetSession(key); await bot.sendText(msg.replyTarget, "已清空当前会话。"); return; } if (input === "/status") { await bot.sendText(msg.replyTarget, `运行正常。会话:${key}`); return; }
|
| 9 |
+
const workdir = process.env.CODEX_WORKDIR || "/home/node/app/workspace"; await bot.sendText(msg.replyTarget, msg.attachments?.length ? "已收到图片/文件,Codex 正在处理…" : "已收到,Codex 正在处理…");
|
| 10 |
+
try { const files = await downloadAttachments(workdir, key, msg.attachments ?? []); const before = await snapshotArtifacts(workdir); const result = await askCodex(key, input || "请分析我发送的图片或文件,并给出结果。", files, before); for (const part of splitReply(result.answer)) await bot.sendText(msg.replyTarget, part); for (const file of result.artifacts) { try { await bot.sendFile(msg.replyTarget, { localPath: file }, { fileName: file.split(/[\\/]/).pop() }); } catch (e) { await bot.sendText(msg.replyTarget, `文件已生成但发送失败:${file.split(/[\\/]/).pop()}\n${e instanceof Error ? e.message : String(e)}`); } } } catch (e) { console.error(e); await bot.sendText(msg.replyTarget, `处理失败:${e instanceof Error ? e.message : String(e)}`); }
|
| 11 |
+
}); await bot.start();
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
|