@epiphytic/openclaw-discord-voice 0.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/README.md +54 -0
- package/index.ts +669 -0
- package/openclaw.plugin.json +180 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @epiphytic/openclaw-discord-voice
|
|
2
|
+
|
|
3
|
+
OpenClaw plugin for real-time Discord voice assistant with fully local AI processing.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Voice conversations** — join Discord voice channels and interact via speech
|
|
8
|
+
- **Local STT** — whisper.cpp with GPU acceleration
|
|
9
|
+
- **Local LLM** — any OpenAI-compatible model (Qwen, GLM, etc.)
|
|
10
|
+
- **Local TTS** — Kokoro-82M for natural speech synthesis
|
|
11
|
+
- **Tool calling** — weather, time, web search
|
|
12
|
+
- **Escalation** — complex requests route to the main OpenClaw agent seamlessly
|
|
13
|
+
- **Text-to-voice bridge** — text channel messages read aloud in voice
|
|
14
|
+
- **Speaker identification** — optional Resemblyzer-based speaker recognition
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- Python 3.11+ with py-cord, whisper.cpp, Kokoro TTS
|
|
19
|
+
- Local LLM server (vLLM, llama.cpp, etc.) with OpenAI-compatible API
|
|
20
|
+
- Discord bot with Voice, Message Content, and Server Members intents
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
openclaw plugins install @epiphytic/openclaw-discord-voice
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
```json5
|
|
31
|
+
{
|
|
32
|
+
plugins: {
|
|
33
|
+
entries: {
|
|
34
|
+
"discord-voice": {
|
|
35
|
+
enabled: true,
|
|
36
|
+
config: {
|
|
37
|
+
botToken: "your-discord-bot-token",
|
|
38
|
+
guildIds: [123456789],
|
|
39
|
+
botName: "Assistant",
|
|
40
|
+
llmUrl: "http://localhost:8000/v1/chat/completions",
|
|
41
|
+
whisperUrl: "http://localhost:8001/inference",
|
|
42
|
+
kokoroUrl: "http://localhost:8002/v1/audio/speech"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
See the [repository](https://github.com/Epiphytic/openclaw-voice) for full configuration options and setup guide.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @epiphytic/openclaw-discord-voice
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw plugin that manages the Discord voice bot (Chip) as a child process.
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* - Background service: spawn, monitor, and restart the Python voice bot
|
|
8
|
+
* - Escalation RPC HTTP handler: receive requests from Chip, route to the main agent, return text
|
|
9
|
+
* - Agent tools (optional): voice_join, voice_leave, voice_speak, voice_status
|
|
10
|
+
*
|
|
11
|
+
* Config is passed to the Python process as JSON written to its stdin.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Type } from "@sinclair/typebox";
|
|
15
|
+
import { exec as execCb, spawn, type ChildProcess } from "node:child_process";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import * as http from "node:http";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
23
|
+
|
|
24
|
+
const execAsync = promisify(execCb);
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Config shape (mirrors openclaw.plugin.json configSchema)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
interface DiscordVoiceConfig {
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
pythonPath?: string;
|
|
33
|
+
botToken: string;
|
|
34
|
+
guildIds: number[];
|
|
35
|
+
transcriptChannelId?: string;
|
|
36
|
+
llmModel?: string;
|
|
37
|
+
llmUrl?: string;
|
|
38
|
+
whisperUrl?: string;
|
|
39
|
+
kokoroUrl?: string;
|
|
40
|
+
ttsVoice?: string;
|
|
41
|
+
botName?: string;
|
|
42
|
+
mainAgentName?: string;
|
|
43
|
+
defaultLocation?: string;
|
|
44
|
+
defaultTimezone?: string;
|
|
45
|
+
extraContext?: string;
|
|
46
|
+
whisperPrompt?: string;
|
|
47
|
+
vadMinSpeechMs?: number;
|
|
48
|
+
speechEndDelayMs?: number;
|
|
49
|
+
channelContextMessages?: number;
|
|
50
|
+
ttsReadChannel?: boolean;
|
|
51
|
+
correctionsFile?: string;
|
|
52
|
+
/** Port for the Python bot's HTTP control/health server (default 18790) */
|
|
53
|
+
controlPort?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Python discovery
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const VENV_CANDIDATES = [
|
|
61
|
+
path.join(process.env.HOME ?? "/root", ".openclaw", "workspace", "openclaw-voice", ".venv"),
|
|
62
|
+
path.join(process.env.HOME ?? "/root", ".venv"),
|
|
63
|
+
path.join(process.env.HOME ?? "/root", "venv"),
|
|
64
|
+
"/opt/openclaw-voice/.venv",
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
async function findPython(override?: string): Promise<string> {
|
|
68
|
+
if (override && existsSync(override)) {
|
|
69
|
+
return override;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const base of VENV_CANDIDATES) {
|
|
73
|
+
const candidate = path.join(base, "bin", "python");
|
|
74
|
+
if (existsSync(candidate)) {
|
|
75
|
+
return candidate;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fall back to PATH
|
|
80
|
+
try {
|
|
81
|
+
const { stdout } = await execAsync("which python3 || which python");
|
|
82
|
+
const found = stdout.trim();
|
|
83
|
+
if (found) {
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// ignore
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return "python3";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Core agent deps (mirrors voice-call's core-bridge.ts pattern)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
type CoreAgentDeps = {
|
|
98
|
+
resolveAgentDir: (cfg: unknown, agentId: string) => string;
|
|
99
|
+
resolveAgentWorkspaceDir: (cfg: unknown, agentId: string) => string;
|
|
100
|
+
resolveAgentIdentity: (cfg: unknown, agentId: string) => { name?: string | null } | null;
|
|
101
|
+
resolveThinkingDefault: (params: { cfg: unknown; provider?: string; model?: string }) => string;
|
|
102
|
+
runEmbeddedPiAgent: (params: {
|
|
103
|
+
sessionId: string;
|
|
104
|
+
sessionKey?: string;
|
|
105
|
+
messageProvider?: string;
|
|
106
|
+
sessionFile: string;
|
|
107
|
+
workspaceDir: string;
|
|
108
|
+
config?: unknown;
|
|
109
|
+
prompt: string;
|
|
110
|
+
provider?: string;
|
|
111
|
+
model?: string;
|
|
112
|
+
thinkLevel?: string;
|
|
113
|
+
verboseLevel?: string;
|
|
114
|
+
timeoutMs: number;
|
|
115
|
+
runId: string;
|
|
116
|
+
lane?: string;
|
|
117
|
+
extraSystemPrompt?: string;
|
|
118
|
+
agentDir?: string;
|
|
119
|
+
}) => Promise<{ payloads?: Array<{ text?: string; isError?: boolean }>; meta?: { aborted?: boolean } }>;
|
|
120
|
+
resolveAgentTimeoutMs: (opts: { cfg: unknown }) => number;
|
|
121
|
+
ensureAgentWorkspace: (params?: { dir: string }) => Promise<void>;
|
|
122
|
+
resolveStorePath: (store?: string, opts?: { agentId?: string }) => string;
|
|
123
|
+
loadSessionStore: (storePath: string) => Record<string, unknown>;
|
|
124
|
+
saveSessionStore: (storePath: string, store: Record<string, unknown>) => Promise<void>;
|
|
125
|
+
resolveSessionFilePath: (sessionId: string, entry: unknown, opts?: { agentId?: string }) => string;
|
|
126
|
+
DEFAULT_MODEL: string;
|
|
127
|
+
DEFAULT_PROVIDER: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
let _coreDepsPromise: Promise<CoreAgentDeps> | null = null;
|
|
131
|
+
|
|
132
|
+
function resolveOpenClawRoot(): string {
|
|
133
|
+
const override = process.env.OPENCLAW_ROOT?.trim();
|
|
134
|
+
if (override) return override;
|
|
135
|
+
|
|
136
|
+
const candidates: string[] = [];
|
|
137
|
+
if (process.argv[1]) candidates.push(path.dirname(process.argv[1]));
|
|
138
|
+
candidates.push(process.cwd());
|
|
139
|
+
try {
|
|
140
|
+
candidates.push(path.dirname(fileURLToPath(import.meta.url)));
|
|
141
|
+
} catch { /* ignore */ }
|
|
142
|
+
|
|
143
|
+
for (const start of candidates) {
|
|
144
|
+
let dir = start;
|
|
145
|
+
for (;;) {
|
|
146
|
+
const pkgPath = path.join(dir, "package.json");
|
|
147
|
+
try {
|
|
148
|
+
if (fs.existsSync(pkgPath)) {
|
|
149
|
+
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
150
|
+
const pkg = JSON.parse(raw) as { name?: string };
|
|
151
|
+
if (pkg.name === "openclaw") return dir;
|
|
152
|
+
}
|
|
153
|
+
} catch { /* ignore */ }
|
|
154
|
+
const parent = path.dirname(dir);
|
|
155
|
+
if (parent === dir) break;
|
|
156
|
+
dir = parent;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw new Error("Cannot resolve OpenClaw root. Set OPENCLAW_ROOT.");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function loadCoreAgentDeps(): Promise<CoreAgentDeps> {
|
|
164
|
+
if (_coreDepsPromise) return _coreDepsPromise;
|
|
165
|
+
_coreDepsPromise = (async () => {
|
|
166
|
+
const distPath = path.join(resolveOpenClawRoot(), "dist", "extensionAPI.js");
|
|
167
|
+
if (!fs.existsSync(distPath)) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return await import(pathToFileURL(distPath).href) as CoreAgentDeps;
|
|
173
|
+
})();
|
|
174
|
+
return _coreDepsPromise;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Agent invocation
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
async function invokeMainAgent(params: {
|
|
182
|
+
message: string;
|
|
183
|
+
guildId: string;
|
|
184
|
+
channelId: string;
|
|
185
|
+
userId: string;
|
|
186
|
+
cfg: unknown;
|
|
187
|
+
}): Promise<string> {
|
|
188
|
+
const { message, guildId, channelId, userId, cfg } = params;
|
|
189
|
+
|
|
190
|
+
const deps = await loadCoreAgentDeps();
|
|
191
|
+
|
|
192
|
+
const agentId = "main";
|
|
193
|
+
const sessionKey = `discord-voice:${guildId}:${channelId}`;
|
|
194
|
+
const storePath = deps.resolveStorePath((cfg as any)?.session?.store, { agentId });
|
|
195
|
+
const agentDir = deps.resolveAgentDir(cfg, agentId);
|
|
196
|
+
const workspaceDir = deps.resolveAgentWorkspaceDir(cfg, agentId);
|
|
197
|
+
await deps.ensureAgentWorkspace({ dir: workspaceDir });
|
|
198
|
+
|
|
199
|
+
const sessionStore = deps.loadSessionStore(storePath);
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
type SessionEntry = { sessionId: string; updatedAt: number };
|
|
202
|
+
let sessionEntry = sessionStore[sessionKey] as SessionEntry | undefined;
|
|
203
|
+
if (!sessionEntry) {
|
|
204
|
+
const { randomUUID } = await import("node:crypto");
|
|
205
|
+
sessionEntry = { sessionId: randomUUID(), updatedAt: now };
|
|
206
|
+
sessionStore[sessionKey] = sessionEntry;
|
|
207
|
+
await deps.saveSessionStore(storePath, sessionStore);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sessionId = sessionEntry.sessionId;
|
|
211
|
+
const sessionFile = deps.resolveSessionFilePath(sessionId, sessionEntry, { agentId });
|
|
212
|
+
|
|
213
|
+
const modelRef = (cfg as any)?.agents?.list?.[0]?.model ?? deps.DEFAULT_MODEL;
|
|
214
|
+
const slashIdx = modelRef.indexOf("/");
|
|
215
|
+
const provider = slashIdx === -1 ? deps.DEFAULT_PROVIDER : modelRef.slice(0, slashIdx);
|
|
216
|
+
const model = slashIdx === -1 ? modelRef : modelRef.slice(slashIdx + 1);
|
|
217
|
+
|
|
218
|
+
const thinkLevel = deps.resolveThinkingDefault({ cfg, provider, model });
|
|
219
|
+
const timeoutMs = deps.resolveAgentTimeoutMs({ cfg });
|
|
220
|
+
const runId = `discord-voice:${guildId}:${Date.now()}`;
|
|
221
|
+
|
|
222
|
+
const result = await deps.runEmbeddedPiAgent({
|
|
223
|
+
sessionId,
|
|
224
|
+
sessionKey,
|
|
225
|
+
messageProvider: "discord-voice",
|
|
226
|
+
sessionFile,
|
|
227
|
+
workspaceDir,
|
|
228
|
+
config: cfg,
|
|
229
|
+
prompt: message,
|
|
230
|
+
provider,
|
|
231
|
+
model,
|
|
232
|
+
thinkLevel,
|
|
233
|
+
verboseLevel: "off",
|
|
234
|
+
timeoutMs,
|
|
235
|
+
runId,
|
|
236
|
+
lane: "discord-voice",
|
|
237
|
+
agentDir,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const texts = (result.payloads ?? [])
|
|
241
|
+
.filter((p) => p.text && !p.isError)
|
|
242
|
+
.map((p) => p.text?.trim())
|
|
243
|
+
.filter(Boolean);
|
|
244
|
+
|
|
245
|
+
return texts.join(" ") || "I couldn't generate a response right now.";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// HTTP helpers
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
|
253
|
+
return new Promise((resolve, reject) => {
|
|
254
|
+
let body = "";
|
|
255
|
+
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
256
|
+
req.on("end", () => {
|
|
257
|
+
try { resolve(JSON.parse(body)); }
|
|
258
|
+
catch (e) { reject(new Error("Invalid JSON body")); }
|
|
259
|
+
});
|
|
260
|
+
req.on("error", reject);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function sendJson(res: http.ServerResponse, status: number, data: unknown): void {
|
|
265
|
+
const body = JSON.stringify(data);
|
|
266
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
267
|
+
res.end(body);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Process manager
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
const MAX_RESTARTS = 3;
|
|
275
|
+
const RESTART_DELAY_MS = 5_000;
|
|
276
|
+
const STOP_GRACE_MS = 5_000;
|
|
277
|
+
|
|
278
|
+
class VoiceBotProcess {
|
|
279
|
+
private proc: ChildProcess | null = null;
|
|
280
|
+
private restarts = 0;
|
|
281
|
+
private stopping = false;
|
|
282
|
+
private config: DiscordVoiceConfig;
|
|
283
|
+
private pythonPath: string;
|
|
284
|
+
private logger: OpenClawPluginApi["logger"];
|
|
285
|
+
private gatewayPort: number;
|
|
286
|
+
|
|
287
|
+
constructor(opts: {
|
|
288
|
+
config: DiscordVoiceConfig;
|
|
289
|
+
pythonPath: string;
|
|
290
|
+
logger: OpenClawPluginApi["logger"];
|
|
291
|
+
gatewayPort: number;
|
|
292
|
+
}) {
|
|
293
|
+
this.config = opts.config;
|
|
294
|
+
this.pythonPath = opts.pythonPath;
|
|
295
|
+
this.logger = opts.logger;
|
|
296
|
+
this.gatewayPort = opts.gatewayPort;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async start(): Promise<void> {
|
|
300
|
+
this.stopping = false;
|
|
301
|
+
this.restarts = 0;
|
|
302
|
+
await this._spawn();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private buildConfigJson(): string {
|
|
306
|
+
const controlPort = this.config.controlPort ?? 18790;
|
|
307
|
+
const payload = {
|
|
308
|
+
token: this.config.botToken,
|
|
309
|
+
guild_ids: this.config.guildIds,
|
|
310
|
+
transcript_channel_id: this.config.transcriptChannelId ?? null,
|
|
311
|
+
llm_model: this.config.llmModel ?? "Qwen/Qwen3-30B-A3B-Instruct-2507",
|
|
312
|
+
llm_url: this.config.llmUrl ?? "http://localhost:8000/v1/chat/completions",
|
|
313
|
+
whisper_url: this.config.whisperUrl ?? "http://localhost:8001/inference",
|
|
314
|
+
kokoro_url: this.config.kokoroUrl ?? "http://localhost:8002/v1/audio/speech",
|
|
315
|
+
tts_voice: this.config.ttsVoice ?? "af_heart",
|
|
316
|
+
bot_name: this.config.botName ?? "Assistant",
|
|
317
|
+
main_agent_name: this.config.mainAgentName ?? "main agent",
|
|
318
|
+
default_location: this.config.defaultLocation ?? "",
|
|
319
|
+
default_timezone: this.config.defaultTimezone ?? "UTC",
|
|
320
|
+
extra_context: this.config.extraContext ?? "",
|
|
321
|
+
whisper_prompt: this.config.whisperPrompt ?? "",
|
|
322
|
+
vad_min_speech_ms: this.config.vadMinSpeechMs ?? 500,
|
|
323
|
+
speech_end_delay_ms: this.config.speechEndDelayMs ?? 1000,
|
|
324
|
+
channel_context_messages: this.config.channelContextMessages ?? 10,
|
|
325
|
+
tts_read_channel: this.config.ttsReadChannel ?? true,
|
|
326
|
+
corrections_file: this.config.correctionsFile ?? "",
|
|
327
|
+
control_port: controlPort,
|
|
328
|
+
gateway_port: this.gatewayPort,
|
|
329
|
+
};
|
|
330
|
+
return JSON.stringify(payload);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async _spawn(): Promise<void> {
|
|
334
|
+
if (this.stopping) return;
|
|
335
|
+
|
|
336
|
+
const configJson = this.buildConfigJson();
|
|
337
|
+
|
|
338
|
+
this.logger.info(`[discord-voice] Spawning Python bot (python: ${this.pythonPath})`);
|
|
339
|
+
|
|
340
|
+
this.proc = spawn(
|
|
341
|
+
this.pythonPath,
|
|
342
|
+
["-m", "openclaw_voice.cli", "discord-bot", "--config-json", "-"],
|
|
343
|
+
{
|
|
344
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
345
|
+
env: { ...process.env },
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// Write config JSON to stdin, then close it
|
|
350
|
+
if (this.proc.stdin) {
|
|
351
|
+
this.proc.stdin.write(configJson + "\n");
|
|
352
|
+
this.proc.stdin.end();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
this.proc.on("exit", (code, signal) => {
|
|
356
|
+
if (this.stopping) {
|
|
357
|
+
this.logger.info(`[discord-voice] Bot process exited (stopping): code=${code} signal=${signal}`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.logger.warn(`[discord-voice] Bot process exited unexpectedly: code=${code} signal=${signal}`);
|
|
362
|
+
|
|
363
|
+
if (this.restarts < MAX_RESTARTS) {
|
|
364
|
+
this.restarts++;
|
|
365
|
+
this.logger.info(`[discord-voice] Restart ${this.restarts}/${MAX_RESTARTS} in ${RESTART_DELAY_MS}ms…`);
|
|
366
|
+
setTimeout(() => { this._spawn().catch(() => {}); }, RESTART_DELAY_MS);
|
|
367
|
+
} else {
|
|
368
|
+
this.logger.error(`[discord-voice] Bot process crashed ${MAX_RESTARTS} times — giving up.`);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
this.proc.on("error", (err) => {
|
|
373
|
+
this.logger.error(`[discord-voice] Bot process error: ${err.message}`);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async stop(): Promise<void> {
|
|
378
|
+
this.stopping = true;
|
|
379
|
+
const proc = this.proc;
|
|
380
|
+
if (!proc || proc.exitCode !== null) return;
|
|
381
|
+
|
|
382
|
+
proc.kill("SIGTERM");
|
|
383
|
+
|
|
384
|
+
await new Promise<void>((resolve) => {
|
|
385
|
+
const timer = setTimeout(() => {
|
|
386
|
+
this.logger.warn("[discord-voice] Graceful stop timed out — SIGKILL");
|
|
387
|
+
proc.kill("SIGKILL");
|
|
388
|
+
resolve();
|
|
389
|
+
}, STOP_GRACE_MS);
|
|
390
|
+
|
|
391
|
+
proc.once("exit", () => {
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
resolve();
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
this.proc = null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
get controlPort(): number {
|
|
401
|
+
return this.config.controlPort ?? 18790;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async httpPost(path: string, body: unknown): Promise<unknown> {
|
|
405
|
+
const port = this.controlPort;
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
const data = JSON.stringify(body);
|
|
408
|
+
const req = http.request(
|
|
409
|
+
{ hostname: "127.0.0.1", port, path, method: "POST",
|
|
410
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } },
|
|
411
|
+
(res) => {
|
|
412
|
+
let raw = "";
|
|
413
|
+
res.on("data", (c: Buffer) => { raw += c; });
|
|
414
|
+
res.on("end", () => {
|
|
415
|
+
try { resolve(JSON.parse(raw)); }
|
|
416
|
+
catch { resolve({ raw }); }
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
req.on("error", reject);
|
|
421
|
+
req.setTimeout(30_000, () => { req.destroy(new Error("Request timed out")); });
|
|
422
|
+
req.write(data);
|
|
423
|
+
req.end();
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async httpGet(path: string): Promise<unknown> {
|
|
428
|
+
const port = this.controlPort;
|
|
429
|
+
return new Promise((resolve, reject) => {
|
|
430
|
+
const req = http.request(
|
|
431
|
+
{ hostname: "127.0.0.1", port, path, method: "GET" },
|
|
432
|
+
(res) => {
|
|
433
|
+
let raw = "";
|
|
434
|
+
res.on("data", (c: Buffer) => { raw += c; });
|
|
435
|
+
res.on("end", () => {
|
|
436
|
+
try { resolve(JSON.parse(raw)); }
|
|
437
|
+
catch { resolve({ raw }); }
|
|
438
|
+
});
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
req.on("error", reject);
|
|
442
|
+
req.setTimeout(10_000, () => { req.destroy(new Error("Request timed out")); });
|
|
443
|
+
req.end();
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Plugin registration
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
export default function register(api: OpenClawPluginApi): void {
|
|
453
|
+
const raw = (api.pluginConfig ?? {}) as Record<string, unknown>;
|
|
454
|
+
|
|
455
|
+
if (raw.enabled === false) {
|
|
456
|
+
api.logger.info("[discord-voice] Plugin disabled in config");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const cfg: DiscordVoiceConfig = {
|
|
461
|
+
botToken: (raw.botToken as string) ?? "",
|
|
462
|
+
guildIds: (raw.guildIds as number[]) ?? [],
|
|
463
|
+
transcriptChannelId: raw.transcriptChannelId as string | undefined,
|
|
464
|
+
llmModel: raw.llmModel as string | undefined,
|
|
465
|
+
llmUrl: raw.llmUrl as string | undefined,
|
|
466
|
+
whisperUrl: raw.whisperUrl as string | undefined,
|
|
467
|
+
kokoroUrl: raw.kokoroUrl as string | undefined,
|
|
468
|
+
ttsVoice: raw.ttsVoice as string | undefined,
|
|
469
|
+
botName: raw.botName as string | undefined,
|
|
470
|
+
mainAgentName: raw.mainAgentName as string | undefined,
|
|
471
|
+
defaultLocation: raw.defaultLocation as string | undefined,
|
|
472
|
+
defaultTimezone: raw.defaultTimezone as string | undefined,
|
|
473
|
+
extraContext: raw.extraContext as string | undefined,
|
|
474
|
+
whisperPrompt: raw.whisperPrompt as string | undefined,
|
|
475
|
+
vadMinSpeechMs: raw.vadMinSpeechMs as number | undefined,
|
|
476
|
+
speechEndDelayMs: raw.speechEndDelayMs as number | undefined,
|
|
477
|
+
channelContextMessages: raw.channelContextMessages as number | undefined,
|
|
478
|
+
ttsReadChannel: raw.ttsReadChannel as boolean | undefined,
|
|
479
|
+
correctionsFile: raw.correctionsFile as string | undefined,
|
|
480
|
+
controlPort: (raw.controlPort as number | undefined) ?? 18790,
|
|
481
|
+
pythonPath: raw.pythonPath as string | undefined,
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
if (!cfg.botToken) {
|
|
485
|
+
api.logger.error("[discord-voice] botToken is required but not configured");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (!cfg.guildIds.length) {
|
|
489
|
+
api.logger.warn("[discord-voice] guildIds is empty — bot will not join any guilds");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Derive gateway HTTP port from OpenClaw config
|
|
493
|
+
const gatewayPort: number = (api.config as any)?.gateway?.port ?? 18789;
|
|
494
|
+
|
|
495
|
+
let botManager: VoiceBotProcess | null = null;
|
|
496
|
+
|
|
497
|
+
// -------------------------------------------------------------------------
|
|
498
|
+
// Escalation HTTP route — registered on OpenClaw gateway HTTP server
|
|
499
|
+
// -------------------------------------------------------------------------
|
|
500
|
+
api.registerHttpRoute({
|
|
501
|
+
path: "/rpc/discord-voice.escalate",
|
|
502
|
+
handler: async (req, res) => {
|
|
503
|
+
if (req.method !== "POST") {
|
|
504
|
+
sendJson(res, 405, { error: "Method Not Allowed" });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
let body: unknown;
|
|
508
|
+
try {
|
|
509
|
+
body = await readJsonBody(req);
|
|
510
|
+
} catch {
|
|
511
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const params = body as Record<string, unknown>;
|
|
516
|
+
const message = typeof params.message === "string" ? params.message : "";
|
|
517
|
+
const guildId = String(params.guildId ?? "");
|
|
518
|
+
const channelId = String(params.channelId ?? "");
|
|
519
|
+
const userId = String(params.userId ?? "");
|
|
520
|
+
|
|
521
|
+
if (!message) {
|
|
522
|
+
sendJson(res, 400, { error: "message is required" });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
const text = await invokeMainAgent({
|
|
528
|
+
message,
|
|
529
|
+
guildId,
|
|
530
|
+
channelId,
|
|
531
|
+
userId,
|
|
532
|
+
cfg: api.config,
|
|
533
|
+
});
|
|
534
|
+
sendJson(res, 200, { text });
|
|
535
|
+
} catch (err) {
|
|
536
|
+
api.logger.error(`[discord-voice] Escalation error: ${err instanceof Error ? err.message : String(err)}`);
|
|
537
|
+
sendJson(res, 500, { error: "Agent invocation failed", text: "I'm having trouble connecting right now." });
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// -------------------------------------------------------------------------
|
|
543
|
+
// Background service
|
|
544
|
+
// -------------------------------------------------------------------------
|
|
545
|
+
api.registerService({
|
|
546
|
+
id: "discord-voice-bot",
|
|
547
|
+
start: async () => {
|
|
548
|
+
const pythonPath = await findPython(cfg.pythonPath);
|
|
549
|
+
api.logger.info(`[discord-voice] Using Python: ${pythonPath}`);
|
|
550
|
+
|
|
551
|
+
botManager = new VoiceBotProcess({
|
|
552
|
+
config: cfg,
|
|
553
|
+
pythonPath,
|
|
554
|
+
logger: api.logger,
|
|
555
|
+
gatewayPort,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await botManager.start();
|
|
559
|
+
},
|
|
560
|
+
stop: async () => {
|
|
561
|
+
if (botManager) {
|
|
562
|
+
await botManager.stop();
|
|
563
|
+
botManager = null;
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// -------------------------------------------------------------------------
|
|
569
|
+
// Agent tools (optional — must be allow-listed by the agent config)
|
|
570
|
+
// -------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
api.registerTool(
|
|
573
|
+
{
|
|
574
|
+
name: "voice_join",
|
|
575
|
+
label: "Voice Join",
|
|
576
|
+
description: "Join a Discord voice channel. The voice bot will connect and start listening.",
|
|
577
|
+
parameters: Type.Object({
|
|
578
|
+
guild_id: Type.Number({ description: "Discord guild (server) ID" }),
|
|
579
|
+
channel_id: Type.Number({ description: "Discord voice channel ID" }),
|
|
580
|
+
}),
|
|
581
|
+
async execute(_id, params) {
|
|
582
|
+
if (!botManager) {
|
|
583
|
+
return { content: [{ type: "text" as const, text: "Voice bot is not running." }] };
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
const result = await botManager.httpPost("/control/join", {
|
|
587
|
+
guild_id: params.guild_id,
|
|
588
|
+
channel_id: params.channel_id,
|
|
589
|
+
});
|
|
590
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
591
|
+
} catch (err) {
|
|
592
|
+
return { content: [{ type: "text" as const, text: `Failed to join: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
{ optional: true },
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
api.registerTool(
|
|
600
|
+
{
|
|
601
|
+
name: "voice_leave",
|
|
602
|
+
label: "Voice Leave",
|
|
603
|
+
description: "Disconnect the voice bot from a Discord voice channel.",
|
|
604
|
+
parameters: Type.Object({
|
|
605
|
+
guild_id: Type.Number({ description: "Discord guild (server) ID" }),
|
|
606
|
+
}),
|
|
607
|
+
async execute(_id, params) {
|
|
608
|
+
if (!botManager) {
|
|
609
|
+
return { content: [{ type: "text" as const, text: "Voice bot is not running." }] };
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
const result = await botManager.httpPost("/control/leave", { guild_id: params.guild_id });
|
|
613
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
614
|
+
} catch (err) {
|
|
615
|
+
return { content: [{ type: "text" as const, text: `Failed to leave: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
{ optional: true },
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
api.registerTool(
|
|
623
|
+
{
|
|
624
|
+
name: "voice_speak",
|
|
625
|
+
label: "Voice Speak",
|
|
626
|
+
description: "Synthesize text and play it in a Discord voice channel.",
|
|
627
|
+
parameters: Type.Object({
|
|
628
|
+
guild_id: Type.Number({ description: "Discord guild (server) ID" }),
|
|
629
|
+
text: Type.String({ description: "Text to synthesize and play" }),
|
|
630
|
+
}),
|
|
631
|
+
async execute(_id, params) {
|
|
632
|
+
if (!botManager) {
|
|
633
|
+
return { content: [{ type: "text" as const, text: "Voice bot is not running." }] };
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
const result = await botManager.httpPost("/control/speak", {
|
|
637
|
+
guild_id: params.guild_id,
|
|
638
|
+
text: params.text,
|
|
639
|
+
});
|
|
640
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
641
|
+
} catch (err) {
|
|
642
|
+
return { content: [{ type: "text" as const, text: `Failed to speak: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
{ optional: true },
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
api.registerTool(
|
|
650
|
+
{
|
|
651
|
+
name: "voice_status",
|
|
652
|
+
label: "Voice Status",
|
|
653
|
+
description: "Get the current status of the Discord voice bot (connected guilds, uptime).",
|
|
654
|
+
parameters: Type.Object({}),
|
|
655
|
+
async execute(_id, _params) {
|
|
656
|
+
if (!botManager) {
|
|
657
|
+
return { content: [{ type: "text" as const, text: "Voice bot is not running." }] };
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
const status = await botManager.httpGet("/health");
|
|
661
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(status, null, 2) }] };
|
|
662
|
+
} catch (err) {
|
|
663
|
+
return { content: [{ type: "text" as const, text: `Failed to get status: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
{ optional: true },
|
|
668
|
+
);
|
|
669
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "discord-voice",
|
|
3
|
+
"name": "Discord Voice",
|
|
4
|
+
"description": "Real-time Discord voice assistant with local LLM, STT, and TTS",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"uiHints": {
|
|
7
|
+
"botToken": {
|
|
8
|
+
"label": "Discord Bot Token",
|
|
9
|
+
"sensitive": true
|
|
10
|
+
},
|
|
11
|
+
"guildIds": {
|
|
12
|
+
"label": "Guild IDs",
|
|
13
|
+
"help": "Discord server IDs to operate in (array of numbers)"
|
|
14
|
+
},
|
|
15
|
+
"transcriptChannelId": {
|
|
16
|
+
"label": "Transcript Channel ID",
|
|
17
|
+
"help": "Discord channel ID for posting voice transcripts (optional)"
|
|
18
|
+
},
|
|
19
|
+
"llmModel": {
|
|
20
|
+
"label": "LLM Model",
|
|
21
|
+
"placeholder": "Qwen/Qwen3-30B-A3B-Instruct-2507"
|
|
22
|
+
},
|
|
23
|
+
"llmUrl": {
|
|
24
|
+
"label": "LLM URL",
|
|
25
|
+
"placeholder": "http://localhost:8000/v1/chat/completions"
|
|
26
|
+
},
|
|
27
|
+
"whisperUrl": {
|
|
28
|
+
"label": "Whisper URL",
|
|
29
|
+
"placeholder": "http://localhost:8001/inference"
|
|
30
|
+
},
|
|
31
|
+
"kokoroUrl": {
|
|
32
|
+
"label": "Kokoro TTS URL",
|
|
33
|
+
"placeholder": "http://localhost:8002/v1/audio/speech"
|
|
34
|
+
},
|
|
35
|
+
"ttsVoice": {
|
|
36
|
+
"label": "TTS Voice",
|
|
37
|
+
"placeholder": "af_heart"
|
|
38
|
+
},
|
|
39
|
+
"botName": {
|
|
40
|
+
"label": "Bot Name",
|
|
41
|
+
"placeholder": "Assistant"
|
|
42
|
+
},
|
|
43
|
+
"mainAgentName": {
|
|
44
|
+
"label": "Main Agent Name",
|
|
45
|
+
"placeholder": "main agent"
|
|
46
|
+
},
|
|
47
|
+
"defaultLocation": {
|
|
48
|
+
"label": "Default Location",
|
|
49
|
+
"placeholder": "Vancouver, BC, Canada"
|
|
50
|
+
},
|
|
51
|
+
"defaultTimezone": {
|
|
52
|
+
"label": "Default Timezone",
|
|
53
|
+
"placeholder": "UTC"
|
|
54
|
+
},
|
|
55
|
+
"extraContext": {
|
|
56
|
+
"label": "Extra Context",
|
|
57
|
+
"help": "Freeform context appended to the voice bot's system prompt"
|
|
58
|
+
},
|
|
59
|
+
"whisperPrompt": {
|
|
60
|
+
"label": "Whisper Prompt",
|
|
61
|
+
"help": "Vocabulary hints for Whisper transcription (unusual names, terms)"
|
|
62
|
+
},
|
|
63
|
+
"vadMinSpeechMs": {
|
|
64
|
+
"label": "VAD Min Speech (ms)",
|
|
65
|
+
"advanced": true
|
|
66
|
+
},
|
|
67
|
+
"speechEndDelayMs": {
|
|
68
|
+
"label": "Speech End Delay (ms)",
|
|
69
|
+
"advanced": true
|
|
70
|
+
},
|
|
71
|
+
"channelContextMessages": {
|
|
72
|
+
"label": "Channel Context Messages",
|
|
73
|
+
"advanced": true
|
|
74
|
+
},
|
|
75
|
+
"ttsReadChannel": {
|
|
76
|
+
"label": "TTS Read Channel",
|
|
77
|
+
"help": "Read aloud messages posted to the linked text channel",
|
|
78
|
+
"advanced": true
|
|
79
|
+
},
|
|
80
|
+
"correctionsFile": {
|
|
81
|
+
"label": "Corrections File",
|
|
82
|
+
"help": "Path to TOML file with post-STT word corrections",
|
|
83
|
+
"advanced": true
|
|
84
|
+
},
|
|
85
|
+
"controlPort": {
|
|
86
|
+
"label": "Control Port",
|
|
87
|
+
"help": "HTTP port for the Python bot's control/health server (default 18790)",
|
|
88
|
+
"advanced": true
|
|
89
|
+
},
|
|
90
|
+
"pythonPath": {
|
|
91
|
+
"label": "Python Path",
|
|
92
|
+
"help": "Override Python executable path. Leave empty for auto-detection.",
|
|
93
|
+
"advanced": true
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"configSchema": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"additionalProperties": false,
|
|
99
|
+
"properties": {
|
|
100
|
+
"enabled": {
|
|
101
|
+
"type": "boolean"
|
|
102
|
+
},
|
|
103
|
+
"pythonPath": {
|
|
104
|
+
"type": "string",
|
|
105
|
+
"description": "Override Python executable. Auto-detected if empty."
|
|
106
|
+
},
|
|
107
|
+
"botToken": {
|
|
108
|
+
"type": "string",
|
|
109
|
+
"description": "Discord bot token"
|
|
110
|
+
},
|
|
111
|
+
"guildIds": {
|
|
112
|
+
"type": "array",
|
|
113
|
+
"items": { "type": "number" },
|
|
114
|
+
"description": "Discord guild IDs"
|
|
115
|
+
},
|
|
116
|
+
"transcriptChannelId": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"description": "Discord channel ID for posting transcripts"
|
|
119
|
+
},
|
|
120
|
+
"llmModel": {
|
|
121
|
+
"type": "string"
|
|
122
|
+
},
|
|
123
|
+
"llmUrl": {
|
|
124
|
+
"type": "string"
|
|
125
|
+
},
|
|
126
|
+
"whisperUrl": {
|
|
127
|
+
"type": "string"
|
|
128
|
+
},
|
|
129
|
+
"kokoroUrl": {
|
|
130
|
+
"type": "string"
|
|
131
|
+
},
|
|
132
|
+
"ttsVoice": {
|
|
133
|
+
"type": "string"
|
|
134
|
+
},
|
|
135
|
+
"botName": {
|
|
136
|
+
"type": "string"
|
|
137
|
+
},
|
|
138
|
+
"mainAgentName": {
|
|
139
|
+
"type": "string"
|
|
140
|
+
},
|
|
141
|
+
"defaultLocation": {
|
|
142
|
+
"type": "string"
|
|
143
|
+
},
|
|
144
|
+
"defaultTimezone": {
|
|
145
|
+
"type": "string"
|
|
146
|
+
},
|
|
147
|
+
"extraContext": {
|
|
148
|
+
"type": "string"
|
|
149
|
+
},
|
|
150
|
+
"whisperPrompt": {
|
|
151
|
+
"type": "string"
|
|
152
|
+
},
|
|
153
|
+
"vadMinSpeechMs": {
|
|
154
|
+
"type": "number",
|
|
155
|
+
"minimum": 0
|
|
156
|
+
},
|
|
157
|
+
"speechEndDelayMs": {
|
|
158
|
+
"type": "number",
|
|
159
|
+
"minimum": 0
|
|
160
|
+
},
|
|
161
|
+
"channelContextMessages": {
|
|
162
|
+
"type": "number",
|
|
163
|
+
"minimum": 0
|
|
164
|
+
},
|
|
165
|
+
"ttsReadChannel": {
|
|
166
|
+
"type": "boolean"
|
|
167
|
+
},
|
|
168
|
+
"correctionsFile": {
|
|
169
|
+
"type": "string"
|
|
170
|
+
},
|
|
171
|
+
"controlPort": {
|
|
172
|
+
"type": "number",
|
|
173
|
+
"minimum": 1,
|
|
174
|
+
"maximum": 65535,
|
|
175
|
+
"description": "HTTP port for the Python bot control/health server (default 18790)"
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"required": ["botToken", "guildIds"]
|
|
179
|
+
}
|
|
180
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@epiphytic/openclaw-discord-voice",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw plugin for real-time Discord voice assistant with local LLM, STT, and TTS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/Epiphytic/openclaw-voice.git",
|
|
11
|
+
"directory": "plugin"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"openclaw",
|
|
15
|
+
"openclaw-plugin",
|
|
16
|
+
"discord",
|
|
17
|
+
"voice",
|
|
18
|
+
"tts",
|
|
19
|
+
"stt",
|
|
20
|
+
"llm"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"index.ts",
|
|
24
|
+
"openclaw.plugin.json",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@sinclair/typebox": "^0.34.0"
|
|
29
|
+
},
|
|
30
|
+
"openclaw": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./index.ts"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|