@voiceclaw/voiceclaw-plugin 1.1.0 → 1.1.2
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/dist/index.d.ts +33 -0
- package/dist/index.js +13 -0
- package/dist/src/channel.d.ts +62 -0
- package/dist/src/channel.js +163 -0
- package/dist/src/types.d.ts +57 -0
- package/dist/src/types.js +46 -0
- package/dist/src/ws-client.d.ts +46 -0
- package/dist/src/ws-client.js +146 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +8 -5
- package/index.ts +0 -26
- package/src/channel.ts +0 -242
- package/src/types.ts +0 -100
- package/src/ws-client.ts +0 -185
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
declare const _default: {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
configSchema: import("zod").ZodObject<{
|
|
17
|
+
channels: import("zod").ZodOptional<import("zod").ZodObject<{
|
|
18
|
+
voiceclaw: import("zod").ZodOptional<import("zod").ZodUnion<readonly [import("zod").ZodObject<{
|
|
19
|
+
pairingCode: import("zod").ZodOptional<import("zod").ZodString>;
|
|
20
|
+
workerUrl: import("zod").ZodOptional<import("zod").ZodString>;
|
|
21
|
+
enabled: import("zod").ZodOptional<import("zod").ZodBoolean>;
|
|
22
|
+
}, import("zod/v4/core").$strip>, import("zod").ZodObject<{
|
|
23
|
+
accounts: import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodObject<{
|
|
24
|
+
pairingCode: import("zod").ZodOptional<import("zod").ZodString>;
|
|
25
|
+
workerUrl: import("zod").ZodOptional<import("zod").ZodString>;
|
|
26
|
+
enabled: import("zod").ZodOptional<import("zod").ZodBoolean>;
|
|
27
|
+
}, import("zod/v4/core").$strip>>;
|
|
28
|
+
}, import("zod/v4/core").$strip>]>>;
|
|
29
|
+
}, import("zod/v4/core").$strip>>;
|
|
30
|
+
}, import("zod/v4/core").$strip>;
|
|
31
|
+
register(api: PluginApi): void;
|
|
32
|
+
};
|
|
33
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { voiceClawPlugin, setLogger } from './src/channel.js';
|
|
2
|
+
import { voiceClawConfigSchema } from './src/types.js';
|
|
3
|
+
export default {
|
|
4
|
+
id: 'voiceclaw-plugin',
|
|
5
|
+
name: 'VoiceClaw',
|
|
6
|
+
description: 'VoiceClaw channel plugin — Siri & iOS/macOS voice entry for OpenClaw',
|
|
7
|
+
configSchema: voiceClawConfigSchema,
|
|
8
|
+
register(api) {
|
|
9
|
+
setLogger(api.logger);
|
|
10
|
+
api.registerChannel({ plugin: voiceClawPlugin });
|
|
11
|
+
api.logger.info('VoiceClaw channel plugin registered');
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ResolvedAccount } from './types';
|
|
2
|
+
interface Logger {
|
|
3
|
+
info: (msg: string) => void;
|
|
4
|
+
error: (msg: string) => void;
|
|
5
|
+
warn: (msg: string) => void;
|
|
6
|
+
}
|
|
7
|
+
export declare const voiceClawPlugin: {
|
|
8
|
+
id: string;
|
|
9
|
+
meta: {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
selectionLabel: string;
|
|
13
|
+
docsPath: string;
|
|
14
|
+
blurb: string;
|
|
15
|
+
aliases: string[];
|
|
16
|
+
};
|
|
17
|
+
capabilities: {
|
|
18
|
+
chatTypes: string[];
|
|
19
|
+
};
|
|
20
|
+
configSchema: import("zod").ZodObject<{
|
|
21
|
+
channels: import("zod").ZodOptional<import("zod").ZodObject<{
|
|
22
|
+
voiceclaw: import("zod").ZodOptional<import("zod").ZodUnion<readonly [import("zod").ZodObject<{
|
|
23
|
+
pairingCode: import("zod").ZodOptional<import("zod").ZodString>;
|
|
24
|
+
workerUrl: import("zod").ZodOptional<import("zod").ZodString>;
|
|
25
|
+
enabled: import("zod").ZodOptional<import("zod").ZodBoolean>;
|
|
26
|
+
}, import("zod/v4/core").$strip>, import("zod").ZodObject<{
|
|
27
|
+
accounts: import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodObject<{
|
|
28
|
+
pairingCode: import("zod").ZodOptional<import("zod").ZodString>;
|
|
29
|
+
workerUrl: import("zod").ZodOptional<import("zod").ZodString>;
|
|
30
|
+
enabled: import("zod").ZodOptional<import("zod").ZodBoolean>;
|
|
31
|
+
}, import("zod/v4/core").$strip>>;
|
|
32
|
+
}, import("zod/v4/core").$strip>]>>;
|
|
33
|
+
}, import("zod/v4/core").$strip>>;
|
|
34
|
+
}, import("zod/v4/core").$strip>;
|
|
35
|
+
config: {
|
|
36
|
+
listAccountIds: (cfg: Record<string, any>) => string[];
|
|
37
|
+
resolveAccount: (cfg: Record<string, any>, accountId?: string) => ResolvedAccount | undefined;
|
|
38
|
+
};
|
|
39
|
+
outbound: {
|
|
40
|
+
deliveryMode: "direct";
|
|
41
|
+
sendText: (params: any) => Promise<{
|
|
42
|
+
ok: boolean;
|
|
43
|
+
}>;
|
|
44
|
+
};
|
|
45
|
+
gateway: {
|
|
46
|
+
startAccount: (ctx: any) => Promise<void>;
|
|
47
|
+
stopAccount: (ctx: any) => Promise<void>;
|
|
48
|
+
};
|
|
49
|
+
status: {
|
|
50
|
+
getStatus: (ctx: any) => Promise<{
|
|
51
|
+
ok: boolean;
|
|
52
|
+
label: string;
|
|
53
|
+
detail: {
|
|
54
|
+
accountId: string;
|
|
55
|
+
connected: string;
|
|
56
|
+
};
|
|
57
|
+
}>;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
export declare function setLogger(logger: Logger): void;
|
|
61
|
+
export declare function setInboundHandler(handler: (text: string, accountId: string) => Promise<void>): void;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { DEFAULT_WORKER_URL, resolveAccountIds, resolveVoiceClawAccount, voiceClawConfigSchema, } from './types';
|
|
5
|
+
import { RelayWsClient } from './ws-client';
|
|
6
|
+
// ── Token persistence ──────────────────────────────────────────
|
|
7
|
+
const DATA_DIR = join(homedir(), '.voiceclaw');
|
|
8
|
+
function tokenPath(accountId) {
|
|
9
|
+
return join(DATA_DIR, `session-${accountId}.token`);
|
|
10
|
+
}
|
|
11
|
+
function loadSavedToken(accountId) {
|
|
12
|
+
const p = tokenPath(accountId);
|
|
13
|
+
if (existsSync(p)) {
|
|
14
|
+
const token = readFileSync(p, 'utf-8').trim();
|
|
15
|
+
if (token.length > 0)
|
|
16
|
+
return token;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function saveToken(accountId, token) {
|
|
21
|
+
if (!existsSync(DATA_DIR)) {
|
|
22
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
writeFileSync(tokenPath(accountId), token, 'utf-8');
|
|
25
|
+
}
|
|
26
|
+
async function exchangePairingCode(code, workerUrl, logger) {
|
|
27
|
+
logger.info('VoiceClaw: Exchanging pairing code for session token...');
|
|
28
|
+
const url = `${workerUrl}/pair?code=${encodeURIComponent(code)}`;
|
|
29
|
+
const resp = await fetch(url, { method: 'POST' });
|
|
30
|
+
if (!resp.ok) {
|
|
31
|
+
const body = await resp.text();
|
|
32
|
+
throw new Error(`Pairing failed (${resp.status}): ${body}`);
|
|
33
|
+
}
|
|
34
|
+
const data = (await resp.json());
|
|
35
|
+
if (!data.sessionToken) {
|
|
36
|
+
throw new Error('Pairing response missing sessionToken');
|
|
37
|
+
}
|
|
38
|
+
logger.info('VoiceClaw: Pairing successful, session token saved');
|
|
39
|
+
return data.sessionToken;
|
|
40
|
+
}
|
|
41
|
+
async function resolveToken(accountId, account, logger) {
|
|
42
|
+
const saved = loadSavedToken(accountId);
|
|
43
|
+
if (saved) {
|
|
44
|
+
logger.info(`VoiceClaw: Using saved session token for "${accountId}"`);
|
|
45
|
+
return saved;
|
|
46
|
+
}
|
|
47
|
+
if (!account.pairingCode) {
|
|
48
|
+
throw new Error(`No pairing code configured for "${accountId}" and no saved session token found`);
|
|
49
|
+
}
|
|
50
|
+
const workerUrl = account.workerUrl ?? DEFAULT_WORKER_URL;
|
|
51
|
+
const sessionToken = await exchangePairingCode(account.pairingCode, workerUrl, logger);
|
|
52
|
+
saveToken(accountId, sessionToken);
|
|
53
|
+
return sessionToken;
|
|
54
|
+
}
|
|
55
|
+
// ── Channel plugin ─────────────────────────────────────────────
|
|
56
|
+
const clients = new Map();
|
|
57
|
+
let handleInbound = null;
|
|
58
|
+
let pluginLogger = null;
|
|
59
|
+
export const voiceClawPlugin = {
|
|
60
|
+
id: 'voiceclaw',
|
|
61
|
+
meta: {
|
|
62
|
+
id: 'voiceclaw',
|
|
63
|
+
label: 'VoiceClaw',
|
|
64
|
+
selectionLabel: 'VoiceClaw (Siri / iOS / macOS)',
|
|
65
|
+
docsPath: '/channels/voiceclaw',
|
|
66
|
+
blurb: 'Voice entry for OpenClaw via Siri & native iOS/macOS app.',
|
|
67
|
+
aliases: ['vc', 'siri'],
|
|
68
|
+
},
|
|
69
|
+
capabilities: { chatTypes: ['direct'] },
|
|
70
|
+
configSchema: voiceClawConfigSchema,
|
|
71
|
+
config: {
|
|
72
|
+
listAccountIds: (cfg) => resolveAccountIds(cfg),
|
|
73
|
+
resolveAccount: (cfg, accountId) => resolveVoiceClawAccount(cfg, accountId),
|
|
74
|
+
},
|
|
75
|
+
outbound: {
|
|
76
|
+
deliveryMode: 'direct',
|
|
77
|
+
sendText: async (params) => {
|
|
78
|
+
const ctx = params.context;
|
|
79
|
+
const account = ctx?.account;
|
|
80
|
+
const accountId = account?.accountId ?? 'default';
|
|
81
|
+
const client = clients.get(accountId);
|
|
82
|
+
if (!client || client.connectionState !== 'connected') {
|
|
83
|
+
pluginLogger?.warn(`VoiceClaw: No connection for "${accountId}", reply dropped`);
|
|
84
|
+
return { ok: false };
|
|
85
|
+
}
|
|
86
|
+
client.send({
|
|
87
|
+
type: 'agent_reply',
|
|
88
|
+
id: crypto.randomUUID(),
|
|
89
|
+
replyTo: '',
|
|
90
|
+
text: params.text ?? '',
|
|
91
|
+
ts: new Date().toISOString(),
|
|
92
|
+
});
|
|
93
|
+
return { ok: true };
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
gateway: {
|
|
97
|
+
startAccount: async (ctx) => {
|
|
98
|
+
const account = ctx.account;
|
|
99
|
+
const logger = pluginLogger;
|
|
100
|
+
logger.info(`VoiceClaw: Starting account "${account.accountId}"...`);
|
|
101
|
+
try {
|
|
102
|
+
const sessionToken = await resolveToken(account.accountId, account, logger);
|
|
103
|
+
const workerUrl = account.workerUrl ?? DEFAULT_WORKER_URL;
|
|
104
|
+
const client = new RelayWsClient({
|
|
105
|
+
token: sessionToken,
|
|
106
|
+
workerUrl,
|
|
107
|
+
onUserMessage: (msg) => {
|
|
108
|
+
logger.info(`VoiceClaw: User message (${msg.id}): ${msg.text.slice(0, 50)}...`);
|
|
109
|
+
handleInbound?.(msg.text, account.accountId);
|
|
110
|
+
},
|
|
111
|
+
onStateChange: (state) => {
|
|
112
|
+
logger.info(`VoiceClaw: [${account.accountId}] ${state}`);
|
|
113
|
+
},
|
|
114
|
+
logger,
|
|
115
|
+
});
|
|
116
|
+
client.start();
|
|
117
|
+
clients.set(account.accountId, client);
|
|
118
|
+
ctx.setStatus?.({
|
|
119
|
+
ok: true,
|
|
120
|
+
label: `VoiceClaw (${account.accountId})`,
|
|
121
|
+
});
|
|
122
|
+
logger.info(`VoiceClaw: Started relay for "${account.accountId}"`);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
logger.error(`VoiceClaw: Failed to start "${account.accountId}": ${err}`);
|
|
126
|
+
ctx.setStatus?.({
|
|
127
|
+
ok: false,
|
|
128
|
+
label: `VoiceClaw error: ${err}`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
stopAccount: async (ctx) => {
|
|
133
|
+
const account = ctx.account;
|
|
134
|
+
const client = clients.get(account.accountId);
|
|
135
|
+
if (client) {
|
|
136
|
+
client.stop();
|
|
137
|
+
clients.delete(account.accountId);
|
|
138
|
+
pluginLogger?.info(`VoiceClaw: Stopped "${account.accountId}"`);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
status: {
|
|
143
|
+
getStatus: async (ctx) => {
|
|
144
|
+
const account = ctx.account;
|
|
145
|
+
const client = clients.get(account.accountId);
|
|
146
|
+
return {
|
|
147
|
+
ok: client?.connectionState === 'connected',
|
|
148
|
+
label: `VoiceClaw (${account.accountId})`,
|
|
149
|
+
detail: {
|
|
150
|
+
accountId: account.accountId,
|
|
151
|
+
connected: client?.connectionState ?? 'not started',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
// ── Plugin entry point (object form) ───────────────────────────
|
|
158
|
+
export function setLogger(logger) {
|
|
159
|
+
pluginLogger = logger;
|
|
160
|
+
}
|
|
161
|
+
export function setInboundHandler(handler) {
|
|
162
|
+
handleInbound = handler;
|
|
163
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
declare const voiceClawAccountSchema: z.ZodObject<{
|
|
3
|
+
pairingCode: z.ZodOptional<z.ZodString>;
|
|
4
|
+
workerUrl: z.ZodOptional<z.ZodString>;
|
|
5
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
export declare const voiceClawConfigSchema: z.ZodObject<{
|
|
8
|
+
channels: z.ZodOptional<z.ZodObject<{
|
|
9
|
+
voiceclaw: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
10
|
+
pairingCode: z.ZodOptional<z.ZodString>;
|
|
11
|
+
workerUrl: z.ZodOptional<z.ZodString>;
|
|
12
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
13
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
14
|
+
accounts: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
15
|
+
pairingCode: z.ZodOptional<z.ZodString>;
|
|
16
|
+
workerUrl: z.ZodOptional<z.ZodString>;
|
|
17
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
18
|
+
}, z.core.$strip>>;
|
|
19
|
+
}, z.core.$strip>]>>;
|
|
20
|
+
}, z.core.$strip>>;
|
|
21
|
+
}, z.core.$strip>;
|
|
22
|
+
export type VoiceClawConfig = z.infer<typeof voiceClawConfigSchema>;
|
|
23
|
+
export type ResolvedAccount = z.infer<typeof voiceClawAccountSchema> & {
|
|
24
|
+
accountId: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function resolveAccountIds(cfg: VoiceClawConfig): string[];
|
|
27
|
+
export declare function resolveVoiceClawAccount(cfg: VoiceClawConfig, accountId?: string): ResolvedAccount | undefined;
|
|
28
|
+
export declare const DEFAULT_WORKER_URL = "https://voiceclaw-api.techartisan.site";
|
|
29
|
+
export interface UserMessage {
|
|
30
|
+
type: 'user_message';
|
|
31
|
+
id: string;
|
|
32
|
+
text: string;
|
|
33
|
+
ts: string;
|
|
34
|
+
}
|
|
35
|
+
export interface AgentReplyMessage {
|
|
36
|
+
type: 'agent_reply';
|
|
37
|
+
id: string;
|
|
38
|
+
replyTo: string;
|
|
39
|
+
text: string;
|
|
40
|
+
ts: string;
|
|
41
|
+
}
|
|
42
|
+
export interface PingMessage {
|
|
43
|
+
type: 'ping';
|
|
44
|
+
}
|
|
45
|
+
export interface PongMessage {
|
|
46
|
+
type: 'pong';
|
|
47
|
+
}
|
|
48
|
+
export interface AuthOkMessage {
|
|
49
|
+
type: 'auth_ok';
|
|
50
|
+
}
|
|
51
|
+
export interface AuthErrorMessage {
|
|
52
|
+
type: 'auth_error';
|
|
53
|
+
reason: string;
|
|
54
|
+
}
|
|
55
|
+
export type InboundRelayMessage = AuthOkMessage | AuthErrorMessage | UserMessage | PongMessage;
|
|
56
|
+
export type OutboundRelayMessage = AgentReplyMessage | PingMessage;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// ── Account schema ─────────────────────────────────────────────
|
|
3
|
+
const voiceClawAccountSchema = z.object({
|
|
4
|
+
pairingCode: z.string().min(1).optional(),
|
|
5
|
+
workerUrl: z.string().url().optional(),
|
|
6
|
+
enabled: z.boolean().optional(),
|
|
7
|
+
});
|
|
8
|
+
const voiceClawMultiAccountSchema = z.object({
|
|
9
|
+
accounts: z.record(z.string(), voiceClawAccountSchema),
|
|
10
|
+
});
|
|
11
|
+
export const voiceClawConfigSchema = z.object({
|
|
12
|
+
channels: z
|
|
13
|
+
.object({
|
|
14
|
+
voiceclaw: z
|
|
15
|
+
.union([voiceClawAccountSchema, voiceClawMultiAccountSchema])
|
|
16
|
+
.optional(),
|
|
17
|
+
})
|
|
18
|
+
.optional(),
|
|
19
|
+
});
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
function isMultiAccount(cfg) {
|
|
22
|
+
return cfg && typeof cfg === 'object' && 'accounts' in cfg;
|
|
23
|
+
}
|
|
24
|
+
export function resolveAccountIds(cfg) {
|
|
25
|
+
const vc = cfg.channels?.voiceclaw;
|
|
26
|
+
if (!vc)
|
|
27
|
+
return [];
|
|
28
|
+
if (isMultiAccount(vc))
|
|
29
|
+
return Object.keys(vc.accounts);
|
|
30
|
+
return ['default'];
|
|
31
|
+
}
|
|
32
|
+
export function resolveVoiceClawAccount(cfg, accountId) {
|
|
33
|
+
const vc = cfg.channels?.voiceclaw;
|
|
34
|
+
if (!vc)
|
|
35
|
+
return undefined;
|
|
36
|
+
if ('accounts' in vc) {
|
|
37
|
+
const id = accountId ?? Object.keys(vc.accounts)[0];
|
|
38
|
+
const acc = vc.accounts[id];
|
|
39
|
+
if (!acc)
|
|
40
|
+
return undefined;
|
|
41
|
+
return { ...acc, accountId: id };
|
|
42
|
+
}
|
|
43
|
+
return { ...vc, accountId: accountId ?? 'default' };
|
|
44
|
+
}
|
|
45
|
+
// ── Message protocol types ─────────────────────────────────────
|
|
46
|
+
export const DEFAULT_WORKER_URL = 'https://voiceclaw-api.techartisan.site';
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
"channels": ["voiceclaw"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {}
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voiceclaw/voiceclaw-plugin",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"license": "MIT",
|
|
7
8
|
"files": [
|
|
8
|
-
"
|
|
9
|
-
"
|
|
9
|
+
"dist",
|
|
10
|
+
"openclaw.plugin.json",
|
|
11
|
+
"README.md"
|
|
10
12
|
],
|
|
11
13
|
"openclaw": {
|
|
12
14
|
"extensions": [
|
|
13
|
-
"
|
|
15
|
+
"dist/index.js"
|
|
14
16
|
],
|
|
15
17
|
"channel": {
|
|
16
18
|
"id": "voiceclaw",
|
|
@@ -57,6 +59,7 @@
|
|
|
57
59
|
"scripts": {
|
|
58
60
|
"build": "tsc",
|
|
59
61
|
"dev": "tsc --watch",
|
|
60
|
-
"typecheck": "tsc --noEmit"
|
|
62
|
+
"typecheck": "tsc --noEmit",
|
|
63
|
+
"prepublishOnly": "npm run build"
|
|
61
64
|
}
|
|
62
65
|
}
|
package/index.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
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/src/channel.ts
DELETED
|
@@ -1,242 +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 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
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
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
|
-
}
|