fix: adapt claudeqq k3s deploy and notifications
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user