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 关键字(比如 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
解决办法:
- 打开 系统设置 → 隐私与安全性 → 完全磁盘访问权限
- 点击 ”+” 号,添加你常用的终端或 IDE(比如 Terminal、iTerm、VS Code、Cursor)
- 重启应用(这一步很重要,很多人忘了重启,结果权限没生效)
建议开发工具和终端都添加权限,确保在不同环境中都能正常运行。
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 文件的变化,延迟会很明显。更好的做法是:
- 用定时轮询(polling)数据库,而不是监听文件变化
- 以只读模式打开数据库,让 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)来检测新消息?
原因有几点:
- WAL 模式导致
chat.db更新有延迟 - 文件监听会触发太多误报(数据库内部操作也会改文件)
- 轮询结合时间戳查询更靠谱
最佳轮询间隔:
// 太快:浪费 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)
}
工作原理:
- 发送前创建
MessagePromise,记录发送时间、内容、chatId - AppleScript 执行发送
- Watcher 轮询检测到新消息后,通过时间戳和内容匹配
- 匹配成功后 resolve Promise,返回完整的 Message 对象
匹配逻辑考虑了 chatId 的多种格式(iMessage;-;recipient vs recipient),自动提取核心标识符进行匹配。
6.5 临时文件自动清理
为了绕过沙盒限制,发送附件时会临时复制文件到 ~/Pictures。TempFileManager 负责自动清理这些文件:
工作流程:
- 文件命名规则:所有临时文件以
imsg_temp_为前缀 - 启动时清理:SDK 初始化时清理遗留的旧文件
- 定期清理:每 5 分钟扫描一次,删除超过 10 分钟的文件
- 销毁时清理: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 层:plutil → XML →
<string>- 把 Buffer 写到临时
.plist文件 - 调用
plutil -convert xml1 -o - temp.plist - 从 XML 中提取
<string>标签内容,过滤掉NSAttributedString、streamtyped等内部字段 - 选最长的一条作为正文
- 把 Buffer 写到临时
-
第 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,以下功能无法实现:
基础版本的限制:
- 消息编辑 - 无法编辑已发送的消息
- 消息撤回 - 无法在 2 分钟内撤回消息
- Tapback 反应 - 只能读取反应,无法发送(爱心、点赞、哈哈等)
- 打字指示器 - 无法发送/接收实时打字状态
- 消息特效 - 无法发送带特效的消息(烟花、五彩纸屑、气球等)
- 已读回执 - 无法标记消息为已读/未读
- 贴纸发送 - 无法发送 iMessage 贴纸
- 语音消息 - 无法发送带波形显示的语音消息
- 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