Files
pikasTech-unidesk/scripts/src/gh/notify-claudeqq.ts
T
2026-06-26 00:16:53 +08:00

205 lines
9.3 KiB
TypeScript

// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
// Moved mechanically from scripts/src/gh.ts:1891-2085.
import path from "node:path";
import { coreInternalFetch } from "../microservices";
import { preview, previewLines } from "./auth-and-safety";
import { DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL, DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID } from "./types";
import type { ClaudeQqConfig, ClaudeQqEndpointResult, ClaudeQqSendResult, ClaudeQqTargetType, CommanderBriefDiff } from "./types";
export function envEnabled(name: string, defaultValue: boolean): boolean {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return defaultValue;
return !["0", "false", "no", "off", "disabled"].includes(raw.toLowerCase());
}
export function positiveEnvInteger(name: string, defaultValue: number): number {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return defaultValue;
const value = Number(raw);
return Number.isInteger(value) && value > 0 ? value : defaultValue;
}
export function commanderBriefClaudeQqConfig(): ClaudeQqConfig {
const targetTypeRaw = (process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE ?? "private").toLowerCase();
const targetType: ClaudeQqTargetType = targetTypeRaw === "group" ? "group" : "private";
return {
enabled: envEnabled("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED", true),
baseUrl: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL,
targetType,
userId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID,
groupId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID,
timeoutMs: positiveEnvInteger("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS", 15_000),
};
}
export function maskedTarget(config: ClaudeQqConfig): Record<string, unknown> {
if (config.targetType === "group") return { targetType: "group", groupId: config.groupId === undefined ? null : maskId(config.groupId) };
return { targetType: "private", userId: config.userId === undefined ? null : maskId(config.userId) };
}
export function maskId(value: string): string {
if (value.length <= 4) return "*".repeat(value.length);
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
export function sanitizeUrlForOutput(value: string): string {
try {
const parsed = new URL(value);
parsed.username = parsed.username.length > 0 ? "***" : "";
parsed.password = parsed.password.length > 0 ? "***" : "";
parsed.search = parsed.search.length > 0 ? "?..." : "";
parsed.hash = "";
return parsed.toString();
} catch {
return value.includes("?") ? `${value.split("?")[0]}?...` : value;
}
}
export function claudeQqPayload(config: ClaudeQqConfig, message: string): Record<string, unknown> {
if (config.targetType === "group") return { targetType: "group", groupId: config.groupId ?? "", message };
return { targetType: "private", userId: config.userId ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, message };
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function proxiedClaudeQqBasePath(baseUrl: string): string | null {
try {
const parsed = new URL(baseUrl);
const path = parsed.pathname.replace(/\/$/u, "");
if (parsed.hostname === "backend-core" && path === "/api/microservices/claudeqq/proxy") return path;
if ((parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.port === "8080" && path === "/api/microservices/claudeqq/proxy") return path;
return null;
} catch {
return baseUrl === "/api/microservices/claudeqq/proxy" ? baseUrl : null;
}
}
export function normalizeClaudeQqEndpoint(basePath: string, endpoint: string): string {
return `${basePath.replace(/\/$/u, "")}${endpoint}`;
}
export function claudeQqResponseOk(response: unknown): boolean {
if (!isRecord(response)) return false;
if (response.ok === false) return false;
if (typeof response.status === "number" && (response.status < 200 || response.status >= 300)) return false;
const body = response.body;
if (isRecord(body) && (body.ok === false || body.success === false)) return false;
return response.ok === true || typeof response.status === "number";
}
export function summarizeClaudeQqProxyFailure(response: unknown, endpoint: string): ClaudeQqEndpointResult {
if (!isRecord(response)) return { ok: false, endpoint, degradedReason: "invalid-response", message: "ClaudeQQ proxy returned a non-object response", response };
const status = typeof response.status === "number" ? response.status : undefined;
const stdoutTail = typeof response.stdoutTail === "string" ? response.stdoutTail : "";
let parsedTail: unknown = null;
if (stdoutTail.length > 0) {
try {
parsedTail = JSON.parse(stdoutTail.trim()) as unknown;
} catch {
parsedTail = null;
}
}
return {
ok: false,
endpoint,
status,
degradedReason: response.ok === false ? "microservice-proxy-failed" : "invalid-response",
message: typeof response.error === "string"
? response.error
: typeof response.stderrTail === "string" && response.stderrTail.length > 0
? response.stderrTail.slice(-500)
: "ClaudeQQ proxy request failed",
response: parsedTail ?? response,
};
}
export async function sendClaudeQqEndpoint(config: ClaudeQqConfig, endpoint: string, payload: Record<string, unknown>): Promise<ClaudeQqEndpointResult> {
const basePath = proxiedClaudeQqBasePath(config.baseUrl);
if (basePath !== null) {
const response = coreInternalFetch(normalizeClaudeQqEndpoint(basePath, endpoint), {
method: "POST",
body: payload,
maxResponseBytes: 240_000,
timeoutMs: config.timeoutMs,
});
if (claudeQqResponseOk(response)) return { ok: true, endpoint, status: isRecord(response) && typeof response.status === "number" ? response.status : 200, response };
return summarizeClaudeQqProxyFailure(response, endpoint);
}
return {
ok: false,
endpoint,
degradedReason: "notification-path-unavailable",
message: "ClaudeQQ notifications must use backend-core /api/microservices/claudeqq/proxy; local skill, powershell, direct host, and ad-hoc ClaudeQQ URLs are not supported.",
response: {
baseUrl: sanitizeUrlForOutput(config.baseUrl),
requiredBaseUrl: DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL,
recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw",
},
};
}
export async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: string): Promise<ClaudeQqSendResult> {
const target = maskedTarget(config);
if (!config.enabled) {
return { ok: true, attempted: false, skipped: true, skippedReason: "UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED disabled", target };
}
if (config.targetType === "group" && (config.groupId === undefined || config.groupId.length === 0)) {
return { ok: false, attempted: false, skipped: true, skippedReason: "group target requires UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID", target };
}
const payload = claudeQqPayload(config, message);
const first = await sendClaudeQqEndpoint(config, "/api/push/text", payload);
if (first.ok) return { ok: true, attempted: true, endpoint: first.endpoint, status: first.status, response: first.response, target };
const second = await sendClaudeQqEndpoint(config, "/api/send/text", payload);
if (second.ok) return { ok: true, attempted: true, endpoint: second.endpoint, status: second.status, response: second.response, target };
return {
ok: false,
attempted: true,
endpoint: second.endpoint,
status: second.status ?? first.status,
degradedReason: second.degradedReason ?? first.degradedReason ?? "claudeqq-send-failed",
message: second.message ?? first.message ?? "ClaudeQQ send failed",
response: { attempts: [first, second] },
target,
};
}
export function commanderBriefNotificationPlan(issueNumber: number, body: string, diff: CommanderBriefDiff, config: ClaudeQqConfig): Record<string, unknown> {
const proxyBasePath = proxiedClaudeQqBasePath(config.baseUrl);
return {
enabled: config.enabled,
issueNumber,
bodyChars: body.length,
diff: {
ok: diff.ok,
mode: diff.mode,
chars: diff.chars,
sectionCount: diff.sectionCount,
skippedReason: diff.skippedReason ?? null,
preview: diff.ok ? preview(diff.message) : "",
previewLines: diff.ok ? previewLines(diff.message) : [],
containsLiteralBackslashN: diff.message.includes("\\n"),
containsBackticks: diff.message.includes("`"),
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message),
},
claudeqq: {
dryRun: true,
wouldSend: config.enabled && diff.ok && proxyBasePath !== null,
baseUrl: sanitizeUrlForOutput(config.baseUrl),
proxyBasePath,
servicePath: proxyBasePath === null ? null : normalizeClaudeQqEndpoint(proxyBasePath, "/api/push/text"),
target: maskedTarget(config),
timeoutMs: config.timeoutMs,
...(proxyBasePath === null
? {
blockedReason: "notification-path-unavailable",
recommendedCommand: "bun scripts/cli.ts microservice proxy claudeqq /api/push/text --method POST --body-json '<payload>' --raw",
}
: {}),
},
};
}