@textopenclaw/textclaw 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TextClaw
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @textopenclaw/textclaw
2
+
3
+ iMessage channel plugin for [OpenClaw](https://github.com/openclaw/openclaw) via [TextClaw](https://textclaw.now) relay. No Mac required.
4
+
5
+ ```
6
+ Your OpenClaw --> TextClaw Relay API --> iMessage
7
+ ```
8
+
9
+ ## Quick Start
10
+
11
+ 1. **Get an API key** from [textclaw.now](https://textclaw.now) (sign up, then grab your BYOC key from the dashboard)
12
+ 2. **Install the plugin:**
13
+ ```bash
14
+ openclaw plugins install @textopenclaw/textclaw
15
+ ```
16
+ 3. **Add to your channel config:**
17
+ ```json
18
+ {
19
+ "textclaw": {
20
+ "enabled": true,
21
+ "textclawUrl": "https://textclaw.now",
22
+ "apiKey": "YOUR_API_KEY"
23
+ }
24
+ }
25
+ ```
26
+ 4. **Done.** Messages arrive via WebSocket automatically. No public URL needed.
27
+
28
+ ## Configuration
29
+
30
+ | Field | Required | Description |
31
+ |-------|----------|-------------|
32
+ | `enabled` | No | Enable/disable the channel (default: `false`) |
33
+ | `textclawUrl` | Yes | TextClaw API base URL |
34
+ | `apiKey` | Yes | Your BYOC API key from TextClaw |
35
+ | `dmPolicy` | No | `"open"`, `"allowlist"`, `"pairing"`, or `"disabled"` (default: `"open"`) |
36
+ | `allowFrom` | No | Phone numbers allowed to DM when using `"allowlist"` policy |
37
+
38
+ ## Features
39
+
40
+ - Send and receive iMessages (text + media)
41
+ - Typing indicators
42
+ - Tapback reactions: heart, thumbsup, thumbsdown, laugh, emphasize, question
43
+ - Message chunking for long texts (4000 char limit per message)
44
+ - WebSocket connection for real-time inbound message delivery
45
+
46
+ ## How It Works
47
+
48
+ 1. **Outbound**: OpenClaw calls the TextClaw relay API, which forwards the message to iMessage.
49
+ 2. **Inbound**: TextClaw receives inbound message webhooks, matches them to your account, and pushes messages to your OpenClaw instance via WebSocket (with HTTP fallback if you provide an `openclaw_url` during registration).
50
+
51
+ ## Setup for AI Agents
52
+
53
+ If you're an OpenClaw instance configuring iMessage support, here's everything you need:
54
+
55
+ **You were given a TextClaw API key** (64-character hex string from textclaw.now).
56
+
57
+ Install the plugin:
58
+ ```bash
59
+ openclaw plugins install @textopenclaw/textclaw
60
+ ```
61
+
62
+ Add this to your channel config:
63
+ ```json
64
+ {
65
+ "textclaw": {
66
+ "enabled": true,
67
+ "textclawUrl": "https://textclaw.now",
68
+ "apiKey": "YOUR_API_KEY"
69
+ }
70
+ }
71
+ ```
72
+
73
+ That's it. The plugin connects to TextClaw via WebSocket automatically. No public URL, no port forwarding, no extra setup. Messages flow through TextClaw's relay to iMessage and back.
74
+
75
+ ## License
76
+
77
+ MIT
package/index.js ADDED
@@ -0,0 +1,119 @@
1
+ import channelPlugin from "./src/channel.js";
2
+ import { processInboundWebhook } from "./src/monitor.js";
3
+ import { startWebSocket, stopWebSocket } from "./src/ws.js";
4
+ import { setPluginApi } from "./src/runtime.js";
5
+ import { sendReactionToPeer, sendVoiceNoteToPeer } from "./src/send.js";
6
+ import * as api from "./src/api.js";
7
+
8
+ let registered = false;
9
+
10
+ const plugin = {
11
+ id: "textclaw",
12
+ name: "TextClaw iMessage",
13
+ description: "iMessage channel via TextClaw relay — no Mac required",
14
+
15
+ register(pluginApi) {
16
+ // Always update pluginApi and API config (called in both CLI and gateway)
17
+ setPluginApi(pluginApi);
18
+ const account = pluginApi.pluginConfig;
19
+ api.configure(account.textclawUrl, account.apiKey);
20
+
21
+ // Only register channel/routes/tools once
22
+ if (registered) return;
23
+ registered = true;
24
+
25
+ // Register the channel
26
+ pluginApi.registerChannel({ plugin: channelPlugin });
27
+
28
+ // Start WebSocket for inbound messages
29
+ const wsBase = account.textclawUrl
30
+ .replace(/^http:/, "ws:")
31
+ .replace(/^https:/, "wss:")
32
+ .replace(/\/+$/, "");
33
+ const wsUrl = `${wsBase}/ws/byoc/inbound/?api_key=${account.apiKey}`;
34
+ startWebSocket(wsUrl);
35
+
36
+ // Inbound webhook route (HTTP fallback)
37
+ pluginApi.registerHttpRoute({
38
+ path: "/ext/textclaw/inbound",
39
+ auth: "plugin",
40
+ handler: async (req, res) => {
41
+ try {
42
+ await processInboundWebhook(req.body);
43
+ res.statusCode = 200;
44
+ res.end(JSON.stringify({ ok: true }));
45
+ } catch (err) {
46
+ console.error("[textclaw] webhook error:", err);
47
+ res.statusCode = 500;
48
+ res.end(JSON.stringify({ error: err.message }));
49
+ }
50
+ return true;
51
+ },
52
+ });
53
+
54
+ // Status check route
55
+ pluginApi.registerHttpRoute({
56
+ path: "/ext/textclaw/status",
57
+ auth: "plugin",
58
+ handler: async (req, res) => {
59
+ const configuredKey = api.getApiKey();
60
+ const checkKey = req.headers["x-api-key"] || "";
61
+ res.statusCode = 200;
62
+ res.end(JSON.stringify({
63
+ installed: true,
64
+ version: "0.6.0",
65
+ keyMatch: !!(checkKey && checkKey === configuredKey),
66
+ }));
67
+ return true;
68
+ },
69
+ });
70
+
71
+ // Register message tools so the agent can react and send voice notes
72
+ pluginApi.registerTool({
73
+ name: "react_imessage",
74
+ description: "React to an iMessage with a tapback. Available reactions: heart, thumbsup, thumbsdown, laugh, emphasize, question. To react to a specific message, pass its message_handle (the MessageSid from the message metadata). Omit to react to the last inbound message.",
75
+ parameters: {
76
+ type: "object",
77
+ properties: {
78
+ reaction: {
79
+ type: "string",
80
+ enum: ["heart", "thumbsup", "thumbsdown", "laugh", "emphasize", "question"],
81
+ },
82
+ message_handle: {
83
+ type: "string",
84
+ description: "Apple message GUID to react to (from MessageSid in message metadata). Omit to react to the last inbound message.",
85
+ },
86
+ },
87
+ required: ["reaction"],
88
+ },
89
+ async execute({ reaction, message_handle }, { target }) {
90
+ const peer = target?.peer || target;
91
+ await sendReactionToPeer(account, peer, reaction, message_handle || null);
92
+ return { success: true };
93
+ },
94
+ });
95
+
96
+ pluginApi.registerTool({
97
+ name: "send_voice_note",
98
+ description: "Send a voice note to the user via iMessage. Provide a URL to an audio file (mp3, wav, ogg, m4a).",
99
+ parameters: {
100
+ type: "object",
101
+ properties: {
102
+ media_url: { type: "string", description: "URL to the audio file" },
103
+ },
104
+ required: ["media_url"],
105
+ },
106
+ async execute({ media_url }, { target }) {
107
+ const peer = target?.peer || target;
108
+ const result = await sendVoiceNoteToPeer(account, peer, media_url);
109
+ return { success: true, messageId: result.messageId };
110
+ },
111
+ });
112
+
113
+ pluginApi.onShutdown?.(() => stopWebSocket());
114
+
115
+ console.log(`[textclaw] channel started — relay via ${account.textclawUrl}`);
116
+ },
117
+ };
118
+
119
+ export default plugin;
@@ -0,0 +1,35 @@
1
+ {
2
+ "id": "textclaw",
3
+ "name": "TextClaw",
4
+ "description": "iMessage relay channel via outbound WebSocket",
5
+ "channels": ["imessage"],
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "textclawUrl": {
11
+ "type": "string",
12
+ "description": "TextClaw API base URL"
13
+ },
14
+ "apiKey": {
15
+ "type": "string",
16
+ "description": "Your BYOC API key from TextClaw"
17
+ },
18
+ "dmPolicy": {
19
+ "type": "string",
20
+ "enum": ["open", "allowlist", "pairing", "disabled"],
21
+ "default": "open"
22
+ },
23
+ "allowFrom": {
24
+ "type": "array",
25
+ "items": { "type": "string" },
26
+ "default": []
27
+ }
28
+ },
29
+ "required": ["textclawUrl", "apiKey"]
30
+ },
31
+ "uiHints": {
32
+ "apiKey": { "label": "API Key", "sensitive": true },
33
+ "textclawUrl": { "label": "TextClaw Server URL", "placeholder": "https://textclaw.now" }
34
+ }
35
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@textopenclaw/textclaw",
3
+ "version": "1.0.0",
4
+ "description": "iMessage channel plugin for OpenClaw via TextClaw relay. No Mac required.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "keywords": [
8
+ "openclaw",
9
+ "openclaw-plugin",
10
+ "imessage",
11
+ "textclaw",
12
+ "channel",
13
+ "byoc"
14
+ ],
15
+ "author": "TextClaw",
16
+ "license": "MIT",
17
+ "homepage": "https://textclaw.now",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/vladartym/openclaw-textclaw.git"
21
+ },
22
+ "files": [
23
+ "index.js",
24
+ "src/",
25
+ "skills/",
26
+ "openclaw.plugin.json",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "peerDependencies": {
34
+ "openclaw": ">=1.0.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "openclaw": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "openclaw": {
42
+ "extensions": ["./index.js"],
43
+ "channel": {
44
+ "id": "imessage",
45
+ "label": "TextClaw (iMessage)",
46
+ "docsPath": "/channels/textclaw",
47
+ "blurb": "iMessage relay via TextClaw WebSocket bridge."
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: textclaw
3
+ description: "iMessage ops via TextClaw relay — reactions, sending, typing, voice notes."
4
+ metadata:
5
+ openclaw:
6
+ emoji: "\U0001F4AC"
7
+ requires:
8
+ config:
9
+ - plugins.entries.textclaw.config.apiKey
10
+ - plugins.entries.textclaw.config.textclawUrl
11
+ allowed-tools:
12
+ - exec
13
+ - message
14
+ ---
15
+
16
+ # TextClaw iMessage
17
+
18
+ iMessage channel via TextClaw relay. No Mac required.
19
+
20
+ ## Musts
21
+
22
+ - Sending messages: use the `message` tool normally (no `channel` param needed).
23
+ - Reactions: use `exec` with curl to the TextClaw relay API. Do NOT use `message(action="react")` — it does not work on this channel.
24
+ - The relay URL and API key are in your config at `plugins.entries.textclaw.config.textclawUrl` and `plugins.entries.textclaw.config.apiKey`.
25
+
26
+ ## Reactions
27
+
28
+ Use `exec` to curl the TextClaw relay API:
29
+
30
+ ```bash
31
+ curl -s -X POST "<TEXTCLAW_URL>/api/v1/byoc/relay/reaction/" \
32
+ -H "Content-Type: application/json" \
33
+ -H "X-API-Key: <API_KEY>" \
34
+ -d '{"message_handle": "<MESSAGE_ID>", "reaction": "<REACTION>"}'
35
+ ```
36
+
37
+ - `TEXTCLAW_URL`: from config `plugins.entries.textclaw.config.textclawUrl`
38
+ - `API_KEY`: from config `plugins.entries.textclaw.config.apiKey`
39
+ - `MESSAGE_ID`: the `message_id` from the inbound message metadata (this is the Apple GUID). You MUST use the real GUID — do not pass "last" or any placeholder.
40
+ - `REACTION`: one of `heart`, `thumbsup`, `thumbsdown`, `laugh`, `emphasize`, `question`
41
+
42
+ Response `{"ok": true}` means success. `{"ok": false}` means the reaction failed (wrong handle or SendBlue error).
43
+
44
+ **Emoji mapping:**
45
+
46
+ - heart / love / ❤️ → `heart`
47
+ - like / thumbs up / 👍 → `thumbsup`
48
+ - dislike / thumbs down / 👎 → `thumbsdown`
49
+ - haha / laugh / 😂 → `laugh`
50
+ - emphasize / ‼️ → `emphasize`
51
+ - question / ❓ → `question`
52
+
53
+ ## Sending Messages
54
+
55
+ Use the `message` tool normally:
56
+
57
+ ```json
58
+ { "action": "send", "text": "Hello!" }
59
+ ```
60
+
61
+ With media:
62
+
63
+ ```json
64
+ { "action": "send", "text": "Check this out", "mediaUrl": "https://example.com/photo.jpg" }
65
+ ```
66
+
67
+ ## Voice Notes
68
+
69
+ Send audio as an iMessage voice note (plays inline):
70
+
71
+ ```json
72
+ { "action": "send", "text": "", "mediaUrl": "https://example.com/audio.mp3" }
73
+ ```
74
+
75
+ ## Writing Style
76
+
77
+ - Short, conversational, natural tone — this is iMessage, not email.
78
+ - Break long responses into multiple messages if needed.
79
+ - Use tapback reactions to acknowledge messages when a full reply isn't needed.
package/src/api.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * HTTP client for TextClaw relay API.
3
+ *
4
+ * BYOC users route outbound messages through TextClaw's relay endpoints
5
+ * which handle iMessage delivery on their behalf.
6
+ */
7
+
8
+ import { readFileSync } from "fs";
9
+ import { basename } from "path";
10
+
11
+ let baseUrl = ""; // e.g. "https://textclaw.now/api/v1/byoc/relay"
12
+ let apiKey = "";
13
+
14
+ export function configure(url, key) {
15
+ baseUrl = url.replace(/\/+$/, "") + "/api/v1/byoc/relay";
16
+ apiKey = key;
17
+ }
18
+
19
+ export function getApiKey() {
20
+ return apiKey;
21
+ }
22
+
23
+ async function request(method, path, body = null) {
24
+ const url = `${baseUrl}${path}`;
25
+ const headers = {
26
+ "X-API-Key": apiKey,
27
+ "Content-Type": "application/json",
28
+ };
29
+
30
+ const options = { method, headers };
31
+ if (body) {
32
+ options.body = JSON.stringify(body);
33
+ }
34
+
35
+ const resp = await fetch(url, options);
36
+
37
+ if (!resp.ok) {
38
+ const text = await resp.text();
39
+ throw new Error(`TextClaw ${method} ${path} failed (${resp.status}): ${text}`);
40
+ }
41
+
42
+ return resp.json();
43
+ }
44
+
45
+ /**
46
+ * Send a message via TextClaw relay → iMessage.
47
+ * @param {string} _fromNumber - Ignored (TextClaw uses the shared BYOC number)
48
+ * @param {string} toNumber - Recipient phone number (E.164)
49
+ * @param {string} content - Message text
50
+ * @param {string} [mediaUrl] - Optional media URL
51
+ * @returns {Promise<object>}
52
+ */
53
+ export async function sendMessage(_fromNumber, toNumber, content, mediaUrl = null) {
54
+ const body = { to_number: toNumber, content };
55
+ if (mediaUrl) {
56
+ body.media_url = mediaUrl;
57
+ }
58
+ return request("POST", "/send/", body);
59
+ }
60
+
61
+ /**
62
+ * Upload a local file to the TextClaw server.
63
+ * @param {string} filePath - Local file path
64
+ * @returns {Promise<{ url: string }>} Public URL of the uploaded file
65
+ */
66
+ export async function uploadFile(filePath) {
67
+ const url = `${baseUrl}/upload/`;
68
+ const fileData = readFileSync(filePath);
69
+ const fileName = basename(filePath);
70
+
71
+ const form = new FormData();
72
+ form.append("file", new Blob([fileData]), fileName);
73
+
74
+ const resp = await fetch(url, {
75
+ method: "POST",
76
+ headers: { "X-API-Key": apiKey },
77
+ body: form,
78
+ });
79
+
80
+ if (!resp.ok) {
81
+ const text = await resp.text();
82
+ throw new Error(`TextClaw upload failed (${resp.status}): ${text}`);
83
+ }
84
+
85
+ return resp.json();
86
+ }
87
+
88
+ /**
89
+ * Send a voice note via TextClaw relay → iMessage.
90
+ * The server converts the audio to .caf format for inline iMessage playback.
91
+ * If mediaUrl is a local file path, uploads it first.
92
+ * @param {string} _fromNumber - Ignored
93
+ * @param {string} toNumber - Recipient phone number (E.164)
94
+ * @param {string} mediaUrl - URL or local path to an audio file
95
+ * @returns {Promise<object>}
96
+ */
97
+ export async function sendVoiceNote(_fromNumber, toNumber, mediaUrl) {
98
+ // Local file path → upload to server first
99
+ if (mediaUrl && !mediaUrl.startsWith("http")) {
100
+ const uploaded = await uploadFile(mediaUrl);
101
+ mediaUrl = uploaded.url;
102
+ }
103
+
104
+ return request("POST", "/send/", {
105
+ to_number: toNumber,
106
+ content: "",
107
+ media_url: mediaUrl,
108
+ convert_to_voice_note: true,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Send typing indicator via TextClaw relay.
114
+ * @param {string} _fromNumber - Ignored
115
+ * @param {string} toNumber - Recipient phone number (E.164)
116
+ */
117
+ export async function sendTypingIndicator(_fromNumber, toNumber) {
118
+ return request("POST", "/typing/", { to_number: toNumber });
119
+ }
120
+
121
+ /**
122
+ * Send a reaction (tapback) via TextClaw relay.
123
+ * @param {string} _fromNumber - Ignored
124
+ * @param {string} messageHandle - Apple GUID of the message to react to
125
+ * @param {"heart"|"thumbsup"|"thumbsdown"|"laugh"|"emphasize"|"question"} reaction
126
+ */
127
+ export async function sendReaction(_fromNumber, messageHandle, reaction) {
128
+ return request("POST", "/reaction/", {
129
+ message_handle: messageHandle,
130
+ reaction,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Mark a conversation as read via TextClaw relay.
136
+ * @param {string} toNumber - The phone number whose messages to mark as read
137
+ */
138
+ export async function markRead(toNumber) {
139
+ return request("POST", "/mark-read/", { to_number: toNumber });
140
+ }
package/src/channel.js ADDED
@@ -0,0 +1,173 @@
1
+ import * as api from "./api.js";
2
+ import { sendMessageToPeer, sendTyping, sendReactionToPeer, sendVoiceNoteToPeer, chunkText } from "./send.js";
3
+ import { getLastInboundHandle } from "./handle-store.js";
4
+
5
+ /**
6
+ * Map emoji and text aliases to iMessage tapback types.
7
+ */
8
+ const EMOJI_TO_TAPBACK = {
9
+ // Emoji variants
10
+ "\u2764\uFE0F": "heart", "\u2665\uFE0F": "heart", "\u2764": "heart",
11
+ "\uD83D\uDC4D": "thumbsup", "\uD83D\uDC4D\uD83C\uDFFB": "thumbsup",
12
+ "\uD83D\uDC4D\uD83C\uDFFC": "thumbsup", "\uD83D\uDC4D\uD83C\uDFFD": "thumbsup",
13
+ "\uD83D\uDC4E": "thumbsdown", "\uD83D\uDC4E\uD83C\uDFFB": "thumbsdown",
14
+ "\uD83D\uDC4E\uD83C\uDFFC": "thumbsdown", "\uD83D\uDC4E\uD83C\uDFFD": "thumbsdown",
15
+ "\uD83D\uDE02": "laugh", "\uD83E\uDD23": "laugh", "\uD83D\uDE06": "laugh",
16
+ "\u203C\uFE0F": "emphasize", "\u2757": "emphasize", "\u2755": "emphasize",
17
+ "\u2753": "question",
18
+ // Text aliases
19
+ "heart": "heart", "love": "heart",
20
+ "thumbsup": "thumbsup", "like": "thumbsup", "+1": "thumbsup",
21
+ "thumbsdown": "thumbsdown", "dislike": "thumbsdown", "-1": "thumbsdown",
22
+ "laugh": "laugh", "haha": "laugh",
23
+ "emphasize": "emphasize", "emphasis": "emphasize", "!!": "emphasize", "exclamation": "emphasize",
24
+ "question": "question", "?": "question",
25
+ };
26
+
27
+ function mapEmojiToTapback(emoji) {
28
+ return EMOJI_TO_TAPBACK[emoji] || EMOJI_TO_TAPBACK[emoji.toLowerCase()] || null;
29
+ }
30
+
31
+ /**
32
+ * TextClaw iMessage channel plugin for OpenClaw.
33
+ *
34
+ * For BYOC (Bring Your Own Claw) users who subscribe to TextClaw's
35
+ * iMessage relay service. No Mac required — messages route through
36
+ * TextClaw's infrastructure which handles iMessage delivery.
37
+ *
38
+ * Outbound: sends messages, typing indicators, and reactions via TextClaw relay API.
39
+ * Inbound: receives forwarded webhooks from TextClaw via WebSocket or HTTP route.
40
+ */
41
+ const channelPlugin = {
42
+ id: "imessage",
43
+ name: "TextClaw iMessage",
44
+ description: "iMessage channel via TextClaw relay — no Mac required",
45
+
46
+ meta: {
47
+ id: "imessage",
48
+ label: "TextClaw (iMessage)",
49
+ docsPath: "/channels/imessage",
50
+ blurb: "iMessage relay via TextClaw WebSocket bridge.",
51
+ order: 100,
52
+ },
53
+
54
+ capabilities: {
55
+ chatTypes: ["direct"],
56
+ },
57
+
58
+ // -- Config adapter --
59
+ config: {
60
+ listAccountIds(cfg) {
61
+ const pluginCfg = cfg.plugins?.entries?.textclaw?.config;
62
+ return pluginCfg?.textclawUrl ? ["default"] : [];
63
+ },
64
+ resolveAccount(cfg, accountId) {
65
+ return cfg.plugins?.entries?.textclaw?.config ?? {};
66
+ },
67
+ },
68
+
69
+ // -- Gateway adapter --
70
+ gateway: {
71
+ startAccount(account, runtime) {
72
+ api.configure(account.textclawUrl, account.apiKey);
73
+ console.log(`[textclaw] gateway startAccount called — runtime type: ${typeof runtime}`, runtime ? Object.keys(runtime).join(', ') : 'undefined');
74
+ },
75
+
76
+ stopAccount(account) {
77
+ console.log(`[textclaw] gateway account stopped`);
78
+ },
79
+ },
80
+
81
+ // -- Outbound adapter --
82
+ outbound: {
83
+ async sendPayload(account, target, payload) {
84
+ const peer = target.peer;
85
+ const text = payload.text || "";
86
+ const mediaUrl = payload.mediaUrl || null;
87
+
88
+ if (!text && !mediaUrl) return;
89
+
90
+ const chunks = chunkText(text);
91
+
92
+ let lastResult = null;
93
+ for (const chunk of chunks) {
94
+ lastResult = await sendMessageToPeer(account, peer, chunk, {
95
+ mediaUrl: chunks.indexOf(chunk) === 0 ? mediaUrl : null,
96
+ });
97
+ }
98
+
99
+ return lastResult;
100
+ },
101
+
102
+ async sendTypingIndicator(account, target) {
103
+ await sendTyping(account, target.peer);
104
+ },
105
+ },
106
+
107
+ // -- Messaging adapter --
108
+ messaging: {
109
+ normalizeTarget(rawTarget) {
110
+ return {
111
+ peer: rawTarget.replace(/[^+\d]/g, ""),
112
+ chatType: "dm",
113
+ };
114
+ },
115
+
116
+ inferChatType(target) {
117
+ return "dm";
118
+ },
119
+
120
+ getSessionKey(target) {
121
+ return `imessage:${target.peer}`;
122
+ },
123
+ },
124
+
125
+ // -- Actions adapter (OpenClaw message tool integration) --
126
+ actions: {
127
+ describeMessageTool(ctx) {
128
+ return {
129
+ actions: ["react"],
130
+ capabilities: [],
131
+ schema: null,
132
+ };
133
+ },
134
+
135
+ async handleAction({ action, params, cfg, accountId, toolContext }) {
136
+ if (action !== "react") return null;
137
+
138
+ // Resolve peer from session key (format: "imessage:<phone>")
139
+ const sessionKey = toolContext?.sessionKey || "";
140
+ const peer = toolContext?.target?.peer || sessionKey.replace(/^imessage:/, "") || null;
141
+ if (!peer) throw new Error("No peer target available for reaction.");
142
+
143
+ // Map emoji/text to iMessage tapback type
144
+ const emoji = typeof params.emoji === "string" ? params.emoji.trim() : "";
145
+ const reaction = mapEmojiToTapback(emoji);
146
+ if (!reaction) {
147
+ throw new Error(
148
+ `Unsupported reaction "${emoji}". ` +
149
+ `iMessage supports: heart, thumbsup, thumbsdown, laugh, emphasize, question`
150
+ );
151
+ }
152
+
153
+ // Resolve message handle — explicit messageId, toolContext, or last inbound
154
+ const messageHandle = (typeof params.messageId === "string" ? params.messageId : null)
155
+ || toolContext?.currentMessageId
156
+ || getLastInboundHandle(peer)
157
+ || null;
158
+
159
+ await sendReactionToPeer(null, peer, reaction, messageHandle);
160
+
161
+ return {
162
+ content: [{ type: "text", text: JSON.stringify({ ok: true, reaction }) }],
163
+ };
164
+ },
165
+ },
166
+
167
+ // -- Typing config --
168
+ typingMode: "instant",
169
+ typingIntervalMs: 5000,
170
+ maxChunkSize: 4000,
171
+ };
172
+
173
+ export default channelPlugin;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * In-memory store for message_handle (Apple GUID) tracking.
3
+ * Maps peer phone numbers to their most recent message handles,
4
+ * enabling reactions to specific messages.
5
+ */
6
+
7
+ // Map<peerId, { inbound: messageHandle, outbound: messageHandle }>
8
+ const handles = new Map();
9
+
10
+ // Map<messageHandle, { peer, direction, timestamp }>
11
+ const handleIndex = new Map();
12
+
13
+ const MAX_HANDLES = 10000;
14
+
15
+ /**
16
+ * Store a message handle for a peer.
17
+ * @param {string} peer - Phone number (E.164)
18
+ * @param {string} messageHandle - Apple GUID
19
+ * @param {"inbound"|"outbound"} direction
20
+ */
21
+ export function storeHandle(peer, messageHandle, direction) {
22
+ if (!handles.has(peer)) {
23
+ handles.set(peer, {});
24
+ }
25
+ handles.get(peer)[direction] = messageHandle;
26
+
27
+ handleIndex.set(messageHandle, {
28
+ peer,
29
+ direction,
30
+ timestamp: Date.now(),
31
+ });
32
+
33
+ // Evict old entries if index grows too large
34
+ if (handleIndex.size > MAX_HANDLES) {
35
+ const entries = [...handleIndex.entries()];
36
+ entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
37
+ for (let i = 0; i < entries.length - MAX_HANDLES; i++) {
38
+ handleIndex.delete(entries[i][0]);
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Get the most recent inbound message handle for a peer.
45
+ * @param {string} peer - Phone number
46
+ * @returns {string|null}
47
+ */
48
+ export function getLastInboundHandle(peer) {
49
+ return handles.get(peer)?.inbound || null;
50
+ }
51
+
52
+ /**
53
+ * Get the most recent outbound message handle for a peer.
54
+ * @param {string} peer - Phone number
55
+ * @returns {string|null}
56
+ */
57
+ export function getLastOutboundHandle(peer) {
58
+ return handles.get(peer)?.outbound || null;
59
+ }
60
+
61
+ /**
62
+ * Look up metadata for a specific message handle.
63
+ * @param {string} messageHandle
64
+ * @returns {{ peer: string, direction: string, timestamp: number }|null}
65
+ */
66
+ export function lookupHandle(messageHandle) {
67
+ return handleIndex.get(messageHandle) || null;
68
+ }
package/src/media.js ADDED
@@ -0,0 +1,53 @@
1
+ import { createWriteStream, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { randomUUID } from "crypto";
4
+ import { homedir } from "os";
5
+ import { pipeline } from "stream/promises";
6
+
7
+ const MEDIA_DIR = join(homedir(), ".openclaw", "media", "inbound");
8
+
9
+ /**
10
+ * Download a remote media file and save it to OpenClaw's local media directory.
11
+ *
12
+ * OpenClaw's media pipeline expects local files in ~/.openclaw/media/inbound/,
13
+ * matching how the Discord plugin handles attachments.
14
+ *
15
+ * @param {string} url - Remote URL to download
16
+ * @returns {{ path: string, contentType: string }}
17
+ */
18
+ export async function saveRemoteMedia(url) {
19
+ mkdirSync(MEDIA_DIR, { recursive: true });
20
+
21
+ // Detect extension from URL, default to .ogg (voice notes after backend conversion)
22
+ const urlPath = new URL(url).pathname;
23
+ const dotIdx = urlPath.lastIndexOf(".");
24
+ const ext = dotIdx !== -1 ? urlPath.slice(dotIdx) : ".ogg";
25
+
26
+ const filename = `${randomUUID()}${ext}`;
27
+ const localPath = join(MEDIA_DIR, filename);
28
+
29
+ const resp = await fetch(url);
30
+ if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
31
+
32
+ const fileStream = createWriteStream(localPath);
33
+ await pipeline(resp.body, fileStream);
34
+
35
+ // Determine MIME type from extension
36
+ const mimeMap = {
37
+ ".ogg": "audio/ogg; codecs=opus",
38
+ ".mp3": "audio/mpeg",
39
+ ".wav": "audio/wav",
40
+ ".m4a": "audio/mp4",
41
+ ".flac": "audio/flac",
42
+ ".webm": "audio/webm",
43
+ ".mp4": "video/mp4",
44
+ ".png": "image/png",
45
+ ".jpg": "image/jpeg",
46
+ ".jpeg": "image/jpeg",
47
+ ".gif": "image/gif",
48
+ ".webp": "image/webp",
49
+ };
50
+ const contentType = mimeMap[ext.toLowerCase()] || "application/octet-stream";
51
+
52
+ return { path: localPath, contentType };
53
+ }
package/src/monitor.js ADDED
@@ -0,0 +1,182 @@
1
+ import { recordInboundSessionAndDispatchReply } from "openclaw/plugin-sdk/compat";
2
+ import { storeHandle } from "./handle-store.js";
3
+ import { getPluginApi } from "./runtime.js";
4
+ import { sendMessageToPeer, sendTyping, chunkText } from "./send.js";
5
+ import { markRead } from "./api.js";
6
+ import { saveRemoteMedia } from "./media.js";
7
+
8
+ // Per-peer buffer for aggregating response blocks before sending
9
+ const deliverBuffers = new Map();
10
+ const DELIVER_DEBOUNCE_MS = 1500;
11
+
12
+ // Per-peer typing interval — ensures only one interval per peer at a time
13
+ const typingIntervals = new Map();
14
+
15
+ /**
16
+ * Handle an inbound webhook forwarded from TextClaw.
17
+ *
18
+ * TextClaw receives inbound message webhooks and forwards them either
19
+ * via WebSocket or HTTP POST to /ext/textclaw/inbound.
20
+ *
21
+ * @param {object} payload - Inbound message payload (forwarded by TextClaw)
22
+ */
23
+ export async function processInboundWebhook(payload) {
24
+ const eventType = payload.type || detectEventType(payload);
25
+
26
+ switch (eventType) {
27
+ case "receive":
28
+ return handleInboundMessage(payload);
29
+ case "typing_indicator":
30
+ console.log(`[textclaw] inbound typing from ${payload.from_number || payload.number}`);
31
+ return;
32
+ case "reaction":
33
+ console.log(`[textclaw] inbound reaction from ${payload.from_number || payload.number}`);
34
+ return;
35
+ default:
36
+ console.log(`[textclaw] ignoring webhook event type: ${eventType}`);
37
+ }
38
+ }
39
+
40
+ function detectEventType(payload) {
41
+ if (payload.content !== undefined || payload.media_url) return "receive";
42
+ if (payload.reaction) return "reaction";
43
+ if (payload.is_typing !== undefined) return "typing_indicator";
44
+ return "unknown";
45
+ }
46
+
47
+ /**
48
+ * Flush buffered response blocks for a peer as a single combined message.
49
+ */
50
+ async function flushDeliverBuffer(account, peer) {
51
+ const buf = deliverBuffers.get(peer);
52
+ if (!buf) return;
53
+ deliverBuffers.delete(peer);
54
+
55
+ const combined = buf.texts.join("\n\n");
56
+ const mediaUrl = buf.media;
57
+
58
+ if (combined || mediaUrl) {
59
+ const chunks = chunkText(combined);
60
+ for (const chunk of chunks) {
61
+ await sendMessageToPeer(account, peer, chunk, {
62
+ mediaUrl: chunks.indexOf(chunk) === 0 ? mediaUrl : null,
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ async function handleInboundMessage(payload) {
69
+ const api = getPluginApi();
70
+ const runtime = api.runtime;
71
+ const cfg = api.config;
72
+ const account = api.pluginConfig;
73
+
74
+ const peer = payload.from_number || payload.number;
75
+ const messageHandle = payload.message_handle;
76
+ const text = payload.content || "";
77
+ const mediaUrl = payload.media_url || undefined;
78
+
79
+ if (messageHandle) {
80
+ storeHandle(peer, messageHandle, "inbound");
81
+ }
82
+
83
+ // Download media to local file (OpenClaw expects local paths, not URLs)
84
+ let mediaPayload = {};
85
+ if (mediaUrl) {
86
+ try {
87
+ const { path, contentType } = await saveRemoteMedia(mediaUrl);
88
+ mediaPayload = {
89
+ MediaPath: path,
90
+ MediaType: contentType,
91
+ MediaUrl: path,
92
+ MediaPaths: [path],
93
+ MediaUrls: [path],
94
+ MediaTypes: [contentType],
95
+ };
96
+ console.log(`[textclaw] saved media to ${path}`);
97
+ } catch (err) {
98
+ console.warn(`[textclaw] media download failed: ${err.message}`);
99
+ }
100
+ }
101
+
102
+ const sessionKey = `imessage:${peer}`;
103
+
104
+ console.log(`[textclaw] dispatching inbound from ${peer}: "${text.slice(0, 50)}${text.length > 50 ? '...' : ''}"`);
105
+
106
+ // Send read receipt so iMessage shows "Read" to the sender
107
+ markRead(peer).catch((err) => {
108
+ console.warn(`[textclaw] mark-read failed for ${peer}: ${err.message}`);
109
+ });
110
+
111
+ // Clear any existing typing interval for this peer (prevents leaks from rapid messages)
112
+ if (typingIntervals.has(peer)) {
113
+ clearInterval(typingIntervals.get(peer));
114
+ }
115
+
116
+ // Send repeating typing indicator while the agent is thinking (every 3s)
117
+ sendTyping(account, peer);
118
+ const typingInterval = setInterval(() => sendTyping(account, peer), 3000);
119
+ typingIntervals.set(peer, typingInterval);
120
+
121
+ function stopTyping() {
122
+ clearInterval(typingInterval);
123
+ if (typingIntervals.get(peer) === typingInterval) {
124
+ typingIntervals.delete(peer);
125
+ }
126
+ }
127
+
128
+ try {
129
+ await recordInboundSessionAndDispatchReply({
130
+ cfg,
131
+ channel: "imessage",
132
+ accountId: "default",
133
+ agentId: "main",
134
+ routeSessionKey: sessionKey,
135
+ storePath: runtime.state.resolveStateDir() + "/sessions/imessage-sessions.json",
136
+ ctxPayload: {
137
+ Body: text,
138
+ ...mediaPayload,
139
+ From: `imessage:${peer}`,
140
+ To: "imessage:default",
141
+ SessionKey: sessionKey,
142
+ AccountId: "default",
143
+ ChatType: "direct",
144
+ SenderName: peer,
145
+ SenderId: peer,
146
+ Provider: "imessage",
147
+ Surface: "imessage",
148
+ WasMentioned: true,
149
+ MessageSid: messageHandle || "",
150
+ Timestamp: payload.date_sent || new Date().toISOString(),
151
+ },
152
+ recordInboundSession: runtime.channel.session.recordInboundSession,
153
+ dispatchReplyWithBufferedBlockDispatcher: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
154
+ deliver: async (replyPayload) => {
155
+ stopTyping();
156
+ const replyText = replyPayload.Body || replyPayload.text || "";
157
+ const replyMedia = replyPayload.MediaUrl || replyPayload.mediaUrl || null;
158
+
159
+ // Buffer response blocks and debounce — send combined after 1.5s of no new blocks
160
+ if (!deliverBuffers.has(peer)) {
161
+ deliverBuffers.set(peer, { texts: [], media: null, timer: null });
162
+ }
163
+ const buf = deliverBuffers.get(peer);
164
+ if (replyText) buf.texts.push(replyText);
165
+ if (replyMedia) buf.media = replyMedia;
166
+
167
+ clearTimeout(buf.timer);
168
+ buf.timer = setTimeout(() => {
169
+ flushDeliverBuffer(account, peer).catch((err) => {
170
+ console.error("[textclaw] deliver flush error:", err);
171
+ });
172
+ }, DELIVER_DEBOUNCE_MS);
173
+ },
174
+ onRecordError: (err) => console.error("[textclaw] session record error:", err),
175
+ onDispatchError: (err) => console.error("[textclaw] dispatch error:", err),
176
+ replyOptions: {},
177
+ });
178
+ } catch (err) {
179
+ stopTyping();
180
+ console.error("[textclaw] inbound dispatch failed:", err);
181
+ }
182
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,10 @@
1
+ let pluginApi = null;
2
+
3
+ export function setPluginApi(api) {
4
+ pluginApi = api;
5
+ }
6
+
7
+ export function getPluginApi() {
8
+ if (!pluginApi) throw new Error("[textclaw] pluginApi not initialized");
9
+ return pluginApi;
10
+ }
package/src/send.js ADDED
@@ -0,0 +1,110 @@
1
+ import * as api from "./api.js";
2
+ import { storeHandle, getLastInboundHandle } from "./handle-store.js";
3
+
4
+ /**
5
+ * Send a message to a peer via TextClaw relay.
6
+ * @param {object} account - Resolved account config
7
+ * @param {string} peer - Recipient phone number (E.164)
8
+ * @param {string} text - Message content
9
+ * @param {object} [options]
10
+ * @param {string} [options.mediaUrl] - Media attachment URL
11
+ * @returns {Promise<{ messageId: string }>}
12
+ */
13
+ export async function sendMessageToPeer(account, peer, text, options = {}) {
14
+ const result = await api.sendMessage(
15
+ null, // fromNumber not needed — TextClaw uses shared BYOC number
16
+ peer,
17
+ text,
18
+ options.mediaUrl || null,
19
+ );
20
+
21
+ // Store the message_handle for future reactions
22
+ if (result.message_handle) {
23
+ storeHandle(peer, result.message_handle, "outbound");
24
+ }
25
+
26
+ return {
27
+ messageId: result.message_handle || result.external_message_id || "",
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Send a voice note to a peer via TextClaw relay.
33
+ * The server converts the audio to .caf for inline iMessage playback.
34
+ * @param {object} account - Resolved account config
35
+ * @param {string} peer - Recipient phone number (E.164)
36
+ * @param {string} mediaUrl - URL to an audio file
37
+ * @returns {Promise<{ messageId: string }>}
38
+ */
39
+ export async function sendVoiceNoteToPeer(account, peer, mediaUrl) {
40
+ const result = await api.sendVoiceNote(null, peer, mediaUrl);
41
+
42
+ if (result.message_handle) {
43
+ storeHandle(peer, result.message_handle, "outbound");
44
+ }
45
+
46
+ return {
47
+ messageId: result.message_handle || result.external_message_id || "",
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Send typing indicator to a peer.
53
+ * @param {object} account - Resolved account config
54
+ * @param {string} peer - Recipient phone number (E.164)
55
+ */
56
+ export async function sendTyping(account, peer) {
57
+ try {
58
+ await api.sendTypingIndicator(null, peer);
59
+ } catch (err) {
60
+ // Typing indicators are best-effort
61
+ console.warn(`[textclaw] typing indicator failed for ${peer}: ${err.message}`);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Send a reaction (tapback) to the last inbound message from a peer.
67
+ * @param {object} account - Resolved account config
68
+ * @param {string} peer - Phone number of the conversation
69
+ * @param {"heart"|"thumbsup"|"thumbsdown"|"laugh"|"emphasize"|"question"} reaction
70
+ * @param {string} [messageHandle] - Specific message to react to (defaults to last inbound)
71
+ */
72
+ export async function sendReactionToPeer(account, peer, reaction, messageHandle = null) {
73
+ const handle = messageHandle || getLastInboundHandle(peer);
74
+ if (!handle) {
75
+ console.warn(`[textclaw] no message handle found for ${peer}, cannot send reaction`);
76
+ return;
77
+ }
78
+
79
+ await api.sendReaction(null, handle, reaction);
80
+ }
81
+
82
+ /**
83
+ * Chunk text into iMessage-friendly segments.
84
+ * @param {string} text
85
+ * @param {number} [maxLen=4000]
86
+ * @returns {string[]}
87
+ */
88
+ export function chunkText(text, maxLen = 4000) {
89
+ if (text.length <= maxLen) return [text];
90
+
91
+ const chunks = [];
92
+ let remaining = text;
93
+ while (remaining.length > 0) {
94
+ if (remaining.length <= maxLen) {
95
+ chunks.push(remaining);
96
+ break;
97
+ }
98
+ // Try to break at a newline or space
99
+ let breakAt = remaining.lastIndexOf("\n", maxLen);
100
+ if (breakAt < maxLen * 0.5) {
101
+ breakAt = remaining.lastIndexOf(" ", maxLen);
102
+ }
103
+ if (breakAt < maxLen * 0.5) {
104
+ breakAt = maxLen;
105
+ }
106
+ chunks.push(remaining.slice(0, breakAt));
107
+ remaining = remaining.slice(breakAt).trimStart();
108
+ }
109
+ return chunks;
110
+ }
package/src/ws.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * WebSocket client for receiving inbound iMessages from TextClaw.
3
+ *
4
+ * Instead of requiring a public URL for webhook delivery, the extension
5
+ * maintains a persistent WebSocket connection to TextClaw. Messages are
6
+ * pushed through this socket in real time — like Discord bot gateway.
7
+ */
8
+
9
+ import { processInboundWebhook } from "./monitor.js";
10
+
11
+ let ws = null;
12
+ let wsUrl = null;
13
+ let reconnectDelay = 1000;
14
+ let reconnectTimer = null;
15
+ let pongTimer = null;
16
+
17
+ const MAX_RECONNECT_DELAY = 30000;
18
+ const PONG_INTERVAL = 30000;
19
+
20
+ /**
21
+ * Start the WebSocket connection to TextClaw.
22
+ * @param {string} url - WebSocket URL with api_key query param
23
+ */
24
+ export function startWebSocket(url) {
25
+ if (ws) return; // Already connected or connecting — prevent duplicates
26
+ wsUrl = url;
27
+ connect();
28
+ }
29
+
30
+ /**
31
+ * Stop the WebSocket connection and clean up timers.
32
+ */
33
+ export function stopWebSocket() {
34
+ if (reconnectTimer) clearTimeout(reconnectTimer);
35
+ if (pongTimer) clearInterval(pongTimer);
36
+ if (ws) {
37
+ ws.close(1000, "stopping");
38
+ ws = null;
39
+ }
40
+ }
41
+
42
+ function connect() {
43
+ const maskedUrl = wsUrl.replace(/api_key=[^&]+/, "api_key=***");
44
+ console.log(`[textclaw] WebSocket connecting to ${maskedUrl}`);
45
+
46
+ ws = new WebSocket(wsUrl);
47
+
48
+ ws.addEventListener("open", () => {
49
+ console.log("[textclaw] WebSocket connected");
50
+ reconnectDelay = 1000;
51
+ startPong();
52
+ });
53
+
54
+ ws.addEventListener("message", (event) => {
55
+ try {
56
+ const data = JSON.parse(event.data);
57
+ handleMessage(data);
58
+ } catch (err) {
59
+ console.error("[textclaw] WebSocket message parse error:", err);
60
+ }
61
+ });
62
+
63
+ ws.addEventListener("close", (event) => {
64
+ console.log(`[textclaw] WebSocket closed: code=${event.code} reason=${event.reason}`);
65
+ stopPong();
66
+ ws = null;
67
+ // 4010 = replaced by newer connection — don't reconnect
68
+ if (event.code === 4010) {
69
+ console.log("[textclaw] WebSocket replaced by newer connection, not reconnecting");
70
+ return;
71
+ }
72
+ scheduleReconnect();
73
+ });
74
+
75
+ ws.addEventListener("error", (err) => {
76
+ console.error("[textclaw] WebSocket error:", err.message || err);
77
+ // If connection failed before opening, close event may not fire — ensure reconnect
78
+ if (ws && ws.readyState !== WebSocket.OPEN && ws.readyState !== WebSocket.CONNECTING) {
79
+ ws = null;
80
+ scheduleReconnect();
81
+ }
82
+ });
83
+ }
84
+
85
+ function handleMessage(data) {
86
+ switch (data.type) {
87
+ case "connection_ack":
88
+ console.log(`[textclaw] WebSocket authenticated, account=${data.account_id}`);
89
+ break;
90
+ case "inbound_message":
91
+ processInboundWebhook(data.payload).catch((err) => {
92
+ console.error("[textclaw] inbound message processing error:", err);
93
+ });
94
+ break;
95
+ case "ping":
96
+ if (ws && ws.readyState === WebSocket.OPEN) {
97
+ ws.send(JSON.stringify({ type: "pong" }));
98
+ }
99
+ break;
100
+ default:
101
+ console.log(`[textclaw] Unknown WebSocket message type: ${data.type}`);
102
+ }
103
+ }
104
+
105
+ function scheduleReconnect() {
106
+ const jitter = reconnectDelay * 0.25 * (Math.random() * 2 - 1);
107
+ const delay = Math.min(reconnectDelay + jitter, MAX_RECONNECT_DELAY);
108
+
109
+ console.log(`[textclaw] Reconnecting in ${Math.round(delay)}ms`);
110
+ reconnectTimer = setTimeout(() => {
111
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
112
+ connect();
113
+ }, delay);
114
+ }
115
+
116
+ function startPong() {
117
+ pongTimer = setInterval(() => {
118
+ if (ws && ws.readyState === WebSocket.OPEN) {
119
+ ws.send(JSON.stringify({ type: "pong" }));
120
+ }
121
+ }, PONG_INTERVAL);
122
+ }
123
+
124
+ function stopPong() {
125
+ if (pongTimer) {
126
+ clearInterval(pongTimer);
127
+ pongTimer = null;
128
+ }
129
+ }