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.
- share/wingman/node_listener/package-lock.json +1785 -0
- share/wingman/node_listener/package.json +50 -0
- share/wingman/node_listener/src/index.ts +108 -0
- share/wingman/node_listener/src/ipc.ts +70 -0
- share/wingman/node_listener/src/messageHandler.ts +135 -0
- share/wingman/node_listener/src/socket.ts +244 -0
- share/wingman/node_listener/src/types.d.ts +13 -0
- share/wingman/node_listener/tsconfig.json +19 -0
- wingman/__init__.py +4 -0
- wingman/__main__.py +6 -0
- wingman/cli/__init__.py +5 -0
- wingman/cli/commands/__init__.py +1 -0
- wingman/cli/commands/auth.py +90 -0
- wingman/cli/commands/config.py +109 -0
- wingman/cli/commands/init.py +71 -0
- wingman/cli/commands/logs.py +84 -0
- wingman/cli/commands/start.py +111 -0
- wingman/cli/commands/status.py +84 -0
- wingman/cli/commands/stop.py +33 -0
- wingman/cli/commands/uninstall.py +113 -0
- wingman/cli/main.py +50 -0
- wingman/cli/wizard.py +356 -0
- wingman/config/__init__.py +31 -0
- wingman/config/paths.py +153 -0
- wingman/config/personality.py +155 -0
- wingman/config/registry.py +343 -0
- wingman/config/settings.py +294 -0
- wingman/core/__init__.py +16 -0
- wingman/core/agent.py +257 -0
- wingman/core/ipc_handler.py +124 -0
- wingman/core/llm/__init__.py +5 -0
- wingman/core/llm/client.py +77 -0
- wingman/core/memory/__init__.py +6 -0
- wingman/core/memory/context.py +109 -0
- wingman/core/memory/models.py +213 -0
- wingman/core/message_processor.py +277 -0
- wingman/core/policy/__init__.py +5 -0
- wingman/core/policy/evaluator.py +265 -0
- wingman/core/process_manager.py +135 -0
- wingman/core/safety/__init__.py +8 -0
- wingman/core/safety/cooldown.py +63 -0
- wingman/core/safety/quiet_hours.py +75 -0
- wingman/core/safety/rate_limiter.py +58 -0
- wingman/core/safety/triggers.py +117 -0
- wingman/core/transports/__init__.py +14 -0
- wingman/core/transports/base.py +106 -0
- wingman/core/transports/imessage/__init__.py +5 -0
- wingman/core/transports/imessage/db_listener.py +280 -0
- wingman/core/transports/imessage/sender.py +162 -0
- wingman/core/transports/imessage/transport.py +140 -0
- wingman/core/transports/whatsapp.py +180 -0
- wingman/daemon/__init__.py +5 -0
- wingman/daemon/manager.py +303 -0
- wingman/installer/__init__.py +5 -0
- wingman/installer/node_installer.py +253 -0
- wingman_ai-1.0.0.dist-info/METADATA +553 -0
- wingman_ai-1.0.0.dist-info/RECORD +60 -0
- wingman_ai-1.0.0.dist-info/WHEEL +4 -0
- wingman_ai-1.0.0.dist-info/entry_points.txt +2 -0
- 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,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
|