fix(channels): fix WhatsApp structured ingest pipeline + Memory page sync status (#1326)

This commit is contained in:
Mega Mind
2026-05-07 12:25:26 -07:00
committed by GitHub
parent 318950187e
commit 37cd17889c
7 changed files with 505 additions and 48 deletions
+92 -46
View File
@@ -490,11 +490,11 @@ impl CdpConn {
fn emit_snapshot<R: Runtime>(app: &AppHandle<R>, account_id: &str, snap: &ScanSnapshot) {
if !snap.ok {
log::warn!(
"[wa][{}] snapshot not ok, error={:?}",
"[wa][{}] snapshot not ok (idb walk failed: {:?}) — falling through with dom-only data",
account_id,
snap.error
);
return;
// Fall through so DOM messages still reach the structured store.
}
// Resolve the active chat's JID from its display name (parsed from the
// conversation header). Modern WhatsApp Web doesn't put the chat JID
@@ -1055,15 +1055,8 @@ async fn post_whatsapp_data_ingest(
if messages.is_empty() && chats.as_object().map(|o| o.is_empty()).unwrap_or(true) {
return Ok(());
}
log::debug!(
"[wa][{}] whatsapp_data_ingest chats={} messages={} source={}",
account_id,
chats.as_object().map(|o| o.len()).unwrap_or(0),
messages.len(),
source
);
// Convert chats map values to {name: string|null}.
// Convert chats map values to {name: string|null} once, before batching.
// The scanner passes chats as either:
// - Value::String(display_name) — contact-cache format
// - Value::Object({name: ..., ...}) — full IDB scan format
@@ -1073,14 +1066,12 @@ async fn post_whatsapp_data_ingest(
o.iter()
.map(|(jid, v)| {
let name = if let Some(s) = v.as_str() {
// String-valued entry from contact cache: value IS the name.
if s.is_empty() {
Value::Null
} else {
Value::String(s.to_string())
}
} else {
// Object entry: look for a "name" field.
v.get("name")
.and_then(|n| n.as_str())
.filter(|s| !s.is_empty())
@@ -1093,44 +1084,99 @@ async fn post_whatsapp_data_ingest(
})
.unwrap_or_default();
let params = json!({
"account_id": account_id,
"chats": Value::Object(chats_param),
"messages": messages,
});
let body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "openhuman.whatsapp_data_ingest",
"params": params,
});
// Split messages into chunks to stay well under the HTTP body size limit.
// Chats are sent only with the first batch (upserts are idempotent).
const BATCH_SIZE: usize = 500;
let empty_chats = Value::Object(serde_json::Map::new());
let url = crate::core_rpc::core_rpc_url_value();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("http client: {e}"))?;
let req = crate::core_rpc::apply_auth(client.post(&url))
.map_err(|e| format!("prepare {url}: {e}"))?;
let resp = req
.json(&body)
.send()
.await
.map_err(|e| format!("POST {url}: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
return Err(format!("{status}: {body_text}"));
}
let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
if let Some(err) = v.get("error") {
return Err(format!("rpc error: {err}"));
}
// Build at least one batch even when messages is empty (chats-only upsert).
let chunks: Vec<&[Value]> = if messages.is_empty() {
vec![&[]]
} else {
messages.chunks(BATCH_SIZE).collect()
};
let total_batches = chunks.len();
log::debug!(
"[wa][{}] whatsapp_data_ingest ok account={} messages={}",
"[wa][{}] whatsapp_data_ingest chats={} messages={} batches={} source={}",
account_id,
chats_param.len(),
messages.len(),
total_batches,
source
);
for (batch_idx, chunk) in chunks.iter().enumerate() {
let batch_chats = if batch_idx == 0 {
Value::Object(chats_param.clone())
} else {
empty_chats.clone()
};
let params = json!({
"account_id": account_id,
"chats": batch_chats,
"messages": chunk,
});
let body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "openhuman.whatsapp_data_ingest",
"params": params,
});
let mut last_err = String::new();
let mut succeeded = false;
for attempt in 1u8..=2 {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("http client: {e}"))?;
let req = crate::core_rpc::apply_auth(client.post(&url))
.map_err(|e| format!("prepare {url}: {e}"))?;
let send_result = req.json(&body).send().await;
match send_result {
Err(e) if e.is_connect() || e.is_timeout() => {
last_err = format!("POST {url}: {e}");
if attempt < 2 {
log::debug!(
"[wa][{}] whatsapp_data_ingest connect error batch={}/{} attempt {}: {}",
account_id,
batch_idx + 1,
total_batches,
attempt,
last_err
);
tokio::time::sleep(Duration::from_millis(500)).await;
}
continue;
}
Err(e) => return Err(format!("POST {url}: {e}")),
Ok(resp) => {
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
return Err(format!("{status}: {body_text}"));
}
let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
if let Some(err) = v.get("error") {
return Err(format!("rpc error: {err}"));
}
succeeded = true;
break;
}
}
}
if !succeeded {
return Err(last_err);
}
}
log::debug!(
"[wa][{}] whatsapp_data_ingest ok messages={} batches={}",
account_id,
messages.len()
messages.len(),
total_batches,
);
Ok(())
}
@@ -39,6 +39,7 @@ import {
} from '../../utils/tauriCommands';
import { MemoryGraph } from './MemoryGraph';
import { MemorySources } from './MemorySources';
import { WhatsAppMemorySection } from './WhatsAppMemorySection';
interface MemoryWorkspaceProps {
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
@@ -241,6 +242,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
return (
<div className="space-y-4" data-testid="memory-workspace">
<MemorySources syncableToolkits={SYNCABLE_TOOLKITS} pollIntervalMs={5000} onToast={onToast} />
<WhatsAppMemorySection />
<div
className="flex flex-wrap items-center justify-between gap-3"
@@ -0,0 +1,150 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WhatsAppMemorySection } from './WhatsAppMemorySection';
const mockWhatsappListChats = vi.fn();
vi.mock('../../utils/tauriCommands/memory', () => ({
whatsappListChats: (...args: unknown[]) => mockWhatsappListChats(...args),
}));
function makeChat(overrides: Record<string, unknown> = {}) {
return {
chat_id: 'chat-1',
display_name: 'Test Chat',
is_group: false,
account_id: 'acc-1',
last_message_ts: 1_700_000_000,
message_count: 5,
updated_at: 1_700_000_000,
...overrides,
};
}
describe('<WhatsAppMemorySection />', () => {
beforeEach(() => {
mockWhatsappListChats.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders nothing when load returns an empty array', async () => {
mockWhatsappListChats.mockResolvedValueOnce([]);
const { container } = render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => expect(mockWhatsappListChats).toHaveBeenCalled());
expect(container.firstChild).toBeNull();
});
it('stays hidden when load throws (error is swallowed silently)', async () => {
mockWhatsappListChats.mockRejectedValueOnce(new Error('scanner not ready'));
const { container } = render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => expect(mockWhatsappListChats).toHaveBeenCalled());
expect(container.firstChild).toBeNull();
});
it('calls whatsappListChats with limit:200', async () => {
mockWhatsappListChats.mockResolvedValueOnce([]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => expect(mockWhatsappListChats).toHaveBeenCalledWith({ limit: 200 }));
});
it('renders section and plural "chats" when multiple chats load', async () => {
mockWhatsappListChats.mockResolvedValueOnce([
makeChat({ chat_id: 'c1' }),
makeChat({ chat_id: 'c2' }),
]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => screen.getByTestId('whatsapp-memory-section'));
expect(screen.getByText(/2 chats synced/)).toBeTruthy();
});
it('renders singular "chat" for exactly 1 chat', async () => {
mockWhatsappListChats.mockResolvedValueOnce([makeChat()]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => screen.getByTestId('whatsapp-memory-section'));
const el = screen.getByText(/chat synced/);
expect(el.textContent).toMatch(/^1 chat synced/);
});
it('shows "just now" when delta < 60s', async () => {
const updatedAt = 1_700_000_000;
vi.spyOn(Date, 'now').mockReturnValue((updatedAt + 30) * 1000);
mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: updatedAt })]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => screen.getByTestId('whatsapp-memory-section'));
expect(screen.getByText(/just now/)).toBeTruthy();
});
it('shows "Xm ago" for 60-3599s delta', async () => {
const updatedAt = 1_700_000_000;
vi.spyOn(Date, 'now').mockReturnValue((updatedAt + 120) * 1000);
mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: updatedAt })]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => screen.getByTestId('whatsapp-memory-section'));
expect(screen.getByText(/2m ago/)).toBeTruthy();
});
it('shows "Xh ago" for 3600-86399s delta', async () => {
const updatedAt = 1_700_000_000;
vi.spyOn(Date, 'now').mockReturnValue((updatedAt + 7200) * 1000);
mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: updatedAt })]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => screen.getByTestId('whatsapp-memory-section'));
expect(screen.getByText(/2h ago/)).toBeTruthy();
});
it('shows "Xd ago" for delta >= 86400s', async () => {
const updatedAt = 1_700_000_000;
vi.spyOn(Date, 'now').mockReturnValue((updatedAt + 86400 * 3) * 1000);
mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: updatedAt })]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => screen.getByTestId('whatsapp-memory-section'));
expect(screen.getByText(/3d ago/)).toBeTruthy();
});
it('omits timestamp when all chats have updated_at = 0', async () => {
mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: 0 })]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => screen.getByTestId('whatsapp-memory-section'));
const el = screen.getByText(/chat synced/);
expect(el.textContent).not.toMatch(/ago|just now/);
});
it('handleSync: clicking Sync reloads data and updates count', async () => {
mockWhatsappListChats
.mockResolvedValueOnce([makeChat()])
.mockResolvedValueOnce([makeChat({ chat_id: 'c1' }), makeChat({ chat_id: 'c2' })]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => screen.getByTestId('whatsapp-memory-section'));
fireEvent.click(screen.getByRole('button'));
await waitFor(() => expect(mockWhatsappListChats).toHaveBeenCalledTimes(2));
await waitFor(() => screen.getByText(/2 chats synced/));
});
it('handleSync: button shows "Syncing…" and is disabled while load is in flight', async () => {
mockWhatsappListChats.mockResolvedValueOnce([makeChat()]);
render(<WhatsAppMemorySection pollIntervalMs={0} />);
await waitFor(() => screen.getByTestId('whatsapp-memory-section'));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let resolveSync!: (v: any) => void;
mockWhatsappListChats.mockReturnValueOnce(
new Promise(r => {
resolveSync = r;
})
);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => screen.getByText('Syncing…'));
expect(screen.getByRole('button')).toBeDisabled();
await act(async () => {
resolveSync([makeChat()]);
});
await waitFor(() => screen.getByText('Sync'));
});
});
@@ -0,0 +1,110 @@
import { useCallback, useEffect, useState } from 'react';
import { whatsappListChats } from '../../utils/tauriCommands/memory';
interface WhatsAppMemorySectionProps {
pollIntervalMs?: number;
}
export function WhatsAppMemorySection({ pollIntervalMs = 30000 }: WhatsAppMemorySectionProps) {
const [chatCount, setChatCount] = useState<number | null>(null);
const [lastSyncTs, setLastSyncTs] = useState<number | null>(null);
const [syncing, setSyncing] = useState(false);
const load = useCallback(async () => {
try {
const chats = await whatsappListChats({ limit: 200 });
setChatCount(chats.length);
const latest = chats.reduce((max, c) => Math.max(max, c.updated_at), 0);
setLastSyncTs(latest > 0 ? latest : null);
} catch {
// Scanner may not have data yet — stay hidden.
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!pollIntervalMs) return undefined;
const id = setInterval(() => void load(), pollIntervalMs);
return () => clearInterval(id);
}, [pollIntervalMs, load]);
const handleSync = useCallback(async () => {
setSyncing(true);
try {
await load();
} finally {
setSyncing(false);
}
}, [load]);
if (chatCount === null || chatCount === 0) return null;
return (
<div
className="rounded-xl border border-stone-100 bg-white px-4 py-3 shadow-sm"
data-testid="whatsapp-memory-section">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<WhatsAppIcon />
<span className="text-sm font-semibold text-stone-800">WhatsApp</span>
<span className="text-xs text-stone-500">
{chatCount.toLocaleString()} chat{chatCount !== 1 ? 's' : ''} synced
{lastSyncTs !== null && <> · {relativeTime(lastSyncTs)}</>}
</span>
</div>
<button
type="button"
onClick={() => void handleSync()}
disabled={syncing}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-stone-200
bg-white px-3 py-1.5 text-xs font-semibold text-stone-700 shadow-sm
transition-colors hover:bg-stone-50
disabled:cursor-not-allowed disabled:opacity-50
focus:outline-none focus:ring-2 focus:ring-primary-200">
<RefreshIcon spinning={syncing} />
{syncing ? 'Syncing…' : 'Sync'}
</button>
</div>
</div>
);
}
function relativeTime(secs: number): string {
const delta = Date.now() / 1000 - secs;
if (delta < 60) return 'just now';
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
return `${Math.floor(delta / 86400)}d ago`;
}
function WhatsAppIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="#25D366" aria-hidden="true">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
</svg>
);
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={spinning ? 'animate-spin' : ''}
aria-hidden="true">
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
);
}
+91 -1
View File
@@ -5,7 +5,14 @@ import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
import { isTauri } from './common';
import { aiListMemoryFiles, memoryLearnAll, memorySyncAll, memorySyncChannel } from './memory';
import {
aiListMemoryFiles,
memoryLearnAll,
memorySyncAll,
memorySyncChannel,
whatsappListChats,
whatsappListMessages,
} from './memory';
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('./common', () => ({ isTauri: vi.fn(() => true) }));
@@ -150,3 +157,86 @@ describe('aiListMemoryFiles', () => {
await expect(aiListMemoryFiles()).rejects.toThrow(/Not running in Tauri/);
});
});
describe('whatsappListChats', () => {
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(whatsappListChats()).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('calls correct RPC method with provided params', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
await whatsappListChats({ limit: 10 });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.whatsapp_data_list_chats',
params: { limit: 10 },
});
});
test('uses empty params object when none provided', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
await whatsappListChats();
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.whatsapp_data_list_chats',
params: {},
});
});
test('returns array directly when response is already an array', async () => {
const chats = [{ chat_id: 'c1', display_name: 'Direct' }];
mockCallCoreRpc.mockResolvedValueOnce(chats);
const result = await whatsappListChats();
expect(result).toBe(chats);
});
test('extracts result field from wrapped response envelope', async () => {
const chats = [{ chat_id: 'c2', display_name: 'Wrapped' }];
mockCallCoreRpc.mockResolvedValueOnce({ result: chats, logs: [] });
const result = await whatsappListChats();
expect(result).toEqual(chats);
});
test('returns empty array when wrapped response has no result field', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ logs: [] });
const result = await whatsappListChats();
expect(result).toEqual([]);
});
});
describe('whatsappListMessages', () => {
test('throws when not in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(whatsappListMessages({ chat_id: 'c1' })).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
test('calls correct RPC method with required chat_id param', async () => {
mockCallCoreRpc.mockResolvedValueOnce([]);
await whatsappListMessages({ chat_id: 'c1', limit: 50 });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.whatsapp_data_list_messages',
params: { chat_id: 'c1', limit: 50 },
});
});
test('returns array directly when response is already an array', async () => {
const msgs = [{ message_id: 'm1', body: 'hello' }];
mockCallCoreRpc.mockResolvedValueOnce(msgs);
const result = await whatsappListMessages({ chat_id: 'c1' });
expect(result).toBe(msgs);
});
test('extracts result field from wrapped response envelope', async () => {
const msgs = [{ message_id: 'm2', body: 'world' }];
mockCallCoreRpc.mockResolvedValueOnce({ result: msgs, logs: [] });
const result = await whatsappListMessages({ chat_id: 'c1' });
expect(result).toEqual(msgs);
});
test('returns empty array when wrapped response has no result field', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ logs: [] });
const result = await whatsappListMessages({ chat_id: 'c1' });
expect(result).toEqual([]);
});
});
+59
View File
@@ -351,3 +351,62 @@ export async function memoryLearnAll(namespaces?: string[]): Promise<MemoryLearn
console.debug('[memory.learn] memoryLearnAll: exit processed=%d', resp?.namespaces_processed);
return resp;
}
/** A WhatsApp chat record from the local whatsapp_data store. */
export interface WhatsAppChat {
chat_id: string;
display_name: string;
is_group: boolean;
account_id: string;
last_message_ts: number;
message_count: number;
updated_at: number;
}
/** A WhatsApp message record from the local whatsapp_data store. */
export interface WhatsAppMessage {
message_id: string;
chat_id: string;
sender: string;
sender_jid?: string;
from_me: boolean;
body: string;
timestamp: number;
message_type?: string;
account_id: string;
source: string;
}
/** List WhatsApp chats from the local store (scanner-populated). */
export async function whatsappListChats(params?: {
account_id?: string;
limit?: number;
offset?: number;
}): Promise<WhatsAppChat[]> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
const resp = await callCoreRpc<{ result?: WhatsAppChat[]; logs?: string[] } | WhatsAppChat[]>({
method: 'openhuman.whatsapp_data_list_chats',
params: params ?? {},
});
if (Array.isArray(resp)) return resp;
return (resp as { result?: WhatsAppChat[] }).result ?? [];
}
/** List messages for a chat from the local store. */
export async function whatsappListMessages(params: {
chat_id: string;
account_id?: string;
limit?: number;
offset?: number;
}): Promise<WhatsAppMessage[]> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
const resp = await callCoreRpc<
{ result?: WhatsAppMessage[]; logs?: string[] } | WhatsAppMessage[]
>({ method: 'openhuman.whatsapp_data_list_messages', params });
if (Array.isArray(resp)) return resp;
return (resp as { result?: WhatsAppMessage[] }).result ?? [];
}