mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-23 16:01:17 +00:00
fix(test): repair channel regression suites
This commit is contained in:
@@ -12,52 +12,17 @@ import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type EnsureConfiguredBindingRouteReadyFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
type MatchPluginCommandFn = typeof import("openclaw/plugin-sdk/plugin-runtime").matchPluginCommand;
|
||||
type ExecutePluginCommandFn =
|
||||
typeof import("openclaw/plugin-sdk/plugin-runtime").executePluginCommand;
|
||||
type DispatchReplyWithDispatcherFn =
|
||||
typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher;
|
||||
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
|
||||
vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||
ok: true,
|
||||
})),
|
||||
);
|
||||
const matchPluginCommandMockState = vi.hoisted(() => ({
|
||||
current: null as null | ReturnType<typeof vi.fn<MatchPluginCommandFn>>,
|
||||
const runtimeModuleMocks = vi.hoisted(() => ({
|
||||
matchPluginCommand: vi.fn(),
|
||||
executePluginCommand: vi.fn(),
|
||||
dispatchReplyWithDispatcher: vi.fn(),
|
||||
}));
|
||||
const executePluginCommandMockState = vi.hoisted(() => ({
|
||||
current: null as null | ReturnType<typeof vi.fn<ExecutePluginCommandFn>>,
|
||||
}));
|
||||
const dispatchReplyWithDispatcherMockState = vi.hoisted(() => ({
|
||||
current: null as null | ReturnType<typeof vi.fn<DispatchReplyWithDispatcherFn>>,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
matchPluginCommand: (...args: Parameters<MatchPluginCommandFn>) =>
|
||||
matchPluginCommandMockState.current
|
||||
? matchPluginCommandMockState.current(...args)
|
||||
: actual.matchPluginCommand(...args),
|
||||
executePluginCommand: (...args: Parameters<ExecutePluginCommandFn>) =>
|
||||
executePluginCommandMockState.current
|
||||
? executePluginCommandMockState.current(...args)
|
||||
: actual.executePluginCommand(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchReplyWithDispatcher: (...args: Parameters<DispatchReplyWithDispatcherFn>) =>
|
||||
dispatchReplyWithDispatcherMockState.current
|
||||
? dispatchReplyWithDispatcherMockState.current(...args)
|
||||
: actual.dispatchReplyWithDispatcher(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
@@ -70,6 +35,24 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args),
|
||||
executePluginCommand: (...args: unknown[]) => runtimeModuleMocks.executePluginCommand(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchReplyWithDispatcher: (...args: unknown[]) =>
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher(...args),
|
||||
};
|
||||
});
|
||||
|
||||
function createInteraction(params?: {
|
||||
channelType?: ChannelType;
|
||||
channelId?: string;
|
||||
@@ -99,6 +82,7 @@ function createConfig(): OpenClawConfig {
|
||||
}
|
||||
|
||||
async function loadCreateDiscordNativeCommand() {
|
||||
vi.resetModules();
|
||||
return (await import("./native-command.js")).createDiscordNativeCommand;
|
||||
}
|
||||
|
||||
@@ -161,8 +145,7 @@ async function expectPairCommandReply(params: {
|
||||
cfg: params.cfg,
|
||||
name: params.commandName,
|
||||
});
|
||||
const dispatchSpy = vi.fn<DispatchReplyWithDispatcherFn>().mockResolvedValue({} as never);
|
||||
dispatchReplyWithDispatcherMockState.current = dispatchSpy;
|
||||
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(
|
||||
Object.assign(params.interaction, {
|
||||
@@ -189,15 +172,13 @@ async function createStatusCommand(cfg: OpenClawConfig) {
|
||||
}
|
||||
|
||||
function createDispatchSpy() {
|
||||
const dispatchSpy = vi.fn<DispatchReplyWithDispatcherFn>().mockResolvedValue({
|
||||
return runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
|
||||
counts: {
|
||||
final: 1,
|
||||
block: 0,
|
||||
tool: 0,
|
||||
},
|
||||
} as never);
|
||||
dispatchReplyWithDispatcherMockState.current = dispatchSpy;
|
||||
return dispatchSpy;
|
||||
}
|
||||
|
||||
function expectBoundSessionDispatch(
|
||||
@@ -221,10 +202,9 @@ async function expectBoundStatusCommandDispatch(params: {
|
||||
interaction: MockCommandInteraction;
|
||||
expectedPattern: RegExp;
|
||||
}) {
|
||||
const command = await createStatusCommand(params.cfg);
|
||||
|
||||
matchPluginCommandMockState.current = vi.fn<MatchPluginCommandFn>().mockReturnValue(null);
|
||||
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
const command = await createStatusCommand(params.cfg);
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(
|
||||
params.interaction as unknown,
|
||||
@@ -234,17 +214,33 @@ async function expectBoundStatusCommandDispatch(params: {
|
||||
}
|
||||
|
||||
describe("Discord native plugin command dispatch", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
clearPluginCommands();
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
matchPluginCommandMockState.current = null;
|
||||
executePluginCommandMockState.current = null;
|
||||
dispatchReplyWithDispatcherMockState.current = null;
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
const actualPluginRuntime = await vi.importActual<
|
||||
typeof import("openclaw/plugin-sdk/plugin-runtime")
|
||||
>("openclaw/plugin-sdk/plugin-runtime");
|
||||
runtimeModuleMocks.matchPluginCommand.mockReset();
|
||||
runtimeModuleMocks.matchPluginCommand.mockImplementation(
|
||||
actualPluginRuntime.matchPluginCommand,
|
||||
);
|
||||
runtimeModuleMocks.executePluginCommand.mockReset();
|
||||
runtimeModuleMocks.executePluginCommand.mockImplementation(
|
||||
actualPluginRuntime.executePluginCommand,
|
||||
);
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher.mockReset();
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
|
||||
counts: {
|
||||
final: 1,
|
||||
block: 0,
|
||||
tool: 0,
|
||||
},
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("executes plugin commands from the real registry through the native Discord command path", async () => {
|
||||
@@ -319,9 +315,10 @@ describe("Discord native plugin command dispatch", () => {
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
const executeSpy = vi.fn<ExecutePluginCommandFn>();
|
||||
executePluginCommandMockState.current = executeSpy;
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
const executeSpy = runtimeModuleMocks.executePluginCommand;
|
||||
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
@@ -342,7 +339,6 @@ describe("Discord native plugin command dispatch", () => {
|
||||
description: "List cron jobs",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
const command = await createNativeCommand(cfg, commandSpec);
|
||||
const interaction = createInteraction();
|
||||
const pluginMatch = {
|
||||
command: {
|
||||
@@ -355,14 +351,14 @@ describe("Discord native plugin command dispatch", () => {
|
||||
args: undefined,
|
||||
};
|
||||
|
||||
matchPluginCommandMockState.current = vi
|
||||
.fn<MatchPluginCommandFn>()
|
||||
.mockReturnValue(pluginMatch as ReturnType<MatchPluginCommandFn>);
|
||||
const executeSpy = vi
|
||||
.fn<ExecutePluginCommandFn>()
|
||||
.mockResolvedValue({ text: "direct plugin output" });
|
||||
executePluginCommandMockState.current = executeSpy;
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
runtimeModuleMocks.matchPluginCommand.mockReturnValue(pluginMatch as never);
|
||||
const executeSpy = runtimeModuleMocks.executePluginCommand.mockResolvedValue({
|
||||
text: "direct plugin output",
|
||||
});
|
||||
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
const command = await createNativeCommand(cfg, commandSpec);
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
@@ -450,7 +446,6 @@ describe("Discord native plugin command dispatch", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const command = await createStatusCommand(cfg);
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId,
|
||||
@@ -458,8 +453,9 @@ describe("Discord native plugin command dispatch", () => {
|
||||
guildName: "Ops",
|
||||
});
|
||||
|
||||
matchPluginCommandMockState.current = vi.fn<MatchPluginCommandFn>().mockReturnValue(null);
|
||||
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
const command = await createStatusCommand(cfg);
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
@@ -540,19 +536,18 @@ describe("Discord native plugin command dispatch", () => {
|
||||
guildId,
|
||||
guildName: "Ops",
|
||||
});
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "acpx exited with code 1",
|
||||
});
|
||||
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
const command = await createNativeCommand(cfg, {
|
||||
name: "new",
|
||||
description: "Start a new session.",
|
||||
acceptsArgs: true,
|
||||
});
|
||||
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "acpx exited with code 1",
|
||||
});
|
||||
matchPluginCommandMockState.current = vi.fn<MatchPluginCommandFn>().mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../../runtime-api.js";
|
||||
import { setMatrixRuntime } from "../runtime.js";
|
||||
import { voteMatrixPoll } from "./actions/polls.js";
|
||||
import { sendMessageMatrix, sendTypingMatrix } from "./send.js";
|
||||
|
||||
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
@@ -46,6 +43,18 @@ const runtimeStub = {
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||
let sendTypingMatrix: typeof import("./send.js").sendTypingMatrix;
|
||||
let voteMatrixPoll: typeof import("./actions/polls.js").voteMatrixPoll;
|
||||
|
||||
async function loadMatrixSendModules() {
|
||||
vi.resetModules();
|
||||
const runtimeModule = await import("../runtime.js");
|
||||
runtimeModule.setMatrixRuntime(runtimeStub);
|
||||
({ sendMessageMatrix } = await import("./send.js"));
|
||||
({ sendTypingMatrix } = await import("./send.js"));
|
||||
({ voteMatrixPoll } = await import("./actions/polls.js"));
|
||||
}
|
||||
const makeClient = () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue("evt1");
|
||||
const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote");
|
||||
@@ -66,7 +75,11 @@ const makeClient = () => {
|
||||
};
|
||||
|
||||
describe("sendMessageMatrix media", () => {
|
||||
beforeEach(() => {
|
||||
beforeAll(async () => {
|
||||
await loadMatrixSendModules();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
loadWebMediaMock.mockReset().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
@@ -79,7 +92,7 @@ describe("sendMessageMatrix media", () => {
|
||||
mediaKindFromMimeMock.mockReset().mockReturnValue("image");
|
||||
isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false);
|
||||
resolveTextChunkLimitMock.mockReset().mockReturnValue(4000);
|
||||
setMatrixRuntime(runtimeStub);
|
||||
await loadMatrixSendModules();
|
||||
});
|
||||
|
||||
it("uploads media with url payloads", async () => {
|
||||
@@ -317,12 +330,12 @@ describe("sendMessageMatrix media", () => {
|
||||
});
|
||||
|
||||
describe("sendMessageMatrix threads", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
loadConfigMock.mockReset().mockReturnValue({});
|
||||
mediaKindFromMimeMock.mockReset().mockReturnValue("image");
|
||||
isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false);
|
||||
setMatrixRuntime(runtimeStub);
|
||||
await loadMatrixSendModules();
|
||||
});
|
||||
|
||||
it("includes thread relation metadata when threadId is set", async () => {
|
||||
@@ -361,12 +374,16 @@ describe("sendMessageMatrix threads", () => {
|
||||
});
|
||||
|
||||
describe("voteMatrixPoll", () => {
|
||||
beforeEach(() => {
|
||||
beforeAll(async () => {
|
||||
await loadMatrixSendModules();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
loadConfigMock.mockReset().mockReturnValue({});
|
||||
mediaKindFromMimeMock.mockReset().mockReturnValue("image");
|
||||
isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false);
|
||||
setMatrixRuntime(runtimeStub);
|
||||
await loadMatrixSendModules();
|
||||
});
|
||||
|
||||
it("maps 1-based option indexes to Matrix poll answer ids", async () => {
|
||||
@@ -502,12 +519,16 @@ describe("voteMatrixPoll", () => {
|
||||
});
|
||||
|
||||
describe("sendTypingMatrix", () => {
|
||||
beforeEach(() => {
|
||||
beforeAll(async () => {
|
||||
await loadMatrixSendModules();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
loadConfigMock.mockReset().mockReturnValue({});
|
||||
mediaKindFromMimeMock.mockReset().mockReturnValue("image");
|
||||
isVoiceCompatibleAudioMock.mockReset().mockReturnValue(false);
|
||||
setMatrixRuntime(runtimeStub);
|
||||
await loadMatrixSendModules();
|
||||
});
|
||||
|
||||
it("normalizes room-prefixed targets before sending typing state", async () => {
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
waitForMessageCalls,
|
||||
} from "./monitor-inbox.test-harness.js";
|
||||
|
||||
let nextMessageSequence = 0;
|
||||
|
||||
function nextMessageId(label: string): string {
|
||||
nextMessageSequence += 1;
|
||||
return `${label}-${nextMessageSequence}`;
|
||||
}
|
||||
|
||||
describe("web monitor inbox", () => {
|
||||
installWebMonitorInboxUnitTestHooks();
|
||||
|
||||
@@ -24,7 +31,11 @@ describe("web monitor inbox", () => {
|
||||
type: "notify",
|
||||
messages: [
|
||||
{
|
||||
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
key: {
|
||||
id: nextMessageId("quoted"),
|
||||
fromMe: false,
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: {
|
||||
extendedTextMessage: {
|
||||
text: "reply",
|
||||
@@ -66,8 +77,9 @@ describe("web monitor inbox", () => {
|
||||
|
||||
const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage);
|
||||
expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available");
|
||||
const messageId = nextMessageId("stream");
|
||||
const upsert = buildNotifyMessageUpsert({
|
||||
id: "abc",
|
||||
id: messageId,
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
text: "ping",
|
||||
timestamp: 1_700_000_000,
|
||||
@@ -83,7 +95,7 @@ describe("web monitor inbox", () => {
|
||||
expect(sock.readMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
id: "abc",
|
||||
id: messageId,
|
||||
participant: undefined,
|
||||
fromMe: false,
|
||||
},
|
||||
@@ -104,7 +116,7 @@ describe("web monitor inbox", () => {
|
||||
|
||||
const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage);
|
||||
const upsert = buildNotifyMessageUpsert({
|
||||
id: "abc",
|
||||
id: nextMessageId("dedupe"),
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
text: "ping",
|
||||
timestamp: 1_700_000_000,
|
||||
@@ -129,7 +141,7 @@ describe("web monitor inbox", () => {
|
||||
const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID");
|
||||
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce("999:0@s.whatsapp.net");
|
||||
const upsert = buildNotifyMessageUpsert({
|
||||
id: "abc",
|
||||
id: nextMessageId("lid-store"),
|
||||
remoteJid: "999@lid",
|
||||
text: "ping",
|
||||
timestamp: 1_700_000_000,
|
||||
@@ -159,7 +171,7 @@ describe("web monitor inbox", () => {
|
||||
const { listener, sock } = await startInboxMonitor(onMessage as InboxOnMessage);
|
||||
const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID");
|
||||
const upsert = buildNotifyMessageUpsert({
|
||||
id: "abc",
|
||||
id: nextMessageId("lid-authdir"),
|
||||
remoteJid: "555@lid",
|
||||
text: "ping",
|
||||
timestamp: 1_700_000_000,
|
||||
@@ -186,7 +198,7 @@ describe("web monitor inbox", () => {
|
||||
const getPNForLID = vi.spyOn(sock.signalRepository.lidMapping, "getPNForLID");
|
||||
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce("444:0@s.whatsapp.net");
|
||||
const upsert = buildNotifyMessageUpsert({
|
||||
id: "abc",
|
||||
id: nextMessageId("group-lid"),
|
||||
remoteJid: "123@g.us",
|
||||
participant: "444@lid",
|
||||
text: "ping",
|
||||
|
||||
@@ -75,9 +75,6 @@ function createMockSock(): MockSock {
|
||||
};
|
||||
}
|
||||
|
||||
const sock: MockSock = createMockSock();
|
||||
sessionState.sock = sock;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
|
||||
return {
|
||||
@@ -134,7 +131,10 @@ vi.mock("./session.js", () => ({
|
||||
}));
|
||||
|
||||
export function getSock(): MockSock {
|
||||
return sock;
|
||||
if (!sessionState.sock) {
|
||||
throw new Error("mock WhatsApp socket not initialized");
|
||||
}
|
||||
return sessionState.sock;
|
||||
}
|
||||
|
||||
export type InboxOnMessage = NonNullable<Parameters<typeof monitorWebInbox>[0]["onMessage"]>;
|
||||
@@ -212,6 +212,7 @@ export function installWebMonitorInboxUnitTestHooks(opts?: { authDir?: boolean }
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
sessionState.sock = createMockSock();
|
||||
mockLoadConfig.mockReturnValue(DEFAULT_WEB_INBOX_CONFIG);
|
||||
readAllowFromStoreMock.mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user