macOS iMessage 自动化完全指南:从数据库到 AI 机器人

22 min read

macOS iMessage 自动化完全指南:从数据库到 AI 机器人

前言

iMessage 是 macOS 和 iOS 用户常用的即时通讯工具,其所有聊天记录都存储在一个本地的 SQLite 数据库中。对于开发者来说,这提供了一个机会,通过代码实现自动化回复、数据分析、消息监控以及 AI 聊天机器人等功能。然而,实际开发中会遇到不少技术挑战和难点。

这些挑战大多可以追溯到同一件事:读和写走的根本不是一条路。读消息是直接查一个 SQLite 数据库,写消息却只能用 AppleScript 去遥控 Messages.app 这个 GUI 应用——因为 Apple 从没为 iMessage 提供过官方 API。后面各节的坑,几乎都是这条不对称派生出来的:

读一个 SQLite,写一个 GUI:iMessage 自动化的先天不对称。读通道是 chat.db 经完全磁盘访问权限、以只读方式每 2 秒轮询增量查询;写通道是程序调用 AppleScript 驱动沙盒中的 Messages.app;发出的消息也会落库,程序需要把它再读回来才能确认发送成功

一、iMessage 数据存储的底层架构

1.1 数据库位置与结构

iMessage 的所有消息数据都存储在一个 SQLite 数据库中,位置就在这里:

~/Library/Messages/chat.db

这个数据库从 iMessage 诞生之日起就存在了。如果你是老用户,数据库文件可能已经膨胀到几百 MB,甚至超过 1GB。

核心表结构:

chat.db 包含以下关键表:
├── message          # 消息主表
├── chat             # 会话表(群聊/单聊)
├── handle           # 联系人标识表(手机号/邮箱)
├── attachment       # 附件表
├── chat_message_join        # 消息-会话关联表
├── chat_handle_join         # 会话-联系人关联表
└── message_attachment_join  # 消息-附件关联表

1.2 时间戳转换:Mac 纪元时间

数据库里的时间戳可不是我们常见的 Unix 时间戳。比如:

408978598

如果你直接用 new Date(408978598) 去转换,得到的时间肯定是错的。

关键点:macOS 用的是自己的纪元时间(Epoch),起点是 2001-01-01,而不是 Unix 的 1970-01-01。

正确的转换方法是这样的:

const MAC_EPOCH = new Date('2001-01-01T00:00:00Z').getTime()

function convertMacTimestamp(timestamp: number): Date {
    // macOS 存储的是纳秒,需要除以 1000000
    return new Date(MAC_EPOCH + timestamp / 1000000)
}

这是 macOS 和 iOS 系统的标准时间表示方式(Core Data timestamp),习惯了就很简单。

1.3 消息内容的编码:NSAttributedString

iMessage 的消息文本存储在两个字段里:

  • message.text:纯文本消息
  • message.attributedBody:富文本消息(以二进制 plist 格式存储的 NSAttributedString)

在 imessage-kit 项目中,我们用了两种策略来解析 attributedBody:

策略 1:直接字符串匹配(快,但有点糙)

const bufferStr = buffer.toString('utf8')
// 匹配可读字符(ASCII + 中文)
const readableMatches = bufferStr.match(/[\x20-\x7E\u4e00-\u9fff]{5,}/g)

通过正则表达式直接从二进制数据里提取可读文本,然后过滤掉 plist 关键字(比如 NSAttributedString、NSDictionary 之类)。

策略 2:借助 plutil 工具(精准,但慢一些)

plutil -convert xml1 -o - "temp.plist"

macOS 自带的 plutil 工具能把二进制 plist 转成 XML 格式,然后我们从 XML 里提取 <string> 标签的内容。

这两种方法各有千秋:

  • 方法 1 速度快,但偶尔会提取到乱七八糟的字符串
  • 方法 2 很准确,但需要创建临时文件,还要调用系统命令

在实际项目中,imessage-kit 先试方法 1,如果不行再退回到方法 2,算是一个挺聪明的折中方案。

二、突破 macOS 的安全防护

2.1 Full Disk Access:必需的通行证

从 macOS Mojave(10.14)开始,Apple 对隐私保护下了狠手。~/Library/Messages 目录被列为受保护资源,没授权的程序根本进不去。

症状:

$ sqlite3 ~/Library/Messages/chat.db
Error: unable to open database "chat.db": Operation not permitted

解决办法:

  1. 打开 系统设置 → 隐私与安全性 → 完全磁盘访问权限
  2. 点击 “+” 号,添加你常用的终端或 IDE(比如 Terminal、iTerm、VS Code、Cursor)
  3. 重启应用(这一步很重要,很多人忘了重启,结果权限没生效)

建议开发工具和终端都添加权限,确保在不同环境中都能正常运行。

2.2 SQLite WAL 模式的特殊性

iMessage 数据库用的是 SQLite 的 WAL(Write-Ahead Logging)模式,所以你会看到三个文件:

chat.db
chat.db-shm   # 共享内存文件
chat.db-wal   # 预写日志文件

**重要特性:**新消息一来,chat.db-wal 会立刻更新,但主数据库文件 chat.db 可能会拖个几秒甚至几分钟才更新(得等检查点触发)。

这对实时消息监控影响挺大。如果你直接盯着 chat.db 文件的变化,延迟会很明显。更好的做法是:

  1. 用定时轮询(polling)数据库,而不是监听文件变化
  2. 以只读模式打开数据库,让 SQLite 自己处理 WAL 文件
// 正确的做法
const db = new Database(path, { readonly: true })

2.3 并发访问注意事项

SQLite 支持多读,但写操作会锁住数据库。imessage-kit 特意以只读模式 (readonly: true) 打开数据库,避免冲突。

**重要提醒:**千万别想着去修改 iMessage 数据库!这可能会让 Messages.app 崩溃,甚至把数据搞坏。

三、发送消息:AppleScript 的艺术与妥协

3.1 为什么是 AppleScript?

Apple 没有给 iMessage 提供官方 API。作为开发者,我们唯一能用的官方自动化工具就是 AppleScript——这门 1993 年诞生的古老脚本语言。

一个简单的发送例子:

tell application "Messages"
    set targetBuddy to buddy "+1234567890"
    send "Hello from automation!" to targetBuddy
end tell

但实际用起来,里面门道可不少。

3.2 字符转义问题

AppleScript 对特殊字符特别挑剔,必须老老实实转义:

// 错误的做法
const text = 'He said "Hello"'
const script = `send "${text}" to targetBuddy`
// 会直接报语法错误!

// 正确的做法
function escapeAppleScriptString(str: string): string {
    return str
        .replace(/\\/g, '\\\\')  // 反斜杠
        .replace(/"/g, '\\"')    // 双引号
        .replace(/\n/g, '\\n')   // 换行
        .replace(/\r/g, '\\r')   // 回车
        .replace(/\t/g, '\\t')   // 制表符
}

3.3 沙盒限制的绕过方案

如果你想发送文件附件,会遇到一个更头疼的问题:macOS 的沙盒限制。

**问题:**Messages.app 运行在沙盒里,只能访问特定目录(比如 Documents、Downloads、Pictures)。如果你的文件在别的地方,发送会直接失败。

**解决办法:**把文件临时复制到 ~/Pictures 目录

-- 绕过沙盒:复制到 Pictures 目录
set picturesFolder to POSIX path of (path to pictures folder)
set targetPath to picturesFolder & "imsg_temp_1234567890_file.pdf"
do shell script "cp " & quoted form of "/restricted/path/file.pdf" & " " & quoted form of targetPath

-- 发送文件
set theFile to (POSIX file targetPath) as alias
send theFile to targetBuddy

-- 延迟确保上传完成(尤其是 iMessage)
delay 3

imessage-kit 专门做了个 TempFileManager,会自动扫描并清理 ~/Pictures 下的 imsg_temp_* 文件。默认设置是:

  • 文件保留时间:10 分钟
  • 清理检查间隔:5 分钟

3.4 文件发送延迟

不同大小的文件需要不同的延迟时间,确保 iMessage 能顺利上传到 iCloud:

function calculateFileDelay(filePath: string): number {
    const sizeInMB = getFileSizeInMB(filePath)
    
    if (sizeInMB < 1) return 2      // < 1MB: 2 秒
    if (sizeInMB < 10) return 3     // 1-10MB: 3 秒
    return 5                         // > 10MB: 5 秒
}

这些延迟时间是根据 iMessage 上传到 iCloud 的典型速度设定的,具体可以根据你的网络情况调整。

3.5 群组消息的 ChatId 处理

给群聊发消息比单聊复杂得多,因为得用 chatId:

tell application "Messages"
    set targetChat to chat id "chat45e2b868ce1e43da89af262922733382"
    send "Hello group!" to targetChat
end tell

怎么获取 chatId?

直接从数据库的 chat 表里查:

SELECT 
    chat.guid AS chat_id,
    chat.display_name AS name,
    (SELECT COUNT(*) FROM chat_handle_join 
     WHERE chat_id = chat.ROWID) > 1 AS is_group
FROM chat
WHERE is_group = 1

ChatId 格式说明:

  • 群聊:用 GUID(比如 chat45e2b868ce1e43da...)
  • 单聊:可能是 iMessage;+1234567890 或直接是 +1234567890
  • AppleScript 格式:iMessage;+;chat45e2b868...(需要标准化处理)

imessage-kit 内置了智能的 chatId 标准化逻辑,能自动处理这些格式差异。

四、实时监控:轮询与性能优化

4.1 为什么选轮询而不是事件监听?

很多人问:为什么不用文件系统监听(比如 fs.watch)来检测新消息?

原因有几点:

  1. WAL 模式导致 chat.db 更新有延迟
  2. 文件监听会触发太多误报(数据库内部操作也会改文件)
  3. 轮询结合时间戳查询更靠谱

最佳轮询间隔:

// 太快:浪费 CPU
pollInterval: 500

// 太慢:延迟明显
pollInterval: 10000

// 甜点:2 秒
pollInterval: 2000  // 默认值

2 秒是在响应速度和系统负载之间找到的平衡点。

4.2 增量查询与去重

每次轮询只查上次检查之后的新消息:

// 重叠时间动态调整:取 1 秒和轮询间隔的较小值
const overlapMs = Math.min(1000, this.pollInterval)
const since = new Date(lastCheckTime.getTime() - overlapMs)

const { messages } = await db.getMessages({
    since,
    excludeOwnMessages: false  // 先获取所有消息(包括自己的)
})

// 用 Map 去重
const seenMessageIds = new Map<string, number>()
const newMessages = messages.filter(msg => !seenMessageIds.has(msg.id))

**为什么要重叠时间?**防止在时间边界上丢消息(时钟精度和数据库写入顺序的问题)。

4.3 勿扰模式下的监控机制

值得一提的是:就算 Mac 开了勿扰模式,基于时间戳的轮询依然管用。

这是因为我们直接读数据库,不依赖系统通知。对于需要 24 小时运行的自动化机器人,这点特别重要。

五、跨运行时支持:Bun vs Node.js

5.1 数据库驱动的选择

imessage-kit 有一个很巧妙的设计,能自动检测运行时环境:

async function initDatabase() {
    if (typeof Bun !== 'undefined') {
        // Bun 运行时 - 使用内置 SQLite
        const bunSqlite = await import('bun:sqlite')
        Database = bunSqlite.Database
    } else {
        // Node.js 运行时 - 使用 better-sqlite3
        const BetterSqlite3 = await import('better-sqlite3')
        Database = BetterSqlite3.default
    }
}

性能特点:

  • Bun (bun:sqlite):内置驱动,零依赖,启动更快,内存占用更小
  • Node.js (better-sqlite3):成熟稳定,社区支持好,生态完善

两者的读取性能差别不大,选择哪个主要看你的运行时环境和项目需求。

5.2 Bun 的零依赖优势

用 Bun 最大的好处就是:零外部依赖。

// Node.js
"dependencies": {
    "better-sqlite3": "^11.0.0"
}

// Bun
"dependencies": {}  // 完全零依赖!

对于喜欢极简风格的项目,这是个不小的优势。

六、真实场景与应用案例

6.1 自动化回复机器人

基于消息内容的智能回复:

// 启动监听
await sdk.startWatching({
    onDirectMessage: async (message) => {
        await sdk.message(message)
            .ifFromOthers()
            .matchText(/紧急|urgent/i)
            .replyText('收到!我会尽快处理。')
            .execute()
    }
})

实战经验:

  • 加点关键词匹配,免得误触发
  • 限制回复频率,防止陷入循环
  • 记录已处理的消息 ID

6.2 消息数据分析

用 SDK 做消息数据分析特别方便:

const result = await sdk.getMessages({ 
    since: new Date('2024-01-01'),
    limit: 10000 
})

// 统计最活跃的联系人
const senderCounts = new Map()
for (const msg of result.messages) {
    senderCounts.set(
        msg.sender, 
        (senderCounts.get(msg.sender) || 0) + 1
    )
}

// 按发送次数排序
const sorted = Array.from(senderCounts.entries())
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)

console.log('Top 10 最活跃联系人:', sorted)

你还可以进一步分析消息的时间分布、群聊活跃度、附件类型统计等等。

6.3 Webhook 集成

把 iMessage 通知转发到其他系统(比如 Slack、Discord):

const sdk = new IMessageSDK({
    webhook: {
        url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL',
        headers: { 
            'Content-Type': 'application/json' 
        }
    }
})

await sdk.startWatching()
// 自动转发到 Slack

这对团队协作或者监控重要消息特别有用。

6.4 发送消息追踪机制

imessage-kit 实现了 OutgoingMessageManager 来追踪发送的消息。通过启动 watcher,可以在发送后立即获取消息对象:

// 启动 watcher
await sdk.startWatching()

// 发送消息并获取确认
const result = await sdk.send('+1234567890', 'Hello!')
if (result.message) {
    console.log('消息 ID:', result.message.id)
    console.log('发送时间:', result.message.date)
}

工作原理:

  1. 发送前创建 MessagePromise,记录发送时间、内容、chatId
  2. AppleScript 执行发送
  3. Watcher 轮询检测到新消息后,通过时间戳和内容匹配
  4. 匹配成功后 resolve Promise,返回完整的 Message 对象

匹配逻辑考虑了 chatId 的多种格式(iMessage;-;recipient vs recipient),自动提取核心标识符进行匹配。

6.5 临时文件自动清理

为了绕过沙盒限制,发送附件时会临时复制文件到 ~/Pictures。TempFileManager 负责自动清理这些文件:

工作流程:

  1. 文件命名规则:所有临时文件以 imsg_temp_ 为前缀
  2. 启动时清理:SDK 初始化时清理遗留的旧文件
  3. 定期清理:每 5 分钟扫描一次,删除超过 10 分钟的文件
  4. 销毁时清理:SDK 关闭时清理所有临时文件
// TempFileManager 配置
const DEFAULT_CONFIG = {
    maxAge: 10 * 60 * 1000,           // 文件保留 10 分钟
    cleanupInterval: 5 * 60 * 1000,   // 每 5 分钟清理一次
}

这个机制确保了即使程序异常退出,临时文件也会在下次启动时被清理。

6.6 消息去重机制

Watcher 使用 Map<string, number> 记录已处理的消息 ID,防止重复处理:

private seenMessageIds = new Map<string, number>()

// 每次检查时
const newMessages = messages.filter(msg => !this.seenMessageIds.has(msg.id))

// 标记为已处理
for (const msg of newMessages) {
    this.seenMessageIds.set(msg.id, Date.now())
}

// 定期清理(保留最近 1 小时的记录)
if (this.seenMessageIds.size > 10000) {
    const hourAgo = Date.now() - 3600000
    for (const [id, timestamp] of this.seenMessageIds.entries()) {
        if (timestamp < hourAgo) {
            this.seenMessageIds.delete(id)
        }
    }
}

关键点:

  • 使用 Map 而非 Set,存储时间戳用于清理
  • 阈值触发清理(超过 10000 条记录时)
  • 只保留最近 1 小时的记录,防止内存泄漏
  • 轮询时设置重叠时间(1秒)防止边界丢失

七、踩过的坑与解决方案

7.1 发送消息后立即获取的问题

**问题:**调用 send() 后没法马上拿到发送的消息对象,返回值是 undefined。

**原因:**AppleScript 发送消息是异步的,消息写入数据库得花点时间。

解决办法:

// 启动 watcher
await sdk.startWatching()

// 发送消息(watcher 会捕获并返回)
const result = await sdk.send('+1234567890', 'Hello!')
if (result.message) {
    console.log('消息 ID:', result.message.id)
}

imessage-kit 做了一个 OutgoingMessageManager,通过时间戳和内容匹配来关联发送的消息。

7.2 附件路径的 ~ 符号

数据库里存的附件路径可能带 ~:

~/Library/Messages/Attachments/abc/def/IMG_1234.heic

解决办法:

import { homedir } from 'os'

const fullPath = rawPath.startsWith('~') 
    ? rawPath.replace(/^~/, homedir())
    : rawPath

7.3 HEIC 图片格式转换

iPhone 拍的照片是 HEIC 格式,有时候得转成 JPEG。

工具推荐:

brew install imagemagick

# 批量转换
for f in *.heic; do
    magick "$f" "${f%.heic}.jpg"
done

或者用 Node.js 的 sharp 库(不过得编译)。

7.4 attributedBody 解析的三个大坑

前面提到 attributedBody 是用 NSArchiver/typedstream 格式序列化的二进制数据。初版实现用了一个“粗暴正则”来提取文本:

const bufferStr = buffer.toString('utf8')
const matches = bufferStr.match(/[\x20-\x7E\u4e00-\u9fff]{5,}/g)

看起来挺简单,但实际跑起来问题一大堆。

坑 1:智能引号把文本切断

原始正则只匹配 \x20-\x7E(可打印 ASCII)和 \u4e00-\u9fff(中文),没有包含智能引号(\u2018-\u201F)。

为什么会有智能引号? 这是 macOS/iOS 的“智能标点”功能在作怪。系统设置里有个选项叫“使用智能引号和破折号”(Use smart quotes and dashes),默认是开启的。当你在任何原生输入框里打 ' 或 ",系统会自动替换成弯曲的排版引号:

  • ' → '(左单引号 \u2018)或 '(右单引号 \u2019)
  • " → "(左双引号 \u201C)或 "(右双引号 \u201D)

所以用户在 iMessage 里打 It's a test,存到数据库里其实是 It's a test。这里的 ' 是 Unicode \u2019,不是 ASCII 的 '(\x27)。

正则会把它切成 It 和 s a test 两段,长度都不够 5,最后整条消息被当成不存在。

修复: 扩大字符集,加入智能引号和其他常见 Unicode 符号:

/[\x20-\x7E\u2018-\u201F\u2026\u2014\u2013\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]+/g

坑 2:莫名其妙的 +3 / +* 前缀

有些消息会变成这样:

"+3hello"
"+*test"
"++3Hello world"

这些前缀是 typedstream 序列化格式里用来标记“长度/类型”的一部分,恰好也是可见字符,被误当成了正文。

修复思路: 架构级调整——优先用 plutil 正常解析,只在失败时才回退到 buffer 启发式提取。回退路径里用正则后处理掉已知的 plist 标记前缀:

return bestCandidate
  .replace(/^\+\+./, '')              // 去掉 ++3 这类前缀
  .replace(/^\+[\*@#!]/, '')          // 去掉 +* +@ +# +! 前缀
  .replace(/^\+(\d)(?=[^\d\s])/, '')  // 只在 "+3hello" 这种情况下去掉 +3

特别注意:这个正则不会误删 +1234567890(电话号码)或 +1 idea(+数字 后有空格)。

最终架构:plutil 优先 + 正则回退

综合上述问题,最终设计为两层:

  1. 第 1 层:plutil → XML → <string>

    • 把 Buffer 写到临时 .plist 文件
    • 调用 plutil -convert xml1 -o - temp.plist
    • 从 XML 中提取 <string> 标签内容,过滤掉 NSAttributedString、streamtyped 等内部字段
    • 选最长的一条作为正文
  2. 第 2 层(回退):buffer + 正则 + 打分

    • 仅当 plutil 超时、出错、或没找到靠谱内容时才进入
    • 用扩展字符集正则抓可读片段
    • 给每个候选打分:中文字符 × 10 + 总长度 + 英文加分 - 符号扣分 - NS* 前缀扣分
    • 取得分最高的候选,做少量修剪后返回

这套方案在“工程可行性”和“解析精度”之间取得了不错的平衡。

八、性能优化

8.1 数据库查询优化

慢查询例子:

-- 糟糕:全表扫描
SELECT * FROM message WHERE text LIKE '%关键词%'

优化后:

-- 用索引 + 限制时间范围
SELECT * FROM message 
WHERE date >= ? AND text LIKE '%关键词%'
ORDER BY date DESC 
LIMIT 100

8.2 并发发送控制

同时发多条消息时,用信号量(Semaphore)限制并发:

class Semaphore {
    private running = 0
    private waiting: Array<() => void> = []

    constructor(private readonly limit: number) {}

    async acquire(): Promise<() => void> {
        while (this.running >= this.limit) {
            await new Promise<void>(resolve => this.waiting.push(resolve))
        }
        
        this.running++
        
        // 返回 release 函数
        return () => {
            this.running--
            const next = this.waiting.shift()
            if (next) next()
        }
    }

    async run<T>(fn: () => Promise<T>): Promise<T> {
        const release = await this.acquire()
        try {
            return await fn()
        } finally {
            release()
        }
    }
}

// 使用方式
const sem = new Semaphore(5) // 最多 5 个并发
await sem.run(() => sendMessage(...))

imessage-kit 默认并发限制是 5,防止一次发太多消息把 Messages.app 搞挂。

8.3 长时运行的内存管理

对于需要长时间运行的监听服务,imessage-kit 在 watcher 里用 Map 记录处理过的消息 ID。项目中实现了自动清理机制:

// 当 Map 大小超过阈值时触发清理
if (this.seenMessageIds.size > 10000) {
    const hourAgo = Date.now() - 3600000  // 1 小时前
    for (const [id, timestamp] of this.seenMessageIds.entries()) {
        if (timestamp < hourAgo) {
            this.seenMessageIds.delete(id)
        }
    }
}

这个策略在消息量大时能有效控制内存占用,只保留最近 1 小时的消息记录。

九、未来展望与限制

9.1 已知限制

imessage-kit 是基于 AppleScript 和数据库读取实现的开源 SDK,虽然功能完善,但受限于 Apple 的公开 API,以下功能无法实现:

基础版本的限制:

  1. 消息编辑 - 无法编辑已发送的消息
  2. 消息撤回 - 无法在 2 分钟内撤回消息
  3. Tapback 反应 - 只能读取反应,无法发送(爱心、点赞、哈哈等)
  4. 打字指示器 - 无法发送/接收实时打字状态
  5. 消息特效 - 无法发送带特效的消息(烟花、五彩纸屑、气球等)
  6. 已读回执 - 无法标记消息为已读/未读
  7. 贴纸发送 - 无法发送 iMessage 贴纸
  8. 语音消息 - 无法发送带波形显示的语音消息
  9. FaceTime 集成 - 无法创建 FaceTime 链接或监听通话状态

对于需要上述高级功能的开发者,我们提供了企业级解决方案:


选择建议:

  • 使用开源版 imessage-kit:如果你只需要读取消息、发送简单文本/附件、监控新消息
  • 使用 Advanced 版本:如果你需要完整的 iMessage 功能、企业级稳定性、或构建复杂的聊天应用

结语

iMessage 自动化是个充满挑战但又特别有意思的技术领域。从底层数据库结构到 AppleScript 脚本编写,从系统权限控制到性能优化,每一个环节都需要深入了解 macOS 的运行机制。

本文所介绍的技术实现均基于开源项目,该项目提供了完整的 TypeScript SDK,将复杂的底层操作封装成简单易用的 API:


# 技术教程# macOS# 自动化# iMessage# SQLite# AppleScript

Read Next