mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:50:42 +00:00
refactor(matrix): keep runtime wrapper native-only
This commit is contained in:
@@ -23,7 +23,7 @@ At startup, OpenClaw does roughly this:
|
||||
`slots`, `load.paths`)
|
||||
5. decide enablement for each candidate
|
||||
6. load enabled native modules: built bundled modules use a native loader;
|
||||
unbuilt native plugins use jiti
|
||||
third-party local source TypeScript uses the emergency Jiti fallback
|
||||
7. call native `register(api)` hooks and collect registrations into the plugin registry
|
||||
8. expose the registry to commands/runtime surfaces
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ OpenClaw's plugin system has four layers:
|
||||
Core decides whether a discovered plugin is enabled, disabled, blocked, or selected for an exclusive slot such as memory.
|
||||
</Step>
|
||||
<Step title="Runtime loading">
|
||||
Native OpenClaw plugins are loaded in-process and register capabilities into a central registry. Packaged JavaScript loads through native `require`; source TypeScript falls back to Jiti. Compatible bundles are normalized into registry records without importing runtime code.
|
||||
Native OpenClaw plugins are loaded in-process and register capabilities into a central registry. Packaged JavaScript loads through native `require`; third-party local source TypeScript is the emergency Jiti fallback. Compatible bundles are normalized into registry records without importing runtime code.
|
||||
</Step>
|
||||
<Step title="Surface consumption">
|
||||
The rest of OpenClaw reads the registry to expose tools, channels, provider setup, hooks, HTTP routes, CLI commands, and services.
|
||||
|
||||
@@ -61,8 +61,9 @@ Local plugins are treated as developer-controlled directories. OpenClaw does not
|
||||
run `npm install`, `pnpm install`, or dependency repair for them. If a local
|
||||
plugin has dependencies, install them in that plugin before loading it.
|
||||
|
||||
TypeScript local plugins can use the emergency Jiti path. Packaged JavaScript
|
||||
plugins load through native import/require instead of Jiti.
|
||||
Third-party TypeScript local plugins can use the emergency Jiti path. Packaged
|
||||
JavaScript plugins and bundled internal plugins load through native
|
||||
import/require instead of Jiti.
|
||||
|
||||
## Startup and reload
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type MatrixPackageManifest = {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
describe("matrix package manifest", () => {
|
||||
it("keeps runtime dependencies in the package manifest", () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as MatrixPackageManifest;
|
||||
|
||||
expect(packageJson.dependencies?.["fake-indexeddb"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -2,33 +2,12 @@
|
||||
// while packaged dist builds resolve a distinct runtime entry that cannot loop
|
||||
// back into this wrapper through the stable root runtime alias.
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const PLUGIN_ID = "matrix";
|
||||
const OPENCLAW_PLUGIN_SDK_PACKAGE_NAMES = [
|
||||
["openclaw", "plugin-sdk"].join("/"),
|
||||
["@openclaw", "plugin-sdk"].join("/"),
|
||||
];
|
||||
const PLUGIN_SDK_EXPORT_PREFIX = "./plugin-sdk/";
|
||||
const PLUGIN_SDK_SOURCE_EXTENSIONS = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"];
|
||||
const PLUGIN_ENTRY_RUNTIME_BASENAME = "plugin-entry.handlers.runtime";
|
||||
const NATIVE_RUNTIME_EXTENSIONS = [".js", ".mjs", ".cjs"];
|
||||
const JITI_EXTENSIONS = [
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".mtsx",
|
||||
".ctsx",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".json",
|
||||
];
|
||||
|
||||
function readPackageJson(packageRoot) {
|
||||
try {
|
||||
@@ -84,55 +63,6 @@ function resolveExistingFile(basePath, extensions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPluginSdkAliasMap(moduleUrl) {
|
||||
const location = findOpenClawPackageRoot(path.dirname(fileURLToPath(moduleUrl)));
|
||||
if (!location) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { packageRoot, packageJson } = location;
|
||||
const sourcePluginSdkDir = path.join(packageRoot, "src", "plugin-sdk");
|
||||
const distPluginSdkDir = path.join(packageRoot, "dist", "plugin-sdk");
|
||||
const aliasMap = {};
|
||||
const rootAlias =
|
||||
resolveExistingFile(path.join(sourcePluginSdkDir, "root-alias"), [".cjs"]) ??
|
||||
resolveExistingFile(path.join(distPluginSdkDir, "root-alias"), [".cjs"]);
|
||||
if (rootAlias) {
|
||||
for (const packageName of OPENCLAW_PLUGIN_SDK_PACKAGE_NAMES) {
|
||||
aliasMap[packageName] = rootAlias;
|
||||
}
|
||||
}
|
||||
|
||||
for (const exportKey of Object.keys(packageJson.exports ?? {}).toSorted()) {
|
||||
if (!exportKey.startsWith(PLUGIN_SDK_EXPORT_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
const subpath = exportKey.slice(PLUGIN_SDK_EXPORT_PREFIX.length);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath)) {
|
||||
continue;
|
||||
}
|
||||
const resolvedPath =
|
||||
resolveExistingFile(path.join(sourcePluginSdkDir, subpath), PLUGIN_SDK_SOURCE_EXTENSIONS) ??
|
||||
resolveExistingFile(path.join(distPluginSdkDir, subpath), [".js"]);
|
||||
if (resolvedPath) {
|
||||
for (const packageName of OPENCLAW_PLUGIN_SDK_PACKAGE_NAMES) {
|
||||
aliasMap[`${packageName}/${subpath}`] = resolvedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const extensionApi =
|
||||
resolveExistingFile(
|
||||
path.join(packageRoot, "src", "extensionAPI"),
|
||||
PLUGIN_SDK_SOURCE_EXTENSIONS,
|
||||
) ?? resolveExistingFile(path.join(packageRoot, "dist", "extensionAPI"), [".js"]);
|
||||
if (extensionApi) {
|
||||
aliasMap["openclaw/extension-api"] = extensionApi;
|
||||
}
|
||||
|
||||
return aliasMap;
|
||||
}
|
||||
|
||||
function resolveBundledPluginRuntimeModulePath(moduleUrl, params) {
|
||||
const modulePath = fileURLToPath(moduleUrl);
|
||||
const moduleDir = path.dirname(modulePath);
|
||||
@@ -142,7 +72,7 @@ function resolveBundledPluginRuntimeModulePath(moduleUrl, params) {
|
||||
];
|
||||
|
||||
for (const candidate of localCandidates) {
|
||||
const resolved = resolveExistingFile(candidate, PLUGIN_SDK_SOURCE_EXTENSIONS);
|
||||
const resolved = resolveExistingFile(candidate, NATIVE_RUNTIME_EXTENSIONS);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
@@ -157,7 +87,7 @@ function resolveBundledPluginRuntimeModulePath(moduleUrl, params) {
|
||||
];
|
||||
|
||||
for (const candidate of packageCandidates) {
|
||||
const resolved = resolveExistingFile(candidate, PLUGIN_SDK_SOURCE_EXTENSIONS);
|
||||
const resolved = resolveExistingFile(candidate, NATIVE_RUNTIME_EXTENSIONS);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
@@ -170,17 +100,7 @@ function resolveBundledPluginRuntimeModulePath(moduleUrl, params) {
|
||||
}
|
||||
|
||||
async function loadRuntimeModule(modulePath) {
|
||||
if (NATIVE_RUNTIME_EXTENSIONS.includes(path.extname(modulePath))) {
|
||||
return import(pathToFileURL(modulePath).href);
|
||||
}
|
||||
const { createJiti } = require("jiti");
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
alias: buildPluginSdkAliasMap(import.meta.url),
|
||||
interopDefault: true,
|
||||
tryNative: false,
|
||||
extensions: JITI_EXTENSIONS,
|
||||
});
|
||||
return jiti(modulePath);
|
||||
return import(pathToFileURL(modulePath).href);
|
||||
}
|
||||
|
||||
const mod = await loadRuntimeModule(
|
||||
|
||||
@@ -6,13 +6,6 @@ import { afterEach, expect, it } from "vitest";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const REPO_ROOT = process.cwd();
|
||||
const matrixWrapperGlobal = globalThis as typeof globalThis & {
|
||||
__openclawMatrixWrapperJitiOptions?: unknown;
|
||||
};
|
||||
const PLUGIN_SDK_ROOT = ["openclaw", "plugin-sdk"].join("/");
|
||||
const SCOPED_PLUGIN_SDK_ROOT = ["@openclaw", "plugin-sdk"].join("/");
|
||||
const GROUP_ACCESS_SUBPATH = `${PLUGIN_SDK_ROOT}/group-access`;
|
||||
const SCOPED_GROUP_ACCESS_SUBPATH = `${SCOPED_PLUGIN_SDK_ROOT}/group-access`;
|
||||
const MATRIX_RUNTIME_WRAPPER_SOURCE = fs.readFileSync(
|
||||
path.join(REPO_ROOT, "extensions", "matrix", "src", "plugin-entry.runtime.js"),
|
||||
"utf8",
|
||||
@@ -37,27 +30,6 @@ function writeFixtureFile(fixtureRoot: string, relativePath: string, value: stri
|
||||
fs.writeFileSync(fullPath, value, "utf8");
|
||||
}
|
||||
|
||||
function writeCapturingJitiFixture(fixtureRoot: string) {
|
||||
writeFixtureFile(
|
||||
fixtureRoot,
|
||||
"node_modules/jiti/index.js",
|
||||
[
|
||||
"exports.createJiti = function createJiti(_filename, options) {",
|
||||
" globalThis.__openclawMatrixWrapperJitiOptions = options;",
|
||||
" return function jiti() {",
|
||||
" return {",
|
||||
" ensureMatrixCryptoRuntime: async function ensureMatrixCryptoRuntime() {},",
|
||||
" handleVerifyRecoveryKey: async function handleVerifyRecoveryKey() {},",
|
||||
" handleVerificationBootstrap: async function handleVerificationBootstrap() {},",
|
||||
" handleVerificationStatus: async function handleVerificationStatus() {},",
|
||||
" };",
|
||||
" };",
|
||||
"};",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function writeOpenClawPackageFixture(fixtureRoot: string) {
|
||||
writeFixtureFile(
|
||||
fixtureRoot,
|
||||
@@ -78,60 +50,6 @@ function writeOpenClawPackageFixture(fixtureRoot: string) {
|
||||
writeFixtureFile(fixtureRoot, "dist/plugin-sdk/index.js", "export {};\n");
|
||||
}
|
||||
|
||||
function writeOpenClawAliasFixture(fixtureRoot: string, extraExports?: Record<string, string>) {
|
||||
writeFixtureFile(
|
||||
fixtureRoot,
|
||||
"package.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "openclaw",
|
||||
type: "module",
|
||||
exports: {
|
||||
"./plugin-sdk": "./dist/plugin-sdk/index.js",
|
||||
"./plugin-sdk/group-access": "./dist/plugin-sdk/group-access.js",
|
||||
...extraExports,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
writeFixtureFile(fixtureRoot, "src/plugin-sdk/root-alias.cjs", "module.exports = {};\n");
|
||||
writeFixtureFile(fixtureRoot, "src/plugin-sdk/group-access.ts", "export {};\n");
|
||||
writeFixtureFile(fixtureRoot, "openclaw.mjs", "export {};\n");
|
||||
writeFixtureFile(fixtureRoot, "dist/plugin-sdk/index.js", "export {};\n");
|
||||
writeFixtureFile(fixtureRoot, "dist/plugin-sdk/root-alias.cjs", "module.exports = {};\n");
|
||||
writeFixtureFile(fixtureRoot, "dist/plugin-sdk/group-access.js", "export {};\n");
|
||||
}
|
||||
|
||||
function writeTrustedOpenClawBinFixture(
|
||||
fixtureRoot: string,
|
||||
packageBin: string | Record<string, string>,
|
||||
) {
|
||||
writeFixtureFile(
|
||||
fixtureRoot,
|
||||
"package.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "openclaw",
|
||||
type: "module",
|
||||
bin: packageBin,
|
||||
exports: {
|
||||
"./plugin-sdk": "./dist/plugin-sdk/index.js",
|
||||
"./plugin-sdk/group-access": "./dist/plugin-sdk/group-access.js",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
writeFixtureFile(fixtureRoot, "src/plugin-sdk/root-alias.cjs", "module.exports = {};\n");
|
||||
writeFixtureFile(fixtureRoot, "src/plugin-sdk/group-access.ts", "export {};\n");
|
||||
writeFixtureFile(fixtureRoot, "dist/plugin-sdk/index.js", "export {};\n");
|
||||
writeFixtureFile(fixtureRoot, "dist/plugin-sdk/root-alias.cjs", "module.exports = {};\n");
|
||||
writeFixtureFile(fixtureRoot, "dist/plugin-sdk/group-access.js", "export {};\n");
|
||||
}
|
||||
|
||||
function writeSourceRuntimeWrapperFixture(
|
||||
fixtureRoot: string,
|
||||
options: { runtimeExtension?: ".js" | ".ts" } = {},
|
||||
@@ -163,24 +81,6 @@ function expectRuntimeWrapperExports(mod: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function writeCapturingSourceRuntimeWrapperFixture(fixtureRoot: string) {
|
||||
delete matrixWrapperGlobal.__openclawMatrixWrapperJitiOptions;
|
||||
writeOpenClawAliasFixture(fixtureRoot);
|
||||
writeCapturingJitiFixture(fixtureRoot);
|
||||
writeSourceRuntimeWrapperFixture(fixtureRoot, { runtimeExtension: ".ts" });
|
||||
}
|
||||
|
||||
function expectSourcePluginSdkAliases(fixtureRoot: string) {
|
||||
expect(matrixWrapperGlobal.__openclawMatrixWrapperJitiOptions).toMatchObject({
|
||||
alias: {
|
||||
[PLUGIN_SDK_ROOT]: path.join(fixtureRoot, "src", "plugin-sdk", "root-alias.cjs"),
|
||||
[SCOPED_PLUGIN_SDK_ROOT]: path.join(fixtureRoot, "src", "plugin-sdk", "root-alias.cjs"),
|
||||
[GROUP_ACCESS_SUBPATH]: path.join(fixtureRoot, "src", "plugin-sdk", "group-access.ts"),
|
||||
[SCOPED_GROUP_ACCESS_SUBPATH]: path.join(fixtureRoot, "src", "plugin-sdk", "group-access.ts"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
@@ -223,110 +123,18 @@ it("loads the packaged runtime wrapper without recursing through the stable root
|
||||
);
|
||||
}, 240_000);
|
||||
|
||||
it("builds scoped and unscoped plugin-sdk aliases for the wrapper jiti loader", async () => {
|
||||
const fixtureRoot = makeFixtureRoot(".tmp-matrix-runtime-aliases-");
|
||||
it("does not use Jiti when only a TypeScript Matrix runtime shim exists", async () => {
|
||||
const fixtureRoot = makeFixtureRoot(".tmp-matrix-runtime-ts-only-");
|
||||
|
||||
writeCapturingSourceRuntimeWrapperFixture(fixtureRoot);
|
||||
await importFixtureModule(fixtureRoot, "extensions/matrix/src/plugin-entry.runtime.js");
|
||||
|
||||
expectSourcePluginSdkAliases(fixtureRoot);
|
||||
}, 240_000);
|
||||
|
||||
it("resolves extension-api aliases through the same source extension family", async () => {
|
||||
const fixtureRoot = makeFixtureRoot(".tmp-matrix-runtime-extension-api-");
|
||||
|
||||
writeFixtureFile(fixtureRoot, "src/extensionAPI.mts", "export {};\n");
|
||||
writeCapturingSourceRuntimeWrapperFixture(fixtureRoot);
|
||||
await importFixtureModule(fixtureRoot, "extensions/matrix/src/plugin-entry.runtime.js");
|
||||
|
||||
expect(matrixWrapperGlobal.__openclawMatrixWrapperJitiOptions).toMatchObject({
|
||||
alias: {
|
||||
"openclaw/extension-api": path.join(fixtureRoot, "src", "extensionAPI.mts"),
|
||||
},
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
it("keeps wrapper plugin-sdk aliases deterministic and ignores unsafe subpaths", async () => {
|
||||
const fixtureRoot = makeFixtureRoot(".tmp-matrix-runtime-alias-order-");
|
||||
|
||||
delete matrixWrapperGlobal.__openclawMatrixWrapperJitiOptions;
|
||||
writeOpenClawAliasFixture(fixtureRoot, {
|
||||
"./plugin-sdk/zeta": "./dist/plugin-sdk/zeta.js",
|
||||
"./plugin-sdk/../escape": "./dist/plugin-sdk/escape.js",
|
||||
"./plugin-sdk/alpha": "./dist/plugin-sdk/alpha.js",
|
||||
});
|
||||
writeFixtureFile(fixtureRoot, "src/plugin-sdk/alpha.ts", "export {};\n");
|
||||
writeFixtureFile(fixtureRoot, "src/plugin-sdk/zeta.ts", "export {};\n");
|
||||
writeCapturingJitiFixture(fixtureRoot);
|
||||
writeOpenClawPackageFixture(fixtureRoot);
|
||||
writeSourceRuntimeWrapperFixture(fixtureRoot, { runtimeExtension: ".ts" });
|
||||
await importFixtureModule(fixtureRoot, "extensions/matrix/src/plugin-entry.runtime.js");
|
||||
|
||||
const aliasKeys = Object.keys(
|
||||
(
|
||||
(matrixWrapperGlobal.__openclawMatrixWrapperJitiOptions ?? {}) as {
|
||||
alias?: Record<string, string>;
|
||||
}
|
||||
).alias ?? {},
|
||||
);
|
||||
expect(aliasKeys).toEqual([
|
||||
PLUGIN_SDK_ROOT,
|
||||
SCOPED_PLUGIN_SDK_ROOT,
|
||||
`${PLUGIN_SDK_ROOT}/alpha`,
|
||||
`${SCOPED_PLUGIN_SDK_ROOT}/alpha`,
|
||||
GROUP_ACCESS_SUBPATH,
|
||||
SCOPED_GROUP_ACCESS_SUBPATH,
|
||||
`${PLUGIN_SDK_ROOT}/zeta`,
|
||||
`${SCOPED_PLUGIN_SDK_ROOT}/zeta`,
|
||||
]);
|
||||
}, 240_000);
|
||||
|
||||
it("ignores nearby untrusted openclaw package stubs when resolving the wrapper root", async () => {
|
||||
const fixtureRoot = makeFixtureRoot(".tmp-matrix-runtime-trusted-root-");
|
||||
|
||||
delete matrixWrapperGlobal.__openclawMatrixWrapperJitiOptions;
|
||||
writeOpenClawAliasFixture(fixtureRoot);
|
||||
writeFixtureFile(
|
||||
fixtureRoot,
|
||||
"extensions/package.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "openclaw",
|
||||
type: "module",
|
||||
exports: {
|
||||
"./plugin-sdk": "./dist/plugin-sdk/index.js",
|
||||
"./plugin-sdk/group-access": "./dist/plugin-sdk/group-access.js",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"node_modules/jiti/index.js",
|
||||
"throw new Error('matrix wrapper must not require jiti');\n",
|
||||
);
|
||||
writeFixtureFile(
|
||||
fixtureRoot,
|
||||
"extensions/src/plugin-sdk/root-alias.cjs",
|
||||
"module.exports = {};\n",
|
||||
);
|
||||
writeFixtureFile(fixtureRoot, "extensions/src/plugin-sdk/group-access.ts", "export {};\n");
|
||||
writeCapturingJitiFixture(fixtureRoot);
|
||||
writeSourceRuntimeWrapperFixture(fixtureRoot, { runtimeExtension: ".ts" });
|
||||
await importFixtureModule(fixtureRoot, "extensions/matrix/src/plugin-entry.runtime.js");
|
||||
|
||||
expectSourcePluginSdkAliases(fixtureRoot);
|
||||
}, 240_000);
|
||||
|
||||
it("treats string bin hints case-insensitively when trusting wrapper package roots", async () => {
|
||||
const fixtureRoot = makeFixtureRoot(".tmp-matrix-runtime-bin-root-");
|
||||
|
||||
delete matrixWrapperGlobal.__openclawMatrixWrapperJitiOptions;
|
||||
writeTrustedOpenClawBinFixture(fixtureRoot, "OpenClaw.MJS");
|
||||
writeCapturingJitiFixture(fixtureRoot);
|
||||
writeSourceRuntimeWrapperFixture(fixtureRoot, { runtimeExtension: ".ts" });
|
||||
await importFixtureModule(fixtureRoot, "extensions/matrix/src/plugin-entry.runtime.js");
|
||||
|
||||
expect(matrixWrapperGlobal.__openclawMatrixWrapperJitiOptions).toMatchObject({
|
||||
alias: {
|
||||
[PLUGIN_SDK_ROOT]: path.join(fixtureRoot, "src", "plugin-sdk", "root-alias.cjs"),
|
||||
[SCOPED_PLUGIN_SDK_ROOT]: path.join(fixtureRoot, "src", "plugin-sdk", "root-alias.cjs"),
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
importFixtureModule(fixtureRoot, "extensions/matrix/src/plugin-entry.runtime.js"),
|
||||
).rejects.toThrow("Cannot resolve matrix plugin runtime module plugin-entry.handlers.runtime");
|
||||
}, 240_000);
|
||||
|
||||
@@ -107,4 +107,4 @@ export { formatZonedTimestamp } from "openclaw/plugin-sdk/time-runtime";
|
||||
export type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
// resolveMatrixAccountStringValues already comes from the Matrix API barrel.
|
||||
// Re-exporting auth-precedence here makes Jiti try to define the same export twice.
|
||||
// Re-exporting auth-precedence here makes TS source loaders define the export twice.
|
||||
|
||||
Reference in New Issue
Block a user