feat(gmessages): scaffold Google Messages Web scanner (Windows, read-only IDB) (#735)

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
Jwalin Shah
2026-04-24 08:29:53 -07:00
committed by GitHub
co-authored by Jwalin Shah
parent 5542acd1cd
commit a3b0a78f2b
4 changed files with 763 additions and 0 deletions
@@ -0,0 +1,205 @@
//! CDP-driven walk of the Google Messages Web `bugle_db` IndexedDB.
//!
//! Pairs with `idb.rs` (schema + normalization). This module does the
//! `IndexedDB.requestData` paging + `Runtime.callFunctionOn` serialisation
//! dance, then hands the raw JSON rows to `idb::normalize_*` for shape
//! checking.
use serde_json::{json, Value};
use super::idb::{
self, Conversation, Message, ParticipantMap, DATABASE_NAME, STORE_CONVERSATIONS,
STORE_MESSAGES, STORE_PARTICIPANTS,
};
use crate::cdp::CdpConn;
/// IndexedDB security origin for the Google Messages Web app.
const ORIGIN: &str = "https://messages.google.com";
/// Rows per `IndexedDB.requestData` call — matches the WhatsApp scanner.
const PAGE_SIZE: i64 = 500;
/// Hard cap per store to bound full-scan cost on huge histories.
const MAX_RECORDS_PER_STORE: usize = 20_000;
/// `Runtime.callFunctionOn` batch size for RemoteObject serialisation.
const SERIALIZE_BATCH: usize = 100;
pub struct WalkResult {
pub messages: Vec<Message>,
pub conversations: Vec<Conversation>,
pub participants: ParticipantMap,
}
/// Walk `bugle_db`: messages, conversations, participants. Per-store
/// failures are logged and swallowed so one bad store doesn't nuke the
/// cycle — the caller still gets whatever did come back.
pub async fn walk(cdp: &mut CdpConn, session: &str) -> Result<WalkResult, String> {
// `IndexedDB.enable` is a no-op on modern Chromium but older CEF
// builds refuse `requestData` without it. Cost is trivial.
if let Err(e) = cdp.call("IndexedDB.enable", json!({}), Some(session)).await {
log::debug!("[gmessages][idb] enable: {}", e);
}
let messages_raw = match read_store(cdp, session, STORE_MESSAGES).await {
Ok(v) => v,
Err(e) => {
log::warn!("[gmessages][idb] read {} failed: {}", STORE_MESSAGES, e);
Vec::new()
}
};
let convos_raw = match read_store(cdp, session, STORE_CONVERSATIONS).await {
Ok(v) => v,
Err(e) => {
log::warn!("[gmessages][idb] read {} failed: {}", STORE_CONVERSATIONS, e);
Vec::new()
}
};
let parts_raw = match read_store(cdp, session, STORE_PARTICIPANTS).await {
Ok(v) => v,
Err(e) => {
log::warn!("[gmessages][idb] read {} failed: {}", STORE_PARTICIPANTS, e);
Vec::new()
}
};
let messages: Vec<Message> = messages_raw
.iter()
.filter_map(idb::normalize_message)
.collect();
let conversations: Vec<Conversation> = convos_raw
.iter()
.filter_map(idb::normalize_conversation)
.collect();
let mut participants = ParticipantMap::default();
for raw in &parts_raw {
if let Some((id, name)) = idb::normalize_participant(raw) {
participants.insert(id, name);
}
}
log::info!(
"[gmessages][idb] walk messages={} conversations={} participants={}",
messages.len(),
conversations.len(),
participants.len()
);
Ok(WalkResult {
messages,
conversations,
participants,
})
}
async fn read_store(cdp: &mut CdpConn, session: &str, store: &str) -> Result<Vec<Value>, String> {
let mut out: Vec<Value> = Vec::new();
let mut skip: i64 = 0;
loop {
let remaining = MAX_RECORDS_PER_STORE.saturating_sub(out.len());
if remaining == 0 {
break;
}
let page = (remaining as i64).min(PAGE_SIZE);
let resp = cdp
.call(
"IndexedDB.requestData",
json!({
"securityOrigin": ORIGIN,
"databaseName": DATABASE_NAME,
"objectStoreName": store,
"indexName": "",
"skipCount": skip,
"pageSize": page,
}),
Some(session),
)
.await?;
let entries = resp
.get("objectStoreDataEntries")
.and_then(|x| x.as_array())
.cloned()
.unwrap_or_default();
if entries.is_empty() {
break;
}
let value_refs: Vec<&Value> = entries
.iter()
.map(|e| e.get("value").unwrap_or(&Value::Null))
.collect();
let materialised = serialize_values(cdp, session, &value_refs).await?;
out.extend(materialised);
let has_more = resp
.get("hasMore")
.and_then(|x| x.as_bool())
.unwrap_or(false);
skip += entries.len() as i64;
if !has_more {
break;
}
}
log::debug!("[gmessages][idb] store={} records={}", store, out.len());
Ok(out)
}
async fn serialize_values(
cdp: &mut CdpConn,
session: &str,
values: &[&Value],
) -> Result<Vec<Value>, String> {
let mut result: Vec<Value> = vec![Value::Null; values.len()];
let mut pending: Vec<(usize, String)> = Vec::new();
for (i, v) in values.iter().enumerate() {
if let Some(inline) = v.get("value") {
result[i] = inline.clone();
continue;
}
if let Some(oid) = v.get("objectId").and_then(|x| x.as_str()) {
pending.push((i, oid.to_string()));
}
}
for chunk in pending.chunks(SERIALIZE_BATCH) {
let oids: Vec<&str> = chunk.iter().map(|(_, oid)| oid.as_str()).collect();
let serialised = call_function_batch(cdp, session, &oids).await?;
if serialised.len() != chunk.len() {
return Err(format!(
"serialise batch length mismatch: got {}, expected {}",
serialised.len(),
chunk.len()
));
}
for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) {
result[*idx] = val;
}
}
Ok(result)
}
async fn call_function_batch(
cdp: &mut CdpConn,
session: &str,
object_ids: &[&str],
) -> Result<Vec<Value>, String> {
if object_ids.is_empty() {
return Ok(Vec::new());
}
let (first, rest) = object_ids.split_first().unwrap();
let args: Vec<Value> = rest.iter().map(|oid| json!({ "objectId": oid })).collect();
let resp = cdp
.call(
"Runtime.callFunctionOn",
json!({
"objectId": first,
"functionDeclaration": "function(){return [this].concat(Array.prototype.slice.call(arguments));}",
"arguments": args,
"returnByValue": true,
"silent": true,
}),
Some(session),
)
.await?;
if let Some(exc) = resp.get("exceptionDetails") {
return Err(format!("callFunctionOn threw: {exc}"));
}
resp.pointer("/result/value")
.and_then(|v| v.as_array())
.cloned()
.ok_or_else(|| format!("callFunctionOn result not array: {resp}"))
}
+313
View File
@@ -0,0 +1,313 @@
//! Google Messages Web `bugle_db` IndexedDB schema + normalization.
//!
//! Schema knowledge is taken from publicly documented reverse-engineering
//! of the Google Messages Web client (the `mautrix-gmessages` project and
//! the Google Messages Web source itself). No code is copied —
//! only the factual store / key shape, which is not copyrightable.
//!
//! Stores we care about:
//! * `conversations` — thread metadata (id, participant ids, name)
//! * `messages` — individual SMS/RCS rows
//! * `participants` — participant id → contact name resolution
//!
//! Stores we deliberately skip:
//! * `settings`, `drafts`, `attachments-cache` — not needed for recall.
//!
//! This module only holds schema + normalization. The CDP walk that
//! actually calls `IndexedDB.requestData` will live alongside the WhatsApp
//! scanner's CDP plumbing once we lift a shared `cdp` module — see the
//! TODO in `mod.rs`.
use std::collections::HashMap;
use serde_json::Value;
/// `bugle_db` database name. Stable since ~2022 per mautrix-gmessages
/// history; Google has not shipped a schema rename in the tracked window.
pub const DATABASE_NAME: &str = "bugle_db";
pub const STORE_CONVERSATIONS: &str = "conversations";
pub const STORE_MESSAGES: &str = "messages";
pub const STORE_PARTICIPANTS: &str = "participants";
/// Normalized message row emitted to the memory-doc pipeline.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Message {
pub id: String,
pub thread_id: Option<String>,
/// `None` when the message is outbound (sent by the user).
pub sender_id: Option<String>,
pub from_me: bool,
/// Plain UTF-8 body. Attachments / reactions collapse to empty string
/// at normalization — callers render them as `[non-text]`.
pub text: String,
pub timestamp_unix: i64,
/// "sms", "rcs", "mms", etc. Preserved for downstream filters.
pub message_type: Option<String>,
}
/// Normalized conversation (thread) metadata.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Conversation {
pub thread_id: String,
pub display_name: Option<String>,
pub participant_ids: Vec<String>,
}
/// Participant-id → display-name map. Populated from the `participants`
/// store; used by `format_transcript` to render human-readable senders.
#[derive(Debug, Default, Clone)]
pub struct ParticipantMap {
inner: HashMap<String, String>,
}
impl ParticipantMap {
pub fn insert(&mut self, id: String, name: String) {
self.inner.insert(id, name);
}
pub fn display_name(&self, id: &str) -> Option<String> {
self.inner.get(id).cloned()
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
}
/// Convert a raw JSON row from the `messages` object store into our
/// normalized shape. Returns `None` if required fields are missing or
/// malformed — we log + skip rather than failing the entire walk.
///
/// Expected bugle_db fields (observed, documented in mautrix-gmessages):
/// * `messageId` (string) — primary key
/// * `conversationId` (string)
/// * `senderId` (string, absent for outgoing)
/// * `messageStatus` (object with `status` int; outgoing statuses 2/4/6)
/// * `text` (string; may be absent for attachment-only)
/// * `timestamp` (int; microseconds since unix epoch)
/// * `messageType` (string: "SMS", "RCS", etc.)
pub fn normalize_message(raw: &Value) -> Option<Message> {
let id = raw.get("messageId")?.as_str()?.to_string();
let thread_id = raw
.get("conversationId")
.and_then(|v| v.as_str())
.map(str::to_string);
let sender_id = raw
.get("senderId")
.and_then(|v| v.as_str())
.map(str::to_string);
let from_me = is_outgoing(raw);
let text = raw
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// bugle_db timestamps are microseconds since unix epoch. Guard against
// the legacy-seconds form (< 10^12 = before year 33700 in micros,
// practically any real timestamp is in the 10^15 range).
let timestamp_unix = raw.get("timestamp").and_then(|v| v.as_i64()).map(|t| {
if t > 1_000_000_000_000 {
t / 1_000_000
} else {
t
}
})?;
let message_type = raw
.get("messageType")
.and_then(|v| v.as_str())
.map(|s| s.to_ascii_lowercase());
Some(Message {
id,
thread_id,
sender_id: if from_me { None } else { sender_id },
from_me,
text,
timestamp_unix,
message_type,
})
}
/// Heuristic: bugle_db marks outgoing messages with a `messageStatus`
/// object whose `status` is in {2 (OUTGOING_DELIVERED), 4 (OUTGOING_READ),
/// 6 (OUTGOING_FAILED)} or an explicit boolean `isOutgoing` on newer
/// schemas. Fall back to `senderId == null` which is also a reliable
/// signal on older writes.
fn is_outgoing(raw: &Value) -> bool {
if let Some(b) = raw.get("isOutgoing").and_then(|v| v.as_bool()) {
return b;
}
if let Some(status) = raw
.get("messageStatus")
.and_then(|s| s.get("status"))
.and_then(|v| v.as_i64())
{
// Status codes 1-9 are outgoing; 10+ are incoming (OUTGOING_* vs
// INCOMING_* in the bugle_db protobuf enum). Exact values per
// mautrix-gmessages' `libgm/events/types.go`.
return (1..=9).contains(&status);
}
raw.get("senderId")
.map(|v| v.is_null() || v.as_str().is_some_and(str::is_empty))
.unwrap_or(false)
}
/// Normalize a `conversations` store row.
pub fn normalize_conversation(raw: &Value) -> Option<Conversation> {
let thread_id = raw.get("conversationId")?.as_str()?.to_string();
let display_name = raw.get("name").and_then(|v| v.as_str()).map(str::to_string);
let participant_ids = raw
.get("participantIds")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
Some(Conversation {
thread_id,
display_name,
participant_ids,
})
}
/// Normalize a `participants` store row into `(id, name)`.
pub fn normalize_participant(raw: &Value) -> Option<(String, String)> {
let id = raw.get("participantId")?.as_str()?.to_string();
let name = raw
.get("fullName")
.or_else(|| raw.get("firstName"))
.or_else(|| raw.get("displayName"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
None
} else {
Some((id, name))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn normalize_incoming_sms_row() {
let raw = json!({
"messageId": "msg-1",
"conversationId": "thread-1",
"senderId": "+15551234567",
"text": "hello",
"timestamp": 1_700_000_000_000_000i64,
"messageType": "SMS",
"messageStatus": { "status": 100 },
});
let m = normalize_message(&raw).expect("normalize ok");
assert_eq!(m.id, "msg-1");
assert_eq!(m.thread_id.as_deref(), Some("thread-1"));
assert_eq!(m.sender_id.as_deref(), Some("+15551234567"));
assert!(!m.from_me);
assert_eq!(m.text, "hello");
assert_eq!(m.timestamp_unix, 1_700_000_000);
assert_eq!(m.message_type.as_deref(), Some("sms"));
}
#[test]
fn normalize_outgoing_row_sets_from_me_and_blanks_sender() {
let raw = json!({
"messageId": "msg-2",
"conversationId": "thread-1",
"senderId": "+15559998888",
"text": "yo",
"timestamp": 1_700_000_005_000_000i64,
"messageStatus": { "status": 4 },
});
let m = normalize_message(&raw).expect("normalize ok");
assert!(m.from_me, "status=4 is OUTGOING_READ");
assert!(m.sender_id.is_none(), "outgoing rows blank the sender");
}
#[test]
fn normalize_accepts_legacy_second_precision_timestamp() {
let raw = json!({
"messageId": "msg-3",
"conversationId": "thread-1",
"senderId": "+15551234567",
"text": "hi",
"timestamp": 1_700_000_000i64,
"messageStatus": { "status": 100 },
});
let m = normalize_message(&raw).expect("normalize ok");
assert_eq!(m.timestamp_unix, 1_700_000_000);
}
#[test]
fn normalize_skips_row_missing_required_fields() {
let raw = json!({
"conversationId": "thread-1",
"text": "no id",
"timestamp": 1_700_000_000_000_000i64,
});
assert!(normalize_message(&raw).is_none());
}
#[test]
fn normalize_conversation_row_with_participants() {
let raw = json!({
"conversationId": "thread-1",
"name": "Family Group",
"participantIds": ["+15551234567", "+15559998888"],
});
let c = normalize_conversation(&raw).expect("normalize ok");
assert_eq!(c.thread_id, "thread-1");
assert_eq!(c.display_name.as_deref(), Some("Family Group"));
assert_eq!(c.participant_ids.len(), 2);
}
#[test]
fn normalize_participant_row_prefers_full_name() {
let raw = json!({
"participantId": "+15551234567",
"fullName": "Alice Example",
"firstName": "Alice",
});
let (id, name) = normalize_participant(&raw).expect("normalize ok");
assert_eq!(id, "+15551234567");
assert_eq!(name, "Alice Example");
}
#[test]
fn normalize_participant_falls_back_to_first_name() {
let raw = json!({
"participantId": "+15551234567",
"firstName": "Alice",
});
let (_, name) = normalize_participant(&raw).expect("normalize ok");
assert_eq!(name, "Alice");
}
#[test]
fn normalize_participant_returns_none_for_empty_name() {
let raw = json!({
"participantId": "+15551234567",
});
assert!(normalize_participant(&raw).is_none());
}
#[test]
fn participant_map_roundtrip() {
let mut pm = ParticipantMap::default();
assert!(pm.is_empty());
pm.insert("+15551234567".into(), "Alice".into());
assert_eq!(pm.len(), 1);
assert_eq!(pm.display_name("+15551234567").as_deref(), Some("Alice"));
assert!(pm.display_name("unknown").is_none());
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Google Messages Web scanner — Windows-focused, read-only IndexedDB walk.
//!
//! Scope for Stage 1:
//! * Read-only scan of `bugle_db` (the IndexedDB database used by
//! `messages.google.com/web`) via CDP on the embedded CEF webview.
//! * One ingest call per `(thread_id, day)` group — same
//! `openhuman.memory_doc_ingest` shape the iMessage and WhatsApp
//! scanners already use.
//! * No DOM automation. No send path. Send is deferred to a separate
//! PR that will use OS Accessibility APIs (macOS AX / Windows UIA) —
//! indistinguishable from a screen reader, so ToS-clean.
//!
//! Targeted at Windows + Android (the only practical combo for Google
//! Messages — iPhone owners use iMessage, mac users typically use
//! Messages Web in a browser tab that the CEF shell doesn't own). The
//! code is windows-gated at module-scope; on other targets the public
//! surface compiles to no-op stubs so `lib.rs` stays clean.
//!
//! History model differs from iMessage:
//! * iMessage (#724) reads `chat.db` which holds FULL history locally.
//! * Google Messages Web only caches in `bugle_db` what the web client
//! has already synced. If the user never scrolled to older
//! conversations, those pages aren't in IDB. Document this behavior
//! in the UI — "scroll to backfill older history."
//!
//! CDP wiring TODO:
//! * The CEF remote-debugging port + per-account target selection lives
//! in `whatsapp_scanner::mod` today (`CDP_HOST`/`CDP_PORT`,
//! `Target.getTargets` filter). When this module is promoted from
//! scaffold to running scanner, lift that plumbing into a shared
//! `cdp` module and point this scanner at the Google Messages Web
//! target (`messages.google.com/web`). Until then `run_scanner` is a
//! stub that logs and exits — the PR ships the normalization +
//! memory-doc shape so downstream can iterate without the full CDP
//! loop landed.
// Scaffold PR — orchestrator loop is a stub pending the shared CDP lift
// from `whatsapp_scanner`. Once that lands and this module actually
// drives `idb::walk` + `memory_doc_ingest`, drop the blanket allow below.
#![allow(dead_code)]
#[cfg(target_os = "windows")]
use std::sync::Arc;
#[cfg(target_os = "windows")]
use std::time::Duration;
#[cfg(target_os = "windows")]
use parking_lot::Mutex;
#[cfg(target_os = "windows")]
use tauri::{AppHandle, Runtime};
pub mod idb;
#[cfg(target_os = "windows")]
const SCAN_INTERVAL: Duration = Duration::from_secs(60);
/// Per-account scanner registry. Google Messages Web supports one paired
/// phone per browser session; the registry shape is kept symmetric with
/// the iMessage / WhatsApp scanners for future multi-account expansion.
#[cfg(target_os = "windows")]
pub struct ScannerRegistry {
inner: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
#[cfg(target_os = "windows")]
impl ScannerRegistry {
pub fn new() -> Self {
Self {
inner: Mutex::new(None),
}
}
pub fn ensure_scanner<R: Runtime>(self: Arc<Self>, app: AppHandle<R>, account_id: String) {
let mut guard = self.inner.lock();
if guard.as_ref().map_or(false, |h| !h.is_finished()) {
return;
}
let handle = tokio::spawn(run_scanner(app, account_id));
*guard = Some(handle);
}
}
/// Stub loop — logs and exits. Wire CDP target discovery + `idb::walk`
/// here once the shared `cdp` module is lifted from `whatsapp_scanner`.
/// See module-level TODO.
#[cfg(target_os = "windows")]
async fn run_scanner<R: Runtime>(_app: AppHandle<R>, account_id: String) {
log::info!(
"[gmessages] scanner scaffold loaded account={} interval={:?} — CDP wiring pending",
account_id,
SCAN_INTERVAL
);
}
// Non-Windows stub so the rest of the app compiles unchanged on mac/linux.
#[cfg(not(target_os = "windows"))]
pub struct ScannerRegistry;
#[cfg(not(target_os = "windows"))]
impl ScannerRegistry {
pub fn new() -> Self {
Self
}
pub fn ensure_scanner<R: tauri::Runtime>(
self: std::sync::Arc<Self>,
_app: tauri::AppHandle<R>,
_account_id: String,
) {
}
}
/// Format a list of normalized messages into a transcript string suitable
/// for `memory_doc_ingest.content`. Matches the iMessage scanner output
/// shape so Neocortex sees a uniform format across channels.
pub fn format_transcript(messages: &[idb::Message], participants: &idb::ParticipantMap) -> String {
let mut out = String::new();
for m in messages {
let sender = if m.from_me {
"me".to_string()
} else {
m.sender_id
.as_deref()
.and_then(|sid| participants.display_name(sid))
.unwrap_or_else(|| m.sender_id.clone().unwrap_or_else(|| "unknown".into()))
};
let text = m.text.replace('\n', " ");
let body = if text.is_empty() {
"[non-text]".to_string()
} else {
text
};
out.push_str(&format!("[{}] {}: {}\n", m.timestamp_unix, sender, body));
}
out
}
/// Group a flat list of messages into `(thread_id, YYYY-MM-DD) -> Vec<Message>`.
/// Day bucketing uses the local timezone — users inspect memory docs by
/// their calendar day, not UTC (same policy as iMessage #724 after the
/// CodeRabbit local-TZ fix).
pub fn group_by_thread_day(
messages: Vec<idb::Message>,
) -> Vec<((String, String), Vec<idb::Message>)> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<(String, String), Vec<idb::Message>> = BTreeMap::new();
for m in messages {
let Some(thread_id) = m.thread_id.clone() else {
continue;
};
let day = seconds_to_ymd(m.timestamp_unix);
groups.entry((thread_id, day)).or_default().push(m);
}
groups.into_iter().collect()
}
/// Local-timezone day bucket for a unix-second timestamp. Returns
/// "YYYY-MM-DD" or "unknown" for values that fall outside chrono's range.
pub fn seconds_to_ymd(secs: i64) -> String {
use chrono::{Local, TimeZone};
Local
.timestamp_opt(secs, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "unknown".into())
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(id: &str, thread: &str, ts: i64, text: &str, from_me: bool) -> idb::Message {
idb::Message {
id: id.into(),
thread_id: Some(thread.into()),
sender_id: if from_me {
None
} else {
Some("+15551234567".into())
},
from_me,
text: text.into(),
timestamp_unix: ts,
message_type: Some("sms".into()),
}
}
#[test]
fn group_by_thread_day_buckets_messages_correctly() {
// Two messages ~5s apart in the same thread should fall into one
// group; a third in a different thread into its own group.
let base = 1_700_000_000;
let msgs = vec![
msg("1", "t1", base, "hi", false),
msg("2", "t1", base + 5, "yo", true),
msg("3", "t2", base, "other", false),
];
let groups = group_by_thread_day(msgs);
assert_eq!(groups.len(), 2);
let t1 = groups.iter().find(|((t, _), _)| t == "t1").unwrap();
assert_eq!(t1.1.len(), 2);
}
#[test]
fn format_transcript_includes_sender_and_body() {
let msgs = vec![
msg("1", "t1", 1_700_000_000, "hi", false),
msg("2", "t1", 1_700_000_005, "yo", true),
];
let participants = idb::ParticipantMap::default();
let t = format_transcript(&msgs, &participants);
assert!(t.contains("hi"));
assert!(t.contains("me: yo"));
assert!(t.contains("+15551234567: hi"));
}
#[test]
fn format_transcript_resolves_display_name_from_participants() {
let mut participants = idb::ParticipantMap::default();
participants.insert("+15551234567".into(), "Alice".into());
let msgs = vec![msg("1", "t1", 1_700_000_000, "hi", false)];
let t = format_transcript(&msgs, &participants);
assert!(t.contains("Alice: hi"), "got {:?}", t);
}
#[test]
fn format_transcript_marks_empty_body_as_non_text() {
let msgs = vec![msg("1", "t1", 1_700_000_000, "", false)];
let t = format_transcript(&msgs, &idb::ParticipantMap::default());
assert!(t.contains("[non-text]"), "got {:?}", t);
}
#[test]
fn seconds_to_ymd_shape() {
let out = seconds_to_ymd(1_700_000_000);
assert_eq!(out.len(), 10);
assert_eq!(&out[4..5], "-");
assert_eq!(&out[7..8], "-");
}
}
+6
View File
@@ -9,6 +9,8 @@ mod core_update;
mod discord_scanner;
mod gmail;
#[cfg(feature = "cef")]
mod gmessages_scanner;
#[cfg(feature = "cef")]
mod imessage_scanner;
mod notification_settings;
#[cfg(feature = "cef")]
@@ -626,6 +628,10 @@ pub fn run() {
.manage(notification_settings::NotificationSettingsState::new());
#[cfg(feature = "cef")]
let builder = builder.manage(std::sync::Arc::new(imessage_scanner::ScannerRegistry::new()));
#[cfg(feature = "cef")]
let builder = builder.manage(std::sync::Arc::new(
gmessages_scanner::ScannerRegistry::new(),
));
let builder = builder.manage(whatsapp_scanner::ScannerRegistry::new());
#[cfg(feature = "cef")]
let builder = builder.manage(std::sync::Arc::new(slack_scanner::ScannerRegistry::new()));