@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/src/plugin.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import process from "process";
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_BACKEND_WS_URL, DEFAULT_RELAY_URL, PLUGIN_ID } from "./constants";
|
|
5
|
+
import { shouldBeQuiet, silentLogger, isRunningInGatewayProcess } from "./logging";
|
|
6
|
+
import { tryAcquireBridgeLock, releaseBridgeLock, type BridgeLockState } from "./bridgeLock";
|
|
7
|
+
import { ensureSecureBackendWsUrl, ensureSecureRelayUrl } from "./urls";
|
|
8
|
+
import { loadStoredSecret } from "./secrets";
|
|
9
|
+
import { readOpenClawConfig, writeOpenClawConfig, migrateLegacyPluginEntryKey, isPluginEnabledInConfig } from "./openclawConfig";
|
|
10
|
+
import { ensureDefaultBackendConfigInOpenClawConfig, runInitialSetupIfNeeded, logTerminalInstallGuide } from "./setup";
|
|
11
|
+
import { emitPairingArtifactsIfRequested } from "./emitArtifacts";
|
|
12
|
+
import { connectGatewayWs, type GatewayWsState } from "./gatewayWs";
|
|
13
|
+
import { connectBackendWs, startBackendWatchdog, stopBackendWatchdog, type BackendWsState } from "./backendWs";
|
|
14
|
+
import { runRelayFallback } from "./relayFallback";
|
|
15
|
+
import { handleBackendRequest } from "./requestHandler";
|
|
16
|
+
import { callGatewayHttp } from "./gatewayHttp";
|
|
17
|
+
import { makeLeveledLogger } from "./loggerFilter";
|
|
18
|
+
|
|
19
|
+
type PluginRuntime = any;
|
|
20
|
+
|
|
21
|
+
type PluginState = {
|
|
22
|
+
aborted: boolean;
|
|
23
|
+
backgroundStarted: boolean;
|
|
24
|
+
abortController: AbortController;
|
|
25
|
+
bridgeLock: BridgeLockState;
|
|
26
|
+
backend: BackendWsState;
|
|
27
|
+
gateway: GatewayWsState;
|
|
28
|
+
shutdownHandlersInstalled: boolean;
|
|
29
|
+
globalErrorGuardsInstalled: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function createInitialState(): PluginState {
|
|
33
|
+
return {
|
|
34
|
+
aborted: false,
|
|
35
|
+
backgroundStarted: false,
|
|
36
|
+
abortController: new AbortController(),
|
|
37
|
+
bridgeLock: { held: false, path: "", skipLogged: false },
|
|
38
|
+
backend: {
|
|
39
|
+
ws: null,
|
|
40
|
+
connected: false,
|
|
41
|
+
connecting: false,
|
|
42
|
+
currentUrl: "",
|
|
43
|
+
currentTokenHash: "",
|
|
44
|
+
reconnectTimer: null,
|
|
45
|
+
reconnectAttempt: 0,
|
|
46
|
+
watchdogTimer: null,
|
|
47
|
+
pingTimer: null,
|
|
48
|
+
},
|
|
49
|
+
gateway: {
|
|
50
|
+
ws: null,
|
|
51
|
+
connected: false,
|
|
52
|
+
reconnectTimer: null,
|
|
53
|
+
reconnectAttempt: 0,
|
|
54
|
+
urlIndex: 0,
|
|
55
|
+
rpcId: 0,
|
|
56
|
+
pendingRequests: new Map(),
|
|
57
|
+
pendingChat: new Map(),
|
|
58
|
+
},
|
|
59
|
+
shutdownHandlersInstalled: false,
|
|
60
|
+
globalErrorGuardsInstalled: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getActiveSecretToken(pluginConfig: any): string {
|
|
65
|
+
const stored = loadStoredSecret();
|
|
66
|
+
if (stored?.token) return stored.token;
|
|
67
|
+
return String(pluginConfig?.wsToken || pluginConfig?.token || "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function installGlobalErrorGuards(params: { state: PluginState; logger: any; tryConnectBackendWs: (reason: string) => void }): void {
|
|
71
|
+
const { state, logger, tryConnectBackendWs } = params;
|
|
72
|
+
if (state.globalErrorGuardsInstalled) return;
|
|
73
|
+
state.globalErrorGuardsInstalled = true;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
process.on("unhandledRejection", (reason: any) => {
|
|
77
|
+
logger.error?.(`[taptapai] unhandledRejection: ${reason?.stack || reason}`);
|
|
78
|
+
// Attempt reconnect for WS-related rejections, but don't swallow others
|
|
79
|
+
tryConnectBackendWs("unhandledRejection");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
process.on("uncaughtException", (error: any) => {
|
|
83
|
+
logger.error?.(`[taptapai] uncaughtException: ${error?.stack || error}`);
|
|
84
|
+
// Node.js docs warn: the process is in an undefined state after an
|
|
85
|
+
// uncaught exception. Log and exit cleanly instead of continuing.
|
|
86
|
+
try {
|
|
87
|
+
if (state.bridgeLock?.held) {
|
|
88
|
+
releaseBridgeLock(state.bridgeLock);
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
process.exit(1);
|
|
92
|
+
});
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
logger.warn?.(`[taptapai] Could not install global error guards: ${e?.message || e}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function installShutdownHandlers(params: { state: PluginState; logger: any; cleanup: (reason: string) => void }): void {
|
|
99
|
+
const { state, logger, cleanup } = params;
|
|
100
|
+
if (state.shutdownHandlersInstalled) return;
|
|
101
|
+
state.shutdownHandlersInstalled = true;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
process.once("exit", () => cleanup("exit"));
|
|
105
|
+
process.once("SIGINT", () => {
|
|
106
|
+
cleanup("SIGINT");
|
|
107
|
+
process.exit(0);
|
|
108
|
+
});
|
|
109
|
+
process.once("SIGTERM", () => {
|
|
110
|
+
cleanup("SIGTERM");
|
|
111
|
+
process.exit(0);
|
|
112
|
+
});
|
|
113
|
+
} catch (e: any) {
|
|
114
|
+
logger.warn?.(`[taptapai] Could not install shutdown handlers: ${e?.message || e}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createTapTapAiPlugin() {
|
|
119
|
+
const state = createInitialState();
|
|
120
|
+
|
|
121
|
+
const plugin = {
|
|
122
|
+
id: PLUGIN_ID,
|
|
123
|
+
name: "TapTapAI",
|
|
124
|
+
description: "Apple Watch & iOS voice assistant — WS gateway proxy + relay fallback",
|
|
125
|
+
|
|
126
|
+
register(api: OpenClawPluginApi) {
|
|
127
|
+
let runtime: PluginRuntime | null = null;
|
|
128
|
+
let pluginEntry: any = null;
|
|
129
|
+
let pluginConfig: any = null;
|
|
130
|
+
let logger: any = console;
|
|
131
|
+
const quiet = shouldBeQuiet();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
runtime = api.runtime as any;
|
|
135
|
+
pluginEntry = (api as any).pluginConfig;
|
|
136
|
+
pluginConfig = pluginEntry?.config || pluginEntry;
|
|
137
|
+
logger = (api as any).logger || console;
|
|
138
|
+
|
|
139
|
+
// Apply quiet + log level filtering early.
|
|
140
|
+
if (!quiet) {
|
|
141
|
+
logger = makeLeveledLogger(logger, { level: pluginConfig?.logLevel, quiet });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (quiet) {
|
|
145
|
+
logger = silentLogger();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
logger.info?.("[taptapai] Registering plugin v3 (WS + relay bridge)...");
|
|
150
|
+
|
|
151
|
+
// Migration: move plugins.entries.taptapai -> plugins.entries.taptapai-openclaw
|
|
152
|
+
// Only do this in the gateway process.
|
|
153
|
+
if (isRunningInGatewayProcess()) {
|
|
154
|
+
const cfg = readOpenClawConfig(logger);
|
|
155
|
+
if (cfg && migrateLegacyPluginEntryKey(cfg)) {
|
|
156
|
+
writeOpenClawConfig(runtime as any, cfg, logger);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const enabled = () => {
|
|
161
|
+
return isPluginEnabledInConfig(pluginEntry?.enabled, logger);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (!enabled()) {
|
|
165
|
+
logger.info?.("[taptapai] Plugin disabled in config — bridge not started");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Ensure default URLs are present so first-run config is minimal.
|
|
170
|
+
ensureDefaultBackendConfigInOpenClawConfig({ runtime, pluginConfig, logger, backendWsUrlDefault: DEFAULT_BACKEND_WS_URL, relayUrlDefault: DEFAULT_RELAY_URL });
|
|
171
|
+
|
|
172
|
+
const aborted = () => state.aborted;
|
|
173
|
+
|
|
174
|
+
const cleanup = (reason: string) => {
|
|
175
|
+
try {
|
|
176
|
+
logger.info?.(`[taptapai] Shutdown cleanup (${reason})`);
|
|
177
|
+
} catch {}
|
|
178
|
+
try {
|
|
179
|
+
state.abortController.abort();
|
|
180
|
+
} catch {}
|
|
181
|
+
try {
|
|
182
|
+
stopBackendWatchdog(state.backend);
|
|
183
|
+
} catch {}
|
|
184
|
+
try {
|
|
185
|
+
if (state.backend.pingTimer) {
|
|
186
|
+
clearInterval(state.backend.pingTimer);
|
|
187
|
+
state.backend.pingTimer = null;
|
|
188
|
+
}
|
|
189
|
+
} catch {}
|
|
190
|
+
try {
|
|
191
|
+
if (state.backend.reconnectTimer) {
|
|
192
|
+
clearTimeout(state.backend.reconnectTimer);
|
|
193
|
+
state.backend.reconnectTimer = null;
|
|
194
|
+
}
|
|
195
|
+
} catch {}
|
|
196
|
+
try {
|
|
197
|
+
if (state.gateway.reconnectTimer) {
|
|
198
|
+
clearTimeout(state.gateway.reconnectTimer);
|
|
199
|
+
state.gateway.reconnectTimer = null;
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
try {
|
|
203
|
+
state.backend.ws?.close();
|
|
204
|
+
} catch {}
|
|
205
|
+
try {
|
|
206
|
+
state.gateway.ws?.close();
|
|
207
|
+
} catch {}
|
|
208
|
+
state.backend.ws = null;
|
|
209
|
+
state.gateway.ws = null;
|
|
210
|
+
state.backend.connected = false;
|
|
211
|
+
state.gateway.connected = false;
|
|
212
|
+
|
|
213
|
+
// Fail any pending gateway waiters so we don't leak timers.
|
|
214
|
+
try {
|
|
215
|
+
for (const [id, pending] of state.gateway.pendingRequests) {
|
|
216
|
+
clearTimeout(pending.timer);
|
|
217
|
+
pending.reject(new Error("plugin shutdown"));
|
|
218
|
+
state.gateway.pendingRequests.delete(id);
|
|
219
|
+
}
|
|
220
|
+
} catch {}
|
|
221
|
+
try {
|
|
222
|
+
for (const [runId, pending] of state.gateway.pendingChat) {
|
|
223
|
+
clearTimeout(pending.timer);
|
|
224
|
+
pending.reject(new Error("plugin shutdown"));
|
|
225
|
+
state.gateway.pendingChat.delete(runId);
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
|
|
229
|
+
releaseBridgeLock(state.bridgeLock);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const tryConnectBackendWs = (reason: string) => {
|
|
233
|
+
try {
|
|
234
|
+
if (!state.bridgeLock.held) return;
|
|
235
|
+
if (state.backend.connected || state.backend.connecting) return;
|
|
236
|
+
const backendWsUrl = ensureSecureBackendWsUrl(pluginConfig?.backendWsUrl || DEFAULT_BACKEND_WS_URL, logger);
|
|
237
|
+
const token = getActiveSecretToken(pluginConfig);
|
|
238
|
+
if (!backendWsUrl || !token) return;
|
|
239
|
+
logger.info?.(`[taptapai] Backend WS reconnect requested (${reason})`);
|
|
240
|
+
connectBackendWs({
|
|
241
|
+
state: state.backend,
|
|
242
|
+
logger,
|
|
243
|
+
url: backendWsUrl,
|
|
244
|
+
token,
|
|
245
|
+
runtime,
|
|
246
|
+
aborted,
|
|
247
|
+
enabled: () => state.bridgeLock.held,
|
|
248
|
+
onRequest: async (req) => handleBackendRequest({ backendState: state.backend, gatewayState: state.gateway, runtime, pluginConfig, logger }, req),
|
|
249
|
+
});
|
|
250
|
+
} catch (e: any) {
|
|
251
|
+
logger.error?.(`[taptapai] Backend WS reconnect failed (${reason}): ${e?.message || e}`);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
installGlobalErrorGuards({ state, logger, tryConnectBackendWs });
|
|
256
|
+
installShutdownHandlers({ state, logger, cleanup });
|
|
257
|
+
|
|
258
|
+
// First-run setup + bridge start.
|
|
259
|
+
void (async () => {
|
|
260
|
+
try {
|
|
261
|
+
await runInitialSetupIfNeeded({ runtime, pluginConfig, logger, quiet });
|
|
262
|
+
|
|
263
|
+
if (state.backgroundStarted) return;
|
|
264
|
+
|
|
265
|
+
const backendWsUrl = ensureSecureBackendWsUrl(
|
|
266
|
+
String(pluginConfig?.backendWsUrl || process.env.TAPTAPAI_BACKEND_WS_URL || DEFAULT_BACKEND_WS_URL).trim(),
|
|
267
|
+
logger,
|
|
268
|
+
);
|
|
269
|
+
const relayUrl = ensureSecureRelayUrl(
|
|
270
|
+
String(pluginConfig?.relayUrl || process.env.TAPTAPAI_RELAY_URL || DEFAULT_RELAY_URL).trim(),
|
|
271
|
+
logger,
|
|
272
|
+
);
|
|
273
|
+
const token = String(pluginConfig?.wsToken || pluginConfig?.token || getActiveSecretToken(pluginConfig) || "").trim();
|
|
274
|
+
const pollIntervalMs = Number(pluginConfig?.pollIntervalMs || 1000);
|
|
275
|
+
|
|
276
|
+
if (!token) {
|
|
277
|
+
logTerminalInstallGuide(logger, quiet);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!tryAcquireBridgeLock(state.bridgeLock, logger, quiet)) return;
|
|
282
|
+
state.backgroundStarted = true;
|
|
283
|
+
state.aborted = false;
|
|
284
|
+
|
|
285
|
+
logger.info?.("[taptapai] Starting local gateway WS client for agent dispatch...");
|
|
286
|
+
connectGatewayWs({
|
|
287
|
+
state: state.gateway,
|
|
288
|
+
logger,
|
|
289
|
+
aborted,
|
|
290
|
+
enabled: () => state.bridgeLock.held,
|
|
291
|
+
readOpenClawConfig: () => readOpenClawConfig(logger),
|
|
292
|
+
runtime,
|
|
293
|
+
pluginConfig,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (backendWsUrl) {
|
|
297
|
+
logger.info?.(`[taptapai] Starting backend WS connection to ${backendWsUrl}`);
|
|
298
|
+
connectBackendWs({
|
|
299
|
+
state: state.backend,
|
|
300
|
+
logger,
|
|
301
|
+
url: backendWsUrl,
|
|
302
|
+
token,
|
|
303
|
+
runtime,
|
|
304
|
+
aborted,
|
|
305
|
+
enabled: () => state.bridgeLock.held,
|
|
306
|
+
onRequest: async (req) => handleBackendRequest({ backendState: state.backend, gatewayState: state.gateway, runtime, pluginConfig, logger }, req),
|
|
307
|
+
});
|
|
308
|
+
startBackendWatchdog({
|
|
309
|
+
state: state.backend,
|
|
310
|
+
logger,
|
|
311
|
+
url: backendWsUrl,
|
|
312
|
+
token,
|
|
313
|
+
runtime,
|
|
314
|
+
aborted,
|
|
315
|
+
enabled: () => state.bridgeLock.held,
|
|
316
|
+
onRequest: async (req) => handleBackendRequest({ backendState: state.backend, gatewayState: state.gateway, runtime, pluginConfig, logger }, req),
|
|
317
|
+
});
|
|
318
|
+
} else {
|
|
319
|
+
logger.info?.("[taptapai] No backendWsUrl configured — WS mode disabled");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (relayUrl) {
|
|
323
|
+
void runRelayFallback({
|
|
324
|
+
relayUrl,
|
|
325
|
+
token,
|
|
326
|
+
pollIntervalMs,
|
|
327
|
+
signal: state.abortController.signal,
|
|
328
|
+
isWsConnected: () => state.backend.connected,
|
|
329
|
+
callGatewayHttp: (text, sessionId) =>
|
|
330
|
+
callGatewayHttp({ text, session: sessionId, runtime, pluginConfig, readOpenClawConfig: () => readOpenClawConfig(logger), logger }),
|
|
331
|
+
logger,
|
|
332
|
+
}).catch((e: any) => {
|
|
333
|
+
logger.error?.(`[taptapai] Relay fallback crashed: ${e?.message || e}`);
|
|
334
|
+
if (backendWsUrl) tryConnectBackendWs("relay-crash");
|
|
335
|
+
});
|
|
336
|
+
} else {
|
|
337
|
+
logger.info?.("[taptapai] No relayUrl configured — relay fallback disabled");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await emitPairingArtifactsIfRequested({ runtime, pluginConfig, logger, quiet, getActiveSecretToken: () => getActiveSecretToken(pluginConfig) });
|
|
341
|
+
} catch (e: any) {
|
|
342
|
+
// Never crash the gateway from background setup, but log the error.
|
|
343
|
+
logger.error?.(`[taptapai] Background setup error: ${e?.stack || e?.message || e}`);
|
|
344
|
+
}
|
|
345
|
+
})();
|
|
346
|
+
|
|
347
|
+
logger.info?.("[taptapai] Chat setup commands are disabled; use guided terminal installation (openclaw plugins enable + config set).");
|
|
348
|
+
logger.info?.("[taptapai] ✅ Plugin v3 registered");
|
|
349
|
+
} catch (e: any) {
|
|
350
|
+
try {
|
|
351
|
+
console.error?.(`[taptapai] Plugin registration failed: ${e?.stack || e?.message || e}`);
|
|
352
|
+
logger = silentLogger();
|
|
353
|
+
} catch {}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
return plugin;
|
|
360
|
+
}
|
package/src/qr.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import QRCode from "qrcode";
|
|
3
|
+
import type { LoggerLike, StoredSecret } from "./types";
|
|
4
|
+
import { DEFAULT_RELAY_URL } from "./constants";
|
|
5
|
+
import { getPairingArtifactsDir } from "./paths";
|
|
6
|
+
|
|
7
|
+
export function qrPayloadForToken(token: string, relayUrl?: string): string {
|
|
8
|
+
// iOS expects: taptapai://link?host=X&port=Y&token=Z
|
|
9
|
+
const normalizedToken = String(token || "").trim();
|
|
10
|
+
if (!normalizedToken) return "";
|
|
11
|
+
|
|
12
|
+
let host = "";
|
|
13
|
+
let port = 443;
|
|
14
|
+
const relay = String(relayUrl || DEFAULT_RELAY_URL || "").trim();
|
|
15
|
+
|
|
16
|
+
if (!relay) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const u = new URL(relay);
|
|
22
|
+
host = u.hostname;
|
|
23
|
+
if (u.port) {
|
|
24
|
+
const p = Number(u.port);
|
|
25
|
+
if (Number.isFinite(p) && p > 0) port = p;
|
|
26
|
+
} else {
|
|
27
|
+
port = u.protocol === "http:" ? 80 : 443;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!host) return "";
|
|
34
|
+
return `taptapai://link?host=${encodeURIComponent(host)}&port=${encodeURIComponent(String(port))}&token=${encodeURIComponent(normalizedToken)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function renderQr(payload: string, logger?: LoggerLike): Promise<{ dataUrl: string; terminal: string }> {
|
|
38
|
+
let dataUrl = "";
|
|
39
|
+
let terminal = "";
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
dataUrl = await QRCode.toDataURL(payload, { errorCorrectionLevel: "H", margin: 3, width: 512 });
|
|
43
|
+
} catch (e: any) {
|
|
44
|
+
logger?.warn?.(`[taptapai] QR data URL generation failed: ${e?.message || e}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
terminal = await QRCode.toString(payload, { type: "terminal", small: false, errorCorrectionLevel: "H", margin: 2 });
|
|
49
|
+
} catch (e: any) {
|
|
50
|
+
logger?.warn?.(`[taptapai] QR terminal generation failed: ${e?.message || e}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { dataUrl, terminal };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ensurePairingArtifactsDir(): void {
|
|
57
|
+
fs.mkdirSync(getPairingArtifactsDir(), { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeTextFile0600(filePath: string, contents: string): void {
|
|
61
|
+
fs.writeFileSync(filePath, contents, { encoding: "utf8", mode: 0o600 });
|
|
62
|
+
try { fs.chmodSync(filePath, 0o600); } catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeJsonFile0600(filePath: string, obj: any): void {
|
|
66
|
+
writeTextFile0600(filePath, JSON.stringify(obj, null, 2) + "\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function writePairingArtifacts(params: {
|
|
70
|
+
token: string;
|
|
71
|
+
secret?: StoredSecret | null;
|
|
72
|
+
backendWsUrl?: string;
|
|
73
|
+
relayUrl?: string;
|
|
74
|
+
logger?: LoggerLike;
|
|
75
|
+
quiet?: boolean;
|
|
76
|
+
includeDataUrl?: boolean;
|
|
77
|
+
}): Promise<void> {
|
|
78
|
+
const token = String(params.token || "").trim();
|
|
79
|
+
if (!token) return;
|
|
80
|
+
|
|
81
|
+
ensurePairingArtifactsDir();
|
|
82
|
+
const createdAt = new Date().toISOString();
|
|
83
|
+
const payload = qrPayloadForToken(token, params.relayUrl);
|
|
84
|
+
const { dataUrl, terminal } = await renderQr(payload, params.logger);
|
|
85
|
+
|
|
86
|
+
const record = {
|
|
87
|
+
createdAt,
|
|
88
|
+
token,
|
|
89
|
+
qrPayload: payload,
|
|
90
|
+
uuidHex: params.secret?.uuidHex || null,
|
|
91
|
+
n: Number.isFinite(params.secret?.n as any) ? Number(params.secret?.n) : null,
|
|
92
|
+
backendWsUrl: params.backendWsUrl || null,
|
|
93
|
+
relayUrl: params.relayUrl || null,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
writeJsonFile0600(`${getPairingArtifactsDir()}/pairing.latest.json`, record);
|
|
97
|
+
writeTextFile0600(`${getPairingArtifactsDir()}/pairing.token.txt`, `${token}\n`);
|
|
98
|
+
writeTextFile0600(`${getPairingArtifactsDir()}/pairing.qr.payload.txt`, `${payload}\n`);
|
|
99
|
+
if (terminal) writeTextFile0600(`${getPairingArtifactsDir()}/pairing.qr.terminal.txt`, `${terminal}\n`);
|
|
100
|
+
|
|
101
|
+
// Data URL is large and sensitive; only write it when explicitly requested.
|
|
102
|
+
if (params.includeDataUrl && dataUrl) {
|
|
103
|
+
writeTextFile0600(`${getPairingArtifactsDir()}/pairing.qr.dataurl.txt`, `${dataUrl}\n`);
|
|
104
|
+
} else {
|
|
105
|
+
try { fs.unlinkSync(`${getPairingArtifactsDir()}/pairing.qr.dataurl.txt`); } catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Best-effort PNG
|
|
109
|
+
try {
|
|
110
|
+
const q: any = QRCode as any;
|
|
111
|
+
if (typeof q.toFile === "function") {
|
|
112
|
+
await q.toFile(`${getPairingArtifactsDir()}/pairing.qr.png`, payload, { errorCorrectionLevel: "H", margin: 3, width: 512 });
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
|
|
116
|
+
if (!params.quiet) {
|
|
117
|
+
params.logger?.info?.(`[taptapai] Pairing artifacts written: ${getPairingArtifactsDir()}`);
|
|
118
|
+
params.logger?.info?.(`[taptapai] Pair token: ${token.substring(0, 16)}...`);
|
|
119
|
+
params.logger?.info?.(`[taptapai] QR payload: ${payload.substring(0, 40)}...`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { LoggerLike, RelayMessage } from "./types";
|
|
2
|
+
|
|
3
|
+
export async function runRelayFallback(params: {
|
|
4
|
+
relayUrl: string;
|
|
5
|
+
token: string;
|
|
6
|
+
pollIntervalMs: number;
|
|
7
|
+
signal: AbortSignal;
|
|
8
|
+
isWsConnected: () => boolean;
|
|
9
|
+
callGatewayHttp: (text: string, sessionId: string) => Promise<string>;
|
|
10
|
+
logger: LoggerLike;
|
|
11
|
+
}): Promise<void> {
|
|
12
|
+
const { relayUrl, token, pollIntervalMs, signal, isWsConnected, callGatewayHttp, logger } = params;
|
|
13
|
+
logger.info?.(`[taptapai] Starting HTTP relay fallback: ${relayUrl}`);
|
|
14
|
+
|
|
15
|
+
async function pollForMessages(): Promise<RelayMessage[]> {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(`${relayUrl}/relay/poll?limit=10`, {
|
|
18
|
+
method: "GET",
|
|
19
|
+
headers: {
|
|
20
|
+
Accept: "application/json",
|
|
21
|
+
"ngrok-skip-browser-warning": "true",
|
|
22
|
+
Authorization: `Bearer ${token}`,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) return [];
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
return (data as any).messages || [];
|
|
28
|
+
} catch (e: any) {
|
|
29
|
+
logger.debug?.(`[taptapai] Relay poll error: ${e?.message || e}`);
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function sendToRelay(messageId: string, sessionId: string, text: string): Promise<boolean> {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`${relayUrl}/relay/responses`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"ngrok-skip-browser-warning": "true",
|
|
41
|
+
Authorization: `Bearer ${token}`,
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({ message_id: messageId, session_id: sessionId, text }),
|
|
44
|
+
});
|
|
45
|
+
return res.ok;
|
|
46
|
+
} catch (e: any) {
|
|
47
|
+
logger.debug?.(`[taptapai] Relay send error: ${e?.message || e}`);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let pollCount = 0;
|
|
53
|
+
let consecutiveFailures = 0;
|
|
54
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
55
|
+
|
|
56
|
+
while (!signal.aborted) {
|
|
57
|
+
pollCount++;
|
|
58
|
+
if (pollCount % 120 === 0) {
|
|
59
|
+
logger.info?.(`[taptapai] Relay fallback heartbeat #${pollCount}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!isWsConnected()) {
|
|
63
|
+
try {
|
|
64
|
+
const messages = await pollForMessages();
|
|
65
|
+
if (messages.length > 0) consecutiveFailures = 0; // Reset on success
|
|
66
|
+
for (const msg of messages) {
|
|
67
|
+
if (signal.aborted) break;
|
|
68
|
+
logger.info?.(`[taptapai] Relay message: "${msg.text.slice(0, 60)}..."`);
|
|
69
|
+
try {
|
|
70
|
+
const responseText = await callGatewayHttp(msg.text, msg.session_id);
|
|
71
|
+
await sendToRelay(msg.id, msg.session_id, responseText);
|
|
72
|
+
} catch (e: any) {
|
|
73
|
+
await sendToRelay(msg.id, msg.session_id, `Error: ${e?.message || e}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (e: any) {
|
|
77
|
+
consecutiveFailures++;
|
|
78
|
+
logger.debug?.(`[taptapai] Relay poll error (failures=${consecutiveFailures}): ${e?.message || e}`);
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
consecutiveFailures = 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Exponential backoff on consecutive failures
|
|
85
|
+
const backoffMs = consecutiveFailures > 0
|
|
86
|
+
? Math.min(pollIntervalMs * Math.pow(2, consecutiveFailures - 1), MAX_BACKOFF_MS)
|
|
87
|
+
: pollIntervalMs;
|
|
88
|
+
|
|
89
|
+
await new Promise<void>((resolve) => {
|
|
90
|
+
const timer = setTimeout(resolve, backoffMs);
|
|
91
|
+
signal.addEventListener(
|
|
92
|
+
"abort",
|
|
93
|
+
() => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve();
|
|
96
|
+
},
|
|
97
|
+
{ once: true },
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
logger.info?.("[taptapai] Relay fallback stopped");
|
|
103
|
+
}
|