Compare commits
2 Commits
main
...
fix/imsg-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e297de734 | ||
|
|
8144668b1a |
@ -40,6 +40,7 @@
|
|||||||
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
|
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
|
||||||
- Security: bump `tar` to 7.5.3.
|
- Security: bump `tar` to 7.5.3.
|
||||||
- Models: align ZAI thinking toggles.
|
- Models: align ZAI thinking toggles.
|
||||||
|
- iMessage: fetch remote attachments for SSH wrappers and document `remoteHost`. (#1054) — thanks @tyler6204.
|
||||||
|
|
||||||
## 2026.1.15
|
## 2026.1.15
|
||||||
|
|
||||||
|
|||||||
@ -108,10 +108,26 @@ If you want iMessage on another Mac, set `channels.imessage.cliPath` to a wrappe
|
|||||||
Example wrapper:
|
Example wrapper:
|
||||||
```bash
|
```bash
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
exec ssh -T mac-mini imsg "$@"
|
exec ssh -T gateway-host imsg "$@"
|
||||||
```
|
```
|
||||||
|
|
||||||
Multi-account support: use `channels.imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don’t commit `~/.clawdbot/clawdbot.json` (it often contains tokens).
|
**Remote attachments:** When `cliPath` points to a remote host via SSH, attachment paths in the Messages database reference files on the remote machine. Clawdbot can automatically fetch these over SCP by setting `channels.imessage.remoteHost`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
imessage: {
|
||||||
|
cliPath: "~/imsg-ssh", // SSH wrapper to remote Mac
|
||||||
|
remoteHost: "user@gateway-host", // for SCP file transfer
|
||||||
|
includeAttachments: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `remoteHost` is not set, Clawdbot attempts to auto-detect it by parsing the SSH command in your wrapper script. Explicit configuration is recommended for reliability.
|
||||||
|
|
||||||
|
Multi-account support: use `channels.imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don't commit `~/.clawdbot/clawdbot.json` (it often contains tokens).
|
||||||
|
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
@ -182,6 +198,7 @@ Provider options:
|
|||||||
- `channels.imessage.enabled`: enable/disable channel startup.
|
- `channels.imessage.enabled`: enable/disable channel startup.
|
||||||
- `channels.imessage.cliPath`: path to `imsg`.
|
- `channels.imessage.cliPath`: path to `imsg`.
|
||||||
- `channels.imessage.dbPath`: Messages DB path.
|
- `channels.imessage.dbPath`: Messages DB path.
|
||||||
|
- `channels.imessage.remoteHost`: SSH host for SCP attachment transfer when `cliPath` points to a remote Mac (e.g., `user@gateway-host`). Auto-detected from SSH wrapper if not set.
|
||||||
- `channels.imessage.service`: `imessage | sms | auto`.
|
- `channels.imessage.service`: `imessage | sms | auto`.
|
||||||
- `channels.imessage.region`: SMS region.
|
- `channels.imessage.region`: SMS region.
|
||||||
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||||
|
|||||||
@ -1206,6 +1206,7 @@ Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
cliPath: "imsg",
|
cliPath: "imsg",
|
||||||
dbPath: "~/Library/Messages/chat.db",
|
dbPath: "~/Library/Messages/chat.db",
|
||||||
|
remoteHost: "user@gateway-host", // SCP for remote attachments when using SSH wrapper
|
||||||
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
dmPolicy: "pairing", // pairing | allowlist | open | disabled
|
||||||
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
|
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
|
||||||
historyLimit: 50, // include last N group messages as context (0 disables)
|
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||||
@ -1225,11 +1226,12 @@ Notes:
|
|||||||
- The first send will prompt for Messages automation permission.
|
- The first send will prompt for Messages automation permission.
|
||||||
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
|
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
|
||||||
- `channels.imessage.cliPath` can point to a wrapper script (e.g. `ssh` to another Mac that runs `imsg rpc`); use SSH keys to avoid password prompts.
|
- `channels.imessage.cliPath` can point to a wrapper script (e.g. `ssh` to another Mac that runs `imsg rpc`); use SSH keys to avoid password prompts.
|
||||||
|
- For remote SSH wrappers, set `channels.imessage.remoteHost` to fetch attachments via SCP when `includeAttachments` is enabled.
|
||||||
|
|
||||||
Example wrapper:
|
Example wrapper:
|
||||||
```bash
|
```bash
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
exec ssh -T mac-mini "imsg rpc"
|
exec ssh -T gateway-host imsg "$@"
|
||||||
```
|
```
|
||||||
|
|
||||||
### `agents.defaults.workspace`
|
### `agents.defaults.workspace`
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { copyRemoteFileViaScp } from "../../media/remote.js";
|
||||||
|
import { ensureMediaDir } from "../../media/store.js";
|
||||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||||
|
|
||||||
export async function stageSandboxMedia(params: {
|
export async function stageSandboxMedia(params: {
|
||||||
@ -29,7 +32,8 @@ export async function stageSandboxMedia(params: {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
});
|
});
|
||||||
if (!sandbox) return;
|
const usesRemoteMedia = Boolean(ctx.MediaRemoteHost);
|
||||||
|
if (!sandbox && !usesRemoteMedia) return;
|
||||||
|
|
||||||
const resolveAbsolutePath = (value: string): string | null => {
|
const resolveAbsolutePath = (value: string): string | null => {
|
||||||
let resolved = value.trim();
|
let resolved = value.trim();
|
||||||
@ -46,7 +50,9 @@ export async function stageSandboxMedia(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const destDir = path.join(sandbox.workspaceDir, "media", "inbound");
|
const destDir = sandbox
|
||||||
|
? path.join(sandbox.workspaceDir, "media", "inbound")
|
||||||
|
: path.join(await ensureMediaDir(), "inbound");
|
||||||
await fs.mkdir(destDir, { recursive: true });
|
await fs.mkdir(destDir, { recursive: true });
|
||||||
|
|
||||||
const usedNames = new Set<string>();
|
const usedNames = new Set<string>();
|
||||||
@ -61,17 +67,31 @@ export async function stageSandboxMedia(params: {
|
|||||||
if (!baseName) continue;
|
if (!baseName) continue;
|
||||||
const parsed = path.parse(baseName);
|
const parsed = path.parse(baseName);
|
||||||
let fileName = baseName;
|
let fileName = baseName;
|
||||||
let suffix = 1;
|
if (!sandbox && usesRemoteMedia) {
|
||||||
while (usedNames.has(fileName)) {
|
fileName = `${parsed.name}-${crypto.randomUUID()}${parsed.ext}`;
|
||||||
fileName = `${parsed.name}-${suffix}${parsed.ext}`;
|
} else {
|
||||||
suffix += 1;
|
let suffix = 1;
|
||||||
|
while (usedNames.has(fileName)) {
|
||||||
|
fileName = `${parsed.name}-${suffix}${parsed.ext}`;
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
usedNames.add(fileName);
|
usedNames.add(fileName);
|
||||||
|
|
||||||
const dest = path.join(destDir, fileName);
|
const dest = path.join(destDir, fileName);
|
||||||
await fs.copyFile(source, dest);
|
if (ctx.MediaRemoteHost) {
|
||||||
const relative = path.posix.join("media", "inbound", fileName);
|
// Always use SCP when remote host is configured - local paths refer to remote machine
|
||||||
staged.set(source, relative);
|
await copyRemoteFileViaScp({
|
||||||
|
remoteHost: ctx.MediaRemoteHost,
|
||||||
|
remotePath: source,
|
||||||
|
localPath: dest,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fs.copyFile(source, dest);
|
||||||
|
}
|
||||||
|
// For sandbox use relative path, otherwise use absolute path
|
||||||
|
const stagedPath = sandbox ? path.posix.join("media", "inbound", fileName) : dest;
|
||||||
|
staged.set(source, stagedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rewriteIfStaged = (value: string | undefined): string | undefined => {
|
const rewriteIfStaged = (value: string | undefined): string | undefined => {
|
||||||
|
|||||||
@ -38,6 +38,8 @@ export type MsgContext = {
|
|||||||
MediaPaths?: string[];
|
MediaPaths?: string[];
|
||||||
MediaUrls?: string[];
|
MediaUrls?: string[];
|
||||||
MediaTypes?: string[];
|
MediaTypes?: string[];
|
||||||
|
/** Internal: remote host for inbound media fetch (iMessage SSH wrappers). */
|
||||||
|
MediaRemoteHost?: string;
|
||||||
Transcript?: string;
|
Transcript?: string;
|
||||||
ChatType?: string;
|
ChatType?: string;
|
||||||
GroupSubject?: string;
|
GroupSubject?: string;
|
||||||
|
|||||||
@ -14,6 +14,8 @@ export type IMessageAccountConfig = {
|
|||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
/** Optional Messages db path override. */
|
/** Optional Messages db path override. */
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
|
/** Remote host for SCP when attachments live on a different machine (e.g., user@gateway-host). */
|
||||||
|
remoteHost?: string;
|
||||||
/** Optional default send service (imessage|sms|auto). */
|
/** Optional default send service (imessage|sms|auto). */
|
||||||
service?: "imessage" | "sms" | "auto";
|
service?: "imessage" | "sms" | "auto";
|
||||||
/** Optional default region (used when sending SMS). */
|
/** Optional default region (used when sending SMS). */
|
||||||
|
|||||||
@ -367,6 +367,7 @@ export const IMessageAccountSchemaBase = z.object({
|
|||||||
configWrites: z.boolean().optional(),
|
configWrites: z.boolean().optional(),
|
||||||
cliPath: ExecutableTokenSchema.optional(),
|
cliPath: ExecutableTokenSchema.optional(),
|
||||||
dbPath: z.string().optional(),
|
dbPath: z.string().optional(),
|
||||||
|
remoteHost: z.string().optional(),
|
||||||
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
|
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
|
||||||
region: z.string().optional(),
|
region: z.string().optional(),
|
||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resolveEffectiveMessagesConfig,
|
resolveEffectiveMessagesConfig,
|
||||||
resolveHumanDelayConfig,
|
resolveHumanDelayConfig,
|
||||||
@ -39,7 +41,7 @@ import {
|
|||||||
upsertChannelPairingRequest,
|
upsertChannelPairingRequest,
|
||||||
} from "../../pairing/pairing-store.js";
|
} from "../../pairing/pairing-store.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { resolveUserPath, truncateUtf16Safe } from "../../utils.js";
|
||||||
import { resolveIMessageAccount } from "../accounts.js";
|
import { resolveIMessageAccount } from "../accounts.js";
|
||||||
import { createIMessageRpcClient } from "../client.js";
|
import { createIMessageRpcClient } from "../client.js";
|
||||||
import { probeIMessage } from "../probe.js";
|
import { probeIMessage } from "../probe.js";
|
||||||
@ -53,6 +55,66 @@ import { deliverReplies } from "./deliver.js";
|
|||||||
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
|
import { normalizeAllowList, resolveRuntime } from "./runtime.js";
|
||||||
import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
|
import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to detect remote host from an SSH wrapper script like:
|
||||||
|
* exec ssh -T clawdbot@192.168.64.3 /opt/homebrew/bin/imsg "$@"
|
||||||
|
* exec ssh -T mac-mini imsg "$@"
|
||||||
|
* Returns the user@host or host portion if found, undefined otherwise.
|
||||||
|
*/
|
||||||
|
async function detectRemoteHostFromCliPath(cliPath: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const expanded = resolveUserPath(cliPath);
|
||||||
|
const stat = await fs.stat(expanded).catch(() => null);
|
||||||
|
if (!stat?.isFile()) return undefined;
|
||||||
|
|
||||||
|
const content = await fs.readFile(expanded, "utf8");
|
||||||
|
if (!content.includes("ssh")) return undefined;
|
||||||
|
|
||||||
|
const tokensFromLine = (line: string): string[] =>
|
||||||
|
line.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
||||||
|
const stripQuotes = (token: string): string => token.replace(/^['"]|['"]$/g, "");
|
||||||
|
const optsWithValue = new Set([
|
||||||
|
"-b",
|
||||||
|
"-c",
|
||||||
|
"-D",
|
||||||
|
"-E",
|
||||||
|
"-F",
|
||||||
|
"-I",
|
||||||
|
"-i",
|
||||||
|
"-J",
|
||||||
|
"-L",
|
||||||
|
"-o",
|
||||||
|
"-p",
|
||||||
|
"-R",
|
||||||
|
"-S",
|
||||||
|
"-W",
|
||||||
|
"-w",
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
if (!line.includes("ssh") || !line.includes("imsg")) continue;
|
||||||
|
const tokens = tokensFromLine(line).map(stripQuotes).filter(Boolean);
|
||||||
|
const sshIndex = tokens.findIndex((token) => token === "ssh" || token.endsWith("/ssh"));
|
||||||
|
if (sshIndex < 0) continue;
|
||||||
|
for (let idx = sshIndex + 1; idx < tokens.length; idx += 1) {
|
||||||
|
const token = tokens[idx] ?? "";
|
||||||
|
if (!token) continue;
|
||||||
|
if (token.startsWith("-")) {
|
||||||
|
if (optsWithValue.has(token)) idx += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token === "imsg" || token.endsWith("/imsg")) break;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
|
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
|
||||||
const runtime = resolveRuntime(opts);
|
const runtime = resolveRuntime(opts);
|
||||||
const cfg = opts.config ?? loadConfig();
|
const cfg = opts.config ?? loadConfig();
|
||||||
@ -82,6 +144,15 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
|
const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
|
||||||
const dbPath = opts.dbPath ?? imessageCfg.dbPath;
|
const dbPath = opts.dbPath ?? imessageCfg.dbPath;
|
||||||
|
|
||||||
|
// Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script
|
||||||
|
let remoteHost = imessageCfg.remoteHost;
|
||||||
|
if (!remoteHost && cliPath && cliPath !== "imsg") {
|
||||||
|
remoteHost = await detectRemoteHostFromCliPath(cliPath);
|
||||||
|
if (remoteHost) {
|
||||||
|
logVerbose(`imessage: detected remoteHost=${remoteHost} from cliPath`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "imessage" });
|
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "imessage" });
|
||||||
const inboundDebouncer = createInboundDebouncer<{ message: IMessagePayload }>({
|
const inboundDebouncer = createInboundDebouncer<{ message: IMessagePayload }>({
|
||||||
debounceMs: inboundDebounceMs,
|
debounceMs: inboundDebounceMs,
|
||||||
@ -369,6 +440,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
MediaPath: mediaPath,
|
MediaPath: mediaPath,
|
||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
MediaUrl: mediaPath,
|
MediaUrl: mediaPath,
|
||||||
|
MediaRemoteHost: remoteHost,
|
||||||
WasMentioned: effectiveWasMentioned,
|
WasMentioned: effectiveWasMentioned,
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
// Originating channel for reply routing.
|
// Originating channel for reply routing.
|
||||||
|
|||||||
25
src/media/remote.test.ts
Normal file
25
src/media/remote.test.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { formatScpSource, quoteScpPath } from "./remote.js";
|
||||||
|
|
||||||
|
describe("quoteScpPath", () => {
|
||||||
|
it("wraps paths in single quotes", () => {
|
||||||
|
expect(quoteScpPath("/Users/bot/Messages/Attachment One.jpg")).toBe(
|
||||||
|
"'/Users/bot/Messages/Attachment One.jpg'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes single quotes for remote shell", () => {
|
||||||
|
expect(quoteScpPath("/Users/bot/It's/Photo.jpg")).toBe(
|
||||||
|
"'/Users/bot/It'\"'\"'s/Photo.jpg'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatScpSource", () => {
|
||||||
|
it("formats the scp source with quoted path", () => {
|
||||||
|
expect(formatScpSource("user@gateway-host", "/Users/bot/Hello World.jpg")).toBe(
|
||||||
|
"user@gateway-host:'/Users/bot/Hello World.jpg'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
src/media/remote.ts
Normal file
44
src/media/remote.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
const SINGLE_QUOTE_ESCAPE = "'\"'\"'";
|
||||||
|
|
||||||
|
export function quoteScpPath(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, SINGLE_QUOTE_ESCAPE)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatScpSource(remoteHost: string, remotePath: string): string {
|
||||||
|
return `${remoteHost}:${quoteScpPath(remotePath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyRemoteFileViaScp(params: {
|
||||||
|
remoteHost: string;
|
||||||
|
remotePath: string;
|
||||||
|
localPath: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
"/usr/bin/scp",
|
||||||
|
[
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=accept-new",
|
||||||
|
formatScpSource(params.remoteHost, params.remotePath),
|
||||||
|
params.localPath,
|
||||||
|
],
|
||||||
|
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
let stderr = "";
|
||||||
|
child.stderr?.setEncoding("utf8");
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
stderr += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("exit", (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`scp failed (${code}): ${stderr.trim()}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user