edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
205 lines
9.3 KiB
TypeScript
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",
|
|
}
|
|
: {}),
|
|
},
|
|
};
|
|
}
|