@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 +21 -0
- package/README.md +77 -0
- package/index.js +119 -0
- package/openclaw.plugin.json +35 -0
- package/package.json +50 -0
- package/skills/textclaw/SKILL.md +79 -0
- package/src/api.js +140 -0
- package/src/channel.js +173 -0
- package/src/handle-store.js +68 -0
- package/src/media.js +53 -0
- package/src/monitor.js +182 -0
- package/src/runtime.js +10 -0
- package/src/send.js +110 -0
- package/src/ws.js +129 -0
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
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
|
+
}
|