mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
refactor(deadcode): drop orphaned extension helpers
This commit is contained in:
@@ -2,15 +2,6 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
escapeNextcloudTalkMarkdown,
|
||||
formatNextcloudTalkCodeBlock,
|
||||
formatNextcloudTalkInlineCode,
|
||||
formatNextcloudTalkMention,
|
||||
markdownToNextcloudTalk,
|
||||
stripNextcloudTalkFormatting,
|
||||
truncateNextcloudTalkText,
|
||||
} from "./format.js";
|
||||
import {
|
||||
looksLikeNextcloudTalkTargetId,
|
||||
normalizeNextcloudTalkMessagingTarget,
|
||||
@@ -73,30 +64,6 @@ async function makeTempDir(): Promise<string> {
|
||||
}
|
||||
|
||||
describe("nextcloud talk core", () => {
|
||||
it("accepts SecretRef botSecret and apiPassword at top-level", () => {
|
||||
expect(markdownToNextcloudTalk(" **hello** ")).toBe("**hello**");
|
||||
});
|
||||
|
||||
it("escapes markdown-sensitive characters", () => {
|
||||
expect(escapeNextcloudTalkMarkdown("*hello* [x](y)")).toBe("\\*hello\\* \\[x\\]\\(y\\)");
|
||||
});
|
||||
|
||||
it("formats mentions and code consistently", () => {
|
||||
expect(formatNextcloudTalkMention("@alice")).toBe("@alice");
|
||||
expect(formatNextcloudTalkMention("bob")).toBe("@bob");
|
||||
expect(formatNextcloudTalkCodeBlock("const x = 1;", "ts")).toBe("```ts\nconst x = 1;\n```");
|
||||
expect(formatNextcloudTalkInlineCode("x")).toBe("`x`");
|
||||
expect(formatNextcloudTalkInlineCode("x ` y")).toBe("`` x ` y ``");
|
||||
});
|
||||
|
||||
it("strips markdown formatting and truncates on word boundaries", () => {
|
||||
expect(stripNextcloudTalkFormatting("**bold** [link](https://example.com) `code`")).toBe(
|
||||
"bold link",
|
||||
);
|
||||
expect(truncateNextcloudTalkText("alpha beta gamma delta", 14)).toBe("alpha beta...");
|
||||
expect(truncateNextcloudTalkText("short", 14)).toBe("short");
|
||||
});
|
||||
|
||||
it("builds an outbound session route for normalized room targets", () => {
|
||||
const route = resolveNextcloudTalkOutboundSessionRoute({
|
||||
cfg: {},
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Format utilities for Nextcloud Talk messages.
|
||||
*
|
||||
* Nextcloud Talk supports markdown natively, so most formatting passes through.
|
||||
* This module handles any edge cases or transformations needed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert markdown to Nextcloud Talk compatible format.
|
||||
* Nextcloud Talk supports standard markdown, so minimal transformation needed.
|
||||
*/
|
||||
export function markdownToNextcloudTalk(text: string): string {
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in text to prevent markdown interpretation.
|
||||
*/
|
||||
export function escapeNextcloudTalkMarkdown(text: string): string {
|
||||
return text.replace(/([*_`~[\]()#>+\-=|{}!\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a mention for a Nextcloud user.
|
||||
* Nextcloud Talk uses @user format for mentions.
|
||||
*/
|
||||
export function formatNextcloudTalkMention(userId: string): string {
|
||||
return `@${userId.replace(/^@/, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a code block for Nextcloud Talk.
|
||||
*/
|
||||
export function formatNextcloudTalkCodeBlock(code: string, language?: string): string {
|
||||
const lang = language ?? "";
|
||||
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format inline code for Nextcloud Talk.
|
||||
*/
|
||||
export function formatNextcloudTalkInlineCode(code: string): string {
|
||||
if (code.includes("`")) {
|
||||
return `\`\` ${code} \`\``;
|
||||
}
|
||||
return `\`${code}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Nextcloud Talk specific formatting from text.
|
||||
* Useful for extracting plain text content.
|
||||
*/
|
||||
export function stripNextcloudTalkFormatting(text: string): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
.replace(/`[^`]+`/g, "")
|
||||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/\*([^*]+)\*/g, "$1")
|
||||
.replace(/_([^_]+)_/g, "$1")
|
||||
.replace(/~~([^~]+)~~/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a maximum length, preserving word boundaries.
|
||||
*/
|
||||
export function truncateNextcloudTalkText(text: string, maxLength: number, suffix = "..."): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
const truncated = text.slice(0, maxLength - suffix.length);
|
||||
const lastSpace = truncated.lastIndexOf(" ");
|
||||
if (lastSpace > maxLength * 0.7) {
|
||||
return truncated.slice(0, lastSpace) + suffix;
|
||||
}
|
||||
return truncated + suffix;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -221,220 +220,6 @@ function createMirrorBackendMock(): OpenShellSandboxBackend {
|
||||
} as unknown as OpenShellSandboxBackend;
|
||||
}
|
||||
|
||||
function translateRemotePath(value: string, roots: { workspace: string; agent: string }) {
|
||||
if (value === "/sandbox" || value.startsWith("/sandbox/")) {
|
||||
return path.join(roots.workspace, value.slice("/sandbox".length));
|
||||
}
|
||||
if (value === "/agent" || value.startsWith("/agent/")) {
|
||||
return path.join(roots.agent, value.slice("/agent".length));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function runLocalShell(params: {
|
||||
script: string;
|
||||
args?: string[];
|
||||
stdin?: Buffer | string;
|
||||
allowFailure?: boolean;
|
||||
roots: { workspace: string; agent: string };
|
||||
}) {
|
||||
const translatedArgs = (params.args ?? []).map((arg) => translateRemotePath(arg, params.roots));
|
||||
const stdinBuffer =
|
||||
params.stdin === undefined
|
||||
? undefined
|
||||
: Buffer.isBuffer(params.stdin)
|
||||
? params.stdin
|
||||
: Buffer.from(params.stdin);
|
||||
const result = await emulateRemoteShell({
|
||||
script: params.script,
|
||||
args: translatedArgs,
|
||||
stdin: stdinBuffer,
|
||||
allowFailure: params.allowFailure,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
stdout: Buffer.from(rewriteLocalPaths(result.stdout.toString("utf8"), params.roots), "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
function createRemoteBackendMock(roots: {
|
||||
workspace: string;
|
||||
agent: string;
|
||||
}): OpenShellSandboxBackend {
|
||||
return {
|
||||
id: "openshell",
|
||||
runtimeId: "openshell-test",
|
||||
runtimeLabel: "openshell-test",
|
||||
workdir: "/sandbox",
|
||||
env: {},
|
||||
mode: "remote",
|
||||
remoteWorkspaceDir: "/sandbox",
|
||||
remoteAgentWorkspaceDir: "/agent",
|
||||
buildExecSpec: vi.fn(),
|
||||
runShellCommand: vi.fn(),
|
||||
runRemoteShellScript: vi.fn(
|
||||
async (params) =>
|
||||
await runLocalShell({
|
||||
...params,
|
||||
roots,
|
||||
}),
|
||||
),
|
||||
syncLocalPathToRemote: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as OpenShellSandboxBackend;
|
||||
}
|
||||
|
||||
function rewriteLocalPaths(value: string, roots: { workspace: string; agent: string }) {
|
||||
return value.replaceAll(roots.workspace, "/sandbox").replaceAll(roots.agent, "/agent");
|
||||
}
|
||||
|
||||
async function emulateRemoteShell(params: {
|
||||
script: string;
|
||||
args: string[];
|
||||
stdin?: Buffer;
|
||||
allowFailure?: boolean;
|
||||
}): Promise<{ stdout: Buffer; stderr: Buffer; code: number }> {
|
||||
try {
|
||||
if (params.script === 'set -eu\ncat -- "$1"') {
|
||||
return { stdout: await fs.readFile(params.args[0] ?? ""), stderr: Buffer.alloc(0), code: 0 };
|
||||
}
|
||||
|
||||
if (
|
||||
params.script === 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi'
|
||||
) {
|
||||
const target = params.args[0] ?? "";
|
||||
const exists = await pathExistsOrSymlink(target);
|
||||
return { stdout: Buffer.from(exists ? "1\n" : "0\n"), stderr: Buffer.alloc(0), code: 0 };
|
||||
}
|
||||
|
||||
if (params.script.includes('canonical=$(readlink -f -- "$cursor")')) {
|
||||
const canonical = await resolveCanonicalPath(params.args[0] ?? "", params.args[1] === "1");
|
||||
return { stdout: Buffer.from(`${canonical}\n`), stderr: Buffer.alloc(0), code: 0 };
|
||||
}
|
||||
|
||||
if (params.script.includes('stats=$(stat -c "%F|%h" -- "$1")')) {
|
||||
const target = params.args[0] ?? "";
|
||||
if (!(await pathExistsOrSymlink(target))) {
|
||||
return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), code: 0 };
|
||||
}
|
||||
const stats = await fs.lstat(target);
|
||||
return {
|
||||
stdout: Buffer.from(`${describeKind(stats)}|${String(stats.nlink)}\n`),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.script.includes('stat -c "%F|%s|%Y" -- "$1"')) {
|
||||
const target = params.args[0] ?? "";
|
||||
const stats = await fs.lstat(target);
|
||||
return {
|
||||
stdout: Buffer.from(
|
||||
`${describeKind(stats)}|${String(stats.size)}|${String(Math.trunc(stats.mtimeMs / 1000))}\n`,
|
||||
),
|
||||
stderr: Buffer.alloc(0),
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.script.includes("python3 /dev/fd/3 \"$@\" 3<<'PY'")) {
|
||||
const stdout = (await applyMutation(params.args, params.stdin)) ?? Buffer.alloc(0);
|
||||
return { stdout, stderr: Buffer.alloc(0), code: 0 };
|
||||
}
|
||||
|
||||
throw new Error(`unsupported remote shell script: ${params.script}`);
|
||||
} catch (error) {
|
||||
if (!params.allowFailure) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { stdout: Buffer.alloc(0), stderr: Buffer.from(message), code: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExistsOrSymlink(target: string) {
|
||||
try {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function describeKind(stats: fsSync.Stats) {
|
||||
if (stats.isDirectory()) {
|
||||
return "directory";
|
||||
}
|
||||
if (stats.isFile()) {
|
||||
return "regular file";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
async function resolveCanonicalPath(target: string, allowFinalSymlink: boolean) {
|
||||
let suffix = "";
|
||||
let cursor = target;
|
||||
if (allowFinalSymlink && (await isSymlink(target))) {
|
||||
cursor = path.dirname(target);
|
||||
}
|
||||
while (!(await pathExistsOrSymlink(cursor))) {
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
suffix = `${path.posix.sep}${path.basename(cursor)}${suffix}`;
|
||||
cursor = parent;
|
||||
}
|
||||
const canonical = await fs.realpath(cursor);
|
||||
return `${canonical}${suffix}`;
|
||||
}
|
||||
|
||||
async function isSymlink(target: string) {
|
||||
try {
|
||||
return (await fs.lstat(target)).isSymbolicLink();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMutation(args: string[], stdin?: Buffer): Promise<Buffer | void> {
|
||||
const operation = args[0];
|
||||
if (operation === "read") {
|
||||
const [root, relativeParent, basename] = args.slice(1);
|
||||
return await fs.readFile(path.join(root ?? "", relativeParent ?? "", basename ?? ""));
|
||||
}
|
||||
if (operation === "write") {
|
||||
const [root, relativeParent, basename, mkdir] = args.slice(1);
|
||||
const parent = path.join(root ?? "", relativeParent ?? "");
|
||||
if (mkdir === "1") {
|
||||
await fs.mkdir(parent, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(path.join(parent, basename ?? ""), stdin ?? Buffer.alloc(0));
|
||||
return;
|
||||
}
|
||||
if (operation === "mkdirp") {
|
||||
const [root, relativePath] = args.slice(1);
|
||||
await fs.mkdir(path.join(root ?? "", relativePath ?? ""), { recursive: true });
|
||||
return;
|
||||
}
|
||||
if (operation === "remove") {
|
||||
const [root, relativeParent, basename, recursive, force] = args.slice(1);
|
||||
const target = path.join(root ?? "", relativeParent ?? "", basename ?? "");
|
||||
await fs.rm(target, { recursive: recursive === "1", force: force !== "0" });
|
||||
return;
|
||||
}
|
||||
if (operation === "rename") {
|
||||
const [srcRoot, srcParent, srcBase, dstRoot, dstParent, dstBase, mkdir] = args.slice(1);
|
||||
const source = path.join(srcRoot ?? "", srcParent ?? "", srcBase ?? "");
|
||||
const destinationParent = path.join(dstRoot ?? "", dstParent ?? "");
|
||||
if (mkdir === "1") {
|
||||
await fs.mkdir(destinationParent, { recursive: true });
|
||||
}
|
||||
await fs.rename(source, path.join(destinationParent, dstBase ?? ""));
|
||||
return;
|
||||
}
|
||||
throw new Error(`unknown mutation operation: ${operation}`);
|
||||
}
|
||||
|
||||
describe("openshell fs bridges", () => {
|
||||
it("writes locally and syncs the file to the remote workspace", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-fs-");
|
||||
@@ -484,66 +269,4 @@ describe("openshell fs bridges", () => {
|
||||
expect(resolved.hostPath).toBe(path.join(agentWorkspaceDir, "note.txt"));
|
||||
expect(await bridge.readFile({ filePath: "/agent/note.txt" })).toEqual(Buffer.from("agent"));
|
||||
});
|
||||
|
||||
it("writes, reads, renames, and removes files without local host paths", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-openshell-remote-local-");
|
||||
const remoteWorkspaceDir = await makeTempDir("openclaw-openshell-remote-workspace-");
|
||||
const remoteAgentDir = await makeTempDir("openclaw-openshell-remote-agent-");
|
||||
const remoteWorkspaceRealDir = await fs.realpath(remoteWorkspaceDir);
|
||||
const remoteAgentRealDir = await fs.realpath(remoteAgentDir);
|
||||
const backend = createRemoteBackendMock({
|
||||
workspace: remoteWorkspaceRealDir,
|
||||
agent: remoteAgentRealDir,
|
||||
});
|
||||
const sandbox = createSandboxTestContext({
|
||||
overrides: {
|
||||
backendId: "openshell",
|
||||
workspaceDir,
|
||||
agentWorkspaceDir: workspaceDir,
|
||||
containerWorkdir: "/sandbox",
|
||||
},
|
||||
});
|
||||
|
||||
const { createOpenShellRemoteFsBridge } = await import("./remote-fs-bridge.js");
|
||||
const bridge = createOpenShellRemoteFsBridge({ sandbox, backend });
|
||||
await bridge.writeFile({
|
||||
filePath: "nested/file.txt",
|
||||
data: "hello",
|
||||
mkdir: true,
|
||||
});
|
||||
|
||||
expect(await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8")).toBe(
|
||||
"hello",
|
||||
);
|
||||
expect(await fs.readdir(workspaceDir)).toEqual([]);
|
||||
|
||||
const resolved = bridge.resolvePath({ filePath: "nested/file.txt" });
|
||||
expect(resolved.hostPath).toBeUndefined();
|
||||
expect(resolved.containerPath).toBe("/sandbox/nested/file.txt");
|
||||
expect(await bridge.readFile({ filePath: "nested/file.txt" })).toEqual(Buffer.from("hello"));
|
||||
expect(await bridge.stat({ filePath: "nested/file.txt" })).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "file",
|
||||
size: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
await bridge.rename({
|
||||
from: "nested/file.txt",
|
||||
to: "nested/renamed.txt",
|
||||
});
|
||||
await expect(
|
||||
fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "file.txt"), "utf8"),
|
||||
).rejects.toBeDefined();
|
||||
expect(
|
||||
await fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"),
|
||||
).toBe("hello");
|
||||
|
||||
await bridge.remove({
|
||||
filePath: "nested/renamed.txt",
|
||||
});
|
||||
await expect(
|
||||
fs.readFile(path.join(remoteWorkspaceRealDir, "nested", "renamed.txt"), "utf8"),
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import {
|
||||
createRemoteShellSandboxFsBridge,
|
||||
type RemoteShellSandboxHandle,
|
||||
type SandboxContext,
|
||||
type SandboxFsBridge,
|
||||
} from "openclaw/plugin-sdk/sandbox";
|
||||
|
||||
export function createOpenShellRemoteFsBridge(params: {
|
||||
sandbox: SandboxContext;
|
||||
backend: RemoteShellSandboxHandle;
|
||||
}): SandboxFsBridge {
|
||||
return createRemoteShellSandboxFsBridge({
|
||||
sandbox: params.sandbox,
|
||||
runtime: params.backend,
|
||||
});
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* Update-check helpers for the standalone npm package.
|
||||
*
|
||||
* `triggerUpdateCheck()` warms the cache in the background and `getUpdateInfo()`
|
||||
* queries the registry on demand. The lookup talks directly to the npm registry
|
||||
* API and falls back from npmjs.org to npmmirror.com.
|
||||
*/
|
||||
|
||||
import https from "node:https";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const PKG_NAME = "@openclaw/qqbot";
|
||||
const ENCODED_PKG = encodeURIComponent(PKG_NAME);
|
||||
|
||||
const REGISTRIES = [
|
||||
`https://registry.npmjs.org/${ENCODED_PKG}`,
|
||||
`https://registry.npmmirror.com/${ENCODED_PKG}`,
|
||||
];
|
||||
|
||||
let CURRENT_VERSION = "unknown";
|
||||
try {
|
||||
const pkg = require("../package.json");
|
||||
CURRENT_VERSION = pkg.version ?? "unknown";
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
current: string;
|
||||
/** Preferred upgrade target: alpha for prerelease users, latest for stable users. */
|
||||
latest: string | null;
|
||||
/** Stable dist-tag. */
|
||||
stable: string | null;
|
||||
/** Alpha dist-tag. */
|
||||
alpha: string | null;
|
||||
hasUpdate: boolean;
|
||||
checkedAt: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let _log:
|
||||
| { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void }
|
||||
| undefined;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function fetchJson(url: string, timeoutMs: number): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{ timeout: timeoutMs, headers: { Accept: "application/json" } },
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
||||
return;
|
||||
}
|
||||
let data = "";
|
||||
res.on("data", (chunk: string) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error(`timeout fetching ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDistTags(): Promise<Record<string, string>> {
|
||||
for (const url of REGISTRIES) {
|
||||
try {
|
||||
const json = await fetchJson(url, 10_000);
|
||||
const tags = asRecord(asRecord(json)?.["dist-tags"]);
|
||||
if (tags) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tags).flatMap(([key, value]) =>
|
||||
typeof value === "string" ? [[key, value]] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
_log?.debug?.(`[qqbot:update-checker] ${url} failed: ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
throw new Error("all registries failed");
|
||||
}
|
||||
|
||||
function buildUpdateInfo(tags: Record<string, string>): UpdateInfo {
|
||||
const currentIsPrerelease = CURRENT_VERSION.includes("-");
|
||||
const stableTag = tags.latest || null;
|
||||
const alphaTag = tags.alpha || null;
|
||||
|
||||
// Keep prerelease and stable tracks isolated from each other.
|
||||
const compareTarget = currentIsPrerelease ? alphaTag : stableTag;
|
||||
|
||||
const hasUpdate =
|
||||
typeof compareTarget === "string" &&
|
||||
compareTarget !== CURRENT_VERSION &&
|
||||
compareVersions(compareTarget, CURRENT_VERSION) > 0;
|
||||
|
||||
return {
|
||||
current: CURRENT_VERSION,
|
||||
latest: compareTarget,
|
||||
stable: stableTag,
|
||||
alpha: alphaTag,
|
||||
hasUpdate,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Capture a logger and warm the update check in the background. */
|
||||
export function triggerUpdateCheck(log?: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug?: (msg: string) => void;
|
||||
}): void {
|
||||
if (log) {
|
||||
_log = log;
|
||||
}
|
||||
// Warm the cache without blocking startup.
|
||||
getUpdateInfo()
|
||||
.then((info) => {
|
||||
if (info.hasUpdate) {
|
||||
_log?.info?.(
|
||||
`[qqbot:update-checker] new version available: ${info.latest} (current: ${CURRENT_VERSION})`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Query the npm registry on demand. */
|
||||
export async function getUpdateInfo(): Promise<UpdateInfo> {
|
||||
try {
|
||||
const tags = await fetchDistTags();
|
||||
return buildUpdateInfo(tags);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
_log?.debug?.(`[qqbot:update-checker] check failed: ${errorMessage}`);
|
||||
return {
|
||||
current: CURRENT_VERSION,
|
||||
latest: null,
|
||||
stable: null,
|
||||
alpha: null,
|
||||
hasUpdate: false,
|
||||
checkedAt: Date.now(),
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a specific version exists in the npm registry.
|
||||
*/
|
||||
export async function checkVersionExists(version: string): Promise<boolean> {
|
||||
for (const baseUrl of REGISTRIES) {
|
||||
try {
|
||||
const url = `${baseUrl}/${version}`;
|
||||
const json = await fetchJson(url, 10_000);
|
||||
if (readString(asRecord(json), "version") === version) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// try next registry
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const parse = (v: string) => {
|
||||
const clean = v.replace(/^v/, "");
|
||||
const [main, pre] = clean.split("-", 2);
|
||||
return { parts: main.split(".").map(Number), pre: pre || null };
|
||||
};
|
||||
const pa = parse(a);
|
||||
const pb = parse(b);
|
||||
// Compare the numeric core version first.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (pa.parts[i] || 0) - (pb.parts[i] || 0);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
// For equal core versions, stable beats prerelease.
|
||||
if (!pa.pre && pb.pre) {
|
||||
return 1;
|
||||
}
|
||||
if (pa.pre && !pb.pre) {
|
||||
return -1;
|
||||
}
|
||||
if (!pa.pre && !pb.pre) {
|
||||
return 0;
|
||||
}
|
||||
// When both are prereleases, compare each prerelease segment in order.
|
||||
const aParts = pa.pre!.split(".");
|
||||
const bParts = pb.pre!.split(".");
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const aP = aParts[i] ?? "";
|
||||
const bP = bParts[i] ?? "";
|
||||
const aNum = Number(aP);
|
||||
const bNum = Number(bP);
|
||||
// Compare numerically when both segments are numbers.
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
if (aNum !== bNum) {
|
||||
return aNum - bNum;
|
||||
}
|
||||
} else {
|
||||
// Fall back to lexical comparison for string segments.
|
||||
if (aP < bP) {
|
||||
return -1;
|
||||
}
|
||||
if (aP > bP) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* User-facing prompt strings are intentionally empty.
|
||||
*
|
||||
* The QQ Bot plugin follows the same rule as Feishu: runtime errors are logged
|
||||
* but no extra plugin-layer user messages are injected.
|
||||
*/
|
||||
export const QQBOT_USER_MESSAGES = Object.freeze({});
|
||||
Reference in New Issue
Block a user