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

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

前言

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

一、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 关键字(比如 NSAttributedStringNSDictionary 之类)。

策略 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 临时文件自动清理

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

工作流程:

  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)。

正则会把它切成 Its 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> 标签内容,过滤掉 NSAttributedStringstreamtyped 等内部字段
    • 选最长的一条作为正文
  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 链接或监听通话状态

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

advanced-imessage-kit

The Typescript SDK for Next Level iMessage Automation


选择建议:

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

结语

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

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

imessage-kit

A type-safe, elegant iMessage SDK for macOS with zero dependencies


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