wingman-ai 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. share/wingman/node_listener/package-lock.json +1785 -0
  2. share/wingman/node_listener/package.json +50 -0
  3. share/wingman/node_listener/src/index.ts +108 -0
  4. share/wingman/node_listener/src/ipc.ts +70 -0
  5. share/wingman/node_listener/src/messageHandler.ts +135 -0
  6. share/wingman/node_listener/src/socket.ts +244 -0
  7. share/wingman/node_listener/src/types.d.ts +13 -0
  8. share/wingman/node_listener/tsconfig.json +19 -0
  9. wingman/__init__.py +4 -0
  10. wingman/__main__.py +6 -0
  11. wingman/cli/__init__.py +5 -0
  12. wingman/cli/commands/__init__.py +1 -0
  13. wingman/cli/commands/auth.py +90 -0
  14. wingman/cli/commands/config.py +109 -0
  15. wingman/cli/commands/init.py +71 -0
  16. wingman/cli/commands/logs.py +84 -0
  17. wingman/cli/commands/start.py +111 -0
  18. wingman/cli/commands/status.py +84 -0
  19. wingman/cli/commands/stop.py +33 -0
  20. wingman/cli/commands/uninstall.py +113 -0
  21. wingman/cli/main.py +50 -0
  22. wingman/cli/wizard.py +356 -0
  23. wingman/config/__init__.py +31 -0
  24. wingman/config/paths.py +153 -0
  25. wingman/config/personality.py +155 -0
  26. wingman/config/registry.py +343 -0
  27. wingman/config/settings.py +294 -0
  28. wingman/core/__init__.py +16 -0
  29. wingman/core/agent.py +257 -0
  30. wingman/core/ipc_handler.py +124 -0
  31. wingman/core/llm/__init__.py +5 -0
  32. wingman/core/llm/client.py +77 -0
  33. wingman/core/memory/__init__.py +6 -0
  34. wingman/core/memory/context.py +109 -0
  35. wingman/core/memory/models.py +213 -0
  36. wingman/core/message_processor.py +277 -0
  37. wingman/core/policy/__init__.py +5 -0
  38. wingman/core/policy/evaluator.py +265 -0
  39. wingman/core/process_manager.py +135 -0
  40. wingman/core/safety/__init__.py +8 -0
  41. wingman/core/safety/cooldown.py +63 -0
  42. wingman/core/safety/quiet_hours.py +75 -0
  43. wingman/core/safety/rate_limiter.py +58 -0
  44. wingman/core/safety/triggers.py +117 -0
  45. wingman/core/transports/__init__.py +14 -0
  46. wingman/core/transports/base.py +106 -0
  47. wingman/core/transports/imessage/__init__.py +5 -0
  48. wingman/core/transports/imessage/db_listener.py +280 -0
  49. wingman/core/transports/imessage/sender.py +162 -0
  50. wingman/core/transports/imessage/transport.py +140 -0
  51. wingman/core/transports/whatsapp.py +180 -0
  52. wingman/daemon/__init__.py +5 -0
  53. wingman/daemon/manager.py +303 -0
  54. wingman/installer/__init__.py +5 -0
  55. wingman/installer/node_installer.py +253 -0
  56. wingman_ai-1.0.0.dist-info/METADATA +553 -0
  57. wingman_ai-1.0.0.dist-info/RECORD +60 -0
  58. wingman_ai-1.0.0.dist-info/WHEEL +4 -0
  59. wingman_ai-1.0.0.dist-info/entry_points.txt +2 -0
  60. wingman_ai-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,117 @@
1
+ """Trigger word and mention detection."""
2
+
3
+ import logging
4
+ import re
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class TriggerDetector:
10
+ """
11
+ Detects trigger words, mentions, and other conditions that should
12
+ cause the bot to respond.
13
+ """
14
+
15
+ def __init__(self, bot_name: str = "Maximus", additional_triggers: list[str] | None = None):
16
+ self.bot_name = bot_name.lower()
17
+ self.triggers: set[str] = {self.bot_name}
18
+
19
+ # Add common variations
20
+ self.triggers.add(f"@{self.bot_name}")
21
+
22
+ # Add additional triggers
23
+ if additional_triggers:
24
+ for trigger in additional_triggers:
25
+ self.triggers.add(trigger.lower())
26
+
27
+ # Compile regex patterns for efficient matching
28
+ self._compile_patterns()
29
+
30
+ def _compile_patterns(self) -> None:
31
+ """Compile regex patterns for trigger matching."""
32
+ # Pattern for @mentions (handles WhatsApp mention format)
33
+ escaped_triggers = [re.escape(t) for t in self.triggers]
34
+ pattern = r"\b(" + "|".join(escaped_triggers) + r")\b"
35
+ self._trigger_pattern = re.compile(pattern, re.IGNORECASE)
36
+
37
+ def add_trigger(self, trigger: str) -> None:
38
+ """Add a new trigger word."""
39
+ self.triggers.add(trigger.lower())
40
+ self._compile_patterns()
41
+ logger.debug(f"Added trigger: {trigger}")
42
+
43
+ def remove_trigger(self, trigger: str) -> None:
44
+ """Remove a trigger word."""
45
+ self.triggers.discard(trigger.lower())
46
+ self._compile_patterns()
47
+ logger.debug(f"Removed trigger: {trigger}")
48
+
49
+ def has_trigger(self, text: str) -> bool:
50
+ """
51
+ Check if the text contains any trigger words.
52
+
53
+ Args:
54
+ text: Message text to check
55
+
56
+ Returns:
57
+ True if a trigger is found
58
+ """
59
+ if not text:
60
+ return False
61
+
62
+ match = self._trigger_pattern.search(text)
63
+ if match:
64
+ logger.debug(f"Trigger found: '{match.group()}'")
65
+ return True
66
+ return False
67
+
68
+ def is_direct_mention(self, text: str) -> bool:
69
+ """
70
+ Check if the message starts with a mention of the bot.
71
+ This indicates a direct address.
72
+ """
73
+ if not text:
74
+ return False
75
+
76
+ text_lower = text.lower().strip()
77
+
78
+ for trigger in self.triggers:
79
+ if text_lower.startswith(trigger):
80
+ return True
81
+ # Check for @mention at start
82
+ if text_lower.startswith(f"@{trigger}"):
83
+ return True
84
+
85
+ return False
86
+
87
+ def should_respond(
88
+ self, text: str, is_group: bool, is_dm: bool = False, is_reply_to_bot: bool = False
89
+ ) -> tuple[bool, str]:
90
+ """
91
+ Determine if the bot should respond to this message.
92
+
93
+ Args:
94
+ text: Message text
95
+ is_group: Whether this is a group chat
96
+ is_dm: Whether this is a direct message
97
+ is_reply_to_bot: Whether this is a reply to the bot's message
98
+
99
+ Returns:
100
+ Tuple of (should_respond, reason)
101
+ """
102
+ # Always respond to DMs
103
+ if is_dm:
104
+ return True, "direct_message"
105
+
106
+ # Always respond if replying to bot's message
107
+ if is_reply_to_bot:
108
+ return True, "reply_to_bot"
109
+
110
+ # In groups, only respond to triggers
111
+ if is_group:
112
+ if self.has_trigger(text):
113
+ return True, "trigger_word"
114
+ return False, "no_trigger"
115
+
116
+ # Default: don't respond
117
+ return False, "no_match"
@@ -0,0 +1,14 @@
1
+ """Transport abstraction layer for multi-platform messaging."""
2
+
3
+ from .base import BaseTransport, MessageEvent, MessageHandler, Platform
4
+ from .imessage import IMessageTransport
5
+ from .whatsapp import WhatsAppTransport
6
+
7
+ __all__ = [
8
+ "Platform",
9
+ "MessageEvent",
10
+ "BaseTransport",
11
+ "MessageHandler",
12
+ "WhatsAppTransport",
13
+ "IMessageTransport",
14
+ ]
@@ -0,0 +1,106 @@
1
+ """Base transport abstraction for multi-platform messaging."""
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Callable, Coroutine
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Platform(Enum):
14
+ """Supported messaging platforms."""
15
+
16
+ WHATSAPP = "whatsapp"
17
+ IMESSAGE = "imessage"
18
+
19
+
20
+ @dataclass
21
+ class MessageEvent:
22
+ """Unified message structure across all platforms."""
23
+
24
+ # Core message data
25
+ chat_id: str
26
+ sender_id: str
27
+ text: str
28
+ timestamp: float
29
+
30
+ # Platform info
31
+ platform: Platform
32
+
33
+ # Sender info
34
+ sender_name: str | None = None
35
+
36
+ # Message type flags
37
+ is_group: bool = False
38
+ is_self: bool = False
39
+
40
+ # Platform-specific data
41
+ raw_data: dict = field(default_factory=dict)
42
+
43
+ # Reply context (if replying to a message)
44
+ quoted_message: dict | None = None
45
+
46
+
47
+ # Type alias for message handler callback
48
+ MessageHandler = Callable[[MessageEvent], Coroutine[Any, Any, None]]
49
+
50
+
51
+ class BaseTransport(ABC):
52
+ """Abstract base class for message transports."""
53
+
54
+ def __init__(self):
55
+ self._message_handler: MessageHandler | None = None
56
+ self._running = False
57
+
58
+ @property
59
+ @abstractmethod
60
+ def platform(self) -> Platform:
61
+ """Return the platform this transport handles."""
62
+ pass
63
+
64
+ def set_message_handler(self, handler: MessageHandler) -> None:
65
+ """Set the callback for incoming messages."""
66
+ self._message_handler = handler
67
+ logger.debug(f"{self.platform.value}: Message handler registered")
68
+
69
+ async def _dispatch_message(self, event: MessageEvent) -> None:
70
+ """Dispatch a message event to the registered handler."""
71
+ if self._message_handler:
72
+ try:
73
+ await self._message_handler(event)
74
+ except Exception as e:
75
+ logger.error(f"{self.platform.value}: Error in message handler: {e}")
76
+ else:
77
+ logger.warning(f"{self.platform.value}: No message handler registered")
78
+
79
+ @abstractmethod
80
+ async def start(self) -> None:
81
+ """Start the transport and begin listening for messages."""
82
+ pass
83
+
84
+ @abstractmethod
85
+ async def stop(self) -> None:
86
+ """Stop the transport gracefully."""
87
+ pass
88
+
89
+ @abstractmethod
90
+ async def send_message(self, chat_id: str, text: str) -> bool:
91
+ """
92
+ Send a message to the specified chat.
93
+
94
+ Args:
95
+ chat_id: The chat/conversation identifier
96
+ text: The message text to send
97
+
98
+ Returns:
99
+ True if send was successful, False otherwise
100
+ """
101
+ pass
102
+
103
+ @property
104
+ def is_running(self) -> bool:
105
+ """Check if the transport is currently running."""
106
+ return self._running
@@ -0,0 +1,5 @@
1
+ """iMessage transport implementation."""
2
+
3
+ from .transport import IMessageTransport
4
+
5
+ __all__ = ["IMessageTransport"]
@@ -0,0 +1,280 @@
1
+ """iMessage database listener for detecting new messages."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import sqlite3
6
+ import time
7
+ from collections.abc import Callable, Coroutine
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Apple epoch: seconds between Unix epoch (1970) and Apple epoch (2001)
14
+ APPLE_EPOCH_OFFSET = 978307200
15
+
16
+
17
+ @dataclass
18
+ class IMessageData:
19
+ """Raw iMessage data from chat.db."""
20
+
21
+ rowid: int
22
+ text: str
23
+ handle_id: str # Phone number or email
24
+ chat_id: str # Chat identifier
25
+ chat_name: str | None # Group name if available
26
+ timestamp: float # Unix timestamp
27
+ is_from_me: bool
28
+ is_group: bool
29
+
30
+
31
+ class IMessageDBListener:
32
+ """
33
+ Polls the iMessage chat.db database for new messages.
34
+
35
+ Note: Requires Full Disk Access permission for the Python process.
36
+ """
37
+
38
+ # Default chat.db location
39
+ DEFAULT_DB_PATH = Path.home() / "Library" / "Messages" / "chat.db"
40
+
41
+ def __init__(
42
+ self,
43
+ db_path: Path | None = None,
44
+ poll_interval: float = 2.0,
45
+ ):
46
+ self._db_path = db_path or self.DEFAULT_DB_PATH
47
+ self._poll_interval = poll_interval
48
+ self._last_rowid = 0
49
+ self._running = False
50
+ self._message_callback: Callable[[IMessageData], Coroutine] | None = None
51
+
52
+ def set_message_callback(self, callback: Callable[[IMessageData], Coroutine]) -> None:
53
+ """Set the callback for new messages."""
54
+ self._message_callback = callback
55
+
56
+ async def start(self) -> None:
57
+ """Start polling for new messages."""
58
+ if not self._db_path.exists():
59
+ raise FileNotFoundError(
60
+ f"iMessage database not found: {self._db_path}\n"
61
+ "Ensure Messages.app has been used and Full Disk Access is granted."
62
+ )
63
+
64
+ # Get the current max ROWID to start from
65
+ self._last_rowid = self._get_max_rowid()
66
+ logger.info(f"iMessage listener starting from ROWID {self._last_rowid}")
67
+
68
+ self._running = True
69
+ await self._poll_loop()
70
+
71
+ async def stop(self) -> None:
72
+ """Stop polling."""
73
+ self._running = False
74
+ logger.info("iMessage listener stopped")
75
+
76
+ async def _poll_loop(self) -> None:
77
+ """Main polling loop."""
78
+ while self._running:
79
+ try:
80
+ messages = self._fetch_new_messages()
81
+ for msg in messages:
82
+ if self._message_callback:
83
+ await self._message_callback(msg)
84
+ self._last_rowid = max(self._last_rowid, msg.rowid)
85
+ except sqlite3.OperationalError as e:
86
+ if "database is locked" in str(e):
87
+ logger.debug("Database locked, will retry")
88
+ else:
89
+ logger.error(f"Database error: {e}")
90
+ except Exception as e:
91
+ logger.error(f"Error polling iMessage database: {e}")
92
+
93
+ await asyncio.sleep(self._poll_interval)
94
+
95
+ def _get_connection(self) -> sqlite3.Connection:
96
+ """Get a read-only connection to chat.db."""
97
+ # Use URI for read-only access
98
+ conn = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True, timeout=5.0)
99
+ conn.row_factory = sqlite3.Row
100
+ return conn
101
+
102
+ def _get_max_rowid(self) -> int:
103
+ """Get the current maximum ROWID in the messages table."""
104
+ try:
105
+ with self._get_connection() as conn:
106
+ cursor = conn.execute("SELECT MAX(ROWID) FROM message")
107
+ result = cursor.fetchone()
108
+ return result[0] or 0
109
+ except Exception as e:
110
+ logger.error(f"Failed to get max ROWID: {e}")
111
+ return 0
112
+
113
+ def _fetch_new_messages(self) -> list[IMessageData]:
114
+ """Fetch messages newer than last_rowid."""
115
+ messages = []
116
+
117
+ query = """
118
+ SELECT
119
+ m.ROWID,
120
+ m.text,
121
+ m.attributedBody,
122
+ m.handle_id,
123
+ m.date,
124
+ m.is_from_me,
125
+ h.id as handle_identifier,
126
+ c.chat_identifier,
127
+ c.display_name as chat_name,
128
+ c.style as chat_style
129
+ FROM message m
130
+ LEFT JOIN handle h ON m.handle_id = h.ROWID
131
+ LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
132
+ LEFT JOIN chat c ON cmj.chat_id = c.ROWID
133
+ WHERE m.ROWID > ?
134
+ ORDER BY m.ROWID ASC
135
+ LIMIT 50
136
+ """
137
+
138
+ try:
139
+ with self._get_connection() as conn:
140
+ cursor = conn.execute(query, (self._last_rowid,))
141
+ rows = cursor.fetchall()
142
+
143
+ for row in rows:
144
+ # Extract text (handle Ventura+ attributedBody)
145
+ text = self._extract_text(row)
146
+ if not text:
147
+ continue # Skip messages without text
148
+
149
+ # Convert Apple timestamp to Unix
150
+ apple_date = row["date"]
151
+ if apple_date:
152
+ # Apple stores nanoseconds since 2001-01-01
153
+ unix_timestamp = (apple_date / 1e9) + APPLE_EPOCH_OFFSET
154
+ else:
155
+ unix_timestamp = time.time()
156
+
157
+ # Determine if group chat (style > 0 means group)
158
+ is_group = (row["chat_style"] or 0) > 43
159
+
160
+ msg = IMessageData(
161
+ rowid=row["ROWID"],
162
+ text=text,
163
+ handle_id=row["handle_identifier"] or "",
164
+ chat_id=row["chat_identifier"] or "",
165
+ chat_name=row["chat_name"],
166
+ timestamp=unix_timestamp,
167
+ is_from_me=bool(row["is_from_me"]),
168
+ is_group=is_group,
169
+ )
170
+ messages.append(msg)
171
+
172
+ except Exception as e:
173
+ logger.error(f"Failed to fetch new messages: {e}")
174
+
175
+ return messages
176
+
177
+ def _extract_text(self, row: sqlite3.Row) -> str | None:
178
+ """
179
+ Extract message text from a database row.
180
+
181
+ Handles both:
182
+ - Plain text in 'text' column (older macOS)
183
+ - attributedBody blob (Ventura+)
184
+ """
185
+ # Try plain text first
186
+ text = row["text"]
187
+ if text:
188
+ return text.strip()
189
+
190
+ # Try attributedBody (Ventura+ stores text as NSAttributedString blob)
191
+ attributed_body = row["attributedBody"]
192
+ if attributed_body:
193
+ try:
194
+ return self._parse_attributed_body(attributed_body)
195
+ except Exception as e:
196
+ logger.debug(f"Failed to parse attributedBody: {e}")
197
+
198
+ return None
199
+
200
+ def _parse_attributed_body(self, blob: bytes) -> str | None:
201
+ """
202
+ Parse NSAttributedString blob from attributedBody column.
203
+
204
+ The blob contains a serialized NSAttributedString. The actual text
205
+ is stored after a specific marker pattern.
206
+ """
207
+ try:
208
+ # Look for the text content within the blob
209
+ # The structure varies but text is typically after 'NSString' marker
210
+ decoded = blob.decode("utf-8", errors="ignore")
211
+
212
+ # Find the actual message text
213
+ # Look for pattern: text follows certain markers
214
+ markers = [
215
+ "NSString",
216
+ "NSMutableString",
217
+ ]
218
+
219
+ for marker in markers:
220
+ if marker in decoded:
221
+ # Text typically follows the marker with some length info
222
+ idx = decoded.find(marker)
223
+ # Skip past marker and find readable text
224
+ remaining = decoded[idx + len(marker) :]
225
+ # Extract printable characters
226
+ text = "".join(c for c in remaining if c.isprintable() or c.isspace())
227
+ text = text.strip()
228
+ if text and len(text) > 1:
229
+ # Clean up any trailing garbage
230
+ # Text usually ends at first control sequence
231
+ for end_marker in ["\x00", "\x01", "\x02"]:
232
+ if end_marker in text:
233
+ text = text.split(end_marker)[0]
234
+ return text.strip() if text.strip() else None
235
+
236
+ # Alternative: try to find text between known delimiters
237
+ # streamtyped data format
238
+ if b"streamtyped" in blob:
239
+ # Find text after the plist-like structure
240
+ try:
241
+ # Look for readable text sequences
242
+ text_parts = []
243
+ current = []
244
+ for byte in blob:
245
+ if 32 <= byte <= 126: # Printable ASCII
246
+ current.append(chr(byte))
247
+ else:
248
+ if len(current) > 3: # Minimum word length
249
+ text_parts.append("".join(current))
250
+ current = []
251
+ if current and len(current) > 3:
252
+ text_parts.append("".join(current))
253
+
254
+ # Filter out known non-text strings
255
+ filtered = [
256
+ p
257
+ for p in text_parts
258
+ if p
259
+ not in [
260
+ "streamtyped",
261
+ "NSMutableAttributedString",
262
+ "NSAttributedString",
263
+ "NSString",
264
+ "NSDictionary",
265
+ ]
266
+ ]
267
+ if filtered:
268
+ return " ".join(filtered)
269
+ except Exception:
270
+ pass
271
+
272
+ except Exception as e:
273
+ logger.debug(f"Error parsing attributed body: {e}")
274
+
275
+ return None
276
+
277
+ @property
278
+ def is_running(self) -> bool:
279
+ """Check if the listener is running."""
280
+ return self._running
@@ -0,0 +1,162 @@
1
+ """iMessage sender using AppleScript."""
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class IMessageSender:
10
+ """
11
+ Sends iMessages using AppleScript via osascript.
12
+
13
+ Supports both direct messages and group chats.
14
+ """
15
+
16
+ def __init__(self):
17
+ self._last_send_time = 0.0
18
+
19
+ async def send_message(
20
+ self,
21
+ recipient: str,
22
+ text: str,
23
+ is_group: bool = False,
24
+ chat_id: str | None = None,
25
+ ) -> bool:
26
+ """
27
+ Send an iMessage.
28
+
29
+ Args:
30
+ recipient: Phone number, email, or chat identifier
31
+ text: Message text to send
32
+ is_group: Whether this is a group chat
33
+ chat_id: Chat identifier for group messages
34
+
35
+ Returns:
36
+ True if send was successful
37
+ """
38
+ try:
39
+ if is_group and chat_id:
40
+ return await self._send_to_group(chat_id, text)
41
+ else:
42
+ return await self._send_to_individual(recipient, text)
43
+ except Exception as e:
44
+ logger.error(f"Failed to send iMessage: {e}")
45
+ return False
46
+
47
+ async def _send_to_individual(self, recipient: str, text: str) -> bool:
48
+ """Send a direct message to an individual."""
49
+ # Escape special characters for AppleScript
50
+ escaped_text = self._escape_for_applescript(text)
51
+ escaped_recipient = self._escape_for_applescript(recipient)
52
+
53
+ script = f"""
54
+ tell application "Messages"
55
+ set targetService to 1st account whose service type = iMessage
56
+ set targetBuddy to participant "{escaped_recipient}" of targetService
57
+ send "{escaped_text}" to targetBuddy
58
+ end tell
59
+ """
60
+
61
+ return await self._run_applescript(script)
62
+
63
+ async def _send_to_group(self, chat_id: str, text: str) -> bool:
64
+ """Send a message to a group chat."""
65
+ escaped_text = self._escape_for_applescript(text)
66
+ escaped_chat_id = self._escape_for_applescript(chat_id)
67
+
68
+ # Try to find the chat by its identifier
69
+ script = f"""
70
+ tell application "Messages"
71
+ set targetChat to a reference to chat id "{escaped_chat_id}"
72
+ send "{escaped_text}" to targetChat
73
+ end tell
74
+ """
75
+
76
+ success = await self._run_applescript(script)
77
+
78
+ if not success:
79
+ # Fallback: try finding by chat name
80
+ logger.debug("Retrying with chat name lookup")
81
+ script_fallback = f"""
82
+ tell application "Messages"
83
+ set allChats to every chat
84
+ repeat with aChat in allChats
85
+ if id of aChat contains "{escaped_chat_id}" then
86
+ send "{escaped_text}" to aChat
87
+ return
88
+ end if
89
+ end repeat
90
+ end tell
91
+ """
92
+ success = await self._run_applescript(script_fallback)
93
+
94
+ return success
95
+
96
+ async def _run_applescript(self, script: str) -> bool:
97
+ """Execute an AppleScript and return success status."""
98
+ try:
99
+ # Run osascript in a subprocess
100
+ process = await asyncio.create_subprocess_exec(
101
+ "osascript",
102
+ "-e",
103
+ script,
104
+ stdout=asyncio.subprocess.PIPE,
105
+ stderr=asyncio.subprocess.PIPE,
106
+ )
107
+
108
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10.0)
109
+
110
+ if process.returncode == 0:
111
+ logger.debug("AppleScript executed successfully")
112
+ return True
113
+ else:
114
+ error_msg = stderr.decode("utf-8").strip()
115
+ logger.error(f"AppleScript error: {error_msg}")
116
+ return False
117
+
118
+ except asyncio.TimeoutError:
119
+ logger.error("AppleScript timed out")
120
+ return False
121
+ except Exception as e:
122
+ logger.error(f"Failed to execute AppleScript: {e}")
123
+ return False
124
+
125
+ def _escape_for_applescript(self, text: str) -> str:
126
+ """
127
+ Escape special characters for use in AppleScript strings.
128
+
129
+ AppleScript uses backslash for escaping within double-quoted strings.
130
+ """
131
+ # Escape backslashes first, then quotes
132
+ text = text.replace("\\", "\\\\")
133
+ text = text.replace('"', '\\"')
134
+ text = text.replace("\n", "\\n")
135
+ text = text.replace("\r", "\\r")
136
+ text = text.replace("\t", "\\t")
137
+ return text
138
+
139
+ async def check_messages_app(self) -> bool:
140
+ """Check if Messages.app is available and accessible."""
141
+ script = """
142
+ tell application "System Events"
143
+ return exists application process "Messages"
144
+ end tell
145
+ """
146
+
147
+ try:
148
+ process = await asyncio.create_subprocess_exec(
149
+ "osascript",
150
+ "-e",
151
+ script,
152
+ stdout=asyncio.subprocess.PIPE,
153
+ stderr=asyncio.subprocess.PIPE,
154
+ )
155
+
156
+ stdout, _ = await process.communicate()
157
+ result = stdout.decode("utf-8").strip().lower()
158
+ return result == "true"
159
+
160
+ except Exception as e:
161
+ logger.error(f"Failed to check Messages.app: {e}")
162
+ return False