fix: adapt claudeqq k3s deploy and notifications

This commit is contained in:
Codex
2026-05-16 19:03:56 +00:00
parent cd2aebfe75
commit 550a12b0a9
7 changed files with 212 additions and 10 deletions
@@ -0,0 +1,26 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY scripts/src/server_ts/package*.json ./scripts/src/server_ts/
WORKDIR /app/scripts/src/server_ts
RUN npm ci
WORKDIR /app
COPY . .
WORKDIR /app/scripts/src/server_ts
RUN npm run build
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/config.json ./config.json
COPY --from=build /app/scripts/src/server_ts/package*.json ./scripts/src/server_ts/
COPY --from=build /app/scripts/src/server_ts/dist ./scripts/src/server_ts/dist
COPY --from=build /app/unidesk-adapter.cjs ./scripts/src/server_ts/unidesk-adapter.cjs
WORKDIR /app/scripts/src/server_ts
RUN npm ci --omit=dev && npm cache clean --force
CMD ["node", "dist/index.js", "--max-restarts", "0"]
@@ -0,0 +1,130 @@
const http = require("node:http");
const { spawn } = require("node:child_process");
const { URL } = require("node:url");
const listenHost = process.env.CLAUDEQQ_ADAPTER_HOST || "0.0.0.0";
const listenPort = Number.parseInt(process.env.CLAUDEQQ_ADAPTER_PORT || "3290", 10);
const upstreamHost = process.env.CLAUDEQQ_UPSTREAM_HOST || "127.0.0.1";
const upstreamPort = Number.parseInt(process.env.CLAUDEQQ_UPSTREAM_PORT || "9082", 10);
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 startedAt = new Date().toISOString();
const child = spawn("node", ["dist/index.js", "--max-restarts", "0"], {
cwd: process.env.CLAUDEQQ_SERVER_CWD || process.cwd(),
env: process.env,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
console.error(JSON.stringify({ ts: new Date().toISOString(), service: serviceId, event: "upstream_exited", code, signal }));
process.exit(code === null ? 1 : code);
});
function json(res, status, body) {
const text = JSON.stringify(body);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(text),
});
res.end(text);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function normalizePushPayload(raw) {
const body = JSON.parse(raw.length > 0 ? raw.toString("utf8") : "{}");
const message = typeof body.message === "string" ? body.message : "";
if (message.length === 0) throw new Error("message is required");
if (body.targetType === "group" || body.groupId !== undefined) {
const groupId = typeof body.groupId === "string" ? body.groupId : String(body.groupId || "");
if (groupId.length === 0) throw new Error("groupId is required");
return { groupId, message };
}
const userId = typeof body.userId === "string" ? body.userId : String(body.userId || "");
if (userId.length === 0) throw new Error("userId is required");
return { userId, message };
}
function upstreamFetch(path, method = "GET") {
return new Promise((resolve) => {
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") }));
});
req.on("timeout", () => req.destroy(new Error("timeout")));
req.on("error", (error) => resolve({ ok: false, status: 0, body: String(error?.message || error) }));
req.end();
});
}
async function proxy(req, res, path, bodyOverride) {
const incomingBody = bodyOverride === undefined ? await readBody(req) : Buffer.from(JSON.stringify(bodyOverride));
const headers = { ...req.headers };
delete headers.host;
headers["content-length"] = String(incomingBody.length);
if (bodyOverride !== undefined) headers["content-type"] = "application/json";
const options = {
host: upstreamHost,
port: upstreamPort,
method: req.method || "GET",
path,
headers,
};
const upstream = http.request(options, (upstreamRes) => {
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers);
upstreamRes.pipe(res);
});
upstream.on("error", (error) => {
json(res, 502, { ok: false, success: false, service: serviceId, error: String(error?.message || error) });
});
upstream.end(incomingBody);
}
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
if (url.pathname === "/health") {
const upstreamHealth = await upstreamFetch("/health");
json(res, upstreamHealth.ok ? 200 : 503, {
ok: upstreamHealth.ok,
service: serviceId,
pureBackend: true,
napcat: { containerized: true },
adapter: "unidesk-claudeqq-adapter",
startedAt,
upstream: { url: `http://${upstreamHost}:${upstreamPort}`, ok: upstreamHealth.ok, status: upstreamHealth.status },
deploy: {
repo: deployRepo,
commit: deployCommit,
requestedCommit: deployRequestedCommit,
},
});
return;
}
if (url.pathname === "/api/push/text" && req.method === "POST") {
const body = normalizePushPayload(await readBody(req));
await proxy({ ...req, method: "POST" }, res, "/api/send/text", body);
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) });
}
});
server.listen(listenPort, listenHost, () => {
console.log(JSON.stringify({ ts: new Date().toISOString(), service: serviceId, event: "adapter_listening", listenHost, listenPort, upstreamHost, upstreamPort }));
});
@@ -221,6 +221,12 @@ function notificationTargetLabel(): string {
: `private:${ctx().config.notifyClaudeQqUserId || "-"}`;
}
function claudeQqSendPayload(message: string): Record<string, string> {
return ctx().config.notifyClaudeQqTargetType === "group"
? { groupId: ctx().config.notifyClaudeQqGroupId, message }
: { userId: ctx().config.notifyClaudeQqUserId, message };
}
function truncateNotificationText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, maxChars)}\n\n...[Code Queue notification truncated: ${value.length - maxChars} chars omitted; use CLI/WebUI for the full trace]`;
@@ -393,20 +399,30 @@ async function drainClaudeQqNotificationOutbox(trigger = "manual"): Promise<Reco
async function postClaudeQqText(kind: string, message: string): Promise<void> {
if (!notificationTargetConfigured()) return;
const url = `${ctx().config.notifyClaudeQqBaseUrl}/api/push/text`;
const primaryUrl = `${ctx().config.notifyClaudeQqBaseUrl}/api/push/text`;
const legacyUrl = `${ctx().config.notifyClaudeQqBaseUrl}/api/send/text`;
let lastError: unknown = null;
for (let attempt = 1; attempt <= ctx().config.notifyClaudeQqSendAttempts; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ctx().config.notifyClaudeQqTimeoutMs);
let responseText = "";
try {
const response = await fetch(url, {
let response = await fetch(primaryUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(claudeQqTargetPayload(message)),
signal: controller.signal,
});
responseText = await response.text();
if (response.status === 404 || response.status === 405) {
response = await fetch(legacyUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(claudeQqSendPayload(message)),
signal: controller.signal,
});
responseText = await response.text();
}
if (!response.ok) throw new Error(`ClaudeQQ proxy returned HTTP ${response.status}: ${ctx().safePreview(responseText, 500)}`);
try {
const parsed = JSON.parse(responseText) as Record<string, unknown>;
@@ -75,9 +75,7 @@ spec:
workingDir: /app/scripts/src/server_ts
command:
- node
- dist/index.js
- --max-restarts
- "0"
- unidesk-adapter.cjs
ports:
- name: http
containerPort: 3290
@@ -86,6 +84,12 @@ spec:
value: "0.0.0.0"
- name: CLAUDEQQ_PORT
value: "3290"
- name: CLAUDEQQ_ADAPTER_PORT
value: "3290"
- name: CLAUDEQQ_UPSTREAM_HOST
value: "127.0.0.1"
- name: CLAUDEQQ_UPSTREAM_PORT
value: "9082"
- name: CLAUDEQQ_WORKSPACE_DIR
value: "/bot_workspace"
- name: CLAUDEQQ_AUTO_REPLY