Merge pull request #1054 from tyler6204/fix/imsg-remote-attachments
iMessage: Add remote attachment support for VM/SSH deployments
This commit is contained in:
@@ -111,7 +111,23 @@ Example wrapper:
|
|||||||
exec ssh -T mac-mini imsg "$@"
|
exec ssh -T mac-mini 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: "clawdbot@192.168.64.3", // 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., `clawdbot@192.168.64.3`). 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).
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
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 { CONFIG_DIR } from "../../utils.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 +31,11 @@ export async function stageSandboxMedia(params: {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
});
|
});
|
||||||
if (!sandbox) return;
|
|
||||||
|
// For remote attachments without sandbox, use ~/.clawdbot/media (not agent workspace for privacy)
|
||||||
|
const remoteMediaCacheDir = ctx.MediaRemoteHost ? path.join(CONFIG_DIR, "media", "remote-cache", sessionKey) : null;
|
||||||
|
const effectiveWorkspaceDir = sandbox?.workspaceDir ?? remoteMediaCacheDir;
|
||||||
|
if (!effectiveWorkspaceDir) return;
|
||||||
|
|
||||||
const resolveAbsolutePath = (value: string): string | null => {
|
const resolveAbsolutePath = (value: string): string | null => {
|
||||||
let resolved = value.trim();
|
let resolved = value.trim();
|
||||||
@@ -46,7 +52,8 @@ export async function stageSandboxMedia(params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const destDir = path.join(sandbox.workspaceDir, "media", "inbound");
|
// For sandbox: <workspace>/media/inbound, for remote cache: use dir directly
|
||||||
|
const destDir = sandbox ? path.join(effectiveWorkspaceDir, "media", "inbound") : effectiveWorkspaceDir;
|
||||||
await fs.mkdir(destDir, { recursive: true });
|
await fs.mkdir(destDir, { recursive: true });
|
||||||
|
|
||||||
const usedNames = new Set<string>();
|
const usedNames = new Set<string>();
|
||||||
@@ -69,9 +76,15 @@ export async function stageSandboxMedia(params: {
|
|||||||
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 scpFile(ctx.MediaRemoteHost, source, dest);
|
||||||
|
} else {
|
||||||
|
await fs.copyFile(source, dest);
|
||||||
|
}
|
||||||
|
// For sandbox use relative path, for remote cache 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 => {
|
||||||
@@ -111,3 +124,32 @@ export async function stageSandboxMedia(params: {
|
|||||||
logVerbose(`Failed to stage inbound media for sandbox: ${String(err)}`);
|
logVerbose(`Failed to stage inbound media for sandbox: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scpFile(remoteHost: string, remotePath: string, localPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
"/usr/bin/scp",
|
||||||
|
[
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=accept-new",
|
||||||
|
`${remoteHost}:${remotePath}`,
|
||||||
|
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()}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export type MsgContext = {
|
|||||||
MediaPaths?: string[];
|
MediaPaths?: string[];
|
||||||
MediaUrls?: string[];
|
MediaUrls?: string[];
|
||||||
MediaTypes?: string[];
|
MediaTypes?: string[];
|
||||||
|
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
||||||
|
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., clawdbot@192.168.64.3). */
|
||||||
|
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,
|
||||||
@@ -53,6 +55,32 @@ 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 {
|
||||||
|
// Expand ~ to home directory
|
||||||
|
const expanded = cliPath.startsWith("~")
|
||||||
|
? cliPath.replace(/^~/, process.env.HOME ?? "")
|
||||||
|
: cliPath;
|
||||||
|
const content = await fs.readFile(expanded, "utf8");
|
||||||
|
|
||||||
|
// Match user@host pattern first (e.g., clawdbot@192.168.64.3)
|
||||||
|
const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/);
|
||||||
|
if (userHostMatch) return userHostMatch[1];
|
||||||
|
|
||||||
|
// Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg)
|
||||||
|
const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/);
|
||||||
|
return hostOnlyMatch?.[1];
|
||||||
|
} 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 +110,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 +406,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.
|
||||||
|
|||||||
Reference in New Issue
Block a user