// 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 { 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 { 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 { 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): Promise { 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 '' --raw", }, }; } export async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: string): Promise { 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 { 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 '' --raw", } : {}), }, }; }