feat: swift 6 rewrite
This commit is contained in:
parent
cc616a5ae3
commit
e3581d2b20
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
@ -10,20 +10,19 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
# Keep CI aligned with the Go toolchain defined in go.mod
|
||||
go-version-file: go.mod
|
||||
- name: Go fmt
|
||||
run: |
|
||||
diff=$(gofmt -l ./)
|
||||
if [ -n "$diff" ]; then
|
||||
echo "gofmt found issues:" && echo "$diff" && exit 1
|
||||
fi
|
||||
- name: Go test
|
||||
run: go test ./...
|
||||
- name: Install golangci-lint
|
||||
run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
|
||||
- name: golangci-lint
|
||||
run: golangci-lint run --out-format=colored-line-number
|
||||
- name: Swift version
|
||||
run: swift --version
|
||||
- name: Install SwiftLint
|
||||
run: brew install swiftlint
|
||||
- name: Resolve packages
|
||||
run: swift package resolve
|
||||
- name: Patch dependencies
|
||||
run: scripts/patch-deps.sh
|
||||
- name: Swift format lint
|
||||
run: swift format lint --recursive Sources Tests
|
||||
- name: SwiftLint
|
||||
run: swiftlint
|
||||
- name: Swift test
|
||||
run: swift test
|
||||
- name: Swift build
|
||||
run: swift build -c release --product imsg
|
||||
|
||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
@ -15,8 +15,8 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
release:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@ -33,25 +33,29 @@ jobs:
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Stash GoReleaser config
|
||||
run: cp .goreleaser.yaml /tmp/.goreleaser.yaml
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: git checkout ${{ inputs.tag }}
|
||||
|
||||
- name: GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
- name: Resolve packages
|
||||
run: swift package resolve
|
||||
|
||||
- name: Patch dependencies
|
||||
run: scripts/patch-deps.sh
|
||||
|
||||
- name: Build
|
||||
run: swift build -c release --product imsg
|
||||
|
||||
- name: Package artifact
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp .build/release/imsg dist/imsg
|
||||
(cd dist && zip -r imsg-macos.zip imsg)
|
||||
|
||||
- name: Publish release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --clean --config /tmp/.goreleaser.yaml
|
||||
files: dist/imsg-macos.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -30,3 +30,12 @@ go.work.sum
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
|
||||
# SwiftPM
|
||||
.build/
|
||||
.swiftpm/
|
||||
Package.resolved
|
||||
*.xcodeproj
|
||||
|
||||
# Build artifacts
|
||||
bin/
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
linters:
|
||||
enable:
|
||||
- govet
|
||||
- staticcheck
|
||||
- revive
|
||||
- gofmt
|
||||
- goimports
|
||||
- gocritic
|
||||
- errcheck
|
||||
- ineffassign
|
||||
- gosimple
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
@ -1,29 +0,0 @@
|
||||
version: 2
|
||||
|
||||
project_name: imsg
|
||||
|
||||
changelog:
|
||||
disable: true
|
||||
|
||||
builds:
|
||||
- id: imsg
|
||||
main: ./cmd/imsg
|
||||
binary: imsg
|
||||
ldflags:
|
||||
- -s -w -X main.version={{ .Version }}
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
archives:
|
||||
- builds:
|
||||
- imsg
|
||||
format: tar.gz
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
20
.swiftlint.yml
Normal file
20
.swiftlint.yml
Normal file
@ -0,0 +1,20 @@
|
||||
only_rules:
|
||||
- trailing_whitespace
|
||||
- trailing_newline
|
||||
- line_length
|
||||
- file_length
|
||||
- type_body_length
|
||||
|
||||
line_length: 140
|
||||
file_length:
|
||||
warning: 500
|
||||
error: 600
|
||||
type_body_length:
|
||||
warning: 300
|
||||
error: 400
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
- .swiftpm
|
||||
- Packages
|
||||
- .git
|
||||
@ -2,6 +2,15 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.2.0 - 2025-12-28
|
||||
- feat: Swift 6 rewrite with reusable IMsgCore library target
|
||||
- feat: Commander-based CLI with SwiftPM build/test workflow
|
||||
- feat: event-driven watch using filesystem events (no polling)
|
||||
- feat: SQLite.swift + PhoneNumberKit + NSAppleScript integration
|
||||
- chore: SwiftLint + swift-format linting
|
||||
- change: JSON attachment keys now snake_case
|
||||
- deprecation note: `--interval` replaced by `--debounce` (no compatibility)
|
||||
|
||||
## 0.1.1 - 2025-12-27
|
||||
- feat: `imsg chats --json`
|
||||
- fix: drop sqlite `immutable` flag so new messages/replies show up (thanks @zleman1593)
|
||||
|
||||
41
Package.swift
Normal file
41
Package.swift
Normal file
@ -0,0 +1,41 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "imsg",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "IMsgCore", targets: ["IMsgCore"]),
|
||||
.executable(name: "imsg", targets: ["imsg"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
|
||||
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.4"),
|
||||
.package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.2"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "IMsgCore",
|
||||
dependencies: [
|
||||
.product(name: "SQLite", package: "SQLite.swift"),
|
||||
.product(name: "PhoneNumberKit", package: "PhoneNumberKit"),
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedFramework("ScriptingBridge"),
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "imsg",
|
||||
dependencies: [
|
||||
"IMsgCore",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "IMsgCoreTests",
|
||||
dependencies: [
|
||||
"IMsgCore",
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
41
README.md
41
README.md
@ -1,30 +1,32 @@
|
||||
# 💬 imsg — Send, read, stream iMessage & SMS
|
||||
|
||||
A macOS Messages.app CLI to send, read, and stream iMessage/SMS (with attachment metadata). Read-only for receives; send uses AppleScript to drive Messages.app.
|
||||
A macOS Messages.app CLI to send, read, and stream iMessage/SMS (with attachment metadata). Read-only for receives; send uses AppleScript (no private APIs).
|
||||
|
||||
## Features
|
||||
- List chats, view history, or tail new messages (`watch`).
|
||||
- List chats, view history, or stream new messages (`watch`).
|
||||
- Send text and attachments via iMessage or SMS (AppleScript, no private APIs).
|
||||
- Phone normalization to E.164 for reliable buddy lookup (`--region`, default US).
|
||||
- Optional attachment metadata output (mime, name, path, missing flag).
|
||||
- Filters: participants, start/end time, JSON output for tooling.
|
||||
- Read-only DB access (`mode=ro`), no DB writes.
|
||||
- Event-driven watch via filesystem events.
|
||||
|
||||
## Requirements
|
||||
- macOS with Messages.app signed in.
|
||||
- macOS 14+ with Messages.app signed in.
|
||||
- Full Disk Access for your terminal to read `~/Library/Messages/chat.db`.
|
||||
- Automation permission for your terminal to control Messages.app (for sending).
|
||||
- For SMS relay, enable “Text Message Forwarding” on your iPhone to this Mac.
|
||||
|
||||
## Install
|
||||
```bash
|
||||
go install github.com/steipete/imsg/cmd/imsg@latest
|
||||
pnpm build
|
||||
# binary at ./bin/imsg
|
||||
```
|
||||
|
||||
## Commands
|
||||
- `imsg chats [--limit 20] [--json]` — list recent conversations.
|
||||
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]`
|
||||
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--interval 2s] [--attachments] [--participants …] [--start …] [--end …] [--json]`
|
||||
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--debounce 250ms] [--attachments] [--participants …] [--start …] [--end …] [--json]`
|
||||
- `imsg send --to <handle> [--text "hi"] [--file /path/img.jpg] [--service imessage|sms|auto] [--region US]`
|
||||
|
||||
### Quick samples
|
||||
@ -42,27 +44,18 @@ imsg history --chat-id 1 --limit 10 --attachments
|
||||
imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json
|
||||
|
||||
# live stream a chat
|
||||
imsg watch --chat-id 1 --attachments --interval 2s
|
||||
imsg watch --chat-id 1 --attachments --debounce 250ms
|
||||
|
||||
# send a picture
|
||||
imsg send --to "+14155551212" --text "hi" --file ~/Desktop/pic.jpg --service imessage
|
||||
```
|
||||
|
||||
## Examples
|
||||
```bash
|
||||
imsg chats --limit 5
|
||||
imsg chats --limit 5 --json
|
||||
imsg history --chat-id 1 --attachments --start 2025-01-01T00:00:00Z --json
|
||||
imsg watch --chat-id 1 --attachments --participants +15551234567
|
||||
imsg send --to "+14155551212" --text "ping" --file ~/Desktop/pic.png --service imessage
|
||||
```
|
||||
|
||||
## Attachment notes
|
||||
`--attachments` prints per-attachment lines with name, MIME, missing flag, and resolved path (tilde expanded). Only metadata is shown; files aren’t copied.
|
||||
|
||||
## JSON output
|
||||
`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`.
|
||||
`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `sender`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata).
|
||||
`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `sender`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata with `filename`, `transfer_name`, `uti`, `mime_type`, `total_bytes`, `is_sticker`, `original_path`, `missing`).
|
||||
|
||||
## Permissions troubleshooting
|
||||
If you see “unable to open database file” or empty output:
|
||||
@ -72,10 +65,16 @@ If you see “unable to open database file” or empty output:
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
go test ./...
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Limitations
|
||||
- Requires a logged-in macOS user session (osascript needs UI access).
|
||||
- No attachment export yet (metadata only).
|
||||
- Polling-based watch (default 2s) — not event driven.
|
||||
Note: pnpm scripts apply a small patch to SQLite.swift to silence a SwiftPM warning about `PrivacyInfo.xcprivacy`.
|
||||
|
||||
## Linting & formatting
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm format
|
||||
```
|
||||
|
||||
## Core library
|
||||
The reusable Swift core lives in `Sources/IMsgCore` and is consumed by the CLI target. Apps can depend on the `IMsgCore` library target directly.
|
||||
|
||||
17
Sources/IMsgCore/AttachmentResolver.swift
Normal file
17
Sources/IMsgCore/AttachmentResolver.swift
Normal file
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
enum AttachmentResolver {
|
||||
static func resolve(_ path: String) -> (resolved: String, missing: Bool) {
|
||||
guard !path.isEmpty else { return ("", true) }
|
||||
let expanded = (path as NSString).expandingTildeInPath
|
||||
var isDir: ObjCBool = false
|
||||
let exists = FileManager.default.fileExists(atPath: expanded, isDirectory: &isDir)
|
||||
return (expanded, !(exists && !isDir.boolValue))
|
||||
}
|
||||
|
||||
static func displayName(filename: String, transferName: String) -> String {
|
||||
if !transferName.isEmpty { return transferName }
|
||||
if !filename.isEmpty { return filename }
|
||||
return "(unknown)"
|
||||
}
|
||||
}
|
||||
36
Sources/IMsgCore/Errors.swift
Normal file
36
Sources/IMsgCore/Errors.swift
Normal file
@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
public enum IMsgError: LocalizedError, Sendable {
|
||||
case permissionDenied(path: String, underlying: Error)
|
||||
case invalidISODate(String)
|
||||
case invalidService(String)
|
||||
case appleScriptFailure(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied(let path, let underlying):
|
||||
return """
|
||||
\(underlying)
|
||||
|
||||
⚠️ Permission Error: Cannot access Messages database
|
||||
|
||||
The Messages database at \(path) requires Full Disk Access permission.
|
||||
|
||||
To fix:
|
||||
1. Open System Settings → Privacy & Security → Full Disk Access
|
||||
2. Add your terminal application (Terminal.app, iTerm, etc.)
|
||||
3. Restart your terminal
|
||||
4. Try again
|
||||
|
||||
Note: This is required because macOS protects the Messages database.
|
||||
For more details, see: https://github.com/steipete/imsg#permissions-troubleshooting
|
||||
"""
|
||||
case .invalidISODate(let value):
|
||||
return "Invalid ISO8601 date: \(value)"
|
||||
case .invalidService(let value):
|
||||
return "Invalid service: \(value)"
|
||||
case .appleScriptFailure(let message):
|
||||
return "AppleScript failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
Sources/IMsgCore/ISO8601.swift
Normal file
21
Sources/IMsgCore/ISO8601.swift
Normal file
@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
enum ISO8601Parser {
|
||||
static func parse(_ value: String) -> Date? {
|
||||
if value.isEmpty { return nil }
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: value) {
|
||||
return date
|
||||
}
|
||||
let standard = ISO8601DateFormatter()
|
||||
standard.formatOptions = [.withInternetDateTime]
|
||||
return standard.date(from: value)
|
||||
}
|
||||
|
||||
static func format(_ date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
43
Sources/IMsgCore/MessageFilter.swift
Normal file
43
Sources/IMsgCore/MessageFilter.swift
Normal file
@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
public struct MessageFilter: Sendable, Equatable {
|
||||
public let participants: [String]
|
||||
public let startDate: Date?
|
||||
public let endDate: Date?
|
||||
|
||||
public init(participants: [String] = [], startDate: Date? = nil, endDate: Date? = nil) {
|
||||
self.participants = participants
|
||||
self.startDate = startDate
|
||||
self.endDate = endDate
|
||||
}
|
||||
|
||||
public static func fromISO(participants: [String], startISO: String?, endISO: String?) throws
|
||||
-> MessageFilter
|
||||
{
|
||||
let start = startISO.flatMap { ISO8601Parser.parse($0) }
|
||||
if let startISO, start == nil {
|
||||
throw IMsgError.invalidISODate(startISO)
|
||||
}
|
||||
let end = endISO.flatMap { ISO8601Parser.parse($0) }
|
||||
if let endISO, end == nil {
|
||||
throw IMsgError.invalidISODate(endISO)
|
||||
}
|
||||
return MessageFilter(participants: participants, startDate: start, endDate: end)
|
||||
}
|
||||
|
||||
public func allows(_ message: Message) -> Bool {
|
||||
if let startDate, message.date < startDate { return false }
|
||||
if let endDate, message.date >= endDate { return false }
|
||||
if !participants.isEmpty {
|
||||
var match = false
|
||||
for participant in participants {
|
||||
if participant.caseInsensitiveCompare(message.sender) == .orderedSame {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
111
Sources/IMsgCore/MessageSender.swift
Normal file
111
Sources/IMsgCore/MessageSender.swift
Normal file
@ -0,0 +1,111 @@
|
||||
import Carbon
|
||||
import Foundation
|
||||
import ScriptingBridge
|
||||
|
||||
public enum MessageService: String, Sendable, CaseIterable {
|
||||
case auto
|
||||
case imessage
|
||||
case sms
|
||||
}
|
||||
|
||||
public struct MessageSendOptions: Sendable {
|
||||
public var recipient: String
|
||||
public var text: String
|
||||
public var attachmentPath: String
|
||||
public var service: MessageService
|
||||
public var region: String
|
||||
|
||||
public init(
|
||||
recipient: String,
|
||||
text: String = "",
|
||||
attachmentPath: String = "",
|
||||
service: MessageService = .auto,
|
||||
region: String = "US"
|
||||
) {
|
||||
self.recipient = recipient
|
||||
self.text = text
|
||||
self.attachmentPath = attachmentPath
|
||||
self.service = service
|
||||
self.region = region
|
||||
}
|
||||
}
|
||||
|
||||
public struct MessageSender {
|
||||
private let normalizer = PhoneNumberNormalizer()
|
||||
|
||||
public init() {}
|
||||
|
||||
public func send(_ options: MessageSendOptions) throws {
|
||||
var resolved = options
|
||||
if resolved.region.isEmpty { resolved.region = "US" }
|
||||
resolved.recipient = normalizer.normalize(resolved.recipient, region: resolved.region)
|
||||
if resolved.service == .auto { resolved.service = .imessage }
|
||||
|
||||
let script = appleScript()
|
||||
let arguments = [
|
||||
resolved.recipient,
|
||||
resolved.text,
|
||||
resolved.service.rawValue,
|
||||
resolved.attachmentPath,
|
||||
resolved.attachmentPath.isEmpty ? "0" : "1",
|
||||
]
|
||||
|
||||
try runAppleScript(source: script, arguments: arguments)
|
||||
}
|
||||
|
||||
private func appleScript() -> String {
|
||||
return """
|
||||
on run argv
|
||||
set theRecipient to item 1 of argv
|
||||
set theMessage to item 2 of argv
|
||||
set theService to item 3 of argv
|
||||
set theFilePath to item 4 of argv
|
||||
set useAttachment to item 5 of argv
|
||||
|
||||
tell application "Messages"
|
||||
if theService is "sms" then
|
||||
set targetService to first service whose service type is SMS
|
||||
else
|
||||
set targetService to first service whose service type is iMessage
|
||||
end if
|
||||
|
||||
set targetBuddy to buddy theRecipient of targetService
|
||||
if theMessage is not "" then
|
||||
send theMessage to targetBuddy
|
||||
end if
|
||||
if useAttachment is "1" then
|
||||
set theFile to POSIX file theFilePath as alias
|
||||
send theFile to targetBuddy
|
||||
end if
|
||||
end tell
|
||||
end run
|
||||
"""
|
||||
}
|
||||
|
||||
private func runAppleScript(source: String, arguments: [String]) throws {
|
||||
guard let script = NSAppleScript(source: source) else {
|
||||
throw IMsgError.appleScriptFailure("Unable to compile AppleScript")
|
||||
}
|
||||
var errorInfo: NSDictionary?
|
||||
let event = NSAppleEventDescriptor(
|
||||
eventClass: AEEventClass(kASAppleScriptSuite),
|
||||
eventID: AEEventID(kASSubroutineEvent),
|
||||
targetDescriptor: nil,
|
||||
returnID: AEReturnID(kAutoGenerateReturnID),
|
||||
transactionID: AETransactionID(kAnyTransactionID)
|
||||
)
|
||||
event.setParam(
|
||||
NSAppleEventDescriptor(string: "run"), forKeyword: AEKeyword(keyASSubroutineName))
|
||||
let list = NSAppleEventDescriptor.list()
|
||||
for (index, value) in arguments.enumerated() {
|
||||
list.insert(NSAppleEventDescriptor(string: value), at: index + 1)
|
||||
}
|
||||
event.setParam(list, forKeyword: keyDirectObject)
|
||||
script.executeAppleEvent(event, error: &errorInfo)
|
||||
if let errorInfo {
|
||||
let message =
|
||||
(errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown AppleScript error"
|
||||
throw IMsgError.appleScriptFailure(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
279
Sources/IMsgCore/MessageStore.swift
Normal file
279
Sources/IMsgCore/MessageStore.swift
Normal file
@ -0,0 +1,279 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
public final class MessageStore: @unchecked Sendable {
|
||||
public static let appleEpochOffset: TimeInterval = 978_307_200
|
||||
|
||||
public static var defaultPath: String {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
return NSString(string: home).appendingPathComponent("Library/Messages/chat.db")
|
||||
}
|
||||
|
||||
public let path: String
|
||||
|
||||
private let connection: Connection
|
||||
private let queue: DispatchQueue
|
||||
private let queueKey = DispatchSpecificKey<Void>()
|
||||
private let hasAttributedBody: Bool
|
||||
|
||||
public init(path: String = MessageStore.defaultPath) throws {
|
||||
let normalized = NSString(string: path).expandingTildeInPath
|
||||
self.path = normalized
|
||||
self.queue = DispatchQueue(label: "imsg.db", qos: .userInitiated)
|
||||
self.queue.setSpecific(key: queueKey, value: ())
|
||||
do {
|
||||
let uri = URL(fileURLWithPath: normalized).absoluteString
|
||||
let location = Connection.Location.uri(uri, parameters: [.mode(.readOnly)])
|
||||
self.connection = try Connection(location, readonly: true)
|
||||
self.connection.busyTimeout = 5
|
||||
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: self.connection)
|
||||
} catch {
|
||||
throw MessageStore.enhance(error: error, path: normalized)
|
||||
}
|
||||
}
|
||||
|
||||
init(connection: Connection, path: String, hasAttributedBody: Bool? = nil) throws {
|
||||
self.path = path
|
||||
self.queue = DispatchQueue(label: "imsg.db.test", qos: .userInitiated)
|
||||
self.queue.setSpecific(key: queueKey, value: ())
|
||||
self.connection = connection
|
||||
self.connection.busyTimeout = 5
|
||||
if let hasAttributedBody {
|
||||
self.hasAttributedBody = hasAttributedBody
|
||||
} else {
|
||||
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: connection)
|
||||
}
|
||||
}
|
||||
|
||||
public func listChats(limit: Int) throws -> [Chat] {
|
||||
let sql = """
|
||||
SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, c.service_name,
|
||||
MAX(m.date) AS last_date
|
||||
FROM chat c
|
||||
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
|
||||
JOIN message m ON m.ROWID = cmj.message_id
|
||||
GROUP BY c.ROWID
|
||||
ORDER BY last_date DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
return try withConnection { db in
|
||||
var chats: [Chat] = []
|
||||
for row in try db.prepare(sql, limit) {
|
||||
let id = int64Value(row[0]) ?? 0
|
||||
let name = stringValue(row[1])
|
||||
let identifier = stringValue(row[2])
|
||||
let service = stringValue(row[3])
|
||||
let lastDate = appleDate(from: int64Value(row[4]))
|
||||
chats.append(
|
||||
Chat(
|
||||
id: id, identifier: identifier, name: name, service: service, lastMessageAt: lastDate))
|
||||
}
|
||||
return chats
|
||||
}
|
||||
}
|
||||
|
||||
public func messages(chatID: Int64, limit: Int) throws -> [Message] {
|
||||
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
|
||||
let sql = """
|
||||
SELECT m.ROWID, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
|
||||
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
|
||||
\(bodyColumn) AS body
|
||||
FROM message m
|
||||
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
WHERE cmj.chat_id = ?
|
||||
ORDER BY m.date DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
return try withConnection { db in
|
||||
var messages: [Message] = []
|
||||
for row in try db.prepare(sql, chatID, limit) {
|
||||
let rowID = int64Value(row[0]) ?? 0
|
||||
let handleID = int64Value(row[1])
|
||||
let sender = stringValue(row[2])
|
||||
let text = stringValue(row[3])
|
||||
let date = appleDate(from: int64Value(row[4]))
|
||||
let isFromMe = boolValue(row[5])
|
||||
let service = stringValue(row[6])
|
||||
let attachments = intValue(row[7]) ?? 0
|
||||
let body = dataValue(row[8])
|
||||
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
|
||||
messages.append(
|
||||
Message(
|
||||
rowID: rowID,
|
||||
chatID: chatID,
|
||||
sender: sender,
|
||||
text: resolvedText,
|
||||
date: date,
|
||||
isFromMe: isFromMe,
|
||||
service: service,
|
||||
handleID: handleID,
|
||||
attachmentsCount: attachments
|
||||
))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
public func messagesAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [Message] {
|
||||
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
|
||||
var sql = """
|
||||
SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
|
||||
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
|
||||
\(bodyColumn) AS body
|
||||
FROM message m
|
||||
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
WHERE m.ROWID > ?
|
||||
"""
|
||||
var bindings: [Binding?] = [afterRowID]
|
||||
if let chatID {
|
||||
sql += " AND cmj.chat_id = ?"
|
||||
bindings.append(chatID)
|
||||
}
|
||||
sql += " ORDER BY m.ROWID ASC LIMIT ?"
|
||||
bindings.append(limit)
|
||||
|
||||
return try withConnection { db in
|
||||
var messages: [Message] = []
|
||||
for row in try db.prepare(sql, bindings) {
|
||||
let rowID = int64Value(row[0]) ?? 0
|
||||
let resolvedChatID = int64Value(row[1]) ?? chatID ?? 0
|
||||
let handleID = int64Value(row[2])
|
||||
let sender = stringValue(row[3])
|
||||
let text = stringValue(row[4])
|
||||
let date = appleDate(from: int64Value(row[5]))
|
||||
let isFromMe = boolValue(row[6])
|
||||
let service = stringValue(row[7])
|
||||
let attachments = intValue(row[8]) ?? 0
|
||||
let body = dataValue(row[9])
|
||||
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
|
||||
messages.append(
|
||||
Message(
|
||||
rowID: rowID,
|
||||
chatID: resolvedChatID,
|
||||
sender: sender,
|
||||
text: resolvedText,
|
||||
date: date,
|
||||
isFromMe: isFromMe,
|
||||
service: service,
|
||||
handleID: handleID,
|
||||
attachmentsCount: attachments
|
||||
))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
public func attachments(for messageID: Int64) throws -> [AttachmentMeta] {
|
||||
let sql = """
|
||||
SELECT a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker
|
||||
FROM message_attachment_join maj
|
||||
JOIN attachment a ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?
|
||||
"""
|
||||
return try withConnection { db in
|
||||
var metas: [AttachmentMeta] = []
|
||||
for row in try db.prepare(sql, messageID) {
|
||||
let filename = stringValue(row[0])
|
||||
let transferName = stringValue(row[1])
|
||||
let uti = stringValue(row[2])
|
||||
let mimeType = stringValue(row[3])
|
||||
let totalBytes = int64Value(row[4]) ?? 0
|
||||
let isSticker = boolValue(row[5])
|
||||
let resolved = AttachmentResolver.resolve(filename)
|
||||
metas.append(
|
||||
AttachmentMeta(
|
||||
filename: filename,
|
||||
transferName: transferName,
|
||||
uti: uti,
|
||||
mimeType: mimeType,
|
||||
totalBytes: totalBytes,
|
||||
isSticker: isSticker,
|
||||
originalPath: resolved.resolved,
|
||||
missing: resolved.missing
|
||||
))
|
||||
}
|
||||
return metas
|
||||
}
|
||||
}
|
||||
|
||||
public func maxRowID() throws -> Int64 {
|
||||
return try withConnection { db in
|
||||
let value = try db.scalar("SELECT MAX(ROWID) FROM message")
|
||||
return int64Value(value) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private func withConnection<T>(_ block: (Connection) throws -> T) throws -> T {
|
||||
if DispatchQueue.getSpecific(key: queueKey) != nil {
|
||||
return try block(connection)
|
||||
}
|
||||
return try queue.sync {
|
||||
try block(connection)
|
||||
}
|
||||
}
|
||||
|
||||
private static func detectAttributedBody(connection: Connection) -> Bool {
|
||||
do {
|
||||
let rows = try connection.prepare("PRAGMA table_info(message)")
|
||||
for row in rows {
|
||||
if let name = row[1] as? String,
|
||||
name.caseInsensitiveCompare("attributedBody") == .orderedSame
|
||||
{
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func enhance(error: Error, path: String) -> Error {
|
||||
let message = String(describing: error).lowercased()
|
||||
if message.contains("out of memory (14)") || message.contains("authorization denied")
|
||||
|| message.contains("unable to open database") || message.contains("cannot open")
|
||||
{
|
||||
return IMsgError.permissionDenied(path: path, underlying: error)
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
private func appleDate(from value: Int64?) -> Date {
|
||||
guard let value else { return Date(timeIntervalSince1970: MessageStore.appleEpochOffset) }
|
||||
return Date(
|
||||
timeIntervalSince1970: (Double(value) / 1_000_000_000) + MessageStore.appleEpochOffset)
|
||||
}
|
||||
|
||||
private func stringValue(_ binding: Binding?) -> String {
|
||||
return binding as? String ?? ""
|
||||
}
|
||||
|
||||
private func int64Value(_ binding: Binding?) -> Int64? {
|
||||
if let value = binding as? Int64 { return value }
|
||||
if let value = binding as? Int { return Int64(value) }
|
||||
if let value = binding as? Double { return Int64(value) }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func intValue(_ binding: Binding?) -> Int? {
|
||||
if let value = binding as? Int { return value }
|
||||
if let value = binding as? Int64 { return Int(value) }
|
||||
if let value = binding as? Double { return Int(value) }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func boolValue(_ binding: Binding?) -> Bool {
|
||||
if let value = binding as? Bool { return value }
|
||||
if let value = intValue(binding) { return value != 0 }
|
||||
return false
|
||||
}
|
||||
|
||||
private func dataValue(_ binding: Binding?) -> Data {
|
||||
if let blob = binding as? Blob {
|
||||
return Data(blob.bytes)
|
||||
}
|
||||
return Data()
|
||||
}
|
||||
}
|
||||
143
Sources/IMsgCore/MessageWatcher.swift
Normal file
143
Sources/IMsgCore/MessageWatcher.swift
Normal file
@ -0,0 +1,143 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
public struct MessageWatcherConfiguration: Sendable, Equatable {
|
||||
public var debounceInterval: TimeInterval
|
||||
public var batchLimit: Int
|
||||
|
||||
public init(debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100) {
|
||||
self.debounceInterval = debounceInterval
|
||||
self.batchLimit = batchLimit
|
||||
}
|
||||
}
|
||||
|
||||
public final class MessageWatcher: @unchecked Sendable {
|
||||
private let store: MessageStore
|
||||
|
||||
public init(store: MessageStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
public func stream(
|
||||
chatID: Int64? = nil,
|
||||
sinceRowID: Int64? = nil,
|
||||
configuration: MessageWatcherConfiguration = MessageWatcherConfiguration()
|
||||
) -> AsyncThrowingStream<Message, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let state = WatchState(
|
||||
store: store,
|
||||
chatID: chatID,
|
||||
sinceRowID: sinceRowID,
|
||||
configuration: configuration,
|
||||
continuation: continuation
|
||||
)
|
||||
state.start()
|
||||
continuation.onTermination = { _ in
|
||||
state.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class WatchState: @unchecked Sendable {
|
||||
private let store: MessageStore
|
||||
private let chatID: Int64?
|
||||
private let configuration: MessageWatcherConfiguration
|
||||
private let continuation: AsyncThrowingStream<Message, Error>.Continuation
|
||||
private let queue = DispatchQueue(label: "imsg.watch", qos: .userInitiated)
|
||||
|
||||
private var cursor: Int64
|
||||
private var sources: [DispatchSourceFileSystemObject] = []
|
||||
private var pending = false
|
||||
|
||||
init(
|
||||
store: MessageStore,
|
||||
chatID: Int64?,
|
||||
sinceRowID: Int64?,
|
||||
configuration: MessageWatcherConfiguration,
|
||||
continuation: AsyncThrowingStream<Message, Error>.Continuation
|
||||
) {
|
||||
self.store = store
|
||||
self.chatID = chatID
|
||||
self.configuration = configuration
|
||||
self.continuation = continuation
|
||||
self.cursor = sinceRowID ?? 0
|
||||
}
|
||||
|
||||
func start() {
|
||||
queue.async {
|
||||
do {
|
||||
if self.cursor == 0 {
|
||||
self.cursor = try self.store.maxRowID()
|
||||
}
|
||||
self.poll()
|
||||
} catch {
|
||||
self.continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
let paths = [store.path, store.path + "-wal", store.path + "-shm"]
|
||||
for path in paths {
|
||||
if let source = makeSource(path: path) {
|
||||
sources.append(source)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.async {
|
||||
for source in self.sources {
|
||||
source.cancel()
|
||||
}
|
||||
self.sources.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSource(path: String) -> DispatchSourceFileSystemObject? {
|
||||
let fd = open(path, O_EVTONLY)
|
||||
guard fd >= 0 else { return nil }
|
||||
let source = DispatchSource.makeFileSystemObjectSource(
|
||||
fileDescriptor: fd,
|
||||
eventMask: [.write, .extend, .rename, .delete],
|
||||
queue: queue
|
||||
)
|
||||
source.setEventHandler { [weak self] in
|
||||
self?.schedulePoll()
|
||||
}
|
||||
source.setCancelHandler {
|
||||
close(fd)
|
||||
}
|
||||
source.resume()
|
||||
return source
|
||||
}
|
||||
|
||||
private func schedulePoll() {
|
||||
if pending { return }
|
||||
pending = true
|
||||
let delay = configuration.debounceInterval
|
||||
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.poll()
|
||||
}
|
||||
}
|
||||
|
||||
private func poll() {
|
||||
do {
|
||||
let messages = try store.messagesAfter(
|
||||
afterRowID: cursor,
|
||||
chatID: chatID,
|
||||
limit: configuration.batchLimit
|
||||
)
|
||||
for message in messages {
|
||||
continuation.yield(message)
|
||||
if message.rowID > cursor {
|
||||
cursor = message.rowID
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
Sources/IMsgCore/Models.swift
Normal file
82
Sources/IMsgCore/Models.swift
Normal file
@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
public struct Chat: Sendable, Equatable {
|
||||
public let id: Int64
|
||||
public let identifier: String
|
||||
public let name: String
|
||||
public let service: String
|
||||
public let lastMessageAt: Date
|
||||
|
||||
public init(id: Int64, identifier: String, name: String, service: String, lastMessageAt: Date) {
|
||||
self.id = id
|
||||
self.identifier = identifier
|
||||
self.name = name
|
||||
self.service = service
|
||||
self.lastMessageAt = lastMessageAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct Message: Sendable, Equatable {
|
||||
public let rowID: Int64
|
||||
public let chatID: Int64
|
||||
public let sender: String
|
||||
public let text: String
|
||||
public let date: Date
|
||||
public let isFromMe: Bool
|
||||
public let service: String
|
||||
public let handleID: Int64?
|
||||
public let attachmentsCount: Int
|
||||
|
||||
public init(
|
||||
rowID: Int64,
|
||||
chatID: Int64,
|
||||
sender: String,
|
||||
text: String,
|
||||
date: Date,
|
||||
isFromMe: Bool,
|
||||
service: String,
|
||||
handleID: Int64?,
|
||||
attachmentsCount: Int
|
||||
) {
|
||||
self.rowID = rowID
|
||||
self.chatID = chatID
|
||||
self.sender = sender
|
||||
self.text = text
|
||||
self.date = date
|
||||
self.isFromMe = isFromMe
|
||||
self.service = service
|
||||
self.handleID = handleID
|
||||
self.attachmentsCount = attachmentsCount
|
||||
}
|
||||
}
|
||||
|
||||
public struct AttachmentMeta: Sendable, Equatable {
|
||||
public let filename: String
|
||||
public let transferName: String
|
||||
public let uti: String
|
||||
public let mimeType: String
|
||||
public let totalBytes: Int64
|
||||
public let isSticker: Bool
|
||||
public let originalPath: String
|
||||
public let missing: Bool
|
||||
|
||||
public init(
|
||||
filename: String,
|
||||
transferName: String,
|
||||
uti: String,
|
||||
mimeType: String,
|
||||
totalBytes: Int64,
|
||||
isSticker: Bool,
|
||||
originalPath: String,
|
||||
missing: Bool
|
||||
) {
|
||||
self.filename = filename
|
||||
self.transferName = transferName
|
||||
self.uti = uti
|
||||
self.mimeType = mimeType
|
||||
self.totalBytes = totalBytes
|
||||
self.isSticker = isSticker
|
||||
self.originalPath = originalPath
|
||||
self.missing = missing
|
||||
}
|
||||
}
|
||||
15
Sources/IMsgCore/PhoneNumberNormalizer.swift
Normal file
15
Sources/IMsgCore/PhoneNumberNormalizer.swift
Normal file
@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
import PhoneNumberKit
|
||||
|
||||
final class PhoneNumberNormalizer {
|
||||
private let phoneNumberUtility = PhoneNumberUtility()
|
||||
|
||||
func normalize(_ input: String, region: String) -> String {
|
||||
do {
|
||||
let number = try phoneNumberUtility.parse(input, withRegion: region, ignoreType: true)
|
||||
return phoneNumberUtility.format(number, toType: .e164)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Sources/IMsgCore/TypedStreamParser.swift
Normal file
36
Sources/IMsgCore/TypedStreamParser.swift
Normal file
@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
enum TypedStreamParser {
|
||||
static func parseAttributedBody(_ data: Data) -> String {
|
||||
guard !data.isEmpty else { return "" }
|
||||
var bytes = [UInt8](data)
|
||||
let start = [UInt8(0x01), UInt8(0x2b)]
|
||||
let end = [UInt8(0x86), UInt8(0x84)]
|
||||
|
||||
if let startIndex = bytes.firstIndex(of: start[0]) {
|
||||
if startIndex + 1 < bytes.count, bytes[startIndex + 1] == start[1] {
|
||||
bytes = Array(bytes[(startIndex + 2)...])
|
||||
}
|
||||
}
|
||||
if let endIndex = bytes.firstIndex(of: end[0]) {
|
||||
if endIndex + 1 < bytes.count, bytes[endIndex + 1] == end[1] {
|
||||
bytes = Array(bytes[..<endIndex])
|
||||
}
|
||||
}
|
||||
|
||||
let text = String(decoding: bytes, as: UTF8.self)
|
||||
return text.trimmingLeadingControlCharacters()
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
fileprivate func trimmingLeadingControlCharacters() -> String {
|
||||
var scalars = unicodeScalars
|
||||
while let first = scalars.first,
|
||||
CharacterSet.controlCharacters.contains(first) || first == "\n" || first == "\r"
|
||||
{
|
||||
scalars.removeFirst()
|
||||
}
|
||||
return String(String.UnicodeScalarView(scalars))
|
||||
}
|
||||
}
|
||||
11
Sources/imsg/AttachmentDisplay.swift
Normal file
11
Sources/imsg/AttachmentDisplay.swift
Normal file
@ -0,0 +1,11 @@
|
||||
import IMsgCore
|
||||
|
||||
func pluralSuffix(for count: Int) -> String {
|
||||
count == 1 ? "" : "s"
|
||||
}
|
||||
|
||||
func displayName(for meta: AttachmentMeta) -> String {
|
||||
if !meta.transferName.isEmpty { return meta.transferName }
|
||||
if !meta.filename.isEmpty { return meta.filename }
|
||||
return "(unknown)"
|
||||
}
|
||||
97
Sources/imsg/CommandRouter.swift
Normal file
97
Sources/imsg/CommandRouter.swift
Normal file
@ -0,0 +1,97 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
struct CommandRouter {
|
||||
let rootName = "imsg"
|
||||
let version: String
|
||||
let specs: [CommandSpec]
|
||||
let program: Program
|
||||
|
||||
init() {
|
||||
self.version = ProcessInfo.processInfo.environment["IMSG_VERSION"] ?? "dev"
|
||||
self.specs = [
|
||||
ChatsCommand.spec,
|
||||
HistoryCommand.spec,
|
||||
WatchCommand.spec,
|
||||
SendCommand.spec,
|
||||
]
|
||||
let descriptor = CommandDescriptor(
|
||||
name: rootName,
|
||||
abstract: "Send and read iMessage / SMS from the terminal",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: specs.map { $0.descriptor }
|
||||
)
|
||||
self.program = Program(descriptors: [descriptor])
|
||||
}
|
||||
|
||||
func run() async -> Int32 {
|
||||
let argv = normalizeArguments(CommandLine.arguments)
|
||||
if argv.contains("--version") || argv.contains("-V") {
|
||||
Swift.print(version)
|
||||
return 0
|
||||
}
|
||||
if argv.count <= 1 || argv.contains("--help") || argv.contains("-h") {
|
||||
printHelp(for: argv)
|
||||
return 0
|
||||
}
|
||||
|
||||
do {
|
||||
let invocation = try program.resolve(argv: argv)
|
||||
guard let commandName = invocation.path.last,
|
||||
let spec = specs.first(where: { $0.name == commandName })
|
||||
else {
|
||||
Swift.print("Unknown command")
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
return 1
|
||||
}
|
||||
let runtime = RuntimeOptions(parsedValues: invocation.parsedValues)
|
||||
do {
|
||||
try await spec.run(invocation.parsedValues, runtime)
|
||||
return 0
|
||||
} catch {
|
||||
Swift.print(error)
|
||||
return 1
|
||||
}
|
||||
} catch let error as CommanderProgramError {
|
||||
Swift.print(error.description)
|
||||
if case .missingSubcommand = error {
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
}
|
||||
return 1
|
||||
} catch {
|
||||
Swift.print(error)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizeArguments(_ argv: [String]) -> [String] {
|
||||
guard !argv.isEmpty else { return argv }
|
||||
var copy = argv
|
||||
copy[0] = URL(fileURLWithPath: argv[0]).lastPathComponent
|
||||
return copy
|
||||
}
|
||||
|
||||
private func printHelp(for argv: [String]) {
|
||||
let path = helpPath(from: argv)
|
||||
if path.count <= 1 {
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
return
|
||||
}
|
||||
if let spec = specs.first(where: { $0.name == path[1] }) {
|
||||
HelpPrinter.printCommand(rootName: rootName, spec: spec)
|
||||
} else {
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
}
|
||||
}
|
||||
|
||||
private func helpPath(from argv: [String]) -> [String] {
|
||||
var path: [String] = []
|
||||
for token in argv {
|
||||
if token == "--help" || token == "-h" { continue }
|
||||
if token.hasPrefix("-") { break }
|
||||
path.append(token)
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
18
Sources/imsg/CommandSignatures.swift
Normal file
18
Sources/imsg/CommandSignatures.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import Commander
|
||||
import IMsgCore
|
||||
|
||||
enum CommandSignatures {
|
||||
static func baseOptions() -> [OptionDefinition] {
|
||||
[
|
||||
.make(
|
||||
label: "db",
|
||||
names: [.long("db")],
|
||||
help: "Path to chat.db (defaults to ~/Library/Messages/chat.db)"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
static func withRuntimeFlags(_ signature: CommandSignature) -> CommandSignature {
|
||||
signature.withStandardRuntimeFlags()
|
||||
}
|
||||
}
|
||||
19
Sources/imsg/CommandSpec.swift
Normal file
19
Sources/imsg/CommandSpec.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import Commander
|
||||
|
||||
struct CommandSpec: @unchecked Sendable {
|
||||
let name: String
|
||||
let abstract: String
|
||||
let discussion: String?
|
||||
let signature: CommandSignature
|
||||
let usageExamples: [String]
|
||||
let run: (ParsedValues, RuntimeOptions) async throws -> Void
|
||||
|
||||
var descriptor: CommandDescriptor {
|
||||
CommandDescriptor(
|
||||
name: name,
|
||||
abstract: abstract,
|
||||
discussion: discussion,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
}
|
||||
39
Sources/imsg/Commands/ChatsCommand.swift
Normal file
39
Sources/imsg/Commands/ChatsCommand.swift
Normal file
@ -0,0 +1,39 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum ChatsCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "chats",
|
||||
abstract: "List recent conversations",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "limit", names: [.long("limit")], help: "Number of chats to list")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg chats --limit 5",
|
||||
"imsg chats --limit 5 --json",
|
||||
]
|
||||
) { values, runtime in
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let limit = values.optionInt("limit") ?? 20
|
||||
let store = try MessageStore(path: dbPath)
|
||||
let chats = try store.listChats(limit: limit)
|
||||
|
||||
if runtime.jsonOutput {
|
||||
for chat in chats {
|
||||
try JSONLines.print(ChatPayload(chat: chat))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for chat in chats {
|
||||
let last = CLIISO8601.format(chat.lastMessageAt)
|
||||
Swift.print("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)")
|
||||
}
|
||||
}
|
||||
}
|
||||
81
Sources/imsg/Commands/HistoryCommand.swift
Normal file
81
Sources/imsg/Commands/HistoryCommand.swift
Normal file
@ -0,0 +1,81 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum HistoryCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "history",
|
||||
abstract: "Show recent messages for a chat",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid from 'imsg chats'"),
|
||||
.make(label: "limit", names: [.long("limit")], help: "Number of messages to show"),
|
||||
.make(
|
||||
label: "participants", names: [.long("participants")],
|
||||
help: "filter by participant handles", parsing: .upToNextOption),
|
||||
.make(label: "start", names: [.long("start")], help: "ISO8601 start (inclusive)"),
|
||||
.make(label: "end", names: [.long("end")], help: "ISO8601 end (exclusive)"),
|
||||
],
|
||||
flags: [
|
||||
.make(
|
||||
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
|
||||
)
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg history --chat-id 1 --limit 10 --attachments",
|
||||
"imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json",
|
||||
]
|
||||
) { values, runtime in
|
||||
guard let chatID = values.optionInt64("chatID") else {
|
||||
throw ParsedValuesError.missingOption("chat-id")
|
||||
}
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let limit = values.optionInt("limit") ?? 50
|
||||
let showAttachments = values.flag("attachments")
|
||||
let participants = values.optionValues("participants")
|
||||
.flatMap { $0.split(separator: ",").map { String($0) } }
|
||||
.filter { !$0.isEmpty }
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: values.option("start"),
|
||||
endISO: values.option("end")
|
||||
)
|
||||
|
||||
let store = try MessageStore(path: dbPath)
|
||||
let messages = try store.messages(chatID: chatID, limit: limit)
|
||||
let filtered = messages.filter { filter.allows($0) }
|
||||
|
||||
if runtime.jsonOutput {
|
||||
for message in filtered {
|
||||
let attachments = try store.attachments(for: message.rowID)
|
||||
try JSONLines.print(MessagePayload(message: message, attachments: attachments))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for message in filtered {
|
||||
let direction = message.isFromMe ? "sent" : "recv"
|
||||
let timestamp = CLIISO8601.format(message.date)
|
||||
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
|
||||
if message.attachmentsCount > 0 {
|
||||
if showAttachments {
|
||||
let metas = try store.attachments(for: message.rowID)
|
||||
for meta in metas {
|
||||
let name = displayName(for: meta)
|
||||
Swift.print(
|
||||
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Swift.print(
|
||||
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
Sources/imsg/Commands/SendCommand.swift
Normal file
57
Sources/imsg/Commands/SendCommand.swift
Normal file
@ -0,0 +1,57 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum SendCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "send",
|
||||
abstract: "Send a message (text and/or attachment)",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "to", names: [.long("to")], help: "phone number or email"),
|
||||
.make(label: "text", names: [.long("text")], help: "message body"),
|
||||
.make(label: "file", names: [.long("file")], help: "path to attachment"),
|
||||
.make(
|
||||
label: "service", names: [.long("service")], help: "service to use: imessage|sms|auto"),
|
||||
.make(
|
||||
label: "region", names: [.long("region")],
|
||||
help: "default region for phone normalization"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg send --to +14155551212 --text \"hi\"",
|
||||
"imsg send --to +14155551212 --text \"hi\" --file ~/Desktop/pic.jpg --service imessage",
|
||||
]
|
||||
) { values, runtime in
|
||||
let recipient = try values.optionRequired("to")
|
||||
let text = values.option("text") ?? ""
|
||||
let file = values.option("file") ?? ""
|
||||
if text.isEmpty && file.isEmpty {
|
||||
throw ParsedValuesError.missingOption("text or file")
|
||||
}
|
||||
let serviceRaw = values.option("service") ?? "auto"
|
||||
guard let service = MessageService(rawValue: serviceRaw) else {
|
||||
throw IMsgError.invalidService(serviceRaw)
|
||||
}
|
||||
let region = values.option("region") ?? "US"
|
||||
|
||||
let sender = MessageSender()
|
||||
try sender.send(
|
||||
MessageSendOptions(
|
||||
recipient: recipient,
|
||||
text: text,
|
||||
attachmentPath: file,
|
||||
service: service,
|
||||
region: region
|
||||
))
|
||||
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "sent"])
|
||||
} else {
|
||||
Swift.print("sent")
|
||||
}
|
||||
}
|
||||
}
|
||||
93
Sources/imsg/Commands/WatchCommand.swift
Normal file
93
Sources/imsg/Commands/WatchCommand.swift
Normal file
@ -0,0 +1,93 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum WatchCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "watch",
|
||||
abstract: "Stream incoming messages",
|
||||
discussion: nil,
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chatID", names: [.long("chat-id")], help: "limit to chat rowid"),
|
||||
.make(
|
||||
label: "debounce", names: [.long("debounce")],
|
||||
help: "debounce interval for filesystem events (e.g. 250ms)"),
|
||||
.make(
|
||||
label: "sinceRowID", names: [.long("since-rowid")],
|
||||
help: "start watching after this rowid"),
|
||||
.make(
|
||||
label: "participants", names: [.long("participants")],
|
||||
help: "filter by participant handles", parsing: .upToNextOption),
|
||||
.make(label: "start", names: [.long("start")], help: "ISO8601 start (inclusive)"),
|
||||
.make(label: "end", names: [.long("end")], help: "ISO8601 end (exclusive)"),
|
||||
],
|
||||
flags: [
|
||||
.make(
|
||||
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
|
||||
)
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg watch --chat-id 1 --attachments --debounce 250ms",
|
||||
"imsg watch --chat-id 1 --participants +15551234567",
|
||||
]
|
||||
) { values, runtime in
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let chatID = values.optionInt64("chatID")
|
||||
let debounceString = values.option("debounce") ?? "250ms"
|
||||
guard let debounceInterval = DurationParser.parse(debounceString) else {
|
||||
throw ParsedValuesError.invalidOption("debounce")
|
||||
}
|
||||
let sinceRowID = values.optionInt64("sinceRowID")
|
||||
let showAttachments = values.flag("attachments")
|
||||
let participants = values.optionValues("participants")
|
||||
.flatMap { $0.split(separator: ",").map { String($0) } }
|
||||
.filter { !$0.isEmpty }
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: values.option("start"),
|
||||
endISO: values.option("end")
|
||||
)
|
||||
|
||||
let store = try MessageStore(path: dbPath)
|
||||
let watcher = MessageWatcher(store: store)
|
||||
let config = MessageWatcherConfiguration(
|
||||
debounceInterval: debounceInterval,
|
||||
batchLimit: 100
|
||||
)
|
||||
|
||||
for try await message in watcher.stream(
|
||||
chatID: chatID, sinceRowID: sinceRowID, configuration: config)
|
||||
{
|
||||
if !filter.allows(message) {
|
||||
continue
|
||||
}
|
||||
if runtime.jsonOutput {
|
||||
let attachments = try store.attachments(for: message.rowID)
|
||||
try JSONLines.print(MessagePayload(message: message, attachments: attachments))
|
||||
continue
|
||||
}
|
||||
let direction = message.isFromMe ? "sent" : "recv"
|
||||
let timestamp = CLIISO8601.format(message.date)
|
||||
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
|
||||
if message.attachmentsCount > 0 {
|
||||
if showAttachments {
|
||||
let metas = try store.attachments(for: message.rowID)
|
||||
for meta in metas {
|
||||
let name = displayName(for: meta)
|
||||
Swift.print(
|
||||
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Swift.print(
|
||||
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Sources/imsg/DurationParser.swift
Normal file
28
Sources/imsg/DurationParser.swift
Normal file
@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
enum DurationParser {
|
||||
static func parse(_ value: String) -> TimeInterval? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let units: [(suffix: String, multiplier: Double)] = [
|
||||
("ms", 0.001),
|
||||
("s", 1),
|
||||
("m", 60),
|
||||
("h", 3600),
|
||||
]
|
||||
for unit in units {
|
||||
if trimmed.hasSuffix(unit.suffix) {
|
||||
let number = String(trimmed.dropLast(unit.suffix.count))
|
||||
if let value = Double(number) {
|
||||
return value * unit.multiplier
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if let value = Double(trimmed) {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
91
Sources/imsg/HelpPrinter.swift
Normal file
91
Sources/imsg/HelpPrinter.swift
Normal file
@ -0,0 +1,91 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
struct HelpPrinter {
|
||||
static func printRoot(version: String, rootName: String, commands: [CommandSpec]) {
|
||||
Swift.print("\(rootName) \(version)")
|
||||
Swift.print("Send and read iMessage / SMS from the terminal")
|
||||
Swift.print("")
|
||||
Swift.print("Usage:")
|
||||
Swift.print(" \(rootName) <command> [options]")
|
||||
Swift.print("")
|
||||
Swift.print("Commands:")
|
||||
for command in commands {
|
||||
Swift.print(" \(command.name)\t\(command.abstract)")
|
||||
}
|
||||
Swift.print("")
|
||||
Swift.print("Run '\(rootName) <command> --help' for details.")
|
||||
}
|
||||
|
||||
static func printCommand(rootName: String, spec: CommandSpec) {
|
||||
Swift.print("\(rootName) \(spec.name)")
|
||||
Swift.print(spec.abstract)
|
||||
if let discussion = spec.discussion, !discussion.isEmpty {
|
||||
Swift.print("\n\(discussion)")
|
||||
}
|
||||
Swift.print("")
|
||||
Swift.print("Usage:")
|
||||
Swift.print(" \(rootName) \(spec.name) \(usageFragment(for: spec.signature))")
|
||||
Swift.print("")
|
||||
|
||||
if !spec.signature.arguments.isEmpty {
|
||||
Swift.print("Arguments:")
|
||||
for arg in spec.signature.arguments {
|
||||
let optionalMark = arg.isOptional ? "?" : ""
|
||||
Swift.print(" \(arg.label)\(optionalMark)\t\(arg.help ?? "")")
|
||||
}
|
||||
Swift.print("")
|
||||
}
|
||||
|
||||
let options = spec.signature.options
|
||||
let flags = spec.signature.flags
|
||||
if !options.isEmpty || !flags.isEmpty {
|
||||
Swift.print("Options:")
|
||||
for option in options {
|
||||
let names = formatNames(option.names, expectsValue: true)
|
||||
Swift.print(" \(names)\t\(option.help ?? "")")
|
||||
}
|
||||
for flag in flags {
|
||||
let names = formatNames(flag.names, expectsValue: false)
|
||||
Swift.print(" \(names)\t\(flag.help ?? "")")
|
||||
}
|
||||
Swift.print("")
|
||||
}
|
||||
|
||||
if !spec.usageExamples.isEmpty {
|
||||
Swift.print("Examples:")
|
||||
for example in spec.usageExamples {
|
||||
Swift.print(" \(example)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func usageFragment(for signature: CommandSignature) -> String {
|
||||
var parts: [String] = []
|
||||
for argument in signature.arguments {
|
||||
let token = argument.isOptional ? "[\(argument.label)]" : "<\(argument.label)>"
|
||||
parts.append(token)
|
||||
}
|
||||
if !signature.options.isEmpty || !signature.flags.isEmpty {
|
||||
parts.append("[options]")
|
||||
}
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
|
||||
private static func formatNames(_ names: [CommanderName], expectsValue: Bool) -> String {
|
||||
let parts = names.map { name -> String in
|
||||
switch name {
|
||||
case .short(let char):
|
||||
return "-\(char)"
|
||||
case .long(let value):
|
||||
return "--\(value)"
|
||||
case .aliasShort(let char):
|
||||
return "-\(char)"
|
||||
case .aliasLong(let value):
|
||||
return "--\(value)"
|
||||
}
|
||||
}
|
||||
let suffix = expectsValue ? " <value>" : ""
|
||||
return parts.joined(separator: ", ") + suffix
|
||||
}
|
||||
}
|
||||
12
Sources/imsg/IMsgCLI.swift
Normal file
12
Sources/imsg/IMsgCLI.swift
Normal file
@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct IMsgCLI {
|
||||
static func main() async {
|
||||
let router = CommandRouter()
|
||||
let status = await router.run()
|
||||
if status != 0 {
|
||||
exit(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
16
Sources/imsg/JSONLines.swift
Normal file
16
Sources/imsg/JSONLines.swift
Normal file
@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
enum JSONLines {
|
||||
private static let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.withoutEscapingSlashes]
|
||||
return encoder
|
||||
}()
|
||||
|
||||
static func print<T: Encodable>(_ value: T) throws {
|
||||
let data = try encoder.encode(value)
|
||||
if let line = String(data: data, encoding: .utf8) {
|
||||
Swift.print(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
97
Sources/imsg/OutputModels.swift
Normal file
97
Sources/imsg/OutputModels.swift
Normal file
@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
struct ChatPayload: Codable {
|
||||
let id: Int64
|
||||
let name: String
|
||||
let identifier: String
|
||||
let service: String
|
||||
let lastMessageAt: String
|
||||
|
||||
init(chat: Chat) {
|
||||
self.id = chat.id
|
||||
self.name = chat.name
|
||||
self.identifier = chat.identifier
|
||||
self.service = chat.service
|
||||
self.lastMessageAt = CLIISO8601.format(chat.lastMessageAt)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case identifier
|
||||
case service
|
||||
case lastMessageAt = "last_message_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct MessagePayload: Codable {
|
||||
let id: Int64
|
||||
let chatID: Int64
|
||||
let sender: String
|
||||
let isFromMe: Bool
|
||||
let text: String
|
||||
let createdAt: String
|
||||
let attachments: [AttachmentPayload]
|
||||
|
||||
init(message: Message, attachments: [AttachmentMeta]) {
|
||||
self.id = message.rowID
|
||||
self.chatID = message.chatID
|
||||
self.sender = message.sender
|
||||
self.isFromMe = message.isFromMe
|
||||
self.text = message.text
|
||||
self.createdAt = CLIISO8601.format(message.date)
|
||||
self.attachments = attachments.map { AttachmentPayload(meta: $0) }
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case chatID = "chat_id"
|
||||
case sender
|
||||
case isFromMe = "is_from_me"
|
||||
case text
|
||||
case createdAt = "created_at"
|
||||
case attachments
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentPayload: Codable {
|
||||
let filename: String
|
||||
let transferName: String
|
||||
let uti: String
|
||||
let mimeType: String
|
||||
let totalBytes: Int64
|
||||
let isSticker: Bool
|
||||
let originalPath: String
|
||||
let missing: Bool
|
||||
|
||||
init(meta: AttachmentMeta) {
|
||||
self.filename = meta.filename
|
||||
self.transferName = meta.transferName
|
||||
self.uti = meta.uti
|
||||
self.mimeType = meta.mimeType
|
||||
self.totalBytes = meta.totalBytes
|
||||
self.isSticker = meta.isSticker
|
||||
self.originalPath = meta.originalPath
|
||||
self.missing = meta.missing
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case filename = "filename"
|
||||
case transferName = "transfer_name"
|
||||
case uti = "uti"
|
||||
case mimeType = "mime_type"
|
||||
case totalBytes = "total_bytes"
|
||||
case isSticker = "is_sticker"
|
||||
case originalPath = "original_path"
|
||||
case missing = "missing"
|
||||
}
|
||||
}
|
||||
|
||||
enum CLIISO8601 {
|
||||
static func format(_ date: Date) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
54
Sources/imsg/ParsedValues+Decode.swift
Normal file
54
Sources/imsg/ParsedValues+Decode.swift
Normal file
@ -0,0 +1,54 @@
|
||||
import Commander
|
||||
|
||||
enum ParsedValuesError: Error, CustomStringConvertible {
|
||||
case missingOption(String)
|
||||
case invalidOption(String)
|
||||
case missingArgument(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .missingOption(let name):
|
||||
return "Missing required option: --\(name)"
|
||||
case .invalidOption(let name):
|
||||
return "Invalid value for option: --\(name)"
|
||||
case .missingArgument(let name):
|
||||
return "Missing required argument: \(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ParsedValues {
|
||||
func flag(_ label: String) -> Bool {
|
||||
flags.contains(label)
|
||||
}
|
||||
|
||||
func option(_ label: String) -> String? {
|
||||
options[label]?.last
|
||||
}
|
||||
|
||||
func optionValues(_ label: String) -> [String] {
|
||||
options[label] ?? []
|
||||
}
|
||||
|
||||
func optionInt(_ label: String) -> Int? {
|
||||
guard let value = option(label) else { return nil }
|
||||
return Int(value)
|
||||
}
|
||||
|
||||
func optionInt64(_ label: String) -> Int64? {
|
||||
guard let value = option(label) else { return nil }
|
||||
return Int64(value)
|
||||
}
|
||||
|
||||
func optionRequired(_ label: String) throws -> String {
|
||||
guard let value = option(label), !value.isEmpty else {
|
||||
throw ParsedValuesError.missingOption(label)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func argument(_ index: Int) -> String? {
|
||||
guard positional.indices.contains(index) else { return nil }
|
||||
return positional[index]
|
||||
}
|
||||
}
|
||||
13
Sources/imsg/RuntimeOptions.swift
Normal file
13
Sources/imsg/RuntimeOptions.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Commander
|
||||
|
||||
struct RuntimeOptions: Sendable {
|
||||
let jsonOutput: Bool
|
||||
let verbose: Bool
|
||||
let logLevel: String?
|
||||
|
||||
init(parsedValues: ParsedValues) {
|
||||
self.jsonOutput = parsedValues.flags.contains("jsonOutput")
|
||||
self.verbose = parsedValues.flags.contains("verbose")
|
||||
self.logLevel = parsedValues.options["logLevel"]?.last
|
||||
}
|
||||
}
|
||||
284
Tests/IMsgCoreTests/MessageStoreTests.swift
Normal file
284
Tests/IMsgCoreTests/MessageStoreTests.swift
Normal file
@ -0,0 +1,284 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
private enum TestDatabase {
|
||||
static func appleEpoch(_ date: Date) -> Int64 {
|
||||
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
|
||||
return Int64(seconds * 1_000_000_000)
|
||||
}
|
||||
|
||||
static func makeStore(includeAttributedBody: Bool = false) throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
if includeAttributedBody {
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
attributedBody BLOB,
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
} else {
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
}
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE chat (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
chat_identifier TEXT,
|
||||
display_name TEXT,
|
||||
service_name TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
|
||||
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE attachment (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
filename TEXT,
|
||||
transfer_name TEXT,
|
||||
uti TEXT,
|
||||
mime_type TEXT,
|
||||
total_bytes INTEGER,
|
||||
is_sticker INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message_attachment_join (
|
||||
message_id INTEGER,
|
||||
attachment_id INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat(ROWID, chat_identifier, display_name, service_name)
|
||||
VALUES (1, '+123', 'Test Chat', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'Me')")
|
||||
|
||||
let messageRows: [(Int64, Int64, String?, Bool, Date, Int)] = [
|
||||
(1, 1, "hello", false, now.addingTimeInterval(-600), 0),
|
||||
(2, 2, "hi back", true, now.addingTimeInterval(-500), 1),
|
||||
(3, 1, "photo", false, now.addingTimeInterval(-60), 0),
|
||||
]
|
||||
for row in messageRows {
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
""",
|
||||
row.0,
|
||||
row.1,
|
||||
row.2,
|
||||
appleEpoch(row.4),
|
||||
row.3 ? 1 : 0,
|
||||
"iMessage"
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)", row.0)
|
||||
if row.5 > 0 {
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO attachment(
|
||||
ROWID,
|
||||
filename,
|
||||
transfer_name,
|
||||
uti,
|
||||
mime_type,
|
||||
total_bytes,
|
||||
is_sticker
|
||||
)
|
||||
VALUES (1, '~/Library/Messages/Attachments/test.dat', 'test.dat', 'public.data', 'application/octet-stream', 123, 0)
|
||||
"""
|
||||
)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message_attachment_join(message_id, attachment_id)
|
||||
VALUES (?, 1)
|
||||
""",
|
||||
row.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return try MessageStore(connection: db, path: ":memory:")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func listChatsReturnsChat() throws {
|
||||
let store = try TestDatabase.makeStore()
|
||||
let chats = try store.listChats(limit: 5)
|
||||
#expect(chats.count == 1)
|
||||
#expect(chats.first?.identifier == "+123")
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesByChatReturnsMessages() throws {
|
||||
let store = try TestDatabase.makeStore()
|
||||
let messages = try store.messages(chatID: 1, limit: 10)
|
||||
#expect(messages.count == 3)
|
||||
#expect(messages[1].isFromMe)
|
||||
#expect(messages[0].attachmentsCount == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesAfterReturnsMessages() throws {
|
||||
let store = try TestDatabase.makeStore()
|
||||
let messages = try store.messagesAfter(afterRowID: 1, chatID: nil, limit: 10)
|
||||
#expect(messages.count == 2)
|
||||
#expect(messages.first?.rowID == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesByChatUsesAttributedBodyFallback() throws {
|
||||
let db = try Connection(.inMemory)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
attributedBody BLOB,
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE chat (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
chat_identifier TEXT,
|
||||
display_name TEXT,
|
||||
service_name TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
|
||||
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message_attachment_join (
|
||||
message_id INTEGER,
|
||||
attachment_id INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
let bodyBytes = [UInt8(0x01), UInt8(0x2b)] + Array("fallback text".utf8) + [0x86, 0x84]
|
||||
let body = Blob(bytes: bodyBytes)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO chat(ROWID, chat_identifier, display_name, service_name)
|
||||
VALUES (1, '+123', 'Test Chat', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, attributedBody, date, is_from_me, service)
|
||||
VALUES (1, 1, NULL, ?, ?, 0, 'iMessage')
|
||||
""",
|
||||
body,
|
||||
TestDatabase.appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
|
||||
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
let messages = try store.messages(chatID: 1, limit: 10)
|
||||
#expect(messages.count == 1)
|
||||
#expect(messages.first?.text == "fallback text")
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesAfterUsesAttributedBodyFallback() throws {
|
||||
let db = try Connection(.inMemory)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
handle_id INTEGER,
|
||||
text TEXT,
|
||||
attributedBody BLOB,
|
||||
date INTEGER,
|
||||
is_from_me INTEGER,
|
||||
service TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE chat (
|
||||
ROWID INTEGER PRIMARY KEY,
|
||||
chat_identifier TEXT,
|
||||
display_name TEXT,
|
||||
service_name TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
|
||||
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message_attachment_join (
|
||||
message_id INTEGER,
|
||||
attachment_id INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
let bodyBytes = [UInt8(0x01), UInt8(0x2b)] + Array("fallback text".utf8) + [0x86, 0x84]
|
||||
let body = Blob(bytes: bodyBytes)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, attributedBody, date, is_from_me, service)
|
||||
VALUES (1, 1, NULL, ?, ?, 0, 'iMessage')
|
||||
""",
|
||||
body,
|
||||
TestDatabase.appleEpoch(now)
|
||||
)
|
||||
|
||||
let store = try MessageStore(connection: db, path: ":memory:")
|
||||
let messages = try store.messagesAfter(afterRowID: 0, chatID: nil, limit: 10)
|
||||
#expect(messages.count == 1)
|
||||
#expect(messages.first?.text == "fallback text")
|
||||
}
|
||||
|
||||
@Test
|
||||
func attachmentsByMessageReturnsMetadata() throws {
|
||||
let store = try TestDatabase.makeStore()
|
||||
let attachments = try store.attachments(for: 2)
|
||||
#expect(attachments.count == 1)
|
||||
#expect(attachments.first?.mimeType == "application/octet-stream")
|
||||
}
|
||||
383
cmd/imsg/main.go
383
cmd/imsg/main.go
@ -1,383 +0,0 @@
|
||||
// Command imsg is a CLI for interacting with macOS Messages.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/steipete/imsg/internal/db"
|
||||
"github.com/steipete/imsg/internal/send"
|
||||
"github.com/steipete/imsg/internal/watch"
|
||||
)
|
||||
|
||||
var (
|
||||
dbPath string
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "imsg",
|
||||
Short: "Send and read iMessage / SMS from the terminal",
|
||||
Long: `Examples:
|
||||
imsg chats --limit 5
|
||||
imsg history --chat-id 1 --limit 10 --attachments
|
||||
imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json
|
||||
imsg watch --chat-id 1 --attachments --interval 2s
|
||||
imsg send --to "+14155551212" --text "hi" --file ~/Desktop/pic.jpg --service imessage
|
||||
`,
|
||||
Version: version,
|
||||
}
|
||||
|
||||
root.PersistentFlags().StringVar(&dbPath, "db", db.DefaultPath(), "Path to chat.db (defaults to ~/Library/Messages/chat.db)")
|
||||
root.PersistentFlags().BoolP("version", "V", false, "Show version")
|
||||
|
||||
root.SetVersionTemplate("{{.Version}}\n")
|
||||
|
||||
root.AddCommand(newChatsCmd())
|
||||
root.AddCommand(newHistoryCmd())
|
||||
root.AddCommand(newWatchCmd())
|
||||
root.AddCommand(newSendCmd())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newChatsCmd() *cobra.Command {
|
||||
var limit int
|
||||
var jsonOut bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "chats",
|
||||
Short: "List recent conversations",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_ = args
|
||||
ctx := cmd.Context()
|
||||
store, err := db.Open(ctx, dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
chats, err := db.ListChats(ctx, store, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
for _, c := range chats {
|
||||
if err := enc.Encode(map[string]any{
|
||||
"id": c.ID,
|
||||
"name": c.Name,
|
||||
"identifier": c.Identifier,
|
||||
"service": c.Service,
|
||||
"last_message_at": c.LastMessageAt.Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, c := range chats {
|
||||
fmt.Printf("[%d] %s (%s) last=%s\n", c.ID, c.Name, c.Identifier, c.LastMessageAt.Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVar(&limit, "limit", 20, "Number of chats to list")
|
||||
cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON objects instead of plain text")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newHistoryCmd() *cobra.Command {
|
||||
var (
|
||||
chatID int64
|
||||
limit int
|
||||
showAttachments bool
|
||||
participants []string
|
||||
startISO string
|
||||
endISO string
|
||||
jsonOut bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "history",
|
||||
Short: "Show recent messages for a chat",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_ = args
|
||||
if chatID == 0 {
|
||||
return fmt.Errorf("--chat-id is required")
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
store, err := db.Open(ctx, dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
messages, err := db.MessagesByChat(ctx, store, chatID, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := filterMessages(messages, participants, startISO, endISO)
|
||||
|
||||
if jsonOut {
|
||||
return printJSON(filtered, func(m db.Message, metas []db.AttachmentMeta) map[string]any {
|
||||
return map[string]any{
|
||||
"id": m.RowID,
|
||||
"chat_id": m.ChatID,
|
||||
"sender": m.Sender,
|
||||
"is_from_me": m.IsFromMe,
|
||||
"text": m.Text,
|
||||
"created_at": m.Date.Format(time.RFC3339),
|
||||
"attachments": metas,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, m := range filtered {
|
||||
direction := "recv"
|
||||
if m.IsFromMe {
|
||||
direction = "sent"
|
||||
}
|
||||
fmt.Printf("%s [%s] %s: %s\n", m.Date.Format(time.RFC3339), direction, m.Sender, m.Text)
|
||||
if m.Attachments > 0 {
|
||||
if showAttachments {
|
||||
metas, err := db.AttachmentsByMessage(ctx, store, m.RowID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, meta := range metas {
|
||||
fmt.Printf(" attachment: name=%s mime=%s missing=%t path=%s\n", displayName(meta), meta.MimeType, meta.Missing, meta.OriginalPath)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" (%d attachment%c)\n", m.Attachments, plural(m.Attachments))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().Int64Var(&chatID, "chat-id", 0, "chat rowid from 'imsg chats'")
|
||||
cmd.Flags().IntVar(&limit, "limit", 50, "Number of messages to show")
|
||||
cmd.Flags().BoolVar(&showAttachments, "attachments", false, "include attachment metadata")
|
||||
cmd.Flags().StringSliceVar(&participants, "participants", nil, "filter by participant handles (E.164 or email)")
|
||||
cmd.Flags().StringVar(&startISO, "start", "", "ISO8601 start (inclusive), e.g. 2025-01-01T00:00:00Z")
|
||||
cmd.Flags().StringVar(&endISO, "end", "", "ISO8601 end (exclusive)")
|
||||
cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON objects instead of plain text")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newWatchCmd() *cobra.Command {
|
||||
var (
|
||||
chatID int64
|
||||
interval time.Duration
|
||||
since int64
|
||||
showAttachments bool
|
||||
participants []string
|
||||
startISO string
|
||||
endISO string
|
||||
jsonOut bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "watch",
|
||||
Short: "Stream incoming messages",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_ = args
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
store, err := db.Open(ctx, dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sig
|
||||
cancel()
|
||||
}()
|
||||
|
||||
startRowID := since
|
||||
if startRowID == 0 {
|
||||
startRowID, err = db.MaxRowID(ctx, store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return watch.Run(ctx, store, chatID, startRowID, interval, func(msg db.Message) {
|
||||
direction := "recv"
|
||||
if msg.IsFromMe {
|
||||
direction = "sent"
|
||||
}
|
||||
if !passesFilters(msg, participants, startISO, endISO) {
|
||||
return
|
||||
}
|
||||
if jsonOut {
|
||||
metas, _ := db.AttachmentsByMessage(ctx, store, msg.RowID)
|
||||
entry := map[string]any{
|
||||
"id": msg.RowID,
|
||||
"chat_id": msg.ChatID,
|
||||
"sender": msg.Sender,
|
||||
"is_from_me": msg.IsFromMe,
|
||||
"text": msg.Text,
|
||||
"created_at": msg.Date.Format(time.RFC3339),
|
||||
"attachments": metas,
|
||||
}
|
||||
printJSONSingle(entry)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s [%s] %s: %s\n", msg.Date.Format(time.RFC3339), direction, msg.Sender, msg.Text)
|
||||
if msg.Attachments > 0 {
|
||||
if showAttachments {
|
||||
metas, err := db.AttachmentsByMessage(ctx, store, msg.RowID)
|
||||
if err == nil {
|
||||
for _, meta := range metas {
|
||||
fmt.Printf(" attachment: name=%s mime=%s missing=%t path=%s\n", displayName(meta), meta.MimeType, meta.Missing, meta.OriginalPath)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" (%d attachment%c)\n", msg.Attachments, plural(msg.Attachments))
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().Int64Var(&chatID, "chat-id", 0, "limit to chat rowid")
|
||||
cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "polling interval")
|
||||
cmd.Flags().Int64Var(&since, "since-rowid", 0, "start watching after this rowid (defaults to current max)")
|
||||
cmd.Flags().BoolVar(&showAttachments, "attachments", false, "include attachment metadata")
|
||||
cmd.Flags().StringSliceVar(&participants, "participants", nil, "filter by participant handles (E.164 or email)")
|
||||
cmd.Flags().StringVar(&startISO, "start", "", "ISO8601 start (inclusive), e.g. 2025-01-01T00:00:00Z")
|
||||
cmd.Flags().StringVar(&endISO, "end", "", "ISO8601 end (exclusive)")
|
||||
cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON objects instead of plain text")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSendCmd() *cobra.Command {
|
||||
var opts send.Options
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "Send a message (text and/or attachment)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_ = args
|
||||
if opts.Recipient == "" {
|
||||
return fmt.Errorf("--to is required")
|
||||
}
|
||||
if opts.Text == "" && opts.AttachmentPath == "" {
|
||||
return fmt.Errorf("set --text and/or --file")
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if err := send.Send(ctx, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("sent")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Recipient, "to", "", "phone number or email")
|
||||
cmd.Flags().StringVar(&opts.Text, "text", "", "message body")
|
||||
cmd.Flags().StringVar(&opts.AttachmentPath, "file", "", "path to attachment")
|
||||
cmd.Flags().StringVar((*string)(&opts.Service), "service", string(send.ServiceAuto), "service to use: imessage|sms|auto")
|
||||
cmd.Flags().StringVar(&opts.Region, "region", "US", "default region for phone normalization (ISO 3166-1 alpha-2)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func plural(n int) rune {
|
||||
if n == 1 {
|
||||
return ' '
|
||||
}
|
||||
return 's'
|
||||
}
|
||||
|
||||
func parseISO(ts string) (time.Time, bool) {
|
||||
if ts == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||
return t, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func filterMessages(msgs []db.Message, participants []string, startISO, endISO string) []db.Message {
|
||||
start, hasStart := parseISO(startISO)
|
||||
end, hasEnd := parseISO(endISO)
|
||||
filters := func(m db.Message) bool {
|
||||
if hasStart && m.Date.Before(start) {
|
||||
return false
|
||||
}
|
||||
if hasEnd && !m.Date.Before(end) {
|
||||
return false
|
||||
}
|
||||
if len(participants) > 0 {
|
||||
match := false
|
||||
for _, p := range participants {
|
||||
if strings.EqualFold(m.Sender, p) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
out := make([]db.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if filters(m) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func passesFilters(m db.Message, participants []string, startISO, endISO string) bool {
|
||||
return len(filterMessages([]db.Message{m}, participants, startISO, endISO)) > 0
|
||||
}
|
||||
|
||||
func printJSON(msgs []db.Message, fn func(db.Message, []db.AttachmentMeta) map[string]any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
for _, m := range msgs {
|
||||
metas, _ := db.AttachmentsByMessage(context.Background(), mustOpenDB(), m.RowID)
|
||||
if err := enc.Encode(fn(m, metas)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printJSONSingle(entry map[string]any) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
_ = enc.Encode(entry)
|
||||
}
|
||||
|
||||
// mustOpenDB reuses dbPath to fetch attachments when printing JSON inside watchers.
|
||||
func mustOpenDB() *sql.DB {
|
||||
ctx := context.Background()
|
||||
store, _ := db.Open(ctx, dbPath)
|
||||
return store
|
||||
}
|
||||
|
||||
func displayName(meta db.AttachmentMeta) string {
|
||||
if meta.TransferName != "" {
|
||||
return meta.TransferName
|
||||
}
|
||||
if meta.Filename != "" {
|
||||
return meta.Filename
|
||||
}
|
||||
return "(unknown)"
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const appleEpochOffset = 978307200
|
||||
|
||||
func appleFromTime(t time.Time) int64 {
|
||||
return t.Add(-time.Duration(appleEpochOffset) * time.Second).UnixNano()
|
||||
}
|
||||
|
||||
func buildTempDB(t *testing.T) string {
|
||||
t.Helper()
|
||||
dbfile := t.TempDir() + "/chat.db"
|
||||
dbConn, err := sql.Open("sqlite", "file:"+dbfile)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
stmts := []string{
|
||||
`CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);`,
|
||||
`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, handle_id INTEGER, text TEXT, date INTEGER, is_from_me INTEGER, service TEXT);`,
|
||||
`CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);`,
|
||||
`CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);`,
|
||||
`CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := dbConn.Exec(s); err != nil {
|
||||
t.Fatalf("exec %s: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
// seed data
|
||||
_, _ = dbConn.Exec(`INSERT INTO chat(ROWID, chat_identifier, display_name, service_name) VALUES (1, '+123', 'Test Chat', 'iMessage')`)
|
||||
_, _ = dbConn.Exec(`INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'Me')`)
|
||||
|
||||
msgs := []struct {
|
||||
id int
|
||||
handle int
|
||||
text string
|
||||
fromMe bool
|
||||
date time.Time
|
||||
}{
|
||||
{1, 1, "hello", false, now.Add(-5 * time.Minute)},
|
||||
{2, 2, "hi back", true, now.Add(-4 * time.Minute)},
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if _, err := dbConn.Exec(`INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service) VALUES (?,?,?,?,?,?)`, m.id, m.handle, m.text, appleFromTime(m.date), boolToInt(m.fromMe), "iMessage"); err != nil {
|
||||
t.Fatalf("insert message: %v", err)
|
||||
}
|
||||
if _, err := dbConn.Exec(`INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)`, m.id); err != nil {
|
||||
t.Fatalf("insert cmj: %v", err)
|
||||
}
|
||||
}
|
||||
_ = dbConn.Close()
|
||||
return dbfile
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestChatsCommandPrintsChats(t *testing.T) {
|
||||
dbPath = buildTempDB(t)
|
||||
cmd := newChatsCmd()
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"--limit", "5"})
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
_ = cmd.Execute()
|
||||
})
|
||||
if strings.TrimSpace(out) == "" {
|
||||
t.Fatalf("expected output, got empty")
|
||||
}
|
||||
if !strings.Contains(out, "Test Chat") {
|
||||
t.Fatalf("expected chat name in output, got %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatsCommandPrintsJSON(t *testing.T) {
|
||||
dbPath = buildTempDB(t)
|
||||
cmd := newChatsCmd()
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"--limit", "5", "--json"})
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute chats: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||
if !scanner.Scan() {
|
||||
t.Fatalf("expected JSON output, got empty")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(scanner.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if payload["name"] != "Test Chat" {
|
||||
t.Fatalf("expected name, got %#v", payload["name"])
|
||||
}
|
||||
if payload["identifier"] != "+123" {
|
||||
t.Fatalf("expected identifier, got %#v", payload["identifier"])
|
||||
}
|
||||
if payload["last_message_at"] == "" {
|
||||
t.Fatalf("expected last_message_at, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryCommandRequiresChatID(t *testing.T) {
|
||||
dbPath = buildTempDB(t)
|
||||
cmd := newHistoryCmd()
|
||||
cmd.SetContext(context.Background())
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when chat-id missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryCommandPrintsMessages(t *testing.T) {
|
||||
dbPath = buildTempDB(t)
|
||||
cmd := newHistoryCmd()
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"--chat-id", "1", "--limit", "10"})
|
||||
|
||||
out := captureOutput(t, func() {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute history: %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(out, "hello") || !strings.Contains(out, "hi back") {
|
||||
t.Fatalf("missing messages in output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func captureOutput(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
fn()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
outBytes, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
return string(outBytes)
|
||||
}
|
||||
@ -11,11 +11,11 @@
|
||||
2. Ensure CI is green on `main`
|
||||
- `pnpm lint`
|
||||
- `pnpm test`
|
||||
- `gh run list --branch main --limit 1`
|
||||
- `pnpm format` (optional, if formatting changes are expected)
|
||||
3. Tag and push
|
||||
- `git tag -a vX.Y.Z -m "vX.Y.Z"`
|
||||
- `git push origin vX.Y.Z`
|
||||
|
||||
## What happens in CI
|
||||
- `.github/workflows/release.yml` runs GoReleaser on tag push.
|
||||
- `.github/workflows/release.yml` builds a macOS release binary and uploads `imsg-macos.zip`.
|
||||
- After assets upload, the workflow updates the GitHub Release body using the `CHANGELOG.md` section for `X.Y.Z`.
|
||||
|
||||
26
go.mod
26
go.mod
@ -1,26 +0,0 @@
|
||||
module github.com/steipete/imsg
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/nyaruka/phonenumbers v1.6.7
|
||||
github.com/spf13/cobra v1.10.2
|
||||
modernc.org/sqlite v1.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.67.2 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
80
go.sum
80
go.sum
@ -1,80 +0,0 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nyaruka/phonenumbers v1.6.7 h1:WmebT8TNEzNaui5QlrGqbccRC6dZkEkYc+MGQoILSSo=
|
||||
github.com/nyaruka/phonenumbers v1.6.7/go.mod h1:7gjs+Lchqm49adhAKB5cdcng5ZXgt6x7Jgvi0ZorUtU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.2 h1:ZbNmly1rcbjhot5jlOZG0q4p5VwFfjwWqZ5rY2xxOXo=
|
||||
modernc.org/libc v1.67.2/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck=
|
||||
modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@ -1,374 +0,0 @@
|
||||
// Package db provides read-only access to the macOS Messages SQLite store.
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// modernc sqlite provides a pure-Go sqlite driver for CI/macOS without CGO.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// AppleEpochOffset is the number of seconds between 1970-01-01 and 2001-01-01.
|
||||
const AppleEpochOffset = 978307200
|
||||
|
||||
// Chat represents a conversation.
|
||||
type Chat struct {
|
||||
ID int64
|
||||
Identifier string
|
||||
Name string
|
||||
Service string
|
||||
LastMessageAt time.Time
|
||||
}
|
||||
|
||||
// Message represents a single message row.
|
||||
type Message struct {
|
||||
RowID int64
|
||||
ChatID int64
|
||||
Sender string
|
||||
Text string
|
||||
Date time.Time
|
||||
IsFromMe bool
|
||||
Service string
|
||||
HandleID sql.NullInt64
|
||||
Attachments int
|
||||
}
|
||||
|
||||
// AttachmentMeta represents attachment metadata for a message.
|
||||
type AttachmentMeta struct {
|
||||
Filename string
|
||||
TransferName string
|
||||
UTI string
|
||||
MimeType string
|
||||
TotalBytes int64
|
||||
IsSticker bool
|
||||
OriginalPath string
|
||||
Missing bool
|
||||
}
|
||||
|
||||
// DefaultPath returns the default location of chat.db for the current user.
|
||||
func DefaultPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "Library", "Messages", "chat.db")
|
||||
}
|
||||
|
||||
// Open opens chat.db read-only with sensible pragmas for concurrent access.
|
||||
func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||
// Note: Do NOT use immutable=1 here - it caches the database state and
|
||||
// prevents seeing new messages (especially threaded replies) added after connection.
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&mode=ro", filepath.Clean(path))
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, enhanceError(err, path)
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, enhanceError(err, path)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// enhanceError adds helpful context for common permission/access errors.
|
||||
func enhanceError(err error, path string) error {
|
||||
errStr := err.Error()
|
||||
|
||||
// SQLite error 14 (SQLITE_CANTOPEN) and "authorization denied" both indicate permission issues
|
||||
if strings.Contains(errStr, "out of memory (14)") ||
|
||||
strings.Contains(errStr, "authorization denied") ||
|
||||
strings.Contains(errStr, "unable to open database") {
|
||||
return fmt.Errorf(`%w
|
||||
|
||||
⚠️ Permission Error: Cannot access Messages database
|
||||
|
||||
The Messages database at %s requires Full Disk Access permission.
|
||||
|
||||
To fix:
|
||||
1. Open System Settings → Privacy & Security → Full Disk Access
|
||||
2. Add your terminal application (Terminal.app, iTerm, etc.)
|
||||
3. Restart your terminal
|
||||
4. Try again
|
||||
|
||||
Note: This is required because macOS protects the Messages database.
|
||||
For more details, see: https://github.com/steipete/imsg#permissions-troubleshooting`, err, path)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListChats returns chats ordered by most recent activity.
|
||||
func ListChats(ctx context.Context, db *sql.DB, limit int) ([]Chat, error) {
|
||||
const q = `
|
||||
SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, c.service_name,
|
||||
MAX(m.date) AS last_date
|
||||
FROM chat c
|
||||
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
|
||||
JOIN message m ON m.ROWID = cmj.message_id
|
||||
GROUP BY c.ROWID
|
||||
ORDER BY last_date DESC
|
||||
LIMIT ?`
|
||||
rows, err := db.QueryContext(ctx, q, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
chats := []Chat{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
name sql.NullString
|
||||
ident sql.NullString
|
||||
svc sql.NullString
|
||||
lastNs sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&id, &name, &ident, &svc, &lastNs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chats = append(chats, Chat{
|
||||
ID: id,
|
||||
Name: name.String,
|
||||
Identifier: ident.String,
|
||||
Service: svc.String,
|
||||
LastMessageAt: appleTime(lastNs.Int64),
|
||||
})
|
||||
}
|
||||
return chats, rows.Err()
|
||||
}
|
||||
|
||||
// MessagesByChat returns recent messages for a chat ordered newest first.
|
||||
func MessagesByChat(ctx context.Context, db *sql.DB, chatID int64, limit int) ([]Message, error) {
|
||||
bodyCol := "''"
|
||||
if columnExists(ctx, db, "message", "attributedBody") {
|
||||
bodyCol = "m.attributedBody"
|
||||
}
|
||||
q := fmt.Sprintf(`
|
||||
SELECT m.ROWID, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
|
||||
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
|
||||
%s as body
|
||||
FROM message m
|
||||
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
WHERE cmj.chat_id = ?
|
||||
ORDER BY m.date DESC
|
||||
LIMIT ?`, bodyCol)
|
||||
|
||||
rows, err := db.QueryContext(ctx, q, chatID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
msgs := []Message{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
rowID int64
|
||||
handleID sql.NullInt64
|
||||
sender sql.NullString
|
||||
text sql.NullString
|
||||
dateNs sql.NullInt64
|
||||
isFromMe bool
|
||||
service sql.NullString
|
||||
attachments int
|
||||
body []byte
|
||||
)
|
||||
if err := rows.Scan(&rowID, &handleID, &sender, &text, &dateNs, &isFromMe, &service, &attachments, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedText := text.String
|
||||
if resolvedText == "" {
|
||||
resolvedText = parseStreamTyped(body)
|
||||
}
|
||||
msgs = append(msgs, Message{
|
||||
RowID: rowID,
|
||||
ChatID: chatID,
|
||||
Sender: sender.String,
|
||||
Text: resolvedText,
|
||||
Date: appleTime(dateNs.Int64),
|
||||
IsFromMe: isFromMe,
|
||||
Service: service.String,
|
||||
HandleID: handleID,
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
return msgs, rows.Err()
|
||||
}
|
||||
|
||||
// AttachmentsByMessage returns attachment metadata for a given message rowid.
|
||||
func AttachmentsByMessage(ctx context.Context, db *sql.DB, messageID int64) ([]AttachmentMeta, error) {
|
||||
const q = `
|
||||
SELECT a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker
|
||||
FROM message_attachment_join maj
|
||||
JOIN attachment a ON a.ROWID = maj.attachment_id
|
||||
WHERE maj.message_id = ?`
|
||||
|
||||
rows, err := db.QueryContext(ctx, q, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []AttachmentMeta
|
||||
for rows.Next() {
|
||||
var meta AttachmentMeta
|
||||
if err := rows.Scan(&meta.Filename, &meta.TransferName, &meta.UTI, &meta.MimeType, &meta.TotalBytes, &meta.IsSticker); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta.OriginalPath, meta.Missing = resolvePath(meta.Filename)
|
||||
out = append(out, meta)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MessagesAfter returns messages after a given rowid (strictly greater).
|
||||
func MessagesAfter(ctx context.Context, db *sql.DB, afterRowID int64, chatIDFilter int64, limit int) ([]Message, error) {
|
||||
bodyCol := "''"
|
||||
if columnExists(ctx, db, "message", "attributedBody") {
|
||||
bodyCol = "m.attributedBody"
|
||||
}
|
||||
base := fmt.Sprintf(`
|
||||
SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
|
||||
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
|
||||
%s as body
|
||||
FROM message m
|
||||
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
||||
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
||||
WHERE m.ROWID > ?`, bodyCol)
|
||||
args := []any{afterRowID}
|
||||
if chatIDFilter != 0 {
|
||||
base += " AND cmj.chat_id = ?"
|
||||
args = append(args, chatIDFilter)
|
||||
}
|
||||
base += " ORDER BY m.ROWID ASC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := db.QueryContext(ctx, base, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
msgs := []Message{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
rowID int64
|
||||
chatID sql.NullInt64
|
||||
handleID sql.NullInt64
|
||||
sender sql.NullString
|
||||
text sql.NullString
|
||||
dateNs sql.NullInt64
|
||||
isFromMe bool
|
||||
service sql.NullString
|
||||
attachments int
|
||||
body []byte
|
||||
)
|
||||
if err := rows.Scan(&rowID, &chatID, &handleID, &sender, &text, &dateNs, &isFromMe, &service, &attachments, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedText := text.String
|
||||
if resolvedText == "" {
|
||||
resolvedText = parseStreamTyped(body)
|
||||
}
|
||||
msgs = append(msgs, Message{
|
||||
RowID: rowID,
|
||||
ChatID: chatID.Int64,
|
||||
Sender: sender.String,
|
||||
Text: resolvedText,
|
||||
Date: appleTime(dateNs.Int64),
|
||||
IsFromMe: isFromMe,
|
||||
Service: service.String,
|
||||
HandleID: handleID,
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
return msgs, rows.Err()
|
||||
}
|
||||
|
||||
// MaxRowID returns the current highest message rowid.
|
||||
func MaxRowID(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
var maxID sql.NullInt64
|
||||
if err := db.QueryRowContext(ctx, "SELECT MAX(ROWID) FROM message").Scan(&maxID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return maxID.Int64, nil
|
||||
}
|
||||
|
||||
func appleTime(ns int64) time.Time {
|
||||
// ns is nanoseconds since 2001-01-01 UTC
|
||||
return time.Unix(0, ns).Add(time.Duration(AppleEpochOffset) * time.Second)
|
||||
}
|
||||
|
||||
func resolvePath(p string) (string, bool) {
|
||||
if p == "" {
|
||||
return "", true
|
||||
}
|
||||
if strings.HasPrefix(p, "~") {
|
||||
home, _ := os.UserHomeDir()
|
||||
p = strings.Replace(p, "~", home, 1)
|
||||
}
|
||||
exists := false
|
||||
if info, err := os.Stat(p); err == nil && !info.IsDir() {
|
||||
exists = true
|
||||
}
|
||||
return p, !exists
|
||||
}
|
||||
|
||||
// parseStreamTyped attempts to recover plain text from an attributedBody typedstream blob.
|
||||
// It looks for the known start/end sentinels and decodes the UTF-8 payload.
|
||||
func parseStreamTyped(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
const (
|
||||
startA = 0x01
|
||||
startB = 0x2b
|
||||
endA = 0x86
|
||||
endB = 0x84
|
||||
)
|
||||
|
||||
// Trim to data between markers if present
|
||||
if idx := bytes.Index(body, []byte{startA, startB}); idx >= 0 && idx+2 < len(body) {
|
||||
body = body[idx+2:]
|
||||
}
|
||||
if idx := bytes.Index(body, []byte{endA, endB}); idx >= 0 {
|
||||
body = body[:idx]
|
||||
}
|
||||
|
||||
// Decode, tolerating invalid sequences
|
||||
out := string(bytes.ToValidUTF8(body, nil))
|
||||
// Drop leading control chars/newlines that often prefix typedstream payloads
|
||||
out = strings.TrimLeftFunc(out, func(r rune) bool { return r < 32 })
|
||||
return out
|
||||
}
|
||||
|
||||
// columnExists checks if a column is present on a table, used for older schemas.
|
||||
func columnExists(ctx context.Context, db *sql.DB, table, column string) bool {
|
||||
rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype sql.NullString
|
||||
notnull sql.NullInt64
|
||||
dflt sql.NullString
|
||||
pk sql.NullInt64
|
||||
)
|
||||
// pragma table_info columns: cid,name,type,notnull,dflt_value,pk
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(name, column) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -1,282 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func appleFromTime(t time.Time) int64 {
|
||||
return t.Add(-time.Duration(AppleEpochOffset) * time.Second).UnixNano()
|
||||
}
|
||||
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:imsgtest?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
stmts := []string{
|
||||
`CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);`,
|
||||
`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, handle_id INTEGER, text TEXT, date INTEGER, is_from_me INTEGER, service TEXT);`,
|
||||
`CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);`,
|
||||
`CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);`,
|
||||
`CREATE TABLE attachment (ROWID INTEGER PRIMARY KEY, filename TEXT, transfer_name TEXT, uti TEXT, mime_type TEXT, total_bytes INTEGER, is_sticker INTEGER);`,
|
||||
`CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("exec %s: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
// sample data
|
||||
_, _ = db.Exec(`INSERT INTO chat(ROWID, chat_identifier, display_name, service_name) VALUES (1, '+123', 'Test Chat', 'iMessage')`)
|
||||
_, _ = db.Exec(`INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'Me')`)
|
||||
|
||||
msgs := []struct {
|
||||
id int
|
||||
handle int
|
||||
text string
|
||||
fromMe bool
|
||||
date time.Time
|
||||
attachs int
|
||||
}{
|
||||
{1, 1, "hello", false, now.Add(-10 * time.Minute), 0},
|
||||
{2, 2, "hi back", true, now.Add(-9 * time.Minute), 1},
|
||||
{3, 1, "photo", false, now.Add(-1 * time.Minute), 0},
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if _, err := db.Exec(`INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service) VALUES (?,?,?,?,?,?)`, m.id, m.handle, m.text, appleFromTime(m.date), boolToInt(m.fromMe), "iMessage"); err != nil {
|
||||
t.Fatalf("insert message: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)`, m.id); err != nil {
|
||||
t.Fatalf("insert cmj: %v", err)
|
||||
}
|
||||
for i := 0; i < m.attachs; i++ {
|
||||
_, _ = db.Exec(`INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker) VALUES (?,?,?, ?, ?, ?, ?)`, i+1, "~/Library/Messages/Attachments/test.dat", "test.dat", "public.data", "application/octet-stream", 123, 0)
|
||||
_, _ = db.Exec(`INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (?, ?)`, m.id, i+1)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newTestDBWithBody(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:imsgtestbody?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
stmts := []string{
|
||||
`CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);`,
|
||||
`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, handle_id INTEGER, text TEXT, attributedBody BLOB, date INTEGER, is_from_me INTEGER, service TEXT);`,
|
||||
`CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);`,
|
||||
`CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);`,
|
||||
`CREATE TABLE attachment (ROWID INTEGER PRIMARY KEY, filename TEXT, transfer_name TEXT, uti TEXT, mime_type TEXT, total_bytes INTEGER, is_sticker INTEGER);`,
|
||||
`CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("exec %s: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, _ = db.Exec(`INSERT INTO chat(ROWID, chat_identifier, display_name, service_name) VALUES (1, '+123', 'Test Chat', 'iMessage')`)
|
||||
_, _ = db.Exec(`INSERT INTO handle(ROWID, id) VALUES (1, '+123')`)
|
||||
|
||||
body := bodyBlob("fallback text")
|
||||
if _, err := db.Exec(`INSERT INTO message(ROWID, handle_id, text, attributedBody, date, is_from_me, service) VALUES (?,?,?,?,?,?,?)`, 1, 1, nil, body, appleFromTime(now), 0, "iMessage"); err != nil {
|
||||
t.Fatalf("insert message: %v", err)
|
||||
}
|
||||
_, _ = db.Exec(`INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)`)
|
||||
return db
|
||||
}
|
||||
|
||||
func newTempDiskDB(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp("", "imsg-disk-*.db")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
path := f.Name()
|
||||
_ = f.Close()
|
||||
cleanup := func() { _ = os.Remove(path) }
|
||||
return path, cleanup
|
||||
}
|
||||
|
||||
func bodyBlob(s string) []byte {
|
||||
return append(append([]byte{0x01, 0x2b}, []byte(s)...), 0x86, 0x84)
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestOpenSeesLiveUpdates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path, cleanup := newTempDiskDB(t)
|
||||
defer cleanup()
|
||||
|
||||
writer, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&mode=rwc", filepath.Clean(path)))
|
||||
if err != nil {
|
||||
t.Fatalf("open writer: %v", err)
|
||||
}
|
||||
defer func() { _ = writer.Close() }()
|
||||
|
||||
if _, err := writer.Exec(`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, text TEXT)`); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if _, err := writer.Exec(`INSERT INTO message(text) VALUES ('first')`); err != nil {
|
||||
t.Fatalf("insert first: %v", err)
|
||||
}
|
||||
|
||||
reader, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
reader.SetMaxOpenConns(1)
|
||||
reader.SetMaxIdleConns(1)
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
count := func(db *sql.DB) int {
|
||||
var c int
|
||||
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM message").Scan(&c); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
if got := count(reader); got != 1 {
|
||||
t.Fatalf("expected initial count 1, got %d", got)
|
||||
}
|
||||
|
||||
if _, err := writer.Exec(`INSERT INTO message(text) VALUES ('second')`); err != nil {
|
||||
t.Fatalf("insert second: %v", err)
|
||||
}
|
||||
|
||||
if got := count(reader); got != 2 {
|
||||
t.Fatalf("expected reader to see new rows, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestDB(t)
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
chats, err := ListChats(ctx, store, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChats: %v", err)
|
||||
}
|
||||
if len(chats) != 1 {
|
||||
t.Fatalf("expected 1 chat, got %d", len(chats))
|
||||
}
|
||||
if chats[0].Identifier != "+123" {
|
||||
t.Fatalf("unexpected identifier %s", chats[0].Identifier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesByChat(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestDB(t)
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
msgs, err := MessagesByChat(ctx, store, 1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("MessagesByChat: %v", err)
|
||||
}
|
||||
if len(msgs) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Attachments != 0 {
|
||||
t.Fatalf("expected newest message attachments 0, got %d", msgs[0].Attachments)
|
||||
}
|
||||
if !msgs[1].IsFromMe {
|
||||
t.Fatalf("expected second message from me")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesAfter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestDB(t)
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
msgs, err := MessagesAfter(ctx, store, 1, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("MessagesAfter: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 messages after rowid 1, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].RowID != 2 {
|
||||
t.Fatalf("expected first rowid 2, got %d", msgs[0].RowID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesByChatUsesAttributedBodyFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestDBWithBody(t)
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
msgs, err := MessagesByChat(ctx, store, 1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("MessagesByChat fallback: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Text != "fallback text" {
|
||||
t.Fatalf("expected fallback text, got %q", msgs[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesAfterUsesAttributedBodyFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestDBWithBody(t)
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
msgs, err := MessagesAfter(ctx, store, 0, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("MessagesAfter fallback: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Text != "fallback text" {
|
||||
t.Fatalf("expected fallback text, got %q", msgs[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamTypedTrimsControls(t *testing.T) {
|
||||
blob := []byte{0x00, 0x01, 0x2b, '\n', 'H', 'i', 0x86, 0x84, '\r'}
|
||||
got := parseStreamTyped(blob)
|
||||
if got != "Hi" {
|
||||
t.Fatalf("expected Hi, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentsByMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestDB(t)
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
metas, err := AttachmentsByMessage(ctx, store, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachmentsByMessage: %v", err)
|
||||
}
|
||||
if len(metas) != 1 {
|
||||
t.Fatalf("expected 1 attachment, got %d", len(metas))
|
||||
}
|
||||
if metas[0].MimeType != "application/octet-stream" {
|
||||
t.Fatalf("unexpected mime %s", metas[0].MimeType)
|
||||
}
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
// Package send dispatches outbound Messages via AppleScript.
|
||||
package send
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/steipete/imsg/internal/util"
|
||||
)
|
||||
|
||||
// Service represents the transport preference.
|
||||
type Service string
|
||||
|
||||
// Service values control which Messages transport is used.
|
||||
const (
|
||||
// ServiceAuto picks iMessage when available, otherwise SMS.
|
||||
ServiceAuto Service = "auto"
|
||||
// ServiceIMessage forces iMessage.
|
||||
ServiceIMessage Service = "imessage"
|
||||
// ServiceSMS forces SMS/Text Relay.
|
||||
ServiceSMS Service = "sms"
|
||||
)
|
||||
|
||||
// Options controls how a message is sent.
|
||||
type Options struct {
|
||||
Recipient string
|
||||
Text string
|
||||
AttachmentPath string
|
||||
Service Service
|
||||
Region string
|
||||
}
|
||||
|
||||
// Send dispatches a message via Messages.app using AppleScript.
|
||||
func Send(ctx context.Context, opts Options) error {
|
||||
if opts.Region == "" {
|
||||
opts.Region = "US"
|
||||
}
|
||||
opts.Recipient = util.NormalizeE164(opts.Recipient, opts.Region)
|
||||
svc := opts.Service
|
||||
if svc == "" {
|
||||
svc = ServiceAuto
|
||||
}
|
||||
|
||||
attachFlag := "0"
|
||||
if opts.AttachmentPath != "" {
|
||||
attachFlag = "1"
|
||||
}
|
||||
|
||||
script := appleScript()
|
||||
args := []string{"-l", "AppleScript", "-e", script, opts.Recipient, opts.Text, string(svc), opts.AttachmentPath, attachFlag}
|
||||
cmd := exec.CommandContext(ctx, "osascript", args...)
|
||||
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("osascript failed: %w (output: %s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appleScript() string {
|
||||
// Items: 1 recipient, 2 text, 3 service, 4 file path, 5 useAttachment
|
||||
return `on run argv
|
||||
set theRecipient to item 1 of argv
|
||||
set theMessage to item 2 of argv
|
||||
set theService to item 3 of argv
|
||||
set theFilePath to item 4 of argv
|
||||
set useAttachment to item 5 of argv
|
||||
|
||||
tell application "Messages"
|
||||
if theService is "sms" then
|
||||
set targetService to first service whose service type is SMS
|
||||
else
|
||||
set targetService to first service whose service type is iMessage
|
||||
end if
|
||||
|
||||
set targetBuddy to buddy theRecipient of targetService
|
||||
if theMessage is not "" then
|
||||
send theMessage to targetBuddy
|
||||
end if
|
||||
if useAttachment is "1" then
|
||||
-- Messages expects an alias; the coercion prevents "Can't get POSIX file" errors.
|
||||
set theFile to POSIX file theFilePath as alias
|
||||
send theFile to targetBuddy
|
||||
end if
|
||||
end tell
|
||||
end run`
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
package send
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppleScriptIncludesMessages(t *testing.T) {
|
||||
s := appleScript()
|
||||
if s == "" {
|
||||
t.Fatal("empty script")
|
||||
}
|
||||
if !containsAll(s, []string{"Messages", "send", "buddy"}) {
|
||||
t.Fatalf("script missing expected tokens: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppleScriptCoercesAttachmentAlias(t *testing.T) {
|
||||
s := appleScript()
|
||||
if !strings.Contains(s, "POSIX file theFilePath as alias") {
|
||||
t.Fatalf("script should coerce attachment to alias to satisfy Messages: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(s string, parts []string) bool {
|
||||
for _, p := range parts {
|
||||
if !strings.Contains(s, p) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
// Package util contains helper utilities such as phone normalization.
|
||||
package util
|
||||
|
||||
import "github.com/nyaruka/phonenumbers"
|
||||
|
||||
// NormalizeE164 attempts to format the number to E.164 using the provided region (e.g. "US").
|
||||
// If parsing fails, it returns the original input.
|
||||
func NormalizeE164(input, region string) string {
|
||||
num, err := phonenumbers.Parse(input, region)
|
||||
if err != nil || !phonenumbers.IsValidNumber(num) {
|
||||
return input
|
||||
}
|
||||
return phonenumbers.Format(num, phonenumbers.E164)
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeE164(t *testing.T) {
|
||||
got := NormalizeE164("(415) 555-1212", "US")
|
||||
want := "+14155551212"
|
||||
if got != want {
|
||||
t.Fatalf("want %s got %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeE164Fallback(t *testing.T) {
|
||||
raw := "not-a-number"
|
||||
if got := NormalizeE164(raw, "US"); got != raw {
|
||||
t.Fatalf("expected fallback to raw, got %s", got)
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
// Package watch streams new messages from the Messages database.
|
||||
package watch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/imsg/internal/db"
|
||||
)
|
||||
|
||||
// Run polls the database and invokes handler for each new message.
|
||||
func Run(ctx context.Context, store *sql.DB, chatID int64, startRowID int64, interval time.Duration, handler func(db.Message)) error {
|
||||
cursor := startRowID
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
msgs, err := db.MessagesAfter(ctx, store, cursor, chatID, 100)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range msgs {
|
||||
handler(m)
|
||||
if m.RowID > cursor {
|
||||
cursor = m.RowID
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/imsg/internal/db"
|
||||
)
|
||||
|
||||
func TestRunDeliversMessages(t *testing.T) {
|
||||
store := buildDB(t)
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var seen int
|
||||
err := Run(ctx, store, 0, 0, 50*time.Millisecond, func(_ db.Message) {
|
||||
seen++
|
||||
})
|
||||
if err != context.DeadlineExceeded {
|
||||
t.Fatalf("expected deadline exceeded, got %v", err)
|
||||
}
|
||||
if seen == 0 {
|
||||
t.Fatal("expected to see at least one message")
|
||||
}
|
||||
}
|
||||
|
||||
func buildDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dbConn, err := sql.Open("sqlite", "file:watchtest?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
stmts := []string{
|
||||
`CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);`,
|
||||
`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, handle_id INTEGER, text TEXT, date INTEGER, is_from_me INTEGER, service TEXT);`,
|
||||
`CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);`,
|
||||
`CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);`,
|
||||
`CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := dbConn.Exec(s); err != nil {
|
||||
t.Fatalf("exec %s: %v", s, err)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
apple := now.Add(-time.Duration(db.AppleEpochOffset) * time.Second).UnixNano()
|
||||
_, _ = dbConn.Exec(`INSERT INTO chat(ROWID, chat_identifier) VALUES (1, '+1')`)
|
||||
_, _ = dbConn.Exec(`INSERT INTO handle(ROWID, id) VALUES (1, '+1')`)
|
||||
_, _ = dbConn.Exec(`INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service) VALUES (1,1,'hi', ?,0,'iMessage')`, apple)
|
||||
_, _ = dbConn.Exec(`INSERT INTO chat_message_join(chat_id, message_id) VALUES (1,1)`)
|
||||
return dbConn
|
||||
}
|
||||
13
package.json
13
package.json
@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "imsg",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"imsg": "go run ./cmd/imsg",
|
||||
"deps:patch": "swift package resolve && scripts/patch-deps.sh",
|
||||
"imsg": "pnpm -s deps:patch && swift run imsg",
|
||||
"start": "pnpm imsg",
|
||||
"format": "gofmt -w cmd internal",
|
||||
"lint": "golangci-lint run --out-format=colored-line-number",
|
||||
"test": "go test ./...",
|
||||
"build": "mkdir -p bin && go build -o bin/imsg ./cmd/imsg"
|
||||
"format": "swift format --in-place --recursive Sources Tests",
|
||||
"lint": "swift format lint --recursive Sources Tests && swiftlint",
|
||||
"test": "pnpm -s deps:patch && swift test",
|
||||
"build": "pnpm -s deps:patch && mkdir -p bin && swift build -c release --product imsg && cp .build/release/imsg bin/imsg"
|
||||
}
|
||||
}
|
||||
|
||||
23
scripts/patch-deps.sh
Executable file
23
scripts/patch-deps.sh
Executable file
@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SQLITE_PACKAGE=".build/checkouts/SQLite.swift/Package.swift"
|
||||
|
||||
if [[ ! -f "$SQLITE_PACKAGE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
chmod u+w "$SQLITE_PACKAGE" || true
|
||||
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
path = Path('.build/checkouts/SQLite.swift/Package.swift')
|
||||
text = path.read_text()
|
||||
if 'PrivacyInfo.xcprivacy' in text:
|
||||
raise SystemExit(0)
|
||||
needle = 'exclude: [\n "Info.plist"\n ]'
|
||||
replacement = 'exclude: [\n "Info.plist",\n "PrivacyInfo.xcprivacy"\n ]'
|
||||
if needle in text:
|
||||
text = text.replace(needle, replacement)
|
||||
path.write_text(text)
|
||||
PY
|
||||
Loading…
Reference in New Issue
Block a user