feat: ship typing/rpc updates and prep 0.5.0
This commit is contained in:
parent
a2c0865a54
commit
38fa96a3ee
47
CHANGELOG.md
47
CHANGELOG.md
@ -1,17 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.1 - Unreleased
|
||||
## 0.5.0 - Unreleased
|
||||
|
||||
- feat: add typing indicator command + RPC methods with stricter validation (#41, thanks @kohoj)
|
||||
- feat: `--reactions` flag for `watch` command to include tapback events in stream (#26)
|
||||
- feat: reaction events include `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, `reacted_to_guid` fields
|
||||
- feat: `imsg react` command to send tapback reactions via UI automation (#24)
|
||||
- fix: prefer handle sends when chat identifier is a direct handle
|
||||
- fix: apply history filters before limit (#20, thanks @tommybananas)
|
||||
- fix: flush watch output immediately when stdout is buffered (#43, thanks @ccaum)
|
||||
- feat: reaction events include `is_reaction`, `reaction_type`, `reaction_emoji`, `is_reaction_add`, `reacted_to_guid` fields
|
||||
- feat: add `include_reactions` toggle to `watch.subscribe` RPC and extend RPC reaction metadata fields
|
||||
- feat: include `thread_originator_guid` in message output (#39, thanks @ruthmade)
|
||||
- feat: expose `destination_caller_id` in message output (#29, thanks @commander-alexander)
|
||||
- fix: apply history filters before limit (#20, thanks @tommybananas)
|
||||
- fix: flush watch output immediately when stdout is buffered (#43, thanks @ccaum)
|
||||
- fix: prefer handle sends when chat identifier is a direct handle
|
||||
- fix: detect groups from `;+;` prefix in guid/identifier for RPC payloads (#42, thanks @shivshil)
|
||||
- feat: add typing indicator command + RPC methods with stricter validation (#41, thanks @kohoj)
|
||||
- fix: harden `react` AppleScript execution and tighten group-handle detection paths
|
||||
- refactor: consolidate schema detection, stdout writing, and message/RPC payload mapping paths
|
||||
- test: split command test suites by domain and align group-handle expectations
|
||||
- docs: update changelog entries as typing/reaction work landed
|
||||
- chore: bump unreleased version marker to `0.5.0`
|
||||
|
||||
## 0.4.0 - 2026-01-07
|
||||
- feat: surface audio message transcriptions (thanks @antons)
|
||||
@ -22,8 +28,13 @@
|
||||
- ci: switch to make-based lint/test/build
|
||||
- docs: update build/test/release instructions
|
||||
- chore: replace pnpm scripts with make targets
|
||||
- refactor: split message-store query paths for clearer message retrieval internals
|
||||
- test: keep attachment tests isolated from user attachment directories
|
||||
- fix: address attachment upload error handling regressions
|
||||
- docs: refine changelog ordering/notes for patch-deps and 0.4.0 prep
|
||||
- chore: version housekeeping for the 0.3.1 -> 0.4.0 release transition
|
||||
|
||||
## 0.3.0 - 2026-01-02
|
||||
## 0.3.0 - 2026-01-03
|
||||
- feat: JSON-RPC server over stdin/stdout (`imsg rpc`) with chats, history, watch, and send
|
||||
- feat: group chat metadata in JSON/RPC output (participants, chat identifiers, is_group)
|
||||
- feat: tapback + emoji reaction support in JSON output (#8) — thanks @tylerwince
|
||||
@ -35,10 +46,19 @@
|
||||
- docs: add RPC + group chat notes
|
||||
- test: expand RPC/command coverage, add reaction fixtures, drop unused stdout helper
|
||||
- test: add coverage for sender fallback
|
||||
- feat: add IMCore send mode and IMCore-based reaction send path
|
||||
- fix: stabilize IMCore send and sender fallback behavior
|
||||
- change: remove private API send mode in favor of IMCore path
|
||||
- build: add/harden notarized release script checks
|
||||
- chore: update copyright year to 2026
|
||||
- test: split message-store fixtures for more isolated reaction/sender coverage
|
||||
- docs: maintain unreleased/release changelog staging for 0.2.2/0.3.0
|
||||
- chore: release/prepare metadata updates for 0.3.0 and 0.3.1
|
||||
|
||||
## 0.2.1 - 2025-12-30
|
||||
- fix: avoid crash parsing long attributed bodies (>256 bytes) (thanks @tommybananas)
|
||||
- docs: prepare/backfill changelog notes for 0.2.1
|
||||
- chore: bump release version metadata to 0.2.1
|
||||
|
||||
## 0.2.0 - 2025-12-28
|
||||
- feat: Swift 6 rewrite with reusable IMsgCore library target
|
||||
@ -46,17 +66,25 @@
|
||||
- feat: event-driven watch using filesystem events (no polling)
|
||||
- feat: SQLite.swift + PhoneNumberKit + NSAppleScript integration
|
||||
- fix: ship PhoneNumberKit resource bundle for CLI installs
|
||||
- fix: patch/avoid PhoneNumberKit bundle lookup crashes across install layouts
|
||||
- fix: embed Info.plist + AppleEvents entitlement for automation prompts
|
||||
- fix: fall back to osascript when AppleEvents permission is missing
|
||||
- fix: retry osascript on transient unknown AppleScript errors
|
||||
- fix: decode length-prefixed attributed bodies for sent messages
|
||||
- fix: resolve CLI version detection for symlinked/bundle installs
|
||||
- chore: SwiftLint + swift-format linting
|
||||
- change: JSON attachment keys now snake_case
|
||||
- deprecation note: `--interval` replaced by `--debounce` (no compatibility)
|
||||
- docs: add release process documentation
|
||||
- ci: publish release notes from changelog and harden extraction
|
||||
- chore: reset release versioning during Swift rewrite stabilization
|
||||
- chore: version.env + generated version source for `--version`
|
||||
|
||||
## 0.1.1 - 2025-12-27
|
||||
- feat: `imsg chats --json`
|
||||
- fix: drop sqlite `immutable` flag so new messages/replies show up (thanks @zleman1593)
|
||||
- test: add/stabilize live update regression coverage
|
||||
- docs: add unreleased entry and backfill/prepare changelog history
|
||||
- chore: update go dependencies
|
||||
|
||||
## 0.1.0 - 2025-12-20
|
||||
@ -66,3 +94,8 @@
|
||||
- feat: `imsg send` text and/or one attachment (`--service imessage|sms|auto`, `--region`)
|
||||
- feat: attachment metadata output (`--attachments`) incl. resolved path + missing flag
|
||||
- fix: clearer Full Disk Access error for `~/Library/Messages/chat.db`
|
||||
- fix: coerce attachment aliasing in message parsing
|
||||
- build: add GoReleaser workflow and tag backfill support
|
||||
- ci: harden Go/lint environment setup and align toolchain/linter installation
|
||||
- docs: add repository guidelines/package docs and initial README polish
|
||||
- chore: bootstrap initial project/release scaffolding and dependency baseline
|
||||
|
||||
@ -9,9 +9,9 @@ let package = Package(
|
||||
.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"),
|
||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.1"),
|
||||
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.5"),
|
||||
.package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.5"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
|
||||
@ -42,7 +42,7 @@ public enum IMsgError: LocalizedError, Sendable {
|
||||
case .invalidReaction(let value):
|
||||
return """
|
||||
Invalid reaction: \(value)
|
||||
|
||||
|
||||
Valid reactions: love, like, dislike, laugh, emphasis, question
|
||||
Or use an emoji for custom reactions (e.g., 🎉)
|
||||
"""
|
||||
|
||||
@ -53,12 +53,14 @@ public struct ReactionEvent: Sendable, Equatable {
|
||||
extension MessageStore {
|
||||
/// Fetch reaction events (add/remove) after a given rowID.
|
||||
/// These are the reaction messages themselves, useful for streaming reaction events in watch mode.
|
||||
public func reactionEventsAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [ReactionEvent] {
|
||||
public func reactionEventsAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws
|
||||
-> [ReactionEvent]
|
||||
{
|
||||
guard hasReactionColumns else { return [] }
|
||||
|
||||
|
||||
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
|
||||
let destinationCallerColumn = hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
|
||||
|
||||
|
||||
var sql = """
|
||||
SELECT m.ROWID, cmj.chat_id, m.associated_message_type, m.associated_message_guid,
|
||||
m.handle_id, h.id, m.is_from_me, m.date, IFNULL(m.text, '') AS text,
|
||||
@ -68,21 +70,21 @@ extension MessageStore {
|
||||
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
|
||||
LEFT JOIN message orig ON (orig.guid = m.associated_message_guid
|
||||
LEFT JOIN message orig ON (orig.guid = m.associated_message_guid
|
||||
OR m.associated_message_guid LIKE '%/' || orig.guid)
|
||||
WHERE m.ROWID > ?
|
||||
AND m.associated_message_type >= 2000
|
||||
AND m.associated_message_type <= 3006
|
||||
"""
|
||||
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 events: [ReactionEvent] = []
|
||||
for row in try db.prepare(sql, bindings) {
|
||||
@ -113,21 +115,22 @@ extension MessageStore {
|
||||
continue
|
||||
}
|
||||
|
||||
events.append(ReactionEvent(
|
||||
rowID: rowID,
|
||||
chatID: resolvedChatID,
|
||||
reactionType: reactionType,
|
||||
isAdd: isAdd,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
reactedToGUID: decoded.reactedToGUID ?? "",
|
||||
reactedToID: origRowID,
|
||||
text: resolvedText
|
||||
))
|
||||
events.append(
|
||||
ReactionEvent(
|
||||
rowID: rowID,
|
||||
chatID: resolvedChatID,
|
||||
reactionType: reactionType,
|
||||
isAdd: isAdd,
|
||||
sender: sender,
|
||||
isFromMe: isFromMe,
|
||||
date: date,
|
||||
reactedToGUID: decoded.reactedToGUID ?? "",
|
||||
reactedToID: origRowID,
|
||||
text: resolvedText
|
||||
))
|
||||
}
|
||||
return events
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -7,7 +7,9 @@ public struct MessageWatcherConfiguration: Sendable, Equatable {
|
||||
/// When true, reaction events (tapback add/remove) are included in the stream
|
||||
public var includeReactions: Bool
|
||||
|
||||
public init(debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100, includeReactions: Bool = false) {
|
||||
public init(
|
||||
debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100, includeReactions: Bool = false
|
||||
) {
|
||||
self.debounceInterval = debounceInterval
|
||||
self.batchLimit = batchLimit
|
||||
self.includeReactions = includeReactions
|
||||
|
||||
@ -7,6 +7,7 @@ import Foundation
|
||||
///
|
||||
/// Requires macOS 14+, Messages.app signed in, and an existing conversation with the contact.
|
||||
public struct TypingIndicator: Sendable {
|
||||
private static let daemonConnectionTracker = DaemonConnectionTracker()
|
||||
|
||||
/// Start showing the typing indicator for a chat.
|
||||
/// - Parameter chatIdentifier: e.g. `"iMessage;-;+14155551212"` or a chat GUID.
|
||||
@ -47,8 +48,8 @@ public struct TypingIndicator: Sendable {
|
||||
}
|
||||
defer { dlclose(handle) }
|
||||
|
||||
try ensureDaemonConnection(handle: handle)
|
||||
let chat = try lookupChat(handle: handle, identifier: chatIdentifier)
|
||||
try ensureDaemonConnection()
|
||||
let chat = try lookupChat(identifier: chatIdentifier)
|
||||
|
||||
let selector = sel_registerName("setLocalUserIsTyping:")
|
||||
guard let method = class_getInstanceMethod(object_getClass(chat), selector) else {
|
||||
@ -81,7 +82,7 @@ public struct TypingIndicator: Sendable {
|
||||
stopped = true
|
||||
}
|
||||
|
||||
private static func ensureDaemonConnection(handle: UnsafeMutableRawPointer) throws {
|
||||
private static func ensureDaemonConnection() throws {
|
||||
guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else {
|
||||
throw IMsgError.typingIndicatorFailed("IMDaemonController class not found")
|
||||
}
|
||||
@ -95,17 +96,40 @@ public struct TypingIndicator: Sendable {
|
||||
throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance")
|
||||
}
|
||||
|
||||
if hasLiveDaemonConnection(controller) {
|
||||
daemonConnectionTracker.lock.lock()
|
||||
daemonConnectionTracker.hasAttemptedConnection = true
|
||||
daemonConnectionTracker.lock.unlock()
|
||||
return
|
||||
}
|
||||
|
||||
daemonConnectionTracker.lock.lock()
|
||||
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
|
||||
if shouldAttemptConnection {
|
||||
daemonConnectionTracker.hasAttemptedConnection = true
|
||||
}
|
||||
daemonConnectionTracker.lock.unlock()
|
||||
if !shouldAttemptConnection { return }
|
||||
|
||||
let connectSel = sel_registerName("connectToDaemon")
|
||||
if controller.responds(to: connectSel) {
|
||||
_ = controller.perform(connectSel)
|
||||
}
|
||||
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
|
||||
private static func lookupChat(
|
||||
handle: UnsafeMutableRawPointer, identifier: String
|
||||
) throws -> NSObject {
|
||||
private static func hasLiveDaemonConnection(_ controller: AnyObject) -> Bool {
|
||||
let isConnectedSel = sel_registerName("isConnected")
|
||||
guard controller.responds(to: isConnectedSel) else { return false }
|
||||
guard let value = controller.perform(isConnectedSel)?.takeUnretainedValue() else {
|
||||
return false
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
return number.boolValue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func lookupChat(identifier: String) throws -> NSObject {
|
||||
guard let registryClass = objc_getClass("IMChatRegistry") as? NSObject.Type else {
|
||||
throw IMsgError.typingIndicatorFailed("IMChatRegistry class not found")
|
||||
}
|
||||
@ -141,3 +165,8 @@ public struct TypingIndicator: Sendable {
|
||||
+ "Make sure Messages.app has an active conversation with this contact.")
|
||||
}
|
||||
}
|
||||
|
||||
private final class DaemonConnectionTracker: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var hasAttemptedConnection = false
|
||||
}
|
||||
|
||||
72
Sources/imsg/ChatTargetResolver.swift
Normal file
72
Sources/imsg/ChatTargetResolver.swift
Normal file
@ -0,0 +1,72 @@
|
||||
import IMsgCore
|
||||
|
||||
struct ChatTargetInput: Sendable {
|
||||
let recipient: String
|
||||
let chatID: Int64?
|
||||
let chatIdentifier: String
|
||||
let chatGUID: String
|
||||
|
||||
var hasChatTarget: Bool {
|
||||
chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedChatTarget: Sendable {
|
||||
let chatIdentifier: String
|
||||
let chatGUID: String
|
||||
|
||||
var preferredIdentifier: String? {
|
||||
if !chatGUID.isEmpty { return chatGUID }
|
||||
if !chatIdentifier.isEmpty { return chatIdentifier }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatTargetResolver {
|
||||
static func validateRecipientRequirements(
|
||||
input: ChatTargetInput,
|
||||
mixedTargetError: Error,
|
||||
missingRecipientError: Error
|
||||
) throws {
|
||||
if input.hasChatTarget && !input.recipient.isEmpty {
|
||||
throw mixedTargetError
|
||||
}
|
||||
if !input.hasChatTarget && input.recipient.isEmpty {
|
||||
throw missingRecipientError
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveChatTarget(
|
||||
input: ChatTargetInput,
|
||||
lookupChat: (Int64) async throws -> ChatInfo?,
|
||||
unknownChatError: (Int64) -> Error
|
||||
) async throws -> ResolvedChatTarget {
|
||||
var resolvedIdentifier = input.chatIdentifier
|
||||
var resolvedGUID = input.chatGUID
|
||||
|
||||
if let chatID = input.chatID {
|
||||
guard let info = try await lookupChat(chatID) else {
|
||||
throw unknownChatError(chatID)
|
||||
}
|
||||
resolvedIdentifier = info.identifier
|
||||
resolvedGUID = info.guid
|
||||
}
|
||||
|
||||
return ResolvedChatTarget(
|
||||
chatIdentifier: resolvedIdentifier,
|
||||
chatGUID: resolvedGUID
|
||||
)
|
||||
}
|
||||
|
||||
static func directTypingIdentifier(
|
||||
recipient: String,
|
||||
serviceRaw: String,
|
||||
invalidServiceError: (String) -> Error
|
||||
) throws -> String {
|
||||
guard let service = MessageService(rawValue: serviceRaw.lowercased()) else {
|
||||
throw invalidServiceError(serviceRaw)
|
||||
}
|
||||
let prefix = service == .sms ? "SMS" : "iMessage"
|
||||
return "\(prefix);-;\(recipient)"
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,12 @@ enum ReactCommand {
|
||||
abstract: "Send a tapback reaction to the most recent message",
|
||||
discussion: """
|
||||
Sends a tapback reaction to the most recent incoming message in the specified chat.
|
||||
|
||||
|
||||
IMPORTANT LIMITATIONS:
|
||||
- Only reacts to the MOST RECENT incoming message in the conversation
|
||||
- Requires Messages.app to be running
|
||||
- Uses UI automation (System Events) which requires accessibility permissions
|
||||
|
||||
|
||||
Reaction types:
|
||||
love (❤️), like (👍), dislike (👎), laugh (😂), emphasis (‼️), question (❓)
|
||||
Or any single emoji for custom reactions (iOS 17+ / macOS 14+)
|
||||
@ -22,8 +22,9 @@ enum ReactCommand {
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid to react in"),
|
||||
.make(label: "reaction", names: [.long("reaction"), .short("r")],
|
||||
help: "reaction type: love, like, dislike, laugh, emphasis, question, or emoji"),
|
||||
.make(
|
||||
label: "reaction", names: [.long("reaction"), .short("r")],
|
||||
help: "reaction type: love, like, dislike, laugh, emphasis, question, or emoji"),
|
||||
],
|
||||
flags: []
|
||||
)
|
||||
@ -54,7 +55,7 @@ enum ReactCommand {
|
||||
guard let reactionType = ReactionType.parse(reactionString) else {
|
||||
throw IMsgError.invalidReaction(reactionString)
|
||||
}
|
||||
if case let .custom(emoji) = reactionType, !isSingleEmoji(emoji) {
|
||||
if case .custom(let emoji) = reactionType, !isSingleEmoji(emoji) {
|
||||
throw IMsgError.invalidReaction(reactionString)
|
||||
}
|
||||
|
||||
@ -219,7 +220,7 @@ struct ReactResult: Codable {
|
||||
let chatID: Int64
|
||||
let reactionType: String
|
||||
let reactionEmoji: String
|
||||
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case success
|
||||
case chatID = "chat_id"
|
||||
|
||||
@ -42,17 +42,17 @@ enum SendCommand {
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) }
|
||||
) async throws {
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let recipient = values.option("to") ?? ""
|
||||
let chatID = values.optionInt64("chatID")
|
||||
let chatIdentifier = values.option("chatIdentifier") ?? ""
|
||||
let chatGUID = values.option("chatGUID") ?? ""
|
||||
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
|
||||
if hasChatTarget && !recipient.isEmpty {
|
||||
throw ParsedValuesError.invalidOption("to")
|
||||
}
|
||||
if !hasChatTarget && recipient.isEmpty {
|
||||
throw ParsedValuesError.missingOption("to")
|
||||
}
|
||||
let input = ChatTargetInput(
|
||||
recipient: values.option("to") ?? "",
|
||||
chatID: values.optionInt64("chatID"),
|
||||
chatIdentifier: values.option("chatIdentifier") ?? "",
|
||||
chatGUID: values.option("chatGUID") ?? ""
|
||||
)
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: ParsedValuesError.invalidOption("to"),
|
||||
missingRecipientError: ParsedValuesError.missingOption("to")
|
||||
)
|
||||
|
||||
let text = values.option("text") ?? ""
|
||||
let file = values.option("file") ?? ""
|
||||
@ -65,29 +65,29 @@ enum SendCommand {
|
||||
}
|
||||
let region = values.option("region") ?? "US"
|
||||
|
||||
var resolvedChatIdentifier = chatIdentifier
|
||||
var resolvedChatGUID = chatGUID
|
||||
if let chatID {
|
||||
let store = try storeFactory(dbPath)
|
||||
guard let info = try store.chatInfo(chatID: chatID) else {
|
||||
throw IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in
|
||||
let store = try storeFactory(dbPath)
|
||||
return try store.chatInfo(chatID: chatID)
|
||||
},
|
||||
unknownChatError: { chatID in
|
||||
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
}
|
||||
resolvedChatIdentifier = info.identifier
|
||||
resolvedChatGUID = info.guid
|
||||
}
|
||||
if hasChatTarget && resolvedChatIdentifier.isEmpty && resolvedChatGUID.isEmpty {
|
||||
)
|
||||
if input.hasChatTarget && resolvedTarget.preferredIdentifier == nil {
|
||||
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
|
||||
}
|
||||
|
||||
try sendMessage(
|
||||
MessageSendOptions(
|
||||
recipient: recipient,
|
||||
recipient: input.recipient,
|
||||
text: text,
|
||||
attachmentPath: file,
|
||||
service: service,
|
||||
region: region,
|
||||
chatIdentifier: resolvedChatIdentifier,
|
||||
chatGUID: resolvedChatGUID
|
||||
chatIdentifier: resolvedTarget.chatIdentifier,
|
||||
chatGUID: resolvedTarget.chatGUID
|
||||
))
|
||||
|
||||
if runtime.jsonOutput {
|
||||
|
||||
@ -42,39 +42,56 @@ enum TypingCommand {
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
|
||||
startTyping: @escaping (String) throws -> Void = { try TypingIndicator.startTyping(chatIdentifier: $0) },
|
||||
stopTyping: @escaping (String) throws -> Void = { try TypingIndicator.stopTyping(chatIdentifier: $0) },
|
||||
startTyping: @escaping (String) throws -> Void = {
|
||||
try TypingIndicator.startTyping(chatIdentifier: $0)
|
||||
},
|
||||
stopTyping: @escaping (String) throws -> Void = {
|
||||
try TypingIndicator.stopTyping(chatIdentifier: $0)
|
||||
},
|
||||
typeForDuration: @escaping (String, TimeInterval) async throws -> Void = {
|
||||
try await TypingIndicator.typeForDuration(chatIdentifier: $0, duration: $1)
|
||||
}
|
||||
) async throws {
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let recipient = values.option("to") ?? ""
|
||||
let chatID = values.optionInt64("chatID")
|
||||
let chatIdentifier = values.option("chatIdentifier") ?? ""
|
||||
let chatGUID = values.option("chatGUID") ?? ""
|
||||
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
|
||||
let input = ChatTargetInput(
|
||||
recipient: values.option("to") ?? "",
|
||||
chatID: values.optionInt64("chatID"),
|
||||
chatIdentifier: values.option("chatIdentifier") ?? "",
|
||||
chatGUID: values.option("chatGUID") ?? ""
|
||||
)
|
||||
let stopFlag = try parseStopFlag(values.option("stop"))
|
||||
let durationRaw = values.option("duration") ?? ""
|
||||
let serviceRaw = values.option("service") ?? "imessage"
|
||||
|
||||
if hasChatTarget && !recipient.isEmpty {
|
||||
throw ParsedValuesError.invalidOption("to")
|
||||
}
|
||||
if !hasChatTarget && recipient.isEmpty {
|
||||
throw ParsedValuesError.missingOption("to")
|
||||
}
|
||||
|
||||
let resolvedIdentifier = try resolveIdentifier(
|
||||
dbPath: dbPath,
|
||||
recipient: recipient,
|
||||
chatID: chatID,
|
||||
chatIdentifier: chatIdentifier,
|
||||
chatGUID: chatGUID,
|
||||
service: serviceRaw,
|
||||
storeFactory: storeFactory
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: ParsedValuesError.invalidOption("to"),
|
||||
missingRecipientError: ParsedValuesError.missingOption("to")
|
||||
)
|
||||
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in
|
||||
let store = try storeFactory(dbPath)
|
||||
return try store.chatInfo(chatID: chatID)
|
||||
},
|
||||
unknownChatError: { chatID in
|
||||
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
}
|
||||
)
|
||||
let resolvedIdentifier: String
|
||||
if let preferred = resolvedTarget.preferredIdentifier {
|
||||
resolvedIdentifier = preferred
|
||||
} else if input.hasChatTarget {
|
||||
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
|
||||
} else {
|
||||
resolvedIdentifier = try ChatTargetResolver.directTypingIdentifier(
|
||||
recipient: input.recipient,
|
||||
serviceRaw: serviceRaw,
|
||||
invalidServiceError: { IMsgError.invalidService($0) }
|
||||
)
|
||||
}
|
||||
|
||||
if stopFlag {
|
||||
try stopTyping(resolvedIdentifier)
|
||||
if runtime.jsonOutput {
|
||||
@ -104,32 +121,6 @@ enum TypingCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveIdentifier(
|
||||
dbPath: String,
|
||||
recipient: String,
|
||||
chatID: Int64?,
|
||||
chatIdentifier: String,
|
||||
chatGUID: String,
|
||||
service: String,
|
||||
storeFactory: (String) throws -> MessageStore
|
||||
) throws -> String {
|
||||
if !chatGUID.isEmpty { return chatGUID }
|
||||
if !chatIdentifier.isEmpty { return chatIdentifier }
|
||||
if let chatID {
|
||||
let store = try storeFactory(dbPath)
|
||||
guard let info = try store.chatInfo(chatID: chatID) else {
|
||||
throw IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
}
|
||||
if !info.guid.isEmpty { return info.guid }
|
||||
return info.identifier
|
||||
}
|
||||
guard let messageService = MessageService(rawValue: service.lowercased()) else {
|
||||
throw IMsgError.invalidService(service)
|
||||
}
|
||||
let svc = messageService == .sms ? "SMS" : "iMessage"
|
||||
return "\(svc);-;\(recipient)"
|
||||
}
|
||||
|
||||
private static func parseStopFlag(_ raw: String?) throws -> Bool {
|
||||
guard let raw else { return false }
|
||||
if raw == "true" { return true }
|
||||
|
||||
@ -28,9 +28,9 @@ enum WatchCommand {
|
||||
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
|
||||
),
|
||||
.make(
|
||||
label: "reactions", names: [.long("reactions")],
|
||||
label: "reactions", names: [.long("reactions")],
|
||||
help: "include reaction events (tapback add/remove) in the stream"
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
|
||||
243
Sources/imsg/RPCServer+Handlers.swift
Normal file
243
Sources/imsg/RPCServer+Handlers.swift
Normal file
@ -0,0 +1,243 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
extension RPCServer {
|
||||
func handleChatsList(id: Any?, params: [String: Any]) async throws {
|
||||
let limit = intParam(params["limit"]) ?? 20
|
||||
let chats = try store.listChats(limit: max(limit, 1))
|
||||
var payloads: [[String: Any]] = []
|
||||
payloads.reserveCapacity(chats.count)
|
||||
|
||||
for chat in chats {
|
||||
let info = try await cache.info(chatID: chat.id)
|
||||
let participants = try await cache.participants(chatID: chat.id)
|
||||
let identifier = info?.identifier ?? chat.identifier
|
||||
let guid = info?.guid ?? ""
|
||||
let name = (info?.name.isEmpty == false ? info?.name : nil) ?? chat.name
|
||||
let service = info?.service ?? chat.service
|
||||
payloads.append(
|
||||
chatPayload(
|
||||
id: chat.id,
|
||||
identifier: identifier,
|
||||
guid: guid,
|
||||
name: name,
|
||||
service: service,
|
||||
lastMessageAt: chat.lastMessageAt,
|
||||
participants: participants
|
||||
))
|
||||
}
|
||||
|
||||
respond(id: id, result: ["chats": payloads])
|
||||
}
|
||||
|
||||
func handleMessagesHistory(id: Any?, params: [String: Any]) async throws {
|
||||
guard let chatID = int64Param(params["chat_id"]) else {
|
||||
throw RPCError.invalidParams("chat_id is required")
|
||||
}
|
||||
let limit = intParam(params["limit"]) ?? 50
|
||||
let participants = stringArrayParam(params["participants"])
|
||||
let startISO = stringParam(params["start"])
|
||||
let endISO = stringParam(params["end"])
|
||||
let includeAttachments = boolParam(params["attachments"]) ?? false
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: startISO,
|
||||
endISO: endISO
|
||||
)
|
||||
let filtered = try store.messages(chatID: chatID, limit: max(limit, 1), filter: filter)
|
||||
|
||||
var payloads: [[String: Any]] = []
|
||||
payloads.reserveCapacity(filtered.count)
|
||||
for message in filtered {
|
||||
let payload = try await buildMessagePayload(
|
||||
store: store,
|
||||
cache: cache,
|
||||
message: message,
|
||||
includeAttachments: includeAttachments
|
||||
)
|
||||
payloads.append(payload)
|
||||
}
|
||||
|
||||
respond(id: id, result: ["messages": payloads])
|
||||
}
|
||||
|
||||
func handleWatchSubscribe(id: Any?, params: [String: Any]) async throws {
|
||||
let chatID = int64Param(params["chat_id"])
|
||||
let sinceRowID = int64Param(params["since_rowid"])
|
||||
let participants = stringArrayParam(params["participants"])
|
||||
let startISO = stringParam(params["start"])
|
||||
let endISO = stringParam(params["end"])
|
||||
let includeAttachments = boolParam(params["attachments"]) ?? false
|
||||
let includeReactions = boolParam(params["include_reactions"]) ?? false
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: startISO,
|
||||
endISO: endISO
|
||||
)
|
||||
let config = MessageWatcherConfiguration(includeReactions: includeReactions)
|
||||
let subID = await subscriptions.allocateID()
|
||||
let localStore = store
|
||||
let localWatcher = watcher
|
||||
let localCache = cache
|
||||
let localWriter = output
|
||||
let localFilter = filter
|
||||
let localChatID = chatID
|
||||
let localSinceRowID = sinceRowID
|
||||
let localConfig = config
|
||||
let localIncludeAttachments = includeAttachments
|
||||
let task = Task {
|
||||
do {
|
||||
for try await message in localWatcher.stream(
|
||||
chatID: localChatID,
|
||||
sinceRowID: localSinceRowID,
|
||||
configuration: localConfig
|
||||
) {
|
||||
if Task.isCancelled { return }
|
||||
if !localFilter.allows(message) { continue }
|
||||
let payload = try await buildMessagePayload(
|
||||
store: localStore,
|
||||
cache: localCache,
|
||||
message: message,
|
||||
includeAttachments: localIncludeAttachments
|
||||
)
|
||||
localWriter.sendNotification(
|
||||
method: "message",
|
||||
params: ["subscription": subID, "message": payload]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
localWriter.sendNotification(
|
||||
method: "error",
|
||||
params: [
|
||||
"subscription": subID,
|
||||
"error": ["message": String(describing: error)],
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
await subscriptions.insert(task, for: subID)
|
||||
respond(id: id, result: ["subscription": subID])
|
||||
}
|
||||
|
||||
func handleWatchUnsubscribe(id: Any?, params: [String: Any]) async throws {
|
||||
guard let subID = intParam(params["subscription"]) else {
|
||||
throw RPCError.invalidParams("subscription is required")
|
||||
}
|
||||
if let task = await subscriptions.remove(subID) {
|
||||
task.cancel()
|
||||
}
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleTyping(params: [String: Any], id: Any?, start: Bool) async throws {
|
||||
let input = ChatTargetInput(
|
||||
recipient: stringParam(params["to"]) ?? "",
|
||||
chatID: int64Param(params["chat_id"]),
|
||||
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
|
||||
chatGUID: stringParam(params["chat_guid"]) ?? ""
|
||||
)
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
|
||||
missingRecipientError: RPCError.invalidParams("to is required for direct typing")
|
||||
)
|
||||
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in try await cache.info(chatID: chatID) },
|
||||
unknownChatError: { chatID in
|
||||
RPCError.invalidParams("unknown chat_id \(chatID)")
|
||||
}
|
||||
)
|
||||
|
||||
let resolvedIdentifier: String
|
||||
if let preferred = resolvedTarget.preferredIdentifier {
|
||||
resolvedIdentifier = preferred
|
||||
} else if input.hasChatTarget {
|
||||
throw RPCError.invalidParams("missing chat identifier or guid")
|
||||
} else {
|
||||
let serviceRaw = stringParam(params["service"]) ?? "imessage"
|
||||
resolvedIdentifier = try ChatTargetResolver.directTypingIdentifier(
|
||||
recipient: input.recipient,
|
||||
serviceRaw: serviceRaw,
|
||||
invalidServiceError: { _ in RPCError.invalidParams("invalid service") }
|
||||
)
|
||||
}
|
||||
|
||||
if start {
|
||||
try startTyping(resolvedIdentifier)
|
||||
} else {
|
||||
try stopTyping(resolvedIdentifier)
|
||||
}
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
func handleSend(params: [String: Any], id: Any?) async throws {
|
||||
let text = stringParam(params["text"]) ?? ""
|
||||
let file = stringParam(params["file"]) ?? ""
|
||||
let serviceRaw = stringParam(params["service"]) ?? "auto"
|
||||
guard let service = MessageService(rawValue: serviceRaw) else {
|
||||
throw RPCError.invalidParams("invalid service")
|
||||
}
|
||||
let region = stringParam(params["region"]) ?? "US"
|
||||
|
||||
let input = ChatTargetInput(
|
||||
recipient: stringParam(params["to"]) ?? "",
|
||||
chatID: int64Param(params["chat_id"]),
|
||||
chatIdentifier: stringParam(params["chat_identifier"]) ?? "",
|
||||
chatGUID: stringParam(params["chat_guid"]) ?? ""
|
||||
)
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: RPCError.invalidParams("use to or chat_*; not both"),
|
||||
missingRecipientError: RPCError.invalidParams("to is required for direct sends")
|
||||
)
|
||||
|
||||
if text.isEmpty && file.isEmpty {
|
||||
throw RPCError.invalidParams("text or file is required")
|
||||
}
|
||||
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in try await cache.info(chatID: chatID) },
|
||||
unknownChatError: { chatID in
|
||||
RPCError.invalidParams("unknown chat_id \(chatID)")
|
||||
}
|
||||
)
|
||||
if input.hasChatTarget && resolvedTarget.preferredIdentifier == nil {
|
||||
throw RPCError.invalidParams("missing chat identifier or guid")
|
||||
}
|
||||
|
||||
try sendMessage(
|
||||
MessageSendOptions(
|
||||
recipient: input.recipient,
|
||||
text: text,
|
||||
attachmentPath: file,
|
||||
service: service,
|
||||
region: region,
|
||||
chatIdentifier: resolvedTarget.chatIdentifier,
|
||||
chatGUID: resolvedTarget.chatGUID
|
||||
)
|
||||
)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
}
|
||||
|
||||
private func buildMessagePayload(
|
||||
store: MessageStore,
|
||||
cache: ChatCache,
|
||||
message: Message,
|
||||
includeAttachments: Bool
|
||||
) async throws -> [String: Any] {
|
||||
let chatInfo = try await cache.info(chatID: message.chatID)
|
||||
let participants = try await cache.participants(chatID: message.chatID)
|
||||
let attachments = includeAttachments ? try store.attachments(for: message.rowID) : []
|
||||
let reactions = includeAttachments ? try store.reactions(for: message.rowID) : []
|
||||
return try messagePayload(
|
||||
message: message,
|
||||
chatInfo: chatInfo,
|
||||
participants: participants,
|
||||
attachments: attachments,
|
||||
reactions: reactions
|
||||
)
|
||||
}
|
||||
123
Sources/imsg/RPCServer+Support.swift
Normal file
123
Sources/imsg/RPCServer+Support.swift
Normal file
@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
final class RPCWriter: RPCOutput, Sendable {
|
||||
func sendResponse(id: Any, result: Any) {
|
||||
send(["jsonrpc": "2.0", "id": id, "result": result])
|
||||
}
|
||||
|
||||
func sendError(id: Any?, error: RPCError) {
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"id": id ?? NSNull(),
|
||||
"error": error.asDictionary(),
|
||||
]
|
||||
send(payload)
|
||||
}
|
||||
|
||||
func sendNotification(method: String, params: Any) {
|
||||
send(["jsonrpc": "2.0", "method": method, "params": params])
|
||||
}
|
||||
|
||||
private func send(_ object: Any) {
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
||||
if let output = String(data: data, encoding: .utf8) {
|
||||
StdoutWriter.writeLine(output)
|
||||
}
|
||||
} catch {
|
||||
StdoutWriter.writeLine(
|
||||
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RPCError: Error {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: String?
|
||||
|
||||
static func parseError(_ message: String) -> RPCError {
|
||||
RPCError(code: -32700, message: "Parse error", data: message)
|
||||
}
|
||||
|
||||
static func invalidRequest(_ message: String) -> RPCError {
|
||||
RPCError(code: -32600, message: "Invalid Request", data: message)
|
||||
}
|
||||
|
||||
static func methodNotFound(_ method: String) -> RPCError {
|
||||
RPCError(code: -32601, message: "Method not found", data: method)
|
||||
}
|
||||
|
||||
static func invalidParams(_ message: String) -> RPCError {
|
||||
RPCError(code: -32602, message: "Invalid params", data: message)
|
||||
}
|
||||
|
||||
static func internalError(_ message: String) -> RPCError {
|
||||
RPCError(code: -32603, message: "Internal error", data: message)
|
||||
}
|
||||
|
||||
func asDictionary() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"code": code,
|
||||
"message": message,
|
||||
]
|
||||
if let data {
|
||||
dict["data"] = data
|
||||
}
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
actor SubscriptionStore {
|
||||
private var nextID = 1
|
||||
private var tasks: [Int: Task<Void, Never>] = [:]
|
||||
|
||||
func allocateID() -> Int {
|
||||
let id = nextID
|
||||
nextID += 1
|
||||
return id
|
||||
}
|
||||
|
||||
func insert(_ task: Task<Void, Never>, for id: Int) {
|
||||
tasks[id] = task
|
||||
}
|
||||
|
||||
func remove(_ id: Int) -> Task<Void, Never>? {
|
||||
tasks.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
for task in tasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
tasks.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
actor ChatCache {
|
||||
private let store: MessageStore
|
||||
private var infoCache: [Int64: ChatInfo] = [:]
|
||||
private var participantsCache: [Int64: [String]] = [:]
|
||||
|
||||
init(store: MessageStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func info(chatID: Int64) throws -> ChatInfo? {
|
||||
if let cached = infoCache[chatID] { return cached }
|
||||
if let info = try store.chatInfo(chatID: chatID) {
|
||||
infoCache[chatID] = info
|
||||
return info
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func participants(chatID: Int64) throws -> [String] {
|
||||
if let cached = participantsCache[chatID] { return cached }
|
||||
let participants = try store.participants(chatID: chatID)
|
||||
participantsCache[chatID] = participants
|
||||
return participants
|
||||
}
|
||||
}
|
||||
@ -8,15 +8,15 @@ protocol RPCOutput: Sendable {
|
||||
}
|
||||
|
||||
final class RPCServer {
|
||||
private let store: MessageStore
|
||||
private let watcher: MessageWatcher
|
||||
private let output: RPCOutput
|
||||
private let cache: ChatCache
|
||||
private let subscriptions = SubscriptionStore()
|
||||
private let verbose: Bool
|
||||
private let sendMessage: (MessageSendOptions) throws -> Void
|
||||
private let startTyping: (String) throws -> Void
|
||||
private let stopTyping: (String) throws -> Void
|
||||
let store: MessageStore
|
||||
let watcher: MessageWatcher
|
||||
let output: RPCOutput
|
||||
let cache: ChatCache
|
||||
let subscriptions = SubscriptionStore()
|
||||
let verbose: Bool
|
||||
let sendMessage: (MessageSendOptions) throws -> Void
|
||||
let startTyping: (String) throws -> Void
|
||||
let stopTyping: (String) throws -> Void
|
||||
|
||||
init(
|
||||
store: MessageStore,
|
||||
@ -53,6 +53,11 @@ final class RPCServer {
|
||||
await handleLine(line)
|
||||
}
|
||||
|
||||
func respond(id: Any?, result: Any) {
|
||||
guard let id else { return }
|
||||
output.sendResponse(id: id, result: result)
|
||||
}
|
||||
|
||||
private func handleLine(_ line: String) async {
|
||||
guard let data = line.data(using: .utf8) else {
|
||||
output.sendError(id: nil, error: RPCError.parseError("invalid utf8"))
|
||||
@ -116,368 +121,4 @@ final class RPCServer {
|
||||
output.sendError(id: id, error: RPCError.internalError(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
private func respond(id: Any?, result: Any) {
|
||||
guard let id else { return }
|
||||
output.sendResponse(id: id, result: result)
|
||||
}
|
||||
|
||||
private func handleChatsList(id: Any?, params: [String: Any]) async throws {
|
||||
let limit = intParam(params["limit"]) ?? 20
|
||||
let chats = try store.listChats(limit: max(limit, 1))
|
||||
var payloads: [[String: Any]] = []
|
||||
payloads.reserveCapacity(chats.count)
|
||||
|
||||
for chat in chats {
|
||||
let info = try await cache.info(chatID: chat.id)
|
||||
let participants = try await cache.participants(chatID: chat.id)
|
||||
let identifier = info?.identifier ?? chat.identifier
|
||||
let guid = info?.guid ?? ""
|
||||
let name = (info?.name.isEmpty == false ? info?.name : nil) ?? chat.name
|
||||
let service = info?.service ?? chat.service
|
||||
payloads.append(
|
||||
chatPayload(
|
||||
id: chat.id,
|
||||
identifier: identifier,
|
||||
guid: guid,
|
||||
name: name,
|
||||
service: service,
|
||||
lastMessageAt: chat.lastMessageAt,
|
||||
participants: participants
|
||||
))
|
||||
}
|
||||
|
||||
respond(id: id, result: ["chats": payloads])
|
||||
}
|
||||
|
||||
private func handleMessagesHistory(id: Any?, params: [String: Any]) async throws {
|
||||
guard let chatID = int64Param(params["chat_id"]) else {
|
||||
throw RPCError.invalidParams("chat_id is required")
|
||||
}
|
||||
let limit = intParam(params["limit"]) ?? 50
|
||||
let participants = stringArrayParam(params["participants"])
|
||||
let startISO = stringParam(params["start"])
|
||||
let endISO = stringParam(params["end"])
|
||||
let includeAttachments = boolParam(params["attachments"]) ?? false
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: startISO,
|
||||
endISO: endISO
|
||||
)
|
||||
let filtered = try store.messages(chatID: chatID, limit: max(limit, 1), filter: filter)
|
||||
|
||||
var payloads: [[String: Any]] = []
|
||||
payloads.reserveCapacity(filtered.count)
|
||||
for message in filtered {
|
||||
let payload = try await buildMessagePayload(
|
||||
store: store,
|
||||
cache: cache,
|
||||
message: message,
|
||||
includeAttachments: includeAttachments
|
||||
)
|
||||
payloads.append(payload)
|
||||
}
|
||||
|
||||
respond(id: id, result: ["messages": payloads])
|
||||
}
|
||||
|
||||
private func handleWatchSubscribe(id: Any?, params: [String: Any]) async throws {
|
||||
let chatID = int64Param(params["chat_id"])
|
||||
let sinceRowID = int64Param(params["since_rowid"])
|
||||
let participants = stringArrayParam(params["participants"])
|
||||
let startISO = stringParam(params["start"])
|
||||
let endISO = stringParam(params["end"])
|
||||
let includeAttachments = boolParam(params["attachments"]) ?? false
|
||||
let includeReactions = boolParam(params["include_reactions"]) ?? false
|
||||
let filter = try MessageFilter.fromISO(
|
||||
participants: participants,
|
||||
startISO: startISO,
|
||||
endISO: endISO
|
||||
)
|
||||
let config = MessageWatcherConfiguration(includeReactions: includeReactions)
|
||||
let subID = await subscriptions.allocateID()
|
||||
let localStore = store
|
||||
let localWatcher = watcher
|
||||
let localCache = cache
|
||||
let localWriter = output
|
||||
let localFilter = filter
|
||||
let localChatID = chatID
|
||||
let localSinceRowID = sinceRowID
|
||||
let localConfig = config
|
||||
let localIncludeAttachments = includeAttachments
|
||||
let task = Task {
|
||||
do {
|
||||
for try await message in localWatcher.stream(
|
||||
chatID: localChatID,
|
||||
sinceRowID: localSinceRowID,
|
||||
configuration: localConfig
|
||||
) {
|
||||
if Task.isCancelled { return }
|
||||
if !localFilter.allows(message) { continue }
|
||||
let payload = try await buildMessagePayload(
|
||||
store: localStore,
|
||||
cache: localCache,
|
||||
message: message,
|
||||
includeAttachments: localIncludeAttachments
|
||||
)
|
||||
localWriter.sendNotification(
|
||||
method: "message",
|
||||
params: ["subscription": subID, "message": payload]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
localWriter.sendNotification(
|
||||
method: "error",
|
||||
params: [
|
||||
"subscription": subID,
|
||||
"error": ["message": String(describing: error)],
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
await subscriptions.insert(task, for: subID)
|
||||
respond(id: id, result: ["subscription": subID])
|
||||
}
|
||||
|
||||
private func handleWatchUnsubscribe(id: Any?, params: [String: Any]) async throws {
|
||||
guard let subID = intParam(params["subscription"]) else {
|
||||
throw RPCError.invalidParams("subscription is required")
|
||||
}
|
||||
if let task = await subscriptions.remove(subID) {
|
||||
task.cancel()
|
||||
}
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
private func handleTyping(params: [String: Any], id: Any?, start: Bool) async throws {
|
||||
let chatIdentifier = stringParam(params["chat_identifier"]) ?? ""
|
||||
let chatGUID = stringParam(params["chat_guid"]) ?? ""
|
||||
let recipient = stringParam(params["to"]) ?? ""
|
||||
let chatID = int64Param(params["chat_id"])
|
||||
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
|
||||
if hasChatTarget && !recipient.isEmpty {
|
||||
throw RPCError.invalidParams("use to or chat_*; not both")
|
||||
}
|
||||
if !hasChatTarget && recipient.isEmpty {
|
||||
throw RPCError.invalidParams("to is required for direct typing")
|
||||
}
|
||||
|
||||
let serviceRaw = stringParam(params["service"]) ?? "imessage"
|
||||
guard let service = MessageService(rawValue: serviceRaw.lowercased()) else {
|
||||
throw RPCError.invalidParams("invalid service")
|
||||
}
|
||||
|
||||
var resolved = chatGUID.isEmpty ? chatIdentifier : chatGUID
|
||||
if let chatID {
|
||||
if let info = try await cache.info(chatID: chatID) {
|
||||
resolved = info.guid.isEmpty ? info.identifier : info.guid
|
||||
} else {
|
||||
throw RPCError.invalidParams("unknown chat_id \(chatID)")
|
||||
}
|
||||
}
|
||||
if resolved.isEmpty {
|
||||
let svc = service == .sms ? "SMS" : "iMessage"
|
||||
resolved = "\(svc);-;\(recipient)"
|
||||
}
|
||||
|
||||
if start {
|
||||
try startTyping(resolved)
|
||||
} else {
|
||||
try stopTyping(resolved)
|
||||
}
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
private func handleSend(params: [String: Any], id: Any?) async throws {
|
||||
let text = stringParam(params["text"]) ?? ""
|
||||
let file = stringParam(params["file"]) ?? ""
|
||||
let serviceRaw = stringParam(params["service"]) ?? "auto"
|
||||
guard let service = MessageService(rawValue: serviceRaw) else {
|
||||
throw RPCError.invalidParams("invalid service")
|
||||
}
|
||||
let region = stringParam(params["region"]) ?? "US"
|
||||
|
||||
let chatID = int64Param(params["chat_id"])
|
||||
let chatIdentifier = stringParam(params["chat_identifier"]) ?? ""
|
||||
let chatGUID = stringParam(params["chat_guid"]) ?? ""
|
||||
let hasChatTarget = chatID != nil || !chatIdentifier.isEmpty || !chatGUID.isEmpty
|
||||
let recipient = stringParam(params["to"]) ?? ""
|
||||
if hasChatTarget && !recipient.isEmpty {
|
||||
throw RPCError.invalidParams("use to or chat_*; not both")
|
||||
}
|
||||
if !hasChatTarget && recipient.isEmpty {
|
||||
throw RPCError.invalidParams("to is required for direct sends")
|
||||
}
|
||||
|
||||
if text.isEmpty && file.isEmpty {
|
||||
throw RPCError.invalidParams("text or file is required")
|
||||
}
|
||||
|
||||
var resolvedChatIdentifier = chatIdentifier
|
||||
var resolvedChatGUID = chatGUID
|
||||
if let chatID {
|
||||
guard let info = try await cache.info(chatID: chatID) else {
|
||||
throw RPCError.invalidParams("unknown chat_id \(chatID)")
|
||||
}
|
||||
resolvedChatIdentifier = info.identifier
|
||||
resolvedChatGUID = info.guid
|
||||
}
|
||||
if hasChatTarget && resolvedChatIdentifier.isEmpty && resolvedChatGUID.isEmpty {
|
||||
throw RPCError.invalidParams("missing chat identifier or guid")
|
||||
}
|
||||
|
||||
try sendMessage(
|
||||
MessageSendOptions(
|
||||
recipient: recipient,
|
||||
text: text,
|
||||
attachmentPath: file,
|
||||
service: service,
|
||||
region: region,
|
||||
chatIdentifier: resolvedChatIdentifier,
|
||||
chatGUID: resolvedChatGUID
|
||||
)
|
||||
)
|
||||
respond(id: id, result: ["ok": true])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func buildMessagePayload(
|
||||
store: MessageStore,
|
||||
cache: ChatCache,
|
||||
message: Message,
|
||||
includeAttachments: Bool
|
||||
) async throws -> [String: Any] {
|
||||
let chatInfo = try await cache.info(chatID: message.chatID)
|
||||
let participants = try await cache.participants(chatID: message.chatID)
|
||||
let attachments = includeAttachments ? try store.attachments(for: message.rowID) : []
|
||||
let reactions = includeAttachments ? try store.reactions(for: message.rowID) : []
|
||||
return try messagePayload(
|
||||
message: message,
|
||||
chatInfo: chatInfo,
|
||||
participants: participants,
|
||||
attachments: attachments,
|
||||
reactions: reactions
|
||||
)
|
||||
}
|
||||
|
||||
private final class RPCWriter: RPCOutput, Sendable {
|
||||
func sendResponse(id: Any, result: Any) {
|
||||
send(["jsonrpc": "2.0", "id": id, "result": result])
|
||||
}
|
||||
|
||||
func sendError(id: Any?, error: RPCError) {
|
||||
let payload: [String: Any] = [
|
||||
"jsonrpc": "2.0",
|
||||
"id": id ?? NSNull(),
|
||||
"error": error.asDictionary(),
|
||||
]
|
||||
send(payload)
|
||||
}
|
||||
|
||||
func sendNotification(method: String, params: Any) {
|
||||
send(["jsonrpc": "2.0", "method": method, "params": params])
|
||||
}
|
||||
|
||||
private func send(_ object: Any) {
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [])
|
||||
if let output = String(data: data, encoding: .utf8) {
|
||||
StdoutWriter.writeLine(output)
|
||||
}
|
||||
} catch {
|
||||
StdoutWriter.writeLine(
|
||||
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"write failed\"}}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RPCError: Error {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: String?
|
||||
|
||||
static func parseError(_ message: String) -> RPCError {
|
||||
RPCError(code: -32700, message: "Parse error", data: message)
|
||||
}
|
||||
|
||||
static func invalidRequest(_ message: String) -> RPCError {
|
||||
RPCError(code: -32600, message: "Invalid Request", data: message)
|
||||
}
|
||||
|
||||
static func methodNotFound(_ method: String) -> RPCError {
|
||||
RPCError(code: -32601, message: "Method not found", data: method)
|
||||
}
|
||||
|
||||
static func invalidParams(_ message: String) -> RPCError {
|
||||
RPCError(code: -32602, message: "Invalid params", data: message)
|
||||
}
|
||||
|
||||
static func internalError(_ message: String) -> RPCError {
|
||||
RPCError(code: -32603, message: "Internal error", data: message)
|
||||
}
|
||||
|
||||
func asDictionary() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"code": code,
|
||||
"message": message,
|
||||
]
|
||||
if let data {
|
||||
dict["data"] = data
|
||||
}
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
private actor SubscriptionStore {
|
||||
private var nextID = 1
|
||||
private var tasks: [Int: Task<Void, Never>] = [:]
|
||||
|
||||
func allocateID() -> Int {
|
||||
let id = nextID
|
||||
nextID += 1
|
||||
return id
|
||||
}
|
||||
|
||||
func insert(_ task: Task<Void, Never>, for id: Int) {
|
||||
tasks[id] = task
|
||||
}
|
||||
|
||||
func remove(_ id: Int) -> Task<Void, Never>? {
|
||||
tasks.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
for task in tasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
tasks.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private actor ChatCache {
|
||||
private let store: MessageStore
|
||||
private var infoCache: [Int64: ChatInfo] = [:]
|
||||
private var participantsCache: [Int64: [String]] = [:]
|
||||
|
||||
init(store: MessageStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func info(chatID: Int64) throws -> ChatInfo? {
|
||||
if let cached = infoCache[chatID] { return cached }
|
||||
if let info = try store.chatInfo(chatID: chatID) {
|
||||
infoCache[chatID] = info
|
||||
return info
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func participants(chatID: Int64) throws -> [String] {
|
||||
if let cached = participantsCache[chatID] { return cached }
|
||||
let participants = try store.participants(chatID: chatID)
|
||||
participantsCache[chatID] = participants
|
||||
return participants
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.4.1</string>
|
||||
<string>0.5.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.4.1</string>
|
||||
<string>0.5.0</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Send messages via Messages.app.</string>
|
||||
</dict>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Generated by scripts/generate-version.sh. Do not edit.
|
||||
enum IMsgVersion {
|
||||
static let current = "0.4.1"
|
||||
static let current = "0.5.0"
|
||||
}
|
||||
|
||||
@ -10,10 +10,45 @@ enum CommandTestDatabase {
|
||||
}
|
||||
|
||||
static func makePath() throws -> String {
|
||||
let path = try makeDatabasePath()
|
||||
let db = try Connection(path)
|
||||
try createSchema(db, includeChatHandleJoin: false)
|
||||
try seedBasicChat(db)
|
||||
return path
|
||||
}
|
||||
|
||||
static func makePathWithAttachment() throws -> String {
|
||||
let path = try makePath()
|
||||
let db = try Connection(path)
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
|
||||
VALUES (1, '/tmp/file.dat', 'file.dat', 'public.data', 'application/octet-stream', 10, 0)
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
|
||||
return path
|
||||
}
|
||||
|
||||
static func makeStoreForRPC() throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
try createSchema(db, includeChatHandleJoin: true)
|
||||
try seedRPCChat(db)
|
||||
return try MessageStore(
|
||||
connection: db,
|
||||
path: ":memory:",
|
||||
hasAttributedBody: false,
|
||||
hasReactionColumns: false
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeDatabasePath() throws -> String {
|
||||
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let path = dir.appendingPathComponent("chat.db").path
|
||||
let db = try Connection(path)
|
||||
return dir.appendingPathComponent("chat.db").path
|
||||
}
|
||||
|
||||
private static func createSchema(_ db: Connection, includeChatHandleJoin: Bool) throws {
|
||||
try db.execute(
|
||||
"""
|
||||
CREATE TABLE message (
|
||||
@ -38,6 +73,9 @@ enum CommandTestDatabase {
|
||||
"""
|
||||
)
|
||||
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
|
||||
if includeChatHandleJoin {
|
||||
try db.execute("CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);")
|
||||
}
|
||||
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);")
|
||||
@ -54,7 +92,9 @@ enum CommandTestDatabase {
|
||||
);
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
private static func seedBasicChat(_ db: Connection) throws {
|
||||
let now = Date()
|
||||
try db.run(
|
||||
"""
|
||||
@ -71,19 +111,25 @@ enum CommandTestDatabase {
|
||||
appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
|
||||
return path
|
||||
}
|
||||
|
||||
static func makePathWithAttachment() throws -> String {
|
||||
let path = try makePath()
|
||||
let db = try Connection(path)
|
||||
private static func seedRPCChat(_ db: Connection) throws {
|
||||
let now = Date()
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker)
|
||||
VALUES (1, '/tmp/file.dat', 'file.dat', 'public.data', 'application/octet-stream', 10, 0)
|
||||
INSERT INTO chat(ROWID, chat_identifier, guid, display_name, service_name)
|
||||
VALUES (1, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (1, 1)")
|
||||
return path
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'me@icloud.com')")
|
||||
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (5, 1, 'hello', ?, 0, 'iMessage')
|
||||
""",
|
||||
appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 5)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,83 +1,9 @@
|
||||
import Foundation
|
||||
import SQLite
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
private enum RPCTestDatabase {
|
||||
static func appleEpoch(_ date: Date) -> Int64 {
|
||||
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
|
||||
return Int64(seconds * 1_000_000_000)
|
||||
}
|
||||
|
||||
static func makeStore() throws -> MessageStore {
|
||||
let db = try Connection(.inMemory)
|
||||
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,
|
||||
guid 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_handle_join (chat_id INTEGER, handle_id INTEGER);")
|
||||
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, guid, display_name, service_name)
|
||||
VALUES (1, 'iMessage;+;chat123', 'iMessage;+;chat123', 'Group Chat', 'iMessage')
|
||||
"""
|
||||
)
|
||||
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'me@icloud.com')")
|
||||
try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)")
|
||||
try db.run(
|
||||
"""
|
||||
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
|
||||
VALUES (5, 1, 'hello', ?, 0, 'iMessage')
|
||||
""",
|
||||
appleEpoch(now)
|
||||
)
|
||||
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 5)")
|
||||
|
||||
return try MessageStore(
|
||||
connection: db, path: ":memory:", hasAttributedBody: false, hasReactionColumns: false)
|
||||
}
|
||||
}
|
||||
|
||||
final class TestRPCOutput: RPCOutput, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private(set) var responses: [[String: Any]] = []
|
||||
@ -117,7 +43,7 @@ private func int64Value(_ value: Any?) -> Int64? {
|
||||
|
||||
@Test
|
||||
func rpcChatsListReturnsChatPayload() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -137,7 +63,7 @@ func rpcChatsListReturnsChatPayload() async throws {
|
||||
|
||||
@Test
|
||||
func rpcMessagesHistoryIncludesChatFields() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -156,7 +82,7 @@ func rpcMessagesHistoryIncludesChatFields() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendResolvesChatID() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
var captured: MessageSendOptions?
|
||||
let server = RPCServer(
|
||||
@ -177,7 +103,7 @@ func rpcSendResolvesChatID() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsMissingTextAndFile() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -191,7 +117,7 @@ func rpcSendRejectsMissingTextAndFile() async throws {
|
||||
|
||||
@Test
|
||||
func rpcRejectsInvalidJSON() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -203,7 +129,7 @@ func rpcRejectsInvalidJSON() async throws {
|
||||
|
||||
@Test
|
||||
func rpcRejectsNonObjectRequest() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -215,7 +141,7 @@ func rpcRejectsNonObjectRequest() async throws {
|
||||
|
||||
@Test
|
||||
func rpcRejectsInvalidJSONRPCVersion() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -228,7 +154,7 @@ func rpcRejectsInvalidJSONRPCVersion() async throws {
|
||||
|
||||
@Test
|
||||
func rpcRejectsMissingMethod() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -241,7 +167,7 @@ func rpcRejectsMissingMethod() async throws {
|
||||
|
||||
@Test
|
||||
func rpcReportsMethodNotFound() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -254,7 +180,7 @@ func rpcReportsMethodNotFound() async throws {
|
||||
|
||||
@Test
|
||||
func rpcHistoryRequiresChatID() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -267,7 +193,7 @@ func rpcHistoryRequiresChatID() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsInvalidService() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -281,7 +207,7 @@ func rpcSendRejectsInvalidService() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsMissingRecipientForDirectSend() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -294,7 +220,7 @@ func rpcSendRejectsMissingRecipientForDirectSend() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsChatAndRecipient() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -308,7 +234,7 @@ func rpcSendRejectsChatAndRecipient() async throws {
|
||||
|
||||
@Test
|
||||
func rpcSendRejectsUnknownChatID() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -321,7 +247,7 @@ func rpcSendRejectsUnknownChatID() async throws {
|
||||
|
||||
@Test
|
||||
func rpcTypingStartResolvesSMSRecipient() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
var startedIdentifier: String?
|
||||
let server = RPCServer(
|
||||
@ -343,7 +269,7 @@ func rpcTypingStartResolvesSMSRecipient() async throws {
|
||||
|
||||
@Test
|
||||
func rpcTypingStopResolvesChatID() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
var stoppedIdentifier: String?
|
||||
let server = RPCServer(
|
||||
@ -364,7 +290,7 @@ func rpcTypingStopResolvesChatID() async throws {
|
||||
|
||||
@Test
|
||||
func rpcTypingRejectsInvalidService() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -378,7 +304,7 @@ func rpcTypingRejectsInvalidService() async throws {
|
||||
|
||||
@Test
|
||||
func rpcTypingRejectsChatAndRecipient() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -392,7 +318,7 @@ func rpcTypingRejectsChatAndRecipient() async throws {
|
||||
|
||||
@Test
|
||||
func rpcWatchSubscribeEmitsNotificationAndUnsubscribe() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
@ -422,7 +348,7 @@ func rpcWatchSubscribeEmitsNotificationAndUnsubscribe() async throws {
|
||||
|
||||
@Test
|
||||
func rpcWatchUnsubscribeRequiresSubscription() async throws {
|
||||
let store = try RPCTestDatabase.makeStore()
|
||||
let store = try CommandTestDatabase.makeStoreForRPC()
|
||||
let output = TestRPCOutput()
|
||||
let server = RPCServer(store: store, verbose: false, output: output)
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
MARKETING_VERSION=0.4.1
|
||||
MARKETING_VERSION=0.5.0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user