Compare commits
2 Commits
main
...
feature/bl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c197c38c1 | ||
|
|
559494ee8a |
@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||||
|
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477) Thanks @Nicell.
|
||||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||||
|
|||||||
@ -147,7 +147,8 @@ Available actions:
|
|||||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
||||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
|
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
|
||||||
|
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
|
||||||
|
|
||||||
### Message IDs (short vs full)
|
### Message IDs (short vs full)
|
||||||
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
||||||
|
|||||||
@ -521,6 +521,42 @@ describe("bluebubblesMessageActions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes asVoice through sendAttachment", async () => {
|
||||||
|
const { sendBlueBubblesAttachment } = await import("./attachments.js");
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64Buffer = Buffer.from("voice").toString("base64");
|
||||||
|
|
||||||
|
await bluebubblesMessageActions.handleAction({
|
||||||
|
action: "sendAttachment",
|
||||||
|
params: {
|
||||||
|
to: "+15551234567",
|
||||||
|
filename: "voice.mp3",
|
||||||
|
buffer: base64Buffer,
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
asVoice: true,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
filename: "voice.mp3",
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
asVoice: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws when buffer is missing for setGroupIcon", async () => {
|
it("throws when buffer is missing for setGroupIcon", async () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
BLUEBUBBLES_ACTIONS,
|
BLUEBUBBLES_ACTIONS,
|
||||||
createActionGate,
|
createActionGate,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
readBooleanParam,
|
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readReactionParams,
|
readReactionParams,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
@ -51,6 +50,17 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
|||||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
||||||
|
const raw = params[key];
|
||||||
|
if (typeof raw === "boolean") return raw;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
if (trimmed === "true") return true;
|
||||||
|
if (trimmed === "false") return false;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Supported action names for BlueBubbles */
|
/** Supported action names for BlueBubbles */
|
||||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||||
|
|
||||||
@ -356,6 +366,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const caption = readStringParam(params, "caption");
|
const caption = readStringParam(params, "caption");
|
||||||
const contentType =
|
const contentType =
|
||||||
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
||||||
|
const asVoice = readBooleanParam(params, "asVoice");
|
||||||
|
|
||||||
// Buffer can come from params.buffer (base64) or params.path (file path)
|
// Buffer can come from params.buffer (base64) or params.path (file path)
|
||||||
const base64Buffer = readStringParam(params, "buffer");
|
const base64Buffer = readStringParam(params, "buffer");
|
||||||
@ -380,6 +391,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
filename,
|
filename,
|
||||||
contentType: contentType ?? undefined,
|
contentType: contentType ?? undefined,
|
||||||
caption: caption ?? undefined,
|
caption: caption ?? undefined,
|
||||||
|
asVoice: asVoice ?? undefined,
|
||||||
opts,
|
opts,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||||
import type { BlueBubblesAttachment } from "./types.js";
|
import type { BlueBubblesAttachment } from "./types.js";
|
||||||
|
|
||||||
vi.mock("./accounts.js", () => ({
|
vi.mock("./accounts.js", () => ({
|
||||||
@ -238,3 +238,109 @@ describe("downloadBlueBubblesAttachment", () => {
|
|||||||
expect(result.buffer).toEqual(new Uint8Array([1]));
|
expect(result.buffer).toEqual(new Uint8Array([1]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sendBlueBubblesAttachment", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", mockFetch);
|
||||||
|
mockFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function decodeBody(body: Uint8Array) {
|
||||||
|
return Buffer.from(body).toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("marks voice memos when asVoice is true and mp3 is provided", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendBlueBubblesAttachment({
|
||||||
|
to: "chat_guid:iMessage;-;+15551234567",
|
||||||
|
buffer: new Uint8Array([1, 2, 3]),
|
||||||
|
filename: "voice.mp3",
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
asVoice: true,
|
||||||
|
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||||
|
const bodyText = decodeBody(body);
|
||||||
|
expect(bodyText).toContain('name="isAudioMessage"');
|
||||||
|
expect(bodyText).toContain("true");
|
||||||
|
expect(bodyText).toContain('filename="voice.mp3"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes mp3 filenames for voice memos", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendBlueBubblesAttachment({
|
||||||
|
to: "chat_guid:iMessage;-;+15551234567",
|
||||||
|
buffer: new Uint8Array([1, 2, 3]),
|
||||||
|
filename: "voice",
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
asVoice: true,
|
||||||
|
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||||
|
const bodyText = decodeBody(body);
|
||||||
|
expect(bodyText).toContain('filename="voice.mp3"');
|
||||||
|
expect(bodyText).toContain('name="voice.mp3"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when asVoice is true but media is not audio", async () => {
|
||||||
|
await expect(
|
||||||
|
sendBlueBubblesAttachment({
|
||||||
|
to: "chat_guid:iMessage;-;+15551234567",
|
||||||
|
buffer: new Uint8Array([1, 2, 3]),
|
||||||
|
filename: "image.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
asVoice: true,
|
||||||
|
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("voice messages require audio");
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when asVoice is true but audio is not mp3 or caf", async () => {
|
||||||
|
await expect(
|
||||||
|
sendBlueBubblesAttachment({
|
||||||
|
to: "chat_guid:iMessage;-;+15551234567",
|
||||||
|
buffer: new Uint8Array([1, 2, 3]),
|
||||||
|
filename: "voice.wav",
|
||||||
|
contentType: "audio/wav",
|
||||||
|
asVoice: true,
|
||||||
|
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("require mp3 or caf");
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes filenames before sending", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendBlueBubblesAttachment({
|
||||||
|
to: "chat_guid:iMessage;-;+15551234567",
|
||||||
|
buffer: new Uint8Array([1, 2, 3]),
|
||||||
|
filename: "../evil.mp3",
|
||||||
|
contentType: "audio/mpeg",
|
||||||
|
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||||
|
const bodyText = decodeBody(body);
|
||||||
|
expect(bodyText).toContain('filename="evil.mp3"');
|
||||||
|
expect(bodyText).toContain('name="evil.mp3"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
import { resolveChatGuidForTarget } from "./send.js";
|
import { resolveChatGuidForTarget } from "./send.js";
|
||||||
@ -19,6 +20,30 @@ export type BlueBubblesAttachmentOpts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
|
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
|
||||||
|
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
||||||
|
|
||||||
|
function sanitizeFilename(input: string | undefined, fallback: string): string {
|
||||||
|
const trimmed = input?.trim() ?? "";
|
||||||
|
const base = trimmed ? path.basename(trimmed) : "";
|
||||||
|
return base || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
||||||
|
const currentExt = path.extname(filename);
|
||||||
|
if (currentExt.toLowerCase() === extension) return filename;
|
||||||
|
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
|
||||||
|
return `${base || fallbackBase}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVoiceInfo(filename: string, contentType?: string) {
|
||||||
|
const normalizedType = contentType?.trim().toLowerCase();
|
||||||
|
const extension = path.extname(filename).toLowerCase();
|
||||||
|
const isMp3 = extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
|
||||||
|
const isCaf = extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
|
||||||
|
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
|
||||||
|
return { isAudio, isMp3, isCaf };
|
||||||
|
}
|
||||||
|
|
||||||
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||||
const account = resolveBlueBubblesAccount({
|
const account = resolveBlueBubblesAccount({
|
||||||
@ -104,6 +129,7 @@ function extractMessageId(payload: unknown): string {
|
|||||||
/**
|
/**
|
||||||
* Send an attachment via BlueBubbles API.
|
* Send an attachment via BlueBubbles API.
|
||||||
* Supports sending media files (images, videos, audio, documents) to a chat.
|
* Supports sending media files (images, videos, audio, documents) to a chat.
|
||||||
|
* When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo.
|
||||||
*/
|
*/
|
||||||
export async function sendBlueBubblesAttachment(params: {
|
export async function sendBlueBubblesAttachment(params: {
|
||||||
to: string;
|
to: string;
|
||||||
@ -113,12 +139,37 @@ export async function sendBlueBubblesAttachment(params: {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
replyToMessageGuid?: string;
|
replyToMessageGuid?: string;
|
||||||
replyToPartIndex?: number;
|
replyToPartIndex?: number;
|
||||||
|
asVoice?: boolean;
|
||||||
opts?: BlueBubblesAttachmentOpts;
|
opts?: BlueBubblesAttachmentOpts;
|
||||||
}): Promise<SendBlueBubblesAttachmentResult> {
|
}): Promise<SendBlueBubblesAttachmentResult> {
|
||||||
const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
|
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
|
||||||
params;
|
let { buffer, filename, contentType } = params;
|
||||||
|
const wantsVoice = asVoice === true;
|
||||||
|
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
||||||
|
filename = sanitizeFilename(filename, fallbackName);
|
||||||
|
contentType = contentType?.trim() || undefined;
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
|
||||||
|
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
||||||
|
const isAudioMessage = wantsVoice;
|
||||||
|
if (isAudioMessage) {
|
||||||
|
const voiceInfo = resolveVoiceInfo(filename, contentType);
|
||||||
|
if (!voiceInfo.isAudio) {
|
||||||
|
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
|
||||||
|
}
|
||||||
|
if (voiceInfo.isMp3) {
|
||||||
|
filename = ensureExtension(filename, ".mp3", fallbackName);
|
||||||
|
contentType = contentType ?? "audio/mpeg";
|
||||||
|
} else if (voiceInfo.isCaf) {
|
||||||
|
filename = ensureExtension(filename, ".caf", fallbackName);
|
||||||
|
contentType = contentType ?? "audio/x-caf";
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"BlueBubbles voice messages require mp3 or caf audio (convert before sending).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const target = resolveSendTarget(to);
|
const target = resolveSendTarget(to);
|
||||||
const chatGuid = await resolveChatGuidForTarget({
|
const chatGuid = await resolveChatGuidForTarget({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@ -170,6 +221,11 @@ export async function sendBlueBubblesAttachment(params: {
|
|||||||
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
||||||
addField("method", "private-api");
|
addField("method", "private-api");
|
||||||
|
|
||||||
|
// Add isAudioMessage flag for voice memos
|
||||||
|
if (isAudioMessage) {
|
||||||
|
addField("isAudioMessage", "true");
|
||||||
|
}
|
||||||
|
|
||||||
const trimmedReplyTo = replyToMessageGuid?.trim();
|
const trimmedReplyTo = replyToMessageGuid?.trim();
|
||||||
if (trimmedReplyTo) {
|
if (trimmedReplyTo) {
|
||||||
addField("selectedMessageGuid", trimmedReplyTo);
|
addField("selectedMessageGuid", trimmedReplyTo);
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
replyToId?: string | null;
|
replyToId?: string | null;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
asVoice?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
cfg,
|
cfg,
|
||||||
@ -71,6 +72,7 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
caption,
|
caption,
|
||||||
replyToId,
|
replyToId,
|
||||||
accountId,
|
accountId,
|
||||||
|
asVoice,
|
||||||
} = params;
|
} = params;
|
||||||
const core = getBlueBubblesRuntime();
|
const core = getBlueBubblesRuntime();
|
||||||
const maxBytes = resolveChannelMediaMaxBytes({
|
const maxBytes = resolveChannelMediaMaxBytes({
|
||||||
@ -146,6 +148,7 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
filename: resolvedFilename ?? "attachment",
|
filename: resolvedFilename ?? "attachment",
|
||||||
contentType: resolvedContentType ?? undefined,
|
contentType: resolvedContentType ?? undefined,
|
||||||
replyToMessageGuid,
|
replyToMessageGuid,
|
||||||
|
asVoice,
|
||||||
opts: {
|
opts: {
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
|
|||||||
18
src/config/logging.ts
Normal file
18
src/config/logging.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
import { displayPath } from "../utils.js";
|
||||||
|
import { CONFIG_PATH_CLAWDBOT } from "./paths.js";
|
||||||
|
|
||||||
|
type LogConfigUpdatedOptions = {
|
||||||
|
path?: string;
|
||||||
|
suffix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatConfigPath(path: string = CONFIG_PATH_CLAWDBOT): string {
|
||||||
|
return displayPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logConfigUpdated(runtime: RuntimeEnv, opts: LogConfigUpdatedOptions = {}): void {
|
||||||
|
const path = formatConfigPath(opts.path ?? CONFIG_PATH_CLAWDBOT);
|
||||||
|
const suffix = opts.suffix ? ` ${opts.suffix}` : "";
|
||||||
|
runtime.log(`Updated ${path}${suffix}`);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user