Fix ClaudeQQ event API artifact contract
This commit is contained in:
@@ -23,4 +23,4 @@ COPY --from=build /app/unidesk-adapter.cjs ./scripts/src/server_ts/unidesk-adapt
|
||||
WORKDIR /app/scripts/src/server_ts
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
CMD ["node", "dist/index.js", "--max-restarts", "0"]
|
||||
CMD ["node", "unidesk-adapter.cjs"]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const http = require("node:http");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { URL } = require("node:url");
|
||||
|
||||
@@ -10,6 +12,8 @@ const deployCommit = process.env.UNIDESK_DEPLOY_COMMIT || "";
|
||||
const deployRequestedCommit = process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT || deployCommit;
|
||||
const deployRepo = process.env.UNIDESK_DEPLOY_REPO || "";
|
||||
const serviceId = process.env.UNIDESK_DEPLOY_SERVICE_ID || "claudeqq";
|
||||
const workspaceDir = process.env.CLAUDEQQ_WORKSPACE_DIR || "/bot_workspace";
|
||||
const stateDir = process.env.CLAUDEQQ_STATE_DIR || "/app/.state";
|
||||
const startedAt = new Date().toISOString();
|
||||
const endpoints = [
|
||||
"/health",
|
||||
@@ -24,7 +28,11 @@ const endpoints = [
|
||||
|
||||
const child = spawn("node", ["dist/index.js", "--max-restarts", "0"], {
|
||||
cwd: process.env.CLAUDEQQ_SERVER_CWD || process.cwd(),
|
||||
env: process.env,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDEQQ_HOST: upstreamHost,
|
||||
CLAUDEQQ_PORT: String(upstreamPort),
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
@@ -51,6 +59,148 @@ function readBody(req) {
|
||||
});
|
||||
}
|
||||
|
||||
function asRecord(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
}
|
||||
|
||||
function numberOrUndefined(value) {
|
||||
const numberValue = typeof value === "number" ? value : Number.parseInt(String(value || ""), 10);
|
||||
return Number.isFinite(numberValue) ? numberValue : undefined;
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function clampLimit(value) {
|
||||
const parsed = Number.parseInt(String(value || "50"), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return 50;
|
||||
return Math.min(parsed, 200);
|
||||
}
|
||||
|
||||
function textFromMessage(message) {
|
||||
if (typeof message === "string") return message;
|
||||
if (!Array.isArray(message)) return "";
|
||||
return message
|
||||
.map((segment) => {
|
||||
const data = asRecord(asRecord(segment).data);
|
||||
if (typeof data.text === "string") return data.text;
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function eventFromRecord(record, fallbackIndex) {
|
||||
const item = asRecord(record);
|
||||
const raw = asRecord(item.raw_message || item.raw || item);
|
||||
const messageId = numberOrUndefined(raw.message_id ?? item.message_id);
|
||||
const time = numberOrUndefined(raw.time ?? item.time);
|
||||
const userId = raw.user_id ?? item.user_id;
|
||||
const groupId = raw.group_id ?? item.group_id;
|
||||
const eventId = typeof item.id === "string" && item.id.length > 0
|
||||
? item.id
|
||||
: [time || "unknown", messageId || fallbackIndex, userId || "unknown", groupId || "private"].join(":");
|
||||
|
||||
return {
|
||||
id: eventId,
|
||||
type: typeof item.type === "string" ? item.type : "message",
|
||||
source: typeof item.source === "string" ? item.source : "messages-file",
|
||||
timestamp: typeof item.timestamp === "string"
|
||||
? item.timestamp
|
||||
: time
|
||||
? new Date(time * 1000).toISOString()
|
||||
: new Date(0).toISOString(),
|
||||
messageType: typeof raw.message_type === "string" ? raw.message_type : (groupId ? "group" : "private"),
|
||||
userId: userId === undefined ? undefined : String(userId),
|
||||
groupId: groupId === undefined ? undefined : String(groupId),
|
||||
messageId,
|
||||
text: textFromMessage(raw.message ?? item.message ?? item.text),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
function readRecentEvents(searchParams) {
|
||||
const limit = clampLimit(searchParams.get("limit"));
|
||||
const since = searchParams.get("since");
|
||||
const messagesFile = path.join(workspaceDir, "messages", "messages.jsonl");
|
||||
try {
|
||||
if (!fs.existsSync(messagesFile)) return [];
|
||||
const lines = fs.readFileSync(messagesFile, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
const events = lines
|
||||
.slice(-Math.max(limit * 2, limit))
|
||||
.map((line, index) => eventFromRecord(parseJson(line, {}), index))
|
||||
.filter((event) => !since || event.timestamp > since);
|
||||
return events.slice(-limit).reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readSubscriptions() {
|
||||
const subscriptionsFile = path.join(stateDir, "event_subscriptions.json");
|
||||
try {
|
||||
if (!fs.existsSync(subscriptionsFile)) return [];
|
||||
const parsed = parseJson(fs.readFileSync(subscriptionsFile, "utf8"), []);
|
||||
return Array.isArray(parsed) ? parsed.map(asRecord) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackEventResponse(pathname, searchParams, upstream) {
|
||||
if (pathname === "/api/events/recent") {
|
||||
const events = readRecentEvents(searchParams);
|
||||
return {
|
||||
ok: true,
|
||||
events,
|
||||
count: events.length,
|
||||
nextCursor: null,
|
||||
source: "adapter-readonly-fallback",
|
||||
degraded: true,
|
||||
reason: "upstream-event-api-unavailable",
|
||||
upstream: { ok: upstream.ok, status: upstream.status },
|
||||
};
|
||||
}
|
||||
const subscriptions = readSubscriptions();
|
||||
return {
|
||||
ok: true,
|
||||
subscriptions,
|
||||
count: subscriptions.length,
|
||||
source: "adapter-readonly-fallback",
|
||||
degraded: true,
|
||||
reason: "upstream-event-api-unavailable",
|
||||
upstream: { ok: upstream.ok, status: upstream.status },
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEventApiBody(pathname, body, upstream) {
|
||||
const record = asRecord(body);
|
||||
if (record.ok === true) return record;
|
||||
if (pathname === "/api/events/recent" && Array.isArray(record.events)) {
|
||||
return { ...record, ok: true, count: record.events.length, source: "upstream", upstream: { ok: upstream.ok, status: upstream.status } };
|
||||
}
|
||||
if (pathname === "/api/events/subscriptions" && Array.isArray(record.subscriptions)) {
|
||||
return { ...record, ok: true, count: record.subscriptions.length, source: "upstream", upstream: { ok: upstream.ok, status: upstream.status } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function eventApiRead(res, url) {
|
||||
const upstream = await upstreamFetch(`${url.pathname}${url.search}`);
|
||||
if (upstream.ok && typeof upstream.body === "string") {
|
||||
const normalized = normalizeEventApiBody(url.pathname, parseJson(upstream.body, null), upstream);
|
||||
if (normalized) {
|
||||
json(res, 200, normalized);
|
||||
return;
|
||||
}
|
||||
}
|
||||
json(res, 200, fallbackEventResponse(url.pathname, url.searchParams, upstream));
|
||||
}
|
||||
|
||||
function normalizePushPayload(raw) {
|
||||
const body = JSON.parse(raw.length > 0 ? raw.toString("utf8") : "{}");
|
||||
const message = typeof body.message === "string" ? body.message : "";
|
||||
@@ -70,7 +220,10 @@ function upstreamFetch(path, method = "GET") {
|
||||
const req = http.request({ host: upstreamHost, port: upstreamPort, path, method, timeout: 2000 }, (res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
res.on("end", () => resolve({ ok: (res.statusCode || 500) < 500, status: res.statusCode || 0, body: Buffer.concat(chunks).toString("utf8") }));
|
||||
res.on("end", () => {
|
||||
const status = res.statusCode || 0;
|
||||
resolve({ ok: status >= 200 && status < 300, status, body: Buffer.concat(chunks).toString("utf8") });
|
||||
});
|
||||
});
|
||||
req.on("timeout", () => req.destroy(new Error("timeout")));
|
||||
req.on("error", (error) => resolve({ ok: false, status: 0, body: String(error?.message || error) }));
|
||||
@@ -140,6 +293,12 @@ const server = http.createServer(async (req, res) => {
|
||||
wsPort: statusNapcat.wsPort || 3001,
|
||||
},
|
||||
adapter: "unidesk-claudeqq-adapter",
|
||||
eventApi: {
|
||||
contract: "claudeqq-event-api-v1",
|
||||
readonlyFallback: true,
|
||||
validationPaths: ["/api/events/recent", "/api/events/subscriptions"],
|
||||
mutationPaths: ["POST /api/events/subscriptions", "DELETE /api/events/subscriptions/{id}"],
|
||||
},
|
||||
startedAt,
|
||||
upstream: { url: `http://${upstreamHost}:${upstreamPort}`, ok: upstreamHealth.ok, status: upstreamHealth.status },
|
||||
deploy: {
|
||||
@@ -155,6 +314,10 @@ const server = http.createServer(async (req, res) => {
|
||||
await proxy({ ...req, method: "POST" }, res, "/api/send/text", body);
|
||||
return;
|
||||
}
|
||||
if ((url.pathname === "/api/events/recent" || url.pathname === "/api/events/subscriptions") && req.method === "GET") {
|
||||
await eventApiRead(res, url);
|
||||
return;
|
||||
}
|
||||
await proxy(req, res, `${url.pathname}${url.search}`, undefined);
|
||||
} catch (error) {
|
||||
json(res, 400, { ok: false, success: false, service: serviceId, error: String(error?.message || error) });
|
||||
|
||||
Reference in New Issue
Block a user