httpcat-cli 0.0.7 → 0.0.9

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.
@@ -0,0 +1,1369 @@
1
+ import { createInterface } from "readline";
2
+ import WebSocket from "ws";
3
+ import chalk from "chalk";
4
+ // @ts-ignore - neo-blessed doesn't have types, but @types/blessed provides compatible types
5
+ import blessed from "neo-blessed";
6
+ import { formatAddress } from "../utils/formatting.js";
7
+ import { handleError } from "../utils/errors.js";
8
+ import { config } from "../config.js";
9
+ import { printCat } from "../interactive/art.js";
10
+ import { resolveTokenId } from "../utils/token-resolver.js";
11
+ import { privateKeyToAccount } from "viem/accounts";
12
+ const CHAT_JOIN_ENTRYPOINT = "chat_join";
13
+ const CHAT_MESSAGE_ENTRYPOINT = "chat_message";
14
+ const CHAT_RENEW_LEASE_ENTRYPOINT = "chat_renew_lease";
15
+ const LEASE_DURATION_MS = 10 * 60 * 1000; // 10 minutes
16
+ export async function joinChat(client, tokenIdentifier, silent = false) {
17
+ let tokenId;
18
+ // If token identifier provided, resolve it to tokenId
19
+ if (tokenIdentifier) {
20
+ // Check if it's an Ethereum address (starts with 0x and is 42 chars)
21
+ const isAddress = /^0x[a-fA-F0-9]{40}$/.test(tokenIdentifier);
22
+ if (isAddress) {
23
+ // Pass address directly - backend should handle it
24
+ tokenId = tokenIdentifier;
25
+ }
26
+ else {
27
+ // Resolve symbol/name/ticker to tokenId
28
+ tokenId = await resolveTokenId(tokenIdentifier, client, silent);
29
+ }
30
+ }
31
+ const input = tokenId ? { tokenId } : {};
32
+ const { data } = await client.invoke(CHAT_JOIN_ENTRYPOINT, input);
33
+ // Format author addresses for last messages
34
+ data.lastMessages = data.lastMessages.map((msg) => ({
35
+ ...msg,
36
+ authorShort: formatAddress(msg.author, 6),
37
+ }));
38
+ return data;
39
+ }
40
+ export async function sendChatMessage(client, message, leaseId) {
41
+ if (!message.trim()) {
42
+ throw new Error("Message cannot be empty");
43
+ }
44
+ const input = { message, leaseId };
45
+ const { data } = await client.invoke(CHAT_MESSAGE_ENTRYPOINT, input);
46
+ return data;
47
+ }
48
+ export async function renewLease(client, leaseId) {
49
+ const input = leaseId ? { leaseId } : {};
50
+ const { data } = await client.invoke(CHAT_RENEW_LEASE_ENTRYPOINT, input);
51
+ return data;
52
+ }
53
+ function formatTimestamp(timestamp) {
54
+ try {
55
+ const date = new Date(timestamp);
56
+ const now = new Date();
57
+ const diffMs = now.getTime() - date.getTime();
58
+ const diffSec = Math.floor(diffMs / 1000);
59
+ const diffMin = Math.floor(diffSec / 60);
60
+ const diffHour = Math.floor(diffMin / 60);
61
+ if (diffSec < 60) {
62
+ return "just now";
63
+ }
64
+ else if (diffMin < 60) {
65
+ return `${diffMin}m ago`;
66
+ }
67
+ else if (diffHour < 24) {
68
+ return `${diffHour}h ago`;
69
+ }
70
+ else {
71
+ return date.toLocaleTimeString("en-US", {
72
+ hour: "2-digit",
73
+ minute: "2-digit",
74
+ });
75
+ }
76
+ }
77
+ catch {
78
+ return "";
79
+ }
80
+ }
81
+ // Track displayed messages to avoid duplicates
82
+ const displayedMessageIds = new Set();
83
+ function formatMessageText(msg, isOwn = false) {
84
+ const timeStr = formatTimestamp(msg.timestamp);
85
+ const authorColor = isOwn ? chalk.green : chalk.cyan;
86
+ const messageColor = isOwn ? chalk.white : chalk.gray;
87
+ const authorShort = msg.authorShort || formatAddress(msg.author, 6);
88
+ return `${chalk.dim(`[${timeStr}]`)} ${authorColor(authorShort)}: ${messageColor(msg.message)}`;
89
+ }
90
+ function displayMessage(msg, isOwn = false, isPending = false, messageLogBox, rl) {
91
+ // Skip if we've already displayed this message
92
+ if (displayedMessageIds.has(msg.messageId)) {
93
+ return;
94
+ }
95
+ displayedMessageIds.add(msg.messageId);
96
+ const messageText = formatMessageText(msg, isOwn);
97
+ if (messageLogBox) {
98
+ // Use blessed log box - automatically handles scrolling
99
+ messageLogBox.log(messageText);
100
+ messageLogBox.setScrollPerc(100); // Auto-scroll to bottom
101
+ }
102
+ else if (rl) {
103
+ // Fallback to readline approach (for JSON mode or edge cases)
104
+ process.stdout.write("\r\x1b[K");
105
+ process.stdout.write(messageText);
106
+ process.stdout.write("\n");
107
+ if (rl)
108
+ rl.prompt(true);
109
+ }
110
+ else {
111
+ // Simple console.log when no UI active
112
+ console.log(messageText);
113
+ }
114
+ }
115
+ function clearLine() {
116
+ process.stdout.write("\r\x1b[K");
117
+ }
118
+ function formatTimeRemaining(expiresAt) {
119
+ const now = new Date();
120
+ const diffMs = expiresAt.getTime() - now.getTime();
121
+ if (diffMs <= 0) {
122
+ return "expired";
123
+ }
124
+ const minutes = Math.floor(diffMs / 60000);
125
+ const seconds = Math.floor((diffMs % 60000) / 1000);
126
+ if (minutes > 0) {
127
+ return `${minutes}m ${seconds}s`;
128
+ }
129
+ return `${seconds}s`;
130
+ }
131
+ function updateHeaderBox(headerBox, leaseInfo, tokenName, isConnected = false) {
132
+ const title = tokenName
133
+ ? `💬 httpcat Chat: ${tokenName}`
134
+ : "💬 httpcat Chat Stream";
135
+ // Get terminal width, default to 80 if not available
136
+ const width = process.stdout.columns || 80;
137
+ const separator = "═".repeat(Math.max(20, width - 2));
138
+ const lineSeparator = "─".repeat(Math.max(20, width - 2));
139
+ // Build content line by line - use plain text (blessed will handle wrapping)
140
+ const lines = [];
141
+ lines.push(title);
142
+ lines.push(separator);
143
+ lines.push("");
144
+ if (isConnected) {
145
+ lines.push("✅ Connected to chat stream");
146
+ lines.push("");
147
+ }
148
+ lines.push("💰 Entry fee: $0.01 USDC (10 min lease)");
149
+ lines.push("💬 Per message: $0.01 USDC");
150
+ if (leaseInfo) {
151
+ const timeRemaining = formatTimeRemaining(leaseInfo.leaseExpiresAt);
152
+ const isExpired = leaseInfo.leaseExpiresAt.getTime() <= Date.now();
153
+ const timePrefix = isExpired ? "⚠️ " : "⏱️ ";
154
+ lines.push(`${timePrefix}Lease expires in: ${timeRemaining}`);
155
+ }
156
+ lines.push("");
157
+ lines.push("💡 Type your message and press Enter to send");
158
+ lines.push("💡 Type /exit or Ctrl+C to quit");
159
+ lines.push("💡 Type /renew to renew your lease");
160
+ lines.push("");
161
+ lines.push(lineSeparator);
162
+ headerBox.setContent(lines.join("\n"));
163
+ }
164
+ async function ensureLeaseValid(client, leaseInfo, messageLogBox, screen) {
165
+ if (!leaseInfo || leaseInfo.leaseExpiresAt.getTime() <= Date.now()) {
166
+ // Lease expired or doesn't exist, renew it
167
+ if (messageLogBox) {
168
+ messageLogBox.log(chalk.yellow("⏱️ Lease expired. Renewing..."));
169
+ messageLogBox.setScrollPerc(100);
170
+ screen?.render();
171
+ }
172
+ else {
173
+ clearLine();
174
+ console.log(chalk.yellow("⏱️ Lease expired. Renewing..."));
175
+ }
176
+ const renewal = await renewLease(client, leaseInfo?.leaseId);
177
+ return {
178
+ leaseId: renewal.leaseId,
179
+ leaseExpiresAt: new Date(renewal.leaseExpiresAt),
180
+ };
181
+ }
182
+ return leaseInfo;
183
+ }
184
+ export async function startChatStream(client, jsonMode = false, tokenIdentifier) {
185
+ let leaseInfo = null;
186
+ let userAddress;
187
+ let ws = null;
188
+ let wsUrl = null; // Store websocket URL for reconnection
189
+ let rl = null;
190
+ let leaseCheckInterval = null;
191
+ let headerUpdateInterval = null;
192
+ let isExiting = false;
193
+ let isSending = false; // Track sending state across handlers
194
+ let currentInput = ""; // Track current input across handlers
195
+ let pendingMessages = new Map(); // messageId -> tempMessageId (shared between send and receive)
196
+ let pulseIntervals = new Map(); // tempMessageId -> interval
197
+ let isCleaningUp = false; // Flag to prevent intervals from writing during cleanup
198
+ let messageCount = 0; // Track number of messages displayed (for positioning)
199
+ // Blessed UI components (only used in non-JSON mode)
200
+ let screen = null;
201
+ let headerBox = null;
202
+ let messageLogBox = null;
203
+ let inputBox = null;
204
+ // Join chat
205
+ try {
206
+ if (!jsonMode) {
207
+ if (tokenIdentifier) {
208
+ console.log(chalk.dim(`Joining chat room for token: ${tokenIdentifier}...`));
209
+ }
210
+ else {
211
+ console.log(chalk.dim("Joining general chat room..."));
212
+ }
213
+ }
214
+ const joinResult = await joinChat(client, tokenIdentifier, jsonMode);
215
+ leaseInfo = {
216
+ leaseId: joinResult.leaseId,
217
+ leaseExpiresAt: new Date(joinResult.leaseExpiresAt),
218
+ };
219
+ // Initialize userAddress from config if available (fallback until first message)
220
+ if (!userAddress) {
221
+ try {
222
+ const privateKey = config.getPrivateKey();
223
+ if (privateKey) {
224
+ const account = privateKeyToAccount(privateKey);
225
+ userAddress = account.address;
226
+ }
227
+ }
228
+ catch (error) {
229
+ // Ignore - will be set from first message send
230
+ }
231
+ }
232
+ if (jsonMode) {
233
+ // JSON mode: output join result and stream messages as JSON
234
+ console.log(JSON.stringify({
235
+ type: "joined",
236
+ leaseId: joinResult.leaseId,
237
+ leaseExpiresAt: joinResult.leaseExpiresAt,
238
+ tokenId: tokenIdentifier || null,
239
+ lastMessages: joinResult.lastMessages,
240
+ }));
241
+ }
242
+ // Connect to WebSocket
243
+ // Ensure we have a valid lease before connecting
244
+ if (!leaseInfo || leaseInfo.leaseExpiresAt.getTime() <= Date.now()) {
245
+ throw new Error("Lease is invalid or expired. Please try again.");
246
+ }
247
+ // Small delay to ensure database transaction commits before WebSocket connection
248
+ await new Promise((resolve) => setTimeout(resolve, 500));
249
+ const wsUrlObj = new URL(joinResult.wsUrl);
250
+ wsUrlObj.searchParams.set("leaseId", joinResult.leaseId);
251
+ wsUrl = wsUrlObj.toString();
252
+ // Helper function to attach websocket handlers
253
+ const attachWebSocketHandlers = (websocket) => {
254
+ websocket.on("open", () => {
255
+ // Connection established - update header if using blessed UI
256
+ if (!jsonMode && headerBox) {
257
+ updateHeaderBox(headerBox, leaseInfo, tokenIdentifier, true);
258
+ screen?.render();
259
+ }
260
+ });
261
+ websocket.on("message", async (data) => {
262
+ try {
263
+ const event = JSON.parse(data.toString());
264
+ if (event.type === "message" && event.data) {
265
+ const msg = {
266
+ ...event.data,
267
+ authorShort: formatAddress(event.data.author, 6),
268
+ };
269
+ if (jsonMode) {
270
+ console.log(JSON.stringify({
271
+ type: "message",
272
+ data: msg,
273
+ }));
274
+ }
275
+ else {
276
+ // Normalize addresses to lowercase for case-insensitive comparison
277
+ const normalizedAuthor = msg.author?.toLowerCase();
278
+ const normalizedUserAddress = userAddress?.toLowerCase();
279
+ const isOwn = normalizedAuthor === normalizedUserAddress;
280
+ // Check if this is a message we just sent
281
+ // Method 1: Check if messageId is in pendingMessages (most reliable)
282
+ const hasPendingId = pendingMessages.has(msg.messageId);
283
+ // Method 2: Check if message text is in pendingMessages (fallback if messageId doesn't match)
284
+ const hasPendingText = pendingMessages.has(`text:${msg.message}`);
285
+ // Method 3: Check if author matches AND we're currently sending (fallback)
286
+ const isAuthorMatch = isOwn && isSending;
287
+ // Combine all methods
288
+ const isOurPendingMessage = hasPendingId || hasPendingText || isAuthorMatch;
289
+ if (isOurPendingMessage) {
290
+ // Set cleanup flag to prevent intervals from writing
291
+ isCleaningUp = true;
292
+ // FIRST: Stop ALL pulsing animations immediately (before anything else)
293
+ // This prevents the interval from overwriting our clear
294
+ pulseIntervals.forEach((interval, id) => {
295
+ clearInterval(interval);
296
+ });
297
+ pulseIntervals.clear(); // Clear the map completely
298
+ // Clean up tracking FIRST (so intervals know to stop)
299
+ if (pendingMessages.has(msg.messageId)) {
300
+ pendingMessages.delete(msg.messageId);
301
+ }
302
+ // Also clean up text-based tracking
303
+ if (pendingMessages.has(`text:${msg.message}`)) {
304
+ pendingMessages.delete(`text:${msg.message}`);
305
+ }
306
+ // Small delay to ensure intervals have stopped
307
+ await new Promise((resolve) => setTimeout(resolve, 20));
308
+ // Stop pulsing and clear the grey pulsing input line
309
+ if (inputBox) {
310
+ // Clear input box
311
+ inputBox.clearValue();
312
+ screen?.render();
313
+ }
314
+ else if (rl) {
315
+ // Fallback to readline
316
+ process.stdout.write("\n");
317
+ process.stdout.write("\r\x1b[K");
318
+ }
319
+ // Display the message (only if not already displayed)
320
+ if (!displayedMessageIds.has(msg.messageId)) {
321
+ displayMessage(msg, isOwn, false, messageLogBox || undefined, rl || undefined);
322
+ }
323
+ // Re-enable input - clear the input line completely
324
+ isSending = false;
325
+ currentInput = "";
326
+ isCleaningUp = false; // Clear cleanup flag
327
+ if (inputBox && screen) {
328
+ // Clear input box and refocus
329
+ inputBox.clearValue();
330
+ inputBox.focus();
331
+ screen.render();
332
+ }
333
+ else if (rl) {
334
+ // Fallback to readline
335
+ process.stdout.write("\r\x1b[K");
336
+ rl.prompt();
337
+ }
338
+ }
339
+ else {
340
+ // Not our message, just display it normally
341
+ if (!displayedMessageIds.has(msg.messageId)) {
342
+ displayMessage(msg, isOwn, false, messageLogBox || undefined, rl || undefined);
343
+ }
344
+ }
345
+ }
346
+ }
347
+ else if (event.type === "lease_expired") {
348
+ if (jsonMode) {
349
+ console.log(JSON.stringify({
350
+ type: "lease_expired",
351
+ }));
352
+ }
353
+ else {
354
+ if (messageLogBox) {
355
+ messageLogBox.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
356
+ messageLogBox.setScrollPerc(100);
357
+ screen?.render();
358
+ }
359
+ else if (rl) {
360
+ process.stdout.write("\r\x1b[K");
361
+ console.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
362
+ rl.prompt(true);
363
+ }
364
+ else {
365
+ console.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
366
+ }
367
+ }
368
+ leaseInfo = null;
369
+ if (headerBox) {
370
+ updateHeaderBox(headerBox, null, tokenIdentifier, true);
371
+ screen?.render();
372
+ }
373
+ }
374
+ else if (event.type === "error") {
375
+ if (jsonMode) {
376
+ console.log(JSON.stringify({
377
+ type: "error",
378
+ error: event.error,
379
+ }));
380
+ }
381
+ else {
382
+ if (messageLogBox) {
383
+ messageLogBox.log(chalk.red(`❌ Error: ${event.error || "Unknown error"}`));
384
+ messageLogBox.setScrollPerc(100);
385
+ screen?.render();
386
+ }
387
+ else if (rl) {
388
+ process.stdout.write("\r\x1b[K");
389
+ console.log(chalk.red(`❌ Error: ${event.error || "Unknown error"}`));
390
+ rl.prompt(true);
391
+ }
392
+ else {
393
+ console.log(chalk.red(`❌ Error: ${event.error || "Unknown error"}`));
394
+ }
395
+ }
396
+ }
397
+ }
398
+ catch (error) {
399
+ // Ignore parse errors
400
+ }
401
+ });
402
+ websocket.on("error", (error) => {
403
+ // Only log errors if not exiting and connection is still relevant
404
+ if (isExiting)
405
+ return;
406
+ if (jsonMode) {
407
+ console.log(JSON.stringify({
408
+ type: "error",
409
+ error: error.message,
410
+ }));
411
+ }
412
+ else {
413
+ const errorMsg = error.message || "Unknown WebSocket error";
414
+ if (messageLogBox) {
415
+ messageLogBox.log(chalk.red(`❌ WebSocket error: ${errorMsg}`));
416
+ messageLogBox.setScrollPerc(100);
417
+ screen?.render();
418
+ }
419
+ else if (rl) {
420
+ process.stdout.write("\r\x1b[K");
421
+ console.log(chalk.red(`❌ WebSocket error: ${errorMsg}`));
422
+ rl.prompt(true);
423
+ }
424
+ else {
425
+ console.log(chalk.red(`❌ WebSocket error: ${errorMsg}`));
426
+ }
427
+ }
428
+ });
429
+ websocket.on("close", (code, reason) => {
430
+ // Only show warning for unexpected closes (not normal closure or going away)
431
+ // Code 1000 = Normal Closure
432
+ // Code 1001 = Going Away (server restart, etc.)
433
+ // Code 1006 = Abnormal Closure (connection lost)
434
+ // Code 1008 = Policy Violation (e.g., invalid/expired lease)
435
+ const isNormalClose = code === 1000 || code === 1001;
436
+ const shouldShowWarning = !isExiting && !jsonMode && !isNormalClose;
437
+ if (shouldShowWarning) {
438
+ const reasonStr = reason.toString() || "Connection lost";
439
+ let message = `⚠️ WebSocket connection closed`;
440
+ // Special handling for lease-related errors
441
+ if (code === 1008 && reasonStr.toLowerCase().includes("lease")) {
442
+ // Check if lease is actually expired or if this might be a timing issue
443
+ const isLeaseExpired = !leaseInfo || leaseInfo.leaseExpiresAt.getTime() <= Date.now();
444
+ if (isLeaseExpired) {
445
+ message = `⏱️ Your lease has expired. Type /renew to continue chatting.`;
446
+ }
447
+ else {
448
+ // Lease should still be valid - this might be a server-side issue
449
+ message = `⚠️ Lease validation failed on server. Your lease appears valid. Type /renew to get a fresh lease.`;
450
+ }
451
+ leaseInfo = null; // Mark lease as invalid
452
+ if (headerBox) {
453
+ updateHeaderBox(headerBox, null, tokenIdentifier, false);
454
+ screen?.render();
455
+ }
456
+ }
457
+ else if (code === 1006) {
458
+ message = `⚠️ Connection lost. Type /renew to reconnect.`;
459
+ }
460
+ else {
461
+ message = `⚠️ WebSocket connection closed (code: ${code}): ${reasonStr}`;
462
+ }
463
+ if (messageLogBox) {
464
+ messageLogBox.log(chalk.yellow(message));
465
+ messageLogBox.setScrollPerc(100);
466
+ screen?.render();
467
+ }
468
+ else if (rl) {
469
+ process.stdout.write("\r\x1b[K");
470
+ console.log(chalk.yellow(message));
471
+ rl.prompt(true);
472
+ }
473
+ else {
474
+ console.log(chalk.yellow(message));
475
+ }
476
+ }
477
+ });
478
+ };
479
+ // Connect to WebSocket with retry logic for timing issues
480
+ if (!wsUrl) {
481
+ throw new Error("WebSocket URL is not available");
482
+ }
483
+ let retryCount = 0;
484
+ const maxRetries = 3;
485
+ let connected = false;
486
+ while (!connected && retryCount < maxRetries) {
487
+ try {
488
+ if (retryCount > 0) {
489
+ // Exponential backoff: 500ms, 1000ms, 2000ms
490
+ const backoffDelay = Math.min(500 * Math.pow(2, retryCount - 1), 2000);
491
+ if (!jsonMode) {
492
+ if (messageLogBox) {
493
+ messageLogBox.log(chalk.yellow(`🔄 Retrying WebSocket connection (attempt ${retryCount + 1}/${maxRetries})...`));
494
+ messageLogBox.setScrollPerc(100);
495
+ screen?.render();
496
+ }
497
+ else if (rl) {
498
+ console.log(chalk.yellow(`🔄 Retrying WebSocket connection (attempt ${retryCount + 1}/${maxRetries})...`));
499
+ }
500
+ }
501
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay));
502
+ }
503
+ // Create WebSocket connection and wait for it to open or fail
504
+ await new Promise((resolve, reject) => {
505
+ const newWs = new WebSocket(wsUrl);
506
+ // Attach permanent handlers FIRST (before temporary handlers)
507
+ attachWebSocketHandlers(newWs);
508
+ // Set up temporary handlers for connection establishment
509
+ let timeout = null;
510
+ let openHandler = null;
511
+ let closeHandler = null;
512
+ let errorHandler = null;
513
+ const cleanup = () => {
514
+ if (timeout)
515
+ clearTimeout(timeout);
516
+ if (openHandler)
517
+ newWs.removeListener("open", openHandler);
518
+ if (closeHandler)
519
+ newWs.removeListener("close", closeHandler);
520
+ if (errorHandler)
521
+ newWs.removeListener("error", errorHandler);
522
+ };
523
+ timeout = setTimeout(() => {
524
+ cleanup();
525
+ newWs.close();
526
+ reject(new Error("Connection timeout - lease may not be ready yet"));
527
+ }, 10000);
528
+ openHandler = () => {
529
+ cleanup();
530
+ ws = newWs; // Assign to outer variable only after successful connection
531
+ resolve();
532
+ };
533
+ closeHandler = (code, reason) => {
534
+ cleanup();
535
+ const reasonStr = reason.toString();
536
+ // Only retry on code 1008 (lease validation) errors
537
+ if (code === 1008 && reasonStr.toLowerCase().includes("lease")) {
538
+ reject(new Error("Lease validation failed - may need more time to commit"));
539
+ }
540
+ else {
541
+ reject(new Error(`Connection closed: ${code} - ${reasonStr}`));
542
+ }
543
+ };
544
+ errorHandler = (error) => {
545
+ cleanup();
546
+ reject(error);
547
+ };
548
+ // Attach temporary handlers AFTER permanent handlers
549
+ newWs.once("open", openHandler);
550
+ newWs.once("close", closeHandler);
551
+ newWs.once("error", errorHandler);
552
+ });
553
+ connected = true;
554
+ // Connection established successfully
555
+ if (!jsonMode && messageLogBox && retryCount > 0) {
556
+ messageLogBox.log(chalk.green("✅ WebSocket connected successfully"));
557
+ messageLogBox.setScrollPerc(100);
558
+ screen?.render();
559
+ }
560
+ }
561
+ catch (error) {
562
+ retryCount++;
563
+ const errorMsg = error instanceof Error ? error.message : String(error);
564
+ // Check if this is a retryable error (lease validation timing issue)
565
+ const isRetryable = errorMsg.includes("Lease validation failed") ||
566
+ errorMsg.includes("lease may not be ready");
567
+ if (retryCount >= maxRetries || !isRetryable) {
568
+ // Final failure or non-retryable error
569
+ const finalError = `Failed to connect after ${retryCount} attempts: ${errorMsg}`;
570
+ if (jsonMode) {
571
+ console.log(JSON.stringify({
572
+ type: "error",
573
+ error: finalError,
574
+ }));
575
+ }
576
+ else {
577
+ if (messageLogBox) {
578
+ messageLogBox.log(chalk.red(`❌ ${finalError}`));
579
+ messageLogBox.log(chalk.yellow("💡 Try typing /renew to get a fresh lease"));
580
+ messageLogBox.setScrollPerc(100);
581
+ screen?.render();
582
+ }
583
+ else if (rl) {
584
+ console.log(chalk.red(`❌ ${finalError}`));
585
+ console.log(chalk.yellow("💡 Try typing /renew to get a fresh lease"));
586
+ }
587
+ else {
588
+ console.log(chalk.red(`❌ ${finalError}`));
589
+ }
590
+ }
591
+ throw new Error(finalError);
592
+ }
593
+ // Will retry on next iteration
594
+ }
595
+ }
596
+ if (!connected) {
597
+ throw new Error("Failed to establish WebSocket connection");
598
+ }
599
+ // Set up blessed UI or readline interface (only in interactive mode)
600
+ if (!jsonMode) {
601
+ let useBlessed = true;
602
+ // Try to create blessed screen with error handling
603
+ try {
604
+ // Check if terminal supports blessed (basic compatibility check)
605
+ if (!process.stdout.isTTY) {
606
+ throw new Error("Not a TTY - falling back to readline");
607
+ }
608
+ // Create blessed screen and widgets
609
+ screen = blessed.screen({
610
+ smartCSR: true,
611
+ title: "httpcat Chat",
612
+ fullUnicode: true,
613
+ // Add error handling for terminal compatibility
614
+ fastCSR: false, // Disable fast CSR to avoid rendering issues
615
+ });
616
+ // Calculate header height (approximately 10 lines)
617
+ const headerHeight = 10;
618
+ const inputHeight = 3;
619
+ // Create header box (fixed at top)
620
+ headerBox = blessed.box({
621
+ top: 0,
622
+ left: 0,
623
+ width: "100%",
624
+ height: headerHeight,
625
+ content: "",
626
+ tags: false, // Disable tags to avoid rendering issues
627
+ wrap: true,
628
+ scrollable: false,
629
+ alwaysScroll: false,
630
+ padding: {
631
+ left: 1,
632
+ right: 1,
633
+ },
634
+ style: {
635
+ fg: "white",
636
+ bg: "black",
637
+ },
638
+ });
639
+ // Create message log box (scrollable, middle section)
640
+ messageLogBox = blessed.log({
641
+ top: headerHeight,
642
+ left: 0,
643
+ width: "100%",
644
+ height: `100%-${headerHeight + inputHeight}`,
645
+ tags: true,
646
+ scrollable: true,
647
+ alwaysScroll: true,
648
+ scrollbar: {
649
+ ch: " ",
650
+ inverse: true,
651
+ },
652
+ style: {
653
+ fg: "white",
654
+ bg: "black",
655
+ },
656
+ });
657
+ // Create input box (fixed at bottom)
658
+ inputBox = blessed.textbox({
659
+ bottom: 0,
660
+ left: 0,
661
+ width: "100%",
662
+ height: inputHeight,
663
+ content: "",
664
+ inputOnFocus: true,
665
+ tags: true,
666
+ keys: true,
667
+ style: {
668
+ fg: "cyan",
669
+ bg: "black",
670
+ focus: {
671
+ fg: "white",
672
+ bg: "blue",
673
+ },
674
+ },
675
+ });
676
+ // Append widgets to screen
677
+ screen.append(headerBox);
678
+ screen.append(messageLogBox);
679
+ screen.append(inputBox);
680
+ // Test render to catch early errors
681
+ screen.render();
682
+ // Initial header update
683
+ updateHeaderBox(headerBox, leaseInfo, tokenIdentifier, false);
684
+ screen.render();
685
+ }
686
+ catch (blessedError) {
687
+ // Blessed failed - fallback to readline
688
+ useBlessed = false;
689
+ console.error(chalk.yellow("⚠️ Blessed UI initialization failed, falling back to readline interface"));
690
+ console.error(chalk.dim(` Error: ${blessedError instanceof Error ? blessedError.message : String(blessedError)}`));
691
+ // Clean up any partial blessed setup
692
+ if (screen) {
693
+ try {
694
+ screen.destroy();
695
+ }
696
+ catch {
697
+ // Ignore cleanup errors
698
+ }
699
+ screen = null;
700
+ headerBox = null;
701
+ messageLogBox = null;
702
+ inputBox = null;
703
+ }
704
+ // Create readline interface as fallback
705
+ rl = createInterface({
706
+ input: process.stdin,
707
+ output: process.stdout,
708
+ prompt: "",
709
+ });
710
+ // Display header information via console
711
+ console.log();
712
+ console.log(chalk.cyan("═".repeat(80)));
713
+ console.log(chalk.cyan("💬 httpcat Chat Stream"));
714
+ console.log(chalk.cyan("═".repeat(80)));
715
+ console.log();
716
+ console.log(chalk.green("✅ Connected to chat stream"));
717
+ console.log();
718
+ console.log(chalk.dim("💰 Entry fee: $0.01 USDC (10 min lease)"));
719
+ console.log(chalk.dim("💬 Per message: $0.01 USDC"));
720
+ if (leaseInfo) {
721
+ const timeRemaining = formatTimeRemaining(leaseInfo.leaseExpiresAt);
722
+ console.log(chalk.dim(`⏱️ Lease expires in: ${timeRemaining}`));
723
+ }
724
+ console.log();
725
+ console.log(chalk.dim("💡 Type your message and press Enter to send"));
726
+ console.log(chalk.dim("💡 Type /exit or Ctrl+C to quit"));
727
+ console.log(chalk.dim("💡 Type /renew to renew your lease"));
728
+ console.log(chalk.cyan("─".repeat(80)));
729
+ console.log();
730
+ }
731
+ // Display last messages
732
+ if (joinResult.lastMessages.length > 0) {
733
+ const sortedMessages = [...joinResult.lastMessages].sort((a, b) => {
734
+ const timeA = new Date(a.timestamp).getTime();
735
+ const timeB = new Date(b.timestamp).getTime();
736
+ return timeA - timeB; // Oldest first
737
+ });
738
+ sortedMessages.forEach((msg) => {
739
+ displayedMessageIds.add(msg.messageId);
740
+ const isOwn = msg.author === userAddress;
741
+ displayMessage(msg, isOwn, false, messageLogBox || undefined, rl || undefined);
742
+ });
743
+ }
744
+ // Only set up blessed-specific handlers if using blessed
745
+ if (useBlessed && screen) {
746
+ // Handle terminal resize
747
+ screen.on("resize", () => {
748
+ screen?.render();
749
+ });
750
+ // Update header with lease countdown every second
751
+ headerUpdateInterval = setInterval(() => {
752
+ if (leaseInfo && headerBox) {
753
+ updateHeaderBox(headerBox, leaseInfo, tokenIdentifier, true);
754
+ screen?.render();
755
+ }
756
+ }, 1000);
757
+ }
758
+ // Check lease expiration every 30 seconds (works for both blessed and readline)
759
+ leaseCheckInterval = setInterval(() => {
760
+ if (leaseInfo && leaseInfo.leaseExpiresAt.getTime() <= Date.now()) {
761
+ if (messageLogBox) {
762
+ messageLogBox.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
763
+ messageLogBox.setScrollPerc(100);
764
+ screen?.render();
765
+ }
766
+ else if (rl) {
767
+ console.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
768
+ }
769
+ leaseInfo = null;
770
+ if (headerBox) {
771
+ updateHeaderBox(headerBox, null, tokenIdentifier, true);
772
+ screen?.render();
773
+ }
774
+ }
775
+ }, 30000);
776
+ // Handle input submission
777
+ if (useBlessed && inputBox) {
778
+ // Blessed input handling
779
+ inputBox.on("submit", async (value) => {
780
+ const trimmed = value.trim();
781
+ // Ignore input while sending
782
+ if (isSending || !trimmed) {
783
+ inputBox?.clearValue();
784
+ inputBox?.focus();
785
+ screen?.render();
786
+ return;
787
+ }
788
+ // Handle commands
789
+ if (trimmed.startsWith("/")) {
790
+ const [cmd] = trimmed.split(" ");
791
+ switch (cmd) {
792
+ case "/exit":
793
+ case "/quit":
794
+ isExiting = true;
795
+ if (leaseCheckInterval)
796
+ clearInterval(leaseCheckInterval);
797
+ if (headerUpdateInterval)
798
+ clearInterval(headerUpdateInterval);
799
+ if (ws)
800
+ ws.close();
801
+ screen?.destroy();
802
+ console.log();
803
+ printCat("sleeping");
804
+ console.log(chalk.cyan("Chat disconnected. Goodbye! 👋"));
805
+ process.exit(0);
806
+ return;
807
+ case "/renew": {
808
+ try {
809
+ inputBox?.setValue("⏱️ Renewing lease...");
810
+ screen?.render();
811
+ const renewal = await renewLease(client, leaseInfo?.leaseId);
812
+ leaseInfo = {
813
+ leaseId: renewal.leaseId,
814
+ leaseExpiresAt: new Date(renewal.leaseExpiresAt),
815
+ };
816
+ // Update header first
817
+ if (headerBox) {
818
+ updateHeaderBox(headerBox, leaseInfo, tokenIdentifier, false);
819
+ }
820
+ if (messageLogBox) {
821
+ messageLogBox.log(chalk.green(`✅ Lease renewed! Expires in ${formatTimeRemaining(leaseInfo.leaseExpiresAt)}`));
822
+ messageLogBox.log(chalk.yellow("🔄 Reconnecting to chat stream..."));
823
+ messageLogBox.setScrollPerc(100);
824
+ screen?.render();
825
+ }
826
+ // Close old connection properly
827
+ if (ws) {
828
+ // Remove all listeners to prevent conflicts
829
+ ws.removeAllListeners();
830
+ // Ensure connection is fully closed
831
+ // WebSocket.OPEN = 1, WebSocket.CONNECTING = 0
832
+ if (ws.readyState === 1 || ws.readyState === 0) {
833
+ ws.close();
834
+ }
835
+ ws = null;
836
+ }
837
+ // Wait for database transaction to commit and old connection to fully close
838
+ // The server validates the lease in the open handler, so we need to ensure
839
+ // the database update is visible before connecting
840
+ // Increased delay to 2 seconds for better database consistency
841
+ await new Promise((resolve) => setTimeout(resolve, 2000));
842
+ // Reconnect with new lease - with retry logic
843
+ if (wsUrl) {
844
+ const wsUrlObj = new URL(wsUrl);
845
+ wsUrlObj.searchParams.set("leaseId", leaseInfo.leaseId);
846
+ wsUrl = wsUrlObj.toString(); // Update stored URL
847
+ // Retry logic with exponential backoff
848
+ let retryCount = 0;
849
+ const maxRetries = 3;
850
+ let connected = false;
851
+ while (!connected && retryCount < maxRetries) {
852
+ try {
853
+ if (retryCount > 0) {
854
+ const backoffDelay = Math.min(1000 * Math.pow(2, retryCount - 1), 5000);
855
+ if (messageLogBox) {
856
+ messageLogBox.log(chalk.yellow(`🔄 Retry ${retryCount}/${maxRetries} in ${backoffDelay}ms...`));
857
+ messageLogBox.setScrollPerc(100);
858
+ screen?.render();
859
+ }
860
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay));
861
+ }
862
+ // Create new connection and wait for it to open
863
+ const newWsUrl = wsUrl; // TypeScript: ensure it's not null
864
+ await new Promise((resolve, reject) => {
865
+ const newWs = new WebSocket(newWsUrl);
866
+ // Attach permanent handlers FIRST (before temporary handlers)
867
+ attachWebSocketHandlers(newWs);
868
+ // Set up temporary handlers for connection establishment
869
+ let timeout = null;
870
+ let openHandler = null;
871
+ let closeHandler = null;
872
+ let errorHandler = null;
873
+ const cleanup = () => {
874
+ if (timeout)
875
+ clearTimeout(timeout);
876
+ if (openHandler)
877
+ newWs.removeListener("open", openHandler);
878
+ if (closeHandler)
879
+ newWs.removeListener("close", closeHandler);
880
+ if (errorHandler)
881
+ newWs.removeListener("error", errorHandler);
882
+ };
883
+ timeout = setTimeout(() => {
884
+ cleanup();
885
+ newWs.close();
886
+ reject(new Error("Connection timeout - lease may not be ready yet"));
887
+ }, 10000);
888
+ openHandler = () => {
889
+ cleanup();
890
+ ws = newWs; // Assign to outer variable only after successful connection
891
+ resolve();
892
+ };
893
+ closeHandler = (code, reason) => {
894
+ cleanup();
895
+ const reasonStr = reason.toString();
896
+ if (code === 1008 && reasonStr.includes("lease")) {
897
+ reject(new Error("Lease validation failed - please try /renew again"));
898
+ }
899
+ else {
900
+ reject(new Error(`Connection closed: ${code} - ${reasonStr}`));
901
+ }
902
+ };
903
+ errorHandler = (error) => {
904
+ cleanup();
905
+ reject(error);
906
+ };
907
+ // Attach temporary handlers AFTER permanent handlers
908
+ newWs.once("open", openHandler);
909
+ newWs.once("close", closeHandler);
910
+ newWs.once("error", errorHandler);
911
+ });
912
+ connected = true;
913
+ // Connection established
914
+ if (headerBox) {
915
+ updateHeaderBox(headerBox, leaseInfo, tokenIdentifier, true);
916
+ }
917
+ if (messageLogBox) {
918
+ messageLogBox.log(chalk.green("✅ Reconnected to chat stream"));
919
+ messageLogBox.setScrollPerc(100);
920
+ }
921
+ screen?.render();
922
+ }
923
+ catch (error) {
924
+ retryCount++;
925
+ const errorMsg = error instanceof Error ? error.message : String(error);
926
+ if (retryCount >= maxRetries) {
927
+ // Final failure
928
+ throw new Error(`Failed to reconnect after ${maxRetries} attempts: ${errorMsg}`);
929
+ }
930
+ // Log retry attempt
931
+ if (messageLogBox) {
932
+ messageLogBox.log(chalk.yellow(`⚠️ Connection attempt ${retryCount} failed: ${errorMsg}`));
933
+ messageLogBox.setScrollPerc(100);
934
+ screen?.render();
935
+ }
936
+ }
937
+ }
938
+ }
939
+ inputBox?.clearValue();
940
+ inputBox?.focus();
941
+ screen?.render();
942
+ }
943
+ catch (error) {
944
+ const errorMsg = error instanceof Error ? error.message : String(error);
945
+ if (messageLogBox) {
946
+ messageLogBox.log(chalk.red(`❌ ${errorMsg}`));
947
+ messageLogBox.setScrollPerc(100);
948
+ }
949
+ inputBox?.clearValue();
950
+ inputBox?.focus();
951
+ screen?.render();
952
+ }
953
+ return;
954
+ }
955
+ case "/help":
956
+ if (messageLogBox) {
957
+ messageLogBox.log(chalk.dim("Commands: /exit, /quit, /renew, /help"));
958
+ messageLogBox.setScrollPerc(100);
959
+ screen?.render();
960
+ }
961
+ inputBox?.clearValue();
962
+ inputBox?.focus();
963
+ screen?.render();
964
+ return;
965
+ default:
966
+ if (messageLogBox) {
967
+ messageLogBox.log(chalk.yellow(`Unknown command: ${cmd}. Type /help for commands.`));
968
+ messageLogBox.setScrollPerc(100);
969
+ screen?.render();
970
+ }
971
+ inputBox?.clearValue();
972
+ inputBox?.focus();
973
+ screen?.render();
974
+ return;
975
+ }
976
+ }
977
+ // Check if websocket is still connected
978
+ // WebSocket.OPEN = 1
979
+ if (!ws || ws.readyState !== 1) {
980
+ const stateMsg = ws
981
+ ? ws.readyState === 0 ? "connecting"
982
+ : ws.readyState === 2 ? "closing"
983
+ : ws.readyState === 3 ? "closed"
984
+ : "unknown"
985
+ : "not initialized";
986
+ if (messageLogBox) {
987
+ messageLogBox.log(chalk.red(`❌ WebSocket is not connected (state: ${stateMsg}). Please wait for connection or type /renew.`));
988
+ messageLogBox.setScrollPerc(100);
989
+ screen?.render();
990
+ }
991
+ inputBox?.clearValue();
992
+ inputBox?.focus();
993
+ screen?.render();
994
+ return;
995
+ }
996
+ // Check if lease is valid
997
+ if (!leaseInfo || leaseInfo.leaseExpiresAt.getTime() <= Date.now()) {
998
+ if (messageLogBox) {
999
+ messageLogBox.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
1000
+ messageLogBox.setScrollPerc(100);
1001
+ screen?.render();
1002
+ }
1003
+ inputBox?.clearValue();
1004
+ inputBox?.focus();
1005
+ screen?.render();
1006
+ return;
1007
+ }
1008
+ // Send message
1009
+ isSending = true;
1010
+ currentInput = trimmed;
1011
+ // Show pulsing animation in input box
1012
+ let pulseCount = 0;
1013
+ let pulseInterval = null;
1014
+ const tempMessageId = `pending-${Date.now()}-${Math.random()}`;
1015
+ const updatePulse = () => {
1016
+ if (!inputBox || isCleaningUp)
1017
+ return;
1018
+ pulseCount++;
1019
+ const pulseChar = pulseCount % 2 === 0 ? "●" : "○";
1020
+ inputBox.setValue(`${trimmed} ${pulseChar}`);
1021
+ screen?.render();
1022
+ };
1023
+ // Initial pulse
1024
+ updatePulse();
1025
+ pulseInterval = setInterval(updatePulse, 500);
1026
+ pulseIntervals.set(tempMessageId, pulseInterval);
1027
+ try {
1028
+ // Ensure lease is valid before sending
1029
+ leaseInfo = await ensureLeaseValid(client, leaseInfo, messageLogBox || undefined, screen || undefined);
1030
+ if (headerBox) {
1031
+ updateHeaderBox(headerBox, leaseInfo, tokenIdentifier, true);
1032
+ screen?.render();
1033
+ }
1034
+ // Double-check websocket is still connected after lease renewal
1035
+ // WebSocket.OPEN = 1
1036
+ if (!ws || ws.readyState !== 1) {
1037
+ throw new Error("WebSocket connection lost. Please wait for reconnection.");
1038
+ }
1039
+ const result = await sendChatMessage(client, trimmed, leaseInfo.leaseId);
1040
+ userAddress = result.author;
1041
+ // Track this message
1042
+ pendingMessages.set(result.messageId, tempMessageId);
1043
+ pendingMessages.set(`text:${trimmed}`, tempMessageId);
1044
+ // Keep pulsing until WebSocket confirms
1045
+ }
1046
+ catch (error) {
1047
+ // Stop pulsing
1048
+ if (pulseInterval) {
1049
+ clearInterval(pulseInterval);
1050
+ pulseIntervals.delete(tempMessageId);
1051
+ }
1052
+ const errorMsg = error instanceof Error ? error.message : String(error);
1053
+ if (messageLogBox) {
1054
+ messageLogBox.log(chalk.red(`❌ ${errorMsg}`));
1055
+ messageLogBox.setScrollPerc(100);
1056
+ }
1057
+ inputBox?.clearValue();
1058
+ inputBox?.focus();
1059
+ isSending = false;
1060
+ currentInput = "";
1061
+ screen?.render();
1062
+ }
1063
+ });
1064
+ }
1065
+ else if (rl) {
1066
+ // Readline input handling
1067
+ const readlineInterface = rl; // Store reference for TypeScript
1068
+ readlineInterface.setPrompt("");
1069
+ readlineInterface.prompt();
1070
+ readlineInterface.on("line", async (line) => {
1071
+ const trimmed = line.trim();
1072
+ // Ignore empty input
1073
+ if (!trimmed) {
1074
+ readlineInterface.prompt();
1075
+ return;
1076
+ }
1077
+ // Handle commands
1078
+ if (trimmed.startsWith("/")) {
1079
+ const [cmd] = trimmed.split(" ");
1080
+ switch (cmd) {
1081
+ case "/exit":
1082
+ case "/quit":
1083
+ isExiting = true;
1084
+ if (leaseCheckInterval)
1085
+ clearInterval(leaseCheckInterval);
1086
+ if (headerUpdateInterval)
1087
+ clearInterval(headerUpdateInterval);
1088
+ if (ws)
1089
+ ws.close();
1090
+ readlineInterface.close();
1091
+ console.log();
1092
+ printCat("sleeping");
1093
+ console.log(chalk.cyan("Chat disconnected. Goodbye! 👋"));
1094
+ process.exit(0);
1095
+ return;
1096
+ case "/renew": {
1097
+ try {
1098
+ console.log(chalk.yellow("⏱️ Renewing lease..."));
1099
+ const renewal = await renewLease(client, leaseInfo?.leaseId);
1100
+ leaseInfo = {
1101
+ leaseId: renewal.leaseId,
1102
+ leaseExpiresAt: new Date(renewal.leaseExpiresAt),
1103
+ };
1104
+ console.log(chalk.green(`✅ Lease renewed! Expires in ${formatTimeRemaining(leaseInfo.leaseExpiresAt)}`));
1105
+ console.log(chalk.yellow("🔄 Reconnecting to chat stream..."));
1106
+ // Close old connection properly
1107
+ if (ws) {
1108
+ ws.removeAllListeners();
1109
+ // WebSocket.OPEN = 1, WebSocket.CONNECTING = 0
1110
+ if (ws.readyState === 1 || ws.readyState === 0) {
1111
+ ws.close();
1112
+ }
1113
+ ws = null;
1114
+ }
1115
+ // Wait for database transaction to commit
1116
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1117
+ // Reconnect with new lease
1118
+ if (wsUrl) {
1119
+ const wsUrlObj = new URL(wsUrl);
1120
+ wsUrlObj.searchParams.set("leaseId", leaseInfo.leaseId);
1121
+ wsUrl = wsUrlObj.toString();
1122
+ // Retry logic
1123
+ let retryCount = 0;
1124
+ const maxRetries = 3;
1125
+ let connected = false;
1126
+ while (!connected && retryCount < maxRetries) {
1127
+ try {
1128
+ if (retryCount > 0) {
1129
+ const backoffDelay = Math.min(1000 * Math.pow(2, retryCount - 1), 5000);
1130
+ console.log(chalk.yellow(`🔄 Retry ${retryCount}/${maxRetries} in ${backoffDelay}ms...`));
1131
+ await new Promise((resolve) => setTimeout(resolve, backoffDelay));
1132
+ }
1133
+ const newWsUrl = wsUrl;
1134
+ await new Promise((resolve, reject) => {
1135
+ const newWs = new WebSocket(newWsUrl);
1136
+ attachWebSocketHandlers(newWs);
1137
+ let timeout = null;
1138
+ let openHandler = null;
1139
+ let closeHandler = null;
1140
+ let errorHandler = null;
1141
+ const cleanup = () => {
1142
+ if (timeout)
1143
+ clearTimeout(timeout);
1144
+ if (openHandler)
1145
+ newWs.removeListener("open", openHandler);
1146
+ if (closeHandler)
1147
+ newWs.removeListener("close", closeHandler);
1148
+ if (errorHandler)
1149
+ newWs.removeListener("error", errorHandler);
1150
+ };
1151
+ timeout = setTimeout(() => {
1152
+ cleanup();
1153
+ newWs.close();
1154
+ reject(new Error("Connection timeout"));
1155
+ }, 10000);
1156
+ openHandler = () => {
1157
+ cleanup();
1158
+ ws = newWs;
1159
+ resolve();
1160
+ };
1161
+ closeHandler = (code, reason) => {
1162
+ cleanup();
1163
+ const reasonStr = reason.toString();
1164
+ if (code === 1008 && reasonStr.includes("lease")) {
1165
+ reject(new Error("Lease validation failed"));
1166
+ }
1167
+ else {
1168
+ reject(new Error(`Connection closed: ${code} - ${reasonStr}`));
1169
+ }
1170
+ };
1171
+ errorHandler = (error) => {
1172
+ cleanup();
1173
+ reject(error);
1174
+ };
1175
+ newWs.once("open", openHandler);
1176
+ newWs.once("close", closeHandler);
1177
+ newWs.once("error", errorHandler);
1178
+ });
1179
+ connected = true;
1180
+ console.log(chalk.green("✅ Reconnected to chat stream"));
1181
+ }
1182
+ catch (error) {
1183
+ retryCount++;
1184
+ const errorMsg = error instanceof Error ? error.message : String(error);
1185
+ if (retryCount >= maxRetries) {
1186
+ throw new Error(`Failed to reconnect after ${maxRetries} attempts: ${errorMsg}`);
1187
+ }
1188
+ console.log(chalk.yellow(`⚠️ Connection attempt ${retryCount} failed: ${errorMsg}`));
1189
+ }
1190
+ }
1191
+ }
1192
+ if (readlineInterface)
1193
+ readlineInterface.prompt();
1194
+ }
1195
+ catch (error) {
1196
+ const errorMsg = error instanceof Error ? error.message : String(error);
1197
+ console.log(chalk.red(`❌ ${errorMsg}`));
1198
+ if (readlineInterface)
1199
+ readlineInterface.prompt();
1200
+ }
1201
+ return;
1202
+ }
1203
+ case "/help":
1204
+ console.log(chalk.dim("Commands: /exit, /quit, /renew, /help"));
1205
+ if (readlineInterface)
1206
+ readlineInterface.prompt();
1207
+ return;
1208
+ default:
1209
+ console.log(chalk.yellow(`Unknown command: ${cmd}. Type /help for commands.`));
1210
+ if (readlineInterface)
1211
+ readlineInterface.prompt();
1212
+ return;
1213
+ }
1214
+ }
1215
+ // Check if websocket is still connected
1216
+ if (!ws || ws.readyState !== 1) {
1217
+ const stateMsg = ws
1218
+ ? ws.readyState === 0 ? "connecting"
1219
+ : ws.readyState === 2 ? "closing"
1220
+ : ws.readyState === 3 ? "closed"
1221
+ : "unknown"
1222
+ : "not initialized";
1223
+ console.log(chalk.red(`❌ WebSocket is not connected (state: ${stateMsg}). Please wait for connection or type /renew.`));
1224
+ if (readlineInterface)
1225
+ readlineInterface.prompt();
1226
+ return;
1227
+ }
1228
+ // Check if lease is valid
1229
+ if (!leaseInfo || leaseInfo.leaseExpiresAt.getTime() <= Date.now()) {
1230
+ console.log(chalk.yellow("⏱️ Your lease has expired. Type /renew to continue chatting."));
1231
+ if (readlineInterface)
1232
+ readlineInterface.prompt();
1233
+ return;
1234
+ }
1235
+ // Send message
1236
+ isSending = true;
1237
+ currentInput = trimmed;
1238
+ try {
1239
+ // Ensure lease is valid before sending
1240
+ leaseInfo = await ensureLeaseValid(client, leaseInfo, undefined, undefined);
1241
+ // Double-check websocket is still connected
1242
+ if (!ws || ws.readyState !== 1) {
1243
+ throw new Error("WebSocket connection lost. Please wait for reconnection.");
1244
+ }
1245
+ const result = await sendChatMessage(client, trimmed, leaseInfo.leaseId);
1246
+ userAddress = result.author;
1247
+ // Track this message
1248
+ const tempMessageId = `pending-${Date.now()}-${Math.random()}`;
1249
+ pendingMessages.set(result.messageId, tempMessageId);
1250
+ pendingMessages.set(`text:${trimmed}`, tempMessageId);
1251
+ // Message sent - will be displayed when received via WebSocket
1252
+ }
1253
+ catch (error) {
1254
+ const errorMsg = error instanceof Error ? error.message : String(error);
1255
+ console.log(chalk.red(`❌ ${errorMsg}`));
1256
+ isSending = false;
1257
+ currentInput = "";
1258
+ }
1259
+ if (readlineInterface)
1260
+ readlineInterface.prompt();
1261
+ });
1262
+ // Handle Ctrl+C for readline
1263
+ readlineInterface.on("SIGINT", () => {
1264
+ isExiting = true;
1265
+ isSending = false;
1266
+ pulseIntervals.forEach((interval) => clearInterval(interval));
1267
+ pulseIntervals.clear();
1268
+ if (leaseCheckInterval)
1269
+ clearInterval(leaseCheckInterval);
1270
+ if (headerUpdateInterval)
1271
+ clearInterval(headerUpdateInterval);
1272
+ if (ws)
1273
+ ws.close();
1274
+ if (readlineInterface)
1275
+ readlineInterface.close();
1276
+ console.log();
1277
+ printCat("sleeping");
1278
+ console.log(chalk.cyan("Chat disconnected. Goodbye! 👋"));
1279
+ process.exit(0);
1280
+ });
1281
+ }
1282
+ // Handle Ctrl+C (for blessed mode)
1283
+ if (useBlessed && screen) {
1284
+ screen.key(["C-c"], () => {
1285
+ isExiting = true;
1286
+ isSending = false;
1287
+ pulseIntervals.forEach((interval) => clearInterval(interval));
1288
+ pulseIntervals.clear();
1289
+ if (leaseCheckInterval)
1290
+ clearInterval(leaseCheckInterval);
1291
+ if (headerUpdateInterval)
1292
+ clearInterval(headerUpdateInterval);
1293
+ if (ws)
1294
+ ws.close();
1295
+ screen?.destroy();
1296
+ console.log();
1297
+ printCat("sleeping");
1298
+ console.log(chalk.cyan("Chat disconnected. Goodbye! 👋"));
1299
+ process.exit(0);
1300
+ });
1301
+ // Focus input box and render (only for blessed)
1302
+ if (inputBox) {
1303
+ inputBox.focus();
1304
+ screen.render();
1305
+ }
1306
+ }
1307
+ }
1308
+ else {
1309
+ // JSON mode: keep process alive and stream messages
1310
+ process.on("SIGINT", () => {
1311
+ isExiting = true;
1312
+ if (ws)
1313
+ ws.close();
1314
+ process.exit(0);
1315
+ });
1316
+ }
1317
+ }
1318
+ catch (error) {
1319
+ // Cleanup on error
1320
+ try {
1321
+ if (leaseCheckInterval)
1322
+ clearInterval(leaseCheckInterval);
1323
+ if (headerUpdateInterval)
1324
+ clearInterval(headerUpdateInterval);
1325
+ pulseIntervals.forEach((interval) => clearInterval(interval));
1326
+ pulseIntervals.clear();
1327
+ if (ws !== null) {
1328
+ try {
1329
+ ws.removeAllListeners();
1330
+ // WebSocket.OPEN = 1, WebSocket.CONNECTING = 0
1331
+ const wsState = ws.readyState;
1332
+ if (wsState === 1 || wsState === 0) {
1333
+ ws.close();
1334
+ }
1335
+ }
1336
+ catch {
1337
+ // Ignore cleanup errors
1338
+ }
1339
+ }
1340
+ if (screen) {
1341
+ screen.destroy();
1342
+ }
1343
+ if (rl) {
1344
+ rl.close();
1345
+ }
1346
+ }
1347
+ catch (cleanupError) {
1348
+ // Ignore cleanup errors
1349
+ }
1350
+ if (jsonMode) {
1351
+ console.log(JSON.stringify({
1352
+ type: "error",
1353
+ error: error instanceof Error ? error.message : String(error),
1354
+ }));
1355
+ }
1356
+ else {
1357
+ handleError(error, config.get("preferences")?.verboseLogging);
1358
+ }
1359
+ process.exit(1);
1360
+ }
1361
+ }
1362
+ export function displayChatResult(result) {
1363
+ console.log();
1364
+ console.log(chalk.green("✅ Message sent!"));
1365
+ console.log(chalk.dim(` ID: ${result.messageId}`));
1366
+ console.log(chalk.dim(` Time: ${new Date(result.timestamp).toLocaleString()}`));
1367
+ console.log();
1368
+ }
1369
+ //# sourceMappingURL=chat.js.map