pi-tau-mux 1.0.9
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 +91 -0
- package/extensions/imessage-bridge.ts +373 -0
- package/extensions/mirror-server.ts +1844 -0
- package/extensions/tau-client.ts +239 -0
- package/extras/com.tau.pi-daemon.plist +32 -0
- package/package.json +29 -0
- package/public/app.js +1894 -0
- package/public/dialogs.js +205 -0
- package/public/file-browser.js +182 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/tau-192.png +0 -0
- package/public/icons/tau-512.png +0 -0
- package/public/icons/tau-logo.png +0 -0
- package/public/icons/tau-logo.svg +13 -0
- package/public/icons/tau-maskable-512.png +0 -0
- package/public/icons/tau-new.png +0 -0
- package/public/index.html +217 -0
- package/public/launcher.js +89 -0
- package/public/manifest.json +28 -0
- package/public/markdown.js +305 -0
- package/public/message-renderer.js +261 -0
- package/public/session-sidebar.js +454 -0
- package/public/state.js +90 -0
- package/public/style.css +3434 -0
- package/public/sw.js +70 -0
- package/public/themes.js +71 -0
- package/public/tool-card.js +349 -0
- package/public/websocket-client.js +143 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# pi-tau-mux
|
|
2
|
+
|
|
3
|
+
**A lightweight client extension that connects Pi to [pi-tau-mux-server](https://github.com/dwainm/pi-tau-mux-server).**
|
|
4
|
+
|
|
5
|
+
This extension turns your Pi session into a client that registers with the standalone Tau mux server, enabling real-time mirroring in the browser across multiple Pi instances.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌─────────────┐ ┌──────────────────────┐ ┌─────────────┐
|
|
11
|
+
│ Pi TUI │ │ pi-tau-mux-server │ │ Browser │
|
|
12
|
+
│ (terminal) │ WebSocket /pi │ (standalone daemon) │ WebSocket /ws │ (Tau UI) │
|
|
13
|
+
│ │◄───────────────────►│ │◄──────────────────►│ │
|
|
14
|
+
└─────────────┘ │ Aggregates all │ └─────────────┘
|
|
15
|
+
│ Pi instances │
|
|
16
|
+
┌─────────────┐ │ │ ┌─────────────┐
|
|
17
|
+
│ Pi TUI │ │ Serves web UI │ │ Phone │
|
|
18
|
+
│ (another) │◄───────────────────►│ Scans sessions │◄──────────────────►│ (QR scan) │
|
|
19
|
+
└─────────────┘ └──────────────────────┘ └─────────────┘
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**This extension** = lightweight client (registers, forwards events, unregisters)
|
|
23
|
+
**[pi-tau-mux-server](https://github.com/dwainm/pi-tau-mux-server)** = standalone server (web UI, session browser, Tailscale support)
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi install git:github.com/dwainm/pi-tau-mux
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
1. **Start the mux server** (one-time or auto-start):
|
|
34
|
+
```bash
|
|
35
|
+
pi-tau-mux-server
|
|
36
|
+
```
|
|
37
|
+
Or let the extension prompt you when Pi starts.
|
|
38
|
+
|
|
39
|
+
2. **Start Pi normally** — the extension auto-connects to the mux server
|
|
40
|
+
|
|
41
|
+
3. **Open the web UI** at the URL shown (e.g., `http://localhost:3001` or your Tailscale URL)
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
| Command | Description |
|
|
46
|
+
|---------|-------------|
|
|
47
|
+
| `/tauconnect` | Connect to the mux server |
|
|
48
|
+
| `/taudisconnect` | Disconnect from the mux server |
|
|
49
|
+
|
|
50
|
+
## Environment Variables
|
|
51
|
+
|
|
52
|
+
| Variable | Default | Description |
|
|
53
|
+
|----------|---------|-------------|
|
|
54
|
+
| `TAU_HOST` | `localhost` | Mux server host |
|
|
55
|
+
| `TAU_PORT` | `3001` | Mux server port |
|
|
56
|
+
| `TAU_AUTO_CONNECT` | `1` | Set to `0` to disable auto-connect |
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
### Session Mirroring
|
|
61
|
+
- Real-time streaming of messages, tool calls, and thinking blocks
|
|
62
|
+
- Multiple Pi instances can connect to the same mux server
|
|
63
|
+
- Browser shows all active sessions across projects
|
|
64
|
+
|
|
65
|
+
### Tailscale Support
|
|
66
|
+
The mux server auto-detects Tailscale and uses your Tailscale IP or MagicDNS hostname. Scan the QR code from any device on your tailnet.
|
|
67
|
+
|
|
68
|
+
### Session Browser
|
|
69
|
+
View history from any past Pi session, grouped by project. Active sessions show a "LIVE" indicator.
|
|
70
|
+
|
|
71
|
+
## Related
|
|
72
|
+
|
|
73
|
+
- **[pi-tau-mux-server](https://github.com/dwainm/pi-tau-mux-server)** — The standalone server (install globally)
|
|
74
|
+
- **[pi-coding-agent](https://github.com/mariozechner/pi-coding-agent)** — The Pi coding agent
|
|
75
|
+
- **[Tau (original)](https://github.com/deflating/tau)** — This is a fork with tmux awareness and mux architecture
|
|
76
|
+
|
|
77
|
+
## Why the Split?
|
|
78
|
+
|
|
79
|
+
The original Tau ran an HTTP server inside each Pi process. This worked for single instances but caused issues with:
|
|
80
|
+
- Multiple Pi instances (port conflicts)
|
|
81
|
+
- Remote access (needed each port forwarded)
|
|
82
|
+
- Resource usage (server per Pi process)
|
|
83
|
+
|
|
84
|
+
The mux architecture solves these:
|
|
85
|
+
- **One server** for all Pi instances
|
|
86
|
+
- **One port** to forward or expose via Tailscale
|
|
87
|
+
- **Session aggregation** across all running Pi instances
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iMessage Bridge Extension
|
|
3
|
+
*
|
|
4
|
+
* Connects Pi to iMessage via BlueBubbles REST API.
|
|
5
|
+
* Polls for incoming messages, injects them as user messages,
|
|
6
|
+
* and sends assistant responses back via iMessage.
|
|
7
|
+
*
|
|
8
|
+
* Config via environment variables:
|
|
9
|
+
* BB_PASSWORD - BlueBubbles server password (default: Zawsx@12)
|
|
10
|
+
* BB_URL - BlueBubbles server URL (default: http://localhost:1234)
|
|
11
|
+
* BB_PHONE - Phone number to bridge (default: +61435599858)
|
|
12
|
+
* BB_POLL_INTERVAL - Poll interval in ms (default: 2000)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import * as http from "node:http";
|
|
19
|
+
import * as https from "node:https";
|
|
20
|
+
|
|
21
|
+
const BB_PASSWORD = process.env.BB_PASSWORD || "Zawsx@12";
|
|
22
|
+
const BB_URL = process.env.BB_URL || "http://localhost:1234";
|
|
23
|
+
const BB_PHONE = process.env.BB_PHONE || "+61435599858";
|
|
24
|
+
const BB_POLL_INTERVAL = parseInt(process.env.BB_POLL_INTERVAL || "2000");
|
|
25
|
+
const CHAT_GUID = `iMessage;-;${BB_PHONE}`;
|
|
26
|
+
const ATTACHMENTS_DIR = path.join(process.env.HOME || "~", "claude-memory/imessage/attachments");
|
|
27
|
+
|
|
28
|
+
export default function (pi: ExtensionAPI) {
|
|
29
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
+
let lastMessageTime = 0;
|
|
31
|
+
let waitingForReply = false; // true when we've injected an iMessage and are waiting for the turn to end
|
|
32
|
+
let latestCtx: ExtensionContext | null = null;
|
|
33
|
+
let enabled = false;
|
|
34
|
+
|
|
35
|
+
fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
|
36
|
+
|
|
37
|
+
// ═══════════════════════════════════════
|
|
38
|
+
// HTTP helpers
|
|
39
|
+
// ═══════════════════════════════════════
|
|
40
|
+
|
|
41
|
+
function request(method: string, urlPath: string, body?: any): Promise<any> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const url = new URL(urlPath, BB_URL);
|
|
44
|
+
url.searchParams.set("password", BB_PASSWORD);
|
|
45
|
+
|
|
46
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
47
|
+
const payload = body ? JSON.stringify(body) : undefined;
|
|
48
|
+
|
|
49
|
+
const req = mod.request(url, {
|
|
50
|
+
method,
|
|
51
|
+
headers: payload ? { "Content-Type": "application/json" } : {},
|
|
52
|
+
}, (res) => {
|
|
53
|
+
let data = "";
|
|
54
|
+
res.on("data", (chunk: Buffer) => data += chunk);
|
|
55
|
+
res.on("end", () => {
|
|
56
|
+
try { resolve(JSON.parse(data)); }
|
|
57
|
+
catch { resolve(data); }
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
req.on("error", reject);
|
|
62
|
+
if (payload) req.write(payload);
|
|
63
|
+
req.end();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function downloadAttachment(guid: string, mime: string): Promise<{ path: string; isAudio: boolean } | null> {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const url = new URL(`/api/v1/attachment/${guid}/download`, BB_URL);
|
|
70
|
+
url.searchParams.set("password", BB_PASSWORD);
|
|
71
|
+
|
|
72
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
73
|
+
mod.get(url, (res) => {
|
|
74
|
+
const contentType = res.headers["content-type"] || mime;
|
|
75
|
+
const extMap: Record<string, string> = {
|
|
76
|
+
"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
|
|
77
|
+
"image/webp": ".webp", "image/heic": ".heic",
|
|
78
|
+
"video/mp4": ".mp4", "audio/mpeg": ".mp3",
|
|
79
|
+
"audio/mp4": ".m4a", "audio/x-m4a": ".m4a",
|
|
80
|
+
"audio/aac": ".aac", "audio/caf": ".caf",
|
|
81
|
+
"application/pdf": ".pdf",
|
|
82
|
+
};
|
|
83
|
+
const isAudio = (contentType || "").startsWith("audio/");
|
|
84
|
+
const ext = extMap[contentType || ""] || "";
|
|
85
|
+
const filename = `${guid.replace(/\//g, "_")}${ext}`;
|
|
86
|
+
const filepath = path.join(ATTACHMENTS_DIR, filename);
|
|
87
|
+
|
|
88
|
+
const chunks: Buffer[] = [];
|
|
89
|
+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
90
|
+
res.on("end", () => {
|
|
91
|
+
fs.writeFileSync(filepath, Buffer.concat(chunks));
|
|
92
|
+
log(`Downloaded attachment: ${filepath}`);
|
|
93
|
+
resolve({ path: filepath, isAudio });
|
|
94
|
+
});
|
|
95
|
+
res.on("error", () => resolve(null));
|
|
96
|
+
}).on("error", () => resolve(null));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ═══════════════════════════════════════
|
|
101
|
+
// Logging
|
|
102
|
+
// ═══════════════════════════════════════
|
|
103
|
+
|
|
104
|
+
function log(msg: string) {
|
|
105
|
+
console.log(`[iMessage] ${msg}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ═══════════════════════════════════════
|
|
109
|
+
// Send iMessage via BlueBubbles
|
|
110
|
+
// ═══════════════════════════════════════
|
|
111
|
+
|
|
112
|
+
async function sendIMessage(text: string) {
|
|
113
|
+
try {
|
|
114
|
+
await request("POST", "/api/v1/message/text", {
|
|
115
|
+
chatGuid: CHAT_GUID,
|
|
116
|
+
message: text,
|
|
117
|
+
});
|
|
118
|
+
log(`Sent reply (${text.length} chars)`);
|
|
119
|
+
} catch (err: any) {
|
|
120
|
+
log(`Failed to send: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function sendTypingIndicator() {
|
|
125
|
+
try {
|
|
126
|
+
await request("POST", `/api/v1/chat/${CHAT_GUID}/typing`);
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ═══════════════════════════════════════
|
|
131
|
+
// Poll for new messages
|
|
132
|
+
// ═══════════════════════════════════════
|
|
133
|
+
|
|
134
|
+
async function getLatestMessageTime(): Promise<number> {
|
|
135
|
+
try {
|
|
136
|
+
const res = await request("POST", "/api/v1/message/query", {
|
|
137
|
+
limit: 1, sort: "DESC",
|
|
138
|
+
});
|
|
139
|
+
const msgs = res?.data || [];
|
|
140
|
+
return msgs.length > 0 ? msgs[0].dateCreated || 0 : 0;
|
|
141
|
+
} catch (err: any) {
|
|
142
|
+
log(`Error getting latest message time: ${err.message}`);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function pollMessages() {
|
|
148
|
+
if (!enabled || !latestCtx) return;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const res = await request("POST", "/api/v1/message/query", {
|
|
152
|
+
limit: 20,
|
|
153
|
+
sort: "DESC",
|
|
154
|
+
after: lastMessageTime,
|
|
155
|
+
with: ["chat", "handle", "attachment"],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const messages = (res?.data || []).reverse();
|
|
159
|
+
|
|
160
|
+
for (const msg of messages) {
|
|
161
|
+
const msgTime = msg.dateCreated || 0;
|
|
162
|
+
if (msgTime <= lastMessageTime) continue;
|
|
163
|
+
|
|
164
|
+
lastMessageTime = Math.max(lastMessageTime, msgTime);
|
|
165
|
+
|
|
166
|
+
// Skip our own messages
|
|
167
|
+
if (msg.isFromMe) continue;
|
|
168
|
+
|
|
169
|
+
// Check it's from Matt
|
|
170
|
+
const handle = msg.handle || {};
|
|
171
|
+
const address = handle.address || "";
|
|
172
|
+
const fromMatt = BB_PHONE && address.includes(BB_PHONE.replace("+", ""));
|
|
173
|
+
if (!fromMatt) {
|
|
174
|
+
const chats = msg.chats || [];
|
|
175
|
+
const chatMatch = chats.some((c: any) => (c.chatIdentifier || "").includes(BB_PHONE));
|
|
176
|
+
if (!chatMatch) continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Process the message
|
|
180
|
+
await processMessage(msg);
|
|
181
|
+
}
|
|
182
|
+
} catch (err: any) {
|
|
183
|
+
log(`Poll error: ${err.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function processMessage(msg: any) {
|
|
188
|
+
let text = msg.text || "";
|
|
189
|
+
const attachments = msg.attachments || [];
|
|
190
|
+
|
|
191
|
+
const attachmentPaths: string[] = [];
|
|
192
|
+
let voiceNotePath: string | null = null;
|
|
193
|
+
|
|
194
|
+
for (const att of attachments) {
|
|
195
|
+
const guid = att.guid || "";
|
|
196
|
+
const mime = att.mimeType || "";
|
|
197
|
+
if (!guid) continue;
|
|
198
|
+
const result = await downloadAttachment(guid, mime);
|
|
199
|
+
if (result) {
|
|
200
|
+
attachmentPaths.push(result.path);
|
|
201
|
+
if (result.isAudio) voiceNotePath = result.path;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Build the message content
|
|
206
|
+
let fullMessage = text;
|
|
207
|
+
if (voiceNotePath) {
|
|
208
|
+
fullMessage = `[Voice note from Matt at ${voiceNotePath} — transcribe and respond]`;
|
|
209
|
+
if (text) fullMessage += ` (caption: ${text})`;
|
|
210
|
+
} else if (attachmentPaths.length > 0) {
|
|
211
|
+
fullMessage += ` [Attachments: ${attachmentPaths.join(", ")}]`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!fullMessage.trim()) return;
|
|
215
|
+
|
|
216
|
+
log(`From Matt: ${fullMessage.substring(0, 100)}...`);
|
|
217
|
+
|
|
218
|
+
// Send typing indicator
|
|
219
|
+
await sendTypingIndicator();
|
|
220
|
+
|
|
221
|
+
// Inject as a real user message
|
|
222
|
+
waitingForReply = true;
|
|
223
|
+
|
|
224
|
+
// Build content array with images if applicable
|
|
225
|
+
const imageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
|
|
226
|
+
const imageAttachments = attachmentPaths.filter(p =>
|
|
227
|
+
imageExts.some(ext => p.toLowerCase().endsWith(ext))
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (imageAttachments.length > 0 && !voiceNotePath) {
|
|
231
|
+
const content: any[] = [];
|
|
232
|
+
if (text) content.push({ type: "text", text: `[iMessage from Matt] ${text}` });
|
|
233
|
+
for (const imgPath of imageAttachments) {
|
|
234
|
+
try {
|
|
235
|
+
const imgData = fs.readFileSync(imgPath);
|
|
236
|
+
const ext = path.extname(imgPath).toLowerCase().replace(".", "");
|
|
237
|
+
const mediaType = ext === "jpg" ? "image/jpeg" : `image/${ext}`;
|
|
238
|
+
content.push({
|
|
239
|
+
type: "image",
|
|
240
|
+
source: { type: "base64", mediaType, data: imgData.toString("base64") },
|
|
241
|
+
});
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
pi.sendUserMessage(content, { deliverAs: "followUp" });
|
|
245
|
+
} else {
|
|
246
|
+
pi.sendUserMessage(`[iMessage from Matt] ${fullMessage}`, { deliverAs: "followUp" });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ═══════════════════════════════════════
|
|
251
|
+
// Capture responses and send back
|
|
252
|
+
// ═══════════════════════════════════════
|
|
253
|
+
|
|
254
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
255
|
+
if (!enabled || !waitingForReply) return;
|
|
256
|
+
waitingForReply = false;
|
|
257
|
+
|
|
258
|
+
// Extract the assistant's text response from the turn
|
|
259
|
+
const message = event.message;
|
|
260
|
+
if (!message) return;
|
|
261
|
+
|
|
262
|
+
// Get text content from the message
|
|
263
|
+
let responseText = "";
|
|
264
|
+
if (typeof message.content === "string") {
|
|
265
|
+
responseText = message.content;
|
|
266
|
+
} else if (Array.isArray(message.content)) {
|
|
267
|
+
responseText = message.content
|
|
268
|
+
.filter((b: any) => b.type === "text")
|
|
269
|
+
.map((b: any) => b.text)
|
|
270
|
+
.join("\n");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!responseText.trim()) return;
|
|
274
|
+
|
|
275
|
+
// Strip markdown for iMessage (keep it readable)
|
|
276
|
+
const cleanText = responseText
|
|
277
|
+
.replace(/```[\s\S]*?```/g, "[code block]") // collapse code blocks
|
|
278
|
+
.replace(/`([^`]+)`/g, "$1") // inline code → plain
|
|
279
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1") // bold → plain
|
|
280
|
+
.replace(/\*([^*]+)\*/g, "$1") // italic → plain
|
|
281
|
+
.replace(/^#{1,6}\s+/gm, "") // strip headers
|
|
282
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links → text
|
|
283
|
+
.trim();
|
|
284
|
+
|
|
285
|
+
// iMessage has a practical limit — split long messages
|
|
286
|
+
const MAX_LEN = 4000;
|
|
287
|
+
if (cleanText.length <= MAX_LEN) {
|
|
288
|
+
await sendIMessage(cleanText);
|
|
289
|
+
} else {
|
|
290
|
+
// Split on paragraph boundaries
|
|
291
|
+
const paragraphs = cleanText.split(/\n\n+/);
|
|
292
|
+
let chunk = "";
|
|
293
|
+
for (const para of paragraphs) {
|
|
294
|
+
if (chunk.length + para.length + 2 > MAX_LEN) {
|
|
295
|
+
if (chunk) await sendIMessage(chunk.trim());
|
|
296
|
+
chunk = para;
|
|
297
|
+
} else {
|
|
298
|
+
chunk += (chunk ? "\n\n" : "") + para;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (chunk) await sendIMessage(chunk.trim());
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ═══════════════════════════════════════
|
|
306
|
+
// Lifecycle
|
|
307
|
+
// ═══════════════════════════════════════
|
|
308
|
+
|
|
309
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
310
|
+
latestCtx = ctx;
|
|
311
|
+
|
|
312
|
+
// Check if BlueBubbles is reachable
|
|
313
|
+
try {
|
|
314
|
+
const res = await request("GET", "/api/v1/ping");
|
|
315
|
+
if (res?.message === "pong") {
|
|
316
|
+
enabled = true;
|
|
317
|
+
log("Connected to BlueBubbles");
|
|
318
|
+
ctx.ui.setStatus("imessage", "📱 iMessage bridge active");
|
|
319
|
+
|
|
320
|
+
// Start from current latest message
|
|
321
|
+
lastMessageTime = await getLatestMessageTime();
|
|
322
|
+
log(`Starting poll from message time: ${lastMessageTime}`);
|
|
323
|
+
|
|
324
|
+
// Start polling
|
|
325
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
326
|
+
pollTimer = setInterval(pollMessages, BB_POLL_INTERVAL);
|
|
327
|
+
} else {
|
|
328
|
+
log("BlueBubbles not responding — bridge disabled");
|
|
329
|
+
}
|
|
330
|
+
} catch (err: any) {
|
|
331
|
+
log(`BlueBubbles unreachable (${err.message}) — bridge disabled`);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
pi.on("session_shutdown", async () => {
|
|
336
|
+
if (pollTimer) {
|
|
337
|
+
clearInterval(pollTimer);
|
|
338
|
+
pollTimer = null;
|
|
339
|
+
}
|
|
340
|
+
enabled = false;
|
|
341
|
+
log("Bridge shut down");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ═══════════════════════════════════════
|
|
345
|
+
// Commands
|
|
346
|
+
// ═══════════════════════════════════════
|
|
347
|
+
|
|
348
|
+
pi.registerCommand("imessage", {
|
|
349
|
+
description: "Send an iMessage to Matt",
|
|
350
|
+
args: [{ name: "message", description: "Message text", required: true }],
|
|
351
|
+
execute: async (args, ctx) => {
|
|
352
|
+
if (!enabled) {
|
|
353
|
+
ctx.ui.notify("iMessage bridge not connected", "error");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const text = args.join(" ");
|
|
357
|
+
await sendIMessage(text);
|
|
358
|
+
ctx.ui.notify(`Sent iMessage: ${text.substring(0, 50)}...`, "info");
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
pi.registerCommand("imessage-status", {
|
|
363
|
+
description: "Check iMessage bridge status",
|
|
364
|
+
execute: async (_args, ctx) => {
|
|
365
|
+
ctx.ui.notify(
|
|
366
|
+
enabled
|
|
367
|
+
? `iMessage bridge active. Polling every ${BB_POLL_INTERVAL / 1000}s. Last message time: ${lastMessageTime}`
|
|
368
|
+
: "iMessage bridge disabled (BlueBubbles unreachable)",
|
|
369
|
+
"info"
|
|
370
|
+
);
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|