YYP0502 commited on
Commit
408eee3
·
verified ·
1 Parent(s): 3aed583

Upload 21 files

Browse files
Files changed (7) hide show
  1. .env.example +1 -1
  2. DEPLOY.md +12 -0
  3. scripts/health.py +29 -23
  4. scripts/show_logs.py +16 -0
  5. scripts/start.sh +49 -21
  6. src/codex.ts +38 -1
  7. src/index.ts +75 -11
.env.example CHANGED
@@ -10,7 +10,7 @@ CODEX_MODEL=your-model
10
  # Optional
11
  CODEX_REASONING_EFFORT=high
12
  # Space container forces danger-full-access inside Docker to avoid bwrap namespaces.
13
- CODEX_TIMEOUT_MS=300000
14
  MAX_REPLY_CHARS=3500
15
  HISTORY_TURNS=8
16
  MAX_INPUT_FILE_BYTES=20971520
 
10
  # Optional
11
  CODEX_REASONING_EFFORT=high
12
  # Space container forces danger-full-access inside Docker to avoid bwrap namespaces.
13
+ CODEX_TIMEOUT_MS=900000
14
  MAX_REPLY_CHARS=3500
15
  HISTORY_TURNS=8
16
  MAX_INPUT_FILE_BYTES=20971520
DEPLOY.md CHANGED
@@ -35,3 +35,15 @@ Space 上线并取得真实公开域名后,在 UptimeRobot 选择 HTTP(s) moni
35
  Hugging Face Space 的 Docker 运行环境可能禁止非特权 user namespace。Codex 的 `workspace-write` 沙箱依赖 bwrap,会报 `No permissions to create a new namespace`,导致所有工具命令执行失败,即使模型 API 和 curl 本身可访问。QQ 桥接现固定调用 `codex exec -s danger-full-access`,不再启动 bwrap;Jupyter Terminal 的 Codex 配置也默认 `sandbox_mode = "danger-full-access"`。原先 Space Settings 中的 `CODEX_SANDBOX=workspace-write` 可删除,桥接不会再读取它。**需要重新上传并重新构建 Space**;旧镜像仍会使用旧代码。
36
 
37
  这仅依赖 Docker/Space 容器的隔离,不等同于 Codex 的文件系统沙箱。QQ 白名单用户可以让模型读取容器内任何可读文件、执行命令、访问运行时环境变量和外网。严格保护 QQ_ALLOWED_OPENIDS 与 JUPYTER_TOKEN;不要把其他生产密钥放入同一 Space。如不能接受该风险,请改用支持非特权 user namespace 的宿主环境,或等待平台支持相应沙箱能力。
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  Hugging Face Space 的 Docker 运行环境可能禁止非特权 user namespace。Codex 的 `workspace-write` 沙箱依赖 bwrap,会报 `No permissions to create a new namespace`,导致所有工具命令执行失败,即使模型 API 和 curl 本身可访问。QQ 桥接现固定调用 `codex exec -s danger-full-access`,不再启动 bwrap;Jupyter Terminal 的 Codex 配置也默认 `sandbox_mode = "danger-full-access"`。原先 Space Settings 中的 `CODEX_SANDBOX=workspace-write` 可删除,桥接不会再读取它。**需要重新上传并重新构建 Space**;旧镜像仍会使用旧代码。
36
 
37
  这仅依赖 Docker/Space 容器的隔离,不等同于 Codex 的文件系统沙箱。QQ 白名单用户可以让模型读取容器内任何可读文件、执行命令、访问运行时环境变量和外网。严格保护 QQ_ALLOWED_OPENIDS 与 JUPYTER_TOKEN;不要把其他生产密钥放入同一 Space。如不能接受该风险,请改用支持非特权 user namespace 的宿主环境,或等待平台支持相应沙箱能力。
38
+
39
+
40
+ ## 长任务无回复与 Jupyter 拒绝访问排查(本版)
41
+ 旧版 `codex exec --json` 的 stdout 没有消费,输出事件积累后管道会满,使子进程卡住。新版持续消费 stdout、记录退出状态;默认 CODEX_TIMEOUT_MS=900000(15 分钟),超时后强制终止进程。若 Space Settings 已配置旧值 CODEX_TIMEOUT_MS=300000,旧值仍优先,请手动改为 900000 或 1200000(20 分钟)。QQ 每 3 分钟发送进度;发送 `/status` 可查询当前任务运行时间,不会排在任务后面。
42
+
43
+ 启动日志在 Space 的运行日志中输出,并同时写在容器临时目录:`/home/node/app/data/startup.log`、`jupyter.log`、`bridge.log`、`nginx.log`。启动顺序改为 Jupyter → HTTP 代理 → QQ 机器人;QQ 配置缺失或机器人退出时不会一并杀死 Jupyter 界面。`/health` 现在只确认 Jupyter 可连接,不代表 QQ 机器人已登录;通过 QQ `/status` 检查机器人。
44
+
45
+ JupyterLab 必须设置 Space Secret `JUPYTER_TOKEN`,打开实际 Space App 地址的 `/lab`,在登录页面输入该 token。若显示 Hugging Face 的 403/拒绝访问页面而不是 Jupyter 登录页,检查 Space 是否 Private、账号是否有该 Space 访问权、是否打开了实际 App 地址;这类平台访问控制不是容器内 token 可以解决的。若 `/health` 为 200、`/lab` 跳转到 `/login`,说明容器与代理已工作,需检查 token/浏览器 Cookie。不要将 token 作为 URL 参数公开分享。
46
+
47
+ 若仍无法进入界面,请从 Hugging Face Space 的 Runtime logs 复制 `jupyter.log`/`nginx` 对应错误、以及浏览器网络请求的 HTTP 状态码(不要包含 token 或 API key)。
48
+
49
+ QQ 白名单用户还可以发送 `/diag` 获取脱敏后的最近 bridge/startup/jupyter 日志;若机器人仍在线,优先用 `/status` 和 `/diag` 排查,无须进入 Jupyter Terminal。不要把诊断消息转发给其他人。容器的 Jupyter 与 QQ 日志默认保存在临时 data 目录;启动崩溃时会将脱敏后的最后几行写入 Space Runtime logs。
scripts/health.py CHANGED
@@ -1,24 +1,30 @@
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()
 
1
+ import http.server
2
+ import os
3
+ import socket
4
+ import sys
5
+
6
+ PIDS = [int(p) for p in sys.argv[1:] if p.isdigit() and int(p) > 0]
7
+
8
+ class Handler(http.server.BaseHTTPRequestHandler):
9
+ def do_GET(self):
10
+ if self.path != "/health":
11
+ self.send_error(404)
12
+ return
13
+ try:
14
+ for pid in PIDS:
15
+ os.kill(pid, 0)
16
+ with socket.create_connection(("127.0.0.1", 8888), timeout=2):
17
+ pass
18
+ status, body = 200, b"ok\n"
19
+ except OSError:
20
+ status, body = 503, b"unavailable\n"
21
+ self.send_response(status)
22
+ self.send_header("Content-Type", "text/plain")
23
+ self.send_header("Cache-Control", "no-store")
24
+ self.send_header("Content-Length", str(len(body)))
25
+ self.end_headers()
26
+ self.wfile.write(body)
27
+ def log_message(self, *_):
28
+ pass
29
+
30
  http.server.ThreadingHTTPServer(("127.0.0.1", 7861), Handler).serve_forever()
scripts/show_logs.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ for filename in sys.argv[1:]:
6
+ path = Path(filename)
7
+ if not path.is_file():
8
+ continue
9
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()[-40:]
10
+ secrets = [os.environ.get(name, "") for name in ("JUPYTER_TOKEN", "CODEX_API_KEY", "QQBOT_APP_SECRET", "QQBOT_APP_ID")]
11
+ print(f"--- {path.name} (last {len(lines)} lines, secrets redacted) ---")
12
+ for line in lines:
13
+ for secret in secrets:
14
+ if secret:
15
+ line = line.replace(secret, "[REDACTED]")
16
+ print(line[:1000])
scripts/start.sh CHANGED
@@ -1,32 +1,60 @@
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
  export CODEX_HOME=/home/node/app/data/codex-home
16
  mkdir -p "$CODEX_HOME/skills"
17
  cp -r /home/node/app/skills/. "$CODEX_HOME/skills/"
18
- python3 scripts/configure_codex.py
19
- codex --version
20
- node dist/index.js &
21
- bot_pid=$!
22
- 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 &
 
 
 
 
23
  jupyter_pid=$!
24
- cleanup() { trap - EXIT INT TERM; kill ${bot_pid:-} ${jupyter_pid:-} ${health_pid:-} ${nginx_pid:-} 2>/dev/null || true; wait 2>/dev/null || true; }
25
- trap cleanup EXIT
26
- trap 'exit 0' INT TERM
27
- python3 scripts/health.py $bot_pid $jupyter_pid &
28
  health_pid=$!
29
- nginx -c /home/node/app/nginx.conf -g 'daemon off;' &
30
  nginx_pid=$!
31
- wait -n $bot_pid $jupyter_pid $nginx_pid
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  exit 1
 
1
  #!/usr/bin/env bash
2
+ set -Eeuo pipefail
3
  cd /home/node/app
 
 
 
 
 
 
 
4
  mkdir -p data workspace /tmp/nginx
5
+ # Both chat history and Codex/Jupyter working files are ephemeral.
6
  find workspace -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
7
+ rm -f data/history.json data/bridge.log data/codex-*.txt data/jupyter.log data/nginx.log data/startup.log
8
+ exec > >(tee -a data/startup.log) 2>&1
9
+ : "${JUPYTER_TOKEN:?Set JUPYTER_TOKEN in Space Secrets to access JupyterLab}"
10
+ printf '%s startup begin\n' "$(date -Is)"
11
+
12
  export CODEX_HOME=/home/node/app/data/codex-home
13
  mkdir -p "$CODEX_HOME/skills"
14
  cp -r /home/node/app/skills/. "$CODEX_HOME/skills/"
15
+
16
+ # Start Jupyter first. A bad bot/model configuration must not make the Space UI unavailable.
17
+ jupyter lab --ip=127.0.0.1 --port=8888 --no-browser \
18
+ --ServerApp.root_dir=/home/node/app/workspace \
19
+ --ServerApp.token="${JUPYTER_TOKEN}" \
20
+ --ServerApp.password='' \
21
+ --ServerApp.allow_remote_access=True \
22
+ --ServerApp.trust_xheaders=True \
23
+ --ServerApp.base_url=/ > data/jupyter.log 2>&1 &
24
  jupyter_pid=$!
25
+ printf '%s jupyter pid=%s\n' "$(date -Is)" "$jupyter_pid"
26
+
27
+ python3 scripts/health.py "$jupyter_pid" > data/health.log 2>&1 &
 
28
  health_pid=$!
29
+ nginx -c /home/node/app/nginx.conf -g 'daemon off;' > data/nginx.log 2>&1 &
30
  nginx_pid=$!
31
+
32
+ bot_pid=''
33
+ required=(QQBOT_APP_ID QQBOT_APP_SECRET QQ_ALLOWED_OPENIDS CODEX_BASE_URL CODEX_API_KEY CODEX_MODEL JUPYTER_TOKEN)
34
+ missing=()
35
+ for name in "${required[@]}"; do [[ -n "${!name:-}" ]] || missing+=("$name"); done
36
+ if ((${#missing[@]})); then
37
+ printf '%s bot not started; missing variables: %s\n' "$(date -Is)" "${missing[*]}" | tee -a data/bridge.log
38
+ else
39
+ python3 scripts/configure_codex.py
40
+ codex --version | tee -a data/bridge.log
41
+ node dist/index.js > data/bridge.log 2>&1 &
42
+ bot_pid=$!
43
+ printf '%s bot pid=%s started\n' "$(date -Is)" "$bot_pid" | tee -a data/bridge.log
44
+ fi
45
+
46
+ cleanup() {
47
+ trap - EXIT INT TERM
48
+ for pid in "$nginx_pid" "$health_pid" "$bot_pid" "$jupyter_pid"; do
49
+ [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
50
+ done
51
+ wait 2>/dev/null || true
52
+ }
53
+ trap cleanup EXIT
54
+ trap 'exit 0' INT TERM
55
+ while kill -0 "$jupyter_pid" 2>/dev/null && kill -0 "$nginx_pid" 2>/dev/null; do
56
+ sleep 5
57
+ done
58
+ printf '%s jupyter or nginx exited; container will restart\n' "$(date -Is)"
59
+ python3 scripts/show_logs.py data/jupyter.log data/nginx.log data/bridge.log
60
  exit 1
src/codex.ts CHANGED
@@ -52,7 +52,44 @@ export async function askCodex(k: string, text: string, files: string[] = [], be
52
  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)}`);
53
  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}`);
54
  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();
55
- 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"); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
  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; }
58
  export async function snapshotArtifacts(workdir: string) { await mkdir(path.resolve(workdir), { recursive: true }); return listArtifacts(path.resolve(workdir)); }
 
52
  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)}`);
53
  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}`);
54
  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();
55
+ return new Promise((resolve, reject) => {
56
+ const c = spawn(command, args, { cwd: workdir, env: childEnv, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
57
+ let err = "";
58
+ // Codex --json can emit many events. Always drain stdout, or its pipe fills and stalls the child.
59
+ let stdoutBytes = 0;
60
+ c.stdout.on("data", b => { stdoutBytes += b.length; });
61
+ c.stderr.on("data", b => { err = (err + String(b)).slice(-6000); });
62
+ const configuredTimeout = Number(env("CODEX_TIMEOUT_MS", "900000"));
63
+ const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 900000;
64
+ let timedOut = false;
65
+ const timer = setTimeout(() => {
66
+ timedOut = true;
67
+ void log(`timeout key=${k} after=${timeoutMs}ms`);
68
+ c.kill("SIGKILL");
69
+ reject(new Error(`Codex 响应超时(${Math.round(timeoutMs / 60000)} 分钟)。请缩小任务或提高 CODEX_TIMEOUT_MS。`));
70
+ }, timeoutMs);
71
+ c.on("error", e => { clearTimeout(timer); reject(new Error(`无法启动 Codex:${e.message}`)); });
72
+ c.on("close", async code => {
73
+ try {
74
+ await log(`codex exit key=${k} code=${code} stdoutBytes=${stdoutBytes} timedOut=${timedOut}`);
75
+ clearTimeout(timer);
76
+ if (timedOut) return;
77
+ let answer = "";
78
+ try { answer = (await readFile(out, "utf8")).trim(); } catch {}
79
+ if (code !== 0) { await log(`error code=${code} stderr=${err.slice(-1500)}`); reject(new Error(err.trim().slice(-1000) || `Codex 退出码 ${code}`)); return; }
80
+ if (!answer) answer = "Codex 没有返回文本。";
81
+ const a = store[k] ??= [];
82
+ a.push({ role: "user", text, at: new Date().toISOString() }, { role: "assistant", text: answer, at: new Date().toISOString() });
83
+ if (a.length > 40) a.splice(0, a.length - 40);
84
+ await save();
85
+ const artifacts = await findNewArtifacts(workdir, before ?? new Map());
86
+ await log(`success key=${k} chars=${answer.length} artifacts=${artifacts.length}`);
87
+ resolve({ answer, artifacts });
88
+ } catch (e) { reject(e); }
89
+ });
90
+ c.stdin.on("error", e => { void log(`stdin error key=${k} ${e.message}`); });
91
+ c.stdin.end(prompt(k, text, files), "utf8");
92
+ });
93
  }
94
  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; }
95
  export async function snapshotArtifacts(workdir: string) { await mkdir(path.resolve(workdir), { recursive: true }); return listArtifacts(path.resolve(workdir)); }
src/index.ts CHANGED
@@ -1,14 +1,78 @@
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "dotenv/config";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { QQBot, mentionGate, messageFilter, rateLimiter, accessPolicy, errorHandler } from "@tencent-connect/qqbot-nodejs";
5
+ import { initStore, resetSession, askCodex, splitReply, downloadAttachments, snapshotArtifacts } from "./codex.js";
 
 
 
 
 
 
6
 
7
+ for (const n of ["QQBOT_APP_ID", "QQBOT_APP_SECRET"]) if (!process.env[n]?.trim()) throw new Error(`缺少环境变量 ${n}`);
8
+ const allowed = (process.env.QQ_ALLOWED_OPENIDS ?? "").split(",").map(x => x.trim()).filter(Boolean);
9
+ if (!allowed.length) console.warn("QQ_ALLOWED_OPENIDS 为空,将拒绝所有消息。");
10
+ await initStore();
11
 
12
+ const bot = new QQBot({ appId: process.env.QQBOT_APP_ID!, appSecret: process.env.QQBOT_APP_SECRET!, logger: console });
13
+ bot.use(errorHandler());
14
+ bot.use(messageFilter({ skipSelfEcho: true, dedup: { windowMs: 10000 } }));
15
+ bot.use(accessPolicy({ c2c: { mode: "allowlist", allow: allowed }, group: { mode: "allowlist", allow: allowed } }));
16
+ bot.use(mentionGate({ requireMentionInGroup: true }));
17
+ bot.use(rateLimiter({ perSender: { max: 6, windowMs: 60000 }, global: { max: 30, windowMs: 60000 } }));
18
+ // Do not queue /status behind a long-running Codex request.
19
+ const active = new Map<string, number>();
20
+ bot.on("ready", () => console.log("QQ 机器人已连接;等待消息。"));
21
+ bot.on("error", e => console.error("QQ SDK error:", e));
22
 
23
+ bot.on("message", async (_ctx, msg) => {
24
+ const key = `${msg.kind}:${msg.replyTarget.targetId}`;
25
+ const input = msg.content.trim();
26
+ if (!input && !(msg.attachments?.length)) return;
27
+ if (input === "/status") {
28
+ const started = active.get(key);
29
+ await bot.sendText(msg.replyTarget, started ? `任务仍在处理,已运行 ${Math.floor((Date.now() - started) / 60000)} 分钟。` : "机器人在线,当前会话没有运行中的任务。");
30
+ return;
31
+ }
32
+ if (input === "/diag") {
33
+ const files = ["bridge.log", "startup.log", "jupyter.log"];
34
+ const excerpts: string[] = [];
35
+ for (const file of files) {
36
+ try {
37
+ let lines = (await readFile(path.join(process.cwd(), "data", file), "utf8")).split("\n").filter(Boolean).slice(-8).join("\n");
38
+ for (const secret of [process.env.CODEX_API_KEY, process.env.QQBOT_APP_SECRET, process.env.JUPYTER_TOKEN, process.env.QQBOT_APP_ID]) if (secret) lines = lines.replaceAll(secret, "[REDACTED]");
39
+ excerpts.push(`${file}:\n${lines.slice(-900)}`);
40
+ } catch { excerpts.push(`${file}: 暂无日志`); }
41
+ }
42
+ await bot.sendText(msg.replyTarget, excerpts.join("\n\n").slice(-3000));
43
+ return;
44
+ }
45
+ if (input === "/reset") {
46
+ if (active.has(key)) { await bot.sendText(msg.replyTarget, "当前任务尚在运行;请等待任务结束后再重置。"); return; }
47
+ await resetSession(key);
48
+ await bot.sendText(msg.replyTarget, "已清空当前会话。");
49
+ return;
50
+ }
51
+ if (active.has(key)) { await bot.sendText(msg.replyTarget, "当前已有任务运行中。发送 /status 可查看耗时,请勿重复提交。"); return; }
52
+ active.set(key, Date.now());
53
+ let progress: ReturnType<typeof setInterval> | undefined;
54
+ try {
55
+ await bot.sendText(msg.replyTarget, msg.attachments?.length ? "已收到附件,Codex 正在处理…" : "已收到,Codex 正在处理…");
56
+ progress = setInterval(() => {
57
+ const mins = Math.floor((Date.now() - active.get(key)!) / 60000);
58
+ void bot.sendText(msg.replyTarget, `任务仍在运行(${mins} 分钟),完成或失败后会回复。`).catch(e => console.error("进度消息发送失败:", e));
59
+ }, 180000);
60
+ const workdir = process.env.CODEX_WORKDIR || "/home/node/app/workspace";
61
+ const files = await downloadAttachments(workdir, key, msg.attachments ?? []);
62
+ const before = await snapshotArtifacts(workdir);
63
+ const result = await askCodex(key, input || "请分析我发送的图片或文件,并给出结果。", files, before);
64
+ for (const part of splitReply(result.answer)) await bot.sendText(msg.replyTarget, part);
65
+ for (const file of result.artifacts) {
66
+ try { await bot.sendFile(msg.replyTarget, { localPath: file }, { fileName: file.split(/[\\/]/).pop() }); }
67
+ catch (e) { await bot.sendText(msg.replyTarget, `文件已生成但发送失败:${file.split(/[\\/]/).pop()}\n${e instanceof Error ? e.message : String(e)}`); }
68
+ }
69
+ } catch (e) {
70
+ console.error("任务失败:", e);
71
+ try { await bot.sendText(msg.replyTarget, `处理失败:${e instanceof Error ? e.message : String(e)}`); }
72
+ catch (sendError) { console.error("失败消息也无法发送:", sendError); }
73
+ } finally {
74
+ if (progress) clearInterval(progress);
75
+ active.delete(key);
76
+ }
77
+ });
78
+ await bot.start();