Merge pull request #535 from mdahmann/fix/imessage-groupish-threads
imessage: isolate group-ish threads by chat_id
This commit is contained in:
@@ -13,6 +13,7 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
|
||||
- iMessage provider backed by `imsg` on macOS.
|
||||
- Deterministic routing: replies always go back to iMessage.
|
||||
- DMs share the agent's main session; groups are isolated (`imessage:group:<chat_id>`).
|
||||
- If a multi-participant thread arrives with `is_group=false`, you can still isolate it by `chat_id` using `imessage.groups` (see “Group-ish threads” below).
|
||||
|
||||
## Requirements
|
||||
- macOS with Messages signed in.
|
||||
@@ -24,35 +25,61 @@ Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio
|
||||
1) Ensure Messages is signed in on this Mac.
|
||||
2) Configure iMessage and start the gateway.
|
||||
|
||||
### Remote/SSH variant (optional)
|
||||
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that
|
||||
execs `ssh` and runs `imsg rpc` on the remote host. Clawdbot only needs a
|
||||
stdio stream; `imsg` still runs on the remote macOS host.
|
||||
### Dedicated bot macOS user (for isolated identity)
|
||||
If you want the bot to send from a **separate iMessage identity** (and keep your personal Messages clean), use a dedicated Apple ID + a dedicated macOS user.
|
||||
|
||||
Example wrapper (save somewhere in your PATH and `chmod +x`):
|
||||
1) Create a dedicated Apple ID (example: `my-cool-bot@icloud.com`).
|
||||
- Apple may require a phone number for verification / 2FA.
|
||||
2) Create a macOS user (example: `clawdshome`) and sign into it.
|
||||
3) Open Messages in that macOS user and sign into iMessage using the bot Apple ID.
|
||||
4) Enable Remote Login (System Settings → General → Sharing → Remote Login).
|
||||
5) Install `imsg`:
|
||||
- `brew install steipete/tap/imsg`
|
||||
6) Set up SSH so `ssh <bot-macos-user>@localhost true` works without a password.
|
||||
7) Point `imessage.accounts.bot.cliPath` at an SSH wrapper that runs `imsg` as the bot user.
|
||||
|
||||
First-run note: sending/receiving may require GUI approvals (Automation + Full Disk Access) in the *bot macOS user*. If `imsg rpc` looks stuck or exits, log into that user (Screen Sharing helps), run a one-time `imsg chats --limit 1` / `imsg send ...`, approve prompts, then retry.
|
||||
|
||||
Example wrapper (`chmod +x`). Replace `<bot-macos-user>` with your actual macOS username:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run an interactive SSH once first to accept host keys:
|
||||
# ssh <bot-macos-user>@localhost true
|
||||
exec /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=5 -T <bot-macos-user>@localhost \
|
||||
"/usr/local/bin/imsg" "$@"
|
||||
```
|
||||
|
||||
Example config:
|
||||
```json5
|
||||
{
|
||||
imessage: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
bot: {
|
||||
name: "Bot",
|
||||
enabled: true,
|
||||
cliPath: "/path/to/imsg-bot",
|
||||
dbPath: "/Users/<bot-macos-user>/Library/Messages/chat.db"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For single-account setups, use flat options (`imessage.cliPath`, `imessage.dbPath`) instead of the `accounts` map.
|
||||
|
||||
### Remote/SSH variant (optional)
|
||||
If you want iMessage on another Mac, set `imessage.cliPath` to a wrapper that runs `imsg` on the remote macOS host over SSH. Clawdbot only needs stdio.
|
||||
|
||||
Example wrapper:
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
exec ssh -T mac-mini imsg "$@"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Remote Mac must have Messages signed in and `imsg` installed.
|
||||
- Full Disk Access + Automation prompts happen on the remote Mac.
|
||||
- Use SSH keys (no password prompt) so the gateway can launch `imsg rpc` unattended.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
imessage: {
|
||||
enabled: true,
|
||||
cliPath: "/usr/local/bin/imessage-remote",
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["+15555550123"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Multi-account support: use `imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
Multi-account support: use `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)
|
||||
DMs:
|
||||
@@ -73,6 +100,27 @@ Groups:
|
||||
- `imsg` streams message events; the gateway normalizes them into the shared provider envelope.
|
||||
- Replies always route back to the same chat id or handle.
|
||||
|
||||
## Group-ish threads (`is_group=false`)
|
||||
Some iMessage threads can have multiple participants but still arrive with `is_group=false` depending on how Messages stores the chat identifier.
|
||||
|
||||
If you explicitly configure a `chat_id` under `imessage.groups`, Clawdbot treats that thread as a “group” for:
|
||||
- session isolation (separate `imessage:group:<chat_id>` session key)
|
||||
- group allowlisting / mention gating behavior
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
imessage: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["+15555550123"],
|
||||
groups: {
|
||||
"42": { "requireMention": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
This is useful when you want an isolated personality/model for a specific thread (see [Multi-agent routing](/concepts/multi-agent)). For filesystem isolation, see [Sandboxing](/gateway/sandboxing).
|
||||
|
||||
## Media + limits
|
||||
- Optional attachment ingestion via `imessage.includeAttachments`.
|
||||
- Media cap via `imessage.mediaMaxMb`.
|
||||
|
||||
@@ -215,6 +215,46 @@ describe("monitorIMessageProvider", () => {
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats configured chat_id as a group session even when is_group is false", async () => {
|
||||
config = {
|
||||
...config,
|
||||
imessage: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groups: { "2": { requireMention: false } },
|
||||
},
|
||||
};
|
||||
|
||||
const run = monitorIMessageProvider();
|
||||
await waitForSubscribe();
|
||||
|
||||
notificationHandler?.({
|
||||
method: "message",
|
||||
params: {
|
||||
message: {
|
||||
id: 14,
|
||||
chat_id: 2,
|
||||
sender: "+15550001111",
|
||||
is_from_me: false,
|
||||
text: "hello",
|
||||
is_group: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flush();
|
||||
closeResolve?.();
|
||||
await run;
|
||||
|
||||
expect(replyMock).toHaveBeenCalled();
|
||||
const ctx = replyMock.mock.calls[0]?.[0] as {
|
||||
ChatType?: string;
|
||||
SessionKey?: string;
|
||||
};
|
||||
expect(ctx.ChatType).toBe("group");
|
||||
expect(ctx.SessionKey).toBe("agent:main:imessage:group:2");
|
||||
});
|
||||
|
||||
it("prefixes tool and final replies with responsePrefix", async () => {
|
||||
config = {
|
||||
...config,
|
||||
|
||||
@@ -170,10 +170,36 @@ export async function monitorIMessageProvider(
|
||||
const chatId = message.chat_id ?? undefined;
|
||||
const chatGuid = message.chat_guid ?? undefined;
|
||||
const chatIdentifier = message.chat_identifier ?? undefined;
|
||||
const isGroup = Boolean(message.is_group);
|
||||
|
||||
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
|
||||
const groupListPolicy = groupIdCandidate
|
||||
? resolveProviderGroupPolicy({
|
||||
cfg,
|
||||
provider: "imessage",
|
||||
accountId: accountInfo.accountId,
|
||||
groupId: groupIdCandidate,
|
||||
})
|
||||
: {
|
||||
allowlistEnabled: false,
|
||||
allowed: true,
|
||||
groupConfig: undefined,
|
||||
defaultConfig: undefined,
|
||||
};
|
||||
|
||||
// Some iMessage threads can have multiple participants but still report
|
||||
// is_group=false depending on how Messages stores the identifier.
|
||||
// If the owner explicitly configures a chat_id under imessage.groups, treat
|
||||
// that thread as a "group" for permission gating and session isolation.
|
||||
const treatAsGroupByConfig = Boolean(
|
||||
groupIdCandidate &&
|
||||
groupListPolicy.allowlistEnabled &&
|
||||
groupListPolicy.groupConfig,
|
||||
);
|
||||
|
||||
const isGroup = Boolean(message.is_group) || treatAsGroupByConfig;
|
||||
if (isGroup && !chatId) return;
|
||||
|
||||
const groupId = isGroup ? String(chatId) : undefined;
|
||||
const groupId = isGroup ? groupIdCandidate : undefined;
|
||||
const storeAllowFrom = await readProviderAllowFromStore("imessage").catch(
|
||||
() => [],
|
||||
);
|
||||
@@ -214,12 +240,6 @@ export async function monitorIMessageProvider(
|
||||
return;
|
||||
}
|
||||
}
|
||||
const groupListPolicy = resolveProviderGroupPolicy({
|
||||
cfg,
|
||||
provider: "imessage",
|
||||
accountId: accountInfo.accountId,
|
||||
groupId,
|
||||
});
|
||||
if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
|
||||
logVerbose(
|
||||
`imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`,
|
||||
|
||||
Reference in New Issue
Block a user