@taptapai/taptapai-openclaw 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -0
- package/index.ts +4 -0
- package/openclaw.plugin.json +80 -0
- package/package.json +27 -0
- package/src/backendWs.ts +252 -0
- package/src/bridgeLock.ts +89 -0
- package/src/constants.ts +14 -0
- package/src/emitArtifacts.ts +46 -0
- package/src/gatewayHttp.ts +65 -0
- package/src/gatewayWs.ts +380 -0
- package/src/loggerFilter.ts +35 -0
- package/src/logging.ts +38 -0
- package/src/openclawConfig.ts +129 -0
- package/src/paths.ts +39 -0
- package/src/plugin.ts +360 -0
- package/src/qr.ts +121 -0
- package/src/relayFallback.ts +103 -0
- package/src/requestHandler.ts +366 -0
- package/src/sanitize.ts +57 -0
- package/src/secrets.ts +93 -0
- package/src/sessions.ts +117 -0
- package/src/setup.ts +134 -0
- package/src/types.ts +36 -0
- package/src/urls.ts +41 -0
- package/src/websocketFactory.ts +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# TapTapAI OpenClaw Plugin
|
|
2
|
+
|
|
3
|
+
TapTapAI plugin for OpenClaw with outbound backend connectivity.
|
|
4
|
+
|
|
5
|
+
- Primary: `wss://.../ws/openclaw/v2`
|
|
6
|
+
- Fallback: `https://.../relay/*`
|
|
7
|
+
|
|
8
|
+
There are **no chat setup commands**.
|
|
9
|
+
|
|
10
|
+
Plugin ID: `taptapai-openclaw`
|
|
11
|
+
|
|
12
|
+
Setup/pairing is done via the `taptapai` CLI (recommended) or by manually editing OpenClaw config.
|
|
13
|
+
|
|
14
|
+
## Recommended setup (terminal)
|
|
15
|
+
|
|
16
|
+
On the OpenClaw host:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
export TAPTAPAI_BACKEND_WS_URL=wss://your-backend.example.com/ws/openclaw/v2
|
|
20
|
+
export TAPTAPAI_RELAY_URL=https://your-backend.example.com
|
|
21
|
+
|
|
22
|
+
taptapai setup
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Manual setup (advanced)
|
|
26
|
+
|
|
27
|
+
### 1) Enable plugin
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
openclaw plugins enable taptapai-openclaw
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 2) Install plugin files/deps (if needed)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
mkdir -p ~/.openclaw/extensions
|
|
37
|
+
cp -r openclaw-plugin ~/.openclaw/extensions/taptapai
|
|
38
|
+
cd ~/.openclaw/extensions/taptapai && npm install --omit=dev
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 3) Configure (no channels)
|
|
42
|
+
|
|
43
|
+
Use plugin config under `plugins.entries.taptapai-openclaw.config`:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Required
|
|
47
|
+
openclaw config set plugins.entries.taptapai-openclaw.config.backendWsUrl "wss://your-backend.example.com/ws/openclaw/v2"
|
|
48
|
+
openclaw config set plugins.entries.taptapai-openclaw.config.relayUrl "https://your-backend.example.com"
|
|
49
|
+
|
|
50
|
+
# Optional (first run only). The plugin clears it after successful setup.
|
|
51
|
+
openclaw config set plugins.entries.taptapai-openclaw.config.setupPassword "choose-a-strong-password"
|
|
52
|
+
|
|
53
|
+
# Optional
|
|
54
|
+
openclaw config set plugins.entries.taptapai-openclaw.config.pollIntervalMs 1000
|
|
55
|
+
openclaw config set plugins.entries.taptapai-openclaw.config.logLevel "info"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
After restarting the gateway, the plugin writes pairing artifacts here:
|
|
59
|
+
|
|
60
|
+
- `~/.openclaw/taptapai/pairing/pairing.latest.json`
|
|
61
|
+
- `~/.openclaw/taptapai/pairing/pairing.token.txt`
|
|
62
|
+
- `~/.openclaw/taptapai/pairing/pairing.qr.payload.txt`
|
|
63
|
+
- `~/.openclaw/taptapai/pairing/pairing.qr.terminal.txt` (ASCII QR)
|
|
64
|
+
- `~/.openclaw/taptapai/pairing/pairing.qr.png` (best-effort)
|
|
65
|
+
|
|
66
|
+
### 4) Restart gateway
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
systemctl --user restart openclaw-gateway.service
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 5) Pair iOS using logs
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
journalctl --user -u openclaw-gateway.service -n 200 --no-pager | grep -E "Pair token:|QR payload:"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use the emitted token (or QR payload) in the iOS app.
|
|
79
|
+
|
|
80
|
+
After successful setup, `setupPassword` is removed from config automatically.
|
|
81
|
+
|
|
82
|
+
## Rotation
|
|
83
|
+
|
|
84
|
+
To rotate (new token; old token becomes invalid), use:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
taptapai rotate-credential
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This prompts for the original setup password, verifies it, and then increments N.
|
|
91
|
+
|
|
92
|
+
### Manual rotation (advanced)
|
|
93
|
+
|
|
94
|
+
If you are not using the CLI, the plugin can still bootstrap/rotate from a one-time `setupPassword`:
|
|
95
|
+
|
|
96
|
+
1) Set a new one-time password:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
openclaw config set plugins.entries.taptapai-openclaw.config.setupPassword "new-strong-password"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
2) Restart the gateway.
|
|
103
|
+
|
|
104
|
+
The plugin will generate a new token (increments N), overwrite the cached secret, update config, and rewrite pairing artifacts.
|
|
105
|
+
|
|
106
|
+
## Show current token/QR (no rotation)
|
|
107
|
+
|
|
108
|
+
If you just need the current token + QR again, run `taptapai setup` (it prints the current token/QR without rotating).
|
|
109
|
+
|
|
110
|
+
Or manually:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
openclaw config set plugins.entries.taptapai-openclaw.config.emitPairingArtifacts true
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Restart the gateway; the plugin rewrites the pairing artifacts and clears the flag.
|
|
117
|
+
|
|
118
|
+
## Configuration
|
|
119
|
+
|
|
120
|
+
| Option | Required | Description |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `backendWsUrl` | Yes | Backend WS URL (`wss://.../ws/openclaw/v2`) |
|
|
123
|
+
| `relayUrl` | Yes | Relay fallback URL (`https://...`) |
|
|
124
|
+
| `pollIntervalMs` | No | Relay poll interval (ms) |
|
|
125
|
+
| `setupPassword` | First run | One-time password used to derive token; auto-cleared |
|
|
126
|
+
| `wsToken` / `token` | No | Auto-managed after setup |
|
|
127
|
+
| `emitPairingArtifacts` | No | If true, write pairing artifacts and clear the flag |
|
|
128
|
+
| `emitPairingArtifactsIncludeDataUrl` | No | If true, also write QR data URL artifact |
|
|
129
|
+
| `logLevel` | No | `error`/`warn`/`info`/`debug` |
|
|
130
|
+
|
|
131
|
+
## Verification
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
journalctl --user -u openclaw-gateway.service -n 200 --no-pager | grep -i "taptapai\|WS connected\|Pair token"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Troubleshooting
|
|
138
|
+
|
|
139
|
+
- If setup did not run, prefer `taptapai setup`. If doing manual setup, ensure `setupPassword` is set and restart gateway.
|
|
140
|
+
- If backend WS does not connect, ensure `backendWsUrl` is `wss://`.
|
|
141
|
+
- If relay is used, ensure `relayUrl` is `https://`.
|
package/index.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "taptapai-openclaw",
|
|
3
|
+
"name": "TapTapAI",
|
|
4
|
+
"description": "TapTapAI gateway proxy",
|
|
5
|
+
"configSchema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": true,
|
|
8
|
+
"properties": {
|
|
9
|
+
"enabled": {
|
|
10
|
+
"type": "boolean",
|
|
11
|
+
"description": "Enable or disable the plugin"
|
|
12
|
+
},
|
|
13
|
+
"backendWsUrl": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "WebSocket URL to the TapTapAI backend (e.g., wss://taptapai.example.com/ws/openclaw/v2). Required."
|
|
16
|
+
},
|
|
17
|
+
"wsToken": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Shared secret for WebSocket authentication handshake"
|
|
20
|
+
},
|
|
21
|
+
"relayUrl": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Fallback HTTP relay URL (e.g., https://taptapai.example.com). Used when WebSocket is unavailable. Required."
|
|
24
|
+
},
|
|
25
|
+
"token": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Pairing token for the HTTP relay fallback"
|
|
28
|
+
},
|
|
29
|
+
"pollIntervalMs": {
|
|
30
|
+
"type": "number",
|
|
31
|
+
"description": "Polling interval in milliseconds for HTTP relay fallback (default: 1000)"
|
|
32
|
+
},
|
|
33
|
+
"setupPassword": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "One-time setup password used on first startup to derive wsToken/token. Removed from config after successful setup."
|
|
36
|
+
},
|
|
37
|
+
"emitPairingArtifacts": {
|
|
38
|
+
"type": "boolean",
|
|
39
|
+
"description": "If true, the gateway process writes token + QR artifacts to ~/.openclaw/taptapai/pairing/ and then clears this flag."
|
|
40
|
+
},
|
|
41
|
+
"emitPairingArtifactsIncludeDataUrl": {
|
|
42
|
+
"type": "boolean",
|
|
43
|
+
"description": "If true, also write the QR data URL (pairing.qr.dataurl.txt). This can be sensitive and large; defaults to false."
|
|
44
|
+
},
|
|
45
|
+
"logLevel": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Plugin log verbosity in the gateway process.",
|
|
48
|
+
"enum": ["error", "warn", "info", "debug"],
|
|
49
|
+
"default": "info"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"uiHints": {
|
|
54
|
+
"setupPassword": {
|
|
55
|
+
"label": "Setup password (one-time)",
|
|
56
|
+
"sensitive": true,
|
|
57
|
+
"placeholder": "Choose a strong password"
|
|
58
|
+
},
|
|
59
|
+
"backendWsUrl": {
|
|
60
|
+
"label": "Backend WS URL",
|
|
61
|
+
"placeholder": "wss://.../ws/openclaw/v2"
|
|
62
|
+
},
|
|
63
|
+
"relayUrl": {
|
|
64
|
+
"label": "Relay URL (fallback)",
|
|
65
|
+
"placeholder": "https://..."
|
|
66
|
+
},
|
|
67
|
+
"emitPairingArtifacts": {
|
|
68
|
+
"label": "Write pairing artifacts",
|
|
69
|
+
"placeholder": "true"
|
|
70
|
+
},
|
|
71
|
+
"emitPairingArtifactsIncludeDataUrl": {
|
|
72
|
+
"label": "Include QR data URL",
|
|
73
|
+
"placeholder": "false"
|
|
74
|
+
},
|
|
75
|
+
"logLevel": {
|
|
76
|
+
"label": "Log level",
|
|
77
|
+
"placeholder": "info"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@taptapai/taptapai-openclaw",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "TapTapAI gateway proxy plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"openclaw": {
|
|
7
|
+
"extensions": ["./index.ts"]
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.ts",
|
|
11
|
+
"src/",
|
|
12
|
+
"openclaw.plugin.json",
|
|
13
|
+
"README.md",
|
|
14
|
+
"package.json"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"qrcode": "^1.5.4",
|
|
21
|
+
"ws": "^8.18.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.13.1",
|
|
25
|
+
"@types/qrcode": "^1.5.5"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/backendWs.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { LoggerLike } from "./types";
|
|
2
|
+
import type { RpcRequest } from "./types";
|
|
3
|
+
import { MAX_RECONNECT_DELAY_MS } from "./constants";
|
|
4
|
+
import { sha256HexPrefix } from "./secrets";
|
|
5
|
+
import { getWebSocketCtor } from "./websocketFactory";
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
|
|
10
|
+
let _pluginVersion = "";
|
|
11
|
+
function getPluginVersion(): string {
|
|
12
|
+
if (_pluginVersion) return _pluginVersion;
|
|
13
|
+
try {
|
|
14
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const pkg = JSON.parse(readFileSync(join(dir, "..", "package.json"), "utf8"));
|
|
16
|
+
_pluginVersion = pkg.version || "0.0.0";
|
|
17
|
+
} catch {
|
|
18
|
+
_pluginVersion = "0.0.0";
|
|
19
|
+
}
|
|
20
|
+
return _pluginVersion;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type BackendWsState = {
|
|
24
|
+
ws: WebSocket | null;
|
|
25
|
+
connected: boolean;
|
|
26
|
+
connecting: boolean;
|
|
27
|
+
currentUrl: string;
|
|
28
|
+
currentTokenHash: string;
|
|
29
|
+
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
|
30
|
+
reconnectAttempt: number;
|
|
31
|
+
watchdogTimer: ReturnType<typeof setInterval> | null;
|
|
32
|
+
pingTimer: ReturnType<typeof setInterval> | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function connectBackendWs(params: {
|
|
36
|
+
state: BackendWsState;
|
|
37
|
+
logger: LoggerLike;
|
|
38
|
+
url: string;
|
|
39
|
+
token: string;
|
|
40
|
+
runtime: any;
|
|
41
|
+
onRequest: (req: RpcRequest) => Promise<void>;
|
|
42
|
+
aborted: () => boolean;
|
|
43
|
+
enabled: () => boolean;
|
|
44
|
+
}): void {
|
|
45
|
+
const { state, logger, url, token, runtime, onRequest, aborted, enabled } = params;
|
|
46
|
+
if (!enabled()) return;
|
|
47
|
+
if (aborted()) return;
|
|
48
|
+
|
|
49
|
+
const normalizedUrl = String(url || "").trim();
|
|
50
|
+
const normalizedToken = String(token || "").trim();
|
|
51
|
+
const tokenHash = normalizedToken ? sha256HexPrefix(normalizedToken, 16) : "";
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
state.ws &&
|
|
55
|
+
(state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING) &&
|
|
56
|
+
state.currentUrl === normalizedUrl &&
|
|
57
|
+
state.currentTokenHash === tokenHash
|
|
58
|
+
) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (state.ws) {
|
|
63
|
+
try { state.ws.close(); } catch {}
|
|
64
|
+
state.ws = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
state.connected = false;
|
|
68
|
+
state.connecting = true;
|
|
69
|
+
state.currentUrl = normalizedUrl;
|
|
70
|
+
state.currentTokenHash = tokenHash;
|
|
71
|
+
|
|
72
|
+
logger.info?.(`[taptapai] Connecting WS to ${normalizedUrl}...`);
|
|
73
|
+
|
|
74
|
+
let ws: WebSocket;
|
|
75
|
+
try {
|
|
76
|
+
const WS = getWebSocketCtor(logger);
|
|
77
|
+
ws = new WS(normalizedUrl) as any;
|
|
78
|
+
} catch (e: any) {
|
|
79
|
+
const msg = e?.message || String(e);
|
|
80
|
+
logger.error?.(`[taptapai] WebSocket constructor error: ${msg}`);
|
|
81
|
+
state.connecting = false;
|
|
82
|
+
// If WebSocket isn't available, reconnecting won't help.
|
|
83
|
+
if (/WebSocket is not available/i.test(msg)) return;
|
|
84
|
+
scheduleReconnect({ ...params, url: normalizedUrl, token: normalizedToken });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
state.ws = ws;
|
|
89
|
+
|
|
90
|
+
ws.addEventListener("open", () => {
|
|
91
|
+
if (state.ws !== ws) return;
|
|
92
|
+
try {
|
|
93
|
+
logger.info?.("[taptapai] WS open, sending handshake...");
|
|
94
|
+
state.reconnectAttempt = 0;
|
|
95
|
+
ws.send(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
type: "handshake",
|
|
98
|
+
auth: { token: normalizedToken },
|
|
99
|
+
info: {
|
|
100
|
+
plugin: "taptapai",
|
|
101
|
+
version: getPluginVersion(),
|
|
102
|
+
openclaw_version: runtime?.version ?? "unknown",
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
} catch (e: any) {
|
|
107
|
+
logger.error?.(`[taptapai] WS open handler error: ${e?.message || e}`);
|
|
108
|
+
state.connected = false;
|
|
109
|
+
state.connecting = false;
|
|
110
|
+
scheduleReconnect({ ...params, url: normalizedUrl, token: normalizedToken });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
ws.addEventListener("message", async (event: MessageEvent) => {
|
|
115
|
+
if (state.ws !== ws) return;
|
|
116
|
+
const raw = typeof event.data === "string" ? event.data : (event.data as any).toString();
|
|
117
|
+
try {
|
|
118
|
+
const msg = JSON.parse(raw);
|
|
119
|
+
|
|
120
|
+
if (msg.type === "welcome") {
|
|
121
|
+
if (state.ws !== ws) return;
|
|
122
|
+
state.connected = true;
|
|
123
|
+
state.connecting = false;
|
|
124
|
+
if (state.reconnectTimer) {
|
|
125
|
+
try { clearTimeout(state.reconnectTimer); } catch {}
|
|
126
|
+
state.reconnectTimer = null;
|
|
127
|
+
}
|
|
128
|
+
logger.info?.("[taptapai] ✅ WS connected and authenticated");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (msg.type === "pong") return;
|
|
133
|
+
|
|
134
|
+
if (msg.id && msg.method) {
|
|
135
|
+
await onRequest(msg as RpcRequest);
|
|
136
|
+
}
|
|
137
|
+
} catch (e: any) {
|
|
138
|
+
logger.error?.(`[taptapai] WS message error: ${e?.message || e}`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
ws.addEventListener("close", (event: CloseEvent) => {
|
|
143
|
+
if (state.ws !== ws) return;
|
|
144
|
+
logger.info?.(`[taptapai] WS closed: ${event.code} ${event.reason}`);
|
|
145
|
+
state.connected = false;
|
|
146
|
+
state.connecting = false;
|
|
147
|
+
state.ws = null;
|
|
148
|
+
scheduleReconnect({ ...params, url: normalizedUrl, token: normalizedToken });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
ws.addEventListener("error", (event: any) => {
|
|
152
|
+
if (state.ws !== ws) return;
|
|
153
|
+
const msg = event?.message || event?.error?.message || String(event);
|
|
154
|
+
logger.error?.(`[taptapai] WS error: ${msg}`);
|
|
155
|
+
try { ws.close(); } catch {}
|
|
156
|
+
state.ws = null;
|
|
157
|
+
state.connected = false;
|
|
158
|
+
state.connecting = false;
|
|
159
|
+
scheduleReconnect({ ...params, url: normalizedUrl, token: normalizedToken });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Clear any previous ping timer before creating a new one
|
|
163
|
+
if (state.pingTimer) {
|
|
164
|
+
clearInterval(state.pingTimer);
|
|
165
|
+
state.pingTimer = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
state.pingTimer = setInterval(() => {
|
|
169
|
+
if (state.ws !== ws) {
|
|
170
|
+
clearInterval(state.pingTimer!);
|
|
171
|
+
state.pingTimer = null;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
175
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
176
|
+
} else {
|
|
177
|
+
clearInterval(state.pingTimer!);
|
|
178
|
+
state.pingTimer = null;
|
|
179
|
+
}
|
|
180
|
+
}, 25_000);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function scheduleReconnect(params: {
|
|
184
|
+
state: BackendWsState;
|
|
185
|
+
logger: LoggerLike;
|
|
186
|
+
url: string;
|
|
187
|
+
token: string;
|
|
188
|
+
runtime: any;
|
|
189
|
+
onRequest: (req: RpcRequest) => Promise<void>;
|
|
190
|
+
aborted: () => boolean;
|
|
191
|
+
enabled: () => boolean;
|
|
192
|
+
}): void {
|
|
193
|
+
const { state, logger, url, token, aborted, enabled } = params;
|
|
194
|
+
if (!enabled()) return;
|
|
195
|
+
if (aborted()) return;
|
|
196
|
+
if (state.reconnectTimer) return;
|
|
197
|
+
if (state.connected) return;
|
|
198
|
+
if (state.ws && (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING)) return;
|
|
199
|
+
if (state.connecting) return;
|
|
200
|
+
|
|
201
|
+
state.reconnectAttempt++;
|
|
202
|
+
const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempt - 1), MAX_RECONNECT_DELAY_MS);
|
|
203
|
+
logger.info?.(`[taptapai] Reconnecting in ${delay}ms (attempt ${state.reconnectAttempt})...`);
|
|
204
|
+
state.reconnectTimer = setTimeout(() => {
|
|
205
|
+
state.reconnectTimer = null;
|
|
206
|
+
connectBackendWs(params);
|
|
207
|
+
}, delay);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function startBackendWatchdog(params: {
|
|
211
|
+
state: BackendWsState;
|
|
212
|
+
logger: LoggerLike;
|
|
213
|
+
url: string;
|
|
214
|
+
token: string;
|
|
215
|
+
runtime: any;
|
|
216
|
+
onRequest: (req: RpcRequest) => Promise<void>;
|
|
217
|
+
aborted: () => boolean;
|
|
218
|
+
enabled: () => boolean;
|
|
219
|
+
}): void {
|
|
220
|
+
const { state, logger, url, token, aborted, enabled } = params;
|
|
221
|
+
if (!enabled()) return;
|
|
222
|
+
if (state.watchdogTimer) return;
|
|
223
|
+
|
|
224
|
+
state.watchdogTimer = setInterval(() => {
|
|
225
|
+
if (!enabled()) return;
|
|
226
|
+
if (aborted()) return;
|
|
227
|
+
if (!url) return;
|
|
228
|
+
|
|
229
|
+
if (!state.connected && !state.connecting && !state.reconnectTimer && !(state.ws && state.ws.readyState === WebSocket.CONNECTING)) {
|
|
230
|
+
logger.warn?.("[taptapai] Watchdog: backend WS disconnected, forcing reconnect");
|
|
231
|
+
try {
|
|
232
|
+
connectBackendWs(params);
|
|
233
|
+
} catch (e: any) {
|
|
234
|
+
logger.error?.(`[taptapai] Watchdog reconnect error: ${e?.message || e}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}, 10_000);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function stopBackendWatchdog(state: BackendWsState): void {
|
|
241
|
+
if (!state.watchdogTimer) return;
|
|
242
|
+
clearInterval(state.watchdogTimer);
|
|
243
|
+
state.watchdogTimer = null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function sendBackendResponse(state: BackendWsState, id: string, type: string, data?: any, extra?: Record<string, any>): void {
|
|
247
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
|
|
248
|
+
const msg: any = { id, type };
|
|
249
|
+
if (data !== undefined) msg.data = data;
|
|
250
|
+
if (extra) Object.assign(msg, extra);
|
|
251
|
+
state.ws.send(JSON.stringify(msg));
|
|
252
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import type { BridgeLockRecord, LoggerLike } from "./types";
|
|
4
|
+
import { getBridgeLockPath } from "./paths";
|
|
5
|
+
|
|
6
|
+
export function isPidRunning(pid: number): boolean {
|
|
7
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type BridgeLockState = {
|
|
17
|
+
held: boolean;
|
|
18
|
+
path: string;
|
|
19
|
+
skipLogged: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function tryAcquireBridgeLock(state: BridgeLockState, logger: LoggerLike, quiet: boolean): boolean {
|
|
23
|
+
if (state.held) return true;
|
|
24
|
+
state.path = state.path || getBridgeLockPath();
|
|
25
|
+
|
|
26
|
+
const lock: BridgeLockRecord = {
|
|
27
|
+
pid: process.pid,
|
|
28
|
+
startedAt: new Date().toISOString(),
|
|
29
|
+
host: os.hostname(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const attemptCreate = (): { ok: boolean; err?: any } => {
|
|
33
|
+
try {
|
|
34
|
+
fs.writeFileSync(state.path, JSON.stringify(lock, null, 2) + "\n", { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
35
|
+
try { fs.chmodSync(state.path, 0o600); } catch {}
|
|
36
|
+
state.held = true;
|
|
37
|
+
if (!quiet) logger.info?.(`[taptapai] Bridge lock acquired: ${state.path} (pid=${process.pid})`);
|
|
38
|
+
return { ok: true };
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
return { ok: false, err: e };
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const created = attemptCreate();
|
|
45
|
+
if (created.ok) return true;
|
|
46
|
+
if (created.err && String(created.err?.code || "") && String(created.err.code) !== "EEXIST") {
|
|
47
|
+
logger.warn?.(`[taptapai] Bridge lock create failed (${created.err.code}): ${created.err?.message || created.err}`);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Lock exists; if stale, remove and retry once.
|
|
52
|
+
// Use rename-to-temp-then-create to reduce TOCTOU window.
|
|
53
|
+
try {
|
|
54
|
+
const existing = JSON.parse(fs.readFileSync(state.path, "utf8")) as Partial<BridgeLockRecord>;
|
|
55
|
+
const existingPid = Number(existing?.pid ?? 0);
|
|
56
|
+
if (!isPidRunning(existingPid)) {
|
|
57
|
+
// Rename stale lock to temp, then try to create new lock atomically (wx).
|
|
58
|
+
const stalePath = state.path + ".stale." + process.pid;
|
|
59
|
+
try { fs.renameSync(state.path, stalePath); } catch {}
|
|
60
|
+
const recreated = attemptCreate();
|
|
61
|
+
try { fs.unlinkSync(stalePath); } catch {} // clean up stale
|
|
62
|
+
if (recreated.ok) return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!quiet && !state.skipLogged) {
|
|
66
|
+
state.skipLogged = true;
|
|
67
|
+
logger.info?.(
|
|
68
|
+
`[taptapai] Bridge already running (pid=${existingPid || "?"} on ${String(existing?.host || "?")}); skipping backend connections in this process.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
if (!quiet && !state.skipLogged) {
|
|
73
|
+
state.skipLogged = true;
|
|
74
|
+
logger.warn?.("[taptapai] Bridge lock exists but could not be read; skipping backend connections in this process.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function releaseBridgeLock(state: BridgeLockState): void {
|
|
82
|
+
if (!state.held) return;
|
|
83
|
+
state.held = false;
|
|
84
|
+
try {
|
|
85
|
+
if (state.path) fs.unlinkSync(state.path);
|
|
86
|
+
} catch (e: any) {
|
|
87
|
+
// Best-effort: lock file may already be gone
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const PLUGIN_ID = "taptapai-openclaw";
|
|
2
|
+
export const LEGACY_PLUGIN_ID = "taptapai";
|
|
3
|
+
|
|
4
|
+
// Backend URLs must be explicitly configured. These are empty by default to
|
|
5
|
+
// prevent connections to a potentially-hijacked third-party endpoint.
|
|
6
|
+
// Users must set backendWsUrl and relayUrl in their plugin config.
|
|
7
|
+
export const DEFAULT_BACKEND_HOST = "";
|
|
8
|
+
export const DEFAULT_BACKEND_WS_URL = "";
|
|
9
|
+
export const DEFAULT_RELAY_URL = "";
|
|
10
|
+
|
|
11
|
+
export const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
export const GATEWAY_DEFAULT_PORT = 18789;
|
|
14
|
+
export const GATEWAY_PROTOCOL_VERSION = 3;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LoggerLike } from "./types";
|
|
2
|
+
import { DEFAULT_BACKEND_WS_URL, DEFAULT_RELAY_URL } from "./constants";
|
|
3
|
+
import { loadStoredSecret } from "./secrets";
|
|
4
|
+
import { writePairingArtifacts } from "./qr";
|
|
5
|
+
import { getActivePluginConfig, readOpenClawConfig, writeOpenClawConfig } from "./openclawConfig";
|
|
6
|
+
|
|
7
|
+
export async function emitPairingArtifactsIfRequested(params: {
|
|
8
|
+
runtime: any;
|
|
9
|
+
pluginConfig: any;
|
|
10
|
+
logger: LoggerLike;
|
|
11
|
+
quiet: boolean;
|
|
12
|
+
getActiveSecretToken: () => string;
|
|
13
|
+
}): Promise<void> {
|
|
14
|
+
const { runtime, pluginConfig, logger, quiet, getActiveSecretToken } = params;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const requested = Boolean((pluginConfig as any)?.emitPairingArtifacts);
|
|
18
|
+
if (!requested) return;
|
|
19
|
+
|
|
20
|
+
const token = String(getActiveSecretToken() || "").trim();
|
|
21
|
+
if (!token) {
|
|
22
|
+
if (!quiet) logger.warn?.("[taptapai] emitPairingArtifacts requested but no token is configured");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const stored = loadStoredSecret();
|
|
27
|
+
const backendWsUrl = String((pluginConfig as any)?.backendWsUrl || DEFAULT_BACKEND_WS_URL).trim();
|
|
28
|
+
const relayUrl = String((pluginConfig as any)?.relayUrl || DEFAULT_RELAY_URL).trim();
|
|
29
|
+
const includeDataUrl = Boolean((pluginConfig as any)?.emitPairingArtifactsIncludeDataUrl);
|
|
30
|
+
|
|
31
|
+
await writePairingArtifacts({ token, secret: stored, backendWsUrl, relayUrl, logger, quiet, includeDataUrl });
|
|
32
|
+
|
|
33
|
+
// Clear one-shot flag.
|
|
34
|
+
const cfg = readOpenClawConfig(logger);
|
|
35
|
+
const conf = getActivePluginConfig(cfg);
|
|
36
|
+
if (conf && "emitPairingArtifacts" in conf) {
|
|
37
|
+
delete conf.emitPairingArtifacts;
|
|
38
|
+
writeOpenClawConfig(runtime, cfg, logger);
|
|
39
|
+
}
|
|
40
|
+
if (pluginConfig && (pluginConfig as any).emitPairingArtifacts) delete (pluginConfig as any).emitPairingArtifacts;
|
|
41
|
+
|
|
42
|
+
if (!quiet) logger.info?.("[taptapai] emitPairingArtifacts completed");
|
|
43
|
+
} catch (e: any) {
|
|
44
|
+
if (!quiet) logger.warn?.(`[taptapai] emitPairingArtifacts failed: ${e?.message || e}`);
|
|
45
|
+
}
|
|
46
|
+
}
|