feat: add iMessage AppleScript daemon for iPhone-to-agent messaging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-03-27 12:53:59 -07:00
co-authored by Claude Sonnet 4.6
parent f75bd469cc
commit 37ad345b2d
2 changed files with 301 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
"""iMessage daemon — polls chat.db and routes to DeepResearchAgent.
Monitors a designated iMessage conversation for new messages, routes
them to the agent, and sends responses back via AppleScript.
Requires macOS with Full Disk Access for chat.db reading and
Accessibility permission for AppleScript Messages control.
"""
from __future__ import annotations
import logging
import os
import signal
import sqlite3
import subprocess
import time
from pathlib import Path
from typing import Any, Dict, List
logger = logging.getLogger(__name__)
_DEFAULT_DB_PATH = str(
Path.home() / "Library" / "Messages" / "chat.db"
)
_POLL_INTERVAL = 5
_PID_FILE = str(
Path.home() / ".openjarvis" / "imessage-agent.pid"
)
def poll_new_messages(
*,
db_path: str = _DEFAULT_DB_PATH,
last_rowid: int = 0,
chat_identifier: str = "",
) -> List[Dict[str, Any]]:
"""Return new incoming messages since last_rowid."""
try:
conn = sqlite3.connect(
f"file:{db_path}?mode=ro", uri=True
)
conn.row_factory = sqlite3.Row
except sqlite3.OperationalError:
return []
try:
rows = conn.execute(
"SELECT m.ROWID as rowid, m.text, m.date, "
"c.chat_identifier "
"FROM message m "
"JOIN chat_message_join cmj "
"ON cmj.message_id = m.ROWID "
"JOIN chat c ON c.ROWID = cmj.chat_id "
"WHERE m.ROWID > ? AND m.is_from_me = 0 "
"AND m.text IS NOT NULL "
"AND c.chat_identifier = ? "
"ORDER BY m.ROWID ASC",
(last_rowid, chat_identifier),
).fetchall()
return [dict(row) for row in rows]
finally:
conn.close()
def send_imessage(chat_identifier: str, message: str) -> bool:
"""Send an iMessage via AppleScript."""
escaped = message.replace("\\", "\\\\").replace('"', '\\"')
script = (
f'tell application "Messages"\n'
f' set targetChat to a reference to '
f'chat id "{chat_identifier}"\n'
f' send "{escaped}" to targetChat\n'
f"end tell"
)
try:
subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=30,
)
return True
except (subprocess.TimeoutExpired, FileNotFoundError):
logger.error("Failed to send iMessage via AppleScript")
return False
def run_daemon(
*,
chat_identifier: str,
db_path: str = _DEFAULT_DB_PATH,
handler: Any = None,
poll_interval: float = _POLL_INTERVAL,
max_iterations: int = 0,
) -> None:
"""Run the iMessage polling daemon."""
pid_path = Path(_PID_FILE)
pid_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.write_text(str(os.getpid()))
last_rowid = _get_max_rowid(db_path)
logger.info(
"iMessage daemon started — monitoring %s from ROWID %d",
chat_identifier,
last_rowid,
)
running = True
def _stop(signum: int, frame: Any) -> None:
nonlocal running
running = False
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
iterations = 0
while running:
messages = poll_new_messages(
db_path=db_path,
last_rowid=last_rowid,
chat_identifier=chat_identifier,
)
for msg in messages:
last_rowid = msg["rowid"]
text = msg["text"]
logger.info("Received: %s", text[:100])
if handler is not None:
try:
response = handler(text)
if response:
send_imessage(chat_identifier, response)
except Exception:
logger.exception(
"Handler failed for message %d",
msg["rowid"],
)
iterations += 1
if max_iterations and iterations >= max_iterations:
break
time.sleep(poll_interval)
if pid_path.exists():
pid_path.unlink()
logger.info("iMessage daemon stopped")
def _get_max_rowid(db_path: str) -> int:
"""Get the current max ROWID from chat.db."""
try:
conn = sqlite3.connect(
f"file:{db_path}?mode=ro", uri=True
)
row = conn.execute(
"SELECT MAX(ROWID) FROM message"
).fetchone()
conn.close()
return row[0] or 0
except sqlite3.OperationalError:
return 0
def is_running() -> bool:
"""Check if the daemon is currently running."""
pid_path = Path(_PID_FILE)
if not pid_path.exists():
return False
try:
pid = int(pid_path.read_text().strip())
os.kill(pid, 0)
return True
except (ValueError, ProcessLookupError, PermissionError):
pid_path.unlink(missing_ok=True)
return False
def stop_daemon() -> bool:
"""Stop the running daemon. Returns True if stopped."""
pid_path = Path(_PID_FILE)
if not pid_path.exists():
return False
try:
pid = int(pid_path.read_text().strip())
os.kill(pid, signal.SIGTERM)
pid_path.unlink(missing_ok=True)
return True
except (ValueError, ProcessLookupError, PermissionError):
pid_path.unlink(missing_ok=True)
return False
+108
View File
@@ -0,0 +1,108 @@
"""Tests for iMessage AppleScript daemon."""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
def _create_fake_chat_db(db_path: Path) -> None:
conn = sqlite3.connect(str(db_path))
conn.executescript("""
CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT, display_name TEXT
);
CREATE TABLE chat_message_join (
chat_id INTEGER, message_id INTEGER
);
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY, text TEXT,
handle_id INTEGER, date INTEGER, is_from_me INTEGER
);
""")
conn.execute("INSERT INTO handle VALUES (1, '+15551234567')")
conn.execute(
"INSERT INTO chat VALUES (1, '+15551234567', 'Test Chat')"
)
conn.execute("INSERT INTO chat_message_join VALUES (1, 1)")
conn.execute(
"INSERT INTO message VALUES "
"(1, 'Hello agent', 1, 700000000000000000, 0)"
)
conn.commit()
conn.close()
def test_poll_new_messages(tmp_path: Path) -> None:
from openjarvis.channels.imessage_daemon import poll_new_messages
db_path = tmp_path / "chat.db"
_create_fake_chat_db(db_path)
messages = poll_new_messages(
db_path=str(db_path), last_rowid=0,
chat_identifier="+15551234567",
)
assert len(messages) == 1
assert messages[0]["text"] == "Hello agent"
assert messages[0]["rowid"] == 1
def test_poll_skips_old_messages(tmp_path: Path) -> None:
from openjarvis.channels.imessage_daemon import poll_new_messages
db_path = tmp_path / "chat.db"
_create_fake_chat_db(db_path)
messages = poll_new_messages(
db_path=str(db_path), last_rowid=1,
chat_identifier="+15551234567",
)
assert len(messages) == 0
def test_poll_filters_by_chat(tmp_path: Path) -> None:
from openjarvis.channels.imessage_daemon import poll_new_messages
db_path = tmp_path / "chat.db"
_create_fake_chat_db(db_path)
messages = poll_new_messages(
db_path=str(db_path), last_rowid=0,
chat_identifier="+15559999999",
)
assert len(messages) == 0
def test_poll_skips_own_messages(tmp_path: Path) -> None:
from openjarvis.channels.imessage_daemon import poll_new_messages
db_path = tmp_path / "chat.db"
conn = sqlite3.connect(str(db_path))
conn.executescript("""
CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT, display_name TEXT
);
CREATE TABLE chat_message_join (
chat_id INTEGER, message_id INTEGER
);
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY, text TEXT,
handle_id INTEGER, date INTEGER, is_from_me INTEGER
);
""")
conn.execute("INSERT INTO handle VALUES (1, '+15551234567')")
conn.execute(
"INSERT INTO chat VALUES (1, '+15551234567', 'Test')"
)
conn.execute("INSERT INTO chat_message_join VALUES (1, 1)")
conn.execute(
"INSERT INTO message VALUES "
"(1, 'My own msg', 1, 700000000000000000, 1)"
)
conn.commit()
conn.close()
messages = poll_new_messages(
db_path=str(db_path), last_rowid=0,
chat_identifier="+15551234567",
)
assert len(messages) == 0