diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d77ad..9eb7e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.11.2 - Unreleased +### Send +- fix: thread attributed-text formatting through the RPC `send` bridge path, not just `send-rich`, so direct/handle sends render **bold**/*italic*/etc. on macOS 15+. `handleSend` now forwards format ranges to the bridge, accepting `formatting` (the key OpenClaw's `message` tool emits) alongside `text_formatting`/`textFormatting` (#143, thanks @omarshahine). + ## 0.11.1 - 2026-06-10 ### Read Commands diff --git a/Sources/imsg/RPCServer+Handlers.swift b/Sources/imsg/RPCServer+Handlers.swift index a462ef7..5b5417e 100644 --- a/Sources/imsg/RPCServer+Handlers.swift +++ b/Sources/imsg/RPCServer+Handlers.swift @@ -167,6 +167,12 @@ extension RPCServer { func handleSend(params: [String: Any], id: Any?) async throws { let text = stringParam(params["text"]) ?? "" let file = stringParam(params["file"]) ?? "" + // Optional attributed-text formatting (bold/italic/…, macOS 15+). Only the + // IMCore bridge transport can render it; AppleScript sends stay plain. + // Accept `text_formatting`/`textFormatting` (matching `send-rich`) plus the + // bare `formatting` key that the OpenClaw gateway emits on its `send` calls. + let textFormatting = + params["text_formatting"] ?? params["textFormatting"] ?? params["formatting"] let serviceRaw = stringParam(params["service"]) ?? "auto" guard let service = MessageService(rawValue: serviceRaw) else { throw RPCError.invalidParams("invalid service") @@ -262,7 +268,8 @@ extension RPCServer { let data = try await sendViaBridge( chatGUID: bridgeChatGUID, text: text, - file: file + file: file, + textFormatting: textFormatting ) var result: [String: Any] = ["ok": true, "transport": "bridge"] if let guid = data["messageGuid"] as? String, !guid.isEmpty { @@ -521,7 +528,8 @@ extension RPCServer { private func sendViaBridge( chatGUID: String, text: String, - file: String + file: String, + textFormatting: Any? = nil ) async throws -> [String: Any] { if !file.isEmpty { guard text.isEmpty else { @@ -533,7 +541,11 @@ extension RPCServer { ["chatGuid": chatGUID, "filePath": stagedFile, "isAudioMessage": false] ) } - return try await bridgeInvoker(.sendMessage, ["chatGuid": chatGUID, "message": text]) + var messageParams: [String: Any] = ["chatGuid": chatGUID, "message": text] + if let textFormatting { + messageParams["textFormatting"] = textFormatting + } + return try await bridgeInvoker(.sendMessage, messageParams) } } diff --git a/Tests/imsgTests/RPCServerBridgeTests.swift b/Tests/imsgTests/RPCServerBridgeTests.swift index 21e6111..8a6a24d 100644 --- a/Tests/imsgTests/RPCServerBridgeTests.swift +++ b/Tests/imsgTests/RPCServerBridgeTests.swift @@ -40,6 +40,62 @@ func rpcSendUsesBridgeWhenReadyAndExistingDirectChatResolves() async throws { #expect(result?["service"] as? String == "iMessage") } +@Test +func rpcSendThreadsTextFormattingToBridge() async throws { + let store = try CommandTestDatabase.makeStoreForRPCDirectChat() + let output = TestRPCOutput() + var capturedParams: [String: Any] = [:] + let server = RPCServer( + store: store, + verbose: false, + output: output, + sendMessage: { _ in }, + resolveSentMessage: { _, _, _, _ in nil }, + invokeBridge: { _, params in + capturedParams = params + return ["messageGuid": "bridge-guid", "chatGuid": "iMessage;-;+123", "service": "iMessage"] + }, + isBridgeReady: { true } + ) + + // The OpenClaw gateway emits format ranges under the bare `formatting` key. + let line = #""" + {"jsonrpc":"2.0","id":"3fmt","method":"send","params":{"to":"+123","text":"hello world","formatting":[{"start":0,"length":5,"styles":["bold"]}]}} + """# + await server.handleLineForTesting(line) + + let ranges = capturedParams["textFormatting"] as? [[String: Any]] + #expect(ranges?.count == 1) + #expect(ranges?.first?["start"] as? Int == 0) + #expect(ranges?.first?["length"] as? Int == 5) + #expect(ranges?.first?["styles"] as? [String] == ["bold"]) + #expect(capturedParams["message"] as? String == "hello world") +} + +@Test +func rpcSendWithoutFormattingOmitsTextFormatting() async throws { + let store = try CommandTestDatabase.makeStoreForRPCDirectChat() + let output = TestRPCOutput() + var capturedParams: [String: Any] = [:] + let server = RPCServer( + store: store, + verbose: false, + output: output, + sendMessage: { _ in }, + resolveSentMessage: { _, _, _, _ in nil }, + invokeBridge: { _, params in + capturedParams = params + return ["messageGuid": "bridge-guid", "chatGuid": "iMessage;-;+123", "service": "iMessage"] + }, + isBridgeReady: { true } + ) + + let line = #"{"jsonrpc":"2.0","id":"3nofmt","method":"send","params":{"to":"+123","text":"yo"}}"# + await server.handleLineForTesting(line) + + #expect(capturedParams["textFormatting"] == nil) +} + @Test func rpcSendAutoSMSDetectionKeepsAnyPrefixBridgeLookup() async throws { let store = try CommandTestDatabase.makeStoreForRPCDirectChat()