diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eafe00b..916bdd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af4b560..c1e0cd7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index aaadf73..aec4ae4 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,12 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# SwiftPM +.build/ +.swiftpm/ +Package.resolved +*.xcodeproj + +# Build artifacts +bin/ diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index cf0f97a..0000000 --- a/.golangci.yml +++ /dev/null @@ -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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 664d789..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -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 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..809e855 --- /dev/null +++ b/.swiftlint.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8969a5a..e16a9fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..9609ffa --- /dev/null +++ b/Package.swift @@ -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", + ] + ), + ] +) diff --git a/README.md b/README.md index b974e66..c23c021 100644 --- a/README.md +++ b/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 [--limit 50] [--attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]` -- `imsg watch [--chat-id ] [--since-rowid ] [--interval 2s] [--attachments] [--participants …] [--start …] [--end …] [--json]` +- `imsg watch [--chat-id ] [--since-rowid ] [--debounce 250ms] [--attachments] [--participants …] [--start …] [--end …] [--json]` - `imsg send --to [--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. diff --git a/Sources/IMsgCore/AttachmentResolver.swift b/Sources/IMsgCore/AttachmentResolver.swift new file mode 100644 index 0000000..000bbd9 --- /dev/null +++ b/Sources/IMsgCore/AttachmentResolver.swift @@ -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)" + } +} diff --git a/Sources/IMsgCore/Errors.swift b/Sources/IMsgCore/Errors.swift new file mode 100644 index 0000000..fd33c3e --- /dev/null +++ b/Sources/IMsgCore/Errors.swift @@ -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)" + } + } +} diff --git a/Sources/IMsgCore/ISO8601.swift b/Sources/IMsgCore/ISO8601.swift new file mode 100644 index 0000000..ec5b323 --- /dev/null +++ b/Sources/IMsgCore/ISO8601.swift @@ -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) + } +} diff --git a/Sources/IMsgCore/MessageFilter.swift b/Sources/IMsgCore/MessageFilter.swift new file mode 100644 index 0000000..5e97594 --- /dev/null +++ b/Sources/IMsgCore/MessageFilter.swift @@ -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 + } +} diff --git a/Sources/IMsgCore/MessageSender.swift b/Sources/IMsgCore/MessageSender.swift new file mode 100644 index 0000000..6ef6c5b --- /dev/null +++ b/Sources/IMsgCore/MessageSender.swift @@ -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) + } + } +} diff --git a/Sources/IMsgCore/MessageStore.swift b/Sources/IMsgCore/MessageStore.swift new file mode 100644 index 0000000..37f7b36 --- /dev/null +++ b/Sources/IMsgCore/MessageStore.swift @@ -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() + 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(_ 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() + } +} diff --git a/Sources/IMsgCore/MessageWatcher.swift b/Sources/IMsgCore/MessageWatcher.swift new file mode 100644 index 0000000..ffecb8d --- /dev/null +++ b/Sources/IMsgCore/MessageWatcher.swift @@ -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 { + 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.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.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) + } + } +} diff --git a/Sources/IMsgCore/Models.swift b/Sources/IMsgCore/Models.swift new file mode 100644 index 0000000..e3820e0 --- /dev/null +++ b/Sources/IMsgCore/Models.swift @@ -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 + } +} diff --git a/Sources/IMsgCore/PhoneNumberNormalizer.swift b/Sources/IMsgCore/PhoneNumberNormalizer.swift new file mode 100644 index 0000000..596ba14 --- /dev/null +++ b/Sources/IMsgCore/PhoneNumberNormalizer.swift @@ -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 + } + } +} diff --git a/Sources/IMsgCore/TypedStreamParser.swift b/Sources/IMsgCore/TypedStreamParser.swift new file mode 100644 index 0000000..d05c461 --- /dev/null +++ b/Sources/IMsgCore/TypedStreamParser.swift @@ -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[.. String { + var scalars = unicodeScalars + while let first = scalars.first, + CharacterSet.controlCharacters.contains(first) || first == "\n" || first == "\r" + { + scalars.removeFirst() + } + return String(String.UnicodeScalarView(scalars)) + } +} diff --git a/Sources/imsg/AttachmentDisplay.swift b/Sources/imsg/AttachmentDisplay.swift new file mode 100644 index 0000000..c613eb1 --- /dev/null +++ b/Sources/imsg/AttachmentDisplay.swift @@ -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)" +} diff --git a/Sources/imsg/CommandRouter.swift b/Sources/imsg/CommandRouter.swift new file mode 100644 index 0000000..806386e --- /dev/null +++ b/Sources/imsg/CommandRouter.swift @@ -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 + } +} diff --git a/Sources/imsg/CommandSignatures.swift b/Sources/imsg/CommandSignatures.swift new file mode 100644 index 0000000..8bf8ef5 --- /dev/null +++ b/Sources/imsg/CommandSignatures.swift @@ -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() + } +} diff --git a/Sources/imsg/CommandSpec.swift b/Sources/imsg/CommandSpec.swift new file mode 100644 index 0000000..7dea258 --- /dev/null +++ b/Sources/imsg/CommandSpec.swift @@ -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 + ) + } +} diff --git a/Sources/imsg/Commands/ChatsCommand.swift b/Sources/imsg/Commands/ChatsCommand.swift new file mode 100644 index 0000000..dae76dc --- /dev/null +++ b/Sources/imsg/Commands/ChatsCommand.swift @@ -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)") + } + } +} diff --git a/Sources/imsg/Commands/HistoryCommand.swift b/Sources/imsg/Commands/HistoryCommand.swift new file mode 100644 index 0000000..0839731 --- /dev/null +++ b/Sources/imsg/Commands/HistoryCommand.swift @@ -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)))" + ) + } + } + } + } +} diff --git a/Sources/imsg/Commands/SendCommand.swift b/Sources/imsg/Commands/SendCommand.swift new file mode 100644 index 0000000..c63bd85 --- /dev/null +++ b/Sources/imsg/Commands/SendCommand.swift @@ -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") + } + } +} diff --git a/Sources/imsg/Commands/WatchCommand.swift b/Sources/imsg/Commands/WatchCommand.swift new file mode 100644 index 0000000..9887b4c --- /dev/null +++ b/Sources/imsg/Commands/WatchCommand.swift @@ -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)))" + ) + } + } + } + } +} diff --git a/Sources/imsg/DurationParser.swift b/Sources/imsg/DurationParser.swift new file mode 100644 index 0000000..79693ef --- /dev/null +++ b/Sources/imsg/DurationParser.swift @@ -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 + } +} diff --git a/Sources/imsg/HelpPrinter.swift b/Sources/imsg/HelpPrinter.swift new file mode 100644 index 0000000..33987b5 --- /dev/null +++ b/Sources/imsg/HelpPrinter.swift @@ -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) [options]") + Swift.print("") + Swift.print("Commands:") + for command in commands { + Swift.print(" \(command.name)\t\(command.abstract)") + } + Swift.print("") + Swift.print("Run '\(rootName) --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 ? " " : "" + return parts.joined(separator: ", ") + suffix + } +} diff --git a/Sources/imsg/IMsgCLI.swift b/Sources/imsg/IMsgCLI.swift new file mode 100644 index 0000000..ddcf254 --- /dev/null +++ b/Sources/imsg/IMsgCLI.swift @@ -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) + } + } +} diff --git a/Sources/imsg/JSONLines.swift b/Sources/imsg/JSONLines.swift new file mode 100644 index 0000000..2245353 --- /dev/null +++ b/Sources/imsg/JSONLines.swift @@ -0,0 +1,16 @@ +import Foundation + +enum JSONLines { + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + return encoder + }() + + static func print(_ value: T) throws { + let data = try encoder.encode(value) + if let line = String(data: data, encoding: .utf8) { + Swift.print(line) + } + } +} diff --git a/Sources/imsg/OutputModels.swift b/Sources/imsg/OutputModels.swift new file mode 100644 index 0000000..24fb63d --- /dev/null +++ b/Sources/imsg/OutputModels.swift @@ -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) + } +} diff --git a/Sources/imsg/ParsedValues+Decode.swift b/Sources/imsg/ParsedValues+Decode.swift new file mode 100644 index 0000000..abe906b --- /dev/null +++ b/Sources/imsg/ParsedValues+Decode.swift @@ -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] + } +} diff --git a/Sources/imsg/RuntimeOptions.swift b/Sources/imsg/RuntimeOptions.swift new file mode 100644 index 0000000..337d804 --- /dev/null +++ b/Sources/imsg/RuntimeOptions.swift @@ -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 + } +} diff --git a/Tests/IMsgCoreTests/MessageStoreTests.swift b/Tests/IMsgCoreTests/MessageStoreTests.swift new file mode 100644 index 0000000..d4395f7 --- /dev/null +++ b/Tests/IMsgCoreTests/MessageStoreTests.swift @@ -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") +} diff --git a/cmd/imsg/main.go b/cmd/imsg/main.go deleted file mode 100644 index e23e975..0000000 --- a/cmd/imsg/main.go +++ /dev/null @@ -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)" -} diff --git a/cmd/imsg/main_test.go b/cmd/imsg/main_test.go deleted file mode 100644 index a99b912..0000000 --- a/cmd/imsg/main_test.go +++ /dev/null @@ -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) -} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3b5389a..96a0268 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -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`. diff --git a/go.mod b/go.mod deleted file mode 100644 index 0631ab9..0000000 --- a/go.mod +++ /dev/null @@ -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 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 9887dbb..0000000 --- a/go.sum +++ /dev/null @@ -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= diff --git a/imsg b/imsg deleted file mode 100755 index 751e8f2..0000000 Binary files a/imsg and /dev/null differ diff --git a/internal/db/db.go b/internal/db/db.go deleted file mode 100644 index 3d4a849..0000000 --- a/internal/db/db.go +++ /dev/null @@ -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 -} diff --git a/internal/db/db_test.go b/internal/db/db_test.go deleted file mode 100644 index 0748bbc..0000000 --- a/internal/db/db_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/send/send.go b/internal/send/send.go deleted file mode 100644 index 8261aa1..0000000 --- a/internal/send/send.go +++ /dev/null @@ -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` -} diff --git a/internal/send/send_test.go b/internal/send/send_test.go deleted file mode 100644 index 97bc331..0000000 --- a/internal/send/send_test.go +++ /dev/null @@ -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 -} diff --git a/internal/util/number.go b/internal/util/number.go deleted file mode 100644 index 72156fb..0000000 --- a/internal/util/number.go +++ /dev/null @@ -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) -} diff --git a/internal/util/number_test.go b/internal/util/number_test.go deleted file mode 100644 index aba1b58..0000000 --- a/internal/util/number_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/watch/watch.go b/internal/watch/watch.go deleted file mode 100644 index c81c1e2..0000000 --- a/internal/watch/watch.go +++ /dev/null @@ -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): - } - } - } -} diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go deleted file mode 100644 index 984912a..0000000 --- a/internal/watch/watch_test.go +++ /dev/null @@ -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 -} diff --git a/package.json b/package.json index 3988815..89c934b 100644 --- a/package.json +++ b/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" } } diff --git a/scripts/patch-deps.sh b/scripts/patch-deps.sh new file mode 100755 index 0000000..e337cfc --- /dev/null +++ b/scripts/patch-deps.sh @@ -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