Skip to content

feat(sync): add Composio whatsapp memory-sync pipeline - #108

Closed
CodeGhost21 wants to merge 1 commit into
tinyhumansai:mainfrom
CodeGhost21:feat/composio-whatsapp-sync
Closed

feat(sync): add Composio whatsapp memory-sync pipeline#108
CodeGhost21 wants to merge 1 commit into
tinyhumansai:mainfrom
CodeGhost21:feat/composio-whatsapp-sync

Conversation

@CodeGhost21

@CodeGhost21 CodeGhost21 commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds WhatsappSyncPipeline, a Composio memory-sync pipeline for the whatsapp toolkit, closing the tinycortex-side gap where connecting WhatsApp reports ACTIVE but sync fails with tinycortex sync does not support toolkit 'whatsapp'.

  • Shape: message-shaped and flat, modeled on GmailSyncPipeline (src/memory/sync/composio/gmail.rs) — one fetch action, cursor-paged, incremental by message timestamp. WhatsApp's fetch action returns messages across chats in a single stream, so no per-container scope pass (unlike Slack) is needed.
  • Contract: implements both SyncPipeline and IncrementalSource; toolkit()"whatsapp", action()WHATSAPP_FETCH_MESSAGES. Uses the shared providers/common.rs document() helper like the other providers.
  • Stable dedupe: keyed on the provider's stable per-message id (WhatsApp wamid) → document_id = "whatsapp:<message_id>", metadata.taint = "external_sync". Re-syncing upserts rather than duplicates; per-run request ids are never used as the key (the defect class fixed in fix(memory): use stable document_id as sync upsert key (fixes #4947 Bug 2 secret-guard sync failure) openhuman#4953).
  • Logging: debug logging on the new flow is content-free — message counts and toolkit slug only, never bodies or phone numbers.
  • Registration: wired through providers/mod.rs, composio/mod.rs, and memory/sync/mod.rs exactly like the existing six pipelines.

Reference followed

GmailSyncPipeline for the overall shape; LinearSyncPipeline for the providers/common.rs helper usage and module placement.

Tests

Unit tests in whatsapp.rs cover: toolkit()/action()/id(), extract_page() over a sample Composio JSON payload (message array + pagination cursor + empty-envelope fallback), a stable document_id from document(), and cursor normalization (millis/seconds/RFC3339). Run:

  • cargo test --features sync — green (1271 lib tests + integration suites).
  • cargo fmt --all --check — clean.

Scope

Part of #80 (tinycortex pipeline body; openhuman wiring is the follow-up step). The openhuman-side changes — bumping the vendor/tinycortex submodule pointer, adding the sync.rs:344 selector arm, and registering the ComposioProvider so memory_sources.supported_toolkits advertises the slug — are a separate follow-up and are not included here.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added WhatsApp synchronization through Composio.
    • Supports incremental message retrieval with pagination.
    • Stores synchronized messages with stable identifiers and avoids duplicates.
    • Handles multiple timestamp and pagination formats for reliable updates.

Add `WhatsappSyncPipeline`, a message-shaped Composio sync pipeline for the
`whatsapp` toolkit, modeled on `GmailSyncPipeline` (flat, cursor-paged,
incremental by timestamp). It implements both `SyncPipeline` and
`IncrementalSource`, fetching via the `WHATSAPP_FETCH_MESSAGES` action and
mapping each message to a memory document.

Dedupe uses the provider's stable per-message id: `document_id =
"whatsapp:<message_id>"` with `metadata.taint = "external_sync"`, so re-syncing
upserts rather than duplicates (no per-run request ids as keys). Debug logging
is content-free (counts and toolkit only — never bodies or phone numbers).

Registered through `providers/mod.rs`, `composio/mod.rs`, and
`memory/sync/mod.rs` alongside the existing six pipelines.

Tests: toolkit()/action()/id(), extract_page() over a sample Composio payload
(items + pagination cursor), stable document_id from document(), and cursor
normalization. `cargo test --features sync` green (1271 lib tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an incremental Composio WhatsApp synchronization pipeline with cursor pagination, timestamp normalization, stable message IDs, document mapping, tests, and public re-exports.

Changes

WhatsApp synchronization

Layer / File(s) Summary
WhatsApp pipeline implementation
src/memory/sync/composio/providers/whatsapp.rs
Defines WhatsappSyncPipeline, fetches and paginates WhatsApp messages, normalizes cursors, deduplicates by message ID, maps messages to SkillDocument, and tests the behavior.
Public pipeline registration
src/memory/sync/composio/providers/mod.rs, src/memory/sync/composio/mod.rs, src/memory/sync/mod.rs
Declares the WhatsApp provider module and re-exports WhatsappSyncPipeline through the synchronization APIs.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant SyncPipeline
  participant WhatsappSyncPipeline
  participant ComposioClient
  participant SkillDocument
  SyncPipeline->>WhatsappSyncPipeline: tick()
  WhatsappSyncPipeline->>ComposioClient: fetch messages using cursor or after
  ComposioClient-->>WhatsappSyncPipeline: message page and next cursor
  WhatsappSyncPipeline->>SkillDocument: create stable documents from wamid
Loading

Suggested reviewers: senamakel

Poem

A bunny hops where WhatsApp streams,
Cursors chase the message dreams.
Each wamid marks a memory bright,
Pages turn from left to right.
The sync pipeline now takes flight! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a Composio WhatsApp memory-sync pipeline.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/memory/sync/composio/providers/whatsapp.rs`:
- Around line 264-383: Move the inline #[cfg(test)] tests module from the
WhatsApp implementation into a sibling whatsapp_tests.rs file. Preserve all
existing test cases, helper definitions, imports, and behavior, and wire the
sibling module into the implementation using the project’s established per-file
test-module pattern.
- Around line 111-129: Update the arguments method’s persisted state.cursor
branch to avoid cursor_to_seconds(cursor).unwrap_or_default(); when parsing
fails, use the same bounded config.sync.budget.sync_depth_days fallback as the
no-cursor branch, while preserving the parsed cursor behavior and page-token
handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b610ba1b-5e84-45fe-a047-8c8ed11aed13

📥 Commits

Reviewing files that changed from the base of the PR and between 9a0603a and 054cb99.

📒 Files selected for processing (4)
  • src/memory/sync/composio/mod.rs
  • src/memory/sync/composio/providers/mod.rs
  • src/memory/sync/composio/providers/whatsapp.rs
  • src/memory/sync/mod.rs

Comment on lines +111 to +129
fn arguments(
&self,
_scope: &SyncScope,
config: &MemoryConfig,
state: &SyncState,
page: Option<&str>,
) -> Value {
let mut arguments = serde_json::json!({ "limit": self.page_size });
if let Some(token) = page {
arguments["cursor"] = serde_json::json!(token);
} else if let Some(cursor) = state.cursor.as_deref() {
arguments["after"] = serde_json::json!(cursor_to_seconds(cursor).unwrap_or_default());
} else if let Some(days) = config.sync.budget.sync_depth_days {
arguments["after"] = serde_json::json!((chrono::Utc::now()
- chrono::Duration::days(days as i64))
.timestamp());
}
arguments
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unparseable cursor silently falls back to epoch 0, not a safe default.

At Line 122, cursor_to_seconds(cursor).unwrap_or_default() yields 0i64 when the persisted state.cursor fails to parse (neither integer nor RFC3339). That sets arguments["after"] = 0, i.e. "fetch every message since 1970," which is the opposite of a safe incremental fallback — it can trigger an unbounded, expensive historical refetch against Composio/WhatsApp on the very run meant to resume incrementally. The adjacent branch (no cursor at all) correctly falls back to config.sync.budget.sync_depth_days; an unparseable cursor should use the same bounded fallback rather than silently defaulting to epoch 0.

🐛 Proposed fix: reuse the depth-days fallback instead of defaulting to epoch 0
-        if let Some(token) = page {
-            arguments["cursor"] = serde_json::json!(token);
-        } else if let Some(cursor) = state.cursor.as_deref() {
-            arguments["after"] = serde_json::json!(cursor_to_seconds(cursor).unwrap_or_default());
-        } else if let Some(days) = config.sync.budget.sync_depth_days {
-            arguments["after"] = serde_json::json!((chrono::Utc::now()
-                - chrono::Duration::days(days as i64))
-            .timestamp());
-        }
+        let depth_fallback = || {
+            config.sync.budget.sync_depth_days.map(|days| {
+                (chrono::Utc::now() - chrono::Duration::days(days as i64)).timestamp()
+            })
+        };
+        if let Some(token) = page {
+            arguments["cursor"] = serde_json::json!(token);
+        } else if let Some(seconds) = state
+            .cursor
+            .as_deref()
+            .and_then(cursor_to_seconds)
+            .or_else(depth_fallback)
+        {
+            arguments["after"] = serde_json::json!(seconds);
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn arguments(
&self,
_scope: &SyncScope,
config: &MemoryConfig,
state: &SyncState,
page: Option<&str>,
) -> Value {
let mut arguments = serde_json::json!({ "limit": self.page_size });
if let Some(token) = page {
arguments["cursor"] = serde_json::json!(token);
} else if let Some(cursor) = state.cursor.as_deref() {
arguments["after"] = serde_json::json!(cursor_to_seconds(cursor).unwrap_or_default());
} else if let Some(days) = config.sync.budget.sync_depth_days {
arguments["after"] = serde_json::json!((chrono::Utc::now()
- chrono::Duration::days(days as i64))
.timestamp());
}
arguments
}
fn arguments(
&self,
_scope: &SyncScope,
config: &MemoryConfig,
state: &SyncState,
page: Option<&str>,
) -> Value {
let mut arguments = serde_json::json!({ "limit": self.page_size });
let depth_fallback = || {
config.sync.budget.sync_depth_days.map(|days| {
(chrono::Utc::now() - chrono::Duration::days(days as i64)).timestamp()
})
};
if let Some(token) = page {
arguments["cursor"] = serde_json::json!(token);
} else if let Some(seconds) = state
.cursor
.as_deref()
.and_then(cursor_to_seconds)
.or_else(depth_fallback)
{
arguments["after"] = serde_json::json!(seconds);
}
arguments
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/sync/composio/providers/whatsapp.rs` around lines 111 - 129,
Update the arguments method’s persisted state.cursor branch to avoid
cursor_to_seconds(cursor).unwrap_or_default(); when parsing fails, use the same
bounded config.sync.budget.sync_depth_days fallback as the no-cursor branch,
while preserving the parsed cursor behavior and page-token handling.

Comment on lines +264 to +383
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::config::{ComposioMode, ComposioSyncConfig};

fn pipeline() -> WhatsappSyncPipeline {
let config = ComposioSyncConfig {
mode: ComposioMode::Direct,
base_url: "https://backend.composio.dev".into(),
api_key: None,
bearer_token: None,
entity_id: None,
};
WhatsappSyncPipeline::new(ComposioClient::new(config), "conn-1")
}

struct NoopExecutor;

#[async_trait]
impl ActionExecutor for NoopExecutor {
async fn execute(
&self,
_action: &str,
_arguments: Value,
_connection_id: Option<&str>,
) -> anyhow::Result<crate::memory::sync::composio::ExecuteResponse> {
anyhow::bail!("no execution expected in this test")
}
}

#[test]
fn advertises_toolkit_and_fetch_action() {
let pipeline = pipeline();
assert_eq!(pipeline.toolkit(), "whatsapp");
assert_eq!(pipeline.action(), "WHATSAPP_FETCH_MESSAGES");
assert_eq!(pipeline.id(), "composio:whatsapp");
}

#[test]
fn extract_page_reads_messages_and_cursor() {
let pipeline = pipeline();
let payload = serde_json::json!({
"data": {
"messages": [
{"id": "wamid.AAA", "timestamp": "1721000000", "from": "chat-1"},
{"id": "wamid.BBB", "timestamp": "1721000100", "from": "chat-1"}
],
"paging": { "cursors": { "after": "CURSOR-2" } }
}
});

let fetched = pipeline.extract_page(&payload, None);

assert_eq!(fetched.items.len(), 2);
assert_eq!(fetched.items[0]["id"], "wamid.AAA");
assert_eq!(fetched.next.as_deref(), Some("CURSOR-2"));
assert_eq!(
pipeline.dedup_key(&fetched.items[0]).as_deref(),
Some("wamid.AAA")
);
assert_eq!(
pipeline.sort_cursor(&fetched.items[1]).as_deref(),
Some("1721000100")
);
}

#[test]
fn extract_page_defaults_when_envelope_is_empty() {
let pipeline = pipeline();
let fetched = pipeline.extract_page(&serde_json::json!({}), None);
assert!(fetched.items.is_empty());
assert!(fetched.next.is_none());
}

#[tokio::test]
async fn document_uses_stable_message_id_as_key() {
let pipeline = pipeline();
let raw = serde_json::json!({
"id": "wamid.AAA",
"timestamp": "1721000000",
"from": "chat-1",
"text": {"body": "hello"}
});
let mut state = SyncState::new("whatsapp", "conn-1");
let item = SyncItem {
dedup_key: "wamid.AAA".into(),
sort_cursor: Some("1721000000".into()),
raw: raw.clone(),
};

let doc = pipeline
.document(
&SyncScope::flat(),
"conn-1",
item,
&NoopExecutor,
&mut state,
)
.await
.unwrap();

// Stable key: identical across runs, so upserts dedupe.
assert_eq!(doc.document_id, "whatsapp:wamid.AAA");
assert_eq!(doc.toolkit, "whatsapp");
assert_eq!(doc.namespace_skill_id, "whatsapp");
assert_eq!(doc.metadata["taint"], "external_sync");
assert_eq!(doc.title, "WhatsApp chat chat-1");
}

#[test]
fn cursor_to_seconds_handles_millis_seconds_and_rfc3339() {
assert_eq!(cursor_to_seconds("1721000000000"), Some(1721000000));
assert_eq!(cursor_to_seconds("1721000000"), Some(1721000000));
let expected = chrono::DateTime::parse_from_rfc3339("2024-07-15T00:00:00Z")
.unwrap()
.timestamp();
assert_eq!(cursor_to_seconds("2024-07-15T00:00:00Z"), Some(expected));
assert_eq!(cursor_to_seconds("not-a-timestamp"), None);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Tests should live in a sibling whatsapp_tests.rs, not inline.

The #[cfg(test)] mod tests block is embedded directly in the implementation file. As per path instructions, src/**/*.rs should "Keep tests in per-file <name>_tests.rs siblings, such as store.rs and store_tests.rs, rather than mixing tests into implementation files."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/sync/composio/providers/whatsapp.rs` around lines 264 - 383, Move
the inline #[cfg(test)] tests module from the WhatsApp implementation into a
sibling whatsapp_tests.rs file. Preserve all existing test cases, helper
definitions, imports, and behavior, and wire the sibling module into the
implementation using the project’s established per-file test-module pattern.

Source: Path instructions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant