mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
fix(channels): fix WhatsApp structured ingest pipeline + Memory page sync status (#1326)
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
Vendored
+1
-1
Submodule app/src-tauri/vendor/tauri-cef updated: f1ee9554ff...c10bc0f729
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user