Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions Sources/imsg/RPCServer+Handlers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}

Expand Down
56 changes: 56 additions & 0 deletions Tests/imsgTests/RPCServerBridgeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading