diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0583114..294c64aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ #### Messaging / Channels - Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose. +- iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935) - Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr. - Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4. - Discord: isolate autoThread thread context. (#856) — thanks @davidguttman. diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index f51f90f31..08741fac9 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -53,4 +53,38 @@ describe("buildThreadingToolContext", () => { expect(result.currentChannelId).toBe("chat:99"); }); + + it("uses the sender handle for iMessage direct chats", () => { + const sessionCtx = { + Provider: "imessage", + ChatType: "direct", + From: "imessage:+15550001", + To: "chat_id:12", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("imessage:+15550001"); + }); + + it("uses chat_id for iMessage groups", () => { + const sessionCtx = { + Provider: "imessage", + ChatType: "group", + From: "group:7", + To: "chat_id:7", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: cfg, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("chat_id:7"); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 5f5d1ca18..4f955b8aa 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -26,7 +26,12 @@ export function buildThreadingToolContext(params: { const dock = getChannelDock(provider); if (!dock?.threading?.buildToolContext) return {}; // WhatsApp context isolation keys off conversation id, not the bot's own number. - const threadingTo = provider === "whatsapp" ? (sessionCtx.From ?? sessionCtx.To) : sessionCtx.To; + const threadingTo = + provider === "whatsapp" + ? (sessionCtx.From ?? sessionCtx.To) + : provider === "imessage" && sessionCtx.ChatType === "direct" + ? (sessionCtx.From ?? sessionCtx.To) + : sessionCtx.To; return ( dock.threading.buildToolContext({ cfg: config, diff --git a/src/imessage/client.ts b/src/imessage/client.ts index 94b391dab..52d255ad8 100644 --- a/src/imessage/client.ts +++ b/src/imessage/client.ts @@ -180,7 +180,17 @@ export class IMessageRpcClient { this.pending.delete(key); if (parsed.error) { - const msg = parsed.error.message ?? "imsg rpc error"; + const baseMessage = parsed.error.message ?? "imsg rpc error"; + const details = parsed.error.data; + const code = parsed.error.code; + const suffixes = [] as string[]; + if (typeof code === "number") suffixes.push(`code=${code}`); + if (details !== undefined) { + const detailText = + typeof details === "string" ? details : JSON.stringify(details, null, 2); + if (detailText) suffixes.push(detailText); + } + const msg = suffixes.length > 0 ? `${baseMessage}: ${suffixes.join(" ")}` : baseMessage; pending.reject(new Error(msg)); return; } diff --git a/src/imessage/monitor.updates-last-route-chat-id-direct-messages.test.ts b/src/imessage/monitor.updates-last-route-chat-id-direct-messages.test.ts index c5d2bf929..49e739f1f 100644 --- a/src/imessage/monitor.updates-last-route-chat-id-direct-messages.test.ts +++ b/src/imessage/monitor.updates-last-route-chat-id-direct-messages.test.ts @@ -92,7 +92,7 @@ beforeEach(() => { }); describe("monitorIMessageProvider", () => { - it("updates last route with chat_id for direct messages", async () => { + it("updates last route with sender handle for direct messages", async () => { replyMock.mockResolvedValueOnce({ text: "ok" }); const run = monitorIMessageProvider(); await waitForSubscribe(); @@ -118,7 +118,7 @@ describe("monitorIMessageProvider", () => { expect(updateLastRouteMock).toHaveBeenCalledWith( expect.objectContaining({ channel: "imessage", - to: "chat_id:7", + to: "+15550004444", }), ); }); diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index 1d86ec993..be98db0a4 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -296,7 +296,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P }); } - const imessageTo = chatTarget || `imessage:${sender}`; + const imessageTo = (isGroup ? chatTarget : undefined) || `imessage:${sender}`; const ctxPayload = { Body: combinedBody, RawBody: bodyText, @@ -329,7 +329,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P const storePath = resolveStorePath(sessionCfg?.store, { agentId: route.agentId, }); - const to = chatTarget || sender; + const to = (isGroup ? chatTarget : undefined) || sender; if (to) { await updateLastRoute({ storePath, diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 58954e205..c3b027107 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -100,4 +100,36 @@ describe("runMessageAction context isolation", () => { }), ).rejects.toThrow(/Cross-context messaging denied/); }); + + it("allows iMessage send when target matches current handle", async () => { + const result = await runMessageAction({ + cfg: whatsappConfig, + action: "send", + params: { + channel: "imessage", + to: "imessage:+15551234567", + message: "hi", + }, + toolContext: { currentChannelId: "imessage:+15551234567" }, + dryRun: true, + }); + + expect(result.kind).toBe("send"); + }); + + it("blocks iMessage send when target differs from current handle", async () => { + await expect( + runMessageAction({ + cfg: whatsappConfig, + action: "send", + params: { + channel: "imessage", + to: "imessage:+15551230000", + message: "hi", + }, + toolContext: { currentChannelId: "imessage:+15551234567" }, + dryRun: true, + }), + ).rejects.toThrow(/Cross-context messaging denied/); + }); });