imessage-ts
Version:
TypeScript library for interacting with iMessage on macOS - send messages, monitor chats, and automate responses
424 lines ⢠19.8 kB
JavaScript
"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