**Motivations:** - Align master with current codebase (token from projects/<id>/.secrets/<env>/ia_token) - Id resolution by mail To or by API token; no slug **Root causes:** - Token moved from conf.json to .secrets/<env>/ia_token; env from directory name **Correctifs:** - Server and scripts resolve project+env by scanning all projects and envs **Evolutions:** - tickets-fetch-inbox routes by To address; notary-ai agents and API doc updated **Pages affectées:** - ai_working_help/server.js, docs, project_config.py, lib/project_config.sh - projects/README.md, lecoffreio/docs/API.md, gitea-issues/tickets-fetch-inbox.py
164 lines
5.7 KiB
JavaScript
164 lines
5.7 KiB
JavaScript
/**
|
|
* ai_working_help API server.
|
|
* Routes: POST /v1/enqueue, GET /v1/response/:request_uid, GET /health, GET /v1/health.
|
|
* Project id and env are resolved from the Bearer token by searching all
|
|
* projects/<id>/.secrets/<env>/ia_token files; the matching project id is used for the spooler.
|
|
* Spooler: projects/<id>/data/notary-ai/{pending,responded}.
|
|
*/
|
|
const express = require("express");
|
|
const bodyParser = require("body-parser");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const app = express();
|
|
app.use(bodyParser.json({ limit: "1mb" }));
|
|
|
|
const PORT = process.env.AI_WORKING_HELP_PORT || 3020;
|
|
const IA_DEV_ROOT = path.resolve(__dirname, "..");
|
|
const PROJECTS_DIR = path.join(IA_DEV_ROOT, "projects");
|
|
|
|
/**
|
|
* Resolve project id and env from token by scanning projects/<id>/.secrets/<env>/ia_token.
|
|
* @returns {{ projectId: string, env: string } | null}
|
|
*/
|
|
function resolveProjectAndEnvByToken(token) {
|
|
if (!token || typeof token !== "string") return null;
|
|
const t = token.trim();
|
|
if (!t) return null;
|
|
const dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true });
|
|
for (const d of dirs) {
|
|
if (!d.isDirectory()) continue;
|
|
const secretsDir = path.join(PROJECTS_DIR, d.name, ".secrets");
|
|
try {
|
|
if (!fs.existsSync(secretsDir) || !fs.statSync(secretsDir).isDirectory()) continue;
|
|
const envDirs = fs.readdirSync(secretsDir, { withFileTypes: true });
|
|
for (const ed of envDirs) {
|
|
if (!ed.isDirectory()) continue;
|
|
const tokenPath = path.join(secretsDir, ed.name, "ia_token");
|
|
if (!fs.existsSync(tokenPath) || !fs.statSync(tokenPath).isFile()) continue;
|
|
const content = fs.readFileSync(tokenPath, "utf8").trim();
|
|
const envName = ed.name;
|
|
// Token is either full value in file (content) or base in file + env suffix: nicolecoffreio<env>
|
|
if (content === t || content + envName === t) return { projectId: d.name, env: envName };
|
|
}
|
|
} catch (_) {
|
|
// skip
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveProjectIdByToken(token) {
|
|
const resolved = resolveProjectAndEnvByToken(token);
|
|
return resolved ? resolved.projectId : null;
|
|
}
|
|
|
|
function requireApiTokenAndResolveProject(req, res, next) {
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || typeof authHeader !== "string") {
|
|
return res.status(401).json({ message: "Invalid or missing token" });
|
|
}
|
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
const token = match ? String(match[1]).trim() : "";
|
|
const resolved = resolveProjectAndEnvByToken(token);
|
|
if (!resolved) {
|
|
return res.status(401).json({ message: "Invalid or missing token" });
|
|
}
|
|
req.projectId = resolved.projectId;
|
|
req.projectEnv = resolved.env;
|
|
next();
|
|
}
|
|
|
|
function safeUid(uid) {
|
|
return String(uid).replace(/[^a-zA-Z0-9-_]/g, "_").slice(0, 128) || "unknown";
|
|
}
|
|
|
|
function projectDataDir(projectId) {
|
|
if (!projectId || /[^a-zA-Z0-9-_]/.test(projectId)) return null;
|
|
return path.join(PROJECTS_DIR, projectId, "data", "notary-ai");
|
|
}
|
|
|
|
function findFileByRequestUid(dir, requestUid) {
|
|
if (!fs.existsSync(dir)) return null;
|
|
const files = fs.readdirSync(dir);
|
|
for (const f of files) {
|
|
if (!f.endsWith(".json")) continue;
|
|
const filePath = path.join(dir, f);
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
if (data.request_uid === requestUid) return { path: filePath, data };
|
|
} catch (_) {
|
|
// skip invalid json
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
app.get("/health", (req, res) => {
|
|
res.status(200).json({ status: "ok" });
|
|
});
|
|
|
|
app.get("/v1/health", (req, res) => {
|
|
res.status(200).json({ status: "ok" });
|
|
});
|
|
|
|
app.post("/v1/enqueue", requireApiTokenAndResolveProject, (req, res) => {
|
|
const dir = projectDataDir(req.projectId);
|
|
if (!dir) {
|
|
return res.status(400).json({ message: "Invalid project" });
|
|
}
|
|
const pendingDir = path.join(dir, "pending");
|
|
const respondedDir = path.join(dir, "responded");
|
|
const body = req.body || {};
|
|
const requestUid = body.request_uid;
|
|
if (!requestUid || typeof requestUid !== "string") {
|
|
return res.status(400).json({ message: "Missing request_uid" });
|
|
}
|
|
const payload = {
|
|
request_uid: requestUid,
|
|
folder_uid: body.folder_uid,
|
|
office_uid: body.office_uid,
|
|
user_id: body.user_id,
|
|
question: body.question,
|
|
folder_context: body.folder_context || {},
|
|
status: "pending",
|
|
};
|
|
try {
|
|
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
|
|
if (!fs.existsSync(respondedDir)) fs.mkdirSync(respondedDir, { recursive: true });
|
|
const safe = safeUid(requestUid);
|
|
const filePath = path.join(pendingDir, `${safe}.json`);
|
|
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
res.status(202).json({ request_uid: requestUid });
|
|
} catch (err) {
|
|
console.error("[ai_working_help] enqueue write error", err);
|
|
res.status(500).json({ message: "Write failed" });
|
|
}
|
|
});
|
|
|
|
app.get("/v1/response/:request_uid", requireApiTokenAndResolveProject, (req, res) => {
|
|
const requestUid = req.params.request_uid;
|
|
const dir = projectDataDir(req.projectId);
|
|
if (!dir) {
|
|
return res.status(400).json({ message: "Invalid project" });
|
|
}
|
|
const respondedDir = path.join(dir, "responded");
|
|
const foundResponded = findFileByRequestUid(respondedDir, requestUid);
|
|
if (foundResponded && foundResponded.data.response) {
|
|
return res.status(200).json({
|
|
status: "responded",
|
|
response: foundResponded.data.response,
|
|
});
|
|
}
|
|
const pendingDir = path.join(dir, "pending");
|
|
const foundPending = findFileByRequestUid(pendingDir, requestUid);
|
|
if (foundPending) {
|
|
return res.status(200).json({ status: "pending" });
|
|
}
|
|
res.status(200).json({ status: "pending" });
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`[ai_working_help] listening on port ${PORT}`);
|
|
});
|