536 lines
18 KiB
JavaScript
536 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* TextMe CLI
|
||
* Command-line interface for TextMe messaging service
|
||
*/
|
||
|
||
import { Command } from 'commander';
|
||
import { homedir } from 'os';
|
||
import { mkdir, readFile, writeFile } from 'fs/promises';
|
||
import { existsSync } from 'fs';
|
||
import { join } from 'path';
|
||
import qrcode from 'qrcode-terminal';
|
||
|
||
import { TextMeAuth, TextMeAPI, TextMeRealtime, createTextMeClient } from '../src/index.js';
|
||
import type { Conversation, Message, UserInfo, IncomingMessage } from '../src/index.js';
|
||
|
||
// ============================================================================
|
||
// Credentials Management
|
||
// ============================================================================
|
||
|
||
interface StoredCredentials {
|
||
token: string;
|
||
expiresAt?: number;
|
||
savedAt: number;
|
||
}
|
||
|
||
const CREDENTIALS_DIR = join(homedir(), '.textme');
|
||
const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'credentials.json');
|
||
const CAPTURED_TOKEN_FILE = join(CREDENTIALS_DIR, 'captured-token.json');
|
||
|
||
async function ensureCredentialsDir(): Promise<void> {
|
||
if (!existsSync(CREDENTIALS_DIR)) {
|
||
await mkdir(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
|
||
}
|
||
}
|
||
|
||
async function saveCredentials(token: string, expiresAt?: number): Promise<void> {
|
||
await ensureCredentialsDir();
|
||
const credentials: StoredCredentials = {
|
||
token,
|
||
expiresAt,
|
||
savedAt: Date.now(),
|
||
};
|
||
await writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 });
|
||
}
|
||
|
||
async function loadCredentials(): Promise<StoredCredentials | null> {
|
||
// First try the main credentials file
|
||
try {
|
||
const data = await readFile(CREDENTIALS_FILE, 'utf-8');
|
||
return JSON.parse(data) as StoredCredentials;
|
||
} catch {
|
||
// Fall through to check captured token
|
||
}
|
||
|
||
// Check for captured token from mitmproxy
|
||
try {
|
||
const data = await readFile(CAPTURED_TOKEN_FILE, 'utf-8');
|
||
const captured = JSON.parse(data) as { token: string; captured_at?: string };
|
||
if (captured.token) {
|
||
console.log('📱 Using token captured from mobile app');
|
||
return {
|
||
token: captured.token,
|
||
savedAt: Date.now(),
|
||
};
|
||
}
|
||
} catch {
|
||
// No captured token either
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
async function importCapturedToken(): Promise<boolean> {
|
||
try {
|
||
const data = await readFile(CAPTURED_TOKEN_FILE, 'utf-8');
|
||
const captured = JSON.parse(data) as { token: string; captured_at?: string };
|
||
if (captured.token) {
|
||
await saveCredentials(captured.token);
|
||
return true;
|
||
}
|
||
} catch {
|
||
return false;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function getAuthenticatedClient(): Promise<TextMeAPI> {
|
||
const credentials = await loadCredentials();
|
||
if (!credentials) {
|
||
console.error('❌ Not authenticated. Run `textme auth` first.');
|
||
process.exit(1);
|
||
}
|
||
|
||
// Check if token might be expired
|
||
if (credentials.expiresAt && Date.now() > credentials.expiresAt) {
|
||
console.warn('⚠️ Token may be expired. Run `textme auth` to re-authenticate.');
|
||
}
|
||
|
||
return createTextMeClient(credentials.token);
|
||
}
|
||
|
||
async function getToken(): Promise<string> {
|
||
const credentials = await loadCredentials();
|
||
if (!credentials) {
|
||
console.error('❌ Not authenticated. Run `textme auth` first.');
|
||
process.exit(1);
|
||
}
|
||
return credentials.token;
|
||
}
|
||
|
||
// ============================================================================
|
||
// CLI Helpers
|
||
// ============================================================================
|
||
|
||
function formatTimestamp(dateStr: string): string {
|
||
const date = new Date(dateStr);
|
||
const now = new Date();
|
||
const isToday = date.toDateString() === now.toDateString();
|
||
|
||
if (isToday) {
|
||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||
}
|
||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
|
||
date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||
}
|
||
|
||
function truncate(text: string, maxLength: number): string {
|
||
if (text.length <= maxLength) return text;
|
||
return text.slice(0, maxLength - 1) + '…';
|
||
}
|
||
|
||
function getConversationName(conv: Conversation): string {
|
||
if (conv.name) return conv.name;
|
||
if (conv.participants.length === 0) return 'Unknown';
|
||
if (conv.participants.length === 1) {
|
||
return conv.participants[0].name || conv.participants[0].phone_number;
|
||
}
|
||
return conv.participants.map(p => p.name || p.phone_number).join(', ');
|
||
}
|
||
|
||
// ============================================================================
|
||
// Commands
|
||
// ============================================================================
|
||
|
||
const program = new Command();
|
||
|
||
program
|
||
.name('textme')
|
||
.description('TextMe CLI - Send and receive messages from the terminal')
|
||
.version('0.1.0');
|
||
|
||
// Auth command
|
||
program
|
||
.command('auth')
|
||
.description('Authenticate via QR code scan')
|
||
.action(async () => {
|
||
console.log('🔐 TextMe Authentication\n');
|
||
|
||
// Check if there's already a captured token
|
||
const existing = await loadCredentials();
|
||
if (existing) {
|
||
console.log('ℹ️ Found existing token. Use `textme whoami` to verify or continue to re-authenticate.\n');
|
||
}
|
||
|
||
const auth = new TextMeAuth();
|
||
const sessionId = auth.generateQRSession();
|
||
|
||
// Generate QR code URL for mobile app to scan
|
||
const qrData = `textme://auth/${sessionId}`;
|
||
|
||
console.log('Scan this QR code with the TextMe mobile app:\n');
|
||
qrcode.generate(qrData, { small: true });
|
||
|
||
console.log('\nWaiting for scan... (timeout: 2 minutes)');
|
||
console.log('⚠️ If this fails, use `textme import` with mitmproxy capture instead.\n');
|
||
|
||
try {
|
||
const token = await auth.waitForQRAuth(sessionId);
|
||
|
||
// Get token expiry (default 24h from now if not available)
|
||
const expiresAt = Date.now() + (24 * 60 * 60 * 1000);
|
||
|
||
await saveCredentials(token, expiresAt);
|
||
|
||
console.log('✅ Authentication successful!');
|
||
console.log(` Token saved to ${CREDENTIALS_FILE}`);
|
||
|
||
// Try to get user info to confirm
|
||
try {
|
||
const client = createTextMeClient(token);
|
||
const user = await client.getUserInfo();
|
||
console.log(`\n👤 Logged in as: ${user.first_name || user.username || user.email}`);
|
||
if (user.phone_number) {
|
||
console.log(` Phone: ${user.phone_number}`);
|
||
}
|
||
} catch {
|
||
// Ignore - user info is optional
|
||
}
|
||
} catch (err) {
|
||
if (err instanceof Error) {
|
||
if (err.message.includes('timeout')) {
|
||
console.error('\n⏰ QR scan timed out. Please try again.');
|
||
} else if (err.message.includes('expired')) {
|
||
console.error('\n⏰ Session expired. Please try again.');
|
||
} else {
|
||
console.error(`\n❌ Authentication failed: ${err.message}`);
|
||
}
|
||
} else {
|
||
console.error('\n❌ Authentication failed');
|
||
}
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
// Import command - import captured token from mitmproxy
|
||
program
|
||
.command('import')
|
||
.description('Import token captured from mobile app via mitmproxy')
|
||
.option('-t, --token <token>', 'Manually provide a JWT token')
|
||
.action(async (options) => {
|
||
console.log('📱 Token Import\n');
|
||
|
||
if (options.token) {
|
||
// Manual token provided
|
||
await saveCredentials(options.token);
|
||
console.log('✅ Token saved!');
|
||
console.log(' Run `textme whoami` to verify.\n');
|
||
return;
|
||
}
|
||
|
||
// Try to import from captured-token.json
|
||
const imported = await importCapturedToken();
|
||
|
||
if (imported) {
|
||
console.log('✅ Imported token from captured-token.json');
|
||
console.log(` Saved to ${CREDENTIALS_FILE}`);
|
||
console.log('\n Run `textme whoami` to verify.\n');
|
||
} else {
|
||
console.log('❌ No captured token found.');
|
||
console.log('\nTo capture a token from the mobile app:');
|
||
console.log(' 1. Run: mitmproxy -s scripts/capture-token.py');
|
||
console.log(' 2. Configure your phone to use the proxy');
|
||
console.log(' 3. Open TextMe app on your phone');
|
||
console.log(' 4. Token will be saved to ~/.textme/captured-token.json');
|
||
console.log('\nOr manually provide a token:');
|
||
console.log(' textme import --token YOUR_JWT_TOKEN\n');
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
// Whoami command
|
||
program
|
||
.command('whoami')
|
||
.description('Display current user information')
|
||
.action(async () => {
|
||
try {
|
||
const client = await getAuthenticatedClient();
|
||
const user: UserInfo = await client.getUserInfo();
|
||
|
||
console.log('\n👤 User Information\n');
|
||
console.log(` ID: ${user.id}`);
|
||
console.log(` Email: ${user.email}`);
|
||
if (user.username) console.log(` Username: ${user.username}`);
|
||
if (user.first_name || user.last_name) {
|
||
console.log(` Name: ${[user.first_name, user.last_name].filter(Boolean).join(' ')}`);
|
||
}
|
||
if (user.phone_number) console.log(` Phone: ${user.phone_number}`);
|
||
console.log(` Verified: ${user.is_verified ? '✓' : '✗'}`);
|
||
console.log(` Created: ${formatTimestamp(user.created_at)}`);
|
||
console.log();
|
||
} catch (err) {
|
||
if (err instanceof Error) {
|
||
console.error(`❌ Failed to get user info: ${err.message}`);
|
||
} else {
|
||
console.error('❌ Failed to get user info');
|
||
}
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
// List command
|
||
program
|
||
.command('list')
|
||
.description('List conversations')
|
||
.option('-a, --archived', 'Show archived conversations')
|
||
.option('-l, --limit <n>', 'Number of conversations to show', '20')
|
||
.action(async (options) => {
|
||
try {
|
||
const client = await getAuthenticatedClient();
|
||
const response = await client.listConversations({
|
||
limit: parseInt(options.limit, 10),
|
||
archived: options.archived,
|
||
});
|
||
|
||
if (response.conversations.length === 0) {
|
||
console.log('\n📭 No conversations found.\n');
|
||
return;
|
||
}
|
||
|
||
console.log('\n📬 Conversations\n');
|
||
console.log(' ID Unread Name / Participants');
|
||
console.log(' ────── ────── ────────────────────────────────────────');
|
||
|
||
for (const conv of response.conversations) {
|
||
const name = getConversationName(conv);
|
||
const unreadBadge = conv.unread_count > 0 ? `(${conv.unread_count})`.padEnd(6) : ' ';
|
||
const archiveBadge = conv.is_archived ? ' [archived]' : '';
|
||
const muteBadge = conv.is_muted ? ' [muted]' : '';
|
||
|
||
console.log(` ${String(conv.id).padEnd(7)} ${unreadBadge} ${truncate(name, 40)}${archiveBadge}${muteBadge}`);
|
||
|
||
if (conv.last_message) {
|
||
const preview = truncate(conv.last_message.body, 50).replace(/\n/g, ' ');
|
||
const time = formatTimestamp(conv.last_message.created_at);
|
||
console.log(` └─ ${preview} (${time})`);
|
||
}
|
||
}
|
||
|
||
console.log(`\n Showing ${response.conversations.length} of ${response.count} conversations\n`);
|
||
} catch (err) {
|
||
if (err instanceof Error) {
|
||
console.error(`❌ Failed to list conversations: ${err.message}`);
|
||
} else {
|
||
console.error('❌ Failed to list conversations');
|
||
}
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
// Read command
|
||
program
|
||
.command('read <conversation-id>')
|
||
.description('Read messages from a conversation')
|
||
.option('-n, --limit <n>', 'Number of messages to show', '20')
|
||
.action(async (conversationId: string, options) => {
|
||
try {
|
||
const client = await getAuthenticatedClient();
|
||
const convId = parseInt(conversationId, 10);
|
||
|
||
if (isNaN(convId)) {
|
||
console.error('❌ Invalid conversation ID. Must be a number.');
|
||
process.exit(1);
|
||
}
|
||
|
||
// Get conversation details
|
||
const conv = await client.getConversation(convId);
|
||
const messages = await client.getMessages(convId, {
|
||
limit: parseInt(options.limit, 10),
|
||
});
|
||
|
||
console.log(`\n💬 ${getConversationName(conv)}\n`);
|
||
|
||
if (messages.messages.length === 0) {
|
||
console.log(' No messages yet.\n');
|
||
return;
|
||
}
|
||
|
||
// Display messages in chronological order (oldest first)
|
||
const sortedMessages = [...messages.messages].reverse();
|
||
|
||
for (const msg of sortedMessages) {
|
||
const sender = conv.participants.find(p => p.id === msg.sender_id);
|
||
const senderName = sender?.name || sender?.phone_number || 'You';
|
||
const time = formatTimestamp(msg.created_at);
|
||
const statusIcon = getStatusIcon(msg.status);
|
||
|
||
console.log(` [${time}] ${senderName} ${statusIcon}`);
|
||
|
||
// Handle multi-line messages
|
||
const lines = msg.body.split('\n');
|
||
for (const line of lines) {
|
||
console.log(` ${line}`);
|
||
}
|
||
|
||
// Show attachments
|
||
if (msg.attachments && msg.attachments.length > 0) {
|
||
for (const att of msg.attachments) {
|
||
console.log(` 📎 ${att.filename} (${att.content_type})`);
|
||
}
|
||
}
|
||
|
||
console.log();
|
||
}
|
||
|
||
console.log(` Showing ${messages.messages.length} messages\n`);
|
||
} catch (err) {
|
||
if (err instanceof Error) {
|
||
console.error(`❌ Failed to read messages: ${err.message}`);
|
||
} else {
|
||
console.error('❌ Failed to read messages');
|
||
}
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
function getStatusIcon(status: Message['status']): string {
|
||
switch (status) {
|
||
case 'pending': return '⏳';
|
||
case 'sent': return '✓';
|
||
case 'delivered': return '✓✓';
|
||
case 'read': return '✓✓';
|
||
case 'failed': return '❌';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
// Send command
|
||
program
|
||
.command('send <conversation-id> <message>')
|
||
.description('Send a message to a conversation')
|
||
.action(async (conversationId: string, message: string) => {
|
||
try {
|
||
const client = await getAuthenticatedClient();
|
||
const convId = parseInt(conversationId, 10);
|
||
|
||
if (isNaN(convId)) {
|
||
console.error('❌ Invalid conversation ID. Must be a number.');
|
||
process.exit(1);
|
||
}
|
||
|
||
const response = await client.sendMessage(convId, {
|
||
body: message,
|
||
type: 'text',
|
||
});
|
||
|
||
console.log(`\n✅ Message sent!`);
|
||
console.log(` ID: ${response.message.id}`);
|
||
console.log(` Status: ${response.message.status}`);
|
||
console.log();
|
||
} catch (err) {
|
||
if (err instanceof Error) {
|
||
console.error(`❌ Failed to send message: ${err.message}`);
|
||
} else {
|
||
console.error('❌ Failed to send message');
|
||
}
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
// Watch command
|
||
program
|
||
.command('watch')
|
||
.description('Watch for incoming messages in real-time')
|
||
.option('-c, --conversation <ids>', 'Comma-separated conversation IDs to watch')
|
||
.action(async (options) => {
|
||
try {
|
||
const token = await getToken();
|
||
const realtime = new TextMeRealtime();
|
||
|
||
console.log('\n👁️ Watching for messages... (Ctrl+C to stop)\n');
|
||
|
||
// Set up event handlers
|
||
realtime.on('connected', () => {
|
||
console.log('🟢 Connected to TextMe realtime\n');
|
||
});
|
||
|
||
realtime.on('disconnected', ({ reason }) => {
|
||
console.log(`🔴 Disconnected: ${reason}`);
|
||
});
|
||
|
||
realtime.on('message', (msg: IncomingMessage) => {
|
||
const time = msg.timestamp.toLocaleTimeString('en-US', {
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
});
|
||
|
||
console.log(`[${time}] 💬 ${msg.senderName || msg.senderId}`);
|
||
console.log(` Conversation: ${msg.conversationId}`);
|
||
console.log(` ${msg.text}`);
|
||
|
||
if (msg.mediaUrl) {
|
||
console.log(` 📎 ${msg.mediaType}: ${msg.mediaUrl}`);
|
||
}
|
||
console.log();
|
||
});
|
||
|
||
realtime.on('typing', (event) => {
|
||
const indicator = event.isTyping ? '✏️ typing...' : ' stopped typing';
|
||
console.log(` [${event.conversationId}] ${event.userId} ${indicator}`);
|
||
});
|
||
|
||
realtime.on('call', (event) => {
|
||
const callType = event.type === 'incoming' ? '📞 Incoming call' :
|
||
event.type === 'ended' ? '📴 Call ended' :
|
||
event.type === 'missed' ? '📵 Missed call' : '📱 Call';
|
||
console.log(` ${callType} from ${event.callerName || event.callerId}`);
|
||
if (event.duration) {
|
||
console.log(` Duration: ${event.duration}s`);
|
||
}
|
||
console.log();
|
||
});
|
||
|
||
realtime.on('error', (err) => {
|
||
console.error(`❌ Error: ${err.message}`);
|
||
});
|
||
|
||
// Connect
|
||
await realtime.connect(token);
|
||
|
||
// Subscribe to specific conversations if provided
|
||
if (options.conversation) {
|
||
const ids = options.conversation.split(',').map((s: string) => s.trim());
|
||
for (const id of ids) {
|
||
realtime.subscribe(id);
|
||
console.log(` Subscribed to conversation ${id}`);
|
||
}
|
||
console.log();
|
||
}
|
||
|
||
// Handle graceful shutdown
|
||
const shutdown = () => {
|
||
console.log('\n\n👋 Disconnecting...');
|
||
realtime.disconnect();
|
||
process.exit(0);
|
||
};
|
||
|
||
process.on('SIGINT', shutdown);
|
||
process.on('SIGTERM', shutdown);
|
||
|
||
// Keep process alive
|
||
await new Promise(() => {});
|
||
} catch (err) {
|
||
if (err instanceof Error) {
|
||
console.error(`❌ Failed to connect: ${err.message}`);
|
||
} else {
|
||
console.error('❌ Failed to connect');
|
||
}
|
||
process.exit(1);
|
||
}
|
||
});
|
||
|
||
// Parse and run
|
||
program.parse();
|