UNPKG

imessage-ts

Version:

TypeScript library for interacting with iMessage on macOS - send messages, monitor chats, and automate responses

424 lines • 19.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.IMessageServer = void 0; const express_1 = __importDefault(require("express")); const axios_1 = __importDefault(require("axios")); const path_1 = __importDefault(require("path")); const fs_extra_1 = __importDefault(require("fs-extra")); const os_1 = __importDefault(require("os")); const index_1 = require("../index"); class IMessageServer { constructor(config = {}) { this.config = { port: 3000, webhookUrl: '', whitelistedSenders: [], autoRespond: false, wittyResponses: [ "I'm currently away from my keyboard, probably debugging life šŸ›", "Auto-reply: Converting coffee to code... ā˜•ļø", "I'm in airplane mode right now āœˆļø", "Currently in a meeting with my pillow 😓", "404: Human not found. Try again later.", "I'm probably staring at a screen somewhere else right now šŸ’»" ], saveReceivedImages: true, imagesSaveDir: 'received-images', apiKey: '', // Default to no API key tempAttachmentsDir: path_1.default.join(os_1.default.tmpdir(), 'imessage-attachments'), // Default to tmp directory ...config }; this.client = null; this.server = null; this.app = (0, express_1.default)(); this.app.use(express_1.default.json({ limit: '50mb' })); // Increase limit to 50MB for large images // API Key authentication middleware this.app.use((req, res, next) => { // Skip auth for health check if (req.path === '/health') { return next(); } // If API key is configured, check it if (this.config.apiKey) { const apiKey = req.headers['x-api-key'] || req.query.apiKey; if (apiKey !== this.config.apiKey) { return res.status(401).json({ error: 'Unauthorized', message: 'Invalid or missing API key. Please provide a valid API key in the x-api-key header or apiKey query parameter.' }); } } next(); }); this.setupRoutes(); this.client = new index_1.IMessageClient(); } async sendWebhook(event, data) { if (!this.config.webhookUrl) return; try { await axios_1.default.post(this.config.webhookUrl, { event, timestamp: new Date().toISOString(), data }); console.log(`Webhook sent: ${event}`); } catch (error) { console.error('Failed to send webhook:', error); } } setupRoutes() { // Health check this.app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime(), config: { whitelistedSenders: this.config.whitelistedSenders, autoRespond: this.config.autoRespond, webhookUrl: this.config.webhookUrl ? 'configured' : 'not configured' } }); }); // Send a message this.app.post('/send', async (req, res) => { try { const { to, text, service = 'iMessage', attachments } = req.body; if (!to || !text) { return res.status(400).json({ error: 'Missing required fields: to, text' }); } const messageService = service.toLowerCase() === 'sms' ? index_1.MessageService.SMS : index_1.MessageService.IMESSAGE; let processedAttachments = []; // Process attachments if provided if (attachments && Array.isArray(attachments)) { for (const attachment of attachments) { if (typeof attachment === 'string') { // It's a file path processedAttachments.push(attachment); } else if (attachment && typeof attachment === 'object' && attachment.data) { // It's a base64 object try { // Remove data URL prefix if present const base64Data = attachment.data.replace(/^data:.+;base64,/, ''); const buffer = Buffer.from(base64Data, 'base64'); // Create a temporary file in the configured directory await fs_extra_1.default.ensureDir(this.config.tempAttachmentsDir); // Generate unique filename const timestamp = Date.now(); const fileName = attachment.fileName || `attachment-${timestamp}`; const extension = path_1.default.extname(fileName) || '.bin'; const tempFileName = `${timestamp}-${Math.random().toString(36).substring(7)}-${path_1.default.basename(fileName, extension)}${extension}`; const tempFilePath = path_1.default.join(this.config.tempAttachmentsDir, tempFileName); // Write the file await fs_extra_1.default.writeFile(tempFilePath, buffer); processedAttachments.push(tempFilePath); // Schedule cleanup after 5 minutes setTimeout(async () => { try { await fs_extra_1.default.remove(tempFilePath); } catch (error) { // Ignore cleanup errors } }, 5 * 60 * 1000); } catch (error) { console.error('Failed to process attachment:', error); return res.status(400).json({ error: 'Invalid attachment data: ' + error.message }); } } } } await this.client.sendMessage({ to, text, service: messageService, attachments: processedAttachments.length > 0 ? processedAttachments : undefined }); res.json({ success: true, message: 'Message sent' }); await this.sendWebhook('message_sent', { to, text, service, attachments: processedAttachments }); } catch (error) { console.error('Send message error:', error); res.status(500).json({ error: 'Failed to send message' }); } }); // Get conversations this.app.get('/conversations', async (req, res) => { try { const limit = parseInt(req.query.limit) || 10; const conversations = await this.client.getConversations({ limit }); res.json(conversations); } catch (error) { console.error('Get conversations error:', error); res.status(500).json({ error: 'Failed to get conversations' }); } }); // Get messages from a conversation this.app.get('/messages/:conversationId', async (req, res) => { try { const { conversationId } = req.params; const limit = parseInt(req.query.limit) || 20; const messages = await this.client.getMessages({ conversationId, limit }); res.json(messages); } catch (error) { console.error('Get messages error:', error); res.status(500).json({ error: 'Failed to get messages' }); } }); // Trigger a reaction this.app.post('/react', async (req, res) => { try { const { to, reaction, messageText } = req.body; if (!to || !reaction) { return res.status(400).json({ error: 'Missing required fields: to, reaction' }); } const reactionEmojis = { 'like': 'šŸ‘', 'love': 'ā¤ļø', 'dislike': 'šŸ‘Ž', 'laugh': 'šŸ˜‚', 'emphasize': 'ā€¼ļø', 'question': 'ā“' }; const emoji = reactionEmojis[reaction.toLowerCase()] || 'šŸ‘'; const text = messageText ? `Reacted ${emoji} to: ${messageText}` : `Reacted ${emoji}`; await this.client.sendMessage({ to, text, service: index_1.MessageService.IMESSAGE }); res.json({ success: true, message: 'Reaction sent' }); } catch (error) { console.error('React error:', error); res.status(500).json({ error: 'Failed to send reaction' }); } }); // Send an image this.app.post('/send-image', async (req, res) => { try { const { to, imagePath, imageData, fileName = 'image.png', text = "Here's an image for you! šŸ“ø" } = req.body; if (!to) { return res.status(400).json({ error: 'Missing required field: to' }); } if (!imagePath && !imageData) { return res.status(400).json({ error: 'Either imagePath or imageData (base64) is required' }); } let finalImagePath = imagePath; // If base64 data is provided, save it to a temporary file if (imageData) { try { // Remove data URL prefix if present const base64Data = imageData.replace(/^data:image\/\w+;base64,/, ''); const buffer = Buffer.from(base64Data, 'base64'); // Create a temporary file in the configured directory await fs_extra_1.default.ensureDir(this.config.tempAttachmentsDir); // Generate unique filename const timestamp = Date.now(); const extension = path_1.default.extname(fileName) || '.png'; const tempFileName = `${timestamp}-${Math.random().toString(36).substring(7)}${extension}`; finalImagePath = path_1.default.join(this.config.tempAttachmentsDir, tempFileName); // Write the file await fs_extra_1.default.writeFile(finalImagePath, buffer); // Schedule cleanup after 5 minutes setTimeout(async () => { try { await fs_extra_1.default.remove(finalImagePath); } catch (error) { // Ignore cleanup errors } }, 5 * 60 * 1000); } catch (error) { return res.status(400).json({ error: 'Invalid image data: ' + error.message }); } } // Verify the file exists if (!await fs_extra_1.default.pathExists(finalImagePath)) { return res.status(400).json({ error: 'Image file not found' }); } // wait 5 seconds await new Promise(resolve => setTimeout(resolve, 5000)); await this.client.sendMessage({ to, text, attachments: [finalImagePath], service: index_1.MessageService.IMESSAGE }); res.json({ success: true, message: 'Image sent' }); } catch (error) { console.error('Send image error:', error); res.status(500).json({ error: 'Failed to send image' }); } }); // Webhook endpoint to receive external events this.app.post('/webhook', async (req, res) => { try { const { action, data } = req.body; console.log('Received webhook:', action, data); switch (action) { case 'send_message': await this.client.sendMessage({ to: data.to, text: data.text, service: data.service === 'sms' ? index_1.MessageService.SMS : index_1.MessageService.IMESSAGE }); break; case 'send_witty_response': const response = this.config.wittyResponses[Math.floor(Math.random() * this.config.wittyResponses.length)]; await this.client.sendMessage({ to: data.to, text: response, service: index_1.MessageService.IMESSAGE }); break; default: return res.status(400).json({ error: 'Unknown action' }); } res.json({ success: true }); } catch (error) { console.error('Webhook error:', error); res.status(500).json({ error: 'Failed to process webhook' }); } }); // 404 handler this.app.use((req, res) => { res.status(404).json({ error: 'Endpoint not found' }); }); } async handleMessage(event) { const sender = event.message.handle; const text = event.message.text; // Process attachments to include file data let messageWithAttachmentData = { ...event.message }; if (event.message.attachments && event.message.attachments.length > 0) { const attachmentsWithData = await Promise.all(event.message.attachments.map(async (attachment) => { try { // Read the file and convert to base64 let filePath = attachment.filePath || ''; // Handle paths that start with ~/ if (filePath.startsWith('~/')) { filePath = filePath.replace('~', os_1.default.homedir()); } if (filePath && await fs_extra_1.default.pathExists(filePath)) { const fileBuffer = await fs_extra_1.default.readFile(filePath); const base64Data = fileBuffer.toString('base64'); return { ...attachment, fileData: base64Data, fileSize: fileBuffer.length }; } } catch (error) { console.error(`Failed to read attachment ${attachment.fileName}:`, error); } // Return original attachment if we couldn't read it return attachment; })); messageWithAttachmentData.attachments = attachmentsWithData; } // Send webhook for all received messages await this.sendWebhook('message_received', { sender, text, conversation: event.conversation, message: messageWithAttachmentData }); // Only auto-respond if enabled and sender is whitelisted if (!this.config.autoRespond || !sender || !text) return; if (this.config.whitelistedSenders.length > 0 && !this.config.whitelistedSenders.includes(sender)) return; if (event.message.isFromMe) return; console.log(`Auto-responding to ${sender}: ${text}`); const responseService = event.message.service === 'iMessage' ? index_1.MessageService.IMESSAGE : index_1.MessageService.SMS; try { // Simple auto-response logic let responseText; if (text.toLowerCase().includes('hello') || text.toLowerCase().includes('hi')) { responseText = 'Hello! This is an automated response.'; } else { responseText = this.config.wittyResponses[Math.floor(Math.random() * this.config.wittyResponses.length)]; } await this.client.sendMessage({ to: sender, text: responseText, service: responseService }); await this.sendWebhook('auto_response_sent', { sender, response: responseText }); } catch (error) { console.error('Failed to send auto-response:', error); await this.sendWebhook('error', { sender, error: error.message }); } } async start() { await this.client.initialize(); const isAvailable = await this.client.isAvailable(); if (!isAvailable) { throw new Error('iMessage is not available. Please sign in to iMessage on this Mac.'); } // Set up message handler this.client.on('message', (message) => this.handleMessage(message)); this.client.startWatching(); this.server = this.app.listen(this.config.port, () => { console.log(`\nšŸš€ iMessage Server running on port ${this.config.port}`); if (this.config.apiKey) { console.log('šŸ” API Key authentication is ENABLED'); console.log(' Include x-api-key header or apiKey query parameter in requests'); } else { console.log('āš ļø API Key authentication is DISABLED (not recommended for production)'); } if (this.config.webhookUrl) { console.log(`šŸ“¤ Webhooks enabled: ${this.config.webhookUrl}`); } if (this.config.autoRespond && this.config.whitelistedSenders.length > 0) { console.log(`šŸ¤– Auto-responder enabled for: ${this.config.whitelistedSenders.join(', ')}`); } console.log(`šŸ“ Temporary attachments directory: ${this.config.tempAttachmentsDir}`); console.log('\nReady to receive messages...\n'); }); // Handle cleanup on exit process.on('SIGINT', () => { console.log('\nStopping server...'); this.stop(); process.exit(); }); } stop() { if (this.server) { this.server.close(); } if (this.config.autoRespond) { this.client.stopWatching(); } this.client.close(); } } exports.IMessageServer = IMessageServer; //# sourceMappingURL=index.js.map