Upload 21 files
Browse files- .env.example +1 -1
- DEPLOY.md +12 -0
- scripts/health.py +29 -23
- scripts/show_logs.py +16 -0
- scripts/start.sh +49 -21
- src/codex.ts +38 -1
- 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=
|
| 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 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
self.
|
| 22 |
-
self.
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 -
|
| 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
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
jupyter_pid=$!
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 3 |
-
import
|
| 4 |
-
|
| 5 |
-
|
| 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();
|