@alfe.ai/openclaw-voice 0.0.1 → 0.0.3
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 +1 -58
- package/dist/index.js +1 -265
- package/dist/plugin.d.ts +59 -0
- package/dist/plugin.js +188 -0
- package/package.json +7 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,59 +1,2 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
//#region src/plugin.d.ts
|
|
4
|
-
|
|
5
|
-
interface Logger {
|
|
6
|
-
info(msg: string, ...args: unknown[]): void;
|
|
7
|
-
warn(msg: string, ...args: unknown[]): void;
|
|
8
|
-
error(msg: string, ...args: unknown[]): void;
|
|
9
|
-
debug(msg: string, ...args: unknown[]): void;
|
|
10
|
-
}
|
|
11
|
-
interface VoicePluginConfig {
|
|
12
|
-
voiceServiceUrl?: string;
|
|
13
|
-
voiceServicePort?: string | number;
|
|
14
|
-
voiceServiceApiKey?: string;
|
|
15
|
-
daemonSocket?: string;
|
|
16
|
-
[key: string]: unknown;
|
|
17
|
-
}
|
|
18
|
-
interface OpenClawConfig {
|
|
19
|
-
plugins?: {
|
|
20
|
-
entries?: Record<string, {
|
|
21
|
-
config?: VoicePluginConfig;
|
|
22
|
-
[key: string]: unknown;
|
|
23
|
-
}>;
|
|
24
|
-
[key: string]: unknown;
|
|
25
|
-
};
|
|
26
|
-
[key: string]: unknown;
|
|
27
|
-
}
|
|
28
|
-
interface OpenClawPluginApi {
|
|
29
|
-
logger: Logger;
|
|
30
|
-
config?: OpenClawConfig;
|
|
31
|
-
registerTool(tool: ToolDef): void;
|
|
32
|
-
registerGatewayMethod(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
|
|
33
|
-
on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
|
|
34
|
-
priority?: number;
|
|
35
|
-
}): void;
|
|
36
|
-
}
|
|
37
|
-
interface ToolDef {
|
|
38
|
-
name: string;
|
|
39
|
-
description: string;
|
|
40
|
-
label: string;
|
|
41
|
-
parameters: TSchema;
|
|
42
|
-
execute: (toolCallId: string, params: Record<string, unknown>) => Promise<{
|
|
43
|
-
content: {
|
|
44
|
-
type: 'text';
|
|
45
|
-
text: string;
|
|
46
|
-
}[];
|
|
47
|
-
details: unknown;
|
|
48
|
-
}>;
|
|
49
|
-
}
|
|
50
|
-
declare const plugin: {
|
|
51
|
-
id: string;
|
|
52
|
-
name: string;
|
|
53
|
-
description: string;
|
|
54
|
-
version: string;
|
|
55
|
-
activate(api: OpenClawPluginApi): Promise<void>;
|
|
56
|
-
deactivate(api: OpenClawPluginApi): void;
|
|
57
|
-
};
|
|
58
|
-
//#endregion
|
|
1
|
+
import plugin from "./plugin.js";
|
|
59
2
|
export { plugin as default };
|
package/dist/index.js
CHANGED
|
@@ -1,266 +1,2 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
//#region src/plugin.ts
|
|
5
|
-
/**
|
|
6
|
-
* @alfe/voice-plugin — OpenClaw native plugin
|
|
7
|
-
*
|
|
8
|
-
* Thin client that registers voice tools with OpenClaw and forwards
|
|
9
|
-
* all operations to the voice service over HTTP. The plugin holds no
|
|
10
|
-
* credentials and imports nothing from the voice service.
|
|
11
|
-
*/
|
|
12
|
-
const DEFAULT_SOCKET_PATH = join(homedir(), ".alfe", "gateway.sock");
|
|
13
|
-
const VOICE_CAPABILITIES = [
|
|
14
|
-
"voice.call",
|
|
15
|
-
"voice.answer",
|
|
16
|
-
"voice.dtmf",
|
|
17
|
-
"voice.hangup"
|
|
18
|
-
];
|
|
19
|
-
let voiceServiceUrl = "";
|
|
20
|
-
let voiceServiceApiKey = "";
|
|
21
|
-
async function voiceApi(method, path, body) {
|
|
22
|
-
const url = `${voiceServiceUrl}${path}`;
|
|
23
|
-
const headers = { "Content-Type": "application/json" };
|
|
24
|
-
if (voiceServiceApiKey) headers["x-api-key"] = voiceServiceApiKey;
|
|
25
|
-
const res = await fetch(url, {
|
|
26
|
-
method,
|
|
27
|
-
headers,
|
|
28
|
-
body: body ? JSON.stringify(body) : void 0
|
|
29
|
-
});
|
|
30
|
-
const json = await res.json();
|
|
31
|
-
if (!res.ok) {
|
|
32
|
-
const errorMsg = typeof json.error === "string" ? json.error : `Voice service returned ${String(res.status)}`;
|
|
33
|
-
throw new Error(errorMsg);
|
|
34
|
-
}
|
|
35
|
-
return json;
|
|
36
|
-
}
|
|
37
|
-
let daemonIpcClient = null;
|
|
38
|
-
async function connectToDaemon(socketPath, log) {
|
|
39
|
-
try {
|
|
40
|
-
const IPCClientCtor = (await import("@alfe.ai/openclaw")).IPCClient;
|
|
41
|
-
const client = new IPCClientCtor(socketPath, log);
|
|
42
|
-
client.on("connected", async () => {
|
|
43
|
-
log.info("Connected to Alfe daemon — registering voice capabilities...");
|
|
44
|
-
const response = await client.request("capability.register", {
|
|
45
|
-
plugin: "@alfe.ai/openclaw-voice",
|
|
46
|
-
capabilities: [...VOICE_CAPABILITIES]
|
|
47
|
-
});
|
|
48
|
-
if (response.ok) log.info("Voice capabilities registered with daemon");
|
|
49
|
-
else log.warn(`Failed to register voice capabilities: ${response.error?.message ?? "unknown"}`);
|
|
50
|
-
});
|
|
51
|
-
client.on("disconnected", (reason) => {
|
|
52
|
-
log.warn(`Disconnected from Alfe daemon: ${String(reason)}`);
|
|
53
|
-
});
|
|
54
|
-
client.on("error", (err) => {
|
|
55
|
-
log.debug(`Daemon IPC error: ${err.message}`);
|
|
56
|
-
});
|
|
57
|
-
client.start();
|
|
58
|
-
return client;
|
|
59
|
-
} catch {
|
|
60
|
-
log.info("Alfe daemon not available — voice plugin running standalone");
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
function ok(data) {
|
|
65
|
-
return {
|
|
66
|
-
content: [{
|
|
67
|
-
type: "text",
|
|
68
|
-
text: JSON.stringify(data)
|
|
69
|
-
}],
|
|
70
|
-
details: data
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
function errResult(message) {
|
|
74
|
-
return {
|
|
75
|
-
content: [{
|
|
76
|
-
type: "text",
|
|
77
|
-
text: JSON.stringify({ error: message })
|
|
78
|
-
}],
|
|
79
|
-
details: { error: message }
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
function defineTool(def) {
|
|
83
|
-
return {
|
|
84
|
-
name: def.name,
|
|
85
|
-
description: def.description,
|
|
86
|
-
label: def.name,
|
|
87
|
-
parameters: def.parameters,
|
|
88
|
-
execute: async (_toolCallId, params) => {
|
|
89
|
-
try {
|
|
90
|
-
return ok(await def.handler(params));
|
|
91
|
-
} catch (e) {
|
|
92
|
-
return errResult(e.message);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
/** Maps OpenClaw session keys to voice session IDs */
|
|
98
|
-
const sessionKeyToVoiceId = /* @__PURE__ */ new Map();
|
|
99
|
-
/** Maps voice session IDs back to OpenClaw session keys */
|
|
100
|
-
const voiceIdToSessionKey = /* @__PURE__ */ new Map();
|
|
101
|
-
function linkSession(sessionKey, voiceSessionId) {
|
|
102
|
-
sessionKeyToVoiceId.set(sessionKey, voiceSessionId);
|
|
103
|
-
voiceIdToSessionKey.set(voiceSessionId, sessionKey);
|
|
104
|
-
}
|
|
105
|
-
function unlinkSession(sessionKey) {
|
|
106
|
-
const voiceId = sessionKeyToVoiceId.get(sessionKey);
|
|
107
|
-
if (voiceId) {
|
|
108
|
-
voiceIdToSessionKey.delete(voiceId);
|
|
109
|
-
sessionKeyToVoiceId.delete(sessionKey);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
async function findVoiceSessionForAgent(sessionKey) {
|
|
113
|
-
const direct = sessionKeyToVoiceId.get(sessionKey);
|
|
114
|
-
if (direct) return direct;
|
|
115
|
-
const discordMatch = /voice-discord[:-](\S+)/.exec(sessionKey);
|
|
116
|
-
if (discordMatch) try {
|
|
117
|
-
const sessions = (await voiceApi("GET", "/api/discord/sessions")).sessions ?? [];
|
|
118
|
-
for (const s of sessions) if (s.sessionId.includes(discordMatch[1]) || sessionKey.includes(s.sessionId)) return s.sessionId;
|
|
119
|
-
} catch {}
|
|
120
|
-
}
|
|
121
|
-
async function findVoiceSessionForCurrentContext() {
|
|
122
|
-
try {
|
|
123
|
-
const sessions = (await voiceApi("GET", "/api/discord/sessions")).sessions ?? [];
|
|
124
|
-
if (sessions.length === 1) return sessions[0].sessionId;
|
|
125
|
-
} catch {}
|
|
126
|
-
}
|
|
127
|
-
const voiceTools = [
|
|
128
|
-
defineTool({
|
|
129
|
-
name: "voice_hangup",
|
|
130
|
-
description: "Hang up the current voice call or leave the voice channel. Use when the conversation is done or the user asks you to leave.",
|
|
131
|
-
parameters: Type.Object({ sessionId: Type.Optional(Type.String({ description: "Voice session ID. If omitted, uses the current session." })) }),
|
|
132
|
-
handler: async (params) => {
|
|
133
|
-
const sid = params.sessionId ?? await findVoiceSessionForCurrentContext();
|
|
134
|
-
if (!sid) throw new Error("No active voice session found");
|
|
135
|
-
try {
|
|
136
|
-
if ((await voiceApi("GET", `/api/status/${encodeURIComponent(sid)}`)).sessionType === "discord") return await voiceApi("POST", `/api/discord/leave/${encodeURIComponent(sid)}`);
|
|
137
|
-
} catch {}
|
|
138
|
-
return await voiceApi("POST", `/api/leave/${encodeURIComponent(sid)}`);
|
|
139
|
-
}
|
|
140
|
-
}),
|
|
141
|
-
defineTool({
|
|
142
|
-
name: "voice_transfer",
|
|
143
|
-
description: "Transfer the current phone call to another number. Only works for Twilio calls.",
|
|
144
|
-
parameters: Type.Object({
|
|
145
|
-
targetNumber: Type.String({ description: "Phone number to transfer to (E.164 format)" }),
|
|
146
|
-
sessionId: Type.Optional(Type.String({ description: "Voice session ID. If omitted, uses the current session." }))
|
|
147
|
-
}),
|
|
148
|
-
handler: async (params) => {
|
|
149
|
-
const targetNumber = params.targetNumber;
|
|
150
|
-
const sid = params.sessionId ?? await findVoiceSessionForCurrentContext();
|
|
151
|
-
if (!sid) throw new Error("No active voice session found");
|
|
152
|
-
return await voiceApi("POST", `/api/transfer/${encodeURIComponent(sid)}`, { targetNumber });
|
|
153
|
-
}
|
|
154
|
-
}),
|
|
155
|
-
defineTool({
|
|
156
|
-
name: "voice_dtmf",
|
|
157
|
-
description: "Send DTMF tones (dial pad digits) on the current phone call. Only works for Twilio calls.",
|
|
158
|
-
parameters: Type.Object({
|
|
159
|
-
digits: Type.String({ description: "DTMF digits to send (0-9, *, #, w for pause)" }),
|
|
160
|
-
sessionId: Type.Optional(Type.String({ description: "Voice session ID. If omitted, uses the current session." }))
|
|
161
|
-
}),
|
|
162
|
-
handler: async (params) => {
|
|
163
|
-
const digits = params.digits;
|
|
164
|
-
const sid = params.sessionId ?? await findVoiceSessionForCurrentContext();
|
|
165
|
-
if (!sid) throw new Error("No active voice session found");
|
|
166
|
-
return await voiceApi("POST", `/api/dtmf/${encodeURIComponent(sid)}`, { digits });
|
|
167
|
-
}
|
|
168
|
-
})
|
|
169
|
-
];
|
|
170
|
-
const voiceToolNames = new Set(voiceTools.map((t) => t.name));
|
|
171
|
-
const plugin = {
|
|
172
|
-
id: "@alfe.ai/openclaw-voice",
|
|
173
|
-
name: "Alfe Voice Plugin",
|
|
174
|
-
description: "Real-time voice integration — Twilio phone calls, Discord voice channels, and meeting bots",
|
|
175
|
-
version: "0.1.0",
|
|
176
|
-
async activate(api) {
|
|
177
|
-
if (globalThis.__voiceGatewayActivated) {
|
|
178
|
-
api.logger.debug("Alfe Voice plugin already activated, skipping re-init");
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
globalThis.__voiceGatewayActivated = true;
|
|
182
|
-
const log = api.logger;
|
|
183
|
-
log.info("Alfe Voice plugin activating...");
|
|
184
|
-
const fullConfig = api.config ?? {};
|
|
185
|
-
const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
|
|
186
|
-
voiceServiceUrl = pluginConfig.voiceServiceUrl ?? process.env.VOICE_SERVICE_URL ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? process.env.VOICE_SERVICE_PORT ?? "3100")}`;
|
|
187
|
-
voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? process.env.VOICE_SERVICE_API_KEY ?? "";
|
|
188
|
-
log.info(`Voice service: ${voiceServiceUrl}`);
|
|
189
|
-
daemonIpcClient = await connectToDaemon(pluginConfig.daemonSocket ?? process.env.ALFE_GATEWAY_SOCKET ?? DEFAULT_SOCKET_PATH, log);
|
|
190
|
-
for (const tool of voiceTools) api.registerTool(tool);
|
|
191
|
-
log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
|
|
192
|
-
api.registerGatewayMethod("voice.speak", async (...args) => {
|
|
193
|
-
const { sessionId, text } = args[0];
|
|
194
|
-
log.info(`voice.speak RPC → session=${sessionId}, text=${text?.slice(0, 50) ?? "(audio)"}...`);
|
|
195
|
-
if (!text) throw new Error("voice.speak requires text");
|
|
196
|
-
return await voiceApi("POST", "/api/speak", {
|
|
197
|
-
botId: sessionId,
|
|
198
|
-
text
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
log.info("Registered gateway RPC method: voice.speak");
|
|
202
|
-
api.on("session_start", async (...eventArgs) => {
|
|
203
|
-
const key = eventArgs[0].sessionKey;
|
|
204
|
-
if (!key) return;
|
|
205
|
-
if (!(key.includes("voice-discord") || key.includes("voice-twilio") || key.includes("voice-recall"))) return;
|
|
206
|
-
log.info(`Voice session starting: ${key}`);
|
|
207
|
-
const discordMatch = /voice-discord:(\d+):(\d+)/.exec(key);
|
|
208
|
-
if (discordMatch) {
|
|
209
|
-
const [, guildId, channelId] = discordMatch;
|
|
210
|
-
try {
|
|
211
|
-
const result = await voiceApi("POST", "/api/discord/join", {
|
|
212
|
-
guildId,
|
|
213
|
-
channelId
|
|
214
|
-
});
|
|
215
|
-
linkSession(key, result.sessionId);
|
|
216
|
-
log.info(`Auto-joined Discord voice for session ${key} → ${String(result.sessionId)}`);
|
|
217
|
-
} catch (err) {
|
|
218
|
-
log.error(`Failed to auto-join Discord voice: ${err.message}`);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}, { priority: 50 });
|
|
222
|
-
api.on("session_end", async (...eventArgs) => {
|
|
223
|
-
const key = eventArgs[0].sessionKey;
|
|
224
|
-
if (!key) return;
|
|
225
|
-
const voiceId = sessionKeyToVoiceId.get(key);
|
|
226
|
-
if (!voiceId) return;
|
|
227
|
-
log.info(`Voice session ending: ${key} → ${voiceId}`);
|
|
228
|
-
try {
|
|
229
|
-
await voiceApi("POST", `/api/leave/${encodeURIComponent(voiceId)}`);
|
|
230
|
-
} catch (err) {
|
|
231
|
-
log.error(`Error leaving voice on session end: ${err.message}`);
|
|
232
|
-
}
|
|
233
|
-
unlinkSession(key);
|
|
234
|
-
});
|
|
235
|
-
api.on("message_received", (...eventArgs) => {
|
|
236
|
-
const event = eventArgs[0];
|
|
237
|
-
if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
|
|
238
|
-
});
|
|
239
|
-
api.on("before_tool_call", async (...eventArgs) => {
|
|
240
|
-
const event = eventArgs[0];
|
|
241
|
-
const ctx = eventArgs[1];
|
|
242
|
-
if (voiceToolNames.has(event.toolName) && !event.params.sessionId && ctx.sessionKey) {
|
|
243
|
-
const voiceId = await findVoiceSessionForAgent(ctx.sessionKey);
|
|
244
|
-
if (voiceId) event.params.sessionId = voiceId;
|
|
245
|
-
}
|
|
246
|
-
}, { priority: 50 });
|
|
247
|
-
log.info("Alfe Voice plugin activated");
|
|
248
|
-
},
|
|
249
|
-
deactivate(api) {
|
|
250
|
-
globalThis.__voiceGatewayActivated = false;
|
|
251
|
-
const log = api.logger;
|
|
252
|
-
log.info("Alfe Voice plugin deactivating...");
|
|
253
|
-
if (daemonIpcClient) {
|
|
254
|
-
try {
|
|
255
|
-
daemonIpcClient.stop();
|
|
256
|
-
log.info("Disconnected from Alfe daemon");
|
|
257
|
-
} catch (err) {
|
|
258
|
-
log.debug(`Error disconnecting from daemon: ${err.message}`);
|
|
259
|
-
}
|
|
260
|
-
daemonIpcClient = null;
|
|
261
|
-
}
|
|
262
|
-
log.info("Alfe Voice plugin deactivated");
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
|
-
//#endregion
|
|
1
|
+
import plugin from "./plugin.js";
|
|
266
2
|
export { plugin as default };
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { TSchema } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
//#region src/plugin.d.ts
|
|
4
|
+
|
|
5
|
+
interface Logger {
|
|
6
|
+
info(msg: string, ...args: unknown[]): void;
|
|
7
|
+
warn(msg: string, ...args: unknown[]): void;
|
|
8
|
+
error(msg: string, ...args: unknown[]): void;
|
|
9
|
+
debug(msg: string, ...args: unknown[]): void;
|
|
10
|
+
}
|
|
11
|
+
interface VoicePluginConfig {
|
|
12
|
+
voiceServiceUrl?: string;
|
|
13
|
+
voiceServicePort?: string | number;
|
|
14
|
+
voiceServiceApiKey?: string;
|
|
15
|
+
daemonSocket?: string;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
interface OpenClawConfig {
|
|
19
|
+
plugins?: {
|
|
20
|
+
entries?: Record<string, {
|
|
21
|
+
config?: VoicePluginConfig;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}>;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
interface OpenClawPluginApi {
|
|
29
|
+
logger: Logger;
|
|
30
|
+
config?: OpenClawConfig;
|
|
31
|
+
registerTool(tool: ToolDef): void;
|
|
32
|
+
registerGatewayMethod(name: string, handler: (...args: unknown[]) => Promise<unknown>): void;
|
|
33
|
+
on(event: string, handler: (...args: unknown[]) => void | Promise<void>, options?: {
|
|
34
|
+
priority?: number;
|
|
35
|
+
}): void;
|
|
36
|
+
}
|
|
37
|
+
interface ToolDef {
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
label: string;
|
|
41
|
+
parameters: TSchema;
|
|
42
|
+
execute: (toolCallId: string, params: Record<string, unknown>) => Promise<{
|
|
43
|
+
content: {
|
|
44
|
+
type: 'text';
|
|
45
|
+
text: string;
|
|
46
|
+
}[];
|
|
47
|
+
details: unknown;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
declare const plugin: {
|
|
51
|
+
id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
version: string;
|
|
55
|
+
activate(api: OpenClawPluginApi): Promise<void>;
|
|
56
|
+
deactivate(api: OpenClawPluginApi): void;
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { plugin as default };
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { DEFAULT_SOCKET_PATH, resolveConfig } from "@alfe.ai/config";
|
|
3
|
+
//#region src/plugin.ts
|
|
4
|
+
/**
|
|
5
|
+
* @alfe/voice-plugin — OpenClaw native plugin
|
|
6
|
+
*
|
|
7
|
+
* Thin client that registers voice tools with OpenClaw and forwards
|
|
8
|
+
* TTS/STT operations to the voice service's public API.
|
|
9
|
+
*
|
|
10
|
+
* In the new architecture, voice service is a channel-agnostic pipeline
|
|
11
|
+
* brain. Channel-specific operations (hangup, transfer, DTMF) are handled
|
|
12
|
+
* by the respective channel services (Discord, Twilio, etc.), not voice.
|
|
13
|
+
*
|
|
14
|
+
* This plugin provides:
|
|
15
|
+
* - voice.speak RPC — converts text to audio via POST /voice/tts
|
|
16
|
+
* - voice_hangup tool — placeholder (requires channel service)
|
|
17
|
+
* - voice_transfer tool — placeholder (requires Twilio service)
|
|
18
|
+
* - voice_dtmf tool �� placeholder (requires Twilio service)
|
|
19
|
+
*/
|
|
20
|
+
const VOICE_CAPABILITIES = [
|
|
21
|
+
"voice.call",
|
|
22
|
+
"voice.answer",
|
|
23
|
+
"voice.dtmf",
|
|
24
|
+
"voice.hangup"
|
|
25
|
+
];
|
|
26
|
+
let voiceServiceUrl = "";
|
|
27
|
+
let voiceServiceApiKey = "";
|
|
28
|
+
async function voiceApi(method, path, body) {
|
|
29
|
+
const url = `${voiceServiceUrl}${path}`;
|
|
30
|
+
const headers = { "Content-Type": "application/json" };
|
|
31
|
+
if (voiceServiceApiKey) headers["x-api-key"] = voiceServiceApiKey;
|
|
32
|
+
const res = await fetch(url, {
|
|
33
|
+
method,
|
|
34
|
+
headers,
|
|
35
|
+
body: body ? JSON.stringify(body) : void 0
|
|
36
|
+
});
|
|
37
|
+
const json = await res.json();
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const errorMsg = typeof json.error === "string" ? json.error : `Voice service returned ${String(res.status)}`;
|
|
40
|
+
throw new Error(errorMsg);
|
|
41
|
+
}
|
|
42
|
+
return json;
|
|
43
|
+
}
|
|
44
|
+
let daemonIpcClient = null;
|
|
45
|
+
async function connectToDaemon(socketPath, log) {
|
|
46
|
+
try {
|
|
47
|
+
const IPCClientCtor = (await import("@alfe.ai/openclaw")).IPCClient;
|
|
48
|
+
const client = new IPCClientCtor(socketPath, log);
|
|
49
|
+
client.on("connected", async () => {
|
|
50
|
+
log.info("Connected to Alfe daemon — registering voice capabilities...");
|
|
51
|
+
const response = await client.request("capability.register", {
|
|
52
|
+
plugin: "@alfe.ai/openclaw-voice",
|
|
53
|
+
capabilities: [...VOICE_CAPABILITIES]
|
|
54
|
+
});
|
|
55
|
+
if (response.ok) log.info("Voice capabilities registered with daemon");
|
|
56
|
+
else log.warn(`Failed to register voice capabilities: ${response.error?.message ?? "unknown"}`);
|
|
57
|
+
});
|
|
58
|
+
client.on("disconnected", (reason) => {
|
|
59
|
+
log.warn(`Disconnected from Alfe daemon: ${String(reason)}`);
|
|
60
|
+
});
|
|
61
|
+
client.on("error", (err) => {
|
|
62
|
+
log.debug(`Daemon IPC error: ${err.message}`);
|
|
63
|
+
});
|
|
64
|
+
client.start();
|
|
65
|
+
return client;
|
|
66
|
+
} catch {
|
|
67
|
+
log.info("Alfe daemon not available — voice plugin running standalone");
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function ok(data) {
|
|
72
|
+
return {
|
|
73
|
+
content: [{
|
|
74
|
+
type: "text",
|
|
75
|
+
text: JSON.stringify(data)
|
|
76
|
+
}],
|
|
77
|
+
details: data
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function errResult(message) {
|
|
81
|
+
return {
|
|
82
|
+
content: [{
|
|
83
|
+
type: "text",
|
|
84
|
+
text: JSON.stringify({ error: message })
|
|
85
|
+
}],
|
|
86
|
+
details: { error: message }
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function defineTool(def) {
|
|
90
|
+
return {
|
|
91
|
+
name: def.name,
|
|
92
|
+
description: def.description,
|
|
93
|
+
label: def.name,
|
|
94
|
+
parameters: def.parameters,
|
|
95
|
+
execute: async (_toolCallId, params) => {
|
|
96
|
+
try {
|
|
97
|
+
return ok(await def.handler(params));
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return errResult(e.message);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const voiceTools = [
|
|
105
|
+
defineTool({
|
|
106
|
+
name: "voice_hangup",
|
|
107
|
+
description: "Hang up the current voice call or leave the voice channel. Use when the conversation is done or the user asks you to leave.",
|
|
108
|
+
parameters: Type.Object({ sessionId: Type.Optional(Type.String({ description: "Voice session ID." })) }),
|
|
109
|
+
handler: () => {
|
|
110
|
+
throw new Error("voice_hangup requires a channel service (Discord/Twilio). The voice service no longer manages channels directly.");
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
defineTool({
|
|
114
|
+
name: "voice_transfer",
|
|
115
|
+
description: "Transfer the current phone call to another number. Only works for Twilio calls.",
|
|
116
|
+
parameters: Type.Object({
|
|
117
|
+
targetNumber: Type.String({ description: "Phone number to transfer to (E.164 format)" }),
|
|
118
|
+
sessionId: Type.Optional(Type.String({ description: "Voice session ID." }))
|
|
119
|
+
}),
|
|
120
|
+
handler: () => {
|
|
121
|
+
throw new Error("voice_transfer requires the Twilio service. The voice service no longer manages phone calls directly.");
|
|
122
|
+
}
|
|
123
|
+
}),
|
|
124
|
+
defineTool({
|
|
125
|
+
name: "voice_dtmf",
|
|
126
|
+
description: "Send DTMF tones (dial pad digits) on the current phone call. Only works for Twilio calls.",
|
|
127
|
+
parameters: Type.Object({
|
|
128
|
+
digits: Type.String({ description: "DTMF digits to send (0-9, *, #, w for pause)" }),
|
|
129
|
+
sessionId: Type.Optional(Type.String({ description: "Voice session ID." }))
|
|
130
|
+
}),
|
|
131
|
+
handler: () => {
|
|
132
|
+
throw new Error("voice_dtmf requires the Twilio service. The voice service no longer manages phone calls directly.");
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
];
|
|
136
|
+
const plugin = {
|
|
137
|
+
id: "@alfe.ai/openclaw-voice",
|
|
138
|
+
name: "Alfe Voice Plugin",
|
|
139
|
+
description: "Voice integration — TTS/STT via the voice service, channel-specific operations via channel services",
|
|
140
|
+
version: "0.2.0",
|
|
141
|
+
async activate(api) {
|
|
142
|
+
if (globalThis.__voiceGatewayActivated) {
|
|
143
|
+
api.logger.debug("Alfe Voice plugin already activated, skipping re-init");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
globalThis.__voiceGatewayActivated = true;
|
|
147
|
+
const log = api.logger;
|
|
148
|
+
log.info("Alfe Voice plugin activating...");
|
|
149
|
+
const fullConfig = api.config ?? {};
|
|
150
|
+
const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
|
|
151
|
+
voiceServiceUrl = pluginConfig.voiceServiceUrl ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? "3100")}`;
|
|
152
|
+
voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? "";
|
|
153
|
+
log.info(`Voice service: ${voiceServiceUrl}`);
|
|
154
|
+
const alfeConfig = await resolveConfig().catch(() => null);
|
|
155
|
+
daemonIpcClient = await connectToDaemon(pluginConfig.daemonSocket ?? alfeConfig?.socketPath ?? DEFAULT_SOCKET_PATH, log);
|
|
156
|
+
for (const tool of voiceTools) api.registerTool(tool);
|
|
157
|
+
log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
|
|
158
|
+
api.registerGatewayMethod("voice.speak", async (...args) => {
|
|
159
|
+
const { text } = args[0];
|
|
160
|
+
log.info(`voice.speak RPC → text=${text?.slice(0, 50) ?? "(none)"}...`);
|
|
161
|
+
if (!text) throw new Error("voice.speak requires text");
|
|
162
|
+
return await voiceApi("POST", "/voice/tts", { text });
|
|
163
|
+
});
|
|
164
|
+
log.info("Registered gateway RPC method: voice.speak");
|
|
165
|
+
api.on("message_received", (...eventArgs) => {
|
|
166
|
+
const event = eventArgs[0];
|
|
167
|
+
if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
|
|
168
|
+
});
|
|
169
|
+
log.info("Alfe Voice plugin activated");
|
|
170
|
+
},
|
|
171
|
+
deactivate(api) {
|
|
172
|
+
globalThis.__voiceGatewayActivated = false;
|
|
173
|
+
const log = api.logger;
|
|
174
|
+
log.info("Alfe Voice plugin deactivating...");
|
|
175
|
+
if (daemonIpcClient) {
|
|
176
|
+
try {
|
|
177
|
+
daemonIpcClient.stop();
|
|
178
|
+
log.info("Disconnected from Alfe daemon");
|
|
179
|
+
} catch (err) {
|
|
180
|
+
log.debug(`Error disconnecting from daemon: ${err.message}`);
|
|
181
|
+
}
|
|
182
|
+
daemonIpcClient = null;
|
|
183
|
+
}
|
|
184
|
+
log.info("Alfe Voice plugin deactivated");
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
//#endregion
|
|
188
|
+
export { plugin as default };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfe.ai/openclaw-voice",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "OpenClaw voice plugin for Alfe — Discord audio, Twilio, Recall.ai",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"import": "./dist/index.js",
|
|
11
11
|
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./plugin": {
|
|
14
|
+
"import": "./dist/plugin.js",
|
|
15
|
+
"types": "./dist/plugin.d.ts"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
18
|
"openclaw": {
|
|
@@ -20,7 +24,8 @@
|
|
|
20
24
|
"dist"
|
|
21
25
|
],
|
|
22
26
|
"dependencies": {
|
|
23
|
-
"@sinclair/typebox": "^0.34.48"
|
|
27
|
+
"@sinclair/typebox": "^0.34.48",
|
|
28
|
+
"@alfe.ai/config": "0.0.5"
|
|
24
29
|
},
|
|
25
30
|
"scripts": {
|
|
26
31
|
"build": "tsdown",
|