@mapier/imsg-sdk 0.2.0 → 0.2.2
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.
- package/README.md +21 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/doctor.d.ts +25 -0
- package/dist/doctor.js +92 -0
- package/dist/doctor.js.map +1 -0
- package/dist/gateway/fake.d.ts +1 -0
- package/dist/gateway/fake.js +42 -0
- package/dist/gateway/fake.js.map +1 -1
- package/dist/gateway/imsg.d.ts +1 -0
- package/dist/gateway/imsg.js +58 -28
- package/dist/gateway/imsg.js.map +1 -1
- package/dist/gateway/types.d.ts +1 -0
- package/dist/imsg/rpc.d.ts +17 -1
- package/dist/imsg/rpc.js +26 -3
- package/dist/imsg/rpc.js.map +1 -1
- package/dist/imsg/watch.d.ts +1 -1
- package/dist/imsg/watch.js +9 -2
- package/dist/imsg/watch.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/docs/api.md +58 -0
- package/docs/bridge-contract.md +64 -0
- package/docs/capability-matrix.md +23 -0
- package/docs/compatibility.md +48 -0
- package/docs/gateway-contract.md +818 -0
- package/docs/host-mac-control.md +188 -0
- package/docs/imsg-polls.md +473 -0
- package/docs/interactions.md +176 -0
- package/docs/operations.md +47 -0
- package/package.json +9 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Host-Mac control — remote ops for the iMessage gateway Mac
|
|
2
|
+
|
|
3
|
+
How we remotely control the Mac that sends/receives iMessages for the Mapi agent.
|
|
4
|
+
Written generically as "the host Mac"; current concrete values below.
|
|
5
|
+
|
|
6
|
+
## Current host
|
|
7
|
+
|
|
8
|
+
| Field | Value |
|
|
9
|
+
|---|---|
|
|
10
|
+
| Machine | Haoxiang's MacBook Air, macOS 26.5.1 |
|
|
11
|
+
| Tailnet IP | `100.64.126.62` |
|
|
12
|
+
| User | `mark` |
|
|
13
|
+
| Repo path | `~/mapier/imsg-agent` |
|
|
14
|
+
| tmux session | `mapi` (window 0 = shell, window `agent` = running agent) |
|
|
15
|
+
| Agent's iMessage number | `+16282640380` |
|
|
16
|
+
| Test phone 1 | `+17738867338` (chat 3996) |
|
|
17
|
+
| Test phone 2 | `+15105426028` (chat 22) |
|
|
18
|
+
| Test phone 3 | `+13129001215` — for group add/remove/leave legs (the platform blocks `removeParticipant`/`leaveGroup` below 3 other members, so smokes need a third handle to build a big-enough disposable group) |
|
|
19
|
+
|
|
20
|
+
## 1. Access path
|
|
21
|
+
|
|
22
|
+
- Tailscale tailnet connects dev Mac ↔ host Mac. If status shows "stopped": `tailscale up`.
|
|
23
|
+
- SSH with key auth:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_rsa mark@100.64.126.62
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`IdentitiesOnly=yes` avoids "Too many authentication failures" from ssh-agent key
|
|
30
|
+
spam — this error bit us during `ssh-copy-id`.
|
|
31
|
+
- Remote Login must be ON, all users, on the host: System Settings → General → Sharing.
|
|
32
|
+
|
|
33
|
+
## 2. Why plain SSH is not enough (TCC model)
|
|
34
|
+
|
|
35
|
+
The core insight: sends and chat.db reads work over plain SSH once File access is
|
|
36
|
+
granted. But tapback/react and group-create use System Events UI automation
|
|
37
|
+
(keystrokes), and macOS TCC (Transparency, Consent, and Control) authorizes UI
|
|
38
|
+
automation **per responsible process**, not per user or per terminal.
|
|
39
|
+
|
|
40
|
+
- **Full Disk Access** is a plain file check — passes for any process once granted.
|
|
41
|
+
- **Accessibility** (send keystrokes) blames the daemonized **tmux server**
|
|
42
|
+
(ppid 1, launchd) — NOT Terminal.app, even when tmux was started from a Terminal
|
|
43
|
+
that already has Accessibility. Error looks like:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
System Events got an error: osascript is not allowed to send keystrokes. (1002)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- **Fix**: grant Accessibility to the tmux *binary* itself, not to Terminal:
|
|
50
|
+
1. System Settings → Privacy & Security → Accessibility → `+`
|
|
51
|
+
2. `Cmd+Shift+G` → `/opt/homebrew/bin/tmux` → add → toggle ON
|
|
52
|
+
3. Restart the tmux server so it picks up the grant:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
tmux kill-server && tmux new -s mapi
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- Also required once: Full Disk Access for the terminal app, and Automation →
|
|
59
|
+
Messages (this prompts on the first send/react — someone must click on the
|
|
60
|
+
host screen directly or via Screen Sharing to approve it).
|
|
61
|
+
|
|
62
|
+
## 3. The tmux command bridge (how we drive it)
|
|
63
|
+
|
|
64
|
+
All GUI-privileged commands run **inside** the tmux session via `send-keys`,
|
|
65
|
+
result captured via `capture-pane`:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
ssh -o IdentitiesOnly=yes -i ~/.ssh/id_rsa mark@100.64.126.62 \
|
|
69
|
+
'/opt/homebrew/bin/tmux send-keys -t mapi "<command>" Enter; sleep 5; /opt/homebrew/bin/tmux capture-pane -t mapi -p -J'
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Gotchas:
|
|
73
|
+
|
|
74
|
+
- `capture-pane` only returns the visible viewport — long output gets clipped.
|
|
75
|
+
Redirect the command's output to a file and `cat` it over SSH instead of
|
|
76
|
+
reading it off the pane.
|
|
77
|
+
- `grep -v "^$"` exits 1 when everything is blank, which breaks `&&` chains.
|
|
78
|
+
- Quote `$` as `\$` — it has to survive shell → SSH → tmux send-keys quoting layers.
|
|
79
|
+
- Use full paths (`/opt/homebrew/bin/...`) — tmux panes may not have brew on `PATH`.
|
|
80
|
+
- `tmux new-window` with an inline command closes the window instantly if the
|
|
81
|
+
command fails. Create the window bare, then `send-keys` separately, so errors
|
|
82
|
+
stay visible instead of vanishing with the window.
|
|
83
|
+
|
|
84
|
+
## 4. iMessage primitives
|
|
85
|
+
|
|
86
|
+
The host keeps `/opt/homebrew/bin/imsg` 0.12.3 as the basic-I/O baseline. The
|
|
87
|
+
pod uses `/Users/mark/imsg-emoji-build/bin/imsg` from Mapier's pinned
|
|
88
|
+
`mapier/deploy` branch via `IMSG_BIN`. That 0.13.x build contains the
|
|
89
|
+
live-verified macOS-26 participant/group-photo fixes and arbitrary-emoji RPC.
|
|
90
|
+
After rebuilding it, run `bin/imsg launch --json` to inject the matching dylib.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# list chats — numeric chat_id per thread; hex identifier = group chat
|
|
94
|
+
imsg chats --limit 5
|
|
95
|
+
|
|
96
|
+
# read history — one JSON object per line
|
|
97
|
+
# text comes from attributedBody; the raw sqlite `text` column is often NULL
|
|
98
|
+
# on modern macOS, don't trust it
|
|
99
|
+
imsg history --chat-id <id> --limit N --json
|
|
100
|
+
|
|
101
|
+
# send — works for 1:1 (--to) or groups (--chat-id); AppleScript events,
|
|
102
|
+
# no UI focus needed, no autocorrect
|
|
103
|
+
imsg send --to "+1..." --text "..."
|
|
104
|
+
imsg send --chat-id <id> --text "..."
|
|
105
|
+
|
|
106
|
+
# react — UI automation, needs the Accessibility setup from §2, ~2s when working
|
|
107
|
+
imsg react --chat-id <id> --reaction love|like|dislike|laugh|emphasis|question
|
|
108
|
+
|
|
109
|
+
# native Apple Name & Photo flow (patched binary; privacy-sensitive share)
|
|
110
|
+
/Users/mark/imsg-emoji-build/bin/imsg name-photo status --chat '<chat-guid>' --json
|
|
111
|
+
/Users/mark/imsg-emoji-build/bin/imsg name-photo share --chat '<chat-guid>' --json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Numeric chat IDs are local execution details, not configuration. A consumer
|
|
115
|
+
binding to a group configures it by portable GUID plus exact expected
|
|
116
|
+
participants and resolves it afresh at boot. After changing that binding or
|
|
117
|
+
upgrading `imsg`, run the read-only `scripts/resolve-group-smoke.ts`; it
|
|
118
|
+
performs no sends, reactions, or thread creation.
|
|
119
|
+
|
|
120
|
+
React caveats:
|
|
121
|
+
|
|
122
|
+
- Reactions **toggle**. Reacting where the same reaction already exists
|
|
123
|
+
**removes** it — check `reactions[]` in `imsg history` first.
|
|
124
|
+
- Targets the most-recent visible bubble; targeting precision is not yet
|
|
125
|
+
gate-tested (the 20× gate in `SETUP.md` §3d is still owed).
|
|
126
|
+
|
|
127
|
+
Name & Photo caveat: `share` sends the pod's configured identity/photo to
|
|
128
|
+
every participant. Inspect `status` and confirm the destination first. The
|
|
129
|
+
gateway requires `available:true` and `should_offer:true`; the 2026-07-14 live
|
|
130
|
+
gate returned `requested:true`, and the receiving test phone displayed Mapi's
|
|
131
|
+
new contact info.
|
|
132
|
+
|
|
133
|
+
Group creation:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
osascript scripts/create-group.applescript "+1A" "+1B" "first message"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Drives `Cmd+N` compose keystrokes; sending the first message creates the
|
|
140
|
+
thread (~7s). Caveat: keystroke typing passes through Messages autocorrect
|
|
141
|
+
(turned "mapi" into "maxi") — planned fix is clipboard + `Cmd+V` paste. The
|
|
142
|
+
group appears in `imsg chats` immediately; send-by-chat-id then works normally.
|
|
143
|
+
|
|
144
|
+
Verification via sqlite when needed (only inside the FDA-granted tmux):
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
sqlite3 ~/Library/Messages/chat.db
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- Tapbacks are rows with `associated_message_type` 2000–2005 (2000 = love),
|
|
151
|
+
`associated_message_guid` = `"p:0/<target-guid>"`.
|
|
152
|
+
- Date decode: `datetime(date/1000000000 + 978307200, 'unixepoch')`.
|
|
153
|
+
|
|
154
|
+
## 5. Running the agent
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
tmux new-window -t mapi -n agent
|
|
158
|
+
# then, via send-keys into that window:
|
|
159
|
+
cd ~/mapier/imsg-agent && /opt/homebrew/bin/pnpm dev 2>&1 | tee -a agent.log
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- pnpm on the host: `brew install pnpm` (or `corepack enable` — the repo pins
|
|
163
|
+
the version via `packageManager`). The old better-sqlite3 ABI-rebuild note
|
|
164
|
+
is obsolete: #20 removed the native dependency.
|
|
165
|
+
- Logs: `agent.log` in the repo. Prefixes: `[inbound]`, `[send]`, `[gate]`.
|
|
166
|
+
Multi-bubble bursts appear as consecutive `[send]` lines.
|
|
167
|
+
- `.env` on the host is **not in git**: `PERSONA_MODEL=deepseek:deepseek-chat`,
|
|
168
|
+
`DEEPSEEK_API_KEY`, `ALLOWED_HANDLES` (empty = agent replies to nobody).
|
|
169
|
+
|
|
170
|
+
## 6. Host posture
|
|
171
|
+
|
|
172
|
+
Lid open, never lock, no auto-sleep — UI automation needs an unlocked GUI
|
|
173
|
+
session. FileVault may stay ON. Manual login required after any reboot.
|
|
174
|
+
|
|
175
|
+
## Known-fragile list
|
|
176
|
+
|
|
177
|
+
- **React toggle behavior**: reacting twice with the same reaction removes it
|
|
178
|
+
instead of no-op'ing — always check existing `reactions[]` first.
|
|
179
|
+
- **React targeting precision unproven**: targets "most recent visible bubble";
|
|
180
|
+
the 20× accuracy gate (`SETUP.md` §3d) hasn't been run yet.
|
|
181
|
+
- **Autocorrect in group-create**: keystroke-typed group names/messages get
|
|
182
|
+
mangled by Messages autocorrect ("mapi" → "maxi"); clipboard-paste fix planned.
|
|
183
|
+
- **capture-pane clipping**: long command output silently truncates to the
|
|
184
|
+
visible tmux viewport — always redirect to a file for anything nontrivial.
|
|
185
|
+
- **TCC grants go stale after tmux/imsg brew upgrades**: a `brew upgrade` that
|
|
186
|
+
replaces the tmux or imsg binary can silently drop the Accessibility/Full
|
|
187
|
+
Disk Access grant — re-toggle it in System Settings if UI automation starts
|
|
188
|
+
failing after an upgrade.
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
# Native iMessage poll investigation
|
|
2
|
+
|
|
3
|
+
Date: 2026-07-07.
|
|
4
|
+
|
|
5
|
+
Purpose: capture the exact host setup, commands, and findings from testing
|
|
6
|
+
`openclaw/imsg` 0.12.3 native Messages polls for future investigation.
|
|
7
|
+
|
|
8
|
+
## Host and test thread
|
|
9
|
+
|
|
10
|
+
Host:
|
|
11
|
+
|
|
12
|
+
- Machine: Haoxiang's MacBook Air, macOS 26.5.1
|
|
13
|
+
- Tailnet IP: `100.64.126.62`
|
|
14
|
+
- User: `mark`
|
|
15
|
+
- Repo path: `~/mapier/imsg-agent`
|
|
16
|
+
- Pod handle: `+16282640380`
|
|
17
|
+
- `imsg` version: `0.12.3`
|
|
18
|
+
|
|
19
|
+
Test thread:
|
|
20
|
+
|
|
21
|
+
- Chat rowid: `3996`
|
|
22
|
+
- Chat guid: `any;-;+17738867338`
|
|
23
|
+
- Peer handle: `+17738867338`
|
|
24
|
+
- `imsg chats --limit 5 --json` reported this thread as `service:"SMS"` even
|
|
25
|
+
though Messages rendered the native poll UI on the tester device.
|
|
26
|
+
|
|
27
|
+
## Bridge setup
|
|
28
|
+
|
|
29
|
+
Native poll send/vote requires the `imsg` advanced bridge. Before enabling the
|
|
30
|
+
bridge, `imsg status --json` showed:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"advanced_features": false,
|
|
35
|
+
"v2_ready": false,
|
|
36
|
+
"bridge_version": 0,
|
|
37
|
+
"basic_features": true,
|
|
38
|
+
"sip": "disabled"
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The helper dylib shipped with the required macOS 26 architecture:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
lipo -archs /opt/homebrew/Cellar/imsg/0.12.3/libexec/imsg-bridge-helper.dylib
|
|
46
|
+
# x86_64 arm64 arm64e
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
SIP was already disabled:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
csrutil status
|
|
53
|
+
# System Integrity Protection status: disabled.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The missing macOS 26 setting was library validation. Check it with:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
defaults read /Library/Preferences/com.apple.security.libraryvalidation.plist DisableLibraryValidation
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
It initially did not exist. Set it with admin privileges:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist DisableLibraryValidation -bool true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then reboot the host. FileVault/manual login may be required before Tailscale
|
|
69
|
+
and SSH return.
|
|
70
|
+
|
|
71
|
+
After reboot:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
imsg launch --json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Expected success:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"status": "launched",
|
|
82
|
+
"message": "Messages.app launched with dylib injection",
|
|
83
|
+
"dylib": "/opt/homebrew/Cellar/imsg/0.12.3/libexec/imsg-bridge-helper.dylib"
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Verify:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
imsg status --json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Observed after the fix:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"advanced_features": true,
|
|
98
|
+
"v2_ready": true,
|
|
99
|
+
"bridge_version": 2,
|
|
100
|
+
"typing_indicators": true,
|
|
101
|
+
"read_receipts": true,
|
|
102
|
+
"message": "Connected to Messages.app. IMCore features available."
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Also verified the helper was loaded:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pid=$(pgrep -x Messages | head -1)
|
|
110
|
+
lsof -p "$pid" | grep -i 'imsg-bridge\|bridge-helper'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Observed:
|
|
114
|
+
|
|
115
|
+
```text
|
|
116
|
+
/opt/homebrew/Cellar/imsg/0.12.3/libexec/imsg-bridge-helper.dylib
|
|
117
|
+
/Users/mark/Library/Containers/com.apple.MobileSMS/Data/.imsg-bridge-ready
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Poll send test
|
|
121
|
+
|
|
122
|
+
Command:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
imsg poll send \
|
|
126
|
+
--chat-id 3996 \
|
|
127
|
+
--question "poll smoke 1136?" \
|
|
128
|
+
--option "yes" \
|
|
129
|
+
--option "no" \
|
|
130
|
+
--json
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Observed success:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"messageGuid": "41F8E456-6EF3-4FC9-9736-93C29DD2E620",
|
|
138
|
+
"balloonBundleID": "com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.messages.Polls",
|
|
139
|
+
"chatGuid": "any;-;+17738867338",
|
|
140
|
+
"poll": {
|
|
141
|
+
"kind": "created",
|
|
142
|
+
"event": "imessage.poll.created",
|
|
143
|
+
"question": "poll smoke 1136?",
|
|
144
|
+
"sessionIdentifier": "E76A5829-4530-4DC8-B106-88C2DA2795BD",
|
|
145
|
+
"options": [
|
|
146
|
+
{
|
|
147
|
+
"id": "D0EB4FE3-2D3E-4642-B178-C266E2B32052",
|
|
148
|
+
"text": "yes"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"id": "A316E9F5-3AFA-4275-80F2-DC96C1D99E30",
|
|
152
|
+
"text": "no"
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Reading poll history
|
|
160
|
+
|
|
161
|
+
High-level read:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
imsg history --chat-id 3996 --limit 15 --json
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Observed rows:
|
|
168
|
+
|
|
169
|
+
- Row `65919`: poll balloon.
|
|
170
|
+
- `guid`: `41F8E456-6EF3-4FC9-9736-93C29DD2E620`
|
|
171
|
+
- `text`: replacement character
|
|
172
|
+
- `balloon_bundle_id`: `com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.messages.Polls`
|
|
173
|
+
- `poll.kind`: `created`
|
|
174
|
+
- `poll.question`: `poll smoke 1136?`
|
|
175
|
+
- `poll.options`: `yes`, `no`
|
|
176
|
+
- Row `65920`: plain caption message.
|
|
177
|
+
- `guid`: `1F4396DE-18DF-461B-A6D5-E190B25EABBD`
|
|
178
|
+
- `text`: `poll smoke 1136?`
|
|
179
|
+
- `reply_to_guid`: poll message guid
|
|
180
|
+
|
|
181
|
+
This matches the `imsg poll --help` behavior: Messages does not render the poll
|
|
182
|
+
title on the balloon, so `imsg` sends the question as a visible plain caption
|
|
183
|
+
right after the poll.
|
|
184
|
+
|
|
185
|
+
Raw `chat.db` query used:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
sqlite3 ~/Library/Messages/chat.db "
|
|
189
|
+
select
|
|
190
|
+
m.ROWID,
|
|
191
|
+
datetime(m.date/1000000000 + 978307200, 'unixepoch') as ts,
|
|
192
|
+
m.guid,
|
|
193
|
+
m.is_from_me,
|
|
194
|
+
h.id as handle,
|
|
195
|
+
quote(m.text),
|
|
196
|
+
m.balloon_bundle_id,
|
|
197
|
+
m.associated_message_guid,
|
|
198
|
+
m.associated_message_type,
|
|
199
|
+
length(m.payload_data),
|
|
200
|
+
length(m.message_summary_info),
|
|
201
|
+
m.reply_to_guid
|
|
202
|
+
from message m
|
|
203
|
+
left join handle h on h.ROWID=m.handle_id
|
|
204
|
+
where m.ROWID >= 65918
|
|
205
|
+
order by m.ROWID;
|
|
206
|
+
"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Observed poll rows:
|
|
210
|
+
|
|
211
|
+
```text
|
|
212
|
+
65919|2026-07-07 18:36:07|41F8E456-6EF3-4FC9-9736-93C29DD2E620|1|+17738867338|NULL|com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.messages.Polls||0|7700|60|FBD9147D-F0D4-4B8C-8433-A16F10ACD1ED
|
|
213
|
+
65920|2026-07-07 18:36:08|1F4396DE-18DF-461B-A6D5-E190B25EABBD|1|+17738867338|NULL|||0||51|41F8E456-6EF3-4FC9-9736-93C29DD2E620
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Balloon `associated_message_type` is unstable early.** The capture above
|
|
217
|
+
(seconds after send) shows `0` on row 65919; re-querying the same row after
|
|
218
|
+
the vote/option activity synced returns `3`, and `imsg` reports
|
|
219
|
+
`poll.metadata.associated_message_type: 3` for it (verified twice on
|
|
220
|
+
2026-07-07). Messages restamps the column. Consumers must never branch on the
|
|
221
|
+
balloon's value — classification keys on `kind`, type `2` (option update),
|
|
222
|
+
type `4000` (vote), and `original_guid` only (`pollEventKind`, `src/polls.ts`).
|
|
223
|
+
|
|
224
|
+
Dump payload blobs:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
sqlite3 ~/Library/Messages/chat.db "
|
|
228
|
+
select writefile('/tmp/poll_payload_65919.bin', payload_data)
|
|
229
|
+
from message
|
|
230
|
+
where ROWID=65919;
|
|
231
|
+
|
|
232
|
+
select writefile('/tmp/poll_summary_65919.bin', message_summary_info)
|
|
233
|
+
from message
|
|
234
|
+
where ROWID=65919;
|
|
235
|
+
"
|
|
236
|
+
|
|
237
|
+
file /tmp/poll_payload_65919.bin /tmp/poll_summary_65919.bin
|
|
238
|
+
plutil -p /tmp/poll_payload_65919.bin
|
|
239
|
+
plutil -p /tmp/poll_summary_65919.bin
|
|
240
|
+
strings -a /tmp/poll_payload_65919.bin | grep -Ei 'yes|no|poll|option|vote'
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The payload is an Apple binary property list. Its embedded `data:` URL decoded
|
|
244
|
+
to a JSON payload with:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"version": 1,
|
|
249
|
+
"item": {
|
|
250
|
+
"title": "poll smoke 1136?",
|
|
251
|
+
"creatorHandle": "e:mapi@mapier.ai",
|
|
252
|
+
"orderedPollOptions": [
|
|
253
|
+
{
|
|
254
|
+
"optionIdentifier": "D0EB4FE3-2D3E-4642-B178-C266E2B32052",
|
|
255
|
+
"canBeEdited": false,
|
|
256
|
+
"creatorHandle": "e:mapi@mapier.ai",
|
|
257
|
+
"attributedText": "yes",
|
|
258
|
+
"text": "yes"
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"optionIdentifier": "A316E9F5-3AFA-4275-80F2-DC96C1D99E30",
|
|
262
|
+
"canBeEdited": false,
|
|
263
|
+
"creatorHandle": "e:mapi@mapier.ai",
|
|
264
|
+
"attributedText": "no",
|
|
265
|
+
"text": "no"
|
|
266
|
+
}
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The URL had `c=2`, matching the two original options.
|
|
273
|
+
|
|
274
|
+
## Vote and user-added option findings
|
|
275
|
+
|
|
276
|
+
Tester action 1: selected `yes`.
|
|
277
|
+
|
|
278
|
+
Tester action 2: added a third option from the user device.
|
|
279
|
+
|
|
280
|
+
Observed from the host afterward:
|
|
281
|
+
|
|
282
|
+
- `imsg history --chat-id 3996 --limit 15 --json` still showed only the two
|
|
283
|
+
original options.
|
|
284
|
+
- `sqlite3 ~/Library/Messages/chat.db` showed no new rows for chat `3996` after
|
|
285
|
+
rows `65919` and `65920`.
|
|
286
|
+
- `max(ROWID)` was `65922`, but rows `65921` and `65922` belonged to chat `190`,
|
|
287
|
+
not the poll chat.
|
|
288
|
+
- Re-dumping `payload_data` and `message_summary_info` for row `65919` produced
|
|
289
|
+
identical SHA-256 hashes before and after the vote/third-option test.
|
|
290
|
+
- The decoded payload still had `c=2` and only the original `yes`/`no` options.
|
|
291
|
+
|
|
292
|
+
Hash check used:
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
shasum -a 256 \
|
|
296
|
+
/tmp/poll_payload_65919.bin \
|
|
297
|
+
/tmp/poll_payload_65919_after.bin \
|
|
298
|
+
/tmp/poll_summary_65919.bin \
|
|
299
|
+
/tmp/poll_summary_65919_after.bin
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Observed:
|
|
303
|
+
|
|
304
|
+
```text
|
|
305
|
+
b80198e108faea14bca823748292528f7ed86d6c0e4407f92fd8717e8313a512 /tmp/poll_payload_65919.bin
|
|
306
|
+
b80198e108faea14bca823748292528f7ed86d6c0e4407f92fd8717e8313a512 /tmp/poll_payload_65919_after.bin
|
|
307
|
+
f29fc961af96b20a71de09b39b575c0f1358a263a704ffa8df03a760dbf91997 /tmp/poll_summary_65919.bin
|
|
308
|
+
f29fc961af96b20a71de09b39b575c0f1358a263a704ffa8df03a760dbf91997 /tmp/poll_summary_65919_after.bin
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Conclusion from this test: on this host and this thread, Mapi can see a poll it
|
|
312
|
+
creates, including the question and original options. It could not see the
|
|
313
|
+
tester vote or the tester-added third option.
|
|
314
|
+
|
|
315
|
+
**Correction (same day, ~18:52): the negative finding above was sync lag, not a
|
|
316
|
+
capability gap. See the follow-up section below — both the vote and the
|
|
317
|
+
user-added option landed in chat.db as new message rows minutes later, and
|
|
318
|
+
`imsg` 0.12.3 parses both.**
|
|
319
|
+
|
|
320
|
+
## Follow-up: vote and option readback works
|
|
321
|
+
|
|
322
|
+
Re-checked the same chat later the same day. Two new rows had arrived in chat
|
|
323
|
+
`3996`, both from `+17738867338`, both pointing at the original poll guid:
|
|
324
|
+
|
|
325
|
+
```text
|
|
326
|
+
65925|2026-07-07 18:52:07|AF3C34AC-E272-46AF-931A-C65AA21CC1F9|is_from_me=0|+17738867338|associated_message_type=4000|assoc_guid=41F8E456-...|payload 706B|summary 239B
|
|
327
|
+
65926|2026-07-07 18:52:20|C863C985-FCA7-45E3-BD9B-FEA749D04982|is_from_me=0|+17738867338|associated_message_type=2 |assoc_guid=41F8E456-...|payload 1876B|summary 61B
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Row 65925 — the vote (`associated_message_type` 4000)
|
|
331
|
+
|
|
332
|
+
`payload_data` is an NSKeyedArchiver plist wrapping a `data:` URL whose JSON is:
|
|
333
|
+
|
|
334
|
+
```json
|
|
335
|
+
{"version":1,"item":{"votes":[{
|
|
336
|
+
"voteOptionIdentifier":"D0EB4FE3-2D3E-4642-B178-C266E2B32052",
|
|
337
|
+
"serverVoteTime":805143043.515404,
|
|
338
|
+
"participantHandle":"+17738867338"}]}}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
That option id is the original `yes`. `message_summary_info` is human-readable:
|
|
342
|
+
`ams = "You removed a vote from “no”"` — the tester apparently voted `no`
|
|
343
|
+
first, removed it, then voted `yes`; the `votes` array reflects the
|
|
344
|
+
participant's current vote state.
|
|
345
|
+
|
|
346
|
+
### Row 65926 — the user-added option (`associated_message_type` 2)
|
|
347
|
+
|
|
348
|
+
Balloon session update. Decoded `data:` URL JSON carries the full updated
|
|
349
|
+
option list, with per-option `creatorHandle` identifying who added what:
|
|
350
|
+
|
|
351
|
+
```json
|
|
352
|
+
{"item":{"creatorHandle":"+17738867338","title":"","orderedPollOptions":[
|
|
353
|
+
{"text":"yes","optionIdentifier":"D0EB4FE3-...","creatorHandle":"e:mapi@mapier.ai"},
|
|
354
|
+
{"text":"no","optionIdentifier":"A316E9F5-...","creatorHandle":"e:mapi@mapier.ai"},
|
|
355
|
+
{"text":"unsure","optionIdentifier":"2E247807-B986-4E57-ABB3-7E7128503A5A","creatorHandle":"+17738867338"}
|
|
356
|
+
]},"version":1}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The tester-added third option is `unsure`.
|
|
360
|
+
|
|
361
|
+
### imsg 0.12.3 parses both — history and watch
|
|
362
|
+
|
|
363
|
+
No manual plist decoding needed. `imsg history --chat-id 3996 --json` returns:
|
|
364
|
+
|
|
365
|
+
- Vote row: `poll.kind: "vote"`, `poll.event: "imessage.poll.voted"`, and
|
|
366
|
+
`poll.vote` / `poll.votes[]` with `option_id`, **`option_text` already
|
|
367
|
+
resolved** (`"yes"`), `participant`, `server_time`, plus
|
|
368
|
+
`original_guid` = the poll message guid.
|
|
369
|
+
- Option-update row: full `poll.options[]` including `unsure`, `creator` =
|
|
370
|
+
`+17738867338`, `original_guid` = the poll guid.
|
|
371
|
+
|
|
372
|
+
`imsg watch --chat-id 3996 --since-rowid 65918 --json` replays the same rows
|
|
373
|
+
with the same `poll` objects, so the live watch/rpc stream the pod already
|
|
374
|
+
consumes carries poll events.
|
|
375
|
+
|
|
376
|
+
### Parsing caveats
|
|
377
|
+
|
|
378
|
+
- The option-update row is mislabeled `poll.kind: "created"` /
|
|
379
|
+
`event: "imessage.poll.created"` (imsg keys "created" off the balloon
|
|
380
|
+
payload shape, and `associated_message_type` 2 is also used for fresh
|
|
381
|
+
balloons). Detect updates as: poll object whose `original_guid` differs
|
|
382
|
+
from the original poll send, or diff `options[]` against the options seen
|
|
383
|
+
at `poll send` time.
|
|
384
|
+
- The update row's `poll.question` is junk (the update payload has
|
|
385
|
+
`title: ""`, and imsg falls back to unrelated nearby text). Join via
|
|
386
|
+
`original_guid` to the original poll for the real question.
|
|
387
|
+
- `poll.votes[]` is the *sender's current votes*, not an event log — a vote
|
|
388
|
+
switch shows up as one row with the final state (the removal narrative only
|
|
389
|
+
survives in `message_summary_info.ams`).
|
|
390
|
+
- Timing: the vote/update rows appeared with `date` ≈ 18:52, ~16 min after the
|
|
391
|
+
poll send, and were absent during the first inspection. Whether that lag was
|
|
392
|
+
tester timing or Messages/CloudKit sync latency is unresolved — do not
|
|
393
|
+
assume poll updates are immediate.
|
|
394
|
+
|
|
395
|
+
## Source-level notes (openclaw/imsg)
|
|
396
|
+
|
|
397
|
+
From reading the imsg source (github.com/openclaw/imsg) after the follow-up:
|
|
398
|
+
|
|
399
|
+
- Poll decode lives in `MessagePollDecoder` (`Sources/IMsgCore/MessagePolls.swift`)
|
|
400
|
+
with vote/option-text resolution in `MessageStore+Polls.swift`; the injected
|
|
401
|
+
bridge send path is `handleSendPoll` / `buildPollVoteIMMessage` in
|
|
402
|
+
`Sources/IMsgHelper/IMsgInjected.m`.
|
|
403
|
+
- **Reading polls needs no bridge/dylib** — only `poll send` / `poll vote`
|
|
404
|
+
(private IMCore API) require `imsg launch`. Readback is implicit in the
|
|
405
|
+
normal read paths (`history`, `watch`, RPC `messages.history` /
|
|
406
|
+
`watch.subscribe`); any row that decodes as a poll gets the `poll` object
|
|
407
|
+
attached, no flag needed.
|
|
408
|
+
- **No live IMCore push channel for polls.** The dylib event tailer
|
|
409
|
+
(`IMsgEventTailer`, used for typing indicators) has zero poll code. Poll
|
|
410
|
+
events are chat.db-derived via `MessageWatcher` (filesystem events + 5s
|
|
411
|
+
fallback poll), so latency floor is DB-write time, and votes only become
|
|
412
|
+
visible when Messages persists the rows.
|
|
413
|
+
- The decoder constants match what we observed: `voteAssociatedMessageType`
|
|
414
|
+
= 4000, `updateAssociatedMessageType` = 2; the reaction-exclusion SQL only
|
|
415
|
+
drops `associated_message_type` 2000–3006, so poll rows pass through by
|
|
416
|
+
default.
|
|
417
|
+
- Version timeline: readback shipped in 0.10.0, `poll vote` in 0.12.0, vote
|
|
418
|
+
option-text resolution from Add-Choice rows fixed in 0.12.2 (#153). Nothing
|
|
419
|
+
after 0.12.3 adds a new readback mechanism — 0.12.3 is current for polls.
|
|
420
|
+
- Watch caveat: `MessageWatcher` gives up on a row whose `chat_message_join`
|
|
421
|
+
hasn't resolved after 20 retries (`WatchState.unresolvedChatRetryLimit`) and
|
|
422
|
+
silently advances past it — a dropped row is indistinguishable from "never
|
|
423
|
+
written" if only watching. When debugging missing poll events, check the raw
|
|
424
|
+
`message` table by ROWID first, independent of the chat join.
|
|
425
|
+
- `poll vote` outbound had a macOS 26.4.x persistence bug fixed in 0.12.2
|
|
426
|
+
(#150) — evidence that Apple's Polls IMCore plumbing regresses across 26.x
|
|
427
|
+
point releases; treat inbound persistence as fragile too.
|
|
428
|
+
- The upstream repo's own docs (`docs/history.md`, "Manual native poll test
|
|
429
|
+
plan") document exactly our verification flow:
|
|
430
|
+
`imsg history --chat-id <id> --json | jq -c 'select(.poll != null)'`.
|
|
431
|
+
|
|
432
|
+
## Current Mapi behavior
|
|
433
|
+
|
|
434
|
+
**Updated 2026-07-07 (same day, follow-up change): the pipeline now forwards
|
|
435
|
+
poll events to the LLM.** The original gap — `toModelMessages()` forwarded only
|
|
436
|
+
`m.text`, so the agent saw a vote as a blank bubble and an added option as
|
|
437
|
+
`�`, and replied "did something get lost in translation there? 😂" — is fixed:
|
|
438
|
+
|
|
439
|
+
- `src/types.ts` models the poll payload (`MessagePoll` on `ImsgMessage`);
|
|
440
|
+
both gateways pass it through (the rpc path already forwarded whole JSON
|
|
441
|
+
objects, so no `src/gateway/imsg.ts` change was needed).
|
|
442
|
+
- `src/polls.ts` classifies rows (`pollEventKind` — absorbing the
|
|
443
|
+
kind-mislabel and junk-question hazards above) and renders them as bracket
|
|
444
|
+
notes (`renderPollEvent`), resolving the question via `original_guid`.
|
|
445
|
+
- `toModelMessages()` (imsg-agent) pushes those notes — e.g.
|
|
446
|
+
`[voted "yes" on the poll "poll smoke 1136?"]`, `[added option "unsure" to
|
|
447
|
+
the poll "poll smoke 1136?"]` — instead of the junk display text.
|
|
448
|
+
- Contract + fake parity + sim UI: gateway-contract.md §2 "Native poll
|
|
449
|
+
readback", FakeGateway poll fixtures, sim poll bubble (sim-dev-tools.md §6).
|
|
450
|
+
|
|
451
|
+
## Things to try next
|
|
452
|
+
|
|
453
|
+
Resolved by the follow-up above:
|
|
454
|
+
|
|
455
|
+
- ~~Try `imsg watch --json`~~ — watch emits the same `poll` objects as history.
|
|
456
|
+
- ~~Search whether poll updates land elsewhere~~ — they land in the same chat
|
|
457
|
+
as new `message` rows (`associated_message_type` 4000 = vote, 2 = option
|
|
458
|
+
update); the original balloon row is never rewritten.
|
|
459
|
+
|
|
460
|
+
Still open:
|
|
461
|
+
|
|
462
|
+
- Repeat in a known iMessage-only DM. The tested thread `3996` reports
|
|
463
|
+
`service:"SMS"` on the host.
|
|
464
|
+
- Repeat in an iMessage group chat (multi-participant votes; per-participant
|
|
465
|
+
attribution in `poll.votes[]`).
|
|
466
|
+
- Characterize vote-sync latency (the ~16 min gap above) and whether it
|
|
467
|
+
requires Messages.app frontmost / bridge injected.
|
|
468
|
+
- Vote removal without re-vote: does a bare retraction produce a 4000 row with
|
|
469
|
+
an empty `votes[]`?
|
|
470
|
+
- If building on this, update `src/types.ts`, gateway contract docs, fake
|
|
471
|
+
gateway parity, and `toModelMessages()` so the LLM receives poll events in a
|
|
472
|
+
stable text form — including the `original_guid` join and the
|
|
473
|
+
kind-mislabel caveats above.
|