@voiceclaw/voiceclaw-plugin 1.0.9 → 1.1.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/index.ts +26 -0
- package/package.json +30 -30
- package/src/channel.ts +242 -0
- package/src/types.ts +100 -0
- package/src/ws-client.ts +185 -0
- package/dist/index.d.ts +0 -13
- package/dist/index.js +0 -172
- package/dist/types.d.ts +0 -45
- package/dist/types.js +0 -4
- package/dist/ws-client.d.ts +0 -46
- package/dist/ws-client.js +0 -146
- package/openclaw.plugin.json +0 -11
package/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { voiceClawPlugin, setLogger } from './src/channel.js';
|
|
2
|
+
import { voiceClawConfigSchema } from './src/types.js';
|
|
3
|
+
|
|
4
|
+
interface PluginApi {
|
|
5
|
+
config: Record<string, any>;
|
|
6
|
+
logger: {
|
|
7
|
+
info: (msg: string) => void;
|
|
8
|
+
error: (msg: string) => void;
|
|
9
|
+
warn: (msg: string) => void;
|
|
10
|
+
};
|
|
11
|
+
registerChannel: (opts: { plugin: any }) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
id: 'voiceclaw-plugin',
|
|
16
|
+
name: 'VoiceClaw',
|
|
17
|
+
description:
|
|
18
|
+
'VoiceClaw channel plugin — Siri & iOS/macOS voice entry for OpenClaw',
|
|
19
|
+
configSchema: voiceClawConfigSchema,
|
|
20
|
+
register(api: PluginApi) {
|
|
21
|
+
setLogger(api.logger);
|
|
22
|
+
api.registerChannel({ plugin: voiceClawPlugin });
|
|
23
|
+
api.logger.info('VoiceClaw channel plugin registered');
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
package/package.json
CHANGED
|
@@ -1,33 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voiceclaw/voiceclaw-plugin",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "OpenClaw channel plugin for VoiceClaw — relay messages between your AI agent and the VoiceClaw iOS/macOS app via Siri",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
5
|
"type": "module",
|
|
7
6
|
"license": "MIT",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "https://github.com/anthropics/voiceclaw"
|
|
11
|
-
},
|
|
12
|
-
"homepage": "https://voiceclaw.techartisan.site",
|
|
13
|
-
"keywords": [
|
|
14
|
-
"openclaw",
|
|
15
|
-
"openclaw-plugin",
|
|
16
|
-
"openclaw-channel",
|
|
17
|
-
"siri",
|
|
18
|
-
"voiceclaw",
|
|
19
|
-
"ios",
|
|
20
|
-
"macos",
|
|
21
|
-
"voice"
|
|
22
|
-
],
|
|
23
7
|
"files": [
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"README.md"
|
|
8
|
+
"index.ts",
|
|
9
|
+
"src/"
|
|
27
10
|
],
|
|
28
11
|
"openclaw": {
|
|
29
12
|
"extensions": [
|
|
30
|
-
"
|
|
13
|
+
"./index.ts"
|
|
31
14
|
],
|
|
32
15
|
"channel": {
|
|
33
16
|
"id": "voiceclaw",
|
|
@@ -41,22 +24,39 @@
|
|
|
41
24
|
]
|
|
42
25
|
},
|
|
43
26
|
"install": {
|
|
44
|
-
"npmSpec": "@voiceclaw/voiceclaw-plugin"
|
|
45
|
-
"defaultChoice": "npm"
|
|
27
|
+
"npmSpec": "@voiceclaw/voiceclaw-plugin"
|
|
46
28
|
}
|
|
47
29
|
},
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/anthropics/voiceclaw"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://voiceclaw.techartisan.site",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"openclaw",
|
|
37
|
+
"openclaw-plugin",
|
|
38
|
+
"openclaw-channel",
|
|
39
|
+
"siri",
|
|
40
|
+
"voiceclaw",
|
|
41
|
+
"ios",
|
|
42
|
+
"macos",
|
|
43
|
+
"voice"
|
|
44
|
+
],
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"openclaw": ">=2026.1.0"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"ws": "^8.18.0",
|
|
50
|
+
"zod": "^3.24.0"
|
|
53
51
|
},
|
|
54
52
|
"devDependencies": {
|
|
55
53
|
"@types/node": "^22.0.0",
|
|
56
54
|
"@types/ws": "^8.18.1",
|
|
57
55
|
"typescript": "^5.7.0"
|
|
58
56
|
},
|
|
59
|
-
"
|
|
60
|
-
"
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsc",
|
|
59
|
+
"dev": "tsc --watch",
|
|
60
|
+
"typecheck": "tsc --noEmit"
|
|
61
61
|
}
|
|
62
62
|
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import type { ResolvedAccount, UserMessage } from './types';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_WORKER_URL,
|
|
7
|
+
resolveAccountIds,
|
|
8
|
+
resolveVoiceClawAccount,
|
|
9
|
+
voiceClawConfigSchema,
|
|
10
|
+
} from './types';
|
|
11
|
+
import { RelayWsClient } from './ws-client';
|
|
12
|
+
|
|
13
|
+
// ── Token persistence ──────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const DATA_DIR = join(homedir(), '.voiceclaw');
|
|
16
|
+
|
|
17
|
+
function tokenPath(accountId: string): string {
|
|
18
|
+
return join(DATA_DIR, `session-${accountId}.token`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadSavedToken(accountId: string): string | null {
|
|
22
|
+
const p = tokenPath(accountId);
|
|
23
|
+
if (existsSync(p)) {
|
|
24
|
+
const token = readFileSync(p, 'utf-8').trim();
|
|
25
|
+
if (token.length > 0) return token;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function saveToken(accountId: string, token: string): void {
|
|
31
|
+
if (!existsSync(DATA_DIR)) {
|
|
32
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
writeFileSync(tokenPath(accountId), token, 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Pairing code exchange ──────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
interface Logger {
|
|
40
|
+
info: (msg: string) => void;
|
|
41
|
+
error: (msg: string) => void;
|
|
42
|
+
warn: (msg: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function exchangePairingCode(
|
|
46
|
+
code: string,
|
|
47
|
+
workerUrl: string,
|
|
48
|
+
logger: Logger,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
logger.info('VoiceClaw: Exchanging pairing code for session token...');
|
|
51
|
+
const url = `${workerUrl}/pair?code=${encodeURIComponent(code)}`;
|
|
52
|
+
const resp = await fetch(url, { method: 'POST' });
|
|
53
|
+
|
|
54
|
+
if (!resp.ok) {
|
|
55
|
+
const body = await resp.text();
|
|
56
|
+
throw new Error(`Pairing failed (${resp.status}): ${body}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const data = (await resp.json()) as { sessionToken?: string };
|
|
60
|
+
if (!data.sessionToken) {
|
|
61
|
+
throw new Error('Pairing response missing sessionToken');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
logger.info('VoiceClaw: Pairing successful, session token saved');
|
|
65
|
+
return data.sessionToken;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function resolveToken(
|
|
69
|
+
accountId: string,
|
|
70
|
+
account: ResolvedAccount,
|
|
71
|
+
logger: Logger,
|
|
72
|
+
): Promise<string> {
|
|
73
|
+
const saved = loadSavedToken(accountId);
|
|
74
|
+
if (saved) {
|
|
75
|
+
logger.info(`VoiceClaw: Using saved session token for "${accountId}"`);
|
|
76
|
+
return saved;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!account.pairingCode) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`No pairing code configured for "${accountId}" and no saved session token found`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const workerUrl = account.workerUrl ?? DEFAULT_WORKER_URL;
|
|
86
|
+
const sessionToken = await exchangePairingCode(
|
|
87
|
+
account.pairingCode,
|
|
88
|
+
workerUrl,
|
|
89
|
+
logger,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
saveToken(accountId, sessionToken);
|
|
93
|
+
return sessionToken;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Channel plugin ─────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
const clients = new Map<string, RelayWsClient>();
|
|
99
|
+
let handleInbound: ((text: string, accountId: string) => Promise<void>) | null =
|
|
100
|
+
null;
|
|
101
|
+
let pluginLogger: Logger | null = null;
|
|
102
|
+
|
|
103
|
+
export const voiceClawPlugin = {
|
|
104
|
+
id: 'voiceclaw',
|
|
105
|
+
|
|
106
|
+
meta: {
|
|
107
|
+
id: 'voiceclaw',
|
|
108
|
+
label: 'VoiceClaw',
|
|
109
|
+
selectionLabel: 'VoiceClaw (Siri / iOS / macOS)',
|
|
110
|
+
docsPath: '/channels/voiceclaw',
|
|
111
|
+
blurb:
|
|
112
|
+
'Voice entry for OpenClaw via Siri & native iOS/macOS app.',
|
|
113
|
+
aliases: ['vc', 'siri'],
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
capabilities: { chatTypes: ['direct'] },
|
|
117
|
+
|
|
118
|
+
configSchema: voiceClawConfigSchema,
|
|
119
|
+
|
|
120
|
+
config: {
|
|
121
|
+
listAccountIds: (cfg: Record<string, any>): string[] =>
|
|
122
|
+
resolveAccountIds(cfg as any),
|
|
123
|
+
resolveAccount: (cfg: Record<string, any>, accountId?: string) =>
|
|
124
|
+
resolveVoiceClawAccount(cfg as any, accountId),
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
outbound: {
|
|
128
|
+
deliveryMode: 'direct' as const,
|
|
129
|
+
|
|
130
|
+
sendText: async (params: any) => {
|
|
131
|
+
const ctx = params.context;
|
|
132
|
+
const account = ctx?.account as ResolvedAccount | undefined;
|
|
133
|
+
const accountId = account?.accountId ?? 'default';
|
|
134
|
+
const client = clients.get(accountId);
|
|
135
|
+
|
|
136
|
+
if (!client || client.connectionState !== 'connected') {
|
|
137
|
+
pluginLogger?.warn(
|
|
138
|
+
`VoiceClaw: No connection for "${accountId}", reply dropped`,
|
|
139
|
+
);
|
|
140
|
+
return { ok: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
client.send({
|
|
144
|
+
type: 'agent_reply',
|
|
145
|
+
id: crypto.randomUUID(),
|
|
146
|
+
replyTo: '',
|
|
147
|
+
text: params.text ?? '',
|
|
148
|
+
ts: new Date().toISOString(),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return { ok: true };
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
gateway: {
|
|
156
|
+
startAccount: async (ctx: any) => {
|
|
157
|
+
const account = ctx.account as ResolvedAccount;
|
|
158
|
+
const logger = pluginLogger!;
|
|
159
|
+
|
|
160
|
+
logger.info(`VoiceClaw: Starting account "${account.accountId}"...`);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const sessionToken = await resolveToken(
|
|
164
|
+
account.accountId,
|
|
165
|
+
account,
|
|
166
|
+
logger,
|
|
167
|
+
);
|
|
168
|
+
const workerUrl = account.workerUrl ?? DEFAULT_WORKER_URL;
|
|
169
|
+
|
|
170
|
+
const client = new RelayWsClient({
|
|
171
|
+
token: sessionToken,
|
|
172
|
+
workerUrl,
|
|
173
|
+
onUserMessage: (msg: UserMessage) => {
|
|
174
|
+
logger.info(
|
|
175
|
+
`VoiceClaw: User message (${msg.id}): ${msg.text.slice(0, 50)}...`,
|
|
176
|
+
);
|
|
177
|
+
handleInbound?.(msg.text, account.accountId);
|
|
178
|
+
},
|
|
179
|
+
onStateChange: (state) => {
|
|
180
|
+
logger.info(`VoiceClaw: [${account.accountId}] ${state}`);
|
|
181
|
+
},
|
|
182
|
+
logger,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
client.start();
|
|
186
|
+
clients.set(account.accountId, client);
|
|
187
|
+
ctx.setStatus?.({
|
|
188
|
+
ok: true,
|
|
189
|
+
label: `VoiceClaw (${account.accountId})`,
|
|
190
|
+
});
|
|
191
|
+
logger.info(
|
|
192
|
+
`VoiceClaw: Started relay for "${account.accountId}"`,
|
|
193
|
+
);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
logger.error(
|
|
196
|
+
`VoiceClaw: Failed to start "${account.accountId}": ${err}`,
|
|
197
|
+
);
|
|
198
|
+
ctx.setStatus?.({
|
|
199
|
+
ok: false,
|
|
200
|
+
label: `VoiceClaw error: ${err}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
stopAccount: async (ctx: any) => {
|
|
206
|
+
const account = ctx.account as ResolvedAccount;
|
|
207
|
+
const client = clients.get(account.accountId);
|
|
208
|
+
if (client) {
|
|
209
|
+
client.stop();
|
|
210
|
+
clients.delete(account.accountId);
|
|
211
|
+
pluginLogger?.info(`VoiceClaw: Stopped "${account.accountId}"`);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
status: {
|
|
217
|
+
getStatus: async (ctx: any) => {
|
|
218
|
+
const account = ctx.account as ResolvedAccount;
|
|
219
|
+
const client = clients.get(account.accountId);
|
|
220
|
+
return {
|
|
221
|
+
ok: client?.connectionState === 'connected',
|
|
222
|
+
label: `VoiceClaw (${account.accountId})`,
|
|
223
|
+
detail: {
|
|
224
|
+
accountId: account.accountId,
|
|
225
|
+
connected: client?.connectionState ?? 'not started',
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// ── Plugin entry point (object form) ───────────────────────────
|
|
233
|
+
|
|
234
|
+
export function setLogger(logger: Logger) {
|
|
235
|
+
pluginLogger = logger;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function setInboundHandler(
|
|
239
|
+
handler: (text: string, accountId: string) => Promise<void>,
|
|
240
|
+
) {
|
|
241
|
+
handleInbound = handler;
|
|
242
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// ── Account schema ─────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const voiceClawAccountSchema = z.object({
|
|
6
|
+
pairingCode: z.string().min(1).optional(),
|
|
7
|
+
workerUrl: z.string().url().optional(),
|
|
8
|
+
enabled: z.boolean().optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const voiceClawMultiAccountSchema = z.object({
|
|
12
|
+
accounts: z.record(z.string(), voiceClawAccountSchema),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const voiceClawConfigSchema = z.object({
|
|
16
|
+
channels: z
|
|
17
|
+
.object({
|
|
18
|
+
voiceclaw: z
|
|
19
|
+
.union([voiceClawAccountSchema, voiceClawMultiAccountSchema])
|
|
20
|
+
.optional(),
|
|
21
|
+
})
|
|
22
|
+
.optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type VoiceClawConfig = z.infer<typeof voiceClawConfigSchema>;
|
|
26
|
+
|
|
27
|
+
export type ResolvedAccount = z.infer<typeof voiceClawAccountSchema> & {
|
|
28
|
+
accountId: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
function isMultiAccount(cfg: any): cfg is z.infer<typeof voiceClawMultiAccountSchema> {
|
|
33
|
+
return cfg && typeof cfg === 'object' && 'accounts' in cfg;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveAccountIds(cfg: VoiceClawConfig): string[] {
|
|
37
|
+
const vc = cfg.channels?.voiceclaw;
|
|
38
|
+
if (!vc) return [];
|
|
39
|
+
if (isMultiAccount(vc)) return Object.keys(vc.accounts);
|
|
40
|
+
return ['default'];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveVoiceClawAccount(
|
|
44
|
+
cfg: VoiceClawConfig,
|
|
45
|
+
accountId?: string,
|
|
46
|
+
): ResolvedAccount | undefined {
|
|
47
|
+
const vc = cfg.channels?.voiceclaw;
|
|
48
|
+
if (!vc) return undefined;
|
|
49
|
+
|
|
50
|
+
if ('accounts' in vc) {
|
|
51
|
+
const id = accountId ?? Object.keys(vc.accounts)[0];
|
|
52
|
+
const acc = vc.accounts[id];
|
|
53
|
+
if (!acc) return undefined;
|
|
54
|
+
return { ...acc, accountId: id };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { ...vc, accountId: accountId ?? 'default' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Message protocol types ─────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export const DEFAULT_WORKER_URL = 'https://voiceclaw-api.techartisan.site';
|
|
63
|
+
|
|
64
|
+
export interface UserMessage {
|
|
65
|
+
type: 'user_message';
|
|
66
|
+
id: string;
|
|
67
|
+
text: string;
|
|
68
|
+
ts: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AgentReplyMessage {
|
|
72
|
+
type: 'agent_reply';
|
|
73
|
+
id: string;
|
|
74
|
+
replyTo: string;
|
|
75
|
+
text: string;
|
|
76
|
+
ts: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PingMessage {
|
|
80
|
+
type: 'ping';
|
|
81
|
+
}
|
|
82
|
+
export interface PongMessage {
|
|
83
|
+
type: 'pong';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AuthOkMessage {
|
|
87
|
+
type: 'auth_ok';
|
|
88
|
+
}
|
|
89
|
+
export interface AuthErrorMessage {
|
|
90
|
+
type: 'auth_error';
|
|
91
|
+
reason: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type InboundRelayMessage =
|
|
95
|
+
| AuthOkMessage
|
|
96
|
+
| AuthErrorMessage
|
|
97
|
+
| UserMessage
|
|
98
|
+
| PongMessage;
|
|
99
|
+
|
|
100
|
+
export type OutboundRelayMessage = AgentReplyMessage | PingMessage;
|
package/src/ws-client.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import type {
|
|
3
|
+
InboundRelayMessage,
|
|
4
|
+
OutboundRelayMessage,
|
|
5
|
+
UserMessage,
|
|
6
|
+
} from './types';
|
|
7
|
+
import { DEFAULT_WORKER_URL } from './types';
|
|
8
|
+
|
|
9
|
+
export type WsClientState = 'disconnected' | 'connecting' | 'connected';
|
|
10
|
+
|
|
11
|
+
export interface WsClientOptions {
|
|
12
|
+
token: string;
|
|
13
|
+
workerUrl?: string;
|
|
14
|
+
onUserMessage: (msg: UserMessage) => void;
|
|
15
|
+
onStateChange?: (state: WsClientState) => void;
|
|
16
|
+
logger?: { info: (msg: string) => void; error: (msg: string) => void };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Persistent WebSocket client that connects to the Cloudflare Workers
|
|
21
|
+
* relay as the "plugin" role. Auth is implicit (the token is the room
|
|
22
|
+
* key). Handles heartbeat and automatic reconnection with exponential
|
|
23
|
+
* backoff.
|
|
24
|
+
*/
|
|
25
|
+
export class RelayWsClient {
|
|
26
|
+
private ws: WebSocket | null = null;
|
|
27
|
+
private opts: WsClientOptions;
|
|
28
|
+
private state: WsClientState = 'disconnected';
|
|
29
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
30
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
private backoffMs = 1000;
|
|
32
|
+
private stopped = false;
|
|
33
|
+
|
|
34
|
+
private static readonly MAX_BACKOFF_MS = 30_000;
|
|
35
|
+
private static readonly HEARTBEAT_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
constructor(opts: WsClientOptions) {
|
|
38
|
+
this.opts = opts;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Start the WebSocket connection. */
|
|
42
|
+
start(): void {
|
|
43
|
+
this.stopped = false;
|
|
44
|
+
this.connect();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stop and close the WebSocket; no reconnect. */
|
|
48
|
+
stop(): void {
|
|
49
|
+
this.stopped = true;
|
|
50
|
+
this.cleanup();
|
|
51
|
+
this.setState('disconnected');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Send a message to the relay. */
|
|
55
|
+
send(msg: OutboundRelayMessage): void {
|
|
56
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
57
|
+
this.ws.send(JSON.stringify(msg));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Current connection state. */
|
|
62
|
+
get connectionState(): WsClientState {
|
|
63
|
+
return this.state;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Private ──────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
private connect(): void {
|
|
69
|
+
if (this.stopped) return;
|
|
70
|
+
this.cleanup();
|
|
71
|
+
this.setState('connecting');
|
|
72
|
+
|
|
73
|
+
const base = (this.opts.workerUrl ?? DEFAULT_WORKER_URL).replace(/\/$/, '');
|
|
74
|
+
const wsUrl = base
|
|
75
|
+
.replace(/^https:\/\//, 'wss://')
|
|
76
|
+
.replace(/^http:\/\//, 'ws://');
|
|
77
|
+
const url = `${wsUrl}/ws?token=${encodeURIComponent(this.opts.token)}&role=plugin`;
|
|
78
|
+
|
|
79
|
+
this.log('info', `Connecting to relay...`);
|
|
80
|
+
const ws = new WebSocket(url);
|
|
81
|
+
|
|
82
|
+
ws.on('open', () => {
|
|
83
|
+
this.log('info', 'WebSocket open');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
ws.on('message', (data: WebSocket.Data) => {
|
|
87
|
+
try {
|
|
88
|
+
const raw = typeof data === 'string' ? data : data.toString();
|
|
89
|
+
const msg: InboundRelayMessage = JSON.parse(raw);
|
|
90
|
+
this.handleMessage(msg);
|
|
91
|
+
} catch {
|
|
92
|
+
this.log('error', `Failed to parse message: ${data}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
ws.on('close', () => {
|
|
97
|
+
this.log('info', 'WebSocket closed');
|
|
98
|
+
this.setState('disconnected');
|
|
99
|
+
this.scheduleReconnect();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
ws.on('error', (err: Error) => {
|
|
103
|
+
this.log('error', `WebSocket error: ${err.message}`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
this.ws = ws;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private handleMessage(msg: InboundRelayMessage): void {
|
|
110
|
+
switch (msg.type) {
|
|
111
|
+
case 'auth_ok':
|
|
112
|
+
this.log('info', 'Connected and authenticated');
|
|
113
|
+
this.setState('connected');
|
|
114
|
+
this.backoffMs = 1000;
|
|
115
|
+
this.startHeartbeat();
|
|
116
|
+
break;
|
|
117
|
+
|
|
118
|
+
case 'auth_error':
|
|
119
|
+
this.log('error', `Auth failed: ${msg.reason}`);
|
|
120
|
+
this.setState('disconnected');
|
|
121
|
+
this.stopped = true;
|
|
122
|
+
break;
|
|
123
|
+
|
|
124
|
+
case 'user_message':
|
|
125
|
+
this.opts.onUserMessage(msg);
|
|
126
|
+
break;
|
|
127
|
+
|
|
128
|
+
case 'pong':
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private startHeartbeat(): void {
|
|
134
|
+
this.stopHeartbeat();
|
|
135
|
+
this.heartbeatTimer = setInterval(() => {
|
|
136
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
137
|
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
138
|
+
}
|
|
139
|
+
}, RelayWsClient.HEARTBEAT_MS);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private stopHeartbeat(): void {
|
|
143
|
+
if (this.heartbeatTimer) {
|
|
144
|
+
clearInterval(this.heartbeatTimer);
|
|
145
|
+
this.heartbeatTimer = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private scheduleReconnect(): void {
|
|
150
|
+
if (this.stopped) return;
|
|
151
|
+
this.log('info', `Reconnecting in ${this.backoffMs}ms...`);
|
|
152
|
+
this.reconnectTimer = setTimeout(() => {
|
|
153
|
+
this.connect();
|
|
154
|
+
}, this.backoffMs);
|
|
155
|
+
this.backoffMs = Math.min(this.backoffMs * 2, RelayWsClient.MAX_BACKOFF_MS);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private cleanup(): void {
|
|
159
|
+
this.stopHeartbeat();
|
|
160
|
+
if (this.reconnectTimer) {
|
|
161
|
+
clearTimeout(this.reconnectTimer);
|
|
162
|
+
this.reconnectTimer = null;
|
|
163
|
+
}
|
|
164
|
+
if (this.ws) {
|
|
165
|
+
this.ws.removeAllListeners();
|
|
166
|
+
if (
|
|
167
|
+
this.ws.readyState === WebSocket.OPEN ||
|
|
168
|
+
this.ws.readyState === WebSocket.CONNECTING
|
|
169
|
+
) {
|
|
170
|
+
this.ws.close();
|
|
171
|
+
}
|
|
172
|
+
this.ws = null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private setState(s: WsClientState): void {
|
|
177
|
+
if (this.state === s) return;
|
|
178
|
+
this.state = s;
|
|
179
|
+
this.opts.onStateChange?.(s);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private log(level: 'info' | 'error', msg: string): void {
|
|
183
|
+
this.opts.logger?.[level](`[VoiceClaw WS] ${msg}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/dist/index.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
interface PluginApi {
|
|
2
|
-
config: Record<string, any>;
|
|
3
|
-
logger: {
|
|
4
|
-
info: (msg: string) => void;
|
|
5
|
-
error: (msg: string) => void;
|
|
6
|
-
warn: (msg: string) => void;
|
|
7
|
-
};
|
|
8
|
-
registerChannel: (opts: {
|
|
9
|
-
plugin: any;
|
|
10
|
-
}) => void;
|
|
11
|
-
}
|
|
12
|
-
export default function register(api: PluginApi): void;
|
|
13
|
-
export {};
|
package/dist/index.js
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { RelayWsClient } from './ws-client';
|
|
5
|
-
import { DEFAULT_WORKER_URL } from './types';
|
|
6
|
-
const clients = new Map();
|
|
7
|
-
let handleInbound = null;
|
|
8
|
-
// ── Token persistence ──────────────────────────────────────────
|
|
9
|
-
const DATA_DIR = join(homedir(), '.voiceclaw');
|
|
10
|
-
function tokenPath(accountId) {
|
|
11
|
-
return join(DATA_DIR, `session-${accountId}.token`);
|
|
12
|
-
}
|
|
13
|
-
function loadSavedToken(accountId) {
|
|
14
|
-
const p = tokenPath(accountId);
|
|
15
|
-
if (existsSync(p)) {
|
|
16
|
-
const token = readFileSync(p, 'utf-8').trim();
|
|
17
|
-
if (token.length > 0)
|
|
18
|
-
return token;
|
|
19
|
-
}
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
function saveToken(accountId, token) {
|
|
23
|
-
if (!existsSync(DATA_DIR)) {
|
|
24
|
-
mkdirSync(DATA_DIR, { recursive: true });
|
|
25
|
-
}
|
|
26
|
-
writeFileSync(tokenPath(accountId), token, 'utf-8');
|
|
27
|
-
}
|
|
28
|
-
// ── Pairing code exchange ──────────────────────────────────────
|
|
29
|
-
async function exchangePairingCode(code, workerUrl, logger) {
|
|
30
|
-
logger.info(`VoiceClaw: Exchanging pairing code for session token...`);
|
|
31
|
-
const url = `${workerUrl}/pair?code=${encodeURIComponent(code)}`;
|
|
32
|
-
const resp = await fetch(url, { method: 'POST' });
|
|
33
|
-
if (!resp.ok) {
|
|
34
|
-
const body = await resp.text();
|
|
35
|
-
throw new Error(`Pairing failed (${resp.status}): ${body}`);
|
|
36
|
-
}
|
|
37
|
-
const data = (await resp.json());
|
|
38
|
-
if (!data.sessionToken) {
|
|
39
|
-
throw new Error('Pairing response missing sessionToken');
|
|
40
|
-
}
|
|
41
|
-
logger.info('VoiceClaw: Pairing successful, session token saved');
|
|
42
|
-
return data.sessionToken;
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* Resolve the session token for an account:
|
|
46
|
-
* 1. If a saved session token exists on disk, use it.
|
|
47
|
-
* 2. Otherwise, exchange the pairing code and save the result.
|
|
48
|
-
*/
|
|
49
|
-
async function resolveToken(accountId, account, logger) {
|
|
50
|
-
const saved = loadSavedToken(accountId);
|
|
51
|
-
if (saved) {
|
|
52
|
-
logger.info(`VoiceClaw: Using saved session token for "${accountId}"`);
|
|
53
|
-
return saved;
|
|
54
|
-
}
|
|
55
|
-
if (!account.pairingCode) {
|
|
56
|
-
throw new Error(`No pairing code configured for "${accountId}" and no saved session token found`);
|
|
57
|
-
}
|
|
58
|
-
const workerUrl = account.workerUrl ?? DEFAULT_WORKER_URL;
|
|
59
|
-
const sessionToken = await exchangePairingCode(account.pairingCode, workerUrl, logger);
|
|
60
|
-
saveToken(accountId, sessionToken);
|
|
61
|
-
return sessionToken;
|
|
62
|
-
}
|
|
63
|
-
// ── Plugin registration ────────────────────────────────────────
|
|
64
|
-
export default function register(api) {
|
|
65
|
-
const channelId = 'voiceclaw';
|
|
66
|
-
const voiceClawChannel = {
|
|
67
|
-
id: channelId,
|
|
68
|
-
meta: {
|
|
69
|
-
id: channelId,
|
|
70
|
-
label: 'VoiceClaw',
|
|
71
|
-
selectionLabel: 'VoiceClaw (Siri / iOS / macOS)',
|
|
72
|
-
docsPath: '/channels/voiceclaw',
|
|
73
|
-
blurb: 'Voice entry for OpenClaw via Siri & native iOS/macOS app.',
|
|
74
|
-
aliases: ['vc', 'siri'],
|
|
75
|
-
},
|
|
76
|
-
capabilities: { chatTypes: ['direct'] },
|
|
77
|
-
config: {
|
|
78
|
-
listAccountIds: (cfg) => Object.keys(cfg.channels?.voiceclaw?.accounts ?? {}),
|
|
79
|
-
resolveAccount: (cfg, accountId) => cfg.channels?.voiceclaw?.accounts?.[accountId ?? 'default'] ?? {
|
|
80
|
-
accountId,
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
outbound: {
|
|
84
|
-
deliveryMode: 'direct',
|
|
85
|
-
sendText: async ({ text, accountId, }) => {
|
|
86
|
-
const id = accountId ?? 'default';
|
|
87
|
-
const client = clients.get(id);
|
|
88
|
-
if (!client || client.connectionState !== 'connected') {
|
|
89
|
-
api.logger.warn(`VoiceClaw: No connection for "${id}", reply dropped`);
|
|
90
|
-
return { ok: false };
|
|
91
|
-
}
|
|
92
|
-
client.send({
|
|
93
|
-
type: 'agent_reply',
|
|
94
|
-
id: crypto.randomUUID(),
|
|
95
|
-
replyTo: '',
|
|
96
|
-
text,
|
|
97
|
-
ts: new Date().toISOString(),
|
|
98
|
-
});
|
|
99
|
-
return { ok: true };
|
|
100
|
-
},
|
|
101
|
-
},
|
|
102
|
-
onboarding: {
|
|
103
|
-
configure: async (ctx) => {
|
|
104
|
-
// Support: openclaw channels add --channel voiceclaw --token <code>
|
|
105
|
-
let pairingCode = ctx.options?.token;
|
|
106
|
-
if (!pairingCode) {
|
|
107
|
-
pairingCode = await ctx.prompter.text({
|
|
108
|
-
message: 'Enter the 6-digit pairing code from the VoiceClaw app:',
|
|
109
|
-
validate: (v) => v.trim().length >= 4 ? undefined : 'Pairing code must be at least 4 characters',
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
if (!pairingCode || pairingCode.trim() === '') {
|
|
113
|
-
return 'skip';
|
|
114
|
-
}
|
|
115
|
-
const cfg = { ...ctx.config };
|
|
116
|
-
if (!cfg.channels)
|
|
117
|
-
cfg.channels = {};
|
|
118
|
-
if (!cfg.channels.voiceclaw)
|
|
119
|
-
cfg.channels.voiceclaw = {};
|
|
120
|
-
if (!cfg.channels.voiceclaw.accounts)
|
|
121
|
-
cfg.channels.voiceclaw.accounts = {};
|
|
122
|
-
cfg.channels.voiceclaw.accounts.default = {
|
|
123
|
-
pairingCode: pairingCode.trim(),
|
|
124
|
-
};
|
|
125
|
-
return { cfg, accountId: 'default' };
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
gateway: {
|
|
129
|
-
start: async (ctx) => {
|
|
130
|
-
api.logger.info('VoiceClaw: gateway.start() called');
|
|
131
|
-
handleInbound = ctx.processInbound;
|
|
132
|
-
const accounts = ctx.config.channels?.voiceclaw?.accounts ?? {};
|
|
133
|
-
for (const [accountId, account] of Object.entries(accounts)) {
|
|
134
|
-
if (account.enabled === false)
|
|
135
|
-
continue;
|
|
136
|
-
try {
|
|
137
|
-
const sessionToken = await resolveToken(accountId, account, api.logger);
|
|
138
|
-
const workerUrl = account.workerUrl ?? DEFAULT_WORKER_URL;
|
|
139
|
-
const client = new RelayWsClient({
|
|
140
|
-
token: sessionToken,
|
|
141
|
-
workerUrl,
|
|
142
|
-
onUserMessage: (msg) => {
|
|
143
|
-
api.logger.info(`VoiceClaw: User message (${msg.id}): ${msg.text.slice(0, 50)}...`);
|
|
144
|
-
handleInbound?.(msg.text, accountId);
|
|
145
|
-
},
|
|
146
|
-
onStateChange: (state) => {
|
|
147
|
-
api.logger.info(`VoiceClaw: [${accountId}] ${state}`);
|
|
148
|
-
},
|
|
149
|
-
logger: api.logger,
|
|
150
|
-
});
|
|
151
|
-
client.start();
|
|
152
|
-
clients.set(accountId, client);
|
|
153
|
-
api.logger.info(`VoiceClaw: Started relay for "${accountId}"`);
|
|
154
|
-
}
|
|
155
|
-
catch (err) {
|
|
156
|
-
api.logger.error(`VoiceClaw: Failed to start "${accountId}": ${err}`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
stop: () => {
|
|
161
|
-
for (const [accountId, client] of clients) {
|
|
162
|
-
client.stop();
|
|
163
|
-
api.logger.info(`VoiceClaw: Stopped "${accountId}"`);
|
|
164
|
-
}
|
|
165
|
-
clients.clear();
|
|
166
|
-
handleInbound = null;
|
|
167
|
-
},
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
api.registerChannel({ plugin: voiceClawChannel });
|
|
171
|
-
api.logger.info('VoiceClaw channel plugin registered');
|
|
172
|
-
}
|
package/dist/types.d.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/** Role of the WebSocket client. */
|
|
2
|
-
export type ClientRole = 'app' | 'plugin';
|
|
3
|
-
/** Auth success (sent by server on connect). */
|
|
4
|
-
export interface AuthOkMessage {
|
|
5
|
-
type: 'auth_ok';
|
|
6
|
-
}
|
|
7
|
-
/** Auth failure. */
|
|
8
|
-
export interface AuthErrorMessage {
|
|
9
|
-
type: 'auth_error';
|
|
10
|
-
reason: string;
|
|
11
|
-
}
|
|
12
|
-
/** User message from app → plugin. */
|
|
13
|
-
export interface UserMessage {
|
|
14
|
-
type: 'user_message';
|
|
15
|
-
id: string;
|
|
16
|
-
text: string;
|
|
17
|
-
ts: string;
|
|
18
|
-
}
|
|
19
|
-
/** Agent reply from plugin → app. */
|
|
20
|
-
export interface AgentReplyMessage {
|
|
21
|
-
type: 'agent_reply';
|
|
22
|
-
id: string;
|
|
23
|
-
replyTo: string;
|
|
24
|
-
text: string;
|
|
25
|
-
ts: string;
|
|
26
|
-
}
|
|
27
|
-
/** Heartbeat. */
|
|
28
|
-
export interface PingMessage {
|
|
29
|
-
type: 'ping';
|
|
30
|
-
}
|
|
31
|
-
export interface PongMessage {
|
|
32
|
-
type: 'pong';
|
|
33
|
-
}
|
|
34
|
-
/** Plugin config from channels.voiceclaw.accounts.<id> */
|
|
35
|
-
export interface VoiceClawAccountConfig {
|
|
36
|
-
pairingCode: string;
|
|
37
|
-
workerUrl?: string;
|
|
38
|
-
enabled?: boolean;
|
|
39
|
-
}
|
|
40
|
-
/** Default relay server URL. */
|
|
41
|
-
export declare const DEFAULT_WORKER_URL = "https://voiceclaw-api.techartisan.site";
|
|
42
|
-
/** Messages the plugin receives from the relay. */
|
|
43
|
-
export type InboundRelayMessage = AuthOkMessage | AuthErrorMessage | UserMessage | PongMessage;
|
|
44
|
-
/** Messages the plugin sends to the relay. */
|
|
45
|
-
export type OutboundRelayMessage = AgentReplyMessage | PingMessage;
|
package/dist/types.js
DELETED
package/dist/ws-client.d.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import type { OutboundRelayMessage, UserMessage } from './types';
|
|
2
|
-
export type WsClientState = 'disconnected' | 'connecting' | 'connected';
|
|
3
|
-
export interface WsClientOptions {
|
|
4
|
-
token: string;
|
|
5
|
-
workerUrl?: string;
|
|
6
|
-
onUserMessage: (msg: UserMessage) => void;
|
|
7
|
-
onStateChange?: (state: WsClientState) => void;
|
|
8
|
-
logger?: {
|
|
9
|
-
info: (msg: string) => void;
|
|
10
|
-
error: (msg: string) => void;
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Persistent WebSocket client that connects to the Cloudflare Workers
|
|
15
|
-
* relay as the "plugin" role. Auth is implicit (the token is the room
|
|
16
|
-
* key). Handles heartbeat and automatic reconnection with exponential
|
|
17
|
-
* backoff.
|
|
18
|
-
*/
|
|
19
|
-
export declare class RelayWsClient {
|
|
20
|
-
private ws;
|
|
21
|
-
private opts;
|
|
22
|
-
private state;
|
|
23
|
-
private reconnectTimer;
|
|
24
|
-
private heartbeatTimer;
|
|
25
|
-
private backoffMs;
|
|
26
|
-
private stopped;
|
|
27
|
-
private static readonly MAX_BACKOFF_MS;
|
|
28
|
-
private static readonly HEARTBEAT_MS;
|
|
29
|
-
constructor(opts: WsClientOptions);
|
|
30
|
-
/** Start the WebSocket connection. */
|
|
31
|
-
start(): void;
|
|
32
|
-
/** Stop and close the WebSocket; no reconnect. */
|
|
33
|
-
stop(): void;
|
|
34
|
-
/** Send a message to the relay. */
|
|
35
|
-
send(msg: OutboundRelayMessage): void;
|
|
36
|
-
/** Current connection state. */
|
|
37
|
-
get connectionState(): WsClientState;
|
|
38
|
-
private connect;
|
|
39
|
-
private handleMessage;
|
|
40
|
-
private startHeartbeat;
|
|
41
|
-
private stopHeartbeat;
|
|
42
|
-
private scheduleReconnect;
|
|
43
|
-
private cleanup;
|
|
44
|
-
private setState;
|
|
45
|
-
private log;
|
|
46
|
-
}
|
package/dist/ws-client.js
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import WebSocket from 'ws';
|
|
2
|
-
import { DEFAULT_WORKER_URL } from './types';
|
|
3
|
-
/**
|
|
4
|
-
* Persistent WebSocket client that connects to the Cloudflare Workers
|
|
5
|
-
* relay as the "plugin" role. Auth is implicit (the token is the room
|
|
6
|
-
* key). Handles heartbeat and automatic reconnection with exponential
|
|
7
|
-
* backoff.
|
|
8
|
-
*/
|
|
9
|
-
export class RelayWsClient {
|
|
10
|
-
ws = null;
|
|
11
|
-
opts;
|
|
12
|
-
state = 'disconnected';
|
|
13
|
-
reconnectTimer = null;
|
|
14
|
-
heartbeatTimer = null;
|
|
15
|
-
backoffMs = 1000;
|
|
16
|
-
stopped = false;
|
|
17
|
-
static MAX_BACKOFF_MS = 30_000;
|
|
18
|
-
static HEARTBEAT_MS = 30_000;
|
|
19
|
-
constructor(opts) {
|
|
20
|
-
this.opts = opts;
|
|
21
|
-
}
|
|
22
|
-
/** Start the WebSocket connection. */
|
|
23
|
-
start() {
|
|
24
|
-
this.stopped = false;
|
|
25
|
-
this.connect();
|
|
26
|
-
}
|
|
27
|
-
/** Stop and close the WebSocket; no reconnect. */
|
|
28
|
-
stop() {
|
|
29
|
-
this.stopped = true;
|
|
30
|
-
this.cleanup();
|
|
31
|
-
this.setState('disconnected');
|
|
32
|
-
}
|
|
33
|
-
/** Send a message to the relay. */
|
|
34
|
-
send(msg) {
|
|
35
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
36
|
-
this.ws.send(JSON.stringify(msg));
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
/** Current connection state. */
|
|
40
|
-
get connectionState() {
|
|
41
|
-
return this.state;
|
|
42
|
-
}
|
|
43
|
-
// ── Private ──────────────────────────────────────────────────
|
|
44
|
-
connect() {
|
|
45
|
-
if (this.stopped)
|
|
46
|
-
return;
|
|
47
|
-
this.cleanup();
|
|
48
|
-
this.setState('connecting');
|
|
49
|
-
const base = (this.opts.workerUrl ?? DEFAULT_WORKER_URL).replace(/\/$/, '');
|
|
50
|
-
const wsUrl = base
|
|
51
|
-
.replace(/^https:\/\//, 'wss://')
|
|
52
|
-
.replace(/^http:\/\//, 'ws://');
|
|
53
|
-
const url = `${wsUrl}/ws?token=${encodeURIComponent(this.opts.token)}&role=plugin`;
|
|
54
|
-
this.log('info', `Connecting to relay...`);
|
|
55
|
-
const ws = new WebSocket(url);
|
|
56
|
-
ws.on('open', () => {
|
|
57
|
-
this.log('info', 'WebSocket open');
|
|
58
|
-
});
|
|
59
|
-
ws.on('message', (data) => {
|
|
60
|
-
try {
|
|
61
|
-
const raw = typeof data === 'string' ? data : data.toString();
|
|
62
|
-
const msg = JSON.parse(raw);
|
|
63
|
-
this.handleMessage(msg);
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
this.log('error', `Failed to parse message: ${data}`);
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
ws.on('close', () => {
|
|
70
|
-
this.log('info', 'WebSocket closed');
|
|
71
|
-
this.setState('disconnected');
|
|
72
|
-
this.scheduleReconnect();
|
|
73
|
-
});
|
|
74
|
-
ws.on('error', (err) => {
|
|
75
|
-
this.log('error', `WebSocket error: ${err.message}`);
|
|
76
|
-
});
|
|
77
|
-
this.ws = ws;
|
|
78
|
-
}
|
|
79
|
-
handleMessage(msg) {
|
|
80
|
-
switch (msg.type) {
|
|
81
|
-
case 'auth_ok':
|
|
82
|
-
this.log('info', 'Connected and authenticated');
|
|
83
|
-
this.setState('connected');
|
|
84
|
-
this.backoffMs = 1000;
|
|
85
|
-
this.startHeartbeat();
|
|
86
|
-
break;
|
|
87
|
-
case 'auth_error':
|
|
88
|
-
this.log('error', `Auth failed: ${msg.reason}`);
|
|
89
|
-
this.setState('disconnected');
|
|
90
|
-
this.stopped = true;
|
|
91
|
-
break;
|
|
92
|
-
case 'user_message':
|
|
93
|
-
this.opts.onUserMessage(msg);
|
|
94
|
-
break;
|
|
95
|
-
case 'pong':
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
startHeartbeat() {
|
|
100
|
-
this.stopHeartbeat();
|
|
101
|
-
this.heartbeatTimer = setInterval(() => {
|
|
102
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
103
|
-
this.ws.send(JSON.stringify({ type: 'ping' }));
|
|
104
|
-
}
|
|
105
|
-
}, RelayWsClient.HEARTBEAT_MS);
|
|
106
|
-
}
|
|
107
|
-
stopHeartbeat() {
|
|
108
|
-
if (this.heartbeatTimer) {
|
|
109
|
-
clearInterval(this.heartbeatTimer);
|
|
110
|
-
this.heartbeatTimer = null;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
scheduleReconnect() {
|
|
114
|
-
if (this.stopped)
|
|
115
|
-
return;
|
|
116
|
-
this.log('info', `Reconnecting in ${this.backoffMs}ms...`);
|
|
117
|
-
this.reconnectTimer = setTimeout(() => {
|
|
118
|
-
this.connect();
|
|
119
|
-
}, this.backoffMs);
|
|
120
|
-
this.backoffMs = Math.min(this.backoffMs * 2, RelayWsClient.MAX_BACKOFF_MS);
|
|
121
|
-
}
|
|
122
|
-
cleanup() {
|
|
123
|
-
this.stopHeartbeat();
|
|
124
|
-
if (this.reconnectTimer) {
|
|
125
|
-
clearTimeout(this.reconnectTimer);
|
|
126
|
-
this.reconnectTimer = null;
|
|
127
|
-
}
|
|
128
|
-
if (this.ws) {
|
|
129
|
-
this.ws.removeAllListeners();
|
|
130
|
-
if (this.ws.readyState === WebSocket.OPEN ||
|
|
131
|
-
this.ws.readyState === WebSocket.CONNECTING) {
|
|
132
|
-
this.ws.close();
|
|
133
|
-
}
|
|
134
|
-
this.ws = null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
setState(s) {
|
|
138
|
-
if (this.state === s)
|
|
139
|
-
return;
|
|
140
|
-
this.state = s;
|
|
141
|
-
this.opts.onStateChange?.(s);
|
|
142
|
-
}
|
|
143
|
-
log(level, msg) {
|
|
144
|
-
this.opts.logger?.[level](`[VoiceClaw WS] ${msg}`);
|
|
145
|
-
}
|
|
146
|
-
}
|
package/openclaw.plugin.json
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "voiceclaw-plugin",
|
|
3
|
-
"name": "VoiceClaw",
|
|
4
|
-
"version": "1.0.0",
|
|
5
|
-
"description": "VoiceClaw channel plugin — Siri & iOS/macOS voice entry for OpenClaw",
|
|
6
|
-
"configSchema": {
|
|
7
|
-
"type": "object",
|
|
8
|
-
"additionalProperties": false,
|
|
9
|
-
"properties": {}
|
|
10
|
-
}
|
|
11
|
-
}
|