diff --git a/docs/contributing/README.md b/docs/contributing/README.md
index 3d91cf3f9..5ea066611 100644
--- a/docs/contributing/README.md
+++ b/docs/contributing/README.md
@@ -14,6 +14,6 @@ For contributors, reviewers, and maintainers.
## Suggested Reading Order
1. `CONTRIBUTING.md`
-2. `docs/pr-workflow.md`
-3. `docs/reviewer-playbook.md`
-4. `docs/ci-map.md`
+2. `pr-workflow.md`
+3. `reviewer-playbook.md`
+4. `ci-map.md`
diff --git a/docs/contributing/pr-workflow.md b/docs/contributing/pr-workflow.md
index c984b1b5a..405500842 100644
--- a/docs/contributing/pr-workflow.md
+++ b/docs/contributing/pr-workflow.md
@@ -12,8 +12,8 @@ This document defines how ZeroClaw handles high PR volume while maintaining:
Related references:
- [`docs/README.md`](../README.md) for documentation taxonomy and navigation.
-- [`docs/ci-map.md`](./ci-map.md) for per-workflow ownership, triggers, and triage flow.
-- [`docs/reviewer-playbook.md`](./reviewer-playbook.md) for day-to-day reviewer execution.
+- [`ci-map.md`](./ci-map.md) for per-workflow ownership, triggers, and triage flow.
+- [`reviewer-playbook.md`](./reviewer-playbook.md) for day-to-day reviewer execution.
## 0. Summary
@@ -44,7 +44,7 @@ Go to:
Go to:
-- [docs/ci-map.md](./ci-map.md)
+- [ci-map.md](./ci-map.md)
- [Section 4.2](#42-step-b-validation)
### 1.3 High-risk path touched
@@ -55,7 +55,7 @@ Go to:
Go to:
- [Section 9](#9-security-and-stability-rules)
-- [docs/reviewer-playbook.md](./reviewer-playbook.md)
+- [reviewer-playbook.md](./reviewer-playbook.md)
### 1.4 PR is superseded or duplicate
diff --git a/docs/contributing/reviewer-playbook.md b/docs/contributing/reviewer-playbook.md
index a04ab7299..32998825f 100644
--- a/docs/contributing/reviewer-playbook.md
+++ b/docs/contributing/reviewer-playbook.md
@@ -1,6 +1,6 @@
# Reviewer Playbook
-This playbook is the operational companion to [`docs/pr-workflow.md`](./pr-workflow.md).
+This playbook is the operational companion to [`pr-workflow.md`](./pr-workflow.md).
For broader documentation navigation, use [`docs/README.md`](../README.md).
## 0. Summary
diff --git a/docs/i18n/vi/contributing/README.md b/docs/i18n/vi/contributing/README.md
index 0549f0af8..30ea023d1 100644
--- a/docs/i18n/vi/contributing/README.md
+++ b/docs/i18n/vi/contributing/README.md
@@ -13,6 +13,6 @@ Dành cho contributor, reviewer và maintainer.
## Thứ tự đọc được đề xuất
1. `CONTRIBUTING.md`
-2. `docs/pr-workflow.md`
-3. `docs/reviewer-playbook.md`
-4. `docs/ci-map.md`
+2. `../pr-workflow.md`
+3. `../reviewer-playbook.md`
+4. `../ci-map.md`
diff --git a/docs/setup-guides/zai-glm-setup.md b/docs/setup-guides/zai-glm-setup.md
index 0692fd691..97fcec547 100644
--- a/docs/setup-guides/zai-glm-setup.md
+++ b/docs/setup-guides/zai-glm-setup.md
@@ -12,7 +12,7 @@ ZeroClaw supports these Z.AI aliases and endpoints out of the box:
| `zai` | `https://api.z.ai/api/coding/paas/v4` | Global endpoint |
| `zai-cn` | `https://open.bigmodel.cn/api/paas/v4` | China endpoint |
-If you need a custom base URL, see `docs/custom-providers.md`.
+If you need a custom base URL, see [`../contributing/custom-providers.md`](../contributing/custom-providers.md).
## Setup
diff --git a/docs/vi/contributing/README.md b/docs/vi/contributing/README.md
index f407b31de..8bad9dff4 100644
--- a/docs/vi/contributing/README.md
+++ b/docs/vi/contributing/README.md
@@ -13,6 +13,6 @@ Dành cho contributor, reviewer và maintainer.
## Thứ tự đọc được đề xuất
1. `CONTRIBUTING.md`
-2. `docs/pr-workflow.md`
-3. `docs/reviewer-playbook.md`
-4. `docs/ci-map.md`
+2. `../pr-workflow.md`
+3. `../reviewer-playbook.md`
+4. `../ci-map.md`
diff --git a/src/channels/imessage.rs b/src/channels/imessage.rs
index 4e51786f5..6eb2027a2 100644
--- a/src/channels/imessage.rs
+++ b/src/channels/imessage.rs
@@ -5,6 +5,68 @@ use rusqlite::{Connection, OpenFlags};
use std::path::Path;
use tokio::sync::mpsc;
+/// Extract plain text from an iMessage `attributedBody` typedstream blob.
+///
+/// Modern macOS (Ventura+) stores message content in `attributedBody` as an
+/// `NSMutableAttributedString` serialized via Apple's typedstream format,
+/// rather than the plain `text` column.
+///
+/// This follows the well-documented marker-based approach used by LangChain,
+/// steipete/imsg, and mac_apt (all MIT-licensed). See:
+///
+fn extract_text_from_attributed_body(blob: &[u8]) -> Option {
+ // Find the start-of-text marker: [0x01, 0x2B]
+ // 0x2B is the C-string type tag in Apple's typedstream format.
+ let marker_pos = blob.windows(2).position(|w| w == [0x01, 0x2B])?;
+ let rest = blob.get(marker_pos + 2..)?;
+
+ if rest.is_empty() {
+ return None;
+ }
+
+ // Read variable-length prefix immediately after the marker.
+ // The length determines text extent — we do NOT scan for an end marker,
+ // because byte pairs like [0x86, 0x84] can appear inside valid UTF-8
+ // (e.g. U+2184 LATIN SMALL LETTER REVERSED C encodes to E2 86 84).
+ //
+ // 0x00-0x7F => literal length (1 byte)
+ // 0x81 => next 2 bytes are little-endian u16 length
+ // 0x82 => next 4 bytes are little-endian u32 length
+ // 0x80, 0x83+ are not observed in iMessage typedstreams; reject gracefully.
+ let (length, text_start) = match rest[0] {
+ 0x81 if rest.len() >= 3 => {
+ let len = u16::from_le_bytes([rest[1], rest[2]]) as usize;
+ (len, 3)
+ }
+ 0x82 if rest.len() >= 5 => {
+ let len = u32::from_le_bytes([rest[1], rest[2], rest[3], rest[4]]) as usize;
+ (len, 5)
+ }
+ b if b <= 0x7F => (b as usize, 1),
+ _ => return None,
+ };
+
+ let text_bytes = rest.get(text_start..text_start + length)?;
+ std::str::from_utf8(text_bytes).ok().map(str::to_owned)
+}
+
+/// Resolve message content from the `text` column with `attributedBody` fallback.
+///
+/// Prefers the plain `text` column when present. Falls back to parsing the
+/// typedstream blob in `attributedBody` (modern macOS). Logs a warning when
+/// `attributedBody` exists but cannot be parsed.
+fn resolve_message_content(rowid: i64, text: Option, body: Option>) -> String {
+ text.filter(|t| !t.trim().is_empty())
+ .or_else(|| {
+ let parsed = body.as_deref().and_then(extract_text_from_attributed_body);
+ if parsed.is_none() && body.as_ref().is_some_and(|b| !b.is_empty()) {
+ tracing::warn!(rowid, "failed to parse attributedBody");
+ }
+ parsed
+ })
+ .unwrap_or_default()
+}
+
/// iMessage channel using macOS `AppleScript` bridge.
/// Polls the Messages database for new messages and sends replies via `osascript`.
#[derive(Clone)]
@@ -179,21 +241,21 @@ end tell"#
move || -> (Connection, anyhow::Result>) {
let result = (|| -> anyhow::Result> {
let mut stmt = conn.prepare(
- "SELECT m.ROWID, h.id, m.text \
+ "SELECT m.ROWID, h.id, m.text, m.attributedBody \
FROM message m \
JOIN handle h ON m.handle_id = h.ROWID \
WHERE m.ROWID > ?1 \
AND m.is_from_me = 0 \
- AND m.text IS NOT NULL \
+ AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL) \
ORDER BY m.ROWID ASC \
LIMIT 20",
)?;
let rows = stmt.query_map([since], |row| {
- Ok((
- row.get::<_, i64>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, String>(2)?,
- ))
+ let rowid = row.get::<_, i64>(0)?;
+ let sender = row.get::<_, String>(1)?;
+ let text: Option = row.get(2)?;
+ let body: Option> = row.get(3)?;
+ Ok((rowid, sender, resolve_message_content(rowid, text, body)))
})?;
let results = rows.collect::, _>>()?;
Ok(results)
@@ -291,23 +353,28 @@ async fn fetch_new_messages(
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
let mut stmt = conn.prepare(
- "SELECT m.ROWID, h.id, m.text \
+ "SELECT m.ROWID, h.id, m.text, m.attributedBody \
FROM message m \
JOIN handle h ON m.handle_id = h.ROWID \
WHERE m.ROWID > ?1 \
AND m.is_from_me = 0 \
- AND m.text IS NOT NULL \
+ AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL) \
ORDER BY m.ROWID ASC \
LIMIT 20",
)?;
let rows = stmt.query_map([since_rowid], |row| {
- Ok((
- row.get::<_, i64>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, String>(2)?,
- ))
+ let rowid = row.get::<_, i64>(0)?;
+ let sender = row.get::<_, String>(1)?;
+ let text: Option = row.get(2)?;
+ let body: Option> = row.get(3)?;
+ Ok((rowid, sender, resolve_message_content(rowid, text, body)))
})?;
- rows.collect::, _>>().map_err(Into::into)
+ let results: Vec<_> = rows
+ .collect::, _>>()?
+ .into_iter()
+ .filter(|(_, _, content)| !content.trim().is_empty())
+ .collect();
+ Ok(results)
})
.await??;
Ok(results)
@@ -608,6 +675,7 @@ mod tests {
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
+ attributedBody BLOB,
is_from_me INTEGER DEFAULT 0,
FOREIGN KEY (handle_id) REFERENCES handle(ROWID)
);",
@@ -773,10 +841,10 @@ mod tests {
}
#[tokio::test]
- async fn fetch_new_messages_excludes_null_text() {
+ async fn fetch_new_messages_excludes_null_text_and_null_body() {
let (_dir, db_path) = create_test_db();
- // Insert test data
+ // Insert test data: one with text, one with neither text nor attributedBody
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
@@ -789,13 +857,14 @@ mod tests {
[]
).unwrap();
conn.execute(
- "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, NULL, 0)",
+ "INSERT INTO message (ROWID, handle_id, text, attributedBody, is_from_me) VALUES (20, 1, NULL, NULL, 0)",
[],
)
.unwrap();
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
+ // Message with NULL text AND NULL attributedBody is excluded
assert_eq!(result.len(), 1);
assert_eq!(result[0].2, "Has text");
}
@@ -913,7 +982,7 @@ mod tests {
}
#[tokio::test]
- async fn fetch_new_messages_handles_empty_text() {
+ async fn fetch_new_messages_filters_empty_text() {
let (_dir, db_path) = create_test_db();
{
@@ -931,9 +1000,8 @@ mod tests {
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
- // Empty string is NOT NULL, so it's included
- assert_eq!(result.len(), 1);
- assert_eq!(result[0].2, "");
+ // Empty-content messages are filtered out
+ assert!(result.is_empty());
}
#[tokio::test]
@@ -979,4 +1047,270 @@ mod tests {
let result = fetch_new_messages(&db_path, i64::MAX - 1).await.unwrap();
assert!(result.is_empty());
}
+
+ // ══════════════════════════════════════════════════════════
+ // attributedBody / typedstream parsing tests
+ // ══════════════════════════════════════════════════════════
+
+ /// Build a minimal typedstream blob containing the given text.
+ /// Format: [header] [class bytes] [0x01, 0x2B] [length-prefix] [utf8] [0x86, 0x84]
+ fn make_attributed_body(text: &str) -> Vec {
+ let text_bytes = text.as_bytes();
+ let mut blob = Vec::new();
+ // Fake streamtyped header (not parsed by our extractor)
+ blob.extend_from_slice(b"\x04\x0bstreamtyped\x81\xe8\x03");
+ // Class hierarchy bytes (skipped by marker scan)
+ blob.extend_from_slice(b"\x84\x84NSMutableAttributedString\x00");
+ // Start-of-text marker
+ blob.push(0x01);
+ blob.push(0x2B);
+ // Length prefix (try_from panics on violation — correct for test helper)
+ let len = text_bytes.len();
+ if len <= 0x7F {
+ blob.push(u8::try_from(len).unwrap());
+ } else if len <= 0xFFFF {
+ blob.push(0x81);
+ blob.extend_from_slice(&u16::try_from(len).unwrap().to_le_bytes());
+ } else {
+ blob.push(0x82);
+ blob.extend_from_slice(&u32::try_from(len).unwrap().to_le_bytes());
+ }
+ // Text content
+ blob.extend_from_slice(text_bytes);
+ // End-of-text marker
+ blob.push(0x86);
+ blob.push(0x84);
+ // Trailing attribute bytes (ignored)
+ blob.extend_from_slice(b"\x86\x86");
+ blob
+ }
+
+ // Real attributedBody blob from macOS chat.db, captured during testing.
+ // Decodes to: "Testing with imsg installed"
+ const REAL_BLOB_TESTING: &[u8] = &[
+ 0x04, 0x0B, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6D, 0x74, 0x79, 0x70, 0x65, 0x64, 0x81, 0xE8,
+ 0x03, 0x84, 0x01, 0x40, 0x84, 0x84, 0x84, 0x12, 0x4E, 0x53, 0x41, 0x74, 0x74, 0x72, 0x69,
+ 0x62, 0x75, 0x74, 0x65, 0x64, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, 0x00, 0x84, 0x84, 0x08,
+ 0x4E, 0x53, 0x4F, 0x62, 0x6A, 0x65, 0x63, 0x74, 0x00, 0x85, 0x92, 0x84, 0x84, 0x84, 0x08,
+ 0x4E, 0x53, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, 0x01, 0x94, 0x84, 0x01, 0x2B, 0x1B, 0x54,
+ 0x65, 0x73, 0x74, 0x69, 0x6E, 0x67, 0x20, 0x77, 0x69, 0x74, 0x68, 0x20, 0x69, 0x6D, 0x73,
+ 0x67, 0x20, 0x69, 0x6E, 0x73, 0x74, 0x61, 0x6C, 0x6C, 0x65, 0x64, 0x86, 0x84, 0x02, 0x69,
+ 0x49, 0x01, 0x1B, 0x92, 0x84, 0x84, 0x84, 0x0C, 0x4E, 0x53, 0x44, 0x69, 0x63, 0x74, 0x69,
+ 0x6F, 0x6E, 0x61, 0x72, 0x79, 0x00, 0x94, 0x84, 0x01, 0x69, 0x01, 0x92, 0x84, 0x96, 0x96,
+ 0x1D, 0x5F, 0x5F, 0x6B, 0x49, 0x4D, 0x4D, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x50, 0x61,
+ 0x72, 0x74, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x4E, 0x61, 0x6D, 0x65,
+ 0x86, 0x92, 0x84, 0x84, 0x84, 0x08, 0x4E, 0x53, 0x4E, 0x75, 0x6D, 0x62, 0x65, 0x72, 0x00,
+ 0x84, 0x84, 0x07, 0x4E, 0x53, 0x56, 0x61, 0x6C, 0x75, 0x65, 0x00, 0x94, 0x84, 0x01, 0x2A,
+ 0x84, 0x99, 0x99, 0x00, 0x86, 0x86, 0x86,
+ ];
+
+ // Real attributedBody blob from unknownbreaker/MessageBridge (MIT).
+ // Decodes to: "1"
+ const REAL_BLOB_ONE: &[u8] = &[
+ 0x04, 0x0b, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x74, 0x79, 0x70, 0x65, 0x64, 0x81, 0xe8,
+ 0x03, 0x84, 0x01, 0x40, 0x84, 0x84, 0x84, 0x12, 0x4e, 0x53, 0x41, 0x74, 0x74, 0x72, 0x69,
+ 0x62, 0x75, 0x74, 0x65, 0x64, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x00, 0x84, 0x84, 0x08,
+ 0x4e, 0x53, 0x4f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x00, 0x85, 0x92, 0x84, 0x84, 0x84, 0x08,
+ 0x4e, 0x53, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x01, 0x94, 0x84, 0x01, 0x2b, 0x01, 0x31,
+ 0x86, 0x84, 0x02, 0x69, 0x49, 0x01, 0x01, 0x92, 0x84, 0x84, 0x84, 0x0c, 0x4e, 0x53, 0x44,
+ 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x72, 0x79, 0x00, 0x94, 0x84, 0x01, 0x69, 0x01,
+ 0x92, 0x84, 0x96, 0x96, 0x1d, 0x5f, 0x5f, 0x6b, 0x49, 0x4d, 0x4d, 0x65, 0x73, 0x73, 0x61,
+ 0x67, 0x65, 0x50, 0x61, 0x72, 0x74, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65,
+ 0x4e, 0x61, 0x6d, 0x65, 0x86, 0x92, 0x84, 0x84, 0x84, 0x08, 0x4e, 0x53, 0x4e, 0x75, 0x6d,
+ 0x62, 0x65, 0x72, 0x00, 0x84, 0x84, 0x07, 0x4e, 0x53, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x00,
+ 0x94, 0x84, 0x01, 0x2a, 0x84, 0x99, 0x99, 0x00, 0x86, 0x86, 0x86,
+ ];
+
+ #[test]
+ fn extract_real_blob_testing_with_imsg() {
+ let result = extract_text_from_attributed_body(REAL_BLOB_TESTING);
+ assert_eq!(result, Some("Testing with imsg installed".to_string()));
+ }
+
+ #[test]
+ fn extract_real_blob_single_char() {
+ // From unknownbreaker/MessageBridge (MIT)
+ let result = extract_text_from_attributed_body(REAL_BLOB_ONE);
+ assert_eq!(result, Some("1".to_string()));
+ }
+
+ #[test]
+ fn extract_text_containing_end_marker_bytes() {
+ // U+2184 LATIN SMALL LETTER REVERSED C encodes to E2 86 84 in UTF-8.
+ // The old parser scanned for [0x86, 0x84] as end marker and would
+ // truncate here. The length-based parser must handle this correctly.
+ let text = "before\u{2184}after";
+ let blob = make_attributed_body(text);
+ let result = extract_text_from_attributed_body(&blob);
+ assert_eq!(result, Some(text.to_string()));
+ }
+
+ #[test]
+ fn extract_zero_length_returns_empty_string() {
+ // Marker found with length prefix = 0. Valid typedstream encoding
+ // for an empty NSString — parser returns Some(""), which
+ // resolve_message_content() will treat as empty and discard.
+ let blob = b"\x01\x2B\x00";
+ let result = extract_text_from_attributed_body(blob);
+ assert_eq!(result, Some(String::new()));
+ }
+
+ #[test]
+ fn extract_no_markers_returns_none() {
+ let blob = b"just some random bytes with no markers";
+ let result = extract_text_from_attributed_body(blob);
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn extract_invalid_utf8_returns_none() {
+ let blob = b"\x01\x2B\x04\xFF\xFE\x80\x81";
+ let result = extract_text_from_attributed_body(blob);
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn extract_truncated_blob_returns_none() {
+ // Length prefix says 27 bytes but blob is truncated
+ let blob = b"\x01\x2B\x1B\x54\x65\x73\x74";
+ let result = extract_text_from_attributed_body(blob);
+ assert!(result.is_none());
+ }
+
+ #[test]
+ fn extract_long_text_two_byte_length() {
+ // >127 bytes triggers 0x81 length prefix
+ let long_text: String = "A".repeat(200);
+ let blob = make_attributed_body(&long_text);
+ let result = extract_text_from_attributed_body(&blob);
+ assert_eq!(result, Some(long_text));
+ }
+
+ #[test]
+ fn extract_four_byte_length_prefix() {
+ // Test the 0x82 branch: 4-byte little-endian u32 length prefix.
+ // Construct directly — make_attributed_body only emits 0x82 for >64KB.
+ let text = b"Hello";
+ let mut blob = Vec::new();
+ blob.extend_from_slice(b"\x01\x2B"); // start marker
+ blob.push(0x82); // 4-byte length tag
+ blob.extend_from_slice(&5_u32.to_le_bytes()); // length = 5
+ blob.extend_from_slice(text);
+ let result = extract_text_from_attributed_body(&blob);
+ assert_eq!(result, Some("Hello".to_string()));
+ }
+
+ #[test]
+ fn extract_text_boundary_127_to_128() {
+ // 127 is max single-byte length, 128 is min two-byte length
+ for len in [127, 128] {
+ let text: String = "X".repeat(len);
+ let blob = make_attributed_body(&text);
+ let result = extract_text_from_attributed_body(&blob);
+ assert_eq!(result, Some(text), "failed at length {len}");
+ }
+ }
+
+ #[tokio::test]
+ async fn fetch_new_messages_reads_attributed_body_fallback() {
+ let (_dir, db_path) = create_test_db();
+
+ {
+ let conn = Connection::open(&db_path).unwrap();
+ conn.execute(
+ "INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
+ [],
+ )
+ .unwrap();
+ // Real blob from macOS chat.db — text=NULL, attributedBody present
+ conn.execute(
+ "INSERT INTO message (ROWID, handle_id, text, attributedBody, is_from_me) VALUES (10, 1, NULL, ?1, 0)",
+ [REAL_BLOB_TESTING.to_vec()],
+ ).unwrap();
+ }
+
+ let result = fetch_new_messages(&db_path, 0).await.unwrap();
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].2, "Testing with imsg installed");
+ }
+
+ #[tokio::test]
+ async fn fetch_new_messages_empty_text_falls_back_to_attributed_body() {
+ let (_dir, db_path) = create_test_db();
+
+ {
+ let conn = Connection::open(&db_path).unwrap();
+ conn.execute(
+ "INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
+ [],
+ )
+ .unwrap();
+ // text = '' (empty string, not NULL) with valid attributedBody
+ conn.execute(
+ "INSERT INTO message (ROWID, handle_id, text, attributedBody, is_from_me) VALUES (10, 1, '', ?1, 0)",
+ [REAL_BLOB_ONE.to_vec()],
+ ).unwrap();
+ }
+
+ let result = fetch_new_messages(&db_path, 0).await.unwrap();
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].2, "1");
+ }
+
+ #[tokio::test]
+ async fn fetch_new_messages_prefers_text_over_attributed_body() {
+ let (_dir, db_path) = create_test_db();
+
+ {
+ let conn = Connection::open(&db_path).unwrap();
+ conn.execute(
+ "INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
+ [],
+ )
+ .unwrap();
+ // Both text and attributedBody present — text column wins
+ conn.execute(
+ "INSERT INTO message (ROWID, handle_id, text, attributedBody, is_from_me) VALUES (10, 1, 'Plain text', ?1, 0)",
+ [REAL_BLOB_ONE.to_vec()],
+ ).unwrap();
+ }
+
+ let result = fetch_new_messages(&db_path, 0).await.unwrap();
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].2, "Plain text");
+ }
+
+ #[tokio::test]
+ async fn fetch_new_messages_mixed_text_and_attributed_body() {
+ let (_dir, db_path) = create_test_db();
+
+ {
+ let conn = Connection::open(&db_path).unwrap();
+ conn.execute(
+ "INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
+ [],
+ )
+ .unwrap();
+ // Old-style message with text column
+ conn.execute(
+ "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Legacy message', 0)",
+ []
+ ).unwrap();
+ // Modern message with only attributedBody (real blob)
+ conn.execute(
+ "INSERT INTO message (ROWID, handle_id, text, attributedBody, is_from_me) VALUES (20, 1, NULL, ?1, 0)",
+ [REAL_BLOB_ONE.to_vec()],
+ ).unwrap();
+ // Message with neither (should be excluded)
+ conn.execute(
+ "INSERT INTO message (ROWID, handle_id, text, attributedBody, is_from_me) VALUES (30, 1, NULL, NULL, 0)",
+ [],
+ ).unwrap();
+ }
+
+ let result = fetch_new_messages(&db_path, 0).await.unwrap();
+ assert_eq!(result.len(), 2);
+ assert_eq!(result[0].2, "Legacy message");
+ assert_eq!(result[1].2, "1");
+ }
}