crawd 0.8.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.
Files changed (50) hide show
  1. package/README.md +176 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.js +975 -0
  4. package/dist/client.d.ts +53 -0
  5. package/dist/client.js +40 -0
  6. package/dist/types.d.ts +86 -0
  7. package/dist/types.js +0 -0
  8. package/openclaw.plugin.json +108 -0
  9. package/package.json +86 -0
  10. package/skills/crawd/SKILL.md +81 -0
  11. package/src/backend/coordinator.ts +883 -0
  12. package/src/backend/index.ts +581 -0
  13. package/src/backend/server.ts +589 -0
  14. package/src/cli.ts +130 -0
  15. package/src/client.ts +101 -0
  16. package/src/commands/auth.ts +145 -0
  17. package/src/commands/config.ts +43 -0
  18. package/src/commands/down.ts +15 -0
  19. package/src/commands/logs.ts +32 -0
  20. package/src/commands/skill.ts +189 -0
  21. package/src/commands/start.ts +120 -0
  22. package/src/commands/status.ts +73 -0
  23. package/src/commands/stop.ts +16 -0
  24. package/src/commands/stream-key.ts +45 -0
  25. package/src/commands/talk.ts +30 -0
  26. package/src/commands/up.ts +59 -0
  27. package/src/commands/update.ts +92 -0
  28. package/src/config/schema.ts +66 -0
  29. package/src/config/store.ts +185 -0
  30. package/src/daemon/manager.ts +280 -0
  31. package/src/daemon/pid.ts +102 -0
  32. package/src/lib/chat/base.ts +13 -0
  33. package/src/lib/chat/manager.ts +105 -0
  34. package/src/lib/chat/pumpfun/client.ts +56 -0
  35. package/src/lib/chat/types.ts +48 -0
  36. package/src/lib/chat/youtube/client.ts +131 -0
  37. package/src/lib/pumpfun/live/client.ts +69 -0
  38. package/src/lib/pumpfun/live/index.ts +3 -0
  39. package/src/lib/pumpfun/live/types.ts +38 -0
  40. package/src/lib/pumpfun/v2/client.ts +139 -0
  41. package/src/lib/pumpfun/v2/index.ts +5 -0
  42. package/src/lib/pumpfun/v2/socket/client.ts +60 -0
  43. package/src/lib/pumpfun/v2/socket/index.ts +6 -0
  44. package/src/lib/pumpfun/v2/socket/types.ts +7 -0
  45. package/src/lib/pumpfun/v2/types.ts +234 -0
  46. package/src/lib/tts/tiktok.ts +91 -0
  47. package/src/plugin.ts +280 -0
  48. package/src/types.ts +78 -0
  49. package/src/utils/logger.ts +43 -0
  50. package/src/utils/paths.ts +55 -0
@@ -0,0 +1,581 @@
1
+ import 'dotenv/config';
2
+ import { randomUUID } from "crypto";
3
+ import { writeFile, mkdir } from "fs/promises";
4
+ import { watch } from "fs";
5
+ import { join } from "path";
6
+ import Fastify from "fastify";
7
+ import fastifyStatic from "@fastify/static";
8
+ import cors from "@fastify/cors";
9
+ import { Server } from "socket.io";
10
+ import OpenAI from "openai";
11
+ import { pumpfun } from "../lib/pumpfun/v2";
12
+ import { ChatManager } from "../lib/chat/manager";
13
+ import { PumpFunChatClient } from "../lib/chat/pumpfun/client";
14
+ import { YouTubeChatClient } from "../lib/chat/youtube/client";
15
+ import { GatewayClient, Coordinator, type CoordinatorConfig, type CoordinatorEvent, type InvokeRequestPayload } from "./coordinator";
16
+ import { generateShortId } from "../lib/chat/types";
17
+ import { configureTikTokTTS, generateTikTokTTS } from "../lib/tts/tiktok";
18
+ import type { ChatMessage } from "../lib/chat/types";
19
+ import { loadEnv, loadConfig } from "../config/store.js";
20
+ import { ENV_PATH, CONFIG_PATH } from "../utils/paths.js";
21
+ import type { TtsProvider, ReplyTurnEvent, TalkEvent } from "../types";
22
+
23
+ // Parse coordinator config from env vars
24
+ function parseCoordinatorConfig(): Partial<CoordinatorConfig> {
25
+ const config: Partial<CoordinatorConfig> = {};
26
+
27
+ if (process.env.VIBE_ENABLED !== undefined) {
28
+ config.vibeEnabled = process.env.VIBE_ENABLED === 'true';
29
+ }
30
+ if (process.env.VIBE_INTERVAL_MS) {
31
+ config.vibeIntervalMs = Number(process.env.VIBE_INTERVAL_MS);
32
+ }
33
+ if (process.env.IDLE_AFTER_MS) {
34
+ config.idleAfterMs = Number(process.env.IDLE_AFTER_MS);
35
+ }
36
+ if (process.env.SLEEP_AFTER_IDLE_MS) {
37
+ config.sleepAfterIdleMs = Number(process.env.SLEEP_AFTER_IDLE_MS);
38
+ }
39
+ if (process.env.VIBE_PROMPT) {
40
+ config.vibePrompt = process.env.VIBE_PROMPT;
41
+ }
42
+
43
+ return config;
44
+ }
45
+
46
+ const port = Number(process.env.PORT || 4000);
47
+ const BACKEND_URL = process.env.BACKEND_URL || `http://localhost:${port}`;
48
+ const TOKEN_MINT = process.env.NEXT_PUBLIC_TOKEN_MINT;
49
+ const MCAP_POLL_MS = 10_000;
50
+ const TTS_DIR = join(process.cwd(), "tmp", "tts");
51
+
52
+ // TTS provider selection — mutable, updated by file watcher
53
+ let CHAT_PROVIDER = (process.env.TTS_CHAT_PROVIDER || 'tiktok') as TtsProvider;
54
+ let CHAT_VOICE = process.env.TTS_CHAT_VOICE;
55
+ let BOT_PROVIDER = (process.env.TTS_BOT_PROVIDER || 'elevenlabs') as TtsProvider;
56
+ let BOT_VOICE = process.env.TTS_BOT_VOICE;
57
+
58
+ // Unique version ID generated at startup - changes on each deploy/restart
59
+ const BUILD_VERSION = randomUUID();
60
+
61
+ const fastify = Fastify({ logger: true });
62
+ const openai = new OpenAI();
63
+
64
+ // Dynamic import for optional ElevenLabs dependency
65
+ let elevenlabs: any = null;
66
+ if (process.env.ELEVENLABS_API_KEY) {
67
+ try {
68
+ const { ElevenLabsClient } = await import("@elevenlabs/elevenlabs-js");
69
+ elevenlabs = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_API_KEY });
70
+ } catch {
71
+ fastify.log.warn("@elevenlabs/elevenlabs-js not installed, ElevenLabs TTS disabled");
72
+ }
73
+ }
74
+
75
+ // Configure TikTok TTS if session ID is available
76
+ if (process.env.TIKTOK_SESSION_ID) {
77
+ configureTikTokTTS(process.env.TIKTOK_SESSION_ID);
78
+ }
79
+
80
+ // --- Auto-reload ~/.crawd/.env and config.json on change ---
81
+
82
+ async function reloadConfig() {
83
+ const env = loadEnv();
84
+ const config = loadConfig();
85
+ const changes: string[] = [];
86
+
87
+ // Update secrets in process.env
88
+ for (const [key, value] of Object.entries(env)) {
89
+ if (value && process.env[key] !== value) {
90
+ changes.push(key);
91
+ process.env[key] = value;
92
+ }
93
+ }
94
+
95
+ // Update TTS provider/voice from config
96
+ const newChatProvider = (config.tts.chatProvider || 'tiktok') as TtsProvider;
97
+ const newChatVoice = config.tts.chatVoice;
98
+ const newBotProvider = (config.tts.botProvider || 'elevenlabs') as TtsProvider;
99
+ const newBotVoice = config.tts.botVoice;
100
+
101
+ if (newChatProvider !== CHAT_PROVIDER) { changes.push('tts.chatProvider'); CHAT_PROVIDER = newChatProvider; }
102
+ if (newChatVoice !== CHAT_VOICE) { changes.push('tts.chatVoice'); CHAT_VOICE = newChatVoice; }
103
+ if (newBotProvider !== BOT_PROVIDER) { changes.push('tts.botProvider'); BOT_PROVIDER = newBotProvider; }
104
+ if (newBotVoice !== BOT_VOICE) { changes.push('tts.botVoice'); BOT_VOICE = newBotVoice; }
105
+
106
+ // Reinitialize ElevenLabs client if key changed
107
+ if (changes.includes('ELEVENLABS_API_KEY') && process.env.ELEVENLABS_API_KEY) {
108
+ try {
109
+ const { ElevenLabsClient } = await import("@elevenlabs/elevenlabs-js");
110
+ elevenlabs = new ElevenLabsClient({ apiKey: process.env.ELEVENLABS_API_KEY });
111
+ } catch { /* already warned at startup */ }
112
+ }
113
+
114
+ // Reconfigure TikTok TTS if session ID changed
115
+ if (changes.includes('TIKTOK_SESSION_ID') && process.env.TIKTOK_SESSION_ID) {
116
+ configureTikTokTTS(process.env.TIKTOK_SESSION_ID);
117
+ }
118
+
119
+ if (changes.length > 0) {
120
+ fastify.log.info({ changes }, 'Config reloaded');
121
+ }
122
+ }
123
+
124
+ let reloadTimer: ReturnType<typeof setTimeout> | null = null;
125
+ function scheduleReload() {
126
+ if (reloadTimer) clearTimeout(reloadTimer);
127
+ reloadTimer = setTimeout(() => reloadConfig(), 100);
128
+ }
129
+
130
+ for (const file of [ENV_PATH, CONFIG_PATH]) {
131
+ try {
132
+ watch(file, () => scheduleReload());
133
+ } catch { /* file may not exist yet */ }
134
+ }
135
+
136
+ // --- TTS provider functions ---
137
+
138
+ async function generateOpenAITTS(text: string, voice?: string): Promise<string> {
139
+ const response = await openai.audio.speech.create({
140
+ model: "tts-1-hd",
141
+ voice: (voice || "onyx") as "onyx",
142
+ input: text,
143
+ });
144
+
145
+ const buffer = Buffer.from(await response.arrayBuffer());
146
+ const filename = `${randomUUID()}.mp3`;
147
+ await mkdir(TTS_DIR, { recursive: true });
148
+ await writeFile(join(TTS_DIR, filename), buffer);
149
+
150
+ return `${BACKEND_URL}/tts/${filename}`;
151
+ }
152
+
153
+ async function generateElevenLabsTTS(text: string, voice?: string): Promise<string> {
154
+ if (!elevenlabs) throw new Error("ELEVENLABS_API_KEY not configured");
155
+
156
+ const audio = await elevenlabs.textToSpeech.convert(voice || "TX3LPaxmHKxFdv7VOQHJ", {
157
+ modelId: "eleven_multilingual_v2",
158
+ text,
159
+ outputFormat: "mp3_44100_128",
160
+ voiceSettings: {
161
+ stability: 0,
162
+ similarityBoost: 1.0,
163
+ useSpeakerBoost: true,
164
+ speed: 1.0,
165
+ },
166
+ });
167
+
168
+ // Convert stream to buffer - works with Bun and Node.js
169
+ const response = new Response(audio as any);
170
+ const arrayBuffer = await response.arrayBuffer();
171
+ const buffer = Buffer.from(arrayBuffer);
172
+
173
+ // Check if response is valid MP3 (starts with ID3 or FF FB/FA/F3/F2)
174
+ const isMP3 = (buffer[0] === 0x49 && buffer[1] === 0x44 && buffer[2] === 0x33) || // ID3
175
+ (buffer[0] === 0xFF && (buffer[1] & 0xE0) === 0xE0); // MP3 frame sync
176
+
177
+ if (!isMP3) {
178
+ const preview = buffer.subarray(0, 200).toString("utf-8");
179
+ console.error(`ElevenLabs returned non-audio response: ${preview}`);
180
+ throw new Error("ElevenLabs returned invalid audio (possibly error page)");
181
+ }
182
+
183
+ const filename = `${randomUUID()}.mp3`;
184
+ await mkdir(TTS_DIR, { recursive: true });
185
+ await writeFile(join(TTS_DIR, filename), buffer);
186
+
187
+ console.log(`TTS file written: ${filename}, size: ${buffer.length} bytes`);
188
+
189
+ return `${BACKEND_URL}/tts/${filename}`;
190
+ }
191
+
192
+ async function generateTikTokTTSFile(text: string, voice?: string): Promise<string> {
193
+ const buffer = await generateTikTokTTS(text, voice);
194
+ const filename = `${randomUUID()}.mp3`;
195
+ await mkdir(TTS_DIR, { recursive: true });
196
+ await writeFile(join(TTS_DIR, filename), buffer);
197
+ console.log(`TikTok TTS file written: ${filename}, size: ${buffer.length} bytes`);
198
+ return `${BACKEND_URL}/tts/${filename}`;
199
+ }
200
+
201
+ /** Generate TTS using the specified provider and voice, falling back to OpenAI on failure */
202
+ async function tts(text: string, provider: TtsProvider, voice?: string): Promise<string> {
203
+ const providers: Record<TtsProvider, () => Promise<string>> = {
204
+ openai: () => generateOpenAITTS(text, voice),
205
+ elevenlabs: () => generateElevenLabsTTS(text, voice),
206
+ tiktok: () => generateTikTokTTSFile(text, voice),
207
+ };
208
+
209
+ try {
210
+ return await providers[provider]();
211
+ } catch (e) {
212
+ if (provider !== 'openai') {
213
+ fastify.log.warn(e, `${provider} TTS failed, falling back to OpenAI`);
214
+ return await generateOpenAITTS(text);
215
+ }
216
+ throw e;
217
+ }
218
+ }
219
+
220
+ /** Generate TTS for a chat message (uses CHAT_PROVIDER) */
221
+ function chatTTS(text: string) { return tts(text, CHAT_PROVIDER, CHAT_VOICE); }
222
+
223
+ /** Generate TTS for a bot message (uses BOT_PROVIDER) */
224
+ function botTTS(text: string) { return tts(text, BOT_PROVIDER, BOT_VOICE); }
225
+
226
+ // --- Non-TTS helpers ---
227
+
228
+ async function fetchMarketCap(): Promise<number | null> {
229
+ if (!TOKEN_MINT) return null;
230
+
231
+ try {
232
+ const coin = await pumpfun.getCoin(TOKEN_MINT);
233
+ return coin.usd_market_cap;
234
+ } catch (e) {
235
+ fastify.log.error(e, "failed to fetch market cap");
236
+ return null;
237
+ }
238
+ }
239
+
240
+ async function main() {
241
+ fastify.log.info({ chatProvider: CHAT_PROVIDER, botProvider: BOT_PROVIDER }, 'TTS providers configured');
242
+
243
+ await fastify.register(cors, { origin: true });
244
+ await mkdir(TTS_DIR, { recursive: true });
245
+ await fastify.register(fastifyStatic, {
246
+ root: TTS_DIR,
247
+ prefix: "/tts/",
248
+ decorateReply: false,
249
+ });
250
+
251
+ const io = new Server(fastify.server, {
252
+ cors: { origin: "*" },
253
+ });
254
+
255
+ let latestMcap: number | null = null;
256
+
257
+ async function pollMarketCap() {
258
+ fastify.log.info("polling market cap");
259
+ const mcap = await fetchMarketCap();
260
+ fastify.log.info({ mcap }, `fetched market cap: ${mcap}`);
261
+
262
+ if (mcap === null) return;
263
+
264
+ latestMcap = mcap;
265
+ io.emit("crawd:mcap", { mcap });
266
+ }
267
+
268
+ // --- Pending talk ack tracking (for synchronous talk tool calls) ---
269
+ const pendingTalkAcks = new Map<string, { resolve: () => void; timer: ReturnType<typeof setTimeout> }>();
270
+ const TALK_ACK_TIMEOUT_MS = 60_000;
271
+
272
+ function waitForTalkAck(talkId: string): Promise<void> {
273
+ return new Promise((resolve) => {
274
+ const timer = setTimeout(() => {
275
+ pendingTalkAcks.delete(talkId);
276
+ fastify.log.warn({ talkId }, 'Talk ack timed out, resolving anyway');
277
+ resolve();
278
+ }, TALK_ACK_TIMEOUT_MS);
279
+ pendingTalkAcks.set(talkId, { resolve, timer });
280
+ });
281
+ }
282
+
283
+ function resolveTalkAck(talkId: string) {
284
+ const pending = pendingTalkAcks.get(talkId);
285
+ if (pending) {
286
+ clearTimeout(pending.timer);
287
+ pendingTalkAcks.delete(talkId);
288
+ pending.resolve();
289
+ }
290
+ }
291
+
292
+ // Chat manager and coordinator instances
293
+ let chatManager: ChatManager | null = null;
294
+ let coordinator: Coordinator | null = null;
295
+
296
+ async function startChatSystem() {
297
+ chatManager = new ChatManager();
298
+
299
+ // Register Pump Fun client if enabled
300
+ if (process.env.PUMPFUN_ENABLED !== 'false' && TOKEN_MINT) {
301
+ chatManager.registerClient('pumpfun', new PumpFunChatClient(
302
+ TOKEN_MINT,
303
+ process.env.PUMPFUN_AUTH_TOKEN ?? null
304
+ ));
305
+ }
306
+
307
+ // Register YouTube client if enabled
308
+ if (process.env.YOUTUBE_ENABLED === 'true' && process.env.YOUTUBE_VIDEO_ID) {
309
+ chatManager.registerClient('youtube', new YouTubeChatClient(
310
+ process.env.YOUTUBE_VIDEO_ID
311
+ ));
312
+ }
313
+
314
+ // Unified message handler
315
+ chatManager.onMessage((msg: ChatMessage) => {
316
+ fastify.log.info({ platform: msg.platform, user: msg.username }, 'chat message');
317
+
318
+ // Emit to frontend overlay
319
+ io.emit('crawd:chat', msg);
320
+
321
+ // Send to coordinator for batching (if connected)
322
+ coordinator?.onMessage(msg);
323
+ });
324
+
325
+ // Start coordinator if gateway is configured
326
+ const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL;
327
+ const gatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
328
+
329
+ if (gatewayUrl && gatewayToken) {
330
+ const gateway = new GatewayClient(gatewayUrl, gatewayToken);
331
+ const coordConfig = parseCoordinatorConfig();
332
+ coordinator = new Coordinator(gateway.triggerAgent.bind(gateway), coordConfig);
333
+
334
+ // Emit coordinator status changes to frontend
335
+ coordinator.setOnEvent((event: CoordinatorEvent) => {
336
+ if (event.type === 'stateChange') {
337
+ const status = event.to;
338
+ io.emit('crawd:status', { status });
339
+ } else if (event.type === 'vibeExecuted' && !event.skipped) {
340
+ io.emit('crawd:status', { status: 'vibing' });
341
+ }
342
+ // Note: chatProcessed no longer emits status — we only wake/emit
343
+ // when the agent actually replies (via talk tool or text fallback).
344
+ });
345
+
346
+ /**
347
+ * Generate TTS and emit atomic talk event, wait for overlay ack.
348
+ * If replyTo is provided, also generates chat TTS — overlay plays chat first, then bot.
349
+ */
350
+ async function handleTalkInvoke(text: string, replyTo?: ChatMessage): Promise<void> {
351
+ const talkId = randomUUID();
352
+ fastify.log.info({ talkId, text: text.slice(0, 80), replyTo: replyTo?.shortId }, 'Handling talk invoke');
353
+
354
+ // Generate TTS in parallel when there's a chat message to reply to
355
+ const [ttsUrl, chatTtsUrl] = await Promise.all([
356
+ botTTS(text),
357
+ replyTo ? chatTTS(`Chat says: ${replyTo.message}`) : Promise.resolve(undefined),
358
+ ]);
359
+
360
+ const event: TalkEvent = { id: talkId, message: text, ttsUrl };
361
+ if (replyTo && chatTtsUrl) {
362
+ event.chat = {
363
+ message: replyTo.message,
364
+ username: replyTo.username,
365
+ ttsUrl: chatTtsUrl,
366
+ };
367
+ }
368
+
369
+ io.emit('crawd:talk', event);
370
+ fastify.log.info({ talkId, hasChat: !!event.chat }, 'Emitted crawd:talk, waiting for ack');
371
+
372
+ await waitForTalkAck(talkId);
373
+ fastify.log.info({ talkId }, 'Talk complete');
374
+ }
375
+
376
+ try {
377
+ await gateway.connect();
378
+ coordinator.start();
379
+ fastify.log.info('Coordinator started, gateway connected');
380
+
381
+ // Register invoke handler on gateway client for the talk command
382
+ gateway.onInvokeRequest = async (payload: InvokeRequestPayload) => {
383
+ fastify.log.info({ command: payload.command, id: payload.id }, 'Invoke request received');
384
+
385
+ if (payload.command === 'talk') {
386
+ try {
387
+ const params = payload.paramsJSON ? JSON.parse(payload.paramsJSON) : {};
388
+ const text = params.text;
389
+ if (!text || typeof text !== 'string') {
390
+ await gateway.sendInvokeResult(payload.id, payload.nodeId, {
391
+ ok: false,
392
+ error: { code: 'INVALID_PARAMS', message: 'text parameter is required' },
393
+ });
394
+ return;
395
+ }
396
+
397
+ // Agent is actively speaking — wake the coordinator
398
+ if (coordinator && coordinator.state !== 'active') {
399
+ coordinator.wake();
400
+ } else {
401
+ coordinator?.resetActivity();
402
+ }
403
+
404
+ // Look up replyTo chat message if provided
405
+ const replyTo = params.replyTo && coordinator
406
+ ? coordinator.getRecentMessage(params.replyTo)
407
+ : undefined;
408
+
409
+ await handleTalkInvoke(text, replyTo);
410
+ await gateway.sendInvokeResult(payload.id, payload.nodeId, {
411
+ ok: true,
412
+ payload: { spoken: true },
413
+ });
414
+ } catch (err) {
415
+ fastify.log.error(err, 'Talk invoke failed');
416
+ await gateway.sendInvokeResult(payload.id, payload.nodeId, {
417
+ ok: false,
418
+ error: { code: 'TTS_FAILED', message: String(err) },
419
+ });
420
+ }
421
+ } else {
422
+ fastify.log.warn({ command: payload.command }, 'Unknown invoke command');
423
+ await gateway.sendInvokeResult(payload.id, payload.nodeId, {
424
+ ok: false,
425
+ error: { code: 'UNKNOWN_COMMAND', message: `Unknown command: ${payload.command}` },
426
+ });
427
+ }
428
+ };
429
+ } catch (err) {
430
+ fastify.log.error(err, 'Failed to connect to gateway');
431
+ gateway.disconnect();
432
+ coordinator = null;
433
+ }
434
+ } else {
435
+ fastify.log.warn('Gateway not configured - coordinator disabled');
436
+ }
437
+
438
+ // Connect all chat clients
439
+ await chatManager.connectAll();
440
+ }
441
+
442
+ io.on("connection", (socket) => {
443
+ fastify.log.info(`socket connected: ${socket.id}`);
444
+
445
+ if (latestMcap !== null) {
446
+ socket.emit("crawd:mcap", { mcap: latestMcap });
447
+ }
448
+
449
+ // Listen for talk:done acks from overlay
450
+ socket.on("crawd:talk:done", (data: { id: string }) => {
451
+ if (data?.id) {
452
+ fastify.log.info({ talkId: data.id }, 'Talk ack received from overlay');
453
+ resolveTalkAck(data.id);
454
+ }
455
+ });
456
+
457
+ socket.on("disconnect", () => {
458
+ fastify.log.info(`socket disconnected: ${socket.id}`);
459
+ });
460
+ });
461
+
462
+ fastify.post<{ Body: { message: string } }>(
463
+ "/crawd/talk",
464
+ async (request, reply) => {
465
+ const { message } = request.body;
466
+ if (!message || typeof message !== "string") {
467
+ return reply.status(400).send({ error: "message is required" });
468
+ }
469
+
470
+ try {
471
+ const talkId = randomUUID();
472
+ const ttsUrl = await botTTS(message);
473
+ const event: TalkEvent = { id: talkId, message, ttsUrl };
474
+ io.emit("crawd:talk", event);
475
+ return { ok: true, id: talkId };
476
+ } catch (e) {
477
+ fastify.log.error(e, "failed to generate TTS");
478
+ return reply.status(500).send({ error: "Failed to generate TTS" });
479
+ }
480
+ }
481
+ );
482
+
483
+ fastify.get("/chat/status", async () => {
484
+ return { connected: chatManager?.getConnectedKeys() ?? [] };
485
+ });
486
+
487
+ fastify.get("/version", async () => {
488
+ return { version: BUILD_VERSION };
489
+ });
490
+
491
+ fastify.get("/coordinator/status", async () => {
492
+ if (!coordinator) {
493
+ return { enabled: false };
494
+ }
495
+ return { enabled: true, ...coordinator.getState() };
496
+ });
497
+
498
+ fastify.post<{ Body: Partial<CoordinatorConfig> }>(
499
+ "/coordinator/config",
500
+ async (request, reply) => {
501
+ if (!coordinator) {
502
+ return reply.status(400).send({ error: "Coordinator not enabled" });
503
+ }
504
+
505
+ const config = request.body;
506
+ coordinator.updateConfig(config);
507
+ return { ok: true, ...coordinator.getState() };
508
+ }
509
+ );
510
+
511
+ fastify.post<{ Body: { username: string; message: string } }>(
512
+ "/mock/chat",
513
+ async (request, reply) => {
514
+ const { username, message } = request.body;
515
+ if (!username || !message) {
516
+ return reply.status(400).send({ error: "username and message are required" });
517
+ }
518
+
519
+ const id = randomUUID();
520
+ const mockMsg: ChatMessage = {
521
+ id,
522
+ shortId: generateShortId(),
523
+ username,
524
+ message,
525
+ platform: 'pumpfun',
526
+ timestamp: Date.now(),
527
+ };
528
+
529
+ fastify.log.info({ username, message }, "mock chat message");
530
+ io.emit("crawd:chat", mockMsg);
531
+ coordinator?.onMessage(mockMsg);
532
+ return { ok: true, id };
533
+ }
534
+ );
535
+
536
+ // Mock turn endpoint for debug UI — generates real TTS
537
+ fastify.post<{ Body: { username: string; message: string; response: string } }>(
538
+ "/mock/turn",
539
+ async (request, reply) => {
540
+ const { username, message, response } = request.body;
541
+ if (!username || !message || !response) {
542
+ return reply.status(400).send({ error: "username, message, and response are required" });
543
+ }
544
+
545
+ fastify.log.info({ username, message, response }, "mock turn");
546
+
547
+ try {
548
+ const [chatTtsUrl, botTtsUrl] = await Promise.all([
549
+ chatTTS(`Chat says: ${message}`),
550
+ botTTS(response),
551
+ ]);
552
+
553
+ const event: ReplyTurnEvent = {
554
+ chat: { username, message },
555
+ botMessage: response,
556
+ chatTtsUrl,
557
+ botTtsUrl,
558
+ };
559
+
560
+ io.emit('crawd:reply-turn', event);
561
+
562
+ return { ok: true };
563
+ } catch (e) {
564
+ fastify.log.error(e, "failed to generate mock turn TTS");
565
+ return reply.status(500).send({ error: "Failed to generate TTS" });
566
+ }
567
+ }
568
+ );
569
+
570
+ await startChatSystem();
571
+
572
+ const host = process.env.BIND_HOST || "0.0.0.0";
573
+ await fastify.listen({ port, host });
574
+
575
+ pollMarketCap();
576
+ setInterval(pollMarketCap, MCAP_POLL_MS);
577
+
578
+ fastify.log.info("Chat system started");
579
+ }
580
+
581
+ main();