Delete src
Browse files- src/codex.ts +0 -102
- src/doctor.ts +0 -3
- src/index.ts +0 -87
- src/session-settings.ts +0 -21
src/codex.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
| 1 |
-
import "dotenv/config";
|
| 2 |
-
import { mkdir, readFile, writeFile, appendFile, readdir, stat } from "node:fs/promises";
|
| 3 |
-
|
| 4 |
-
import path from "node:path";
|
| 5 |
-
import type { CallSettings } from "./session-settings.js";
|
| 6 |
-
|
| 7 |
-
const root = path.resolve(process.cwd());
|
| 8 |
-
const dataDir = path.join(root, "data");
|
| 9 |
-
const historyFile = path.join(dataDir, "history.json");
|
| 10 |
-
const logFile = path.join(dataDir, "bridge.log");
|
| 11 |
-
const env = (n: string, f = "") => process.env[n]?.trim() || f;
|
| 12 |
-
export type Turn = { role: "user" | "assistant"; text: string; at: string };
|
| 13 |
-
export type InputAttachment = { url: string; filename?: string; content_type?: string; size?: number };
|
| 14 |
-
type Store = Record<string, Turn[]>;
|
| 15 |
-
let store: Store = {};
|
| 16 |
-
let writeChain = Promise.resolve();
|
| 17 |
-
|
| 18 |
-
export async function initStore() { await mkdir(dataDir, { recursive: true }); store = {}; await writeFile(historyFile, "{}", "utf8"); }
|
| 19 |
-
function save() { writeChain = writeChain.then(() => writeFile(historyFile, JSON.stringify(store, null, 2), "utf8")); return writeChain; }
|
| 20 |
-
export async function resetSession(k: string) { delete store[k]; await save(); }
|
| 21 |
-
function turns(k: string) { return store[k] ?? []; }
|
| 22 |
-
async function log(s: string) { await appendFile(logFile, `${new Date().toISOString()} ${s}\n`, "utf8").catch(() => {}); }
|
| 23 |
-
function safeName(name: string, fallback: string) { const base = path.basename(name || fallback).replace(/[^a-zA-Z0-9._-]/g, "_"); return base || fallback; }
|
| 24 |
-
|
| 25 |
-
function providerEnv(): NodeJS.ProcessEnv { return { ...process.env }; }
|
| 26 |
-
|
| 27 |
-
async function downloadInputs(workdir: string, key: string, attachments: InputAttachment[]) {
|
| 28 |
-
const dir = path.join(workdir, "qq-inbox", safeName(key, "session")); await mkdir(dir, { recursive: true });
|
| 29 |
-
const max = Number(env("MAX_INPUT_FILE_BYTES", String(20 * 1024 * 1024))), result: string[] = [];
|
| 30 |
-
for (let i = 0; i < attachments.length; i++) {
|
| 31 |
-
const a = attachments[i]; if (!a.url || !/^https?:$/i.test(new URL(a.url).protocol)) continue;
|
| 32 |
-
if (a.size && a.size > max) throw new Error(`图片过大,限制为 ${Math.round(max / 1024 / 1024)} MB`);
|
| 33 |
-
const r = await fetch(a.url); if (!r.ok) throw new Error(`下载 QQ 图片失败:HTTP ${r.status}`);
|
| 34 |
-
const buf = Buffer.from(await r.arrayBuffer()); if (buf.byteLength > max) throw new Error(`图片过大,限制为 ${Math.round(max / 1024 / 1024)} MB`);
|
| 35 |
-
const ext = path.extname(a.filename || "") || (a.content_type?.split("/")[1] ? `.${a.content_type.split("/")[1].split(";")[0]}` : ".bin");
|
| 36 |
-
const file = path.join(dir, `${Date.now()}-${i}-${safeName(path.basename(a.filename || `attachment${ext}`), `attachment${ext}`)}`);
|
| 37 |
-
await writeFile(file, buf); result.push(file);
|
| 38 |
-
}
|
| 39 |
-
return result;
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
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 机器人与用户对话。请直接回答,不要提及桥接程序或内部提示。你运行在可联网的 Hugging Face 容器中:需要最新信息时必须使用 curl 或 Python requests 访问公开网页/API并核验来源,若请求失败须说明具体错误,不得编造新闻。你可以在当前工作目录创建和编辑 pptx、xlsx、docx、pdf 等文件;使用已安装的 python-pptx、openpyxl、python-docx、reportlab,完成后确认文件存在并将生成文件返回给用户。\n\n最近对话:\n${h}\n\n用户本次消息:\n${text}${f}`; }
|
| 43 |
-
|
| 44 |
-
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; }
|
| 45 |
-
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(); }
|
| 46 |
-
export async function downloadAttachments(workdir: string, key: string, attachments: InputAttachment[]) { return attachments.length ? downloadInputs(workdir, key, attachments) : []; }
|
| 47 |
-
|
| 48 |
-
export async function askCodex(k: string, text: string, files: string[] = [], before?: Map<string, number>, settings: CallSettings = {}): Promise<{ answer: string; artifacts: string[] }> {
|
| 49 |
-
const workdir = path.resolve(env("CODEX_WORKDIR", path.join(root, "workspace"))); await mkdir(workdir, { recursive: true });
|
| 50 |
-
const out = path.join(dataDir, `codex-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
| 51 |
-
const args = ["exec", "--json", "--skip-git-repo-check", "-C", workdir, "-s", "danger-full-access", "-o", out];
|
| 52 |
-
if (env("CODEX_ENABLE_WEB_SEARCH", "false") === "true") args.push("--search");
|
| 53 |
-
const model = settings.model ?? env("CODEX_MODEL"); if (model) args.push("-m", model); const effort = settings.effort ?? env("CODEX_REASONING_EFFORT"); if (effort) args.push("-c", `model_reasoning_effort=${JSON.stringify(effort)}`);
|
| 54 |
-
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}`);
|
| 55 |
-
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();
|
| 56 |
-
return new Promise((resolve, reject) => {
|
| 57 |
-
const c = spawn(command, args, { cwd: workdir, env: childEnv, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
|
| 58 |
-
let err = "";
|
| 59 |
-
// Codex --json can emit many events. Always drain stdout, or its pipe fills and stalls the child.
|
| 60 |
-
let stdoutBytes = 0;
|
| 61 |
-
c.stdout.on("data", b => { stdoutBytes += b.length; });
|
| 62 |
-
c.stderr.on("data", b => { err = (err + String(b)).slice(-6000); });
|
| 63 |
-
const configuredTimeout = Number(env("CODEX_TIMEOUT_MS", "900000"));
|
| 64 |
-
const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 900000;
|
| 65 |
-
let timedOut = false;
|
| 66 |
-
const timer = setTimeout(() => {
|
| 67 |
-
timedOut = true;
|
| 68 |
-
void log(`timeout key=${k} after=${timeoutMs}ms`);
|
| 69 |
-
c.kill("SIGKILL");
|
| 70 |
-
reject(new Error(`Codex 响应超时(${Math.round(timeoutMs / 60000)} 分钟)。请缩小任务或提高 CODEX_TIMEOUT_MS。`));
|
| 71 |
-
}, timeoutMs);
|
| 72 |
-
c.on("error", e => { clearTimeout(timer); reject(new Error(`无法启动 Codex:${e.message}`)); });
|
| 73 |
-
c.on("close", async code => {
|
| 74 |
-
try {
|
| 75 |
-
await log(`codex exit key=${k} code=${code} stdoutBytes=${stdoutBytes} timedOut=${timedOut}`);
|
| 76 |
-
clearTimeout(timer);
|
| 77 |
-
if (timedOut) return;
|
| 78 |
-
let answer = "";
|
| 79 |
-
try { answer = (await readFile(out, "utf8")).trim(); } catch {}
|
| 80 |
-
if (code !== 0) { await log(`error code=${code} stderr=${err.slice(-1500)}`); reject(new Error(err.trim().slice(-1000) || `Codex 退出码 ${code}`)); return; }
|
| 81 |
-
if (!answer) answer = "Codex 没有返回文本。";
|
| 82 |
-
const a = store[k] ??= [];
|
| 83 |
-
a.push({ role: "user", text, at: new Date().toISOString() }, { role: "assistant", text: answer, at: new Date().toISOString() });
|
| 84 |
-
if (a.length > 40) a.splice(0, a.length - 40);
|
| 85 |
-
await save();
|
| 86 |
-
const artifacts = await findNewArtifacts(workdir, before ?? new Map());
|
| 87 |
-
await log(`success key=${k} chars=${answer.length} artifacts=${artifacts.length}`);
|
| 88 |
-
resolve({ answer, artifacts });
|
| 89 |
-
} catch (e) { reject(e); }
|
| 90 |
-
});
|
| 91 |
-
c.stdin.on("error", e => { void log(`stdin error key=${k} ${e.message}`); });
|
| 92 |
-
c.stdin.end(prompt(k, text, files), "utf8");
|
| 93 |
-
});
|
| 94 |
-
}
|
| 95 |
-
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; }
|
| 96 |
-
export async function snapshotArtifacts(workdir: string) { await mkdir(path.resolve(workdir), { recursive: true }); return listArtifacts(path.resolve(workdir)); }
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/doctor.ts
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 1 |
-
import "dotenv/config";
|
| 2 |
-
import { readFile } from "node:fs/promises";
|
| 3 |
-
import path from "node:path";
|
| 4 |
-
import { effectiveSettings, switchSetting } from "./session-settings.js";
|
| 5 |
-
import { QQBot, mentionGate, messageFilter, rateLimiter, accessPolicy, errorHandler } from "@tencent-connect/qqbot-nodejs";
|
| 6 |
-
import { initStore, resetSession, askCodex, splitReply, downloadAttachments, snapshotArtifacts } from "./codex.js";
|
| 7 |
-
|
| 8 |
-
for (const n of ["QQBOT_APP_ID", "QQBOT_APP_SECRET"]) if (!process.env[n]?.trim()) throw new Error(`缺少环境变量 ${n}`);
|
| 9 |
-
const allowed = (process.env.QQ_ALLOWED_OPENIDS ?? "").split(",").map(x => x.trim()).filter(Boolean);
|
| 10 |
-
if (!allowed.length) console.warn("QQ_ALLOWED_OPENIDS 为空,将拒绝所有消息。");
|
| 11 |
-
await initStore();
|
| 12 |
-
|
| 13 |
-
const bot = new QQBot({ appId: process.env.QQBOT_APP_ID!, appSecret: process.env.QQBOT_APP_SECRET!, logger: console });
|
| 14 |
-
bot.use(errorHandler());
|
| 15 |
-
bot.use(messageFilter({ skipSelfEcho: true, dedup: { windowMs: 10000 } }));
|
| 16 |
-
bot.use(accessPolicy({ c2c: { mode: "allowlist", allow: allowed }, group: { mode: "allowlist", allow: allowed } }));
|
| 17 |
-
bot.use(mentionGate({ requireMentionInGroup: true }));
|
| 18 |
-
bot.use(rateLimiter({ perSender: { max: 6, windowMs: 60000 }, global: { max: 30, windowMs: 60000 } }));
|
| 19 |
-
// Do not queue /status behind a long-running Codex request.
|
| 20 |
-
const active = new Map<string, number>();
|
| 21 |
-
bot.on("ready", () => console.log("QQ 机器人已连接;等待消息。"));
|
| 22 |
-
bot.on("error", e => console.error("QQ SDK error:", e));
|
| 23 |
-
|
| 24 |
-
bot.on("message", async (_ctx, msg) => {
|
| 25 |
-
const key = `${msg.kind}:${msg.replyTarget.targetId}`;
|
| 26 |
-
const input = msg.content.trim();
|
| 27 |
-
if (!input && !(msg.attachments?.length)) return;
|
| 28 |
-
const switched = switchSetting(key, input);
|
| 29 |
-
if (switched !== undefined) {
|
| 30 |
-
await bot.sendText(msg.replyTarget, switched);
|
| 31 |
-
return;
|
| 32 |
-
}
|
| 33 |
-
if (input === "/status") {
|
| 34 |
-
const started = active.get(key);
|
| 35 |
-
const settings = effectiveSettings(key);
|
| 36 |
-
const state = started ? `任务仍在处理,已运行 ${Math.floor((Date.now() - started) / 60000)} 分钟。` : "机器人在线,当前会话没有运行中的任务。";
|
| 37 |
-
await bot.sendText(msg.replyTarget, `${state}\n下一次调用模型:${settings.model || "CLI 默认"}\n推理深度:${settings.effort || "CLI 默认"}`);
|
| 38 |
-
return;
|
| 39 |
-
}
|
| 40 |
-
if (input === "/diag") {
|
| 41 |
-
const files = ["bridge.log", "startup.log", "jupyter.log"];
|
| 42 |
-
const excerpts: string[] = [];
|
| 43 |
-
for (const file of files) {
|
| 44 |
-
try {
|
| 45 |
-
let lines = (await readFile(path.join(process.cwd(), "data", file), "utf8")).split("\n").filter(Boolean).slice(-8).join("\n");
|
| 46 |
-
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]");
|
| 47 |
-
excerpts.push(`${file}:\n${lines.slice(-900)}`);
|
| 48 |
-
} catch { excerpts.push(`${file}: 暂无日志`); }
|
| 49 |
-
}
|
| 50 |
-
await bot.sendText(msg.replyTarget, excerpts.join("\n\n").slice(-3000));
|
| 51 |
-
return;
|
| 52 |
-
}
|
| 53 |
-
if (input === "/reset") {
|
| 54 |
-
if (active.has(key)) { await bot.sendText(msg.replyTarget, "当前任务尚在运行;请等待任务结束后再重置。"); return; }
|
| 55 |
-
await resetSession(key);
|
| 56 |
-
await bot.sendText(msg.replyTarget, "已清空当前会话。");
|
| 57 |
-
return;
|
| 58 |
-
}
|
| 59 |
-
if (active.has(key)) { await bot.sendText(msg.replyTarget, "当前已有任务运行中。发送 /status 可查看耗时,请勿重复提交。"); return; }
|
| 60 |
-
active.set(key, Date.now());
|
| 61 |
-
const callSettings = effectiveSettings(key);
|
| 62 |
-
let progress: ReturnType<typeof setInterval> | undefined;
|
| 63 |
-
try {
|
| 64 |
-
await bot.sendText(msg.replyTarget, msg.attachments?.length ? "已收到附件,Codex 正在处理…" : "已收到,Codex 正在处理…");
|
| 65 |
-
progress = setInterval(() => {
|
| 66 |
-
const mins = Math.floor((Date.now() - active.get(key)!) / 60000);
|
| 67 |
-
void bot.sendText(msg.replyTarget, `任务仍在运行(${mins} 分钟),完成或失败后会回复。`).catch(e => console.error("进度消息发送失败:", e));
|
| 68 |
-
}, 180000);
|
| 69 |
-
const workdir = process.env.CODEX_WORKDIR || "/home/node/app/workspace";
|
| 70 |
-
const files = await downloadAttachments(workdir, key, msg.attachments ?? []);
|
| 71 |
-
const before = await snapshotArtifacts(workdir);
|
| 72 |
-
const result = await askCodex(key, input || "请分析我发送的图片或文件,并给出结果。", files, before, callSettings);
|
| 73 |
-
for (const part of splitReply(result.answer)) await bot.sendText(msg.replyTarget, part);
|
| 74 |
-
for (const file of result.artifacts) {
|
| 75 |
-
try { await bot.sendFile(msg.replyTarget, { localPath: file }, { fileName: file.split(/[\\/]/).pop() }); }
|
| 76 |
-
catch (e) { await bot.sendText(msg.replyTarget, `文件已生成但发送失败:${file.split(/[\\/]/).pop()}\n${e instanceof Error ? e.message : String(e)}`); }
|
| 77 |
-
}
|
| 78 |
-
} catch (e) {
|
| 79 |
-
console.error("任务失败:", e);
|
| 80 |
-
try { await bot.sendText(msg.replyTarget, `处理失败:${e instanceof Error ? e.message : String(e)}`); }
|
| 81 |
-
catch (sendError) { console.error("失败消息也无法发送:", sendError); }
|
| 82 |
-
} finally {
|
| 83 |
-
if (progress) clearInterval(progress);
|
| 84 |
-
active.delete(key);
|
| 85 |
-
}
|
| 86 |
-
});
|
| 87 |
-
await bot.start();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/session-settings.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
| 1 |
-
export type CallSettings = { model?: string; effort?: string };
|
| 2 |
-
const overrides = new Map<string, CallSettings>();
|
| 3 |
-
const efforts = new Set(["low", "medium", "high", "xhigh", "max", "ultra"]);
|
| 4 |
-
export function effectiveSettings(key: string): CallSettings {
|
| 5 |
-
const value = overrides.get(key);
|
| 6 |
-
return { model: value?.model ?? process.env.CODEX_MODEL?.trim(), effort: value?.effort ?? process.env.CODEX_REASONING_EFFORT?.trim() };
|
| 7 |
-
}
|
| 8 |
-
export function switchSetting(key: string, input: string): string | undefined {
|
| 9 |
-
const match = /^\/(switch|effort)(?:\s+([\s\S]*))?$/.exec(input);
|
| 10 |
-
if (!match) return undefined;
|
| 11 |
-
const [, command, raw = ""] = match;
|
| 12 |
-
const value = raw.trim();
|
| 13 |
-
if (command === "switch") {
|
| 14 |
-
if (!value || value.length > 200 || /\s|[\x00-\x1f\x7f]/.test(value)) return "模型切换失败。用法:/switch 模型名,例如 /switch gpt-6-sol";
|
| 15 |
-
overrides.set(key, { ...overrides.get(key), model: value });
|
| 16 |
-
return `模型切换成功:${value}。从下一次请求使用,重启后恢复环境变量默认值。`;
|
| 17 |
-
}
|
| 18 |
-
if (!efforts.has(value)) return "推理深度切换失败。可选:low / medium / high / xhigh / max / ultra";
|
| 19 |
-
overrides.set(key, { ...overrides.get(key), effort: value });
|
| 20 |
-
return `推理深度切换成功:${value}。从下一次请求使用,重启后恢复环境变量默认值。`;
|
| 21 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|