From ec08c2a55497554f69f6060239ad65f5e9a17072 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 15 Jul 2026 14:03:00 -0700 Subject: [PATCH] slab: guard iMessage recipients and add Tapbacks --- slab/bin/dm-mcp.mjs | 68 ++++++++++++++++++++++++++++++++++----- slab/bin/imsg.mjs | 77 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 135 insertions(+), 10 deletions(-) diff --git a/slab/bin/dm-mcp.mjs b/slab/bin/dm-mcp.mjs index bfdff7d79b..fa70e62213 100644 --- a/slab/bin/dm-mcp.mjs +++ b/slab/bin/dm-mcp.mjs @@ -230,24 +230,61 @@ async function toolSend({ channel, to, text: body, confirm, machine } = {}) { } if (ch === "imessage" || ch === "imsg") { - // `to` selects a named contact / raw handle from imsg.json's contacts map - // (imsg send --to). Omit it to hit the config's default contact. - const toArgs = to ? ["--to", String(to)] : []; + // Never silently fall back to imsg.json's default contact. An explicit + // recipient is required, resolved before preview, and resolved again by + // imsg.mjs at send time. + if (!to) throw new Error("`to` is required for an iMessage send (named contact or raw handle)"); + const toArgs = ["--to", String(to)]; + const { stdout: resolved } = await runBridge(IMSG, ["resolve", ...toArgs], machine, { timeoutMs: 30000 }); + const rcpt = JSON.parse(resolved); if (!confirm) { return text( `PREVIEW — not sent. Re-call with confirm:true to send.\n` + `channel: iMessage machine: ${isLocal(machine) ? "local" : machine}\n` + - `to: ${to ? `contact/handle "${to}" (from imsg.json contacts)` : "the default contact in ~/.config/slab/imsg.json"}\n` + + `to: ${rcpt.displayName} [requested: "${to}"]\n` + `--- message ---\n${body}`, ); } const { stdout } = await runBridge(IMSG, ["send", body, ...toArgs], machine, { timeoutMs: 30000 }); - return text(`✅ iMessage sent${to ? ` to "${to}"` : ""}${stdout.trim() ? `: ${stdout.trim()}` : ""}`); + return text(`✅ iMessage sent to ${rcpt.displayName}${stdout.trim() ? `: ${stdout.trim()}` : ""}`); } throw new Error(`unknown channel "${channel}" (use "signal" or "imessage")`); } +const TAPBACK_LABELS = new Map([ + ["heart", "heart"], ["love", "heart"], + ["thumbs-up", "thumbs up"], ["thumbsup", "thumbs up"], ["like", "thumbs up"], + ["thumbs-down", "thumbs down"], ["thumbsdown", "thumbs down"], ["dislike", "thumbs down"], + ["haha", "Ha Ha"], ["laugh", "Ha Ha"], + ["emphasis", "emphasis"], ["!!", "emphasis"], + ["question", "question mark"], ["?", "question mark"], +]); + +// Classic iMessage Tapback on the selected contact's most recent incoming +// message. Apple exposes this through Messages' Cmd-T, 1–6 shortcuts rather +// than its scripting dictionary, so execution uses guarded UI automation. +async function toolReact({ to, reaction, confirm, machine } = {}) { + if (!to) throw new Error("`to` is required for an iMessage reaction (named contact or raw handle)"); + const requested = String(reaction || "").trim().toLowerCase(); + const label = TAPBACK_LABELS.get(requested); + if (!label) throw new Error("unknown `reaction` — use heart, thumbs-up, thumbs-down, haha, emphasis, or question"); + const toArgs = ["--to", String(to)]; + const { stdout: resolved } = await runBridge(IMSG, ["resolve", ...toArgs], machine, { timeoutMs: 30000 }); + const rcpt = JSON.parse(resolved); + if (!confirm) { + return text( + `PREVIEW — no reaction added. Re-call with confirm:true to react.\n` + + `channel: iMessage machine: ${isLocal(machine) ? "local" : machine}\n` + + `to: ${rcpt.displayName} [requested: "${to}"]\n` + + `target: most recent incoming message\nreaction: ${label}`, + ); + } + const { stdout } = await runBridge(IMSG, ["react", requested, ...toArgs], machine, { timeoutMs: 30000 }); + const result = JSON.parse(stdout); + return text(`✅ added ${result.reaction} Tapback to ${result.displayName}'s most recent incoming message`); +} + const TOOLS = [ { name: "dm_inbox", @@ -315,12 +352,12 @@ const TOOLS = [ }, { name: "dm_send", - description: "Send a DM. TWO-STEP AND SAFE: the first call PREVIEWS the resolved recipient + message and does NOT send; call again with confirm:true to actually send. Signal recipient (`to`) may be an ACI, +E164, or a name (resolved to its ACI). iMessage sends to the handle configured in imsg.json (an arbitrary `to` is ignored, and flagged in the preview).", + description: "Send a DM. TWO-STEP AND SAFE: the first call PREVIEWS the resolved recipient + message and does NOT send; call again with confirm:true to actually send. `to` is required. Signal accepts an ACI, +E164, or name. iMessage accepts a named contact from imsg.json or a raw handle and shows the resolved display name in the preview.", inputSchema: { type: "object", properties: { channel: { type: "string", enum: ["signal", "imessage"], description: "Which channel." }, - to: { type: "string", description: "Signal: ACI / +number / name. iMessage: advisory only." }, + to: { type: "string", description: "Required recipient. Signal: ACI / +number / name. iMessage: named imsg.json contact or raw +number/email." }, text: { type: "string", description: "The message body (multi-line ok)." }, confirm: { type: "boolean", description: "Must be true to actually send. Omit/false = preview only." }, machine: { type: "string", description: "Machine (default local; signal-cli sends route over ssh for remote)." }, @@ -328,6 +365,20 @@ const TOOLS = [ required: ["channel", "text"], }, }, + { + name: "dm_react", + description: "Add a classic iMessage Tapback to a contact's most recent incoming message. TWO-STEP AND SAFE: preview first, then re-call with confirm:true. Requires explicit `to`. Supported reactions: heart, thumbs-up, thumbs-down, haha, emphasis, question.", + inputSchema: { + type: "object", + properties: { + to: { type: "string", description: "Required named imsg.json contact or raw +number/email." }, + reaction: { type: "string", enum: ["heart", "thumbs-up", "thumbs-down", "haha", "emphasis", "question"], description: "Classic Tapback to add." }, + confirm: { type: "boolean", description: "Must be true to actually react. Omit/false = preview only." }, + machine: { type: "string", description: "Machine (default local; Messages UI automation is local-only)." }, + }, + required: ["to", "reaction"], + }, + }, ]; async function callTool(name, args) { @@ -339,6 +390,7 @@ async function callTool(name, args) { case "dm_attachments": return toolAttachments(args || {}); case "dm_contacts": return toolContacts(args || {}); case "dm_send": return toolSend(args || {}); + case "dm_react": return toolReact(args || {}); default: throw new Error(`Unknown tool: ${name}`); } } @@ -380,4 +432,4 @@ async function handleMessage(message) { const port = httpPort(process.argv, 7771); if (port) serveHttp({ handleMessage, port, banner: "✉️ dm-mcp shared daemon" }); -else serveStdio({ handleMessage, banner: "✉️ dm-mcp server started (dm_inbox, dm_chats, dm_read, dm_groups, dm_attachments, dm_contacts, dm_send)" }); +else serveStdio({ handleMessage, banner: "✉️ dm-mcp server started (dm_inbox, dm_chats, dm_read, dm_groups, dm_attachments, dm_contacts, dm_send, dm_react)" }); diff --git a/slab/bin/imsg.mjs b/slab/bin/imsg.mjs index f16cd37cb2..a10a8067f7 100755 --- a/slab/bin/imsg.mjs +++ b/slab/bin/imsg.mjs @@ -15,7 +15,8 @@ // // Subcommands: // imsg status JSON summary to stdout; rings bell on new inbound -// imsg send send to the first configured handle via Messages.app +// imsg send send to an explicitly selected contact via Messages.app +// imsg react classic Tapback on that contact's latest incoming message // imsg tail live terminal client (prints + BEL on new inbound) // imsg open open the Messages.app conversation // imsg config print the resolved config path (and create a stub) @@ -299,6 +300,46 @@ end run`; } } +const TAPBACKS = new Map([ + ["heart", { key: "1", label: "heart" }], + ["love", { key: "1", label: "heart" }], + ["thumbs-up", { key: "2", label: "thumbs up" }], + ["thumbsup", { key: "2", label: "thumbs up" }], + ["like", { key: "2", label: "thumbs up" }], + ["thumbs-down", { key: "3", label: "thumbs down" }], + ["thumbsdown", { key: "3", label: "thumbs down" }], + ["dislike", { key: "3", label: "thumbs down" }], + ["haha", { key: "4", label: "Ha Ha" }], + ["laugh", { key: "4", label: "Ha Ha" }], + ["emphasis", { key: "5", label: "emphasis" }], + ["!!", { key: "5", label: "emphasis" }], + ["question", { key: "6", label: "question mark" }], + ["?", { key: "6", label: "question mark" }], +]); + +function reactToLatest(handles, kind) { + const spec = TAPBACKS.get(String(kind).trim().toLowerCase()); + if (!spec) { + throw new Error("unknown Tapback — use heart, thumbs-up, thumbs-down, haha, emphasis, or question"); + } + const to = String(handles[0]); + const opened = spawnSync("/usr/bin/open", [`imessage://${to}`], { encoding: "utf8" }); + if (opened.status !== 0) throw new Error((opened.stderr || "could not open Messages conversation").trim()); + const script = ` +tell application "Messages" to activate +delay 1 +tell application "System Events" + tell process "Messages" + keystroke "t" using command down + delay 0.25 + keystroke "${spec.key}" + end tell +end tell`; + const reacted = spawnSync("/usr/bin/osascript", ["-e", script], { encoding: "utf8" }); + if (reacted.status !== 0) throw new Error((reacted.stderr || "Tapback failed").trim()); + return spec.label; +} + // ─── command: status ───────────────────────────────────────────────────── function cmdStatus() { @@ -458,6 +499,18 @@ try { case "status": cmdStatus(); break; + case "resolve": { + const cfg = loadConfig(); + if (!cfg) { + console.error(`No contact configured. Edit ${CONFIG_PATH}`); + process.exit(1); + } + const ti = rest.indexOf("--to"); + const toArg = ti >= 0 ? rest[ti + 1] : rest[0]; + const rcpt = resolveRecipient(cfg, toArg); + print({ displayName: rcpt.displayName }); + break; + } case "send": { const cfg = loadConfig(); if (!cfg) { @@ -478,6 +531,26 @@ try { sendMessage(rcpt.handles, body); break; } + case "react": { + const cfg = loadConfig(); + if (!cfg) { + console.error(`No contact configured. Edit ${CONFIG_PATH}`); + process.exit(1); + } + const args = [...rest]; + let toArg = null; + const ti = args.indexOf("--to"); + if (ti >= 0) { toArg = args[ti + 1]; args.splice(ti, 2); } + if (!toArg) { + console.error("usage: imsg react --to "); + process.exit(1); + } + const kind = args.join(" ").trim(); + const rcpt = resolveRecipient(cfg, toArg); + const reaction = reactToLatest(rcpt.handles, kind); + print({ displayName: rcpt.displayName, reaction }); + break; + } case "tail": await cmdTail(); break; @@ -493,7 +566,7 @@ try { break; default: console.error( - "usage: imsg status|send |tail|open|config", + "usage: imsg status|resolve [--to] |send [--to ]|react --to |tail|open|config", ); process.exit(1); } -- 2.51.2